mjolnir-qa 3.0.0 → 4.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/dist/cli.mjs CHANGED
@@ -1,17 +1,297 @@
1
1
  #!/usr/bin/env node
2
- import { $ as correlateFindings, A as plainContext, At as parseJsonFile, B as shouldUseAscii, Bt as isValidCategory, C as runForensics, Ct as globToRegExp, D as nextStep, Dt as loadConfig, E as buildFooter, Et as isSuppressionActive, F as padTo, Ft as QA_IMPACT_LABELS, G as EVIDENCE, H as deriveScoreState, I as palette, It as RULE_CATEGORIES, J as atomicTempPath, K as TINT, L as sanitizeData, Lt as SEVERITY_ORDER$1, M as severityIcon, Mt as buildEvidenceGraph, N as box, Nt as MEASURED_FP, O as okIcon, Ot as readFileBounded, P as measure, Pt as DEDUCTIONS, Q as getReachableFiles, R as scoreGauge, Rt as deriveEvidenceLevel, S as renderSuppressions, St as createIgnoreMatcher, T as FLAKE_GLYPH, Tt as ConfigValidationError, U as headlineFor, V as wrapText, W as BADGE_BAND, X as writeFileAtomic, Y as sweepStaleTempFiles, Z as buildDependencyGraph, _t as computeCodeText, at as massCeiling, b as loadLocalRules, bt as DEFAULT_IGNORE_MATCHER, ct as getRule, dt as SCAN_ADAPTERS, et as classifyProvenance, ft as SEARCHED_FOR, gt as parseYamlGuarded, h as scan_pipeline_exports, ht as parseWorkflow, it as deductionFor, j as sectionHeader, jt as ENGINE_VERSION, k as panel, kt as isRecord$3, lt as resolveGitPath, m as runScan$1, mt as parseAzurePipeline, n as KNOWN_RULE_IDS$1, nt as capForTier, ot as RETIRED_RULE_IDS, pt as isAzurePipelineFixture, q as TRUST, rt as computeDimensions, st as RULES, tt as computeAgenticProfile, ut as runGit, v as pluginsGateOpen, vt as parseTsFile, w as sanitizeErrorText, wt as isLintFixtureDir, x as loadSuppressions, xt as LIMITS, y as renderGateNotice, yt as detectFrameworks, z as shouldColorize, zt as isAdvisoryFinding } from "./scan-pipeline-CAH9_Qgh.mjs";
2
+ import { $ as BADGE_BAND, A as plainContext, At as isLintFixtureDir, B as shouldUseAscii, Bt as DEDUCTIONS, C as runForensics, Ct as computeCodeText, D as nextStep, Dt as LIMITS, E as buildFooter, Et as DEFAULT_IGNORE_MATCHER, F as padTo, Ft as isRecord$3, G as countOrNull, Gt as deriveEvidenceLevel, H as atomicTempPath, Ht as RULE_CATEGORIES, I as palette, It as parseJsonFile, J as evidenceTag, K as deriveScoreState, Kt as isAdvisoryFinding, L as sanitizeData, Lt as ENGINE_VERSION, M as severityIcon, Mt as isSuppressionActive, N as box, Nt as loadConfig, O as okIcon, Ot as createIgnoreMatcher, P as measure, Pt as readFileBounded, Q as TRUST_RUNGS, R as scoreGauge, Rt as buildEvidenceGraph, S as renderSuppressions, St as parseYamlGuarded, T as FLAKE_GLYPH, Tt as detectFrameworks, U as sweepStaleTempFiles, Ut as SEVERITY_ORDER$1, V as wrapText, Vt as QA_IMPACT_LABELS, W as writeFileAtomic, Wt as TRUST_ORDER, X as testsAnalyzedCell, Y as headlineFor, Z as verdictFor, _t as SCAN_ADAPTERS, at as correlateFindings, b as loadLocalRules, bt as parseAzurePipeline, ct as capForTier, dt as massCeiling, et as SCORE, ft as RETIRED_RULE_IDS, gt as runGit, h as scan_pipeline_exports, ht as resolveGitPath, it as getReachableFiles, j as sectionHeader, jt as ConfigValidationError, k as panel, kt as globToRegExp, lt as computeDimensions, m as runScan$1, mt as getRule, n as KNOWN_RULE_IDS$1, nt as TINT, ot as classifyProvenance, pt as RULES, q as evidenceLevelOf, qt as isValidCategory, rt as buildDependencyGraph, st as computeAgenticProfile, tt as STATUS, ut as deductionFor, v as pluginsGateOpen, vt as SEARCHED_FOR, w as sanitizeErrorText, wt as parseTsFile, x as loadSuppressions, xt as parseWorkflow, y as renderGateNotice, yt as isAzurePipelineFixture, z as shouldColorize, zt as MEASURED_FP } from "./scan-pipeline-CCJdEKNa.mjs";
3
3
  import { accessSync, appendFileSync, chmodSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import process$1 from "node:process";
6
6
  import { fileURLToPath, pathToFileURL } from "node:url";
7
7
  import { createHash } from "node:crypto";
8
+ import { execFileSync } from "node:child_process";
8
9
  import ts, { ts as ts$1 } from "ts-morph";
9
10
  import { parse } from "yaml";
10
- import { execFileSync } from "node:child_process";
11
11
  import { createInterface } from "node:readline";
12
12
  import { Buffer as Buffer$1 } from "node:buffer";
13
13
  import { tmpdir } from "node:os";
14
14
  import { isDeepStrictEqual } from "node:util";
15
+ //#region src/claim-evidence.ts
16
+ /**
17
+ * The one place a rendered surface decides whether it may claim "clean".
18
+ *
19
+ * Trust Constitution laws 1, 2 and 11:
20
+ * 1. Unknown is not Pass.
21
+ * 2. Unsupported is not Pass.
22
+ * 11. Partial is never clean; a complete empty result may be clean, an
23
+ * incomplete empty result is INCONCLUSIVE.
24
+ *
25
+ * The prototype commands each grew their own version of this decision, and
26
+ * each of them got it subtly wrong — a hardcoded zero that read as a
27
+ * measurement, a fabricated pass on an empty run, a partial scan that exited
28
+ * clean. One function, one decision, so a surface cannot be more confident
29
+ * than the evidence it was given.
30
+ */
31
+ /**
32
+ * The single determination. Callers may not compute their own.
33
+ */
34
+ function decideClaim(evidence) {
35
+ if (!evidence.supported) return {
36
+ state: "INCONCLUSIVE",
37
+ exitCode: 2,
38
+ reason: evidence.unsupportedReason ? `UNSUPPORTED: ${evidence.unsupportedReason}` : "UNSUPPORTED: this surface cannot make a claim about the target."
39
+ };
40
+ if (evidence.partial) return {
41
+ state: "INCONCLUSIVE",
42
+ exitCode: 2,
43
+ reason: "PARTIAL: the analysis did not cover the whole surface, so no clean or blocking claim is made."
44
+ };
45
+ if (evidence.blockingFindings > 0) return {
46
+ state: "BLOCKED",
47
+ exitCode: 1,
48
+ reason: `BLOCKED: ${evidence.blockingFindings} finding(s) at the configured gate.`
49
+ };
50
+ return {
51
+ state: "READY",
52
+ exitCode: 0,
53
+ reason: "READY: complete analysis, no findings at the configured gate."
54
+ };
55
+ }
56
+ /**
57
+ * Fail-closed for a surface that has no measurement of its own. A prototype
58
+ * command that cannot measure must say so rather than render a plausible
59
+ * number, and must not exit clean while doing it.
60
+ */
61
+ function unmeasuredClaim(surface, why) {
62
+ return {
63
+ state: "INCONCLUSIVE",
64
+ exitCode: 2,
65
+ reason: `UNMEASURED: ${surface} cannot measure this. ${why}`
66
+ };
67
+ }
68
+ /**
69
+ * The one exit-code matrix (plan V5-002).
70
+ *
71
+ * Every command that finishes an analysis routes its exit code through this
72
+ * function. The previous arrangement had each surface compute its own, and
73
+ * the gaps were not subtle:
74
+ *
75
+ * - `--score` returned `exitForFindings(...)` with no `partial` check at
76
+ * all, so `--score` on a truncated scan with zero findings exited 0. That
77
+ * is the flag CI badge and baseline tooling use.
78
+ * - `exitForFindings` knew nothing about partial, so any caller that forgot
79
+ * the check silently produced a clean result.
80
+ *
81
+ * The order is the contract: an incomplete analysis is INCONCLUSIVE before
82
+ * findings are considered at all. There is no gate setting that turns a
83
+ * partial scan into a pass — `--blocking none` suppresses *findings*, not the
84
+ * fact that the analysis did not finish.
85
+ */
86
+ function scanExitCode(input) {
87
+ if (input.partial) return 2;
88
+ if (input.gate === "advisory") return 0;
89
+ const gateSeverities = input.gate === "warning" ? ["error", "warning"] : ["error"];
90
+ return input.findings.some((f) => gateSeverities.includes(f.severity) && input.isAdvisory?.(f) !== true) ? 1 : 0;
91
+ }
92
+ //#endregion
93
+ //#region src/frameworks/framework-inventory.ts
94
+ const FRAMEWORK_INVENTORY = [
95
+ {
96
+ frameworkId: "playwright",
97
+ entityType: "E2E_FRAMEWORK",
98
+ language: "TypeScript/JavaScript",
99
+ executorAdapterIds: ["typescript"],
100
+ maturity: "F4",
101
+ supportStatus: "OFFICIAL_PARTIAL",
102
+ targetMaturity: "F5",
103
+ targetSupportStatus: "OFFICIAL_FULL",
104
+ validatedVersions: [
105
+ "1.44",
106
+ "1.45",
107
+ "1.46",
108
+ "1.47",
109
+ "1.48"
110
+ ],
111
+ keyGapForNextLevel: "GAP-PW-006 parallelism/worker safety analysis"
112
+ },
113
+ {
114
+ frameworkId: "jest",
115
+ entityType: "TEST_FRAMEWORK",
116
+ language: "TypeScript/JavaScript",
117
+ executorAdapterIds: ["typescript"],
118
+ maturity: "F3",
119
+ supportStatus: "OFFICIAL_PARTIAL",
120
+ targetMaturity: "F4",
121
+ targetSupportStatus: "OFFICIAL_FULL",
122
+ validatedVersions: ["29", "30"],
123
+ keyGapForNextLevel: "GAP-JEST-004 lifecycle modeling"
124
+ },
125
+ {
126
+ frameworkId: "vitest",
127
+ entityType: "TEST_FRAMEWORK",
128
+ language: "TypeScript/JavaScript",
129
+ executorAdapterIds: ["typescript"],
130
+ maturity: "F3",
131
+ supportStatus: "OFFICIAL_PARTIAL",
132
+ targetMaturity: "F4",
133
+ targetSupportStatus: "OFFICIAL_FULL",
134
+ validatedVersions: [
135
+ "1.6",
136
+ "2.0",
137
+ "2.1"
138
+ ],
139
+ keyGapForNextLevel: "GAP-VIT-004 lifecycle modeling"
140
+ },
141
+ {
142
+ frameworkId: "pytest",
143
+ entityType: "TEST_FRAMEWORK",
144
+ language: "Python",
145
+ executorAdapterIds: ["python"],
146
+ maturity: "F2",
147
+ supportStatus: "OFFICIAL_PARTIAL",
148
+ targetMaturity: "F3",
149
+ targetSupportStatus: "OFFICIAL_FULL",
150
+ validatedVersions: [
151
+ "7.4",
152
+ "8.0",
153
+ "8.1",
154
+ "8.2",
155
+ "8.3"
156
+ ],
157
+ keyGapForNextLevel: "GAP-PY-004 lifecycle modeling"
158
+ },
159
+ {
160
+ frameworkId: "junit",
161
+ entityType: "TEST_FRAMEWORK",
162
+ language: "Java",
163
+ executorAdapterIds: ["java"],
164
+ maturity: "F2",
165
+ supportStatus: "OFFICIAL_PARTIAL",
166
+ targetMaturity: "F3",
167
+ targetSupportStatus: "OFFICIAL_FULL",
168
+ validatedVersions: ["5.10", "5.11"],
169
+ keyGapForNextLevel: "GAP-JU-004 lifecycle modeling"
170
+ },
171
+ {
172
+ frameworkId: "nunit",
173
+ entityType: "TEST_FRAMEWORK",
174
+ language: "C#",
175
+ executorAdapterIds: ["csharp"],
176
+ maturity: "F2",
177
+ supportStatus: "OFFICIAL_PARTIAL",
178
+ targetMaturity: "F3",
179
+ targetSupportStatus: "OFFICIAL_FULL",
180
+ validatedVersions: ["3.14", "4.2"],
181
+ keyGapForNextLevel: "GAP-NU-004 lifecycle modeling"
182
+ },
183
+ {
184
+ frameworkId: "xunit",
185
+ entityType: "TEST_FRAMEWORK",
186
+ language: "C#",
187
+ executorAdapterIds: ["csharp"],
188
+ maturity: "F2",
189
+ supportStatus: "OFFICIAL_PARTIAL",
190
+ targetMaturity: "F3",
191
+ targetSupportStatus: "OFFICIAL_FULL",
192
+ validatedVersions: ["2.9"],
193
+ keyGapForNextLevel: "GAP-XU-004 lifecycle modeling"
194
+ },
195
+ {
196
+ frameworkId: "cypress",
197
+ entityType: "E2E_FRAMEWORK",
198
+ language: "TypeScript/JavaScript",
199
+ executorAdapterIds: ["typescript"],
200
+ maturity: "F2",
201
+ supportStatus: "EXPERIMENTAL",
202
+ targetMaturity: "F3",
203
+ targetSupportStatus: "OFFICIAL_PARTIAL",
204
+ validatedVersions: ["13"],
205
+ keyGapForNextLevel: "GAP-CY-004 lifecycle modeling"
206
+ },
207
+ {
208
+ frameworkId: "selenium",
209
+ entityType: "AUTOMATION_LIBRARY",
210
+ language: "Java/TypeScript/Python",
211
+ executorAdapterIds: [
212
+ "typescript",
213
+ "python",
214
+ "java"
215
+ ],
216
+ maturity: "F1",
217
+ supportStatus: "EXPERIMENTAL",
218
+ targetMaturity: "F2",
219
+ targetSupportStatus: "OFFICIAL_PARTIAL",
220
+ validatedVersions: [
221
+ "4.20",
222
+ "4.21",
223
+ "4.22",
224
+ "4.23",
225
+ "4.24",
226
+ "4.25"
227
+ ],
228
+ keyGapForNextLevel: "GAP-SE-002 AST-based usage analysis"
229
+ },
230
+ {
231
+ frameworkId: "testng",
232
+ entityType: "TEST_FRAMEWORK",
233
+ language: "Java",
234
+ executorAdapterIds: ["java"],
235
+ maturity: "F1",
236
+ supportStatus: "EXPERIMENTAL",
237
+ targetMaturity: "F2",
238
+ targetSupportStatus: "OFFICIAL_PARTIAL",
239
+ validatedVersions: ["7.10", "7.11"],
240
+ keyGapForNextLevel: "GAP-TN-002 AST-based usage analysis"
241
+ },
242
+ {
243
+ frameworkId: "github-actions",
244
+ entityType: "CI_PROVIDER",
245
+ language: "YAML",
246
+ executorAdapterIds: ["github-actions"],
247
+ maturity: "F3",
248
+ supportStatus: "OFFICIAL_PARTIAL",
249
+ targetMaturity: "F4",
250
+ targetSupportStatus: "OFFICIAL_FULL",
251
+ validatedVersions: ["v4"],
252
+ keyGapForNextLevel: "GAP-GHA-004 advanced matrix strategy modeling"
253
+ },
254
+ {
255
+ frameworkId: "azure-devops",
256
+ entityType: "CI_PROVIDER",
257
+ language: "YAML",
258
+ executorAdapterIds: ["azure-pipelines"],
259
+ maturity: "F3",
260
+ supportStatus: "OFFICIAL_PARTIAL",
261
+ targetMaturity: "F4",
262
+ targetSupportStatus: "OFFICIAL_FULL",
263
+ validatedVersions: ["2024"],
264
+ keyGapForNextLevel: "GAP-AZ-004 stage dependency graph modeling"
265
+ },
266
+ {
267
+ frameworkId: "jenkins",
268
+ entityType: "CI_PROVIDER",
269
+ language: "Groovy/YAML",
270
+ executorAdapterIds: ["jenkins"],
271
+ maturity: "F3",
272
+ supportStatus: "OFFICIAL_PARTIAL",
273
+ targetMaturity: "F4",
274
+ targetSupportStatus: "OFFICIAL_FULL",
275
+ validatedVersions: ["2.440", "2.450"],
276
+ keyGapForNextLevel: "GAP-JK-004 declarative pipeline advanced features"
277
+ },
278
+ {
279
+ frameworkId: "gitlab-ci",
280
+ entityType: "CI_PROVIDER",
281
+ language: "YAML",
282
+ executorAdapterIds: [],
283
+ maturity: "F0",
284
+ supportStatus: "UNSUPPORTED",
285
+ targetMaturity: "F1",
286
+ targetSupportStatus: "DISCOVERED",
287
+ validatedVersions: [],
288
+ keyGapForNextLevel: "GAP-GL-001 basic pipeline discovery"
289
+ }
290
+ ];
291
+ function getFrameworkById(frameworkId) {
292
+ return FRAMEWORK_INVENTORY.find((f) => f.frameworkId === frameworkId);
293
+ }
294
+ //#endregion
15
295
  //#region src/commands/help.ts
16
296
  /** Ordered registry — grouped by the same categories the overview uses. */
17
297
  const HELP_ENTRIES = [
@@ -247,6 +527,80 @@ const HELP_ENTRIES = [
247
527
  summary: "run as a read-only MCP server over stdio (scan / explain / diff / verify / forensics / triage / pw-report)",
248
528
  usage: "mjolnir mcp",
249
529
  examples: ["mjolnir mcp"]
530
+ },
531
+ {
532
+ verb: "release-report",
533
+ summary: "release readiness verdict: GO, CONDITIONAL GO, or NO-GO",
534
+ usage: "mjolnir release-report [path] [--since <ref>] [--history <json>]",
535
+ examples: ["mjolnir release-report . --since HEAD~1"]
536
+ },
537
+ {
538
+ verb: "report",
539
+ summary: "generate a Playwright-compatible report from scan results",
540
+ usage: "mjolnir report [path] [--output <file>]",
541
+ examples: ["mjolnir report . --output playwright-report.json"]
542
+ },
543
+ {
544
+ verb: "trend",
545
+ summary: "record, show, or diff local quality trend snapshots",
546
+ usage: "mjolnir trend <record|show|diff> [path] [--limit <N>]",
547
+ examples: ["mjolnir trend record .", "mjolnir trend show ."]
548
+ },
549
+ {
550
+ verb: "exec-report",
551
+ summary: "generate executive quality KPIs, risk, and recommendations (advisory)",
552
+ usage: "mjolnir exec-report [path]",
553
+ examples: ["mjolnir exec-report ."]
554
+ },
555
+ {
556
+ verb: "policy",
557
+ summary: "initialize, validate, or check team quality policy gates",
558
+ usage: "mjolnir policy <init|validate|check> [path] [--policy <file>]",
559
+ examples: ["mjolnir policy init .", "mjolnir policy check . --policy mjolnir.policy.json"]
560
+ },
561
+ {
562
+ verb: "quarantine",
563
+ summary: "review deterministic quarantine proposals (read-only prototype)",
564
+ usage: "mjolnir quarantine <list|review|stats> [path]",
565
+ examples: ["mjolnir quarantine list .", "mjolnir quarantine stats"]
566
+ },
567
+ {
568
+ verb: "analyze",
569
+ summary: "run bounded cross-file analysis with explicit findings",
570
+ usage: "mjolnir analyze [path] --cross-file",
571
+ examples: ["mjolnir analyze . --cross-file"]
572
+ },
573
+ {
574
+ verb: "ci-adapter",
575
+ summary: "generate GitHub Actions, GitLab CI, or Jenkins templates",
576
+ usage: "mjolnir ci-adapter <github|gitlab|jenkins> [target]",
577
+ examples: ["mjolnir ci-adapter github ."]
578
+ },
579
+ {
580
+ verb: "dashboard",
581
+ summary: "generate a self-contained quality dashboard HTML artifact",
582
+ usage: "mjolnir dashboard [path] [--output <file>]",
583
+ examples: ["mjolnir dashboard . --output dashboard.html"]
584
+ },
585
+ {
586
+ verb: "enterprise",
587
+ summary: "capability manifest for deployment review — records what this product does NOT provide (no server, no SSO, no hosted tier, no compliance packet)",
588
+ usage: "mjolnir enterprise config [output-dir]",
589
+ examples: ["mjolnir enterprise config ./enterprise-output"],
590
+ next: "sso and compliance subcommands were removed in 4.0 — they wrote artifacts describing capabilities Mjölnir does not have. Read the manifest's `notProvided` list instead."
591
+ },
592
+ {
593
+ verb: "business-case",
594
+ summary: "measured false-positive rates per finding; a cost figure only with --incident-cost",
595
+ usage: "mjolnir business-case [path] [--incident-cost <n>] [--strict]",
596
+ examples: ["mjolnir business-case .", "mjolnir business-case . --incident-cost 25000"],
597
+ next: "Mjölnir will not estimate the cost of a false-green incident for you — that number has to come from your own incident history."
598
+ },
599
+ {
600
+ verb: "maturity",
601
+ summary: "presence of named QA artifacts (a signal, not a maturity score)",
602
+ usage: "mjolnir maturity <assess|levels> [path]",
603
+ examples: ["mjolnir maturity assess .", "mjolnir maturity levels"]
250
604
  }
251
605
  ];
252
606
  /** Scan-flag entries documented per-flag via the overview. */
@@ -400,7 +754,9 @@ const GROUPS = [
400
754
  "baseline",
401
755
  "diff",
402
756
  "verify",
403
- "ci-integrity"
757
+ "ci-integrity",
758
+ "ci-adapter",
759
+ "policy"
404
760
  ]
405
761
  },
406
762
  {
@@ -433,7 +789,17 @@ const GROUPS = [
433
789
  "trust-trend",
434
790
  "evidence-graph",
435
791
  "framework-maturity",
436
- "suppression-gate"
792
+ "suppression-gate",
793
+ "business-case",
794
+ "release-report",
795
+ "report",
796
+ "trend",
797
+ "exec-report",
798
+ "quarantine",
799
+ "analyze",
800
+ "dashboard",
801
+ "enterprise",
802
+ "maturity"
437
803
  ]
438
804
  },
