Changelog
All notable changes to Maintainability Agent will be documented here.
How to read this file. Entries are written for someone deciding whether a change affects them, so each one states the behaviour that moved before it explains why. Most are long because this project fixes a class rather than an instance, and the reasoning is the evidence that the class is closed — it is a track record, not an apology. If you want the short version of where the product stands, read the README; if you want what is coming, the roadmap. Neither requires this file.
Unreleased
- The security gate job runs secure-code-agent 0.13.1 too. 5.1.2 moved the
plain pins and left the job that writes the code-scanning SARIF on 0.12.11,
through its
[required-scanners]install and theinstall-flooraction's SHA. A test now holds every CI pin of the tool, in each form, to one version.
5.1.2 - 2026-10-30
- An example report and the documentation, online. The README links a real HTML report, this repository audited by itself at 5.1.2, and the rendered documentation, both served by GitHub Pages at maintainability-agent.techportfolio.monster. A test holds the link, the version it names and the report's self-containment; regenerating the report is a release step.
- CI runs secure-code-agent 0.13.1. Its SARIF no longer marks a run as failed when a scanner covers only part of the tree, so GitHub's code-scanning page stops reporting a configuration error for the security upload.
- An accessibility statement and a pull request template.
ACCESSIBILITY.mdsays what each output offers assistive technology, what is known not to work well (the HTML trend charts have no accessible name and separate series by colour), and how to report a barrier; a test holds its claims about the outputs..github/pull_request_template.mdasks for the failing test, the checks and the changelog entry. Nothing in the package changes.
5.1.1 - 2026-10-30
- A directory that vanishes mid-walk no longer crashes the audit (D236).
Fifteen walks of the audited tree used
rglob, which went into.gitand raisedFileNotFoundErrorwhen a directory it had listed was gone by the time it opened it: a git worktree being added or removed, or an IDE or build writing beside the audit, ended the audit with a traceback. Every walk now goes through one walker that never enters.git, never follows a symlinked directory out of the tree, and treats a directory it cannot open as empty. A structural test refusesrglobanywhere else in the package. - The README speaks five more languages, briefly, and carries a badge row
(Decision 20). Short pages in Spanish, German, French, Hindi and
Simplified Chinese say what the tool is and how to install and run it, and
point to the English README as the complete copy. Each is marked as a
machine translation and carries no version number, so a README change never
leaves one stale. The badges show the published version, Python versions,
licence, the self-audit of
mainand downloads. Nothing in the package changes.
5.1.0 - 2026-10-27
New, reported only: test honesty, after Ken Pugh's
AlignThree Spectables (Decision 19, #313). No
score, grade, gate or calibration constant moves; every published number is
what 5.0.1 printed. The report gains a test_honesty block, and the Markdown
and HTML reports and the remediation prompt show it:
- Skipped tests. Skip, pending and expected-failure markers in test files, by file and line. A suite reports them as something other than a failure, so a skip reads as a pass.
- Tests that compute their own expected value. Python test comparisons
whose one side calls the code under test while the other does arithmetic on
variables (
assert add(a, b) == a + b). The rule is narrow on purpose: on this repository's suite it reports nothing, and path joins, literal arithmetic, relations between two results and input sizes are not flagged. - Spectables specifications.
.spectablefiles are read where a repository keeps them, and three authoring mistakes from the published grammar are reported: a row with the wrong number of cells, a repeated scenario name, and an=Namecell naming noDefine. On Ken Pugh's own 94 example specifications it reports nothing. Unknown types are not checked, because no list of built-in types is published.
The README's acknowledgements credit Ken Pugh, and this feature's own
specification is written as a Spectables file at
docs/specs/test-honesty.spectable.
- Decision 18, the CI notes and source comments are reworded. The decision record, test docstrings, code comments, workflow comments and the defect register now state what was found, decided and fixed. No behaviour changes.
5.0.1 - 2026-10-27
- A corpus row names the analyzer it lost, and why (#312). The 5.0.0
re-measure lost one tool on each of seven repositories and the rows could
not say why, because they listed only the tools that contributed. On
fastapi it was radon timing out: one 7,304-line test file costs it 109 of
its 120 seconds, so whether it finishes depends on the machine. Each row
now carries
did_not_contribute— a timeout, a failure or unreadable output — while a tool that does not apply stays out. No published number moves.
5.0.0 - 2026-10-26
Grades move: this is a recalibration. The analyzer pool was re-pinned and every corpus repository re-measured on it. On the 201 repositories, 2 change grade — go C→B, manim B→C — and 14 move at the published decimal. The grade counts are unchanged and the corpus median still scores exactly 4.0. If you track a grade over time, expect a step at this release; the trend already reports it as a break, because the calibration constant changed.
Changed — the analyzer pool is re-pinned (#296)
constraints/analyzers.txtmoves to current releases: astroid 4.3.4, complexipy 8.2.0, fortitude-lint 0.10.0, multimetric 2.4.5, pyflakes 4.0.3, pylint 4.1.3 and ruff 0.17.2, among others.- lizard is held at 1.24.0. 1.24.3 fails with "Fail to parse file" on
JavaScript that 1.24.0 reads, and exits 1 for the whole batch, so lizard
measured nothing on 26 of the 201 corpus repositories. A hold now lives in
constraints/held.txtwith its reason, and both the job that resolves the pins and the weekly drift check apply it, so the next refresh keeps it and the drift check does not report it. - This is the first corpus measurement in which pylint and cohesion
contribute, which they should have done since 4.3.0 (D226). They read
Python declarations as denser, and that is what moved the scale: the
declarations reference goes 0.0929 → 0.0944 and
CALIBRATION_C4.3092 → 4.35. The other references did not move. The full record is in_calibration.pyanddocs/migration-2.0.md.
4.4.6 - 2026-10-26
An audit no longer leaves temp directories behind. No published number moves.
- The defect (D235). jscpd, complexipy, checkstyle and spotbugs each made
a scratch directory every time the adapter was built — on every audit,
whether or not the tool ran — and never removed it. One machine held
74,535 of them, 13.5 GB. Scratch is now made only when a tool runs and is
removed once its output is read. The test suite stopped leaving them too.
Directories an earlier version left are not removed for you: delete
jscpd-*,complexipy-*,checkstyle-*andspotbugs-*from your system temp directory.
4.4.5 - 2026-10-26
The audit of main runs again, and a range git cannot resolve is refused
by name. No published number moves.
- The defect (D234). Since CI moved the maintainability audit from pull
requests to pushes on main (Decision 18), it diffed against the pull
request's base branch, which is empty on a push, so it failed on every push
to main — with a traceback. It now audits the push's own range, and
--changed-onlywith a range git cannot resolve exits 2 and names it.
4.4.4 - 2026-10-26
A production file whose name starts with test pairs with its test.
Testability can rise for a repository that holds one.
- The defect (D233, issue #306). pFUnit's camelCase rule
(
testFooBar_mod.F90coversfooBar_mod.F90) was applied to every language, sotest-rp.mjs,tester.pyandtesting.mjslost their first four letters and never paired with the tests that cover them. The rule now applies to Fortran tests only.
4.4.3 - 2026-10-15
--update puts every MCP server on the new version itself. No published
number moves.
- The defect (D232). After an upgrade, 4.4.2 told the operator to run
/mcpto restart the MCP server. In the VS Code panel/mcponly lists servers, so the servers went on serving the old code. - Now each MCP server records its pid and version and marks itself busy
while an audit runs.
--updatestops the servers on an older version that are not mid-audit; the host starts the new version on its next call. A busy server is left running and the next hourly update stops it.
4.4.2 - 2026-10-15
--update keeps the install current without ever moving it backwards.
No published number moves.
- The defect (D230). The local updater was a script outside the product.
It trusted PyPI's lagging
info.versionand once downgraded 3.7.29 to 3.7.19, and it never re-synced the skill after an upgrade. --updatelets the installer resolve the newest release, refuses a lower result, re-syncs the skill, and says when the MCP server needs a restart.--install-updaterschedules it hourly as a macOS launch agent namedio.github.marshallguillory86.maintainability-agent.update, removing the legacy agent so two never run.- The test suite never writes outside its own fixtures (D231). Run
from a git hook, it inherited
GIT_DIRand committed its fixtures into the real repository. It now drops git's location variables before any test runs git.
4.4.1 - 2026-10-14
An audit can no longer rewrite the repository it audits. No published number moves.
- The defect. The ruff adapter ran
ruff checkwithout--no-fix, and ruff honours a repository's ownfix = true. An audit of a repository configured that way rewrote 102 of its files — unused imports deleted, imports re-sorted — and broke its test suites. MA promises it never edits source; that promise had an unguarded path. The fortitude adapter had the same shape. - The fix. ruff and fortitude run with
--no-fix. - The class. The audited tree's tracked state is snapshotted around every
analyzer run. Any change raises
RepositoryModifiedError, naming the files, and the audit returns no grade: a score for code that is no longer what was audited is not a score. Over MCP it is a refusal you can act on, and the adapter backstop cannot swallow it. A parametrized test forbids write-enabling flags (--fix,--write,--in-place,--applyand the like) in every adapter's command line.
4.4.0 - 2026-10-12
The analyzers your own pipeline already runs can now close a coverage gap, and a report says when an analyzer's findings do not move the grade. No published number moves.
Added — your pipeline's SARIF, read and never run (Decision 17)
- This tool runs a pinned handful of the analyzers it catalogs. The rest run
where you already run them, and their SARIF is read: declare each file in
analyzers.pipeline_resultswith the concerns it covers. First-run setup asks on every surface —declare, then the files. A declared file turns those concerns from unexamined to examined for the languages its results name; its findings are listed and do not score. A file older than the last commit is called stale, and one that cannot be read is reported as such. - Where concerns are unexamined, the coverage section now names this route beside installing a tool.
Fixed — findings the grade does not use looked ignored (D228)
- jscpd's duplicate blocks are listed in the findings, and the duplication grade is computed from the built-in detector, by design. On a C# repository that read as the analyzer's output being ignored. The coverage section now names each analyzer whose findings are listed but not scored, with the count and the dimension.
4.3.1 - 2026-10-09
This public repository no longer names its owner's private repositories, and a check now stops it happening again. Nothing an audit measures or reports changes.
Fixed — private repositories were named in a public one (D227)
- Three private repositories were named in the changelog, the defect
register, a document's file name, code comments, test docstrings and the
scan history. They are replaced with neutral wording, and
docs/defects-from-a-field-audit-2026-09-26.mdis the renamed document. - The existing check read only the lines a change added, so a name already
in the tree was invisible to it.
tools/check_private_names.pyreads every tracked file, every path and every commit message in a change against names held outside the tree, reports a match by file and line without repeating it, and fails when it has no names to check. It runs in the required "Commit identity and signatures" job. Binary files are read too, including the documents inside office files. - Its first run found three business documents, as HTML and .docx, naming an organisation the earlier check already forbade. They are removed; they were never in a published package.
Fixed — every CI job tests against the pinned lizard
- Three jobs installed the dev extras without the analyzer pool's pins and
took lizard 1.24.1, which counts Rust match arms as the scanner does, so a
declared divergence vanished and the release job refused to publish. Every
job that installs the dev extras now applies
constraints/analyzers.txt, and a test reads the workflows to keep it so.
4.3.0 - 2026-10-04
Code a change touches can be held to a stricter bar, and three analyzers that silently contributed nothing now run. No published number moves for a repository that sets no policy.
Fixed — three analyzers were lost to every audit (D226)
- pylint analysed nothing: exclude globs were passed to its
--ignore-pathsas regular expressions it could not compile. They are now translated, for pylint and mypy. - interrogate analysed directories it was told to skip, and one
unparseable file there crashed it: its exclusions arrived as one argument.
Each path is now its own
--exclude. - cohesion was recorded as failed without running: its availability
check used
--version, which it does not accept. - A tool that writes no output is now reported as failed with what it said, whatever its exit code, instead of as unreadable output.
Added — code a change touches meets a stricter bar (Decision 15)
policy.changed_codeinmaintainability-agent.jsonsets stricter function limits —max_function_lines,max_complexity,max_cognitive_complexity— for the functions a change touches. On a run with--changed-only REV, a function the change added or altered a line in is held to them; untouched code is judged by the standard as before, so a team can gate new work without first fixing every old function.- A policy only tightens: a limit looser than the standard is refused when
the configuration loads. It decides the gate, never the grade — breaches
fail
--fail-on-gateandgate_passed, and are listed under Changed-code policy in every report format, while the score is unchanged.
4.2.0 - 2026-10-03
A committed docs site no longer counts twice. Every repository is meant to commit an HTML site rendered from its Markdown, and the audit scored it as source on the default configuration: duplicate blocks doubled and debt markers were reported again in pages nobody may edit. No published number moves for a repository without one.
Added
paths.exclude_additionaladds exclusions to the defaults instead of replacing them; settingpaths.exclude_patternsto skip one directory discarded every default exclusion (D224).
Fixed
- A committed docs site rendered from Markdown is no longer scored as source:
tools/render_docs.pystamps each page@generated, which discovery honours. Each document used to count twice — duplicate blocks doubled and debt markers were reported again in pages nobody may edit (D224, #292). - Reading a file no longer fails on Windows for want of
os.O_NONBLOCK(D225). - CI runs secure-code-agent 0.12.11.
4.1.0 - 2026-10-03
No published number moves. The analyzers now read every file of a large
repository, and the whole calibration corpus was re-measured to prove what
that changes: ten of the 201 repositories read differently, every scoring
constant came out identical to four decimals, and no repository's score moved
at the published decimal or changed grade. Re-measuring took 61 minutes with
--jobs 12, against about five hours one at a time.
Fixed
- Analyzers that are handed a file list — complexipy, multimetric, cohesion, pydocstyle, PMD and Checkstyle — read every file of a large repository. The list used to be cut to fit one command line, so FFmpeg's analyzers saw 1,543 of 4,687 files; it is now split across runs and merged, and a batch that fails is reported as the tool's failure (D220).
- The calibration's scanner fingerprint covers every module a measurement
passes through; it missed eight language scanners and most of their
imports, so
--reusecould keep rows an older scanner produced (D221). - A corpus measurement saves each row as it goes and a restarted run picks
up where it stopped, and
--jobs Nmeasures N repositories at once with the same rows in the same order. The calibration noise check takes--jobstoo, with the same result for the same seed. - The security run's time limit was a hard-coded 300 seconds, and a full
secure-code-agent run on a large repository takes longer, so the pillar
came back unmeasured. The default is now 900 seconds, and
security.timeout_secondsinmaintainability-agent.jsonsets it, held to 1–3600 like the analyzer limit. - cohesion on a repository with no Python is reported not applicable rather than failed; it used to be spawned with no files and exit with a usage error (D223).
4.0.1 - 2026-10-02
Security — a repository could run code on the auditing machine (D222)
- Upgrade promptly if you use the chat door. Since 3.12.0, asking about a
repository — before choosing "run" — ran secure-code-agent's preflight
against the repository's own
secure-code-agent.json, which could name any program with any arguments. A hostile repository could execute code on your machine just by being asked about. The preflight before consent now reads a neutral configuration and never the repository's. - The run after consent had the same exposure through secure-code-agent
itself. It is closed there in secure-code-agent 0.12.10, and this release
requires it:
pip install 'secure-code-agent>=0.12.10'. - Found by an adversarial audit of 4.0.0 and reproduced before it was fixed.
Fixed
- The chat view lists source-named symlinks the scan skipped; only the full report did.
- A symlink to a file outside the repository is no longer counted as source the score read.
- Every documented
secure-code-agentinstall line names the supported floor, and a test keeps it that way; three still said>=0.12.2.
4.0.0 - 2026-10-02
Grades move: this is a recalibration. Kotlin and Shell are anchored, so the reference every score is a multiple of is now drawn from 201 mature repositories in fifteen languages rather than 180 in thirteen. On the 180 repositories in both corpora, 9 change grade — 8 B→C and 1 C→B — and 52 move at the published decimal. The corpus median still scores exactly 4.0. If you track a grade over time, expect a step at this release; the trend already reports it as a break, because the calibration constant changed.
Changed — Kotlin and Shell are in the reference corpus
- Fifteen Kotlin and six Shell repositories joined the corpus, selected by the same query as every other language. A Kotlin or Shell grade is no longer called provisional. COBOL remains the one unanchored language.
- Shell entered thin: ten of its sixteen candidates were collections, configuration or too small to be codebases. That is disclosed in the corpus and the standard rather than padded by hand.
- Every row was re-measured on the analyzer pins of 3.12.0. Separated on the 180 shared repositories, the new pins alone would change 4 grades; the new languages account for the rest.
- References: file size 0.0938 → 0.0871, declarations 0.0962 → 0.0929,
duplication 0.3144 → 0.2973, risk 0.0341 → 0.0249;
CALIBRATION_C4.2565 → 4.3092. The full record is in_calibration.pyanddocs/migration-2.0.md. - The smallest repository the scale scores is now 20 source files, down from 24, because pyenv entered the corpus at 20. Repositories of 20 to 23 files now get a rolled-up score.
- The report's corpus note took its size and language list from typed prose; both now come from one constant checked against the corpus itself.
Fixed
- A symlink with a source extension whose target does not exist — usually a
link into a git submodule that was not fetched — ended the audit with a
traceback. It is skipped, because there is nothing to measure, and named:
summary.dangling_source_links, and Links not followed in the markdown and HTML reports (D219). A FIFO is still refused.
3.12.0 - 2026-10-01
Added — missing security scanners are named before the run
- When secure-code-agent cannot find a scanner it would run, the run-or-
reconfigure question now says so and carries each one's install command in
environment_work_order, before an audit is spent on anunverifiedsecurity pillar. Every report carries the same entries. The commands are secure-code-agent's own, read from--preflight --json, so the advice cannot drift from the tool it describes. An unreadable preflight adds nothing and never breaks a run.
Fixed
- Two CI steps installed the analyzer pool without
fortitude-lint, so the weekly drift check compared the pins with a smaller pool than the one they pin and reported it as drift (#284). A test now requires every step that installs the pool to install all of it. - The analyzer pins are re-resolved on Linux (2026-10-01): complexipy 8, pyflakes 4, isort 9, and newer pylint, mypy, flake8 and ruff. On this repository every analyzer reports the same findings under both pin sets, and complexipy 8 scores all 1176 functions as 7 did.
- A payload test failed on any machine whose installed version differed from the source tree; it no longer depends on the machine's install.
3.11.1 - 2026-09-28
Fixed — conformance and coverage edges an audit of 3.11.0 found (D218)
- A test file whose path git quotes (a space, a quote, a non-ASCII name) is now
read by
--conformance; it was skipped entirely, socleannever saw it. skipif,skipIf,pytest.skip(),xit(andtest.skip(count as skips, and only a helper-shaped call excuses a deleted assertion — notprint(...).- A deleted test is reported as
test deleted. --asklists globs that matched nothing, and an ask that names nothing is a usage error.- Coverage is not read through a symlinked
coverage/directory, and an unreadable coverage file no longer stops a readable one from being scored. - A saved report cannot take the place of the repository's config, history or baseline file.
3.11.0 - 2026-09-27
The condition behind the first addition — a deterministic check is only evidence when the work under review could not author the record — is credited to Michael Eakins, who raised it in a public discussion of this project. Thank you, Michael.
Added — clean sees a weakened test (Decision 12)
--conformance said a diff was clean when it stayed in scope and added no
suppression to a file the work order named. The test file the pairing rule lets
in beside a fix could be skipped, aliased, emptied or made tautological, and
clean stayed true while the suite went green. Now any test file in the diff
made unable to fail — a skip or suppression, an assertion that cannot fail
(assert True, expect(x).toBe(x)), the assertion API replaced (assertEqual =
…, expect.extend over a built-in matcher, jest.mock('expect')), a failing
assertion swallowed, or assertions deleted with nothing in their place — fails
clean and is listed under oracle_weakened. The precision bar was frozen
before the detector was written: zero false alarms on a labelled set of
legitimate test edits, every labelled weakening caught. Still shape: whether an
assertion expects the right value is not something this checks.
Added — --ask: read a diff against an ask this tool did not write (Decision 13)
--conformance main...HEAD --ask FILE reads the diff against the paths and
globs the task was asked to change instead of the work order, for work this tool
did not order. Every change outside the ask is out of scope, with production and
test changes counted apart — so "every frozen test passed and the diff touched
code nobody asked for" is something the record can say. The file is read from
your path, never from the audited tree, and --ask without --conformance is
refused.
Added — coverage from lcov
A suite that writes coverage/lcov.info or lcov.info — the JavaScript
convention (c8, nyc, jest, vitest) — now scores test_effectiveness as
coverage.xml does, under the same rule that only an artifact this run wrote is
read.
3.10.0 - 2026-09-27
Fixed — a chosen report is saved, and chat gets the summary (D216)
On the chat door, choosing html returned the whole HTML report inline — on a
real repository far over what a host accepts, so the host refused it and no
report was delivered. The report is now saved to the location you choose
when asked where to save (output_path), and chat gets the bounded view and
the file's path. Choosing markdown or html without a location is refused
before the audit runs, so your host asks. A saved HTML report is marked
generated and is not audited as your code on the next run. The whole chat
reply now has a size budget, tested for every format on a large repository.
Fixed — Shell reading (D217)
$((1 << n)) and (( y = x << 2 )) were read as heredocs, which hid every
function after them from the audit. They are arithmetic now. A keyword after
do, then or else in an argument list (echo do for it) no longer counts
as a branch. The Shell page names every line shape that still over-counts.
Fixed — consent to run a test command is per repository (D214)
Consent given while setting up one repository ran that repository's test
command in every repository audited afterwards: auditing a private application executed
this project's own pytest --cov=maintainability_audit against a JavaScript
tree. Consent is now recorded against the repository it was given for, and a
repository with none of its own runs nothing and says so. A new repository
keeps your other answers and is asked only its own test command, on the chat
door and at the terminal alike, and clearing the command declines for that
repository only. If you opted in before 3.10.0, you will be asked your test
command once per repository: a consent recorded before this is not run
anywhere, because which repository it was given for is not known.
Fixed — behaviour suites are test code (D215)
acceptance/ and e2e/ are read as test directories, so a repository
whose behaviour tests live there no longer has them graded as production code
and its handlers reported untested. cypress/, playwright/, integration/
and features/ are not: each is production code somewhere, and one is a
calibration-corpus repository. The two directory lists now share one, with
testing/ kept as a declared discovery-only name until the next
recalibration.
3.9.0 - 2026-09-24
Added — Shell is parsed
The sixteenth language with a scanner of its own, and the second of the five
the roadmap schedules. .sh, .bash and .zsh produce a declaration
population: functions in the POSIX name() form and bash's function name
forms, whose body may open on the next line and may be a subshell ( … ).
Parsed by default, and that changes something for most users. Shell is in nearly every repository, so their scripts now join the graded population, and a grade can move because a repository's scripts were read for the first time. Shell is not in the calibration corpus, so every report says the grade is provisional for it, beside COBOL and Kotlin. The report's list of concerns an independent analyzer examined also narrows for any repository with scored scripts, because only jscpd reads shell: that list is the intersection over scored languages, and it is disclosure only — the grade does not read it. Each language's own coverage is still reported separately.
Three readings are shell's own:
A masker of its own. # starts a comment only at the start of a word, so
$#, ${#list[@]} and ${path#prefix} are code. // and /* are not
comments, so rm -rf "$dir"/* does not blank the rest of the line. Heredoc
bodies are text, <<- terminators may be tab-indented, and a double-quoted
string may span lines. Quote characters survive masking, so a case arm written
"start"|"stop") is still recognisable as one.
A keyword counts only in command position. Shell scripts are full of
unquoted prose, and echo waiting for the server has a for in it that is an
argument. if, elif, while, until, for and select count at the start
of a command; case counts at its arms, not its header, and *) is the default
and not counted; && and || each count, because cmd || exit 1 is the guard
clause scripts are built from.
Nesting is read from fi, done and esac. The brace reader would score a
deeply nested script as flat. A construct written on one line opens and closes
there and leaks no depth.
No second implementation reads shell complexity: lizard 1.24.0 has no shell reader and nothing adapted in the catalog measures it. The reader is checked against the grammar instead — every compound command and list operator in POSIX XCU 2.9.3–2.9.4 and the bash constructs in the Bash Reference Manual 3.2.5, one specimen each — and Shell is named beside COBOL in both the second-opinion and the external-complexity exemptions, each disclosed on the language-support page. What it misses is on its page.
The unanchored-language caveat now reads as a list — "COBOL, Kotlin and Shell" — rather than joining every name with "and", which was right only while there were two.
Maintenance — the ways a finished tool ages now open an issue
Three risks the roadmap named, each of which failed silently:
- The pinned analyzer pool stops installing. The weekly drift job went red
and nobody saw it. It now keeps one standing GitHub issue open while it fails
(install failure or drift) and closes it when it passes
(
tools/scheduled_issue.py). - The calibration corpus goes stale.
reference.corpus_measuredin every report says when the corpus was measured, and the corpus note says so in words. A test binds that date to the corpus file's last commit. A new weekly check (tools/check_corpus_age.py) opens an issue once it is more than 365 days old. It never blocks a release. - Python 3.12 is the only version CI runs. The package claims 3.11–3.13. A
weekly
maintenance.ymljob runs the suite on 3.11, 3.13 and the newest Python, and a test fails if a claimed version has no CI job exercising it.
Documentation — every document in one readable HTML copy
docs/html/ now holds a reading copy of all the documentation: the root
documents, all 60-odd under docs/, grouped by kind, and cards linking the
standalone HTML pages already there. tools/render_docs.py produces it, adapted
from another project's documentation renderer. The markdown stays the source of truth.
The set of documents is read from git rather than a list, so a new document is
picked up automatically and an untracked file is never published. The output has
no timestamps, so tests/test_rendered_docs.py can require the committed copy to
match its markdown: a document edited without re-rendering fails the build.
markdown joins the dev extra; the product never renders documentation.
Documentation — AI reviewing AI is still an opinion
The docs said a platform that generates code and grades it is producing a
self-assessment, and said nothing about the more common case: a different
model reviewing AI-written code, as AI pull-request reviewers such as Cursor's
Bugbot do. docs/why-this-exists.md now covers it. Model review is a finder,
not evidence, and a finding counts once something deterministic reproduces it.
That is the loop ADR 013's hostile-audit brief already closes.
docs/product-intent.md states the principle, and the roadmap lists model-judged
code review among what this tool does not compete on, as a complement.
Fixed — a release cannot ship over one that never reached PyPI
3.7.2 was tagged and its release build failed — the suite ran without secure-code-agent installed — so it was never published. 3.7.3 fixed the build (D177) and shipped over the gap, and nothing recorded that a version was missing. The tag check added in 3.8.1 would not have caught it: the tag existed.
The release build now runs tools/check_previous_release_published.py before
publishing, which asks PyPI whether the previous version is there and refuses
otherwise — including when PyPI cannot be reached, because a check that passes
when it cannot run is not a check. Run against the real index, it refuses 3.7.3
by name.
docs/release-plan.md now lists every version with a changelog section and no
package — 0.3.0–0.6.0, 2.11.0, 3.7.2, 3.7.12 and 3.7.20–3.7.30 — with what
happened and which release carried its changes, and says plainly that none is
to be republished.
Documentation — the documents that describe today caught up with Kotlin
3.8.0 updated the README, the language-support page and the roadmap's
schedule, and left six statements about the present describing the tree
before it: the help index still said "the fourteen parsed languages", the
architecture document's list of what DECLARATION_SUFFIXES gates stopped at
Ruby, the roadmap's Shipped entry counted fourteen, its paragraph on unwritten
languages said Kotlin had no declaration population, and its Sonar comparison
counted nine free languages of fourteen — Sonar's Community Build covers Kotlin
too, so it is ten of fifteen. Two code comments carried the old count.
test_current_state_docs_name_every_parsed_language now holds the help index's
count to the one the report derives, and the architecture list to the
language-support table, so the next language cannot leave them behind.
3.8.1 - 2026-09-23
Fixed — a merged version cannot sit untagged under the next one
3.7.20 through 3.7.30 merged over two days and none was tagged, so none reached PyPI. Among them was D211, the fix that stops an audited tree redirecting a read out of itself — merged, reviewed, and delivered to nobody who installed the package. 3.8.0 was the first release after 3.7.19 and carried all eleven.
Nothing noticed because the only check on tagging accepts the release plan's
"Last tagged version" row if it names the newest tag or the version the tree
is about to ship, and tools/stamp_version.py writes the second on every bump.
The row always satisfied the check whether or not anything was tagged.
test_the_previous_release_was_tagged now requires the release before the one
a tree ships to carry its tag, which stops a backlog on its first step: 3.7.21
could not have merged while 3.7.20 sat untagged. It deliberately does not look
further back — forty-seven releases between 1.1.0 and 3.7.5 are on PyPI with no
tag here, because the 2026-09-16 history rewrite left their tags pointing at
commits that no longer exist. Those were removed on purpose and must not be
re-created; a re-pushed tag would republish an old version over a newer one.
It fails rather than skips on a checkout with no tags, so the release build now fetches full history like every quality-gate job already did. This release is the first to run through both.
Fixed — pytest --cov could hide a failing test behind its own pass count
The test-execution tests run a repository's suite inside a temporary tree.
Under pytest --cov that child wrote coverage data too, reading its settings
from its own working directory — which has none, so it recorded statements
while this repository records branches. combine then aborted with DataError
after pytest had already printed "N passed", and the internal error consumed
the failure summary. One run on 2026-09-23 reported a failure whose name was
never recovered.
The suite now points every child at this repository's coverage settings. Nothing changes in CI, where the two already agreed; a local coverage run completes and reports its failures.
3.8.0 - 2026-09-23
Added — Kotlin is parsed
Kotlin is the fifteenth language with a scanner of its own, the largest real
gap left on the roadmap, and the first of the five languages scheduled there
one per minor release. .kt and .kts produce a declaration population:
functions, constructors, init blocks, and the types — class, interface,
object, and the data/sealed/enum/annotation/value/inner forms,
which Kotlin writes as modifiers on class rather than as keywords of their
own.
Three readings are Kotlin's own, and each is a place a shared default reached the wrong answer:
An expression body has no brace. fun area() = w * h is idiomatic Kotlin,
not a corner. The shared end-finder bounds a body by its braces and falls back
to indentation, and that fallback walks to the closing } of the enclosing
class — a one-line function came back two lines long, with the brace ending
its container counted as its last line. The shared bodyless check looks for
=>, the arrow JavaScript and C# use for an expression member; Kotlin writes
=, so every expression-bodied function in a tree would have been dropped as a
signature with nothing behind it. Kotlin supplies its own end-finder and
answers the bodyless question itself.
when is counted at its arms, the rule already shared with switch in Go,
PHP, Ruby, Python, Swift and Fortran. Kotlin spells an arm ->, which is also
how it spells a lambda's parameter list and a function type, so both are
consumed before the arms are counted. when was in none of the cognitive
vocabularies either, so a dispatch written the idiomatic way read as
cognitively flat while the same dispatch written if/else if was charged in
full — the Fortran do, Swift guard and C# foreach defect in a fifth
language.
Kotlin has no ternary, because if is an expression there. The C-family
?…: alternative is therefore absent rather than merely unused: with it,
fun find(id: Int?, name: String?) scores two, which is D115's defect in a
sixth spelling after C#, TypeScript, PHP, Java and Swift. ?: (elvis) is
counted as Kotlin's ??; ?. is not, because it yields null and carries on
down the same path.
An extension function needs no second pass: fun Widget.draw() writes its own
receiver, which is the one place Kotlin is easier than Swift.
Fixed — lizard reads Kotlin, and the catalog said it did not
The analyzer catalog's lizard row listed sixteen languages and Kotlin was not
among them, while lizard 1.24.0 reads a .kt file without being asked to. This
is the Fortran stale-row problem exactly: selection is gated on that list, so
Kotlin would have come out not-applicable on every repository while the tool
that measures it sat installed in the pool.
Two test fixtures carried the same wrong claim in prose — that no tool in the analyzer catalog measures Kotlin's complexity, offered as the reason Kotlin was a durable stand-in for an unparsed language. It was never checked against the tool. Both fixtures now use Elixir, on the narrower and checkable ground that the roadmap lists it under languages no scanner is scheduled for.
Changed — Kotlin is disclosed as unanchored
Kotlin is parsed but absent from the 180-repository reference corpus, so every grade skin now discloses that a Kotlin repository's grade is provisional — the caveat COBOL already carries. The two are there for different reasons and with different ends: COBOL is excluded from the corpus permanently, and Kotlin anchors at the next recalibration. The scanner shipped ahead of the corpus, and saying so is the whole job of that disclosure.
3.7.30 - 2026-09-22
Documentation — the hostile-audit brief is recorded as an unfinished feature (D202)
D202 described the --hostile-prompt-output brief appearing in no user-facing
document and reaching no report field, and was filed Open pending a decision on
what it should become. That decision was made — it is an unfinished feature
rather than a defect — and the entry now records it as a decision closure with
its decider and date, rather than as a gap someone is meant to close.
Nothing about the emitter changed. It remains deterministic, seeded from a real run, and connected to no scoring path.
3.7.29 - 2026-09-22
Fixed — the labor-rate questions accept the rate you type (D213)
The three rates published options: [90], [140], [210] — a one-item
enumeration — and meant it as a type marker. The elicitation door reads a
non-string option list as "any number", so that path worked. A host reading the
published questions as data reads options as what the word says and offers the
single value, so entering a real rate meant contradicting the contract the tool
had just published.
The questions now carry type: "number", and default is a suggestion rather
than the only permitted answer. Both readers ask the question what it is instead
of inferring it from the shape of its options.
Menus are unchanged and still refuse what they do not offer.
3.7.28 - 2026-09-22
Fixed — C#'s foreach is counted as the loop it is (D212)
\bfor\b does not match foreach: the word boundary falls after for. C#'s
primary loop therefore cost nothing at all. Two methods doing the same work
scored foreach at complexity 1 and cognitive 0 against a counted for at 2
and 2 — the first contains a loop, and both metrics read it as straight-line
code.
If you audit C#, your complexity and cognitive numbers will rise, and the new numbers are the ones that count the loops that were always there. Any threshold tuned against the old readings may need revisiting.
This is the fourth time a language's own keyword was missing from its pattern —
Fortran's do, Swift's guard and repeat, PHP's elseif and foreach came
before it, each fixed in one language. The check is now over every parsed
language, and a language with no loop fixture fails rather than being skipped.
lizard, the independent implementation this project checks itself against,
does not count foreach. The divergence is declared with its reasoning: the C#
specification defines foreach (V v in x) by its expansion to a
while (e.MoveNext()) loop, so the condition is in the language's own
definition of the statement.
3.7.27 - 2026-09-21
Security — the audited tree cannot redirect a read out of itself (D211)
A configured path has always been bounded: resolved, refused if it lands outside the root, then walked component by component so an inward symlink cannot redirect it either. Three readers took their paths from the audited tree and never went through that boundary — practice detection, test-command detection and the generated-file banner. They were checked for what a path is, never for where it goes.
A repository whose pyproject.toml was a symlink to a file outside its root
scored practice level 2 with a linter-config signal, where the same tree with
an ordinary in-root file scored 1. The audited tree could raise its own reported
level using a file this tool was never pointed at, and practice level feeds the
pillar posture.
refuse_symlinked_route moved down to the module the reading primitive lives
in, and practice and test-command detection now read through a door that applies
both the containment check and the route walk. Either alone leaks: the route
catches an inward link that resolves back inside, the containment check catches
an absolute target the walk never meets.
The operator-named door is unchanged and still does not check: an operator names their own configuration and a symlinked config is an ordinary setup. What changed is the reads where the audited repository chose the path.
3.7.26 - 2026-09-21
Fixed — an unmeasured pillar reports nothing on either axis (D210)
Practice level is a fact about the repository, and the pillar section states it
once above the table. Every row then repeated that one number, including the
rows for pillars this tool does not measure — so the efficiency row read
practice 4 for a pillar ADR 007 declares out of scope, and an unmeasured
security row said the same, each beside a dash in the Condition column that said
it had no reading.
posture had been nulled for exactly this reason when an earlier version
printed "efficiency — healthy" from the practice axis alone; its input was left
behind. Both axes are now absent for a pillar with no reading of its own.
A delegated pillar that came back with a document keeps its number, and it is a
different one: secure-code-agent measures security practice and reports its
own level.
3.7.25 - 2026-09-21
Fixed — a clone names every copy, and is not told to be removed (D209)
A duplicate block is a statement about two or more locations. The work order
carries one path, so the item read "duplicated block in billing.py" with the
target "remove the duplicated block" — and an agent handed that deletes one
copy, which clears nothing and breaks the file it was deleted from.
Items for findings that occupy several places now carry all of them, and the target says what resolving it means: "extract the shared block so its 2 copies become one; editing one copy alone leaves the rest". Near-duplicates get the same treatment from the other side — their item never named what they nearly duplicate, while the near-duplicate section in the same prompt already did.
Both surfaces that print a location — the bounded prompt and the per-item copy-paste block that travels alone into an agent — read one shared helper, so one of them cannot be fixed without the other.
"Remove" is the verb D165 corrected for risk patterns, where it read as "delete the matched text" and cleared a finding without resolving it. Same verb, one class over.
3.7.24 - 2026-09-21
Changed — who gets handed what is its own module (D208)
No behaviour changed. _work_order.py had reached 745 lines against this
repository's own 750-line gate, and the next fix needed ten more. The whole
question of which work-order items an agent is handed — withheld_reason and
the two clauses it applies, the bounded paste, the conformance targets — moved
to _handoff. _work_order is 491 lines and decides what findings are worth
and how they are ordered.
The boundary was already written down and enforced by nobody: architecture.md
said presentation may import "_work_order's presentation-facing prompt
selection", naming a module that did not exist. It exists now, so the rule is a
module name that test_architecture holds rather than a sentence.
3.7.23 - 2026-09-21
Fixed — the prompt no longer lists what it just withheld (D207)
One prompt said two things. It printed "Not in scope for this change: N finding(s) need a design decision before code moves … Do not attempt them here", and that escalated findings were deliberately excluded — and then listed those same targets under "to inspect first", "to verify" and "to inspect".
withheld_reason is one rule with two clauses: a Major Project, and a finding
fixed before that came back. The focus lists honoured only the second, and only
on three of their seven categories — hotspots, large files and risk findings.
Duplicate blocks, near-duplicates, dead code and competing libraries were
filtered by nothing at all.
Every category now reads the same rule through the same locator, which also gained the competing-libraries shape so that finding class can be placed at all. An identified finding is matched by identity and an unidentified one by location, and the two are not interchangeable — a location is coarser than the identity it stands in for, and mixing them lets one class's withheld finding suppress another class's listed one.
A run that withholds nothing produces the same focus lists it always did.
3.7.22 - 2026-09-21
Fixed — a pillar's condition says what produced it (D206)
A pillar's condition is the mean of the aspects that carried a number, which is
the only honest arithmetic — a None is not a zero — and it means an
unmeasured aspect is dropped rather than counted. Maintainability's seven
aspects with one bad one average 4.43; withhold that one aspect and the same
pillar reads 5.0, in a row that still listed all seven and said nothing.
Withholding evidence improved a reported value, and the value named nothing
that produced it.
The overall grade was never exposed to this — it is banded from the evidence
floor, where unknowns price at 0 — and flooring a pillar's condition too was
rejected, because a delegated pillar's condition arrives already computed by
the tool that owns it and the two would stop being comparable in the same
column. So every pillar entry now carries condition_coverage and no skin
prints a partial condition without saying how many of how many aspects made it
and which are missing. "Could not measure" and "nothing to measure" are named
separately, because collapsing them is how an unknown reads as clean.
Added — score.not_applicable (D206)
Which Nones in the aspect layer are a resolved absence rather than missing
evidence. The scorer has always drawn that line for the grade and the report
kept only the consequence, so a consumer had to infer it by subtracting the
blocker text from the aspect layer. Additive and optional, like
analyzer_scored_dimensions: no schema version moves and nothing reading
version 2 breaks on it.
3.7.21 - 2026-09-20
Fixed — the written release process described the chore that had just been removed (D203)
3.7.17 reduced the version to one literal and 3.7.19 stamped the four documents
that state it. Neither touched the instructions. release.yml's header still
said to bump it in pyproject.toml, __init__.py and config.py, and
CONTRIBUTING.md still said "all three places" and named config.py VERSION —
so following the documented process reintroduced exactly the copies that were
removed, and the suite would then refuse the result. Both now describe the
process the gates actually enforce, including the stamping step, which is not
optional.
Fixed — twelve releases published notes that said nothing (D204)
The GitHub Release job reads its notes from this file's ## X.Y.Z section and
falls back to the sentence Release X.Y.Z. when there is none. This file
stopped at 3.7.7 and twelve tags went out after it, each with that placeholder.
The sections below are the backfill, and a test now fails when the shipping
version has no section — so the fallback stays for a re-pointed tag rather than
serving as the normal path.
Fixed — the version checks verified a proxy for what they claimed (D205)
Two checks tested something adjacent to their own claim. The release gate's
comment said a tag is checked against "the literal the wheel will actually
carry"; it read a file in the tree, which is the one thing that cannot say what
a built artifact carries — and the build step installed the wheel, printed its
version and compared it to nothing. The built wheel is now compared to the tag,
and the comment says what each check does. Separately,
test_only_one_module_writes_the_number searched for the current version's
characters, so a module left holding VERSION = "3.7.16" after the package
moved on was invisible — the check passed because the copy was stale. It now
matches the shape of a version assignment.
3.7.20 - 2026-09-20
Added — json is offered wherever a presentation is chosen (D201)
--format json had been accepted on both doors for eleven releases and offered
by no question. The terminal asked "chat, markdown, or html", the MCP
default_format question listed the same three from a hand-written copy of the
terminal's list, and the skill and server prompt were correct about a set that
was wrong. It is now the fourth option at every door, with chat still what
Enter selects; at a terminal it goes to stdout rather than writing a file. The
four presentations read one report dictionary and none computes a score.
The option list lives in one place now, and the MCP door's format validator reads it too, so what a surface offers and what a door accepts cannot diverge again.
Known — the hostile-audit brief reaches no report and no document (D202, open, Low)
--hostile-prompt-output and the maintainability-hostile-audit prompt emit a
run-derived brief for an adversarial review. It is in no README, neither
SKILL.md, and nowhere in the product intent, and whatever a reviewer concludes
from it reaches no report field and no history record. Nothing in it can affect
a grade — no code path connects the emitter to the scorer — so it is recorded
rather than held for. It may be an unfinished feature rather than a defect.
3.7.19 - 2026-09-20
Changed — pydocstyle's imperative-mood rule is off, with the reasoning recorded (D200)
D401 was 330 of 566 findings, each asking for """Returns the score.""" to
become """Return the score.""". The docstrings here are deliberately narrative
and most carry a defect's reasoning where the code that caused it lives;
complying would flatten the sentences this project explains itself with, and a
docstring's mood says nothing about whether code survives change. Turned off in
configuration with the reason beside it rather than as 330 expiring
suppressions. Every other rule stays on. Analyzer findings fell 832 to 502 and
the documentation aspect did not move.
Added — the release version is stamped into the documents that state it (D200)
Four documents have to name the version and each went stale once: the README's
version line, the README's GitHub Action pin (which is what a reader actually
installs), the release plan's row and the SECURITY.md support table.
tools/stamp_version.py writes all four from the single literal and exits
non-zero if any pattern stops matching, rather than stamping three and
reporting success — which is how v3.7.9 was tagged and lost its release. It
does not improve change_coupling and says so: those files still move together
because they must.
3.7.18 - 2026-09-20
Fixed — every skin says what examined test effectiveness (D199)
The bounded chat view reported test effectiveness: not measurable for a suite
that had run and failed, because it never rendered the suite at all — a value
with no attributable source, and a failed measurement misreported as an absent
one. Chat now renders the suite section the complete report and HTML already
carried.
3.7.17 - 2026-09-20
Changed — the version is written once (D198)
The version lived in five files, and this repository's own audit put
__init__.py and config.py at the top of its change-coupling table at 0.985
confidence. v3.7.9 had already been tagged and failed its release because four
of the five were updated and the fifth was not, and the sibling project had
shipped a wheel stamped with one version while every report it produced carried
another. maintainability_audit.__version__ holds the literal now,
config.VERSION re-exports it, and pyproject.toml reads it through
[tool.setuptools.dynamic].
The release gate had been reading pyproject's static version key, which this
change removed, so v3.7.17 failed eleven seconds in — the fix for a
release-breaking duplication broke a release, because the check protecting it
was reading one of the copies. The gate now reads the module that holds it.
3.7.16 - 2026-09-19
Fixed — the HTML report draws the security work order (D197)
A reader who chose HTML got the delegate's work order wrapped in <pre>: raw
Markdown inside a rendered page. secure-code-agent now emits the same work
order as data, and this tool draws it in the delegate's own tiers, counts and
caps. Requires secure-code-agent 0.12.8 or later.
3.7.15 - 2026-09-19
Fixed — the audit names the program it ran, not only the one configured (D196)
The same configured test command produced different results through the two doors: the CLI resolved the project's pytest and measured 96% coverage, the MCP server resolved another environment's and could not run the suite at all, and neither result said which program it had executed. The resolved path is now reported beside the configured command wherever the suite is shown.
3.7.14 - 2026-09-18
Fixed — a presentation override the user did not choose is disclosed (D194)
A per-call format silently overrode the presentation chosen during setup, so
an agent's preference and the user's own choice were indistinguishable in the
result. The override is now disclosed.
3.7.13 - 2026-09-18
Fixed — the data path is the same setup as elicitation (D193)
setup_answers was published but unfinished. Labor rates arrived as strings and
were never converted, so the economic bounds stayed pending forever; an illegal
value such as depth: "nuclear" persisted the default and reported success; and
three surfaces still told a host to "call again" without naming the parameter it
should call with. Submissions are now checked and converted against the
questions the host was actually offered.
3.7.12 - 2026-09-18
Fixed — the setup instruction describes the reply it ships with (D192)
The instruction announced that the repository had never been set up, above three
labor rates that exist only because it had been; recited the presentation
options when the payload contained no such question; and never named
setup_answers. It is derived from the questions in the reply now.
No tag was cut for this version.
3.7.11 - 2026-09-18
Fixed — both setup paths ask the same stage, and what setup writes validates (D190, D191)
Answering the first stage through the data path handed stage one back again, because the staging decision lived only in the elicitation schema. One stage decision now feeds both renderings.
Separately, the published config schema set additionalProperties: false and
described none of presentation, history, test_execution or
economic_context, so a completed first-run setup produced a file the tool's own
contract rejected. The gate is now the round trip: write with apply_answers,
validate what lands.
3.7.10 - 2026-09-18
Fixed — a question this agent asks is one a host can answer (D189)
The tool returned its seven setup questions, the user answered all seven, and
nothing was written: setup was an elicitation-resolved parameter absent from
the published schema, so a host that cannot be elicited had no argument to
answer with and the documented fallback could not close. A published
setup_answers carries them to the same writer, and the legal names derive from
the questions so an unanswerable question is not expressible.
This also closed the dead end where consent to run the test suite, with no command recorded, produced a refusal on one surface and silence on the other (D185).
3.7.9 - 2026-09-18
Added — the agent reports when it is running stale code (D188)
The MCP server answered a call with agent_version 3.7.7 hours after 3.7.8
shipped: the version was a source constant, so a stale process reports its own
staleness as fact and nothing can notice. The venv beneath it was an editable
install frozen at 1.8.0 carrying secure-code-agent 0.2.0 against a floor of
0.12.7, which would have refused the delegate and left the Security pillar
unmeasured. The agent now compares loaded code against installed metadata and
names which of the two is stale — a process that needs restarting, or an install
that needs reinstalling.
3.7.8 - 2026-09-18
Fixed — the security pillar asserts a scanner set, so it can be graded (D184)
The delegate config asserted no scanner set, so the security pillar reported
unverified on complete coverage and could not be graded at all.
gates.require_scanners now names the scanners the gate job installs.
Fixed — this tool's own capabilities are declared rather than scored against it (D186)
The security grade was held at B+ by 68 true, permanent reports about what this tool is: spawning analyzer subprocesses, reading analyzer XML, calling the SonarCloud API, seeding a calibration bootstrap. The delegate config now declares those four capabilities with a stated reason each, and the findings are reported on their own axes rather than scored, with the undeclared score disclosed beside the declared one.
Fixed — code scanning gets the filtered record, not the full one (D187)
Every required check passed and a merge was still refused, because the uploaded
SARIF was the full record and code scanning had opened a blocking review thread
for each of seventeen assert statements in the test files the branch added.
Code scanning now receives the filtered document secure-code-agent writes for
it, and the full record is kept as an artifact.
3.7.7 - 2026-09-16
Documentation and CI only; no product behaviour changes. Released so the package page carries the corrected README.
Added — the authorship check refuses an outside address or a forbidden term (D183)
Every pull request's commits must name only the repository's commit identity in
their messages, merges included, so a Co-authored-by: trailer for any other
address fails before it reaches history. A second step refuses terms kept in a
repository secret rather than in the tree, reading identity fields, messages
and every added line, and fails closed when the secret is unset. CI only; no
product behaviour changes.
Documentation — synced with the build
- The GitHub Action example pinned
v1.0.0; it pins the shipped version, and a test now holds it there the way the version line is held. - The report help page said eight languages are parsed; fourteen are. It also gains the security pillar, which it never described.
- The roadmap records 3.6.0 through 3.7.6 and no longer says the audit does not
run
secure-code-agent, which has been false since 3.7.0. - The release plan's measured counts and the self-audit record are regenerated.
3.7.6 - 2026-09-16
A documentation and CI release. No product behaviour changes.
Changed — the published repository and package describe the product, not how it is built
Source comments, test docstrings, the defect register, the changelog and the documentation no longer record which contributor found, wrote, tested or decided each change, and no longer quote anyone. Audits keep their commit and date, and every register entry keeps its defect, fix, closing test and mutation statement, so each is still traceable and still proven.
The source distribution on PyPI carries the test suite, so earlier releases include some of that text; this is the first release without it.
Changed — the authorship check verifies identity and signatures only
The pull-request workflow no longer requires a trailer naming the author of each
commit. It still requires the repository's commit identity and a signature from
a key on .github/allowed_signers, and a commit altered after signing still
fails. The local commit-msg hook that required the trailer is removed.
Removed
tools/prompts/v1, a set of internal prompt files nothing referenced.- Two generated self-audit snapshots under
docs/audits. maintainability-report.html, a generated report committed at the repository root in 0.9.0, now ignored.
3.7.5 - 2026-09-14
One fix. The security pillar now honours reviewed suppressions on any repository, not only this one. Scoring, ordering and policy are unchanged.
Fixed — this audit requires secure-code-agent 0.12.2, the first release that honours paths: suppressions (D182)
Before: the audit ran any secure-code-agent from 0.12.1 up. 0.12.1 matches .scignore.yaml
paths: globs against absolute finding paths, and this audit always passes it an absolute root. Every
reviewed paths: suppression was ignored, and its finding counted as live in the security pillar,
which every skin shows since D179. 3.7.4 worked around that for this repository alone, by rewriting its
own entry as file:.
Now: the supported range is >=0.12.2,<1. secure-code-agent fixed the defect in 0.12.2 (its D20):
finding paths are repository-relative, and its fingerprints no longer depend on where the checkout
lives. With 0.12.1 installed, the pillar states that the release is unsupported and the environment
work order gives the install command, the same as for any release outside the range.
- CI installs
secure-code-agent==0.12.2in all five places. README, CONTRIBUTING, the machine-setup page and ADR 007 give the new minimum. - This repository's
.scignore.yamlis back topaths:. Audited with the entry restored, 0.12.1 left all fourgitleaks.curl-auth-userhistory matches live and 0.12.2 suppressed all four.
If you run the audit yourself: pip install -U 'secure-code-agent>=0.12.2'. secure-code-agent's own
changelog covers what 0.12.2 changes for baselines. Existing ones keep matching.
3.7.4 - 2026-09-14
Three fixes from the audit of 3.7.3. Each one is a report that left out, or contradicted, what the same run had found. Scoring, ordering and policy are unchanged.
Fixed — the security pillar shows what secure-code-agent found, and its work order reaches you (D179)
An audit of this repository recorded 5 critical and 1 high security finding, and no surface mentioned them. The pillar row read "unverified: not graded — …". The remediation prompt that secure-code-agent wrote was deleted with the temporary directory it was written into.
- The pillar reading in the chat view, the Markdown and HTML reports and the remediation prompt now
states the producer's counts, such as
findings: 5 critical, 1 high, 4 medium, 69 low, whether the pillar was graded or not. - secure-code-agent's own work order is kept as
security_work_orderin the JSON report. It is printed whole in the complete Markdown and HTML reports, and returned at the top level of every chat-door result. The bounded chat view and the maintainability prompt say where it is and do not merge it: those findings are still that tool's work order, not this one's (ADR 007).
Fixed — a copy-paste prompt no longer authorises what the remediation prompt withholds (D180)
The demo's remediation prompt withheld two duplicated blocks as needing a design decision. Their
copy-paste blocks in the report still said "Task: remove the duplicated block." Every item the
prompt withholds — a Major Project, or a finding fixed before that came back — now gets a block
that asks for the design options and a stop, on the chat view and the Markdown and HTML reports.
examples/demo/expected-prompt.md is regenerated.
Fixed — semantic findings respect exclusions on a full scan (D181)
Excluding tests/fixtures/ removed the fixture from every count, but a full scan still reported
a design review candidate from it and named the file in the prompt. A semantic finding is now kept
only when its file is one the audit read, the same rule changed-only runs already followed (D174).
The semantic coverage counts are taken from the findings kept.
Repository — this project's own critical and high security findings are cleared
Once D179 made the counts visible, secure-code-agent's run on this repository showed 5 critical and 1 high. None was a leaked credential, and each is now fixed or acknowledged with a reason:
- The Sonar quality-gate step in CI sends its token in a
Bearerheader, notcurl -u "$SONAR_TOKEN:", which gitleaks reports as a hardcoded credential. - The four remaining gitleaks matches are in the git history of a workflow deleted in
a59c8e7, and each is a reference to a secret. The existing acknowledgement never applied: it matched by relative path, while gitleaks reports absolute ones. It now matches by file. - The manual Sonar-resolve workflow's Checkov CKV_GHA_7 finding is acknowledged with its reason: it builds nothing, and its inputs reach the script only through environment variables.
- The analyzer-drift test runs the workflow's shell helper under
bash, as Actions does, rather thanshell=True.
3.7.3 - 2026-09-13
3.7.2 was tagged and never published. Its release build tests the built wheel in a fresh environment, and that job never installed secure-code-agent, so the security pillar's tests read "not installed" and the build stopped before PyPI. 3.7.3 is 3.7.2 as intended: the same code, with the release job installing secure-code-agent 0.12.1 like every other job that runs the suite.
- A new test reads every workflow job that runs pytest and fails if it doesn't install secure-code-agent. The earlier check only looked at the pins that existed, and the release job had none.
- The unsupported-release test no longer depends on whether secure-code-agent happens to be installed where it runs.
Everything described under 3.7.2 below ships in this release.
3.7.2 - 2026-09-13 (tagged, not published)
Changed — secure-code-agent is installed alongside, not a dependency, and 0.12.1 is required
3.7.0 and 3.7.1 declared secure-code-agent>=0.10,<0.11 as a dependency, which excluded
the current release. The range was copied from this project's CI pin, not taken from
the tool's published releases, which were at 0.12.1. Installing 3.7.x on a machine that
had 0.12.1 downgraded it to 0.10.0. The dependency also put secure-code-agent's own
mcp<2 pin into the same environment as this package's chat door, which needs mcp>=2.
It is no longer a package dependency. The two tools are independently releasable
(secure-code-agent's D3, this project's ADR 008), so it is installed alongside, the way
the analyzer pool is: pip install 'secure-code-agent>=0.12.1'. Every audit still runs
it. The audit runs whichever release is installed if it falls within >=0.12.1,<1;
a missing or out-of-range release is the pillar's stated reason, with the install
command in the environment work order. CI installs 0.12.1, and a test keeps every CI pin
inside the supported range.
0.12.1 writes the same security-pillar.json contract (schema v2), and this release
reads it unchanged.
3.7.1 - 2026-09-13
Fixed — the chat door says what is installed when it cannot start (D178)
If maintainability-agent-mcp can't import mcp, the message now names the installed
version, the range this server needs, and any other package that requires mcp —
rather than "MCP support is not installed", which it said even when mcp was
installed at an incompatible version.
If you followed 3.7.0's setup page: installing secure-code-agent[required-scanners]
or [python-scanners] into this package's environment pulls in semgrep, which pins
mcp<2 and replaces mcp 2.x, and the chat door stops starting. Restore it with
pip install "mcp>=2,<3", remove the pip-installed semgrep, njsscan and checkov, and
install semgrep and checkov from Homebrew. The setup page now says so.
3.7.0 - 2026-09-13
Reported from the ChatGPT desktop app: MA ran, and the security pillar was not in the report.
Changed — every audit runs secure-code-agent for the security pillar (D177)
Minor rather than patch: an audit now executes a second tool, installs it as a
dependency, and can write one more file. Until now the security pillar was a
handoff. secure-code-agent wrote security-pillar.json and MA read it if present.
CI did that on every pull request; nothing else did, and the chat door never
could. So the pillar was measured in CI and nowhere a person actually asked.
secure-code-agentis now a dependency (>=0.10,<0.11), and every audit runs it through the interpreter that is auditing.- What it writes into your repository: its append-only trend,
.secure-code/history.jsonl, and nothing else. Every report it can produce is redirected to a temporary directory. The MCP door now declares six artifacts, not five. The history can't be switched off without handing secure-code-agent a configuration from outside the tree, which it would trust to name executables inside the tree, so it is declared, not suppressed. --security-pillar <path>still wins. CI passes it, so the tool runs once.- A
security-pillar.jsonsitting in the audited tree is no longer read on its own authority: a repository doesn't get to report its own security posture in place of a measurement. - When no measurement comes back, the pillar says why — not installed (with the install command in the environment work order), a rejected configuration, or a timeout.
What the pillar reads depends on the scanners secure-code-agent can find. With none
installed it runs its built-in rules and the pillar reads unverified: not graded —
scanner coverage is partial. secure-code-agent --preflight . lists what to
install.
Fixed — the chat view and the prompt show the pillars (D176)
The chat view printed nothing about security, measured or not, and neither did the remediation prompt, so a missing pillar looked exactly like a clean one. The chat view now carries the same pillar table as the complete report, and the prompt states the security pillar — who measured it and what it found, or why it wasn't measured.
An ungraded pillar is no longer described as "clean scan, but nothing prevents tomorrow's regression". It reads "not graded", with the reason.
3.6.3 - 2026-09-13
Fixed — a build no longer changes what an audit scores (D175)
Scores can move on a repository audited after a build, and move toward what the
same commit scores on a clean checkout. Running mvn test in jsoup put its test
resources in target/test-classes, and the next audit scored 29 more files — one of
them failing — as jsoup's own code. The same commit audited differently before and
after the build, and differently on a laptop than in CI.
Build output now leaves the scored population when the repository itself says it is
not source: a path git does not track and the repository's own .gitignore excludes.
It is counted in summary.generated_files, not silently dropped. Nothing is excluded
by directory name, per ADR 010. Tracked files, and untracked files no rule matches,
are scored as before, and a machine's global ignore file is never read.
Every file the scanner reads under a generated or vendored tree now leaves the score — its HTML, XML and JSON as well as its source. Before, only source-code files did.
3.6.2 - 2026-09-13
The hostile audit of 3.6.1 (77de21c). Four surfaces of one run told a
different story from the report they belong to. No scoring, ordering, band or
policy change.
Fixed — the pre-commit hook words a declaration the way the audit does (D171)
3.6.1 made a work item name the limit its kind is graded on, in the audit and the
mid-edit check. A commit blocked by the hook still read currently 2876 lines; a
class is graded on length alone where the audit read against the 300-line class
limit. The hook now grades and words with the same thresholds. This gap was noticed
while 3.6.1 was being made and not filed; the register records that.
Fixed — a withheld estimate is not attributed to anything (D172)
When no estimate was issued, the prompt still said "the maintainability estimate above uses the analyzer readings" (or "comes from the built-in detectors"). It now says no estimate was issued and that analyzer output is evidence about the code.
Fixed — "Nothing to do" means nothing to do in the prompt too (D173)
A run with no work order said Nothing to do in the chat report, while the remediation prompt — which a chat host treats as the work order — asked for "the smallest coherent patch" and listed warn-band hotspots the work order had refused. The prompt now carries the same sentence and no task, hotspot list or deliverable; the rules that bound an agent stay. A work order whose every item is withheld from the prompt (Major Projects, escalated findings) says so the same way.
Fixed — a changed-only run stays inside its change (D174)
--changed-only ran the semantic walk over the whole tree, so an audit of two
commits nominated a TypeScript fixture neither touched. Semantic findings are now
kept only where their source file is in the change. Full runs are unchanged.
3.6.1 - 2026-09-13
Found by running a Java repository's top work item through the chat door to verify that remediation works. Remediation worked; six things the report said about the work were not true, and one of them exposed an ordering that broke a rule the project already had. None changes scoring, bands or policy. D164–D169 correct what a report states; D170 makes the economic reorder obey ADR 007 §3, the only ordering change, and only with an economic context configured.
Fixed — a work item states its own finding, not its class's estimate of it (D164)
A declaration item now names the limit its kind is graded on. Before, a
2876-line Java class read currently 2876 lines, complexity 25 — a function's
reading against no limit — with Why: "a long, branching function … extracting
one is bounded, local work". It now reads currently 2876 lines against the
300-line class limit, and a function's item names lines, complexity and
cognitive cost each against its own limit.
The Why no longer sizes the change. "Bounded, local work" is the class's
judgment, published in standard.md beside its declared effort; printed on an
item it claimed something the audit never measured, and was false for a class
ten times its limit. unpaired-hotspot carried the same clause. The band is
unchanged and still declared per class — standard.md now says so — so a reader
sizing the work has the item's fact rather than the class's estimate.
Fixed — a risk-pattern item no longer asks for the match to be deleted (D165)
remove the configured risk pattern on a // todo read as "delete the comment",
which clears the finding and the score while the debt stays. The target now
names the rule and asks for what the match records: act on what the
debt-marker rule matched here, or say why it stays; deleting the matched text
alone hides the finding without resolving it.
The rationale said the rule was one "this project chose to enforce on itself", and the pre-commit hook said "this repository's own configuration asked". For a repository on the shipped defaults, neither is true. All three now attribute the rule to the active configuration.
Fixed — the verify command runs the interpreter that audited (D166)
Every item ended python -m maintainability_audit --root . --format json. Where
no python exists it did not run; where python3 resolved to an older install
it re-audited with a different release than the one that issued the item. The
command is now bound to sys.executable, as D144 bound the environment work
order's install commands. The demo's checked-in work order normalises the
interpreter to python3 the way it already normalised the root.
tools/regen_demo_prompt.py also isolates the user configuration tier now: it
had been reading the regenerating developer's own config, and a personal
economic context reordered the checked-in demo.
Fixed — the COBOL caveat appears only where COBOL was scanned (D167)
Every grade skin printed "COBOL is parsed but is not in the reference corpus,
so a grade reported for code in it is provisional" on every repository. The
caveat now follows the run's own language census, summary.languages. A tree
that contains COBOL still discloses on every skin, and a stored report without
a census keeps the full disclosure.
Fixed — one report, one count of the corpus's languages (D168)
The prompt said "eight of the ten parsed languages" while the corpus note on the same report said thirteen of fourteen. Both now count the two lists the reference block publishes.
Fixed — the work order's heading states the order it is in (D169)
With an economic context configured, the exposure sort (ADR 004) replaces the band order, but the heading still said "ordered by what it costs to leave against what it costs to fix" and the prompt called its first items "the highest value for the least change". The table and the prompt led with different items and neither said why. The heading now names the sort that produced the list, and the prompt describes its own rule — severe findings first, one item per kind.
Fixed — the economic reorder no longer lifts a Fill-In above a Quick Win (D170)
The one ordering change in this release, and only with an economic context configured. The exposure sort (ADR 004) ranked the whole work order by recurrence and churn, so a Fill-In on a file that changes often could lead a Quick Win on a stable one — the nit-loop ADR 007 §3 rule 5 exists to prevent. Band is now the primary key and exposure orders items within each band. Without an economic context nothing moves. ADR 004 is amended to say so.
3.6.0 - 2026-09-12
A value-audit rerun on 3.5.0, which asked whether the first five minutes that release advertised actually work. Two of them did not.
Changed — the published starter config opens every language, not a web subset (D162)
Minor rather than patch, because the scanned population grows. A repository
audited with maintainability-audit.example.json will now read files it
previously skipped, so findings appear and a reported score can move. Nothing
about the config format changed.
That file is what the README tells a stranger to curl and pass as --config.
Its paths.include_extensions listed 10 suffixes — Python, the JS/TS family,
HTML, CSS, Markdown. The shipped default opens 48. A config is a
replacement, not a merge, so following the README on a Go, Java, Rust, Ruby,
PHP, Swift, C#, C/C++, Fortran or COBOL tree scanned the README and little else.
Measured on a one-function Go tree, following the README command exactly:
| before | after | |
|---|---|---|
| files scanned | 1 (README.md) |
2 |
.go |
Source Not Read | applyPricing, complexity 31 |
| work order | absent | one quick-win |
The report named the miss — | .go | Go | 1 | — and told the reader to add the
extension. The file they were told to download is what removed it.
The list is no longer hand-written. It is the shipped default's own set, so a
language added to DECLARATION_SUFFIXES reaches the advertised config in the
same change rather than whenever somebody remembers this file.
Changed — --prompt-output leads with the work order and issues no rates when unscored (D163)
The file the README calls the work order was a score sheet. Two halves, one entry.
render_ai_prompt opened with a preamble, seven rules and a nine-line audit
summary carrying the estimate and the letter grade. The first thing to do
arrived after all of it. That is the defect 3.5.0 fixed on the chat surface
(D160), still standing on the file the CLI path hands a stranger. The work order
now follows the rules that bound it, and everything describing the run rather
than the task follows the task.
And it printed corpus multiples on a run whose overall the scorer had
withheld. A multiple is a rate against a calibrated median — it is a score —
so P7 governs it. On examples/demo the report said Not scored and the demo's
own README said "only the rates are withheld", while the prompt said
declarations at 3.9x and Start with declarations. The complete Markdown
report on the same run printed no multiples. Only the advertised product file
did.
It now prints none either. The grade blockers still render, because those say why no number was issued, which is what that reader needs.
The work order itself is unchanged and still names every finding. A finding is an observation, not a rate, and withholding those would leave an undersized repository with nothing at all.
3.5.0 - 2026-09-11
The first five minutes, rebuilt: - pipes the prompt into an agent, the chat
reply opens with the work order instead of a metric table, and there is a demo
tree that produces a real one. From an audit of what a stranger sees after
cloning.
Added — - writes to stdout on every rendered output (D161)
Minor rather than patch, because a flag gains a meaning it never had.
- is the POSIX spelling for stdout. Every rendered output went straight to
Path(name), so - was a filename:
maintainability-agent --config c.json --prompt-output - | claude
gave an empty pipe, a report on stdout nobody asked for, and the prompt on disk under a name a shell fights you over. Nothing errored.
That command shape is the five-minute path the audit recommends, and the flag
it recommends did not work. All seven rendered outputs — --output,
--prompt-output, --comment-output, --agent-instructions-output,
--attestation-output, --hostile-prompt-output, --sarif-output — now stream
through one seam, so a future output cannot be added with the old behaviour by
accident.
The obvious fix introduces a second defect, handled here too. With no
--output the report prints to stdout, so sending the prompt there as well
hands a reader a prompt with a full report stapled to its front — worse than the
file, because it looks like it worked. A secondary output claiming stdout now
suppresses the implicit report; an explicit --output - still prints it.
--write-baseline and --html-output refuse - by name and with a reason:
a baseline is read back by a later run, an HTML report is opened rather than
piped. Refusing is the point — an output that accepts - and does something
else is the defect being fixed.
Changed — the chat surface opens with the work order, not the grade (D160)
The behaviour that moves is what a chat reply shows first. No data changed and no format was removed; the bounded skin's sections are reordered.
The MCP chat format — the surface most people drive this tool through — built
## Summary into the shared prelude, so every reply opened with four metadata
lines and a fourteen-row metric table before anything said there was work to do.
Measured on a tree with three fat modules:
| before | after | |
|---|---|---|
first line of ## Work Order |
29 | 8 |
| first pasteable prompt | 51 | 36 |
| lines before the letter grade | 8 | 60 |
Hard gates still lead — a failed gate changes which item you start with. The summary, the capped-grade reasons and the score tables follow the work order as the evidence that aimed it. The complete report file is unchanged: it is read top to bottom and correctly opens with what it is.
A second half, found while measuring the first: a run with an empty backlog rendered no work-order section at all. This repository is that run — 511 files, 74 function warnings, and a chat view of 73 lines that never once mentioned a work order. A reader could not tell nothing met the bands from this tool does not produce work orders. It now says which.
Added — a demo tree that produces a real work order
examples/demo is a two-module order system with problems a reviewer would
raise: a 72-line pricing function at complexity 27, the only money path with no
test, and a pricing block duplicated between billing and invoicing so the
invoice and the charge can disagree. Four items, three finding classes, a
copy-paste prompt each, and it reads in under a minute.
The score is deliberately absent and the demo's README explains why: two files is below the calibration floor of 24, so no rate is issued. That is the posture in one output — the work order is first class and the score is second.
expected-prompt.md is checked in and a test regenerates and diffs it, so a
renderer change shows its blast radius on real output rather than on a fixture
nobody reads.
Added — the README names the analyzer pool, and says what you get without it
A first run with no analyzer installed falls back to the built-in tier, and the install path never said so — so the fallback read as the product rather than as a degraded reading of it. That is the commonest reason a first run looks thin.
The stanza names the exact set CI installs and calibrates against, and
test_the_readme_names_the_analyzer_pool_ci_installs derives the expected set
from the catalog and fails if the README drifts from it.
Fixed — the trend break named the wrong thing
The break reason read "a delegated pillar's producer or its version changed",
while the key for a schema-2 document is scoring_model and the version is only
the v1 fallback. A reader whose series broke went looking for a release change
that in the common case had not happened.
Confirmed against the delegate rather than assumed: secure-code-agent 0.11.0 is
a docs-only release with its scoring model unchanged, and it correctly does not
break the series.
Changed — a register citation naming a test that does not exist is refused
prove_falsifiers already refused an entry where no citation resolved. It
had nothing to say when some did, so an entry citing three tests and
defining one proved the one and dropped the other two in silence.
That is D147's exact shape: the entry closed on opted_in_command, the citation
read as evidence, and the function had never been written. The sibling citation
that does resolve is what hides the fiction. Every cited name must now resolve,
and the refusal names which did not.
Documentation
- The roadmap states the one public bet — clone-to-work-order — at the top, rather than leaving a reader to infer it from what is missing.
- The CHANGELOG opens with how to read it: these entries are a track record, and anyone wanting the short version is pointed at the README and the roadmap.
3.4.0 - 2026-09-11
Config excludes that held a glob start working, so a scanned population can shrink and a score can move; the delegated security pillar reports the delegate's scoring model; scan trends now break when a delegated producer changes.
Fixed — a directory exclude holding a glob matched nothing (D156)
Minor rather than patch, because an inert rule becomes live. A configured exclude that silently did nothing now excludes what it names, so the scanned population shrinks and a reported score can move. Nothing about the config format changed — the patterns simply start meaning what they always said.
Two forms were dead:
**/dir/, the gitignore spelling for "this directory wherever it appears", matched nothing at any depth — including the depth it names;- any directory pattern holding a glob, so
*.egg-info/excluded nothing while an.egg-infodirectory sat in the tree being scanned.
Three branches and both fell through all of them. The literal branch
cannot see a *. fnmatch against the whole path wants a trailing
slash no file path has. The containment branch — the one that makes a
bare dir/ work at any depth — is skipped because the pattern holds
glob characters.
So the two spellings of one intent differed by everything: dir/
excluded at every depth, **/dir/ excluded nothing. A config mixing the
forms had half its rules working, and file patterns like **/*.min.js
were unaffected, which made the failure harder to see rather than
easier.
An inert exclude has no symptom of its own. Nothing errors, nothing is reported, and the only evidence is findings in files the operator believed were excluded — which reads as the audit being noisy rather than the config being dead.
If your config uses either form, expect fewer files scanned after this upgrade, and a score computed over that smaller population. That is the rule doing what it was written to do.
Changed — the delegated security pillar is read from secure-code-agent 0.10.0
The pinned version moves to 0.10.0, the first release emitting the v2
pillar contract. MA reads v1 and v2, keying a delegated trend on the
producer's scoring_model where the document declares one and on its
version where it does not.
0.10.0 carries a scoring change on the producer's side, so a delegated security trend breaks once at that boundary. That break is correct: the same repository scores differently under the new model, and joining across it would draw a line through a change in the instrument.
3.3.0 - 2026-09-11
Changed — a repository's documented test command no longer runs (D152)
Minor rather than patch, because a config key stops taking effect. If
a repository's maintainability-agent.json documents
expected_commands.test, that command is no longer what this tool runs
when the suite is opted in. The command now comes from the person's
tier — the one they were shown and accepted at setup.
D147 closed the half it named:
load_config strips test_execution.requested from the repository
tier, so a tree cannot opt the host in. It never stopped the tree
choosing what runs.
expected_commands.test is deliberately not stripped — it is
documentation, and require_test_command reads it, so removing it would
fail a real gate on every repository that documents its command
honestly. The comment guarding that decision ended: "their answer is
what opted_in_command reads."
opted_in_command did not exist. Three docstrings named it as the
user-tier reader that made keeping the key safe, and nothing
implemented it. run_test_suite read the merged config, where the
repository's document lands by design.
So a person consented to pytest -q, having been shown pytest -q, and
the host ran whatever the tree documented instead. That is worse than an
unclosed opt-in, because the audit trail says the person agreed.
test_execution.timeout_seconds took the same path unclamped: a
documented 2147483647 made the host wait sixty-eight years for a child
it was told to run. It now passes the same bound
analyzers.timeout_seconds already had.
A repository that documents a command still satisfies
require_test_command. It simply no longer chooses the program.
Added — --security-pillar, and the delegated pillar that now runs
ADR 007 delegates the security
pillar: this tool reports what secure-code-agent measured rather than
measuring it. The document is read from
.maintainability/security-pillar.json, and that path was hardcoded —
while secure-code-agent's own help said the file is "for
maintainability-agent to ingest via --security-pillar", describing a
flag that did not exist.
It exists now, and takes a path. The default is unchanged, so nothing moves for anyone not passing it.
Fixed — a delegated pillar's trend joined across the producer's scoring change (D155)
A delegated pillar's condition is stored in scan history like any other,
and the comparability gate could not see when the tool that produced it
changed its scoring. secure-code-agent altered its normalizer twice in
one day without touching the document's schema: same shape, same fields,
a different number for the same repository. The trend would have drawn
one continuous line through it.
Scan records now carry delegated_producers — security:secure-code-agent 0.9.0
per delegated pillar — and it is a comparability field, so a producer or
version change breaks the series and names itself.
History written before this release has no producer recorded, so the first scan after upgrading opens a new series. That break is real: the scans before it cannot be told apart from ones a different producer version made.
The trend keys on the producer's scoring model where its document
declares one, and on its version where it does not. Keying on version
alone over-breaks — a docs release of the delegate would reset the trend
having changed no score — so the pillar contract gained a scoring_model
field, agreed between the two tools before either released.
MA reads schema v1 and v2. v2 is v1 plus that field, so either tool can release first and there is no window in which documents are refused.
A v1 document is never collapsed onto a single model id: the delegate's releases before the field existed do not share one scoring model, so they keep the version as their key and fragment correctly.
Fixed — a config path could be reached through a symlink (D153)
authorize_config on the MCP seam checked that a caller's
config_path resolves inside the repository. That catches a config
outside the tree. It cannot catch an inward link —
root/link -> root/src — which resolves to a path inside the repository
and passes every containment check there is.
D34's rule is two halves: resolve and bound, then walk the route as
written, because an inward link shows only on the lexical path.
repository_path did both. This door did the first, so the two
disagreed on the same tree — one refused, the other returned the target.
The audited tree supplies the directory the link lives in.
Added — reports disclose that analyzer children are not isolated (D154)
Product intent has said since P1 was amended that this package "does not sandbox children", and lists "that third-party tools cannot use the network" among the things it does not promise. The scoping is correct and deliberate. The report never said it.
A reader holding an audit whose promise reads "the analysis itself performs no network access" and a coverage table naming the external tools that ran had nothing connecting the two. The coverage section now carries the note, once, when children actually ran.
P8's gap rather than P1's — an undisclosed promise, not a broken one.
Fixed — the HTML report carried none of the coverage gap notes
"One source only", "nothing examined", and a dimension the analyzer tier declined are the coverage section's prose about its own gaps. The HTML skin printed the table and none of the notes — including "Nothing examined", which the module itself calls the point of the whole section.
ADR 011 says the skins render one report dict and never disagree. That claim was resting on the prose being built as Markdown strings inside the Markdown renderer.
Fixed — the MCP presentation contract described itself wrongly
audit_repository's documentation said chat and markdown are "on
the wire the two are the same text" while the code beside it already
rendered chat as the bounded skin and markdown as the complete
report. The behaviour was right; the sentence was not, and a reader who
believed it would have taken the bounding for a rendering accident
rather than the contract.
3.2.0 - 2026-09-10
Added — the security pillar reports what secure-code-agent measured
ADR 007's fifth pillar was never MA's to measure. It is now delegated
rather than absent: if .maintainability/security-pillar.json exists and
carries the secure-code-agent/security-pillar v1 schema, the pillar
table names the producer, its practice level and its condition, and both
the chat and HTML reports render it alongside the four MA computes.
The file is treated as a boundary, not as trusted input. A missing file, a directory, unparseable JSON, a non-object, an unknown schema, a mistyped field, or a posture that disagrees with the level and condition it was derived from all resolve to "no delegated pillar" — never to a partial one. The two axes are still never averaged.
No delegate, no change: a repository without the file reports exactly what it reported before.
Changed — the trend section reports the current series, not all of them
A repository that has been audited across many releases accumulates comparable segments: the series breaks whenever the instrument changed, which is correct and is why a spliced trend line is never drawn. This repository's own history reached 49 of them, and all 49 were rendered in full, in both skins — a section nobody reads.
The series in force is now given in full and the earlier ones become one table, newest first. Both skins, identically.
Two things that section said are also fixed. It rendered "1 scans"
for every single-scan series. And it named the changed field as
rubric_version, which holds the package version — so a reader was
told the rubric moved when a patch release was all that happened, on
38 of this repository's 49 breaks. Break reasons now read in prose:
"the tool version changed", "the configured thresholds changed".
The stored field name is unchanged, so no history migrates.
Added — ADR 014 settles the adversarial-properties scope question
ADR 013 deferred the detection
half of that play to its own ADR because it brushes the
secure-code-agent boundary. ADR 014
decides it: MA takes the evidence-integrity half — code that reads an
absent, empty or failed measurement as a passing result — and leaves
every exploitability class to the delegate. The boundary is a rule, not
a list, and it is directional: where a finding is genuinely both, MA
yields.
A scope decision only. Nothing is built, nothing gates, nothing scores, and implementation is gated on a precision bar frozen before any result exists.
Fixed — pairing read filenames, so 52 tested modules were called untested (D149)
describe_tdd paired a production module only when some test file's
subject stem matched it — test_scoring.py covers scoring.py. That is
one convention stated as if it were the only one, and every suite
organised by behaviour was reported as having untested production code.
The tool did it to itself. The 3.1.0 self-audit raised 58
unpaired-hotspot findings against 146 production files; 52 of those
modules were imported by at least one test, under a suite at 94.46%
coverage with a single module below 82%.
Pairing now accepts a test that names or imports a unit. Python reads the imports from the AST rather than a regex; the regex path is retained for JS/TS, Java and Fortran.
Reported numbers move. A repository whose suite is behaviour-named
will see unpaired-hotspot findings drop and its testability aspect
rise. Nothing about the repository changed — the previous reading was
wrong.
Changed — an interactive terminal asks all seven setup questions (D150)
docs/cli.md promised an interactive first run on a TTY is "the same
setup questions as chat/MCP". It asked five of seven. default_format is
legitimately absent (ADR 011 §3 asks it per invocation); economics was
simply missing, and nothing was asked in its place, so a terminal user
was never offered the economic scenario and got reports without money in
them having declined nothing.
Minor rather than patch, because first-run behaviour changes. A terminal user who has not yet configured a repository is now asked one more question than before, and answering it changes what their reports contain.
Changed — a release build refuses to run while the ledger has an open entry (D151)
v3.1.0 was tagged at 17:13 with a known defect that was not filed until
20:16 — a violation of the project's own standing rule, caught by nothing
because nothing checked. The release workflow now greps the defect
register for ### D<n> — Open and fails before the build if it finds
one. The gate is unconditional: it runs on every trigger, not only on
tag pushes, so it cannot pass by never having run.
Fixed — four sentences said "COBOL are parsed" (D148)
Sentences built around a count agreed with the wrong noun when the count
was one. A small _grammar module now supplies the verb, pronoun and
possessive for a quantity, and the four callers take agreement from it
rather than hard-coding the plural.
Fixed — the security job in this repository's own CI ran without its config
quality-gates.yml invoked secure-code-agent with no --config, so
secure-code-agent.json sat in the tree unread and the job scanned under
defaults. Repository hygiene rather than a shipped defect, recorded here
because the audit that found it is the same one that produced the pillar
integration above.
3.1.0 - 2026-09-09
Changed — a repository can no longer opt the host into running its own code
Minor rather than patch, because a config key stops taking effect. If a
repository's maintainability-agent.json sets
test_execution.requested: true, its suite will no longer run. The opt-in
now comes from the user tier alone. A person who answered the setup
question is unaffected; a repository that answered it for them is ignored.
Decision 9 says this agent does not execute the
audited tree's code, amended by Class 5 to "unless the operator opted in
and configured a command". suite_opted_in read both halves of that
opt-in from the merged config, where a repository beats a person by
design — so four lines in a pull request opted the host in and chose the
program, with no reply from anybody:
"test_execution": {"requested": true},
"expected_commands": {"test": ["…"]}
That is D35's trust inversion on the one seam that runs an arbitrary command rather than installing a package, so it is the same defect with a shorter path to execution (D147).
load_config now strips test_execution.requested out of the repository
tier before the merge. The check stays where it was, so suite_opted_in
and run_test_suite remain pure functions of the config they are handed —
what a caller, a test and a reader all expect — and the tier question
lives in the one function whose job is deciding which tier wins.
expected_commands.test is deliberately not stripped. That key is
documentation — "this is how you test me" — and the require_test_command
hard gate reads it to require that a repository declares one. Removing it
would fail that gate on every repository doing the right thing, to stop a
threat the key does not carry: a command nobody opted in to running is
inert. Authority is the opt-in; the command is documentation.
Migration: if you relied on a repository config to enable suite
execution, run setup once (action: "reconfigure", or an interactive CLI
run) and answer the test-execution question. Nothing else changes.
Fixed — two staged setup replies copied the audited tree into the user tier
_apply_bounds and _apply_command handed write_user_answers the
repository's own document. So a repository declaring
analyzers.acquire_tools, plus any stage-two reply — the labor rates,
or the test command — copied that grant into the user tier, and
acquisition_permitted read its own tier and answered true. The check was
never wrong; it had been told the tree's answers were the person's.
Not a missed validation. Both writers built one dict for the repository
write and reused it for the user write, and the tier boundary was carried
by a variable name. The user tier is now built from the user tier, the
way _persist_answers already built it from the setup payload (D147).
Fixed — repository-controlled reads bypassed the regular-file door
Nine readers took a path the audited tree chooses and read it by name:
every package.json under the root, .gitmodules, every CI and toolchain
file, every discovered file's banner, the checked-in TypeScript analysis,
every TypeScript source, every manifest, coverage.xml, and the whole
unread_source walk.
read_text on a FIFO waits for a writer that never comes, so a
package.json named as a pipe stopped an audit dead — and no
except OSError catches that, because there is no error to catch, only a
process that does not return.
The quieter half is D141's other side: unread_source and
recorded_type_analysis tested is_file() first, which is False for a
FIFO, and read the path as absent. unread_source is the worst of the
set because it reports what share of the repository the score describes —
a source-suffixed FIFO went missing from both sides of that fraction, so
the share read exactly as it would if the file had never existed (D145).
Fixed — --check read a plus-only diff fragment as file content
An agent copies the green lines out of a review pane and pipes them in. No
@@, no ---/+++, so every shape check passed it through, the brace
scanners found nothing in +function hello() {, and declarations_read
came back true on all 33 declaration suffixes.
D133 closed this for a whole diff and D138 for a hunk-only paste, each
written from the example reported. The rule is now stated instead: a body
whose every non-blank line begins with + or - is a diff fragment. The
mention-versus-assertion guard survives by construction — somebody writing
about a diff writes something around the quoted line, and that line does
not begin with a mark. Context lines carry a space, so a pasted excerpt
still parses (D146).
Fixed — the unanchored caveat named five anchored languages
UNANCHORED_LANGUAGES still read ("Swift", "COBOL", "Go", "Rust", "PHP",
"Ruby") two releases after 3.0.0 anchored five of the six. Every skin that
prints a grade reads it, so Swift, Go, Rust, PHP and Ruby readers were told
their grade was provisional against a corpus that had measured 14, 14, 14,
14 and 12 repositories of their language. A caveat naming an anchored
language is not a caution; it is a false statement about the evidence
(D143).
roadmap.md and both copies of standard.md carried the same claim in
prose nothing checks, and are corrected with it.
Fixed — environment remedies addressed whichever pip PATH found
environment_work_order emitted pip install lizard and
lizard --version. Neither names the interpreter that just reported the
tool missing. This is 3.0.2's D142 seen from the other side: on the machine
where that was found, the work order said pip install lizard to someone
who had run exactly that. Pip-backed adapters now emit
{sys.executable} -m pip install <dist> and verify with pip show (D144).
Changed — this repository's own security gate can now fail
Audit (maintainability + security) is a required check here and its
security half could never trip: --fail-on-gate was passed with no
secure-code-agent.json in the tree, so no gate existed. Measured on a
three-line target with subprocess.run(cmd, shell=True) — two HIGH
findings, grade F, gate PASS, exit 0.
Now configured rather than dropped, and proven both ways: exit 0 clean,
exit 1 with a planted sink. The five outstanding criticals were all one
false positive (curl -u "$SONAR_TOKEN:" is a variable, not a literal),
acknowledged in .scignore.yaml with a reason and an expiry rather than
silenced by lowering a threshold. secure-code-agent is pinned: unpinned,
it changed the meaning of a green build between two runs four hours apart.
This is CI configuration for this repository, not product integration.
The security pillar remains DELEGATED and this tool still never runs a
security analyzer.
3.0.2 - 2026-09-09
Fixed — the analyzer pool was invisible to any system-Python install
A silently degraded audit scored better than a correct one. Found by
running this agent against its own repository through the MCP server, the
surface product intent calls primary. Nine
analyzers reported "not installed or not on PATH" — the whole Python pool,
lizard and complexipy among them — so declarations fell to the
built-in tier.
Every one of them was installed. locate searched the interpreter's own
bin and then PATH, on the assumption that "Python analyzers install
alongside the agent". That holds in a virtualenv. On a system Python it
does not: the framework directory is not writable, pip falls back to
--user, and the console script lands in the per-user scripts directory —
on macOS ~/Library/Python/3.11/bin, which is on neither list and not on
the default PATH.
So the environment work order told the operator to run pip install
lizard, they had already done exactly that, and the next audit reported it
missing again. The remedy the product prescribes could not fix the
problem the product reports — for any operator on a system Python, which
is the install path the README itself offers first.
Measured on this repository, same commit and config:
| pool invisible | pool visible | |
|---|---|---|
| tools contributed | 0 of 13 | 9 of 13 |
| verified grade | B | C |
The estimate did not move; the grade did, and downward. A degradation that flatters the score is the worst direction for a silent failure, because nothing in the result asks to be looked at — the gate passed, the letter improved, and the only signal was a work order whose instruction had already been followed.
Both script directories are now derived from the running interpreter:
sys.executable's parent, then sysconfig.get_path("scripts", ...) under
the preferred user scheme. Neither is hardcoded, so this is not a macOS
patch. PATH remains the last resort, so tools installed by homebrew or a
system package manager — jscpd and pmd here — still resolve.
Filed as D142 with a falsifier and four guards written from the opposite direction, because the dangerous fix is one that widens the search until something is always found and names a tool that cannot run.
A note for MCP users: the server is a long-lived process and holds the module it imported at start-up. After upgrading, restart it — reloading the editor window is enough — or it will keep reporting the old result.
3.0.1 - 2026-09-08
Fixed — the post-2.11.0 audit, closed in full
Four defects. No scoring constants move, so no grade changes; one label in every report does.
A hunk-only diff read as clean (D138, High). --check refused a
complete diff -u and nothing else, so the fragment an agent actually
pastes — the hunk alone, because that is what a chat window shows — read
as file content and every one of the 46 parsed suffixes reported
declarations_read: true on it. D133 closed "a piped diff" on a detector
that required the ---/+++ headers too. Either half of the shape now
refuses. A hunk header quoted inside a string still parses: the check is
anchored at line start, so writing about a diff is unaffected.
A published declarations rate mixed two counting conventions silently
(D140, High). 3.0.0 completes an analyzer criterion set from the
built-in tier per declaration, and published the result as an analyzer
reading with no provenance. lizard reports cognitive complexity for no
language at all, so on thirteen of the fourteen parsed languages that
rate is two conventions wearing one label. Reports now say
declarations (completed by built-in detectors). This changes the
"Estimate source" line in every report where the fill applies — the
number is unchanged, the attribution is not.
Scan reads hung on a FIFO, and skipped it silently (D141, Medium).
The regular-file guard operator-named reads use is now shared with scan
reads rather than reimplemented. That alone changed nothing, because the
FIFO never reached a read: the walk tested is_file() first, which is
false for a FIFO, so src/hang.py was dropped and the audit measured one
file short without saying so. It refuses now, rather than hanging or
quietly under-reporting.
The language-support lead out-claimed its own verification (D139,
Medium). The page said every language's branch set is derived from its
own grammar. Only Python's is — ast implements the grammar and CI
enumerates from it. D129 corrected the verification section seventy lines
down, where a reader of the table never reaches it.
3.0.0 - 2026-09-08
Changed — the recalibration, and the first release that re-grades
Every published grade may move. 33 of the 112 repositories present in both the 2.x and 3.0 corpora change grade — 15 B→C, 7 C→B, 5 A→B, 4 C→D and 2 B→A — and the corpus gains its first D grades. A 2.11.x re-measure taken the day before moved nothing at all and said so; this one moves a third of them, which is the difference between a patch and a major.
| Dimension | 2.x | 3.0 |
|---|---|---|
file_size |
0.0952 | 0.0938 |
declarations |
0.0908 | 0.0962 |
duplication |
0.3222 | 0.3144 |
risk |
0.0826 | 0.0341 |
gates |
0.05 | 0.05 |
CALIBRATION_C |
8.7161 | 4.2565 |
The corpus median still rolls up to exactly 4.0000, which is the anchor's only job and the one number that must not move.
Three changes land together because each moves scanner_fingerprint and
invalidates every stored measurement. Paying for them separately would
mean measuring the corpus three times for an anchor obsolete at the next
change.
The corpus grew from 112 repositories to 180, and from eight anchored languages to thirteen. Swift, Go, Rust, PHP and Ruby are anchored at fourteen repositories each, twelve for Ruby. The 112 existing rows keep the commits their measurements were taken at, so the move is attributable to what was added.
COBOL is not anchored, and will not be. Measured against the corpus
criteria, GitHub holds one COBOL repository above 500 stars, and what is
there is not COBOL: the most-starred is a programming course, proleap,
che4z, koopa and opensourcecobol4j are Java projects that parse or
compile COBOL, and node-cobol is a JavaScript wrapper. The one genuine
COBOL codebase has 38 stars. Its real corpus is on private mainframes.
COBOL stays parsed and disclosed as unanchored rather than anchored on
teaching material.
The analyzer tier now drives declarations almost everywhere. No
single tier measures the full criterion set: lizard reads sixteen
languages for cyclomatic complexity and declaration lines and none for
cognitive complexity, while the built-in scanner reads all three for
fourteen. Completing one from the other per declaration, joined on
(path, start_line), takes analyzer-driven declarations from 6 rows of
180 to 173. Where lizard reads the language its readings now drive
the score, and the hand-written keyword sets supply only the concept no
permissively-licensed tool measures.
risk more than halved, and the languages explain it. Risk patterns
are configured policy and the defaults were written against Python and C.
As a multiple of the new reference the added languages read Swift 0.00×,
PHP 0.01×, Ruby 0.04×, Go 0.10× and Rust 0.26×, against C 5.82×, C++
4.95× and Python 4.24×. The old reference was measuring the pattern set
rather than the code.
Also in this release
- Population floors move to the new corpus minima — 24 source files and 139 declarations, from 32 and 139. The added languages brought smaller repositories, and a floor above the corpus minimum is a repository the scale cannot score while claiming to be calibrated by it.
- The shipped corpus note said 112 repositories across eight of ten parsed languages. It says 180 across thirteen of fourteen.
- D137, and the two defects found shipping its follow-up, are in the register.
Changed — the reference corpus was re-measured, and the anchor held
No behaviour changes and no constant changes. The measurements file is
repository evidence under tools/ and is not packaged, so no published
number moves.
The stored corpus had gone stale without anyone noticing: its rows were
measured at tool versions 1.10.1 and 2.0.0 under scanner fingerprint
3b5e063e95316342, while HEAD is 2.11.2 under 1a902618a0600746 —
eleven minor releases later, including four new language scanners and the
D112–D136 fixes. All 112 repositories were re-measured --with-analyzers
at the same pinned commits, so every move is attributable to the scanners
and to nothing else.
92 of 112 declaration readings moved and the anchor did not.
| stored | measured | |
|---|---|---|
| file_size | 0.0952 | 0.0952 |
| declarations | 0.0908 | 0.0903 |
| duplication | 0.3222 | 0.3222 |
| risk | 0.0826 | 0.0811 |
| gates | 0.05 | 0.05 |
| CALIBRATION_C | 8.7161 | 8.7161 |
CALIBRATION_C re-bisected to the same value to four decimals. The two
references that moved did so by 0.55% and 1.8%, against the 10% the suite
tolerates, so they are left as they stand rather than edited by a margin
smaller than the sampling error in them. No repository changes grade —
A=5, B=52, C=55, median exactly 4.0000, before and after; two repositories
move at the published decimal, by 0.1 each.
This is the first of the recalibration's four steps, and it falsified the reason the roadmap gave for shipping that work as a major release: that it "re-grades every repository, which is a breaking change to every published number". For scanner drift that is now measured and false. The prediction has been struck where it appeared and replaced with what was measured. It says nothing about extending the corpus to hold the six unanchored languages, which moves the population the medians are drawn from rather than the instrument reading it; that half is still untested, and the major still rests on it and on the two built-in changes.
2.11.2 - 2026-09-07
Changed — the documents caught up with the code
No behaviour changes. What ships is the packaged copy of the scoring standard, which had drifted from the document it is generated from.
The roadmap was the worst of it, and it is the one document a reader
uses to decide what is coming. It said ## Next: Swift for seven minor
releases after Swift shipped in 2.4.0, and named the
remediation-integrity checks as the other near-term block after they
closed in 2.1.0 through 2.3.0. It counted "nine declaration languages"
when there were fourteen. It described Swift, Go and Rust as examples of
unwritten languages, in the releases that wrote them.
## Next now names the recalibration, which is the only scheduled work:
six unanchored languages, plus deleting the per-language cyclomatic
regexes and merging analyzer evidence per concept rather than per
dimension. Everything that touches measurement queues behind it.
One of these was a false claim rather than a stale count. The corpus entry said the anchoring gap "closes for what ships today" — it does not. Swift, COBOL, Go, Rust, PHP and Ruby all ship parsed and unanchored, scored against an anchor holding none of them. That is the corpus policy working as decided, and it is the debt the recalibration pays; saying it had closed was the error.
Five more, found by sweeping for the same shape rather than waiting to trip over them:
docs/standard.mdsaid two parsed languages are outside the corpus. Six are.docs/architecture.mdlisted eight languages inDECLARATION_SUFFIXES. There are fourteen.docs/decisions.mdhad ADR 006 still reading "Go and Rust remain unwritten on exactly those terms".docs/help/README.mdoffered "the eight parsed languages" to a reader.- The roadmap's Shipped line credited the corpus with covering "all eight parsed languages" rather than the eight parsed when it was measured.
Each correction states what it used to claim rather than being silently overwritten, because "Next: Swift" surviving seven releases is the part worth remembering.
Fixed — an audit of the surfaces, six defects and a decision
Three High, three Medium, filed as D130–D136. Each was reproduced before it was fixed and each is closed as a class rather than as the instance reported.
--sarif-inputread an operator-named path with no validation (D130, High). A FIFO blocked forever,/dev/zeroread until memory died, a directory or missing file escaped as a traceback. D104 built the one-handle reader for--configand--baselineand recorded the class as closed on the claim that those were the only two — a claim written into the reader's own docstring, which is why the third door stayed invisible. Every CLI option is now classified by what it does with a path, read from the parser, so a fourth cannot be added unclassified.- The reader was not the read primitive (D131, High). Eight sites
across five modules still resolved a name and then read it: history,
first-run persistence, the XDG user tier, the MCP baseline check, and
_safe_write's own append and clobber reads — a time-of-check/time-of-use gap in the helper written to close them. The always-on case was worse than D130:mkfifo .maintainability/history.jsonlhung every audit.repository_pathis why it hid — it bounds a path's location and says nothing about what kind of file is there. - A piped diff read as clean in every language but Python (D133,
High). The fix that closed it for Python used
ast.parse, and the line above returnedTruefor every other suffix. Refused by the unified diff format now, so it holds for all thirteen; a string literal mentioning a hunk header is still not a diff. - A cognitive-only failure reported a negative line overage (D132).
-72 overof a budget the function was inside. Cognitive complexity is a named budget now, and a failure no budget names reports no figure rather than a wrong one. --checkaccepted five flags it would only ignore (D134):--comment-output,--attestation-output,--agent-instructions-output,--hostile-prompt-output,--transformation. It shares one refusal list with--stagednow, so the two cannot drift again.- Headroom reported the line budget and called that the answer
(D135). A 20-line function at cyclomatic 12 against a warn line of 10
read
band: ok— silent in text, comfortable in JSON. Headroom now covers every budget a declaration can be failed on and bands on the worst;limitandremainingkeep meaning the line budget, because they shipped meaning that.
Changed — --check stays on the CLI
Decided 2026-09-06. D136 recorded chat as the primary surface and
--check as CLI-only. It is closed as a non-goal, not left open: an
open entry naming a gap reads as an instruction to close it. An MCP tool
is not a port of a flag — it is a name, an argument contract, and a
decision about the setup and consent flow every other MCP call goes
through. If chat users ask for it, it reopens as a feature with its own
contract rather than resuming as an unfinished bug.
The register's guards learned the category rather than being satisfied with a fake test: a defect closure names a falsifier, a decision closure names a decider and a date.
2.11.1 - 2026-09-06
2.11.0 was tagged and never published. Its release build failed on a
stale row in docs/release-plan.md, before the publish job ran, so
nothing reached PyPI and the version is skipped rather than yanked. This
is the first release of that line, and it carries 2.11.0's whole
contents plus the fixes below.
Fixed — four constructs no fixture contained, two of them High
An audit of the 2.11.0 tree found four defects, and none of their constructs were in any grammar fixture. Filed as D125–D129.
- Every method on every generic Go type was invisible (D125). The
receiver pattern required
)immediately after the type name, andfunc (s *Store[T]) Getputs[T]between them. Go has had generic types since 1.18, anddocs/languages/go.mdclaimed the behaviour worked — the page was the only thing asserting it. lizard does not find these methods either, so no amount of agreement would have surfaced it. - An assigned
ifclosed a Ruby method at the wrong line (D126). Not an under-count: it reported a different function than the one in the file.x = if cond … endis an expression and needs its ownend; an 8-line method came back as 6, its last two lines attributed to nothing.x = case,x = beginand@memo ||= beginwere the same shape. - PHP's Elvis operator scored nothing (D127). Making a ternary
require a following
:— so?intstops counting — excluded?:, which is spelled with exactly those two characters. - Rust's
let … elsedecided nothing (D128). Stable since 1.65, and noiftoken anywhere in it. - The fixtures were a sample and the page called them the specification (D129). They exercised the constructs their author already knew about, written from the same knowledge as the scanners they check.
Added — Python's fixture coverage is derived from the grammar
ast is the module that implements the Python grammar, so the checklist
is asked of it: every node whose fields carry test, orelse,
handlers, ifs, cases or finalbody, plus BoolOp. No node names
are written down, so a construct added to a future Python arrives in the
checklist on its own rather than when somebody remembers it.
Run against the existing fixture it immediately named three constructs
that had never been exercised: Assert, AsyncFor and TryStar.
Every other language's fixture is still a hand-written set, and the
documentation now says so instead of implying coverage it does not have.
Ruby's Ripper, Go's go/ast and Rust's syn can each do the same and
are unwritten rather than refused.
Changed — documentation that contradicted itself
- The README told readers Go, Rust, PHP and Ruby are not parsed,
three lines under the table listing them as parsed, and
docs/roadmap.mdsaid no scanner was scheduled for them in the release that wrote their scanners (D123). Both are corrected and both are now enforced by a test that reads the sentence making the negative claim and holds it against the languages the scanner actually reads. - The README kept three independent copies of the language list and two had rotted. It now keeps one.
- The planned split into
maintainability-auditplus amaintainability-agentcompanion is recorded across product intent, target architecture, the decision register, the roadmap and the release plan — every entry marked planned and unimplemented, with the naming migration explicitly undecided.
Known open defects
Seven, filed as D130–D136, two of them High: --sarif-input reads an
operator-named path with no validation, and a unified diff piped to
--check reads as clean for every brace language. Both are in surfaces
already released in 2.9.0 and 2.10.0 — this release does not introduce
them and fixes two High defects that would otherwise stay live.
2.11.0 - 2026-09-06
Fixed — every branch keyword now answers to a second implementation, and nine defects fell out
This leads, ahead of the four new languages, because it is the larger fact about every number this tool has ever printed.
Until this release every branch keyword set here was asserted rather than tested: a list written from somebody's knowledge of a language, checked against examples written by the same person from the same knowledge. That reads as evidence because the prose around it is confident, which is worse than reading as a guess.
lizard computes cyclomatic complexity from a separate codebase, by
separate authors, with its own reading of each grammar. Measured against
it on this repository's own source, this project agreed on 448 of 985
declarations — 45%. It is now 968 of 1000.
Nine defects, filed as D112–D120 with the mutation that reproduces each:
- Python was scored against its own comments and strings (D112). The
flagship language, and the one most of the calibration corpus is
measured through, was the one language handed raw lines while every
other received a masked copy. A branchless four-line function scored
4. Masking now uses
tokenize, CPython's own lexer, so the language decides what a string is rather than a pattern written from memory. - Python's boolean operators were never counted (D113). The pattern
looked for
&&and||, in a language that spells themandandor— 3,199 invisible operators in this repository alone. - The fix for D112 blanked the code inside f-strings (D114), found by the same method that found the original, which is the point of having one.
- A
?in type position was counted as a ternary, in five languages (D115): C#int?, TypeScripttitle?:, JavaList<?>, PHP?int, SwiftInt?. A ternary needs both halves, so a following:is now required. - Unconditional constructs were counted as decisions (D116):
gotoin Go and PHP,loopin Rust. C had it right all along. - A multi-way header was counted beside its own arms (D117): Go's
select, PHP'sdo … while, Swift'srepeat … while, Rust's_ =>. The arms-not-header rule was already written down for Fortran and was not applied when four more languages arrived. - Rust refused to count
?(D118), on a false analogy with JavaScript's optional chaining.f()?continues or returns early — two paths, one decision. - No measurement was ever checked against a second implementation (D119): the defect behind the seven above, and the only one about method rather than code.
- Python counted the wildcard
case _(D120), which is itsdefault— and Go's, Rust's and the C family's defaults are correctly not counted, so the same dispatch scored differently depending on the language it happened to be written in. Found by the check D119 built, in the language that check had missed.
Correcting the measurement immediately exposed five real gate breaches in this repository's own source that the broken reading had been hiding, the worst a function at complexity 29 against a limit of 15. All five are decomposed rather than waived.
Added — the check that makes the above hard to repeat
tools/complexity_oracle.pycompares complexity per declaration against an independent implementation across a whole tree. It answers one question and refuses the neighbouring one: it does not say which implementation is right. A divergence sends a reader to the grammar, which is the only authority either tool answers to.tests/fixtures/grammar/holds one fixture per language, exercising the control-flow constructs that language's specification defines, checked construct-by-construct in CI. Thirteen languages.- Coverage is counted in branch readers, not fixtures. The reader is the thing that can be wrong, and counting fixtures is what let Python go unchecked while twelve other languages were added (D120). A reader with no second implementation available must be named with the reason: COBOL is the only one, and it says so in the test rather than going quiet.
- Divergences are declared with the grammar reasoning that settles
them, never silently absorbed, and a second test fails when a
declared divergence stops being real. The check exists to hold this
project to the specification, not to
lizard: chasing a second implementation is the same error as trusting the first, with an extra step — andlizard's own TypeScript reader carries D115.
Added — Go, Rust, PHP and Ruby are parsed
Four languages with declaration scanning, per-declaration complexity and budget enforcement, each with branch keywords derived from its own specification and checked against the oracle above.
- Go — functions and methods, the receiver written onto the method
so no qualifying pass is needed.
selectcounted at its cases. - Rust —
fn, with animplpass qualifying methods asStore::get. Match arms are the branches; the wildcard is a default, not a test. - PHP — markup first and keywords second: everything outside
<?php … ?>is blanked before a keyword is looked for, because a keyword in HTML text is not code. Methods qualify asStore::get. - Ruby — bounded by counting openers rather than braces, since the
language ends blocks with
end. Methods qualify asStore#getfor instance andStore.buildfor singleton, which is how Ruby writes them.
None of the four is in the reference corpus, so a grade for code in them is provisional and the report says so on every run: the findings are as good as the parser, but the rate they are compared against was measured on other languages.
Changed — documentation split at the gates this tool enforces
docs/language-support.mdwas 584 lines and is now 153, with a page per language underdocs/languages/. The README was split the same way — at the gate, rather than by shaving lines to stay under it._discovery.pycrossed the 500-line file gate as helpers were extracted from it, so_banner.pywas split out verbatim: one file's leading comment block, with the banner vocabulary passed in so the import runs one way. The gate doing its job on the tool itself, as it did forcliand_argumentsbefore it.
2.10.0 - 2026-09-05
Added — --check, the in-loop door
--check PATHreads proposed content on stdin and reports what its budgets allow, for an agent mid-edit. No repository, no git, no scan:PATHnames the content and is never opened, so what you pass is what gets measured rather than whatever is on disk. Reading the path would answer about the previous version, which is the pre-commit bug one step earlier in the loop.- Headroom, not only verdicts. A gate speaks once it is already too
late to be cheap; the lines left in a budget are usable while the
author is still writing. That difference is why this sits beside
--stagedrather than inside it, and it is what the roadmap meant by a constraint that "prevents rather than rejects". - It never scores — one file has no population — and says so in the result rather than leaving a missing key to be read as a good grade.
- A language with no declaration scanner says so. Reporting nothing
for a
.rbfile is true, useless, and indistinguishable from clean unless stated. Absence read as a pass is the defect this project keeps finding in itself. - Documented where the people and agents who need it will meet it:
README,
docs/cli.md,docs/ide-agent-integration.md, and the packaged skill, which teaches agents to reach for this instead of a full audit when the question is "does this fit".
Fixed — six defects, none of them found by the author
Every one came from somewhere other than the person who wrote the code: a field check that read no source, an adversarial audit, and the gate turning on itself.
- Classes were measured against the function budget.
max_class_lines(300) has shipped for versions — the config schema documents it and the declaration scanner honours it — and only the in-loop check did not, so an ordinary 111-line class reported-31 lines leftagainst a budget it was nowhere near. - Negative headroom printed beside exit 0. Headroom means what is left; a breach is a finding, not a warning carrying a minus sign.
- A diff piped into
--checkread as clean. The parser refused it, nothing was found, anddeclarations_read: truereported the file as read and fine — absence as a pass, through the feature whose docstring promises to refuse it, by way of the likeliest mistake at that door. --stagedgathered risk findings and dropped them. A stagedeval("bad()")passed in silence while the same line is a finding in a full audit.check_jsonwrotescored: falseas a literal rather than reading the result, so the field would have kept reassuring a caller through the exact bug it exists to disclose.over_byalways measured lines against the function budget, so a short, complex function rendered as-71 over.
Fixed — the falsifier gate had stopped proving an entire file
- One test declaring
Covers existing behaviour:exempted every test beside it, because 2.9.0 made the added-file check report per test and left the exemption file-level. Nineteen falsifiers went unproven with the gate reporting green. A file-level claim now has to come from the module docstring; a test's own docstring exempts that test alone.
2.9.0 - 2026-09-05
The other end of the loop. This project has said in its own roadmap
that it is end-of-loop heavy — strong where it is cheapest to be strong,
a CI gate after the work is done, and thin during the loop where a
constraint prevents rather than rejects. --staged is the pre-commit
half of that gap.
Added — --staged, and the hook that runs it
--stagedscans what the git index will commit. It reads the index, not the working tree: stage half a file withgit add -p, keep typing, and what gets measured is what the commit will actually contain. Reading the tree is the classic pre-commit bug and passes content nobody measured.- It never scores, and says so. A diff has no population, and this
project's own rubric already refuses a rate below the calibration
floor. What survives without a population is the absolute half — this
declaration is over its budget, this file crossed
max_file_lines, this line added a suppression. Thresholds need no denominator. - It applies no repository gates and writes nothing. A missing README or a dirty worktree is a property of a repository, not of a diff. No history append, no report file, and emphatically not the opt-in test suite: on this repository that suite is 255 of the 266 seconds a full audit takes, and a four-minute commit is a hook that gets uninstalled the same afternoon. The scan costs 0.17 seconds.
- The remedy text is not a second copy. Findings are built by the
same
_work_orderbuilders the full audit uses, so a blocked commit quotes the wording a report would. What is skipped is everythingwork_orderdoes around them: it recomputes the score twice per class, and the evidence boundary refuses that outright on a diff. The refusal is correct, and this honours it rather than routing around it. --install-precommit-hookwrites the hook. It refuses to replace a hook this tool did not write — printing the one line to add to that hook instead — and replaces its own without asking, because that is an upgrade. It honourscore.hooksPathand works in a linked worktree, asking the config files rather than the git process, because every command this tool runs is deliberately neutralised withcore.hooksPath=/dev/nullso an audit can never execute the audited tree's hooks (D92).- It refuses the flags it would have to ignore.
--changed-only,--record-history,--fail-on-gate, every output path: a hook that accepts a flag and does nothing with it teaches the author it was honoured.
Fixed — the falsifier gate could be talked out of proving things
- D109. The gate's added-file check asked whether every test in a
new file passed at the base, so one hollow test rode in beside
fourteen real ones. It printed
14 of 15 fail without the changeand exited 0, while on the same run its per-test check held two new tests in an existing file individually. A test's obligation to falsify something depended on how new its neighbours happened to be. Both paths now report every individual test that passes at the base. The hollow test it let through asserted a flag name that argparse's usage banner already contains, so it passed on a tree where--stageddid not exist. - D111. The
Covers existing behaviour:escape was matched anywhere in the source, so a docstring explaining the escape exempted the test doing the explaining — D109's own falsifier was excused by a sentence describing the mechanism. This is D108's defect one layer up, and it has D108's fix: a quoted occurrence is a mention, not a declaration.
Changed — run_git decodes what it cannot read instead of raising
- Every git command now decodes with
errors="replace", matching whatsource.read_lineshas always done for files on disk. A Latin-1 source file was measured by the repository scan and would have crashed the pre-commit one, and a hook that crashes on somebody's encoding is a hook that blocks their commit for a reason they cannot act on. Everywhere else this reads git's own ASCII output, so nothing else changes.run_gitalso grew astrip=Falsefor the single caller that reads file content rather than an answer: stripping a blob deletes its leading blank lines, and every line number computed from it is then wrong by however many were removed.
Fixed — a suppression scan that reported markers it only read about
- D108.
SUPPRESSION_MARKERScarried a comment promising that a marker inside a docstring explaining the convention is not a suppression. Splitting the regexes per language was the whole mitigation, and it does not mitigate this: the pattern matched the marker wherever it appeared.--stagedfound it in the first hour by blocking a commit whose only offence was a docstring about suppressions. Both callers now go through one function,_conformance.markers_in, which refuses a quoted match — one rule, asked twice, because a hook stricter than the gate it belongs to blocks commits CI would pass.
2.8.1 - 2026-09-05
Everything SonarCloud had to say, once the project was made public. The gate had only ever blocked on new code, so five findings in older code had never surfaced. Four closed by changing the code; two were resolved as false positives with the grounds recorded in the register rather than only in a dashboard.
Fixed — a file kept its own permissions, and a read stopped hanging
S2612,_safe_write. The staging file was chmod'ed to a fixed0o644under a comment claiming to "match the old mode". It did not:mkstempcreates 0600 and the hardcoded mode then widened it, so a config a user had restricted to 0600 came back world-readable after every write. The mode is now inherited — the target's own where it exists, andmkstemp's 0600 for a new file.- The fix's own first attempt read the process umask (
os.umask(0), then put it back) to reproduceopen()'s default. That is a race: any file another thread creates inside that window is born world-writable. Removed; a cosmetic default is not worth it. S8707,config.pyandbaseline.py.--configand--baselinewere read straight intojson.loads— the only two path-taking entry points with no validation at all. The harm was not traversal but a hang:read_texton a FIFO blocks forever, and on/dev/zeroconsumes memory until the process dies. Measured before the fix, a bare read had to be killed at four seconds.read_operator_filenow opens the path once and checks and reads through that single handle — regular files only, size-bounded,O_NONBLOCKso a FIFO cannot block ahead of validation.S5863×2. Two determinism checks compared an expression with itself. The intent was right; the shape was indistinguishable from the typo the rule exists to catch. The calls are bound to names and each assertion now says what a failure would mean.
Added
tools/sonar_resolve.pyand a dispatch workflow. Two findings had been dismissed by hand and both decisions lived only in a dashboard, invisible to a reader of the code — the shape this project calls a defect when other people do it. The script posts the justification before the transition, refuses a comment too short to reconstruct the decision from, and refuses an issue already resolved. The token stays a repository secret.
Unchanged, deliberately
A symlinked config is still allowed: the operator named the path and
controls it, and the audited tree's own default is a different question
discovered_config already answers. Pinned by a test so it is not added
by reflex.
The authorship gate still checks signatures over merge commits. Making GitHub's "Update branch" button pass would have meant skipping them, and that control exists because skipping them let unattested content through once already. Stale branches get rebased instead.
2.8.0 - 2026-09-04
An audit on f51fbbc found three classes, all shipped in the previous two days. Two of them were mine from 2.4.0 and 2.7.0.
Security — the audited tree chose what this process executed
_semantic_ts resolved tsc from the tree's own
node_modules/.bin/tsc before falling back to PATH. A tsconfig.json
beside a writable node_modules/.bin/tsc was therefore enough to make
this agent execute the repository under audit — and the tree did not have
to contain any TypeScript. SECURITY.md's claim that the opt-in test
command is "the sole path by which anything from the audited tree runs"
was false for as long as that resolution existed.
Deleted rather than guarded. There is no safe way to run a binary
whose identity the audited tree controls. tsc is now invoked as a
constant program name resolved on PATH.
- Behaviour change: a project whose only TypeScript compiler is project-local now leaves type coverage unknown rather than clean. That is the honest fallback the evidence model already has a word for, and it is the reason this is a minor rather than a patch.
- SECURITY.md now states the rule that keeps the claim true: every binary
this process runs is named by this machine — a constant on
PATHor a catalog path — never by a path built out of the audited repository.
Fixed — two scanners missed in the unsafe direction
- COBOL cognitive complexity was identically 0 in production.
detect_functionshands the reader a slice of the already-masked file, andcobol_cognitivemasked it again. COBOL's is the only file-level masker — what a line means depends on which division it sits in — so a paragraph slice, which contains noPROCEDURE DIVISION, blanked to nothing. Every COBOL paragraph scored zero nesting cost, including deeply nested ones. The language's own test passed throughout because it called the function with a whole file. - A column-1
DISPLAY "A".was read as a COBOL paragraph. Once the literal is blanked it is a word, a gap and a period — a header's exact shape. Fixed structurally: a header's period abuts its name, and the gap is what gives a statement away. That rejects the whole class of operand-bearing statements rather than a list of verbs. - A
funcinside a Swift triple-quoted string was reported as a declaration, becausemask_linesis line-local.mask_swift_linesblanks multiline literals, raw and ordinary, before anything reads a line._ranges_javadiscloses the same hole for Java text blocks; it is closed here rather than copied, because Swift documentation strings carry sample code far more often.
Added — the grade says which languages the anchor omits
score.reference.corpus_note claimed the corpus spans "every language
this scanner parses". True when written, false from 2.4.0, and it stayed
that way for three releases across the report, docs/standard.md, the
packaged standard and the README — while the remediation prompt still
described a Python/TypeScript/JavaScript corpus.
Unanchored remains policy. What changes is that every surface printing a grade now names what the anchor does not hold:
score.referencegainsunanchored_languages(Swift,COBOL).- The markdown summary, the HTML executive strip and the remediation
prompt each print it, through one shared
_evidence_viewhelper rather than three copies of the same sentence. _anchorholds the fact beside the calibration constant it qualifies, so the two go stale together.
A limit disclosed only in the JSON is disclosed nowhere that matters — the shape this project already shipped once as F1.
2.7.0 - 2026-09-04
COBOL is the tenth parsed language, and the first whose declarations have
no end marker at all. C closes a body with }; Fortran closes one with
end. A COBOL paragraph ends where the next paragraph begins — the
boundary is the start of the next thing, and nothing in the source
announces it. scan_bounded already took the bounding rule as an
argument, so this needed no change to the shared walk: what is shared is
that a range never runs past its own body, not the mechanism enforcing it.
Added
_ranges_cobolreads PROCEDURE DIVISION paragraphs from.cbl,.coband.cpyin both spellings, in fixed-form and free-form source.cobol_branch_pointsandcobol_cognitive. COBOL closes scopes two ways — hyphenatedEND-words and the period that ends a sentence — and neither is in the C-family reading. Classic COBOL writesIF X DISPLAY Y.with noEND-IFanywhere, so a reader that only decremented onEND-terminators would let nesting climb through a whole paragraph.- An exemption that has to be disclosed. The lint requiring every
parsed language to reach a complexity analyzer now has a named
NO_EXTERNAL_COMPLEXITYlist, and a second test fails unless each entry is also named indocs/language-support.md. An escape hatch nobody sees is how a language ends up quietly measured by half the rubric.
The judgments, because each is a defect taken the other way
- A level number is not a declaration.
01 CUSTOMER-RECORD.is shaped exactly like a paragraph header, and an ordinary program has hundreds of them. No line-local rule can tell them apart, because the difference is which division they are in — so the mask blanks every division before PROCEDURE and the recogniser never sees one. Counting them would have dominated the declaration population every rate divides by. - Programs and sections are containers, walked into and not graded, or their paragraphs' lines are counted twice.
- Area A is the rule, not a guess about indentation. Headers live in
columns 8-11 and statements in 12-72. The first working version allowed
one column too many and reported a masked
DISPLAY "A".as a declaration named DISPLAY, once per statement in the file. - Reading columns is decided by evidence, in the safe direction. Both formats share extensions, so the file is read to find out. Free-form indented seven spaces is byte-identical in those columns to card source with a blank sequence field, and stripping seven blanks from it changes nothing; what is excluded is code in columns 1-7. Reading fixed-form as free-form loses declarations, while the reverse deletes the front of every line and invents findings from the wreckage.
Known and disclosed
No offline analyzer reads COBOL. lizard does not; jscpd reads duplication only, which is the half the complexity floor exists to reject. The mainframe tooling that would — IBM Developer for z/OS, ADDI, the analysis around Dependency Based Build — is licensed and host-resident. So the built-in scanner is the only reading COBOL gets, the analyzer tier reports it as unmeasured rather than pretending, and both the README and the language-support page say so.
A section whose statements sit outside any paragraph mints nothing, COPY
members are not expanded, and REPLACE is not applied.
Not calibrated. Under the corpus policy decided with 2.4.1, COBOL ships parsed and unanchored: its findings are as good as its parser, and its grade is provisional until the single recalibration that follows the remaining scanners.
2.6.0 - 2026-09-04
Did run seven of this transformation land better than run six? The last of the five remediation-integrity items, and the only one that is a report rather than a gate. Where an agent performs one class of work repeatedly — a framework upgrade, a migration, a codemod across services — nobody measures whether the seventh run produced better code than the sixth. The generator cannot: its output is not reproducible and it keeps no memory across runs.
Added
--transformation NAMErecords the operator's name for the class of work a scan followed and reports how this run of it compares with earlier ones — each run's movement in estimate since the previous recorded scan, and how the newest run's movement compares with the one before it._run_comparisonjoins;_history_viewrenders. It changes no score and cannot fail a build.- History schema 4 carries the
transformationlabel. Schema-3 lines load with it empty and belong to no series, which is the truth about them: nothing recorded what they followed. The field is deliberately outsideCOMPARABILITY_FIELDS, so a series is never broken for saying its own name.
What it refuses to claim
The value here is entirely in what it declines to say, because every temptation runs the other way.
- Never "better". Two runs of one codemod land on different code, so a larger movement can come from the tree it was applied to as easily as from the run. The report says moved further, moved less, or within tolerance — which is what the numbers support.
- An interval, not an effect. Nothing in a tree says which transformation produced it, so the label is a claim the operator makes and this tool records without verifying. Everything else that happened in that interval is inside the number, and the rendered section says so.
- A withheld estimate is not a zero. P7 withholds a number because the evidence did not support one; subtracting from it would manufacture exactly the number the scoring model refused to publish.
- It refuses across an instrument change, reusing the
segmentsrule--fail-on-regressionalready answers to, and names how many runs it refused rather than dropping them silently — a reader who cannot see a refusal concludes there was no history.
Scope
One repository's history. The comparison joins the runs recorded in the
tree being audited, which is the case the audiences in
product-intent.md actually have: the same agent doing the same kind of
work in one repository, over and over — a framework upgrade done in six
passes, a migration re-run.
Corrected after release rather than quietly rewritten: this section
originally announced a cross-repository version, over a fleet of
services, as the case "where this is worth the most". No audience in
product-intent.md is a platform team with a service fleet, and no such
direction had been decided. It was an invented roadmap commitment and it
is withdrawn.
2.5.0 - 2026-09-04
The repository answers the test-command question. expected_commands.test
was hand-typed on every tree, and the prompt's example was pytest, so the
question was hardest to answer exactly where this tool had just learned to
parse the language: a Package.swift beside a Tests/ directory says
swift test as plainly as a manifest says anything.
Added
_test_commandsreads build manifests and offers the command they imply as the setup question's default, naming the file it read:swift test(Package.swift),fpm test,cargo test,go test ./...,ctest,mvn test,gradle test,dotnet test,npm testandpytest. Nothing is executed — whether the suite may run remains the Class 5 opt-in, default off.- The default reaches both surfaces identically. Chat and MCP carry it
in the question's
defaultfield; the CLI inserts it into the input line throughreadline, so Enter accepts and clearing the line still cancels the opt-in exactly as a blank answer always did. A "press Enter to accept" prompt would have taken that cancellation away.
What it refuses to guess
A command that cannot run is worse than no suggestion, so three cases are deliberately blank:
xcodebuild test, which the roadmap named as a target and which is not shipped. Barexcodebuild testneeds-schemeand usually-destination; neither is derivable from the tree, so every suggestion it could make is a command that fails.- A Swift package with no test target, where
swift testexits non-zero.Tests/or a.testTargetis required, not just a manifest. - npm's placeholder
scripts.test— theecho "Error: no test specified" && exit 1that exists in order not to be a test command.CMakeLists.txtwithoutenable_testing()is refused for the same reason, andgo testis offered as./...because the bare form tests only the root package and exits zero.
Unchanged, deliberately
Detection suggests and never decides. The require_test_command hard
gate asks whether a human documented a command; a value the tool wrote
into the config unasked would satisfy that gate on evidence it invented
about itself. A test pins that a detectable command does not silence the
gate.
2.4.1 - 2026-09-04
Swift shipped unreachable. .swift was routed to its scanner and
absent from the default include_extensions, so no Swift file was ever
opened and the language 2.4.0 announced did nothing. The guard that was
supposed to catch this checked one direction only — that every included
extension has a parser — and a language parsed but not included passed it
silently. It now checks both, and the failure message says why the
one-directional version was worthless: the language is claimed and
unreachable.
Measured on vapor/vapor: 3 files and 0 declarations before, 253 files
and 1,924 declarations after. Across the Swift candidates, 4 of 16
repositories met the qualification floor before the fix and 16 of 16 after
— which is the shape of the bug, not a tuning change.
Documentation caught up with 2.2.0 and 2.3.0. The remediation-integrity
flags (--conformance, --fail-on-out-of-scope, --fail-on-regression,
--attestation-output) shipped two releases ago and appeared in no
user-facing document. The README now has them; the roadmap's "remediation
hole" section and four entries under architecture's Known debt are closed
rather than still listed as future work; and product-intent.md grows the
loop's fifth step, because "the work order bounds the agent by instruction
and nothing verifies it" had become false. What is still true, and is now
stated in all three places, is that these checks read a diff's shape and
never its correctness.
2.4.0 - 2026-09-04
Swift is the ninth parsed language, and the first added since the corpus was extended — so it is scored against an anchor that does not hold it, which the roadmap already names as the cost every new language re-incurs.
Added
-
A Swift declaration scanner. Functions, initialisers, deinitialisers, subscripts, and the types (
class,struct,enum,protocol,actor), each bounded by its own braces over the walk shared with C, C++, C# and Java. Every Swift declaration is keyword-led, which makes recognition easier here than in C++, where a barename(is equally a call, a macro and a constructor. -
Extension members carry the type they extend.
extension Widget { func draw() }reportsWidget.draw, not a baredrawindistinguishable from every other type's. C++ gets this free because the source writesvoid geo::Widget::draw(); Swift does not, so the scanner carries it. A work order saying "shortendraw" against a tree with eleven of them is not a bounded instruction. -
Swift's own reading of a branch.
guardis the language's primary early exit and is absent from the C-family pattern, so a guard-heavy function would have read as branchless — the defect Fortran shipped with, where six nesteddoloops scored complexity 1 because the pattern did not know the keyword.
Fixed
- Two hazards the roadmap did not predict, both found by the tests and both now pinned:
Swift has no statement terminator, so the shared bare-signature rule
— a signature that closes without opening a brace, which in C is a ; —
could not see where a protocol requirement ended. It walked on and
adopted the next line's brace, reporting func describe() -> String as
two lines of body. A protocol of forty requirements is not forty
declarations.
class is both a keyword and a modifier: class Widget declares a
type, class func make() declares a type-level method. Stripping it with
the other modifiers cost every final class Store its keyword — the type
vanished and only its members were reported, which makes a declaration
rate wrong rather than merely incomplete.
- A roadmap claim was wrong and is corrected rather than built against.
It said Swift's string interpolation "puts braces inside literals, so
masking has to run before depth counting". Swift interpolates with
\(expr)— parentheses, not braces — and string contents are masked before any counting regardless.
2.3.0 - 2026-09-04
The attestation artifact. The three remediation-integrity mechanisms now compose into the record they were built for. No score changes.
Added
--attestation-outputwrites an independent, reproducible record of a change: what was measured, what the agent was told to change, what it actually changed, and what moved. The fourth emitter on the prompt seam, besiderender_ai_prompt,render_agent_instructionsandrender_hostile_audit_prompt.
Why an independent process has to produce it. A platform that generates code and then reports on its quality is producing a self-assessment — a property of the arrangement, not a criticism of any particular one, and no amount of engineering removes it. The question a reviewer asks is who checked this, and can they re-derive the check months from now, and an author cannot be the answer.
The value is in what it refuses to overstate, so that is what the tests hold:
- A mixed result reads as mixed. A change that stayed in scope while silencing a finding renders as yes on the first question and no on the second. It cannot be waved as a pass.
- Not asked is not passed. A question nobody ran is
Nonein the record and not asked in the document, neverTrue. An incomparable ratchet renders as not established, because "no dimension regressed" is a claim the evidence cannot support when the scans sit either side of an instrument change. - Reproducible, and explicitly not signed. The digest is over the record's own content, so the same commit measured with the same analyzer versions reproduces it and anyone can recompute it. Nothing here holds a key, and "signed" is the word a reader reaches for with an attestation — which is exactly why the document says the opposite in its own text.
- An uncommitted tree is disclosed. A verdict about "the commit" that actually depends on unstaged work is unreproducible.
It never opens the diff to judge it and it touches no score, both stated in the artifact rather than left to inference.
2.2.0 - 2026-09-03
The third remediation-integrity mechanism. No score changes.
Added
--fail-on-regressionexits 1 when any scoring category is lower than the previous comparable scan.--fail-on-newalready caught a finding that clears and returns; nothing caught the slower failure — a change that improves one dimension while quietly regressing another, green by every existing gate, with the thing that got worse never named.
The judgment that matters is when subtracting is allowed at all. Two
scans taken with different instruments cannot be differenced, and
_scan_history.segments already owns that rule for the trend charts, so
the ratchet refuses rather than reaching across a break. Without it, the
day 2.0.0 moved CALIBRATION_C from 5.8843 to 8.7161 this gate would
have reported every repository on earth as catastrophically regressed —
and the gate would have been the thing that was wrong.
So there are three outcomes, not two: held, regressed, and not comparable. The third exits 2 and prints why, because a run that could not ask the question must not be read as one that asked and found nothing.
Two further limits, both tested: an aspect that becomes unmeasurable is not a drop — withheld evidence reading as a failure is the inversion P3 forbids — and aspects are reported beside the verdict but do not decide it, since failing on every aspect wobble makes a gate that gets switched off.
Changed
cli.pycrossed the 500-line file gate, so everything that composes from a finished report and turns it into an exit code moved to_gates: conformance, the ratchet, andaudit_exit_code. The guard intest_identity_resolutionthat pins structural finding-matching follows the function rather than the filename.
2.1.0 - 2026-09-03
The bounded work order becomes checkable. Fix exactly these findings and refactor nothing else was the product's central claim and was enforced socially: the prompt said it, and nothing compared the diff that came back. No score changes in this release.
Added
--conformance REVSPECreports how a diff relates to the work order it was given: which changed files the work order named, which are tests pairing to a named file, which are neither, and any suppression the diff added.--fail-on-out-of-scopeturns that into a gate; both are opt-in, because a finding can legitimately require touching a caller nobody flagged, and a check that cannot be argued with becomes a check that is bypassed.
The two halves ship together because either alone is bypassable.
Scope conformance answers "did it change only what it was asked to" — and
is satisfied by touching the named file, adding # noqa to the flagged
line, and changing nothing else. Suppression detection answers "and
without switching a checker off in a file that was flagged". The record
reports conformant for the first and clean for both; the gate reads
clean.
Two judgments make it usable rather than noisy. A test pairing to a
named file is in scope, by the convention _test_pairing already uses
for coverage — a correct remediation nearly always touches the test that
proves the fix, and flagging that would make every good remediation look
disobedient. And only added lines are read, so a decade of
accumulated directives already in the tree is not attributed to this
change.
It never scores. Whether a diff was obedient is a fact about an agent's
behaviour, not evidence about the code's condition, so _conformance is
assembly and may not reach scoring — declared in architecture.md and
held by test_architecture. The record carries a note saying it
compared paths and added lines, so a green result cannot be read as "the
change was correct".
-
git_tools.added_lines, the diff-content reader behind it.--no-ext-diffis load-bearing there rather than defensive: this module's read-only config neutralisesdiff.external, which costs nothing for--name-onlyand kills a content diff outright, because git then tries to execute the empty string. -
--target-path TARGET=PATHfor--init-agent-standards. The table of agent conventions will always trail the market, and the first agent it trailed was real: an evaluation of this tool against IBM Bob fell back to--target genericbecause no Bob convention exists here and nobody knew where Bob reads from. Guessing the filename is the wrong fix — a generatedbob-maintainability.mdlooks configured and is never opened — so an unknown target is now refused, naming the flag that resolves it. A supplied path also overrides a built-in convention.
Fixed
- The instruction-target list was declared three times — the mapping,
and
cli.pytwice aschoicesand as the default — so adding a target meant remembering all three or shipping one the CLI refused to accept. It is declared once, with a guard assertingcli.pynever names a target literally. The dead fallback that generated{target}-maintainability.mdis gone;choiceshad made it unreachable, and removingchoiceswithout the refusal would have turned a latent bug live.
2.0.0 - 2026-09-03
Every score changes. The reference corpus every number is a multiple of
now spans all eight parsed languages instead of three, so CALIBRATION_C
and the per-dimension references move and every repository re-grades.
Read the migration note before comparing a 2.0
score to a 1.x one.
Changed
- The calibration corpus is 112 repositories across eight languages, up from 40 across three. Java, C, C++, C# and Fortran were parsed for five releases while being scored against medians measured on none of their code — LAPACK read 7.18x the declaration median against an anchor holding no Fortran at all. The gap is closed by extending the anchor rather than by softening the disclosure.
The original 40 rows are unchanged and still pinned to the commits
they were measured at, so what moved the constants is the languages
added and nothing else. Selection is the same mechanical query —
created before 2021, pushed since 2026, under the size cap — with one
deliberate exception: Fortran entered at a 500-star threshold rather
than 3,000, because the entire Fortran ecosystem has zero repositories
above the bar the other seven use. Holding one number across ecosystems
would have measured popularity rather than maturity and excluded the
language outright. The per-language thresholds are recorded in
corpus.json under selection.min_stars.
-
score.referencenames the corpus it is drawn from, and a guard now derives that disclosure fromcorpus.jsonin both directions — the report cannot name a language the corpus lacks, or omit one it holds. The previous guard asserted three language names by hand, which is why it broke the moment the corpus grew and said nothing about what should replace it. -
CPlusPlusThingsleft the corpus andgoogletesttook its place. It is a C++ teaching repository; the marker list gained"things"so that category is excluded mechanically rather than by veto. Recorded plainly because it was found by its measurements —declarations0.0078 against a 0.1005 reference — and not by its name, which is the outcome-triggered discovery a mechanical corpus exists to prevent. The same disclosure33-js-conceptsreceived. Re-running selection with the new marker changed exactly one member.
Added
-
Measurement rows carry what produced them: the pinned commit, the tool version, and the version of every analyzer that contributed. Rows used to hold the repository name alone, so nothing could tell a stale measurement from a fresh one and every recalibration had to re-measure the whole corpus. This is the discipline ADR 001 stage 9 put on the history corpus, applied to the other set of numbers everything derives from.
-
measure.py --reuse, which reuses a stored row only when its pinned commit, tool version and analyzer population still match, and measures the rest. Adding a language now costs its own repositories rather than all 112. It arrives with the refusal it makes necessary: a corpus measured on two different versions of one tool is not a corpus, so the run refuses to fit constants to it and names the tool.
Fixed
-
verify_corpus.pycould destroy the pinned corpus. It defaulted--outtocorpus.jsonand wrote there unconditionally, so a run whose stdout was redirected elsewhere still replaced 40 pinned repositories with whatever candidate file was passed. It now refuses unless--replacesays that is the intent. Found the hard way: it happened during this release and an unrelated guard caught it, which is luck rather than design. -
measure.pycould downgrade the stored evidence. A run without--with-analyzersoverwrote analyzer-primary measurements with built-in-only ones and warned after the file was gone. It now refuses before doing any work — a first attempt at the same check sat at the write site and cloned 112 repositories before saying no. -
The major-line guard refused every major release it governed. D100 holds that no copy may claim a major line above the newest tag, but the packaged version must be bumped before the tag exists, because
release.ymlrefuses to publish when the two disagree. The bar is not dropped: a declared major line must now have its own dated CHANGELOG section, which is exactly the step D85 skipped when it promoted every copy to1.0.0rc1against a v0.9.1 tag.
1.10.1 - 2026-09-03
Names a hole under the product's central claim, rather than leaving a reader to infer it is closed.
Changed
-
The work order bounds the agent by instruction, and the README now says so. Step 3 emits a prompt scoped to the findings; nothing verifies that the diff which comes back stayed inside it, and a finding can still be made to disappear by suppressing it rather than fixing it. That limit sits beside the thing that is true — the agent authors, no model runs in CI, and the gate holds a fixed oracle, so this gate cannot degrade from "right by construction" to "right most of the time" the way a model-judged one does.
-
Three checks are on the roadmap with the failure each closes, in the order they are worth building: scope conformance verified against the work order (cheapest — the work order already names its findings and paths, so comparing a diff is mechanical); a suppression counted as a reopen rather than a resolution (
# noqa,# type: ignore,eslint-disable,pragma: no cover, a skipped test); and a per-dimension regression ratchet (--fail-on-newand baseline v3 already catch a finding that clears and returns, but a change that improves one dimension while regressing another passes today). -
architecture.mdcarries the same three under Known debt, each naming the layer it would live in — conformance in assembly, reading a revspec throughgit_toolsand never reaching scoring, because whether a diff was obedient is not evidence about the code's condition.
Prompted by What is agentic testing?, whose argument for where the model should stop describes the architecture this tool already has; its repair-step failure modes are the ones that still apply here.
Fixed
tests/test_architecture.pywas sitting at exactly its own 500-line limit, with zero headroom — the next module added to a layer set would have failed the gate, and 1.10.0 only fit because a comment was shortened to one line. Split: the layer sets and the reason each module belongs where it does move totests/_architecture_layers.py(220 lines), the assertions that read the real import graph stay intest_architecture.py(314). The data is the claim; the tests are the enforcement, and a reader wanting one should not have to scroll past the other.
1.10.0 - 2026-09-03
ADR 013 ships. The adversarial audit now has an artifact.
Added
render_hostile_audit_prompt— a third emitter on the prompt seam, besiderender_ai_promptandrender_agent_instructions. Surfaced as CLI--hostile-prompt-outputand the MCP promptmaintainability-hostile-audit.
The hostile audit is the highest-leverage quality process this project has and was the only one with no artifact: it depended on a person hand-writing a fresh prompt into a fresh session, re-deriving context the report already held, with audit quality riding on whoever wrote the prompt that day. Not seeded, not repeatable, not comparable run to run.
The brief is emitted deterministically from one run and carries the commit under audit (and whether the tree was dirty), the evidence already computed — what ran, what did not, which concepts nothing measured, which have a single source — the eight published promises each with the shape of evidence that falsifies it, and the audit contract: reproduce rather than speculate, verify against the named commit, and turn every accepted finding into a test that fails without its fix.
The boundary is the one ADR 008 drew: the deterministic core seeds the hostile audit; it never performs it. An LLM red-team inside the audit would break P1 outright, so the reasoning happens outside exactly as the fixing does. It returns text — no gate, no score, nothing written, nothing sent — and its findings return to the falsifier-and-fix loop, never to the estimate, range or grade.
- The promise table lives in code so the brief renders offline without
docs/installed beside the package, andtest_every_published_promise_reaches_the_adversaryholds it todocs/product-intent.mdin both directions. A promise handed over in a stale wording is worse than one not handed over at all: it aims the audit at a claim the project no longer makes.
Fixed
- Two defects in the emitter, caught on its first render. The
analyzer outcomes were pasted in as stringified nested dicts — several
thousand characters on one line, including every language jscpd claims —
and
evidence_statusprinted as{'status': 'complete', ...}rather thancomplete. A brief that buries its own content is worse than the hand-written prompt it replaces, sotest_no_stringified_structure_reaches_the_briefnow fails on any dict or list-of-dicts in the output and on any line long enough to be a dumped structure.
1.9.0 - 2026-09-02
ADR 001 is finished. Stage 9 was the last unmigrated stage and the one with a shipped false result behind it.
Added
- History is pinned, and a drifted cache is refused.
tools/calibration/history_manifest.pymaterializes once — touching the network — and writes down exactly what it fetched: pinned head, the selection rule, the selected commit ids, the parent objects those commits require, and the tool version.measure_fix_breadthnow reads that manifest and never touches the network; a cache that has drifted from the pin is refused with the difference named, rather than measured as if it were pinned.
The coupling this replaces produced a real false number and took six
audits to find. The oldest commit a shallow clone holds has no parent
locally, so git diffs it against the empty tree and --numstat reports
the whole tree as added: one fix commit measured (1 file, 75 lines) in a
full clone and (2 files, 39 lines) at the shallow boundary. Each audit
repaired the symptom it found because there was nothing to check a cache
against. Now there is, and
tests/test_history_manifest.py reproduces the boundary with real git
repositories cloned shallow over file:// rather than mocking it.
- The checked-in manifest,
tools/calibration/history_manifest.json: 33 subjects, 6,120 pinned commit ids, 293 required parent objects, and 14 subjects whose cache sits on a graft boundary. It also records something the study could not previously see — five of the original 38 subjects no longer exist, deleted or made private since selection, which is an argument for pinning rather than against it.
Fixed
- Documents that called shipped work open.
release-plan.mdsaid 1.0 "still waits on acceptance (8.8), the 7.5 hostile audit (8.9), and the human tag (8.10)" — all three done on 2026-09-01, nine releases earlier — and separately that "8.8–8.10 remain open below".adr-011said an ask "remains open under D3" when D3's register heading reads Closed. Phase 7.2's exit condition is that no document calls shipped work open or deferred; these were the fourth, fifth and sixth instances in the paperwork of the project that exists to catch drift.
1.8.3 - 2026-09-02
The falsifier gate could not see a test added to a file that already existed, 1.8.2 walked through that hole, and closing it immediately caught 1.8.2's own guard.
Fixed
- The gate reaches tests added to existing files.
_added_test_filesis added-files-only for a stated reason: a modified file may defend a fix older than the base, so proving its tests would be a false accusation. That covers the tests already in the file — not one the diff adds to it, which is exactly as new as one arriving in a new file. 1.8.2 added its guard to an existingtests/test_readme_claims.py; CI printed a pass for this gate having proven nothing about it, and the guard was mutation-tested by hand instead, which is the manual step the gate exists to replace. Selection is now per-test and read from the diff, so a function that merely moved does not read as new, with the same stated-not-assumedCovers existing behaviour:escape read from the function's own source. - 1.8.2's README guard did not falsify, and the repaired gate said so. It asserted the language sentence was internally consistent — the "as of" version no older than the newest version it named. At the base commit that sentence was self-consistent (1.5.0 ≥ 1.4) and merely incomplete: it named free-form Fortran and stopped, while the table below already carried fixed-form Fortran and HTML. The hand mutation that appeared to prove it had spliced together a state that never existed in history. The defect was an omission, and only a count sees an omission, so the guard now holds the sentence's count against the languages its own table lists — which is what would have caught the 1.8.1 README, saying seven while its table listed eight.
Changed
- Swift is the next language, not Go. 1.8.2's roadmap named Go
(1.9.0) and Rust (1.10.0), read out of ADR 006's
"Go and Rust remain unwritten on exactly those terms". That entry
records what was decided, not what comes next, and reading a register
entry as a plan was the error. Swift takes 1.9.0, with the four shapes
that make it not quite C-family stated: an
extensionadds methods to a type declared elsewhere, aprotocoldeclares bodiless requirements, computed properties have braces and are the C# properties problem again, and string interpolation puts braces inside literals. Go and Rust stay named and unscheduled.
1.8.2 - 2026-09-02
The roadmap never mentioned the language work — not the five scanners shipped between 1.1.0 and 1.6.0, and not the ones still to come. The help docs never mentioned languages at all. Both are the front door for somebody deciding whether this tool reads their code.
Added
- The roadmap states the language plan. Shipped now records the nine
declaration languages with the release each arrived in, the shared
_ranges_corewalk, and Fortran's three follow-on increments. A new Language adapters section carries the planned work: Go (1.9.0) and Rust (1.10.0), which are the two ADR 006 names "unwritten on exactly those terms", with the hard part of each stated — Go's receivers and method sets, Rust'simplblocks, bodiless trait signatures and macro-generated declarations. It also records what Go and Rust already get without a scanner (discovery, practice signals, multimetric for Go) and what they do not: a declaration population. - Two gaps that widen with every language added, named on the roadmap
rather than discovered again later: test-command detection stops at
pytest -q, sofpm test,ctest,go testandcargo testleaveexpected_commands.testhand-configured; and the calibration corpus is 40 JS/TS/Python repositories, which every added language inherits. - The help docs explain withheld declaration rates. A repository in an
unparsed language shows few declaration findings and may have its grade
withheld; that is the disclosure working, not the scan failing, and
nothing said so. Adding a suffix to
include_extensionswidens what is scanned, not what can be parsed. The analyzer help now also states that language decides tool eligibility, and that fortitude emits verdicts rather than a population.
Fixed
- The README contradicted itself for two releases. It read "Seven
languages are parsed as of 1.5.0" while listing fixed-form Fortran,
which shipped in 1.6.0. The existing guard pins the language table,
and the table was right the whole time, so nothing caught the sentence
above it. Now eight languages as of 1.6.0, and
test_the_readme_language_sentence_is_not_older_than_what_it_listsholds the sentence to its own contents: the "as of" version must be at least the newest per-language version the sentence names. Restoring the old wording fails it.
1.8.1 - 2026-09-02
1.8.0 fixed the falsifier gate and could not prove it. Running that down found a second defect underneath the first, and both are now guarded by tests that fail without them.
Fixed
- A child that never ran counted as a proof.
_proveread every non-zero exit as "the test failed at the base", which it treats as proof — so an interpreter that could not start pytest at all reported that every falsifier falsified. This is not hypothetical: it is what hid the 1.8.0 fix from its own guard. The mutation that should have failed that guard passed instead, because the child was exiting 1 withNo module named pytestand the tool was reading that as evidence.
The line is now drawn at output, not exit code, because a
collection error is a real signal here: a new test file naming a module
the base does not have cannot be collected, and this tool already
counts that as the weaker proof it is. Only a child that printed
nothing at all never ran.
- The 1.8.0 fix broke pytest for the child on some interpreters.
Replacing PYTHONPATH with the worktree's src alone left the child
unable to import pytest itself wherever pytest lives outside the
default path. The worktree now goes first and the parent's path
follows.
Added
- Two guards, each mutation-verified. Reverting the shadowing fix
fails
test_the_reverted_tree_is_the_tree_the_tests_import; reverting the never-ran rule failstest_a_child_that_never_ran_is_not_a_proof. Both were written, watched to fail, and corrected twice before they discriminated — the first draft passed in 0.14s against a scratch repository whose every commit had been refused by a machine-level hook, and the second put its decoy where the code does not read from. A guard nobody has watched fail is not a guard, and neither of these was until it was.
End to end against v1.5.1, the gate now reports 19 of 20 added tests failing without their change, where before the 1.8.0 fix it reported 0 of 19.
1.8.0 - 2026-09-02
A retrospective audit of everything from v0.9.1 through v1.7.0 — 104 commits, benched against product intent, the architecture and the decision register — and the five findings it produced, fixed.
Fixed
- The falsifier gate proved nothing about code.
prove_falsifiers.pyreverts the tree to the base and runs the new tests against it, and the reverted tree was never the tree the tests imported:pip install -e .puts the checkout's ownsrcon the path, so every run imported the package at HEAD. Reverting the files changed what a test could read and nothing about what it could call — which is why document falsifiers proved correctly for months while every behaviour falsifier passed vacuously.PYTHONPATHnow names the worktree's own source. Measured on the Fortran work: 0 of 19 tests failed at base before the fix, 18 of 19 after. - The gate also could not see how work now arrives. It triggered only
on register entries citing closing tests, or added
tests/*_class.pyfiles. The language increments used neither, so roughly 300 tests merged unproven. It now revert-proves every added test file, with an explicitCovers existing behaviour:marker for the legitimate case of a test that documents behaviour which already worked — stated, not assumed. - The decision register contradicted the product. ADR 006 still read "we will not write more range detectors" after five were written; two further passages named v1.0's six languages as current and listed C, C# and Fortran as absent from the default extensions. Amended in place with what overturned each, rather than rewritten — the register's value is that it records both.
- Stale known debt. Declaration extraction was described as gated on "Python, JS/TS/HTML, and Java", and the scanner note said "both suffix sets" where there are now six.
Changed
- A report now names the corpus its anchor is drawn from.
score.referencegainscorpus_languagesandcorpus_note, and the bounded prompt says "the median of a mature open-source corpus of Python, TypeScript and JavaScript". The rubric is uniform across repositories, which is the promise; the corpus is not uniform across languages, which is a limit that was true and unstated. Java, C, C++, C# and Fortran are scored against medians measured on none of their code: audited against that anchor LAPACK reports declarations at 7.18x and fortran-lang/stdlib at 1.10x, and the first number is a true statement about LAPACK against mature OSS web code rather than a statement about typical Fortran.
Disclosed rather than corrected. Per-language references would remove
the awkwardness by trading a stated limit for a silent breach of P2,
and extending the corpus moves CALIBRATION_C and re-grades every
repository — a deliberate release, not a patch. docs/standard.md
carries the reasoning under "What the anchor does not cover".
- Fixed-form Fortran tests state their strength, measured by mutation
rather than claimed: swapping the card-column masker fails 1 of 9,
removing labelled-DO tracking fails 3 of 9, and the rest are
defensive regressions. A second test was added as a load-bearing case,
measured, found not to discriminate, and is documented as defensive —
the claim was removed rather than the test.
What the audit found holding: layering and acyclicity intact across 104 commits under 13 property tests; jscpd reporting 0 duplicate blocks across six sibling scanner modules; the setup question set unchanged through seven releases; and the tool ending its own audit at zero failures of every kind, having split four pieces of code that tripped its gates rather than shaving them to fit.
1.7.0 - 2026-09-02
Practice detection speaks the Fortran toolchain. Every list in
_practice — linter configs, formatter configs, the lint command
pattern, the gate manifests — was written from the Python, JS, JVM and
Go ecosystems. Fortran appeared in none of them.
Fixed
- A Fortran project that lints on every change scored as though it did
not. Measured on a tree carrying
fortitude.toml,.fprettify.rcand a CI job running both: level 2, zero signals — the tool saw nothing.fortitudejoins the lint commands,fortitude.tomland.fortitude.tomlthe linter configs,.fprettify.rcandfprettify.inithe formatter configs. That tree is now level 3. fpm.tomlis read as a gate manifest. A Fortran project declares its build, its tests and — since fortitude 0.9 — its lint settings there, so a coverage or complexity gate written in it was invisible for exactly the reason one inpyproject.tomlwould have been.
It does not lift every Fortran repository, and that is the point.
fortran-lang/stdlib has CI, an .editorconfig and no linter at all;
it was level 2 before this change and is level 2 after. A detector that
promoted every Fortran tree would be measuring the language rather than
the practice, so that case is pinned as a test beside the fix.
1.6.0 - 2026-09-02
Fortran is measured with Fortran's reading. 1.4.0 found Fortran declarations correctly and then handed them to a C-family measurement, which produced numbers that were not approximate but wrong. This release fixes the measurement, gives Fortran the analyzer tier it was locked out of, and claims fixed-form.
Fixed
do— Fortran's primary loop — was not counted at all. The branch pattern was the C family's, so six nested loops scored complexity 1..and./.or.are not&&/||, so five operators scored 3.end ifcontains the wordif, so every branch counted twice. Scientific Fortran is mostly nested loops, so a numerical kernel read as trivial — and the ranking that decides which routines a maintainer is told to fix was drawn from those numbers.- Nesting was invisible. Cognitive complexity was read from brace
depth, and Fortran has no braces, so four flat
ifs and four deeply nested ones both scored 8 — the metric that exists to measure nesting reporting none of it. It now counts nesting from the constructs themselves. - Comments were read as code. The masker is the C family's, where
!is negation; in Fortran it starts a comment.! loop while the residual is largewas three branch points. Fortran now has its own masker. - lizard never ran on Fortran. It reads Fortran and measures a
nested-loop kernel at CCN 5. This project's catalog row for it omitted
fortran, and selection is gated on that row, so lizard came outnot-applicableon every Fortran repository — leaving the language with no complexity reading from any analyzer, while the tool that could supply one sat installed in the pool. Corrected through a newVERIFIED_EXTRA_LANGUAGESmap, for corrections proven by running the tool rather than by trusting an upstream inventory. TODO/FIXMEwere invisible outside the web stack. The one risk pattern that ships by default listed.py,.jsand friends only, so Java, C, C++, C# and Fortran could carry debt markers nothing looked at — silent on exactly the trees where the debt is oldest. All claimed languages are covered now.
Added
- Fixed-form Fortran (
.f,.for,.ftnand uppercase). Same scanner, sameend-keyword bounding; only how a line becomes a statement differs — which is why it is a masker rather than a second scanner. Card columns are honoured (label 1-5, continuation 6, statement 7-72, sequence numbers ignored), and continuation lines are joined onto the statement they continue: a condition whoseTHENsits on the next line otherwise reads as a single-lineIF, and the matchingEND IFthen closes the enclosing procedure, ending it early and reading the rest of its body as top-level code. - Fortran 77 labelled
DOloops (DO 20 I = 1, N…20 CONTINUE) are understood. They do not close withEND DO, so a reader waiting for one never balances — the procedure collapsed to a single line. Terminating labels are now tracked. - A lint that fails the build if any parsed language has no analyzer measuring complexity. The first version asked whether some adapted tool claimed the language and passed with the stale row still in place, because jscpd lists nearly every language and reads duplication only. A language with duplication and no complexity has half the rubric unmeasured.
Verification
Audited fortran-lang/stdlib — 640 Fortran files, 488 scanned. Ranges
land exactly: add_log_unit is reported at lines 260-404, and the source
has subroutine add_log_unit on 260 and end subroutine add_log_unit on
404. Complexity agrees with lizard on 4 of 5 procedures in a real file;
the fifth is ours at 16 against lizard's 10, and all 15 decisions are
enumerable in the source — 13 ifs, two dos and one .or. — so the
difference is lizard under-counting single-line if (…) return.
1.5.1 - 2026-09-02
Documentation, and two guards so it cannot drift again.
Fixed
- The README named version 1.0.0 — five releases stale, at the top of
the first thing anyone reads, while four languages were added
underneath it.
SECURITY.mdis guarded against exactly this (D45) and the README was not, so it now is: the version line is checked againstconfig.VERSION, and the next release either updates it or fails. - The README language table is now held to the parser.
docs/language-support.mdwas already tied toDECLARATION_SUFFIXESin both directions; the README carries its own shorter table, which is what most people actually read, and nothing tied it to anything. A language could ship parsed, be documented indocs/, and be missing from the front page. The guard caught two rows immediately: the JS/TS row named no suffixes at all, and Fortran omitted.F95,.F03and.F08.
Documentation
- A languages line in the header, so what the tool reads is visible without scrolling to line 238.
- A correction. 1.4.0 and 1.5.0 both said lizard does not read
Fortran. It does — verified against lizard 1.24.0, which measures a
nested-loop kernel at CCN 5. What is true is that this project's
catalog row for lizard omits
fortran, so lizard is markednot-applicableon a Fortran repository and never runs. That is a stale data row rather than a missing capability, and 1.6.0 fixes it. - Which analyzer covers which language, as a table. That is the part 1.5.0 changed and the page never said: Fortran is the one language with no metric emitter — lizard does not read it and fortitude reports findings rather than measurements — so its declaration population comes from the built-in scanner alone.
1.5.0 - 2026-09-01
Fortran gets an analyzer. 1.4.0 shipped the scanner and noted that
Fortran was the one claimed language with nothing external beside it —
lizard does not read it. Fortitude is the answer: Rust-fast,
pip install-able, 100+ rules across correctness, obsolescent features,
modernisation, portability and style.
Added
- A fortitude adapter, registered in the pool at
baselinedepth — zero project configuration, fast, runs on whatever Fortran is present. Verified the way this project requires before a tool leaves thealltier: installed (0.9.2), run, and its output parsed. On a 12-module synthetic tree it contributes 84 findings, each with a rule code, a file and a line.
It does not claim types. Fortitude's correctness rules are about
declaring things explicitly — implicit none, intent, kind parameters
— and a reader who saw types in a coverage record would take it to
mean the code was type-checked. Nothing type-checks Fortran here. Only
C191: unreachable-statement maps off style, to dead-code, because
unreachable code is exactly that.
It is also a verdict emitter: it reports findings and cannot supply a
declaration population. The built-in scanner remains the only path to a
population for Fortran, which docs/language-support.md now says
precisely rather than saying Fortran has no analyzer at all.
Notes
The recorded-output test pins the one way fortitude differs from ruff,
whose JSON is otherwise the same shape: the location key is line, not
row. Reading ruff's spelling here would have dropped every line number
silently, leaving findings that name a file and no place in it.
1.4.0 - 2026-09-01
Free-form Fortran is a first-class language — and the first one here with no braces, which is why this release also generalises the walk that every other language shares.
Added
- A dedicated Fortran scanner (
fortran_declaration_ranges). Modules, submodules, programs, subroutines, functions and derived types, each bounded by its ownend..f90,.f95,.f03,.f08, their preprocessed.F90spellings, and pFUnit's.pfare parsed and opened by default. - Functions are named correctly with a type prefix
(
pure elemental real(dp) function norm(v)) or aresultclause (real function accel(h) result(a)). Keywords match case-insensitively, because Fortran is and older code SHOUTS. - An
interfaceblock is stepped over, not descended into. It holds signatures with no bodies; walking in would mint a one-line declaration for every procedure a module merely describes. - Fortran test conventions are recognised, in the same release as the
scanner. fpm puts tests in
test/; test-drive writestest_gravity.f90; pFUnit writes.pfand the camelCasetestGravity_mod.F90; module files are conventionallygravity_mod.f90, so_modcomes off both sides when pairing. Claiming Fortran without these would have reported untested production code on every Fortran repository there is.
Fixed-form is deliberately not claimed. .f, .for and .ftn are
column-sensitive — a comment is C in column 1, a continuation is any
character in column 6 — which is a different scanner with different
failure modes. Those suffixes stay out until they have one, and their
own falsifiers.
There is no analyzer fallback here: lizard does not read Fortran, so the built-in scanner is the only path to a declaration population rather than a zero-install convenience.
Changed
- The shared walk now takes its bounding rule as an argument.
scan_boundedgainedfind_end(braces by default) andignore(kinds stepped over without being graded). Fortran supplies anend-keyword finder that counts program units, becauseif,do,select,associateandblockall close withendtoo — a scanner stopping at the first one would end a subroutine at its firstend ifand read the rest of its body as top-level code. Every other language is untouched, proven by their suites passing unchanged. .F90and its siblings are recognised at all. Suffixes are matched case-sensitively throughout and only lowercase Fortran spellings were in the extension map, so preprocessed Fortran was invisible — not even counted as unread source.
Fixed
- Two documentation lints could not read a suffix containing a digit.
Both matched
`\.[A-Za-z]+`, so.f90and.F90were invisible to them: the language-table contract reported the page as silent about nine suffixes it documents in full, and the "does not market a detector that never runs" lint silently skipped the row it exists to police — a lint that stops linting without failing. - The claimed-languages fixture failed this project's own gate. It had grown one nested conditional per language and reached complexity 24 at the fifth. It is a table now, and the next language is a row.
14 Fortran falsifiers cover keyword bounding against inner
if/do/select blocks, bare end, interface blocks, typed and
result-clause functions, derived types versus variables of them, case
insensitivity, comments, contains, submodules, continuations, and the
unclosed-unit fallback.
1.3.0 - 2026-09-01
C# is a first-class language, and the C family is complete. Third of the three increments — C in 1.1.0, C++ in 1.2.0, C# here. The architecture held to the end: a language is a module and a row.
Added
- A dedicated C# scanner (
csharp_declaration_ranges). Methods, constructors, destructors, and the types —class,interface,struct,record,record structandenum..csis parsed and opened by default. - Generic parameter lists are blanked before matching, so
public List<T> Sort<T>(List<T> items) where T : IComparableyieldsSort; attributes are stripped with their arguments, so[Obsolete("x")]is not read as a method calledObsolete. - Namespaces are handled in both forms: a braced
namespace Geo {is walked into without being graded, and C# 10's file-scopednamespace Geo;declares nothing and is ignored rather than being allowed to swallow the file.
Properties are deliberately not declarations. public int Count {
get; set; } has braces and would bound cleanly, and an ordinary C# tree
holds thousands of them — each one line long, each nobody's maintenance
burden. Counting them would dilute the population every declaration rate
divides by. They are excluded by construction rather than by a rule: a
property has no parameter list, and every pattern requires one. A method
written public int Fast() => count_; is counted, because it has
one.
Changed
_mask_genericsmoved to_ranges_core. Java and C# write generics the same way, and the second consumer got the shared helper rather than a copy — otherwise one of the two grows a fix the other never gets. Java's behaviour is unchanged, proven by its suite passing untouched.
Fixed
- The one test covering the real MCP stdio transport could not run
outside CI. It spawns a child process, and the SDK spawns that child
with a scrubbed environment —
HOME,LOGNAME,PATH,SHELL,USERand nothing else — soPYTHONPATHdid not survive. In a source checkout the child died withModuleNotFoundError: No module named 'maintainability_audit', the client reported onlyConnection closed, and the failure looked environmental rather than like a test that could never pass. It now inherits this process's own import path, which covers both layouts (src/in a checkout, site-packages when installed) and themcpSDK the child needs before it can answer at all. The full suite now runs locally with nothing deselected.
16 C# falsifiers cover properties, both namespace forms, attributes, constructor chains and destructors, generics with constraints, expression-bodied members, interface members, positional records, control flow inside bodies, and both unclosed-body shapes.
1.2.0 - 2026-09-01
C++ is a first-class language. Second of the three C-family increments (C shipped in 1.1.0; C# follows in 1.3.0). The architecture 1.1.0 built held: C++ is a module and a row, with no edit to the dispatcher and none to the shared core beyond the loop it now provides.
Added
- A dedicated C++ scanner (
cpp_declaration_ranges). Free functions, class and struct members, constructors, destructors, operator overloads, and the types themselves —class,struct,union,enumandenum class..cpp,.hpp,.cc,.cxxand.hhare parsed and opened by default. - It takes one rule from each neighbour: types are descended into,
as in Java, because a C++ class holds its methods; bodyless
declarations are skipped, as in C, so a prototype, a pure virtual
(
virtual int area() const = 0;) and= defaultmint nothing. A header of forty declarations is not forty declarations. - Names are reported as a reader searches for them: an out-of-line
definition keeps its qualification (
void geo::Widget::draw()isgeo::Widget::draw), an operator overload isoperator==, and atemplate <...>header is stripped so the declaration under it is what gets measured.
.h stays C. It is the one extension both languages write, and the
C reading under-reports a C++ header where the reverse would read no
more and risk more.
Changed
- One walk for every brace-delimited language.
scan_boundedmoves into_ranges_coreand C, C++ and Java all use it; each language now supplies only its patterns. Java and C each had their own copy of that loop, and C++ and C# would have made four — which is how a fix lands in three of them and the fourth keeps the bug. The two migrated scanners are behaviour-identical, proven by their existing suites passing unchanged.
Fixed
- A macro invocation can no longer swallow the next declaration.
MY_MACRO(x)on its own line has the shape of an Allman signature — a name, a parameter list, end of line — and the first version of the C++ scanner accepted it, letting the next line's braces close it. The macro was reported as a function and the real function beneath it disappeared: an invented declaration hiding a true one. An end-of-line tail is now trusted only when a return type was written or the name is qualified. The cost is a constructor braced Allman inside its class, which is missed — the direction this design errs in. C was checked for the same defect and does not have it; its pattern already requires a return type.
17 C++ falsifiers cover namespaces, templates, operator overloads, out-of-line definitions, initialiser lists, access specifiers, the macro case, control flow and casts inside bodies, and the indentation fallback.
1.1.0 - 2026-09-01
C is a first-class language. The first of three C-family increments
(C, then C++ in 1.2.0, then C# in 1.3.0). A C repository now gets
declaration-level findings from a pip install with no analyzer
installed, instead of a withheld declaration rate.
Added
- A dedicated C scanner (
c_declaration_ranges). File-scope function definitions andstruct/enum/uniontypes, each bounded by its own braces. It reuses the Java scanner's brace primitives rather than inventing a second bounding rule, so the guarantee that covers every other language covers C: a declaration's range never runs past its own body. Storage-class and inline keywords and pointer return types are stripped before matching (static inline int clamp(isclamp;const char *greeting(isgreeting), and the brace may sit on the signature line, on its own line, or after a signature split over several lines — all three are bounded correctly. .cand.hare parsed and opened by default. Both joinDECLARATION_SUFFIXESand the defaultinclude_extensions; a suffix is listed in one only when the other agrees, whichtest_claimed_languagesenforces in both directions..his read as C for now; the C++ increment disambiguates it.
Two things are deliberately not declarations: a prototype has no
body, so a header of 40 prototypes is not 40 declarations, and a
preprocessor line is skipped whole, so #define MAX(a, b) … is not
read as a function called MAX. K&R-style definitions and
macro-generated declarations are missed, each costing one declaration
and never a cascade. The full list is in
docs/language-support.md.
The calibration is unchanged, and that was verified rather than
assumed. Only 3 of the 40 pinned corpus repositories contain any C at
all (27, 16 and 2 files in trees of 4008, 3085 and 878), all three are
analyzer-primary, and re-deriving the constants with C included moves
every reference and CALIBRATION_C by less than 0.0001. No repository
is re-graded by this release for a language it does not contain.
Changed
- Declaration scanning is one module per language over a shared core.
_ranges.pyheld the brace machinery and the JS/TS patterns together, and sat at its 500-line budget — so every new language either grew that file or reached into it for private names. It is now_ranges_core(the rule: a range is bounded by its own body), with_ranges_js,_ranges_javaand_ranges_cowning one language's patterns each. The dependency runs one way: a language imports the core, and the core never learns a language. - The dispatch is a table, not an
ifchain.declarations.SCANNERSmaps a suffix set to its scanner, so adding a language is a module and a row — no edit to the dispatcher, the core, or any structural test. The Java-only wiring guards were generalised to read that table and hold every claimed language to the rule, so C++ and C# inherit them on arrival. - The shipped defaults moved to
_config_defaults.config.pywas carrying both the default configuration and the loading/validation logic, also at 500 lines, andinclude_extensionsis a list every new language must edit.configre-exportsDEFAULT_CONFIGandDEFAULT_IDIOM_GROUPS, so every existing import is unchanged.
Together these are the reason 1.1.0 is a structural release and not just a scanner: C++ and C# are now additive rather than another argument with a file-length gate.
Fixed
tools/calibration/measure.py --checkno longer rewrites the corpus it checks against. A check now reports and leavesmeasurements.jsonalone; previously it overwrote the checked-in analyzer-primary measurements with whatever that run produced. Run without--with-analyzersit therefore reported a large falseCALIBRATION_Cdrift and left the tree holding the corpus that appeared to prove it. It also now warns when a built-ins-only run is being compared against analyzer-fitted constants.
1.0.1 - 2026-09-01
A housekeeping release: no runtime behavior changed (the reconfigure and TypeScript-semantic fixes shipped in 1.0.0). This ships the release-pipeline fix and internal headroom, and refreshes the docs.
Changed
- The release pipeline is idempotent and cuts the GitHub Release itself.
release.ymlnow publishes to PyPI withskip-existing(a re-run or re-pointed tag no longer hard-fails with400 File already exists) and a new tag-only job creates or updates the GitHub Release from the CHANGELOG section for the tag. Agit push origin vX.Y.Zruns build → PyPI → GitHub Release end to end, every job safe to re-run. _mcp_setupsplit for headroom (#127). The setup persistence (_apply_bounds/_apply_command/_persist_answersand the value helpers) moved to_setup_persist, and theSetupRequiredexception to_setup_errors;apply_answersstays in_mcp_setupand delegates. Output is byte-identical — a pure internal refactor,_mcp_setup500 → 364 lines.
Documentation
- README gained a Language support section — Python is first-class
(AST-exact), Java and the JS/TS/JSX/TSX family are bounded, TypeScript
semantics come from a local
tsc, and every other language is under-reported unless an external analyzer (lizard/jscpd/ESLint/PMD/…) covers it — and a Support/Sponsor section with.github/FUNDING.yml. - The architecture Known-debt note no longer pins a stale
CALIBRATION_C(2.2658 → 5.8843), pointing at_calibration.pyas the source of truth.
1.0.0 - 2026-09-01
The 1.0 milestone. Not a rewrite — a line drawn under a long arc of subtraction. 1.0 is what remained after every claim this project could not stand behind was removed: the scoring engine that graded repo size was rebuilt into per-dimension rates calibrated against a query-selected corpus (0.5.0); the headline claim that near-duplication distinguishes AI-written code was retracted when a matched control erased the gap (0.6.0); the score learned to be withheld when the evidence cannot support one, and external analyzers became the primary evidence with disagreement widening the range rather than being averaged (0.7.0); the surfaces converged on one setup, three transports with chat as the primary door (0.8–0.9); and a real-repo acceptance round (Phase 8, on a private repository) hardened the last edges. What ships as 1.0 is the thing the whole project is for: a deterministic, offline, one-rubric audit whose output is a bounded work order — a copy-paste prompt per finding that tells an agent to fix exactly these and refactor nothing else.
1.0 — the bounded work order, delivered
- The report is the complete work order; chat/CLI is a bounded UI. The HTML and Markdown reports carry the entire backlog with a self-contained copy-paste prompt for every item; the chat/CLI surface stays a tight summary plus a pointer, so a host's payload cap can never truncate the prompt (the tool's whole point). Per-item prompts are deterministic — built from finding fields, no LLM, same bytes for the same inputs.
- HTML report charts rebuilt for legibility. The pillar legend moved to
its own band below the plot (it used to overlap the lines), the x-axis is
dated, and the y-axis shows the full 0–5 gridline ladder. SVG extracted to
_charts— still deterministic, offline, library-free. - TypeScript semantic coverage now finds workspace projects and a local
tsc. A repo whosetsconfig.jsonlives in a workspace (web/,packages/*) and whose compiler is innode_modules/.binno longer reportsunknown. Never installs, never fetches; a missing compiler still readsunknown, never a fabricated clean run. - Reconfigure preserves hand-tuned config. Re-running setup used to write
the config from the wizard's answers alone, deleting
pathsscoping,thresholds, gates, commands, risk patterns and the instruction pack. It now merges the answers over the existing config. Falsified. - The report header states its provenance — run date, commit, branch, root, and the scoring standard — in both skins. The run date is a disclosed determinism exception (P1).
- The opted-in test suite gets its own timeout (default 600s) instead of dying to the 120s analyzer cap, and the Class 5 opt-in is surfaced on repositories configured before it existed.
- Internal headroom.
renderersand_mcp_setupwere split along real seams (_markdown_sections,_setup_persist/_setup_errors) so neither rides its own 500-line gate; output is byte-identical.
Fixed
- A boundary refusal reaches the caller as a refusal, not as a crash. The
MCP SDK distinguishes a failure the server anticipated — whose message
reaches the caller — from a crash, which is reduced to
Error executing tool <name>(or a resource's bare URI) with the traceback kept server-side. This product raised its most anticipated failure, the allowed-roots boundary, as a plainPathNotAllowed, so it was classified as a crash and its text withheld: a user pointing the tool outside its roots was no longer told why, nor that--allow-rootandMAINTAINABILITY_MCP_ALLOWED_ROOTSexist. The report resource lostSetupRequired's message the same way. Both MCP seams now declare their anticipated refusals (ToolError,ResourceError) while the plain functions keep raising the domain types for the CLI and library callers. The anticipated set is derived from every named exception in the package: each is in the tuple, or excluded with a stated reason, so a sixth type added tomorrow fails the build instead of a reviewer. Surfaced whenmcp2.1.0 stopped interpolating crash text; the dependency range is unchanged, because the SDK's behavior is correct and the misclassification was ours (D48). Every member of that set which can reach a seam is now driven through one —StaleBaselineandInvalidAuditArgumentout of the audit tool,SetupRequiredout of the report resource's own handler, each verified by deleting its type from the tuple and watching the test fail. What proves "declared" differs by seam, and two drafts of these tests got it wrong in opposite directions. The first asserted only that the caller's message is not the SDK's generic crash string, which onmcp2.0.0 is true of the tool either way — it passed withStaleBaselineremoved. The tool tests now call the registered coroutine directly, soToolErrorplus the chained domain type is our own translation on any 2.x. Applying that same reasoning to the resource was the second mistake: there the SDK wraps and chains an escaping exception, so a crash also arrives asResourceErrorwith the original__cause__. That test excludes the 2.1.0 crash wrapperUnexpectedResourceErrorand requires the refusal's own text, which is what separates the two on 2.0.0. The third mistake was which text: it looked foraudit_repository, and the 2.0.0 wrapper interpolates the URI, so a repository directory of that name puts the substring into a crash message — reproduced, a crash satisfying the assertion meant to catch crashes. The fixture now builds under exactly that directory name and the discriminator ishas not been set up, a fragment of the refusal's own sentence that a URI cannot carry; the door-naming check stays as D30's separate requirement. A falsifier that shares a vocabulary with the thing it tests can be satisfied by the failure it exists to catch. - Three documentation claims that outran the code. An audit of the change
above found each of them. Architecture invariant 12 justified
PolicyError's place in the anticipated set by saying a missing type stops teaching — butPolicyErrorcannot reach any seam, because_analysis.analyze()catches it and returnsAnalysis(error=...); it is declared ahead of a path that does not exist yet, and the invariant now says so while keeping the claim forStaleBaseline, which does travel.docs/cli.mddescribed--install-skillas checking its copy against the reviewedskills/maintainability-agent/SKILL.md; the command reads the packaged_skill_datapayload and consults no repository, and what holds that payload to the reviewed file is the suite. And_audit_tool_for's docstring explained the function in terms of this repository's own 500-line gate rather than what it does.
Changed
- The three entries below are renumbered D47–D49; the register jumps from
D31 to D47. They were written as D32–D34 on a branch cut from the same
commit as
fix/round-two-findings, which had already minted D32–D46, so for a day two live branches defined six different defects under three identifiers. Nothing conflicts in git — the branches touch different regions of the register — so nothing would have failed until a reader tried to work out which D34 a changelog line meant. The release gate is "zero open entries in the register", which is only countable if an entry number means one defect. The gap is reserved, not missing, and closes when that branch lands. - The chat surface calls the audit before it inspects configuration, on every
door it is reached through. D21 established the rule — call
audit_repositoryfirst, never deliberate over a missing or stalemaintainability-agent.json— but its falsifier read only the opt-in skill.SERVER_INSTRUCTIONSnever carried the rule, and the slash prompt taught the opposite: "First offer the presentation choice ... Then callaudit_repository". Both MCP surfaces now state the rule in D21's words. Found by inspection while diagnosing a field report that this does not explain — that host loaded a pre-D21 skill from a wheel older than the fix (D47). - The skill inside the distribution is checked, not assumed. Two tests read
_skill_data/SKILL.mdfrom the source checkout, so the payload a host actually loads — carried in the built distribution and written out by--install-skill— was asserted by nothing. The staged build must now carry it, byte-identical to the reviewed skill, with D21's call-first rule in it and no "configuration check first" anywhere. Same shape as D23, on the payload D23 did not cover (D49).
0.9.1 - 2026-08-21
Security
- A configured history path can no longer escape its repository.
paths.historyis read from a file inside the repository under audit, and every consumer builtroot / configuredwithout validating the result — so a repository could name../outside.jsonl, an absolute path, or a path through a symlinked directory, and an audit with history enabled would create and append that file outside the authorized root. Found by audit through the public MCP seam.config.repository_pathnow resolves and bounds every configured repository-scoped path before any existence check, directory creation or append, and all five construction sites use it: the MCP audit tool, the MCP report resource, and both CLI paths. Traversal, absolute escapes and symlink escapes are one comparison, refused with the same structuredPathNotAllowedthe roots boundary already used.
Added
--install-skill/--force-skill. The agent skill ships inside the package, byte-pinned to the repository'sskills/tree by test, and one command syncs it into an agent's skills directory — closing the drift that left a dead CLI-first recipe installed for three days after the docs sweep fixed it. A differing installed copy is refused with the list of differences;--force-skillperforms the full sync.
Fixed
- Skill installation is bound, atomic and complete. The skill root is
opened once with
O_NOFOLLOW|O_DIRECTORYand every read, write and unlink is relative to that descriptor, so swapping the pathname afterwards cannot redirect a write; a failed rebind refuses rather than resolving against the process working directory; files are staged and renamed into place, so a destination replaced by a hard link is never written through; and every byte is written or the install refuses instead of reporting success on a truncated file. - Occupancy is every directory entry. An empty directory, a FIFO or a
socket in the skill root used to read as a fresh install and be modified
without consent, and a FIFO named
SKILL.mdhung the installer because reading it meant opening it. Occupancy is decided fromstatmetadata without opening anything. - Analyzer selection composes the runnable set from the repository. The language inventory and concern mapping decide what runs before any probe or spawn; a catalogued tool with no adapter is stated rather than counted as runnable; and refused path identifications are visible in JSON, Markdown and HTML alike.
Added
--install-skill/--force-skill. The agent skill ships inside the package, byte-pinned to the repository'sskills/tree by test, and one command syncs it into an agent's skills directory. A differing installed copy is refused with the list of differences;--force-skillperforms the full sync — overwriting edits and removing files the package no longer ships. Closes the field-found drift that left a dead CLI-first recipe installed for three days after D12 fixed it. The repository's own self-scan excludes the packaged mirror (_skill_data/): its byte-identity toskills/is the pin, not duplication.- Refused path identifications are visible.
unidentified_source_pathslists tool path spellings that matched zero or several files, so a refusal is never mistaken for an identification. - D15's original requirement, proven as written. An audit caught the D15
close rewriting the entry; the original goal-directed-selection requirement
is restored to the register and holds behind
tests/test_d15_goal_directed.py.
0.9.0 - 2026-08-19
Added
- The JVM analyzer track (decision 9): PMD, Checkstyle, SpotBugs. Three adapters in two shapes. PMD pins two design rules (cognitive and cyclomatic complexity) over source, live-proven on Apache commons-cli. Checkstyle runs the bundled Google ruleset from a neutral working directory (the audited tree's suppressions are never read) and claims exactly what it emits: style and documentation. SpotBugs reads bytecode that already exists — the agent never builds (ADR 012); missing bytecode becomes a build-then-rerun entry in the environment work order, runs record staleness evidence (source vs class mtimes) on the coverage row, stale findings say so, and the SECURITY category is excluded by a bundled filter (that boundary belongs to secure-code-agent).
- D15 composition honesty. One report composes both adapter shapes: finding identity is the located rule for rule-backed findings — never a shared concept; rule-less findings fall back to message identity, a package-relative sourcepath is identified with its repo-relative file when exactly one match exists (refused on ambiguity), composed coverage states stale_artifact_evidence, and artifact-read applicability follows artifacts rather than the source-language inventory. Closes the seventeenth and final chat-surface register entry.
- Root grants over MCP (decisions 4–5). An out-of-roots audit asks one
structured question — this session (default) / always / no. Session grants
live in the process; always grants persist to the user tier and survive
first-run setup; consent binds to the resolved path the question named, and
a retargeted symlink voids it. History recording follows persisted consent
on both doors — written
history.record: falseoutranks a TTY (decision 7). - Baseline adoption and per-format payloads over MCP (D7, D8), install remedies with the concepts they restore (D9), and chat-first documentation, help files, and instruction packs across every operator surface (D4, D12, D16, D17).
Changed
- Coverage rows state the languages the integration actually reads, tool versions skip ASCII-art banners, concern pools select by what a pinned invocation can emit, and the analyzer-pool tier promise names its two disclosed exceptions (Checkstyle and SpotBugs live spawns remain unproven until binaries exist).
- Chat-first setup and audit repairs after v0.8.1. Local MCP first-run elicitation now closes D2 and D11 and ships D3's structured-setup portion, persists accepted analyzer, depth, license, economic-context and presentation choices, and returns unanswered setup for the host to ask again. The H1/M1/M2 follow-up makes prompt remedies honor analyzer coverage, re-elicits until answers are written, and uses chat as the unconfigured presentation default.
0.8.1 - 2026-08-16
Changed
- Chat-surface foundations D1 and D13. Analyzer-pool execution is
config-driven through
build_report, both CLI overrides, the MCP tool, and the MCP report resource. XDG user configuration, repository-over-user precedence, and persistent first-run state also ship. These changes landed after v0.8.0, are not in the v0.8.0 tag, and ship in v0.8.1.
0.8.0 - 2026-08-16
Pre-1.0 test release: everything merged since 0.7.0, packaged for field testing. The known defects of the primary (chat/MCP) surface are recorded in docs/defect-register-chat-surface.md (D1-D15) and are the next fix cycle; this release ships them disclosed, not resolved.
Added
-
ADR 009 identity resolution. Finding identity is structured (kind, path, name, ordinal, body digest) and matching follows git's rename evidence:
git mvand same-name reorders no longer read as fixed-plus-new in--fail-on-newor recurrence. Baseline v3 stores identity records and the commit; history records are schema 3. -
ADR 003 option C (TypeScript prototype). Type-backed universal facts, checked-in
semantic_policyviolations, and prompt-only design-review candidates, from recorded type analysis or a local tsc. Semantic results render on all three skins and never change the score, the gates, or the grade. -
ADR 004 v1 economic context. An optional, clearly-labeled low/base/high scenario computed from configured labor rates beside the score; exposure-ordered work order; scenario vocabulary only. The score is byte-identical with and without it.
-
Executive HTML report. The HTML skin leads with estimate, grade, gate state, Severe/High/Medium/Low counts from published class risk, and direction; findings are HTML tables sharing
render_markdown's identities; charts gained axes, ticks, titles and a pillar legend. Severity labels are presentation only. -
flake8 and cohesion adapters (2.7). flake8's default
path:row:col: CODElines become located findings (verdict emitter). cohesion's per-classTotal:percentages become measurements. xenon stays unadapted: it re-ranks radon and would fake corroboration.
Changed
-
Network-boundary disclosure. README, product intent, architecture and the analyzer pool state that this agent does not transmit the audited tree, and that third-party tools it spawns are not network-sandboxed. Claiming an air-gapped child process fails a lint.
-
ADR 011 presentations and history schema 2 shipped. Three report skins (chat default, Markdown file, one self-contained HTML file) render one report dictionary. New history records carry the per-scan score breakdown, while schema-1 records remain readable as gaps. 1.0 still waits on acceptance, the 7.5 hostile audit, and a human tag.
-
Self-audit restamped (7.1). Provenance commit
9c2257a, 235 files scanned. README table matches the stamp. Still a B. -
PyYAML is a declared
devextra, not a silent test import.tools/build_catalog.pyis the only user. Tests cannot importyaml. A structural lint fails undeclared third-party imports intests/andtools/. -
CI history cache (6.4). The consumer workflow restores and saves
.maintainability/history.jsonlaround a recorded scan (if: always()on save).action.ymltakes opt-inrecord-history.examples/local-ci.shrecords. No new release tag. -
Per-concept analyzer spread moves
maintainability_range(3.4). Independent tools disagreeing on one concept widens the interval; a lone tool is not priced as perfect agreement. The estimate stays the analyzer-primary point. The band matrix is still unused. -
MCP ships all three primitives. Resources expose the rubric, the analyzer catalog, and a Markdown report that is byte-identical to the CLI rendering. The
maintainability-agentprompt is the slash command. Start it withmaintainability-agent mcp;maintainability-agent-mcpremains for existing IDE configs. -
A 1.0 migration note. docs/migration-1.0.md names the post-0.7 breaks:
--analyzersmoves the point estimate, andCALIBRATION_C2.6279 → 2.2658. Schema 3 and baseline v2 do not break again. 1.0 is not tagged. -
The report contract, 0.7 migration note, and tool inventory match the shipped tree. Schema version 3 (nullable estimate), Java has a declaration population,
CALIBRATION_Cis 2.2658 after 3.6, and coverage is reported. A structural lint fails the build if those pages revert to the 0.7-era story. -
First-run prompt (6.1). On a TTY with no config the CLI asks depth and license policy and writes
maintainability-agent.json. Non-TTY and existing-config runs never prompt and never rewrite. -
Environment work order (2.5c). The report names each selected analyzer that could not run, why, the install command, and how to verify it. The agent never installs.
-
The point estimate uses analyzer measurements where the full concept set was measured.
--analyzersis no longer a coverage-only side channel:scoring._primary_pressurestakes the analyzer reading per dimension and keeps the built-in fallback where the analyzers were silent. The range widens to contain both sources; they are never averaged. The Markdown report, the remediation prompt, CLI--help, and the README name that mix rather than claiming the opposite. -
Calibration 3.6.
CALIBRATION_C2.6279 → 2.2658 and the declarations reference 0.0599 → 0.0860, fitted to the analyzer-primary mix after generated and vendored code left the scored population. 13 of 40 corpus members supplied an analyzer declaration reading; 27 stayed on the built-in fallback. Old and new numbers are in_calibration.py.
0.7.0 - 2026-08-13
Scores the evidence cannot support are withheld. --fail-on-new no longer
fires on code that only moved. Two incompatibilities: report schema
version 3 (maintainability_estimate is nullable) and baseline format
version 2. See docs/migration-0.7.md.
Added
-
The MCP server can run the analyzer pool.
audit_repositorytakesrun_analyzersand returns the coverage, findings and measurements alongside the report, withanalyzers_runstated at the top level so a caller cannot mistake a six-detector audit for a ten-tool one. -
Three new default risk patterns, each earned by a defect this project actually shipped.
absence-as-zerocatches a measurement default that conflates "measured none" with "never measured" — the bug behind a one-function repository scoring 5.0/A+, and behind a clean scan reading as unexamined.vacuous-assertioncatches assertions that cannot fail, after one let a gap survive the test written to catch it.silent-truncationcatches a returned collection cut without saying so. They are review prompts, not defect assertions: narrowed until they stay quiet on accumulators and named limits. -
--analyzersruns external quality tools and reports coverage. Nine adapters ship working: lizard, radon, ruff, vulture, interrogate, jscpd, complexipy, multimetric and pydocstyle. Every report states which tools were attempted, which ran, which were unavailable and why, their versions, and which concerns nothing examined — a concern nobody looked at is reported unexamined, never clean. Off by default in 0.7.0 because analyzer measurements did not move the point estimate, not because the pool was unfinished. - A copy-paste GitHub Actions recipe at
.github/workflows/maintainability.yml, the file the README already named.
Fixed
- Every entry point uses the repository's own
maintainability-agent.json. Discovery lived in the CLI at first, which was not enough — the MCP server returned 405 findings where the CLI returned 162 on the same repository. It now lives inconfig. - The repository's own
maintainability-agent.jsonis used without--config. The CLI previously fell back to built-in defaults when no config was named, so a repository with a config file beside it was audited against different exclusions than it asked for. On this project the difference was 422 findings versus 162, most of the excess from a generated data file its config had excluded all along. An explicit--configstill wins. -
File patterns in
exclude_patternsnow reach the external analyzers. Directory and file patterns were both wrapped as directory globs, sodata/generated.jsonbecame**/data/generated.json/**and never matched. -
--changed-onlyno longer reports a whole-repository grade for a diff. It previously returned an estimate andevidence_status: completeover as few as zero declarations, and every PR-scoped CI run inherited it. A scope-limited scan now withholds the estimate, the range and the verified grade, with a reason naming the scope and the remedy. Findings and aspects are unaffected, and--fail-on-gateexit codes are unchanged. - Finding identity survives edits elsewhere in the file. Fingerprints embedded the start
line, so inserting one import above an untouched function made it read as simultaneously
fixed and new — a false failure for
--fail-on-newafter any refactor that shifts lines. Identity now uses the path, declaration name and same-name ordinal instead of the start line. - Two same-named declarations in one file are two findings everywhere, not just in the
baseline.
finding_fingerprintsnumbered overloads#0and#1correctly, but the work order,prompt_targetsand the prompt's escalation check each rebuilt the identity with a hardcoded ordinal of0. So twohugemethods in one file were one finding: the work order named the same declaration twice, recurrence recorded advice about the first one twice and never about the second, and escalating either overload suppressed whichever the prompt compared first. The population, the order rule and the numbering now live indeclaration_identities/risk_identities, and consumers look identity up rather than derive it. -
Risk findings can be tracked across scans at all.
prompt_targetsrebuilt a risk identity from the work-order item's rendered title, so the name it hashed was the label "configured risk pattern" rather than the pattern's own name. No such identity is ever in a report, so every risk target failed the corroboration check and was silently dropped — on this repository, all 21 of them. -
A repository too small to measure gets no score. A tree holding one production function and one test reported 5.0/A+ with every finding count genuinely zero — the arithmetic was right and the number was empty. Rates now require a population the scale was calibrated on: the whole score is gated on the tree's size, and inside a scorable repository each aspect is gated on its own denominator. Findings are never suppressed, only rates.
- The unread-source remedy no longer says the repository is too small. Adding the missing extension was the right next step; calling the tree undersized after that was a second, false diagnosis.
- Work-order items use the hotspot's
start_line. Oversized declarations carriedline: Nonebecause the work order read a key the hotspot never had. - Competing-libraries items read
divergent_idioms. The work order looked upidiom_concerns, a key the report never carries, and dropped every idiom finding. - The AI prompt names complete evidence and the estimate's source. A complete report used to be silent about evidence status. Analyzer findings sat under a built-in estimate with no caveat. The status is now printed in every state, and the built-in-only caveat appears only when analyzers spoke.
Changed
- Promise P1 amended: analysis performs no network access; tool acquisition may.
The audit now invokes external analyzers, and some live in ecosystems whose normal
install path is a fetch —
npxfor the Node tools. The property the promise protected is intact: the analysis itself touches no network and your code is never transmitted anywhere. Acquiring a tool may fetch it on first run, and the version acquired is recorded. Install ahead of time (npm install -g jscpd) to pin a version or build air-gapped. Prerequisites are listed indocs/analyzer-pool.md. - Report schema version 3.
maintainability_estimateandmaintainability_rangeare nullable andevidence_status.statusmay beinsufficient. Consumers assuming a number must handle null. - Baseline format version 2. Old fingerprints cannot be converted, so a version-1 baseline
is rejected with an instruction to regenerate rather than silently suppressing nothing.
Regenerate with
--write-baseline.
Recalibration, and a retraction. The reference corpus is now chosen by a query instead of by the author's taste, which moved every constant in the scale — read the table before comparing a score to one from 0.6.x.
Added — local MCP server
- A read-only stdio MCP boundary for Codex and its VS Code extension.
maintainability-agent-mcpexposes the production audit and its bounded remediation prompt together; it does not restate scanning, scoring or rendering. Canonical repository allow-lists block path and symlink escapes, config files must remain inside the audited repository, changed-only input cannot inject git options, and the server accepts no command strings or output paths. MCP remains an optional install extra, so the base CLI keeps its dependency footprint.
Changed — ADR 001 stage 8 (breaking report contract)
Stage 8 moved the report schema from 1 to 2. The four ambiguous compatibility score fields left with no aliases. 0.7 then moved the schema to 3 when the estimate became nullable; that is the version this release writes. Version 1 is still rejected. There is no migration.
| Removed | Replacement |
|---|---|
score.overall |
score.maintainability_estimate |
score.overall_range |
score.maintainability_range |
score.grade |
nothing — score.verified_grade is the only letter a report carries |
score.grade_blockers |
score.verified_grade_blockers |
- Why
gradehas no replacement. It was banded from the evidence floor, so on an incomplete report it meant "the worst the evidence allows" while reading as the repository's grade. Stage 7 labelled it; stage 8 removes it. A report now either issues a verified grade or issues none, and there is no second letter to fall back to. verified_grade_blockersexplains an issued grade only. When no grade was issued the list is empty, because there is nothing to cap — what is missing is named inevidence_status.reasonswith its measurement path and provenance. Conflating the two is what let an evidence gap read as a quality demotion.- No value moved. Parity was proven against four reports captured from
a6b3c0fbefore any edit — complete, complete-with-NotApplicable, incomplete from unavailable history, and incomplete from a missing summary measurement — checked in undertests/fixtures/stage8_anchors/. Estimate, range, verified grade, categories, aspects, rubric, dimensions, reference, worst dimension and evidence status are identical in all four. The calibration constant re-derives to the same value and all 40 corpus repositories still produce a median of exactly 4.0. - No version-1 migration exists, deliberately. The consumer inventory established that nothing rescores a persisted report, so a migration would have served no caller. Version 1, unversioned and unknown versions all fail with
UnsupportedReportSchema. - Baselines stop carrying a score snapshot. Nothing ever read it back —
load_baselinetakes the fingerprint list alone — and writing one would freeze an obsolete contract into every new baseline. At stage 8 the file version stayed 1 and older files still loaded. 0.7 later rejected version-1 baselines because finding identity changed and those fingerprints cannot be converted; this release writes baseline format version 2. - The presentation boundary lost its fallbacks.
_evidence_viewreads canonical fields directly;compatibility_gradeandNOT_REPORTEDare gone, along with the legacy-field defaults. A malformed score object now fails rather than receiving compatibility semantics. - Guarded against reintroduction. A structural test fails the build if
src/ortools/reads or emits any of the four removed keys, while leaving internal variable names, test descriptions and historical prose alone.
Added — ADR 001 stage 7 (consumer migration)
- Every consumer now distinguishes the estimate from the verified grade. Through stage 6 the Markdown report, PR comment, remediation prompt and agent instructions all headlined
score.grade— which is banded from the evidence floor, so on an incomplete report it means "the worst the evidence allows" while reading as though it were the repository's grade. All four now show the maintainability estimate, its range, the evidence status with its named profile, and either the verified grade or the words Not verified. The compatibility grade stays visible for the deprecation window and is labelled as compatibility/evidence-floor wherever it appears; removing it is stage 8. - A null verified grade is never rendered as a letter, a dash, or a blank. Each of those reads as a result, and the point of the field is that no result was issued.
- Unavailable measurements are named with their typed path and provenance, so a reader knows which measurement to restore rather than that "history" is vaguely absent.
- The remediation prompt states that incomplete evidence is not a code defect and must not widen the work order, and names the usual cause (
actions/checkoutdefaults tofetch-depth: 1). Without that, an agent told "evidence is incomplete" does what agents do and starts changing code — the measured cost of an unbounded instruction is indocs/studies.md. - SARIF carries evidence at run level only. Profile, status, verified grade and reasons ride in the run's property bag; missing evidence never becomes a result, because "this clone was shallow" is not a source-code finding and does not belong in the Security tab beside real defects. Result count, rule ids, levels and locations are unchanged. A report with no score object omits the bag entirely rather than emitting
verifiedGrade: null, which would assert a withholding that never happened. - One presentation helper owns the wording (
_evidence_view), so four consumers cannot arrive at four interpretations of what null means. It reads the public report dictionary, computes no score and imports nothing from the scoring layer. - Nothing about scoring changed, verified by comparing a complete and an incomplete production report before and after: no field of
scorediffers.--fail-on-gatestill reads hard findings only and still exits 0 for a withheld grade, per the rejected ADR 002. - Five audit findings closed before this shipped. A collapsed range no longer claims "no unmeasured evidence" — rounding can make the bounds coincide on a report whose grade was withheld, so the phrasing now consults the typed status and a property test sweeps every required measurement rather than the one example. The agent instructions were only half migrated, showing the grade value while omitting the range, status, profile, reasons and the don't-widen rule; the changelog and standard claimed otherwise and were wrong. Remediation advice now follows the missing measurement paths instead of telling every incomplete report to check its clone depth — including one whose only gap was
summary.test_file_count. The SARIF preservation test compared nothing: it now diffs results and rules against output captured from commit 91430f3. And the roadmap still listed finished stages as pending. - Deliberate no-ops, confirmed in source rather than assumed: no badge consumer and no API grade consumer exist, and
load_baselinestill reads fingerprint strings only.
Fixed — ADR 001 Stage 6 closure
- fix(scoring): typed ownership evidence is now the only source of applicability.
NormalizedEvidencecarried bothhistory.single_author_filesand a companionhistory_presentBoolean. Grading used the Boolean to exemptknowledge_concentrationwhenever any history block existed, so deleting onlysingle_author_filesproduced an explicitUnknownyet retained an A+ compatibility grade with no unmeasured-ownership blocker. The companion flag is removed. Only a genuineNotApplicablestate receives the no-population exemption;Unknownnow demotes top grades to B and names the missing aspect, as the standard has always claimed. - correction: Stage 6 changed only externally supplied incomplete compatibility input. The
404d82chandoff said compatibility JSON and Markdown were unchanged. Comparingbuild_reportoutput before and after Stage 6 shows no compatibility-field difference because the producer always emits all three documentation flags. A versioned report supplied externally withhas_readme,has_changelog, orhas_docs_diromitted does change intentionally: its documentation aspect becomes unmeasured and its range, grade and blockers may move. No rubric weights, grade bands or calibration constants changed. - fix(scoring): NotApplicable no longer widens the uncertainty interval. A young repository with readable history but no settled files reported complete evidence and a verified A+ beside a range of
[4.8, 5.0]. The aspect layer represented bothUnknownandNotApplicableasNone, and the rollup priced both across 0..5. The typed state now supplies a NotApplicable exclusion to the one shared rollup: the aspect is removed from its category denominator for the point and both bounds. It earns no clean score, pays no unknown price, and complete evidence collapses the range.
Retracted
- retract: the claim that near-duplication distinguishes AI-written code. 0.6.0 reported production cross-file near-duplication at 1.49% for AI-written applications against 0.20% for human-written OSS, and the README called it "the first signal that separates AI-written applications from mature human-written OSS". The AI cohort was six young applications; the control was twelve libraries with a decade of maintenance behind them. Authorship, age, domain and size all differed at once, and the release attributed the gap to the one variable it found interesting — while a paragraph further down admitted the confound. Re-run against a control matched on age, popularity (both cohorts median zero stars) and language, the difference is not significant: p = 0.546, and p = 0.871 inside a common size band. What moved since 0.6.0 is not the AI figure (1.49% → 1.73%) but the control, 0.20% → 0.83%. The signal was maturity, not authorship.
- no other metric earns the claim either, though one is left genuinely open. File-failure rate (p = 0.043) and duplicate-block rate (p = 0.042) fall under 0.05 — more than chance alone would typically yield across five tests, but both are the metrics most correlated with codebase size (r = 0.58, 0.49), and the AI cohort carries 1.8x the declarations because size was not matched at selection, only handled afterwards by re-testing inside a shared size band. Banded, file failures converge on near-identical medians (1.82% vs 1.77%, p = 0.123) — that one really does look like size. Duplicate blocks keep a 3.3x banded median gap at p = 0.117: underpowered, not resolved. And the design's stated hole is that the control is "no AI trailer found", which Copilot and pasted LLM output would pass — so the honest conclusion is "this design could not measure a difference", not "there is no difference".
- checkable rather than asserted: the cohort definitions are pinned in
tools/calibration/ai.jsonandhuman.json(URL, commit, stars, trailer fraction per repo),cohorts.jsoncarries all 78 measurements, andanalyze_cohorts.pyreproduces the tables offline with no network. The rank-sum test carries tie and continuity corrections and is pinned numerically against scipy — the first version lacked the tie correction, inflating p by up to 2.4x on tie-heavy metrics, in the null's favor. - the near-duplicate detector itself is unaffected and still shipped. A helper written twice under two names is worth finding regardless of who wrote it. What is withdrawn is the claim that finding it tells you anything about authorship.
Fixed — second hostile-audit response
A second adversarial audit probed the rubric within hours of it landing. Its stale claims are answered in the tracker; its live findings are all fixed here:
- fix(scoring): the reported overall is always the mean of the reported categories. The zero-test testability cap used to mutate the displayed category after the overall was computed, so exactly the repos being penalized got an overall that contradicted the published rollup formula. The cap now applies before the rollup.
- fix(scoring): an empty test-shaped artifact no longer buys an A.
test_file_countcounted any path-matching file (a Markdown note undertests/, an emptytest_x.py), and a 1.5-point floor priced "tests exist" without asking whether they contained anything. Test files must now be source files, and test files holding zero declarations score the same as no tests: probes for zero tests, one empty test file, and md-in-tests/ all read B / testability 2.0 / named blocker. - fix(scoring): history aspects score from full counts, not truncated display lists. Hotspot and coupling rates were computed as
len(capped-at-25 list) / files_changed, so a repository with 100 real hotspots in 1,000 changed files scored as 25/1000 and the pressure fell as repositories grew — the size-bias class this scale exists to forbid, reintroduced by its newest metric.history_sectionnow recordsqualifying_hotspotsandcode_coupling_pairsbefore truncation and the scorer reads only those. - feat(scoring): unknown evidence blocks the top grades. A shallow clone could omit coupling, hotspots and ownership and outscore the same repository with its history visible. Unmeasured aspects now demote A-grades to B with a blocker naming them; "looked and found nothing to measure" (a young repo with no settled files) is distinguished from "couldn't look" and does not block.
- fix(calibration): the anchor is derived through the full rubric, not a structural-only approximation. The corpus measurements now carry an
evidenceblock per repo (test presence, dead code, near-duplication, idioms, documentation — captured by re-measuring all 40 pinned clones), and the derivation prices it via the sameevidence_aspect_scoresfunction live reports use.CALIBRATION_C2.966 → 3.1994. History aspects stay out of the anchor (shallow pins) and renormalize away exactly as they do for any shallow clone. The re-measure also reconfirmed every dimension reference byte-for-byte, disproving the audit's repeated staleness claim. - docs: the "override it in config" sentence was false and is corrected. Rubric weights and bands are source, not config; per-repo override is roadmap, and any future mechanism will label its output a house variant.
Fixed — third hostile-audit response
- fix(scoring): unknown aspects price at the corpus anchor instead of renormalizing away. Renormalization let a shallow clone of clean code score 5.0 while the same code with worst-band history scored 4.2 — hiding the log was worth +0.8. Anchor pricing puts the shallow point estimate between worst and clean (worst 4.2 < shallow < clean 5.0). An earlier revision of this entry claimed "hiding evidence can no longer raise the score"; a fourth audit correctly showed that false — hiding worse-than-anchor history still gains up to the anchor-to-worst gap, which no single imputed value can prevent. The honest closure is
score.overall_range: unknowns priced at 0 and 5 bound the overall, so concealment visibly widens the interval instead of silently improving a point. A fifth audit called "closure" spin here, correctly — the interval disclosed the exploit to a human reader whilegradestill improved under concealment for every machine consumer. The actual closure is grading from the interval's floor, in the fifth-round entry. Pinned by tests (ordering, bound, and interval collapse under full evidence).CALIBRATION_Cre-fitted: 3.1994 → 2.8545. - fix(scoring):
overall == weighted mean of the printed categories, exactly. An audit produced categories displaying 3.5/4.2/5.0/4.5/2.0 with overall 3.9 against a displayed mean of 3.8 — the overall came from hidden unrounded values. It is now computed from the categories exactly as displayed, so the published identity is arithmetic a reader can check on the report itself. - fix(experiment): the fix-scope runner and analyzer now implement their protocol. Subjects are validated against pinned manifest commits before every copy; diffs are taken against a recorded base SHA rather than a movable HEAD (verified no agent had moved HEAD, so no recorded run was affected); the rerun-once rule exists; non-completed runs are excluded and listed; a nothing-touched run no longer scores perfect scope. The analyzer computes both statistic phrasings the protocol contains — marginal medians (the registered inequalities) and paired per-repo differences — and returns INCONCLUSIVE if they disagree: ambiguity in a pre-registration resolves against the experimenter.
- docs: synced to the implementation. "Signals reported but not yet scored" said unscored about three aspects the rubric scores; the scoring-inputs list omitted eight of thirteen aspects; both fixed with the drift named.
Fixed — sixth hostile-audit response
The fifth round's response claimed all five findings were closed. Two were not, and the sixth audit proved it. Both were cases of fixing the demonstrated instance instead of the class — the exact failure the previous entry congratulated itself for avoiding.
- fix(scoring): withholding evidence could still raise the floor. The untested testability cap only applied when
test_file_countwas present, so deleting the field escaped the penalty that reporting zero tests incurred, and the evidence floor rose. Grading the floor is worthless if absent evidence can raise the floor. Unknown test evidence is now three-valued and priced by the same dial as every other unknown — typical for the point estimate, worst case for the floor. Sweeping every summary key then found three more fields with the identical shape (file_failures,files_scanned,risk_findings), where.get(key, 0)silently turned "this report does not say" into "there were none": a perfect score for saying nothing. Absent inputs now make their dimension unmeasured. The replacement test sweepssummaryitself rather than a hand-maintained list, and the audit was right that the old test's name — "hiding evidence can never raise the grade" — claimed far more than hiding one history object proved. - fix(fix-breadth): the shallow boundary commit fabricated its own diff, and it changed published numbers. The oldest commit a shallow clone holds has no parent, so git diffs it against the empty tree and
--numstatreports the whole tree as added: a synthetic fix measured 1 file / 75 lines deep and 2 files / 39 lines at the boundary. Two AI-cohort repositories had a fix commit on that boundary, and the fabricated whole-tree diff counted as a "broad" fix — the defect inflated the AI cohort's broad-fix share in the direction that flattered the hypothesis. Clones now fetch one commit deeper than the window and grafted commits are excluded. Corrected:broad_fix_sharep = 0.025 → 0.028 unbanded, two per-repo shares down (0.114 → 0.102, 0.434 → 0.427); banded tests and quoted medians unchanged. The previous entry's claim that re-measurement "reproduced the published results identically" was true only of the cache-depth repair and is corrected in place below. Newtests/test_fix_breadth_window.pypins the property across four cache depths with synthetic repositories instead of pinning the one repo whose failure had been demonstrated.
Fixed — fifth hostile-audit response
The fifth audit accepted the fourth round's corrections and then found that two of them had introduced new defects, plus three older claims still weaker than stated. Every finding was reproduced against the pushed snapshot before being fixed; all five were true.
- fix(scoring): the uncertainty interval no longer excludes the score it bounds. The untested-repository testability cap was applied to the point estimate but not to the interval endpoints, so a perfect-structure, zero-test repository reported 4.4 with a range of [4.5, 4.5] — and because the endpoints were equal the markdown renderer hid the range, leaving only JSON consumers able to see the contradiction. Both endpoints now run the identical pipeline with only the unknown price swapped, so
low <= overall <= highholds by construction. The collapse test that existed used a tested repository and walked straight past the boundary; the new test checks five configurations including both untested cases. - fix(scoring):
knowledge_concentrationwas a decorative aspect and now carries weight. It was measured, printed under "Aspect Scores", documented as one of thirteen scored aspects, and weighted in no category at all: moving a repository from every settled file having many authors to every settled file having one changed the overall by exactly zero. Thirteen were advertised; twelve did the work. Bus factor now takes .10 of analyzability and .10 of modifiability, andtest_every_scored_aspect_carries_weight_in_some_categoryfails the build if any advertised aspect is ever unweighted again — the structural block, not just the instance fix. - feat(scoring): the grade is banded from the evidence floor, closing concealment at every boundary. The fourth round shipped
overall_rangeand called it the closure; the fifth correctly named that spin. The interval warned a careful human whilescore.overallandscore.grade— the fields CI gates, badges, rankings and API consumers actually read — still improved when evidence was withheld (3.9/C visible, 4.5/B hidden).gradenow comes fromoverall_range[0]. Hiding an aspect can only widen the interval downward, so concealment is now monotonically unprofitable rather than merely disclosed, and a blocker names both numbers whenever the floor and the point estimate differ. Blockers also render in the report itself for the first time — they previously reached only the remediation prompt, so a demotion arrived unexplained in the artifact people open. - fix(calibration): "the same pipeline" is now one pipeline rather than two that agree at the median. Three consecutive audits found the derivation differing from the live scorer by exactly one step — category rounding, then the untested cap (corpus member
tabby: derived 3.9, live 3.8), then per-aspect rounding inside the curve. Each time the median survived and the per-repository claim did not._derivenow calls_formula.overall_from_aspectsdirectly, the curve is a single shared function, and a new test compares derivation againstscore_reportfor all forty corpus repositories instead of at the median.CALIBRATION_C2.8712 → 2.6279; the reference medians re-measured byte-identical for the third audit running. - fix(fix-breadth): the deterministic window is now actually deterministic. The deepening step was gated on the cached HEAD differing from the pinned commit, so a shallow cache already at the pin was used untouched: a depth-one clone of
open-mercato/cezarat its recorded pin produced 0 fix commits against the deep cache's 96, silently dropping the repository from the population rather than measuring it. Depth is now verified and repaired independently of HEAD, and a subject that cannot be deepened is refused rather than measured short. Re-measuring under that repair reproduced the published cohorts, comparisons and banded tests identically. That was not the whole defect: a sixth audit found the shallow boundary commit fabricating its own diff, which had inflated two AI-cohort repositories' broad-fix share and moved an unbanded p-value. Corrected in the sixth-round entry. - chore: CI now runs on every branch. The audit noted that pushing
e4a7fbftriggered no workflow at all, because the push trigger was limited tomain/masterand no PR was open — so the tool's own gate went unenforced on exactly the commits under review. - refactor:
scoring.pysplit into_pressures,_aspects, andscoring. Not housekeeping: the tool's own gate failed the build on this repo twice during this round — an 85-linescore_reportover the function ceiling, then a 517-linescoring.pyover the 500-line file ceiling. The split follows the real layering (counts → aspect scores → grading), and it also removed the import cycle that had forced_deriveto import the scorer from inside a function body. Self-audit: 4.6 / B, 94 files, zero hard-gate failures, coverage 95.34%.
Fixed — fourth hostile-audit response
The fourth audit examined the pushed snapshot itself and caught this changelog telling two direct falsehoods, both corrected in place above with the correction named rather than silently rewritten:
- "Hiding evidence can no longer raise the score" was false. No single imputed value can make concealment neutral for repos whose true evidence is worse than the imputation. Closed honestly instead:
score.overall_rangeprices every unknown at 0 and at 5 and ships in the report and the markdown summary, so hidden evidence visibly widens an interval rather than silently flattering a point. New tests pin the concealment ordering, its bound, and the interval's collapse under full evidence — the audit correctly noted the prior fix shipped with zero test changes. - "Three generic runs got worse" was false — it was two (plus one bounded run at net −1, miscounted into the generic column). Corrected in the standard and here.
- The preregistration narrative overstated itself. The protocol and decision rule predate the first run; the analyzer does not — its first version landed about a minute into the runs and it was rewritten mid-run. The standard now states the chronology instead of the flattering summary of it.
- The anchor now goes through the literally-same pipeline. The derivation rounds categories to one decimal exactly as
score_reportships them (an audit found 6/40 corpus repos differing between paths while the docs said "same pipeline"). The rounded pipeline is a step function, soCALIBRATION_Cis the midpoint of the plateau where the corpus median hits 4.0 exactly — asserted by a new test as== 4.0, not a 3.9–4.1 band. 2.8545 → 2.8712. A fifth audit showed "literally-same" was still false at this revision: the derivation skipped the untested testability cap, andtabbyderived 3.9 against a live 3.8. The median reproduced anyway, which is exactly why a median is not the test. See the fifth-round entry for the fix. - Experiment evidence is now durable in-repo.
tools/experiments/fix_scope/artifacts/carries every arm's full diff against its pinned base and every bounded prompt; regenerated prompts match recorded lengths byte-for-byte. The agents' full transcripts were never captured (2,000-char tails only) — that loss is permanent and stated. - Fix-breadth measures a fixed window.
git log -n 300from each pinned HEAD, so cache deepening cannot shift results (the audit demonstrated it could); the false "capped at 300" note now truthfully describes window vs. clone depth. Incomplete as shipped: a fifth audit showed a shallower same-HEAD cache could still erase the window entirely, because the deepening step was gated on the commit matching rather than on the depth. Fixed in the fifth-round entry. - CI lint now covers
tools/, and for the record: the coverage percentage gates themaintainability_auditpackage only — the calibration and experiment programs undertools/are exercised by their own runs and spot-verified by audits, not by the coverage gate, and no one should read 95% as claiming otherwise.
Measured — the bounded prompt, tested: INCONCLUSIVE on the registered rule
- feat(experiment): the product's central promise met its first controlled test. Pre-registered protocol (committed before any run, analyzer committed mid-run), six pinned repositories, paired
codex execruns ongpt-5.6-sol: generic "improve maintainability" vs this tool's bounded prompt. Registered verdict INCONCLUSIVE — bounded was not narrower on files touched (median 3.0 vs 2.5), which the rule required, but was better-targeted (out-of-scope 0.484 vs 0.500) and closed far more findings (median 7.5 vs 0.0, positive in 5/6 pairs, best +78). Two generic runs made their codebase measurably worse (an earlier revision of this entry said three, miscounting a bounded −1 as generic). The registered rule braced for generic thrashing; what showed up was generic timidity — so the bounded prompt's measured value in this test is effectiveness, not narrowness. Limits stated with the result, including that findings-closed is this tool's own ruler and the bounded prompt names what the ruler measures. Every arm re-derived against pinned bases after an audit; zero mismatches. Seedocs/studies.md"Does the bounded prompt work?" andtools/experiments/fix_scope/.
Measured — fix breadth: a direction that did not survive pinning
- feat(study): fix-commit breadth, per cohort — reported as an exploratory trend, after its first significance failed to replicate. "Broad rewrites for narrow bugs" is a diff property, so
tools/calibration/measure_fix_breadth.pymeasures it from history: files and lines touched per fix-labeled non-merge commit. A first run over unpinned caches showed nominally significant gaps and was briefly described as "the first signal to survive the controls"; an audit correctly noted the histories were not reproducible from pinned inputs, and the pinned re-run — per-repo commit and actual history depth now recorded infix_breadth.json, size-banding computed by the script rather than by hand — keeps the direction (AI-assisted median 3 vs 2 files per fix, 21% vs 13% broad); a later deterministic-window respecification (git log -n 300from each pinned HEAD) lands nominally under 0.05 again (banded 0.029/0.046/0.037) — three specifications straddling the threshold is fragility by demonstration, and none survives Holm (0.0167). Three correlated outcomes, no registered primary, repo-level rather than commit-level authorship, and subject-line fix detection that agents satisfy far more often than humans (19/20 vs 11/18 repos): the claim is downgraded to a consistent direction worth a better-designed study, and nothing more. - docs(philosophy): "Why AI-Specific?" rewritten — volume, not pathology. The page asserted five recognizably-AI failure modes; the one tested did not survive its control, and decades of hand-written unmaintainable code say slop needed no AI to exist. What AI changes is the rate: code arrives faster than it can be read. The same volume makes the fix loop viable — deterministic findings, one uniform standard, bounded prompts — which needs no claim that AI writes worse code, only that it writes more.
Added
- feat(scoring): the score is now a full rubric — 13 aspects, explicit weights, honest unknowns. A hostile audit demonstrated the previous score's worst property with one input: a 100-file repository with zero test files scored 5.0/A+ with testability 5.0, because every category was a re-weighting of five structural pressures and nothing ever asked whether a test existed — and the test suite blessed it. The overall is now a three-layer rollup defined in one place (
_formula.py): thirteen aspect scores (five corpus-calibrated structural pressures; eight rubric-banded evidence scores — test presence, dead code, near-duplication, idiom consistency, churn hotspots, code-to-code change coupling, knowledge concentration, documentation) → ISO categories as weighted means → overall as their equal-weighted mean. Every weight and band is data, printed in the report (score.aspects,score.rubric) and documented indocs/standard.md. An unmeasurable aspect scoresnulland its weight renormalizes — a shallow clone must not grade as either clean or dirty — and the five aspects the tool cannot measure at all (test effectiveness, naming, comment accuracy, indirection, architectural coherence) are named in every report as unscored, with reasons. A repository with production code and zero test files can no longer receive an A-grade; its testability caps at 2.0 and the blocker says why.CALIBRATION_Cre-fitted through the new pipeline by bisection (3.5466 → 2.966); the corpus median still rolls up to exactly 4.0. The rubric is a standard: judgments made explicit, deterministic, and applied identically to every repository — which is what every standard is, ISO/IEC 25010 included, and needs no apology. Empirical claims about the world are the thing held to an evidence bar (see Retracted, below). An outcome study specified indocs/standard.mdwould tune the weights against measured change effort; it has not been run. - fix(self): this repo now opts into its own gates, and its README stops lying. The same audit caught that making threshold gates opt-in had silently neutered
--fail-on-gatefor this repository's CI, and that the README advertised a stale 5.0/A+ while a fresh run said 4.8/B with warnings. All three threshold gates are nowtrueinmaintainability-agent.json(the two live failures they exposed were fixed: a measurement-data JSON excluded from line audits, one function refactored under the ceiling), and the README table now states the current 4.8/B and names the audit that caught the stale claim. - feat(history): churn, hotspots and change coupling from the repo's own log. Every other metric here is a photograph of the code as it stands; maintainability is defined (ISO/IEC 25010) as the effort to modify, and effort is paid per change — so a 600-line file untouched for three years and a 600-line file edited weekly scored identically, when only one is costing anything. The report now carries: per-file churn (commits, lines, authors) over a 12-month window; hotspots, churn multiplied by the file's summed cognitive complexity, so a file ranks only by being both hard to read and constantly read; and change coupling, pairs of files that co-change in most of their commits — the shape of a boundary drawn in the wrong place, invisible to every static metric in the package. Merges are excluded (their numstat double-counts the branch), sweep commits (>30 files) are excluded from coupling by raw commit size, rename notation is resolved including multi-segment and empty-side forms, and a shallow clone reports
history: nullrather than zeros, because "no history" and "no changes" are opposite findings. Reported, not scored — these have not been validated against an outcome, and unvalidated signals no longer move grades here.
Changed
- feat(calibration): the corpus is selected mechanically — 14 repositories to 40. The old corpus was fourteen projects picked because the author knew them, which is selection bias sitting directly underneath a scale used to grade other people's code.
tools/calibration/select_corpus.pynow issues a GitHub search anyone can re-run —stars:>3000 created:<2021-01-01 pushed:>2026-01-01across Python, TypeScript and JavaScript — andtools/calibration/verify_corpus.pyclones each candidate and keeps only those holding 20+ source files and 100+ declarations, pinning each to the commit measured. The result spans 32 to 18,789 source files and 463,581 declarations. Seven candidates were rejected on contents rather than on name (PayloadsAllTheThings, 33 declarations;airbnb/javascript, 14), and the rejections are recorded incorpus.jsonrather than silently dropped. created:<2021-01-01is load-bearing. This corpus is the human-written baseline against which AI-assisted code is compared, and today's most-starred repositories include projects begun well into the LLM era. Admitting those would answer the question before measuring it.- the constants moved, and the direction is not uniform:
| dimension | 0.6.x (14 hand-picked) | now (40 queried) |
|---|---|---|
| file_size | 0.0779 | 0.0576 |
| declarations | 0.0243 | 0.0599 |
| duplication | 1.4659 | 3.7350 |
| risk | 0.0546 | 0.0726 |
CALIBRATION_C |
5.2754 | 3.5466 |
Duplication is 2.5x more lenient: identical code that scored 5.0x now scores 2.0x. The hand-picked corpus was almost entirely libraries, which are built for reuse and have had years of review pressure to remove repetition; sorting by stars also returns applications and tools (n8n, excalidraw, playwright, transformers), which carry far more. The queried corpus describes widely-used code rather than well-factored libraries — the more honest reference for a tool that grades arbitrary repositories, but the bar is now set by a population that includes application code.
- excluded one repository on what it is, not on how it scored. 33-js-concepts cleared verification — an index.js plus thirty concept-demo test files is enough declarations to look like a codebase — and landed as the corpus outlier on duplication (38.8x the median) and file size (3x the next repo), because parallel teaching examples are supposed to repeat. It is excluded as a teaching repo, alongside the tutorials and courses the name filter already removed. The distinction matters: filtering a corpus by its own measurements manufactures whatever reference the filter was aimed at. Removing it moved c 3.5724 → 3.5466, which is what a median is for.
- fix(gates): the file, function and duplicate-block gates are opt-in. Measured across the corpus these fired on every single repository — duplicate counts of 33 to 5,325 against a default max_duplicate_blocks of 20 — so --fail-on-gate failed everywhere out of the box. A gate that always fails is not a gate; it trains people to pass the flag and ignore the result, and it gave the gates dimension zero variance. fail_on_file_failures, fail_on_function_failures and fail_on_duplicate_blocks now default to false. The findings are still reported — only whether they block CI changed, and a repo opts in to that.
- the gates reference is now fixed at 0.05 rather than corpus-derived. Hard gates are discrete policy breaches a repository opts into, not a rate drawn from a population. Once gating became opt-in the corpus median went to zero, and dividing by zero would have made the dimension silently ignore real failures. One gate failure now reads as 1.0x. scoring._relative reports 0.0 for any zero reference instead of dividing.
- fix(config): vendored third-party code is excluded by default. vendor/, third_party/, *.min.js and friends. Auditing vendored code measures someone else's decisions, and — worse for a reference corpus — constants drawn from a population containing it describe bundles rather than maintained source. lodash's corpus entry was 41% vendored.
- fix(metrics): the README gate accepts any README. It required README.md exactly, so Django — which ships README.rst — was reported as having none. That is the class of finding that teaches people the tool does not know what it is looking at.
0.6.1 - 2026-08-08
Release plumbing and a performance consolidation. No behaviour changes.
- feat(ci): PyPI publishing is automated on tag push, via Trusted Publishing.
.github/workflows/release.ymlverifies the tag matches the packaged version before building — a mismatch would publish the wrong code under the right name, and PyPI uploads cannot be replaced — then builds, runstwine check, installs the wheel, runs the suite and the tool's own--fail-on-gateaudit against the built artifact, and only then publishes. Authentication is OIDC: GitHub mints a short-lived token scoped to this workflow in this repository. There is no API token in repository secrets, so there is nothing to leak and nothing to expire — which is exactly what took the SonarQube scan down for three months. - perf: each file is read once and parsed once per audit. Five scanners each needed the same lines and the same declarations and each computed them independently, so an audit read every file five times and parsed every source file three times.
SourceIndexholds both for the life of one audit. On Django (3,153 files, 529 KLOC): 10.4s → 8.5s for +27MB, 18% faster for 8% more memory. The headline number is modest; the point is that cost no longer scales with the number of scanners — a sixth now costs no additional reads. Deliberately not a global or decorator cache, so a long-lived process does not accumulate every file it has ever seen and a re-audit sees current content. - fix(config): this repo's own config now excludes build artifacts.
maintainability-agent.jsonoverridesexclude_patternswholesale, and unlike the shipped defaults it omittedbuild/anddist/.python -m buildleavesbuild/lib/maintainability_audit/— an exact copy of every source file — so the audit reported the whole codebase as duplicated andmax_duplicate_blocks: 0failed the gate. Caught by the release workflow on its first real run, which is what that step is for. Note the general lesson for any config: overridingexclude_patternsreplaces the defaults rather than extending them. - tests: +8 in
tests/test_source_index.pypinning that the work is genuinely shared, that a shared index never changes a result, and that every scanner still runs standalone.
0.6.0 - 2026-08-08
Adds four Tier-1 signals aimed directly at the "AI writes code humans cannot maintain" criticism. Reported as findings; deliberately not yet scored. No breaking changes.
- feat(similarity): near-duplicate declaration detection — a helper written twice under two names. The most-cited empirical complaint about AI-written code is clone-instead-of-reuse: an agent that cannot see your existing helper writes a second one, the copies drift, and a bug fixed in one survives in the others. Exact text matching cannot catch it, because the second copy is the same structure with different identifiers. Declaration bodies are now reduced to a token sequence with identifiers anonymized by order of first appearance (Python via stdlib
tokenize, C-family via a regex over the_masking-scrubbed source), compared as sets of 6-token shingles by Jaccard similarity, and reported at or above 0.8. An inverted shingle index keeps the comparison linear enough to audit Django (3,153 files) in 7.5s. - feat(prompts): the finding names the declaration to reuse. This is the one finding that ships with its own fix. "There is duplication" is not actionable; "
toAtomicAmountatTradeTicket.tsx:862already does this" is. Cross-file pairs lead, since those are the ones written without knowing the first existed. The prompt also tells the agent not to merge two functions that merely look alike but would need to change for different reasons. - measured: production cross-file near-duplication, as a share of eligible declarations, across the reference corpus — mature human-written OSS median 0.20%, max 2.15% (n=12); AI-written applications median 1.49%, max 12.05% (n=6). Three of the AI repositories exceed every repository in the OSS corpus. This is the first signal measured here that separates the two populations: on file size, declaration size and complexity they are statistically indistinguishable. The confounds are real and documented — the OSS corpus is libraries, the AI cohort is applications, and both samples are small.
- not scored, deliberately. Most repositories sit at zero, so a median-based reference would be unstable: dividing by ~0.002 turns a rounding difference into a large multiple. Signals earn a place in the score by holding up across more repositories, not by being new.
- fitted, not guessed: the eligibility thresholds (80 normalized tokens, 2+ control-flow tokens) were tuned against the corpus to remove two false-positive classes it exposed — bodies too short for similarity to mean anything, and thin delegations whose shape is dictated by the API surface rather than by what they do (requests'
put/patch, flask'stemplate_filter/template_test). Test files are excluded: in mature projects nearly every near-duplicate is a deliberately parallel test variant, which is not the defect being measured. - feat(cognitive): cognitive complexity — nesting-weighted reading cost. The cyclomatic figure is a keyword tally and is blind to nesting: five sequential guard clauses and five levels of nesting both scored 6, though one is read a line at a time and the other must be held in the head at once. Each flow break is now charged plus the depth it sits at, so nesting compounds — that pair now scores 5 and 15.
elseandelifare charged flat rather than nested (they resolve a decision already being tracked), and a run of boolean operators counts once, becausea and b and cis one idea to read. Python is exact from the AST; C-family nesting is inferred from brace depth over the masked source, which under-reports on brace-free bodies — the safe direction. Reported beside the branch count rather than replacing it: a function can be low in one and high in the other, and that difference is the point. - feat(config):
max_cognitive_complexity(25) andwarn_cognitive_complexity(15). Fitted against 21,300 declarations in the reference corpus (p50 = 1, p90 = 9, p95 = 17, p99 = 49): warning at 15 flags 5.5% of declarations, failing at 25 flags 2.7% — comparable hit rates to the existing file thresholds. Configs omitting both keys are unaffected, so no existing repo starts failing on a metric it never opted into. - chore(calibration): constants re-fitted after the threshold change. Adding a threshold changes what counts as a finding, which moves the declarations reference — this is precisely the case
tools/calibration/measure.pyexists for. It reported the drift, the corpus was re-measured, anddeclarationsmoved 0.0233 → 0.0243 withCALIBRATION_C5.2318 → 5.2754. Small, because the new threshold flags only a modest additional tail in mature code. - feat(deadcode): unreferenced private declarations. Agents leave debris — a helper written for an approach abandoned two prompts later, still compiling, tested by nothing. Naive "no callers" scanning reports a library's entire public surface, so only declarations the language marks internal are candidates: a leading underscore in Python, no
exportin JS/TS. Privacy is the load-bearing assumption — it is the author's own claim that no external caller exists, which is what makes "no references in this repo" sufficient. Decorated declarations, dunder methods and test files are excluded. The prompt lists them as deletion candidates while telling the agent to confirm each one, because a name reached only through dynamic dispatch is indistinguishable from a dead one here. - fixed two false-positive classes found on first contact with the corpus. Counting identifiers over the comment/string-masked copy blanked f-string interpolations, which are live code — flask's
_get_werkzeug_versionis called from inside one and was reported dead; counting now runs over raw source, which errs toward hiding a dead function rather than inventing one. And an object-literal method (beforeBreadcrumb(crumb) { … }in a Sentry config) binds no name and is invoked by whoever receives the object, so C-family candidates must now bind a name. Together these dropped one repo from 15 findings to 0. Both are pinned by regression tests. - measured, and reported honestly: the rate barely separates the cohorts — mature OSS median 0.0% (max 0.55%), AI-written median 0.14% (max 1.53%). Unlike near-duplication this is not evidence about AI-written code; it earns its place as hygiene. Findings-only, not scored.
- feat(idioms): competing libraries for one concern. Three HTTP clients means three error shapes, three retry stories, three sets of behaviour to learn. The cost is not duplication — each call site may be fine — it is that no single mental model covers the codebase, which is the signature of independent generation: every answer locally reasonable, nothing reconciling them. Detected from imports across production source, grouped by concern.
- this one needs a curated list, and the changelog should say so. There is no structural way to know that
momentanddate-fnscompete whilereactandreact-domdo not — it requires knowing what the packages do. The shipped list is deliberately small (HTTP clients, date handling, client state, schema validation, ORMs, web frameworks), restricted to alternatives that are well known and change slowly, and incomplete by construction.idiom_groupsin config replaces it wholesale. - its first corpus run produced only false positives, both now pinned by tests. A package named in a fenced code block inside a Markdown document counted as an import — two of three findings on one repo came from documentation prose. And
blackwas reported as running two HTTP clients when one wasaiohttpin theblackddaemon and the otherurllib3in a CI helper underscripts/: separate programs sharing a repository, not one codebase with two mental models. Non-source files and standalone script directories are now excluded, alongside tests and the repository's own package. - measured: after those fixes it reports nothing across all 14 corpus repositories and fires once, correctly, on a repo running
aiohttpin 27 service files andhttpxin 3 — with two files importing both. High precision, low recall, and quiet by design: silence means "nothing recognised", never "nothing wrong". - tests: +40 across
tests/test_near_duplicates.py,tests/test_cognitive_complexity.py,tests/test_dead_code.pyandtests/test_idiom_divergence.py, covering renamed-copy detection, comment/string immunity, both false-positive classes, the reporting shape, nesting compounding, and back-compatibility for configs without the new thresholds. One caught a spec error of mine: I had asserted each declaration appears in at most one pair, when the useful behaviour for N copies is N-1 pairs all referencing a single original — one instruction to reuse it, rather than a clique. - dogfood: self-audit holds at 5.0 / 5 (A+) across 69 files, with zero near-duplicates, zero unreferenced private declarations, and nothing over the new reading-cost threshold.
0.5.0 - 2026-08-07
The scoring engine was rebuilt after being measured against real code and found to be wrong. Config is unchanged; the shape of report["score"] gains fields.
- fix(scoring): scores are rates, not counts — the old model was measuring repo size. Findings were counted absolutely, so 20 oversized files cost the same in a 50-file project as in a 3,000-file one. Run against a corpus of mature open-source repositories, the 0.4.0 model scored Django, pytest, black, tornado, click, httpx, attrs, lodash, svelte, axios and fastapi all at 0.0 / F, while a 53-file toy repo scored 4.6 / A. Above roughly a 3% file-warning rate every repo pinned to the floor, so the scale could not distinguish a mediocre codebase from a catastrophic one — and a tool that grades Django unmaintainable cannot be used to argue anything about code quality. Every pressure is now a finding count divided by the population it was drawn from.
- fix(scoring): each dimension is normalized against what real code actually carries. Raw pressures live on wildly different scales — measured across the corpus, duplication runs ~15x file-size pressure and ~93x declaration pressure — so summing them raw scored duplication and essentially nothing else. Each is divided by its own corpus median, and
score["dimensions"]now reports multiples where 1.0x is typical real-world code. The curve is hyperbolic and fitted so the corpus median scores exactly 4.0: a well-run real codebase earns a B. - feat(scoring): A+ is gated, not averaged. A mean lets a repo hide one bad dimension behind four good ones. The top two grades now additionally require every dimension to be clean, and demotion cascades — a repo denied A+ must still satisfy A's ceilings to receive an A, so a single hard-gate failure disqualifies both.
score["grade_blockers"]names the specific measurement that capped the grade. - feat(prompts): the remediation prompt leads with the dimension costing the most. A letter grade is not actionable;
duplication at 4.4xis. The prompt now lists only elevated dimensions (>1.0x), names the worst one as the starting point, and explicitly tells the agent to prefer leaving a repo alone over manufacturing work when nothing is elevated. This is the point of the score — it exists to aim the prompt. - refactor:
_calibration.pyholds the fitted constants apart from the scoring logic, since they are empirical findings with provenance rather than tunable knobs;prompts.pysplits agent-facing output from human-facing rendering (sarif.pyno longer reaches through the markdown renderer). - tests: +10 in
tests/test_scoring_calibration.pypinning the properties the old model violated — size independence, non-saturation, corpus calibration, and gated top grades. One of them caught a real bug during development: demotion stepped down exactly one grade without re-checking, letting a hard-gate failure land on an A. - feat(calibration): the reference corpus is checked in, pinned, and reproducible.
tools/calibration/corpus.jsonnames 14 mature OSS repositories at exact commits (52 to 4,034 files, Python and JS/TS);tools/calibration/measure.pyclones them at those commits, measures, and reports drift against the stored constants (--checkexits 1 when stale). The derivation from measurements to constants is pure arithmetic in_derive.py, andtests/test_calibration_corpus.pyre-runs it offline from the checked-inmeasurements.json— no clone, no network — so a hand-edited constant or an unwritten re-measurement fails the suite. Constants that cannot be re-derived are exactly the unfalsifiable claim this release exists to stop making. - fix(calibration): references re-fitted on the full 14-repo corpus. The initial constants came from an 11-repo subset that omitted django, fastapi and svelte. Adding them moved the file-size reference from 0.1233 to 0.0779 — a 37% shift, because large repos carry proportionally less file-size pressure than small ones. A small-repo-only corpus would have re-introduced the very size bias this release removed.
- known limits: these remain structural proxies. They say nothing about naming, comment accuracy, architectural coherence, or whether a reader can build a correct mental model. The corpus is finite — recalibrate
DIMENSION_REFERENCESwhenever the default thresholds change. - dogfood: self-audit holds at 5.0 / 5 (A+) across 57 files under the new engine, with the A+ gate satisfied on every dimension.
0.4.0 - 2026-08-06
Bug-fix release for TypeScript/JavaScript function detection, plus two threshold corrections. Config gains two optional keys; no breaking changes.
- fix(metrics): JS/TS/HTML declarations are bounded by their own braces, not by the next regex match.
_regex_function_rangesended every declaration at "next match minus one", which is only safe if the pattern list matches every declaration in the file. It didn't —export function, generic signatures (async function request<T>(), and object/class methods were all invisible — so the first recognised declaration absorbed everything up to the next match or to end-of-file. On a downstream TypeScript repo a 4-linecsrfToken()was reported as 262 lines / complexity 35 and the clean file it lived in was graded an F; all 18 function findings on that repo were false. New_ranges.js_declaration_rangeswalks brace/paren depth over a_masking-scrubbed copy of the source (comments and string/template literals blanked), so an unrecognised declaration now costs one missed finding instead of a cascade of false ones. Unresolvable bodies fall back toindent_bounded_end, which the plain regex detector now uses too — the old "run to end-of-file" fallback is gone everywhere. - fix(metrics): complexity for JS/TS/HTML is scored against code only.
ifin a doc comment and?in a URL string are no longer counted as branches. - fix(metrics): classes are graded on
max_class_lines/warn_class_lines(new, default 300/200), on length alone. A class was being judged againstmax_function_lines, so an ordinary six-method class was reported as an over-long "function"; and becauseast.walkyields a class and each of its methods, its complexity was the sum of branches already charged to those methods.FunctionMetricgains akindfield ("function"/"class"), carried into the JSON report. - fix(renderers): a flagged class now reads as a class in every output. Storing
kindis not enough — a class printed unlabelled under a "Function Hotspots" heading, beside a complexity number, reproduces the misreading the field exists to prevent. The markdown table'sFunctioncolumn is nowDeclaration; a class is rendered`ScanWorker` (class)in the report, remediation prompt, PR comment, and SARIF message, and its complexity cell is-because that figure is a double count and nothing is graded against it. New_hotspots.pyholds this phrasing sosarif.pyno longer imports the markdown renderer. Reports and baselines written before 0.4.0 carry nokind; its absence is read as"function". - fix(config):
migrations/is excluded by default. A 102-line, complexity-2upgrade()is what a correct migration looks like. Migrations are append-only history; refactoring one rewrites the past. - tests: +23 regression tests across
tests/test_declaration_ranges.py(brace bounding,export function, generics, object-literal methods, class methods and field arrows, callbacks vs. methods, control-flow keywords, comment/string/template noise, inline object return types, multi-line parameter lists, runaway expression bodies, unclosed braces, inline HTML<script>) andtests/test_declaration_grading.py(indentation-bounded fallback, class thresholds, migration exclusion, and how a flagged class is worded in each renderer). New modules are at 100% coverage; suite total 98% over 61 tests. - refactor:
metrics.py(426 lines) split along its actual responsibilities. It had accumulated four jobs. Now:metricswalks the repo (which files count, how big, what gates trip),declarationsmeasures and grades what's inside a file,duplicationcompares files against each other, andreportassembles the JSON every renderer consumes. The graph is acyclic — the upper three importmetrics, which imports none of them.build_reportmoved toreport;cliand the package entry points are unchanged, but a directfrom maintainability_audit.metrics import build_reportnow needs.report.tests/test_audit_components.pyandtests/test_declaration_ranges.pywere split to match the modules they exercise. - docs: new docs/language-support.md states per-language accuracy, the deliberate under-report bias, and every known limitation. README trimmed of content that duplicated
CONTRIBUTING.md. - dogfood: self-audit scores 5.0 / 5 (A+) across 54 files — zero warnings, zero failures, zero duplicates, zero risk findings, zero hard gates, all five ISO/IEC 25010 categories at 5.0. This is the output of the refactor above, not a claim that preceded it: the fix work alone landed the repo at 4.4 / 5 (B) with four files past the 250-line warn threshold, and the README was still advertising a stale 5.0 from v0.1.0. Shipping a false-positive fix while publishing an inaccurate grade would have been the same failure mode the release exists to correct.
0.3.0 - 2026-05-23
Bug-fix release. Three independent false-positive sources observed during a downstream audit are eliminated; no breaking config changes.
- fix(metrics): use
ast.end_linenofor Python function/class line counts. The detector previously computed the end of a function as "next sibling start − 1" (with end-of-file as the fallback), so a 4-lineEnumfollowed by 300 lines of unrelated code reported 321 lines / complexity 23. Short Python functions and classes now report their actual indented body length. Non-Python files keep the regex fallback.async defis also detected now (the regex never matched it). New helpers:_python_function_ranges,_regex_function_rangesinmetrics.py. - fix(scoring): testability/analyzability no longer punish test-only function pressure. Refactoring duplicate test boilerplate into a shared fixture used to drop testability 0.9 → 0.4 and analyzability 0.7 → 0.3 even though the same assertions still passed. The summary now exposes
production_file_failures/production_function_failures/production_hard_gate_failures(plus their warning + test-side counterparts), andscoring.score_reportuses the production-only pressure fortestabilityandanalyzability.modularity,reusability, andmodifiabilitykeep their original combined-pressure formulas. New helper:is_test_pathinmetrics.py. - fix(metrics): duplicate-block detector skips low-information lines. Five-line column-name lists collided between an INSERT column tuple and a function's keyword-argument signature, even though that shared ordering IS the architectural contract. Blocks made entirely of bare identifiers (e.g.
name,), simple kwarg passthroughs (x=x,), or pure punctuation are now ignored. Real cross-file code duplication still surfaces (covered by a new regression test). - refactor(metrics): extract
_compute_gates_and_summary,_function_hotspots,_count_status,_split_by_test_pathso the mainbuild_reportandreport_summarystay inside the self-audit complexity threshold after the new logic lands. - tests: +10 regression tests in
tests/test_audit_components.pycovering the three bugs (Enum-after-300-lines, single-return-then-def, empty-class-then-long-func, async def, fallback-on-SyntaxError, identifier-list dup skipping, real duplication still flagged, test-only function failure not dropping testability/analyzability, production/test summary split). - dogfood: self-audit on this repo post-fix scores 4.6 / 5 (A) under the project's strict local config (zero function failures, zero hard gates, 98% coverage).
0.2.0 - 2026-05-12
- Adds a portable invokable skill under
skills/maintainability-agent/that drops into Codex, Claude Code, and GitHub Copilot Chat so/maintainability-agentis one keystroke away in any of them. - Adds per-host adapters:
agents/openai.yaml(Codex, already present),agents/anthropic.yaml(Claude Code install paths),agents/copilot.yaml(Copilot prompt-file source/destination). - Adds
copilot/maintainability-agent.prompt.md— VS Code Copilot Chat prompt file shaped for Copilot's prompt-file frontmatter; reuses the SKILL.md body verbatim. - Documents the new install paths in README ("Invokable Skill / Slash Command" section + 5th bullet in the feature list).
- Updates SKILL.md description so Claude's relevance ranker fires correctly while staying valid for Codex.
0.1.0 - 2026-05-11
- Initial local implementation of deterministic maintainability auditing.
- Adds Markdown, JSON, SARIF, PR comment, baseline, and AI remediation prompt outputs.
- Adds model/tool-specific instruction generation for Claude Code, Codex, Cursor, Copilot, Windsurf, and generic agents.
- Adds 92% coverage gating,
coverage.xmloutput, SonarQube Cloud starter config, and external quality-tool readiness docs. - Self-audit on this codebase: 5.0 / 5 (A+), zero warnings across every category. Checked in at docs/self-audit.md.