feat: nested .ratchet planning homes for monorepos - #8
Conversation
Walk up to the nearest .ratchet from where the command is invoked instead of joining .ratchet onto the cwd, so list/view/archive behave consistently with status/instructions when run inside a subdirectory. Adds regression tests covering subdirectory resolution. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PlanningHome gains optional parent (lazily resolved by continuing the walk-up past the current root) and moduleName (POSIX relative path from the root home). A home with an enclosing .ratchet is a module; the topmost is the root. Single-home repos resolve parent to null and gain no module name, so behavior and output are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds discoverModules(rootHome): a bounded fast-glob scan for nested .ratchet directories below the root, skipping node_modules/.git and gitignored paths, not descending past a found module, and deriving module names from the relative path. Parses an optional modules: registry and a module name: override in config.yaml, with duplicate names erroring at discovery time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
reconcileModuleRegistry returns non-fatal lint warnings for discovered-but-unregistered and registered-but-missing modules. Discovery stays the source of truth; with no registry declared there are no warnings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
resolvePlanningHomeForCommand resolves the root home from cwd, runs discovery, and substitutes the named module's home; an unknown name errors with the discovered-name list. Threaded through new change, status, instructions, view, and archive. Without --module, nearest-wins resolution is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root-level list now folds in changes from every discovered module, labeled by module name; module-level list stays scoped to itself. A module with an unparseable config degrades to a warning instead of failing the listing, and registry lint warnings are surfaced. Root-only output is unchanged: the module field and label appear only for module rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
loadLayeredStandards(home) loads the parent chain root-first and lets a module shadow root standards by tag (whole-document replacement). Wired into instruction output and into standards-tag validation, so a module change sees inherited root standards plus its own and may declare a root-defined tag. A root home layers to exactly its own library, keeping single-home behavior identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Archive is already home-local once it resolves the planning home (tasks 1.1 and 3.1): applyFeatures and the archive dir derive from the resolved home root. These tests lock in that a module change materializes features into the module store and moves to the module archive, leaving the root store untouched, and vice versa for root changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
materializeStandardLinks now keeps forward sidecars module-local and regenerates each standard's Implemented by block in the home that defines it. Reverse indexes are built per defining-home: the home's own features are unqualified, while features from other modules are qualified <module>: <capability>/<file>. An inherited root standard therefore collects module features in the root file; a module-local standard stays within the module and never touches the root. Single-home repos keep the prior behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drives the built CLI against a root + two modules (api, web) covering nearest-wins resolution from a subdirectory and the root, root aggregation with module labels, scoped module-level list, --module addressing for status and new change, and the unknown-module error. Standards layering and feature-store scenarios are covered by the core integration tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Asserts a single-root .ratchet repo emits no module field in list/status JSON, no module labels or warnings in human output, and byte-stable change listings regardless of the invocation directory. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
joctaTorres
left a comment
There was a problem hiding this comment.
PR #8 review — "feat: nested .ratchet planning homes for monorepos"
Base: 40a7bc5 · Head: 529ecb7 · 1991+ / 58− · 33 files (14 in-scope src/**)
Quality snapshot
In-scope = src/**/*.ts only. CC/NLOC measured with lizard 1.23 (TS parsed as JS); coverage with vitest v8 at head. ABC is regex-approximate (upper-bound, no AST). MI/Halstead are estimated, not from a TS-native analyzer. No view components in the diff, so backend thresholds apply throughout.
| Metric | Before | After | Δ | Status |
|---|---|---|---|---|
Cyclomatic — archive.execute (max touched) |
44 | 49 | +5 | 🔴 |
Cyclomatic — readProjectConfig |
21 | 27 | +6 | 🔴 |
Cyclomatic — list.execute |
17 | 24 | +7 | 🔴 |
Cyclomatic — generateInstructions |
14 | 15 | +1 | 🟡 |
Cyclomatic — new fns (discoverModules 11, materializeStandardLinks 10) |
— | ≤11 | new | 🟢 |
CC density — max (materializeStandardLinks) |
— | 0.29 | new | 🟡 |
ABC magnitude — readProjectConfig (approx) |
~70 | ~101 | up | 🟡 |
ABC magnitude — list.execute (approx) |
~72 | ~97 | up | 🟡 |
| Coverage — line (new files: discovery/planning-home/project-config) | n/a | 96–98% | new tests | 🟢 |
| Coverage — branch (same) | n/a | 88–93% | new tests | 🟢 |
Coverage — archive.ts line |
~57% | 59% | ≈flat | 🟡 |
| New coupling types | — | Data + one Control flag (--module) |
new | 🟡 |
| LCOM4 | not measured (no TS tool) | — | — | skipped |
| Halstead D / MI | estimated only | — | — | skipped |
| DSQI / IPL / WEAVE | skipped (no tool / not perf-critical / no integration) |
Hard gates: the three CC ceilings above already exceeded 15 at base; this PR pushed each one further (list.execute +7, readProjectConfig +6, archive.execute +5). Flagged 🔴 because the PR moved them the wrong way, not because it created them — none of the new functions trip a gate (all ≤ 11 CC, density ≤ 0.30). Coverage and ABC magnitude gates pass.
Skipped / estimated metrics: LCOM4 — no TS LCOM4 tool installed (not auto-installed). Halstead D, MI — estimated from lizard tokens, treat as directional. DSQI/IPL/WEAVE — not applicable / no integration. lizard + coverage ran in an isolated /tmp worktree, never the working tree.
Must-fix advisories
- 🔴 CC ceiling —
list.execute17 → 24 (gate 15). The root-aggregation block (discovery + registry lint + per-module collect + degrade-on-error) was inlined into an already-oversized method. Extract it. Inline atsrc/core/list.ts:138. - 🔴 CC ceiling —
readProjectConfig21 → 27 (gate 15). Two more field parsers (modules,name) bolted onto a function that already hand-rolls every field linearly. Extract a per-field helper. Inline atsrc/core/project-config.ts:174. - 🔴 CC ceiling —
archive.execute44 → 49 (gate 15). Pre-existing god-method; the new--moduleresolution made it worse. Not in scope to fully fix here, but please stop growing it. Inline atsrc/core/archive.ts:57.
Reviewer attention (soft signals)
- Inconsistent handling of
discoverModulesthrowing on duplicate names.list.ts:141wraps discovery in try/catch and degrades to a warning, butfeatures-apply.ts:554(archive-time link materialization) andmodule-discovery.ts:228(resolvePlanningHomeForCommand) do not. A duplicate name anywhere in the repo would hard-crash anarchive/status/new-changeof an unrelated module. Pick one policy and apply it at all three call sites. discoverModulesis an unmemoized full filesystem scan (fast-glob+ areadProjectConfigper module), re-run on everylist, every--modulecommand, and again insidematerializeStandardLinks. Fine at current sizes (~1–3ms/config per the project-config note), but worth a one-line note that it is intentionally uncached.- CC density
materializeStandardLinks= 0.29 sits just under the 0.30 gate; its dual reverse-link mode (single-home vs layered) is the densest new logic. A short comment splitting the two branches would help. --moduleis a new Control-coupling flag threaded through five commands plusResolveCommandHomeOptions. Shape is consistent and each handler stays thin, so this is acceptable — noted because it is the one new non-Data coupling edge.
Opinionated findings (architectural — see inline for line-level)
src/core/module-discovery.ts
- §3.4 best-effort parser —
gitignoreGlobsis a hand-rolled partial.gitignoretranslator (no negations, no**, no anchored nesting). The docstring is honest, but a wrong-but-silent ignore could hide a real module. Inline at:60. - §3.2 connascence of platform —
rel.split(path.sep)on afast-globresult (always/) is a no-op on Windows; it works only because fast-glob normalizes. Same pattern inplanning-home.ts:126andproject-config.ts:362. A namedtoPosixhelper removes the implicit dependency on fast-glob's separator. Inline at:113.
src/core/features-apply.ts
- §3.7 / §3.5 cohesion —
materializeStandardLinkscarries two responsibilities (forward-link write + two-mode reverse-link regen). The reverse half (:551–:583) reads as its own unit; pulling it intoregenerateLayeredReverseLinks(home)isolates the nested logic from the legacy single-home path. Inline at:551.
src/core/list.ts
- §3.1 layering — the root-aggregation loop mixes discovery, registry-lint emission, broken-module degradation, and change collection inside the command method. A
collectModuleChanges(planningHome)helper returning{changes, warnings}keepsexecuteorchestration-only. Inline at:138.
Summary
Solid, well-tested feature: the new modules (module-discovery, the planning-home additions, project-config readers) land at 96–98% line / 88–93% branch coverage with 50 new tests including a dedicated backward-compat suite, and the lazy parent-resolution design genuinely keeps single-home repos on the identical path. Address first the three already-over-gate functions (list.execute, readProjectConfig, archive.execute) that this PR pushed further past CC 15 by inlining new logic instead of extracting helpers. Second, reconcile the three inconsistent reactions to discoverModules throwing on a duplicate name before a misconfigured sibling can crash an unrelated command. Comment-only review — the merge decision stays with the human reviewer.
| // Module-level list (a home with a parent) stays scoped to itself. | ||
| const changes: ChangeInfo[] = [...rootChanges]; | ||
| const isRootHome = getParentPlanningHome(planningHome) === null; | ||
| if (isRootHome) { |
There was a problem hiding this comment.
🔴 CC ceiling — list.execute 17 → 24 (above the 15 gate). This root-aggregation block (discovery + registry-lint emission + per-module collect + degrade-on-error) was inlined into an already-oversized command method. Extract a collectModuleChanges(planningHome) returning { changes, warnings } so execute stays orchestration-only. Grounded in: CC 17 → 24; §3.1 layering.
There was a problem hiding this comment.
Done in 7cda082. Extracted collectModuleChanges(planningHome) returning { changes, warnings }; list.execute now just folds the module rows into the root rows and prints the warnings, so it stays orchestration-only. Module discovery there now also routes through the shared discoverModulesSafe, replacing the inline try/catch.
|
|
||
| // Parse modules registry (root config). Expect an array of non-empty | ||
| // strings; ignore anything else with a warning. | ||
| if (raw.modules !== undefined) { |
There was a problem hiding this comment.
🔴 CC ceiling — readProjectConfig 21 → 27 (above the 15 gate). The new modules and name parsers were appended to a function that already hand-rolls every field inline. Pull each field into a small parseField/helper (the rules block especially), or build the partial config from a field map. Grounded in: CC 21 → 27; §3.7 function length.
There was a problem hiding this comment.
Done in fdf5723. Extracted per-field parse helpers parseContextField / parseRulesField / parseModulesField / parseNameField, each returning the parsed value or undefined and emitting its own warnings. readProjectConfig is now a flat assign-when-present orchestration over those helpers; behavior is unchanged (all field-level warnings preserved).
| // defining home. Without a planning home, or for a plain single-home repo, | ||
| // this is exactly the legacy behavior (scan `root`'s store, regenerate | ||
| // `root`'s standards). | ||
| const isNested = home !== undefined && getParentPlanningHome(home) !== null; |
There was a problem hiding this comment.
§3.5 / §3.7 cohesion — materializeStandardLinks does two jobs. The reverse-link half below (the single-home vs layered-multi-home branch, L551–L583) is a self-contained unit at CC density 0.29 — just under the 0.30 gate. Extracting regenerateLayeredReverseLinks(home) would keep the nested-monorepo logic out of the legacy single-home path and drop this function's density. Grounded in: CC density 0.29; cohesion.
There was a problem hiding this comment.
Done in 89467d4. Extracted regenerateLayeredReverseLinks(rootHome, modules) so the nested-monorepo two-mode reverse-link logic is isolated from the legacy single-home path; materializeStandardLinks now does the forward-link write, then either the single-home projection or delegates to the layered helper. Discovery there also routes through discoverModulesSafe so a duplicate name degrades to the single-home path instead of crashing the archive.
| * a best-effort translation good enough for the common directory-ignore case | ||
| * (e.g. `dist/`, `build`, `tmp/`); it is not a full gitignore implementation. | ||
| */ | ||
| function gitignoreGlobs(rootDir: string): string[] { |
There was a problem hiding this comment.
§3.4 best-effort parser — gitignoreGlobs is a partial .gitignore translation (no negations, no **, no anchored nesting). The docstring is honest about it, but discovery is the source of truth, so a pattern this doesn't handle could silently hide a real module rather than fail loudly. Consider deferring to git check-ignore/ignore lib, or at minimum noting in the module-not-found path that gitignore handling is approximate. Grounded in: CoN-Meaning on the ignore globs.
There was a problem hiding this comment.
Addressed in c964289. Kept the best-effort translator but made a silently-hidden module diagnosable: the Unknown module error in resolvePlanningHomeForCommand now notes that discovery skips paths matched by the root .gitignore via an approximate translator (directory ignores only; negations/globs unsupported), so the user can check whether the missing module's path is gitignored. Full deferral to git check-ignore / the ignore lib is left as a follow-up.
| // parent. Drop the root's own `.ratchet` (relpath === RATCHET_DIR_NAME). | ||
| const moduleRoots: string[] = []; | ||
| for (const rel of matches) { | ||
| const normalized = rel.split(path.sep).join('/'); |
There was a problem hiding this comment.
nit: §3.2 connascence of platform — rel.split(path.sep) here relies on fast-glob always emitting /. On Windows path.sep is \, so this split is a no-op and only works because fast-glob normalizes separators — an implicit dependency. Same idiom in planning-home.ts:126 and project-config.ts:362. A single named toPosix(p) helper makes the contract explicit and removes the duplication. Grounded in: CoN-Algorithm.
There was a problem hiding this comment.
Done in 7cdf601. Completed the WIP: toPosix(p) is now the single exported helper in planning-home.ts, and all three .split(path.sep).join('/') sites route through it — relativeModulePath (planning-home), the fast-glob match normalization (module-discovery:113), and the module-registry normalization (project-config). The helper's doc spells out the fast-glob /-vs-path.sep contract so the connascence-of-platform is named in one place.
# Conflicts: # src/core/project-config.ts
…Posix
Introduce a single exported toPosix(p) helper and route the three
.split(path.sep).join('/') sites through it: relativeModulePath
(planning-home), the fast-glob match normalization (module-discovery),
and the module registry normalization (project-config). Naming the
conversion makes the fast-glob "/"-vs-path.sep contract explicit and
removes the connascence-of-platform idiom duplication.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
discoverModules throws on a duplicate module name (a real data error), but only list.ts degraded that to a warning while features-apply and resolvePlanningHomeForCommand let it propagate, so a duplicate name anywhere hard-crashed unrelated commands. Add discoverModulesSafe, which catches any discovery failure and warns + returns [], and adopt it as the single policy for incidental cross-module aggregation. Route --module resolution through it here; list and archive-time link materialization follow in their own commits. Also document that discoverModules is an intentionally uncached, full filesystem scan re-run per command (mirrors the readProjectConfig caching rationale). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
list.execute carried the whole module-aggregation block inline
(discovery, registry lint, per-module load + collect), pushing its
cyclomatic complexity well past the ceiling. Extract
collectModuleChanges(planningHome) returning { changes, warnings } so
execute stays orchestration-only: fold changes into the root rows, print
warnings. Discovery now routes through discoverModulesSafe (shared
duplicate-name policy), replacing the inline try/catch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
materializeStandardLinks did two jobs: the always-run forward-link write and the two-mode reverse-link regen (legacy single-home vs. layered nested-monorepo). Extract regenerateLayeredReverseLinks(rootHome, modules) so the nested layering logic is isolated from the legacy single-home path. Discovery routes through discoverModulesSafe so a duplicate module name degrades to the single-home reverse-link path rather than crashing the archive (third call site of the shared policy). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
readProjectConfig validated each field inline, with the context, rules, modules, and name blocks pushing its cyclomatic complexity past the ceiling. Extract parseContextField/parseRulesField/parseModulesField/ parseNameField, each returning the parsed value or undefined and emitting its own warnings. readProjectConfig is now a flat assign-when- present orchestration over those helpers; behavior is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
archive.execute is a pre-existing god-method (tracked in github.com//issues/4) and is not in scope to fully refactor here. As a stop-the-bleed, extract the planning-home / --module resolution phase into a private resolvePlanningHome(options) so execute does not grow and the resolution branch is named and isolated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
gitignoreGlobs is a best-effort .gitignore translator (directory ignores only; negations and globs unsupported), so a module under a gitignored path is silently dropped from discovery. Surface this in the Unknown module error so a silently-hidden module is diagnosable. Full deferral to git check-ignore / the `ignore` lib is left as a follow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Adds support for multiple nested
.ratchetdirectories in complex repos and monorepos. The root.ratchetremains a full planning home that additionally discovers, addresses, and aggregates nested module homes..ratchet;list/view/archivenow route through the central resolver instead of hardcoding./.ratchetnode_modules/.git/gitignored, no descent past a found module); an optionalmodules:registry in rootconfig.yamllints the layout with non-fatal warnings in both directionsname:override in module config; a shared--module <name>option targets a module from anywhere (new change,status,instructions,view,archive)listincludes module changes labeled by module; a broken module degrades to a warning.ratchetrepos behave byte-identically (pinned by a dedicated backward-compat e2e suite)Change artifacts (Gherkin features + plan) live in
.ratchet/changes/nested-planning-homes/.Verification
Independently verified against the change artifacts: all 22 Gherkin scenarios satisfied (code + tests, most also exercised empirically against a temp monorepo fixture), all 12 plan tasks done.
Test plan
pnpm build✓pnpm lint✓ (0 issues)pnpm test✓ — 893 tests / 55 files passing (50 new tests, including monorepo e2e fixture and single-home backward-compat suite)🤖 Generated with Claude Code