* feat(#247): runtime-neutral phase uat-passed predicate from HUMAN-UAT results Wire the already-reserved `phase.uat-passed` alias (subcommand `uat-passed`, mutation:false) into the phase command router with a new markdown-aware predicate that evaluates HUMAN-UAT results and reports pass only when every required check passes. Post-SDK-retirement (ADR-0174/#174) successor to the SDK-framed #70, with no SDK-specific API surface. New pure module src/uat-predicate.cts: - stripFalsePositiveContexts: frontmatter -> HTML-comment -> CommonMark-style fenced-block state machine (tracks delimiter char+length) -> blockquote, each a small composable step, so a `result: passed` inside frontmatter, a fenced/~~~ block (incl. ~~~ nested in a ``` fence), a comment, or a blockquote is never counted. - parseUatResultItems: heading-block parser, column-0-anchored same-line result; a heading with no result -> `missing` (fail-closed). - analyzeMarkdown: unterminated fence/comment detection (malformed -> blocker). - evaluateUatPassed: allowlist pass/verification semantics; passed = no blockers && >=1 check && all passing; no_uat_artifacts discriminator (no vacuous pass); optional requireVerification policy hook. Thin cmdPhaseUatPassed handler in phase.cts; router closure rejects unknown flags via makeInvalidArgs. Hardened across two Codex adversarial passes (vacuous pass, dropped failing tests, permissive verification status, nested-fence escape, cross-line result value, masked unterminated comment) — all fixed fail-closed. New unit + CLI-integration suites incl. a fast-check property test; docs, CONTEXT glossary, inventory, and changeset updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#247): backfill changeset PR number (#1063) * fix(#247): indexOf paired-scan for unterminated-comment detection CodeQL js/incomplete-multi-character-sanitization (high) flagged the `raw.replace(/<!--[\s\S]*?-->/g,'')`-then-`.includes('<!--')` detection in analyzeMarkdown as incomplete sanitization (a single regex pass can leave a residual `<!--`). Replace it with a paired left-to-right indexOf scan that contains no `.replace()` of the comment token — CodeQL-clean and strictly more correct (a closed earlier comment can never mask a later unterminated one). Behaviour unchanged; 98 predicate tests + scoped docker run green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
268 lines
9.1 KiB
JavaScript
268 lines
9.1 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Integration tests for `phase uat-passed <N>` CLI command.
|
|
* Issue #247 — phase uat-passed predicate
|
|
*
|
|
* Tests the full dispatch path: gsd-tools → phase-command-router → phase.cmdPhaseUatPassed
|
|
*/
|
|
|
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Set up a minimal project with a phase directory and ROADMAP so that
|
|
* findPhaseInternal(cwd, phaseNum) can resolve it.
|
|
* Returns { tmpDir, phaseDir }.
|
|
*/
|
|
function setupProject(phaseSlug = '01-feature') {
|
|
const tmpDir = createTempProject();
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
[
|
|
'# Roadmap',
|
|
'',
|
|
'- [ ] Phase 1: Feature',
|
|
'',
|
|
'### Phase 1: Feature',
|
|
'**Goal:** Build feature',
|
|
'**Plans:** 1 plans',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', phaseSlug);
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
return { tmpDir, phaseDir };
|
|
}
|
|
|
|
function writeUatFile(phaseDir, filename, content) {
|
|
fs.writeFileSync(path.join(phaseDir, filename), content, 'utf-8');
|
|
}
|
|
|
|
function makePassingUat() {
|
|
return [
|
|
'---',
|
|
'status: passed',
|
|
'---',
|
|
'',
|
|
'# UAT Results',
|
|
'',
|
|
'### 1. Login works',
|
|
'expected: User logs in successfully',
|
|
'result: passed',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
function makePendingUat() {
|
|
return [
|
|
'---',
|
|
'status: partial',
|
|
'---',
|
|
'',
|
|
'# UAT Results',
|
|
'',
|
|
'### 1. Login works',
|
|
'expected: User logs in successfully',
|
|
'result: passed',
|
|
'',
|
|
'### 2. Logout works',
|
|
'expected: User logs out successfully',
|
|
'result: pending',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
function makeFencedFalsePositiveUat() {
|
|
// Only "result: passed" lines are inside a fenced block.
|
|
// The real test has result: pending → should evaluate to passed:false.
|
|
return [
|
|
'---',
|
|
'status: partial',
|
|
'---',
|
|
'',
|
|
'# UAT Results',
|
|
'',
|
|
'## Example (do not run)',
|
|
'```',
|
|
'### 1. Test',
|
|
'expected: Example',
|
|
'result: passed',
|
|
'```',
|
|
'',
|
|
'### 1. Real Test',
|
|
'expected: The thing works',
|
|
'result: pending',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
// ─── Basic pass/fail cases ─────────────────────────────────────────────────────
|
|
|
|
describe('phase uat-passed — basic pass/fail', () => {
|
|
let tmpDir;
|
|
let phaseDir;
|
|
|
|
beforeEach(() => {
|
|
({ tmpDir, phaseDir } = setupProject('01-feature'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('passing UAT → passed:true with correct JSON shape', () => {
|
|
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
|
|
const result = runGsdTools('phase uat-passed 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
|
|
|
|
const out = JSON.parse(result.output);
|
|
assert.strictEqual(out.passed, true);
|
|
assert.strictEqual(out.phase, '1');
|
|
assert.ok(Array.isArray(out.uat_files), 'uat_files must be an array');
|
|
assert.ok(Array.isArray(out.verification_files), 'verification_files must be an array');
|
|
assert.ok(Array.isArray(out.checks), 'checks must be an array');
|
|
assert.ok(Array.isArray(out.blockers), 'blockers must be an array');
|
|
assert.ok(out.policy && typeof out.policy.require_verification === 'boolean',
|
|
'policy.require_verification must be a boolean');
|
|
assert.strictEqual(typeof out.no_uat_artifacts, 'boolean', 'no_uat_artifacts must be a boolean');
|
|
assert.strictEqual(out.no_uat_artifacts, false, 'no_uat_artifacts must be false when checks exist');
|
|
assert.strictEqual(out.blockers.length, 0);
|
|
});
|
|
|
|
test('pending UAT → passed:false', () => {
|
|
writeUatFile(phaseDir, 'feature-UAT.md', makePendingUat());
|
|
const result = runGsdTools('phase uat-passed 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const out = JSON.parse(result.output);
|
|
assert.strictEqual(out.passed, false);
|
|
assert.strictEqual(out.phase, '1');
|
|
assert.ok(out.blockers.length > 0, 'Should have blockers for pending test');
|
|
});
|
|
|
|
test('false-positive only (fenced block) → passed:false', () => {
|
|
writeUatFile(phaseDir, 'feature-UAT.md', makeFencedFalsePositiveUat());
|
|
const result = runGsdTools('phase uat-passed 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const out = JSON.parse(result.output);
|
|
assert.strictEqual(out.passed, false,
|
|
'result:passed inside a fenced block must not flip the predicate to passed');
|
|
});
|
|
|
|
test('no UAT files → passed:false + no_uat_artifacts:true (fail-closed, no vacuous pass)', () => {
|
|
// Phase directory exists but has no UAT files — fail-closed: absence is NOT a pass
|
|
const result = runGsdTools('phase uat-passed 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const out = JSON.parse(result.output);
|
|
assert.strictEqual(out.passed, false,
|
|
'Phase with no UAT files must NOT vacuously pass — fail-closed predicate');
|
|
assert.strictEqual(out.no_uat_artifacts, true,
|
|
'no_uat_artifacts must be true when no UAT items found');
|
|
assert.deepStrictEqual(out.uat_files, []);
|
|
});
|
|
});
|
|
|
|
// ─── --require-verification flag ──────────────────────────────────────────────
|
|
|
|
describe('phase uat-passed — --require-verification flag', () => {
|
|
let tmpDir;
|
|
let phaseDir;
|
|
|
|
beforeEach(() => {
|
|
({ tmpDir, phaseDir } = setupProject('01-feature'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('--require-verification with no verification file → passed:false', () => {
|
|
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
|
|
const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const out = JSON.parse(result.output);
|
|
assert.strictEqual(out.passed, false,
|
|
'require-verification with no verification file should fail');
|
|
assert.strictEqual(out.policy.require_verification, true);
|
|
assert.ok(out.blockers.some(b => /verification required/i.test(b)),
|
|
`Expected verification-required blocker, got: ${JSON.stringify(out.blockers)}`);
|
|
});
|
|
|
|
test('--require-verification with passing verification → passed:true', () => {
|
|
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
|
|
writeUatFile(phaseDir, 'feature-VERIFICATION.md', '---\nstatus: passed\n---\n\nVerified OK.');
|
|
const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const out = JSON.parse(result.output);
|
|
assert.strictEqual(out.passed, true);
|
|
assert.strictEqual(out.policy.require_verification, true);
|
|
});
|
|
});
|
|
|
|
// ─── Error cases ──────────────────────────────────────────────────────────────
|
|
|
|
describe('phase uat-passed — error cases', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
// Write a minimal ROADMAP so phase 1 exists
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
[
|
|
'# Roadmap',
|
|
'',
|
|
'### Phase 1: Feature',
|
|
'**Goal:** Build feature',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-feature'), { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('missing phase number → error message', () => {
|
|
const result = runGsdTools('phase uat-passed', tmpDir);
|
|
assert.ok(!result.success, 'Should fail with no phase number');
|
|
assert.ok(
|
|
result.error.includes('phase number required') ||
|
|
result.error.includes('Available:'),
|
|
`Expected phase-number-required error, got: ${result.error}`,
|
|
);
|
|
});
|
|
|
|
test('unknown phase number → error message', () => {
|
|
const result = runGsdTools('phase uat-passed 99', tmpDir);
|
|
assert.ok(!result.success, 'Should fail for unknown phase');
|
|
assert.ok(
|
|
result.error.includes('not found') || result.error.includes('99'),
|
|
`Expected not-found error, got: ${result.error}`,
|
|
);
|
|
});
|
|
|
|
test('unknown flag (typo --require-verifcation) → InvalidArgs error, not silent pass', () => {
|
|
const result = runGsdTools('phase uat-passed 1 --require-verifcation', tmpDir);
|
|
assert.ok(!result.success,
|
|
'Unknown flag must cause an error, not silently pass');
|
|
assert.ok(
|
|
result.error.includes('--require-verifcation') ||
|
|
result.error.includes('does not support') ||
|
|
result.error.includes('invalid'),
|
|
`Expected unknown-flag error, got: ${result.error}`,
|
|
);
|
|
});
|
|
});
|