mjolnir-qa 3.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,12 +11,598 @@ once shipped, so this file is the record of what changed between versions.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [5.0.0] — 2026-09-27
15
+
16
+ ### Breaking changes
17
+
18
+ - **`business-case` no longer prints a dollar figure you did not supply.** The
19
+ `--industry` flag selected from a table of invented incident costs (fintech
20
+ $50 000, healthcare $100 000, …) with no source, and the product of that table
21
+ and a measured false-positive rate was printed as "Expected Savings" and
22
+ "Total potential savings". Pass `--incident-cost <n>` to see the arithmetic;
23
+ the measured FP rates, their sample sizes and the evidence weight behind them
24
+ are always shown. `--industry`, `--history` and `--projected` now exit with a
25
+ reason: `--history` read no history despite promising estimates from scan
26
+ improvements, and `--projected` divided the total by six and called the
27
+ quotient a monthly rate.
28
+ - **`enterprise sso` and `enterprise compliance` no longer write files.** They
29
+ wrote an SSO guide instructing readers to add an `sso` block to
30
+ `mjolnir.config.json` — a key nothing reads — and three auditor-facing SOC 2 /
31
+ HIPAA / PCI-DSS templates mapping controls to capabilities this product does
32
+ not have (SSO/SAML integration, a scan "audit trail", a "privacy scan"). Both
33
+ now refuse with a reason. `enterprise config` emits
34
+ `capability-manifest.json`, whose `notProvided` list records the absences
35
+ instead of inventing a deployment.
36
+ - **The evidence descriptor is now the same on every surface.** The HTML and
37
+ Markdown trust reports showed four bare words (`E2 · deterministic`) where the
38
+ terminal showed the full descriptor. They now show
39
+ `[E2 · deterministic · measured FP 8% · n=14 · trust L3 · runtime: file executed]`.
40
+ A finding with no `evidenceLevel` of its own now derives one from its type and
41
+ confidence instead of defaulting to `E2` — the strongest claim the product can
42
+ make, previously asserted for findings nobody had measured.
43
+ - **Unmeasured counts read "unknown — not measured", not `0`.** The trust
44
+ report, the PR comment and the step summary all rendered
45
+ `testDeclarationCount ?? 0` as "0 tests analyzed in 0 files" for a producer
46
+ that had never measured a declaration. The trust-report JSON twin now emits
47
+ `null` for those fields. A zero is a claim; an absent measurement is not.
48
+ - **The dashboard no longer varies between runs of an unchanged repository.**
49
+ The generation timestamp moved out of the visible body into
50
+ `<meta name="mjolnir-generated-at">`, and `--deterministic` omits it entirely
51
+ so the artifact is byte-identical and can be diffed in review.
52
+ - **`mjolnir report` no longer publishes a test-run status.** The top-level
53
+ `status` was computed from the findings
54
+ (`partial ? "interrupted" : hasError ? "failed" : "passed"`), so a clean scan
55
+ wrote `{status: "passed", totalTests: 0}` — the first field a Playwright
56
+ consumer reads, asserting a green run over zero executed tests, with the
57
+ contradicting `execution: "STATIC_ANALYSIS"` marker buried in the extension
58
+ block. `failed` was equally dishonest: it implies tests ran and lost. The
59
+ field is now the literal `interrupted` on every path, and what the scan
60
+ actually found is reported separately as
61
+ `mjolnir.scanOutcome` (`clean` / `blocked` / `partial`), which the previous
62
+ `mjolnir.status` field carried. Consumers reading the root `status` will see
63
+ `passed` → `interrupted`; that is the correction, not a regression.
64
+ - **Score bands are decided in one place.** The dashboard split at 60 while the
65
+ terminal split at 80, so a score the terminal called UNWORTHY rendered amber
66
+ in HTML; the contributor handover used 90; `mermaid.ts` and
67
+ `monorepo-analysis.ts` each carried their own copy. All of them now read
68
+ `src/reporter/presentation.ts`.
69
+
70
+ ### Added
71
+
72
+ - **`npm run ci:standard` now runs in CI.** `scripts/check-ci-standard.mjs`
73
+ was unit-tested by `tests/integrations/ci-standard.spec.ts` but referenced
74
+ by no workflow, so the invariants it checks — the PR scan's partial-analysis
75
+ handling, the coverage and ratchet gates, and the read-only scan job's
76
+ permissions — were enforced nowhere. It runs as a step in `workflow-lint`,
77
+ next to actionlint, since both are static checks of the repo's own CI
78
+ config.
79
+
80
+ - **Opt-in Sentry crash reporting.** Set `SENTRY_DSN` and the CLI and the MCP
81
+ stdio server report their own fatal errors — release-tagged as
82
+ `mjolnir-qa@<version>`, tagged by surface, and nothing else: no user data
83
+ (`sendDefaultPii: false`), no traced spans (`tracesSampleRate: 0`), no
84
+ findings or file contents. With no DSN the SDK is never even imported, so the
85
+ default install gains no dependency, no startup cost, and no network call. A
86
+ tool that reads private repositories must not phone home on its own, so the
87
+ DSN is the only switch. `@sentry/node` is an OPTIONAL peer dependency for the
88
+ same reason: ~1.5 MB that nothing in a code scanner needs. Capture points are
89
+ the two existing top-level catch blocks rather than `uncaughtException` /
90
+ `unhandledRejection` handlers, which would have changed the frozen exit-code
91
+ contract. `npm run sentry:release` creates the release and uploads source maps
92
+ (the build now emits them; the published tarball still excludes them). Both
93
+ release pipelines now run it after `npm run build` and before `npm pack`,
94
+ because the debug ids it stamps are written into `dist/` in place: pack first
95
+ and the shipped tarball carries no ids, so the maps uploaded for the release
96
+ match nothing and every stack trace degrades to a bundle frame.
97
+ - `npm run report:honesty` — no surface may print a zero for a measurement that
98
+ may be absent. Four reviewed exceptions are recorded for computation inputs,
99
+ each with a reason and a follow-up.
100
+ - `npm run thresholds:parity` — no score-band literal may exist outside the
101
+ threshold registry. It immediately caught a selector-health threshold that
102
+ shared a number with a trust band but not a meaning.
103
+ - `npm run claims:revalidate` — every claim asserted `fixed` must rest on a
104
+ candidate-bound, independent revalidation. It rejects evidence that cites the
105
+ artifact it is meant to prove, and downgrades rather than deleting a claim
106
+ that loses its evidence.
107
+ - `npm run verbs:budget` — the 5.x law, ratcheted at 50 verbs with a 44 target
108
+ for 5.0.
109
+ - `npm run script:paths` — no `package.json` script may name a `tests/`,
110
+ `src/`, `scripts/`, `docs/`, `site/` or `enterprise/` path that does not
111
+ exist. 107 path references are checked today.
112
+ - `npm run m26:gaps:revalidate` — re-runs every gap row's own
113
+ `revalidation_command` against the working tree and records the exit code and
114
+ the commit it ran at.
115
+ - `tests/contract/artifact-determinism.spec.ts` — two runs of the HTML
116
+ artifact over an unchanged repository must be byte-identical.
117
+ - `src/reporter/presentation.ts` — the one module that makes a presentation
118
+ decision. It reads a finding, a score or a palette and returns the word, band
119
+ or descriptor to print. It opens no file, touches no clock and formats no
120
+ document, and a test enforces that.
121
+
14
122
  ### Changed
