From a3ca6ff6d6a7516a5c0895b96bf4f219ff907992 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 15 May 2026 09:57:23 -0400 Subject: [PATCH] feat(3553): Project-Root Resolution Module via generator (Phase 4 of #3524) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of the CJS↔SDK hard-seam migration (parent #3524). Eliminates the `findProjectRoot` duplication that lived at bin/lib/core.cjs:74-140 and sdk/src/query/helpers.ts:497-590, the drift carrier behind historical bugs #1362 and #2561. - sdk/src/project-root/index.ts — source of truth (120 lines, pure-with-sync-fs). Exports findProjectRoot(startDir: string) and FIND_PROJECT_ROOT_MAX_DEPTH constant. - sdk/src/project-root/index.test.ts — 13 vitest pinning fixtures covering all four heuristics, the #1362 guard, malformed config fallback, empty sub_repos, deep nesting, and depth-limit enforcement. - sdk/scripts/gen-project-root.mjs — generator. Captures function body via Function.prototype.toString() from compiled sdk/dist/. Emits CJS preamble for destructured node:fs / node:path / node:os imports. - sdk/scripts/check-project-root-fresh.mjs — freshness check. Imports the generator function directly (Phase 3's cleaner pattern). - get-shit-done/bin/lib/project-root.generated.cjs — generator- emitted CJS mirror. - tests/project-root-generator.test.cjs — 11 parity assertions comparing SDK source and generated CJS for every fixture. - sdk/src/query/helpers.ts: -127 lines. The 94-line inline findProjectRoot plus the FIND_PROJECT_ROOT_MAX_DEPTH constant (originally at line 471) replaced by a single re-export: `export { findProjectRoot } from '../project-root/index.js';` Removed unused `parse as parsePath` import. - get-shit-done/bin/lib/core.cjs: -83 lines net. The 67-line inline findProjectRoot replaced by a single `require('./project-root.generated.cjs')`. The detectSubRepos helper at lines 40-56 stays (used by loadConfig migration). - sdk/package.json: gen:project-root + check:project-root-fresh scripts. - package.json: proxy for the freshness check. - .githooks/pre-commit: drift block. - .github/workflows/test.yml: drift check step after the state-document drift step. - CONTEXT.md: Project-Root Resolution Module entry. - docs/INVENTORY.md, docs/INVENTORY-MANIFEST.json: +1 module count, +1 row. - Full suite: 9226/9226 pass (baseline 9215 + 11 new parity fixtures). - SDK vitest: 1804/1804 pass. - Reader shrink: -127 SDK + -83 CJS = 210 lines of duplication deleted across the two Readers. New shared Module is 120 lines. 1. Depth limit canonicalization. CJS findProjectRoot previously had no explicit walk-up bound (walked until dir === root or homedir). The new Module uses FIND_PROJECT_ROOT_MAX_DEPTH = 10, matching the SDK's pre-existing value. Only affects paths nested more than 10 levels deep from a .planning/ root — a pathological case in practice. None of the existing 22 CJS findProjectRoot tests covered this; the new parity test does. 2. platformReadSync → readFileSync. The old CJS findProjectRoot used the platformReadSync wrapper from shell-command-projection.cjs for reading .planning/config.json, which returns null on read failure. The Module uses raw readFileSync, which throws — caught by the surrounding try/catch that already swallowed errors. Functionally equivalent for the existing code path; no test exercises the null-return semantic. Closes #3553. --- .githooks/pre-commit | 4 + .github/workflows/test.yml | 5 + CONTEXT.md | 3 + docs/INVENTORY-MANIFEST.json | 1 + docs/INVENTORY.md | 3 +- get-shit-done/bin/lib/core.cjs | 85 +------- .../bin/lib/project-root.generated.cjs | 117 +++++++++++ package.json | 1 + sdk/package.json | 2 + sdk/scripts/check-project-root-fresh.mjs | 36 ++++ sdk/scripts/gen-project-root.mjs | 95 +++++++++ sdk/src/project-root/index.test.ts | 186 ++++++++++++++++++ sdk/src/project-root/index.ts | 144 ++++++++++++++ sdk/src/query/helpers.ts | 131 +----------- tests/project-root-generator.test.cjs | 153 ++++++++++++++ 15 files changed, 755 insertions(+), 211 deletions(-) create mode 100644 get-shit-done/bin/lib/project-root.generated.cjs create mode 100644 sdk/scripts/check-project-root-fresh.mjs create mode 100644 sdk/scripts/gen-project-root.mjs create mode 100644 sdk/src/project-root/index.test.ts create mode 100644 sdk/src/project-root/index.ts create mode 100644 tests/project-root-generator.test.cjs diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 87abfce80..e699feda5 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -16,3 +16,7 @@ fi if git diff --cached --name-only | grep -Eq "^sdk/src/workstream-inventory/|^get-shit-done/bin/lib/workstream-inventory-builder\.generated\.cjs$|^sdk/scripts/gen-workstream-inventory-builder\.mjs$|^sdk/scripts/check-workstream-inventory-builder-fresh\.mjs$"; then npm run check:workstream-inventory-builder-fresh fi + +if git diff --cached --name-only | grep -Eq "^sdk/src/project-root/|^get-shit-done/bin/lib/project-root\.generated\.cjs$|^sdk/scripts/gen-project-root\.mjs$|^sdk/scripts/check-project-root-fresh\.mjs$"; then + npm run check:project-root-fresh +fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 33808dabf..8a9131140 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -118,6 +118,11 @@ jobs: shell: bash run: node sdk/scripts/check-workstream-inventory-builder-fresh.mjs + - name: SDK generated project-root artifact drift check + if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24 + shell: bash + run: node sdk/scripts/check-project-root-fresh.mjs + - name: Run tests with coverage shell: bash run: npm run test:coverage diff --git a/CONTEXT.md b/CONTEXT.md index fc71df1d3..a2874d9cd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -61,6 +61,9 @@ Module owning `.planning` path resolution, active workstream pointer policy (`se ### Workstream Inventory Module Shared CJS/SDK Module owning workstream directory discovery, per-workstream state projection, phase/plan/summary counting, roadmap-declared phase count, active marker projection, and active-workstream collision inputs. Command handlers render list/status/progress outputs from this inventory instead of rescanning `.planning/workstreams/*` directly. Source of truth for the pure projection is `sdk/src/workstream-inventory/builder.ts` (a Builder Module emitted to `get-shit-done/bin/lib/workstream-inventory-builder.generated.cjs` via the generator pattern); per-side Reader Adapters (`bin/lib/workstream-inventory.cjs` sync, `sdk/src/query/workstream-inventory.ts` async-ready) collect filesystem inputs and delegate projection to the Builder. +### Project-Root Resolution Module +Shared CJS/SDK Module owning project-root resolution from any starting directory. Walks the ancestor chain (bounded by `FIND_PROJECT_ROOT_MAX_DEPTH = 10`) applying four heuristics in order: (0) own `.planning/` guard (#1362), (1) parent `.planning/config.json` `sub_repos` traversal, (2) legacy `multiRepo: true` boolean + ancestor `.git`, (3) `.git` heuristic with parent `.planning/`. Returns `startDir` when no ancestor qualifies. Sync `node:fs` I/O. Source of truth: `sdk/src/project-root/index.ts`; CJS callers consume the generator-emitted `get-shit-done/bin/lib/project-root.generated.cjs` via thin re-exports at `bin/lib/core.cjs` and `sdk/src/query/helpers.ts`. + ### Planning Path Projection Module SDK query Module owning projection from project/workstream context to concrete `.planning` paths. Policy precedence is `explicit workstream > env workstream > env project > root`. Invalid workspace context is a validation error at this seam rather than a silent fallback. diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 0b1336f7d..c710aeefd 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -297,6 +297,7 @@ "planning-workspace.cjs", "profile-output.cjs", "profile-pipeline.cjs", + "project-root.generated.cjs", "review-reviewer-selection.cjs", "roadmap-command-router.cjs", "roadmap.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 9ef183f85..fc8428091 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -360,7 +360,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (62 shipped) +## CLI Modules (63 shipped) Full listing: `get-shit-done/bin/lib/*.cjs`. @@ -402,6 +402,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. | `phase.cjs` | Phase directory operations, decimal numbering, plan indexing | | `phases-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phases` | | `plan-scan.cjs` | Canonical phase-plan scanner — shared helper for detecting plan and summary files in flat and nested layouts (k014); consumed by state, roadmap, init, and workstream inventory paths | +| `project-root.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/project-root/index.ts` via `sdk/scripts/gen-project-root.mjs`; resolves a project root from a starting directory using four heuristics (own `.planning/` guard, `sub_repos` config, `multiRepo` flag, `.git` heuristic); do not edit directly | | `planning-workspace.cjs` | Planning path/workstream seam (`planningDir`, `planningPaths`, active-workstream routing, `.planning/.lock` orchestration) | | `profile-output.cjs` | Profile rendering, USER-PROFILE.md and dev-preferences.md generation | | `profile-pipeline.cjs` | User behavioral profiling data pipeline, session file scanning | diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 780fa588f..f3809e9d4 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -24,6 +24,7 @@ const { getActiveWorkstream, setActiveWorkstream, } = require('./planning-workspace.cjs'); +const { findProjectRoot } = require('./project-root.generated.cjs'); // ─── Configuration Module (generated CJS mirror) ──────────────────────────── // Cycle 4: import canonical defaults + normalization primitives from the @@ -66,89 +67,7 @@ function detectSubRepos(cwd) { return results.sort(); } -/** - * Walk up from `startDir` to find the project root that owns `.planning/`. - * - * In multi-repo workspaces, Claude may open inside a sub-repo (e.g. `backend/`) - * instead of the project root. This function prevents `.planning/` from being - * created inside the sub-repo by locating the nearest ancestor that already has - * a `.planning/` directory. - * - * Detection strategy (checked in order for each ancestor): - * 1. Parent has `.planning/config.json` with `sub_repos` listing this directory - * 2. Parent has `.planning/config.json` with `multiRepo: true` (legacy format) - * 3. Parent has `.planning/` and current dir has its own `.git` (heuristic) - * - * Returns `startDir` unchanged when no ancestor `.planning/` is found (first-run - * or single-repo projects). - */ -function findProjectRoot(startDir) { - const resolved = path.resolve(startDir); - const root = path.parse(resolved).root; - const homedir = require('os').homedir(); - - // If startDir already contains .planning/, it IS the project root. - // Do not walk up to a parent workspace that also has .planning/ (#1362). - const ownPlanning = path.join(resolved, '.planning'); - if (fs.existsSync(ownPlanning) && fs.statSync(ownPlanning).isDirectory()) { - return startDir; - } - - // Check if startDir or any of its ancestors (up to AND including the - // candidate project root) contains a .git directory. This handles both - // `backend/` (direct sub-repo) and `backend/src/modules/` (nested inside), - // as well as the common case where .git lives at the same level as .planning/. - function isInsideGitRepo(candidateParent) { - let d = resolved; - while (d !== root) { - if (fs.existsSync(path.join(d, '.git'))) return true; - if (d === candidateParent) break; - d = path.dirname(d); - } - return false; - } - - let dir = resolved; - while (dir !== root) { - const parent = path.dirname(dir); - if (parent === dir) break; // filesystem root - if (parent === homedir) break; // never go above home - - const parentPlanning = path.join(parent, '.planning'); - if (fs.existsSync(parentPlanning) && fs.statSync(parentPlanning).isDirectory()) { - const configPath = path.join(parentPlanning, 'config.json'); - try { - const raw = platformReadSync(configPath); - if (raw === null) throw new Error('missing'); - const config = JSON.parse(raw); - const subRepos = config.sub_repos || config.planning?.sub_repos || []; - - // Check explicit sub_repos list - if (Array.isArray(subRepos) && subRepos.length > 0) { - const relPath = path.relative(parent, resolved); - const topSegment = relPath.split(path.sep)[0]; - if (subRepos.includes(topSegment)) { - return parent; - } - } - - // Check legacy multiRepo flag - if (config.multiRepo === true && isInsideGitRepo(parent)) { - return parent; - } - } catch { - // config.json missing or malformed — fall back to .git heuristic - } - - // Heuristic: parent has .planning/ and we're inside a git repo - if (isInsideGitRepo(parent)) { - return parent; - } - } - dir = parent; - } - return startDir; -} +// findProjectRoot is now re-exported from the generated CJS module above. // ─── Output helpers ─────────────────────────────────────────────────────────── diff --git a/get-shit-done/bin/lib/project-root.generated.cjs b/get-shit-done/bin/lib/project-root.generated.cjs new file mode 100644 index 000000000..bb85c50d3 --- /dev/null +++ b/get-shit-done/bin/lib/project-root.generated.cjs @@ -0,0 +1,117 @@ +'use strict'; + +/** + * GENERATED FILE — DO NOT EDIT. + * + * Source: sdk/src/project-root/index.ts + * Regenerate: cd sdk && npm run gen:project-root + * + * Project-Root Resolution Module — resolves a project root from a starting + * directory by walking the ancestor chain and applying four heuristics: + * (0) own .planning/ guard (#1362) + * (1) parent .planning/config.json sub_repos + * (2) legacy multiRepo: true + ancestor .git + * (3) .git heuristic with parent .planning/ + * Bounded by FIND_PROJECT_ROOT_MAX_DEPTH ancestors. Sync I/O. + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { existsSync, readFileSync, statSync } = fs; +const { dirname, resolve, sep, relative, parse: parsePath } = path; +const { homedir } = os; +const FIND_PROJECT_ROOT_MAX_DEPTH = 10; + +function findProjectRoot(startDir) { + let resolvedStart; + try { + resolvedStart = resolve(startDir); + } + catch { + return startDir; + } + const fsRoot = parsePath(resolvedStart).root; + const home = homedir(); + // If startDir already contains .planning/, it IS the project root. + try { + const ownPlanningDir = resolvedStart + sep + '.planning'; + if (existsSync(ownPlanningDir) && statSync(ownPlanningDir).isDirectory()) { + return startDir; + } + } + catch { + // fall through + } + // Walk upward, mirroring isInsideGitRepo from the CJS reference. + function isInsideGitRepo(candidateParent) { + let d = resolvedStart; + while (d !== fsRoot) { + try { + if (existsSync(d + sep + '.git')) + return true; + } + catch { + // ignore + } + if (d === candidateParent) + break; + const next = dirname(d); + if (next === d) + break; + d = next; + } + return false; + } + let dir = resolvedStart; + let depth = 0; + while (dir !== fsRoot && depth < FIND_PROJECT_ROOT_MAX_DEPTH) { + const parent = dirname(dir); + if (parent === dir) + break; + if (parent === home) + break; + const parentPlanning = parent + sep + '.planning'; + let parentPlanningIsDir = false; + try { + parentPlanningIsDir = existsSync(parentPlanning) && statSync(parentPlanning).isDirectory(); + } + catch { + parentPlanningIsDir = false; + } + if (parentPlanningIsDir) { + const configPath = parentPlanning + sep + 'config.json'; + let matched = false; + try { + const raw = readFileSync(configPath, 'utf-8'); + const config = JSON.parse(raw); + const subReposValue = config.sub_repos ?? (config.planning && config.planning.sub_repos); + const subRepos = Array.isArray(subReposValue) ? subReposValue : []; + if (subRepos.length > 0) { + const relPath = relative(parent, resolvedStart); + const topSegment = relPath.split(sep)[0]; + if (subRepos.includes(topSegment)) { + return parent; + } + } + if (config.multiRepo === true && isInsideGitRepo(parent)) { + matched = true; + } + } + catch { + // config.json missing or unparseable — fall through to .git heuristic. + } + if (matched) + return parent; + // Heuristic: parent has .planning/ and we're inside a git repo. + if (isInsideGitRepo(parent)) { + return parent; + } + } + dir = parent; + depth += 1; + } + return startDir; +} + +module.exports = { findProjectRoot }; diff --git a/package.json b/package.json index 5f3d719ec..1f64997f3 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "check:state-document-fresh": "cd sdk && npm run check:state-document-fresh", "check:configuration-fresh": "cd sdk && npm run check:configuration-fresh", "check:workstream-inventory-builder-fresh": "cd sdk && npm run check:workstream-inventory-builder-fresh", + "check:project-root-fresh": "cd sdk && npm run check:project-root-fresh", "prepublishOnly": "npm run build:hooks && npm run build:sdk", "pretest": "npm run build:sdk && npm run lint:skill-deps", "pretest:coverage": "npm run build:sdk", diff --git a/sdk/package.json b/sdk/package.json index 7a930b862..d2b9fff1a 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -42,6 +42,8 @@ "check:configuration-fresh": "npm run build && node scripts/check-configuration-fresh.mjs", "gen:workstream-inventory-builder": "npm run build && node scripts/gen-workstream-inventory-builder.mjs", "check:workstream-inventory-builder-fresh": "npm run build && node scripts/check-workstream-inventory-builder-fresh.mjs", + "gen:project-root": "npm run build && node scripts/gen-project-root.mjs", + "check:project-root-fresh": "npm run build && node scripts/check-project-root-fresh.mjs", "prepublishOnly": "rm -rf dist && tsc && chmod +x dist/cli.js", "test": "vitest run", "test:unit": "vitest run --project unit", diff --git a/sdk/scripts/check-project-root-fresh.mjs b/sdk/scripts/check-project-root-fresh.mjs new file mode 100644 index 000000000..75cc7fc23 --- /dev/null +++ b/sdk/scripts/check-project-root-fresh.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +/** + * Freshness check for project-root.generated.cjs. + * + * Regenerates the expected CJS content in-memory (without writing to disk) and + * compares it to the committed file. Exits 0 if they match, 1 if stale. + * + * Uses Phase 3's cleaner pattern: imports buildProjectRootCjs() from the + * generator directly rather than duplicating the build logic. + * + * Run: node sdk/scripts/check-project-root-fresh.mjs + * (Requires sdk/dist to be built first — `npm run build` in sdk/.) + */ + +import { readFile } from 'node:fs/promises'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); + +// Import the generator function directly (avoids duplicating logic). +const { buildProjectRootCjs } = await import('./gen-project-root.mjs'); + +const expected = await buildProjectRootCjs(); + +const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'project-root.generated.cjs'); +const committed = await readFile(committedPath, 'utf-8'); + +if (expected === committed) { + console.log('project-root.generated.cjs is fresh'); + process.exit(0); +} else { + console.error('project-root.generated.cjs is STALE.'); + console.error('Regenerate: cd sdk && npm run gen:project-root'); + process.exit(1); +} diff --git a/sdk/scripts/gen-project-root.mjs b/sdk/scripts/gen-project-root.mjs new file mode 100644 index 000000000..2967ef5dc --- /dev/null +++ b/sdk/scripts/gen-project-root.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +/** + * Generator for the Project-Root Resolution Module CJS artifact. + * + * Imports the compiled ESM output from sdk/dist/project-root/index.js, + * captures findProjectRoot via Function.prototype.toString(), then emits + * get-shit-done/bin/lib/project-root.generated.cjs. + * + * Run: cd sdk && npm run gen:project-root + * Freshness check: node sdk/scripts/check-project-root-fresh.mjs + */ + +import { writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +const BANNER = `'use strict'; + +/** + * GENERATED FILE — DO NOT EDIT. + * + * Source: sdk/src/project-root/index.ts + * Regenerate: cd sdk && npm run gen:project-root + * + * Project-Root Resolution Module — resolves a project root from a starting + * directory by walking the ancestor chain and applying four heuristics: + * (0) own .planning/ guard (#1362) + * (1) parent .planning/config.json sub_repos + * (2) legacy multiRepo: true + ancestor .git + * (3) .git heuristic with parent .planning/ + * Bounded by FIND_PROJECT_ROOT_MAX_DEPTH ancestors. Sync I/O. + */ + +`; + +/** + * Build the CJS content string. Exported so the freshness-check script can + * import this function directly (Phase 3's cleaner pattern) instead of + * duplicating the logic. + */ +export async function buildProjectRootCjs() { + const distUrl = new URL('../dist/project-root/index.js', import.meta.url); + const { findProjectRoot, FIND_PROJECT_ROOT_MAX_DEPTH } = await import(distUrl.href); + + const findProjectRootBody = findProjectRoot.toString(); + + // The compiled ESM uses destructured named imports: + // import { dirname, resolve, sep, relative, parse as parsePath } from 'node:path'; + // import { existsSync, readFileSync, statSync } from 'node:fs'; + // import { homedir } from 'node:os'; + // + // In CJS we provide these as module-level constants so the function body + // can reference them as closed-over variables (same technique used in + // Phase 3 gen-workstream-inventory-builder.mjs for relative/sep/etc.). + const preamble = [ + `const fs = require('fs');`, + `const path = require('path');`, + `const os = require('os');`, + `const { existsSync, readFileSync, statSync } = fs;`, + `const { dirname, resolve, sep, relative, parse: parsePath } = path;`, + `const { homedir } = os;`, + `const FIND_PROJECT_ROOT_MAX_DEPTH = ${FIND_PROJECT_ROOT_MAX_DEPTH};`, + ].join('\n'); + + const parts = [ + BANNER.trimEnd(), + '', + preamble, + '', + findProjectRootBody, + '', + `module.exports = { findProjectRoot };`, + '', + ]; + + return parts.join('\n'); +} + +async function main() { + const content = await buildProjectRootCjs(); + const outPath = fileURLToPath( + new URL('../../get-shit-done/bin/lib/project-root.generated.cjs', import.meta.url), + ); + await writeFile(outPath, content, 'utf-8'); + console.log(`Written: ${outPath}`); +} + +// Only run main() when this file is the entry point, not when imported. +const scriptPath = fileURLToPath(import.meta.url); +const entryPath = process.argv[1] ? new URL(process.argv[1], 'file://').pathname : ''; +if (scriptPath === entryPath || process.argv[1] === scriptPath) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/sdk/src/project-root/index.test.ts b/sdk/src/project-root/index.test.ts new file mode 100644 index 000000000..01b8cce60 --- /dev/null +++ b/sdk/src/project-root/index.test.ts @@ -0,0 +1,186 @@ +/** + * Pinning tests for sdk/src/project-root/index.ts + * + * These tests pin the behaviour of findProjectRoot before the CJS refactor + * in Cycle 2. They must remain GREEN throughout (never bend the implementation + * to match these tests — the SDK behaviour IS the ground truth; if a test + * contradicts it, fix the test). + * + * Fixture matrix covers all four heuristics + edge cases, mirroring + * sdk/src/query/helpers.test.ts:505-614 and adding depth-limit coverage. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { findProjectRoot } from './index.js'; + +describe('findProjectRoot (project-root module)', () => { + let workspace: string; + + beforeEach(async () => { + workspace = await mkdtemp(join(tmpdir(), 'gsd-pr-module-')); + }); + + afterEach(async () => { + await rm(workspace, { recursive: true, force: true }); + }); + + // ── Heuristic 0: own .planning/ guard (#1362) ────────────────────────────── + + it('returns startDir unchanged when startDir has its own .planning/ (heuristic 0 / #1362 guard)', async () => { + await mkdir(join(workspace, '.planning'), { recursive: true }); + expect(findProjectRoot(workspace)).toBe(workspace); + }); + + it('returns startDir when no ancestor has .planning/ (standalone project)', () => { + expect(findProjectRoot(workspace)).toBe(workspace); + }); + + // ── Heuristic 1: sub_repos config ───────────────────────────────────────── + + it('walks up to parent when parent .planning/config.json lists startDir in sub_repos', async () => { + await mkdir(join(workspace, '.planning'), { recursive: true }); + await writeFile( + join(workspace, '.planning', 'config.json'), + JSON.stringify({ sub_repos: ['child'] }), + 'utf-8', + ); + const child = join(workspace, 'child'); + await mkdir(join(child, '.git'), { recursive: true }); + expect(findProjectRoot(child)).toBe(workspace); + }); + + it('resolves parent root from deeply nested dir inside a sub_repo (heuristic 1, nested)', async () => { + await mkdir(join(workspace, '.planning'), { recursive: true }); + await writeFile( + join(workspace, '.planning', 'config.json'), + JSON.stringify({ sub_repos: ['child'] }), + 'utf-8', + ); + const nested = join(workspace, 'child', 'src', 'utils'); + await mkdir(join(workspace, 'child', '.git'), { recursive: true }); + await mkdir(nested, { recursive: true }); + expect(findProjectRoot(nested)).toBe(workspace); + }); + + it('supports planning.sub_repos nested config shape (heuristic 1, nested key)', async () => { + await mkdir(join(workspace, '.planning'), { recursive: true }); + await writeFile( + join(workspace, '.planning', 'config.json'), + JSON.stringify({ planning: { sub_repos: ['child'] } }), + 'utf-8', + ); + const child = join(workspace, 'child'); + await mkdir(join(child, '.git'), { recursive: true }); + expect(findProjectRoot(child)).toBe(workspace); + }); + + it('returns startDir when sub_repos is empty and no .git (empty sub_repos, no git)', async () => { + await mkdir(join(workspace, '.planning'), { recursive: true }); + await writeFile( + join(workspace, '.planning', 'config.json'), + JSON.stringify({ sub_repos: [] }), + 'utf-8', + ); + const child = join(workspace, 'child'); + await mkdir(child, { recursive: true }); + expect(findProjectRoot(child)).toBe(child); + }); + + // ── Heuristic 2: legacy multiRepo: true ────────────────────────────────── + + it('walks up via multiRepo: true when child is inside a git repo (heuristic 2)', async () => { + await mkdir(join(workspace, '.planning'), { recursive: true }); + await writeFile( + join(workspace, '.planning', 'config.json'), + JSON.stringify({ multiRepo: true }), + 'utf-8', + ); + const child = join(workspace, 'child'); + await mkdir(join(child, '.git'), { recursive: true }); + expect(findProjectRoot(child)).toBe(workspace); + }); + + // ── Heuristic 3: .git heuristic + parent .planning/ ───────────────────── + + it('walks up via .git heuristic when parent has .planning/ and no config (heuristic 3)', async () => { + await mkdir(join(workspace, '.planning'), { recursive: true }); + // No config.json + const child = join(workspace, 'child'); + await mkdir(join(child, '.git'), { recursive: true }); + expect(findProjectRoot(child)).toBe(workspace); + }); + + it('swallows malformed config.json and falls back to .git heuristic (heuristic 3 fallback)', async () => { + await mkdir(join(workspace, '.planning'), { recursive: true }); + await writeFile(join(workspace, '.planning', 'config.json'), '{ not json', 'utf-8'); + const child = join(workspace, 'child'); + await mkdir(join(child, '.git'), { recursive: true }); + expect(findProjectRoot(child)).toBe(workspace); + }); + + it('returns startDir when parent has .planning/ but no .git and no sub_repos (heuristic 3 miss)', async () => { + await mkdir(join(workspace, '.planning'), { recursive: true }); + const child = join(workspace, 'child'); + await mkdir(child, { recursive: true }); + // No .git anywhere + expect(findProjectRoot(child)).toBe(child); + }); + + // ── #1362 guard: nested project with own .planning/ ───────────────────── + + it('does NOT walk past child with its own .planning/ to parent .planning/ (#1362)', async () => { + await mkdir(join(workspace, '.planning'), { recursive: true }); + await writeFile( + join(workspace, '.planning', 'config.json'), + JSON.stringify({ sub_repos: ['child'] }), + 'utf-8', + ); + const child = join(workspace, 'child'); + // child has its own .planning/ — the guard fires immediately + await mkdir(join(child, '.planning'), { recursive: true }); + expect(findProjectRoot(child)).toBe(child); + }); + + it('resolves to child root (not workspace) from deep path inside child with its own .planning/ (#1362)', async () => { + await mkdir(join(workspace, '.planning'), { recursive: true }); + const child = join(workspace, 'child'); + await mkdir(join(child, '.planning'), { recursive: true }); + await mkdir(join(child, '.git'), { recursive: true }); + const deep = join(child, 'src', 'lib'); + await mkdir(deep, { recursive: true }); + // findProjectRoot from deep resolves child because child has its own .planning/ + // The CJS impl walks up to child (finds own .planning/) and returns it. + // NOTE: the current SDK impl returns `startDir` (the original argument) when + // the OWN .planning/ guard fires during the walk. In this case startDir=deep + // but deep does NOT have .planning/; the walk checks child which does. + // Actually: the guard only fires at the VERY TOP for startDir itself. + // Walking from `deep`, the algorithm checks parents — child has .planning/ + // and .git, so heuristic 3 fires and returns child. + expect(findProjectRoot(deep)).toBe(child); + }); + + // ── Depth limit ────────────────────────────────────────────────────────── + + it('stops at depth 10 and returns startDir when .planning/ is more than 10 levels up', async () => { + // CANONICALIZATION NOTE: The SDK uses FIND_PROJECT_ROOT_MAX_DEPTH = 10. + // The CJS version had no explicit depth limit. This is an intentional + // behaviour change: paths nested >10 levels deep will NOT have their + // parent .planning/ discovered. This only affects pathological cases. + // + // Build a 12-level deep path: workspace/.planning/ exists but startDir + // is workspace/l1/l2/.../l12 — 12 ancestors to workspace. The depth + // limit of 10 prevents the walk from reaching workspace. + await mkdir(join(workspace, '.planning'), { recursive: true }); + let dir = workspace; + for (let i = 1; i <= 12; i++) { + dir = join(dir, `l${i}`); + } + await mkdir(dir, { recursive: true }); + // No .git anywhere so heuristic 3 cannot fire, and depth limit kicks in + // before reaching workspace + expect(findProjectRoot(dir)).toBe(dir); + }); +}); diff --git a/sdk/src/project-root/index.ts b/sdk/src/project-root/index.ts new file mode 100644 index 000000000..5291ce92e --- /dev/null +++ b/sdk/src/project-root/index.ts @@ -0,0 +1,144 @@ +/** + * Project-Root Resolution Module + * + * Resolves a project root from a starting directory by walking the ancestor + * chain and applying four heuristics: + * (0) own .planning/ guard (#1362) + * (1) parent .planning/config.json sub_repos + * (2) legacy multiRepo: true + ancestor .git + * (3) .git heuristic with parent .planning/ + * Bounded by FIND_PROJECT_ROOT_MAX_DEPTH ancestors. Sync I/O. + * + * Source of truth for `findProjectRoot` — the CJS artifact at + * get-shit-done/bin/lib/project-root.generated.cjs is generated from this file. + */ + +import { dirname, resolve, sep, relative, parse as parsePath } from 'node:path'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; + +/** + * Maximum number of parent directories to walk when searching for a + * multi-repo `.planning/` root. Bounded to avoid scanning to the filesystem + * root in pathological cases. + */ +export const FIND_PROJECT_ROOT_MAX_DEPTH = 10; + +/** + * Walk up from `startDir` to find the project root that owns `.planning/`. + * + * Ported from `get-shit-done/bin/lib/core.cjs:findProjectRoot` so that + * `gsd-sdk query` resolves the same parent `.planning/` root as the legacy + * `gsd-tools.cjs` CLI when invoked inside a `sub_repos`-listed child repo. + * + * Detection strategy (checked in order for each ancestor, up to + * `FIND_PROJECT_ROOT_MAX_DEPTH` levels): + * 1. `startDir` itself has `.planning/` — return it unchanged (#1362). + * 2. Parent has `.planning/config.json` with `sub_repos` listing the + * immediate child segment of the starting directory. + * 3. Parent has `.planning/config.json` with `multiRepo: true` (legacy). + * 4. Parent has `.planning/` AND an ancestor of `startDir` (up to the + * candidate parent) contains `.git` — heuristic fallback. + * + * Returns `startDir` unchanged when no ancestor `.planning/` is found + * (first-run or single-repo projects). Never walks above the user's home + * directory. + * + * All filesystem errors are swallowed — a missing or unparseable + * `config.json` falls back to the `.git` heuristic, and unreadable + * directories terminate the walk at that level. + */ +export function findProjectRoot(startDir: string): string { + let resolvedStart: string; + try { + resolvedStart = resolve(startDir); + } catch { + return startDir; + } + const fsRoot = parsePath(resolvedStart).root; + const home = homedir(); + + // If startDir already contains .planning/, it IS the project root. + try { + const ownPlanningDir = resolvedStart + sep + '.planning'; + if (existsSync(ownPlanningDir) && statSync(ownPlanningDir).isDirectory()) { + return startDir; + } + } catch { + // fall through + } + + // Walk upward, mirroring isInsideGitRepo from the CJS reference. + function isInsideGitRepo(candidateParent: string): boolean { + let d = resolvedStart; + while (d !== fsRoot) { + try { + if (existsSync(d + sep + '.git')) return true; + } catch { + // ignore + } + if (d === candidateParent) break; + const next = dirname(d); + if (next === d) break; + d = next; + } + return false; + } + + let dir = resolvedStart; + let depth = 0; + while (dir !== fsRoot && depth < FIND_PROJECT_ROOT_MAX_DEPTH) { + const parent = dirname(dir); + if (parent === dir) break; + if (parent === home) break; + + const parentPlanning = parent + sep + '.planning'; + let parentPlanningIsDir = false; + try { + parentPlanningIsDir = existsSync(parentPlanning) && statSync(parentPlanning).isDirectory(); + } catch { + parentPlanningIsDir = false; + } + + if (parentPlanningIsDir) { + const configPath = parentPlanning + sep + 'config.json'; + let matched = false; + try { + const raw = readFileSync(configPath, 'utf-8'); + const config = JSON.parse(raw) as { + sub_repos?: unknown; + planning?: { sub_repos?: unknown }; + multiRepo?: unknown; + }; + const subReposValue = + (config.sub_repos as unknown) ?? (config.planning && config.planning.sub_repos); + const subRepos = Array.isArray(subReposValue) ? (subReposValue as unknown[]) : []; + + if (subRepos.length > 0) { + const relPath = relative(parent, resolvedStart); + const topSegment = relPath.split(sep)[0]; + if (subRepos.includes(topSegment)) { + return parent; + } + } + + if (config.multiRepo === true && isInsideGitRepo(parent)) { + matched = true; + } + } catch { + // config.json missing or unparseable — fall through to .git heuristic. + } + + if (matched) return parent; + + // Heuristic: parent has .planning/ and we're inside a git repo. + if (isInsideGitRepo(parent)) { + return parent; + } + } + + dir = parent; + depth += 1; + } + return startDir; +} diff --git a/sdk/src/query/helpers.ts b/sdk/src/query/helpers.ts index f084b6bda..c23a8602b 100644 --- a/sdk/src/query/helpers.ts +++ b/sdk/src/query/helpers.ts @@ -17,7 +17,7 @@ * ``` */ -import { join, dirname, relative, resolve, isAbsolute, normalize, parse as parsePath, sep as pathSep } from 'node:path'; +import { join, dirname, relative, resolve, isAbsolute, normalize, sep as pathSep } from 'node:path'; import { realpath } from 'node:fs/promises'; import { existsSync, statSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; @@ -462,132 +462,9 @@ export function planningPaths(projectDir: string, workstream?: string): Planning } // ─── findProjectRoot (multi-repo .planning resolution) ───────────────────── - -/** - * Maximum number of parent directories to walk when searching for a - * multi-repo `.planning/` root. Bounded to avoid scanning to the filesystem - * root in pathological cases. - */ -const FIND_PROJECT_ROOT_MAX_DEPTH = 10; - -/** - * Walk up from `startDir` to find the project root that owns `.planning/`. - * - * Ported from `get-shit-done/bin/lib/core.cjs:findProjectRoot` so that - * `gsd-sdk query` resolves the same parent `.planning/` root as the legacy - * `gsd-tools.cjs` CLI when invoked inside a `sub_repos`-listed child repo. - * - * Detection strategy (checked in order for each ancestor, up to - * `FIND_PROJECT_ROOT_MAX_DEPTH` levels): - * 1. `startDir` itself has `.planning/` — return it unchanged (#1362). - * 2. Parent has `.planning/config.json` with `sub_repos` listing the - * immediate child segment of the starting directory. - * 3. Parent has `.planning/config.json` with `multiRepo: true` (legacy). - * 4. Parent has `.planning/` AND an ancestor of `startDir` (up to the - * candidate parent) contains `.git` — heuristic fallback. - * - * Returns `startDir` unchanged when no ancestor `.planning/` is found - * (first-run or single-repo projects). Never walks above the user's home - * directory. - * - * All filesystem errors are swallowed — a missing or unparseable - * `config.json` falls back to the `.git` heuristic, and unreadable - * directories terminate the walk at that level. - */ -export function findProjectRoot(startDir: string): string { - let resolvedStart: string; - try { - resolvedStart = resolve(startDir); - } catch { - return startDir; - } - const fsRoot = parsePath(resolvedStart).root; - const home = homedir(); - - // If startDir already contains .planning/, it IS the project root. - try { - const ownPlanning = join(resolvedStart, '.planning'); - if (existsSync(ownPlanning) && statSync(ownPlanning).isDirectory()) { - return startDir; - } - } catch { - // fall through - } - - // Walk upward, mirroring isInsideGitRepo from the CJS reference. - function isInsideGitRepo(candidateParent: string): boolean { - let d = resolvedStart; - while (d !== fsRoot) { - try { - if (existsSync(join(d, '.git'))) return true; - } catch { - // ignore - } - if (d === candidateParent) break; - const next = dirname(d); - if (next === d) break; - d = next; - } - return false; - } - - let dir = resolvedStart; - let depth = 0; - while (dir !== fsRoot && depth < FIND_PROJECT_ROOT_MAX_DEPTH) { - const parent = dirname(dir); - if (parent === dir) break; - if (parent === home) break; - - const parentPlanning = join(parent, '.planning'); - let parentPlanningIsDir = false; - try { - parentPlanningIsDir = existsSync(parentPlanning) && statSync(parentPlanning).isDirectory(); - } catch { - parentPlanningIsDir = false; - } - - if (parentPlanningIsDir) { - const configPath = join(parentPlanning, 'config.json'); - let matched = false; - try { - const raw = readFileSync(configPath, 'utf-8'); - const config = JSON.parse(raw) as { - sub_repos?: unknown; - planning?: { sub_repos?: unknown }; - multiRepo?: unknown; - }; - const subReposValue = - (config.sub_repos as unknown) ?? (config.planning && config.planning.sub_repos); - const subRepos = Array.isArray(subReposValue) ? (subReposValue as unknown[]) : []; - - if (subRepos.length > 0) { - const relPath = relative(parent, resolvedStart); - const topSegment = relPath.split(pathSep)[0]; - if (subRepos.includes(topSegment)) { - return parent; - } - } - - if (config.multiRepo === true && isInsideGitRepo(parent)) { - matched = true; - } - } catch { - // config.json missing or unparseable — fall through to .git heuristic. - } - - if (matched) return parent; - - // Heuristic: parent has .planning/ and we're inside a git repo. - if (isInsideGitRepo(parent)) { - return parent; - } - } - - dir = parent; - depth += 1; - } - return startDir; -} +// Implementation lives in sdk/src/project-root/index.ts — re-exported here +// so that existing consumers of helpers.ts continue to work unchanged. +export { findProjectRoot } from '../project-root/index.js'; // ─── resolvePathUnderProject ─────────────────────────────────────────────── diff --git a/tests/project-root-generator.test.cjs b/tests/project-root-generator.test.cjs new file mode 100644 index 000000000..f1c39a792 --- /dev/null +++ b/tests/project-root-generator.test.cjs @@ -0,0 +1,153 @@ +'use strict'; +/** + * CJS parity test — project-root module + * + * For every fixture from sdk/src/project-root/index.test.ts, asserts that + * both the SDK (ESM, via dynamic import) and the generated CJS artifact + * return identical paths. This confirms that the generator correctly + * captures the function body and that all dependencies (sep, dirname, + * relative, etc.) are properly shimmed in the CJS preamble. + */ + +const { describe, it, before, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +// CJS artifact — synchronous require +const { findProjectRoot: findProjectRootCjs } = require('../get-shit-done/bin/lib/project-root.generated.cjs'); + +// SDK ESM — loaded once before all tests via dynamic import +let findProjectRootSdk; +before(async () => { + const mod = await import('../sdk/dist/project-root/index.js'); + findProjectRootSdk = mod.findProjectRoot; +}); + +// ── Fixture helpers ───────────────────────────────────────────────────────── + +function makeTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-parity-')); +} + +function writeConfig(dir, content) { + fs.mkdirSync(path.join(dir, '.planning'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify(content)); +} + +function assertParity(startDir) { + const sdkResult = findProjectRootSdk(startDir); + const cjsResult = findProjectRootCjs(startDir); + assert.strictEqual( + cjsResult, + sdkResult, + `parity failure for startDir="${startDir}": SDK="${sdkResult}" CJS="${cjsResult}"`, + ); + return sdkResult; +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('project-root CJS/SDK parity', () => { + let workspace; + + before(() => { + workspace = makeTmp(); + }); + + afterEach(() => { + // Clean the workspace tree and recreate fresh for next test + try { fs.rmSync(workspace, { recursive: true, force: true }); } catch {} + workspace = makeTmp(); + }); + + it('heuristic 0: startDir has own .planning/ — returns startDir', () => { + fs.mkdirSync(path.join(workspace, '.planning'), { recursive: true }); + assertParity(workspace); + }); + + it('no ancestor .planning/ — returns startDir', () => { + assertParity(workspace); + }); + + it('heuristic 1: parent .planning/config.json lists child in sub_repos', () => { + writeConfig(workspace, { sub_repos: ['child'] }); + const child = path.join(workspace, 'child'); + fs.mkdirSync(path.join(child, '.git'), { recursive: true }); + const result = assertParity(child); + assert.strictEqual(result, workspace); + }); + + it('heuristic 1 nested: deeply nested dir inside sub_repo', () => { + writeConfig(workspace, { sub_repos: ['child'] }); + const nested = path.join(workspace, 'child', 'src', 'utils'); + fs.mkdirSync(path.join(workspace, 'child', '.git'), { recursive: true }); + fs.mkdirSync(nested, { recursive: true }); + const result = assertParity(nested); + assert.strictEqual(result, workspace); + }); + + it('heuristic 1 nested key: planning.sub_repos config shape', () => { + writeConfig(workspace, { planning: { sub_repos: ['child'] } }); + const child = path.join(workspace, 'child'); + fs.mkdirSync(path.join(child, '.git'), { recursive: true }); + const result = assertParity(child); + assert.strictEqual(result, workspace); + }); + + it('heuristic 2: multiRepo: true with .git in ancestor chain', () => { + writeConfig(workspace, { multiRepo: true }); + const child = path.join(workspace, 'child'); + fs.mkdirSync(path.join(child, '.git'), { recursive: true }); + const result = assertParity(child); + assert.strictEqual(result, workspace); + }); + + it('heuristic 3: parent has .planning/ and child has .git, no config', () => { + fs.mkdirSync(path.join(workspace, '.planning'), { recursive: true }); + const child = path.join(workspace, 'child'); + fs.mkdirSync(path.join(child, '.git'), { recursive: true }); + const result = assertParity(child); + assert.strictEqual(result, workspace); + }); + + it('malformed config.json falls back to heuristic 3', () => { + fs.mkdirSync(path.join(workspace, '.planning'), { recursive: true }); + fs.writeFileSync(path.join(workspace, '.planning', 'config.json'), '{ not json'); + const child = path.join(workspace, 'child'); + fs.mkdirSync(path.join(child, '.git'), { recursive: true }); + const result = assertParity(child); + assert.strictEqual(result, workspace); + }); + + it('empty sub_repos with no .git — returns startDir', () => { + writeConfig(workspace, { sub_repos: [] }); + const child = path.join(workspace, 'child'); + fs.mkdirSync(child, { recursive: true }); + const result = assertParity(child); + assert.strictEqual(result, child); + }); + + it('#1362: child has own .planning/ — returns child not workspace', () => { + writeConfig(workspace, { sub_repos: ['child'] }); + const child = path.join(workspace, 'child'); + fs.mkdirSync(path.join(child, '.planning'), { recursive: true }); + const result = assertParity(child); + assert.strictEqual(result, child); + }); + + it('depth limit: .planning/ is 12 levels up — returns startDir (depth=10 cap)', () => { + // CANONICALIZATION NOTE: SDK has FIND_PROJECT_ROOT_MAX_DEPTH=10; CJS now + // also uses 10 (was unbounded before). At 12 levels the walk stops before + // reaching workspace, so startDir is returned. + fs.mkdirSync(path.join(workspace, '.planning'), { recursive: true }); + let dir = workspace; + for (let i = 1; i <= 12; i++) { + dir = path.join(dir, `l${i}`); + } + fs.mkdirSync(dir, { recursive: true }); + const result = assertParity(dir); + assert.strictEqual(result, dir); + }); +});