Files
msd-core/src/complexity-trigger.cts
Tom Boucher 1f76861202 fix(#3657): tolerate commonmark fence widths in ledger readers (#3733)
* test(#3657): fence-width tolerance regression rows

* test(#3657): fix pure-row fixtures to use appendWindow result shape

* fix(#3657): tolerate commonmark fence widths in ledger readers

* fix(#3657): restore throw-block indentation in parseJsonBlock

* chore(#3657): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
2026-08-21 03:45:06 -04:00

1113 lines
39 KiB
TypeScript

/**
* Complexity-triggered refactor extension point — analyzer, evaluator, and
* baseline persistence (issue #1953).
*
* LEAF MODULE — imports ONLY: node:fs, node:path. No other src/ imports.
*
* Pipeline: analyzeSource (decision-point complexity per function, via
* stripLiterals to blank comments/string/regex content while preserving
* length and line numbers) -> evaluateCandidates (threshold/jump-delta
* scoring against a stable per-function baseline anchor) -> nextBaseline /
* reanchorBaseline (anchor bookkeeping; the anchor never advances on a
* plain evaluate — only an explicit disposition moves it, via
* reanchorBaseline) -> renderProposal / parseProposal (the on-disk
* `-REFACTOR.md` artifact) -> readBaseline / writeBaseline (I/O, atomic
* temp-then-rename, never throws).
*
* Authoritative surface: .gsd/phase/feat-1953-complexity-triggered-refactor/41-api-contract.md
* Executable spec: tests/complexity-trigger.test.cjs
*
* Out of scope for this module: git invocation, config reads,
* capability-activation checks, CLI arg parsing, and the ship-gate query —
* those live in their own modules (command router / ship gate, not this
* leaf module).
*
* Exports:
* Constants: BASELINE_FILE_NAME, PROPOSAL_SUFFIX, SCHEMA_VERSION,
* DEFAULTS, ANALYZABLE_EXTENSIONS, VERDICT, REASON (includes
* REFACTOR_FILE_UNREADABLE for an unreadable/confined-escape file)
* Pure: stripLiterals, analyzeSource, isAnalyzablePath,
* evaluateCandidates, nextBaseline, reanchorBaseline,
* renderProposal, parseProposal
* I/O: readBaseline, writeBaseline
*/
import fs from 'node:fs';
import path from 'node:path';
// ─── Constants ─────────────────────────────────────────────────────────────
export const BASELINE_FILE_NAME = 'complexity-baseline.json'; // under .planning/
export const PROPOSAL_SUFFIX = '-REFACTOR.md'; // ${PHASE_DIR}/${PADDED}-REFACTOR.md
export const SCHEMA_VERSION = 1;
export const DEFAULTS = Object.freeze({ threshold: 15, jumpDelta: 5 });
export const ANALYZABLE_EXTENSIONS = Object.freeze(
['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts'],
);
export const VERDICT = Object.freeze({
TRIGGERED: 'triggered',
BELOW_THRESHOLD: 'below_threshold',
SKIPPED: 'skipped',
});
/**
* Frozen reason enum. Adding a code = three coordinated changes: this enum,
* the emitting call site, and the Object.keys(REASON).sort() lock test.
*/
export const REASON = Object.freeze({
REFACTOR_OK: 'refactor_ok',
REFACTOR_DISABLED: 'refactor_disabled',
REFACTOR_NO_TOUCHED_FILES: 'refactor_no_touched_files',
REFACTOR_GIT_UNAVAILABLE: 'refactor_git_unavailable',
REFACTOR_ANALYZER_UNSUPPORTED: 'refactor_analyzer_unsupported',
REFACTOR_ANALYZER_UNPARSEABLE: 'refactor_analyzer_unparseable',
REFACTOR_FILE_UNREADABLE: 'refactor_file_unreadable',
REFACTOR_BASELINE_MALFORMED: 'refactor_baseline_malformed',
REFACTOR_BASELINE_WRITE_FAILED: 'refactor_baseline_write_failed',
REFACTOR_STRICT_NOT_ENFORCING: 'refactor_strict_not_enforcing',
REFACTOR_ARTIFACT_NOT_FOUND: 'refactor_artifact_not_found',
REFACTOR_ALREADY_DISPOSITIONED: 'refactor_already_dispositioned',
REFACTOR_DECLINE_REASON_EMPTY: 'refactor_decline_reason_empty',
REFACTOR_INVALID_PHASE: 'refactor_invalid_phase',
REFACTOR_USAGE: 'refactor_usage',
});
// ─── Types ─────────────────────────────────────────────────────────────────
export interface FunctionScore {
name: string;
startLine: number;
endLine: number;
score: number;
}
export type AnalyzedFile =
| { file: string; ok: true; method: 'decision-points'; functions: FunctionScore[] }
| { file: string; ok: false; reason: string };
export interface BaselineEntry {
score: number;
phase?: string;
}
export type Baseline = Record<string, BaselineEntry>; // key: `${file}::${name}`
export interface Candidate {
file: string;
name: string;
startLine: number;
score: number;
baseline: number | null; // null when the function has no baseline
delta: number | null; // null when baseline is null — NEVER `score`
reasons: Array<'threshold' | 'jump'>; // ordered ['threshold','jump'] when both
}
export interface Evaluation {
verdict: (typeof VERDICT)[keyof typeof VERDICT];
candidates: Candidate[];
target: Candidate | null; // candidates[0] ?? null
skipped: Array<{ file: string; reason: string }>;
thresholdUsed: number;
jumpDeltaUsed: number;
}
export interface Proposal {
schema_version: number;
status: 'proposed' | 'accepted' | 'declined';
phase: string;
target_file: string;
target_function: string;
score: number;
baseline: number | null;
delta: number | null;
metric: 'decision-points';
recorded_at: string;
resolved_at: string | null;
reason: string;
candidates: Candidate[];
}
// ─── stripLiterals ──────────────────────────────────────────────────────────
/**
* Words after which a following `/` must be interpreted as the start of an
* expression (so a `/` there is a regex literal, never division).
*/
const REGEX_KEYWORD_CONTEXT_WORDS = new Set([
'return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void',
'throw', 'case', 'do', 'else', 'yield', 'await', 'default',
]);
function blankChar(ch: string): string {
return ch === '\n' ? '\n' : ' ';
}
/**
* Replaces comment and literal *content* with spaces, preserving length and
* every newline so line numbers survive. Handles `//`, `/* *\/`, `'`, `"`,
* backtick templates (`${...}` interpolations are kept as code), regex
* literals (disambiguated from division by the preceding significant
* token), and backslash escapes. Unterminated string / template / block
* comment => { ok:false, reason: REFACTOR_ANALYZER_UNPARSEABLE }.
*/
export function stripLiterals(
source: string,
): { ok: true; stripped: string } | { ok: false; reason: string } {
const len = source.length;
const out: string[] = new Array<string>(len);
let i = 0;
let lastTokenIsValue = false;
interface TemplateFrame { state: 'literal' | 'interp'; braceDepth: number }
const templateStack: TemplateFrame[] = [];
while (i < len) {
const top = templateStack[templateStack.length - 1];
if (top && top.state === 'literal') {
const ch = source[i];
if (ch === '\\') {
out[i] = blankChar(ch);
i++;
if (i < len) { out[i] = blankChar(source[i]); i++; }
continue;
}
if (ch === '`') {
out[i] = ' ';
templateStack.pop();
i++;
lastTokenIsValue = true;
continue;
}
if (ch === '$' && source[i + 1] === '{') {
out[i] = '$';
out[i + 1] = '{';
top.state = 'interp';
top.braceDepth = 0;
i += 2;
lastTokenIsValue = false;
continue;
}
out[i] = blankChar(ch);
i++;
continue;
}
const ch = source[i];
// Line comment.
if (ch === '/' && source[i + 1] === '/') {
while (i < len && source[i] !== '\n') { out[i] = ' '; i++; }
continue;
}
// Block comment.
if (ch === '/' && source[i + 1] === '*') {
out[i] = ' '; out[i + 1] = ' '; i += 2;
let closed = false;
while (i < len) {
if (source[i] === '*' && source[i + 1] === '/') {
out[i] = ' '; out[i + 1] = ' '; i += 2; closed = true; break;
}
out[i] = blankChar(source[i]); i++;
}
if (!closed) return { ok: false, reason: REASON.REFACTOR_ANALYZER_UNPARSEABLE };
lastTokenIsValue = false;
continue;
}
// Single/double-quoted string. A raw (unescaped) newline before the
// closing quote is treated as unterminated (matches real JS syntax).
if (ch === '\'' || ch === '"') {
const quote = ch;
out[i] = ' '; i++;
let closed = false;
while (i < len) {
const c = source[i];
if (c === '\n') break;
if (c === '\\') {
out[i] = ' '; i++;
if (i < len && source[i] !== '\n') { out[i] = ' '; i++; }
continue;
}
if (c === quote) { out[i] = ' '; i++; closed = true; break; }
out[i] = ' '; i++;
}
if (!closed) return { ok: false, reason: REASON.REFACTOR_ANALYZER_UNPARSEABLE };
lastTokenIsValue = true;
continue;
}
// Template literal open.
if (ch === '`') {
out[i] = ' ';
templateStack.push({ state: 'literal', braceDepth: 0 });
i++;
continue;
}
// Regex literal candidate — only when the preceding significant token
// indicates an expression context (never after a value-producing token).
if (ch === '/' && !lastTokenIsValue) {
const start = i;
out[i] = ' '; i++;
let inClass = false;
let closed = false;
while (i < len) {
const c = source[i];
if (c === '\n') break;
if (c === '\\') {
out[i] = ' '; i++;
if (i < len && source[i] !== '\n') { out[i] = ' '; i++; }
continue;
}
if (c === '[') { inClass = true; out[i] = ' '; i++; continue; }
if (c === ']') { inClass = false; out[i] = ' '; i++; continue; }
if (c === '/' && !inClass) { out[i] = ' '; i++; closed = true; break; }
out[i] = ' '; i++;
}
if (closed) {
while (i < len && /[a-zA-Z]/.test(source[i])) { out[i] = ' '; i++; }
lastTokenIsValue = true;
continue;
}
// Not actually a regex literal (never closed) — fall back to
// treating the '/' as division and re-scan normally from there.
i = start;
out[i] = source[i]; i++;
lastTokenIsValue = false;
continue;
}
// Template interpolation brace tracking (real code inside ${...}).
if (top && top.state === 'interp') {
if (ch === '{') { top.braceDepth++; out[i] = ch; i++; lastTokenIsValue = false; continue; }
if (ch === '}') {
if (top.braceDepth === 0) {
out[i] = '}';
top.state = 'literal';
i++;
lastTokenIsValue = false;
continue;
}
top.braceDepth--;
out[i] = ch; i++; lastTokenIsValue = true; continue;
}
}
// Identifier / keyword.
if (/[A-Za-z_$]/.test(ch)) {
let j = i;
while (j < len && /[A-Za-z0-9_$]/.test(source[j])) j++;
const word = source.slice(i, j);
for (let k = i; k < j; k++) out[k] = source[k];
lastTokenIsValue = !REGEX_KEYWORD_CONTEXT_WORDS.has(word);
i = j;
continue;
}
// Numeric literal.
if (/[0-9]/.test(ch)) {
let j = i;
while (j < len && /[0-9a-fA-FxXoObB.]/.test(source[j])) j++;
for (let k = i; k < j; k++) out[k] = source[k];
lastTokenIsValue = true;
i = j;
continue;
}
if (ch === ')' || ch === ']') {
out[i] = ch; i++; lastTokenIsValue = true; continue;
}
if (ch === '\n') { out[i] = '\n'; i++; continue; }
out[i] = ch; i++;
if (!/\s/.test(ch)) lastTokenIsValue = false;
}
if (templateStack.length > 0) {
return { ok: false, reason: REASON.REFACTOR_ANALYZER_UNPARSEABLE };
}
return { ok: true, stripped: out.join('') };
}
// ─── analyzeSource ──────────────────────────────────────────────────────────
const DECISION_KEYWORDS = new Set(['if', 'for', 'while', 'do', 'case', 'catch']);
// Words that can textually precede `(...) {` without being a method/function
// definition — must be excluded from the method-shorthand heuristic so
// `if (x) {` / `switch (x) {` etc. are never mistaken for a function.
const NON_METHOD_WORDS = new Set([...DECISION_KEYWORDS, 'switch', 'with']);
function findMatchingParen(s: string, openIdx: number): number {
let depth = 0;
for (let k = openIdx; k < s.length; k++) {
if (s[k] === '(') depth++;
else if (s[k] === ')') { depth--; if (depth === 0) return k; }
}
return -1;
}
/**
* Attempts to skip a generic type-parameter list starting at `s[idx] === '<'`
* (e.g. `<T>`, `<T extends X>`, `<T = Y>`, `<T, U>`, nested `<T extends
* Record<string, X>>`). Returns the index just past the matching top-level
* `>`, or -1 when the content is not shaped like one — either the brackets
* never balance, or an operator token that never appears inside a type
* parameter list (`&&`, `||`, `==`, `<=`, `>=`, arithmetic, a bare `?`)
* shows up first. This — plus the caller only ever committing to the skip
* when a `(` immediately follows the close — is what keeps a `<` used as a
* less-than comparison from being mistaken for generics: a real comparison
* either fails to balance before an unrelated bracket closes, contains one
* of the disallowed operator tokens, or is not immediately followed by a
* parameter list.
*/
function skipGenericParamList(s: string, idx: number): number {
const len = s.length;
let i = idx + 1;
const stack: string[] = ['>'];
while (i < len) {
const ch = s[i];
const next = s[i + 1];
if (ch === '=' && next === '>') { i += 2; continue; } // nested function-type arrow (e.g. a default's `(a:X)=>Y`)
if (
(ch === '=' && next === '=')
|| (ch === '<' && next === '=')
|| (ch === '>' && next === '=')
|| (ch === '&' && next === '&')
|| (ch === '|' && next === '|')
|| ch === '+' || ch === '*' || ch === '%' || ch === '!'
|| ch === '?' || ch === ';' || ch === '/' || ch === '-'
) return -1;
if (ch === '<') { stack.push('>'); i++; continue; }
if (ch === '(') { stack.push(')'); i++; continue; }
if (ch === '[') { stack.push(']'); i++; continue; }
if (ch === '{') { stack.push('}'); i++; continue; }
if (ch === '>' || ch === ')' || ch === ']' || ch === '}') {
if (stack.length === 0 || stack[stack.length - 1] !== ch) return -1;
stack.pop();
i++;
if (stack.length === 0) return i;
continue;
}
i++;
}
return -1;
}
/**
* Attempts to skip a return-type / type-predicate annotation starting at
* `s[idx] === ':'` (e.g. `: Promise<void>`, `: Record<string, X>`, `: A |
* B`, `: { a: number }`, `: string[]`, `: x is Foo`). Returns the index of
* the terminator that follows — a function-body `{`, an arrow `=>`, or a
* statement-ending `;` — never consuming the terminator itself, or -1 when
* the content is not shaped like a type. `expectStart` tracks whether a
* fresh type token may begin here so an object-type literal's own `{`/`}`
* (`: { a: number }`) is never mistaken for the function body, and an
* unexpected/unmatched closing bracket (e.g. the real end of an enclosing
* `switch`/ternary this `:` was actually a member of, not a return type)
* aborts rather than guesses.
*/
function skipTypeExpr(s: string, idx: number): number {
const len = s.length;
let i = idx + 1;
const stack: string[] = [];
let expectStart = true;
while (i < len) {
const ch = s[i];
if (/\s/.test(ch)) { i++; continue; }
const next = s[i + 1];
if (stack.length === 0) {
if (ch === ';') return i;
if (ch === '=' && next === '>') return i;
if (ch === '{' && !expectStart) return i;
if (ch === ',' && !expectStart) return i;
if (ch === '?') return -1; // a bare top-level '?' means this was a ternary, not a return type
}
if (ch === '=' && next === '>') { i += 2; expectStart = true; continue; } // nested function-type arrow
if (
(ch === '=' && next === '=')
|| (ch === '<' && next === '=')
|| (ch === '>' && next === '=')
|| (ch === '&' && next === '&')
|| (ch === '|' && next === '|')
|| ch === '+' || ch === '*' || ch === '%' || ch === '!'
) return -1;
if (ch === '<') { stack.push('>'); i++; expectStart = true; continue; }
if (ch === '(') { stack.push(')'); i++; expectStart = true; continue; }
if (ch === '[') { stack.push(']'); i++; expectStart = true; continue; }
if (ch === '{') { stack.push('}'); i++; expectStart = true; continue; }
if (ch === '>' || ch === ')' || ch === ']' || ch === '}') {
if (stack.length === 0 || stack[stack.length - 1] !== ch) return -1;
stack.pop();
i++;
expectStart = false;
continue;
}
if (ch === '|' || ch === '&' || ch === ',' || ch === '.' || ch === ':') {
i++; expectStart = true; continue;
}
if (/[A-Za-z0-9_$]/.test(ch)) {
let j = i;
while (j < len && /[A-Za-z0-9_$]/.test(s[j])) j++;
i = j;
expectStart = false;
continue;
}
i++;
}
return -1;
}
function newlinesBetween(s: string, from: number, to: number): number {
let n = 0;
for (let k = from; k < to; k++) if (s[k] === '\n') n++;
return n;
}
/** Best-effort name inference for an arrow function from `NAME = ` or `NAME: ` immediately preceding it. */
function inferArrowName(s: string, pos: number): string {
let j = pos - 1;
while (j >= 0 && /\s/.test(s[j])) j--;
const isPlainEquals = j >= 0 && s[j] === '='
&& s[j - 1] !== '=' && s[j - 1] !== '!' && s[j - 1] !== '<' && s[j - 1] !== '>';
const isPropColon = j >= 0 && s[j] === ':';
if (!isPlainEquals && !isPropColon) return '';
j--;
while (j >= 0 && /\s/.test(s[j])) j--;
const end = j + 1;
while (j >= 0 && /[A-Za-z0-9_$]/.test(s[j])) j--;
return s.slice(j + 1, end);
}
type FunctionFrame =
| { kind: 'function'; name: string; startLine: number; score: number; mode: 'brace' }
| { kind: 'function'; name: string; startLine: number; score: number; mode: 'expr'; entryDepth: number };
type BlockFrame = { kind: 'block' };
type ScanFrame = FunctionFrame | BlockFrame;
function scanFunctions(stripped: string): FunctionScore[] {
const len = stripped.length;
let i = 0;
let line = 1;
let depth = 0;
const stack: ScanFrame[] = [];
const results: FunctionScore[] = [];
function currentFunctionFrame(): FunctionFrame | null {
for (let k = stack.length - 1; k >= 0; k--) {
const f = stack[k];
if (f.kind === 'function') return f;
}
return null;
}
function bump(): void {
const f = currentFunctionFrame();
if (f) f.score += 1;
}
function closeExprFramesIfNeeded(atDepth: number): void {
for (;;) {
const top = stack[stack.length - 1];
if (top && top.kind === 'function' && top.mode === 'expr' && top.entryDepth === atDepth) {
stack.pop();
results.push({ name: top.name, startLine: top.startLine, endLine: line, score: top.score });
} else {
break;
}
}
}
while (i < len) {
const ch = stripped[i];
if (ch === '\n') { line++; i++; continue; }
if (/\s/.test(ch)) { i++; continue; }
if (ch === '&' && stripped[i + 1] === '&') { bump(); i += 2; continue; }
if (ch === '|' && stripped[i + 1] === '|') { bump(); i += 2; continue; }
if (ch === '?') {
if (stripped[i + 1] === '.') { i += 2; continue; }
if (stripped[i + 1] === '?') { i += 2; continue; }
bump(); i += 1; continue;
}
if (ch === '(') {
const closeIdx = findMatchingParen(stripped, i);
if (closeIdx !== -1) {
let k = closeIdx + 1;
while (k < len && /\s/.test(stripped[k])) k++;
if (stripped[k] === ':') {
const afterType = skipTypeExpr(stripped, k);
if (afterType !== -1) {
k = afterType;
while (k < len && /\s/.test(stripped[k])) k++;
}
}
if (stripped[k] === '=' && stripped[k + 1] === '>') {
const name = inferArrowName(stripped, i);
const startLine = line;
let bodyStart = k + 2;
while (bodyStart < len && /\s/.test(stripped[bodyStart])) bodyStart++;
const nl = newlinesBetween(stripped, i, bodyStart);
if (stripped[bodyStart] === '{') {
line += nl;
stack.push({ kind: 'function', name, startLine, score: 1, mode: 'brace' });
depth++;
i = bodyStart + 1;
continue;
}
line += nl;
stack.push({ kind: 'function', name, startLine, score: 1, mode: 'expr', entryDepth: depth });
i = bodyStart;
continue;
}
}
depth++; i++; continue;
}
if (ch === '[') { depth++; i++; continue; }
if (ch === ')' || ch === ']') {
closeExprFramesIfNeeded(depth);
depth--; i++; continue;
}
if (ch === '{') {
stack.push({ kind: 'block' });
depth++; i++; continue;
}
if (ch === '}') {
closeExprFramesIfNeeded(depth);
depth--;
const top = stack.pop();
if (top && top.kind === 'function' && top.mode === 'brace') {
results.push({ name: top.name, startLine: top.startLine, endLine: line, score: top.score });
}
i++; continue;
}
if (ch === ';' || ch === ',') {
closeExprFramesIfNeeded(depth);
i++; continue;
}
if (/[A-Za-z_$]/.test(ch)) {
let j = i;
while (j < len && /[A-Za-z0-9_$]/.test(stripped[j])) j++;
const word = stripped.slice(i, j);
const wordLine = line;
if (word === 'function') {
let k = j;
while (k < len && /\s/.test(stripped[k])) k++;
if (stripped[k] === '*') { k++; while (k < len && /\s/.test(stripped[k])) k++; }
let name = '';
if (k < len && /[A-Za-z_$]/.test(stripped[k])) {
let m = k;
while (m < len && /[A-Za-z0-9_$]/.test(stripped[m])) m++;
name = stripped.slice(k, m);
k = m;
while (k < len && /\s/.test(stripped[k])) k++;
}
if (stripped[k] === '<') {
const afterGenerics = skipGenericParamList(stripped, k);
if (afterGenerics !== -1) {
let kk = afterGenerics;
while (kk < len && /\s/.test(stripped[kk])) kk++;
if (stripped[kk] === '(') k = kk; // only commit if genuinely followed by params
}
}
if (stripped[k] === '(') {
const closeIdx = findMatchingParen(stripped, k);
if (closeIdx !== -1) {
let p = closeIdx + 1;
while (p < len && /\s/.test(stripped[p])) p++;
if (stripped[p] === ':') {
const afterType = skipTypeExpr(stripped, p);
if (afterType !== -1) {
p = afterType;
while (p < len && /\s/.test(stripped[p])) p++;
}
}
if (stripped[p] === '{') {
line += newlinesBetween(stripped, i, p);
stack.push({ kind: 'function', name, startLine: wordLine, score: 1, mode: 'brace' });
depth++;
i = p + 1;
continue;
}
}
}
i = j;
continue;
}
if (NON_METHOD_WORDS.has(word)) {
if (DECISION_KEYWORDS.has(word)) bump();
i = j;
continue;
}
{
let k = j;
while (k < len && /\s/.test(stripped[k])) k++;
if (stripped[k] === '<') {
const afterGenerics = skipGenericParamList(stripped, k);
if (afterGenerics !== -1) {
let kk = afterGenerics;
while (kk < len && /\s/.test(stripped[kk])) kk++;
if (stripped[kk] === '(') k = kk; // only commit if genuinely followed by params
}
}
if (stripped[k] === '(') {
const closeIdx = findMatchingParen(stripped, k);
if (closeIdx !== -1) {
let p = closeIdx + 1;
while (p < len && /\s/.test(stripped[p])) p++;
if (stripped[p] === ':') {
const afterType = skipTypeExpr(stripped, p);
if (afterType !== -1) {
p = afterType;
while (p < len && /\s/.test(stripped[p])) p++;
}
}
if (stripped[p] === '{') {
line += newlinesBetween(stripped, i, p);
stack.push({ kind: 'function', name: word, startLine: wordLine, score: 1, mode: 'brace' });
depth++;
i = p + 1;
continue;
}
}
} else if (stripped[k] === '=' && stripped[k + 1] === '>') {
const name = inferArrowName(stripped, i);
let bodyStart = k + 2;
while (bodyStart < len && /\s/.test(stripped[bodyStart])) bodyStart++;
const nl = newlinesBetween(stripped, i, bodyStart);
if (stripped[bodyStart] === '{') {
line += nl;
stack.push({ kind: 'function', name, startLine: wordLine, score: 1, mode: 'brace' });
depth++;
i = bodyStart + 1;
continue;
}
line += nl;
stack.push({ kind: 'function', name, startLine: wordLine, score: 1, mode: 'expr', entryDepth: depth });
i = bodyStart;
continue;
}
}
i = j;
continue;
}
i++;
}
closeExprFramesIfNeeded(depth);
return results;
}
/**
* Normalizes CRLF -> LF *before* analysis so scores and line numbers are
* identical either way. Base score 1 per function, +1 for each of: if,
* else-if (the if, not the else), for, for..of, for..in, while, do, case
* (not default), catch, &&, ||, ?:. Word-boundary matched. Not counted:
* ?., ??, bare else, default:. Decision points are attributed to the
* innermost enclosing function; top-level code belongs to no function.
*/
export function analyzeSource(
source: string,
): { ok: true; method: 'decision-points'; functions: FunctionScore[] } | { ok: false; reason: string } {
const normalized = source.replace(/\r\n/g, '\n');
const stripped = stripLiterals(normalized);
if (!stripped.ok) return { ok: false, reason: stripped.reason };
return { ok: true, method: 'decision-points', functions: scanFunctions(stripped.stripped) };
}
// ─── isAnalyzablePath ───────────────────────────────────────────────────────
/**
* Normalizes separators unconditionally (never via path.sep). True iff the
* extension is analyzable and the path is not under tests/ or
* gsd-core/bin/lib/.
*/
export function isAnalyzablePath(relPath: string): boolean {
const normalized = relPath.replace(/\\/g, '/');
const ext = path.posix.extname(normalized);
if (!ANALYZABLE_EXTENSIONS.includes(ext)) return false;
if (normalized === 'tests' || normalized.startsWith('tests/')) return false;
if (normalized === 'gsd-core/bin/lib' || normalized.startsWith('gsd-core/bin/lib/')) return false;
return true;
}
// ─── evaluateCandidates ─────────────────────────────────────────────────────
function coercePositiveNumber(v: unknown, fallback: number): number {
const n = typeof v === 'number' ? v : Number(v);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
export function evaluateCandidates(input: {
analyzed: AnalyzedFile[];
baseline: Baseline;
threshold?: unknown;
jumpDelta?: unknown;
}): Evaluation {
const thresholdUsed = coercePositiveNumber(input.threshold, DEFAULTS.threshold);
const jumpDeltaUsed = coercePositiveNumber(input.jumpDelta, DEFAULTS.jumpDelta);
const candidates: Candidate[] = [];
const skipped: Array<{ file: string; reason: string }> = [];
for (const entry of input.analyzed) {
if (!entry.ok) {
skipped.push({ file: entry.file, reason: entry.reason });
continue;
}
for (const fn of entry.functions) {
const key = `${entry.file}::${fn.name}`;
const baselineEntry = input.baseline[key];
const baselineScore = baselineEntry ? baselineEntry.score : null;
const reasons: Array<'threshold' | 'jump'> = [];
if (fn.score > thresholdUsed) reasons.push('threshold');
let delta: number | null = null;
if (baselineScore !== null) {
delta = fn.score - baselineScore;
if (delta > jumpDeltaUsed) reasons.push('jump');
}
if (reasons.length > 0) {
candidates.push({
file: entry.file,
name: fn.name,
startLine: fn.startLine,
score: fn.score,
baseline: baselineScore,
delta,
reasons,
});
}
}
}
candidates.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
const ad = a.delta === null ? -Infinity : a.delta;
const bd = b.delta === null ? -Infinity : b.delta;
if (bd !== ad) return bd - ad;
if (a.file !== b.file) return a.file < b.file ? -1 : 1;
if (a.name !== b.name) return a.name < b.name ? -1 : 1;
return a.startLine - b.startLine;
});
return {
verdict: candidates.length > 0 ? VERDICT.TRIGGERED : VERDICT.BELOW_THRESHOLD,
candidates,
target: candidates[0] ?? null,
skipped,
thresholdUsed,
jumpDeltaUsed,
};
}
// ─── Baseline anchor bookkeeping ────────────────────────────────────────────
/**
* Anchor semantics (see 41-api-contract.md "Anchor semantics" — corrected
* 2026-08-09): for each successfully analyzed function, no prior entry
* inserts an anchor at the current score (first observation); a prior
* entry carries forward UNCHANGED. The anchor never advances on a plain
* evaluate, whether or not the function triggered — only reanchorBaseline
* (called by an explicit disposition) moves it.
*/
export function nextBaseline(
prev: Baseline,
analyzed: AnalyzedFile[],
opts: { analyzedFiles?: string[]; phase?: string } = {},
): Baseline {
const next: Baseline = {};
const seenKeys = new Set<string>();
for (const entry of analyzed) {
if (!entry.ok) continue;
for (const fn of entry.functions) {
const key = `${entry.file}::${fn.name}`;
seenKeys.add(key);
const existing = prev[key];
if (existing) {
next[key] = existing;
} else {
next[key] = opts.phase ? { score: fn.score, phase: opts.phase } : { score: fn.score };
}
}
}
const analyzedFileSet = new Set(opts.analyzedFiles ?? []);
for (const [key, entry] of Object.entries(prev)) {
if (seenKeys.has(key)) continue;
const file = key.slice(0, key.lastIndexOf('::'));
if (analyzedFileSet.has(file)) continue; // function no longer exists -> prune
next[key] = entry; // file untouched this run -> never prune
}
return next;
}
/** Moves the anchor for `key` to `score` — accept re-anchors to the post-refactor score, decline re-anchors to the current (higher) score. */
export function reanchorBaseline(
prev: Baseline,
key: string,
score: number,
opts: { phase?: string } = {},
): Baseline {
return {
...prev,
[key]: opts.phase ? { score, phase: opts.phase } : { score },
};
}
// ─── Proposal render/parse ──────────────────────────────────────────────────
const PROPOSAL_JSON_FENCE_OPEN = '````json';
const PROPOSAL_JSON_FENCE_CLOSE = '````';
// Reader-side fence tolerance (#3657 — same defect class as the WINDOWS.md
// ledger): CommonMark formatters (Prettier et al.) narrow the written
// 4-backtick fence to the shortest legal width (3) whenever the body holds no
// backtick run, and a canonical-JSON candidates array never does. Locate the
// block by a line-anchored 3+ fence and close on a run at least as wide
// (CommonMark: a shorter run does not close). The writer above is unchanged.
// This module is a leaf (CONTEXT.md — imports only node:fs/node:path), so the
// span logic is local rather than imported from broken-windows.
const PROPOSAL_FENCE_OPEN_RE = /^(`{3,})json[ \t]*\r?$/m;
function locateProposalJsonBlock(text: string): { jsonText: string } | null {
const open = text.match(PROPOSAL_FENCE_OPEN_RE);
if (!open || open.index === undefined) return null;
const width = open[1].length;
const bodyStart = open.index + open[0].length;
for (const close of text.slice(bodyStart).matchAll(/^(`{3,})[ \t]*\r?$/gm)) {
if (close[1].length < width) continue;
const bodyEnd = close.index ?? 0;
return { jsonText: text.slice(bodyStart, bodyStart + bodyEnd).trim() };
}
return null;
}
export function renderProposal(p: Proposal): string {
const fm = [
'---',
`schema_version: ${p.schema_version}`,
`status: ${p.status}`,
`phase: ${p.phase}`,
`target_file: ${p.target_file}`,
`target_function: ${p.target_function}`,
`score: ${p.score}`,
`baseline: ${p.baseline === null ? 'null' : p.baseline}`,
`delta: ${p.delta === null ? 'null' : p.delta}`,
`metric: ${p.metric}`,
`recorded_at: ${p.recorded_at}`,
`resolved_at: ${p.resolved_at === null ? 'null' : p.resolved_at}`,
`reason: ${JSON.stringify(p.reason)}`,
'---',
'',
].join('\n');
const header = [
`# Refactor proposal: ${p.target_file}::${p.target_function}`,
'',
`Score ${p.score}${p.baseline !== null ? ` (baseline ${p.baseline}, delta ${p.delta})` : ''} — status: ${p.status}.`,
'',
].join('\n');
const jsonBlock = [
PROPOSAL_JSON_FENCE_OPEN,
JSON.stringify(p.candidates, null, 2),
PROPOSAL_JSON_FENCE_CLOSE,
'',
].join('\n');
return [fm, header, jsonBlock].join('\n');
}
/**
* Parses a rendered proposal back into its IR. Frontmatter is the fast
* scalar path; the JSON block is authoritative for candidates. Fails
* closed (returns null) on any structural drift rather than guessing.
*/
export function parseProposal(text: string): Proposal | null {
try {
if (!text.startsWith('---\n') && !text.startsWith('---\r\n')) return null;
const headerEnd = text.startsWith('---\r\n') ? 5 : 4;
const closeIdx = text.indexOf('\n---', headerEnd);
if (closeIdx === -1) return null;
const yamlBody = text.slice(headerEnd, closeIdx);
const fm: Record<string, string> = {};
for (const rawLine of yamlBody.split(/\r?\n/)) {
const line = rawLine.replace(/\r$/, '');
if (line.trim() === '') continue;
const m = line.match(/^([a-zA-Z0-9_]+):\s*(.*)$/);
if (!m) return null;
fm[m[1]] = m[2].trim();
}
const span = locateProposalJsonBlock(text);
if (span === null) return null;
const jsonText = span.jsonText;
let candidates: unknown;
try {
candidates = JSON.parse(jsonText);
} catch {
return null;
}
if (!Array.isArray(candidates)) return null;
const status = fm.status;
if (status !== 'proposed' && status !== 'accepted' && status !== 'declined') return null;
const schemaVersion = Number(fm.schema_version);
if (!Number.isInteger(schemaVersion)) return null;
const score = Number(fm.score);
if (!Number.isFinite(score)) return null;
const baseline = fm.baseline === 'null' ? null : Number(fm.baseline);
if (baseline !== null && !Number.isFinite(baseline)) return null;
const delta = fm.delta === 'null' ? null : Number(fm.delta);
if (delta !== null && !Number.isFinite(delta)) return null;
const resolvedAt = fm.resolved_at === 'null' || fm.resolved_at === undefined ? null : fm.resolved_at;
let reason = '';
try {
const parsedReason: unknown = JSON.parse(fm.reason ?? '""');
reason = typeof parsedReason === 'string' ? parsedReason : (fm.reason ?? '');
} catch {
reason = fm.reason ?? '';
}
return {
schema_version: schemaVersion,
status,
phase: fm.phase ?? '',
target_file: fm.target_file ?? '',
target_function: fm.target_function ?? '',
score,
baseline,
delta,
metric: 'decision-points',
recorded_at: fm.recorded_at ?? '',
resolved_at: resolvedAt,
reason,
candidates: candidates as Candidate[],
};
} catch {
return null;
}
}
// ─── I/O: baseline persistence ──────────────────────────────────────────────
interface FsDeps {
fs?: typeof fs;
}
function baselinePath(planningDir: string): string {
return path.join(planningDir, BASELINE_FILE_NAME);
}
const RENAME_RETRY_ERRNOS = new Set(['EPERM', 'EBUSY', 'EACCES']);
const RENAME_MAX_ATTEMPTS = 5;
const RENAME_BACKOFF_MS = 25;
function renameWithRetry(fsImpl: typeof fs, tmp: string, target: string): void {
let lastErr: unknown;
for (let attempt = 0; attempt < RENAME_MAX_ATTEMPTS; attempt++) {
try {
fsImpl.renameSync(tmp, target);
return;
} catch (err: unknown) {
lastErr = err;
const code = (err && typeof err === 'object' && 'code' in err)
? String((err as { code?: unknown }).code)
: '';
if (code && RENAME_RETRY_ERRNOS.has(code) && attempt < RENAME_MAX_ATTEMPTS - 1) {
const delay = RENAME_BACKOFF_MS * Math.pow(2, attempt);
const start = Date.now();
while (Date.now() - start < delay) {
// Busy-wait a very short time — transient locks usually clear quickly.
}
continue;
}
throw err;
}
}
throw lastErr;
}
/**
* Degrades to { ok:false, baseline:{}, reason: REFACTOR_BASELINE_MALFORMED }
* on absent-but-unreadable, bad JSON, or a non-plain-object root — and
* never rewrites the file on a failed read. Missing file is
* { ok:true, baseline:{} } with no reason. Never throws.
*/
export function readBaseline(
planningDir: string,
deps: FsDeps = {},
): { ok: boolean; baseline: Baseline; reason?: string } {
const fsImpl = deps.fs ?? fs;
const p = baselinePath(planningDir);
let raw: string;
try {
raw = fsImpl.readFileSync(p, 'utf8');
} catch (e: unknown) {
const code = (e && typeof e === 'object' && 'code' in e)
? String((e as { code?: unknown }).code)
: '';
if (code === 'ENOENT') return { ok: true, baseline: {} };
return { ok: false, baseline: {}, reason: REASON.REFACTOR_BASELINE_MALFORMED };
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { ok: false, baseline: {}, reason: REASON.REFACTOR_BASELINE_MALFORMED };
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { ok: false, baseline: {}, reason: REASON.REFACTOR_BASELINE_MALFORMED };
}
return { ok: true, baseline: parsed as Baseline };
}
/**
* Writes `<name>.<pid>.tmp` then renames; on any failure unlinks the temp
* file and returns { ok:false, reason: REFACTOR_BASELINE_WRITE_FAILED }.
* Never throws.
*/
export function writeBaseline(
planningDir: string,
baseline: Baseline,
deps: FsDeps = {},
): { ok: boolean; reason?: string } {
const fsImpl = deps.fs ?? fs;
const p = baselinePath(planningDir);
const tmp = `${p}.${process.pid}.tmp`;
try {
if (!fsImpl.existsSync(planningDir)) {
fsImpl.mkdirSync(planningDir, { recursive: true });
}
fsImpl.writeFileSync(tmp, JSON.stringify(baseline, null, 2), 'utf8');
} catch {
try { fsImpl.unlinkSync(tmp); } catch { /* best-effort cleanup */ }
return { ok: false, reason: REASON.REFACTOR_BASELINE_WRITE_FAILED };
}
try {
renameWithRetry(fsImpl, tmp, p);
} catch {
try { fsImpl.unlinkSync(tmp); } catch { /* best-effort cleanup */ }
return { ok: false, reason: REASON.REFACTOR_BASELINE_WRITE_FAILED };
}
return { ok: true };
}