// fix-op.mjs — R11 fix-operation classifier over judge-pass flags. // // Contract: docs/r11-tiered-fix-design.md §3 (the O1/O2/O3 partition is by // OPERATION, not by rule code) and §4 (the O1 invariant). // // This module IS the O1 driver with writes disabled. It attempts the value swap // and checks §4's three conditions; an item it cannot prove is O3 with a typed // abort code. That is deliberate: a proxy heuristic would have measured // something other than the mechanism that will later touch a public corpus. // // Two properties the callers depend on: // - PURE. No fs, no network, no mutation of the input flag. The caller reads // the file and passes its text. // - FAILS CLOSED. Every path returns O1-with-proof or O3-with-a-known-code. // A misrouted O3 costs one human review; a misrouted O1 ships a wrong edit // to a publicly distributed file. // // What this module deliberately does NOT do: decide O2. Subtraction candidacy // turns on which sub-assertion the judge's prose `reason` names as failing, and // no regex reads prose. O2 requires operator ratification (§5) before it exists // as a class at all; until then every non-O1 item is O3 by design. /** * Abort codes. The taxonomy is part of the contract, not diagnostics: the pilot's * measurement #3 (§10) is the DISTRIBUTION of these, because "abort rate 85 %" * is not actionable while "60 % LOCATOR_MISS" is an engineering gap and "60 % * NOT_VERBATIM" is intrinsic to the corpus. */ export const ABORT_CODES = { MULTI_PART_CLAIM: 'MULTI_PART_CLAIM', // enumeration / several assertions in one claim (§3, the R8 class) NO_VALUE_TOKEN: 'NO_VALUE_TOKEN', // nothing swappable — the claim asserts prose STATUS_SYNONYM: 'STATUS_SYNONYM', // GA/Preview class: file vocabulary != source vocabulary (operator question) MULTI_VALUE_TOKEN: 'MULTI_VALUE_TOKEN', // several distinct values — which one is wrong is a judgement LOCATOR_MISS: 'LOCATOR_MISS', // value not found in the block the flag points at LOCATOR_AMBIGUOUS: 'LOCATOR_AMBIGUOUS', // value occurs more than once in that block NOT_VERBATIM: 'NOT_VERBATIM', // no same-type replacement occurs verbatim in evidence_quote (§4.1) MULTI_REPLACEMENT: 'MULTI_REPLACEMENT', // quote offers several candidate values CONTEXT_MISMATCH: 'CONTEXT_MISMATCH', // §4 held but the tokens do not denote the same quantity (see below) INVARIANT_FAIL: 'INVARIANT_FAIL', // swap constructed but §4 did not hold — must never happen silently }; /** Verdict code for a proven swap. Kept out of ABORT_CODES so `op === 'O1' <=> code === 'PROVEN'`. */ export const PROVEN = 'PROVEN'; // Value types, most specific first. Matching is non-overlapping and priority // ordered, so `2.3.0` is one version rather than two numbers, and `20 %` is a // percent rather than the number 20. Types never cross in a swap: a percent may // only be replaced by a percent. const TOKEN_PATTERNS = [ ['iso_date', /\d{4}-\d{2}-\d{2}/g], ['percent', /\d+(?:[.,]\d+)?\s?%/g], ['version', /v?\d+\.\d+\.\d+/g], ['number', /\d+(?:[.,]\d+)?/g], ]; // Lifecycle vocabulary. Present in a claim without any numeric token, this is the // GA/Preview class: the corpus writes `**Preview**` / `**GA**` while the cited // source writes "generally available". A swap satisfies §4 literally while // pasting English prose into a Norwegian table, which is why the class was put to // the operator as a design question — answered by the §4b table below. const STATUS_RE = /\b(?:GA|generally available|allment tilgjengelig|public preview|private preview|preview|deprecated|utfaset|retired|avviklet)\b/i; // ------------------------------------------------------- §4b synonym table // // RATIFIED 2026-08-03 (docs/r11-tiered-fix-design.md §4b). This is the one place // where the value written into the file does NOT appear verbatim in the quote — // §4 condition 1 is structurally unsatisfiable for it, because the corpus writes // a label and the source writes a phrase. The table is what the operator ratified // in its place, and it is CLOSED: a pair not listed here aborts, and nothing // extends it at run time. // // `corpus[0]` is the canonical value written back. The order is the table's own: // the row's least specific label wins, so a source that says only "preview" can // never produce the more specific "Public Preview" — that would assert something // the source does not. export const STATUS_TABLE = [ { row: 'GA', corpus: ['GA'], source: ['generally available', 'general availability'] }, { row: 'PREVIEW', corpus: ['Preview', 'Public Preview'], source: ['public preview', 'preview'] }, { row: 'PRIVATE_PREVIEW', corpus: ['Private Preview'], source: ['private preview'] }, { row: 'DEPRECATED', corpus: ['Deprecated', 'Utfaset'], source: ['deprecated', 'retired'] }, ]; // Longest first, so "private preview" is consumed as its own row before the // "preview" row can claim the tail of it. const SOURCE_PHRASES = STATUS_TABLE.flatMap((r) => r.source.map((p) => ({ row: r.row, phrase: p }))).sort( (a, b) => b.phrase.length - a.phrase.length, ); const CORPUS_LABELS = new Map( STATUS_TABLE.flatMap((r) => r.corpus.map((label) => [label.toLowerCase(), r])), ); // A hyphen counts as a word character HERE, unlike WORD elsewhere in this module: // `2025-11-15-preview` is an api-version identifier, not a statement that the // feature is in preview. Treating `-` as a boundary would harvest lifecycle rows // out of URLs and code samples. const isPhraseChar = (ch) => ch !== undefined && /[A-Za-z0-9-]/.test(ch); /** * Which lifecycle rows does the cited quote assert, per the closed §4b table? * Matching is case-insensitive, non-overlapping, longest phrase first. * @returns {string[]} distinct row keys, in table order */ export function sourceStatusRows(quote) { const text = (quote || '').toLowerCase(); const taken = []; const rows = new Set(); for (const { row, phrase } of SOURCE_PHRASES) { let from = 0; for (;;) { const at = text.indexOf(phrase, from); if (at === -1) break; const end = at + phrase.length; from = end; if (taken.some(([s, e]) => at < e && end > s)) continue; if (isPhraseChar(text[at - 1]) || isPhraseChar(text[end])) continue; taken.push([at, end]); rows.add(row); } } return STATUS_TABLE.map((r) => r.row).filter((r) => rows.has(r)); } // Markup wrappers the corpus actually uses around a lifecycle label. The label is // replaced INSIDE the wrapper, which is how §4b constraint 3 ("the file's own // markup preserved") is satisfied without any markup handling at write time. const WRAPPERS = ['**', '__', '*', '_', '`']; /** * Is this segment exactly a lifecycle label, once whitespace and one or more * markup wrappers are peeled off? Returns the label's span in the ORIGINAL line. */ function labelInSegment(segment, segStart) { let text = segment; let off = 0; const trim = () => { const lead = text.length - text.trimStart().length; off += lead; text = text.trim(); }; trim(); for (;;) { const w = WRAPPERS.find((x) => text.length > 2 * x.length && text.startsWith(x) && text.endsWith(x)); if (!w) break; off += w.length; text = text.slice(w.length, text.length - w.length); trim(); } const rec = CORPUS_LABELS.get(text.toLowerCase()); return rec ? { row: rec.row, label: text, index: segStart + off, length: text.length } : null; } // An emphasised run anywhere on the line. `|` is excluded from the inner text so a // run can never span two table cells. const EMPHASIS_RE = /(\*\*|__|\*|_|`)([^*_`|]+?)\1/g; /** * The single complete lifecycle label on `line`, or null when there is none or * more than one (§4b constraint 2: a whole table cell or an emphasised token, * never a substring of a longer sentence). * * Deliberately LINE-scoped rather than block-scoped, unlike the numeric locator. * Lifecycle vocabulary repeats down every column of a status table, so a block * window is ambiguous by construction — and all 15 pilot flags in this class * point at the row that carries the claim, not at the table header. * * @returns {{row: string, label: string, index: number, length: number}|null} */ export function fileStatusLabel(line) { const text = line || ''; const hits = []; if (text.includes('|')) { let at = 0; for (const cell of text.split('|')) { const hit = labelInSegment(cell, at); if (hit) hits.push(hit); at += cell.length + 1; } } EMPHASIS_RE.lastIndex = 0; let m; while ((m = EMPHASIS_RE.exec(text)) !== null) { const hit = labelInSegment(m[0], m.index); if (hit && !hits.some((h) => h.index === hit.index)) hits.push(hit); } return hits.length === 1 ? hits[0] : null; } /** * §4b: classify a status-vocabulary flag against the ratified table. * * Every abort keeps the STATUS_SYNONYM code and names its cause in `detail.reason` * — the top-level abort taxonomy is the pilot's measurement #3 and stays * comparable across the implementation, with the reasons reported as a * sub-distribution. */ function classifyStatusSynonym(flag, lines) { const detail = (reason, extra = {}) => abort(ABORT_CODES.STATUS_SYNONYM, { detail: { reason, ...extra } }); if (flag.line < 1 || flag.line > lines.length) return detail('LINE_OUT_OF_RANGE', { line: flag.line }); const rows = sourceStatusRows(flag.evidence_quote || ''); if (rows.length === 0) return detail('NO_SOURCE_STATUS'); if (rows.length > 1) return detail('SOURCE_STATUS_AMBIGUOUS', { rows }); const before = lines[flag.line - 1]; const hit = fileStatusLabel(before); if (!hit) return detail('NO_COMPLETE_FILE_LABEL'); if (hit.row === rows[0]) return detail('FILE_ALREADY_MATCHES', { row: hit.row }); const target = STATUS_TABLE.find((r) => r.row === rows[0]); const replacement = target.corpus[0]; const after = before.slice(0, hit.index) + replacement + before.slice(hit.index + hit.length); // §4 conditions 2 and 3 still bind. Condition 1 is replaced by the table: what // must appear verbatim in the quote is the SOURCE phrasing, not the written value. const rest = before.slice(0, hit.index) === after.slice(0, hit.index) && before.slice(hit.index + hit.length) === after.slice(hit.index + replacement.length); const rebuilt = lines.slice(); rebuilt[flag.line - 1] = after; const changedLines = rebuilt.reduce((n, l, i) => n + (l === lines[i] ? 0 : 1), 0); if (!rest || changedLines !== 1) { return abort(ABORT_CODES.INVARIANT_FAIL, { detail: { restIdentical: rest, changedLines } }); } return { op: 'O1', code: PROVEN, proposal: { file: flag.file, line: flag.line, token: hit.label, replacement, type: 'status', status_row_from: hit.row, status_row_to: rows[0], before, after, evidence_url: flag.evidence_url, evidence_quote: flag.evidence_quote, }, }; } /** * Extract swappable value tokens, non-overlapping and priority ordered. * Status words are NOT value tokens — see STATUS_RE. * @returns {Array<{type: string, value: string, index: number}>} in order of appearance */ export function extractValueTokens(text) { if (!text) return []; const taken = []; // [start, end) ranges already consumed by a higher-priority type const out = []; for (const [type, re] of TOKEN_PATTERNS) { re.lastIndex = 0; let m; while ((m = re.exec(text)) !== null) { const start = m.index; const end = start + m[0].length; if (taken.some(([s, e]) => start < e && end > s)) continue; taken.push([start, end]); out.push({ type, value: m[0], index: start }); } } return out.sort((a, b) => a.index - b.index); } /** True if the text carries lifecycle-status vocabulary. */ export function hasStatusWord(text) { return STATUS_RE.test(text || ''); } /** * The contiguous non-blank block containing `line` (1-indexed). * * This is the search window, and it is structural rather than a magic ±N: claims * are LLM-extracted restatements whose `line` often points at a table HEADER * while the asserted value sits in a row below. A block is exactly that table, * list, or paragraph. A blank line degenerates to itself. */ export function blockWindow(lines, line) { if (line < 1 || line > lines.length) return { start: line, end: line }; if (lines[line - 1].trim() === '') return { start: line, end: line }; let start = line; let end = line; while (start > 1 && lines[start - 2].trim() !== '') start -= 1; while (end < lines.length && lines[end].trim() !== '') end += 1; return { start, end }; } // ---------------------------------------------------------- context condition // // MEASURED, NOT ASSUMED: §4 alone admits wrong edits. On the pilot it proved six // swaps of which four were false — "30-dagers" -> "24" from a quote saying 24 // HOURS (unit crossing), an indexing rate replaced by a query throttle (metric // crossing), and two identifiers mutilated by digits harvested out of "E7" and a // table cell ("Microsoft Agent 365" -> "Agent 7", "text-embedding-ada-002" -> // "ada-2"). §4 constrains where the new value CAME FROM and what the edit LOOKS // LIKE; it constrains nothing about whether the two tokens denote the same // quantity. // // The condition below adds that, and it is deliberately lexical rather than // semantic: the token must sit under the same label, or the same trailing unit, // on both sides. No translation table — "dokumenter" is not taught to equal // "documents", because a synonym/translation table introduces a new fact source // and is an operator decision (§5-class), not an engineering one. The consequence // is measured and reported: a swap is provable essentially only where the context // is language-neutral (a URL, a code sample, a parameter key). const WORD = /[A-Za-z0-9_.\-æøåÆØÅ]/; /** Normalise a context run for comparison: lowercase, punctuation stripped. */ const normContext = (s) => s.toLowerCase().replace(/[^a-z0-9æøå]/g, ''); /** The word run immediately left of [index], skipping any separator run first. */ function leftContext(text, index) { let i = index - 1; // A separator run may be skipped; a word character adjacent to the token may // NOT be — that adjacency is what makes "7" part of the identifier "E7". if (i >= 0 && !WORD.test(text[i])) { while (i >= 0 && !WORD.test(text[i])) i -= 1; } let end = i + 1; while (i >= 0 && WORD.test(text[i])) i -= 1; return normContext(text.slice(i + 1, end)); } /** The word run immediately right of [index], skipping any separator run first. */ function rightContext(text, index) { let i = index; if (i < text.length && !WORD.test(text[i])) { while (i < text.length && !WORD.test(text[i])) i += 1; } const start = i; while (i < text.length && WORD.test(text[i])) i += 1; return normContext(text.slice(start, i)); } /** * Do the two occurrences sit in corresponding context? True when a non-empty * label matches on the left, or a non-empty unit matches on the right. */ export function contextCorresponds(fileLine, fileIndex, fileLen, quote, quoteIndex, quoteLen) { const lf = leftContext(fileLine, fileIndex); const lq = leftContext(quote, quoteIndex); if (lf && lf === lq) return true; const rf = rightContext(fileLine, fileIndex + fileLen); const rq = rightContext(quote, quoteIndex + quoteLen); return Boolean(rf) && rf === rq; } /** Distinct by type+value, preserving order. */ function distinct(tokens) { const seen = new Set(); return tokens.filter((t) => { const k = `${t.type}${t.value}`; if (seen.has(k)) return false; seen.add(k); return true; }); } function abort(code, detail = {}) { return { op: 'O3', code, ...detail }; } /** * Classify one flag record into a fix operation. * * @param {object} flag flag record per docs/r11-flag-format-2026-07.md * @param {string} fileText current content of flag.file * @param {{contextCheck?: boolean}} [opts] contextCheck:false reproduces §4 exactly as * written — used to MEASURE what the context condition adds, never to ship edits. * @returns {{op: 'O1'|'O3', code: string, proposal?: object, detail?: object}} */ export function classifyFlag(flag, fileText, opts = {}) { const contextCheck = opts.contextCheck !== false; const claim = flag.claim || ''; const lines = (fileText || '').split('\n'); // §3: an enumeration is not a value swap even when it contains a number. The // structure decides, not the rule code — R8 is a signal, and the run records // it, but it is not the partition. if (claim.includes(' | ') || (claim.match(/,/g) || []).length >= 3) { return abort(ABORT_CODES.MULTI_PART_CLAIM); } const tokens = distinct(extractValueTokens(claim)); if (tokens.length === 0) { if (!hasStatusWord(claim)) return abort(ABORT_CODES.NO_VALUE_TOKEN); return classifyStatusSynonym(flag, lines); // §4b — the ratified table, not a swap of prose } if (tokens.length > 1) { return abort(ABORT_CODES.MULTI_VALUE_TOKEN, { detail: { candidates: tokens.map((t) => t.value) } }); } const wrong = tokens[0]; // Locate the value in the block the flag points at. Unique hit or nothing: // a locator that guesses is how a wrong edit reaches a public file. if (flag.line < 1 || flag.line > lines.length) { return abort(ABORT_CODES.LOCATOR_MISS, { detail: { reason: 'line out of range', line: flag.line } }); } const win = blockWindow(lines, flag.line); const hits = []; for (let n = win.start; n <= win.end; n += 1) { const text = lines[n - 1]; let from = 0; for (;;) { const at = text.indexOf(wrong.value, from); if (at === -1) break; hits.push({ line: n, index: at }); from = at + wrong.value.length; } } if (hits.length === 0) return abort(ABORT_CODES.LOCATOR_MISS, { detail: { token: wrong.value, window: win } }); if (hits.length > 1) { return abort(ABORT_CODES.LOCATOR_AMBIGUOUS, { detail: { token: wrong.value, hits: hits.length, window: win } }); } // §4.1: the replacement must occur verbatim in the quote the judge actually // cited. Same type only, and a quote that merely restates the claim's own value // offers no replacement at all. const replacements = distinct(extractValueTokens(flag.evidence_quote || '')).filter( (t) => t.type === wrong.type && t.value !== wrong.value, ); if (replacements.length === 0) return abort(ABORT_CODES.NOT_VERBATIM, { detail: { token: wrong.value, type: wrong.type } }); if (replacements.length > 1) { return abort(ABORT_CODES.MULTI_REPLACEMENT, { detail: { candidates: replacements.map((t) => t.value) } }); } const right = replacements[0]; const hit = hits[0]; const before = lines[hit.line - 1]; // The condition §4 is missing: same label or same unit on both sides. if ( contextCheck && !contextCorresponds(before, hit.index, wrong.value.length, flag.evidence_quote || '', right.index, right.value.length) ) { return abort(ABORT_CODES.CONTEXT_MISMATCH, { detail: { token: wrong.value, replacement: right.value, would_have_been: before.slice(0, hit.index) + right.value + before.slice(hit.index + wrong.value.length), }, }); } const after = before.slice(0, hit.index) + right.value + before.slice(hit.index + wrong.value.length); // Re-check §4 against the constructed edit rather than trusting construction. const quoteHasValue = (flag.evidence_quote || '').includes(right.value); const restIdentical = before.slice(0, hit.index) === after.slice(0, hit.index) && before.slice(hit.index + wrong.value.length) === after.slice(hit.index + right.value.length); const rebuilt = lines.slice(); rebuilt[hit.line - 1] = after; const changedLines = rebuilt.reduce((n, l, i) => n + (l === lines[i] ? 0 : 1), 0); if (!quoteHasValue || !restIdentical || changedLines !== 1) { return abort(ABORT_CODES.INVARIANT_FAIL, { detail: { quoteHasValue, restIdentical, changedLines } }); } return { op: 'O1', code: PROVEN, proposal: { file: flag.file, line: hit.line, token: wrong.value, replacement: right.value, type: wrong.type, before, after, evidence_url: flag.evidence_url, evidence_quote: flag.evidence_quote, }, }; }