diff --git a/.changeset/plucky-newts-squeak.md b/.changeset/plucky-newts-squeak.md new file mode 100644 index 000000000..b4d77ce07 --- /dev/null +++ b/.changeset/plucky-newts-squeak.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 4124 +--- +**The codebase drift check now reports real drift** instead of flagging every file in the repository on every run. Mapping a codebase records the point it was mapped at, so the check compares against that point, and it skips with a reason when no such record exists. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 085fdd0b8..955c24781 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -792,6 +792,22 @@ verification. `.planning/codebase/*.md` file; `bin/lib/drift.cjs` provides `readMappedCommit` and `writeMappedCommit` round-trip helpers. +The baseline is written by `gsd-tools stamp-codebase-map`, a shell step in the +map-codebase workflow, not by the mapper agent. The mapper's own freshness +markers (`**Analysis Date:**`, ``) are restamped +unconditionally on an Update run, so an agent that rewrites only the dates still +looks current to a reader; the machine-readable stamp is the one marker that +cannot be satisfied by a date-only rewrite, which is exactly why it is not the +agent's to write. `--files a.md,b.md` narrows the stamp to the documents a +caller actually refreshed, as the auto-remap path does. + +An absent or unresolvable baseline is reported as `skipped` with reason +`no-mapped-commit` or `unresolvable-mapped-commit`, never as drift. Diffing +HEAD against the empty tree would report every tracked file as newly added, +which makes a stale map indistinguishable from a fresh one. Files under +`.planning/` are excluded from the diff: the map's own commit is a planning +artifact, not codebase structure. + --- ## Installer Architecture diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index c84258d90..389ef811f 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -4321,6 +4321,21 @@ const HOST_COMMAND_ROUTERS = { // rather than a family — ADR-2346 promotes to a family only at >=3. 'estimate-check': ({ args, cwd, raw }) => estimateCli.cmdEstimateCheck(cwd, args.slice(1), raw), 'estimate-calibration': ({ args, cwd, raw }) => estimateCli.cmdEstimateCalibration(cwd, args.slice(1), raw), + // #3418: writes `last_mapped_commit` into every codebase-map document that + // exists, closing the loop drift.cjs was built for. A LEAF verb rather than a + // `verify` subcommand on purpose -- the verify family is read-only by + // contract and this one mutates; ADR-2346 promotes a leaf to a family only at + // >=3 verbs, and this is one. + 'stamp-codebase-map': ({ args, cwd, raw, error }) => { + const { files } = parseNamedArgsOrExit(args, { valueFlags: ['files'], positionals: 1 }, error); + // A value flag with no value parses to `null`, same as an absent one, so + // presence is read off `args`: a bare `--files` (an unquoted empty shell + // variable) must hit the empty-filter refusal, not widen to all seven. + const only = args.includes('--files') + ? String(files ?? '').split(',').map((f) => f.trim()).filter(Boolean) + : undefined; + verify.cmdStampCodebaseMap(cwd, raw, only); + }, 'estimate-calibrate': ({ args, cwd, raw }) => estimateCli.cmdEstimateCalibrate(cwd, args.slice(1), raw), 'config-new-project': routeConfigNewProject, 'config-path': routeConfigPath, @@ -4619,7 +4634,7 @@ const TOP_LEVEL_USAGE = 'Usage: gsd-tools [args] [--raw] [--pick `). If all provided paths are invalid, fall back to a normal whole-repo run. -- On write, each mapper stamps `last_mapped_commit: ` into the YAML - frontmatter of every document it produces (see `bin/lib/drift.cjs:writeMappedCommit`). +- The `last_mapped_commit` baseline is NOT the mapper's job. It is stamped + deterministically by the `stamp_codebase_map` step below, on every run, + incremental or full. See that step for why. **Explicit contract — propagate `--paths` through a single normalized variable.** Downstream steps (`spawn_agents`, `sequential_mapping`, and any @@ -103,9 +104,21 @@ What's next? Wait for user response. If "Refresh": Delete .planning/codebase/, continue to create_structure -If "Update": Ask which documents to update, continue to spawn_agents (filtered) +If "Update": Ask which documents to update, then record the selection for the +stamp step below and continue to spawn_agents (filtered): + +```bash +# Comma-separated filenames the user selected, e.g. "STACK.md,CONCERNS.md": +UPDATED_DOCS="" +``` + If "Skip": Exit workflow +`UPDATED_DOCS` narrows `stamp_codebase_map`. Leave it empty on every other +path (Refresh, first run, `--paths`), which regenerate all seven documents. +An Update run does not touch the documents the user did not select, so +stamping those at HEAD would claim a freshness they do not have. + **If doesn't exist:** Continue to create_structure. @@ -347,6 +360,40 @@ wc -l .planning/codebase/*.md If any documents missing or empty, note which agents may have failed. +Continue to stamp_codebase_map. + + + +Stamp the drift baseline into every document that was just written: + +```bash +gsd_run stamp-codebase-map ${UPDATED_DOCS:+--files "$UPDATED_DOCS"} +``` + +This writes `last_mapped_commit: ` and `last_mapped_at: ` into +the YAML frontmatter of each `.planning/codebase/*.md` that exists. It runs on +every mapping run, incremental (`--paths`) and full alike. `--files` narrows it +to the documents an Update run actually refreshed; `--paths` needs no narrowing +because all seven are regenerated, just scoped in content. + +**Why this is a shell step and not an instruction to the mapper.** The stamp is +the only machine-readable freshness marker: the `verify codebase-drift` gate +reads it to decide what to diff HEAD against. The human-readable markers the +mapper writes (`**Analysis Date:**`, ``) are restamped +unconditionally on an Update run, so a mapper that decides its work is already +done and rewrites only the dates still looks fresh to a human. Leaving the +machine-readable stamp to the same agent reproduces exactly the failure the +stamp exists to detect. A shell step cannot be skipped by a confident agent. + +The command is non-blocking: it emits `skipped` with a `reason` outside a git +repo or when no documents exist. Report `stamped` and `commit` in the summary +if any entry in `failed` is non-empty; otherwise continue silently. + +Run in this position, before `commit_codebase_map`, the stamp lands on +documents the mapper just wrote, so its markdown whitespace normalization is +folded into the same commit. Running `stamp-codebase-map` by hand against an +already-committed map reflows that map's whitespace as a side effect. + Continue to scan_for_secrets. diff --git a/src/verify.cts b/src/verify.cts index 05e9d338c..d8e1ef3e5 100644 --- a/src/verify.cts +++ b/src/verify.cts @@ -61,8 +61,12 @@ type HealthDiagnostic = healthDiagnosticMod.Diagnostic; // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-snapshot.cjs is an export= CommonJS module import planningSnapshotMod = require('./planning-snapshot.cjs'); const { buildPlanningSnapshot } = planningSnapshotMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports -- onboard-projection.cjs is an export= CommonJS module +import onboardProjectionMod = require('./onboard-projection.cjs'); +const { REQUIRED_CODEBASE_MAP_FILES } = onboardProjectionMod; +import { realClock } from './clock.cjs'; -const { planningDir } = planningWorkspace; +const { planningDir, planningRoot, withPlanningLock } = planningWorkspace; const { defaultPhaseCleanCommitTimesMs } = verificationMod; const { extractFrontmatter, parseMustHavesBlock } = frontmatterMod; const { readStateHeadFreshness } = stateMod; @@ -2231,6 +2235,118 @@ function cmdVerifySchemaDrift( ); } +/** + * Stamp `last_mapped_commit` (plus `last_mapped_at`) into the frontmatter of + * every codebase-map document that exists on disk, using the current HEAD sha. + * + * #3418: `drift.cjs` shipped a correct `writeMappedCommit` with no production + * caller, so no full `/gsd:map-codebase` run ever wrote the machine-readable + * baseline that `cmdVerifyCodebaseDrift` reads. The stamp lives in CODE rather + * than in a prose instruction to the mapper agent on purpose: an agent that + * decides its work is already done skips a prose step silently, which is the + * exact class of failure the stamp exists to detect. + * + * Only documents that already exist are stamped -- a `--fast` map produces four + * of the seven, and this must not conjure the missing three as frontmatter-only + * stubs that would then satisfy the seven-file completeness probe. `only` + * (`--files a.md,b.md`) narrows further, for a caller that refreshed a subset. + * + * Non-blocking by contract, like every other drift surface: any failure emits + * `skipped` with a reason and exits 0. + */ +function cmdStampCodebaseMap(cwd: string, raw: boolean, only?: string[]): void { + // Non-hoisted: load-order matters for circular dep guard + // eslint-disable-next-line @typescript-eslint/no-require-imports -- drift.cjs is an export= CommonJS module + const drift = require('./drift.cjs') as Record; + + const emit = (payload: unknown) => output(payload, raw); + + const skip = (reason: string) => ({ + stamped: [], + commit: null, + stamped_at: null, + skipped: true, + reason, + }); + + try { + // Probed outside the lock on purpose: taking the lock creates `.planning/` + // if absent, and a stamp against a project that has no codebase map at all + // must not conjure the directory. The per-document existence check inside + // the lock is the one that matters -- a map deleted after this probe lands + // on `no-codebase-map` there rather than being recreated as stubs. + const codebaseDir = path.join(planningDir(cwd), 'codebase'); + if (!fs.existsSync(codebaseDir)) { + emit(skip('no-codebase-dir')); + return; + } + + // `--files` restricts the stamp to the documents a caller actually + // refreshed. The execute-phase auto-remap path rewrites STRUCTURE.md and + // ARCHITECTURE.md only; stamping the other five at HEAD there would claim a + // currency they do not have. Membership is checked against the closed + // seven-document set, so an unknown name is a fail-loud non-answer rather + // than a path this function tries to resolve. An empty value is refused + // rather than read as "no filter": a caller that meant to narrow the scope + // must not silently widen it to all seven. + let candidates = REQUIRED_CODEBASE_MAP_FILES; + if (only) { + if (only.length === 0) { + emit(skip('empty-codebase-map-file-filter')); + return; + } + const unknown = only.filter((file) => !candidates.includes(file)); + if (unknown.length > 0) { + emit(skip('unknown-codebase-map-file: ' + unknown.join(','))); + return; + } + candidates = candidates.filter((file) => only.includes(file)); + } + + // Everything the stamp reads is read under the lock it writes under, the + // same lock the rest of the .planning/ writers take. Two stampers can run + // at once (the full map-codebase run and the execute-phase auto-remap); + // one that resolved HEAD or listed the present documents before waiting on + // the lock would write its now-stale sha over the newer one, or recreate a + // document deleted while it waited as a frontmatter-only stub. + const result = withPlanningLock(cwd, () => { + const revProbe = execGit(['rev-parse', 'HEAD'], { cwd }) as unknown as { exitCode: number; stdout: string }; + if (revProbe.exitCode !== 0) return skip('not-a-git-repo'); + const commit = revProbe.stdout.trim(); + if (!/^[0-9a-f]{7,40}$/.test(commit)) return skip('unreadable-head'); + + const present = candidates.filter((file) => + fs.existsSync(path.join(codebaseDir, file)), + ); + if (present.length === 0) return skip('no-codebase-map'); + + // Host-local calendar day, matching the `**Analysis Date:**` line the + // mapper agent writes -- the two freshness markers must not disagree by + // a timezone. + const stampedAt = realClock.localToday(); + const write = drift['writeMappedCommit'] as (f: string, sha: string, iso: string) => void; + + const stamped: string[] = []; + const failed: { file: string; reason: string }[] = []; + for (const file of present) { + try { + write(path.join(codebaseDir, file), commit, stampedAt); + stamped.push(file); + } catch (err) { + // One unwritable document must not cost the stamp on the other six. + failed.push({ file, reason: err instanceof Error ? err.message : String(err) }); + } + } + + return { stamped, failed, commit, stamped_at: stampedAt, skipped: false, reason: null }; + }); + + emit(result); + } catch (err) { + emit(skip('exception: ' + (err instanceof Error ? err.message : String(err)))); + } +} + function cmdVerifyCodebaseDrift(cwd: string, raw: boolean): void { // Non-hoisted: load-order matters for circular dep guard // eslint-disable-next-line @typescript-eslint/no-require-imports -- drift.cjs is an export= CommonJS module @@ -2284,14 +2400,45 @@ function cmdVerifyCodebaseDrift(cwd: string, raw: boolean): void { return; } - const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; - let base = lastMapped; - if (!base) { - base = EMPTY_TREE; - } else { - const verify = execGit(['cat-file', '-t', base], { cwd }) as unknown as { exitCode: number; stdout: string }; - if (verify.exitCode !== 0) base = EMPTY_TREE; + // #3418: an absent or unresolvable baseline means NO COMPARISON IS POSSIBLE. + // It is neither zero drift nor total drift, and reporting it as either is a + // lie the consumer cannot detect. The former fallback diffed HEAD against + // the empty tree, so every tracked file read as newly added and the gate + // reported maximum drift identically on every run -- which made a genuinely + // stale map indistinguishable from a fresh one, and let `spawn_mapper` fire + // a whole-repo remap while presenting itself as an incremental one. + if (!lastMapped) { + emit({ + block: false, + skipped: true, + reason: 'no-mapped-commit', + action_required: false, + directive: 'none', + elements: [], + last_mapped_commit: null, + }); + return; } + const baseProbe = execGit(['cat-file', '-t', lastMapped], { cwd }) as unknown as { exitCode: number; stdout: string }; + if (baseProbe.exitCode !== 0 || baseProbe.stdout.trim() !== 'commit') { + // A stamp git cannot resolve: history rewrite, GC, or a shallow clone. + // Distinct reason from 'no-mapped-commit' -- the map claims a baseline, + // this repository just cannot see it, which is an operator-actionable + // difference (re-map vs. unshallow). A resolvable non-commit (a tree or + // blob sha, a ref name) is the same class of bad baseline: git would + // happily diff against it and report drift against the wrong object. + emit({ + block: false, + skipped: true, + reason: 'unresolvable-mapped-commit', + action_required: false, + directive: 'none', + elements: [], + last_mapped_commit: lastMapped, + }); + return; + } + const base = lastMapped; const diff = execGit(['diff', '--name-status', base, 'HEAD'], { cwd }) as unknown as { exitCode: number; stdout: string }; if (diff.exitCode !== 0) { @@ -2306,6 +2453,28 @@ function cmdVerifyCodebaseDrift(cwd: string, raw: boolean): void { return; } + // #3418: GSD's own planning artifacts are not codebase structure. A + // map-codebase run commits `.planning/codebase/*.md`, so a correctly + // stamped baseline would be re-poisoned by the very commit that carries + // the stamp -- the next gate invocation would report the map's own seven + // documents as seven new directories, back over the default threshold of + // three. Derived from planningRoot() rather than a hardcoded literal so a + // repoint of the planning root cannot leave this filter behind. + // + // `git diff --name-status` always prints repo-root-relative paths, so a cwd + // below the root needs the `sub/` prefix or the filter matches nothing. + // That prefix comes from git (`--show-prefix`: root-relative, forward + // slashes, trailing slash, empty at the root). The rejected alternative was + // path.relative(`--show-toplevel`, cwd), which mixes two path producers: on + // Windows os.tmpdir() hands back the 8.3 short form while git resolves the + // long one, so relative() between them yields a `../..` chain that matches + // nothing. The `.planning` half below is safe to compute with relative() + // because both of its sides are the same cwd string. + const prefixProbe = execGit(['rev-parse', '--show-prefix'], { cwd }) as unknown as { exitCode: number; stdout: string }; + const repoPrefix = prefixProbe.exitCode === 0 ? prefixProbe.stdout.trim() : ''; + const planningPrefix = repoPrefix + path.relative(cwd, planningRoot(cwd)).split(path.sep).join('/') + '/'; + const isPlanningArtifact = (file: string) => file.split('\\').join('/').startsWith(planningPrefix); + const added: string[] = []; const modified: string[] = []; const deleted: string[] = []; @@ -2324,6 +2493,7 @@ function cmdVerifyCodebaseDrift(cwd: string, raw: boolean): void { // ASCII common case — passes through untouched. Both capture groups are // decoded: R/C lines carry old AND new paths, either may be quoted. const file = decodeGitQuotedPath(m[3] || m[2]); + if (isPlanningArtifact(file)) continue; if (status === 'A' || status === 'R' || status === 'C') added.push(file); else if (status === 'M') modified.push(file); else if (status === 'D') deleted.push(file); @@ -2404,5 +2574,6 @@ export = { cmdVerifyCodebaseDrift, computeContextDrift, cmdVerifyContextDrift, + cmdStampCodebaseMap, STATE_HEAD_ADVISORY_COMMITS, }; diff --git a/tests/drift-detection.test.cjs b/tests/drift-detection.test.cjs index 2fe07477f..875589983 100644 --- a/tests/drift-detection.test.cjs +++ b/tests/drift-detection.test.cjs @@ -1070,3 +1070,313 @@ describe('bug #619 — codebase-drift-gate resolves gsd-tools via the runtime sh }); }); } + +// ─── Regression #3418: the drift baseline is written by code, not by prose ─── +// +// `writeMappedCommit` shipped correct and callerless: nothing in the tree +// invoked it, so a full `/gsd:map-codebase` run wrote no `last_mapped_commit`. +// `cmdVerifyCodebaseDrift` then read null and fell back to diffing HEAD against +// the empty tree, so every tracked file read as newly added and the gate +// reported maximum drift identically on every run. Two halves, tested here: +// the `stamp-codebase-map` writer, and the reader's refusal to invent a +// baseline it does not have. + +const CODEBASE_MAP_DOCS = [ + 'STACK.md', 'ARCHITECTURE.md', 'STRUCTURE.md', 'CONVENTIONS.md', + 'TESTING.md', 'INTEGRATIONS.md', 'CONCERNS.md', +]; + +describe('stamp-codebase-map CLI (#3418)', () => { + let tmp; + let codebaseDir; + + function writeMap(docs = CODEBASE_MAP_DOCS) { + for (const doc of docs) { + fs.writeFileSync(path.join(codebaseDir, doc), `# ${doc}\n\nBody.\n`); + } + } + + beforeEach(() => { + tmp = createTempGitProject('gsd-stamp-3418-'); + codebaseDir = path.join(tmp, '.planning', 'codebase'); + fs.mkdirSync(codebaseDir, { recursive: true }); + }); + afterEach(() => cleanup(tmp)); + + test('stamps every codebase-map document with the HEAD sha (#3418)', () => { + writeMap(); + const head = git(tmp, 'rev-parse', 'HEAD'); + + const r = runGsdTools(['stamp-codebase-map'], tmp); + assert.strictEqual(r.success, true, r.error); + const data = JSON.parse(r.output); + + assert.strictEqual(data.skipped, false); + assert.strictEqual(data.commit, head); + assert.deepStrictEqual(data.failed, []); + assert.strictEqual(data.stamped.length, CODEBASE_MAP_DOCS.length); + + for (const doc of CODEBASE_MAP_DOCS) { + assert.strictEqual( + readMappedCommit(path.join(codebaseDir, doc)), head, + `${doc} must carry the HEAD sha; null means the writer is callerless again (#3418)`, + ); + } + }); + + test('stamps only documents that exist, never conjures the missing ones (#3418)', () => { + // The `--fast` map produces four of the seven. Creating the other three as + // frontmatter-only stubs would make them satisfy the seven-file + // completeness probe while carrying no analysis at all. + const fastDocs = ['STACK.md', 'INTEGRATIONS.md', 'ARCHITECTURE.md', 'STRUCTURE.md']; + writeMap(fastDocs); + + const r = runGsdTools(['stamp-codebase-map'], tmp); + assert.strictEqual(r.success, true, r.error); + const data = JSON.parse(r.output); + + assert.deepStrictEqual(data.stamped.sort(), [...fastDocs].sort()); + for (const doc of CODEBASE_MAP_DOCS.filter((d) => !fastDocs.includes(d))) { + assert.strictEqual( + fs.existsSync(path.join(codebaseDir, doc)), false, + `${doc} was absent before the stamp and must stay absent after it`, + ); + } + }); + + test('--files restricts the stamp to the named subset (#3418)', () => { + // The execute-phase auto-remap path refreshes STRUCTURE.md and + // ARCHITECTURE.md only. Stamping the other five at HEAD there would claim a + // currency they do not have. + writeMap(); + + const r = runGsdTools( + ['stamp-codebase-map', '--files', 'STRUCTURE.md,ARCHITECTURE.md'], tmp, + ); + assert.strictEqual(r.success, true, r.error); + const data = JSON.parse(r.output); + + assert.deepStrictEqual(data.stamped.sort(), ['ARCHITECTURE.md', 'STRUCTURE.md']); + assert.strictEqual(readMappedCommit(path.join(codebaseDir, 'CONCERNS.md')), null, + 'a document outside --files must be left unstamped, not stamped at HEAD'); + }); + + test('--files with an empty value stamps nothing rather than all seven (#4124 review)', () => { + writeMap(); + + const r = runGsdTools(['stamp-codebase-map', '--files', ''], tmp); + assert.strictEqual(r.success, true, 'must stay non-blocking'); + const data = JSON.parse(r.output); + + assert.strictEqual(data.skipped, true); + assert.strictEqual(data.reason, 'empty-codebase-map-file-filter'); + assert.strictEqual(readMappedCommit(path.join(codebaseDir, 'STRUCTURE.md')), null, + 'a caller narrowing the scope must not have it silently widened to the whole map'); + }); + + test('a bare --files stamps nothing rather than all seven (#4124 review)', () => { + writeMap(); + + const r = runGsdTools(['stamp-codebase-map', '--files'], tmp); + assert.strictEqual(r.success, true, 'must stay non-blocking'); + const data = JSON.parse(r.output); + + assert.strictEqual(data.skipped, true); + assert.strictEqual(data.reason, 'empty-codebase-map-file-filter'); + assert.strictEqual(readMappedCommit(path.join(codebaseDir, 'STRUCTURE.md')), null, + 'an unquoted empty shell variable drops the token, and must not widen the scope'); + }); + + test('--files with an unknown name stamps nothing and reports why (#3418)', () => { + writeMap(); + + const r = runGsdTools(['stamp-codebase-map', '--files', '../../etc/passwd'], tmp); + assert.strictEqual(r.success, true, 'must stay non-blocking'); + const data = JSON.parse(r.output); + + assert.strictEqual(data.skipped, true); + assert.match(data.reason, /^unknown-codebase-map-file:/); + assert.deepStrictEqual(data.stamped, []); + assert.strictEqual(readMappedCommit(path.join(codebaseDir, 'STRUCTURE.md')), null, + 'a rejected --files value must not partially stamp the map'); + }); + + test('skips without a git repo instead of failing the run (#3418)', () => { + const nonGit = createTempProject('gsd-stamp-nongit-'); + try { + fs.mkdirSync(path.join(nonGit, '.planning', 'codebase'), { recursive: true }); + fs.writeFileSync( + path.join(nonGit, '.planning', 'codebase', 'STRUCTURE.md'), '# STRUCTURE\n', + ); + const r = runGsdTools(['stamp-codebase-map'], nonGit); + assert.strictEqual(r.success, true, 'must exit 0 outside a git repo'); + const data = JSON.parse(r.output); + assert.strictEqual(data.skipped, true); + assert.strictEqual(data.reason, 'not-a-git-repo'); + } finally { + cleanup(nonGit); + } + }); + + test('skips when no codebase map exists (#3418)', () => { + const r = runGsdTools(['stamp-codebase-map'], tmp); + assert.strictEqual(r.success, true, r.error); + const data = JSON.parse(r.output); + assert.strictEqual(data.skipped, true); + assert.strictEqual(data.reason, 'no-codebase-map'); + }); + + test('a stamped map gives the drift gate a real base to diff against (#3418)', () => { + // The end-to-end loop the issue reported broken: map, stamp, commit, then + // ask the gate. Before the fix this reported every tracked file as drift. + writeMap(); + const r1 = runGsdTools(['stamp-codebase-map'], tmp); + assert.strictEqual(r1.success, true, r1.error); + const stampedAt = JSON.parse(r1.output).commit; + + git(tmp, 'add', '-A'); + git(tmp, 'commit', '-m', 'map codebase'); + + const r2 = runGsdTools(['verify', 'codebase-drift'], tmp); + assert.strictEqual(r2.success, true, r2.error); + const data = JSON.parse(r2.output); + + assert.strictEqual(data.skipped, false); + assert.strictEqual(data.last_mapped_commit, stampedAt); + assert.strictEqual(data.action_required, false); + assert.deepStrictEqual(data.elements, [], + 'a freshly stamped map must report zero drift; a populated list means the empty-tree fallback is back (#3418)'); + }); + + test('the map\'s own commit does not read as drift on the next run (#3418)', () => { + // The stamp is written before `.planning/codebase/*.md` is committed, so + // the commit carrying the baseline lands after it. Counting GSD's own + // planning artifacts as codebase structure would re-poison the gate with + // seven new directories -- over the default threshold of three -- on the + // first invocation after a clean map. + writeMap(); + runGsdTools(['stamp-codebase-map'], tmp); + git(tmp, 'add', '-A'); + git(tmp, 'commit', '-m', 'map codebase'); + + const data = JSON.parse(runGsdTools(['verify', 'codebase-drift'], tmp).output); + assert.strictEqual(data.action_required, false, + 'the map documents are planning artifacts, not codebase structure'); + assert.deepStrictEqual(data.affected_paths, []); + }); + + test('the stamp takes the planning lock around its read-modify-write (#4124 review)', () => { + // Seven frontmatter read-modify-writes with no lock lose an update when the + // full map run and the execute-phase auto-remap stamp at the same time. + // A dead holder's lock is stolen and released by withPlanningLock, so its + // disappearance is the proof the stamp went through the lock at all. + writeMap(); + const lockPath = path.join(tmp, '.planning', '.lock'); + fs.writeFileSync(lockPath, JSON.stringify({ + pid: 999999, cwd: tmp, acquired: new Date(0).toISOString(), + })); + + const r = runGsdTools(['stamp-codebase-map'], tmp); + assert.strictEqual(r.success, true, r.error); + assert.strictEqual(JSON.parse(r.output).skipped, false); + assert.strictEqual(fs.existsSync(lockPath), false, + 'the stale lock must be consumed and released; a surviving lock means the stamp never took it'); + }); + + test('the planning-artifact filter holds when cwd is below the repo root (#4124 review)', () => { + // `git diff --name-status` prints repo-root-relative paths whatever the + // cwd, so a prefix computed against cwd would read `.planning/` while git + // prints `sub/.planning/` and the filter would silently match nothing. + const sub = path.join(tmp, 'packages', 'app'); + const subCodebase = path.join(sub, '.planning', 'codebase'); + fs.mkdirSync(subCodebase, { recursive: true }); + for (const doc of CODEBASE_MAP_DOCS) { + fs.writeFileSync(path.join(subCodebase, doc), `# ${doc}\n\nBody.\n`); + } + + const r1 = runGsdTools(['stamp-codebase-map'], sub); + assert.strictEqual(r1.success, true, r1.error); + git(tmp, 'add', '-A'); + git(tmp, 'commit', '-m', 'map codebase from a subdirectory'); + + const data = JSON.parse(runGsdTools(['verify', 'codebase-drift'], sub).output); + assert.strictEqual(data.skipped, false); + assert.strictEqual(data.action_required, false, + 'the map documents under sub/.planning are still planning artifacts'); + assert.deepStrictEqual(data.elements, []); + }); +}); + +describe('verify codebase-drift: an absent baseline is not total drift (#3418)', () => { + let tmp; + let structure; + + beforeEach(() => { + tmp = createTempGitProject('gsd-drift-3418-'); + fs.mkdirSync(path.join(tmp, '.planning', 'codebase'), { recursive: true }); + structure = path.join(tmp, '.planning', 'codebase', 'STRUCTURE.md'); + + // Structural files that the empty-tree fallback would have reported as + // newly added. Without them the regression would pass for the wrong reason. + for (const pkg of ['alpha', 'beta', 'gamma', 'delta']) { + const dir = path.join(tmp, 'packages', pkg, 'src'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'index.ts'), 'export {};\n'); + } + fs.writeFileSync(structure, '# Codebase Structure\n\n- `packages/`\n'); + git(tmp, 'add', '-A'); + git(tmp, 'commit', '-m', 'map codebase without a stamp'); + }); + afterEach(() => cleanup(tmp)); + + test('an unstamped STRUCTURE.md skips with no-mapped-commit (#3418)', () => { + const r = runGsdTools(['verify', 'codebase-drift'], tmp); + assert.strictEqual(r.success, true, r.error); + const data = JSON.parse(r.output); + + assert.strictEqual(data.reason, 'no-mapped-commit'); + assert.strictEqual(data.skipped, true); + assert.strictEqual(data.block, false, + 'block:true here is the reported bug: the whole repo diffed against the empty tree (#3418)'); + assert.strictEqual(data.action_required, false); + assert.strictEqual(data.last_mapped_commit, null); + assert.deepStrictEqual(data.elements, [], + 'no baseline means no comparison, so there is nothing to report as drift'); + }); + + test('a stamp git cannot resolve skips with unresolvable-mapped-commit (#3418)', () => { + // A history rewrite, a GC, or a shallow clone leaves a stamp pointing at a + // commit this repository cannot see. That is a different operator problem + // from never having been mapped, and it also used to fall through to the + // empty tree. + writeMappedCommit(structure, 'deadbeef'.repeat(5), '2026-04-22'); + + const r = runGsdTools(['verify', 'codebase-drift'], tmp); + assert.strictEqual(r.success, true, r.error); + const data = JSON.parse(r.output); + + assert.strictEqual(data.reason, 'unresolvable-mapped-commit'); + assert.strictEqual(data.skipped, true); + assert.strictEqual(data.block, false); + assert.strictEqual(data.last_mapped_commit, 'deadbeef'.repeat(5)); + assert.deepStrictEqual(data.elements, []); + }); + + test('a stamp that resolves to a non-commit object skips too (#3418)', () => { + // A tree sha resolves with exit 0, and `git diff HEAD` is valid, so + // an exit-code-only probe would diff against the wrong object and report + // that as real drift. + const tree = git(tmp, 'rev-parse', 'HEAD^{tree}'); + writeMappedCommit(structure, tree, '2026-04-22'); + + const r = runGsdTools(['verify', 'codebase-drift'], tmp); + assert.strictEqual(r.success, true, r.error); + const data = JSON.parse(r.output); + + assert.strictEqual(data.reason, 'unresolvable-mapped-commit'); + assert.strictEqual(data.skipped, true); + assert.strictEqual(data.block, false); + assert.strictEqual(data.last_mapped_commit, tree); + assert.deepStrictEqual(data.elements, []); + }); +});