/** * Assumption-Delta detector (#1561). * * A rarely-firing, advisory architecture checkpoint. When a phase makes * something PLURAL / OPTIONAL / CHOSEN that used to be SINGULAR / REQUIRED / * DERIVED, the primary key / identity model may silently stop matching the * generalized intent. This detector scans phase-scope prose for the linguistic * signals of that transition so the plan:pre capability hook (see * capabilities/assumption-delta/) can surface ONE identity-model question. * * Design notes (rubber-duck'd): * - DETERMINISTIC + TYPED IR. The "does it fire?" decision is a pure function * returning { detected, signals, terms }, not an LLM judgment — so the * low-false-positive guarantee (acceptance criterion #2) is testable. * - BARE "or" IS INTENTIONALLY EXCLUDED from the default pluralization cues. * The issue lists "or" as a tell, but bare "or" is extremely common in * English prose and would make the gate fire on nearly every phase * description. Pluralization requires a stronger second-case cue * (second / alternative / fallback / additional / ...). The vocabulary is * tunable (config + the `terms` parameter) so teams can widen it. * - FENCED CODE BLOCKS ARE STRIPPED first (via the markdown-sectionizer seam) * so a trigger term that appears only inside a code snippet does not fire. * - Mirrors ui-safety-gate.cts: a pure function + a STDIN-reading CLI. * * Public API: * detectAssumptionDelta(text, terms?) -> { detected, signals, terms } * DEFAULT_ASSUMPTION_DELTA_TERMS * * CLI (ADR-3889 Phase 3, #3907): * echo "$PHASE_SECTION" | node gsd-core/bin/lib/assumption-delta.cjs [--json] * exit 0 = signal detected, 1 = none (real input, examined, no signal), * NO_INPUT (registry, src/cli-exit.cts) = stdin empty/whitespace-only, * UNAVAILABLE (registry) = stdin read failed * --json additionally prints the typed IR on stdout (unchanged for 0/1); * NO_INPUT/UNAVAILABLE print {skipped:true, reason:"no_input"|"stdin_error"} */ import { stripFencedCode } from './markdown-sectionizer.cjs'; import { escapeRegex } from './pattern.cjs'; export type AssumptionDeltaKind = 'pluralization' | 'optional' | 'chosen'; export interface AssumptionDeltaSignal { kind: AssumptionDeltaKind; term: string; snippet: string; } export interface AssumptionDeltaTermSet { pluralization: string[]; optional: string[]; chosen: string[]; } export interface AssumptionDeltaResult { detected: boolean; signals: AssumptionDeltaSignal[]; terms: AssumptionDeltaTermSet; } /** * Curated default trigger vocabulary. Each kind lists cue terms that signal a * core-assumption monopoly has been lost. ADDITIVE-ONLY (Hyrum's Law: once * shipped, this set is a depended-upon interface). Tunable via the `terms` * parameter or the capability's config slice. */ export const DEFAULT_ASSUMPTION_DELTA_TERMS: Readonly = { // Primary trigger — a second X where there was one. // Bare "or" excluded (prose-frequency false positives). pluralization: [ 'second', 'alternative', 'alternate', 'fallback', 'also', 'additional', 'another', 'supplementary', 'alongside', 'multiple', 'plural', '2nd', ], // required / `only` -> optional optional: ['optional', 'optionally'], // derived -> chosen / constant -> parameter chosen: [ 'chosen', 'choose', 'selectable', 'configurable', 'parameterized', 'parameterised', 'parameterize', 'parameterise', 'custom', ], }; /** Hardening caps for the tunable term vocabulary (Codex review finding). */ const MAX_TERMS_PER_KIND = 200; const MAX_TERM_LEN = 32; /** * Normalize a caller-provided term list: trim, lowercase, reject empties and * punctuation-only terms (e.g. "-"), dedupe (preserve order), and cap the * count/length so a huge or hostile `--terms` value cannot build a giant * alternation regex or echo a massive payload. Defaults are already clean, so * this is a no-op on them. */ function normalizeTerms(list: unknown): string[] { if (!Array.isArray(list)) return []; const seen = new Set(); const out: string[] = []; for (const raw of list) { if (typeof raw !== 'string') continue; const t = raw.trim().toLowerCase().slice(0, MAX_TERM_LEN); // Require at least one alphanumeric char so punctuation-only terms like // "-" cannot match prose punctuation as a "signal". if (!t || !/[a-z0-9]/.test(t)) continue; if (seen.has(t)) continue; seen.add(t); out.push(t); if (out.length >= MAX_TERMS_PER_KIND) break; } return out; } /** * Resolve the effective term set: per-kind override. An explicitly-provided * non-empty array for a kind REPLACES that kind's defaults (then normalized); * an absent kind KEEPS its defaults. An explicitly-empty array disables that * kind (override present, normalized to []). This lets a caller narrow one axis * without re-declaring the others. */ function resolveTerms(terms?: Partial): AssumptionDeltaTermSet { const merge = (key: AssumptionDeltaKind): string[] => { const t = terms && terms[key]; return Array.isArray(t) ? normalizeTerms(t) : [...DEFAULT_ASSUMPTION_DELTA_TERMS[key]]; }; return { pluralization: merge('pluralization'), optional: merge('optional'), chosen: merge('chosen'), }; } /** Trim + collapse + truncate a context window around a match for the snippet. */ function makeSnippet(line: string, term: string): string { const cleaned = line.replace(/\s+/g, ' ').trim(); if (cleaned.length <= 120) return cleaned; // Centre the window on the matched term when the line is long. const idx = cleaned.toLowerCase().indexOf(term); if (idx < 0) return cleaned.slice(0, 120); const start = Math.max(0, idx - 50); const end = Math.min(cleaned.length, idx + term.length + 50); const prefix = start > 0 ? '…' : ''; const suffix = end < cleaned.length ? '…' : ''; return `${prefix}${cleaned.slice(start, end)}${suffix}`; } /** * Detect assumption-delta signals in phase-scope prose. * * @param text - Roadmap phase section / scope prose. Non-string inputs degrade * to `{ detected: false }` without throwing. * @param terms - Optional per-kind override (see resolveTerms). * @returns typed IR: { detected, signals[], terms }. `terms` is the effective * (merged) set actually used, so callers/tests can audit what fired. */ export function detectAssumptionDelta( text: unknown, terms?: Partial, ): AssumptionDeltaResult { if (typeof text !== 'string') { return { detected: false, signals: [], terms: resolveTerms(terms) }; } const effective = resolveTerms(terms); // Strip fenced code blocks so trigger terms inside code snippets do not fire. // stripFencedCode is CommonMark-correct and CRLF-safe. const stripped = stripFencedCode(text.replace(/\r\n/g, '\n')).text; if (stripped.trim().length === 0) { return { detected: false, signals: [], terms: effective }; } const signals: AssumptionDeltaSignal[] = []; const kinds: AssumptionDeltaKind[] = ['pluralization', 'optional', 'chosen']; for (const kind of kinds) { const cueTerms = effective[kind]; if (cueTerms.length === 0) continue; // Word-boundary anchored, case-insensitive — same shape as ui-safety-gate. // (^|[^a-zA-Z0-9])(TERM)([^a-zA-Z0-9]|$) prevents interior-substring matches. const escaped = cueTerms.map(escapeRegex).join('|'); const pattern = new RegExp('(^|[^a-zA-Z0-9])(' + escaped + ')([^a-zA-Z0-9]|$)', 'gi'); const seen = new Set(); for (const line of stripped.split('\n')) { pattern.lastIndex = 0; for (const m of line.matchAll(pattern)) { const raw = m[2]; if (!raw) continue; const matched = raw.toLowerCase(); const key = `${kind}:${matched}`; if (seen.has(key)) continue; seen.add(key); signals.push({ kind, term: matched, snippet: makeSnippet(line, matched) }); } } } return { detected: signals.length > 0, signals, terms: effective }; } // ── CLI entry point ────────────────────────────────────────────────────────── // Reads phase-section text from STDIN (not argv) to avoid OS ARG_MAX limits. // Invoked by workflow bash as: echo "$PHASE_SECTION" | node .../assumption-delta.cjs [--json] // // Exit codes (ADR-3889 Phase 3, #3907): 0 = signal detected, 1 = none (real // input, examined, no signal), NO_INPUT (registry — src/cli-exit.cts) = stdin // empty/whitespace-only, UNAVAILABLE (registry) = stdin read failed. The prior // single "2 = startup error" arm let empty input flow into the detector and // exit 1 — an unexamined input reported as an authoritative negative. Under // --json, NO_INPUT/UNAVAILABLE emit `{skipped:true, reason:"no_input"| // "stdin_error"}` — no `detected` key, so a stale `detected:false` can never // sit beside `skipped:true`; the detected/no-signal `--json` payloads are // byte-identical to before. Terminates via terminateNow (write-then-terminate, // total by construction), not raw process.exit — these exits fire from inside // a stdin event handler. Mirrors ui-safety-gate.cjs / api-coverage.cjs. if (require.main === module) { const argv = process.argv.slice(2); const wantJson = argv.includes('--json'); // eslint-disable-next-line @typescript-eslint/no-require-imports const cliExit = require('./cli-exit.cjs') as { terminateNow: (outcome: string, payload: unknown) => never }; // --terms : config-tunable vocabulary override. Replaces the // pluralization cues (the primary trigger); optional/chosen keep defaults. // An EMPTY value ("") or a flag-shaped value restores the curated defaults // (does NOT disable pluralization). Terms are normalized (deduped, etc.) by // detectAssumptionDelta's resolveTerms. let termsOverride: Partial | undefined; const termsIdx = argv.indexOf('--terms'); const termsVal = termsIdx !== -1 ? argv[termsIdx + 1] : undefined; if (typeof termsVal === 'string' && !termsVal.startsWith('-')) { const list = termsVal .split(',') .map((t) => t.trim().toLowerCase()) .filter((t) => t.length > 0); termsOverride = list.length > 0 ? { pluralization: list } : undefined; } const chunks: string[] = []; process.stdin.setEncoding('utf-8'); process.stdin.on('data', (chunk: string) => chunks.push(chunk)); process.stdin.on('end', () => { const input = chunks.join(''); // Whitespace-only counts as empty (ADR-3889 §1's NO_INPUT: "zero units // were in scope, and that emptiness is known to be genuine"). Real input // — including a lone NUL byte, which .trim() does not strip — always // falls through to the detector. if (input.trim().length === 0) { cliExit.terminateNow('NO_INPUT', wantJson ? { skipped: true, reason: 'no_input' } : undefined); } const result = detectAssumptionDelta(input, termsOverride); cliExit.terminateNow(result.detected ? 'PASS' : 'FAIL', wantJson ? result : undefined); }); process.stdin.on('error', (err: Error) => { process.stderr.write(`ERROR: assumption-delta.cjs stdin read failed: ${err.message}\n`); cliExit.terminateNow('UNAVAILABLE', wantJson ? { skipped: true, reason: 'stdin_error' } : undefined); }); }