diff --git a/.changeset/mellow-wasps-click.md b/.changeset/mellow-wasps-click.md new file mode 100644 index 000000000..1f449a5b2 --- /dev/null +++ b/.changeset/mellow-wasps-click.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4025 +--- +**Acknowledged moot items stay closed in `audit-uat`** — the `audit_acknowledged` frontmatter marker (the documented, self-invalidating "this item is moot" seam) now suppresses items in `query audit-uat` exactly as it already does in `audit-open`, with the same snapshot keys and a visible `acknowledged_files` count — no more choosing between lying (`status: passed`), inventing tokens, or deleting the planning record. (#3805) diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index 85f7d9ef8..bc3c880a5 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -256,9 +256,10 @@ "audit-command-cutover.test.cjs", "audit-fix-command.test.cjs", "audit-milestone-filename-guard.test.cjs", + "audit-uat-acknowledged.test.cjs", "audit-workstream-layouts.test.cjs" ], - "issue": "#3804 \u2014 the layout tests drive the real audit-uat CLI against on-disk workstream fixtures; the existing audit suites cover the CLI cutover and the filename contract, and this covers phase-directory enumeration" + "issue": "#3804/#3805 \u2014 audit-layout and acknowledged-marker suites drive the real audit-uat CLI against on-disk fixtures" } } } diff --git a/src/audit.cts b/src/audit.cts index a320ab4d7..78b0c3596 100644 --- a/src/audit.cts +++ b/src/audit.cts @@ -1789,6 +1789,13 @@ export = { formatAuditReport, listAuditPhaseTargets, cmdAuditAcknowledge, + // #3805: exported so uat.cts's cmdAuditUat routes the SAME artifacts' + // suppression through the ONE predicate instead of hand-rolling a tenth + // copy outside this file's visibility (the exact defect class the + // predicate's own header warns about). The snapshot derivations ride + // along so the snapshotKeys cannot drift between the two consumers. + isAuditItemAcknowledged, + deriveUatGapSnapshotValue, // #2142: exported so src/milestone.cts's archiveQuickTaskDirectories README // index generator shares this ONE discovery rule rather than re-deriving it. resolveQuickTaskSummaryFile, diff --git a/src/uat.cts b/src/uat.cts index 5bbc79404..42d743741 100644 --- a/src/uat.cts +++ b/src/uat.cts @@ -35,6 +35,9 @@ const { PHASE_NUMBER_TOKEN_SOURCE, scopeToPhase } = phaseIdMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseLocator = require('./phase-locator.cjs'); const { listMilestonePhaseDirs, getAllArchivedPhaseDirs } = phaseLocator; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import auditMod = require('./audit.cjs'); +const { isAuditItemAcknowledged, deriveUatGapSnapshotValue } = auditMod; import { requireSafePath, sanitizeForDisplay } from './security.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports -- config-loader.cjs is an export= CommonJS module import configLoader = require('./config-loader.cjs'); @@ -158,6 +161,7 @@ function cmdAuditUat(cwd: string, raw: boolean): void { } const results: UatFileResult[] = []; + let acknowledgedFiles = 0; // Active dirs are milestone-filtered; archived dirs deliberately are NOT. // listMilestonePhaseDirs derives the CURRENT milestone's phase directories @@ -198,7 +202,16 @@ function cmdAuditUat(cwd: string, raw: boolean): void { const uatFilePath = path.join(phaseDir, file); const content = readNormalizedDocument(uatFilePath); const { items, headingsSeen } = parseUatItemsWithStats(content); - const status = (extractFrontmatter(content, uatFilePath).status as string || 'unknown'); + const uatFm = extractFrontmatter(content, uatFilePath) as Record; + const status = ((uatFm.status as string) || 'unknown').toLowerCase(); + // #3805: honour the audit_acknowledged marker with the SAME snapshot + // key audit.cts's scanUatGaps uses ('gap_snapshot', derived value + // composed by the shared derivation) — one acknowledgement means the + // same thing to both commands. + if (isAuditItemAcknowledged(uatFm, { snapshotKey: 'gap_snapshot', currentValue: deriveUatGapSnapshotValue(status, content) })) { + acknowledgedFiles++; + continue; + } // `parse_gap` means the file contained `### N.` test blocks that // yielded no items — NOT merely "zero items and not complete" (#3707 // MAJOR: that broader signal false-positived on an all-pass file and on @@ -258,8 +271,17 @@ function cmdAuditUat(cwd: string, raw: boolean): void { for (const file of scopeToPhase(files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md')), dir)) { const verificationFilePath = path.join(phaseDir, file); const content = readNormalizedDocument(verificationFilePath); - const status = extractFrontmatter(content, verificationFilePath).status as string || 'unknown'; + const verFm = extractFrontmatter(content, verificationFilePath) as Record; + const status = ((verFm.status as string) || 'unknown').toLowerCase(); + // #3805: same marker, same 'status' snapshot key as scanVerificationGaps, + // and the same ORDERING — the open-status gate runs FIRST (a marker on + // a file that would never surface is not a suppressed item), then the + // acknowledgement suppresses what the gate surfaced. if (status === 'human_needed' || status === 'gaps_found') { + if (isAuditItemAcknowledged(verFm, { snapshotKey: 'status', currentValue: status })) { + acknowledgedFiles++; + continue; + } const items = parseVerificationItems(content, status, verificationFilePath); if (items.length > 0) { results.push({ @@ -349,7 +371,9 @@ function cmdAuditUat(cwd: string, raw: boolean): void { } } - output({ results, summary }, raw, undefined); + // #3805: acknowledged files surface as a COUNT (audit-open's honesty + // model: the marker fired, the items are suppressed, both facts visible). + output({ results, summary, acknowledged_files: acknowledgedFiles }, raw, undefined); } // ─── cmdRenderCheckpoint ────────────────────────────────────────────────────── diff --git a/tests/audit-uat-acknowledged.test.cjs b/tests/audit-uat-acknowledged.test.cjs new file mode 100644 index 000000000..ac1ef64d5 --- /dev/null +++ b/tests/audit-uat-acknowledged.test.cjs @@ -0,0 +1,141 @@ +'use strict'; + +// ───────────────────────────────────────────────────────────────────────────── +// #3805 — audit-uat must honour the audit_acknowledged marker that +// audit-open honours. +// +// isAuditItemAcknowledged is the ONE shared suppression predicate, but its +// "every scanner below" scope ended at audit.cts's file boundary: +// cmdAuditUat (src/uat.cts) hand-rolled discovery over the SAME UAT and +// VERIFICATION artifacts and never read the marker — so the documented, +// self-invalidating "this item is moot" seam worked for audit-open and was +// ignored by audit-uat, leaving no honest way to close a moot item. +// ───────────────────────────────────────────────────────────────────────────── + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); + +const UAT_BODY = [ + '## Tests', + '', + '### 1. Deleted Feature Probe', + 'expected: The deleted admin route no longer exists', + 'result: pending', + '', +].join('\n'); + +function seedUatPhase(tmpDir, phaseDir, frontmatterExtra) { + const dir = path.join(tmpDir, '.planning', 'phases', phaseDir); + fs.mkdirSync(dir, { recursive: true }); + const fm = [ + '---', + 'status: human_needed', + ...(frontmatterExtra || []), + '---', + '', + ].join('\n'); + fs.writeFileSync(path.join(dir, `${phaseDir}-UAT.md`), fm + UAT_BODY); + return dir; +} + +function seedVerificationPhase(tmpDir, phaseDir, frontmatterExtra) { + const dir = path.join(tmpDir, '.planning', 'phases', phaseDir); + fs.mkdirSync(dir, { recursive: true }); + const fm = [ + '---', + 'status: human_needed', + ...(frontmatterExtra || []), + '---', + '', + ].join('\n'); + fs.writeFileSync(path.join(dir, `${phaseDir}-VERIFICATION.md`), fm + [ + '# Verification', + '', + '## Human Verification', + '', + '1. **Test:** the deleted route stays deleted', + ' - **Result:** pending', + '', + ].join('\n')); + return dir; +} + +function roadmapWith(phases) { + const lines = ['# Roadmap', '']; + for (const p of phases) lines.push(`### Phase ${p}: P${p}`, '- [ ] w', ''); + return lines.join('\n'); +} + +function runAudit(cwd) { + const r = runGsdTools(['query', 'audit-uat'], cwd); + assert.ok(r.success, r.error); + return JSON.parse(r.output); +} + +describe('#3805: audit-uat honours audit_acknowledged', () => { + test('#3805: an acknowledged UAT file is suppressed and counted', (t) => { + const tmpDir = createTempProject('gsd-3805-uat-'); + t.after(() => cleanup(tmpDir)); + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmapWith(['02'])); + seedUatPhase(tmpDir, '02-probe', [ + 'audit_acknowledged:', + ' milestone: v1.0', + ' at: 2026-08-24', + ' gap_snapshot: human_needed::scenarios=1', + ]); + // Note: the gap_snapshot must match the CURRENT derived value for the + // marker to suppress (self-invalidation on edit). + const out = runAudit(tmpDir); + assert.equal(out.summary.total_items, 0, + `#3805: the acknowledged UAT item must be suppressed; got ${JSON.stringify(out.results)}`); + assert.equal(out.acknowledged_files, 1, + '#3805: the suppressed file must be visible in acknowledged_files (audit-open honesty model)'); + }); + + test('#3805: a STALE gap_snapshot (content changed since ack) still surfaces', (t) => { + const tmpDir = createTempProject('gsd-3805-uatstale-'); + t.after(() => cleanup(tmpDir)); + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmapWith(['03'])); + // Marker snapshots scenarios=2 but the file now has 1 — self-invalidation. + seedUatPhase(tmpDir, '03-probe', [ + 'audit_acknowledged:', + ' milestone: v1.0', + ' at: 2026-08-24', + ' gap_snapshot: human_needed::scenarios=2', + ]); + const out = runAudit(tmpDir); + assert.ok(out.summary.total_items > 0, + 'a marker whose snapshot no longer matches must NOT suppress (self-invalidation)'); + assert.equal(out.acknowledged_files, 0, 'a stale marker does not count as acknowledged'); + }); + + test('#3805: an acknowledged VERIFICATION file is suppressed (status snapshot)', (t) => { + const tmpDir = createTempProject('gsd-3805-ver-'); + t.after(() => cleanup(tmpDir)); + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmapWith(['04'])); + seedVerificationPhase(tmpDir, '04-probe', [ + 'audit_acknowledged:', + ' milestone: v1.0', + ' at: 2026-08-24', + ' status: human_needed', + ]); + const out = runAudit(tmpDir); + assert.equal(out.summary.total_items, 0, + `#3805: the acknowledged VERIFICATION item must be suppressed; got ${JSON.stringify(out.results)}`); + assert.equal(out.acknowledged_files, 1, + '#3805: the suppressed VERIFICATION file must be visible in acknowledged_files'); + }); + + test('#3805 control: an unacknowledged open UAT file still surfaces', (t) => { + const tmpDir = createTempProject('gsd-3805-ctl-'); + t.after(() => cleanup(tmpDir)); + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmapWith(['05'])); + seedUatPhase(tmpDir, '05-probe'); + const out = runAudit(tmpDir); + assert.ok(out.summary.total_items > 0, 'no marker → item surfaces (pre-existing behavior)'); + }); +});