mjolnir-qa 2.1.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +410 -2
- 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 +232 -16
- package/dist/cli.mjs +2035 -1262
- package/dist/mcp/stdio.mjs +1559 -517
- package/dist/scan-pipeline-Bg-46cLS.mjs +2 -0
- package/dist/{scan-pipeline-MQGUSey3.mjs → scan-pipeline-CCJdEKNa.mjs} +1722 -718
- package/package.json +59 -8
- package/dist/scan-pipeline-C_Rs4u8N.mjs +0 -2
package/dist/cli.mjs
CHANGED
|
@@ -1,17 +1,297 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { $ as
|
|
3
|
-
import { accessSync, appendFileSync, chmodSync,
|
|
2
|
+
import { $ as BADGE_BAND, A as plainContext, At as isLintFixtureDir, B as shouldUseAscii, Bt as DEDUCTIONS, C as runForensics, Ct as computeCodeText, D as nextStep, Dt as LIMITS, E as buildFooter, Et as DEFAULT_IGNORE_MATCHER, F as padTo, Ft as isRecord$3, G as countOrNull, Gt as deriveEvidenceLevel, H as atomicTempPath, Ht as RULE_CATEGORIES, I as palette, It as parseJsonFile, J as evidenceTag, K as deriveScoreState, Kt as isAdvisoryFinding, L as sanitizeData, Lt as ENGINE_VERSION, M as severityIcon, Mt as isSuppressionActive, N as box, Nt as loadConfig, O as okIcon, Ot as createIgnoreMatcher, P as measure, Pt as readFileBounded, Q as TRUST_RUNGS, R as scoreGauge, Rt as buildEvidenceGraph, S as renderSuppressions, St as parseYamlGuarded, T as FLAKE_GLYPH, Tt as detectFrameworks, U as sweepStaleTempFiles, Ut as SEVERITY_ORDER$1, V as wrapText, Vt as QA_IMPACT_LABELS, W as writeFileAtomic, Wt as TRUST_ORDER, X as testsAnalyzedCell, Y as headlineFor, Z as verdictFor, _t as SCAN_ADAPTERS, at as correlateFindings, b as loadLocalRules, bt as parseAzurePipeline, ct as capForTier, dt as massCeiling, et as SCORE, ft as RETIRED_RULE_IDS, gt as runGit, h as scan_pipeline_exports, ht as resolveGitPath, it as getReachableFiles, j as sectionHeader, jt as ConfigValidationError, k as panel, kt as globToRegExp, lt as computeDimensions, m as runScan$1, mt as getRule, n as KNOWN_RULE_IDS$1, nt as TINT, ot as classifyProvenance, pt as RULES, q as evidenceLevelOf, qt as isValidCategory, rt as buildDependencyGraph, st as computeAgenticProfile, tt as STATUS, ut as deductionFor, v as pluginsGateOpen, vt as SEARCHED_FOR, w as sanitizeErrorText, wt as parseTsFile, x as loadSuppressions, xt as parseWorkflow, y as renderGateNotice, yt as isAzurePipelineFixture, z as shouldColorize, zt as MEASURED_FP } from "./scan-pipeline-CCJdEKNa.mjs";
|
|
3
|
+
import { accessSync, appendFileSync, chmodSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import process$1 from "node:process";
|
|
6
6
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
-
import { createHash
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
8
9
|
import ts, { ts as ts$1 } from "ts-morph";
|
|
9
10
|
import { parse } from "yaml";
|
|
10
|
-
import { execFileSync } from "node:child_process";
|
|
11
11
|
import { createInterface } from "node:readline";
|
|
12
12
|
import { Buffer as Buffer$1 } from "node:buffer";
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
14
|
import { isDeepStrictEqual } from "node:util";
|
|
15
|
+
//#region src/claim-evidence.ts
|
|
16
|
+
/**
|
|
17
|
+
* The one place a rendered surface decides whether it may claim "clean".
|
|
18
|
+
*
|
|
19
|
+
* Trust Constitution laws 1, 2 and 11:
|
|
20
|
+
* 1. Unknown is not Pass.
|
|
21
|
+
* 2. Unsupported is not Pass.
|
|
22
|
+
* 11. Partial is never clean; a complete empty result may be clean, an
|
|
23
|
+
* incomplete empty result is INCONCLUSIVE.
|
|
24
|
+
*
|
|
25
|
+
* The prototype commands each grew their own version of this decision, and
|
|
26
|
+
* each of them got it subtly wrong — a hardcoded zero that read as a
|
|
27
|
+
* measurement, a fabricated pass on an empty run, a partial scan that exited
|
|
28
|
+
* clean. One function, one decision, so a surface cannot be more confident
|
|
29
|
+
* than the evidence it was given.
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* The single determination. Callers may not compute their own.
|
|
33
|
+
*/
|
|
34
|
+
function decideClaim(evidence) {
|
|
35
|
+
if (!evidence.supported) return {
|
|
36
|
+
state: "INCONCLUSIVE",
|
|
37
|
+
exitCode: 2,
|
|
38
|
+
reason: evidence.unsupportedReason ? `UNSUPPORTED: ${evidence.unsupportedReason}` : "UNSUPPORTED: this surface cannot make a claim about the target."
|
|
39
|
+
};
|
|
40
|
+
if (evidence.partial) return {
|
|
41
|
+
state: "INCONCLUSIVE",
|
|
42
|
+
exitCode: 2,
|
|
43
|
+
reason: "PARTIAL: the analysis did not cover the whole surface, so no clean or blocking claim is made."
|
|
44
|
+
};
|
|
45
|
+
if (evidence.blockingFindings > 0) return {
|
|
46
|
+
state: "BLOCKED",
|
|
47
|
+
exitCode: 1,
|
|
48
|
+
reason: `BLOCKED: ${evidence.blockingFindings} finding(s) at the configured gate.`
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
state: "READY",
|
|
52
|
+
exitCode: 0,
|
|
53
|
+
reason: "READY: complete analysis, no findings at the configured gate."
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Fail-closed for a surface that has no measurement of its own. A prototype
|
|
58
|
+
* command that cannot measure must say so rather than render a plausible
|
|
59
|
+
* number, and must not exit clean while doing it.
|
|
60
|
+
*/
|
|
61
|
+
function unmeasuredClaim(surface, why) {
|
|
62
|
+
return {
|
|
63
|
+
state: "INCONCLUSIVE",
|
|
64
|
+
exitCode: 2,
|
|
65
|
+
reason: `UNMEASURED: ${surface} cannot measure this. ${why}`
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The one exit-code matrix (plan V5-002).
|
|
70
|
+
*
|
|
71
|
+
* Every command that finishes an analysis routes its exit code through this
|
|
72
|
+
* function. The previous arrangement had each surface compute its own, and
|
|
73
|
+
* the gaps were not subtle:
|
|
74
|
+
*
|
|
75
|
+
* - `--score` returned `exitForFindings(...)` with no `partial` check at
|
|
76
|
+
* all, so `--score` on a truncated scan with zero findings exited 0. That
|
|
77
|
+
* is the flag CI badge and baseline tooling use.
|
|
78
|
+
* - `exitForFindings` knew nothing about partial, so any caller that forgot
|
|
79
|
+
* the check silently produced a clean result.
|
|
80
|
+
*
|
|
81
|
+
* The order is the contract: an incomplete analysis is INCONCLUSIVE before
|
|
82
|
+
* findings are considered at all. There is no gate setting that turns a
|
|
83
|
+
* partial scan into a pass — `--blocking none` suppresses *findings*, not the
|
|
84
|
+
* fact that the analysis did not finish.
|
|
85
|
+
*/
|
|
86
|
+
function scanExitCode(input) {
|
|
87
|
+
if (input.partial) return 2;
|
|
88
|
+
if (input.gate === "advisory") return 0;
|
|
89
|
+
const gateSeverities = input.gate === "warning" ? ["error", "warning"] : ["error"];
|
|
90
|
+
return input.findings.some((f) => gateSeverities.includes(f.severity) && input.isAdvisory?.(f) !== true) ? 1 : 0;
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region src/frameworks/framework-inventory.ts
|
|
94
|
+
const FRAMEWORK_INVENTORY = [
|
|
95
|
+
{
|
|
96
|
+
frameworkId: "playwright",
|
|
97
|
+
entityType: "E2E_FRAMEWORK",
|
|
98
|
+
language: "TypeScript/JavaScript",
|
|
99
|
+
executorAdapterIds: ["typescript"],
|
|
100
|
+
maturity: "F4",
|
|
101
|
+
supportStatus: "OFFICIAL_PARTIAL",
|
|
102
|
+
targetMaturity: "F5",
|
|
103
|
+
targetSupportStatus: "OFFICIAL_FULL",
|
|
104
|
+
validatedVersions: [
|
|
105
|
+
"1.44",
|
|
106
|
+
"1.45",
|
|
107
|
+
"1.46",
|
|
108
|
+
"1.47",
|
|
109
|
+
"1.48"
|
|
110
|
+
],
|
|
111
|
+
keyGapForNextLevel: "GAP-PW-006 parallelism/worker safety analysis"
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
frameworkId: "jest",
|
|
115
|
+
entityType: "TEST_FRAMEWORK",
|
|
116
|
+
language: "TypeScript/JavaScript",
|
|
117
|
+
executorAdapterIds: ["typescript"],
|
|
118
|
+
maturity: "F3",
|
|
119
|
+
supportStatus: "OFFICIAL_PARTIAL",
|
|
120
|
+
targetMaturity: "F4",
|
|
121
|
+
targetSupportStatus: "OFFICIAL_FULL",
|
|
122
|
+
validatedVersions: ["29", "30"],
|
|
123
|
+
keyGapForNextLevel: "GAP-JEST-004 lifecycle modeling"
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
frameworkId: "vitest",
|
|
127
|
+
entityType: "TEST_FRAMEWORK",
|
|
128
|
+
language: "TypeScript/JavaScript",
|
|
129
|
+
executorAdapterIds: ["typescript"],
|
|
130
|
+
maturity: "F3",
|
|
131
|
+
supportStatus: "OFFICIAL_PARTIAL",
|
|
132
|
+
targetMaturity: "F4",
|
|
133
|
+
targetSupportStatus: "OFFICIAL_FULL",
|
|
134
|
+
validatedVersions: [
|
|
135
|
+
"1.6",
|
|
136
|
+
"2.0",
|
|
137
|
+
"2.1"
|
|
138
|
+
],
|
|
139
|
+
keyGapForNextLevel: "GAP-VIT-004 lifecycle modeling"
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
frameworkId: "pytest",
|
|
143
|
+
entityType: "TEST_FRAMEWORK",
|
|
144
|
+
language: "Python",
|
|
145
|
+
executorAdapterIds: ["python"],
|
|
146
|
+
maturity: "F2",
|
|
147
|
+
supportStatus: "OFFICIAL_PARTIAL",
|
|
148
|
+
targetMaturity: "F3",
|
|
149
|
+
targetSupportStatus: "OFFICIAL_FULL",
|
|
150
|
+
validatedVersions: [
|
|
151
|
+
"7.4",
|
|
152
|
+
"8.0",
|
|
153
|
+
"8.1",
|
|
154
|
+
"8.2",
|
|
155
|
+
"8.3"
|
|
156
|
+
],
|
|
157
|
+
keyGapForNextLevel: "GAP-PY-004 lifecycle modeling"
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
frameworkId: "junit",
|
|
161
|
+
entityType: "TEST_FRAMEWORK",
|
|
162
|
+
language: "Java",
|
|
163
|
+
executorAdapterIds: ["java"],
|
|
164
|
+
maturity: "F2",
|
|
165
|
+
supportStatus: "OFFICIAL_PARTIAL",
|
|
166
|
+
targetMaturity: "F3",
|
|
167
|
+
targetSupportStatus: "OFFICIAL_FULL",
|
|
168
|
+
validatedVersions: ["5.10", "5.11"],
|
|
169
|
+
keyGapForNextLevel: "GAP-JU-004 lifecycle modeling"
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
frameworkId: "nunit",
|
|
173
|
+
entityType: "TEST_FRAMEWORK",
|
|
174
|
+
language: "C#",
|
|
175
|
+
executorAdapterIds: ["csharp"],
|
|
176
|
+
maturity: "F2",
|
|
177
|
+
supportStatus: "OFFICIAL_PARTIAL",
|
|
178
|
+
targetMaturity: "F3",
|
|
179
|
+
targetSupportStatus: "OFFICIAL_FULL",
|
|
180
|
+
validatedVersions: ["3.14", "4.2"],
|
|
181
|
+
keyGapForNextLevel: "GAP-NU-004 lifecycle modeling"
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
frameworkId: "xunit",
|
|
185
|
+
entityType: "TEST_FRAMEWORK",
|
|
186
|
+
language: "C#",
|
|
187
|
+
executorAdapterIds: ["csharp"],
|
|
188
|
+
maturity: "F2",
|
|
189
|
+
supportStatus: "OFFICIAL_PARTIAL",
|
|
190
|
+
targetMaturity: "F3",
|
|
191
|
+
targetSupportStatus: "OFFICIAL_FULL",
|
|
192
|
+
validatedVersions: ["2.9"],
|
|
193
|
+
keyGapForNextLevel: "GAP-XU-004 lifecycle modeling"
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
frameworkId: "cypress",
|
|
197
|
+
entityType: "E2E_FRAMEWORK",
|
|
198
|
+
language: "TypeScript/JavaScript",
|
|
199
|
+
executorAdapterIds: ["typescript"],
|
|
200
|
+
maturity: "F2",
|
|
201
|
+
supportStatus: "EXPERIMENTAL",
|
|
202
|
+
targetMaturity: "F3",
|
|
203
|
+
targetSupportStatus: "OFFICIAL_PARTIAL",
|
|
204
|
+
validatedVersions: ["13"],
|
|
205
|
+
keyGapForNextLevel: "GAP-CY-004 lifecycle modeling"
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
frameworkId: "selenium",
|
|
209
|
+
entityType: "AUTOMATION_LIBRARY",
|
|
210
|
+
language: "Java/TypeScript/Python",
|
|
211
|
+
executorAdapterIds: [
|
|
212
|
+
"typescript",
|
|
213
|
+
"python",
|
|
214
|
+
"java"
|
|
215
|
+
],
|
|
216
|
+
maturity: "F1",
|
|
217
|
+
supportStatus: "EXPERIMENTAL",
|
|
218
|
+
targetMaturity: "F2",
|
|
219
|
+
targetSupportStatus: "OFFICIAL_PARTIAL",
|
|
220
|
+
validatedVersions: [
|
|
221
|
+
"4.20",
|
|
222
|
+
"4.21",
|
|
223
|
+
"4.22",
|
|
224
|
+
"4.23",
|
|
225
|
+
"4.24",
|
|
226
|
+
"4.25"
|
|
227
|
+
],
|
|
228
|
+
keyGapForNextLevel: "GAP-SE-002 AST-based usage analysis"
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
frameworkId: "testng",
|
|
232
|
+
entityType: "TEST_FRAMEWORK",
|
|
233
|
+
language: "Java",
|
|
234
|
+
executorAdapterIds: ["java"],
|
|
235
|
+
maturity: "F1",
|
|
236
|
+
supportStatus: "EXPERIMENTAL",
|
|
237
|
+
targetMaturity: "F2",
|
|
238
|
+
targetSupportStatus: "OFFICIAL_PARTIAL",
|
|
239
|
+
validatedVersions: ["7.10", "7.11"],
|
|
240
|
+
keyGapForNextLevel: "GAP-TN-002 AST-based usage analysis"
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
frameworkId: "github-actions",
|
|
244
|
+
entityType: "CI_PROVIDER",
|
|
245
|
+
language: "YAML",
|
|
246
|
+
executorAdapterIds: ["github-actions"],
|
|
247
|
+
maturity: "F3",
|
|
248
|
+
supportStatus: "OFFICIAL_PARTIAL",
|
|
249
|
+
targetMaturity: "F4",
|
|
250
|
+
targetSupportStatus: "OFFICIAL_FULL",
|
|
251
|
+
validatedVersions: ["v4"],
|
|
252
|
+
keyGapForNextLevel: "GAP-GHA-004 advanced matrix strategy modeling"
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
frameworkId: "azure-devops",
|
|
256
|
+
entityType: "CI_PROVIDER",
|
|
257
|
+
language: "YAML",
|
|
258
|
+
executorAdapterIds: ["azure-pipelines"],
|
|
259
|
+
maturity: "F3",
|
|
260
|
+
supportStatus: "OFFICIAL_PARTIAL",
|
|
261
|
+
targetMaturity: "F4",
|
|
262
|
+
targetSupportStatus: "OFFICIAL_FULL",
|
|
263
|
+
validatedVersions: ["2024"],
|
|
264
|
+
keyGapForNextLevel: "GAP-AZ-004 stage dependency graph modeling"
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
frameworkId: "jenkins",
|
|
268
|
+
entityType: "CI_PROVIDER",
|
|
269
|
+
language: "Groovy/YAML",
|
|
270
|
+
executorAdapterIds: ["jenkins"],
|
|
271
|
+
maturity: "F3",
|
|
272
|
+
supportStatus: "OFFICIAL_PARTIAL",
|
|
273
|
+
targetMaturity: "F4",
|
|
274
|
+
targetSupportStatus: "OFFICIAL_FULL",
|
|
275
|
+
validatedVersions: ["2.440", "2.450"],
|
|
276
|
+
keyGapForNextLevel: "GAP-JK-004 declarative pipeline advanced features"
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
frameworkId: "gitlab-ci",
|
|
280
|
+
entityType: "CI_PROVIDER",
|
|
281
|
+
language: "YAML",
|
|
282
|
+
executorAdapterIds: [],
|
|
283
|
+
maturity: "F0",
|
|
284
|
+
supportStatus: "UNSUPPORTED",
|
|
285
|
+
targetMaturity: "F1",
|
|
286
|
+
targetSupportStatus: "DISCOVERED",
|
|
287
|
+
validatedVersions: [],
|
|
288
|
+
keyGapForNextLevel: "GAP-GL-001 basic pipeline discovery"
|
|
289
|
+
}
|
|
290
|
+
];
|
|
291
|
+
function getFrameworkById(frameworkId) {
|
|
292
|
+
return FRAMEWORK_INVENTORY.find((f) => f.frameworkId === frameworkId);
|
|
293
|
+
}
|
|
294
|
+
//#endregion
|
|
15
295
|
//#region src/commands/help.ts
|
|
16
296
|
/** Ordered registry — grouped by the same categories the overview uses. */
|
|
17
297
|
const HELP_ENTRIES = [
|
|
@@ -247,6 +527,80 @@ const HELP_ENTRIES = [
|
|
|
247
527
|
summary: "run as a read-only MCP server over stdio (scan / explain / diff / verify / forensics / triage / pw-report)",
|
|
248
528
|
usage: "mjolnir mcp",
|
|
249
529
|
examples: ["mjolnir mcp"]
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
verb: "release-report",
|
|
533
|
+
summary: "release readiness verdict: GO, CONDITIONAL GO, or NO-GO",
|
|
534
|
+
usage: "mjolnir release-report [path] [--since <ref>] [--history <json>]",
|
|
535
|
+
examples: ["mjolnir release-report . --since HEAD~1"]
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
verb: "report",
|
|
539
|
+
summary: "generate a Playwright-compatible report from scan results",
|
|
540
|
+
usage: "mjolnir report [path] [--output <file>]",
|
|
541
|
+
examples: ["mjolnir report . --output playwright-report.json"]
|
|
542
|
+
},
|
|
543
|
+
{
|
|
544
|
+
verb: "trend",
|
|
545
|
+
summary: "record, show, or diff local quality trend snapshots",
|
|
546
|
+
usage: "mjolnir trend <record|show|diff> [path] [--limit <N>]",
|
|
547
|
+
examples: ["mjolnir trend record .", "mjolnir trend show ."]
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
verb: "exec-report",
|
|
551
|
+
summary: "generate executive quality KPIs, risk, and recommendations (advisory)",
|
|
552
|
+
usage: "mjolnir exec-report [path]",
|
|
553
|
+
examples: ["mjolnir exec-report ."]
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
verb: "policy",
|
|
557
|
+
summary: "initialize, validate, or check team quality policy gates",
|
|
558
|
+
usage: "mjolnir policy <init|validate|check> [path] [--policy <file>]",
|
|
559
|
+
examples: ["mjolnir policy init .", "mjolnir policy check . --policy mjolnir.policy.json"]
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
verb: "quarantine",
|
|
563
|
+
summary: "review deterministic quarantine proposals (read-only prototype)",
|
|
564
|
+
usage: "mjolnir quarantine <list|review|stats> [path]",
|
|
565
|
+
examples: ["mjolnir quarantine list .", "mjolnir quarantine stats"]
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
verb: "analyze",
|
|
569
|
+
summary: "run bounded cross-file analysis with explicit findings",
|
|
570
|
+
usage: "mjolnir analyze [path] --cross-file",
|
|
571
|
+
examples: ["mjolnir analyze . --cross-file"]
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
verb: "ci-adapter",
|
|
575
|
+
summary: "generate GitHub Actions, GitLab CI, or Jenkins templates",
|
|
576
|
+
usage: "mjolnir ci-adapter <github|gitlab|jenkins> [target]",
|
|
577
|
+
examples: ["mjolnir ci-adapter github ."]
|
|
578
|
+
},
|
|
579
|
+
{
|
|
580
|
+
verb: "dashboard",
|
|
581
|
+
summary: "generate a self-contained quality dashboard HTML artifact",
|
|
582
|
+
usage: "mjolnir dashboard [path] [--output <file>]",
|
|
583
|
+
examples: ["mjolnir dashboard . --output dashboard.html"]
|
|
584
|
+
},
|
|
585
|
+
{
|
|
586
|
+
verb: "enterprise",
|
|
587
|
+
summary: "capability manifest for deployment review — records what this product does NOT provide (no server, no SSO, no hosted tier, no compliance packet)",
|
|
588
|
+
usage: "mjolnir enterprise config [output-dir]",
|
|
589
|
+
examples: ["mjolnir enterprise config ./enterprise-output"],
|
|
590
|
+
next: "sso and compliance subcommands were removed in 4.0 — they wrote artifacts describing capabilities Mjölnir does not have. Read the manifest's `notProvided` list instead."
|
|
591
|
+
},
|
|
592
|
+
{
|
|
593
|
+
verb: "business-case",
|
|
594
|
+
summary: "measured false-positive rates per finding; a cost figure only with --incident-cost",
|
|
595
|
+
usage: "mjolnir business-case [path] [--incident-cost <n>] [--strict]",
|
|
596
|
+
examples: ["mjolnir business-case .", "mjolnir business-case . --incident-cost 25000"],
|
|
597
|
+
next: "Mjölnir will not estimate the cost of a false-green incident for you — that number has to come from your own incident history."
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
verb: "maturity",
|
|
601
|
+
summary: "presence of named QA artifacts (a signal, not a maturity score)",
|
|
602
|
+
usage: "mjolnir maturity <assess|levels> [path]",
|
|
603
|
+
examples: ["mjolnir maturity assess .", "mjolnir maturity levels"]
|
|
250
604
|
}
|
|
251
605
|
];
|
|
252
606
|
/** Scan-flag entries documented per-flag via the overview. */
|
|
@@ -400,7 +754,9 @@ const GROUPS = [
|
|
|
400
754
|
"baseline",
|
|
401
755
|
"diff",
|
|
402
756
|
"verify",
|
|
403
|
-
"ci-integrity"
|
|
757
|
+
"ci-integrity",
|
|
758
|
+
"ci-adapter",
|
|
759
|
+
"policy"
|
|
404
760
|
]
|
|
405
761
|
},
|
|
406
762
|
{
|
|
@@ -433,7 +789,17 @@ const GROUPS = [
|
|
|
433
789
|
"trust-trend",
|
|
434
790
|
"evidence-graph",
|
|
435
791
|
"framework-maturity",
|
|
436
|
-
"suppression-gate"
|
|
792
|
+
"suppression-gate",
|
|
793
|
+
"business-case",
|
|
794
|
+
"release-report",
|
|
795
|
+
"report",
|
|
796
|
+
"trend",
|
|
797
|
+
"exec-report",
|
|
798
|
+
"quarantine",
|
|
799
|
+
"analyze",
|
|
800
|
+
"dashboard",
|
|
801
|
+
"enterprise",
|
|
802
|
+
"maturity"
|
|
437
803
|
]
|
|
438
804
|
},
|
|
439
805
|
{
|
|
@@ -554,19 +920,146 @@ function renderRootHelp(schemaVersion = 1, options = {}) {
|
|
|
554
920
|
}
|
|
555
921
|
lines.push("");
|
|
556
922
|
}
|
|
557
|
-
lines.push("Copy-paste starts:");
|
|
558
|
-
lines.push(" $ mjolnir score this repo's test suite");
|
|
559
|
-
lines.push(" $ mjolnir --scope changed CI gate: only what the branch touched");
|
|
560
|
-
lines.push(" $ mjolnir ci install write the PR workflow");
|
|
561
|
-
lines.push(" $ mjolnir forensics test-results where the flakes hide");
|
|
562
|
-
lines.push("");
|
|
563
|
-
lines.push("Per-command help: mjolnir help <verb> (e.g. mjolnir help fix)");
|
|
564
|
-
lines.push("");
|
|
565
|
-
lines.push(`Exit codes: ${EXIT_CODE_TABLE.map(([c]) => c).join(" · ")}`);
|
|
566
|
-
for (const [code, meaning] of EXIT_CODE_TABLE) lines.push(` ${code.padEnd(3)} ${meaning}`);
|
|
567
|
-
lines.push("");
|
|
568
|
-
lines.push(`Docs: ${DOCS_URL} (JSON schemaVersion ${schemaVersion}, additive-only)`);
|
|
569
|
-
return lines.join("\n");
|
|
923
|
+
lines.push("Copy-paste starts:");
|
|
924
|
+
lines.push(" $ mjolnir score this repo's test suite");
|
|
925
|
+
lines.push(" $ mjolnir --scope changed CI gate: only what the branch touched");
|
|
926
|
+
lines.push(" $ mjolnir ci install write the PR workflow");
|
|
927
|
+
lines.push(" $ mjolnir forensics test-results where the flakes hide");
|
|
928
|
+
lines.push("");
|
|
929
|
+
lines.push("Per-command help: mjolnir help <verb> (e.g. mjolnir help fix)");
|
|
930
|
+
lines.push("");
|
|
931
|
+
lines.push(`Exit codes: ${EXIT_CODE_TABLE.map(([c]) => c).join(" · ")}`);
|
|
932
|
+
for (const [code, meaning] of EXIT_CODE_TABLE) lines.push(` ${code.padEnd(3)} ${meaning}`);
|
|
933
|
+
lines.push("");
|
|
934
|
+
lines.push(`Docs: ${DOCS_URL} (JSON schemaVersion ${schemaVersion}, additive-only)`);
|
|
935
|
+
return lines.join("\n");
|
|
936
|
+
}
|
|
937
|
+
//#endregion
|
|
938
|
+
//#region src/integrations/sentry.ts
|
|
939
|
+
/**
|
|
940
|
+
* Sentry crash reporting — opt-in, and inert unless a DSN is configured.
|
|
941
|
+
*
|
|
942
|
+
* Why opt-in: Mjölnir ships as an `npx` CLI over other people's private
|
|
943
|
+
* repositories. A tool that reads a codebase must never phone home on its
|
|
944
|
+
* own, so the ONLY switch is an explicit `SENTRY_DSN` in the environment.
|
|
945
|
+
* Absent that, every function here returns immediately and `@sentry/node` is
|
|
946
|
+
* never imported — zero network, zero added startup cost on a path whose
|
|
947
|
+
* whole selling point is scan speed.
|
|
948
|
+
*
|
|
949
|
+
* Why an optional peer dependency: `@sentry/node` is ~1.5 MB unpacked and
|
|
950
|
+
* nothing in a code scanner needs it. Making it a hard `dependency` would
|
|
951
|
+
* tax every install for a feature most users never turn on, so it is an
|
|
952
|
+
* optional peer: installs on request (`npm i @sentry/node`), absent by
|
|
953
|
+
* default, and a missing module is handled as "reporting unavailable"
|
|
954
|
+
* rather than as a crash.
|
|
955
|
+
*
|
|
956
|
+
* Capture points are deliberately the two EXISTING top-level catch blocks
|
|
957
|
+
* (cli.ts entry, mcp/stdio.ts) rather than `process.on("uncaughtException")`
|
|
958
|
+
* / `("unhandledRejection")` handlers. Registering those would silently
|
|
959
|
+
* change the CLI's frozen exit-code contract (§24.1) and could keep a
|
|
960
|
+
* process alive after a fatal error. One capture point per surface is
|
|
961
|
+
* enough to answer "did Mjölnir itself break, and where".
|
|
962
|
+
*
|
|
963
|
+
* What is deliberately NOT sent: no user data (`sendDefaultPii: false`), no
|
|
964
|
+
* traced request/span data (`tracesSampleRate: 0` — a batch CLI has no
|
|
965
|
+
* request to trace), no console breadcrumbs, and no scan findings or file
|
|
966
|
+
* contents. Events carry the error, its stack, the release, and the surface
|
|
967
|
+
* that raised it.
|
|
968
|
+
*/
|
|
969
|
+
/** The loaded SDK, or null while reporting is off/unavailable. */
|
|
970
|
+
let sdk = null;
|
|
971
|
+
/**
|
|
972
|
+
* Flush budget for the process-exit paths. Long enough for one HTTPS
|
|
973
|
+
* envelope on a slow link, short enough that a broken network cannot turn
|
|
974
|
+
* a crashed CLI into a hung one. Sentry's own default is 2s.
|
|
975
|
+
*/
|
|
976
|
+
const FLUSH_TIMEOUT_MS = 2e3;
|
|
977
|
+
/** Writes a diagnostic without touching stdout. */
|
|
978
|
+
function warn(message) {
|
|
979
|
+
process.stderr.write(`mjolnir: sentry — ${message}\n`);
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* The release name Sentry groups events under. Matches the npm package
|
|
983
|
+
* name so a release created from a tag and a release reported by the SDK
|
|
984
|
+
* are the same release, not two half-populated ones.
|
|
985
|
+
*/
|
|
986
|
+
const SENTRY_RELEASE = `mjolnir-qa@${ENGINE_VERSION}`;
|
|
987
|
+
/**
|
|
988
|
+
* Resolve the environment tag. Unset means "a developer's machine" unless
|
|
989
|
+
* CI says otherwise: a `npx` run is not a production event, and filing
|
|
990
|
+
* those under `production` would poison the issue stream with noise from
|
|
991
|
+
* end users triaging their own repos.
|
|
992
|
+
*/
|
|
993
|
+
function resolveEnvironment() {
|
|
994
|
+
const fromEnv = process.env.SENTRY_ENVIRONMENT?.trim();
|
|
995
|
+
if (fromEnv) return fromEnv;
|
|
996
|
+
return process.env.CI ? "ci" : "local";
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Turn reporting on if — and only if — a DSN is configured. Safe to call
|
|
1000
|
+
* from every entry point: the second call is a no-op that returns the
|
|
1001
|
+
* already-decided answer, so a process that reaches two surfaces cannot
|
|
1002
|
+
* initialize the SDK twice.
|
|
1003
|
+
*
|
|
1004
|
+
* Returns true when reporting is live. Never throws: a monitoring
|
|
1005
|
+
* integration that can take down the tool it monitors has failed at its
|
|
1006
|
+
* one job.
|
|
1007
|
+
*/
|
|
1008
|
+
async function initSentry() {
|
|
1009
|
+
if (sdk) return true;
|
|
1010
|
+
const dsn = process.env.SENTRY_DSN?.trim();
|
|
1011
|
+
if (!dsn) return false;
|
|
1012
|
+
let loaded;
|
|
1013
|
+
try {
|
|
1014
|
+
loaded = await import("@sentry/node");
|
|
1015
|
+
} catch {
|
|
1016
|
+
warn("SENTRY_DSN is set but @sentry/node is not installed. Install it with: npm install @sentry/node");
|
|
1017
|
+
return false;
|
|
1018
|
+
}
|
|
1019
|
+
try {
|
|
1020
|
+
loaded.init({
|
|
1021
|
+
dsn,
|
|
1022
|
+
release: SENTRY_RELEASE,
|
|
1023
|
+
environment: resolveEnvironment(),
|
|
1024
|
+
tracesSampleRate: 0,
|
|
1025
|
+
sendDefaultPii: false,
|
|
1026
|
+
attachStacktrace: true
|
|
1027
|
+
});
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
warn(`SDK init failed (${error instanceof Error ? error.message : String(error)}). Crash reporting stays off.`);
|
|
1030
|
+
return false;
|
|
1031
|
+
}
|
|
1032
|
+
sdk = loaded;
|
|
1033
|
+
return true;
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Report one fatal error. `surface` names the entry point that raised it
|
|
1037
|
+
* ("cli" or "mcp") so the issue stream can be split by how Mjölnir was
|
|
1038
|
+
* being used when it broke. A non-Error throw is reported as-is rather
|
|
1039
|
+
* than dropped: a hostile throw value is exactly the case worth seeing.
|
|
1040
|
+
*/
|
|
1041
|
+
function captureInternalError(error, surface) {
|
|
1042
|
+
if (!sdk) return;
|
|
1043
|
+
try {
|
|
1044
|
+
sdk.captureException(error, { tags: { surface } });
|
|
1045
|
+
} catch (captureError) {
|
|
1046
|
+
warn(`capture failed (${captureError instanceof Error ? captureError.message : String(captureError)}).`);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Deliver anything buffered before the process exits. Awaits the SDK's
|
|
1051
|
+
* own transport drain and returns whether the envelope was handed off;
|
|
1052
|
+
* the caller is on an exit path, so the answer is deliberately not
|
|
1053
|
+
* surfaced as a failure — a lost crash report must not change the exit
|
|
1054
|
+
* code a caller (or CI) is about to read.
|
|
1055
|
+
*/
|
|
1056
|
+
async function flushSentry(timeoutMs = FLUSH_TIMEOUT_MS) {
|
|
1057
|
+
if (!sdk) return false;
|
|
1058
|
+
try {
|
|
1059
|
+
return await sdk.flush(timeoutMs);
|
|
1060
|
+
} catch {
|
|
1061
|
+
return false;
|
|
1062
|
+
}
|
|
570
1063
|
}
|
|
571
1064
|
//#endregion
|
|
572
1065
|
//#region src/integrations/ci-install.ts
|
|
@@ -583,8 +1076,10 @@ function renderRootHelp(schemaVersion = 1, options = {}) {
|
|
|
583
1076
|
* `ciInstall` silently overwrote hand-customized workflows. The template now
|
|
584
1077
|
* mirrors the dogfooded `.github/workflows/mjolnir.yml` (pinned action SHAs,
|
|
585
1078
|
* `if: always()` on reporting steps, a real gate step that reads
|
|
586
|
-
* `mjolnir.json`,
|
|
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:
|
|
@@ -672,60 +1206,71 @@ concurrency:
|
|
|
672
1206
|
|
|
673
1207
|
permissions:
|
|
674
1208
|
contents: read
|
|
675
|
-
pull-requests: write
|
|
676
1209
|
|
|
677
1210
|
jobs:
|
|
678
1211
|
scan:
|
|
679
1212
|
runs-on: ubuntu-latest
|
|
680
|
-
# A hung scan must not sit for the 6-hour default.
|
|
681
1213
|
timeout-minutes: 10
|
|
1214
|
+
permissions:
|
|
1215
|
+
contents: read
|
|
682
1216
|
steps:
|
|
683
1217
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
684
1218
|
with:
|
|
685
|
-
fetch-depth: 0
|
|
1219
|
+
fetch-depth: 0
|
|
686
1220
|
persist-credentials: false
|
|
687
|
-
# Scan with the pinned version. No subcommand: npx resolves
|
|
688
|
-
# the package bin directly; passing mjolnir as an arg causes
|
|
689
|
-
# npm 10+ npx to invoke it twice (unknown subcommand).
|
|
690
1221
|
- name: Scan changed code (exit 1/2 is data — the gate step decides)
|
|
691
1222
|
continue-on-error: true
|
|
692
|
-
run: npx --yes https://registry.npmjs.org/mjolnir-qa/-/mjolnir-qa-${
|
|
693
|
-
# Reporting, not gating: a crashed scan leaves mjolnir.json empty/missing
|
|
694
|
-
# and the summary step exits 2/10 — continue-on-error keeps the advisory
|
|
695
|
-
# job green, exactly like the v1 inline script did (the gate step decides).
|
|
1223
|
+
run: npx --yes https://registry.npmjs.org/mjolnir-qa/-/mjolnir-qa-${INSTALL_VERSION}.tgz . --scope changed --json > mjolnir.json
|
|
696
1224
|
- name: Annotations + Job Summary
|
|
697
1225
|
if: always()
|
|
698
1226
|
continue-on-error: true
|
|
699
|
-
run: npx --yes https://registry.npmjs.org/mjolnir-qa/-/mjolnir-qa-${
|
|
1227
|
+
run: npx --yes https://registry.npmjs.org/mjolnir-qa/-/mjolnir-qa-${INSTALL_VERSION}.tgz summary mjolnir.json
|
|
700
1228
|
- name: Render PR comment
|
|
701
1229
|
if: always()
|
|
702
1230
|
continue-on-error: true
|
|
703
|
-
run: npx --yes https://registry.npmjs.org/mjolnir-qa/-/mjolnir-qa-${
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
1231
|
+
run: npx --yes https://registry.npmjs.org/mjolnir-qa/-/mjolnir-qa-${INSTALL_VERSION}.tgz pr-comment --from mjolnir.json > mjolnir-comment.md
|
|
1232
|
+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
1233
|
+
if: always()
|
|
1234
|
+
with:
|
|
1235
|
+
name: mjolnir-report
|
|
1236
|
+
path: mjolnir-comment.md
|
|
1237
|
+
if-no-files-found: warn
|
|
1238
|
+
retention-days: 30
|
|
1239
|
+
${gate === "advisory" ? ` - name: Gate (advisory)
|
|
1240
|
+
if: always()
|
|
1241
|
+
run: echo "Advisory mode — findings reported, never blocking."` : ` - name: Gate (${gate})
|
|
1242
|
+
if: always()
|
|
1243
|
+
run: |
|
|
1244
|
+
node -e '
|
|
1245
|
+
${indentBlock(gateScript(gate), 10)}
|
|
1246
|
+
'`}
|
|
1247
|
+
|
|
1248
|
+
publish:
|
|
1249
|
+
needs: scan
|
|
1250
|
+
if: always() && needs.scan.result != 'cancelled'
|
|
1251
|
+
runs-on: ubuntu-latest
|
|
1252
|
+
timeout-minutes: 5
|
|
1253
|
+
permissions:
|
|
1254
|
+
contents: read
|
|
1255
|
+
pull-requests: write
|
|
1256
|
+
steps:
|
|
1257
|
+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
1258
|
+
with:
|
|
1259
|
+
name: mjolnir-report
|
|
1260
|
+
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
710
1261
|
if: always()
|
|
711
1262
|
continue-on-error: true
|
|
712
|
-
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
713
1263
|
with:
|
|
714
1264
|
script: |
|
|
715
1265
|
const fs = require('fs');
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
const
|
|
724
|
-
owner: context.repo.owner,
|
|
725
|
-
repo: context.repo.repo,
|
|
726
|
-
issue_number: context.issue.number,
|
|
727
|
-
});
|
|
728
|
-
const existing = comments.find((c) => c.body?.startsWith(marker));
|
|
1266
|
+
const body = fs.readFileSync('mjolnir-comment.md', 'utf8');
|
|
1267
|
+
if (!body.trim()) return;
|
|
1268
|
+
const marker = '<!-- mjolnir-report:v2 -->';
|
|
1269
|
+
const comments = await github.paginate(
|
|
1270
|
+
github.rest.issues.listComments,
|
|
1271
|
+
{ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, per_page: 100 },
|
|
1272
|
+
);
|
|
1273
|
+
const existing = comments.find((comment) => comment.body?.includes(marker));
|
|
729
1274
|
if (existing) {
|
|
730
1275
|
await github.rest.issues.updateComment({
|
|
731
1276
|
owner: context.repo.owner,
|
|
@@ -741,20 +1286,9 @@ jobs:
|
|
|
741
1286
|
body,
|
|
742
1287
|
});
|
|
743
1288
|
}
|
|
744
|
-
${gate === "advisory" ? `
|
|
745
|
-
# PR comment, never blocking. The scan step's exit code is visible
|
|
746
|
-
# as the step outcome, but continue-on-error keeps the job green.
|
|
747
|
-
- name: Gate (advisory)
|
|
748
|
-
if: always()
|
|
749
|
-
run: echo "Advisory mode — findings reported, never blocking."` : ` # Gate enforcement: the scan step's own exit code is deliberately
|
|
750
|
-
# neutralized (continue-on-error) so reporting steps always run; THIS
|
|
751
|
-
# step is what fails the job. A partial scan never blocks.
|
|
752
|
-
- name: Gate (${gate})
|
|
1289
|
+
${gate === "advisory" ? ` - name: Advisory note
|
|
753
1290
|
if: always()
|
|
754
|
-
run:
|
|
755
|
-
node -e '
|
|
756
|
-
${indentBlock(gateScript(gate), 10)}
|
|
757
|
-
'`}
|
|
1291
|
+
run: echo "Advisory mode — findings are reported, never blocking."` : ""}
|
|
758
1292
|
`;
|
|
759
1293
|
/** Every gate variant, used to tell "our template" from "hand-customized". */
|
|
760
1294
|
const GATES = [
|
|
@@ -764,23 +1298,35 @@ const GATES = [
|
|
|
764
1298
|
];
|
|
765
1299
|
/**
|
|
766
1300
|
* The action-ref the action-based template pins (P1: distribution).
|
|
767
|
-
* `
|
|
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
|
|
|
@@ -793,69 +1339,77 @@ concurrency:
|
|
|
793
1339
|
|
|
794
1340
|
permissions:
|
|
795
1341
|
contents: read
|
|
796
|
-
pull-requests: write
|
|
797
1342
|
|
|
798
1343
|
jobs:
|
|
799
1344
|
scan:
|
|
800
1345
|
runs-on: ubuntu-latest
|
|
801
|
-
# A hung scan must not sit for the 6-hour default.
|
|
802
1346
|
timeout-minutes: 10
|
|
1347
|
+
permissions:
|
|
1348
|
+
contents: read
|
|
803
1349
|
steps:
|
|
804
1350
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
805
1351
|
with:
|
|
806
|
-
fetch-depth: 0
|
|
1352
|
+
fetch-depth: 0
|
|
807
1353
|
persist-credentials: false
|
|
808
|
-
# The action scans with the published mjolnir-qa package
|
|
809
|
-
# pinned to this CLI release. The composite action is pinned via
|
|
810
|
-
# ACTION_REF. The fail-on input is the gate: it fails the
|
|
811
|
-
# job on findings at the gate and never on a partial scan
|
|
812
|
-
# (exit 2 downgrades to a warning — the frozen exit-code
|
|
813
|
-
# contract). Advisory mode reports, never blocks.
|
|
814
1354
|
- name: Mjölnir verification trust scan
|
|
815
1355
|
id: mjolnir
|
|
816
|
-
|
|
817
|
-
continue-on-error: ${gate === "advisory" ? "true" : "false"}
|
|
1356
|
+
continue-on-error: true
|
|
818
1357
|
uses: ${ACTION_REF}
|
|
819
1358
|
with:
|
|
820
1359
|
scope: changed
|
|
821
1360
|
format: json
|
|
822
1361
|
fail-on: ${gate === "advisory" ? "none" : gate}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
-
|
|
1362
|
+
pr-comment: "false"
|
|
1363
|
+
trust-artifact: "false"
|
|
1364
|
+
annotations: "true"
|
|
1365
|
+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
827
1366
|
if: always()
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1367
|
+
with:
|
|
1368
|
+
name: mjolnir-report
|
|
1369
|
+
path: mjolnir-comment.md
|
|
1370
|
+
if-no-files-found: warn
|
|
1371
|
+
retention-days: 30
|
|
1372
|
+
- name: Gate (${gate})
|
|
831
1373
|
if: always()
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
1374
|
+
shell: bash
|
|
1375
|
+
env:
|
|
1376
|
+
MJ_SCAN_EXIT: \${{ steps.mjolnir.outputs.exit_code }}
|
|
1377
|
+
run: |
|
|
1378
|
+
set -euo pipefail
|
|
1379
|
+
case "\${MJ_SCAN_EXIT:-}" in
|
|
1380
|
+
0) echo "Mjölnir scan clean at the configured gate." ;;
|
|
1381
|
+
1) echo "::error::Mjölnir gate failed: findings at the configured gate."; exit 1 ;;
|
|
1382
|
+
2) echo "::error::Mjölnir scan was PARTIAL — the surface was not fully analyzed, so no clean claim is made."; exit 1 ;;
|
|
1383
|
+
"") echo "::error::Mjölnir produced no exit code; the action step did not run."; exit 1 ;;
|
|
1384
|
+
*) echo "::error::Mjölnir exited \${MJ_SCAN_EXIT} (usage/internal error)."; exit 1 ;;
|
|
1385
|
+
esac
|
|
1386
|
+
|
|
1387
|
+
publish:
|
|
1388
|
+
needs: scan
|
|
1389
|
+
if: always() && needs.scan.result != 'cancelled'
|
|
1390
|
+
runs-on: ubuntu-latest
|
|
1391
|
+
timeout-minutes: 5
|
|
1392
|
+
permissions:
|
|
1393
|
+
contents: read
|
|
1394
|
+
pull-requests: write
|
|
1395
|
+
steps:
|
|
1396
|
+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
1397
|
+
with:
|
|
1398
|
+
name: mjolnir-report
|
|
1399
|
+
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
840
1400
|
if: always()
|
|
841
1401
|
continue-on-error: true
|
|
842
|
-
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
843
1402
|
with:
|
|
844
1403
|
script: |
|
|
845
1404
|
const fs = require('fs');
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
const
|
|
854
|
-
owner: context.repo.owner,
|
|
855
|
-
repo: context.repo.repo,
|
|
856
|
-
issue_number: context.issue.number,
|
|
857
|
-
});
|
|
858
|
-
const existing = listed.data.find((c) => c.body?.startsWith(marker));
|
|
1405
|
+
const body = fs.readFileSync('mjolnir-comment.md', 'utf8');
|
|
1406
|
+
if (!body.trim()) return;
|
|
1407
|
+
const marker = '<!-- mjolnir-report:v2 -->';
|
|
1408
|
+
const comments = await github.paginate(
|
|
1409
|
+
github.rest.issues.listComments,
|
|
1410
|
+
{ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, per_page: 100 },
|
|
1411
|
+
);
|
|
1412
|
+
const existing = comments.find((comment) => comment.body?.includes(marker));
|
|
859
1413
|
if (existing) {
|
|
860
1414
|
await github.rest.issues.updateComment({
|
|
861
1415
|
owner: context.repo.owner,
|
|
@@ -871,17 +1425,9 @@ jobs:
|
|
|
871
1425
|
body,
|
|
872
1426
|
});
|
|
873
1427
|
}
|
|
874
|
-
${gate === "advisory" ? `
|
|
875
|
-
# never blocking — the action ran with fail-on: none and
|
|
876
|
-
# continue-on-error, so even a crashed scan cannot fail this job.
|
|
877
|
-
- name: Gate (advisory)
|
|
1428
|
+
${gate === "advisory" ? ` - name: Advisory note
|
|
878
1429
|
if: always()
|
|
879
|
-
run: echo "Advisory mode — findings reported, never blocking."` :
|
|
880
|
-
# ${gate} exits 1 on findings at the gate and the step is
|
|
881
|
-
# continue-on-error: false, so its failure fails the job. A partial
|
|
882
|
-
# scan never blocks (the action downgrades exit 2 to a warning per
|
|
883
|
-
# the frozen exit-code contract). The reporting steps above ran
|
|
884
|
-
# first (if: always()), so a red gate never suppresses the report.`}
|
|
1430
|
+
run: echo "Advisory mode — findings are reported, never blocking."` : ""}
|
|
885
1431
|
`;
|
|
886
1432
|
/** Multiset line diff — counts only, for the refusal message. */
|
|
887
1433
|
function summarizeContentDiff(existing, incoming) {
|
|
@@ -912,7 +1458,7 @@ function ciInstall(root, gate, options = {}) {
|
|
|
912
1458
|
diffSummary: summarizeContentDiff(current, template)
|
|
913
1459
|
};
|
|
914
1460
|
}
|
|
915
|
-
|
|
1461
|
+
writeFileAtomic(target, template);
|
|
916
1462
|
return {
|
|
917
1463
|
written: target,
|
|
918
1464
|
existed,
|
|
@@ -940,6 +1486,10 @@ function digestView(f) {
|
|
|
940
1486
|
trustLevel: f.trustLevel ?? null,
|
|
941
1487
|
confidence: f.confidence,
|
|
942
1488
|
findingType: f.findingType,
|
|
1489
|
+
qaImpact: f.qaImpact,
|
|
1490
|
+
findingId: f.findingId ?? null,
|
|
1491
|
+
rootCauseId: f.rootCauseId ?? null,
|
|
1492
|
+
deduplicationGroup: f.deduplicationGroup ?? null,
|
|
943
1493
|
fixGroupId: f.fixGroupId ?? null
|
|
944
1494
|
};
|
|
945
1495
|
}
|
|
@@ -960,8 +1510,12 @@ function canonicalScanJson(result) {
|
|
|
960
1510
|
rules: result.analysisStatus.rules,
|
|
961
1511
|
skippedFiles: result.analysisStatus.skippedFiles,
|
|
962
1512
|
rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
|
|
963
|
-
|
|
1513
|
+
parseFallbacks: result.analysisStatus.parseFallbacks ?? 0,
|
|
1514
|
+
truncationReasons: result.analysisStatus.truncationReasons ?? [],
|
|
1515
|
+
reasons: result.analysisStatus.reasons ?? []
|
|
964
1516
|
},
|
|
1517
|
+
scopeIntegrity: result.scopeIntegrity ?? null,
|
|
1518
|
+
runIdentity: result.runIdentity ?? null,
|
|
965
1519
|
findings: result.findings.map(digestView)
|
|
966
1520
|
});
|
|
967
1521
|
}
|
|
@@ -1018,9 +1572,13 @@ function buildMachineContract(result) {
|
|
|
1018
1572
|
rules: result.analysisStatus.rules,
|
|
1019
1573
|
skippedFiles: result.analysisStatus.skippedFiles,
|
|
1020
1574
|
rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
|
|
1575
|
+
...result.analysisStatus.parseFallbacks !== void 0 ? { parseFallbacks: result.analysisStatus.parseFallbacks } : {},
|
|
1021
1576
|
truncationReasons: result.analysisStatus.truncationReasons ?? [],
|
|
1577
|
+
...result.analysisStatus.reasons !== void 0 ? { reasons: result.analysisStatus.reasons } : {},
|
|
1022
1578
|
frameworkDetectionUnknown: result.frameworkDetectionUnknown,
|
|
1023
|
-
durationMs: result.analysisStatus.durationMs
|
|
1579
|
+
durationMs: result.analysisStatus.durationMs,
|
|
1580
|
+
...result.scopeIntegrity !== void 0 ? { scopeIntegrity: result.scopeIntegrity } : {},
|
|
1581
|
+
...result.runIdentity !== void 0 ? { runIdentity: result.runIdentity } : {}
|
|
1024
1582
|
},
|
|
1025
1583
|
...result.trustSummary !== void 0 ? { trustSummary: result.trustSummary } : {},
|
|
1026
1584
|
...result.agenticProfile !== void 0 ? { provenance: result.agenticProfile } : {},
|
|
@@ -1294,7 +1852,7 @@ function whatWouldChangeTheVerdict(r) {
|
|
|
1294
1852
|
/** Structural validation of the loaded JSON (hostile-input safe). */
|
|
1295
1853
|
function parseScanJson(raw) {
|
|
1296
1854
|
try {
|
|
1297
|
-
return parseJsonFile(raw, "scan result", (v) => isRecord$
|
|
1855
|
+
return parseJsonFile(raw, "scan result", (v) => isRecord$3(v) && v.schemaVersion === 1 && Array.isArray(v.findings) && Boolean(v.analysisStatus) && typeof v.partial === "boolean");
|
|
1298
1856
|
} catch {
|
|
1299
1857
|
return;
|
|
1300
1858
|
}
|
|
@@ -1409,122 +1967,6 @@ function pctOf(v) {
|
|
|
1409
1967
|
return `${Math.round(v * 100)}%`;
|
|
1410
1968
|
}
|
|
1411
1969
|
//#endregion
|
|
1412
|
-
//#region src/lib/fs-atomic.ts
|
|
1413
|
-
/**
|
|
1414
|
-
* Atomic file writes (audit S9).
|
|
1415
|
-
*
|
|
1416
|
-
* Every durability-critical write in Mjölnir (baseline, stats, badge,
|
|
1417
|
-
* TRIAGE.md, scaffolded rule files) used to hand-roll
|
|
1418
|
-
* `writeFileSync(path, data)` — a crash mid-write left a TRUNCATED file
|
|
1419
|
-
* at the real path, and a subsequent read (diff, badge endpoint) served
|
|
1420
|
-
* confident nonsense from it.
|
|
1421
|
-
*
|
|
1422
|
-
* `writeFileAtomic` writes to a temp sibling, then RENAMES. On the same
|
|
1423
|
-
* volume rename is atomic: readers see either the complete old file or
|
|
1424
|
-
* the complete new file, never a half-written one. The temp name is
|
|
1425
|
-
* created with `wx` (exclusive) so concurrent writers cannot interleave,
|
|
1426
|
-
* stale temps are cleaned up on failure, and on Windows the rename is
|
|
1427
|
-
* retried briefly because a concurrent reader can hold the destination
|
|
1428
|
-
* open (EBUSY/EPERM).
|
|
1429
|
-
*/
|
|
1430
|
-
function atomicTempPath(path) {
|
|
1431
|
-
return `${path}.mjolnir-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}.tmp`;
|
|
1432
|
-
}
|
|
1433
|
-
/**
|
|
1434
|
-
* Atomically replace `path` with `data`.
|
|
1435
|
-
*/
|
|
1436
|
-
function writeFileAtomic(path, data, opts = {}) {
|
|
1437
|
-
const dir = dirname(path);
|
|
1438
|
-
if (opts.mkdirs !== false && !existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
1439
|
-
const tmp = atomicTempPath(path);
|
|
1440
|
-
let fd;
|
|
1441
|
-
try {
|
|
1442
|
-
fd = openSync(tmp, "wx", opts.mode ?? 420);
|
|
1443
|
-
writeSync(fd, data, null, opts.encoding ?? "utf8");
|
|
1444
|
-
} finally {
|
|
1445
|
-
if (fd !== void 0) closeSync(fd);
|
|
1446
|
-
}
|
|
1447
|
-
try {
|
|
1448
|
-
renameWithWindowsRetry(tmp, path);
|
|
1449
|
-
} catch (err) {
|
|
1450
|
-
try {
|
|
1451
|
-
if (existsSync(tmp)) unlinkSync(tmp);
|
|
1452
|
-
} catch {}
|
|
1453
|
-
throw err;
|
|
1454
|
-
}
|
|
1455
|
-
}
|
|
1456
|
-
/**
|
|
1457
|
-
* renameSync retry loop for Windows: a concurrent reader (another scan,
|
|
1458
|
-
* a badge endpoint, an editor) holding the destination open makes
|
|
1459
|
-
* rename fail with EBUSY/EPERM. A short bounded retry closes the race
|
|
1460
|
-
* without turning an atomic swap into a partial write.
|
|
1461
|
-
*
|
|
1462
|
-
* Internal contract test hook: the platform check keeps this loop off
|
|
1463
|
-
* the POSIX hot path; on win32 the EBUSY/EPERM arms are exercised by
|
|
1464
|
-
* the fs-atomic-retry spec (mocked renameSync).
|
|
1465
|
-
*/
|
|
1466
|
-
const RENAME_RETRIES = 8;
|
|
1467
|
-
const RENAME_RETRY_DELAY_MS = 25;
|
|
1468
|
-
/** Synchronous sleep that does not spin the CPU. */
|
|
1469
|
-
function sleepSync(ms) {
|
|
1470
|
-
try {
|
|
1471
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
1472
|
-
} catch {
|
|
1473
|
-
const until = Date.now() + ms;
|
|
1474
|
-
while (Date.now() < until);
|
|
1475
|
-
}
|
|
1476
|
-
}
|
|
1477
|
-
function renameWithWindowsRetry(from, to) {
|
|
1478
|
-
for (let attempt = 0;; attempt++) try {
|
|
1479
|
-
renameSync(from, to);
|
|
1480
|
-
return;
|
|
1481
|
-
} catch (err) {
|
|
1482
|
-
const code = err?.code;
|
|
1483
|
-
if (process.platform === "win32" && (code === "EBUSY" || code === "EPERM") && attempt < RENAME_RETRIES) {
|
|
1484
|
-
sleepSync(RENAME_RETRY_DELAY_MS);
|
|
1485
|
-
continue;
|
|
1486
|
-
}
|
|
1487
|
-
throw err;
|
|
1488
|
-
}
|
|
1489
|
-
}
|
|
1490
|
-
const STALE_TEMP_AGE_MS = 864e5;
|
|
1491
|
-
const TEMP_PID_RE = /^.+\.mjolnir-([1-9]\d{0,9})-(\d{13})-[0-9a-f]{8}\.tmp$/;
|
|
1492
|
-
function sweepStaleTempFiles(dir) {
|
|
1493
|
-
let swept = 0;
|
|
1494
|
-
let entries;
|
|
1495
|
-
try {
|
|
1496
|
-
entries = readdirSync(dir);
|
|
1497
|
-
} catch {
|
|
1498
|
-
return 0;
|
|
1499
|
-
}
|
|
1500
|
-
const cutoff = Date.now() - STALE_TEMP_AGE_MS;
|
|
1501
|
-
for (const entry of entries) {
|
|
1502
|
-
const match = TEMP_PID_RE.exec(entry);
|
|
1503
|
-
if (!match || Number(match[2]) > cutoff) continue;
|
|
1504
|
-
const path = join(dir, entry);
|
|
1505
|
-
try {
|
|
1506
|
-
const stat = lstatSync(path);
|
|
1507
|
-
if (!stat.isFile() || stat.mtimeMs > cutoff) continue;
|
|
1508
|
-
if (pidAlive(Number(match[1]))) continue;
|
|
1509
|
-
unlinkSync(path);
|
|
1510
|
-
swept++;
|
|
1511
|
-
} catch {
|
|
1512
|
-
continue;
|
|
1513
|
-
}
|
|
1514
|
-
}
|
|
1515
|
-
return swept;
|
|
1516
|
-
}
|
|
1517
|
-
function pidAlive(pid) {
|
|
1518
|
-
if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 2147483647) return true;
|
|
1519
|
-
if (pid === process.pid) return true;
|
|
1520
|
-
try {
|
|
1521
|
-
process.kill(pid, 0);
|
|
1522
|
-
return true;
|
|
1523
|
-
} catch (err) {
|
|
1524
|
-
return err?.code !== "ESRCH";
|
|
1525
|
-
}
|
|
1526
|
-
}
|
|
1527
|
-
//#endregion
|
|
1528
1970
|
//#region src/engine/finding-identity.ts
|
|
1529
1971
|
/**
|
|
1530
1972
|
* Finding Identity (ENGINE-002): deterministic, stable fingerprints for
|
|
@@ -2029,12 +2471,15 @@ function trustVerdictFor(v) {
|
|
|
2029
2471
|
* today the honest command set is repeat/compare/inspect, not
|
|
2030
2472
|
* "collect a trace", because trace.zip is not yet ingested.
|
|
2031
2473
|
*/
|
|
2474
|
+
function safeTriageText(value) {
|
|
2475
|
+
return value.replace(/[;&|`$()\\\r\n]/g, " ").slice(0, 200);
|
|
2476
|
+
}
|
|
2032
2477
|
function nextActionFor(v) {
|
|
2033
2478
|
switch (classifyVerdict(v)) {
|
|
2034
|
-
case "RETRY-DEPENDENT": return `repeat execution of this test (3+ runs) to confirm the flake rate, then quarantine + ticket: ${v.file}`;
|
|
2035
|
-
case "TIMEOUT": return `re-run in isolation to separate slowness from a hang:
|
|
2036
|
-
case "FAILING": return `reproduce
|
|
2037
|
-
case "SKIPPED": return `inspect the skip condition in ${v.file} — a skip after a failure usually hides an environment problem`;
|
|
2479
|
+
case "RETRY-DEPENDENT": return `repeat execution of this test (3+ runs) to confirm the flake rate, then quarantine + ticket: ${safeTriageText(v.file)}`;
|
|
2480
|
+
case "TIMEOUT": return `re-run this test in isolation with a 60-second timeout to separate slowness from a hang: ${safeTriageText(v.file)}`;
|
|
2481
|
+
case "FAILING": return `reproduce this test in isolation, then fix and re-run: ${safeTriageText(v.file)} — ${safeTriageText(v.title)}`;
|
|
2482
|
+
case "SKIPPED": return `inspect the skip condition in ${safeTriageText(v.file)} — a skip after a failure usually hides an environment problem`;
|
|
2038
2483
|
}
|
|
2039
2484
|
}
|
|
2040
2485
|
/** Deterministic guided rows, worst first (same order law as triageRows). */
|
|
@@ -2068,7 +2513,7 @@ function renderTriageWorkflow(report) {
|
|
|
2068
2513
|
}
|
|
2069
2514
|
for (const r of rows) {
|
|
2070
2515
|
const flag = r.classification === "RETRY-DEPENDENT" ? "TRUE-FLAKE" : r.classification === "FAILING" ? "FAILING" : void 0;
|
|
2071
|
-
lines.push(`[${r.classification}${flag ? ` · ${flag}` : ""}] ${r.test} (${r.file})`);
|
|
2516
|
+
lines.push(`[${r.classification}${flag ? ` · ${flag}` : ""}] ${safeTriageText(r.test)} (${safeTriageText(r.file)})`);
|
|
2072
2517
|
for (const e of r.evidence) lines.push(` evidence: ${e}`);
|
|
2073
2518
|
lines.push(` trust: ${r.trustVerdict}`);
|
|
2074
2519
|
lines.push(` next: ${r.nextAction}`);
|
|
@@ -2240,7 +2685,16 @@ function renderPwRunSummary(s) {
|
|
|
2240
2685
|
*/
|
|
2241
2686
|
/** Hard caps (§21 threat model: parameter size + resource bounds). */
|
|
2242
2687
|
const MAX_PARAM_BYTES = 65536;
|
|
2688
|
+
const MAX_DURATION_MS$1 = 36e5;
|
|
2689
|
+
const DEFAULT_MAX_DURATION_MS$1 = 6e5;
|
|
2243
2690
|
const PROTOCOL_VERSION = "2025-06-18";
|
|
2691
|
+
const CONFIGURED_ROOT = process.env.MJOLNIR_WORKSPACE_ROOT ? resolve(process.env.MJOLNIR_WORKSPACE_ROOT) : null;
|
|
2692
|
+
function isPathAllowed(path) {
|
|
2693
|
+
if (!CONFIGURED_ROOT) return true;
|
|
2694
|
+
const candidate = resolve(path);
|
|
2695
|
+
const rel = relative(CONFIGURED_ROOT, candidate);
|
|
2696
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
2697
|
+
}
|
|
2244
2698
|
/** JSON-RPC error codes (subset used here). */
|
|
2245
2699
|
const MCP_ERRORS = {
|
|
2246
2700
|
PARSE: -32700,
|
|
@@ -2359,7 +2813,10 @@ function validateParams(name, args) {
|
|
|
2359
2813
|
if (Buffer$1.byteLength(JSON.stringify(args), "utf8") > MAX_PARAM_BYTES) return `parameters exceed ${MAX_PARAM_BYTES} bytes (threat model §21)`;
|
|
2360
2814
|
if (name === "scan") {
|
|
2361
2815
|
if (typeof args["path"] !== "string" || args["path"].length === 0) return "scan requires a non-empty string `path`";
|
|
2362
|
-
if (args["maxDurationMs"] !== void 0
|
|
2816
|
+
if (args["maxDurationMs"] !== void 0) {
|
|
2817
|
+
const duration = args["maxDurationMs"];
|
|
2818
|
+
if (typeof duration !== "number" || !Number.isFinite(duration) || duration <= 0 || duration > MAX_DURATION_MS$1) return "`maxDurationMs` must be a finite positive number within the server budget";
|
|
2819
|
+
}
|
|
2363
2820
|
}
|
|
2364
2821
|
if (name === "explain") {
|
|
2365
2822
|
if (typeof args["ruleId"] !== "string" || !/^QA-[A-Z]+-\d{3}$/.test(args["ruleId"])) return "explain requires `ruleId` matching /^QA-[A-Z]+-\\d{3}$/";
|
|
@@ -2404,6 +2861,24 @@ async function handleToolCall(call) {
|
|
|
2404
2861
|
message: paramError
|
|
2405
2862
|
}
|
|
2406
2863
|
};
|
|
2864
|
+
const pathArgument = call.args["path"];
|
|
2865
|
+
if (typeof pathArgument === "string" && !isPathAllowed(pathArgument)) return {
|
|
2866
|
+
jsonrpc: "2.0",
|
|
2867
|
+
id: call.id,
|
|
2868
|
+
error: {
|
|
2869
|
+
code: MCP_ERRORS.INVALID_PARAMS,
|
|
2870
|
+
message: "path is outside the configured workspace root"
|
|
2871
|
+
}
|
|
2872
|
+
};
|
|
2873
|
+
const fixturesRoot = call.args["fixturesRoot"];
|
|
2874
|
+
if (typeof fixturesRoot === "string" && !isPathAllowed(fixturesRoot)) return {
|
|
2875
|
+
jsonrpc: "2.0",
|
|
2876
|
+
id: call.id,
|
|
2877
|
+
error: {
|
|
2878
|
+
code: MCP_ERRORS.INVALID_PARAMS,
|
|
2879
|
+
message: "fixturesRoot is outside the configured workspace root"
|
|
2880
|
+
}
|
|
2881
|
+
};
|
|
2407
2882
|
try {
|
|
2408
2883
|
if (call.name === "scan") {
|
|
2409
2884
|
const target = resolve(call.args["path"]);
|
|
@@ -2415,7 +2890,7 @@ async function handleToolCall(call) {
|
|
|
2415
2890
|
message: `scan target does not exist: ${target}`
|
|
2416
2891
|
}
|
|
2417
2892
|
};
|
|
2418
|
-
const maxDurationMs = typeof call.args["maxDurationMs"] === "number" ? call.args["maxDurationMs"] :
|
|
2893
|
+
const maxDurationMs = typeof call.args["maxDurationMs"] === "number" ? call.args["maxDurationMs"] : DEFAULT_MAX_DURATION_MS$1;
|
|
2419
2894
|
const result = await serializeScan(() => runScan$1({
|
|
2420
2895
|
target,
|
|
2421
2896
|
json: true,
|
|
@@ -3034,16 +3509,6 @@ function renderTerminal(result, opts) {
|
|
|
3034
3509
|
appendFooter(lines, result, ui);
|
|
3035
3510
|
return lines.join("\n");
|
|
3036
3511
|
}
|
|
3037
|
-
/**
|
|
3038
|
-
* Contract-stable three-band verdict (property-locked in
|
|
3039
|
-
* tests/scoring-precision.spec.ts). Delegates to the ScoreState model —
|
|
3040
|
-
* 100 keeps returning WORTHY here; the FORGED premium treatment lives
|
|
3041
|
-
* in the dedicated block, not in this public mapping.
|
|
3042
|
-
*/
|
|
3043
|
-
function verdictFor(score) {
|
|
3044
|
-
const verdict = deriveScoreState(score).verdict;
|
|
3045
|
-
return verdict === "FORGED" ? "WORTHY" : verdict;
|
|
3046
|
-
}
|
|
3047
3512
|
/** Appends the score, gauge, verdict, and honesty metadata for the full scan. */
|
|
3048
3513
|
function appendScoreSection(lines, result, p, width, ascii) {
|
|
3049
3514
|
const state = deriveScoreState(result.score);
|
|
@@ -3180,26 +3645,9 @@ function appendNextActions(lines, display, fullResult, ui) {
|
|
|
3180
3645
|
} else if (partial) lines.push(nextStep("mjolnir --verbose", ui));
|
|
3181
3646
|
lines.push("");
|
|
3182
3647
|
}
|
|
3183
|
-
function evidenceTag$1(f) {
|
|
3184
|
-
const level = f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence);
|
|
3185
|
-
let tag = `${level} · ${level === "E2" ? "deterministic" : level === "E1" ? "heuristic" : "observation"}`;
|
|
3186
|
-
if (f.measuredFpRate !== void 0) {
|
|
3187
|
-
tag += ` · measured FP ${Math.round(f.measuredFpRate * 100)}%`;
|
|
3188
|
-
if (f.measuredFpN !== void 0) tag += ` · n=${f.measuredFpN}`;
|
|
3189
|
-
}
|
|
3190
|
-
if (f.trustLevel !== void 0) tag += ` · trust ${f.trustLevel}`;
|
|
3191
|
-
if (f.runtimeCorroboration !== void 0) {
|
|
3192
|
-
const c = f.runtimeCorroboration;
|
|
3193
|
-
let label = "file executed";
|
|
3194
|
-
if (c.level === "defect") label = "defect corroborated";
|
|
3195
|
-
else if (c.level === "test") label = "test executed";
|
|
3196
|
-
tag += ` · runtime: ${label}`;
|
|
3197
|
-
}
|
|
3198
|
-
return `[${tag}]`;
|
|
3199
|
-
}
|
|
3200
3648
|
/** Deterministic per-severity verification hint: what re-running should
|
|
3201
|
-
*
|
|
3202
|
-
*
|
|
3649
|
+
* show after the fix lands. Deduction is the honest, evidence-discounted
|
|
3650
|
+
* number this finding costs right now. */
|
|
3203
3651
|
function verifyHint(f) {
|
|
3204
3652
|
const pts = deductionFor(f);
|
|
3205
3653
|
if (f.severity === "error") return pts > 0 ? `Re-run mjolnir after the change — the gate should stop failing and the score should recover by ${pts}.` : "Re-run mjolnir after the change — the finding should no longer appear.";
|
|
@@ -3212,7 +3660,7 @@ function toCard(f, tone) {
|
|
|
3212
3660
|
severity: f.severity,
|
|
3213
3661
|
loc: `${sanitizeData(f.ruleId)} · ${sanitizeData(f.file)}:${f.line}`,
|
|
3214
3662
|
problem: sanitizeData(problem),
|
|
3215
|
-
evidence: evidenceTag
|
|
3663
|
+
evidence: evidenceTag(f),
|
|
3216
3664
|
impact: `${f.qaImpact} — ${sanitizeData(f.why)}`,
|
|
3217
3665
|
fix: sanitizeData(f.fix),
|
|
3218
3666
|
verify: verifyHint(f)
|
|
@@ -3332,7 +3780,7 @@ function appendFindings(lines, result, counts, verbose, ui, tone) {
|
|
|
3332
3780
|
continue;
|
|
3333
3781
|
}
|
|
3334
3782
|
const groupHead = ` ${severityIcon(maxSeverity(unit.findings), ui)} ${p.bold(sanitizeData(unit.ruleId))} ${p.dim(`× ${n} — same fix applies`)}`;
|
|
3335
|
-
const groupEvidence = evidenceTag
|
|
3783
|
+
const groupEvidence = evidenceTag(first);
|
|
3336
3784
|
if (measure(`${groupHead} ${groupEvidence}`) <= width) lines.push(`${groupHead} ${p.dim(groupEvidence)}`);
|
|
3337
3785
|
else {
|
|
3338
3786
|
lines.push(groupHead);
|
|
@@ -3421,7 +3869,7 @@ function appendFooter(lines, result, ui) {
|
|
|
3421
3869
|
complete: result.analysisStatus.discovery !== "partial",
|
|
3422
3870
|
durationMs: result.analysisStatus.durationMs
|
|
3423
3871
|
}));
|
|
3424
|
-
const advisory = result.findings.filter((f) => (f
|
|
3872
|
+
const advisory = result.findings.filter((f) => evidenceLevelOf(f) === "E0").length;
|
|
3425
3873
|
if (advisory > 0) pushWrapped(lines, p, `${advisory} advisory finding${advisory === 1 ? "" : "s"} (E0 — observation only, no score impact)`, width);
|
|
3426
3874
|
if (result.findings.length > 0) {
|
|
3427
3875
|
const firedRuleIds = new Set(result.findings.map((f) => f.ruleId));
|
|
@@ -3483,29 +3931,89 @@ function countBySeverity(result) {
|
|
|
3483
3931
|
}
|
|
3484
3932
|
return counts;
|
|
3485
3933
|
}
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3934
|
+
//#endregion
|
|
3935
|
+
//#region src/engine/trust-classification.ts
|
|
3936
|
+
/**
|
|
3937
|
+
* Completeness, decided once.
|
|
3938
|
+
*
|
|
3939
|
+
* Every branch here treats ABSENCE as "not proven complete" rather than as
|
|
3940
|
+
* "complete". That is the whole point: a missing `analysisStatus` is not a
|
|
3941
|
+
* clean bill of health, a zero ceiling reason is not a claim, and treating
|
|
3942
|
+
* either as completeness is how an unmeasured run gets to say "trust them".
|
|
3943
|
+
*/
|
|
3944
|
+
function isComplete(result, summary) {
|
|
3945
|
+
if (result.partial) return false;
|
|
3946
|
+
if (summary.ceilingReasons.length > 0) return false;
|
|
3947
|
+
const status = result.analysisStatus;
|
|
3948
|
+
if (status === void 0) return false;
|
|
3949
|
+
if (status.discovery !== "complete" || status.rules !== "complete") return false;
|
|
3950
|
+
if ((status.skippedFiles ?? 0) > 0) return false;
|
|
3951
|
+
if ((status.rulesCrashed ?? 0) > 0) return false;
|
|
3952
|
+
return (status.reasons ?? []).length === 0;
|
|
3953
|
+
}
|
|
3954
|
+
/**
|
|
3955
|
+
* The determination. Every surface calls this; none of them re-derives it.
|
|
3956
|
+
*/
|
|
3957
|
+
function classifyTrust(result, summary) {
|
|
3958
|
+
const gaps = [];
|
|
3959
|
+
const complete = isComplete(result, summary);
|
|
3960
|
+
if (result.partial) gaps.push("the scan was partial");
|
|
3961
|
+
const status = result.analysisStatus;
|
|
3962
|
+
if (status === void 0) gaps.push("the report carries no analysisStatus, so completeness is unknown");
|
|
3963
|
+
else {
|
|
3964
|
+
if (status.discovery !== "complete") gaps.push("discovery was truncated");
|
|
3965
|
+
if (status.rules !== "complete") gaps.push("rule evaluation was truncated");
|
|
3966
|
+
if ((status.skippedFiles ?? 0) > 0) gaps.push(`${status.skippedFiles} file(s) were skipped`);
|
|
3967
|
+
if ((status.rulesCrashed ?? 0) > 0) gaps.push(`${status.rulesCrashed} rule(s) crashed`);
|
|
3968
|
+
for (const reason of status.reasons ?? []) if (!gaps.some((g) => g.includes(reason))) gaps.push(reason);
|
|
3969
|
+
}
|
|
3970
|
+
for (const ceiling of summary.ceilingReasons) gaps.push(`confidence ceiling: ${ceiling}`);
|
|
3971
|
+
if (!complete) return {
|
|
3972
|
+
claim: "INCOMPLETE",
|
|
3973
|
+
order: 0,
|
|
3974
|
+
licensesClean: false,
|
|
3975
|
+
reason: "the analysis did not finish, so it proves nothing about the surface it did not reach",
|
|
3976
|
+
gaps
|
|
3977
|
+
};
|
|
3978
|
+
const level = summary.level;
|
|
3979
|
+
const rank = TRUST_ORDER.indexOf(level);
|
|
3980
|
+
const confident = summary.confidence >= .75;
|
|
3981
|
+
if (rank >= TRUST_ORDER.indexOf("L4")) return {
|
|
3982
|
+
claim: "RUN_EVIDENCE_BACKS_FINDINGS",
|
|
3983
|
+
order: 5,
|
|
3984
|
+
licensesClean: result.findings.length > 0,
|
|
3985
|
+
reason: confident ? "a real run corroborates these findings" : "a run exists but coverage is thin — verify the gaps",
|
|
3986
|
+
gaps
|
|
3987
|
+
};
|
|
3988
|
+
if (rank >= TRUST_ORDER.indexOf("L3")) return {
|
|
3989
|
+
claim: "RUNTIME_CORROBORATED",
|
|
3990
|
+
order: 4,
|
|
3991
|
+
licensesClean: result.findings.length > 0,
|
|
3992
|
+
reason: confident ? "the relevant files executed — static and runtime signal agree" : "files executed, but evidence is thin — treat findings as leads",
|
|
3993
|
+
gaps
|
|
3994
|
+
};
|
|
3995
|
+
if (level === "L0" || summary.evidenceCoverage === 0) return {
|
|
3996
|
+
claim: "NO_EVIDENCE",
|
|
3997
|
+
order: 1,
|
|
3998
|
+
licensesClean: false,
|
|
3999
|
+
reason: "static signal only, with no runtime evidence — an observation, not a verdict",
|
|
4000
|
+
gaps
|
|
4001
|
+
};
|
|
4002
|
+
if (confident) return {
|
|
4003
|
+
claim: "DETERMINISTIC_STATIC",
|
|
4004
|
+
order: 3,
|
|
4005
|
+
licensesClean: true,
|
|
4006
|
+
reason: "deterministic static analysis, complete but uncorroborated by a run",
|
|
4007
|
+
gaps
|
|
4008
|
+
};
|
|
4009
|
+
return {
|
|
4010
|
+
claim: "THIN_STATIC_SIGNAL",
|
|
4011
|
+
order: 2,
|
|
4012
|
+
licensesClean: true,
|
|
4013
|
+
reason: "static analysis is complete but low-confidence — treat findings as leads",
|
|
4014
|
+
gaps
|
|
4015
|
+
};
|
|
4016
|
+
}
|
|
3509
4017
|
//#endregion
|
|
3510
4018
|
//#region src/lib/format.ts
|
|
3511
4019
|
/**
|
|
@@ -3517,11 +4025,6 @@ function pct$1(v) {
|
|
|
3517
4025
|
return `${Math.round(v * 100)}%`;
|
|
3518
4026
|
}
|
|
3519
4027
|
//#endregion
|
|
3520
|
-
//#region src/reporter/evidence-tag.ts
|
|
3521
|
-
function evidenceTag(f) {
|
|
3522
|
-
return f.runtimeCorroboration ? f.runtimeCorroboration.level === "defect" ? "run corroborated" : "run executed" : (f.evidenceLevel ?? "E2") === "E2" ? "deterministic" : "pattern";
|
|
3523
|
-
}
|
|
3524
|
-
//#endregion
|
|
3525
4028
|
//#region src/reporter/trust-report.ts
|
|
3526
4029
|
/**
|
|
3527
4030
|
* The rung labels, built from `src/brand/symbols.ts` rather than typed
|
|
@@ -3540,22 +4043,34 @@ function evidenceTag(f) {
|
|
|
3540
4043
|
*/
|
|
3541
4044
|
const TRUST_LABELS = Object.fromEntries(TRUST_RUNGS.map((r) => [r.level, `${r.level} · ${r.meaning}${r.runtime ? " · runtime" : ""}`]));
|
|
3542
4045
|
/**
|
|
3543
|
-
* The human verdict line
|
|
3544
|
-
*
|
|
3545
|
-
*
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
4046
|
+
* The human verdict line, rendered from the ONE determination.
|
|
4047
|
+
*
|
|
4048
|
+
* This used to be a band mapping over (level, confidence) living here, in the
|
|
4049
|
+
* reporter — engine knowledge in a presentation layer, which is how a second
|
|
4050
|
+
* surface ends up disagreeing with the first. The decision now lives in
|
|
4051
|
+
* `src/engine/trust-classification.ts` and every surface renders the same
|
|
4052
|
+
* answer; this function only chooses words.
|
|
4053
|
+
*/
|
|
4054
|
+
function trustHeadline(s, classification) {
|
|
4055
|
+
const claim = classification?.claim;
|
|
4056
|
+
if (claim !== void 0) switch (claim) {
|
|
4057
|
+
case "RUN_EVIDENCE_BACKS_FINDINGS": return s.confidence >= .75 ? "Run evidence backs these findings — trust them." : "Run evidence exists, but the scan was incomplete — verify the gaps.";
|
|
4058
|
+
case "RUNTIME_CORROBORATED": return s.confidence >= .75 ? "The relevant files executed — solid static + runtime signal." : "Files executed, evidence is thin — treat findings as leads.";
|
|
4059
|
+
case "DETERMINISTIC_STATIC": return "Deterministic static analysis — trustworthy, uncorroborated by a run.";
|
|
4060
|
+
case "THIN_STATIC_SIGNAL": return "Static signal only, incomplete analysis — treat as leads, not verdicts.";
|
|
4061
|
+
case "NO_EVIDENCE": return "Static signal only, with no runtime evidence — treat as an observation, not a verdict.";
|
|
4062
|
+
case "INCOMPLETE": return "The analysis did not finish — it proves nothing about the surface it did not reach.";
|
|
4063
|
+
}
|
|
4064
|
+
return "Trust could not be established for this run.";
|
|
3552
4065
|
}
|
|
3553
4066
|
/** WHY THIS VERDICT — evidence-backed reasons, each from a real field. */
|
|
3554
4067
|
function trustReasons(result, s) {
|
|
3555
4068
|
const reasons = [];
|
|
3556
4069
|
const corroborated = result.findings.filter((f) => f.runtimeCorroboration !== void 0).length;
|
|
3557
|
-
if (result.findings.length === 0)
|
|
3558
|
-
|
|
4070
|
+
if (result.findings.length === 0) {
|
|
4071
|
+
const incomplete = result.partial || result.analysisStatus.discovery !== "complete" || result.analysisStatus.rules !== "complete" || s.level === "L0" && s.evidenceCoverage === 0;
|
|
4072
|
+
reasons.push(incomplete ? "No findings so far, but the scan or evidence is incomplete — the gaps are not proof of cleanliness." : "No findings fired on the analyzed surface — the scan found nothing to report.");
|
|
4073
|
+
} else reasons.push(`${result.findings.length} finding(s) fired; ${corroborated} corroborated by a real run report.`);
|
|
3559
4074
|
if (s.confidenceCeiling !== void 0) reasons.push(`confidence capped at ${pct$1(s.confidenceCeiling)} by: ${s.ceilingReasons.join(", ")}.`);
|
|
3560
4075
|
if (s.provisionalRuleIds.length > 0) reasons.push(`${s.provisionalRuleIds.length} fired rule(s) unmeasured (author-estimated FP) — disclosed, not counted.`);
|
|
3561
4076
|
else if (s.measuredFpOfFiredRules !== void 0) reasons.push(`every fired rule carries a measured false-positive rate (evidence-weighted ${pct$1(s.measuredFpOfFiredRules)}).`);
|
|
@@ -3580,7 +4095,7 @@ function topTrustRisks(findings, n = 3) {
|
|
|
3580
4095
|
}
|
|
3581
4096
|
/** NEXT ACTION — one concrete command, deterministic. */
|
|
3582
4097
|
function nextAction(result) {
|
|
3583
|
-
if (result.partial) return "re-run with a higher --max-duration to close the truncated surface";
|
|
4098
|
+
if (result.partial || result.analysisStatus.discovery !== "complete" || result.analysisStatus.rules !== "complete" || (result.analysisStatus.rulesCrashed ?? 0) > 0) return "re-run with a higher --max-duration to close the truncated surface";
|
|
3584
4099
|
const risks = topTrustRisks(result.findings, 1);
|
|
3585
4100
|
if (risks.length === 0) return result.testDeclarationCount === 0 ? "add tests — a repo without test declarations has nothing to verify" : "no findings on the analyzed surface — install advisory CI (`mjolnir ci install`) and re-scan the changed scope";
|
|
3586
4101
|
return `mjolnir explain ${risks[0]?.ruleId ?? ""} — then fix the top risk first`;
|
|
@@ -3606,9 +4121,10 @@ function renderTrustReport(result, opts) {
|
|
|
3606
4121
|
provisionalRuleIds: [],
|
|
3607
4122
|
ceilingReasons: []
|
|
3608
4123
|
};
|
|
4124
|
+
const classification = classifyTrust(result, s);
|
|
3609
4125
|
lines.push(sectionHeader("TRUST VERDICT", ui));
|
|
3610
4126
|
lines.push(` ${p.accent(TRUST_LABELS[s.level] ?? s.level)}`);
|
|
3611
|
-
lines.push(` ${trustHeadline(s)}`);
|
|
4127
|
+
lines.push(` ${trustHeadline(s, classification)}`);
|
|
3612
4128
|
lines.push("");
|
|
3613
4129
|
lines.push(sectionHeader("CONFIDENCE", ui));
|
|
3614
4130
|
lines.push(` confidence ${pct$1(s.confidence)}${s.confidenceCeiling !== void 0 ? ` (ceiling ${pct$1(s.confidenceCeiling)})` : ""}`);
|
|
@@ -3617,7 +4133,7 @@ function renderTrustReport(result, opts) {
|
|
|
3617
4133
|
if (s.measuredFpOfFiredRules !== void 0) lines.push(` measured FP (fired) ${pct$1(s.measuredFpOfFiredRules)} — evidence-weighted`);
|
|
3618
4134
|
else if (s.provisionalRuleIds.length > 0) lines.push(` measured FP (fired) PROVISIONAL — unmeasured rules fired: ${s.provisionalRuleIds.slice(0, 3).join(", ")}${s.provisionalRuleIds.length > 3 ? `, +${s.provisionalRuleIds.length - 3} more` : ""}`);
|
|
3619
4135
|
else lines.push(" measured FP (fired) none fired — nothing to weight");
|
|
3620
|
-
lines.push(` tests analyzed ${result.testDeclarationCount
|
|
4136
|
+
lines.push(` tests analyzed ${testsAnalyzedCell(result.testDeclarationCount, result.testFileCount)}`);
|
|
3621
4137
|
lines.push("");
|
|
3622
4138
|
lines.push(sectionHeader("WHY THIS VERDICT", ui));
|
|
3623
4139
|
for (const r of trustReasons(result, s)) lines.push(` - ${r}`);
|
|
@@ -3795,9 +4311,13 @@ function escapeLabel(text) {
|
|
|
3795
4311
|
function classDef(name, tint) {
|
|
3796
4312
|
return ` classDef ${name} fill:${tint.fill},stroke:${tint.stroke},color:${tint.text};`;
|
|
3797
4313
|
}
|
|
4314
|
+
/** Band → the Mermaid class name that paints it. BW-104: this used to
|
|
4315
|
+
* re-declare the 80/50 boundaries, so the diagram could colour a
|
|
4316
|
+
* dimension the terminal called UNWORTHY. It now asks the one model. */
|
|
3798
4317
|
function dimensionStyleClass(dim) {
|
|
3799
|
-
|
|
3800
|
-
if (
|
|
4318
|
+
const band = deriveScoreState(dim.score).band;
|
|
4319
|
+
if (band === "forged" || band === "trusted") return "healthy";
|
|
4320
|
+
if (band === "warning") return "warn";
|
|
3801
4321
|
return "critical";
|
|
3802
4322
|
}
|
|
3803
4323
|
const SEVERITY_ORDER = [
|
|
@@ -3993,6 +4513,312 @@ function currentCommit(root, opts = {}) {
|
|
|
3993
4513
|
}
|
|
3994
4514
|
}
|
|
3995
4515
|
//#endregion
|
|
4516
|
+
//#region src/commands/report-io.ts
|
|
4517
|
+
/**
|
|
4518
|
+
* Shared saved-report loading (agent-handoff plan §9.0).
|
|
4519
|
+
*
|
|
4520
|
+
* `summary`, `handoff` and `why` all consume a saved `--json` report.
|
|
4521
|
+
* One loader, one validation, one error shape — previously
|
|
4522
|
+
* validateReportJson was private to summary.ts; extracting it keeps
|
|
4523
|
+
* the three commands byte-identical in their loading behavior without
|
|
4524
|
+
* duplication. The exit-code mapping stays in the callers (they own
|
|
4525
|
+
* their io), but the error MESSAGES are identical because they come
|
|
4526
|
+
* from here.
|
|
4527
|
+
*/
|
|
4528
|
+
const MAX_REPORT_BYTES = 33554432;
|
|
4529
|
+
const MAX_FINDINGS = 1e4;
|
|
4530
|
+
const CONFIDENCE_VALUES = /* @__PURE__ */ new Set([
|
|
4531
|
+
"high",
|
|
4532
|
+
"medium",
|
|
4533
|
+
"low"
|
|
4534
|
+
]);
|
|
4535
|
+
const FINDING_TYPE_VALUES = /* @__PURE__ */ new Set([
|
|
4536
|
+
"deterministic-defect",
|
|
4537
|
+
"heuristic-risk",
|
|
4538
|
+
"observation"
|
|
4539
|
+
]);
|
|
4540
|
+
const QA_IMPACT_VALUES = /* @__PURE__ */ new Set([
|
|
4541
|
+
"BLOCKS-RELEASE",
|
|
4542
|
+
"FLAKY-RISK",
|
|
4543
|
+
"FALSE-GREEN",
|
|
4544
|
+
"HYGIENE"
|
|
4545
|
+
]);
|
|
4546
|
+
const EVIDENCE_VALUES = /* @__PURE__ */ new Set([
|
|
4547
|
+
"E0",
|
|
4548
|
+
"E1",
|
|
4549
|
+
"E2"
|
|
4550
|
+
]);
|
|
4551
|
+
const TRUST_VALUES = new Set(TRUST_ORDER);
|
|
4552
|
+
/** Human message for any thrown value — never "undefined"/"[object Object]". */
|
|
4553
|
+
function errorText$1(err) {
|
|
4554
|
+
if (err instanceof Error) return err.message;
|
|
4555
|
+
if (typeof err === "string") return err;
|
|
4556
|
+
if (typeof err === "object" && err !== null) return JSON.stringify(err);
|
|
4557
|
+
return String(err);
|
|
4558
|
+
}
|
|
4559
|
+
function isRecord$2(value) {
|
|
4560
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4561
|
+
}
|
|
4562
|
+
function fail(message) {
|
|
4563
|
+
throw new Error(message);
|
|
4564
|
+
}
|
|
4565
|
+
function requireRecord(value, label) {
|
|
4566
|
+
if (!isRecord$2(value)) fail(`${label} must be an object`);
|
|
4567
|
+
return value;
|
|
4568
|
+
}
|
|
4569
|
+
function requireString(value, label) {
|
|
4570
|
+
if (typeof value !== "string" || value.length === 0) fail(`${label} must be a non-empty string`);
|
|
4571
|
+
return value;
|
|
4572
|
+
}
|
|
4573
|
+
function requireBoolean(value, label) {
|
|
4574
|
+
if (typeof value !== "boolean") fail(`${label} must be a boolean`);
|
|
4575
|
+
return value;
|
|
4576
|
+
}
|
|
4577
|
+
function requireFiniteNumber(value, label) {
|
|
4578
|
+
if (typeof value !== "number" || !Number.isFinite(value)) fail(`${label} must be a finite number`);
|
|
4579
|
+
return value;
|
|
4580
|
+
}
|
|
4581
|
+
function requireInteger(value, label, minimum = 0) {
|
|
4582
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) fail(`${label} must be an integer >= ${minimum}`);
|
|
4583
|
+
return value;
|
|
4584
|
+
}
|
|
4585
|
+
function validateDimension(value, index) {
|
|
4586
|
+
const dimension = requireRecord(value, `dimensions[${index}]`);
|
|
4587
|
+
const category = requireString(dimension.category, `dimensions[${index}].category`);
|
|
4588
|
+
if (!RULE_CATEGORIES.includes(category)) fail(`dimensions[${index}].category is unknown`);
|
|
4589
|
+
const score = requireFiniteNumber(dimension.score, `dimensions[${index}].score`);
|
|
4590
|
+
if (score < 0 || score > 100) fail(`dimensions[${index}].score must be between 0 and 100`);
|
|
4591
|
+
requireInteger(dimension.errors, `dimensions[${index}].errors`);
|
|
4592
|
+
requireInteger(dimension.warnings, `dimensions[${index}].warnings`);
|
|
4593
|
+
requireInteger(dimension.infos, `dimensions[${index}].infos`);
|
|
4594
|
+
}
|
|
4595
|
+
function validateFinding(value, index) {
|
|
4596
|
+
const finding = requireRecord(value, `findings[${index}]`);
|
|
4597
|
+
requireString(finding.ruleId, `findings[${index}].ruleId`);
|
|
4598
|
+
const category = requireString(finding.category, `findings[${index}].category`);
|
|
4599
|
+
if (!RULE_CATEGORIES.includes(category)) fail(`findings[${index}].category is unknown`);
|
|
4600
|
+
const severity = requireString(finding.severity, `findings[${index}].severity`);
|
|
4601
|
+
if (!SEVERITY_ORDER$1.includes(severity)) fail(`findings[${index}].severity is unknown`);
|
|
4602
|
+
const confidence = requireString(finding.confidence, `findings[${index}].confidence`);
|
|
4603
|
+
if (!CONFIDENCE_VALUES.has(confidence)) fail(`findings[${index}].confidence is unknown`);
|
|
4604
|
+
const findingType = requireString(finding.findingType, `findings[${index}].findingType`);
|
|
4605
|
+
if (!FINDING_TYPE_VALUES.has(findingType)) fail(`findings[${index}].findingType is unknown`);
|
|
4606
|
+
const qaImpact = requireString(finding.qaImpact, `findings[${index}].qaImpact`);
|
|
4607
|
+
if (!QA_IMPACT_VALUES.has(qaImpact)) fail(`findings[${index}].qaImpact is unknown`);
|
|
4608
|
+
requireString(finding.file, `findings[${index}].file`);
|
|
4609
|
+
requireInteger(finding.line, `findings[${index}].line`, 1);
|
|
4610
|
+
requireInteger(finding.column, `findings[${index}].column`, 1);
|
|
4611
|
+
requireString(finding.message, `findings[${index}].message`);
|
|
4612
|
+
requireString(finding.why, `findings[${index}].why`);
|
|
4613
|
+
requireString(finding.fix, `findings[${index}].fix`);
|
|
4614
|
+
if (finding.evidenceLevel !== void 0 && (typeof finding.evidenceLevel !== "string" || !EVIDENCE_VALUES.has(finding.evidenceLevel))) fail(`findings[${index}].evidenceLevel is unknown`);
|
|
4615
|
+
if (finding.trustLevel !== void 0 && (typeof finding.trustLevel !== "string" || !TRUST_VALUES.has(finding.trustLevel))) fail(`findings[${index}].trustLevel is unknown`);
|
|
4616
|
+
if (finding.detectorRevision !== void 0) requireInteger(finding.detectorRevision, `findings[${index}].detectorRevision`, 1);
|
|
4617
|
+
if (finding.measuredFpRate !== void 0) {
|
|
4618
|
+
const rate = requireFiniteNumber(finding.measuredFpRate, `findings[${index}].measuredFpRate`);
|
|
4619
|
+
if (rate < 0 || rate > 1) fail(`findings[${index}].measuredFpRate must be between 0 and 1`);
|
|
4620
|
+
}
|
|
4621
|
+
if (finding.measuredFpN !== void 0) requireInteger(finding.measuredFpN, `findings[${index}].measuredFpN`);
|
|
4622
|
+
}
|
|
4623
|
+
function validateAnalysisStatus(value) {
|
|
4624
|
+
const status = requireRecord(value, "analysisStatus");
|
|
4625
|
+
if (status.discovery !== "complete" && status.discovery !== "partial") fail("analysisStatus.discovery must be \"complete\" or \"partial\"");
|
|
4626
|
+
if (status.rules !== "complete" && status.rules !== "partial") fail("analysisStatus.rules must be \"complete\" or \"partial\"");
|
|
4627
|
+
requireInteger(status.skippedFiles, "analysisStatus.skippedFiles");
|
|
4628
|
+
requireFiniteNumber(status.durationMs, "analysisStatus.durationMs");
|
|
4629
|
+
if (status.rulesCrashed !== void 0) requireInteger(status.rulesCrashed, "analysisStatus.rulesCrashed");
|
|
4630
|
+
if (status.parseFallbacks !== void 0) requireInteger(status.parseFallbacks, "analysisStatus.parseFallbacks");
|
|
4631
|
+
if (status.truncationReasons !== void 0) {
|
|
4632
|
+
if (!Array.isArray(status.truncationReasons)) fail("analysisStatus.truncationReasons must be an array");
|
|
4633
|
+
status.truncationReasons.forEach((reason, index) => requireString(reason, `analysisStatus.truncationReasons[${index}]`));
|
|
4634
|
+
}
|
|
4635
|
+
if (status.reasons !== void 0) {
|
|
4636
|
+
if (!Array.isArray(status.reasons)) fail("analysisStatus.reasons must be an array");
|
|
4637
|
+
status.reasons.forEach((reason, index) => requireString(reason, `analysisStatus.reasons[${index}]`));
|
|
4638
|
+
}
|
|
4639
|
+
}
|
|
4640
|
+
function validateScopeIntegrity(value) {
|
|
4641
|
+
const scope = requireRecord(value, "scopeIntegrity");
|
|
4642
|
+
requireInteger(scope.discovered, "scopeIntegrity.discovered");
|
|
4643
|
+
requireInteger(scope.analyzed, "scopeIntegrity.analyzed");
|
|
4644
|
+
requireInteger(scope.ignored, "scopeIntegrity.ignored");
|
|
4645
|
+
requireInteger(scope.unrecognized, "scopeIntegrity.unrecognized");
|
|
4646
|
+
requireInteger(scope.parseFailed, "scopeIntegrity.parseFailed");
|
|
4647
|
+
requireInteger(scope.truncated, "scopeIntegrity.truncated");
|
|
4648
|
+
if (scope.scopeVerdict !== "PROVEN" && scope.scopeVerdict !== "PARTIAL") fail("scopeIntegrity.scopeVerdict must be \"PROVEN\" or \"PARTIAL\"");
|
|
4649
|
+
if (scope.reasons !== void 0) {
|
|
4650
|
+
if (!Array.isArray(scope.reasons)) fail("scopeIntegrity.reasons must be an array");
|
|
4651
|
+
scope.reasons.forEach((reason, index) => requireString(reason, `scopeIntegrity.reasons[${index}]`));
|
|
4652
|
+
}
|
|
4653
|
+
}
|
|
4654
|
+
/**
|
|
4655
|
+
* Parse-and-validate a saved report. Throws Error with a
|
|
4656
|
+
* human-explanatory message on invalid JSON, shape, or contract values.
|
|
4657
|
+
*/
|
|
4658
|
+
function validateReportJson(text) {
|
|
4659
|
+
if (Buffer.byteLength(text, "utf8") > MAX_REPORT_BYTES) fail(`report exceeds the ${MAX_REPORT_BYTES}-byte limit`);
|
|
4660
|
+
let parsed;
|
|
4661
|
+
try {
|
|
4662
|
+
parsed = JSON.parse(text);
|
|
4663
|
+
} catch (err) {
|
|
4664
|
+
throw new Error(`not valid JSON (${errorText$1(err)})`, { cause: err });
|
|
4665
|
+
}
|
|
4666
|
+
const doc = requireRecord(parsed, "the report is not an object");
|
|
4667
|
+
if (doc.schemaVersion !== 1) fail(`unsupported schemaVersion ${JSON.stringify(doc.schemaVersion)} — expected schemaVersion 1`);
|
|
4668
|
+
requireBoolean(doc.partial, "partial");
|
|
4669
|
+
if (doc.score !== null) {
|
|
4670
|
+
const score = requireFiniteNumber(doc.score, "score");
|
|
4671
|
+
if (score < 0 || score > 100) fail("score must be null or between 0 and 100");
|
|
4672
|
+
}
|
|
4673
|
+
if (doc.reason !== void 0 && doc.reason !== "no-tests-found") fail("reason must be \"no-tests-found\" when present");
|
|
4674
|
+
if (doc.frameworks === void 0) fail("missing a \"frameworks\" array — is this a complete Mjölnir --json report?");
|
|
4675
|
+
if (!Array.isArray(doc.frameworks) || doc.frameworks.some((value) => typeof value !== "string")) fail("frameworks must be an array of strings");
|
|
4676
|
+
requireBoolean(doc.frameworkDetectionUnknown, "frameworkDetectionUnknown");
|
|
4677
|
+
if (!Array.isArray(doc.dimensions)) fail("dimensions must be an array");
|
|
4678
|
+
doc.dimensions.forEach(validateDimension);
|
|
4679
|
+
if (!Array.isArray(doc.findings)) fail("missing a \"findings\" array — is this a Mjölnir --json report?");
|
|
4680
|
+
if (doc.findings.length > MAX_FINDINGS) fail(`findings exceeds the ${MAX_FINDINGS}-finding limit`);
|
|
4681
|
+
doc.findings.forEach(validateFinding);
|
|
4682
|
+
validateAnalysisStatus(doc.analysisStatus);
|
|
4683
|
+
if (doc.scopeIntegrity !== void 0) validateScopeIntegrity(doc.scopeIntegrity);
|
|
4684
|
+
const status = requireRecord(doc.analysisStatus, "analysisStatus");
|
|
4685
|
+
const partialMarkers = status["discovery"] !== "complete" || status["rules"] !== "complete" || Number(status["skippedFiles"]) > 0 || Number(status["rulesCrashed"] ?? 0) > 0 || Array.isArray(status["reasons"]) && status["reasons"].length > 0;
|
|
4686
|
+
if (doc.partial === false && partialMarkers) fail("partial=false conflicts with analysisStatus");
|
|
4687
|
+
if (doc.partial === true && typeof doc.score === "number" && doc.score === 100) fail("partial reports cannot have a perfect score");
|
|
4688
|
+
if ((isRecord$2(doc.scopeIntegrity) ? doc.scopeIntegrity : void 0)?.["scopeVerdict"] === "PARTIAL" && doc.partial === false) fail("partial=false conflicts with scopeIntegrity");
|
|
4689
|
+
return parsed;
|
|
4690
|
+
}
|
|
4691
|
+
/** Load + validate a saved report from disk. Throws on any problem. */
|
|
4692
|
+
function loadSavedReport(reportPath) {
|
|
4693
|
+
const stat = statSync(reportPath);
|
|
4694
|
+
if (!stat.isFile()) fail(`report path is not a file: ${reportPath}`);
|
|
4695
|
+
if (stat.size > MAX_REPORT_BYTES) fail(`report exceeds the ${MAX_REPORT_BYTES}-byte limit`);
|
|
4696
|
+
return validateReportJson(readFileSync(reportPath, "utf8"));
|
|
4697
|
+
}
|
|
4698
|
+
/** True when the report file exists (callers own the not-found message). */
|
|
4699
|
+
function reportExists(reportPath) {
|
|
4700
|
+
return existsSync(reportPath);
|
|
4701
|
+
}
|
|
4702
|
+
function identityOf(result) {
|
|
4703
|
+
const identity = result.runIdentity;
|
|
4704
|
+
return isRecord$2(identity) ? identity : void 0;
|
|
4705
|
+
}
|
|
4706
|
+
/**
|
|
4707
|
+
* Classify a loaded report. Pure — it reads the report and the caller's
|
|
4708
|
+
* expectation, touches nothing else.
|
|
4709
|
+
*
|
|
4710
|
+
* `expected` is optional on purpose: a caller with no expectation still gets an
|
|
4711
|
+
* honest classification, it just cannot detect a cross-identity read.
|
|
4712
|
+
*/
|
|
4713
|
+
function classifySavedReport(result, expected) {
|
|
4714
|
+
const identity = identityOf(result);
|
|
4715
|
+
if (identity === void 0) return {
|
|
4716
|
+
state: "OPEN",
|
|
4717
|
+
reason: "report carries no run identity — it predates machine-anchored identity, or was written by hand. Usable as history, not as proof."
|
|
4718
|
+
};
|
|
4719
|
+
const scanId = identity["scanId"];
|
|
4720
|
+
if (typeof scanId !== "string" || scanId.length === 0) return {
|
|
4721
|
+
state: "OPEN",
|
|
4722
|
+
reason: "run identity has no scanId — the identity is unusable."
|
|
4723
|
+
};
|
|
4724
|
+
const commit = typeof identity["commit"] === "string" ? identity["commit"] : void 0;
|
|
4725
|
+
const candidate = identity["candidate"];
|
|
4726
|
+
const candidateManifestId = isRecord$2(candidate) && typeof candidate["manifestId"] === "string" ? candidate["manifestId"] : void 0;
|
|
4727
|
+
if (expected !== void 0) {
|
|
4728
|
+
if (expected.scanId !== void 0 && expected.scanId !== scanId) return {
|
|
4729
|
+
state: "OPEN",
|
|
4730
|
+
reason: `report was produced by a different run (${scanId}) than the current one (${expected.scanId})`
|
|
4731
|
+
};
|
|
4732
|
+
if (expected.commit !== void 0 && expected.commit !== commit) return {
|
|
4733
|
+
state: "OPEN",
|
|
4734
|
+
reason: `report was produced against commit ${commit ?? "unknown"}, not ${expected.commit}`
|
|
4735
|
+
};
|
|
4736
|
+
if (expected.candidateManifestId !== void 0 && expected.candidateManifestId !== candidateManifestId) return {
|
|
4737
|
+
state: "OPEN",
|
|
4738
|
+
reason: `report is bound to candidate ${candidateManifestId ?? "none"}, not ${expected.candidateManifestId}`
|
|
4739
|
+
};
|
|
4740
|
+
}
|
|
4741
|
+
return {
|
|
4742
|
+
state: "VERIFIED",
|
|
4743
|
+
scanId,
|
|
4744
|
+
...commit !== void 0 ? { commit } : {},
|
|
4745
|
+
...candidateManifestId !== void 0 ? { candidateManifestId } : {}
|
|
4746
|
+
};
|
|
4747
|
+
}
|
|
4748
|
+
/**
|
|
4749
|
+
* Load a saved report AND classify it.
|
|
4750
|
+
*
|
|
4751
|
+
* `loadSavedReport` stays for callers that genuinely only need the parse; the
|
|
4752
|
+
* trust-aware path is here so that no consumer reaches a report's contents
|
|
4753
|
+
* without having been told how much they may believe.
|
|
4754
|
+
*/
|
|
4755
|
+
function loadSavedReportStrict(reportPath, expected) {
|
|
4756
|
+
const result = loadSavedReport(reportPath);
|
|
4757
|
+
return {
|
|
4758
|
+
result,
|
|
4759
|
+
trust: classifySavedReport(result, expected),
|
|
4760
|
+
path: reportPath
|
|
4761
|
+
};
|
|
4762
|
+
}
|
|
4763
|
+
//#endregion
|
|
4764
|
+
//#region src/integrations/github/evidence-sanitization.ts
|
|
4765
|
+
const MAX_MARKDOWN_TEXT_LENGTH = 500;
|
|
4766
|
+
/**
|
|
4767
|
+
* Characters that have special meaning in Markdown and must be escaped
|
|
4768
|
+
* to render as literal text.
|
|
4769
|
+
*/
|
|
4770
|
+
const MARKDOWN_SPECIAL_RE = /[[\]*_`~\\|{}()<>#!]/g;
|
|
4771
|
+
/**
|
|
4772
|
+
* HTML tags and entities that could enable injection.
|
|
4773
|
+
*/
|
|
4774
|
+
const HTML_INJECTION_RE = /<[^>]*>/g;
|
|
4775
|
+
const HTML_ENTITY_RE = /&\w+;|&#\d+;|&#x[\da-f]+;/gi;
|
|
4776
|
+
/**
|
|
4777
|
+
* Patterns that could form malformed or malicious links.
|
|
4778
|
+
*/
|
|
4779
|
+
const MALFORMED_LINK_PATTERNS = [/\]\s*\(\s*(?:javascript|data|vbscript)\s*:/gi, /\]\s*\(\s*[^)]{0,200}<[^)]{0,200}>/g];
|
|
4780
|
+
const RTL_OVERRIDE_RE = /[\u200f\u202e\u2067\u2066\u2069\u2068]/g;
|
|
4781
|
+
/**
|
|
4782
|
+
* Escape Markdown special characters in text so they render as literals.
|
|
4783
|
+
*/
|
|
4784
|
+
function escapeMarkdownChars(text) {
|
|
4785
|
+
return text.replace(MARKDOWN_SPECIAL_RE, "\\$&");
|
|
4786
|
+
}
|
|
4787
|
+
/**
|
|
4788
|
+
* Strip HTML tags and entities to prevent injection.
|
|
4789
|
+
*/
|
|
4790
|
+
function stripHtml(text) {
|
|
4791
|
+
return text.replace(HTML_ENTITY_RE, "").replace(HTML_INJECTION_RE, "");
|
|
4792
|
+
}
|
|
4793
|
+
/**
|
|
4794
|
+
* Neutralize potential malformed link patterns.
|
|
4795
|
+
*/
|
|
4796
|
+
function neutralizeMalformedLinks(text) {
|
|
4797
|
+
let result = text;
|
|
4798
|
+
for (const pattern of MALFORMED_LINK_PATTERNS) result = result.replace(pattern, (match) => `[sanitized: ${match.slice(0, 40)}]`);
|
|
4799
|
+
return result;
|
|
4800
|
+
}
|
|
4801
|
+
/**
|
|
4802
|
+
* Strip RTL override characters that could confuse display.
|
|
4803
|
+
*/
|
|
4804
|
+
function stripRtlOverrides(text) {
|
|
4805
|
+
return text.replace(RTL_OVERRIDE_RE, "");
|
|
4806
|
+
}
|
|
4807
|
+
/**
|
|
4808
|
+
* Sanitize arbitrary text for safe inclusion in Markdown.
|
|
4809
|
+
* Escapes special chars, strips HTML, prevents link injection,
|
|
4810
|
+
* strips RTL overrides, and caps length.
|
|
4811
|
+
*/
|
|
4812
|
+
function sanitizeForMarkdown(text) {
|
|
4813
|
+
let result = text;
|
|
4814
|
+
result = stripHtml(result);
|
|
4815
|
+
result = stripRtlOverrides(result);
|
|
4816
|
+
result = neutralizeMalformedLinks(result);
|
|
4817
|
+
result = escapeMarkdownChars(result);
|
|
4818
|
+
if (result.length > MAX_MARKDOWN_TEXT_LENGTH) result = result.slice(0, MAX_MARKDOWN_TEXT_LENGTH) + "\\.\\.\\.";
|
|
4819
|
+
return result;
|
|
4820
|
+
}
|
|
4821
|
+
//#endregion
|
|
3996
4822
|
//#region src/commands/trust-report.ts
|
|
3997
4823
|
/**
|
|
3998
4824
|
* `mjolnir trust-report` — the Trust Artifact (Mega MVP Master Plan
|
|
@@ -4033,7 +4859,23 @@ function fallbackSummary(result) {
|
|
|
4033
4859
|
}
|
|
4034
4860
|
/** HTML-escape a text interpolation (the artifact is hostile-input-safe). */
|
|
4035
4861
|
function esc(text) {
|
|
4036
|
-
return text.
|
|
4862
|
+
return [...text].filter((character) => {
|
|
4863
|
+
const code = character.codePointAt(0) ?? 0;
|
|
4864
|
+
return !(code <= 31 || code >= 127 && code <= 159 || code === 8207 || code === 8238 || code >= 8294 && code <= 8297);
|
|
4865
|
+
}).join("").slice(0, 1e3).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
4866
|
+
}
|
|
4867
|
+
function publicText(text) {
|
|
4868
|
+
return sanitizeForMarkdown(sanitizeErrorText(text, { maxLength: 1e3 }));
|
|
4869
|
+
}
|
|
4870
|
+
function publicJsonText(text) {
|
|
4871
|
+
return sanitizeErrorText(text, { maxLength: 1e3 });
|
|
4872
|
+
}
|
|
4873
|
+
function writeArtifact(path, content) {
|
|
4874
|
+
try {
|
|
4875
|
+
writeFileAtomic(path, content, { encoding: "utf8" });
|
|
4876
|
+
} catch (err) {
|
|
4877
|
+
throw new Error(`failed to write artifact ${path}: ${errorMessage(err)}`, { cause: err });
|
|
4878
|
+
}
|
|
4037
4879
|
}
|
|
4038
4880
|
function buildArtifactIdentity(result, commit) {
|
|
4039
4881
|
const revisions = /* @__PURE__ */ new Map();
|
|
@@ -4061,13 +4903,13 @@ function buildArtifactIdentity(result, commit) {
|
|
|
4061
4903
|
}
|
|
4062
4904
|
/** The MD identity section lines (§9 R9). */
|
|
4063
4905
|
function identityLinesMd(identity) {
|
|
4064
|
-
const revs = identity.detectorRevisions.map((r) => `\`${r.ruleId}@${r.detectorRevision}\``).join(" · ");
|
|
4906
|
+
const revs = identity.detectorRevisions.map((r) => `\`${sanitizeForMarkdown(r.ruleId)}@${r.detectorRevision}\``).join(" · ");
|
|
4065
4907
|
const levels = Object.entries(identity.evidenceInventory.byEvidenceLevel).map(([level, n]) => `${level}:${n}`).join(" · ");
|
|
4066
4908
|
return [
|
|
4067
4909
|
`## Artifact integrity`,
|
|
4068
4910
|
"",
|
|
4069
|
-
`- **scanId**: ${identity.scanId
|
|
4070
|
-
`- **Commit**: ${identity.commit
|
|
4911
|
+
`- **scanId**: ${identity.scanId ? sanitizeForMarkdown(identity.scanId) : "unbound (pre-R9 producer — regenerate)"}`,
|
|
4912
|
+
`- **Commit**: ${identity.commit ? sanitizeForMarkdown(identity.commit) : "unknown (not bound)"}`,
|
|
4071
4913
|
`- **Rule(rev) inventory**: ${revs === "" ? "none fired" : revs}`,
|
|
4072
4914
|
`- **Evidence inventory**: ${identity.evidenceInventory.totalFindings} finding(s), ${identity.evidenceInventory.corroborated} runtime-corroborated, by level ${levels === "" ? "none" : levels}.`,
|
|
4073
4915
|
""
|
|
@@ -4079,7 +4921,7 @@ function renderTrustReportMarkdown(result, label, commit) {
|
|
|
4079
4921
|
const lines = [];
|
|
4080
4922
|
lines.push(`<!-- mjolnir-trust-report:v1 -->`);
|
|
4081
4923
|
lines.push("");
|
|
4082
|
-
lines.push(`# Mjölnir Trust Report — ${label}`);
|
|
4924
|
+
lines.push(`# Mjölnir Trust Report — ${publicText(label)}`);
|
|
4083
4925
|
lines.push("");
|
|
4084
4926
|
lines.push(`> Tests tell you what passed. Mjölnir tells you what you can trust.`);
|
|
4085
4927
|
lines.push("");
|
|
@@ -4097,10 +4939,10 @@ function renderTrustReportMarkdown(result, label, commit) {
|
|
|
4097
4939
|
lines.push(`| Inconclusive | ${pct$1(s.inconclusiveRate)} |`);
|
|
4098
4940
|
lines.push(`| Measured FP (fired) | ${s.measuredFpOfFiredRules !== void 0 ? pct$1(s.measuredFpOfFiredRules) : s.provisionalRuleIds.length > 0 ? `PROVISIONAL (${s.provisionalRuleIds.length} unmeasured)` : "n/a"} |`);
|
|
4099
4941
|
lines.push(`| Score | ${result.score ?? "unknown"} |`);
|
|
4100
|
-
lines.push(`| Tests analyzed | ${result.testDeclarationCount
|
|
4942
|
+
lines.push(`| Tests analyzed | ${testsAnalyzedCell(result.testDeclarationCount, result.testFileCount)} |`);
|
|
4101
4943
|
lines.push("");
|
|
4102
4944
|
if (s.ceilingReasons.length > 0) {
|
|
4103
|
-
lines.push(`Incompleteness factors: ${s.ceilingReasons.join(", ")}.`);
|
|
4945
|
+
lines.push(`Incompleteness factors: ${s.ceilingReasons.map(publicText).join(", ")}.`);
|
|
4104
4946
|
lines.push("");
|
|
4105
4947
|
}
|
|
4106
4948
|
lines.push(`## Top trust risks`);
|
|
@@ -4112,7 +4954,7 @@ function renderTrustReportMarkdown(result, label, commit) {
|
|
|
4112
4954
|
lines.push(`| ---- | -------- | -------- | ------- |`);
|
|
4113
4955
|
for (const f of risks) {
|
|
4114
4956
|
const ev = evidenceTag(f);
|
|
4115
|
-
lines.push(`| ${f.ruleId} | ${f.file}:${f.line} | ${ev} | ${f.message
|
|
4957
|
+
lines.push(`| ${publicText(f.ruleId)} | ${publicText(f.file)}:${f.line} | ${ev} | ${publicText(f.message)} |`);
|
|
4116
4958
|
}
|
|
4117
4959
|
}
|
|
4118
4960
|
lines.push("");
|
|
@@ -4126,7 +4968,7 @@ function renderTrustReportMarkdown(result, label, commit) {
|
|
|
4126
4968
|
lines.push("");
|
|
4127
4969
|
lines.push(`## Next action`);
|
|
4128
4970
|
lines.push("");
|
|
4129
|
-
lines.push(nextAction(result));
|
|
4971
|
+
lines.push(publicText(nextAction(result)));
|
|
4130
4972
|
lines.push("");
|
|
4131
4973
|
lines.push(...identityLinesMd(buildArtifactIdentity(result, commit)));
|
|
4132
4974
|
lines.push(`---`);
|
|
@@ -4138,6 +4980,7 @@ function renderTrustReportMarkdown(result, label, commit) {
|
|
|
4138
4980
|
/** Deterministic JSON artifact (agent-consumable twin of the MD). */
|
|
4139
4981
|
function renderTrustReportJson(result, commit) {
|
|
4140
4982
|
const s = fallbackSummary(result);
|
|
4983
|
+
const completionVerdict = result.partial || result.analysisStatus.discovery !== "complete" || result.analysisStatus.rules !== "complete" || (result.analysisStatus.rulesCrashed ?? 0) > 0 ? "INCOMPLETE" : result.score === null ? "UNKNOWN" : "COMPLETE";
|
|
4141
4984
|
return JSON.stringify({
|
|
4142
4985
|
schemaVersion: 1,
|
|
4143
4986
|
artifact: "mjolnir-trust-report",
|
|
@@ -4154,9 +4997,13 @@ function renderTrustReportJson(result, commit) {
|
|
|
4154
4997
|
ceilingReasons: s.ceilingReasons
|
|
4155
4998
|
},
|
|
4156
4999
|
score: result.score,
|
|
5000
|
+
partial: result.partial,
|
|
5001
|
+
analysisStatus: result.analysisStatus,
|
|
5002
|
+
scopeIntegrity: result.scopeIntegrity ?? null,
|
|
5003
|
+
verdict: completionVerdict,
|
|
4157
5004
|
tests: {
|
|
4158
|
-
files: result.testFileCount
|
|
4159
|
-
declarations: result.testDeclarationCount
|
|
5005
|
+
files: countOrNull(result.testFileCount),
|
|
5006
|
+
declarations: countOrNull(result.testDeclarationCount)
|
|
4160
5007
|
},
|
|
4161
5008
|
findings: {
|
|
4162
5009
|
total: result.findings.length,
|
|
@@ -4166,14 +5013,14 @@ function renderTrustReportJson(result, commit) {
|
|
|
4166
5013
|
advisory: result.findings.filter((f) => isAdvisoryFinding(f)).length
|
|
4167
5014
|
},
|
|
4168
5015
|
topTrustRisks: topTrustRisks(result.findings, 10).map((f) => ({
|
|
4169
|
-
ruleId: f.ruleId,
|
|
4170
|
-
file: f.file,
|
|
5016
|
+
ruleId: publicJsonText(f.ruleId),
|
|
5017
|
+
file: publicJsonText(f.file),
|
|
4171
5018
|
line: f.line,
|
|
4172
5019
|
severity: f.severity,
|
|
4173
|
-
evidence: f.runtimeCorroboration === void 0 ? f
|
|
4174
|
-
message: f.message
|
|
5020
|
+
evidence: f.runtimeCorroboration === void 0 ? evidenceLevelOf(f) : f.runtimeCorroboration.level,
|
|
5021
|
+
message: publicJsonText(f.message)
|
|
4175
5022
|
})),
|
|
4176
|
-
nextAction: nextAction(result)
|
|
5023
|
+
nextAction: publicJsonText(nextAction(result))
|
|
4177
5024
|
}, null, 2) + "\n";
|
|
4178
5025
|
}
|
|
4179
5026
|
/**
|
|
@@ -4191,20 +5038,18 @@ function renderTrustReportHtml(result, label, commit) {
|
|
|
4191
5038
|
const warnings = result.findings.filter((f) => f.severity === "warning").length;
|
|
4192
5039
|
const infos = result.findings.filter((f) => f.severity === "info").length;
|
|
4193
5040
|
const advisory = result.findings.filter((f) => isAdvisoryFinding(f)).length;
|
|
4194
|
-
const confidenceRows = [];
|
|
4195
5041
|
const measuredFpCell = s.measuredFpOfFiredRules !== void 0 ? pct$1(s.measuredFpOfFiredRules) : s.provisionalRuleIds.length > 0 ? `PROVISIONAL (${s.provisionalRuleIds.length} unmeasured)` : "n/a";
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
return `<tr><td>${esc(k ?? "")}</td><td>${esc(v ?? "")}</td></tr>`;
|
|
4205
|
-
}).join("\n ");
|
|
5042
|
+
const confidenceRowsHtml = [
|
|
5043
|
+
["Confidence", `${pct$1(s.confidence)}${s.confidenceCeiling !== void 0 ? ` (ceiling ${pct$1(s.confidenceCeiling)})` : ""}`],
|
|
5044
|
+
["Evidence coverage", pct$1(s.evidenceCoverage)],
|
|
5045
|
+
["Inconclusive", pct$1(s.inconclusiveRate)],
|
|
5046
|
+
["Measured FP (fired)", measuredFpCell],
|
|
5047
|
+
["Score", result.score === null ? "unknown" : String(result.score)],
|
|
5048
|
+
["Tests analyzed", testsAnalyzedCell(result.testDeclarationCount, result.testFileCount)]
|
|
5049
|
+
].map(([k, v]) => `<tr><th scope="row">${esc(k)}</th><td>${esc(v)}</td></tr>`).join("\n ");
|
|
4206
5050
|
const risksTable = risks.length === 0 ? `<p>None — no non-advisory findings fired.</p>` : `<table>
|
|
4207
|
-
<
|
|
5051
|
+
<caption>The ${risks.length} highest-risk non-advisory findings. Evidence shows what each finding actually rests on.</caption>
|
|
5052
|
+
<thead><tr><th scope="col">Rule</th><th scope="col">Location</th><th scope="col">Evidence</th><th scope="col">Message</th></tr></thead>
|
|
4208
5053
|
<tbody>
|
|
4209
5054
|
${risks.map((f) => {
|
|
4210
5055
|
const ev = evidenceTag(f);
|
|
@@ -4243,6 +5088,8 @@ function renderTrustReportHtml(result, label, commit) {
|
|
|
4243
5088
|
`<section id="confidence">`,
|
|
4244
5089
|
`<h2>Confidence</h2>`,
|
|
4245
5090
|
`<table>`,
|
|
5091
|
+
` <caption>Mjölnir confidence measurements. A value of “unknown — not measured” means the scan did not produce that measurement; it is not zero.</caption>`,
|
|
5092
|
+
` <thead><tr><th scope="col">Measurement</th><th scope="col">Value</th></tr></thead>`,
|
|
4246
5093
|
` <tbody>`,
|
|
4247
5094
|
` ${confidenceRowsHtml}`,
|
|
4248
5095
|
` </tbody>`,
|
|
@@ -4290,38 +5137,35 @@ async function runTrustReportCommand(argv, io) {
|
|
|
4290
5137
|
io.err("error: --commit requires the run's HEAD sha");
|
|
4291
5138
|
return 10;
|
|
4292
5139
|
}
|
|
4293
|
-
let
|
|
4294
|
-
try {
|
|
4295
|
-
raw = readFileSync(resolve(fromPath), "utf8");
|
|
4296
|
-
} catch (err) {
|
|
4297
|
-
io.err(`error: cannot read ${fromPath}: ${errorMessage(err)}`);
|
|
4298
|
-
return 10;
|
|
4299
|
-
}
|
|
4300
|
-
let parsed;
|
|
5140
|
+
let loaded;
|
|
4301
5141
|
try {
|
|
4302
|
-
|
|
5142
|
+
loaded = loadSavedReportStrict(resolve(fromPath));
|
|
4303
5143
|
} catch (err) {
|
|
4304
5144
|
io.err(`error: cannot read ${fromPath}: ${errorMessage(err)}`);
|
|
4305
5145
|
return 10;
|
|
4306
5146
|
}
|
|
4307
|
-
|
|
4308
|
-
|
|
5147
|
+
const scan = loaded.result;
|
|
5148
|
+
if (loaded.trust.state !== "VERIFIED") io.err(`warning: ${loaded.trust.reason} The trust report below describes an OPEN artifact.`);
|
|
5149
|
+
if (commitArg && scan.runIdentity?.commit && scan.runIdentity.commit !== commitArg) {
|
|
5150
|
+
io.err(`error: report commit ${scan.runIdentity.commit} does not match --commit ${commitArg}`);
|
|
4309
5151
|
return 10;
|
|
4310
5152
|
}
|
|
4311
|
-
|
|
4312
|
-
|
|
5153
|
+
if (commitArg && scan.runIdentity) scan.runIdentity = {
|
|
5154
|
+
...scan.runIdentity,
|
|
5155
|
+
commit: commitArg
|
|
5156
|
+
};
|
|
4313
5157
|
const md = renderTrustReportMarkdown(scan, fromPath, commitArg ?? null);
|
|
4314
5158
|
if (argv.includes("--stdout")) {
|
|
4315
5159
|
io.out(md);
|
|
4316
|
-
return 0;
|
|
5160
|
+
return loaded.trust.state === "VERIFIED" ? 0 : 2;
|
|
4317
5161
|
}
|
|
4318
5162
|
try {
|
|
4319
5163
|
const outPath = resolve(dirname(fromPath), TRUST_REPORT_MD);
|
|
4320
5164
|
const html = renderTrustReportHtml(scan, fromPath, commitArg ?? null);
|
|
4321
5165
|
const json = renderTrustReportJson(scan, commitArg ?? null);
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
5166
|
+
writeArtifact(outPath, md);
|
|
5167
|
+
writeArtifact(resolve(dirname(fromPath), TRUST_REPORT_HTML), html);
|
|
5168
|
+
writeArtifact(resolve(dirname(fromPath), TRUST_REPORT_JSON), json);
|
|
4325
5169
|
io.out(`trust report written: ${outPath} (plus ${TRUST_REPORT_HTML}, ${TRUST_REPORT_JSON} — same identity binding)`);
|
|
4326
5170
|
return 0;
|
|
4327
5171
|
} catch (err) {
|
|
@@ -4340,17 +5184,21 @@ async function runTrustReportCommand(argv, io) {
|
|
|
4340
5184
|
target,
|
|
4341
5185
|
json: true,
|
|
4342
5186
|
verbose: false,
|
|
4343
|
-
maxDurationMs:
|
|
5187
|
+
maxDurationMs: 6e5,
|
|
4344
5188
|
scopeChanged: false,
|
|
4345
5189
|
format: "json"
|
|
4346
5190
|
});
|
|
4347
5191
|
const commit = currentCommit(target, { nullable: true });
|
|
5192
|
+
if (commit && result.runIdentity) result.runIdentity = {
|
|
5193
|
+
...result.runIdentity,
|
|
5194
|
+
commit
|
|
5195
|
+
};
|
|
4348
5196
|
const md = renderTrustReportMarkdown(result, targetArg, commit);
|
|
4349
5197
|
const json = renderTrustReportJson(result, commit);
|
|
4350
5198
|
const html = renderTrustReportHtml(result, targetArg, commit);
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
5199
|
+
writeArtifact(join(target, TRUST_REPORT_MD), md);
|
|
5200
|
+
writeArtifact(join(target, TRUST_REPORT_JSON), json);
|
|
5201
|
+
writeArtifact(join(target, TRUST_REPORT_HTML), html);
|
|
4354
5202
|
io.out(`trust report written: ${TRUST_REPORT_MD}, ${TRUST_REPORT_JSON}, ${TRUST_REPORT_HTML} (deterministic — same scan, same bytes)`);
|
|
4355
5203
|
return 0;
|
|
4356
5204
|
} catch (err) {
|
|
@@ -4391,10 +5239,10 @@ function findingLineMd(f) {
|
|
|
4391
5239
|
return `${icon} **${escMd(f.ruleId)}** \`${safePath}:${f.line}\` — ${escMd(f.message)} ${escMd(`[${evidenceTagLine(f)}]`)}\n Fix: ${fixBody}`;
|
|
4392
5240
|
}
|
|
4393
5241
|
/** Score cell with color-coded emoji and optional delta. */
|
|
4394
|
-
function renderScoreBadge(score, delta) {
|
|
5242
|
+
function renderScoreBadge(score, delta, verdictOverride) {
|
|
4395
5243
|
if (score === null) return "### Score\n*n/a*";
|
|
4396
5244
|
deriveScoreState(score);
|
|
4397
|
-
let text = `**${score}**/100 ${verdictFor(score)}`;
|
|
5245
|
+
let text = `**${score}**/100 ${verdictOverride ?? verdictFor(score)}`;
|
|
4398
5246
|
if (delta !== void 0 && delta !== 0) text += ` (${delta > 0 ? "+" : ""}${delta})`;
|
|
4399
5247
|
return `### Score\n${text}`;
|
|
4400
5248
|
}
|
|
@@ -4422,7 +5270,7 @@ function renderConfidenceTable(result, summary) {
|
|
|
4422
5270
|
lines.push(`| Evidence coverage | ${pct$1(summary.evidenceCoverage)} |`);
|
|
4423
5271
|
lines.push(`| Inconclusive | ${pct$1(summary.inconclusiveRate)} |`);
|
|
4424
5272
|
lines.push(`| Measured FP (fired) | ${summary.measuredFpOfFiredRules !== void 0 ? pct$1(summary.measuredFpOfFiredRules) : summary.provisionalRuleIds.length > 0 ? `PROVISIONAL (${summary.provisionalRuleIds.length} unmeasured)` : "n/a"} |`);
|
|
4425
|
-
lines.push(`| Tests analyzed | ${result.testDeclarationCount
|
|
5273
|
+
lines.push(`| Tests analyzed | ${testsAnalyzedCell(result.testDeclarationCount, result.testFileCount)} |`);
|
|
4426
5274
|
lines.push("");
|
|
4427
5275
|
lines.push("</details>");
|
|
4428
5276
|
return lines;
|
|
@@ -4462,6 +5310,10 @@ function renderArtifactIntegrity(identity) {
|
|
|
4462
5310
|
lines.push("</details>");
|
|
4463
5311
|
return lines;
|
|
4464
5312
|
}
|
|
5313
|
+
function isIncompleteResult(result) {
|
|
5314
|
+
const status = result.analysisStatus;
|
|
5315
|
+
return result.partial || status === void 0 || status.discovery !== "complete" || status.rules !== "complete" || (status.rulesCrashed ?? 0) > 0 || result.scopeIntegrity?.scopeVerdict === "PARTIAL";
|
|
5316
|
+
}
|
|
4465
5317
|
/**
|
|
4466
5318
|
* Render the unified "Mjölnir Verification Report" PR comment.
|
|
4467
5319
|
*
|
|
@@ -4479,7 +5331,8 @@ function renderUnifiedReport(result, options = {}) {
|
|
|
4479
5331
|
provisionalRuleIds: [],
|
|
4480
5332
|
ceilingReasons: []
|
|
4481
5333
|
};
|
|
4482
|
-
|
|
5334
|
+
const incomplete = isIncompleteResult(result) || !result.trustSummary || summary.level === "L0" && summary.evidenceCoverage === 0;
|
|
5335
|
+
lines.push("## <img width=\"16\" alt=\"Mjölnir\" src=\"https://raw.githubusercontent.com/Sergey-Bar/Mjolnir/main/assets/mjolnir-icon.svg\"/> Mjölnir Verification Report");
|
|
4483
5336
|
lines.push("");
|
|
4484
5337
|
lines.push("> Tests tell you what passed. Mjölnir tells you what you can trust.");
|
|
4485
5338
|
lines.push("");
|
|
@@ -4487,6 +5340,11 @@ function renderUnifiedReport(result, options = {}) {
|
|
|
4487
5340
|
lines.push("_Scope: only the lines this PR changed._");
|
|
4488
5341
|
lines.push("");
|
|
4489
5342
|
}
|
|
5343
|
+
if (incomplete) {
|
|
5344
|
+
const reasons = result.analysisStatus?.reasons ?? [];
|
|
5345
|
+
lines.push(`> **Analysis status: INCOMPLETE.** ${reasons.length > 0 ? reasons.map((reason) => escMd(reason)).join(", ") : "The scan did not complete a trustworthy analysis."}`);
|
|
5346
|
+
lines.push("");
|
|
5347
|
+
}
|
|
4490
5348
|
const diff = options.diff;
|
|
4491
5349
|
const usingDiff = diff?.hasBaseline === true;
|
|
4492
5350
|
const findings = usingDiff ? diff.newFindings : result.findings;
|
|
@@ -4494,7 +5352,7 @@ function renderUnifiedReport(result, options = {}) {
|
|
|
4494
5352
|
const warningCount = findings.filter((f) => f.severity === "warning").length;
|
|
4495
5353
|
const baseScore = diff?.baselineScore;
|
|
4496
5354
|
const delta = baseScore !== void 0 && result.score !== null ? result.score - baseScore : void 0;
|
|
4497
|
-
lines.push("<table>");
|
|
5355
|
+
lines.push("<table role=\"presentation\">");
|
|
4498
5356
|
lines.push("<tr>");
|
|
4499
5357
|
lines.push("<td width=\"130\">");
|
|
4500
5358
|
lines.push("");
|
|
@@ -4503,7 +5361,7 @@ function renderUnifiedReport(result, options = {}) {
|
|
|
4503
5361
|
lines.push("</td>");
|
|
4504
5362
|
lines.push("<td width=\"130\">");
|
|
4505
5363
|
lines.push("");
|
|
4506
|
-
lines.push(renderScoreBadge(result.score, delta));
|
|
5364
|
+
lines.push(renderScoreBadge(result.score, delta, incomplete ? "INCOMPLETE" : void 0));
|
|
4507
5365
|
lines.push("");
|
|
4508
5366
|
lines.push("</td>");
|
|
4509
5367
|
lines.push("<td width=\"130\">");
|
|
@@ -4521,7 +5379,7 @@ function renderUnifiedReport(result, options = {}) {
|
|
|
4521
5379
|
lines.push("");
|
|
4522
5380
|
const state = deriveScoreState(result.score);
|
|
4523
5381
|
const headlineFindings = usingDiff ? findings.length : result.findings.length;
|
|
4524
|
-
lines.push(`**Headline:** ${headlineFor(state, headlineFindings)}`);
|
|
5382
|
+
lines.push(incomplete ? "**Headline:** Analysis is incomplete; this report is not a clean or worthy verdict." : `**Headline:** ${headlineFor(state, headlineFindings)}`);
|
|
4525
5383
|
lines.push("");
|
|
4526
5384
|
lines.push("---");
|
|
4527
5385
|
lines.push("");
|
|
@@ -4534,7 +5392,7 @@ function renderUnifiedReport(result, options = {}) {
|
|
|
4534
5392
|
lines.push("");
|
|
4535
5393
|
}
|
|
4536
5394
|
if (findings.length === 0) {
|
|
4537
|
-
lines.push("✅ No new findings in this PR's changes.");
|
|
5395
|
+
lines.push(incomplete ? "⚠️ No findings were observed, but the analysis is incomplete; this is not proof of cleanliness." : usingDiff ? "✅ No new findings in this PR's changes." : "✅ No new findings in the analyzed surface.");
|
|
4538
5396
|
lines.push("");
|
|
4539
5397
|
} else {
|
|
4540
5398
|
const errors = findings.filter((f) => f.severity === "error");
|
|
@@ -4599,7 +5457,7 @@ function renderUnifiedReport(result, options = {}) {
|
|
|
4599
5457
|
lines.push("```");
|
|
4600
5458
|
lines.push("");
|
|
4601
5459
|
const repoLink = options.repoUrl ? ` — ${options.repoUrl}` : "";
|
|
4602
|
-
lines.push(`<sub>Posted by GitHub Actions${repoLink} — blocks merging when the CI gate reports findings at the configured severity.</sub>`);
|
|
5460
|
+
lines.push(incomplete ? `<sub>Posted by GitHub Actions${repoLink} — analysis incomplete; no release verdict is claimed.</sub>` : `<sub>Posted by GitHub Actions${repoLink} — blocks merging when the CI gate reports findings at the configured severity.</sub>`);
|
|
4603
5461
|
return lines.join("\n");
|
|
4604
5462
|
}
|
|
4605
5463
|
//#endregion
|
|
@@ -4626,57 +5484,6 @@ function renderPrComment(result, options = {}) {
|
|
|
4626
5484
|
});
|
|
4627
5485
|
}
|
|
4628
5486
|
//#endregion
|
|
4629
|
-
//#region src/commands/report-io.ts
|
|
4630
|
-
/**
|
|
4631
|
-
* Shared saved-report loading (agent-handoff plan §9.0).
|
|
4632
|
-
*
|
|
4633
|
-
* `summary`, `handoff` and `why` all consume a saved `--json` report.
|
|
4634
|
-
* One loader, one validation, one error shape — previously
|
|
4635
|
-
* validateReportJson was private to summary.ts; extracting it keeps
|
|
4636
|
-
* the three commands byte-identical in their loading behavior without
|
|
4637
|
-
* duplication. The exit-code mapping stays in the callers (they own
|
|
4638
|
-
* their io), but the error MESSAGES are identical because they come
|
|
4639
|
-
* from here.
|
|
4640
|
-
*/
|
|
4641
|
-
/** Human message for any thrown value — never "undefined"/"[object Object]". */
|
|
4642
|
-
function errorText$1(err) {
|
|
4643
|
-
if (err instanceof Error) return err.message;
|
|
4644
|
-
if (typeof err === "string") return err;
|
|
4645
|
-
if (typeof err === "object" && err !== null) return JSON.stringify(err);
|
|
4646
|
-
return String(err);
|
|
4647
|
-
}
|
|
4648
|
-
/**
|
|
4649
|
-
* Parse-and-validate a saved report. Throws Error with a
|
|
4650
|
-
* human-explanatory message on: invalid JSON, non-object document,
|
|
4651
|
-
* wrong schemaVersion, missing findings array, missing frameworks
|
|
4652
|
-
* array (certification F1: a schema-1 report is MORE than
|
|
4653
|
-
* `{schemaVersion, findings}` — summary/handoff/why read
|
|
4654
|
-
* `result.frameworks`, so a schema-incomplete stub must be rejected
|
|
4655
|
-
* HERE, by the one shared loader, not crash downstream).
|
|
4656
|
-
*/
|
|
4657
|
-
function validateReportJson(text) {
|
|
4658
|
-
let parsed;
|
|
4659
|
-
try {
|
|
4660
|
-
parsed = JSON.parse(text);
|
|
4661
|
-
} catch (err) {
|
|
4662
|
-
throw new Error(`not valid JSON (${errorText$1(err)})`, { cause: err });
|
|
4663
|
-
}
|
|
4664
|
-
if (typeof parsed !== "object" || parsed === null) throw new Error("the file is a JSON value but not an object");
|
|
4665
|
-
const doc = parsed;
|
|
4666
|
-
if (doc.schemaVersion !== 1) throw new Error(`unsupported schemaVersion ${JSON.stringify(doc.schemaVersion)} — expected 1`);
|
|
4667
|
-
if (!Array.isArray(doc.findings)) throw new Error("missing a \"findings\" array — is this a Mjölnir --json report?");
|
|
4668
|
-
if (!Array.isArray(doc.frameworks)) throw new Error("missing a \"frameworks\" array — is this a complete Mjölnir --json report?");
|
|
4669
|
-
return parsed;
|
|
4670
|
-
}
|
|
4671
|
-
/** Load + validate a saved report from disk. Throws on any problem. */
|
|
4672
|
-
function loadSavedReport(reportPath) {
|
|
4673
|
-
return validateReportJson(readFileSync(reportPath, "utf8"));
|
|
4674
|
-
}
|
|
4675
|
-
/** True when the report file exists (callers own the not-found message). */
|
|
4676
|
-
function reportExists(reportPath) {
|
|
4677
|
-
return existsSync(reportPath);
|
|
4678
|
-
}
|
|
4679
|
-
//#endregion
|
|
4680
5487
|
//#region src/commands/why.ts
|
|
4681
5488
|
/**
|
|
4682
5489
|
* `mjolnir why <file>:<line>` — occurrence-level evidence/explanation
|
|
@@ -4739,7 +5546,7 @@ function evidenceLines(f, ui) {
|
|
|
4739
5546
|
}
|
|
4740
5547
|
return lines;
|
|
4741
5548
|
}
|
|
4742
|
-
const SUPPRESSION_HINT = "Suppression (only with cause): an `ignore` entry in mjolnir.config.json — reason REQUIRED
|
|
5549
|
+
const SUPPRESSION_HINT = "Suppression (only with cause): an `ignore` entry in mjolnir.config.json — reason REQUIRED; only an explicit ISO `expires` date is bounded. Prefer fixing the root cause.";
|
|
4743
5550
|
/** Render the why answer. Pure over (match, ui). */
|
|
4744
5551
|
function renderWhy(match, ui = plainContext()) {
|
|
4745
5552
|
const { p } = ui;
|
|
@@ -5108,22 +5915,11 @@ function buildBadge(result, commit) {
|
|
|
5108
5915
|
* (the falsifiable claim: "verified at commit X on date Y").
|
|
5109
5916
|
*/
|
|
5110
5917
|
function renderBadgeSnippet(result, repoUrl = "https://github.com/Sergey-Bar/Mjolnir") {
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
5116
|
-
"HEAD"
|
|
5117
|
-
], {
|
|
5118
|
-
cwd: process.cwd(),
|
|
5119
|
-
encoding: "utf8",
|
|
5120
|
-
stdio: [
|
|
5121
|
-
"ignore",
|
|
5122
|
-
"pipe",
|
|
5123
|
-
"ignore"
|
|
5124
|
-
]
|
|
5125
|
-
}).trim();
|
|
5126
|
-
} catch {}
|
|
5918
|
+
const commit = runGit(process.cwd(), [
|
|
5919
|
+
"rev-parse",
|
|
5920
|
+
"--short",
|
|
5921
|
+
"HEAD"
|
|
5922
|
+
])?.trim() ?? "unknown";
|
|
5127
5923
|
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
5128
5924
|
const errors = result.findings.filter((f) => f.severity === "error").length;
|
|
5129
5925
|
return [
|
|
@@ -5378,7 +6174,7 @@ function createRuleScaffold(input, rootDir) {
|
|
|
5378
6174
|
};
|
|
5379
6175
|
mkdirSync(join(rootDir, "src", "rules", parsed.dir), { recursive: true });
|
|
5380
6176
|
const files = [];
|
|
5381
|
-
|
|
6177
|
+
const ruleSrc = `/**
|
|
5382
6178
|
* ${input.id} — ${input.title}.
|
|
5383
6179
|
*
|
|
5384
6180
|
* TODO(implement): replace the placeholder below. The rule currently
|
|
@@ -5404,7 +6200,8 @@ export const ${camel(parsed.lower)} = defineRule({
|
|
|
5404
6200
|
return findings;
|
|
5405
6201
|
},
|
|
5406
6202
|
});
|
|
5407
|
-
|
|
6203
|
+
`;
|
|
6204
|
+
writeFileAtomic(absRule, ruleSrc);
|
|
5408
6205
|
files.push(ruleRel);
|
|
5409
6206
|
for (const direction of ["must-fire", "must-not-fire"]) {
|
|
5410
6207
|
const dirRel = join("tests", "fixtures", input.id, direction);
|
|
@@ -5505,14 +6302,19 @@ function buildHandover(scan, forensics) {
|
|
|
5505
6302
|
heading: "⚠ CI trust warnings (green ≠ verified)",
|
|
5506
6303
|
items: ciTrust.slice(0, 4).map((f) => `${f.file}:${f.line} — ${f.message}`)
|
|
5507
6304
|
});
|
|
5508
|
-
if (new Set(scan.findings.map((f) => f.file)).size === 0 && scan.score !== null && scan.score
|
|
6305
|
+
if (new Set(scan.findings.map((f) => f.file)).size === 0 && scan.score !== null && deriveScoreState(scan.score).band === "trusted" || scan.findings.length === 0) sections.push({
|
|
5509
6306
|
heading: "🟢 Solid foundation",
|
|
5510
6307
|
items: ["No tracked anti-patterns found in scanned specs — good place to start contributing."]
|
|
5511
6308
|
});
|
|
5512
|
-
const
|
|
6309
|
+
const flakyFromRuntime = forensics === null ? null : forensics.flakyTests;
|
|
6310
|
+
const totalIssues = fakeGreen.length + flaky.length + ciTrust.length + (flakyFromRuntime ?? 0);
|
|
6311
|
+
if (flakyFromRuntime === null) sections.push({
|
|
6312
|
+
heading: "⚠ Not measured — no runtime evidence",
|
|
6313
|
+
items: ["No run report was ingested, so flakiness, retries and real pass/fail outcomes are UNKNOWN here. Run `mjolnir forensics <results-dir>` (or point this at a test-results directory) before treating this suite as green.", "Everything above is static analysis only (trust L0–L2): it can show a pattern, never confirm a test ran."]
|
|
6314
|
+
});
|
|
5513
6315
|
return {
|
|
5514
6316
|
sections,
|
|
5515
|
-
summaryLine: totalIssues
|
|
6317
|
+
summaryLine: totalIssues > 0 ? `${totalIssues} thing${totalIssues === 1 ? "" : "s"} to know about before your first release sign-off.` : flakyFromRuntime === null ? "Static analysis found nothing — but no runtime evidence was ingested, so this is NOT a statement that the suite is in good shape." : "Welcome aboard — the suite is in good shape."
|
|
5516
6318
|
};
|
|
5517
6319
|
}
|
|
5518
6320
|
function renderHandover(map) {
|
|
@@ -5550,13 +6352,15 @@ function renderHandover(map) {
|
|
|
5550
6352
|
* tests/privacy-network-isolation.spec.ts, which scans this file too).
|
|
5551
6353
|
*/
|
|
5552
6354
|
const ui$13 = plainContext();
|
|
5553
|
-
/** The S1-resolved absolute git binary, or
|
|
6355
|
+
/** The S1-resolved absolute git binary, or null when none is available. */
|
|
5554
6356
|
function gitExe() {
|
|
5555
|
-
return resolveGitPath()
|
|
6357
|
+
return resolveGitPath();
|
|
5556
6358
|
}
|
|
5557
6359
|
function git(root, args) {
|
|
6360
|
+
const executable = gitExe();
|
|
6361
|
+
if (!executable) return null;
|
|
5558
6362
|
try {
|
|
5559
|
-
return execFileSync(
|
|
6363
|
+
return execFileSync(executable, [
|
|
5560
6364
|
"-C",
|
|
5561
6365
|
root,
|
|
5562
6366
|
...args
|
|
@@ -5575,8 +6379,10 @@ function git(root, args) {
|
|
|
5575
6379
|
}
|
|
5576
6380
|
/** Like git(), but returns the raw bytes — no utf8 decode round-trip. */
|
|
5577
6381
|
function gitBuffer(root, args) {
|
|
6382
|
+
const executable = gitExe();
|
|
6383
|
+
if (!executable) return null;
|
|
5578
6384
|
try {
|
|
5579
|
-
return execFileSync(
|
|
6385
|
+
return execFileSync(executable, [
|
|
5580
6386
|
"-C",
|
|
5581
6387
|
root,
|
|
5582
6388
|
...args
|
|
@@ -5610,7 +6416,16 @@ async function computeImpact(root, options) {
|
|
|
5610
6416
|
"HEAD",
|
|
5611
6417
|
options.baseBranch ?? "main"
|
|
5612
6418
|
])?.trim() ?? void 0;
|
|
5613
|
-
else
|
|
6419
|
+
else {
|
|
6420
|
+
baseRef = git(root, ["rev-parse", baseRef])?.trim();
|
|
6421
|
+
if (!baseRef) return {
|
|
6422
|
+
hasComparison: false,
|
|
6423
|
+
unknownReason: "base-ref-invalid",
|
|
6424
|
+
resolved: [],
|
|
6425
|
+
introduced: [],
|
|
6426
|
+
unknownFacts
|
|
6427
|
+
};
|
|
6428
|
+
}
|
|
5614
6429
|
if (!baseRef) return {
|
|
5615
6430
|
hasComparison: false,
|
|
5616
6431
|
unknownReason: "no-prior-commit",
|
|
@@ -5618,7 +6433,15 @@ async function computeImpact(root, options) {
|
|
|
5618
6433
|
introduced: [],
|
|
5619
6434
|
unknownFacts
|
|
5620
6435
|
};
|
|
5621
|
-
const headRef = git(root, ["rev-parse", "HEAD"])?.trim()
|
|
6436
|
+
const headRef = git(root, ["rev-parse", "HEAD"])?.trim();
|
|
6437
|
+
if (!headRef) return {
|
|
6438
|
+
hasComparison: false,
|
|
6439
|
+
unknownReason: "head-ref-invalid",
|
|
6440
|
+
baseRef,
|
|
6441
|
+
resolved: [],
|
|
6442
|
+
introduced: [],
|
|
6443
|
+
unknownFacts
|
|
6444
|
+
};
|
|
5622
6445
|
if (baseRef === headRef) return {
|
|
5623
6446
|
hasComparison: false,
|
|
5624
6447
|
unknownReason: options.since ? "base-equals-head" : "no-prior-commit",
|
|
@@ -5669,7 +6492,7 @@ async function computeImpact(root, options) {
|
|
|
5669
6492
|
if (blob === null) continue;
|
|
5670
6493
|
const dest = join(tmpDir, relPath);
|
|
5671
6494
|
mkdirSync(dirname(dest), { recursive: true });
|
|
5672
|
-
|
|
6495
|
+
writeFileAtomic(dest, blob);
|
|
5673
6496
|
}
|
|
5674
6497
|
if (truncated) {
|
|
5675
6498
|
baseTreeTruncated = {
|
|
@@ -5979,7 +6802,7 @@ function tryReadPackageJson(rootDir) {
|
|
|
5979
6802
|
const p = join(rootDir, "package.json");
|
|
5980
6803
|
if (!existsSync(p)) return null;
|
|
5981
6804
|
try {
|
|
5982
|
-
return parseJsonFile(readFileSync(p, "utf8"), p, isRecord$
|
|
6805
|
+
return parseJsonFile(readFileSync(p, "utf8"), p, isRecord$3);
|
|
5983
6806
|
} catch {
|
|
5984
6807
|
return null;
|
|
5985
6808
|
}
|
|
@@ -6843,7 +7666,8 @@ async function runScanCommand(argv, io = {
|
|
|
6843
7666
|
onProgress: (e) => progress.onEvent(e),
|
|
6844
7667
|
onGateNotice: (notice) => io.err(notice),
|
|
6845
7668
|
...args.debug ? { onRuleCrash: (ruleId, file, error) => {
|
|
6846
|
-
|
|
7669
|
+
const detail = error instanceof Error ? [error.message, error.stack].filter((value) => Boolean(value)).join("\n") : String(error);
|
|
7670
|
+
crashLog.push(`${ruleId} crashed on ${file}: ${detail}`);
|
|
6847
7671
|
} } : {}
|
|
6848
7672
|
});
|
|
6849
7673
|
progress.done();
|
|
@@ -6856,7 +7680,12 @@ async function runScanCommand(argv, io = {
|
|
|
6856
7680
|
if (args.json) io.err("--score overrides --json; stdout is the bare score.");
|
|
6857
7681
|
const { config: scoreConfig } = loadConfig(target, { knownRuleIds: KNOWN_RULE_IDS$1 });
|
|
6858
7682
|
io.out(result.score === null ? "unknown" : String(result.score));
|
|
6859
|
-
return
|
|
7683
|
+
return scanExitCode({
|
|
7684
|
+
partial: result.partial,
|
|
7685
|
+
findings: result.findings,
|
|
7686
|
+
gate: args.blocking === "none" ? "advisory" : args.blocking ?? scoreConfig.gate ?? "error",
|
|
7687
|
+
isAdvisory: isAdvisoryFinding
|
|
7688
|
+
});
|
|
6860
7689
|
}
|
|
6861
7690
|
renderScanOutput(result, args, target, io);
|
|
6862
7691
|
if (args.format === "terminal") {
|
|
@@ -6873,7 +7702,12 @@ async function runScanCommand(argv, io = {
|
|
|
6873
7702
|
if (result.partial) return 2;
|
|
6874
7703
|
const { config, warnings } = loadConfig(target, { knownRuleIds: KNOWN_RULE_IDS$1 });
|
|
6875
7704
|
for (const w of warnings) io.err(w);
|
|
6876
|
-
return
|
|
7705
|
+
return scanExitCode({
|
|
7706
|
+
partial: false,
|
|
7707
|
+
findings: result.findings,
|
|
7708
|
+
gate: args.blocking === "none" ? "advisory" : args.blocking ?? config.gate ?? "error",
|
|
7709
|
+
isAdvisory: isAdvisoryFinding
|
|
7710
|
+
});
|
|
6877
7711
|
} catch (err) {
|
|
6878
7712
|
if (err instanceof ConfigValidationError) {
|
|
6879
7713
|
io.err(err.message);
|
|
@@ -7217,6 +8051,33 @@ async function runPrCommentCommand(argv, io = {
|
|
|
7217
8051
|
out,
|
|
7218
8052
|
err
|
|
7219
8053
|
}) {
|
|
8054
|
+
const fromIdx = argv.indexOf("--from");
|
|
8055
|
+
if (fromIdx !== -1) {
|
|
8056
|
+
const fromPath = argv[fromIdx + 1];
|
|
8057
|
+
if (!fromPath || fromPath.startsWith("-")) {
|
|
8058
|
+
io.err("error: --from requires a saved report path");
|
|
8059
|
+
return 10;
|
|
8060
|
+
}
|
|
8061
|
+
try {
|
|
8062
|
+
const saved = loadSavedReport(resolve(fromPath));
|
|
8063
|
+
const option = (flag) => {
|
|
8064
|
+
const index = argv.indexOf(flag);
|
|
8065
|
+
return index >= 0 ? argv[index + 1] : void 0;
|
|
8066
|
+
};
|
|
8067
|
+
const version = option("--version");
|
|
8068
|
+
const commit = option("--commit");
|
|
8069
|
+
const repoUrl = option("--repo-url");
|
|
8070
|
+
io.out(renderPrComment(saved, {
|
|
8071
|
+
...version ? { version } : {},
|
|
8072
|
+
...commit ? { commit } : {},
|
|
8073
|
+
...repoUrl ? { repoUrl } : {}
|
|
8074
|
+
}));
|
|
8075
|
+
return 0;
|
|
8076
|
+
} catch (err) {
|
|
8077
|
+
io.err(`error: cannot read ${fromPath}: ${errorText$1(err)}`);
|
|
8078
|
+
return 10;
|
|
8079
|
+
}
|
|
8080
|
+
}
|
|
7220
8081
|
const args = parseArgsOrUsage(argv, io);
|
|
7221
8082
|
if (!args) return 10;
|
|
7222
8083
|
try {
|
|
@@ -7376,7 +8237,12 @@ async function runRulesCommand(argv, io = {
|
|
|
7376
8237
|
return 0;
|
|
7377
8238
|
}
|
|
7378
8239
|
const root = process$1.cwd();
|
|
7379
|
-
const
|
|
8240
|
+
const gateOpen = pluginsGateOpen(argv.includes("--enable-plugins"));
|
|
8241
|
+
const external = withExternal ? await loadLocalRules(root, gateOpen) : void 0;
|
|
8242
|
+
if (external?.skipped.length) io.err(renderGateNotice(external.skipped.map((name) => ({
|
|
8243
|
+
kind: "js-module",
|
|
8244
|
+
name
|
|
8245
|
+
}))));
|
|
7380
8246
|
let catalog = [...buildCatalog(), ...external ? buildCatalog(external.rules, { provenance: "external" }) : []];
|
|
7381
8247
|
for (const w of external?.errors ?? []) io.err(`mjolnir: ${w}`);
|
|
7382
8248
|
if (argv.includes("--unmeasured")) catalog = catalog.filter((e) => e.measuredFpRate === void 0);
|
|
@@ -7701,7 +8567,7 @@ function computeDetectorHashes(rules, rulesDir, retiredIds = []) {
|
|
|
7701
8567
|
return manifest;
|
|
7702
8568
|
}
|
|
7703
8569
|
function loadManifest(path) {
|
|
7704
|
-
return parseJsonFile(readFileSync(path, "utf8"), path, (v) => isRecord$
|
|
8570
|
+
return parseJsonFile(readFileSync(path, "utf8"), path, (v) => isRecord$3(v));
|
|
7705
8571
|
}
|
|
7706
8572
|
//#endregion
|
|
7707
8573
|
//#region src/commands/doctor.ts
|
|
@@ -7878,7 +8744,7 @@ function checkTierEnforcement(verdictsDir, rules = RULES) {
|
|
|
7878
8744
|
const lines = readFileSync(join(verdictsDir, f), "utf8").split("\n").filter((l) => l.trim().length > 0);
|
|
7879
8745
|
for (const line of lines) try {
|
|
7880
8746
|
const parsed = JSON.parse(line);
|
|
7881
|
-
if (!isRecord$
|
|
8747
|
+
if (!isRecord$3(parsed)) continue;
|
|
7882
8748
|
const entry = parsed;
|
|
7883
8749
|
if (entry.verdict === "TP" || entry.verdict === "FP") {
|
|
7884
8750
|
if (typeof entry.ruleId === "string") live.set(entry.ruleId, (live.get(entry.ruleId) ?? 0) + 1);
|
|
@@ -7986,7 +8852,7 @@ function checkFixtureIntegrity(fixturesRoot, rules = RULES) {
|
|
|
7986
8852
|
const allowlistPath = join(fixturesRoot, "typecheck-allowlist.json");
|
|
7987
8853
|
if (existsSync(allowlistPath)) try {
|
|
7988
8854
|
const raw = JSON.parse(readFileSync(allowlistPath, "utf8"));
|
|
7989
|
-
if (!isRecord$
|
|
8855
|
+
if (!isRecord$3(raw)) {
|
|
7990
8856
|
ok = false;
|
|
7991
8857
|
details.push("typecheck-allowlist.json is not an object");
|
|
7992
8858
|
} else if (Array.isArray(raw.entries)) details.push(`Layer A typecheck allowlist: ${raw.entries.length} justified entr(ies)`);
|
|
@@ -8100,7 +8966,7 @@ function checkMeasurementConsistency(verdictsDir, sidecarPath, rules = RULES, me
|
|
|
8100
8966
|
let row;
|
|
8101
8967
|
try {
|
|
8102
8968
|
const parsed = JSON.parse(trimmed);
|
|
8103
|
-
if (!isRecord$
|
|
8969
|
+
if (!isRecord$3(parsed)) {
|
|
8104
8970
|
failures.push(`${f}: unparseable verdict row (corpus integrity)`);
|
|
8105
8971
|
continue;
|
|
8106
8972
|
}
|
|
@@ -8117,7 +8983,7 @@ function checkMeasurementConsistency(verdictsDir, sidecarPath, rules = RULES, me
|
|
|
8117
8983
|
let sidecar = {};
|
|
8118
8984
|
if (existsSync(sidecarPath)) try {
|
|
8119
8985
|
const parsed = JSON.parse(readFileSync(sidecarPath, "utf8"));
|
|
8120
|
-
if (!isRecord$
|
|
8986
|
+
if (!isRecord$3(parsed)) failures.push("sidecar is not a valid object — regenerate the measurement artifacts");
|
|
8121
8987
|
else sidecar = parsed;
|
|
8122
8988
|
} catch (e) {
|
|
8123
8989
|
failures.push(`sidecar unreadable/malformed: ${errorText(e)} — regenerate the measurement artifacts`);
|
|
@@ -8365,9 +9231,14 @@ function truncateMessage(message, max = 250) {
|
|
|
8365
9231
|
* `--path-prefix <dir>` re-scopes for subdirectory scans.
|
|
8366
9232
|
*/
|
|
8367
9233
|
const DETAILS_PER_SEVERITY_CAP = 25;
|
|
8368
|
-
|
|
8369
|
-
|
|
8370
|
-
|
|
9234
|
+
/**
|
|
9235
|
+
* The score bar. BW-105: the canonical gauge with the inert palette — the
|
|
9236
|
+
* job summary is a Markdown file, so it takes the gauge's geometry (bar +
|
|
9237
|
+
* head tick) and none of its SGR escapes, while the band is carried in
|
|
9238
|
+
* words on the line above.
|
|
9239
|
+
*/
|
|
9240
|
+
function scoreBar$1(score) {
|
|
9241
|
+
return scoreGauge(score, palette(false), 20);
|
|
8371
9242
|
}
|
|
8372
9243
|
/** Markdown step summary. Pure over (result, options) — testable. */
|
|
8373
9244
|
function renderStepSummary(result, options = {}) {
|
|
@@ -8577,9 +9448,19 @@ function fpLine(f) {
|
|
|
8577
9448
|
if (f.measuredFpRate === void 0) return "Measured FP rate: none — this rule ships on assumption (no measured false-positive rate).";
|
|
8578
9449
|
return `Measured FP rate: ${Math.round(f.measuredFpRate * 100)}%${f.measuredFpN !== void 0 ? ` over ${f.measuredFpN} classified verdicts` : ""}.`;
|
|
8579
9450
|
}
|
|
8580
|
-
|
|
8581
|
-
|
|
8582
|
-
|
|
9451
|
+
/**
|
|
9452
|
+
* The score bar. BW-105: this used to be a private, COLOURLESS copy of
|
|
9453
|
+
* `scoreGauge` — 20 blocks of `█`/`░` with no head tick, so 99 and 100
|
|
9454
|
+
* rendered identically and a reader could not read a band off the bar.
|
|
9455
|
+
*
|
|
9456
|
+
* It delegates to the canonical gauge with the inert palette: the head-tick
|
|
9457
|
+
* geometry is the gauge's own, and NO SGR escapes reach a Markdown file
|
|
9458
|
+
* (which would render as literal `[38;2;…m` on GitHub). The band itself is
|
|
9459
|
+
* carried in words on the line above — `VERDICT (band)` — which is R11
|
|
9460
|
+
* anyway: a colour is never the only signal.
|
|
9461
|
+
*/
|
|
9462
|
+
function scoreBar(score) {
|
|
9463
|
+
return scoreGauge(score, palette(false), 20);
|
|
8583
9464
|
}
|
|
8584
9465
|
function scopeNote(options) {
|
|
8585
9466
|
const parts = [];
|
|
@@ -8715,7 +9596,7 @@ function renderHandoff(result, options = {}, version = ENGINE_VERSION) {
|
|
|
8715
9596
|
});
|
|
8716
9597
|
lines.push("## How to use this document");
|
|
8717
9598
|
lines.push("");
|
|
8718
|
-
lines.push("- Validate each occurrence according to its evidence level before editing.", "- Apply the smallest behavior-preserving fix.", "- Re-run the verification procedure below; correlate by fingerprint.", "- Never suppress a finding merely to obtain a green scan (suppressions live in `mjolnir.config.json
|
|
9599
|
+
lines.push("- Validate each occurrence according to its evidence level before editing.", "- Apply the smallest behavior-preserving fix.", "- Re-run the verification procedure below; correlate by fingerprint.", "- Never suppress a finding merely to obtain a green scan (suppressions live in `mjolnir.config.json` and require a reason; only an explicit ISO `expires` date is bounded).");
|
|
8719
9600
|
lines.push("");
|
|
8720
9601
|
lines.push(scopeNote(options) ? scopeNote(options).trim() : "");
|
|
8721
9602
|
if (!scopeNote(options)) lines.pop();
|
|
@@ -8873,10 +9754,10 @@ function agentBrief(surface, cwdNote) {
|
|
|
8873
9754
|
`Run \`${cwdNote}npx mjolnir-qa@${ENGINE_VERSION} . --scope changed\` before finishing any task that touched test code.`,
|
|
8874
9755
|
"",
|
|
8875
9756
|
"- Exit 0 = clean. Exit 1 = new findings on changed lines: fix them (smallest behavior-preserving change) or justify them to the user.",
|
|
8876
|
-
`- npx mjolnir-qa@${ENGINE_VERSION} why <file>:<line> explains any finding (evidence level, measured FP rate, fix).`,
|
|
9757
|
+
`- npx mjolnir-qa@${ENGINE_VERSION} why <file>:<line> explains any finding (evidence level, measured FP rate or explicit unmeasured state, fix).`,
|
|
8877
9758
|
`- npx mjolnir-qa@${ENGINE_VERSION} handoff renders a full remediation plan from a saved --json report.`,
|
|
8878
9759
|
`- Agent loop: establish the before-state once with npx mjolnir-qa@${ENGINE_VERSION} baseline (commits .mjolnir/baseline.json); after fixing, npx mjolnir-qa@${ENGINE_VERSION} verify prints the before/after digest — resolved (per §15 lifecycle) / new / unchanged by ruleId+location, and the score delta. Exit 0 clean, 1 new errors, 2 partial or no baseline.`,
|
|
8879
|
-
"- NEVER suppress a finding merely to obtain a green scan (suppressions live in mjolnir.config.json
|
|
9760
|
+
"- NEVER suppress a finding merely to obtain a green scan (suppressions live in mjolnir.config.json and require a reason; only an explicit ISO `expires` date is bounded).",
|
|
8880
9761
|
"- After fixing: re-run the scan, report files changed, report checks not run, report unresolved findings honestly.",
|
|
8881
9762
|
"",
|
|
8882
9763
|
"## Agent safety contract (non-negotiable)",
|
|
@@ -9023,7 +9904,7 @@ function executeInstall(entries) {
|
|
|
9023
9904
|
if (e.action === "refuse" || e.action === "no-op") continue;
|
|
9024
9905
|
const dir = join(e.file, "..");
|
|
9025
9906
|
mkdirSync(dir, { recursive: true });
|
|
9026
|
-
|
|
9907
|
+
writeFileAtomic(e.file, e.content);
|
|
9027
9908
|
written++;
|
|
9028
9909
|
}
|
|
9029
9910
|
return written;
|
|
@@ -9101,19 +9982,12 @@ function hookBlock(version) {
|
|
|
9101
9982
|
function resolveHookTarget(cwd) {
|
|
9102
9983
|
const huskyDir = join(cwd, ".husky");
|
|
9103
9984
|
if (existsSync(huskyDir)) return join(huskyDir, "pre-commit");
|
|
9104
|
-
|
|
9105
|
-
|
|
9106
|
-
|
|
9107
|
-
|
|
9108
|
-
|
|
9109
|
-
|
|
9110
|
-
], { stdio: [
|
|
9111
|
-
"ignore",
|
|
9112
|
-
"pipe",
|
|
9113
|
-
"ignore"
|
|
9114
|
-
] }).toString().trim();
|
|
9115
|
-
return join(cwd, hooksPath, "pre-commit");
|
|
9116
|
-
} catch {}
|
|
9985
|
+
const hooksPath = runGit(cwd, ["config", "core.hooksPath"])?.trim();
|
|
9986
|
+
if (hooksPath) {
|
|
9987
|
+
const candidate = resolve(cwd, hooksPath);
|
|
9988
|
+
const relativePath = relative(cwd, candidate);
|
|
9989
|
+
if (relativePath === "" || !relativePath.startsWith("..") && !isAbsolute(relativePath)) return join(candidate, "pre-commit");
|
|
9990
|
+
}
|
|
9117
9991
|
return join(cwd, ".git", "hooks", "pre-commit");
|
|
9118
9992
|
}
|
|
9119
9993
|
/**
|
|
@@ -9150,22 +10024,22 @@ function executeHookInstall(entry) {
|
|
|
9150
10024
|
switch (entry.action) {
|
|
9151
10025
|
case "create":
|
|
9152
10026
|
mkdirSync(join(entry.file, ".."), { recursive: true });
|
|
9153
|
-
|
|
10027
|
+
writeFileAtomic(entry.file, `#!/bin/sh\n${hookBlock(ENGINE_VERSION)}\n`);
|
|
9154
10028
|
return true;
|
|
9155
10029
|
case "append": {
|
|
9156
10030
|
const existing = readFileSync(entry.file, "utf8");
|
|
9157
10031
|
const sep = existing.endsWith("\n") ? "" : "\n";
|
|
9158
|
-
|
|
10032
|
+
writeFileAtomic(entry.file, `${existing}${sep}\n${hookBlock(ENGINE_VERSION)}\n`);
|
|
9159
10033
|
return true;
|
|
9160
10034
|
}
|
|
9161
10035
|
case "update": {
|
|
9162
10036
|
const existing = readFileSync(entry.file, "utf8");
|
|
9163
10037
|
const openIdx = existing.indexOf(HOOK_MARKER_OPEN);
|
|
9164
10038
|
const closeIdx = existing.indexOf(HOOK_MARKER_CLOSE);
|
|
9165
|
-
if (openIdx !== -1 && closeIdx !== -1)
|
|
10039
|
+
if (openIdx !== -1 && closeIdx !== -1) writeFileAtomic(entry.file, existing.slice(0, openIdx) + hookBlock(ENGINE_VERSION) + existing.slice(closeIdx + 29));
|
|
9166
10040
|
else {
|
|
9167
10041
|
const sep = existing.endsWith("\n") ? "" : "\n";
|
|
9168
|
-
|
|
10042
|
+
writeFileAtomic(entry.file, `${existing}${sep}\n${hookBlock(ENGINE_VERSION)}\n`);
|
|
9169
10043
|
}
|
|
9170
10044
|
return true;
|
|
9171
10045
|
}
|
|
@@ -9619,7 +10493,7 @@ function checkArtifactIntegrity(root) {
|
|
|
9619
10493
|
what: "the artifact embeds its identity binding (scanId/commit/rule(rev)/evidence inventory)"
|
|
9620
10494
|
},
|
|
9621
10495
|
{
|
|
9622
|
-
ok: report.includes("TRUST_REPORT_HTML") && report.includes("renderTrustReportHtml") && /
|
|
10496
|
+
ok: report.includes("TRUST_REPORT_HTML") && report.includes("renderTrustReportHtml") && /write(?:FileSync|Artifact)\(join\(target,\s*TRUST_REPORT_HTML\)/.test(report),
|
|
9623
10497
|
what: "the HTML artifact ships and the command writes all three formats"
|
|
9624
10498
|
},
|
|
9625
10499
|
{
|
|
@@ -9642,6 +10516,15 @@ function checkArtifactIntegrity(root) {
|
|
|
9642
10516
|
details: bad
|
|
9643
10517
|
};
|
|
9644
10518
|
}
|
|
10519
|
+
function isReleaseVersion(value) {
|
|
10520
|
+
const isNumeric = (part) => /^\d+$/.test(part) && (part === "0" || !part.startsWith("0"));
|
|
10521
|
+
const [core, prerelease, ...extra] = value.split("-");
|
|
10522
|
+
const [major, minor, patch, ...coreExtra] = (core ?? "").split(".");
|
|
10523
|
+
if (extra.length > 0 || major === void 0 || minor === void 0 || patch === void 0 || coreExtra.length > 0 || !isNumeric(major) || !isNumeric(minor) || !isNumeric(patch)) return false;
|
|
10524
|
+
if (prerelease === void 0) return true;
|
|
10525
|
+
const [kind, number, ...rcExtra] = prerelease.split(".");
|
|
10526
|
+
return kind === "rc" && number !== void 0 && isNumeric(number) && rcExtra.length === 0;
|
|
10527
|
+
}
|
|
9645
10528
|
function checkReleaseVersionConsistency(root) {
|
|
9646
10529
|
const pkgPath = join(root, "package.json");
|
|
9647
10530
|
const clPath = join(root, "CHANGELOG.md");
|
|
@@ -9651,9 +10534,14 @@ function checkReleaseVersionConsistency(root) {
|
|
|
9651
10534
|
details: ["package.json or CHANGELOG.md missing"]
|
|
9652
10535
|
};
|
|
9653
10536
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
9654
|
-
const
|
|
9655
|
-
|
|
9656
|
-
|
|
10537
|
+
const head = readFileSync(clPath, "utf8").split(/\r?\n/).reduce((found, line) => {
|
|
10538
|
+
if (found !== void 0 || !line.startsWith("## [")) return found;
|
|
10539
|
+
const headingEnd = line.indexOf("]", 4);
|
|
10540
|
+
if (headingEnd < 5) return void 0;
|
|
10541
|
+
const candidate = line.slice(4, headingEnd).trim();
|
|
10542
|
+
return isReleaseVersion(candidate) ? candidate : void 0;
|
|
10543
|
+
}, void 0);
|
|
10544
|
+
if (!pkg.version || !isReleaseVersion(pkg.version) || head === void 0 || !isReleaseVersion(head)) return {
|
|
9657
10545
|
evidence: "INCONCLUSIVE",
|
|
9658
10546
|
determination: "INCONCLUSIVE",
|
|
9659
10547
|
details: ["unparsable version surfaces"]
|
|
@@ -9873,98 +10761,90 @@ function runReleaseTrustCommand(argv, io = {
|
|
|
9873
10761
|
//#endregion
|
|
9874
10762
|
//#region src/commands/business-case.ts
|
|
9875
10763
|
/**
|
|
9876
|
-
* `mjolnir business-case` —
|
|
9877
|
-
*
|
|
9878
|
-
*
|
|
9879
|
-
*
|
|
9880
|
-
*
|
|
9881
|
-
*
|
|
9882
|
-
*
|
|
9883
|
-
*
|
|
9884
|
-
*
|
|
9885
|
-
*
|
|
9886
|
-
*
|
|
9887
|
-
*
|
|
9888
|
-
|
|
9889
|
-
|
|
9890
|
-
|
|
9891
|
-
|
|
9892
|
-
|
|
9893
|
-
|
|
9894
|
-
|
|
9895
|
-
|
|
9896
|
-
|
|
9897
|
-
|
|
9898
|
-
|
|
9899
|
-
|
|
9900
|
-
|
|
9901
|
-
|
|
9902
|
-
default: "general",
|
|
9903
|
-
fintech: "financial services (regulatory fines, PCI/HIPAA)",
|
|
9904
|
-
healthcare: "healthcare (HIPAA, patient safety)",
|
|
9905
|
-
ecommerce: "e-commerce (cart abandonment, lost sales)",
|
|
9906
|
-
saas: "SaaS (churn, reputation)",
|
|
9907
|
-
enterprise: "enterprise (contract penalties, SLA breaches)",
|
|
9908
|
-
gaming: "gaming (player trust, reviews)",
|
|
9909
|
-
education: "education (student data, accreditation)"
|
|
9910
|
-
};
|
|
9911
|
-
/** Calculate expected savings per finding. */
|
|
10764
|
+
* `mjolnir business-case` — measured false-positive rates, and the
|
|
10765
|
+
* arithmetic a reader can audit.
|
|
10766
|
+
*
|
|
10767
|
+
* What changed, and why: this command multiplied a MEASURED false-positive
|
|
10768
|
+
* rate by a table of invented incident costs (fintech $50 000, healthcare
|
|
10769
|
+
* $100 000, …) with no source, and printed the product as "Expected
|
|
10770
|
+
* Savings" and "Total potential savings". A dollar figure with no
|
|
10771
|
+
* provenance is worse than no dollar figure: someone puts it in a
|
|
10772
|
+
* business case and defends it in a room. It also had a `--history` flag
|
|
10773
|
+
* whose own help text promised "estimates from actual scan improvements"
|
|
10774
|
+
* and which did nothing but print a pointer elsewhere, and a `--projected`
|
|
10775
|
+
* flag that divided the total by six and called the result a monthly rate.
|
|
10776
|
+
*
|
|
10777
|
+
* So the invented cost table is gone. The one number this command can
|
|
10778
|
+
* defend is the measured false-positive rate and how many findings carry
|
|
10779
|
+
* it. A dollar conversion happens only when the reader supplies the cost
|
|
10780
|
+
* of an incident themselves (`--incident-cost`), because then the number
|
|
10781
|
+
* has a source: theirs.
|
|
10782
|
+
*
|
|
10783
|
+
* Scheduled for removal in 5.0 — see docs/RELEASE-TRAINS.md.
|
|
10784
|
+
*/
|
|
10785
|
+
/**
|
|
10786
|
+
* Evidence weight per level, applied to a user-supplied incident cost.
|
|
10787
|
+
* E0 is an observation and never counts — there is nothing to save against
|
|
10788
|
+
* a claim nobody made.
|
|
10789
|
+
*/
|
|
9912
10790
|
function expectedSavings(fpRate, evidenceLevel, incidentCost) {
|
|
9913
10791
|
if (fpRate === null || fpRate === void 0) return null;
|
|
9914
10792
|
const confidence = evidenceLevel === "E2" ? 1 : evidenceLevel === "E1" ? .5 : 0;
|
|
9915
10793
|
if (confidence === 0) return null;
|
|
9916
10794
|
return Math.round(incidentCost * (1 - fpRate) * confidence);
|
|
9917
10795
|
}
|
|
9918
|
-
function renderSummary(savingsPerFinding, incidentCost,
|
|
10796
|
+
function renderSummary(savingsPerFinding, incidentCost, totalSavings) {
|
|
10797
|
+
const measured = savingsPerFinding.filter((s) => s.fpRate !== null).length;
|
|
9919
10798
|
const lines = [
|
|
9920
|
-
"Mjölnir
|
|
9921
|
-
"
|
|
10799
|
+
"Mjölnir — measured false-positive rates on the findings that fired",
|
|
10800
|
+
"=".repeat(58),
|
|
9922
10801
|
"",
|
|
9923
|
-
`
|
|
9924
|
-
|
|
9925
|
-
|
|
10802
|
+
`Measured: ${measured} of ${savingsPerFinding.length} findings carry a corpus-measured FP rate.`,
|
|
10803
|
+
...incidentCost === null ? [
|
|
10804
|
+
"",
|
|
10805
|
+
"NO DOLLAR FIGURES ARE SHOWN. Converting a false-positive rate into money",
|
|
10806
|
+
"requires the cost of one false-green incident in YOUR organisation, which",
|
|
10807
|
+
"this tool does not know and will not invent. Pass --incident-cost <n> to",
|
|
10808
|
+
"see the arithmetic; the number is then yours, not Mjölnir's."
|
|
10809
|
+
] : [
|
|
10810
|
+
"",
|
|
10811
|
+
`Incident cost used: $${incidentCost.toLocaleString()} (your figure, from --incident-cost).`,
|
|
10812
|
+
`Findings that fired: ${savingsPerFinding.length}.`
|
|
10813
|
+
],
|
|
9926
10814
|
"",
|
|
9927
|
-
"| Rule ID | FP Rate | Evidence | Expected
|
|
9928
|
-
"| ------- | ------- | -------- |
|
|
10815
|
+
"| Rule ID | FP Rate | n | Evidence | Expected cost |",
|
|
10816
|
+
"| ------- | ------- | - | -------- | ------------- |"
|
|
9929
10817
|
];
|
|
9930
|
-
for (const { ruleId, fpRate, evidenceLevel, expectedSavings: savings } of savingsPerFinding) {
|
|
9931
|
-
const fpDisplay = fpRate !== null ? `${Math.round(fpRate * 100)}%` : "
|
|
9932
|
-
const
|
|
9933
|
-
|
|
9934
|
-
|
|
9935
|
-
|
|
9936
|
-
lines.push("
|
|
9937
|
-
lines.push("
|
|
9938
|
-
lines.push("
|
|
9939
|
-
lines.push("- E1
|
|
9940
|
-
lines.push("
|
|
9941
|
-
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()}`);
|
|
9942
10831
|
return lines.join("\n");
|
|
9943
10832
|
}
|
|
9944
|
-
function renderProjection(monthlySavings, months, incidentCost) {
|
|
9945
|
-
const totalProjected = monthlySavings * months;
|
|
9946
|
-
const incidentsPrevented = Math.round(totalProjected / incidentCost);
|
|
9947
|
-
return [
|
|
9948
|
-
"",
|
|
9949
|
-
"SAVINGS PROJECTION",
|
|
9950
|
-
"==================",
|
|
9951
|
-
"",
|
|
9952
|
-
`Monthly savings estimate: $${monthlySavings.toLocaleString()}`,
|
|
9953
|
-
`Projection period: ${months} months`,
|
|
9954
|
-
`Projected total savings: $${totalProjected.toLocaleString()}`,
|
|
9955
|
-
`Incidents prevented: ~${incidentsPrevented} (at $${incidentCost.toLocaleString()}/incident)`,
|
|
9956
|
-
""
|
|
9957
|
-
].join("\n");
|
|
9958
|
-
}
|
|
9959
10833
|
/**
|
|
9960
10834
|
* Entry point for `mjolnir business-case`.
|
|
9961
10835
|
*
|
|
9962
10836
|
* Flags:
|
|
9963
|
-
* --
|
|
9964
|
-
*
|
|
9965
|
-
*
|
|
9966
|
-
* --projected <months> Show projected savings over N months
|
|
10837
|
+
* --incident-cost <n> The cost of ONE false-green incident in your
|
|
10838
|
+
* organisation. Until you supply it, no dollar
|
|
10839
|
+
* figure is printed at all.
|
|
9967
10840
|
* --strict Include quarantine-tier findings
|
|
10841
|
+
*
|
|
10842
|
+
* `--history` and `--projected` are GONE. Both promised arithmetic this
|
|
10843
|
+
* tool cannot do: `--history` claimed to estimate from actual scan
|
|
10844
|
+
* improvements while reading no history at all, and `--projected` divided
|
|
10845
|
+
* the total by six and called the quotient a monthly rate. A flag that
|
|
10846
|
+
* does not do what its help says is worse than no flag, because the help
|
|
10847
|
+
* is the promise.
|
|
9968
10848
|
*/
|
|
9969
10849
|
async function runBusinessCaseCommand(argv, io = {
|
|
9970
10850
|
out,
|
|
@@ -9972,25 +10852,29 @@ async function runBusinessCaseCommand(argv, io = {
|
|
|
9972
10852
|
}) {
|
|
9973
10853
|
try {
|
|
9974
10854
|
const target = argv.find((a) => !a.startsWith("-")) ?? ".";
|
|
9975
|
-
const industryIdx = argv.indexOf("--industry");
|
|
9976
|
-
const industry = industryIdx !== -1 ? argv[industryIdx + 1] ?? "default" : "default";
|
|
9977
|
-
const historyIdx = argv.indexOf("--history");
|
|
9978
|
-
const historyMonths = historyIdx !== -1 ? Number.parseInt(argv[historyIdx + 1] ?? "", 10) : 0;
|
|
9979
|
-
const projectedIdx = argv.indexOf("--projected");
|
|
9980
|
-
const projectedMonths = projectedIdx !== -1 ? Number.parseInt(argv[projectedIdx + 1] ?? "", 10) : 0;
|
|
9981
10855
|
const strict = argv.includes("--strict");
|
|
9982
|
-
const
|
|
9983
|
-
|
|
9984
|
-
|
|
9985
|
-
|
|
9986
|
-
|
|
10856
|
+
for (const gone of [
|
|
10857
|
+
"--industry",
|
|
10858
|
+
"--history",
|
|
10859
|
+
"--projected"
|
|
10860
|
+
]) if (argv.includes(gone)) {
|
|
10861
|
+
(io.err ?? err)(`${gone} is no longer accepted. ` + (gone === "--industry" ? "Incident costs are not industry defaults; pass --incident-cost <n> with a figure from your own incident history." : gone === "--history" ? "It read no history. Use `mjolnir impact --since <date>` for evidence-backed change data." : "A projection is an arithmetic identity, not an estimate. Use `mjolnir trend` for measured history."));
|
|
10862
|
+
return 10;
|
|
9987
10863
|
}
|
|
9988
|
-
|
|
9989
|
-
|
|
9990
|
-
|
|
10864
|
+
const costIdx = argv.indexOf("--incident-cost");
|
|
10865
|
+
let incidentCost = null;
|
|
10866
|
+
if (costIdx !== -1) {
|
|
10867
|
+
const raw = argv[costIdx + 1];
|
|
10868
|
+
const parsed = Number.parseInt(raw ?? "", 10);
|
|
10869
|
+
if (raw === void 0 || raw.startsWith("-") || !Number.isFinite(parsed) || parsed <= 0) {
|
|
10870
|
+
(io.err ?? err)("--incident-cost requires a positive number: the cost of one false-green incident.");
|
|
10871
|
+
return 10;
|
|
10872
|
+
}
|
|
10873
|
+
incidentCost = parsed;
|
|
9991
10874
|
}
|
|
9992
|
-
const incidentCost = INDUSTRY_COSTS[industry] ?? DEFAULT_INCIDENT_COST;
|
|
9993
10875
|
io.out(`Scanning ${target} ...`);
|
|
10876
|
+
const invalid = validateScanTarget(target, io.err ?? err);
|
|
10877
|
+
if (invalid !== null) return invalid;
|
|
9994
10878
|
const savingsPerFinding = (await runScan({
|
|
9995
10879
|
target,
|
|
9996
10880
|
json: false,
|
|
@@ -9999,29 +10883,18 @@ async function runBusinessCaseCommand(argv, io = {
|
|
|
9999
10883
|
scopeChanged: false,
|
|
10000
10884
|
format: "terminal",
|
|
10001
10885
|
strict
|
|
10002
|
-
})).findings.map((f) => {
|
|
10003
|
-
|
|
10004
|
-
|
|
10005
|
-
|
|
10006
|
-
|
|
10007
|
-
|
|
10008
|
-
|
|
10009
|
-
|
|
10010
|
-
|
|
10011
|
-
|
|
10012
|
-
|
|
10013
|
-
|
|
10014
|
-
io.out(renderSummary(savingsPerFinding, incidentCost, industry, totalExpectedSavings));
|
|
10015
|
-
if (projectedMonths > 0) {
|
|
10016
|
-
const monthlySavings = Math.round(totalExpectedSavings / 6);
|
|
10017
|
-
io.out(renderProjection(monthlySavings, projectedMonths, incidentCost));
|
|
10018
|
-
}
|
|
10019
|
-
if (historyMonths > 0) io.out(`\nHistorical analysis (last ${historyMonths} months):\n NOTE: Historical incident costing requires CI log access.
|
|
10020
|
-
Run \`mjolnir impact --since \${historyMonths}.months.ago\` for
|
|
10021
|
-
evidence-backed impact data from git history.
|
|
10022
|
-
Run \`mjolnir release-report --since v\${historyMonths}.0.0\` for
|
|
10023
|
-
release-quality trajectory.`);
|
|
10024
|
-
io.out("\nThese are projections based on measured FP rates from the OSS corpus.");
|
|
10886
|
+
})).findings.map((f) => ({
|
|
10887
|
+
ruleId: f.ruleId,
|
|
10888
|
+
fpRate: f.measuredFpRate ?? null,
|
|
10889
|
+
fpN: f.measuredFpN ?? null,
|
|
10890
|
+
evidenceLevel: evidenceLevelOf(f),
|
|
10891
|
+
expectedSavings: incidentCost === null ? null : expectedSavings(f.measuredFpRate ?? null, evidenceLevelOf(f), incidentCost)
|
|
10892
|
+
}));
|
|
10893
|
+
const totalExpectedSavings = incidentCost === null ? null : savingsPerFinding.reduce((sum, f) => sum + (f.expectedSavings ?? 0), 0);
|
|
10894
|
+
io.out(renderSummary(savingsPerFinding, incidentCost, totalExpectedSavings));
|
|
10895
|
+
io.out("");
|
|
10896
|
+
io.out("The FP rates above are measured on the OSS corpus. The dollar column, if you");
|
|
10897
|
+
io.out("asked for one, uses YOUR incident cost — Mjölnir does not estimate it.");
|
|
10025
10898
|
io.out("Run with --strict to also surface quarantine-tier findings.");
|
|
10026
10899
|
return 0;
|
|
10027
10900
|
} catch (e) {
|
|
@@ -10038,10 +10911,18 @@ async function runBusinessCaseCommand(argv, io = {
|
|
|
10038
10911
|
* posture and renders a management-ready verdict:
|
|
10039
10912
|
* GO · CONDITIONAL GO · NO-GO
|
|
10040
10913
|
*
|
|
10041
|
-
*
|
|
10914
|
+
* What changed, and why: this report hardcoded `hygieneFixed: 0`,
|
|
10915
|
+
* `hygieneIntroduced: 0`, `newTestsAdded: 0`, `newTestsWithoutAssertions: 0`
|
|
10916
|
+
* and `flakyAtLastRelease: 0`, and printed them as measured figures beside a
|
|
10917
|
+
* ✓/↑ trend mark — so a report that measured nothing looked like a report
|
|
10918
|
+
* that measured an absence. It also added 0.5/0.2/0.1 engineer-hours per
|
|
10919
|
+
* finding to produce a "test-debt cost estimate" nobody had estimated, and it
|
|
10920
|
+
* ignored the scan's `partial` flag, so a truncated scan could reach GO.
|
|
10042
10921
|
*
|
|
10043
|
-
*
|
|
10044
|
-
*
|
|
10922
|
+
* Unmeasured values are now `null` and print as "not measured". Trend marks
|
|
10923
|
+
* require two real measurements. GO requires a complete scan. The release
|
|
10924
|
+
* decision itself moves to the canonical proof in v5 (V5-070); until then this
|
|
10925
|
+
* is a summary, and says so.
|
|
10045
10926
|
*/
|
|
10046
10927
|
const ui$7 = plainContext();
|
|
10047
10928
|
/** Rules that count as CI integrity issues for release verdicts. */
|
|
@@ -10050,34 +10931,40 @@ const CI_INTEGRITY_RULES = /* @__PURE__ */ new Set([
|
|
|
10050
10931
|
"QA-CI-002",
|
|
10051
10932
|
"QA-CI-008"
|
|
10052
10933
|
]);
|
|
10934
|
+
const NOT_MEASURED = "not measured";
|
|
10935
|
+
function show(value) {
|
|
10936
|
+
return value === null ? NOT_MEASURED : String(value);
|
|
10937
|
+
}
|
|
10053
10938
|
function renderVerdictBlock(v) {
|
|
10054
10939
|
const lines = [];
|
|
10055
10940
|
lines.push(sectionHeader(`RELEASE READINESS — since ${v.since}`, ui$7));
|
|
10056
10941
|
lines.push("");
|
|
10057
10942
|
lines.push(`Score: ${v.score !== null ? v.score + "/100" : "unknown"}`);
|
|
10943
|
+
lines.push(`Analysis complete: ${v.partial ? "no (PARTIAL)" : "yes"}`);
|
|
10944
|
+
if (v.partial) lines.push("A partial scan cannot support a GO verdict: the unanalyzed surface is exactly where an unknown finding would live.");
|
|
10058
10945
|
lines.push("");
|
|
10059
10946
|
lines.push("Test hygiene since last release:");
|
|
10060
|
-
lines.push(` Issues fixed: ${v.hygieneFixed}`);
|
|
10061
|
-
lines.push(` Issues introduced: ${v.hygieneIntroduced}`);
|
|
10947
|
+
lines.push(` Issues fixed: ${show(v.hygieneFixed)}`);
|
|
10948
|
+
lines.push(` Issues introduced: ${show(v.hygieneIntroduced)}`);
|
|
10062
10949
|
lines.push("");
|
|
10063
10950
|
lines.push("New tests added:");
|
|
10064
|
-
lines.push(` Total: ${v.newTestsAdded}`);
|
|
10065
|
-
lines.push(` Without assertions: ${v.newTestsWithoutAssertions}`);
|
|
10951
|
+
lines.push(` Total: ${show(v.newTestsAdded)}`);
|
|
10952
|
+
lines.push(` Without assertions: ${show(v.newTestsWithoutAssertions)}`);
|
|
10066
10953
|
lines.push("");
|
|
10067
10954
|
lines.push("Skipped during the cycle:");
|
|
10068
|
-
lines.push(` ${v.skippedDuringCycle} ← what we are NOT verifying`);
|
|
10955
|
+
lines.push(` ${v.skippedDuringCycle}${v.skippedDuringCycle > 0 ? " ← what we are NOT verifying" : ""}`);
|
|
10069
10956
|
lines.push("");
|
|
10070
10957
|
lines.push("Flaky tests:");
|
|
10071
10958
|
lines.push(` At release: ${v.flakyAtRelease}`);
|
|
10072
|
-
const improvement = v.flakyAtRelease <= v.flakyAtLastRelease ? " ✓" : " ↑";
|
|
10073
|
-
lines.push(` At last release: ${v.flakyAtLastRelease}${improvement}`);
|
|
10959
|
+
const improvement = v.flakyAtLastRelease === null ? "" : v.flakyAtRelease <= v.flakyAtLastRelease ? " ✓" : " ↑";
|
|
10960
|
+
lines.push(` At last release: ${show(v.flakyAtLastRelease)}${improvement}`);
|
|
10074
10961
|
lines.push("");
|
|
10075
10962
|
lines.push("CI integrity:");
|
|
10076
10963
|
const coeMark = v.continueOnErrorActive === 0 ? " ✓" : " ⚠";
|
|
10077
10964
|
lines.push(` continue-on-error still active: ${v.continueOnErrorActive}${coeMark}`);
|
|
10078
10965
|
lines.push(` Total CI integrity issues: ${v.ciIntegrityIssues}`);
|
|
10079
10966
|
lines.push("");
|
|
10080
|
-
lines.push(`Test-debt cost estimate:
|
|
10967
|
+
lines.push(`Test-debt cost estimate: ${v.testDebtHours === null ? NOT_MEASURED : `~${v.testDebtHours.toFixed(1)} engineer-hours/qtr`}`);
|
|
10081
10968
|
lines.push("");
|
|
10082
10969
|
lines.push(`Blocking findings (error): ${v.blockingFindings}`);
|
|
10083
10970
|
lines.push(`Warning findings (warning): ${v.advisoryFindings}`);
|
|
@@ -10097,7 +10984,7 @@ function renderVerdictBlock(v) {
|
|
|
10097
10984
|
if (v.continueOnErrorActive > 0) lines.push(` → ${v.continueOnErrorActive} continue-on-error still active — review before ship`);
|
|
10098
10985
|
if (v.skippedDuringCycle > 0) lines.push(` → ${v.skippedDuringCycle} skipped test(s) — verify coverage gaps`);
|
|
10099
10986
|
if (v.blockingFindings === 0 && v.advisoryFindings > 0) lines.push(` → ${v.advisoryFindings} advisory finding(s) — review before ship`);
|
|
10100
|
-
} else lines.push("
|
|
10987
|
+
} else lines.push("No gate in this report fired. This is a summary of one static scan, not a release authorization: the canonical release proof is the authority, and it requires candidate-bound evidence this command does not have.");
|
|
10101
10988
|
lines.push("");
|
|
10102
10989
|
lines.push("Screenshot this report into the release channel.");
|
|
10103
10990
|
return lines.join("\n");
|
|
@@ -10113,6 +11000,13 @@ function determineVerdict(result, history) {
|
|
|
10113
11000
|
reasons
|
|
10114
11001
|
};
|
|
10115
11002
|
}
|
|
11003
|
+
if (result.partial) {
|
|
11004
|
+
reasons.push("scan was PARTIAL — the unanalyzed surface is unverified");
|
|
11005
|
+
return {
|
|
11006
|
+
verdict: "NO-GO",
|
|
11007
|
+
reasons
|
|
11008
|
+
};
|
|
11009
|
+
}
|
|
10116
11010
|
const flakyCount = history.flakyAtRelease ?? 0;
|
|
10117
11011
|
const ciIssues = history.continueOnErrorActive ?? 0;
|
|
10118
11012
|
const skipped = history.skippedDuringCycle ?? 0;
|
|
@@ -10135,24 +11029,20 @@ function buildReleaseReport(result, since, history = {}) {
|
|
|
10135
11029
|
const { verdict } = determineVerdict(result, history);
|
|
10136
11030
|
const errorFindings = result.findings.filter((f) => f.severity === "error");
|
|
10137
11031
|
const warningFindings = result.findings.filter((f) => f.severity === "warning");
|
|
10138
|
-
let testDebtHours = 0;
|
|
10139
|
-
for (const f of result.findings) if (CI_INTEGRITY_RULES.has(f.ruleId)) testDebtHours += .5;
|
|
10140
|
-
else if (f.severity === "error") testDebtHours += .5;
|
|
10141
|
-
else if (f.severity === "warning") testDebtHours += .2;
|
|
10142
|
-
else testDebtHours += .1;
|
|
10143
11032
|
return {
|
|
10144
11033
|
verdict,
|
|
10145
11034
|
since,
|
|
10146
|
-
|
|
10147
|
-
|
|
10148
|
-
|
|
10149
|
-
|
|
11035
|
+
partial: result.partial,
|
|
11036
|
+
hygieneFixed: null,
|
|
11037
|
+
hygieneIntroduced: null,
|
|
11038
|
+
newTestsAdded: null,
|
|
11039
|
+
newTestsWithoutAssertions: null,
|
|
10150
11040
|
skippedDuringCycle: history.skippedDuringCycle ?? 0,
|
|
10151
11041
|
flakyAtRelease: history.flakyAtRelease ?? 0,
|
|
10152
|
-
flakyAtLastRelease:
|
|
11042
|
+
flakyAtLastRelease: null,
|
|
10153
11043
|
ciIntegrityIssues: warningFindings.length,
|
|
10154
11044
|
continueOnErrorActive: errorFindings.filter((f) => CI_INTEGRITY_RULES.has(f.ruleId)).length,
|
|
10155
|
-
testDebtHours,
|
|
11045
|
+
testDebtHours: history.testDebtHours ?? null,
|
|
10156
11046
|
blockingFindings: errorFindings.length,
|
|
10157
11047
|
advisoryFindings: warningFindings.length,
|
|
10158
11048
|
score: result.score,
|
|
@@ -10194,18 +11084,18 @@ async function runReleaseReportCommand(argv, io) {
|
|
|
10194
11084
|
target,
|
|
10195
11085
|
json: false,
|
|
10196
11086
|
verbose: false,
|
|
10197
|
-
maxDurationMs:
|
|
11087
|
+
maxDurationMs: 6e5,
|
|
10198
11088
|
scopeChanged: false,
|
|
10199
11089
|
format: "terminal",
|
|
10200
11090
|
strict: false
|
|
10201
11091
|
}), since, history);
|
|
10202
11092
|
io.out(renderReleaseReport(report));
|
|
10203
|
-
|
|
10204
|
-
|
|
10205
|
-
|
|
10206
|
-
case "NO-GO": return 1;
|
|
10207
|
-
default: return 20;
|
|
11093
|
+
if (report.verdict === "GO") {
|
|
11094
|
+
io.err("mjolnir release-report: this is a static-scan summary, not release authorization. Run the release gate (npm run candidate:decision:release) before shipping.");
|
|
11095
|
+
return 0;
|
|
10208
11096
|
}
|
|
11097
|
+
if (report.verdict === "CONDITIONAL GO" || report.verdict === "NO-GO") return 1;
|
|
11098
|
+
return 20;
|
|
10209
11099
|
} catch (e) {
|
|
10210
11100
|
internalErrorMessage(e, io.err, false);
|
|
10211
11101
|
return 20;
|
|
@@ -10214,56 +11104,42 @@ async function runReleaseReportCommand(argv, io) {
|
|
|
10214
11104
|
//#endregion
|
|
10215
11105
|
//#region src/commands/report-playwright.ts
|
|
10216
11106
|
/**
|
|
10217
|
-
* `mjolnir report
|
|
11107
|
+
* `mjolnir report` — Mjölnir findings in the Playwright report shape.
|
|
10218
11108
|
*
|
|
10219
|
-
*
|
|
10220
|
-
*
|
|
11109
|
+
* What this command does NOT do, and why: a Playwright report describes tests
|
|
11110
|
+
* that were EXECUTED, with per-test outcomes and durations. A static scan
|
|
11111
|
+
* executed nothing. Presenting findings as Playwright `suites`/`tests` with a
|
|
11112
|
+
* `passed` status manufactured a runtime result that never happened — and on a
|
|
11113
|
+
* clean scan it published "0 tests, all passed", which a Playwright consumer
|
|
11114
|
+
* reads as a green test run.
|
|
10221
11115
|
*
|
|
10222
|
-
*
|
|
10223
|
-
*
|
|
11116
|
+
* So the execution block is empty and says so, and the findings live in the
|
|
11117
|
+
* `mjolnir` extension block where they are honestly labelled as static
|
|
11118
|
+
* analysis. The file is a findings report in a familiar shape, not a test run.
|
|
10224
11119
|
*/
|
|
10225
11120
|
const ui$6 = plainContext();
|
|
10226
|
-
function findingOutcome(f) {
|
|
10227
|
-
if (f.severity === "error") return "failed";
|
|
10228
|
-
if (f.severity === "warning") return "expectedFailure";
|
|
10229
|
-
return "passed";
|
|
10230
|
-
}
|
|
10231
11121
|
function buildPlaywrightReport(result) {
|
|
10232
11122
|
const now = /* @__PURE__ */ new Date();
|
|
10233
|
-
const
|
|
10234
|
-
|
|
10235
|
-
|
|
10236
|
-
existing.push(f);
|
|
10237
|
-
findingsByFile.set(f.file, existing);
|
|
10238
|
-
}
|
|
10239
|
-
const suites = Array.from(findingsByFile.entries()).map(([file, findings]) => ({
|
|
10240
|
-
title: file,
|
|
10241
|
-
file,
|
|
10242
|
-
tests: findings.map((f) => ({
|
|
10243
|
-
title: `${f.ruleId}: ${f.message}`,
|
|
10244
|
-
path: file,
|
|
10245
|
-
outcome: findingOutcome(f),
|
|
10246
|
-
duration: 0,
|
|
10247
|
-
annotations: [{
|
|
10248
|
-
type: f.severity === "error" ? "error" : "warning",
|
|
10249
|
-
message: `${f.ruleId} — ${f.message}${f.fix ? `\nFix: ${f.fix}` : ""}`
|
|
10250
|
-
}]
|
|
10251
|
-
}))
|
|
10252
|
-
}));
|
|
10253
|
-
const allTests = suites.flatMap((s) => s.tests);
|
|
11123
|
+
const partial = result.partial === true;
|
|
11124
|
+
const hasError = result.findings.some((f) => f.severity === "error");
|
|
11125
|
+
const status = partial ? "interrupted" : hasError ? "failed" : "passed";
|
|
10254
11126
|
return {
|
|
10255
11127
|
version: 1,
|
|
10256
11128
|
startTime: now.toISOString(),
|
|
10257
11129
|
endTime: now.toISOString(),
|
|
10258
11130
|
duration: 0,
|
|
10259
|
-
status
|
|
10260
|
-
totalTests:
|
|
10261
|
-
passedTests:
|
|
10262
|
-
failedTests:
|
|
10263
|
-
suites,
|
|
11131
|
+
status,
|
|
11132
|
+
totalTests: 0,
|
|
11133
|
+
passedTests: 0,
|
|
11134
|
+
failedTests: 0,
|
|
11135
|
+
suites: [],
|
|
10264
11136
|
mjolnir: {
|
|
11137
|
+
execution: "STATIC_ANALYSIS",
|
|
11138
|
+
status,
|
|
11139
|
+
partial,
|
|
10265
11140
|
score: result.score,
|
|
10266
11141
|
framework: result.frameworks[0] ?? "unknown",
|
|
11142
|
+
frameworkDetectionUnknown: result.frameworkDetectionUnknown === true,
|
|
10267
11143
|
findings: result.findings.map((f) => {
|
|
10268
11144
|
const entry = {
|
|
10269
11145
|
ruleId: f.ruleId,
|
|
@@ -10288,12 +11164,19 @@ async function runReportPlaywrightCommand(argv, io) {
|
|
|
10288
11164
|
io.err(`mjolnir report playwright: target does not exist: ${target}`);
|
|
10289
11165
|
return 10;
|
|
10290
11166
|
}
|
|
11167
|
+
const root = resolve(target);
|
|
11168
|
+
const requestedOutput = resolve(outputPath);
|
|
11169
|
+
const outputRelative = relative(root, requestedOutput);
|
|
11170
|
+
if (isAbsolute(outputRelative) || outputRelative.startsWith("..")) {
|
|
11171
|
+
io.err("mjolnir report playwright: output must stay within the target root");
|
|
11172
|
+
return 10;
|
|
11173
|
+
}
|
|
10291
11174
|
try {
|
|
10292
11175
|
const result = await runScan$1({
|
|
10293
11176
|
target,
|
|
10294
11177
|
json: true,
|
|
10295
11178
|
verbose: false,
|
|
10296
|
-
maxDurationMs:
|
|
11179
|
+
maxDurationMs: 6e5,
|
|
10297
11180
|
scopeChanged: false,
|
|
10298
11181
|
format: "json",
|
|
10299
11182
|
strict: false
|
|
@@ -10301,15 +11184,21 @@ async function runReportPlaywrightCommand(argv, io) {
|
|
|
10301
11184
|
const report = buildPlaywrightReport(result);
|
|
10302
11185
|
const outDir = resolve(".");
|
|
10303
11186
|
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
|
|
10304
|
-
const fullPath =
|
|
10305
|
-
|
|
10306
|
-
const
|
|
11187
|
+
const fullPath = requestedOutput;
|
|
11188
|
+
writeFileAtomic(fullPath, JSON.stringify(report, null, 2) + "\n", { encoding: "utf8" });
|
|
11189
|
+
const decision = decideClaim({
|
|
11190
|
+
partial: result.partial,
|
|
11191
|
+
blockingFindings: result.findings.filter((f) => f.severity === "error").length,
|
|
11192
|
+
supported: true
|
|
11193
|
+
});
|
|
11194
|
+
const header = sectionHeader("PLAYWRIGHT-SHAPED FINDINGS REPORT", ui$6);
|
|
10307
11195
|
io.out(`${header}\n`);
|
|
10308
11196
|
io.out(`Score: ${result.score !== null ? result.score + "/100" : "unknown"}`);
|
|
10309
11197
|
io.out(`Findings: ${result.findings.length} (${result.findings.filter((f) => f.severity === "error").length} error, ${result.findings.filter((f) => f.severity === "warning").length} warning)`);
|
|
10310
11198
|
io.out(`Report written: ${fullPath}`);
|
|
10311
|
-
io.out(
|
|
10312
|
-
|
|
11199
|
+
io.out("Executed tests: 0 — this is static analysis, not a test run. The Playwright suite block is empty by design.");
|
|
11200
|
+
io.out(`Determination: ${decision.state} — ${decision.reason}`);
|
|
11201
|
+
return decision.exitCode;
|
|
10313
11202
|
} catch (e) {
|
|
10314
11203
|
internalErrorMessage(e, io.err, false);
|
|
10315
11204
|
return 20;
|
|
@@ -10449,32 +11338,49 @@ async function runTrendCommand(argv, io) {
|
|
|
10449
11338
|
//#endregion
|
|
10450
11339
|
//#region src/commands/exec-report.ts
|
|
10451
11340
|
/**
|
|
10452
|
-
* `mjolnir exec-report` —
|
|
11341
|
+
* `mjolnir exec-report` — a short, measured summary of one scan.
|
|
10453
11342
|
*
|
|
10454
|
-
*
|
|
10455
|
-
*
|
|
11343
|
+
* What changed, and why: this command used to render an executive "Risk Level:
|
|
11344
|
+
* LOW", KPI deltas ("↑ healthy") with no previous run to compare against,
|
|
11345
|
+
* and assurances ("Score 85/100 is excellent — maintain current quality gate")
|
|
11346
|
+
* that no measurement supports. It also ignored the `partial` flag entirely,
|
|
11347
|
+
* so a truncated scan could print "No findings — clean scan." and exit 0.
|
|
10456
11348
|
*
|
|
10457
|
-
*
|
|
10458
|
-
*
|
|
11349
|
+
* It now reports only what the scan measured, names the surface each number
|
|
11350
|
+
* came from, and defers the verdict to the one determination function
|
|
11351
|
+
* (`decideClaim`) so a partial scan can never read as clean. Whether a
|
|
11352
|
+
* business is "low risk" is a decision this tool does not make for anyone.
|
|
10459
11353
|
*/
|
|
10460
11354
|
const ui$4 = plainContext();
|
|
10461
|
-
function
|
|
10462
|
-
const errors = findings.filter((f) => f.severity === "error").length;
|
|
10463
|
-
const warnings = findings.filter((f) => f.severity === "warning").length;
|
|
10464
|
-
|
|
10465
|
-
|
|
10466
|
-
|
|
10467
|
-
}
|
|
10468
|
-
|
|
10469
|
-
|
|
10470
|
-
|
|
10471
|
-
|
|
10472
|
-
|
|
10473
|
-
|
|
10474
|
-
|
|
10475
|
-
|
|
10476
|
-
|
|
10477
|
-
|
|
11355
|
+
function buildExecutiveKpis(result) {
|
|
11356
|
+
const errors = result.findings.filter((f) => f.severity === "error").length;
|
|
11357
|
+
const warnings = result.findings.filter((f) => f.severity === "warning").length;
|
|
11358
|
+
return [
|
|
11359
|
+
{
|
|
11360
|
+
label: "Worthiness score",
|
|
11361
|
+
value: result.score !== null ? `${result.score}/100` : "not measured",
|
|
11362
|
+
source: "scan result score",
|
|
11363
|
+
direction: "higher-is-better"
|
|
11364
|
+
},
|
|
11365
|
+
{
|
|
11366
|
+
label: "Error findings",
|
|
11367
|
+
value: String(errors),
|
|
11368
|
+
source: "scan result findings",
|
|
11369
|
+
direction: "lower-is-better"
|
|
11370
|
+
},
|
|
11371
|
+
{
|
|
11372
|
+
label: "Warning findings",
|
|
11373
|
+
value: String(warnings),
|
|
11374
|
+
source: "scan result findings",
|
|
11375
|
+
direction: "lower-is-better"
|
|
11376
|
+
},
|
|
11377
|
+
{
|
|
11378
|
+
label: "Analysis complete",
|
|
11379
|
+
value: result.partial ? "no (partial)" : "yes",
|
|
11380
|
+
source: "scan result partial flag",
|
|
11381
|
+
direction: null
|
|
11382
|
+
}
|
|
11383
|
+
];
|
|
10478
11384
|
}
|
|
10479
11385
|
async function runExecReportCommand(argv, io) {
|
|
10480
11386
|
const target = argv.find((a) => !a.startsWith("-")) ?? ".";
|
|
@@ -10487,60 +11393,34 @@ async function runExecReportCommand(argv, io) {
|
|
|
10487
11393
|
target,
|
|
10488
11394
|
json: true,
|
|
10489
11395
|
verbose: false,
|
|
10490
|
-
maxDurationMs:
|
|
11396
|
+
maxDurationMs: 6e5,
|
|
10491
11397
|
scopeChanged: false,
|
|
10492
11398
|
format: "json",
|
|
10493
11399
|
strict: false
|
|
10494
11400
|
});
|
|
10495
|
-
const
|
|
10496
|
-
|
|
10497
|
-
|
|
10498
|
-
|
|
10499
|
-
|
|
10500
|
-
|
|
10501
|
-
|
|
10502
|
-
status: result.score !== null && result.score >= 80 ? "good" : result.score !== null && result.score >= 60 ? "warning" : "critical"
|
|
10503
|
-
},
|
|
10504
|
-
{
|
|
10505
|
-
label: "Total Findings",
|
|
10506
|
-
value: String(result.findings.length),
|
|
10507
|
-
delta: "",
|
|
10508
|
-
status: result.findings.length <= 5 ? "good" : result.findings.length <= 20 ? "warning" : "critical"
|
|
10509
|
-
},
|
|
10510
|
-
{
|
|
10511
|
-
label: "Errors",
|
|
10512
|
-
value: String(result.findings.filter((f) => f.severity === "error").length),
|
|
10513
|
-
delta: "",
|
|
10514
|
-
status: result.findings.filter((f) => f.severity === "error").length === 0 ? "good" : "critical"
|
|
10515
|
-
},
|
|
10516
|
-
{
|
|
10517
|
-
label: "Warnings",
|
|
10518
|
-
value: String(result.findings.filter((f) => f.severity === "warning").length),
|
|
10519
|
-
delta: "",
|
|
10520
|
-
status: result.findings.filter((f) => f.severity === "warning").length <= 5 ? "good" : "warning"
|
|
10521
|
-
}
|
|
10522
|
-
];
|
|
10523
|
-
const header = sectionHeader("EXECUTIVE QUALITY REPORT", ui$4);
|
|
11401
|
+
const decision = decideClaim({
|
|
11402
|
+
partial: result.partial,
|
|
11403
|
+
blockingFindings: result.findings.filter((f) => f.severity === "error").length,
|
|
11404
|
+
supported: true
|
|
11405
|
+
});
|
|
11406
|
+
const kpis = buildExecutiveKpis(result);
|
|
11407
|
+
const header = sectionHeader("SCAN SUMMARY", ui$4);
|
|
10524
11408
|
io.out(`${header}\n`);
|
|
10525
|
-
io.out(`Generated: ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
10526
11409
|
io.out(`Target: ${target}`);
|
|
10527
|
-
io.out(`
|
|
11410
|
+
io.out(`Determination: ${decision.state} — ${decision.reason}`);
|
|
10528
11411
|
io.out("");
|
|
10529
|
-
io.out("---
|
|
10530
|
-
for (const kpi of kpis) {
|
|
10531
|
-
const icon = kpi.status === "good" ? "✅" : kpi.status === "warning" ? "⚠️ " : "🔴";
|
|
10532
|
-
io.out(` ${icon} ${kpi.label}: ${kpi.value} ${kpi.delta}`);
|
|
10533
|
-
}
|
|
11412
|
+
io.out("--- Measured values ---");
|
|
11413
|
+
for (const kpi of kpis) io.out(` ${kpi.label}: ${kpi.value} (source: ${kpi.source})`);
|
|
10534
11414
|
io.out("");
|
|
10535
|
-
io.out("--- Top
|
|
11415
|
+
io.out("--- Top findings ---");
|
|
10536
11416
|
const top = result.findings.slice(0, 5);
|
|
10537
11417
|
for (const f of top) io.out(` [${f.severity.toUpperCase()}] ${f.ruleId}: ${f.message} (${f.file}:${f.line})`);
|
|
10538
|
-
if (result.findings.length === 0) io.out(" No findings
|
|
11418
|
+
if (result.findings.length === 0) io.out(result.partial ? " No findings in the analyzed portion of the surface. The scan was PARTIAL, so this is not a clean result." : " No findings in a complete scan of this surface.");
|
|
11419
|
+
if (result.findings.length > top.length) io.out(` …and ${result.findings.length - top.length} more.`);
|
|
10539
11420
|
io.out("");
|
|
10540
|
-
io.out("
|
|
10541
|
-
for (const r of recommendations) io.out(` • ${r}`);
|
|
11421
|
+
io.out("Not reported here: business risk, ROI, or release readiness. Those are decisions, and this tool has no evidence for them.");
|
|
10542
11422
|
io.out("");
|
|
10543
|
-
return
|
|
11423
|
+
return decision.exitCode;
|
|
10544
11424
|
} catch (e) {
|
|
10545
11425
|
internalErrorMessage(e, io.err, false);
|
|
10546
11426
|
return 20;
|
|
@@ -10598,7 +11478,7 @@ async function runPolicyCommand(argv, io) {
|
|
|
10598
11478
|
const policy = defaultPolicy();
|
|
10599
11479
|
const dir = target === ".mjolnir" ? target : ".";
|
|
10600
11480
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
10601
|
-
|
|
11481
|
+
writeFileAtomic(policyPath, JSON.stringify(policy, null, 2) + "\n");
|
|
10602
11482
|
io.out(`Policy written to ${policyPath}`);
|
|
10603
11483
|
io.out(`Use 'mjolnir policy validate ${policyPath}' to validate.`);
|
|
10604
11484
|
io.out(`Use 'mjolnir policy check <target> --policy ${policyPath}' to check results.`);
|
|
@@ -10674,83 +11554,158 @@ async function runPolicyCommand(argv, io) {
|
|
|
10674
11554
|
//#endregion
|
|
10675
11555
|
//#region src/commands/quarantine.ts
|
|
10676
11556
|
/**
|
|
10677
|
-
* `mjolnir quarantine` —
|
|
11557
|
+
* `mjolnir quarantine` — quarantined-test workflow.
|
|
10678
11558
|
*
|
|
10679
|
-
*
|
|
10680
|
-
*
|
|
11559
|
+
* What changed, and why: this command derived a quarantine "proposal" from a
|
|
11560
|
+
* STATIC scan by inventing an attempt count from severity (`attempts: 3` for an
|
|
11561
|
+
* error, `2` for a warning), then printed "Quarantines updated." after an
|
|
11562
|
+
* action that changed nothing, and printed hardcoded zeros as if they were
|
|
11563
|
+
* measured statistics. A quarantine decision is a claim about a flaky test's
|
|
11564
|
+
* runtime behaviour; a static scan has no runtime behaviour to claim, so it
|
|
11565
|
+
* cannot produce one.
|
|
10681
11566
|
*
|
|
10682
|
-
*
|
|
10683
|
-
*
|
|
10684
|
-
*
|
|
10685
|
-
*
|
|
11567
|
+
* It now refuses to invent. Proposals require runtime evidence (attempts and
|
|
11568
|
+
* an observed failure) from a saved forensics report; without one it says so
|
|
11569
|
+
* and points at `mjolnir forensics`. There is no quarantine store yet — the
|
|
11570
|
+
* ledger this belongs in is the suppression ledger (see
|
|
11571
|
+
* docs/RELEASE-TRAINS.md, V5-090) — so nothing claims to be updated.
|
|
10686
11572
|
*
|
|
10687
|
-
*
|
|
10688
|
-
* propose when attempts >= 2 AND everFailed, priority by attempts then duration.
|
|
11573
|
+
* Scheduled for removal in 5.0.
|
|
10689
11574
|
*/
|
|
10690
11575
|
const ui$3 = plainContext();
|
|
10691
|
-
|
|
10692
|
-
|
|
10693
|
-
|
|
10694
|
-
|
|
10695
|
-
|
|
10696
|
-
|
|
10697
|
-
|
|
10698
|
-
|
|
11576
|
+
const CANDIDATE_KEYS = [
|
|
11577
|
+
"flaky",
|
|
11578
|
+
"flakyTests",
|
|
11579
|
+
"tests",
|
|
11580
|
+
"retries",
|
|
11581
|
+
"failures",
|
|
11582
|
+
"results"
|
|
11583
|
+
];
|
|
11584
|
+
function asRecord(value) {
|
|
11585
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
11586
|
+
return value;
|
|
11587
|
+
}
|
|
11588
|
+
/**
|
|
11589
|
+
* Extract runtime failure evidence from a saved forensics report. A record
|
|
11590
|
+
* without an observed attempt count is not evidence and is dropped rather than
|
|
11591
|
+
* defaulted.
|
|
11592
|
+
*/
|
|
11593
|
+
function runtimeFailuresFromReport(parsed) {
|
|
11594
|
+
const root = asRecord(parsed);
|
|
11595
|
+
if (!root) return [];
|
|
11596
|
+
const candidates = [];
|
|
11597
|
+
for (const key of CANDIDATE_KEYS) {
|
|
11598
|
+
const value = root[key];
|
|
11599
|
+
if (Array.isArray(value)) candidates.push(...value);
|
|
11600
|
+
}
|
|
11601
|
+
const evidence = [];
|
|
11602
|
+
for (const candidate of candidates) {
|
|
11603
|
+
const record = asRecord(candidate);
|
|
11604
|
+
if (!record) continue;
|
|
11605
|
+
const attempts = typeof record.attempts === "number" ? record.attempts : typeof record.failureCount === "number" ? record.failureCount : void 0;
|
|
11606
|
+
if (attempts === void 0) continue;
|
|
11607
|
+
const file = typeof record.file === "string" ? record.file : void 0;
|
|
11608
|
+
if (!file) continue;
|
|
11609
|
+
evidence.push({
|
|
11610
|
+
ruleId: typeof record.title === "string" ? record.title : "UNKNOWN",
|
|
11611
|
+
file,
|
|
11612
|
+
line: typeof record.line === "number" ? record.line : 0,
|
|
11613
|
+
message: typeof record.message === "string" ? record.message : "no message recorded",
|
|
11614
|
+
attempts,
|
|
11615
|
+
everFailed: record.everFailed === true || attempts > 0
|
|
11616
|
+
});
|
|
11617
|
+
}
|
|
11618
|
+
return evidence;
|
|
11619
|
+
}
|
|
11620
|
+
function buildQuarantineProposals(evidence) {
|
|
11621
|
+
return evidence.filter((item) => item.attempts >= 2 && item.everFailed).sort((a, b) => b.attempts - a.attempts || a.file.localeCompare(b.file) || a.line - b.line).map((item, index) => ({
|
|
11622
|
+
id: `Q-${String(index + 1).padStart(3, "0")}`,
|
|
11623
|
+
ruleId: item.ruleId,
|
|
11624
|
+
file: item.file,
|
|
11625
|
+
line: item.line,
|
|
11626
|
+
message: item.message,
|
|
11627
|
+
attempts: item.attempts,
|
|
11628
|
+
everFailed: item.everFailed,
|
|
10699
11629
|
status: "proposed",
|
|
10700
|
-
reason:
|
|
11630
|
+
reason: `${item.attempts} observed attempt(s) with a recorded failure — runtime evidence, not inferred from severity`
|
|
10701
11631
|
}));
|
|
10702
11632
|
}
|
|
10703
|
-
|
|
11633
|
+
function readReport(path) {
|
|
11634
|
+
const root = resolve(path);
|
|
11635
|
+
if (!existsSync(root)) return {
|
|
11636
|
+
ok: false,
|
|
11637
|
+
reason: `no such file: ${path}`
|
|
11638
|
+
};
|
|
11639
|
+
try {
|
|
11640
|
+
return {
|
|
11641
|
+
ok: true,
|
|
11642
|
+
value: JSON.parse(readFileSync(root, "utf8"))
|
|
11643
|
+
};
|
|
11644
|
+
} catch (e) {
|
|
11645
|
+
return {
|
|
11646
|
+
ok: false,
|
|
11647
|
+
reason: e instanceof Error ? e.message : "unreadable report"
|
|
11648
|
+
};
|
|
11649
|
+
}
|
|
11650
|
+
}
|
|
11651
|
+
function runQuarantineCommand(argv, io) {
|
|
10704
11652
|
const subcommand = argv[0] ?? "list";
|
|
10705
|
-
const
|
|
11653
|
+
const rest = argv.slice(1);
|
|
11654
|
+
const target = rest.find((a) => !a.startsWith("-")) ?? ".";
|
|
11655
|
+
const reportIdx = rest.indexOf("--from");
|
|
10706
11656
|
if (subcommand === "list") {
|
|
10707
|
-
|
|
10708
|
-
|
|
10709
|
-
|
|
10710
|
-
|
|
10711
|
-
|
|
10712
|
-
|
|
10713
|
-
|
|
10714
|
-
|
|
10715
|
-
}
|
|
10716
|
-
|
|
10717
|
-
|
|
10718
|
-
|
|
10719
|
-
|
|
10720
|
-
|
|
10721
|
-
|
|
10722
|
-
|
|
10723
|
-
return 0;
|
|
11657
|
+
if (reportIdx === -1 || !rest[reportIdx + 1]) {
|
|
11658
|
+
io.out(sectionHeader("QUARANTINE PROPOSALS", ui$3));
|
|
11659
|
+
io.out("");
|
|
11660
|
+
io.out("No runtime report supplied, so no proposals can be produced.");
|
|
11661
|
+
io.out("A quarantine proposal asserts that a test actually failed more than once. A static scan has no runtime behaviour to observe, so Mjölnir will not invent one.");
|
|
11662
|
+
io.out("");
|
|
11663
|
+
io.out("Produce one: mjolnir forensics <results-dir> && mjolnir quarantine list --from <report.json>");
|
|
11664
|
+
return unmeasuredClaim("quarantine list", "proposals require observed runtime failures, and no forensics report was supplied.").exitCode;
|
|
11665
|
+
}
|
|
11666
|
+
const reportPath = rest[reportIdx + 1];
|
|
11667
|
+
const reportRoot = resolve(target);
|
|
11668
|
+
const requested = resolve(reportPath);
|
|
11669
|
+
const rel = relative(reportRoot, requested);
|
|
11670
|
+
if (isAbsolute(rel) || rel.startsWith("..")) {
|
|
11671
|
+
io.err("mjolnir quarantine: --from report must stay within the target root");
|
|
11672
|
+
return 10;
|
|
10724
11673
|
}
|
|
11674
|
+
const report = readReport(reportPath);
|
|
11675
|
+
if (!report.ok) {
|
|
11676
|
+
io.err(`mjolnir quarantine: cannot read report: ${report.reason}`);
|
|
11677
|
+
return 10;
|
|
11678
|
+
}
|
|
11679
|
+
const evidence = runtimeFailuresFromReport(report.value);
|
|
11680
|
+
const proposals = buildQuarantineProposals(evidence);
|
|
10725
11681
|
io.out(sectionHeader("QUARANTINE PROPOSALS", ui$3));
|
|
10726
11682
|
io.out("");
|
|
10727
|
-
|
|
10728
|
-
|
|
10729
|
-
|
|
11683
|
+
if (proposals.length === 0) {
|
|
11684
|
+
io.out(`0 proposal(s) from ${evidence.length} runtime record(s). Nothing in this report shows a test failing more than once.`);
|
|
11685
|
+
return unmeasuredClaim("quarantine list", "no runtime record met the observed-failure threshold; an empty proposal list is not a clean test suite.").exitCode;
|
|
11686
|
+
}
|
|
11687
|
+
io.out("| ID | Attempts | Test | File | Status |");
|
|
11688
|
+
io.out("| -- | -------- | ---- | ---- | ------ |");
|
|
11689
|
+
for (const p of proposals) io.out(`| ${p.id} | ${p.attempts} | ${p.ruleId} | ${p.file}:${p.line} | ${p.status} |`);
|
|
10730
11690
|
io.out("");
|
|
10731
|
-
io.out(`${proposals.length} proposal(s).
|
|
10732
|
-
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;
|
|
10733
11693
|
}
|
|
10734
11694
|
if (subcommand === "review") {
|
|
10735
|
-
const proposals = buildQuarantineProposals([]);
|
|
10736
|
-
const action = argv.find((a) => a === "--accept" || a === "--defer" || a === "--reject");
|
|
10737
11695
|
io.out(sectionHeader("QUARANTINE REVIEW", ui$3));
|
|
10738
11696
|
io.out("");
|
|
10739
|
-
io.out("
|
|
10740
|
-
|
|
10741
|
-
|
|
10742
|
-
io.out(`Action: ${action}`);
|
|
10743
|
-
io.out("Quarantines updated.");
|
|
10744
|
-
} else io.out("Use --accept, --defer, or --reject to manage.");
|
|
10745
|
-
return 0;
|
|
11697
|
+
io.out("No quarantine store exists, so there is nothing to review and nothing was changed.");
|
|
11698
|
+
io.out("Quarantine state lives in the suppression ledger: edit .mjolnir/suppressions.json, where every entry is versioned, justified, and subject to expiry.");
|
|
11699
|
+
return unmeasuredClaim("quarantine review", "the command is a placeholder; the ledger is the source of truth.").exitCode;
|
|
10746
11700
|
}
|
|
10747
11701
|
if (subcommand === "stats") {
|
|
10748
11702
|
io.out(sectionHeader("QUARANTINE STATS", ui$3));
|
|
10749
11703
|
io.out("");
|
|
10750
|
-
io.out("
|
|
11704
|
+
io.out("No quarantine store exists, so no statistics can be reported.");
|
|
11705
|
+
io.out("Printing zeros here would look like a measured absence of flakiness. It is not one.");
|
|
10751
11706
|
io.out("");
|
|
10752
|
-
io.out("
|
|
10753
|
-
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;
|
|
10754
11709
|
}
|
|
10755
11710
|
io.err(`Unknown quarantine subcommand: ${subcommand}`);
|
|
10756
11711
|
return 10;
|
|
@@ -10787,20 +11742,44 @@ function runAnalyzeCommand(argv, io) {
|
|
|
10787
11742
|
const findings = [];
|
|
10788
11743
|
const fileImports = /* @__PURE__ */ new Map();
|
|
10789
11744
|
const fileContents = /* @__PURE__ */ new Map();
|
|
10790
|
-
|
|
11745
|
+
let limited = false;
|
|
11746
|
+
const deadline = Date.now() + 6e5;
|
|
11747
|
+
function walk(dir, depth = 0) {
|
|
11748
|
+
if (limited || depth > LIMITS.maxDepth || Date.now() >= deadline) {
|
|
11749
|
+
limited = true;
|
|
11750
|
+
return;
|
|
11751
|
+
}
|
|
10791
11752
|
for (const name of readdirSync(dir)) {
|
|
10792
11753
|
const path = join(dir, name);
|
|
10793
11754
|
if (name === "node_modules" || name === ".git") continue;
|
|
10794
|
-
|
|
10795
|
-
|
|
10796
|
-
|
|
11755
|
+
let stat;
|
|
11756
|
+
try {
|
|
11757
|
+
stat = lstatSync(path);
|
|
11758
|
+
} catch {
|
|
11759
|
+
continue;
|
|
11760
|
+
}
|
|
11761
|
+
if (stat.isSymbolicLink()) continue;
|
|
11762
|
+
if (stat.isDirectory()) {
|
|
11763
|
+
walk(path, depth + 1);
|
|
11764
|
+
if (limited) return;
|
|
11765
|
+
} else if (name.endsWith(".ts") || name.endsWith(".tsx") || name.endsWith(".js") || name.endsWith(".jsx")) {
|
|
11766
|
+
if (fileContents.size >= LIMITS.maxFilesPerAdapter) {
|
|
11767
|
+
limited = true;
|
|
11768
|
+
return;
|
|
11769
|
+
}
|
|
11770
|
+
const read = readFileBounded(path, LIMITS.maxFileBytes);
|
|
11771
|
+
if (!read.ok) {
|
|
11772
|
+
limited = true;
|
|
11773
|
+
continue;
|
|
11774
|
+
}
|
|
11775
|
+
const content = read.data.toString("utf8");
|
|
10797
11776
|
const rel = relative(process.cwd(), path);
|
|
10798
11777
|
fileContents.set(rel, content);
|
|
10799
11778
|
const imports = collectImports(content);
|
|
10800
11779
|
const importCounts = /* @__PURE__ */ new Map();
|
|
10801
11780
|
for (const imp of imports) importCounts.set(imp, (importCounts.get(imp) ?? 0) + 1);
|
|
10802
11781
|
fileImports.set(rel, importCounts);
|
|
10803
|
-
}
|
|
11782
|
+
}
|
|
10804
11783
|
}
|
|
10805
11784
|
}
|
|
10806
11785
|
walk(target);
|
|
@@ -10854,6 +11833,10 @@ function runAnalyzeCommand(argv, io) {
|
|
|
10854
11833
|
}
|
|
10855
11834
|
io.out("");
|
|
10856
11835
|
}
|
|
11836
|
+
if (limited) {
|
|
11837
|
+
io.err("mjolnir analyze: resource limit reached; cross-file analysis is partial");
|
|
11838
|
+
return 2;
|
|
11839
|
+
}
|
|
10857
11840
|
return 0;
|
|
10858
11841
|
} catch (e) {
|
|
10859
11842
|
internalErrorMessage(e, io.err, false);
|
|
@@ -10946,7 +11929,7 @@ function runCiAdapterCommand(argv, io) {
|
|
|
10946
11929
|
default: return 10;
|
|
10947
11930
|
}
|
|
10948
11931
|
try {
|
|
10949
|
-
|
|
11932
|
+
writeFileAtomic(join(target, filename), output);
|
|
10950
11933
|
} catch (error) {
|
|
10951
11934
|
io.err(`Unable to write CI template: ${error instanceof Error ? error.message : String(error)}`);
|
|
10952
11935
|
return 20;
|
|
@@ -10966,13 +11949,32 @@ function runCiAdapterCommand(argv, io) {
|
|
|
10966
11949
|
* file with inline CSS and JavaScript, suitable for embedding
|
|
10967
11950
|
* in team wikis or sharing via static hosting.
|
|
10968
11951
|
*/
|
|
11952
|
+
/** Band → CSS colour, read from the brand tokens so the dashboard cannot
|
|
11953
|
+
* name a colour of its own. `trusted` is aurora-cyan, not green: green is
|
|
11954
|
+
* reserved for non-score success (the terminal's `Palette.ok`). */
|
|
11955
|
+
const BAND_HEX = {
|
|
11956
|
+
critical: SCORE.critical,
|
|
11957
|
+
warning: SCORE.warning,
|
|
11958
|
+
trusted: SCORE.trusted,
|
|
11959
|
+
forged: SCORE.forged,
|
|
11960
|
+
unmeasured: SCORE.unmeasured
|
|
11961
|
+
};
|
|
11962
|
+
const FINDINGS_SHOWN_LIMIT = 100;
|
|
11963
|
+
function escapeHtml(text) {
|
|
11964
|
+
return sanitizeErrorText(text, { maxLength: 1e3 }).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
11965
|
+
}
|
|
10969
11966
|
function generateDashboardHtml(data) {
|
|
10970
|
-
const scoreColor = data.score
|
|
10971
|
-
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);
|
|
10972
11973
|
return `<!DOCTYPE html>
|
|
10973
11974
|
<html lang="en">
|
|
10974
11975
|
<head>
|
|
10975
11976
|
<meta charset="UTF-8">
|
|
11977
|
+
${data.generatedAt === null ? "" : `<meta name="mjolnir-generated-at" content="${escapeHtml(data.generatedAt)}">\n`}<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
10976
11978
|
<title>Quality Dashboard</title>
|
|
10977
11979
|
<style>
|
|
10978
11980
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 960px; margin: 0 auto; padding: 2rem; background: #0a0a0a; color: #e5e5e5; }
|
|
@@ -10989,16 +11991,15 @@ function generateDashboardHtml(data) {
|
|
|
10989
11991
|
</head>
|
|
10990
11992
|
<body>
|
|
10991
11993
|
<h1>🔍 Quality Dashboard</h1>
|
|
10992
|
-
|
|
10993
|
-
<div class="score">${data.score !== null ? data.score + "/100" : "N/A"}</div>
|
|
11994
|
+
${banner}${data.generatedAt === null ? `<p>Deterministic build — no generation timestamp is recorded, so this file is byte-identical for an unchanged repository.</p>` : ""}<div class="score">${data.score !== null ? data.score + "/100" : "N/A"}</div>
|
|
10994
11995
|
<div class="kpi-grid">
|
|
10995
11996
|
<div class="kpi"><div class="value">${data.totalFindings}</div><div class="label">Findings</div></div>
|
|
10996
|
-
<div class="kpi"><div class="value" style="color
|
|
10997
|
-
<div class="kpi"><div class="value" style="color
|
|
10998
|
-
<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>
|
|
10999
12000
|
</div>
|
|
11000
12001
|
<h2>Findings</h2>
|
|
11001
|
-
<table><thead><tr><th>Rule</th><th>Severity</th><th>File</th><th>Message</th></tr></thead><tbody>${rows}</tbody></table>
|
|
12002
|
+
${truncation}<table><caption>Findings on the analyzed surface, newest severity first. Capped at ${FINDINGS_SHOWN_LIMIT} rows.</caption><thead><tr><th scope="col">Rule</th><th scope="col">Severity</th><th scope="col">File</th><th scope="col">Message</th></tr></thead><tbody>${rows}</tbody></table>
|
|
11002
12003
|
</body>
|
|
11003
12004
|
</html>`;
|
|
11004
12005
|
}
|
|
@@ -11006,41 +12007,60 @@ async function runDashboardCommand(argv, io) {
|
|
|
11006
12007
|
const target = argv.find((a) => !a.startsWith("-")) ?? ".";
|
|
11007
12008
|
const outputIdx = argv.indexOf("--output");
|
|
11008
12009
|
const outputPath = outputIdx !== -1 ? argv[outputIdx + 1] ?? "dashboard.html" : "dashboard.html";
|
|
12010
|
+
const deterministic = argv.includes("--deterministic");
|
|
11009
12011
|
if (!existsSync(target)) {
|
|
11010
12012
|
io.err(`mjolnir dashboard: target does not exist: ${target}`);
|
|
11011
12013
|
return 10;
|
|
11012
12014
|
}
|
|
12015
|
+
const root = resolve(target);
|
|
12016
|
+
const resolvedOutput = resolve(outputPath);
|
|
12017
|
+
const outputRelative = relative(root, resolvedOutput);
|
|
12018
|
+
if (isAbsolute(outputRelative) || outputRelative.startsWith("..")) {
|
|
12019
|
+
io.err("mjolnir dashboard: output must stay within the target root");
|
|
12020
|
+
return 10;
|
|
12021
|
+
}
|
|
11013
12022
|
try {
|
|
11014
|
-
const { runScan } = await import("./scan-pipeline-
|
|
12023
|
+
const { runScan } = await import("./scan-pipeline-Bg-46cLS.mjs");
|
|
11015
12024
|
const result = await runScan({
|
|
11016
12025
|
target,
|
|
11017
12026
|
json: true,
|
|
11018
12027
|
verbose: false,
|
|
11019
|
-
maxDurationMs:
|
|
12028
|
+
maxDurationMs: 6e5,
|
|
11020
12029
|
scopeChanged: false,
|
|
11021
12030
|
format: "json",
|
|
11022
12031
|
strict: false
|
|
11023
12032
|
});
|
|
12033
|
+
const shown = result.findings.slice(0, FINDINGS_SHOWN_LIMIT);
|
|
11024
12034
|
const data = {
|
|
11025
12035
|
score: result.score,
|
|
11026
12036
|
totalFindings: result.findings.length,
|
|
11027
12037
|
errorCount: result.findings.filter((f) => f.severity === "error").length,
|
|
11028
12038
|
warningCount: result.findings.filter((f) => f.severity === "warning").length,
|
|
11029
|
-
frameworkCount: result.frameworks.length,
|
|
11030
|
-
|
|
12039
|
+
frameworkCount: result.frameworkDetectionUnknown ? null : result.frameworks.length,
|
|
12040
|
+
partial: result.partial,
|
|
12041
|
+
findingsShown: shown.length,
|
|
12042
|
+
findings: shown.map((f) => ({
|
|
11031
12043
|
ruleId: f.ruleId,
|
|
11032
12044
|
severity: f.severity,
|
|
11033
12045
|
file: f.file,
|
|
11034
12046
|
message: f.message
|
|
11035
12047
|
})),
|
|
11036
|
-
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
12048
|
+
generatedAt: deterministic ? null : (/* @__PURE__ */ new Date()).toISOString()
|
|
11037
12049
|
};
|
|
11038
12050
|
const html = generateDashboardHtml(data);
|
|
11039
|
-
|
|
11040
|
-
io.out(`Dashboard written to ${
|
|
12051
|
+
writeFileAtomic(resolvedOutput, html, { encoding: "utf8" });
|
|
12052
|
+
io.out(`Dashboard written to ${resolvedOutput}`);
|
|
11041
12053
|
io.out(`Score: ${data.score !== null ? data.score + "/100" : "N/A"}`);
|
|
11042
12054
|
io.out(`Findings: ${data.totalFindings} (${data.errorCount} errors, ${data.warningCount} warnings)`);
|
|
11043
|
-
|
|
12055
|
+
if (data.frameworkCount === null) io.out("Frameworks: unknown — detection did not complete");
|
|
12056
|
+
if (data.findingsShown < data.totalFindings) io.out(`Table shows ${data.findingsShown} of ${data.totalFindings} findings (display limit).`);
|
|
12057
|
+
const decision = decideClaim({
|
|
12058
|
+
partial: data.partial,
|
|
12059
|
+
blockingFindings: data.errorCount,
|
|
12060
|
+
supported: true
|
|
12061
|
+
});
|
|
12062
|
+
io.out(`Determination: ${decision.state} — ${decision.reason}`);
|
|
12063
|
+
return decision.exitCode;
|
|
11044
12064
|
} catch (e) {
|
|
11045
12065
|
internalErrorMessage(e, io.err, false);
|
|
11046
12066
|
return 20;
|
|
@@ -11049,18 +12069,34 @@ async function runDashboardCommand(argv, io) {
|
|
|
11049
12069
|
//#endregion
|
|
11050
12070
|
//#region src/commands/enterprise.ts
|
|
11051
12071
|
/**
|
|
11052
|
-
* `mjolnir enterprise` —
|
|
12072
|
+
* `mjolnir enterprise` — refuses, and says what exists instead.
|
|
12073
|
+
*
|
|
12074
|
+
* What changed, and why: this command wrote three kinds of artifact that
|
|
12075
|
+
* Mjölnir cannot back.
|
|
12076
|
+
*
|
|
12077
|
+
* 1. A deployment config declaring `"authentication": "sso-saml"`. There
|
|
12078
|
+
* is no server, no session, and no SAML implementation in this product.
|
|
12079
|
+
* The config described a deployment that does not exist.
|
|
12080
|
+
* 2. An SSO setup guide instructing the reader to add an `sso` block to
|
|
12081
|
+
* `mjolnir.config.json`. Nothing reads that key. The guide was
|
|
12082
|
+
* confident, plausible, and inert.
|
|
12083
|
+
* 3. Three compliance templates — SOC 2, HIPAA, PCI-DSS — mapping
|
|
12084
|
+
* controls to "SSO/SAML integration", a "built-in scan audit trail"
|
|
12085
|
+
* and a "privacy scan". None of those is a Mjölnir feature: a scan
|
|
12086
|
+
* reads a repository and prints. A compliance template is a document
|
|
12087
|
+
* an organisation shows an auditor, so mapping controls to
|
|
12088
|
+
* capabilities that do not exist is not a cosmetic bug.
|
|
11053
12089
|
*
|
|
11054
|
-
*
|
|
11055
|
-
*
|
|
11056
|
-
*
|
|
11057
|
-
*
|
|
11058
|
-
* - Custom policy templates
|
|
12090
|
+
* So the command no longer writes files. What it can honestly offer is
|
|
12091
|
+
* `enterprise/threat-model.json` and `enterprise/data-flows.json`, which
|
|
12092
|
+
* ARE real, ARE validated in CI by `npm run enterprise:threat-model`,
|
|
12093
|
+
* and describe the product that exists: a zero-network local CLI.
|
|
11059
12094
|
*
|
|
11060
|
-
*
|
|
11061
|
-
*
|
|
11062
|
-
*
|
|
11063
|
-
*
|
|
12095
|
+
* The `config` subcommand still emits a fact file, because a manifest of
|
|
12096
|
+
* what the tool is and is not is genuinely useful to an operator
|
|
12097
|
+
* evaluating it — as long as it records absence instead of inventing it.
|
|
12098
|
+
*
|
|
12099
|
+
* Scheduled for removal in 5.0 — see docs/RELEASE-TRAINS.md.
|
|
11064
12100
|
*/
|
|
11065
12101
|
const ui$1 = plainContext();
|
|
11066
12102
|
function runEnterpriseCommand(argv, io) {
|
|
@@ -11069,119 +12105,108 @@ function runEnterpriseCommand(argv, io) {
|
|
|
11069
12105
|
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
|
11070
12106
|
if (subcommand === "config") {
|
|
11071
12107
|
const config = {
|
|
11072
|
-
|
|
11073
|
-
version:
|
|
11074
|
-
|
|
11075
|
-
|
|
11076
|
-
|
|
11077
|
-
|
|
11078
|
-
|
|
11079
|
-
|
|
11080
|
-
|
|
11081
|
-
|
|
11082
|
-
|
|
11083
|
-
|
|
11084
|
-
|
|
11085
|
-
|
|
11086
|
-
|
|
11087
|
-
|
|
11088
|
-
|
|
11089
|
-
logging: {
|
|
11090
|
-
level: "info",
|
|
11091
|
-
retention: "90d",
|
|
11092
|
-
audit: true
|
|
11093
|
-
}
|
|
12108
|
+
product: "mjolnir-qa",
|
|
12109
|
+
version: ENGINE_VERSION,
|
|
12110
|
+
shape: "zero-runtime-dependency local CLI",
|
|
12111
|
+
network: "none — no telemetry, no hosted service, no sync",
|
|
12112
|
+
runtimeDependencies: 0,
|
|
12113
|
+
verified: [
|
|
12114
|
+
"local-only execution (tests/contract/privacy-network-isolation.spec.ts)",
|
|
12115
|
+
"versioned threat + data-flow model (npm run enterprise:threat-model)",
|
|
12116
|
+
"deterministic machine contract, drift-locked (contractVersion 1)"
|
|
12117
|
+
],
|
|
12118
|
+
notProvided: [
|
|
12119
|
+
"SSO / SAML / OIDC — no server, no session, no identity provider integration",
|
|
12120
|
+
"hosted control plane, multi-tenant sync, or audit-log service",
|
|
12121
|
+
"compliance certification or auditor-facing control mapping",
|
|
12122
|
+
"air-gapped installation or offline recovery drill (see GAP-M26-015)"
|
|
12123
|
+
],
|
|
12124
|
+
unsupportedRequests: "docs/M26-SUPPORT-MATRIX.json records each of these as an explicit BLOCKED cell rather than a plan."
|
|
11094
12125
|
};
|
|
11095
|
-
const path = join(outputDir, "
|
|
11096
|
-
|
|
11097
|
-
io.out(sectionHeader("ENTERPRISE
|
|
11098
|
-
io.out(`
|
|
11099
|
-
io.out(`
|
|
11100
|
-
io.out(
|
|
11101
|
-
|
|
11102
|
-
|
|
11103
|
-
|
|
11104
|
-
const guide = `# SSO/SAML Setup Guide
|
|
11105
|
-
|
|
11106
|
-
## Service Provider (SP) Configuration
|
|
11107
|
-
- Entity ID: https://your-domain.com/mjolnir
|
|
11108
|
-
- ACS URL: https://your-domain.com/mjolnir/auth/sso/callback
|
|
11109
|
-
- Binding: HTTP-POST
|
|
11110
|
-
|
|
11111
|
-
## Identity Provider (IdP) Configuration
|
|
11112
|
-
- Metadata URL: https://your-idp.com/sso/metadata
|
|
11113
|
-
- Required Attributes:
|
|
11114
|
-
- email (mapped to user identity)
|
|
11115
|
-
- role (mapped to access level)
|
|
11116
|
-
- department (mapped to team)
|
|
11117
|
-
|
|
11118
|
-
## Mjölnir Configuration
|
|
11119
|
-
Add to mjolnir.config.json:
|
|
11120
|
-
\`\`\`json
|
|
11121
|
-
{
|
|
11122
|
-
"sso": {
|
|
11123
|
-
"enabled": true,
|
|
11124
|
-
"provider": "saml",
|
|
11125
|
-
"entryPoint": "https://your-idp.com/sso",
|
|
11126
|
-
"attributeMapping": {
|
|
11127
|
-
"email": "user",
|
|
11128
|
-
"role": "accessLevel",
|
|
11129
|
-
"department": "team"
|
|
11130
|
-
}
|
|
11131
|
-
}
|
|
11132
|
-
}
|
|
11133
|
-
\`\`\`
|
|
11134
|
-
`;
|
|
11135
|
-
const path = join(outputDir, "sso-setup.md");
|
|
11136
|
-
writeFileSync(path, guide);
|
|
11137
|
-
io.out(`SSO guide written: ${path}`);
|
|
12126
|
+
const path = join(outputDir, "capability-manifest.json");
|
|
12127
|
+
writeFileAtomic(path, JSON.stringify(config, null, 2) + "\n");
|
|
12128
|
+
io.out(sectionHeader("ENTERPRISE CAPABILITY MANIFEST", ui$1));
|
|
12129
|
+
io.out(`Written: ${path}`);
|
|
12130
|
+
io.out(`Version: ${ENGINE_VERSION} · runtime dependencies: 0 · network: none`);
|
|
12131
|
+
io.out("");
|
|
12132
|
+
io.out("This product is a local CLI. It provides no server, no SSO, no hosted");
|
|
12133
|
+
io.out("control plane and no compliance certification. The manifest's");
|
|
12134
|
+
io.out("`notProvided` list is the honest answer to a deployment questionnaire.");
|
|
11138
12135
|
return 0;
|
|
11139
12136
|
}
|
|
11140
|
-
if (subcommand === "compliance") {
|
|
11141
|
-
|
|
11142
|
-
|
|
11143
|
-
"HIPAA",
|
|
11144
|
-
"PCI-DSS"
|
|
11145
|
-
]) {
|
|
11146
|
-
const template = `# ${framework} Compliance Template for Mjölnir
|
|
11147
|
-
|
|
11148
|
-
## Control Mapping
|
|
11149
|
-
| Control | Mjölnir Feature | Evidence |
|
|
11150
|
-
| --------- | --------------- | -------- |
|
|
11151
|
-
| Access Control | SSO/SAML integration | sso-setup.md |
|
|
11152
|
-
| Audit Logging | Built-in scan audit trail | --json output |
|
|
11153
|
-
| Data Protection | Local-first, zero-network | privacy scan |
|
|
11154
|
-
| Change Management | quarantined rules, policy gates | policy check |
|
|
11155
|
-
| Monitoring | dashboard, exec-report | HTML/terminal output |
|
|
11156
|
-
`;
|
|
11157
|
-
const path = join(outputDir, `${framework.toLowerCase()}-compliance.md`);
|
|
11158
|
-
writeFileSync(path, template);
|
|
11159
|
-
io.out(`Compliance template: ${path}`);
|
|
11160
|
-
}
|
|
11161
|
-
return 0;
|
|
12137
|
+
if (subcommand === "sso" || subcommand === "compliance") {
|
|
12138
|
+
io.err(subcommand === "sso" ? "mjolnir enterprise sso: not available, and no artifact will be written.\n Mjölnir is a local CLI with no server, no session and no identity-provider\n integration. The previous SSO guide told readers to add an `sso` block to\n mjolnir.config.json — a key nothing reads.\n What exists: the threat + data-flow model, validated by\n `npm run enterprise:threat-model`. See `mjolnir enterprise config`." : "mjolnir enterprise compliance: not available, and no artifact will be written.\n Mjölnir cannot produce an auditor-facing control mapping. The previous\n templates mapped controls to capabilities this product does not have\n (SSO/SAML integration, a scan \"audit trail\", a \"privacy scan\"), which is\n not a formatting bug — it is a false statement in a document meant for\n an auditor.\n What exists: enterprise/threat-model.json and enterprise/data-flows.json,\n validated in CI, describing the product that actually ships.");
|
|
12139
|
+
return 10;
|
|
11162
12140
|
}
|
|
11163
12141
|
io.err(`Unknown enterprise subcommand: ${subcommand}`);
|
|
12142
|
+
io.err("Available: config");
|
|
11164
12143
|
return 10;
|
|
11165
12144
|
}
|
|
11166
12145
|
//#endregion
|
|
11167
12146
|
//#region src/commands/maturity.ts
|
|
11168
12147
|
/**
|
|
11169
|
-
* `mjolnir maturity` —
|
|
12148
|
+
* `mjolnir maturity` — organizational QA-maturity signals.
|
|
11170
12149
|
*
|
|
11171
|
-
*
|
|
11172
|
-
*
|
|
11173
|
-
*
|
|
11174
|
-
*
|
|
11175
|
-
*
|
|
12150
|
+
* What changed, and why: this command used to emit an "Overall: Optimizing
|
|
12151
|
+
* (87/100)" style assessment derived from whether three files happened to
|
|
12152
|
+
* exist, with hardcoded dimension scores (75/70/65/30) and a `ruleCount = 79`
|
|
12153
|
+
* fallback invented when a catalog could not be read. A file-existence proxy
|
|
12154
|
+
* is not a maturity measurement, and a number with no provenance is worse than
|
|
12155
|
+
* no number: it is a decision someone else will make on.
|
|
11176
12156
|
*
|
|
11177
|
-
*
|
|
11178
|
-
*
|
|
11179
|
-
*
|
|
11180
|
-
*
|
|
11181
|
-
* assess — run the maturity assessment
|
|
11182
|
-
* levels — show all maturity levels
|
|
12157
|
+
* It now reports what it can actually observe — the presence of specific,
|
|
12158
|
+
* named QA artifacts — and states that the artifact is a signal, not a score.
|
|
12159
|
+
* There is no overall score, because Mjölnir cannot measure organizational
|
|
12160
|
+
* maturity. Scheduled for removal in 5.0; see docs/RELEASE-TRAINS.md.
|
|
11183
12161
|
*/
|
|
11184
12162
|
const ui = plainContext();
|
|
12163
|
+
const SIGNALS = [
|
|
12164
|
+
{
|
|
12165
|
+
id: "policy",
|
|
12166
|
+
question: "Is a Mjölnir policy file committed?",
|
|
12167
|
+
path: ".mjolnir/mjolnir.policy.json",
|
|
12168
|
+
says: "a policy file exists in this checkout",
|
|
12169
|
+
doesNotSay: "that the policy is enforced, current, or correct"
|
|
12170
|
+
},
|
|
12171
|
+
{
|
|
12172
|
+
id: "suppressions",
|
|
12173
|
+
question: "Is a suppression ledger committed?",
|
|
12174
|
+
path: ".mjolnir/suppressions.json",
|
|
12175
|
+
says: "suppressions are tracked as data",
|
|
12176
|
+
doesNotSay: "that the suppressions are justified or still needed"
|
|
12177
|
+
},
|
|
12178
|
+
{
|
|
12179
|
+
id: "history",
|
|
12180
|
+
question: "Are previous runs recorded?",
|
|
12181
|
+
path: ".mjolnir/stats.json",
|
|
12182
|
+
says: "run history exists in this checkout",
|
|
12183
|
+
doesNotSay: "that the history is complete, or that anyone reviewed it"
|
|
12184
|
+
},
|
|
12185
|
+
{
|
|
12186
|
+
id: "baseline",
|
|
12187
|
+
question: "Is a scan baseline committed?",
|
|
12188
|
+
path: ".mjolnir/baseline.json",
|
|
12189
|
+
says: "a baseline exists to diff against",
|
|
12190
|
+
doesNotSay: "that the baseline was taken from a clean, complete scan"
|
|
12191
|
+
},
|
|
12192
|
+
{
|
|
12193
|
+
id: "rule-catalog",
|
|
12194
|
+
question: "Is the generated rule catalog committed?",
|
|
12195
|
+
path: "docs/rules/catalog.md",
|
|
12196
|
+
says: "generated rule documentation is in the repository",
|
|
12197
|
+
doesNotSay: "how many rules are enabled, measured, or certified"
|
|
12198
|
+
}
|
|
12199
|
+
];
|
|
12200
|
+
function assessMaturitySignals(root) {
|
|
12201
|
+
return SIGNALS.map((signal) => ({
|
|
12202
|
+
id: signal.id,
|
|
12203
|
+
question: signal.question,
|
|
12204
|
+
path: signal.path,
|
|
12205
|
+
present: existsSync(join(root, signal.path)),
|
|
12206
|
+
says: signal.says,
|
|
12207
|
+
doesNotSay: signal.doesNotSay
|
|
12208
|
+
}));
|
|
12209
|
+
}
|
|
11185
12210
|
const MATURITY_LEVELS = [
|
|
11186
12211
|
"Initial",
|
|
11187
12212
|
"Managed",
|
|
@@ -11189,51 +12214,16 @@ const MATURITY_LEVELS = [
|
|
|
11189
12214
|
"Quantitatively Managed",
|
|
11190
12215
|
"Optimizing"
|
|
11191
12216
|
];
|
|
11192
|
-
function assessDimension(name, score, findings = []) {
|
|
11193
|
-
if (score >= 90) return {
|
|
11194
|
-
name,
|
|
11195
|
-
level: "Optimizing",
|
|
11196
|
-
score,
|
|
11197
|
-
findings
|
|
11198
|
-
};
|
|
11199
|
-
if (score >= 75) return {
|
|
11200
|
-
name,
|
|
11201
|
-
level: "Quantitatively Managed",
|
|
11202
|
-
score,
|
|
11203
|
-
findings
|
|
11204
|
-
};
|
|
11205
|
-
if (score >= 60) return {
|
|
11206
|
-
name,
|
|
11207
|
-
level: "Defined",
|
|
11208
|
-
score,
|
|
11209
|
-
findings
|
|
11210
|
-
};
|
|
11211
|
-
if (score >= 40) return {
|
|
11212
|
-
name,
|
|
11213
|
-
level: "Managed",
|
|
11214
|
-
score,
|
|
11215
|
-
findings
|
|
11216
|
-
};
|
|
11217
|
-
return {
|
|
11218
|
-
name,
|
|
11219
|
-
level: "Initial",
|
|
11220
|
-
score,
|
|
11221
|
-
findings
|
|
11222
|
-
};
|
|
11223
|
-
}
|
|
11224
12217
|
function runMaturityCommand(argv, io) {
|
|
11225
12218
|
const subcommand = argv[0] ?? "assess";
|
|
11226
12219
|
const target = argv.slice(1).find((a) => !a.startsWith("-")) ?? ".";
|
|
11227
12220
|
if (subcommand === "levels") {
|
|
11228
12221
|
io.out(sectionHeader("MATURITY LEVELS", ui));
|
|
11229
12222
|
io.out("");
|
|
11230
|
-
for (let i = 0; i < MATURITY_LEVELS.length; i++) {
|
|
11231
|
-
const level = MATURITY_LEVELS[i];
|
|
11232
|
-
io.out(`${i + 1}. ${level}`);
|
|
11233
|
-
}
|
|
12223
|
+
for (let i = 0; i < MATURITY_LEVELS.length; i++) io.out(`${i + 1}. ${MATURITY_LEVELS[i]}`);
|
|
11234
12224
|
io.out("");
|
|
11235
|
-
io.out("
|
|
11236
|
-
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;
|
|
11237
12227
|
}
|
|
11238
12228
|
if (subcommand === "assess") {
|
|
11239
12229
|
if (!existsSync(target)) {
|
|
@@ -11241,65 +12231,19 @@ function runMaturityCommand(argv, io) {
|
|
|
11241
12231
|
return 10;
|
|
11242
12232
|
}
|
|
11243
12233
|
try {
|
|
11244
|
-
|
|
11245
|
-
const
|
|
11246
|
-
|
|
11247
|
-
|
|
11248
|
-
|
|
11249
|
-
ruleCount = 79;
|
|
11250
|
-
}
|
|
11251
|
-
else ruleCount = 79;
|
|
11252
|
-
let hasPolicy = false;
|
|
11253
|
-
const policyPath = join(target, ".mjolnir", "mjolnir.policy.json");
|
|
11254
|
-
if (existsSync(policyPath)) try {
|
|
11255
|
-
hasPolicy = true;
|
|
11256
|
-
} catch {
|
|
11257
|
-
hasPolicy = false;
|
|
11258
|
-
}
|
|
11259
|
-
let hasHistory = false;
|
|
11260
|
-
const statsPath = join(target, ".mjolnir", "stats.json");
|
|
11261
|
-
if (existsSync(statsPath)) try {
|
|
11262
|
-
hasHistory = true;
|
|
11263
|
-
} catch {
|
|
11264
|
-
hasHistory = false;
|
|
11265
|
-
}
|
|
11266
|
-
let hasTrends = false;
|
|
11267
|
-
const trendPath = join(target, ".mjolnir", "trend.jsonl");
|
|
11268
|
-
if (existsSync(trendPath)) try {
|
|
11269
|
-
hasTrends = true;
|
|
11270
|
-
} catch {
|
|
11271
|
-
hasTrends = false;
|
|
11272
|
-
}
|
|
11273
|
-
const dimensions = [
|
|
11274
|
-
assessDimension("Test Hygiene", Math.min(100, Math.round(ruleCount / 100 * 100)), [`${ruleCount} rules loaded`, ruleCount >= 50 ? "Good rule coverage" : "Increase rule coverage"]),
|
|
11275
|
-
assessDimension("CI Integrity", hasPolicy ? 75 : 30, [hasPolicy ? "Policy-as-code active" : "Implement policy-as-code", "Gate configuration verified"]),
|
|
11276
|
-
assessDimension("Runtime Verification", hasTrends ? 70 : 25, [hasTrends ? "Trend tracking active" : "Start quality trend tracking", hasHistory ? "Historical data available" : "No historical data yet"]),
|
|
11277
|
-
assessDimension("Process Maturity", hasHistory ? 65 : 20, [hasHistory ? "Fix tracking active" : "Start tracking fixes", "Triage cadence needs definition"])
|
|
11278
|
-
];
|
|
11279
|
-
const avgScore = Math.round(dimensions.reduce((sum, d) => sum + d.score, 0) / dimensions.length);
|
|
11280
|
-
const overall = avgScore >= 90 ? "Optimizing" : avgScore >= 75 ? "Quantitatively Managed" : avgScore >= 60 ? "Defined" : avgScore >= 40 ? "Managed" : "Initial";
|
|
11281
|
-
const assessment = {
|
|
11282
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
11283
|
-
overall,
|
|
11284
|
-
overallScore: avgScore,
|
|
11285
|
-
dimensions
|
|
11286
|
-
};
|
|
11287
|
-
io.out(sectionHeader("MATURITY ASSESSMENT", ui));
|
|
11288
|
-
io.out(`Generated: ${assessment.timestamp}`);
|
|
11289
|
-
io.out(`Overall: ${assessment.overall} (${assessment.overallScore}/100)`);
|
|
12234
|
+
const signals = assessMaturitySignals(target);
|
|
12235
|
+
const present = signals.filter((signal) => signal.present).length;
|
|
12236
|
+
io.out(sectionHeader("QA ARTIFACT SIGNALS", ui));
|
|
12237
|
+
io.out(`Target: ${target} · ${present}/${signals.length} named artifacts present`);
|
|
12238
|
+
io.out("No overall score: Mjölnir cannot measure organizational maturity.");
|
|
11290
12239
|
io.out("");
|
|
11291
|
-
for (const
|
|
11292
|
-
|
|
11293
|
-
io.out(
|
|
11294
|
-
|
|
11295
|
-
io.out(
|
|
11296
|
-
}
|
|
11297
|
-
const improvement = dimensions.filter((d) => d.score < 75).map((d) => `Improve ${d.name} to reach Quantitatively Managed`);
|
|
11298
|
-
if (improvement.length > 0) {
|
|
11299
|
-
io.out("--- Improvement Areas ---");
|
|
11300
|
-
for (const i of improvement) io.out(`• ${i}`);
|
|
12240
|
+
for (const signal of signals) {
|
|
12241
|
+
io.out(`${signal.present ? "[present]" : "[absent]"} ${signal.question}`);
|
|
12242
|
+
io.out(` path: ${signal.path}`);
|
|
12243
|
+
io.out(` presence says: ${signal.says}`);
|
|
12244
|
+
io.out(` presence does NOT say: ${signal.doesNotSay}`);
|
|
11301
12245
|
}
|
|
11302
|
-
return
|
|
12246
|
+
return unmeasuredClaim("maturity", "These are artifact-presence signals, not a maturity assessment, so no clean result is reported.").exitCode;
|
|
11303
12247
|
} catch (e) {
|
|
11304
12248
|
internalErrorMessage(e, io.err, false);
|
|
11305
12249
|
return 20;
|
|
@@ -11426,7 +12370,7 @@ function analyzeCrossFileSignals(files, findings, root) {
|
|
|
11426
12370
|
}));
|
|
11427
12371
|
const correlationConclusions = correlateFindings(findings).map((c) => `${c.conclusionType}: ${c.corroboration}`).join("; ");
|
|
11428
12372
|
const graph = buildDependencyGraph(root);
|
|
11429
|
-
const
|
|
12373
|
+
const reachability = getReachableFiles(orderedFiles.map((f) => f.path), graph);
|
|
11430
12374
|
return {
|
|
11431
12375
|
signals: [
|
|
11432
12376
|
...duplicateTestNames,
|
|
@@ -11438,7 +12382,7 @@ function analyzeCrossFileSignals(files, findings, root) {
|
|
|
11438
12382
|
circularDependencies,
|
|
11439
12383
|
correlationConclusions,
|
|
11440
12384
|
dependencyGraphSize: graph.size,
|
|
11441
|
-
|
|
12385
|
+
reachability
|
|
11442
12386
|
};
|
|
11443
12387
|
}
|
|
11444
12388
|
function detectSharedImports(files) {
|
|
@@ -11518,7 +12462,9 @@ function renderCrossFileAnalysis(result) {
|
|
|
11518
12462
|
lines.push(`Shared imports: ${result.sharedImports.length}`);
|
|
11519
12463
|
lines.push(`Circular dependencies: ${result.circularDependencies.length}`);
|
|
11520
12464
|
lines.push(`Dependency graph size: ${result.dependencyGraphSize}`);
|
|
11521
|
-
|
|
12465
|
+
const { reachability } = result;
|
|
12466
|
+
if (reachability.resolvedAny) lines.push(`Reachable files: ${reachability.reachable.length} (resolved from the dependency graph)`);
|
|
12467
|
+
else lines.push(`Reachable files: not resolved — the graph is keyed by package manifest and none of the ${reachability.unresolvedStarts.length} starting file(s) could be placed. This number is not evidence of a traversal.`);
|
|
11522
12468
|
lines.push("");
|
|
11523
12469
|
if (result.signals.length > 0) {
|
|
11524
12470
|
lines.push("Signals:");
|
|
@@ -11683,190 +12629,6 @@ function renderEvidenceGraphResult(result) {
|
|
|
11683
12629
|
return lines.join("\n").trimEnd();
|
|
11684
12630
|
}
|
|
11685
12631
|
//#endregion
|
|
11686
|
-
//#region src/frameworks/framework-inventory.ts
|
|
11687
|
-
const FRAMEWORK_INVENTORY = [
|
|
11688
|
-
{
|
|
11689
|
-
frameworkId: "playwright",
|
|
11690
|
-
entityType: "E2E_FRAMEWORK",
|
|
11691
|
-
language: "TypeScript/JavaScript",
|
|
11692
|
-
maturity: "F4",
|
|
11693
|
-
supportStatus: "OFFICIAL_PARTIAL",
|
|
11694
|
-
targetMaturity: "F5",
|
|
11695
|
-
targetSupportStatus: "OFFICIAL_FULL",
|
|
11696
|
-
validatedVersions: [
|
|
11697
|
-
"1.44",
|
|
11698
|
-
"1.45",
|
|
11699
|
-
"1.46",
|
|
11700
|
-
"1.47",
|
|
11701
|
-
"1.48"
|
|
11702
|
-
],
|
|
11703
|
-
keyGapForNextLevel: "GAP-PW-006 parallelism/worker safety analysis"
|
|
11704
|
-
},
|
|
11705
|
-
{
|
|
11706
|
-
frameworkId: "jest",
|
|
11707
|
-
entityType: "TEST_FRAMEWORK",
|
|
11708
|
-
language: "TypeScript/JavaScript",
|
|
11709
|
-
maturity: "F3",
|
|
11710
|
-
supportStatus: "OFFICIAL_PARTIAL",
|
|
11711
|
-
targetMaturity: "F4",
|
|
11712
|
-
targetSupportStatus: "OFFICIAL_FULL",
|
|
11713
|
-
validatedVersions: ["29", "30"],
|
|
11714
|
-
keyGapForNextLevel: "GAP-JEST-004 lifecycle modeling"
|
|
11715
|
-
},
|
|
11716
|
-
{
|
|
11717
|
-
frameworkId: "vitest",
|
|
11718
|
-
entityType: "TEST_FRAMEWORK",
|
|
11719
|
-
language: "TypeScript/JavaScript",
|
|
11720
|
-
maturity: "F3",
|
|
11721
|
-
supportStatus: "OFFICIAL_PARTIAL",
|
|
11722
|
-
targetMaturity: "F4",
|
|
11723
|
-
targetSupportStatus: "OFFICIAL_FULL",
|
|
11724
|
-
validatedVersions: [
|
|
11725
|
-
"1.6",
|
|
11726
|
-
"2.0",
|
|
11727
|
-
"2.1"
|
|
11728
|
-
],
|
|
11729
|
-
keyGapForNextLevel: "GAP-VIT-004 lifecycle modeling"
|
|
11730
|
-
},
|
|
11731
|
-
{
|
|
11732
|
-
frameworkId: "pytest",
|
|
11733
|
-
entityType: "TEST_FRAMEWORK",
|
|
11734
|
-
language: "Python",
|
|
11735
|
-
maturity: "F2",
|
|
11736
|
-
supportStatus: "OFFICIAL_PARTIAL",
|
|
11737
|
-
targetMaturity: "F3",
|
|
11738
|
-
targetSupportStatus: "OFFICIAL_FULL",
|
|
11739
|
-
validatedVersions: [
|
|
11740
|
-
"7.4",
|
|
11741
|
-
"8.0",
|
|
11742
|
-
"8.1",
|
|
11743
|
-
"8.2",
|
|
11744
|
-
"8.3"
|
|
11745
|
-
],
|
|
11746
|
-
keyGapForNextLevel: "GAP-PY-004 lifecycle modeling"
|
|
11747
|
-
},
|
|
11748
|
-
{
|
|
11749
|
-
frameworkId: "junit",
|
|
11750
|
-
entityType: "TEST_FRAMEWORK",
|
|
11751
|
-
language: "Java",
|
|
11752
|
-
maturity: "F2",
|
|
11753
|
-
supportStatus: "OFFICIAL_PARTIAL",
|
|
11754
|
-
targetMaturity: "F3",
|
|
11755
|
-
targetSupportStatus: "OFFICIAL_FULL",
|
|
11756
|
-
validatedVersions: ["5.10", "5.11"],
|
|
11757
|
-
keyGapForNextLevel: "GAP-JU-004 lifecycle modeling"
|
|
11758
|
-
},
|
|
11759
|
-
{
|
|
11760
|
-
frameworkId: "nunit",
|
|
11761
|
-
entityType: "TEST_FRAMEWORK",
|
|
11762
|
-
language: "C#",
|
|
11763
|
-
maturity: "F2",
|
|
11764
|
-
supportStatus: "OFFICIAL_PARTIAL",
|
|
11765
|
-
targetMaturity: "F3",
|
|
11766
|
-
targetSupportStatus: "OFFICIAL_FULL",
|
|
11767
|
-
validatedVersions: ["3.14", "4.2"],
|
|
11768
|
-
keyGapForNextLevel: "GAP-NU-004 lifecycle modeling"
|
|
11769
|
-
},
|
|
11770
|
-
{
|
|
11771
|
-
frameworkId: "xunit",
|
|
11772
|
-
entityType: "TEST_FRAMEWORK",
|
|
11773
|
-
language: "C#",
|
|
11774
|
-
maturity: "F2",
|
|
11775
|
-
supportStatus: "OFFICIAL_PARTIAL",
|
|
11776
|
-
targetMaturity: "F3",
|
|
11777
|
-
targetSupportStatus: "OFFICIAL_FULL",
|
|
11778
|
-
validatedVersions: ["2.9"],
|
|
11779
|
-
keyGapForNextLevel: "GAP-XU-004 lifecycle modeling"
|
|
11780
|
-
},
|
|
11781
|
-
{
|
|
11782
|
-
frameworkId: "cypress",
|
|
11783
|
-
entityType: "E2E_FRAMEWORK",
|
|
11784
|
-
language: "TypeScript/JavaScript",
|
|
11785
|
-
maturity: "F2",
|
|
11786
|
-
supportStatus: "EXPERIMENTAL",
|
|
11787
|
-
targetMaturity: "F3",
|
|
11788
|
-
targetSupportStatus: "OFFICIAL_PARTIAL",
|
|
11789
|
-
validatedVersions: ["13"],
|
|
11790
|
-
keyGapForNextLevel: "GAP-CY-004 lifecycle modeling"
|
|
11791
|
-
},
|
|
11792
|
-
{
|
|
11793
|
-
frameworkId: "selenium",
|
|
11794
|
-
entityType: "AUTOMATION_LIBRARY",
|
|
11795
|
-
language: "Java/TypeScript/Python",
|
|
11796
|
-
maturity: "F1",
|
|
11797
|
-
supportStatus: "EXPERIMENTAL",
|
|
11798
|
-
targetMaturity: "F2",
|
|
11799
|
-
targetSupportStatus: "OFFICIAL_PARTIAL",
|
|
11800
|
-
validatedVersions: [
|
|
11801
|
-
"4.20",
|
|
11802
|
-
"4.21",
|
|
11803
|
-
"4.22",
|
|
11804
|
-
"4.23",
|
|
11805
|
-
"4.24",
|
|
11806
|
-
"4.25"
|
|
11807
|
-
],
|
|
11808
|
-
keyGapForNextLevel: "GAP-SE-002 AST-based usage analysis"
|
|
11809
|
-
},
|
|
11810
|
-
{
|
|
11811
|
-
frameworkId: "testng",
|
|
11812
|
-
entityType: "TEST_FRAMEWORK",
|
|
11813
|
-
language: "Java",
|
|
11814
|
-
maturity: "F1",
|
|
11815
|
-
supportStatus: "EXPERIMENTAL",
|
|
11816
|
-
targetMaturity: "F2",
|
|
11817
|
-
targetSupportStatus: "OFFICIAL_PARTIAL",
|
|
11818
|
-
validatedVersions: ["7.10", "7.11"],
|
|
11819
|
-
keyGapForNextLevel: "GAP-TN-002 AST-based usage analysis"
|
|
11820
|
-
},
|
|
11821
|
-
{
|
|
11822
|
-
frameworkId: "github-actions",
|
|
11823
|
-
entityType: "CI_PROVIDER",
|
|
11824
|
-
language: "YAML",
|
|
11825
|
-
maturity: "F3",
|
|
11826
|
-
supportStatus: "OFFICIAL_PARTIAL",
|
|
11827
|
-
targetMaturity: "F4",
|
|
11828
|
-
targetSupportStatus: "OFFICIAL_FULL",
|
|
11829
|
-
validatedVersions: ["v4"],
|
|
11830
|
-
keyGapForNextLevel: "GAP-GHA-004 advanced matrix strategy modeling"
|
|
11831
|
-
},
|
|
11832
|
-
{
|
|
11833
|
-
frameworkId: "azure-devops",
|
|
11834
|
-
entityType: "CI_PROVIDER",
|
|
11835
|
-
language: "YAML",
|
|
11836
|
-
maturity: "F3",
|
|
11837
|
-
supportStatus: "OFFICIAL_PARTIAL",
|
|
11838
|
-
targetMaturity: "F4",
|
|
11839
|
-
targetSupportStatus: "OFFICIAL_FULL",
|
|
11840
|
-
validatedVersions: ["2024"],
|
|
11841
|
-
keyGapForNextLevel: "GAP-AZ-004 stage dependency graph modeling"
|
|
11842
|
-
},
|
|
11843
|
-
{
|
|
11844
|
-
frameworkId: "jenkins",
|
|
11845
|
-
entityType: "CI_PROVIDER",
|
|
11846
|
-
language: "Groovy/YAML",
|
|
11847
|
-
maturity: "F3",
|
|
11848
|
-
supportStatus: "OFFICIAL_PARTIAL",
|
|
11849
|
-
targetMaturity: "F4",
|
|
11850
|
-
targetSupportStatus: "OFFICIAL_FULL",
|
|
11851
|
-
validatedVersions: ["2.440", "2.450"],
|
|
11852
|
-
keyGapForNextLevel: "GAP-JK-004 declarative pipeline advanced features"
|
|
11853
|
-
},
|
|
11854
|
-
{
|
|
11855
|
-
frameworkId: "gitlab-ci",
|
|
11856
|
-
entityType: "CI_PROVIDER",
|
|
11857
|
-
language: "YAML",
|
|
11858
|
-
maturity: "F0",
|
|
11859
|
-
supportStatus: "UNSUPPORTED",
|
|
11860
|
-
targetMaturity: "F1",
|
|
11861
|
-
targetSupportStatus: "DISCOVERED",
|
|
11862
|
-
validatedVersions: [],
|
|
11863
|
-
keyGapForNextLevel: "GAP-GL-001 basic pipeline discovery"
|
|
11864
|
-
}
|
|
11865
|
-
];
|
|
11866
|
-
function getFrameworkById(frameworkId) {
|
|
11867
|
-
return FRAMEWORK_INVENTORY.find((f) => f.frameworkId === frameworkId);
|
|
11868
|
-
}
|
|
11869
|
-
//#endregion
|
|
11870
12632
|
//#region src/frameworks/scorecard.ts
|
|
11871
12633
|
const SCORECARD_DIMENSIONS = [
|
|
11872
12634
|
"discovery",
|
|
@@ -14281,7 +15043,7 @@ function loadPolicy(path) {
|
|
|
14281
15043
|
}
|
|
14282
15044
|
let record;
|
|
14283
15045
|
try {
|
|
14284
|
-
record = parseJsonFile(text, path, isRecord$
|
|
15046
|
+
record = parseJsonFile(text, path, isRecord$3);
|
|
14285
15047
|
} catch (error) {
|
|
14286
15048
|
throw new ConfigValidationError(`invalid suppression policy: ${path}`, { cause: error });
|
|
14287
15049
|
}
|
|
@@ -14300,7 +15062,7 @@ function loadPolicy(path) {
|
|
|
14300
15062
|
};
|
|
14301
15063
|
}
|
|
14302
15064
|
function isTrustSnapshot(value) {
|
|
14303
|
-
return isRecord$
|
|
15065
|
+
return isRecord$3(value) && typeof value.scanId === "string" && typeof value.timestamp === "string" && (typeof value.score === "number" || value.score === null) && typeof value.findings === "number" && typeof value.errors === "number" && typeof value.warnings === "number" && typeof value.infos === "number" && typeof value.advisory === "number" && typeof value.trustLevel === "string" && typeof value.confidence === "number" && typeof value.evidenceCoverage === "number" && typeof value.inconclusiveRate === "number" && typeof value.partial === "boolean" && typeof value.frameworkCount === "number" && typeof value.ruleCount === "number";
|
|
14304
15066
|
}
|
|
14305
15067
|
function loadTrustHistory(path) {
|
|
14306
15068
|
if (!existsSync(path)) return [];
|
|
@@ -14326,53 +15088,46 @@ function isStringArray(value) {
|
|
|
14326
15088
|
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
14327
15089
|
}
|
|
14328
15090
|
function isValidAnalysisStatus(value) {
|
|
14329
|
-
if (!isRecord$
|
|
15091
|
+
if (!isRecord$3(value)) return false;
|
|
14330
15092
|
return (value.discovery === "complete" || value.discovery === "partial") && (value.rules === "complete" || value.rules === "partial") && isNonNegativeInteger(value.skippedFiles) && isFiniteNumber(value.durationMs) && value.durationMs >= 0 && (value.truncationReasons === void 0 || isStringArray(value.truncationReasons)) && (value.rulesCrashed === void 0 || isNonNegativeInteger(value.rulesCrashed));
|
|
14331
15093
|
}
|
|
14332
15094
|
function isValidDimension(value) {
|
|
14333
|
-
return isRecord$
|
|
15095
|
+
return isRecord$3(value) && typeof value.category === "string" && isFiniteNumber(value.score) && value.score >= 0 && value.score <= 100 && isNonNegativeInteger(value.errors) && isNonNegativeInteger(value.warnings) && isNonNegativeInteger(value.infos);
|
|
14334
15096
|
}
|
|
14335
15097
|
function isValidFinding(value) {
|
|
14336
|
-
if (!isRecord$
|
|
15098
|
+
if (!isRecord$3(value)) return false;
|
|
14337
15099
|
return typeof value.ruleId === "string" && value.ruleId.length > 0 && typeof value.category === "string" && typeof value.file === "string" && value.file.length > 0 && isPositiveInteger(value.line) && isPositiveInteger(value.column) && typeof value.message === "string" && value.message.length > 0 && typeof value.severity === "string" && typeof value.confidence === "string" && typeof value.findingType === "string" && typeof value.qaImpact === "string" && typeof value.why === "string" && typeof value.fix === "string";
|
|
14338
15100
|
}
|
|
14339
15101
|
function isValidTrustSummary(value) {
|
|
14340
|
-
if (!isRecord$
|
|
14341
|
-
return
|
|
14342
|
-
"L0",
|
|
14343
|
-
"L1",
|
|
14344
|
-
"L2",
|
|
14345
|
-
"L3",
|
|
14346
|
-
"L4",
|
|
14347
|
-
"L5"
|
|
14348
|
-
].includes(String(value.level)) && isFiniteNumber(value.confidence) && value.confidence >= 0 && value.confidence <= 1 && isFiniteNumber(value.evidenceCoverage) && value.evidenceCoverage >= 0 && value.evidenceCoverage <= 1 && isFiniteNumber(value.inconclusiveRate) && value.inconclusiveRate >= 0 && value.inconclusiveRate <= 1 && (value.measuredFpOfFiredRules === void 0 || isFiniteNumber(value.measuredFpOfFiredRules) && value.measuredFpOfFiredRules >= 0 && value.measuredFpOfFiredRules <= 1) && isStringArray(value.provisionalRuleIds) && (value.confidenceCeiling === void 0 || isFiniteNumber(value.confidenceCeiling) && value.confidenceCeiling >= 0 && value.confidenceCeiling <= 1) && isStringArray(value.ceilingReasons);
|
|
15102
|
+
if (!isRecord$3(value)) return false;
|
|
15103
|
+
return TRUST_ORDER.includes(String(value.level)) && isFiniteNumber(value.confidence) && value.confidence >= 0 && value.confidence <= 1 && isFiniteNumber(value.evidenceCoverage) && value.evidenceCoverage >= 0 && value.evidenceCoverage <= 1 && isFiniteNumber(value.inconclusiveRate) && value.inconclusiveRate >= 0 && value.inconclusiveRate <= 1 && (value.measuredFpOfFiredRules === void 0 || isFiniteNumber(value.measuredFpOfFiredRules) && value.measuredFpOfFiredRules >= 0 && value.measuredFpOfFiredRules <= 1) && isStringArray(value.provisionalRuleIds) && (value.confidenceCeiling === void 0 || isFiniteNumber(value.confidenceCeiling) && value.confidenceCeiling >= 0 && value.confidenceCeiling <= 1) && isStringArray(value.ceilingReasons);
|
|
14349
15104
|
}
|
|
14350
15105
|
function isValidProvenance(value) {
|
|
14351
|
-
if (!isRecord$
|
|
15106
|
+
if (!isRecord$3(value)) return false;
|
|
14352
15107
|
return isNonNegativeInteger(value.testFiles) && isNonNegativeInteger(value.generatedMarkedFiles) && isNonNegativeInteger(value.codegenLikeFiles) && isFiniteNumber(value.shareMarkedGenerated) && value.shareMarkedGenerated >= 0 && value.shareMarkedGenerated <= 1 && isNonNegativeInteger(value.findingsInGeneratedFiles) && isNonNegativeInteger(value.findingsInUnmarkedFiles) && typeof value.note === "string";
|
|
14353
15108
|
}
|
|
14354
15109
|
function isValidForensicVerdicts(value) {
|
|
14355
|
-
if (!isRecord$
|
|
15110
|
+
if (!isRecord$3(value) || !isRecord$3(value.byVerdict)) return false;
|
|
14356
15111
|
return isNonNegativeInteger(value.classifications) && isNonNegativeInteger(value.inconclusive) && Object.values(value.byVerdict).every((count) => isNonNegativeInteger(count));
|
|
14357
15112
|
}
|
|
14358
15113
|
function isValidSummary(value) {
|
|
14359
|
-
if (!isRecord$
|
|
15114
|
+
if (!isRecord$3(value)) return false;
|
|
14360
15115
|
return typeof value.digest === "string" && value.digest.length > 0 && isNonNegativeInteger(value.findings) && (value.score === null || isFiniteNumber(value.score) && value.score >= 0 && value.score <= 100) && isNonNegativeInteger(value.errors) && isNonNegativeInteger(value.warnings) && isNonNegativeInteger(value.infos) && isNonNegativeInteger(value.advisory);
|
|
14361
15116
|
}
|
|
14362
15117
|
function isValidAnnotation(value) {
|
|
14363
|
-
if (!isRecord$
|
|
15118
|
+
if (!isRecord$3(value)) return false;
|
|
14364
15119
|
return typeof value.path === "string" && value.path.length > 0 && isPositiveInteger(value.start_line) && (value.annotation_level === "failure" || value.annotation_level === "warning" || value.annotation_level === "notice") && typeof value.message === "string" && typeof value.ruleId === "string" && value.ruleId.length > 0 && (value.detectorRevision === void 0 || isPositiveInteger(value.detectorRevision)) && typeof value.advisory === "boolean";
|
|
14365
15120
|
}
|
|
14366
15121
|
function isValidCompleteness(value) {
|
|
14367
|
-
if (!isRecord$
|
|
15122
|
+
if (!isRecord$3(value)) return false;
|
|
14368
15123
|
return typeof value.partial === "boolean" && (value.discovery === "complete" || value.discovery === "partial") && (value.rules === "complete" || value.rules === "partial") && isNonNegativeInteger(value.skippedFiles) && isNonNegativeInteger(value.rulesCrashed) && isStringArray(value.truncationReasons) && typeof value.frameworkDetectionUnknown === "boolean" && isFiniteNumber(value.durationMs) && value.durationMs >= 0;
|
|
14369
15124
|
}
|
|
14370
15125
|
function isValidContract(value) {
|
|
14371
|
-
if (!isRecord$
|
|
15126
|
+
if (!isRecord$3(value)) return false;
|
|
14372
15127
|
return Number.isSafeInteger(value.contractVersion) && isValidSummary(value.summary) && Array.isArray(value.annotations) && value.annotations.length <= 50 && value.annotations.every((annotation) => isValidAnnotation(annotation)) && typeof value.annotationsTruncated === "boolean" && isValidCompleteness(value.completeness) && (value.trustSummary === void 0 || isValidTrustSummary(value.trustSummary)) && (value.provenance === void 0 || isValidProvenance(value.provenance)) && (value.forensicVerdicts === void 0 || isValidForensicVerdicts(value.forensicVerdicts));
|
|
14373
15128
|
}
|
|
14374
15129
|
function isScanResultDocument(value) {
|
|
14375
|
-
if (!isRecord$
|
|
15130
|
+
if (!isRecord$3(value) || value.schemaVersion !== 1) return false;
|
|
14376
15131
|
return (value.score === null || isFiniteNumber(value.score) && value.score >= 0 && value.score <= 100) && typeof value.partial === "boolean" && isStringArray(value.frameworks) && Array.isArray(value.dimensions) && value.dimensions.every((dimension) => isValidDimension(dimension)) && Array.isArray(value.findings) && value.findings.every((finding) => isValidFinding(finding)) && isValidAnalysisStatus(value.analysisStatus) && (value.agenticProfile === void 0 || isValidProvenance(value.agenticProfile)) && (value.trustSummary === void 0 || isValidTrustSummary(value.trustSummary)) && (value.forensicVerdicts === void 0 || isValidForensicVerdicts(value.forensicVerdicts)) && isValidContract(value.contract);
|
|
14377
15132
|
}
|
|
14378
15133
|
function emitVerification(verification, json, io) {
|
|
@@ -14628,12 +15383,14 @@ async function runCIIntegrityCommand(argv, io = {
|
|
|
14628
15383
|
* keeps every existing consumer stable.
|
|
14629
15384
|
*/
|
|
14630
15385
|
const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isValidFindingRecord, discoverAndParseRuntimeReport, KNOWN_RULE_IDS, OVERLAP_META_BY_RULE_ID, EVIDENCE_OVERRIDES, SUITE_INVALIDATING_RULE_IDS } = scan_pipeline_exports;
|
|
15386
|
+
const DEFAULT_MAX_DURATION_MS = 6e5;
|
|
15387
|
+
const MAX_DURATION_MS = 36e5;
|
|
14631
15388
|
function parseArgs(argv, onError) {
|
|
14632
15389
|
const args = {
|
|
14633
15390
|
target: ".",
|
|
14634
15391
|
json: false,
|
|
14635
15392
|
verbose: false,
|
|
14636
|
-
maxDurationMs:
|
|
15393
|
+
maxDurationMs: DEFAULT_MAX_DURATION_MS,
|
|
14637
15394
|
scopeChanged: false,
|
|
14638
15395
|
format: "terminal"
|
|
14639
15396
|
};
|
|
@@ -14679,7 +15436,7 @@ function parseArgs(argv, onError) {
|
|
|
14679
15436
|
args.base = ref;
|
|
14680
15437
|
} else if (a === "--max-duration") {
|
|
14681
15438
|
const v = Number(argv[++i]);
|
|
14682
|
-
if (!Number.isFinite(v) || v <= 0) return reject({
|
|
15439
|
+
if (!Number.isFinite(v) || v <= 0 || v * 1e3 > 36e5) return reject({
|
|
14683
15440
|
flag: "--max-duration",
|
|
14684
15441
|
token: argv[i]
|
|
14685
15442
|
});
|
|
@@ -14841,14 +15598,22 @@ function validateScanTarget(target, err) {
|
|
|
14841
15598
|
return null;
|
|
14842
15599
|
}
|
|
14843
15600
|
/**
|
|
14844
|
-
* Exit-code decision for
|
|
14845
|
-
*
|
|
14846
|
-
*
|
|
15601
|
+
* Exit-code decision for findings only (audit H-7): the previously-dead
|
|
15602
|
+
* config.gate field selects which severities block. Advisory (E0) findings
|
|
15603
|
+
* never gate at any level.
|
|
15604
|
+
*
|
|
15605
|
+
* This is a findings-only view of the one exit matrix in `claim-evidence`.
|
|
15606
|
+
* Callers that have just finished an ANALYSIS must use `scanExitCode`
|
|
15607
|
+
* instead — this function cannot see `partial`, and a truncated scan with
|
|
15608
|
+
* zero findings is exactly the case that turns a bug into a green build.
|
|
14847
15609
|
*/
|
|
14848
15610
|
function exitForFindings(findings, gate) {
|
|
14849
|
-
|
|
14850
|
-
|
|
14851
|
-
|
|
15611
|
+
return scanExitCode({
|
|
15612
|
+
partial: false,
|
|
15613
|
+
findings,
|
|
15614
|
+
gate,
|
|
15615
|
+
isAdvisory: isAdvisoryFinding
|
|
15616
|
+
});
|
|
14852
15617
|
}
|
|
14853
15618
|
/** Testable `ci install` handler. Returns the process exit code. */
|
|
14854
15619
|
function runCiInstall(argv, io = {
|
|
@@ -15040,11 +15805,19 @@ function isEntryPoint() {
|
|
|
15040
15805
|
return import.meta.url === pathToFileURL(argv1).href;
|
|
15041
15806
|
}
|
|
15042
15807
|
}
|
|
15043
|
-
if (isEntryPoint())
|
|
15044
|
-
|
|
15045
|
-
|
|
15046
|
-
|
|
15047
|
-
|
|
15808
|
+
if (isEntryPoint()) {
|
|
15809
|
+
await initSentry();
|
|
15810
|
+
try {
|
|
15811
|
+
process$1.exitCode = await main();
|
|
15812
|
+
} catch (err) {
|
|
15813
|
+
captureInternalError(err, "cli");
|
|
15814
|
+
internalErrorMessage(err, (s) => process$1.stderr.write(s + "\n"), false);
|
|
15815
|
+
process$1.exitCode = 20;
|
|
15816
|
+
} finally {
|
|
15817
|
+
await flushSentry();
|
|
15818
|
+
}
|
|
15048
15819
|
}
|
|
15049
15820
|
//#endregion
|
|
15050
|
-
export { ENGINE_VERSION as CLI_VERSION, EVIDENCE_OVERRIDES, KNOWN_RULE_IDS, OVERLAP_META_BY_RULE_ID, SUITE_INVALIDATING_RULE_IDS, buildUniversalRules, discoverAndParseRuntimeReport, err, exitForFindings, fallbackWorkspace, internalErrorMessage, isEntryPoint, isValidFindingRecord, levenshtein, main, nearestFlags, out, parseArgs, parseArgsOrUsage, pathMatchesGlob, printUsage, renderScanOutput, runAnalyzeCommand, runBadgeCommand, runBaselineCommand, runBusinessCaseCommand, runCIIntegrityCommand, runCiAdapterCommand, runCiInstall, runContractVerifyCommand, runCreateRuleCommand, runCrossFileCommand, runDashboardCommand, runDebtCommand, runDiffCommand, runDoctorCommand, runDoctorPlaywright, runEnterpriseCommand, runEvidenceGraphCommand, runExecReportCommand, runExplainCommand, runFixCommand, runForensicsCommand, runFrameworkMaturityCommand, runHandoffCommand, runHandoverCommand, runHelpCommand, runImpactCommand, runInitCommand, runInstallCommand, runMaturityCommand, runMutationCommand, runPolicyCommand, runPrCommentCommand, runPwReportCommand, runQuarantineCommand, runReleaseReportCommand, runReleaseTrustCommand, runReportPlaywrightCommand, runRulesCommand, runScan, runScanCommand, runStatsCommand, runSummaryCommand, runSuppressionGateCommand, runSuppressions, runTrendCommand, runTriageCommand, runTrustReportCommand, runTrustTrendCommand, runVerifyCommand, runWhyCommand, usageErrorMessage, validateScanTarget };
|
|
15821
|
+
export { ENGINE_VERSION as CLI_VERSION, DEFAULT_MAX_DURATION_MS, EVIDENCE_OVERRIDES, KNOWN_RULE_IDS, MAX_DURATION_MS, OVERLAP_META_BY_RULE_ID, SUITE_INVALIDATING_RULE_IDS, buildUniversalRules, discoverAndParseRuntimeReport, err, exitForFindings, fallbackWorkspace, internalErrorMessage, isEntryPoint, isValidFindingRecord, levenshtein, main, nearestFlags, out, parseArgs, parseArgsOrUsage, pathMatchesGlob, printUsage, renderScanOutput, runAnalyzeCommand, runBadgeCommand, runBaselineCommand, runBusinessCaseCommand, runCIIntegrityCommand, runCiAdapterCommand, runCiInstall, runContractVerifyCommand, runCreateRuleCommand, runCrossFileCommand, runDashboardCommand, runDebtCommand, runDiffCommand, runDoctorCommand, runDoctorPlaywright, runEnterpriseCommand, runEvidenceGraphCommand, runExecReportCommand, runExplainCommand, runFixCommand, runForensicsCommand, runFrameworkMaturityCommand, runHandoffCommand, runHandoverCommand, runHelpCommand, runImpactCommand, runInitCommand, runInstallCommand, runMaturityCommand, runMutationCommand, runPolicyCommand, runPrCommentCommand, runPwReportCommand, runQuarantineCommand, runReleaseReportCommand, runReleaseTrustCommand, runReportPlaywrightCommand, runRulesCommand, runScan, runScanCommand, runStatsCommand, runSummaryCommand, runSuppressionGateCommand, runSuppressions, runTrendCommand, runTriageCommand, runTrustReportCommand, runTrustTrendCommand, runVerifyCommand, runWhyCommand, usageErrorMessage, validateScanTarget };
|
|
15822
|
+
|
|
15823
|
+
//# sourceMappingURL=cli.mjs.map
|