diff --git a/skills/in-progress/README.md b/skills/in-progress/README.md index 58b1585..83ef669 100644 --- a/skills/in-progress/README.md +++ b/skills/in-progress/README.md @@ -8,4 +8,4 @@ Skills that are still being developed. They're not ready to ship — expect roug - **[writing-fragments](./writing-fragments/SKILL.md)** — Grilling session that mines you for fragments — heterogeneous nuggets of writing — and appends them to a single document as raw material for a future article. - **[writing-shape](./writing-shape/SKILL.md)** — Take a markdown file of raw material and shape it into an article paragraph by paragraph, arguing format choices at each step. - **[claude-handoff](./claude-handoff/SKILL.md)** — Hand the current conversation off to a fresh background agent that picks up the work immediately, seeded with a handoff summary via `claude --bg`. User-invoked. -- **[setup-deep-modules](./setup-deep-modules/SKILL.md)** — Wire dependency-cruiser into a TypeScript repo so each package is a deep module — everything hidden behind its `index.ts`, tests exercising it through that interface. User-invoked. +- **[setup-ts-deep-modules](./setup-ts-deep-modules/SKILL.md)** — Wire dependency-cruiser into a TypeScript repo so each package is a deep module — implementation hidden in subfolders, reachable only through its entry-point files, tests exercising it through those. User-invoked. diff --git a/skills/in-progress/setup-deep-modules/SKILL.md b/skills/in-progress/setup-ts-deep-modules/SKILL.md similarity index 53% rename from skills/in-progress/setup-deep-modules/SKILL.md rename to skills/in-progress/setup-ts-deep-modules/SKILL.md index 7b4d9a3..07f6ada 100644 --- a/skills/in-progress/setup-deep-modules/SKILL.md +++ b/skills/in-progress/setup-ts-deep-modules/SKILL.md @@ -1,12 +1,12 @@ --- -name: setup-deep-modules -description: Wire dependency-cruiser into a TypeScript repo so each package is a deep module — everything hidden behind its index. User-invoked. +name: setup-ts-deep-modules +description: Wire dependency-cruiser into a TypeScript repo so each package is a deep module — implementation hidden in subfolders, reachable only through its entry-point files. User-invoked. disable-model-invocation: true --- -# Setup Deep Modules +# Setup TS Deep Modules -Make every package in this repo a **deep module**: a lot of behaviour behind a small interface. Each package under the packages root exposes exactly one interface — its `index.ts` — and everything else is hidden. This skill installs [dependency-cruiser](https://github.com/sverweij/dependency-cruiser) and the rules that make the index the only way in, then proves the rules bite. +Make every package in this repo a **deep module**: a lot of behaviour behind a small interface. A package's public surface is its **entry points** — the files at the package root — and everything in its subfolders is hidden. This skill installs [dependency-cruiser](https://github.com/sverweij/dependency-cruiser) and the rules that make the entry points the only way in, then proves the rules bite. For the vocabulary (deep module, interface, seam, depth), run the `/codebase-design` skill — use its language throughout. @@ -15,18 +15,23 @@ For the vocabulary (deep module, interface, seam, depth), run the `/codebase-des ``` src/packages/ / - index.ts ← the ONLY public interface. Outsiders import this and nothing else. - ← free to import each other; invisible to the outside world. - tests/ ← co-located tests + fixtures. Reach the package through index only. + index.ts ← an entry point (public). Import this from outside. + client.ts ← another entry point. Packages may expose SEVERAL. + / ← internals: hidden from outside, free to import each other. + tests/ ← co-located tests + fixtures (a subfolder, so private). ``` +The public surface is the package's **root files** — not one designated `index.ts`. Implementation lives in subfolders. + Four rules, all `error`: -1. **Index boundary** — code outside a package (app code or another package) may import only `/index.ts`, never anything deeper. -2. **Intra-package freedom** — a package's own internals import each other freely. -3. **Tests through the index** — files under `/tests/` may import any package's `index.ts` and their own `tests/` fixtures, but never any package's internals (not even their own). Integration tests across packages are fine; deep imports are not. +1. **Entry-point boundary** — code outside a package (app code or another package) may import only that package's entry points (its root files), never anything in its subfolders. +2. **Intra-package freedom** — a package's own files import each other freely. +3. **Tests through the entry points** — files under `/tests/` may import any package's entry points and their own `tests/` fixtures, but never any package's subfolder internals (not even their own). Integration tests across packages are fine; deep imports are not. 4. **No cycles** — no dependency cycles. +**Entry points, not a barrel.** Because the public surface is *every* root file, a package can expose several small entry points (`index.ts`, `client.ts`, `server.ts`) instead of funnelling everything through one giant `index.ts`. Barrel files that re-export a whole subtree are discouraged — keep entry points small and hide implementation in subfolders. + Layering (which packages may depend on which) is a *different* concern and is left as a commented stub in the config for this repo to fill in. ## Steps @@ -47,7 +52,7 @@ Install `dependency-cruiser` as a devDependency with the detected package manage ### 3. Write the config -Copy [`dependency-cruiser.config.cjs`](./dependency-cruiser.config.cjs) to the repo root as `.dependency-cruiser.cjs`. Set `PACKAGES_ROOT` to the root detected in step 1. If the repo is JavaScript rather than TypeScript, change the index pattern and extensions from `ts`/`tsx` to `js`/`jsx`. +Copy [`dependency-cruiser.config.cjs`](./dependency-cruiser.config.cjs) to the repo root as `.dependency-cruiser.cjs`. Set `PACKAGES_ROOT` to the root detected in step 1. The rules are path-depth based and extension-agnostic, so nothing else needs adapting. **Done when:** `.dependency-cruiser.cjs` exists with the correct `PACKAGES_ROOT`, and the four forbidden rules are present. @@ -63,34 +68,35 @@ Copy [`dependency-cruiser.config.cjs`](./dependency-cruiser.config.cjs) to the r Create a committed `/example/` as a copy-me template: -- `index.ts` — the interface. Export one function that delegates to an internal file (so the package is visibly *deep*, not a pass-through). -- an internal file (e.g. `impl.ts`) — imported by `index.ts`, not exported. -- `tests/example.test.ts` — imports **only** `../index`, and asserts against the public function. +- `index.ts` — an entry point. Export one function that delegates to an internal file (so the package is visibly *deep*, not a pass-through). +- `lib/impl.ts` — an internal file in a **subfolder**, imported by `index.ts`, not reachable from outside. +- `tests/example.test.ts` — imports **only** `../index` (an entry point), and asserts against the public function. Tell the user this is a starter template to copy or delete. -**Done when:** the example package exists and imports only through its own index. +**Done when:** the example package exists, exposes its behaviour through a root entry point, and hides `impl` in a subfolder. ### 6. Prove the rules bite This is the completion criterion for the whole skill — a config that doesn't fail on a violation is worthless. 1. Run `lint:boundaries`. It must **pass** on the clean example. -2. Temporarily add a deep import to `tests/example.test.ts` (e.g. `import { thing } from "../impl"`). Run `lint:boundaries` again — it must **fail** with `tests-only-through-index`. +2. Temporarily add a deep import to `tests/example.test.ts` (e.g. `import { thing } from "../lib/impl"`). Run `lint:boundaries` again — it must **fail** with `tests-through-entrypoints`. 3. Revert the deep import. Run once more — it must **pass**. **Done when:** you have observed a pass, then a fail on the deep import, then a pass again. If step 2 does not fail, the rules are not wired correctly — fix before finishing. ### 7. Document the convention -Write a `README.md` **in the packages folder** (`/README.md`) — next to the packages it governs — covering: the `src/packages//` layout, "import only through `index.ts`", where tests live, and how to run `lint:boundaries`. Keep it to the copy-me snippet plus the four rules in one paragraph each. +Write a `README.md` **in the packages folder** (`/README.md`) — next to the packages it governs — covering: the `src/packages//` layout, "import only through a package's entry points (its root files)", that implementation lives in subfolders, where tests live, and how to run `lint:boundaries`. **Discourage barrel files** explicitly — expose several small entry points instead of re-exporting a whole subtree through one index. Keep it to the copy-me snippet plus the four rules in one paragraph each. Then add a **context pointer** to it from the repo's agent-instructions file — `CLAUDE.md` if present, else `AGENTS.md` (create `AGENTS.md` if neither exists). One line is enough, e.g. `Packages are deep modules — see [src/packages/README.md](./src/packages/README.md) before adding or importing one.` This is what makes an agent discover the boundary rule instead of tripping over it. -**Done when:** `/README.md` exists, and the repo's `CLAUDE.md`/`AGENTS.md` links to it. +**Done when:** `/README.md` exists and discourages barrels, and the repo's `CLAUDE.md`/`AGENTS.md` links to it. ## Notes - The config's `$1` back-references (dependency-cruiser's group matching) are what let a package reach its own internals while outsiders can't — don't flatten them into separate per-package rules. +- Public vs private is decided by **depth**: a package's root files are entry points; anything in a subfolder is private. Adding an entry point is just adding a root file — no barrel, no config change. - Packages are **flat**: one tier of immediate children under the root. A package's internals may nest as deep as you like; a package may not contain another package. - Use `.cjs` (not `.js`) so the config's `module.exports` works even in `"type": "module"` repos. diff --git a/skills/in-progress/setup-deep-modules/dependency-cruiser.config.cjs b/skills/in-progress/setup-ts-deep-modules/dependency-cruiser.config.cjs similarity index 56% rename from skills/in-progress/setup-deep-modules/dependency-cruiser.config.cjs rename to skills/in-progress/setup-ts-deep-modules/dependency-cruiser.config.cjs index 72df09e..119fb72 100644 --- a/skills/in-progress/setup-deep-modules/dependency-cruiser.config.cjs +++ b/skills/in-progress/setup-ts-deep-modules/dependency-cruiser.config.cjs @@ -2,9 +2,10 @@ // Deep-module enforcement for dependency-cruiser. // // Each package under the packages root is a DEEP MODULE: a lot of behaviour -// behind a small interface (its `index.ts`). The rules below make the index the -// only way in — internals stay hidden, and tests exercise the package through -// the same interface everyone else does. +// behind a small interface. A package's PUBLIC SURFACE is its ENTRY POINTS — +// the files at the package root. Implementation lives in SUBFOLDERS and is +// private. A package may expose several small entry points (index.ts, +// client.ts, server.ts, …) — prefer that over one giant barrel index. // // The only thing you should ever need to edit here is PACKAGES_ROOT. @@ -13,49 +14,45 @@ const PACKAGES_ROOT = "src/packages"; // --- derived patterns (no need to edit) ------------------------------------- const R = PACKAGES_ROOT; -/** Any file that lives inside some package's internals or subfolders. */ -const INSIDE_A_PACKAGE = `^${R}/[^/]+/.+`; -/** A package's public interface — the only legal way in. */ -const ANY_INDEX = `^${R}/[^/]+/index\\.(ts|tsx)$`; +/** + * A package's private internals: anything nested inside a package subfolder. + * The package's root files are its entry points and are NOT matched here — + * they stay importable from outside. + */ +const PACKAGE_INTERNALS = `^${R}/[^/]+/[^/]+/`; /** @type {import('dependency-cruiser').IConfiguration} */ module.exports = { forbidden: [ { - name: "index-boundary-from-app", + name: "entrypoint-boundary-from-app", comment: - "App/root code may reach a package only through its index — never a deep import.", + "App/root code may import a package's entry points (its root files), but nothing inside its subfolders.", severity: "error", from: { pathNot: `^${R}/` }, // importer is NOT inside any package - to: { path: INSIDE_A_PACKAGE, pathNot: ANY_INDEX }, + to: { path: PACKAGE_INTERNALS }, }, { - name: "index-boundary-across-packages", + name: "entrypoint-boundary-across-packages", comment: - "A package's internals may import each other freely, but may reach OTHER packages only through their index.", + "A package's own files import each other freely, but may reach OTHER packages only through their entry points — never their internals.", severity: "error", // importer is inside a package ($1), but is not a test file from: { path: `^${R}/([^/]+)/`, pathNot: `^${R}/[^/]+/tests/` }, to: { - path: INSIDE_A_PACKAGE, - pathNot: [ - `^${R}/$1/`, // same package → intra-package freedom - ANY_INDEX, // any package's index → allowed - ], + path: PACKAGE_INTERNALS, + pathNot: `^${R}/$1/`, // same package → intra-package freedom }, }, { - name: "tests-only-through-index", + name: "tests-through-entrypoints", comment: - "A package's tests exercise it through the index like everyone else: they may import any package's index and their own tests/ fixtures, but never any package's internals — not even their own.", + "A package's tests exercise it through its entry points like everyone else: they may import any package's entry points and their own tests/ fixtures, but never any package's internals — not even their own.", severity: "error", from: { path: `^${R}/([^/]+)/tests/` }, // a test file, in package $1 to: { - path: INSIDE_A_PACKAGE, - pathNot: [ - `^${R}/$1/tests/`, // own tests/ fixtures → allowed - ANY_INDEX, // any package's index → allowed (integration tests are fine) - ], + path: PACKAGE_INTERNALS, + pathNot: `^${R}/$1/tests/`, // own tests/ fixtures → allowed }, }, { @@ -75,8 +72,9 @@ module.exports = { }, // --- Layering (optional, off by default) ---------------------------------- - // Interface-hiding controls HOW you import (through the index). Layering - // controls WHICH packages may depend on which. Add your own rules here, e.g.: + // Interface-hiding controls HOW you import (through the entry points). + // Layering controls WHICH packages may depend on which. Add your own rules + // here, e.g.: // // { // name: "ui-may-not-depend-on-billing",