mjolnir-qa 0.5.2 → 0.5.4
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 +190 -0
- package/README.ar.md +3 -3
- package/README.bn.md +3 -3
- package/README.br.md +3 -3
- package/README.bs.md +3 -3
- package/README.da.md +3 -3
- package/README.de.md +3 -3
- package/README.es.md +3 -3
- package/README.fr.md +3 -3
- package/README.gr.md +3 -3
- package/README.he.md +3 -3
- package/README.it.md +3 -3
- package/README.ja.md +3 -3
- package/README.ko.md +3 -3
- package/README.md +3 -3
- package/README.no.md +3 -3
- package/README.pl.md +3 -3
- package/README.ru.md +3 -3
- package/README.th.md +3 -3
- package/README.tr.md +3 -3
- package/README.uk.md +3 -3
- package/README.vi.md +3 -3
- package/README.zh.md +3 -3
- package/README.zht.md +3 -3
- package/dist/cli.d.mts +113 -4
- package/dist/cli.mjs +2340 -493
- package/package.json +1 -1
package/dist/cli.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
-
import { accessSync, chmodSync, constants, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { accessSync, appendFileSync, chmodSync, constants, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { execFileSync, execSync } from "node:child_process";
|
|
5
5
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import process$1 from "node:process";
|
|
@@ -25,6 +25,15 @@ const QA_IMPACT_LABELS = {
|
|
|
25
25
|
"FALSE-GREEN": "False-green risk",
|
|
26
26
|
HYGIENE: "Test hygiene debt"
|
|
27
27
|
};
|
|
28
|
+
/** The closed set of rule categories (plan §5.5). `--category` values
|
|
29
|
+
* are validated against this list — unknown categories are a usage
|
|
30
|
+
* error, not a silent no-op. */
|
|
31
|
+
const RULE_CATEGORIES = [
|
|
32
|
+
"QA-TEST",
|
|
33
|
+
"QA-TQUAL",
|
|
34
|
+
"QA-PW",
|
|
35
|
+
"QA-CI"
|
|
36
|
+
];
|
|
28
37
|
/**
|
|
29
38
|
* Honest default evidence level for a finding (Honesty Core Phase 1).
|
|
30
39
|
* Derivation is deterministic and conservative:
|
|
@@ -8960,8 +8969,17 @@ const off = {
|
|
|
8960
8969
|
function rgb([r, g, b]) {
|
|
8961
8970
|
return (s) => `\x1b[38;2;${r};${g};${b}m${sanitizeData(s)}\x1b[0m`;
|
|
8962
8971
|
}
|
|
8963
|
-
/**
|
|
8972
|
+
/**
|
|
8973
|
+
* True when colors should be emitted for this render call.
|
|
8974
|
+
*
|
|
8975
|
+
* Precedence (chalk convention): FORCE_COLOR wins over everything —
|
|
8976
|
+
* `FORCE_COLOR=0` (or "false"/empty) forces plain output even on a TTY,
|
|
8977
|
+
* any other value forces color even when piped. Without FORCE_COLOR,
|
|
8978
|
+
* NO_COLOR disables color and the rest follows TTY-ness.
|
|
8979
|
+
*/
|
|
8964
8980
|
function shouldColorize(isTTY) {
|
|
8981
|
+
const forced = process.env["FORCE_COLOR"];
|
|
8982
|
+
if (forced !== void 0) return forced !== "0" && forced !== "false" && forced !== "";
|
|
8965
8983
|
return isTTY && !process.env["NO_COLOR"];
|
|
8966
8984
|
}
|
|
8967
8985
|
function palette(enabled) {
|
|
@@ -9066,23 +9084,90 @@ function gaugeColorForBand(band, p) {
|
|
|
9066
9084
|
if (band === "unmeasured") return p.dim;
|
|
9067
9085
|
return band === "warning" ? p.warning : p.error;
|
|
9068
9086
|
}
|
|
9069
|
-
|
|
9070
|
-
|
|
9071
|
-
|
|
9072
|
-
*
|
|
9073
|
-
|
|
9074
|
-
|
|
9075
|
-
|
|
9076
|
-
|
|
9077
|
-
|
|
9078
|
-
|
|
9087
|
+
//#endregion
|
|
9088
|
+
//#region src/reporter/ui.ts
|
|
9089
|
+
/**
|
|
9090
|
+
* Neutral context for direct calls (tests, library use) and as the
|
|
9091
|
+
* safe default when a command runner is invoked without wiring: no
|
|
9092
|
+
* color, unicode glyphs, 80 columns. cli.ts builds the real context
|
|
9093
|
+
* once per run (TTY, width, ASCII heuristic) and passes it down.
|
|
9094
|
+
*/
|
|
9095
|
+
function plainContext(width = 80) {
|
|
9096
|
+
return {
|
|
9097
|
+
p: palette(false),
|
|
9098
|
+
ascii: false,
|
|
9099
|
+
width
|
|
9100
|
+
};
|
|
9101
|
+
}
|
|
9102
|
+
/** Canonical severity glyphs. Symbols always accompany color (R11). */
|
|
9103
|
+
const SEVERITY_GLYPHS = {
|
|
9104
|
+
unicode: {
|
|
9079
9105
|
error: "✗",
|
|
9080
9106
|
warning: "⚠",
|
|
9081
9107
|
info: "ℹ"
|
|
9082
|
-
}
|
|
9083
|
-
|
|
9084
|
-
|
|
9085
|
-
|
|
9108
|
+
},
|
|
9109
|
+
ascii: {
|
|
9110
|
+
error: "X",
|
|
9111
|
+
warning: "!",
|
|
9112
|
+
info: "i"
|
|
9113
|
+
}
|
|
9114
|
+
};
|
|
9115
|
+
const FLAKE_GLYPH = "🔥";
|
|
9116
|
+
/** Section header: `▚ TITLE` (ascii `= TITLE`). The one header style. */
|
|
9117
|
+
function sectionHeader(title, ui) {
|
|
9118
|
+
const glyph = ui.ascii ? "=" : "▚";
|
|
9119
|
+
return ` ${ui.p.accent(`${glyph} ${title}`)}`;
|
|
9120
|
+
}
|
|
9121
|
+
/** Rounded panel around wrapped text lines, indented two spaces. */
|
|
9122
|
+
function panel(lines, ui, pad = 1) {
|
|
9123
|
+
return box(lines, pad, {
|
|
9124
|
+
maxWidth: ui.width - 2,
|
|
9125
|
+
ascii: ui.ascii
|
|
9126
|
+
}).map((l) => ` ${l}`);
|
|
9127
|
+
}
|
|
9128
|
+
/** Horizontal rule under a section block. */
|
|
9129
|
+
function divider(ui) {
|
|
9130
|
+
return ui.p.dim((ui.ascii ? "-" : "─").repeat(58));
|
|
9131
|
+
}
|
|
9132
|
+
/**
|
|
9133
|
+
* Next-step affordance: dim `$ command` line. The `$` prefix is the
|
|
9134
|
+
* universal "type this in your shell" marker; the command itself is
|
|
9135
|
+
* plain (not dimmed) so it copies cleanly from a terminal.
|
|
9136
|
+
*/
|
|
9137
|
+
function nextStep(command, ui) {
|
|
9138
|
+
return ` ${ui.p.dim("$")} ${command}`;
|
|
9139
|
+
}
|
|
9140
|
+
/** Themed severity icon: glyph + padded label, e.g. `✗ ERROR `. */
|
|
9141
|
+
function severityIcon(severity, ui) {
|
|
9142
|
+
const g = (ui.ascii ? SEVERITY_GLYPHS.ascii : SEVERITY_GLYPHS.unicode)[severity];
|
|
9143
|
+
if (severity === "error") return `${ui.p.error(g)} ${ui.p.error("ERROR ")}`;
|
|
9144
|
+
if (severity === "warning") return `${ui.p.warning(g)} ${ui.p.warning("WARN ")}`;
|
|
9145
|
+
return `${ui.p.info(g)} ${ui.p.info("INFO ")}`;
|
|
9146
|
+
}
|
|
9147
|
+
/** Themed success icon (`✓` / `v` in ascii). */
|
|
9148
|
+
function okIcon(ui) {
|
|
9149
|
+
const g = ui.ascii ? "v" : "✓";
|
|
9150
|
+
return ui.p.ok(g);
|
|
9151
|
+
}
|
|
9152
|
+
/**
|
|
9153
|
+
* Footer builder: `Analysis complete · <duration>ms` + optional
|
|
9154
|
+
* suppressed count + optional next action. `durationMs` is formatted
|
|
9155
|
+
* by formatDuration; `next` is rendered as a `$ command` line.
|
|
9156
|
+
*/
|
|
9157
|
+
function buildFooter(opts) {
|
|
9158
|
+
const { ui } = opts;
|
|
9159
|
+
const lines = [];
|
|
9160
|
+
lines.push(divider(ui));
|
|
9161
|
+
const status = opts.complete ? `${ui.p.ok("complete")} · ${formatDuration(opts.durationMs)}` : `${ui.p.warning("PARTIAL — verdict may be incomplete")} · ${formatDuration(opts.durationMs)}`;
|
|
9162
|
+
lines.push(` Analysis: ${status}`);
|
|
9163
|
+
if (opts.suppressedCount && opts.suppressedCount > 0) lines.push(ui.p.dim(` ${opts.suppressedCount} finding(s) suppressed by config`));
|
|
9164
|
+
if (opts.next) lines.push(nextStep(opts.next, ui));
|
|
9165
|
+
return lines;
|
|
9166
|
+
}
|
|
9167
|
+
/** Human duration: `1.2s` above a second, `850ms` below. */
|
|
9168
|
+
function formatDuration(ms) {
|
|
9169
|
+
if (ms === void 0) return "?";
|
|
9170
|
+
return ms >= 1e3 ? `${(ms / 1e3).toFixed(1)}s` : `${ms}ms`;
|
|
9086
9171
|
}
|
|
9087
9172
|
//#endregion
|
|
9088
9173
|
//#region src/reporter/art.ts
|
|
@@ -9131,8 +9216,7 @@ String.raw`
|
|
|
9131
9216
|
| \IIIIII/ |
|
|
9132
9217
|
\ /
|
|
9133
9218
|
` + "";
|
|
9134
|
-
|
|
9135
|
-
const DIVIDER = "─".repeat(58);
|
|
9219
|
+
"─".repeat(58);
|
|
9136
9220
|
/** ASCII-mode state caption — the text marker that carries the state
|
|
9137
9221
|
* when color is absent (symbols-accompany-color doctrine, R11). */
|
|
9138
9222
|
const HAMMER_CAPTIONS = {
|
|
@@ -9410,23 +9494,42 @@ function renderTerminal(result, opts) {
|
|
|
9410
9494
|
const p = palette(shouldColorize(opts.isTTY));
|
|
9411
9495
|
const width = Math.max(MIN_BOX_WIDTH, opts.width ?? process.stdout.columns ?? 80);
|
|
9412
9496
|
const ascii = opts.ascii ?? shouldUseAscii();
|
|
9497
|
+
const ui = {
|
|
9498
|
+
p,
|
|
9499
|
+
ascii,
|
|
9500
|
+
width
|
|
9501
|
+
};
|
|
9413
9502
|
const lines = [];
|
|
9414
9503
|
const logo = ascii ? LOGO_ASCII : LOGO;
|
|
9415
9504
|
for (const l of logo.split("\n")) if (l.trim()) lines.push(p.accent(l));
|
|
9416
9505
|
lines.push("");
|
|
9417
|
-
if (result.score === null) return renderNoTests(
|
|
9506
|
+
if (result.score === null) return renderNoTests(ui);
|
|
9418
9507
|
const counts = countBySeverity(result);
|
|
9419
9508
|
appendScoreSection(lines, {
|
|
9420
9509
|
...result,
|
|
9421
9510
|
score: result.score
|
|
9422
9511
|
}, p, width, ascii);
|
|
9423
|
-
appendFrameworks(lines, result,
|
|
9424
|
-
|
|
9425
|
-
|
|
9426
|
-
|
|
9427
|
-
|
|
9512
|
+
appendFrameworks(lines, result, ui);
|
|
9513
|
+
if (result.staged !== void 0) {
|
|
9514
|
+
lines.push(ui.p.dim(` staged surface: ${result.staged.files} file(s) scanned; score reflects that surface`));
|
|
9515
|
+
lines.push("");
|
|
9516
|
+
}
|
|
9517
|
+
appendDimensions(lines, result, ui);
|
|
9518
|
+
appendDeductions(lines, result, counts, ui);
|
|
9519
|
+
const filtered = opts.visibleFindings;
|
|
9520
|
+
const filtering = filtered !== void 0 && filtered.length < result.findings.length;
|
|
9521
|
+
const display = filtering ? {
|
|
9522
|
+
...result,
|
|
9523
|
+
findings: filtered
|
|
9524
|
+
} : result;
|
|
9525
|
+
appendFixThisFirst(lines, display, ui);
|
|
9526
|
+
if (filtering) {
|
|
9527
|
+
lines.push(ui.p.dim(` filtered view: ${filtered?.length} of ${result.findings.length} findings shown; score reflects the full scan`));
|
|
9528
|
+
lines.push("");
|
|
9529
|
+
}
|
|
9530
|
+
appendFindings(lines, display, counts, opts.verbose === true, ui, opts.tone);
|
|
9428
9531
|
if (counts.total === 0 && result.score === 100) appendForgedBlock(lines, p, ascii);
|
|
9429
|
-
appendFooter(lines, result,
|
|
9532
|
+
appendFooter(lines, result, ui);
|
|
9430
9533
|
return lines.join("\n");
|
|
9431
9534
|
}
|
|
9432
9535
|
/**
|
|
@@ -9460,28 +9563,30 @@ function colorizeVerdict(verdict, band, p) {
|
|
|
9460
9563
|
if (band === "warning") return p.warning(verdict);
|
|
9461
9564
|
return p.error(verdict);
|
|
9462
9565
|
}
|
|
9463
|
-
function appendFrameworks(lines, result,
|
|
9566
|
+
function appendFrameworks(lines, result, ui) {
|
|
9567
|
+
const { p } = ui;
|
|
9464
9568
|
if (result.frameworks.length > 0) {
|
|
9465
9569
|
const tags = result.frameworks.map((f) => `[${f}]`).join(" ");
|
|
9466
9570
|
lines.push(` ${p.dim("DETECTED")} ${p.info(tags)}`);
|
|
9467
9571
|
} else if (result.frameworkDetectionUnknown) lines.push(` ${p.dim("FRAMEWORK")} unknown — scanning all test-looking files. Add a package.json/config the detector recognizes for framework-aware scoring.`);
|
|
9468
9572
|
lines.push("");
|
|
9469
9573
|
}
|
|
9470
|
-
function appendDimensions(lines, result,
|
|
9574
|
+
function appendDimensions(lines, result, ui) {
|
|
9471
9575
|
const dims = result.dimensions.length > 0 ? result.dimensions : computeDimensions(result.findings);
|
|
9472
9576
|
if (dims.length === 0) return;
|
|
9473
|
-
lines.push(
|
|
9577
|
+
lines.push(sectionHeader("DIAGNOSTICS BY CATEGORY", ui));
|
|
9474
9578
|
const width = Math.max(...dims.map((d) => d.category.length));
|
|
9475
9579
|
for (const d of dims) {
|
|
9476
9580
|
const label = padTo(d.category, width);
|
|
9477
9581
|
const scoreText = String(d.score).padStart(3);
|
|
9478
|
-
lines.push(` ${label} ${scoreGauge(d.score, p, 16, ascii)} ${scoreText}`);
|
|
9582
|
+
lines.push(` ${label} ${scoreGauge(d.score, ui.p, 16, ui.ascii)} ${scoreText}`);
|
|
9479
9583
|
}
|
|
9480
9584
|
lines.push("");
|
|
9481
9585
|
}
|
|
9482
|
-
function appendDeductions(lines, result, counts,
|
|
9586
|
+
function appendDeductions(lines, result, counts, ui) {
|
|
9587
|
+
const { p } = ui;
|
|
9483
9588
|
if (counts.total === 0) return;
|
|
9484
|
-
lines.push(
|
|
9589
|
+
lines.push(sectionHeader("WHERE POINTS WERE LOST", ui));
|
|
9485
9590
|
const rows = [];
|
|
9486
9591
|
const bySeverity = {
|
|
9487
9592
|
error: {
|
|
@@ -9511,21 +9616,18 @@ function appendDeductions(lines, result, counts, p, width, ascii) {
|
|
|
9511
9616
|
const discounted = s.ded < s.n * DEDUCTIONS[sev];
|
|
9512
9617
|
rows.push(`${s.n} × ${sev.padEnd(7)} −${String(s.ded).padStart(3)}${discounted ? p.dim(" (evidence-discounted)") : ""}`);
|
|
9513
9618
|
}
|
|
9514
|
-
for (const row of
|
|
9515
|
-
maxWidth: width - 2,
|
|
9516
|
-
ascii
|
|
9517
|
-
})) lines.push(` ${row}`);
|
|
9619
|
+
for (const row of panel(rows, ui)) lines.push(row);
|
|
9518
9620
|
lines.push("");
|
|
9519
9621
|
}
|
|
9520
|
-
function appendFixThisFirst(lines, result,
|
|
9622
|
+
function appendFixThisFirst(lines, result, ui) {
|
|
9521
9623
|
const fixes = topFixes(result.findings, 3);
|
|
9522
9624
|
if (fixes.length === 0) return;
|
|
9523
|
-
lines.push(
|
|
9625
|
+
lines.push(sectionHeader("FIX THIS FIRST", ui));
|
|
9524
9626
|
for (const { finding: f, scoreGain, autofixable } of fixes) {
|
|
9525
9627
|
const gainText = `+${scoreGain} pt${scoreGain === 1 ? "" : "s"}`;
|
|
9526
|
-
const autofixTag = autofixable ? p.ok(" [autofix available]") : "";
|
|
9628
|
+
const autofixTag = autofixable ? ui.p.ok(" [autofix available]") : "";
|
|
9527
9629
|
const loc = `${sanitizeData(f.ruleId)} · ${sanitizeData(f.file)}:${f.line}`;
|
|
9528
|
-
lines.push(` ${p.bold(gainText)} ${loc}${autofixTag}`);
|
|
9630
|
+
lines.push(` ${ui.p.bold(gainText)} ${loc}${autofixTag}`);
|
|
9529
9631
|
}
|
|
9530
9632
|
lines.push("");
|
|
9531
9633
|
}
|
|
@@ -9586,9 +9688,10 @@ function wrapLines(text, width) {
|
|
|
9586
9688
|
}
|
|
9587
9689
|
const CARD_LABEL_PAD = 8;
|
|
9588
9690
|
const CARD_GUTTER = " ";
|
|
9589
|
-
function pushCard(lines, card,
|
|
9691
|
+
function pushCard(lines, card, ui) {
|
|
9692
|
+
const { p, width } = ui;
|
|
9590
9693
|
const contentWidth = Math.max(20, width - 2 - 4 - CARD_LABEL_PAD);
|
|
9591
|
-
lines.push(` ${
|
|
9694
|
+
lines.push(` ${severityIcon(card.severity, ui)} ${p.bold(card.loc)} ${p.dim(card.evidence)}`);
|
|
9592
9695
|
const fields = [
|
|
9593
9696
|
{
|
|
9594
9697
|
label: "Problem",
|
|
@@ -9627,8 +9730,14 @@ function pushCard(lines, card, p, width, ascii) {
|
|
|
9627
9730
|
* "same fix applies" header. Non-verbose shows MAX_CARDS cards plus an
|
|
9628
9731
|
* overflow line; --verbose shows everything.
|
|
9629
9732
|
*/
|
|
9630
|
-
function appendFindings(lines, result, counts, verbose,
|
|
9733
|
+
function appendFindings(lines, result, counts, verbose, ui, tone) {
|
|
9734
|
+
const { p } = ui;
|
|
9631
9735
|
if (counts.total === 0) return;
|
|
9736
|
+
if (result.findings.length === 0) {
|
|
9737
|
+
lines.push(ui.p.dim(" filtered view: no findings in the selected category"));
|
|
9738
|
+
lines.push("");
|
|
9739
|
+
return;
|
|
9740
|
+
}
|
|
9632
9741
|
const byRule = /* @__PURE__ */ new Map();
|
|
9633
9742
|
for (const f of result.findings) {
|
|
9634
9743
|
const list = byRule.get(f.ruleId) ?? [];
|
|
@@ -9660,7 +9769,7 @@ function appendFindings(lines, result, counts, verbose, p, ascii, width, tone) {
|
|
|
9660
9769
|
let shown = 0;
|
|
9661
9770
|
let hidden = 0;
|
|
9662
9771
|
const hiddenRules = /* @__PURE__ */ new Set();
|
|
9663
|
-
lines.push(
|
|
9772
|
+
lines.push(sectionHeader("FINDINGS", ui));
|
|
9664
9773
|
lines.push("");
|
|
9665
9774
|
for (const unit of units) {
|
|
9666
9775
|
if (unit.kind === "group") {
|
|
@@ -9671,7 +9780,7 @@ function appendFindings(lines, result, counts, verbose, p, ascii, width, tone) {
|
|
|
9671
9780
|
hiddenRules.add(unit.ruleId);
|
|
9672
9781
|
continue;
|
|
9673
9782
|
}
|
|
9674
|
-
lines.push(` ${
|
|
9783
|
+
lines.push(` ${severityIcon(maxSeverity(unit.findings), ui)} ${p.bold(sanitizeData(unit.ruleId))} ${p.dim(`× ${n} — same fix applies`)} ${p.dim(evidenceTag$1(first))}`);
|
|
9675
9784
|
lines.push(`${CARD_GUTTER}${p.accent("Fix".padEnd(CARD_LABEL_PAD))}${p.dim(sanitizeData(first.fix))}`);
|
|
9676
9785
|
for (const f of unit.findings) lines.push(`${CARD_GUTTER}${" ".repeat(CARD_LABEL_PAD)}${p.dim(`· ${sanitizeData(f.file)}:${f.line} — ${sanitizeData(f.message)}`)}`);
|
|
9677
9786
|
lines.push("");
|
|
@@ -9683,7 +9792,7 @@ function appendFindings(lines, result, counts, verbose, p, ascii, width, tone) {
|
|
|
9683
9792
|
hiddenRules.add(unit.finding.ruleId);
|
|
9684
9793
|
continue;
|
|
9685
9794
|
}
|
|
9686
|
-
pushCard(lines, toCard(unit.finding, tone),
|
|
9795
|
+
pushCard(lines, toCard(unit.finding, tone), ui);
|
|
9687
9796
|
shown++;
|
|
9688
9797
|
}
|
|
9689
9798
|
if (hidden > 0) lines.push(` ${p.dim(`… +${hidden} more across ${hiddenRules.size} rule${hiddenRules.size === 1 ? "" : "s"}. Run with --verbose for all findings.`)}`);
|
|
@@ -9711,10 +9820,13 @@ function appendForgedBlock(lines, p, ascii) {
|
|
|
9711
9820
|
lines.push(p.forged(TROPHY));
|
|
9712
9821
|
lines.push("");
|
|
9713
9822
|
}
|
|
9714
|
-
function appendFooter(lines, result,
|
|
9715
|
-
|
|
9716
|
-
|
|
9717
|
-
|
|
9823
|
+
function appendFooter(lines, result, ui) {
|
|
9824
|
+
const { p } = ui;
|
|
9825
|
+
lines.push(...buildFooter({
|
|
9826
|
+
ui,
|
|
9827
|
+
complete: result.analysisStatus.discovery !== "partial",
|
|
9828
|
+
durationMs: result.analysisStatus.durationMs
|
|
9829
|
+
}));
|
|
9718
9830
|
const advisory = result.findings.filter((f) => (f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence)) === "E0").length;
|
|
9719
9831
|
if (advisory > 0) lines.push(p.dim(` ${advisory} advisory finding${advisory === 1 ? "" : "s"} (E0 — observation only, no score impact)`));
|
|
9720
9832
|
if (result.findings.length > 0) {
|
|
@@ -9738,7 +9850,8 @@ function appendFooter(lines, result, p) {
|
|
|
9738
9850
|
}
|
|
9739
9851
|
lines.push("");
|
|
9740
9852
|
}
|
|
9741
|
-
function renderNoTests(
|
|
9853
|
+
function renderNoTests(ui) {
|
|
9854
|
+
const { p, ascii } = ui;
|
|
9742
9855
|
const warnGlyph = ascii ? "!" : "⚠";
|
|
9743
9856
|
const searched = SEARCHED_FOR.map((e) => `${e.label}: ${e.globs.join(" ")}`);
|
|
9744
9857
|
return [
|
|
@@ -9756,7 +9869,7 @@ function renderNoTests(p, ascii) {
|
|
|
9756
9869
|
maxWidth: 78
|
|
9757
9870
|
}).map((l) => ` ${l}`),
|
|
9758
9871
|
"",
|
|
9759
|
-
"
|
|
9872
|
+
nextStep("mjolnir <path-to-your-tests>", ui),
|
|
9760
9873
|
""
|
|
9761
9874
|
].join("\n");
|
|
9762
9875
|
}
|
|
@@ -9802,7 +9915,7 @@ function renderSarif(result, repoRootUri) {
|
|
|
9802
9915
|
tool: { driver: {
|
|
9803
9916
|
name: "Mjölnir",
|
|
9804
9917
|
informationUri: "https://github.com/Sergey-Bar/Mjolnir",
|
|
9805
|
-
version: "0.5.
|
|
9918
|
+
version: "0.5.4",
|
|
9806
9919
|
rules: [...rules.values()].map((r) => {
|
|
9807
9920
|
const meta = RULES.find((x) => x.id === r.id);
|
|
9808
9921
|
return {
|
|
@@ -9945,97 +10058,1367 @@ function renderMermaid(result) {
|
|
|
9945
10058
|
return lines.join("\n");
|
|
9946
10059
|
}
|
|
9947
10060
|
//#endregion
|
|
9948
|
-
//#region src/
|
|
10061
|
+
//#region src/reporter/progress.ts
|
|
9949
10062
|
/**
|
|
9950
|
-
*
|
|
10063
|
+
* Live scan progress (Terminal + CI UX Overhaul plan, M3).
|
|
9951
10064
|
*
|
|
9952
|
-
*
|
|
9953
|
-
*
|
|
9954
|
-
*
|
|
9955
|
-
* full-file attribution), and detached HEAD.
|
|
10065
|
+
* Determinism model: render-on-event, NO wall-clock timer. Frames
|
|
10066
|
+
* advance and lines repaint only when `onProgress` fires — testable
|
|
10067
|
+
* with a fake stream, no fake timers, no flaky CI.
|
|
9956
10068
|
*
|
|
9957
|
-
*
|
|
9958
|
-
*
|
|
9959
|
-
*
|
|
9960
|
-
*
|
|
9961
|
-
*
|
|
9962
|
-
*
|
|
10069
|
+
* Stream discipline: every cursor-control ANSI sequence goes to the
|
|
10070
|
+
* injected stream only. The stream is gated by the caller (cli.ts
|
|
10071
|
+
* auto-disables on non-TTY stderr, --json/--format machine modes,
|
|
10072
|
+
* --no-progress, GITHUB_ACTIONS/CI env), so stdout purity and
|
|
10073
|
+
* byte-identical JSON are untouched by construction.
|
|
10074
|
+
*
|
|
10075
|
+
* Zero new dependencies: braille + ASCII spinner frames are inline
|
|
10076
|
+
* constants; erasure is plain `\r` + ESC[K.
|
|
9963
10077
|
*/
|
|
9964
|
-
|
|
9965
|
-
|
|
9966
|
-
|
|
9967
|
-
|
|
9968
|
-
|
|
9969
|
-
|
|
9970
|
-
|
|
9971
|
-
|
|
9972
|
-
|
|
9973
|
-
|
|
9974
|
-
|
|
9975
|
-
|
|
9976
|
-
|
|
9977
|
-
|
|
10078
|
+
/** Spinner frames: braille, ASCII fallback. */
|
|
10079
|
+
const BRAILLE_FRAMES = [..."⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"];
|
|
10080
|
+
const ASCII_FRAMES = [..."|/-\\"];
|
|
10081
|
+
/** True when a live progress renderer may write to the stream at all.
|
|
10082
|
+
* `env` is required: the caller owns the ambient-vs-test distinction
|
|
10083
|
+
* (cli.ts passes process.env; tests pass a controlled object). */
|
|
10084
|
+
function shouldRenderProgress(opts) {
|
|
10085
|
+
if (opts.noProgress) return false;
|
|
10086
|
+
if (opts.machineFormat) return false;
|
|
10087
|
+
if (!opts.isTTY) return false;
|
|
10088
|
+
if (opts.env["GITHUB_ACTIONS"] === "true" || opts.env["CI"] === "true") return false;
|
|
10089
|
+
return true;
|
|
10090
|
+
}
|
|
10091
|
+
/** Frame at `index`, defensive against an empty frames list. Exported
|
|
10092
|
+
* so the fallback arms stay pinned (renderProgressLine always passes a
|
|
10093
|
+
* non-empty constant list and an in-range index). */
|
|
10094
|
+
function pickFrame(frames, index) {
|
|
10095
|
+
return frames[index % frames.length] ?? frames[0] ?? " ";
|
|
10096
|
+
}
|
|
10097
|
+
const PHASE_LABELS = {
|
|
10098
|
+
discover: "Discovering files",
|
|
10099
|
+
parse: "Parsing frameworks",
|
|
10100
|
+
rules: "Running rules",
|
|
10101
|
+
score: "Scoring"
|
|
10102
|
+
};
|
|
10103
|
+
/** One rendered progress line for an event and frame index. */
|
|
10104
|
+
function renderProgressLine(event, frameIndex, opts = {}) {
|
|
10105
|
+
let line = `${pickFrame(opts.ascii ? ASCII_FRAMES : BRAILLE_FRAMES, frameIndex)} ${PHASE_LABELS[event.phase]}…`;
|
|
10106
|
+
const parts = [];
|
|
10107
|
+
if (event.total !== void 0) parts.push(`${event.done ?? 0}/${event.total}`);
|
|
10108
|
+
if (event.detail) parts.push(sanitizeData(event.detail));
|
|
10109
|
+
if (parts.length > 0) line += ` (${parts.join(" · ")})`;
|
|
10110
|
+
return line;
|
|
10111
|
+
}
|
|
10112
|
+
const ERASE_TAIL = "\x1B[K";
|
|
10113
|
+
/**
|
|
10114
|
+
* The event-driven progress renderer. All writes go to the injected
|
|
10115
|
+
* stream; the internal `lines` count tracks what must be erased when
|
|
10116
|
+
* the phase changes or the scan completes.
|
|
10117
|
+
*/
|
|
10118
|
+
var ProgressRenderer = class {
|
|
10119
|
+
frameIndex = 0;
|
|
10120
|
+
activeLines = 0;
|
|
10121
|
+
stream;
|
|
10122
|
+
ascii;
|
|
10123
|
+
enabled;
|
|
10124
|
+
constructor(opts) {
|
|
10125
|
+
this.stream = opts.stream;
|
|
10126
|
+
this.ascii = opts.ascii === true;
|
|
10127
|
+
this.enabled = opts.isTTY;
|
|
10128
|
+
}
|
|
10129
|
+
get active() {
|
|
10130
|
+
return this.enabled;
|
|
10131
|
+
}
|
|
10132
|
+
/** Test-visible frame index (render-on-event determinism). */
|
|
10133
|
+
get frame() {
|
|
10134
|
+
return this.frameIndex;
|
|
10135
|
+
}
|
|
10136
|
+
write(s) {
|
|
10137
|
+
this.stream.write(s);
|
|
10138
|
+
}
|
|
10139
|
+
erase() {
|
|
10140
|
+
if (this.activeLines > 0) {
|
|
10141
|
+
this.write(`\r\x1b[${this.activeLines}A${ERASE_TAIL}${"\n\x1B[K".repeat(Math.max(0, this.activeLines - 1))}\r`);
|
|
10142
|
+
this.activeLines = 0;
|
|
10143
|
+
}
|
|
10144
|
+
}
|
|
10145
|
+
/** Handle one onProgress event: erase, paint, advance the frame. */
|
|
10146
|
+
onEvent(event) {
|
|
10147
|
+
if (!this.enabled) return;
|
|
10148
|
+
this.erase();
|
|
10149
|
+
const line = renderProgressLine(event, this.frameIndex, { ascii: this.ascii });
|
|
10150
|
+
this.write(`${line}\n`);
|
|
10151
|
+
this.activeLines = 1;
|
|
10152
|
+
this.frameIndex++;
|
|
10153
|
+
}
|
|
10154
|
+
/** Scan finished (or formats are about to print): clear the line. */
|
|
10155
|
+
done() {
|
|
10156
|
+
if (!this.enabled) return;
|
|
10157
|
+
this.erase();
|
|
10158
|
+
}
|
|
10159
|
+
};
|
|
10160
|
+
//#endregion
|
|
10161
|
+
//#region src/reporter/github.ts
|
|
10162
|
+
/** Escape a workflow-command PROPERTY value. */
|
|
10163
|
+
function escapeAnnotationProperty(value) {
|
|
10164
|
+
return value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A").replaceAll(":", "%3A").replaceAll(",", "%2C");
|
|
10165
|
+
}
|
|
10166
|
+
/** Escape a workflow-command MESSAGE. */
|
|
10167
|
+
function escapeAnnotationMessage(value) {
|
|
10168
|
+
return value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
|
|
10169
|
+
}
|
|
10170
|
+
/** ANSI-injection guard for the summary path (belt + braces). */
|
|
10171
|
+
function stripAnsiForSummary(value) {
|
|
10172
|
+
return value.replace(/\x1b\[[0-9;:?]*[ -/]*[@-~]/g, "");
|
|
10173
|
+
}
|
|
10174
|
+
const SEVERITY_TO_COMMAND = {
|
|
10175
|
+
error: "error",
|
|
10176
|
+
warning: "warning",
|
|
10177
|
+
info: "notice"
|
|
10178
|
+
};
|
|
10179
|
+
/** Render ONE workflow-command annotation line (no trailing newline). */
|
|
10180
|
+
function renderAnnotation(a) {
|
|
10181
|
+
const props = [`file=${escapeAnnotationProperty(a.file)}`, `line=${Math.max(1, a.line ?? 1)}`];
|
|
10182
|
+
if (a.column !== void 0) props.push(`column=${Math.max(1, a.column)}`);
|
|
10183
|
+
props.push(`title=${escapeAnnotationProperty(a.title)}`);
|
|
10184
|
+
return `::${SEVERITY_TO_COMMAND[a.severity]} ${props.join(",")}::${escapeAnnotationMessage(a.message)}`;
|
|
10185
|
+
}
|
|
10186
|
+
/** Render annotations for every finding in a report's findings array. */
|
|
10187
|
+
function renderAnnotations(findings) {
|
|
10188
|
+
return findings.map((f) => {
|
|
10189
|
+
return renderAnnotation({
|
|
10190
|
+
severity: f.severity === "error" || f.severity === "warning" ? f.severity : "info",
|
|
10191
|
+
file: f.file,
|
|
10192
|
+
line: f.line,
|
|
10193
|
+
column: f.column,
|
|
10194
|
+
title: f.ruleId,
|
|
10195
|
+
message: f.message
|
|
9978
10196
|
});
|
|
9979
|
-
}
|
|
9980
|
-
|
|
10197
|
+
});
|
|
10198
|
+
}
|
|
10199
|
+
/** GitHub caps annotation messages — keep the head, point at the summary. */
|
|
10200
|
+
function truncateMessage(message, max = 250) {
|
|
10201
|
+
if (message.length <= max) return message;
|
|
10202
|
+
return `${message.slice(0, max)}… (full text in the step summary)`;
|
|
10203
|
+
}
|
|
10204
|
+
//#endregion
|
|
10205
|
+
//#region src/commands/pr-comment.ts
|
|
10206
|
+
const MARKER = "<!-- mjolnir-pr-comment -->";
|
|
10207
|
+
/**
|
|
10208
|
+
* Bug-audit QA-2026-08-30 QA-10: finding metadata rendered into the PR
|
|
10209
|
+
* comment body is untrusted (hostile filenames, plugin messages). A raw
|
|
10210
|
+
* `|` breaks the markdown layout, a raw backtick escapes the code span,
|
|
10211
|
+
* and `<\/script>`/link syntax could inject content into the PR page.
|
|
10212
|
+
* Escape markdown-significant characters and strip control/ANSI escapes.
|
|
10213
|
+
*/
|
|
10214
|
+
function escapeMarkdown(s) {
|
|
10215
|
+
return sanitizeData(s).replace(/([\\`*_{}[\]()#+!|<>])/g, "\\$1");
|
|
10216
|
+
}
|
|
10217
|
+
/** True when a fix recommendation reads as code rather than prose —
|
|
10218
|
+
* those render as a code span, the rest stay italic. */
|
|
10219
|
+
function looksLikeCode(s) {
|
|
10220
|
+
return /[(;={]|await |expect\(/.test(s);
|
|
10221
|
+
}
|
|
10222
|
+
/** Evidence tag per finding — Evidence > Assumption in the fabric: a
|
|
10223
|
+
* measured false-positive rate is shown right on the line when present. */
|
|
10224
|
+
function evidenceTag(f) {
|
|
10225
|
+
const level = f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence);
|
|
10226
|
+
let tag = `${level} · ${level === "E2" ? "deterministic" : level === "E1" ? "heuristic" : "observation"}`;
|
|
10227
|
+
if (f.measuredFpRate !== void 0) {
|
|
10228
|
+
tag += ` · measured FP ${Math.round(f.measuredFpRate * 100)}%`;
|
|
10229
|
+
if (f.measuredFpN !== void 0) tag += ` · n=${f.measuredFpN}`;
|
|
9981
10230
|
}
|
|
10231
|
+
return tag;
|
|
9982
10232
|
}
|
|
9983
|
-
|
|
9984
|
-
const
|
|
9985
|
-
|
|
9986
|
-
|
|
9987
|
-
|
|
9988
|
-
|
|
9989
|
-
|
|
9990
|
-
|
|
9991
|
-
|
|
9992
|
-
|
|
9993
|
-
|
|
9994
|
-
|
|
9995
|
-
|
|
9996
|
-
|
|
9997
|
-
|
|
9998
|
-
|
|
9999
|
-
|
|
10000
|
-
|
|
10233
|
+
function findingLine(f) {
|
|
10234
|
+
const icon = f.severity === "error" ? "🔴" : f.severity === "warning" ? "🟡" : "🔵";
|
|
10235
|
+
const fix = escapeMarkdown(f.fix);
|
|
10236
|
+
const fixBody = looksLikeCode(f.fix) ? `\`${fix}\`` : `_${fix}_`;
|
|
10237
|
+
return `${icon} **${escapeMarkdown(f.ruleId)}** \`${escapeMarkdown(f.file)}:${f.line}\` — ${escapeMarkdown(f.message)} ${escapeMarkdown(`[${evidenceTag(f)}]`)}\n Fix: ${fixBody}`;
|
|
10238
|
+
}
|
|
10239
|
+
/**
|
|
10240
|
+
* Render the full comment body (plan M5 redesign). Idempotent by
|
|
10241
|
+
* design — the leading HTML comment marker lets the posting workflow
|
|
10242
|
+
* find and update its own prior comment instead of spamming a new one
|
|
10243
|
+
* on every push.
|
|
10244
|
+
*/
|
|
10245
|
+
function renderPrComment(result, options = {}) {
|
|
10246
|
+
const lines = [MARKER, ""];
|
|
10247
|
+
lines.push("### 🔨 Mjölnir — Verification Trust");
|
|
10248
|
+
lines.push("");
|
|
10249
|
+
const diff = options.diff;
|
|
10250
|
+
const usingDiff = diff?.hasBaseline === true;
|
|
10251
|
+
const findings = usingDiff ? diff.newFindings : result.findings;
|
|
10252
|
+
if (usingDiff) lines.push(`Comparing against the stored baseline (commit \`${diff.baselineCommit ?? "unknown"}\`) — showing only what this PR changed.`);
|
|
10253
|
+
else if (result.scope === "changed") lines.push("Showing only findings on lines this PR changed.");
|
|
10254
|
+
else lines.push("No baseline was found — showing the full scan. Run `mjolnir baseline` on the base branch to scope future comments to just what changed.");
|
|
10255
|
+
lines.push("");
|
|
10256
|
+
if (result.score !== null) {
|
|
10257
|
+
const state = deriveScoreState(result.score);
|
|
10258
|
+
const verdict = verdictFor(result.score);
|
|
10259
|
+
const baseScore = diff?.baselineScore;
|
|
10260
|
+
if (baseScore !== void 0) {
|
|
10261
|
+
const delta = result.score - baseScore;
|
|
10262
|
+
const sign = delta > 0 ? "+" : "";
|
|
10263
|
+
const commit = diff?.baselineCommit?.slice(0, 7) || "unknown";
|
|
10264
|
+
lines.push(`**Score:** ${result.score}/100 (${sign}${delta} since baseline \`${commit}\`)`);
|
|
10265
|
+
} else lines.push(`**Score:** ${result.score}/100 · ${verdict} (${state.band})`);
|
|
10266
|
+
lines.push("");
|
|
10267
|
+
lines.push(`_${headlineFor(state, result.findings.length)}_`);
|
|
10268
|
+
lines.push("");
|
|
10001
10269
|
}
|
|
10002
|
-
|
|
10270
|
+
if (result.dimensions && result.dimensions.length > 0) {
|
|
10271
|
+
lines.push("| Category | Score |");
|
|
10272
|
+
lines.push("|---|---|");
|
|
10273
|
+
for (const d of result.dimensions) lines.push(`| ${escapeMarkdown(d.category)} | ${d.score}/100 |`);
|
|
10274
|
+
lines.push("");
|
|
10275
|
+
}
|
|
10276
|
+
if (findings.length === 0) lines.push("✅ No new issues found in this PR's changes.");
|
|
10277
|
+
else {
|
|
10278
|
+
const errors = findings.filter((f) => f.severity === "error");
|
|
10279
|
+
const warnings = findings.filter((f) => f.severity === "warning");
|
|
10280
|
+
const infos = findings.filter((f) => f.severity === "info");
|
|
10281
|
+
lines.push(`**${findings.length} new finding${findings.length === 1 ? "" : "s"}** (${errors.length} error, ${warnings.length} warning):`);
|
|
10282
|
+
lines.push("");
|
|
10283
|
+
const groups = [
|
|
10284
|
+
{
|
|
10285
|
+
icon: "🔴",
|
|
10286
|
+
label: "errors",
|
|
10287
|
+
list: errors,
|
|
10288
|
+
open: true
|
|
10289
|
+
},
|
|
10290
|
+
{
|
|
10291
|
+
icon: "🟡",
|
|
10292
|
+
label: "warnings",
|
|
10293
|
+
list: warnings,
|
|
10294
|
+
open: false
|
|
10295
|
+
},
|
|
10296
|
+
{
|
|
10297
|
+
icon: "🔵",
|
|
10298
|
+
label: "infos",
|
|
10299
|
+
list: infos,
|
|
10300
|
+
open: false
|
|
10301
|
+
}
|
|
10302
|
+
];
|
|
10303
|
+
let rendered = 0;
|
|
10304
|
+
for (const g of groups) {
|
|
10305
|
+
if (g.list.length === 0) continue;
|
|
10306
|
+
rendered += Math.min(g.list.length, 25);
|
|
10307
|
+
lines.push("<details" + (g.open ? " open" : "") + ">");
|
|
10308
|
+
lines.push(`<summary>${g.icon} ${g.list.length} ${g.label}</summary>`);
|
|
10309
|
+
lines.push("");
|
|
10310
|
+
for (const f of g.list.slice(0, 25)) lines.push(`- ${findingLine(f)}`);
|
|
10311
|
+
if (g.list.length > 25) {
|
|
10312
|
+
lines.push("");
|
|
10313
|
+
lines.push(`_...and ${g.list.length - 25} more ${g.label}. Run \`mjolnir\` locally for the full list._`);
|
|
10314
|
+
}
|
|
10315
|
+
lines.push("");
|
|
10316
|
+
lines.push("</details>");
|
|
10317
|
+
lines.push("");
|
|
10318
|
+
}
|
|
10319
|
+
const overflow = findings.length - rendered;
|
|
10320
|
+
if (overflow > 0) lines.push(`_...and ${overflow} more overall — run \`mjolnir\` locally (or \`--verbose\`) for the full list._`);
|
|
10321
|
+
}
|
|
10322
|
+
lines.push("");
|
|
10323
|
+
const version = options.version ? `@${options.version}` : "";
|
|
10324
|
+
lines.push("**What to run next:**");
|
|
10325
|
+
lines.push("");
|
|
10326
|
+
lines.push("```bash");
|
|
10327
|
+
lines.push(`npx mjolnir-qa${version} . # full scan + score`);
|
|
10328
|
+
lines.push(`npx mjolnir-qa${version} . --verbose # every finding, uncapped`);
|
|
10329
|
+
lines.push("```");
|
|
10330
|
+
lines.push("");
|
|
10331
|
+
lines.push(`_Advisory only — this comment never blocks merging. Generated by [Mjölnir](${options.repoUrl ?? "https://github.com/Sergey-Bar/Mjolnir"})._`);
|
|
10332
|
+
if (usingDiff && diff.resolvedFindings.length > 0) {
|
|
10333
|
+
lines.push("");
|
|
10334
|
+
lines.push(`✨ ${diff.resolvedFindings.length} pre-existing finding${diff.resolvedFindings.length === 1 ? "" : "s"} fixed in this PR.`);
|
|
10335
|
+
}
|
|
10336
|
+
return lines.join("\n");
|
|
10003
10337
|
}
|
|
10004
|
-
|
|
10005
|
-
|
|
10006
|
-
|
|
10007
|
-
|
|
10008
|
-
|
|
10009
|
-
|
|
10010
|
-
|
|
10011
|
-
|
|
10012
|
-
|
|
10338
|
+
//#endregion
|
|
10339
|
+
//#region src/commands/report-io.ts
|
|
10340
|
+
/**
|
|
10341
|
+
* Shared saved-report loading (agent-handoff plan §9.0).
|
|
10342
|
+
*
|
|
10343
|
+
* `summary`, `handoff` and `why` all consume a saved `--json` report.
|
|
10344
|
+
* One loader, one validation, one error shape — previously
|
|
10345
|
+
* validateReportJson was private to summary.ts; extracting it keeps
|
|
10346
|
+
* the three commands byte-identical in their loading behavior without
|
|
10347
|
+
* duplication. The exit-code mapping stays in the callers (they own
|
|
10348
|
+
* their io), but the error MESSAGES are identical because they come
|
|
10349
|
+
* from here.
|
|
10350
|
+
*/
|
|
10351
|
+
/** Human message for any thrown value — never "undefined"/"[object Object]". */
|
|
10352
|
+
function errorText(err) {
|
|
10353
|
+
if (err instanceof Error) return err.message;
|
|
10354
|
+
if (typeof err === "string") return err;
|
|
10355
|
+
if (typeof err === "object" && err !== null) return JSON.stringify(err);
|
|
10356
|
+
return String(err);
|
|
10357
|
+
}
|
|
10358
|
+
/**
|
|
10359
|
+
* Parse-and-validate a saved report. Throws Error with a
|
|
10360
|
+
* human-explanatory message on: invalid JSON, non-object document,
|
|
10361
|
+
* wrong schemaVersion, missing findings array.
|
|
10362
|
+
*/
|
|
10363
|
+
function validateReportJson(text) {
|
|
10364
|
+
let parsed;
|
|
10365
|
+
try {
|
|
10366
|
+
parsed = JSON.parse(text);
|
|
10367
|
+
} catch (err) {
|
|
10368
|
+
throw new Error(`not valid JSON (${errorText(err)})`, { cause: err });
|
|
10013
10369
|
}
|
|
10014
|
-
|
|
10370
|
+
if (typeof parsed !== "object" || parsed === null) throw new Error("the file is a JSON value but not an object");
|
|
10371
|
+
const doc = parsed;
|
|
10372
|
+
if (doc.schemaVersion !== 1) throw new Error(`unsupported schemaVersion ${JSON.stringify(doc.schemaVersion)} — expected 1`);
|
|
10373
|
+
if (!Array.isArray(doc.findings)) throw new Error("missing a \"findings\" array — is this a Mjölnir --json report?");
|
|
10374
|
+
return parsed;
|
|
10015
10375
|
}
|
|
10016
|
-
|
|
10017
|
-
|
|
10018
|
-
|
|
10019
|
-
|
|
10020
|
-
|
|
10376
|
+
/** Load + validate a saved report from disk. Throws on any problem. */
|
|
10377
|
+
function loadSavedReport(reportPath) {
|
|
10378
|
+
return validateReportJson(readFileSync(reportPath, "utf8"));
|
|
10379
|
+
}
|
|
10380
|
+
/** True when the report file exists (callers own the not-found message). */
|
|
10381
|
+
function reportExists(reportPath) {
|
|
10382
|
+
return existsSync(reportPath);
|
|
10383
|
+
}
|
|
10384
|
+
//#endregion
|
|
10385
|
+
//#region src/commands/summary.ts
|
|
10386
|
+
/**
|
|
10387
|
+
* `mjolnir summary [report.json]` — CI annotations + step summary
|
|
10388
|
+
* (Terminal + CI UX Overhaul plan, M4).
|
|
10389
|
+
*
|
|
10390
|
+
* Additive command; default report path `mjolnir.json`. Reads a saved
|
|
10391
|
+
* ScanResult JSON (the `--json` scan output) and emits:
|
|
10392
|
+
* 1. GitHub annotations to stdout, only when GITHUB_ACTIONS=true —
|
|
10393
|
+
* one per finding, via the single github.ts emitter.
|
|
10394
|
+
* 2. A step-summary markdown document to $GITHUB_STEP_SUMMARY when
|
|
10395
|
+
* set (else stdout; --stdout forces stdout): score + band, text
|
|
10396
|
+
* score bar, dimensions table, top deductions, collapsible
|
|
10397
|
+
* per-severity details with fix lines, and an honesty notice for
|
|
10398
|
+
* partial/no-score reports.
|
|
10399
|
+
*
|
|
10400
|
+
* Exit codes (frozen contract): 0 on success — this command NEVER
|
|
10401
|
+
* blocks; the gate step decides. 10 missing file argument. 2
|
|
10402
|
+
* unreadable/invalid JSON (a data problem; the message explains).
|
|
10403
|
+
*
|
|
10404
|
+
* Paths: findings are scan-target-relative. The generated workflow
|
|
10405
|
+
* scans the repo root, so the default output is directly usable;
|
|
10406
|
+
* `--path-prefix <dir>` re-scopes for subdirectory scans.
|
|
10407
|
+
*/
|
|
10408
|
+
const DETAILS_PER_SEVERITY_CAP = 25;
|
|
10409
|
+
function scoreBar$1(score, width = 20) {
|
|
10410
|
+
const filled = Math.round(score / 100 * width);
|
|
10411
|
+
return `${"█".repeat(filled)}${"░".repeat(Math.max(0, width - filled))}`;
|
|
10412
|
+
}
|
|
10413
|
+
/** Markdown step summary. Pure over (result, options) — testable. */
|
|
10414
|
+
function renderStepSummary(result, options = {}) {
|
|
10415
|
+
const lines = [];
|
|
10416
|
+
lines.push("### 🔨 Mjölnir — Verification Trust");
|
|
10417
|
+
lines.push("");
|
|
10418
|
+
if (result.score === null) {
|
|
10419
|
+
lines.push("Score: **not measurable** — no test files found (`reason: no-tests-found`).");
|
|
10420
|
+
lines.push("");
|
|
10421
|
+
lines.push("> No fake numbers: a repo without tests has no score to show.");
|
|
10422
|
+
} else {
|
|
10423
|
+
const state = deriveScoreState(result.score);
|
|
10424
|
+
const verdict = verdictFor(result.score);
|
|
10425
|
+
lines.push(`Score: **${result.score}/100** · ${verdict} (${state.band}) · ${headlineFor(state, result.findings.length)}`);
|
|
10426
|
+
lines.push("");
|
|
10427
|
+
lines.push("```text");
|
|
10428
|
+
lines.push(`${scoreBar$1(result.score)} ${result.score}/100`);
|
|
10429
|
+
lines.push("```");
|
|
10430
|
+
}
|
|
10431
|
+
lines.push("");
|
|
10432
|
+
if (result.frameworks.length > 0) {
|
|
10433
|
+
lines.push(`Detected: ${result.frameworks.map((f) => `\`${f}\``).join(" · ")}`);
|
|
10434
|
+
lines.push("");
|
|
10435
|
+
}
|
|
10436
|
+
if (result.dimensions.length > 0) {
|
|
10437
|
+
lines.push("| Category | Score |");
|
|
10438
|
+
lines.push("|----------|-------|");
|
|
10439
|
+
for (const d of result.dimensions) lines.push(`| ${escapeMarkdown(d.category)} | ${d.score}/100 |`);
|
|
10440
|
+
lines.push("");
|
|
10441
|
+
}
|
|
10442
|
+
if (result.rawDeductions !== void 0 && result.testDeclarationCount) {
|
|
10443
|
+
lines.push(`Transparency: ${result.rawDeductions} raw pts over ${result.testDeclarationCount} test declarations (normalized).`);
|
|
10444
|
+
lines.push("");
|
|
10445
|
+
}
|
|
10446
|
+
const bySeverity = {
|
|
10447
|
+
error: [],
|
|
10448
|
+
warning: [],
|
|
10449
|
+
info: []
|
|
10021
10450
|
};
|
|
10022
|
-
const
|
|
10023
|
-
|
|
10024
|
-
|
|
10025
|
-
|
|
10026
|
-
|
|
10451
|
+
for (const f of result.findings) bySeverity[f.severity].push(f);
|
|
10452
|
+
const icons = {
|
|
10453
|
+
error: "🔴",
|
|
10454
|
+
warning: "🟡",
|
|
10455
|
+
info: "🔵"
|
|
10027
10456
|
};
|
|
10028
|
-
const
|
|
10029
|
-
"
|
|
10030
|
-
"
|
|
10031
|
-
"
|
|
10032
|
-
|
|
10033
|
-
|
|
10034
|
-
|
|
10035
|
-
|
|
10036
|
-
|
|
10037
|
-
|
|
10038
|
-
|
|
10457
|
+
for (const sev of [
|
|
10458
|
+
"error",
|
|
10459
|
+
"warning",
|
|
10460
|
+
"info"
|
|
10461
|
+
]) {
|
|
10462
|
+
const list = bySeverity[sev];
|
|
10463
|
+
if (list.length === 0) continue;
|
|
10464
|
+
const open = sev === "error" ? " open" : "";
|
|
10465
|
+
lines.push(`<details${open}>`, `<summary>${icons[sev]} ${list.length} ${sev}${list.length === 1 ? "" : "s"}</summary>`, "");
|
|
10466
|
+
for (const f of list.slice(0, DETAILS_PER_SEVERITY_CAP)) {
|
|
10467
|
+
const path = options.pathPrefix ? `${options.pathPrefix.replace(/\/$/, "")}/${f.file}` : f.file;
|
|
10468
|
+
lines.push(`- **${escapeMarkdown(f.ruleId)}** \`${escapeMarkdown(path)}:${f.line}\` — ${escapeMarkdown(stripAnsiForSummary(f.message))}`);
|
|
10469
|
+
lines.push(` - Fix: ${escapeMarkdown(stripAnsiForSummary(f.fix))}`);
|
|
10470
|
+
}
|
|
10471
|
+
if (list.length > DETAILS_PER_SEVERITY_CAP) lines.push(`- … and ${list.length - DETAILS_PER_SEVERITY_CAP} more — see the full JSON artifact.`);
|
|
10472
|
+
lines.push("", "</details>", "");
|
|
10473
|
+
}
|
|
10474
|
+
if (result.findings.length === 0 && result.score !== null) {
|
|
10475
|
+
lines.push("Zero findings — nothing to fix.");
|
|
10476
|
+
lines.push("");
|
|
10477
|
+
}
|
|
10478
|
+
if (result.partial) {
|
|
10479
|
+
lines.push("> ⚠ Partial scan: the budget expired or files were skipped — verdict may be incomplete.");
|
|
10480
|
+
lines.push("");
|
|
10481
|
+
}
|
|
10482
|
+
lines.push("<!-- mjolnir-honesty: scores derive from rules with published evidence levels and measured false-positive rates where available. -->");
|
|
10483
|
+
return lines.join("\n");
|
|
10484
|
+
}
|
|
10485
|
+
/** Render one finding as an escaped annotation line. Fields are
|
|
10486
|
+
* sanitized with the same sanitizeData layer the terminal uses
|
|
10487
|
+
* (github.ts's escapers cover %/CR/LF, not OSC/C0), then %/CR/LF-escaped. */
|
|
10488
|
+
function annotationForFinding(f) {
|
|
10489
|
+
return renderAnnotations([{
|
|
10490
|
+
...f,
|
|
10491
|
+
file: sanitizeData(f.file),
|
|
10492
|
+
ruleId: sanitizeData(f.ruleId),
|
|
10493
|
+
message: truncateMessage(stripAnsiForSummary(sanitizeData(f.message)))
|
|
10494
|
+
}])[0];
|
|
10495
|
+
}
|
|
10496
|
+
const KNOWN_SUMMARY_FLAGS = /* @__PURE__ */ new Set([
|
|
10497
|
+
"--stdout",
|
|
10498
|
+
"--path-prefix",
|
|
10499
|
+
"--help",
|
|
10500
|
+
"-h"
|
|
10501
|
+
]);
|
|
10502
|
+
/**
|
|
10503
|
+
* Testable summary command core. Returns the process exit code.
|
|
10504
|
+
* Streams: annotations → stdout (always; GitHub greps them), summary →
|
|
10505
|
+
* step-summary file when set unless --stdout. Unknown flags are a
|
|
10506
|
+
* usage error (exit 10) — a typo'd --stdout must not silently route
|
|
10507
|
+
* the summary to $GITHUB_STEP_SUMMARY.
|
|
10508
|
+
*/
|
|
10509
|
+
function runSummaryCommand(argv, io = {
|
|
10510
|
+
out: (line) => console.log(line),
|
|
10511
|
+
err: (line) => console.error(line)
|
|
10512
|
+
}) {
|
|
10513
|
+
for (let i = 0; i < argv.length; i++) {
|
|
10514
|
+
const a = argv[i] ?? "";
|
|
10515
|
+
if (!a.startsWith("-")) continue;
|
|
10516
|
+
if (a === "--path-prefix") {
|
|
10517
|
+
const val = argv[i + 1];
|
|
10518
|
+
if (val === void 0 || val.startsWith("-")) {
|
|
10519
|
+
io.err(usageErrorMessage({
|
|
10520
|
+
flag: "--path-prefix",
|
|
10521
|
+
token: val
|
|
10522
|
+
}));
|
|
10523
|
+
return 10;
|
|
10524
|
+
}
|
|
10525
|
+
i++;
|
|
10526
|
+
continue;
|
|
10527
|
+
}
|
|
10528
|
+
if (KNOWN_SUMMARY_FLAGS.has(a)) continue;
|
|
10529
|
+
io.err(usageErrorMessage({ token: a }));
|
|
10530
|
+
return 10;
|
|
10531
|
+
}
|
|
10532
|
+
const stdout = argv.includes("--stdout");
|
|
10533
|
+
const prefixIdx = argv.indexOf("--path-prefix");
|
|
10534
|
+
const pathPrefix = prefixIdx !== -1 ? argv[prefixIdx + 1] : void 0;
|
|
10535
|
+
const reportPath = argv.filter((a, i) => a !== void 0 && !a.startsWith("-") && (prefixIdx === -1 || i !== prefixIdx + 1))[0] ?? "mjolnir.json";
|
|
10536
|
+
if (!existsSync(reportPath)) {
|
|
10537
|
+
io.err(`mjolnir summary: report file not found: ${reportPath}`);
|
|
10538
|
+
io.err(" Run the scan with --json first: mjolnir --json > mjolnir.json");
|
|
10539
|
+
return 10;
|
|
10540
|
+
}
|
|
10541
|
+
let result;
|
|
10542
|
+
try {
|
|
10543
|
+
result = loadSavedReport(reportPath);
|
|
10544
|
+
} catch (err) {
|
|
10545
|
+
io.err(`mjolnir summary: cannot read ${reportPath}: ${errorText(err)}`);
|
|
10546
|
+
return 2;
|
|
10547
|
+
}
|
|
10548
|
+
const env = process.env;
|
|
10549
|
+
if (env["GITHUB_ACTIONS"] === "true") for (const f of result.findings) {
|
|
10550
|
+
const file = pathPrefix ? `${pathPrefix.replace(/\/$/, "")}/${f.file}` : f.file;
|
|
10551
|
+
io.out(annotationForFinding({
|
|
10552
|
+
...f,
|
|
10553
|
+
file
|
|
10554
|
+
}));
|
|
10555
|
+
}
|
|
10556
|
+
const summary = renderStepSummary(result, {
|
|
10557
|
+
stdout,
|
|
10558
|
+
...pathPrefix !== void 0 ? { pathPrefix } : {}
|
|
10559
|
+
});
|
|
10560
|
+
const stepSummaryPath = stdout ? void 0 : env["GITHUB_STEP_SUMMARY"];
|
|
10561
|
+
if (stepSummaryPath) try {
|
|
10562
|
+
appendFileSync(stepSummaryPath, `${summary}\n`);
|
|
10563
|
+
} catch (err) {
|
|
10564
|
+
io.err(`mjolnir summary: could not write $GITHUB_STEP_SUMMARY (${errorText(err)}); printing to stdout instead.`);
|
|
10565
|
+
io.out(summary);
|
|
10566
|
+
}
|
|
10567
|
+
else io.out(summary);
|
|
10568
|
+
return 0;
|
|
10569
|
+
}
|
|
10570
|
+
//#endregion
|
|
10571
|
+
//#region src/commands/why.ts
|
|
10572
|
+
/**
|
|
10573
|
+
* `mjolnir why <file>:<line>` — occurrence-level evidence/explanation
|
|
10574
|
+
* query (agent-handoff plan M2).
|
|
10575
|
+
*
|
|
10576
|
+
* Role: an INFORMATIONAL query, not a gate. It works regardless of
|
|
10577
|
+
* verdict or tier, shows evidence tags, measured FP rates, runtime
|
|
10578
|
+
* corroboration, why/fix, and suppression guidance. Matching is exact
|
|
10579
|
+
* file + exact line — the occurrence location as reported, not a
|
|
10580
|
+
* durable identity (the fingerprint contract, baseline.ts, governs
|
|
10581
|
+
* before/after correlation instead).
|
|
10582
|
+
*
|
|
10583
|
+
* Two modes:
|
|
10584
|
+
* - `--json <mjolnir.json>`: the saved report is AUTHORITATIVE — the
|
|
10585
|
+
* query runs against exactly what the saved scan found (deterministic,
|
|
10586
|
+
* offline).
|
|
10587
|
+
* - live (default): runs a fresh scan of the target.
|
|
10588
|
+
*
|
|
10589
|
+
* Exit codes: 0 match · 1 no finding at that location · 10 usage ·
|
|
10590
|
+
* 2 invalid saved report · 20 crash.
|
|
10591
|
+
*/
|
|
10592
|
+
/** Exact file + exact line match over a finding list. Pure. */
|
|
10593
|
+
function explainAt(findings, file, line) {
|
|
10594
|
+
const normalized = file.replace(/\\/g, "/");
|
|
10595
|
+
return {
|
|
10596
|
+
findings: findings.filter((f) => f.file === normalized && f.line === line),
|
|
10597
|
+
file: normalized,
|
|
10598
|
+
line
|
|
10599
|
+
};
|
|
10600
|
+
}
|
|
10601
|
+
/** Parse the `<file>:<line>` positional (split at the LAST colon). */
|
|
10602
|
+
function parseFileLine(token) {
|
|
10603
|
+
const idx = token.lastIndexOf(":");
|
|
10604
|
+
if (idx === -1) return null;
|
|
10605
|
+
const file = token.slice(0, idx);
|
|
10606
|
+
const line = Number(token.slice(idx + 1));
|
|
10607
|
+
if (!file || !Number.isInteger(line) || line < 1) return null;
|
|
10608
|
+
return {
|
|
10609
|
+
file,
|
|
10610
|
+
line
|
|
10611
|
+
};
|
|
10612
|
+
}
|
|
10613
|
+
function evidenceLines(f, ui) {
|
|
10614
|
+
const lines = [];
|
|
10615
|
+
if (f.evidenceLevel !== void 0 || f.trustLevel !== void 0) {
|
|
10616
|
+
const parts = [];
|
|
10617
|
+
if (f.evidenceLevel !== void 0) parts.push(`evidence ${f.evidenceLevel}`);
|
|
10618
|
+
if (f.trustLevel !== void 0) parts.push(`trust ${f.trustLevel}`);
|
|
10619
|
+
lines.push(` Evidence: ${parts.join(" · ")}`);
|
|
10620
|
+
}
|
|
10621
|
+
if (f.measuredFpRate !== void 0) {
|
|
10622
|
+
const pct = Math.round(f.measuredFpRate * 100);
|
|
10623
|
+
const n = f.measuredFpN !== void 0 ? ` over ${f.measuredFpN} classified verdicts` : "";
|
|
10624
|
+
lines.push(` Measured FP rate: ${pct}%${n}`);
|
|
10625
|
+
} else lines.push(" Measured FP rate: none — this rule ships on assumption.");
|
|
10626
|
+
if (f.runtimeCorroboration !== void 0) {
|
|
10627
|
+
const c = f.runtimeCorroboration;
|
|
10628
|
+
const label = c.level === "defect" ? "defect corroborated by the run report" : c.level === "test" ? "the containing test executed in the run report" : "the containing file executed in the run report";
|
|
10629
|
+
lines.push(` Runtime corroboration: ${label} (${c.source})`);
|
|
10630
|
+
}
|
|
10631
|
+
return lines;
|
|
10632
|
+
}
|
|
10633
|
+
const SUPPRESSION_HINT = "Suppression (only with cause): an `ignore` entry in mjolnir.config.json — reason REQUIRED, expires after 90 days. Prefer fixing the root cause.";
|
|
10634
|
+
/** Render the why answer. Pure over (match, ui). */
|
|
10635
|
+
function renderWhy(match, ui = plainContext()) {
|
|
10636
|
+
const { p } = ui;
|
|
10637
|
+
if (match.findings.length === 0) return [
|
|
10638
|
+
sectionHeader(`WHY — ${match.file}:${match.line}`, ui),
|
|
10639
|
+
"",
|
|
10640
|
+
` No finding at ${match.file}:${match.line} in this report.`,
|
|
10641
|
+
"",
|
|
10642
|
+
p.dim(" Locations are exact (file + line as reported). If the code"),
|
|
10643
|
+
p.dim(" moved since the scan, re-run mjolnir to refresh locations."),
|
|
10644
|
+
""
|
|
10645
|
+
].join("\n");
|
|
10646
|
+
const lines = [
|
|
10647
|
+
sectionHeader(`WHY — ${match.file}:${match.line}`, ui),
|
|
10648
|
+
"",
|
|
10649
|
+
`${match.findings.length} finding${match.findings.length === 1 ? "" : "s"} at this location:`,
|
|
10650
|
+
""
|
|
10651
|
+
];
|
|
10652
|
+
for (const f of match.findings) {
|
|
10653
|
+
lines.push(` ${severityIcon(f.severity, ui)} ${p.bold(escapeMarkdown(f.ruleId))} — ${escapeMarkdown(f.message)}`);
|
|
10654
|
+
lines.push(` Why it matters: ${escapeMarkdown(f.why)}`);
|
|
10655
|
+
lines.push(` Fix: ${escapeMarkdown(f.fix)}`);
|
|
10656
|
+
for (const line of evidenceLines(f, ui)) lines.push(line);
|
|
10657
|
+
lines.push(SUPPRESSION_HINT);
|
|
10658
|
+
lines.push("");
|
|
10659
|
+
}
|
|
10660
|
+
lines.push(nextStep("mjolnir explain <RULE-ID>", ui) + " — full rule context.");
|
|
10661
|
+
return lines.join("\n");
|
|
10662
|
+
}
|
|
10663
|
+
/**
|
|
10664
|
+
* Testable why command core. Returns the process exit code.
|
|
10665
|
+
* Live mode awaits the real scan; saved-report mode is synchronous.
|
|
10666
|
+
*/
|
|
10667
|
+
async function runWhyCommand(argv, io = {
|
|
10668
|
+
out: (line) => console.log(line),
|
|
10669
|
+
err: (line) => console.error(line)
|
|
10670
|
+
}) {
|
|
10671
|
+
const locationToken = argv.find((a) => !a.startsWith("-"));
|
|
10672
|
+
if (!locationToken) {
|
|
10673
|
+
io.err("Usage: mjolnir why <file>:<line> [--json <mjolnir.json>]");
|
|
10674
|
+
return 10;
|
|
10675
|
+
}
|
|
10676
|
+
const location = parseFileLine(locationToken);
|
|
10677
|
+
if (!location) {
|
|
10678
|
+
io.err(`mjolnir why: cannot parse location "${locationToken}" — expected <file>:<line>`);
|
|
10679
|
+
return 10;
|
|
10680
|
+
}
|
|
10681
|
+
const jsonIdx = argv.indexOf("--json");
|
|
10682
|
+
const reportPath = jsonIdx !== -1 ? argv[jsonIdx + 1] : void 0;
|
|
10683
|
+
const targetIdx = argv.findIndex((a, i) => !a.startsWith("-") && i !== 0 && (jsonIdx === -1 || i !== jsonIdx + 1));
|
|
10684
|
+
const target = targetIdx !== -1 ? argv[targetIdx] : ".";
|
|
10685
|
+
let result;
|
|
10686
|
+
if (reportPath !== void 0) {
|
|
10687
|
+
if (!reportExists(reportPath)) {
|
|
10688
|
+
io.err(`mjolnir why: report file not found: ${reportPath}`);
|
|
10689
|
+
io.err(" Run the scan with --json first: mjolnir --json > mjolnir.json");
|
|
10690
|
+
return 10;
|
|
10691
|
+
}
|
|
10692
|
+
try {
|
|
10693
|
+
result = loadSavedReport(reportPath);
|
|
10694
|
+
} catch (err) {
|
|
10695
|
+
io.err(`mjolnir why: cannot read ${reportPath}: ${errorText(err)}`);
|
|
10696
|
+
return 2;
|
|
10697
|
+
}
|
|
10698
|
+
} else {
|
|
10699
|
+
if (!existsSync(target)) {
|
|
10700
|
+
io.err(`mjolnir why: scan target does not exist: ${target}`);
|
|
10701
|
+
return 10;
|
|
10702
|
+
}
|
|
10703
|
+
try {
|
|
10704
|
+
result = await runScan({
|
|
10705
|
+
target,
|
|
10706
|
+
json: false,
|
|
10707
|
+
verbose: false,
|
|
10708
|
+
maxDurationMs: Number.POSITIVE_INFINITY,
|
|
10709
|
+
scopeChanged: false,
|
|
10710
|
+
format: "terminal",
|
|
10711
|
+
strict: argv.includes("--strict")
|
|
10712
|
+
});
|
|
10713
|
+
} catch (err) {
|
|
10714
|
+
io.err(`mjolnir why: scan failed: ${errorText(err)}`);
|
|
10715
|
+
return 20;
|
|
10716
|
+
}
|
|
10717
|
+
}
|
|
10718
|
+
const match = explainAt(result.findings, location.file, location.line);
|
|
10719
|
+
const catIdxs = [];
|
|
10720
|
+
argv.forEach((a, i) => {
|
|
10721
|
+
if (a === "--category") catIdxs.push(i + 1);
|
|
10722
|
+
});
|
|
10723
|
+
const categories = catIdxs.map((i) => argv[i]).filter((c) => c !== void 0);
|
|
10724
|
+
const filtered = categories.length > 0 ? match.findings.filter((f) => categories.includes(f.category)) : match.findings;
|
|
10725
|
+
io.out(renderWhy({
|
|
10726
|
+
...match,
|
|
10727
|
+
findings: filtered
|
|
10728
|
+
}));
|
|
10729
|
+
return filtered.length > 0 ? 0 : 1;
|
|
10730
|
+
}
|
|
10731
|
+
//#endregion
|
|
10732
|
+
//#region src/commands/handoff.ts
|
|
10733
|
+
const OCCURRENCE_CAP = 25;
|
|
10734
|
+
const EVIDENCE_BOUNDARY = {
|
|
10735
|
+
E2: "The detector's evidence is deterministic for this pattern. Check the location, then apply the prescribed fix — the finding is a deterministic defect at its boundary.",
|
|
10736
|
+
E1: "REQUIRES CONFIRMATION before editing. This is a heuristic finding — the detector's observation alone does not prove the defect. Establish the runtime/product context first, and treat the fix as a hypothesis to validate.",
|
|
10737
|
+
E0: "Observation only — informational by definition. This finding can never deduct points or gate CI. Decide whether it matters in context; do NOT 'fix' it blindly."
|
|
10738
|
+
};
|
|
10739
|
+
function evidenceBoundary(f) {
|
|
10740
|
+
const level = f.evidenceLevel ?? "E1";
|
|
10741
|
+
const base = EVIDENCE_BOUNDARY[level];
|
|
10742
|
+
if (f.runtimeCorroboration !== void 0) return `${base}\n${f.runtimeCorroboration.level === "defect" ? "Runtime corroboration: the run report directly corroborates this defect." : f.runtimeCorroboration.level === "test" ? "Runtime corroboration: the containing test executed in the run report." : "Runtime corroboration: the containing file executed in the run report."}`;
|
|
10743
|
+
return base;
|
|
10744
|
+
}
|
|
10745
|
+
function fpLine(f) {
|
|
10746
|
+
if (f.measuredFpRate === void 0) return "Measured FP rate: none — this rule ships on assumption (no measured false-positive rate).";
|
|
10747
|
+
return `Measured FP rate: ${Math.round(f.measuredFpRate * 100)}%${f.measuredFpN !== void 0 ? ` over ${f.measuredFpN} classified verdicts` : ""}.`;
|
|
10748
|
+
}
|
|
10749
|
+
function scoreBar(score, width = 20) {
|
|
10750
|
+
const filled = Math.round(score / 100 * width);
|
|
10751
|
+
return `${"█".repeat(filled)}${"░".repeat(Math.max(0, width - filled))}`;
|
|
10752
|
+
}
|
|
10753
|
+
function scopeNote(options) {
|
|
10754
|
+
const parts = [];
|
|
10755
|
+
if (options.categories && options.categories.length > 0) parts.push(`categories: ${options.categories.join(", ")}`);
|
|
10756
|
+
if (options.rules && options.rules.length > 0) parts.push(`rules: ${options.rules.join(", ")}`);
|
|
10757
|
+
return parts.length > 0 ? ` (filtered — ${parts.join("; ")})` : "";
|
|
10758
|
+
}
|
|
10759
|
+
/**
|
|
10760
|
+
* The verification procedure block — the Trust contract rendered into
|
|
10761
|
+
* every artifact (plan §5.3). `version` is pinned at render time.
|
|
10762
|
+
*/
|
|
10763
|
+
function verificationBlock(version) {
|
|
10764
|
+
return [
|
|
10765
|
+
"## Verification procedure",
|
|
10766
|
+
"",
|
|
10767
|
+
"1. Before editing (recommended): `mjolnir baseline` captures the pre-fix report.",
|
|
10768
|
+
"2. After the fixes: `npx mjolnir-qa@" + version + " . --scope changed` re-verifies the targeted surface.",
|
|
10769
|
+
"3. Correlate before/after by finding fingerprint (ruleId + file + message — line numbers are occurrence locations, not identity; edits may move lines).",
|
|
10770
|
+
"",
|
|
10771
|
+
"Outcomes:",
|
|
10772
|
+
"",
|
|
10773
|
+
"- **TARGET_RESOLVED** — every fingerprint in this document is absent from the post-fix scan.",
|
|
10774
|
+
"- **TARGET_REMAINS** — at least one target fingerprint is still present. Report it honestly; do not suppress to clear the report.",
|
|
10775
|
+
"- **NEW_FINDINGS_INTRODUCED** — the post-fix scan contains fingerprints absent from the pre-fix report. Report them; do not silently accept them.",
|
|
10776
|
+
"- **VERIFICATION_NOT_RUN** — no post-fix scan, or a partial scan. Never claim a fix is verified without it.",
|
|
10777
|
+
"",
|
|
10778
|
+
"> ⚠ A clean `--scope changed` run re-verifies the targeted changed-scope remediation. It is NOT a statement that the entire repository is clean — run a full scan for that.",
|
|
10779
|
+
"",
|
|
10780
|
+
"Report files changed. Report checks not run. Report unresolved findings honestly."
|
|
10781
|
+
];
|
|
10782
|
+
}
|
|
10783
|
+
/** The per-rule remediation copy block (fenced, self-contained). */
|
|
10784
|
+
function ruleCopyBlock(group, version) {
|
|
10785
|
+
const f = group.findings[0];
|
|
10786
|
+
const occurrences = group.findings.slice(0, OCCURRENCE_CAP).map((x) => `- \`${escapeMarkdown(x.file)}:${x.line}\``).join("\n");
|
|
10787
|
+
const overflow = group.findings.length > OCCURRENCE_CAP ? `\n- … and ${group.findings.length - OCCURRENCE_CAP} more — see the JSON report.` : "";
|
|
10788
|
+
return [
|
|
10789
|
+
"```text",
|
|
10790
|
+
`Remediation task: ${escapeMarkdown(group.ruleId)} (fix group: ${escapeMarkdown(group.fixGroupId ?? group.ruleId)})`,
|
|
10791
|
+
"",
|
|
10792
|
+
`What was detected: ${escapeMarkdown(f.message)}`,
|
|
10793
|
+
"",
|
|
10794
|
+
`Evidence boundary: ${evidenceBoundary(f).replace(/\n/g, " ")}`,
|
|
10795
|
+
escapeMarkdown(fpLine(f)),
|
|
10796
|
+
"",
|
|
10797
|
+
`What should change: ${escapeMarkdown(f.fix)}`,
|
|
10798
|
+
"",
|
|
10799
|
+
"Constraints:",
|
|
10800
|
+
"- Make the smallest behavior-preserving change that fixes the root cause.",
|
|
10801
|
+
"- Preserve public interfaces, failure semantics, and repository conventions.",
|
|
10802
|
+
"- Adapt identifiers and framework details instead of copying blindly.",
|
|
10803
|
+
"- Do NOT disable the rule or suppress matching code merely to obtain a green scan.",
|
|
10804
|
+
"",
|
|
10805
|
+
"Occurrences (validate each):",
|
|
10806
|
+
occurrences,
|
|
10807
|
+
overflow,
|
|
10808
|
+
"",
|
|
10809
|
+
"Verification:",
|
|
10810
|
+
` npx mjolnir-qa@${version} . --scope changed`,
|
|
10811
|
+
"",
|
|
10812
|
+
"Expected verification behavior: the fingerprint (ruleId + file + message) of every occurrence above disappears from the post-fix scan, and no NEW fingerprint appears.",
|
|
10813
|
+
"",
|
|
10814
|
+
"Report files changed. Report checks not run. Report unresolved findings honestly.",
|
|
10815
|
+
"```"
|
|
10816
|
+
].join("\n");
|
|
10817
|
+
}
|
|
10818
|
+
/** The full deterministic handoff artifact. Pure over (result, options). */
|
|
10819
|
+
function renderHandoff(result, options = {}, version = CLI_VERSION) {
|
|
10820
|
+
const lines = [];
|
|
10821
|
+
lines.push("### 🔨 Mjölnir — Fix Handoff");
|
|
10822
|
+
lines.push("");
|
|
10823
|
+
if (result.score !== null) {
|
|
10824
|
+
const state = deriveScoreState(result.score);
|
|
10825
|
+
const verdict = verdictFor(result.score);
|
|
10826
|
+
lines.push(`Score: **${result.score}/100** · ${verdict} (${state.band}) · ${headlineFor(state, result.findings.length)}`);
|
|
10827
|
+
lines.push("");
|
|
10828
|
+
lines.push("```text");
|
|
10829
|
+
lines.push(`${scoreBar(result.score)} ${result.score}/100`);
|
|
10830
|
+
lines.push("```");
|
|
10831
|
+
} else lines.push("Score: **not measurable** — no test files found (`reason: no-tests-found`).");
|
|
10832
|
+
lines.push("");
|
|
10833
|
+
if (result.partial) {
|
|
10834
|
+
lines.push("> ⚠ Partial scan: the budget expired or files were skipped — the finding list may be incomplete. Treat VERIFICATION claims accordingly.");
|
|
10835
|
+
lines.push("");
|
|
10836
|
+
}
|
|
10837
|
+
if (result.findings.length === 0) {
|
|
10838
|
+
lines.push("Zero findings — nothing to fix.");
|
|
10839
|
+
lines.push("");
|
|
10840
|
+
lines.push("No remediation prompt is included. Do not modify the repository on the basis of this document.");
|
|
10841
|
+
lines.push("");
|
|
10842
|
+
if (result.partial) {
|
|
10843
|
+
lines.push("> The scan was partial — the absence of findings is only as trustworthy as the scan's coverage.");
|
|
10844
|
+
lines.push("");
|
|
10845
|
+
}
|
|
10846
|
+
return lines.join("\n");
|
|
10847
|
+
}
|
|
10848
|
+
lines.push(`This document turns ${result.findings.length} finding${result.findings.length === 1 ? "" : "s"} into a remediation plan. Work top-down (errors before warnings); validate each occurrence according to its evidence level before editing.`);
|
|
10849
|
+
lines.push("");
|
|
10850
|
+
const groups = /* @__PURE__ */ new Map();
|
|
10851
|
+
for (const f of result.findings) {
|
|
10852
|
+
const gid = f.fixGroupId ?? f.ruleId;
|
|
10853
|
+
const g = groups.get(gid);
|
|
10854
|
+
if (g) g.findings.push(f);
|
|
10855
|
+
else groups.set(gid, {
|
|
10856
|
+
ruleId: f.ruleId,
|
|
10857
|
+
fixGroupId: gid,
|
|
10858
|
+
findings: [f]
|
|
10859
|
+
});
|
|
10860
|
+
}
|
|
10861
|
+
const ruleFilter = options.rules && options.rules.length > 0 ? new Set(options.rules) : void 0;
|
|
10862
|
+
const catFilter = options.categories && options.categories.length > 0 ? new Set(options.categories) : void 0;
|
|
10863
|
+
const selected = [...groups.values()].filter((g) => !ruleFilter || ruleFilter.has(g.ruleId)).map((g) => ({
|
|
10864
|
+
...g,
|
|
10865
|
+
findings: catFilter ? g.findings.filter((f) => catFilter.has(f.category)) : g.findings
|
|
10866
|
+
})).filter((g) => g.findings.length > 0);
|
|
10867
|
+
if (selected.length === 0) {
|
|
10868
|
+
lines.push("No findings match the requested filters — no remediation prompt is included.");
|
|
10869
|
+
lines.push("");
|
|
10870
|
+
return lines.join("\n");
|
|
10871
|
+
}
|
|
10872
|
+
const sevOrder = {
|
|
10873
|
+
error: 0,
|
|
10874
|
+
warning: 1,
|
|
10875
|
+
info: 2
|
|
10876
|
+
};
|
|
10877
|
+
const sortable = selected;
|
|
10878
|
+
sortable.sort((a, b) => {
|
|
10879
|
+
const sa = sevOrder[a.findings[0].severity];
|
|
10880
|
+
const sb = sevOrder[b.findings[0].severity];
|
|
10881
|
+
if (sa !== sb) return sa - sb;
|
|
10882
|
+
if (b.findings.length !== a.findings.length) return b.findings.length - a.findings.length;
|
|
10883
|
+
return a.ruleId < b.ruleId ? -1 : 1;
|
|
10884
|
+
});
|
|
10885
|
+
lines.push("## How to use this document");
|
|
10886
|
+
lines.push("");
|
|
10887
|
+
lines.push("- Validate each occurrence according to its evidence level before editing.", "- Apply the smallest behavior-preserving fix.", "- Re-run the verification procedure below; correlate by fingerprint.", "- Never suppress a finding merely to obtain a green scan (suppressions live in `mjolnir.config.json`, require a reason, and expire after 90 days).");
|
|
10888
|
+
lines.push("");
|
|
10889
|
+
lines.push(scopeNote(options) ? scopeNote(options).trim() : "");
|
|
10890
|
+
if (!scopeNote(options)) lines.pop();
|
|
10891
|
+
lines.push("");
|
|
10892
|
+
for (const g of sortable) {
|
|
10893
|
+
const first = g.findings[0];
|
|
10894
|
+
lines.push(`### ${escapeMarkdown(g.ruleId)} — ${first.severity} × ${g.findings.length} (fix group: ${escapeMarkdown(g.fixGroupId)})`);
|
|
10895
|
+
lines.push("");
|
|
10896
|
+
lines.push(`**What is wrong:** ${escapeMarkdown(first.message)}`);
|
|
10897
|
+
lines.push("");
|
|
10898
|
+
lines.push(`**Why Mjölnir believes it:** ${escapeMarkdown(first.why)}`);
|
|
10899
|
+
lines.push("");
|
|
10900
|
+
lines.push(`**How trustworthy (evidence boundary):**`);
|
|
10901
|
+
lines.push("");
|
|
10902
|
+
lines.push(evidenceBoundary(first));
|
|
10903
|
+
lines.push("");
|
|
10904
|
+
lines.push(escapeMarkdown(fpLine(first)));
|
|
10905
|
+
lines.push("");
|
|
10906
|
+
lines.push(`**What should change:** ${escapeMarkdown(first.fix)}`);
|
|
10907
|
+
lines.push("");
|
|
10908
|
+
lines.push("**What must NOT change:** behavior unrelated to this finding — public interfaces, failure semantics, accessibility, and repository conventions stay intact.");
|
|
10909
|
+
lines.push("");
|
|
10910
|
+
lines.push(`**Occurrences (${g.findings.length}):**`);
|
|
10911
|
+
lines.push("");
|
|
10912
|
+
for (const f of g.findings.slice(0, OCCURRENCE_CAP)) lines.push(`- \`${escapeMarkdown(f.file)}:${f.line}\` — ${escapeMarkdown(f.message)}`);
|
|
10913
|
+
if (g.findings.length > OCCURRENCE_CAP) lines.push(`- … and ${g.findings.length - OCCURRENCE_CAP} more — see the JSON report.`);
|
|
10914
|
+
lines.push("");
|
|
10915
|
+
lines.push(ruleCopyBlock(g, version));
|
|
10916
|
+
lines.push("");
|
|
10917
|
+
}
|
|
10918
|
+
lines.push("## One-shot handoff prompt");
|
|
10919
|
+
lines.push("");
|
|
10920
|
+
lines.push("```text");
|
|
10921
|
+
lines.push(`Work through the ${selected.length} remediation group(s) above IN ORDER. For each:`);
|
|
10922
|
+
lines.push("- Validate the occurrences according to that group's evidence boundary (E2 = deterministic, act after a location check; E1/E0 = confirm in context first, never assume the observation alone proves the defect).");
|
|
10923
|
+
lines.push("- Apply the smallest behavior-preserving fix from the group's instruction.");
|
|
10924
|
+
lines.push("- Do NOT suppress findings merely to make the scan green.");
|
|
10925
|
+
lines.push(`- Re-run: npx mjolnir-qa@${version} . --scope changed`);
|
|
10926
|
+
lines.push("- Correlate before/after by fingerprint (ruleId + file + message).");
|
|
10927
|
+
lines.push("- Report: files changed, checks not run, and any findings that remain (TARGET_REMAINS) or newly appeared (NEW_FINDINGS_INTRODUCED).");
|
|
10928
|
+
lines.push("- Stop and ask the user when an evidence boundary cannot be resolved.");
|
|
10929
|
+
lines.push("```");
|
|
10930
|
+
lines.push("");
|
|
10931
|
+
lines.push(...verificationBlock(version));
|
|
10932
|
+
lines.push("");
|
|
10933
|
+
lines.push(`_Generated by [Mjölnir](https://github.com/Sergey-Bar/Mjolnir) — evidence and verification; the agent remains responsible for every change._`);
|
|
10934
|
+
return lines.join("\n");
|
|
10935
|
+
}
|
|
10936
|
+
const KNOWN_FLAGS = /* @__PURE__ */ new Set([
|
|
10937
|
+
"--category",
|
|
10938
|
+
"--rules",
|
|
10939
|
+
"--help",
|
|
10940
|
+
"-h"
|
|
10941
|
+
]);
|
|
10942
|
+
/**
|
|
10943
|
+
* Testable handoff command core. Returns the process exit code.
|
|
10944
|
+
* `mjolnir handoff [mjolnir.json] [--category <cat>]... [--rules <ids>]`
|
|
10945
|
+
*/
|
|
10946
|
+
function runHandoffCommand(argv, io = {
|
|
10947
|
+
out: (line) => console.log(line),
|
|
10948
|
+
err: (line) => console.error(line)
|
|
10949
|
+
}) {
|
|
10950
|
+
for (let i = 0; i < argv.length; i++) {
|
|
10951
|
+
const a = argv[i];
|
|
10952
|
+
if (!a.startsWith("-")) continue;
|
|
10953
|
+
if (a === "--category" || a === "--rules") {
|
|
10954
|
+
const val = argv[i + 1];
|
|
10955
|
+
if (val === void 0 || val.startsWith("-")) {
|
|
10956
|
+
io.err(usageErrorMessage({
|
|
10957
|
+
flag: a,
|
|
10958
|
+
token: val
|
|
10959
|
+
}));
|
|
10960
|
+
return 10;
|
|
10961
|
+
}
|
|
10962
|
+
i++;
|
|
10963
|
+
continue;
|
|
10964
|
+
}
|
|
10965
|
+
if (KNOWN_FLAGS.has(a)) continue;
|
|
10966
|
+
io.err(usageErrorMessage({ token: a }));
|
|
10967
|
+
return 10;
|
|
10968
|
+
}
|
|
10969
|
+
const positional = [];
|
|
10970
|
+
const categories = [];
|
|
10971
|
+
let rules;
|
|
10972
|
+
for (let i = 0; i < argv.length; i++) {
|
|
10973
|
+
const a = argv[i];
|
|
10974
|
+
if (a === "--category") {
|
|
10975
|
+
categories.push(argv[i + 1]);
|
|
10976
|
+
i++;
|
|
10977
|
+
continue;
|
|
10978
|
+
}
|
|
10979
|
+
if (a === "--rules") {
|
|
10980
|
+
rules = argv[i + 1].split(",").map((r) => r.trim()).filter((r) => r.length > 0);
|
|
10981
|
+
i++;
|
|
10982
|
+
continue;
|
|
10983
|
+
}
|
|
10984
|
+
if (!a.startsWith("-")) positional.push(a);
|
|
10985
|
+
}
|
|
10986
|
+
const reportPath = positional[0] ?? "mjolnir.json";
|
|
10987
|
+
if (!reportExists(reportPath)) {
|
|
10988
|
+
io.err(`mjolnir handoff: report file not found: ${reportPath}`);
|
|
10989
|
+
io.err(" Run the scan with --json first: mjolnir --json > mjolnir.json");
|
|
10990
|
+
return 10;
|
|
10991
|
+
}
|
|
10992
|
+
let result;
|
|
10993
|
+
try {
|
|
10994
|
+
result = loadSavedReport(reportPath);
|
|
10995
|
+
} catch (err) {
|
|
10996
|
+
io.err(`mjolnir handoff: cannot read ${reportPath}: ${errorText(err)}`);
|
|
10997
|
+
return 2;
|
|
10998
|
+
}
|
|
10999
|
+
io.out(renderHandoff(result, {
|
|
11000
|
+
categories,
|
|
11001
|
+
...rules ? { rules } : {}
|
|
11002
|
+
}));
|
|
11003
|
+
return 0;
|
|
11004
|
+
}
|
|
11005
|
+
//#endregion
|
|
11006
|
+
//#region src/commands/install-agents.ts
|
|
11007
|
+
/**
|
|
11008
|
+
* `mjolnir install` — instruction-surface installer (agent-handoff
|
|
11009
|
+
* plan M4).
|
|
11010
|
+
*
|
|
11011
|
+
* Directory probes detect INSTRUCTION SURFACES, not agents: the probe
|
|
11012
|
+
* is a deterministic, offline existence check. All detected surfaces
|
|
11013
|
+
* are written; none take precedence over another.
|
|
11014
|
+
*
|
|
11015
|
+
* Safety contract (plan §17):
|
|
11016
|
+
* - Writes are planned by a pure `planInstall()` and executed by
|
|
11017
|
+
* `executeInstall()`; `--dry-run` never executes.
|
|
11018
|
+
* - A target file that exists WITHOUT a Mjölnir marker is user-owned →
|
|
11019
|
+
* refusal (exit 10). `--force` overwrites ONLY marker-identified
|
|
11020
|
+
* Mjölnir-owned files.
|
|
11021
|
+
* - Re-running rewrites Mjölnir-owned regions idempotently; identical
|
|
11022
|
+
* content → no write at all.
|
|
11023
|
+
* - Content is deterministic (no timestamps) and version-pinned to the
|
|
11024
|
+
* running CLI_VERSION — never @latest.
|
|
11025
|
+
*
|
|
11026
|
+
* Exit codes: 0 installed/updated/nothing-detected (honest message) ·
|
|
11027
|
+
* 10 refusal/usage · 20 crash.
|
|
11028
|
+
*/
|
|
11029
|
+
const MARKER_OPEN = "<!-- mjolnir:managed";
|
|
11030
|
+
const MARKER_CLOSE = "<!-- /mjolnir:managed -->";
|
|
11031
|
+
function agentBrief(surface, cwdNote) {
|
|
11032
|
+
return [
|
|
11033
|
+
`# Mjölnir — verification trust loop (${surface})`,
|
|
11034
|
+
"",
|
|
11035
|
+
`${MARKER_OPEN} v${CLI_VERSION} -->`,
|
|
11036
|
+
"",
|
|
11037
|
+
`Run \`${cwdNote}npx mjolnir-qa@${CLI_VERSION} . --scope changed\` before finishing any task that touched test code.`,
|
|
11038
|
+
"",
|
|
11039
|
+
"- Exit 0 = clean. Exit 1 = new findings on changed lines: fix them (smallest behavior-preserving change) or justify them to the user.",
|
|
11040
|
+
`- npx mjolnir-qa@${CLI_VERSION} why <file>:<line> explains any finding (evidence level, measured FP rate, fix).`,
|
|
11041
|
+
`- npx mjolnir-qa@${CLI_VERSION} handoff renders a full remediation plan from a saved --json report.`,
|
|
11042
|
+
"- NEVER suppress a finding merely to obtain a green scan (suppressions live in mjolnir.config.json, require a reason, and expire after 90 days).",
|
|
11043
|
+
"- After fixing: re-run the scan, report files changed, report checks not run, report unresolved findings honestly.",
|
|
11044
|
+
"",
|
|
11045
|
+
`<!-- /mjolnir:managed -->`,
|
|
11046
|
+
""
|
|
11047
|
+
].join("\n");
|
|
11048
|
+
}
|
|
11049
|
+
/** Enumerate every detected surface with its planned content. Pure. */
|
|
11050
|
+
function detectSurfaces(cwd) {
|
|
11051
|
+
const surfaces = [];
|
|
11052
|
+
if (existsSync(join(cwd, ".claude"))) surfaces.push({
|
|
11053
|
+
name: "Claude Code command surface",
|
|
11054
|
+
dir: join(cwd, ".claude", "commands"),
|
|
11055
|
+
file: join(cwd, ".claude", "commands", "mjolnir.md"),
|
|
11056
|
+
content: agentBrief("Claude Code command", "/"),
|
|
11057
|
+
mode: "whole-file"
|
|
11058
|
+
});
|
|
11059
|
+
if (existsSync(join(cwd, ".kilo"))) surfaces.push({
|
|
11060
|
+
name: "Kilo command surface",
|
|
11061
|
+
dir: join(cwd, ".kilo", "command"),
|
|
11062
|
+
file: join(cwd, ".kilo", "command", "mjolnir.md"),
|
|
11063
|
+
content: agentBrief("Kilo command", ""),
|
|
11064
|
+
mode: "whole-file"
|
|
11065
|
+
});
|
|
11066
|
+
if (existsSync(join(cwd, ".cursor"))) surfaces.push({
|
|
11067
|
+
name: "Cursor rule surface",
|
|
11068
|
+
dir: join(cwd, ".cursor", "rules"),
|
|
11069
|
+
file: join(cwd, ".cursor", "rules", "mjolnir.mdc"),
|
|
11070
|
+
content: agentBrief("Cursor rule", ""),
|
|
11071
|
+
mode: "whole-file"
|
|
11072
|
+
});
|
|
11073
|
+
if (existsSync(join(cwd, "AGENTS.md"))) surfaces.push({
|
|
11074
|
+
name: "AGENTS.md instruction surface",
|
|
11075
|
+
dir: cwd,
|
|
11076
|
+
file: join(cwd, "AGENTS.md"),
|
|
11077
|
+
content: agentBrief("AGENTS.md", ""),
|
|
11078
|
+
mode: "append-block"
|
|
11079
|
+
});
|
|
11080
|
+
return surfaces;
|
|
11081
|
+
}
|
|
11082
|
+
function hasMjolnirMarker(content) {
|
|
11083
|
+
return content.includes("<!-- mjolnir:managed") && content.includes("<!-- /mjolnir:managed -->");
|
|
11084
|
+
}
|
|
11085
|
+
function mergedBlock(existing, content) {
|
|
11086
|
+
const openIdx = existing.indexOf(MARKER_OPEN);
|
|
11087
|
+
const closeIdx = existing.indexOf(MARKER_CLOSE);
|
|
11088
|
+
if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) {
|
|
11089
|
+
const body = content.slice(content.indexOf(MARKER_OPEN), content.indexOf(MARKER_CLOSE) + 25);
|
|
11090
|
+
return existing.slice(0, openIdx) + body + existing.slice(closeIdx + 25);
|
|
11091
|
+
}
|
|
11092
|
+
return `${existing}${existing.endsWith("\n") ? "" : "\n"}\n${content}`;
|
|
11093
|
+
}
|
|
11094
|
+
/** Pure plan: what install WOULD do. Zero I/O. */
|
|
11095
|
+
function planInstall(cwd, options = {}) {
|
|
11096
|
+
const surfaces = detectSurfaces(cwd);
|
|
11097
|
+
const entries = [];
|
|
11098
|
+
for (const s of surfaces) {
|
|
11099
|
+
if (s.mode === "append-block") {
|
|
11100
|
+
const existing = readFileSync(s.file, "utf8");
|
|
11101
|
+
if (hasMjolnirMarker(existing)) {
|
|
11102
|
+
const merged = mergedBlock(existing, s.content);
|
|
11103
|
+
entries.push(merged === existing ? {
|
|
11104
|
+
action: "no-op",
|
|
11105
|
+
surface: s.name,
|
|
11106
|
+
file: s.file,
|
|
11107
|
+
content: existing
|
|
11108
|
+
} : {
|
|
11109
|
+
action: "update-in-place",
|
|
11110
|
+
surface: s.name,
|
|
11111
|
+
file: s.file,
|
|
11112
|
+
content: merged
|
|
11113
|
+
});
|
|
11114
|
+
continue;
|
|
11115
|
+
}
|
|
11116
|
+
entries.push({
|
|
11117
|
+
action: "create",
|
|
11118
|
+
surface: s.name,
|
|
11119
|
+
file: s.file,
|
|
11120
|
+
content: mergedBlock(existing, s.content)
|
|
11121
|
+
});
|
|
11122
|
+
continue;
|
|
11123
|
+
}
|
|
11124
|
+
if (existsSync(s.file)) {
|
|
11125
|
+
const existing = readFileSync(s.file, "utf8");
|
|
11126
|
+
if (existing === s.content) {
|
|
11127
|
+
entries.push({
|
|
11128
|
+
action: "no-op",
|
|
11129
|
+
surface: s.name,
|
|
11130
|
+
file: s.file,
|
|
11131
|
+
content: existing
|
|
11132
|
+
});
|
|
11133
|
+
continue;
|
|
11134
|
+
}
|
|
11135
|
+
if (!hasMjolnirMarker(existing)) {
|
|
11136
|
+
entries.push({
|
|
11137
|
+
action: "refuse",
|
|
11138
|
+
surface: s.name,
|
|
11139
|
+
file: s.file,
|
|
11140
|
+
reason: "existing file is not Mjölnir-managed (no marker) — pass --force ONLY after reviewing it"
|
|
11141
|
+
});
|
|
11142
|
+
continue;
|
|
11143
|
+
}
|
|
11144
|
+
if (options.force !== true) {
|
|
11145
|
+
entries.push({
|
|
11146
|
+
action: "refuse",
|
|
11147
|
+
surface: s.name,
|
|
11148
|
+
file: s.file,
|
|
11149
|
+
reason: "Mjölnir-managed file has local edits — pass --force to overwrite"
|
|
11150
|
+
});
|
|
11151
|
+
continue;
|
|
11152
|
+
}
|
|
11153
|
+
entries.push({
|
|
11154
|
+
action: "update-in-place",
|
|
11155
|
+
surface: s.name,
|
|
11156
|
+
file: s.file,
|
|
11157
|
+
content: s.content
|
|
11158
|
+
});
|
|
11159
|
+
continue;
|
|
11160
|
+
}
|
|
11161
|
+
entries.push({
|
|
11162
|
+
action: "create",
|
|
11163
|
+
surface: s.name,
|
|
11164
|
+
file: s.file,
|
|
11165
|
+
content: s.content
|
|
11166
|
+
});
|
|
11167
|
+
}
|
|
11168
|
+
return {
|
|
11169
|
+
entries,
|
|
11170
|
+
detected: surfaces.length
|
|
11171
|
+
};
|
|
11172
|
+
}
|
|
11173
|
+
/** Execute a plan. Returns the number of files written. */
|
|
11174
|
+
function executeInstall(entries) {
|
|
11175
|
+
let written = 0;
|
|
11176
|
+
for (const e of entries) {
|
|
11177
|
+
if (e.action === "refuse" || e.action === "no-op") continue;
|
|
11178
|
+
const dir = join(e.file, "..");
|
|
11179
|
+
mkdirSync(dir, { recursive: true });
|
|
11180
|
+
writeFileSync(e.file, e.content);
|
|
11181
|
+
written++;
|
|
11182
|
+
}
|
|
11183
|
+
return written;
|
|
11184
|
+
}
|
|
11185
|
+
/**
|
|
11186
|
+
* Testable install command core. Returns the process exit code.
|
|
11187
|
+
* `mjolnir install [--dry-run] [--force]` — probes the given cwd
|
|
11188
|
+
* (production default: process.cwd()).
|
|
11189
|
+
*/
|
|
11190
|
+
function runInstallCommand(argv, io = {
|
|
11191
|
+
out: (line) => console.log(line),
|
|
11192
|
+
err: (line) => console.error(line)
|
|
11193
|
+
}, cwd = process.cwd()) {
|
|
11194
|
+
const dryRun = argv.includes("--dry-run");
|
|
11195
|
+
const force = argv.includes("--force");
|
|
11196
|
+
const stagedHook = argv.includes("--staged-hook");
|
|
11197
|
+
for (const a of argv) {
|
|
11198
|
+
if (a === "--dry-run" || a === "--force" || a === "--staged-hook") continue;
|
|
11199
|
+
if (a === "--help" || a === "-h") continue;
|
|
11200
|
+
io.err(usageMessageFor(a));
|
|
11201
|
+
return 10;
|
|
11202
|
+
}
|
|
11203
|
+
const { entries, detected } = planInstall(cwd, { force });
|
|
11204
|
+
if (detected === 0 && !stagedHook) {
|
|
11205
|
+
io.out("No instruction surfaces detected — nothing to install. Surfaces probed: .claude/ (Claude Code), .kilo/ (Kilo), .cursor/ (Cursor), AGENTS.md.");
|
|
11206
|
+
return 0;
|
|
11207
|
+
}
|
|
11208
|
+
if (dryRun) {
|
|
11209
|
+
io.out("Install plan (dry run — nothing written):");
|
|
11210
|
+
for (const e of entries) if (e.action === "refuse") io.err(` REFUSE ${e.file}: ${e.reason}`);
|
|
11211
|
+
else io.out(` ${e.action} ${e.file}`);
|
|
11212
|
+
if (stagedHook) {
|
|
11213
|
+
const hook = planHookInstall(cwd);
|
|
11214
|
+
io.out(` ${hook.action} ${hook.file} (non-blocking pre-commit hook)`);
|
|
11215
|
+
}
|
|
11216
|
+
return 0;
|
|
11217
|
+
}
|
|
11218
|
+
let refused = false;
|
|
11219
|
+
for (const e of entries) {
|
|
11220
|
+
if (e.action === "refuse") {
|
|
11221
|
+
io.err(`mjolnir install: refusing ${e.file} — ${e.reason}`);
|
|
11222
|
+
refused = true;
|
|
11223
|
+
continue;
|
|
11224
|
+
}
|
|
11225
|
+
if (e.action === "no-op") continue;
|
|
11226
|
+
}
|
|
11227
|
+
const written = executeInstall(entries);
|
|
11228
|
+
for (const e of entries) if (e.action !== "refuse" && e.action !== "no-op") io.out(` ${e.action}: ${e.surface} → ${e.file} (mjolnir-qa@${CLI_VERSION})`);
|
|
11229
|
+
if (stagedHook) {
|
|
11230
|
+
const hook = planHookInstall(cwd);
|
|
11231
|
+
executeHookInstall(hook);
|
|
11232
|
+
io.out(` ${hook.action}: non-blocking pre-commit hook → ${hook.file} (mjolnir-qa@${CLI_VERSION} --staged --blocking warning)`);
|
|
11233
|
+
}
|
|
11234
|
+
for (const e of entries) if (e.action !== "refuse" && e.action !== "no-op") io.out(` ${e.action}: ${e.surface} → ${e.file} (mjolnir-qa@${CLI_VERSION})`);
|
|
11235
|
+
if (refused) {
|
|
11236
|
+
io.err("Some surfaces were skipped — see refusals above. Nothing was overwritten.");
|
|
11237
|
+
return 10;
|
|
11238
|
+
}
|
|
11239
|
+
io.out(`Installed on ${detected} instruction surface(s); ${written} file(s) written.`);
|
|
11240
|
+
return 0;
|
|
11241
|
+
}
|
|
11242
|
+
function usageMessageFor(token) {
|
|
11243
|
+
return `mjolnir install: unknown argument "${token}" — supported: --dry-run, --force`;
|
|
11244
|
+
}
|
|
11245
|
+
const HOOK_MARKER_OPEN = "# mjolnir:managed pre-commit (non-blocking)";
|
|
11246
|
+
const HOOK_MARKER_CLOSE = "# /mjolnir:managed pre-commit";
|
|
11247
|
+
function hookBlock(version) {
|
|
11248
|
+
return [
|
|
11249
|
+
`${HOOK_MARKER_OPEN} v${version}`,
|
|
11250
|
+
`# Advisory: surfaces staged-file findings without blocking the commit.`,
|
|
11251
|
+
`mjolnir --staged --blocking warning || true`,
|
|
11252
|
+
HOOK_MARKER_CLOSE
|
|
11253
|
+
].join("\n");
|
|
11254
|
+
}
|
|
11255
|
+
/** The hook file an existing hook manager (husky / core.hooksPath) owns. */
|
|
11256
|
+
function resolveHookTarget(cwd) {
|
|
11257
|
+
const huskyDir = join(cwd, ".husky");
|
|
11258
|
+
if (existsSync(huskyDir)) return join(huskyDir, "pre-commit");
|
|
11259
|
+
try {
|
|
11260
|
+
const hooksPath = execFileSync("git", [
|
|
11261
|
+
"-C",
|
|
11262
|
+
cwd,
|
|
11263
|
+
"config",
|
|
11264
|
+
"core.hooksPath"
|
|
11265
|
+
], { stdio: [
|
|
11266
|
+
"ignore",
|
|
11267
|
+
"pipe",
|
|
11268
|
+
"ignore"
|
|
11269
|
+
] }).toString().trim();
|
|
11270
|
+
return join(cwd, hooksPath, "pre-commit");
|
|
11271
|
+
} catch {}
|
|
11272
|
+
return join(cwd, ".git", "hooks", "pre-commit");
|
|
11273
|
+
}
|
|
11274
|
+
/**
|
|
11275
|
+
* Pure plan for `--staged-hook` (plan M5). Reuses husky/core.hooksPath
|
|
11276
|
+
* when present; otherwise the default .git/hooks path. Marker-based:
|
|
11277
|
+
* an existing hook WITHOUT the marker is user-owned → refuse.
|
|
11278
|
+
*/
|
|
11279
|
+
function planHookInstall(cwd) {
|
|
11280
|
+
const file = resolveHookTarget(cwd);
|
|
11281
|
+
if (!existsSync(file)) return {
|
|
11282
|
+
action: "create",
|
|
11283
|
+
file
|
|
11284
|
+
};
|
|
11285
|
+
let existing;
|
|
11286
|
+
try {
|
|
11287
|
+
existing = readFileSync(file, "utf8");
|
|
11288
|
+
} catch {
|
|
11289
|
+
return {
|
|
11290
|
+
action: "refuse",
|
|
11291
|
+
file,
|
|
11292
|
+
reason: "existing hook is unreadable"
|
|
11293
|
+
};
|
|
11294
|
+
}
|
|
11295
|
+
if (existing.includes(HOOK_MARKER_OPEN)) return {
|
|
11296
|
+
action: "update",
|
|
11297
|
+
file
|
|
11298
|
+
};
|
|
11299
|
+
return {
|
|
11300
|
+
action: "append",
|
|
11301
|
+
file
|
|
11302
|
+
};
|
|
11303
|
+
}
|
|
11304
|
+
function executeHookInstall(entry) {
|
|
11305
|
+
switch (entry.action) {
|
|
11306
|
+
case "create":
|
|
11307
|
+
mkdirSync(join(entry.file, ".."), { recursive: true });
|
|
11308
|
+
writeFileSync(entry.file, `#!/bin/sh\n${hookBlock(CLI_VERSION)}\n`);
|
|
11309
|
+
return true;
|
|
11310
|
+
case "append": {
|
|
11311
|
+
const existing = readFileSync(entry.file, "utf8");
|
|
11312
|
+
const sep = existing.endsWith("\n") ? "" : "\n";
|
|
11313
|
+
writeFileSync(entry.file, `${existing}${sep}\n${hookBlock(CLI_VERSION)}\n`);
|
|
11314
|
+
return true;
|
|
11315
|
+
}
|
|
11316
|
+
case "update": {
|
|
11317
|
+
const existing = readFileSync(entry.file, "utf8");
|
|
11318
|
+
const openIdx = existing.indexOf(HOOK_MARKER_OPEN);
|
|
11319
|
+
const closeIdx = existing.indexOf(HOOK_MARKER_CLOSE);
|
|
11320
|
+
if (openIdx !== -1 && closeIdx !== -1) writeFileSync(entry.file, existing.slice(0, openIdx) + hookBlock(CLI_VERSION) + existing.slice(closeIdx + 29));
|
|
11321
|
+
else {
|
|
11322
|
+
const sep = existing.endsWith("\n") ? "" : "\n";
|
|
11323
|
+
writeFileSync(entry.file, `${existing}${sep}\n${hookBlock(CLI_VERSION)}\n`);
|
|
11324
|
+
}
|
|
11325
|
+
return true;
|
|
11326
|
+
}
|
|
11327
|
+
default: return false;
|
|
11328
|
+
}
|
|
11329
|
+
}
|
|
11330
|
+
//#endregion
|
|
11331
|
+
//#region src/scope/changed.ts
|
|
11332
|
+
/**
|
|
11333
|
+
* Changed-scope engine (Sprint-Plan W6, Product-MVP §9 `--scope changed`).
|
|
11334
|
+
*
|
|
11335
|
+
* Compares the current branch against its merge-base and reports only
|
|
11336
|
+
* findings on NEW/CHANGED lines. Handles: new files, modified files,
|
|
11337
|
+
* renames (treated as modified), shallow clones (graceful fallback to
|
|
11338
|
+
* full-file attribution), and detached HEAD.
|
|
11339
|
+
*
|
|
11340
|
+
* Audits H-9/H-10: the changed-file predicate is derived from the real
|
|
11341
|
+
* adapter registry (isKnownTestFile) so Python, Java, C#, and workflow
|
|
11342
|
+
* changes are not silently dropped; the default base falls back
|
|
11343
|
+
* main → master → origin/HEAD, an explicit --base is honored, and the
|
|
11344
|
+
* WORKING TREE (uncommitted + untracked files) is included so running
|
|
11345
|
+
* locally before committing still sees the change.
|
|
11346
|
+
*/
|
|
11347
|
+
function git$1(root, args) {
|
|
11348
|
+
try {
|
|
11349
|
+
return execFileSync("git", [
|
|
11350
|
+
"-C",
|
|
11351
|
+
root,
|
|
11352
|
+
...args
|
|
11353
|
+
], {
|
|
11354
|
+
encoding: "utf8",
|
|
11355
|
+
stdio: [
|
|
11356
|
+
"ignore",
|
|
11357
|
+
"pipe",
|
|
11358
|
+
"ignore"
|
|
11359
|
+
],
|
|
11360
|
+
timeout: 15e3
|
|
11361
|
+
});
|
|
11362
|
+
} catch {
|
|
11363
|
+
return null;
|
|
11364
|
+
}
|
|
11365
|
+
}
|
|
11366
|
+
/** Candidates for the default base branch, in fallback order (H-10). */
|
|
11367
|
+
const DEFAULT_BASE_CANDIDATES = [
|
|
11368
|
+
"main",
|
|
11369
|
+
"master",
|
|
11370
|
+
"origin/main",
|
|
11371
|
+
"origin/master",
|
|
11372
|
+
"origin/HEAD"
|
|
11373
|
+
];
|
|
11374
|
+
function resolveMergeBase(root, baseBranch) {
|
|
11375
|
+
const candidates = baseBranch ? [baseBranch, `origin/${baseBranch}`] : DEFAULT_BASE_CANDIDATES;
|
|
11376
|
+
for (const candidate of candidates) {
|
|
11377
|
+
if (candidate.startsWith("-")) continue;
|
|
11378
|
+
const mergeBase = git$1(root, [
|
|
11379
|
+
"merge-base",
|
|
11380
|
+
"HEAD",
|
|
11381
|
+
candidate
|
|
11382
|
+
])?.trim();
|
|
11383
|
+
if (mergeBase) return mergeBase;
|
|
11384
|
+
}
|
|
11385
|
+
return null;
|
|
11386
|
+
}
|
|
11387
|
+
/** Parse `git diff --name-status -z` output → changed file paths. */
|
|
11388
|
+
function collectNameStatus(output) {
|
|
11389
|
+
const entries = output.split("\0").filter(Boolean);
|
|
11390
|
+
const files = [];
|
|
11391
|
+
for (let i = 0; i < entries.length; i++) {
|
|
11392
|
+
const status = entries[i];
|
|
11393
|
+
if (status.startsWith("R") || status.startsWith("C")) i++;
|
|
11394
|
+
const file = entries[i + 1];
|
|
11395
|
+
if (file && isKnownTestFile(file)) files.push(file);
|
|
11396
|
+
}
|
|
11397
|
+
return files;
|
|
11398
|
+
}
|
|
11399
|
+
function computeChangedScope(root, baseBranch) {
|
|
11400
|
+
if (!existsSync(join(root, ".git"))) return {
|
|
11401
|
+
changed: {},
|
|
11402
|
+
degraded: true,
|
|
11403
|
+
reason: "not-a-git-repo"
|
|
11404
|
+
};
|
|
11405
|
+
const mergeBase = resolveMergeBase(root, baseBranch);
|
|
11406
|
+
if (!mergeBase) return {
|
|
11407
|
+
changed: {},
|
|
11408
|
+
degraded: true,
|
|
11409
|
+
reason: "no-merge-base"
|
|
11410
|
+
};
|
|
11411
|
+
const committed = git$1(root, [
|
|
11412
|
+
"diff",
|
|
11413
|
+
"--name-status",
|
|
11414
|
+
"-z",
|
|
11415
|
+
"--diff-filter=AMR",
|
|
11416
|
+
mergeBase,
|
|
11417
|
+
"HEAD"
|
|
11418
|
+
]);
|
|
11419
|
+
if (committed === null) return {
|
|
11420
|
+
changed: {},
|
|
11421
|
+
degraded: true,
|
|
10039
11422
|
reason: "diff-failed"
|
|
10040
11423
|
};
|
|
10041
11424
|
const workingTree = git$1(root, [
|
|
@@ -10153,6 +11536,23 @@ function filterToChanged(findings, diff) {
|
|
|
10153
11536
|
return false;
|
|
10154
11537
|
});
|
|
10155
11538
|
}
|
|
11539
|
+
/**
|
|
11540
|
+
* --staged (agent-handoff plan §5.7): the staged file names, as a
|
|
11541
|
+
* *scan-surface restriction*. Returns null when git data is
|
|
11542
|
+
* unavailable (degraded — callers fall back to the full surface with
|
|
11543
|
+
* an honest stderr note). Empty list = genuinely nothing staged.
|
|
11544
|
+
*/
|
|
11545
|
+
function computeStagedFiles(root) {
|
|
11546
|
+
if (!existsSync(join(root, ".git"))) return null;
|
|
11547
|
+
const raw = git$1(root, [
|
|
11548
|
+
"diff",
|
|
11549
|
+
"--cached",
|
|
11550
|
+
"--name-only",
|
|
11551
|
+
"-z"
|
|
11552
|
+
]);
|
|
11553
|
+
if (raw === null) return null;
|
|
11554
|
+
return raw.split("\0").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
11555
|
+
}
|
|
10156
11556
|
//#endregion
|
|
10157
11557
|
//#region src/engine/rule-runner.ts
|
|
10158
11558
|
/**
|
|
@@ -10602,8 +12002,18 @@ function gateScript(gate) {
|
|
|
10602
12002
|
"process.exit(0);"
|
|
10603
12003
|
].join("\n");
|
|
10604
12004
|
}
|
|
10605
|
-
/**
|
|
10606
|
-
|
|
12005
|
+
/**
|
|
12006
|
+
* Template v2 (Terminal + CI UX Overhaul plan, M4): the summary step
|
|
12007
|
+
* calls `mjolnir summary mjolnir.json` — annotations + step summary via
|
|
12008
|
+
* ONE emitter — instead of the v1 inline SUMMARY_SCRIPT. The gate
|
|
12009
|
+
* script is unchanged (reads `partial`, `findings[].severity`).
|
|
12010
|
+
*/
|
|
12011
|
+
/** Renders findings into `$GITHUB_STEP_SUMMARY`; tolerates a missing scan result.
|
|
12012
|
+
* v1 inline script, retained ONLY for overwrite-refusal recognition of
|
|
12013
|
+
* workflows generated by older versions (they are treated as ours, so
|
|
12014
|
+
* a frictionless `ci install` upgrade stays possible). Exported for the
|
|
12015
|
+
* recognition test that reconstructs the embedded form. */
|
|
12016
|
+
const SUMMARY_SCRIPT_V1 = [
|
|
10607
12017
|
"const fs = require(\"fs\");",
|
|
10608
12018
|
"let r = {};",
|
|
10609
12019
|
"try { r = JSON.parse(fs.readFileSync(\"mjolnir.json\", \"utf8\")); } catch (e) {}",
|
|
@@ -10620,6 +12030,13 @@ const SUMMARY_SCRIPT = [
|
|
|
10620
12030
|
"}",
|
|
10621
12031
|
"process.stdout.write(lines.length + \" finding(s)\\n\");"
|
|
10622
12032
|
].join("\n");
|
|
12033
|
+
/** True when a workflow was generated by any Mjölnir template (v1 or v2).
|
|
12034
|
+
* The v1 needle is matched in its INDENTED form: the v1 template embedded
|
|
12035
|
+
* the script via indentBlock(…, 10) inside the `run: |` scalar, so the
|
|
12036
|
+
* raw unindented substring never appears in a real v1 file. */
|
|
12037
|
+
function isKnownTemplate(content) {
|
|
12038
|
+
return GATES.some((g) => content === TEMPLATE(g)) || content.includes(indentBlock(SUMMARY_SCRIPT_V1, 10));
|
|
12039
|
+
}
|
|
10623
12040
|
/** Indents an embedded script so it sits inside a YAML `run: |` block scalar. */
|
|
10624
12041
|
function indentBlock(text, spaces) {
|
|
10625
12042
|
const pad = " ".repeat(spaces);
|
|
@@ -10659,16 +12076,17 @@ jobs:
|
|
|
10659
12076
|
- name: Scan changed code (exit 1/2 is data — the gate step decides)
|
|
10660
12077
|
continue-on-error: true
|
|
10661
12078
|
run: npx --yes mjolnir-qa@${CLI_VERSION} . --scope changed --json > mjolnir.json
|
|
12079
|
+
# Reporting, not gating: a crashed scan leaves mjolnir.json empty/missing
|
|
12080
|
+
# and the summary step exits 2/10 — continue-on-error keeps the advisory
|
|
12081
|
+
# job green, exactly like the v1 inline script did (the gate step decides).
|
|
12082
|
+
- name: Annotations + Job Summary
|
|
12083
|
+
if: always()
|
|
12084
|
+
continue-on-error: true
|
|
12085
|
+
run: npx --yes mjolnir-qa@${CLI_VERSION} summary mjolnir.json
|
|
10662
12086
|
- name: Render PR comment
|
|
10663
12087
|
if: always()
|
|
10664
12088
|
continue-on-error: true
|
|
10665
12089
|
run: npx --yes mjolnir-qa@${CLI_VERSION} pr-comment . > mjolnir-comment.md
|
|
10666
|
-
- name: Append findings to the Job Summary
|
|
10667
|
-
if: always()
|
|
10668
|
-
run: |
|
|
10669
|
-
node -e '
|
|
10670
|
-
${indentBlock(SUMMARY_SCRIPT, 10)}
|
|
10671
|
-
'
|
|
10672
12090
|
# Best-effort: on a pull_request event from a fork the GITHUB_TOKEN is
|
|
10673
12091
|
# read-only and this step will 403 for every external contributor. The
|
|
10674
12092
|
# Job Summary above is the fallback that always renders.
|
|
@@ -10751,7 +12169,7 @@ function ciInstall(root, gate = "advisory", options = {}) {
|
|
|
10751
12169
|
const existed = existsSync(target);
|
|
10752
12170
|
if (existed) {
|
|
10753
12171
|
const current = readFileSync(target, "utf8");
|
|
10754
|
-
if (!
|
|
12172
|
+
if (!isKnownTemplate(current) && !(options.force ?? false)) return {
|
|
10755
12173
|
written: target,
|
|
10756
12174
|
existed,
|
|
10757
12175
|
refused: true,
|
|
@@ -10768,6 +12186,7 @@ function ciInstall(root, gate = "advisory", options = {}) {
|
|
|
10768
12186
|
}
|
|
10769
12187
|
//#endregion
|
|
10770
12188
|
//#region src/forensics/analyze.ts
|
|
12189
|
+
const ui$14 = plainContext();
|
|
10771
12190
|
const MAX_RECORDS = 1e5;
|
|
10772
12191
|
function analyze(records, source) {
|
|
10773
12192
|
const verdicts = [];
|
|
@@ -10824,7 +12243,7 @@ function bar(ms, maxMs, width = 20) {
|
|
|
10824
12243
|
}
|
|
10825
12244
|
function renderLeaderboard(report) {
|
|
10826
12245
|
const lines = [];
|
|
10827
|
-
lines.push("
|
|
12246
|
+
lines.push(sectionHeader("FLAKINESS LEADERBOARD", ui$14));
|
|
10828
12247
|
lines.push("");
|
|
10829
12248
|
lines.push(`${report.totalTests} tests · ${report.failed} failed · ${report.flakyTests} flaky · ${report.retriedTests} retried`);
|
|
10830
12249
|
lines.push("");
|
|
@@ -10858,7 +12277,7 @@ function renderFlakyMd(report) {
|
|
|
10858
12277
|
lines.push("| Status | Test | File | Attempts | Duration |");
|
|
10859
12278
|
lines.push("|--------|------|------|----------|----------|");
|
|
10860
12279
|
for (const v of top) {
|
|
10861
|
-
const status = v.passedOnRetry ?
|
|
12280
|
+
const status = v.passedOnRetry ? `${FLAKE_GLYPH} TRUE-FLAKE` : "❌ failing";
|
|
10862
12281
|
lines.push(`| ${status} | \`${v.title}\` | \`${v.file}\` | ${v.attempts} | ${(v.totalDurationMs / 1e3).toFixed(1)}s |`);
|
|
10863
12282
|
}
|
|
10864
12283
|
lines.push("");
|
|
@@ -11085,6 +12504,7 @@ function listFiles(dir) {
|
|
|
11085
12504
|
}
|
|
11086
12505
|
//#endregion
|
|
11087
12506
|
//#region src/forensics/triage.ts
|
|
12507
|
+
const ui$13 = plainContext();
|
|
11088
12508
|
const QUARANTINE_MIN_ATTEMPTS = 2;
|
|
11089
12509
|
/** Deterministic triage table rows, worst first. */
|
|
11090
12510
|
function triageRows(report) {
|
|
@@ -11113,7 +12533,7 @@ function suggestAction(v) {
|
|
|
11113
12533
|
function renderTriage(report) {
|
|
11114
12534
|
const rows = triageRows(report);
|
|
11115
12535
|
const lines = [];
|
|
11116
|
-
lines.push("
|
|
12536
|
+
lines.push(sectionHeader("FLAKY TRIAGE — auto-generated, do not edit", ui$13));
|
|
11117
12537
|
lines.push("");
|
|
11118
12538
|
if (rows.length === 0) {
|
|
11119
12539
|
lines.push("Nothing to triage — no failures or retries in this run.");
|
|
@@ -11125,105 +12545,478 @@ function renderTriage(report) {
|
|
|
11125
12545
|
lines.push(`• [${flag}] ${r.title} (${r.file}) — ${r.attempts} attempt${r.attempts === 1 ? "" : "s"} → ${r.suggestedAction}`);
|
|
11126
12546
|
}
|
|
11127
12547
|
lines.push("");
|
|
11128
|
-
lines.push(`Auto-quarantine proposal: ${quarantineCount} test${quarantineCount === 1 ? "" : "s"} (retried ≥${QUARANTINE_MIN_ATTEMPTS} and failed at least once).`);
|
|
11129
|
-
lines.push("Quarantined tests should run nightly, not per-PR — quarantine is not deletion.");
|
|
12548
|
+
lines.push(`Auto-quarantine proposal: ${quarantineCount} test${quarantineCount === 1 ? "" : "s"} (retried ≥${QUARANTINE_MIN_ATTEMPTS} and failed at least once).`);
|
|
12549
|
+
lines.push("Quarantined tests should run nightly, not per-PR — quarantine is not deletion.");
|
|
12550
|
+
return lines.join("\n");
|
|
12551
|
+
}
|
|
12552
|
+
/** TRIAGE.md — the meeting artifact. */
|
|
12553
|
+
function renderTriageMd(report) {
|
|
12554
|
+
const rows = triageRows(report);
|
|
12555
|
+
const lines = [];
|
|
12556
|
+
lines.push("# TRIAGE.md");
|
|
12557
|
+
lines.push("");
|
|
12558
|
+
lines.push("> Auto-generated by `mjolnir triage` from real run data. Do not edit by hand.");
|
|
12559
|
+
lines.push("");
|
|
12560
|
+
if (rows.length === 0) {
|
|
12561
|
+
lines.push("_No flaky or failing tests this run — nothing to triage._ 🎉");
|
|
12562
|
+
return lines.join("\n");
|
|
12563
|
+
}
|
|
12564
|
+
lines.push("| Status | Test | File | Attempts | Duration | Suggested action | Quarantine? |");
|
|
12565
|
+
lines.push("|--------|------|------|----------|----------|------------------|-------------|");
|
|
12566
|
+
for (const r of rows) {
|
|
12567
|
+
const status = r.passedOnRetry ? `${FLAKE_GLYPH} TRUE-FLAKE` : "❌ FAILING";
|
|
12568
|
+
lines.push(`| ${status} | \`${r.title}\` | \`${r.file}\` | ${r.attempts} | ${(r.totalDurationMs / 1e3).toFixed(1)}s | ${r.suggestedAction} | ${r.proposedQuarantine ? "✅ propose" : "—"} |`);
|
|
12569
|
+
}
|
|
12570
|
+
const q = rows.filter((r) => r.proposedQuarantine).length;
|
|
12571
|
+
lines.push("");
|
|
12572
|
+
lines.push(`**Auto-quarantine proposal: ${q}** — move them to the nightly suite and open tracked tickets.`);
|
|
12573
|
+
lines.push("");
|
|
12574
|
+
return lines.join("\n");
|
|
12575
|
+
}
|
|
12576
|
+
//#endregion
|
|
12577
|
+
//#region src/commands/badge.ts
|
|
12578
|
+
/**
|
|
12579
|
+
* `mjolnir badge` — evidentiary shields.io endpoint JSON (Tier 1 #5).
|
|
12580
|
+
*
|
|
12581
|
+
* Static JSON, no server. The badge makes falsifiable claims:
|
|
12582
|
+
* score + date + commit. Anyone can click through and verify.
|
|
12583
|
+
*/
|
|
12584
|
+
/**
|
|
12585
|
+
* Badge colors follow the SAME ScoreState bands as the terminal
|
|
12586
|
+
* (≥80 trusted / ≥50 warning / <50 critical / 100 forged) — this
|
|
12587
|
+
* retarget fixes the historical threshold drift (the badge used
|
|
12588
|
+
* ≥90/≥75/≥50 with four bands while the reporter used ≥80/≥50).
|
|
12589
|
+
*
|
|
12590
|
+
* Shields.io has no cyan or white-gold, so the mapping is documented
|
|
12591
|
+
* here: trusted → `important` (blue-family, closest to aurora-cyan),
|
|
12592
|
+
* forged → `success` (the strongest positive signal shields offers).
|
|
12593
|
+
* The badge is a peripheral surface; ScoreState remains the truth.
|
|
12594
|
+
*/
|
|
12595
|
+
function colorFor(score) {
|
|
12596
|
+
const band = deriveScoreState(score).band;
|
|
12597
|
+
if (band === "unmeasured") return "lightgrey";
|
|
12598
|
+
if (band === "forged") return "success";
|
|
12599
|
+
if (band === "trusted") return "important";
|
|
12600
|
+
return band === "warning" ? "yellow" : "red";
|
|
12601
|
+
}
|
|
12602
|
+
/** Build the shields.io endpoint payload from a scan result. */
|
|
12603
|
+
function buildBadge(result) {
|
|
12604
|
+
const errors = result.findings.filter((f) => f.severity === "error").length;
|
|
12605
|
+
const score = result.score;
|
|
12606
|
+
return {
|
|
12607
|
+
schemaVersion: 1,
|
|
12608
|
+
label: "MJÖLNIR",
|
|
12609
|
+
message: score === null ? "no tests found" : score === 100 && errors === 0 ? "100/100 · forged" : `${score}/100 · ${errors} error${errors === 1 ? "" : "s"}`,
|
|
12610
|
+
color: colorFor(score),
|
|
12611
|
+
namedLogo: "vitest"
|
|
12612
|
+
};
|
|
12613
|
+
}
|
|
12614
|
+
/**
|
|
12615
|
+
* Full README-ready markdown snippet with commit-bound verification line
|
|
12616
|
+
* (the falsifiable claim: "verified at commit X on date Y").
|
|
12617
|
+
*/
|
|
12618
|
+
function renderBadgeSnippet(result, repoUrl = "https://github.com/Sergey-Bar/Mjolnir") {
|
|
12619
|
+
let commit = "unknown";
|
|
12620
|
+
try {
|
|
12621
|
+
commit = execSync("git rev-parse --short HEAD", {
|
|
12622
|
+
cwd: process.cwd(),
|
|
12623
|
+
encoding: "utf8",
|
|
12624
|
+
stdio: [
|
|
12625
|
+
"ignore",
|
|
12626
|
+
"pipe",
|
|
12627
|
+
"ignore"
|
|
12628
|
+
]
|
|
12629
|
+
}).trim();
|
|
12630
|
+
} catch {}
|
|
12631
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
12632
|
+
const errors = result.findings.filter((f) => f.severity === "error").length;
|
|
12633
|
+
return [
|
|
12634
|
+
"```markdown",
|
|
12635
|
+
"[](" + repoUrl + ")",
|
|
12636
|
+
"<!-- Mjölnir verified at commit " + commit + " on " + date + ": " + (result.score === null ? "no tests found" : result.score + "/100") + ", " + errors + " blocking error(s) -->",
|
|
12637
|
+
"```"
|
|
12638
|
+
].join("\n");
|
|
12639
|
+
}
|
|
12640
|
+
function writeBadge(result, options) {
|
|
12641
|
+
const path = join(options.outDir, "mjolnir-badge.json");
|
|
12642
|
+
writeFileSync(path, JSON.stringify(buildBadge(result), null, 2));
|
|
12643
|
+
return path;
|
|
12644
|
+
}
|
|
12645
|
+
//#endregion
|
|
12646
|
+
//#region src/commands/help.ts
|
|
12647
|
+
/** Ordered registry — grouped by the same categories the overview uses. */
|
|
12648
|
+
const HELP_ENTRIES = [
|
|
12649
|
+
{
|
|
12650
|
+
verb: "ci install",
|
|
12651
|
+
summary: "generate the PR workflow (scan + annotations + gate)",
|
|
12652
|
+
usage: "mjolnir ci install [--gate advisory|error|warning] [--force]",
|
|
12653
|
+
examples: ["mjolnir ci install", "mjolnir ci install --gate error --force"],
|
|
12654
|
+
next: "mjolnir --scope changed"
|
|
12655
|
+
},
|
|
12656
|
+
{
|
|
12657
|
+
verb: "pr-comment",
|
|
12658
|
+
summary: "render a scoped PR comment (Markdown)",
|
|
12659
|
+
usage: "mjolnir pr-comment [path] [--base <ref>]",
|
|
12660
|
+
examples: ["mjolnir pr-comment .", "mjolnir pr-comment . --base origin/main"]
|
|
12661
|
+
},
|
|
12662
|
+
{
|
|
12663
|
+
verb: "summary",
|
|
12664
|
+
summary: "CI annotations + step summary from a saved --json report",
|
|
12665
|
+
usage: "mjolnir summary [mjolnir.json] [--stdout] [--path-prefix <dir>]",
|
|
12666
|
+
examples: ["mjolnir --json > mjolnir.json && mjolnir summary mjolnir.json", "mjolnir summary mjolnir.json --stdout"]
|
|
12667
|
+
},
|
|
12668
|
+
{
|
|
12669
|
+
verb: "forensics",
|
|
12670
|
+
summary: "runtime evidence from a real run: retries, flakes, durations",
|
|
12671
|
+
usage: "mjolnir forensics <test-results-dir-or-report-file> [--no-flaky-md]",
|
|
12672
|
+
examples: ["mjolnir forensics test-results"]
|
|
12673
|
+
},
|
|
12674
|
+
{
|
|
12675
|
+
verb: "triage",
|
|
12676
|
+
summary: "flaky-triage proposal + TRIAGE.md meeting artifact",
|
|
12677
|
+
usage: "mjolnir triage <test-results-dir-or-report-file> [--no-md]",
|
|
12678
|
+
examples: ["mjolnir triage test-results --no-md"]
|
|
12679
|
+
},
|
|
12680
|
+
{
|
|
12681
|
+
verb: "pw-report",
|
|
12682
|
+
summary: "Playwright run summary (counts, true flakes, slowest tests)",
|
|
12683
|
+
usage: "mjolnir pw-report <playwright-report.json | test-results-dir>",
|
|
12684
|
+
examples: ["mjolnir pw-report test-results"]
|
|
12685
|
+
},
|
|
12686
|
+
{
|
|
12687
|
+
verb: "doctor:playwright",
|
|
12688
|
+
summary: "Playwright deep scan + Selector Health report",
|
|
12689
|
+
usage: "mjolnir doctor:playwright [path]",
|
|
12690
|
+
examples: ["mjolnir doctor:playwright e2e"]
|
|
12691
|
+
},
|
|
12692
|
+
{
|
|
12693
|
+
verb: "fix",
|
|
12694
|
+
summary: "apply safe auto-fixes with proof (re-scan verifies each)",
|
|
12695
|
+
usage: "mjolnir fix [path] [--dry-run]",
|
|
12696
|
+
examples: ["mjolnir fix --dry-run", "mjolnir fix ."]
|
|
12697
|
+
},
|
|
12698
|
+
{
|
|
12699
|
+
verb: "baseline",
|
|
12700
|
+
summary: "snapshot the current finding set as the comparison point",
|
|
12701
|
+
usage: "mjolnir baseline [path]",
|
|
12702
|
+
examples: ["mjolnir baseline", "mjolnir diff"],
|
|
12703
|
+
next: "mjolnir diff"
|
|
12704
|
+
},
|
|
12705
|
+
{
|
|
12706
|
+
verb: "diff",
|
|
12707
|
+
summary: "compare a fresh scan against the baseline — new/worsened only",
|
|
12708
|
+
usage: "mjolnir diff [path]",
|
|
12709
|
+
examples: ["mjolnir diff"]
|
|
12710
|
+
},
|
|
12711
|
+
{
|
|
12712
|
+
verb: "impact",
|
|
12713
|
+
summary: "what a commit introduced vs resolved, since a prior commit",
|
|
12714
|
+
usage: "mjolnir impact [path] [--since <ref>]",
|
|
12715
|
+
examples: ["mjolnir impact . --since HEAD~1"]
|
|
12716
|
+
},
|
|
12717
|
+
{
|
|
12718
|
+
verb: "debt",
|
|
12719
|
+
summary: "test-debt register with an estimated quarterly cost",
|
|
12720
|
+
usage: "mjolnir debt [path]",
|
|
12721
|
+
examples: ["mjolnir debt"]
|
|
12722
|
+
},
|
|
12723
|
+
{
|
|
12724
|
+
verb: "handover",
|
|
12725
|
+
summary: "new-QA onboarding map of the suite",
|
|
12726
|
+
usage: "mjolnir handover [path]",
|
|
12727
|
+
examples: ["mjolnir handover"]
|
|
12728
|
+
},
|
|
12729
|
+
{
|
|
12730
|
+
verb: "stats",
|
|
12731
|
+
summary: "all-time local counters of fixes seen via diff",
|
|
12732
|
+
usage: "mjolnir stats",
|
|
12733
|
+
examples: ["mjolnir stats"]
|
|
12734
|
+
},
|
|
12735
|
+
{
|
|
12736
|
+
verb: "badge",
|
|
12737
|
+
summary: "shields.io endpoint JSON + snippet from a scan",
|
|
12738
|
+
usage: "mjolnir badge [path]",
|
|
12739
|
+
examples: ["mjolnir badge ."]
|
|
12740
|
+
},
|
|
12741
|
+
{
|
|
12742
|
+
verb: "init",
|
|
12743
|
+
summary: "detect frameworks + setup checklist (never overwrites)",
|
|
12744
|
+
usage: "mjolnir init [--interactive]",
|
|
12745
|
+
examples: ["mjolnir init"]
|
|
12746
|
+
},
|
|
12747
|
+
{
|
|
12748
|
+
verb: "why",
|
|
12749
|
+
summary: "why did Mjölnir flag <file>:<line>? evidence + fix (not a gate)",
|
|
12750
|
+
usage: "mjolnir why <file>:<line> [path] [--json <mjolnir.json>]",
|
|
12751
|
+
examples: ["mjolnir why e2e/a.spec.ts:3", "mjolnir why e2e/a.spec.ts:3 --json mjolnir.json"]
|
|
12752
|
+
},
|
|
12753
|
+
{
|
|
12754
|
+
verb: "handoff",
|
|
12755
|
+
summary: "deterministic fix-handoff artifact from a saved --json report",
|
|
12756
|
+
usage: "mjolnir handoff [mjolnir.json] [--category <cat>] [--rules <ids>]",
|
|
12757
|
+
examples: ["mjolnir --json > mjolnir.json && mjolnir handoff mjolnir.json", "mjolnir handoff mjolnir.json --category QA-PW"]
|
|
12758
|
+
},
|
|
12759
|
+
{
|
|
12760
|
+
verb: "explain",
|
|
12761
|
+
summary: "what/why/fix + measured FP rate for one rule",
|
|
12762
|
+
usage: "mjolnir explain <RULE-ID> [--fixtures-root <dir>]",
|
|
12763
|
+
examples: ["mjolnir explain QA-TEST-001", "mjolnir rules --unmeasured"]
|
|
12764
|
+
},
|
|
12765
|
+
{
|
|
12766
|
+
verb: "rules",
|
|
12767
|
+
summary: "rule catalog with trust metadata (md/json/unmeasured)",
|
|
12768
|
+
usage: "mjolnir rules [--md] [--unmeasured|--measured] [--external]",
|
|
12769
|
+
examples: ["mjolnir rules --md --unmeasured"]
|
|
12770
|
+
},
|
|
12771
|
+
{
|
|
12772
|
+
verb: "suppressions",
|
|
12773
|
+
summary: "list suppressed findings (governance transparency)",
|
|
12774
|
+
usage: "mjolnir suppressions",
|
|
12775
|
+
examples: ["mjolnir suppressions"]
|
|
12776
|
+
},
|
|
12777
|
+
{
|
|
12778
|
+
verb: "create-rule",
|
|
12779
|
+
summary: "scaffold a new rule + fixtures (must-fire, must-not-fire)",
|
|
12780
|
+
usage: "mjolnir create-rule <QA-XXX-nnn> --title \"Rule title\"",
|
|
12781
|
+
examples: ["mjolnir create-rule QA-PW-131 --title \"No request waits\""]
|
|
12782
|
+
},
|
|
12783
|
+
{
|
|
12784
|
+
verb: "doctor",
|
|
12785
|
+
summary: "self-audit of the rule base (fixture firewall, tiers, caps)",
|
|
12786
|
+
usage: "mjolnir doctor [repo-root]",
|
|
12787
|
+
examples: ["mjolnir doctor"]
|
|
12788
|
+
},
|
|
12789
|
+
{
|
|
12790
|
+
verb: "install",
|
|
12791
|
+
summary: "install the agent instruction surfaces + optional staged hook",
|
|
12792
|
+
usage: "mjolnir install [--staged-hook] [--dry-run] [--force]",
|
|
12793
|
+
examples: ["mjolnir install --dry-run", "mjolnir install --staged-hook"]
|
|
12794
|
+
}
|
|
12795
|
+
];
|
|
12796
|
+
/** Scan-flag entries documented per-flag via the overview. */
|
|
12797
|
+
const HELP_FLAGS = [
|
|
12798
|
+
{
|
|
12799
|
+
flag: "--json",
|
|
12800
|
+
summary: "machine-readable output"
|
|
12801
|
+
},
|
|
12802
|
+
{
|
|
12803
|
+
flag: "--format sarif",
|
|
12804
|
+
summary: "SARIF 2.1 for GitHub Code Scanning"
|
|
12805
|
+
},
|
|
12806
|
+
{
|
|
12807
|
+
flag: "--format mermaid",
|
|
12808
|
+
summary: "test-architecture diagram"
|
|
12809
|
+
},
|
|
12810
|
+
{
|
|
12811
|
+
flag: "--tone blunt",
|
|
12812
|
+
summary: "blunter, pattern-mocking messages"
|
|
12813
|
+
},
|
|
12814
|
+
{
|
|
12815
|
+
flag: "--verbose",
|
|
12816
|
+
summary: "show all findings"
|
|
12817
|
+
},
|
|
12818
|
+
{
|
|
12819
|
+
flag: "--scope changed",
|
|
12820
|
+
summary: "only new/changed lines vs merge-base"
|
|
12821
|
+
},
|
|
12822
|
+
{
|
|
12823
|
+
flag: "--max-duration <sec>",
|
|
12824
|
+
summary: "analysis time budget"
|
|
12825
|
+
},
|
|
12826
|
+
{
|
|
12827
|
+
flag: "--width <cols>",
|
|
12828
|
+
summary: "override terminal width"
|
|
12829
|
+
},
|
|
12830
|
+
{
|
|
12831
|
+
flag: "--ascii / --no-ascii",
|
|
12832
|
+
summary: "force glyph mode"
|
|
12833
|
+
},
|
|
12834
|
+
{
|
|
12835
|
+
flag: "--strict",
|
|
12836
|
+
summary: "include quarantine-tier rules"
|
|
12837
|
+
},
|
|
12838
|
+
{
|
|
12839
|
+
flag: "--debug",
|
|
12840
|
+
summary: "print swallowed rule crashes"
|
|
12841
|
+
},
|
|
12842
|
+
{
|
|
12843
|
+
flag: "--cache",
|
|
12844
|
+
summary: "reuse local per-file verdicts"
|
|
12845
|
+
},
|
|
12846
|
+
{
|
|
12847
|
+
flag: "--no-progress",
|
|
12848
|
+
summary: "no live scan-progress line on stderr"
|
|
12849
|
+
},
|
|
12850
|
+
{
|
|
12851
|
+
flag: "--score",
|
|
12852
|
+
summary: "print only the numeric score (or `unknown`)"
|
|
12853
|
+
},
|
|
12854
|
+
{
|
|
12855
|
+
flag: "--category <cat>",
|
|
12856
|
+
summary: "presentation filter (repeatable)"
|
|
12857
|
+
},
|
|
12858
|
+
{
|
|
12859
|
+
flag: "--staged",
|
|
12860
|
+
summary: "scan only git staged files"
|
|
12861
|
+
},
|
|
12862
|
+
{
|
|
12863
|
+
flag: "--blocking <level>",
|
|
12864
|
+
summary: "exit-status override: error|warning|none"
|
|
12865
|
+
}
|
|
12866
|
+
];
|
|
12867
|
+
const EXIT_CODE_TABLE = [
|
|
12868
|
+
["0", "clean — no findings or the requested artifact was produced"],
|
|
12869
|
+
["1", "errors found (or the diff/impact verdict says the PR should not merge)"],
|
|
12870
|
+
["2", "partial — the scan ran but was truncated, or input was unreadable"],
|
|
12871
|
+
["10", "usage — bad flags or arguments; help is printed"],
|
|
12872
|
+
["20", "crash — internal error; rerun with --debug for the stack trace"]
|
|
12873
|
+
];
|
|
12874
|
+
function findEntry(verb) {
|
|
12875
|
+
return HELP_ENTRIES.find((e) => e.verb === verb);
|
|
12876
|
+
}
|
|
12877
|
+
/** True when `mjolnir help <verb>` has a detailed page. */
|
|
12878
|
+
function hasVerbHelp(verb) {
|
|
12879
|
+
return findEntry(verb) !== void 0;
|
|
12880
|
+
}
|
|
12881
|
+
/** One per-verb help page: summary, usage, examples, next step. */
|
|
12882
|
+
function renderVerbHelp(verb) {
|
|
12883
|
+
const e = findEntry(verb);
|
|
12884
|
+
if (!e) return [
|
|
12885
|
+
` No detailed help for "${verb}".`,
|
|
12886
|
+
"",
|
|
12887
|
+
" $ mjolnir --help",
|
|
12888
|
+
""
|
|
12889
|
+
].join("\n");
|
|
12890
|
+
const lines = [];
|
|
12891
|
+
lines.push(` ${e.verb} — ${e.summary}`);
|
|
12892
|
+
lines.push("");
|
|
12893
|
+
lines.push(` Usage:`);
|
|
12894
|
+
lines.push(` ${e.usage}`);
|
|
12895
|
+
lines.push("");
|
|
12896
|
+
lines.push(` Examples:`);
|
|
12897
|
+
for (const ex of e.examples) lines.push(` $ ${ex}`);
|
|
12898
|
+
if (e.next) {
|
|
12899
|
+
lines.push("");
|
|
12900
|
+
lines.push(` Next step:`);
|
|
12901
|
+
lines.push(` $ ${e.next}`);
|
|
12902
|
+
}
|
|
12903
|
+
lines.push("");
|
|
11130
12904
|
return lines.join("\n");
|
|
11131
12905
|
}
|
|
11132
|
-
|
|
11133
|
-
|
|
11134
|
-
|
|
12906
|
+
const DOCS_URL = "https://github.com/Sergey-Bar/Mjolnir#readme";
|
|
12907
|
+
/** The overview's grouped one-line sections, in display order. */
|
|
12908
|
+
const GROUPS = [
|
|
12909
|
+
{
|
|
12910
|
+
title: "Scan",
|
|
12911
|
+
verbs: []
|
|
12912
|
+
},
|
|
12913
|
+
{
|
|
12914
|
+
title: "CI & PRs",
|
|
12915
|
+
verbs: [
|
|
12916
|
+
"ci install",
|
|
12917
|
+
"summary",
|
|
12918
|
+
"pr-comment",
|
|
12919
|
+
"badge",
|
|
12920
|
+
"impact",
|
|
12921
|
+
"baseline",
|
|
12922
|
+
"diff"
|
|
12923
|
+
]
|
|
12924
|
+
},
|
|
12925
|
+
{
|
|
12926
|
+
title: "Forensics",
|
|
12927
|
+
verbs: [
|
|
12928
|
+
"forensics",
|
|
12929
|
+
"triage",
|
|
12930
|
+
"pw-report",
|
|
12931
|
+
"doctor:playwright"
|
|
12932
|
+
]
|
|
12933
|
+
},
|
|
12934
|
+
{
|
|
12935
|
+
title: "Maintenance",
|
|
12936
|
+
verbs: [
|
|
12937
|
+
"fix",
|
|
12938
|
+
"debt",
|
|
12939
|
+
"stats",
|
|
12940
|
+
"suppressions",
|
|
12941
|
+
"handover",
|
|
12942
|
+
"init",
|
|
12943
|
+
"doctor",
|
|
12944
|
+
"create-rule"
|
|
12945
|
+
]
|
|
12946
|
+
},
|
|
12947
|
+
{
|
|
12948
|
+
title: "Meta",
|
|
12949
|
+
verbs: [
|
|
12950
|
+
"rules",
|
|
12951
|
+
"explain",
|
|
12952
|
+
"why",
|
|
12953
|
+
"handoff",
|
|
12954
|
+
"install"
|
|
12955
|
+
]
|
|
12956
|
+
}
|
|
12957
|
+
];
|
|
12958
|
+
const SCAN_SUMMARY_LINES = ["mjolnir [path] full-repo scan + WORTHINESS score"];
|
|
12959
|
+
/**
|
|
12960
|
+
* The redesigned root help (plan M2): grouped sections, one-line
|
|
12961
|
+
* descriptions, copy-pasteable examples, the frozen exit-code table and
|
|
12962
|
+
* the docs link. Content is identical whether colored or piped — the
|
|
12963
|
+
* caller decides (runHelpCommand passes a resolved palette; printUsage
|
|
12964
|
+
* stays plain).
|
|
12965
|
+
*/
|
|
12966
|
+
function renderRootHelp(schemaVersion = 1) {
|
|
12967
|
+
const byVerb = new Map(HELP_ENTRIES.map((e) => [e.verb, e]));
|
|
11135
12968
|
const lines = [];
|
|
11136
|
-
lines.push("
|
|
12969
|
+
lines.push("🔨 mjölnir — verification trust engine for test suites and CI pipelines");
|
|
11137
12970
|
lines.push("");
|
|
11138
|
-
lines.push("
|
|
12971
|
+
lines.push("Usage: mjolnir [path] [options] · mjolnir <subcommand> [args] · mjolnir help <verb>");
|
|
11139
12972
|
lines.push("");
|
|
11140
|
-
|
|
11141
|
-
|
|
11142
|
-
|
|
12973
|
+
lines.push("The product is one command in CI:");
|
|
12974
|
+
lines.push("");
|
|
12975
|
+
lines.push(" mjolnir --scope changed scan only what the branch touched; exit 1 on");
|
|
12976
|
+
lines.push(" new findings. `mjolnir ci install` writes the");
|
|
12977
|
+
lines.push(" workflow for you.");
|
|
12978
|
+
lines.push("");
|
|
12979
|
+
lines.push("Everything else is optional.");
|
|
12980
|
+
lines.push("");
|
|
12981
|
+
lines.push(" " + SCAN_SUMMARY_LINES[0]);
|
|
12982
|
+
lines.push(" mjolnir explain <RULE-ID> what/why/fix + measured FP rate for one rule");
|
|
12983
|
+
lines.push(" mjolnir rules --unmeasured the rules running on assumption, not measurement");
|
|
12984
|
+
lines.push("");
|
|
12985
|
+
lines.push("Options:");
|
|
12986
|
+
for (const f of HELP_FLAGS) {
|
|
12987
|
+
const pad = f.flag.padEnd(22);
|
|
12988
|
+
lines.push(` ${pad}${f.summary}`);
|
|
11143
12989
|
}
|
|
11144
|
-
lines.push("
|
|
11145
|
-
lines.push("
|
|
11146
|
-
|
|
11147
|
-
|
|
11148
|
-
lines.push(
|
|
12990
|
+
lines.push(" -v, --version print the installed version and exit");
|
|
12991
|
+
lines.push(" -h, --help show this help");
|
|
12992
|
+
lines.push("");
|
|
12993
|
+
for (const g of GROUPS) {
|
|
12994
|
+
lines.push(`Subcommands — ${g.title}:`);
|
|
12995
|
+
for (const verb of g.verbs) {
|
|
12996
|
+
const e = byVerb.get(verb);
|
|
12997
|
+
if (!e) continue;
|
|
12998
|
+
const usage = e.usage.replace(/^mjolnir /, "").padEnd(46);
|
|
12999
|
+
lines.push(` ${usage}${e.summary}`);
|
|
13000
|
+
}
|
|
13001
|
+
lines.push("");
|
|
11149
13002
|
}
|
|
11150
|
-
|
|
13003
|
+
lines.push("Copy-paste starts:");
|
|
13004
|
+
lines.push(" $ mjolnir score this repo's test suite");
|
|
13005
|
+
lines.push(" $ mjolnir --scope changed CI gate: only what the branch touched");
|
|
13006
|
+
lines.push(" $ mjolnir ci install write the PR workflow");
|
|
13007
|
+
lines.push(" $ mjolnir forensics test-results where the flakes hide");
|
|
11151
13008
|
lines.push("");
|
|
11152
|
-
lines.push(
|
|
13009
|
+
lines.push("Per-command help: mjolnir help <verb> (e.g. mjolnir help fix)");
|
|
13010
|
+
lines.push("");
|
|
13011
|
+
lines.push(`Exit codes: ${EXIT_CODE_TABLE.map(([c]) => c).join(" · ")}`);
|
|
13012
|
+
for (const [code, meaning] of EXIT_CODE_TABLE) lines.push(` ${code.padEnd(3)} ${meaning}`);
|
|
11153
13013
|
lines.push("");
|
|
13014
|
+
lines.push(`Docs: ${DOCS_URL} (JSON schemaVersion ${schemaVersion}, additive-only)`);
|
|
11154
13015
|
return lines.join("\n");
|
|
11155
13016
|
}
|
|
11156
13017
|
//#endregion
|
|
11157
|
-
//#region src/commands/badge.ts
|
|
11158
|
-
/**
|
|
11159
|
-
* `mjolnir badge` — evidentiary shields.io endpoint JSON (Tier 1 #5).
|
|
11160
|
-
*
|
|
11161
|
-
* Static JSON, no server. The badge makes falsifiable claims:
|
|
11162
|
-
* score + date + commit. Anyone can click through and verify.
|
|
11163
|
-
*/
|
|
11164
|
-
/**
|
|
11165
|
-
* Badge colors follow the SAME ScoreState bands as the terminal
|
|
11166
|
-
* (≥80 trusted / ≥50 warning / <50 critical / 100 forged) — this
|
|
11167
|
-
* retarget fixes the historical threshold drift (the badge used
|
|
11168
|
-
* ≥90/≥75/≥50 with four bands while the reporter used ≥80/≥50).
|
|
11169
|
-
*
|
|
11170
|
-
* Shields.io has no cyan or white-gold, so the mapping is documented
|
|
11171
|
-
* here: trusted → `important` (blue-family, closest to aurora-cyan),
|
|
11172
|
-
* forged → `success` (the strongest positive signal shields offers).
|
|
11173
|
-
* The badge is a peripheral surface; ScoreState remains the truth.
|
|
11174
|
-
*/
|
|
11175
|
-
function colorFor(score) {
|
|
11176
|
-
const band = deriveScoreState(score).band;
|
|
11177
|
-
if (band === "unmeasured") return "lightgrey";
|
|
11178
|
-
if (band === "forged") return "success";
|
|
11179
|
-
if (band === "trusted") return "important";
|
|
11180
|
-
return band === "warning" ? "yellow" : "red";
|
|
11181
|
-
}
|
|
11182
|
-
/** Build the shields.io endpoint payload from a scan result. */
|
|
11183
|
-
function buildBadge(result) {
|
|
11184
|
-
const errors = result.findings.filter((f) => f.severity === "error").length;
|
|
11185
|
-
const score = result.score;
|
|
11186
|
-
return {
|
|
11187
|
-
schemaVersion: 1,
|
|
11188
|
-
label: "MJÖLNIR",
|
|
11189
|
-
message: score === null ? "no tests found" : score === 100 && errors === 0 ? "100/100 · forged" : `${score}/100 · ${errors} error${errors === 1 ? "" : "s"}`,
|
|
11190
|
-
color: colorFor(score),
|
|
11191
|
-
namedLogo: "vitest"
|
|
11192
|
-
};
|
|
11193
|
-
}
|
|
11194
|
-
/**
|
|
11195
|
-
* Full README-ready markdown snippet with commit-bound verification line
|
|
11196
|
-
* (the falsifiable claim: "verified at commit X on date Y").
|
|
11197
|
-
*/
|
|
11198
|
-
function renderBadgeSnippet(result, repoUrl = "https://github.com/Sergey-Bar/Mjolnir") {
|
|
11199
|
-
let commit = "unknown";
|
|
11200
|
-
try {
|
|
11201
|
-
commit = execSync("git rev-parse --short HEAD", {
|
|
11202
|
-
cwd: process.cwd(),
|
|
11203
|
-
encoding: "utf8",
|
|
11204
|
-
stdio: [
|
|
11205
|
-
"ignore",
|
|
11206
|
-
"pipe",
|
|
11207
|
-
"ignore"
|
|
11208
|
-
]
|
|
11209
|
-
}).trim();
|
|
11210
|
-
} catch {}
|
|
11211
|
-
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
11212
|
-
const errors = result.findings.filter((f) => f.severity === "error").length;
|
|
11213
|
-
return [
|
|
11214
|
-
"```markdown",
|
|
11215
|
-
"[](" + repoUrl + ")",
|
|
11216
|
-
"<!-- Mjölnir verified at commit " + commit + " on " + date + ": " + (result.score === null ? "no tests found" : result.score + "/100") + ", " + errors + " blocking error(s) -->",
|
|
11217
|
-
"```"
|
|
11218
|
-
].join("\n");
|
|
11219
|
-
}
|
|
11220
|
-
function writeBadge(result, options) {
|
|
11221
|
-
const path = join(options.outDir, "mjolnir-badge.json");
|
|
11222
|
-
writeFileSync(path, JSON.stringify(buildBadge(result), null, 2));
|
|
11223
|
-
return path;
|
|
11224
|
-
}
|
|
11225
|
-
//#endregion
|
|
11226
13018
|
//#region src/commands/debt.ts
|
|
13019
|
+
const ui$12 = plainContext();
|
|
11227
13020
|
/** Cost model (documented, conservative): hours/quarter per occurrence. */
|
|
11228
13021
|
const COST_MODEL = {
|
|
11229
13022
|
"QA-TEST-004": {
|
|
@@ -11304,21 +13097,17 @@ function computeDebt(result) {
|
|
|
11304
13097
|
function renderDebt(result) {
|
|
11305
13098
|
const { classes, totalHours } = computeDebt(result);
|
|
11306
13099
|
const lines = [];
|
|
11307
|
-
lines.push("
|
|
13100
|
+
lines.push(sectionHeader("TEST DEBT REGISTER", ui$12));
|
|
11308
13101
|
lines.push("");
|
|
11309
13102
|
if (classes.length === 0) {
|
|
11310
13103
|
lines.push("No tracked debt classes found — the suite is clean.");
|
|
11311
13104
|
return lines.join("\n");
|
|
11312
13105
|
}
|
|
11313
|
-
|
|
11314
|
-
|
|
11315
|
-
for (const c of classes) {
|
|
11316
|
-
|
|
11317
|
-
|
|
11318
|
-
}
|
|
11319
|
-
lines.push("╞══════════════════════════════════════════════════════╡");
|
|
11320
|
-
lines.push(`│ TOTAL ESTIMATED DRAG: ~${totalHours.toFixed(1)} engineer-hours/qtr │`);
|
|
11321
|
-
lines.push("╘══════════════════════════════════════════════════════╛");
|
|
13106
|
+
const rows = ["DEBT CLASS", ""];
|
|
13107
|
+
rows[0] = `DEBT CLASS COUNT EST. HOURS/QUARTER`;
|
|
13108
|
+
for (const c of classes) rows.push(`${c.label.padEnd(26)} ${String(c.count).padStart(5)} ${c.estHoursPerQuarter.toFixed(1).padStart(8)}`);
|
|
13109
|
+
rows.push(`TOTAL ESTIMATED DRAG: ~${totalHours.toFixed(1)} engineer-hours/qtr`);
|
|
13110
|
+
for (const row of panel(rows, ui$12)) lines.push(row);
|
|
11322
13111
|
lines.push("");
|
|
11323
13112
|
lines.push("Cost model is conservative and documented in src/commands/debt.ts.");
|
|
11324
13113
|
return lines.join("\n");
|
|
@@ -11338,6 +13127,7 @@ function renderDebt(result) {
|
|
|
11338
13127
|
* The generated rule intentionally FAILS its fixtures until the author
|
|
11339
13128
|
* implements it — you cannot ship a stub.
|
|
11340
13129
|
*/
|
|
13130
|
+
const ui$11 = plainContext();
|
|
11341
13131
|
/** Map rule family prefix → source directory + default category. */
|
|
11342
13132
|
const FAMILY_META = {
|
|
11343
13133
|
test: {
|
|
@@ -11475,7 +13265,7 @@ function camel(idLower) {
|
|
|
11475
13265
|
function renderScaffoldReport(result) {
|
|
11476
13266
|
if (!result.ok) return `create-rule failed: ${result.error}`;
|
|
11477
13267
|
const lines = [];
|
|
11478
|
-
lines.push("
|
|
13268
|
+
lines.push(sectionHeader("RULE SCAFFOLD CREATED", ui$11));
|
|
11479
13269
|
lines.push("");
|
|
11480
13270
|
for (const f of result.files) lines.push(` + ${f}`);
|
|
11481
13271
|
lines.push("");
|
|
@@ -11488,6 +13278,7 @@ function renderScaffoldReport(result) {
|
|
|
11488
13278
|
}
|
|
11489
13279
|
//#endregion
|
|
11490
13280
|
//#region src/commands/handover.ts
|
|
13281
|
+
const ui$10 = plainContext();
|
|
11491
13282
|
const FAKE_GREEN_RULES = /* @__PURE__ */ new Set([
|
|
11492
13283
|
"QA-TEST-003",
|
|
11493
13284
|
"QA-PY-003",
|
|
@@ -11557,9 +13348,7 @@ function buildHandover(scan, forensics) {
|
|
|
11557
13348
|
}
|
|
11558
13349
|
function renderHandover(map) {
|
|
11559
13350
|
const lines = [];
|
|
11560
|
-
lines.push("
|
|
11561
|
-
lines.push("║ WELCOME TO THE TEST SUITE — WHAT YOU NEED TO KNOW");
|
|
11562
|
-
lines.push("╚══════════════════════════════════════════════╝");
|
|
13351
|
+
lines.push(sectionHeader("WELCOME TO THE TEST SUITE — WHAT YOU NEED TO KNOW", ui$10));
|
|
11563
13352
|
lines.push("");
|
|
11564
13353
|
lines.push(map.summaryLine);
|
|
11565
13354
|
for (const s of map.sections) {
|
|
@@ -11591,6 +13380,7 @@ function renderHandover(map) {
|
|
|
11591
13380
|
* catch in *other* tools. Local-only, zero network (verified by
|
|
11592
13381
|
* tests/privacy-network-isolation.spec.ts, which scans this file too).
|
|
11593
13382
|
*/
|
|
13383
|
+
const ui$9 = plainContext();
|
|
11594
13384
|
function git(root, args) {
|
|
11595
13385
|
try {
|
|
11596
13386
|
return execFileSync("git", [
|
|
@@ -11765,7 +13555,7 @@ async function computeImpact(root, options) {
|
|
|
11765
13555
|
}
|
|
11766
13556
|
function renderImpact(report) {
|
|
11767
13557
|
const lines = [];
|
|
11768
|
-
lines.push("
|
|
13558
|
+
lines.push(sectionHeader("IMPACT REPORT", ui$9));
|
|
11769
13559
|
lines.push("");
|
|
11770
13560
|
if (!report.hasComparison) {
|
|
11771
13561
|
lines.push(`UNKNOWN — no comparison could be made (${report.unknownReason ?? "unknown reason"}).`);
|
|
@@ -11819,7 +13609,17 @@ function renderImpact(report) {
|
|
|
11819
13609
|
* file if they want a shared baseline; that's a deliberate choice this
|
|
11820
13610
|
* command does not make for them.
|
|
11821
13611
|
*/
|
|
13612
|
+
const ui$8 = plainContext();
|
|
11822
13613
|
const DEFAULT_BASELINE_PATH = join(".mjolnir", "baseline.json");
|
|
13614
|
+
/**
|
|
13615
|
+
* Correlation identity for before/after comparison (agent-handoff plan
|
|
13616
|
+
* §5.2): ruleId + file + message, deliberately EXCLUDING `line` — a
|
|
13617
|
+
* source edit that shifts a finding still correlates. file:line is an
|
|
13618
|
+
* occurrence location, not a durable identity; message rewording,
|
|
13619
|
+
* file renames and rule-id changes correlate as resolved+new
|
|
13620
|
+
* (documented limitation). Exported for the handoff verification
|
|
13621
|
+
* contract — do not duplicate this algorithm.
|
|
13622
|
+
*/
|
|
11823
13623
|
function fingerprint(f) {
|
|
11824
13624
|
return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
|
|
11825
13625
|
}
|
|
@@ -11910,21 +13710,21 @@ function diffAgainstBaseline(result, baseline) {
|
|
|
11910
13710
|
}
|
|
11911
13711
|
function renderBaselineSaved(path, count, replaced) {
|
|
11912
13712
|
const lines = [
|
|
11913
|
-
"
|
|
13713
|
+
sectionHeader("BASELINE SAVED", ui$8),
|
|
11914
13714
|
"",
|
|
11915
13715
|
`Captured ${count} finding${count === 1 ? "" : "s"} to ${path}.`
|
|
11916
13716
|
];
|
|
11917
13717
|
if (replaced?.backupPath !== void 0) lines.push(`Replaced an existing baseline — the previous one was saved to ${replaced.backupPath}.`);
|
|
11918
|
-
lines.push("
|
|
13718
|
+
lines.push(nextStep("mjolnir diff", ui$8) + " — see only what's new.");
|
|
11919
13719
|
return lines.join("\n");
|
|
11920
13720
|
}
|
|
11921
13721
|
function renderBaselineDiff(diff) {
|
|
11922
13722
|
const lines = [];
|
|
11923
|
-
lines.push("
|
|
13723
|
+
lines.push(sectionHeader("DIFF AGAINST BASELINE", ui$8));
|
|
11924
13724
|
lines.push("");
|
|
11925
13725
|
if (!diff.hasBaseline) {
|
|
11926
13726
|
lines.push("UNKNOWN — no baseline found.");
|
|
11927
|
-
lines.push("
|
|
13727
|
+
lines.push(nextStep("mjolnir baseline", ui$8) + " to capture a comparison point.");
|
|
11928
13728
|
return lines.join("\n");
|
|
11929
13729
|
}
|
|
11930
13730
|
lines.push(`Baseline captured ${diff.baselineCapturedAt ?? "unknown time"} at commit ${diff.baselineCommit ?? "unknown"}.`);
|
|
@@ -11970,6 +13770,7 @@ function renderBaselineDiff(diff) {
|
|
|
11970
13770
|
* fix recorded by `diff`), announced once and never repeated. Display-only:
|
|
11971
13771
|
* it does not change scores, exit codes or the JSON schema.
|
|
11972
13772
|
*/
|
|
13773
|
+
const ui$7 = plainContext();
|
|
11973
13774
|
const DEFAULT_STATS_PATH = join(".mjolnir", "stats.json");
|
|
11974
13775
|
const MILESTONE_MESSAGES = {
|
|
11975
13776
|
"first-clean-scan": "MILESTONE: first flawless scan recorded for this repo (score 100, zero findings).",
|
|
@@ -12057,12 +13858,13 @@ function saveStats(stats, outPath) {
|
|
|
12057
13858
|
}
|
|
12058
13859
|
function renderStats(stats) {
|
|
12059
13860
|
const lines = [];
|
|
12060
|
-
lines.push("
|
|
13861
|
+
lines.push(sectionHeader("ALL-TIME STATS (this machine, this repo)", ui$7));
|
|
12061
13862
|
lines.push("");
|
|
12062
13863
|
if (!stats || stats.recordedFixEvents === 0) {
|
|
12063
13864
|
lines.push("No fixes recorded yet.");
|
|
12064
|
-
lines.push("
|
|
12065
|
-
lines.push("
|
|
13865
|
+
lines.push("Fixes are counted here only when observed by mjolnir diff —", "capture a baseline first, then diff after making fixes:");
|
|
13866
|
+
lines.push(nextStep("mjolnir baseline", ui$7));
|
|
13867
|
+
lines.push(nextStep("mjolnir diff", ui$7));
|
|
12066
13868
|
lines.push("");
|
|
12067
13869
|
lines.push("UNKNOWN: totals before tracking started. This command only counts");
|
|
12068
13870
|
lines.push("what it has personally witnessed via mjolnir diff.");
|
|
@@ -12080,87 +13882,6 @@ function renderStats(stats) {
|
|
|
12080
13882
|
return lines.join("\n");
|
|
12081
13883
|
}
|
|
12082
13884
|
//#endregion
|
|
12083
|
-
//#region src/commands/pr-comment.ts
|
|
12084
|
-
const MARKER = "<!-- mjolnir-pr-comment -->";
|
|
12085
|
-
/**
|
|
12086
|
-
* Bug-audit QA-2026-08-30 QA-10: finding metadata rendered into the PR
|
|
12087
|
-
* comment body is untrusted (hostile filenames, plugin messages). A raw
|
|
12088
|
-
* `|` breaks the markdown layout, a raw backtick escapes the code span,
|
|
12089
|
-
* and `<\/script>`/link syntax could inject content into the PR page.
|
|
12090
|
-
* Escape markdown-significant characters and strip control/ANSI escapes.
|
|
12091
|
-
*/
|
|
12092
|
-
function escapeMarkdown(s) {
|
|
12093
|
-
return sanitizeData(s).replace(/([\\`*_{}[\]()#+!|<>])/g, "\\$1");
|
|
12094
|
-
}
|
|
12095
|
-
/** True when a fix recommendation reads as code rather than prose —
|
|
12096
|
-
* those render as a code span, the rest stay italic. */
|
|
12097
|
-
function looksLikeCode(s) {
|
|
12098
|
-
return /[(;={]|await |expect\(/.test(s);
|
|
12099
|
-
}
|
|
12100
|
-
/** Evidence tag per finding — Evidence > Assumption in the fabric: a
|
|
12101
|
-
* measured false-positive rate is shown right on the line when present. */
|
|
12102
|
-
function evidenceTag(f) {
|
|
12103
|
-
const level = f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence);
|
|
12104
|
-
let tag = `${level} · ${level === "E2" ? "deterministic" : level === "E1" ? "heuristic" : "observation"}`;
|
|
12105
|
-
if (f.measuredFpRate !== void 0) {
|
|
12106
|
-
tag += ` · measured FP ${Math.round(f.measuredFpRate * 100)}%`;
|
|
12107
|
-
if (f.measuredFpN !== void 0) tag += ` · n=${f.measuredFpN}`;
|
|
12108
|
-
}
|
|
12109
|
-
return tag;
|
|
12110
|
-
}
|
|
12111
|
-
function findingLine(f) {
|
|
12112
|
-
const icon = f.severity === "error" ? "🔴" : f.severity === "warning" ? "🟡" : "🔵";
|
|
12113
|
-
const fix = escapeMarkdown(f.fix);
|
|
12114
|
-
const fixBody = looksLikeCode(f.fix) ? `\`${fix}\`` : `_${fix}_`;
|
|
12115
|
-
return `${icon} **${escapeMarkdown(f.ruleId)}** \`${escapeMarkdown(f.file)}:${f.line}\` — ${escapeMarkdown(f.message)} ${escapeMarkdown(`[${evidenceTag(f)}]`)}\n ${fixBody}`;
|
|
12116
|
-
}
|
|
12117
|
-
/**
|
|
12118
|
-
* Render the full comment body. Idempotent by design — the leading HTML
|
|
12119
|
-
* comment marker lets the posting workflow find and update its own prior
|
|
12120
|
-
* comment instead of spamming a new one on every push.
|
|
12121
|
-
*/
|
|
12122
|
-
function renderPrComment(result, options = {}) {
|
|
12123
|
-
const lines = [MARKER, ""];
|
|
12124
|
-
lines.push("### 🔨 Mjölnir scan");
|
|
12125
|
-
lines.push("");
|
|
12126
|
-
const diff = options.diff;
|
|
12127
|
-
const usingDiff = diff?.hasBaseline === true;
|
|
12128
|
-
const findings = usingDiff ? diff.newFindings : result.findings;
|
|
12129
|
-
if (usingDiff) lines.push(`Comparing against the stored baseline (commit \`${diff.baselineCommit ?? "unknown"}\`) — showing only what this PR changed.`);
|
|
12130
|
-
else if (result.scope === "changed") lines.push("Showing only findings on lines this PR changed.");
|
|
12131
|
-
else lines.push("No baseline was found — showing the full scan. Run `mjolnir baseline` on the base branch to scope future comments to just what changed.");
|
|
12132
|
-
lines.push("");
|
|
12133
|
-
if (result.score !== null) {
|
|
12134
|
-
const baseScore = diff?.baselineScore;
|
|
12135
|
-
if (baseScore !== void 0) {
|
|
12136
|
-
const delta = result.score - baseScore;
|
|
12137
|
-
const sign = delta > 0 ? "+" : "";
|
|
12138
|
-
const commit = diff?.baselineCommit?.slice(0, 7) || "unknown";
|
|
12139
|
-
lines.push(`**Score:** ${result.score}/100 (${sign}${delta} since baseline \`${commit}\`)`);
|
|
12140
|
-
} else lines.push(`**Score:** ${result.score}/100`);
|
|
12141
|
-
lines.push("");
|
|
12142
|
-
}
|
|
12143
|
-
if (findings.length === 0) lines.push("✅ No new issues found in this PR's changes.");
|
|
12144
|
-
else {
|
|
12145
|
-
const errors = findings.filter((f) => f.severity === "error").length;
|
|
12146
|
-
const warnings = findings.filter((f) => f.severity === "warning").length;
|
|
12147
|
-
lines.push(`**${findings.length} new finding${findings.length === 1 ? "" : "s"}** (${errors} error, ${warnings} warning):`);
|
|
12148
|
-
lines.push("");
|
|
12149
|
-
for (const f of findings.slice(0, 25)) lines.push(`- ${findingLine(f)}`);
|
|
12150
|
-
if (findings.length > 25) {
|
|
12151
|
-
lines.push("");
|
|
12152
|
-
lines.push(`_...and ${findings.length - 25} more. Run \`mjolnir\` locally for the full list._`);
|
|
12153
|
-
}
|
|
12154
|
-
}
|
|
12155
|
-
if (usingDiff && diff.resolvedFindings.length > 0) {
|
|
12156
|
-
lines.push("");
|
|
12157
|
-
lines.push(`✨ This PR also fixed ${diff.resolvedFindings.length} pre-existing finding${diff.resolvedFindings.length === 1 ? "" : "s"}.`);
|
|
12158
|
-
}
|
|
12159
|
-
lines.push("");
|
|
12160
|
-
lines.push(`_Advisory only — this comment never blocks merging. Generated by [Mjölnir](${options.repoUrl ?? "https://github.com/Sergey-Bar/Mjolnir"})._`);
|
|
12161
|
-
return lines.join("\n");
|
|
12162
|
-
}
|
|
12163
|
-
//#endregion
|
|
12164
13885
|
//#region src/commands/init.ts
|
|
12165
13886
|
/**
|
|
12166
13887
|
* `mjolnir init` — onboarding wizard (Tier 2 #10).
|
|
@@ -12172,6 +13893,7 @@ function renderPrComment(result, options = {}) {
|
|
|
12172
13893
|
*
|
|
12173
13894
|
* Idempotent: existing files are reported, never overwritten.
|
|
12174
13895
|
*/
|
|
13896
|
+
const ui$6 = plainContext();
|
|
12175
13897
|
function runInit(rootDir, workspace, options = {}) {
|
|
12176
13898
|
const steps = [];
|
|
12177
13899
|
const nextCommands = [];
|
|
@@ -12221,7 +13943,7 @@ function runInit(rootDir, workspace, options = {}) {
|
|
|
12221
13943
|
}
|
|
12222
13944
|
function renderInit(result) {
|
|
12223
13945
|
const lines = [];
|
|
12224
|
-
lines.push("
|
|
13946
|
+
lines.push(sectionHeader("MJÖLNIR INIT", ui$6));
|
|
12225
13947
|
lines.push("");
|
|
12226
13948
|
for (const s of result.steps) {
|
|
12227
13949
|
const icon = s.status === "advice" ? "·" : s.status === "exists" ? "=" : "-";
|
|
@@ -12230,7 +13952,7 @@ function renderInit(result) {
|
|
|
12230
13952
|
if (result.nextCommands.length > 0) {
|
|
12231
13953
|
lines.push("");
|
|
12232
13954
|
lines.push("Next commands:");
|
|
12233
|
-
for (const c of result.nextCommands) lines.push(
|
|
13955
|
+
for (const c of result.nextCommands) lines.push(nextStep(c, ui$6));
|
|
12234
13956
|
}
|
|
12235
13957
|
lines.push("");
|
|
12236
13958
|
lines.push("Existing files are never overwritten — init is safe to re-run.");
|
|
@@ -12248,6 +13970,7 @@ function tryReadPackageJson(rootDir) {
|
|
|
12248
13970
|
}
|
|
12249
13971
|
//#endregion
|
|
12250
13972
|
//#region src/commands/pw-report.ts
|
|
13973
|
+
const ui$5 = plainContext();
|
|
12251
13974
|
function summarizePwRun(report) {
|
|
12252
13975
|
const slowest = [...report.verdicts].sort((a, b) => b.totalDurationMs - a.totalDurationMs).slice(0, 5).map((v) => ({
|
|
12253
13976
|
title: v.title,
|
|
@@ -12267,10 +13990,10 @@ function summarizePwRun(report) {
|
|
|
12267
13990
|
}
|
|
12268
13991
|
function renderPwRunSummary(s) {
|
|
12269
13992
|
const lines = [];
|
|
12270
|
-
lines.push("
|
|
13993
|
+
lines.push(sectionHeader("MJÖLNIR — RUN SUMMARY", ui$5));
|
|
12271
13994
|
lines.push("");
|
|
12272
13995
|
lines.push(`${s.total} tests · ${s.passed} passed · ${s.failed} failed · ${s.skipped} skipped`);
|
|
12273
|
-
if (s.retried > 0 || s.trueFlakes > 0) lines.push(`↻ ${s.retried} retried ·
|
|
13996
|
+
if (s.retried > 0 || s.trueFlakes > 0) lines.push(`↻ ${s.retried} retried · ${FLAKE_GLYPH} ${s.trueFlakes} TRUE-FLAKE${s.trueFlakes === 1 ? "" : "S"} (passed only on attempt ≥2)`);
|
|
12274
13997
|
const secs = (s.wallTimeMs / 1e3).toFixed(1);
|
|
12275
13998
|
lines.push(`⏱ total test time: ${secs}s`);
|
|
12276
13999
|
if (s.slowest.length > 0 && s.slowest[0].ms > 0) {
|
|
@@ -12298,6 +14021,7 @@ function renderPwRunSummary(s) {
|
|
|
12298
14021
|
* Everything else stays suggestion-only. No AST surgery on heuristic
|
|
12299
14022
|
* findings — false fixes would break the brand promise.
|
|
12300
14023
|
*/
|
|
14024
|
+
const ui$4 = plainContext();
|
|
12301
14025
|
const MAX_FILE_BYTES = 524288;
|
|
12302
14026
|
/**
|
|
12303
14027
|
* Path-containment guard (adversarial-audit wave; hardened per audit
|
|
@@ -12654,7 +14378,7 @@ function fixVerified(edit, fixedText, originalText) {
|
|
|
12654
14378
|
}
|
|
12655
14379
|
function renderFixReport(results, dryRun) {
|
|
12656
14380
|
const lines = [];
|
|
12657
|
-
lines.push(dryRun ? "
|
|
14381
|
+
lines.push(sectionHeader(dryRun ? "FIX PLAN (dry-run)" : "FIX REPORT", ui$4));
|
|
12658
14382
|
lines.push("");
|
|
12659
14383
|
if (results.length === 0) {
|
|
12660
14384
|
lines.push("No safe auto-fixes available for these findings.");
|
|
@@ -12662,7 +14386,7 @@ function renderFixReport(results, dryRun) {
|
|
|
12662
14386
|
return lines.join("\n");
|
|
12663
14387
|
}
|
|
12664
14388
|
for (const r of results) {
|
|
12665
|
-
const icon = r.status === "applied" ?
|
|
14389
|
+
const icon = r.status === "applied" ? okIcon(ui$4) : r.status === "planned" ? "▸" : r.status === "failed" ? "✗" : "·";
|
|
12666
14390
|
lines.push(`${icon} [${r.ruleId}] ${r.file}:${r.line} — ${r.description}`);
|
|
12667
14391
|
}
|
|
12668
14392
|
const applied = results.filter((r) => r.status === "applied").length;
|
|
@@ -12722,6 +14446,7 @@ function isProvisional(rule) {
|
|
|
12722
14446
|
*
|
|
12723
14447
|
* Exit codes reuse the frozen set: 0 healthy · 1 violations · 20 crash.
|
|
12724
14448
|
*/
|
|
14449
|
+
const ui$3 = plainContext();
|
|
12725
14450
|
const VALID_ID = /^QA-(?:TEST|TQUAL|PW|CI|PY|ENV|JV|CS|CYP|SE|WDIO|PPTR|APM)-\d{3}$/;
|
|
12726
14451
|
function nonHiddenFiles(dir) {
|
|
12727
14452
|
if (!existsSync(dir)) return [];
|
|
@@ -12920,7 +14645,7 @@ function runDoctorSelfAudit(fixturesRoot) {
|
|
|
12920
14645
|
function renderDoctorReport(report) {
|
|
12921
14646
|
const lines = [
|
|
12922
14647
|
"",
|
|
12923
|
-
"
|
|
14648
|
+
sectionHeader("MJÖLNIR — SELF-AUDIT", ui$3),
|
|
12924
14649
|
""
|
|
12925
14650
|
];
|
|
12926
14651
|
for (const c of report.checks) {
|
|
@@ -13030,6 +14755,7 @@ function firstFixtureFile(dir) {
|
|
|
13030
14755
|
* must-fire fixture, so the example shown is real detector output, not
|
|
13031
14756
|
* hand-written prose that can drift from what the rule actually does.
|
|
13032
14757
|
*/
|
|
14758
|
+
const ui$2 = plainContext();
|
|
13033
14759
|
/**
|
|
13034
14760
|
* Runs the rule against its own must-fire fixture to get one real,
|
|
13035
14761
|
* concrete example finding. `fixturesRoot` defaults to this repo's own
|
|
@@ -13102,7 +14828,7 @@ function renderExplain(result) {
|
|
|
13102
14828
|
const r = result.rule;
|
|
13103
14829
|
const evidenceLevel = r.evidenceLevel ?? deriveEvidenceLevel(r.findingType, r.confidence);
|
|
13104
14830
|
const lines = [];
|
|
13105
|
-
lines.push(
|
|
14831
|
+
lines.push(sectionHeader(`${r.id} — ${r.title}`, ui$2));
|
|
13106
14832
|
lines.push("");
|
|
13107
14833
|
lines.push(`Severity: ${r.severity}`);
|
|
13108
14834
|
lines.push(`Confidence: ${r.confidence}`);
|
|
@@ -13229,6 +14955,7 @@ function applySeverityOverrides(findings, cfg) {
|
|
|
13229
14955
|
* Suppression governance (Sprint-Plan W7, Product-MVP §12).
|
|
13230
14956
|
* `mjolnir suppressions` — every suppression stays visible.
|
|
13231
14957
|
*/
|
|
14958
|
+
const ui$1 = plainContext();
|
|
13232
14959
|
function loadSuppressions(root) {
|
|
13233
14960
|
const { config, path } = loadConfig(root);
|
|
13234
14961
|
const anchor = path ? statSync(path).mtime : void 0;
|
|
@@ -13247,7 +14974,7 @@ function renderSuppressions(report) {
|
|
|
13247
14974
|
if (report.total === 0) return "\nNo suppressed findings. Full transparency maintained.\n";
|
|
13248
14975
|
const lines = [
|
|
13249
14976
|
"",
|
|
13250
|
-
"
|
|
14977
|
+
sectionHeader("QUALITY GOVERNANCE", ui$1),
|
|
13251
14978
|
"",
|
|
13252
14979
|
`Suppressed findings: ${report.total}`,
|
|
13253
14980
|
`Active: ${report.active}`,
|
|
@@ -13637,6 +15364,7 @@ const LOCATOR_RISK = {
|
|
|
13637
15364
|
* OK — data-testid attribute selectors
|
|
13638
15365
|
* BAD — CSS class chains, structural selectors, XPath (brittle)
|
|
13639
15366
|
*/
|
|
15367
|
+
const ui = plainContext();
|
|
13640
15368
|
function classifyLocator(line) {
|
|
13641
15369
|
if (!/locator|getBy|\$x/.test(line)) return null;
|
|
13642
15370
|
if (/getBy(?:Role|Text|Label|Placeholder|AltText|Title|TestId)\s*\(/.test(line)) return "role-based";
|
|
@@ -13713,7 +15441,7 @@ function computeSpecHealth(file, lines) {
|
|
|
13713
15441
|
function renderSelectorHealth(specs) {
|
|
13714
15442
|
const lines = [
|
|
13715
15443
|
"",
|
|
13716
|
-
"
|
|
15444
|
+
sectionHeader("SELECTOR HEALTH", ui),
|
|
13717
15445
|
""
|
|
13718
15446
|
];
|
|
13719
15447
|
for (const spec of specs) {
|
|
@@ -13768,7 +15496,7 @@ function computeSelectorHealth(root, ignoreMatcher = DEFAULT_IGNORE_MATCHER) {
|
|
|
13768
15496
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
13769
15497
|
* `tests/version-consistency.spec.ts` locally.
|
|
13770
15498
|
*/
|
|
13771
|
-
const CLI_VERSION = "0.5.
|
|
15499
|
+
const CLI_VERSION = "0.5.4";
|
|
13772
15500
|
const UNIVERSAL_RULES = RULES.map(asUniversal);
|
|
13773
15501
|
/** Registered rule IDs — used to warn on unknown severityOverrides keys (M4). */
|
|
13774
15502
|
const KNOWN_RULE_IDS = new Set(RULES.map((r) => r.id));
|
|
@@ -13823,7 +15551,7 @@ async function buildUniversalRules(root, strict) {
|
|
|
13823
15551
|
* Built from the registry so the scorer never has to import it.
|
|
13824
15552
|
*/
|
|
13825
15553
|
const SUITE_INVALIDATING_RULE_IDS = new Set(RULES.filter((r) => r.suiteInvalidating === true).map((r) => r.id));
|
|
13826
|
-
function parseArgs(argv) {
|
|
15554
|
+
function parseArgs(argv, onError) {
|
|
13827
15555
|
const args = {
|
|
13828
15556
|
target: ".",
|
|
13829
15557
|
json: false,
|
|
@@ -13832,6 +15560,10 @@ function parseArgs(argv) {
|
|
|
13832
15560
|
scopeChanged: false,
|
|
13833
15561
|
format: "terminal"
|
|
13834
15562
|
};
|
|
15563
|
+
const reject = (detail) => {
|
|
15564
|
+
onError?.(detail);
|
|
15565
|
+
return null;
|
|
15566
|
+
};
|
|
13835
15567
|
for (let i = 0; i < argv.length; i++) {
|
|
13836
15568
|
const a = argv[i] ?? "";
|
|
13837
15569
|
if (a === "--json") {
|
|
@@ -13844,11 +15576,18 @@ function parseArgs(argv) {
|
|
|
13844
15576
|
else if (fmt === "json") {
|
|
13845
15577
|
args.format = "json";
|
|
13846
15578
|
args.json = true;
|
|
13847
|
-
} else if (fmt !== "terminal") return
|
|
15579
|
+
} else if (fmt !== "terminal") return reject({
|
|
15580
|
+
flag: "--format",
|
|
15581
|
+
token: fmt
|
|
15582
|
+
});
|
|
13848
15583
|
} else if (a === "--verbose") args.verbose = true;
|
|
13849
15584
|
else if (a === "--scope") {
|
|
13850
|
-
|
|
13851
|
-
|
|
15585
|
+
const mode = argv[++i];
|
|
15586
|
+
if (mode === "changed") args.scopeChanged = true;
|
|
15587
|
+
else return reject({
|
|
15588
|
+
flag: "--scope",
|
|
15589
|
+
token: mode
|
|
15590
|
+
});
|
|
13852
15591
|
} else if (a === "--base") {
|
|
13853
15592
|
const ref = argv[++i];
|
|
13854
15593
|
if (!ref || ref.startsWith("-")) return null;
|
|
@@ -13864,16 +15603,115 @@ function parseArgs(argv) {
|
|
|
13864
15603
|
} else if (a === "--ascii") args.ascii = true;
|
|
13865
15604
|
else if (a === "--no-ascii") args.ascii = false;
|
|
13866
15605
|
else if (a === "--tone") {
|
|
13867
|
-
|
|
13868
|
-
|
|
15606
|
+
const tone = argv[++i];
|
|
15607
|
+
if (tone === "blunt") args.tone = "blunt";
|
|
15608
|
+
else return reject({
|
|
15609
|
+
flag: "--tone",
|
|
15610
|
+
token: tone
|
|
15611
|
+
});
|
|
13869
15612
|
} else if (a === "--strict") args.strict = true;
|
|
13870
15613
|
else if (a === "--debug") args.debug = true;
|
|
13871
15614
|
else if (a === "--record-milestones") args.recordMilestones = true;
|
|
13872
15615
|
else if (a === "--cache") args.cache = true;
|
|
13873
|
-
else if (a === "--
|
|
15616
|
+
else if (a === "--no-progress") args.noProgress = true;
|
|
15617
|
+
else if (a === "--category") {
|
|
15618
|
+
const cat = argv[++i];
|
|
15619
|
+
if (cat === void 0 || !RULE_CATEGORIES.includes(cat)) return reject({
|
|
15620
|
+
flag: "--category",
|
|
15621
|
+
token: cat
|
|
15622
|
+
});
|
|
15623
|
+
args.categories = [...args.categories ?? [], cat];
|
|
15624
|
+
} else if (a === "--score") args.scoreOnly = true;
|
|
15625
|
+
else if (a === "--staged") args.staged = true;
|
|
15626
|
+
else if (a === "--blocking") {
|
|
15627
|
+
const level = argv[++i];
|
|
15628
|
+
if (level === "error" || level === "warning" || level === "none") args.blocking = level;
|
|
15629
|
+
else return reject({
|
|
15630
|
+
flag: "--blocking",
|
|
15631
|
+
token: level
|
|
15632
|
+
});
|
|
15633
|
+
} else if (a === "--help" || a === "-h") return null;
|
|
13874
15634
|
else if (!a.startsWith("-")) args.target = a;
|
|
13875
|
-
else return
|
|
15635
|
+
else return reject({ token: a });
|
|
15636
|
+
}
|
|
15637
|
+
return args;
|
|
15638
|
+
}
|
|
15639
|
+
/** Scan flags that exist — the "did you mean" candidate pool. */
|
|
15640
|
+
const KNOWN_SCAN_FLAGS = [
|
|
15641
|
+
"--json",
|
|
15642
|
+
"--format",
|
|
15643
|
+
"--verbose",
|
|
15644
|
+
"--scope",
|
|
15645
|
+
"--base",
|
|
15646
|
+
"--max-duration",
|
|
15647
|
+
"--width",
|
|
15648
|
+
"--ascii",
|
|
15649
|
+
"--no-ascii",
|
|
15650
|
+
"--tone",
|
|
15651
|
+
"--strict",
|
|
15652
|
+
"--debug",
|
|
15653
|
+
"--record-milestones",
|
|
15654
|
+
"--cache",
|
|
15655
|
+
"--help",
|
|
15656
|
+
"-h",
|
|
15657
|
+
"--version",
|
|
15658
|
+
"-v",
|
|
15659
|
+
"--dry-run"
|
|
15660
|
+
];
|
|
15661
|
+
/** Hand-rolled Levenshtein distance (plan M2: no new dependencies). */
|
|
15662
|
+
function levenshtein(a, b) {
|
|
15663
|
+
if (a === b) return 0;
|
|
15664
|
+
if (a.length === 0) return b.length;
|
|
15665
|
+
if (b.length === 0) return a.length;
|
|
15666
|
+
const memo = /* @__PURE__ */ new Map();
|
|
15667
|
+
const walk = (i, j) => {
|
|
15668
|
+
if (i === a.length) return b.length - j;
|
|
15669
|
+
if (j === b.length) return a.length - i;
|
|
15670
|
+
const key = `${i}:${j}`;
|
|
15671
|
+
const hit = memo.get(key);
|
|
15672
|
+
if (hit !== void 0) return hit;
|
|
15673
|
+
const cost = a[i] === b[j] ? 0 : 1;
|
|
15674
|
+
const best = Math.min(walk(i + 1, j) + 1, walk(i, j + 1) + 1, walk(i + 1, j + 1) + cost);
|
|
15675
|
+
memo.set(key, best);
|
|
15676
|
+
return best;
|
|
15677
|
+
};
|
|
15678
|
+
return walk(0, 0);
|
|
15679
|
+
}
|
|
15680
|
+
/** Nearest known flags within distance ≤ 2, nearest first. */
|
|
15681
|
+
function nearestFlags(flag, max = 3) {
|
|
15682
|
+
return KNOWN_SCAN_FLAGS.map((f) => ({
|
|
15683
|
+
f,
|
|
15684
|
+
d: levenshtein(flag, f)
|
|
15685
|
+
})).filter((x) => x.d <= 2).sort((x, y) => x.d - y.d).slice(0, max).map((x) => x.f);
|
|
15686
|
+
}
|
|
15687
|
+
/**
|
|
15688
|
+
* Friendly usage error (plan M2, exit 10 preserved): nearest-flag
|
|
15689
|
+
* suggestion, the valid neighbors, and the exact help command. Printed
|
|
15690
|
+
* to stderr; findings/usage stay on their documented streams.
|
|
15691
|
+
*/
|
|
15692
|
+
function usageErrorMessage(detail) {
|
|
15693
|
+
const lines = [];
|
|
15694
|
+
if (detail.flag) lines.push(`mjolnir: invalid value "${detail.token ?? ""}" for ${detail.flag}`);
|
|
15695
|
+
else lines.push(`mjolnir: unknown flag "${detail.token ?? ""}"`);
|
|
15696
|
+
if (detail.token) {
|
|
15697
|
+
const near = nearestFlags(detail.token);
|
|
15698
|
+
if (near.length > 0) lines.push(` Did you mean: ${near.join(" ")}`);
|
|
13876
15699
|
}
|
|
15700
|
+
lines.push(` Run mjolnir --help for the full flag list.`);
|
|
15701
|
+
return lines.join("\n");
|
|
15702
|
+
}
|
|
15703
|
+
/**
|
|
15704
|
+
* Shared parse-or-report path for scan-backed subcommands: friendly
|
|
15705
|
+
* usage errors on stderr (exit 10), the full overview only for an
|
|
15706
|
+
* explicit help flag. Returns null when the caller must exit 10.
|
|
15707
|
+
*/
|
|
15708
|
+
function parseArgsOrUsage(argv, io) {
|
|
15709
|
+
let reported = false;
|
|
15710
|
+
const args = parseArgs(argv, (detail) => {
|
|
15711
|
+
reported = true;
|
|
15712
|
+
io.err(usageErrorMessage(detail));
|
|
15713
|
+
});
|
|
15714
|
+
if (!args && !reported) printUsage(io.out);
|
|
13877
15715
|
return args;
|
|
13878
15716
|
}
|
|
13879
15717
|
/**
|
|
@@ -13972,6 +15810,22 @@ async function runScan(args, hooks = {}) {
|
|
|
13972
15810
|
testFiles: wfBucket
|
|
13973
15811
|
});
|
|
13974
15812
|
ctx.testFiles.push(...wfBucket);
|
|
15813
|
+
let stagedSurface = false;
|
|
15814
|
+
if (args.staged) {
|
|
15815
|
+
const staged = computeStagedFiles(scanRoot.root);
|
|
15816
|
+
if (staged === null) hooks.onConfigWarning?.("mjolnir: --staged ignored — not a git repository (scanning the full surface).");
|
|
15817
|
+
else {
|
|
15818
|
+
const stagedSet = new Set(staged.map((s) => s.replace(/\\/g, "/")));
|
|
15819
|
+
ctx.testFiles = ctx.testFiles.filter((f) => stagedSet.has(relative(scanRoot.root, f).replace(/\\/g, "/")));
|
|
15820
|
+
stagedSurface = true;
|
|
15821
|
+
if (ctx.testFiles.length === 0) hooks.onConfigWarning?.("mjolnir: --staged — no staged files match the scan surface.");
|
|
15822
|
+
}
|
|
15823
|
+
}
|
|
15824
|
+
hooks.onProgress?.({
|
|
15825
|
+
phase: "discover",
|
|
15826
|
+
done: ctx.testFiles.length,
|
|
15827
|
+
total: ctx.testFiles.length
|
|
15828
|
+
});
|
|
13975
15829
|
let scanned = 0;
|
|
13976
15830
|
for (const path of ctx.testFiles) {
|
|
13977
15831
|
if (Date.now() > deadline) {
|
|
@@ -14010,6 +15864,12 @@ async function runScan(args, hooks = {}) {
|
|
|
14010
15864
|
for (const f of cachedFindings) findings.push(f);
|
|
14011
15865
|
continue;
|
|
14012
15866
|
}
|
|
15867
|
+
hooks.onProgress?.({
|
|
15868
|
+
phase: "parse",
|
|
15869
|
+
done: scanned,
|
|
15870
|
+
total: ctx.testFiles.length,
|
|
15871
|
+
detail: relPath
|
|
15872
|
+
});
|
|
14013
15873
|
const adapter = isWorkflow ? githubActionsAdapter : isPython ? pythonAdapter : isJava ? javaAdapter : isCs ? csharpAdapter : typescriptAdapter;
|
|
14014
15874
|
const parsedFile = {
|
|
14015
15875
|
path: relPath,
|
|
@@ -14018,7 +15878,15 @@ async function runScan(args, hooks = {}) {
|
|
|
14018
15878
|
let parsed;
|
|
14019
15879
|
const findingsStart = findings.length;
|
|
14020
15880
|
try {
|
|
14021
|
-
if (adapter.parseAst && Date.now() <= deadline)
|
|
15881
|
+
if (adapter.parseAst && Date.now() <= deadline) {
|
|
15882
|
+
hooks.onProgress?.({
|
|
15883
|
+
phase: "rules",
|
|
15884
|
+
done: scanned,
|
|
15885
|
+
total: ctx.testFiles.length,
|
|
15886
|
+
detail: relPath
|
|
15887
|
+
});
|
|
15888
|
+
parsed = await adapter.parseAst(parsedFile);
|
|
15889
|
+
}
|
|
14022
15890
|
const fileForRules = parsed ? {
|
|
14023
15891
|
...parsedFile,
|
|
14024
15892
|
ast: parsed.ast
|
|
@@ -14091,6 +15959,10 @@ async function runScan(args, hooks = {}) {
|
|
|
14091
15959
|
if (runtimeReportPath) try {
|
|
14092
15960
|
stampRuntimeCorroboration(findings, runForensics(runtimeReportPath, { writeFlakyMd: false }).report);
|
|
14093
15961
|
} catch {}
|
|
15962
|
+
hooks.onProgress?.({
|
|
15963
|
+
phase: "score",
|
|
15964
|
+
done: findings.length
|
|
15965
|
+
});
|
|
14094
15966
|
const dimensions = computeDimensions(findings);
|
|
14095
15967
|
const rawDeductions = findings.reduce((sum, f) => sum + deductionFor(f), 0);
|
|
14096
15968
|
const total = computeTotal(dimensions, findings, {
|
|
@@ -14100,8 +15972,10 @@ async function runScan(args, hooks = {}) {
|
|
|
14100
15972
|
});
|
|
14101
15973
|
const elapsed = Date.now() - started;
|
|
14102
15974
|
const hasTests = testFileCount > 0;
|
|
15975
|
+
for (const f of findings) f.fixGroupId = f.ruleId;
|
|
14103
15976
|
const result = {
|
|
14104
15977
|
schemaVersion: 1,
|
|
15978
|
+
...stagedSurface ? { staged: { files: testFileCount } } : {},
|
|
14105
15979
|
partial: discoveryTruncated || rulesPartial || skippedFiles > 0,
|
|
14106
15980
|
score: hasTests ? total : null,
|
|
14107
15981
|
...hasTests ? {} : { reason: "no-tests-found" },
|
|
@@ -14231,7 +16105,7 @@ function runForensicsCommand(argv, io = {
|
|
|
14231
16105
|
}
|
|
14232
16106
|
return report.flakyTests > 0 || report.failed > 0 ? 1 : 0;
|
|
14233
16107
|
} catch (err) {
|
|
14234
|
-
io.err
|
|
16108
|
+
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
14235
16109
|
return 20;
|
|
14236
16110
|
}
|
|
14237
16111
|
}
|
|
@@ -14285,7 +16159,7 @@ function runDoctorCommand(argv, io = {
|
|
|
14285
16159
|
io.out(renderDoctorReport(report));
|
|
14286
16160
|
return report.healthy ? 0 : 1;
|
|
14287
16161
|
} catch (err) {
|
|
14288
|
-
io.err
|
|
16162
|
+
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
14289
16163
|
return 20;
|
|
14290
16164
|
}
|
|
14291
16165
|
}
|
|
@@ -14331,7 +16205,7 @@ function runExplainCommand(argv, io = {
|
|
|
14331
16205
|
if (!result.ok) return 10;
|
|
14332
16206
|
return 0;
|
|
14333
16207
|
} catch (err) {
|
|
14334
|
-
io.err
|
|
16208
|
+
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
14335
16209
|
return 20;
|
|
14336
16210
|
}
|
|
14337
16211
|
}
|
|
@@ -14371,37 +16245,54 @@ async function runScanCommand(argv, io = {
|
|
|
14371
16245
|
out,
|
|
14372
16246
|
err
|
|
14373
16247
|
}) {
|
|
14374
|
-
const args =
|
|
14375
|
-
if (!args)
|
|
14376
|
-
printUsage(io.out);
|
|
14377
|
-
return 10;
|
|
14378
|
-
}
|
|
16248
|
+
const args = parseArgsOrUsage(argv, io);
|
|
16249
|
+
if (!args) return 10;
|
|
14379
16250
|
const target = resolve(args.target);
|
|
14380
16251
|
const invalid = validateScanTarget(target, io.err);
|
|
14381
16252
|
if (invalid !== null) return invalid;
|
|
14382
16253
|
try {
|
|
14383
16254
|
const crashLog = [];
|
|
16255
|
+
const progress = new ProgressRenderer({
|
|
16256
|
+
stream: process$1.stderr,
|
|
16257
|
+
isTTY: shouldRenderProgress({
|
|
16258
|
+
isTTY: process$1.stderr.isTTY === true,
|
|
16259
|
+
noProgress: args.noProgress === true,
|
|
16260
|
+
machineFormat: args.format !== "terminal",
|
|
16261
|
+
env: process$1.env
|
|
16262
|
+
})
|
|
16263
|
+
});
|
|
14384
16264
|
const result = await runScan({
|
|
14385
16265
|
...args,
|
|
14386
16266
|
target
|
|
14387
16267
|
}, {
|
|
14388
16268
|
onConfigWarning: (message) => io.err(message),
|
|
16269
|
+
onProgress: (e) => progress.onEvent(e),
|
|
14389
16270
|
...args.debug ? { onRuleCrash: (ruleId, file, error) => {
|
|
14390
16271
|
crashLog.push(`${ruleId} crashed on ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
14391
16272
|
} } : {}
|
|
14392
16273
|
});
|
|
16274
|
+
progress.done();
|
|
14393
16275
|
if (args.debug && crashLog.length > 0) {
|
|
14394
16276
|
io.err(`debug: ${crashLog.length} rule crash(es) were swallowed by crash isolation:`);
|
|
14395
16277
|
for (const line of crashLog.slice(0, 50)) io.err(` ${line}`);
|
|
14396
16278
|
if (crashLog.length > 50) io.err(` … and ${crashLog.length - 50} more`);
|
|
14397
16279
|
}
|
|
16280
|
+
if (args.scoreOnly) {
|
|
16281
|
+
if (args.json) io.err("--score overrides --json; stdout is the bare score.");
|
|
16282
|
+
const { config: scoreConfig } = loadConfig(target, { knownRuleIds: KNOWN_RULE_IDS });
|
|
16283
|
+
io.out(result.score === null ? "unknown" : String(result.score));
|
|
16284
|
+
return exitForFindings(result.findings, args.blocking === "none" ? "advisory" : args.blocking ?? scoreConfig.gate ?? "error");
|
|
16285
|
+
}
|
|
14398
16286
|
if (args.format === "sarif") io.out(renderSarif(result));
|
|
14399
16287
|
else if (args.format === "mermaid") io.out(renderMermaid(result));
|
|
14400
16288
|
else if (args.json) io.out(JSON.stringify(result, null, 2));
|
|
14401
16289
|
else {
|
|
16290
|
+
const categories = args.categories;
|
|
16291
|
+
const visible = categories && categories.length > 0 ? result.findings.filter((f) => categories.includes(f.category)) : result.findings;
|
|
14402
16292
|
io.out(renderTerminal(result, {
|
|
14403
16293
|
isTTY: process$1.stdout.isTTY ?? false,
|
|
14404
16294
|
verbose: args.verbose,
|
|
16295
|
+
...categories && categories.length > 0 ? { visibleFindings: visible } : {},
|
|
14405
16296
|
...args.width !== void 0 ? { width: args.width } : {},
|
|
14406
16297
|
...args.ascii !== void 0 ? { ascii: args.ascii } : {},
|
|
14407
16298
|
...args.tone !== void 0 ? { tone: args.tone } : {}
|
|
@@ -14419,13 +16310,13 @@ async function runScanCommand(argv, io = {
|
|
|
14419
16310
|
if (result.partial) return 2;
|
|
14420
16311
|
const { config, warnings } = loadConfig(target, { knownRuleIds: KNOWN_RULE_IDS });
|
|
14421
16312
|
for (const w of warnings) io.err(w);
|
|
14422
|
-
return exitForFindings(result.findings, config.gate ?? "error");
|
|
16313
|
+
return exitForFindings(result.findings, args.blocking === "none" ? "advisory" : args.blocking ?? config.gate ?? "error");
|
|
14423
16314
|
} catch (err) {
|
|
14424
16315
|
if (err instanceof ConfigValidationError) {
|
|
14425
16316
|
io.err(err.message);
|
|
14426
16317
|
return 10;
|
|
14427
16318
|
}
|
|
14428
|
-
io.err
|
|
16319
|
+
internalErrorMessage(err, io.err, args?.debug === true);
|
|
14429
16320
|
return 20;
|
|
14430
16321
|
}
|
|
14431
16322
|
}
|
|
@@ -14464,7 +16355,7 @@ function runTriageCommand(argv, io = {
|
|
|
14464
16355
|
}
|
|
14465
16356
|
return 0;
|
|
14466
16357
|
} catch (err) {
|
|
14467
|
-
io.err
|
|
16358
|
+
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
14468
16359
|
return 20;
|
|
14469
16360
|
}
|
|
14470
16361
|
}
|
|
@@ -14473,11 +16364,8 @@ async function runBadgeCommand(argv, io = {
|
|
|
14473
16364
|
out,
|
|
14474
16365
|
err
|
|
14475
16366
|
}) {
|
|
14476
|
-
const args =
|
|
14477
|
-
if (!args)
|
|
14478
|
-
printUsage(io.out);
|
|
14479
|
-
return 10;
|
|
14480
|
-
}
|
|
16367
|
+
const args = parseArgsOrUsage(argv, io);
|
|
16368
|
+
if (!args) return 10;
|
|
14481
16369
|
try {
|
|
14482
16370
|
const target = resolve(args.target);
|
|
14483
16371
|
const invalid = validateScanTarget(target, io.err);
|
|
@@ -14492,7 +16380,7 @@ async function runBadgeCommand(argv, io = {
|
|
|
14492
16380
|
io.out(renderBadgeSnippet(result));
|
|
14493
16381
|
return 0;
|
|
14494
16382
|
} catch (err) {
|
|
14495
|
-
io.err
|
|
16383
|
+
internalErrorMessage(err, io.err, args?.debug === true);
|
|
14496
16384
|
return 20;
|
|
14497
16385
|
}
|
|
14498
16386
|
}
|
|
@@ -14501,11 +16389,8 @@ async function runDebtCommand(argv, io = {
|
|
|
14501
16389
|
out,
|
|
14502
16390
|
err
|
|
14503
16391
|
}) {
|
|
14504
|
-
const args =
|
|
14505
|
-
if (!args)
|
|
14506
|
-
printUsage(io.out);
|
|
14507
|
-
return 10;
|
|
14508
|
-
}
|
|
16392
|
+
const args = parseArgsOrUsage(argv, io);
|
|
16393
|
+
if (!args) return 10;
|
|
14509
16394
|
try {
|
|
14510
16395
|
const target = resolve(args.target);
|
|
14511
16396
|
const invalid = validateScanTarget(target, io.err);
|
|
@@ -14517,7 +16402,7 @@ async function runDebtCommand(argv, io = {
|
|
|
14517
16402
|
io.out(renderDebt(result));
|
|
14518
16403
|
return 0;
|
|
14519
16404
|
} catch (err) {
|
|
14520
|
-
io.err
|
|
16405
|
+
internalErrorMessage(err, io.err, args?.debug === true);
|
|
14521
16406
|
return 20;
|
|
14522
16407
|
}
|
|
14523
16408
|
}
|
|
@@ -14527,11 +16412,8 @@ async function runFixCommand(argv, io = {
|
|
|
14527
16412
|
err
|
|
14528
16413
|
}) {
|
|
14529
16414
|
const dryRun = argv.includes("--dry-run");
|
|
14530
|
-
const args =
|
|
14531
|
-
if (!args)
|
|
14532
|
-
printUsage(io.out);
|
|
14533
|
-
return 10;
|
|
14534
|
-
}
|
|
16415
|
+
const args = parseArgsOrUsage(argv.filter((a) => a !== "--dry-run"), io);
|
|
16416
|
+
if (!args) return 10;
|
|
14535
16417
|
try {
|
|
14536
16418
|
const target = resolve(args.target);
|
|
14537
16419
|
const invalid = validateScanTarget(target, io.err);
|
|
@@ -14544,7 +16426,7 @@ async function runFixCommand(argv, io = {
|
|
|
14544
16426
|
io.out(renderFixReport(fixes, dryRun));
|
|
14545
16427
|
return fixes.some((f) => f.status === "failed") ? 1 : 0;
|
|
14546
16428
|
} catch (err) {
|
|
14547
|
-
io.err
|
|
16429
|
+
internalErrorMessage(err, io.err, args?.debug === true);
|
|
14548
16430
|
return 20;
|
|
14549
16431
|
}
|
|
14550
16432
|
}
|
|
@@ -14569,7 +16451,7 @@ function runCreateRuleCommand(argv, io = {
|
|
|
14569
16451
|
io.out(renderScaffoldReport(result));
|
|
14570
16452
|
return result.ok ? 0 : 1;
|
|
14571
16453
|
} catch (err) {
|
|
14572
|
-
io.err
|
|
16454
|
+
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
14573
16455
|
return 20;
|
|
14574
16456
|
}
|
|
14575
16457
|
}
|
|
@@ -14600,10 +16482,7 @@ async function runImpactCommand(argv, io = {
|
|
|
14600
16482
|
const sinceIdx = argv.indexOf("--since");
|
|
14601
16483
|
const since = sinceIdx !== -1 ? argv[sinceIdx + 1] : void 0;
|
|
14602
16484
|
const args = parseArgs(sinceIdx === -1 ? argv : argv.filter((_a, i) => i !== sinceIdx && i !== sinceIdx + 1));
|
|
14603
|
-
if (!args)
|
|
14604
|
-
printUsage(io.out);
|
|
14605
|
-
return 10;
|
|
14606
|
-
}
|
|
16485
|
+
if (!args) return 10;
|
|
14607
16486
|
try {
|
|
14608
16487
|
const target = resolve(args.target);
|
|
14609
16488
|
const invalid = validateScanTarget(target, io.err);
|
|
@@ -14618,7 +16497,7 @@ async function runImpactCommand(argv, io = {
|
|
|
14618
16497
|
io.out(renderImpact(report));
|
|
14619
16498
|
return report.hasComparison ? 0 : 2;
|
|
14620
16499
|
} catch (err) {
|
|
14621
|
-
io.err
|
|
16500
|
+
internalErrorMessage(err, io.err, args?.debug === true);
|
|
14622
16501
|
return 20;
|
|
14623
16502
|
}
|
|
14624
16503
|
}
|
|
@@ -14627,11 +16506,8 @@ async function runBaselineCommand(argv, io = {
|
|
|
14627
16506
|
out,
|
|
14628
16507
|
err
|
|
14629
16508
|
}) {
|
|
14630
|
-
const args =
|
|
14631
|
-
if (!args)
|
|
14632
|
-
printUsage(io.out);
|
|
14633
|
-
return 10;
|
|
14634
|
-
}
|
|
16509
|
+
const args = parseArgsOrUsage(argv, io);
|
|
16510
|
+
if (!args) return 10;
|
|
14635
16511
|
try {
|
|
14636
16512
|
const target = resolve(args.target);
|
|
14637
16513
|
const invalid = validateScanTarget(target, io.err);
|
|
@@ -14645,7 +16521,7 @@ async function runBaselineCommand(argv, io = {
|
|
|
14645
16521
|
io.out(renderBaselineSaved(DEFAULT_BASELINE_PATH, result.findings.length, { ...saved.backupPath !== void 0 ? { backupPath: saved.backupPath } : {} }));
|
|
14646
16522
|
return 0;
|
|
14647
16523
|
} catch (err) {
|
|
14648
|
-
io.err
|
|
16524
|
+
internalErrorMessage(err, io.err, args?.debug === true);
|
|
14649
16525
|
return 20;
|
|
14650
16526
|
}
|
|
14651
16527
|
}
|
|
@@ -14654,11 +16530,8 @@ async function runDiffCommand(argv, io = {
|
|
|
14654
16530
|
out,
|
|
14655
16531
|
err
|
|
14656
16532
|
}) {
|
|
14657
|
-
const args =
|
|
14658
|
-
if (!args)
|
|
14659
|
-
printUsage(io.out);
|
|
14660
|
-
return 10;
|
|
14661
|
-
}
|
|
16533
|
+
const args = parseArgsOrUsage(argv, io);
|
|
16534
|
+
if (!args) return 10;
|
|
14662
16535
|
try {
|
|
14663
16536
|
const target = resolve(args.target);
|
|
14664
16537
|
const invalid = validateScanTarget(target, io.err);
|
|
@@ -14683,7 +16556,7 @@ async function runDiffCommand(argv, io = {
|
|
|
14683
16556
|
if (!diff.hasBaseline) return 2;
|
|
14684
16557
|
return diff.newFindings.some((f) => f.severity === "error") ? 1 : 0;
|
|
14685
16558
|
} catch (err) {
|
|
14686
|
-
io.err
|
|
16559
|
+
internalErrorMessage(err, io.err, args?.debug === true);
|
|
14687
16560
|
return 20;
|
|
14688
16561
|
}
|
|
14689
16562
|
}
|
|
@@ -14692,11 +16565,8 @@ async function runPrCommentCommand(argv, io = {
|
|
|
14692
16565
|
out,
|
|
14693
16566
|
err
|
|
14694
16567
|
}) {
|
|
14695
|
-
const args =
|
|
14696
|
-
if (!args)
|
|
14697
|
-
printUsage(io.out);
|
|
14698
|
-
return 10;
|
|
14699
|
-
}
|
|
16568
|
+
const args = parseArgsOrUsage(argv, io);
|
|
16569
|
+
if (!args) return 10;
|
|
14700
16570
|
try {
|
|
14701
16571
|
const target = resolve(args.target);
|
|
14702
16572
|
const invalid = validateScanTarget(target, io.err);
|
|
@@ -14707,10 +16577,13 @@ async function runPrCommentCommand(argv, io = {
|
|
|
14707
16577
|
});
|
|
14708
16578
|
const baseline = loadBaseline(join(target, DEFAULT_BASELINE_PATH));
|
|
14709
16579
|
const diff = baseline ? diffAgainstBaseline(result, baseline) : void 0;
|
|
14710
|
-
io.out(renderPrComment(result,
|
|
16580
|
+
io.out(renderPrComment(result, {
|
|
16581
|
+
...diff ? { diff } : {},
|
|
16582
|
+
version: CLI_VERSION
|
|
16583
|
+
}));
|
|
14711
16584
|
return 0;
|
|
14712
16585
|
} catch (err) {
|
|
14713
|
-
io.err
|
|
16586
|
+
internalErrorMessage(err, io.err, args?.debug === true);
|
|
14714
16587
|
return 20;
|
|
14715
16588
|
}
|
|
14716
16589
|
}
|
|
@@ -14726,7 +16599,7 @@ function runStatsCommand(argv, io = {
|
|
|
14726
16599
|
io.out(renderStats(stats));
|
|
14727
16600
|
return 0;
|
|
14728
16601
|
} catch (err) {
|
|
14729
|
-
io.err
|
|
16602
|
+
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
14730
16603
|
return 20;
|
|
14731
16604
|
}
|
|
14732
16605
|
}
|
|
@@ -14735,11 +16608,8 @@ async function runHandoverCommand(argv, io = {
|
|
|
14735
16608
|
out,
|
|
14736
16609
|
err
|
|
14737
16610
|
}) {
|
|
14738
|
-
const args =
|
|
14739
|
-
if (!args)
|
|
14740
|
-
printUsage(io.out);
|
|
14741
|
-
return 10;
|
|
14742
|
-
}
|
|
16611
|
+
const args = parseArgsOrUsage(argv, io);
|
|
16612
|
+
if (!args) return 10;
|
|
14743
16613
|
try {
|
|
14744
16614
|
const target = resolve(args.target);
|
|
14745
16615
|
const invalid = validateScanTarget(target, io.err);
|
|
@@ -14756,7 +16626,7 @@ async function runHandoverCommand(argv, io = {
|
|
|
14756
16626
|
io.out(renderHandover(buildHandover(result, forensics)));
|
|
14757
16627
|
return 0;
|
|
14758
16628
|
} catch (err) {
|
|
14759
|
-
io.err
|
|
16629
|
+
internalErrorMessage(err, io.err, args?.debug === true);
|
|
14760
16630
|
return 20;
|
|
14761
16631
|
}
|
|
14762
16632
|
}
|
|
@@ -14777,7 +16647,7 @@ function runInitCommand(argv, io = {
|
|
|
14777
16647
|
io.out(renderInit(result));
|
|
14778
16648
|
return 0;
|
|
14779
16649
|
} catch (err) {
|
|
14780
|
-
io.err
|
|
16650
|
+
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
14781
16651
|
return 20;
|
|
14782
16652
|
}
|
|
14783
16653
|
}
|
|
@@ -14800,15 +16670,20 @@ function runPwReportCommand(argv, io = {
|
|
|
14800
16670
|
io.out(renderPwRunSummary(summarizePwRun(report)));
|
|
14801
16671
|
return report.failed > 0 || report.flakyTests > 0 ? 1 : 0;
|
|
14802
16672
|
} catch (err) {
|
|
14803
|
-
io.err
|
|
16673
|
+
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
14804
16674
|
return 20;
|
|
14805
16675
|
}
|
|
14806
16676
|
}
|
|
14807
|
-
async function main(argv = process$1.argv.slice(2)
|
|
16677
|
+
async function main(argv = process$1.argv.slice(2), io = {
|
|
16678
|
+
out,
|
|
16679
|
+
err
|
|
16680
|
+
}) {
|
|
14808
16681
|
if (argv[0] === "--version" || argv[0] === "-v") {
|
|
14809
|
-
out(`mjolnir-qa ${CLI_VERSION}\n`);
|
|
16682
|
+
io.out(`mjolnir-qa ${CLI_VERSION}\n`);
|
|
14810
16683
|
return 0;
|
|
14811
16684
|
}
|
|
16685
|
+
if (argv.length >= 2 && (argv[1] === "--help" || argv[1] === "-h")) return runHelpCommand([argv[0]], io);
|
|
16686
|
+
if (argv[0] === "ci" && argv.length >= 3 && (argv[2] === "--help" || argv[2] === "-h")) return runHelpCommand(["ci", "install"], io);
|
|
14812
16687
|
if (argv[0] === "ci" && argv[1] === "install") return runCiInstall(argv.slice(2));
|
|
14813
16688
|
if (argv[0] === "suppressions") return runSuppressions();
|
|
14814
16689
|
if (argv[0] === "forensics") return runForensicsCommand(argv.slice(1));
|
|
@@ -14819,6 +16694,7 @@ async function main(argv = process$1.argv.slice(2)) {
|
|
|
14819
16694
|
if (argv[0] === "baseline") return runBaselineCommand(argv.slice(1));
|
|
14820
16695
|
if (argv[0] === "diff") return runDiffCommand(argv.slice(1));
|
|
14821
16696
|
if (argv[0] === "pr-comment") return runPrCommentCommand(argv.slice(1));
|
|
16697
|
+
if (argv[0] === "summary") return runSummaryCommand(argv.slice(1), io);
|
|
14822
16698
|
if (argv[0] === "stats") return runStatsCommand(argv.slice(1));
|
|
14823
16699
|
if (argv[0] === "fix") return runFixCommand(argv.slice(1));
|
|
14824
16700
|
if (argv[0] === "create-rule") return runCreateRuleCommand(argv.slice(1));
|
|
@@ -14829,84 +16705,55 @@ async function main(argv = process$1.argv.slice(2)) {
|
|
|
14829
16705
|
if (argv[0] === "rules") return runRulesCommand(argv.slice(1));
|
|
14830
16706
|
if (argv[0] === "explain") return runExplainCommand(argv.slice(1));
|
|
14831
16707
|
if (argv[0] === "doctor:playwright") return runDoctorPlaywright(argv);
|
|
14832
|
-
return
|
|
16708
|
+
if (argv[0] === "why") return runWhyCommand(argv.slice(1), io);
|
|
16709
|
+
if (argv[0] === "handoff") return runHandoffCommand(argv.slice(1), io);
|
|
16710
|
+
if (argv[0] === "install") return runInstallCommand(argv.slice(1), io);
|
|
16711
|
+
if (argv[0] === "help") return runHelpCommand(argv.slice(1), io);
|
|
16712
|
+
return runScanCommand(argv, io);
|
|
16713
|
+
}
|
|
16714
|
+
/**
|
|
16715
|
+
* `mjolnir help` / `mjolnir help <verb>` (plan M2). `--help`/`-h` and
|
|
16716
|
+
* `<verb> --help` route here too. Exit 0 — help answers a question.
|
|
16717
|
+
* Two-word verbs (`ci install`) are resolved first via the join of the
|
|
16718
|
+
* leading non-flag tokens, then the single-word form.
|
|
16719
|
+
*/
|
|
16720
|
+
function runHelpCommand(argv, io = {
|
|
16721
|
+
out,
|
|
16722
|
+
err
|
|
16723
|
+
}) {
|
|
16724
|
+
const tokens = argv.filter((a) => !a.startsWith("-"));
|
|
16725
|
+
if (tokens.length >= 2) {
|
|
16726
|
+
const joined = `${tokens[0]} ${tokens[1]}`;
|
|
16727
|
+
if (hasVerbHelp(joined)) {
|
|
16728
|
+
io.out(renderVerbHelp(joined));
|
|
16729
|
+
return 0;
|
|
16730
|
+
}
|
|
16731
|
+
}
|
|
16732
|
+
if (tokens.length > 0) {
|
|
16733
|
+
io.out(renderVerbHelp(tokens[0]));
|
|
16734
|
+
return 0;
|
|
16735
|
+
}
|
|
16736
|
+
io.out(renderRootHelp(1));
|
|
16737
|
+
return 0;
|
|
14833
16738
|
}
|
|
14834
16739
|
function printUsage(print) {
|
|
14835
|
-
print(
|
|
14836
|
-
|
|
14837
|
-
|
|
14838
|
-
|
|
14839
|
-
|
|
14840
|
-
|
|
14841
|
-
|
|
14842
|
-
|
|
14843
|
-
|
|
14844
|
-
|
|
14845
|
-
|
|
14846
|
-
|
|
14847
|
-
|
|
14848
|
-
|
|
14849
|
-
|
|
14850
|
-
|
|
14851
|
-
|
|
14852
|
-
--json machine-readable output (schemaVersion 1)
|
|
14853
|
-
--format sarif SARIF 2.1 output for GitHub Code Scanning
|
|
14854
|
-
--format mermaid test-architecture diagram (frameworks → rule
|
|
14855
|
-
categories → severity), pastes directly into a
|
|
14856
|
-
GitHub/GitLab markdown comment or a slide
|
|
14857
|
-
--tone blunt blunter, pattern-mocking messages (opt-in)
|
|
14858
|
-
--verbose show all findings
|
|
14859
|
-
--scope changed only findings on new/changed lines vs merge-base
|
|
14860
|
-
--base <ref> base ref for --scope changed (default: main, then
|
|
14861
|
-
master, then origin/HEAD); uncommitted local
|
|
14862
|
-
changes are always included
|
|
14863
|
-
--max-duration <sec> stop analysis after N seconds (partial results flagged)
|
|
14864
|
-
--width <cols> override terminal width for box/gauge wrapping
|
|
14865
|
-
(defaults to the detected terminal width, or 80)
|
|
14866
|
-
--ascii force plain-ASCII glyphs/box-drawing (auto-detected
|
|
14867
|
-
for cmd.exe/legacy consoles; use this to force it
|
|
14868
|
-
anywhere, e.g. an unrecognized CI log renderer)
|
|
14869
|
-
--no-ascii force Unicode box-drawing even where auto-detection
|
|
14870
|
-
would have chosen ASCII
|
|
14871
|
-
--strict include quarantine-tier rules (higher FP risk) in scan
|
|
14872
|
-
--debug print errors swallowed by rule crash isolation
|
|
14873
|
-
(display-only; exit codes unchanged)
|
|
14874
|
-
--record-milestones allow this scan to write .mjolnir/stats.json for
|
|
14875
|
-
milestone tracking (default: scans never write)
|
|
14876
|
-
--cache reuse per-file verdicts from the local cache
|
|
14877
|
-
(.mjolnir/cache/, content-addressed, never leaves
|
|
14878
|
-
this machine); identical results, faster re-scans
|
|
14879
|
-
-v, --version print the installed version and exit
|
|
14880
|
-
-h, --help show this help
|
|
14881
|
-
|
|
14882
|
-
Subcommands — everyday:
|
|
14883
|
-
ci install [--gate advisory|error|warning] [--force]
|
|
14884
|
-
generate the PR workflow; --force overwrites
|
|
14885
|
-
a hand-customized one (default: refuse)
|
|
14886
|
-
explain <RULE-ID> [--fixtures-root <dir>] what/why/fix + measured FP rate
|
|
14887
|
-
rules [--md] [--unmeasured|--measured] [--external] rule catalog with trust metadata
|
|
14888
|
-
suppressions list suppressed findings
|
|
14889
|
-
|
|
14890
|
-
Subcommands — when something's flaky:
|
|
14891
|
-
forensics <dir|file> [--no-flaky-md] runtime evidence: retries, flakes
|
|
14892
|
-
triage <dir|file> [--no-md] flaky-triage proposal + TRIAGE.md
|
|
14893
|
-
pw-report <dir|file> Playwright run summary
|
|
14894
|
-
doctor:playwright Playwright deep scan + Selector Health
|
|
14895
|
-
|
|
14896
|
-
Subcommands — occasional / reporting:
|
|
14897
|
-
fix [--dry-run] apply safe auto-fixes with proof
|
|
14898
|
-
baseline / diff snapshot findings, then new/worsened only
|
|
14899
|
-
impact [--since <ref>] what changed since a prior commit
|
|
14900
|
-
debt test-debt register with a cost model
|
|
14901
|
-
handover new-QA onboarding map of the suite
|
|
14902
|
-
stats all-time local counters of fixes seen
|
|
14903
|
-
badge shields.io endpoint JSON + snippet
|
|
14904
|
-
pr-comment render a scoped PR comment (Markdown)
|
|
14905
|
-
init [--interactive] detect frameworks + setup checklist
|
|
14906
|
-
create-rule <ID> --title "..." scaffold a new rule + fixtures
|
|
14907
|
-
doctor self-audit of the rule base
|
|
14908
|
-
|
|
14909
|
-
Exit codes: 0 clean · 1 errors found · 2 partial · 10 usage · 20 crash`);
|
|
16740
|
+
print(renderRootHelp());
|
|
16741
|
+
}
|
|
16742
|
+
/**
|
|
16743
|
+
* Friendly exit-20 path (plan M2): the crash says it's Mjölnir's bug,
|
|
16744
|
+
* not the user's repo, carries the underlying message for a report, and
|
|
16745
|
+
* prints the stack ONLY when `debug` is set (uniform across
|
|
16746
|
+
* subcommands — they don't parse scan flags). Tests pin
|
|
16747
|
+
* /internal error/i. Exported so the --debug stack arm is directly
|
|
16748
|
+
* spec-coverable (spawning a real crash under --debug would be flaky).
|
|
16749
|
+
*/
|
|
16750
|
+
function internalErrorMessage(err, emit, debug) {
|
|
16751
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
16752
|
+
emit("mjolnir internal error — this is a bug in Mjölnir, not your repo:");
|
|
16753
|
+
emit(` ${message}`);
|
|
16754
|
+
if (debug && err instanceof Error && err.stack) emit(err.stack);
|
|
16755
|
+
emit("Rerun with --debug for the stack trace. Please report this:");
|
|
16756
|
+
emit(" https://github.com/Sergey-Bar/Mjolnir/issues");
|
|
14910
16757
|
}
|
|
14911
16758
|
function isEntryPoint() {
|
|
14912
16759
|
const argv1 = process$1.argv[1];
|
|
@@ -14919,4 +16766,4 @@ function isEntryPoint() {
|
|
|
14919
16766
|
}
|
|
14920
16767
|
if (isEntryPoint()) process$1.exitCode = await main();
|
|
14921
16768
|
//#endregion
|
|
14922
|
-
export { CLI_VERSION, buildUniversalRules, exitForFindings, fallbackWorkspace, isEntryPoint, main, parseArgs, pathMatchesGlob, runBadgeCommand, runBaselineCommand, runCiInstall, runCreateRuleCommand, runDebtCommand, runDiffCommand, runDoctorCommand, runDoctorPlaywright, runExplainCommand, runFixCommand, runForensicsCommand, runHandoverCommand, runImpactCommand, runInitCommand, runPrCommentCommand, runPwReportCommand, runRulesCommand, runScan, runScanCommand, runStatsCommand, runSuppressions, runTriageCommand };
|
|
16769
|
+
export { CLI_VERSION, buildUniversalRules, exitForFindings, fallbackWorkspace, internalErrorMessage, isEntryPoint, levenshtein, main, nearestFlags, parseArgs, pathMatchesGlob, runBadgeCommand, runBaselineCommand, runCiInstall, runCreateRuleCommand, runDebtCommand, runDiffCommand, runDoctorCommand, runDoctorPlaywright, runExplainCommand, runFixCommand, runForensicsCommand, runHandoverCommand, runHelpCommand, runImpactCommand, runInitCommand, runPrCommentCommand, runPwReportCommand, runRulesCommand, runScan, runScanCommand, runStatsCommand, runSuppressions, runTriageCommand, usageErrorMessage };
|