mirror of
https://github.com/mattpocock/skills.git
synced 2026-07-29 02:52:42 +07:00
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:
co-authored by
Claude Opus 4.8
parent
f947f93b92
commit
3ea30afc68
@@ -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.
|
||||
|
||||
+24
-18
@@ -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/
|
||||
<name>/
|
||||
index.ts ← the ONLY public interface. Outsiders import this and nothing else.
|
||||
<internals> ← 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.
|
||||
<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`:
|
||||
|
||||
1. **Index boundary** — code outside a package (app code or another package) may import only `<pkg>/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 `<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.
|
||||
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 `<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.
|
||||
|
||||
**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 `<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).
|
||||
- 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** (`<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.
|
||||
|
||||
**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
|
||||
|
||||
- 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.
|
||||
+24
-26
@@ -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",
|
||||
Reference in New Issue
Block a user