439
805
  {
@@ -569,6 +935,133 @@ function renderRootHelp(schemaVersion = 1, options = {}) {
569
935
  return lines.join("\n");
570
936
  }
571
937
  //#endregion
938
+ //#region src/integrations/sentry.ts
939
+ /**
940
+ * Sentry crash reporting — opt-in, and inert unless a DSN is configured.
941
+ *
942
+ * Why opt-in: Mjölnir ships as an `npx` CLI over other people's private
943
+ * repositories. A tool that reads a codebase must never phone home on its
944
+ * own, so the ONLY switch is an explicit `SENTRY_DSN` in the environment.
945
+ * Absent that, every function here returns immediately and `@sentry/node` is
946
+ * never imported — zero network, zero added startup cost on a path whose
947
+ * whole selling point is scan speed.
948
+ *
949
+ * Why an optional peer dependency: `@sentry/node` is ~1.5 MB unpacked and
950
+ * nothing in a code scanner needs it. Making it a hard `dependency` would
951
+ * tax every install for a feature most users never turn on, so it is an
952
+ * optional peer: installs on request (`npm i @sentry/node`), absent by
953
+ * default, and a missing module is handled as "reporting unavailable"
954
+ * rather than as a crash.
955
+ *
956
+ * Capture points are deliberately the two EXISTING top-level catch blocks
957
+ * (cli.ts entry, mcp/stdio.ts) rather than `process.on("uncaughtException")`
958
+ * / `("unhandledRejection")` handlers. Registering those would silently
959
+ * change the CLI's frozen exit-code contract (§24.1) and could keep a
960
+ * process alive after a fatal error. One capture point per surface is
961
+ * enough to answer "did Mjölnir itself break, and where".
962
+ *
963
+ * What is deliberately NOT sent: no user data (`sendDefaultPii: false`), no
964
+ * traced request/span data (`tracesSampleRate: 0` — a batch CLI has no
965
+ * request to trace), no console breadcrumbs, and no scan findings or file
966
+ * contents. Events carry the error, its stack, the release, and the surface
967
+ * that raised it.
968
+ */
969
+ /** The loaded SDK, or null while reporting is off/unavailable. */
970
+ let sdk = null;
971
+ /**
972
+ * Flush budget for the process-exit paths. Long enough for one HTTPS
973
+ * envelope on a slow link, short enough that a broken network cannot turn
974
+ * a crashed CLI into a hung one. Sentry's own default is 2s.
975
+ */
976
+ const FLUSH_TIMEOUT_MS = 2e3;
977
+ /** Writes a diagnostic without touching stdout. */
978
+ function warn(message) {
979
+ process.stderr.write(`mjolnir: sentry — ${message}\n`);
980
+ }
981
+ /**
982
+ * The release name Sentry groups events under. Matches the npm package
983
+ * name so a release created from a tag and a release reported by the SDK
984
+ * are the same release, not two half-populated ones.
985
+ */
986
+ const SENTRY_RELEASE = `mjolnir-qa@${ENGINE_VERSION}`;
987
+ /**
988
+ * Resolve the environment tag. Unset means "a developer's machine" unless
989
+ * CI says otherwise: a `npx` run is not a production event, and filing
990
+ * those under `production` would poison the issue stream with noise from
991
+ * end users triaging their own repos.
992
+ */
993
+ function resolveEnvironment() {
994
+ const fromEnv = process.env.SENTRY_ENVIRONMENT?.trim();
995
+ if (fromEnv) return fromEnv;
996
+ return process.env.CI ? "ci" : "local";
997
+ }
998
+ /**
999
+ * Turn reporting on if — and only if — a DSN is configured. Safe to call
1000
+ * from every entry point: the second call is a no-op that returns the
1001
+ * already-decided answer, so a process that reaches two surfaces cannot
1002
+ * initialize the SDK twice.
1003
+ *
1004
+ * Returns true when reporting is live. Never throws: a monitoring
1005
+ * integration that can take down the tool it monitors has failed at its
1006
+ * one job.
1007
+ */
1008
+ async function initSentry() {
1009
+ if (sdk) return true;
1010
+ const dsn = process.env.SENTRY_DSN?.trim();
1011
+ if (!dsn) return false;
1012
+ let loaded;
1013
+ try {
1014
+ loaded = await import("@sentry/node");
1015
+ } catch {
1016
+ warn("SENTRY_DSN is set but @sentry/node is not installed. Install it with: npm install @sentry/node");
1017
+ return false;
1018
+ }
1019
+ try {
1020
+ loaded.init({
1021
+ dsn,
1022
+ release: SENTRY_RELEASE,
1023
+ environment: resolveEnvironment(),
1024
+ tracesSampleRate: 0,
1025
+ sendDefaultPii: false,
1026
+ attachStacktrace: true
1027
+ });
1028
+ } catch (error) {
1029
+ warn(`SDK init failed (${error instanceof Error ? error.message : String(error)}). Crash reporting stays off.`);
1030
+ return false;
1031
+ }
1032
+ sdk = loaded;
1033
+ return true;
1034
+ }
1035
+ /**
1036
+ * Report one fatal error. `surface` names the entry point that raised it
1037
+ * ("cli" or "mcp") so the issue stream can be split by how Mjölnir was
1038
+ * being used when it broke. A non-Error throw is reported as-is rather
1039
+ * than dropped: a hostile throw value is exactly the case worth seeing.
1040
+ */
1041
+ function captureInternalError(error, surface) {
1042
+ if (!sdk) return;
1043
+ try {
1044
+ sdk.captureException(error, { tags: { surface } });
1045
+ } catch (captureError) {
1046
+ warn(`capture failed (${captureError instanceof Error ? captureError.message : String(captureError)}).`);
1047
+ }
1048
+ }
1049
+ /**
1050
+ * Deliver anything buffered before the process exits. Awaits the SDK's
1051
+ * own transport drain and returns whether the envelope was handed off;
1052
+ * the caller is on an exit path, so the answer is deliberately not
1053
+ * surfaced as a failure — a lost crash report must not change the exit
1054
+ * code a caller (or CI) is about to read.
1055
+ */
1056
+ async function flushSentry(timeoutMs = FLUSH_TIMEOUT_MS) {
1057
+ if (!sdk) return false;
1058
+ try {
1059
+ return await sdk.flush(timeoutMs);
1060
+ } catch {
1061
+ return false;
1062
+ }
1063
+ }
1064
+ //#endregion
572
1065
  //#region src/integrations/ci-install.ts
573
1066
  /**
574
1067
  * CI integration (Sprint-Plan W7): generates .github/workflows/mjolnir.yml
@@ -583,8 +1076,10 @@ function renderRootHelp(schemaVersion = 1, options = {}) {
583
1076
  * `ciInstall` silently overwrote hand-customized workflows. The template now
584
1077
  * mirrors the dogfooded `.github/workflows/mjolnir.yml` (pinned action SHAs,
585
1078
  * `if: always()` on reporting steps, a real gate step that reads
586
- * `mjolnir.json`, partial scans never block) and `ciInstall` refuses to
587
- * replace a customized workflow without an explicit `--force`.
1079
+ * `mjolnir.json`, and `ciInstall` refuses to replace a customized workflow
1080
+ * without an explicit `--force`. A partial scan FAILS the generated gate: an
1081
+ * analysis that did not finish has not proven anything about the surface it
1082
+ * did not reach.
588
1083
  */
589
1084
  /**
590
1085
  * The gate-check script embedded in generated workflows (and executed
@@ -609,8 +1104,8 @@ function gateScript(gate) {
609
1104
  " process.exit(1);",
610
1105
  "}",
611
1106
  "if (r.partial === true) {",
612
- " process.stdout.write(\"Scan was PARTIAL - some files were not analyzed; gate not enforced.\\n\");",
613
- " process.exit(0);",
1107
+ " process.stderr.write(\"Scan was PARTIAL - some files were not analyzed, so the surface is unverified. Failing: an incomplete scan is not a pass.\\n\");",
1108
+ " process.exit(1);",
614
1109
  "}",
615
1110
  "const findings = Array.isArray(r.findings) ? r.findings : [];",
616
1111
  "const errors = findings.filter(function (f) { return f && f.severity === \"error\"; }).length;",
@@ -621,6 +1116,39 @@ function gateScript(gate) {
621
1116
  ].join("\n");
622
1117
  }
623
1118
  /**
1119
+ * The npm version a generated workflow should install.
1120
+ *
1121
+ * A generated CI workflow that names a version npm does not have is not a
1122
+ * configuration file, it is a 404 on the first run. While the working
1123
+ * candidate is a release candidate, `CLI_VERSION` is not on the registry, so
1124
+ * the npx template used to emit a tarball URL that could not resolve.
1125
+ *
1126
+ * Resolution order:
1127
+ * 1. `publishedStable` from the nearest package.json — the record of the last
1128
+ * published release, present in current builds.
1129
+ * 2. That package's own `version` — correct for an installed published
1130
+ * package (3.0.0 shipped before `publishedStable` existed).
1131
+ * 3. `CLI_VERSION` — a development build, where naming the working version
1132
+ * is the honest description of what is being generated.
1133
+ */
1134
+ function publishedVersionForInstall(startDir = import.meta.dirname) {
1135
+ let dir = resolve(startDir);
1136
+ for (let depth = 0; depth < 12; depth++) {
1137
+ const candidate = join(dir, "package.json");
1138
+ if (existsSync(candidate)) try {
1139
+ const parsed = JSON.parse(readFileSync(candidate, "utf8"));
1140
+ if (parsed.name === "mjolnir-qa") {
1141
+ if (typeof parsed.publishedStable === "string" && parsed.publishedStable) return parsed.publishedStable;
1142
+ if (typeof parsed.version === "string" && parsed.version) return parsed.version;
1143
+ }
1144
+ } catch {}
1145
+ const parent = dirname(dir);
1146
+ if (parent === dir) break;
1147
+ dir = parent;
1148
+ }
1149
+ return ENGINE_VERSION;
1150
+ }
1151
+ /**
624
1152
  * Template v2 (Terminal + CI UX Overhaul plan, M4): the summary step
625
1153
  * calls `mjolnir summary mjolnir.json` — annotations + step summary via
626
1154
  * ONE emitter — instead of the v1 inline SUMMARY_SCRIPT. The gate
@@ -661,6 +1189,12 @@ function indentBlock(text, spaces) {
661
1189
  return text.split("\n").map((l) => l.length > 0 ? pad + l : l).join("\n");
662
1190
  }
663
1191
  /** The generated workflow for one gate level. Exported for template tests. */
1192
+ /**
1193
+ * The npm version both generated templates install. Resolved once at module
1194
+ * load from the running package's own manifest, so a generated workflow can
1195
+ * never name a version the registry does not have.
1196
+ */
1197
+ const INSTALL_VERSION = publishedVersionForInstall();
664
1198
  const TEMPLATE = (gate) => `name: Mjölnir
665
1199
 
666
1200
  on:
@@ -686,15 +1220,15 @@ jobs:
686
1220
  persist-credentials: false
687
1221
  - name: Scan changed code (exit 1/2 is data — the gate step decides)
688
1222
  continue-on-error: true
689
- run: npx --yes https://registry.npmjs.org/mjolnir-qa/-/mjolnir-qa-${ENGINE_VERSION}.tgz . --scope changed --json > mjolnir.json
1223
+ run: npx --yes https://registry.npmjs.org/mjolnir-qa/-/mjolnir-qa-${INSTALL_VERSION}.tgz . --scope changed --json > mjolnir.json
690
1224
  - name: Annotations + Job Summary
691
1225
  if: always()
692
1226
  continue-on-error: true
693
- run: npx --yes https://registry.npmjs.org/mjolnir-qa/-/mjolnir-qa-${ENGINE_VERSION}.tgz summary mjolnir.json
1227
+ run: npx --yes https://registry.npmjs.org/mjolnir-qa/-/mjolnir-qa-${INSTALL_VERSION}.tgz summary mjolnir.json
694
1228
  - name: Render PR comment
695
1229
  if: always()
696
1230
  continue-on-error: true
697
- run: npx --yes https://registry.npmjs.org/mjolnir-qa/-/mjolnir-qa-${ENGINE_VERSION}.tgz pr-comment --from mjolnir.json > mjolnir-comment.md
1231
+ run: npx --yes https://registry.npmjs.org/mjolnir-qa/-/mjolnir-qa-${INSTALL_VERSION}.tgz pr-comment --from mjolnir.json > mjolnir-comment.md
698
1232
  - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
699
1233
  if: always()
700
1234
  with:
@@ -764,23 +1298,35 @@ const GATES = [
764
1298
  ];
765
1299
  /**
766
1300
  * The action-ref the action-based template pins (P1: distribution).
767
- * `v1` is the major moving tag release.yml's action-tags job maintains on
768
- * every stable release — Marketplace convention. The generated workflow
769
- * pins the major, never @latest: a new release must not change gate
770
- * semantics without a commit of the consumer's.
1301
+ * The current `v3` major is maintained on every stable release by the
1302
+ * dedicated Action tag workflow. The generated workflow pins an immutable
1303
+ * Action SHA, never @latest.
771
1304
  */
772
1305
  const ACTION_REF = "Sergey-Bar/Mjolnir@4a588bc62d517bc85fc44c0eae64c6587d3bf70b";
773
1306
  /**
774
1307
  * The action-based workflow for one gate level (P1.3): the root
775
1308
  * action.yml does checkout-independent scanning — setup-node, the scan
776
1309
  * itself (writing mjolnir.json for the reporting steps), and the gate
777
- * via the action's `fail-on` input. The action owns the gate: with
778
- * fail-on error/warning its step exits 1 on findings at the gate, so the
779
- * workflow needs no separate gate step; partial scans never block (the
780
- * action downgrades exit 2 to a loud warning, per the frozen exit-code
781
- * contract). Reporting steps run `if: always()` exactly like the npx
782
- * template. Advisory mode adds an explicit advisory note as the last
783
- * step so the job summary says "never blocking" in plain words.
1310
+ * via the action's `fail-on` input. Reporting steps run `if: always()`
1311
+ * exactly like the npx template, so a run that found something still
1312
+ * produces a report.
1313
+ *
1314
+ * Three defects this template used to ship, all of which made a check that
1315
+ * could never go red:
1316
+ *
1317
+ * 1. The gate read `steps.mjolnir.outputs.exit`. The Action exposes
1318
+ * `exit_code` — and exposing it as `exit-code` was itself the bug: a
1319
+ * hyphenated output name parses as subtraction in an expression, so the
1320
+ * read produced an empty string, neither branch fired, and the gate exited
1321
+ * 0 on findings. This is the same class as the Action output-id mismatch
1322
+ * fixed in `action.yml`; the generated copy had to be fixed too, or every
1323
+ * new user inherited it.
1324
+ * 2. Exit 2 (partial) was downgraded to a warning. An analysis that did not
1325
+ * finish is not a pass — see the exit-code contract in docs/VERSIONING.md.
1326
+ * 3. `version:` was pinned to the engine's working version, which while the
1327
+ * candidate is a release candidate is not on npm, so the generated
1328
+ * workflow could not install what it asked for. The input is omitted now
1329
+ * and the Action's own default (the published stable) applies.
784
1330
  */
785
1331
  const ACTION_TEMPLATE = (gate) => `name: Mjölnir
786
1332
 
@@ -813,7 +1359,6 @@ jobs:
813
1359
  scope: changed
814
1360
  format: json
815
1361
  fail-on: ${gate === "advisory" ? "none" : gate}
816
- version: ${ENGINE_VERSION}
817
1362
  pr-comment: "false"
818
1363
  trust-artifact: "false"
819
1364
  annotations: "true"
@@ -828,15 +1373,16 @@ jobs:
828
1373
  if: always()
829
1374
  shell: bash
830
1375
  env:
831
- MJ_SCAN_EXIT: \${{ steps.mjolnir.outputs.exit }}
1376
+ MJ_SCAN_EXIT: \${{ steps.mjolnir.outputs.exit_code }}
832
1377
  run: |
833
- if [ "$MJ_SCAN_EXIT" = "1" ]; then
834
- exit 1
835
- fi
836
- if [ "$MJ_SCAN_EXIT" = "2" ]; then
837
- echo "::warning::Mjolnir analysis is partial; reporting remains visible but the gate is not claimed."
838
- fi
839
- exit 0
1378
+ set -euo pipefail
1379
+ case "\${MJ_SCAN_EXIT:-}" in
1380
+ 0) echo "Mjölnir scan clean at the configured gate." ;;
1381
+ 1) echo "::error::Mjölnir gate failed: findings at the configured gate."; exit 1 ;;
1382
+ 2) echo "::error::Mjölnir scan was PARTIAL — the surface was not fully analyzed, so no clean claim is made."; exit 1 ;;
1383
+ "") echo "::error::Mjölnir produced no exit code; the action step did not run."; exit 1 ;;
1384
+ *) echo "::error::Mjölnir exited \${MJ_SCAN_EXIT} (usage/internal error)."; exit 1 ;;
1385
+ esac
840
1386
 
841
1387
  publish:
842
1388
  needs: scan
@@ -912,7 +1458,7 @@ function ciInstall(root, gate, options = {}) {
912
1458
  diffSummary: summarizeContentDiff(current, template)
913
1459
  };
914
1460
  }
915
- writeFileSync(target, template);
1461
+ writeFileAtomic(target, template);
916
1462
  return {
917
1463
  written: target,
918
1464
  existed,
@@ -2963,16 +3509,6 @@ function renderTerminal(result, opts) {
2963
3509
  appendFooter(lines, result, ui);
2964
3510
  return lines.join("\n");
2965
3511
  }
2966
- /**
2967
- * Contract-stable three-band verdict (property-locked in
2968
- * tests/scoring-precision.spec.ts). Delegates to the ScoreState model —
2969
- * 100 keeps returning WORTHY here; the FORGED premium treatment lives
2970
- * in the dedicated block, not in this public mapping.
2971
- */
2972
- function verdictFor(score) {
2973
- const verdict = deriveScoreState(score).verdict;
2974
- return verdict === "FORGED" ? "WORTHY" : verdict;
2975
- }
2976
3512
  /** Appends the score, gauge, verdict, and honesty metadata for the full scan. */
2977
3513
  function appendScoreSection(lines, result, p, width, ascii) {
2978
3514
  const state = deriveScoreState(result.score);
@@ -3109,26 +3645,9 @@ function appendNextActions(lines, display, fullResult, ui) {
3109
3645
  } else if (partial) lines.push(nextStep("mjolnir --verbose", ui));
3110
3646
  lines.push("");
3111
3647
  }
3112
- function evidenceTag$1(f) {
3113
- const level = f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence);
3114
- let tag = `${level} · ${level === "E2" ? "deterministic" : level === "E1" ? "heuristic" : "observation"}`;
3115
- if (f.measuredFpRate !== void 0) {
3116
- tag += ` · measured FP ${Math.round(f.measuredFpRate * 100)}%`;
3117
- if (f.measuredFpN !== void 0) tag += ` · n=${f.measuredFpN}`;
3118
- }
3119
- if (f.trustLevel !== void 0) tag += ` · trust ${f.trustLevel}`;
3120
- if (f.runtimeCorroboration !== void 0) {
3121
- const c = f.runtimeCorroboration;
3122
- let label = "file executed";
3123
- if (c.level === "defect") label = "defect corroborated";
3124
- else if (c.level === "test") label = "test executed";
3125
- tag += ` · runtime: ${label}`;
3126
- }
3127
- return `[${tag}]`;
3128
- }
3129
3648
  /** Deterministic per-severity verification hint: what re-running should
3130
- * show after the fix lands. Deduction is the honest, evidence-discounted
3131
- * number this finding costs right now. */
3649
+ * show after the fix lands. Deduction is the honest, evidence-discounted
3650
+ * number this finding costs right now. */
3132
3651
  function verifyHint(f) {
3133
3652
  const pts = deductionFor(f);
3134
3653
  if (f.severity === "error") return pts > 0 ? `Re-run mjolnir after the change — the gate should stop failing and the score should recover by ${pts}.` : "Re-run mjolnir after the change — the finding should no longer appear.";
@@ -3141,7 +3660,7 @@ function toCard(f, tone) {
3141
3660
  severity: f.severity,
3142
3661
  loc: `${sanitizeData(f.ruleId)} · ${sanitizeData(f.file)}:${f.line}`,
3143
3662
  problem: sanitizeData(problem),
3144
- evidence: evidenceTag$1(f),
3663
+ evidence: evidenceTag(f),
3145
3664
  impact: `${f.qaImpact} — ${sanitizeData(f.why)}`,
3146
3665
  fix: sanitizeData(f.fix),
3147
3666
  verify: verifyHint(f)
@@ -3261,7 +3780,7 @@ function appendFindings(lines, result, counts, verbose, ui, tone) {
3261
3780
  continue;
3262
3781
  }
3263
3782
  const groupHead = ` ${severityIcon(maxSeverity(unit.findings), ui)} ${p.bold(sanitizeData(unit.ruleId))} ${p.dim(`× ${n} — same fix applies`)}`;
3264
- const groupEvidence = evidenceTag$1(first);
3783
+ const groupEvidence = evidenceTag(first);
3265
3784
  if (measure(`${groupHead} ${groupEvidence}`) <= width) lines.push(`${groupHead} ${p.dim(groupEvidence)}`);
3266
3785
  else {
3267
3786
  lines.push(groupHead);
@@ -3350,7 +3869,7 @@ function appendFooter(lines, result, ui) {
3350
3869
  complete: result.analysisStatus.discovery !== "partial",
3351
3870
  durationMs: result.analysisStatus.durationMs
3352
3871
  }));
3353
- const advisory = result.findings.filter((f) => (f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence)) === "E0").length;
3872
+ const advisory = result.findings.filter((f) => evidenceLevelOf(f) === "E0").length;
3354
3873
  if (advisory > 0) pushWrapped(lines, p, `${advisory} advisory finding${advisory === 1 ? "" : "s"} (E0 — observation only, no score impact)`, width);
3355
3874
  if (result.findings.length > 0) {
3356
3875
  const firedRuleIds = new Set(result.findings.map((f) => f.ruleId));
@@ -3412,29 +3931,89 @@ function countBySeverity(result) {
3412
3931
  }
3413
3932
  return counts;
3414
3933
  }
