mjolnir-qa 0.5.2 → 0.5.3

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