* test(#3805): audit-uat must honour the audit_acknowledged marker * fix(#3805): route audit-uat's UAT and VERIFICATION scans through the shared acknowledged predicate * chore(#3805): changeset fragment (pr number backfilled after PR creation) * chore(#3805): backfill changeset PR number (4025) --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/mellow-wasps-click.md
Normal file
5
.changeset/mellow-wasps-click.md
Normal file
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
30
src/uat.cts
30
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
141
tests/audit-uat-acknowledged.test.cjs
Normal file
141
tests/audit-uat-acknowledged.test.cjs
Normal file
@@ -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)');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user