3415
- EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2;
3416
- const RUNG_MEANINGS = [
3417
- "observation only",
3418
- "heuristic static",
3419
- "deterministic static",
3420
- "the finding's file executed",
3421
- "the finding's test executed",
3422
- "the run verdict corroborates"
3423
- ];
3424
- const RUNG_COLORS = [
3425
- TRUST.l0,
3426
- TRUST.l1,
3427
- TRUST.l2,
3428
- TRUST.l3,
3429
- TRUST.l4,
3430
- TRUST.l5
3431
- ];
3432
- const TRUST_RUNGS = RUNG_MEANINGS.map((meaning, i) => ({
3433
- level: `L${i}`,
3434
- meaning,
3435
- runtime: i >= 3,
3436
- color: RUNG_COLORS[i]
3437
- }));
3934
+ //#endregion
3935
+ //#region src/engine/trust-classification.ts
3936
+ /**
3937
+ * Completeness, decided once.
3938
+ *
3939
+ * Every branch here treats ABSENCE as "not proven complete" rather than as
3940
+ * "complete". That is the whole point: a missing `analysisStatus` is not a
3941
+ * clean bill of health, a zero ceiling reason is not a claim, and treating
3942
+ * either as completeness is how an unmeasured run gets to say "trust them".
3943
+ */
3944
+ function isComplete(result, summary) {
3945
+ if (result.partial) return false;
3946
+ if (summary.ceilingReasons.length > 0) return false;
3947
+ const status = result.analysisStatus;
3948
+ if (status === void 0) return false;
3949
+ if (status.discovery !== "complete" || status.rules !== "complete") return false;
3950
+ if ((status.skippedFiles ?? 0) > 0) return false;
3951
+ if ((status.rulesCrashed ?? 0) > 0) return false;
3952
+ return (status.reasons ?? []).length === 0;
3953
+ }
3954
+ /**
3955
+ * The determination. Every surface calls this; none of them re-derives it.
3956
+ */
3957
+ function classifyTrust(result, summary) {
3958
+ const gaps = [];
3959
+ const complete = isComplete(result, summary);
3960
+ if (result.partial) gaps.push("the scan was partial");
3961
+ const status = result.analysisStatus;
3962
+ if (status === void 0) gaps.push("the report carries no analysisStatus, so completeness is unknown");
3963
+ else {
3964
+ if (status.discovery !== "complete") gaps.push("discovery was truncated");
3965
+ if (status.rules !== "complete") gaps.push("rule evaluation was truncated");
3966
+ if ((status.skippedFiles ?? 0) > 0) gaps.push(`${status.skippedFiles} file(s) were skipped`);
3967
+ if ((status.rulesCrashed ?? 0) > 0) gaps.push(`${status.rulesCrashed} rule(s) crashed`);
3968
+ for (const reason of status.reasons ?? []) if (!gaps.some((g) => g.includes(reason))) gaps.push(reason);
3969
+ }
3970
+ for (const ceiling of summary.ceilingReasons) gaps.push(`confidence ceiling: ${ceiling}`);
3971
+ if (!complete) return {
3972
+ claim: "INCOMPLETE",
3973
+ order: 0,
3974
+ licensesClean: false,
3975
+ reason: "the analysis did not finish, so it proves nothing about the surface it did not reach",
3976
+ gaps
3977
+ };
3978
+ const level = summary.level;
3979
+ const rank = TRUST_ORDER.indexOf(level);
3980
+ const confident = summary.confidence >= .75;
3981
+ if (rank >= TRUST_ORDER.indexOf("L4")) return {
3982
+ claim: "RUN_EVIDENCE_BACKS_FINDINGS",
3983
+ order: 5,
3984
+ licensesClean: result.findings.length > 0,
3985
+ reason: confident ? "a real run corroborates these findings" : "a run exists but coverage is thin — verify the gaps",
3986
+ gaps
3987
+ };
3988
+ if (rank >= TRUST_ORDER.indexOf("L3")) return {
3989
+ claim: "RUNTIME_CORROBORATED",
3990
+ order: 4,
3991
+ licensesClean: result.findings.length > 0,
3992
+ reason: confident ? "the relevant files executed — static and runtime signal agree" : "files executed, but evidence is thin — treat findings as leads",
3993
+ gaps
3994
+ };
3995
+ if (level === "L0" || summary.evidenceCoverage === 0) return {
3996
+ claim: "NO_EVIDENCE",
3997
+ order: 1,
3998
+ licensesClean: false,
3999
+ reason: "static signal only, with no runtime evidence — an observation, not a verdict",
4000
+ gaps
4001
+ };
4002
+ if (confident) return {
4003
+ claim: "DETERMINISTIC_STATIC",
4004
+ order: 3,
4005
+ licensesClean: true,
4006
+ reason: "deterministic static analysis, complete but uncorroborated by a run",
4007
+ gaps
4008
+ };
4009
+ return {
4010
+ claim: "THIN_STATIC_SIGNAL",
4011
+ order: 2,
4012
+ licensesClean: true,
4013
+ reason: "static analysis is complete but low-confidence — treat findings as leads",
4014
+ gaps
4015
+ };
4016
+ }
3438
4017
  //#endregion
3439
4018
  //#region src/lib/format.ts
3440
4019
  /**
@@ -3446,11 +4025,6 @@ function pct$1(v) {
3446
4025
  return `${Math.round(v * 100)}%`;
3447
4026
  }
3448
4027
  //#endregion
3449
- //#region src/reporter/evidence-tag.ts
3450
- function evidenceTag(f) {
3451
- return f.runtimeCorroboration ? f.runtimeCorroboration.level === "defect" ? "run corroborated" : "run executed" : (f.evidenceLevel ?? "E2") === "E2" ? "deterministic" : "pattern";
3452
- }
3453
- //#endregion
3454
4028
  //#region src/reporter/trust-report.ts
3455
4029
  /**
3456
4030
  * The rung labels, built from `src/brand/symbols.ts` rather than typed
@@ -3469,16 +4043,25 @@ function evidenceTag(f) {
3469
4043
  */
3470
4044
  const TRUST_LABELS = Object.fromEntries(TRUST_RUNGS.map((r) => [r.level, `${r.level} · ${r.meaning}${r.runtime ? " · runtime" : ""}`]));
