* feat(#2646): surface unresolved deferred-items.md at milestone close auditOpenArtifacts scanned eight categories; deferred-items.md was not among them. #2287 made that file readable at the PHASE boundary (audit-uat, /gsd-progress check 7), but one boundary up it stayed invisible — and phase directories archive to milestones/vX.Y-phases/ by default (#1871), so an out-of-scope discovery a phase agent correctly recorded rather than fixed left the live tree at milestone close having never reached the [R]/[A]/[C] prompt that exists to catch exactly this. Adds deferred_items as a ninth scanner plus its count, its items entry and its report section. The workflow needed no change: complete-milestone branches on "any section with count > 0", so the new category flows through the existing prompt. The resolved/unresolved predicate is NOT reimplemented. uat.cjs already exports parseDeferredItems, which owns the parsing rule (entries under a `## Deferred Items` level-2 heading, else the whole file fail-safe; RESOLVED only on an explicit case-insensitive `status: resolved` field). The scanner requires it lazily, inside the scan, so audit-command-router's property that a route never loads the module it does not need is preserved. Two readers of one file sharing one predicate is the point — duplicating the inequality is how they drift into disagreeing about what "open" means. Regression test proves fail-first: 9 of its 10 cases go red against the pre-change tree. The tenth is the deliberate no-regression boundary (a clean tree emits no section) and is green both ways. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015TQU48ETJjEmGLJjA6hdQ4 * docs(#2646): document the pre-close artifact audit and its nine categories The /gsd-complete-milestone entry did not mention the audit at all, so the gate that can stop a close was undocumented — and this change adds a category to it. Tabulates all nine with their source artifact and what makes each one "open", plus the [R]/[A]/[C] outcomes. Also disambiguates the one genuinely confusing thing: the per-phase deferred-items.md scanned here is NOT the `## Deferred Items` section the [A] path writes into STATE.md. Same name, different artifact, opposite ends of the flow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015TQU48ETJjEmGLJjA6hdQ4 * chore(#2646): backfill changeset pr number Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015TQU48ETJjEmGLJjA6hdQ4 --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
1098 lines
45 KiB
JavaScript
1098 lines
45 KiB
JavaScript
'use strict';
|
|
/**
|
|
* audit-command-cutover.test.cjs — ADR-959 phase 4d-impl-3 equivalence tests.
|
|
*
|
|
* Verifies that `audit-uat` and `audit-open`, after cutover from the hardcoded
|
|
* `case 'audit-uat':` and `case 'audit-open':` arms in gsd-tools.cjs to the
|
|
* capability registry dispatch path (default → dispatchCapabilityCommand →
|
|
* audit-command-router.cjs → routeAuditUat | routeAuditOpen), behave
|
|
* identically to the old inline cases.
|
|
*
|
|
* Test categories:
|
|
* 1. UNIT (recording mock) — precise arg/call equivalence for each router
|
|
* 2. DISPATCH — commands reach routers via default-case registry dispatch
|
|
* 3. BEHAVIOR — subprocess tests with real output-shape assertions
|
|
* 4. JSON-ERRORS — structured {ok:false,reason,message} for error paths
|
|
* 5. REGISTRY — commandFamilies entries, audit capability in registry
|
|
*/
|
|
|
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
|
|
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
|
const { routeAuditUat, routeAuditOpen } = require('../gsd-core/bin/lib/audit-command-router.cjs');
|
|
|
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
function makeErrorRecorder() {
|
|
const calls = [];
|
|
const fn = (msg, reason) => calls.push({ msg, reason });
|
|
fn.calls = calls;
|
|
return fn;
|
|
}
|
|
|
|
// ─── 1. UNIT — recording mocks (precise routing equivalence) ─────────────────
|
|
|
|
describe('audit routers: unit tests via recording mocks', () => {
|
|
const CWD = '/fake/cwd';
|
|
const RAW = false;
|
|
|
|
// ── routeAuditUat ──────────────────────────────────────────────────────────
|
|
|
|
test('routeAuditUat: calls _uat.cmdAuditUat(cwd, raw) exactly once', () => {
|
|
const uatCalls = [];
|
|
const mockUat = {
|
|
cmdAuditUat: (cwd, raw) => uatCalls.push({ cwd, raw }),
|
|
};
|
|
const errFn = makeErrorRecorder();
|
|
|
|
routeAuditUat({
|
|
args: ['audit-uat'],
|
|
cwd: CWD, raw: RAW, error: errFn,
|
|
_uat: mockUat,
|
|
});
|
|
|
|
assert.strictEqual(errFn.calls.length, 0, 'error must not be called');
|
|
assert.strictEqual(uatCalls.length, 1, 'cmdAuditUat must be called exactly once');
|
|
assert.strictEqual(uatCalls[0].cwd, CWD, 'cwd passed through correctly');
|
|
assert.strictEqual(uatCalls[0].raw, RAW, 'raw passed through correctly');
|
|
});
|
|
|
|
test('routeAuditUat: raw=true is forwarded correctly', () => {
|
|
const uatCalls = [];
|
|
const mockUat = {
|
|
cmdAuditUat: (cwd, raw) => uatCalls.push({ cwd, raw }),
|
|
};
|
|
routeAuditUat({
|
|
args: ['audit-uat'],
|
|
cwd: CWD, raw: true, error: makeErrorRecorder(),
|
|
_uat: mockUat,
|
|
});
|
|
assert.strictEqual(uatCalls[0].raw, true, 'raw=true must be forwarded');
|
|
});
|
|
|
|
// ── routeAuditOpen ─────────────────────────────────────────────────────────
|
|
|
|
test('routeAuditOpen (no --json): calls auditOpenArtifacts, formatAuditReport; output(null, true, report)', () => {
|
|
const auditCalls = [];
|
|
const coreCalls = [];
|
|
const FAKE_RESULT = { fake: true };
|
|
const FAKE_REPORT = 'REPORT TEXT';
|
|
const mockAudit = {
|
|
auditOpenArtifacts: (cwd) => { auditCalls.push({ fn: 'auditOpenArtifacts', cwd }); return FAKE_RESULT; },
|
|
formatAuditReport: (res) => { auditCalls.push({ fn: 'formatAuditReport', res }); return FAKE_REPORT; },
|
|
};
|
|
// Inject a recording _core stub so no bytes reach the real process stdout.
|
|
const mockCore = {
|
|
output: (...callArgs) => coreCalls.push(callArgs),
|
|
};
|
|
routeAuditOpen({
|
|
args: ['audit-open'],
|
|
cwd: CWD, raw: RAW, error: makeErrorRecorder(),
|
|
_audit: mockAudit,
|
|
_core: mockCore,
|
|
});
|
|
// auditOpenArtifacts called first, then formatAuditReport with its result
|
|
assert.strictEqual(auditCalls.length, 2, 'must call auditOpenArtifacts then formatAuditReport');
|
|
assert.strictEqual(auditCalls[0].fn, 'auditOpenArtifacts', 'first call must be auditOpenArtifacts');
|
|
assert.strictEqual(auditCalls[0].cwd, CWD, 'auditOpenArtifacts cwd must match');
|
|
assert.strictEqual(auditCalls[1].fn, 'formatAuditReport', 'second call must be formatAuditReport');
|
|
assert.strictEqual(auditCalls[1].res, FAKE_RESULT, 'formatAuditReport must receive auditOpenArtifacts result');
|
|
// Assert the exact 3-arg core.output call form for text mode:
|
|
// core.output(null, true, formatAuditReport(result))
|
|
assert.strictEqual(coreCalls.length, 1, 'core.output must be called exactly once');
|
|
assert.strictEqual(coreCalls[0][0], null, 'text mode: first arg to core.output must be null');
|
|
assert.strictEqual(coreCalls[0][1], true, 'text mode: second arg to core.output must be true');
|
|
assert.strictEqual(coreCalls[0][2], FAKE_REPORT, 'text mode: third arg to core.output must be the formatted report');
|
|
});
|
|
|
|
test('routeAuditOpen (--json): calls auditOpenArtifacts but NOT formatAuditReport; output(result, raw)', () => {
|
|
const auditCalls = [];
|
|
const coreCalls = [];
|
|
const FAKE_RESULT = { fake: true };
|
|
const mockAudit = {
|
|
auditOpenArtifacts: (cwd) => { auditCalls.push({ fn: 'auditOpenArtifacts', cwd }); return FAKE_RESULT; },
|
|
formatAuditReport: (res) => { auditCalls.push({ fn: 'formatAuditReport', res }); return 'REPORT'; },
|
|
};
|
|
// Inject a recording _core stub so no bytes reach the real process stdout.
|
|
const mockCore = {
|
|
output: (...callArgs) => coreCalls.push(callArgs),
|
|
};
|
|
routeAuditOpen({
|
|
args: ['audit-open', '--json'],
|
|
cwd: CWD, raw: RAW, error: makeErrorRecorder(),
|
|
_audit: mockAudit,
|
|
_core: mockCore,
|
|
});
|
|
// auditOpenArtifacts called; formatAuditReport must NOT be called for --json
|
|
const fmtCalls = auditCalls.filter(c => c.fn === 'formatAuditReport');
|
|
assert.strictEqual(fmtCalls.length, 0, '--json mode must NOT call formatAuditReport');
|
|
const artifactCalls = auditCalls.filter(c => c.fn === 'auditOpenArtifacts');
|
|
assert.strictEqual(artifactCalls.length, 1, '--json mode must call auditOpenArtifacts once');
|
|
// Assert the exact 2-arg core.output call form for JSON mode:
|
|
// core.output(result, raw)
|
|
assert.strictEqual(coreCalls.length, 1, 'core.output must be called exactly once');
|
|
assert.strictEqual(coreCalls[0][0], FAKE_RESULT, 'json mode: first arg to core.output must be the result object');
|
|
assert.strictEqual(coreCalls[0][1], RAW, 'json mode: second arg to core.output must be raw');
|
|
assert.strictEqual(coreCalls[0].length, 2, 'json mode: core.output must be called with exactly 2 args');
|
|
});
|
|
});
|
|
|
|
// ─── 2. DISPATCH — commands reach routers via default-case ───────────────────
|
|
|
|
describe('audit cutover: dispatch path (default-case → capability registry)', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject('gsd-audit-cutover-');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('audit-uat dispatches via capability registry (no "Unknown command" error)', () => {
|
|
const result = runGsdTools(['audit-uat'], tmpDir);
|
|
// audit-uat with a minimal project may succeed or fail on file-not-found;
|
|
// the key assertion is it never emits "Unknown command: audit-uat"
|
|
const isUnknownCmd = (result.error || '').includes('Unknown command: audit-uat');
|
|
assert.strictEqual(isUnknownCmd, false,
|
|
`Must not emit "Unknown command: audit-uat". stderr: ${result.error}`);
|
|
assert.ok(result.success,
|
|
`audit-uat must exit 0. stderr: ${result.error}`);
|
|
});
|
|
|
|
test('audit-open dispatches via capability registry (no "Unknown command" error)', () => {
|
|
const result = runGsdTools(['audit-open'], tmpDir);
|
|
const isUnknownCmd = (result.error || '').includes('Unknown command: audit-open');
|
|
assert.strictEqual(isUnknownCmd, false,
|
|
`Must not emit "Unknown command: audit-open". stderr: ${result.error}`);
|
|
assert.ok(result.success,
|
|
`audit-open must exit 0. stderr: ${result.error}`);
|
|
});
|
|
|
|
test('audit-open --json dispatches via capability registry', () => {
|
|
const result = runGsdTools(['audit-open', '--json'], tmpDir);
|
|
const isUnknownCmd = (result.error || '').includes('Unknown command: audit-open');
|
|
assert.strictEqual(isUnknownCmd, false,
|
|
`Must not emit "Unknown command: audit-open" with --json. stderr: ${result.error}`);
|
|
// Must also produce valid JSON output
|
|
assert.ok(result.success,
|
|
`audit-open --json must succeed. stderr: ${result.error}`);
|
|
assert.doesNotThrow(
|
|
() => JSON.parse(result.output),
|
|
'audit-open --json must produce valid JSON',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── 3. BEHAVIOR — subprocess output shape (equivalence to old inline cases) ──
|
|
|
|
describe('audit cutover: output shape equivalence', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject('gsd-audit-behavior-');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('audit-open (text) succeeds and produces non-empty output', () => {
|
|
const result = runGsdTools(['audit-open'], tmpDir);
|
|
assert.ok(result.success,
|
|
`audit-open must succeed. stderr: ${result.error}`);
|
|
assert.ok(result.output && result.output.length > 0,
|
|
'audit-open text output must be non-empty');
|
|
// Must be raw text, not JSON-encoded (regression guard from #2911)
|
|
assert.ok(!result.output.startsWith('"'),
|
|
'text mode must not start with a JSON quote');
|
|
assert.ok(!result.output.includes('\\n'),
|
|
'text mode must not contain literal \\n sequences');
|
|
});
|
|
|
|
test('audit-open --json produces valid JSON with expected shape', () => {
|
|
const result = runGsdTools(['audit-open', '--json'], tmpDir);
|
|
assert.ok(result.success,
|
|
`audit-open --json must succeed. stderr: ${result.error}`);
|
|
let parsed;
|
|
assert.doesNotThrow(
|
|
() => { parsed = JSON.parse(result.output); },
|
|
'audit-open --json must emit valid JSON',
|
|
);
|
|
assert.equal(typeof parsed, 'object', 'parsed payload must be an object');
|
|
assert.ok(parsed !== null, 'parsed payload must not be null');
|
|
// Shape contract from auditOpenArtifacts() (regression guard from #2911)
|
|
assert.equal(typeof parsed.scanned_at, 'string', 'must include scanned_at');
|
|
assert.equal(typeof parsed.has_open_items, 'boolean', 'must include has_open_items');
|
|
assert.equal(typeof parsed.counts, 'object', 'must include counts');
|
|
assert.equal(typeof parsed.items, 'object', 'must include items');
|
|
});
|
|
|
|
test('audit-open (text) report title present as standalone line', () => {
|
|
const result = runGsdTools(['audit-open'], tmpDir);
|
|
assert.ok(result.success,
|
|
`audit-open must succeed. stderr: ${result.error}`);
|
|
const lines = result.output.split('\n').map(l => l.trim()).filter(Boolean);
|
|
assert.ok(
|
|
lines.includes('Milestone Close: Open Artifact Audit'),
|
|
`report title must appear as a standalone line; got: ${JSON.stringify(lines.slice(0, 5))}`,
|
|
);
|
|
});
|
|
|
|
test('audit-uat succeeds and produces non-empty stdout', () => {
|
|
const result = runGsdTools(['audit-uat'], tmpDir);
|
|
assert.ok(result.success,
|
|
`audit-uat must succeed. stderr: ${result.error}`);
|
|
assert.ok(result.output && result.output.length > 0,
|
|
'audit-uat must write non-empty output to stdout');
|
|
});
|
|
|
|
test('audit-uat --raw flag passes through (does not break dispatch)', () => {
|
|
const result = runGsdTools(['audit-uat', '--raw'], tmpDir);
|
|
// --raw is a gsd-tools global flag; it modifies output encoding but
|
|
// the command must still succeed and produce output
|
|
assert.ok(result.success,
|
|
`audit-uat --raw must succeed. stderr: ${result.error}`);
|
|
});
|
|
});
|
|
|
|
// ─── 4. JSON-ERRORS — GSD_JSON_ERRORS mode passes through cleanly ────────────
|
|
|
|
describe('audit cutover: GSD_JSON_ERRORS mode (both commands succeed without structured error)', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject('gsd-audit-jsonerr-');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('audit-open --json with GSD_JSON_ERRORS=1 succeeds (no spurious error payload)', () => {
|
|
// Successful commands must not emit JSON error payloads; verify exit 0.
|
|
const result = runGsdTools(['audit-open', '--json'], tmpDir, { GSD_JSON_ERRORS: '1' });
|
|
assert.ok(result.success,
|
|
`audit-open --json must succeed even with GSD_JSON_ERRORS=1; stderr: ${result.error}`);
|
|
});
|
|
|
|
test('audit-open text with GSD_JSON_ERRORS=1 succeeds (no spurious error payload)', () => {
|
|
const result = runGsdTools(['audit-open'], tmpDir, { GSD_JSON_ERRORS: '1' });
|
|
assert.ok(result.success,
|
|
`audit-open text mode must succeed even with GSD_JSON_ERRORS=1; stderr: ${result.error}`);
|
|
});
|
|
|
|
test('audit-uat with GSD_JSON_ERRORS=1 succeeds (no spurious error payload)', () => {
|
|
const result = runGsdTools(['audit-uat'], tmpDir, { GSD_JSON_ERRORS: '1' });
|
|
assert.ok(result.success,
|
|
`audit-uat must succeed even with GSD_JSON_ERRORS=1; stderr: ${result.error}`);
|
|
});
|
|
});
|
|
|
|
// ─── 5. REGISTRY — commandFamilies entries ───────────────────────────────────
|
|
|
|
describe('audit cutover: registry entries correct', () => {
|
|
test('commandFamilies["audit-uat"] present and well-shaped', () => {
|
|
const entry = registry.commandFamilies['audit-uat'];
|
|
assert.ok(entry, 'commandFamilies["audit-uat"] must be present');
|
|
assert.strictEqual(entry.capId, 'audit',
|
|
'commandFamilies["audit-uat"].capId must be "audit"');
|
|
assert.strictEqual(entry.module, 'audit-command-router.cjs',
|
|
'commandFamilies["audit-uat"].module must be "audit-command-router.cjs"');
|
|
assert.strictEqual(entry.router, 'routeAuditUat',
|
|
'commandFamilies["audit-uat"].router must be "routeAuditUat"');
|
|
});
|
|
|
|
test('commandFamilies["audit-open"] present and well-shaped', () => {
|
|
const entry = registry.commandFamilies['audit-open'];
|
|
assert.ok(entry, 'commandFamilies["audit-open"] must be present');
|
|
assert.strictEqual(entry.capId, 'audit',
|
|
'commandFamilies["audit-open"].capId must be "audit"');
|
|
assert.strictEqual(entry.module, 'audit-command-router.cjs',
|
|
'commandFamilies["audit-open"].module must be "audit-command-router.cjs"');
|
|
assert.strictEqual(entry.router, 'routeAuditOpen',
|
|
'commandFamilies["audit-open"].router must be "routeAuditOpen"');
|
|
});
|
|
|
|
test('capabilities.audit present with role:feature and tier:full', () => {
|
|
const cap = registry.capabilities.audit;
|
|
assert.ok(cap, 'capabilities.audit must be present');
|
|
assert.strictEqual(cap.role, 'feature', 'audit capability must have role: feature');
|
|
assert.strictEqual(cap.tier, 'full', 'audit capability must have tier: full');
|
|
});
|
|
|
|
test('capabilities.audit.commands has both audit-uat and audit-open entries', () => {
|
|
const cap = registry.capabilities.audit;
|
|
assert.ok(Array.isArray(cap.commands) && cap.commands.length === 2,
|
|
'audit capability must have exactly 2 commands');
|
|
|
|
const uatCmd = cap.commands.find(c => c.family === 'audit-uat');
|
|
assert.ok(uatCmd, 'commands must include audit-uat family');
|
|
assert.strictEqual(uatCmd.module, 'audit-command-router.cjs');
|
|
assert.strictEqual(uatCmd.router, 'routeAuditUat');
|
|
|
|
const openCmd = cap.commands.find(c => c.family === 'audit-open');
|
|
assert.ok(openCmd, 'commands must include audit-open family');
|
|
assert.strictEqual(openCmd.module, 'audit-command-router.cjs');
|
|
assert.strictEqual(openCmd.router, 'routeAuditOpen');
|
|
});
|
|
|
|
test('routeAuditUat and routeAuditOpen are exported functions', () => {
|
|
assert.strictEqual(typeof routeAuditUat, 'function',
|
|
'routeAuditUat must be an exported function');
|
|
assert.strictEqual(typeof routeAuditOpen, 'function',
|
|
'routeAuditOpen must be an exported function');
|
|
});
|
|
|
|
test('profileMembership.audit is vacuous (no skills → no skill-cluster entry)', () => {
|
|
// audit declares skills:[] → no skill-cluster-based profileMembership entry.
|
|
// This is correct: profileMembership tracks skill ownership, not capability existence.
|
|
const pm = registry.profileMembership.audit;
|
|
assert.strictEqual(pm, undefined,
|
|
'profileMembership.audit must be undefined (no skills declared)');
|
|
});
|
|
|
|
test('capabilityClusters.audit is vacuous (no skills → no cluster entry)', () => {
|
|
// Same as profileMembership — skill-less capabilities produce no cluster entries.
|
|
const clusters = registry.capabilityClusters.audit;
|
|
assert.strictEqual(clusters, undefined,
|
|
'capabilityClusters.audit must be undefined (no skills declared)');
|
|
});
|
|
|
|
test('audit has no skills — vacuous install/surface (no skill-index entries)', () => {
|
|
// audit capability declares no skills, so bySkill has no "audit" entry
|
|
// (there is no skill named "audit")
|
|
const cap = registry.capabilities.audit;
|
|
assert.deepStrictEqual(cap.skills, [],
|
|
'audit capability must have empty skills array');
|
|
});
|
|
|
|
test('graphify commandFamilies entry still present (no regression)', () => {
|
|
const entry = registry.commandFamilies['graphify'];
|
|
assert.ok(entry, 'commandFamilies["graphify"] must still be present');
|
|
assert.strictEqual(entry.capId, 'graphify');
|
|
assert.strictEqual(entry.module, 'graphify-command-router.cjs');
|
|
assert.strictEqual(entry.router, 'routeGraphifyCommand');
|
|
});
|
|
});
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-2659-audit-open-crash.test.cjs — consolidation epic #1969 (B2 #1971)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-2659-audit-open-crash (consolidation epic #1969 B2 #1971)", () => {
|
|
'use strict';
|
|
|
|
/**
|
|
* Regression test for #2659.
|
|
*
|
|
* The `audit-open` dispatch case in bin/gsd-tools.cjs previously called bare
|
|
* `output(...)` on both the --json and text branches. `output` is never in
|
|
* local scope — the entire core module is imported as `const core`, so every
|
|
* other case uses `core.output(...)`. The bare calls therefore crashed with
|
|
* `ReferenceError: output is not defined` the moment `audit-open` ran.
|
|
*
|
|
* This test runs both invocations against a minimal temp project and asserts
|
|
* they exit successfully with non-empty stdout. It fails with the
|
|
* ReferenceError on any revision that still has the bare `output(...)` calls.
|
|
*/
|
|
|
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
|
|
describe('audit-open — does not crash with ReferenceError (#2659)', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject('gsd-bug-2659-');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('audit-open (text output) succeeds and produces stdout', () => {
|
|
const result = runGsdTools('audit-open', tmpDir);
|
|
assert.ok(
|
|
result.success,
|
|
`audit-open must not crash. stderr: ${result.error}`
|
|
);
|
|
assert.ok(
|
|
!/ReferenceError.*output is not defined/.test(result.error || ''),
|
|
`audit-open must not throw ReferenceError. stderr: ${result.error}`
|
|
);
|
|
assert.ok(
|
|
result.output && result.output.length > 0,
|
|
'audit-open must write a non-empty report to stdout'
|
|
);
|
|
});
|
|
|
|
test('audit-open --json succeeds and produces stdout', () => {
|
|
const result = runGsdTools(['audit-open', '--json'], tmpDir);
|
|
assert.ok(
|
|
result.success,
|
|
`audit-open --json must not crash. stderr: ${result.error}`
|
|
);
|
|
assert.ok(
|
|
!/ReferenceError.*output is not defined/.test(result.error || ''),
|
|
`audit-open --json must not throw ReferenceError. stderr: ${result.error}`
|
|
);
|
|
assert.ok(
|
|
result.output && result.output.length > 0,
|
|
'audit-open --json must write output to stdout'
|
|
);
|
|
let parsed;
|
|
assert.doesNotThrow(
|
|
() => { parsed = JSON.parse(result.output); },
|
|
'audit-open --json must emit valid JSON'
|
|
);
|
|
assert.ok(
|
|
parsed !== null && typeof parsed === 'object',
|
|
'audit-open --json must emit a JSON object or array'
|
|
);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-2911-audit-open-output-shape.test.cjs — consolidation epic #1969 (B2 #1971)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-2911-audit-open-output-shape (consolidation epic #1969 B2 #1971)", () => {
|
|
'use strict';
|
|
|
|
/**
|
|
* Regression test for #2911.
|
|
*
|
|
* Two bugs in the `audit-open` dispatch case in bin/gsd-tools.cjs:
|
|
*
|
|
* 1. Bare `output(...)` calls (only `core.output` is in scope) → ReferenceError.
|
|
* 2. Even after switching to `core.output(formatted, raw)`, the human-readable
|
|
* branch JSON-stringifies the formatted string because `core.output` only
|
|
* bypasses JSON encoding when called as `core.output(null, true, rawValue)`.
|
|
* Result: stdout contains `"━━━…\n Milestone Close: …\n…"` (a JSON string
|
|
* literal) instead of the rendered report.
|
|
*
|
|
* The shape assertions below catch both regressions structurally — never via
|
|
* substring matching on serialized output:
|
|
*
|
|
* - text mode: parse stdout as a sequence of lines and assert the expected
|
|
* section headers exist as standalone lines (i.e. raw text, not escaped).
|
|
* If the report is JSON-stringified, the stdout is a single line wrapped
|
|
* in double quotes with `\n` escapes — line-array assertions fail.
|
|
* - --json mode: JSON.parse the stdout and assert the keys returned by
|
|
* `auditOpenArtifacts(cwd)` (scanned_at, has_open_items, counts, items)
|
|
* are present and well-typed.
|
|
*/
|
|
|
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
|
|
describe('audit-open — output shape (#2911)', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject('gsd-bug-2911-');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('text mode emits the formatted report as raw text (not JSON-encoded)', () => {
|
|
const result = runGsdTools('audit-open', tmpDir);
|
|
assert.ok(
|
|
result.success,
|
|
`audit-open must not crash. stderr: ${result.error}`
|
|
);
|
|
|
|
const lines = result.output.split('\n').map(l => l.trim()).filter(Boolean);
|
|
|
|
// The first non-empty line must be the divider character row, *not* a
|
|
// JSON-encoded string starting with a quote. If core.output JSON-stringified
|
|
// the formatted report, the entire payload sits on one line wrapped in
|
|
// double quotes ("━━━…\n…").
|
|
assert.ok(
|
|
!result.output.startsWith('"'),
|
|
'text-mode stdout must not begin with a JSON quote (would mean the report was JSON.stringified)'
|
|
);
|
|
assert.ok(
|
|
!result.output.includes('\\n'),
|
|
'text-mode stdout must not contain literal "\\n" sequences (would mean the report was JSON.stringified)'
|
|
);
|
|
|
|
// Section headers from formatAuditReport that must appear as standalone lines.
|
|
assert.ok(
|
|
lines.includes('Milestone Close: Open Artifact Audit'),
|
|
`expected report title as a standalone line; got lines: ${JSON.stringify(lines.slice(0, 5))}`
|
|
);
|
|
assert.ok(
|
|
lines.includes('All artifact types clear. Safe to proceed.'),
|
|
`expected the empty-state line as standalone text; got lines: ${JSON.stringify(lines)}`
|
|
);
|
|
});
|
|
|
|
test('--json mode emits parseable JSON matching auditOpenArtifacts shape', () => {
|
|
const result = runGsdTools(['audit-open', '--json'], tmpDir);
|
|
assert.ok(
|
|
result.success,
|
|
`audit-open --json must not crash. stderr: ${result.error}`
|
|
);
|
|
|
|
let parsed;
|
|
assert.doesNotThrow(
|
|
() => { parsed = JSON.parse(result.output); },
|
|
'audit-open --json must emit valid JSON (not a doubly-stringified string)'
|
|
);
|
|
|
|
assert.equal(typeof parsed, 'object', 'parsed payload must be an object');
|
|
assert.ok(parsed !== null, 'parsed payload must not be null');
|
|
|
|
// Shape contract from auditOpenArtifacts() in gsd-core/bin/lib/audit.cjs.
|
|
assert.equal(typeof parsed.scanned_at, 'string', 'must include scanned_at ISO timestamp');
|
|
assert.equal(typeof parsed.has_open_items, 'boolean', 'must include has_open_items boolean');
|
|
assert.equal(typeof parsed.counts, 'object', 'must include counts object');
|
|
assert.equal(typeof parsed.items, 'object', 'must include items object');
|
|
|
|
const expectedCountKeys = [
|
|
'debug_sessions', 'quick_tasks', 'threads', 'todos',
|
|
'seeds', 'uat_gaps', 'verification_gaps', 'context_questions',
|
|
'deferred_items', 'total',
|
|
];
|
|
for (const key of expectedCountKeys) {
|
|
assert.equal(
|
|
typeof parsed.counts[key], 'number',
|
|
`counts.${key} must be a number`
|
|
);
|
|
}
|
|
|
|
const expectedItemKeys = [
|
|
'debug_sessions', 'quick_tasks', 'threads', 'todos',
|
|
'seeds', 'uat_gaps', 'verification_gaps', 'context_questions',
|
|
'deferred_items',
|
|
];
|
|
for (const key of expectedItemKeys) {
|
|
assert.ok(
|
|
Array.isArray(parsed.items[key]),
|
|
`items.${key} must be an array`
|
|
);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-2836-audit-open-summary-uat-drift.test.cjs — consolidation epic #1969 (B3 #1972)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-2836-audit-open-summary-uat-drift (consolidation epic #1969 B3 #1972)", () => {
|
|
/**
|
|
* Regression tests for bug #2836
|
|
*
|
|
* audit-open had two convention drifts vs the documented workflows:
|
|
* 1. quick-task scanner looked for bare `SUMMARY.md`, but workflows/quick.md
|
|
* mandates `${quick_id}-SUMMARY.md`. Result: every documented quick task
|
|
* reported as `status: missing`.
|
|
* 2. UAT terminal-status enum only accepted `complete`, but
|
|
* workflows/execute-phase.md uses `resolved` post-gap-closure.
|
|
* Result: gap-closed UATs reported as open.
|
|
*
|
|
* Tests structurally invoke auditOpenArtifacts() against real fixtures on disk
|
|
* and assert the returned items array — never regex on raw file content.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { describe, test, before, after } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
|
|
const auditModule = require('../gsd-core/bin/lib/audit.cjs');
|
|
const { auditOpenArtifacts } = auditModule;
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
function mkTmp() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'bug-2836-'));
|
|
}
|
|
|
|
describe('bug #2836: audit-open quick-task summary filename + UAT terminal status', () => {
|
|
// Ensure GSD env vars do not redirect planningDir() away from our fixture.
|
|
let prevProject, prevWorkstream;
|
|
before(() => {
|
|
prevProject = process.env.GSD_PROJECT;
|
|
prevWorkstream = process.env.GSD_WORKSTREAM;
|
|
delete process.env.GSD_PROJECT;
|
|
delete process.env.GSD_WORKSTREAM;
|
|
});
|
|
after(() => {
|
|
if (prevProject !== undefined) process.env.GSD_PROJECT = prevProject;
|
|
if (prevWorkstream !== undefined) process.env.GSD_WORKSTREAM = prevWorkstream;
|
|
});
|
|
|
|
test('quick task with ${quick_id}-SUMMARY.md is recognized as complete (not missing)', () => {
|
|
const cwd = mkTmp();
|
|
try {
|
|
const quickId = '260429-test-foo';
|
|
const taskDir = path.join(cwd, '.planning', 'quick', quickId);
|
|
fs.mkdirSync(taskDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(taskDir, `${quickId}-SUMMARY.md`),
|
|
'---\nstatus: complete\n---\ntest summary\n',
|
|
'utf-8'
|
|
);
|
|
|
|
const result = auditOpenArtifacts(cwd);
|
|
const realQuickTasks = result.items.quick_tasks.filter(
|
|
i => !i.scan_error && !i._remainder_count
|
|
);
|
|
|
|
assert.equal(
|
|
realQuickTasks.length, 0,
|
|
`quick task with ${quickId}-SUMMARY.md (status: complete) must not appear ` +
|
|
`as an open item; got: ${JSON.stringify(realQuickTasks)}`
|
|
);
|
|
assert.equal(result.counts.quick_tasks, 0);
|
|
} finally {
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
test('UAT with status: resolved is treated as terminal (not an open gap)', () => {
|
|
const cwd = mkTmp();
|
|
try {
|
|
const phaseDir = path.join(cwd, '.planning', 'phases', '01-test');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '01-UAT.md'),
|
|
'---\nstatus: resolved\n---\nUAT body — gap closed via execute-phase flow.\n',
|
|
'utf-8'
|
|
);
|
|
|
|
const result = auditOpenArtifacts(cwd);
|
|
const realUatGaps = result.items.uat_gaps.filter(i => !i.scan_error);
|
|
|
|
assert.equal(
|
|
realUatGaps.length, 0,
|
|
`UAT with status: resolved must not appear as an open gap; ` +
|
|
`got: ${JSON.stringify(realUatGaps)}`
|
|
);
|
|
assert.equal(result.counts.uat_gaps, 0);
|
|
} finally {
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
test('UAT with status: complete remains terminal (no regression)', () => {
|
|
const cwd = mkTmp();
|
|
try {
|
|
const phaseDir = path.join(cwd, '.planning', 'phases', '02-test');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '02-UAT.md'),
|
|
'---\nstatus: complete\n---\nUAT body.\n',
|
|
'utf-8'
|
|
);
|
|
|
|
const result = auditOpenArtifacts(cwd);
|
|
const realUatGaps = result.items.uat_gaps.filter(i => !i.scan_error);
|
|
assert.equal(realUatGaps.length, 0);
|
|
} finally {
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
test('UAT with status: pending is still flagged as an open gap', () => {
|
|
const cwd = mkTmp();
|
|
try {
|
|
const phaseDir = path.join(cwd, '.planning', 'phases', '03-test');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '03-UAT.md'),
|
|
'---\nstatus: pending\n---\nresult: pending\n',
|
|
'utf-8'
|
|
);
|
|
|
|
const result = auditOpenArtifacts(cwd);
|
|
const realUatGaps = result.items.uat_gaps.filter(i => !i.scan_error);
|
|
assert.equal(realUatGaps.length, 1, 'pending UAT must still be flagged');
|
|
assert.equal(realUatGaps[0].status, 'pending');
|
|
} finally {
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
test('quick task without any SUMMARY file is still flagged as missing', () => {
|
|
const cwd = mkTmp();
|
|
try {
|
|
const quickId = '260429-test-bar';
|
|
const taskDir = path.join(cwd, '.planning', 'quick', quickId);
|
|
fs.mkdirSync(taskDir, { recursive: true });
|
|
// No SUMMARY file at all.
|
|
|
|
const result = auditOpenArtifacts(cwd);
|
|
const realQuickTasks = result.items.quick_tasks.filter(
|
|
i => !i.scan_error && !i._remainder_count
|
|
);
|
|
assert.equal(realQuickTasks.length, 1);
|
|
assert.equal(realQuickTasks[0].status, 'missing');
|
|
} finally {
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('bug #2836: workflows/help.md one-liner reconciliation', () => {
|
|
test('help.md quick-task one-liner uses ${quick_id}-SUMMARY.md pattern', () => {
|
|
// After #3039, help content moved into help/modes/full.md.
|
|
const helpPath = path.resolve(
|
|
__dirname, '..', 'gsd-core', 'workflows', 'help', 'modes', 'full.md'
|
|
);
|
|
const content = fs.readFileSync(helpPath, 'utf-8');
|
|
|
|
// Locate the documented "Result: Creates ..." quick-task one-liner and
|
|
// assert it references the per-task SUMMARY filename pattern, not bare
|
|
// SUMMARY.md. We parse by line to avoid false positives elsewhere.
|
|
const resultLines = content.split(/\r?\n/).filter(l =>
|
|
l.includes('Result: Creates') && l.includes('.planning/quick/')
|
|
);
|
|
assert.ok(resultLines.length > 0, 'expected a quick-task Result line in help.md');
|
|
for (const line of resultLines) {
|
|
assert.ok(
|
|
/\$\{quick_id\}-SUMMARY\.md|NNN-slug-SUMMARY\.md/.test(line),
|
|
`help.md quick-task Result line must reference per-task SUMMARY filename ` +
|
|
`(e.g. \${quick_id}-SUMMARY.md); got: ${line}`
|
|
);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-950-quick-summary-status-complete.test.cjs — consolidation epic #1969 (B3 #1972)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-950-quick-summary-status-complete (consolidation epic #1969 B3 #1972)", () => {
|
|
// allow-test-rule: source-text-is-the-product (see #950)
|
|
/**
|
|
* Regression tests for bug #950
|
|
*
|
|
* audit-open chronically flagged genuinely-complete quick tasks as [unknown]
|
|
* because NO shipped summary template carried a `status:` frontmatter field —
|
|
* so status was only emitted when the writing agent improvised it.
|
|
*
|
|
* The fix: add `status: complete` to all four summary templates and enforce it
|
|
* in the executor agent + quick.md workflow. Tests here exercise the scanner
|
|
* directly via auditOpenArtifacts() and also guard template text as a secondary
|
|
* contract check.
|
|
*
|
|
* Primary guard: behavioral audit-scanner tests (tasks read by scanQuickTasks)
|
|
* Secondary guard: template-contract text checks (template text IS the runtime contract)
|
|
* Writer-path guard: contract assertions on quick.md + gsd-executor.md (source-text-is-the-product)
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { describe, test, before, after } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
|
|
const auditModule = require('../gsd-core/bin/lib/audit.cjs');
|
|
const { auditOpenArtifacts } = auditModule;
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
const TEMPLATES_DIR = path.resolve(__dirname, '..', 'gsd-core', 'templates');
|
|
const QUICK_MD = path.resolve(__dirname, '..', 'gsd-core', 'workflows', 'quick.md');
|
|
const EXECUTOR_MD = path.resolve(__dirname, '..', 'agents', 'gsd-executor.md');
|
|
|
|
function mkTmp() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'bug-950-'));
|
|
}
|
|
|
|
/**
|
|
* Extract the first YAML frontmatter block from a file's content.
|
|
*
|
|
* Two layouts are handled:
|
|
* - Leading frontmatter: file starts with `---\n…\n---` (summary-minimal/standard/complex.md)
|
|
* - Fenced frontmatter: frontmatter lives inside a ```markdown … ``` fence (summary.md,
|
|
* whose content IS a markdown example showing the template). In that case we extract
|
|
* the `---\n…\n---` block that sits immediately after the opening fence line.
|
|
*
|
|
* Returns the raw text of the YAML block (between the two `---` delimiters, exclusive),
|
|
* or null if no frontmatter could be found.
|
|
*/
|
|
function extractFrontmatter(content) {
|
|
// Case 1: file begins with --- (leading frontmatter)
|
|
if (/^---\r?\n/.test(content)) {
|
|
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
// Case 2: frontmatter is embedded inside a fenced block (```markdown\n---\n…\n---\n)
|
|
const fenceMatch = content.match(/```(?:markdown|md)?\r?\n(---\r?\n[\s\S]*?\r?\n---)\r?\n/);
|
|
if (fenceMatch) {
|
|
// Strip the outer --- delimiters to get just the YAML body
|
|
const block = fenceMatch[1];
|
|
const inner = block.match(/^---\r?\n([\s\S]*?)\r?\n---$/);
|
|
return inner ? inner[1] : null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
describe('bug #950: quick-task SUMMARY must carry status: complete', () => {
|
|
// Ensure GSD env vars do not redirect planningDir() away from our fixture.
|
|
let prevProject, prevWorkstream;
|
|
before(() => {
|
|
prevProject = process.env.GSD_PROJECT;
|
|
prevWorkstream = process.env.GSD_WORKSTREAM;
|
|
delete process.env.GSD_PROJECT;
|
|
delete process.env.GSD_WORKSTREAM;
|
|
});
|
|
after(() => {
|
|
if (prevProject !== undefined) process.env.GSD_PROJECT = prevProject;
|
|
if (prevWorkstream !== undefined) process.env.GSD_WORKSTREAM = prevWorkstream;
|
|
});
|
|
|
|
// ── Behavioral: scanner recognizes complete quick tasks ───────────────────
|
|
|
|
test('[PRIMARY] quick task SUMMARY with status: complete is NOT flagged open', () => {
|
|
// Simulates an executor that correctly wrote the SUMMARY with status: complete
|
|
// (as required after the fix). The scanner must report 0 open quick tasks.
|
|
const cwd = mkTmp();
|
|
try {
|
|
const quickId = '260609-test-status-complete';
|
|
const taskDir = path.join(cwd, '.planning', 'quick', quickId);
|
|
fs.mkdirSync(taskDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(taskDir, `${quickId}-SUMMARY.md`),
|
|
[
|
|
'---',
|
|
'status: complete',
|
|
'date: 2026-06-09',
|
|
'slug: test-status-complete',
|
|
'---',
|
|
'',
|
|
'# Quick Task Summary',
|
|
'',
|
|
'Task completed successfully.',
|
|
].join('\n'),
|
|
'utf-8'
|
|
);
|
|
|
|
const result = auditOpenArtifacts(cwd);
|
|
const realQuickTasks = result.items.quick_tasks.filter(
|
|
i => !i.scan_error && !i._remainder_count
|
|
);
|
|
|
|
assert.equal(
|
|
realQuickTasks.length,
|
|
0,
|
|
`quick task SUMMARY with status: complete must NOT appear as open; ` +
|
|
`got: ${JSON.stringify(realQuickTasks)}`
|
|
);
|
|
assert.equal(result.counts.quick_tasks, 0);
|
|
} finally {
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
test('[PRIMARY] quick task SUMMARY without status: field is still flagged [unknown]', () => {
|
|
// Negative case: a SUMMARY that lacks status: still surfaces as [unknown].
|
|
// This proves the scanner still catches real gaps — the fix must be on the writer side.
|
|
const cwd = mkTmp();
|
|
try {
|
|
const quickId = '260609-test-no-status';
|
|
const taskDir = path.join(cwd, '.planning', 'quick', quickId);
|
|
fs.mkdirSync(taskDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(taskDir, `${quickId}-SUMMARY.md`),
|
|
[
|
|
'---',
|
|
'date: 2026-06-09',
|
|
'slug: test-no-status',
|
|
'---',
|
|
'',
|
|
'# Quick Task Summary',
|
|
'',
|
|
'Task done, but no status field.',
|
|
].join('\n'),
|
|
'utf-8'
|
|
);
|
|
|
|
const result = auditOpenArtifacts(cwd);
|
|
const realQuickTasks = result.items.quick_tasks.filter(
|
|
i => !i.scan_error && !i._remainder_count
|
|
);
|
|
|
|
assert.equal(
|
|
realQuickTasks.length,
|
|
1,
|
|
`quick task SUMMARY without status: must appear as open (unknown); ` +
|
|
`got: ${JSON.stringify(realQuickTasks)}`
|
|
);
|
|
assert.equal(realQuickTasks[0].status, 'unknown', 'expected status to be unknown');
|
|
} finally {
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
test('[PRIMARY] quick task without any SUMMARY is still flagged [missing]', () => {
|
|
// Proves the missing-SUMMARY case still surfaces.
|
|
const cwd = mkTmp();
|
|
try {
|
|
const quickId = '260609-test-missing-summary';
|
|
const taskDir = path.join(cwd, '.planning', 'quick', quickId);
|
|
fs.mkdirSync(taskDir, { recursive: true });
|
|
// No SUMMARY file at all.
|
|
|
|
const result = auditOpenArtifacts(cwd);
|
|
const realQuickTasks = result.items.quick_tasks.filter(
|
|
i => !i.scan_error && !i._remainder_count
|
|
);
|
|
|
|
assert.equal(realQuickTasks.length, 1, 'missing SUMMARY must still be flagged');
|
|
assert.equal(realQuickTasks[0].status, 'missing');
|
|
} finally {
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
test('[PRIMARY] SUMMARY with status: COMPLETE (uppercase) is also recognized', () => {
|
|
// Scanner lowercases before comparing — verify case-insensitivity holds.
|
|
const cwd = mkTmp();
|
|
try {
|
|
const quickId = '260609-test-uppercase-complete';
|
|
const taskDir = path.join(cwd, '.planning', 'quick', quickId);
|
|
fs.mkdirSync(taskDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(taskDir, `${quickId}-SUMMARY.md`),
|
|
'---\nstatus: COMPLETE\n---\n# Summary\nDone.\n',
|
|
'utf-8'
|
|
);
|
|
|
|
const result = auditOpenArtifacts(cwd);
|
|
const realQuickTasks = result.items.quick_tasks.filter(
|
|
i => !i.scan_error && !i._remainder_count
|
|
);
|
|
|
|
assert.equal(
|
|
realQuickTasks.length,
|
|
0,
|
|
`quick task SUMMARY with status: COMPLETE (uppercase) must not appear as open; ` +
|
|
`got: ${JSON.stringify(realQuickTasks)}`
|
|
);
|
|
} finally {
|
|
cleanup(cwd);
|
|
}
|
|
});
|
|
|
|
// ── Secondary: template-contract checks ──────────────────────────────────
|
|
// Assertions are scoped to the actual YAML frontmatter block, not the whole file,
|
|
// so a stray `status: complete` in prose or examples cannot produce a false green.
|
|
|
|
test('[TEMPLATE CONTRACT] summary.md contains status: complete in frontmatter', () => {
|
|
// summary.md is a documentation template — its frontmatter lives inside a
|
|
// ```markdown fence. extractFrontmatter() finds and returns that block.
|
|
const content = fs.readFileSync(path.join(TEMPLATES_DIR, 'summary.md'), 'utf-8');
|
|
const fm = extractFrontmatter(content);
|
|
assert.ok(
|
|
fm !== null,
|
|
'gsd-core/templates/summary.md: could not locate a YAML frontmatter block (leading --- or fenced ```markdown --- block)'
|
|
);
|
|
assert.ok(
|
|
/^status:\s*complete\s*$/m.test(fm),
|
|
`gsd-core/templates/summary.md: \`status: complete\` not found in the frontmatter block.\n` +
|
|
`Frontmatter extracted:\n${fm}`
|
|
);
|
|
});
|
|
|
|
test('[TEMPLATE CONTRACT] summary-minimal.md contains status: complete in frontmatter', () => {
|
|
const content = fs.readFileSync(path.join(TEMPLATES_DIR, 'summary-minimal.md'), 'utf-8');
|
|
const fm = extractFrontmatter(content);
|
|
assert.ok(
|
|
fm !== null,
|
|
'gsd-core/templates/summary-minimal.md: could not locate a leading YAML frontmatter block'
|
|
);
|
|
assert.ok(
|
|
/^status:\s*complete\s*$/m.test(fm),
|
|
`gsd-core/templates/summary-minimal.md: \`status: complete\` not found in the frontmatter block.\n` +
|
|
`Frontmatter extracted:\n${fm}`
|
|
);
|
|
});
|
|
|
|
test('[TEMPLATE CONTRACT] summary-standard.md contains status: complete in frontmatter', () => {
|
|
const content = fs.readFileSync(path.join(TEMPLATES_DIR, 'summary-standard.md'), 'utf-8');
|
|
const fm = extractFrontmatter(content);
|
|
assert.ok(
|
|
fm !== null,
|
|
'gsd-core/templates/summary-standard.md: could not locate a leading YAML frontmatter block'
|
|
);
|
|
assert.ok(
|
|
/^status:\s*complete\s*$/m.test(fm),
|
|
`gsd-core/templates/summary-standard.md: \`status: complete\` not found in the frontmatter block.\n` +
|
|
`Frontmatter extracted:\n${fm}`
|
|
);
|
|
});
|
|
|
|
test('[TEMPLATE CONTRACT] summary-complex.md contains status: complete in frontmatter', () => {
|
|
const content = fs.readFileSync(path.join(TEMPLATES_DIR, 'summary-complex.md'), 'utf-8');
|
|
const fm = extractFrontmatter(content);
|
|
assert.ok(
|
|
fm !== null,
|
|
'gsd-core/templates/summary-complex.md: could not locate a leading YAML frontmatter block'
|
|
);
|
|
assert.ok(
|
|
/^status:\s*complete\s*$/m.test(fm),
|
|
`gsd-core/templates/summary-complex.md: \`status: complete\` not found in the frontmatter block.\n` +
|
|
`Frontmatter extracted:\n${fm}`
|
|
);
|
|
});
|
|
|
|
// ── Writer-path contract checks ───────────────────────────────────────────
|
|
// Guards quick.md and gsd-executor.md so a future edit removing `status: complete`
|
|
// from the SUMMARY-creation instructions would fail the suite before the bug recurs.
|
|
// (source-text-is-the-product: the deployed .md text IS the runtime contract for agents)
|
|
|
|
test('[WRITER-PATH] quick.md constraints require status: complete in SUMMARY frontmatter', () => {
|
|
const content = fs.readFileSync(QUICK_MD, 'utf-8');
|
|
assert.ok(
|
|
/status:\s*complete/.test(content),
|
|
'gsd-core/workflows/quick.md must instruct the executor to write `status: complete` in the SUMMARY frontmatter. ' +
|
|
'The <constraints> block must contain the `status: complete` requirement so a future edit cannot silently drop it.'
|
|
);
|
|
});
|
|
|
|
test('[WRITER-PATH] gsd-executor.md frontmatter spec requires status: complete', () => {
|
|
const content = fs.readFileSync(EXECUTOR_MD, 'utf-8');
|
|
assert.ok(
|
|
/status[\s\S]{0,40}complete/.test(content),
|
|
'agents/gsd-executor.md must document `status: complete` as a required SUMMARY frontmatter field. ' +
|
|
'The Frontmatter section must include `status: complete` so the executor always emits it.'
|
|
);
|
|
});
|
|
});
|
|
});
|
|
}
|