mjolnir-qa 2.1.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 massCeiling, A as box, At as isAdvisoryFinding, B as headlineFor, C as buildFooter, Ct as buildEvidenceGraph, D as plainContext, Dt as RULE_CATEGORIES, E as panel, Et as QA_IMPACT_LABELS, F as scoreGauge, G as buildDependencyGraph, H as EVIDENCE, I as shouldColorize, J as classifyProvenance, K as getReachableFiles, L as shouldUseAscii, M as padTo, N as palette, O as sectionHeader, Ot as SEVERITY_ORDER$1, P as sanitizeData, Q as deductionFor, R as wrapText, S as FLAKE_GLYPH, St as ENGINE_VERSION, T as okIcon, Tt as DEDUCTIONS, U as TINT, V as BADGE_BAND, W as TRUST, X as capForTier, Y as computeAgenticProfile, Z as computeDimensions, _t as ConfigValidationError, at as SEARCHED_FOR, b as renderSuppressions, bt as isRecord$2, ct as parseWorkflow, dt as parseTsFile, et as RETIRED_RULE_IDS, ft as detectFrameworks, gt as isLintFixtureDir, h as scan_pipeline_exports, ht as globToRegExp, it as SCAN_ADAPTERS, j as measure, jt as isValidCategory, k as severityIcon, kt as deriveEvidenceLevel, lt as parseYamlGuarded, m as runScan$1, mt as createIgnoreMatcher, n as KNOWN_RULE_IDS$1, nt as getRule, ot as isAzurePipelineFixture, pt as DEFAULT_IGNORE_MATCHER, q as correlateFindings, rt as resolveGitPath, st as parseAzurePipeline, tt as RULES, ut as computeCodeText, v as loadLocalRules, vt as isSuppressionActive, w as nextStep, wt as MEASURED_FP, x as runForensics, xt as parseJsonFile, y as loadSuppressions, yt as loadConfig, z as deriveScoreState } from "./scan-pipeline-MQGUSey3.mjs";
3
- import { accessSync, appendFileSync, chmodSync, closeSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
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
+ 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
- import { createHash, randomBytes } from "node:crypto";
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
  {
@@ -554,19 +920,146 @@ function renderRootHelp(schemaVersion = 1, options = {}) {
554
920
  }
555
921
  lines.push("");
556
922
  }
557
- lines.push("Copy-paste starts:");
558
- lines.push(" $ mjolnir score this repo's test suite");
559
- lines.push(" $ mjolnir --scope changed CI gate: only what the branch touched");
560
- lines.push(" $ mjolnir ci install write the PR workflow");
561
- lines.push(" $ mjolnir forensics test-results where the flakes hide");
562
- lines.push("");
563
- lines.push("Per-command help: mjolnir help <verb> (e.g. mjolnir help fix)");
564
- lines.push("");
565
- lines.push(`Exit codes: ${EXIT_CODE_TABLE.map(([c]) => c).join(" · ")}`);
566
- for (const [code, meaning] of EXIT_CODE_TABLE) lines.push(` ${code.padEnd(3)} ${meaning}`);
567
- lines.push("");
568
- lines.push(`Docs: ${DOCS_URL} (JSON schemaVersion ${schemaVersion}, additive-only)`);
569
- return lines.join("\n");
923
+ lines.push("Copy-paste starts:");
924
+ lines.push(" $ mjolnir score this repo's test suite");
925
+ lines.push(" $ mjolnir --scope changed CI gate: only what the branch touched");
926
+ lines.push(" $ mjolnir ci install write the PR workflow");
927
+ lines.push(" $ mjolnir forensics test-results where the flakes hide");
928
+ lines.push("");
929
+ lines.push("Per-command help: mjolnir help <verb> (e.g. mjolnir help fix)");
930
+ lines.push("");
931
+ lines.push(`Exit codes: ${EXIT_CODE_TABLE.map(([c]) => c).join(" · ")}`);
932
+ for (const [code, meaning] of EXIT_CODE_TABLE) lines.push(` ${code.padEnd(3)} ${meaning}`);
933
+ lines.push("");
934
+ lines.push(`Docs: ${DOCS_URL} (JSON schemaVersion ${schemaVersion}, additive-only)`);
935
+ return lines.join("\n");
936
+ }
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
+ }
570
1063
  }
571
1064
  //#endregion
572
1065
  //#region src/integrations/ci-install.ts
@@ -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:
@@ -672,60 +1206,71 @@ concurrency:
672
1206
 
673
1207
  permissions:
674
1208
  contents: read
675
- pull-requests: write
676
1209
 
677
1210
  jobs:
678
1211
  scan:
679
1212
  runs-on: ubuntu-latest
680
- # A hung scan must not sit for the 6-hour default.
681
1213
  timeout-minutes: 10
1214
+ permissions:
1215
+ contents: read
682
1216
  steps:
683
1217
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
684
1218
  with:
685
- fetch-depth: 0 # needed for --scope changed merge-base
1219
+ fetch-depth: 0
686
1220
  persist-credentials: false
687
- # Scan with the pinned version. No subcommand: npx resolves
688
- # the package bin directly; passing mjolnir as an arg causes
689
- # npm 10+ npx to invoke it twice (unknown subcommand).
690
1221
  - name: Scan changed code (exit 1/2 is data — the gate step decides)
691
1222
  continue-on-error: true
692
- run: npx --yes https://registry.npmjs.org/mjolnir-qa/-/mjolnir-qa-${ENGINE_VERSION}.tgz . --scope changed --json > mjolnir.json
693
- # Reporting, not gating: a crashed scan leaves mjolnir.json empty/missing
694
- # and the summary step exits 2/10 — continue-on-error keeps the advisory
695
- # job green, exactly like the v1 inline script did (the gate step decides).
1223
+ run: npx --yes https://registry.npmjs.org/mjolnir-qa/-/mjolnir-qa-${INSTALL_VERSION}.tgz . --scope changed --json > mjolnir.json
696
1224
  - name: Annotations + Job Summary
697
1225
  if: always()
698
1226
  continue-on-error: true
699
- 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
700
1228
  - name: Render PR comment
701
1229
  if: always()
702
1230
  continue-on-error: true
703
- run: npx --yes https://registry.npmjs.org/mjolnir-qa/-/mjolnir-qa-${ENGINE_VERSION}.tgz pr-comment . > mjolnir-comment.md
704
- # Best-effort: on a pull_request event from a fork the GITHUB_TOKEN is
705
- # read-only and this step will 403 for every external contributor. The
706
- # Job Summary above is the fallback that always renders.
707
- # (pull_request_target would fix the token but is a code-execution
708
- # risk — deliberately NOT used.)
709
- - name: Post or update PR comment
1231
+ run: npx --yes https://registry.npmjs.org/mjolnir-qa/-/mjolnir-qa-${INSTALL_VERSION}.tgz pr-comment --from mjolnir.json > mjolnir-comment.md
1232
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
1233
+ if: always()
1234
+ with:
1235
+ name: mjolnir-report
1236
+ path: mjolnir-comment.md
1237
+ if-no-files-found: warn
1238
+ retention-days: 30
1239
+ ${gate === "advisory" ? ` - name: Gate (advisory)
1240
+ if: always()
1241
+ run: echo "Advisory mode — findings reported, never blocking."` : ` - name: Gate (${gate})
1242
+ if: always()
1243
+ run: |
1244
+ node -e '
1245
+ ${indentBlock(gateScript(gate), 10)}
1246
+ '`}
1247
+
1248
+ publish:
1249
+ needs: scan
1250
+ if: always() && needs.scan.result != 'cancelled'
1251
+ runs-on: ubuntu-latest
1252
+ timeout-minutes: 5
1253
+ permissions:
1254
+ contents: read
1255
+ pull-requests: write
1256
+ steps:
1257
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
1258
+ with:
1259
+ name: mjolnir-report
1260
+ - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
710
1261
  if: always()
711
1262
  continue-on-error: true
712
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
713
1263
  with:
714
1264
  script: |
715
1265
  const fs = require('fs');
716
- let body = '';
717
- try { body = fs.readFileSync('mjolnir-comment.md', 'utf8'); } catch (e) {}
718
- if (!body.trim()) {
719
- console.log('mjolnir-comment.md is empty or missing — nothing to post.');
720
- return;
721
- }
722
- const marker = '<!-- mjolnir-pr-comment -->';
723
- const { data: comments } = await github.rest.issues.listComments({
724
- owner: context.repo.owner,
725
- repo: context.repo.repo,
726
- issue_number: context.issue.number,
727
- });
728
- const existing = comments.find((c) => c.body?.startsWith(marker));
1266
+ const body = fs.readFileSync('mjolnir-comment.md', 'utf8');
1267
+ if (!body.trim()) return;
1268
+ const marker = '<!-- mjolnir-report:v2 -->';
1269
+ const comments = await github.paginate(
1270
+ github.rest.issues.listComments,
1271
+ { owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, per_page: 100 },
1272
+ );
1273
+ const existing = comments.find((comment) => comment.body?.includes(marker));
729
1274
  if (existing) {
730
1275
  await github.rest.issues.updateComment({
731
1276
  owner: context.repo.owner,
@@ -741,20 +1286,9 @@ jobs:
741
1286
  body,
742
1287
  });
743
1288
  }
744
- ${gate === "advisory" ? ` # Advisory mode: findings are reported in the Job Summary and the
745
- # PR comment, never blocking. The scan step's exit code is visible
746
- # as the step outcome, but continue-on-error keeps the job green.
747
- - name: Gate (advisory)
748
- if: always()
749
- run: echo "Advisory mode — findings reported, never blocking."` : ` # Gate enforcement: the scan step's own exit code is deliberately
750
- # neutralized (continue-on-error) so reporting steps always run; THIS
751
- # step is what fails the job. A partial scan never blocks.
752
- - name: Gate (${gate})
1289
+ ${gate === "advisory" ? ` - name: Advisory note
753
1290
  if: always()
754
- run: |
755
- node -e '
756
- ${indentBlock(gateScript(gate), 10)}
757
- '`}
1291
+ run: echo "Advisory mode — findings are reported, never blocking."` : ""}
758
1292
  `;
759
1293
  /** Every gate variant, used to tell "our template" from "hand-customized". */
760
1294
  const GATES = [
@@ -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
 
@@ -793,69 +1339,77 @@ concurrency:
793
1339
 
794
1340
  permissions:
795
1341
  contents: read
796
- pull-requests: write
797
1342
 
798
1343
  jobs:
799
1344
  scan:
800
1345
  runs-on: ubuntu-latest
801
- # A hung scan must not sit for the 6-hour default.
802
1346
  timeout-minutes: 10
1347
+ permissions:
1348
+ contents: read
803
1349
  steps:
804
1350
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
805
1351
  with:
806
- fetch-depth: 0 # needed for --scope changed merge-base
1352
+ fetch-depth: 0
807
1353
  persist-credentials: false
808
- # The action scans with the published mjolnir-qa package
809
- # pinned to this CLI release. The composite action is pinned via
810
- # ACTION_REF. The fail-on input is the gate: it fails the
811
- # job on findings at the gate and never on a partial scan
812
- # (exit 2 downgrades to a warning — the frozen exit-code
813
- # contract). Advisory mode reports, never blocks.
814
1354
  - name: Mjölnir verification trust scan
815
1355
  id: mjolnir
816
- if: always()
817
- continue-on-error: ${gate === "advisory" ? "true" : "false"}
1356
+ continue-on-error: true
818
1357
  uses: ${ACTION_REF}
819
1358
  with:
820
1359
  scope: changed
821
1360
  format: json
822
1361
  fail-on: ${gate === "advisory" ? "none" : gate}
823
- version: ${ENGINE_VERSION}
824
- # Reporting, not gating: runs even when the scan/gate failed, from
825
- # the same mjolnir.json the action wrote.
826
- - name: Annotations + Job Summary
1362
+ pr-comment: "false"
1363
+ trust-artifact: "false"
1364
+ annotations: "true"
1365
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
827
1366
  if: always()
828
- continue-on-error: true
829
- run: npx --yes https://registry.npmjs.org/mjolnir-qa/-/mjolnir-qa-${ENGINE_VERSION}.tgz summary mjolnir.json
830
- - name: Render PR comment
1367
+ with:
1368
+ name: mjolnir-report
1369
+ path: mjolnir-comment.md
1370
+ if-no-files-found: warn
1371
+ retention-days: 30
1372
+ - name: Gate (${gate})
831
1373
  if: always()
832
- continue-on-error: true
833
- run: npx --yes https://registry.npmjs.org/mjolnir-qa/-/mjolnir-qa-${ENGINE_VERSION}.tgz pr-comment . > mjolnir-comment.md
834
- # Best-effort: on a pull_request event from a fork the GITHUB_TOKEN is
835
- # read-only and this step will 403 for every external contributor. The
836
- # Job Summary above is the fallback that always renders.
837
- # (pull_request_target would fix the token but is a code-execution
838
- # risk — deliberately NOT used.)
839
- - name: Post or update PR comment
1374
+ shell: bash
1375
+ env:
1376
+ MJ_SCAN_EXIT: \${{ steps.mjolnir.outputs.exit_code }}
1377
+ run: |
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
1386
+
1387
+ publish:
1388
+ needs: scan
1389
+ if: always() && needs.scan.result != 'cancelled'
1390
+ runs-on: ubuntu-latest
1391
+ timeout-minutes: 5
1392
+ permissions:
1393
+ contents: read
1394
+ pull-requests: write
1395
+ steps:
1396
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
1397
+ with:
1398
+ name: mjolnir-report
1399
+ - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
840
1400
  if: always()
841
1401
  continue-on-error: true
842
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
843
1402
  with:
844
1403
  script: |
845
1404
  const fs = require('fs');
846
- let body = '';
847
- try { body = fs.readFileSync('mjolnir-comment.md', 'utf8'); } catch (e) {}
848
- if (!body.trim()) {
849
- console.log('mjolnir-comment.md is empty or missing — nothing to post.');
850
- return;
851
- }
852
- const marker = '<!-- mjolnir-pr-comment -->';
853
- const listed = await github.rest.issues.listComments({
854
- owner: context.repo.owner,
855
- repo: context.repo.repo,
856
- issue_number: context.issue.number,
857
- });
858
- const existing = listed.data.find((c) => c.body?.startsWith(marker));
1405
+ const body = fs.readFileSync('mjolnir-comment.md', 'utf8');
1406
+ if (!body.trim()) return;
1407
+ const marker = '<!-- mjolnir-report:v2 -->';
1408
+ const comments = await github.paginate(
1409
+ github.rest.issues.listComments,
1410
+ { owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, per_page: 100 },
1411
+ );
1412
+ const existing = comments.find((comment) => comment.body?.includes(marker));
859
1413
  if (existing) {
860
1414
  await github.rest.issues.updateComment({
861
1415
  owner: context.repo.owner,
@@ -871,17 +1425,9 @@ jobs:
871
1425
  body,
872
1426
  });
873
1427
  }
874
- ${gate === "advisory" ? ` # Advisory mode: findings are reported in the Job Summary,
875
- # never blocking — the action ran with fail-on: none and
876
- # continue-on-error, so even a crashed scan cannot fail this job.
877
- - name: Gate (advisory)
1428
+ ${gate === "advisory" ? ` - name: Advisory note
878
1429
  if: always()
879
- run: echo "Advisory mode — findings reported, never blocking."` : ` # Gate enforcement: the action step above IS the gate — fail-on
880
- # ${gate} exits 1 on findings at the gate and the step is
881
- # continue-on-error: false, so its failure fails the job. A partial
882
- # scan never blocks (the action downgrades exit 2 to a warning per
883
- # the frozen exit-code contract). The reporting steps above ran
884
- # first (if: always()), so a red gate never suppresses the report.`}
1430
+ run: echo "Advisory mode — findings are reported, never blocking."` : ""}
885
1431
  `;
886
1432
  /** Multiset line diff — counts only, for the refusal message. */
887
1433
  function summarizeContentDiff(existing, incoming) {
@@ -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,
@@ -940,6 +1486,10 @@ function digestView(f) {
940
1486
  trustLevel: f.trustLevel ?? null,
941
1487
  confidence: f.confidence,
942
1488
  findingType: f.findingType,
1489
+ qaImpact: f.qaImpact,
1490
+ findingId: f.findingId ?? null,
1491
+ rootCauseId: f.rootCauseId ?? null,
1492
+ deduplicationGroup: f.deduplicationGroup ?? null,
943
1493
  fixGroupId: f.fixGroupId ?? null
944
1494
  };
945
1495
  }
@@ -960,8 +1510,12 @@ function canonicalScanJson(result) {
960
1510
  rules: result.analysisStatus.rules,
961
1511
  skippedFiles: result.analysisStatus.skippedFiles,
962
1512
  rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
963
- truncationReasons: result.analysisStatus.truncationReasons ?? []
1513
+ parseFallbacks: result.analysisStatus.parseFallbacks ?? 0,
1514
+ truncationReasons: result.analysisStatus.truncationReasons ?? [],
1515
+ reasons: result.analysisStatus.reasons ?? []
964
1516
  },
1517
+ scopeIntegrity: result.scopeIntegrity ?? null,
1518
+ runIdentity: result.runIdentity ?? null,
965
1519
  findings: result.findings.map(digestView)
966
1520
  });
967
1521
  }
@@ -1018,9 +1572,13 @@ function buildMachineContract(result) {
1018
1572
  rules: result.analysisStatus.rules,
1019
1573
  skippedFiles: result.analysisStatus.skippedFiles,
1020
1574
  rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
1575
+ ...result.analysisStatus.parseFallbacks !== void 0 ? { parseFallbacks: result.analysisStatus.parseFallbacks } : {},
1021
1576
  truncationReasons: result.analysisStatus.truncationReasons ?? [],
1577
+ ...result.analysisStatus.reasons !== void 0 ? { reasons: result.analysisStatus.reasons } : {},
1022
1578
  frameworkDetectionUnknown: result.frameworkDetectionUnknown,
1023
- durationMs: result.analysisStatus.durationMs
1579
+ durationMs: result.analysisStatus.durationMs,
1580
+ ...result.scopeIntegrity !== void 0 ? { scopeIntegrity: result.scopeIntegrity } : {},
1581
+ ...result.runIdentity !== void 0 ? { runIdentity: result.runIdentity } : {}
1024
1582
  },
1025
1583
  ...result.trustSummary !== void 0 ? { trustSummary: result.trustSummary } : {},
1026
1584
  ...result.agenticProfile !== void 0 ? { provenance: result.agenticProfile } : {},
