* test(#3696): failing-first coverage for the last_activity invariant and --strict exit status * fix(#3696): report the last_activity invariant and make the verdict gateable with --strict * fix(#3696): agree with the real reader on last_activity, and stop reporting structure as truncation * chore(#3696): backfill changeset PR number --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -24,6 +24,7 @@ const {
|
||||
PERMANENTLY_INERT_CODES,
|
||||
STATE_VALIDATE_TEST_FILE,
|
||||
STATE_VALIDATE_CODES,
|
||||
discoverStateValidateCodes,
|
||||
} = guard;
|
||||
|
||||
const FAKE_SEVERITY = Object.freeze({ ERROR: 'error', WARNING: 'warning', INFO: 'info' });
|
||||
@@ -263,14 +264,66 @@ describe('checkFixtureProofInvariant (S0NN pass, #3310)', () => {
|
||||
assert.deepEqual(uncovered, []);
|
||||
});
|
||||
|
||||
test('all 7 real STATE_VALIDATE_CODES (S001-S007) are fixture-covered against the real tests/state.test.cjs', () => {
|
||||
assert.ok(fs.existsSync(STATE_VALIDATE_TEST_FILE), 'tests/state.test.cjs must exist');
|
||||
assert.deepEqual(STATE_VALIDATE_CODES, ['S001', 'S002', 'S003', 'S004', 'S005', 'S006', 'S007']);
|
||||
test('discoverStateValidateCodes reads the code set off stateDiagnostic() call sites', () => {
|
||||
// #3696: the guard's S0NN list used to be a frozen literal, and this test
|
||||
// used to be a second frozen literal mirroring it. Two copies of an
|
||||
// allowlist prove only that they agree with each other — which is exactly
|
||||
// what let S008/S009 be added while the guard reported a green
|
||||
// "7 code(s), all fixture-covered" (ADR-3180 Decision 4(a): a zero it did
|
||||
// not earn). The behaviour under test is now the DISCOVERY RULE, driven
|
||||
// against fixture source text rather than the real module.
|
||||
const fixture = [
|
||||
"warnings.push(stateDiagnostic('S002', SEVERITY.WARNING, 'a', 'b'));",
|
||||
'warnings.push(stateDiagnostic(',
|
||||
" 'S001',",
|
||||
' SEVERITY.ERROR,',
|
||||
'));',
|
||||
'warnings.push(stateDiagnostic(`S002`, SEVERITY.WARNING, "dup", "b"));',
|
||||
"// stateDiagnostic('S404', ...) in a comment still counts — this is a",
|
||||
'// regex guard, and over-inclusion only ever demands MORE fixture cover.',
|
||||
// Review round 2: a space before `(` must not drop the call site. The
|
||||
// fail-closed check only fires on a FULLY empty result, so a PARTIAL
|
||||
// miss escaped the fixture-proof check silently.
|
||||
"warnings.push(stateDiagnostic ('S010', SEVERITY.WARNING, 'spaced', 'b'));",
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(
|
||||
discoverStateValidateCodes(fixture, 'fixture'),
|
||||
['S001', 'S002', 'S010', 'S404'],
|
||||
'codes are deduplicated and sorted, across quoting styles, line breaks, and a space before the paren',
|
||||
);
|
||||
});
|
||||
|
||||
test('discoverStateValidateCodes fails closed when the call shape changes', () => {
|
||||
// The branch that matters. If stateDiagnostic() is renamed, the honest
|
||||
// answer is "I can no longer see the codes", never "there are none, and they
|
||||
// are all covered".
|
||||
assert.throws(
|
||||
() => discoverStateValidateCodes('renamedHelper("S001", SEVERITY.WARNING);', 'fixture'),
|
||||
(err) => /found no stateDiagnostic\(\) call sites in fixture/.test(String(err.message)),
|
||||
'an unrecognised call shape must raise, not return []',
|
||||
);
|
||||
});
|
||||
|
||||
test('every code cmdStateValidate can emit is fixture-covered against the real state test file', () => {
|
||||
assert.ok(fs.existsSync(STATE_VALIDATE_TEST_FILE), 'the state test file must exist');
|
||||
assert.ok(STATE_VALIDATE_CODES.length > 0, 'the real discovery pass must find at least one code');
|
||||
|
||||
const rules = STATE_VALIDATE_CODES.map((code) => ({ code }));
|
||||
const { uncovered } = checkFixtureProofInvariant(rules, [STATE_VALIDATE_TEST_FILE], new Map());
|
||||
|
||||
assert.deepEqual(uncovered, []);
|
||||
assert.deepEqual(uncovered, [], `uncovered S0NN codes: ${uncovered.join(', ')}`);
|
||||
});
|
||||
|
||||
test('the fixture-proof pass still fails on a code with no test (the guard can actually fail)', () => {
|
||||
// A guard nobody has watched fail is a guard nobody knows works.
|
||||
const { uncovered } = checkFixtureProofInvariant(
|
||||
[...STATE_VALIDATE_CODES.map((code) => ({ code })), { code: 'S999' }],
|
||||
[STATE_VALIDATE_TEST_FILE],
|
||||
new Map(),
|
||||
);
|
||||
|
||||
assert.deepEqual(uncovered, ['S999']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4820,6 +4820,506 @@ describe('#3310 state validate — S0NN coded diagnostics', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #3696 — the `last_activity` invariant is CHECKABLE, and `--strict` makes it
|
||||
// gateable.
|
||||
//
|
||||
// Before this, a STATE.md whose `Last activity:` value no reader can parse
|
||||
// validated as `{valid:true, warnings:[], scope:"complete"}` — the scan ran
|
||||
// fully and had nothing to say, because `cmdStateValidate` never read the field
|
||||
// at all. And `valid:false` still exited 0, so no CI step or git hook could gate
|
||||
// on state correctness without parsing JSON.
|
||||
//
|
||||
// S008 = the value is present but does not name a real calendar date.
|
||||
// S009 = the description was truncated by a line wrap.
|
||||
//
|
||||
// Calendar validity (not merely `\d{4}-\d{2}-\d{2}` shape) is the invariant on
|
||||
// purpose: `smart-entry`'s reader already rejects `2026-02-30` via
|
||||
// `isRealCalendarDate` (ADR-227 — validate shape AND value). Accepting it here
|
||||
// would leave the two surfaces disagreeing about whether the file is usable,
|
||||
// which is the defect #3696 opens with, not a fix for it.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('#3696 state validate — last_activity invariant (S008/S009) and --strict', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createFixture();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
// A document that validates CLEAN: phase resolves, phase dir exists, plan
|
||||
// count agrees, no verification file. Extra body lines are appended verbatim
|
||||
// so each case differs ONLY in the last_activity shape under test.
|
||||
function writeCleanState(extraBodyLines = [], opts = {}) {
|
||||
const eol = opts.crlf ? '\r\n' : '\n';
|
||||
const head = opts.frontmatter ? ['---', ...opts.frontmatter, '---', ''] : [];
|
||||
const lines = [
|
||||
'# Project State',
|
||||
'',
|
||||
'**Status:** Executing Phase 1',
|
||||
'**Current Phase:** 1',
|
||||
'**Total Plans in Phase:** 1',
|
||||
...extraBodyLines,
|
||||
'',
|
||||
];
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), [...head, ...lines].join(eol));
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
||||
}
|
||||
|
||||
function validate(args = 'state validate') {
|
||||
const result = runGsdTools(args, tmpDir);
|
||||
return { result, output: JSON.parse(result.output) };
|
||||
}
|
||||
|
||||
// ── S008: the value must name a real calendar date ──────────────────────────
|
||||
|
||||
test('S008: an unparseable last_activity is reported instead of validating clean', () => {
|
||||
writeCleanState(['Last activity: not-a-date — broke the date on purpose']);
|
||||
|
||||
const { output } = validate();
|
||||
assert.strictEqual(output.scope, 'complete', 'the scan must have actually run — this is not a degraded-scope excuse');
|
||||
assert.strictEqual(output.valid, false, 'an unreadable last_activity must not validate clean');
|
||||
const s008 = findWarning(output, 'S008');
|
||||
assert.ok(s008, `S008 must fire for an unparseable last_activity; got: ${JSON.stringify(output.warnings)}`);
|
||||
assert.strictEqual(s008.severity, SEVERITY.WARNING);
|
||||
assert.strictEqual(s008.remedy.action, 'advise');
|
||||
assert.match(s008.message, /last activity/i);
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('S008: a well-formed last_activity with a description stays clean', () => {
|
||||
writeCleanState(['Last activity: 2026-08-19 — did a thing']);
|
||||
|
||||
const { output } = validate();
|
||||
assert.strictEqual(output.valid, true, `well-formed control must stay clean; got: ${JSON.stringify(output.warnings)}`);
|
||||
assert.deepStrictEqual(output.warnings, []);
|
||||
});
|
||||
|
||||
test('S008: a bare well-formed date with no description stays clean', () => {
|
||||
// parseProseLastActivityField returns description:null for this shape; it is
|
||||
// a legitimate value, not a truncation.
|
||||
writeCleanState(['Last activity: 2026-08-19']);
|
||||
|
||||
const { output } = validate();
|
||||
assert.ok(!findWarning(output, 'S008'), `a bare date is a valid shape; got: ${JSON.stringify(output.warnings)}`);
|
||||
assert.ok(!findWarning(output, 'S009'), 'a bare date is not a truncated description');
|
||||
});
|
||||
|
||||
test('S008: an absent last_activity is not a defect (a fresh project must stay clean)', () => {
|
||||
// The single most important negative case: a freshly-initialized STATE.md
|
||||
// has no activity yet. Flagging absence would fire on every new project.
|
||||
writeCleanState([]);
|
||||
|
||||
const { output } = validate();
|
||||
assert.strictEqual(output.valid, true, `absence is not drift; got: ${JSON.stringify(output.warnings)}`);
|
||||
assert.ok(!findWarning(output, 'S008'));
|
||||
});
|
||||
|
||||
test('S008: a frontmatter-only last_activity is validated through the same owner', () => {
|
||||
// Routes the read through stateFieldValue's frontmatter rung — the same owner
|
||||
// cmdStateValidate already uses for status/total_plans_in_phase, so the
|
||||
// fm-only shape is not a blind spot (ADR-3180 §7.7).
|
||||
writeCleanState([], { frontmatter: ['current_phase: 1', 'status: executing', 'last_activity: not-a-date'] });
|
||||
|
||||
const { output } = validate();
|
||||
const s008 = findWarning(output, 'S008');
|
||||
assert.ok(s008, `S008 must fire for a frontmatter-only last_activity; got: ${JSON.stringify(output.warnings)}`);
|
||||
});
|
||||
|
||||
test('S008: an ASCII-hyphen separator is accepted like an em dash', () => {
|
||||
writeCleanState(['Last activity: 2026-08-19 - did a thing']);
|
||||
|
||||
const { output } = validate();
|
||||
assert.ok(!findWarning(output, 'S008'), `the owner regex accepts an ASCII hyphen; got: ${JSON.stringify(output.warnings)}`);
|
||||
});
|
||||
|
||||
test('S008: a shape-valid but calendar-impossible date is rejected (the two surfaces must not disagree)', () => {
|
||||
// 2026-02-30 matches \d{4}-\d{2}-\d{2} but does not exist. smart-entry's
|
||||
// isRealCalendarDate already rejects it (ADR-227). If state validate accepted
|
||||
// it, the two readers would still disagree about whether the file is usable —
|
||||
// the exact complaint #3696 opens with.
|
||||
writeCleanState(['Last activity: 2026-02-30 — a day that does not exist']);
|
||||
|
||||
const { output } = validate();
|
||||
const s008 = findWarning(output, 'S008');
|
||||
assert.ok(s008, `S008 must fire for an impossible calendar date; got: ${JSON.stringify(output.warnings)}`);
|
||||
});
|
||||
|
||||
test('S008: month and day boundaries fire on limit-1 and limit+1 only', () => {
|
||||
const cases = [
|
||||
['2026-00-15', true], // month limit-1
|
||||
['2026-01-15', false], // month limit (low)
|
||||
['2026-12-15', false], // month limit (high)
|
||||
['2026-13-15', true], // month limit+1
|
||||
['2026-01-00', true], // day limit-1
|
||||
['2026-01-01', false], // day limit (low)
|
||||
['2026-01-31', false], // day limit (high, 31-day month)
|
||||
['2026-01-32', true], // day limit+1
|
||||
];
|
||||
for (const [value, mustFire] of cases) {
|
||||
writeCleanState([`Last activity: ${value} — boundary probe`]);
|
||||
const { output } = validate();
|
||||
const fired = Boolean(findWarning(output, 'S008'));
|
||||
assert.strictEqual(fired, mustFire, `${value}: expected S008 fired=${mustFire}, got ${fired} (${JSON.stringify(output.warnings)})`);
|
||||
}
|
||||
});
|
||||
|
||||
test('isRealCalendarDate: state validate and smart-entry agree on calendar validity', () => {
|
||||
// Parity assertion (CLAUDE.md "Generative Fix Divergence"): the predicate has
|
||||
// ONE owner and both surfaces import it. This fails the moment a second copy
|
||||
// appears and drifts.
|
||||
const smartEntry = require('../gsd-core/bin/lib/smart-entry.cjs');
|
||||
assert.strictEqual(
|
||||
typeof stateDocument.isRealCalendarDate,
|
||||
'function',
|
||||
'state-document.cjs must own isRealCalendarDate',
|
||||
);
|
||||
for (const [y, m, d, expected] of [
|
||||
[2026, 2, 30, false],
|
||||
[2026, 2, 28, true],
|
||||
[2024, 2, 29, true],
|
||||
[2026, 2, 29, false],
|
||||
[2026, 13, 1, false],
|
||||
[2026, 12, 31, true],
|
||||
]) {
|
||||
assert.strictEqual(
|
||||
stateDocument.isRealCalendarDate(y, m, d),
|
||||
expected,
|
||||
`owner disagrees on ${y}-${m}-${d}`,
|
||||
);
|
||||
}
|
||||
assert.ok(
|
||||
!Object.prototype.hasOwnProperty.call(smartEntry, 'isRealCalendarDate')
|
||||
|| smartEntry.isRealCalendarDate === stateDocument.isRealCalendarDate,
|
||||
'smart-entry must reuse the owner, never re-declare its own copy',
|
||||
);
|
||||
});
|
||||
|
||||
test('property: no real calendar date ever raises S008', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.date({ min: new Date(Date.UTC(2000, 0, 1)), max: new Date(Date.UTC(2099, 11, 31)) }),
|
||||
(d) => {
|
||||
const iso = d.toISOString().slice(0, 10);
|
||||
// Suffix VARIES: a dashed description, a bare date, and a
|
||||
// separator-less description. A fixed `— probe` suffix is what
|
||||
// let the round-2 false positive through this property.
|
||||
const suffix = ['', ' — property probe', ' property probe'][d.getUTCDate() % 3];
|
||||
writeCleanState([`Last activity: ${iso}${suffix}`]);
|
||||
const { output } = validate();
|
||||
assert.ok(
|
||||
!findWarning(output, 'S008'),
|
||||
`S008 must never fire for the real calendar date ${iso}${suffix}; got: ${JSON.stringify(output.warnings)}`,
|
||||
);
|
||||
},
|
||||
),
|
||||
{ numRuns: 12 },
|
||||
);
|
||||
});
|
||||
|
||||
// ── S009: a wrapped description must not vanish ─────────────────────────────
|
||||
|
||||
test('S009: a wrapped last_activity description is reported instead of silently truncated', () => {
|
||||
writeCleanState([
|
||||
'Last activity: 2026-08-19 — Project initialized from ingest (SPEC-pal-restore.md); PROJECT.md,',
|
||||
'REQUIREMENTS.md, ROADMAP.md written',
|
||||
]);
|
||||
|
||||
const { output } = validate();
|
||||
assert.strictEqual(output.valid, false, 'a truncated description must not validate clean');
|
||||
const s009 = findWarning(output, 'S009');
|
||||
assert.ok(s009, `S009 must fire for a wrapped description; got: ${JSON.stringify(output.warnings)}`);
|
||||
assert.strictEqual(s009.severity, SEVERITY.WARNING);
|
||||
assert.strictEqual(s009.remedy.action, 'advise');
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('S009: a blank line after last_activity is structure, not a wrap', () => {
|
||||
writeCleanState(['Last activity: 2026-08-19 — done', '', 'Some later prose.']);
|
||||
|
||||
const { output } = validate();
|
||||
assert.ok(!findWarning(output, 'S009'), `a blank line ends the field; got: ${JSON.stringify(output.warnings)}`);
|
||||
});
|
||||
|
||||
test('S009: a following field line is structure, not a wrap', () => {
|
||||
writeCleanState(['Last activity: 2026-08-19 — done', 'Blockers: none']);
|
||||
assert.ok(!findWarning(validate().output, 'S009'), 'a sibling field is not a continuation');
|
||||
|
||||
writeCleanState(['Last activity: 2026-08-19 — done', '**Blockers:** none']);
|
||||
assert.ok(!findWarning(validate().output, 'S009'), 'a bold sibling field is not a continuation');
|
||||
});
|
||||
|
||||
test('S009: a following heading is structure, not a wrap', () => {
|
||||
writeCleanState(['Last activity: 2026-08-19 — done', '## Next Up']);
|
||||
|
||||
assert.ok(!findWarning(validate().output, 'S009'));
|
||||
});
|
||||
|
||||
test('S009: a following list marker is structure, not a wrap', () => {
|
||||
for (const marker of ['- item', '* item', '+ item', '1. item', '2) item']) {
|
||||
writeCleanState(['Last activity: 2026-08-19 — done', marker]);
|
||||
const { output } = validate();
|
||||
assert.ok(!findWarning(output, 'S009'), `"${marker}" is a list, not a continuation; got: ${JSON.stringify(output.warnings)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('S009: a following table row or horizontal rule is structure, not a wrap', () => {
|
||||
// The `---` case is the horizontal-rule trap: a check that fires on
|
||||
// legitimate Markdown structure is worse than no check at all.
|
||||
for (const line of ['| Field | Value |', '---', '***', '___', '> quoted', '```']) {
|
||||
writeCleanState(['Last activity: 2026-08-19 — done', line]);
|
||||
const { output } = validate();
|
||||
assert.ok(!findWarning(output, 'S009'), `"${line}" is structure, not a continuation; got: ${JSON.stringify(output.warnings)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('S009: last_activity as the final line with no trailing newline does not fire', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
'# Project State\n\n**Status:** Executing Phase 1\n**Current Phase:** 1\n**Total Plans in Phase:** 1\nLast activity: 2026-08-19 — done',
|
||||
);
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
||||
|
||||
const { output } = validate();
|
||||
assert.ok(!findWarning(output, 'S009'), `end-of-file is not a continuation; got: ${JSON.stringify(output.warnings)}`);
|
||||
});
|
||||
|
||||
test('S009: CRLF line endings produce the same verdict as LF', () => {
|
||||
writeCleanState([
|
||||
'Last activity: 2026-08-19 — Project initialized from ingest; PROJECT.md,',
|
||||
'REQUIREMENTS.md written',
|
||||
], { crlf: true });
|
||||
assert.ok(findWarning(validate().output, 'S009'), 'a CRLF wrap must fire exactly like LF');
|
||||
|
||||
writeCleanState(['Last activity: 2026-08-19 — done', 'Blockers: none'], { crlf: true });
|
||||
assert.ok(!findWarning(validate().output, 'S009'), 'a CRLF sibling field must not fire');
|
||||
});
|
||||
|
||||
|
||||
// ── Review round 2 — cross-surface agreement and structure false positives ──
|
||||
|
||||
test('S008: a value the real reader parses is not reported unreadable (no separator before the description)', () => {
|
||||
// The first cut asserted through parseProseLastActivityField, whose grammar
|
||||
// is fully anchored and REQUIRES a dash separator. smart-entry's
|
||||
// parseActivityTimestamp needs only a leading date, so this value parses
|
||||
// fine there while S008 called it unreadable — the same
|
||||
// two-surfaces-disagree defect #3696 exists to close, pointing the other
|
||||
// way. Asserted against the real reader, not against a restatement of it.
|
||||
const smartEntry = require('../gsd-core/bin/lib/smart-entry.cjs');
|
||||
const value = '2026-08-24 Shipped feature X without a dash separator';
|
||||
|
||||
if (typeof smartEntry.parseActivityTimestamp === 'function') {
|
||||
assert.ok(
|
||||
Number.isFinite(smartEntry.parseActivityTimestamp(value)),
|
||||
'precondition: the real reader must parse this value',
|
||||
);
|
||||
}
|
||||
|
||||
writeCleanState([`Last activity: ${value}`]);
|
||||
const { output } = validate();
|
||||
assert.ok(
|
||||
!findWarning(output, 'S008'),
|
||||
`S008 must not fire on a value the reader parses; got: ${JSON.stringify(output.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('S008: an ISO date-time prefix is accepted', () => {
|
||||
writeCleanState(['Last activity: 2026-08-24T09:00:00Z shipped it']);
|
||||
|
||||
assert.ok(!findWarning(validate().output, 'S008'));
|
||||
});
|
||||
|
||||
test('S008: a date-shaped run with no separators is still rejected', () => {
|
||||
// Boundary on the leading-token rule itself: `20260824` is eight digits, not
|
||||
// a date, and must not be admitted just because it starts with four.
|
||||
writeCleanState(['Last activity: 20260824 shipped it']);
|
||||
|
||||
assert.ok(findWarning(validate().output, 'S008'), '`20260824` is not a leading ISO date token');
|
||||
});
|
||||
|
||||
test('S009: a setext heading underneath last_activity is structure, not a wrap', () => {
|
||||
// Both underline styles. `===` was missed entirely by the first cut, and
|
||||
// `---` was missed differently: the rule stopped AT the underline, having
|
||||
// already swallowed the heading TITLE above it as prose. Detection has to
|
||||
// look ahead one line, so both are pinned here.
|
||||
for (const underline of ['===', '---', '======', '- - -'.replace(/ /g, '')]) {
|
||||
writeCleanState(['Last activity: 2026-08-19 — done', 'My Heading', underline]);
|
||||
const { output } = validate();
|
||||
assert.ok(
|
||||
!findWarning(output, 'S009'),
|
||||
`a setext heading underlined with "${underline}" is structure; got: ${JSON.stringify(output.warnings)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('S009: an indented code block is structure, not a wrap', () => {
|
||||
for (const indented of [' const x = 1;', '\tconst x = 1;']) {
|
||||
writeCleanState(['Last activity: 2026-08-19 — done', indented]);
|
||||
const { output } = validate();
|
||||
assert.ok(
|
||||
!findWarning(output, 'S009'),
|
||||
`an indented code block is structure; got: ${JSON.stringify(output.warnings)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('S009: an HTML block is structure, not a wrap', () => {
|
||||
writeCleanState(['Last activity: 2026-08-19 — done', '<div>a note</div>']);
|
||||
|
||||
assert.ok(!findWarning(validate().output, 'S009'));
|
||||
});
|
||||
|
||||
|
||||
test('S009: a frontmatter-sourced last_activity is not judged by a stale wrapped body line', () => {
|
||||
// The ladder prefers the frontmatter scalar, so when frontmatter supplies
|
||||
// last_activity NOBODY reads the body line. Scanning it anyway reported a
|
||||
// dropped remainder that no reader consumes — and under --strict exited 1 —
|
||||
// on a document whose actual last_activity is entirely valid.
|
||||
writeCleanState(
|
||||
[
|
||||
'Last activity: 2026-01-01 — a stale body line that',
|
||||
'wraps onto a second line',
|
||||
],
|
||||
{ frontmatter: ['current_phase: 1', 'status: executing', 'last_activity: 2026-08-19'] },
|
||||
);
|
||||
|
||||
const { result, output } = validate('state validate --strict');
|
||||
assert.ok(
|
||||
!findWarning(output, 'S009'),
|
||||
`the body line is shadowed by frontmatter and must not be judged; got: ${JSON.stringify(output.warnings)}`,
|
||||
);
|
||||
assert.strictEqual(output.valid, true);
|
||||
assert.strictEqual(result.exitCode, 0, '--strict must not fail a document whose last_activity is valid');
|
||||
});
|
||||
|
||||
test('S008: a frontmatter-sourced last_activity is still judged on its own value', () => {
|
||||
// The complement of the test above: shadowing must suppress the BODY scan,
|
||||
// never the check itself.
|
||||
writeCleanState(
|
||||
['Last activity: 2026-08-19 — a clean body line'],
|
||||
{ frontmatter: ['current_phase: 1', 'status: executing', 'last_activity: not-a-date'] },
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
findWarning(validate().output, 'S008'),
|
||||
'the frontmatter value is the one every reader uses, so it is the one that must be checked',
|
||||
);
|
||||
});
|
||||
|
||||
test('S008: a last_activity line with only whitespace reads as not-yet-filled-in, not as drift', () => {
|
||||
// stateExtractField's `[ \t]*(.+)` backtracks to hand back a single space,
|
||||
// so the value arrives as '' — non-null, and it used to reach S008 and
|
||||
// report the empty string back at the reader.
|
||||
writeCleanState(['Last activity: ']);
|
||||
|
||||
const { output } = validate();
|
||||
assert.ok(
|
||||
!findWarning(output, 'S008'),
|
||||
`an empty value is indistinguishable from absence; got: ${JSON.stringify(output.warnings)}`,
|
||||
);
|
||||
assert.strictEqual(output.valid, true);
|
||||
});
|
||||
|
||||
|
||||
test('S008: the SHIPPED state template validates clean (its last_activity is an unfilled placeholder)', () => {
|
||||
// templates/state.md:35 ships `Last activity: [YYYY-MM-DD] — [What happened]`,
|
||||
// so this is the state of EVERY freshly-initialized project until something
|
||||
// records activity. The first cut of S008 spared only the ABSENT form and
|
||||
// fired on the shipped template itself — caught by the pre-existing
|
||||
// "template-equivalent phase identities remain clean without disk drift"
|
||||
// test. This pins the same invariant from the S008 side, where the
|
||||
// regression would actually be introduced.
|
||||
const stateContent = readShippedStateTemplateBody([
|
||||
['status: planning', ['current_phase: 2', 'status: planning'].join('\n')],
|
||||
['Phase: [X] of [Y] ([Phase name])', 'Phase: 02 of 2 (State Validation Drift Diagnostics)'],
|
||||
['Status: [Ready to plan / Planning / Ready to execute / In progress / Phase complete]', 'Status: Planning'],
|
||||
]);
|
||||
assert.match(
|
||||
stateContent,
|
||||
/Last activity: \[YYYY-MM-DD\]/,
|
||||
'precondition: the shipped template must still carry the placeholder this test is about',
|
||||
);
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent);
|
||||
fs.mkdirSync(
|
||||
path.join(tmpDir, '.planning', 'phases', '02-state-validation-drift-diagnostics'),
|
||||
{ recursive: true },
|
||||
);
|
||||
|
||||
const { output } = validate();
|
||||
assert.strictEqual(output.valid, true, `the shipped template must validate clean; got: ${JSON.stringify(output.warnings)}`);
|
||||
assert.deepStrictEqual(output.warnings, []);
|
||||
});
|
||||
|
||||
test('S008: a bracket placeholder only counts as unfilled at the START of the value', () => {
|
||||
// Guard against the over-broad reading. A real description that cites a
|
||||
// bracketed reference is a filled-in value, and its date must still be
|
||||
// checked — otherwise the placeholder rule silently swallows genuine drift.
|
||||
writeCleanState(['Last activity: not-a-date — see [#123] for context']);
|
||||
|
||||
assert.ok(
|
||||
findWarning(validate().output, 'S008'),
|
||||
'a bracket later in the value does not make the value unfilled',
|
||||
);
|
||||
});
|
||||
|
||||
// ── --strict: the exit status becomes gateable, opt-in only ─────────────────
|
||||
|
||||
test('--strict: a document with warnings exits non-zero', () => {
|
||||
writeCleanState(['Last activity: not-a-date — broken']);
|
||||
|
||||
const { result, output } = validate('state validate --strict');
|
||||
assert.strictEqual(output.valid, false);
|
||||
assert.strictEqual(result.exitCode, 1, 'a CI step must be able to gate on the exit status');
|
||||
});
|
||||
|
||||
test('--strict: a clean document still exits zero', () => {
|
||||
writeCleanState(['Last activity: 2026-08-19 — done']);
|
||||
|
||||
const { result, output } = validate('state validate --strict');
|
||||
assert.strictEqual(output.valid, true);
|
||||
assert.strictEqual(result.exitCode, 0);
|
||||
});
|
||||
|
||||
test('--strict: the default exit status is unchanged when the flag is absent', () => {
|
||||
// Hyrum's Law guard (ADR-3180 Decision 3): state validate's exit status is
|
||||
// observable behaviour reaching downstream consumers that cannot be
|
||||
// enumerated. Flipping the DEFAULT would break every script that runs it
|
||||
// unconditionally, so the new behaviour is opt-in — and this test fails if
|
||||
// anyone later "simplifies" it into the default.
|
||||
writeCleanState(['Last activity: not-a-date — broken']);
|
||||
|
||||
const { result, output } = validate();
|
||||
assert.strictEqual(output.valid, false);
|
||||
assert.strictEqual(result.exitCode, 0, 'the default exit status must NOT change');
|
||||
});
|
||||
|
||||
test('--strict: the S001 early-return path also exits non-zero', () => {
|
||||
// S001 returns early from its own output(...) call; a fix that only set the
|
||||
// exit code at the end of the function would miss this branch.
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), Buffer.from('# Project State\0corrupt'));
|
||||
|
||||
const { result, output } = validate('state validate --strict');
|
||||
assert.strictEqual(output.valid, false);
|
||||
assert.strictEqual(result.exitCode, 1);
|
||||
});
|
||||
|
||||
test('--strict: a missing STATE.md exits non-zero', () => {
|
||||
// createFixture() makes .planning/ but no STATE.md — the
|
||||
// {error:'STATE.md not found'} pre-check shape, a third early return.
|
||||
const { result, output } = validate('state validate --strict');
|
||||
assert.ok(output.error, 'the not-found shape is unchanged');
|
||||
assert.strictEqual(result.exitCode, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #3187 (ADR-3180 §7.7) — matrix section B: `state validate`'s scope field,
|
||||
// including the #3162 headline regression and #1255 frontmatter shadowing.
|
||||
|
||||
Reference in New Issue
Block a user