102 lines
7.3 KiB
Markdown
102 lines
7.3 KiB
Markdown
---
|
|
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.
|
|
---
|
|
|
|
# Setup TS Deep Modules
|
|
|
|
Configure dependency-cruiser to enforce the requested package boundaries, then prove the rules detect a violation. Start from existing package layout and public exports. The bundled configuration is for flat packages whose root files are entry points and whose subfolders are private; adapt it when the repository uses another contract rather than restructuring the application to fit the template.
|
|
|
|
For the vocabulary (deep module, interface, seam, depth), run the `/codebase-design` skill — use its language throughout.
|
|
|
|
## Bundled template's shape
|
|
|
|
```
|
|
src/packages/
|
|
<name>/
|
|
index.ts ← an entry point (public). Import this from outside.
|
|
client.ts ← another entry point. Packages may expose SEVERAL.
|
|
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`. 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`:
|
|
|
|
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 separate concern. Add those rules only for an actual requested architectural constraint.
|
|
|
|
## Steps
|
|
|
|
### 1. Detect the environment
|
|
|
|
- **Package manager** — inspect the owning package's `packageManager` and lockfile, including `bun.lock` or `bun.lockb`; preserve its tooling.
|
|
- **Packages root and contract** — use existing package locations and exports. If establishing a new convention, propose the smallest suitable boundary and ask only when the choice materially affects the architecture.
|
|
- **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
|
|
|
|
Reuse `dependency-cruiser` if installed; otherwise add it to the owning package's devDependencies with the detected package manager. Check its installed documentation for supported configuration before wiring rules.
|
|
|
|
**Done when:** `dependency-cruiser` is in `devDependencies`.
|
|
|
|
### 3. Write the config
|
|
|
|
Use [`dependency-cruiser.config.cjs`](dependency-cruiser.config.cjs) as a starting point, merging existing configuration in place. Adapt package paths, public-entry rules, test locations, and `tsConfig` to the selected project. Do not assume changing `PACKAGES_ROOT` alone covers another layout.
|
|
|
|
**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 <packages-root>` (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:** the boundary check runs through the requested existing check command, or its standalone invocation and missing umbrella integration are documented.
|
|
|
|
### 5. Select a validation fixture
|
|
|
|
Use an existing representative package or a temporary task-owned fixture with:
|
|
|
|
- `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.
|
|
|
|
Keep the fixture isolated from user work. A permanent example package is optional and should be created only when requested.
|
|
|
|
**Done when:** the fixture exercises allowed entry-point access and a forbidden private import under the actual configured rules.
|
|
|
|
### 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 the boundary check on the selected fixture and record the allowed result.
|
|
2. Add an intentional forbidden import in the isolated fixture; verify that the relevant rule reports it. Also exercise cross-package boundaries when those rules changed.
|
|
3. Remove only the task's intentional violation and rerun. Remove a disposable fixture after validation; retain a regression check only where it adds ongoing value.
|
|
|
|
**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
|
|
|
|
Update the nearest existing package/contributor guidance with the selected public-entry convention and check command. Add a short local document only when no suitable one exists; do not require another README or a fixed two-folder layout.
|
|
|
|
Link that guidance from the applicable agent instructions when it is not already discoverable. Preserve the user's chosen agent product and file.
|
|
|
|
**Done when:** the actual convention and check command are discoverable, with no temporary violation or unrequested example package left behind.
|
|
|
|
## 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. 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.
|