agentleFS
LedgerHQLedgerHQ/ledger-live/.agents/skills/package-public-api/SKILL.md

package-public-api

Skill627 starsChanged 8 days agoMITView on GitHub

A new-architecture package (shared/, domain/, features/) exposes its API through barrels that contain nothing but `export *`, and keeps its private code in an internals location. Read when creating a package under shared/, domain/ or features/, when editing any `index.*` file, or when a `lint:structure` check fails.

The file

We split this skill into the rules it gives an agent

4 rules. About 0 are common, 3 are distinctive, and 1 is very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈3

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 1

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Why the sorting matters: If you need to pick which names leave a file, that file holds both public and private code.

    line 27
  2. Very specific:

    No: delete the barrel and import the files directly, or move the folder under internals/.

    line 79
  3. Distinctive:

    Import paths for consumers do not change.

    line 150
  4. Distinctive:

    Start conformant, it costs nothing:

    line 156

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Barrels and internals

An index.* file is a barrel: a pure regrouping point, nothing else.

// ✅
export * from "./schema";
export type * from "./types";
export { default } from "./useEnv"; // the one tolerated exception

// ❌  sorting in the export proves ./slice mixes public and private
export { accountNamesSlice, bulkSetAccountNames as setAccountNames } from "./slice";

// ❌  an index that contains code is not an index
export const CurrencySchema = z.discriminatedUnion("type", [...]);

Why the sorting matters. If you need to pick which names leave a file, that file holds both public and private code. The fix is never a longer export list — it is moving the private part to an internals location. export * then becomes honest: everything in that file is public.

Enforced by nx run <project>:lint:structure (the plugin), on every index.* under src/, at any depth.

The rule follows a file's role, not its name. It follows every relative export * from a barrel, through any number of files, resolved as the web and native bundlers do, and it reads every source file named in package.json#exports, under any condition. Test support entries (a subpath segment starting with test or mock) are left out. Such a module may declare anything, but it re-exports like a barrel: export * from a relative file and an optional default re-export only. So export * from "./exports" in the index with the sorting or the proxy in exports.ts fails the same way (hidden-barrel, cross-package-reexport, …).

Where a barrel may sit

Under src/, an index.* (platform variants included) is allowed in three places:

  1. An entry: src/index.*, or the target of a package.json#exports entry. Test support entries count.
  2. Below an entry, through export *. One level below an entry is always fine. Deeper, the barrel is a nested barrel and nested-barrel reports it, unless the chain is pure: every barrel on the way holds only export * from "./x" inside its own folder (plain modules on the way do not count). The wording is BARREL_LOCATION and CHAIN_BREAKS in rules.js.<!-- rules.js: BARREL_LOCATION, CHAIN_BREAKS -->
  3. An internals location: internals/, as defined below.
src/
├── index.ts                        # ✅ entry: export * from "./services"; export * from "./hooks";
├── services/index.ts               # ✅ one level below: export * from "./portfolio";
├── services/portfolio/index.ts     # ✅ pure chain: entry → services → services/portfolio
├── hooks/index.ts                  # ✅ one level below, but breaks the chain:
│                                   #    export * from "./useThing"; export * from "../shared";
├── hooks/useThing/index.ts         # ❌ nested-barrel: hooks/index.ts re-exports a path outside its folder
├── internals/index.ts              # ✅ internals location
└── Row/index.ts                    # ❌ orphan-barrel: imported by view.ts, re-exported by no entry

Why. A barrel only earns its place as the public face of a package or of one part of it. An orphan is an indirection no consumer sees, and it outlives its entry: drop "./state" from exports and state/index.ts stays behind as an orphan, so the same check catches it. Depth alone hides nothing when each sub-domain has its own face and the folders show the chain. A middle barrel that filters, renames or adds code does: the name a consumer imports is no longer the name the folders lead to.

