Files
msd-core/src/complexity-trigger.cts
Tom Boucher b901d1e06f feat(#1953): complexity-triggered refactor extension point (execute:post) (#3261)
* test(#1953): failing-first suite for the complexity-triggered refactor hook

60 behavioral cases against src/complexity-trigger.cts, which does not exist yet:
decision-point counting, the comment/literal stripping leak surface, threshold and
jump-delta boundaries at limit-1/limit/limit+1, stable-anchor baseline semantics,
and fs fault injection via mock.method. Two fast-check properties assert that
stripping never manufactures a decision point and that comments and string
literals are score-neutral.

Also registers the refactor-trigger capability manifest (inert until
refactor.trigger_enabled) and regenerates the capability registry and matrix.

Verified RED on the remote runner before any implementation exists.

* feat(#1953): complexity-triggered refactor extension point

Adds the opt-in refactor-trigger capability. After a phase executes, an
execute:post step measures per-function complexity for the files the phase
touched and writes a scoped refactor proposal when a function crosses the
configured threshold or drifts past its recorded anchor.

Design notes worth carrying:

- The signal is computed in-core (decision-point counting over comment- and
  literal-stripped source, Node builtins only) rather than via Memtrace or a
  shelled-out analyzer. The hook fires as a deterministic CLI, not an agent
  with MCP tools, and core takes no external dependencies — this is the only
  option a behavioral test can bind to. The metric sits behind a seam.
- The baseline is a stable anchor, not a rolling value: set on first
  observation, moved only on disposition. A rolling baseline makes the delta
  the single-phase change, so a function creeping +2 per phase never trips a
  delta of 5 and the jump check adds nothing over the absolute threshold.
- Strict mode records an open deviation window in the broken-windows ledger
  rather than declaring its own ship:pre gate. ship.md has no generic ship:pre
  gate dispatch — only two hardcoded branches — so a third gate of any kind
  would be declared and never evaluated.
- The gate clears on the proposal being dispositioned, never on the score
  improving. A blocking complexity number is one an executor can satisfy by
  splitting a coherent function in two.

execute-phase.md gains a generic execute:post step-dispatch contract; it
previously matched only ref.skill == "code-review", so any other step
registered there was declared and never run. The code-review branch is
unchanged.

Full rationale in ADR-1953.

Closes #1953

* fix(#1953): close git option injection and symlink escape in the refactor hook

Three findings from the isolated security review, all fixed inline.

HIGH — changedFilesSince interpolated the --since value into a revision
token placed before the -- separator. A -- only stops PATHSPEC parsing of
arguments after it; git still option-parses what comes before. So
--since '--output=/tmp/x' became --output=/tmp/x..HEAD, which git accepts
as --output=<file> and uses to redirect diff output — an arbitrary write.
Fixed with --end-of-options before the revision range plus a conservative
ref validator. The validator deliberately permits ~ ^ @ { } because those
are legitimate git REVISION syntax (HEAD~1, main@{yesterday}) as distinct
from ref-NAME syntax; --end-of-options is the actual barrier. The doc
comment asserting the trailing -- was sufficient was wrong and is corrected.

MEDIUM — resolveConfinedPath confined by string prefix only, so a symlink
committed inside the repo passed the check (its own path is under cwd) and
readFileSync then followed it outside the root. Now lstat-checks for a
regular file and skips anything else with REFACTOR_FILE_UNREADABLE, so one
bad path skips one file and the run continues.

LOW — the new execute:post dispatch contract showed the gsd_run example
before the rule requiring ref.command be validated first. That prose is
executed by an agent, so textual order is execution order. Reordered.

Refs #1953

* fix(#1953): make the analyzer able to see TypeScript at all

Found by running the shipped analyzer over its own source: it reported
functions=1 for a 940-line module with 24 function forms. A return-type
annotation or a generic parameter list made a function invisible —
`function f(a): number {}` and `function f<T>(a: T): T {}` both detected as
zero. Since gsd-core is written in .cts and the capability declares
.ts/.cts/.mts analyzable, the feature silently found nothing in this repo's
own primary language while reporting success. A safety net that reports
"all clear" because it cannot see is worse than no safety net.

All 98 tests passed over this, because every fixture was plain JS — the
exact failure the test matrix's own "assert against the shape production
uses" warning describes. Adds a TypeScript-shapes suite covering return
types (including unions, generics, object literals and type predicates),
generic parameter lists (constrained and defaulted), export/async/generator
combinations, annotated arrows, class-method modifiers, and optional/
default/rest params — plus the two traps: an overload signature has no body
and must not count, and `a < b && c > d` is a comparison, not a generic.
Detection now reports 24/37/21 functions for the three source files, which
matches a hand count exactly.

Also from review:

- The strict-mode ledger dedup identified entries by parsing a prose
  description string. That is banned by CONTRIBUTING's raw-text-matching
  rule and was a real bug: the "exactly one window per untriaged proposal"
  guarantee rested on prose matching, so rewording a description or editing
  WINDOWS.md by hand silently produced duplicates. Now matches structurally
  on kind + phase + file + line.
- A property test asserted on the stripper's output text. Reframed to
  assert the same invariant through analyzeSource's score.
- nextBaseline's `candidates` parameter has been dead since the anchor
  change; removed from the signature and all call sites.
- Extracted the duplicated require-or-degrade and capability-check
  boilerplate.
- ADR-1953's Implementation bullet still named a `refactor.ship-gate` in
  check-command-router.cts — a leftover from the design cut D6 rejects.
  That file is untouched and no such gate exists. Removed.

Refs #1953

* fix(#1953): keep execute-phase.md under its byte ceiling; un-vacuum the large-file test

Five of the seven remote-runner failures were one cause: the execute:post
dispatch contract, written out inline, grew execute-phase.md 1876 bytes
(93,400 -> 95,276) against a frozen PRE_PHASE6 ceiling of 93,600. A drift-ack
does not clear that — tests/phase6-capstone-conformance.test.cjs and
tests/fix-2285-claude-orchestration-wiring.test.cjs assert the file is
literally under the cap.

The contract now lives in gsd-core/references/loop-hook-dispatch.md, which
already claimed to be the point-agnostic dispatch reference and already
documented ref.skill and ref.agent. It gains the ref.command shape, its
in-context validation rule, the advisory-by-construction statement, and a
note that a point whose workflow hand-rolls one kind is not implementing
this contract. execute-phase.md now defers to it in one line: 145 bytes of
growth, 55 B of headroom under the cap. Better placement than the first cut
— the reference was overstating its coverage, and this makes the claim true
rather than duplicating prose next to it.

Acknowledged by appending to tests/emitted-drift-acks/2930-*.json rather
than a new 1953-*.json: two ack sources may never name the same path, and
that fragment is already the accumulating ack for this file.

Sixth and seventh failures: analyzesLargeFileWithinBounds tripped its own
vacuity guard — the fixture generated ~480 KB against a `> 500000` assert,
so the guard fired and the three assertions after it never ran. The test
has been vacuous since it was written. The matrix row specifies ~1 MB, so
N goes 8000 -> 20000 (1.17 MB, 17% margin) and the guard to > 1_000_000.
Verified by reproducing the exact body against the compiled module: 1168888
bytes, 118 ms, all four assertions hold.

Refs #1953

* fix(#1953): fold the execute:post step deferral into the existing resolve line

The remaining two failures were one test: execute-phase.md carries a SECOND,
tighter assertion than the 93,600 ceiling — `<=93400`, which is exactly its
current size. The file cannot grow by a single byte. My previous fix got it
under 93,600 but not under 93,400, so it still failed. ("H." in the report is
just the parent describe of that same test, not a separate defect.)

Rather than add a paragraph, the deferral now REPLACES the existing hook
resolution line. It read:

  Resolve active step hooks from `EXECUTE_POST_HOOKS_JSON` where
  `kind == "step"` and `ref.skill == "code-review"`.

which is the bug itself written down — only code-review was ever dispatched.
It now reads:

  Dispatch each `kind == "step"` hook per
  @gsd-core/references/loop-hook-dispatch.md. For `code-review`:

The following prose already begins "If no active code-review step hook
exists", so it reads correctly and the code-review handling is untouched.
Net effect on the file is -11 bytes: 93,400 -> 93,389, under the margin
assertion rather than merely under the ceiling.

That also removes the need for a drift-ack: the file shrank, so there is no
growth to acknowledge, and the append to the shared 2930-*.json fragment is
reverted. Leaving it would have shipped a claim of "145 bytes of growth"
that is no longer true, on a file six other issues share.

The test's own comment states the principle this ended up honoring: "the host
loop must stay small — optional-feature detail belongs in the capability
fragment, not the host workflow." Putting the dispatch contract in the
reference rather than inline is that rule, applied.

Refs #1953

* fix(#1953): keep the code-review hook literal the workflow test requires

tests/code-review.test.cjs extracts the <step name="code_review_gate"> block
and asserts it contains `ref.skill == "code-review"` verbatim. The previous
commit replaced the line carrying that literal, so the token vanished and the
test went red — a fair assertion: code-review IS the bespoke branch there and
the workflow should still name it.

Restored inside the same one-line deferral, which now reads:

  Dispatch `kind == "step"` hooks per @gsd-core/references/loop-hook-dispatch.md.
  `ref.skill == "code-review"`:

93,396 bytes — still under the `<=93400` margin assertion and 4 bytes below
the base, so the file continues to shrink rather than grow.

Because three consecutive runs were each reddened by a different assertion on
this one file, this change was verified by sweeping ALL of them at once rather
than one run at a time: every test under tests/ that reads execute-phase.md or
references/loop-hook-dispatch.md was located by resolving its path constants,
and each content/size assertion was evaluated directly against the working
tree — 22 assertions, plus two real executions (gen-section-manifest --check,
and emitted-attribution's full real-tree differential). All pass.

That sweep also confirms the earlier judgement call: the net change to
execute-phase.md is a SHRINK, and the size ratchet only gates growth, so
reverting the append to the shared 2930-*.json ack fragment was correct — an
ack would have been both unnecessary and factually wrong.

Refs #1953

* chore(#1953): backfill changeset pr number to 3261

* docs(#1953): add the missing how-to for acting on a refactor proposal

Reference and explanation shipped (COMMANDS.md, CONFIGURATION.md,
FEATURES.md 159, ADR-1953) but the Diataxis how-to quadrant did not, and
that is the one a user reaches for. CONTRIBUTING's required-docs table is
'new command -> COMMANDS.md + FEATURES.md', so CI was green on a gap.

Enabling this feature is genuinely multi-step and no single page walked it:
turn it on, tune the threshold, understand advisory vs strict, discover
that strict needs a SECOND toggle on a DIFFERENT capability, and know what
to do when a proposal appears. The two-toggle subtlety in particular was a
footnote in a config table; here it is a section with both commands.

Follows the shape of its closest siblings, resolve-edge-coverage-findings
and resolve-prohibition-findings — both 'the loop surfaced a finding, here
is what to do with it'. Includes a reason-code table for the silent cases,
since the analyzer is deliberately quiet in six situations and a user who
expected a proposal needs to tell 'nothing to report' from 'could not look'.

Indexed from docs/README.md beside the other loop how-tos.

Docs-only: exempt from the push gate, no re-verification, pass marker on
2af188b4 untouched.

Refs #1953

* feat(#1953): warn when strict mode is on but nothing will actually block

Closes acceptance criterion 5, which I had wrongly marked satisfied.

refactor.trigger_strict records an untriaged proposal as an open deviation
window, but a ship only STOPS if workflow.windows_enforce is also on — a
toggle owned by the broken-windows capability that this feature neither sets
nor requires. So a user could enable strict, believe ship was gated, and find
out otherwise at ship time.

The split itself stays: requires:["broken-windows"] would force-install the
ledger on advisory users who never enable strict, and a ship:pre gate of our
own would never fire because ship.md has no generic ship:pre gate dispatch.
What was missing was discoverability, so that is what this fixes.

`refactor evaluate` now emits a typed REFACTOR_STRICT_NOT_ENFORCING warning,
naming the exact remediation command, whenever strict is on and either
workflow.windows_enforce is off or broken-windows is unavailable. It fires
only on a run that produced a candidate — with nothing to block on there is
nothing to warn about, and warning every run would be noise.

Reads workflow.windows_enforce through the same resolveConfigKey walk the
router already uses for its own keys rather than a second config reader.
Four tests cover the matrix: strict+enforce-off warns, strict+enforce-on does
not, strict+ledger-absent warns, strict-off never warns.

Also corrects a user-facing message in this same file that told the user to
run `gsd-tools config-set` — the wrong form. docs/CONFIGURATION.md and the
broken-windows capability both use `gsd config-set`, and gsd-tools is invoked
as `node gsd-tools.cjs`, so the bare form may not resolve. The two adjacent
messages in this file now agree.

Refs #1953

---------

Co-authored-by: sim <sim@local>
2026-08-09 19:52:47 -04:00

1092 lines
38 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 = '````';
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 jsonStart = text.indexOf(PROPOSAL_JSON_FENCE_OPEN);
if (jsonStart === -1) return null;
const jsonEnd = text.indexOf(PROPOSAL_JSON_FENCE_CLOSE, jsonStart + PROPOSAL_JSON_FENCE_OPEN.length);
if (jsonEnd === -1) return null;
const jsonText = text.slice(jsonStart + PROPOSAL_JSON_FENCE_OPEN.length, jsonEnd).trim();
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 };
}