Skip to content

feat: nested .ratchet planning homes for monorepos - #8

Open
joctaTorres wants to merge 20 commits into
mainfrom
monorepo
Open

feat: nested .ratchet planning homes for monorepos#8
joctaTorres wants to merge 20 commits into
mainfrom
monorepo

Conversation

@joctaTorres

Copy link
Copy Markdown
Owner

Summary

Adds support for multiple nested .ratchet directories in complex repos and monorepos. The root .ratchet remains a full planning home that additionally discovers, addresses, and aggregates nested module homes.

  • Nearest-wins resolution: commands run inside a sub-module resolve that module's .ratchet; list/view/archive now route through the central resolver instead of hardcoding ./.ratchet
  • Hybrid module discovery: filesystem scan is the source of truth (skips node_modules/.git/gitignored, no descent past a found module); an optional modules: registry in root config.yaml lints the layout with non-fatal warnings in both directions
  • Module identity & addressing: names default to the root-relative path with a name: override in module config; a shared --module <name> option targets a module from anywhere (new change, status, instructions, view, archive)
  • Root aggregation: root-level list includes module changes labeled by module; a broken module degrades to a warning
  • Standards layering: module changes see root standards plus their own, module wins on tag collision; tag validation runs against the layered set
  • Module-local feature stores: archive materializes features into the module's own store; standard reverse-links regenerate in the defining home with module-qualified entries
  • No breaking changes: single-.ratchet repos 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

joctaTorres and others added 12 commits June 10, 2026 16:27
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 joctaTorres left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.execute 17 → 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 at src/core/list.ts:138.
  • 🔴 CC ceiling — readProjectConfig 21 → 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 at src/core/project-config.ts:174.
  • 🔴 CC ceiling — archive.execute 44 → 49 (gate 15). Pre-existing god-method; the new --module resolution made it worse. Not in scope to fully fix here, but please stop growing it. Inline at src/core/archive.ts:57.

Reviewer attention (soft signals)

  • Inconsistent handling of discoverModules throwing on duplicate names. list.ts:141 wraps discovery in try/catch and degrades to a warning, but features-apply.ts:554 (archive-time link materialization) and module-discovery.ts:228 (resolvePlanningHomeForCommand) do not. A duplicate name anywhere in the repo would hard-crash an archive/status/new-change of an unrelated module. Pick one policy and apply it at all three call sites.
  • discoverModules is an unmemoized full filesystem scan (fast-glob + a readProjectConfig per module), re-run on every list, every --module command, and again inside materializeStandardLinks. 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.
  • --module is a new Control-coupling flag threaded through five commands plus ResolveCommandHomeOptions. 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 parsergitignoreGlobs is a hand-rolled partial .gitignore translator (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 platformrel.split(path.sep) on a fast-glob result (always /) is a no-op on Windows; it works only because fast-glob normalizes. Same pattern in planning-home.ts:126 and project-config.ts:362. A named toPosix helper removes the implicit dependency on fast-glob's separator. Inline at :113.

src/core/features-apply.ts

  • §3.7 / §3.5 cohesionmaterializeStandardLinks carries two responsibilities (forward-link write + two-mode reverse-link regen). The reverse half (:551:583) reads as its own unit; pulling it into regenerateLayeredReverseLinks(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} keeps execute orchestration-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.

Comment thread src/core/list.ts
// Module-level list (a home with a parent) stays scoped to itself.
const changes: ChangeInfo[] = [...rootChanges];
const isRootHome = getParentPlanningHome(planningHome) === null;
if (isRootHome) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread src/core/archive.ts Outdated
// 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;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

§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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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[] {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

§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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/core/module-discovery.ts Outdated
// 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('/');

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

joctaTorres and others added 8 commits June 17, 2026 19:47
# 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant