Files
msd-core/docs/adr/1953-complexity-triggered-refactor.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

152 lines
9.8 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-1953: Complexity-triggered refactor — the loop measures the entropy it just added
- **Status:** Proposed
- **Date:** 2026-08-09
- **Issue:** [#1953](https://github.com/open-gsd/gsd-core/issues/1953)
- **Implementation:** `src/complexity-trigger.cts`, `src/refactor-trigger-command-router.cts`, `capabilities/refactor-trigger/capability.json`, and a changed-files adapter in `src/git-base-branch.cts`
- **Extends:** [ADR-857](857-capability-system.md) (registers on the `execute:post` extension point) · [ADR-894](894-capability-declaration-format.md) (a `role: "feature"` manifest with `commands`, `steps`, and a `gates` entry)
- **Related:** [#1950](https://github.com/open-gsd/gsd-core/issues/1950) / the `broken-windows` capability — this ADR reuses its ledger rather than adding a second one
## Context
*The Pragmatic Programmer* Topic 40 — "Refactor Early, Refactor Often" — argues for
refactoring as gardening: a little, continuously, because entropy compounds. MSD today has
refactoring only as a **manual commit type** (`agents/msd-executor.md`, `agents/msd-planner.md`):
an optional cleanup the executor may perform. Nothing measures accumulated complexity and
nothing triggers a refactor. It happens if, and only if, someone remembers.
That gap is worse under an AI executor than under a human one, and for a structural reason:
**every phase runs in a fresh context**, so no single run ever sees the accumulated mess.
Each phase adds a branch here and a special case there, every one locally justified by "make
the tests pass". By the time a human notices, the hotspot is a rewrite.
The signal needed to close this is cheap and already sitting there: the loop knows exactly
which files a phase touched, and it knows the moment the phase ends. What is missing is a
trigger.
## Decision
**D1 — A capability on `execute:post`, not a change to the loop.** `refactor-trigger` is a
`role: "feature"` capability, `activationKey: refactor.trigger_enabled`, default **off**.
Nothing about the loop changes for anyone who does not opt in.
**D2 — The signal is computed in-core, with no new dependency.** A decision-point counter
over comment- and literal-stripped source, Node builtins only, in the same regex idiom
`src/intel.cts` already uses for export extraction. This was chosen over the issue's original
"use Memtrace's complexity signals" and over shelling out to ESLint `complexity` / radon.
The reason is not that the alternatives are worse metrics — they are better ones. It is that
the `execute:post` hook fires as a **deterministic CLI**, not as an agent holding MCP tools,
and core forbids external dependencies. An agent-side Memtrace variant could not be bound by
a behavioral test, which is a hard requirement of the linked issue's own acceptance criteria.
The metric sits behind a named seam so a second one is additive later.
**D3 — Two numbers, never one composite.** A function is a candidate when its absolute score
exceeds `refactor.complexity_threshold`, **or** when its growth over its anchor exceeds
`refactor.complexity_jump_delta`. Both are reported. Trigger semantics are ESLint's
`complexity: {max: N}` semantics — strictly greater — so a score equal to the threshold does
not trigger.
**D4 — The baseline is a stable anchor, not a rolling value.** It is set the first time a
function is observed and moves only when the proposal is *dispositioned*. The delta is
therefore cumulative since the last conscious decision about that function.
A rolling baseline was tried first and discarded: with it, the delta is always the
single-phase change, so a function creeping `+2` per phase against a delta of 5 never trips
the jump check, and the absolute threshold catches the creep first — leaving the jump-delta
contributing nothing. The stable anchor catches that creep a full phase earlier, which is the
entire reason the second number exists. The cost is that a legitimately-growing function
re-proposes until dispositioned.
**D5 — Advisory by default; strict mode tracks on *disposition*, never on the score.** This is
the load-bearing decision of the whole design. A blocking complexity number is a metric that
an executor optimizing for green gates can satisfy by splitting one coherent function into two
incoherent ones — identical total complexity, worse cohesion. So the tracked entry clears when
the proposal is **dispositioned** — `refactor accept` or `refactor decline`, either one — and
never asks whether the number went down.
**D6 — This capability declares no gates. Strict mode routes through the broken-windows
ledger.** An untriaged proposal becomes an open `deviation` entry via `appendWindow`;
blocking is broken-windows' existing, already-dispatched `ship:pre` gate, enabled separately
with `workflow.windows_enforce`. A declined proposal resolves its entry as `waived` with the
recorded reason; an accepted one resolves it as `fixed`. There is no second ledger and no
second gate.
Two earlier cuts of this decision were wrong and are recorded because the reason generalizes.
A `check.predicate` on the proposal artifact was killed by `artifact-frontmatter-equals`
mapping *artifact not found* to `block: true` — it would block every ship in which no
proposal was produced. A `check.query` was then killed by the test suite: `ship.md` has **no
generic `ship:pre` gate dispatch at all**, only two hardcoded branches (`security` at
`ship.md:105`, `broken-windows` at `ship.md:155`), so a third gate of either kind would be
declared and never evaluated — strict mode would silently do nothing. The structural guards
in `tests/loop-hooks-ship-pre-e2e.test.cjs` pin that reality rather than express a
preference. Generalized: **at `ship:pre`, a capability cannot add a gate; it can only add a
window.**
**D7 — A declined refactor is recorded in the existing broken-windows ledger.** The same
`appendWindow` path as D6, degrading to a note when that capability is absent. No second ledger.
**D8 — `execute:post` gains a generic step-hook dispatch contract.** `execute-phase.md`
dispatches `execute:post` step hooks only where `ref.skill == "code-review"`, so a `ref.command`
step there is declared-but-never-run. The contract added mirrors the existing one in `ship.md`.
The code-review branch is left byte-identical; the generic contract handles the rest.
## What stays OUTSIDE
- **Choosing or applying the refactor.** The capability surfaces a proposal. It never edits
code, never picks a strategy, never auto-applies. Executing an accepted proposal remains the
executor's existing `refactor` commit type.
- **Cognitive complexity, Halstead, maintainability index.** One metric behind a seam. Adding a
second is a later, additive decision, not a reason to widen this one.
- **Non-JS/TS languages.** Unsupported extensions are reported as skipped, never scored.
- **Test files and generated output.** Excluded by default; branchy tests are not a defect.
- **Threading the touched-file set through `agents/msd-executor.md`** (named in the issue's
scope list). Deriving it from a bounded `git diff` inside the CLI is deterministic and
testable and avoids the agent-size ripple.
- **Repo-wide or scheduled scanning.** The signal is strongest on exactly the files the phase
just touched; a periodic full-repo scan is decoupled from the change that caused the growth.
## Consequences
**Good.** Continuous, automatic refactoring pressure that does not depend on anyone
remembering. Slow creep becomes visible a phase before the absolute threshold would catch it.
Declined cleanups stay tracked instead of evaporating. Zero cost when off, and zero new
dependencies when on.
**Bad, and accepted.** The metric is approximate by construction: biased against a flat
`switch` (SonarSource's well-known objection to raw cyclomatic complexity), blind to nesting
depth, and JS/TS-only. A rename reads as delete-plus-add and loses its anchor. Defaults will
need tuning — too low reads as refactor spam, which the linked issue names as the main
maintenance burden. The leak surface of a non-AST analyzer is real; it is handled by refusing
to emit a number rather than by emitting an approximate one.
**A soft dependency, disclosed.** Strict mode can only *block* when the broken-windows
capability is installed and `workflow.windows_enforce` is on — two toggles, not one. Without
it, strict mode still records the proposal and says so, but cannot stop a ship. `requires:
["broken-windows"]` was rejected because it would force-install the ledger on advisory users
who never enable strict mode.
**Risk we are deliberately holding.** D5 mitigates Goodhart exposure but does not eliminate it.
A team that turns on strict mode and treats proposals as chores to clear will get worse code
than one that leaves it advisory. The default and the documentation both push toward the latter.
## Open questions
1. Is 15 the right default threshold? SonarSource's default; ESLint's is 20; radon's rank C
starts at 11. Needs field data.
2. Should an accepted proposal auto-create a task in the next phase's plan, rather than relying
on the developer to act? Deferred — it would couple this capability to the planner.
3. Should cognitive complexity become the default once available, with cyclomatic as the
fallback? The seam allows it; the evidence to decide does not exist yet.
## References
- *The Pragmatic Programmer*, Topic 40 — "Refactoring".
- T. J. McCabe, "A Complexity Measure", IEEE TSE, 1976 — `V(G) = E − N + 2P`.
- G. Ann Campbell, "Cognitive Complexity" (SonarSource) — the readability critique of raw
cyclomatic complexity that motivates D2's seam and the flat-`switch` caveat.
- ESLint `complexity` rule — the source of D3's strictly-greater `max` semantics.
- Martin Fowler, *Refactoring* and "Code Smell" — a threshold crossing is a signal to look
closer, not a verdict; the reason D5 surfaces rather than blocks.
- `docs/reference/capability-manifest.md`, `docs/reference/gate-predicates.md`.