* fix(#4186): anchored status vocabulary, record-session arg guard, recount pin Three defects from #4186: 1. normalizeStateStatus ran a first-match-wins SUBSTRING chain over the free-prose body Status field, so prose merely mentioning a status word was silently rewritten to a credible wrong token (a .planning/ path in Italian prose -> status: planning; verifica -> verifying; completezza -> completed). Recognition is now an ANCHORED whole-field match against a declared vocabulary (STATUS_EXACT_TOKENS + STATUS_ANCHORED_PATTERNS, state-document.cts) — case/whitespace-tolerant, branch-order artifacts preserved (Planning complete -> planning; Phase complete — ready for verification -> verifying). The recorded lenient fallback (#3873 row 26) stands: unrecognized prose passes through verbatim. Read-side consumers (W011, statusline) ride the same function. 2. The progress recount skew (stray *-SUMMARY.md inflating completed_plans) is already dead on next via #1988/PR #2016 (countMatchedSummaries pairs summaries to plans) — verified live and pinned with regression rows composed against the #4129/#4359 ratchet. 3. state record-session with no args executed and wrote STATE.md; it now errors like state update (stopped-at or resume-file required), handler- side so SDK callers are covered too. Four tests pinning the bare-call write are updated to the new contract. * fix(#4186): update status pins to the anchored vocabulary contract Bench round 1 follow-ups: - Legacy bare 'Milestone complete' kept as reader-side vocabulary (ADR-2207 removed the writers, not recognition of legacy files). - state.test pins updated: 'Paused at Plan 3' and round-trip 'Executing Plan 5' were pins of the substring guessing itself — the round-trip now uses the real handler form 'Executing Phase 5'. - record-session no-op/no-fields tests repurposed to the usage-error contract (CLI + SDK-level ExitError), byte-unchanged assertions kept. - statusline tests repinned: vocabulary values collapse to keywords; narratives render the documented first-word fallback instead of a guessed token. Hook doc comment updated to match. - docs-guard exempt baseline: state.test.cjs now cites docs/CLI-TOOLS.md. - docs/CLI-TOOLS.md: record-session signature notes the required flag. * fix(#4186): repair a dangling sentence in the schema docstring * test(#4186): bound the completed_plans scan regex (#2128 class) * chore(#4186): backfill changeset PR number --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -2236,3 +2236,147 @@ describe('#3642 — single-section leak controls and seam pins', () => {
|
||||
assert.strictEqual(roadmapParser.hasMilestoneSectioning(two), true, '>=2 predicate unchanged: two headings is sectioning');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #4186 — normalizeStateStatus must derive the `status` token by ANCHORED
|
||||
// matching against the declared status vocabulary (whole-field value,
|
||||
// case-insensitive, whitespace-collapsed), never by substring-scanning the
|
||||
// free-prose body Status field. Prose that merely MENTIONS a status word (a
|
||||
// `.planning/` path, Italian `verifica*`, `completezza`, `fasi complete`)
|
||||
// must pass through verbatim — the visible paragraph beats a valid, credible,
|
||||
// wrong token. The lenient fallback itself is a recorded contract
|
||||
// (state-md-schema.cts #3873 phase-3 row 26) and is preserved.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('#4186: normalizeStateStatus anchored status vocabulary — substring traps', () => {
|
||||
const { normalizeStateStatus } = require('../gsd-core/bin/lib/state-document.cjs');
|
||||
|
||||
// Row 1 — the failing-first regression: a `.planning/` path inside Italian
|
||||
// prose must NOT land on `planning` (the trigger word lives in the
|
||||
// DIRECTORY NAME; the reporter measured this exact counter-pair).
|
||||
test('row 1: a .planning/ path inside prose never yields `planning`', () => {
|
||||
assert.strictEqual(
|
||||
normalizeStateStatus('Lavoro sospeso, vedi .planning/STATE.md', null),
|
||||
'Lavoro sospeso, vedi .planning/STATE.md',
|
||||
);
|
||||
});
|
||||
|
||||
test('rows 2-5: Italian prose mentioning paths/verification/completeness passes through verbatim', () => {
|
||||
const prose = [
|
||||
'Esecuzione in corso su .planning/',
|
||||
'Fase 34 COMPLETA, VERIFICATA 8/8 e FUSA IN PRODUZIONE',
|
||||
'Aggiornato .planning/STATE.md dopo la verifica di fase',
|
||||
'Referto in .planning/phases/34, completezza ok',
|
||||
];
|
||||
for (const p of prose) {
|
||||
assert.strictEqual(normalizeStateStatus(p, null), p, `prose must pass through verbatim: ${p}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('row 6: Italian verifica-family words do not match the `verif` trigger', () => {
|
||||
for (const p of ['verifica', 'verificata', 'verifiche', 'ri-verifica']) {
|
||||
assert.strictEqual(normalizeStateStatus(p, null), p);
|
||||
}
|
||||
});
|
||||
|
||||
test('rows 7-8: `completezza` and `fasi complete` do not yield `completed`', () => {
|
||||
assert.strictEqual(normalizeStateStatus('riportata per completezza', null), 'riportata per completezza');
|
||||
assert.strictEqual(normalizeStateStatus('fasi complete', null), 'fasi complete');
|
||||
});
|
||||
|
||||
test('rows 9-10: control — prose with no trigger words was already verbatim and stays so', () => {
|
||||
const control = [
|
||||
'completata / completato / completo / completa / incompleta / completamente',
|
||||
'Lavoro sospeso in attesa del CEO',
|
||||
];
|
||||
for (const p of control) {
|
||||
assert.strictEqual(normalizeStateStatus(p, null), p);
|
||||
}
|
||||
});
|
||||
|
||||
test('row 11: English status words embedded in prose sentences are not rewrites either', () => {
|
||||
assert.strictEqual(normalizeStateStatus('Waiting on the planning department', null), 'Waiting on the planning department');
|
||||
assert.strictEqual(normalizeStateStatus('Notes done, see log', null), 'Notes done, see log');
|
||||
assert.strictEqual(
|
||||
normalizeStateStatus('Discussed the verif steps with QA, paused decision', null),
|
||||
'Discussed the verif steps with QA, paused decision',
|
||||
);
|
||||
});
|
||||
|
||||
test('row 12 (existing #3873 row-26 contract): unrecognized text passes through unchanged', () => {
|
||||
assert.strictEqual(
|
||||
normalizeStateStatus('totally-unrecognized-status-text', null),
|
||||
'totally-unrecognized-status-text',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#4186: normalizeStateStatus anchored status vocabulary — documented vocabulary still normalizes', () => {
|
||||
const { normalizeStateStatus } = require('../gsd-core/bin/lib/state-document.cjs');
|
||||
|
||||
test('rows 13-15: variable handler-written phase statuses (case/whitespace variants)', () => {
|
||||
assert.strictEqual(normalizeStateStatus('Executing Phase 5', null), 'executing');
|
||||
assert.strictEqual(normalizeStateStatus('EXECUTING PHASE 5', null), 'executing');
|
||||
assert.strictEqual(normalizeStateStatus('executing phase 46', null), 'executing');
|
||||
assert.strictEqual(normalizeStateStatus('Planning Phase 3', null), 'planning');
|
||||
assert.strictEqual(normalizeStateStatus('Verifying Phase 2', null), 'verifying');
|
||||
});
|
||||
|
||||
test('row 16: `Phase N complete` still lands on `completed` (composes with the #3578 demote guard)', () => {
|
||||
assert.strictEqual(normalizeStateStatus('Phase 12 complete', null), 'completed');
|
||||
assert.strictEqual(normalizeStateStatus('Phase 3A complete', null), 'completed');
|
||||
});
|
||||
|
||||
test('rows 17-18: ADR-2207 lifecycle terminal statuses (CONTEXT.md:94 recorded contract)', () => {
|
||||
assert.strictEqual(normalizeStateStatus('All phases complete', null), 'completed');
|
||||
assert.strictEqual(normalizeStateStatus('ALL PHASES COMPLETE', null), 'completed');
|
||||
assert.strictEqual(normalizeStateStatus('v1.0 milestone complete', null), 'completed');
|
||||
assert.strictEqual(normalizeStateStatus('1.0 milestone complete', null), 'completed');
|
||||
});
|
||||
|
||||
test('rows 19-25: fixed-form handler defaults, including case and whitespace variants', () => {
|
||||
assert.strictEqual(normalizeStateStatus('Ready to plan', null), 'planning');
|
||||
assert.strictEqual(normalizeStateStatus('Ready to execute', null), 'executing');
|
||||
assert.strictEqual(normalizeStateStatus('In progress', null), 'executing');
|
||||
assert.strictEqual(normalizeStateStatus('In progress', null), 'executing');
|
||||
assert.strictEqual(normalizeStateStatus(' Paused ', null), 'paused');
|
||||
assert.strictEqual(normalizeStateStatus('Paused', null), 'paused');
|
||||
assert.strictEqual(normalizeStateStatus('Stopped', null), 'paused');
|
||||
assert.strictEqual(normalizeStateStatus('stopped', null), 'paused');
|
||||
assert.strictEqual(normalizeStateStatus('Discussing', null), 'discussing');
|
||||
assert.strictEqual(normalizeStateStatus('Verifying', null), 'verifying');
|
||||
assert.strictEqual(normalizeStateStatus('Completed', null), 'completed');
|
||||
assert.strictEqual(normalizeStateStatus('Done', null), 'completed');
|
||||
assert.strictEqual(normalizeStateStatus('done', null), 'completed');
|
||||
assert.strictEqual(normalizeStateStatus('Complete', null), 'completed');
|
||||
assert.strictEqual(normalizeStateStatus('Complete ✓', null), 'completed');
|
||||
assert.strictEqual(normalizeStateStatus('Complete✔', null), 'completed');
|
||||
});
|
||||
|
||||
test('rows 26-27: branch-order artifacts are preserved byte-for-behaviour', () => {
|
||||
// `verif` outranks `complete` (pinned by tests/state.test.cjs's
|
||||
// advance-plan case-5 comment); `planning` outranks `complete`.
|
||||
assert.strictEqual(normalizeStateStatus('Phase complete — ready for verification', null), 'verifying');
|
||||
assert.strictEqual(normalizeStateStatus('Planning complete', null), 'planning');
|
||||
});
|
||||
|
||||
test('rows 29-30: unknown fallback and the pausedAt force are unchanged', () => {
|
||||
assert.strictEqual(normalizeStateStatus(null, null), 'unknown');
|
||||
assert.strictEqual(normalizeStateStatus('', null), 'unknown');
|
||||
assert.strictEqual(normalizeStateStatus('Executing Phase 5', '2026-09-01'), 'paused');
|
||||
});
|
||||
|
||||
test('row 31: statuses with no branch today stay verbatim', () => {
|
||||
assert.strictEqual(normalizeStateStatus('Awaiting next milestone', null), 'Awaiting next milestone');
|
||||
assert.strictEqual(normalizeStateStatus('Defining requirements', null), 'Defining requirements');
|
||||
assert.strictEqual(normalizeStateStatus('Active', null), 'Active');
|
||||
});
|
||||
|
||||
test('row 32: trailing prose after a vocabulary form is executor-authored and passes through', () => {
|
||||
// Same discipline as #1070's KNOWN_STATUS_PATTERNS: "Complete but needs
|
||||
// manual QA" is NOT a template default. The anchored vocabulary only
|
||||
// recognizes the whole-field value.
|
||||
assert.strictEqual(normalizeStateStatus('Executing Phase 5 — final stretch', null), 'Executing Phase 5 — final stretch');
|
||||
assert.strictEqual(normalizeStateStatus('Complete but needs manual QA', null), 'Complete but needs manual QA');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user