@@ -1294,7 +1852,7 @@ function whatWouldChangeTheVerdict(r) {
1294
1852
  /** Structural validation of the loaded JSON (hostile-input safe). */
1295
1853
  function parseScanJson(raw) {
1296
1854
  try {
1297
- return parseJsonFile(raw, "scan result", (v) => isRecord$2(v) && v.schemaVersion === 1 && Array.isArray(v.findings) && Boolean(v.analysisStatus) && typeof v.partial === "boolean");
1855
+ return parseJsonFile(raw, "scan result", (v) => isRecord$3(v) && v.schemaVersion === 1 && Array.isArray(v.findings) && Boolean(v.analysisStatus) && typeof v.partial === "boolean");
1298
1856
  } catch {
1299
1857
  return;
1300
1858
  }
@@ -1409,122 +1967,6 @@ function pctOf(v) {
1409
1967
  return `${Math.round(v * 100)}%`;
1410
1968
  }
1411
1969
  //#endregion
1412
- //#region src/lib/fs-atomic.ts
1413
- /**
1414
- * Atomic file writes (audit S9).
1415
- *
1416
- * Every durability-critical write in Mjölnir (baseline, stats, badge,
1417
- * TRIAGE.md, scaffolded rule files) used to hand-roll
1418
- * `writeFileSync(path, data)` — a crash mid-write left a TRUNCATED file
1419
- * at the real path, and a subsequent read (diff, badge endpoint) served
1420
- * confident nonsense from it.
1421
- *
1422
- * `writeFileAtomic` writes to a temp sibling, then RENAMES. On the same
1423
- * volume rename is atomic: readers see either the complete old file or
1424
- * the complete new file, never a half-written one. The temp name is
1425
- * created with `wx` (exclusive) so concurrent writers cannot interleave,
1426
- * stale temps are cleaned up on failure, and on Windows the rename is
1427
- * retried briefly because a concurrent reader can hold the destination
1428
- * open (EBUSY/EPERM).
1429
- */
1430
- function atomicTempPath(path) {
1431
- return `${path}.mjolnir-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}.tmp`;
1432
- }
1433
- /**
1434
- * Atomically replace `path` with `data`.
1435
- */
1436
- function writeFileAtomic(path, data, opts = {}) {
1437
- const dir = dirname(path);
1438
- if (opts.mkdirs !== false && !existsSync(dir)) mkdirSync(dir, { recursive: true });
1439
- const tmp = atomicTempPath(path);
1440
- let fd;
1441
- try {
1442
- fd = openSync(tmp, "wx", opts.mode ?? 420);
1443
- writeSync(fd, data, null, opts.encoding ?? "utf8");
1444
- } finally {
1445
- if (fd !== void 0) closeSync(fd);
1446
- }
1447
- try {
1448
- renameWithWindowsRetry(tmp, path);
1449
- } catch (err) {
1450
- try {
1451
- if (existsSync(tmp)) unlinkSync(tmp);
1452
- } catch {}
1453
- throw err;
1454
- }
1455
- }
1456
- /**
1457
- * renameSync retry loop for Windows: a concurrent reader (another scan,
1458
- * a badge endpoint, an editor) holding the destination open makes
1459
- * rename fail with EBUSY/EPERM. A short bounded retry closes the race
1460
- * without turning an atomic swap into a partial write.
1461
- *
1462
- * Internal contract test hook: the platform check keeps this loop off
1463
- * the POSIX hot path; on win32 the EBUSY/EPERM arms are exercised by
1464
- * the fs-atomic-retry spec (mocked renameSync).
1465
- */
1466
- const RENAME_RETRIES = 8;
1467
- const RENAME_RETRY_DELAY_MS = 25;
1468
- /** Synchronous sleep that does not spin the CPU. */
1469
- function sleepSync(ms) {
1470
- try {
1471
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1472
- } catch {
1473
- const until = Date.now() + ms;
1474
- while (Date.now() < until);
1475
- }
1476
- }
1477
- function renameWithWindowsRetry(from, to) {
1478
- for (let attempt = 0;; attempt++) try {
1479
- renameSync(from, to);
1480
- return;
1481
- } catch (err) {
1482
- const code = err?.code;
1483
- if (process.platform === "win32" && (code === "EBUSY" || code === "EPERM") && attempt < RENAME_RETRIES) {
1484
- sleepSync(RENAME_RETRY_DELAY_MS);
1485
- continue;
1486
- }
1487
- throw err;
1488
- }
1489
- }
1490
- const STALE_TEMP_AGE_MS = 864e5;
1491
- const TEMP_PID_RE = /^.+\.mjolnir-([1-9]\d{0,9})-(\d{13})-[0-9a-f]{8}\.tmp$/;
1492
- function sweepStaleTempFiles(dir) {
1493
- let swept = 0;
1494
- let entries;
1495
- try {
1496
- entries = readdirSync(dir);
1497
- } catch {
1498
- return 0;
1499
- }
1500
- const cutoff = Date.now() - STALE_TEMP_AGE_MS;
1501
- for (const entry of entries) {
1502
- const match = TEMP_PID_RE.exec(entry);
1503
- if (!match || Number(match[2]) > cutoff) continue;
1504
- const path = join(dir, entry);
1505
- try {
1506
- const stat = lstatSync(path);
1507
- if (!stat.isFile() || stat.mtimeMs > cutoff) continue;
1508
- if (pidAlive(Number(match[1]))) continue;
1509
- unlinkSync(path);
1510
- swept++;
1511
- } catch {
1512
- continue;
1513
- }
1514
- }
1515
- return swept;
1516
- }
1517
- function pidAlive(pid) {
1518
- if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 2147483647) return true;
1519
- if (pid === process.pid) return true;
1520
- try {
1521
- process.kill(pid, 0);
1522
- return true;
1523
- } catch (err) {
1524
- return err?.code !== "ESRCH";
1525
- }
1526
- }
1527
- //#endregion
1528
1970
  //#region src/engine/finding-identity.ts
1529
1971
  /**
1530
1972
  * Finding Identity (ENGINE-002): deterministic, stable fingerprints for
@@ -2029,12 +2471,15 @@ function trustVerdictFor(v) {
2029
2471
  * today the honest command set is repeat/compare/inspect, not
2030
2472
  * "collect a trace", because trace.zip is not yet ingested.
2031
2473
  */
2474
+ function safeTriageText(value) {
2475
+ return value.replace(/[;&|`$()\\\r\n]/g, " ").slice(0, 200);
2476
+ }
2032
2477
  function nextActionFor(v) {
2033
2478
  switch (classifyVerdict(v)) {
2034
- case "RETRY-DEPENDENT": return `repeat execution of this test (3+ runs) to confirm the flake rate, then quarantine + ticket: ${v.file}`;
2035
- case "TIMEOUT": return `re-run in isolation to separate slowness from a hang: npx playwright test ${v.file} --timeout 60000`;
2036
- case "FAILING": return `reproduce locally: npx playwright test ${v.file} -g "${v.title.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}" — then fix and re-run`;
2037
- case "SKIPPED": return `inspect the skip condition in ${v.file} — a skip after a failure usually hides an environment problem`;
2479
+ case "RETRY-DEPENDENT": return `repeat execution of this test (3+ runs) to confirm the flake rate, then quarantine + ticket: ${safeTriageText(v.file)}`;
2480
+ case "TIMEOUT": return `re-run this test in isolation with a 60-second timeout to separate slowness from a hang: ${safeTriageText(v.file)}`;
2481
+ case "FAILING": return `reproduce this test in isolation, then fix and re-run: ${safeTriageText(v.file)} — ${safeTriageText(v.title)}`;
2482
+ case "SKIPPED": return `inspect the skip condition in ${safeTriageText(v.file)} — a skip after a failure usually hides an environment problem`;
2038
2483
  }
2039
2484
  }
2040
2485
  /** Deterministic guided rows, worst first (same order law as triageRows). */
@@ -2068,7 +2513,7 @@ function renderTriageWorkflow(report) {
2068
2513
  }
2069
2514
  for (const r of rows) {
2070
2515
  const flag = r.classification === "RETRY-DEPENDENT" ? "TRUE-FLAKE" : r.classification === "FAILING" ? "FAILING" : void 0;
2071
- lines.push(`[${r.classification}${flag ? ` · ${flag}` : ""}] ${r.test} (${r.file})`);
2516
+ lines.push(`[${r.classification}${flag ? ` · ${flag}` : ""}] ${safeTriageText(r.test)} (${safeTriageText(r.file)})`);
2072
2517
  for (const e of r.evidence) lines.push(` evidence: ${e}`);
2073
2518
  lines.push(` trust: ${r.trustVerdict}`);
2074
2519
  lines.push(` next: ${r.nextAction}`);
@@ -2240,7 +2685,16 @@ function renderPwRunSummary(s) {
2240
2685
  */
2241
2686
  /** Hard caps (§21 threat model: parameter size + resource bounds). */
2242
2687
  const MAX_PARAM_BYTES = 65536;
2688
+ const MAX_DURATION_MS$1 = 36e5;
2689
+ const DEFAULT_MAX_DURATION_MS$1 = 6e5;
2243
2690
  const PROTOCOL_VERSION = "2025-06-18";
2691
+ const CONFIGURED_ROOT = process.env.MJOLNIR_WORKSPACE_ROOT ? resolve(process.env.MJOLNIR_WORKSPACE_ROOT) : null;
2692
+ function isPathAllowed(path) {
2693
+ if (!CONFIGURED_ROOT) return true;
2694
+ const candidate = resolve(path);
2695
+ const rel = relative(CONFIGURED_ROOT, candidate);
2696
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
2697
+ }
2244
2698
  /** JSON-RPC error codes (subset used here). */
2245
2699
  const MCP_ERRORS = {
2246
2700
  PARSE: -32700,
@@ -2359,7 +2813,10 @@ function validateParams(name, args) {
2359
2813
  if (Buffer$1.byteLength(JSON.stringify(args), "utf8") > MAX_PARAM_BYTES) return `parameters exceed ${MAX_PARAM_BYTES} bytes (threat model §21)`;
2360
2814
  if (name === "scan") {
2361
2815
  if (typeof args["path"] !== "string" || args["path"].length === 0) return "scan requires a non-empty string `path`";
2362
- if (args["maxDurationMs"] !== void 0 && typeof args["maxDurationMs"] !== "number") return "`maxDurationMs` must be a number";
2816
+ if (args["maxDurationMs"] !== void 0) {
2817
+ const duration = args["maxDurationMs"];
2818
+ if (typeof duration !== "number" || !Number.isFinite(duration) || duration <= 0 || duration > MAX_DURATION_MS$1) return "`maxDurationMs` must be a finite positive number within the server budget";
2819
+ }
2363
2820
  }
2364
2821
  if (name === "explain") {
2365
2822
  if (typeof args["ruleId"] !== "string" || !/^QA-[A-Z]+-\d{3}$/.test(args["ruleId"])) return "explain requires `ruleId` matching /^QA-[A-Z]+-\\d{3}$/";
@@ -2404,6 +2861,24 @@ async function handleToolCall(call) {
2404
2861
  message: paramError
2405
2862
  }
2406
2863
  };
2864
+ const pathArgument = call.args["path"];
2865
+ if (typeof pathArgument === "string" && !isPathAllowed(pathArgument)) return {
2866
+ jsonrpc: "2.0",
2867
+ id: call.id,
2868
+ error: {
2869
+ code: MCP_ERRORS.INVALID_PARAMS,
2870
+ message: "path is outside the configured workspace root"
2871
+ }
2872
+ };
2873
+ const fixturesRoot = call.args["fixturesRoot"];
2874
+ if (typeof fixturesRoot === "string" && !isPathAllowed(fixturesRoot)) return {
2875
+ jsonrpc: "2.0",
2876
+ id: call.id,
2877
+ error: {
2878
+ code: MCP_ERRORS.INVALID_PARAMS,
2879
+ message: "fixturesRoot is outside the configured workspace root"
2880
+ }
2881
+ };
2407
2882
  try {
2408
2883
  if (call.name === "scan") {
2409
2884
  const target = resolve(call.args["path"]);
@@ -2415,7 +2890,7 @@ async function handleToolCall(call) {
2415
2890
  message: `scan target does not exist: ${target}`
2416
2891
  }
2417
2892
  };
2418
- const maxDurationMs = typeof call.args["maxDurationMs"] === "number" ? call.args["maxDurationMs"] : Number.POSITIVE_INFINITY;
2893
+ const maxDurationMs = typeof call.args["maxDurationMs"] === "number" ? call.args["maxDurationMs"] : DEFAULT_MAX_DURATION_MS$1;
2419
2894
  const result = await serializeScan(() => runScan$1({
2420
2895
  target,
2421
2896
  json: true,
@@ -3034,16 +3509,6 @@ function renderTerminal(result, opts) {
3034
3509
  appendFooter(lines, result, ui);
3035
3510
  return lines.join("\n");
3036
3511
  }
3037
- /**
3038
- * Contract-stable three-band verdict (property-locked in
3039
- * tests/scoring-precision.spec.ts). Delegates to the ScoreState model —
3040
- * 100 keeps returning WORTHY here; the FORGED premium treatment lives
3041
- * in the dedicated block, not in this public mapping.
3042
- */
3043
- function verdictFor(score) {
3044
- const verdict = deriveScoreState(score).verdict;
3045
- return verdict === "FORGED" ? "WORTHY" : verdict;
3046
- }
3047
3512
  /** Appends the score, gauge, verdict, and honesty metadata for the full scan. */
3048
3513
  function appendScoreSection(lines, result, p, width, ascii) {
3049
3514
  const state = deriveScoreState(result.score);
@@ -3180,26 +3645,9 @@ function appendNextActions(lines, display, fullResult, ui) {
3180
3645
  } else if (partial) lines.push(nextStep("mjolnir --verbose", ui));
3181
3646
  lines.push("");
3182
3647
  }
3183
- function evidenceTag$1(f) {
3184
- const level = f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence);
3185
- let tag = `${level} · ${level === "E2" ? "deterministic" : level === "E1" ? "heuristic" : "observation"}`;
3186
- if (f.measuredFpRate !== void 0) {
3187
- tag += ` · measured FP ${Math.round(f.measuredFpRate * 100)}%`;
3188
- if (f.measuredFpN !== void 0) tag += ` · n=${f.measuredFpN}`;
3189
- }
3190
- if (f.trustLevel !== void 0) tag += ` · trust ${f.trustLevel}`;
3191
- if (f.runtimeCorroboration !== void 0) {
3192
- const c = f.runtimeCorroboration;
3193
- let label = "file executed";
3194
- if (c.level === "defect") label = "defect corroborated";
3195
- else if (c.level === "test") label = "test executed";
3196
- tag += ` · runtime: ${label}`;
3197
- }
3198
- return `[${tag}]`;
3199
- }
3200
3648
  /** Deterministic per-severity verification hint: what re-running should
3201
- * show after the fix lands. Deduction is the honest, evidence-discounted
3202
- * 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. */
3203
3651
  function verifyHint(f) {
3204
3652
  const pts = deductionFor(f);
3205
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.";
@@ -3212,7 +3660,7 @@ function toCard(f, tone) {
3212
3660
  severity: f.severity,
3213
3661
  loc: `${sanitizeData(f.ruleId)} · ${sanitizeData(f.file)}:${f.line}`,
3214
3662
  problem: sanitizeData(problem),
3215
- evidence: evidenceTag$1(f),
3663
+ evidence: evidenceTag(f),
3216
3664
  impact: `${f.qaImpact} — ${sanitizeData(f.why)}`,
3217
3665
  fix: sanitizeData(f.fix),
3218
3666
  verify: verifyHint(f)
@@ -3332,7 +3780,7 @@ function appendFindings(lines, result, counts, verbose, ui, tone) {
3332
3780
  continue;
3333
3781
  }
3334
3782
  const groupHead = ` ${severityIcon(maxSeverity(unit.findings), ui)} ${p.bold(sanitizeData(unit.ruleId))} ${p.dim(`× ${n} — same fix applies`)}`;
3335
- const groupEvidence = evidenceTag$1(first);
3783
+ const groupEvidence = evidenceTag(first);
3336
3784
  if (measure(`${groupHead} ${groupEvidence}`) <= width) lines.push(`${groupHead} ${p.dim(groupEvidence)}`);
3337
3785
  else {
3338
3786
  lines.push(groupHead);
@@ -3421,7 +3869,7 @@ function appendFooter(lines, result, ui) {
3421
3869
  complete: result.analysisStatus.discovery !== "partial",
3422
3870
  durationMs: result.analysisStatus.durationMs
3423
3871
  }));
3424
- 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;
3425
3873
  if (advisory > 0) pushWrapped(lines, p, `${advisory} advisory finding${advisory === 1 ? "" : "s"} (E0 — observation only, no score impact)`, width);
3426
3874
  if (result.findings.length > 0) {
3427
3875
  const firedRuleIds = new Set(result.findings.map((f) => f.ruleId));
@@ -3483,29 +3931,89 @@ function countBySeverity(result) {
3483
3931
  }
3484
3932
  return counts;
3485
3933
  }
3486
- EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2;
3487
- const RUNG_MEANINGS = [
3488
- "observation only",
3489
- "heuristic static",
3490
- "deterministic static",
3491
- "the finding's file executed",
3492
- "the finding's test executed",
3493
- "the run verdict corroborates"
3494
- ];
3495
- const RUNG_COLORS = [
3496
- TRUST.l0,
3497
- TRUST.l1,
3498
- TRUST.l2,
3499
- TRUST.l3,
3500
- TRUST.l4,
3501
- TRUST.l5
3502
- ];
3503
- const TRUST_RUNGS = RUNG_MEANINGS.map((meaning, i) => ({
3504
- level: `L${i}`,
3505
- meaning,
3506
- runtime: i >= 3,
3507
- color: RUNG_COLORS[i]
3508
- }));
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
+ }
3509
4017
  //#endregion
3510
4018
  //#region src/lib/format.ts
3511
4019
  /**
@@ -3517,11 +4025,6 @@ function pct$1(v) {
3517
4025
  return `${Math.round(v * 100)}%`;
3518
4026
  }
3519
4027
  //#endregion
3520
- //#region src/reporter/evidence-tag.ts
3521
- function evidenceTag(f) {
3522
- return f.runtimeCorroboration ? f.runtimeCorroboration.level === "defect" ? "run corroborated" : "run executed" : (f.evidenceLevel ?? "E2") === "E2" ? "deterministic" : "pattern";
3523
- }
3524
- //#endregion
3525
4028
  //#region src/reporter/trust-report.ts
3526
4029
  /**
3527
4030
  * The rung labels, built from `src/brand/symbols.ts` rather than typed
@@ -3540,22 +4043,34 @@ function evidenceTag(f) {
3540
4043
  */
3541
4044
  const TRUST_LABELS = Object.fromEntries(TRUST_RUNGS.map((r) => [r.level, `${r.level} · ${r.meaning}${r.runtime ? " · runtime" : ""}`]));
3542
4045
  /**
3543
- * The human verdict line derived from the measurement. Deterministic
3544
- * mapping from (level, confidence) bands — a measurement-derived label,
3545
- * never a new verdict enum (plan §3/§36: no new global enums).
3546
- */
3547
- function trustHeadline(s) {
3548
- 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.";
3549
- 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.";
3550
- if (s.confidence >= .75) return "Deterministic static analysis — trustworthy, uncorroborated by a run.";
3551
- 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.";
3552
4065
  }
3553
4066
  /** WHY THIS VERDICT — evidence-backed reasons, each from a real field. */
3554
4067
  function trustReasons(result, s) {
3555
4068
  const reasons = [];
3556
4069
  const corroborated = result.findings.filter((f) => f.runtimeCorroboration !== void 0).length;
3557
- if (result.findings.length === 0) reasons.push(result.partial ? "No findings so far, but the scan did not finish — the gaps are not proof of cleanliness." : "No findings fired on the analyzed surface — the scan found nothing to report.");
3558
- else reasons.push(`${result.findings.length} finding(s) fired; ${corroborated} corroborated by a real run report.`);
4070
+ if (result.findings.length === 0) {
4071
+ const incomplete = result.partial || result.analysisStatus.discovery !== "complete" || result.analysisStatus.rules !== "complete" || s.level === "L0" && s.evidenceCoverage === 0;
4072
+ reasons.push(incomplete ? "No findings so far, but the scan or evidence is incomplete — the gaps are not proof of cleanliness." : "No findings fired on the analyzed surface — the scan found nothing to report.");
4073
+ } else reasons.push(`${result.findings.length} finding(s) fired; ${corroborated} corroborated by a real run report.`);
3559
4074
  if (s.confidenceCeiling !== void 0) reasons.push(`confidence capped at ${pct$1(s.confidenceCeiling)} by: ${s.ceilingReasons.join(", ")}.`);
3560
4075
  if (s.provisionalRuleIds.length > 0) reasons.push(`${s.provisionalRuleIds.length} fired rule(s) unmeasured (author-estimated FP) — disclosed, not counted.`);
3561
4076
  else if (s.measuredFpOfFiredRules !== void 0) reasons.push(`every fired rule carries a measured false-positive rate (evidence-weighted ${pct$1(s.measuredFpOfFiredRules)}).`);
@@ -3580,7 +4095,7 @@ function topTrustRisks(findings, n = 3) {
3580
4095
  }
3581
4096
  /** NEXT ACTION — one concrete command, deterministic. */
3582
4097
  function nextAction(result) {
3583
- if (result.partial) return "re-run with a higher --max-duration to close the truncated surface";
4098
+ if (result.partial || result.analysisStatus.discovery !== "complete" || result.analysisStatus.rules !== "complete" || (result.analysisStatus.rulesCrashed ?? 0) > 0) return "re-run with a higher --max-duration to close the truncated surface";
3584
4099
  const risks = topTrustRisks(result.findings, 1);
3585
4100
  if (risks.length === 0) return result.testDeclarationCount === 0 ? "add tests — a repo without test declarations has nothing to verify" : "no findings on the analyzed surface — install advisory CI (`mjolnir ci install`) and re-scan the changed scope";
3586
4101
  return `mjolnir explain ${risks[0]?.ruleId ?? ""} — then fix the top risk first`;
@@ -3606,9 +4121,10 @@ function renderTrustReport(result, opts) {
3606
4121
  provisionalRuleIds: [],
3607
4122
  ceilingReasons: []
3608
4123
  };
4124
+ const classification = classifyTrust(result, s);
3609
4125
  lines.push(sectionHeader("TRUST VERDICT", ui));
3610
4126
  lines.push(` ${p.accent(TRUST_LABELS[s.level] ?? s.level)}`);
3611
- lines.push(` ${trustHeadline(s)}`);
4127
+ lines.push(` ${trustHeadline(s, classification)}`);
3612
4128
  lines.push("");
3613
4129
  lines.push(sectionHeader("CONFIDENCE", ui));
3614
4130
  lines.push(` confidence ${pct$1(s.confidence)}${s.confidenceCeiling !== void 0 ? ` (ceiling ${pct$1(s.confidenceCeiling)})` : ""}`);
@@ -3617,7 +4133,7 @@ function renderTrustReport(result, opts) {
3617
4133
  if (s.measuredFpOfFiredRules !== void 0) lines.push(` measured FP (fired) ${pct$1(s.measuredFpOfFiredRules)} — evidence-weighted`);
3618
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` : ""}`);
3619
4135
  else lines.push(" measured FP (fired) none fired — nothing to weight");
3620
- 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)}`);
3621
4137
  lines.push("");
3622
4138
  lines.push(sectionHeader("WHY THIS VERDICT", ui));
3623
4139
  for (const r of trustReasons(result, s)) lines.push(` - ${r}`);
@@ -3795,9 +4311,13 @@ function escapeLabel(text) {
3795
4311
  function classDef(name, tint) {
3796
4312
  return ` classDef ${name} fill:${tint.fill},stroke:${tint.stroke},color:${tint.text};`;
3797
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. */
3798
4317
  function dimensionStyleClass(dim) {
3799
- if (dim.score >= 80) return "healthy";
3800
- 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";
3801
4321
  return "critical";
3802
4322
  }
3803
4323
  const SEVERITY_ORDER = [
@@ -3993,6 +4513,312 @@ function currentCommit(root, opts = {}) {
3993
4513
  }
3994
4514
  }
3995
4515
  //#endregion
4516
+ //#region src/commands/report-io.ts
4517
+ /**
4518
+ * Shared saved-report loading (agent-handoff plan §9.0).
4519
+ *
4520
+ * `summary`, `handoff` and `why` all consume a saved `--json` report.
4521
+ * One loader, one validation, one error shape — previously
4522
+ * validateReportJson was private to summary.ts; extracting it keeps
4523
+ * the three commands byte-identical in their loading behavior without
4524
+ * duplication. The exit-code mapping stays in the callers (they own
4525
+ * their io), but the error MESSAGES are identical because they come
4526
+ * from here.
4527
+ */
4528
+ const MAX_REPORT_BYTES = 33554432;
4529
+ const MAX_FINDINGS = 1e4;
4530
+ const CONFIDENCE_VALUES = /* @__PURE__ */ new Set([
4531
+ "high",
4532
+ "medium",
4533
+ "low"
4534
+ ]);
4535
+ const FINDING_TYPE_VALUES = /* @__PURE__ */ new Set([
4536
+ "deterministic-defect",
4537
+ "heuristic-risk",
4538
+ "observation"
4539
+ ]);
4540
+ const QA_IMPACT_VALUES = /* @__PURE__ */ new Set([
4541
+ "BLOCKS-RELEASE",
4542
+ "FLAKY-RISK",
4543
+ "FALSE-GREEN",
4544
+ "HYGIENE"
4545
+ ]);
4546
+ const EVIDENCE_VALUES = /* @__PURE__ */ new Set([
4547
+ "E0",
4548
+ "E1",
4549
+ "E2"
4550
+ ]);
4551
+ const TRUST_VALUES = new Set(TRUST_ORDER);
4552
+ /** Human message for any thrown value — never "undefined"/"[object Object]". */
4553
+ function errorText$1(err) {
4554
+ if (err instanceof Error) return err.message;
4555
+ if (typeof err === "string") return err;
4556
+ if (typeof err === "object" && err !== null) return JSON.stringify(err);
4557
+ return String(err);
4558
+ }
4559
+ function isRecord$2(value) {
4560
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4561
+ }
4562
+ function fail(message) {
4563
+ throw new Error(message);
4564
+ }
4565
+ function requireRecord(value, label) {
4566
+ if (!isRecord$2(value)) fail(`${label} must be an object`);
4567
+ return value;
4568
+ }
4569
+ function requireString(value, label) {
4570
+ if (typeof value !== "string" || value.length === 0) fail(`${label} must be a non-empty string`);
4571
+ return value;
4572
+ }
4573
+ function requireBoolean(value, label) {
4574
+ if (typeof value !== "boolean") fail(`${label} must be a boolean`);
4575
+ return value;
4576
+ }
4577
+ function requireFiniteNumber(value, label) {
4578
+ if (typeof value !== "number" || !Number.isFinite(value)) fail(`${label} must be a finite number`);
4579
+ return value;
4580
+ }
4581
+ function requireInteger(value, label, minimum = 0) {
4582
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) fail(`${label} must be an integer >= ${minimum}`);
4583
+ return value;
4584
+ }
4585
+ function validateDimension(value, index) {
4586
+ const dimension = requireRecord(value, `dimensions[${index}]`);
4587
+ const category = requireString(dimension.category, `dimensions[${index}].category`);
4588
+ if (!RULE_CATEGORIES.includes(category)) fail(`dimensions[${index}].category is unknown`);
4589
+ const score = requireFiniteNumber(dimension.score, `dimensions[${index}].score`);
4590
+ if (score < 0 || score > 100) fail(`dimensions[${index}].score must be between 0 and 100`);
4591
+ requireInteger(dimension.errors, `dimensions[${index}].errors`);
4592
+ requireInteger(dimension.warnings, `dimensions[${index}].warnings`);
4593
+ requireInteger(dimension.infos, `dimensions[${index}].infos`);
4594
+ }
4595
+ function validateFinding(value, index) {
4596
+ const finding = requireRecord(value, `findings[${index}]`);
4597
+ requireString(finding.ruleId, `findings[${index}].ruleId`);
4598
+ const category = requireString(finding.category, `findings[${index}].category`);
4599
+ if (!RULE_CATEGORIES.includes(category)) fail(`findings[${index}].category is unknown`);
4600
+ const severity = requireString(finding.severity, `findings[${index}].severity`);
4601
+ if (!SEVERITY_ORDER$1.includes(severity)) fail(`findings[${index}].severity is unknown`);
4602
+ const confidence = requireString(finding.confidence, `findings[${index}].confidence`);
4603
+ if (!CONFIDENCE_VALUES.has(confidence)) fail(`findings[${index}].confidence is unknown`);
4604
+ const findingType = requireString(finding.findingType, `findings[${index}].findingType`);
4605
+ if (!FINDING_TYPE_VALUES.has(findingType)) fail(`findings[${index}].findingType is unknown`);
4606
+ const qaImpact = requireString(finding.qaImpact, `findings[${index}].qaImpact`);
4607
+ if (!QA_IMPACT_VALUES.has(qaImpact)) fail(`findings[${index}].qaImpact is unknown`);
4608
+ requireString(finding.file, `findings[${index}].file`);
4609
+ requireInteger(finding.line, `findings[${index}].line`, 1);
4610
+ requireInteger(finding.column, `findings[${index}].column`, 1);
4611
+ requireString(finding.message, `findings[${index}].message`);
4612
+ requireString(finding.why, `findings[${index}].why`);
4613
+ requireString(finding.fix, `findings[${index}].fix`);
4614
+ if (finding.evidenceLevel !== void 0 && (typeof finding.evidenceLevel !== "string" || !EVIDENCE_VALUES.has(finding.evidenceLevel))) fail(`findings[${index}].evidenceLevel is unknown`);
4615
+ if (finding.trustLevel !== void 0 && (typeof finding.trustLevel !== "string" || !TRUST_VALUES.has(finding.trustLevel))) fail(`findings[${index}].trustLevel is unknown`);
4616
+ if (finding.detectorRevision !== void 0) requireInteger(finding.detectorRevision, `findings[${index}].detectorRevision`, 1);
4617
+ if (finding.measuredFpRate !== void 0) {
4618
+ const rate = requireFiniteNumber(finding.measuredFpRate, `findings[${index}].measuredFpRate`);
4619
+ if (rate < 0 || rate > 1) fail(`findings[${index}].measuredFpRate must be between 0 and 1`);
4620
+ }
4621
+ if (finding.measuredFpN !== void 0) requireInteger(finding.measuredFpN, `findings[${index}].measuredFpN`);
4622
+ }
4623
+ function validateAnalysisStatus(value) {
4624
+ const status = requireRecord(value, "analysisStatus");
4625
+ if (status.discovery !== "complete" && status.discovery !== "partial") fail("analysisStatus.discovery must be \"complete\" or \"partial\"");
4626
+ if (status.rules !== "complete" && status.rules !== "partial") fail("analysisStatus.rules must be \"complete\" or \"partial\"");
4627
+ requireInteger(status.skippedFiles, "analysisStatus.skippedFiles");
4628
+ requireFiniteNumber(status.durationMs, "analysisStatus.durationMs");
4629
+ if (status.rulesCrashed !== void 0) requireInteger(status.rulesCrashed, "analysisStatus.rulesCrashed");
4630
+ if (status.parseFallbacks !== void 0) requireInteger(status.parseFallbacks, "analysisStatus.parseFallbacks");
4631
+ if (status.truncationReasons !== void 0) {
4632
+ if (!Array.isArray(status.truncationReasons)) fail("analysisStatus.truncationReasons must be an array");
4633
+ status.truncationReasons.forEach((reason, index) => requireString(reason, `analysisStatus.truncationReasons[${index}]`));
4634
+ }
4635
+ if (status.reasons !== void 0) {
4636
+ if (!Array.isArray(status.reasons)) fail("analysisStatus.reasons must be an array");
4637
+ status.reasons.forEach((reason, index) => requireString(reason, `analysisStatus.reasons[${index}]`));
4638
+ }
4639
+ }
4640
+ function validateScopeIntegrity(value) {
4641
+ const scope = requireRecord(value, "scopeIntegrity");
4642
+ requireInteger(scope.discovered, "scopeIntegrity.discovered");
4643
+ requireInteger(scope.analyzed, "scopeIntegrity.analyzed");
4644
+ requireInteger(scope.ignored, "scopeIntegrity.ignored");
4645
+ requireInteger(scope.unrecognized, "scopeIntegrity.unrecognized");
4646
+ requireInteger(scope.parseFailed, "scopeIntegrity.parseFailed");
4647
+ requireInteger(scope.truncated, "scopeIntegrity.truncated");
4648
+ if (scope.scopeVerdict !== "PROVEN" && scope.scopeVerdict !== "PARTIAL") fail("scopeIntegrity.scopeVerdict must be \"PROVEN\" or \"PARTIAL\"");
4649
+ if (scope.reasons !== void 0) {
4650
+ if (!Array.isArray(scope.reasons)) fail("scopeIntegrity.reasons must be an array");
4651
+ scope.reasons.forEach((reason, index) => requireString(reason, `scopeIntegrity.reasons[${index}]`));
4652
+ }
4653
+ }
4654
+ /**
4655
+ * Parse-and-validate a saved report. Throws Error with a
4656
+ * human-explanatory message on invalid JSON, shape, or contract values.
4657
+ */
4658
+ function validateReportJson(text) {
4659
+ if (Buffer.byteLength(text, "utf8") > MAX_REPORT_BYTES) fail(`report exceeds the ${MAX_REPORT_BYTES}-byte limit`);
4660
+ let parsed;
4661
+ try {
4662
+ parsed = JSON.parse(text);
4663
+ } catch (err) {
4664
+ throw new Error(`not valid JSON (${errorText$1(err)})`, { cause: err });
4665
+ }
4666
+ const doc = requireRecord(parsed, "the report is not an object");
4667
+ if (doc.schemaVersion !== 1) fail(`unsupported schemaVersion ${JSON.stringify(doc.schemaVersion)} — expected schemaVersion 1`);
4668
+ requireBoolean(doc.partial, "partial");
4669
+ if (doc.score !== null) {
4670
+ const score = requireFiniteNumber(doc.score, "score");
4671
+ if (score < 0 || score > 100) fail("score must be null or between 0 and 100");
4672
+ }
4673
+ if (doc.reason !== void 0 && doc.reason !== "no-tests-found") fail("reason must be \"no-tests-found\" when present");
4674
+ if (doc.frameworks === void 0) fail("missing a \"frameworks\" array — is this a complete Mjölnir --json report?");
4675
+ if (!Array.isArray(doc.frameworks) || doc.frameworks.some((value) => typeof value !== "string")) fail("frameworks must be an array of strings");
4676
+ requireBoolean(doc.frameworkDetectionUnknown, "frameworkDetectionUnknown");
4677
+ if (!Array.isArray(doc.dimensions)) fail("dimensions must be an array");
4678
+ doc.dimensions.forEach(validateDimension);
4679
+ if (!Array.isArray(doc.findings)) fail("missing a \"findings\" array — is this a Mjölnir --json report?");
4680
+ if (doc.findings.length > MAX_FINDINGS) fail(`findings exceeds the ${MAX_FINDINGS}-finding limit`);
4681
+ doc.findings.forEach(validateFinding);
4682
+ validateAnalysisStatus(doc.analysisStatus);
4683
+ if (doc.scopeIntegrity !== void 0) validateScopeIntegrity(doc.scopeIntegrity);
4684
+ const status = requireRecord(doc.analysisStatus, "analysisStatus");
4685
+ const partialMarkers = status["discovery"] !== "complete" || status["rules"] !== "complete" || Number(status["skippedFiles"]) > 0 || Number(status["rulesCrashed"] ?? 0) > 0 || Array.isArray(status["reasons"]) && status["reasons"].length > 0;
4686
+ if (doc.partial === false && partialMarkers) fail("partial=false conflicts with analysisStatus");
4687
+ if (doc.partial === true && typeof doc.score === "number" && doc.score === 100) fail("partial reports cannot have a perfect score");
4688
+ if ((isRecord$2(doc.scopeIntegrity) ? doc.scopeIntegrity : void 0)?.["scopeVerdict"] === "PARTIAL" && doc.partial === false) fail("partial=false conflicts with scopeIntegrity");
4689
+ return parsed;
4690
+ }
4691
+ /** Load + validate a saved report from disk. Throws on any problem. */
4692
+ function loadSavedReport(reportPath) {
4693
+ const stat = statSync(reportPath);
4694
+ if (!stat.isFile()) fail(`report path is not a file: ${reportPath}`);
4695
+ if (stat.size > MAX_REPORT_BYTES) fail(`report exceeds the ${MAX_REPORT_BYTES}-byte limit`);
4696
+ return validateReportJson(readFileSync(reportPath, "utf8"));
4697
+ }
4698
+ /** True when the report file exists (callers own the not-found message). */
4699
+ function reportExists(reportPath) {
4700
+ return existsSync(reportPath);
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
+ }
4763
+ //#endregion
4764
+ //#region src/integrations/github/evidence-sanitization.ts
4765
+ const MAX_MARKDOWN_TEXT_LENGTH = 500;
4766
+ /**
4767
+ * Characters that have special meaning in Markdown and must be escaped
4768
+ * to render as literal text.
4769
+ */
4770
+ const MARKDOWN_SPECIAL_RE = /[[\]*_`~\\|{}()<>#!]/g;
4771
+ /**
4772
+ * HTML tags and entities that could enable injection.
4773
+ */
4774
+ const HTML_INJECTION_RE = /<[^>]*>/g;
4775
+ const HTML_ENTITY_RE = /&\w+;|&#\d+;|&#x[\da-f]+;/gi;
4776
+ /**
4777
+ * Patterns that could form malformed or malicious links.
4778
+ */
4779
+ const MALFORMED_LINK_PATTERNS = [/\]\s*\(\s*(?:javascript|data|vbscript)\s*:/gi, /\]\s*\(\s*[^)]{0,200}<[^)]{0,200}>/g];
4780
+ const RTL_OVERRIDE_RE = /[\u200f\u202e\u2067\u2066\u2069\u2068]/g;
4781
+ /**
4782
+ * Escape Markdown special characters in text so they render as literals.
4783
+ */
4784
+ function escapeMarkdownChars(text) {
4785
+ return text.replace(MARKDOWN_SPECIAL_RE, "\\$&");
4786
+ }
4787
+ /**
4788
+ * Strip HTML tags and entities to prevent injection.
4789
+ */
4790
+ function stripHtml(text) {
4791
+ return text.replace(HTML_ENTITY_RE, "").replace(HTML_INJECTION_RE, "");
4792
+ }
4793
+ /**
4794
+ * Neutralize potential malformed link patterns.
4795
+ */
4796
+ function neutralizeMalformedLinks(text) {
4797
+ let result = text;
4798
+ for (const pattern of MALFORMED_LINK_PATTERNS) result = result.replace(pattern, (match) => `[sanitized: ${match.slice(0, 40)}]`);
4799
+ return result;
4800
+ }
4801
+ /**
4802
+ * Strip RTL override characters that could confuse display.
4803
+ */
4804
+ function stripRtlOverrides(text) {
4805
+ return text.replace(RTL_OVERRIDE_RE, "");
4806
+ }
4807
+ /**
4808
+ * Sanitize arbitrary text for safe inclusion in Markdown.
4809
+ * Escapes special chars, strips HTML, prevents link injection,
4810
+ * strips RTL overrides, and caps length.
4811
+ */
4812
+ function sanitizeForMarkdown(text) {
4813
+ let result = text;
4814
+ result = stripHtml(result);
4815
+ result = stripRtlOverrides(result);
4816
+ result = neutralizeMalformedLinks(result);
4817
+ result = escapeMarkdownChars(result);
4818
+ if (result.length > MAX_MARKDOWN_TEXT_LENGTH) result = result.slice(0, MAX_MARKDOWN_TEXT_LENGTH) + "\\.\\.\\.";
4819
+ return result;
4820
+ }
4821
+ //#endregion
3996
4822
  //#region src/commands/trust-report.ts
3997
4823
  /**
3998
4824
  * `mjolnir trust-report` — the Trust Artifact (Mega MVP Master Plan
@@ -4033,7 +4859,23 @@ function fallbackSummary(result) {
4033
4859
  }
4034
4860
  /** HTML-escape a text interpolation (the artifact is hostile-input-safe). */
4035
4861
  function esc(text) {
4036
- return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
4862
+ return [...text].filter((character) => {
4863
+ const code = character.codePointAt(0) ?? 0;
4864
+ return !(code <= 31 || code >= 127 && code <= 159 || code === 8207 || code === 8238 || code >= 8294 && code <= 8297);
4865
+ }).join("").slice(0, 1e3).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
4866
+ }
4867
+ function publicText(text) {
4868
+ return sanitizeForMarkdown(sanitizeErrorText(text, { maxLength: 1e3 }));
4869
+ }
4870
+ function publicJsonText(text) {
4871
+ return sanitizeErrorText(text, { maxLength: 1e3 });
4872
+ }
4873
+ function writeArtifact(path, content) {
4874
+ try {
4875
+ writeFileAtomic(path, content, { encoding: "utf8" });
4876
+ } catch (err) {
4877
+ throw new Error(`failed to write artifact ${path}: ${errorMessage(err)}`, { cause: err });
4878
+ }
4037
4879
  }
4038
4880
  function buildArtifactIdentity(result, commit) {
4039
4881
  const revisions = /* @__PURE__ */ new Map();
@@ -4061,13 +4903,13 @@ function buildArtifactIdentity(result, commit) {
4061
4903
  }
4062
4904
  /** The MD identity section lines (§9 R9). */
4063
4905
  function identityLinesMd(identity) {
4064
- const revs = identity.detectorRevisions.map((r) => `\`${r.ruleId}@${r.detectorRevision}\``).join(" · ");
4906
+ const revs = identity.detectorRevisions.map((r) => `\`${sanitizeForMarkdown(r.ruleId)}@${r.detectorRevision}\``).join(" · ");
4065
4907
  const levels = Object.entries(identity.evidenceInventory.byEvidenceLevel).map(([level, n]) => `${level}:${n}`).join(" · ");
4066
4908
  return [
4067
4909
  `## Artifact integrity`,
4068
4910
  "",
4069
- `- **scanId**: ${identity.scanId ?? "unbound (pre-R9 producer — regenerate)"}`,
4070
- `- **Commit**: ${identity.commit ?? "unknown (not bound)"}`,
4911
+ `- **scanId**: ${identity.scanId ? sanitizeForMarkdown(identity.scanId) : "unbound (pre-R9 producer — regenerate)"}`,
4912
+ `- **Commit**: ${identity.commit ? sanitizeForMarkdown(identity.commit) : "unknown (not bound)"}`,
4071
4913
  `- **Rule(rev) inventory**: ${revs === "" ? "none fired" : revs}`,
4072
4914
  `- **Evidence inventory**: ${identity.evidenceInventory.totalFindings} finding(s), ${identity.evidenceInventory.corroborated} runtime-corroborated, by level ${levels === "" ? "none" : levels}.`,
4073
4915
  ""
@@ -4079,7 +4921,7 @@ function renderTrustReportMarkdown(result, label, commit) {
4079
4921
  const lines = [];
4080
4922
  lines.push(`<!-- mjolnir-trust-report:v1 -->`);
4081
4923
  lines.push("");
4082
- lines.push(`# Mjölnir Trust Report — ${label}`);
4924
+ lines.push(`# Mjölnir Trust Report — ${publicText(label)}`);
4083
4925
  lines.push("");
4084
4926
  lines.push(`> Tests tell you what passed. Mjölnir tells you what you can trust.`);
4085
4927
  lines.push("");
@@ -4097,10 +4939,10 @@ function renderTrustReportMarkdown(result, label, commit) {
4097
4939
  lines.push(`| Inconclusive | ${pct$1(s.inconclusiveRate)} |`);
4098
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"} |`);
4099
4941
  lines.push(`| Score | ${result.score ?? "unknown"} |`);
4100
- lines.push(`| Tests analyzed | ${result.testDeclarationCount ?? 0} in ${result.testFileCount ?? 0} files |`);
4942
+ lines.push(`| Tests analyzed | ${testsAnalyzedCell(result.testDeclarationCount, result.testFileCount)} |`);
4101
4943
  lines.push("");
4102
4944
  if (s.ceilingReasons.length > 0) {
4103
- lines.push(`Incompleteness factors: ${s.ceilingReasons.join(", ")}.`);
4945
+ lines.push(`Incompleteness factors: ${s.ceilingReasons.map(publicText).join(", ")}.`);
4104
4946
  lines.push("");
4105
4947
  }
4106
4948
  lines.push(`## Top trust risks`);
@@ -4112,7 +4954,7 @@ function renderTrustReportMarkdown(result, label, commit) {
4112
4954
  lines.push(`| ---- | -------- | -------- | ------- |`);
4113
4955
  for (const f of risks) {
4114
4956
  const ev = evidenceTag(f);
4115
- lines.push(`| ${f.ruleId} | ${f.file}:${f.line} | ${ev} | ${f.message.replaceAll("|", "\\|")} |`);
4957
+ lines.push(`| ${publicText(f.ruleId)} | ${publicText(f.file)}:${f.line} | ${ev} | ${publicText(f.message)} |`);
4116
4958
  }
4117
4959
  }
4118
4960
  lines.push("");
@@ -4126,7 +4968,7 @@ function renderTrustReportMarkdown(result, label, commit) {
4126
4968
  lines.push("");
4127
4969
  lines.push(`## Next action`);
4128
4970
  lines.push("");
4129
- lines.push(nextAction(result));
4971
+ lines.push(publicText(nextAction(result)));
4130
4972
  lines.push("");
4131
4973
  lines.push(...identityLinesMd(buildArtifactIdentity(result, commit)));
4132
4974
  lines.push(`---`);
@@ -4138,6 +4980,7 @@ function renderTrustReportMarkdown(result, label, commit) {
4138
4980
  /** Deterministic JSON artifact (agent-consumable twin of the MD). */
4139
4981
  function renderTrustReportJson(result, commit) {
4140
4982
  const s = fallbackSummary(result);
4983
+ const completionVerdict = result.partial || result.analysisStatus.discovery !== "complete" || result.analysisStatus.rules !== "complete" || (result.analysisStatus.rulesCrashed ?? 0) > 0 ? "INCOMPLETE" : result.score === null ? "UNKNOWN" : "COMPLETE";
4141
4984
  return JSON.stringify({
4142
4985
  schemaVersion: 1,
4143
4986
  artifact: "mjolnir-trust-report",
@@ -4154,9 +4997,13 @@ function renderTrustReportJson(result, commit) {
4154
4997
  ceilingReasons: s.ceilingReasons
4155
4998
  },
4156
4999
  score: result.score,
5000
+ partial: result.partial,
5001
+ analysisStatus: result.analysisStatus,
5002
+ scopeIntegrity: result.scopeIntegrity ?? null,
5003
+ verdict: completionVerdict,
4157
5004
  tests: {
4158
- files: result.testFileCount ?? 0,
4159
- declarations: result.testDeclarationCount ?? 0
5005
+ files: countOrNull(result.testFileCount),
5006
+ declarations: countOrNull(result.testDeclarationCount)
4160
5007
  },
4161
5008
  findings: {
4162
5009
  total: result.findings.length,
@@ -4166,14 +5013,14 @@ function renderTrustReportJson(result, commit) {
4166
5013
  advisory: result.findings.filter((f) => isAdvisoryFinding(f)).length
4167
5014
  },
4168
5015
  topTrustRisks: topTrustRisks(result.findings, 10).map((f) => ({
4169
- ruleId: f.ruleId,
4170
- file: f.file,
5016
+ ruleId: publicJsonText(f.ruleId),
5017
+ file: publicJsonText(f.file),
4171
5018
  line: f.line,
4172
5019
  severity: f.severity,
4173
- evidence: f.runtimeCorroboration === void 0 ? f.evidenceLevel ?? "E2" : f.runtimeCorroboration.level,
4174
- message: f.message
5020
+ evidence: f.runtimeCorroboration === void 0 ? evidenceLevelOf(f) : f.runtimeCorroboration.level,
5021
+ message: publicJsonText(f.message)
4175
5022
  })),
4176
- nextAction: nextAction(result)
5023
+ nextAction: publicJsonText(nextAction(result))
4177
5024
  }, null, 2) + "\n";
4178
5025
  }
4179
5026
  /**
@@ -4191,20 +5038,18 @@ function renderTrustReportHtml(result, label, commit) {
4191
5038
  const warnings = result.findings.filter((f) => f.severity === "warning").length;
4192
5039
  const infos = result.findings.filter((f) => f.severity === "info").length;
4193
5040
  const advisory = result.findings.filter((f) => isAdvisoryFinding(f)).length;
4194
- const confidenceRows = [];
4195
5041
  const measuredFpCell = s.measuredFpOfFiredRules !== void 0 ? pct$1(s.measuredFpOfFiredRules) : s.provisionalRuleIds.length > 0 ? `PROVISIONAL (${s.provisionalRuleIds.length} unmeasured)` : "n/a";
4196
- confidenceRows.push(`Confidence|${pct$1(s.confidence)}${s.confidenceCeiling !== void 0 ? ` (ceiling ${pct$1(s.confidenceCeiling)})` : ""}`);
4197
- confidenceRows.push(`Evidence coverage|${pct$1(s.evidenceCoverage)}`);
4198
- confidenceRows.push(`Inconclusive|${pct$1(s.inconclusiveRate)}`);
4199
- confidenceRows.push(`Measured FP (fired)|${measuredFpCell}`);
4200
- confidenceRows.push(`Score|${result.score ?? "unknown"}`);
4201
- confidenceRows.push(`Tests analyzed|${result.testDeclarationCount ?? 0} in ${result.testFileCount ?? 0} files`);
4202
- const confidenceRowsHtml = confidenceRows.map((row) => {
4203
- const [k, v] = row.split("|");
4204
- return `<tr><td>${esc(k ?? "")}</td><td>${esc(v ?? "")}</td></tr>`;
4205
- }).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 ");
4206
5050
  const risksTable = risks.length === 0 ? `<p>None — no non-advisory findings fired.</p>` : `<table>
4207
- <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>
4208
5053
  <tbody>
4209
5054
  ${risks.map((f) => {
4210
5055
  const ev = evidenceTag(f);
@@ -4243,6 +5088,8 @@ function renderTrustReportHtml(result, label, commit) {
4243
5088
  `<section id="confidence">`,
4244
5089
  `<h2>Confidence</h2>`,
4245
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>`,
4246
5093
  ` <tbody>`,
4247
5094
  ` ${confidenceRowsHtml}`,
4248
5095
  ` </tbody>`,
@@ -4290,38 +5137,35 @@ async function runTrustReportCommand(argv, io) {
4290
5137
  io.err("error: --commit requires the run's HEAD sha");
4291
5138
  return 10;
4292
5139
  }
4293
- let raw;
4294
- try {
4295
- raw = readFileSync(resolve(fromPath), "utf8");
4296
- } catch (err) {
4297
- io.err(`error: cannot read ${fromPath}: ${errorMessage(err)}`);
4298
- return 10;
4299
- }
4300
- let parsed;
5140
+ let loaded;
4301
5141
  try {
4302
- parsed = JSON.parse(raw);
5142
+ loaded = loadSavedReportStrict(resolve(fromPath));
4303
5143
  } catch (err) {
4304
5144
  io.err(`error: cannot read ${fromPath}: ${errorMessage(err)}`);
4305
5145
  return 10;
4306
5146
  }
4307
- if (typeof parsed !== "object" || parsed === null || parsed.schemaVersion !== 1) {
4308
- io.err(`error: ${fromPath} is not a canonical mjolnir scan result (schemaVersion 1)`);
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.`);
5149
+ if (commitArg && scan.runIdentity?.commit && scan.runIdentity.commit !== commitArg) {
5150
+ io.err(`error: report commit ${scan.runIdentity.commit} does not match --commit ${commitArg}`);
4309
5151
  return 10;
4310
5152
  }
4311
- const { contract: _contract, ...rest } = parsed;
4312
- const scan = rest;
5153
+ if (commitArg && scan.runIdentity) scan.runIdentity = {
5154
+ ...scan.runIdentity,
5155
+ commit: commitArg
5156
+ };
4313
5157
  const md = renderTrustReportMarkdown(scan, fromPath, commitArg ?? null);
4314
5158
  if (argv.includes("--stdout")) {
4315
5159
  io.out(md);
4316
- return 0;
5160
+ return loaded.trust.state === "VERIFIED" ? 0 : 2;
4317
5161
  }
4318
5162
  try {
4319
5163
  const outPath = resolve(dirname(fromPath), TRUST_REPORT_MD);
4320
5164
  const html = renderTrustReportHtml(scan, fromPath, commitArg ?? null);
4321
5165
  const json = renderTrustReportJson(scan, commitArg ?? null);
4322
- writeFileSync(outPath, md);
4323
- writeFileSync(resolve(dirname(fromPath), TRUST_REPORT_HTML), html);
4324
- writeFileSync(resolve(dirname(fromPath), TRUST_REPORT_JSON), json);
5166
+ writeArtifact(outPath, md);
5167
+ writeArtifact(resolve(dirname(fromPath), TRUST_REPORT_HTML), html);
5168
+ writeArtifact(resolve(dirname(fromPath), TRUST_REPORT_JSON), json);
4325
5169
  io.out(`trust report written: ${outPath} (plus ${TRUST_REPORT_HTML}, ${TRUST_REPORT_JSON} — same identity binding)`);
4326
5170
  return 0;
4327
5171
  } catch (err) {
@@ -4340,17 +5184,21 @@ async function runTrustReportCommand(argv, io) {
4340
5184
  target,
4341
5185
  json: true,
4342
5186
  verbose: false,
4343
- maxDurationMs: Number.POSITIVE_INFINITY,
5187
+ maxDurationMs: 6e5,
4344
5188
  scopeChanged: false,
4345
5189
  format: "json"
4346
5190
  });
4347
5191
  const commit = currentCommit(target, { nullable: true });
5192
+ if (commit && result.runIdentity) result.runIdentity = {
5193
+ ...result.runIdentity,
5194
+ commit
5195
+ };
4348
5196
  const md = renderTrustReportMarkdown(result, targetArg, commit);
4349
5197
  const json = renderTrustReportJson(result, commit);
4350
5198
  const html = renderTrustReportHtml(result, targetArg, commit);
4351
- writeFileSync(join(target, TRUST_REPORT_MD), md);
4352
- writeFileSync(join(target, TRUST_REPORT_JSON), json);
4353
- writeFileSync(join(target, TRUST_REPORT_HTML), html);
5199
+ writeArtifact(join(target, TRUST_REPORT_MD), md);
5200
+ writeArtifact(join(target, TRUST_REPORT_JSON), json);
5201
+ writeArtifact(join(target, TRUST_REPORT_HTML), html);
4354
5202
  io.out(`trust report written: ${TRUST_REPORT_MD}, ${TRUST_REPORT_JSON}, ${TRUST_REPORT_HTML} (deterministic — same scan, same bytes)`);
4355
5203
  return 0;
4356
5204
  } catch (err) {
@@ -4391,10 +5239,10 @@ function findingLineMd(f) {
4391
5239
  return `${icon} **${escMd(f.ruleId)}** \`${safePath}:${f.line}\` — ${escMd(f.message)} ${escMd(`[${evidenceTagLine(f)}]`)}\n Fix: ${fixBody}`;
4392
5240
  }
4393
5241
  /** Score cell with color-coded emoji and optional delta. */
4394
- function renderScoreBadge(score, delta) {
5242
+ function renderScoreBadge(score, delta, verdictOverride) {
4395
5243
  if (score === null) return "### Score\n*n/a*";
4396
5244
  deriveScoreState(score);
4397
- let text = `**${score}**/100 ${verdictFor(score)}`;
5245
+ let text = `**${score}**/100 ${verdictOverride ?? verdictFor(score)}`;
4398
5246
  if (delta !== void 0 && delta !== 0) text += ` (${delta > 0 ? "+" : ""}${delta})`;
4399
5247
  return `### Score\n${text}`;
4400
5248
  }
@@ -4422,7 +5270,7 @@ function renderConfidenceTable(result, summary) {
4422
5270
  lines.push(`| Evidence coverage | ${pct$1(summary.evidenceCoverage)} |`);
4423
5271
  lines.push(`| Inconclusive | ${pct$1(summary.inconclusiveRate)} |`);
4424
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"} |`);
4425
- lines.push(`| Tests analyzed | ${result.testDeclarationCount ?? 0} in ${result.testFileCount ?? 0} files |`);
5273
+ lines.push(`| Tests analyzed | ${testsAnalyzedCell(result.testDeclarationCount, result.testFileCount)} |`);
4426
5274
  lines.push("");
4427
5275
  lines.push("</details>");
4428
5276
  return lines;
@@ -4462,6 +5310,10 @@ function renderArtifactIntegrity(identity) {
4462
5310
  lines.push("</details>");
4463
5311
  return lines;
4464
5312
  }
5313
+ function isIncompleteResult(result) {
5314
+ const status = result.analysisStatus;
5315
+ return result.partial || status === void 0 || status.discovery !== "complete" || status.rules !== "complete" || (status.rulesCrashed ?? 0) > 0 || result.scopeIntegrity?.scopeVerdict === "PARTIAL";
5316
+ }
4465
5317
  /**
4466
5318
  * Render the unified "Mjölnir Verification Report" PR comment.
4467
5319
  *
@@ -4479,7 +5331,8 @@ function renderUnifiedReport(result, options = {}) {
4479
5331
  provisionalRuleIds: [],
4480
5332
  ceilingReasons: []
4481
5333
  };
4482
- lines.push("## <img width=\"16\" src=\"https://raw.githubusercontent.com/Sergey-Bar/Mjolnir/main/assets/mjolnir-icon.svg\"/> Mjölnir Verification Report");
5334
+ const incomplete = isIncompleteResult(result) || !result.trustSummary || summary.level === "L0" && summary.evidenceCoverage === 0;
5335
+ lines.push("## <img width=\"16\" alt=\"Mjölnir\" src=\"https://raw.githubusercontent.com/Sergey-Bar/Mjolnir/main/assets/mjolnir-icon.svg\"/> Mjölnir Verification Report");
4483
5336
  lines.push("");
4484
5337
  lines.push("> Tests tell you what passed. Mjölnir tells you what you can trust.");
4485
5338
  lines.push("");
@@ -4487,6 +5340,11 @@ function renderUnifiedReport(result, options = {}) {
4487
5340
  lines.push("_Scope: only the lines this PR changed._");
4488
5341
  lines.push("");
4489
5342
  }
5343
+ if (incomplete) {
5344
+ const reasons = result.analysisStatus?.reasons ?? [];
5345
+ lines.push(`> **Analysis status: INCOMPLETE.** ${reasons.length > 0 ? reasons.map((reason) => escMd(reason)).join(", ") : "The scan did not complete a trustworthy analysis."}`);
5346
+ lines.push("");
5347
+ }
4490
5348
  const diff = options.diff;
4491
5349
  const usingDiff = diff?.hasBaseline === true;
4492
5350
  const findings = usingDiff ? diff.newFindings : result.findings;
@@ -4494,7 +5352,7 @@ function renderUnifiedReport(result, options = {}) {
4494
5352
  const warningCount = findings.filter((f) => f.severity === "warning").length;
4495
5353
  const baseScore = diff?.baselineScore;
4496
5354
  const delta = baseScore !== void 0 && result.score !== null ? result.score - baseScore : void 0;
4497
- lines.push("<table>");
5355
+ lines.push("<table role=\"presentation\">");
4498
5356
  lines.push("<tr>");
4499
5357
  lines.push("<td width=\"130\">");
4500
5358
  lines.push("");
@@ -4503,7 +5361,7 @@ function renderUnifiedReport(result, options = {}) {
4503
5361
  lines.push("</td>");
4504
5362
  lines.push("<td width=\"130\">");
4505
5363
  lines.push("");
4506
- lines.push(renderScoreBadge(result.score, delta));
5364
+ lines.push(renderScoreBadge(result.score, delta, incomplete ? "INCOMPLETE" : void 0));
4507
5365
  lines.push("");
4508
5366
  lines.push("</td>");
4509
5367
  lines.push("<td width=\"130\">");
@@ -4521,7 +5379,7 @@ function renderUnifiedReport(result, options = {}) {
4521
5379
  lines.push("");
4522
5380
  const state = deriveScoreState(result.score);
4523
5381
  const headlineFindings = usingDiff ? findings.length : result.findings.length;
4524
- lines.push(`**Headline:** ${headlineFor(state, headlineFindings)}`);
5382
+ lines.push(incomplete ? "**Headline:** Analysis is incomplete; this report is not a clean or worthy verdict." : `**Headline:** ${headlineFor(state, headlineFindings)}`);
4525
5383
  lines.push("");
4526
5384
  lines.push("---");
4527
5385
  lines.push("");
@@ -4534,7 +5392,7 @@ function renderUnifiedReport(result, options = {}) {
4534
5392
  lines.push("");
4535
5393
  }
4536
5394
  if (findings.length === 0) {
4537
- lines.push("✅ No new findings in this PR's changes.");
5395
+ lines.push(incomplete ? "⚠️ No findings were observed, but the analysis is incomplete; this is not proof of cleanliness." : usingDiff ? "✅ No new findings in this PR's changes." : "✅ No new findings in the analyzed surface.");
4538
5396
  lines.push("");
4539
5397
  } else {
4540
5398
  const errors = findings.filter((f) => f.severity === "error");
@@ -4599,7 +5457,7 @@ function renderUnifiedReport(result, options = {}) {
4599
5457
  lines.push("```");
4600
5458
  lines.push("");
4601
5459
  const repoLink = options.repoUrl ? ` — ${options.repoUrl}` : "";
4602
- lines.push(`<sub>Posted by GitHub Actions${repoLink} — blocks merging when the CI gate reports findings at the configured severity.</sub>`);
5460
+ lines.push(incomplete ? `<sub>Posted by GitHub Actions${repoLink} — analysis incomplete; no release verdict is claimed.</sub>` : `<sub>Posted by GitHub Actions${repoLink} — blocks merging when the CI gate reports findings at the configured severity.</sub>`);
4603
5461
  return lines.join("\n");
4604
5462
  }
4605
5463
  //#endregion
@@ -4626,57 +5484,6 @@ function renderPrComment(result, options = {}) {
4626
5484
  });
4627
5485
  }
4628
5486
  //#endregion
4629
- //#region src/commands/report-io.ts
4630
- /**
4631
- * Shared saved-report loading (agent-handoff plan §9.0).
4632
- *
4633
- * `summary`, `handoff` and `why` all consume a saved `--json` report.
4634
- * One loader, one validation, one error shape — previously
4635
- * validateReportJson was private to summary.ts; extracting it keeps
4636
- * the three commands byte-identical in their loading behavior without
4637
- * duplication. The exit-code mapping stays in the callers (they own
4638
- * their io), but the error MESSAGES are identical because they come
4639
- * from here.
4640
- */
4641
- /** Human message for any thrown value — never "undefined"/"[object Object]". */
4642
- function errorText$1(err) {
4643
- if (err instanceof Error) return err.message;
4644
- if (typeof err === "string") return err;
4645
- if (typeof err === "object" && err !== null) return JSON.stringify(err);
4646
- return String(err);
4647
- }
4648
- /**
4649
- * Parse-and-validate a saved report. Throws Error with a
4650
- * human-explanatory message on: invalid JSON, non-object document,
4651
- * wrong schemaVersion, missing findings array, missing frameworks
4652
- * array (certification F1: a schema-1 report is MORE than
4653
- * `{schemaVersion, findings}` — summary/handoff/why read
4654
- * `result.frameworks`, so a schema-incomplete stub must be rejected
4655
- * HERE, by the one shared loader, not crash downstream).
4656
- */
4657
- function validateReportJson(text) {
4658
- let parsed;
4659
- try {
4660
- parsed = JSON.parse(text);
4661
- } catch (err) {
4662
- throw new Error(`not valid JSON (${errorText$1(err)})`, { cause: err });
4663
- }
4664
- if (typeof parsed !== "object" || parsed === null) throw new Error("the file is a JSON value but not an object");
4665
- const doc = parsed;
4666
- if (doc.schemaVersion !== 1) throw new Error(`unsupported schemaVersion ${JSON.stringify(doc.schemaVersion)} — expected 1`);
4667
- if (!Array.isArray(doc.findings)) throw new Error("missing a \"findings\" array — is this a Mjölnir --json report?");
4668
- if (!Array.isArray(doc.frameworks)) throw new Error("missing a \"frameworks\" array — is this a complete Mjölnir --json report?");
4669
- return parsed;
4670
- }
4671
- /** Load + validate a saved report from disk. Throws on any problem. */
4672
- function loadSavedReport(reportPath) {
4673
- return validateReportJson(readFileSync(reportPath, "utf8"));
4674
- }
4675
- /** True when the report file exists (callers own the not-found message). */
4676
- function reportExists(reportPath) {
4677
- return existsSync(reportPath);
4678
- }
4679
- //#endregion
4680
5487
  //#region src/commands/why.ts
4681
5488
  /**
4682
5489
  * `mjolnir why <file>:<line>` — occurrence-level evidence/explanation
@@ -4739,7 +5546,7 @@ function evidenceLines(f, ui) {
4739
5546
  }
4740
5547
  return lines;
4741
5548
  }
4742
- 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.";
4743
5550
  /** Render the why answer. Pure over (match, ui). */
4744
5551
  function renderWhy(match, ui = plainContext()) {
4745
5552
  const { p } = ui;
@@ -5108,22 +5915,11 @@ function buildBadge(result, commit) {
5108
5915
  * (the falsifiable claim: "verified at commit X on date Y").
5109
5916
  */
5110
5917
  function renderBadgeSnippet(result, repoUrl = "https://github.com/Sergey-Bar/Mjolnir") {
5111
- let commit = "unknown";
5112
- try {
5113
- commit = execFileSync("git", [
5114
- "rev-parse",
5115
- "--short",
5116
- "HEAD"
5117
- ], {
5118
- cwd: process.cwd(),
5119
- encoding: "utf8",
5120
- stdio: [
5121
- "ignore",
5122
- "pipe",
5123
- "ignore"
5124
- ]
5125
- }).trim();
5126
- } catch {}
5918
+ const commit = runGit(process.cwd(), [
5919
+ "rev-parse",
5920
+ "--short",
5921
+ "HEAD"
5922
+ ])?.trim() ?? "unknown";
5127
5923
  const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
5128
5924
  const errors = result.findings.filter((f) => f.severity === "error").length;
5129
5925
  return [
@@ -5378,7 +6174,7 @@ function createRuleScaffold(input, rootDir) {
5378
6174
  };
5379
6175
  mkdirSync(join(rootDir, "src", "rules", parsed.dir), { recursive: true });
5380
6176
  const files = [];
5381
- writeFileAtomic(absRule, `/**
6177
+ const ruleSrc = `/**
5382
6178
  * ${input.id} — ${input.title}.
5383
6179
  *
5384
6180
  * TODO(implement): replace the placeholder below. The rule currently
@@ -5404,7 +6200,8 @@ export const ${camel(parsed.lower)} = defineRule({
5404
6200
  return findings;
5405
6201
  },
5406
6202
  });
5407
- `);
6203
+ `;
6204
+ writeFileAtomic(absRule, ruleSrc);
5408
6205
  files.push(ruleRel);
5409
6206
  for (const direction of ["must-fire", "must-not-fire"]) {
5410
6207
  const dirRel = join("tests", "fixtures", input.id, direction);
@@ -5505,14 +6302,19 @@ function buildHandover(scan, forensics) {
5505
6302
  heading: "⚠ CI trust warnings (green ≠ verified)",
5506
6303
  items: ciTrust.slice(0, 4).map((f) => `${f.file}:${f.line} — ${f.message}`)
5507
6304
  });
5508
- 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({
5509
6306
  heading: "🟢 Solid foundation",
5510
6307
  items: ["No tracked anti-patterns found in scanned specs — good place to start contributing."]
5511
6308
  });
5512
- 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
+ });
5513
6315
  return {
5514
6316
  sections,
5515
- 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."
5516
6318
  };
5517
6319
  }
5518
6320
  function renderHandover(map) {
@@ -5550,13 +6352,15 @@ function renderHandover(map) {
5550
6352
  * tests/privacy-network-isolation.spec.ts, which scans this file too).
5551
6353
  */
5552
6354
  const ui$13 = plainContext();
5553
- /** The S1-resolved absolute git binary, or the bare name to fail on. */
6355
+ /** The S1-resolved absolute git binary, or null when none is available. */
5554
6356
  function gitExe() {
5555
- return resolveGitPath() ?? "git";
6357
+ return resolveGitPath();
5556
6358
  }
5557
6359
  function git(root, args) {
6360
+ const executable = gitExe();
6361
+ if (!executable) return null;
5558
6362
  try {
5559
- return execFileSync(gitExe(), [
6363
+ return execFileSync(executable, [
5560
6364
  "-C",
5561
6365
  root,
5562
6366
  ...args
@@ -5575,8 +6379,10 @@ function git(root, args) {
5575
6379
  }
5576
6380
  /** Like git(), but returns the raw bytes — no utf8 decode round-trip. */
5577
6381
  function gitBuffer(root, args) {
6382
+ const executable = gitExe();
6383
+ if (!executable) return null;
5578
6384
  try {
5579
- return execFileSync(gitExe(), [
6385
+ return execFileSync(executable, [
5580
6386
  "-C",
5581
6387
  root,
5582
6388
  ...args
@@ -5610,7 +6416,16 @@ async function computeImpact(root, options) {
5610
6416
  "HEAD",
5611
6417
  options.baseBranch ?? "main"
5612
6418
  ])?.trim() ?? void 0;
5613
- else baseRef = git(root, ["rev-parse", baseRef])?.trim() ?? baseRef;
6419
+ else {
6420
+ baseRef = git(root, ["rev-parse", baseRef])?.trim();
6421
+ if (!baseRef) return {
6422
+ hasComparison: false,
6423
+ unknownReason: "base-ref-invalid",
6424
+ resolved: [],
6425
+ introduced: [],
6426
+ unknownFacts
6427
+ };
6428
+ }
5614
6429
  if (!baseRef) return {
5615
6430
  hasComparison: false,
5616
6431
  unknownReason: "no-prior-commit",
@@ -5618,7 +6433,15 @@ async function computeImpact(root, options) {
5618
6433
  introduced: [],
5619
6434
  unknownFacts
5620
6435
  };
5621
- const headRef = git(root, ["rev-parse", "HEAD"])?.trim() ?? "HEAD";
6436
+ const headRef = git(root, ["rev-parse", "HEAD"])?.trim();
6437
+ if (!headRef) return {
6438
+ hasComparison: false,
6439
+ unknownReason: "head-ref-invalid",
6440
+ baseRef,
6441
+ resolved: [],
6442
+ introduced: [],
6443
+ unknownFacts
6444
+ };
5622
6445
  if (baseRef === headRef) return {
5623
6446
  hasComparison: false,
5624
6447
  unknownReason: options.since ? "base-equals-head" : "no-prior-commit",
@@ -5669,7 +6492,7 @@ async function computeImpact(root, options) {
5669
6492
  if (blob === null) continue;
5670
6493
  const dest = join(tmpDir, relPath);
5671
6494
  mkdirSync(dirname(dest), { recursive: true });
5672
- writeFileSync(dest, blob);
6495
+ writeFileAtomic(dest, blob);
5673
6496
  }
5674
6497
  if (truncated) {
5675
6498
  baseTreeTruncated = {
@@ -5979,7 +6802,7 @@ function tryReadPackageJson(rootDir) {
5979
6802
  const p = join(rootDir, "package.json");
5980
6803
  if (!existsSync(p)) return null;
5981
6804
  try {
5982
- return parseJsonFile(readFileSync(p, "utf8"), p, isRecord$2);
6805
+ return parseJsonFile(readFileSync(p, "utf8"), p, isRecord$3);
5983
6806
  } catch {
5984
6807
  return null;
5985
6808
  }
@@ -6843,7 +7666,8 @@ async function runScanCommand(argv, io = {
6843
7666
  onProgress: (e) => progress.onEvent(e),
6844
7667
  onGateNotice: (notice) => io.err(notice),
6845
7668
  ...args.debug ? { onRuleCrash: (ruleId, file, error) => {
6846
- 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}`);
6847
7671
  } } : {}
6848
7672
  });
6849
7673
  progress.done();
@@ -6856,7 +7680,12 @@ async function runScanCommand(argv, io = {
6856
7680
  if (args.json) io.err("--score overrides --json; stdout is the bare score.");
6857
7681
  const { config: scoreConfig } = loadConfig(target, { knownRuleIds: KNOWN_RULE_IDS$1 });
6858
7682
  io.out(result.score === null ? "unknown" : String(result.score));
6859
- 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
+ });
6860
7689
  }
6861
7690
  renderScanOutput(result, args, target, io);
6862
7691
  if (args.format === "terminal") {
@@ -6873,7 +7702,12 @@ async function runScanCommand(argv, io = {
6873
7702
  if (result.partial) return 2;
6874
7703
  const { config, warnings } = loadConfig(target, { knownRuleIds: KNOWN_RULE_IDS$1 });
6875
7704
  for (const w of warnings) io.err(w);
6876
- 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
+ });
6877
7711
  } catch (err) {
6878
7712
  if (err instanceof ConfigValidationError) {
6879
7713
  io.err(err.message);
@@ -7217,6 +8051,33 @@ async function runPrCommentCommand(argv, io = {
7217
8051
  out,
7218
8052
  err
7219
8053
  }) {
8054
+ const fromIdx = argv.indexOf("--from");
8055
+ if (fromIdx !== -1) {
8056
+ const fromPath = argv[fromIdx + 1];
8057
+ if (!fromPath || fromPath.startsWith("-")) {
8058
+ io.err("error: --from requires a saved report path");
8059
+ return 10;
8060
+ }
8061
+ try {
8062
+ const saved = loadSavedReport(resolve(fromPath));
8063
+ const option = (flag) => {
8064
+ const index = argv.indexOf(flag);
8065
+ return index >= 0 ? argv[index + 1] : void 0;
8066
+ };
8067
+ const version = option("--version");
8068
+ const commit = option("--commit");
8069
+ const repoUrl = option("--repo-url");
8070
+ io.out(renderPrComment(saved, {
8071
+ ...version ? { version } : {},
8072
+ ...commit ? { commit } : {},
8073
+ ...repoUrl ? { repoUrl } : {}
8074
+ }));
8075
+ return 0;
8076
+ } catch (err) {
8077
+ io.err(`error: cannot read ${fromPath}: ${errorText$1(err)}`);
8078
+ return 10;
8079
+ }
8080
+ }
7220
8081
  const args = parseArgsOrUsage(argv, io);
7221
8082
  if (!args) return 10;
7222
8083
  try {
@@ -7376,7 +8237,12 @@ async function runRulesCommand(argv, io = {
7376
8237
  return 0;
7377
8238
  }
7378
8239
  const root = process$1.cwd();
7379
- const external = withExternal ? await loadLocalRules(root) : void 0;
8240
+ const gateOpen = pluginsGateOpen(argv.includes("--enable-plugins"));
8241
+ const external = withExternal ? await loadLocalRules(root, gateOpen) : void 0;
8242
+ if (external?.skipped.length) io.err(renderGateNotice(external.skipped.map((name) => ({
8243
+ kind: "js-module",
8244
+ name
8245
+ }))));
7380
8246
  let catalog = [...buildCatalog(), ...external ? buildCatalog(external.rules, { provenance: "external" }) : []];
7381
8247
  for (const w of external?.errors ?? []) io.err(`mjolnir: ${w}`);
7382
8248
  if (argv.includes("--unmeasured")) catalog = catalog.filter((e) => e.measuredFpRate === void 0);
@@ -7701,7 +8567,7 @@ function computeDetectorHashes(rules, rulesDir, retiredIds = []) {
7701
8567
  return manifest;
7702
8568
  }
7703
8569
  function loadManifest(path) {
7704
- return parseJsonFile(readFileSync(path, "utf8"), path, (v) => isRecord$2(v));
8570
+ return parseJsonFile(readFileSync(path, "utf8"), path, (v) => isRecord$3(v));
7705
8571
  }
7706
8572
  //#endregion
7707
8573
  //#region src/commands/doctor.ts
@@ -7878,7 +8744,7 @@ function checkTierEnforcement(verdictsDir, rules = RULES) {
7878
8744
  const lines = readFileSync(join(verdictsDir, f), "utf8").split("\n").filter((l) => l.trim().length > 0);
7879
8745
  for (const line of lines) try {
7880
8746
  const parsed = JSON.parse(line);
7881
- if (!isRecord$2(parsed)) continue;
8747
+ if (!isRecord$3(parsed)) continue;
7882
8748
  const entry = parsed;
7883
8749
  if (entry.verdict === "TP" || entry.verdict === "FP") {
7884
8750
  if (typeof entry.ruleId === "string") live.set(entry.ruleId, (live.get(entry.ruleId) ?? 0) + 1);
@@ -7986,7 +8852,7 @@ function checkFixtureIntegrity(fixturesRoot, rules = RULES) {
7986
8852
  const allowlistPath = join(fixturesRoot, "typecheck-allowlist.json");
7987
8853
  if (existsSync(allowlistPath)) try {
7988
8854
  const raw = JSON.parse(readFileSync(allowlistPath, "utf8"));
7989
- if (!isRecord$2(raw)) {
8855
+ if (!isRecord$3(raw)) {
7990
8856
  ok = false;
7991
8857
  details.push("typecheck-allowlist.json is not an object");
7992
8858
  } else if (Array.isArray(raw.entries)) details.push(`Layer A typecheck allowlist: ${raw.entries.length} justified entr(ies)`);
@@ -8100,7 +8966,7 @@ function checkMeasurementConsistency(verdictsDir, sidecarPath, rules = RULES, me
8100
8966
  let row;
8101
8967
  try {
8102
8968
  const parsed = JSON.parse(trimmed);
8103
- if (!isRecord$2(parsed)) {
8969
+ if (!isRecord$3(parsed)) {
8104
8970
  failures.push(`${f}: unparseable verdict row (corpus integrity)`);
8105
8971
  continue;
8106
8972
  }
@@ -8117,7 +8983,7 @@ function checkMeasurementConsistency(verdictsDir, sidecarPath, rules = RULES, me
8117
8983
  let sidecar = {};
8118
8984
  if (existsSync(sidecarPath)) try {
8119
8985
  const parsed = JSON.parse(readFileSync(sidecarPath, "utf8"));
8120
- if (!isRecord$2(parsed)) failures.push("sidecar is not a valid object — regenerate the measurement artifacts");
8986
+ if (!isRecord$3(parsed)) failures.push("sidecar is not a valid object — regenerate the measurement artifacts");
8121
8987
  else sidecar = parsed;
8122
8988
  } catch (e) {
8123
8989
  failures.push(`sidecar unreadable/malformed: ${errorText(e)} — regenerate the measurement artifacts`);
@@ -8365,9 +9231,14 @@ function truncateMessage(message, max = 250) {
8365
9231
  * `--path-prefix <dir>` re-scopes for subdirectory scans.
8366
9232
  */
8367
9233
  const DETAILS_PER_SEVERITY_CAP = 25;
8368
- function scoreBar$1(score, width = 20) {
8369
- const filled = Math.round(score / 100 * width);
8370
- 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);
8371
9242
  }
8372
9243
  /** Markdown step summary. Pure over (result, options) — testable. */
8373
9244
  function renderStepSummary(result, options = {}) {
@@ -8577,9 +9448,19 @@ function fpLine(f) {
8577
9448
  if (f.measuredFpRate === void 0) return "Measured FP rate: none — this rule ships on assumption (no measured false-positive rate).";
8578
9449
  return `Measured FP rate: ${Math.round(f.measuredFpRate * 100)}%${f.measuredFpN !== void 0 ? ` over ${f.measuredFpN} classified verdicts` : ""}.`;
8579
9450
  }
8580
- function scoreBar(score, width = 20) {
8581
- const filled = Math.round(score / 100 * width);
8582
- 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);
8583
9464
  }
8584
9465
  function scopeNote(options) {
8585
9466
  const parts = [];
@@ -8715,7 +9596,7 @@ function renderHandoff(result, options = {}, version = ENGINE_VERSION) {
8715
9596
  });
8716
9597
  lines.push("## How to use this document");
8717
9598
  lines.push("");
8718
- 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).");
8719
9600
  lines.push("");
8720
9601
  lines.push(scopeNote(options) ? scopeNote(options).trim() : "");
8721
9602
  if (!scopeNote(options)) lines.pop();
@@ -8873,10 +9754,10 @@ function agentBrief(surface, cwdNote) {
8873
9754
  `Run \`${cwdNote}npx mjolnir-qa@${ENGINE_VERSION} . --scope changed\` before finishing any task that touched test code.`,
8874
9755
  "",
8875
9756
  "- Exit 0 = clean. Exit 1 = new findings on changed lines: fix them (smallest behavior-preserving change) or justify them to the user.",
8876
- `- 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).`,
8877
9758
  `- npx mjolnir-qa@${ENGINE_VERSION} handoff renders a full remediation plan from a saved --json report.`,
8878
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.`,
8879
- "- 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).",
8880
9761
  "- After fixing: re-run the scan, report files changed, report checks not run, report unresolved findings honestly.",
8881
9762
  "",
8882
9763
  "## Agent safety contract (non-negotiable)",
@@ -9023,7 +9904,7 @@ function executeInstall(entries) {
9023
9904
  if (e.action === "refuse" || e.action === "no-op") continue;
9024
9905
  const dir = join(e.file, "..");
9025
9906
  mkdirSync(dir, { recursive: true });
9026
- writeFileSync(e.file, e.content);
9907
+ writeFileAtomic(e.file, e.content);
9027
9908
  written++;
9028
9909
  }
9029
9910
  return written;
@@ -9101,19 +9982,12 @@ function hookBlock(version) {
9101
9982
  function resolveHookTarget(cwd) {
9102
9983
  const huskyDir = join(cwd, ".husky");
9103
9984
  if (existsSync(huskyDir)) return join(huskyDir, "pre-commit");
9104
- try {
9105
- const hooksPath = execFileSync("git", [
9106
- "-C",
9107
- cwd,
9108
- "config",
9109
- "core.hooksPath"
9110
- ], { stdio: [
9111
- "ignore",
9112
- "pipe",
9113
- "ignore"
9114
- ] }).toString().trim();
9115
- return join(cwd, hooksPath, "pre-commit");
9116
- } catch {}
9985
+ const hooksPath = runGit(cwd, ["config", "core.hooksPath"])?.trim();
9986
+ if (hooksPath) {
9987
+ const candidate = resolve(cwd, hooksPath);
9988
+ const relativePath = relative(cwd, candidate);
9989
+ if (relativePath === "" || !relativePath.startsWith("..") && !isAbsolute(relativePath)) return join(candidate, "pre-commit");
9990
+ }
9117
9991
  return join(cwd, ".git", "hooks", "pre-commit");
9118
9992
  }
9119
9993
  /**
@@ -9150,22 +10024,22 @@ function executeHookInstall(entry) {
9150
10024
  switch (entry.action) {
9151
10025
  case "create":
9152
10026
  mkdirSync(join(entry.file, ".."), { recursive: true });
9153
- writeFileSync(entry.file, `#!/bin/sh\n${hookBlock(ENGINE_VERSION)}\n`);
10027
+ writeFileAtomic(entry.file, `#!/bin/sh\n${hookBlock(ENGINE_VERSION)}\n`);
9154
10028
  return true;
9155
10029
  case "append": {
9156
10030
  const existing = readFileSync(entry.file, "utf8");
9157
10031
  const sep = existing.endsWith("\n") ? "" : "\n";
9158
- writeFileSync(entry.file, `${existing}${sep}\n${hookBlock(ENGINE_VERSION)}\n`);
10032
+ writeFileAtomic(entry.file, `${existing}${sep}\n${hookBlock(ENGINE_VERSION)}\n`);
9159
10033
  return true;
9160
10034
  }
9161
10035
  case "update": {
9162
10036
  const existing = readFileSync(entry.file, "utf8");
9163
10037
  const openIdx = existing.indexOf(HOOK_MARKER_OPEN);
9164
10038
  const closeIdx = existing.indexOf(HOOK_MARKER_CLOSE);
9165
- 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));
9166
10040
  else {
9167
10041
  const sep = existing.endsWith("\n") ? "" : "\n";
9168
- writeFileSync(entry.file, `${existing}${sep}\n${hookBlock(ENGINE_VERSION)}\n`);
10042
+ writeFileAtomic(entry.file, `${existing}${sep}\n${hookBlock(ENGINE_VERSION)}\n`);
9169
10043
  }
9170
10044
  return true;
9171
10045
  }
@@ -9619,7 +10493,7 @@ function checkArtifactIntegrity(root) {
9619
10493
  what: "the artifact embeds its identity binding (scanId/commit/rule(rev)/evidence inventory)"
9620
10494
  },
9621
10495
  {
9622
- ok: report.includes("TRUST_REPORT_HTML") && report.includes("renderTrustReportHtml") && /writeFileSync\(join\(target,\s*TRUST_REPORT_HTML\)/.test(report),
10496
+ ok: report.includes("TRUST_REPORT_HTML") && report.includes("renderTrustReportHtml") && /write(?:FileSync|Artifact)\(join\(target,\s*TRUST_REPORT_HTML\)/.test(report),
9623
10497
  what: "the HTML artifact ships and the command writes all three formats"
9624
10498
  },
9625
10499
  {
@@ -9642,6 +10516,15 @@ function checkArtifactIntegrity(root) {
9642
10516
  details: bad
9643
10517
  };
9644
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
+ }
9645
10528
  function checkReleaseVersionConsistency(root) {
9646
10529
  const pkgPath = join(root, "package.json");
9647
10530
  const clPath = join(root, "CHANGELOG.md");
@@ -9651,9 +10534,14 @@ function checkReleaseVersionConsistency(root) {
9651
10534
  details: ["package.json or CHANGELOG.md missing"]
9652
10535
  };
9653
10536
  const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
9654
- const cl = readFileSync(clPath, "utf8");
9655
- const head = /^##\s+\[?(\d+\.\d+\.\d+)\]?/m.exec(cl)?.[1];
9656
- 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 {
9657
10545
  evidence: "INCONCLUSIVE",
9658
10546
  determination: "INCONCLUSIVE",
9659
10547
  details: ["unparsable version surfaces"]
@@ -9873,98 +10761,90 @@ function runReleaseTrustCommand(argv, io = {
9873
10761
  //#endregion
9874
10762
  //#region src/commands/business-case.ts
9875
10763
  /**
9876
- * `mjolnir business-case` — ROI estimate per finding (extended).
9877
- *
9878
- * Projects the business impact of untrustworthy verification by
9879
- * combining:
9880
- * - Measured false-positive rate per rule (from the FP audit
9881
- * corpus)
9882
- * - Industry-specific cost of a false-green release per incident
9883
- * - Historical incident data from git log (optional)
9884
- * - Projected savings over 6/12 months (optional)
9885
- *
9886
- * Static analysis — no test execution, no telemetry, deterministic
9887
- * output.
9888
- */
9889
- const DEFAULT_INCIDENT_COST = 25e3;
9890
- /** Industry cost multipliers for false-green incidents. */
9891
- const INDUSTRY_COSTS = {
9892
- default: DEFAULT_INCIDENT_COST,
9893
- fintech: 5e4,
9894
- healthcare: 1e5,
9895
- ecommerce: 15e3,
9896
- saas: 3e4,
9897
- enterprise: 4e4,
9898
- gaming: 2e4,
9899
- education: 1e4
9900
- };
9901
- const INDUSTRY_DESCRIPTIONS = {
9902
- default: "general",
9903
- fintech: "financial services (regulatory fines, PCI/HIPAA)",
9904
- healthcare: "healthcare (HIPAA, patient safety)",
9905
- ecommerce: "e-commerce (cart abandonment, lost sales)",
9906
- saas: "SaaS (churn, reputation)",
9907
- enterprise: "enterprise (contract penalties, SLA breaches)",
9908
- gaming: "gaming (player trust, reviews)",
9909
- education: "education (student data, accreditation)"
9910
- };
9911
- /** 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
+ */
9912
10790
  function expectedSavings(fpRate, evidenceLevel, incidentCost) {
9913
10791
  if (fpRate === null || fpRate === void 0) return null;
9914
10792
  const confidence = evidenceLevel === "E2" ? 1 : evidenceLevel === "E1" ? .5 : 0;
9915
10793
  if (confidence === 0) return null;
9916
10794
  return Math.round(incidentCost * (1 - fpRate) * confidence);
9917
10795
  }
9918
- function renderSummary(savingsPerFinding, incidentCost, industry, totalSavings) {
10796
+ function renderSummary(savingsPerFinding, incidentCost, totalSavings) {
10797
+ const measured = savingsPerFinding.filter((s) => s.fpRate !== null).length;
9919
10798
  const lines = [
9920
- "Mjölnir Business Case — Estimated ROI per finding",
9921
- "=================================================",
10799
+ "Mjölnir — measured false-positive rates on the findings that fired",
10800
+ "=".repeat(58),
9922
10801
  "",
9923
- `Industry profile: ${industry} (${INDUSTRY_DESCRIPTIONS[industry] ?? "general"})`,
9924
- `Assumed cost per false-green incident: $${incidentCost}`,
9925
- `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
+ ],
9926
10814
  "",
9927
- "| Rule ID | FP Rate | Evidence | Expected Savings |",
9928
- "| ------- | ------- | -------- | --------------- |"
10815
+ "| Rule ID | FP Rate | n | Evidence | Expected cost |",
10816
+ "| ------- | ------- | - | -------- | ------------- |"
9929
10817
  ];
9930
- for (const { ruleId, fpRate, evidenceLevel, expectedSavings: savings } of savingsPerFinding) {
9931
- const fpDisplay = fpRate !== null ? `${Math.round(fpRate * 100)}%` : "unmeasured";
9932
- const savingsDisplay = savings !== null ? `$${savings}` : "n/a";
9933
- lines.push(`| ${ruleId} | ${fpDisplay} | ${evidenceLevel} | ${savingsDisplay} |`);
9934
- }
9935
- lines.push("");
9936
- lines.push("Interpretation:");
9937
- lines.push("---------------");
9938
- lines.push("- E2 findings (deterministic proof) carry full business risk — eliminate first");
9939
- lines.push("- E1 findings (pattern evidence) carry half the assessed risk");
9940
- lines.push("- Unmeasured rules (n < 10) have no quantified FP rate — add corpus classification");
9941
- 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()}`);
9942
10831
  return lines.join("\n");
9943
10832
  }
9944
- function renderProjection(monthlySavings, months, incidentCost) {
9945
- const totalProjected = monthlySavings * months;
9946
- const incidentsPrevented = Math.round(totalProjected / incidentCost);
9947
- return [
9948
- "",
9949
- "SAVINGS PROJECTION",
9950
- "==================",
9951
- "",
9952
- `Monthly savings estimate: $${monthlySavings.toLocaleString()}`,
9953
- `Projection period: ${months} months`,
9954
- `Projected total savings: $${totalProjected.toLocaleString()}`,
9955
- `Incidents prevented: ~${incidentsPrevented} (at $${incidentCost.toLocaleString()}/incident)`,
9956
- ""
9957
- ].join("\n");
9958
- }
9959
10833
  /**
9960
10834
  * Entry point for `mjolnir business-case`.
9961
10835
  *
9962
10836
  * Flags:
9963
- * --industry <type> Industry cost profile (default/fintech/healthcare/...)
9964
- * --history <months> Use git history for the last N months (estimates from
9965
- * actual scan improvements, not projections)
9966
- * --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.
9967
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.
9968
10848
  */
9969
10849
  async function runBusinessCaseCommand(argv, io = {
9970
10850
  out,
@@ -9972,25 +10852,29 @@ async function runBusinessCaseCommand(argv, io = {
9972
10852
  }) {
9973
10853
  try {
9974
10854
  const target = argv.find((a) => !a.startsWith("-")) ?? ".";
9975
- const industryIdx = argv.indexOf("--industry");
9976
- const industry = industryIdx !== -1 ? argv[industryIdx + 1] ?? "default" : "default";
9977
- const historyIdx = argv.indexOf("--history");
9978
- const historyMonths = historyIdx !== -1 ? Number.parseInt(argv[historyIdx + 1] ?? "", 10) : 0;
9979
- const projectedIdx = argv.indexOf("--projected");
9980
- const projectedMonths = projectedIdx !== -1 ? Number.parseInt(argv[projectedIdx + 1] ?? "", 10) : 0;
9981
10855
  const strict = argv.includes("--strict");
9982
- const historyProvided = historyIdx !== -1;
9983
- const projectedProvided = projectedIdx !== -1;
9984
- if (historyProvided && historyMonths < 0) {
9985
- (io.err ?? err)("--history requires a positive number of months");
9986
- 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;
9987
10863
  }
9988
- if (projectedProvided && projectedMonths <= 0) {
9989
- (io.err ?? err)("--projected requires a positive number of months");
9990
- 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;
9991
10874
  }
9992
- const incidentCost = INDUSTRY_COSTS[industry] ?? DEFAULT_INCIDENT_COST;
9993
10875
  io.out(`Scanning ${target} ...`);
10876
+ const invalid = validateScanTarget(target, io.err ?? err);
10877
+ if (invalid !== null) return invalid;
9994
10878
  const savingsPerFinding = (await runScan({
9995
10879
  target,
9996
10880
  json: false,
@@ -9999,29 +10883,18 @@ async function runBusinessCaseCommand(argv, io = {
9999
10883
  scopeChanged: false,
10000
10884
  format: "terminal",
10001
10885
  strict
10002
- })).findings.map((f) => {
10003
- const fpRate = f.measuredFpRate ?? null;
10004
- const evidenceLevel = f.evidenceLevel ?? "E0";
10005
- const savings = expectedSavings(fpRate, evidenceLevel, incidentCost);
10006
- return {
10007
- ruleId: f.ruleId,
10008
- fpRate,
10009
- evidenceLevel,
10010
- expectedSavings: savings
10011
- };
10012
- });
10013
- const totalExpectedSavings = savingsPerFinding.reduce((sum, f) => sum + (f.expectedSavings ?? 0), 0);
10014
- io.out(renderSummary(savingsPerFinding, incidentCost, industry, totalExpectedSavings));
10015
- if (projectedMonths > 0) {
10016
- const monthlySavings = Math.round(totalExpectedSavings / 6);
10017
- io.out(renderProjection(monthlySavings, projectedMonths, incidentCost));
10018
- }
10019
- if (historyMonths > 0) io.out(`\nHistorical analysis (last ${historyMonths} months):\n NOTE: Historical incident costing requires CI log access.
10020
- Run \`mjolnir impact --since \${historyMonths}.months.ago\` for
10021
- evidence-backed impact data from git history.
10022
- Run \`mjolnir release-report --since v\${historyMonths}.0.0\` for
10023
- release-quality trajectory.`);
10024
- 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.");
10025
10898
  io.out("Run with --strict to also surface quarantine-tier findings.");
10026
10899
  return 0;
10027
10900
  } catch (e) {
@@ -10038,10 +10911,18 @@ async function runBusinessCaseCommand(argv, io = {
10038
10911
  * posture and renders a management-ready verdict:
10039
10912
  * GO · CONDITIONAL GO · NO-GO
10040
10913
  *
10041
- * 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.
10042
10921
  *
10043
- * Exit codes (frozen contract): 0 GO · 1 CONDITIONAL GO ·
10044
- * 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.
10045
10926
  */
10046
10927
  const ui$7 = plainContext();
10047
10928
  /** Rules that count as CI integrity issues for release verdicts. */
@@ -10050,34 +10931,40 @@ const CI_INTEGRITY_RULES = /* @__PURE__ */ new Set([
10050
10931
  "QA-CI-002",
10051
10932
  "QA-CI-008"
10052
10933
  ]);
10934
+ const NOT_MEASURED = "not measured";
10935
+ function show(value) {
10936
+ return value === null ? NOT_MEASURED : String(value);
10937
+ }
10053
10938
  function renderVerdictBlock(v) {
10054
10939
  const lines = [];
10055
10940
  lines.push(sectionHeader(`RELEASE READINESS — since ${v.since}`, ui$7));
10056
10941
  lines.push("");
10057
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.");
10058
10945
  lines.push("");
10059
10946
  lines.push("Test hygiene since last release:");
10060
- lines.push(` Issues fixed: ${v.hygieneFixed}`);
10061
- lines.push(` Issues introduced: ${v.hygieneIntroduced}`);
10947
+ lines.push(` Issues fixed: ${show(v.hygieneFixed)}`);
10948
+ lines.push(` Issues introduced: ${show(v.hygieneIntroduced)}`);
10062
10949
  lines.push("");
10063
10950
  lines.push("New tests added:");
10064
- lines.push(` Total: ${v.newTestsAdded}`);
10065
- lines.push(` Without assertions: ${v.newTestsWithoutAssertions}`);
10951
+ lines.push(` Total: ${show(v.newTestsAdded)}`);
10952
+ lines.push(` Without assertions: ${show(v.newTestsWithoutAssertions)}`);
10066
10953
  lines.push("");
10067
10954
  lines.push("Skipped during the cycle:");
10068
- lines.push(` ${v.skippedDuringCycle} ← what we are NOT verifying`);
10955
+ lines.push(` ${v.skippedDuringCycle}${v.skippedDuringCycle > 0 ? " ← what we are NOT verifying" : ""}`);
10069
10956
  lines.push("");
10070
10957
  lines.push("Flaky tests:");
10071
10958
  lines.push(` At release: ${v.flakyAtRelease}`);
10072
- const improvement = v.flakyAtRelease <= v.flakyAtLastRelease ? " ✓" : " ↑";
10073
- 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}`);
10074
10961
  lines.push("");
10075
10962
  lines.push("CI integrity:");
10076
10963
  const coeMark = v.continueOnErrorActive === 0 ? " ✓" : " ⚠";
10077
10964
  lines.push(` continue-on-error still active: ${v.continueOnErrorActive}${coeMark}`);
10078
10965
  lines.push(` Total CI integrity issues: ${v.ciIntegrityIssues}`);
10079
10966
  lines.push("");
10080
- 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`}`);
10081
10968
  lines.push("");
10082
10969
  lines.push(`Blocking findings (error): ${v.blockingFindings}`);
10083
10970
  lines.push(`Warning findings (warning): ${v.advisoryFindings}`);
@@ -10097,7 +10984,7 @@ function renderVerdictBlock(v) {
10097
10984
  if (v.continueOnErrorActive > 0) lines.push(` → ${v.continueOnErrorActive} continue-on-error still active — review before ship`);
10098
10985
  if (v.skippedDuringCycle > 0) lines.push(` → ${v.skippedDuringCycle} skipped test(s) — verify coverage gaps`);
10099
10986
  if (v.blockingFindings === 0 && v.advisoryFindings > 0) lines.push(` → ${v.advisoryFindings} advisory finding(s) — review before ship`);
10100
- } 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.");
10101
10988
  lines.push("");
10102
10989
  lines.push("Screenshot this report into the release channel.");
10103
10990
  return lines.join("\n");
@@ -10113,6 +11000,13 @@ function determineVerdict(result, history) {
10113
11000
  reasons
10114
11001
  };
10115
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
+ }
10116
11010
  const flakyCount = history.flakyAtRelease ?? 0;
10117
11011
  const ciIssues = history.continueOnErrorActive ?? 0;
10118
11012
  const skipped = history.skippedDuringCycle ?? 0;
@@ -10135,24 +11029,20 @@ function buildReleaseReport(result, since, history = {}) {
10135
11029
  const { verdict } = determineVerdict(result, history);
10136
11030
  const errorFindings = result.findings.filter((f) => f.severity === "error");
10137
11031
  const warningFindings = result.findings.filter((f) => f.severity === "warning");
10138
- let testDebtHours = 0;
10139
- for (const f of result.findings) if (CI_INTEGRITY_RULES.has(f.ruleId)) testDebtHours += .5;
10140
- else if (f.severity === "error") testDebtHours += .5;
10141
- else if (f.severity === "warning") testDebtHours += .2;
10142
- else testDebtHours += .1;
10143
11032
  return {
10144
11033
  verdict,
10145
11034
  since,
10146
- hygieneFixed: 0,
10147
- hygieneIntroduced: 0,
10148
- newTestsAdded: 0,
10149
- newTestsWithoutAssertions: 0,
11035
+ partial: result.partial,
11036
+ hygieneFixed: null,
11037
+ hygieneIntroduced: null,
11038
+ newTestsAdded: null,
11039
+ newTestsWithoutAssertions: null,
10150
11040
  skippedDuringCycle: history.skippedDuringCycle ?? 0,
10151
11041
  flakyAtRelease: history.flakyAtRelease ?? 0,
10152
- flakyAtLastRelease: 0,
11042
+ flakyAtLastRelease: null,
10153
11043
  ciIntegrityIssues: warningFindings.length,
10154
11044
  continueOnErrorActive: errorFindings.filter((f) => CI_INTEGRITY_RULES.has(f.ruleId)).length,
10155
- testDebtHours,
11045
+ testDebtHours: history.testDebtHours ?? null,
10156
11046
  blockingFindings: errorFindings.length,
10157
11047
  advisoryFindings: warningFindings.length,
10158
11048
  score: result.score,
@@ -10194,18 +11084,18 @@ async function runReleaseReportCommand(argv, io) {
10194
11084
  target,
10195
11085
  json: false,
10196
11086
  verbose: false,
10197
- maxDurationMs: Number.POSITIVE_INFINITY,
11087
+ maxDurationMs: 6e5,
10198
11088
  scopeChanged: false,
10199
11089
  format: "terminal",
10200
11090
  strict: false
10201
11091
  }), since, history);
10202
11092
  io.out(renderReleaseReport(report));
10203
- switch (report.verdict) {
10204
- case "GO": return 0;
10205
- case "CONDITIONAL GO": return 1;
10206
- case "NO-GO": return 1;
10207
- 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;
10208
11096
  }
11097
+ if (report.verdict === "CONDITIONAL GO" || report.verdict === "NO-GO") return 1;
11098
+ return 20;
10209
11099
  } catch (e) {
10210
11100
  internalErrorMessage(e, io.err, false);
10211
11101
  return 20;
@@ -10214,56 +11104,42 @@ async function runReleaseReportCommand(argv, io) {
10214
11104
  //#endregion
10215
11105
  //#region src/commands/report-playwright.ts
10216
11106
  /**
10217
- * `mjolnir report playwright` — Playwright Reporter Package (SDET-2).
11107
+ * `mjolnir report` — Mjölnir findings in the Playwright report shape.
10218
11108
  *
10219
- * Generates a Playwright-compatible JSON report from Mjölnir scan results,
10220
- * 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.
10221
11115
  *
10222
- * The report follows the Playwright JSON report schema's structure:
10223
- * 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.
10224
11119
  */
10225
11120
  const ui$6 = plainContext();
10226
- function findingOutcome(f) {
10227
- if (f.severity === "error") return "failed";
10228
- if (f.severity === "warning") return "expectedFailure";
10229
- return "passed";
10230
- }
10231
11121
  function buildPlaywrightReport(result) {
10232
11122
  const now = /* @__PURE__ */ new Date();
10233
- const findingsByFile = /* @__PURE__ */ new Map();
10234
- for (const f of result.findings) {
10235
- const existing = findingsByFile.get(f.file) ?? [];
10236
- existing.push(f);
10237
- findingsByFile.set(f.file, existing);
10238
- }
10239
- const suites = Array.from(findingsByFile.entries()).map(([file, findings]) => ({
10240
- title: file,
10241
- file,
10242
- tests: findings.map((f) => ({
10243
- title: `${f.ruleId}: ${f.message}`,
10244
- path: file,
10245
- outcome: findingOutcome(f),
10246
- duration: 0,
10247
- annotations: [{
10248
- type: f.severity === "error" ? "error" : "warning",
10249
- message: `${f.ruleId} — ${f.message}${f.fix ? `\nFix: ${f.fix}` : ""}`
10250
- }]
10251
- }))
10252
- }));
10253
- 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";
10254
11126
  return {
10255
11127
  version: 1,
10256
11128
  startTime: now.toISOString(),
10257
11129
  endTime: now.toISOString(),
10258
11130
  duration: 0,
10259
- status: result.findings.some((f) => f.severity === "error") ? "failed" : "passed",
10260
- totalTests: allTests.length,
10261
- passedTests: allTests.filter((t) => t.outcome === "passed").length,
10262
- failedTests: allTests.filter((t) => t.outcome === "failed").length,
10263
- suites,
11131
+ status,
11132
+ totalTests: 0,
11133
+ passedTests: 0,
11134
+ failedTests: 0,
11135
+ suites: [],
10264
11136
  mjolnir: {
11137
+ execution: "STATIC_ANALYSIS",
11138
+ status,
11139
+ partial,
10265
11140
  score: result.score,
10266
11141
  framework: result.frameworks[0] ?? "unknown",
11142
+ frameworkDetectionUnknown: result.frameworkDetectionUnknown === true,
10267
11143
  findings: result.findings.map((f) => {
10268
11144
  const entry = {
10269
11145
  ruleId: f.ruleId,
@@ -10288,12 +11164,19 @@ async function runReportPlaywrightCommand(argv, io) {
10288
11164
  io.err(`mjolnir report playwright: target does not exist: ${target}`);
10289
11165
  return 10;
10290
11166
  }
11167
+ const root = resolve(target);
11168
+ const requestedOutput = resolve(outputPath);
11169
+ const outputRelative = relative(root, requestedOutput);
11170
+ if (isAbsolute(outputRelative) || outputRelative.startsWith("..")) {
11171
+ io.err("mjolnir report playwright: output must stay within the target root");
11172
+ return 10;
11173
+ }
10291
11174
  try {
10292
11175
  const result = await runScan$1({
10293
11176
  target,
10294
11177
  json: true,
10295
11178
  verbose: false,
10296
- maxDurationMs: Number.POSITIVE_INFINITY,
11179
+ maxDurationMs: 6e5,
10297
11180
  scopeChanged: false,
10298
11181
  format: "json",
10299
11182
  strict: false
@@ -10301,15 +11184,21 @@ async function runReportPlaywrightCommand(argv, io) {
10301
11184
  const report = buildPlaywrightReport(result);
10302
11185
  const outDir = resolve(".");
10303
11186
  if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
10304
- const fullPath = join(outDir, outputPath);
10305
- writeFileSync(fullPath, JSON.stringify(report, null, 2) + "\n");
10306
- const header = sectionHeader("PLAYWRIGHT REPORT", ui$6);
11187
+ const fullPath = requestedOutput;
11188
+ writeFileAtomic(fullPath, JSON.stringify(report, null, 2) + "\n", { encoding: "utf8" });
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);
10307
11195
  io.out(`${header}\n`);
10308
11196
  io.out(`Score: ${result.score !== null ? result.score + "/100" : "unknown"}`);
10309
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)`);
10310
11198
  io.out(`Report written: ${fullPath}`);
10311
- io.out(`Tests: ${report.totalTests} total, ${report.passedTests} passed, ${report.failedTests} failed`);
10312
- 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;
10313
11202
  } catch (e) {
10314
11203
  internalErrorMessage(e, io.err, false);
10315
11204
  return 20;
@@ -10449,32 +11338,49 @@ async function runTrendCommand(argv, io) {
10449
11338
  //#endregion
10450
11339
  //#region src/commands/exec-report.ts
10451
11340
  /**
10452
- * `mjolnir exec-report` — Executive Quality Report (QM-3).
11341
+ * `mjolnir exec-report` — a short, measured summary of one scan.
10453
11342
  *
10454
- * Generates a board-ready quality report with KPIs, trend summary,
10455
- * 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.
10456
11348
  *
10457
- * Output is human-readable terminal format suitable for copying into
10458
- * 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.
10459
11353
  */
10460
11354
  const ui$4 = plainContext();
10461
- function assessRisk(findings) {
10462
- const errors = findings.filter((f) => f.severity === "error").length;
10463
- const warnings = findings.filter((f) => f.severity === "warning").length;
10464
- if (errors > 5 || warnings > 20) return "high";
10465
- if (errors > 0 || warnings > 5) return "medium";
10466
- return "low";
10467
- }
10468
- function buildRecommendations(findings, score) {
10469
- const recs = [];
10470
- const errors = findings.filter((f) => f.severity === "error").length;
10471
- const warnings = findings.filter((f) => f.severity === "warning").length;
10472
- if (errors > 0) recs.push(`Fix ${errors} error finding(s) blocking release confidence`);
10473
- if (warnings > 0) recs.push(`Review ${warnings} warning finding(s) before next release`);
10474
- if (score !== null && score < 70) recs.push(`Score ${score}/100 is below the 70 threshold — prioritize rule coverage`);
10475
- if (score !== null && score >= 90) recs.push(`Score ${score}/100 is excellent — maintain current quality gate`);
10476
- if (recs.length === 0) recs.push("No critical issues — maintain current quality practices");
10477
- 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
+ ];
10478
11384
  }
10479
11385
  async function runExecReportCommand(argv, io) {
10480
11386
  const target = argv.find((a) => !a.startsWith("-")) ?? ".";
@@ -10487,60 +11393,34 @@ async function runExecReportCommand(argv, io) {
10487
11393
  target,
10488
11394
  json: true,
10489
11395
  verbose: false,
10490
- maxDurationMs: Number.POSITIVE_INFINITY,
11396
+ maxDurationMs: 6e5,
10491
11397
  scopeChanged: false,
10492
11398
  format: "json",
10493
11399
  strict: false
10494
11400
  });
10495
- const risk = assessRisk(result.findings);
10496
- const recommendations = buildRecommendations(result.findings, result.score);
10497
- const kpis = [
10498
- {
10499
- label: "Worthiness Score",
10500
- value: result.score !== null ? `${result.score}/100` : "N/A",
10501
- delta: result.score !== null && result.score >= 80 ? "↑ healthy" : result.score !== null ? "↓ needs work" : "—",
10502
- status: result.score !== null && result.score >= 80 ? "good" : result.score !== null && result.score >= 60 ? "warning" : "critical"
10503
- },
10504
- {
10505
- label: "Total Findings",
10506
- value: String(result.findings.length),
10507
- delta: "",
10508
- status: result.findings.length <= 5 ? "good" : result.findings.length <= 20 ? "warning" : "critical"
10509
- },
10510
- {
10511
- label: "Errors",
10512
- value: String(result.findings.filter((f) => f.severity === "error").length),
10513
- delta: "",
10514
- status: result.findings.filter((f) => f.severity === "error").length === 0 ? "good" : "critical"
10515
- },
10516
- {
10517
- label: "Warnings",
10518
- value: String(result.findings.filter((f) => f.severity === "warning").length),
10519
- delta: "",
10520
- status: result.findings.filter((f) => f.severity === "warning").length <= 5 ? "good" : "warning"
10521
- }
10522
- ];
10523
- 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);
10524
11408
  io.out(`${header}\n`);
10525
- io.out(`Generated: ${(/* @__PURE__ */ new Date()).toISOString()}`);
10526
11409
  io.out(`Target: ${target}`);
10527
- io.out(`Risk Level: ${risk.toUpperCase()}`);
11410
+ io.out(`Determination: ${decision.state} — ${decision.reason}`);
10528
11411
  io.out("");
10529
- io.out("--- Key Performance Indicators ---");
10530
- for (const kpi of kpis) {
10531
- const icon = kpi.status === "good" ? "✅" : kpi.status === "warning" ? "⚠️ " : "🔴";
10532
- io.out(` ${icon} ${kpi.label}: ${kpi.value} ${kpi.delta}`);
10533
- }
11412
+ io.out("--- Measured values ---");
11413
+ for (const kpi of kpis) io.out(` ${kpi.label}: ${kpi.value} (source: ${kpi.source})`);
10534
11414
  io.out("");
10535
- io.out("--- Top Findings ---");
11415
+ io.out("--- Top findings ---");
10536
11416
  const top = result.findings.slice(0, 5);
10537
11417
  for (const f of top) io.out(` [${f.severity.toUpperCase()}] ${f.ruleId}: ${f.message} (${f.file}:${f.line})`);
10538
- 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.`);
10539
11420
  io.out("");
10540
- io.out("--- Recommendations ---");
10541
- 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.");
10542
11422
  io.out("");
10543
- return result.findings.some((f) => f.severity === "error") ? 1 : 0;
11423
+ return decision.exitCode;
10544
11424
  } catch (e) {
10545
11425
  internalErrorMessage(e, io.err, false);
10546
11426
  return 20;
@@ -10598,7 +11478,7 @@ async function runPolicyCommand(argv, io) {
10598
11478
  const policy = defaultPolicy();
10599
11479
  const dir = target === ".mjolnir" ? target : ".";
10600
11480
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
10601
- writeFileSync(policyPath, JSON.stringify(policy, null, 2) + "\n");
11481
+ writeFileAtomic(policyPath, JSON.stringify(policy, null, 2) + "\n");
10602
11482
  io.out(`Policy written to ${policyPath}`);
10603
11483
  io.out(`Use 'mjolnir policy validate ${policyPath}' to validate.`);
10604
11484
  io.out(`Use 'mjolnir policy check <target> --policy ${policyPath}' to check results.`);
@@ -10674,83 +11554,158 @@ async function runPolicyCommand(argv, io) {
10674
11554
  //#endregion
10675
11555
  //#region src/commands/quarantine.ts
10676
11556
  /**
10677
- * `mjolnir quarantine` — Quarantine Workflow Management (TL-3).
11557
+ * `mjolnir quarantine` — quarantined-test workflow.
10678
11558
  *
10679
- * Manages quarantined tests: list proposed quarantines, review them,
10680
- * 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.
10681
11566
  *
10682
- * Subcommands:
10683
- * list — show all proposed quarantines from a forensics report
10684
- * review — review a specific quarantine proposal
10685
- * 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.
10686
11572
  *
10687
- * Reads forensics output to propose quarantines deterministically:
10688
- * propose when attempts >= 2 AND everFailed, priority by attempts then duration.
11573
+ * Scheduled for removal in 5.0.
10689
11574
  */
10690
11575
  const ui$3 = plainContext();
10691
- function buildQuarantineProposals(findings) {
10692
- return findings.filter((f) => f.severity === "error" || f.severity === "warning").map((f, i) => ({
10693
- id: `Q-${String(i + 1).padStart(3, "0")}`,
10694
- ruleId: f.ruleId,
10695
- file: f.file,
10696
- line: f.line,
10697
- message: f.message,
10698
- 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,
10699
11629
  status: "proposed",
10700
- 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`
10701
11631
  }));
10702
11632
  }
10703
- 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) {
10704
11652
  const subcommand = argv[0] ?? "list";
10705
- 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");
10706
11656
  if (subcommand === "list") {
10707
- const findings = await runScan$1({
10708
- target,
10709
- json: true,
10710
- verbose: false,
10711
- maxDurationMs: Number.POSITIVE_INFINITY,
10712
- scopeChanged: false,
10713
- format: "json",
10714
- strict: false
10715
- }).catch((e) => {
10716
- internalErrorMessage(e, io.err, false);
10717
- return null;
10718
- });
10719
- if (!findings) return 20;
10720
- const proposals = buildQuarantineProposals(findings.findings);
10721
- if (proposals.length === 0) {
10722
- io.out("No quarantine proposals. All findings are within acceptable thresholds.");
10723
- 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;
10724
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);
10725
11681
  io.out(sectionHeader("QUARANTINE PROPOSALS", ui$3));
10726
11682
  io.out("");
10727
- io.out(`| ID | Rule | File | Severity | Status |`);
10728
- io.out(`| -- | ---- | ---- | -------- | ------ |`);
10729
- 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} |`);
10730
11690
  io.out("");
10731
- io.out(`${proposals.length} proposal(s). Use 'mjolnir quarantine review' to manage.`);
10732
- 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;
10733
11693
  }
10734
11694
  if (subcommand === "review") {
10735
- const proposals = buildQuarantineProposals([]);
10736
- const action = argv.find((a) => a === "--accept" || a === "--defer" || a === "--reject");
10737
11695
  io.out(sectionHeader("QUARANTINE REVIEW", ui$3));
10738
11696
  io.out("");
10739
- io.out("Pending reviews:");
10740
- for (const p of proposals) io.out(` ${p.id}: ${p.ruleId} — ${p.message} (${p.file}:${p.line})`);
10741
- if (action) {
10742
- io.out(`Action: ${action}`);
10743
- io.out("Quarantines updated.");
10744
- } else io.out("Use --accept, --defer, or --reject to manage.");
10745
- 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;
10746
11700
  }
10747
11701
  if (subcommand === "stats") {
10748
11702
  io.out(sectionHeader("QUARANTINE STATS", ui$3));
10749
11703
  io.out("");
10750
- 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.");
10751
11706
  io.out("");
10752
- io.out("No quarantine activity recorded yet.");
10753
- 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;
10754
11709
  }
10755
11710
  io.err(`Unknown quarantine subcommand: ${subcommand}`);
10756
11711
  return 10;
@@ -10787,20 +11742,44 @@ function runAnalyzeCommand(argv, io) {
10787
11742
  const findings = [];
10788
11743
  const fileImports = /* @__PURE__ */ new Map();
10789
11744
  const fileContents = /* @__PURE__ */ new Map();
10790
- function walk(dir) {
11745
+ let limited = false;
11746
+ const deadline = Date.now() + 6e5;
11747
+ function walk(dir, depth = 0) {
11748
+ if (limited || depth > LIMITS.maxDepth || Date.now() >= deadline) {
11749
+ limited = true;
11750
+ return;
11751
+ }
10791
11752
  for (const name of readdirSync(dir)) {
10792
11753
  const path = join(dir, name);
10793
11754
  if (name === "node_modules" || name === ".git") continue;
10794
- if (statSync(path).isDirectory()) walk(path);
10795
- else if (name.endsWith(".ts") || name.endsWith(".tsx") || name.endsWith(".js") || name.endsWith(".jsx")) try {
10796
- const content = readFileSync(path, "utf8");
11755
+ let stat;
11756
+ try {
11757
+ stat = lstatSync(path);
11758
+ } catch {
11759
+ continue;
11760
+ }
11761
+ if (stat.isSymbolicLink()) continue;
11762
+ if (stat.isDirectory()) {
11763
+ walk(path, depth + 1);
11764
+ if (limited) return;
11765
+ } else if (name.endsWith(".ts") || name.endsWith(".tsx") || name.endsWith(".js") || name.endsWith(".jsx")) {
11766
+ if (fileContents.size >= LIMITS.maxFilesPerAdapter) {
11767
+ limited = true;
11768
+ return;
11769
+ }
11770
+ const read = readFileBounded(path, LIMITS.maxFileBytes);
11771
+ if (!read.ok) {
11772
+ limited = true;
11773
+ continue;
11774
+ }
11775
+ const content = read.data.toString("utf8");
10797
11776
  const rel = relative(process.cwd(), path);
10798
11777
  fileContents.set(rel, content);
10799
11778
  const imports = collectImports(content);
10800
11779
  const importCounts = /* @__PURE__ */ new Map();
10801
11780
  for (const imp of imports) importCounts.set(imp, (importCounts.get(imp) ?? 0) + 1);
10802
11781
  fileImports.set(rel, importCounts);
10803
- } catch {}
11782
+ }
10804
11783
  }
10805
11784
  }
10806
11785
  walk(target);
@@ -10854,6 +11833,10 @@ function runAnalyzeCommand(argv, io) {
10854
11833
  }
10855
11834
  io.out("");
10856
11835
  }
11836
+ if (limited) {
11837
+ io.err("mjolnir analyze: resource limit reached; cross-file analysis is partial");
11838
+ return 2;
11839
+ }
10857
11840
  return 0;
10858
11841
  } catch (e) {
10859
11842
  internalErrorMessage(e, io.err, false);
@@ -10946,7 +11929,7 @@ function runCiAdapterCommand(argv, io) {
10946
11929
  default: return 10;
10947
11930
  }
10948
11931
  try {
10949
- writeFileSync(join(target, filename), output);
11932
+ writeFileAtomic(join(target, filename), output);
10950
11933
  } catch (error) {
10951
11934
  io.err(`Unable to write CI template: ${error instanceof Error ? error.message : String(error)}`);
10952
11935
  return 20;
@@ -10966,13 +11949,32 @@ function runCiAdapterCommand(argv, io) {
10966
11949
  * file with inline CSS and JavaScript, suitable for embedding
10967
11950
  * in team wikis or sharing via static hosting.
10968
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;
11963
+ function escapeHtml(text) {
11964
+ return sanitizeErrorText(text, { maxLength: 1e3 }).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
11965
+ }
10969
11966
  function generateDashboardHtml(data) {
10970
- const scoreColor = data.score === null ? "#888" : data.score >= 80 ? "#22c55e" : data.score >= 60 ? "#eab308" : "#ef4444";
10971
- const rows = data.findings.map((f) => `<tr><td>${f.ruleId}</td><td><span style="color:${f.severity === "error" ? "#ef4444" : f.severity === "warning" ? "#eab308" : "#22c55e"}">${f.severity}</span></td><td>${f.file}</td><td>${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);
10972
11973
  return `<!DOCTYPE html>
10973
11974
  <html lang="en">
10974
11975
  <head>
10975
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">
10976
11978
  <title>Quality Dashboard</title>
10977
11979
  <style>
10978
11980
  body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 960px; margin: 0 auto; padding: 2rem; background: #0a0a0a; color: #e5e5e5; }
@@ -10989,16 +11991,15 @@ function generateDashboardHtml(data) {
10989
11991
  </head>
10990
11992
  <body>
10991
11993
  <h1>🔍 Quality Dashboard</h1>
10992
- <p>Generated: ${data.generatedAt}</p>
10993
- <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>
10994
11995
  <div class="kpi-grid">
10995
11996
  <div class="kpi"><div class="value">${data.totalFindings}</div><div class="label">Findings</div></div>
10996
- <div class="kpi"><div class="value" style="color:#ef4444">${data.errorCount}</div><div class="label">Errors</div></div>
10997
- <div class="kpi"><div class="value" style="color:#eab308">${data.warningCount}</div><div class="label">Warnings</div></div>
10998
- <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>
10999
12000
  </div>
11000
12001
  <h2>Findings</h2>
11001
- <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>
11002
12003
  </body>
11003
12004
  </html>`;
11004
12005
  }
@@ -11006,41 +12007,60 @@ async function runDashboardCommand(argv, io) {
11006
12007
  const target = argv.find((a) => !a.startsWith("-")) ?? ".";
11007
12008
  const outputIdx = argv.indexOf("--output");
11008
12009
  const outputPath = outputIdx !== -1 ? argv[outputIdx + 1] ?? "dashboard.html" : "dashboard.html";
12010
+ const deterministic = argv.includes("--deterministic");
11009
12011
  if (!existsSync(target)) {
11010
12012
  io.err(`mjolnir dashboard: target does not exist: ${target}`);
11011
12013
  return 10;
11012
12014
  }
12015
+ const root = resolve(target);
12016
+ const resolvedOutput = resolve(outputPath);
12017
+ const outputRelative = relative(root, resolvedOutput);
12018
+ if (isAbsolute(outputRelative) || outputRelative.startsWith("..")) {
12019
+ io.err("mjolnir dashboard: output must stay within the target root");
12020
+ return 10;
12021
+ }
11013
12022
  try {
11014
- const { runScan } = await import("./scan-pipeline-C_Rs4u8N.mjs");
12023
+ const { runScan } = await import("./scan-pipeline-Bg-46cLS.mjs");
11015
12024
  const result = await runScan({
11016
12025
  target,
11017
12026
  json: true,
11018
12027
  verbose: false,
11019
- maxDurationMs: Number.POSITIVE_INFINITY,
12028
+ maxDurationMs: 6e5,
11020
12029
  scopeChanged: false,
11021
12030
  format: "json",
11022
12031
  strict: false
11023
12032
  });
12033
+ const shown = result.findings.slice(0, FINDINGS_SHOWN_LIMIT);
11024
12034
  const data = {
11025
12035
  score: result.score,
11026
12036
  totalFindings: result.findings.length,
11027
12037
  errorCount: result.findings.filter((f) => f.severity === "error").length,
11028
12038
  warningCount: result.findings.filter((f) => f.severity === "warning").length,
11029
- frameworkCount: result.frameworks.length,
11030
- 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) => ({
11031
12043
  ruleId: f.ruleId,
11032
12044
  severity: f.severity,
11033
12045
  file: f.file,
11034
12046
  message: f.message
11035
12047
  })),
11036
- generatedAt: (/* @__PURE__ */ new Date()).toISOString()
12048
+ generatedAt: deterministic ? null : (/* @__PURE__ */ new Date()).toISOString()
11037
12049
  };
11038
12050
  const html = generateDashboardHtml(data);
11039
- writeFileSync(outputPath, html);
11040
- io.out(`Dashboard written to ${outputPath}`);
12051
+ writeFileAtomic(resolvedOutput, html, { encoding: "utf8" });
12052
+ io.out(`Dashboard written to ${resolvedOutput}`);
11041
12053
  io.out(`Score: ${data.score !== null ? data.score + "/100" : "N/A"}`);
11042
12054
  io.out(`Findings: ${data.totalFindings} (${data.errorCount} errors, ${data.warningCount} warnings)`);
11043
- 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;
11044
12064
  } catch (e) {
11045
12065
  internalErrorMessage(e, io.err, false);
11046
12066
  return 20;
@@ -11049,18 +12069,34 @@ async function runDashboardCommand(argv, io) {
11049
12069
  //#endregion
11050
12070
  //#region src/commands/enterprise.ts
11051
12071
  /**
11052
- * `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.
12076
+ *
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.
11053
12089
  *
11054
- * Generates enterprise deployment artifacts:
11055
- * - Self-hosted deployment config
11056
- * - SSO/SAML setup guide
11057
- * - Compliance templates (SOC 2, HIPAA, PCI-DSS)
11058
- * - Custom policy templates
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.
11059
12094
  *
11060
- * Subcommands:
11061
- * config — generate deployment config
11062
- * sso — generate SSO setup guide
11063
- * compliance — generate compliance template
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.
11064
12100
  */
11065
12101
  const ui$1 = plainContext();
11066
12102
  function runEnterpriseCommand(argv, io) {
@@ -11069,119 +12105,108 @@ function runEnterpriseCommand(argv, io) {
11069
12105
  if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
11070
12106
  if (subcommand === "config") {
11071
12107
  const config = {
11072
- service: "mjolnir-qa",
11073
- version: "2.0.0",
11074
- deployment: {
11075
- type: "self-hosted",
11076
- protocol: "https",
11077
- port: 443,
11078
- authentication: "sso-saml",
11079
- sessionTimeout: "8h",
11080
- maxFileSize: "100MB"
11081
- },
11082
- features: {
11083
- rules: true,
11084
- plugins: true,
11085
- cache: true,
11086
- reporter: true,
11087
- mcp: true
11088
- },
11089
- logging: {
11090
- level: "info",
11091
- retention: "90d",
11092
- audit: true
11093
- }
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."
11094
12125
  };
11095
- const path = join(outputDir, "deployment-config.json");
11096
- writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
11097
- io.out(sectionHeader("ENTERPRISE CONFIG", ui$1));
11098
- io.out(`Config written: ${path}`);
11099
- io.out(`Type: ${config.deployment.type}`);
11100
- io.out(`Auth: ${config.deployment.authentication}`);
11101
- return 0;
11102
- }
11103
- if (subcommand === "sso") {
11104
- const guide = `# SSO/SAML Setup Guide
11105
-
11106
- ## Service Provider (SP) Configuration
11107
- - Entity ID: https://your-domain.com/mjolnir
11108
- - ACS URL: https://your-domain.com/mjolnir/auth/sso/callback
11109
- - Binding: HTTP-POST
11110
-
11111
- ## Identity Provider (IdP) Configuration
11112
- - Metadata URL: https://your-idp.com/sso/metadata
11113
- - Required Attributes:
11114
- - email (mapped to user identity)
11115
- - role (mapped to access level)
11116
- - department (mapped to team)
11117
-
11118
- ## Mjölnir Configuration
11119
- Add to mjolnir.config.json:
11120
- \`\`\`json
11121
- {
11122
- "sso": {
11123
- "enabled": true,
11124
- "provider": "saml",
11125
- "entryPoint": "https://your-idp.com/sso",
11126
- "attributeMapping": {
11127
- "email": "user",
11128
- "role": "accessLevel",
11129
- "department": "team"
11130
- }
11131
- }
11132
- }
11133
- \`\`\`
11134
- `;
11135
- const path = join(outputDir, "sso-setup.md");
11136
- writeFileSync(path, guide);
11137
- 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.");
11138
12135
  return 0;
11139
12136
  }
11140
- if (subcommand === "compliance") {
11141
- for (const framework of [
11142
- "SOC2",
11143
- "HIPAA",
11144
- "PCI-DSS"
11145
- ]) {
11146
- const template = `# ${framework} Compliance Template for Mjölnir
11147
-
11148
- ## Control Mapping
11149
- | Control | Mjölnir Feature | Evidence |
11150
- | --------- | --------------- | -------- |
11151
- | Access Control | SSO/SAML integration | sso-setup.md |
11152
- | Audit Logging | Built-in scan audit trail | --json output |
11153
- | Data Protection | Local-first, zero-network | privacy scan |
11154
- | Change Management | quarantined rules, policy gates | policy check |
11155
- | Monitoring | dashboard, exec-report | HTML/terminal output |
11156
- `;
11157
- const path = join(outputDir, `${framework.toLowerCase()}-compliance.md`);
11158
- writeFileSync(path, template);
11159
- io.out(`Compliance template: ${path}`);
11160
- }
11161
- 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;
11162
12140
  }
11163
12141
  io.err(`Unknown enterprise subcommand: ${subcommand}`);
12142
+ io.err("Available: config");
11164
12143
  return 10;
11165
12144
  }
11166
12145
  //#endregion
11167
12146
  //#region src/commands/maturity.ts
11168
12147
  /**
11169
- * `mjolnir maturity` — Quality Maturity Model (QM-6).
12148
+ * `mjolnir maturity` — organizational QA-maturity signals.
11170
12149
  *
11171
- * Assesses the organization's QA maturity across dimensions:
11172
- * Test hygiene — rule coverage, assertion quality
11173
- * CI integrity — gate coverage, feedback speed
11174
- * Runtime verification — forensics adoption, flake management
11175
- * Process maturity — triage cadence, suppression governance
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.
11176
12156
  *
11177
- * Output: maturity level (Initial → Managed → Defined → Quantitatively Managed → Optimizing)
11178
- * with specific improvement recommendations.
11179
- *
11180
- * Subcommands:
11181
- * assess — run the maturity assessment
11182
- * 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.
11183
12161
  */
11184
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
+ }
11185
12210
  const MATURITY_LEVELS = [
11186
12211
  "Initial",
11187
12212
  "Managed",
@@ -11189,51 +12214,16 @@ const MATURITY_LEVELS = [
11189
12214
  "Quantitatively Managed",
11190
12215
  "Optimizing"
11191
12216
  ];
11192
- function assessDimension(name, score, findings = []) {
11193
- if (score >= 90) return {
11194
- name,
11195
- level: "Optimizing",
11196
- score,
11197
- findings
11198
- };
11199
- if (score >= 75) return {
11200
- name,
11201
- level: "Quantitatively Managed",
11202
- score,
11203
- findings
11204
- };
11205
- if (score >= 60) return {
11206
- name,
11207
- level: "Defined",
11208
- score,
11209
- findings
11210
- };
11211
- if (score >= 40) return {
11212
- name,
11213
- level: "Managed",
11214
- score,
11215
- findings
11216
- };
11217
- return {
11218
- name,
11219
- level: "Initial",
11220
- score,
11221
- findings
11222
- };
11223
- }
11224
12217
  function runMaturityCommand(argv, io) {
11225
12218
  const subcommand = argv[0] ?? "assess";
11226
12219
  const target = argv.slice(1).find((a) => !a.startsWith("-")) ?? ".";
11227
12220
  if (subcommand === "levels") {
11228
12221
  io.out(sectionHeader("MATURITY LEVELS", ui));
11229
12222
  io.out("");
11230
- for (let i = 0; i < MATURITY_LEVELS.length; i++) {
11231
- const level = MATURITY_LEVELS[i];
11232
- io.out(`${i + 1}. ${level}`);
11233
- }
12223
+ for (let i = 0; i < MATURITY_LEVELS.length; i++) io.out(`${i + 1}. ${MATURITY_LEVELS[i]}`);
11234
12224
  io.out("");
11235
- io.out("Score ranges: Initial (0-39), Managed (40-59), Defined (60-74), Quantitatively Managed (75-89), Optimizing (90-100)");
11236
- 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;
11237
12227
  }
11238
12228
  if (subcommand === "assess") {
11239
12229
  if (!existsSync(target)) {
@@ -11241,65 +12231,19 @@ function runMaturityCommand(argv, io) {
11241
12231
  return 10;
11242
12232
  }
11243
12233
  try {
11244
- let ruleCount = 0;
11245
- const catalogPath = join(target, "docs", "rules", "catalog.md");
11246
- if (existsSync(catalogPath)) try {
11247
- ruleCount = (readFileSync(catalogPath, "utf8").match(/QA-/g) ?? []).length;
11248
- } catch {
11249
- ruleCount = 79;
11250
- }
11251
- else ruleCount = 79;
11252
- let hasPolicy = false;
11253
- const policyPath = join(target, ".mjolnir", "mjolnir.policy.json");
11254
- if (existsSync(policyPath)) try {
11255
- hasPolicy = true;
11256
- } catch {
11257
- hasPolicy = false;
11258
- }
11259
- let hasHistory = false;
11260
- const statsPath = join(target, ".mjolnir", "stats.json");
11261
- if (existsSync(statsPath)) try {
11262
- hasHistory = true;
11263
- } catch {
11264
- hasHistory = false;
11265
- }
11266
- let hasTrends = false;
11267
- const trendPath = join(target, ".mjolnir", "trend.jsonl");
11268
- if (existsSync(trendPath)) try {
11269
- hasTrends = true;
11270
- } catch {
11271
- hasTrends = false;
11272
- }
11273
- const dimensions = [
11274
- assessDimension("Test Hygiene", Math.min(100, Math.round(ruleCount / 100 * 100)), [`${ruleCount} rules loaded`, ruleCount >= 50 ? "Good rule coverage" : "Increase rule coverage"]),
11275
- assessDimension("CI Integrity", hasPolicy ? 75 : 30, [hasPolicy ? "Policy-as-code active" : "Implement policy-as-code", "Gate configuration verified"]),
11276
- assessDimension("Runtime Verification", hasTrends ? 70 : 25, [hasTrends ? "Trend tracking active" : "Start quality trend tracking", hasHistory ? "Historical data available" : "No historical data yet"]),
11277
- assessDimension("Process Maturity", hasHistory ? 65 : 20, [hasHistory ? "Fix tracking active" : "Start tracking fixes", "Triage cadence needs definition"])
11278
- ];
11279
- const avgScore = Math.round(dimensions.reduce((sum, d) => sum + d.score, 0) / dimensions.length);
11280
- const overall = avgScore >= 90 ? "Optimizing" : avgScore >= 75 ? "Quantitatively Managed" : avgScore >= 60 ? "Defined" : avgScore >= 40 ? "Managed" : "Initial";
11281
- const assessment = {
11282
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
11283
- overall,
11284
- overallScore: avgScore,
11285
- dimensions
11286
- };
11287
- io.out(sectionHeader("MATURITY ASSESSMENT", ui));
11288
- io.out(`Generated: ${assessment.timestamp}`);
11289
- 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.");
11290
12239
  io.out("");
11291
- for (const d of dimensions) {
11292
- const icon = d.score >= 75 ? "🟢" : d.score >= 50 ? "🟡" : "🔴";
11293
- io.out(`${icon} ${d.name}: ${d.level} (${d.score}/100)`);
11294
- for (const f of d.findings) io.out(` - ${f}`);
11295
- io.out("");
11296
- }
11297
- const improvement = dimensions.filter((d) => d.score < 75).map((d) => `Improve ${d.name} to reach Quantitatively Managed`);
11298
- if (improvement.length > 0) {
11299
- io.out("--- Improvement Areas ---");
11300
- for (const i of improvement) io.out(`• ${i}`);
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}`);
11301
12245
  }
11302
- return 0;
12246
+ return unmeasuredClaim("maturity", "These are artifact-presence signals, not a maturity assessment, so no clean result is reported.").exitCode;
11303
12247
  } catch (e) {
11304
12248
  internalErrorMessage(e, io.err, false);
11305
12249
  return 20;
@@ -11426,7 +12370,7 @@ function analyzeCrossFileSignals(files, findings, root) {
11426
12370
  }));
11427
12371
  const correlationConclusions = correlateFindings(findings).map((c) => `${c.conclusionType}: ${c.corroboration}`).join("; ");
11428
12372
  const graph = buildDependencyGraph(root);
11429
- const reachableFiles = getReachableFiles(orderedFiles.map((f) => f.path), graph);
12373
+ const reachability = getReachableFiles(orderedFiles.map((f) => f.path), graph);
11430
12374
  return {
11431
12375
  signals: [
11432
12376
  ...duplicateTestNames,
@@ -11438,7 +12382,7 @@ function analyzeCrossFileSignals(files, findings, root) {
11438
12382
  circularDependencies,
11439
12383
  correlationConclusions,
11440
12384
  dependencyGraphSize: graph.size,
11441
- reachableFilesCount: reachableFiles.length
12385
+ reachability
11442
12386
  };
11443
12387
  }
11444
12388
  function detectSharedImports(files) {
@@ -11518,7 +12462,9 @@ function renderCrossFileAnalysis(result) {
11518
12462
  lines.push(`Shared imports: ${result.sharedImports.length}`);
11519
12463
  lines.push(`Circular dependencies: ${result.circularDependencies.length}`);
11520
12464
  lines.push(`Dependency graph size: ${result.dependencyGraphSize}`);
11521
- 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.`);
11522
12468
  lines.push("");
11523
12469
  if (result.signals.length > 0) {
11524
12470
  lines.push("Signals:");
@@ -11683,190 +12629,6 @@ function renderEvidenceGraphResult(result) {
11683
12629
  return lines.join("\n").trimEnd();
11684
12630
  }
11685
12631
  //#endregion
11686
- //#region src/frameworks/framework-inventory.ts
11687
- const FRAMEWORK_INVENTORY = [
11688
- {
11689
- frameworkId: "playwright",
11690
- entityType: "E2E_FRAMEWORK",
11691
- language: "TypeScript/JavaScript",
11692
- maturity: "F4",
11693
- supportStatus: "OFFICIAL_PARTIAL",
11694
- targetMaturity: "F5",
11695
- targetSupportStatus: "OFFICIAL_FULL",
11696
- validatedVersions: [
11697
- "1.44",
11698
- "1.45",
11699
- "1.46",
11700
- "1.47",
11701
- "1.48"
11702
- ],
11703
- keyGapForNextLevel: "GAP-PW-006 parallelism/worker safety analysis"
11704
- },
11705
- {
11706
- frameworkId: "jest",
11707
- entityType: "TEST_FRAMEWORK",
11708
- language: "TypeScript/JavaScript",
11709
- maturity: "F3",
11710
- supportStatus: "OFFICIAL_PARTIAL",
11711
- targetMaturity: "F4",
11712
- targetSupportStatus: "OFFICIAL_FULL",
11713
- validatedVersions: ["29", "30"],
11714
- keyGapForNextLevel: "GAP-JEST-004 lifecycle modeling"
11715
- },
11716
- {
11717
- frameworkId: "vitest",
11718
- entityType: "TEST_FRAMEWORK",
11719
- language: "TypeScript/JavaScript",
11720
- maturity: "F3",
11721
- supportStatus: "OFFICIAL_PARTIAL",
11722
- targetMaturity: "F4",
11723
- targetSupportStatus: "OFFICIAL_FULL",
11724
- validatedVersions: [
11725
- "1.6",
11726
- "2.0",
11727
- "2.1"
11728
- ],
11729
- keyGapForNextLevel: "GAP-VIT-004 lifecycle modeling"
11730
- },
11731
- {
11732
- frameworkId: "pytest",
11733
- entityType: "TEST_FRAMEWORK",
11734
- language: "Python",
11735
- maturity: "F2",
11736
- supportStatus: "OFFICIAL_PARTIAL",
11737
- targetMaturity: "F3",
11738
- targetSupportStatus: "OFFICIAL_FULL",
11739
- validatedVersions: [
11740
- "7.4",
11741
- "8.0",
11742
- "8.1",
11743
- "8.2",
11744
- "8.3"
11745
- ],
11746
- keyGapForNextLevel: "GAP-PY-004 lifecycle modeling"
11747
- },
11748
- {
11749
- frameworkId: "junit",
11750
- entityType: "TEST_FRAMEWORK",
11751
- language: "Java",
11752
- maturity: "F2",
11753
- supportStatus: "OFFICIAL_PARTIAL",
11754
- targetMaturity: "F3",
11755
- targetSupportStatus: "OFFICIAL_FULL",
11756
- validatedVersions: ["5.10", "5.11"],
11757
- keyGapForNextLevel: "GAP-JU-004 lifecycle modeling"
11758
- },
11759
- {
11760
- frameworkId: "nunit",
11761
- entityType: "TEST_FRAMEWORK",
11762
- language: "C#",
11763
- maturity: "F2",
11764
- supportStatus: "OFFICIAL_PARTIAL",
11765
- targetMaturity: "F3",
11766
- targetSupportStatus: "OFFICIAL_FULL",
11767
- validatedVersions: ["3.14", "4.2"],
11768
- keyGapForNextLevel: "GAP-NU-004 lifecycle modeling"
11769
- },
11770
- {
11771
- frameworkId: "xunit",
11772
- entityType: "TEST_FRAMEWORK",
11773
- language: "C#",
11774
- maturity: "F2",
11775
- supportStatus: "OFFICIAL_PARTIAL",
11776
- targetMaturity: "F3",
11777
- targetSupportStatus: "OFFICIAL_FULL",
11778
- validatedVersions: ["2.9"],
11779
- keyGapForNextLevel: "GAP-XU-004 lifecycle modeling"
11780
- },
11781
- {
11782
- frameworkId: "cypress",
11783
- entityType: "E2E_FRAMEWORK",
11784
- language: "TypeScript/JavaScript",
11785
- maturity: "F2",
11786
- supportStatus: "EXPERIMENTAL",
11787
- targetMaturity: "F3",
11788
- targetSupportStatus: "OFFICIAL_PARTIAL",
11789
- validatedVersions: ["13"],
11790
- keyGapForNextLevel: "GAP-CY-004 lifecycle modeling"
11791
- },
11792
- {
11793
- frameworkId: "selenium",
11794
- entityType: "AUTOMATION_LIBRARY",
11795
- language: "Java/TypeScript/Python",
11796
- maturity: "F1",
11797
- supportStatus: "EXPERIMENTAL",
11798
- targetMaturity: "F2",
11799
- targetSupportStatus: "OFFICIAL_PARTIAL",
11800
- validatedVersions: [
11801
- "4.20",
11802
- "4.21",
11803
- "4.22",
11804
- "4.23",
11805
- "4.24",
11806
- "4.25"
11807
- ],
11808
- keyGapForNextLevel: "GAP-SE-002 AST-based usage analysis"
11809
- },
11810
- {
11811
- frameworkId: "testng",
11812
- entityType: "TEST_FRAMEWORK",
11813
- language: "Java",
11814
- maturity: "F1",
11815
- supportStatus: "EXPERIMENTAL",
11816
- targetMaturity: "F2",
11817
- targetSupportStatus: "OFFICIAL_PARTIAL",
11818
- validatedVersions: ["7.10", "7.11"],
11819
- keyGapForNextLevel: "GAP-TN-002 AST-based usage analysis"
11820
- },
11821
- {
11822
- frameworkId: "github-actions",
11823
- entityType: "CI_PROVIDER",
11824
- language: "YAML",
11825
- maturity: "F3",
11826
- supportStatus: "OFFICIAL_PARTIAL",
11827
- targetMaturity: "F4",
11828
- targetSupportStatus: "OFFICIAL_FULL",
11829
- validatedVersions: ["v4"],
11830
- keyGapForNextLevel: "GAP-GHA-004 advanced matrix strategy modeling"
11831
- },
11832
- {
11833
- frameworkId: "azure-devops",
11834
- entityType: "CI_PROVIDER",
11835
- language: "YAML",
11836
- maturity: "F3",
11837
- supportStatus: "OFFICIAL_PARTIAL",
11838
- targetMaturity: "F4",
11839
- targetSupportStatus: "OFFICIAL_FULL",
11840
- validatedVersions: ["2024"],
11841
- keyGapForNextLevel: "GAP-AZ-004 stage dependency graph modeling"
11842
- },
11843
- {
11844
- frameworkId: "jenkins",
11845
- entityType: "CI_PROVIDER",
11846
- language: "Groovy/YAML",
11847
- maturity: "F3",
11848
- supportStatus: "OFFICIAL_PARTIAL",
11849
- targetMaturity: "F4",
11850
- targetSupportStatus: "OFFICIAL_FULL",
11851
- validatedVersions: ["2.440", "2.450"],
11852
- keyGapForNextLevel: "GAP-JK-004 declarative pipeline advanced features"
11853
- },
11854
- {
11855
- frameworkId: "gitlab-ci",
11856
- entityType: "CI_PROVIDER",
11857
- language: "YAML",
11858
- maturity: "F0",
11859
- supportStatus: "UNSUPPORTED",
11860
- targetMaturity: "F1",
11861
- targetSupportStatus: "DISCOVERED",
11862
- validatedVersions: [],
11863
- keyGapForNextLevel: "GAP-GL-001 basic pipeline discovery"
11864
- }
11865
- ];
11866
- function getFrameworkById(frameworkId) {
11867
- return FRAMEWORK_INVENTORY.find((f) => f.frameworkId === frameworkId);
11868
- }
11869
- //#endregion
11870
12632
  //#region src/frameworks/scorecard.ts
11871
12633
  const SCORECARD_DIMENSIONS = [
11872
12634
  "discovery",
@@ -14281,7 +15043,7 @@ function loadPolicy(path) {
14281
15043
  }
14282
15044
  let record;
14283
15045
  try {
14284
- record = parseJsonFile(text, path, isRecord$2);
15046
+ record = parseJsonFile(text, path, isRecord$3);
14285
15047
  } catch (error) {
14286
15048
  throw new ConfigValidationError(`invalid suppression policy: ${path}`, { cause: error });
14287
15049
  }
@@ -14300,7 +15062,7 @@ function loadPolicy(path) {
14300
15062
  };
14301
15063
  }
14302
15064
  function isTrustSnapshot(value) {
14303
- return isRecord$2(value) && typeof value.scanId === "string" && typeof value.timestamp === "string" && (typeof value.score === "number" || value.score === null) && typeof value.findings === "number" && typeof value.errors === "number" && typeof value.warnings === "number" && typeof value.infos === "number" && typeof value.advisory === "number" && typeof value.trustLevel === "string" && typeof value.confidence === "number" && typeof value.evidenceCoverage === "number" && typeof value.inconclusiveRate === "number" && typeof value.partial === "boolean" && typeof value.frameworkCount === "number" && typeof value.ruleCount === "number";
15065
+ return isRecord$3(value) && typeof value.scanId === "string" && typeof value.timestamp === "string" && (typeof value.score === "number" || value.score === null) && typeof value.findings === "number" && typeof value.errors === "number" && typeof value.warnings === "number" && typeof value.infos === "number" && typeof value.advisory === "number" && typeof value.trustLevel === "string" && typeof value.confidence === "number" && typeof value.evidenceCoverage === "number" && typeof value.inconclusiveRate === "number" && typeof value.partial === "boolean" && typeof value.frameworkCount === "number" && typeof value.ruleCount === "number";
14304
15066
  }
14305
15067
  function loadTrustHistory(path) {
14306
15068
  if (!existsSync(path)) return [];
@@ -14326,53 +15088,46 @@ function isStringArray(value) {
14326
15088
  return Array.isArray(value) && value.every((entry) => typeof entry === "string");
14327
15089
  }
14328
15090
  function isValidAnalysisStatus(value) {
14329
- if (!isRecord$2(value)) return false;
15091
+ if (!isRecord$3(value)) return false;
14330
15092
  return (value.discovery === "complete" || value.discovery === "partial") && (value.rules === "complete" || value.rules === "partial") && isNonNegativeInteger(value.skippedFiles) && isFiniteNumber(value.durationMs) && value.durationMs >= 0 && (value.truncationReasons === void 0 || isStringArray(value.truncationReasons)) && (value.rulesCrashed === void 0 || isNonNegativeInteger(value.rulesCrashed));
14331
15093
  }
14332
15094
  function isValidDimension(value) {
14333
- return isRecord$2(value) && typeof value.category === "string" && isFiniteNumber(value.score) && value.score >= 0 && value.score <= 100 && isNonNegativeInteger(value.errors) && isNonNegativeInteger(value.warnings) && isNonNegativeInteger(value.infos);
15095
+ return isRecord$3(value) && typeof value.category === "string" && isFiniteNumber(value.score) && value.score >= 0 && value.score <= 100 && isNonNegativeInteger(value.errors) && isNonNegativeInteger(value.warnings) && isNonNegativeInteger(value.infos);
14334
15096
  }
14335
15097
  function isValidFinding(value) {
14336
- if (!isRecord$2(value)) return false;
15098
+ if (!isRecord$3(value)) return false;
14337
15099
  return typeof value.ruleId === "string" && value.ruleId.length > 0 && typeof value.category === "string" && typeof value.file === "string" && value.file.length > 0 && isPositiveInteger(value.line) && isPositiveInteger(value.column) && typeof value.message === "string" && value.message.length > 0 && typeof value.severity === "string" && typeof value.confidence === "string" && typeof value.findingType === "string" && typeof value.qaImpact === "string" && typeof value.why === "string" && typeof value.fix === "string";
14338
15100
  }
14339
15101
  function isValidTrustSummary(value) {
14340
- if (!isRecord$2(value)) return false;
14341
- return [
14342
- "L0",
14343
- "L1",
14344
- "L2",
14345
- "L3",
14346
- "L4",
14347
- "L5"
14348
- ].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);
15102
+ if (!isRecord$3(value)) return false;
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);
14349
15104
  }
14350
15105
  function isValidProvenance(value) {
14351
- if (!isRecord$2(value)) return false;
15106
+ if (!isRecord$3(value)) return false;
14352
15107
  return isNonNegativeInteger(value.testFiles) && isNonNegativeInteger(value.generatedMarkedFiles) && isNonNegativeInteger(value.codegenLikeFiles) && isFiniteNumber(value.shareMarkedGenerated) && value.shareMarkedGenerated >= 0 && value.shareMarkedGenerated <= 1 && isNonNegativeInteger(value.findingsInGeneratedFiles) && isNonNegativeInteger(value.findingsInUnmarkedFiles) && typeof value.note === "string";
14353
15108
  }
14354
15109
  function isValidForensicVerdicts(value) {
14355
- if (!isRecord$2(value) || !isRecord$2(value.byVerdict)) return false;
15110
+ if (!isRecord$3(value) || !isRecord$3(value.byVerdict)) return false;
14356
15111
  return isNonNegativeInteger(value.classifications) && isNonNegativeInteger(value.inconclusive) && Object.values(value.byVerdict).every((count) => isNonNegativeInteger(count));
14357
15112
  }
14358
15113
  function isValidSummary(value) {
14359
- if (!isRecord$2(value)) return false;
15114
+ if (!isRecord$3(value)) return false;
14360
15115
  return typeof value.digest === "string" && value.digest.length > 0 && isNonNegativeInteger(value.findings) && (value.score === null || isFiniteNumber(value.score) && value.score >= 0 && value.score <= 100) && isNonNegativeInteger(value.errors) && isNonNegativeInteger(value.warnings) && isNonNegativeInteger(value.infos) && isNonNegativeInteger(value.advisory);
14361
15116
  }
14362
15117
  function isValidAnnotation(value) {
14363
- if (!isRecord$2(value)) return false;
15118
+ if (!isRecord$3(value)) return false;
14364
15119
  return typeof value.path === "string" && value.path.length > 0 && isPositiveInteger(value.start_line) && (value.annotation_level === "failure" || value.annotation_level === "warning" || value.annotation_level === "notice") && typeof value.message === "string" && typeof value.ruleId === "string" && value.ruleId.length > 0 && (value.detectorRevision === void 0 || isPositiveInteger(value.detectorRevision)) && typeof value.advisory === "boolean";
14365
15120
  }
14366
15121
  function isValidCompleteness(value) {
14367
- if (!isRecord$2(value)) return false;
15122
+ if (!isRecord$3(value)) return false;
14368
15123
  return typeof value.partial === "boolean" && (value.discovery === "complete" || value.discovery === "partial") && (value.rules === "complete" || value.rules === "partial") && isNonNegativeInteger(value.skippedFiles) && isNonNegativeInteger(value.rulesCrashed) && isStringArray(value.truncationReasons) && typeof value.frameworkDetectionUnknown === "boolean" && isFiniteNumber(value.durationMs) && value.durationMs >= 0;
14369
15124
  }
14370
15125
  function isValidContract(value) {
14371
- if (!isRecord$2(value)) return false;
15126
+ if (!isRecord$3(value)) return false;
14372
15127
  return Number.isSafeInteger(value.contractVersion) && isValidSummary(value.summary) && Array.isArray(value.annotations) && value.annotations.length <= 50 && value.annotations.every((annotation) => isValidAnnotation(annotation)) && typeof value.annotationsTruncated === "boolean" && isValidCompleteness(value.completeness) && (value.trustSummary === void 0 || isValidTrustSummary(value.trustSummary)) && (value.provenance === void 0 || isValidProvenance(value.provenance)) && (value.forensicVerdicts === void 0 || isValidForensicVerdicts(value.forensicVerdicts));
14373
15128
  }
14374
15129
  function isScanResultDocument(value) {
14375
- if (!isRecord$2(value) || value.schemaVersion !== 1) return false;
15130
+ if (!isRecord$3(value) || value.schemaVersion !== 1) return false;
14376
15131
  return (value.score === null || isFiniteNumber(value.score) && value.score >= 0 && value.score <= 100) && typeof value.partial === "boolean" && isStringArray(value.frameworks) && Array.isArray(value.dimensions) && value.dimensions.every((dimension) => isValidDimension(dimension)) && Array.isArray(value.findings) && value.findings.every((finding) => isValidFinding(finding)) && isValidAnalysisStatus(value.analysisStatus) && (value.agenticProfile === void 0 || isValidProvenance(value.agenticProfile)) && (value.trustSummary === void 0 || isValidTrustSummary(value.trustSummary)) && (value.forensicVerdicts === void 0 || isValidForensicVerdicts(value.forensicVerdicts)) && isValidContract(value.contract);
14377
15132
  }
14378
15133
  function emitVerification(verification, json, io) {
@@ -14628,12 +15383,14 @@ async function runCIIntegrityCommand(argv, io = {
14628
15383
  * keeps every existing consumer stable.
14629
15384
  */
14630
15385
  const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isValidFindingRecord, discoverAndParseRuntimeReport, KNOWN_RULE_IDS, OVERLAP_META_BY_RULE_ID, EVIDENCE_OVERRIDES, SUITE_INVALIDATING_RULE_IDS } = scan_pipeline_exports;
15386
+ const DEFAULT_MAX_DURATION_MS = 6e5;
15387
+ const MAX_DURATION_MS = 36e5;
14631
15388
  function parseArgs(argv, onError) {
14632
15389
  const args = {
14633
15390
  target: ".",
14634
15391
  json: false,
14635
15392
  verbose: false,
14636
- maxDurationMs: Number.POSITIVE_INFINITY,
15393
+ maxDurationMs: DEFAULT_MAX_DURATION_MS,
14637
15394
  scopeChanged: false,
14638
15395
  format: "terminal"
14639
15396
  };
@@ -14679,7 +15436,7 @@ function parseArgs(argv, onError) {
14679
15436
  args.base = ref;
14680
15437
  } else if (a === "--max-duration") {
14681
15438
  const v = Number(argv[++i]);
14682
- if (!Number.isFinite(v) || v <= 0) return reject({
15439
+ if (!Number.isFinite(v) || v <= 0 || v * 1e3 > 36e5) return reject({
14683
15440
  flag: "--max-duration",
14684
15441
  token: argv[i]
14685
15442
  });
@@ -14841,14 +15598,22 @@ function validateScanTarget(target, err) {
14841
15598
  return null;
14842
15599
  }
14843
15600
  /**
14844
- * Exit-code decision for a finished scan under the given gate level
14845
- * (audit H-7): the previously-dead config.gate field now selects which
14846
- * 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.
14847
15609
  */
14848
15610
  function exitForFindings(findings, gate) {
14849
- if (gate === "advisory") return 0;
14850
- const gateSeverities = gate === "warning" ? ["error", "warning"] : ["error"];
14851
- 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
+ });
14852
15617
  }
14853
15618
  /** Testable `ci install` handler. Returns the process exit code. */
14854
15619
  function runCiInstall(argv, io = {
@@ -15040,11 +15805,19 @@ function isEntryPoint() {
15040
15805
  return import.meta.url === pathToFileURL(argv1).href;
15041
15806
  }
15042
15807
  }
15043
- if (isEntryPoint()) try {
15044
- process$1.exitCode = await main();
15045
- } catch (err) {
15046
- internalErrorMessage(err, (s) => process$1.stderr.write(s + "\n"), false);
15047
- 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
+ }
15048
15819
  }
15049
15820
  //#endregion
15050
- export { ENGINE_VERSION as CLI_VERSION, EVIDENCE_OVERRIDES, KNOWN_RULE_IDS, 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 };
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