* 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>
924 lines
38 KiB
TypeScript
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,
|
|
};
|