Rename to setup-ts-deep-modules; entry-points model over single barrel

- 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) <noreply@anthropic.com>
This commit is contained in:
Matt Pocock
2026-07-10 10:10:02 +01:00
co-authored by Claude Opus 4.8
parent f947f93b92
commit 3ea30afc68
3 changed files with 49 additions and 45 deletions
+1 -1
View File
@@ -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-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. - **[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. - **[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.
@@ -1,12 +1,12 @@
--- ---
name: setup-deep-modules name: setup-ts-deep-modules
description: Wire dependency-cruiser into a TypeScript repo so each package is a deep module — everything hidden behind its index. User-invoked. 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 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. 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/ src/packages/
<name>/ <name>/
index.ts ← the ONLY public interface. Outsiders import this and nothing else. index.ts ← an entry point (public). Import this from outside.
<internals> ← free to import each other; invisible to the outside world. client.ts ← another entry point. Packages may expose SEVERAL.
tests/ ← co-located tests + fixtures. Reach the package through index only. <subfolder>/ ← 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`: Four rules, all `error`:
1. **Index boundary** — code outside a package (app code or another package) may import only `<pkg>/index.ts`, never anything deeper. 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 internals import each other freely. 2. **Intra-package freedom** — a package's own files import each other freely.
3. **Tests through the index** — files under `<pkg>/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. 3. **Tests through the entry points** — files under `<pkg>/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. 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. 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 ## Steps
@@ -47,7 +52,7 @@ Install `dependency-cruiser` as a devDependency with the detected package manage
### 3. Write the config ### 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. **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 `<packages-root>/example/` as a copy-me template: Create a committed `<packages-root>/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). - `index.ts`an entry point. 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. - `lib/impl.ts` — an internal file in a **subfolder**, imported by `index.ts`, not reachable from outside.
- `tests/example.test.ts` — imports **only** `../index`, and asserts against the public function. - `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. 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 ### 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. 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. 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**. 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. **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 ### 7. Document the convention
Write a `README.md` **in the packages folder** (`<packages-root>/README.md`) — next to the packages it governs — covering: the `src/packages/<name>/` 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** (`<packages-root>/README.md`) — next to the packages it governs — covering: the `src/packages/<name>/` 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. 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:** `<packages-root>/README.md` exists, and the repo's `CLAUDE.md`/`AGENTS.md` links to it. **Done when:** `<packages-root>/README.md` exists and discourages barrels, and the repo's `CLAUDE.md`/`AGENTS.md` links to it.
## Notes ## 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. - 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. - 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. - Use `.cjs` (not `.js`) so the config's `module.exports` works even in `"type": "module"` repos.
@@ -2,9 +2,10 @@
// Deep-module enforcement for dependency-cruiser. // Deep-module enforcement for dependency-cruiser.
// //
// Each package under the packages root is a DEEP MODULE: a lot of behaviour // 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 // behind a small interface. A package's PUBLIC SURFACE is its ENTRY POINTS —
// only way in — internals stay hidden, and tests exercise the package through // the files at the package root. Implementation lives in SUBFOLDERS and is
// the same interface everyone else does. // 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. // 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) ------------------------------------- // --- derived patterns (no need to edit) -------------------------------------
const R = PACKAGES_ROOT; const R = PACKAGES_ROOT;
/** Any file that lives inside some package's internals or subfolders. */ /**
const INSIDE_A_PACKAGE = `^${R}/[^/]+/.+`; * A package's private internals: anything nested inside a package subfolder.
/** A package's public interface — the only legal way in. */ * The package's root files are its entry points and are NOT matched here
const ANY_INDEX = `^${R}/[^/]+/index\\.(ts|tsx)$`; * they stay importable from outside.
*/
const PACKAGE_INTERNALS = `^${R}/[^/]+/[^/]+/`;
/** @type {import('dependency-cruiser').IConfiguration} */ /** @type {import('dependency-cruiser').IConfiguration} */
module.exports = { module.exports = {
forbidden: [ forbidden: [
{ {
name: "index-boundary-from-app", name: "entrypoint-boundary-from-app",
comment: 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", severity: "error",
from: { pathNot: `^${R}/` }, // importer is NOT inside any package 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: 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", severity: "error",
// importer is inside a package ($1), but is not a test file // importer is inside a package ($1), but is not a test file
from: { path: `^${R}/([^/]+)/`, pathNot: `^${R}/[^/]+/tests/` }, from: { path: `^${R}/([^/]+)/`, pathNot: `^${R}/[^/]+/tests/` },
to: { to: {
path: INSIDE_A_PACKAGE, path: PACKAGE_INTERNALS,
pathNot: [ pathNot: `^${R}/$1/`, // same package → intra-package freedom
`^${R}/$1/`, // same package → intra-package freedom
ANY_INDEX, // any package's index → allowed
],
}, },
}, },
{ {
name: "tests-only-through-index", name: "tests-through-entrypoints",
comment: 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", severity: "error",
from: { path: `^${R}/([^/]+)/tests/` }, // a test file, in package $1 from: { path: `^${R}/([^/]+)/tests/` }, // a test file, in package $1
to: { to: {
path: INSIDE_A_PACKAGE, path: PACKAGE_INTERNALS,
pathNot: [ pathNot: `^${R}/$1/tests/`, // own tests/ fixtures → allowed
`^${R}/$1/tests/`, // own tests/ fixtures → allowed
ANY_INDEX, // any package's index → allowed (integration tests are fine)
],
}, },
}, },
{ {
@@ -75,8 +72,9 @@ module.exports = {
}, },
// --- Layering (optional, off by default) ---------------------------------- // --- Layering (optional, off by default) ----------------------------------
// Interface-hiding controls HOW you import (through the index). Layering // Interface-hiding controls HOW you import (through the entry points).
// controls WHICH packages may depend on which. Add your own rules here, e.g.: // Layering controls WHICH packages may depend on which. Add your own rules
// here, e.g.:
// //
// { // {
// name: "ui-may-not-depend-on-billing", // name: "ui-may-not-depend-on-billing",