Files
msd-core/docs/how-to/act-on-a-refactor-proposal.md
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

7.4 KiB

How to catch complexity before it compounds

Goal: Turn on the complexity-triggered refactor hook, understand the proposals it surfaces after a phase, and dispose of each one — so a function that grew a branch per phase gets refactored while it is still one function, instead of becoming a rewrite nobody noticed accumulating.

Prerequisites: A MSD project on v1.10.0 or later. Nothing else — the hook has no external dependency and needs no indexer. It is off by default; a project that never enables it is completely unaffected.

For what the metric measures, why the anchor moves only on disposition, and the metric's known biases, see Complexity-Triggered Refactor and ADR-1953. This guide covers only how to use it.


Turn it on

msd config-set refactor.trigger_enabled true

That is the whole setup. From the next /msd-execute-phase, an execute:post step measures the files that phase touched and writes a proposal only if something crosses a line. Nothing else about the loop changes: the hook never edits code, never picks a refactor, and never blocks.

To check it without running a phase:

node msd-tools.cjs refactor evaluate --phase 3 --raw

Read a proposal

A triggered proposal lands at .planning/phases/<N>/<NN>-REFACTOR.md. It names one target — the hotspot — and lists every other candidate it found.

Two numbers matter, and they answer different questions:

  • score — the function's absolute complexity right now. It triggered because it exceeds refactor.complexity_threshold (default 15). Answers "is this function too complex?"
  • delta — growth over the function's anchor, the score recorded the last time you consciously decided about it. It triggered because it exceeds refactor.complexity_jump_delta (default 5). Answers "has this been quietly creeping?"

A proposal can carry both reasons. The delta is the one worth reading closely: because the anchor does not move on its own, a function gaining two points per phase accumulates against it and trips the delta a phase or more before the absolute threshold would have caught it. That gap is the entire point of the second number.

Both thresholds are strictly greater — a score of exactly 15 against a threshold of 15 does not trigger. This matches ESLint's complexity: {max: N}.


Act on it

A proposal stays untriaged until you disposition it. There are exactly two ways, and both clear it:

node msd-tools.cjs refactor accept  --phase 3
node msd-tools.cjs refactor decline --phase 3 --reason "flat dispatch table — branchy by construction, not a hotspot"

Accept when you intend to refactor. The anchor re-anchors to the function's current score, so after you do the work the next evaluation measures growth from the new, lower baseline.

Decline when the complexity is justified. The reason is required and recorded. The anchor re-anchors to the current score — you have consciously accepted this much complexity, so the delta clock restarts from here rather than nagging you every phase about growth you already signed off.

The score improving does not clear a proposal — only a disposition does. This is deliberate. If clearing required the number to go down, the cheapest way to satisfy it would be splitting one coherent function into two incoherent ones: identical total complexity, worse cohesion. The gate asks whether you decided, not whether the metric moved.

To see what is outstanding across the project:

node msd-tools.cjs refactor status

Tune the thresholds

If proposals feel like noise, raise the threshold rather than turning the hook off:

msd config-set refactor.complexity_threshold 20     # ESLint's own default
msd config-set refactor.complexity_jump_delta 8

Defaults are 15 (SonarSource's default) and 5. For reference, radon's rank C — "moderate, slightly complex" — begins at 11, and ESLint's complexity rule defaults to 20. There is no universally correct number; start at the default and raise it once you have seen a few proposals you disagreed with.


Make it block before ship

Advisory mode surfaces proposals and tracks nothing. To make an untriaged proposal a task that must be resolved before shipping, you need two settings, not one:

msd config-set refactor.trigger_strict true      # 1. record proposals in the ledger
msd config-set workflow.windows_enforce true     # 2. make the ledger block /msd-ship

Why two: refactor.trigger_strict records an untriaged proposal as an open deviation entry in the broken-windows ledger. The blocking is that capability's existing ship:pre gate, which is separately opt-in. Setting only the first gives you tracking without enforcement — which is a reasonable place to stop, but it will not stop a ship.

If you enable only refactor.trigger_strict, every refactor evaluate that triggers reports a typed warning saying so, so you never learn about the enforcement gap by hitting it at ship time:

"warnings": [
  {
    "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: msd config-set workflow.windows_enforce true"
  }
]

If the broken-windows capability is not installed, strict mode still records the proposal locally and says so in its output (ledger_recorded: false with a note) — and the same refactor_strict_not_enforcing warning fires, with a message telling you to install the broken-windows capability first. It cannot block on its own.

Dispositioning resolves the ledger entry automatically: accept marks it fixed, decline marks it waived with your reason attached.


When it stays quiet

The hook is deliberately silent in several situations. If you expected a proposal and got none, check these before assuming it is broken — --raw reports the reason in every case:

You see What happened
refactor_no_touched_files The phase changed nothing the analyzer looks at.
refactor_analyzer_unsupported The file's language or path is out of scope. Only .js .cjs .mjs .ts .cts .mts are analyzed; tests/ and generated msd-core/bin/lib/ paths are excluded by design.
refactor_analyzer_unparseable An unterminated string, template, or block comment. The analyzer refuses to emit a score it cannot defend rather than guessing — a silently wrong number is worse than none.
refactor_git_unavailable Not a git repository, git missing, the call timed out, or the phase has no committed PLAN.md to anchor against. Degrades quietly, exit 0.
refactor_file_unreadable One file could not be read, or resolved outside the project root. That file is skipped; the rest of the run continues.
Nothing at all, below_threshold Everything the phase touched is under both lines. This is the normal, healthy case.

Two limits worth knowing up front. A renamed function loses its anchor — it reads as a delete plus an add, so it is evaluated against the absolute threshold only until it is dispositioned again. And the metric is biased against a flat switch: twelve readable cases score 13, which is why proposals are advisory and why decline takes a reason instead of demanding a code change.