From 30a9c7447faf38152983ed9ae20dffdfe4ba9b77 Mon Sep 17 00:00:00 2001 From: Matt Pocock Date: Fri, 10 Jul 2026 09:49:13 +0100 Subject: [PATCH] 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"], + }, + }, +};