mjolnir-qa 0.5.2 → 0.5.4

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