Files
msd-core/src/refactor-trigger-command-router.cts
Tom Boucher 4f32209f78 enhance(#3267): reduce handleEvaluate complexity below refactor-trigger's own threshold (#3978)
* fix(#3267): reduce handleEvaluate complexity below refactor-trigger's own threshold

handleEvaluate scored 26 (then 21 after later #3261 commits) against the
complexity-triggered-refactor feature's own default threshold of 15. Extracts
the read-and-analyze loop (analyzeTouchedFiles) and the artifact/baseline/
ledger write path (finalizeEvaluation) into named helpers, per the issue's
suggested direction. Behavior-preserving: every existing test in
tests/refactor-trigger-cli.test.cjs is unchanged, and every degrade-path
reason code (REFACTOR_INVALID_PHASE, REFACTOR_GIT_UNAVAILABLE,
REFACTOR_NO_TOUCHED_FILES, REFACTOR_FILE_UNREADABLE,
REFACTOR_ANALYZER_UNSUPPORTED, REFACTOR_ANALYZER_UNPARSEABLE,
REFACTOR_BASELINE_WRITE_FAILED, REFACTOR_STRICT_NOT_ENFORCING) keeps its
current value and emission path.

The four complexity-trigger.cts lexer functions (scanFunctions,
stripLiterals, skipTypeExpr, skipGenericParamList) are deliberately left
untouched, per ADR-1953 D5 — they are a hand-rolled lexer state machine,
densely branchy by construction, and refactoring them to lower the metric
would be exactly the "split a coherent function to satisfy a metric"
behavior D5 exists to prevent.

Closes #3267

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs: backfill changeset PR number for #3978

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 08:12:27 -04:00

975 lines
40 KiB
TypeScript

'use strict';
/**
* Refactor-trigger command router — CLI subcommand dispatcher for
* `gsd-tools refactor` (issue #1953).
*
* Follows `src/intel-command-router.cts` exactly: `routeHubCommandFamily`,
* `makeInvalidArgs` for validation failures, `output(value, raw)` for
* success, `_`-prefixed injection seams, lazy `require` of the heavy leaf
* module (`complexity-trigger.cjs`) and the adjacent modules
* (`git-base-branch.cjs`, `broken-windows.cjs`) inside the route function.
*
* Authoritative surfaces:
* .gsd/phase/feat-1953-complexity-triggered-refactor/42-router-contract.md
* .gsd/phase/feat-1953-complexity-triggered-refactor/41-api-contract.md
*
* `src/complexity-trigger.cts` stays a pure leaf (analyzer, evaluator,
* baseline persistence) — this module owns capability-activation gating,
* git invocation (via the `src/git-base-branch.cts` adapters), config
* reads, CLI arg parsing, phase-directory resolution, and the optional
* broken-windows ledger integration. It never duplicates the leaf's logic.
*
* Arg indexing:
* args[0] = 'refactor' (family — matched by dispatchCapabilityCommand)
* args[1] = subcommand (accept | decline | evaluate | status)
* args[2..] = flags (--phase, --since, --reason, --raw)
*
* Seams: `_complexity` (complexity-trigger.cjs), `_git` (git-base-branch.cjs),
* `_windows` (broken-windows.cjs — OPTIONAL; absent or a throwing `require`
* is the documented degrade path, never an error), `_core` (output capture).
* Production callers omit all four.
*/
import path from 'node:path';
import fs from 'node:fs';
import { retryRenameSync } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import io = require('./io.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import commandRoutingHub = require('./command-routing-hub.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import cjsCommandRouterAdapter = require('./cjs-command-router-adapter.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import capabilityStateMod = require('./capability-state.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import capabilityActivationMod = require('./capability-activation.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import configLoaderMod = require('./config-loader.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseLocatorMod = require('./phase-locator.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspaceMod = require('./planning-workspace.cjs');
// Type-only: erased at emit, so pulling in the leaf modules' exact exported
// shapes here does not violate the "lazy require of the heavy module inside
// the route function" rule — only the runtime `require(...)` calls below are
// deferred into the handlers.
import type * as ComplexityTriggerModule from './complexity-trigger.cjs';
import type { AnalyzedFile, Candidate, Evaluation, Proposal } from './complexity-trigger.cjs';
import type * as GitBaseBranchModule from './git-base-branch.cjs';
import type * as BrokenWindowsModule from './broken-windows.cjs';
import type { Ledger, WindowEntry } from './broken-windows.cjs';
const { output } = io;
const { makeInvalidArgs } = commandRoutingHub;
const { routeHubCommandFamily } = cjsCommandRouterAdapter;
const { isCapabilityActive } = capabilityStateMod;
const { resolveConfigKey } = capabilityActivationMod;
const { loadConfig } = configLoaderMod;
const { findPhaseInternal, listMilestonePhaseDirs } = phaseLocatorMod;
const { planningDir } = planningWorkspaceMod;
const CAPABILITY_ID = 'refactor-trigger';
// ─── Types ────────────────────────────────────────────────────────────────────
type ComplexityModule = typeof ComplexityTriggerModule;
type GitModule = typeof GitBaseBranchModule;
type WindowsModule = typeof BrokenWindowsModule;
interface CoreModule {
output(value: unknown, raw: boolean): void;
}
// Default CoreModule implementation. `_core` seam overrides this entirely
// for test injection (captures output calls without writing to real stdout).
const _defaultCore: CoreModule = { output };
interface RouteRefactorTriggerCommandOptions {
args: string[];
cwd: string;
raw: boolean;
error: (message: string, reason?: string) => void;
/** Test seam: inject a mock complexity-trigger module. Defaults to the real module. */
_complexity?: ComplexityModule;
/** Test seam: inject a mock git-base-branch module. Defaults to the real module. */
_git?: GitModule;
/** Test seam: inject a mock broken-windows module. Defaults to the real module (optional capability). */
_windows?: WindowsModule;
/** Test seam: inject a mock core module to capture output calls. Defaults to the real module. */
_core?: CoreModule;
}
// ─── Disabled response ──────────────────────────────────────────────────────
function disabledResponse(): { disabled: true; message: string } {
return {
disabled: true,
message: 'refactor-trigger is not enabled. Enable with: gsd config-set refactor.trigger_enabled true',
};
}
// ─── Arg parsing ────────────────────────────────────────────────────────────
// Positive integer, optionally zero-padded, optionally with dotted decimal
// sub-phase segments (1, 01, 12, 3.1). Rejects 0, negative, letters, and
// whitespace — matches the router contract's "positive integer or decimal
// phase id" rule.
const PHASE_VALUE_RE = /^0*[1-9]\d*(?:\.\d+)*$/;
interface FlagRead {
present: boolean;
value: string;
duplicated: boolean;
/** True when `--flag` was followed by another flag-shaped token (or nothing) instead of a value. */
flagShaped: boolean;
}
/**
* Reads a `--flag value` or `--flag=value` occurrence from `args`. Repeated
* occurrences are reported via `duplicated` (the last one wins in `value`,
* but callers should treat `duplicated` as invalid input). A token
* immediately following `--flag` that itself starts with `--` (or the flag
* being the last token) is NOT consumed as a value — `flagShaped` is set and
* `value` stays `''`, so `--phase --raw` never silently swallows `--raw`.
*/
function readFlag(args: string[], flag: string): FlagRead {
let present = false;
let value = '';
let count = 0;
let flagShaped = false;
const eqPrefix = `${flag}=`;
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === flag) {
count++;
present = true;
const next = args[i + 1];
if (next === undefined) {
value = '';
} else if (next.startsWith('--')) {
value = '';
flagShaped = true;
} else {
value = next;
flagShaped = false;
}
} else if (a.startsWith(eqPrefix)) {
count++;
present = true;
value = a.slice(eqPrefix.length);
flagShaped = false;
}
}
return { present, value, duplicated: count > 1, flagShaped };
}
/**
* Validate `--phase`: required, and a positive integer or decimal phase id.
* Empty, whitespace-only, duplicated, `--phase=`, `--phase==N`, or a
* flag-shaped value all fail. Missing entirely -> REFACTOR_USAGE (no value
* was ever offered); present but invalid -> REFACTOR_INVALID_PHASE (a value
* was given but rejected). Never throws.
*/
function requirePhaseArg(
args: string[],
complexity: ComplexityModule,
usage: string,
): { ok: true; phase: string } | { ok: false; result: unknown } {
const flag = readFlag(args, '--phase');
if (!flag.present) {
return { ok: false, result: makeInvalidArgs('phase', usage, complexity.REASON.REFACTOR_USAGE) };
}
const trimmed = flag.value.trim();
if (flag.duplicated || flag.flagShaped || trimmed === '' || !PHASE_VALUE_RE.test(trimmed)) {
return {
ok: false,
result: makeInvalidArgs(
'phase',
`Invalid --phase value: ${JSON.stringify(flag.value)}. ${usage}`,
complexity.REASON.REFACTOR_INVALID_PHASE,
),
};
}
return { ok: true, phase: trimmed };
}
// ─── Phase / path resolution ────────────────────────────────────────────────
interface ResolvedPhase {
/** Project-relative, POSIX-separated phase directory (e.g. ".planning/phases/03-x"). */
phaseDir: string;
/** Zero-padded phase token (e.g. "03"), matching `${PADDED}-REFACTOR.md`. */
padded: string;
}
/**
* Resolve the on-disk phase directory for a validated `--phase` value. A
* phase that cannot be located on disk (never planned, wrong number, an
* archived milestone this call isn't scoped to) returns `null` — this is a
* distinct fact from a git failure (git is fine; the phase just doesn't
* exist), so callers report REFACTOR_INVALID_PHASE, never
* REFACTOR_GIT_UNAVAILABLE, for this case.
*/
function resolvePhaseDirForArg(cwd: string, phaseArg: string): ResolvedPhase | null {
const search = findPhaseInternal(cwd, phaseArg);
if (!search || search.found !== true) return null;
return { phaseDir: search.directory, padded: search.phase_number };
}
/**
* Resolve `relFile` (a repo-relative path, typically from `git diff
* --name-only`) against `cwd` and refuse a path that escapes the project
* root. Returns the absolute path, or `null` when it escapes.
*
* Two layers: the string-level `startsWith` check is a cheap first gate but
* confines only the SYMLINK's own path, not its target — a symlink
* committed in the repo (e.g. `src/evil.cts -> /etc/passwd`) has an
* in-tree path that passes the string check, and `fs.readFileSync` on it
* would follow the link and read outside the root. `lstatSync` (which does
* NOT follow symlinks, unlike `statSync`) is the second gate: anything that
* is not a regular file — a symlink most of all — is refused outright.
* Refusing rather than resolving-and-re-confining is deliberate: it is
* simpler and strictly stricter (a symlink whose target legitimately lives
* inside the root is still refused, which is an acceptable false positive
* for this analyzer). An `lstatSync` failure (ENOENT on a broken symlink,
* EACCES, a race) is treated the same as "not a regular file" — never
* propagated — so callers can keep using their existing null-means-skip
* convention (`REASON.REFACTOR_FILE_UNREADABLE`) uniformly.
*/
function resolveConfinedPath(cwd: string, relFile: string): string | null {
const root = path.resolve(cwd);
const resolved = path.resolve(root, relFile);
if (resolved !== root && !resolved.startsWith(root + path.sep)) return null;
try {
if (!fs.lstatSync(resolved).isFile()) return null;
} catch {
return null;
}
return resolved;
}
function artifactFileName(padded: string, complexity: ComplexityModule): string {
return `${padded}${complexity.PROPOSAL_SUFFIX}`;
}
function artifactAbsPath(cwd: string, phaseDir: string, padded: string, complexity: ComplexityModule): string {
return path.join(cwd, phaseDir, artifactFileName(padded, complexity));
}
function candidateKey(file: string, name: string): string {
return `${file}::${name}`;
}
// ─── Atomic write (proposal + ledger) ───────────────────────────────────────
/**
* Atomic publish: write a sibling `.tmp.<pid>` file, then rename over the
* target via `retryRenameSync` (transient-Windows-lock-tolerant — matches
* `local/require-fs-op-fallback`, ADR-1703 Phase 6). On any failure, best-
* effort unlinks the temp file and rethrows.
*/
function writeTextAtomic(filePath: string, content: string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tmp = `${filePath}.${process.pid}.tmp`;
fs.writeFileSync(tmp, content, 'utf8');
try {
retryRenameSync(tmp, filePath);
} catch (err) {
try { fs.unlinkSync(tmp); } catch { /* best-effort cleanup */ }
throw err;
}
}
// ─── Config reads ───────────────────────────────────────────────────────────
/**
* Read `refactor.complexity_threshold` / `refactor.complexity_jump_delta` /
* `refactor.trigger_strict` via the same four-level precedence walk
* (`resolveConfigKey`) the capability-activation gate uses: loadConfig
* result -> workstream config.json -> root config.json -> registry
* `configSchema` default. Reading the frozen first-party registry directly
* is deliberate (not `capability-loader.cjs`'s `loadRegistry`): with
* `includeInstalled` omitted, `loadRegistry()` returns the exact same frozen
* object — refactor-trigger is first-party, so the overlay/consent machinery
* has nothing to add here and this avoids the extra indirection.
*/
function readEvalConfig(cwd: string): { threshold: unknown; jumpDelta: unknown; strict: boolean; windowsEnforce: boolean } {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const registry = require('./capability-registry.cjs') as Record<string, unknown>;
const config = loadConfig(cwd);
const threshold = resolveConfigKey('refactor.complexity_threshold', { config, cwd, registry }).value;
const jumpDelta = resolveConfigKey('refactor.complexity_jump_delta', { config, cwd, registry }).value;
const strict = resolveConfigKey('refactor.trigger_strict', { config, cwd, registry }).value;
// Read via the SAME four-level precedence walk as the `refactor.*` keys
// above — reusing `resolveConfigKey`/`loadConfig`/the frozen registry
// rather than a second config reader. A missing key resolves to the
// registry default (false), matching "treat a missing key as falsy".
const windowsEnforce = resolveConfigKey('workflow.windows_enforce', { config, cwd, registry }).value;
return { threshold, jumpDelta, strict: Boolean(strict), windowsEnforce: Boolean(windowsEnforce) };
}
// ─── Strict-mode enforcement-gap warning (#1953) ────────────────────────────
const WINDOWS_ENFORCE_REMEDIATION = 'gsd config-set workflow.windows_enforce true';
/**
* `refactor.trigger_strict` only ever APPENDS a deviation window to the
* broken-windows ledger — a ship only actually stops when the separate
* `workflow.windows_enforce` toggle is also on. A user who enables only
* `refactor.trigger_strict` gets tracking with no enforcement and, absent
* this warning, no signal that this is the case. Fires strictly on TRIGGERED
* evaluates with strict mode on, mirroring the existing ledger-recording
* gate: either the broken-windows capability could not record the window at
* all (`ledgerRecorded === false` — the existing degrade path), or it did,
* but `workflow.windows_enforce` is falsy. Never fires with strict off.
*/
function strictNotEnforcingWarning(
complexity: ComplexityModule,
ledgerRecorded: boolean,
windowsEnforce: boolean,
): { reason: string; message: string } | null {
if (!ledgerRecorded) {
return {
reason: complexity.REASON.REFACTOR_STRICT_NOT_ENFORCING,
message: 'refactor.trigger_strict is on, but the broken-windows capability is unavailable, so ship will not '
+ `actually be blocked. Install the broken-windows capability, then run: ${WINDOWS_ENFORCE_REMEDIATION}`,
};
}
if (!windowsEnforce) {
return {
reason: complexity.REASON.REFACTOR_STRICT_NOT_ENFORCING,
message: 'refactor.trigger_strict is on, but workflow.windows_enforce is off, so ship will not actually be '
+ `blocked. Run: ${WINDOWS_ENFORCE_REMEDIATION}`,
};
}
return null;
}
// ─── Broken-windows integration (strict mode; OPTIONAL) ─────────────────────
function windowsLedgerPath(cwd: string, windows: WindowsModule): string {
return path.join(cwd, '.planning', windows.LEDGER_FILE_NAME);
}
function readLedgerOrEmpty(cwd: string, windows: WindowsModule): Ledger | null {
const ledgerPath = windowsLedgerPath(cwd, windows);
try {
const raw = fs.readFileSync(ledgerPath, 'utf8');
return windows.parseLedger(raw);
} catch (e: unknown) {
const code = (e && typeof e === 'object' && 'code' in e) ? String((e as { code?: unknown }).code) : '';
if (code === 'ENOENT') return windows.emptyLedger(new Date().toISOString());
return null; // unreadable/malformed — degrade rather than throw
}
}
/**
* Structural identity match (#1953 defect 2): a deviation window's identity
* is the typed `phase`/`file`/`line` triple `appendWindow` already persists
* — never the human-readable `description` prose. A reworded description or
* a user editing `WINDOWS.md` prose can never break dedup.
*/
function findOpenDeviationEntry(ledger: Ledger, phase: string, file: string, line: number): WindowEntry | null {
return ledger.entries.find(
(e) => e.status === 'open' && e.kind === 'deviation' && e.phase === phase && e.file === file && e.line === line,
) ?? null;
}
/**
* Shared require-or-degrade + ledger-read boilerplate for
* `recordStrictWindow` / `resolveLedgerWindow` (#1953 defect 5a). `_windows`
* unavailable (module absent, or its `require` throws) or the ledger
* unreadable both degrade to a `{ ok: false, note }` result — never an
* error, never throws.
*/
function loadWindowsOrDegrade(
cwd: string,
windowsOverride: WindowsModule | undefined,
): { ok: true; windows: WindowsModule; ledger: Ledger } | { ok: false; note: string } {
let windows: WindowsModule;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
windows = windowsOverride ?? (require('./broken-windows.cjs') as WindowsModule);
} catch {
return { ok: false, note: 'broken-windows capability unavailable — proposal recorded locally only' };
}
const ledger = readLedgerOrEmpty(cwd, windows);
if (ledger === null) {
return { ok: false, note: 'broken-windows ledger unreadable — proposal recorded locally only' };
}
return { ok: true, windows, ledger };
}
/**
* Strict-mode window append (step 9 of `evaluate`). Degrades to
* `{ recorded: false, note }` per `loadWindowsOrDegrade` — never an error,
* never throws. Idempotent: re-evaluating the same still-untriaged phase
* finds the existing OPEN entry (matched structurally on phase + target
* file/line) and does not append a second one.
*/
function recordStrictWindow(
cwd: string,
padded: string,
target: Candidate,
windowsOverride: WindowsModule | undefined,
): { recorded: boolean; note?: string } {
const loaded = loadWindowsOrDegrade(cwd, windowsOverride);
if (!loaded.ok) return { recorded: false, note: loaded.note };
const { windows, ledger } = loaded;
if (findOpenDeviationEntry(ledger, padded, target.file, target.startLine)) {
return { recorded: true, note: 'already recorded for this phase (idempotent)' };
}
const key = candidateKey(target.file, target.name);
const description = `${key} — complexity ${target.score}` +
(target.baseline !== null ? ` (baseline ${target.baseline}, delta ${target.delta})` : '');
const now = new Date().toISOString();
try {
const result = windows.appendWindow(
ledger,
{ kind: 'deviation', phase: padded, file: target.file, line: target.startLine, description },
{ now },
);
writeTextAtomic(windowsLedgerPath(cwd, windows), windows.renderLedger(result.ledger));
return { recorded: true };
} catch (e) {
return { recorded: false, note: `failed to record broken-windows entry: ${e instanceof Error ? e.message : String(e)}` };
}
}
/**
* Resolve (mark fixed/waived) the ledger window matching phase + target
* file/line, if any. Never throws. Mirrors `recordStrictWindow`'s
* `{ recorded, note }` degrade shape (#1953 defect 2): every
* `resolved: false` path carries a `note` naming the real reason, so
* `accept`/`decline` never tell a user "it failed" without saying why.
*/
function resolveLedgerWindow(
cwd: string,
padded: string,
file: string,
line: number,
kind: 'accept' | 'decline',
reasonText: string,
windowsOverride: WindowsModule | undefined,
): { resolved: boolean; note?: string } {
const loaded = loadWindowsOrDegrade(cwd, windowsOverride);
if (!loaded.ok) return { resolved: false, note: loaded.note };
const { windows, ledger } = loaded;
const entry = findOpenDeviationEntry(ledger, padded, file, line);
if (!entry) {
return { resolved: false, note: 'no open broken-windows entry found for this phase/target — nothing to resolve' };
}
const now = new Date().toISOString();
try {
const updated = kind === 'accept'
? windows.markFixed(ledger, entry.id, { now })
: windows.markWaived(ledger, entry.id, reasonText || 'declined', { now });
writeTextAtomic(windowsLedgerPath(cwd, windows), windows.renderLedger(updated));
return { resolved: true };
} catch (e) {
return { resolved: false, note: `failed to resolve broken-windows entry: ${e instanceof Error ? e.message : String(e)}` };
}
}
// ─── evaluate ────────────────────────────────────────────────────────────────
const EVALUATE_USAGE = 'Usage: gsd-tools refactor evaluate --phase <N> [--since <ref>] [--raw]';
/**
* Step 5 of `evaluate`: filter `touched` with `isAnalyzablePath`, read each
* survivor and classify it into `analyzed`. A read failure (or a path that
* escapes the project root) skips that one file with
* `REFACTOR_FILE_UNREADABLE` and the loop continues — never aborts the
* evaluation. Only files that were SUCCESSFULLY analyzed feed
* `successfullyAnalyzedFiles` — an unreadable file must never wipe that
* file's baseline history via nextBaseline's "file in analyzedFiles but
* function missing" prune rule.
*/
function analyzeTouchedFiles(
cwd: string,
touched: string[],
complexity: ComplexityModule,
): { analyzed: AnalyzedFile[]; successfullyAnalyzedFiles: string[] } {
const analyzed: AnalyzedFile[] = [];
const successfullyAnalyzedFiles: string[] = [];
for (const relFile of touched) {
if (!complexity.isAnalyzablePath(relFile)) continue;
const confined = resolveConfinedPath(cwd, relFile);
if (confined === null) {
analyzed.push({ file: relFile, ok: false, reason: complexity.REASON.REFACTOR_FILE_UNREADABLE });
continue;
}
let source: string;
try {
source = fs.readFileSync(confined, 'utf8');
} catch {
analyzed.push({ file: relFile, ok: false, reason: complexity.REASON.REFACTOR_FILE_UNREADABLE });
continue;
}
const result = complexity.analyzeSource(source);
if (!result.ok) {
analyzed.push({ file: relFile, ok: false, reason: result.reason });
} else {
analyzed.push({ file: relFile, ok: true, method: result.method, functions: result.functions });
successfullyAnalyzedFiles.push(relFile);
}
}
return { analyzed, successfullyAnalyzedFiles };
}
interface FinalizeEvaluationOptions {
cwd: string;
phaseDir: string;
padded: string;
planningDirPath: string;
complexity: ComplexityModule;
evaluation: Evaluation;
evalConfig: { threshold: unknown; jumpDelta: unknown; strict: boolean; windowsEnforce: boolean };
baselineRead: ReturnType<ComplexityModule['readBaseline']>;
analyzed: AnalyzedFile[];
successfullyAnalyzedFiles: string[];
windowsOverride: WindowsModule | undefined;
}
/**
* Steps 7-9 of `evaluate`: write the proposal artifact when TRIGGERED, write
* the next baseline (failure is reported but never fails the command),
* record the strict-mode ledger window when applicable, and assemble the
* final result object. Pure orchestration over the side-effecting helpers —
* every degrade path (artifact write throw, baseline write failure, ledger
* unavailable) keeps its existing reason code and result shape.
*/
function finalizeEvaluation(opts: FinalizeEvaluationOptions): Record<string, unknown> {
const {
cwd, phaseDir, padded, planningDirPath, complexity, evaluation, evalConfig,
baselineRead, analyzed, successfullyAnalyzedFiles, windowsOverride,
} = opts;
// Step 7.
let artifactWritten = false;
let artifactPath: string | null = null;
let artifactWriteError: string | null = null;
if (evaluation.verdict === complexity.VERDICT.TRIGGERED && evaluation.target) {
const target = evaluation.target;
const proposal: Proposal = {
schema_version: complexity.SCHEMA_VERSION,
status: 'proposed',
phase: padded,
target_file: target.file,
target_function: target.name,
score: target.score,
baseline: target.baseline,
delta: target.delta,
metric: 'decision-points',
recorded_at: new Date().toISOString(),
resolved_at: null,
reason: target.reasons.join(','),
candidates: evaluation.candidates,
};
artifactPath = artifactAbsPath(cwd, phaseDir, padded, complexity);
try {
writeTextAtomic(artifactPath, complexity.renderProposal(proposal));
artifactWritten = true;
} catch (e) {
artifactWriteError = e instanceof Error ? e.message : String(e);
}
}
// Step 8: baseline write failure is reported but does not fail the command.
const nextBaselineValue = complexity.nextBaseline(
baselineRead.baseline,
analyzed,
{ analyzedFiles: successfullyAnalyzedFiles, phase: padded },
);
const baselineWrite = complexity.writeBaseline(planningDirPath, nextBaselineValue);
// Step 9: strict mode, TRIGGERED only.
let ledgerRecorded: boolean | undefined;
let ledgerNote: string | undefined;
const warnings: Array<{ reason: string; message: string }> = [];
if (evalConfig.strict && evaluation.verdict === complexity.VERDICT.TRIGGERED && evaluation.target) {
const strictResult = recordStrictWindow(cwd, padded, evaluation.target, windowsOverride);
ledgerRecorded = strictResult.recorded;
ledgerNote = strictResult.note;
const warning = strictNotEnforcingWarning(complexity, ledgerRecorded, evalConfig.windowsEnforce);
if (warning) warnings.push(warning);
}
const result: Record<string, unknown> = {
verdict: evaluation.verdict,
phase: padded,
candidates: evaluation.candidates,
target: evaluation.target,
skipped: evaluation.skipped,
threshold_used: evaluation.thresholdUsed,
jump_delta_used: evaluation.jumpDeltaUsed,
artifact_written: artifactWritten,
artifact_path: artifactPath,
baseline_write: baselineWrite.ok,
};
if (artifactWriteError !== null) result.artifact_write_error = artifactWriteError;
if (!baselineWrite.ok && baselineWrite.reason) result.baseline_write_reason = baselineWrite.reason;
if (ledgerRecorded !== undefined) result.ledger_recorded = ledgerRecorded;
if (ledgerNote !== undefined) result.ledger_note = ledgerNote;
if (warnings.length > 0) result.warnings = warnings;
return result;
}
function handleEvaluate(
args: string[],
cwd: string,
raw: boolean,
c: CoreModule,
complexity: ComplexityModule,
git: GitModule,
windowsOverride: WindowsModule | undefined,
): unknown {
const rest = args.slice(2);
const phaseCheck = requirePhaseArg(rest, complexity, EVALUATE_USAGE);
if (!phaseCheck.ok) return phaseCheck.result;
// Step 3: resolve PHASE_DIR + anchor (phaseStartCommit, or --since override).
const resolved = resolvePhaseDirForArg(cwd, phaseCheck.phase);
if (resolved === null) {
c.output({ verdict: complexity.VERDICT.SKIPPED, reason: complexity.REASON.REFACTOR_INVALID_PHASE, phase: phaseCheck.phase }, raw);
return undefined;
}
const { phaseDir, padded } = resolved;
const sinceFlag = readFlag(rest, '--since');
const sinceOverride = sinceFlag.present && !sinceFlag.flagShaped ? sinceFlag.value.trim() : '';
const sinceRef = sinceOverride !== '' ? sinceOverride : git.phaseStartCommit(cwd, phaseDir);
if (sinceRef === null) {
c.output({ verdict: complexity.VERDICT.SKIPPED, reason: complexity.REASON.REFACTOR_GIT_UNAVAILABLE, phase: padded }, raw);
return undefined;
}
const touched = git.changedFilesSince(cwd, sinceRef);
if (touched === null) {
c.output({ verdict: complexity.VERDICT.SKIPPED, reason: complexity.REASON.REFACTOR_GIT_UNAVAILABLE, phase: padded }, raw);
return undefined;
}
// Step 4: empty touched set.
if (touched.length === 0) {
c.output({ verdict: complexity.VERDICT.BELOW_THRESHOLD, reason: complexity.REASON.REFACTOR_NO_TOUCHED_FILES, phase: padded }, raw);
return undefined;
}
// Step 5: filter, read, and classify each touched file.
const { analyzed, successfullyAnalyzedFiles } = analyzeTouchedFiles(cwd, touched, complexity);
// Step 6.
const planningDirPath = planningDir(cwd);
const baselineRead = complexity.readBaseline(planningDirPath);
const evalConfig = readEvalConfig(cwd);
const evaluation: Evaluation = complexity.evaluateCandidates({
analyzed,
baseline: baselineRead.baseline,
threshold: evalConfig.threshold,
jumpDelta: evalConfig.jumpDelta,
});
// Steps 7-9: artifact write, baseline persistence, strict-mode ledger.
const result = finalizeEvaluation({
cwd, phaseDir, padded, planningDirPath, complexity, evaluation, evalConfig,
baselineRead, analyzed, successfullyAnalyzedFiles, windowsOverride,
});
c.output(result, raw);
return undefined;
}
// ─── status ──────────────────────────────────────────────────────────────────
const STATUS_USAGE = 'Usage: gsd-tools refactor status [--phase <N>] [--raw]';
function scanProposals(
cwd: string,
complexity: ComplexityModule,
): Array<{ phase: string; target_file: string; target_function: string; status: string; score: number }> {
const phasesDir = path.join(planningDir(cwd), 'phases');
const results: Array<{ phase: string; target_file: string; target_function: string; status: string; score: number }> = [];
// #1953 (phase-enumeration-drift guard): phase-directory enumeration has one
// owner (`listMilestonePhaseDirs`, src/phase-locator.cts). Called unscoped
// (no `cwd` in opts) to preserve this scan's prior all-phases-directory
// reach — the only behavior change is that sentinel directories (backlog
// `0-*` / icebox `999-*`, per the canonical `isSentinelPhaseId`) are now
// excluded, and the enumeration order is `comparePhaseNum`-sorted rather
// than raw filesystem order.
const { value: dirs } = listMilestonePhaseDirs(phasesDir);
for (const dirName of dirs) {
const full = path.join(phasesDir, dirName);
let files: string[];
try {
files = fs.readdirSync(full);
} catch {
continue;
}
for (const fileName of files) {
if (!fileName.endsWith(complexity.PROPOSAL_SUFFIX)) continue;
try {
const text = fs.readFileSync(path.join(full, fileName), 'utf8');
const proposal = complexity.parseProposal(text);
if (proposal) {
results.push({
phase: proposal.phase,
target_file: proposal.target_file,
target_function: proposal.target_function,
status: proposal.status,
score: proposal.score,
});
}
} catch {
continue;
}
}
}
return results;
}
function handleStatus(args: string[], cwd: string, raw: boolean, c: CoreModule, complexity: ComplexityModule): unknown {
const rest = args.slice(2);
const phaseFlag = readFlag(rest, '--phase');
if (!phaseFlag.present) {
c.output({ proposals: scanProposals(cwd, complexity) }, raw);
return undefined;
}
const trimmed = phaseFlag.value.trim();
if (phaseFlag.duplicated || phaseFlag.flagShaped || trimmed === '' || !PHASE_VALUE_RE.test(trimmed)) {
return makeInvalidArgs(
'phase',
`Invalid --phase value: ${JSON.stringify(phaseFlag.value)}. ${STATUS_USAGE}`,
complexity.REASON.REFACTOR_INVALID_PHASE,
);
}
const resolved = resolvePhaseDirForArg(cwd, trimmed);
if (resolved === null) {
c.output({ found: false, phase: trimmed }, raw);
return undefined;
}
const artifactPath = artifactAbsPath(cwd, resolved.phaseDir, resolved.padded, complexity);
let text: string;
try {
text = fs.readFileSync(artifactPath, 'utf8');
} catch {
c.output({ found: false, phase: resolved.padded }, raw);
return undefined;
}
const proposal = complexity.parseProposal(text);
if (proposal === null) {
c.output({ found: false, phase: resolved.padded }, raw);
return undefined;
}
c.output({ found: true, phase: resolved.padded, proposal }, raw);
return undefined;
}
// ─── accept / decline ──────────────────────────────────────────────────────
/**
* Re-measure the target function's CURRENT complexity score by re-reading
* and re-analyzing `proposal.target_file` — `accept` re-anchors to the
* post-refactor score and `decline` to the current (unchanged) score, and
* both need the LIVE value, not the frozen `proposal.score` snapshot from
* evaluate-time. Falls back to `proposal.score` when the file is gone,
* unreadable, unparseable, or the function can no longer be found (e.g.
* renamed) — reanchorBaseline always needs *a* number, and the proposal's
* last-known score is the least-surprising fallback.
*/
function measureCurrentScore(cwd: string, proposal: Proposal, complexity: ComplexityModule): number {
const confined = resolveConfinedPath(cwd, proposal.target_file);
if (confined === null) return proposal.score;
let source: string;
try {
source = fs.readFileSync(confined, 'utf8');
} catch {
return proposal.score;
}
const result = complexity.analyzeSource(source);
if (!result.ok) return proposal.score;
const fn = result.functions.find((f) => f.name === proposal.target_function);
return fn ? fn.score : proposal.score;
}
function dispositionUsage(kind: 'accept' | 'decline'): string {
return kind === 'accept'
? 'Usage: gsd-tools refactor accept --phase <N> [--raw]'
: 'Usage: gsd-tools refactor decline --phase <N> --reason "<text>" [--raw]';
}
function handleDisposition(
kind: 'accept' | 'decline',
args: string[],
cwd: string,
raw: boolean,
c: CoreModule,
complexity: ComplexityModule,
windowsOverride: WindowsModule | undefined,
): unknown {
const rest = args.slice(2);
const usage = dispositionUsage(kind);
const phaseCheck = requirePhaseArg(rest, complexity, usage);
if (!phaseCheck.ok) return phaseCheck.result;
let reasonText = '';
if (kind === 'decline') {
const reasonFlag = readFlag(rest, '--reason');
const trimmedReason = reasonFlag.value.trim();
if (!reasonFlag.present || reasonFlag.flagShaped || trimmedReason === '') {
return makeInvalidArgs('reason', usage, complexity.REASON.REFACTOR_DECLINE_REASON_EMPTY);
}
reasonText = reasonFlag.value;
}
const resolved = resolvePhaseDirForArg(cwd, phaseCheck.phase);
if (resolved === null) {
return makeInvalidArgs(
'phase',
`No refactor proposal found for phase ${phaseCheck.phase}.`,
complexity.REASON.REFACTOR_ARTIFACT_NOT_FOUND,
);
}
const artifactPath = artifactAbsPath(cwd, resolved.phaseDir, resolved.padded, complexity);
let text: string;
try {
text = fs.readFileSync(artifactPath, 'utf8');
} catch {
return makeInvalidArgs(
'phase',
`No refactor proposal found for phase ${resolved.padded}.`,
complexity.REASON.REFACTOR_ARTIFACT_NOT_FOUND,
);
}
const proposal = complexity.parseProposal(text);
if (proposal === null) {
return makeInvalidArgs(
'phase',
`Refactor proposal for phase ${resolved.padded} is unreadable.`,
complexity.REASON.REFACTOR_ARTIFACT_NOT_FOUND,
);
}
if (proposal.status !== 'proposed') {
return makeInvalidArgs(
'phase',
`Refactor proposal for phase ${resolved.padded} is already ${proposal.status}.`,
complexity.REASON.REFACTOR_ALREADY_DISPOSITIONED,
);
}
const key = candidateKey(proposal.target_file, proposal.target_function);
const now = new Date().toISOString();
const liveScore = measureCurrentScore(cwd, proposal, complexity);
const targetCandidate = proposal.candidates.find(
(cand) => cand.file === proposal.target_file && cand.name === proposal.target_function,
) ?? proposal.candidates[0] ?? null;
const updatedProposal: Proposal = {
...proposal,
status: kind === 'accept' ? 'accepted' : 'declined',
resolved_at: now,
reason: kind === 'accept' ? proposal.reason : reasonText,
};
writeTextAtomic(artifactPath, complexity.renderProposal(updatedProposal));
const planningDirPath = planningDir(cwd);
const baselineRead = complexity.readBaseline(planningDirPath);
const nextBaselineValue = complexity.reanchorBaseline(baselineRead.baseline, key, liveScore, { phase: resolved.padded });
const baselineWrite = complexity.writeBaseline(planningDirPath, nextBaselineValue);
const ledgerResult = resolveLedgerWindow(
cwd,
resolved.padded,
proposal.target_file,
targetCandidate ? targetCandidate.startLine : -1,
kind,
reasonText,
windowsOverride,
);
const dispositionResult: Record<string, unknown> = {
status: updatedProposal.status,
phase: resolved.padded,
target: key,
reanchored_to: liveScore,
baseline_write: baselineWrite.ok,
ledger_resolved: ledgerResult.resolved,
};
if (ledgerResult.note !== undefined) dispositionResult.ledger_note = ledgerResult.note;
c.output(dispositionResult, raw);
return undefined;
}
// ─── Dispatch ────────────────────────────────────────────────────────────────
/**
* Shared capability-active gate + lazy `complexity-trigger.cjs` require
* (#1953 defect 5b) — identical across all four subcommand handlers. Emits
* the disabled response and returns `undefined` without invoking `run` when
* the capability is off; otherwise resolves the module (respecting the
* `_complexity` test seam) and hands it to `run`.
*/
function withActiveComplexity(
cwd: string,
raw: boolean,
c: CoreModule,
complexityOverride: ComplexityModule | undefined,
run: (complexity: ComplexityModule) => unknown,
): unknown {
if (!isCapabilityActive(CAPABILITY_ID, cwd)) {
c.output(disabledResponse(), raw);
return undefined;
}
// eslint-disable-next-line @typescript-eslint/no-require-imports
const complexity: ComplexityModule = complexityOverride ?? (require('./complexity-trigger.cjs') as ComplexityModule);
return run(complexity);
}
function routeRefactorTriggerCommand({ args, cwd, raw, error, _complexity, _git, _windows, _core }: RouteRefactorTriggerCommandOptions): void {
const c: CoreModule = _core ?? _defaultCore;
routeHubCommandFamily({
family: 'refactor',
args,
// Alphabetical for a stable unknown-subcommand message, matching intel/graphify.
subcommands: ['accept', 'decline', 'evaluate', 'status'],
handlers: {
evaluate: () => withActiveComplexity(cwd, raw, c, _complexity, (complexity) => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const git: GitModule = _git ?? (require('./git-base-branch.cjs') as GitModule);
return handleEvaluate(args, cwd, raw, c, complexity, git, _windows);
}),
status: () => withActiveComplexity(cwd, raw, c, _complexity, (complexity) => handleStatus(args, cwd, raw, c, complexity)),
accept: () => withActiveComplexity(
cwd, raw, c, _complexity,
(complexity) => handleDisposition('accept', args, cwd, raw, c, complexity, _windows),
),
decline: () => withActiveComplexity(
cwd, raw, c, _complexity,
(complexity) => handleDisposition('decline', args, cwd, raw, c, complexity, _windows),
),
},
unknownMessage: (subcommand: string, available: string[]) =>
`Unknown refactor subcommand. Available: ${available.join(', ')}`,
error,
cwd,
raw,
});
}
export = {
routeRefactorTriggerCommand,
};