enhance(#3783): audit-uat summary segments current-milestone vs archived debt (#4336)

* test(#3783): add failing coverage for audit-uat summary segmentation

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#3783): segment audit-uat summary into current_milestone and archived buckets

Additive: current_milestone/archived are new; total_items, total_files, parse_gap_files, by_phase, and by_category are unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#3783): add changeset fragment for audit-uat summary segmentation

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(#3783): allowlist the new audit-uat-summary-segmentation test file

lint-test-file-count.cjs baselines the "audit" module (keyed off bin/lib/audit.cjs)
at 6 pre-existing files; this adds the new dedicated suite as a 7th, matching the
module's existing one-file-per-feature-slice precedent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(#3783): fix phase/file number mismatch in the mixed-milestone fixture

The active phase fixture used dir "02-current" with file "01-UAT.md" — a
cross-phase stray per phase-id.cts's isPhaseArtifact/scopeToPhase (#3511),
so the file was silently excluded from the scan and current_milestone read
{files:0, items:0} instead of {files:1, items:1}. Confirmed by direct CLI
run against a hand-built fixture before recommitting. Renamed the file to
02-UAT.md to match its directory's phase number, matching every other
fixture in this suite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#3783): backfill changeset PR number to 4336

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-09-05 19:03:14 -04:00
committed by GitHub
parent f9f72cb54c
commit 0be5bf865a
4 changed files with 281 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
---
type: Added
pr: 4336
---
**`query audit-uat` now segments its summary by milestone.** The JSON output adds `summary.current_milestone: {files, items}` and `summary.archived: {files, items, by_milestone}`, so a consumer can read current-vs-archived UAT/verification debt directly instead of re-deriving the `archived_milestone` filter itself. Existing fields (`total_items`, `total_files`, `parse_gap_files`, `by_category`, `by_phase`) are unchanged. (#3783)
<!-- docs-exempt: internal JSON contract of the `query audit-uat` CLI verb, consumed by workflow .md files (progress.md, audit-fix.md) rather than end users; the pre-existing sibling fields (total_items, by_phase, by_category, parse_gap_files) this is additive to were never documented in docs/ either, and the maintainer's triage approval scoped this PR to the summary builder + tests only -->

View File

@@ -261,9 +261,10 @@
"audit-milestone-filename-guard.test.cjs",
"audit-open-remainder-count.test.cjs",
"audit-uat-acknowledged.test.cjs",
"audit-uat-summary-segmentation.test.cjs",
"audit-workstream-layouts.test.cjs"
],
"issue": "#3804/#3805 \u2014 audit-layout and acknowledged-marker suites drive the real audit-uat CLI against on-disk fixtures | #3817 \u2014 regression suite proving countReal counts the truncation remainder marker instead of treating it as one phantom item"
"issue": "#3804/#3805 \u2014 audit-layout and acknowledged-marker suites drive the real audit-uat CLI against on-disk fixtures | #3817 \u2014 regression suite proving countReal counts the truncation remainder marker instead of treating it as one phantom item | #3783 \u2014 dedicated suite for the summary.current_milestone/archived segmentation, matching this module's one-file-per-feature-slice precedent (e.g. audit-uat-acknowledged.test.cjs) rather than growing the unrelated 6900+ line tests/uat.test.cjs (a different module, keyed off uat.cjs)"
},
"shell-command-projection": {
"files": [

View File

@@ -331,6 +331,8 @@ function cmdAuditUat(cwd: string, raw: boolean): void {
parse_gap_files: number;
by_category: Record<string, number>;
by_phase: Record<string, number>;
current_milestone: { files: number; items: number };
archived: { files: number; items: number; by_milestone: Record<string, number> };
} = {
total_files: results.length,
total_items: results.reduce((sum, r) => sum + r.items.length, 0),
@@ -354,9 +356,26 @@ function cmdAuditUat(cwd: string, raw: boolean): void {
parse_gap_files: results.filter((r) => r.parse_gap).length,
by_category: {},
by_phase: {},
// #3783: additive segmentation so a consumer reads one field instead of
// re-deriving the `archived_milestone` filter itself. Deliberately does
// NOT touch total_items/parse_gap_files — see the parse_gap_files
// comment above for why splitting THAT counter by archive status was
// tried and reverted; this is a purely additive pair of new keys.
current_milestone: { files: 0, items: 0 },
archived: { files: 0, items: 0, by_milestone: {} },
};
for (const r of results) {
const resultItemCount = r.items.length;
if (r.archived_milestone) {
summary.archived.files++;
summary.archived.items += resultItemCount;
summary.archived.by_milestone[r.archived_milestone] =
(summary.archived.by_milestone[r.archived_milestone] || 0) + resultItemCount;
} else {
summary.current_milestone.files++;
summary.current_milestone.items += resultItemCount;
}
// Deliberate (#3707 follow-up MINOR): this seeds a `by_phase` key at 0
// even for a parse-gap-only phase whose `items` is empty — do NOT "tidy"
// this away as dead code. The 0-valued key is itself the cue that this

View File

@@ -0,0 +1,253 @@
/**
* #3783 — `query audit-uat`'s summary must self-segment current-milestone
* debt from archived-milestone debt instead of conflating both into
* `total_items`/`by_phase`/`by_category`.
*
* The scan already stamps each archived result with `archived_milestone`
* (`src/uat.cts` `UatFileResult.archived_milestone`, set from
* `getAllArchivedPhaseDirs`'s per-version label) but previously discarded
* that distinction when building `summary`. This adds two additive fields —
* `summary.current_milestone: {files, items}` and
* `summary.archived: {files, items, by_milestone}` — so a consumer reads one
* field instead of re-deriving the `archived_milestone` filter itself (the
* duplication class behind #3782, which fixed `progress.md`'s render-side
* conflation independently and is not touched here).
*/
'use strict';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const FRONTMATTER = `---
status: testing
phase: 01-foundation
started: 2025-01-01T00:00:00Z
updated: 2025-01-01T00:00:00Z
---
`;
function uatBody(items) {
let body = `${FRONTMATTER}## Tests\n\n`;
items.forEach(({ name, result }, i) => {
body += `### ${i + 1}. ${name}\nresult: ${result}\n\n`;
});
return body;
}
function writeActivePhaseUat(tmpDir, phaseSlug, fileName, items) {
const phaseDir = path.join(tmpDir, '.planning', 'phases', phaseSlug);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, fileName), uatBody(items));
}
function writeArchivedPhaseUat(tmpDir, version, phaseSlug, fileName, items) {
const phaseDir = path.join(tmpDir, '.planning', 'milestones', `${version}-phases`, phaseSlug);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, fileName), uatBody(items));
}
function writeArchivedDeferred(tmpDir, version, phaseSlug, text) {
const phaseDir = path.join(tmpDir, '.planning', 'milestones', `${version}-phases`, phaseSlug);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, 'deferred-items.md'),
`## Deferred Items\n\n- ${text}\n`,
);
}
function writeActiveDeferred(tmpDir, phaseSlug, text) {
const phaseDir = path.join(tmpDir, '.planning', 'phases', phaseSlug);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, 'deferred-items.md'),
`## Deferred Items\n\n- ${text}\n`,
);
}
function runAudit(tmpDir) {
const result = runGsdTools('audit-uat --raw', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
return JSON.parse(result.output);
}
describe('#3783: audit-uat summary self-segments current-milestone vs archived debt', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('current-milestone-only results segment entirely into current_milestone', () => {
writeActivePhaseUat(tmpDir, '01-foundation', '01-UAT.md', [
{ name: 'Alpha', result: 'pending' },
{ name: 'Beta', result: 'pass' },
]);
const output = runAudit(tmpDir);
const describeAll = () => JSON.stringify(output.summary, null, 2);
assert.deepStrictEqual(
output.summary.current_milestone,
{ files: 1, items: 1 },
describeAll(),
);
assert.deepStrictEqual(
output.summary.archived,
{ files: 0, items: 0, by_milestone: {} },
describeAll(),
);
// Legacy fields untouched by the new segmentation.
assert.strictEqual(output.summary.total_items, 1, describeAll());
assert.strictEqual(output.summary.total_files, 1, describeAll());
});
test('archived-only results segment entirely into archived, keyed by version', () => {
writeArchivedPhaseUat(tmpDir, 'v0.1.0', '01-foundation', '01-UAT.md', [
{ name: 'Alpha', result: 'pending' },
{ name: 'Beta', result: 'blocked' },
]);
const output = runAudit(tmpDir);
const describeAll = () => JSON.stringify(output.summary, null, 2);
assert.deepStrictEqual(
output.summary.current_milestone,
{ files: 0, items: 0 },
describeAll(),
);
assert.strictEqual(output.summary.archived.files, 1, describeAll());
assert.strictEqual(output.summary.archived.items, 2, describeAll());
assert.deepStrictEqual(
output.summary.archived.by_milestone,
{ 'v0.1.0': 2 },
describeAll(),
);
assert.strictEqual(output.summary.total_items, 2, describeAll());
});
test('mixed active + multiple archived milestones segments per version', () => {
writeActivePhaseUat(tmpDir, '02-current', '02-UAT.md', [
{ name: 'Active Item', result: 'pending' },
]);
writeArchivedPhaseUat(tmpDir, 'v0.1.0', '01-foundation', '01-UAT.md', [
{ name: 'Old A', result: 'pending' },
{ name: 'Old B', result: 'blocked' },
{ name: 'Old C', result: 'skipped' },
]);
writeArchivedPhaseUat(tmpDir, 'v0.2.0', '01-followup', '01-UAT.md', [
{ name: 'Newer A', result: 'pending' },
]);
const output = runAudit(tmpDir);
const describeAll = () => JSON.stringify(output.summary, null, 2);
assert.deepStrictEqual(
output.summary.current_milestone,
{ files: 1, items: 1 },
describeAll(),
);
assert.strictEqual(output.summary.archived.files, 2, describeAll());
assert.strictEqual(output.summary.archived.items, 4, describeAll());
assert.deepStrictEqual(
output.summary.archived.by_milestone,
{ 'v0.1.0': 3, 'v0.2.0': 1 },
describeAll(),
);
// Cross-population legacy total is the sum of both buckets (unchanged shape).
assert.strictEqual(
output.summary.total_items,
output.summary.current_milestone.items + output.summary.archived.items,
describeAll(),
);
});
test('a zero-item result still counts toward .files but not .items, in the correct bucket', () => {
// A phase whose only test row has no `result:` line at all is a parse
// gap: zero items, but the file WAS scanned. See UatFileResult.parse_gap.
writeActivePhaseUat(tmpDir, '01-foundation', '01-UAT.md', []);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'phases', '01-foundation', '01-UAT.md'),
`${FRONTMATTER}## Tests\n\n### 1. Unparseable\n`,
);
writeArchivedPhaseUat(tmpDir, 'v0.1.0', '01-old', '01-UAT.md', []);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'milestones', 'v0.1.0-phases', '01-old', '01-UAT.md'),
`${FRONTMATTER}## Tests\n\n### 1. Unparseable Old\n`,
);
const output = runAudit(tmpDir);
const describeAll = () => JSON.stringify(output.summary, null, 2);
assert.deepStrictEqual(
output.summary.current_milestone,
{ files: 1, items: 0 },
describeAll(),
);
assert.strictEqual(output.summary.archived.files, 1, describeAll());
assert.strictEqual(output.summary.archived.items, 0, describeAll());
assert.deepStrictEqual(
output.summary.archived.by_milestone,
{ 'v0.1.0': 0 },
describeAll(),
);
});
test('legacy summary fields stay unchanged (additive-only)', () => {
writeActivePhaseUat(tmpDir, '01-foundation', '01-UAT.md', [
{ name: 'Alpha', result: 'pending' },
]);
writeArchivedPhaseUat(tmpDir, 'v0.1.0', '01-old', '01-UAT.md', [
{ name: 'Old', result: 'blocked' },
]);
const output = runAudit(tmpDir);
const describeAll = () => JSON.stringify(output.summary, null, 2);
assert.strictEqual(output.summary.total_files, 2, describeAll());
assert.strictEqual(output.summary.total_items, 2, describeAll());
assert.strictEqual(output.summary.parse_gap_files, 0, describeAll());
assert.deepStrictEqual(output.summary.by_category, { pending: 1, blocked: 1 }, describeAll());
assert.ok(Object.prototype.hasOwnProperty.call(output.summary.by_phase, '01'), describeAll());
});
test('deferred-type results segment by archived_milestone the same as uat/verification', () => {
writeActiveDeferred(tmpDir, '01-foundation', 'Some deferred follow-up.');
writeArchivedDeferred(tmpDir, 'v0.1.0', '01-old', 'An old deferred item.');
const output = runAudit(tmpDir);
const describeAll = () => JSON.stringify(output, null, 2);
assert.strictEqual(output.summary.current_milestone.files, 1, describeAll());
assert.strictEqual(output.summary.current_milestone.items, 1, describeAll());
assert.strictEqual(output.summary.archived.files, 1, describeAll());
assert.strictEqual(output.summary.archived.items, 1, describeAll());
assert.deepStrictEqual(output.summary.archived.by_milestone, { 'v0.1.0': 1 }, describeAll());
});
test('an empty scan yields zeroed current_milestone and archived buckets', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
const output = runAudit(tmpDir);
const describeAll = () => JSON.stringify(output.summary, null, 2);
assert.deepStrictEqual(
output.summary.current_milestone,
{ files: 0, items: 0 },
describeAll(),
);
assert.deepStrictEqual(
output.summary.archived,
{ files: 0, items: 0, by_milestone: {} },
describeAll(),
);
});
});