* 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:
@@ -33,6 +33,10 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { collectSection } from './markdown-sectionizer.cjs';
|
||||
// #3696: the calendar-validity predicate moved to the STATE.md document module
|
||||
// so `state validate` can assert the same `last_activity` invariant this reader
|
||||
// already enforces (ADR-227). Two copies would let the two surfaces disagree
|
||||
// about whether a STATE.md is usable — which is the defect #3696 reports.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import ioMod = require('./io.cjs');
|
||||
const { output } = ioMod;
|
||||
@@ -229,27 +233,6 @@ const ISO_LEADING_RE =
|
||||
*/
|
||||
const ZONE_DESIGNATOR_RE = /^\s*[A-Z]{2,5}(?![A-Za-z])/;
|
||||
|
||||
/**
|
||||
* True only when y/m/d name a date that actually exists on the calendar.
|
||||
*
|
||||
* `Date.parse` validates shape but not value: it rolls an out-of-range day
|
||||
* FORWARD rather than rejecting it (`2026-02-30` -> `2026-03-02`,
|
||||
* `2026-04-31` -> `2026-05-01`). Shape-only validation would therefore
|
||||
* propagate a different, wrong instant instead of failing safe — precisely
|
||||
* what ADR-227 ("validate shape AND value; on failure of either layer coerce
|
||||
* to the contract's safe default, never propagate") exists to prevent. A
|
||||
* round-trip through Date.UTC detects the rollover: any component the
|
||||
* constructor normalised comes back changed.
|
||||
*/
|
||||
function isRealCalendarDate(year: number, month: number, day: number): boolean {
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) return false;
|
||||
const probe = new Date(Date.UTC(year, month - 1, day));
|
||||
return (
|
||||
probe.getUTCFullYear() === year &&
|
||||
probe.getUTCMonth() === month - 1 &&
|
||||
probe.getUTCDate() === day
|
||||
);
|
||||
}
|
||||
|
||||
function parseActivityTimestamp(raw: string | null): number | null {
|
||||
if (!raw) return null;
|
||||
@@ -260,7 +243,7 @@ function parseActivityTimestamp(raw: string | null): number | null {
|
||||
// Reject an impossible calendar date outright rather than letting
|
||||
// Date.parse substitute a rolled-forward one. null = "no activity signal",
|
||||
// the safe default staleActivity already fails open on.
|
||||
if (!isRealCalendarDate(Number(year), Number(month), Number(day))) return null;
|
||||
if (!stateDocument.isRealCalendarDate(Number(year), Number(month), Number(day))) return null;
|
||||
// The date is real, so stay as liberal as before (Postel): a whole-string
|
||||
// parse still wins when the engine can make sense of the value. Reading the
|
||||
// token first would silently DROP a trailing zone name -- "2026-06-08
|
||||
|
||||
@@ -47,7 +47,7 @@ interface StateModule {
|
||||
cmdSignalWaiting(cwd: string, type: string | null | undefined, question: string | null | undefined, options: string | null | undefined, phase: string | null | undefined, raw: boolean): void;
|
||||
cmdSignalResume(cwd: string, raw: boolean): void;
|
||||
cmdStatePlannedPhase(cwd: string, phase: string | null | undefined, name: string | null | undefined, plans: number | null, raw: boolean): void;
|
||||
cmdStateValidate(cwd: string, raw: boolean): void;
|
||||
cmdStateValidate(cwd: string, raw: boolean, opts?: { strict?: boolean }): void;
|
||||
cmdStateSync(cwd: string, opts: { verify: string | boolean | null | undefined }, raw: boolean): void;
|
||||
cmdStatePrune(cwd: string, opts: { keepRecent: string; dryRun: boolean }, raw: boolean): void;
|
||||
cmdStateRebuild(cwd: string, opts: { dryRun: boolean; verbose: boolean }, raw: boolean): void;
|
||||
@@ -186,7 +186,13 @@ function routeStateCommand({ state, args, cwd, raw, error }: RouteStateCommandOp
|
||||
// the authoritative current_phase_name, mirroring begin-phase.
|
||||
state.cmdStatePlannedPhase(cwd, strArg(a, 'phase'), strArg(a, 'name'), parsePlans(strArg(a, 'plans')), raw);
|
||||
},
|
||||
validate: () => state.cmdStateValidate(cwd, raw),
|
||||
validate: () => {
|
||||
// #3696: --strict makes the verdict gateable by exit status. The
|
||||
// default stays exit 0 — the exit code is Tier-2 observable output
|
||||
// reaching unenumerable downstream consumers (ADR-3180 Decision 3).
|
||||
const a = parseNamedArgs(args, [], ['strict']);
|
||||
state.cmdStateValidate(cwd, raw, { strict: a['strict'] === true });
|
||||
},
|
||||
sync: () => {
|
||||
const a = parseNamedArgs(args, [], ['verify']);
|
||||
state.cmdStateSync(cwd, { verify: a['verify'] }, raw);
|
||||
|
||||
@@ -214,6 +214,181 @@ function locateFieldRow(content: string, fieldName: string): { valueStart: numbe
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True only when y/m/d name a date that actually exists on the calendar.
|
||||
*
|
||||
* `Date.parse` validates shape but not value: it rolls an out-of-range day
|
||||
* FORWARD rather than rejecting it (`2026-02-30` -> `2026-03-02`,
|
||||
* `2026-04-31` -> `2026-05-01`). Shape-only validation would therefore
|
||||
* propagate a different, wrong instant instead of failing safe — precisely
|
||||
* what ADR-227 ("validate shape AND value; on failure of either layer coerce
|
||||
* to the contract's safe default, never propagate") exists to prevent. A
|
||||
* round-trip through Date.UTC detects the rollover: any component the
|
||||
* constructor normalised comes back changed.
|
||||
*
|
||||
* #3696: this predicate previously lived privately inside `smart-entry.cts`,
|
||||
* where it gated `parseActivityTimestamp`. `state validate` needed the same
|
||||
* answer to assert the `last_activity` invariant (S008), and a second copy is
|
||||
* the "generative fix divergence" class outright — two surfaces that disagree
|
||||
* about whether a STATE.md is usable is the defect #3696 opens with, so a
|
||||
* parity test over two copies would be codifying the bug rather than fixing
|
||||
* it. It moves here because this module is already the designated owner of
|
||||
* STATE.md field semantics (ADR-3180 §7.7) and `smart-entry.cts` imports no
|
||||
* peer that would make the reverse direction a cycle.
|
||||
*/
|
||||
/**
|
||||
* True when a field carries no value a writer ever supplied: absent, blank, or
|
||||
* still holding the shipped template's bracket placeholder.
|
||||
*
|
||||
* `templates/state.md:35` ships `Last activity: [YYYY-MM-DD] — [What happened]`,
|
||||
* so EVERY freshly-initialized project has this exact string until something
|
||||
* records activity. #3696's first cut only spared the ABSENT form, which made
|
||||
* S008 fire on the shipped template itself — caught by the pre-existing
|
||||
* "template-equivalent phase identities remain clean without disk drift" test,
|
||||
* which is precisely what it is there for.
|
||||
*
|
||||
* The placeholder test is anchored at the START rather than "contains a bracket
|
||||
* anywhere", so a real description that happens to cite one — `2026-08-19 — fixed
|
||||
* [#123] parsing` — is still a filled-in value. That keeps the rule from
|
||||
* silently swallowing genuine drift.
|
||||
*
|
||||
* Distinct from `isStateTemplateDefault`, which answers a different question
|
||||
* ("may a later handler overwrite this?") and deliberately returns true for a
|
||||
* bare ISO date — a perfectly valid value here.
|
||||
*/
|
||||
export function isUnfilledFieldValue(value: string | null | undefined): boolean {
|
||||
if (value === null || value === undefined) return true;
|
||||
const trimmed = value.trim();
|
||||
return trimmed === '' || trimmed.startsWith('[');
|
||||
}
|
||||
|
||||
/**
|
||||
* The `YYYY-MM-DD` prefix of `value`, but only when it names a date that
|
||||
* actually exists. `null` for anything else — no leading date token at all, or
|
||||
* a token that is shape-valid and calendar-impossible.
|
||||
*
|
||||
* #3696 review: this is deliberately a LEADING-TOKEN test, not the fully
|
||||
* anchored prose grammar `parseProseLastActivityField` uses. That function
|
||||
* requires the whole value to be `date` or `date <separator> description`, and
|
||||
* returns `{date: <the entire raw string>}` when it does not match — a shape
|
||||
* that reads like success. Asserting the S008 invariant through it therefore
|
||||
* rejected values the real reader accepts: `smart-entry`'s
|
||||
* `parseActivityTimestamp` needs only a leading date and reconstructs the
|
||||
* instant even when the suffix carries no dash, so
|
||||
* `Last activity: 2026-08-24 Shipped feature X` parses fine there while S008
|
||||
* called it unreadable. That is the same two-surfaces-disagree defect #3696
|
||||
* exists to close, merely pointing the other way.
|
||||
*
|
||||
* So the invariant asserted is the one the readers actually share: a leading
|
||||
* ISO date token that is a real calendar date.
|
||||
*/
|
||||
export function leadingCalendarDate(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})(?![\d-])/.exec(value.trim());
|
||||
if (!match) return null;
|
||||
return isRealCalendarDate(Number(match[1]), Number(match[2]), Number(match[3]))
|
||||
? `${match[1]}-${match[2]}-${match[3]}`
|
||||
: null;
|
||||
}
|
||||
|
||||
export function isRealCalendarDate(year: number, month: number, day: number): boolean {
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) return false;
|
||||
const probe = new Date(Date.UTC(year, month - 1, day));
|
||||
return (
|
||||
probe.getUTCFullYear() === year &&
|
||||
probe.getUTCMonth() === month - 1 &&
|
||||
probe.getUTCDate() === day
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown structure that can legitimately follow a single-line field. A line
|
||||
* matching any of these is the NEXT construct, never a continuation of the
|
||||
* field above it.
|
||||
*
|
||||
* BREADTH IS THE POINT, and the failure direction is deliberate: a missed
|
||||
* truncation costs a diagnostic nobody sees, while a false S009 reports drift on
|
||||
* a well-formed STATE.md — a gate that fires on valid documents is worse than no
|
||||
* gate. When a shape is ambiguous, it belongs here.
|
||||
*
|
||||
* #3696 review round 2 added the last three arms after all three were shown to
|
||||
* produce false S009 fires on well-formed content: an indented code block, an
|
||||
* HTML block, and a setext underline (`===`, which the `[-*_]{3,}` rule does not
|
||||
* cover — it only knows `-`, `*` and `_`).
|
||||
*/
|
||||
const MD_STRUCTURE_LINE_RE =
|
||||
/^(?:#{1,6}\s|\||>|```|~~~|[-*_]{3,}\s*$|=+\s*$|[-*+]\s|\d+[.)]\s|\[[^\]]+\]:|<|(?: {4}|\t))/;
|
||||
|
||||
/**
|
||||
* A setext heading's underline — `===` or `---` on its own line. The line ABOVE
|
||||
* one of these is a heading TITLE, which is indistinguishable from prose on its
|
||||
* own, so the scan must look ahead by one line rather than consume it. Without
|
||||
* this, `Last activity: …\nMy Heading\n===` reported "My Heading ===" as dropped
|
||||
* continuation text (#3696 review round 2).
|
||||
*/
|
||||
const SETEXT_UNDERLINE_RE = /^(?:=+|-+)\s*$/;
|
||||
|
||||
const STATE_SIBLING_FIELD_LINE_RE = /^\*{0,2}[A-Za-z][A-Za-z0-9 _-]*\*{0,2}:{1,2}\*{0,2}(?:\s|$)/;
|
||||
|
||||
/**
|
||||
* Return the prose that FOLLOWS a single-line field but plainly belongs to it —
|
||||
* i.e. the remainder `stateExtractField` silently drops when a writer emits a
|
||||
* value long enough to wrap.
|
||||
*
|
||||
* `stateExtractField`'s `(.+)` is newline-excluding, so
|
||||
*
|
||||
* Last activity: 2026-08-19 — Project initialized from ingest; PROJECT.md,
|
||||
* REQUIREMENTS.md, ROADMAP.md written
|
||||
*
|
||||
* yields only the first line and the rest is lost with no diagnostic (#3696).
|
||||
* `templates/state.md` prescribes a single-line field, so the DOCUMENT is what
|
||||
* is wrong here, not the reader — this function exists so `state validate` can
|
||||
* SAY so, not so the reader can start guessing at a multi-line grammar the
|
||||
* template does not sanction.
|
||||
*
|
||||
* That is also why the fix is not in `stateExtractField` itself: it has 20
|
||||
* direct callers and a CRITICAL blast radius (ADR-3180 §7.7, Rejected #1), and
|
||||
* joining continuations there would apply to every field — `Status:` would
|
||||
* swallow the line beneath it.
|
||||
*
|
||||
* Returns `null` when the field is absent, is a pipe-table row (a table cell
|
||||
* cannot wrap), or is followed by end-of-file, a blank line, Markdown
|
||||
* structure, or a sibling field.
|
||||
*/
|
||||
export function stateFieldContinuation(content: string, fieldName: string): string | null {
|
||||
const escaped = escapeRegex(fieldName);
|
||||
// Same two single-line grammars stateExtractField uses, in the same order, so
|
||||
// this locates exactly the line whose value it returned. The pipe-table rung
|
||||
// is deliberately absent: a `| Field | value |` row is bounded by its closing
|
||||
// pipe and cannot wrap.
|
||||
const match =
|
||||
new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i').exec(content) ??
|
||||
new RegExp(`^${escaped}:[ \\t]*(.+)`, 'im').exec(content);
|
||||
if (!match) return null;
|
||||
|
||||
// `(.+)` stops at the line terminator, so the field's line ends where the
|
||||
// match does. JS `.` excludes \r as well as \n, so on a CRLF document the \r
|
||||
// sits just AFTER the match rather than inside it — hence the strip below
|
||||
// before testing for the newline.
|
||||
const afterValue = match.index + match[0].length;
|
||||
const rest = content.slice(afterValue).replace(/^\r/, '');
|
||||
if (!rest.startsWith('\n')) return null; // end of file: nothing follows
|
||||
|
||||
const lines = rest.slice(1).split('\n').map((line) => line.replace(/\r$/, ''));
|
||||
const continuation: string[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) break;
|
||||
if (MD_STRUCTURE_LINE_RE.test(line)) break;
|
||||
if (STATE_SIBLING_FIELD_LINE_RE.test(line)) break;
|
||||
// Look ahead one line: a setext underline below makes THIS line a heading
|
||||
// title, so stop before consuming it rather than after.
|
||||
if (i + 1 < lines.length && SETEXT_UNDERLINE_RE.test(lines[i + 1])) break;
|
||||
continuation.push(line.trim());
|
||||
}
|
||||
return continuation.length ? continuation.join(' ') : null;
|
||||
}
|
||||
|
||||
export function stateExtractField(content: string, fieldName: string): string | null {
|
||||
const escaped = escapeRegex(fieldName);
|
||||
// Bold inline format: **FieldName:** value
|
||||
|
||||
106
src/state.cts
106
src/state.cts
@@ -75,6 +75,14 @@ import {
|
||||
shouldPreserveExistingProgress,
|
||||
stateExtractField,
|
||||
stateFieldValue,
|
||||
// #3696: the `last_activity` invariant that `state validate` (S008/S009) now
|
||||
// asserts. Both live in the field-semantics owner, not here, so `smart-entry`
|
||||
// and `state validate` cannot drift apart about the same field.
|
||||
// `leadingCalendarDate` wraps `isRealCalendarDate`, which smart-entry calls
|
||||
// directly — one predicate, two callers, no copies.
|
||||
isUnfilledFieldValue,
|
||||
leadingCalendarDate,
|
||||
stateFieldContinuation,
|
||||
stateReplaceField,
|
||||
KNOWN_TEMPLATE_DEFAULTS,
|
||||
stateReplaceFieldIfTemplate,
|
||||
@@ -4279,10 +4287,30 @@ function stateDiagnostic(code: string, severity: Severity, message: string, advi
|
||||
return { code, severity, message, remedy: adviseRemedy(advice) };
|
||||
}
|
||||
|
||||
function cmdStateValidate(cwd: string, raw: boolean): void {
|
||||
function cmdStateValidate(cwd: string, raw: boolean, opts: { strict?: boolean } = {}): void {
|
||||
const statePath = planningPaths(cwd).state;
|
||||
// #3696: `valid: false` used to exit 0, so a CI step or git hook could not gate
|
||||
// on state correctness without parsing JSON — every consumer had to
|
||||
// re-implement the "is this actually valid" decision, which is the
|
||||
// duplication #3473 is about.
|
||||
//
|
||||
// The DEFAULT is deliberately unchanged. `state validate`'s exit status is
|
||||
// Tier-2 observable output reaching "downstream projects that cannot be
|
||||
// enumerated" (ADR-3180 Decision 3, Hyrum's Law), so flipping 0 -> 1 for
|
||||
// everyone would break every script that runs it unconditionally. `--strict`
|
||||
// is the opt-in the issue itself offers as the alternative.
|
||||
//
|
||||
// Routed through one emit helper rather than a trailing assignment because
|
||||
// three of the exit paths below (`STATE.md not found`, S001, and the four
|
||||
// `return` branches in the phase-drift scan) emit and return early — a fix
|
||||
// that only set the exit code at the end of the function would silently miss
|
||||
// them, which is exactly the shape of the bug being fixed.
|
||||
const emit = (payload: { valid?: boolean; error?: string; warnings?: Diagnostic[]; scope?: planningScopeMod.Scope }): void => {
|
||||
if (opts.strict && payload.valid !== true) process.exitCode = 1;
|
||||
output(payload, raw, undefined);
|
||||
};
|
||||
if (!fs.existsSync(statePath)) {
|
||||
output({ error: 'STATE.md not found' }, raw, undefined);
|
||||
emit({ error: 'STATE.md not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4296,10 +4324,10 @@ function cmdStateValidate(cwd: string, raw: boolean): void {
|
||||
// unconditionally and returned immediately, matching every other
|
||||
// error-class code, not a mere warning). Message reused verbatim from
|
||||
// `textEncodingError`, not paraphrased.
|
||||
output({
|
||||
emit({
|
||||
valid: false,
|
||||
warnings: [stateDiagnostic('S001', SEVERITY.ERROR, encErr, 'Re-save STATE.md as UTF-8 text with the embedded NUL byte(s) removed')],
|
||||
}, raw, undefined);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const warnings: Diagnostic[] = [];
|
||||
@@ -4326,7 +4354,7 @@ function cmdStateValidate(cwd: string, raw: boolean): void {
|
||||
'Cannot validate phase drift: STATE.md has no usable current_phase, Current Phase, or Current Position Phase value',
|
||||
'Set current_phase (frontmatter) or Current Phase / Current Position Phase (body) in STATE.md',
|
||||
));
|
||||
output({ valid: false, warnings, scope }, raw, undefined);
|
||||
emit({ valid: false, warnings, scope });
|
||||
return;
|
||||
}
|
||||
const selectedPhaseKey = phaseKeyFromToken(currentPhase);
|
||||
@@ -4345,7 +4373,7 @@ function cmdStateValidate(cwd: string, raw: boolean): void {
|
||||
`Cannot validate phase drift: phases directory is missing for phase ${currentPhase}`,
|
||||
'Create the phases directory or correct current_phase to a phase that exists on disk',
|
||||
));
|
||||
output({ valid: false, warnings, scope }, raw, undefined);
|
||||
emit({ valid: false, warnings, scope });
|
||||
return;
|
||||
}
|
||||
let phaseDirPath: string;
|
||||
@@ -4359,7 +4387,7 @@ function cmdStateValidate(cwd: string, raw: boolean): void {
|
||||
`Cannot validate phase drift: no phase directory matches phase ${currentPhase}`,
|
||||
'Create a phase directory matching the current phase or correct current_phase',
|
||||
));
|
||||
output({ valid: false, warnings, scope }, raw, undefined);
|
||||
emit({ valid: false, warnings, scope });
|
||||
return;
|
||||
}
|
||||
phaseDirPath = path.join(phasesDir, phaseDir.name);
|
||||
@@ -4370,7 +4398,7 @@ function cmdStateValidate(cwd: string, raw: boolean): void {
|
||||
`Cannot validate phase drift: phases directory is unreadable for phase ${currentPhase}`,
|
||||
'Check phases directory permissions and re-run validate',
|
||||
));
|
||||
output({ valid: false, warnings, scope }, raw, undefined);
|
||||
emit({ valid: false, warnings, scope });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -4462,8 +4490,68 @@ function cmdStateValidate(cwd: string, raw: boolean): void {
|
||||
));
|
||||
}
|
||||
|
||||
// #3696 — the `last_activity` invariant. Three readers consumed this field
|
||||
// and none of them checked it, so a value no reader can parse validated as
|
||||
// `{valid:true, warnings:[], scope:'complete'}`: the scan ran to completion
|
||||
// and simply never looked. Read through the same owner every other field here
|
||||
// uses (ADR-3180 §7.7) — never a private `stateExtractField` call, which is
|
||||
// what `scripts/lint-state-field-drift.cjs` counts.
|
||||
const lastActivity = stateFieldValue(fm, body, 'last_activity', 'Last activity').value;
|
||||
// NOT FILLED IN IS NOT DRIFT, and that covers three shapes, not one: absent,
|
||||
// blank, and the shipped template's `[YYYY-MM-DD] — [What happened]`
|
||||
// placeholder. Only a value a writer actually supplied can be wrong.
|
||||
if (!isUnfilledFieldValue(lastActivity)) {
|
||||
// Calendar validity, not merely `\d{4}-\d{2}-\d{2}` shape: smart-entry's
|
||||
// reader 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 complaint #3696 opens with.
|
||||
//
|
||||
// Review round 2: this asserts the LEADING date token, not
|
||||
// `parseProseLastActivityField`'s fully-anchored `date — description`
|
||||
// grammar. That grammar is stricter than any real reader, and routing the
|
||||
// check through it made S008 fire on values smart-entry parses fine (e.g.
|
||||
// `2026-08-24 Shipped feature X`, no dash separator) — the same
|
||||
// two-surfaces-disagree defect, pointing the other way. See
|
||||
// `leadingCalendarDate`.
|
||||
if (leadingCalendarDate(lastActivity) === null) {
|
||||
warnings.push(stateDiagnostic(
|
||||
'S008',
|
||||
SEVERITY.WARNING,
|
||||
`Unreadable last activity: "${lastActivity}" does not begin with a real calendar date, so no reader can date this project's activity`,
|
||||
'Rewrite the Last activity line to begin with a date that exists, as "YYYY-MM-DD — what happened"',
|
||||
));
|
||||
}
|
||||
|
||||
// The attached half of #3696: `templates/state.md` prescribes a single-line
|
||||
// field, but writers emit descriptions long enough to wrap, and
|
||||
// `stateExtractField`'s newline-excluding `(.+)` drops the remainder with no
|
||||
// diagnostic. The DOCUMENT is what violates the template here, so this
|
||||
// reports the violation rather than teaching the reader a multi-line grammar
|
||||
// the template does not sanction (ADR-3180 §7.7 Rejected #1 forbids widening
|
||||
// stateExtractField, which has 20 callers and a CRITICAL blast radius).
|
||||
//
|
||||
// Scan the body ONLY when the body is what was actually read. The ladder
|
||||
// prefers the frontmatter scalar, so a document carrying a clean
|
||||
// `last_activity:` in frontmatter AND a stale, wrapped `Last activity:` line
|
||||
// in the body would otherwise report S009 — and exit 1 under `--strict` —
|
||||
// over a remainder that no reader consumes and whose field is entirely
|
||||
// valid. Asking the owner with an EMPTY body isolates the frontmatter rung
|
||||
// without re-deriving the ladder here (which is what
|
||||
// `scripts/lint-state-field-drift.cjs` counts).
|
||||
const fromFrontmatter = stateFieldValue(fm, '', 'last_activity', 'Last activity').value;
|
||||
const dropped = fromFrontmatter !== null ? null : stateFieldContinuation(body, 'Last activity');
|
||||
if (dropped !== null) {
|
||||
warnings.push(stateDiagnostic(
|
||||
'S009',
|
||||
SEVERITY.WARNING,
|
||||
`Truncated last activity description: "${dropped}" follows the Last activity line and is silently dropped by every reader`,
|
||||
'Fold the Last activity description onto one line — the template prescribes a single-line field',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
const valid = warnings.length === 0;
|
||||
output({ valid, warnings, scope }, raw, undefined);
|
||||
emit({ valid, warnings, scope });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user