Files
msd-core/src/unusable-input.cts
Tom Boucher 3fd03bec4c fix(#3760): refuse a legacy-key migration into a non-object config section (#3767)
* test(#3760): failing-first regression for non-object config section

Locks the contract from the issue's Expected section before any fix exists:
a legacy-key section holding a string, number, boolean or array must be
preserved verbatim, reported, and never persisted in an expanded form.

Covers both blocks the issue names (branching_strategy -> git.*, sub_repos ->
planning.*), the migrateOnDisk multiRepo branch that shares the shape, and the
loader write paths that are what actually reach the user's config.json.
Includes the negative-space cases that must keep hoisting ({} , null, absent
section, canonical-nested-wins) and two fast-check properties.

Refs #3760

* fix(#3760): refuse a legacy-key migration into a non-object config section

normalizeLegacyKeys hoisted a legacy top-level key into its canonical nested
section by spreading `result[section] ?? {}`. `??` guards only null and
undefined, so a section holding a string was enumerated by index —
`{...'main'}` is `{0:'m',1:'a',2:'i',3:'n'}` — while a number or boolean
spread to `{}` and the value vanished. Because a fired block always pushed a
Normalization, and every caller treats a non-empty normalizations array as
'config is dirty', that shape was written back to .planning/config.json and
the original value became unrecoverable.

Both blocks the issue names are fixed via one shared hoistLegacyKey helper,
plus the two further sites that share the shape and are reachable from the
same input: migrateOnDisk's multiRepo branch, and the loader's two
`if (!planning) planning = {}` guards, where a non-empty string is truthy and
the following assignment threw a strict-mode TypeError that the enclosing
catch swallowed — discarding the user's entire config.

A present non-object section now blocks its own migration. The section, the
legacy key, and the file are left byte-identical; no Normalization is pushed,
so nothing marks the config dirty; the refusal is reported in-band as
`skipped[]` and out-of-band through the ADR-1411 warnUnusableInput seam
(new frozen reason config_section_not_object). null and undefined keep their
long-standing 'absent' meaning and still create the section.

This is the nested-section analog of the ADR-227 shape check _readConfigFile
already performs on the top-level document: valid JSON is not a config object.
isConfigSection is exported and shared by both modules rather than copied.

Fixes #3760

* fix(#3760): keep the multiRepo marker when planning cannot receive it

Follow-up from the isolated adversarial review, and the same defect class as
the two blocks the issue names — in the block it did not name.

normalizeLegacyKeys block 3 deleted `multiRepo` and pushed a Normalization
before anything consulted the planning section, deferring 'can this section
receive sub_repos?' to the caller that runs filesystem detection. By then the
marker was already gone and the config was already dirty, so with
{"multiRepo":true,"planning":"docs"} the loader wrote the file back with
multiRepo removed, the sub_repos injection silently no-opped against the
string, and no diagnostic was emitted at all. migrateOnDisk warned for the
same input; the ~30-caller loadConfig path did not.

Section validity is knowable from the parsed config alone — detection is only
needed for the VALUE, not for whether the destination can hold it. The refusal
moves into block 3: the marker is kept, no Normalization is pushed, and a
skipped entry is recorded, so all three callers inherit the preservation and
the diagnostic together. The caller-side guards drop to pure narrowing.

Also from review: skipped[] now reports sectionType ('string' | 'number' |
'boolean' | 'array') instead of sectionValue. migrateOnDisk's report is printed
verbatim by `migrate-config`, and this module already masks config values on
the set/unset output path; the type is the whole diagnostic and the value is
still in the file.

And `migrate-config --raw` no longer answers a refused migration with 'No
legacy keys found — config is already canonical.' Legacy keys WERE found and
declined, and the decline is the one thing only the user can fix by hand.

Refs #3760

* fix(#3760): keep configuration.cjs dependency-free; emit from its callers

The remote matrix caught a regression my own change introduced: adding
`require('./unusable-input.cjs')` to configuration.cts broke the #3571
install-layout contract. `configuration.cjs` must load from a layout holding
only itself plus bin/shared/*.manifest.json — the installer does not co-locate
arbitrary siblings — so the new require failed at load time:

  Cannot find module './unusable-input.cjs'
  Require stack:
  - /tmp/gsd-3571-.../.codex/gsd-core/bin/lib/configuration.cjs

pinned by 'co-located bin/shared manifests let configuration.cjs load without
sdk/shared' in tests/install.test.cjs (3 failures).

The contract is deliberate and the test is right, so the module goes back to
zero sibling requires and the out-of-band diagnostic moves to the callers that
already carry a dependency budget and hold the resolved path: cmdMigrateConfig
(config.cts) and loadConfigResolved (config-loader.cts). normalizeLegacyKeys
keeps reporting refusals in-band via skipped[], which is what lets it be pure
and dependency-free at the same time.

The emission-count and dedup assertions move to tests/config-loader.test.cjs,
where the diagnostic now originates. A new assertion pins the inverse for the
module itself — migrateOnDisk must emit ZERO diagnostics while still reporting
skipped[] — so regrowing a sibling require fails a unit test instead of only
the install suite. CONTEXT.md records why the emitter is the caller.

Refs #3760

* chore(#3760): backfill changeset pr number to 3767

---------

Co-authored-by: sim <sim@local>
2026-08-22 18:57:19 -04:00

295 lines
15 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',
/**
* A config.json parsed cleanly, but a section that a legacy-key migration needed to write
* into (`git`, `planning`) holds something that is not an object — a string, number, boolean
* or array. Distinct from the section being absent: absence is the ordinary path and the
* migration simply creates the section. Only a PRESENT non-object is corruption, and it is
* exactly the ADR-227 "shape, not just parseability" class one level down from the top-level
* document check `_readConfigFile` already performs. The migration is refused rather than
* applied — spreading the value would enumerate a string into character keys, and a number
* or boolean into nothing at all — so the section, the legacy key, and the file on disk are
* all left exactly as the user wrote them. (#3760, tenth #1879 site)
*/
CONFIG_SECTION_NOT_OBJECT: 'config_section_not_object',
} 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',
[UNUSABLE_REASON.CONFIG_SECTION_NOT_OBJECT]:
'a config section that a legacy-key migration targets holds a non-object value; the migration was SKIPPED and both the section and the legacy key were left unchanged — fix the section by hand, or the legacy key will never migrate',
});
// ─── 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,
};