enhance(#3418): report real codebase drift instead of the whole repository (#4124)

* fix(#3418): write the codebase-drift baseline from code instead of agent prose

writeMappedCommit shipped correct and callerless, so no full map-codebase run ever wrote last_mapped_commit. The gate then read null and diffed HEAD against the empty tree, reporting every tracked file as newly added on every run.

Adds the stamp-codebase-map leaf verb and calls it from the map-codebase workflow and the execute-phase auto-remap path, replacing the prose instruction that asked the mapper agent to stamp its own output. An agent that concludes its work is already done skips a prose step silently, which is the failure the stamp exists to detect.

The gate now reports an absent or unresolvable baseline as skipped, with reason no-mapped-commit or unresolvable-mapped-commit, rather than as whole-repo drift. Files under .planning/ are excluded from the diff so the map's own commit does not read as seven new directories on the next run.

Emitted-Drift-Ack-Growth: map-codebase.md — adds the stamp_codebase_map step and its rationale, new workflow content this change requires

* test(#3418): cover the stamp writer and the absent-baseline gate

* docs(#3418): document how the drift baseline is written and skipped

* docs(#3418): note that a manual stamp reflows the map's whitespace

writeMappedCommit writes through platformWriteSync, which normalizes markdown whitespace on .md targets. Run in its workflow position the stamp lands on documents the mapper just wrote, so the normalization is folded into the same commit, but a hand-run stamp over an already-committed map reflows that map as a side effect. Reported on the issue thread.

* chore(#3418): add changeset fragment

Typed Changed to match the enhancement route the linked issue's label sets. The docs-required lint is satisfied by the ARCHITECTURE.md update already in this branch.

* fix(#3418): anchor the planning-artifact filter to the repo root

git diff --name-status prints repo-root-relative paths whatever the cwd, so computing the exclusion prefix against cwd yielded ".planning/" while git printed "sub/.planning/" and the filter silently matched nothing from a subdirectory.

* fix(#3418): derive the planning prefix from git, not from path arithmetic

Anchoring the exclusion prefix with path.relative() against `rev-parse --show-toplevel` broke on Windows, where os.tmpdir() hands back the 8.3 short form and git resolves the long one, so relative() produced a "../.." chain that matched nothing. `rev-parse --show-prefix` gives the cwd's root-relative prefix from the same producer as the diff paths, so the two sides cannot disagree.

* fix(#3418): take the planning lock around the codebase-map stamp

Stamping seven documents is seven frontmatter read-modify-writes, and two stampers can run at once: the full map-codebase run and the execute-phase auto-remap. Wrap the write loop in withPlanningLock, the same lock the other .planning/ writers take, so a concurrent pair cannot lose an update.

Also corrects the path-arithmetic comment, which read as if the Windows short-path hazard applied to the .planning half of the prefix. It applies to the rejected --show-toplevel alternative; both sides of the surviving relative() call are the same cwd string.

* fix(#3418): read HEAD and the map file list under the planning lock

The stamp resolved HEAD and listed the present codebase-map documents before it acquired the planning lock, so a stamper that then waited on the lock could write its now-stale sha over a newer one, or recreate a document deleted while it waited as a frontmatter-only stub. Both reads now happen inside the lock, matching the read-and-write-in-one-lock pattern config.cts and phase.cts already use. An empty --files value is refused as well instead of silently widening the stamp to all seven documents.

* fix(#3418): narrow the map stamp to the documents an update run refreshed

An "Update - only update specific documents" run reached the new stamp step with no --files narrowing, so the six documents the user did not select were stamped at HEAD and read as freshly mapped. The selection now threads through to --files, the same way the auto-remap path already does.

A bare --files (an unquoted empty shell variable drops the token) parsed to null, indistinguishable from an absent flag, so it skipped the empty-filter refusal and stamped all seven. Presence is now read off argv.

* fix(#3418): require the drift baseline to resolve to a commit, not any object

`git cat-file -t` exits 0 for a tree or blob sha and for a ref name, and `git diff <tree> HEAD` is valid, so an exit-code-only probe accepted a baseline that is not a commit and reported the resulting diff as real drift. Check the reported type instead of the exit code alone, which routes every non-commit stamp to the same `unresolvable-mapped-commit` skip.

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Norman Yee
2026-09-08 19:08:20 -10:00
committed by GitHub
parent 3ad75a6d59
commit 42c02a00c0
7 changed files with 594 additions and 15 deletions

View File

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

View File

@@ -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:**`, `<!-- refreshed: ... -->`) 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

View File

@@ -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 <command> [args] [--raw] [--pick <fiel
'capability, classify-confidence, git, learnings, list-seeds, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, planning, profile-questionnaire, ' +
'profile-sample, progress, project-instruction-file, prompt-budget, quick-batch, quick-tasks-append, quick-tasks-migrate, requirements, research-plan, research-store, resolve-granularity, resolve-model, restore-custom-files, roadmap, runtime-identity, scaffold, smart-entry, state, ' +
'config-set-model-profile, dispatch-capacity, dispatch-isolation, dispatch-should-flatten, inspect-dispatch-isolation, record-dispatch-isolation, estimate-calibrate, estimate-calibration, estimate-check, resolve-agent, resolve-dispatch-type, ' +
'resolve-execution, review-lane, skill-manifest, skills-root, state-snapshot, stats, summary-extract, teams-status, todo, uat, update-context, verification, websearch, windows, ' +
'resolve-execution, review-lane, skill-manifest, skills-root, stamp-codebase-map, state-snapshot, stats, summary-extract, teams-status, todo, uat, update-context, verification, websearch, windows, ' +
'task, template, user-story, validate, verify, verify-path-exists, verify-summary, eval, workstream, worktree\n\n' +
'Global flags:\n' +
' --raw Emit raw output without post-processing\n' +

View File

@@ -79,7 +79,6 @@ Today's date: {date}
--paths {affected_paths joined by comma}
Refresh STRUCTURE.md and ARCHITECTURE.md scoped to the listed paths only.
Stamp last_mapped_commit in each document's frontmatter.
${AGENT_SKILLS_MAPPER}"
)
```
@@ -90,8 +89,24 @@ If the spawn fails or the agent reports an error: log `Codebase drift
auto-remap failed: {reason}` and continue to `verify_phase_goal`. The phase
is NOT failed by a remap failure.
If the remap succeeds: log `Codebase drift auto-remap completed for paths:
{affected_paths}` and continue to `verify_phase_goal`.
If the remap succeeds, stamp the new baseline into the two documents the
mapper just refreshed:
```bash
gsd_run stamp-codebase-map --files STRUCTURE.md,ARCHITECTURE.md
```
The stamp is a shell step, not a line in the mapper's prompt. An agent that
concludes its work is already done skips a prose instruction silently, and the
stamp is the one marker no human reviewing the documents would notice missing
(#3418). `--files` is scoped to what this step actually refreshed -- the other
five documents were not remapped and must not claim currency at HEAD.
Only stamp on success: stamping after a failed remap would record a baseline
the map never reached.
Then log `Codebase drift auto-remap completed for paths: {affected_paths}` and
continue to `verify_phase_goal`.
The two relevant config keys (continue on error / failure if either is invalid):
- `workflow.drift_threshold` (integer, default 3) — minimum drift elements before action

View File

@@ -41,8 +41,9 @@ operates in **incremental-remap mode**:
- Reject path values that contain `..`, start with `/`, or include shell
metacharacters (`;`, `` ` ``, `$`, `&`, `|`, `<`, `>`). If all provided
paths are invalid, fall back to a normal whole-repo run.
- On write, each mapper stamps `last_mapped_commit: <HEAD sha>` 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="<selected documents>"
```
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.
</step>
@@ -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.
</step>
<step name="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: <HEAD sha>` and `last_mapped_at: <date>` 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:**`, `<!-- refreshed: ... -->`) 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.
</step>

View File

@@ -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<string, unknown>;
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,
};

View File

@@ -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 <tree> 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, []);
});
});