15
123
 
16
- - **Advisory-first CI adoption** — `mjolnir ci install` and the root
17
- GitHub Action now default to non-blocking findings and non-blocking partial
18
- scans. Blocking remains explicit through `--gate error`, `--gate warning`,
19
- or `fail-on`.
124
+ - **Ruleset `01` now enforces 11 required checks instead of 4.** It required
125
+ only the four `build-test` matrix contexts, which meant the repo's entire
126
+ review surface — self-scan, certification, generated-docs-drift, site-build,
127
+ workflow-lint, property, fuzz — could be red while a PR still merged. That
128
+ made every one of those checks advice rather than a gate, and it meant the
129
+ deleted `merge-verify.yml` header was describing a ruleset that never
130
+ existed. The enforced set is now the four matrix entries plus
131
+ `property-tests`, `fuzz`, `site-build`, `workflow-lint`, `self-scan`,
132
+ `certification` and `generated-docs-drift` — every check that can actually
133
+ block. Deliberately still _not_ required: `detector-revision-diff` (advisory
134
+ by design — a hard fail would only manufacture routine revision bumps),
135
+ `scan` / `publish` (the Mjölnir PR report, which reviewers read but which
136
+ duplicates `self-scan` on the `src` tree), and the CodeQL/OSV workflows,
137
+ which report but do not gate. `strict_required_status_checks_policy` and
138
+ `do_not_enforce_on_create` are preserved, so a PR must still be current with
139
+ `main` to merge.
140
+ - **`docs/BRANCH-PROTECTION.md` now states the enforced set instead of an
141
+ aspiration.** It listed eight required checks; three of them were enforced.
142
+ Its verification command also read `branches/main/protection`, which does not
143
+ carry required checks at all on this repo — they live in ruleset `01` — so it
144
+ could not have worked. Both are corrected.
145
+
146
+ ### Removed
147
+
148
+ - **The Snyk webhook is gone.** Snyk posted a `security/snyk (sergey-bar)`
149
+ commit status on every push, duplicating the first-party `osv-scanner.yml`
150
+ (`scan-pr` / `scan-scheduled`) and the Dependabot security updates that are
151
+ already configured. Repo webhooks are now empty. Restore with Snyk's own
152
+ integration settings if it is ever wanted back — the deleted hook pointed at
153
+ `https://api.snyk.io/webhook/github/4ac3f376-3f24-42f7-a2c4-8830700f671d`.
154
+ - **The `merge-verify` workflow is gone.** It re-ran the entire PR gate
155
+ (build, typecheck, lint, coverage, property, fuzz, site, self-scan, docs
156
+ regen) on every push to `main` — roughly doubling CI cost. Its stated
157
+ justification was that "a green PR that merges into a red main is not a
158
+ green PR", but that failure class is already closed by ruleset `01` running
159
+ `strict_required_status_checks_policy`, which refuses to merge a PR that is
160
+ not current with `main`. The file also asserted that ruleset `01` "requires
161
+ the complete PR matrix, property/fuzz, site, workflow-lint, self-scan,
162
+ certification, detector-revision, and generated-docs checks before merge" —
163
+ it did not, and never has. The ruleset requires only the four `build-test`
164
+ matrix contexts. Both the workflow and the claim are removed rather than left
165
+ to mislead a reviewer about what blocks a merge.
166
+ - **The `bench-advisory` CI job is gone.** It spent a full `npm ci` plus a
167
+ 3 000-file benchmark run on every PR to produce a comparison that
168
+ `continue-on-error: true` then discarded, and whose only output was a
169
+ warning nobody could gate on. `npm run bench:scan`, the pinned
170
+ `.mjolnir/bench-baseline.json`, and their schema/comparison tests in
171
+ `tests/adversarial/bench-gate.spec.ts` are untouched — the benchmark is
172
+ still runnable and still tested, it just no longer costs every PR a runner.
173
+ - **Four copies of the soft job time-budget report.** Each of `build-test`,
174
+ `self-scan`, `generated-docs-drift` and `bench-advisory` re-derived its own
175
+ wall-clock minutes into `$GITHUB_STEP_SUMMARY` and printed a warning that,
176
+ by its own comment, "never gates". The jobs already declare
177
+ `timeout-minutes`, which is the hard bound, and GitHub reports job duration
178
+ natively.
179
+
180
+ ### Fixed
181
+
182
+ - **Order in the report no longer depends on the machine that produced it.** 23
183
+ sites in `src/` sorted with a bare `localeCompare`, which resolves against the
184
+ process's default locale. `scan-cache.ts` was the worst: it sorted INSIDE
185
+ `hashDir`, so the order was fed to `hash.update(entry.name)` and became a
186
+ semantic input to the detector fingerprint that `isIncrementalSafe` keys off
187
+ — one repository, two fingerprints, one per locale. All 23 now use
188
+ `compareCodePoints` from `src/lib/compare.ts`, except two whose key is a
189
+ human-readable label, which use a comparator pinned to `en` rather than
190
+ inheriting the environment. `tests/contract/deterministic-ordering.spec.ts`
191
+ fails the build on any unpinned `localeCompare` in `src/`.
192
+ - **`mjolnir bind` can no longer be hijacked by a repository's own `git`.**
193
+ `src/engine/candidate-binding.ts` — called on every scan — ran
194
+ `execFileSync("git", …)` with a bare name. On Windows `CreateProcess` searches
195
+ the current directory before `PATH`, so a committed `git.exe` in an untrusted
196
+ repo chose the `commit` and `tree` that land in `runIdentity` and the evidence
197
+ graph. It is the last bare-name process launch left in `src/`, and
198
+ `tests/contract/no-bare-process-exec.spec.ts` now audits the whole tree.
199
+ - **The coverage exemption ledger can no longer drift silently.** Each
200
+ non-structural entry in `docs/COVERAGE-EXEMPTIONS.json` now carries
201
+ `defectSignatures` — regexes that must still match its file — or an explicit
202
+ `closureState` when the defect it described is gone. A signature that stops
203
+ matching fails the gate, so a code fix forces a ledger edit in the same
204
+ commit. Six entries were already describing defects the current code had
205
+ fixed (`maturity`, `business-case`, `dashboard`, `enterprise`, `exec-report`
206
+ and the `report-playwright` row), and are reclassified accordingly.
207
+ - **Skipped and expected-fail tests now have a price.** Nothing in the
208
+ repository counted them, so the count could grow from zero to hundreds with
209
+ every other signal still green. `docs/SKIP-BUDGET.json` records the current
210
+ counts — 8 skipped tests, 1 skipped file, 3 expected failures — measured by
211
+ `scripts/vitest-skip-budget-reporter.mjs` during the run. They may fall;
212
+ raising them fails `npm run skip:budget` and names the tests that spent the
213
+ budget. A missing or incomplete measurement fails too, so a reporter that
214
+ stops being wired in cannot read as a clean run.
215
+ - **The M26 release gate and the version-drift checks are covered where it
216
+ matters.** Both run inside `certify` and both were excluded from the coverage
217
+ ratchet, so their reject arms — the paths that only execute when something is
218
+ wrong — had no reviewer pressure on them. `tests/ledger/m26-validator-reject-arms.spec.ts`
219
+ is table-driven over every required field, so a field that is added without a
220
+ test fails; `tests/release/version-surface-drift-arms.spec.ts` covers the
221
+ fail-fast arms of the version synchronizer.
222
+ - **One error derivation, one `isRecord`, one crash-isolation path.** The tree
223
+ carried `isRecord` eight times (seven byte-identical), error rendering four
224
+ times under two names, and crash isolation seven times — and the copies
225
+ disagreed, which is the only reason any of this mattered. `String(obj)`
226
+ renders `[object Object]`, so the two _thinner_ error helpers turned a real
227
+ Node failure shape like `throw { code: "EISDIR" }` into a message naming
228
+ nothing — in a doctor detail, an MCP tool result and a `summary` line
229
+ respectively. The richer body survived the collapse, and
230
+ `isRecord` — the predicate every JSON reader uses to decide "this is an
231
+ object I may index into", which is a question a hostile saved report gets
232
+ to ask — is now the exported one in `src/lib/safe-json.ts`. The four
233
+ adapters' duplicated `try { rule.run } catch { onCrash }` is now
234
+ `runRuleIsolated`, because that block is what the `rulesCrashed` count is
235
+ built on and seven copies mean seven chances to lose it.
236
+ The per-adapter _filter chain_ and budget check were deliberately **not**
237
+ folded into the existing `runRulesShared`: the TypeScript adapter injects
238
+ an AST, framework tags, a lazy `codeText` getter, config-only gating and
239
+ `configGateMatches`, and it _aborts the whole file_ on budget exhaustion
240
+ where `runRulesShared` only skips one rule. Routing every adapter through
241
+ it as written would have silently changed which rules run — the exact
242
+ unmeasured claim this program exists to delete.
243
+ `tests/contract/single-error-helper.spec.ts` holds the counts at one, and
244
+ pins the object arm so a future "simplification" cannot drop it.
245
+
246
+ - **A crashed rule is no longer certified as a rule that stayed silent.**
247
+ The doc generator caught a rule THROW while running it against its own
248
+ must-not-fire fixture and returned `null`; the caller read that as
249
+ `fired: false` and committed, into a generated doc page and the
250
+ certification surface, the sentence _"Verified against … — a legitimate,
251
+ similar-looking pattern this rule correctly leaves alone."_ A detector that
252
+ crashes produces exactly the same absence of findings as one that correctly
253
+ abstains, so that claim had no evidence behind it — and it contradicted the
254
+ same function's own docstring, which says the field degrades honestly.
255
+ The outcome is now the tri-state the doctor's own model already defines:
256
+ `FIRED` (a real firewall violation), `DID_NOT_FIRE` (certified), and
257
+ `INCONCLUSIVE` (the rule did not run). An `INCONCLUSIVE` page says so and
258
+ says why, and it never renders the certification sentence. A missing
259
+ fixture is a fourth, separate thing — nothing was attempted — and says
260
+ that instead. The field is typed as a `FixtureOutcome` union rather than a
261
+ boolean, so reintroducing a derived `fired` flag does not typecheck.
262
+
263
+ - **A human-facing table stopped sorting by code unit.** Pinning every
264
+ `localeCompare` to code-unit order (the fix for the ambient-locale drift)
265
+ made the per-rule corpus-occurrence table read
266
+ `SeleniumHQ-selenium` before `microsoft-playwright-dotnet`, because `S`
267
+ sorts before `m`. Stable, and worse to read than the alphabetical order the
268
+ table implies it has. That surface is a table a person chooses repos from,
269
+ so it now uses an **explicitly pinned** locale — readable order, identical
270
+ on every machine, which is the actual defect being avoided.
271
+
272
+ - **Fifteen silent capability losses are now counted, and the count reaches the
273
+ report.** A `catch` in a detection path that returned a clean default made
274
+ the scan exit 0, report `analysisComplete`, and quietly lose the thing that
275
+ made its verdict trustworthy — the reader had no way to tell a whole scan
276
+ from a narrowed one. The worst case was `getCodeOnlyText` returning raw text
277
+ after a throw, which switches OFF the comment/string false-positive firewall
278
+ for that file rather than merely slowing it down. Every such site now records
279
+ a reason in one append-only ledger (`src/engine/degradation-ledger.ts`),
280
+ following the precedent already set by `parserRetryDegradationCount`, and the
281
+ scan result carries `analysisStatus.degradations` — reason-coded counts,
282
+ present only when something was actually lost, alongside a
283
+ `degraded:<reason>:<count>` entry in `reasons`. A non-empty set makes the
284
+ scan `partial`. `truncationReasons` is untouched: a degradation is not
285
+ truncation, and a reader must be able to tell "the scan stopped" from "the
286
+ scan lost a layer".
287
+ - Two distinctions the ledger refuses to blur. **Absent is not degraded:** a
288
+ Python, Java, Go or Rust repository has no `package.json`, and recording
289
+ that would mark every non-Node project partial while training readers to
290
+ ignore the field — so only a manifest that exists and will not parse
291
+ counts. **A no-AST adapter is not a downgrade either:** the same line
292
+ W1.4 draws for the deadline fallback.
293
+ - Two sites outside the original list were found by the new contract spec
294
+ and fixed in the same commit: the tree-sitter Java/C#/Python parses (left
295
+ alone, because the pipeline already counts every `parseAst → undefined` as
296
+ one parse fallback, and counting again would tally one file twice in two
297
+ different fields) and `hashDir`'s `readdirSync`, whose early `return`
298
+ omitted a whole subtree from the detector fingerprint — two rule trees
299
+ differing only inside an unreadable directory then hash identically, which
300
+ is a stale-cache hit wearing a fresh one's clothes.
301
+ - `mjolnir trend show` no longer prints "No trend data yet." and exits clean
302
+ for a history file whose every line failed to parse. The count of
303
+ discarded lines is now reported on stderr, and a history that exists but
304
+ cannot be read is `EXIT_INTERNAL` — the difference between no history and
305
+ unreadable history, which both used to arrive as the same empty array.
306
+ - `mjolnir explain` distinguishes _the rule found nothing_ from _the rule
307
+ never ran_. A rule that THREW on its own fixture produced the same output
308
+ as a correctly silent one, in the one surface whose purpose is to show
309
+ that a rule works. It now says so, with the reason.
310
+ - `tests/contract/no-uncounted-degradation.spec.ts` is the durable half: a
311
+ `catch` in the detection path that RETURNS A VALUE must record a reason,
312
+ rethrow, route to a counted sink, or be exempted with a stated failure
313
+ direction. The rule is about the _return_, not the `catch` — 37 of the 70
314
+ detection-path catches return nothing and cannot lie to a caller, and a
315
+ spec that listed all 70 as individual exemptions would be the same as no
316
+ exemption list at all.
317
+
318
+ - **Scanning a subdirectory no longer reports a framework it read from its
319
+ parent.** `runScan` walks up past the target to find the project root, and
320
+ re-anchors the scan root to the target when the target is a strict
321
+ descendant — but it kept the discovered root's parsed `package.json`. So
322
+ `mjolnir scan monorepo/packages/foo` reported `jest` because
323
+ `monorepo/package.json` said so: a read outside the explicit scan root, and
324
+ a framework verdict about a tree nobody pointed at. Framework detection now
325
+ works from in-root evidence only (config files and the anchor's own
326
+ manifest), and reports `frameworkDetectionUnknown` when that evidence is
327
+ absent — an absent verdict you can see, rather than a parent's you cannot.
328
+ Scanning the project root itself is unchanged. This is the CLI counterpart
329
+ to the containment property the MCP workspace-boundary contract already
330
+ enforced.
331
+ - **A scan that ran out of time no longer loses its AST stage silently.** When a
332
+ scan passes `--max-duration`, the remaining files were analyzed by regex
333
+ instead of AST — a different detection capability, not a slower version of the
334
+ same one, so the same tree produced different findings and a different score.
335
+ The mode was faithfully recorded in the scan cache and reported nowhere; the
336
+ only nearby disclosure, `file-budget`, fired incidentally because the
337
+ per-file rule-loop deadline was usually already spent too. Such a scan now
338
+ carries the truncation reason `ast-budget-fallback` and an
339
+ `analysisStatus.reasons` entry `ast-budget-fallback-files:<n>` saying how many
340
+ files were analyzed by regex, and is honestly `partial`. An adapter that
341
+ declares no AST stage at all (a YAML workflow, for instance) is a declared
342
+ capability envelope and is NOT reported as truncation. `truncationReasons` is
343
+ an open `string[]` by design, so consumers should test for members rather than
344
+ assert the exact array.
345
+ - **`frontier:contracts` was reporting 19 suites it never ran.** It named 24
346
+ test files; nineteen had been deleted by the `cc5fcb88` cleanup and its
347
+ follow-up. Vitest treats a missing path as "no tests here" rather than an
348
+ error, so the script exited 0 and printed "57 passed" while nineteen contract
349
+ suites — including every `m3x`/`m4x`/`m5x` frontier contract — silently did
350
+ not execute. A missing test file and a green test file are indistinguishable
351
+ from the outside, so a gate that passes because its subject is absent has
352
+ certified nothing. The list is rebuilt from the 23 contract suites that
353
+ exist (1 110 tests, not 57), and `npm run script:paths` now fails the build if
354
+ any `package.json` script names a path that is gone.
355
+ - The certification ladder's language vocabulary could not name the answer its
356
+ own admission gate produces. `admit()` returns `UNMEASURED` as the supported
357
+ state of any claim not backed by evidence, but `LANGUAGE_STATE_RANK` had no
358
+ word for it — so a language claiming `CERTIFIED` with an empty cohort was
359
+ handed a result its own vocabulary could not record. `UNMEASURED` is now
360
+ expressible in both vocabularies, and the four rule-level states
361
+ (`MEASURED-CORE` … `PROVISIONAL`) are declared in `RULE_ONLY_STATES` as
362
+ explicitly _not_ language outcomes rather than being filtered out by a
363
+ hand-maintained list.
364
+ - The contributor handover reported "Welcome aboard — the suite is in good
365
+ shape" whenever no issues were found, including when no run report had been
366
+ ingested and flakiness was therefore unmeasured. It now says what it did not
367
+ check.
368
+ - The confidence table's row labels were encoded as `"k|v"` strings and split
369
+ back apart, so any value containing a pipe was silently truncated. The rows
370
+ are typed pairs. The table also gained a `<caption>`, a `<thead>` and
371
+ `scope` attributes; it was previously a bare `<tbody>` a screen reader
372
+ announced as six unlabelled rows.
373
+ - `handoff` and `summary` each carried a private colourless score bar that
374
+ rendered 99 and 100 identically. Both now use the canonical `scoreGauge`, and
375
+ the redundant `theme.meter` alias is gone.
376
+ - The gap ledger gained three revalidation statuses
377
+ (`ALREADY_FIXED` / `CONFIRMED_STILL_OPEN` / `STALE_UNVERIFIABLE`). A row may
378
+ no longer be cleared without a revalidation that was actually run at a named
379
+ commit, and `STALE_UNVERIFIABLE` deliberately does not clear a release gate —
380
+ an unverifiable claim must never be able to unblock a release.
381
+ - `m26:github:sync` derived every one of 429 issue dispositions from GitHub's
382
+ own `state` field — a pure function of its input, containing no engineering
383
+ judgement — and `GAP-M26-002` then marked that script `fixed`, citing the
384
+ artifacts it produces. Dispositions now come from
385
+ `docs/issue-dispositions.json`, where each entry must carry a non-empty
386
+ reason and a verification naming a command or a source path, and may not be
387
+ verified by the script that wrote it. An untriaged issue stays open and is
388
+ flagged, never guessed and never silently closed. 215 of 429 are now visibly
389
+ awaiting a human decision, which is the honest count.
390
+
391
+ ### Documentation
392
+
393
+ - `PRODUCT-ENHANCEMENT-ANALYSIS.md` is marked `SUPERSEDED-BY: BITTERSWEET` and
394
+ gained a section XII auditing its "all 16 features shipped" claims: six do not
395
+ hold (two shipped fabricated values, two shipped a different capability than
396
+ described, two never shipped). The document is kept as the record of what was
397
+ believed on 2026-09-20, because deleting it would destroy the only evidence
398
+ that the belief was wrong.
399
+ - `docs/RELEASE-TRAINS.md` now records why each of the six scheduled verb
400
+ removals is justified differently, so the deprecation notices do not all send
401
+ the same message to users who acted on different output.
402
+
403
+ ## [4.0.0] — 2026-09-26
404
+
405
+ The first release whose reporting surface is checked against the working tree
406
+ rather than against a plan. Every box below is enforced by a gate in
407
+ `npm run certify`.
408
+
409
+ ### What you get, relative to 3.0.0
410
+
411
+ **The product no longer reports numbers it did not measure.**
412
+
413
+ - [x] A scan that finished now says it finished. A minified bundle or a lockfile
414
+ in the repository used to downgrade a complete scan to `PARTIAL`, and the
415
+ terminal told you "some files were not analyzed, so the surface is
416
+ unverified" when every discovered test file _had_ been analyzed. Across
417
+ the 37-repository corpus, complete scans went from 13/37 to 29/37.
418
+ - [x] Unmeasured counts read `unknown — not measured`, not `0`. The trust report,
419
+ the PR comment and the step summary all printed "0 tests analyzed in 0
420
+ files" for a producer that had never measured a test declaration. A zero
421
+ is a claim; an absent measurement is not.
422
+ - [x] `handover` no longer tells you "the suite is in good shape" when no run
423
+ report was ingested and flakiness was therefore never checked. It now
424
+ names what it did not check.
425
+ - [x] The HTML dashboard produces the same bytes for the same repository. It
426
+ embedded `new Date()` in its visible body, so it could never be diffed in
427
+ review. `--deterministic` omits the timestamp entirely.
428
+ - [x] The trust report's evidence descriptor is the terminal's, on every surface.
429
+ HTML and Markdown showed four bare words (`E2 · deterministic`) where the
430
+ terminal showed the measured FP rate, the sample size, the trust rung and
431
+ what the runtime actually corroborated.
432
+ - [x] A finding with no evidence level derives one from its own type and
433
+ confidence. It used to default to `E2` — the strongest claim the product
434
+ can make — for findings nobody had measured.
435
+ - [x] The contributor handover no longer derives a dollar figure from a table of
436
+ invented incident costs. `business-case` multiplied a measured
437
+ false-positive rate by unsourced per-industry costs and printed the product
438
+ as "Expected Savings". A cost figure now requires `--incident-cost`, so the
439
+ number is yours. `--history` (which read no history) and `--projected`
440
+ (which divided by six) are gone, with reasons.
441
+ - [x] `mjolnir enterprise` no longer writes a deployment config claiming
442
+ `sso-saml`, an SSO guide telling you to add a config key nothing reads, or
443
+ auditor-facing SOC 2 / HIPAA / PCI-DSS templates mapping controls to
444
+ capabilities this product does not have. `sso` and `compliance` refuse;
445
+ `config` emits a capability manifest whose `notProvided` list records the
446
+ absences.
447
+ - [x] The dependency graph no longer prints a count derived from nothing.
448
+ "Reachable files: N" was always the number of files that went in.
449
+
450
+ **The product now sees things it was blind to.**
451
+
452
+ - [x] Node-native TypeScript tests (`.test.mts`, `.spec.mts`, `.test.cts`,
453
+ `.spec.cts`) are scanned. They were not mis-scored — they were never
454
+ scanned, so every rule was silent on them with no finding and no note.
455
+ Verified with two byte-identical tests: the `.ts` copy produced the
456
+ finding, the `.mts` copy produced nothing at all.
457
+ - [x] The CI matrix installs cleanly on all three platforms. `npm ci` failed on
458
+ every job before a test ran, and then failed again with a missing
459
+ `lightningcss` binary for Linux, because a lockfile regenerated on Windows
460
+ cannot contain the other platforms' optional dependencies.
461
+
462
+ **The gates now catch these instead of the next person.**
463
+
464
+ - [x] `report:honesty` — no surface may print a zero for an absent measurement.
465
+ Four reviewed exceptions, each with a reason and a follow-up.
466
+ - [x] `thresholds:parity` — no score-band literal outside the registry. It
467
+ immediately caught a selector-health threshold that shared a number with a
468
+ trust band but not a meaning.
469
+ - [x] `script:paths` — no `package.json` script may name a path that is missing
470
+ or uncommitted. It found `frontier:contracts` naming 19 test files deleted
471
+ by an earlier cleanup: Vitest treats a missing path as "no tests here", so
472
+ the script exited 0 reporting "57 passed" while nineteen contract suites
473
+ never ran. Rebuilt from the 23 that exist — 1 110 tests, not 57.
474
+ - [x] `lockfile:platforms` — the lockfile must carry the optional binaries for
475
+ every platform CI builds on. The failure is invisible locally by
476
+ construction, so it needed a gate that runs everywhere.
477
+ - [x] `claims:revalidate` — a claim asserted `fixed` must rest on a
478
+ candidate-bound, independent revalidation. It rejects evidence citing the
479
+ artifact it is meant to prove, and downgrades rather than deletes a claim
480
+ that loses its evidence.
481
+ - [x] `verbs:budget` — the 5.x law: one new verb requires one removal or a merge.
482
+ - [x] `m26:gaps:revalidate` — re-runs each gap row's own command and records the
483
+ exit code and the commit it ran at.
484
+ - [x] `release:verify` reports every blocker in one run. It was a `&&` chain, so
485
+ it stopped at the first of four and you met them one per run.
486
+ - [x] The coverage gate is a floor and a ratchet, not one number pretending to
487
+ be both.
488
+
489
+ **Truth in the project's own records.**
490
+
491
+ - [x] The gap ledger was revalidated: all 17 rows had their own
492
+ `revalidation_command` run against this tree. `GAP-M26-005` read
493
+ `fixed` / PASS while its command _fails_. A cleared row can no longer
494
+ exist without a revalidation bound to a named commit, and
495
+ `STALE_UNVERIFIABLE` deliberately cannot unblock a release.
496
+ - [x] **A gap row could be marked fixed while its own revalidation failed.**
497
+ The `exit_code === 0` check was reachable through only one of the four
498
+ combinations the schema allows, so a `fixed` row — or an
499
+ `ALREADY_FIXED` row carrying closure evidence — was accepted with
500
+ `exit_code: 1` recorded in its own revalidation. Three of those four
501
+ routes cleared the row. The test that covered it hid the hole, because
502
+ the fixture only set `closure_evidence` for `fixed`.
503
+ - [x] The 429 GitHub dispositions no longer derive from GitHub's own `state`
504
+ field. A snapshot cannot prove itself; each disposition now needs a reason
505
+ and a verification. 215 issues are visibly awaiting a human decision,
506
+ which is the honest count.
507
+ - [x] `PRODUCT-ENHANCEMENT-ANALYSIS.md` claimed 16 shipped features; six do not
508
+ hold. It is marked `SUPERSEDED-BY` and carries a per-feature audit, kept
509
+ rather than deleted because deleting it would destroy the only evidence
510
+ that the belief was wrong.
511
+
512
+ **Found by the pre-release bug sweep, after the fix list was written.** Both
513
+ were live in 3.0.0 and neither was in the plan.
514
+
515
+ - [x] `mjolnir business-case <path>` answered a path that does not exist with
516
+ exit 0 and a clean report: "Measured: 0 of 0 findings carry a
517
+ corpus-measured FP rate". It called `runScan` directly and so never
518
+ passed through `validateScanTarget` — the check whose own comment says a
519
+ typo'd CI path must be a loud red, never a silent green. Thirteen of the
520
+ fifteen scanning verbs were already loud; this one was not, and a green
521
+ table over a path that was never scanned is the most dangerous output
522
+ this product can produce. Now exit 10.
523
+ - [x] The extended TypeScript test-file regex was probed against 27 shapes:
524
+ all 12 that must match do, all 15 that must not are rejected —
525
+ including `a.min.mts`, `a.spec.cts.bak` and `notatest.mts`.
526
+ - [x] `ships()` in `script:paths` was probed for the failure modes a new gate
527
+ usually has: an empty directory, a directory holding only untracked
528
+ files, and a symlink loop. All resolve correctly and none hangs. A
529
+ first version treated a directory as "tracked" only if `git ls-files`
530
+ listed the directory itself, which it never does — that produced two
531
+ false positives and is fixed.
532
+
533
+ ### Known open, deliberately
534
+
535
+ - [ ] **Trust certification is not claimed, and this release was authorized
536
+ without it.** `candidate:readiness` reports `BLOCKED` /
537
+ `INCONCLUSIVE` on all ten of its checks — candidate identity is not a
538
+ release candidate, the candidate SHA is not authorized, engineering
539
+ certification is not complete, and external validation, protected holdout
540
+ proof, real-world repository proof, the platform matrix, consumer install
541
+ proof and remote workflow proof are all absent.
542
+ `m26:audit` is likewise unsatisfied. Both ran, both printed, and neither
543
+ blocked the publish, because `vars.STABLE_RELEASE_AUTHORIZED` is `true` —
544
+ the same explicit override 4.0.0 shipped under.
545
+ The distinction is the point: **publication of a package is not the same
546
+ claim as a certification of it.** 5.0.0 asserts that the reporting
547
+ surface is honest, that a silent capability loss is disclosed, and that no
548
+ verdict is published without a measurement behind it. It does not assert
549
+ that a holdout corpus, a platform matrix and an external validation run
550
+ have been completed, because none of them have.
551
+ Consumers are not asked to take that on trust: the npm package publishes
552
+ the candidate manifest, which reads `NOT_CERTIFIED` and
553
+ `NOT_AUTHORIZED`, and every one of those ten blockers is enumerated above
554
+ rather than summarised. Closing them is the work that would make a
555
+ certification claim possible; it is not work this release did.
556
+ - [ ] `CERTIFICATION_STATES` is not in ladder order, so `rankOf` — the only
557
+ ordering function — ranks `KNOWN` above `TRUST-COMPLETE`, and
558
+ `requiresEvidence` asks `BLOCKED` and `DEGRADED` for a corpus their own
559
+ doc comment says they need none. Two `it.fails` tests record it, so
560
+ reordering the array fails the build. Fixing it moves `rankOf` for every
561
+ state and changes admission outcomes, so it is a contract change, not a
562
+ patch.
563
+ - [ ] 23 corpus count-drifts are unreviewed, the largest being `QA-PY-007`
564
+ firing 1332 times in apache-airflow and 92 now. Not accepted: running
565
+ `--update` without reading them would delete the only evidence that
566
+ detection changed.
567
+ - [ ] 8 of 37 corpus repositories report `PARTIAL` because test-support files
568
+ under `__tests__/` are not scanned. The verdict is honest — an incomplete
569
+ scan is not a pass — and the fix is a design decision about what "the
570
+ surface" means, not a patch.
571
+ - [ ] 215 of 229 GitHub issues are untriaged. Prepared dispositions exist in
572
+ `docs/issue-dispositions.json`; none has been applied, because closing a
573
+ public issue is destructive and is a person's decision.
574
+ - [ ] `BW-022` (one workspace model, real graph edges) is open. The plan claimed
575
+ it is "what makes `impact` honest"; it is not — `impact` never used the
576
+ graph and is honest on its own path. The graph is a no-op on an unshipped
577
+ code path, and the function now reports that instead of implying otherwise.
578
+
579
+ ### Upgrade notes
580
+
581
+ - Node-native TypeScript test files are now scanned, so a project using
582
+ `.mts`/`.cts` tests **will see new findings**. That is the fix working, not a
583
+ regression.
584
+ - `business-case` requires `--incident-cost` for any dollar figure, and now
585
+ exits 10 on a target that does not exist instead of printing an empty report.
586
+ - `enterprise sso` and `enterprise compliance` now exit non-zero with a reason.
587
+ - The trust report JSON's `tests.files` and `tests.declarations` are `null`
588
+ rather than `0` when unmeasured.
589
+
590
+ ## [4.0.0-rc.1] — 2026-09-25
591
+
592
+ ### Breaking changes
593
+
594
+ - The next published version must be a new major line. Generated CI is now
595
+ advisory-first while the direct composite Action remains blocking by default;
596
+ suppression metrics changed from configured to matched findings, and
597
+ zero-finding output no longer implies a clean or green suite. Publishing
598
+ these changes as another `3.0.0` is prohibited.
599
+
600
+ ### Changed
601
+
602
+ - **Advisory-first generated CI adoption** — `mjolnir ci install` now
603
+ defaults to non-blocking findings and non-blocking partial scans. The direct
604
+ composite Action remains blocking by default. Blocking behavior is explicit
605
+ through `--gate error`, `--gate warning`, or `fail-on`.
20
606
  - Suppression counts now report matched findings, not configured entries;
