fix(#4546): deferred UAT follow-ups no longer block completion and promote to the backlog (#4769)

* test(#4546): failing-first tests for deferred uat follow-ups

* chore(#4546): regenerate derived lists for the deferred-promotion suite

The new verify-work-deferred-promotion suite changes the tests/ tree the
macOS conformance-tier classifier tracks and is a novel file under the
verify prefix in the test-file-count ratchet; both derived lists are
regenerated/registered per their own guards' instructions.

* fix(#4546): deferred uat follow-ups no longer block, and get promoted

Two halves of one disconnect (#1921's deferral design vs the completion
predicate):

- uat-predicate: the item parser now captures the block's reason: line
  alongside result:. A skipped item whose reason carries the
  verify-work writer's 'Deferred follow-up:' template is a deliberate
  deferral -- non-blocking, flagged deferred in the report. Quote-
  tolerant (the writer wraps the value) and case-insensitive. A
  reasonless skip, a non-deferral reason, pending/blocked/issue/
  failed/missing all still block, exactly as before.
- verify-work complete_session: when the Deferred Follow-Ups section is
  non-empty, offer to promote the items to a ROADMAP.md 999.x backlog
  entry reusing next.md's prior_phase_completeness entry shape, with a
  --files-scoped commit. Offer, not auto-mutation -- matches the
  workflow's interactive convention and next.md's own prompt style.

* chore(#4546): refresh compact-content benchmark baseline

verify-work.md grew (the #4546 deferred-follow-up promotion offer in
complete_session); the registered split's token counts moved with it.
Baseline recomputed with the script's own --write.

Emitted-Drift-Ack-Growth: verify-work.md — complete_session gained the deferred-follow-up promotion offer (detection, [P]/[K] choice, the next.md-shaped 999.x entry template, and the --files-scoped ROADMAP.md commit); the growth is the new contract text, not duplication

* fix(#4546): gate/audit agreement and review fixes for deferred follow-ups

- src/uat.cts categorizeItem: a skipped item carrying the deferred
  follow-up template reason now categorizes as 'deferred' (the category
  already existed for deferred-items.md entries) instead of being
  misfiled into the blocked families by keyword match -- the gate/audit
  agreement #3078-CR expects, restored in the permissive direction the
  #1921 design intends. Checked BEFORE the keyword families so '...
  on the release build next version' is not build_needed.
- verify-work.md promotion step: numbering scans for the smallest free
  999.n (count races + non-contiguous history), one backlog entry per
  deferred follow-up, ROADMAP.md-absent behavior specified, idea text
  newline-flattened, Deferred at placeholder harmonized with next.md.
- DEFERRED_REASON_RE: trust assumption documented (authoring contract,
  not a security boundary; non-matching spellings block fail-closed).
- tests: the property now drives evaluateUatPassed and derives
  expectations from the input spec (never restates the matcher),
  includes the no-result-line branch, and pins its seed; the parity
  test drops try/finally for the approved pattern, uses createTempDir,
  sites its allow-test-rule marker at the suppression site, and asserts
  the literal [P]/[K] choices.

* fix(#4546): close promotion-test docstring, drop fc replay-path misuse, refresh baseline

The final matrix run caught three defects in my own review-fix commit:
the parity test file's JSDoc was left unterminated (the whole file
parsed as one comment -- zero tests registered, hence the file-level
'test failed' the runner reported); fast-check's replay-path parameter
was misused as a label (invalid path at replay); and the workflow-text
ambiguity fixes re-drifted the compact-content benchmark baseline.

* docs(#4546): add Fixed changeset for deferred follow-up coverage

* docs(#4546): backfill changeset PR number

* fix(#4546): use the pattern seam escapeRegex for shape-marker matching

The hand-rolled metacharacter escape in the shape-marker assertion
tripped local/no-adhoc-regex-escape, whose named remedy this adopts.

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-15 07:30:38 -04:00
committed by GitHub
parent 0967358b8b
commit cbbde6786a
9 changed files with 353 additions and 10 deletions

View File

@@ -33,14 +33,14 @@
"reductionPct": 11.9
},
"verify-work": {
"offTokens": 12113,
"onTokens": 10140,
"reductionPct": 16.29
"offTokens": 12664,
"onTokens": 10691,
"reductionPct": 15.58
}
},
"aggregate": {
"offTokens": 107596,
"onTokens": 90948,
"reductionPct": 15.47
"offTokens": 108147,
"onTokens": 91499,
"reductionPct": 15.39
}
}

View File

@@ -1654,3 +1654,147 @@ describe('#3078-CR: evaluateUatPassed agrees with the audit surface (parseUatIte
assert.equal(auditReport.items.length, 0, 'audit: a clean file must have no outstanding rows');
});
});
// ─── #4546 — deferred follow-up skips are non-blocking ────────────────────────
describe('#4546 — deferred follow-up skips', () => {
let tmpDir;
beforeEach(() => {
tmpDir = makeTmpDir();
});
afterEach(() => {
rmDir(tmpDir);
});
function makeDeferredUatItem(n, name, reason) {
const lines = [`### ${n}. ${name}`, `expected: ${name} works`];
if (reason === null) {
lines.push('result: skipped');
} else {
lines.push('result: skipped', `reason: ${reason}`);
}
return lines;
}
test('deferred follow-up skip is non-blocking (#4546)', () => {
const content = [
'---', 'status: complete', '---', '',
'### 1. Test A', 'expected: A', 'result: passed', '',
...makeDeferredUatItem(2, 'Test B', '"Deferred follow-up: nice to have, next version"'), '',
].join('\n');
writeFile(tmpDir, 'phase-UAT.md', content);
const report = evaluateUatPassed(tmpDir);
assert.strictEqual(report.passed, true,
`a deliberately deferred follow-up must not block: ${JSON.stringify(report.blockers)}`);
assert.strictEqual(report.blockers.length, 0);
const deferred = report.checks.find(c => c.test === 2);
assert.ok(deferred, 'deferred check present');
assert.strictEqual(deferred.passing, true, 'deferred skip counts as passing');
assert.strictEqual(deferred.deferred, true, 'deferred skip is flagged deferred in the report');
const passing = report.checks.find(c => c.test === 1);
assert.strictEqual(passing.deferred, false, 'a real pass is not flagged deferred');
});
test('plain skipped without a reason still blocks (#4546 negative space)', () => {
const content = [
'---', 'status: complete', '---', '',
'### 1. Test A', 'expected: A', 'result: passed', '',
...makeDeferredUatItem(2, 'Test B', null), '',
].join('\n');
writeFile(tmpDir, 'phase-UAT.md', content);
const report = evaluateUatPassed(tmpDir);
assert.strictEqual(report.passed, false,
'a skipped test with no reason is unresolved and must block');
assert.ok(report.blockers.some(b => /test 2/.test(b)),
`expected a blocker for test 2, got: ${JSON.stringify(report.blockers)}`);
});
test('skipped with a non-deferral reason still blocks (#4546 negative space)', () => {
const content = [
'---', 'status: complete', '---', '',
'### 1. Test A', 'expected: A', 'result: passed', '',
...makeDeferredUatItem(2, 'Test B', '"waiting on credentials"'), '',
].join('\n');
writeFile(tmpDir, 'phase-UAT.md', content);
const report = evaluateUatPassed(tmpDir);
assert.strictEqual(report.passed, false,
'a skipped test whose reason is not a deferral must block');
});
test('deferred reason match is case-insensitive (#4546 boundary)', () => {
const content = [
'---', 'status: complete', '---', '',
...makeDeferredUatItem(1, 'Test A', '"deferred follow-up: later"'), '',
].join('\n');
writeFile(tmpDir, 'phase-UAT.md', content);
const report = evaluateUatPassed(tmpDir);
assert.strictEqual(report.passed, true,
'the matcher anchors on the shipped template text case-insensitively');
});
test('a deferred skip does not mask other blockers (#4546 independence)', () => {
const content = [
'---', 'status: complete', '---', '',
...makeDeferredUatItem(1, 'Test A', '"Deferred follow-up: later"'), '',
'### 2. Test B', 'expected: B', 'result: pending', '',
'### 3. Test C', 'expected: C', 'result: blocked', 'blocked_by: server', '',
'### 4. Test D', 'expected: D', 'result: issue', 'reported: "crashes"', '',
].join('\n');
writeFile(tmpDir, 'phase-UAT.md', content);
const report = evaluateUatPassed(tmpDir);
assert.strictEqual(report.passed, false,
'deferred skip must not mask pending/blocked/issue blockers');
assert.ok(report.blockers.some(b => /test 2/.test(b)), 'pending still blocks');
assert.ok(report.blockers.some(b => /test 3/.test(b)), 'blocked still blocks');
assert.ok(report.blockers.some(b => /test 4/.test(b)), 'issue still blocks');
assert.ok(!report.blockers.some(b => /test 1/.test(b)),
`the deferred item must not appear among blockers: ${JSON.stringify(report.blockers)}`);
});
test('property: deferred-skip acceptance drives the real gate over arbitrary result/reason pairs (#4546)', () => {
// Gate-driven: expectations are derived from the INPUT (the spec sentence
// in #4546), then asserted against evaluateUatPassed's report — the
// property never restates the implementation's regex. The generated
// classes include the no-result-line shape (the parser's 'missing'
// branch) and reasonless skips.
const deferredRe = /^["']?deferred follow-up\b/i;
fc.assert(
fc.property(
fc.constantFrom('passed', 'pass', 'skipped', 'pending', 'blocked', 'issue', 'missing'),
fc.option(fc.stringMatching(/^["']?[a-z ]{0,30}$/), { nil: undefined }),
(result, reason) => {
const itemLines = [`### 1. Test X`, 'expected: X works'];
if (result !== 'missing') {
itemLines.push(`result: ${result}`);
if (reason !== undefined) itemLines.push(`reason: ${reason}`);
}
const content = [
'---', 'status: complete', '---', '',
...itemLines, '',
].join('\n');
fs.writeFileSync(path.join(tmpDir, 'phase-UAT.md'), content, 'utf-8');
const report = evaluateUatPassed(tmpDir);
assert.strictEqual(report.checks.length, 1);
const check = report.checks[0];
const isDeferral = result === 'skipped' &&
typeof reason === 'string' && deferredRe.test(reason);
const specPassing = result === 'passed' || result === 'pass' || isDeferral;
assert.strictEqual(check.result, result === 'missing' ? 'missing' : result);
assert.strictEqual(check.passing, specPassing,
`result=${result} reason=${JSON.stringify(reason)}: gate must ${specPassing ? 'pass' : 'block'}`);
assert.strictEqual(check.deferred, isDeferral,
`result=${result} reason=${JSON.stringify(reason)}: deferred flag`);
assert.strictEqual(report.passed, specPassing,
'a single-item file passes exactly when the item passes');
assert.deepStrictEqual(report.blockers, specPassing ? [] : [report.blockers[0]]);
}
),
{ numRuns: 120, seed: 4546 }
);
});
});

View File

@@ -0,0 +1,106 @@
'use strict';
/**
* Writer-contract + parity tests for the #4546 deferred-follow-up promotion.
*
* Two parallel surfaces own the deferral contract:
* - the WRITER: gsd-core/workflows/verify-work.md, whose process_response step
* writes `reason: "Deferred follow-up: {verbatim user response}"` and whose
* complete_session step must offer 999.x promotion of the Deferred
* Follow-Ups section (reusing next.md's prior_phase_completeness entry
* shape);
* - the READER: the UAT predicate (src/uat-predicate.cts), which must treat
* that exact template text as non-blocking.
*
* Row 8 drives the writer's OWN template text (extracted from the shipped
* workflow, placeholder substituted) through the real predicate — if either
* surface changes its half of the contract, this fails.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { evaluateUatPassed } = require('../gsd-core/bin/lib/uat-predicate.cjs');
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
const { cleanup, createTempDir } = require('./helpers.cjs');
const ROOT = path.join(__dirname, '..');
const VERIFY_WORK_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'verify-work.md');
const NEXT_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'next.md');
function readWorkflow(p) {
return fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n');
}
/** Extract the body of a named <step> block from a workflow, or throw. */
function stepBlock(src, stepName, label) {
const start = src.indexOf(`<step name="${stepName}">`);
assert.ok(start !== -1, `${label} must contain <step name="${stepName}">`);
const end = src.indexOf('</step>', start);
assert.ok(end !== -1, `<step name="${stepName}"> must be closed`);
return src.slice(start, end);
}
describe('verify-work deferred follow-up promotion (#4546)', () => {
const verifyWork = readWorkflow(VERIFY_WORK_PATH);
test('writer reason template matches the predicate matcher (#4546 parity)', () => {
// Extract the shipped reason template from the writer (process_response
// step): `reason: "Deferred follow-up: {verbatim user response}"`.
const m = verifyWork.match(/reason: "(Deferred follow-up: \{[^}]+\})"/);
assert.ok(m, 'verify-work.md process_response must write the deferred reason template');
const templateText = m[1]; // e.g. `Deferred follow-up: {verbatim user response}`
const sampleReason = templateText.replace(/\{[^}]+\}/, 'nice to have, next version');
// Drive the writer's own template text through the real predicate: a UAT
// file whose only non-passing item carries this reason must pass.
// allow-test-rule: source-text-is-the-product (#4546) — the readFileSync
// in readWorkflow above is this marker's other suppression site: the
// workflow text is the deployed contract.
const tmpDir = createTempDir('gsd-4546-parity-');
const content = [
'---', 'status: complete', '---', '',
'### 1. Test A', 'expected: A', 'result: passed', '',
'### 2. Test B', 'expected: B', 'result: skipped',
`reason: "${sampleReason}"`, '',
].join('\n');
fs.writeFileSync(path.join(tmpDir, 'phase-UAT.md'), content, 'utf8');
const report = evaluateUatPassed(tmpDir);
assert.strictEqual(report.passed, true,
`the writer's own deferred template text must be non-blocking to the reader: ${JSON.stringify(report.blockers)}`);
cleanup(tmpDir);
});
test('complete_session offers 999.x promotion of deferred follow-ups (#4546)', () => {
const block = stepBlock(verifyWork, 'complete_session', 'verify-work.md');
// Detection: the step must read the Deferred Follow-Ups section the
// process_response step writes (same section name, exact).
assert.match(block, /Deferred Follow-Ups/,
'complete_session must consult the Deferred Follow-Ups section');
// An OFFER (not silent auto-mutation): the literal [P]/[K] choice pair
// must exist — a bare prose 'offer' would satisfy nothing testable.
assert.match(block, /\[P\] Promote to ROADMAP\.md 999\.x backlog/,
'complete_session must present the [P] promote choice');
assert.match(block, /\[K\] Keep them in the UAT file only/,
'complete_session must present the [K] keep choice');
// The promoted entry reuses next.md's exact mechanism shape.
const nextWork = readWorkflow(NEXT_PATH);
const nextBlock = stepBlock(nextWork, 'prior_phase_completeness', 'next.md');
const nextShapeMarkers = ['### Phase 999.', '**Goal:**', '**Source phase:**', '**Deferred at:**'];
for (const marker of nextShapeMarkers) {
assert.ok(nextBlock.includes(marker),
`next.md prior_phase_completeness entry shape must contain ${marker} (fixture sanity)`);
assert.match(block, new RegExp(escapeRegex(marker)),
`complete_session promotion entry must reuse the next.md shape marker: ${marker}`);
}
// The deferral record is committed scoped to the roadmap.
assert.match(block, /--files[^\n]*ROADMAP\.md/,
'the promotion commit must be scoped to .planning/ROADMAP.md via --files');
});
});