Folds 15 tests/fix-*.test.cjs regression files (191 test() blocks) into their module's main suite, per the wave decomposition of #3315 (H3 of epic #3053). 187 blocks land in 8 existing suites (4 exact-duplicate cases dropped, documented inline); 4 blocks move via git mv into 2 new suite files with no prior coverage to merge into. Zero production behavior change. Also tightens two H1 (#3313) ratchets that the fold's own file-count reduction moved past their grace window, per the ratchets' documented dual failure mode (a stale/too-loose baseline fails exactly like a novel violation): - lint-test-file-count.allowlist.json: removes the stale "audit" entry (folding fix-2766 into tests/uat.test.cjs drops that module back to its 2-file cap). - lint-allow-test-rule-refs.ceiling.json: lowers maxFiles 314 -> 309, the real post-fold high-water mark (gsd-test's own repo-baseline test caught this — CI, not a human, found it). Two orthogonal review passes (Standards+Spec code-review, isolated security-review) found and this commit fixes two issues before push: a genuinely-distinct #2287 test case (file-absent vs. file-present- resolved) that a prior fold pass had wrongly dropped as a duplicate — restored verbatim into tests/uat.test.cjs; and a missing same-line allow-test-rule citation on the #2196 block in tests/debug-session-management.test.cjs, added for consistency with its sibling #2257 block. lint-removed-but-needed also caught two stale doc references to the now-folded-away fix-2285-claude-orchestration-wiring.test.cjs filename (docs/adr/1143-claude-orchestration-capability.md, gsd-core/references/execute-phase-response-language.md) — updated both to point at tests/claude-orchestration.test.cjs, its new home. Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -9,13 +9,14 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const fc = require('./helpers/fast-check-setup.cjs');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
const { runGsdTools, createTempProject, createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const {
|
||||
buildCheckpoint,
|
||||
CHECKPOINT_FRAMES,
|
||||
CHECKPOINT_LANGUAGE_ALIASES,
|
||||
resolveCheckpointFrame,
|
||||
checkpointBoxLine,
|
||||
parseDeferredItems,
|
||||
} = require('../gsd-core/bin/lib/uat.cjs');
|
||||
|
||||
describe('audit-uat command', () => {
|
||||
@@ -1414,3 +1415,661 @@ awaiting: user response
|
||||
assert.strictEqual(result.output, expected);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── cmdAuditUat behavioral coverage (#2287 deferred-items.md) ─────────────
|
||||
|
||||
describe('#2287 cmdAuditUat: deferred-items.md awareness', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('no deferred-items.md present (0 entries) → no results, no false positive', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '.gitkeep'), '');
|
||||
|
||||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.deepStrictEqual(output.results, []);
|
||||
assert.strictEqual(output.summary.total_items, 0);
|
||||
assert.strictEqual(output.summary.total_files, 0);
|
||||
});
|
||||
|
||||
test('deferred-items.md with only a resolved entry (0 unresolved) → no result surfaced', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(phaseDir, 'deferred-items.md'), [
|
||||
'## Deferred Items',
|
||||
'',
|
||||
'- Already handled unrelated lint warning.',
|
||||
' status: resolved',
|
||||
].join('\n'));
|
||||
|
||||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.deepStrictEqual(output.results, [],
|
||||
'a fully-resolved deferred-items.md must not surface any result');
|
||||
assert.strictEqual(output.summary.total_items, 0);
|
||||
});
|
||||
|
||||
test('deferred-items.md with 1 unresolved entry → surfaced in structured JSON output', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(phaseDir, 'deferred-items.md'), [
|
||||
'## Deferred Items',
|
||||
'',
|
||||
'- Found an unrelated pre-existing test failure in `some-other-module` while working on',
|
||||
' this phase\'s task. Out of scope for this task — logged here per SCOPE BOUNDARY.',
|
||||
].join('\n'));
|
||||
|
||||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.summary.total_items, 1);
|
||||
assert.strictEqual(output.summary.total_files, 1);
|
||||
assert.strictEqual(output.summary.by_category.deferred, 1);
|
||||
assert.strictEqual(output.summary.by_phase['01'], 1);
|
||||
|
||||
const deferredResult = output.results.find(r => r.type === 'deferred');
|
||||
assert.ok(deferredResult, 'a deferred-typed result must be present');
|
||||
assert.strictEqual(deferredResult.phase, '01');
|
||||
assert.strictEqual(deferredResult.file, 'deferred-items.md');
|
||||
assert.strictEqual(
|
||||
deferredResult.file_path,
|
||||
'.planning/phases/01-foundation/deferred-items.md',
|
||||
);
|
||||
assert.strictEqual(deferredResult.items.length, 1);
|
||||
assert.match(deferredResult.items[0].name, /unrelated pre-existing test failure/);
|
||||
assert.strictEqual(deferredResult.items[0].result, 'unresolved');
|
||||
assert.strictEqual(deferredResult.items[0].category, 'deferred');
|
||||
});
|
||||
|
||||
test('deferred-items.md with 2+ entries (mixed resolved/unresolved) → only unresolved surfaced', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(phaseDir, 'deferred-items.md'), [
|
||||
'## Deferred Items',
|
||||
'',
|
||||
'- First unrelated finding, still open.',
|
||||
'- Second unrelated finding, also still open.',
|
||||
'- Third finding, already fixed separately.',
|
||||
' status: resolved',
|
||||
].join('\n'));
|
||||
|
||||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
const deferredResult = output.results.find(r => r.type === 'deferred');
|
||||
assert.ok(deferredResult);
|
||||
assert.strictEqual(deferredResult.items.length, 2,
|
||||
'exactly the 2 unresolved entries must surface; the resolved 3rd must not');
|
||||
const names = deferredResult.items.map(i => i.name);
|
||||
assert.ok(names.some(n => n.includes('First unrelated finding')));
|
||||
assert.ok(names.some(n => n.includes('Second unrelated finding')));
|
||||
assert.ok(!names.some(n => n.includes('Third finding')));
|
||||
});
|
||||
|
||||
test('deferred entries surface across multiple phase directories', () => {
|
||||
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||
const phase2 = path.join(tmpDir, '.planning', 'phases', '02-auth');
|
||||
fs.mkdirSync(phase1, { recursive: true });
|
||||
fs.mkdirSync(phase2, { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(phase1, 'deferred-items.md'), [
|
||||
'## Deferred Items',
|
||||
'',
|
||||
'- Phase 1 unrelated finding.',
|
||||
].join('\n'));
|
||||
fs.writeFileSync(path.join(phase2, 'deferred-items.md'), [
|
||||
'## Deferred Items',
|
||||
'',
|
||||
'- Phase 2 unrelated finding.',
|
||||
].join('\n'));
|
||||
|
||||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
const deferredResults = output.results.filter(r => r.type === 'deferred');
|
||||
assert.strictEqual(deferredResults.length, 2);
|
||||
assert.strictEqual(output.summary.total_items, 2);
|
||||
assert.strictEqual(output.summary.by_phase['01'], 1);
|
||||
assert.strictEqual(output.summary.by_phase['02'], 1);
|
||||
});
|
||||
|
||||
test('an entry with a garbled/missing status fails safe and is surfaced (not silently dropped)', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(phaseDir, 'deferred-items.md'), [
|
||||
'## Deferred Items',
|
||||
'',
|
||||
'- An entry with no status field at all.',
|
||||
].join('\n'));
|
||||
|
||||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.summary.total_items, 1,
|
||||
'missing status must SURFACE the entry, not silently drop it');
|
||||
});
|
||||
|
||||
test('existing UAT/VERIFICATION scanning is unchanged when a deferred-items.md is also present', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(phaseDir, '01-UAT.md'), [
|
||||
'---',
|
||||
'status: testing',
|
||||
'phase: 01-foundation',
|
||||
'started: 2025-01-01T00:00:00Z',
|
||||
'updated: 2025-01-01T00:00:00Z',
|
||||
'---',
|
||||
'',
|
||||
'## Tests',
|
||||
'',
|
||||
'### 1. Login Form',
|
||||
'expected: Form displays with email and password fields',
|
||||
'result: pending',
|
||||
].join('\n'));
|
||||
|
||||
fs.writeFileSync(path.join(phaseDir, 'deferred-items.md'), [
|
||||
'## Deferred Items',
|
||||
'',
|
||||
'- An unrelated out-of-scope finding.',
|
||||
].join('\n'));
|
||||
|
||||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.results.length, 2, 'both the UAT file and deferred-items.md must surface as separate results');
|
||||
const uatResult = output.results.find(r => r.type === 'uat');
|
||||
const deferredResult = output.results.find(r => r.type === 'deferred');
|
||||
assert.ok(uatResult, 'existing uat-type result must still be present');
|
||||
assert.strictEqual(uatResult.items.length, 1);
|
||||
assert.strictEqual(uatResult.items[0].result, 'pending');
|
||||
assert.ok(deferredResult, 'new deferred-type result must be present');
|
||||
assert.strictEqual(deferredResult.items.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── forensic_audit workflow-prose source-contract guard (#2287) ──────────
|
||||
|
||||
// #2994 fragmentization moved the --forensic-gated forensic_audit step out of
|
||||
// progress.md into gsd-core/workflows/progress/steps/forensic-audit.md behind
|
||||
// a section marker. Read that step file directly — it is the sole remaining
|
||||
// source of the forensic_audit step body these guards assert on.
|
||||
const PROGRESS_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'progress', 'steps', 'forensic-audit.md');
|
||||
|
||||
describe('#2287 progress.md forensic_audit: deferred-items.md contract', () => {
|
||||
const content = fs.readFileSync(PROGRESS_MD, 'utf-8');
|
||||
const stepStart = content.indexOf('<step name="forensic_audit">');
|
||||
const stepEnd = content.indexOf('</step>', stepStart);
|
||||
const section = stepStart !== -1 && stepEnd !== -1 ? content.slice(stepStart, stepEnd) : '';
|
||||
|
||||
test('forensic_audit step exists', () => {
|
||||
assert.notEqual(stepStart, -1, 'progress.md (or its extracted progress/steps/forensic-audit.md) must contain the forensic_audit step');
|
||||
});
|
||||
|
||||
test('forensic_audit now runs 7 checks (was 6) and globs deferred-items.md', () => {
|
||||
assert.ok(/running 7 deep checks/i.test(section),
|
||||
'forensic_audit must advertise 7 deep checks (was 6) now that deferred-items.md is read');
|
||||
assert.ok(/\.planning\/phases\/\*\/deferred-items\.md/.test(section),
|
||||
'forensic_audit must glob .planning/phases/*/deferred-items.md');
|
||||
});
|
||||
|
||||
test('the new check reports unresolved deferred items with the same ✓/⚠ semantics as the other checks', () => {
|
||||
assert.ok(/check\s*7/i.test(section),
|
||||
'a 7th check must be present');
|
||||
assert.ok(/unresolved deferred items/i.test(section),
|
||||
'the check must be framed around unresolved deferred items');
|
||||
assert.ok(/✓[^\n]*no unresolved deferred items/i.test(section),
|
||||
'the check must emit a ✓ pass line when no unresolved deferred items exist');
|
||||
assert.ok(/⚠[^\n]*unresolved deferred items found/i.test(section),
|
||||
'the check must emit a ⚠ warning line when unresolved deferred items exist');
|
||||
});
|
||||
|
||||
test('an entry is resolved only via an explicit status: resolved field (fail-safe otherwise)', () => {
|
||||
assert.ok(/status:\s*resolved/i.test(section),
|
||||
'the resolved/unresolved parsing rule must be documented in the step prose');
|
||||
});
|
||||
|
||||
test('the verdict summary now gates on 7 checks (was 6)', () => {
|
||||
assert.ok(/after all 7 checks/i.test(section),
|
||||
'the verdict section must say "after all 7 checks"');
|
||||
assert.ok(/if all 7 checks passed/i.test(section),
|
||||
'the verdict section must say "if all 7 checks passed"');
|
||||
assert.ok(!/after all 6 checks/i.test(section) && !/if all 6 checks passed/i.test(section),
|
||||
'stale "6 checks" phrasing must not remain in the step');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parseDeferredItems property test (#2287) ──────────────────────────────
|
||||
|
||||
describe('#2287 parseDeferredItems: property (status: resolved fail-safe)', () => {
|
||||
// Single-line entry text: no newlines (would break bullet-entry splitting),
|
||||
// non-empty after trim, and never itself SHAPED like a `status:` field line
|
||||
// (that would be indistinguishable from a real field regardless of intent).
|
||||
const plainText = fc.string({ minLength: 1, maxLength: 40 })
|
||||
.map((s) => s.replace(/[\r\n]/g, ' ').trim())
|
||||
.filter((s) => s.length > 0 && !/^status:/i.test(s));
|
||||
|
||||
// Decoy: entry text that CONTAINS a `status: resolved`-shaped substring
|
||||
// mid-line (not at line start) — must never be misread as a resolved
|
||||
// marker, since extractGapEntryFields only recognises a field anchored to
|
||||
// the START of its own trimmed line (see parseDeferredItems' doc comment).
|
||||
const decoyText = plainText.map((s) => `${s} status: resolved trailing note`);
|
||||
|
||||
const textArb = fc.oneof(plainText, decoyText);
|
||||
const entryArb = fc.record({ text: textArb, resolved: fc.boolean() });
|
||||
|
||||
test('property: an entry is surfaced iff it is NOT marked status: resolved; surfaced count == non-resolved count', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(entryArb, { maxLength: 20 }),
|
||||
(rawEntries) => {
|
||||
// Index-prefix for uniqueness so surfaced items can be mapped back
|
||||
// to their source entry unambiguously even with colliding random text.
|
||||
const entries = rawEntries.map((e, i) => ({ text: `E${i}_${e.text}`, resolved: e.resolved }));
|
||||
|
||||
const lines = ['## Deferred Items', ''];
|
||||
for (const e of entries) {
|
||||
lines.push(`- ${e.text}`);
|
||||
if (e.resolved) lines.push(' status: resolved');
|
||||
}
|
||||
const content = lines.join('\n');
|
||||
|
||||
const items = parseDeferredItems(content);
|
||||
const surfacedNames = new Set(items.map((it) => it.name));
|
||||
|
||||
const expectedUnresolved = entries.filter((e) => !e.resolved);
|
||||
const expectedResolved = entries.filter((e) => e.resolved);
|
||||
|
||||
// Total surfaced count equals the count of non-resolved entries.
|
||||
assert.strictEqual(items.length, expectedUnresolved.length);
|
||||
|
||||
// Every non-resolved entry IS surfaced (including status:-shaped
|
||||
// decoy substrings embedded mid-line — those must not flip the
|
||||
// outcome).
|
||||
for (const e of expectedUnresolved) {
|
||||
assert.ok(surfacedNames.has(e.text), `expected unresolved entry to surface: ${e.text}`);
|
||||
}
|
||||
|
||||
// No status:-resolved entry is EVER surfaced.
|
||||
for (const e of expectedResolved) {
|
||||
assert.ok(!surfacedNames.has(e.text), `status: resolved entry must never surface: ${e.text}`);
|
||||
}
|
||||
|
||||
// Every returned item carries the fixed deferred category/result shape.
|
||||
for (const item of items) {
|
||||
assert.strictEqual(item.result, 'unresolved');
|
||||
assert.strictEqual(item.category, 'deferred');
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #2766: archived phase dirs, and GFM-table-shaped deferred/gaps ────────
|
||||
|
||||
const UAT_ONE_PENDING = [
|
||||
'---',
|
||||
'status: partial',
|
||||
'phase: 01-foundation',
|
||||
'---',
|
||||
'',
|
||||
'## Current Test',
|
||||
'',
|
||||
'[awaiting human testing]',
|
||||
'',
|
||||
'## Tests',
|
||||
'',
|
||||
'### 1. A scenario nobody ever ran',
|
||||
'expected: something observable happens',
|
||||
'result: [pending]',
|
||||
'',
|
||||
'## Summary',
|
||||
'',
|
||||
'total: 1',
|
||||
'pending: 1',
|
||||
'',
|
||||
'## Gaps',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
/** Write a UAT file whose `## Gaps` section holds `gapsBody`. */
|
||||
function uatWithGaps(gapsBody) {
|
||||
return [
|
||||
'---',
|
||||
'status: complete',
|
||||
'phase: 50-gaps',
|
||||
'---',
|
||||
'',
|
||||
'## Current Test',
|
||||
'',
|
||||
'[testing complete]',
|
||||
'',
|
||||
'## Tests',
|
||||
'',
|
||||
'### 1. A passing scenario',
|
||||
'expected: this one is fine',
|
||||
'result: pass',
|
||||
'',
|
||||
'## Summary',
|
||||
'',
|
||||
'total: 1',
|
||||
'passed: 1',
|
||||
'',
|
||||
'## Gaps',
|
||||
'',
|
||||
gapsBody,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── Bug 1: archived phase dirs ───────────────────────────────────────────────
|
||||
|
||||
describe('#2766 cmdAuditUat: archived phase directories', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('phases ONLY in the archive → items surfaced, not a hard error', () => {
|
||||
const archiveDir = path.join(
|
||||
tmpDir, '.planning', 'milestones', 'v1.0-phases', '01-foundation',
|
||||
);
|
||||
fs.mkdirSync(archiveDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(archiveDir, '01-UAT.md'), UAT_ONE_PENDING);
|
||||
|
||||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.summary.total_items, 1);
|
||||
assert.strictEqual(output.results.length, 1);
|
||||
assert.strictEqual(output.results[0].phase, '01');
|
||||
assert.strictEqual(output.results[0].archived_milestone, 'v1.0');
|
||||
assert.match(output.results[0].file_path, /milestones\/v1\.0-phases\//);
|
||||
});
|
||||
|
||||
test('active and archived trees are both scanned', () => {
|
||||
const activeDir = path.join(tmpDir, '.planning', 'phases', '40-current');
|
||||
fs.mkdirSync(activeDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(activeDir, '40-UAT.md'), UAT_ONE_PENDING);
|
||||
|
||||
const archiveDir = path.join(
|
||||
tmpDir, '.planning', 'milestones', 'v1.0-phases', '01-foundation',
|
||||
);
|
||||
fs.mkdirSync(archiveDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(archiveDir, '01-UAT.md'), UAT_ONE_PENDING);
|
||||
|
||||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
const byPhase = new Map(output.results.map(r => [r.phase, r]));
|
||||
assert.ok(byPhase.has('01'), `archived phase missing: ${JSON.stringify([...byPhase.keys()])}`);
|
||||
assert.ok(byPhase.has('40'), `active phase missing: ${JSON.stringify([...byPhase.keys()])}`);
|
||||
assert.strictEqual(byPhase.get('01').archived_milestone, 'v1.0');
|
||||
assert.strictEqual(byPhase.get('40').archived_milestone, undefined);
|
||||
});
|
||||
|
||||
test('multiple archived milestones are all scanned', () => {
|
||||
for (const [version, phase] of [['v1.0', '01-foundation'], ['v2.0', '07-later']]) {
|
||||
const dir = path.join(tmpDir, '.planning', 'milestones', `${version}-phases`, phase);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, `${phase.slice(0, 2)}-UAT.md`), UAT_ONE_PENDING);
|
||||
}
|
||||
|
||||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.summary.total_items, 2);
|
||||
assert.deepStrictEqual(
|
||||
output.results.map(r => r.archived_milestone).sort(),
|
||||
['v1.0', 'v2.0'],
|
||||
);
|
||||
});
|
||||
|
||||
test('an empty active phases dir still succeeds with no items (pre-existing behavior)', () => {
|
||||
// createTempProject() ships an empty `.planning/phases/`, so this is the
|
||||
// shape the existing uat.test.cjs "no UAT files" case covers — the archive
|
||||
// change must not turn it into an error.
|
||||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.deepStrictEqual(output.results, []);
|
||||
assert.strictEqual(output.summary.total_items, 0);
|
||||
});
|
||||
|
||||
test('no phases dir AND no archive still errors — no false all-clear', (t) => {
|
||||
// A bare temp dir with a .planning/ that has NO phases subdir and no
|
||||
// milestones archive — built from createTempDir rather than by deleting
|
||||
// createTempProject's phases dir, so nothing is torn down mid-test.
|
||||
const bare = createTempDir();
|
||||
t.after(() => cleanup(bare));
|
||||
|
||||
fs.mkdirSync(path.join(bare, '.planning'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('audit-uat --raw', bare);
|
||||
assert.strictEqual(result.success, false, 'expected a failure when no phases exist at all');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Bug 2: table-shaped deferred-items.md ────────────────────────────────────
|
||||
|
||||
describe('#2766 parseDeferredItems: GFM table shape', () => {
|
||||
const names = (md) => parseDeferredItems(md).map(i => i.name);
|
||||
|
||||
test('header + delimiter → header dropped, data rows surfaced', () => {
|
||||
assert.deepStrictEqual(
|
||||
names([
|
||||
'## Discovered during 01-03',
|
||||
'',
|
||||
'| Test | Failing seeds |',
|
||||
'|------|---------------|',
|
||||
'| test_a | 0, 1 |',
|
||||
'| test_b | 424242 |',
|
||||
].join('\n')),
|
||||
['test_a — 0, 1', 'test_b — 424242'],
|
||||
);
|
||||
});
|
||||
|
||||
test('later columns are preserved, not truncated to the first cell', () => {
|
||||
const [name] = names('| T | seeds |\n|---|---|\n| test_a | 0, 1, 424242 |');
|
||||
assert.match(name, /0, 1, 424242/);
|
||||
});
|
||||
|
||||
test('headerless table → every row surfaced', () => {
|
||||
assert.deepStrictEqual(
|
||||
names('| test_a | 0 |\n| test_b | 1 |'),
|
||||
['test_a — 0', 'test_b — 1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('row marked resolved/done/pass is suppressed', () => {
|
||||
assert.deepStrictEqual(
|
||||
names([
|
||||
'| Test | Seeds | Status |',
|
||||
'|---|---|---|',
|
||||
'| test_open | 0 | open |',
|
||||
'| test_fixed | 1 | resolved |',
|
||||
'| test_done | 2 | DONE |',
|
||||
].join('\n')),
|
||||
['test_open — 0 — open'],
|
||||
);
|
||||
});
|
||||
|
||||
test('two prose-separated tables → each drops its own header', () => {
|
||||
assert.deepStrictEqual(
|
||||
names([
|
||||
'| T1 | x |', '|---|---|', '| one | 1 |',
|
||||
'',
|
||||
'some prose in between',
|
||||
'',
|
||||
'| T2 | y |', '|---|---|', '| two | 2 |',
|
||||
].join('\n')),
|
||||
['one — 1', 'two — 2'],
|
||||
);
|
||||
});
|
||||
|
||||
test('bullets and a table in one file → union, no double-counting', () => {
|
||||
const got = names([
|
||||
'## Deferred Items',
|
||||
'',
|
||||
'- a bullet-shaped deferred entry',
|
||||
'',
|
||||
'| Test | Seeds |',
|
||||
'|---|---|',
|
||||
'| test_a | 0 |',
|
||||
].join('\n'));
|
||||
assert.strictEqual(got.length, 2, JSON.stringify(got));
|
||||
assert.ok(got.some(n => n.includes('bullet-shaped')));
|
||||
assert.ok(got.some(n => n.startsWith('test_a')));
|
||||
});
|
||||
|
||||
test('bullet-only file unchanged (no regression on #2287)', () => {
|
||||
assert.deepStrictEqual(
|
||||
names('## Deferred Items\n\n- entry one\n- entry two\n'),
|
||||
['entry one', 'entry two'],
|
||||
);
|
||||
});
|
||||
|
||||
test('explicit status: resolved bullet still suppressed (no regression on #2287)', () => {
|
||||
const got = names(
|
||||
'## Deferred Items\n\n- truth: "closed thing"\n status: resolved\n- truth: "open thing"\n',
|
||||
);
|
||||
assert.strictEqual(got.length, 1, JSON.stringify(got));
|
||||
assert.match(got[0], /open thing/);
|
||||
});
|
||||
|
||||
test('no table and no bullets → zero items, no throw', () => {
|
||||
assert.deepStrictEqual(names('# Notes\n\njust prose, nothing actionable.\n'), []);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Bug 3: table-shaped ## Gaps section ──────────────────────────────────────
|
||||
|
||||
describe('#2766 parseGapsItems: GFM table shape', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
/** Run audit-uat over a phase whose UAT file has `gapsBody` as its Gaps section. */
|
||||
function gapsItems(gapsBody) {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '50-gaps');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '50-UAT.md'), uatWithGaps(gapsBody));
|
||||
|
||||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
const uat = output.results.find(r => r.type === 'uat');
|
||||
return uat ? uat.items : [];
|
||||
}
|
||||
|
||||
test('header-mapped table → truth/status/reason/test extracted', () => {
|
||||
const items = gapsItems([
|
||||
'| Truth | Status | Reason | Test |',
|
||||
'|-------|--------|--------|------|',
|
||||
'| Login should redirect | failed | User reported a 500 | 1 |',
|
||||
].join('\n'));
|
||||
|
||||
assert.strictEqual(items.length, 1, JSON.stringify(items));
|
||||
assert.strictEqual(items[0].name, 'Login should redirect');
|
||||
assert.strictEqual(items[0].result, 'failed');
|
||||
assert.strictEqual(items[0].reason, 'User reported a 500');
|
||||
assert.strictEqual(items[0].test, 1);
|
||||
});
|
||||
|
||||
test('status: resolved row suppressed, open row kept', () => {
|
||||
const items = gapsItems([
|
||||
'| Truth | Status |',
|
||||
'|-------|--------|',
|
||||
'| closed thing | resolved |',
|
||||
'| open thing | failed |',
|
||||
].join('\n'));
|
||||
|
||||
assert.strictEqual(items.length, 1, JSON.stringify(items.map(i => i.name)));
|
||||
assert.strictEqual(items[0].name, 'open thing');
|
||||
});
|
||||
|
||||
test('no status column → surfaced as unknown, not dropped', () => {
|
||||
const items = gapsItems('| Truth | Note |\n|---|---|\n| something is off | see logs |');
|
||||
|
||||
assert.strictEqual(items.length, 1, JSON.stringify(items));
|
||||
assert.strictEqual(items[0].result, 'unknown');
|
||||
assert.strictEqual(items[0].name, 'something is off');
|
||||
});
|
||||
|
||||
test('unrecognizable header → joined cells + unknown status', () => {
|
||||
const items = gapsItems('| Alpha | Beta |\n|---|---|\n| xxx | yyy |');
|
||||
|
||||
assert.strictEqual(items.length, 1, JSON.stringify(items));
|
||||
assert.strictEqual(items[0].result, 'unknown');
|
||||
assert.match(items[0].name, /xxx/);
|
||||
assert.match(items[0].name, /yyy/);
|
||||
});
|
||||
|
||||
test('headerless table → explicit resolved cell still suppressed', () => {
|
||||
const items = gapsItems('| open thing | failed |\n| closed thing | resolved |');
|
||||
|
||||
assert.strictEqual(items.length, 1, JSON.stringify(items.map(i => i.name)));
|
||||
assert.match(items[0].name, /open thing/);
|
||||
});
|
||||
|
||||
test('bullets and a table in one Gaps section → union, no double-counting', () => {
|
||||
const items = gapsItems([
|
||||
'- truth: "a bullet gap"',
|
||||
' status: failed',
|
||||
'',
|
||||
'| Truth | Status |',
|
||||
'|---|---|',
|
||||
'| a table gap | failed |',
|
||||
].join('\n'));
|
||||
|
||||
assert.strictEqual(items.length, 2, JSON.stringify(items.map(i => i.name)));
|
||||
assert.ok(items.some(i => i.name === 'a bullet gap'));
|
||||
assert.ok(items.some(i => i.name === 'a table gap'));
|
||||
});
|
||||
|
||||
test('bullet-only Gaps unchanged (no regression on #2286)', () => {
|
||||
const items = gapsItems('- truth: "only a bullet"\n status: failed\n reason: "because"\n');
|
||||
|
||||
assert.strictEqual(items.length, 1, JSON.stringify(items));
|
||||
assert.strictEqual(items[0].name, 'only a bullet');
|
||||
assert.strictEqual(items[0].reason, 'because');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user