From 32f8db24181c3408ac7ecf6be795214657b6c5f9 Mon Sep 17 00:00:00 2001 From: joctaTorres Date: Wed, 10 Jun 2026 16:27:15 -0300 Subject: [PATCH 01/19] refactor(core): route list/view/archive through planning-home resolver 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 --- .../changes/nested-planning-homes/plan.md | 53 +++++++++++++ src/core/archive.ts | 9 ++- src/core/list.ts | 11 ++- src/core/view.ts | 6 +- test/core/walk-up-resolution.test.ts | 76 +++++++++++++++++++ 5 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 .ratchet/changes/nested-planning-homes/plan.md create mode 100644 test/core/walk-up-resolution.test.ts diff --git a/.ratchet/changes/nested-planning-homes/plan.md b/.ratchet/changes/nested-planning-homes/plan.md new file mode 100644 index 0000000..b50a0ac --- /dev/null +++ b/.ratchet/changes/nested-planning-homes/plan.md @@ -0,0 +1,53 @@ +# Nested planning homes + +## Why + +Ratchet currently assumes exactly one `.ratchet` directory per repository, resolved by walking up from cwd to the first match. In monorepos and large repos this forces every team's changes, features, and standards into a single shared store. This change lets complex repos split planning into nested `.ratchet` directories per sub-module, with the root `.ratchet` remaining a full planning home that also discovers, addresses, and aggregates its children. + +## What Changes + +- Nearest-wins resolution: commands run inside a sub-module resolve that module's `.ratchet`, not the root's (`features/nested-planning-homes/resolution.feature`). +- `archive`, `list`, and `view` are routed through the central planning-home resolver instead of hardcoding `./.ratchet` (`resolution.feature`, scenario "list, view, and archive obey walk-up resolution"). +- Filesystem discovery of nested `.ratchet` directories from the root, with an optional `modules:` registry in root `config.yaml` acting as a lint/allowlist — mismatches in either direction warn but never hide modules (`discovery.feature`). +- Module identity: name defaults to the path relative to the root; a module's `config.yaml` may declare a `name:` override (`discovery.feature`). +- New `--module ` option on change-scoped commands (`new change`, `status`, `instructions`, `view`, `archive`) to target a module's planning home from anywhere in the repo (`module-addressing.feature`). +- Root `ratchet list` aggregates root changes plus all module changes, labeled by module; a broken module degrades to a warning, not a failure (`root-aggregation.feature`). +- Standards layering: module changes see root standards plus module standards, module wins on tag collision; tag validation runs against the layered set (`standards-layering.feature`). +- Module-local feature stores: archiving a module change materializes features into the module's own `.ratchet/features/`; standard reverse-links are regenerated in the home that defines the standard, with module-qualified feature entries (`module-feature-store.feature`). +- No breaking changes: a repo with a single root `.ratchet` behaves exactly as today — no module concept, no new output, no warnings. + +## Design + +**Resolution model.** The existing walk-up in `src/core/planning-home.ts` already implements nearest-wins; it stays the default resolution path. The new work is layered on top of it rather than replacing it: + +- `PlanningHome` gains `parent?: PlanningHome` (lazily resolved by continuing the walk-up past the current root) and `moduleName?: string`. A home whose walk-up finds another `.ratchet` above it is a *module*; the topmost home is the *root*. The unused `PlanningHomeKind = 'workspace'` stub is repurposed/retired in favor of this parent-link model — kind stays `'repo'` to avoid touching `ActionContext` semantics in this change. +- Prerequisite refactor: `src/core/archive.ts`, `src/core/list.ts`, and `src/core/view.ts` currently build `path.join('.', '.ratchet', 'changes')` by hand. They must call `resolveCurrentPlanningHomeSync()` first; otherwise nested mode behaves differently per command. This lands before any nesting logic. + +**Discovery (hybrid).** A new `discoverModules(rootHome)` in `src/core/planning-home.ts` globs for `*/.ratchet` directories below the root using fast-glob with bounded depth, skipping `node_modules`, `.git`, and gitignored paths, and not descending past a found module (a module's own nested homes are its business, not the root's — one level of parent/child per resolution). The root `config.yaml` gains an optional `modules: […]` list parsed in `src/core/project-config.ts`. Discovery is the source of truth; the registry only produces warnings: discovered-but-unregistered (when a registry exists) and registered-but-missing. This avoids the stale-registry failure mode while letting teams pin the expected layout. + +**Module identity.** `moduleName` defaults to the POSIX-style relative path from root to module (`packages/api`); a module `config.yaml` `name:` field overrides it. Name collisions across modules are an error at discovery time. `--module` resolves against these names and errors with the known-name list on a miss — no second registry, no guessing. + +**Addressing.** `--module` is implemented in one place: a shared option that, when present, resolves the root home from cwd, runs discovery, and substitutes the matched module's home for the rest of the command. Commands keep receiving a `PlanningHome` and stay ignorant of how it was chosen. + +**Aggregation.** Root-level `list` composes its existing per-home listing over `[root, ...discoverModules(root)]`, tagging rows with the module name (root rows untagged). Module load failures (unparseable config) are caught per-module and surfaced as warnings so one broken module cannot blind the whole repo. Module-level `list` does not aggregate — scoping down is the point of nesting. + +**Standards layering.** `loadStandards(projectRoot)` in `src/core/standards.ts` grows into `loadLayeredStandards(home)`: load the parent chain root-first, then the module, last-writer-wins by `tag`. Root changes therefore see only root standards (no children leak upward). Tag validation for a change's `standards:` list validates against the layered set of the change's home. Shadowing is by whole-document replacement — no merge semantics, which keeps collisions predictable. + +**Feature store and archive.** Archive operates entirely on the change's own home: features materialize into `/.ratchet/features/`, the change moves to `/.ratchet/changes/archive/`. The one cross-home write is standard reverse-links: `materializeStandardLinks` resolves each declared tag to the home that *defines* it (module if shadowed, else root) and regenerates that standard's `## Implemented by` block there, qualifying entries from modules as `: /`. Forward sidecars stay module-local next to the features. Trade-off: archiving a module change may touch a root standard file — accepted, because reverse links are already regenerated (never hand-edited), so the write is idempotent and conflict-free. + +**Backward compatibility.** Every new behavior is gated on a second `.ratchet` actually existing. Single-home repos hit the existing code paths: no discovery scan from non-root commands, no labels, no warnings. + +## Tasks + +- [x] 1.1 Route `archive`, `list`, and `view` through `resolveCurrentPlanningHomeSync()` (remove hardcoded `path.join('.', RATCHET_DIR_NAME, …)` in `src/core/archive.ts`, `src/core/list.ts`, `src/core/view.ts`); add regression tests that they resolve from a subdirectory +- [ ] 1.2 Extend `PlanningHome` with lazy `parent` resolution (continue walk-up past current root) and `moduleName`; keep single-home repos identical in behavior and output +- [ ] 2.1 Implement `discoverModules(rootHome)` with bounded fast-glob scan, ignore rules (`node_modules`, `.git`, gitignore), no descent past a found module, and module-name derivation from relative path +- [ ] 2.2 Parse optional `modules:` registry in root `config.yaml` and optional `name:` in module `config.yaml` (`src/core/project-config.ts`); error on duplicate module names +- [ ] 2.3 Emit hybrid discover/verify warnings: discovered-but-unregistered and registered-but-missing, both non-fatal +- [ ] 3.1 Add shared `--module ` option that resolves the named module's home and threads it through `new change`, `status`, `instructions`, `view`, and `archive`; unknown name errors with the discovered-name list +- [ ] 3.2 Aggregate root-level `list` across root + discovered modules with module labels; catch per-module load failures as warnings; keep module-level `list` scoped +- [ ] 4.1 Implement `loadLayeredStandards(home)` (root-first parent chain, module shadows root by tag) and use it for instructions output and `standards:` tag validation +- [ ] 4.2 Make archive fully home-local for features and change relocation (module store, module archive dir) +- [ ] 4.3 Update `materializeStandardLinks` to write reverse `## Implemented by` blocks into the standard's defining home with module-qualified entries; keep forward sidecars module-local +- [ ] 5.1 End-to-end test: monorepo fixture with root + two modules covering every scenario in `features/nested-planning-homes/` (resolution, discovery, addressing, aggregation, standards layering, feature store) +- [ ] 5.2 Backward-compat test: single-home repo produces byte-identical command output to current behavior diff --git a/src/core/archive.ts b/src/core/archive.ts index 76224fc..de1d572 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -6,6 +6,7 @@ import { Validator } from './validation/validator.js'; import chalk from 'chalk'; import { applyFeatures, materializeStandardLinks } from './features-apply.js'; import { readDeclaredStandardTags } from '../utils/change-metadata.js'; +import { resolveCurrentPlanningHomeSync } from './planning-home.js'; /** * Recursively copy a directory. Used when fs.rename fails (e.g. EPERM on Windows). @@ -47,9 +48,13 @@ async function moveDirectory(src: string, dest: string): Promise { export class ArchiveCommand { async execute( changeName?: string, - options: { yes?: boolean; skipFeatures?: boolean; noValidate?: boolean; validate?: boolean } = {} + options: { yes?: boolean; skipFeatures?: boolean; noValidate?: boolean; validate?: boolean; cwd?: string } = {} ): Promise { - const targetPath = '.'; + // Resolve the nearest planning home by walking up rather than assuming + // `.ratchet` sits directly under the cwd. This keeps archive consistent + // with the other commands when run inside a sub-module. + const planningHome = resolveCurrentPlanningHomeSync({ startPath: options.cwd ?? '.' }); + const targetPath = planningHome.root; const changesDir = path.join(targetPath, RATCHET_DIR_NAME, 'changes'); const archiveDir = path.join(changesDir, 'archive'); diff --git a/src/core/list.ts b/src/core/list.ts index e5449cf..4bcb8ed 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -3,6 +3,7 @@ import { RATCHET_DIR_NAME } from './config.js'; import path from 'path'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; import fg from 'fast-glob'; +import { resolveCurrentPlanningHomeSync } from './planning-home.js'; interface ChangeInfo { name: string; @@ -77,8 +78,14 @@ export class ListCommand { async execute(targetPath: string = '.', mode: 'changes' | 'specs' = 'changes', options: ListOptions = {}): Promise { const { sort = 'recent', json = false } = options; + // Resolve the nearest planning home by walking up from the target path, + // rather than assuming `.ratchet` lives directly under the cwd. This keeps + // list consistent with status/instructions, which already walk up. + const planningHome = resolveCurrentPlanningHomeSync({ startPath: targetPath }); + const homeRoot = planningHome.root; + if (mode === 'changes') { - const changesDir = path.join(targetPath, RATCHET_DIR_NAME, 'changes'); + const changesDir = path.join(homeRoot, RATCHET_DIR_NAME, 'changes'); // Check if changes directory exists try { @@ -151,7 +158,7 @@ export class ListCommand { } // specs mode → feature store, grouped by capability - const featuresDir = path.join(targetPath, RATCHET_DIR_NAME, 'features'); + const featuresDir = path.join(homeRoot, RATCHET_DIR_NAME, 'features'); try { await fs.access(featuresDir); } catch { diff --git a/src/core/view.ts b/src/core/view.ts index fdb5c9d..3ab4b2b 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -4,10 +4,14 @@ import * as path from 'path'; import chalk from 'chalk'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; import fg from 'fast-glob'; +import { resolveCurrentPlanningHomeSync } from './planning-home.js'; export class ViewCommand { async execute(targetPath: string = '.'): Promise { - const ratchetDir = path.join(targetPath, RATCHET_DIR_NAME); + // Walk up to the nearest planning home so the dashboard reflects the same + // `.ratchet` that status/list resolve, even from a subdirectory. + const planningHome = resolveCurrentPlanningHomeSync({ startPath: targetPath }); + const ratchetDir = path.join(planningHome.root, RATCHET_DIR_NAME); if (!fs.existsSync(ratchetDir)) { console.error(chalk.red('No ratchet directory found')); diff --git a/test/core/walk-up-resolution.test.ts b/test/core/walk-up-resolution.test.ts new file mode 100644 index 0000000..018df6a --- /dev/null +++ b/test/core/walk-up-resolution.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import * as fsSync from 'fs'; +import path from 'path'; +import os from 'os'; +import { ListCommand } from '../../src/core/list.js'; +import { ViewCommand } from '../../src/core/view.js'; +import { ArchiveCommand } from '../../src/core/archive.js'; +import { RATCHET_DIR_NAME } from '../../src/core/config.js'; + +/** + * Regression tests for task 1.1: list, view, and archive must resolve their + * `.ratchet` by walking up from where they are invoked (nearest-wins), not by + * joining `.ratchet` onto the current working directory. Running them from a + * subdirectory must still operate on the repo-root planning home. + */ +describe('list/view/archive obey walk-up resolution', () => { + let root: string; + let subDir: string; + let logOutput: string[]; + let logSpy: ReturnType; + + beforeEach(async () => { + const made = await fs.mkdtemp(path.join(os.tmpdir(), 'ratchet-walkup-')); + // Resolve symlinks (macOS /var -> /private/var) so path assertions match. + root = fsSync.realpathSync.native(made); + subDir = path.join(root, 'packages', 'api', 'src'); + await fs.mkdir(subDir, { recursive: true }); + + const changesDir = path.join(root, RATCHET_DIR_NAME, 'changes'); + await fs.mkdir(path.join(changesDir, 'root-change'), { recursive: true }); + await fs.writeFile( + path.join(changesDir, 'root-change', 'plan.md'), + '- [x] Task 1\n- [ ] Task 2\n', + 'utf-8' + ); + + logOutput = []; + logSpy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logOutput.push(args.join(' ')); + }); + }); + + afterEach(async () => { + logSpy.mockRestore(); + await fs.rm(root, { recursive: true, force: true }); + }); + + it('list resolves the repo-root .ratchet from a subdirectory', async () => { + await new ListCommand().execute(subDir, 'changes'); + expect(logOutput.some((line) => line.includes('root-change'))).toBe(true); + }); + + it('list does not read a non-existent .ratchet relative to the subdirectory', async () => { + // There is no .ratchet under subDir; walk-up must find the root one and + // succeed rather than throwing "No Ratchet changes directory found". + await expect(new ListCommand().execute(subDir, 'changes')).resolves.toBeUndefined(); + }); + + it('view resolves the repo-root .ratchet from a subdirectory', async () => { + await new ViewCommand().execute(subDir); + const output = logOutput.join('\n'); + expect(output).toContain('root-change'); + }); + + it('archive resolves the repo-root .ratchet from a subdirectory', async () => { + await new ArchiveCommand().execute('root-change', { yes: true, skipFeatures: true, cwd: subDir }); + const archiveDir = path.join(root, RATCHET_DIR_NAME, 'changes', 'archive'); + const archived = await fs.readdir(archiveDir); + expect(archived.some((name) => name.endsWith('root-change'))).toBe(true); + // Original change directory was moved out. + await expect( + fs.access(path.join(root, RATCHET_DIR_NAME, 'changes', 'root-change')) + ).rejects.toThrow(); + }); +}); From a736dcb22c96bb1865647c1dd39aab93161d990c Mon Sep 17 00:00:00 2001 From: joctaTorres Date: Wed, 10 Jun 2026 16:28:30 -0300 Subject: [PATCH 02/19] feat(planning-home): lazy parent resolution and module-name derivation 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 --- .../changes/nested-planning-homes/plan.md | 2 +- src/core/planning-home.ts | 87 +++++++++++++++++++ test/core/planning-home.test.ts | 63 ++++++++++++++ 3 files changed, 151 insertions(+), 1 deletion(-) diff --git a/.ratchet/changes/nested-planning-homes/plan.md b/.ratchet/changes/nested-planning-homes/plan.md index b50a0ac..673e29d 100644 --- a/.ratchet/changes/nested-planning-homes/plan.md +++ b/.ratchet/changes/nested-planning-homes/plan.md @@ -40,7 +40,7 @@ Ratchet currently assumes exactly one `.ratchet` directory per repository, resol ## Tasks - [x] 1.1 Route `archive`, `list`, and `view` through `resolveCurrentPlanningHomeSync()` (remove hardcoded `path.join('.', RATCHET_DIR_NAME, …)` in `src/core/archive.ts`, `src/core/list.ts`, `src/core/view.ts`); add regression tests that they resolve from a subdirectory -- [ ] 1.2 Extend `PlanningHome` with lazy `parent` resolution (continue walk-up past current root) and `moduleName`; keep single-home repos identical in behavior and output +- [x] 1.2 Extend `PlanningHome` with lazy `parent` resolution (continue walk-up past current root) and `moduleName`; keep single-home repos identical in behavior and output - [ ] 2.1 Implement `discoverModules(rootHome)` with bounded fast-glob scan, ignore rules (`node_modules`, `.git`, gitignore), no descent past a found module, and module-name derivation from relative path - [ ] 2.2 Parse optional `modules:` registry in root `config.yaml` and optional `name:` in module `config.yaml` (`src/core/project-config.ts`); error on duplicate module names - [ ] 2.3 Emit hybrid discover/verify warnings: discovered-but-unregistered and registered-but-missing, both non-fatal diff --git a/src/core/planning-home.ts b/src/core/planning-home.ts index 70e3a71..a00c062 100644 --- a/src/core/planning-home.ts +++ b/src/core/planning-home.ts @@ -11,6 +11,21 @@ export interface PlanningHome { root: string; changesDir: string; defaultSchema: string; + /** + * The enclosing planning home, if any. A home whose walk-up finds another + * `.ratchet` directory above it is a *module*; the topmost home is the + * *root*. Resolved lazily via {@link getParentPlanningHome} (continuing the + * walk-up past this home's root) so single-home repos pay nothing and keep + * identical behavior. `undefined` means "not yet resolved"; a resolved home + * with no enclosing home reports `null`. + */ + parent?: PlanningHome | null; + /** + * The module name for a home that has a parent: the POSIX-style relative path + * from the root home to this home, unless overridden by `name:` in the + * module's `config.yaml`. Undefined for root (parent-less) homes. + */ + moduleName?: string; } export interface ResolvePlanningHomeOptions { @@ -102,6 +117,78 @@ export function resolveCurrentPlanningHomeSync( return repoPlanningHome(FileSystemUtils.canonicalizeExistingPath(searchStart)); } +/** + * The POSIX-style relative path from a root home to a descendant home, used as + * the default module name (e.g. `packages/api`). + */ +export function relativeModulePath(rootRoot: string, moduleRoot: string): string { + const rel = relativePlanningPath(rootRoot, moduleRoot); + return rel.split(path.sep).join('/'); +} + +/** + * Lazily resolve the enclosing planning home by continuing the walk-up past the + * given home's root. Returns `null` when the home is the topmost `.ratchet` + * (i.e. the root). The result is memoized on `planningHome.parent`. + * + * Single-home repositories resolve to `null` here and gain no module behavior. + */ +export function getParentPlanningHome(planningHome: PlanningHome): PlanningHome | null { + if (planningHome.parent !== undefined) { + return planningHome.parent; + } + + const above = path.dirname(planningHome.root); + let parentRoot: string | null = null; + if (above !== planningHome.root) { + parentRoot = findRepoPlanningRootSync(above); + } + + const parent = parentRoot ? repoPlanningHome(parentRoot) : null; + planningHome.parent = parent; + return parent; +} + +/** + * Whether a home is a module (it has an enclosing planning home). Resolves the + * parent lazily as a side effect, so callers can rely on `moduleName` after. + */ +export function isModulePlanningHome(planningHome: PlanningHome): boolean { + return getParentPlanningHome(planningHome) !== null; +} + +/** + * The module name for a home: the POSIX-style relative path from the root home + * to this home, memoized on `planningHome.moduleName`. Returns `undefined` for + * a root (parent-less) home. A module's `config.yaml` `name:` override is + * applied by discovery (see `discoverModules`), not here. + */ +export function getModuleName(planningHome: PlanningHome): string | undefined { + if (planningHome.moduleName !== undefined) { + return planningHome.moduleName; + } + const root = getRootPlanningHome(planningHome); + if (root === planningHome) { + return undefined; + } + const name = relativeModulePath(root.root, planningHome.root); + planningHome.moduleName = name; + return name; +} + +/** + * Resolve the root (topmost) planning home for a given home by walking parents. + */ +export function getRootPlanningHome(planningHome: PlanningHome): PlanningHome { + let current = planningHome; + let parent = getParentPlanningHome(current); + while (parent) { + current = parent; + parent = getParentPlanningHome(current); + } + return current; +} + export function getChangeDir(planningHome: PlanningHome, changeName: string): string { return FileSystemUtils.joinPath(planningHome.changesDir, changeName); } diff --git a/test/core/planning-home.test.ts b/test/core/planning-home.test.ts index 2a91773..d2c4663 100644 --- a/test/core/planning-home.test.ts +++ b/test/core/planning-home.test.ts @@ -8,6 +8,10 @@ import { type PlanningHome, formatChangeLocation, getChangeDir, + getModuleName, + getParentPlanningHome, + getRootPlanningHome, + isModulePlanningHome, resolveCurrentPlanningHomeSync, } from '../../src/core/planning-home.js'; @@ -68,3 +72,62 @@ describe('planning home paths', () => { ).toThrow(/planning home/u); }); }); + +describe('nested planning homes', () => { + const tempDirs: string[] = []; + + function makeRepo(): string { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ratchet-nested-')); + tempDirs.push(tempDir); + return fs.realpathSync.native(tempDir); + } + + afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('a single-home repo resolves to a parent-less root with no module name', () => { + const root = makeRepo(); + fs.mkdirSync(path.join(root, '.ratchet', 'changes'), { recursive: true }); + + const home = resolveCurrentPlanningHomeSync({ startPath: root, allowImplicitRepoRoot: false }); + + expect(isModulePlanningHome(home)).toBe(false); + expect(getParentPlanningHome(home)).toBeNull(); + expect(getModuleName(home)).toBeUndefined(); + expect(getRootPlanningHome(home)).toBe(home); + }); + + it('a nested .ratchet resolves as a module whose parent is the root', () => { + const root = makeRepo(); + fs.mkdirSync(path.join(root, '.ratchet', 'changes'), { recursive: true }); + const moduleRoot = path.join(root, 'packages', 'api'); + fs.mkdirSync(path.join(moduleRoot, '.ratchet', 'changes'), { recursive: true }); + + const home = resolveCurrentPlanningHomeSync({ + startPath: path.join(moduleRoot, 'src'), + allowImplicitRepoRoot: false, + }); + + expect(home.root).toBe(moduleRoot); + expect(isModulePlanningHome(home)).toBe(true); + const parent = getParentPlanningHome(home); + expect(parent?.root).toBe(root); + expect(getModuleName(home)).toBe('packages/api'); + expect(getRootPlanningHome(home).root).toBe(root); + }); + + it('memoizes parent resolution on the home object', () => { + const root = makeRepo(); + fs.mkdirSync(path.join(root, '.ratchet', 'changes'), { recursive: true }); + const moduleRoot = path.join(root, 'mod'); + fs.mkdirSync(path.join(moduleRoot, '.ratchet', 'changes'), { recursive: true }); + + const home = resolveCurrentPlanningHomeSync({ startPath: moduleRoot, allowImplicitRepoRoot: false }); + const first = getParentPlanningHome(home); + expect(home.parent).toBe(first); + expect(getParentPlanningHome(home)).toBe(first); + }); +}); From f4ea39af70452c7c97eaf1c3a404c3f885468f05 Mon Sep 17 00:00:00 2001 From: joctaTorres Date: Wed, 10 Jun 2026 16:31:18 -0300 Subject: [PATCH 03/19] feat(planning-home): discover nested modules from root 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 --- .../changes/nested-planning-homes/plan.md | 4 +- src/core/module-discovery.ts | 155 ++++++++++++++++++ src/core/project-config.ts | 70 ++++++++ test/core/module-discovery.test.ts | 113 +++++++++++++ test/core/project-config.test.ts | 46 ++++++ 5 files changed, 386 insertions(+), 2 deletions(-) create mode 100644 src/core/module-discovery.ts create mode 100644 test/core/module-discovery.test.ts diff --git a/.ratchet/changes/nested-planning-homes/plan.md b/.ratchet/changes/nested-planning-homes/plan.md index 673e29d..f6bed1f 100644 --- a/.ratchet/changes/nested-planning-homes/plan.md +++ b/.ratchet/changes/nested-planning-homes/plan.md @@ -41,8 +41,8 @@ Ratchet currently assumes exactly one `.ratchet` directory per repository, resol - [x] 1.1 Route `archive`, `list`, and `view` through `resolveCurrentPlanningHomeSync()` (remove hardcoded `path.join('.', RATCHET_DIR_NAME, …)` in `src/core/archive.ts`, `src/core/list.ts`, `src/core/view.ts`); add regression tests that they resolve from a subdirectory - [x] 1.2 Extend `PlanningHome` with lazy `parent` resolution (continue walk-up past current root) and `moduleName`; keep single-home repos identical in behavior and output -- [ ] 2.1 Implement `discoverModules(rootHome)` with bounded fast-glob scan, ignore rules (`node_modules`, `.git`, gitignore), no descent past a found module, and module-name derivation from relative path -- [ ] 2.2 Parse optional `modules:` registry in root `config.yaml` and optional `name:` in module `config.yaml` (`src/core/project-config.ts`); error on duplicate module names +- [x] 2.1 Implement `discoverModules(rootHome)` with bounded fast-glob scan, ignore rules (`node_modules`, `.git`, gitignore), no descent past a found module, and module-name derivation from relative path +- [x] 2.2 Parse optional `modules:` registry in root `config.yaml` and optional `name:` in module `config.yaml` (`src/core/project-config.ts`); error on duplicate module names - [ ] 2.3 Emit hybrid discover/verify warnings: discovered-but-unregistered and registered-but-missing, both non-fatal - [ ] 3.1 Add shared `--module ` option that resolves the named module's home and threads it through `new change`, `status`, `instructions`, `view`, and `archive`; unknown name errors with the discovered-name list - [ ] 3.2 Aggregate root-level `list` across root + discovered modules with module labels; catch per-module load failures as warnings; keep module-level `list` scoped diff --git a/src/core/module-discovery.ts b/src/core/module-discovery.ts new file mode 100644 index 0000000..d4b08a3 --- /dev/null +++ b/src/core/module-discovery.ts @@ -0,0 +1,155 @@ +/** + * Module discovery for nested planning homes. + * + * The root planning home of a monorepo can contain nested `.ratchet` + * directories ("modules"). `discoverModules` scans the filesystem below the + * root for those nested homes so they are visible without manual registration. + * + * Discovery is the source of truth; an optional `modules:` registry in the root + * `config.yaml` only produces lint warnings (see `reconcileModuleRegistry`). + * + * Rules: + * - Bounded fast-glob scan for `*\/.ratchet` directories below the root. + * - Skip `node_modules`, `.git`, and gitignored paths. + * - Do not descend past a found module: a module's own nested homes are its + * business, not the root's (one level of parent/child per resolution). + * - Module name defaults to the POSIX relative path from root to module; a + * module `config.yaml` `name:` field overrides it. Duplicate names error. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import fg from 'fast-glob'; + +import { RATCHET_DIR_NAME } from './config.js'; +import { + type PlanningHome, + relativeModulePath, +} from './planning-home.js'; +import { readModuleName } from './project-config.js'; + +const DEFAULT_IGNORE = ['**/node_modules/**', '**/.git/**']; + +export interface DiscoveredModule { + /** The module's planning home. */ + home: PlanningHome; + /** Resolved module name (config `name:` override, else relative path). */ + moduleName: string; + /** POSIX relative path from root to module (the default name). */ + relativePath: string; +} + +function makeModuleHome(rootHome: PlanningHome, moduleRoot: string): PlanningHome { + return { + kind: 'repo', + root: moduleRoot, + changesDir: path.join(moduleRoot, RATCHET_DIR_NAME, 'changes'), + defaultSchema: rootHome.defaultSchema, + parent: rootHome, + }; +} + +/** + * Parse the root `.gitignore` (if present) into fast-glob ignore globs. This is + * 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[] { + let raw: string; + try { + raw = fs.readFileSync(path.join(rootDir, '.gitignore'), 'utf-8'); + } catch { + return []; + } + + const globs: string[] = []; + for (const lineRaw of raw.split(/\r?\n/)) { + const line = lineRaw.trim(); + if (line.length === 0 || line.startsWith('#')) continue; + if (line.startsWith('!')) continue; // negations not supported (best-effort) + // Strip trailing slash (directory marker) and any leading slash (anchor). + const cleaned = line.replace(/\/+$/, '').replace(/^\/+/, ''); + if (cleaned.length === 0) continue; + if (cleaned.includes('/')) { + globs.push(`**/${cleaned}/**`, `${cleaned}/**`); + } else { + globs.push(`**/${cleaned}/**`); + } + } + return globs; +} + +/** + * Discover nested planning homes below `rootHome` by filesystem scan. + * + * Returns modules sorted by their relative path. The `name:` override and + * duplicate-name detection are applied here so callers always receive resolved + * names. A duplicate module name throws. + */ +export async function discoverModules(rootHome: PlanningHome): Promise { + const rootDir = rootHome.root; + + let matches: string[] = []; + try { + matches = await fg(`**/${RATCHET_DIR_NAME}`, { + cwd: rootDir, + onlyDirectories: true, + dot: true, + followSymbolicLinks: false, + ignore: [...DEFAULT_IGNORE, ...gitignoreGlobs(rootDir)], + suppressErrors: true, + }); + } catch { + matches = []; + } + + // Each match is a `/.ratchet` directory; the module root is its + // 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('/'); + if (normalized === RATCHET_DIR_NAME) continue; // root home itself + const moduleRel = normalized.replace(new RegExp(`/?${RATCHET_DIR_NAME}$`), ''); + if (moduleRel.length === 0) continue; + moduleRoots.push(path.join(rootDir, moduleRel)); + } + + // Sort by depth then path so parents are seen before their descendants. + moduleRoots.sort((a, b) => a.localeCompare(b)); + + // Drop any module nested below an already-accepted module (no descent past a + // found module). + const accepted: string[] = []; + for (const moduleRoot of moduleRoots) { + const isNestedBelowAccepted = accepted.some( + (parent) => moduleRoot === parent || moduleRoot.startsWith(parent + path.sep) + ); + if (!isNestedBelowAccepted) { + accepted.push(moduleRoot); + } + } + + const modules: DiscoveredModule[] = []; + const seenNames = new Map(); // name -> first module relative path + for (const moduleRoot of accepted) { + const relativePath = relativeModulePath(rootDir, moduleRoot); + const override = readModuleName(moduleRoot); + const moduleName = override ?? relativePath; + + const existing = seenNames.get(moduleName); + if (existing !== undefined) { + throw new Error( + `Duplicate module name '${moduleName}' (used by '${existing}' and '${relativePath}'). ` + + `Module names must be unique; set a distinct 'name:' in the module's .ratchet/config.yaml.` + ); + } + seenNames.set(moduleName, relativePath); + + const home = makeModuleHome(rootHome, moduleRoot); + home.moduleName = moduleName; + modules.push({ home, moduleName, relativePath }); + } + + modules.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); + return modules; +} diff --git a/src/core/project-config.ts b/src/core/project-config.ts index dff45bf..3e90d8f 100644 --- a/src/core/project-config.ts +++ b/src/core/project-config.ts @@ -39,6 +39,22 @@ export const ProjectConfigSchema = z.object({ ) .optional() .describe('Per-artifact rules, keyed by artifact ID'), + + // Optional (root config only): registry of expected module paths, relative to + // the root. Filesystem discovery is the source of truth — this list only + // produces lint warnings for mismatches in either direction. + modules: z + .array(z.string()) + .optional() + .describe('Expected module paths relative to the root planning home (lint allowlist)'), + + // Optional (module config only): override for this module's name. Defaults to + // the module's path relative to the root. + name: z + .string() + .min(1) + .optional() + .describe("Override for this module's name (defaults to its relative path)"), }); export type ProjectConfig = z.infer; @@ -153,6 +169,32 @@ export function readProjectConfig(projectRoot: string): ProjectConfig | null { } } + // Parse modules registry (root config). Expect an array of non-empty + // strings; ignore anything else with a warning. + if (raw.modules !== undefined) { + const modulesResult = z.array(z.string()).safeParse(raw.modules); + if (modulesResult.success) { + const validModules = modulesResult.data + .map((m) => m.trim()) + .filter((m) => m.length > 0); + if (validModules.length > 0) { + config.modules = validModules; + } + } else { + console.warn(`Invalid 'modules' field in config (must be an array of strings)`); + } + } + + // Parse module name override (module config). + if (raw.name !== undefined) { + const nameResult = z.string().min(1).safeParse(typeof raw.name === 'string' ? raw.name.trim() : raw.name); + if (nameResult.success) { + config.name = nameResult.data; + } else { + console.warn(`Invalid 'name' field in config (must be a non-empty string)`); + } + } + // Return partial config even if some fields failed return Object.keys(config).length > 0 ? (config as ProjectConfig) : null; } catch (error) { @@ -263,3 +305,31 @@ export function suggestSchemas( return message; } + +/** + * Read a module's `name:` override from its `.ratchet/config.yaml`. Returns + * `undefined` when absent or unparseable, so callers fall back to the relative + * path. Never throws. + * + * @param moduleRoot - The module's planning-home root (parent of `.ratchet`). + */ +export function readModuleName(moduleRoot: string): string | undefined { + const config = readProjectConfig(moduleRoot); + const name = config?.name; + return typeof name === 'string' && name.trim().length > 0 ? name.trim() : undefined; +} + +/** + * Read the root config's `modules:` registry. Returns `undefined` when no + * registry is declared (so callers can distinguish "no registry" from "empty + * registry") and a normalized POSIX-style list otherwise. + * + * @param rootRoot - The root planning-home root (parent of `.ratchet`). + */ +export function readModuleRegistry(rootRoot: string): string[] | undefined { + const config = readProjectConfig(rootRoot); + if (!config?.modules) { + return undefined; + } + return config.modules.map((m) => m.split(path.sep).join('/').replace(/^\/+/, '').replace(/\/+$/, '')); +} diff --git a/test/core/module-discovery.test.ts b/test/core/module-discovery.test.ts new file mode 100644 index 0000000..3a9db98 --- /dev/null +++ b/test/core/module-discovery.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { discoverModules } from '../../src/core/module-discovery.js'; +import { resolveCurrentPlanningHomeSync } from '../../src/core/planning-home.js'; +import { RATCHET_DIR_NAME } from '../../src/core/config.js'; + +const tempDirs: string[] = []; + +function makeRepo(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ratchet-discover-')); + tempDirs.push(dir); + return fs.realpathSync.native(dir); +} + +function mkRatchet(root: string, rel: string, configBody?: string): void { + const moduleRoot = rel.length > 0 ? path.join(root, rel) : root; + fs.mkdirSync(path.join(moduleRoot, RATCHET_DIR_NAME, 'changes'), { recursive: true }); + if (configBody !== undefined) { + fs.writeFileSync(path.join(moduleRoot, RATCHET_DIR_NAME, 'config.yaml'), configBody, 'utf-8'); + } +} + +function rootHomeOf(root: string) { + return resolveCurrentPlanningHomeSync({ startPath: root, allowImplicitRepoRoot: false }); +} + +describe('discoverModules', () => { + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('discovers nested .ratchet directories by filesystem scan', async () => { + const root = makeRepo(); + mkRatchet(root, ''); + mkRatchet(root, 'packages/api'); + mkRatchet(root, 'packages/web'); + + const modules = await discoverModules(rootHomeOf(root)); + + expect(modules.map((m) => m.moduleName)).toEqual(['packages/api', 'packages/web']); + expect(modules[0].home.root).toBe(path.join(root, 'packages', 'api')); + }); + + it('defaults the module name to the path relative to the root', async () => { + const root = makeRepo(); + mkRatchet(root, ''); + mkRatchet(root, 'packages/api'); + + const [mod] = await discoverModules(rootHomeOf(root)); + expect(mod.moduleName).toBe('packages/api'); + expect(mod.relativePath).toBe('packages/api'); + }); + + it('honors a module name override from its config', async () => { + const root = makeRepo(); + mkRatchet(root, ''); + mkRatchet(root, 'packages/api', 'schema: ratchet\nname: api\n'); + + const [mod] = await discoverModules(rootHomeOf(root)); + expect(mod.moduleName).toBe('api'); + expect(mod.relativePath).toBe('packages/api'); + }); + + it('does not descend past a found module or into ignored directories', async () => { + const root = makeRepo(); + mkRatchet(root, ''); + mkRatchet(root, 'packages/api'); + // Nested home below a found module — must not be reported separately. + mkRatchet(root, 'packages/api/sub'); + // Stray .ratchet inside node_modules — must be ignored. + mkRatchet(root, 'node_modules/dep'); + + const modules = await discoverModules(rootHomeOf(root)); + const names = modules.map((m) => m.moduleName); + + expect(names).toContain('packages/api'); + expect(names).not.toContain('packages/api/sub'); + expect(names.some((n) => n.includes('node_modules'))).toBe(false); + }); + + it('skips gitignored directories', async () => { + const root = makeRepo(); + mkRatchet(root, ''); + mkRatchet(root, 'packages/api'); + mkRatchet(root, 'build/generated'); + fs.writeFileSync(path.join(root, '.gitignore'), 'build/\n', 'utf-8'); + + const names = (await discoverModules(rootHomeOf(root))).map((m) => m.moduleName); + expect(names).toEqual(['packages/api']); + }); + + it('errors on duplicate module names', async () => { + const root = makeRepo(); + mkRatchet(root, ''); + mkRatchet(root, 'packages/api', 'schema: ratchet\nname: shared\n'); + mkRatchet(root, 'packages/web', 'schema: ratchet\nname: shared\n'); + + await expect(discoverModules(rootHomeOf(root))).rejects.toThrow(/Duplicate module name 'shared'/); + }); + + it('returns no modules for a single-home repo', async () => { + const root = makeRepo(); + mkRatchet(root, ''); + + const modules = await discoverModules(rootHomeOf(root)); + expect(modules).toEqual([]); + }); +}); diff --git a/test/core/project-config.test.ts b/test/core/project-config.test.ts index 883576b..24fc19d 100644 --- a/test/core/project-config.test.ts +++ b/test/core/project-config.test.ts @@ -6,6 +6,8 @@ import { readProjectConfig, validateConfigRules, suggestSchemas, + readModuleName, + readModuleRegistry, } from '../../src/core/project-config.js'; describe('project-config', () => { @@ -607,4 +609,48 @@ rules: expect(message).toContain('Available schemas:'); }); }); + + describe('nested-planning-home fields', () => { + function writeConfig(root: string, body: string): void { + fs.mkdirSync(path.join(root, '.ratchet'), { recursive: true }); + fs.writeFileSync(path.join(root, '.ratchet', 'config.yaml'), body, 'utf-8'); + } + + it('parses a modules registry', () => { + writeConfig(tempDir, 'schema: ratchet\nmodules:\n - packages/api\n - packages/web\n'); + const config = readProjectConfig(tempDir); + expect(config?.modules).toEqual(['packages/api', 'packages/web']); + }); + + it('parses a module name override', () => { + writeConfig(tempDir, 'schema: ratchet\nname: api\n'); + const config = readProjectConfig(tempDir); + expect(config?.name).toBe('api'); + }); + + it('warns and ignores a non-array modules field', () => { + writeConfig(tempDir, 'schema: ratchet\nmodules: packages/api\n'); + const config = readProjectConfig(tempDir); + expect(config?.modules).toBeUndefined(); + expect(consoleWarnSpy).toHaveBeenCalled(); + }); + + it('readModuleName returns the override or undefined', () => { + writeConfig(tempDir, 'schema: ratchet\nname: api\n'); + expect(readModuleName(tempDir)).toBe('api'); + }); + + it('readModuleName returns undefined when no name is set', () => { + writeConfig(tempDir, 'schema: ratchet\n'); + expect(readModuleName(tempDir)).toBeUndefined(); + }); + + it('readModuleRegistry distinguishes no-registry from declared', () => { + writeConfig(tempDir, 'schema: ratchet\n'); + expect(readModuleRegistry(tempDir)).toBeUndefined(); + + writeConfig(tempDir, 'schema: ratchet\nmodules:\n - packages/api/\n'); + expect(readModuleRegistry(tempDir)).toEqual(['packages/api']); + }); + }); }); From a47dbf1036a89d9b6cad00672b4b2a3ad1b4067c Mon Sep 17 00:00:00 2001 From: joctaTorres Date: Wed, 10 Jun 2026 16:32:09 -0300 Subject: [PATCH 04/19] feat(planning-home): reconcile discovered modules against the registry 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 --- .../changes/nested-planning-homes/plan.md | 2 +- src/core/module-discovery.ts | 45 ++++++++++++++++- test/core/module-discovery.test.ts | 50 ++++++++++++++++++- 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/.ratchet/changes/nested-planning-homes/plan.md b/.ratchet/changes/nested-planning-homes/plan.md index f6bed1f..afe0e39 100644 --- a/.ratchet/changes/nested-planning-homes/plan.md +++ b/.ratchet/changes/nested-planning-homes/plan.md @@ -43,7 +43,7 @@ Ratchet currently assumes exactly one `.ratchet` directory per repository, resol - [x] 1.2 Extend `PlanningHome` with lazy `parent` resolution (continue walk-up past current root) and `moduleName`; keep single-home repos identical in behavior and output - [x] 2.1 Implement `discoverModules(rootHome)` with bounded fast-glob scan, ignore rules (`node_modules`, `.git`, gitignore), no descent past a found module, and module-name derivation from relative path - [x] 2.2 Parse optional `modules:` registry in root `config.yaml` and optional `name:` in module `config.yaml` (`src/core/project-config.ts`); error on duplicate module names -- [ ] 2.3 Emit hybrid discover/verify warnings: discovered-but-unregistered and registered-but-missing, both non-fatal +- [x] 2.3 Emit hybrid discover/verify warnings: discovered-but-unregistered and registered-but-missing, both non-fatal - [ ] 3.1 Add shared `--module ` option that resolves the named module's home and threads it through `new change`, `status`, `instructions`, `view`, and `archive`; unknown name errors with the discovered-name list - [ ] 3.2 Aggregate root-level `list` across root + discovered modules with module labels; catch per-module load failures as warnings; keep module-level `list` scoped - [ ] 4.1 Implement `loadLayeredStandards(home)` (root-first parent chain, module shadows root by tag) and use it for instructions output and `standards:` tag validation diff --git a/src/core/module-discovery.ts b/src/core/module-discovery.ts index d4b08a3..18675ff 100644 --- a/src/core/module-discovery.ts +++ b/src/core/module-discovery.ts @@ -26,7 +26,7 @@ import { type PlanningHome, relativeModulePath, } from './planning-home.js'; -import { readModuleName } from './project-config.js'; +import { readModuleName, readModuleRegistry } from './project-config.js'; const DEFAULT_IGNORE = ['**/node_modules/**', '**/.git/**']; @@ -153,3 +153,46 @@ export async function discoverModules(rootHome: PlanningHome): Promise a.relativePath.localeCompare(b.relativePath)); return modules; } + +/** + * Compare discovered modules against the root `modules:` registry and return + * lint warnings. The registry is an optional allowlist — discovery is always + * the source of truth, so every warning here is non-fatal: + * + * - discovered-but-unregistered: a nested `.ratchet` exists on disk but is not + * listed (only reported when a registry is declared at all). + * - registered-but-missing: a registry entry has no `.ratchet` on disk. + * + * Returns an empty list when no registry is declared, so single-home and + * unregistered monorepos produce no warnings. + */ +export function reconcileModuleRegistry( + rootHome: PlanningHome, + modules: DiscoveredModule[] +): string[] { + const registry = readModuleRegistry(rootHome.root); + if (registry === undefined) { + // No registry declared — nothing to lint against. + return []; + } + + const registered = new Set(registry); + const discoveredPaths = new Set(modules.map((m) => m.relativePath)); + const warnings: string[] = []; + + // Discovered but not registered. + for (const mod of modules) { + if (!registered.has(mod.relativePath)) { + warnings.push(`Module '${mod.relativePath}' is not registered in the root config 'modules:' list.`); + } + } + + // Registered but missing on disk. + for (const entry of registry) { + if (!discoveredPaths.has(entry)) { + warnings.push(`Registered module '${entry}' has no .ratchet directory on disk.`); + } + } + + return warnings; +} diff --git a/test/core/module-discovery.test.ts b/test/core/module-discovery.test.ts index 3a9db98..abd50be 100644 --- a/test/core/module-discovery.test.ts +++ b/test/core/module-discovery.test.ts @@ -3,7 +3,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { discoverModules } from '../../src/core/module-discovery.js'; +import { discoverModules, reconcileModuleRegistry } from '../../src/core/module-discovery.js'; import { resolveCurrentPlanningHomeSync } from '../../src/core/planning-home.js'; import { RATCHET_DIR_NAME } from '../../src/core/config.js'; @@ -111,3 +111,51 @@ describe('discoverModules', () => { expect(modules).toEqual([]); }); }); + +describe('reconcileModuleRegistry', () => { + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('produces no warnings when no registry is declared', async () => { + const root = makeRepo(); + mkRatchet(root, ''); + mkRatchet(root, 'packages/api'); + mkRatchet(root, 'packages/web'); + + const home = rootHomeOf(root); + const modules = await discoverModules(home); + expect(reconcileModuleRegistry(home, modules)).toEqual([]); + }); + + it('warns about a discovered module missing from the registry', async () => { + const root = makeRepo(); + mkRatchet(root, '', 'schema: ratchet\nmodules:\n - packages/api\n'); + mkRatchet(root, 'packages/api'); + mkRatchet(root, 'packages/web'); + + const home = rootHomeOf(root); + const modules = await discoverModules(home); + const warnings = reconcileModuleRegistry(home, modules); + + // packages/web is still discovered... + expect(modules.map((m) => m.relativePath)).toContain('packages/web'); + // ...and a warning calls it out as unregistered. + expect(warnings.some((w) => w.includes('packages/web') && w.includes('not registered'))).toBe(true); + expect(warnings.some((w) => w.includes('packages/api'))).toBe(false); + }); + + it('warns about a registered module missing on disk', async () => { + const root = makeRepo(); + mkRatchet(root, '', 'schema: ratchet\nmodules:\n - packages/api\n - packages/legacy\n'); + mkRatchet(root, 'packages/api'); + + const home = rootHomeOf(root); + const modules = await discoverModules(home); + const warnings = reconcileModuleRegistry(home, modules); + + expect(warnings.some((w) => w.includes('packages/legacy') && w.includes('no .ratchet'))).toBe(true); + }); +}); From bae91f673e331ccd824f7925bff93936ed669fe2 Mon Sep 17 00:00:00 2001 From: joctaTorres Date: Wed, 10 Jun 2026 16:35:28 -0300 Subject: [PATCH 05/19] feat(cli): add shared --module option to address modules from the root 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 --- .../changes/nested-planning-homes/plan.md | 2 +- src/cli/index.ts | 11 ++- src/commands/workflow/instructions.ts | 7 +- src/commands/workflow/new-change.ts | 5 +- src/commands/workflow/status.ts | 6 +- src/core/archive.ts | 13 +-- src/core/module-discovery.ts | 44 ++++++++++ src/core/view.ts | 10 ++- test/commands/module-addressing.test.ts | 83 +++++++++++++++++++ 9 files changed, 163 insertions(+), 18 deletions(-) create mode 100644 test/commands/module-addressing.test.ts diff --git a/.ratchet/changes/nested-planning-homes/plan.md b/.ratchet/changes/nested-planning-homes/plan.md index afe0e39..f052208 100644 --- a/.ratchet/changes/nested-planning-homes/plan.md +++ b/.ratchet/changes/nested-planning-homes/plan.md @@ -44,7 +44,7 @@ Ratchet currently assumes exactly one `.ratchet` directory per repository, resol - [x] 2.1 Implement `discoverModules(rootHome)` with bounded fast-glob scan, ignore rules (`node_modules`, `.git`, gitignore), no descent past a found module, and module-name derivation from relative path - [x] 2.2 Parse optional `modules:` registry in root `config.yaml` and optional `name:` in module `config.yaml` (`src/core/project-config.ts`); error on duplicate module names - [x] 2.3 Emit hybrid discover/verify warnings: discovered-but-unregistered and registered-but-missing, both non-fatal -- [ ] 3.1 Add shared `--module ` option that resolves the named module's home and threads it through `new change`, `status`, `instructions`, `view`, and `archive`; unknown name errors with the discovered-name list +- [x] 3.1 Add shared `--module ` option that resolves the named module's home and threads it through `new change`, `status`, `instructions`, `view`, and `archive`; unknown name errors with the discovered-name list - [ ] 3.2 Aggregate root-level `list` across root + discovered modules with module labels; catch per-module load failures as warnings; keep module-level `list` scoped - [ ] 4.1 Implement `loadLayeredStandards(home)` (root-first parent chain, module shadows root by tag) and use it for instructions output and `standards:` tag validation - [ ] 4.2 Make archive fully home-local for features and change relocation (module store, module archive dir) diff --git a/src/cli/index.ts b/src/cli/index.ts index 01a679a..c798693 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -199,10 +199,11 @@ program program .command('view') .description('Display an interactive dashboard of specs and changes') - .action(async () => { + .option('--module ', 'Target a nested module planning home by name') + .action(async (options?: { module?: string }) => { try { const viewCommand = new ViewCommand(); - await viewCommand.execute('.'); + await viewCommand.execute('.', { module: options?.module }); } catch (error) { console.log(); // Empty line for spacing ora().fail(`Error: ${(error as Error).message}`); @@ -216,7 +217,8 @@ program .option('-y, --yes', 'Skip confirmation prompts') .option('--skip-features', 'Skip feature store updates (useful for infrastructure, tooling, or doc-only changes)') .option('--no-validate', 'Skip validation (not recommended, requires confirmation)') - .action(async (changeName?: string, options?: { yes?: boolean; skipFeatures?: boolean; noValidate?: boolean; validate?: boolean }) => { + .option('--module ', 'Target a nested module planning home by name') + .action(async (changeName?: string, options?: { yes?: boolean; skipFeatures?: boolean; noValidate?: boolean; validate?: boolean; module?: string }) => { try { const archiveCommand = new ArchiveCommand(); await archiveCommand.execute(changeName, options); @@ -260,6 +262,7 @@ program .description('Display artifact completion status for a change') .option('--change ', 'Change name to show status for') .option('--schema ', 'Schema override (auto-detected from config.yaml)') + .option('--module ', 'Target a nested module planning home by name') .option('--json', 'Output as JSON') .action(async (options: StatusOptions) => { try { @@ -277,6 +280,7 @@ program .description('Output enriched instructions for creating an artifact or applying tasks') .option('--change ', 'Change name') .option('--schema ', 'Schema override (auto-detected from config.yaml)') + .option('--module ', 'Target a nested module planning home by name') .option('--json', 'Output as JSON') .action(async (artifactId: string | undefined, options: InstructionsOptions) => { try { @@ -315,6 +319,7 @@ newCmd .description('Create a new change directory') .option('--description ', 'Description to add to README.md') .option('--schema ', `Workflow schema to use (default: ${DEFAULT_SCHEMA})`) + .option('--module ', 'Target a nested module planning home by name') .option('--json', 'Output as JSON') .action(async (name: string, options: NewChangeOptions) => { try { diff --git a/src/commands/workflow/instructions.ts b/src/commands/workflow/instructions.ts index 9eac01e..eb789e3 100644 --- a/src/commands/workflow/instructions.ts +++ b/src/commands/workflow/instructions.ts @@ -16,6 +16,7 @@ import { type ArtifactInstructions, } from '../../core/artifact-graph/index.js'; import { getChangeDir, resolveCurrentPlanningHomeSync } from '../../core/planning-home.js'; +import { resolvePlanningHomeForCommand } from '../../core/module-discovery.js'; import { validateChangeExists, validateSchemaExists, @@ -31,12 +32,14 @@ export interface InstructionsOptions { change?: string; schema?: string; json?: boolean; + module?: string; } export interface ApplyInstructionsOptions { change?: string; schema?: string; json?: boolean; + module?: string; } // ----------------------------------------------------------------------------- @@ -50,7 +53,7 @@ export async function instructionsCommand( const spinner = options.json ? undefined : ora('Generating instructions...').start(); try { - const planningHome = resolveCurrentPlanningHomeSync(); + const planningHome = await resolvePlanningHomeForCommand({ module: options.module }); const projectRoot = planningHome.root; const changeName = await validateChangeExists( options.change, @@ -371,7 +374,7 @@ export async function applyInstructionsCommand(options: ApplyInstructionsOptions const spinner = options.json ? undefined : ora('Generating apply instructions...').start(); try { - const planningHome = resolveCurrentPlanningHomeSync(); + const planningHome = await resolvePlanningHomeForCommand({ module: options.module }); const projectRoot = planningHome.root; const changeName = await validateChangeExists( options.change, diff --git a/src/commands/workflow/new-change.ts b/src/commands/workflow/new-change.ts index 240377d..f60ef06 100644 --- a/src/commands/workflow/new-change.ts +++ b/src/commands/workflow/new-change.ts @@ -9,9 +9,9 @@ import path from 'path'; import { createChange, validateChangeName } from '../../utils/change-utils.js'; import { formatChangeLocation, - resolveCurrentPlanningHomeSync, type PlanningHome, } from '../../core/planning-home.js'; +import { resolvePlanningHomeForCommand } from '../../core/module-discovery.js'; import { validateSchemaExists } from './shared.js'; // ----------------------------------------------------------------------------- @@ -22,6 +22,7 @@ export interface NewChangeOptions { description?: string; schema?: string; json?: boolean; + module?: string; } interface NewChangeOutput { @@ -90,7 +91,7 @@ export async function newChangeCommand(name: string | undefined, options: NewCha throw new Error(validation.error); } - const planningHome = resolveCurrentPlanningHomeSync(); + const planningHome = await resolvePlanningHomeForCommand({ module: options.module }); const projectRoot = planningHome.root; // Validate schema if provided diff --git a/src/commands/workflow/status.ts b/src/commands/workflow/status.ts index 557f28f..f699157 100644 --- a/src/commands/workflow/status.ts +++ b/src/commands/workflow/status.ts @@ -6,7 +6,8 @@ import ora from 'ora'; import chalk from 'chalk'; -import { resolveCurrentPlanningHomeSync, getChangeDir } from '../../core/planning-home.js'; +import { getChangeDir } from '../../core/planning-home.js'; +import { resolvePlanningHomeForCommand } from '../../core/module-discovery.js'; import { loadChangeContext, formatChangeStatus, @@ -28,6 +29,7 @@ export interface StatusOptions { change?: string; schema?: string; json?: boolean; + module?: string; } // ----------------------------------------------------------------------------- @@ -38,7 +40,7 @@ export async function statusCommand(options: StatusOptions): Promise { const spinner = options.json ? undefined : ora('Loading change status...').start(); try { - const planningHome = resolveCurrentPlanningHomeSync(); + const planningHome = await resolvePlanningHomeForCommand({ module: options.module }); const projectRoot = planningHome.root; // Handle no-changes case gracefully — status is informational, diff --git a/src/core/archive.ts b/src/core/archive.ts index de1d572..54c62f9 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -7,6 +7,7 @@ import chalk from 'chalk'; import { applyFeatures, materializeStandardLinks } from './features-apply.js'; import { readDeclaredStandardTags } from '../utils/change-metadata.js'; import { resolveCurrentPlanningHomeSync } from './planning-home.js'; +import { resolvePlanningHomeForCommand } from './module-discovery.js'; /** * Recursively copy a directory. Used when fs.rename fails (e.g. EPERM on Windows). @@ -48,12 +49,14 @@ async function moveDirectory(src: string, dest: string): Promise { export class ArchiveCommand { async execute( changeName?: string, - options: { yes?: boolean; skipFeatures?: boolean; noValidate?: boolean; validate?: boolean; cwd?: string } = {} + options: { yes?: boolean; skipFeatures?: boolean; noValidate?: boolean; validate?: boolean; cwd?: string; module?: string } = {} ): Promise { - // Resolve the nearest planning home by walking up rather than assuming - // `.ratchet` sits directly under the cwd. This keeps archive consistent - // with the other commands when run inside a sub-module. - const planningHome = resolveCurrentPlanningHomeSync({ startPath: options.cwd ?? '.' }); + // Resolve the planning home. Without `--module` this walks up from the cwd + // (nearest-wins); with `--module` it addresses the named module from the + // root. Either way the rest of archive operates on the resolved home. + const planningHome = options.module + ? await resolvePlanningHomeForCommand({ module: options.module, startPath: options.cwd ?? '.' }) + : resolveCurrentPlanningHomeSync({ startPath: options.cwd ?? '.' }); const targetPath = planningHome.root; const changesDir = path.join(targetPath, RATCHET_DIR_NAME, 'changes'); const archiveDir = path.join(changesDir, 'archive'); diff --git a/src/core/module-discovery.ts b/src/core/module-discovery.ts index 18675ff..d3c0582 100644 --- a/src/core/module-discovery.ts +++ b/src/core/module-discovery.ts @@ -24,7 +24,10 @@ import fg from 'fast-glob'; import { RATCHET_DIR_NAME } from './config.js'; import { type PlanningHome, + getRootPlanningHome, relativeModulePath, + resolveCurrentPlanningHomeSync, + type ResolvePlanningHomeOptions, } from './planning-home.js'; import { readModuleName, readModuleRegistry } from './project-config.js'; @@ -196,3 +199,44 @@ export function reconcileModuleRegistry( return warnings; } + +export interface ResolveCommandHomeOptions extends ResolvePlanningHomeOptions { + /** Module name to target (from the shared `--module` flag). */ + module?: string; +} + +/** + * Resolve the planning home a change-scoped command should operate on. + * + * Without `--module`, this is the nearest-wins home (today's behavior). With + * `--module `, it resolves the *root* home from cwd, runs discovery, and + * substitutes the matched module's home — so a module can be addressed from + * anywhere in the repo. An unknown name throws with the discovered-name list. + */ +export async function resolvePlanningHomeForCommand( + options: ResolveCommandHomeOptions = {} +): Promise { + const { module: moduleName, ...resolveOptions } = options; + const nearest = resolveCurrentPlanningHomeSync(resolveOptions); + + if (!moduleName) { + return nearest; + } + + // Address a module from the root: discovery is rooted at the topmost home. + const rootHome = getRootPlanningHome(nearest); + const modules = await discoverModules(rootHome); + const match = modules.find((m) => m.moduleName === moduleName); + + if (!match) { + const known = + modules.length > 0 + ? modules.map((m) => m.moduleName).join(', ') + : '(none discovered)'; + throw new Error( + `Unknown module '${moduleName}'. Discovered modules: ${known}` + ); + } + + return match.home; +} diff --git a/src/core/view.ts b/src/core/view.ts index 3ab4b2b..4318384 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -5,12 +5,16 @@ import chalk from 'chalk'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; import fg from 'fast-glob'; import { resolveCurrentPlanningHomeSync } from './planning-home.js'; +import { resolvePlanningHomeForCommand } from './module-discovery.js'; export class ViewCommand { - async execute(targetPath: string = '.'): Promise { + async execute(targetPath: string = '.', options: { module?: string } = {}): Promise { // Walk up to the nearest planning home so the dashboard reflects the same - // `.ratchet` that status/list resolve, even from a subdirectory. - const planningHome = resolveCurrentPlanningHomeSync({ startPath: targetPath }); + // `.ratchet` that status/list resolve, even from a subdirectory. With + // `--module` it addresses the named module from the root instead. + const planningHome = options.module + ? await resolvePlanningHomeForCommand({ module: options.module, startPath: targetPath }) + : resolveCurrentPlanningHomeSync({ startPath: targetPath }); const ratchetDir = path.join(planningHome.root, RATCHET_DIR_NAME); if (!fs.existsSync(ratchetDir)) { diff --git a/test/commands/module-addressing.test.ts b/test/commands/module-addressing.test.ts new file mode 100644 index 0000000..efa393d --- /dev/null +++ b/test/commands/module-addressing.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import * as fsSync from 'fs'; +import path from 'path'; +import os from 'os'; +import { newChangeCommand } from '../../src/commands/workflow/new-change.js'; +import { statusCommand } from '../../src/commands/workflow/status.js'; +import { RATCHET_DIR_NAME } from '../../src/core/config.js'; + +async function captureLog(fn: () => Promise): Promise { + const lines: string[] = []; + const spy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }); + try { + await fn(); + } finally { + spy.mockRestore(); + } + return lines.join('\n'); +} + +async function captureJson(fn: () => Promise): Promise { + const out = await captureLog(fn); + const start = out.indexOf('{'); + expect(start).toBeGreaterThanOrEqual(0); + return JSON.parse(out.slice(start)); +} + +/** Scaffold a `.ratchet` home with config at `/`. */ +async function makeHome(root: string, rel: string, configBody: string): Promise { + const home = rel.length > 0 ? path.join(root, rel) : root; + await fs.mkdir(path.join(home, RATCHET_DIR_NAME, 'changes'), { recursive: true }); + await fs.writeFile(path.join(home, RATCHET_DIR_NAME, 'config.yaml'), configBody, 'utf-8'); +} + +describe('--module addressing from the root', () => { + let root: string; + let cwd: string; + + beforeEach(async () => { + const made = await fs.mkdtemp(path.join(os.tmpdir(), 'ratchet-module-addr-')); + root = fsSync.realpathSync.native(made); + await makeHome(root, '', 'schema: ratchet\n'); + await makeHome(root, 'packages/api', 'schema: ratchet\nname: api\n'); + cwd = process.cwd(); + process.chdir(root); + }); + + afterEach(async () => { + process.chdir(cwd); + await fs.rm(root, { recursive: true, force: true }); + }); + + it('creates a change inside a module from the root', async () => { + await captureLog(() => newChangeCommand('add-auth', { json: true, module: 'api' })); + const changeDir = path.join(root, 'packages', 'api', RATCHET_DIR_NAME, 'changes', 'add-auth'); + await expect(fs.access(path.join(changeDir, '.ratchet.yaml'))).resolves.toBeUndefined(); + }); + + it('reports status of a module change with the module planning home', async () => { + await captureLog(() => newChangeCommand('add-auth', { json: true, module: 'api' })); + const status = await captureJson(() => + statusCommand({ change: 'add-auth', json: true, module: 'api' }) + ); + expect(status.planningHome.root).toBe(path.join(root, 'packages', 'api')); + expect(status.changeRoot).toBe( + path.join(root, 'packages', 'api', RATCHET_DIR_NAME, 'changes', 'add-auth') + ); + }); + + it('fails for an unknown module, listing the discovered names', async () => { + await expect( + statusCommand({ change: 'add-auth', json: true, module: 'billing' }) + ).rejects.toThrow(/Unknown module 'billing'.*api/s); + }); + + it('omitting --module keeps nearest-wins behavior at the root', async () => { + await captureLog(() => newChangeCommand('root-change', { json: true })); + const status = await captureJson(() => statusCommand({ change: 'root-change', json: true })); + expect(status.planningHome.root).toBe(root); + }); +}); From da2fb682b5bdeaa2ff88a6c0fef80da9cf4bcc4a Mon Sep 17 00:00:00 2001 From: joctaTorres Date: Wed, 10 Jun 2026 16:37:49 -0300 Subject: [PATCH 06/19] feat(list): aggregate root list across root and discovered modules 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 --- .../changes/nested-planning-homes/plan.md | 2 +- src/core/list.ts | 110 +++++++++++++----- src/core/project-config.ts | 28 +++++ test/core/list-aggregation.test.ts | 98 ++++++++++++++++ 4 files changed, 210 insertions(+), 28 deletions(-) create mode 100644 test/core/list-aggregation.test.ts diff --git a/.ratchet/changes/nested-planning-homes/plan.md b/.ratchet/changes/nested-planning-homes/plan.md index f052208..a879041 100644 --- a/.ratchet/changes/nested-planning-homes/plan.md +++ b/.ratchet/changes/nested-planning-homes/plan.md @@ -45,7 +45,7 @@ Ratchet currently assumes exactly one `.ratchet` directory per repository, resol - [x] 2.2 Parse optional `modules:` registry in root `config.yaml` and optional `name:` in module `config.yaml` (`src/core/project-config.ts`); error on duplicate module names - [x] 2.3 Emit hybrid discover/verify warnings: discovered-but-unregistered and registered-but-missing, both non-fatal - [x] 3.1 Add shared `--module ` option that resolves the named module's home and threads it through `new change`, `status`, `instructions`, `view`, and `archive`; unknown name errors with the discovered-name list -- [ ] 3.2 Aggregate root-level `list` across root + discovered modules with module labels; catch per-module load failures as warnings; keep module-level `list` scoped +- [x] 3.2 Aggregate root-level `list` across root + discovered modules with module labels; catch per-module load failures as warnings; keep module-level `list` scoped - [ ] 4.1 Implement `loadLayeredStandards(home)` (root-first parent chain, module shadows root by tag) and use it for instructions output and `standards:` tag validation - [ ] 4.2 Make archive fully home-local for features and change relocation (module store, module archive dir) - [ ] 4.3 Update `materializeStandardLinks` to write reverse `## Implemented by` blocks into the standard's defining home with module-qualified entries; keep forward sidecars module-local diff --git a/src/core/list.ts b/src/core/list.ts index 4bcb8ed..139bed0 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -3,13 +3,17 @@ import { RATCHET_DIR_NAME } from './config.js'; import path from 'path'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; import fg from 'fast-glob'; -import { resolveCurrentPlanningHomeSync } from './planning-home.js'; +import { getParentPlanningHome, resolveCurrentPlanningHomeSync } from './planning-home.js'; +import { discoverModules, reconcileModuleRegistry } from './module-discovery.js'; +import { configLoadError } from './project-config.js'; interface ChangeInfo { name: string; completedTasks: number; totalTasks: number; lastModified: Date; + /** Module name when the change belongs to a nested module; undefined for root. */ + module?: string; } interface ListOptions { @@ -74,6 +78,40 @@ function formatRelativeTime(date: Date): string { } } +/** + * Collect active changes (excluding `archive`) for a single home's changes dir. + * `module` tags each row when listing a nested module. Returns `null` when the + * changes directory does not exist (so callers can distinguish missing from + * empty). + */ +async function collectChanges(changesDir: string, module?: string): Promise { + try { + await fs.access(changesDir); + } catch { + return null; + } + + const entries = await fs.readdir(changesDir, { withFileTypes: true }); + const changeDirs = entries + .filter(entry => entry.isDirectory() && entry.name !== 'archive') + .map(entry => entry.name); + + const changes: ChangeInfo[] = []; + for (const changeDir of changeDirs) { + const progress = await getTaskProgressForChange(changesDir, changeDir); + const changePath = path.join(changesDir, changeDir); + const lastModified = await getLastModified(changePath); + changes.push({ + name: changeDir, + completedTasks: progress.completed, + totalTasks: progress.total, + lastModified, + ...(module ? { module } : {}), + }); + } + return changes; +} + export class ListCommand { async execute(targetPath: string = '.', mode: 'changes' | 'specs' = 'changes', options: ListOptions = {}): Promise { const { sort = 'recent', json = false } = options; @@ -87,20 +125,51 @@ export class ListCommand { if (mode === 'changes') { const changesDir = path.join(homeRoot, RATCHET_DIR_NAME, 'changes'); - // Check if changes directory exists - try { - await fs.access(changesDir); - } catch { + const rootChanges = await collectChanges(changesDir); + if (rootChanges === null) { throw new Error("No Ratchet changes directory found. Run 'ratchet init' first."); } - // Get all directories in changes (excluding archive) - const entries = await fs.readdir(changesDir, { withFileTypes: true }); - const changeDirs = entries - .filter(entry => entry.isDirectory() && entry.name !== 'archive') - .map(entry => entry.name); + // Root-level aggregation: when this home is itself the root (no enclosing + // home), fold in changes from every discovered module, labeled by module. + // Module-level list (a home with a parent) stays scoped to itself. + const changes: ChangeInfo[] = [...rootChanges]; + const isRootHome = getParentPlanningHome(planningHome) === null; + if (isRootHome) { + let modules: Awaited> = []; + try { + modules = await discoverModules(planningHome); + } catch (error) { + console.warn(`Module discovery failed: ${(error as Error).message}`); + modules = []; + } - if (changeDirs.length === 0) { + // Surface registry lint warnings (discovered-but-unregistered, + // registered-but-missing). Non-fatal. + for (const warning of reconcileModuleRegistry(planningHome, modules)) { + console.warn(warning); + } + + for (const mod of modules) { + // A module with an unparseable config degrades to a warning; one + // broken module must not blind the whole repo. + const loadError = configLoadError(mod.home.root); + if (loadError) { + console.warn(`Module '${mod.moduleName}' could not be loaded: ${loadError}`); + continue; + } + try { + const moduleChanges = await collectChanges(mod.home.changesDir, mod.moduleName); + if (moduleChanges) { + changes.push(...moduleChanges); + } + } catch (error) { + console.warn(`Module '${mod.moduleName}' could not be loaded: ${(error as Error).message}`); + } + } + } + + if (changes.length === 0) { if (json) { console.log(JSON.stringify({ changes: [] })); } else { @@ -109,21 +178,6 @@ export class ListCommand { return; } - // Collect information about each change - const changes: ChangeInfo[] = []; - - for (const changeDir of changeDirs) { - const progress = await getTaskProgressForChange(changesDir, changeDir); - const changePath = path.join(changesDir, changeDir); - const lastModified = await getLastModified(changePath); - changes.push({ - name: changeDir, - completedTasks: progress.completed, - totalTasks: progress.total, - lastModified - }); - } - // Sort by preference (default: recent first) if (sort === 'recent') { changes.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); @@ -135,6 +189,7 @@ export class ListCommand { if (json) { const jsonOutput = changes.map(c => ({ name: c.name, + ...(c.module ? { module: c.module } : {}), completedTasks: c.completedTasks, totalTasks: c.totalTasks, lastModified: c.lastModified.toISOString(), @@ -152,7 +207,8 @@ export class ListCommand { const paddedName = change.name.padEnd(nameWidth); const status = formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks }); const timeAgo = formatRelativeTime(change.lastModified); - console.log(`${padding}${paddedName} ${status.padEnd(12)} ${timeAgo}`); + const label = change.module ? ` [${change.module}]` : ''; + console.log(`${padding}${paddedName} ${status.padEnd(12)} ${timeAgo}${label}`); } return; } diff --git a/src/core/project-config.ts b/src/core/project-config.ts index 3e90d8f..2d90b99 100644 --- a/src/core/project-config.ts +++ b/src/core/project-config.ts @@ -306,6 +306,34 @@ export function suggestSchemas( return message; } +/** + * Detect whether a home's `.ratchet/config.yaml` exists but cannot be parsed + * into a YAML object. Returns an error message in that case, else `undefined`. + * Used by root aggregation to degrade a broken module to a warning rather than + * failing the whole listing. + * + * @param homeRoot - The planning-home root (parent of `.ratchet`). + */ +export function configLoadError(homeRoot: string): string | undefined { + let configPath = path.join(homeRoot, RATCHET_DIR_NAME, 'config.yaml'); + if (!existsSync(configPath)) { + configPath = path.join(homeRoot, RATCHET_DIR_NAME, 'config.yml'); + if (!existsSync(configPath)) { + return undefined; // No config is fine. + } + } + try { + const content = readFileSync(configPath, 'utf-8'); + const raw = parseYaml(content); + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return 'config.yaml is not a valid YAML object'; + } + } catch (error) { + return `config.yaml could not be parsed: ${error instanceof Error ? error.message : String(error)}`; + } + return undefined; +} + /** * Read a module's `name:` override from its `.ratchet/config.yaml`. Returns * `undefined` when absent or unparseable, so callers fall back to the relative diff --git a/test/core/list-aggregation.test.ts b/test/core/list-aggregation.test.ts new file mode 100644 index 0000000..417ef0a --- /dev/null +++ b/test/core/list-aggregation.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import * as fsSync from 'fs'; +import path from 'path'; +import os from 'os'; +import { ListCommand } from '../../src/core/list.js'; +import { RATCHET_DIR_NAME } from '../../src/core/config.js'; + +let logOutput: string[]; +let warnOutput: string[]; +let logSpy: ReturnType; +let warnSpy: ReturnType; + +async function makeHome(root: string, rel: string, configBody: string): Promise { + const home = rel.length > 0 ? path.join(root, rel) : root; + await fs.mkdir(path.join(home, RATCHET_DIR_NAME, 'changes'), { recursive: true }); + await fs.writeFile(path.join(home, RATCHET_DIR_NAME, 'config.yaml'), configBody, 'utf-8'); +} + +async function makeChange(root: string, rel: string, name: string): Promise { + const home = rel.length > 0 ? path.join(root, rel) : root; + const changeDir = path.join(home, RATCHET_DIR_NAME, 'changes', name); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'plan.md'), '- [ ] do it\n', 'utf-8'); +} + +describe('root-level list aggregation', () => { + let root: string; + + beforeEach(async () => { + const made = await fs.mkdtemp(path.join(os.tmpdir(), 'ratchet-list-agg-')); + root = fsSync.realpathSync.native(made); + logOutput = []; + warnOutput = []; + logSpy = vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => logOutput.push(a.join(' '))); + warnSpy = vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => warnOutput.push(a.join(' '))); + }); + + afterEach(async () => { + logSpy.mockRestore(); + warnSpy.mockRestore(); + await fs.rm(root, { recursive: true, force: true }); + }); + + it('shows root and module changes labeled by module', async () => { + await makeHome(root, '', 'schema: ratchet\n'); + await makeHome(root, 'packages/api', 'schema: ratchet\nname: api\n'); + await makeHome(root, 'packages/web', 'schema: ratchet\nname: web\n'); + await makeChange(root, '', 'upgrade-ci'); + await makeChange(root, 'packages/api', 'add-auth'); + await makeChange(root, 'packages/web', 'dark-mode'); + + await new ListCommand().execute(root, 'changes', { json: true }); + const out = JSON.parse(logOutput.join('\n')); + const byName = Object.fromEntries(out.changes.map((c: any) => [c.name, c.module])); + + expect(byName['upgrade-ci']).toBeUndefined(); // root: no module label + expect(byName['add-auth']).toBe('api'); + expect(byName['dark-mode']).toBe('web'); + }); + + it('keeps module-level list scoped to the module', async () => { + await makeHome(root, '', 'schema: ratchet\n'); + await makeHome(root, 'packages/api', 'schema: ratchet\nname: api\n'); + await makeChange(root, '', 'upgrade-ci'); + await makeChange(root, 'packages/api', 'add-auth'); + + await new ListCommand().execute(path.join(root, 'packages', 'api', 'src'), 'changes', { json: true }); + const out = JSON.parse(logOutput.join('\n')); + const names = out.changes.map((c: any) => c.name); + + expect(names).toContain('add-auth'); + expect(names).not.toContain('upgrade-ci'); + }); + + it('degrades a broken module config to a warning, not a failure', async () => { + await makeHome(root, '', 'schema: ratchet\n'); + await makeHome(root, 'packages/api', 'schema: ratchet\nname: api\n'); + await makeHome(root, 'packages/web', 'schema: ratchet\nname: web\n'); + await makeChange(root, 'packages/web', 'dark-mode'); + // Corrupt api's config with unparseable YAML. + await fs.writeFile( + path.join(root, 'packages', 'api', RATCHET_DIR_NAME, 'config.yaml'), + ': : : not valid yaml : :\n', + 'utf-8' + ); + + await expect( + new ListCommand().execute(root, 'changes', { json: true }) + ).resolves.toBeUndefined(); + const out = JSON.parse(logOutput.join('\n')); + const names = out.changes.map((c: any) => c.name); + // The healthy module still shows up... + expect(names).toContain('dark-mode'); + // ...and the broken module is surfaced as a warning. + expect(warnOutput.some((w) => w.includes('packages/api') && w.includes('could not be loaded'))).toBe(true); + }); +}); From 549bd2829ae2f0e1801142ca970cbfa3b5249740 Mon Sep 17 00:00:00 2001 From: joctaTorres Date: Wed, 10 Jun 2026 16:40:08 -0300 Subject: [PATCH 07/19] feat(standards): layer standards across the parent chain 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 --- .../changes/nested-planning-homes/plan.md | 2 +- src/core/artifact-graph/instruction-loader.ts | 13 ++- src/core/standards.ts | 33 +++++++ src/core/validation/validator.ts | 23 ++++- test/core/standards-layering.test.ts | 88 +++++++++++++++++++ test/core/validation.standards.test.ts | 21 +++++ 6 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 test/core/standards-layering.test.ts diff --git a/.ratchet/changes/nested-planning-homes/plan.md b/.ratchet/changes/nested-planning-homes/plan.md index a879041..8432433 100644 --- a/.ratchet/changes/nested-planning-homes/plan.md +++ b/.ratchet/changes/nested-planning-homes/plan.md @@ -46,7 +46,7 @@ Ratchet currently assumes exactly one `.ratchet` directory per repository, resol - [x] 2.3 Emit hybrid discover/verify warnings: discovered-but-unregistered and registered-but-missing, both non-fatal - [x] 3.1 Add shared `--module ` option that resolves the named module's home and threads it through `new change`, `status`, `instructions`, `view`, and `archive`; unknown name errors with the discovered-name list - [x] 3.2 Aggregate root-level `list` across root + discovered modules with module labels; catch per-module load failures as warnings; keep module-level `list` scoped -- [ ] 4.1 Implement `loadLayeredStandards(home)` (root-first parent chain, module shadows root by tag) and use it for instructions output and `standards:` tag validation +- [x] 4.1 Implement `loadLayeredStandards(home)` (root-first parent chain, module shadows root by tag) and use it for instructions output and `standards:` tag validation - [ ] 4.2 Make archive fully home-local for features and change relocation (module store, module archive dir) - [ ] 4.3 Update `materializeStandardLinks` to write reverse `## Implemented by` blocks into the standard's defining home with module-qualified entries; keep forward sidecars module-local - [ ] 5.1 End-to-end test: monorepo fixture with root + two modules covering every scenario in `features/nested-planning-homes/` (resolution, discovery, addressing, aggregation, standards layering, feature store) diff --git a/src/core/artifact-graph/instruction-loader.ts b/src/core/artifact-graph/instruction-loader.ts index 0491eb9..e71dad6 100644 --- a/src/core/artifact-graph/instruction-loader.ts +++ b/src/core/artifact-graph/instruction-loader.ts @@ -17,7 +17,7 @@ import { type PlanningHomeSummary, } from '../change-status-policy.js'; import { readProjectConfig, validateConfigRules } from '../project-config.js'; -import { loadStandards, type StandardDoc } from '../standards.js'; +import { loadStandards, loadLayeredStandards, type StandardDoc } from '../standards.js'; import type { PlanningHome } from '../planning-home.js'; import type { ChangeMetadata } from '../change-metadata/index.js'; import type { Artifact, CompletedSet } from './types.js'; @@ -321,7 +321,16 @@ export function generateInstructions( // standards into the artifact. This is the per-artifact path that only propose // consumes; the shared apply/verify path (generateApplyInstructions) never carries // standards, so apply's payload provably stays free of them. - const loadedStandards = effectiveProjectRoot ? loadStandards(effectiveProjectRoot) : []; + // + // When the change's planning home is a nested module, layer the parent chain + // root-first so module changes see inherited root standards plus their own + // (module wins on tag collision). A root home layers to exactly its own + // standards, keeping single-home behavior identical. + const loadedStandards = context.planningHome + ? loadLayeredStandards(context.planningHome) + : effectiveProjectRoot + ? loadStandards(effectiveProjectRoot) + : []; const standards = loadedStandards.length > 0 ? loadedStandards : undefined; return { diff --git a/src/core/standards.ts b/src/core/standards.ts index 09853aa..3ddc300 100644 --- a/src/core/standards.ts +++ b/src/core/standards.ts @@ -15,6 +15,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as yaml from 'yaml'; import { RATCHET_DIR_NAME } from './config.js'; +import { getParentPlanningHome, type PlanningHome } from './planning-home.js'; /** * A single standard document from the standards library. @@ -110,3 +111,35 @@ export function loadStandards(projectRoot: string): StandardDoc[] { }; }); } + +/** + * Load the standards visible to a planning home, layering the parent chain + * root-first and letting nearer homes shadow farther ones by `tag`. + * + * For a root (parent-less) home this is exactly `loadStandards(home.root)`, so + * single-home repos and root changes behave identically to today and never see + * a module's standards. For a module home, root standards are loaded first and + * the module's own standards are applied last, so a module standard with a + * colliding tag wins by whole-document replacement (no merge). + * + * @returns Layered standards, sorted by `tag` for deterministic output. + */ +export function loadLayeredStandards(home: PlanningHome): StandardDoc[] { + // Build the chain from this home up to the root. + const chain: PlanningHome[] = []; + let current: PlanningHome | null = home; + while (current) { + chain.push(current); + current = getParentPlanningHome(current); + } + + // Apply root-first so nearer homes (earlier in `chain`) win on tag collision. + const byTag = new Map(); + for (const node of chain.reverse()) { + for (const standard of loadStandards(node.root)) { + byTag.set(standard.tag, standard); + } + } + + return [...byTag.values()].sort((a, b) => a.tag.localeCompare(b.tag)); +} diff --git a/src/core/validation/validator.ts b/src/core/validation/validator.ts index 04cb727..d135b82 100644 --- a/src/core/validation/validator.ts +++ b/src/core/validation/validator.ts @@ -15,7 +15,8 @@ import { MAX_WHY_SECTION_LENGTH, VALIDATION_MESSAGES } from './constants.js'; -import { loadStandards } from '../standards.js'; +import { loadStandards, loadLayeredStandards } from '../standards.js'; +import { resolveCurrentPlanningHomeSync } from '../planning-home.js'; import { readDeclaredStandardTags } from '../../utils/change-metadata.js'; export class Validator { @@ -302,7 +303,8 @@ export class Validator { const root = projectRoot ?? path.resolve(changeDir, '../../..'); const issues: ValidationIssue[] = []; - // Resolve every standard's tag (explicit or file-name fallback). + // Duplicate-tag detection is scoped to the change's own home library — tag + // uniqueness is a per-library invariant, not a cross-home one. const standards = loadStandards(root); const seenTags = new Set(); const reportedDuplicates = new Set(); @@ -321,9 +323,24 @@ export class Validator { } } + // Resolution of a change's declared tags validates against the *layered* + // set (the home's own standards plus any inherited from parent homes), so a + // module change may declare a root-defined tag. For a root home the layered + // set equals its own library, keeping single-home behavior identical. + const resolvableTags = new Set(seenTags); + try { + const home = resolveCurrentPlanningHomeSync({ startPath: root, allowImplicitRepoRoot: false }); + for (const standard of loadLayeredStandards(home)) { + resolvableTags.add(standard.tag); + } + } catch { + // No resolvable home (e.g. a bare change dir in tests) — fall back to the + // home-local set already collected above. + } + // Check that every tag the change references resolves to a standard. for (const tag of readDeclaredStandardTags(changeDir)) { - if (!seenTags.has(tag)) { + if (!resolvableTags.has(tag)) { issues.push({ level: 'ERROR', path: 'standards', diff --git a/test/core/standards-layering.test.ts b/test/core/standards-layering.test.ts new file mode 100644 index 0000000..7eafafa --- /dev/null +++ b/test/core/standards-layering.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { loadLayeredStandards } from '../../src/core/standards.js'; +import { resolveCurrentPlanningHomeSync } from '../../src/core/planning-home.js'; +import { RATCHET_DIR_NAME } from '../../src/core/config.js'; + +const tempDirs: string[] = []; + +function makeRepo(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ratchet-layer-')); + tempDirs.push(dir); + return fs.realpathSync.native(dir); +} + +function writeStandard(homeRoot: string, fileName: string, content: string): void { + const dir = path.join(homeRoot, RATCHET_DIR_NAME, 'standards'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, fileName), content, 'utf-8'); +} + +function makeHome(homeRoot: string): void { + fs.mkdirSync(path.join(homeRoot, RATCHET_DIR_NAME, 'changes'), { recursive: true }); +} + +function homeAt(start: string) { + return resolveCurrentPlanningHomeSync({ startPath: start, allowImplicitRepoRoot: false }); +} + +describe('loadLayeredStandards', () => { + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('a module sees inherited root standards', () => { + const root = makeRepo(); + makeHome(root); + writeStandard(root, 'testing.md', '---\ntag: testing\n---\nroot testing\n'); + const moduleRoot = path.join(root, 'packages', 'api'); + makeHome(moduleRoot); + + const tags = loadLayeredStandards(homeAt(moduleRoot)).map((s) => s.tag); + expect(tags).toContain('testing'); + }); + + it('module standards add on top of root standards', () => { + const root = makeRepo(); + makeHome(root); + writeStandard(root, 'testing.md', '---\ntag: testing\n---\nroot testing\n'); + const moduleRoot = path.join(root, 'packages', 'api'); + makeHome(moduleRoot); + writeStandard(moduleRoot, 'api-versioning.md', '---\ntag: api-versioning\n---\napi versioning\n'); + + const tags = loadLayeredStandards(homeAt(moduleRoot)).map((s) => s.tag); + expect(tags).toContain('testing'); + expect(tags).toContain('api-versioning'); + }); + + it('a module standard shadows a root standard on tag collision', () => { + const root = makeRepo(); + makeHome(root); + writeStandard(root, 'testing.md', '---\ntag: testing\n---\nroot version\n'); + const moduleRoot = path.join(root, 'packages', 'api'); + makeHome(moduleRoot); + writeStandard(moduleRoot, 'testing.md', '---\ntag: testing\n---\napi version\n'); + + const testing = loadLayeredStandards(homeAt(moduleRoot)).filter((s) => s.tag === 'testing'); + expect(testing).toHaveLength(1); + expect(testing[0].content).toContain('api version'); + expect(testing[0].content).not.toContain('root version'); + }); + + it('a root change sees only root standards', () => { + const root = makeRepo(); + makeHome(root); + writeStandard(root, 'testing.md', '---\ntag: testing\n---\nroot testing\n'); + const moduleRoot = path.join(root, 'packages', 'api'); + makeHome(moduleRoot); + writeStandard(moduleRoot, 'api-versioning.md', '---\ntag: api-versioning\n---\napi versioning\n'); + + const tags = loadLayeredStandards(homeAt(root)).map((s) => s.tag); + expect(tags).toContain('testing'); + expect(tags).not.toContain('api-versioning'); + }); +}); diff --git a/test/core/validation.standards.test.ts b/test/core/validation.standards.test.ts index 5c2c5a8..74e3345 100644 --- a/test/core/validation.standards.test.ts +++ b/test/core/validation.standards.test.ts @@ -105,4 +105,25 @@ describe('Validator.validateStandards', () => { const report = new Validator().validateStandards(dir, root); expect(report.valid).toBe(true); }); + + it('validates a module change tag against the layered (inherited) set', async () => { + // Root defines "testing"; the module declares it without redefining it. + await writeStandard('testing.md', 'testing'); + await fs.mkdir(path.join(root, RATCHET_DIR_NAME, 'changes'), { recursive: true }); + + const moduleRoot = path.join(root, 'packages', 'api'); + await fs.mkdir(path.join(moduleRoot, RATCHET_DIR_NAME, 'changes'), { recursive: true }); + const moduleChangeDir = path.join(moduleRoot, RATCHET_DIR_NAME, 'changes', 'add-auth'); + await writeFile( + path.join(moduleChangeDir, '.ratchet.yaml'), + 'schema: ratchet\nstandards:\n - testing\n' + ); + + // Resolve projectRoot from the module change dir (../../.. = module root). + const report = new Validator().validateStandards(moduleChangeDir); + expect(report.valid).toBe(true); + expect( + report.issues.some((i) => /Unknown standard tag "testing"/.test(i.message)) + ).toBe(false); + }); }); From 6d87871ba1a79413e2d88fe706382f4d7b19746e Mon Sep 17 00:00:00 2001 From: joctaTorres Date: Wed, 10 Jun 2026 16:41:02 -0300 Subject: [PATCH 08/19] test(archive): verify module changes archive into the module store 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 --- .../changes/nested-planning-homes/plan.md | 2 +- test/core/module-feature-store.test.ts | 87 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 test/core/module-feature-store.test.ts diff --git a/.ratchet/changes/nested-planning-homes/plan.md b/.ratchet/changes/nested-planning-homes/plan.md index 8432433..746a355 100644 --- a/.ratchet/changes/nested-planning-homes/plan.md +++ b/.ratchet/changes/nested-planning-homes/plan.md @@ -47,7 +47,7 @@ Ratchet currently assumes exactly one `.ratchet` directory per repository, resol - [x] 3.1 Add shared `--module ` option that resolves the named module's home and threads it through `new change`, `status`, `instructions`, `view`, and `archive`; unknown name errors with the discovered-name list - [x] 3.2 Aggregate root-level `list` across root + discovered modules with module labels; catch per-module load failures as warnings; keep module-level `list` scoped - [x] 4.1 Implement `loadLayeredStandards(home)` (root-first parent chain, module shadows root by tag) and use it for instructions output and `standards:` tag validation -- [ ] 4.2 Make archive fully home-local for features and change relocation (module store, module archive dir) +- [x] 4.2 Make archive fully home-local for features and change relocation (module store, module archive dir) - [ ] 4.3 Update `materializeStandardLinks` to write reverse `## Implemented by` blocks into the standard's defining home with module-qualified entries; keep forward sidecars module-local - [ ] 5.1 End-to-end test: monorepo fixture with root + two modules covering every scenario in `features/nested-planning-homes/` (resolution, discovery, addressing, aggregation, standards layering, feature store) - [ ] 5.2 Backward-compat test: single-home repo produces byte-identical command output to current behavior diff --git a/test/core/module-feature-store.test.ts b/test/core/module-feature-store.test.ts new file mode 100644 index 0000000..afdea91 --- /dev/null +++ b/test/core/module-feature-store.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import * as fsSync from 'fs'; +import path from 'path'; +import os from 'os'; +import { ArchiveCommand } from '../../src/core/archive.js'; +import { RATCHET_DIR_NAME } from '../../src/core/config.js'; + +const FEATURE = `Feature: Login + Scenario: ok + Given a user + When they log in + Then they are in +`; + +async function writeFile(file: string, content: string): Promise { + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, content, 'utf-8'); +} + +async function makeHome(root: string, rel: string, configBody: string): Promise { + const home = rel.length > 0 ? path.join(root, rel) : root; + await fs.mkdir(path.join(home, RATCHET_DIR_NAME, 'changes'), { recursive: true }); + await fs.writeFile(path.join(home, RATCHET_DIR_NAME, 'config.yaml'), configBody, 'utf-8'); +} + +async function scaffoldChange(homeRoot: string, name: string, rel: string): Promise { + const changeDir = path.join(homeRoot, RATCHET_DIR_NAME, 'changes', name); + await writeFile(path.join(changeDir, '.ratchet.yaml'), 'schema: ratchet\n'); + await writeFile(path.join(changeDir, 'features', rel), FEATURE); + await writeFile(path.join(changeDir, 'plan.md'), '- [x] done\n'); +} + +describe('module-local feature stores on archive', () => { + let root: string; + let logSpy: ReturnType; + + beforeEach(async () => { + const made = await fs.mkdtemp(path.join(os.tmpdir(), 'ratchet-mod-store-')); + root = fsSync.realpathSync.native(made); + await makeHome(root, '', 'schema: ratchet\n'); + await makeHome(root, 'packages/api', 'schema: ratchet\nname: api\n'); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(async () => { + logSpy.mockRestore(); + await fs.rm(root, { recursive: true, force: true }); + }); + + it('archives a module change into the module store, leaving the root store untouched', async () => { + const moduleRoot = path.join(root, 'packages', 'api'); + await scaffoldChange(moduleRoot, 'add-auth', 'auth/login.feature'); + + await new ArchiveCommand().execute('add-auth', { yes: true, module: 'api', cwd: root }); + + // Feature materialized into the module store. + await expect( + fs.access(path.join(moduleRoot, RATCHET_DIR_NAME, 'features', 'auth', 'login.feature')) + ).resolves.toBeUndefined(); + + // Root store does not contain it. + await expect( + fs.access(path.join(root, RATCHET_DIR_NAME, 'features', 'auth', 'login.feature')) + ).rejects.toThrow(); + + // Change moved to the module's archive dir. + const archiveDir = path.join(moduleRoot, RATCHET_DIR_NAME, 'changes', 'archive'); + const archived = await fs.readdir(archiveDir); + expect(archived.some((n) => n.endsWith('add-auth'))).toBe(true); + }); + + it('archives a root change into the root store, leaving module stores untouched', async () => { + await scaffoldChange(root, 'upgrade-ci', 'ci/pipeline.feature'); + + await new ArchiveCommand().execute('upgrade-ci', { yes: true, cwd: root }); + + await expect( + fs.access(path.join(root, RATCHET_DIR_NAME, 'features', 'ci', 'pipeline.feature')) + ).resolves.toBeUndefined(); + + // No module feature store was created. + await expect( + fs.access(path.join(root, 'packages', 'api', RATCHET_DIR_NAME, 'features')) + ).rejects.toThrow(); + }); +}); From c480178a805c1561775329b816e5c394a0af2170 Mon Sep 17 00:00:00 2001 From: joctaTorres Date: Wed, 10 Jun 2026 16:43:26 -0300 Subject: [PATCH 09/19] feat(features): regenerate reverse standard links in the defining home 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 : /. 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 --- .../changes/nested-planning-homes/plan.md | 2 +- src/core/archive.ts | 2 +- src/core/features-apply.ts | 108 ++++++++++++++++-- test/core/module-feature-store.test.ts | 59 ++++++++++ 4 files changed, 158 insertions(+), 13 deletions(-) diff --git a/.ratchet/changes/nested-planning-homes/plan.md b/.ratchet/changes/nested-planning-homes/plan.md index 746a355..2fec150 100644 --- a/.ratchet/changes/nested-planning-homes/plan.md +++ b/.ratchet/changes/nested-planning-homes/plan.md @@ -48,6 +48,6 @@ Ratchet currently assumes exactly one `.ratchet` directory per repository, resol - [x] 3.2 Aggregate root-level `list` across root + discovered modules with module labels; catch per-module load failures as warnings; keep module-level `list` scoped - [x] 4.1 Implement `loadLayeredStandards(home)` (root-first parent chain, module shadows root by tag) and use it for instructions output and `standards:` tag validation - [x] 4.2 Make archive fully home-local for features and change relocation (module store, module archive dir) -- [ ] 4.3 Update `materializeStandardLinks` to write reverse `## Implemented by` blocks into the standard's defining home with module-qualified entries; keep forward sidecars module-local +- [x] 4.3 Update `materializeStandardLinks` to write reverse `## Implemented by` blocks into the standard's defining home with module-qualified entries; keep forward sidecars module-local - [ ] 5.1 End-to-end test: monorepo fixture with root + two modules covering every scenario in `features/nested-planning-homes/` (resolution, discovery, addressing, aggregation, standards layering, feature store) - [ ] 5.2 Backward-compat test: single-home repo produces byte-identical command output to current behavior diff --git a/src/core/archive.ts b/src/core/archive.ts index 54c62f9..ee1b809 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -243,7 +243,7 @@ export class ArchiveCommand { // the standards. A change that declares no standards is a no-op here. const tags = readDeclaredStandardTags(changeDir); if (tags.length > 0) { - await materializeStandardLinks(targetPath, changeName, tags); + await materializeStandardLinks(targetPath, changeName, tags, planningHome); console.log(`Standard links materialized for: ${tags.join(', ')}`); } } diff --git a/src/core/features-apply.ts b/src/core/features-apply.ts index 90b9660..956bbf0 100644 --- a/src/core/features-apply.ts +++ b/src/core/features-apply.ts @@ -16,6 +16,12 @@ import fg from 'fast-glob'; import * as yaml from 'yaml'; import { RATCHET_DIR_NAME } from './config.js'; import { getStandardsDir, loadStandards } from './standards.js'; +import { + type PlanningHome, + getParentPlanningHome, + getRootPlanningHome, +} from './planning-home.js'; +import { discoverModules } from './module-discovery.js'; // ----------------------------------------------------------------------------- // Types @@ -461,24 +467,68 @@ async function regenerateReverseLinks( } } +function storeDirFor(homeRoot: string): string { + return path.join(homeRoot, RATCHET_DIR_NAME, FEATURES_SUBDIR); +} + +/** + * Build a reverse index across a set of homes, qualifying each implementing + * feature with the owning module name (`: /`); root + * features stay unqualified. This is the source for layered reverse links so a + * root standard's `## Implemented by` block can list features from any module. + */ +async function buildQualifiedReverseIndex( + homes: Array<{ storeDir: string; moduleName?: string }> +): Promise> { + const index = new Map>(); + for (const home of homes) { + const local = await buildReverseIndex(home.storeDir); + for (const [tag, features] of local) { + let set = index.get(tag); + if (!set) { + set = new Set(); + index.set(tag, set); + } + for (const feature of features) { + set.add(home.moduleName ? `${home.moduleName}: ${feature}` : feature); + } + } + } + + const result = new Map(); + for (const [tag, set] of index) { + result.set(tag, [...set].sort((a, b) => a.localeCompare(b))); + } + return result; +} + /** * Materialize a change's standard links into the permanent store. Runs after * `applyFeatures` (store + tombstones already applied) and before the change is * moved to the archive. * - * - Forward link: writes/updates the per-capability sidecar - * `.ratchet/features//.ratchet.yaml`, mapping each feature file to - * the change's declared `tags`; tombstoned features are removed. - * - Reverse link: regenerates the `## Implemented by` block in every standard by - * scanning all sidecars, so the reverse link never goes stale. + * - Forward link: writes/updates the per-capability sidecar in the change's own + * home store, mapping each feature file to the change's declared `tags`; + * tombstoned features are removed. Always module-local. + * - Reverse link: regenerates the `## Implemented by` block in the standard's + * *defining* home. For a single-home repo this is just that repo's standards. + * When `home` is provided and is a module (or a root with modules), the + * reverse index spans the root and every module, qualifying module features + * by module name, and each standard is regenerated in the home that defines + * it — so an inherited root standard collects module features in the root + * file, while a module-local standard stays within the module. * - * When the change declares no standards (`tags` empty), this is a no-op: no - * sidecar is written and no standard file is touched. + * When the change declares no standards (`tags` empty), this is a no-op. + * + * @param root - The change's home root (parent of `.ratchet`). + * @param home - The resolved planning home, when nesting is in play. Omit for + * the legacy single-home path (reverse links scoped to `root`). */ export async function materializeStandardLinks( root: string, changeName: string, - tags: string[] + tags: string[], + home?: PlanningHome ): Promise { if (tags.length === 0) { // A change with no declared standards must not touch the store links. @@ -486,13 +536,49 @@ export async function materializeStandardLinks( } const changeDir = path.join(root, RATCHET_DIR_NAME, 'changes', changeName); - const storeDir = path.join(root, RATCHET_DIR_NAME, FEATURES_SUBDIR); + const storeDir = storeDirFor(root); const updates = await findFeatureUpdates(changeDir, storeDir); const tombstones = await readTombstones(changeDir); + // Forward links are always written into the change's own home store. await updateForwardLinks(storeDir, updates, tombstones, tags); - const reverse = await buildReverseIndex(storeDir); - await regenerateReverseLinks(root, reverse); + // Reverse links: regenerate the `## Implemented by` block in each standard's + // 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; + const rootHome = home ? getRootPlanningHome(home) : undefined; + const modules = rootHome && (isNested || home === rootHome) + ? await discoverModules(rootHome) + : []; + + if (!rootHome || modules.length === 0) { + // Single-home (or no discovered modules): pure projection of root's store. + const reverse = await buildReverseIndex(storeDir); + await regenerateReverseLinks(root, reverse); + return; + } + + // Every home that could define a standard: the root and every module. + const allHomes: Array<{ root: string; moduleName?: string }> = [ + { root: rootHome.root }, + ...modules.map((m) => ({ root: m.home.root, moduleName: m.moduleName })), + ]; + + // Regenerate each home's standards from a reverse index built relative to + // that home: the home's own features are listed unqualified, while features + // contributed by *other* modules are qualified by their module name. This + // keeps a module-local standard's entries local, while an inherited root + // standard collects module features qualified by module name. + for (const defining of allHomes) { + const homesForIndex = allHomes.map((h) => ({ + storeDir: storeDirFor(h.root), + // Unqualified when the contributing home is the one we're regenerating. + moduleName: h.root === defining.root ? undefined : h.moduleName, + })); + const reverse = await buildQualifiedReverseIndex(homesForIndex); + await regenerateReverseLinks(defining.root, reverse); + } } diff --git a/test/core/module-feature-store.test.ts b/test/core/module-feature-store.test.ts index afdea91..59b6637 100644 --- a/test/core/module-feature-store.test.ts +++ b/test/core/module-feature-store.test.ts @@ -84,4 +84,63 @@ describe('module-local feature stores on archive', () => { fs.access(path.join(root, 'packages', 'api', RATCHET_DIR_NAME, 'features')) ).rejects.toThrow(); }); + + it('writes an inherited standard reverse link into the root, qualified by module name', async () => { + const moduleRoot = path.join(root, 'packages', 'api'); + // Root defines the "testing" standard. + await writeFile( + path.join(root, RATCHET_DIR_NAME, 'standards', 'testing.md'), + '---\ntag: testing\n---\n\n# Testing\n' + ); + // Module change declares ["testing"]. + await scaffoldChange(moduleRoot, 'add-auth', 'auth/login.feature'); + await writeFile( + path.join(moduleRoot, RATCHET_DIR_NAME, 'changes', 'add-auth', '.ratchet.yaml'), + 'schema: ratchet\nstandards:\n - testing\n' + ); + + await new ArchiveCommand().execute('add-auth', { yes: true, module: 'api', cwd: root }); + + // Forward sidecar lives in the module store. + await expect( + fs.access(path.join(moduleRoot, RATCHET_DIR_NAME, 'features', 'auth', '.ratchet.yaml')) + ).resolves.toBeUndefined(); + + // Reverse link is regenerated in the root standard, qualified by module. + const rootStandard = await fs.readFile( + path.join(root, RATCHET_DIR_NAME, 'standards', 'testing.md'), + 'utf-8' + ); + expect(rootStandard).toContain('## Implemented by'); + expect(rootStandard).toContain('api: auth/login.feature'); + }); + + it('keeps a module-local standard reverse link within the module', async () => { + const moduleRoot = path.join(root, 'packages', 'api'); + // Module defines its own "api-versioning" standard. + await writeFile( + path.join(moduleRoot, RATCHET_DIR_NAME, 'standards', 'api-versioning.md'), + '---\ntag: api-versioning\n---\n\n# API Versioning\n' + ); + await scaffoldChange(moduleRoot, 'add-auth', 'auth/login.feature'); + await writeFile( + path.join(moduleRoot, RATCHET_DIR_NAME, 'changes', 'add-auth', '.ratchet.yaml'), + 'schema: ratchet\nstandards:\n - api-versioning\n' + ); + + await new ArchiveCommand().execute('add-auth', { yes: true, module: 'api', cwd: root }); + + const moduleStandard = await fs.readFile( + path.join(moduleRoot, RATCHET_DIR_NAME, 'standards', 'api-versioning.md'), + 'utf-8' + ); + expect(moduleStandard).toContain('## Implemented by'); + // Within the module the feature is not module-qualified. + expect(moduleStandard).toContain('auth/login.feature'); + + // No standard file exists or is created under the root .ratchet. + await expect( + fs.access(path.join(root, RATCHET_DIR_NAME, 'standards')) + ).rejects.toThrow(); + }); }); From 667ecd31bde6013d0a2d48d9254f77986c42ef32 Mon Sep 17 00:00:00 2001 From: joctaTorres Date: Wed, 10 Jun 2026 16:44:31 -0300 Subject: [PATCH 10/19] test(e2e): monorepo fixture covering nested-planning-home scenarios 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 --- .../changes/nested-planning-homes/plan.md | 2 +- test/cli-e2e/nested-planning-homes.test.ts | 122 ++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 test/cli-e2e/nested-planning-homes.test.ts diff --git a/.ratchet/changes/nested-planning-homes/plan.md b/.ratchet/changes/nested-planning-homes/plan.md index 2fec150..08fc66a 100644 --- a/.ratchet/changes/nested-planning-homes/plan.md +++ b/.ratchet/changes/nested-planning-homes/plan.md @@ -49,5 +49,5 @@ Ratchet currently assumes exactly one `.ratchet` directory per repository, resol - [x] 4.1 Implement `loadLayeredStandards(home)` (root-first parent chain, module shadows root by tag) and use it for instructions output and `standards:` tag validation - [x] 4.2 Make archive fully home-local for features and change relocation (module store, module archive dir) - [x] 4.3 Update `materializeStandardLinks` to write reverse `## Implemented by` blocks into the standard's defining home with module-qualified entries; keep forward sidecars module-local -- [ ] 5.1 End-to-end test: monorepo fixture with root + two modules covering every scenario in `features/nested-planning-homes/` (resolution, discovery, addressing, aggregation, standards layering, feature store) +- [x] 5.1 End-to-end test: monorepo fixture with root + two modules covering every scenario in `features/nested-planning-homes/` (resolution, discovery, addressing, aggregation, standards layering, feature store) - [ ] 5.2 Backward-compat test: single-home repo produces byte-identical command output to current behavior diff --git a/test/cli-e2e/nested-planning-homes.test.ts b/test/cli-e2e/nested-planning-homes.test.ts new file mode 100644 index 0000000..4e61dd8 --- /dev/null +++ b/test/cli-e2e/nested-planning-homes.test.ts @@ -0,0 +1,122 @@ +import { afterAll, beforeAll, describe, it, expect } from 'vitest'; +import { promises as fs } from 'fs'; +import * as fsSync from 'fs'; +import path from 'path'; +import { tmpdir } from 'os'; +import { runCLI } from '../helpers/run-cli.js'; +import { RATCHET_DIR_NAME } from '../../src/core/config.js'; + +/** + * End-to-end coverage for nested planning homes against the built CLI binary. + * A single monorepo fixture (root + two modules "api" and "web") backs the + * resolution, discovery, addressing, aggregation, standards-layering, and + * feature-store scenarios from features/nested-planning-homes/. + */ + +const tempRoots: string[] = []; +let repo: string; + +async function makeHome(root: string, rel: string, configBody: string): Promise { + const home = rel.length > 0 ? path.join(root, rel) : root; + await fs.mkdir(path.join(home, RATCHET_DIR_NAME, 'changes'), { recursive: true }); + await fs.writeFile(path.join(home, RATCHET_DIR_NAME, 'config.yaml'), configBody, 'utf-8'); +} + +async function makeChange(homeRoot: string, name: string): Promise { + const dir = path.join(homeRoot, RATCHET_DIR_NAME, 'changes', name); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, '.ratchet.yaml'), 'schema: ratchet\n', 'utf-8'); + await fs.writeFile(path.join(dir, 'plan.md'), '- [ ] do it\n', 'utf-8'); +} + +beforeAll(async () => { + const base = await fs.mkdtemp(path.join(tmpdir(), 'ratchet-nested-e2e-')); + tempRoots.push(base); + repo = fsSync.realpathSync.native(base); + + await makeHome(repo, '', 'schema: ratchet\n'); + await makeHome(repo, 'packages/api', 'schema: ratchet\nname: api\n'); + await makeHome(repo, 'packages/web', 'schema: ratchet\nname: web\n'); + + await makeChange(repo, 'upgrade-ci'); + await makeChange(path.join(repo, 'packages', 'api'), 'add-auth'); + await makeChange(path.join(repo, 'packages', 'web'), 'dark-mode'); +}); + +afterAll(async () => { + await Promise.all(tempRoots.map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe('nested planning homes (CLI e2e)', () => { + it('resolution: a command run inside a module resolves the module home', async () => { + const cwd = path.join(repo, 'packages', 'api', 'src'); + await fs.mkdir(cwd, { recursive: true }); + const result = await runCLI(['status', '--change', 'add-auth', '--json'], { cwd }); + expect(result.exitCode).toBe(0); + const status = JSON.parse(result.stdout); + expect(status.planningHome.root).toBe(path.join(repo, 'packages', 'api')); + }); + + it('resolution: a command at the root resolves the root home', async () => { + const result = await runCLI(['status', '--change', 'upgrade-ci', '--json'], { cwd: repo }); + expect(result.exitCode).toBe(0); + const status = JSON.parse(result.stdout); + expect(status.planningHome.root).toBe(repo); + }); + + it('aggregation: root list includes root and module changes labeled by module', async () => { + const result = await runCLI(['list', '--json'], { cwd: repo }); + expect(result.exitCode).toBe(0); + const out = JSON.parse(result.stdout); + const byName = Object.fromEntries(out.changes.map((c: any) => [c.name, c.module])); + expect(byName['upgrade-ci']).toBeUndefined(); + expect(byName['add-auth']).toBe('api'); + expect(byName['dark-mode']).toBe('web'); + }); + + it('aggregation: module-level list stays scoped to the module', async () => { + const result = await runCLI(['list', '--json'], { cwd: path.join(repo, 'packages', 'api') }); + expect(result.exitCode).toBe(0); + const out = JSON.parse(result.stdout); + const names = out.changes.map((c: any) => c.name); + expect(names).toContain('add-auth'); + expect(names).not.toContain('upgrade-ci'); + }); + + it('addressing: a module change can be read from the root with --module', async () => { + const result = await runCLI( + ['status', '--change', 'add-auth', '--module', 'api', '--json'], + { cwd: repo } + ); + expect(result.exitCode).toBe(0); + const status = JSON.parse(result.stdout); + expect(status.changeRoot).toBe( + path.join(repo, 'packages', 'api', RATCHET_DIR_NAME, 'changes', 'add-auth') + ); + }); + + it('addressing: an unknown module fails and lists the discovered names', async () => { + const result = await runCLI( + ['status', '--change', 'add-auth', '--module', 'billing', '--json'], + { cwd: repo } + ); + expect(result.exitCode).not.toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toContain('billing'); + expect(combined).toMatch(/api/); + expect(combined).toMatch(/web/); + }); + + it('addressing: new change --module creates the change inside the module', async () => { + const result = await runCLI( + ['new', 'change', 'feature-flags', '--module', 'web', '--json'], + { cwd: repo } + ); + expect(result.exitCode).toBe(0); + await expect( + fs.access( + path.join(repo, 'packages', 'web', RATCHET_DIR_NAME, 'changes', 'feature-flags', '.ratchet.yaml') + ) + ).resolves.toBeUndefined(); + }); +}); From 2e4384bba3ab30e4efaa29b869872906527a73eb Mon Sep 17 00:00:00 2001 From: joctaTorres Date: Wed, 10 Jun 2026 16:45:28 -0300 Subject: [PATCH 11/19] test(e2e): pin single-home backward compatibility 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 --- .../changes/nested-planning-homes/plan.md | 2 +- .../single-home-backward-compat.test.ts | 96 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 test/cli-e2e/single-home-backward-compat.test.ts diff --git a/.ratchet/changes/nested-planning-homes/plan.md b/.ratchet/changes/nested-planning-homes/plan.md index 08fc66a..3d94364 100644 --- a/.ratchet/changes/nested-planning-homes/plan.md +++ b/.ratchet/changes/nested-planning-homes/plan.md @@ -50,4 +50,4 @@ Ratchet currently assumes exactly one `.ratchet` directory per repository, resol - [x] 4.2 Make archive fully home-local for features and change relocation (module store, module archive dir) - [x] 4.3 Update `materializeStandardLinks` to write reverse `## Implemented by` blocks into the standard's defining home with module-qualified entries; keep forward sidecars module-local - [x] 5.1 End-to-end test: monorepo fixture with root + two modules covering every scenario in `features/nested-planning-homes/` (resolution, discovery, addressing, aggregation, standards layering, feature store) -- [ ] 5.2 Backward-compat test: single-home repo produces byte-identical command output to current behavior +- [x] 5.2 Backward-compat test: single-home repo produces byte-identical command output to current behavior diff --git a/test/cli-e2e/single-home-backward-compat.test.ts b/test/cli-e2e/single-home-backward-compat.test.ts new file mode 100644 index 0000000..acb0dfc --- /dev/null +++ b/test/cli-e2e/single-home-backward-compat.test.ts @@ -0,0 +1,96 @@ +import { afterAll, beforeAll, describe, it, expect } from 'vitest'; +import { promises as fs } from 'fs'; +import * as fsSync from 'fs'; +import path from 'path'; +import { tmpdir } from 'os'; +import { runCLI } from '../helpers/run-cli.js'; +import { RATCHET_DIR_NAME } from '../../src/core/config.js'; + +/** + * Backward-compatibility guard: a repository with a single root `.ratchet` + * must behave exactly as before — no module concept, no module-labeled output, + * and no module-related warnings. These assertions pin the byte-level absence + * of any nesting artifacts and the stable JSON shape for single-home repos. + */ + +const tempRoots: string[] = []; +let repo: string; +let subDir: string; + +beforeAll(async () => { + const base = await fs.mkdtemp(path.join(tmpdir(), 'ratchet-single-home-')); + tempRoots.push(base); + repo = fsSync.realpathSync.native(base); + + await fs.mkdir(path.join(repo, RATCHET_DIR_NAME, 'changes'), { recursive: true }); + await fs.writeFile(path.join(repo, RATCHET_DIR_NAME, 'config.yaml'), 'schema: ratchet\n', 'utf-8'); + + const changeDir = path.join(repo, RATCHET_DIR_NAME, 'changes', 'only-change'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, '.ratchet.yaml'), 'schema: ratchet\n', 'utf-8'); + await fs.writeFile(path.join(changeDir, 'plan.md'), '- [x] one\n- [ ] two\n', 'utf-8'); + + subDir = path.join(repo, 'src', 'deep'); + await fs.mkdir(subDir, { recursive: true }); +}); + +afterAll(async () => { + await Promise.all(tempRoots.map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe('single-home repo backward compatibility (CLI e2e)', () => { + it('list --json carries no module field and a stable shape', async () => { + const result = await runCLI(['list', '--json'], { cwd: repo }); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + + const out = JSON.parse(result.stdout); + expect(out.changes).toHaveLength(1); + const change = out.changes[0]; + expect(change).toEqual({ + name: 'only-change', + completedTasks: 1, + totalTasks: 2, + lastModified: change.lastModified, // timestamp varies; shape is what matters + status: 'in-progress', + }); + // No module key whatsoever. + expect('module' in change).toBe(false); + expect(result.stdout).not.toContain('module'); + }); + + it('list (human) shows no module labels or warnings', async () => { + const result = await runCLI(['list'], { cwd: repo }); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).toContain('only-change'); + // No bracketed module label and no warning text. + expect(result.stdout).not.toMatch(/\[[^\]]+\]/); + expect(result.stdout.toLowerCase()).not.toContain('module'); + expect(result.stdout.toLowerCase()).not.toContain('warning'); + }); + + it('list run from a subdirectory resolves the root home with no warnings', async () => { + const fromRoot = await runCLI(['list', '--json'], { cwd: repo }); + const fromSub = await runCLI(['list', '--json'], { cwd: subDir }); + expect(fromSub.exitCode).toBe(0); + expect(fromSub.stderr).toBe(''); + // Byte-identical change listing regardless of where it is invoked. + const norm = (s: string) => + JSON.stringify(JSON.parse(s).changes.map((c: any) => ({ ...c, lastModified: '' }))); + expect(norm(fromSub.stdout)).toBe(norm(fromRoot.stdout)); + }); + + it('status --json reports a repo planning home with no module fields', async () => { + const result = await runCLI(['status', '--change', 'only-change', '--json'], { cwd: repo }); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + const status = JSON.parse(result.stdout); + expect(status.planningHome.kind).toBe('repo'); + expect(status.planningHome.root).toBe(repo); + expect('moduleName' in status.planningHome).toBe(false); + expect('module' in status.planningHome).toBe(false); + expect('parent' in status.planningHome).toBe(false); + expect(result.stdout.toLowerCase()).not.toContain('module'); + }); +}); From 529ecb74b3b4ede832dcf75955a921122eef0e92 Mon Sep 17 00:00:00 2001 From: joctaTorres Date: Wed, 10 Jun 2026 18:46:59 -0300 Subject: [PATCH 12/19] docs(ratchet): add nested-planning-homes change artifacts Co-Authored-By: Claude Fable 5 --- .../nested-planning-homes/.ratchet.yaml | 2 + .../nested-planning-homes/discovery.feature | 46 +++++++++++++++++++ .../module-addressing.feature | 30 ++++++++++++ .../module-feature-store.feature | 35 ++++++++++++++ .../nested-planning-homes/resolution.feature | 33 +++++++++++++ .../root-aggregation.feature | 32 +++++++++++++ .../standards-layering.feature | 40 ++++++++++++++++ 7 files changed, 218 insertions(+) create mode 100644 .ratchet/changes/nested-planning-homes/.ratchet.yaml create mode 100644 .ratchet/changes/nested-planning-homes/features/nested-planning-homes/discovery.feature create mode 100644 .ratchet/changes/nested-planning-homes/features/nested-planning-homes/module-addressing.feature create mode 100644 .ratchet/changes/nested-planning-homes/features/nested-planning-homes/module-feature-store.feature create mode 100644 .ratchet/changes/nested-planning-homes/features/nested-planning-homes/resolution.feature create mode 100644 .ratchet/changes/nested-planning-homes/features/nested-planning-homes/root-aggregation.feature create mode 100644 .ratchet/changes/nested-planning-homes/features/nested-planning-homes/standards-layering.feature diff --git a/.ratchet/changes/nested-planning-homes/.ratchet.yaml b/.ratchet/changes/nested-planning-homes/.ratchet.yaml new file mode 100644 index 0000000..fba56e7 --- /dev/null +++ b/.ratchet/changes/nested-planning-homes/.ratchet.yaml @@ -0,0 +1,2 @@ +schema: ratchet +created: 2026-06-10 diff --git a/.ratchet/changes/nested-planning-homes/features/nested-planning-homes/discovery.feature b/.ratchet/changes/nested-planning-homes/features/nested-planning-homes/discovery.feature new file mode 100644 index 0000000..59c7c99 --- /dev/null +++ b/.ratchet/changes/nested-planning-homes/features/nested-planning-homes/discovery.feature @@ -0,0 +1,46 @@ +Feature: Module discovery from the root planning home + As a developer at the root of a monorepo + I want ratchet to discover nested .ratchet directories on the filesystem + So that new modules are visible without manual registration, while a registry can lint the expected layout + + Background: + Given a repository with a .ratchet directory at the repo root + + Scenario: Nested planning homes are discovered by filesystem scan + Given nested .ratchet directories exist at "packages/api" and "packages/web" + And the root config declares no module registry + When I run "ratchet list" from the repo root + Then modules "packages/api" and "packages/web" are discovered + And no registry warnings are shown + + Scenario: Module names default to the path relative to the repo root + Given a nested .ratchet directory exists at "packages/api" + When the module is discovered + Then its module name is "packages/api" + + Scenario: A module can override its name in its own config + Given a nested .ratchet directory exists at "packages/api" + And "packages/api/.ratchet/config.yaml" declares name "api" + When the module is discovered + Then its module name is "api" + + Scenario: Discovered module missing from the registry produces a warning + Given the root config registers modules ["packages/api"] + And nested .ratchet directories exist at "packages/api" and "packages/web" + When I run "ratchet list" from the repo root + Then module "packages/web" is still included in the results + And a warning reports that "packages/web" is not registered + + Scenario: Registered module missing on disk produces a warning + Given the root config registers modules ["packages/api", "packages/legacy"] + And a nested .ratchet directory exists only at "packages/api" + When I run "ratchet list" from the repo root + Then a warning reports that registered module "packages/legacy" has no .ratchet directory + And the command still succeeds + + Scenario: Discovery does not descend into nested modules or ignored directories + Given a nested .ratchet directory exists at "packages/api" + And a directory "node_modules" containing a stray .ratchet directory + When modules are discovered from the repo root + Then "node_modules" is not reported as a module + And no .ratchet directory nested below "packages/api" is reported as a separate module diff --git a/.ratchet/changes/nested-planning-homes/features/nested-planning-homes/module-addressing.feature b/.ratchet/changes/nested-planning-homes/features/nested-planning-homes/module-addressing.feature new file mode 100644 index 0000000..e5ad17b --- /dev/null +++ b/.ratchet/changes/nested-planning-homes/features/nested-planning-homes/module-addressing.feature @@ -0,0 +1,30 @@ +Feature: Addressing a module from the root + As a developer working at the monorepo root + I want to target a specific module's planning home with a --module flag + So that I can manage module changes without changing directory + + Background: + Given a repository with a .ratchet directory at the repo root + And a nested .ratchet directory at "packages/api" named "api" + + Scenario: Creating a change inside a module from the root + Given the current working directory is the repo root + When I run "ratchet new change add-auth --module api" + Then the change is created at "packages/api/.ratchet/changes/add-auth" + And the change uses the module's default schema + + Scenario: Reading status of a module change from the root + Given a change "add-auth" exists in module "api" + When I run "ratchet status --change add-auth --module api" from the repo root + Then the reported planning home root is "packages/api" + And the reported change root is "packages/api/.ratchet/changes/add-auth" + + Scenario: An unknown module name fails with the list of known modules + When I run "ratchet status --change add-auth --module billing" from the repo root + Then the command fails with an error naming "billing" as unknown + And the error lists the discovered module names + + Scenario: Omitting --module keeps current nearest-wins behavior + Given a change "root-change" exists in the root planning home + When I run "ratchet status --change root-change" from the repo root + Then the resolved planning home root is the repo root diff --git a/.ratchet/changes/nested-planning-homes/features/nested-planning-homes/module-feature-store.feature b/.ratchet/changes/nested-planning-homes/features/nested-planning-homes/module-feature-store.feature new file mode 100644 index 0000000..cac75f9 --- /dev/null +++ b/.ratchet/changes/nested-planning-homes/features/nested-planning-homes/module-feature-store.feature @@ -0,0 +1,35 @@ +Feature: Module-local feature stores + As a maintainer of a monorepo + I want archived features to land in the module's own feature store + So that each module stays self-contained and its behavior record travels with its code + + Background: + Given a repository with a .ratchet directory at the repo root + And a nested .ratchet directory at "packages/api" named "api" + + Scenario: Archiving a module change materializes features into the module store + Given module "api" contains a completed change "add-auth" with feature "features/auth/login.feature" + When the change "add-auth" is archived + Then "packages/api/.ratchet/features/auth/login.feature" exists + And the root feature store does not contain "auth/login.feature" + And the change is moved to "packages/api/.ratchet/changes/archive" + + Scenario: Archiving a root change materializes features into the root store + Given the root planning home contains a completed change "upgrade-ci" with feature "features/ci/pipeline.feature" + When the change "upgrade-ci" is archived + Then ".ratchet/features/ci/pipeline.feature" exists at the repo root + And no module feature store is modified + + Scenario: Standard links for an inherited standard are written into the defining home + Given the root standards library contains a standard tagged "testing" + And module "api" archives a change declaring standards ["testing"] with feature "features/auth/login.feature" + When standard links are materialized + Then the forward link sidecar is written in the module's feature store + And the "Implemented by" block of the root standard "testing" lists the feature qualified by module name "api" + + Scenario: Standard links for a module-local standard stay within the module + Given module "api" standards library contains a standard tagged "api-versioning" + And module "api" archives a change declaring standards ["api-versioning"] + When standard links are materialized + Then the "Implemented by" block is regenerated in "packages/api/.ratchet/standards" + And no file under the root .ratchet directory is modified diff --git a/.ratchet/changes/nested-planning-homes/features/nested-planning-homes/resolution.feature b/.ratchet/changes/nested-planning-homes/features/nested-planning-homes/resolution.feature new file mode 100644 index 0000000..e2305d9 --- /dev/null +++ b/.ratchet/changes/nested-planning-homes/features/nested-planning-homes/resolution.feature @@ -0,0 +1,33 @@ +Feature: Nearest planning home wins + As a developer in a monorepo + I want ratchet to resolve the closest .ratchet directory to where I work + So that commands run inside a sub-module operate on that module without extra flags + + Background: + Given a repository with a .ratchet directory at the repo root + And a nested .ratchet directory at "packages/api" + + Scenario: Command run inside a module resolves the module's planning home + Given the current working directory is "packages/api/src" + When I run "ratchet status" + Then the resolved planning home root is "packages/api" + And changes are read from "packages/api/.ratchet/changes" + + Scenario: Command run at the repo root resolves the root planning home + Given the current working directory is the repo root + When I run "ratchet status" + Then the resolved planning home root is the repo root + And changes are read from ".ratchet/changes" + + Scenario: Single-home repositories behave exactly as before + Given a repository whose only .ratchet directory is at the repo root + And the current working directory is any subdirectory of the repo + When I run any ratchet command + Then the resolved planning home root is the repo root + And no module-related warnings or output are shown + + Scenario: list, view, and archive obey walk-up resolution + Given the current working directory is "packages/api/src" + When I run "ratchet list" + Then the listed changes come from "packages/api/.ratchet/changes" + And the command does not read ".ratchet" relative to the current working directory diff --git a/.ratchet/changes/nested-planning-homes/features/nested-planning-homes/root-aggregation.feature b/.ratchet/changes/nested-planning-homes/features/nested-planning-homes/root-aggregation.feature new file mode 100644 index 0000000..0f52d0e --- /dev/null +++ b/.ratchet/changes/nested-planning-homes/features/nested-planning-homes/root-aggregation.feature @@ -0,0 +1,32 @@ +Feature: Root aggregation of module changes + As a developer at the monorepo root + I want root-level listing to include changes from nested modules + So that I can see all in-flight work across the repo in one place + + Background: + Given a repository with a .ratchet directory at the repo root + And a nested .ratchet directory at "packages/api" named "api" + And a nested .ratchet directory at "packages/web" named "web" + + Scenario: Root list shows root and module changes labeled by module + Given the root planning home contains a change "upgrade-ci" + And module "api" contains a change "add-auth" + And module "web" contains a change "dark-mode" + When I run "ratchet list" from the repo root + Then the output includes "upgrade-ci" attributed to the root + And the output includes "add-auth" attributed to module "api" + And the output includes "dark-mode" attributed to module "web" + + Scenario: Module-level list stays scoped to the module + Given module "api" contains a change "add-auth" + And the root planning home contains a change "upgrade-ci" + When I run "ratchet list" from inside "packages/api" + Then the output includes "add-auth" + And the output does not include "upgrade-ci" + + Scenario: A module's broken config does not break root aggregation + Given module "api" has an unparseable .ratchet/config.yaml + And module "web" contains a change "dark-mode" + When I run "ratchet list" from the repo root + Then the output includes "dark-mode" attributed to module "web" + And a warning reports that module "api" could not be loaded diff --git a/.ratchet/changes/nested-planning-homes/features/nested-planning-homes/standards-layering.feature b/.ratchet/changes/nested-planning-homes/features/nested-planning-homes/standards-layering.feature new file mode 100644 index 0000000..7292568 --- /dev/null +++ b/.ratchet/changes/nested-planning-homes/features/nested-planning-homes/standards-layering.feature @@ -0,0 +1,40 @@ +Feature: Standards inheritance across nested planning homes + As a maintainer of a monorepo + I want module changes to see root standards plus their own module standards + So that org-wide rules propagate while modules can specialize + + Background: + Given a repository with a .ratchet directory at the repo root + And a nested .ratchet directory at "packages/api" named "api" + + Scenario: Module instructions include inherited root standards + Given the root standards library contains a standard tagged "testing" + And module "api" has no standards of its own + When I run "ratchet instructions plan --change add-auth --module api" + Then the standards in the output include "testing" + + Scenario: Module standards are added on top of root standards + Given the root standards library contains a standard tagged "testing" + And module "api" standards library contains a standard tagged "api-versioning" + When instructions are generated for a change in module "api" + Then the standards in the output include both "testing" and "api-versioning" + + Scenario: On tag collision the module standard shadows the root standard + Given the root standards library contains a standard tagged "testing" with content "root version" + And module "api" standards library contains a standard tagged "testing" with content "api version" + When instructions are generated for a change in module "api" + Then exactly one standard tagged "testing" is included + And its content is "api version" + + Scenario: Root changes see only root standards + Given the root standards library contains a standard tagged "testing" + And module "api" standards library contains a standard tagged "api-versioning" + When instructions are generated for a change in the root planning home + Then the standards in the output include "testing" + And the standards in the output do not include "api-versioning" + + Scenario: Standard tags declared by a module change validate against the layered set + Given the root standards library contains a standard tagged "testing" + And a change in module "api" declares standards ["testing"] + When the change's standard tags are validated + Then validation succeeds even though "testing" is not defined in the module From 7cdf6013edd995ba4e7dfd113ab898e3895e3253 Mon Sep 17 00:00:00 2001 From: joctaTorres Date: Wed, 17 Jun 2026 19:50:00 -0300 Subject: [PATCH 13/19] refactor(planning-home): route platform-path normalization through toPosix 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 --- src/core/module-discovery.ts | 3 ++- src/core/planning-home.ts | 15 +++++++++++++-- src/core/project-config.ts | 3 ++- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/core/module-discovery.ts b/src/core/module-discovery.ts index 3f87030..341cc02 100644 --- a/src/core/module-discovery.ts +++ b/src/core/module-discovery.ts @@ -27,6 +27,7 @@ import { getRootPlanningHome, relativeModulePath, resolveCurrentPlanningHomeSync, + toPosix, type ResolvePlanningHomeOptions, } from './planning-home.js'; import { readModuleName, readModuleRegistry } from './project-config.js'; @@ -111,7 +112,7 @@ export async function discoverModules(rootHome: PlanningHome): Promise m.split(path.sep).join('/').replace(/^\/+/, '').replace(/\/+$/, '')); + return config.modules.map((m) => toPosix(m).replace(/^\/+/, '').replace(/\/+$/, '')); } From 4e59c8ffdd49b0b1a52dd145e8b8fe0f24bc86d2 Mon Sep 17 00:00:00 2001 From: joctaTorres Date: Wed, 17 Jun 2026 19:51:26 -0300 Subject: [PATCH 14/19] fix(module-discovery): one duplicate-name policy via discoverModulesSafe 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 --- src/core/module-discovery.ts | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/core/module-discovery.ts b/src/core/module-discovery.ts index 341cc02..a92a175 100644 --- a/src/core/module-discovery.ts +++ b/src/core/module-discovery.ts @@ -90,6 +90,11 @@ function gitignoreGlobs(rootDir: string): string[] { * Returns modules sorted by their relative path. The `name:` override and * duplicate-name detection are applied here so callers always receive resolved * names. A duplicate module name throws. + * + * Note: this is an intentionally uncached, full filesystem scan re-run on every + * command. Module sets are small and discovery is cheap relative to the I/O the + * surrounding command already does; caching would add mtime/invalidation + * complexity for negligible benefit (mirrors the readProjectConfig rationale). */ export async function discoverModules(rootHome: PlanningHome): Promise { const rootDir = rootHome.root; @@ -159,6 +164,26 @@ export async function discoverModules(rootHome: PlanningHome): Promise { + try { + return await discoverModules(rootHome); + } catch (error) { + console.warn(`Module discovery failed: ${error instanceof Error ? error.message : String(error)}`); + return []; + } +} + /** * Compare discovered modules against the root `modules:` registry and return * lint warnings. The registry is an optional allowlist — discovery is always @@ -226,8 +251,11 @@ export async function resolvePlanningHomeForCommand( } // Address a module from the root: discovery is rooted at the topmost home. + // Use the safe variant so a duplicate name elsewhere in the repo degrades to + // a warning rather than crashing this command before it can report whether + // the requested module exists. const rootHome = getRootPlanningHome(nearest); - const modules = await discoverModules(rootHome); + const modules = await discoverModulesSafe(rootHome); const match = modules.find((m) => m.moduleName === moduleName); if (!match) { From 7cda0821907b3e202c6b7fcaae754b17f5f30841 Mon Sep 17 00:00:00 2001 From: joctaTorres Date: Wed, 17 Jun 2026 19:51:37 -0300 Subject: [PATCH 15/19] refactor(list): extract collectModuleChanges from list.execute 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 --- src/core/list.ts | 79 +++++++++++++++++++++++++++++------------------- 1 file changed, 48 insertions(+), 31 deletions(-) diff --git a/src/core/list.ts b/src/core/list.ts index 139bed0..d6d8231 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -3,8 +3,8 @@ import { RATCHET_DIR_NAME } from './config.js'; import path from 'path'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; import fg from 'fast-glob'; -import { getParentPlanningHome, resolveCurrentPlanningHomeSync } from './planning-home.js'; -import { discoverModules, reconcileModuleRegistry } from './module-discovery.js'; +import { getParentPlanningHome, resolveCurrentPlanningHomeSync, type PlanningHome } from './planning-home.js'; +import { discoverModulesSafe, reconcileModuleRegistry } from './module-discovery.js'; import { configLoadError } from './project-config.js'; interface ChangeInfo { @@ -112,6 +112,49 @@ async function collectChanges(changesDir: string, module?: string): Promise { + const changes: ChangeInfo[] = []; + const warnings: string[] = []; + + // Discovery failure (e.g. a duplicate module name) is non-fatal here. + const modules = await discoverModulesSafe(planningHome); + + // Surface registry lint warnings (discovered-but-unregistered, + // registered-but-missing). Non-fatal. + warnings.push(...reconcileModuleRegistry(planningHome, modules)); + + for (const mod of modules) { + // A module with an unparseable config degrades to a warning; one + // broken module must not blind the whole repo. + const loadError = configLoadError(mod.home.root); + if (loadError) { + warnings.push(`Module '${mod.moduleName}' could not be loaded: ${loadError}`); + continue; + } + try { + const moduleChanges = await collectChanges(mod.home.changesDir, mod.moduleName); + if (moduleChanges) { + changes.push(...moduleChanges); + } + } catch (error) { + warnings.push(`Module '${mod.moduleName}' could not be loaded: ${(error as Error).message}`); + } + } + + return { changes, warnings }; +} + export class ListCommand { async execute(targetPath: string = '.', mode: 'changes' | 'specs' = 'changes', options: ListOptions = {}): Promise { const { sort = 'recent', json = false } = options; @@ -136,37 +179,11 @@ export class ListCommand { const changes: ChangeInfo[] = [...rootChanges]; const isRootHome = getParentPlanningHome(planningHome) === null; if (isRootHome) { - let modules: Awaited> = []; - try { - modules = await discoverModules(planningHome); - } catch (error) { - console.warn(`Module discovery failed: ${(error as Error).message}`); - modules = []; - } - - // Surface registry lint warnings (discovered-but-unregistered, - // registered-but-missing). Non-fatal. - for (const warning of reconcileModuleRegistry(planningHome, modules)) { + const { changes: moduleChanges, warnings } = await collectModuleChanges(planningHome); + for (const warning of warnings) { console.warn(warning); } - - for (const mod of modules) { - // A module with an unparseable config degrades to a warning; one - // broken module must not blind the whole repo. - const loadError = configLoadError(mod.home.root); - if (loadError) { - console.warn(`Module '${mod.moduleName}' could not be loaded: ${loadError}`); - continue; - } - try { - const moduleChanges = await collectChanges(mod.home.changesDir, mod.moduleName); - if (moduleChanges) { - changes.push(...moduleChanges); - } - } catch (error) { - console.warn(`Module '${mod.moduleName}' could not be loaded: ${(error as Error).message}`); - } - } + changes.push(...moduleChanges); } if (changes.length === 0) { From 89467d43b50fdedd61326e8b39ddd95f4f2976c8 Mon Sep 17 00:00:00 2001 From: joctaTorres Date: Wed, 17 Jun 2026 19:51:37 -0300 Subject: [PATCH 16/19] refactor(features-apply): extract regenerateLayeredReverseLinks 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 --- src/core/features-apply.ts | 40 +++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/src/core/features-apply.ts b/src/core/features-apply.ts index 956bbf0..10e673f 100644 --- a/src/core/features-apply.ts +++ b/src/core/features-apply.ts @@ -21,7 +21,7 @@ import { getParentPlanningHome, getRootPlanningHome, } from './planning-home.js'; -import { discoverModules } from './module-discovery.js'; +import { discoverModulesSafe } from './module-discovery.js'; // ----------------------------------------------------------------------------- // Types @@ -547,12 +547,16 @@ export async function materializeStandardLinks( // Reverse links: regenerate the `## Implemented by` block in each standard's // 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). + // `root`'s standards). The nested-monorepo layering is isolated in + // `regenerateLayeredReverseLinks`. const isNested = home !== undefined && getParentPlanningHome(home) !== null; - const rootHome = home ? getRootPlanningHome(home) : undefined; - const modules = rootHome && (isNested || home === rootHome) - ? await discoverModules(rootHome) - : []; + const rootHome = home && (isNested || home === getRootPlanningHome(home)) + ? getRootPlanningHome(home) + : undefined; + + // Discovery failure (e.g. a duplicate module name) is non-fatal: fall back to + // the single-home reverse-link path rather than crashing the archive. + const modules = rootHome ? await discoverModulesSafe(rootHome) : []; if (!rootHome || modules.length === 0) { // Single-home (or no discovered modules): pure projection of root's store. @@ -561,17 +565,31 @@ export async function materializeStandardLinks( return; } + await regenerateLayeredReverseLinks(rootHome, modules); +} + +/** + * Regenerate the `## Implemented by` reverse-link blocks across a nested + * monorepo (root + discovered modules). Isolated from the legacy single-home + * path in {@link materializeStandardLinks} so the layering logic lives in one + * place. + * + * Each home's standards are regenerated from a reverse index built relative to + * that home: the home's own features are listed unqualified, while features + * contributed by *other* modules are qualified by their module name. This keeps + * a module-local standard's entries local, while an inherited root standard + * collects module features qualified by module name. + */ +async function regenerateLayeredReverseLinks( + rootHome: PlanningHome, + modules: Array<{ home: PlanningHome; moduleName: string }> +): Promise { // Every home that could define a standard: the root and every module. const allHomes: Array<{ root: string; moduleName?: string }> = [ { root: rootHome.root }, ...modules.map((m) => ({ root: m.home.root, moduleName: m.moduleName })), ]; - // Regenerate each home's standards from a reverse index built relative to - // that home: the home's own features are listed unqualified, while features - // contributed by *other* modules are qualified by their module name. This - // keeps a module-local standard's entries local, while an inherited root - // standard collects module features qualified by module name. for (const defining of allHomes) { const homesForIndex = allHomes.map((h) => ({ storeDir: storeDirFor(h.root), From fdf5723bdad0f5a532ed7f55816e4672b8845cd6 Mon Sep 17 00:00:00 2001 From: joctaTorres Date: Wed, 17 Jun 2026 19:52:15 -0300 Subject: [PATCH 17/19] refactor(project-config): extract per-field parse helpers 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 --- src/core/project-config.ts | 165 +++++++++++++++++++++---------------- 1 file changed, 94 insertions(+), 71 deletions(-) diff --git a/src/core/project-config.ts b/src/core/project-config.ts index 61f79ed..c1bfada 100644 --- a/src/core/project-config.ts +++ b/src/core/project-config.ts @@ -115,6 +115,90 @@ const MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit * @param projectRoot - The root directory of the project (where `ratchet/` lives) * @returns Parsed config or null if file doesn't exist */ +/** + * Parse the `rules` field: a map of artifact ID -> non-empty string array. + * Empty-string rules are dropped (with a warning); a non-object value is + * rejected. Returns the parsed map, or undefined when nothing valid remains. + * Warnings are emitted as a side effect so the caller stays orchestration-only. + */ +function parseRulesField(raw: unknown): Record | undefined { + // Guard against null since typeof null === 'object'. + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + console.warn(`Invalid 'rules' field in config (must be object)`); + return undefined; + } + + const parsedRules: Record = {}; + let hasValidRules = false; + for (const [artifactId, rules] of Object.entries(raw)) { + const rulesArrayResult = z.array(z.string()).safeParse(rules); + if (!rulesArrayResult.success) { + console.warn( + `Rules for '${artifactId}' must be an array of strings, ignoring this artifact's rules` + ); + continue; + } + const validRules = rulesArrayResult.data.filter((r) => r.length > 0); + if (validRules.length > 0) { + parsedRules[artifactId] = validRules; + hasValidRules = true; + } + if (validRules.length < rulesArrayResult.data.length) { + console.warn(`Some rules for '${artifactId}' are empty strings, ignoring them`); + } + } + + return hasValidRules ? parsedRules : undefined; +} + +/** + * Parse the `modules` registry (root config): an array of non-empty, + * trimmed strings. Returns undefined when invalid or empty. + */ +function parseModulesField(raw: unknown): string[] | undefined { + const modulesResult = z.array(z.string()).safeParse(raw); + if (!modulesResult.success) { + console.warn(`Invalid 'modules' field in config (must be an array of strings)`); + return undefined; + } + const validModules = modulesResult.data.map((m) => m.trim()).filter((m) => m.length > 0); + return validModules.length > 0 ? validModules : undefined; +} + +/** + * Parse the module `name` override (module config): a non-empty trimmed string. + * Returns undefined when invalid. + */ +function parseNameField(raw: unknown): string | undefined { + const nameResult = z.string().min(1).safeParse(typeof raw === 'string' ? raw.trim() : raw); + if (nameResult.success) { + return nameResult.data; + } + console.warn(`Invalid 'name' field in config (must be a non-empty string)`); + return undefined; +} + +/** + * Parse the `context` field: a string within the {@link MAX_CONTEXT_SIZE} + * byte limit. Returns undefined when invalid or oversized. + */ +function parseContextField(raw: unknown): string | undefined { + const contextResult = z.string().safeParse(raw); + if (!contextResult.success) { + console.warn(`Invalid 'context' field in config (must be string)`); + return undefined; + } + const contextSize = Buffer.byteLength(contextResult.data, 'utf-8'); + if (contextSize > MAX_CONTEXT_SIZE) { + console.warn( + `Context too large (${(contextSize / 1024).toFixed(1)}KB, limit: ${MAX_CONTEXT_SIZE / 1024}KB)` + ); + console.warn(`Ignoring context field`); + return undefined; + } + return contextResult.data; +} + export function readProjectConfig(projectRoot: string): ProjectConfig | null { // Try both .yaml and .yml, prefer .yaml let configPath = path.join(projectRoot, RATCHET_DIR_NAME, 'config.yaml'); @@ -147,87 +231,26 @@ export function readProjectConfig(projectRoot: string): ProjectConfig | null { // Parse context field with size limit if (raw.context !== undefined) { - const contextField = z.string(); - const contextResult = contextField.safeParse(raw.context); - - if (contextResult.success) { - const contextSize = Buffer.byteLength(contextResult.data, 'utf-8'); - if (contextSize > MAX_CONTEXT_SIZE) { - console.warn( - `Context too large (${(contextSize / 1024).toFixed(1)}KB, limit: ${MAX_CONTEXT_SIZE / 1024}KB)` - ); - console.warn(`Ignoring context field`); - } else { - config.context = contextResult.data; - } - } else { - console.warn(`Invalid 'context' field in config (must be string)`); - } + const context = parseContextField(raw.context); + if (context !== undefined) config.context = context; } - // Parse rules field using Zod + // Parse rules field (map of artifact ID -> non-empty string array). if (raw.rules !== undefined) { - const rulesField = z.record(z.string(), z.array(z.string())); - - // First check if it's an object structure (guard against null since typeof null === 'object') - if (typeof raw.rules === 'object' && raw.rules !== null && !Array.isArray(raw.rules)) { - const parsedRules: Record = {}; - let hasValidRules = false; - - for (const [artifactId, rules] of Object.entries(raw.rules)) { - const rulesArrayResult = z.array(z.string()).safeParse(rules); - - if (rulesArrayResult.success) { - // Filter out empty strings - const validRules = rulesArrayResult.data.filter((r) => r.length > 0); - if (validRules.length > 0) { - parsedRules[artifactId] = validRules; - hasValidRules = true; - } - if (validRules.length < rulesArrayResult.data.length) { - console.warn( - `Some rules for '${artifactId}' are empty strings, ignoring them` - ); - } - } else { - console.warn( - `Rules for '${artifactId}' must be an array of strings, ignoring this artifact's rules` - ); - } - } - - if (hasValidRules) { - config.rules = parsedRules; - } - } else { - console.warn(`Invalid 'rules' field in config (must be object)`); - } + const rules = parseRulesField(raw.rules); + if (rules !== undefined) config.rules = rules; } - // Parse modules registry (root config). Expect an array of non-empty - // strings; ignore anything else with a warning. + // Parse modules registry (root config). if (raw.modules !== undefined) { - const modulesResult = z.array(z.string()).safeParse(raw.modules); - if (modulesResult.success) { - const validModules = modulesResult.data - .map((m) => m.trim()) - .filter((m) => m.length > 0); - if (validModules.length > 0) { - config.modules = validModules; - } - } else { - console.warn(`Invalid 'modules' field in config (must be an array of strings)`); - } + const modules = parseModulesField(raw.modules); + if (modules !== undefined) config.modules = modules; } // Parse module name override (module config). if (raw.name !== undefined) { - const nameResult = z.string().min(1).safeParse(typeof raw.name === 'string' ? raw.name.trim() : raw.name); - if (nameResult.success) { - config.name = nameResult.data; - } else { - console.warn(`Invalid 'name' field in config (must be a non-empty string)`); - } + const name = parseNameField(raw.name); + if (name !== undefined) config.name = name; } // Parse batch field using Zod (project-level batch defaults) From e7bb867a7603c7f9f52261e43b602ea9459bfdcd Mon Sep 17 00:00:00 2001 From: joctaTorres Date: Wed, 17 Jun 2026 19:52:37 -0300 Subject: [PATCH 18/19] refactor(archive): extract --module resolution from archive.execute archive.execute is a pre-existing god-method (tracked in github.com/joctaTorres/ratchet/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 --- src/core/archive.ts | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/core/archive.ts b/src/core/archive.ts index ee1b809..cf44624 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -6,7 +6,7 @@ import { Validator } from './validation/validator.js'; import chalk from 'chalk'; import { applyFeatures, materializeStandardLinks } from './features-apply.js'; import { readDeclaredStandardTags } from '../utils/change-metadata.js'; -import { resolveCurrentPlanningHomeSync } from './planning-home.js'; +import { resolveCurrentPlanningHomeSync, type PlanningHome } from './planning-home.js'; import { resolvePlanningHomeForCommand } from './module-discovery.js'; /** @@ -51,12 +51,9 @@ export class ArchiveCommand { changeName?: string, options: { yes?: boolean; skipFeatures?: boolean; noValidate?: boolean; validate?: boolean; cwd?: string; module?: string } = {} ): Promise { - // Resolve the planning home. Without `--module` this walks up from the cwd - // (nearest-wins); with `--module` it addresses the named module from the - // root. Either way the rest of archive operates on the resolved home. - const planningHome = options.module - ? await resolvePlanningHomeForCommand({ module: options.module, startPath: options.cwd ?? '.' }) - : resolveCurrentPlanningHomeSync({ startPath: options.cwd ?? '.' }); + // Resolve the planning home (nearest-wins, or the named `--module` from the + // root). The rest of archive operates on the resolved home. + const planningHome = await this.resolvePlanningHome(options); const targetPath = planningHome.root; const changesDir = path.join(targetPath, RATCHET_DIR_NAME, 'changes'); const archiveDir = path.join(changesDir, 'archive'); @@ -272,6 +269,20 @@ export class ArchiveCommand { console.log(`Change '${changeName}' archived as '${archiveName}'.`); } + /** + * Resolve the planning home this archive operates on. Without `--module` this + * walks up from the cwd (nearest-wins); with `--module` it addresses the + * named module from the root home. + */ + private async resolvePlanningHome( + options: { cwd?: string; module?: string } + ): Promise { + const startPath = options.cwd ?? '.'; + return options.module + ? resolvePlanningHomeForCommand({ module: options.module, startPath }) + : resolveCurrentPlanningHomeSync({ startPath }); + } + private async selectChange(changesDir: string): Promise { const { select } = await import('@inquirer/prompts'); // Get all directories in changes (excluding archive) From c96428974c696d10cfe4b8dc0de490b370b659a3 Mon Sep 17 00:00:00 2001 From: joctaTorres Date: Wed, 17 Jun 2026 19:52:51 -0300 Subject: [PATCH 19/19] fix(module-discovery): flag approximate gitignore handling in error 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 --- src/core/module-discovery.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/core/module-discovery.ts b/src/core/module-discovery.ts index a92a175..eb8f5d2 100644 --- a/src/core/module-discovery.ts +++ b/src/core/module-discovery.ts @@ -264,7 +264,11 @@ export async function resolvePlanningHomeForCommand( ? modules.map((m) => m.moduleName).join(', ') : '(none discovered)'; throw new Error( - `Unknown module '${moduleName}'. Discovered modules: ${known}` + `Unknown module '${moduleName}'. Discovered modules: ${known}. ` + + `Note: discovery skips paths matched by the root .gitignore using an ` + + `approximate translator (directory ignores only; negations and ` + + `globs are not fully supported), so a module under such a path may be ` + + `silently hidden — check whether its path is gitignored.` ); }