Files
msd-core/src/refactor-trigger-command-router.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

924 lines
38 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]';
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 with isAnalyzablePath; read each survivor. A read failure
// (or a path that escapes the project root) skips that one file with a
// reason and the run continues — never aborts the evaluation.
const analyzed: AnalyzedFile[] = [];
// Only files that were SUCCESSFULLY analyzed feed nextBaseline's prune set
// — an unreadable file must never wipe that file's baseline history via
// the "file in analyzedFiles but function missing" prune rule.
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);
}
}
// 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,
});
// 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;
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,
};