diff --git a/.changeset/3503-diff-base-scope-anchor.md b/.changeset/3503-diff-base-scope-anchor.md
new file mode 100644
index 000000000..2c72430a0
--- /dev/null
+++ b/.changeset/3503-diff-base-scope-anchor.md
@@ -0,0 +1,6 @@
+---
+type: Fixed
+pr: 3526
+---
+
+**`/gsd:code-review` now derives the phase diff base from GSD's own commit scopes instead of a prose phrase, ending silently wrong review scopes** — the diff base fed to the reviewer file-list fallback, the SUMMARY↔diff cross-check union, the reviewer agent's `diff_base`, and the fallow `--changed-since` structural pass was greped from commit messages for the literal "Phase N" and kept the oldest match, so any prose mention anywhere in history (a planning commit deferring work "to Phase N per D-09", a doc commit using "### Phase N" as a format example) silently set the base months before the phase existed — on a real repo ~4 phases too early, inflating the reviewer's reading list ~78% with no warning — while GSD's own commits (`docs(phase-N):`, `feat(N-MM):`, `docs(N):`), which never contain the literal phrase, were never matched at all. All three derivations now anchor on the subject-line conventional-commit phase scope (both padded `06` and unpadded `6` spellings, since workflows emit the unpadded roadmap number), commit bodies can no longer capture the base, and a history with no scope-style commits fails loudly with the existing no-base warning and `--files` escape hatch instead of silently picking an arbitrary commit. (#3503)
diff --git a/gsd-core/workflows/code-review.md b/gsd-core/workflows/code-review.md
index 30f040e6d..cfed1c539 100644
--- a/gsd-core/workflows/code-review.md
+++ b/gsd-core/workflows/code-review.md
@@ -237,14 +237,26 @@ against the diff and warn about (then add) any changed files the SUMMARY extract
surface — so a partial SUMMARY result can no longer silently mask the rest of the phase.
```bash
# Compute diff base from phase commits — fail closed if no reliable base found.
-# #2989: anchor the grep to the phase-mention convention ("Phase N" / "phase N")
-# so a bare digit substring doesn't match version strings, dates, issue refs,
-# or other phases' numbers. When no commit genuinely references the phase, this
-# yields empty and the fail-closed warning below actually fires.
-# #3191: the trailing boundary is the POSIX class ([^[:alnum:]_]|$), NOT \b —
-# \b is not a POSIX ERE token, so under --extended-regexp it silently matches
-# nothing on macOS regex(3), making this fallback dead on Apple platforms.
-PHASE_COMMITS=$(git log --oneline --all --grep="[Pp]hase ${PADDED_PHASE}([^[:alnum:]_]|$)" --extended-regexp --format="%H" 2>/dev/null)
+# #3503: anchor the grep to GSD's own conventional-commit phase scopes — the
+# subject-line formats this system itself emits (docs(phase-N): from
+# execute-phase.md, plan scopes feat(N-MM):/test(N-MM): from references/tdd.md,
+# bare phase scopes docs(N):). The #2989/#3191 prose anchor "[Pp]hase N"
+# matched free prose in ANY commit body — planning commits forward-reference
+# later phases ("deferred to Phase N per D-09"), doc commits use "### Phase N"
+# as a format example — and tail -1 (oldest match) turned each false positive
+# into a base unboundedly before the phase, while GSD's own scope commits
+# never contain the literal "Phase N" at all. The ^ anchor makes this a
+# subject-line match, so commit-body prose can never capture the base.
+# Workflows emit the UNPADDED roadmap phase number (docs(phase-6):) while
+# PADDED_PHASE is zero-padded ("06") — accept both spellings.
+# #3191: stay POSIX-ERE portable — the boundary is the closing paren + colon,
+# never \b (not a POSIX ERE token; under --extended-regexp it silently matches
+# nothing on macOS regex(3), making this fallback dead on Apple platforms).
+PHASE_SCOPE_NUM="${PADDED_PHASE}"
+case "${PADDED_PHASE}" in
+ 0[0-9]*) PHASE_SCOPE_NUM="${PADDED_PHASE#0}|${PADDED_PHASE}" ;;
+esac
+PHASE_COMMITS=$(git log --oneline --all --extended-regexp --grep="^[[:alpha:]]+!?\((phase-)?(${PHASE_SCOPE_NUM})(-[0-9]+)?\)!?:" --format="%H" 2>/dev/null)
DIFF_BASE=""
if [ -n "$PHASE_COMMITS" ]; then
DIFF_BASE=$(echo "$PHASE_COMMITS" | tail -1)^
@@ -425,13 +437,17 @@ Compute the review output path:
REVIEW_PATH="${PHASE_DIR}/${PADDED_PHASE}-REVIEW.md"
```
-Compute DIFF_BASE for agent context (in case agent needs it). #3191: this must be
-the SAME anchored, POSIX-portable derivation the Tier-3 scope step uses — the
-reviewer agent consumes `diff_base` exactly when `files:` is empty, i.e. the same
-fail-closed scenario Tier 3 protects, so a divergent unanchored recomputation here
-re-arms the mis-scoping one tier down:
+Compute DIFF_BASE for agent context (in case agent needs it). #3191/#3503: this
+must be the SAME anchored, POSIX-portable conventional-commit-scope derivation
+the Tier-3 scope step uses — the reviewer agent consumes `diff_base` exactly
+when `files:` is empty, i.e. the same fail-closed scenario Tier 3 protects, so
+a divergent recomputation here re-arms the mis-scoping one tier down:
```bash
-PHASE_COMMITS=$(git log --oneline --all --grep="[Pp]hase ${PADDED_PHASE}([^[:alnum:]_]|$)" --extended-regexp --format="%H" 2>/dev/null)
+PHASE_SCOPE_NUM="${PADDED_PHASE}"
+case "${PADDED_PHASE}" in
+ 0[0-9]*) PHASE_SCOPE_NUM="${PADDED_PHASE#0}|${PADDED_PHASE}" ;;
+esac
+PHASE_COMMITS=$(git log --oneline --all --extended-regexp --grep="^[[:alpha:]]+!?\((phase-)?(${PHASE_SCOPE_NUM})(-[0-9]+)?\)!?:" --format="%H" 2>/dev/null)
if [ -n "$PHASE_COMMITS" ]; then
DIFF_BASE=$(echo "$PHASE_COMMITS" | tail -1)^
# Verify the parent commit exists (first commit in repo has no parent)
diff --git a/gsd-core/workflows/code-review/steps/structural-pre-pass.md b/gsd-core/workflows/code-review/steps/structural-pre-pass.md
index 57a0286c4..b3aa2b9e0 100644
--- a/gsd-core/workflows/code-review/steps/structural-pre-pass.md
+++ b/gsd-core/workflows/code-review/steps/structural-pre-pass.md
@@ -26,14 +26,20 @@ FALLOW_STDERR_TMP=$(mktemp)
# Phase scope uses fallow's native changed-files scoping (--changed-since ).
# Derive the phase base commit; if none is found, fall back to repo scope (fallow
-# auto-detects the base branch). #3191: the grep is the SAME anchored,
-# POSIX-portable phase-mention derivation the workflow's Tier-3 scope step uses
-# ("Phase N" followed by a non-alphanumeric or end-of-line) — a bare digit
-# substring matches version strings, dates, and other phases, and the oldest
+# auto-detects the base branch). #3191/#3503: the grep is the SAME anchored,
+# POSIX-portable conventional-commit-scope derivation the workflow's Tier-3
+# scope step uses (subject-line `type((phase-)?N(-plan)?):`, padded or unpadded
+# phase spelling). Free prose in commit bodies — "deferred to Phase N per
+# D-09", "### Phase N" format examples — never captures the base, and a bare
+# digit substring matches version strings, dates, and other phases; the oldest
# such false match would silently widen --changed-since far past the phase.
FALLOW_SCOPE_ARGS=()
if [ \"$FALLOW_SCOPE\" = \"phase\" ]; then
- FALLOW_PHASE_COMMITS=$(git log --oneline --all --grep=\"[Pp]hase ${PADDED_PHASE}([^[:alnum:]_]|$)\" --extended-regexp --format=\"%H\" 2>/dev/null)
+ PHASE_SCOPE_NUM=\"${PADDED_PHASE}\"
+ case \"$PADDED_PHASE\" in
+ 0[0-9]*) PHASE_SCOPE_NUM=\"${PADDED_PHASE#0}|${PADDED_PHASE}\" ;;
+ esac
+ FALLOW_PHASE_COMMITS=$(git log --oneline --all --extended-regexp --grep=\"^[[:alpha:]]+!?\((phase-)?(${PHASE_SCOPE_NUM})(-[0-9]+)?\)!?:\" --format=\"%H\" 2>/dev/null)
if [ -n \"$FALLOW_PHASE_COMMITS\" ]; then
FALLOW_BASE=$(echo \"$FALLOW_PHASE_COMMITS\" | tail -1)^
FALLOW_SCOPE_ARGS=(--changed-since \"$FALLOW_BASE\")
diff --git a/tests/code-review-pipeline-regression.test.cjs b/tests/code-review-pipeline-regression.test.cjs
index 6146af7d9..7da10ff9f 100644
--- a/tests/code-review-pipeline-regression.test.cjs
+++ b/tests/code-review-pipeline-regression.test.cjs
@@ -729,9 +729,103 @@ describe('Bug 4 (#2352) — compute_file_scope tilde-path expansion', () => {
});
});
+// ---------------------------------------------------------------------------
+// Shared diff-base extraction/execution helpers (Bug 5 #3191, Bug 6 #3503).
+//
+// The workflow computes "the phase's base commit" in three independent bash
+// invocations (each is its own shell): the Tier-3 file-scope fallback
+// (compute_file_scope), the agent-context DIFF_BASE (spawn_reviewer), and the
+// fallow pre-pass's --changed-since base (structural-pre-pass.md).
+//
+// Behavioral style follows Bug 4: extract the SHIPPED bash from the workflow
+// .md files by content anchor and execute it via a real bash subprocess
+// against a git fixture — so the assertion binds the deployed text, not a
+// JS reimplementation. Running the real `git log` (not a regex shim) is what
+// makes platform-level regex holes (the #3191 macOS `\b` no-op) visible.
+// ---------------------------------------------------------------------------
+
+// The ```bash fence containing `marker`, located after `fromIdx`.
+function fenceContaining(src, marker, fromIdx = 0) {
+ const markerIdx = src.indexOf(marker, fromIdx);
+ assert.ok(markerIdx !== -1, `expected to find "${marker}" in workflow source`);
+ const fenceStart = src.lastIndexOf('```bash', markerIdx);
+ assert.ok(fenceStart !== -1, `no \`\`\`bash fence before "${marker}"`);
+ const bodyStart = src.indexOf('\n', fenceStart) + 1;
+ const fenceEnd = src.indexOf('\n```', bodyStart);
+ assert.ok(fenceEnd !== -1, `unterminated \`\`\`bash fence containing "${marker}"`);
+ return src.slice(bodyStart, fenceEnd);
+}
+
+// The Tier-3 derivation prefix: fence start up to the REVIEW_FILES branch.
+function extractTier3Derivation() {
+ const src = readFileNormalized(WORKFLOW_PATH);
+ const fence = fenceContaining(src, '# Compute diff base from phase commits');
+ const cut = fence.indexOf('if [ ${#REVIEW_FILES[@]} -eq 0 ]');
+ assert.ok(cut !== -1, 'Tier-3 fence must contain the REVIEW_FILES empty-scope branch');
+ return fence.slice(0, cut);
+}
+
+// spawn_reviewer's whole DIFF_BASE fence.
+function extractSpawnReviewerDerivation() {
+ const src = readFileNormalized(WORKFLOW_PATH);
+ const spawnIdx = src.indexOf('');
+ assert.ok(spawnIdx !== -1, 'code-review.md must have a spawn_reviewer step');
+ return fenceContaining(src, 'PHASE_COMMITS=$(git log', spawnIdx);
+}
+
+// The fallow phase-scope derivation, from the step fragment. The fragment
+// carries markdown-escaped quotes (\") in this fence — an authoring
+// artifact that survived #2994 fragmentization verbatim; the runtime agent
+// normalizes them when transcribing, so the test does the same before
+// executing. Sliced from FALLOW_SCOPE_ARGS=() (skipping the gsd-tools
+// runtime resolver line above it, which exits 1 on machines without an
+// installed gsd-tools and is orthogonal to the base-derivation under test)
+// to just before the gsd_run invocation (which needs the real binary).
+function extractFallowDerivation() {
+ const src = readFileNormalized(PRE_PASS_STEP_PATH);
+ const fence = fenceContaining(src, 'FALLOW_PHASE_COMMITS=$(git log');
+ const scopeStart = fence.indexOf('FALLOW_SCOPE_ARGS=()');
+ assert.ok(scopeStart !== -1, 'fallow fence must define FALLOW_SCOPE_ARGS=()');
+ const cut = fence.indexOf('gsd_run run-with-timeout');
+ assert.ok(cut !== -1, 'fallow fence must contain the gsd_run run-with-timeout call');
+ assert.ok(scopeStart < cut, 'FALLOW_SCOPE_ARGS must precede the gsd_run invocation');
+ return fence.slice(scopeStart, cut).replace(/\\"/g, '"');
+}
+
+// Execute a derivation snippet with PADDED_PHASE (and the fallow scope gate)
+// set, echoing the values it computes between sentinels so multi-line
+// PHASE_COMMITS parse cleanly.
+function runDerivation(repo, snippet, phase) {
+ const script = [
+ `PADDED_PHASE=${phase}`,
+ 'FALLOW_SCOPE=phase',
+ snippet,
+ 'echo "===PHASE_COMMITS==="',
+ 'printf \'%s\\n\' "$PHASE_COMMITS"',
+ 'echo "===DIFF_BASE==="',
+ 'printf \'%s\\n\' "$DIFF_BASE"',
+ 'echo "===FALLOW_BASE==="',
+ 'printf \'%s\\n\' "$FALLOW_BASE"',
+ 'echo "===END==="',
+ ].join('\n');
+ return toLegacyResult(
+ runHook('-c', [script, 'bash'], {
+ interpreter: 'bash',
+ cwd: repo,
+ timeoutMs: PROBE_TIMEOUT_MS,
+ })
+ );
+}
+
+function parseSentinel(stdout, name) {
+ const m = stdout.match(new RegExp(`===${name}===\\n([\\s\\S]*?)\\n===`));
+ if (!m) return null;
+ return m[1].split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
+}
+
// ---------------------------------------------------------------------------
// Bug 5 (#3191) — EVERY diff-base derivation must use the same anchored,
-// portable phase-mention grep.
+// portable derivation.
//
// The workflow computes "the phase's base commit" in three independent bash
// invocations (each is its own shell): the Tier-3 file-scope fallback
@@ -742,105 +836,29 @@ describe('Bug 4 (#2352) — compute_file_scope tilde-path expansion', () => {
// always fails closed. The other two sites kept the original unanchored
// `--grep="${PADDED_PHASE}"`, whose oldest substring match is routinely a
// version-string/date commit from months before the phase existed.
+// (#3503 later replaced the anchor itself — a subject-line conventional-
+// commit scope match instead of the "[Pp]hase N" prose phrase, which GSD's
+// own commits never contain; see Bug 6. The lockstep + portability +
+// fail-closed contract THIS block verifies is unchanged.)
//
// Behavioral style follows Bug 4: extract the SHIPPED bash from the workflow
// .md files by content anchor and execute it via a real bash subprocess
// against a git fixture — so the assertion binds the deployed text, not a
// JS reimplementation. Running the real `git log` (not a regex shim) is what
-// makes the macOS `\b` hole visible: the fixture's real "Phase 06" commit
-// MUST be matched by the shipped pattern on every platform (#3191 AC2).
+// keeps platform-level regex holes (the #3191 macOS `\b` no-op) visible.
// ---------------------------------------------------------------------------
-describe('Bug 5 (#3191) — anchored, portable phase-mention grep at all three diff-base sites', () => {
+describe('Bug 5 (#3191) — same anchored, portable phase-scope grep at all three diff-base sites', () => {
const SKIP_WIN32 = { skip: process.platform === 'win32' };
- // The ```bash fence containing `marker`, located after `fromIdx`.
- function fenceContaining(src, marker, fromIdx = 0) {
- const markerIdx = src.indexOf(marker, fromIdx);
- assert.ok(markerIdx !== -1, `expected to find "${marker}" in workflow source`);
- const fenceStart = src.lastIndexOf('```bash', markerIdx);
- assert.ok(fenceStart !== -1, `no \`\`\`bash fence before "${marker}"`);
- const bodyStart = src.indexOf('\n', fenceStart) + 1;
- const fenceEnd = src.indexOf('\n```', bodyStart);
- assert.ok(fenceEnd !== -1, `unterminated \`\`\`bash fence containing "${marker}"`);
- return src.slice(bodyStart, fenceEnd);
- }
-
- // The Tier-3 derivation prefix: fence start up to the REVIEW_FILES branch.
- function extractTier3Derivation() {
- const src = readFileNormalized(WORKFLOW_PATH);
- const fence = fenceContaining(src, '# Compute diff base from phase commits');
- const cut = fence.indexOf('if [ ${#REVIEW_FILES[@]} -eq 0 ]');
- assert.ok(cut !== -1, 'Tier-3 fence must contain the REVIEW_FILES empty-scope branch');
- return fence.slice(0, cut);
- }
-
- // spawn_reviewer's whole DIFF_BASE fence.
- function extractSpawnReviewerDerivation() {
- const src = readFileNormalized(WORKFLOW_PATH);
- const spawnIdx = src.indexOf('');
- assert.ok(spawnIdx !== -1, 'code-review.md must have a spawn_reviewer step');
- return fenceContaining(src, 'PHASE_COMMITS=$(git log', spawnIdx);
- }
-
- // The fallow phase-scope derivation, from the step fragment. The fragment
- // carries markdown-escaped quotes (\") in this fence — an authoring
- // artifact that survived #2994 fragmentization verbatim; the runtime agent
- // normalizes them when transcribing, so the test does the same before
- // executing. Sliced from FALLOW_SCOPE_ARGS=() (skipping the gsd-tools
- // runtime resolver line above it, which exits 1 on machines without an
- // installed gsd-tools and is orthogonal to the base-derivation under test)
- // to just before the gsd_run invocation (which needs the real binary).
- function extractFallowDerivation() {
- const src = readFileNormalized(PRE_PASS_STEP_PATH);
- const fence = fenceContaining(src, 'FALLOW_PHASE_COMMITS=$(git log');
- const scopeStart = fence.indexOf('FALLOW_SCOPE_ARGS=()');
- assert.ok(scopeStart !== -1, 'fallow fence must define FALLOW_SCOPE_ARGS=()');
- const cut = fence.indexOf('gsd_run run-with-timeout');
- assert.ok(cut !== -1, 'fallow fence must contain the gsd_run run-with-timeout call');
- assert.ok(scopeStart < cut, 'FALLOW_SCOPE_ARGS must precede the gsd_run invocation');
- return fence.slice(scopeStart, cut).replace(/\\"/g, '"');
- }
-
- // Execute a derivation snippet with PADDED_PHASE (and the fallow scope gate)
- // set, echoing the values it computes between sentinels so multi-line
- // PHASE_COMMITS parse cleanly.
- function runDerivation(repo, snippet, phase) {
- const script = [
- `PADDED_PHASE=${phase}`,
- 'FALLOW_SCOPE=phase',
- snippet,
- 'echo "===PHASE_COMMITS==="',
- 'printf \'%s\\n\' "$PHASE_COMMITS"',
- 'echo "===DIFF_BASE==="',
- 'printf \'%s\\n\' "$DIFF_BASE"',
- 'echo "===FALLOW_BASE==="',
- 'printf \'%s\\n\' "$FALLOW_BASE"',
- 'echo "===END==="',
- ].join('\n');
- return toLegacyResult(
- runHook('-c', [script, 'bash'], {
- interpreter: 'bash',
- cwd: repo,
- timeoutMs: PROBE_TIMEOUT_MS,
- })
- );
- }
-
- function parseSentinel(stdout, name) {
- const m = stdout.match(new RegExp(`===${name}===\\n([\\s\\S]*?)\\n===`));
- if (!m) return null;
- return m[1].split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
- }
-
// Fixture: five commits whose messages exercise every false-match class
- // from the issue — version string + date, bare digits in a (NN) scope,
- // another phase whose number is a digit-superset — plus the phase's real
- // first commit and an unrelated HEAD.
+ // from the issue — version string + date, another phase's plan whose scope
+ // number is a digit-superset, a prose "Phase N" mention in another phase's
+ // subject — plus the phase's real first scope commit and an unrelated HEAD.
function buildFixture(prefix, phaseCommitMessage) {
const repo = createTempGitProject(prefix);
const commits = [
['c1.txt', 'chore: bump to v2.06.0 on 2026-01-05'],
- ['c2.txt', 'docs(06-01): unrelated sub-phase work'],
+ ['c2.txt', 'docs(60-01): unrelated phase-plan work'],
['c3.txt', phaseCommitMessage],
['c4.txt', 'chore: Phase 60 cleanup'],
['c5.txt', 'docs: touch README'],
@@ -856,10 +874,10 @@ describe('Bug 5 (#3191) — anchored, portable phase-mention grep at all three d
}
test(
- 'T1 + T4: Tier-3 derivation matches ONLY the real phase-mention commit — including on macOS (#3191 \\b portability)',
+ 'T1 + T4: Tier-3 derivation matches ONLY the phase\'s real scope commit — never a digit-substring or superset hit',
SKIP_WIN32,
() => {
- const { repo, hashes } = buildFixture('gsd-3191-tier3-', 'feat: Phase 06 kickoff — scanner core');
+ const { repo, hashes } = buildFixture('gsd-3191-tier3-', 'docs(06): capture phase context');
try {
const result = runDerivation(repo, extractTier3Derivation(), '06');
assert.equal(result.status, 0, `snippet exited ${result.status}; stderr=${result.stderr}`);
@@ -871,7 +889,7 @@ describe('Bug 5 (#3191) — anchored, portable phase-mention grep at all three d
assert.deepStrictEqual(
phaseCommits,
[hashes['c3.txt']],
- `Tier-3 grep must match only the real "Phase 06" commit; got: ${JSON.stringify(phaseCommits)}`
+ `Tier-3 grep must match only the phase's real scope commit; got: ${JSON.stringify(phaseCommits)}`
);
assert.deepStrictEqual(
diffBase,
@@ -888,7 +906,7 @@ describe('Bug 5 (#3191) — anchored, portable phase-mention grep at all three d
'T2: spawn_reviewer DIFF_BASE derivation uses the same anchored grep (not the bare digit)',
SKIP_WIN32,
() => {
- const { repo, hashes } = buildFixture('gsd-3191-spawn-', 'feat: Phase 06 kickoff — scanner core');
+ const { repo, hashes } = buildFixture('gsd-3191-spawn-', 'docs(06): capture phase context');
try {
const result = runDerivation(repo, extractSpawnReviewerDerivation(), '06');
assert.equal(result.status, 0, `snippet exited ${result.status}; stderr=${result.stderr}`);
@@ -900,7 +918,7 @@ describe('Bug 5 (#3191) — anchored, portable phase-mention grep at all three d
assert.deepStrictEqual(
phaseCommits,
[hashes['c3.txt']],
- `spawn_reviewer grep must match only the real "Phase 06" commit; got: ${JSON.stringify(phaseCommits)}`
+ `spawn_reviewer grep must match only the phase's real scope commit; got: ${JSON.stringify(phaseCommits)}`
);
assert.deepStrictEqual(
diffBase,
@@ -917,7 +935,7 @@ describe('Bug 5 (#3191) — anchored, portable phase-mention grep at all three d
'T3: fallow phase scope derives --changed-since from the anchored grep, never an old substring match',
SKIP_WIN32,
() => {
- const { repo, hashes } = buildFixture('gsd-3191-fallow-', 'feat: Phase 06 kickoff — scanner core');
+ const { repo, hashes } = buildFixture('gsd-3191-fallow-', 'docs(06): capture phase context');
try {
const result = runDerivation(repo, extractFallowDerivation(), '06');
assert.equal(result.status, 0, `snippet exited ${result.status}; stderr=${result.stderr}`);
@@ -937,10 +955,10 @@ describe('Bug 5 (#3191) — anchored, portable phase-mention grep at all three d
);
test(
- 'T5: with no genuine phase-mention commit, every derivation yields NO base (fail-closed preserved)',
+ 'T5: with no genuine phase scope commit, every derivation yields NO base (fail-closed preserved)',
SKIP_WIN32,
() => {
- const { repo } = buildFixture('gsd-3191-closed-', 'feat: scanner core'); // no "Phase 06" anywhere
+ const { repo } = buildFixture('gsd-3191-closed-', 'feat: scanner core'); // no phase-06 scope commit anywhere
try {
for (const [label, snippet] of [
['tier3', extractTier3Derivation()],
@@ -963,9 +981,11 @@ describe('Bug 5 (#3191) — anchored, portable phase-mention grep at all three d
);
// T6 docs-parity anti-revert: every `git log --grep` derivation in both
- // files must be anchored with the POSIX-portable boundary and must not use
- // `\b` under --extended-regexp (which silently no-ops on macOS regex(3)).
- test('T6 docs-parity: all git-log grep derivations are anchored and free of the non-POSIX \\b', () => {
+ // files must use the SAME (#3191 lockstep) #3503 scope-anchored pattern — a
+ // subject-line conventional-commit phase scope, both padded and unpadded
+ // spellings via PHASE_SCOPE_NUM — and must not use `\b` under
+ // --extended-regexp (which silently no-ops on macOS regex(3)).
+ test('T6 docs-parity: all git-log grep derivations use the identical scope-anchored, POSIX-portable pattern', () => {
const sources = [
readFileNormalized(WORKFLOW_PATH),
readFileNormalized(PRE_PASS_STEP_PATH).replace(/\\"/g, '"'),
@@ -980,10 +1000,11 @@ describe('Bug 5 (#3191) — anchored, portable phase-mention grep at all three d
grepLines.length >= 3,
`expected at least 3 git-log grep derivation sites (Tier 3, spawn_reviewer, fallow); found ${grepLines.length}`
);
+ const SCOPE_GREP = '--grep="^[[:alpha:]]+!?\\((phase-)?(${PHASE_SCOPE_NUM})(-[0-9]+)?\\)!?:"';
for (const line of grepLines) {
assert.ok(
- line.includes('--grep="[Pp]hase ${PADDED_PHASE}([^[:alnum:]_]|$)"'),
- `grep derivation must be anchored to the phase-mention convention with a POSIX boundary:\n${line}`
+ line.includes(SCOPE_GREP),
+ `grep derivation must be anchored to GSD's own conventional-commit phase scope (#3503), not free prose:\n${line}`
);
assert.ok(
line.includes('--extended-regexp'),
@@ -994,5 +1015,210 @@ describe('Bug 5 (#3191) — anchored, portable phase-mention grep at all three d
`grep derivation must not use \\b under --extended-regexp — it is not POSIX ERE and silently matches nothing on macOS (#3191):\n${line}`
);
}
+ // Lockstep (#3191): all three sites must carry byte-identical grep text —
+ // and the padded/unpadded PHASE_SCOPE_NUM prep that feeds it.
+ for (const src of sources) {
+ assert.ok(
+ src.includes('PHASE_SCOPE_NUM="${PADDED_PHASE}"'),
+ 'each file must derive PHASE_SCOPE_NUM from PADDED_PHASE (padded/unpadded alternation)'
+ );
+ assert.ok(
+ src.includes('0[0-9]*) PHASE_SCOPE_NUM="${PADDED_PHASE#0}|${PADDED_PHASE}"'),
+ 'each file must accept the UNPADDED phase spelling GSD workflows emit (docs(phase-6):)'
+ );
+ }
});
});
+
+// ---------------------------------------------------------------------------
+// Bug 6 (#3503) — the diff-base grep must key on GSD's own commit scopes,
+// not free prose.
+//
+// The #2989/#3191 anchor ("[Pp]hase N" + POSIX boundary) still resolves the
+// base ~4 phases early on real repos: `git log --grep` searches FULL commit
+// bodies, and `tail -1` deliberately keeps the OLDEST match — so a single
+// prose mention of the phase anywhere in history (a planning commit that
+// forward-references it: "deferred to Phase N per D-09"; a doc commit that
+// uses "### Phase N" as a format EXAMPLE) silently captures the base, while
+// GSD's own commits — which never contain the literal "Phase N", they use
+// conventional-commit scopes: docs(phase-6): from execute-phase.md,
+// feat(6-01):/test(6-01): from references/tdd.md, docs(6): plan commits —
+// are matched by nothing. The wrong base silently inflates the reviewer's
+// reading list (the #2666 SUMMARY/diff union) and widens fallow's
+// --changed-since with no warning.
+//
+// Same behavioral style as Bug 5: the SHIPPED bash is extracted from the
+// workflow .md files by content anchor and executed against a real git
+// fixture whose history contains every false-positive class from the issue,
+// in commit BODIES (which is where the old pattern's damage lives).
+// ---------------------------------------------------------------------------
+describe('Bug 6 (#3503) — diff base keys on GSD commit scopes, not prose mentions', () => {
+ const SKIP_WIN32 = { skip: process.platform === 'win32' };
+
+ // Commit [file, subject, body?] tuples; bodies use a second -m so they are
+ // real commit bodies (what `git log --grep` searches beyond the subject).
+ function buildHistory(prefix, commits) {
+ const repo = createTempGitProject(prefix);
+ const hashes = {};
+ for (const [file, subject, body] of commits) {
+ fs.writeFileSync(path.join(repo, file), `${subject}\n`);
+ gitOrThrow(['add', file], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
+ const args = body === undefined
+ ? ['commit', '-m', subject]
+ : ['commit', '-m', subject, '-m', body];
+ gitOrThrow(args, { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
+ hashes[file] = gitOrThrow(['rev-parse', 'HEAD'], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS }).trim();
+ }
+ return { repo, hashes };
+ }
+
+ // The #3503 repro history: every prose false-positive class from the issue
+ // — a version-string digit substring, a planning commit whose BODY
+ // forward-references the phase, a doc commit whose BODY uses "### Phase N"
+ // as a format example — followed by the phase's GENUINE scope-style commits
+ // in all three spellings GSD emits (padded docs(06):, plan feat(06-01):,
+ // and the UNPADDED docs(phase-6): that execute-phase.md actually writes,
+ // since workflows interpolate the unpadded roadmap number while
+ // PADDED_PHASE is zero-padded).
+ const REPRO_HISTORY = [
+ ['c1.txt', 'chore: bump to v2.06.0 on 2026-01-05'],
+ ['c2.txt', 'feat(60-01): probe wiring', 'The EF path still uses it, fenced to Phase 06 per D-09.'],
+ ['c3.txt', 'docs: commit message format', 'Phase headers use the form:\n\n### Phase 06 (Cluster B): Title\n\nin ROADMAP detail sections.'],
+ ['c4.txt', 'docs(06): capture phase context'],
+ ['c5.txt', 'feat(06-01): implement scanner core'],
+ ['c6.txt', 'docs(phase-6): update tracking after wave 1'],
+ ['c7.txt', 'docs: touch README'],
+ ];
+
+ test(
+ 'T1: prose forward-references and doc-format examples never capture the base — it resolves to the phase first scope commit at all three sites',
+ SKIP_WIN32,
+ () => {
+ const { repo, hashes } = buildHistory('gsd-3503-scope-', REPRO_HISTORY);
+ try {
+ const sites = [
+ ['tier3', extractTier3Derivation()],
+ ['spawn_reviewer', extractSpawnReviewerDerivation()],
+ ['fallow', extractFallowDerivation()],
+ ];
+ for (const [label, snippet] of sites) {
+ const result = runDerivation(repo, snippet, '06');
+ assert.equal(result.status, 0, `${label} exited ${result.status}; stderr=${result.stderr}`);
+ const phaseCommits = parseSentinel(result.stdout, 'PHASE_COMMITS');
+ const diffBase = parseSentinel(result.stdout, 'DIFF_BASE');
+ const fallowBase = parseSentinel(result.stdout, 'FALLOW_BASE');
+ // Pre-fix (#3503): the prose matches in c2/c3 bodies are older than
+ // the phase and tail -1 keeps the oldest, so DIFF_BASE resolves to
+ // c2^ — unboundedly before the phase — at every site. (The fallow
+ // snippet computes FALLOW_PHASE_COMMITS, not PHASE_COMMITS; its
+ // matched-set is asserted via FALLOW_BASE below.)
+ if (label !== 'fallow') {
+ assert.deepStrictEqual(
+ new Set(phaseCommits || []),
+ new Set([hashes['c4.txt'], hashes['c5.txt'], hashes['c6.txt']]),
+ `${label}: grep must match exactly the phase's three scope commits; got: ${JSON.stringify(phaseCommits)}`
+ );
+ }
+ const expected = [`${hashes['c4.txt']}^`];
+ if (label === 'fallow') {
+ assert.deepStrictEqual(
+ fallowBase,
+ expected,
+ `${label}: base must be the FIRST (oldest) scope commit's parent`
+ );
+ } else {
+ assert.deepStrictEqual(
+ diffBase,
+ expected,
+ `${label}: base must be the FIRST (oldest) scope commit's parent`
+ );
+ }
+ }
+ } finally {
+ cleanup(repo);
+ }
+ }
+ );
+
+ test(
+ 'T2: unpadded scope spellings (docs(phase-6):, feat(6-01):) resolve identically — PADDED_PHASE is zero-padded but GSD emits the unpadded number',
+ SKIP_WIN32,
+ () => {
+ const { repo, hashes } = buildHistory('gsd-3503-unpadded-', [
+ ['c1.txt', 'feat(60-01): probe wiring', 'Deferred to Phase 06 per D-09.'],
+ ['c2.txt', 'docs(phase-6): capture phase context'],
+ ['c3.txt', 'feat(6-01): implement scanner core'],
+ ['c4.txt', 'test(6): persist human verification items as UAT'],
+ ['c5.txt', 'docs: touch README'],
+ ]);
+ try {
+ for (const [label, snippet] of [
+ ['tier3', extractTier3Derivation()],
+ ['spawn_reviewer', extractSpawnReviewerDerivation()],
+ ['fallow', extractFallowDerivation()],
+ ]) {
+ const result = runDerivation(repo, snippet, '06');
+ assert.equal(result.status, 0, `${label} exited ${result.status}; stderr=${result.stderr}`);
+ const phaseCommits = parseSentinel(result.stdout, 'PHASE_COMMITS');
+ const diffBase = parseSentinel(result.stdout, 'DIFF_BASE');
+ const fallowBase = parseSentinel(result.stdout, 'FALLOW_BASE');
+ // (The fallow snippet computes FALLOW_PHASE_COMMITS, not
+ // PHASE_COMMITS; its matched set is asserted via FALLOW_BASE below.)
+ if (label !== 'fallow') {
+ assert.deepStrictEqual(
+ new Set(phaseCommits || []),
+ new Set([hashes['c2.txt'], hashes['c3.txt'], hashes['c4.txt']]),
+ `${label}: unpadded scope spellings must all match; got: ${JSON.stringify(phaseCommits)}`
+ );
+ }
+ const expected = [`${hashes['c2.txt']}^`];
+ if (label === 'fallow') {
+ assert.deepStrictEqual(
+ fallowBase,
+ expected,
+ `${label}: base must be the first unpadded scope commit's parent`
+ );
+ } else {
+ assert.deepStrictEqual(
+ diffBase,
+ expected,
+ `${label}: base must be the first unpadded scope commit's parent`
+ );
+ }
+ }
+ } finally {
+ cleanup(repo);
+ }
+ }
+ );
+
+ test(
+ 'T3: prose mentions WITHOUT any scope-style commit fail closed (no silent arbitrary base)',
+ SKIP_WIN32,
+ () => {
+ const { repo } = buildHistory('gsd-3503-closed-', REPRO_HISTORY.slice(0, 3).concat([
+ ['c4.txt', 'docs: touch README'],
+ ]));
+ try {
+ for (const [label, snippet] of [
+ ['tier3', extractTier3Derivation()],
+ ['spawn_reviewer', extractSpawnReviewerDerivation()],
+ ['fallow', extractFallowDerivation()],
+ ]) {
+ const result = runDerivation(repo, snippet, '06');
+ assert.equal(result.status, 0, `${label} exited ${result.status}; stderr=${result.stderr}`);
+ const phaseCommits = parseSentinel(result.stdout, 'PHASE_COMMITS');
+ const diffBase = parseSentinel(result.stdout, 'DIFF_BASE');
+ const fallowBase = parseSentinel(result.stdout, 'FALLOW_BASE');
+ // Pre-fix (#3503): the prose bodies match, so the derivation picks a
+ // bogus base instead of failing closed behind the workflow warning.
+ assert.deepStrictEqual(phaseCommits, [], `${label}: prose mentions may not match`);
+ assert.deepStrictEqual(diffBase, [], `${label}: DIFF_BASE must stay empty`);
+ assert.deepStrictEqual(fallowBase, [], `${label}: FALLOW_BASE must stay unset`);
+ }
+ } finally {
+ cleanup(repo);
+ }
+ }
+ );
+});
diff --git a/tests/emitted-drift-acks/3191-unanchored-grep-sites.json b/tests/emitted-drift-acks/3191-unanchored-grep-sites.json
deleted file mode 100644
index 11fa1690c..000000000
--- a/tests/emitted-drift-acks/3191-unanchored-grep-sites.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "version": 1,
- "paths": {
- "code-review.md": "#3191: +711 bytes. The two remaining unanchored `git log --grep=\"${PADDED_PHASE}\"` diff-base derivations (spawn_reviewer; Tier-3 was anchored in #2989) now use the anchored '[Pp]hase N([^[:alnum:]_]|$)' with --extended-regexp, and the #2989 site's trailing \\b — not a POSIX ERE token, silently matching nothing on macOS regex(3) — is replaced by the POSIX class; spawn_reviewer also gains Tier-3's parent-exists guard. The fallow structural-pre-pass.md fragment (not size-measured; hash ripple explained by its own committed change) carries the same anchoring. Supersedes the spent 2989-code-review-anchored-diff-base.json fragment. — #3423 append (epic #1891 F8): also grew 9 bytes with the -> tag rename (3 tag tokens, +3 bytes each; tag-token-only delta, no prose changed)."
- }
-}
diff --git a/tests/emitted-drift-acks/3503-diff-base-scope-anchor.json b/tests/emitted-drift-acks/3503-diff-base-scope-anchor.json
new file mode 100644
index 000000000..702a86b20
--- /dev/null
+++ b/tests/emitted-drift-acks/3503-diff-base-scope-anchor.json
@@ -0,0 +1,6 @@
+{
+ "version": 1,
+ "paths": {
+ "code-review.md": "#3503: +915 bytes vs next. The emitted workflow copy grows with its source gsd-core/workflows/code-review.md — the scope-anchored diff-base grep, the PHASE_SCOPE_NUM padded/unpadded prep, and the #3503 contract comments at both derivation sites (Tier-3 fallback and spawn_reviewer). Replaces the spent 3191-unanchored-grep-sites.json fragment (its code-review.md entry was consumed when #3191 merged and could no longer clear anything). The source-path ripples (gsd-core/workflows/code-review.md, gsd-core/workflows/code-review/steps/structural-pre-pass.md) are identity-attributed by the table and need no ack."
+ }
+}