From 30a9c7447faf38152983ed9ae20dffdfe4ba9b77 Mon Sep 17 00:00:00 2001 From: Matt Pocock Date: Fri, 10 Jul 2026 09:49:13 +0100 Subject: [PATCH 1/5] Add setup-deep-modules skill (in-progress) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user-invoked setup skill that wires dependency-cruiser into a TypeScript repo so each package under the packages root is a deep module: everything hidden behind its `index.ts` interface. Ships a `.dependency-cruiser.cjs` template with four error-level rules — index boundary from app code, index boundary across packages (with intra-package freedom via $1 back-references), tests-through-the-index, and no-circular — plus a commented layering stub. The skill installs the tool, folds a `lint:boundaries` script into the repo's umbrella check, scaffolds an example package, and self-verifies that a deep import fails. Rules validated against a live dependency-cruiser 16 run. Co-Authored-By: Claude Opus 4.8 (1M context) --- skills/in-progress/README.md | 1 + .../in-progress/setup-deep-modules/SKILL.md | 94 ++++++++++++++++++ .../dependency-cruiser.config.cjs | 95 +++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 skills/in-progress/setup-deep-modules/SKILL.md create mode 100644 skills/in-progress/setup-deep-modules/dependency-cruiser.config.cjs diff --git a/skills/in-progress/README.md b/skills/in-progress/README.md index 68345c2..58b1585 100644 --- a/skills/in-progress/README.md +++ b/skills/in-progress/README.md @@ -8,3 +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. diff --git a/skills/in-progress/setup-deep-modules/SKILL.md b/skills/in-progress/setup-deep-modules/SKILL.md new file mode 100644 index 0000000..3a94220 --- /dev/null +++ b/skills/in-progress/setup-deep-modules/SKILL.md @@ -0,0 +1,94 @@ +--- +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. +disable-model-invocation: true +--- + +# Setup 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. + +For the vocabulary (deep module, interface, seam, depth), see the [`codebase-design`](../../engineering/codebase-design/SKILL.md) skill — use its language throughout. + +## The shape this enforces + +``` +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. +``` + +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. +4. **No cycles** — no dependency cycles. + +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 + +### 1. Detect the environment + +- **Package manager** — `pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, `bun.lockb` → bun, else npm. Use it for every command below (`pnpm`/`yarn`/`npm run`/`bunx`). +- **Packages root** — if `src/` exists use `src/packages`, else `packages`. Confirm the choice with the user if the repo already has a different obvious convention. +- **Existing config** — check for a `.dependency-cruiser.*` file. If one exists, do **not** overwrite it: merge the four rules and the options in, and tell the user what you added. + +**Done when:** package manager, packages root, and existing-config status are all known. + +### 2. Install dependency-cruiser + +Install `dependency-cruiser` as a devDependency with the detected package manager. + +**Done when:** `dependency-cruiser` is in `devDependencies`. + +### 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`. + +**Done when:** `.dependency-cruiser.cjs` exists with the correct `PACKAGES_ROOT`, and the four forbidden rules are present. + +### 4. Wire it into the checks + +- Add a `lint:boundaries` script: `depcruise ` (or `depcruise src`). +- Fold it into the repo's umbrella check command — the one that already runs typecheck (e.g. a `check` / `ci` / `validate` script). Do **not** touch `tsconfig` or add path aliases. +- If there is no umbrella script, add `lint:boundaries` and tell the user to include it in CI. + +**Done when:** `lint:boundaries` exists and runs as part of the same command as typecheck. + +### 5. Scaffold the example package + +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. + +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. + +### 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`. +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 short `docs/deep-modules.md` (or append to the repo's contributing/README) 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. + +**Done when:** a contributor can read one page and know the layout and the boundary rule. + +## 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. +- 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-deep-modules/dependency-cruiser.config.cjs new file mode 100644 index 0000000..72df09e --- /dev/null +++ b/skills/in-progress/setup-deep-modules/dependency-cruiser.config.cjs @@ -0,0 +1,95 @@ +// @ts-check +// 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. +// +// The only thing you should ever need to edit here is PACKAGES_ROOT. + +/** Where packages live. One immediate child dir per package (flat, no nesting). */ +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)$`; + +/** @type {import('dependency-cruiser').IConfiguration} */ +module.exports = { + forbidden: [ + { + name: "index-boundary-from-app", + comment: + "App/root code may reach a package only through its index — never a deep import.", + severity: "error", + from: { pathNot: `^${R}/` }, // importer is NOT inside any package + to: { path: INSIDE_A_PACKAGE, pathNot: ANY_INDEX }, + }, + { + name: "index-boundary-across-packages", + comment: + "A package's internals may import each other freely, but may reach OTHER packages only through their index.", + 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 + ], + }, + }, + { + name: "tests-only-through-index", + 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.", + 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) + ], + }, + }, + { + name: "tests-folder-is-private", + comment: + "A package's tests/ folder is reachable only from tests — nothing else may import fixtures.", + severity: "error", + from: { pathNot: `^${R}/[^/]+/tests/` }, // importer is not itself a test + to: { path: `^${R}/[^/]+/tests/` }, + }, + { + name: "no-circular", + comment: "No dependency cycles. Scope to `^${R}/` if you want to allow cycles outside packages.", + severity: "error", + from: {}, + to: { circular: true }, + }, + + // --- 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.: + // + // { + // name: "ui-may-not-depend-on-billing", + // severity: "error", + // from: { path: `^${R}/ui/` }, + // to: { path: `^${R}/billing/` }, + // }, + ], + options: { + doNotFollow: { path: "node_modules" }, + tsConfig: { fileName: "tsconfig.json" }, + enhancedResolveOptions: { + extensions: [".ts", ".tsx", ".js", ".jsx", ".json"], + }, + }, +}; From d80ff7a84022d39a4cd2754ced04822be0d654a5 Mon Sep 17 00:00:00 2001 From: Matt Pocock Date: Fri, 10 Jul 2026 09:53:56 +0100 Subject: [PATCH 2/5] Reference codebase-design as /codebase-design, not a path The install location isn't known at runtime, so a relative path breaks. Use the literal skill invocation instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- skills/in-progress/setup-deep-modules/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/in-progress/setup-deep-modules/SKILL.md b/skills/in-progress/setup-deep-modules/SKILL.md index 3a94220..e7a2392 100644 --- a/skills/in-progress/setup-deep-modules/SKILL.md +++ b/skills/in-progress/setup-deep-modules/SKILL.md @@ -8,7 +8,7 @@ disable-model-invocation: true 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. -For the vocabulary (deep module, interface, seam, depth), see the [`codebase-design`](../../engineering/codebase-design/SKILL.md) skill — use its language throughout. +For the vocabulary (deep module, interface, seam, depth), run the `/codebase-design` skill — use its language throughout. ## The shape this enforces From f947f93b92ec22c22edad13ab78af2af1983a0fd Mon Sep 17 00:00:00 2001 From: Matt Pocock Date: Fri, 10 Jul 2026 09:55:08 +0100 Subject: [PATCH 3/5] Put the convention README in the packages folder + point CLAUDE.md at it Step 7 now writes /README.md next to the packages it governs, and adds a context pointer from CLAUDE.md/AGENTS.md so an agent discovers the boundary rule. Co-Authored-By: Claude Opus 4.8 (1M context) --- skills/in-progress/setup-deep-modules/SKILL.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/skills/in-progress/setup-deep-modules/SKILL.md b/skills/in-progress/setup-deep-modules/SKILL.md index e7a2392..7b4d9a3 100644 --- a/skills/in-progress/setup-deep-modules/SKILL.md +++ b/skills/in-progress/setup-deep-modules/SKILL.md @@ -83,9 +83,11 @@ This is the completion criterion for the whole skill — a config that doesn't f ### 7. Document the convention -Write a short `docs/deep-modules.md` (or append to the repo's contributing/README) 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 `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. -**Done when:** a contributor can read one page and know the layout and the boundary rule. +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. ## Notes From 3ea30afc6852debbe5d6ce2835d9ab64e21ae4fa Mon Sep 17 00:00:00 2001 From: Matt Pocock Date: Fri, 10 Jul 2026 10:10:02 +0100 Subject: [PATCH 4/5] Rename to setup-ts-deep-modules; entry-points model over single barrel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename skill to setup-ts-deep-modules (it's TypeScript-specific). - A package's public surface is now its ENTRY POINTS — every file at the package root — not one designated index.ts. Implementation lives in subfolders and is private. This is depth-based and extension-agnostic. - Packages can expose several small entry points instead of funnelling everything through one giant barrel index; barrels are discouraged, and the generated repo docs (step 7) say so explicitly. - Rules renamed accordingly: entrypoint-boundary-from-app, entrypoint-boundary-across-packages, tests-through-entrypoints. - Example package now hides impl in a lib/ subfolder. Re-validated against dependency-cruiser 16: multiple entry points and cross-package entry imports pass; deep imports into subfolders fail with the expected rule from app code, other packages, and tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- skills/in-progress/README.md | 2 +- .../SKILL.md | 42 +++++++++------- .../dependency-cruiser.config.cjs | 50 +++++++++---------- 3 files changed, 49 insertions(+), 45 deletions(-) rename skills/in-progress/{setup-deep-modules => setup-ts-deep-modules}/SKILL.md (53%) rename skills/in-progress/{setup-deep-modules => setup-ts-deep-modules}/dependency-cruiser.config.cjs (56%) 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", From 3687fb4f381409d4fdd35091be94689d5addcf15 Mon Sep 17 00:00:00 2001 From: Matt Pocock Date: Fri, 10 Jul 2026 10:16:03 +0100 Subject: [PATCH 5/5] Name lib/ + tests/ as the canonical package subfolders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The depth-based rules already treat any subfolder as private, so the config needs no change. Document lib/ (implementation) and tests/ as the conventional two-folder shape, with entry points at the root — a fixed convention that never needs the config extended for a new folder. Co-Authored-By: Claude Opus 4.8 (1M context) --- skills/in-progress/setup-ts-deep-modules/SKILL.md | 8 ++++---- .../setup-ts-deep-modules/dependency-cruiser.config.cjs | 6 ++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/skills/in-progress/setup-ts-deep-modules/SKILL.md b/skills/in-progress/setup-ts-deep-modules/SKILL.md index 07f6ada..5963b8a 100644 --- a/skills/in-progress/setup-ts-deep-modules/SKILL.md +++ b/skills/in-progress/setup-ts-deep-modules/SKILL.md @@ -17,11 +17,11 @@ src/packages/ / 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. + lib/ ← implementation: 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. +The public surface is the package's **root files** — not one designated `index.ts`. By convention implementation lives in `lib/` and tests in `tests/`, giving every package the same two-folder shape. The rule itself is general, though: *anything* in *any* subfolder is private, so you never extend the config to add a folder. Four rules, all `error`: @@ -88,7 +88,7 @@ This is the completion criterion for the whole skill — a config that doesn't f ### 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 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. +Write a `README.md` **in the packages folder** (`/README.md`) — next to the packages it governs — covering: the `src/packages//` layout (entry points at the root, `lib/` for implementation, `tests/` for tests), "import only through a package's entry points (its root files)", 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. @@ -97,6 +97,6 @@ Then add a **context pointer** to it from the repo's agent-instructions file — ## 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. +- Public vs private is decided by **depth**: a package's root files are entry points; anything in a subfolder is private. The conventional subfolders are `lib/` (implementation) and `tests/`, but the rule doesn't hardcode them — any subfolder is private, so a new folder never needs a config change. Adding an entry point is just adding a root file — no barrel. - 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-ts-deep-modules/dependency-cruiser.config.cjs b/skills/in-progress/setup-ts-deep-modules/dependency-cruiser.config.cjs index 119fb72..94a9dcd 100644 --- a/skills/in-progress/setup-ts-deep-modules/dependency-cruiser.config.cjs +++ b/skills/in-progress/setup-ts-deep-modules/dependency-cruiser.config.cjs @@ -4,8 +4,10 @@ // Each package under the packages root is 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. 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. +// private — by convention `lib/` for implementation and `tests/` for tests, +// though any subfolder 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.