Architecture fitness functions: maintainability without reading the code
Architecture fitness functions are automated checks that fail the build when code breaks a structural rule: a forbidden layer import, a dependency cycle, a function over its complexity budget, new duplication, or an unapproved public API change. Run as required CI gates with ratchets, they keep agent-written code maintainable without anyone reading every diff.
If you lead a team that merges 40 agent pull requests a week, this page is for you and your developers, with a trend view for the CTO. Each of those pull requests passes its tests, and each diff looks reasonable in isolation. Six months later the domain package imports the ORM, the checkout service and the billing service import each other, and there are three slightly different formatMoney helpers. No single pull request did it, so no single review could have caught it. Tests check behaviour; nothing checked the shape. This page turns the shape into checks.
What you’ll walk away with from architecture fitness functions
Section titled “What you’ll walk away with from architecture fitness functions”- The five fitness-function families worth gating on, with the enforcing tool for TypeScript, Python and Java.
- Configurations for all three stacks, tested on 26 September 2026: dependency-cruiser 18.4.0, import-linter 2.15, ArchUnit 1.5.1, ESLint 10, Ruff 0.16.9, Checkstyle, jscpd 5.3.2, API Extractor 7.59.2, Griffe 2.3.0 and japicmp 0.26.2.
- The ratchet pattern for adopting it on a brownfield codebase.
- A GitHub Actions job, a canary that proves the gate can still fail, and a
Stophook that makes the agent fix violations before it reports done. - Three copy-paste prompts: derive the rules, fix a violation without touching them, and propose the next ratchet.
Why does agent-written code erode when nobody reads every diff?
Section titled “Why does agent-written code erode when nobody reads every diff?”An agent optimises for the task in front of it. The shortest path often crosses a boundary (import the repository straight into the handler) or copies a helper instead of finding it. Each shortcut is locally reasonable, and review by sampling misses most of them.
The research agrees that this is a structural effect, not a matter of prompting harder:
- SlopCodeBench (Orlanski et al., arXiv, v2 7 May 2026) had agents repeatedly extend their own solutions across 196 checkpoints. The best passed 14.8% of them, and the benchmark names the degradation “structural erosion (concentrated complexity) and verbosity (redundant code)”.
- DORA (Google Cloud, 2025 report, 23 September 2025) found AI adoption positively related to throughput and negatively related to stability, and names the mechanism: “Teams working in loosely coupled architectures with fast feedback loops see gains, while those constrained by tightly coupled systems and slow processes see little or no benefit.”
- GitClear (“The Maintainability Gap: AI Code Quality in 2026”, June 2026, 623 million code changes, reported here from a secondary search extract) measured block duplication up 81% against 2023 and moved (refactored) code falling from 13% to 3.8% of changed lines. GitClear presents the data as correlation, not proof of cause.
Fitness functions are the deterministic answer. The term is from Neal Ford, Rebecca Parsons and Patrick Kua’s Building Evolutionary Architectures (O’Reilly): any objective, automated check of an architectural characteristic.
Which fitness functions should you gate on?
Section titled “Which fitness functions should you gate on?”Start with these five families: cheap, actionable for an agent, and blind spots for tests.
| Family | Catches | TypeScript | Python | Java |
|---|---|---|---|---|
| Dependency direction and layering | Domain importing infrastructure, handlers bypassing services | dependency-cruiser | import-linter (layers, forbidden) | ArchUnit layeredArchitecture() |
| Cycles | Two modules that can no longer change independently | dependency-cruiser circular | import-linter layers or independence | ArchUnit slices().beFreeOfCycles() |
| Complexity budget | Functions that grow a branch per feature | ESLint complexity, max-depth, max-params | Ruff C901, PLR0912, PLR0913 | Checkstyle CyclomaticComplexity, ParameterNumber |
| Duplication budget | Copied helpers, pasted validation | jscpd | jscpd | jscpd |
| Public API surface | Unapproved breaking changes to exports | API Extractor | Griffe check | japicmp |
For HTTP APIs, add a contract diff on the OpenAPI document: oasdiff breaking --fail-on ERR base.yaml head.yaml exits with code 1 when it finds error-level breaking changes, in any stack.
Coverage and mutation score are missing on purpose: they measure the tests, not the architecture; see how strong your oracle is. Security scanning has its own page, security testing.
Why ratchets beat thresholds on an existing codebase
Section titled “Why ratchets beat thresholds on an existing codebase”On a codebase that already has 212 violations, a threshold (“zero layering violations”) is either red forever or so loose it catches nothing. A ratchet records the violations that exist today in a baseline, fails only on new ones, and lets the baseline shrink but never grow.
Every tool on this page has a ratchet built in:
| Tool | Baseline mechanism |
|---|---|
| dependency-cruiser | --baseline writes .dependency-cruiser-known-violations.json; --ignore-known skips those and fails on new ones. To tighten, re-run with --baseline --baseline-mode shrink-only, which only removes fixed entries |
| import-linter | ignore_imports lists known violations per contract |
| ArchUnit | FreezingArchRule.freeze(rule) stores current violations and fails only on new ones |
| jscpd | --baseline-from-ref origin/main --fail-on-new-clones compares against the base branch, with no file to edit |
| ESLint, Ruff, Checkstyle | Start the budget at your current worst function, then lower it on a schedule |
The ratchet gives the agent a task it can meet on every pull request: “do not add a violation”. Fixing the 212 is separate, planned work.
Set up fitness functions, step by step
Section titled “Set up fitness functions, step by step”-
Write the architecture down in five sentences. Name the layers, the allowed direction between them, and the two or three rules you would reject a pull request for. If you keep decisions as ADRs, this is the machine-checkable part of them; see architecture decisions agents can follow.
-
Have the agent draft the rules from the code as it is. Use the first prompt below. It maps the real import graph, proposes rules matching your five sentences and counts today’s violations per rule. You review the rule set, not the code.
-
Run the rules and record the baseline. Commit the configuration and the baseline file in one pull request, and write the baseline size in its description. That number is where the ratchet starts.
-
Add the canary, then make the job a required check. The canary (below) plants a known violation and fails if the gate does not catch it. Require the fitness job in the branch ruleset, so a red result blocks merge for humans and agents alike.
-
Take the rules away from the agent. Put the rule configuration, baselines,
package.json(it holds thefitnessscript), the canary, the workflow and the hook files in.claude/and.codex/underCODEOWNERSowned by the tech lead or the platform team. Deny the agent edits to the same paths in its session (see the tool tabs below), exceptpackage.json: it stays underCODEOWNERSonly, because the agent needs it for dependency changes and the hook and CI call the tools directly rather than through its scripts. For Python and Java the equivalents arepyproject.toml,config/fitness-checkstyle.xml,ArchitectureTest.javaandsrc/test/resources/archunit_store/**. A rule the agent can loosen, or a hook it can switch off, is a suggestion. -
Feed violations back before CI does. Wire the fast checks into the agent’s own loop, so it fixes a layering break in the same session instead of in a second round trip through CI.
-
Tighten the ratchet on a schedule. Once a month, lower each budget to the current worst value and delete stale baseline entries; for dependency-cruiser,
--baseline --baseline-mode shrink-onlyremoves fixed entries and never adds new ones. The third prompt below drafts the change as a patch, so a scheduled Codex automation or Cursor Automation can run it under the locked rules; the tech lead applies it.
Fitness function configs for TypeScript, Python and Java
Section titled “Fitness function configs for TypeScript, Python and Java”Each tab holds a tested set for one stack. The layer names match a common four-layer layout (web or api, app/application or services, domain, infra/infrastructure or adapters); rename them to yours.
Dependency rules with dependency-cruiser 18.4.0. Install it with the other TypeScript tools on this tab, including the TypeScript pin the caution below explains:
npm i -D dependency-cruiser eslint typescript-eslint jscpd @microsoft/api-extractor typescript@~6.0/** @type {import('dependency-cruiser').IConfiguration} */module.exports = { forbidden: [ { name: 'no-circular', severity: 'error', comment: 'Cycles make every module in the loop depend on every other one.', from: {}, to: { circular: true }, }, { name: 'domain-stays-pure', severity: 'error', comment: 'src/domain holds business rules; it must not import app, infra or web code.', from: { path: '^src/domain/' }, to: { path: '^src/(app|infra|web)/' }, }, { name: 'web-goes-through-app', severity: 'error', comment: 'Route handlers call use cases in src/app, never the database layer directly.', from: { path: '^src/web/' }, to: { path: '^src/infra/' }, }, ], options: { doNotFollow: { path: 'node_modules' }, tsConfig: { fileName: 'tsconfig.json' }, },};The comment is what the agent reads when the rule fails, so write it as an instruction. Record the baseline once, then gate on new violations only. npx --no-install runs only the locally installed dev dependency; without it, a missing package is fetched from the registry by name, which is how a squatted name such as the unscoped api-extractor would end up running in CI:
# Terminal, once: record today's violationsnpx --no-install depcruise src --config .dependency-cruiser.cjs --baseline
# CI and the agent's loop: fail on anything not in the baselinenpx --no-install depcruise src --config .dependency-cruiser.cjs --ignore-known --output-type err-longComplexity budget with ESLint 10 core rules and the typescript-eslint parser:
import { defineConfig } from 'eslint/config';import tseslint from 'typescript-eslint';
export default defineConfig({ files: ['src/**/*.ts'], languageOptions: { parser: tseslint.parser }, rules: { complexity: ['error', { max: 10 }], 'max-depth': ['error', 3], 'max-lines-per-function': ['error', { max: 60, skipBlankLines: true, skipComments: true }], 'max-params': ['error', 4], },});Duplication with jscpd 5.3.2, gated on new clones against the base branch:
npx --no-install jscpd src --baseline-from-ref origin/main --fail-on-new-clones --fail-on-emptyAPI surface with API Extractor 7.59.2 for a package others import. Run npx --no-install api-extractor init once, build the declarations, and commit the generated etc/*.api.md report. Without --local, api-extractor run fails when the public API no longer matches the committed report; --print-api-report-diff prints what changed:
npx --no-install tsc -p tsconfig.build.json && npx --no-install api-extractor run --print-api-report-diffA developer who intends the change runs npx --no-install api-extractor run --local, which rewrites the report, and the report diff goes to the API owner for approval.
One command for the agent and CI. Collect the fast checks under one script, so the agent instruction below can say npm run fitness; the Stop hook and CI call the tools directly, for the reason given there:
{ "scripts": { "fitness": "npm run fitness:deps && npm run fitness:complexity && npm run fitness:dupes", "fitness:deps": "depcruise src --config .dependency-cruiser.cjs --ignore-known --output-type err-long", "fitness:complexity": "eslint src --no-inline-config", "fitness:dupes": "jscpd src --baseline-from-ref origin/main --fail-on-new-clones --fail-on-empty" }}--no-inline-config stops eslint-disable comments from switching a budget off. --baseline-from-ref origin/main needs the base branch present locally, so fetch it (git fetch origin main) before the agent starts. For Python and Java, a Makefile target or a Maven profile plays the same role.
Dependency rules with import-linter 2.15 (pip install import-linter), in pyproject.toml:
[tool.importlinter]root_package = "shop"include_external_packages = true
[[tool.importlinter.contracts]]name = "Layered architecture"type = "layers"layers = [ "shop.api", "shop.services", "shop.domain",]
[[tool.importlinter.contracts]]name = "Domain does not touch infrastructure"type = "forbidden"source_modules = ["shop.domain"]forbidden_modules = ["shop.adapters", "sqlalchemy", "httpx"]# Known violations, each with an owner and a ticket. This list only shrinks.ignore_imports = [ "shop.domain.order -> shop.adapters.db", # PAY-812]
[[tool.importlinter.contracts]]name = "API goes through services"type = "forbidden"source_modules = ["shop.api"]forbidden_modules = ["shop.adapters"]A layers contract fails on any upward import, which also rules out cycles between layers. Without include_external_packages = true, a third-party forbidden module makes lint-imports exit with an error before checking anything. Run it with the source directory on the path:
PYTHONPATH=src lint-importsComplexity budget with Ruff 0.16.9, in the same pyproject.toml:
[tool.ruff.lint]extend-select = ["C901", "PLR0912", "PLR0913", "PLR0915"]
[tool.ruff.lint.mccabe]max-complexity = 10
[tool.ruff.lint.pylint]max-args = 5max-branches = 12max-statements = 50Duplication with jscpd, which is also on PyPI (pip install jscpd) as the same self-contained binary:
jscpd src --baseline-from-ref origin/main --fail-on-new-clones --fail-on-emptyAPI surface with Griffe 2.3.0, which compares the package against a Git reference and reports breaking changes:
griffe check shop --search src --against origin/main --format githubDependency rules with ArchUnit 1.5.1, as a test (com.tngtech.archunit:archunit-junit5:1.5.1, test scope):
package com.acme.shop;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;import static com.tngtech.archunit.library.Architectures.layeredArchitecture;import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
import com.tngtech.archunit.core.importer.ImportOption;import com.tngtech.archunit.junit.AnalyzeClasses;import com.tngtech.archunit.junit.ArchTest;import com.tngtech.archunit.lang.ArchRule;import com.tngtech.archunit.library.freeze.FreezingArchRule;
@AnalyzeClasses(packages = "com.acme.shop", importOptions = ImportOption.DoNotIncludeTests.class)class ArchitectureTest {
@ArchTest static final ArchRule layers = FreezingArchRule.freeze( layeredArchitecture() .consideringOnlyDependenciesInLayers() .layer("Web").definedBy("..web..") .layer("Application").definedBy("..application..") .layer("Domain").definedBy("..domain..") .layer("Infrastructure").definedBy("..infrastructure..") .whereLayer("Web").mayNotBeAccessedByAnyLayer() .whereLayer("Application").mayOnlyBeAccessedByLayers("Web") .whereLayer("Infrastructure").mayOnlyBeAccessedByLayers("Application"));
@ArchTest static final ArchRule domainIsPure = FreezingArchRule.freeze( noClasses().that().resideInAPackage("..domain..") .should().dependOnClassesThat() .resideInAnyPackage("..infrastructure..", "org.springframework..", "jakarta.persistence.."));
@ArchTest static final ArchRule noCycles = slices().matching("com.acme.shop.(*)..").should().beFreeOfCycles();}Freeze every rule that fails on legacy code, including layers: it already flags existing domain-to-infrastructure imports, so unfrozen it turns a brownfield build red on day one. Freeze noCycles too if the codebase has cycles today. Allow the store’s creation once, locally, and commit the store directory:
freeze.store.default.path=src/test/resources/archunit_storefreeze.store.default.allowStoreCreation=trueAfter the first run, remove allowStoreCreation so CI fails instead of silently creating an empty store.
Complexity budget with Checkstyle through maven-checkstyle-plugin 3.6.0 (check goal, bound to verify):
<module name="Checker"> <property name="severity" value="error"/> <module name="TreeWalker"> <module name="CyclomaticComplexity"><property name="max" value="10"/></module> <module name="NestedIfDepth"><property name="max" value="2"/></module> <module name="MethodLength"><property name="max" value="60"/></module> <module name="ParameterNumber"><property name="max" value="5"/></module> </module></module>Duplication with the jscpd command from the TypeScript tab.
API surface for a library with japicmp-maven-plugin 0.26.2 (cmp goal): point oldVersion at the last released artifact and set breakBuildOnBinaryIncompatibleModifications and breakBuildOnSourceIncompatibleModifications to true.
Run the fitness gates in CI
Section titled “Run the fitness gates in CI”The gates are plain commands, so CI needs no agent or API key. This job runs the TypeScript set; for Python and Java, swap in the commands from their tabs.
name: fitnesson: pull_request
permissions: contents: read
jobs: fitness: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: fetch-depth: 0 # jscpd builds its baseline from origin/main persist-credentials: false - uses: actions/setup-node@v7 with: node-version: 22 - run: npm ci - run: ./scripts/fitness-canary.sh - run: npx --no-install depcruise src --config .dependency-cruiser.cjs --ignore-known --output-type err-long - run: npx --no-install eslint src --no-inline-config - run: npx --no-install jscpd src --baseline-from-ref origin/main --fail-on-new-clones --fail-on-empty # API surface: only for packages others import. In a monorepo, run it per # published package the pull request touched. - run: npx --no-install tsc -p tsconfig.build.json && npx --no-install api-extractor run --print-api-report-diffThe first four checks take seconds. The API report step is the one check the agent’s hook leaves to CI; drop it for an application that nobody imports.
How do you feed violations back to the agent before CI?
Section titled “How do you feed violations back to the agent before CI?”CI is the boundary; the agent’s own loop is the fast path. The instruction is identical in all three tools, so put it in CLAUDE.md or AGENTS.md:
## Architecture fitness functions- Before you report a task done, run `npm run fitness`. It must pass.- A failure message is an instruction. Move the code to the right layer, reuse the existing helper jscpd points to, or split the function. Do not edit `.dependency-cruiser.cjs`, `eslint.config.js`, `.dependency-cruiser-known-violations.json`, `.jscpd.json`, the `fitness` scripts in `package.json` or `scripts/fitness-canary.sh`, and do not add `eslint-disable` comments.- If a rule blocks a change you believe is right, stop and write the case to `ARCH_DISPUTE.md` for the tech lead.The tools differ in enforcement: whether the check runs even when the agent forgets.
A Stop hook runs the gates every time Claude tries to finish. Exit code 2 “prevents Claude from stopping, continues the conversation”, and Claude reads the stderr as the reason. Checked against Claude Code 2.1.283 and its hooks documentation on 26 September 2026.
In .claude/settings.json, the committed project settings, where a leading / resolves to the project root:
{ "permissions": { "deny": [ "Edit(/.dependency-cruiser.cjs)", "Edit(/.dependency-cruiser-known-violations.json)", "Edit(/eslint.config.js)", "Edit(/.jscpd.json)", "Edit(/scripts/fitness-canary.sh)", "Edit(/.github/**)", "Edit(/.claude/**)", "Edit(/.codex/**)" ] }, "hooks": { "Stop": [ { "hooks": [ { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/fitness-on-stop.sh" } ] } ] }}#!/usr/bin/env bash# .claude/hooks/fitness-on-stop.sh: no "done" while a fitness function is redinput=$(cat)# Already continuing because of this hook: block once, then hand over.if [ "$(printf '%s' "$input" | jq -r '.stop_hook_active // false' 2>/dev/null)" = "true" ]; then echo 'Fitness functions still failing after one retry; leaving it to a human and CI.' >&2 exit 0ficd "${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel)}" || exit 0# The same three commands as the CI job, not `npm run fitness`: a script in# package.json is something the agent could rewrite to a no-op.if ! out=$( { npx --no-install depcruise src --config .dependency-cruiser.cjs --ignore-known --output-type err-long \ && npx --no-install eslint src --no-inline-config \ && npx --no-install jscpd src --baseline-from-ref origin/main --fail-on-new-clones --fail-on-empty; } 2>&1); then printf 'Fitness functions failed. Fix the code, not the rules or baselines:\n%s\n' "$out" \ | tail -n 60 >&2 exit 2fiexit 0Make it executable; it needs jq. It runs only the fast checks and calls the tools directly, so editing package.json cannot switch it off, and the .claude/** and .codex/** deny rules protect the hook itself. It blocks only once: when stop_hook_active shows the agent is already continuing because of this hook, it lets the turn end, and CI still blocks the merge. Claude Code also overrides a block after eight consecutive stop-hook continuations (CLAUDE_CODE_STOP_HOOK_BLOCK_CAP changes the cap). Deny rules do not stop a script that writes files itself, so enable the sandbox from protecting the oracle if the agent has shell access.
Codex reads the AGENTS.md instruction above. For enforcement, Codex CLI 0.157.1 has a Stop hook event among its 12 hook events, and its .codex/hooks.json uses the same JSON shape as Claude Code. Codex runs the command in the session’s working directory, so resolve the repository root in the command; the script falls back to git rev-parse --show-toplevel when CLAUDE_PROJECT_DIR is unset:
{ "hooks": { "Stop": [ { "hooks": [{ "type": "command", "command": "\"$(git rev-parse --show-toplevel)/.claude/hooks/fitness-on-stop.sh\"", "timeout": 600 }] } ] }}Codex also passes stop_hook_active, and that check is what bounds the loop; do not count on a cap like Claude Code’s. Project hooks do not run until you trust them with /hooks, and again after anyone changes hooks.json. The cross-tool setup, including fixtures that prove each hook blocks, is in agent hooks.
To stop the agent loosening the rules or the hook, add the configuration and baseline files, .codex/hooks.json and the hook script as exact read paths to the permission profile from protecting the oracle. Exact paths work there; read-only globs are rejected in 0.157.1.
Add the instruction as a project Rule so Agent applies it to every task. Cursor also has hooks, which “run before or after defined stages of the agent loop and can observe, block, or modify behavior” (cursor.com/docs/hooks, checked 28 August 2026). We could not re-check the event names on 26 September 2026, so take them from Cursor’s hooks reference when you port the script. Agent hooks covers the adapter approach and how to keep a Cursor hook advisory until it is proven.
Cloud Agents run in their own VMs, so a hook on your laptop does not travel with them: the required CI check is what binds them. Bugbot can review rule-change pull requests, but it is a reviewer, not a gate.
Copy-paste prompts for fitness functions
Section titled “Copy-paste prompts for fitness functions”How do you know the fitness functions actually work?
Section titled “How do you know the fitness functions actually work?”A gate that cannot fail is worse than no gate, because the green check buys trust it has not earned. Two of the tools on this page produced exactly that during our tests: dependency-cruiser under TypeScript 7 and ArchUnit under an older surefire. Prove the gate can still fail on every run:
#!/usr/bin/env bash# scripts/fitness-canary.sh: proves the dependency gate can still failset -ucanary=src/domain/__fitness_canary__.tsecho "import { db } from '../infra/db.js'; export const c = db;" > "$canary"trap 'rm -f "$canary"' EXITout=$(npx --no-install depcruise src --config .dependency-cruiser.cjs --ignore-known --output-type err 2>&1)# Require the rule name: a crash (bad config, missing tsconfig) also exits non-zero.if ! printf '%s' "$out" | grep -q 'domain-stays-pure'; then echo "The known-bad import was not reported: the gate is blind or broken." >&2 printf '%s\n' "$out" | tail -n 20 >&2 exit 1fiecho "Canary caught: the dependency gate is live."Point the canary’s import at a module that really exists in your infrastructure layer: dependency-cruiser cannot resolve a missing file, so the rule never matches and the canary raises a false alarm.
We ran this canary on 26 September 2026. With TypeScript 6 it reported the gate live; with TypeScript 7 installed it failed the job as blind, which is exactly the alarm you want. Use the same idea per stack: a fixture module that imports across a forbidden boundary for import-linter, an ArchUnit test class that asserts layers fails against a fixture package, and --fail-on-empty for jscpd.
Sign-off by role:
| Who | Owns | Approves |
|---|---|---|
| Tech lead | The rule set, the budgets and the canary | Every change to a rule, budget or baseline, through CODEOWNERS |
| API owner | The committed API report or the base for Griffe and japicmp | Every intentional API break |
| Developer or agent | Keeping the fitness job green | Nothing in the rules; disputes go to ARCH_DISPUTE.md |
| CTO | The trend | The monthly numbers below |
For the CTO, three numbers per repository are enough and come straight from the tools: the baseline size (dependency-cruiser’s JSON output reports summary.baselineSize; import-linter’s ignore_imports length; ArchUnit’s store), which should only fall; the number of rule or budget changes merged per month, each with an approval; and fitness failures caught per week on agent pull requests. A falling baseline with steady catches means the architecture is holding while the agents do the typing; the per-PR results belong in the evidence bundle.
What breaks when you gate on fitness functions?
Section titled “What breaks when you gate on fitness functions?”The gate goes green because it analysed nothing. An unsupported TypeScript version, a wrong root_package, an old surefire or a bad path turns the check into a no-op that exits 0. Recovery: run the canary first in CI, add --fail-on-empty to jscpd, and read the “modules cruised” count in the log after every toolchain upgrade.
The agent loosens the rule instead of fixing the code. It adds a baseline entry, raises max, or sprinkles eslint-disable. Recovery: step 5, plus ESLint’s --no-inline-config so a disable comment has no effect.
The agent satisfies the rule with a worse design. It splits a complex function into three private helpers that each take seven parameters, or moves infrastructure code into the domain folder so the import becomes “legal”. Recovery: pair complexity with max-params, keep layer rules path-based and review the file moves in the agent PR review triage, where moved files are a risk flag.
The baseline becomes a second codebase nobody reads. Hundreds of ignore_imports lines with no owner. Recovery: require a ticket reference on each entry, run the ratchet prompt monthly, and track the baseline size as a number.
Duplication gates fire on generated code. Protobuf stubs, ORM migrations and snapshot files are duplicated by design. Recovery: exclude generated paths with jscpd’s ignore setting in .jscpd.json, not by raising the budget.
The rules encode an architecture nobody agreed on. The agent drafted them, nobody pushed back, and now they block good changes. Recovery: rules come from the five sentences in step 1 and an ADR the team reviewed. Change the ADR first, then the rule, in one pull request the team approves.
Where to go next with fitness functions
Section titled “Where to go next with fitness functions”On the tech-lead track, the next step is reviewing agent pull requests.