Files
msd-core/src/unusable-input.cts
Tom Boucher 3c1e358a2d fix(#3099): emit LAST_ACTIVITY_UNPARSEABLE diagnostic for unusable last_activity (#3139)
* fix(#3099): emit LAST_ACTIVITY_UNPARSEABLE diagnostic when last_activity is present but unparseable

parseActivityTimestamp returned null for both absent AND present-but-unusable
last_activity values, silently suppressing the idle-stranded recommendation.
Per ADR-1411's amendment (corrupt is not absent), the fallback stays but a
diagnostic is now emitted via the warnUnusableInput seam.

- Added LAST_ACTIVITY_UNPARSEABLE to UNUSABLE_REASON enum + prose
- Wired warnUnusableInput into detectSignals when lastActivityRaw is truthy
  but parseActivityTimestamp returned null
- Updated UNUSABLE_REASON lock test
- Added 5 regression tests (unusable→diagnostic, absent→silent, well-formed→silent, dedup)

* chore(#3099): add changeset fragment

* chore(#3099): backfill changeset PR number 3139

---------

Co-authored-by: sim <sim@local>
2026-08-07 05:32:49 -04:00

254 lines
13 KiB
TypeScript

/**
* Unusable Input Diagnostic — the out-of-band half of ADR-1411's
* "corrupt is not absent" amendment (epic #1879).
*
* ADR-1411 splits the amendment's mechanism in two. Where a read already returns a
* provenance envelope, the cause is named *in-band* — that is `ConfigResolution.reason`
* (#1880, shipped). Where a read returns a bare sentinel or a plausible default it cannot
* extend, the return value is preserved exactly and the cause is surfaced *out-of-band*,
* as a deduplicated diagnostic on stderr. This module owns that second mechanism.
*
* It exists as a shared seam rather than a pattern copied per site because four call sites
* across four modules need identical behaviour (#1882 frontmatter, #1881 roadmap-parser,
* #1883 planning-workspace/verify, #1884 planning lock). Four hand-rolled copies of one
* behaviour is `DEFECT.GENERATIVE-FIX` by construction; one seam with a frozen reason set
* is the documented cure.
*
* Two contracts this module must not break:
*
* - **Unconditional.** ADR-1411 diverges deliberately from ADR-227's never-implemented
* `GSD_DEBUG` opt-in: "an opt-in nobody sets is indistinguishable from the silence
* #1879 is about". There is no config gate here, by design.
* - **Never throws.** Callers are leaf readers that promised a total function. A failed
* stderr write (closed stream, EPIPE) must not turn a silent degradation into a crash.
*/
import crypto from 'node:crypto';
// ─── Reason vocabulary ────────────────────────────────────────────────────────
/**
* Frozen so tests assert a typed surface instead of diagnostic prose
* (CONTRIBUTING.md — Prohibited: Raw Text Matching on Test Outputs).
*
* Adding a reason is three coordinated changes, matching the repo's `REASON`-enum
* convention: the entry here, the emitting call site, and the test that locks
* `Object.keys(UNUSABLE_REASON).sort()`. Each epic-#1879 phase adds only its own —
* pre-declaring the later phases' reasons would be speculative generality and would
* leave values no call site emits.
*/
const UNUSABLE_REASON = Object.freeze({
/**
* A file opened a `---` frontmatter fence at byte 0, carried at least one parseable
* key, and never closed the fence — a truncated or half-written file, NOT a file that
* legitimately has no frontmatter. (#1882)
*/
FRONTMATTER_UNTERMINATED: 'frontmatter_unterminated',
/**
* A ROADMAP.md exists but could not be read (EACCES/EIO/…). Distinct from a project that
* simply has no ROADMAP yet: absence returns the same sentinel, silently. (#1881)
*/
ROADMAP_UNREADABLE: 'roadmap_unreadable',
/**
* A `last_activity` value in STATE.md frontmatter is present but unparseable as a date.
* Distinct from absent: absence means the field was never set, while an unparseable value
* means it was set to something Date.parse rejects. Per ADR-1411 amendment: corrupt is not
* absent — the fallback (stale_activity: false) stays, but a diagnostic is emitted so the
* silent degradation is visible. (#3099, sixth #1879 site)
*/
LAST_ACTIVITY_UNPARSEABLE: 'last_activity_unparseable',
} as const);
type UnusableReason = (typeof UNUSABLE_REASON)[keyof typeof UNUSABLE_REASON];
/** One human-readable clause per reason. Prose lives here, never in a test assertion. */
const REASON_PROSE: Readonly<Record<UnusableReason, string>> = Object.freeze({
[UNUSABLE_REASON.FRONTMATTER_UNTERMINATED]:
'frontmatter opens with "---" but never closes; metadata was NOT applied',
[UNUSABLE_REASON.ROADMAP_UNREADABLE]:
'ROADMAP.md exists but could not be read; phase and milestone lookups fell back to defaults',
[UNUSABLE_REASON.LAST_ACTIVITY_UNPARSEABLE]:
'last_activity in STATE.md is present but unparseable as a date; stale_activity fell back to false (idle-stranded suppressed)',
});
// ─── Dedup state ──────────────────────────────────────────────────────────────
/**
* Process-lifetime dedup set. Mirrors `config-loader.cjs`'s `_warnedUnknownConfigKeys`
* guard, which ADR-1411 names as the precedent to reuse.
*/
const _warnedUnusableInputs = new Set<string>();
/**
* Count of diagnostics actually WRITTEN, which is not the same as the size of the dedup set:
* one emission records every key the input could later be identified by, so set size counts
* identities while this counts events. Tests assert on this because the behavioural claim is
* "how many diagnostics did the operator see", not "how many keys are interned".
*/
let _unusableInputEmissions = 0;
/**
* ASCII control characters (including NUL) are stripped from any path before it is used
* as a key component or written to a terminal. Two reasons, both real:
*
* - the key separator is NUL, so a `sourcePath` containing NUL could otherwise forge a
* collision with a different (path, reason) pair and suppress a genuine second failure;
* - a path carrying ANSI escapes would be replayed verbatim into the operator's terminal.
*/
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/g;
/**
* Strip control characters. Deliberately does NOT normalize path separators.
*
* An earlier revision folded backslashes to `/` unconditionally, reasoning that `C:\a\b.md`
* and `C:/a/b.md` are one file and should not report twice. That is true on Windows, and
* false — destructively — everywhere else: `\` is a legal filename character on Linux and
* macOS, so `/repo/weird\name/PLAN.md` and `/repo/weird/name/PLAN.md` are two genuinely
* different files that collapsed to one key, and the second one's diagnostic was silently
* swallowed. ADR-1411 forbids exactly that ("keying too coarsely suppresses a genuine second
* failure in a different file"), and this repo targets Linux/macOS/Windows alike.
*
* The trade is now explicit and one-directional: two spellings of one Windows path may
* report twice (mild noise), but two distinct files can never silence each other (lost
* signal). Dropping a real diagnostic is the strictly worse failure.
*/
function sanitizeSource(source: string): string {
return source.replace(CONTROL_CHARS, '');
}
/**
* Identify the offending input. A path is preferred because it is what an operator can act
* on. When the caller has only an in-memory string, fall back to a short content digest so
* that *different* bad inputs still produce *different* keys.
*
* The leading `p`/`d` tag is what keeps the two namespaces disjoint. Without it a caller
* whose file is literally named `<unnamed:8efa5269728e7271>` would key identically to a
* path-less caller whose content happens to hash to that digest — no brute force required,
* since the digest of any predictable content (a shared template, known boilerplate) can
* simply be computed and used as a filename to pre-seed suppression. Because control
* characters — including NUL — are stripped from `source`, a caller-supplied path can never
* contain the separator and so can never forge a key in the other namespace either.
*
* The digest is computed only on the emission path, which is rare, so it never costs
* anything on a healthy read.
*/
function sourceKey(source?: string, content?: string): string {
if (typeof source === 'string' && source.trim() !== '') {
return `p\u0000${sanitizeSource(source)}`;
}
const digest = crypto.createHash('sha256').update(content ?? '').digest('hex').slice(0, 16);
return `d\u0000${digest}`;
}
/** Human-facing name for the offending input, derived from the same key. */
function displaySource(key: string): string {
return key.startsWith('p\u0000') ? key.slice(2) : `<unnamed:${key.slice(2)}>`;
}
// ─── Emission ─────────────────────────────────────────────────────────────────
interface WarnUnusableInputArgs {
/** Which unusable-input condition fired. */
reason: UnusableReason;
/** Resolved path of the offending file, when the caller has one. */
source?: string;
/** Raw content, used only to derive a dedup key when `source` is absent. */
content?: string;
}
/**
* Emit a deduplicated diagnostic naming an input that exists but cannot be used.
*
* The key is `<normalized source>\0<reason>`. ADR-1411 requires the resolved path AND the
* distinguishing cause — keying on the path alone would let a second, different fault on
* the same file go unreported; keying on the message prose would couple the guard to
* wording.
*
* @returns `true` when this call actually wrote a diagnostic, `false` when it was
* deduplicated. Returning the decision is what lets tests assert emission *counts* on a
* typed surface rather than scraping stderr.
*/
function warnUnusableInput({ reason, source, content }: WarnUnusableInputArgs): boolean {
// Defensive: an unknown reason must not emit a diagnostic with `undefined` in it.
const prose = Object.prototype.hasOwnProperty.call(REASON_PROSE, reason)
? REASON_PROSE[reason]
: null;
if (prose === null) return false;
// The guarantee, stated precisely, because it is not symmetric:
//
// * a file reported BY NAME is reported at most once, and
// * an anonymous re-parse of content already reported by name stays silent, and
// * two DIFFERENT files always both report, even when their truncated bytes are identical.
//
// The asymmetry is the anonymous-FIRST ordering (a path-less parse, then a named parse of the
// same content), which emits twice. That is a deliberate limit, not an oversight. A path-less
// caller cannot identify its file, so suppressing the later named report would also suppress a
// genuine second failure in a DIFFERENT file whenever two files share byte-identical truncated
// content — the over-coarse keying ADR-1411 explicitly forbids. Between a duplicate line and a
// swallowed diagnostic the ADR ranks the swallow worse, so the duplicate is accepted; and the
// second line is the more useful of the two, because it carries the filename.
//
// Mechanically: check ONLY the key matching what this caller actually knows, but record every
// key the input could later be identified by.
const identity = sourceKey(source, content);
const keys = [`${identity}\u0000${reason}`];
if (typeof content === 'string' && identity.startsWith('p\u0000')) {
keys.push(`${sourceKey(undefined, content)}\u0000${reason}`);
}
if (_warnedUnusableInputs.has(keys[0])) return false;
for (const k of keys) _warnedUnusableInputs.add(k);
try {
process.stderr.write(`gsd: warning — ${displaySource(identity)}: ${prose}. (#1879)\n`);
// Counted only after a write that actually completed. Incrementing before the try counted
// attempts, so on a broken stderr the counter claimed a diagnostic had reached the operator
// when nothing had — a seam documented as "written" reporting something else.
_unusableInputEmissions += 1;
} catch {
/* a closed or broken stderr must never escalate a degraded read into a crash */
}
return true;
}
// ─── Test seams ───────────────────────────────────────────────────────────────
/**
* Clear the dedup state between cases.
*
* This exists because the set is process-global: without it, the second test to use a key
* silently observes the first test's suppression. #2674 is the cautionary precedent — a
* reset helper that cleared two of three sets was a silent no-op for the very suite that
* existed to test it, and the cases only passed because each happened to pick a key no
* other case reused.
*/
function _resetUnusableInputWarningsForTests(): void {
_warnedUnusableInputs.clear();
_unusableInputEmissions = 0;
}
/** Number of diagnostics written — the typed surface tests assert on instead of stderr prose. */
function _unusableInputEmissionCountForTests(): number {
return _unusableInputEmissions;
}
/** Size of the dedup set (identities interned, not events). Retained for key-shape assertions. */
function _unusableInputWarningCountForTests(): number {
return _warnedUnusableInputs.size;
}
/** Test seam: the sanitized form of a source, so control-char handling is asserted on a
* returned value instead of by scraping what reached stderr. */
function _sanitizeSourceForTests(source: string): string {
return sanitizeSource(source);
}
export = {
UNUSABLE_REASON,
_sanitizeSourceForTests,
warnUnusableInput,
_resetUnusableInputWarningsForTests,
_unusableInputWarningCountForTests,
_unusableInputEmissionCountForTests,
};