enhance(#2572): run the verify-summary artifact check against phase SUMMARYs (#2685)

* enhance(#2572): run the verify-summary artifact check against phase SUMMARYs (W025)

The artifact<->git check has existed since the beginning but was only ever
pointed at .planning/research/SUMMARY.md (new-project.md:1145,
new-milestone.md:425). Phase summaries -- the ones that actually claim
"I created these files" -- were never checked.

- extract verifySummaryCore from cmdVerifySummary: same checks, lifted out of
  the output() wrapper so callers consume {passed, checks, errors} directly
  instead of shelling out and re-parsing JSON; cmdVerifySummary is now a thin
  adapter over it
- validate.health gains advisory W025 per phase SUMMARY with missing files

Advisory only: appends to warnings[], never touches status escalation beyond
the channel's own warning semantics, the repair set, or readVerificationStatus.

Resolves both open questions from triage: (a) commits_exist is deliberately NOT
surfaced -- its hash pattern matches any hex-shaped token in prose, too loose to
show a user; (b) a phase carries N per-plan summaries plus a legacy bare
SUMMARY.md, so all of them are checked via the repo-wide filter.

* chore(#2572): add changeset fragment

* feat(#2572): move the SUMMARY artifact check to phase completion

Responds to the #2685 review. Three substantive changes.

Seam (Blocker 2). The check now runs in cmdPhaseComplete, the seam the
issue body cited (src/phase.cts:~1745), not validate.health. That channel
does exist: cmdPhaseComplete declares warnings[], populates it from the
UAT/VERIFICATION pre-scan, and emits it. The cycle objection raised
against the earlier deviation holds for state.cts only -- verify.cts has
no transitive import path to phase.cts, so phase.cts -> verify.cjs adds
no cycle (verified over every src/*.cts). Moving it also retires the
retroactive firing across all historical phases: this fires once, at
completion, for the phase being completed.

Extraction (Blocker 1). Pattern 2 now excludes [ and ] from its path
class. The SUMMARY templates prescribe a YAML flow sequence
(key-files.created: [a.ts, b.ts]) and the label matches case-insensitively,
so the class previously captured the literal [ and produced a candidate
that can never exist on disk -- firing on healthy projects built from the
templates GSD itself ships. Stripping frontmatter was the other offered
remedy; measured across all three shipped templates it is a no-op on top
of the exclusion, so it is not carried. Consequence named in-code: the
key-files block still is not read, which needs a real frontmatter parse.

Also narrowed to the noise classes confirmed in review -- globs, bare
hostnames, and paths resolving outside the project are skipped rather
than reported, and the containment guard the old comment claimed now
actually exists.

Budget (Majors 1 and 3). verifySummaryCore takes a checkCommits option;
phase completion passes false, so the discarded git cat-file probes are
not spawned at all. It also passes Infinity, so every referenced file is
reported instead of the first two -- a summary listing twelve files of
which nine are missing now says nine, not zero. The verb keeps its
historical 2-file default.

Tests (Major 2). The vacuous fixtures are gone with the health block.
The replacements use /-bearing paths that genuinely extract, and each
fix was mutation-checked: un-anchoring pattern 2, dropping the glob,
hostname or containment filter, forcing commit checking on, and
re-capping at 2 each fail at least one test.

* docs(#2572): describe the phase-completion SUMMARY artifact check

The W025 text under /gsd-health is withdrawn with the health seam; the
check is documented where it now runs, under `phase complete` in
docs/CLI-TOOLS.md.

Both the docs and the changeset previously overclaimed: they said a
referenced file not on disk is warned about, while at most two candidates
per SUMMARY were ever examined. The cap is gone at this seam, so the
claim now holds -- and the text states the limits that remain, rather
than leaving them to be discovered: the key-files frontmatter block is
not read, commit hashes are not resolved, and globs, URLs, bare
hostnames and out-of-project paths are skipped rather than reported.

---------

Co-authored-by: CI Rebase Check <ci@gsd-redux>
This commit is contained in:
Rezolv
2026-07-28 18:31:33 -04:00
committed by GitHub
parent 6932fb16d7
commit 7e8f6a6d7d
6 changed files with 567 additions and 17 deletions

View File

@@ -9449,3 +9449,153 @@ describe('issue #2334: ghost-REQ-ID classification must probe write surfaces, no
},
);
});
// ─── #2572: phase-SUMMARY artifact↔disk advisory at phase completion ─────────
//
// A SUMMARY asserts "I created these files". Until #2572 nothing checked that
// claim for phase summaries — `verify-summary` existed but was only ever
// pointed at `.planning/research/SUMMARY.md`, so an interrupted or
// over-reported phase counted toward 100% silently.
//
// The advisory joins the existing `warnings[]` channel of `phase complete`
// (rendered by execute-phase.md's "If has_warnings is true" step). It is
// advisory ONLY: the completion GATE is readVerificationStatus, untouched here.
function build2572SummaryArtifactFixture() {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2572-artifact-'));
const planDir = path.join(tmpDir, '.planning');
const dirtyDir = path.join(planDir, 'phases', '01-dirty');
const cleanDir = path.join(planDir, 'phases', '02-clean');
fs.mkdirSync(dirtyDir, { recursive: true });
fs.mkdirSync(cleanDir, { recursive: true });
fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
// Only these two land on disk.
fs.writeFileSync(path.join(tmpDir, 'src/landed-one.ts'), 'x\n');
fs.writeFileSync(path.join(tmpDir, 'src/landed-two.ts'), 'x\n');
fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), [
'# Roadmap', '',
'- [ ] Phase 01: Dirty',
'- [ ] Phase 02: Clean', '',
'### Phase 01: Dirty',
'**Goal:** Ship the dirty thing',
'**Plans:** 1 plans', '',
'### Phase 02: Clean',
'**Goal:** Ship the clean thing',
'**Plans:** 1 plans', '',
'## Progress', '',
'| Phase | Plans Complete | Status | Completed |',
'|-------|----------------|--------|-----------|',
'| 01. Dirty | 0/1 | Not started | - |',
'| 02. Clean | 0/1 | Not started | - |',
'',
].join('\n'));
fs.writeFileSync(path.join(planDir, 'STATE.md'), [
'# State', '',
'**Current Phase:** 01',
'**Current Phase Name:** Dirty',
'**Status:** In progress',
'**Completed Phases:** 0',
'**Total Phases:** 2',
'**Progress:** 0%',
'',
].join('\n'));
fs.writeFileSync(path.join(dirtyDir, '01-01-PLAN.md'), '# Plan\nDo the work.\n');
// Frontmatter uses the shipped template's YAML flow sequence on purpose: the
// bracket artifact it once produced must not surface as a phantom warning.
fs.writeFileSync(path.join(dirtyDir, '01-01-SUMMARY.md'), [
'---',
'phase: 01-dirty',
'key-files:',
' created: [src/landed-one.ts, src/never-landed.ts]',
'status: complete',
'---', '',
'# Phase 1 Summary', '',
'Created `src/landed-one.ts` and `src/never-landed.ts`.',
'',
].join('\n'));
fs.writeFileSync(path.join(cleanDir, '02-01-PLAN.md'), '# Plan\nDo the work.\n');
fs.writeFileSync(path.join(cleanDir, '02-01-SUMMARY.md'), [
'---',
'phase: 02-clean',
'key-files:',
' created: [src/landed-two.ts]',
'status: complete',
'---', '',
'# Phase 2 Summary', '',
'Created `src/landed-two.ts`.',
'',
].join('\n'));
return { tmpDir };
}
describe('#2572: phase complete warns when a SUMMARY claims files that never landed', () => {
test('#2572-1: a SUMMARY naming a file that is not on disk produces a warning at completion', () => {
const { tmpDir } = build2572SummaryArtifactFixture();
try {
const { output } = runVerifiedPhaseComplete(['phase', 'complete', '1'], tmpDir);
const parsed = JSON.parse(output);
const warnings = parsed.warnings || [];
assert.ok(
warnings.some((w) => w.includes('src/never-landed.ts')),
`#2572-1 FAILED: expected a warning naming src/never-landed.ts, got: ${JSON.stringify(warnings)}`,
);
assert.ok(
warnings.some((w) => w.includes('01-01-SUMMARY.md')),
`#2572-1 FAILED: the warning must name the SUMMARY it came from, got: ${JSON.stringify(warnings)}`,
);
assert.strictEqual(parsed.has_warnings, true, '#2572-1 FAILED: has_warnings must be true');
} finally {
cleanup(tmpDir);
}
});
test('#2572-2: the advisory is ADVISORY — completion still succeeds and reports the phase complete', () => {
const { tmpDir } = build2572SummaryArtifactFixture();
try {
const { output } = runVerifiedPhaseComplete(['phase', 'complete', '1'], tmpDir);
const parsed = JSON.parse(output);
assert.strictEqual(parsed.completed_phase, '1', '#2572-2 FAILED: completion must not be blocked by the advisory');
assert.strictEqual(parsed.state_updated, true, '#2572-2 FAILED: STATE.md must still be written');
} finally {
cleanup(tmpDir);
}
});
test('#2572-3 (control): a phase whose SUMMARY files all exist emits NO artifact warning', () => {
const { tmpDir } = build2572SummaryArtifactFixture();
try {
const { output } = runVerifiedPhaseComplete(['phase', 'complete', '2'], tmpDir);
const parsed = JSON.parse(output);
const warnings = parsed.warnings || [];
assert.ok(
!warnings.some((w) => /not on disk/.test(w)),
`#2572-3 FAILED: a clean phase must emit no artifact advisory. This is the ` +
`"absent for a clean one" half — and the fixture references a real '/'-bearing ` +
`path, so silence here is earned, not vacuous. Got: ${JSON.stringify(warnings)}`,
);
} finally {
cleanup(tmpDir);
}
});
test('#2572-4: the shipped template frontmatter flow sequence produces no phantom "[path" warning', () => {
const { tmpDir } = build2572SummaryArtifactFixture();
try {
const { output } = runVerifiedPhaseComplete(['phase', 'complete', '2'], tmpDir);
const warnings = JSON.parse(output).warnings || [];
assert.ok(
!warnings.some((w) => w.includes('[src/')),
`#2572-4 FAILED (#2685 Blocker 1): a YAML flow sequence in frontmatter must not ` +
`leak a bracket-prefixed candidate. Got: ${JSON.stringify(warnings)}`,
);
} finally {
cleanup(tmpDir);
}
});
});

View File

@@ -2664,3 +2664,227 @@ describe('AC3: executable proof — file-wide ban vs region-scoped simultaneousl
});
});
}
// ─── #2572: verify-summary pure core, callable without the CLI wrapper ───────
describe('verifySummaryCore — reusable structured contract (#2572)', () => {
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { after } = require('node:test');
const { execSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const { verifySummaryCore } = require('../gsd-core/bin/lib/verify.cjs');
const dirs = [];
after(() => { while (dirs.length) cleanup(dirs.pop()); });
function repo(summaryBody, extraFiles = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2572-'));
dirs.push(dir);
execSync('git init -q', { cwd: dir, stdio: 'pipe' });
execSync('git config user.email "t@t.com"', { cwd: dir, stdio: 'pipe' });
execSync('git config user.name "T"', { cwd: dir, stdio: 'pipe' });
execSync('git config commit.gpgsign false', { cwd: dir, stdio: 'pipe' });
for (const [rel, body] of Object.entries(extraFiles)) {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), body);
}
fs.writeFileSync(path.join(dir, 'SUMMARY.md'), summaryBody);
execSync('git add -A && git commit -q -m seed', { cwd: dir, stdio: 'pipe' });
return dir;
}
test('returns the structured contract without writing to stdout', () => {
const dir = repo('# Summary\n\nCreated: `src/a.ts`\n', { 'src/a.ts': 'x\n' });
const r = verifySummaryCore(dir, 'SUMMARY.md', 2);
assert.strictEqual(typeof r, 'object');
assert.deepStrictEqual(Object.keys(r).sort(), ['checks', 'errors', 'passed']);
assert.strictEqual(r.checks.summary_exists, true);
assert.strictEqual(r.passed, true);
});
test('reports a missing referenced file as a structured check, not a throw', () => {
const dir = repo('# Summary\n\nCreated: `src/gone.ts`\n');
const r = verifySummaryCore(dir, 'SUMMARY.md', 2);
assert.strictEqual(r.passed, false);
assert.ok(r.checks.files_created.missing.includes('src/gone.ts'),
`expected src/gone.ts in missing, got ${JSON.stringify(r.checks.files_created)}`);
});
test('absent SUMMARY yields summary_exists false — never throws', () => {
const dir = repo('# Summary\n');
let r;
assert.doesNotThrow(() => { r = verifySummaryCore(dir, 'nope/SUMMARY.md', 2); });
assert.strictEqual(r.checks.summary_exists, false);
assert.strictEqual(r.passed, false);
});
test('unresolvable commit hash is reported via commits_exist', () => {
const dir = repo('# Summary\n\nCommit: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\n');
const r = verifySummaryCore(dir, 'SUMMARY.md', 2);
assert.strictEqual(r.checks.commits_exist, false);
assert.ok(r.errors.some((e) => /commit/i.test(e)), `expected a commit error, got ${JSON.stringify(r.errors)}`);
});
// ── Blocker 1 (#2685 review): frontmatter must be stripped before extraction ──
//
// All three SUMMARY templates prescribe a YAML flow sequence for key-files:
// key-files:
// created: [src/auth/login.ts, src/auth/session.ts]
// The prose pattern would otherwise capture the literal '[' as part of the
// first path, producing a candidate that can never exist on disk — firing on
// a healthy project built from GSD's own shipped template.
test('#2685 B1: a template-shaped frontmatter flow sequence yields no phantom "[path" candidate', () => {
const body = [
'---',
'phase: 04-auth',
'key-files:',
' created: [src/auth/login.ts, src/auth/session.ts]',
' modified: [src/auth/session.ts]',
'status: complete',
'---',
'',
'# Phase 4 Summary',
'',
'Built the login flow in `src/auth/login.ts`.',
'',
].join('\n');
const dir = repo(body, { 'src/auth/login.ts': 'x\n', 'src/auth/session.ts': 'x\n' });
const r = verifySummaryCore(dir, 'SUMMARY.md', Infinity, { checkCommits: false });
assert.deepStrictEqual(
r.checks.files_created.missing, [],
`#2685 B1 FAILED: every named file exists on disk, so nothing may be reported missing. ` +
`Got: ${JSON.stringify(r.checks.files_created)}`,
);
assert.ok(
!r.checks.files_created.missing.some((f) => f.includes('[')),
'a bracket-prefixed candidate must never reach the missing list',
);
});
test('#2685 B1: the check is still ABSENT for a clean phase and PRESENT for a dirty one', () => {
const clean = repo('# Summary\n\nBuilt `src/kept.ts` here.\n', { 'src/kept.ts': 'x\n' });
const rc = verifySummaryCore(clean, 'SUMMARY.md', Infinity, { checkCommits: false });
assert.strictEqual(rc.checks.files_created.checked, 1, 'the clean fixture must actually extract a candidate (not vacuously pass)');
assert.deepStrictEqual(rc.checks.files_created.missing, [], 'clean phase must not warn');
const dirty = repo('# Summary\n\nBuilt `src/kept.ts` and `src/never-landed.ts`.\n', { 'src/kept.ts': 'x\n' });
const rd = verifySummaryCore(dirty, 'SUMMARY.md', Infinity, { checkCommits: false });
assert.deepStrictEqual(
rd.checks.files_created.missing, ['src/never-landed.ts'],
`dirty phase must name exactly the file that never landed, got ${JSON.stringify(rd.checks.files_created.missing)}`,
);
});
// ── Major 1: checkCount is the single most behavior-defining constant here ──
test('#2685 M1: checkCount boundary — 1, 2, 3 extractable paths against the default cap', () => {
const mk = (n) => repo('# Summary\n\n' + Array.from({ length: n }, (_, i) => `- \`src/m${i}.ts\``).join('\n') + '\n');
assert.strictEqual(verifySummaryCore(mk(1), 'SUMMARY.md', undefined, { checkCommits: false }).checks.files_created.checked, 1);
assert.strictEqual(verifySummaryCore(mk(2), 'SUMMARY.md', undefined, { checkCommits: false }).checks.files_created.checked, 2);
assert.strictEqual(
verifySummaryCore(mk(3), 'SUMMARY.md', undefined, { checkCommits: false }).checks.files_created.checked, 2,
'the CLI default must remain capped at 2 — unchanged from before #2572',
);
assert.strictEqual(
verifySummaryCore(mk(3), 'SUMMARY.md', Infinity, { checkCommits: false }).checks.files_created.checked, 3,
'Infinity must lift the cap so an interrupted phase reports every missing file',
);
});
test('#2685 M1: an interrupted phase listing 12 files of which 9 are missing reports all 9', () => {
const refs = Array.from({ length: 12 }, (_, i) => `src/f${i}.ts`);
const present = Object.fromEntries(refs.slice(0, 3).map((f) => [f, 'x\n']));
const dir = repo('# Summary\n\n' + refs.map((f) => `- built \`${f}\``).join('\n') + '\n', present);
const capped = verifySummaryCore(dir, 'SUMMARY.md', undefined, { checkCommits: false });
assert.strictEqual(
capped.checks.files_created.missing.length, 0,
'precondition: at the 2-file default this real defect is invisible — that is exactly Major 1',
);
const all = verifySummaryCore(dir, 'SUMMARY.md', Infinity, { checkCommits: false });
assert.strictEqual(all.checks.files_created.missing.length, 9,
`expected all 9 missing, got ${JSON.stringify(all.checks.files_created.missing)}`);
});
// ── Major 3: the advisory path must spawn no git subprocesses ──
test('#2685 M3: checkCommits:false skips hash resolution entirely', () => {
const body = '# Summary\n\n## Task Commits\n- deadbeefdeadbeefdeadbeefdeadbeefdeadbeef initial\n';
const dir = repo(body);
const r = verifySummaryCore(dir, 'SUMMARY.md', Infinity, { checkCommits: false });
assert.strictEqual(r.checks.commits_exist, false, 'commits_exist is false meaning NOT CHECKED');
assert.deepStrictEqual(
r.errors.filter((e) => /commit/i.test(e)), [],
'with commit checking off, an unresolvable hash must not manufacture an error',
);
});
// ── Major 4 residue: confirmed false-positive classes stay filtered ──
test('#2685 M4: globs, bare hostnames, and traversal references are never probed', () => {
const body = [
'# Summary',
'',
// Each noise class is BACKTICKED on purpose: pattern 1 extracts any
// backticked `<something>.<ext>`, so these genuinely reach the candidate
// filter. An un-backticked fixture would pass vacuously.
'Touched `src/**/*.ts` across the tree.',
'See `docs.example.com/guide.html` for background.',
'Also `../../../../etc/passwd` and `https://example.com/x.html`.',
'Real file: `src/real.ts`.',
'',
].join('\n');
const dir = repo(body, { 'src/real.ts': 'x\n' });
const r = verifySummaryCore(dir, 'SUMMARY.md', Infinity, { checkCommits: false });
assert.strictEqual(
r.checks.files_created.checked, 1,
`only src/real.ts is a probeable candidate, got checked=${r.checks.files_created.checked}`,
);
assert.deepStrictEqual(r.checks.files_created.missing, [], 'no noise class may be reported missing');
});
test('#2685 minor: a dotfile directory is still a valid first segment', () => {
const dir = repo('# Summary\n\nAdded `.github/workflows/ci.yml`.\n', { '.github/workflows/ci.yml': 'x\n' });
const r = verifySummaryCore(dir, 'SUMMARY.md', Infinity, { checkCommits: false });
assert.strictEqual(r.checks.files_created.checked, 1,
'.github/... must not be mistaken for a hostname');
assert.deepStrictEqual(r.checks.files_created.missing, []);
});
// ── Minor: property coverage over the extractor (it is a parser) ──
test('#2685 property: no synthesized SUMMARY body yields a malformed candidate', () => {
const fc = require('./helpers/fast-check-setup.cjs');
const dir = repo('# seed\n');
const summaryPath = path.join(dir, 'SUMMARY.md');
fc.assert(
fc.property(fc.string(), fc.array(fc.string(), { maxLength: 8 }), (prose, paths) => {
const body = [
'---',
'key-files:',
` created: [${paths.join(', ')}]`,
'---',
'',
prose,
...paths.map((p) => `- built \`${p}\``),
].join('\n');
fs.writeFileSync(summaryPath, body);
const r = verifySummaryCore(dir, 'SUMMARY.md', Infinity, { checkCommits: false });
for (const c of r.checks.files_created.missing) {
assert.ok(!c.includes('['), `bracket artifact leaked: ${JSON.stringify(c)}`);
assert.ok(!c.includes('*') && !c.includes('?'), `glob leaked: ${JSON.stringify(c)}`);
assert.ok(
path.resolve(dir, c).startsWith(path.resolve(dir) + path.sep),
`candidate escaped the project root: ${JSON.stringify(c)}`,
);
}
return true;
}),
{ numRuns: 200 },
);
});
});