* 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:
7
.changeset/serene-newts-click.md
Normal file
7
.changeset/serene-newts-click.md
Normal 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 -->
|
||||
@@ -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": [
|
||||
|
||||
19
src/uat.cts
19
src/uat.cts
@@ -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
|
||||
|
||||
253
tests/audit-uat-summary-segmentation.test.cjs
Normal file
253
tests/audit-uat-summary-segmentation.test.cjs
Normal 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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user