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