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-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.
|
||||||
|
|||||||
+24
-18
@@ -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.
|
||||||
+24
-26
@@ -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",
|
||||||
Reference in New Issue
Block a user