3471
4045
  /**
3472
- * The human verdict line derived from the measurement. Deterministic
3473
- * mapping from (level, confidence) bands — a measurement-derived label,
3474
- * never a new verdict enum (plan §3/§36: no new global enums).
3475
- */
3476
- function trustHeadline(s) {
3477
- if (s.level === "L5" || s.level === "L4") return s.confidence >= .75 ? "Run evidence backs these findings — trust them." : "Run evidence exists, but the scan was incomplete — verify the gaps.";
3478
- if (s.level === "L3") return s.confidence >= .75 ? "The relevant files executed — solid static + runtime signal." : "Files executed, evidence is thin — treat findings as leads.";
3479
- if (s.level === "L0") return "Static signal only, with no runtime evidence — treat as an observation, not a verdict.";
3480
- if (s.confidence >= .75) return "Deterministic static analysis — trustworthy, uncorroborated by a run.";
3481
- return "Static signal only, incomplete analysis — treat as leads, not verdicts.";
4046
+ * The human verdict line, rendered from the ONE determination.
4047
+ *
4048
+ * This used to be a band mapping over (level, confidence) living here, in the
4049
+ * reporter — engine knowledge in a presentation layer, which is how a second
4050
+ * surface ends up disagreeing with the first. The decision now lives in
4051
+ * `src/engine/trust-classification.ts` and every surface renders the same
4052
+ * answer; this function only chooses words.
4053
+ */
4054
+ function trustHeadline(s, classification) {
4055
+ const claim = classification?.claim;
4056
+ if (claim !== void 0) switch (claim) {
4057
+ case "RUN_EVIDENCE_BACKS_FINDINGS": return s.confidence >= .75 ? "Run evidence backs these findings — trust them." : "Run evidence exists, but the scan was incomplete — verify the gaps.";
4058
+ case "RUNTIME_CORROBORATED": return s.confidence >= .75 ? "The relevant files executed — solid static + runtime signal." : "Files executed, evidence is thin — treat findings as leads.";
4059
+ case "DETERMINISTIC_STATIC": return "Deterministic static analysis — trustworthy, uncorroborated by a run.";
4060
+ case "THIN_STATIC_SIGNAL": return "Static signal only, incomplete analysis — treat as leads, not verdicts.";
4061
+ case "NO_EVIDENCE": return "Static signal only, with no runtime evidence — treat as an observation, not a verdict.";
4062
+ case "INCOMPLETE": return "The analysis did not finish — it proves nothing about the surface it did not reach.";
4063
+ }
4064
+ return "Trust could not be established for this run.";
3482
4065
  }
3483
4066
  /** WHY THIS VERDICT — evidence-backed reasons, each from a real field. */
3484
4067
  function trustReasons(result, s) {
@@ -3538,9 +4121,10 @@ function renderTrustReport(result, opts) {
3538
4121
  provisionalRuleIds: [],
3539
4122
  ceilingReasons: []
3540
4123
  };
4124
+ const classification = classifyTrust(result, s);
3541
4125
  lines.push(sectionHeader("TRUST VERDICT", ui));
3542
4126
  lines.push(` ${p.accent(TRUST_LABELS[s.level] ?? s.level)}`);
3543
- lines.push(` ${trustHeadline(s)}`);
4127
+ lines.push(` ${trustHeadline(s, classification)}`);
3544
4128
  lines.push("");
3545
4129
  lines.push(sectionHeader("CONFIDENCE", ui));
3546
4130
  lines.push(` confidence ${pct$1(s.confidence)}${s.confidenceCeiling !== void 0 ? ` (ceiling ${pct$1(s.confidenceCeiling)})` : ""}`);
@@ -3549,7 +4133,7 @@ function renderTrustReport(result, opts) {
3549
4133
  if (s.measuredFpOfFiredRules !== void 0) lines.push(` measured FP (fired) ${pct$1(s.measuredFpOfFiredRules)} — evidence-weighted`);
3550
4134
  else if (s.provisionalRuleIds.length > 0) lines.push(` measured FP (fired) PROVISIONAL — unmeasured rules fired: ${s.provisionalRuleIds.slice(0, 3).join(", ")}${s.provisionalRuleIds.length > 3 ? `, +${s.provisionalRuleIds.length - 3} more` : ""}`);
3551
4135
  else lines.push(" measured FP (fired) none fired — nothing to weight");
3552
- lines.push(` tests analyzed ${result.testDeclarationCount ?? 0} declaration(s) in ${result.testFileCount ?? 0} file(s)`);
4136
+ lines.push(` tests analyzed ${testsAnalyzedCell(result.testDeclarationCount, result.testFileCount)}`);
3553
4137
  lines.push("");
3554
4138
  lines.push(sectionHeader("WHY THIS VERDICT", ui));
3555
4139
  for (const r of trustReasons(result, s)) lines.push(` - ${r}`);
@@ -3727,9 +4311,13 @@ function escapeLabel(text) {
3727
4311
  function classDef(name, tint) {
3728
4312
  return ` classDef ${name} fill:${tint.fill},stroke:${tint.stroke},color:${tint.text};`;
3729
4313
  }
4314
+ /** Band → the Mermaid class name that paints it. BW-104: this used to
4315
+ * re-declare the 80/50 boundaries, so the diagram could colour a
4316
+ * dimension the terminal called UNWORTHY. It now asks the one model. */
3730
4317
  function dimensionStyleClass(dim) {
3731
- if (dim.score >= 80) return "healthy";
3732
- if (dim.score >= 50) return "warn";
4318
+ const band = deriveScoreState(dim.score).band;
4319
+ if (band === "forged" || band === "trusted") return "healthy";
4320
+ if (band === "warning") return "warn";
3733
4321
  return "critical";
3734
4322
  }
3735
4323
  const SEVERITY_ORDER = [
@@ -3960,14 +4548,7 @@ const EVIDENCE_VALUES = /* @__PURE__ */ new Set([
3960
4548
  "E1",
3961
4549
  "E2"
3962
4550
  ]);
3963
- const TRUST_VALUES = /* @__PURE__ */ new Set([
3964
- "L0",
3965
- "L1",
3966
- "L2",
3967
- "L3",
3968
- "L4",
3969
- "L5"
3970
- ]);
4551
+ const TRUST_VALUES = new Set(TRUST_ORDER);
3971
4552
  /** Human message for any thrown value — never "undefined"/"[object Object]". */
3972
4553
  function errorText$1(err) {
3973
4554
  if (err instanceof Error) return err.message;
@@ -4118,6 +4699,67 @@ function loadSavedReport(reportPath) {
4118
4699
  function reportExists(reportPath) {
4119
4700
  return existsSync(reportPath);
4120
4701
  }
4702
+ function identityOf(result) {
4703
+ const identity = result.runIdentity;
4704
+ return isRecord$2(identity) ? identity : void 0;
4705
+ }
4706
+ /**
4707
+ * Classify a loaded report. Pure — it reads the report and the caller's
4708
+ * expectation, touches nothing else.
4709
+ *
4710
+ * `expected` is optional on purpose: a caller with no expectation still gets an
4711
+ * honest classification, it just cannot detect a cross-identity read.
4712
+ */
4713
+ function classifySavedReport(result, expected) {
4714
+ const identity = identityOf(result);
4715
+ if (identity === void 0) return {
4716
+ state: "OPEN",
4717
+ reason: "report carries no run identity — it predates machine-anchored identity, or was written by hand. Usable as history, not as proof."
4718
+ };
4719
+ const scanId = identity["scanId"];
4720
+ if (typeof scanId !== "string" || scanId.length === 0) return {
4721
+ state: "OPEN",
4722
+ reason: "run identity has no scanId — the identity is unusable."
4723
+ };
4724
+ const commit = typeof identity["commit"] === "string" ? identity["commit"] : void 0;
4725
+ const candidate = identity["candidate"];
4726
+ const candidateManifestId = isRecord$2(candidate) && typeof candidate["manifestId"] === "string" ? candidate["manifestId"] : void 0;
4727
+ if (expected !== void 0) {
4728
+ if (expected.scanId !== void 0 && expected.scanId !== scanId) return {
4729
+ state: "OPEN",
4730
+ reason: `report was produced by a different run (${scanId}) than the current one (${expected.scanId})`
4731
+ };
4732
+ if (expected.commit !== void 0 && expected.commit !== commit) return {
4733
+ state: "OPEN",
4734
+ reason: `report was produced against commit ${commit ?? "unknown"}, not ${expected.commit}`
4735
+ };
4736
+ if (expected.candidateManifestId !== void 0 && expected.candidateManifestId !== candidateManifestId) return {
4737
+ state: "OPEN",
4738
+ reason: `report is bound to candidate ${candidateManifestId ?? "none"}, not ${expected.candidateManifestId}`
4739
+ };
4740
+ }
4741
+ return {
4742
+ state: "VERIFIED",
4743
+ scanId,
4744
+ ...commit !== void 0 ? { commit } : {},
4745
+ ...candidateManifestId !== void 0 ? { candidateManifestId } : {}
4746
+ };
4747
+ }
4748
+ /**
4749
+ * Load a saved report AND classify it.
4750
+ *
4751
+ * `loadSavedReport` stays for callers that genuinely only need the parse; the
4752
+ * trust-aware path is here so that no consumer reaches a report's contents
4753
+ * without having been told how much they may believe.
4754
+ */
4755
+ function loadSavedReportStrict(reportPath, expected) {
4756
+ const result = loadSavedReport(reportPath);
4757
+ return {
4758
+ result,
4759
+ trust: classifySavedReport(result, expected),
4760
+ path: reportPath
4761
+ };
4762
+ }
4121
4763
  //#endregion
4122
4764
  //#region src/integrations/github/evidence-sanitization.ts
4123
4765
  const MAX_MARKDOWN_TEXT_LENGTH = 500;
@@ -4297,7 +4939,7 @@ function renderTrustReportMarkdown(result, label, commit) {
4297
4939
  lines.push(`| Inconclusive | ${pct$1(s.inconclusiveRate)} |`);
4298
4940
  lines.push(`| Measured FP (fired) | ${s.measuredFpOfFiredRules !== void 0 ? pct$1(s.measuredFpOfFiredRules) : s.provisionalRuleIds.length > 0 ? `PROVISIONAL (${s.provisionalRuleIds.length} unmeasured)` : "n/a"} |`);
4299
4941
  lines.push(`| Score | ${result.score ?? "unknown"} |`);
4300
- lines.push(`| Tests analyzed | ${result.testDeclarationCount ?? 0} in ${result.testFileCount ?? 0} files |`);
4942
+ lines.push(`| Tests analyzed | ${testsAnalyzedCell(result.testDeclarationCount, result.testFileCount)} |`);
4301
4943
  lines.push("");
4302
4944
  if (s.ceilingReasons.length > 0) {
4303
4945
  lines.push(`Incompleteness factors: ${s.ceilingReasons.map(publicText).join(", ")}.`);
@@ -4360,8 +5002,8 @@ function renderTrustReportJson(result, commit) {
4360
5002
  scopeIntegrity: result.scopeIntegrity ?? null,
4361
5003
  verdict: completionVerdict,
4362
5004
  tests: {
4363
- files: result.testFileCount ?? 0,
4364
- declarations: result.testDeclarationCount ?? 0
5005
+ files: countOrNull(result.testFileCount),
5006
+ declarations: countOrNull(result.testDeclarationCount)
4365
5007
  },
4366
5008
  findings: {
4367
5009
  total: result.findings.length,
@@ -4375,7 +5017,7 @@ function renderTrustReportJson(result, commit) {
4375
5017
  file: publicJsonText(f.file),
4376
5018
  line: f.line,
4377
5019
  severity: f.severity,
4378
- evidence: f.runtimeCorroboration === void 0 ? f.evidenceLevel ?? "E2" : f.runtimeCorroboration.level,
5020
+ evidence: f.runtimeCorroboration === void 0 ? evidenceLevelOf(f) : f.runtimeCorroboration.level,
4379
5021
  message: publicJsonText(f.message)
4380
5022
  })),
4381
5023
  nextAction: publicJsonText(nextAction(result))
@@ -4396,20 +5038,18 @@ function renderTrustReportHtml(result, label, commit) {
4396
5038
  const warnings = result.findings.filter((f) => f.severity === "warning").length;
4397
5039
  const infos = result.findings.filter((f) => f.severity === "info").length;
4398
5040
  const advisory = result.findings.filter((f) => isAdvisoryFinding(f)).length;
4399
- const confidenceRows = [];
4400
5041
  const measuredFpCell = s.measuredFpOfFiredRules !== void 0 ? pct$1(s.measuredFpOfFiredRules) : s.provisionalRuleIds.length > 0 ? `PROVISIONAL (${s.provisionalRuleIds.length} unmeasured)` : "n/a";
4401
- confidenceRows.push(`Confidence|${pct$1(s.confidence)}${s.confidenceCeiling !== void 0 ? ` (ceiling ${pct$1(s.confidenceCeiling)})` : ""}`);
4402
- confidenceRows.push(`Evidence coverage|${pct$1(s.evidenceCoverage)}`);
4403
- confidenceRows.push(`Inconclusive|${pct$1(s.inconclusiveRate)}`);
4404
- confidenceRows.push(`Measured FP (fired)|${measuredFpCell}`);
4405
- confidenceRows.push(`Score|${result.score ?? "unknown"}`);
4406
- confidenceRows.push(`Tests analyzed|${result.testDeclarationCount ?? 0} in ${result.testFileCount ?? 0} files`);
4407
- const confidenceRowsHtml = confidenceRows.map((row) => {
4408
- const [k, v] = row.split("|");
4409
- return `<tr><td>${esc(k ?? "")}</td><td>${esc(v ?? "")}</td></tr>`;
4410
- }).join("\n ");
5042
+ const confidenceRowsHtml = [
5043
+ ["Confidence", `${pct$1(s.confidence)}${s.confidenceCeiling !== void 0 ? ` (ceiling ${pct$1(s.confidenceCeiling)})` : ""}`],
5044
+ ["Evidence coverage", pct$1(s.evidenceCoverage)],
5045
+ ["Inconclusive", pct$1(s.inconclusiveRate)],
5046
+ ["Measured FP (fired)", measuredFpCell],
5047
+ ["Score", result.score === null ? "unknown" : String(result.score)],
5048
+ ["Tests analyzed", testsAnalyzedCell(result.testDeclarationCount, result.testFileCount)]
5049
+ ].map(([k, v]) => `<tr><th scope="row">${esc(k)}</th><td>${esc(v)}</td></tr>`).join("\n ");
4411
5050
  const risksTable = risks.length === 0 ? `<p>None — no non-advisory findings fired.</p>` : `<table>
4412
- <thead><tr><th>Rule</th><th>Location</th><th>Evidence</th><th>Message</th></tr></thead>
5051
+ <caption>The ${risks.length} highest-risk non-advisory findings. Evidence shows what each finding actually rests on.</caption>
5052
+ <thead><tr><th scope="col">Rule</th><th scope="col">Location</th><th scope="col">Evidence</th><th scope="col">Message</th></tr></thead>
4413
5053
  <tbody>
4414
5054
  ${risks.map((f) => {
4415
5055
  const ev = evidenceTag(f);
@@ -4448,6 +5088,8 @@ function renderTrustReportHtml(result, label, commit) {
4448
5088
  `<section id="confidence">`,
4449
5089
  `<h2>Confidence</h2>`,
4450
5090
  `<table>`,
5091
+ ` <caption>Mjölnir confidence measurements. A value of “unknown — not measured” means the scan did not produce that measurement; it is not zero.</caption>`,
5092
+ ` <thead><tr><th scope="col">Measurement</th><th scope="col">Value</th></tr></thead>`,
4451
5093
  ` <tbody>`,
4452
5094
  ` ${confidenceRowsHtml}`,
4453
5095
  ` </tbody>`,
@@ -4495,13 +5137,15 @@ async function runTrustReportCommand(argv, io) {
4495
5137
  io.err("error: --commit requires the run's HEAD sha");
4496
5138
  return 10;
4497
5139
  }
4498
- let scan;
5140
+ let loaded;
4499
5141
  try {
4500
- scan = loadSavedReport(resolve(fromPath));
5142
+ loaded = loadSavedReportStrict(resolve(fromPath));
4501
5143
  } catch (err) {
4502
5144
  io.err(`error: cannot read ${fromPath}: ${errorMessage(err)}`);
4503
5145
  return 10;
4504
5146
  }
5147
+ const scan = loaded.result;
5148
+ if (loaded.trust.state !== "VERIFIED") io.err(`warning: ${loaded.trust.reason} The trust report below describes an OPEN artifact.`);
4505
5149
  if (commitArg && scan.runIdentity?.commit && scan.runIdentity.commit !== commitArg) {
4506
5150
  io.err(`error: report commit ${scan.runIdentity.commit} does not match --commit ${commitArg}`);
4507
5151
  return 10;
@@ -4513,7 +5157,7 @@ async function runTrustReportCommand(argv, io) {
4513
5157
  const md = renderTrustReportMarkdown(scan, fromPath, commitArg ?? null);
4514
5158
  if (argv.includes("--stdout")) {
4515
5159
  io.out(md);
4516
- return 0;
5160
+ return loaded.trust.state === "VERIFIED" ? 0 : 2;
4517
5161
  }
4518
5162
  try {
4519
5163
  const outPath = resolve(dirname(fromPath), TRUST_REPORT_MD);
@@ -4626,7 +5270,7 @@ function renderConfidenceTable(result, summary) {
4626
5270
  lines.push(`| Evidence coverage | ${pct$1(summary.evidenceCoverage)} |`);
4627
5271
  lines.push(`| Inconclusive | ${pct$1(summary.inconclusiveRate)} |`);
4628
5272
  lines.push(`| Measured FP (fired) | ${summary.measuredFpOfFiredRules !== void 0 ? pct$1(summary.measuredFpOfFiredRules) : summary.provisionalRuleIds.length > 0 ? `PROVISIONAL (${summary.provisionalRuleIds.length} unmeasured)` : "n/a"} |`);
4629
- lines.push(`| Tests analyzed | ${result.testDeclarationCount ?? 0} in ${result.testFileCount ?? 0} files |`);
5273
+ lines.push(`| Tests analyzed | ${testsAnalyzedCell(result.testDeclarationCount, result.testFileCount)} |`);
4630
5274
  lines.push("");
4631
5275
  lines.push("</details>");
4632
5276
  return lines;
@@ -4902,7 +5546,7 @@ function evidenceLines(f, ui) {
4902
5546
  }
4903
5547
  return lines;
4904
5548
  }
4905
- const SUPPRESSION_HINT = "Suppression (only with cause): an `ignore` entry in mjolnir.config.json — reason REQUIRED, expires after 90 days. Prefer fixing the root cause.";
5549
+ const SUPPRESSION_HINT = "Suppression (only with cause): an `ignore` entry in mjolnir.config.json — reason REQUIRED; only an explicit ISO `expires` date is bounded. Prefer fixing the root cause.";
4906
5550
  /** Render the why answer. Pure over (match, ui). */
4907
5551
  function renderWhy(match, ui = plainContext()) {
4908
5552
  const { p } = ui;
@@ -5658,14 +6302,19 @@ function buildHandover(scan, forensics) {
5658
6302
  heading: "⚠ CI trust warnings (green ≠ verified)",
5659
6303
  items: ciTrust.slice(0, 4).map((f) => `${f.file}:${f.line} — ${f.message}`)
5660
6304
  });
5661
- if (new Set(scan.findings.map((f) => f.file)).size === 0 && scan.score !== null && scan.score >= 90 || scan.findings.length === 0) sections.push({
6305
+ if (new Set(scan.findings.map((f) => f.file)).size === 0 && scan.score !== null && deriveScoreState(scan.score).band === "trusted" || scan.findings.length === 0) sections.push({
5662
6306
  heading: "🟢 Solid foundation",
5663
6307
  items: ["No tracked anti-patterns found in scanned specs — good place to start contributing."]
5664
6308
  });
5665
- const totalIssues = fakeGreen.length + flaky.length + ciTrust.length + (forensics?.flakyTests ?? 0);
6309
+ const flakyFromRuntime = forensics === null ? null : forensics.flakyTests;
6310
+ const totalIssues = fakeGreen.length + flaky.length + ciTrust.length + (flakyFromRuntime ?? 0);
6311
+ if (flakyFromRuntime === null) sections.push({
6312
+ heading: "⚠ Not measured — no runtime evidence",
6313
+ items: ["No run report was ingested, so flakiness, retries and real pass/fail outcomes are UNKNOWN here. Run `mjolnir forensics <results-dir>` (or point this at a test-results directory) before treating this suite as green.", "Everything above is static analysis only (trust L0–L2): it can show a pattern, never confirm a test ran."]
6314
+ });
5666
6315
  return {
5667
6316
  sections,
5668
- summaryLine: totalIssues === 0 ? "Welcome aboard — the suite is in good shape." : `${totalIssues} thing${totalIssues === 1 ? "" : "s"} to know about before your first release sign-off.`
6317
+ summaryLine: totalIssues > 0 ? `${totalIssues} thing${totalIssues === 1 ? "" : "s"} to know about before your first release sign-off.` : flakyFromRuntime === null ? "Static analysis found nothing — but no runtime evidence was ingested, so this is NOT a statement that the suite is in good shape." : "Welcome aboard — the suite is in good shape."
5669
6318
  };
5670
6319
  }
5671
6320
  function renderHandover(map) {
@@ -5843,7 +6492,7 @@ async function computeImpact(root, options) {
5843
6492
  if (blob === null) continue;
5844
6493
  const dest = join(tmpDir, relPath);
5845
6494
  mkdirSync(dirname(dest), { recursive: true });
5846
- writeFileSync(dest, blob);
6495
+ writeFileAtomic(dest, blob);
5847
6496
  }
5848
6497
  if (truncated) {
5849
6498
  baseTreeTruncated = {
@@ -7017,7 +7666,8 @@ async function runScanCommand(argv, io = {
7017
7666
  onProgress: (e) => progress.onEvent(e),
7018
7667
  onGateNotice: (notice) => io.err(notice),
7019
7668
  ...args.debug ? { onRuleCrash: (ruleId, file, error) => {
7020
- crashLog.push(`${ruleId} crashed on ${file}: ${error instanceof Error ? error.message : String(error)}`);
7669
+ const detail = error instanceof Error ? [error.message, error.stack].filter((value) => Boolean(value)).join("\n") : String(error);
7670
+ crashLog.push(`${ruleId} crashed on ${file}: ${detail}`);
7021
7671
  } } : {}
7022
7672
  });
7023
7673
  progress.done();
@@ -7030,7 +7680,12 @@ async function runScanCommand(argv, io = {
7030
7680
  if (args.json) io.err("--score overrides --json; stdout is the bare score.");
7031
7681
  const { config: scoreConfig } = loadConfig(target, { knownRuleIds: KNOWN_RULE_IDS$1 });
7032
7682
  io.out(result.score === null ? "unknown" : String(result.score));
7033
- return exitForFindings(result.findings, args.blocking === "none" ? "advisory" : args.blocking ?? scoreConfig.gate ?? "error");
7683
+ return scanExitCode({
7684
+ partial: result.partial,
7685
+ findings: result.findings,
7686
+ gate: args.blocking === "none" ? "advisory" : args.blocking ?? scoreConfig.gate ?? "error",
7687
+ isAdvisory: isAdvisoryFinding
7688
+ });
7034
7689
  }
7035
7690
  renderScanOutput(result, args, target, io);
7036
7691
  if (args.format === "terminal") {
@@ -7047,7 +7702,12 @@ async function runScanCommand(argv, io = {
7047
7702
  if (result.partial) return 2;
7048
7703
  const { config, warnings } = loadConfig(target, { knownRuleIds: KNOWN_RULE_IDS$1 });
7049
7704
  for (const w of warnings) io.err(w);
7050
- return exitForFindings(result.findings, args.blocking === "none" ? "advisory" : args.blocking ?? config.gate ?? "error");
7705
+ return scanExitCode({
7706
+ partial: false,
7707
+ findings: result.findings,
7708
+ gate: args.blocking === "none" ? "advisory" : args.blocking ?? config.gate ?? "error",
7709
+ isAdvisory: isAdvisoryFinding
7710
+ });
7051
7711
  } catch (err) {
7052
7712
  if (err instanceof ConfigValidationError) {
7053
7713
  io.err(err.message);
@@ -8571,9 +9231,14 @@ function truncateMessage(message, max = 250) {
8571
9231
  * `--path-prefix <dir>` re-scopes for subdirectory scans.
8572
9232
  */
8573
9233
  const DETAILS_PER_SEVERITY_CAP = 25;
8574
- function scoreBar$1(score, width = 20) {
8575
- const filled = Math.round(score / 100 * width);
8576
- return `${"█".repeat(filled)}${"░".repeat(Math.max(0, width - filled))}`;
9234
+ /**
9235
+ * The score bar. BW-105: the canonical gauge with the inert palette — the
9236
+ * job summary is a Markdown file, so it takes the gauge's geometry (bar +
9237
+ * head tick) and none of its SGR escapes, while the band is carried in
9238
+ * words on the line above.
9239
+ */
9240
+ function scoreBar$1(score) {
9241
+ return scoreGauge(score, palette(false), 20);
8577
9242
  }
8578
9243
  /** Markdown step summary. Pure over (result, options) — testable. */
8579
9244
  function renderStepSummary(result, options = {}) {
@@ -8783,9 +9448,19 @@ function fpLine(f) {
8783
9448
  if (f.measuredFpRate === void 0) return "Measured FP rate: none — this rule ships on assumption (no measured false-positive rate).";
8784
9449
  return `Measured FP rate: ${Math.round(f.measuredFpRate * 100)}%${f.measuredFpN !== void 0 ? ` over ${f.measuredFpN} classified verdicts` : ""}.`;
8785
9450
  }
8786
- function scoreBar(score, width = 20) {
8787
- const filled = Math.round(score / 100 * width);
8788
- return `${"█".repeat(filled)}${"░".repeat(Math.max(0, width - filled))}`;
9451
+ /**
9452
+ * The score bar. BW-105: this used to be a private, COLOURLESS copy of
9453
+ * `scoreGauge` — 20 blocks of `█`/`░` with no head tick, so 99 and 100
9454
+ * rendered identically and a reader could not read a band off the bar.
9455
+ *
9456
+ * It delegates to the canonical gauge with the inert palette: the head-tick
9457
+ * geometry is the gauge's own, and NO SGR escapes reach a Markdown file
9458
+ * (which would render as literal `[38;2;…m` on GitHub). The band itself is
9459
+ * carried in words on the line above — `VERDICT (band)` — which is R11
9460
+ * anyway: a colour is never the only signal.
9461
+ */
9462
+ function scoreBar(score) {
9463
+ return scoreGauge(score, palette(false), 20);
8789
9464
  }
8790
9465
  function scopeNote(options) {
8791
9466
  const parts = [];
@@ -8921,7 +9596,7 @@ function renderHandoff(result, options = {}, version = ENGINE_VERSION) {
8921
9596
  });
8922
9597
  lines.push("## How to use this document");
8923
9598
  lines.push("");
8924
- lines.push("- Validate each occurrence according to its evidence level before editing.", "- Apply the smallest behavior-preserving fix.", "- Re-run the verification procedure below; correlate by fingerprint.", "- Never suppress a finding merely to obtain a green scan (suppressions live in `mjolnir.config.json`, require a reason, and expire after 90 days).");
9599
+ lines.push("- Validate each occurrence according to its evidence level before editing.", "- Apply the smallest behavior-preserving fix.", "- Re-run the verification procedure below; correlate by fingerprint.", "- Never suppress a finding merely to obtain a green scan (suppressions live in `mjolnir.config.json` and require a reason; only an explicit ISO `expires` date is bounded).");
8925
9600
  lines.push("");
8926
9601
  lines.push(scopeNote(options) ? scopeNote(options).trim() : "");
8927
9602
  if (!scopeNote(options)) lines.pop();
@@ -9079,10 +9754,10 @@ function agentBrief(surface, cwdNote) {
9079
9754
  `Run \`${cwdNote}npx mjolnir-qa@${ENGINE_VERSION} . --scope changed\` before finishing any task that touched test code.`,
9080
9755
  "",
9081
9756
  "- Exit 0 = clean. Exit 1 = new findings on changed lines: fix them (smallest behavior-preserving change) or justify them to the user.",
9082
- `- npx mjolnir-qa@${ENGINE_VERSION} why <file>:<line> explains any finding (evidence level, measured FP rate, fix).`,
9757
+ `- npx mjolnir-qa@${ENGINE_VERSION} why <file>:<line> explains any finding (evidence level, measured FP rate or explicit unmeasured state, fix).`,
9083
9758
  `- npx mjolnir-qa@${ENGINE_VERSION} handoff renders a full remediation plan from a saved --json report.`,
9084
9759
  `- Agent loop: establish the before-state once with npx mjolnir-qa@${ENGINE_VERSION} baseline (commits .mjolnir/baseline.json); after fixing, npx mjolnir-qa@${ENGINE_VERSION} verify prints the before/after digest — resolved (per §15 lifecycle) / new / unchanged by ruleId+location, and the score delta. Exit 0 clean, 1 new errors, 2 partial or no baseline.`,
9085
- "- NEVER suppress a finding merely to obtain a green scan (suppressions live in mjolnir.config.json, require a reason, and expire after 90 days).",
9760
+ "- NEVER suppress a finding merely to obtain a green scan (suppressions live in mjolnir.config.json and require a reason; only an explicit ISO `expires` date is bounded).",
9086
9761
  "- After fixing: re-run the scan, report files changed, report checks not run, report unresolved findings honestly.",
9087
9762
  "",
9088
9763
  "## Agent safety contract (non-negotiable)",
@@ -9229,7 +9904,7 @@ function executeInstall(entries) {
9229
9904
  if (e.action === "refuse" || e.action === "no-op") continue;
9230
9905
  const dir = join(e.file, "..");
9231
9906
  mkdirSync(dir, { recursive: true });
9232
- writeFileSync(e.file, e.content);
9907
+ writeFileAtomic(e.file, e.content);
9233
9908
  written++;
9234
9909
  }
9235
9910
  return written;
@@ -9349,22 +10024,22 @@ function executeHookInstall(entry) {
9349
10024
  switch (entry.action) {
9350
10025
  case "create":
9351
10026
  mkdirSync(join(entry.file, ".."), { recursive: true });
9352
- writeFileSync(entry.file, `#!/bin/sh\n${hookBlock(ENGINE_VERSION)}\n`);
10027
+ writeFileAtomic(entry.file, `#!/bin/sh\n${hookBlock(ENGINE_VERSION)}\n`);
9353
10028
  return true;
9354
10029
  case "append": {
9355
10030
  const existing = readFileSync(entry.file, "utf8");
9356
10031
  const sep = existing.endsWith("\n") ? "" : "\n";
9357
- writeFileSync(entry.file, `${existing}${sep}\n${hookBlock(ENGINE_VERSION)}\n`);
10032
+ writeFileAtomic(entry.file, `${existing}${sep}\n${hookBlock(ENGINE_VERSION)}\n`);
9358
10033
  return true;
9359
10034
  }
9360
10035
  case "update": {
9361
10036
  const existing = readFileSync(entry.file, "utf8");
9362
10037
  const openIdx = existing.indexOf(HOOK_MARKER_OPEN);
9363
10038
  const closeIdx = existing.indexOf(HOOK_MARKER_CLOSE);
9364
- if (openIdx !== -1 && closeIdx !== -1) writeFileSync(entry.file, existing.slice(0, openIdx) + hookBlock(ENGINE_VERSION) + existing.slice(closeIdx + 29));
10039
+ if (openIdx !== -1 && closeIdx !== -1) writeFileAtomic(entry.file, existing.slice(0, openIdx) + hookBlock(ENGINE_VERSION) + existing.slice(closeIdx + 29));
9365
10040
  else {
9366
10041
  const sep = existing.endsWith("\n") ? "" : "\n";
9367
- writeFileSync(entry.file, `${existing}${sep}\n${hookBlock(ENGINE_VERSION)}\n`);
10042
+ writeFileAtomic(entry.file, `${existing}${sep}\n${hookBlock(ENGINE_VERSION)}\n`);
9368
10043
  }
9369
10044
  return true;
9370
10045
  }
@@ -9841,6 +10516,15 @@ function checkArtifactIntegrity(root) {
9841
10516
  details: bad
9842
10517
  };
9843
10518
  }
10519
+ function isReleaseVersion(value) {
10520
+ const isNumeric = (part) => /^\d+$/.test(part) && (part === "0" || !part.startsWith("0"));
10521
+ const [core, prerelease, ...extra] = value.split("-");
10522
+ const [major, minor, patch, ...coreExtra] = (core ?? "").split(".");
10523
+ if (extra.length > 0 || major === void 0 || minor === void 0 || patch === void 0 || coreExtra.length > 0 || !isNumeric(major) || !isNumeric(minor) || !isNumeric(patch)) return false;
10524
+ if (prerelease === void 0) return true;
10525
+ const [kind, number, ...rcExtra] = prerelease.split(".");
10526
+ return kind === "rc" && number !== void 0 && isNumeric(number) && rcExtra.length === 0;
10527
+ }
9844
10528
  function checkReleaseVersionConsistency(root) {
9845
10529
  const pkgPath = join(root, "package.json");
9846
10530
  const clPath = join(root, "CHANGELOG.md");
@@ -9850,9 +10534,14 @@ function checkReleaseVersionConsistency(root) {
9850
10534
  details: ["package.json or CHANGELOG.md missing"]
9851
10535
  };
9852
10536
  const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
9853
- const cl = readFileSync(clPath, "utf8");
9854
- const head = /^##\s+\[?(\d+\.\d+\.\d+)\]?/m.exec(cl)?.[1];
9855
- if (!pkg.version || head === void 0) return {
10537
+ const head = readFileSync(clPath, "utf8").split(/\r?\n/).reduce((found, line) => {
10538
+ if (found !== void 0 || !line.startsWith("## [")) return found;
10539
+ const headingEnd = line.indexOf("]", 4);
10540
+ if (headingEnd < 5) return void 0;
10541
+ const candidate = line.slice(4, headingEnd).trim();
10542
+ return isReleaseVersion(candidate) ? candidate : void 0;
10543
+ }, void 0);
10544
+ if (!pkg.version || !isReleaseVersion(pkg.version) || head === void 0 || !isReleaseVersion(head)) return {
9856
10545
  evidence: "INCONCLUSIVE",
9857
10546
  determination: "INCONCLUSIVE",
9858
10547
  details: ["unparsable version surfaces"]
@@ -10072,98 +10761,90 @@ function runReleaseTrustCommand(argv, io = {
10072
10761
  //#endregion
10073
10762
  //#region src/commands/business-case.ts
10074
10763
  /**
10075
- * `mjolnir business-case` — ROI estimate per finding (extended).
10076
- *
10077
- * Projects the business impact of untrustworthy verification by
10078
- * combining:
10079
- * - Measured false-positive rate per rule (from the FP audit
10080
- * corpus)
10081
- * - Industry-specific cost of a false-green release per incident
10082
- * - Historical incident data from git log (optional)
10083
- * - Projected savings over 6/12 months (optional)
10084
- *
10085
- * Static analysis — no test execution, no telemetry, deterministic
10086
- * output.
10087
- */
10088
- const DEFAULT_INCIDENT_COST = 25e3;
10089
- /** Industry cost multipliers for false-green incidents. */
10090
- const INDUSTRY_COSTS = {
10091
- default: DEFAULT_INCIDENT_COST,
10092
- fintech: 5e4,
10093
- healthcare: 1e5,
10094
- ecommerce: 15e3,
10095
- saas: 3e4,
10096
- enterprise: 4e4,
10097
- gaming: 2e4,
10098
- education: 1e4
10099
- };
10100
- const INDUSTRY_DESCRIPTIONS = {
10101
- default: "general",
10102
- fintech: "financial services (regulatory fines, PCI/HIPAA)",
10103
- healthcare: "healthcare (HIPAA, patient safety)",
10104
- ecommerce: "e-commerce (cart abandonment, lost sales)",
10105
- saas: "SaaS (churn, reputation)",
10106
- enterprise: "enterprise (contract penalties, SLA breaches)",
10107
- gaming: "gaming (player trust, reviews)",
10108
- education: "education (student data, accreditation)"
10109
- };
10110
- /** Calculate expected savings per finding. */
10764
+ * `mjolnir business-case` — measured false-positive rates, and the
10765
+ * arithmetic a reader can audit.
10766
+ *
10767
+ * What changed, and why: this command multiplied a MEASURED false-positive
10768
+ * rate by a table of invented incident costs (fintech $50 000, healthcare
10769
+ * $100 000, …) with no source, and printed the product as "Expected
10770
+ * Savings" and "Total potential savings". A dollar figure with no
10771
+ * provenance is worse than no dollar figure: someone puts it in a
10772
+ * business case and defends it in a room. It also had a `--history` flag
10773
+ * whose own help text promised "estimates from actual scan improvements"
10774
+ * and which did nothing but print a pointer elsewhere, and a `--projected`
10775
+ * flag that divided the total by six and called the result a monthly rate.
10776
+ *
10777
+ * So the invented cost table is gone. The one number this command can
10778
+ * defend is the measured false-positive rate and how many findings carry
10779
+ * it. A dollar conversion happens only when the reader supplies the cost
10780
+ * of an incident themselves (`--incident-cost`), because then the number
10781
+ * has a source: theirs.
10782
+ *
10783
+ * Scheduled for removal in 5.0 — see docs/RELEASE-TRAINS.md.
10784
+ */
10785
+ /**
10786
+ * Evidence weight per level, applied to a user-supplied incident cost.
10787
+ * E0 is an observation and never counts — there is nothing to save against
10788
+ * a claim nobody made.
10789
+ */
10111
10790
  function expectedSavings(fpRate, evidenceLevel, incidentCost) {
10112
10791
  if (fpRate === null || fpRate === void 0) return null;
10113
10792
  const confidence = evidenceLevel === "E2" ? 1 : evidenceLevel === "E1" ? .5 : 0;
10114
10793
  if (confidence === 0) return null;
10115
10794
  return Math.round(incidentCost * (1 - fpRate) * confidence);
10116
10795
  }
10117
- function renderSummary(savingsPerFinding, incidentCost, industry, totalSavings) {
10796
+ function renderSummary(savingsPerFinding, incidentCost, totalSavings) {
10797
+ const measured = savingsPerFinding.filter((s) => s.fpRate !== null).length;
10118
10798
  const lines = [
10119
- "Mjölnir Business Case — Estimated ROI per finding",
10120
- "=================================================",
10799
+ "Mjölnir — measured false-positive rates on the findings that fired",
10800
+ "=".repeat(58),
10121
10801
  "",
10122
- `Industry profile: ${industry} (${INDUSTRY_DESCRIPTIONS[industry] ?? "general"})`,
10123
- `Assumed cost per false-green incident: $${incidentCost}`,
10124
- `Sample: ${savingsPerFinding.filter((s) => s.fpRate !== null).length} of ${savingsPerFinding.length} rules have measured FP rates`,
10802
+ `Measured: ${measured} of ${savingsPerFinding.length} findings carry a corpus-measured FP rate.`,
10803
+ ...incidentCost === null ? [
10804
+ "",
10805
+ "NO DOLLAR FIGURES ARE SHOWN. Converting a false-positive rate into money",
10806
+ "requires the cost of one false-green incident in YOUR organisation, which",
10807
+ "this tool does not know and will not invent. Pass --incident-cost <n> to",
10808
+ "see the arithmetic; the number is then yours, not Mjölnir's."
10809
+ ] : [
10810
+ "",
10811
+ `Incident cost used: $${incidentCost.toLocaleString()} (your figure, from --incident-cost).`,
10812
+ `Findings that fired: ${savingsPerFinding.length}.`
10813
+ ],
10125
10814
  "",
10126
- "| Rule ID | FP Rate | Evidence | Expected Savings |",
10127
- "| ------- | ------- | -------- | --------------- |"
10815
+ "| Rule ID | FP Rate | n | Evidence | Expected cost |",
10816
+ "| ------- | ------- | - | -------- | ------------- |"
10128
10817
  ];
10129
- for (const { ruleId, fpRate, evidenceLevel, expectedSavings: savings } of savingsPerFinding) {
10130
- const fpDisplay = fpRate !== null ? `${Math.round(fpRate * 100)}%` : "unmeasured";
10131
- const savingsDisplay = savings !== null ? `$${savings}` : "n/a";
10132
- lines.push(`| ${ruleId} | ${fpDisplay} | ${evidenceLevel} | ${savingsDisplay} |`);
10133
- }
10134
- lines.push("");
10135
- lines.push("Interpretation:");
10136
- lines.push("---------------");
10137
- lines.push("- E2 findings (deterministic proof) carry full business risk — eliminate first");
10138
- lines.push("- E1 findings (pattern evidence) carry half the assessed risk");
10139
- lines.push("- Unmeasured rules (n < 10) have no quantified FP rate — add corpus classification");
10140
- lines.push(`- Total potential savings across all findings: $${totalSavings.toLocaleString()}`);
10818
+ for (const { ruleId, fpRate, fpN, evidenceLevel, expectedSavings: savings } of savingsPerFinding) {
10819
+ const fpDisplay = fpRate !== null ? `${Math.round(fpRate * 100)}%` : "not measured";
10820
+ const nDisplay = fpN !== null ? String(fpN) : "—";
10821
+ const savingsDisplay = incidentCost === null || savings === null ? "—" : `$${savings.toLocaleString()}`;
10822
+ lines.push(`| ${ruleId} | ${fpDisplay} | ${nDisplay} | ${evidenceLevel} | ${savingsDisplay} |`);
10823
+ }
10824
+ lines.push("");
10825
+ lines.push("How to read this:");
10826
+ lines.push("--------------------");
10827
+ lines.push("- E2 is deterministic proof and counts in full; E1 is pattern evidence and counts half;");
10828
+ lines.push(" E0 is an observation and never counts. The evidence column is why the weight differs.");
10829
+ lines.push("- A rule with no measured FP rate is NOT assumed safe. It is unmeasured, and it says so.");
10830
+ if (totalSavings !== null) lines.push(`- Total expected cost across these findings: $${totalSavings.toLocaleString()}`);
10141
10831
  return lines.join("\n");
10142
10832
  }
10143
- function renderProjection(monthlySavings, months, incidentCost) {
10144
- const totalProjected = monthlySavings * months;
10145
- const incidentsPrevented = Math.round(totalProjected / incidentCost);
10146
- return [
10147
- "",
10148
- "SAVINGS PROJECTION",
10149
- "==================",
10150
- "",
10151
- `Monthly savings estimate: $${monthlySavings.toLocaleString()}`,
10152
- `Projection period: ${months} months`,
10153
- `Projected total savings: $${totalProjected.toLocaleString()}`,
10154
- `Incidents prevented: ~${incidentsPrevented} (at $${incidentCost.toLocaleString()}/incident)`,
10155
- ""
10156
- ].join("\n");
10157
- }
10158
10833
  /**
10159
10834
  * Entry point for `mjolnir business-case`.
10160
10835
  *
10161
10836
  * Flags:
10162
- * --industry <type> Industry cost profile (default/fintech/healthcare/...)
10163
- * --history <months> Use git history for the last N months (estimates from
10164
- * actual scan improvements, not projections)
10165
- * --projected <months> Show projected savings over N months
10837
+ * --incident-cost <n> The cost of ONE false-green incident in your
10838
+ * organisation. Until you supply it, no dollar
10839
+ * figure is printed at all.
10166
10840
  * --strict Include quarantine-tier findings
10841
+ *
10842
+ * `--history` and `--projected` are GONE. Both promised arithmetic this
10843
+ * tool cannot do: `--history` claimed to estimate from actual scan
10844
+ * improvements while reading no history at all, and `--projected` divided
10845
+ * the total by six and called the quotient a monthly rate. A flag that
10846
+ * does not do what its help says is worse than no flag, because the help
10847
+ * is the promise.
10167
10848
  */
10168
10849
  async function runBusinessCaseCommand(argv, io = {
10169
10850
  out,
@@ -10171,25 +10852,29 @@ async function runBusinessCaseCommand(argv, io = {
10171
10852
  }) {
10172
10853
  try {
10173
10854
  const target = argv.find((a) => !a.startsWith("-")) ?? ".";
10174
- const industryIdx = argv.indexOf("--industry");
10175
- const industry = industryIdx !== -1 ? argv[industryIdx + 1] ?? "default" : "default";
10176
- const historyIdx = argv.indexOf("--history");
10177
- const historyMonths = historyIdx !== -1 ? Number.parseInt(argv[historyIdx + 1] ?? "", 10) : 0;
10178
- const projectedIdx = argv.indexOf("--projected");
10179
- const projectedMonths = projectedIdx !== -1 ? Number.parseInt(argv[projectedIdx + 1] ?? "", 10) : 0;
10180
10855
  const strict = argv.includes("--strict");
10181
- const historyProvided = historyIdx !== -1;
10182
- const projectedProvided = projectedIdx !== -1;
10183
- if (historyProvided && historyMonths < 0) {
10184
- (io.err ?? err)("--history requires a positive number of months");
10185
- return 20;
10856
+ for (const gone of [
10857
+ "--industry",
10858
+ "--history",
10859
+ "--projected"
10860
+ ]) if (argv.includes(gone)) {
10861
+ (io.err ?? err)(`${gone} is no longer accepted. ` + (gone === "--industry" ? "Incident costs are not industry defaults; pass --incident-cost <n> with a figure from your own incident history." : gone === "--history" ? "It read no history. Use `mjolnir impact --since <date>` for evidence-backed change data." : "A projection is an arithmetic identity, not an estimate. Use `mjolnir trend` for measured history."));
10862
+ return 10;
10186
10863
  }
10187
- if (projectedProvided && projectedMonths <= 0) {
10188
- (io.err ?? err)("--projected requires a positive number of months");
10189
- return 20;
10864
+ const costIdx = argv.indexOf("--incident-cost");
10865
+ let incidentCost = null;
10866
+ if (costIdx !== -1) {
10867
+ const raw = argv[costIdx + 1];
10868
+ const parsed = Number.parseInt(raw ?? "", 10);
10869
+ if (raw === void 0 || raw.startsWith("-") || !Number.isFinite(parsed) || parsed <= 0) {
10870
+ (io.err ?? err)("--incident-cost requires a positive number: the cost of one false-green incident.");
10871
+ return 10;
10872
+ }
10873
+ incidentCost = parsed;
10190
10874
  }
10191
- const incidentCost = INDUSTRY_COSTS[industry] ?? DEFAULT_INCIDENT_COST;
10192
10875
  io.out(`Scanning ${target} ...`);
10876
+ const invalid = validateScanTarget(target, io.err ?? err);
10877
+ if (invalid !== null) return invalid;
10193
10878
  const savingsPerFinding = (await runScan({
10194
10879
  target,
10195
10880
  json: false,
@@ -10198,29 +10883,18 @@ async function runBusinessCaseCommand(argv, io = {
10198
10883
  scopeChanged: false,
10199
10884
  format: "terminal",
10200
10885
  strict
10201
- })).findings.map((f) => {
10202
- const fpRate = f.measuredFpRate ?? null;
10203
- const evidenceLevel = f.evidenceLevel ?? "E0";
10204
- const savings = expectedSavings(fpRate, evidenceLevel, incidentCost);
10205
- return {
10206
- ruleId: f.ruleId,
10207
- fpRate,
10208
- evidenceLevel,
10209
- expectedSavings: savings
10210
- };
10211
- });
10212
- const totalExpectedSavings = savingsPerFinding.reduce((sum, f) => sum + (f.expectedSavings ?? 0), 0);
10213
- io.out(renderSummary(savingsPerFinding, incidentCost, industry, totalExpectedSavings));
10214
- if (projectedMonths > 0) {
10215
- const monthlySavings = Math.round(totalExpectedSavings / 6);
10216
- io.out(renderProjection(monthlySavings, projectedMonths, incidentCost));
10217
- }
10218
- if (historyMonths > 0) io.out(`\nHistorical analysis (last ${historyMonths} months):\n NOTE: Historical incident costing requires CI log access.
10219
- Run \`mjolnir impact --since \${historyMonths}.months.ago\` for
10220
- evidence-backed impact data from git history.
10221
- Run \`mjolnir release-report --since v\${historyMonths}.0.0\` for
10222
- release-quality trajectory.`);
10223
- io.out("\nThese are projections based on measured FP rates from the OSS corpus.");
10886
+ })).findings.map((f) => ({
10887
+ ruleId: f.ruleId,
10888
+ fpRate: f.measuredFpRate ?? null,
10889
+ fpN: f.measuredFpN ?? null,
10890
+ evidenceLevel: evidenceLevelOf(f),
10891
+ expectedSavings: incidentCost === null ? null : expectedSavings(f.measuredFpRate ?? null, evidenceLevelOf(f), incidentCost)
10892
+ }));
10893
+ const totalExpectedSavings = incidentCost === null ? null : savingsPerFinding.reduce((sum, f) => sum + (f.expectedSavings ?? 0), 0);
10894
+ io.out(renderSummary(savingsPerFinding, incidentCost, totalExpectedSavings));
10895
+ io.out("");
10896
+ io.out("The FP rates above are measured on the OSS corpus. The dollar column, if you");
10897
+ io.out("asked for one, uses YOUR incident cost — Mjölnir does not estimate it.");
10224
10898
  io.out("Run with --strict to also surface quarantine-tier findings.");
10225
10899
  return 0;
10226
10900
  } catch (e) {
@@ -10237,10 +10911,18 @@ async function runBusinessCaseCommand(argv, io = {
10237
10911
  * posture and renders a management-ready verdict:
10238
10912
  * GO · CONDITIONAL GO · NO-GO
10239
10913
  *
10240
- * One command, screenshot-into-the-release-channel ready.
10914
+ * What changed, and why: this report hardcoded `hygieneFixed: 0`,
10915
+ * `hygieneIntroduced: 0`, `newTestsAdded: 0`, `newTestsWithoutAssertions: 0`
10916
+ * and `flakyAtLastRelease: 0`, and printed them as measured figures beside a
10917
+ * ✓/↑ trend mark — so a report that measured nothing looked like a report
10918
+ * that measured an absence. It also added 0.5/0.2/0.1 engineer-hours per
10919
+ * finding to produce a "test-debt cost estimate" nobody had estimated, and it
10920
+ * ignored the scan's `partial` flag, so a truncated scan could reach GO.
10241
10921
  *
10242
- * Exit codes (frozen contract): 0 GO · 1 CONDITIONAL GO ·
10243
- * 2 NO-GO · 10 usage · 20 internal.
10922
+ * Unmeasured values are now `null` and print as "not measured". Trend marks
10923
+ * require two real measurements. GO requires a complete scan. The release
10924
+ * decision itself moves to the canonical proof in v5 (V5-070); until then this
10925
+ * is a summary, and says so.
10244
10926
  */
10245
10927
  const ui$7 = plainContext();
10246
10928
  /** Rules that count as CI integrity issues for release verdicts. */
@@ -10249,34 +10931,40 @@ const CI_INTEGRITY_RULES = /* @__PURE__ */ new Set([
10249
10931
  "QA-CI-002",
10250
10932
  "QA-CI-008"
10251
10933
  ]);
10934
+ const NOT_MEASURED = "not measured";
10935
+ function show(value) {
10936
+ return value === null ? NOT_MEASURED : String(value);
10937
+ }
10252
10938
  function renderVerdictBlock(v) {
10253
10939
  const lines = [];
10254
10940
  lines.push(sectionHeader(`RELEASE READINESS — since ${v.since}`, ui$7));
10255
10941
  lines.push("");
10256
10942
  lines.push(`Score: ${v.score !== null ? v.score + "/100" : "unknown"}`);
10943
+ lines.push(`Analysis complete: ${v.partial ? "no (PARTIAL)" : "yes"}`);
10944
+ if (v.partial) lines.push("A partial scan cannot support a GO verdict: the unanalyzed surface is exactly where an unknown finding would live.");
10257
10945
  lines.push("");
10258
10946
  lines.push("Test hygiene since last release:");
10259
- lines.push(` Issues fixed: ${v.hygieneFixed}`);
10260
- lines.push(` Issues introduced: ${v.hygieneIntroduced}`);
10947
+ lines.push(` Issues fixed: ${show(v.hygieneFixed)}`);
10948
+ lines.push(` Issues introduced: ${show(v.hygieneIntroduced)}`);
10261
10949
  lines.push("");
10262
10950
  lines.push("New tests added:");
10263
- lines.push(` Total: ${v.newTestsAdded}`);
10264
- lines.push(` Without assertions: ${v.newTestsWithoutAssertions}`);
10951
+ lines.push(` Total: ${show(v.newTestsAdded)}`);
10952
+ lines.push(` Without assertions: ${show(v.newTestsWithoutAssertions)}`);
10265
10953
  lines.push("");
10266
10954
  lines.push("Skipped during the cycle:");
10267
- lines.push(` ${v.skippedDuringCycle} ← what we are NOT verifying`);
10955
+ lines.push(` ${v.skippedDuringCycle}${v.skippedDuringCycle > 0 ? " ← what we are NOT verifying" : ""}`);
10268
10956
  lines.push("");
10269
10957
  lines.push("Flaky tests:");
10270
10958
  lines.push(` At release: ${v.flakyAtRelease}`);
10271
- const improvement = v.flakyAtRelease <= v.flakyAtLastRelease ? " ✓" : " ↑";
10272
- lines.push(` At last release: ${v.flakyAtLastRelease}${improvement}`);
10959
+ const improvement = v.flakyAtLastRelease === null ? "" : v.flakyAtRelease <= v.flakyAtLastRelease ? " ✓" : " ↑";
10960
+ lines.push(` At last release: ${show(v.flakyAtLastRelease)}${improvement}`);
10273
10961
  lines.push("");
10274
10962
  lines.push("CI integrity:");
10275
10963
  const coeMark = v.continueOnErrorActive === 0 ? " ✓" : " ⚠";
10276
10964
  lines.push(` continue-on-error still active: ${v.continueOnErrorActive}${coeMark}`);
10277
10965
  lines.push(` Total CI integrity issues: ${v.ciIntegrityIssues}`);
10278
10966
  lines.push("");
10279
- lines.push(`Test-debt cost estimate: ~${v.testDebtHours.toFixed(1)} engineer-hours/qtr`);
10967
+ lines.push(`Test-debt cost estimate: ${v.testDebtHours === null ? NOT_MEASURED : `~${v.testDebtHours.toFixed(1)} engineer-hours/qtr`}`);
10280
10968
  lines.push("");
10281
10969
  lines.push(`Blocking findings (error): ${v.blockingFindings}`);
10282
10970
  lines.push(`Warning findings (warning): ${v.advisoryFindings}`);
@@ -10296,7 +10984,7 @@ function renderVerdictBlock(v) {
10296
10984
  if (v.continueOnErrorActive > 0) lines.push(` → ${v.continueOnErrorActive} continue-on-error still active — review before ship`);
10297
10985
  if (v.skippedDuringCycle > 0) lines.push(` → ${v.skippedDuringCycle} skipped test(s) — verify coverage gaps`);
10298
10986
  if (v.blockingFindings === 0 && v.advisoryFindings > 0) lines.push(` → ${v.advisoryFindings} advisory finding(s) — review before ship`);
10299
- } else lines.push("All gates clear. Release with confidence.");
10987
+ } else lines.push("No gate in this report fired. This is a summary of one static scan, not a release authorization: the canonical release proof is the authority, and it requires candidate-bound evidence this command does not have.");
10300
10988
  lines.push("");
10301
10989
  lines.push("Screenshot this report into the release channel.");
10302
10990
  return lines.join("\n");
@@ -10312,6 +11000,13 @@ function determineVerdict(result, history) {
10312
11000
  reasons
10313
11001
  };
10314
11002
  }
11003
+ if (result.partial) {
11004
+ reasons.push("scan was PARTIAL — the unanalyzed surface is unverified");
11005
+ return {
11006
+ verdict: "NO-GO",
11007
+ reasons
11008
+ };
11009
+ }
10315
11010
  const flakyCount = history.flakyAtRelease ?? 0;
10316
11011
  const ciIssues = history.continueOnErrorActive ?? 0;
10317
11012
  const skipped = history.skippedDuringCycle ?? 0;
@@ -10334,24 +11029,20 @@ function buildReleaseReport(result, since, history = {}) {
10334
11029
  const { verdict } = determineVerdict(result, history);
10335
11030
  const errorFindings = result.findings.filter((f) => f.severity === "error");
10336
11031
  const warningFindings = result.findings.filter((f) => f.severity === "warning");
10337
- let testDebtHours = 0;
10338
- for (const f of result.findings) if (CI_INTEGRITY_RULES.has(f.ruleId)) testDebtHours += .5;
10339
- else if (f.severity === "error") testDebtHours += .5;
10340
- else if (f.severity === "warning") testDebtHours += .2;
10341
- else testDebtHours += .1;
10342
11032
  return {
10343
11033
  verdict,
10344
11034
  since,
10345
- hygieneFixed: 0,
10346
- hygieneIntroduced: 0,
10347
- newTestsAdded: 0,
10348
- newTestsWithoutAssertions: 0,
11035
+ partial: result.partial,
11036
+ hygieneFixed: null,
11037
+ hygieneIntroduced: null,
11038
+ newTestsAdded: null,
11039
+ newTestsWithoutAssertions: null,
10349
11040
  skippedDuringCycle: history.skippedDuringCycle ?? 0,
10350
11041
  flakyAtRelease: history.flakyAtRelease ?? 0,
10351
- flakyAtLastRelease: 0,
11042
+ flakyAtLastRelease: null,
10352
11043
  ciIntegrityIssues: warningFindings.length,
10353
11044
  continueOnErrorActive: errorFindings.filter((f) => CI_INTEGRITY_RULES.has(f.ruleId)).length,
10354
- testDebtHours,
11045
+ testDebtHours: history.testDebtHours ?? null,
10355
11046
  blockingFindings: errorFindings.length,
10356
11047
  advisoryFindings: warningFindings.length,
10357
11048
  score: result.score,
@@ -10393,18 +11084,18 @@ async function runReleaseReportCommand(argv, io) {
10393
11084
  target,
10394
11085
  json: false,
10395
11086
  verbose: false,
10396
- maxDurationMs: Number.POSITIVE_INFINITY,
11087
+ maxDurationMs: 6e5,
10397
11088
  scopeChanged: false,
10398
11089
  format: "terminal",
10399
11090
  strict: false
10400
11091
  }), since, history);
10401
11092
  io.out(renderReleaseReport(report));
10402
- switch (report.verdict) {
10403
- case "GO": return 0;
10404
- case "CONDITIONAL GO": return 1;
10405
- case "NO-GO": return 1;
10406
- default: return 20;
11093
+ if (report.verdict === "GO") {
11094
+ io.err("mjolnir release-report: this is a static-scan summary, not release authorization. Run the release gate (npm run candidate:decision:release) before shipping.");
11095
+ return 0;
10407
11096
  }
11097
+ if (report.verdict === "CONDITIONAL GO" || report.verdict === "NO-GO") return 1;
11098
+ return 20;
10408
11099
  } catch (e) {
10409
11100
  internalErrorMessage(e, io.err, false);
10410
11101
  return 20;
@@ -10413,56 +11104,42 @@ async function runReleaseReportCommand(argv, io) {
10413
11104
  //#endregion
10414
11105
  //#region src/commands/report-playwright.ts
10415
11106
  /**
10416
- * `mjolnir report playwright` — Playwright Reporter Package (SDET-2).
11107
+ * `mjolnir report` — Mjölnir findings in the Playwright report shape.
10417
11108
  *
10418
- * Generates a Playwright-compatible JSON report from Mjölnir scan results,
10419
- * enabling Playwright's own UI and tooling to visualize Mjölnir findings.
11109
+ * What this command does NOT do, and why: a Playwright report describes tests
11110
+ * that were EXECUTED, with per-test outcomes and durations. A static scan
11111
+ * executed nothing. Presenting findings as Playwright `suites`/`tests` with a
11112
+ * `passed` status manufactured a runtime result that never happened — and on a
11113
+ * clean scan it published "0 tests, all passed", which a Playwright consumer
11114
+ * reads as a green test run.
10420
11115
  *
10421
- * The report follows the Playwright JSON report schema's structure:
10422
- * each finding becomes a "test" entry with Mjölnir's verdict as outcome.
11116
+ * So the execution block is empty and says so, and the findings live in the
11117
+ * `mjolnir` extension block where they are honestly labelled as static
11118
+ * analysis. The file is a findings report in a familiar shape, not a test run.
10423
11119
  */
10424
11120
  const ui$6 = plainContext();
10425
- function findingOutcome(f) {
10426
- if (f.severity === "error") return "failed";
10427
- if (f.severity === "warning") return "expectedFailure";
10428
- return "passed";
10429
- }
10430
11121
  function buildPlaywrightReport(result) {
10431
11122
  const now = /* @__PURE__ */ new Date();
10432
- const findingsByFile = /* @__PURE__ */ new Map();
10433
- for (const f of result.findings) {
10434
- const existing = findingsByFile.get(f.file) ?? [];
10435
- existing.push(f);
10436
- findingsByFile.set(f.file, existing);
10437
- }
10438
- const suites = Array.from(findingsByFile.entries()).map(([file, findings]) => ({
10439
- title: file,
10440
- file,
10441
- tests: findings.map((f) => ({
10442
- title: `${f.ruleId}: ${f.message}`,
10443
- path: file,
10444
- outcome: findingOutcome(f),
10445
- duration: 0,
10446
- annotations: [{
10447
- type: f.severity === "error" ? "error" : "warning",
10448
- message: `${f.ruleId} — ${f.message}${f.fix ? `\nFix: ${f.fix}` : ""}`
10449
- }]
10450
- }))
10451
- }));
10452
- const allTests = suites.flatMap((s) => s.tests);
11123
+ const partial = result.partial === true;
11124
+ const hasError = result.findings.some((f) => f.severity === "error");
11125
+ const status = partial ? "interrupted" : hasError ? "failed" : "passed";
10453
11126
  return {
10454
11127
  version: 1,
10455
11128
  startTime: now.toISOString(),
10456
11129
  endTime: now.toISOString(),
10457
11130
  duration: 0,
10458
- status: result.findings.some((f) => f.severity === "error") ? "failed" : "passed",
10459
- totalTests: allTests.length,
10460
- passedTests: allTests.filter((t) => t.outcome === "passed").length,
10461
- failedTests: allTests.filter((t) => t.outcome === "failed").length,
10462
- suites,
11131
+ status,
11132
+ totalTests: 0,
11133
+ passedTests: 0,
11134
+ failedTests: 0,
11135
+ suites: [],
10463
11136
  mjolnir: {
11137
+ execution: "STATIC_ANALYSIS",
11138
+ status,
11139
+ partial,
10464
11140
  score: result.score,
10465
11141
  framework: result.frameworks[0] ?? "unknown",
11142
+ frameworkDetectionUnknown: result.frameworkDetectionUnknown === true,
10466
11143
  findings: result.findings.map((f) => {
10467
11144
  const entry = {
10468
11145
  ruleId: f.ruleId,
@@ -10509,13 +11186,19 @@ async function runReportPlaywrightCommand(argv, io) {
10509
11186
  if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
10510
11187
  const fullPath = requestedOutput;
10511
11188
  writeFileAtomic(fullPath, JSON.stringify(report, null, 2) + "\n", { encoding: "utf8" });
10512
- const header = sectionHeader("PLAYWRIGHT REPORT", ui$6);
11189
+ const decision = decideClaim({
11190
+ partial: result.partial,
11191
+ blockingFindings: result.findings.filter((f) => f.severity === "error").length,
11192
+ supported: true
11193
+ });
11194
+ const header = sectionHeader("PLAYWRIGHT-SHAPED FINDINGS REPORT", ui$6);
10513
11195
  io.out(`${header}\n`);
10514
11196
  io.out(`Score: ${result.score !== null ? result.score + "/100" : "unknown"}`);
10515
11197
  io.out(`Findings: ${result.findings.length} (${result.findings.filter((f) => f.severity === "error").length} error, ${result.findings.filter((f) => f.severity === "warning").length} warning)`);
10516
11198
  io.out(`Report written: ${fullPath}`);
10517
- io.out(`Tests: ${report.totalTests} total, ${report.passedTests} passed, ${report.failedTests} failed`);
10518
- return result.findings.some((f) => f.severity === "error") ? 1 : 0;
11199
+ io.out("Executed tests: 0 — this is static analysis, not a test run. The Playwright suite block is empty by design.");
11200
+ io.out(`Determination: ${decision.state} — ${decision.reason}`);
11201
+ return decision.exitCode;
10519
11202
  } catch (e) {
10520
11203
  internalErrorMessage(e, io.err, false);
10521
11204
  return 20;
@@ -10655,32 +11338,49 @@ async function runTrendCommand(argv, io) {
10655
11338
  //#endregion
10656
11339
  //#region src/commands/exec-report.ts
10657
11340
  /**
10658
- * `mjolnir exec-report` — Executive Quality Report (QM-3).
11341
+ * `mjolnir exec-report` — a short, measured summary of one scan.
10659
11342
  *
10660
- * Generates a board-ready quality report with KPIs, trend summary,
10661
- * risk assessment, and strategic recommendations.
11343
+ * What changed, and why: this command used to render an executive "Risk Level:
11344
+ * LOW", KPI deltas ("↑ healthy") with no previous run to compare against,
11345
+ * and assurances ("Score 85/100 is excellent — maintain current quality gate")
11346
+ * that no measurement supports. It also ignored the `partial` flag entirely,
11347
+ * so a truncated scan could print "No findings — clean scan." and exit 0.
10662
11348
  *
10663
- * Output is human-readable terminal format suitable for copying into
10664
- * executive presentations or email briefings.
11349
+ * It now reports only what the scan measured, names the surface each number
11350
+ * came from, and defers the verdict to the one determination function
11351
+ * (`decideClaim`) so a partial scan can never read as clean. Whether a
11352
+ * business is "low risk" is a decision this tool does not make for anyone.
10665
11353
  */
10666
11354
  const ui$4 = plainContext();
10667
- function assessRisk(findings) {
10668
- const errors = findings.filter((f) => f.severity === "error").length;
10669
- const warnings = findings.filter((f) => f.severity === "warning").length;
10670
- if (errors > 5 || warnings > 20) return "high";
10671
- if (errors > 0 || warnings > 5) return "medium";
10672
- return "low";
10673
- }
10674
- function buildRecommendations(findings, score) {
10675
- const recs = [];
10676
- const errors = findings.filter((f) => f.severity === "error").length;
10677
- const warnings = findings.filter((f) => f.severity === "warning").length;
10678
- if (errors > 0) recs.push(`Fix ${errors} error finding(s) blocking release confidence`);
10679
- if (warnings > 0) recs.push(`Review ${warnings} warning finding(s) before next release`);
10680
- if (score !== null && score < 70) recs.push(`Score ${score}/100 is below the 70 threshold — prioritize rule coverage`);
10681
- if (score !== null && score >= 90) recs.push(`Score ${score}/100 is excellent — maintain current quality gate`);
10682
- if (recs.length === 0) recs.push("No critical issues — maintain current quality practices");
10683
- return recs;
11355
+ function buildExecutiveKpis(result) {
11356
+ const errors = result.findings.filter((f) => f.severity === "error").length;
11357
+ const warnings = result.findings.filter((f) => f.severity === "warning").length;
11358
+ return [
11359
+ {
11360
+ label: "Worthiness score",
11361
+ value: result.score !== null ? `${result.score}/100` : "not measured",
11362
+ source: "scan result score",
11363
+ direction: "higher-is-better"
11364
+ },
11365
+ {
11366
+ label: "Error findings",
11367
+ value: String(errors),
11368
+ source: "scan result findings",
11369
+ direction: "lower-is-better"
11370
+ },
11371
+ {
11372
+ label: "Warning findings",
11373
+ value: String(warnings),
11374
+ source: "scan result findings",
11375
+ direction: "lower-is-better"
11376
+ },
11377
+ {
11378
+ label: "Analysis complete",
11379
+ value: result.partial ? "no (partial)" : "yes",
11380
+ source: "scan result partial flag",
11381
+ direction: null
11382
+ }
11383
+ ];
10684
11384
  }
10685
11385
  async function runExecReportCommand(argv, io) {
10686
11386
  const target = argv.find((a) => !a.startsWith("-")) ?? ".";
@@ -10693,60 +11393,34 @@ async function runExecReportCommand(argv, io) {
10693
11393
  target,
10694
11394
  json: true,
10695
11395
  verbose: false,
10696
- maxDurationMs: Number.POSITIVE_INFINITY,
11396
+ maxDurationMs: 6e5,
10697
11397
  scopeChanged: false,
10698
11398
  format: "json",
10699
11399
  strict: false
10700
11400
  });
10701
- const risk = assessRisk(result.findings);
10702
- const recommendations = buildRecommendations(result.findings, result.score);
10703
- const kpis = [
10704
- {
10705
- label: "Worthiness Score",
10706
- value: result.score !== null ? `${result.score}/100` : "N/A",
10707
- delta: result.score !== null && result.score >= 80 ? "↑ healthy" : result.score !== null ? "↓ needs work" : "—",
10708
- status: result.score !== null && result.score >= 80 ? "good" : result.score !== null && result.score >= 60 ? "warning" : "critical"
10709
- },
10710
- {
10711
- label: "Total Findings",
10712
- value: String(result.findings.length),
10713
- delta: "",
10714
- status: result.findings.length <= 5 ? "good" : result.findings.length <= 20 ? "warning" : "critical"
10715
- },
10716
- {
10717
- label: "Errors",
10718
- value: String(result.findings.filter((f) => f.severity === "error").length),
10719
- delta: "",
10720
- status: result.findings.filter((f) => f.severity === "error").length === 0 ? "good" : "critical"
10721
- },
10722
- {
10723
- label: "Warnings",
10724
- value: String(result.findings.filter((f) => f.severity === "warning").length),
10725
- delta: "",
10726
- status: result.findings.filter((f) => f.severity === "warning").length <= 5 ? "good" : "warning"
10727
- }
10728
- ];
10729
- const header = sectionHeader("EXECUTIVE QUALITY REPORT", ui$4);
11401
+ const decision = decideClaim({
11402
+ partial: result.partial,
11403
+ blockingFindings: result.findings.filter((f) => f.severity === "error").length,
11404
+ supported: true
11405
+ });
11406
+ const kpis = buildExecutiveKpis(result);
11407
+ const header = sectionHeader("SCAN SUMMARY", ui$4);
10730
11408
  io.out(`${header}\n`);
10731
- io.out(`Generated: ${(/* @__PURE__ */ new Date()).toISOString()}`);
10732
11409
  io.out(`Target: ${target}`);
10733
- io.out(`Risk Level: ${risk.toUpperCase()}`);
11410
+ io.out(`Determination: ${decision.state} — ${decision.reason}`);
10734
11411
  io.out("");
10735
- io.out("--- Key Performance Indicators ---");
10736
- for (const kpi of kpis) {
10737
- const icon = kpi.status === "good" ? "✅" : kpi.status === "warning" ? "⚠️ " : "🔴";
10738
- io.out(` ${icon} ${kpi.label}: ${kpi.value} ${kpi.delta}`);
10739
- }
11412
+ io.out("--- Measured values ---");
11413
+ for (const kpi of kpis) io.out(` ${kpi.label}: ${kpi.value} (source: ${kpi.source})`);
10740
11414
  io.out("");
10741
- io.out("--- Top Findings ---");
11415
+ io.out("--- Top findings ---");
10742
11416
  const top = result.findings.slice(0, 5);
10743
11417
  for (const f of top) io.out(` [${f.severity.toUpperCase()}] ${f.ruleId}: ${f.message} (${f.file}:${f.line})`);
10744
- if (result.findings.length === 0) io.out(" No findings — clean scan.");
11418
+ if (result.findings.length === 0) io.out(result.partial ? " No findings in the analyzed portion of the surface. The scan was PARTIAL, so this is not a clean result." : " No findings in a complete scan of this surface.");
11419
+ if (result.findings.length > top.length) io.out(` …and ${result.findings.length - top.length} more.`);
10745
11420
  io.out("");
10746
- io.out("--- Recommendations ---");
10747
- for (const r of recommendations) io.out(` • ${r}`);
11421
+ io.out("Not reported here: business risk, ROI, or release readiness. Those are decisions, and this tool has no evidence for them.");
10748
11422
  io.out("");
10749
- return result.findings.some((f) => f.severity === "error") ? 1 : 0;
11423
+ return decision.exitCode;
10750
11424
  } catch (e) {
10751
11425
  internalErrorMessage(e, io.err, false);
10752
11426
  return 20;
@@ -10804,7 +11478,7 @@ async function runPolicyCommand(argv, io) {
10804
11478
  const policy = defaultPolicy();
10805
11479
  const dir = target === ".mjolnir" ? target : ".";
10806
11480
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
10807
- writeFileSync(policyPath, JSON.stringify(policy, null, 2) + "\n");
11481
+ writeFileAtomic(policyPath, JSON.stringify(policy, null, 2) + "\n");
10808
11482
  io.out(`Policy written to ${policyPath}`);
10809
11483
  io.out(`Use 'mjolnir policy validate ${policyPath}' to validate.`);
10810
11484
  io.out(`Use 'mjolnir policy check <target> --policy ${policyPath}' to check results.`);
@@ -10880,83 +11554,158 @@ async function runPolicyCommand(argv, io) {
10880
11554
  //#endregion
10881
11555
  //#region src/commands/quarantine.ts
10882
11556
  /**
10883
- * `mjolnir quarantine` — Quarantine Workflow Management (TL-3).
11557
+ * `mjolnir quarantine` — quarantined-test workflow.
10884
11558
  *
10885
- * Manages quarantined tests: list proposed quarantines, review them,
10886
- * accept/defer/reject, and track quarantine state over time.
11559
+ * What changed, and why: this command derived a quarantine "proposal" from a
11560
+ * STATIC scan by inventing an attempt count from severity (`attempts: 3` for an
11561
+ * error, `2` for a warning), then printed "Quarantines updated." after an
11562
+ * action that changed nothing, and printed hardcoded zeros as if they were
11563
+ * measured statistics. A quarantine decision is a claim about a flaky test's
11564
+ * runtime behaviour; a static scan has no runtime behaviour to claim, so it
11565
+ * cannot produce one.
10887
11566
  *
10888
- * Subcommands:
10889
- * list — show all proposed quarantines from a forensics report
10890
- * review — review a specific quarantine proposal
10891
- * stats — show quarantine statistics
11567
+ * It now refuses to invent. Proposals require runtime evidence (attempts and
11568
+ * an observed failure) from a saved forensics report; without one it says so
11569
+ * and points at `mjolnir forensics`. There is no quarantine store yet — the
11570
+ * ledger this belongs in is the suppression ledger (see
11571
+ * docs/RELEASE-TRAINS.md, V5-090) — so nothing claims to be updated.
10892
11572
  *
10893
- * Reads forensics output to propose quarantines deterministically:
10894
- * propose when attempts >= 2 AND everFailed, priority by attempts then duration.
11573
+ * Scheduled for removal in 5.0.
10895
11574
  */
10896
11575
  const ui$3 = plainContext();
10897
- function buildQuarantineProposals(findings) {
10898
- return findings.filter((f) => f.severity === "error" || f.severity === "warning").map((f, i) => ({
10899
- id: `Q-${String(i + 1).padStart(3, "0")}`,
10900
- ruleId: f.ruleId,
10901
- file: f.file,
10902
- line: f.line,
10903
- message: f.message,
10904
- attempts: f.severity === "error" ? 3 : 2,
11576
+ const CANDIDATE_KEYS = [
11577
+ "flaky",
11578
+ "flakyTests",
11579
+ "tests",
11580
+ "retries",
11581
+ "failures",
11582
+ "results"
11583
+ ];
11584
+ function asRecord(value) {
11585
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
11586
+ return value;
11587
+ }
11588
+ /**
11589
+ * Extract runtime failure evidence from a saved forensics report. A record
11590
+ * without an observed attempt count is not evidence and is dropped rather than
11591
+ * defaulted.
11592
+ */
11593
+ function runtimeFailuresFromReport(parsed) {
11594
+ const root = asRecord(parsed);
11595
+ if (!root) return [];
11596
+ const candidates = [];
11597
+ for (const key of CANDIDATE_KEYS) {
11598
+ const value = root[key];
11599
+ if (Array.isArray(value)) candidates.push(...value);
11600
+ }
11601
+ const evidence = [];
11602
+ for (const candidate of candidates) {
11603
+ const record = asRecord(candidate);
11604
+ if (!record) continue;
11605
+ const attempts = typeof record.attempts === "number" ? record.attempts : typeof record.failureCount === "number" ? record.failureCount : void 0;
11606
+ if (attempts === void 0) continue;
11607
+ const file = typeof record.file === "string" ? record.file : void 0;
11608
+ if (!file) continue;
11609
+ evidence.push({
11610
+ ruleId: typeof record.title === "string" ? record.title : "UNKNOWN",
11611
+ file,
11612
+ line: typeof record.line === "number" ? record.line : 0,
11613
+ message: typeof record.message === "string" ? record.message : "no message recorded",
11614
+ attempts,
11615
+ everFailed: record.everFailed === true || attempts > 0
11616
+ });
11617
+ }
11618
+ return evidence;
11619
+ }
11620
+ function buildQuarantineProposals(evidence) {
11621
+ return evidence.filter((item) => item.attempts >= 2 && item.everFailed).sort((a, b) => b.attempts - a.attempts || a.file.localeCompare(b.file) || a.line - b.line).map((item, index) => ({
11622
+ id: `Q-${String(index + 1).padStart(3, "0")}`,
11623
+ ruleId: item.ruleId,
11624
+ file: item.file,
11625
+ line: item.line,
11626
+ message: item.message,
11627
+ attempts: item.attempts,
11628
+ everFailed: item.everFailed,
10905
11629
  status: "proposed",
10906
- reason: f.severity === "error" ? "High-severity finding — quarantine recommended" : "Warning — review before quarantine"
11630
+ reason: `${item.attempts} observed attempt(s) with a recorded failure — runtime evidence, not inferred from severity`
10907
11631
  }));
10908
11632
  }
10909
- async function runQuarantineCommand(argv, io) {
11633
+ function readReport(path) {
11634
+ const root = resolve(path);
11635
+ if (!existsSync(root)) return {
11636
+ ok: false,
11637
+ reason: `no such file: ${path}`
11638
+ };
11639
+ try {
11640
+ return {
11641
+ ok: true,
11642
+ value: JSON.parse(readFileSync(root, "utf8"))
11643
+ };
11644
+ } catch (e) {
11645
+ return {
11646
+ ok: false,
11647
+ reason: e instanceof Error ? e.message : "unreadable report"
11648
+ };
11649
+ }
11650
+ }
11651
+ function runQuarantineCommand(argv, io) {
10910
11652
  const subcommand = argv[0] ?? "list";
10911
- const target = argv.slice(1).find((a) => !a.startsWith("-")) ?? ".";
11653
+ const rest = argv.slice(1);
11654
+ const target = rest.find((a) => !a.startsWith("-")) ?? ".";
11655
+ const reportIdx = rest.indexOf("--from");
10912
11656
  if (subcommand === "list") {
10913
- const findings = await runScan$1({
10914
- target,
10915
- json: true,
10916
- verbose: false,
10917
- maxDurationMs: Number.POSITIVE_INFINITY,
10918
- scopeChanged: false,
10919
- format: "json",
10920
- strict: false
10921
- }).catch((e) => {
10922
- internalErrorMessage(e, io.err, false);
10923
- return null;
10924
- });
10925
- if (!findings) return 20;
10926
- const proposals = buildQuarantineProposals(findings.findings);
10927
- if (proposals.length === 0) {
10928
- io.out("No quarantine proposals. All findings are within acceptable thresholds.");
10929
- return 0;
11657
+ if (reportIdx === -1 || !rest[reportIdx + 1]) {
11658
+ io.out(sectionHeader("QUARANTINE PROPOSALS", ui$3));
11659
+ io.out("");
11660
+ io.out("No runtime report supplied, so no proposals can be produced.");
11661
+ io.out("A quarantine proposal asserts that a test actually failed more than once. A static scan has no runtime behaviour to observe, so Mjölnir will not invent one.");
11662
+ io.out("");
11663
+ io.out("Produce one: mjolnir forensics <results-dir> && mjolnir quarantine list --from <report.json>");
11664
+ return unmeasuredClaim("quarantine list", "proposals require observed runtime failures, and no forensics report was supplied.").exitCode;
11665
+ }
11666
+ const reportPath = rest[reportIdx + 1];
11667
+ const reportRoot = resolve(target);
11668
+ const requested = resolve(reportPath);
11669
+ const rel = relative(reportRoot, requested);
11670
+ if (isAbsolute(rel) || rel.startsWith("..")) {
11671
+ io.err("mjolnir quarantine: --from report must stay within the target root");
11672
+ return 10;
10930
11673
  }
11674
+ const report = readReport(reportPath);
11675
+ if (!report.ok) {
11676
+ io.err(`mjolnir quarantine: cannot read report: ${report.reason}`);
11677
+ return 10;
11678
+ }
11679
+ const evidence = runtimeFailuresFromReport(report.value);
11680
+ const proposals = buildQuarantineProposals(evidence);
10931
11681
  io.out(sectionHeader("QUARANTINE PROPOSALS", ui$3));
10932
11682
  io.out("");
10933
- io.out(`| ID | Rule | File | Severity | Status |`);
10934
- io.out(`| -- | ---- | ---- | -------- | ------ |`);
10935
- for (const p of proposals) io.out(`| ${p.id} | ${p.ruleId} | ${p.file} | proposed | proposed |`);
11683
+ if (proposals.length === 0) {
11684
+ io.out(`0 proposal(s) from ${evidence.length} runtime record(s). Nothing in this report shows a test failing more than once.`);
11685
+ return unmeasuredClaim("quarantine list", "no runtime record met the observed-failure threshold; an empty proposal list is not a clean test suite.").exitCode;
11686
+ }
11687
+ io.out("| ID | Attempts | Test | File | Status |");
11688
+ io.out("| -- | -------- | ---- | ---- | ------ |");
11689
+ for (const p of proposals) io.out(`| ${p.id} | ${p.attempts} | ${p.ruleId} | ${p.file}:${p.line} | ${p.status} |`);
10936
11690
  io.out("");
10937
- io.out(`${proposals.length} proposal(s). Use 'mjolnir quarantine review' to manage.`);
10938
- return findings.findings.some((f) => f.severity === "error") ? 1 : 0;
11691
+ io.out(`${proposals.length} proposal(s) from observed runtime failures. Accepting one is a human decision this command does not make.`);
11692
+ return unmeasuredClaim("quarantine list", "proposals are a human decision queue, not an enforced gate.").exitCode;
10939
11693
  }
10940
11694
  if (subcommand === "review") {
10941
- const proposals = buildQuarantineProposals([]);
10942
- const action = argv.find((a) => a === "--accept" || a === "--defer" || a === "--reject");
10943
11695
  io.out(sectionHeader("QUARANTINE REVIEW", ui$3));
10944
11696
  io.out("");
10945
- io.out("Pending reviews:");
10946
- for (const p of proposals) io.out(` ${p.id}: ${p.ruleId} — ${p.message} (${p.file}:${p.line})`);
10947
- if (action) {
10948
- io.out(`Action: ${action}`);
10949
- io.out("Quarantines updated.");
10950
- } else io.out("Use --accept, --defer, or --reject to manage.");
10951
- return 0;
11697
+ io.out("No quarantine store exists, so there is nothing to review and nothing was changed.");
11698
+ io.out("Quarantine state lives in the suppression ledger: edit .mjolnir/suppressions.json, where every entry is versioned, justified, and subject to expiry.");
11699
+ return unmeasuredClaim("quarantine review", "the command is a placeholder; the ledger is the source of truth.").exitCode;
10952
11700
  }
10953
11701
  if (subcommand === "stats") {
10954
11702
  io.out(sectionHeader("QUARANTINE STATS", ui$3));
10955
11703
  io.out("");
10956
- io.out("Total: 0 | Proposed: 0 | Accepted: 0 | Deferred: 0 | Rejected: 0");
11704
+ io.out("No quarantine store exists, so no statistics can be reported.");
11705
+ io.out("Printing zeros here would look like a measured absence of flakiness. It is not one.");
10957
11706
  io.out("");
10958
- io.out("No quarantine activity recorded yet.");
10959
- return 0;
11707
+ io.out("Run `mjolnir trust-trend` for measured history, or read .mjolnir/suppressions.json for the current suppression ledger.");
11708
+ return unmeasuredClaim("quarantine stats", "no store exists to measure; zeros would be a fabricated statistic.").exitCode;
10960
11709
  }
10961
11710
  io.err(`Unknown quarantine subcommand: ${subcommand}`);
10962
11711
  return 10;
@@ -11180,7 +11929,7 @@ function runCiAdapterCommand(argv, io) {
11180
11929
  default: return 10;
11181
11930
  }
11182
11931
  try {
11183
- writeFileSync(join(target, filename), output);
11932
+ writeFileAtomic(join(target, filename), output);
11184
11933
  } catch (error) {
11185
11934
  io.err(`Unable to write CI template: ${error instanceof Error ? error.message : String(error)}`);
11186
11935
  return 20;
@@ -11200,16 +11949,32 @@ function runCiAdapterCommand(argv, io) {
11200
11949
  * file with inline CSS and JavaScript, suitable for embedding
11201
11950
  * in team wikis or sharing via static hosting.
11202
11951
  */
11952
+ /** Band → CSS colour, read from the brand tokens so the dashboard cannot
11953
+ * name a colour of its own. `trusted` is aurora-cyan, not green: green is
11954
+ * reserved for non-score success (the terminal's `Palette.ok`). */
11955
+ const BAND_HEX = {
11956
+ critical: SCORE.critical,
11957
+ warning: SCORE.warning,
11958
+ trusted: SCORE.trusted,
11959
+ forged: SCORE.forged,
11960
+ unmeasured: SCORE.unmeasured
11961
+ };
11962
+ const FINDINGS_SHOWN_LIMIT = 100;
11203
11963
  function escapeHtml(text) {
11204
11964
  return sanitizeErrorText(text, { maxLength: 1e3 }).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
11205
11965
  }
11206
11966
  function generateDashboardHtml(data) {
11207
- const scoreColor = data.score === null ? "#888" : data.score >= 80 ? "#22c55e" : data.score >= 60 ? "#eab308" : "#ef4444";
11208
- const rows = data.findings.map((f) => `<tr><td>${escapeHtml(f.ruleId)}</td><td><span style="color:${f.severity === "error" ? "#ef4444" : f.severity === "warning" ? "#eab308" : "#22c55e"}">${escapeHtml(f.severity)}</span></td><td>${escapeHtml(f.file)}</td><td>${escapeHtml(f.message)}</td></tr>`).join("");
11967
+ const scoreColor = BAND_HEX[deriveScoreState(data.score).band];
11968
+ const severityColor = (severity) => severity === "error" ? STATUS.error : severity === "warning" ? STATUS.warning : STATUS.ok;
11969
+ const rows = data.findings.map((f) => `<tr><td>${escapeHtml(f.ruleId)}</td><td><span style="color:${severityColor(f.severity)}">${escapeHtml(f.severity)}</span></td><td>${escapeHtml(f.file)}</td><td>${escapeHtml(f.message)}</td></tr>`).join("");
11970
+ const banner = data.partial ? `<p style="color:${STATUS.warning};font-weight:600">PARTIAL ANALYSIS — the whole surface was not analyzed. These numbers describe the analyzed portion only.</p>` : "";
11971
+ const truncation = data.findingsShown < data.totalFindings ? `<p>Showing ${data.findingsShown} of ${data.totalFindings} findings (display limit).</p>` : "";
11972
+ const frameworkValue = data.frameworkCount === null ? "unknown" : String(data.frameworkCount);
11209
11973
  return `<!DOCTYPE html>
11210
11974
  <html lang="en">
11211
11975
  <head>
11212
11976
  <meta charset="UTF-8">
11977
+ ${data.generatedAt === null ? "" : `<meta name="mjolnir-generated-at" content="${escapeHtml(data.generatedAt)}">\n`}<meta name="viewport" content="width=device-width, initial-scale=1">
11213
11978
  <title>Quality Dashboard</title>
11214
11979
  <style>
11215
11980
  body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 960px; margin: 0 auto; padding: 2rem; background: #0a0a0a; color: #e5e5e5; }
@@ -11226,16 +11991,15 @@ function generateDashboardHtml(data) {
11226
11991
  </head>
11227
11992
  <body>
11228
11993
  <h1>🔍 Quality Dashboard</h1>
11229
- <p>Generated: ${escapeHtml(data.generatedAt)}</p>
11230
- <div class="score">${data.score !== null ? data.score + "/100" : "N/A"}</div>
11994
+ ${banner}${data.generatedAt === null ? `<p>Deterministic build — no generation timestamp is recorded, so this file is byte-identical for an unchanged repository.</p>` : ""}<div class="score">${data.score !== null ? data.score + "/100" : "N/A"}</div>
11231
11995
  <div class="kpi-grid">
11232
11996
  <div class="kpi"><div class="value">${data.totalFindings}</div><div class="label">Findings</div></div>
11233
- <div class="kpi"><div class="value" style="color:#ef4444">${data.errorCount}</div><div class="label">Errors</div></div>
11234
- <div class="kpi"><div class="value" style="color:#eab308">${data.warningCount}</div><div class="label">Warnings</div></div>
11235
- <div class="kpi"><div class="value">${data.frameworkCount}</div><div class="label">Frameworks</div></div>
11997
+ <div class="kpi"><div class="value" style="color:${STATUS.error}">${data.errorCount}</div><div class="label">Errors</div></div>
11998
+ <div class="kpi"><div class="value" style="color:${STATUS.warning}">${data.warningCount}</div><div class="label">Warnings</div></div>
11999
+ <div class="kpi"><div class="value">${frameworkValue}</div><div class="label">Frameworks</div></div>
11236
12000
  </div>
11237
12001
  <h2>Findings</h2>
11238
- <table><thead><tr><th>Rule</th><th>Severity</th><th>File</th><th>Message</th></tr></thead><tbody>${rows}</tbody></table>
12002
+ ${truncation}<table><caption>Findings on the analyzed surface, newest severity first. Capped at ${FINDINGS_SHOWN_LIMIT} rows.</caption><thead><tr><th scope="col">Rule</th><th scope="col">Severity</th><th scope="col">File</th><th scope="col">Message</th></tr></thead><tbody>${rows}</tbody></table>
11239
12003
  </body>
11240
12004
  </html>`;
11241
12005
  }
@@ -11243,6 +12007,7 @@ async function runDashboardCommand(argv, io) {
11243
12007
  const target = argv.find((a) => !a.startsWith("-")) ?? ".";
11244
12008
  const outputIdx = argv.indexOf("--output");
11245
12009
  const outputPath = outputIdx !== -1 ? argv[outputIdx + 1] ?? "dashboard.html" : "dashboard.html";
12010
+ const deterministic = argv.includes("--deterministic");
11246
12011
  if (!existsSync(target)) {
11247
12012
  io.err(`mjolnir dashboard: target does not exist: ${target}`);
11248
12013
  return 10;
@@ -11255,7 +12020,7 @@ async function runDashboardCommand(argv, io) {
11255
12020
  return 10;
11256
12021
  }
11257
12022
  try {
11258
- const { runScan } = await import("./scan-pipeline-CRe7-MQn.mjs");
12023
+ const { runScan } = await import("./scan-pipeline-Bg-46cLS.mjs");
11259
12024
  const result = await runScan({
11260
12025
  target,
11261
12026
  json: true,
@@ -11265,26 +12030,37 @@ async function runDashboardCommand(argv, io) {
11265
12030
  format: "json",
11266
12031
  strict: false
11267
12032
  });
12033
+ const shown = result.findings.slice(0, FINDINGS_SHOWN_LIMIT);
11268
12034
  const data = {
11269
12035
  score: result.score,
11270
12036
  totalFindings: result.findings.length,
11271
12037
  errorCount: result.findings.filter((f) => f.severity === "error").length,
11272
12038
  warningCount: result.findings.filter((f) => f.severity === "warning").length,
11273
- frameworkCount: result.frameworks.length,
11274
- findings: result.findings.slice(0, 100).map((f) => ({
12039
+ frameworkCount: result.frameworkDetectionUnknown ? null : result.frameworks.length,
12040
+ partial: result.partial,
12041
+ findingsShown: shown.length,
12042
+ findings: shown.map((f) => ({
11275
12043
  ruleId: f.ruleId,
11276
12044
  severity: f.severity,
11277
12045
  file: f.file,
11278
12046
  message: f.message
11279
12047
  })),
11280
- generatedAt: (/* @__PURE__ */ new Date()).toISOString()
12048
+ generatedAt: deterministic ? null : (/* @__PURE__ */ new Date()).toISOString()
11281
12049
  };
11282
12050
  const html = generateDashboardHtml(data);
11283
12051
  writeFileAtomic(resolvedOutput, html, { encoding: "utf8" });
11284
12052
  io.out(`Dashboard written to ${resolvedOutput}`);
11285
12053
  io.out(`Score: ${data.score !== null ? data.score + "/100" : "N/A"}`);
11286
12054
  io.out(`Findings: ${data.totalFindings} (${data.errorCount} errors, ${data.warningCount} warnings)`);
11287
- return data.errorCount > 0 ? 1 : 0;
12055
+ if (data.frameworkCount === null) io.out("Frameworks: unknown — detection did not complete");
12056
+ if (data.findingsShown < data.totalFindings) io.out(`Table shows ${data.findingsShown} of ${data.totalFindings} findings (display limit).`);
12057
+ const decision = decideClaim({
12058
+ partial: data.partial,
12059
+ blockingFindings: data.errorCount,
12060
+ supported: true
12061
+ });
12062
+ io.out(`Determination: ${decision.state} — ${decision.reason}`);
12063
+ return decision.exitCode;
11288
12064
  } catch (e) {
11289
12065
  internalErrorMessage(e, io.err, false);
11290
12066
  return 20;
@@ -11293,18 +12069,34 @@ async function runDashboardCommand(argv, io) {
11293
12069
  //#endregion
11294
12070
  //#region src/commands/enterprise.ts
11295
12071
  /**
11296
- * `mjolnir enterprise` — Enterprise Deployment (QM-4).
12072
+ * `mjolnir enterprise` — refuses, and says what exists instead.
12073
+ *
12074
+ * What changed, and why: this command wrote three kinds of artifact that
12075
+ * Mjölnir cannot back.
11297
12076
  *
11298
- * Generates enterprise deployment artifacts:
11299
- * - Self-hosted deployment config
11300
- * - SSO/SAML setup guide
11301
- * - Compliance templates (SOC 2, HIPAA, PCI-DSS)
11302
- * - Custom policy templates
12077
+ * 1. A deployment config declaring `"authentication": "sso-saml"`. There
12078
+ * is no server, no session, and no SAML implementation in this product.
12079
+ * The config described a deployment that does not exist.
12080
+ * 2. An SSO setup guide instructing the reader to add an `sso` block to
12081
+ * `mjolnir.config.json`. Nothing reads that key. The guide was
12082
+ * confident, plausible, and inert.
12083
+ * 3. Three compliance templates — SOC 2, HIPAA, PCI-DSS — mapping
12084
+ * controls to "SSO/SAML integration", a "built-in scan audit trail"
12085
+ * and a "privacy scan". None of those is a Mjölnir feature: a scan
12086
+ * reads a repository and prints. A compliance template is a document
12087
+ * an organisation shows an auditor, so mapping controls to
12088
+ * capabilities that do not exist is not a cosmetic bug.
11303
12089
  *
11304
- * Subcommands:
11305
- * config — generate deployment config
11306
- * sso — generate SSO setup guide
11307
- * compliance — generate compliance template
12090
+ * So the command no longer writes files. What it can honestly offer is
12091
+ * `enterprise/threat-model.json` and `enterprise/data-flows.json`, which
12092
+ * ARE real, ARE validated in CI by `npm run enterprise:threat-model`,
12093
+ * and describe the product that exists: a zero-network local CLI.
12094
+ *
12095
+ * The `config` subcommand still emits a fact file, because a manifest of
12096
+ * what the tool is and is not is genuinely useful to an operator
12097
+ * evaluating it — as long as it records absence instead of inventing it.
12098
+ *
12099
+ * Scheduled for removal in 5.0 — see docs/RELEASE-TRAINS.md.
11308
12100
  */
11309
12101
  const ui$1 = plainContext();
11310
12102
  function runEnterpriseCommand(argv, io) {
@@ -11313,119 +12105,108 @@ function runEnterpriseCommand(argv, io) {
11313
12105
  if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
11314
12106
  if (subcommand === "config") {
11315
12107
  const config = {
11316
- service: "mjolnir-qa",
11317
- version: "2.0.0",
11318
- deployment: {
11319
- type: "self-hosted",
11320
- protocol: "https",
11321
- port: 443,
11322
- authentication: "sso-saml",
11323
- sessionTimeout: "8h",
11324
- maxFileSize: "100MB"
11325
- },
11326
- features: {
11327
- rules: true,
11328
- plugins: true,
11329
- cache: true,
11330
- reporter: true,
11331
- mcp: true
11332
- },
11333
- logging: {
11334
- level: "info",
11335
- retention: "90d",
11336
- audit: true
11337
- }
12108
+ product: "mjolnir-qa",
12109
+ version: ENGINE_VERSION,
12110
+ shape: "zero-runtime-dependency local CLI",
12111
+ network: "none — no telemetry, no hosted service, no sync",
12112
+ runtimeDependencies: 0,
12113
+ verified: [
12114
+ "local-only execution (tests/contract/privacy-network-isolation.spec.ts)",
12115
+ "versioned threat + data-flow model (npm run enterprise:threat-model)",
12116
+ "deterministic machine contract, drift-locked (contractVersion 1)"
12117
+ ],
12118
+ notProvided: [
12119
+ "SSO / SAML / OIDC — no server, no session, no identity provider integration",
12120
+ "hosted control plane, multi-tenant sync, or audit-log service",
12121
+ "compliance certification or auditor-facing control mapping",
12122
+ "air-gapped installation or offline recovery drill (see GAP-M26-015)"
12123
+ ],
12124
+ unsupportedRequests: "docs/M26-SUPPORT-MATRIX.json records each of these as an explicit BLOCKED cell rather than a plan."
11338
12125
  };
11339
- const path = join(outputDir, "deployment-config.json");
11340
- writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
11341
- io.out(sectionHeader("ENTERPRISE CONFIG", ui$1));
11342
- io.out(`Config written: ${path}`);
11343
- io.out(`Type: ${config.deployment.type}`);
11344
- io.out(`Auth: ${config.deployment.authentication}`);
11345
- return 0;
11346
- }
11347
- if (subcommand === "sso") {
11348
- const guide = `# SSO/SAML Setup Guide
11349
-
11350
- ## Service Provider (SP) Configuration
11351
- - Entity ID: https://your-domain.com/mjolnir
11352
- - ACS URL: https://your-domain.com/mjolnir/auth/sso/callback
11353
- - Binding: HTTP-POST
11354
-
11355
- ## Identity Provider (IdP) Configuration
11356
- - Metadata URL: https://your-idp.com/sso/metadata
11357
- - Required Attributes:
11358
- - email (mapped to user identity)
11359
- - role (mapped to access level)
11360
- - department (mapped to team)
11361
-
11362
- ## Mjölnir Configuration
11363
- Add to mjolnir.config.json:
11364
- \`\`\`json
11365
- {
11366
- "sso": {
11367
- "enabled": true,
11368
- "provider": "saml",
11369
- "entryPoint": "https://your-idp.com/sso",
11370
- "attributeMapping": {
11371
- "email": "user",
11372
- "role": "accessLevel",
11373
- "department": "team"
11374
- }
11375
- }
11376
- }
11377
- \`\`\`
11378
- `;
11379
- const path = join(outputDir, "sso-setup.md");
11380
- writeFileSync(path, guide);
11381
- io.out(`SSO guide written: ${path}`);
12126
+ const path = join(outputDir, "capability-manifest.json");
12127
+ writeFileAtomic(path, JSON.stringify(config, null, 2) + "\n");
12128
+ io.out(sectionHeader("ENTERPRISE CAPABILITY MANIFEST", ui$1));
12129
+ io.out(`Written: ${path}`);
12130
+ io.out(`Version: ${ENGINE_VERSION} · runtime dependencies: 0 · network: none`);
12131
+ io.out("");
12132
+ io.out("This product is a local CLI. It provides no server, no SSO, no hosted");
12133
+ io.out("control plane and no compliance certification. The manifest's");
12134
+ io.out("`notProvided` list is the honest answer to a deployment questionnaire.");
11382
12135
  return 0;
11383
12136
  }
11384
- if (subcommand === "compliance") {
11385
- for (const framework of [
11386
- "SOC2",
11387
- "HIPAA",
11388
- "PCI-DSS"
11389
- ]) {
11390
- const template = `# ${framework} Compliance Template for Mjölnir
11391
-
11392
- ## Control Mapping
11393
- | Control | Mjölnir Feature | Evidence |
11394
- | --------- | --------------- | -------- |
11395
- | Access Control | SSO/SAML integration | sso-setup.md |
11396
- | Audit Logging | Built-in scan audit trail | --json output |
11397
- | Data Protection | Local-first, zero-network | privacy scan |
11398
- | Change Management | quarantined rules, policy gates | policy check |
11399
- | Monitoring | dashboard, exec-report | HTML/terminal output |
11400
- `;
11401
- const path = join(outputDir, `${framework.toLowerCase()}-compliance.md`);
11402
- writeFileSync(path, template);
11403
- io.out(`Compliance template: ${path}`);
11404
- }
11405
- return 0;
12137
+ if (subcommand === "sso" || subcommand === "compliance") {
12138
+ io.err(subcommand === "sso" ? "mjolnir enterprise sso: not available, and no artifact will be written.\n Mjölnir is a local CLI with no server, no session and no identity-provider\n integration. The previous SSO guide told readers to add an `sso` block to\n mjolnir.config.json — a key nothing reads.\n What exists: the threat + data-flow model, validated by\n `npm run enterprise:threat-model`. See `mjolnir enterprise config`." : "mjolnir enterprise compliance: not available, and no artifact will be written.\n Mjölnir cannot produce an auditor-facing control mapping. The previous\n templates mapped controls to capabilities this product does not have\n (SSO/SAML integration, a scan \"audit trail\", a \"privacy scan\"), which is\n not a formatting bug — it is a false statement in a document meant for\n an auditor.\n What exists: enterprise/threat-model.json and enterprise/data-flows.json,\n validated in CI, describing the product that actually ships.");
12139
+ return 10;
11406
12140
  }
11407
12141
  io.err(`Unknown enterprise subcommand: ${subcommand}`);
12142
+ io.err("Available: config");
11408
12143
  return 10;
11409
12144
  }
11410
12145
  //#endregion
11411
12146
  //#region src/commands/maturity.ts
11412
12147
  /**
11413
- * `mjolnir maturity` — Quality Maturity Model (QM-6).
11414
- *
11415
- * Assesses the organization's QA maturity across dimensions:
11416
- * Test hygiene — rule coverage, assertion quality
11417
- * CI integrity — gate coverage, feedback speed
11418
- * Runtime verification — forensics adoption, flake management
11419
- * Process maturity — triage cadence, suppression governance
12148
+ * `mjolnir maturity` — organizational QA-maturity signals.
11420
12149
  *
11421
- * Output: maturity level (Initial → Managed → Defined → Quantitatively Managed → Optimizing)
11422
- * with specific improvement recommendations.
12150
+ * What changed, and why: this command used to emit an "Overall: Optimizing
12151
+ * (87/100)" style assessment derived from whether three files happened to
12152
+ * exist, with hardcoded dimension scores (75/70/65/30) and a `ruleCount = 79`
12153
+ * fallback invented when a catalog could not be read. A file-existence proxy
12154
+ * is not a maturity measurement, and a number with no provenance is worse than
12155
+ * no number: it is a decision someone else will make on.
11423
12156
  *
11424
- * Subcommands:
11425
- * assess — run the maturity assessment
11426
- * levels — show all maturity levels
12157
+ * It now reports what it can actually observe — the presence of specific,
12158
+ * named QA artifacts — and states that the artifact is a signal, not a score.
12159
+ * There is no overall score, because Mjölnir cannot measure organizational
12160
+ * maturity. Scheduled for removal in 5.0; see docs/RELEASE-TRAINS.md.
11427
12161
  */
11428
12162
  const ui = plainContext();
12163
+ const SIGNALS = [
12164
+ {
12165
+ id: "policy",
12166
+ question: "Is a Mjölnir policy file committed?",
12167
+ path: ".mjolnir/mjolnir.policy.json",
12168
+ says: "a policy file exists in this checkout",
12169
+ doesNotSay: "that the policy is enforced, current, or correct"
12170
+ },
12171
+ {
12172
+ id: "suppressions",
12173
+ question: "Is a suppression ledger committed?",
12174
+ path: ".mjolnir/suppressions.json",
12175
+ says: "suppressions are tracked as data",
12176
+ doesNotSay: "that the suppressions are justified or still needed"
12177
+ },
12178
+ {
12179
+ id: "history",
12180
+ question: "Are previous runs recorded?",
12181
+ path: ".mjolnir/stats.json",
12182
+ says: "run history exists in this checkout",
12183
+ doesNotSay: "that the history is complete, or that anyone reviewed it"
12184
+ },
12185
+ {
12186
+ id: "baseline",
12187
+ question: "Is a scan baseline committed?",
12188
+ path: ".mjolnir/baseline.json",
12189
+ says: "a baseline exists to diff against",
12190
+ doesNotSay: "that the baseline was taken from a clean, complete scan"
12191
+ },
12192
+ {
12193
+ id: "rule-catalog",
12194
+ question: "Is the generated rule catalog committed?",
12195
+ path: "docs/rules/catalog.md",
12196
+ says: "generated rule documentation is in the repository",
12197
+ doesNotSay: "how many rules are enabled, measured, or certified"
12198
+ }
12199
+ ];
12200
+ function assessMaturitySignals(root) {
12201
+ return SIGNALS.map((signal) => ({
12202
+ id: signal.id,
12203
+ question: signal.question,
12204
+ path: signal.path,
12205
+ present: existsSync(join(root, signal.path)),
12206
+ says: signal.says,
12207
+ doesNotSay: signal.doesNotSay
12208
+ }));
12209
+ }
11429
12210
  const MATURITY_LEVELS = [
11430
12211
  "Initial",
11431
12212
  "Managed",
@@ -11433,51 +12214,16 @@ const MATURITY_LEVELS = [
11433
12214
  "Quantitatively Managed",
11434
12215
  "Optimizing"
11435
12216
  ];
11436
- function assessDimension(name, score, findings = []) {
11437
- if (score >= 90) return {
11438
- name,
11439
- level: "Optimizing",
11440
- score,
11441
- findings
11442
- };
11443
- if (score >= 75) return {
11444
- name,
11445
- level: "Quantitatively Managed",
11446
- score,
11447
- findings
11448
- };
11449
- if (score >= 60) return {
11450
- name,
11451
- level: "Defined",
11452
- score,
11453
- findings
11454
- };
11455
- if (score >= 40) return {
11456
- name,
11457
- level: "Managed",
11458
- score,
11459
- findings
11460
- };
11461
- return {
11462
- name,
11463
- level: "Initial",
11464
- score,
11465
- findings
11466
- };
11467
- }
11468
12217
  function runMaturityCommand(argv, io) {
11469
12218
  const subcommand = argv[0] ?? "assess";
11470
12219
  const target = argv.slice(1).find((a) => !a.startsWith("-")) ?? ".";
11471
12220
  if (subcommand === "levels") {
11472
12221
  io.out(sectionHeader("MATURITY LEVELS", ui));
11473
12222
  io.out("");
11474
- for (let i = 0; i < MATURITY_LEVELS.length; i++) {
11475
- const level = MATURITY_LEVELS[i];
11476
- io.out(`${i + 1}. ${level}`);
11477
- }
12223
+ for (let i = 0; i < MATURITY_LEVELS.length; i++) io.out(`${i + 1}. ${MATURITY_LEVELS[i]}`);
11478
12224
  io.out("");
11479
- io.out("Score ranges: Initial (0-39), Managed (40-59), Defined (60-74), Quantitatively Managed (75-89), Optimizing (90-100)");
11480
- return 0;
12225
+ io.out("These are vocabulary only. Mjölnir does not place a repository on this scale: it observes named artifacts and reports which are present.");
12226
+ return unmeasuredClaim("maturity levels", "A maturity level is a judgement, not a measurement.").exitCode;
11481
12227
  }
11482
12228
  if (subcommand === "assess") {
11483
12229
  if (!existsSync(target)) {
@@ -11485,65 +12231,19 @@ function runMaturityCommand(argv, io) {
11485
12231
  return 10;
11486
12232
  }
11487
12233
  try {
11488
- let ruleCount = 0;
11489
- const catalogPath = join(target, "docs", "rules", "catalog.md");
11490
- if (existsSync(catalogPath)) try {
11491
- ruleCount = (readFileSync(catalogPath, "utf8").match(/QA-/g) ?? []).length;
11492
- } catch {
11493
- ruleCount = 79;
11494
- }
11495
- else ruleCount = 79;
11496
- let hasPolicy = false;
11497
- const policyPath = join(target, ".mjolnir", "mjolnir.policy.json");
11498
- if (existsSync(policyPath)) try {
11499
- hasPolicy = true;
11500
- } catch {
11501
- hasPolicy = false;
11502
- }
11503
- let hasHistory = false;
11504
- const statsPath = join(target, ".mjolnir", "stats.json");
11505
- if (existsSync(statsPath)) try {
11506
- hasHistory = true;
11507
- } catch {
11508
- hasHistory = false;
11509
- }
11510
- let hasTrends = false;
11511
- const trendPath = join(target, ".mjolnir", "trend.jsonl");
11512
- if (existsSync(trendPath)) try {
11513
- hasTrends = true;
11514
- } catch {
11515
- hasTrends = false;
11516
- }
11517
- const dimensions = [
11518
- assessDimension("Test Hygiene", Math.min(100, Math.round(ruleCount / 100 * 100)), [`${ruleCount} rules loaded`, ruleCount >= 50 ? "Good rule coverage" : "Increase rule coverage"]),
11519
- assessDimension("CI Integrity", hasPolicy ? 75 : 30, [hasPolicy ? "Policy-as-code active" : "Implement policy-as-code", "Gate configuration verified"]),
11520
- assessDimension("Runtime Verification", hasTrends ? 70 : 25, [hasTrends ? "Trend tracking active" : "Start quality trend tracking", hasHistory ? "Historical data available" : "No historical data yet"]),
11521
- assessDimension("Process Maturity", hasHistory ? 65 : 20, [hasHistory ? "Fix tracking active" : "Start tracking fixes", "Triage cadence needs definition"])
11522
- ];
11523
- const avgScore = Math.round(dimensions.reduce((sum, d) => sum + d.score, 0) / dimensions.length);
11524
- const overall = avgScore >= 90 ? "Optimizing" : avgScore >= 75 ? "Quantitatively Managed" : avgScore >= 60 ? "Defined" : avgScore >= 40 ? "Managed" : "Initial";
11525
- const assessment = {
11526
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
11527
- overall,
11528
- overallScore: avgScore,
11529
- dimensions
11530
- };
11531
- io.out(sectionHeader("MATURITY ASSESSMENT", ui));
11532
- io.out(`Generated: ${assessment.timestamp}`);
11533
- io.out(`Overall: ${assessment.overall} (${assessment.overallScore}/100)`);
12234
+ const signals = assessMaturitySignals(target);
12235
+ const present = signals.filter((signal) => signal.present).length;
12236
+ io.out(sectionHeader("QA ARTIFACT SIGNALS", ui));
12237
+ io.out(`Target: ${target} · ${present}/${signals.length} named artifacts present`);
12238
+ io.out("No overall score: Mjölnir cannot measure organizational maturity.");
11534
12239
  io.out("");
11535
- for (const d of dimensions) {
11536
- const icon = d.score >= 75 ? "🟢" : d.score >= 50 ? "🟡" : "🔴";
11537
- io.out(`${icon} ${d.name}: ${d.level} (${d.score}/100)`);
11538
- for (const f of d.findings) io.out(` - ${f}`);
11539
- io.out("");
12240
+ for (const signal of signals) {
12241
+ io.out(`${signal.present ? "[present]" : "[absent]"} ${signal.question}`);
12242
+ io.out(` path: ${signal.path}`);
12243
+ io.out(` presence says: ${signal.says}`);
12244
+ io.out(` presence does NOT say: ${signal.doesNotSay}`);
11540
12245
  }
11541
- const improvement = dimensions.filter((d) => d.score < 75).map((d) => `Improve ${d.name} to reach Quantitatively Managed`);
11542
- if (improvement.length > 0) {
11543
- io.out("--- Improvement Areas ---");
11544
- for (const i of improvement) io.out(`• ${i}`);
11545
- }
11546
- return 0;
12246
+ return unmeasuredClaim("maturity", "These are artifact-presence signals, not a maturity assessment, so no clean result is reported.").exitCode;
11547
12247
  } catch (e) {
11548
12248
  internalErrorMessage(e, io.err, false);
11549
12249
  return 20;
@@ -11670,7 +12370,7 @@ function analyzeCrossFileSignals(files, findings, root) {
11670
12370
  }));
11671
12371
  const correlationConclusions = correlateFindings(findings).map((c) => `${c.conclusionType}: ${c.corroboration}`).join("; ");
11672
12372
  const graph = buildDependencyGraph(root);
11673
- const reachableFiles = getReachableFiles(orderedFiles.map((f) => f.path), graph);
12373
+ const reachability = getReachableFiles(orderedFiles.map((f) => f.path), graph);
11674
12374
  return {
11675
12375
  signals: [
11676
12376
  ...duplicateTestNames,
@@ -11682,7 +12382,7 @@ function analyzeCrossFileSignals(files, findings, root) {
11682
12382
  circularDependencies,
11683
12383
  correlationConclusions,
11684
12384
  dependencyGraphSize: graph.size,
11685
- reachableFilesCount: reachableFiles.length
12385
+ reachability
11686
12386
  };
11687
12387
  }
11688
12388
  function detectSharedImports(files) {
@@ -11762,7 +12462,9 @@ function renderCrossFileAnalysis(result) {
11762
12462
  lines.push(`Shared imports: ${result.sharedImports.length}`);
11763
12463
  lines.push(`Circular dependencies: ${result.circularDependencies.length}`);
11764
12464
  lines.push(`Dependency graph size: ${result.dependencyGraphSize}`);
11765
- lines.push(`Reachable files: ${result.reachableFilesCount}`);
12465
+ const { reachability } = result;
12466
+ if (reachability.resolvedAny) lines.push(`Reachable files: ${reachability.reachable.length} (resolved from the dependency graph)`);
12467
+ else lines.push(`Reachable files: not resolved — the graph is keyed by package manifest and none of the ${reachability.unresolvedStarts.length} starting file(s) could be placed. This number is not evidence of a traversal.`);
11766
12468
  lines.push("");
11767
12469
  if (result.signals.length > 0) {
11768
12470
  lines.push("Signals:");
@@ -11927,190 +12629,6 @@ function renderEvidenceGraphResult(result) {
11927
12629
  return lines.join("\n").trimEnd();
11928
12630
  }
11929
12631
  //#endregion
11930
- //#region src/frameworks/framework-inventory.ts
11931
- const FRAMEWORK_INVENTORY = [
11932
- {
11933
- frameworkId: "playwright",
11934
- entityType: "E2E_FRAMEWORK",
11935
- language: "TypeScript/JavaScript",
11936
- maturity: "F4",
11937
- supportStatus: "OFFICIAL_PARTIAL",
11938
- targetMaturity: "F5",
11939
- targetSupportStatus: "OFFICIAL_FULL",
11940
- validatedVersions: [
11941
- "1.44",
11942
- "1.45",
11943
- "1.46",
11944
- "1.47",
11945
- "1.48"
11946
- ],
11947
- keyGapForNextLevel: "GAP-PW-006 parallelism/worker safety analysis"
11948
- },
11949
- {
11950
- frameworkId: "jest",
11951
- entityType: "TEST_FRAMEWORK",
11952
- language: "TypeScript/JavaScript",
11953
- maturity: "F3",
11954
- supportStatus: "OFFICIAL_PARTIAL",
11955
- targetMaturity: "F4",
11956
- targetSupportStatus: "OFFICIAL_FULL",
11957
- validatedVersions: ["29", "30"],
11958
- keyGapForNextLevel: "GAP-JEST-004 lifecycle modeling"
11959
- },
11960
- {
11961
- frameworkId: "vitest",
11962
- entityType: "TEST_FRAMEWORK",
11963
- language: "TypeScript/JavaScript",
11964
- maturity: "F3",
11965
- supportStatus: "OFFICIAL_PARTIAL",
11966
- targetMaturity: "F4",
11967
- targetSupportStatus: "OFFICIAL_FULL",
11968
- validatedVersions: [
11969
- "1.6",
11970
- "2.0",
11971
- "2.1"
11972
- ],
11973
- keyGapForNextLevel: "GAP-VIT-004 lifecycle modeling"
11974
- },
11975
- {
11976
- frameworkId: "pytest",
11977
- entityType: "TEST_FRAMEWORK",
11978
- language: "Python",
11979
- maturity: "F2",
11980
- supportStatus: "OFFICIAL_PARTIAL",
11981
- targetMaturity: "F3",
11982
- targetSupportStatus: "OFFICIAL_FULL",
11983
- validatedVersions: [
11984
- "7.4",
11985
- "8.0",
11986
- "8.1",
11987
- "8.2",
11988
- "8.3"
11989
- ],
11990
- keyGapForNextLevel: "GAP-PY-004 lifecycle modeling"
11991
- },
11992
- {
11993
- frameworkId: "junit",
11994
- entityType: "TEST_FRAMEWORK",
11995
- language: "Java",
11996
- maturity: "F2",
11997
- supportStatus: "OFFICIAL_PARTIAL",
11998
- targetMaturity: "F3",
11999
- targetSupportStatus: "OFFICIAL_FULL",
12000
- validatedVersions: ["5.10", "5.11"],
12001
- keyGapForNextLevel: "GAP-JU-004 lifecycle modeling"
12002
- },
12003
- {
12004
- frameworkId: "nunit",
12005
- entityType: "TEST_FRAMEWORK",
12006
- language: "C#",
12007
- maturity: "F2",
12008
- supportStatus: "OFFICIAL_PARTIAL",
12009
- targetMaturity: "F3",
12010
- targetSupportStatus: "OFFICIAL_FULL",
12011
- validatedVersions: ["3.14", "4.2"],
12012
- keyGapForNextLevel: "GAP-NU-004 lifecycle modeling"
12013
- },
12014
- {
12015
- frameworkId: "xunit",
12016
- entityType: "TEST_FRAMEWORK",
12017
- language: "C#",
12018
- maturity: "F2",
12019
- supportStatus: "OFFICIAL_PARTIAL",
12020
- targetMaturity: "F3",
12021
- targetSupportStatus: "OFFICIAL_FULL",
12022
- validatedVersions: ["2.9"],
12023
- keyGapForNextLevel: "GAP-XU-004 lifecycle modeling"
12024
- },
12025
- {
12026
- frameworkId: "cypress",
12027
- entityType: "E2E_FRAMEWORK",
12028
- language: "TypeScript/JavaScript",
12029
- maturity: "F2",
12030
- supportStatus: "EXPERIMENTAL",
12031
- targetMaturity: "F3",
12032
- targetSupportStatus: "OFFICIAL_PARTIAL",
12033
- validatedVersions: ["13"],
12034
- keyGapForNextLevel: "GAP-CY-004 lifecycle modeling"
12035
- },
12036
- {
12037
- frameworkId: "selenium",
12038
- entityType: "AUTOMATION_LIBRARY",
12039
- language: "Java/TypeScript/Python",
12040
- maturity: "F1",
12041
- supportStatus: "EXPERIMENTAL",
12042
- targetMaturity: "F2",
12043
- targetSupportStatus: "OFFICIAL_PARTIAL",
12044
- validatedVersions: [
12045
- "4.20",
12046
- "4.21",
12047
- "4.22",
12048
- "4.23",
12049
- "4.24",
12050
- "4.25"
12051
- ],
12052
- keyGapForNextLevel: "GAP-SE-002 AST-based usage analysis"
12053
- },
12054
- {
12055
- frameworkId: "testng",
12056
- entityType: "TEST_FRAMEWORK",
12057
- language: "Java",
12058
- maturity: "F1",
12059
- supportStatus: "EXPERIMENTAL",
12060
- targetMaturity: "F2",
12061
- targetSupportStatus: "OFFICIAL_PARTIAL",
12062
- validatedVersions: ["7.10", "7.11"],
12063
- keyGapForNextLevel: "GAP-TN-002 AST-based usage analysis"
12064
- },
12065
- {
12066
- frameworkId: "github-actions",
12067
- entityType: "CI_PROVIDER",
12068
- language: "YAML",
12069
- maturity: "F3",
12070
- supportStatus: "OFFICIAL_PARTIAL",
12071
- targetMaturity: "F4",
12072
- targetSupportStatus: "OFFICIAL_FULL",
12073
- validatedVersions: ["v4"],
12074
- keyGapForNextLevel: "GAP-GHA-004 advanced matrix strategy modeling"
12075
- },
12076
- {
12077
- frameworkId: "azure-devops",
12078
- entityType: "CI_PROVIDER",
12079
- language: "YAML",
12080
- maturity: "F3",
12081
- supportStatus: "OFFICIAL_PARTIAL",
12082
- targetMaturity: "F4",
12083
- targetSupportStatus: "OFFICIAL_FULL",
12084
- validatedVersions: ["2024"],
12085
- keyGapForNextLevel: "GAP-AZ-004 stage dependency graph modeling"
12086
- },
12087
- {
12088
- frameworkId: "jenkins",
12089
- entityType: "CI_PROVIDER",
12090
- language: "Groovy/YAML",
12091
- maturity: "F3",
12092
- supportStatus: "OFFICIAL_PARTIAL",
12093
- targetMaturity: "F4",
12094
- targetSupportStatus: "OFFICIAL_FULL",
12095
- validatedVersions: ["2.440", "2.450"],
12096
- keyGapForNextLevel: "GAP-JK-004 declarative pipeline advanced features"
12097
- },
12098
- {
12099
- frameworkId: "gitlab-ci",
12100
- entityType: "CI_PROVIDER",
12101
- language: "YAML",
12102
- maturity: "F0",
12103
- supportStatus: "UNSUPPORTED",
12104
- targetMaturity: "F1",
12105
- targetSupportStatus: "DISCOVERED",
12106
- validatedVersions: [],
12107
- keyGapForNextLevel: "GAP-GL-001 basic pipeline discovery"
12108
- }
12109
- ];
12110
- function getFrameworkById(frameworkId) {
12111
- return FRAMEWORK_INVENTORY.find((f) => f.frameworkId === frameworkId);
12112
- }
12113
- //#endregion
12114
12632
  //#region src/frameworks/scorecard.ts
12115
12633
  const SCORECARD_DIMENSIONS = [
12116
12634
  "discovery",
@@ -14582,14 +15100,7 @@ function isValidFinding(value) {
14582
15100
  }
14583
15101
  function isValidTrustSummary(value) {
14584
15102
  if (!isRecord$3(value)) return false;
14585
- return [
14586
- "L0",
14587
- "L1",
14588
- "L2",
14589
- "L3",
14590
- "L4",
14591
- "L5"
14592
- ].includes(String(value.level)) && isFiniteNumber(value.confidence) && value.confidence >= 0 && value.confidence <= 1 && isFiniteNumber(value.evidenceCoverage) && value.evidenceCoverage >= 0 && value.evidenceCoverage <= 1 && isFiniteNumber(value.inconclusiveRate) && value.inconclusiveRate >= 0 && value.inconclusiveRate <= 1 && (value.measuredFpOfFiredRules === void 0 || isFiniteNumber(value.measuredFpOfFiredRules) && value.measuredFpOfFiredRules >= 0 && value.measuredFpOfFiredRules <= 1) && isStringArray(value.provisionalRuleIds) && (value.confidenceCeiling === void 0 || isFiniteNumber(value.confidenceCeiling) && value.confidenceCeiling >= 0 && value.confidenceCeiling <= 1) && isStringArray(value.ceilingReasons);
15103
+ return TRUST_ORDER.includes(String(value.level)) && isFiniteNumber(value.confidence) && value.confidence >= 0 && value.confidence <= 1 && isFiniteNumber(value.evidenceCoverage) && value.evidenceCoverage >= 0 && value.evidenceCoverage <= 1 && isFiniteNumber(value.inconclusiveRate) && value.inconclusiveRate >= 0 && value.inconclusiveRate <= 1 && (value.measuredFpOfFiredRules === void 0 || isFiniteNumber(value.measuredFpOfFiredRules) && value.measuredFpOfFiredRules >= 0 && value.measuredFpOfFiredRules <= 1) && isStringArray(value.provisionalRuleIds) && (value.confidenceCeiling === void 0 || isFiniteNumber(value.confidenceCeiling) && value.confidenceCeiling >= 0 && value.confidenceCeiling <= 1) && isStringArray(value.ceilingReasons);
14593
15104
  }
14594
15105
  function isValidProvenance(value) {
14595
15106
  if (!isRecord$3(value)) return false;
@@ -15087,14 +15598,22 @@ function validateScanTarget(target, err) {
15087
15598
  return null;
15088
15599
  }
15089
15600
  /**
15090
- * Exit-code decision for a finished scan under the given gate level
15091
- * (audit H-7): the previously-dead config.gate field now selects which
15092
- * severities block. Advisory (E0) findings never gate at any level.
15601
+ * Exit-code decision for findings only (audit H-7): the previously-dead
15602
+ * config.gate field selects which severities block. Advisory (E0) findings
15603
+ * never gate at any level.
15604
+ *
15605
+ * This is a findings-only view of the one exit matrix in `claim-evidence`.
15606
+ * Callers that have just finished an ANALYSIS must use `scanExitCode`
15607
+ * instead — this function cannot see `partial`, and a truncated scan with
15608
+ * zero findings is exactly the case that turns a bug into a green build.
15093
15609
  */
15094
15610
  function exitForFindings(findings, gate) {
15095
- if (gate === "advisory") return 0;
15096
- const gateSeverities = gate === "warning" ? ["error", "warning"] : ["error"];
15097
- return findings.some((f) => gateSeverities.includes(f.severity) && !isAdvisoryFinding(f)) ? 1 : 0;
15611
+ return scanExitCode({
15612
+ partial: false,
15613
+ findings,
15614
+ gate,
15615
+ isAdvisory: isAdvisoryFinding
15616
+ });
15098
15617
  }
15099
15618
  /** Testable `ci install` handler. Returns the process exit code. */
15100
15619
  function runCiInstall(argv, io = {
@@ -15286,11 +15805,19 @@ function isEntryPoint() {
15286
15805
  return import.meta.url === pathToFileURL(argv1).href;
15287
15806
  }
15288
15807
  }
15289
- if (isEntryPoint()) try {
15290
- process$1.exitCode = await main();
15291
- } catch (err) {
15292
- internalErrorMessage(err, (s) => process$1.stderr.write(s + "\n"), false);
15293
- process$1.exitCode = 20;
15808
+ if (isEntryPoint()) {
15809
+ await initSentry();
15810
+ try {
15811
+ process$1.exitCode = await main();
15812
+ } catch (err) {
15813
+ captureInternalError(err, "cli");
15814
+ internalErrorMessage(err, (s) => process$1.stderr.write(s + "\n"), false);
15815
+ process$1.exitCode = 20;
15816
+ } finally {
15817
+ await flushSentry();
15818
+ }
15294
15819
  }
15295
15820
  //#endregion
15296
15821
  export { ENGINE_VERSION as CLI_VERSION, DEFAULT_MAX_DURATION_MS, EVIDENCE_OVERRIDES, KNOWN_RULE_IDS, MAX_DURATION_MS, OVERLAP_META_BY_RULE_ID, SUITE_INVALIDATING_RULE_IDS, buildUniversalRules, discoverAndParseRuntimeReport, err, exitForFindings, fallbackWorkspace, internalErrorMessage, isEntryPoint, isValidFindingRecord, levenshtein, main, nearestFlags, out, parseArgs, parseArgsOrUsage, pathMatchesGlob, printUsage, renderScanOutput, runAnalyzeCommand, runBadgeCommand, runBaselineCommand, runBusinessCaseCommand, runCIIntegrityCommand, runCiAdapterCommand, runCiInstall, runContractVerifyCommand, runCreateRuleCommand, runCrossFileCommand, runDashboardCommand, runDebtCommand, runDiffCommand, runDoctorCommand, runDoctorPlaywright, runEnterpriseCommand, runEvidenceGraphCommand, runExecReportCommand, runExplainCommand, runFixCommand, runForensicsCommand, runFrameworkMaturityCommand, runHandoffCommand, runHandoverCommand, runHelpCommand, runImpactCommand, runInitCommand, runInstallCommand, runMaturityCommand, runMutationCommand, runPolicyCommand, runPrCommentCommand, runPwReportCommand, runQuarantineCommand, runReleaseReportCommand, runReleaseTrustCommand, runReportPlaywrightCommand, runRulesCommand, runScan, runScanCommand, runStatsCommand, runSummaryCommand, runSuppressionGateCommand, runSuppressions, runTrendCommand, runTriageCommand, runTrustReportCommand, runTrustTrendCommand, runVerifyCommand, runWhyCommand, usageErrorMessage, validateScanTarget };
15822
+
15823
+ //# sourceMappingURL=cli.mjs.map