Fix an orphan (orphan-barrel) by asking whether another package uses what it groups:

  • Yes: expose it with one export * from "./dir" line in the root barrel.
  • No: delete the barrel and import the files directly, or move the folder under internals/. Expose only code another package imports.

Fix a nested barrel (nested-barrel) at the barrel the violation names, with its line and reason. In the tree above, export * from "../shared" moves from hooks/index.ts to the entry, or hooks/index.ts re-exports ./useThing/useThing and the nested barrel goes. An index.tsx that holds code is a module, not a barrel: rename it after what it declares (Card/index.tsx becomes Card/Card.tsx).

Purity of what a barrel reaches stays with the rules above. The debt found when the check landed sits in intermediate-barrels.js. It only shrinks: fixing a listed barrel means deleting its entry, and a test fails on an entry whose file is gone or allowed. Never add one. Packages with skip in exceptions.js are not checked.

Where private code goes

Three recognised forms, usable at any depth:

FormUse for
internals/a private area with several files
internals.tsone private helper in a folder
<name>.internals.tsthe private half of a file, e.g. slice.ts + slice.internals.ts

A barrel must never re-export any of them — that would make private code public and defeat the point. Everything else can: a test sitting next to slice.internals.ts imports it directly, so private code stays fully testable without being part of the package API.

Never re-export another package

// ❌  this package becomes a proxy
export * from "@features/flow-contacts-add-contact";
export { useContacts } from "@features/platform-contacts";

Two import paths for the same symbol, and a reader who cannot tell who provides it. Consumers must import the original provider, and declare the dependency themselves. This holds for @shared/*, @domain/*, @features/*, @support/* and @ledgerhq/* alike.

Facade is not proxy. Wrapping a legacy lib behind a typed API — as shared/env does over @ledgerhq/live-env — is legitimate: it owns real code and holds a declared BOUNDARY_EXCEPTIONS entry. Keeping that wrapping in a named file rather than the barrel is the cheapest way to stay conformant; shared/env instead carries a temporary skip in exceptions.js. Entries there are team decisions with an exit condition, not a way around a failing check — never add one to make your own package pass. Its FILE_EXCEPTIONS lists the reached modules that held violations when the rule started following export *. That list only shrinks: a test fails on an entry whose file is gone or clean, so fixing a file means deleting its entry.

Universal components

lint:structure also checks rules R1 to R5 on the React components a dual-platform package exports, next to the barrel rules above. The X.types.ts contract, the rules and their codes, the review-only checks and how to clear a waiver are in universal-components.

Traps

  • export * does not propagate a rename. export { bulkSetAccountNames as setAccountNames } cannot become export *. Rename at the source and update the callers, so one thing has one name.
  • export * does not propagate a default either. Only the re-export form (export { default } from "./x") is tolerated in a barrel. A local export default myThing needs an import above it, which is code in an index — extract it.
  • A package can have several barrels. With a react-native condition in exports, both index.ts and index.native.ts are barrels and both must stay pure.
  • An index.* holding code is not an index. Move the code to a named file (useEnv.ts, schema.ts, CardLogin.web.tsx) and leave export * from "./thatFile" behind. Import paths for consumers do not change.
  • A folder holding only index.ts should not be a folder. definitions/team-qaa/index.ts becomes definitions/team-qaa.ts: same import specifier, one less file, no barrel to keep pure.

New package

Start conformant, it costs nothing:

src/
├── index.ts          # export * from "./schema";
├── schema.ts
├── schema.test.ts
└── internals.ts      # whatever schema.ts should not expose

See structure-flow for where the package itself belongs.

Reviewing

Refuse and point here when you see:

  • a named re-export (export { a, b } from "./x") in any index.*, or in a file it reaches through export *, or in a file named in package.json#exports
  • an import, a declaration or a local export default in any index.*
  • export * as ns from — namespacing is still sorting
  • a barrel re-exporting internals, or re-exporting another workspace package
  • a new index.* below src/ that no entry re-exports, or that sits more than one level below an entry through a chain a barrel breaks (see "Where a barrel may sit")

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.