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.
120 lines
7.4 KiB
Markdown
120 lines
7.4 KiB
Markdown
# 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](../FEATURES.md#159-complexity-triggered-refactor) and [ADR-1953](../adr/1953-complexity-triggered-refactor.md). This guide covers only how to *use* it.
|
|
|
|
---
|
|
|
|
## Turn it on
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
node msd-tools.cjs refactor status
|
|
```
|
|
|
|
---
|
|
|
|
## Tune the thresholds
|
|
|
|
If proposals feel like noise, raise the threshold rather than turning the hook off:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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](../FEATURES.md#158-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:
|
|
|
|
```json
|
|
"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.
|