Files
msd-core/src/unusable-input.cts
sim c5543e533c refactor(#3309): extend planning-snapshot.cts with 8 more parsed fields
Phase 11 of epic #3180 (ADR-3180 §8.1 rule 2). PlanningSnapshot grows from
7 fields to 15: projectSections, statePhaseTokens, stateStatus,
roadmapDeclaredPhases, roadmapPhaseCheckboxes, researchValidationStatus,
milestoneArchiveStatus, planningRootFiles.

Every field is a reused owner (buildRoadmapPhaseVariants/
buildNotStartedPhaseVariants from src/validate.cts, stateFieldValue) or a
small relocation of already-working verify.cts logic (PHASE_NUMBER_TOKEN_SOURCE
scanning, the checkMilestonePrefixMismatches sectionRx walk, W009/W018's
file-existence checks) — never a new algorithm, and never raw document text:
§8.1 rule 2 forbids exposing raw text, not exposing a parsed list or boolean
derived from it once by the snapshot builder.

roadmapPhaseCheckboxes deliberately reads the same ROADMAP checkbox
isPhaseComplete (§7.4, disk-strict) refuses to consult — that owner decides
completion and must not read it; this field only exposes what the checkbox
says, for a diagnostic (W011) whose whole purpose is flagging disagreement.
Not a re-derivation of §7.4, recorded explicitly to prevent that reading.

Adds PROJECT_UNREADABLE to UNUSABLE_REASON (ninth #1879 site), closing a
gap the implementing agent correctly flagged rather than silently leaving
absent-vs-corrupt collapsed for PROJECT.md, matching the STATE_UNREADABLE/
CONFIG_UNREADABLE precedent from this same effort's prior commits.

currentPhaseLabel/statePhaseTokens/stateStatus share one STATE.md read
(buildStateFields) rather than three independent reads.

Additive only — all prior fields and worstScope/buildPhaseSnapshot
unchanged.
2026-08-13 01:10:53 -04:00

281 lines
14 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',
/**
* A STATE.md exists but could not be read (EACCES/EIO/…). Distinct from a project that has
* not run `state.init` yet: absence returns the same non-answer, silently — only an
* exists-but-unreadable STATE.md is corruption. (#3308, seventh #1879 site —
* planning-snapshot's current-phase field)
*/
STATE_UNREADABLE: 'state_unreadable',
/**
* A config.json exists but could not be read/parsed (EACCES/EIO/malformed JSON/…).
* Distinct from a project that has not run any config-writing command yet: absence
* returns the same non-answer, silently — only an exists-but-unreadable config.json
* is corruption. (#3309, eighth #1879 site — planning-snapshot's config field)
*/
CONFIG_UNREADABLE: 'config_unreadable',
/**
* A PROJECT.md exists but could not be read (EACCES/EIO/…). Distinct from a project that has
* not run any project-writing command yet: absence returns the same non-answer, silently —
* only an exists-but-unreadable PROJECT.md is corruption. (#3309, ninth #1879 site —
* planning-snapshot's projectSections field)
*/
PROJECT_UNREADABLE: 'project_unreadable',
} 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)',
[UNUSABLE_REASON.STATE_UNREADABLE]:
'STATE.md exists but could not be read; the current-phase label fell back to unavailable',
[UNUSABLE_REASON.CONFIG_UNREADABLE]:
'config.json exists but could not be read or parsed; the config field fell back to unavailable',
[UNUSABLE_REASON.PROJECT_UNREADABLE]:
'PROJECT.md exists but could not be read; the projectSections field fell back to unavailable',
});
// ─── 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,
};