mjolnir-qa 0.5.2 → 0.5.3
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 +125 -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 +61 -4
- package/dist/cli.mjs +1255 -327
- 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";
|
|
@@ -8960,8 +8960,17 @@ const off = {
|
|
|
8960
8960
|
function rgb([r, g, b]) {
|
|
8961
8961
|
return (s) => `\x1b[38;2;${r};${g};${b}m${sanitizeData(s)}\x1b[0m`;
|
|
8962
8962
|
}
|
|
8963
|
-
/**
|
|
8963
|
+
/**
|
|
8964
|
+
* True when colors should be emitted for this render call.
|
|
8965
|
+
*
|
|
8966
|
+
* Precedence (chalk convention): FORCE_COLOR wins over everything —
|
|
8967
|
+
* `FORCE_COLOR=0` (or "false"/empty) forces plain output even on a TTY,
|
|
8968
|
+
* any other value forces color even when piped. Without FORCE_COLOR,
|
|
8969
|
+
* NO_COLOR disables color and the rest follows TTY-ness.
|
|
8970
|
+
*/
|
|
8964
8971
|
function shouldColorize(isTTY) {
|
|
8972
|
+
const forced = process.env["FORCE_COLOR"];
|
|
8973
|
+
if (forced !== void 0) return forced !== "0" && forced !== "false" && forced !== "";
|
|
8965
8974
|
return isTTY && !process.env["NO_COLOR"];
|
|
8966
8975
|
}
|
|
8967
8976
|
function palette(enabled) {
|
|
@@ -9066,23 +9075,90 @@ function gaugeColorForBand(band, p) {
|
|
|
9066
9075
|
if (band === "unmeasured") return p.dim;
|
|
9067
9076
|
return band === "warning" ? p.warning : p.error;
|
|
9068
9077
|
}
|
|
9069
|
-
|
|
9070
|
-
|
|
9071
|
-
|
|
9072
|
-
*
|
|
9073
|
-
|
|
9074
|
-
|
|
9075
|
-
|
|
9076
|
-
|
|
9077
|
-
|
|
9078
|
-
|
|
9078
|
+
//#endregion
|
|
9079
|
+
//#region src/reporter/ui.ts
|
|
9080
|
+
/**
|
|
9081
|
+
* Neutral context for direct calls (tests, library use) and as the
|
|
9082
|
+
* safe default when a command runner is invoked without wiring: no
|
|
9083
|
+
* color, unicode glyphs, 80 columns. cli.ts builds the real context
|
|
9084
|
+
* once per run (TTY, width, ASCII heuristic) and passes it down.
|
|
9085
|
+
*/
|
|
9086
|
+
function plainContext(width = 80) {
|
|
9087
|
+
return {
|
|
9088
|
+
p: palette(false),
|
|
9089
|
+
ascii: false,
|
|
9090
|
+
width
|
|
9091
|
+
};
|
|
9092
|
+
}
|
|
9093
|
+
/** Canonical severity glyphs. Symbols always accompany color (R11). */
|
|
9094
|
+
const SEVERITY_GLYPHS = {
|
|
9095
|
+
unicode: {
|
|
9079
9096
|
error: "✗",
|
|
9080
9097
|
warning: "⚠",
|
|
9081
9098
|
info: "ℹ"
|
|
9082
|
-
}
|
|
9083
|
-
|
|
9084
|
-
|
|
9085
|
-
|
|
9099
|
+
},
|
|
9100
|
+
ascii: {
|
|
9101
|
+
error: "X",
|
|
9102
|
+
warning: "!",
|
|
9103
|
+
info: "i"
|
|
9104
|
+
}
|
|
9105
|
+
};
|
|
9106
|
+
const FLAKE_GLYPH = "🔥";
|
|
9107
|
+
/** Section header: `▚ TITLE` (ascii `= TITLE`). The one header style. */
|
|
9108
|
+
function sectionHeader(title, ui) {
|
|
9109
|
+
const glyph = ui.ascii ? "=" : "▚";
|
|
9110
|
+
return ` ${ui.p.accent(`${glyph} ${title}`)}`;
|
|
9111
|
+
}
|
|
9112
|
+
/** Rounded panel around wrapped text lines, indented two spaces. */
|
|
9113
|
+
function panel(lines, ui, pad = 1) {
|
|
9114
|
+
return box(lines, pad, {
|
|
9115
|
+
maxWidth: ui.width - 2,
|
|
9116
|
+
ascii: ui.ascii
|
|
9117
|
+
}).map((l) => ` ${l}`);
|
|
9118
|
+
}
|
|
9119
|
+
/** Horizontal rule under a section block. */
|
|
9120
|
+
function divider(ui) {
|
|
9121
|
+
return ui.p.dim((ui.ascii ? "-" : "─").repeat(58));
|
|
9122
|
+
}
|
|
9123
|
+
/**
|
|
9124
|
+
* Next-step affordance: dim `$ command` line. The `$` prefix is the
|
|
9125
|
+
* universal "type this in your shell" marker; the command itself is
|
|
9126
|
+
* plain (not dimmed) so it copies cleanly from a terminal.
|
|
9127
|
+
*/
|
|
9128
|
+
function nextStep(command, ui) {
|
|
9129
|
+
return ` ${ui.p.dim("$")} ${command}`;
|
|
9130
|
+
}
|
|
9131
|
+
/** Themed severity icon: glyph + padded label, e.g. `✗ ERROR `. */
|
|
9132
|
+
function severityIcon(severity, ui) {
|
|
9133
|
+
const g = (ui.ascii ? SEVERITY_GLYPHS.ascii : SEVERITY_GLYPHS.unicode)[severity];
|
|
9134
|
+
if (severity === "error") return `${ui.p.error(g)} ${ui.p.error("ERROR ")}`;
|
|
9135
|
+
if (severity === "warning") return `${ui.p.warning(g)} ${ui.p.warning("WARN ")}`;
|
|
9136
|
+
return `${ui.p.info(g)} ${ui.p.info("INFO ")}`;
|
|
9137
|
+
}
|
|
9138
|
+
/** Themed success icon (`✓` / `v` in ascii). */
|
|
9139
|
+
function okIcon(ui) {
|
|
9140
|
+
const g = ui.ascii ? "v" : "✓";
|
|
9141
|
+
return ui.p.ok(g);
|
|
9142
|
+
}
|
|
9143
|
+
/**
|
|
9144
|
+
* Footer builder: `Analysis complete · <duration>ms` + optional
|
|
9145
|
+
* suppressed count + optional next action. `durationMs` is formatted
|
|
9146
|
+
* by formatDuration; `next` is rendered as a `$ command` line.
|
|
9147
|
+
*/
|
|
9148
|
+
function buildFooter(opts) {
|
|
9149
|
+
const { ui } = opts;
|
|
9150
|
+
const lines = [];
|
|
9151
|
+
lines.push(divider(ui));
|
|
9152
|
+
const status = opts.complete ? `${ui.p.ok("complete")} · ${formatDuration(opts.durationMs)}` : `${ui.p.warning("PARTIAL — verdict may be incomplete")} · ${formatDuration(opts.durationMs)}`;
|
|
9153
|
+
lines.push(` Analysis: ${status}`);
|
|
9154
|
+
if (opts.suppressedCount && opts.suppressedCount > 0) lines.push(ui.p.dim(` ${opts.suppressedCount} finding(s) suppressed by config`));
|
|
9155
|
+
if (opts.next) lines.push(nextStep(opts.next, ui));
|
|
9156
|
+
return lines;
|
|
9157
|
+
}
|
|
9158
|
+
/** Human duration: `1.2s` above a second, `850ms` below. */
|
|
9159
|
+
function formatDuration(ms) {
|
|
9160
|
+
if (ms === void 0) return "?";
|
|
9161
|
+
return ms >= 1e3 ? `${(ms / 1e3).toFixed(1)}s` : `${ms}ms`;
|
|
9086
9162
|
}
|
|
9087
9163
|
//#endregion
|
|
9088
9164
|
//#region src/reporter/art.ts
|
|
@@ -9131,8 +9207,7 @@ String.raw`
|
|
|
9131
9207
|
| \IIIIII/ |
|
|
9132
9208
|
\ /
|
|
9133
9209
|
` + "";
|
|
9134
|
-
|
|
9135
|
-
const DIVIDER = "─".repeat(58);
|
|
9210
|
+
"─".repeat(58);
|
|
9136
9211
|
/** ASCII-mode state caption — the text marker that carries the state
|
|
9137
9212
|
* when color is absent (symbols-accompany-color doctrine, R11). */
|
|
9138
9213
|
const HAMMER_CAPTIONS = {
|
|
@@ -9410,23 +9485,28 @@ function renderTerminal(result, opts) {
|
|
|
9410
9485
|
const p = palette(shouldColorize(opts.isTTY));
|
|
9411
9486
|
const width = Math.max(MIN_BOX_WIDTH, opts.width ?? process.stdout.columns ?? 80);
|
|
9412
9487
|
const ascii = opts.ascii ?? shouldUseAscii();
|
|
9488
|
+
const ui = {
|
|
9489
|
+
p,
|
|
9490
|
+
ascii,
|
|
9491
|
+
width
|
|
9492
|
+
};
|
|
9413
9493
|
const lines = [];
|
|
9414
9494
|
const logo = ascii ? LOGO_ASCII : LOGO;
|
|
9415
9495
|
for (const l of logo.split("\n")) if (l.trim()) lines.push(p.accent(l));
|
|
9416
9496
|
lines.push("");
|
|
9417
|
-
if (result.score === null) return renderNoTests(
|
|
9497
|
+
if (result.score === null) return renderNoTests(ui);
|
|
9418
9498
|
const counts = countBySeverity(result);
|
|
9419
9499
|
appendScoreSection(lines, {
|
|
9420
9500
|
...result,
|
|
9421
9501
|
score: result.score
|
|
9422
9502
|
}, p, width, ascii);
|
|
9423
|
-
appendFrameworks(lines, result,
|
|
9424
|
-
appendDimensions(lines, result,
|
|
9425
|
-
appendDeductions(lines, result, counts,
|
|
9426
|
-
appendFixThisFirst(lines, result,
|
|
9427
|
-
appendFindings(lines, result, counts, opts.verbose === true,
|
|
9503
|
+
appendFrameworks(lines, result, ui);
|
|
9504
|
+
appendDimensions(lines, result, ui);
|
|
9505
|
+
appendDeductions(lines, result, counts, ui);
|
|
9506
|
+
appendFixThisFirst(lines, result, ui);
|
|
9507
|
+
appendFindings(lines, result, counts, opts.verbose === true, ui, opts.tone);
|
|
9428
9508
|
if (counts.total === 0 && result.score === 100) appendForgedBlock(lines, p, ascii);
|
|
9429
|
-
appendFooter(lines, result,
|
|
9509
|
+
appendFooter(lines, result, ui);
|
|
9430
9510
|
return lines.join("\n");
|
|
9431
9511
|
}
|
|
9432
9512
|
/**
|
|
@@ -9460,28 +9540,30 @@ function colorizeVerdict(verdict, band, p) {
|
|
|
9460
9540
|
if (band === "warning") return p.warning(verdict);
|
|
9461
9541
|
return p.error(verdict);
|
|
9462
9542
|
}
|
|
9463
|
-
function appendFrameworks(lines, result,
|
|
9543
|
+
function appendFrameworks(lines, result, ui) {
|
|
9544
|
+
const { p } = ui;
|
|
9464
9545
|
if (result.frameworks.length > 0) {
|
|
9465
9546
|
const tags = result.frameworks.map((f) => `[${f}]`).join(" ");
|
|
9466
9547
|
lines.push(` ${p.dim("DETECTED")} ${p.info(tags)}`);
|
|
9467
9548
|
} 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
9549
|
lines.push("");
|
|
9469
9550
|
}
|
|
9470
|
-
function appendDimensions(lines, result,
|
|
9551
|
+
function appendDimensions(lines, result, ui) {
|
|
9471
9552
|
const dims = result.dimensions.length > 0 ? result.dimensions : computeDimensions(result.findings);
|
|
9472
9553
|
if (dims.length === 0) return;
|
|
9473
|
-
lines.push(
|
|
9554
|
+
lines.push(sectionHeader("DIAGNOSTICS BY CATEGORY", ui));
|
|
9474
9555
|
const width = Math.max(...dims.map((d) => d.category.length));
|
|
9475
9556
|
for (const d of dims) {
|
|
9476
9557
|
const label = padTo(d.category, width);
|
|
9477
9558
|
const scoreText = String(d.score).padStart(3);
|
|
9478
|
-
lines.push(` ${label} ${scoreGauge(d.score, p, 16, ascii)} ${scoreText}`);
|
|
9559
|
+
lines.push(` ${label} ${scoreGauge(d.score, ui.p, 16, ui.ascii)} ${scoreText}`);
|
|
9479
9560
|
}
|
|
9480
9561
|
lines.push("");
|
|
9481
9562
|
}
|
|
9482
|
-
function appendDeductions(lines, result, counts,
|
|
9563
|
+
function appendDeductions(lines, result, counts, ui) {
|
|
9564
|
+
const { p } = ui;
|
|
9483
9565
|
if (counts.total === 0) return;
|
|
9484
|
-
lines.push(
|
|
9566
|
+
lines.push(sectionHeader("WHERE POINTS WERE LOST", ui));
|
|
9485
9567
|
const rows = [];
|
|
9486
9568
|
const bySeverity = {
|
|
9487
9569
|
error: {
|
|
@@ -9511,21 +9593,18 @@ function appendDeductions(lines, result, counts, p, width, ascii) {
|
|
|
9511
9593
|
const discounted = s.ded < s.n * DEDUCTIONS[sev];
|
|
9512
9594
|
rows.push(`${s.n} × ${sev.padEnd(7)} −${String(s.ded).padStart(3)}${discounted ? p.dim(" (evidence-discounted)") : ""}`);
|
|
9513
9595
|
}
|
|
9514
|
-
for (const row of
|
|
9515
|
-
maxWidth: width - 2,
|
|
9516
|
-
ascii
|
|
9517
|
-
})) lines.push(` ${row}`);
|
|
9596
|
+
for (const row of panel(rows, ui)) lines.push(row);
|
|
9518
9597
|
lines.push("");
|
|
9519
9598
|
}
|
|
9520
|
-
function appendFixThisFirst(lines, result,
|
|
9599
|
+
function appendFixThisFirst(lines, result, ui) {
|
|
9521
9600
|
const fixes = topFixes(result.findings, 3);
|
|
9522
9601
|
if (fixes.length === 0) return;
|
|
9523
|
-
lines.push(
|
|
9602
|
+
lines.push(sectionHeader("FIX THIS FIRST", ui));
|
|
9524
9603
|
for (const { finding: f, scoreGain, autofixable } of fixes) {
|
|
9525
9604
|
const gainText = `+${scoreGain} pt${scoreGain === 1 ? "" : "s"}`;
|
|
9526
|
-
const autofixTag = autofixable ? p.ok(" [autofix available]") : "";
|
|
9605
|
+
const autofixTag = autofixable ? ui.p.ok(" [autofix available]") : "";
|
|
9527
9606
|
const loc = `${sanitizeData(f.ruleId)} · ${sanitizeData(f.file)}:${f.line}`;
|
|
9528
|
-
lines.push(` ${p.bold(gainText)} ${loc}${autofixTag}`);
|
|
9607
|
+
lines.push(` ${ui.p.bold(gainText)} ${loc}${autofixTag}`);
|
|
9529
9608
|
}
|
|
9530
9609
|
lines.push("");
|
|
9531
9610
|
}
|
|
@@ -9586,9 +9665,10 @@ function wrapLines(text, width) {
|
|
|
9586
9665
|
}
|
|
9587
9666
|
const CARD_LABEL_PAD = 8;
|
|
9588
9667
|
const CARD_GUTTER = " ";
|
|
9589
|
-
function pushCard(lines, card,
|
|
9668
|
+
function pushCard(lines, card, ui) {
|
|
9669
|
+
const { p, width } = ui;
|
|
9590
9670
|
const contentWidth = Math.max(20, width - 2 - 4 - CARD_LABEL_PAD);
|
|
9591
|
-
lines.push(` ${
|
|
9671
|
+
lines.push(` ${severityIcon(card.severity, ui)} ${p.bold(card.loc)} ${p.dim(card.evidence)}`);
|
|
9592
9672
|
const fields = [
|
|
9593
9673
|
{
|
|
9594
9674
|
label: "Problem",
|
|
@@ -9627,7 +9707,8 @@ function pushCard(lines, card, p, width, ascii) {
|
|
|
9627
9707
|
* "same fix applies" header. Non-verbose shows MAX_CARDS cards plus an
|
|
9628
9708
|
* overflow line; --verbose shows everything.
|
|
9629
9709
|
*/
|
|
9630
|
-
function appendFindings(lines, result, counts, verbose,
|
|
9710
|
+
function appendFindings(lines, result, counts, verbose, ui, tone) {
|
|
9711
|
+
const { p } = ui;
|
|
9631
9712
|
if (counts.total === 0) return;
|
|
9632
9713
|
const byRule = /* @__PURE__ */ new Map();
|
|
9633
9714
|
for (const f of result.findings) {
|
|
@@ -9660,7 +9741,7 @@ function appendFindings(lines, result, counts, verbose, p, ascii, width, tone) {
|
|
|
9660
9741
|
let shown = 0;
|
|
9661
9742
|
let hidden = 0;
|
|
9662
9743
|
const hiddenRules = /* @__PURE__ */ new Set();
|
|
9663
|
-
lines.push(
|
|
9744
|
+
lines.push(sectionHeader("FINDINGS", ui));
|
|
9664
9745
|
lines.push("");
|
|
9665
9746
|
for (const unit of units) {
|
|
9666
9747
|
if (unit.kind === "group") {
|
|
@@ -9671,7 +9752,7 @@ function appendFindings(lines, result, counts, verbose, p, ascii, width, tone) {
|
|
|
9671
9752
|
hiddenRules.add(unit.ruleId);
|
|
9672
9753
|
continue;
|
|
9673
9754
|
}
|
|
9674
|
-
lines.push(` ${
|
|
9755
|
+
lines.push(` ${severityIcon(maxSeverity(unit.findings), ui)} ${p.bold(sanitizeData(unit.ruleId))} ${p.dim(`× ${n} — same fix applies`)} ${p.dim(evidenceTag$1(first))}`);
|
|
9675
9756
|
lines.push(`${CARD_GUTTER}${p.accent("Fix".padEnd(CARD_LABEL_PAD))}${p.dim(sanitizeData(first.fix))}`);
|
|
9676
9757
|
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
9758
|
lines.push("");
|
|
@@ -9683,7 +9764,7 @@ function appendFindings(lines, result, counts, verbose, p, ascii, width, tone) {
|
|
|
9683
9764
|
hiddenRules.add(unit.finding.ruleId);
|
|
9684
9765
|
continue;
|
|
9685
9766
|
}
|
|
9686
|
-
pushCard(lines, toCard(unit.finding, tone),
|
|
9767
|
+
pushCard(lines, toCard(unit.finding, tone), ui);
|
|
9687
9768
|
shown++;
|
|
9688
9769
|
}
|
|
9689
9770
|
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 +9792,13 @@ function appendForgedBlock(lines, p, ascii) {
|
|
|
9711
9792
|
lines.push(p.forged(TROPHY));
|
|
9712
9793
|
lines.push("");
|
|
9713
9794
|
}
|
|
9714
|
-
function appendFooter(lines, result,
|
|
9715
|
-
|
|
9716
|
-
|
|
9717
|
-
|
|
9795
|
+
function appendFooter(lines, result, ui) {
|
|
9796
|
+
const { p } = ui;
|
|
9797
|
+
lines.push(...buildFooter({
|
|
9798
|
+
ui,
|
|
9799
|
+
complete: result.analysisStatus.discovery !== "partial",
|
|
9800
|
+
durationMs: result.analysisStatus.durationMs
|
|
9801
|
+
}));
|
|
9718
9802
|
const advisory = result.findings.filter((f) => (f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence)) === "E0").length;
|
|
9719
9803
|
if (advisory > 0) lines.push(p.dim(` ${advisory} advisory finding${advisory === 1 ? "" : "s"} (E0 — observation only, no score impact)`));
|
|
9720
9804
|
if (result.findings.length > 0) {
|
|
@@ -9738,7 +9822,8 @@ function appendFooter(lines, result, p) {
|
|
|
9738
9822
|
}
|
|
9739
9823
|
lines.push("");
|
|
9740
9824
|
}
|
|
9741
|
-
function renderNoTests(
|
|
9825
|
+
function renderNoTests(ui) {
|
|
9826
|
+
const { p, ascii } = ui;
|
|
9742
9827
|
const warnGlyph = ascii ? "!" : "⚠";
|
|
9743
9828
|
const searched = SEARCHED_FOR.map((e) => `${e.label}: ${e.globs.join(" ")}`);
|
|
9744
9829
|
return [
|
|
@@ -9756,7 +9841,7 @@ function renderNoTests(p, ascii) {
|
|
|
9756
9841
|
maxWidth: 78
|
|
9757
9842
|
}).map((l) => ` ${l}`),
|
|
9758
9843
|
"",
|
|
9759
|
-
"
|
|
9844
|
+
nextStep("mjolnir <path-to-your-tests>", ui),
|
|
9760
9845
|
""
|
|
9761
9846
|
].join("\n");
|
|
9762
9847
|
}
|
|
@@ -9802,7 +9887,7 @@ function renderSarif(result, repoRootUri) {
|
|
|
9802
9887
|
tool: { driver: {
|
|
9803
9888
|
name: "Mjölnir",
|
|
9804
9889
|
informationUri: "https://github.com/Sergey-Bar/Mjolnir",
|
|
9805
|
-
version: "0.5.
|
|
9890
|
+
version: "0.5.3",
|
|
9806
9891
|
rules: [...rules.values()].map((r) => {
|
|
9807
9892
|
const meta = RULES.find((x) => x.id === r.id);
|
|
9808
9893
|
return {
|
|
@@ -9945,6 +10030,492 @@ function renderMermaid(result) {
|
|
|
9945
10030
|
return lines.join("\n");
|
|
9946
10031
|
}
|
|
9947
10032
|
//#endregion
|
|
10033
|
+
//#region src/reporter/progress.ts
|
|
10034
|
+
/**
|
|
10035
|
+
* Live scan progress (Terminal + CI UX Overhaul plan, M3).
|
|
10036
|
+
*
|
|
10037
|
+
* Determinism model: render-on-event, NO wall-clock timer. Frames
|
|
10038
|
+
* advance and lines repaint only when `onProgress` fires — testable
|
|
10039
|
+
* with a fake stream, no fake timers, no flaky CI.
|
|
10040
|
+
*
|
|
10041
|
+
* Stream discipline: every cursor-control ANSI sequence goes to the
|
|
10042
|
+
* injected stream only. The stream is gated by the caller (cli.ts
|
|
10043
|
+
* auto-disables on non-TTY stderr, --json/--format machine modes,
|
|
10044
|
+
* --no-progress, GITHUB_ACTIONS/CI env), so stdout purity and
|
|
10045
|
+
* byte-identical JSON are untouched by construction.
|
|
10046
|
+
*
|
|
10047
|
+
* Zero new dependencies: braille + ASCII spinner frames are inline
|
|
10048
|
+
* constants; erasure is plain `\r` + ESC[K.
|
|
10049
|
+
*/
|
|
10050
|
+
/** Spinner frames: braille, ASCII fallback. */
|
|
10051
|
+
const BRAILLE_FRAMES = [..."⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"];
|
|
10052
|
+
const ASCII_FRAMES = [..."|/-\\"];
|
|
10053
|
+
/** True when a live progress renderer may write to the stream at all.
|
|
10054
|
+
* `env` is required: the caller owns the ambient-vs-test distinction
|
|
10055
|
+
* (cli.ts passes process.env; tests pass a controlled object). */
|
|
10056
|
+
function shouldRenderProgress(opts) {
|
|
10057
|
+
if (opts.noProgress) return false;
|
|
10058
|
+
if (opts.machineFormat) return false;
|
|
10059
|
+
if (!opts.isTTY) return false;
|
|
10060
|
+
if (opts.env["GITHUB_ACTIONS"] === "true" || opts.env["CI"] === "true") return false;
|
|
10061
|
+
return true;
|
|
10062
|
+
}
|
|
10063
|
+
/** Frame at `index`, defensive against an empty frames list. Exported
|
|
10064
|
+
* so the fallback arms stay pinned (renderProgressLine always passes a
|
|
10065
|
+
* non-empty constant list and an in-range index). */
|
|
10066
|
+
function pickFrame(frames, index) {
|
|
10067
|
+
return frames[index % frames.length] ?? frames[0] ?? " ";
|
|
10068
|
+
}
|
|
10069
|
+
const PHASE_LABELS = {
|
|
10070
|
+
discover: "Discovering files",
|
|
10071
|
+
parse: "Parsing frameworks",
|
|
10072
|
+
rules: "Running rules",
|
|
10073
|
+
score: "Scoring"
|
|
10074
|
+
};
|
|
10075
|
+
/** One rendered progress line for an event and frame index. */
|
|
10076
|
+
function renderProgressLine(event, frameIndex, opts = {}) {
|
|
10077
|
+
let line = `${pickFrame(opts.ascii ? ASCII_FRAMES : BRAILLE_FRAMES, frameIndex)} ${PHASE_LABELS[event.phase]}…`;
|
|
10078
|
+
const parts = [];
|
|
10079
|
+
if (event.total !== void 0) parts.push(`${event.done ?? 0}/${event.total}`);
|
|
10080
|
+
if (event.detail) parts.push(sanitizeData(event.detail));
|
|
10081
|
+
if (parts.length > 0) line += ` (${parts.join(" · ")})`;
|
|
10082
|
+
return line;
|
|
10083
|
+
}
|
|
10084
|
+
const ERASE_TAIL = "\x1B[K";
|
|
10085
|
+
/**
|
|
10086
|
+
* The event-driven progress renderer. All writes go to the injected
|
|
10087
|
+
* stream; the internal `lines` count tracks what must be erased when
|
|
10088
|
+
* the phase changes or the scan completes.
|
|
10089
|
+
*/
|
|
10090
|
+
var ProgressRenderer = class {
|
|
10091
|
+
frameIndex = 0;
|
|
10092
|
+
activeLines = 0;
|
|
10093
|
+
stream;
|
|
10094
|
+
ascii;
|
|
10095
|
+
enabled;
|
|
10096
|
+
constructor(opts) {
|
|
10097
|
+
this.stream = opts.stream;
|
|
10098
|
+
this.ascii = opts.ascii === true;
|
|
10099
|
+
this.enabled = opts.isTTY;
|
|
10100
|
+
}
|
|
10101
|
+
get active() {
|
|
10102
|
+
return this.enabled;
|
|
10103
|
+
}
|
|
10104
|
+
/** Test-visible frame index (render-on-event determinism). */
|
|
10105
|
+
get frame() {
|
|
10106
|
+
return this.frameIndex;
|
|
10107
|
+
}
|
|
10108
|
+
write(s) {
|
|
10109
|
+
this.stream.write(s);
|
|
10110
|
+
}
|
|
10111
|
+
erase() {
|
|
10112
|
+
if (this.activeLines > 0) {
|
|
10113
|
+
this.write(`\r\x1b[${this.activeLines}A${ERASE_TAIL}${"\n\x1B[K".repeat(Math.max(0, this.activeLines - 1))}\r`);
|
|
10114
|
+
this.activeLines = 0;
|
|
10115
|
+
}
|
|
10116
|
+
}
|
|
10117
|
+
/** Handle one onProgress event: erase, paint, advance the frame. */
|
|
10118
|
+
onEvent(event) {
|
|
10119
|
+
if (!this.enabled) return;
|
|
10120
|
+
this.erase();
|
|
10121
|
+
const line = renderProgressLine(event, this.frameIndex, { ascii: this.ascii });
|
|
10122
|
+
this.write(`${line}\n`);
|
|
10123
|
+
this.activeLines = 1;
|
|
10124
|
+
this.frameIndex++;
|
|
10125
|
+
}
|
|
10126
|
+
/** Scan finished (or formats are about to print): clear the line. */
|
|
10127
|
+
done() {
|
|
10128
|
+
if (!this.enabled) return;
|
|
10129
|
+
this.erase();
|
|
10130
|
+
}
|
|
10131
|
+
};
|
|
10132
|
+
//#endregion
|
|
10133
|
+
//#region src/reporter/github.ts
|
|
10134
|
+
/** Escape a workflow-command PROPERTY value. */
|
|
10135
|
+
function escapeAnnotationProperty(value) {
|
|
10136
|
+
return value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A").replaceAll(":", "%3A").replaceAll(",", "%2C");
|
|
10137
|
+
}
|
|
10138
|
+
/** Escape a workflow-command MESSAGE. */
|
|
10139
|
+
function escapeAnnotationMessage(value) {
|
|
10140
|
+
return value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
|
|
10141
|
+
}
|
|
10142
|
+
/** ANSI-injection guard for the summary path (belt + braces). */
|
|
10143
|
+
function stripAnsiForSummary(value) {
|
|
10144
|
+
return value.replace(/\x1b\[[0-9;:?]*[ -/]*[@-~]/g, "");
|
|
10145
|
+
}
|
|
10146
|
+
const SEVERITY_TO_COMMAND = {
|
|
10147
|
+
error: "error",
|
|
10148
|
+
warning: "warning",
|
|
10149
|
+
info: "notice"
|
|
10150
|
+
};
|
|
10151
|
+
/** Render ONE workflow-command annotation line (no trailing newline). */
|
|
10152
|
+
function renderAnnotation(a) {
|
|
10153
|
+
const props = [`file=${escapeAnnotationProperty(a.file)}`, `line=${Math.max(1, a.line ?? 1)}`];
|
|
10154
|
+
if (a.column !== void 0) props.push(`column=${Math.max(1, a.column)}`);
|
|
10155
|
+
props.push(`title=${escapeAnnotationProperty(a.title)}`);
|
|
10156
|
+
return `::${SEVERITY_TO_COMMAND[a.severity]} ${props.join(",")}::${escapeAnnotationMessage(a.message)}`;
|
|
10157
|
+
}
|
|
10158
|
+
/** Render annotations for every finding in a report's findings array. */
|
|
10159
|
+
function renderAnnotations(findings) {
|
|
10160
|
+
return findings.map((f) => {
|
|
10161
|
+
return renderAnnotation({
|
|
10162
|
+
severity: f.severity === "error" || f.severity === "warning" ? f.severity : "info",
|
|
10163
|
+
file: f.file,
|
|
10164
|
+
line: f.line,
|
|
10165
|
+
column: f.column,
|
|
10166
|
+
title: f.ruleId,
|
|
10167
|
+
message: f.message
|
|
10168
|
+
});
|
|
10169
|
+
});
|
|
10170
|
+
}
|
|
10171
|
+
/** GitHub caps annotation messages — keep the head, point at the summary. */
|
|
10172
|
+
function truncateMessage(message, max = 250) {
|
|
10173
|
+
if (message.length <= max) return message;
|
|
10174
|
+
return `${message.slice(0, max)}… (full text in the step summary)`;
|
|
10175
|
+
}
|
|
10176
|
+
//#endregion
|
|
10177
|
+
//#region src/commands/pr-comment.ts
|
|
10178
|
+
const MARKER = "<!-- mjolnir-pr-comment -->";
|
|
10179
|
+
/**
|
|
10180
|
+
* Bug-audit QA-2026-08-30 QA-10: finding metadata rendered into the PR
|
|
10181
|
+
* comment body is untrusted (hostile filenames, plugin messages). A raw
|
|
10182
|
+
* `|` breaks the markdown layout, a raw backtick escapes the code span,
|
|
10183
|
+
* and `<\/script>`/link syntax could inject content into the PR page.
|
|
10184
|
+
* Escape markdown-significant characters and strip control/ANSI escapes.
|
|
10185
|
+
*/
|
|
10186
|
+
function escapeMarkdown(s) {
|
|
10187
|
+
return sanitizeData(s).replace(/([\\`*_{}[\]()#+!|<>])/g, "\\$1");
|
|
10188
|
+
}
|
|
10189
|
+
/** True when a fix recommendation reads as code rather than prose —
|
|
10190
|
+
* those render as a code span, the rest stay italic. */
|
|
10191
|
+
function looksLikeCode(s) {
|
|
10192
|
+
return /[(;={]|await |expect\(/.test(s);
|
|
10193
|
+
}
|
|
10194
|
+
/** Evidence tag per finding — Evidence > Assumption in the fabric: a
|
|
10195
|
+
* measured false-positive rate is shown right on the line when present. */
|
|
10196
|
+
function evidenceTag(f) {
|
|
10197
|
+
const level = f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence);
|
|
10198
|
+
let tag = `${level} · ${level === "E2" ? "deterministic" : level === "E1" ? "heuristic" : "observation"}`;
|
|
10199
|
+
if (f.measuredFpRate !== void 0) {
|
|
10200
|
+
tag += ` · measured FP ${Math.round(f.measuredFpRate * 100)}%`;
|
|
10201
|
+
if (f.measuredFpN !== void 0) tag += ` · n=${f.measuredFpN}`;
|
|
10202
|
+
}
|
|
10203
|
+
return tag;
|
|
10204
|
+
}
|
|
10205
|
+
function findingLine(f) {
|
|
10206
|
+
const icon = f.severity === "error" ? "🔴" : f.severity === "warning" ? "🟡" : "🔵";
|
|
10207
|
+
const fix = escapeMarkdown(f.fix);
|
|
10208
|
+
const fixBody = looksLikeCode(f.fix) ? `\`${fix}\`` : `_${fix}_`;
|
|
10209
|
+
return `${icon} **${escapeMarkdown(f.ruleId)}** \`${escapeMarkdown(f.file)}:${f.line}\` — ${escapeMarkdown(f.message)} ${escapeMarkdown(`[${evidenceTag(f)}]`)}\n Fix: ${fixBody}`;
|
|
10210
|
+
}
|
|
10211
|
+
/**
|
|
10212
|
+
* Render the full comment body (plan M5 redesign). Idempotent by
|
|
10213
|
+
* design — the leading HTML comment marker lets the posting workflow
|
|
10214
|
+
* find and update its own prior comment instead of spamming a new one
|
|
10215
|
+
* on every push.
|
|
10216
|
+
*/
|
|
10217
|
+
function renderPrComment(result, options = {}) {
|
|
10218
|
+
const lines = [MARKER, ""];
|
|
10219
|
+
lines.push("### 🔨 Mjölnir — Verification Trust");
|
|
10220
|
+
lines.push("");
|
|
10221
|
+
const diff = options.diff;
|
|
10222
|
+
const usingDiff = diff?.hasBaseline === true;
|
|
10223
|
+
const findings = usingDiff ? diff.newFindings : result.findings;
|
|
10224
|
+
if (usingDiff) lines.push(`Comparing against the stored baseline (commit \`${diff.baselineCommit ?? "unknown"}\`) — showing only what this PR changed.`);
|
|
10225
|
+
else if (result.scope === "changed") lines.push("Showing only findings on lines this PR changed.");
|
|
10226
|
+
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.");
|
|
10227
|
+
lines.push("");
|
|
10228
|
+
if (result.score !== null) {
|
|
10229
|
+
const state = deriveScoreState(result.score);
|
|
10230
|
+
const verdict = verdictFor(result.score);
|
|
10231
|
+
const baseScore = diff?.baselineScore;
|
|
10232
|
+
if (baseScore !== void 0) {
|
|
10233
|
+
const delta = result.score - baseScore;
|
|
10234
|
+
const sign = delta > 0 ? "+" : "";
|
|
10235
|
+
const commit = diff?.baselineCommit?.slice(0, 7) || "unknown";
|
|
10236
|
+
lines.push(`**Score:** ${result.score}/100 (${sign}${delta} since baseline \`${commit}\`)`);
|
|
10237
|
+
} else lines.push(`**Score:** ${result.score}/100 · ${verdict} (${state.band})`);
|
|
10238
|
+
lines.push("");
|
|
10239
|
+
lines.push(`_${headlineFor(state, result.findings.length)}_`);
|
|
10240
|
+
lines.push("");
|
|
10241
|
+
}
|
|
10242
|
+
if (result.dimensions && result.dimensions.length > 0) {
|
|
10243
|
+
lines.push("| Category | Score |");
|
|
10244
|
+
lines.push("|---|---|");
|
|
10245
|
+
for (const d of result.dimensions) lines.push(`| ${escapeMarkdown(d.category)} | ${d.score}/100 |`);
|
|
10246
|
+
lines.push("");
|
|
10247
|
+
}
|
|
10248
|
+
if (findings.length === 0) lines.push("✅ No new issues found in this PR's changes.");
|
|
10249
|
+
else {
|
|
10250
|
+
const errors = findings.filter((f) => f.severity === "error");
|
|
10251
|
+
const warnings = findings.filter((f) => f.severity === "warning");
|
|
10252
|
+
const infos = findings.filter((f) => f.severity === "info");
|
|
10253
|
+
lines.push(`**${findings.length} new finding${findings.length === 1 ? "" : "s"}** (${errors.length} error, ${warnings.length} warning):`);
|
|
10254
|
+
lines.push("");
|
|
10255
|
+
const groups = [
|
|
10256
|
+
{
|
|
10257
|
+
icon: "🔴",
|
|
10258
|
+
label: "errors",
|
|
10259
|
+
list: errors,
|
|
10260
|
+
open: true
|
|
10261
|
+
},
|
|
10262
|
+
{
|
|
10263
|
+
icon: "🟡",
|
|
10264
|
+
label: "warnings",
|
|
10265
|
+
list: warnings,
|
|
10266
|
+
open: false
|
|
10267
|
+
},
|
|
10268
|
+
{
|
|
10269
|
+
icon: "🔵",
|
|
10270
|
+
label: "infos",
|
|
10271
|
+
list: infos,
|
|
10272
|
+
open: false
|
|
10273
|
+
}
|
|
10274
|
+
];
|
|
10275
|
+
let rendered = 0;
|
|
10276
|
+
for (const g of groups) {
|
|
10277
|
+
if (g.list.length === 0) continue;
|
|
10278
|
+
rendered += Math.min(g.list.length, 25);
|
|
10279
|
+
lines.push("<details" + (g.open ? " open" : "") + ">");
|
|
10280
|
+
lines.push(`<summary>${g.icon} ${g.list.length} ${g.label}</summary>`);
|
|
10281
|
+
lines.push("");
|
|
10282
|
+
for (const f of g.list.slice(0, 25)) lines.push(`- ${findingLine(f)}`);
|
|
10283
|
+
if (g.list.length > 25) {
|
|
10284
|
+
lines.push("");
|
|
10285
|
+
lines.push(`_...and ${g.list.length - 25} more ${g.label}. Run \`mjolnir\` locally for the full list._`);
|
|
10286
|
+
}
|
|
10287
|
+
lines.push("");
|
|
10288
|
+
lines.push("</details>");
|
|
10289
|
+
lines.push("");
|
|
10290
|
+
}
|
|
10291
|
+
const overflow = findings.length - rendered;
|
|
10292
|
+
if (overflow > 0) lines.push(`_...and ${overflow} more overall — run \`mjolnir\` locally (or \`--verbose\`) for the full list._`);
|
|
10293
|
+
}
|
|
10294
|
+
lines.push("");
|
|
10295
|
+
const version = options.version ? `@${options.version}` : "";
|
|
10296
|
+
lines.push("**What to run next:**");
|
|
10297
|
+
lines.push("");
|
|
10298
|
+
lines.push("```bash");
|
|
10299
|
+
lines.push(`npx mjolnir-qa${version} . # full scan + score`);
|
|
10300
|
+
lines.push(`npx mjolnir-qa${version} . --verbose # every finding, uncapped`);
|
|
10301
|
+
lines.push("```");
|
|
10302
|
+
lines.push("");
|
|
10303
|
+
lines.push(`_Advisory only — this comment never blocks merging. Generated by [Mjölnir](${options.repoUrl ?? "https://github.com/Sergey-Bar/Mjolnir"})._`);
|
|
10304
|
+
if (usingDiff && diff.resolvedFindings.length > 0) {
|
|
10305
|
+
lines.push("");
|
|
10306
|
+
lines.push(`✨ ${diff.resolvedFindings.length} pre-existing finding${diff.resolvedFindings.length === 1 ? "" : "s"} fixed in this PR.`);
|
|
10307
|
+
}
|
|
10308
|
+
return lines.join("\n");
|
|
10309
|
+
}
|
|
10310
|
+
//#endregion
|
|
10311
|
+
//#region src/commands/summary.ts
|
|
10312
|
+
/**
|
|
10313
|
+
* `mjolnir summary [report.json]` — CI annotations + step summary
|
|
10314
|
+
* (Terminal + CI UX Overhaul plan, M4).
|
|
10315
|
+
*
|
|
10316
|
+
* Additive command; default report path `mjolnir.json`. Reads a saved
|
|
10317
|
+
* ScanResult JSON (the `--json` scan output) and emits:
|
|
10318
|
+
* 1. GitHub annotations to stdout, only when GITHUB_ACTIONS=true —
|
|
10319
|
+
* one per finding, via the single github.ts emitter.
|
|
10320
|
+
* 2. A step-summary markdown document to $GITHUB_STEP_SUMMARY when
|
|
10321
|
+
* set (else stdout; --stdout forces stdout): score + band, text
|
|
10322
|
+
* score bar, dimensions table, top deductions, collapsible
|
|
10323
|
+
* per-severity details with fix lines, and an honesty notice for
|
|
10324
|
+
* partial/no-score reports.
|
|
10325
|
+
*
|
|
10326
|
+
* Exit codes (frozen contract): 0 on success — this command NEVER
|
|
10327
|
+
* blocks; the gate step decides. 10 missing file argument. 2
|
|
10328
|
+
* unreadable/invalid JSON (a data problem; the message explains).
|
|
10329
|
+
*
|
|
10330
|
+
* Paths: findings are scan-target-relative. The generated workflow
|
|
10331
|
+
* scans the repo root, so the default output is directly usable;
|
|
10332
|
+
* `--path-prefix <dir>` re-scopes for subdirectory scans.
|
|
10333
|
+
*/
|
|
10334
|
+
const DETAILS_PER_SEVERITY_CAP = 25;
|
|
10335
|
+
/** Human message for any thrown value — never "undefined"/"[object Object]". */
|
|
10336
|
+
function errorText(err) {
|
|
10337
|
+
if (err instanceof Error) return err.message;
|
|
10338
|
+
if (typeof err === "string") return err;
|
|
10339
|
+
if (typeof err === "object" && err !== null) return JSON.stringify(err);
|
|
10340
|
+
return String(err);
|
|
10341
|
+
}
|
|
10342
|
+
/** Parse-and-validate a saved report. Module-private: the command is
|
|
10343
|
+
* the only consumer; tests exercise it through runSummaryCommand. */
|
|
10344
|
+
function validateReportJson(text) {
|
|
10345
|
+
let parsed;
|
|
10346
|
+
try {
|
|
10347
|
+
parsed = JSON.parse(text);
|
|
10348
|
+
} catch (err) {
|
|
10349
|
+
throw new Error(`not valid JSON (${errorText(err)})`, { cause: err });
|
|
10350
|
+
}
|
|
10351
|
+
if (typeof parsed !== "object" || parsed === null) throw new Error("the file is a JSON value but not an object");
|
|
10352
|
+
const doc = parsed;
|
|
10353
|
+
if (doc.schemaVersion !== 1) throw new Error(`unsupported schemaVersion ${JSON.stringify(doc.schemaVersion)} — expected 1`);
|
|
10354
|
+
if (!Array.isArray(doc.findings)) throw new Error("missing a \"findings\" array — is this a Mjölnir --json report?");
|
|
10355
|
+
return parsed;
|
|
10356
|
+
}
|
|
10357
|
+
function scoreBar(score, width = 20) {
|
|
10358
|
+
const filled = Math.round(score / 100 * width);
|
|
10359
|
+
return `${"█".repeat(filled)}${"░".repeat(Math.max(0, width - filled))}`;
|
|
10360
|
+
}
|
|
10361
|
+
/** Markdown step summary. Pure over (result, options) — testable. */
|
|
10362
|
+
function renderStepSummary(result, options = {}) {
|
|
10363
|
+
const lines = [];
|
|
10364
|
+
lines.push("### 🔨 Mjölnir — Verification Trust");
|
|
10365
|
+
lines.push("");
|
|
10366
|
+
if (result.score === null) {
|
|
10367
|
+
lines.push("Score: **not measurable** — no test files found (`reason: no-tests-found`).");
|
|
10368
|
+
lines.push("");
|
|
10369
|
+
lines.push("> No fake numbers: a repo without tests has no score to show.");
|
|
10370
|
+
} else {
|
|
10371
|
+
const state = deriveScoreState(result.score);
|
|
10372
|
+
const verdict = verdictFor(result.score);
|
|
10373
|
+
lines.push(`Score: **${result.score}/100** · ${verdict} (${state.band}) · ${headlineFor(state, result.findings.length)}`);
|
|
10374
|
+
lines.push("");
|
|
10375
|
+
lines.push("```text");
|
|
10376
|
+
lines.push(`${scoreBar(result.score)} ${result.score}/100`);
|
|
10377
|
+
lines.push("```");
|
|
10378
|
+
}
|
|
10379
|
+
lines.push("");
|
|
10380
|
+
if (result.frameworks.length > 0) {
|
|
10381
|
+
lines.push(`Detected: ${result.frameworks.map((f) => `\`${f}\``).join(" · ")}`);
|
|
10382
|
+
lines.push("");
|
|
10383
|
+
}
|
|
10384
|
+
if (result.dimensions.length > 0) {
|
|
10385
|
+
lines.push("| Category | Score |");
|
|
10386
|
+
lines.push("|----------|-------|");
|
|
10387
|
+
for (const d of result.dimensions) lines.push(`| ${escapeMarkdown(d.category)} | ${d.score}/100 |`);
|
|
10388
|
+
lines.push("");
|
|
10389
|
+
}
|
|
10390
|
+
if (result.rawDeductions !== void 0 && result.testDeclarationCount) {
|
|
10391
|
+
lines.push(`Transparency: ${result.rawDeductions} raw pts over ${result.testDeclarationCount} test declarations (normalized).`);
|
|
10392
|
+
lines.push("");
|
|
10393
|
+
}
|
|
10394
|
+
const bySeverity = {
|
|
10395
|
+
error: [],
|
|
10396
|
+
warning: [],
|
|
10397
|
+
info: []
|
|
10398
|
+
};
|
|
10399
|
+
for (const f of result.findings) bySeverity[f.severity].push(f);
|
|
10400
|
+
const icons = {
|
|
10401
|
+
error: "🔴",
|
|
10402
|
+
warning: "🟡",
|
|
10403
|
+
info: "🔵"
|
|
10404
|
+
};
|
|
10405
|
+
for (const sev of [
|
|
10406
|
+
"error",
|
|
10407
|
+
"warning",
|
|
10408
|
+
"info"
|
|
10409
|
+
]) {
|
|
10410
|
+
const list = bySeverity[sev];
|
|
10411
|
+
if (list.length === 0) continue;
|
|
10412
|
+
const open = sev === "error" ? " open" : "";
|
|
10413
|
+
lines.push(`<details${open}>`, `<summary>${icons[sev]} ${list.length} ${sev}${list.length === 1 ? "" : "s"}</summary>`, "");
|
|
10414
|
+
for (const f of list.slice(0, DETAILS_PER_SEVERITY_CAP)) {
|
|
10415
|
+
const path = options.pathPrefix ? `${options.pathPrefix.replace(/\/$/, "")}/${f.file}` : f.file;
|
|
10416
|
+
lines.push(`- **${escapeMarkdown(f.ruleId)}** \`${escapeMarkdown(path)}:${f.line}\` — ${escapeMarkdown(stripAnsiForSummary(f.message))}`);
|
|
10417
|
+
lines.push(` - Fix: ${escapeMarkdown(stripAnsiForSummary(f.fix))}`);
|
|
10418
|
+
}
|
|
10419
|
+
if (list.length > DETAILS_PER_SEVERITY_CAP) lines.push(`- … and ${list.length - DETAILS_PER_SEVERITY_CAP} more — see the full JSON artifact.`);
|
|
10420
|
+
lines.push("", "</details>", "");
|
|
10421
|
+
}
|
|
10422
|
+
if (result.findings.length === 0 && result.score !== null) {
|
|
10423
|
+
lines.push("Zero findings — nothing to fix.");
|
|
10424
|
+
lines.push("");
|
|
10425
|
+
}
|
|
10426
|
+
if (result.partial) {
|
|
10427
|
+
lines.push("> ⚠ Partial scan: the budget expired or files were skipped — verdict may be incomplete.");
|
|
10428
|
+
lines.push("");
|
|
10429
|
+
}
|
|
10430
|
+
lines.push("<!-- mjolnir-honesty: scores derive from rules with published evidence levels and measured false-positive rates where available. -->");
|
|
10431
|
+
return lines.join("\n");
|
|
10432
|
+
}
|
|
10433
|
+
/** Render one finding as an escaped annotation line. Fields are
|
|
10434
|
+
* sanitized with the same sanitizeData layer the terminal uses
|
|
10435
|
+
* (github.ts's escapers cover %/CR/LF, not OSC/C0), then %/CR/LF-escaped. */
|
|
10436
|
+
function annotationForFinding(f) {
|
|
10437
|
+
return renderAnnotations([{
|
|
10438
|
+
...f,
|
|
10439
|
+
file: sanitizeData(f.file),
|
|
10440
|
+
ruleId: sanitizeData(f.ruleId),
|
|
10441
|
+
message: truncateMessage(stripAnsiForSummary(sanitizeData(f.message)))
|
|
10442
|
+
}])[0];
|
|
10443
|
+
}
|
|
10444
|
+
const KNOWN_SUMMARY_FLAGS = /* @__PURE__ */ new Set([
|
|
10445
|
+
"--stdout",
|
|
10446
|
+
"--path-prefix",
|
|
10447
|
+
"--help",
|
|
10448
|
+
"-h"
|
|
10449
|
+
]);
|
|
10450
|
+
/**
|
|
10451
|
+
* Testable summary command core. Returns the process exit code.
|
|
10452
|
+
* Streams: annotations → stdout (always; GitHub greps them), summary →
|
|
10453
|
+
* step-summary file when set unless --stdout. Unknown flags are a
|
|
10454
|
+
* usage error (exit 10) — a typo'd --stdout must not silently route
|
|
10455
|
+
* the summary to $GITHUB_STEP_SUMMARY.
|
|
10456
|
+
*/
|
|
10457
|
+
function runSummaryCommand(argv, io = {
|
|
10458
|
+
out: (line) => console.log(line),
|
|
10459
|
+
err: (line) => console.error(line)
|
|
10460
|
+
}) {
|
|
10461
|
+
for (let i = 0; i < argv.length; i++) {
|
|
10462
|
+
const a = argv[i] ?? "";
|
|
10463
|
+
if (!a.startsWith("-")) continue;
|
|
10464
|
+
if (a === "--path-prefix") {
|
|
10465
|
+
const val = argv[i + 1];
|
|
10466
|
+
if (val === void 0 || val.startsWith("-")) {
|
|
10467
|
+
io.err(usageErrorMessage({
|
|
10468
|
+
flag: "--path-prefix",
|
|
10469
|
+
token: val
|
|
10470
|
+
}));
|
|
10471
|
+
return 10;
|
|
10472
|
+
}
|
|
10473
|
+
i++;
|
|
10474
|
+
continue;
|
|
10475
|
+
}
|
|
10476
|
+
if (KNOWN_SUMMARY_FLAGS.has(a)) continue;
|
|
10477
|
+
io.err(usageErrorMessage({ token: a }));
|
|
10478
|
+
return 10;
|
|
10479
|
+
}
|
|
10480
|
+
const stdout = argv.includes("--stdout");
|
|
10481
|
+
const prefixIdx = argv.indexOf("--path-prefix");
|
|
10482
|
+
const pathPrefix = prefixIdx !== -1 ? argv[prefixIdx + 1] : void 0;
|
|
10483
|
+
const reportPath = argv.filter((a, i) => a !== void 0 && !a.startsWith("-") && (prefixIdx === -1 || i !== prefixIdx + 1))[0] ?? "mjolnir.json";
|
|
10484
|
+
if (!existsSync(reportPath)) {
|
|
10485
|
+
io.err(`mjolnir summary: report file not found: ${reportPath}`);
|
|
10486
|
+
io.err(" Run the scan with --json first: mjolnir --json > mjolnir.json");
|
|
10487
|
+
return 10;
|
|
10488
|
+
}
|
|
10489
|
+
let result;
|
|
10490
|
+
try {
|
|
10491
|
+
result = validateReportJson(readFileSync(reportPath, "utf8"));
|
|
10492
|
+
} catch (err) {
|
|
10493
|
+
io.err(`mjolnir summary: cannot read ${reportPath}: ${errorText(err)}`);
|
|
10494
|
+
return 2;
|
|
10495
|
+
}
|
|
10496
|
+
const env = process.env;
|
|
10497
|
+
if (env["GITHUB_ACTIONS"] === "true") for (const f of result.findings) {
|
|
10498
|
+
const file = pathPrefix ? `${pathPrefix.replace(/\/$/, "")}/${f.file}` : f.file;
|
|
10499
|
+
io.out(annotationForFinding({
|
|
10500
|
+
...f,
|
|
10501
|
+
file
|
|
10502
|
+
}));
|
|
10503
|
+
}
|
|
10504
|
+
const summary = renderStepSummary(result, {
|
|
10505
|
+
stdout,
|
|
10506
|
+
...pathPrefix !== void 0 ? { pathPrefix } : {}
|
|
10507
|
+
});
|
|
10508
|
+
const stepSummaryPath = stdout ? void 0 : env["GITHUB_STEP_SUMMARY"];
|
|
10509
|
+
if (stepSummaryPath) try {
|
|
10510
|
+
appendFileSync(stepSummaryPath, `${summary}\n`);
|
|
10511
|
+
} catch (err) {
|
|
10512
|
+
io.err(`mjolnir summary: could not write $GITHUB_STEP_SUMMARY (${errorText(err)}); printing to stdout instead.`);
|
|
10513
|
+
io.out(summary);
|
|
10514
|
+
}
|
|
10515
|
+
else io.out(summary);
|
|
10516
|
+
return 0;
|
|
10517
|
+
}
|
|
10518
|
+
//#endregion
|
|
9948
10519
|
//#region src/scope/changed.ts
|
|
9949
10520
|
/**
|
|
9950
10521
|
* Changed-scope engine (Sprint-Plan W6, Product-MVP §9 `--scope changed`).
|
|
@@ -10602,8 +11173,18 @@ function gateScript(gate) {
|
|
|
10602
11173
|
"process.exit(0);"
|
|
10603
11174
|
].join("\n");
|
|
10604
11175
|
}
|
|
10605
|
-
/**
|
|
10606
|
-
|
|
11176
|
+
/**
|
|
11177
|
+
* Template v2 (Terminal + CI UX Overhaul plan, M4): the summary step
|
|
11178
|
+
* calls `mjolnir summary mjolnir.json` — annotations + step summary via
|
|
11179
|
+
* ONE emitter — instead of the v1 inline SUMMARY_SCRIPT. The gate
|
|
11180
|
+
* script is unchanged (reads `partial`, `findings[].severity`).
|
|
11181
|
+
*/
|
|
11182
|
+
/** Renders findings into `$GITHUB_STEP_SUMMARY`; tolerates a missing scan result.
|
|
11183
|
+
* v1 inline script, retained ONLY for overwrite-refusal recognition of
|
|
11184
|
+
* workflows generated by older versions (they are treated as ours, so
|
|
11185
|
+
* a frictionless `ci install` upgrade stays possible). Exported for the
|
|
11186
|
+
* recognition test that reconstructs the embedded form. */
|
|
11187
|
+
const SUMMARY_SCRIPT_V1 = [
|
|
10607
11188
|
"const fs = require(\"fs\");",
|
|
10608
11189
|
"let r = {};",
|
|
10609
11190
|
"try { r = JSON.parse(fs.readFileSync(\"mjolnir.json\", \"utf8\")); } catch (e) {}",
|
|
@@ -10620,6 +11201,13 @@ const SUMMARY_SCRIPT = [
|
|
|
10620
11201
|
"}",
|
|
10621
11202
|
"process.stdout.write(lines.length + \" finding(s)\\n\");"
|
|
10622
11203
|
].join("\n");
|
|
11204
|
+
/** True when a workflow was generated by any Mjölnir template (v1 or v2).
|
|
11205
|
+
* The v1 needle is matched in its INDENTED form: the v1 template embedded
|
|
11206
|
+
* the script via indentBlock(…, 10) inside the `run: |` scalar, so the
|
|
11207
|
+
* raw unindented substring never appears in a real v1 file. */
|
|
11208
|
+
function isKnownTemplate(content) {
|
|
11209
|
+
return GATES.some((g) => content === TEMPLATE(g)) || content.includes(indentBlock(SUMMARY_SCRIPT_V1, 10));
|
|
11210
|
+
}
|
|
10623
11211
|
/** Indents an embedded script so it sits inside a YAML `run: |` block scalar. */
|
|
10624
11212
|
function indentBlock(text, spaces) {
|
|
10625
11213
|
const pad = " ".repeat(spaces);
|
|
@@ -10659,16 +11247,17 @@ jobs:
|
|
|
10659
11247
|
- name: Scan changed code (exit 1/2 is data — the gate step decides)
|
|
10660
11248
|
continue-on-error: true
|
|
10661
11249
|
run: npx --yes mjolnir-qa@${CLI_VERSION} . --scope changed --json > mjolnir.json
|
|
11250
|
+
# Reporting, not gating: a crashed scan leaves mjolnir.json empty/missing
|
|
11251
|
+
# and the summary step exits 2/10 — continue-on-error keeps the advisory
|
|
11252
|
+
# job green, exactly like the v1 inline script did (the gate step decides).
|
|
11253
|
+
- name: Annotations + Job Summary
|
|
11254
|
+
if: always()
|
|
11255
|
+
continue-on-error: true
|
|
11256
|
+
run: npx --yes mjolnir-qa@${CLI_VERSION} summary mjolnir.json
|
|
10662
11257
|
- name: Render PR comment
|
|
10663
11258
|
if: always()
|
|
10664
11259
|
continue-on-error: true
|
|
10665
11260
|
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
11261
|
# Best-effort: on a pull_request event from a fork the GITHUB_TOKEN is
|
|
10673
11262
|
# read-only and this step will 403 for every external contributor. The
|
|
10674
11263
|
# Job Summary above is the fallback that always renders.
|
|
@@ -10751,7 +11340,7 @@ function ciInstall(root, gate = "advisory", options = {}) {
|
|
|
10751
11340
|
const existed = existsSync(target);
|
|
10752
11341
|
if (existed) {
|
|
10753
11342
|
const current = readFileSync(target, "utf8");
|
|
10754
|
-
if (!
|
|
11343
|
+
if (!isKnownTemplate(current) && !(options.force ?? false)) return {
|
|
10755
11344
|
written: target,
|
|
10756
11345
|
existed,
|
|
10757
11346
|
refused: true,
|
|
@@ -10768,6 +11357,7 @@ function ciInstall(root, gate = "advisory", options = {}) {
|
|
|
10768
11357
|
}
|
|
10769
11358
|
//#endregion
|
|
10770
11359
|
//#region src/forensics/analyze.ts
|
|
11360
|
+
const ui$14 = plainContext();
|
|
10771
11361
|
const MAX_RECORDS = 1e5;
|
|
10772
11362
|
function analyze(records, source) {
|
|
10773
11363
|
const verdicts = [];
|
|
@@ -10824,7 +11414,7 @@ function bar(ms, maxMs, width = 20) {
|
|
|
10824
11414
|
}
|
|
10825
11415
|
function renderLeaderboard(report) {
|
|
10826
11416
|
const lines = [];
|
|
10827
|
-
lines.push("
|
|
11417
|
+
lines.push(sectionHeader("FLAKINESS LEADERBOARD", ui$14));
|
|
10828
11418
|
lines.push("");
|
|
10829
11419
|
lines.push(`${report.totalTests} tests · ${report.failed} failed · ${report.flakyTests} flaky · ${report.retriedTests} retried`);
|
|
10830
11420
|
lines.push("");
|
|
@@ -10858,7 +11448,7 @@ function renderFlakyMd(report) {
|
|
|
10858
11448
|
lines.push("| Status | Test | File | Attempts | Duration |");
|
|
10859
11449
|
lines.push("|--------|------|------|----------|----------|");
|
|
10860
11450
|
for (const v of top) {
|
|
10861
|
-
const status = v.passedOnRetry ?
|
|
11451
|
+
const status = v.passedOnRetry ? `${FLAKE_GLYPH} TRUE-FLAKE` : "❌ failing";
|
|
10862
11452
|
lines.push(`| ${status} | \`${v.title}\` | \`${v.file}\` | ${v.attempts} | ${(v.totalDurationMs / 1e3).toFixed(1)}s |`);
|
|
10863
11453
|
}
|
|
10864
11454
|
lines.push("");
|
|
@@ -11085,6 +11675,7 @@ function listFiles(dir) {
|
|
|
11085
11675
|
}
|
|
11086
11676
|
//#endregion
|
|
11087
11677
|
//#region src/forensics/triage.ts
|
|
11678
|
+
const ui$13 = plainContext();
|
|
11088
11679
|
const QUARANTINE_MIN_ATTEMPTS = 2;
|
|
11089
11680
|
/** Deterministic triage table rows, worst first. */
|
|
11090
11681
|
function triageRows(report) {
|
|
@@ -11113,7 +11704,7 @@ function suggestAction(v) {
|
|
|
11113
11704
|
function renderTriage(report) {
|
|
11114
11705
|
const rows = triageRows(report);
|
|
11115
11706
|
const lines = [];
|
|
11116
|
-
lines.push("
|
|
11707
|
+
lines.push(sectionHeader("FLAKY TRIAGE — auto-generated, do not edit", ui$13));
|
|
11117
11708
|
lines.push("");
|
|
11118
11709
|
if (rows.length === 0) {
|
|
11119
11710
|
lines.push("Nothing to triage — no failures or retries in this run.");
|
|
@@ -11144,7 +11735,7 @@ function renderTriageMd(report) {
|
|
|
11144
11735
|
lines.push("| Status | Test | File | Attempts | Duration | Suggested action | Quarantine? |");
|
|
11145
11736
|
lines.push("|--------|------|------|----------|----------|------------------|-------------|");
|
|
11146
11737
|
for (const r of rows) {
|
|
11147
|
-
const status = r.passedOnRetry ?
|
|
11738
|
+
const status = r.passedOnRetry ? `${FLAKE_GLYPH} TRUE-FLAKE` : "❌ FAILING";
|
|
11148
11739
|
lines.push(`| ${status} | \`${r.title}\` | \`${r.file}\` | ${r.attempts} | ${(r.totalDurationMs / 1e3).toFixed(1)}s | ${r.suggestedAction} | ${r.proposedQuarantine ? "✅ propose" : "—"} |`);
|
|
11149
11740
|
}
|
|
11150
11741
|
const q = rows.filter((r) => r.proposedQuarantine).length;
|
|
@@ -11223,7 +11814,340 @@ function writeBadge(result, options) {
|
|
|
11223
11814
|
return path;
|
|
11224
11815
|
}
|
|
11225
11816
|
//#endregion
|
|
11817
|
+
//#region src/commands/help.ts
|
|
11818
|
+
/** Ordered registry — grouped by the same categories the overview uses. */
|
|
11819
|
+
const HELP_ENTRIES = [
|
|
11820
|
+
{
|
|
11821
|
+
verb: "ci install",
|
|
11822
|
+
summary: "generate the PR workflow (scan + annotations + gate)",
|
|
11823
|
+
usage: "mjolnir ci install [--gate advisory|error|warning] [--force]",
|
|
11824
|
+
examples: ["mjolnir ci install", "mjolnir ci install --gate error --force"],
|
|
11825
|
+
next: "mjolnir --scope changed"
|
|
11826
|
+
},
|
|
11827
|
+
{
|
|
11828
|
+
verb: "pr-comment",
|
|
11829
|
+
summary: "render a scoped PR comment (Markdown)",
|
|
11830
|
+
usage: "mjolnir pr-comment [path] [--base <ref>]",
|
|
11831
|
+
examples: ["mjolnir pr-comment .", "mjolnir pr-comment . --base origin/main"]
|
|
11832
|
+
},
|
|
11833
|
+
{
|
|
11834
|
+
verb: "summary",
|
|
11835
|
+
summary: "CI annotations + step summary from a saved --json report",
|
|
11836
|
+
usage: "mjolnir summary [mjolnir.json] [--stdout] [--path-prefix <dir>]",
|
|
11837
|
+
examples: ["mjolnir --json > mjolnir.json && mjolnir summary mjolnir.json", "mjolnir summary mjolnir.json --stdout"]
|
|
11838
|
+
},
|
|
11839
|
+
{
|
|
11840
|
+
verb: "forensics",
|
|
11841
|
+
summary: "runtime evidence from a real run: retries, flakes, durations",
|
|
11842
|
+
usage: "mjolnir forensics <test-results-dir-or-report-file> [--no-flaky-md]",
|
|
11843
|
+
examples: ["mjolnir forensics test-results"]
|
|
11844
|
+
},
|
|
11845
|
+
{
|
|
11846
|
+
verb: "triage",
|
|
11847
|
+
summary: "flaky-triage proposal + TRIAGE.md meeting artifact",
|
|
11848
|
+
usage: "mjolnir triage <test-results-dir-or-report-file> [--no-md]",
|
|
11849
|
+
examples: ["mjolnir triage test-results --no-md"]
|
|
11850
|
+
},
|
|
11851
|
+
{
|
|
11852
|
+
verb: "pw-report",
|
|
11853
|
+
summary: "Playwright run summary (counts, true flakes, slowest tests)",
|
|
11854
|
+
usage: "mjolnir pw-report <playwright-report.json | test-results-dir>",
|
|
11855
|
+
examples: ["mjolnir pw-report test-results"]
|
|
11856
|
+
},
|
|
11857
|
+
{
|
|
11858
|
+
verb: "doctor:playwright",
|
|
11859
|
+
summary: "Playwright deep scan + Selector Health report",
|
|
11860
|
+
usage: "mjolnir doctor:playwright [path]",
|
|
11861
|
+
examples: ["mjolnir doctor:playwright e2e"]
|
|
11862
|
+
},
|
|
11863
|
+
{
|
|
11864
|
+
verb: "fix",
|
|
11865
|
+
summary: "apply safe auto-fixes with proof (re-scan verifies each)",
|
|
11866
|
+
usage: "mjolnir fix [path] [--dry-run]",
|
|
11867
|
+
examples: ["mjolnir fix --dry-run", "mjolnir fix ."]
|
|
11868
|
+
},
|
|
11869
|
+
{
|
|
11870
|
+
verb: "baseline",
|
|
11871
|
+
summary: "snapshot the current finding set as the comparison point",
|
|
11872
|
+
usage: "mjolnir baseline [path]",
|
|
11873
|
+
examples: ["mjolnir baseline", "mjolnir diff"],
|
|
11874
|
+
next: "mjolnir diff"
|
|
11875
|
+
},
|
|
11876
|
+
{
|
|
11877
|
+
verb: "diff",
|
|
11878
|
+
summary: "compare a fresh scan against the baseline — new/worsened only",
|
|
11879
|
+
usage: "mjolnir diff [path]",
|
|
11880
|
+
examples: ["mjolnir diff"]
|
|
11881
|
+
},
|
|
11882
|
+
{
|
|
11883
|
+
verb: "impact",
|
|
11884
|
+
summary: "what a commit introduced vs resolved, since a prior commit",
|
|
11885
|
+
usage: "mjolnir impact [path] [--since <ref>]",
|
|
11886
|
+
examples: ["mjolnir impact . --since HEAD~1"]
|
|
11887
|
+
},
|
|
11888
|
+
{
|
|
11889
|
+
verb: "debt",
|
|
11890
|
+
summary: "test-debt register with an estimated quarterly cost",
|
|
11891
|
+
usage: "mjolnir debt [path]",
|
|
11892
|
+
examples: ["mjolnir debt"]
|
|
11893
|
+
},
|
|
11894
|
+
{
|
|
11895
|
+
verb: "handover",
|
|
11896
|
+
summary: "new-QA onboarding map of the suite",
|
|
11897
|
+
usage: "mjolnir handover [path]",
|
|
11898
|
+
examples: ["mjolnir handover"]
|
|
11899
|
+
},
|
|
11900
|
+
{
|
|
11901
|
+
verb: "stats",
|
|
11902
|
+
summary: "all-time local counters of fixes seen via diff",
|
|
11903
|
+
usage: "mjolnir stats",
|
|
11904
|
+
examples: ["mjolnir stats"]
|
|
11905
|
+
},
|
|
11906
|
+
{
|
|
11907
|
+
verb: "badge",
|
|
11908
|
+
summary: "shields.io endpoint JSON + snippet from a scan",
|
|
11909
|
+
usage: "mjolnir badge [path]",
|
|
11910
|
+
examples: ["mjolnir badge ."]
|
|
11911
|
+
},
|
|
11912
|
+
{
|
|
11913
|
+
verb: "init",
|
|
11914
|
+
summary: "detect frameworks + setup checklist (never overwrites)",
|
|
11915
|
+
usage: "mjolnir init [--interactive]",
|
|
11916
|
+
examples: ["mjolnir init"]
|
|
11917
|
+
},
|
|
11918
|
+
{
|
|
11919
|
+
verb: "explain",
|
|
11920
|
+
summary: "what/why/fix + measured FP rate for one rule",
|
|
11921
|
+
usage: "mjolnir explain <RULE-ID> [--fixtures-root <dir>]",
|
|
11922
|
+
examples: ["mjolnir explain QA-TEST-001", "mjolnir rules --unmeasured"]
|
|
11923
|
+
},
|
|
11924
|
+
{
|
|
11925
|
+
verb: "rules",
|
|
11926
|
+
summary: "rule catalog with trust metadata (md/json/unmeasured)",
|
|
11927
|
+
usage: "mjolnir rules [--md] [--unmeasured|--measured] [--external]",
|
|
11928
|
+
examples: ["mjolnir rules --md --unmeasured"]
|
|
11929
|
+
},
|
|
11930
|
+
{
|
|
11931
|
+
verb: "suppressions",
|
|
11932
|
+
summary: "list suppressed findings (governance transparency)",
|
|
11933
|
+
usage: "mjolnir suppressions",
|
|
11934
|
+
examples: ["mjolnir suppressions"]
|
|
11935
|
+
},
|
|
11936
|
+
{
|
|
11937
|
+
verb: "create-rule",
|
|
11938
|
+
summary: "scaffold a new rule + fixtures (must-fire, must-not-fire)",
|
|
11939
|
+
usage: "mjolnir create-rule <QA-XXX-nnn> --title \"Rule title\"",
|
|
11940
|
+
examples: ["mjolnir create-rule QA-PW-131 --title \"No request waits\""]
|
|
11941
|
+
},
|
|
11942
|
+
{
|
|
11943
|
+
verb: "doctor",
|
|
11944
|
+
summary: "self-audit of the rule base (fixture firewall, tiers, caps)",
|
|
11945
|
+
usage: "mjolnir doctor [repo-root]",
|
|
11946
|
+
examples: ["mjolnir doctor"]
|
|
11947
|
+
}
|
|
11948
|
+
];
|
|
11949
|
+
/** Scan-flag entries documented per-flag via the overview. */
|
|
11950
|
+
const HELP_FLAGS = [
|
|
11951
|
+
{
|
|
11952
|
+
flag: "--json",
|
|
11953
|
+
summary: "machine-readable output"
|
|
11954
|
+
},
|
|
11955
|
+
{
|
|
11956
|
+
flag: "--format sarif",
|
|
11957
|
+
summary: "SARIF 2.1 for GitHub Code Scanning"
|
|
11958
|
+
},
|
|
11959
|
+
{
|
|
11960
|
+
flag: "--format mermaid",
|
|
11961
|
+
summary: "test-architecture diagram"
|
|
11962
|
+
},
|
|
11963
|
+
{
|
|
11964
|
+
flag: "--tone blunt",
|
|
11965
|
+
summary: "blunter, pattern-mocking messages"
|
|
11966
|
+
},
|
|
11967
|
+
{
|
|
11968
|
+
flag: "--verbose",
|
|
11969
|
+
summary: "show all findings"
|
|
11970
|
+
},
|
|
11971
|
+
{
|
|
11972
|
+
flag: "--scope changed",
|
|
11973
|
+
summary: "only new/changed lines vs merge-base"
|
|
11974
|
+
},
|
|
11975
|
+
{
|
|
11976
|
+
flag: "--max-duration <sec>",
|
|
11977
|
+
summary: "analysis time budget"
|
|
11978
|
+
},
|
|
11979
|
+
{
|
|
11980
|
+
flag: "--width <cols>",
|
|
11981
|
+
summary: "override terminal width"
|
|
11982
|
+
},
|
|
11983
|
+
{
|
|
11984
|
+
flag: "--ascii / --no-ascii",
|
|
11985
|
+
summary: "force glyph mode"
|
|
11986
|
+
},
|
|
11987
|
+
{
|
|
11988
|
+
flag: "--strict",
|
|
11989
|
+
summary: "include quarantine-tier rules"
|
|
11990
|
+
},
|
|
11991
|
+
{
|
|
11992
|
+
flag: "--debug",
|
|
11993
|
+
summary: "print swallowed rule crashes"
|
|
11994
|
+
},
|
|
11995
|
+
{
|
|
11996
|
+
flag: "--cache",
|
|
11997
|
+
summary: "reuse local per-file verdicts"
|
|
11998
|
+
},
|
|
11999
|
+
{
|
|
12000
|
+
flag: "--no-progress",
|
|
12001
|
+
summary: "no live scan-progress line on stderr"
|
|
12002
|
+
}
|
|
12003
|
+
];
|
|
12004
|
+
const EXIT_CODE_TABLE = [
|
|
12005
|
+
["0", "clean — no findings or the requested artifact was produced"],
|
|
12006
|
+
["1", "errors found (or the diff/impact verdict says the PR should not merge)"],
|
|
12007
|
+
["2", "partial — the scan ran but was truncated, or input was unreadable"],
|
|
12008
|
+
["10", "usage — bad flags or arguments; help is printed"],
|
|
12009
|
+
["20", "crash — internal error; rerun with --debug for the stack trace"]
|
|
12010
|
+
];
|
|
12011
|
+
function findEntry(verb) {
|
|
12012
|
+
return HELP_ENTRIES.find((e) => e.verb === verb);
|
|
12013
|
+
}
|
|
12014
|
+
/** True when `mjolnir help <verb>` has a detailed page. */
|
|
12015
|
+
function hasVerbHelp(verb) {
|
|
12016
|
+
return findEntry(verb) !== void 0;
|
|
12017
|
+
}
|
|
12018
|
+
/** One per-verb help page: summary, usage, examples, next step. */
|
|
12019
|
+
function renderVerbHelp(verb) {
|
|
12020
|
+
const e = findEntry(verb);
|
|
12021
|
+
if (!e) return [
|
|
12022
|
+
` No detailed help for "${verb}".`,
|
|
12023
|
+
"",
|
|
12024
|
+
" $ mjolnir --help",
|
|
12025
|
+
""
|
|
12026
|
+
].join("\n");
|
|
12027
|
+
const lines = [];
|
|
12028
|
+
lines.push(` ${e.verb} — ${e.summary}`);
|
|
12029
|
+
lines.push("");
|
|
12030
|
+
lines.push(` Usage:`);
|
|
12031
|
+
lines.push(` ${e.usage}`);
|
|
12032
|
+
lines.push("");
|
|
12033
|
+
lines.push(` Examples:`);
|
|
12034
|
+
for (const ex of e.examples) lines.push(` $ ${ex}`);
|
|
12035
|
+
if (e.next) {
|
|
12036
|
+
lines.push("");
|
|
12037
|
+
lines.push(` Next step:`);
|
|
12038
|
+
lines.push(` $ ${e.next}`);
|
|
12039
|
+
}
|
|
12040
|
+
lines.push("");
|
|
12041
|
+
return lines.join("\n");
|
|
12042
|
+
}
|
|
12043
|
+
const DOCS_URL = "https://github.com/Sergey-Bar/Mjolnir#readme";
|
|
12044
|
+
/** The overview's grouped one-line sections, in display order. */
|
|
12045
|
+
const GROUPS = [
|
|
12046
|
+
{
|
|
12047
|
+
title: "Scan",
|
|
12048
|
+
verbs: []
|
|
12049
|
+
},
|
|
12050
|
+
{
|
|
12051
|
+
title: "CI & PRs",
|
|
12052
|
+
verbs: [
|
|
12053
|
+
"ci install",
|
|
12054
|
+
"summary",
|
|
12055
|
+
"pr-comment",
|
|
12056
|
+
"badge",
|
|
12057
|
+
"impact",
|
|
12058
|
+
"baseline",
|
|
12059
|
+
"diff"
|
|
12060
|
+
]
|
|
12061
|
+
},
|
|
12062
|
+
{
|
|
12063
|
+
title: "Forensics",
|
|
12064
|
+
verbs: [
|
|
12065
|
+
"forensics",
|
|
12066
|
+
"triage",
|
|
12067
|
+
"pw-report",
|
|
12068
|
+
"doctor:playwright"
|
|
12069
|
+
]
|
|
12070
|
+
},
|
|
12071
|
+
{
|
|
12072
|
+
title: "Maintenance",
|
|
12073
|
+
verbs: [
|
|
12074
|
+
"fix",
|
|
12075
|
+
"debt",
|
|
12076
|
+
"stats",
|
|
12077
|
+
"suppressions",
|
|
12078
|
+
"handover",
|
|
12079
|
+
"init",
|
|
12080
|
+
"doctor",
|
|
12081
|
+
"create-rule"
|
|
12082
|
+
]
|
|
12083
|
+
},
|
|
12084
|
+
{
|
|
12085
|
+
title: "Meta",
|
|
12086
|
+
verbs: ["rules", "explain"]
|
|
12087
|
+
}
|
|
12088
|
+
];
|
|
12089
|
+
const SCAN_SUMMARY_LINES = ["mjolnir [path] full-repo scan + WORTHINESS score"];
|
|
12090
|
+
/**
|
|
12091
|
+
* The redesigned root help (plan M2): grouped sections, one-line
|
|
12092
|
+
* descriptions, copy-pasteable examples, the frozen exit-code table and
|
|
12093
|
+
* the docs link. Content is identical whether colored or piped — the
|
|
12094
|
+
* caller decides (runHelpCommand passes a resolved palette; printUsage
|
|
12095
|
+
* stays plain).
|
|
12096
|
+
*/
|
|
12097
|
+
function renderRootHelp(schemaVersion = 1) {
|
|
12098
|
+
const byVerb = new Map(HELP_ENTRIES.map((e) => [e.verb, e]));
|
|
12099
|
+
const lines = [];
|
|
12100
|
+
lines.push("🔨 mjölnir — verification trust engine for test suites and CI pipelines");
|
|
12101
|
+
lines.push("");
|
|
12102
|
+
lines.push("Usage: mjolnir [path] [options] · mjolnir <subcommand> [args] · mjolnir help <verb>");
|
|
12103
|
+
lines.push("");
|
|
12104
|
+
lines.push("The product is one command in CI:");
|
|
12105
|
+
lines.push("");
|
|
12106
|
+
lines.push(" mjolnir --scope changed scan only what the branch touched; exit 1 on");
|
|
12107
|
+
lines.push(" new findings. `mjolnir ci install` writes the");
|
|
12108
|
+
lines.push(" workflow for you.");
|
|
12109
|
+
lines.push("");
|
|
12110
|
+
lines.push("Everything else is optional.");
|
|
12111
|
+
lines.push("");
|
|
12112
|
+
lines.push(" " + SCAN_SUMMARY_LINES[0]);
|
|
12113
|
+
lines.push(" mjolnir explain <RULE-ID> what/why/fix + measured FP rate for one rule");
|
|
12114
|
+
lines.push(" mjolnir rules --unmeasured the rules running on assumption, not measurement");
|
|
12115
|
+
lines.push("");
|
|
12116
|
+
lines.push("Options:");
|
|
12117
|
+
for (const f of HELP_FLAGS) {
|
|
12118
|
+
const pad = f.flag.padEnd(22);
|
|
12119
|
+
lines.push(` ${pad}${f.summary}`);
|
|
12120
|
+
}
|
|
12121
|
+
lines.push(" -v, --version print the installed version and exit");
|
|
12122
|
+
lines.push(" -h, --help show this help");
|
|
12123
|
+
lines.push("");
|
|
12124
|
+
for (const g of GROUPS) {
|
|
12125
|
+
lines.push(`Subcommands — ${g.title}:`);
|
|
12126
|
+
for (const verb of g.verbs) {
|
|
12127
|
+
const e = byVerb.get(verb);
|
|
12128
|
+
if (!e) continue;
|
|
12129
|
+
const usage = e.usage.replace(/^mjolnir /, "").padEnd(46);
|
|
12130
|
+
lines.push(` ${usage}${e.summary}`);
|
|
12131
|
+
}
|
|
12132
|
+
lines.push("");
|
|
12133
|
+
}
|
|
12134
|
+
lines.push("Copy-paste starts:");
|
|
12135
|
+
lines.push(" $ mjolnir score this repo's test suite");
|
|
12136
|
+
lines.push(" $ mjolnir --scope changed CI gate: only what the branch touched");
|
|
12137
|
+
lines.push(" $ mjolnir ci install write the PR workflow");
|
|
12138
|
+
lines.push(" $ mjolnir forensics test-results where the flakes hide");
|
|
12139
|
+
lines.push("");
|
|
12140
|
+
lines.push("Per-command help: mjolnir help <verb> (e.g. mjolnir help fix)");
|
|
12141
|
+
lines.push("");
|
|
12142
|
+
lines.push(`Exit codes: ${EXIT_CODE_TABLE.map(([c]) => c).join(" · ")}`);
|
|
12143
|
+
for (const [code, meaning] of EXIT_CODE_TABLE) lines.push(` ${code.padEnd(3)} ${meaning}`);
|
|
12144
|
+
lines.push("");
|
|
12145
|
+
lines.push(`Docs: ${DOCS_URL} (JSON schemaVersion ${schemaVersion}, additive-only)`);
|
|
12146
|
+
return lines.join("\n");
|
|
12147
|
+
}
|
|
12148
|
+
//#endregion
|
|
11226
12149
|
//#region src/commands/debt.ts
|
|
12150
|
+
const ui$12 = plainContext();
|
|
11227
12151
|
/** Cost model (documented, conservative): hours/quarter per occurrence. */
|
|
11228
12152
|
const COST_MODEL = {
|
|
11229
12153
|
"QA-TEST-004": {
|
|
@@ -11304,21 +12228,17 @@ function computeDebt(result) {
|
|
|
11304
12228
|
function renderDebt(result) {
|
|
11305
12229
|
const { classes, totalHours } = computeDebt(result);
|
|
11306
12230
|
const lines = [];
|
|
11307
|
-
lines.push("
|
|
12231
|
+
lines.push(sectionHeader("TEST DEBT REGISTER", ui$12));
|
|
11308
12232
|
lines.push("");
|
|
11309
12233
|
if (classes.length === 0) {
|
|
11310
12234
|
lines.push("No tracked debt classes found — the suite is clean.");
|
|
11311
12235
|
return lines.join("\n");
|
|
11312
12236
|
}
|
|
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("╘══════════════════════════════════════════════════════╛");
|
|
12237
|
+
const rows = ["DEBT CLASS", ""];
|
|
12238
|
+
rows[0] = `DEBT CLASS COUNT EST. HOURS/QUARTER`;
|
|
12239
|
+
for (const c of classes) rows.push(`${c.label.padEnd(26)} ${String(c.count).padStart(5)} ${c.estHoursPerQuarter.toFixed(1).padStart(8)}`);
|
|
12240
|
+
rows.push(`TOTAL ESTIMATED DRAG: ~${totalHours.toFixed(1)} engineer-hours/qtr`);
|
|
12241
|
+
for (const row of panel(rows, ui$12)) lines.push(row);
|
|
11322
12242
|
lines.push("");
|
|
11323
12243
|
lines.push("Cost model is conservative and documented in src/commands/debt.ts.");
|
|
11324
12244
|
return lines.join("\n");
|
|
@@ -11338,6 +12258,7 @@ function renderDebt(result) {
|
|
|
11338
12258
|
* The generated rule intentionally FAILS its fixtures until the author
|
|
11339
12259
|
* implements it — you cannot ship a stub.
|
|
11340
12260
|
*/
|
|
12261
|
+
const ui$11 = plainContext();
|
|
11341
12262
|
/** Map rule family prefix → source directory + default category. */
|
|
11342
12263
|
const FAMILY_META = {
|
|
11343
12264
|
test: {
|
|
@@ -11475,7 +12396,7 @@ function camel(idLower) {
|
|
|
11475
12396
|
function renderScaffoldReport(result) {
|
|
11476
12397
|
if (!result.ok) return `create-rule failed: ${result.error}`;
|
|
11477
12398
|
const lines = [];
|
|
11478
|
-
lines.push("
|
|
12399
|
+
lines.push(sectionHeader("RULE SCAFFOLD CREATED", ui$11));
|
|
11479
12400
|
lines.push("");
|
|
11480
12401
|
for (const f of result.files) lines.push(` + ${f}`);
|
|
11481
12402
|
lines.push("");
|
|
@@ -11488,6 +12409,7 @@ function renderScaffoldReport(result) {
|
|
|
11488
12409
|
}
|
|
11489
12410
|
//#endregion
|
|
11490
12411
|
//#region src/commands/handover.ts
|
|
12412
|
+
const ui$10 = plainContext();
|
|
11491
12413
|
const FAKE_GREEN_RULES = /* @__PURE__ */ new Set([
|
|
11492
12414
|
"QA-TEST-003",
|
|
11493
12415
|
"QA-PY-003",
|
|
@@ -11557,9 +12479,7 @@ function buildHandover(scan, forensics) {
|
|
|
11557
12479
|
}
|
|
11558
12480
|
function renderHandover(map) {
|
|
11559
12481
|
const lines = [];
|
|
11560
|
-
lines.push("
|
|
11561
|
-
lines.push("║ WELCOME TO THE TEST SUITE — WHAT YOU NEED TO KNOW");
|
|
11562
|
-
lines.push("╚══════════════════════════════════════════════╝");
|
|
12482
|
+
lines.push(sectionHeader("WELCOME TO THE TEST SUITE — WHAT YOU NEED TO KNOW", ui$10));
|
|
11563
12483
|
lines.push("");
|
|
11564
12484
|
lines.push(map.summaryLine);
|
|
11565
12485
|
for (const s of map.sections) {
|
|
@@ -11591,6 +12511,7 @@ function renderHandover(map) {
|
|
|
11591
12511
|
* catch in *other* tools. Local-only, zero network (verified by
|
|
11592
12512
|
* tests/privacy-network-isolation.spec.ts, which scans this file too).
|
|
11593
12513
|
*/
|
|
12514
|
+
const ui$9 = plainContext();
|
|
11594
12515
|
function git(root, args) {
|
|
11595
12516
|
try {
|
|
11596
12517
|
return execFileSync("git", [
|
|
@@ -11765,7 +12686,7 @@ async function computeImpact(root, options) {
|
|
|
11765
12686
|
}
|
|
11766
12687
|
function renderImpact(report) {
|
|
11767
12688
|
const lines = [];
|
|
11768
|
-
lines.push("
|
|
12689
|
+
lines.push(sectionHeader("IMPACT REPORT", ui$9));
|
|
11769
12690
|
lines.push("");
|
|
11770
12691
|
if (!report.hasComparison) {
|
|
11771
12692
|
lines.push(`UNKNOWN — no comparison could be made (${report.unknownReason ?? "unknown reason"}).`);
|
|
@@ -11819,6 +12740,7 @@ function renderImpact(report) {
|
|
|
11819
12740
|
* file if they want a shared baseline; that's a deliberate choice this
|
|
11820
12741
|
* command does not make for them.
|
|
11821
12742
|
*/
|
|
12743
|
+
const ui$8 = plainContext();
|
|
11822
12744
|
const DEFAULT_BASELINE_PATH = join(".mjolnir", "baseline.json");
|
|
11823
12745
|
function fingerprint(f) {
|
|
11824
12746
|
return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
|
|
@@ -11910,21 +12832,21 @@ function diffAgainstBaseline(result, baseline) {
|
|
|
11910
12832
|
}
|
|
11911
12833
|
function renderBaselineSaved(path, count, replaced) {
|
|
11912
12834
|
const lines = [
|
|
11913
|
-
"
|
|
12835
|
+
sectionHeader("BASELINE SAVED", ui$8),
|
|
11914
12836
|
"",
|
|
11915
12837
|
`Captured ${count} finding${count === 1 ? "" : "s"} to ${path}.`
|
|
11916
12838
|
];
|
|
11917
12839
|
if (replaced?.backupPath !== void 0) lines.push(`Replaced an existing baseline — the previous one was saved to ${replaced.backupPath}.`);
|
|
11918
|
-
lines.push("
|
|
12840
|
+
lines.push(nextStep("mjolnir diff", ui$8) + " — see only what's new.");
|
|
11919
12841
|
return lines.join("\n");
|
|
11920
12842
|
}
|
|
11921
12843
|
function renderBaselineDiff(diff) {
|
|
11922
12844
|
const lines = [];
|
|
11923
|
-
lines.push("
|
|
12845
|
+
lines.push(sectionHeader("DIFF AGAINST BASELINE", ui$8));
|
|
11924
12846
|
lines.push("");
|
|
11925
12847
|
if (!diff.hasBaseline) {
|
|
11926
12848
|
lines.push("UNKNOWN — no baseline found.");
|
|
11927
|
-
lines.push("
|
|
12849
|
+
lines.push(nextStep("mjolnir baseline", ui$8) + " to capture a comparison point.");
|
|
11928
12850
|
return lines.join("\n");
|
|
11929
12851
|
}
|
|
11930
12852
|
lines.push(`Baseline captured ${diff.baselineCapturedAt ?? "unknown time"} at commit ${diff.baselineCommit ?? "unknown"}.`);
|
|
@@ -11970,6 +12892,7 @@ function renderBaselineDiff(diff) {
|
|
|
11970
12892
|
* fix recorded by `diff`), announced once and never repeated. Display-only:
|
|
11971
12893
|
* it does not change scores, exit codes or the JSON schema.
|
|
11972
12894
|
*/
|
|
12895
|
+
const ui$7 = plainContext();
|
|
11973
12896
|
const DEFAULT_STATS_PATH = join(".mjolnir", "stats.json");
|
|
11974
12897
|
const MILESTONE_MESSAGES = {
|
|
11975
12898
|
"first-clean-scan": "MILESTONE: first flawless scan recorded for this repo (score 100, zero findings).",
|
|
@@ -12057,12 +12980,13 @@ function saveStats(stats, outPath) {
|
|
|
12057
12980
|
}
|
|
12058
12981
|
function renderStats(stats) {
|
|
12059
12982
|
const lines = [];
|
|
12060
|
-
lines.push("
|
|
12983
|
+
lines.push(sectionHeader("ALL-TIME STATS (this machine, this repo)", ui$7));
|
|
12061
12984
|
lines.push("");
|
|
12062
12985
|
if (!stats || stats.recordedFixEvents === 0) {
|
|
12063
12986
|
lines.push("No fixes recorded yet.");
|
|
12064
|
-
lines.push("
|
|
12065
|
-
lines.push("
|
|
12987
|
+
lines.push("Fixes are counted here only when observed by mjolnir diff —", "capture a baseline first, then diff after making fixes:");
|
|
12988
|
+
lines.push(nextStep("mjolnir baseline", ui$7));
|
|
12989
|
+
lines.push(nextStep("mjolnir diff", ui$7));
|
|
12066
12990
|
lines.push("");
|
|
12067
12991
|
lines.push("UNKNOWN: totals before tracking started. This command only counts");
|
|
12068
12992
|
lines.push("what it has personally witnessed via mjolnir diff.");
|
|
@@ -12080,87 +13004,6 @@ function renderStats(stats) {
|
|
|
12080
13004
|
return lines.join("\n");
|
|
12081
13005
|
}
|
|
12082
13006
|
//#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
13007
|
//#region src/commands/init.ts
|
|
12165
13008
|
/**
|
|
12166
13009
|
* `mjolnir init` — onboarding wizard (Tier 2 #10).
|
|
@@ -12172,6 +13015,7 @@ function renderPrComment(result, options = {}) {
|
|
|
12172
13015
|
*
|
|
12173
13016
|
* Idempotent: existing files are reported, never overwritten.
|
|
12174
13017
|
*/
|
|
13018
|
+
const ui$6 = plainContext();
|
|
12175
13019
|
function runInit(rootDir, workspace, options = {}) {
|
|
12176
13020
|
const steps = [];
|
|
12177
13021
|
const nextCommands = [];
|
|
@@ -12221,7 +13065,7 @@ function runInit(rootDir, workspace, options = {}) {
|
|
|
12221
13065
|
}
|
|
12222
13066
|
function renderInit(result) {
|
|
12223
13067
|
const lines = [];
|
|
12224
|
-
lines.push("
|
|
13068
|
+
lines.push(sectionHeader("MJÖLNIR INIT", ui$6));
|
|
12225
13069
|
lines.push("");
|
|
12226
13070
|
for (const s of result.steps) {
|
|
12227
13071
|
const icon = s.status === "advice" ? "·" : s.status === "exists" ? "=" : "-";
|
|
@@ -12230,7 +13074,7 @@ function renderInit(result) {
|
|
|
12230
13074
|
if (result.nextCommands.length > 0) {
|
|
12231
13075
|
lines.push("");
|
|
12232
13076
|
lines.push("Next commands:");
|
|
12233
|
-
for (const c of result.nextCommands) lines.push(
|
|
13077
|
+
for (const c of result.nextCommands) lines.push(nextStep(c, ui$6));
|
|
12234
13078
|
}
|
|
12235
13079
|
lines.push("");
|
|
12236
13080
|
lines.push("Existing files are never overwritten — init is safe to re-run.");
|
|
@@ -12248,6 +13092,7 @@ function tryReadPackageJson(rootDir) {
|
|
|
12248
13092
|
}
|
|
12249
13093
|
//#endregion
|
|
12250
13094
|
//#region src/commands/pw-report.ts
|
|
13095
|
+
const ui$5 = plainContext();
|
|
12251
13096
|
function summarizePwRun(report) {
|
|
12252
13097
|
const slowest = [...report.verdicts].sort((a, b) => b.totalDurationMs - a.totalDurationMs).slice(0, 5).map((v) => ({
|
|
12253
13098
|
title: v.title,
|
|
@@ -12267,10 +13112,10 @@ function summarizePwRun(report) {
|
|
|
12267
13112
|
}
|
|
12268
13113
|
function renderPwRunSummary(s) {
|
|
12269
13114
|
const lines = [];
|
|
12270
|
-
lines.push("
|
|
13115
|
+
lines.push(sectionHeader("MJÖLNIR — RUN SUMMARY", ui$5));
|
|
12271
13116
|
lines.push("");
|
|
12272
13117
|
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 ·
|
|
13118
|
+
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
13119
|
const secs = (s.wallTimeMs / 1e3).toFixed(1);
|
|
12275
13120
|
lines.push(`⏱ total test time: ${secs}s`);
|
|
12276
13121
|
if (s.slowest.length > 0 && s.slowest[0].ms > 0) {
|
|
@@ -12298,6 +13143,7 @@ function renderPwRunSummary(s) {
|
|
|
12298
13143
|
* Everything else stays suggestion-only. No AST surgery on heuristic
|
|
12299
13144
|
* findings — false fixes would break the brand promise.
|
|
12300
13145
|
*/
|
|
13146
|
+
const ui$4 = plainContext();
|
|
12301
13147
|
const MAX_FILE_BYTES = 524288;
|
|
12302
13148
|
/**
|
|
12303
13149
|
* Path-containment guard (adversarial-audit wave; hardened per audit
|
|
@@ -12654,7 +13500,7 @@ function fixVerified(edit, fixedText, originalText) {
|
|
|
12654
13500
|
}
|
|
12655
13501
|
function renderFixReport(results, dryRun) {
|
|
12656
13502
|
const lines = [];
|
|
12657
|
-
lines.push(dryRun ? "
|
|
13503
|
+
lines.push(sectionHeader(dryRun ? "FIX PLAN (dry-run)" : "FIX REPORT", ui$4));
|
|
12658
13504
|
lines.push("");
|
|
12659
13505
|
if (results.length === 0) {
|
|
12660
13506
|
lines.push("No safe auto-fixes available for these findings.");
|
|
@@ -12662,7 +13508,7 @@ function renderFixReport(results, dryRun) {
|
|
|
12662
13508
|
return lines.join("\n");
|
|
12663
13509
|
}
|
|
12664
13510
|
for (const r of results) {
|
|
12665
|
-
const icon = r.status === "applied" ?
|
|
13511
|
+
const icon = r.status === "applied" ? okIcon(ui$4) : r.status === "planned" ? "▸" : r.status === "failed" ? "✗" : "·";
|
|
12666
13512
|
lines.push(`${icon} [${r.ruleId}] ${r.file}:${r.line} — ${r.description}`);
|
|
12667
13513
|
}
|
|
12668
13514
|
const applied = results.filter((r) => r.status === "applied").length;
|
|
@@ -12722,6 +13568,7 @@ function isProvisional(rule) {
|
|
|
12722
13568
|
*
|
|
12723
13569
|
* Exit codes reuse the frozen set: 0 healthy · 1 violations · 20 crash.
|
|
12724
13570
|
*/
|
|
13571
|
+
const ui$3 = plainContext();
|
|
12725
13572
|
const VALID_ID = /^QA-(?:TEST|TQUAL|PW|CI|PY|ENV|JV|CS|CYP|SE|WDIO|PPTR|APM)-\d{3}$/;
|
|
12726
13573
|
function nonHiddenFiles(dir) {
|
|
12727
13574
|
if (!existsSync(dir)) return [];
|
|
@@ -12920,7 +13767,7 @@ function runDoctorSelfAudit(fixturesRoot) {
|
|
|
12920
13767
|
function renderDoctorReport(report) {
|
|
12921
13768
|
const lines = [
|
|
12922
13769
|
"",
|
|
12923
|
-
"
|
|
13770
|
+
sectionHeader("MJÖLNIR — SELF-AUDIT", ui$3),
|
|
12924
13771
|
""
|
|
12925
13772
|
];
|
|
12926
13773
|
for (const c of report.checks) {
|
|
@@ -13030,6 +13877,7 @@ function firstFixtureFile(dir) {
|
|
|
13030
13877
|
* must-fire fixture, so the example shown is real detector output, not
|
|
13031
13878
|
* hand-written prose that can drift from what the rule actually does.
|
|
13032
13879
|
*/
|
|
13880
|
+
const ui$2 = plainContext();
|
|
13033
13881
|
/**
|
|
13034
13882
|
* Runs the rule against its own must-fire fixture to get one real,
|
|
13035
13883
|
* concrete example finding. `fixturesRoot` defaults to this repo's own
|
|
@@ -13102,7 +13950,7 @@ function renderExplain(result) {
|
|
|
13102
13950
|
const r = result.rule;
|
|
13103
13951
|
const evidenceLevel = r.evidenceLevel ?? deriveEvidenceLevel(r.findingType, r.confidence);
|
|
13104
13952
|
const lines = [];
|
|
13105
|
-
lines.push(
|
|
13953
|
+
lines.push(sectionHeader(`${r.id} — ${r.title}`, ui$2));
|
|
13106
13954
|
lines.push("");
|
|
13107
13955
|
lines.push(`Severity: ${r.severity}`);
|
|
13108
13956
|
lines.push(`Confidence: ${r.confidence}`);
|
|
@@ -13229,6 +14077,7 @@ function applySeverityOverrides(findings, cfg) {
|
|
|
13229
14077
|
* Suppression governance (Sprint-Plan W7, Product-MVP §12).
|
|
13230
14078
|
* `mjolnir suppressions` — every suppression stays visible.
|
|
13231
14079
|
*/
|
|
14080
|
+
const ui$1 = plainContext();
|
|
13232
14081
|
function loadSuppressions(root) {
|
|
13233
14082
|
const { config, path } = loadConfig(root);
|
|
13234
14083
|
const anchor = path ? statSync(path).mtime : void 0;
|
|
@@ -13247,7 +14096,7 @@ function renderSuppressions(report) {
|
|
|
13247
14096
|
if (report.total === 0) return "\nNo suppressed findings. Full transparency maintained.\n";
|
|
13248
14097
|
const lines = [
|
|
13249
14098
|
"",
|
|
13250
|
-
"
|
|
14099
|
+
sectionHeader("QUALITY GOVERNANCE", ui$1),
|
|
13251
14100
|
"",
|
|
13252
14101
|
`Suppressed findings: ${report.total}`,
|
|
13253
14102
|
`Active: ${report.active}`,
|
|
@@ -13637,6 +14486,7 @@ const LOCATOR_RISK = {
|
|
|
13637
14486
|
* OK — data-testid attribute selectors
|
|
13638
14487
|
* BAD — CSS class chains, structural selectors, XPath (brittle)
|
|
13639
14488
|
*/
|
|
14489
|
+
const ui = plainContext();
|
|
13640
14490
|
function classifyLocator(line) {
|
|
13641
14491
|
if (!/locator|getBy|\$x/.test(line)) return null;
|
|
13642
14492
|
if (/getBy(?:Role|Text|Label|Placeholder|AltText|Title|TestId)\s*\(/.test(line)) return "role-based";
|
|
@@ -13713,7 +14563,7 @@ function computeSpecHealth(file, lines) {
|
|
|
13713
14563
|
function renderSelectorHealth(specs) {
|
|
13714
14564
|
const lines = [
|
|
13715
14565
|
"",
|
|
13716
|
-
"
|
|
14566
|
+
sectionHeader("SELECTOR HEALTH", ui),
|
|
13717
14567
|
""
|
|
13718
14568
|
];
|
|
13719
14569
|
for (const spec of specs) {
|
|
@@ -13768,7 +14618,7 @@ function computeSelectorHealth(root, ignoreMatcher = DEFAULT_IGNORE_MATCHER) {
|
|
|
13768
14618
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
13769
14619
|
* `tests/version-consistency.spec.ts` locally.
|
|
13770
14620
|
*/
|
|
13771
|
-
const CLI_VERSION = "0.5.
|
|
14621
|
+
const CLI_VERSION = "0.5.3";
|
|
13772
14622
|
const UNIVERSAL_RULES = RULES.map(asUniversal);
|
|
13773
14623
|
/** Registered rule IDs — used to warn on unknown severityOverrides keys (M4). */
|
|
13774
14624
|
const KNOWN_RULE_IDS = new Set(RULES.map((r) => r.id));
|
|
@@ -13823,7 +14673,7 @@ async function buildUniversalRules(root, strict) {
|
|
|
13823
14673
|
* Built from the registry so the scorer never has to import it.
|
|
13824
14674
|
*/
|
|
13825
14675
|
const SUITE_INVALIDATING_RULE_IDS = new Set(RULES.filter((r) => r.suiteInvalidating === true).map((r) => r.id));
|
|
13826
|
-
function parseArgs(argv) {
|
|
14676
|
+
function parseArgs(argv, onError) {
|
|
13827
14677
|
const args = {
|
|
13828
14678
|
target: ".",
|
|
13829
14679
|
json: false,
|
|
@@ -13832,6 +14682,10 @@ function parseArgs(argv) {
|
|
|
13832
14682
|
scopeChanged: false,
|
|
13833
14683
|
format: "terminal"
|
|
13834
14684
|
};
|
|
14685
|
+
const reject = (detail) => {
|
|
14686
|
+
onError?.(detail);
|
|
14687
|
+
return null;
|
|
14688
|
+
};
|
|
13835
14689
|
for (let i = 0; i < argv.length; i++) {
|
|
13836
14690
|
const a = argv[i] ?? "";
|
|
13837
14691
|
if (a === "--json") {
|
|
@@ -13844,11 +14698,18 @@ function parseArgs(argv) {
|
|
|
13844
14698
|
else if (fmt === "json") {
|
|
13845
14699
|
args.format = "json";
|
|
13846
14700
|
args.json = true;
|
|
13847
|
-
} else if (fmt !== "terminal") return
|
|
14701
|
+
} else if (fmt !== "terminal") return reject({
|
|
14702
|
+
flag: "--format",
|
|
14703
|
+
token: fmt
|
|
14704
|
+
});
|
|
13848
14705
|
} else if (a === "--verbose") args.verbose = true;
|
|
13849
14706
|
else if (a === "--scope") {
|
|
13850
|
-
|
|
13851
|
-
|
|
14707
|
+
const mode = argv[++i];
|
|
14708
|
+
if (mode === "changed") args.scopeChanged = true;
|
|
14709
|
+
else return reject({
|
|
14710
|
+
flag: "--scope",
|
|
14711
|
+
token: mode
|
|
14712
|
+
});
|
|
13852
14713
|
} else if (a === "--base") {
|
|
13853
14714
|
const ref = argv[++i];
|
|
13854
14715
|
if (!ref || ref.startsWith("-")) return null;
|
|
@@ -13864,18 +14725,101 @@ function parseArgs(argv) {
|
|
|
13864
14725
|
} else if (a === "--ascii") args.ascii = true;
|
|
13865
14726
|
else if (a === "--no-ascii") args.ascii = false;
|
|
13866
14727
|
else if (a === "--tone") {
|
|
13867
|
-
|
|
13868
|
-
|
|
14728
|
+
const tone = argv[++i];
|
|
14729
|
+
if (tone === "blunt") args.tone = "blunt";
|
|
14730
|
+
else return reject({
|
|
14731
|
+
flag: "--tone",
|
|
14732
|
+
token: tone
|
|
14733
|
+
});
|
|
13869
14734
|
} else if (a === "--strict") args.strict = true;
|
|
13870
14735
|
else if (a === "--debug") args.debug = true;
|
|
13871
14736
|
else if (a === "--record-milestones") args.recordMilestones = true;
|
|
13872
14737
|
else if (a === "--cache") args.cache = true;
|
|
14738
|
+
else if (a === "--no-progress") args.noProgress = true;
|
|
13873
14739
|
else if (a === "--help" || a === "-h") return null;
|
|
13874
14740
|
else if (!a.startsWith("-")) args.target = a;
|
|
13875
|
-
else return
|
|
14741
|
+
else return reject({ token: a });
|
|
13876
14742
|
}
|
|
13877
14743
|
return args;
|
|
13878
14744
|
}
|
|
14745
|
+
/** Scan flags that exist — the "did you mean" candidate pool. */
|
|
14746
|
+
const KNOWN_SCAN_FLAGS = [
|
|
14747
|
+
"--json",
|
|
14748
|
+
"--format",
|
|
14749
|
+
"--verbose",
|
|
14750
|
+
"--scope",
|
|
14751
|
+
"--base",
|
|
14752
|
+
"--max-duration",
|
|
14753
|
+
"--width",
|
|
14754
|
+
"--ascii",
|
|
14755
|
+
"--no-ascii",
|
|
14756
|
+
"--tone",
|
|
14757
|
+
"--strict",
|
|
14758
|
+
"--debug",
|
|
14759
|
+
"--record-milestones",
|
|
14760
|
+
"--cache",
|
|
14761
|
+
"--help",
|
|
14762
|
+
"-h",
|
|
14763
|
+
"--version",
|
|
14764
|
+
"-v",
|
|
14765
|
+
"--dry-run"
|
|
14766
|
+
];
|
|
14767
|
+
/** Hand-rolled Levenshtein distance (plan M2: no new dependencies). */
|
|
14768
|
+
function levenshtein(a, b) {
|
|
14769
|
+
if (a === b) return 0;
|
|
14770
|
+
if (a.length === 0) return b.length;
|
|
14771
|
+
if (b.length === 0) return a.length;
|
|
14772
|
+
const memo = /* @__PURE__ */ new Map();
|
|
14773
|
+
const walk = (i, j) => {
|
|
14774
|
+
if (i === a.length) return b.length - j;
|
|
14775
|
+
if (j === b.length) return a.length - i;
|
|
14776
|
+
const key = `${i}:${j}`;
|
|
14777
|
+
const hit = memo.get(key);
|
|
14778
|
+
if (hit !== void 0) return hit;
|
|
14779
|
+
const cost = a[i] === b[j] ? 0 : 1;
|
|
14780
|
+
const best = Math.min(walk(i + 1, j) + 1, walk(i, j + 1) + 1, walk(i + 1, j + 1) + cost);
|
|
14781
|
+
memo.set(key, best);
|
|
14782
|
+
return best;
|
|
14783
|
+
};
|
|
14784
|
+
return walk(0, 0);
|
|
14785
|
+
}
|
|
14786
|
+
/** Nearest known flags within distance ≤ 2, nearest first. */
|
|
14787
|
+
function nearestFlags(flag, max = 3) {
|
|
14788
|
+
return KNOWN_SCAN_FLAGS.map((f) => ({
|
|
14789
|
+
f,
|
|
14790
|
+
d: levenshtein(flag, f)
|
|
14791
|
+
})).filter((x) => x.d <= 2).sort((x, y) => x.d - y.d).slice(0, max).map((x) => x.f);
|
|
14792
|
+
}
|
|
14793
|
+
/**
|
|
14794
|
+
* Friendly usage error (plan M2, exit 10 preserved): nearest-flag
|
|
14795
|
+
* suggestion, the valid neighbors, and the exact help command. Printed
|
|
14796
|
+
* to stderr; findings/usage stay on their documented streams.
|
|
14797
|
+
*/
|
|
14798
|
+
function usageErrorMessage(detail) {
|
|
14799
|
+
const lines = [];
|
|
14800
|
+
if (detail.flag) lines.push(`mjolnir: invalid value "${detail.token ?? ""}" for ${detail.flag}`);
|
|
14801
|
+
else lines.push(`mjolnir: unknown flag "${detail.token ?? ""}"`);
|
|
14802
|
+
if (detail.token) {
|
|
14803
|
+
const near = nearestFlags(detail.token);
|
|
14804
|
+
if (near.length > 0) lines.push(` Did you mean: ${near.join(" ")}`);
|
|
14805
|
+
}
|
|
14806
|
+
lines.push(` Run mjolnir --help for the full flag list.`);
|
|
14807
|
+
return lines.join("\n");
|
|
14808
|
+
}
|
|
14809
|
+
/**
|
|
14810
|
+
* Shared parse-or-report path for scan-backed subcommands: friendly
|
|
14811
|
+
* usage errors on stderr (exit 10), the full overview only for an
|
|
14812
|
+
* explicit help flag. Returns null when the caller must exit 10.
|
|
14813
|
+
*/
|
|
14814
|
+
function parseArgsOrUsage(argv, io) {
|
|
14815
|
+
let reported = false;
|
|
14816
|
+
const args = parseArgs(argv, (detail) => {
|
|
14817
|
+
reported = true;
|
|
14818
|
+
io.err(usageErrorMessage(detail));
|
|
14819
|
+
});
|
|
14820
|
+
if (!args && !reported) printUsage(io.out);
|
|
14821
|
+
return args;
|
|
14822
|
+
}
|
|
13879
14823
|
/**
|
|
13880
14824
|
* Workspace fallback for targets with no discoverable project root
|
|
13881
14825
|
* (package.json-less repos, Python/Java/C# trees). Exported pure so the
|
|
@@ -13972,6 +14916,11 @@ async function runScan(args, hooks = {}) {
|
|
|
13972
14916
|
testFiles: wfBucket
|
|
13973
14917
|
});
|
|
13974
14918
|
ctx.testFiles.push(...wfBucket);
|
|
14919
|
+
hooks.onProgress?.({
|
|
14920
|
+
phase: "discover",
|
|
14921
|
+
done: ctx.testFiles.length,
|
|
14922
|
+
total: ctx.testFiles.length
|
|
14923
|
+
});
|
|
13975
14924
|
let scanned = 0;
|
|
13976
14925
|
for (const path of ctx.testFiles) {
|
|
13977
14926
|
if (Date.now() > deadline) {
|
|
@@ -14010,6 +14959,12 @@ async function runScan(args, hooks = {}) {
|
|
|
14010
14959
|
for (const f of cachedFindings) findings.push(f);
|
|
14011
14960
|
continue;
|
|
14012
14961
|
}
|
|
14962
|
+
hooks.onProgress?.({
|
|
14963
|
+
phase: "parse",
|
|
14964
|
+
done: scanned,
|
|
14965
|
+
total: ctx.testFiles.length,
|
|
14966
|
+
detail: relPath
|
|
14967
|
+
});
|
|
14013
14968
|
const adapter = isWorkflow ? githubActionsAdapter : isPython ? pythonAdapter : isJava ? javaAdapter : isCs ? csharpAdapter : typescriptAdapter;
|
|
14014
14969
|
const parsedFile = {
|
|
14015
14970
|
path: relPath,
|
|
@@ -14018,7 +14973,15 @@ async function runScan(args, hooks = {}) {
|
|
|
14018
14973
|
let parsed;
|
|
14019
14974
|
const findingsStart = findings.length;
|
|
14020
14975
|
try {
|
|
14021
|
-
if (adapter.parseAst && Date.now() <= deadline)
|
|
14976
|
+
if (adapter.parseAst && Date.now() <= deadline) {
|
|
14977
|
+
hooks.onProgress?.({
|
|
14978
|
+
phase: "rules",
|
|
14979
|
+
done: scanned,
|
|
14980
|
+
total: ctx.testFiles.length,
|
|
14981
|
+
detail: relPath
|
|
14982
|
+
});
|
|
14983
|
+
parsed = await adapter.parseAst(parsedFile);
|
|
14984
|
+
}
|
|
14022
14985
|
const fileForRules = parsed ? {
|
|
14023
14986
|
...parsedFile,
|
|
14024
14987
|
ast: parsed.ast
|
|
@@ -14091,6 +15054,10 @@ async function runScan(args, hooks = {}) {
|
|
|
14091
15054
|
if (runtimeReportPath) try {
|
|
14092
15055
|
stampRuntimeCorroboration(findings, runForensics(runtimeReportPath, { writeFlakyMd: false }).report);
|
|
14093
15056
|
} catch {}
|
|
15057
|
+
hooks.onProgress?.({
|
|
15058
|
+
phase: "score",
|
|
15059
|
+
done: findings.length
|
|
15060
|
+
});
|
|
14094
15061
|
const dimensions = computeDimensions(findings);
|
|
14095
15062
|
const rawDeductions = findings.reduce((sum, f) => sum + deductionFor(f), 0);
|
|
14096
15063
|
const total = computeTotal(dimensions, findings, {
|
|
@@ -14231,7 +15198,7 @@ function runForensicsCommand(argv, io = {
|
|
|
14231
15198
|
}
|
|
14232
15199
|
return report.flakyTests > 0 || report.failed > 0 ? 1 : 0;
|
|
14233
15200
|
} catch (err) {
|
|
14234
|
-
io.err
|
|
15201
|
+
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
14235
15202
|
return 20;
|
|
14236
15203
|
}
|
|
14237
15204
|
}
|
|
@@ -14285,7 +15252,7 @@ function runDoctorCommand(argv, io = {
|
|
|
14285
15252
|
io.out(renderDoctorReport(report));
|
|
14286
15253
|
return report.healthy ? 0 : 1;
|
|
14287
15254
|
} catch (err) {
|
|
14288
|
-
io.err
|
|
15255
|
+
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
14289
15256
|
return 20;
|
|
14290
15257
|
}
|
|
14291
15258
|
}
|
|
@@ -14331,7 +15298,7 @@ function runExplainCommand(argv, io = {
|
|
|
14331
15298
|
if (!result.ok) return 10;
|
|
14332
15299
|
return 0;
|
|
14333
15300
|
} catch (err) {
|
|
14334
|
-
io.err
|
|
15301
|
+
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
14335
15302
|
return 20;
|
|
14336
15303
|
}
|
|
14337
15304
|
}
|
|
@@ -14371,25 +15338,33 @@ async function runScanCommand(argv, io = {
|
|
|
14371
15338
|
out,
|
|
14372
15339
|
err
|
|
14373
15340
|
}) {
|
|
14374
|
-
const args =
|
|
14375
|
-
if (!args)
|
|
14376
|
-
printUsage(io.out);
|
|
14377
|
-
return 10;
|
|
14378
|
-
}
|
|
15341
|
+
const args = parseArgsOrUsage(argv, io);
|
|
15342
|
+
if (!args) return 10;
|
|
14379
15343
|
const target = resolve(args.target);
|
|
14380
15344
|
const invalid = validateScanTarget(target, io.err);
|
|
14381
15345
|
if (invalid !== null) return invalid;
|
|
14382
15346
|
try {
|
|
14383
15347
|
const crashLog = [];
|
|
15348
|
+
const progress = new ProgressRenderer({
|
|
15349
|
+
stream: process$1.stderr,
|
|
15350
|
+
isTTY: shouldRenderProgress({
|
|
15351
|
+
isTTY: process$1.stderr.isTTY === true,
|
|
15352
|
+
noProgress: args.noProgress === true,
|
|
15353
|
+
machineFormat: args.format !== "terminal",
|
|
15354
|
+
env: process$1.env
|
|
15355
|
+
})
|
|
15356
|
+
});
|
|
14384
15357
|
const result = await runScan({
|
|
14385
15358
|
...args,
|
|
14386
15359
|
target
|
|
14387
15360
|
}, {
|
|
14388
15361
|
onConfigWarning: (message) => io.err(message),
|
|
15362
|
+
onProgress: (e) => progress.onEvent(e),
|
|
14389
15363
|
...args.debug ? { onRuleCrash: (ruleId, file, error) => {
|
|
14390
15364
|
crashLog.push(`${ruleId} crashed on ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
14391
15365
|
} } : {}
|
|
14392
15366
|
});
|
|
15367
|
+
progress.done();
|
|
14393
15368
|
if (args.debug && crashLog.length > 0) {
|
|
14394
15369
|
io.err(`debug: ${crashLog.length} rule crash(es) were swallowed by crash isolation:`);
|
|
14395
15370
|
for (const line of crashLog.slice(0, 50)) io.err(` ${line}`);
|
|
@@ -14425,7 +15400,7 @@ async function runScanCommand(argv, io = {
|
|
|
14425
15400
|
io.err(err.message);
|
|
14426
15401
|
return 10;
|
|
14427
15402
|
}
|
|
14428
|
-
io.err
|
|
15403
|
+
internalErrorMessage(err, io.err, args?.debug === true);
|
|
14429
15404
|
return 20;
|
|
14430
15405
|
}
|
|
14431
15406
|
}
|
|
@@ -14464,7 +15439,7 @@ function runTriageCommand(argv, io = {
|
|
|
14464
15439
|
}
|
|
14465
15440
|
return 0;
|
|
14466
15441
|
} catch (err) {
|
|
14467
|
-
io.err
|
|
15442
|
+
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
14468
15443
|
return 20;
|
|
14469
15444
|
}
|
|
14470
15445
|
}
|
|
@@ -14473,11 +15448,8 @@ async function runBadgeCommand(argv, io = {
|
|
|
14473
15448
|
out,
|
|
14474
15449
|
err
|
|
14475
15450
|
}) {
|
|
14476
|
-
const args =
|
|
14477
|
-
if (!args)
|
|
14478
|
-
printUsage(io.out);
|
|
14479
|
-
return 10;
|
|
14480
|
-
}
|
|
15451
|
+
const args = parseArgsOrUsage(argv, io);
|
|
15452
|
+
if (!args) return 10;
|
|
14481
15453
|
try {
|
|
14482
15454
|
const target = resolve(args.target);
|
|
14483
15455
|
const invalid = validateScanTarget(target, io.err);
|
|
@@ -14492,7 +15464,7 @@ async function runBadgeCommand(argv, io = {
|
|
|
14492
15464
|
io.out(renderBadgeSnippet(result));
|
|
14493
15465
|
return 0;
|
|
14494
15466
|
} catch (err) {
|
|
14495
|
-
io.err
|
|
15467
|
+
internalErrorMessage(err, io.err, args?.debug === true);
|
|
14496
15468
|
return 20;
|
|
14497
15469
|
}
|
|
14498
15470
|
}
|
|
@@ -14501,11 +15473,8 @@ async function runDebtCommand(argv, io = {
|
|
|
14501
15473
|
out,
|
|
14502
15474
|
err
|
|
14503
15475
|
}) {
|
|
14504
|
-
const args =
|
|
14505
|
-
if (!args)
|
|
14506
|
-
printUsage(io.out);
|
|
14507
|
-
return 10;
|
|
14508
|
-
}
|
|
15476
|
+
const args = parseArgsOrUsage(argv, io);
|
|
15477
|
+
if (!args) return 10;
|
|
14509
15478
|
try {
|
|
14510
15479
|
const target = resolve(args.target);
|
|
14511
15480
|
const invalid = validateScanTarget(target, io.err);
|
|
@@ -14517,7 +15486,7 @@ async function runDebtCommand(argv, io = {
|
|
|
14517
15486
|
io.out(renderDebt(result));
|
|
14518
15487
|
return 0;
|
|
14519
15488
|
} catch (err) {
|
|
14520
|
-
io.err
|
|
15489
|
+
internalErrorMessage(err, io.err, args?.debug === true);
|
|
14521
15490
|
return 20;
|
|
14522
15491
|
}
|
|
14523
15492
|
}
|
|
@@ -14527,11 +15496,8 @@ async function runFixCommand(argv, io = {
|
|
|
14527
15496
|
err
|
|
14528
15497
|
}) {
|
|
14529
15498
|
const dryRun = argv.includes("--dry-run");
|
|
14530
|
-
const args =
|
|
14531
|
-
if (!args)
|
|
14532
|
-
printUsage(io.out);
|
|
14533
|
-
return 10;
|
|
14534
|
-
}
|
|
15499
|
+
const args = parseArgsOrUsage(argv.filter((a) => a !== "--dry-run"), io);
|
|
15500
|
+
if (!args) return 10;
|
|
14535
15501
|
try {
|
|
14536
15502
|
const target = resolve(args.target);
|
|
14537
15503
|
const invalid = validateScanTarget(target, io.err);
|
|
@@ -14544,7 +15510,7 @@ async function runFixCommand(argv, io = {
|
|
|
14544
15510
|
io.out(renderFixReport(fixes, dryRun));
|
|
14545
15511
|
return fixes.some((f) => f.status === "failed") ? 1 : 0;
|
|
14546
15512
|
} catch (err) {
|
|
14547
|
-
io.err
|
|
15513
|
+
internalErrorMessage(err, io.err, args?.debug === true);
|
|
14548
15514
|
return 20;
|
|
14549
15515
|
}
|
|
14550
15516
|
}
|
|
@@ -14569,7 +15535,7 @@ function runCreateRuleCommand(argv, io = {
|
|
|
14569
15535
|
io.out(renderScaffoldReport(result));
|
|
14570
15536
|
return result.ok ? 0 : 1;
|
|
14571
15537
|
} catch (err) {
|
|
14572
|
-
io.err
|
|
15538
|
+
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
14573
15539
|
return 20;
|
|
14574
15540
|
}
|
|
14575
15541
|
}
|
|
@@ -14600,10 +15566,7 @@ async function runImpactCommand(argv, io = {
|
|
|
14600
15566
|
const sinceIdx = argv.indexOf("--since");
|
|
14601
15567
|
const since = sinceIdx !== -1 ? argv[sinceIdx + 1] : void 0;
|
|
14602
15568
|
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
|
-
}
|
|
15569
|
+
if (!args) return 10;
|
|
14607
15570
|
try {
|
|
14608
15571
|
const target = resolve(args.target);
|
|
14609
15572
|
const invalid = validateScanTarget(target, io.err);
|
|
@@ -14618,7 +15581,7 @@ async function runImpactCommand(argv, io = {
|
|
|
14618
15581
|
io.out(renderImpact(report));
|
|
14619
15582
|
return report.hasComparison ? 0 : 2;
|
|
14620
15583
|
} catch (err) {
|
|
14621
|
-
io.err
|
|
15584
|
+
internalErrorMessage(err, io.err, args?.debug === true);
|
|
14622
15585
|
return 20;
|
|
14623
15586
|
}
|
|
14624
15587
|
}
|
|
@@ -14627,11 +15590,8 @@ async function runBaselineCommand(argv, io = {
|
|
|
14627
15590
|
out,
|
|
14628
15591
|
err
|
|
14629
15592
|
}) {
|
|
14630
|
-
const args =
|
|
14631
|
-
if (!args)
|
|
14632
|
-
printUsage(io.out);
|
|
14633
|
-
return 10;
|
|
14634
|
-
}
|
|
15593
|
+
const args = parseArgsOrUsage(argv, io);
|
|
15594
|
+
if (!args) return 10;
|
|
14635
15595
|
try {
|
|
14636
15596
|
const target = resolve(args.target);
|
|
14637
15597
|
const invalid = validateScanTarget(target, io.err);
|
|
@@ -14645,7 +15605,7 @@ async function runBaselineCommand(argv, io = {
|
|
|
14645
15605
|
io.out(renderBaselineSaved(DEFAULT_BASELINE_PATH, result.findings.length, { ...saved.backupPath !== void 0 ? { backupPath: saved.backupPath } : {} }));
|
|
14646
15606
|
return 0;
|
|
14647
15607
|
} catch (err) {
|
|
14648
|
-
io.err
|
|
15608
|
+
internalErrorMessage(err, io.err, args?.debug === true);
|
|
14649
15609
|
return 20;
|
|
14650
15610
|
}
|
|
14651
15611
|
}
|
|
@@ -14654,11 +15614,8 @@ async function runDiffCommand(argv, io = {
|
|
|
14654
15614
|
out,
|
|
14655
15615
|
err
|
|
14656
15616
|
}) {
|
|
14657
|
-
const args =
|
|
14658
|
-
if (!args)
|
|
14659
|
-
printUsage(io.out);
|
|
14660
|
-
return 10;
|
|
14661
|
-
}
|
|
15617
|
+
const args = parseArgsOrUsage(argv, io);
|
|
15618
|
+
if (!args) return 10;
|
|
14662
15619
|
try {
|
|
14663
15620
|
const target = resolve(args.target);
|
|
14664
15621
|
const invalid = validateScanTarget(target, io.err);
|
|
@@ -14683,7 +15640,7 @@ async function runDiffCommand(argv, io = {
|
|
|
14683
15640
|
if (!diff.hasBaseline) return 2;
|
|
14684
15641
|
return diff.newFindings.some((f) => f.severity === "error") ? 1 : 0;
|
|
14685
15642
|
} catch (err) {
|
|
14686
|
-
io.err
|
|
15643
|
+
internalErrorMessage(err, io.err, args?.debug === true);
|
|
14687
15644
|
return 20;
|
|
14688
15645
|
}
|
|
14689
15646
|
}
|
|
@@ -14692,11 +15649,8 @@ async function runPrCommentCommand(argv, io = {
|
|
|
14692
15649
|
out,
|
|
14693
15650
|
err
|
|
14694
15651
|
}) {
|
|
14695
|
-
const args =
|
|
14696
|
-
if (!args)
|
|
14697
|
-
printUsage(io.out);
|
|
14698
|
-
return 10;
|
|
14699
|
-
}
|
|
15652
|
+
const args = parseArgsOrUsage(argv, io);
|
|
15653
|
+
if (!args) return 10;
|
|
14700
15654
|
try {
|
|
14701
15655
|
const target = resolve(args.target);
|
|
14702
15656
|
const invalid = validateScanTarget(target, io.err);
|
|
@@ -14707,10 +15661,13 @@ async function runPrCommentCommand(argv, io = {
|
|
|
14707
15661
|
});
|
|
14708
15662
|
const baseline = loadBaseline(join(target, DEFAULT_BASELINE_PATH));
|
|
14709
15663
|
const diff = baseline ? diffAgainstBaseline(result, baseline) : void 0;
|
|
14710
|
-
io.out(renderPrComment(result,
|
|
15664
|
+
io.out(renderPrComment(result, {
|
|
15665
|
+
...diff ? { diff } : {},
|
|
15666
|
+
version: CLI_VERSION
|
|
15667
|
+
}));
|
|
14711
15668
|
return 0;
|
|
14712
15669
|
} catch (err) {
|
|
14713
|
-
io.err
|
|
15670
|
+
internalErrorMessage(err, io.err, args?.debug === true);
|
|
14714
15671
|
return 20;
|
|
14715
15672
|
}
|
|
14716
15673
|
}
|
|
@@ -14726,7 +15683,7 @@ function runStatsCommand(argv, io = {
|
|
|
14726
15683
|
io.out(renderStats(stats));
|
|
14727
15684
|
return 0;
|
|
14728
15685
|
} catch (err) {
|
|
14729
|
-
io.err
|
|
15686
|
+
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
14730
15687
|
return 20;
|
|
14731
15688
|
}
|
|
14732
15689
|
}
|
|
@@ -14735,11 +15692,8 @@ async function runHandoverCommand(argv, io = {
|
|
|
14735
15692
|
out,
|
|
14736
15693
|
err
|
|
14737
15694
|
}) {
|
|
14738
|
-
const args =
|
|
14739
|
-
if (!args)
|
|
14740
|
-
printUsage(io.out);
|
|
14741
|
-
return 10;
|
|
14742
|
-
}
|
|
15695
|
+
const args = parseArgsOrUsage(argv, io);
|
|
15696
|
+
if (!args) return 10;
|
|
14743
15697
|
try {
|
|
14744
15698
|
const target = resolve(args.target);
|
|
14745
15699
|
const invalid = validateScanTarget(target, io.err);
|
|
@@ -14756,7 +15710,7 @@ async function runHandoverCommand(argv, io = {
|
|
|
14756
15710
|
io.out(renderHandover(buildHandover(result, forensics)));
|
|
14757
15711
|
return 0;
|
|
14758
15712
|
} catch (err) {
|
|
14759
|
-
io.err
|
|
15713
|
+
internalErrorMessage(err, io.err, args?.debug === true);
|
|
14760
15714
|
return 20;
|
|
14761
15715
|
}
|
|
14762
15716
|
}
|
|
@@ -14777,7 +15731,7 @@ function runInitCommand(argv, io = {
|
|
|
14777
15731
|
io.out(renderInit(result));
|
|
14778
15732
|
return 0;
|
|
14779
15733
|
} catch (err) {
|
|
14780
|
-
io.err
|
|
15734
|
+
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
14781
15735
|
return 20;
|
|
14782
15736
|
}
|
|
14783
15737
|
}
|
|
@@ -14800,15 +15754,20 @@ function runPwReportCommand(argv, io = {
|
|
|
14800
15754
|
io.out(renderPwRunSummary(summarizePwRun(report)));
|
|
14801
15755
|
return report.failed > 0 || report.flakyTests > 0 ? 1 : 0;
|
|
14802
15756
|
} catch (err) {
|
|
14803
|
-
io.err
|
|
15757
|
+
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
14804
15758
|
return 20;
|
|
14805
15759
|
}
|
|
14806
15760
|
}
|
|
14807
|
-
async function main(argv = process$1.argv.slice(2)
|
|
15761
|
+
async function main(argv = process$1.argv.slice(2), io = {
|
|
15762
|
+
out,
|
|
15763
|
+
err
|
|
15764
|
+
}) {
|
|
14808
15765
|
if (argv[0] === "--version" || argv[0] === "-v") {
|
|
14809
|
-
out(`mjolnir-qa ${CLI_VERSION}\n`);
|
|
15766
|
+
io.out(`mjolnir-qa ${CLI_VERSION}\n`);
|
|
14810
15767
|
return 0;
|
|
14811
15768
|
}
|
|
15769
|
+
if (argv.length >= 2 && (argv[1] === "--help" || argv[1] === "-h")) return runHelpCommand([argv[0]], io);
|
|
15770
|
+
if (argv[0] === "ci" && argv.length >= 3 && (argv[2] === "--help" || argv[2] === "-h")) return runHelpCommand(["ci", "install"], io);
|
|
14812
15771
|
if (argv[0] === "ci" && argv[1] === "install") return runCiInstall(argv.slice(2));
|
|
14813
15772
|
if (argv[0] === "suppressions") return runSuppressions();
|
|
14814
15773
|
if (argv[0] === "forensics") return runForensicsCommand(argv.slice(1));
|
|
@@ -14819,6 +15778,7 @@ async function main(argv = process$1.argv.slice(2)) {
|
|
|
14819
15778
|
if (argv[0] === "baseline") return runBaselineCommand(argv.slice(1));
|
|
14820
15779
|
if (argv[0] === "diff") return runDiffCommand(argv.slice(1));
|
|
14821
15780
|
if (argv[0] === "pr-comment") return runPrCommentCommand(argv.slice(1));
|
|
15781
|
+
if (argv[0] === "summary") return runSummaryCommand(argv.slice(1), io);
|
|
14822
15782
|
if (argv[0] === "stats") return runStatsCommand(argv.slice(1));
|
|
14823
15783
|
if (argv[0] === "fix") return runFixCommand(argv.slice(1));
|
|
14824
15784
|
if (argv[0] === "create-rule") return runCreateRuleCommand(argv.slice(1));
|
|
@@ -14829,84 +15789,52 @@ async function main(argv = process$1.argv.slice(2)) {
|
|
|
14829
15789
|
if (argv[0] === "rules") return runRulesCommand(argv.slice(1));
|
|
14830
15790
|
if (argv[0] === "explain") return runExplainCommand(argv.slice(1));
|
|
14831
15791
|
if (argv[0] === "doctor:playwright") return runDoctorPlaywright(argv);
|
|
15792
|
+
if (argv[0] === "help") return runHelpCommand(argv.slice(1), io);
|
|
14832
15793
|
return runScanCommand(argv);
|
|
14833
15794
|
}
|
|
15795
|
+
/**
|
|
15796
|
+
* `mjolnir help` / `mjolnir help <verb>` (plan M2). `--help`/`-h` and
|
|
15797
|
+
* `<verb> --help` route here too. Exit 0 — help answers a question.
|
|
15798
|
+
* Two-word verbs (`ci install`) are resolved first via the join of the
|
|
15799
|
+
* leading non-flag tokens, then the single-word form.
|
|
15800
|
+
*/
|
|
15801
|
+
function runHelpCommand(argv, io = {
|
|
15802
|
+
out,
|
|
15803
|
+
err
|
|
15804
|
+
}) {
|
|
15805
|
+
const tokens = argv.filter((a) => !a.startsWith("-"));
|
|
15806
|
+
if (tokens.length >= 2) {
|
|
15807
|
+
const joined = `${tokens[0]} ${tokens[1]}`;
|
|
15808
|
+
if (hasVerbHelp(joined)) {
|
|
15809
|
+
io.out(renderVerbHelp(joined));
|
|
15810
|
+
return 0;
|
|
15811
|
+
}
|
|
15812
|
+
}
|
|
15813
|
+
if (tokens.length > 0) {
|
|
15814
|
+
io.out(renderVerbHelp(tokens[0]));
|
|
15815
|
+
return 0;
|
|
15816
|
+
}
|
|
15817
|
+
io.out(renderRootHelp(1));
|
|
15818
|
+
return 0;
|
|
15819
|
+
}
|
|
14834
15820
|
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`);
|
|
15821
|
+
print(renderRootHelp());
|
|
15822
|
+
}
|
|
15823
|
+
/**
|
|
15824
|
+
* Friendly exit-20 path (plan M2): the crash says it's Mjölnir's bug,
|
|
15825
|
+
* not the user's repo, carries the underlying message for a report, and
|
|
15826
|
+
* prints the stack ONLY when `debug` is set (uniform across
|
|
15827
|
+
* subcommands — they don't parse scan flags). Tests pin
|
|
15828
|
+
* /internal error/i. Exported so the --debug stack arm is directly
|
|
15829
|
+
* spec-coverable (spawning a real crash under --debug would be flaky).
|
|
15830
|
+
*/
|
|
15831
|
+
function internalErrorMessage(err, emit, debug) {
|
|
15832
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
15833
|
+
emit("mjolnir internal error — this is a bug in Mjölnir, not your repo:");
|
|
15834
|
+
emit(` ${message}`);
|
|
15835
|
+
if (debug && err instanceof Error && err.stack) emit(err.stack);
|
|
15836
|
+
emit("Rerun with --debug for the stack trace. Please report this:");
|
|
15837
|
+
emit(" https://github.com/Sergey-Bar/Mjolnir/issues");
|
|
14910
15838
|
}
|
|
14911
15839
|
function isEntryPoint() {
|
|
14912
15840
|
const argv1 = process$1.argv[1];
|
|
@@ -14919,4 +15847,4 @@ function isEntryPoint() {
|
|
|
14919
15847
|
}
|
|
14920
15848
|
if (isEntryPoint()) process$1.exitCode = await main();
|
|
14921
15849
|
//#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 };
|
|
15850
|
+
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 };
|