21
607
  `suppression-gate` evaluates all-tier pre-suppression findings and enforces
22
608
  total-count limits.
@@ -34,6 +620,44 @@ once shipped, so this file is the record of what changed between versions.
34
620
  before it ships; and `scripts/check-ci-local-parity.mjs`, which keeps
35
621
  `ci.yml`, `merge-verify.yml` and the local gate exercising the same
36
622
  command list. All additive — no frozen surface changed.
623
+ - **M26–M50 execution ledger** — added `docs/ROADMAP.yaml`, dated GitHub
624
+ issue/milestone/PR reconciliation, explicit issue dispositions, an
625
+ append-only gap ledger, a finite support matrix, and an external-validation
626
+ record. Approval authority and staged dependency resolution are recorded;
627
+ external evidence remains `BLOCKED`/`NOT_RUN` and cannot be synthesized.
628
+ - **Provisional capability contracts** — added bounded, non-promoting
629
+ contracts for runtime evidence, provider capabilities, QA domain records,
630
+ agent decision receipts, plugin packs, change intelligence, mutation
631
+ sensitivity, adversarial challenges, simulation, language/framework packs,
632
+ cross-repository systems, history, detector lifecycle, research, benchmark,
633
+ scale, UX parity, and Trust OS release proofs. Each has focused hostile,
634
+ stale, foreign, malformed, and recovery coverage.
635
+ - **Release control surfaces** — added exact version-surface synchronization,
636
+ RC-aware changelog validation, candidate manifest refresh/readiness,
637
+ reporter ramp policy, fail-closed M26 audit gates, and current-major-only
638
+ Action tag maintenance.
639
+ - **README release preparation** — canonical and translated README install
640
+ references, release-status blocks, and new command rows are synchronized to
641
+ `3.0.0`; English fallback blocks are explicitly marked for human translation
642
+ review, and the canonical README states the certification boundary.
643
+ - **Release proof hardening** — stable publication now generates and attaches
644
+ an SPDX SBOM, rejects same-version registry artifacts with a different
645
+ integrity digest, updates only the current `v3` Action major, and never
646
+ clobbers release assets.
647
+ - **Support matrix reconciliation** — 136 cells are classified as 90 tested,
648
+ 44 explicitly blocked, and 2 not applicable, with evidence paths and revisit
649
+ triggers checked locally.
650
+
651
+ ### Fixed
652
+
653
+ - Corpus runs now record complete rule-crash provenance, reject parser
654
+ fallbacks, and support a provenance-only refresh that cannot update counts.
655
+ - QA-PY-004 sanitizes assertion roots before constructing a pattern, removing
656
+ corpus crashes caused by nested call-like targets.
657
+ - Pathological stress runs no longer mask crashes with `|| true`; soak evidence
658
+ no longer claims parent-process RSS as child memory.
659
+ - Exact registry-install smoke can install and verify a published version
660
+ instead of substituting the checkout tarball.
37
661
 
38
662
  ## [3.0.0] — 2026-09-24
39
663
 
@@ -286,8 +910,6 @@ Complete implementation of the Mjolnir Master Engineering Roadmap & Product Spec
286
910
  - `src/commands/doctor.ts` gains check 11 (rule metadata contract validation).
287
911
  - 4 fingerprint implementations consolidated into `src/engine/finding-identity.ts`.
288
912
 
289
- ## [Unreleased] — Nordic brand pass
290
-
291
913
  ### Changed
292
914
 
293
915
  - Terminal report: section headers are marked `▍ TITLE` instead of