mjolnir-qa 0.5.12 → 0.5.14

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
@@ -10,6 +10,7 @@ import { Project, SyntaxKind, ts } from "ts-morph";
10
10
  import { Language, Parser } from "web-tree-sitter";
11
11
  import { parse } from "yaml";
12
12
  import { createHash, randomBytes } from "node:crypto";
13
+ import { createInterface } from "node:readline";
13
14
  import { tmpdir } from "node:os";
14
15
  //#region \0rolldown/runtime.js
15
16
  var __defProp = Object.defineProperty;
@@ -11597,7 +11598,7 @@ const ALLOWED_QA_IMPACTS = /* @__PURE__ */ new Set([
11597
11598
  "HYGIENE"
11598
11599
  ]);
11599
11600
  /** Consistent error message extraction (v8 branch-friendly form). */
11600
- function errorMessage(err) {
11601
+ function errorMessage$1(err) {
11601
11602
  if (err instanceof Error) return err.message;
11602
11603
  return String(err);
11603
11604
  }
@@ -11618,7 +11619,7 @@ async function loadLocalRules(root, gateOpen = false) {
11618
11619
  try {
11619
11620
  entries = readdirSync(dir);
11620
11621
  } catch (err) {
11621
- result.errors.push(`external rules directory "${LOCAL_RULES_DIR}/" could not be read: ${errorMessage(err)}`);
11622
+ result.errors.push(`external rules directory "${LOCAL_RULES_DIR}/" could not be read: ${errorMessage$1(err)}`);
11622
11623
  return result;
11623
11624
  }
11624
11625
  for (const entry of entries.sort()) {
@@ -11640,7 +11641,7 @@ function loadJsonRule(path, result) {
11640
11641
  try {
11641
11642
  raw = JSON.parse(readFileSync(path, "utf8"));
11642
11643
  } catch (err) {
11643
- result.errors.push(`external rule "${name}" is not valid JSON: ${errorMessage(err)}`);
11644
+ result.errors.push(`external rule "${name}" is not valid JSON: ${errorMessage$1(err)}`);
11644
11645
  return;
11645
11646
  }
11646
11647
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
@@ -11676,7 +11677,7 @@ function loadJsonRule(path, result) {
11676
11677
  try {
11677
11678
  regexes = patterns.map((p) => new RegExp(p, "g"));
11678
11679
  } catch (err) {
11679
- result.errors.push(`external rule ${id} declares an invalid regex: ${errorMessage(err)}`);
11680
+ result.errors.push(`external rule ${id} declares an invalid regex: ${errorMessage$1(err)}`);
11680
11681
  return;
11681
11682
  }
11682
11683
  const severity = decl["severity"];
@@ -11771,7 +11772,7 @@ async function loadModuleRules(path, result) {
11771
11772
  try {
11772
11773
  mod = await import(pathToFileURL(path).href);
11773
11774
  } catch (err) {
11774
- result.errors.push(`external rule module "${name}" failed to load: ${errorMessage(err)}`);
11775
+ result.errors.push(`external rule module "${name}" failed to load: ${errorMessage$1(err)}`);
11775
11776
  return;
11776
11777
  }
11777
11778
  const rules = mod?.rules;
@@ -12480,26 +12481,26 @@ function topFixes(findings, n = 3) {
12480
12481
  }
12481
12482
  //#endregion
12482
12483
  //#region src/reporter/art.ts
12483
- /** Block-character logo. Keep ≤ 62 cols wide. */
12484
+ /**
12485
+ * The report's wordmark. A LOCKUP, not a picture of a hammer.
12486
+ *
12487
+ * This used to be eight rows of box-drawing that spelled out a hammer —
12488
+ * directly above the score instrument, which is also a hammer. Two
12489
+ * hammers, ten lines apart, and the smaller static one came first, so
12490
+ * appendScoreSection's own claim that "the hammer is the instrument: the
12491
+ * first thing the eye lands on" was false in every report the tool has
12492
+ * ever printed.
12493
+ *
12494
+ * There is one hammer now, and it is the one that means something: the
12495
+ * state-resolved instrument in HAMMER_STATES. The wordmark stays out of
12496
+ * its way.
12497
+ */
12484
12498
  const LOGO = `
12485
- ╔═══════════╗
12486
- ║ ║
12487
- ╠═══════════╣ M J Ö L N I R
12488
- ║ ║ ║
12489
- ╚═════╩═════╝ VERIFICATION TRUST ENGINE
12490
- ║
12491
- ║
12499
+ M J Ö L N I R · VERIFICATION TRUST ENGINE
12492
12500
  `;
12493
- /** Plain-ASCII fallback logo for cmd.exe/legacy consoles where the
12494
- * block-drawing LOGO above renders as mangled "?" glyphs. */
12501
+ /** Plain-ASCII fallback for cmd.exe/legacy consoles. */
12495
12502
  const LOGO_ASCII = `
12496
- +-----------+
12497
- | |
12498
- +-----------+ M J O L N I R
12499
- | | |
12500
- +-----+-----+ VERIFICATION TRUST ENGINE
12501
- |
12502
- |
12503
+ M J O L N I R - VERIFICATION TRUST ENGINE
12503
12504
  `;
12504
12505
  const TROPHY = String.raw`
12505
12506
  ___________
@@ -13000,7 +13001,12 @@ const CARD_GUTTER = " ";
13000
13001
  function pushCard(lines, card, ui) {
13001
13002
  const { p, width } = ui;
13002
13003
  const contentWidth = Math.max(20, width - 2 - 4 - CARD_LABEL_PAD);
13003
- lines.push(` ${severityIcon(card.severity, ui)} ${p.bold(card.loc)} ${p.dim(card.evidence)}`);
13004
+ const header = ` ${severityIcon(card.severity, ui)} ${p.bold(card.loc)}`;
13005
+ if (measure(`${header} ${card.evidence}`) <= width) lines.push(`${header} ${p.dim(card.evidence)}`);
13006
+ else {
13007
+ lines.push(header);
13008
+ lines.push(`${CARD_GUTTER}${p.dim(card.evidence)}`);
13009
+ }
13004
13010
  const fields = [
13005
13011
  {
13006
13012
  label: "Finding",
@@ -13040,7 +13046,7 @@ function pushCard(lines, card, ui) {
13040
13046
  * overflow line; --verbose shows everything.
13041
13047
  */
13042
13048
  function appendFindings(lines, result, counts, verbose, ui, tone) {
13043
- const { p } = ui;
13049
+ const { p, width } = ui;
13044
13050
  if (counts.total === 0) return;
13045
13051
  if (result.findings.length === 0) {
13046
13052
  lines.push(ui.p.dim(" filtered view: no findings in the selected category"));
@@ -13089,9 +13095,21 @@ function appendFindings(lines, result, counts, verbose, ui, tone) {
13089
13095
  hiddenRules.add(unit.ruleId);
13090
13096
  continue;
13091
13097
  }
13092
- lines.push(` ${severityIcon(maxSeverity(unit.findings), ui)} ${p.bold(sanitizeData(unit.ruleId))} ${p.dim(`× ${n} — same fix applies`)} ${p.dim(evidenceTag$1(first))}`);
13093
- lines.push(`${CARD_GUTTER}${p.accent("Fix".padEnd(CARD_LABEL_PAD))}${p.dim(sanitizeData(first.fix))}`);
13094
- for (const f of unit.findings) lines.push(`${CARD_GUTTER}${" ".repeat(CARD_LABEL_PAD)}${p.dim(`· ${sanitizeData(f.file)}:${f.line} — ${sanitizeData(f.message)}`)}`);
13098
+ const groupHead = ` ${severityIcon(maxSeverity(unit.findings), ui)} ${p.bold(sanitizeData(unit.ruleId))} ${p.dim(`× ${n} — same fix applies`)}`;
13099
+ const groupEvidence = evidenceTag$1(first);
13100
+ if (measure(`${groupHead} ${groupEvidence}`) <= width) lines.push(`${groupHead} ${p.dim(groupEvidence)}`);
13101
+ else {
13102
+ lines.push(groupHead);
13103
+ lines.push(`${CARD_GUTTER}${p.dim(groupEvidence)}`);
13104
+ }
13105
+ const groupContentWidth = Math.max(20, width - 2 - 4 - CARD_LABEL_PAD);
13106
+ wrapLines(sanitizeData(first.fix), groupContentWidth).forEach((seg, i) => {
13107
+ const label = i === 0 ? p.accent("Fix".padEnd(CARD_LABEL_PAD)) : " ".repeat(CARD_LABEL_PAD);
13108
+ lines.push(`${CARD_GUTTER}${label}${p.dim(seg)}`);
13109
+ });
13110
+ for (const f of unit.findings) wrapLines(`· ${sanitizeData(f.file)}:${f.line} — ${sanitizeData(f.message)}`, groupContentWidth).forEach((seg, i) => {
13111
+ lines.push(`${CARD_GUTTER}${" ".repeat(CARD_LABEL_PAD)}${p.dim(i === 0 ? seg : ` ${seg}`)}`);
13112
+ });
13095
13113
  lines.push("");
13096
13114
  shown++;
13097
13115
  continue;
@@ -13129,22 +13147,34 @@ function appendForgedBlock(lines, p, ascii) {
13129
13147
  lines.push(p.forged(TROPHY));
13130
13148
  lines.push("");
13131
13149
  }
13150
+ /**
13151
+ * Pushes dimmed prose that respects the terminal width.
13152
+ *
13153
+ * The honesty footer used to be pushed as single unbroken strings — the
13154
+ * rule-coverage line alone is ~147 columns, so it overflowed every
13155
+ * default 80- or 100-column terminal and ignored `--width` entirely.
13156
+ * `wrapText` is the same helper the finding cards already use.
13157
+ */
13158
+ function pushWrapped(lines, p, text, width) {
13159
+ const indent = " ";
13160
+ for (const line of wrapText(text, Math.max(20, width - 2))) lines.push(p.dim(`${indent}${line}`));
13161
+ }
13132
13162
  function appendFooter(lines, result, ui) {
13133
- const { p } = ui;
13163
+ const { p, width } = ui;
13134
13164
  lines.push(...buildFooter({
13135
13165
  ui,
13136
13166
  complete: result.analysisStatus.discovery !== "partial",
13137
13167
  durationMs: result.analysisStatus.durationMs
13138
13168
  }));
13139
13169
  const advisory = result.findings.filter((f) => (f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence)) === "E0").length;
13140
- if (advisory > 0) lines.push(p.dim(` ${advisory} advisory finding${advisory === 1 ? "" : "s"} (E0 — observation only, no score impact)`));
13170
+ if (advisory > 0) pushWrapped(lines, p, `${advisory} advisory finding${advisory === 1 ? "" : "s"} (E0 — observation only, no score impact)`, width);
13141
13171
  if (result.findings.length > 0) {
13142
13172
  const firedRuleIds = new Set(result.findings.map((f) => f.ruleId));
13143
13173
  const measuredHere = [...firedRuleIds].filter((id) => MEASURED_FP[id] !== void 0).length;
13144
- lines.push(p.dim(` Rule coverage: ${measuredHere}/${firedRuleIds.size} rules that fired here have a measured false-positive rate; the rest are heuristics. \`mjolnir rules --unmeasured\` lists them.`));
13174
+ pushWrapped(lines, p, `Rule coverage: ${measuredHere}/${firedRuleIds.size} rules that fired here have a measured false-positive rate; the rest are heuristics. \`mjolnir rules --unmeasured\` lists them.`, width);
13145
13175
  const verified = result.findings.filter((f) => f.runtimeCorroboration !== void 0).length;
13146
- if (verified > 0) lines.push(p.dim(` Runtime evidence: ${verified}/${result.findings.length} findings corroborated by a real run report (trust L3–L5); the rest are static-only.`));
13147
- else lines.push(p.dim(` Runtime evidence: not available — no run report (mjolnir.report.json / test-results) next to the scan target; all findings are static-only (L0–L2).`));
13176
+ if (verified > 0) pushWrapped(lines, p, `Runtime evidence: ${verified}/${result.findings.length} findings corroborated by a real run report (trust L3–L5); the rest are static-only.`, width);
13177
+ else pushWrapped(lines, p, `Runtime evidence: not available — no run report (mjolnir.report.json / test-results) next to the scan target; all findings are static-only (L0–L2).`, width);
13148
13178
  }
13149
13179
  const profile = result.agenticProfile;
13150
13180
  if (profile && (profile.generatedMarkedFiles > 0 || profile.codegenLikeFiles > 0)) {
@@ -13224,7 +13254,7 @@ function renderSarif(result, repoRootUri) {
13224
13254
  tool: { driver: {
13225
13255
  name: "Mjölnir",
13226
13256
  informationUri: "https://github.com/Sergey-Bar/Mjolnir",
13227
- version: "0.5.12",
13257
+ version: "0.5.14",
13228
13258
  rules: [...rules.values()].map((r) => {
13229
13259
  const meta = RULES.find((x) => x.id === r.id);
13230
13260
  return {
@@ -14646,307 +14676,185 @@ function executeHookInstall(entry) {
14646
14676
  }
14647
14677
  }
14648
14678
  //#endregion
14649
- //#region src/integrations/ci-install.ts
14679
+ //#region src/rules/measurement.ts
14680
+ /** The rule's declared detector implementation revision (§07). */
14681
+ function declaredDetectorRevision(rule) {
14682
+ return rule.detectorRevision ?? 1;
14683
+ }
14650
14684
  /**
14651
- * CI integration (Sprint-Plan W7): generates .github/workflows/mjolnir.yml
14652
- * from internal templates ONLY — no user-input interpolation (R3 supply-chain).
14653
- * Default gate: advisory (report, never block).
14654
- *
14655
- * Bug-audit hardening (H2): the previous template shipped the same
14656
- * `github.rest.checks` no-op this repo's own audit removed from mjolnir.yml,
14657
- * failed the job before the annotate/summary steps could run whenever the
14658
- * scan step exited non-zero, recommended floating `mjolnir-qa@latest`, and
14659
- * `ciInstall` silently overwrote hand-customized workflows. The template now
14660
- * mirrors the dogfooded `.github/workflows/mjolnir.yml` (pinned action SHAs,
14661
- * `if: always()` on reporting steps, a real gate step that reads
14662
- * `mjolnir.json`, partial scans never block) and `ciInstall` refuses to
14663
- * replace a customized workflow without an explicit `--force`.
14685
+ * A measurement exists AND was taken against the detector revision the
14686
+ * rule declares now. A revision mismatch (stale) does NOT count — §07:
14687
+ * stale → provisional → re-measure.
14664
14688
  */
14689
+ function hasValidMeasurement(rule) {
14690
+ const m = MEASURED_FP[rule.id];
14691
+ return m !== void 0 && m.detectorRevision === declaredDetectorRevision(rule);
14692
+ }
14665
14693
  /**
14666
- * The gate-check script embedded in generated workflows (and executed
14667
- * directly by tests against fixture JSONs). Semantics, kept in sync with
14668
- * the tool's own exit-code contract:
14669
- * - missing/unreadable `mjolnir.json` → fail (the scan step crashed; a
14670
- * silent pass here would turn a broken pipeline into a green one);
14671
- * - `partial: true` → never block (truncated results can neither prove
14672
- * nor disprove the gate — the "PARTIAL" banner in the summary is the
14673
- * honest signal);
14674
- * - `error` gate → block on any error-severity finding;
14675
- * - `warning` gate → block on warnings and errors.
14694
+ * The tier a rule effectively ships as: its declared tier, or — for the
14695
+ * omitted-tier case — the measurement-dependent default (§11.2 Step 2).
14676
14696
  */
14677
- function gateScript(gate) {
14678
- return [
14679
- "const fs = require(\"fs\");",
14680
- "let r;",
14681
- "try {",
14682
- " r = JSON.parse(fs.readFileSync(\"mjolnir.json\", \"utf8\"));",
14683
- "} catch (e) {",
14684
- " process.stderr.write(\"mjolnir.json is missing or unreadable - the scan step crashed before the gate could run. Failing instead of passing silently.\\n\");",
14685
- " process.exit(1);",
14686
- "}",
14687
- "if (r.partial === true) {",
14688
- " process.stdout.write(\"Scan was PARTIAL - some files were not analyzed; gate not enforced.\\n\");",
14689
- " process.exit(0);",
14690
- "}",
14691
- "const findings = Array.isArray(r.findings) ? r.findings : [];",
14692
- "const errors = findings.filter(function (f) { return f && f.severity === \"error\"; }).length;",
14693
- "const warnings = findings.filter(function (f) { return f && f.severity === \"warning\"; }).length;",
14694
- "process.stdout.write(\"Mjolnir gate: \" + errors + \" error(s), \" + warnings + \" warning(s).\\n\");",
14695
- `if (${gate === "error" ? "errors > 0" : "errors > 0 || warnings > 0"}) { process.exit(1); }`,
14696
- "process.exit(0);"
14697
- ].join("\n");
14697
+ function effectiveTier(rule) {
14698
+ if (rule.tier !== void 0) return rule.tier;
14699
+ if (hasValidMeasurement(rule)) return "core";
14700
+ return "extended";
14701
+ }
14702
+ /** The §11.2 Step 2 PROVISIONAL display predicate. */
14703
+ function isProvisional(rule) {
14704
+ return effectiveTier(rule) === "extended" && !hasValidMeasurement(rule);
14698
14705
  }
14706
+ //#endregion
14707
+ //#region src/commands/fixture-example.ts
14699
14708
  /**
14700
- * Template v2 (Terminal + CI UX Overhaul plan, M4): the summary step
14701
- * calls `mjolnir summary mjolnir.json` — annotations + step summary via
14702
- * ONE emitter — instead of the v1 inline SUMMARY_SCRIPT. The gate
14703
- * script is unchanged (reads `partial`, `findings[].severity`).
14709
+ * Shared example-fixture selection for `mjolnir explain` (explain.ts)
14710
+ * and the generated rule docs (rule-docs.ts) — one chooser so both
14711
+ * surfaces always show the same example for the same rule. Extracted
14712
+ * after the same bug-audit L9 fix had to be applied to both verbatim
14713
+ * copies: duplicated fixture selection drifts, and a drift means
14714
+ * `mjolnir explain <ID>` contradicts docs/rules/<ID>.md.
14704
14715
  */
14705
- /** Renders findings into `$GITHUB_STEP_SUMMARY`; tolerates a missing scan result.
14706
- * v1 inline script, retained ONLY for overwrite-refusal recognition of
14707
- * workflows generated by older versions (they are treated as ours, so
14708
- * a frictionless `ci install` upgrade stays possible). Exported for the
14709
- * recognition test that reconstructs the embedded form. */
14710
- const SUMMARY_SCRIPT_V1 = [
14711
- "const fs = require(\"fs\");",
14712
- "let r = {};",
14713
- "try { r = JSON.parse(fs.readFileSync(\"mjolnir.json\", \"utf8\")); } catch (e) {}",
14714
- "const findings = Array.isArray(r.findings) ? r.findings : [];",
14715
- "const lines = findings.map(function (f) {",
14716
- " return \"- **\" + f.ruleId + \"** (\" + f.severity + \") \" + f.file + \":\" + f.line + \" - \" + f.message;",
14717
- "});",
14718
- "const head = r.partial === true",
14719
- " ? \"Mjolnir scan was PARTIAL - some files may not have been analyzed.\"",
14720
- " : \"Mjolnir scan finished.\";",
14721
- "const body = [\"## Mjolnir findings\", head, \"\"].concat(lines).join(\"\\n\");",
14722
- "if (process.env.GITHUB_STEP_SUMMARY && lines.length > 0) {",
14723
- " fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, body + \"\\n\");",
14724
- "}",
14725
- "process.stdout.write(lines.length + \" finding(s)\\n\");"
14726
- ].join("\n");
14727
- /** True when a workflow was generated by any Mjölnir template (v1 or v2).
14728
- * The v1 needle is matched in its INDENTED form: the v1 template embedded
14729
- * the script via indentBlock(…, 10) inside the `run: |` scalar, so the
14730
- * raw unindented substring never appears in a real v1 file. */
14731
- function isKnownTemplate(content) {
14732
- return GATES.some((g) => content === TEMPLATE(g)) || content.includes(indentBlock(SUMMARY_SCRIPT_V1, 10));
14716
+ /**
14717
+ * First (byte-stable) fixture file in a must-fire / must-not-fire
14718
+ * directory, or null when the directory is absent or empty.
14719
+ */
14720
+ function firstFixtureFile(dir) {
14721
+ if (!existsSync(dir)) return null;
14722
+ const entries = readdirSync(dir).filter((f) => !f.startsWith(".")).sort();
14723
+ return entries.length > 0 ? join(dir, entries[0]) : null;
14733
14724
  }
14734
- /** Indents an embedded script so it sits inside a YAML `run: |` block scalar. */
14735
- function indentBlock(text, spaces) {
14736
- const pad = " ".repeat(spaces);
14737
- return text.split("\n").map((l) => l.length > 0 ? pad + l : l).join("\n");
14725
+ //#endregion
14726
+ //#region src/commands/explain.ts
14727
+ /**
14728
+ * `mjolnir explain <RULE-ID>` — implements Plan.md Sprint 1.3
14729
+ * (Master-Stabilization-Plan Sprint 5, Task 19).
14730
+ *
14731
+ * For any registered rule, renders what is wrong, why it matters, the
14732
+ * evidence level and confidence behind the verdict, the prescription,
14733
+ * and how to verify the fix. This is a presentation layer only — no new
14734
+ * detection logic. Every field it prints already exists on RuleMeta or
14735
+ * comes from actually running the rule against its own committed
14736
+ * must-fire fixture, so the example shown is real detector output, not
14737
+ * hand-written prose that can drift from what the rule actually does.
14738
+ */
14739
+ const ui$12 = plainContext();
14740
+ /**
14741
+ * Runs the rule against its own must-fire fixture to get one real,
14742
+ * concrete example finding. `fixturesRoot` defaults to this repo's own
14743
+ * `tests/fixtures` — explain only has real examples to show when run
14744
+ * from (or pointed at) a Mjolnir checkout; degrades honestly
14745
+ * (exampleFinding left undefined) otherwise, same as `doctor`.
14746
+ */
14747
+ function explainRule(ruleId, fixturesRoot) {
14748
+ const rule = getRule(ruleId);
14749
+ if (!rule) return {
14750
+ ok: false,
14751
+ error: `Unknown rule ID "${ruleId}". Run \`mjolnir rules\` for the full catalog.`
14752
+ };
14753
+ const fixturePath = firstFixtureFile(join(fixturesRoot, ruleId, "must-fire"));
14754
+ if (!fixturePath) return {
14755
+ ok: true,
14756
+ rule
14757
+ };
14758
+ let text;
14759
+ try {
14760
+ text = readFileSync(fixturePath, "utf8").replace(/\r\n/g, "\n");
14761
+ } catch {
14762
+ return {
14763
+ ok: true,
14764
+ rule
14765
+ };
14766
+ }
14767
+ const normalizedPath = fixturePath.replaceAll("\\", "/");
14768
+ let ast;
14769
+ if (rule.appliesTo === "ci-workflows") try {
14770
+ ast = parseWorkflow(text);
14771
+ } catch {
14772
+ return {
14773
+ ok: true,
14774
+ rule
14775
+ };
14776
+ }
14777
+ let findings;
14778
+ try {
14779
+ const parsed = {
14780
+ path: normalizedPath,
14781
+ text,
14782
+ ast
14783
+ };
14784
+ const codeText = computeCodeText(parsed, normalizedPath.endsWith(".py") ? "python" : normalizedPath.endsWith(".java") ? "java" : normalizedPath.endsWith(".cs") ? "csharp" : "typescript");
14785
+ findings = rule.run({
14786
+ ...parsed,
14787
+ codeText
14788
+ });
14789
+ } catch {
14790
+ return {
14791
+ ok: true,
14792
+ rule
14793
+ };
14794
+ }
14795
+ const example = findings[0];
14796
+ if (!example) return {
14797
+ ok: true,
14798
+ rule
14799
+ };
14800
+ return {
14801
+ ok: true,
14802
+ rule,
14803
+ exampleFinding: example,
14804
+ exampleFixturePath: fixturePath,
14805
+ exampleFixtureRelPath: relative(fixturesRoot, fixturePath).replaceAll("\\", "/")
14806
+ };
14738
14807
  }
14739
- /** The generated workflow for one gate level. Exported for template tests. */
14740
- const TEMPLATE = (gate) => `name: Mjölnir
14741
-
14742
- on:
14743
- pull_request:
14744
-
14745
- concurrency:
14746
- group: mjolnir-\${{ github.ref }}
14747
- cancel-in-progress: true
14748
-
14749
- permissions:
14750
- contents: read
14751
- pull-requests: write
14752
-
14753
- jobs:
14754
- scan:
14755
- runs-on: ubuntu-latest
14756
- # A hung scan must not sit for the 6-hour default.
14757
- timeout-minutes: 10
14758
- steps:
14759
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
14760
- with:
14761
- fetch-depth: 0 # needed for --scope changed merge-base
14762
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
14763
- with:
14764
- node-version: 22
14765
- # Scan with the PINNED version that generated this workflow — never
14766
- # a floating tag: a new release must not change your gate semantics
14767
- # with no commit of yours. To review PRs with the exact tool your
14768
- # repo develops against, add mjolnir-qa to devDependencies and drop
14769
- # the @version suffix so npx resolves the local install.
14770
- - name: Scan changed code (exit 1/2 is data — the gate step decides)
14771
- continue-on-error: true
14772
- run: npx --yes mjolnir-qa@${CLI_VERSION} . --scope changed --json > mjolnir.json
14773
- # Reporting, not gating: a crashed scan leaves mjolnir.json empty/missing
14774
- # and the summary step exits 2/10 — continue-on-error keeps the advisory
14775
- # job green, exactly like the v1 inline script did (the gate step decides).
14776
- - name: Annotations + Job Summary
14777
- if: always()
14778
- continue-on-error: true
14779
- run: npx --yes mjolnir-qa@${CLI_VERSION} summary mjolnir.json
14780
- - name: Render PR comment
14781
- if: always()
14782
- continue-on-error: true
14783
- run: npx --yes mjolnir-qa@${CLI_VERSION} pr-comment . > mjolnir-comment.md
14784
- # Best-effort: on a pull_request event from a fork the GITHUB_TOKEN is
14785
- # read-only and this step will 403 for every external contributor. The
14786
- # Job Summary above is the fallback that always renders.
14787
- # (pull_request_target would fix the token but is a code-execution
14788
- # risk — deliberately NOT used.)
14789
- - name: Post or update PR comment
14790
- if: always()
14791
- continue-on-error: true
14792
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
14793
- with:
14794
- script: |
14795
- const fs = require('fs');
14796
- let body = '';
14797
- try { body = fs.readFileSync('mjolnir-comment.md', 'utf8'); } catch (e) {}
14798
- if (!body.trim()) {
14799
- console.log('mjolnir-comment.md is empty or missing — nothing to post.');
14800
- return;
14801
- }
14802
- const marker = '<!-- mjolnir-pr-comment -->';
14803
- const { data: comments } = await github.rest.issues.listComments({
14804
- owner: context.repo.owner,
14805
- repo: context.repo.repo,
14806
- issue_number: context.issue.number,
14807
- });
14808
- const existing = comments.find((c) => c.body?.startsWith(marker));
14809
- if (existing) {
14810
- await github.rest.issues.updateComment({
14811
- owner: context.repo.owner,
14812
- repo: context.repo.repo,
14813
- comment_id: existing.id,
14814
- body,
14815
- });
14816
- } else {
14817
- await github.rest.issues.createComment({
14818
- owner: context.repo.owner,
14819
- repo: context.repo.repo,
14820
- issue_number: context.issue.number,
14821
- body,
14822
- });
14823
- }
14824
- ${gate === "advisory" ? ` # Advisory mode: findings are reported in the Job Summary and the
14825
- # PR comment, never blocking. The scan step's exit code is visible
14826
- # as the step outcome, but continue-on-error keeps the job green.
14827
- - name: Gate (advisory)
14828
- if: always()
14829
- run: echo "Advisory mode — findings reported, never blocking."` : ` # Gate enforcement: the scan step's own exit code is deliberately
14830
- # neutralized (continue-on-error) so reporting steps always run; THIS
14831
- # step is what fails the job. A partial scan never blocks.
14832
- - name: Gate (${gate})
14833
- if: always()
14834
- run: |
14835
- node -e '
14836
- ${indentBlock(gateScript(gate), 10)}
14837
- '`}
14838
- `;
14839
- /** Every gate variant, used to tell "our template" from "hand-customized". */
14840
- const GATES = [
14841
- "advisory",
14842
- "error",
14843
- "warning"
14844
- ];
14845
- /** Multiset line diff — counts only, for the refusal message. */
14846
- function summarizeContentDiff(existing, incoming) {
14847
- const remaining = /* @__PURE__ */ new Map();
14848
- for (const line of existing.split(/\r?\n/)) remaining.set(line, (remaining.get(line) ?? 0) + 1);
14849
- let added = 0;
14850
- for (const line of incoming.split(/\r?\n/)) {
14851
- const count = remaining.get(line) ?? 0;
14852
- if (count > 0) remaining.set(line, count - 1);
14853
- else added += 1;
14854
- }
14855
- let removed = 0;
14856
- for (const count of remaining.values()) removed += count;
14857
- return [` - ${removed} line(s) of your file are not in the template and would be removed`, ` - ${added} template line(s) are not in your file and would be added`];
14858
- }
14859
- function ciInstall(root, gate = "advisory", options = {}) {
14860
- const wfDir = join(root, ".github", "workflows");
14861
- const target = join(wfDir, "mjolnir.yml");
14862
- if (!existsSync(wfDir)) mkdirSync(wfDir, { recursive: true });
14863
- const existed = existsSync(target);
14864
- if (existed) {
14865
- const current = readFileSync(target, "utf8");
14866
- if (!isKnownTemplate(current) && !(options.force ?? false)) return {
14867
- written: target,
14868
- existed,
14869
- refused: true,
14870
- diffSummary: summarizeContentDiff(current, TEMPLATE(gate))
14871
- };
14872
- }
14873
- writeFileSync(target, TEMPLATE(gate));
14874
- return {
14875
- written: target,
14876
- existed,
14877
- refused: false,
14878
- diffSummary: []
14879
- };
14880
- }
14881
- //#endregion
14882
- //#region src/forensics/triage.ts
14883
- const ui$12 = plainContext();
14884
- const QUARANTINE_MIN_ATTEMPTS = 2;
14885
- /** Deterministic triage table rows, worst first. */
14886
- function triageRows(report) {
14887
- return report.verdicts.filter((v) => v.everFailed || v.passedOnRetry).sort((a, b) => {
14888
- if (a.passedOnRetry !== b.passedOnRetry) return a.passedOnRetry ? -1 : 1;
14889
- if (a.attempts !== b.attempts) return b.attempts - a.attempts;
14890
- return b.totalDurationMs - a.totalDurationMs;
14891
- }).map((v) => ({
14892
- file: v.file,
14893
- title: v.title,
14894
- attempts: v.attempts,
14895
- passedOnRetry: v.passedOnRetry,
14896
- finalStatus: v.finalStatus,
14897
- totalDurationMs: v.totalDurationMs,
14898
- suggestedAction: suggestAction(v),
14899
- proposedQuarantine: v.attempts >= QUARANTINE_MIN_ATTEMPTS && v.everFailed
14900
- }));
14901
- }
14902
- function suggestAction(v) {
14903
- if (v.passedOnRetry && v.attempts >= QUARANTINE_MIN_ATTEMPTS) return "quarantine + ticket";
14904
- if (v.passedOnRetry) return "fix nondeterminism";
14905
- if (v.finalStatus === "failed" || v.finalStatus === "timedOut") return "fix now — failing";
14906
- return "investigate";
14907
- }
14908
- /** Terminal rendering of the triage proposal. */
14909
- function renderTriage(report) {
14910
- const rows = triageRows(report);
14911
- const lines = [];
14912
- lines.push(sectionHeader("FLAKY TRIAGE — auto-generated, do not edit", ui$12));
14913
- lines.push("");
14914
- if (rows.length === 0) {
14915
- lines.push("Nothing to triage — no failures or retries in this run.");
14916
- return lines.join("\n");
14917
- }
14918
- const quarantineCount = rows.filter((r) => r.proposedQuarantine).length;
14919
- for (const r of rows) {
14920
- const flag = r.passedOnRetry ? "TRUE-FLAKE" : "FAILING";
14921
- lines.push(`• [${flag}] ${r.title} (${r.file}) — ${r.attempts} attempt${r.attempts === 1 ? "" : "s"} → ${r.suggestedAction}`);
14922
- }
14923
- lines.push("");
14924
- lines.push(`Auto-quarantine proposal: ${quarantineCount} test${quarantineCount === 1 ? "" : "s"} (retried ≥${QUARANTINE_MIN_ATTEMPTS} and failed at least once).`);
14925
- lines.push("Quarantined tests should run nightly, not per-PR — quarantine is not deletion.");
14926
- return lines.join("\n");
14927
- }
14928
- /** TRIAGE.md — the meeting artifact. */
14929
- function renderTriageMd(report) {
14930
- const rows = triageRows(report);
14808
+ /**
14809
+ * Default column budget when no width is supplied.
14810
+ *
14811
+ * `explain`'s prose used to be pushed as unbroken strings — the
14812
+ * "HOW TO VERIFY THE FIX" paragraph alone is 150 columns — so every
14813
+ * explanation overflowed a default terminal. Renderers here take a width
14814
+ * rather than reading process.stdout, so output stays a pure function of
14815
+ * its arguments (same rule the reporter's palette follows).
14816
+ */
14817
+ const DEFAULT_EXPLAIN_WIDTH = 80;
14818
+ function renderExplain(result, width = DEFAULT_EXPLAIN_WIDTH) {
14819
+ if (!result.ok || !result.rule) return `explain failed: ${result.error ?? "unknown error"}`;
14820
+ const r = result.rule;
14821
+ const evidenceLevel = r.evidenceLevel ?? deriveEvidenceLevel(r.findingType, r.confidence);
14931
14822
  const lines = [];
14932
- lines.push("# TRIAGE.md");
14823
+ /** Pushes prose indented two columns, wrapped to the budget. */
14824
+ const pushBody = (text) => {
14825
+ for (const seg of wrapText(text, Math.max(20, width - 2))) lines.push(` ${seg}`);
14826
+ };
14827
+ lines.push(sectionHeader(`${r.id} — ${r.title}`, ui$12));
14933
14828
  lines.push("");
14934
- lines.push("> Auto-generated by `mjolnir triage` from real run data. Do not edit by hand.");
14829
+ lines.push(`Severity: ${r.severity}`);
14830
+ lines.push(`Confidence: ${r.confidence}`);
14831
+ lines.push(`Tier: ${effectiveTier(r)}${isProvisional(r) ? " (PROVISIONAL)" : ""}`);
14832
+ lines.push(`Evidence: ${evidenceLevel}`);
14833
+ lines.push(`QA impact: ${QA_IMPACT_LABELS[r.qaImpact]} (${r.qaImpact})`);
14834
+ const measured = MEASURED_FP[r.id];
14835
+ lines.push(measured ? `Measured FP: ${Math.round(measured.fpRate * 100)}% (${measured.n} hand-classified corpus verdicts)` : `Measured FP: not yet measured — this rule ships on assumption (see docs/FP-AUDIT.md)`);
14836
+ if (r.falsePositiveRisk) lines.push(`FP risk: ${r.falsePositiveRisk} (author estimate)`);
14837
+ if (r.languages?.length) lines.push(`Languages: ${r.languages.join(", ")}`);
14838
+ if (r.frameworks?.length) lines.push(`Frameworks: ${r.frameworks.join(", ")}`);
14935
14839
  lines.push("");
14936
- if (rows.length === 0) {
14937
- lines.push("_No flaky or failing tests this run — nothing to triage._ 🎉");
14938
- return lines.join("\n");
14939
- }
14940
- lines.push("| Status | Test | File | Attempts | Duration | Suggested action | Quarantine? |");
14941
- lines.push("|--------|------|------|----------|----------|------------------|-------------|");
14942
- for (const r of rows) {
14943
- const status = r.passedOnRetry ? `${FLAKE_GLYPH} TRUE-FLAKE` : "❌ FAILING";
14944
- lines.push(`| ${status} | \`${r.title}\` | \`${r.file}\` | ${r.attempts} | ${(r.totalDurationMs / 1e3).toFixed(1)}s | ${r.suggestedAction} | ${r.proposedQuarantine ? "✅ propose" : "—"} |`);
14945
- }
14946
- const q = rows.filter((r) => r.proposedQuarantine).length;
14840
+ if (result.exampleFinding) {
14841
+ const f = result.exampleFinding;
14842
+ lines.push("WHAT WAS FOUND (real detector output, not a mockup)");
14843
+ pushBody(f.message);
14844
+ lines.push("");
14845
+ lines.push("WHY IT MATTERS");
14846
+ pushBody(f.why);
14847
+ lines.push("");
14848
+ lines.push("HOW TO FIX");
14849
+ pushBody(f.fix);
14850
+ lines.push("");
14851
+ pushBody(`Example from this rule's own must-fire fixture: ${result.exampleFixtureRelPath ?? result.exampleFixturePath ?? "(unknown path)"}`);
14852
+ } else for (const seg of wrapText("No example available — run this command from a mjolnir checkout (or pass --fixtures-root) so the fixture that proves this rule works can be shown as a real example.", width)) lines.push(seg);
14947
14853
  lines.push("");
14948
- lines.push(`**Auto-quarantine proposal: ${q}** — move them to the nightly suite and open tracked tickets.`);
14854
+ lines.push("HOW TO VERIFY THE FIX");
14855
+ pushBody("Re-run `mjolnir` on the changed file(s) — this finding should no longer appear. `mjolnir --scope changed` scopes the check to just what you touched.");
14949
14856
  lines.push("");
14857
+ lines.push(`Docs: mjolnir rules --md (full catalog, this rule included)`);
14950
14858
  return lines.join("\n");
14951
14859
  }
14952
14860
  //#endregion
@@ -15044,68 +14952,998 @@ function sweepStaleTempFiles(dir) {
15044
14952
  return swept;
15045
14953
  }
15046
14954
  //#endregion
15047
- //#region src/commands/badge.ts
15048
- /**
15049
- * `mjolnir badge` — evidentiary shields.io endpoint JSON (Tier 1 #5).
15050
- *
15051
- * Static JSON, no server. The badge makes falsifiable claims:
15052
- * score + date + commit. Anyone can click through and verify.
15053
- */
15054
- /**
15055
- * Badge colors follow the SAME ScoreState bands as the terminal
15056
- * (≥80 trusted / ≥50 warning / <50 critical / 100 forged) — this
15057
- * retarget fixes the historical threshold drift (the badge used
15058
- * ≥90/≥75/≥50 with four bands while the reporter used ≥80/≥50).
15059
- *
15060
- * Shields.io has no cyan or white-gold, so the mapping is documented
15061
- * here: trusted → `important` (blue-family, closest to aurora-cyan),
15062
- * forged → `success` (the strongest positive signal shields offers).
15063
- * The badge is a peripheral surface; ScoreState remains the truth.
15064
- */
15065
- function colorFor(score) {
15066
- const band = deriveScoreState(score).band;
15067
- if (band === "unmeasured") return "lightgrey";
15068
- if (band === "forged") return "success";
15069
- if (band === "trusted") return "important";
15070
- return band === "warning" ? "yellow" : "red";
15071
- }
15072
- /** Build the shields.io endpoint payload from a scan result. */
15073
- function buildBadge(result, commit) {
15074
- const errors = result.findings.filter((f) => f.severity === "error").length;
15075
- const score = result.score;
15076
- return {
15077
- schemaVersion: 1,
15078
- label: "MJÖLNIR",
15079
- message: score === null ? "no tests found" : score === 100 && errors === 0 ? "100/100 · forged" : `${score}/100 · ${errors} error${errors === 1 ? "" : "s"}`,
15080
- color: colorFor(score),
15081
- namedLogo: "vitest",
15082
- ...commit !== void 0 ? { commit } : {}
15083
- };
14955
+ //#region src/engine/resolution.ts
14956
+ /** Correlation identity (v1-compatible): ruleId\0file\0message. */
14957
+ function fingerprint$2(entry) {
14958
+ return `${entry.ruleId}\u0000${entry.file}\u0000${entry.message}`;
15084
14959
  }
15085
14960
  /**
15086
- * Full README-ready markdown snippet with commit-bound verification line
15087
- * (the falsifiable claim: "verified at commit X on date Y").
14961
+ * The §15 ordered algorithm. Pure; first match wins.
15088
14962
  */
15089
- function renderBadgeSnippet(result, repoUrl = "https://github.com/Sergey-Bar/Mjolnir") {
15090
- let commit = "unknown";
15091
- try {
15092
- commit = execSync("git rev-parse --short HEAD", {
15093
- cwd: process.cwd(),
15094
- encoding: "utf8",
15095
- stdio: [
15096
- "ignore",
15097
- "pipe",
15098
- "ignore"
15099
- ]
15100
- }).trim();
15101
- } catch {}
15102
- const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
15103
- const errors = result.findings.filter((f) => f.severity === "error").length;
15104
- return [
15105
- "```markdown",
15106
- "[![MJÖLNIR](https://img.shields.io/endpoint?url=<your-badge-json-url>)](" + repoUrl + ")",
15107
- "<!-- Mjölnir verified at commit " + commit + " on " + date + ": " + (result.score === null ? "no tests found" : result.score + "/100") + ", " + errors + " blocking error(s) -->",
15108
- "```"
14963
+ function resolve$1(input) {
14964
+ const { entry, baseline, current } = input;
14965
+ const fp = fingerprint$2(entry);
14966
+ const comparedAgainst = input.baselineCommit ?? baseline.commit ?? "unknown baseline";
14967
+ if (current.partial || current.analysisStatus.rules !== "complete") return {
14968
+ status: "INCONCLUSIVE",
14969
+ cause: "partial",
14970
+ comparedAgainst
14971
+ };
14972
+ if (input.crashedRuleIds?.has(entry.ruleId)) return {
14973
+ status: "INCONCLUSIVE",
14974
+ cause: "crash",
14975
+ comparedAgainst
14976
+ };
14977
+ if (input.skippedFiles?.has(entry.file)) return {
14978
+ status: "INCONCLUSIVE",
14979
+ cause: "skipped",
14980
+ comparedAgainst
14981
+ };
14982
+ if (input.suppressed?.has(`${entry.ruleId}\u0000${entry.file}`)) return {
14983
+ status: "SUPPRESSED",
14984
+ comparedAgainst
14985
+ };
14986
+ if (input.excludedFiles?.has(entry.file)) return {
14987
+ status: "DISAPPEARED-NON-FIX",
14988
+ cause: "excluded",
14989
+ comparedAgainst
14990
+ };
14991
+ const entryRev = entry.detectorRevision;
14992
+ if (entryRev === void 0) return {
14993
+ status: "INCONCLUSIVE",
14994
+ cause: "legacy-baseline",
14995
+ comparedAgainst
14996
+ };
14997
+ const registryRev = input.registryRevisions.get(entry.ruleId);
14998
+ if (registryRev === void 0) return {
14999
+ status: "DISAPPEARED-NON-FIX",
15000
+ cause: "retired",
15001
+ comparedAgainst
15002
+ };
15003
+ if (registryRev !== entryRev) return {
15004
+ status: "INCONCLUSIVE",
15005
+ cause: "revision-changed",
15006
+ comparedAgainst
15007
+ };
15008
+ if (current.findings.some((f) => fingerprint$2(f) === fp)) return {
15009
+ status: "STILL-PRESENT",
15010
+ comparedAgainst
15011
+ };
15012
+ return {
15013
+ status: "VERIFIED-RESOLVED",
15014
+ comparedAgainst
15015
+ };
15016
+ }
15017
+ /** The §15 rendering law: "FIXED" only for VERIFIED-RESOLVED. */
15018
+ function renderResolution(r) {
15019
+ switch (r.status) {
15020
+ case "VERIFIED-RESOLVED": return "FIXED SINCE BASELINE (verified by a complete same-revision scan)";
15021
+ case "STILL-PRESENT": return "STILL PRESENT";
15022
+ case "SUPPRESSED": return "SUPPRESSED (active ignore entry)";
15023
+ case "INCONCLUSIVE": return `INCONCLUSIVE (${r.cause})`;
15024
+ case "DISAPPEARED-NON-FIX": return `DISAPPEARED — NOT A FIX (${r.cause})`;
15025
+ }
15026
+ }
15027
+ //#endregion
15028
+ //#region src/commands/baseline.ts
15029
+ /**
15030
+ * `mjolnir baseline` / `mjolnir diff` — Sprint 6 Task 24
15031
+ * (Master-Stabilization-Plan.md).
15032
+ *
15033
+ * Implements Plan.md Phase 10 / §24's key insight: existing debt should
15034
+ * not block every PR — only NEW or WORSENED debt should. `baseline`
15035
+ * snapshots the current finding set to disk; `diff` compares the current
15036
+ * scan against that snapshot and reports only what changed.
15037
+ *
15038
+ * Findings are matched across scans by a stable fingerprint (ruleId +
15039
+ * file + message), not by line number — line numbers shift constantly as
15040
+ * a file is edited, so matching on them would report unrelated churn as
15041
+ * "new" debt and miss genuinely new findings that happen to land on a
15042
+ * previously-flagged line.
15043
+ *
15044
+ * Storage: .mjolnir/baseline.json (local; not gitignored — only
15045
+ * .mjolnir/logs/ is, see .gitignore). A team CAN commit this
15046
+ * file if they want a shared baseline; that's a deliberate choice this
15047
+ * command does not make for them.
15048
+ */
15049
+ const ui$11 = plainContext();
15050
+ const DEFAULT_BASELINE_PATH = join(".mjolnir", "baseline.json");
15051
+ /**
15052
+ * Registry-declared detector revisions (§17): a baseline entry whose
15053
+ * revision differs from today's registry is INCONCLUSIVE(revision-
15054
+ * changed), never resolved. Omitted declarations mean revision 1 (the
15055
+ * documented RuleMeta default for first-generation detectors); a
15056
+ * ruleId ABSENT from this map means the rule is retired.
15057
+ */
15058
+ const REGISTRY_REVISIONS = new Map(RULES.map((r) => [r.id, r.detectorRevision ?? 1]));
15059
+ /**
15060
+ * Correlation identity for before/after comparison (agent-handoff plan
15061
+ * §5.2): ruleId + file + message, deliberately EXCLUDING `line` — a
15062
+ * source edit that shifts a finding still correlates. file:line is an
15063
+ * occurrence location, not a durable identity; message rewording,
15064
+ * file renames and rule-id changes correlate as resolved+new
15065
+ * (documented limitation). Exported for the handoff verification
15066
+ * contract — do not duplicate this algorithm.
15067
+ */
15068
+ function fingerprint$1(f) {
15069
+ return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
15070
+ }
15071
+ function buildBaseline(result, commit) {
15072
+ return {
15073
+ schemaVersion: 1,
15074
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
15075
+ commit,
15076
+ ...result.score !== null ? { score: result.score } : {},
15077
+ findings: result.findings.map((f) => ({
15078
+ ruleId: f.ruleId,
15079
+ file: f.file,
15080
+ message: f.message,
15081
+ severity: f.severity,
15082
+ ...f.detectorRevision !== void 0 ? { detectorRevision: f.detectorRevision } : {}
15083
+ }))
15084
+ };
15085
+ }
15086
+ /**
15087
+ * Bug-audit L7 / decision D3: overwriting an existing baseline used to
15088
+ * happen silently — a stale re-capture on a dirty tree made `diff`
15089
+ * report all old debt as new with no way to see what was lost. The
15090
+ * previous baseline is now backed up to `<path>.bak` and the caller
15091
+ * prints the notice.
15092
+ */
15093
+ function saveBaseline(result, commit, outPath) {
15094
+ mkdirSync(dirname(outPath), { recursive: true });
15095
+ const existed = existsSync(outPath);
15096
+ let backupPath;
15097
+ if (existed) {
15098
+ backupPath = `${outPath}.bak`;
15099
+ copyFileSync(outPath, backupPath);
15100
+ }
15101
+ writeFileAtomic(outPath, JSON.stringify(buildBaseline(result, commit), null, 2) + "\n");
15102
+ return {
15103
+ path: outPath,
15104
+ replaced: existed,
15105
+ ...backupPath !== void 0 ? { backupPath } : {}
15106
+ };
15107
+ }
15108
+ function loadBaseline(path, onWarning) {
15109
+ if (!existsSync(path)) return null;
15110
+ try {
15111
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
15112
+ if (typeof parsed === "object" && parsed !== null && "findings" in parsed && Array.isArray(parsed.findings)) {
15113
+ const file = parsed;
15114
+ const version = parsed.schemaVersion;
15115
+ if (version === void 0) onWarning?.("baseline file has no schemaVersion (pre-versioning format) — treated as v1.");
15116
+ else if (version !== 1) {
15117
+ onWarning?.(`baseline file declares schemaVersion ${JSON.stringify(version)}; this Mjölnir understands v1 — baseline ignored (upgrade Mjölnir to diff it).`);
15118
+ return null;
15119
+ }
15120
+ const rawScore = parsed.score;
15121
+ const score = typeof rawScore === "number" && Number.isFinite(rawScore) ? rawScore : void 0;
15122
+ file.findings = file.findings.filter((f) => typeof f === "object" && f !== null && typeof f.ruleId === "string" && typeof f.file === "string" && typeof f.message === "string");
15123
+ return score === void 0 ? file : {
15124
+ ...file,
15125
+ score
15126
+ };
15127
+ }
15128
+ return null;
15129
+ } catch {
15130
+ return null;
15131
+ }
15132
+ }
15133
+ function diffAgainstBaseline(result, baseline) {
15134
+ if (!baseline) return {
15135
+ hasBaseline: false,
15136
+ newFindings: [],
15137
+ resolvedFindings: [],
15138
+ unchangedCount: 0
15139
+ };
15140
+ const baseSet = /* @__PURE__ */ new Map();
15141
+ for (const f of baseline.findings) baseSet.set(fingerprint$1(f), f);
15142
+ const headKeys = /* @__PURE__ */ new Set();
15143
+ const newFindings = [];
15144
+ let unchangedCount = 0;
15145
+ for (const f of result.findings) {
15146
+ const key = fingerprint$1(f);
15147
+ headKeys.add(key);
15148
+ if (baseSet.has(key)) unchangedCount++;
15149
+ else newFindings.push(f);
15150
+ }
15151
+ const resolvedFindings = [];
15152
+ for (const [key, f] of baseSet) if (!headKeys.has(key)) {
15153
+ const resolution = resolve$1({
15154
+ entry: f,
15155
+ baseline,
15156
+ current: result,
15157
+ registryRevisions: REGISTRY_REVISIONS
15158
+ });
15159
+ resolvedFindings.push({
15160
+ ...f,
15161
+ resolution
15162
+ });
15163
+ }
15164
+ return {
15165
+ hasBaseline: true,
15166
+ baselineCapturedAt: baseline.capturedAt,
15167
+ baselineCommit: baseline.commit,
15168
+ ...baseline.score !== void 0 ? { baselineScore: baseline.score } : {},
15169
+ newFindings,
15170
+ resolvedFindings,
15171
+ unchangedCount
15172
+ };
15173
+ }
15174
+ function renderBaselineSaved(path, count, replaced) {
15175
+ const lines = [
15176
+ sectionHeader("BASELINE SAVED", ui$11),
15177
+ "",
15178
+ `Captured ${count} finding${count === 1 ? "" : "s"} to ${path}.`
15179
+ ];
15180
+ if (replaced?.backupPath !== void 0) lines.push(`Replaced an existing baseline — the previous one was saved to ${replaced.backupPath}.`);
15181
+ lines.push(nextStep("mjolnir diff", ui$11) + " — see only what's new.");
15182
+ return lines.join("\n");
15183
+ }
15184
+ function renderBaselineDiff(diff) {
15185
+ const lines = [];
15186
+ lines.push(sectionHeader("DIFF AGAINST BASELINE", ui$11));
15187
+ lines.push("");
15188
+ if (!diff.hasBaseline) {
15189
+ lines.push("UNKNOWN — no baseline found.");
15190
+ lines.push(nextStep("mjolnir baseline", ui$11) + " to capture a comparison point.");
15191
+ return lines.join("\n");
15192
+ }
15193
+ lines.push(`Baseline captured ${diff.baselineCapturedAt ?? "unknown time"} at commit ${diff.baselineCommit ?? "unknown"}.`);
15194
+ lines.push(`${diff.unchangedCount} pre-existing finding${diff.unchangedCount === 1 ? "" : "s"} carried over — not reported as new.`);
15195
+ lines.push("");
15196
+ if (diff.newFindings.length === 0) lines.push("NEW OR WORSENED DEBT: none. This change introduced nothing new.");
15197
+ else {
15198
+ lines.push(`NEW OR WORSENED DEBT (${diff.newFindings.length}) — this is what should block this PR:`);
15199
+ for (const f of diff.newFindings) lines.push(` + ${f.ruleId} (${f.severity}) · ${f.file}:${f.line} — ${f.message}`);
15200
+ }
15201
+ lines.push("");
15202
+ if (diff.resolvedFindings.length > 0) {
15203
+ const verified = diff.resolvedFindings.filter((f) => f.resolution.status === "VERIFIED-RESOLVED");
15204
+ const unresolved = diff.resolvedFindings.filter((f) => f.resolution.status !== "VERIFIED-RESOLVED");
15205
+ if (verified.length > 0) {
15206
+ lines.push(`FIXED SINCE BASELINE (${verified.length}):`);
15207
+ for (const f of verified) lines.push(` ✓ ${f.ruleId} (${f.severity}) · ${f.file} — ${f.message}`);
15208
+ lines.push("");
15209
+ }
15210
+ if (unresolved.length > 0) {
15211
+ lines.push(`DISAPPEARED — NOT CLASSIFIED AS FIXED (${unresolved.length}):`);
15212
+ for (const f of unresolved) lines.push(` ${renderResolution(f.resolution)} · ${f.ruleId} (${f.severity}) · ${f.file} — ${f.message}`);
15213
+ }
15214
+ }
15215
+ return lines.join("\n");
15216
+ }
15217
+ //#endregion
15218
+ //#region src/mcp/server.ts
15219
+ /** Hard caps (§21 threat model: parameter size + resource bounds). */
15220
+ const MAX_PARAM_BYTES = 65536;
15221
+ const PROTOCOL_VERSION = "2025-06-18";
15222
+ /** JSON-RPC error codes (subset used here). */
15223
+ const MCP_ERRORS = {
15224
+ PARSE: -32700,
15225
+ INVALID_REQUEST: -32600,
15226
+ METHOD_NOT_FOUND: -32601,
15227
+ INVALID_PARAMS: -32602,
15228
+ INTERNAL: -32603
15229
+ };
15230
+ /** The tool catalog — 1:1 mappings to canonical verbs, nothing else. */
15231
+ const MCP_TOOLS = [
15232
+ {
15233
+ name: "scan",
15234
+ description: "Run the Mjölnir verification scan on a directory and return the canonical machine contract (findings with ruleId/detectorRevision/evidence/trust, completeness fields, deterministic digest). One scan in flight; zero network; plugin gate applies unchanged.",
15235
+ inputSchema: {
15236
+ type: "object",
15237
+ properties: {
15238
+ path: {
15239
+ type: "string",
15240
+ description: "Absolute or repo-relative directory to scan. Must exist."
15241
+ },
15242
+ maxDurationMs: {
15243
+ type: "number",
15244
+ description: "Optional analysis budget in ms (pipeline budget)."
15245
+ }
15246
+ },
15247
+ required: ["path"]
15248
+ }
15249
+ },
15250
+ {
15251
+ name: "explain",
15252
+ description: "Explain one rule: what it detects, why it matters, how to fix it, plus its measured FP rate and trust metadata. Read-only; never runs a scan.",
15253
+ inputSchema: {
15254
+ type: "object",
15255
+ properties: { ruleId: {
15256
+ type: "string",
15257
+ description: "e.g. QA-PW-101"
15258
+ } },
15259
+ required: ["ruleId"]
15260
+ }
15261
+ },
15262
+ {
15263
+ name: "diff",
15264
+ description: "Compare a completed scan result against the target's committed baseline (.mjolnir/baseline.json) using the §15 lifecycle resolution. Returns new findings and per-disappearance resolutions (VERIFIED-RESOLVED / INCONCLUSIVE(cause) / SUPPRESSED / DISAPPEARED-NON-FIX). Read-only.",
15265
+ inputSchema: {
15266
+ type: "object",
15267
+ properties: {
15268
+ path: {
15269
+ type: "string",
15270
+ description: "Scanned directory."
15271
+ },
15272
+ scanResult: {
15273
+ type: "object",
15274
+ description: "The canonical ScanResult JSON previously returned by the scan tool."
15275
+ }
15276
+ },
15277
+ required: ["path", "scanResult"]
15278
+ }
15279
+ }
15280
+ ];
15281
+ /** Strict param validation — unknown/missing/mistyped ⇒ INVALID_PARAMS. */
15282
+ function validateParams(name, args) {
15283
+ if (JSON.stringify(args).length > MAX_PARAM_BYTES) return `parameters exceed ${MAX_PARAM_BYTES} bytes (threat model §21)`;
15284
+ if (name === "scan") {
15285
+ if (typeof args["path"] !== "string" || args["path"].length === 0) return "scan requires a non-empty string `path`";
15286
+ if (args["maxDurationMs"] !== void 0 && typeof args["maxDurationMs"] !== "number") return "`maxDurationMs` must be a number";
15287
+ }
15288
+ if (name === "explain") {
15289
+ if (typeof args["ruleId"] !== "string" || !/^QA-[A-Z]+-\d{3}$/.test(args["ruleId"])) return "explain requires `ruleId` matching /^QA-[A-Z]+-\\d{3}$/";
15290
+ }
15291
+ if (name === "diff") {
15292
+ if (typeof args["path"] !== "string" || args["path"].length === 0) return "diff requires a non-empty string `path`";
15293
+ if (typeof args["scanResult"] !== "object" || args["scanResult"] === null) return "diff requires the `scanResult` object from a previous scan tool call";
15294
+ }
15295
+ return null;
15296
+ }
15297
+ /**
15298
+ * Serialized queue for tool work (§21: ONE scan in flight). The queue
15299
+ * tail swallows each settled result so the chain never accumulates
15300
+ * rejections — the caller's own `next` still propagates errors to the
15301
+ * awaiting tool call (the `work, work` pair passes the rejection
15302
+ * through to `next` too, so `handleToolCall`'s catch converts it into
15303
+ * a structured INTERNAL response).
15304
+ */
15305
+ let scanInFlight = Promise.resolve();
15306
+ function serializeScan(work) {
15307
+ const next = scanInFlight.then(work, work);
15308
+ scanInFlight = next.then(() => void 0, () => void 0);
15309
+ return next;
15310
+ }
15311
+ /**
15312
+ * Dispatch a tool call. Every tool maps 1:1 to canonical semantics —
15313
+ * the transport adds SHAPE, never MEANING.
15314
+ */
15315
+ async function handleToolCall(call) {
15316
+ const paramError = validateParams(call.name, call.args);
15317
+ if (paramError !== null) return {
15318
+ jsonrpc: "2.0",
15319
+ id: call.id,
15320
+ error: {
15321
+ code: MCP_ERRORS.INVALID_PARAMS,
15322
+ message: paramError
15323
+ }
15324
+ };
15325
+ try {
15326
+ if (call.name === "scan") {
15327
+ const target = resolve(call.args["path"]);
15328
+ if (!existsSync(target)) return {
15329
+ jsonrpc: "2.0",
15330
+ id: call.id,
15331
+ error: {
15332
+ code: MCP_ERRORS.INVALID_PARAMS,
15333
+ message: `scan target does not exist: ${target}`
15334
+ }
15335
+ };
15336
+ const maxDurationMs = typeof call.args["maxDurationMs"] === "number" ? call.args["maxDurationMs"] : Number.POSITIVE_INFINITY;
15337
+ const result = await serializeScan(() => runScan({
15338
+ target,
15339
+ json: true,
15340
+ verbose: false,
15341
+ maxDurationMs,
15342
+ scopeChanged: false,
15343
+ format: "json"
15344
+ }));
15345
+ return {
15346
+ jsonrpc: "2.0",
15347
+ id: call.id,
15348
+ result: {
15349
+ ...result,
15350
+ contract: buildMachineContract(result)
15351
+ }
15352
+ };
15353
+ }
15354
+ if (call.name === "explain") {
15355
+ const ruleId = call.args["ruleId"];
15356
+ const explanation = explainRule(ruleId, process.cwd());
15357
+ if (!explanation.ok) return {
15358
+ jsonrpc: "2.0",
15359
+ id: call.id,
15360
+ error: {
15361
+ code: MCP_ERRORS.INVALID_PARAMS,
15362
+ message: explanation.error
15363
+ }
15364
+ };
15365
+ return {
15366
+ jsonrpc: "2.0",
15367
+ id: call.id,
15368
+ result: explanation
15369
+ };
15370
+ }
15371
+ if (call.name === "diff") {
15372
+ const target = resolve(call.args["path"]);
15373
+ const scanResult = call.args["scanResult"];
15374
+ const baselinePath = join(target, DEFAULT_BASELINE_PATH);
15375
+ if (!existsSync(baselinePath)) return {
15376
+ jsonrpc: "2.0",
15377
+ id: call.id,
15378
+ result: {
15379
+ hasBaseline: false,
15380
+ note: "no committed baseline at .mjolnir/baseline.json — nothing to resolve against"
15381
+ }
15382
+ };
15383
+ const baseline = loadBaseline(baselinePath);
15384
+ if (baseline === null) return {
15385
+ jsonrpc: "2.0",
15386
+ id: call.id,
15387
+ result: {
15388
+ hasBaseline: false,
15389
+ note: "baseline failed to parse"
15390
+ }
15391
+ };
15392
+ const diff = diffAgainstBaseline(scanResult, baseline);
15393
+ return {
15394
+ jsonrpc: "2.0",
15395
+ id: call.id,
15396
+ result: {
15397
+ hasBaseline: true,
15398
+ baselineCapturedAt: diff.baselineCapturedAt,
15399
+ baselineCommit: diff.baselineCommit,
15400
+ newFindings: diff.newFindings,
15401
+ resolutions: diff.resolvedFindings.map((f) => ({
15402
+ ruleId: f.ruleId,
15403
+ file: f.file,
15404
+ message: f.message,
15405
+ resolution: f.resolution
15406
+ })),
15407
+ unchangedCount: diff.unchangedCount
15408
+ }
15409
+ };
15410
+ }
15411
+ return {
15412
+ jsonrpc: "2.0",
15413
+ id: call.id,
15414
+ error: {
15415
+ code: MCP_ERRORS.METHOD_NOT_FOUND,
15416
+ message: `unknown tool: ${call.name}`
15417
+ }
15418
+ };
15419
+ } catch (err) {
15420
+ return {
15421
+ jsonrpc: "2.0",
15422
+ id: call.id,
15423
+ error: {
15424
+ code: MCP_ERRORS.INTERNAL,
15425
+ message: errorMessage(err)
15426
+ }
15427
+ };
15428
+ }
15429
+ }
15430
+ /** Normalize any thrown value to a string (unit-tested both arms). */
15431
+ function errorMessage(err) {
15432
+ return err instanceof Error ? err.message : String(err);
15433
+ }
15434
+ /** JSON-RPC request shape validation (strict — §21). */
15435
+ function validateRequest(msg) {
15436
+ if (typeof msg !== "object" || msg === null) return {
15437
+ ok: false,
15438
+ errorCode: MCP_ERRORS.PARSE
15439
+ };
15440
+ const o = msg;
15441
+ if (o["jsonrpc"] !== "2.0") return {
15442
+ ok: false,
15443
+ errorCode: MCP_ERRORS.INVALID_REQUEST
15444
+ };
15445
+ if (typeof o["method"] !== "string") return {
15446
+ ok: false,
15447
+ errorCode: MCP_ERRORS.INVALID_REQUEST
15448
+ };
15449
+ if (o["id"] === void 0) return {
15450
+ ok: false,
15451
+ errorCode: MCP_ERRORS.INVALID_REQUEST
15452
+ };
15453
+ return {
15454
+ ok: true,
15455
+ id: o["id"],
15456
+ method: o["method"],
15457
+ params: o["params"]
15458
+ };
15459
+ }
15460
+ /**
15461
+ * Handle one JSON-RPC message (initialize / tools/list / tools/call).
15462
+ * Returns the response object, or null for notifications.
15463
+ */
15464
+ async function handleMcpMessage(msg) {
15465
+ const req = validateRequest(msg);
15466
+ if (!req.ok) return {
15467
+ jsonrpc: "2.0",
15468
+ id: null,
15469
+ error: {
15470
+ code: req.errorCode,
15471
+ message: "malformed JSON-RPC request"
15472
+ }
15473
+ };
15474
+ if (req.method === "initialize") return {
15475
+ jsonrpc: "2.0",
15476
+ id: req.id,
15477
+ result: {
15478
+ protocolVersion: PROTOCOL_VERSION,
15479
+ capabilities: { tools: {} },
15480
+ serverInfo: {
15481
+ name: "mjolnir-qa",
15482
+ version: CLI_VERSION
15483
+ }
15484
+ }
15485
+ };
15486
+ if (req.method === "tools/list") return {
15487
+ jsonrpc: "2.0",
15488
+ id: req.id,
15489
+ result: { tools: MCP_TOOLS }
15490
+ };
15491
+ if (req.method === "tools/call") {
15492
+ const params = req.params ?? {};
15493
+ const name = params["name"];
15494
+ if (typeof name !== "string") return {
15495
+ jsonrpc: "2.0",
15496
+ id: req.id,
15497
+ error: {
15498
+ code: MCP_ERRORS.INVALID_PARAMS,
15499
+ message: "tools/call requires a string `name`"
15500
+ }
15501
+ };
15502
+ const args = typeof params["arguments"] === "object" && params["arguments"] !== null ? params["arguments"] : {};
15503
+ const response = await handleToolCall({
15504
+ id: req.id,
15505
+ name,
15506
+ args
15507
+ });
15508
+ if (response.error !== void 0) return {
15509
+ jsonrpc: "2.0",
15510
+ id: req.id,
15511
+ error: response.error
15512
+ };
15513
+ const payload = response.result;
15514
+ return {
15515
+ jsonrpc: "2.0",
15516
+ id: req.id,
15517
+ result: {
15518
+ content: [{
15519
+ type: "text",
15520
+ text: JSON.stringify(payload, null, 2)
15521
+ }],
15522
+ structuredContent: payload
15523
+ }
15524
+ };
15525
+ }
15526
+ return {
15527
+ jsonrpc: "2.0",
15528
+ id: req.id,
15529
+ error: {
15530
+ code: MCP_ERRORS.METHOD_NOT_FOUND,
15531
+ message: `unknown method: ${req.method}`
15532
+ }
15533
+ };
15534
+ }
15535
+ //#endregion
15536
+ //#region src/mcp/transport.ts
15537
+ /**
15538
+ * MCP stdio transport loop (blueprint §21 — the framing around the
15539
+ * pure-transport server).
15540
+ *
15541
+ * Reads newline-delimited JSON-RPC 2.0 frames from `input`, writes one
15542
+ * response frame per request to `output`. Malformed input produces a
15543
+ * structured error response and the loop CONTINUES — the transport
15544
+ * stays alive by contract (§21). EOF resolves; the binary entrypoint
15545
+ * (stdio.ts) exits 0 after it.
15546
+ */
15547
+ /**
15548
+ * Drive the transport over arbitrary streams (real stdio in the binary,
15549
+ * PassThrough in tests). Resolves on input EOF.
15550
+ */
15551
+ async function runStdioTransport(input, output) {
15552
+ const rl = createInterface({ input });
15553
+ for await (const line of rl) await handleStdioLine(line, (s) => output.write(s));
15554
+ }
15555
+ /**
15556
+ * Process one newline-delimited JSON-RPC frame; write the response to
15557
+ * `write`. Never throws — malformed frames become structured errors and
15558
+ * the transport stays alive (§21).
15559
+ */
15560
+ async function handleStdioLine(line, write) {
15561
+ const trimmed = line.trim();
15562
+ if (trimmed.length === 0) return;
15563
+ let parsed;
15564
+ try {
15565
+ parsed = JSON.parse(trimmed);
15566
+ } catch {
15567
+ write(`${JSON.stringify({
15568
+ jsonrpc: "2.0",
15569
+ id: null,
15570
+ error: {
15571
+ code: -32700,
15572
+ message: "parse error — input was not valid JSON"
15573
+ }
15574
+ })}\n`);
15575
+ return;
15576
+ }
15577
+ const response = await handleMcpMessage(parsed);
15578
+ write(`${JSON.stringify(response)}\n`);
15579
+ }
15580
+ //#endregion
15581
+ //#region src/integrations/ci-install.ts
15582
+ /**
15583
+ * CI integration (Sprint-Plan W7): generates .github/workflows/mjolnir.yml
15584
+ * from internal templates ONLY — no user-input interpolation (R3 supply-chain).
15585
+ * Default gate: advisory (report, never block).
15586
+ *
15587
+ * Bug-audit hardening (H2): the previous template shipped the same
15588
+ * `github.rest.checks` no-op this repo's own audit removed from mjolnir.yml,
15589
+ * failed the job before the annotate/summary steps could run whenever the
15590
+ * scan step exited non-zero, recommended floating `mjolnir-qa@latest`, and
15591
+ * `ciInstall` silently overwrote hand-customized workflows. The template now
15592
+ * mirrors the dogfooded `.github/workflows/mjolnir.yml` (pinned action SHAs,
15593
+ * `if: always()` on reporting steps, a real gate step that reads
15594
+ * `mjolnir.json`, partial scans never block) and `ciInstall` refuses to
15595
+ * replace a customized workflow without an explicit `--force`.
15596
+ */
15597
+ /**
15598
+ * The gate-check script embedded in generated workflows (and executed
15599
+ * directly by tests against fixture JSONs). Semantics, kept in sync with
15600
+ * the tool's own exit-code contract:
15601
+ * - missing/unreadable `mjolnir.json` → fail (the scan step crashed; a
15602
+ * silent pass here would turn a broken pipeline into a green one);
15603
+ * - `partial: true` → never block (truncated results can neither prove
15604
+ * nor disprove the gate — the "PARTIAL" banner in the summary is the
15605
+ * honest signal);
15606
+ * - `error` gate → block on any error-severity finding;
15607
+ * - `warning` gate → block on warnings and errors.
15608
+ */
15609
+ function gateScript(gate) {
15610
+ return [
15611
+ "const fs = require(\"fs\");",
15612
+ "let r;",
15613
+ "try {",
15614
+ " r = JSON.parse(fs.readFileSync(\"mjolnir.json\", \"utf8\"));",
15615
+ "} catch (e) {",
15616
+ " process.stderr.write(\"mjolnir.json is missing or unreadable - the scan step crashed before the gate could run. Failing instead of passing silently.\\n\");",
15617
+ " process.exit(1);",
15618
+ "}",
15619
+ "if (r.partial === true) {",
15620
+ " process.stdout.write(\"Scan was PARTIAL - some files were not analyzed; gate not enforced.\\n\");",
15621
+ " process.exit(0);",
15622
+ "}",
15623
+ "const findings = Array.isArray(r.findings) ? r.findings : [];",
15624
+ "const errors = findings.filter(function (f) { return f && f.severity === \"error\"; }).length;",
15625
+ "const warnings = findings.filter(function (f) { return f && f.severity === \"warning\"; }).length;",
15626
+ "process.stdout.write(\"Mjolnir gate: \" + errors + \" error(s), \" + warnings + \" warning(s).\\n\");",
15627
+ `if (${gate === "error" ? "errors > 0" : "errors > 0 || warnings > 0"}) { process.exit(1); }`,
15628
+ "process.exit(0);"
15629
+ ].join("\n");
15630
+ }
15631
+ /**
15632
+ * Template v2 (Terminal + CI UX Overhaul plan, M4): the summary step
15633
+ * calls `mjolnir summary mjolnir.json` — annotations + step summary via
15634
+ * ONE emitter — instead of the v1 inline SUMMARY_SCRIPT. The gate
15635
+ * script is unchanged (reads `partial`, `findings[].severity`).
15636
+ */
15637
+ /** Renders findings into `$GITHUB_STEP_SUMMARY`; tolerates a missing scan result.
15638
+ * v1 inline script, retained ONLY for overwrite-refusal recognition of
15639
+ * workflows generated by older versions (they are treated as ours, so
15640
+ * a frictionless `ci install` upgrade stays possible). Exported for the
15641
+ * recognition test that reconstructs the embedded form. */
15642
+ const SUMMARY_SCRIPT_V1 = [
15643
+ "const fs = require(\"fs\");",
15644
+ "let r = {};",
15645
+ "try { r = JSON.parse(fs.readFileSync(\"mjolnir.json\", \"utf8\")); } catch (e) {}",
15646
+ "const findings = Array.isArray(r.findings) ? r.findings : [];",
15647
+ "const lines = findings.map(function (f) {",
15648
+ " return \"- **\" + f.ruleId + \"** (\" + f.severity + \") \" + f.file + \":\" + f.line + \" - \" + f.message;",
15649
+ "});",
15650
+ "const head = r.partial === true",
15651
+ " ? \"Mjolnir scan was PARTIAL - some files may not have been analyzed.\"",
15652
+ " : \"Mjolnir scan finished.\";",
15653
+ "const body = [\"## Mjolnir findings\", head, \"\"].concat(lines).join(\"\\n\");",
15654
+ "if (process.env.GITHUB_STEP_SUMMARY && lines.length > 0) {",
15655
+ " fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, body + \"\\n\");",
15656
+ "}",
15657
+ "process.stdout.write(lines.length + \" finding(s)\\n\");"
15658
+ ].join("\n");
15659
+ /** True when a workflow was generated by any Mjölnir template (v1 or v2).
15660
+ * The v1 needle is matched in its INDENTED form: the v1 template embedded
15661
+ * the script via indentBlock(…, 10) inside the `run: |` scalar, so the
15662
+ * raw unindented substring never appears in a real v1 file. */
15663
+ function isKnownTemplate(content) {
15664
+ return GATES.some((g) => content === TEMPLATE(g)) || content.includes(indentBlock(SUMMARY_SCRIPT_V1, 10));
15665
+ }
15666
+ /** Indents an embedded script so it sits inside a YAML `run: |` block scalar. */
15667
+ function indentBlock(text, spaces) {
15668
+ const pad = " ".repeat(spaces);
15669
+ return text.split("\n").map((l) => l.length > 0 ? pad + l : l).join("\n");
15670
+ }
15671
+ /** The generated workflow for one gate level. Exported for template tests. */
15672
+ const TEMPLATE = (gate) => `name: Mjölnir
15673
+
15674
+ on:
15675
+ pull_request:
15676
+
15677
+ concurrency:
15678
+ group: mjolnir-\${{ github.ref }}
15679
+ cancel-in-progress: true
15680
+
15681
+ permissions:
15682
+ contents: read
15683
+ pull-requests: write
15684
+
15685
+ jobs:
15686
+ scan:
15687
+ runs-on: ubuntu-latest
15688
+ # A hung scan must not sit for the 6-hour default.
15689
+ timeout-minutes: 10
15690
+ steps:
15691
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
15692
+ with:
15693
+ fetch-depth: 0 # needed for --scope changed merge-base
15694
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
15695
+ with:
15696
+ node-version: 22
15697
+ # Scan with the PINNED version that generated this workflow — never
15698
+ # a floating tag: a new release must not change your gate semantics
15699
+ # with no commit of yours. To review PRs with the exact tool your
15700
+ # repo develops against, add mjolnir-qa to devDependencies and drop
15701
+ # the @version suffix so npx resolves the local install.
15702
+ - name: Scan changed code (exit 1/2 is data — the gate step decides)
15703
+ continue-on-error: true
15704
+ run: npx --yes mjolnir-qa@${CLI_VERSION} . --scope changed --json > mjolnir.json
15705
+ # Reporting, not gating: a crashed scan leaves mjolnir.json empty/missing
15706
+ # and the summary step exits 2/10 — continue-on-error keeps the advisory
15707
+ # job green, exactly like the v1 inline script did (the gate step decides).
15708
+ - name: Annotations + Job Summary
15709
+ if: always()
15710
+ continue-on-error: true
15711
+ run: npx --yes mjolnir-qa@${CLI_VERSION} summary mjolnir.json
15712
+ - name: Render PR comment
15713
+ if: always()
15714
+ continue-on-error: true
15715
+ run: npx --yes mjolnir-qa@${CLI_VERSION} pr-comment . > mjolnir-comment.md
15716
+ # Best-effort: on a pull_request event from a fork the GITHUB_TOKEN is
15717
+ # read-only and this step will 403 for every external contributor. The
15718
+ # Job Summary above is the fallback that always renders.
15719
+ # (pull_request_target would fix the token but is a code-execution
15720
+ # risk — deliberately NOT used.)
15721
+ - name: Post or update PR comment
15722
+ if: always()
15723
+ continue-on-error: true
15724
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
15725
+ with:
15726
+ script: |
15727
+ const fs = require('fs');
15728
+ let body = '';
15729
+ try { body = fs.readFileSync('mjolnir-comment.md', 'utf8'); } catch (e) {}
15730
+ if (!body.trim()) {
15731
+ console.log('mjolnir-comment.md is empty or missing — nothing to post.');
15732
+ return;
15733
+ }
15734
+ const marker = '<!-- mjolnir-pr-comment -->';
15735
+ const { data: comments } = await github.rest.issues.listComments({
15736
+ owner: context.repo.owner,
15737
+ repo: context.repo.repo,
15738
+ issue_number: context.issue.number,
15739
+ });
15740
+ const existing = comments.find((c) => c.body?.startsWith(marker));
15741
+ if (existing) {
15742
+ await github.rest.issues.updateComment({
15743
+ owner: context.repo.owner,
15744
+ repo: context.repo.repo,
15745
+ comment_id: existing.id,
15746
+ body,
15747
+ });
15748
+ } else {
15749
+ await github.rest.issues.createComment({
15750
+ owner: context.repo.owner,
15751
+ repo: context.repo.repo,
15752
+ issue_number: context.issue.number,
15753
+ body,
15754
+ });
15755
+ }
15756
+ ${gate === "advisory" ? ` # Advisory mode: findings are reported in the Job Summary and the
15757
+ # PR comment, never blocking. The scan step's exit code is visible
15758
+ # as the step outcome, but continue-on-error keeps the job green.
15759
+ - name: Gate (advisory)
15760
+ if: always()
15761
+ run: echo "Advisory mode — findings reported, never blocking."` : ` # Gate enforcement: the scan step's own exit code is deliberately
15762
+ # neutralized (continue-on-error) so reporting steps always run; THIS
15763
+ # step is what fails the job. A partial scan never blocks.
15764
+ - name: Gate (${gate})
15765
+ if: always()
15766
+ run: |
15767
+ node -e '
15768
+ ${indentBlock(gateScript(gate), 10)}
15769
+ '`}
15770
+ `;
15771
+ /** Every gate variant, used to tell "our template" from "hand-customized". */
15772
+ const GATES = [
15773
+ "advisory",
15774
+ "error",
15775
+ "warning"
15776
+ ];
15777
+ /** Multiset line diff — counts only, for the refusal message. */
15778
+ function summarizeContentDiff(existing, incoming) {
15779
+ const remaining = /* @__PURE__ */ new Map();
15780
+ for (const line of existing.split(/\r?\n/)) remaining.set(line, (remaining.get(line) ?? 0) + 1);
15781
+ let added = 0;
15782
+ for (const line of incoming.split(/\r?\n/)) {
15783
+ const count = remaining.get(line) ?? 0;
15784
+ if (count > 0) remaining.set(line, count - 1);
15785
+ else added += 1;
15786
+ }
15787
+ let removed = 0;
15788
+ for (const count of remaining.values()) removed += count;
15789
+ return [` - ${removed} line(s) of your file are not in the template and would be removed`, ` - ${added} template line(s) are not in your file and would be added`];
15790
+ }
15791
+ function ciInstall(root, gate = "advisory", options = {}) {
15792
+ const wfDir = join(root, ".github", "workflows");
15793
+ const target = join(wfDir, "mjolnir.yml");
15794
+ if (!existsSync(wfDir)) mkdirSync(wfDir, { recursive: true });
15795
+ const existed = existsSync(target);
15796
+ if (existed) {
15797
+ const current = readFileSync(target, "utf8");
15798
+ if (!isKnownTemplate(current) && !(options.force ?? false)) return {
15799
+ written: target,
15800
+ existed,
15801
+ refused: true,
15802
+ diffSummary: summarizeContentDiff(current, TEMPLATE(gate))
15803
+ };
15804
+ }
15805
+ writeFileSync(target, TEMPLATE(gate));
15806
+ return {
15807
+ written: target,
15808
+ existed,
15809
+ refused: false,
15810
+ diffSummary: []
15811
+ };
15812
+ }
15813
+ //#endregion
15814
+ //#region src/forensics/triage.ts
15815
+ const ui$10 = plainContext();
15816
+ const QUARANTINE_MIN_ATTEMPTS = 2;
15817
+ /** Deterministic triage table rows, worst first. */
15818
+ function triageRows(report) {
15819
+ return report.verdicts.filter((v) => v.everFailed || v.passedOnRetry).sort((a, b) => {
15820
+ if (a.passedOnRetry !== b.passedOnRetry) return a.passedOnRetry ? -1 : 1;
15821
+ if (a.attempts !== b.attempts) return b.attempts - a.attempts;
15822
+ return b.totalDurationMs - a.totalDurationMs;
15823
+ }).map((v) => ({
15824
+ file: v.file,
15825
+ title: v.title,
15826
+ attempts: v.attempts,
15827
+ passedOnRetry: v.passedOnRetry,
15828
+ finalStatus: v.finalStatus,
15829
+ totalDurationMs: v.totalDurationMs,
15830
+ suggestedAction: suggestAction(v),
15831
+ proposedQuarantine: v.attempts >= QUARANTINE_MIN_ATTEMPTS && v.everFailed
15832
+ }));
15833
+ }
15834
+ function suggestAction(v) {
15835
+ if (v.passedOnRetry && v.attempts >= QUARANTINE_MIN_ATTEMPTS) return "quarantine + ticket";
15836
+ if (v.passedOnRetry) return "fix nondeterminism";
15837
+ if (v.finalStatus === "failed" || v.finalStatus === "timedOut") return "fix now — failing";
15838
+ return "investigate";
15839
+ }
15840
+ /** Terminal rendering of the triage proposal. */
15841
+ function renderTriage(report) {
15842
+ const rows = triageRows(report);
15843
+ const lines = [];
15844
+ lines.push(sectionHeader("FLAKY TRIAGE — auto-generated, do not edit", ui$10));
15845
+ lines.push("");
15846
+ if (rows.length === 0) {
15847
+ lines.push("Nothing to triage — no failures or retries in this run.");
15848
+ return lines.join("\n");
15849
+ }
15850
+ const quarantineCount = rows.filter((r) => r.proposedQuarantine).length;
15851
+ for (const r of rows) {
15852
+ const flag = r.passedOnRetry ? "TRUE-FLAKE" : "FAILING";
15853
+ lines.push(`• [${flag}] ${r.title} (${r.file}) — ${r.attempts} attempt${r.attempts === 1 ? "" : "s"} → ${r.suggestedAction}`);
15854
+ }
15855
+ lines.push("");
15856
+ lines.push(`Auto-quarantine proposal: ${quarantineCount} test${quarantineCount === 1 ? "" : "s"} (retried ≥${QUARANTINE_MIN_ATTEMPTS} and failed at least once).`);
15857
+ lines.push("Quarantined tests should run nightly, not per-PR — quarantine is not deletion.");
15858
+ return lines.join("\n");
15859
+ }
15860
+ /** TRIAGE.md — the meeting artifact. */
15861
+ function renderTriageMd(report) {
15862
+ const rows = triageRows(report);
15863
+ const lines = [];
15864
+ lines.push("# TRIAGE.md");
15865
+ lines.push("");
15866
+ lines.push("> Auto-generated by `mjolnir triage` from real run data. Do not edit by hand.");
15867
+ lines.push("");
15868
+ if (rows.length === 0) {
15869
+ lines.push("_No flaky or failing tests this run — nothing to triage._ 🎉");
15870
+ return lines.join("\n");
15871
+ }
15872
+ lines.push("| Status | Test | File | Attempts | Duration | Suggested action | Quarantine? |");
15873
+ lines.push("|--------|------|------|----------|----------|------------------|-------------|");
15874
+ for (const r of rows) {
15875
+ const status = r.passedOnRetry ? `${FLAKE_GLYPH} TRUE-FLAKE` : "❌ FAILING";
15876
+ lines.push(`| ${status} | \`${r.title}\` | \`${r.file}\` | ${r.attempts} | ${(r.totalDurationMs / 1e3).toFixed(1)}s | ${r.suggestedAction} | ${r.proposedQuarantine ? "✅ propose" : "—"} |`);
15877
+ }
15878
+ const q = rows.filter((r) => r.proposedQuarantine).length;
15879
+ lines.push("");
15880
+ lines.push(`**Auto-quarantine proposal: ${q}** — move them to the nightly suite and open tracked tickets.`);
15881
+ lines.push("");
15882
+ return lines.join("\n");
15883
+ }
15884
+ //#endregion
15885
+ //#region src/commands/badge.ts
15886
+ /**
15887
+ * `mjolnir badge` — evidentiary shields.io endpoint JSON (Tier 1 #5).
15888
+ *
15889
+ * Static JSON, no server. The badge makes falsifiable claims:
15890
+ * score + date + commit. Anyone can click through and verify.
15891
+ */
15892
+ /**
15893
+ * Badge colors follow the SAME ScoreState bands as the terminal
15894
+ * (≥80 trusted / ≥50 warning / <50 critical / 100 forged) — this
15895
+ * retarget fixes the historical threshold drift (the badge used
15896
+ * ≥90/≥75/≥50 with four bands while the reporter used ≥80/≥50).
15897
+ *
15898
+ * Shields.io has no cyan or white-gold, so the mapping is documented
15899
+ * here: trusted → `important` (blue-family, closest to aurora-cyan),
15900
+ * forged → `success` (the strongest positive signal shields offers).
15901
+ * The badge is a peripheral surface; ScoreState remains the truth.
15902
+ */
15903
+ function colorFor(score) {
15904
+ const band = deriveScoreState(score).band;
15905
+ if (band === "unmeasured") return "lightgrey";
15906
+ if (band === "forged") return "success";
15907
+ if (band === "trusted") return "important";
15908
+ return band === "warning" ? "yellow" : "red";
15909
+ }
15910
+ /** Build the shields.io endpoint payload from a scan result. */
15911
+ function buildBadge(result, commit) {
15912
+ const errors = result.findings.filter((f) => f.severity === "error").length;
15913
+ const score = result.score;
15914
+ return {
15915
+ schemaVersion: 1,
15916
+ label: "MJÖLNIR",
15917
+ message: score === null ? "no tests found" : score === 100 && errors === 0 ? "100/100 · forged" : `${score}/100 · ${errors} error${errors === 1 ? "" : "s"}`,
15918
+ color: colorFor(score),
15919
+ namedLogo: "vitest",
15920
+ ...commit !== void 0 ? { commit } : {}
15921
+ };
15922
+ }
15923
+ /**
15924
+ * Full README-ready markdown snippet with commit-bound verification line
15925
+ * (the falsifiable claim: "verified at commit X on date Y").
15926
+ */
15927
+ function renderBadgeSnippet(result, repoUrl = "https://github.com/Sergey-Bar/Mjolnir") {
15928
+ let commit = "unknown";
15929
+ try {
15930
+ commit = execSync("git rev-parse --short HEAD", {
15931
+ cwd: process.cwd(),
15932
+ encoding: "utf8",
15933
+ stdio: [
15934
+ "ignore",
15935
+ "pipe",
15936
+ "ignore"
15937
+ ]
15938
+ }).trim();
15939
+ } catch {}
15940
+ const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
15941
+ const errors = result.findings.filter((f) => f.severity === "error").length;
15942
+ return [
15943
+ "```markdown",
15944
+ "[![MJÖLNIR](https://img.shields.io/endpoint?url=<your-badge-json-url>)](" + repoUrl + ")",
15945
+ "<!-- Mjölnir verified at commit " + commit + " on " + date + ": " + (result.score === null ? "no tests found" : result.score + "/100") + ", " + errors + " blocking error(s) -->",
15946
+ "```"
15109
15947
  ].join("\n");
15110
15948
  }
15111
15949
  function writeBadge(result, options) {
@@ -15236,1142 +16074,886 @@ const HELP_ENTRIES = [
15236
16074
  {
15237
16075
  verb: "rules",
15238
16076
  summary: "rule catalog with trust metadata (md/json/unmeasured)",
15239
- usage: "mjolnir rules [--md] [--unmeasured|--measured] [--external]",
15240
- examples: ["mjolnir rules --md --unmeasured"]
15241
- },
15242
- {
15243
- verb: "suppressions",
15244
- summary: "list suppressed findings (governance transparency)",
15245
- usage: "mjolnir suppressions",
15246
- examples: ["mjolnir suppressions"]
15247
- },
15248
- {
15249
- verb: "create-rule",
15250
- summary: "scaffold a new rule + fixtures (must-fire, must-not-fire)",
15251
- usage: "mjolnir create-rule <QA-XXX-nnn> --title \"Rule title\"",
15252
- examples: ["mjolnir create-rule QA-PW-131 --title \"No request waits\""]
15253
- },
15254
- {
15255
- verb: "doctor",
15256
- summary: "self-audit of the rule base (fixture firewall, tiers, caps)",
15257
- usage: "mjolnir doctor [repo-root]",
15258
- examples: ["mjolnir doctor"]
15259
- },
15260
- {
15261
- verb: "install",
15262
- summary: "install the agent instruction surfaces + optional staged hook",
15263
- usage: "mjolnir install [--staged-hook] [--dry-run] [--force]",
15264
- examples: ["mjolnir install --dry-run", "mjolnir install --staged-hook"]
15265
- }
15266
- ];
15267
- /** Scan-flag entries documented per-flag via the overview. */
15268
- const HELP_FLAGS = [
15269
- {
15270
- flag: "--json",
15271
- summary: "machine-readable output"
15272
- },
15273
- {
15274
- flag: "--format sarif",
15275
- summary: "SARIF 2.1 for GitHub Code Scanning"
15276
- },
15277
- {
15278
- flag: "--format mermaid",
15279
- summary: "test-architecture diagram"
15280
- },
15281
- {
15282
- flag: "--tone blunt",
15283
- summary: "blunter, pattern-mocking messages"
15284
- },
15285
- {
15286
- flag: "--verbose",
15287
- summary: "show all findings"
15288
- },
15289
- {
15290
- flag: "--scope changed",
15291
- summary: "only new/changed lines vs merge-base"
15292
- },
15293
- {
15294
- flag: "--max-duration <sec>",
15295
- summary: "analysis time budget"
15296
- },
15297
- {
15298
- flag: "--width <cols>",
15299
- summary: "override terminal width"
15300
- },
15301
- {
15302
- flag: "--ascii / --no-ascii",
15303
- summary: "force glyph mode"
15304
- },
15305
- {
15306
- flag: "--strict",
15307
- summary: "include quarantine-tier rules"
15308
- },
15309
- {
15310
- flag: "--debug",
15311
- summary: "print swallowed rule crashes"
15312
- },
15313
- {
15314
- flag: "--cache",
15315
- summary: "reuse local per-file verdicts"
15316
- },
15317
- {
15318
- flag: "--no-progress",
15319
- summary: "no live scan-progress line on stderr"
15320
- },
15321
- {
15322
- flag: "--score",
15323
- summary: "print only the numeric score (or `unknown`)"
15324
- },
15325
- {
15326
- flag: "--category <cat>",
15327
- summary: "presentation filter (repeatable)"
15328
- },
15329
- {
15330
- flag: "--staged",
15331
- summary: "scan only git staged files"
15332
- },
15333
- {
15334
- flag: "--blocking <level>",
15335
- summary: "exit-status override: error|warning|none"
15336
- },
15337
- {
15338
- flag: "--enable-plugins",
15339
- summary: "allow npm/JS-module rules (default OFF)"
15340
- }
15341
- ];
15342
- const EXIT_CODE_TABLE = [
15343
- ["0", "clean — no findings or the requested artifact was produced"],
15344
- ["1", "errors found (or the diff/impact verdict says the PR should not merge)"],
15345
- ["2", "partial — the scan ran but was truncated, or input was unreadable"],
15346
- ["10", "usage — bad flags or arguments; help is printed"],
15347
- ["20", "crash — internal error; rerun with --debug for the stack trace"]
15348
- ];
15349
- function findEntry(verb) {
15350
- return HELP_ENTRIES.find((e) => e.verb === verb);
15351
- }
15352
- /** True when `mjolnir help <verb>` has a detailed page. */
15353
- function hasVerbHelp(verb) {
15354
- return findEntry(verb) !== void 0;
15355
- }
15356
- /** One per-verb help page: summary, usage, examples, next step. */
15357
- function renderVerbHelp(verb) {
15358
- const e = findEntry(verb);
15359
- if (!e) return [
15360
- ` No detailed help for "${verb}".`,
15361
- "",
15362
- " $ mjolnir --help",
15363
- ""
15364
- ].join("\n");
15365
- const lines = [];
15366
- lines.push(` ${e.verb} — ${e.summary}`);
15367
- lines.push("");
15368
- lines.push(` Usage:`);
15369
- lines.push(` ${e.usage}`);
15370
- lines.push("");
15371
- lines.push(` Examples:`);
15372
- for (const ex of e.examples) lines.push(` $ ${ex}`);
15373
- if (e.next) {
15374
- lines.push("");
15375
- lines.push(` Next step:`);
15376
- lines.push(` $ ${e.next}`);
15377
- }
15378
- lines.push("");
15379
- return lines.join("\n");
15380
- }
15381
- const DOCS_URL = "https://github.com/Sergey-Bar/Mjolnir#readme";
15382
- /** The overview's grouped one-line sections, in display order. */
15383
- const GROUPS = [
16077
+ usage: "mjolnir rules [--md] [--unmeasured|--measured] [--external]",
16078
+ examples: ["mjolnir rules --md --unmeasured"]
16079
+ },
15384
16080
  {
15385
- title: "Scan",
15386
- verbs: []
16081
+ verb: "suppressions",
16082
+ summary: "list suppressed findings (governance transparency)",
16083
+ usage: "mjolnir suppressions",
16084
+ examples: ["mjolnir suppressions"]
15387
16085
  },
15388
16086
  {
15389
- title: "CI & PRs",
15390
- verbs: [
15391
- "ci install",
15392
- "summary",
15393
- "pr-comment",
15394
- "badge",
15395
- "impact",
15396
- "baseline",
15397
- "diff"
15398
- ]
16087
+ verb: "create-rule",
16088
+ summary: "scaffold a new rule + fixtures (must-fire, must-not-fire)",
16089
+ usage: "mjolnir create-rule <QA-XXX-nnn> --title \"Rule title\"",
16090
+ examples: ["mjolnir create-rule QA-PW-131 --title \"No request waits\""]
15399
16091
  },
15400
16092
  {
15401
- title: "Forensics",
15402
- verbs: [
15403
- "forensics",
15404
- "triage",
15405
- "pw-report",
15406
- "doctor:playwright"
15407
- ]
16093
+ verb: "doctor",
16094
+ summary: "self-audit of the rule base (fixture firewall, tiers, caps)",
16095
+ usage: "mjolnir doctor [repo-root]",
16096
+ examples: ["mjolnir doctor"]
15408
16097
  },
15409
16098
  {
15410
- title: "Maintenance",
15411
- verbs: [
15412
- "fix",
15413
- "debt",
15414
- "stats",
15415
- "suppressions",
15416
- "handover",
15417
- "init",
15418
- "doctor",
15419
- "create-rule"
15420
- ]
16099
+ verb: "install",
16100
+ summary: "install the agent instruction surfaces + optional staged hook",
16101
+ usage: "mjolnir install [--staged-hook] [--dry-run] [--force]",
16102
+ examples: ["mjolnir install --dry-run", "mjolnir install --staged-hook"]
15421
16103
  },
15422
16104
  {
15423
- title: "Meta",
15424
- verbs: [
15425
- "rules",
15426
- "explain",
15427
- "why",
15428
- "handoff",
15429
- "install"
15430
- ]
16105
+ verb: "mcp",
16106
+ summary: "run as an MCP server over stdio (scan / explain / diff tools)",
16107
+ usage: "mjolnir mcp",
16108
+ examples: ["mjolnir mcp"]
15431
16109
  }
15432
16110
  ];
15433
- const SCAN_SUMMARY_LINES = ["mjolnir [path] full-repo scan + WORTHINESS score"];
15434
- /**
15435
- * The redesigned root help (plan M2): grouped sections, one-line
15436
- * descriptions, copy-pasteable examples, the frozen exit-code table and
15437
- * the docs link. Content is identical whether colored or piped — the
15438
- * caller decides (runHelpCommand passes a resolved palette; printUsage
15439
- * stays plain).
15440
- */
15441
- function renderRootHelp(schemaVersion = 1) {
15442
- const byVerb = new Map(HELP_ENTRIES.map((e) => [e.verb, e]));
15443
- const lines = [];
15444
- lines.push("🔨 mjölnir — verification trust engine for test suites and CI pipelines");
15445
- lines.push("");
15446
- lines.push("Usage: mjolnir [path] [options] · mjolnir <subcommand> [args] · mjolnir help <verb>");
15447
- lines.push("");
15448
- lines.push("The product is one command in CI:");
15449
- lines.push("");
15450
- lines.push(" mjolnir --scope changed scan only what the branch touched; exit 1 on");
15451
- lines.push(" new findings. `mjolnir ci install` writes the");
15452
- lines.push(" workflow for you.");
15453
- lines.push("");
15454
- lines.push("Everything else is optional.");
15455
- lines.push("");
15456
- lines.push(" " + SCAN_SUMMARY_LINES[0]);
15457
- lines.push(" mjolnir explain <RULE-ID> what/why/fix + measured FP rate for one rule");
15458
- lines.push(" mjolnir rules --unmeasured the rules running on assumption, not measurement");
15459
- lines.push("");
15460
- lines.push("Options:");
15461
- for (const f of HELP_FLAGS) {
15462
- const pad = f.flag.padEnd(22);
15463
- lines.push(` ${pad}${f.summary}`);
15464
- }
15465
- lines.push(" -v, --version print the installed version and exit");
15466
- lines.push(" -h, --help show this help");
15467
- lines.push("");
15468
- for (const g of GROUPS) {
15469
- lines.push(`Subcommands — ${g.title}:`);
15470
- for (const verb of g.verbs) {
15471
- const e = byVerb.get(verb);
15472
- if (!e) continue;
15473
- const usage = e.usage.replace(/^mjolnir /, "").padEnd(46);
15474
- lines.push(` ${usage}${e.summary}`);
15475
- }
15476
- lines.push("");
15477
- }
15478
- lines.push("Copy-paste starts:");
15479
- lines.push(" $ mjolnir score this repo's test suite");
15480
- lines.push(" $ mjolnir --scope changed CI gate: only what the branch touched");
15481
- lines.push(" $ mjolnir ci install write the PR workflow");
15482
- lines.push(" $ mjolnir forensics test-results where the flakes hide");
15483
- lines.push("");
15484
- lines.push("Per-command help: mjolnir help <verb> (e.g. mjolnir help fix)");
15485
- lines.push("");
15486
- lines.push(`Exit codes: ${EXIT_CODE_TABLE.map(([c]) => c).join(" · ")}`);
15487
- for (const [code, meaning] of EXIT_CODE_TABLE) lines.push(` ${code.padEnd(3)} ${meaning}`);
15488
- lines.push("");
15489
- lines.push(`Docs: ${DOCS_URL} (JSON schemaVersion ${schemaVersion}, additive-only)`);
15490
- return lines.join("\n");
15491
- }
15492
- //#endregion
15493
- //#region src/commands/debt.ts
15494
- const ui$11 = plainContext();
15495
- /** Cost model (documented, conservative): hours/quarter per occurrence. */
15496
- const COST_MODEL = {
15497
- "QA-TEST-004": {
15498
- label: "Hard sleeps",
15499
- hoursPerOccurrence: .4
15500
- },
15501
- "QA-PY-005": {
15502
- label: "Hard sleeps",
15503
- hoursPerOccurrence: .4
15504
- },
15505
- "QA-PW-118": {
15506
- label: "Network-idle waits",
15507
- hoursPerOccurrence: .3
15508
- },
15509
- "QA-TEST-002": {
15510
- label: "Skipped tests",
15511
- hoursPerOccurrence: .2
15512
- },
15513
- "QA-PY-002": {
15514
- label: "Skipped tests",
15515
- hoursPerOccurrence: .2
15516
- },
15517
- "QA-TEST-003": {
15518
- label: "No-assertion tests",
15519
- hoursPerOccurrence: .25
15520
- },
15521
- "QA-PY-003": {
15522
- label: "No-assertion tests",
15523
- hoursPerOccurrence: .25
16111
+ /** Scan-flag entries documented per-flag via the overview. */
16112
+ const HELP_FLAGS = [
16113
+ {
16114
+ flag: "--json",
16115
+ summary: "machine-readable output"
15524
16116
  },
15525
- "QA-TEST-010": {
15526
- label: "Empty test bodies",
15527
- hoursPerOccurrence: .15
16117
+ {
16118
+ flag: "--format sarif",
16119
+ summary: "SARIF 2.1 for GitHub Code Scanning"
15528
16120
  },
15529
- "QA-PY-006": {
15530
- label: "Empty test bodies",
15531
- hoursPerOccurrence: .15
16121
+ {
16122
+ flag: "--format mermaid",
16123
+ summary: "test-architecture diagram"
15532
16124
  },
15533
- "QA-TQUAL-001": {
15534
- label: "Mock-only verification",
15535
- hoursPerOccurrence: .3
16125
+ {
16126
+ flag: "--tone blunt",
16127
+ summary: "blunter, pattern-mocking messages"
15536
16128
  },
15537
- "QA-PW-004": {
15538
- label: "Brittle selectors",
15539
- hoursPerOccurrence: .35
16129
+ {
16130
+ flag: "--verbose",
16131
+ summary: "show all findings"
15540
16132
  },
15541
- "QA-ENV-001": {
15542
- label: "Environment coupling",
15543
- hoursPerOccurrence: .5
15544
- }
15545
- };
15546
- function computeDebt(result) {
15547
- const byLabel = /* @__PURE__ */ new Map();
15548
- for (const f of result.findings) {
15549
- const entry = COST_MODEL[f.ruleId];
15550
- if (!entry) continue;
15551
- const cur = byLabel.get(entry.label) ?? {
15552
- count: 0,
15553
- hours: 0,
15554
- ruleIds: /* @__PURE__ */ new Set()
15555
- };
15556
- cur.count += 1;
15557
- cur.hours += entry.hoursPerOccurrence;
15558
- cur.ruleIds.add(f.ruleId);
15559
- byLabel.set(entry.label, cur);
15560
- }
15561
- const classes = [...byLabel.entries()].map(([label, v]) => ({
15562
- label,
15563
- count: v.count,
15564
- estHoursPerQuarter: Math.round(v.hours * 10) / 10,
15565
- ruleIds: [...v.ruleIds].sort((a, b) => a.localeCompare(b))
15566
- })).sort((a, b) => b.estHoursPerQuarter - a.estHoursPerQuarter);
15567
- return {
15568
- classes,
15569
- totalHours: Math.round(classes.reduce((s, c) => s + c.estHoursPerQuarter, 0) * 10) / 10
15570
- };
15571
- }
15572
- function renderDebt(result) {
15573
- const { classes, totalHours } = computeDebt(result);
15574
- const lines = [];
15575
- lines.push(sectionHeader("TEST DEBT REGISTER", ui$11));
15576
- lines.push("");
15577
- if (classes.length === 0) {
15578
- lines.push("No tracked debt classes found — the suite is clean.");
15579
- return lines.join("\n");
15580
- }
15581
- const rows = ["DEBT CLASS", ""];
15582
- rows[0] = `DEBT CLASS COUNT EST. HOURS/QUARTER`;
15583
- for (const c of classes) rows.push(`${c.label.padEnd(26)} ${String(c.count).padStart(5)} ${c.estHoursPerQuarter.toFixed(1).padStart(8)}`);
15584
- rows.push(`TOTAL ESTIMATED DRAG: ~${totalHours.toFixed(1)} engineer-hours/qtr`);
15585
- for (const row of panel(rows, ui$11)) lines.push(row);
15586
- lines.push("");
15587
- lines.push("Cost model is conservative and documented in src/commands/debt.ts.");
15588
- return lines.join("\n");
15589
- }
15590
- //#endregion
15591
- //#region src/commands/rule-families.ts
15592
- const RULE_FAMILIES = [
15593
16133
  {
15594
- token: "TEST",
15595
- dir: "test",
15596
- category: "QA-TEST",
15597
- appliesTo: "test-files"
16134
+ flag: "--scope changed",
16135
+ summary: "only new/changed lines vs merge-base"
15598
16136
  },
15599
16137
  {
15600
- token: "TQUAL",
15601
- dir: "quality",
15602
- category: "QA-TQUAL",
15603
- appliesTo: "test-files"
16138
+ flag: "--max-duration <sec>",
16139
+ summary: "analysis time budget"
15604
16140
  },
15605
16141
  {
15606
- token: "PW",
15607
- dir: "playwright",
15608
- category: "QA-PW",
15609
- appliesTo: "test-files"
16142
+ flag: "--width <cols>",
16143
+ summary: "override terminal width"
15610
16144
  },
15611
16145
  {
15612
- token: "CI",
15613
- dir: "ci",
15614
- category: "QA-CI",
15615
- appliesTo: "ci-workflows"
16146
+ flag: "--ascii / --no-ascii",
16147
+ summary: "force glyph mode"
15616
16148
  },
15617
16149
  {
15618
- token: "PY",
15619
- dir: "python",
15620
- category: "QA-PY",
15621
- appliesTo: "python"
16150
+ flag: "--strict",
16151
+ summary: "include quarantine-tier rules"
15622
16152
  },
15623
16153
  {
15624
- token: "ENV",
15625
- dir: "quality",
15626
- category: "QA-ENV",
15627
- appliesTo: "test-files"
16154
+ flag: "--debug",
16155
+ summary: "print swallowed rule crashes"
15628
16156
  },
15629
16157
  {
15630
- token: "JV",
15631
- dir: "java",
15632
- category: "QA-JV",
15633
- appliesTo: "java"
16158
+ flag: "--cache",
16159
+ summary: "reuse local per-file verdicts"
15634
16160
  },
15635
16161
  {
15636
- token: "CS",
15637
- dir: "csharp",
15638
- category: "QA-CS",
15639
- appliesTo: "csharp"
16162
+ flag: "--no-progress",
16163
+ summary: "no live scan-progress line on stderr"
15640
16164
  },
15641
16165
  {
15642
- token: "CYP",
15643
- dir: "cypress",
15644
- category: "QA-CYP",
15645
- appliesTo: "test-files"
16166
+ flag: "--score",
16167
+ summary: "print only the numeric score (or `unknown`)"
15646
16168
  },
15647
16169
  {
15648
- token: "SE",
15649
- dir: "selenium",
15650
- category: "QA-SE",
15651
- appliesTo: "test-files"
16170
+ flag: "--category <cat>",
16171
+ summary: "presentation filter (repeatable)"
15652
16172
  },
15653
16173
  {
15654
- token: "WDIO",
15655
- dir: "webdriverio",
15656
- category: "QA-WDIO",
15657
- appliesTo: "test-files"
16174
+ flag: "--staged",
16175
+ summary: "scan only git staged files"
15658
16176
  },
15659
16177
  {
15660
- token: "PPTR",
15661
- dir: "puppeteer",
15662
- category: "QA-PPTR",
15663
- appliesTo: "test-files"
16178
+ flag: "--blocking <level>",
16179
+ summary: "exit-status override: error|warning|none"
15664
16180
  },
15665
16181
  {
15666
- token: "APM",
15667
- dir: "apm",
15668
- category: "QA-APM",
15669
- appliesTo: "test-files"
16182
+ flag: "--enable-plugins",
16183
+ summary: "allow npm/JS-module rules (default OFF)"
15670
16184
  }
15671
16185
  ];
15672
- /** The full ID regex — derived from the table, so doctor and create-rule
15673
- * can never disagree about which families exist. */
15674
- const RULE_ID_RE = new RegExp(`^QA-(?:${RULE_FAMILIES.map((f) => f.token).join("|")})-\\d{3}$`);
15675
- function familyByToken(token) {
15676
- const upper = token.toUpperCase();
15677
- return RULE_FAMILIES.find((f) => f.token === upper);
15678
- }
15679
- //#endregion
15680
- //#region src/commands/create-rule.ts
15681
- /**
15682
- * `mjolnir create-rule <ID> --title "..."` — rule scaffold generator
15683
- * (Tier 6 #34, Contribution Surface Engineering).
15684
- *
15685
- * Generates the four files every rule MUST ship with (anti-creep law):
15686
- * src/rules/<family>/qa-<id-lower>.ts — the rule
15687
- * tests/fixtures/<ID>/must-fire/ — at least one must-fire fixture
15688
- * tests/fixtures/<ID>/must-not-fire/ — at least one must-not fixture
15689
- * and prints the exact registry edit to make.
15690
- *
15691
- * The generated rule intentionally FAILS its fixtures until the author
15692
- * implements it — you cannot ship a stub.
15693
- */
15694
- const ui$10 = plainContext();
15695
- function parseId(id) {
15696
- if (!RULE_ID_RE.exec(id)) return null;
15697
- const family = familyByToken(id.slice(3, id.length - 4));
15698
- return {
15699
- family: family.dir,
15700
- dir: family.dir,
15701
- category: family.category,
15702
- appliesTo: family.appliesTo,
15703
- num: id.slice(-3),
15704
- lower: id.toLowerCase()
15705
- };
15706
- }
15707
- function createRuleScaffold(input, rootDir) {
15708
- const parsed = parseId(input.id);
15709
- if (!parsed) return {
15710
- ok: false,
15711
- error: "Invalid rule ID. Expected QA-<FAMILY>-NNN with one of the registered families (TEST, TQUAL, PW, CI, PY, ENV, JV, CS, CYP, SE, WDIO, PPTR, APM).",
15712
- files: [],
15713
- registryEdit: ""
15714
- };
15715
- if (!input.title || input.title.trim().length === 0) return {
15716
- ok: false,
15717
- error: "A --title is required.",
15718
- files: [],
15719
- registryEdit: ""
15720
- };
15721
- const dirRel = join("src", "rules", parsed.dir);
15722
- const ruleRel = join(dirRel, `${parsed.lower}.ts`);
15723
- const absRule = join(rootDir, ruleRel);
15724
- if (existsSync(absRule)) return {
15725
- ok: false,
15726
- error: `Rule file already exists: ${ruleRel}`,
15727
- files: [],
15728
- registryEdit: ""
15729
- };
15730
- mkdirSync(join(rootDir, "src", "rules", parsed.dir), { recursive: true });
15731
- const files = [];
15732
- writeFileAtomic(absRule, `/**
15733
- * ${input.id} — ${input.title}.
15734
- *
15735
- * TODO(implement): replace the placeholder below. The rule currently
15736
- * returns no findings on purpose so the fixture harness FAILS until
15737
- * real detection logic lands (anti-creep law §18.1).
15738
- */
15739
-
15740
- import { defineRule } from "../rule.js";
15741
- import type { Finding } from "../../types.js";
15742
-
15743
- export const ${camel(parsed.lower)} = defineRule({
15744
- id: "${input.id}",
15745
- category: "${parsed.category}",
15746
- title: ${JSON.stringify(input.title)},
15747
- severity: "warning",
15748
- confidence: "medium",
15749
- findingType: "heuristic-risk",
15750
- qaImpact: "HYGIENE",
15751
- appliesTo: ${JSON.stringify(parsed.appliesTo)},
15752
- run(ctx) {
15753
- const findings: Omit<Finding, "ruleId" | "category">[] = [];
15754
- void ctx; // TODO: implement detection over ctx.path / ctx.text
15755
- return findings;
15756
- },
15757
- });
15758
- `);
15759
- files.push(ruleRel);
15760
- for (const direction of ["must-fire", "must-not-fire"]) {
15761
- const dirRel = join("tests", "fixtures", input.id, direction);
15762
- const absDir = join(rootDir, dirRel);
15763
- mkdirSync(absDir, { recursive: true });
15764
- const isPy = parsed.family === "python";
15765
- const name = isPy ? `example.${direction}.py` : `example.${direction}.ts`;
15766
- const content = isPy ? `# ${input.id} ${direction} fixture — replace with a real case.\n` : `// ${input.id} ${direction} fixture — replace with a real case.\n`;
15767
- writeFileAtomic(join(absDir, name), content);
15768
- files.push(join(dirRel, name));
15769
- }
15770
- const exportName = camel(parsed.lower);
15771
- return {
15772
- ok: true,
15773
- files,
15774
- registryEdit: [
15775
- `// 1. Add import to src/rules/index.ts:`,
15776
- `import { ${exportName} } from "./${parsed.dir}/${parsed.lower}.js";`,
15777
- `// 2. Add to the RULES array:`,
15778
- ` ${exportName},`
15779
- ].join("\n")
15780
- };
15781
- }
15782
- function camel(idLower) {
15783
- return idLower.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
15784
- }
15785
- function renderScaffoldReport(result) {
15786
- if (!result.ok) return `create-rule failed: ${result.error}`;
15787
- const lines = [];
15788
- lines.push(sectionHeader("RULE SCAFFOLD CREATED", ui$10));
15789
- lines.push("");
15790
- for (const f of result.files) lines.push(` + ${f}`);
15791
- lines.push("");
15792
- lines.push(result.registryEdit);
15793
- lines.push("");
15794
- lines.push("Next steps:", " 1. Implement detection logic in the rule's run()", " 2. Replace both fixture skeletons with real cases", " 3. Run: npm test — must-fire AND must-not-fire must pass", " 4. A rule whose fixtures fail CANNOT ship (anti-creep law)");
15795
- lines.push("");
15796
- lines.push("Note: running the test suite right now will show the new fixtures FAILING. This is intentional, not a bug in the scaffold — the rule above returns zero findings on purpose, so its must-fire fixture cannot pass until you implement real detection logic. A rule that ships as a silent no-op stub would violate the fixture-firewall law without anyone noticing.");
15797
- return lines.join("\n");
15798
- }
15799
- //#endregion
15800
- //#region src/commands/handover.ts
15801
- const ui$9 = plainContext();
15802
- const FAKE_GREEN_RULES = /* @__PURE__ */ new Set([
15803
- "QA-TEST-003",
15804
- "QA-PY-003",
15805
- "QA-TEST-010",
15806
- "QA-PY-006",
15807
- "QA-TQUAL-002",
15808
- "QA-PY-012"
15809
- ]);
15810
- const FLAKY_RULES = /* @__PURE__ */ new Set([
15811
- "QA-TEST-004",
15812
- "QA-PY-005",
15813
- "QA-PW-118",
15814
- "QA-PW-114",
15815
- "QA-TEST-006",
15816
- "QA-ENV-001"
15817
- ]);
15818
- const CI_TRUST_RULES = /* @__PURE__ */ new Set([
15819
- "QA-CI-001",
15820
- "QA-CI-002",
15821
- "QA-CI-005",
15822
- "QA-CI-007",
15823
- "QA-CI-008"
15824
- ]);
15825
- /** Group findings by directory to name "solid" areas. */
15826
- function dirOf(file) {
15827
- const idx = file.lastIndexOf("/");
15828
- return idx === -1 ? file : file.slice(0, idx);
16186
+ const EXIT_CODE_TABLE = [
16187
+ ["0", "clean — no findings or the requested artifact was produced"],
16188
+ ["1", "errors found (or the diff/impact verdict says the PR should not merge)"],
16189
+ ["2", "partial — the scan ran but was truncated, or input was unreadable"],
16190
+ ["10", "usage — bad flags or arguments; help is printed"],
16191
+ ["20", "crash — internal error; rerun with --debug for the stack trace"]
16192
+ ];
16193
+ function findEntry(verb) {
16194
+ return HELP_ENTRIES.find((e) => e.verb === verb);
15829
16195
  }
15830
- function buildHandover(scan, forensics) {
15831
- const fakeGreen = [];
15832
- const flaky = [];
15833
- const ciTrust = [];
15834
- const touchedDirs = /* @__PURE__ */ new Map();
15835
- for (const f of scan.findings) {
15836
- if (FAKE_GREEN_RULES.has(f.ruleId)) fakeGreen.push(f);
15837
- else if (FLAKY_RULES.has(f.ruleId)) flaky.push(f);
15838
- else if (CI_TRUST_RULES.has(f.ruleId)) ciTrust.push(f);
15839
- const d = dirOf(f.file);
15840
- touchedDirs.set(d, (touchedDirs.get(d) ?? 0) + 1);
15841
- }
15842
- const sections = [];
15843
- const flakyFromRuns = forensics?.verdicts.filter((v) => v.passedOnRetry || v.everFailed) ?? [];
15844
- const flakyItems = [];
15845
- for (const v of flakyFromRuns.slice(0, 5)) flakyItems.push(`${v.title} (${v.file}) — ${v.attempts} attempt${v.attempts === 1 ? "" : "s"}${v.passedOnRetry ? ", TRUE-FLAKE" : ""} → see FLAKY.md`);
15846
- for (const f of flaky.slice(0, 3)) flakyItems.push(`${f.file}:${f.line} — ${f.message}`);
15847
- if (flakyItems.length > 0) sections.push({
15848
- heading: "🔴 Known flaky / timing-sensitive",
15849
- items: flakyItems
15850
- });
15851
- if (fakeGreen.length > 0) sections.push({
15852
- heading: "🟡 Fake-green suspects (tests that can't fail)",
15853
- items: fakeGreen.slice(0, 5).map((f) => `${f.file}:${f.line} — ${f.message}`)
15854
- });
15855
- if (ciTrust.length > 0) sections.push({
15856
- heading: "⚠ CI trust warnings (green ≠ verified)",
15857
- items: ciTrust.slice(0, 4).map((f) => `${f.file}:${f.line} — ${f.message}`)
15858
- });
15859
- if (new Set(scan.findings.map((f) => f.file)).size === 0 && scan.score !== null && scan.score >= 90 || scan.findings.length === 0) sections.push({
15860
- heading: "🟢 Solid foundation",
15861
- items: ["No tracked anti-patterns found in scanned specs — good place to start contributing."]
15862
- });
15863
- const totalIssues = fakeGreen.length + flaky.length + ciTrust.length + (forensics?.flakyTests ?? 0);
15864
- return {
15865
- sections,
15866
- summaryLine: totalIssues === 0 ? "Welcome aboard — the suite is in good shape." : `${totalIssues} thing${totalIssues === 1 ? "" : "s"} to know about before your first release sign-off.`
15867
- };
16196
+ /** True when `mjolnir help <verb>` has a detailed page. */
16197
+ function hasVerbHelp(verb) {
16198
+ return findEntry(verb) !== void 0;
15868
16199
  }
15869
- function renderHandover(map) {
16200
+ /** One per-verb help page: summary, usage, examples, next step. */
16201
+ function renderVerbHelp(verb) {
16202
+ const e = findEntry(verb);
16203
+ if (!e) return [
16204
+ ` No detailed help for "${verb}".`,
16205
+ "",
16206
+ " $ mjolnir --help",
16207
+ ""
16208
+ ].join("\n");
15870
16209
  const lines = [];
15871
- lines.push(sectionHeader("WELCOME TO THE TEST SUITE — WHAT YOU NEED TO KNOW", ui$9));
16210
+ lines.push(` ${e.verb} — ${e.summary}`);
15872
16211
  lines.push("");
15873
- lines.push(map.summaryLine);
15874
- for (const s of map.sections) {
16212
+ lines.push(` Usage:`);
16213
+ lines.push(` ${e.usage}`);
16214
+ lines.push("");
16215
+ lines.push(` Examples:`);
16216
+ for (const ex of e.examples) lines.push(` $ ${ex}`);
16217
+ if (e.next) {
15875
16218
  lines.push("");
15876
- lines.push(s.heading);
15877
- for (const item of s.items) lines.push(` • ${item}`);
16219
+ lines.push(` Next step:`);
16220
+ lines.push(` $ ${e.next}`);
15878
16221
  }
15879
16222
  lines.push("");
15880
- lines.push("Generated by mjolnir handover — re-run after big refactors.");
15881
16223
  return lines.join("\n");
15882
16224
  }
15883
- //#endregion
15884
- //#region src/commands/impact.ts
16225
+ const DOCS_URL = "https://github.com/Sergey-Bar/Mjolnir#readme";
16226
+ /** The overview's grouped one-line sections, in display order. */
16227
+ const GROUPS = [
16228
+ {
16229
+ title: "Scan",
16230
+ verbs: []
16231
+ },
16232
+ {
16233
+ title: "CI & PRs",
16234
+ verbs: [
16235
+ "ci install",
16236
+ "summary",
16237
+ "pr-comment",
16238
+ "badge",
16239
+ "impact",
16240
+ "baseline",
16241
+ "diff"
16242
+ ]
16243
+ },
16244
+ {
16245
+ title: "Forensics",
16246
+ verbs: [
16247
+ "forensics",
16248
+ "triage",
16249
+ "pw-report",
16250
+ "doctor:playwright"
16251
+ ]
16252
+ },
16253
+ {
16254
+ title: "Maintenance",
16255
+ verbs: [
16256
+ "fix",
16257
+ "debt",
16258
+ "stats",
16259
+ "suppressions",
16260
+ "handover",
16261
+ "init",
16262
+ "doctor",
16263
+ "create-rule"
16264
+ ]
16265
+ },
16266
+ {
16267
+ title: "Meta",
16268
+ verbs: [
16269
+ "rules",
16270
+ "explain",
16271
+ "why",
16272
+ "handoff",
16273
+ "install",
16274
+ "mcp"
16275
+ ]
16276
+ }
16277
+ ];
16278
+ const SCAN_SUMMARY_LINES = ["mjolnir [path] full-repo scan + WORTHINESS score"];
15885
16279
  /**
15886
- * `mjolnir impact` — Sprint 6 Task 23 (Master-Stabilization-Plan.md).
15887
- *
15888
- * Answers "what would have burned you": compares the current scan against
15889
- * an earlier point in this repo's own git history and reports anti-patterns
15890
- * that were actually removed (evidence: findings present at the base ref,
15891
- * absent now, backed by a real file+message match) plus new debt introduced
15892
- * since then. That is the entire honesty-safe surface this command can
15893
- * stand behind with real evidence.
15894
- *
15895
- * HARD CONSTRAINT (Honesty Core, non-negotiable per the plan): every number
15896
- * here is evidence-backed from data actually present on disk, or the field
15897
- * is UNKNOWN. This command never estimates or extrapolates "hours saved" or
15898
- * "CI minutes saved" — inventing such a number would be worse than useless,
15899
- * it would be the exact kind of fake-precision this product exists to
15900
- * catch in *other* tools. Local-only, zero network (verified by
15901
- * tests/privacy-network-isolation.spec.ts, which scans this file too).
16280
+ * The redesigned root help (plan M2): grouped sections, one-line
16281
+ * descriptions, copy-pasteable examples, the frozen exit-code table and
16282
+ * the docs link. Content is identical whether colored or piped — the
16283
+ * caller decides (runHelpCommand passes a resolved palette; printUsage
16284
+ * stays plain).
15902
16285
  */
15903
- const ui$8 = plainContext();
15904
- /** The S1-resolved absolute git binary, or the bare name to fail on. */
15905
- function gitExe() {
15906
- return resolveGitPath() ?? "git";
15907
- }
15908
- function git(root, args) {
15909
- try {
15910
- return execFileSync(gitExe(), [
15911
- "-C",
15912
- root,
15913
- ...args
15914
- ], {
15915
- encoding: "utf8",
15916
- stdio: [
15917
- "ignore",
15918
- "pipe",
15919
- "ignore"
15920
- ],
15921
- timeout: 3e4
15922
- });
15923
- } catch {
15924
- return null;
15925
- }
15926
- }
15927
- /** Like git(), but returns the raw bytes — no utf8 decode round-trip. */
15928
- function gitBuffer(root, args) {
15929
- try {
15930
- return execFileSync(gitExe(), [
15931
- "-C",
15932
- root,
15933
- ...args
15934
- ], {
15935
- encoding: "buffer",
15936
- stdio: [
15937
- "ignore",
15938
- "pipe",
15939
- "ignore"
15940
- ],
15941
- timeout: 3e4
15942
- });
15943
- } catch {
15944
- return null;
15945
- }
15946
- }
15947
- /** Fingerprint a finding for cross-commit matching (line numbers shift). */
15948
- function fingerprint$2(f) {
15949
- return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
15950
- }
15951
- async function computeImpact(root, options) {
15952
- const unknownFacts = ["CI minutes or engineer-hours saved: not computed — this repo does not store historical CI run duration locally, and this command never estimates a number it cannot prove."];
15953
- if (!existsSync(join(root, ".git"))) return {
15954
- hasComparison: false,
15955
- unknownReason: "not-a-git-repo",
15956
- resolved: [],
15957
- introduced: [],
15958
- unknownFacts
15959
- };
15960
- let baseRef = options.since;
15961
- if (!baseRef) baseRef = git(root, ["rev-parse", "HEAD~1"])?.trim() ?? git(root, [
15962
- "merge-base",
15963
- "HEAD",
15964
- options.baseBranch ?? "main"
15965
- ])?.trim() ?? void 0;
15966
- else baseRef = git(root, ["rev-parse", baseRef])?.trim() ?? baseRef;
15967
- if (!baseRef) return {
15968
- hasComparison: false,
15969
- unknownReason: "no-prior-commit",
15970
- resolved: [],
15971
- introduced: [],
15972
- unknownFacts
15973
- };
15974
- const headRef = git(root, ["rev-parse", "HEAD"])?.trim() ?? "HEAD";
15975
- if (baseRef === headRef) return {
15976
- hasComparison: false,
15977
- unknownReason: options.since ? "base-equals-head" : "no-prior-commit",
15978
- baseRef,
15979
- headRef,
15980
- resolved: [],
15981
- introduced: [],
15982
- unknownFacts
15983
- };
15984
- let tmpDir;
15985
- try {
15986
- tmpDir = mkdtempSync(join(tmpdir(), "mjolnir-impact-"));
15987
- } catch {
15988
- return {
15989
- hasComparison: false,
15990
- unknownReason: "tree-materialize-failed",
15991
- baseRef,
15992
- headRef,
15993
- resolved: [],
15994
- introduced: [],
15995
- unknownFacts
15996
- };
16286
+ function renderRootHelp(schemaVersion = 1) {
16287
+ const byVerb = new Map(HELP_ENTRIES.map((e) => [e.verb, e]));
16288
+ const lines = [];
16289
+ lines.push("🔨 mjölnir — verification trust engine for test suites and CI pipelines");
16290
+ lines.push("");
16291
+ lines.push("Usage: mjolnir [path] [options] · mjolnir <subcommand> [args] · mjolnir help <verb>");
16292
+ lines.push("");
16293
+ lines.push("The product is one command in CI:");
16294
+ lines.push("");
16295
+ lines.push(" mjolnir --scope changed scan only what the branch touched; exit 1 on");
16296
+ lines.push(" new findings. `mjolnir ci install` writes the");
16297
+ lines.push(" workflow for you.");
16298
+ lines.push("");
16299
+ lines.push("Everything else is optional.");
16300
+ lines.push("");
16301
+ lines.push(" " + SCAN_SUMMARY_LINES[0]);
16302
+ lines.push(" mjolnir explain <RULE-ID> what/why/fix + measured FP rate for one rule");
16303
+ lines.push(" mjolnir rules --unmeasured the rules running on assumption, not measurement");
16304
+ lines.push("");
16305
+ lines.push("Options:");
16306
+ for (const f of HELP_FLAGS) {
16307
+ const pad = f.flag.padEnd(22);
16308
+ lines.push(` ${pad}${f.summary}`);
15997
16309
  }
15998
- let baseResult;
15999
- let baseTreeTruncated;
16000
- try {
16001
- const treeListing = git(root, [
16002
- "ls-tree",
16003
- "-r",
16004
- "--name-only",
16005
- "-z",
16006
- baseRef
16007
- ]);
16008
- if (treeListing === null) return {
16009
- hasComparison: false,
16010
- unknownReason: "tree-listing-failed",
16011
- baseRef,
16012
- headRef,
16013
- resolved: [],
16014
- introduced: [],
16015
- unknownFacts
16016
- };
16017
- const paths = treeListing.split("\0").filter(Boolean);
16018
- const MAX_FILES = 2e4;
16019
- const truncated = paths.length > MAX_FILES;
16020
- for (const relPath of paths.slice(0, MAX_FILES)) {
16021
- const blob = gitBuffer(root, ["show", `${baseRef}:${relPath}`]);
16022
- if (blob === null) continue;
16023
- const dest = join(tmpDir, relPath);
16024
- mkdirSync(dirname(dest), { recursive: true });
16025
- writeFileSync(dest, blob);
16026
- }
16027
- if (truncated) {
16028
- baseTreeTruncated = {
16029
- scanned: Math.min(paths.length, MAX_FILES),
16030
- total: paths.length
16031
- };
16032
- unknownFacts.push(`Base tree materialization truncated at ${MAX_FILES} of ${paths.length} paths — files beyond the cap were never scanned at base, so findings that exist only there are misreported here as new debt.`);
16310
+ lines.push(" -v, --version print the installed version and exit");
16311
+ lines.push(" -h, --help show this help");
16312
+ lines.push("");
16313
+ for (const g of GROUPS) {
16314
+ lines.push(`Subcommands — ${g.title}:`);
16315
+ for (const verb of g.verbs) {
16316
+ const e = byVerb.get(verb);
16317
+ if (!e) continue;
16318
+ const usage = e.usage.replace(/^mjolnir /, "").padEnd(46);
16319
+ lines.push(` ${usage}${e.summary}`);
16033
16320
  }
16034
- baseResult = await options.runScan(tmpDir);
16035
- } catch {
16036
- return {
16037
- hasComparison: false,
16038
- unknownReason: "tree-materialize-failed",
16039
- baseRef,
16040
- headRef,
16041
- resolved: [],
16042
- introduced: [],
16043
- unknownFacts
16321
+ lines.push("");
16322
+ }
16323
+ lines.push("Copy-paste starts:");
16324
+ lines.push(" $ mjolnir score this repo's test suite");
16325
+ lines.push(" $ mjolnir --scope changed CI gate: only what the branch touched");
16326
+ lines.push(" $ mjolnir ci install write the PR workflow");
16327
+ lines.push(" $ mjolnir forensics test-results where the flakes hide");
16328
+ lines.push("");
16329
+ lines.push("Per-command help: mjolnir help <verb> (e.g. mjolnir help fix)");
16330
+ lines.push("");
16331
+ lines.push(`Exit codes: ${EXIT_CODE_TABLE.map(([c]) => c).join(" · ")}`);
16332
+ for (const [code, meaning] of EXIT_CODE_TABLE) lines.push(` ${code.padEnd(3)} ${meaning}`);
16333
+ lines.push("");
16334
+ lines.push(`Docs: ${DOCS_URL} (JSON schemaVersion ${schemaVersion}, additive-only)`);
16335
+ return lines.join("\n");
16336
+ }
16337
+ //#endregion
16338
+ //#region src/commands/debt.ts
16339
+ const ui$9 = plainContext();
16340
+ /** Cost model (documented, conservative): hours/quarter per occurrence. */
16341
+ const COST_MODEL = {
16342
+ "QA-TEST-004": {
16343
+ label: "Hard sleeps",
16344
+ hoursPerOccurrence: .4
16345
+ },
16346
+ "QA-PY-005": {
16347
+ label: "Hard sleeps",
16348
+ hoursPerOccurrence: .4
16349
+ },
16350
+ "QA-PW-118": {
16351
+ label: "Network-idle waits",
16352
+ hoursPerOccurrence: .3
16353
+ },
16354
+ "QA-TEST-002": {
16355
+ label: "Skipped tests",
16356
+ hoursPerOccurrence: .2
16357
+ },
16358
+ "QA-PY-002": {
16359
+ label: "Skipped tests",
16360
+ hoursPerOccurrence: .2
16361
+ },
16362
+ "QA-TEST-003": {
16363
+ label: "No-assertion tests",
16364
+ hoursPerOccurrence: .25
16365
+ },
16366
+ "QA-PY-003": {
16367
+ label: "No-assertion tests",
16368
+ hoursPerOccurrence: .25
16369
+ },
16370
+ "QA-TEST-010": {
16371
+ label: "Empty test bodies",
16372
+ hoursPerOccurrence: .15
16373
+ },
16374
+ "QA-PY-006": {
16375
+ label: "Empty test bodies",
16376
+ hoursPerOccurrence: .15
16377
+ },
16378
+ "QA-TQUAL-001": {
16379
+ label: "Mock-only verification",
16380
+ hoursPerOccurrence: .3
16381
+ },
16382
+ "QA-PW-004": {
16383
+ label: "Brittle selectors",
16384
+ hoursPerOccurrence: .35
16385
+ },
16386
+ "QA-ENV-001": {
16387
+ label: "Environment coupling",
16388
+ hoursPerOccurrence: .5
16389
+ }
16390
+ };
16391
+ function computeDebt(result) {
16392
+ const byLabel = /* @__PURE__ */ new Map();
16393
+ for (const f of result.findings) {
16394
+ const entry = COST_MODEL[f.ruleId];
16395
+ if (!entry) continue;
16396
+ const cur = byLabel.get(entry.label) ?? {
16397
+ count: 0,
16398
+ hours: 0,
16399
+ ruleIds: /* @__PURE__ */ new Set()
16044
16400
  };
16045
- } finally {
16046
- try {
16047
- rmSync(tmpDir, {
16048
- recursive: true,
16049
- force: true
16050
- });
16051
- } catch {}
16401
+ cur.count += 1;
16402
+ cur.hours += entry.hoursPerOccurrence;
16403
+ cur.ruleIds.add(f.ruleId);
16404
+ byLabel.set(entry.label, cur);
16052
16405
  }
16053
- const headResult = await options.runScan(root);
16054
- const baseSet = /* @__PURE__ */ new Map();
16055
- for (const f of baseResult.findings) baseSet.set(fingerprint$2(f), f);
16056
- const headSet = /* @__PURE__ */ new Map();
16057
- for (const f of headResult.findings) headSet.set(fingerprint$2(f), f);
16058
- const resolved = [];
16059
- for (const [key, f] of baseSet) if (!headSet.has(key)) resolved.push({
16060
- ruleId: f.ruleId,
16061
- file: f.file,
16062
- message: f.message
16063
- });
16064
- const introduced = [];
16065
- for (const [key, f] of headSet) if (!baseSet.has(key)) introduced.push({
16066
- ruleId: f.ruleId,
16067
- file: f.file,
16068
- message: f.message
16069
- });
16406
+ const classes = [...byLabel.entries()].map(([label, v]) => ({
16407
+ label,
16408
+ count: v.count,
16409
+ estHoursPerQuarter: Math.round(v.hours * 10) / 10,
16410
+ ruleIds: [...v.ruleIds].sort((a, b) => a.localeCompare(b))
16411
+ })).sort((a, b) => b.estHoursPerQuarter - a.estHoursPerQuarter);
16070
16412
  return {
16071
- hasComparison: true,
16072
- baseRef,
16073
- headRef,
16074
- ...baseTreeTruncated ? { baseTreeTruncated } : {},
16075
- resolved: resolved.sort((a, b) => a.file.localeCompare(b.file)),
16076
- introduced: introduced.sort((a, b) => a.file.localeCompare(b.file)),
16077
- unknownFacts
16413
+ classes,
16414
+ totalHours: Math.round(classes.reduce((s, c) => s + c.estHoursPerQuarter, 0) * 10) / 10
16078
16415
  };
16079
16416
  }
16080
- function renderImpact(report) {
16417
+ function renderDebt(result) {
16418
+ const { classes, totalHours } = computeDebt(result);
16081
16419
  const lines = [];
16082
- lines.push(sectionHeader("IMPACT REPORT", ui$8));
16420
+ lines.push(sectionHeader("TEST DEBT REGISTER", ui$9));
16083
16421
  lines.push("");
16084
- if (!report.hasComparison) {
16085
- lines.push(`UNKNOWN — no comparison could be made (${report.unknownReason ?? "unknown reason"}).`);
16086
- lines.push("This is reported as UNKNOWN rather than a fabricated zero: mjolnir");
16087
- lines.push("never invents a number it cannot prove.");
16088
- lines.push("");
16089
- for (const fact of report.unknownFacts) lines.push(`UNKNOWN: ${fact}`);
16422
+ if (classes.length === 0) {
16423
+ lines.push("No tracked debt classes found — the suite is clean.");
16090
16424
  return lines.join("\n");
16091
16425
  }
16092
- lines.push(`Comparing ${report.baseRef?.slice(0, 12)} → ${report.headRef?.slice(0, 12)}`);
16093
- lines.push("");
16094
- if (report.baseTreeTruncated) {
16095
- lines.push(`UNKNOWN: base tree truncated — scanned ${report.baseTreeTruncated.scanned} of ${report.baseTreeTruncated.total} paths.`);
16096
- lines.push("Files beyond the cap were never scanned at base; their findings are misreported as new debt.");
16097
- lines.push("");
16098
- }
16099
- if (report.resolved.length === 0) lines.push("FIXED SINCE BASE: none found.");
16100
- else {
16101
- lines.push(`FIXED SINCE BASE (${report.resolved.length}) — real, evidence-backed:`);
16102
- for (const f of report.resolved) lines.push(` ✓ ${f.ruleId} · ${f.file} — ${f.message}`);
16103
- }
16104
- lines.push("");
16105
- if (report.introduced.length === 0) lines.push("NEW DEBT SINCE BASE: none found.");
16106
- else {
16107
- lines.push(`NEW DEBT SINCE BASE (${report.introduced.length}):`);
16108
- for (const f of report.introduced) lines.push(` + ${f.ruleId} · ${f.file} — ${f.message}`);
16109
- }
16426
+ const rows = ["DEBT CLASS", ""];
16427
+ rows[0] = `DEBT CLASS COUNT EST. HOURS/QUARTER`;
16428
+ for (const c of classes) rows.push(`${c.label.padEnd(26)} ${String(c.count).padStart(5)} ${c.estHoursPerQuarter.toFixed(1).padStart(8)}`);
16429
+ rows.push(`TOTAL ESTIMATED DRAG: ~${totalHours.toFixed(1)} engineer-hours/qtr`);
16430
+ for (const row of panel(rows, ui$9)) lines.push(row);
16110
16431
  lines.push("");
16111
- for (const fact of report.unknownFacts) lines.push(`UNKNOWN: ${fact}`);
16432
+ lines.push("Cost model is conservative and documented in src/commands/debt.ts.");
16112
16433
  return lines.join("\n");
16113
16434
  }
16114
16435
  //#endregion
16115
- //#region src/engine/resolution.ts
16116
- /** Correlation identity (v1-compatible): ruleId\0file\0message. */
16117
- function fingerprint$1(entry) {
16118
- return `${entry.ruleId}\u0000${entry.file}\u0000${entry.message}`;
16119
- }
16436
+ //#region src/commands/rule-families.ts
16437
+ const RULE_FAMILIES = [
16438
+ {
16439
+ token: "TEST",
16440
+ dir: "test",
16441
+ category: "QA-TEST",
16442
+ appliesTo: "test-files"
16443
+ },
16444
+ {
16445
+ token: "TQUAL",
16446
+ dir: "quality",
16447
+ category: "QA-TQUAL",
16448
+ appliesTo: "test-files"
16449
+ },
16450
+ {
16451
+ token: "PW",
16452
+ dir: "playwright",
16453
+ category: "QA-PW",
16454
+ appliesTo: "test-files"
16455
+ },
16456
+ {
16457
+ token: "CI",
16458
+ dir: "ci",
16459
+ category: "QA-CI",
16460
+ appliesTo: "ci-workflows"
16461
+ },
16462
+ {
16463
+ token: "PY",
16464
+ dir: "python",
16465
+ category: "QA-PY",
16466
+ appliesTo: "python"
16467
+ },
16468
+ {
16469
+ token: "ENV",
16470
+ dir: "quality",
16471
+ category: "QA-ENV",
16472
+ appliesTo: "test-files"
16473
+ },
16474
+ {
16475
+ token: "JV",
16476
+ dir: "java",
16477
+ category: "QA-JV",
16478
+ appliesTo: "java"
16479
+ },
16480
+ {
16481
+ token: "CS",
16482
+ dir: "csharp",
16483
+ category: "QA-CS",
16484
+ appliesTo: "csharp"
16485
+ },
16486
+ {
16487
+ token: "CYP",
16488
+ dir: "cypress",
16489
+ category: "QA-CYP",
16490
+ appliesTo: "test-files"
16491
+ },
16492
+ {
16493
+ token: "SE",
16494
+ dir: "selenium",
16495
+ category: "QA-SE",
16496
+ appliesTo: "test-files"
16497
+ },
16498
+ {
16499
+ token: "WDIO",
16500
+ dir: "webdriverio",
16501
+ category: "QA-WDIO",
16502
+ appliesTo: "test-files"
16503
+ },
16504
+ {
16505
+ token: "PPTR",
16506
+ dir: "puppeteer",
16507
+ category: "QA-PPTR",
16508
+ appliesTo: "test-files"
16509
+ },
16510
+ {
16511
+ token: "APM",
16512
+ dir: "apm",
16513
+ category: "QA-APM",
16514
+ appliesTo: "test-files"
16515
+ }
16516
+ ];
16517
+ /** The full ID regex — derived from the table, so doctor and create-rule
16518
+ * can never disagree about which families exist. */
16519
+ const RULE_ID_RE = new RegExp(`^QA-(?:${RULE_FAMILIES.map((f) => f.token).join("|")})-\\d{3}$`);
16520
+ function familyByToken(token) {
16521
+ const upper = token.toUpperCase();
16522
+ return RULE_FAMILIES.find((f) => f.token === upper);
16523
+ }
16524
+ //#endregion
16525
+ //#region src/commands/create-rule.ts
16120
16526
  /**
16121
- * The §15 ordered algorithm. Pure; first match wins.
16527
+ * `mjolnir create-rule <ID> --title "..."` — rule scaffold generator
16528
+ * (Tier 6 #34, Contribution Surface Engineering).
16529
+ *
16530
+ * Generates the four files every rule MUST ship with (anti-creep law):
16531
+ * src/rules/<family>/qa-<id-lower>.ts — the rule
16532
+ * tests/fixtures/<ID>/must-fire/ — at least one must-fire fixture
16533
+ * tests/fixtures/<ID>/must-not-fire/ — at least one must-not fixture
16534
+ * and prints the exact registry edit to make.
16535
+ *
16536
+ * The generated rule intentionally FAILS its fixtures until the author
16537
+ * implements it — you cannot ship a stub.
16122
16538
  */
16123
- function resolve$1(input) {
16124
- const { entry, baseline, current } = input;
16125
- const fp = fingerprint$1(entry);
16126
- const comparedAgainst = input.baselineCommit ?? baseline.commit ?? "unknown baseline";
16127
- if (current.partial || current.analysisStatus.rules !== "complete") return {
16128
- status: "INCONCLUSIVE",
16129
- cause: "partial",
16130
- comparedAgainst
16131
- };
16132
- if (input.crashedRuleIds?.has(entry.ruleId)) return {
16133
- status: "INCONCLUSIVE",
16134
- cause: "crash",
16135
- comparedAgainst
16136
- };
16137
- if (input.skippedFiles?.has(entry.file)) return {
16138
- status: "INCONCLUSIVE",
16139
- cause: "skipped",
16140
- comparedAgainst
16141
- };
16142
- if (input.suppressed?.has(`${entry.ruleId}\u0000${entry.file}`)) return {
16143
- status: "SUPPRESSED",
16144
- comparedAgainst
16145
- };
16146
- if (input.excludedFiles?.has(entry.file)) return {
16147
- status: "DISAPPEARED-NON-FIX",
16148
- cause: "excluded",
16149
- comparedAgainst
16150
- };
16151
- const entryRev = entry.detectorRevision;
16152
- if (entryRev === void 0) return {
16153
- status: "INCONCLUSIVE",
16154
- cause: "legacy-baseline",
16155
- comparedAgainst
16539
+ const ui$8 = plainContext();
16540
+ function parseId(id) {
16541
+ if (!RULE_ID_RE.exec(id)) return null;
16542
+ const family = familyByToken(id.slice(3, id.length - 4));
16543
+ return {
16544
+ family: family.dir,
16545
+ dir: family.dir,
16546
+ category: family.category,
16547
+ appliesTo: family.appliesTo,
16548
+ num: id.slice(-3),
16549
+ lower: id.toLowerCase()
16156
16550
  };
16157
- const registryRev = input.registryRevisions.get(entry.ruleId);
16158
- if (registryRev === void 0) return {
16159
- status: "DISAPPEARED-NON-FIX",
16160
- cause: "retired",
16161
- comparedAgainst
16551
+ }
16552
+ function createRuleScaffold(input, rootDir) {
16553
+ const parsed = parseId(input.id);
16554
+ if (!parsed) return {
16555
+ ok: false,
16556
+ error: "Invalid rule ID. Expected QA-<FAMILY>-NNN with one of the registered families (TEST, TQUAL, PW, CI, PY, ENV, JV, CS, CYP, SE, WDIO, PPTR, APM).",
16557
+ files: [],
16558
+ registryEdit: ""
16162
16559
  };
16163
- if (registryRev !== entryRev) return {
16164
- status: "INCONCLUSIVE",
16165
- cause: "revision-changed",
16166
- comparedAgainst
16560
+ if (!input.title || input.title.trim().length === 0) return {
16561
+ ok: false,
16562
+ error: "A --title is required.",
16563
+ files: [],
16564
+ registryEdit: ""
16167
16565
  };
16168
- if (current.findings.some((f) => fingerprint$1(f) === fp)) return {
16169
- status: "STILL-PRESENT",
16170
- comparedAgainst
16566
+ const dirRel = join("src", "rules", parsed.dir);
16567
+ const ruleRel = join(dirRel, `${parsed.lower}.ts`);
16568
+ const absRule = join(rootDir, ruleRel);
16569
+ if (existsSync(absRule)) return {
16570
+ ok: false,
16571
+ error: `Rule file already exists: ${ruleRel}`,
16572
+ files: [],
16573
+ registryEdit: ""
16171
16574
  };
16575
+ mkdirSync(join(rootDir, "src", "rules", parsed.dir), { recursive: true });
16576
+ const files = [];
16577
+ writeFileAtomic(absRule, `/**
16578
+ * ${input.id} — ${input.title}.
16579
+ *
16580
+ * TODO(implement): replace the placeholder below. The rule currently
16581
+ * returns no findings on purpose so the fixture harness FAILS until
16582
+ * real detection logic lands (anti-creep law §18.1).
16583
+ */
16584
+
16585
+ import { defineRule } from "../rule.js";
16586
+ import type { Finding } from "../../types.js";
16587
+
16588
+ export const ${camel(parsed.lower)} = defineRule({
16589
+ id: "${input.id}",
16590
+ category: "${parsed.category}",
16591
+ title: ${JSON.stringify(input.title)},
16592
+ severity: "warning",
16593
+ confidence: "medium",
16594
+ findingType: "heuristic-risk",
16595
+ qaImpact: "HYGIENE",
16596
+ appliesTo: ${JSON.stringify(parsed.appliesTo)},
16597
+ run(ctx) {
16598
+ const findings: Omit<Finding, "ruleId" | "category">[] = [];
16599
+ void ctx; // TODO: implement detection over ctx.path / ctx.text
16600
+ return findings;
16601
+ },
16602
+ });
16603
+ `);
16604
+ files.push(ruleRel);
16605
+ for (const direction of ["must-fire", "must-not-fire"]) {
16606
+ const dirRel = join("tests", "fixtures", input.id, direction);
16607
+ const absDir = join(rootDir, dirRel);
16608
+ mkdirSync(absDir, { recursive: true });
16609
+ const isPy = parsed.family === "python";
16610
+ const name = isPy ? `example.${direction}.py` : `example.${direction}.ts`;
16611
+ const content = isPy ? `# ${input.id} ${direction} fixture — replace with a real case.\n` : `// ${input.id} ${direction} fixture — replace with a real case.\n`;
16612
+ writeFileAtomic(join(absDir, name), content);
16613
+ files.push(join(dirRel, name));
16614
+ }
16615
+ const exportName = camel(parsed.lower);
16172
16616
  return {
16173
- status: "VERIFIED-RESOLVED",
16174
- comparedAgainst
16617
+ ok: true,
16618
+ files,
16619
+ registryEdit: [
16620
+ `// 1. Add import to src/rules/index.ts:`,
16621
+ `import { ${exportName} } from "./${parsed.dir}/${parsed.lower}.js";`,
16622
+ `// 2. Add to the RULES array:`,
16623
+ ` ${exportName},`
16624
+ ].join("\n")
16175
16625
  };
16176
16626
  }
16177
- /** The §15 rendering law: "FIXED" only for VERIFIED-RESOLVED. */
16178
- function renderResolution(r) {
16179
- switch (r.status) {
16180
- case "VERIFIED-RESOLVED": return "FIXED SINCE BASELINE (verified by a complete same-revision scan)";
16181
- case "STILL-PRESENT": return "STILL PRESENT";
16182
- case "SUPPRESSED": return "SUPPRESSED (active ignore entry)";
16183
- case "INCONCLUSIVE": return `INCONCLUSIVE (${r.cause})`;
16184
- case "DISAPPEARED-NON-FIX": return `DISAPPEARED — NOT A FIX (${r.cause})`;
16185
- }
16627
+ function camel(idLower) {
16628
+ return idLower.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
16629
+ }
16630
+ function renderScaffoldReport(result) {
16631
+ if (!result.ok) return `create-rule failed: ${result.error}`;
16632
+ const lines = [];
16633
+ lines.push(sectionHeader("RULE SCAFFOLD CREATED", ui$8));
16634
+ lines.push("");
16635
+ for (const f of result.files) lines.push(` + ${f}`);
16636
+ lines.push("");
16637
+ lines.push(result.registryEdit);
16638
+ lines.push("");
16639
+ lines.push("Next steps:", " 1. Implement detection logic in the rule's run()", " 2. Replace both fixture skeletons with real cases", " 3. Run: npm test — must-fire AND must-not-fire must pass", " 4. A rule whose fixtures fail CANNOT ship (anti-creep law)");
16640
+ lines.push("");
16641
+ lines.push("Note: running the test suite right now will show the new fixtures FAILING. This is intentional, not a bug in the scaffold — the rule above returns zero findings on purpose, so its must-fire fixture cannot pass until you implement real detection logic. A rule that ships as a silent no-op stub would violate the fixture-firewall law without anyone noticing.");
16642
+ return lines.join("\n");
16186
16643
  }
16187
16644
  //#endregion
16188
- //#region src/commands/baseline.ts
16189
- /**
16190
- * `mjolnir baseline` / `mjolnir diff` — Sprint 6 Task 24
16191
- * (Master-Stabilization-Plan.md).
16192
- *
16193
- * Implements Plan.md Phase 10 / §24's key insight: existing debt should
16194
- * not block every PR — only NEW or WORSENED debt should. `baseline`
16195
- * snapshots the current finding set to disk; `diff` compares the current
16196
- * scan against that snapshot and reports only what changed.
16197
- *
16198
- * Findings are matched across scans by a stable fingerprint (ruleId +
16199
- * file + message), not by line number — line numbers shift constantly as
16200
- * a file is edited, so matching on them would report unrelated churn as
16201
- * "new" debt and miss genuinely new findings that happen to land on a
16202
- * previously-flagged line.
16203
- *
16204
- * Storage: .mjolnir/baseline.json (local; not gitignored — only
16205
- * .mjolnir/logs/ is, see .gitignore). A team CAN commit this
16206
- * file if they want a shared baseline; that's a deliberate choice this
16207
- * command does not make for them.
16208
- */
16645
+ //#region src/commands/handover.ts
16209
16646
  const ui$7 = plainContext();
16210
- const DEFAULT_BASELINE_PATH = join(".mjolnir", "baseline.json");
16211
- /**
16212
- * Registry-declared detector revisions (§17): a baseline entry whose
16213
- * revision differs from today's registry is INCONCLUSIVE(revision-
16214
- * changed), never resolved. Omitted declarations mean revision 1 (the
16215
- * documented RuleMeta default for first-generation detectors); a
16216
- * ruleId ABSENT from this map means the rule is retired.
16217
- */
16218
- const REGISTRY_REVISIONS = new Map(RULES.map((r) => [r.id, r.detectorRevision ?? 1]));
16219
- /**
16220
- * Correlation identity for before/after comparison (agent-handoff plan
16221
- * §5.2): ruleId + file + message, deliberately EXCLUDING `line` — a
16222
- * source edit that shifts a finding still correlates. file:line is an
16223
- * occurrence location, not a durable identity; message rewording,
16224
- * file renames and rule-id changes correlate as resolved+new
16225
- * (documented limitation). Exported for the handoff verification
16226
- * contract — do not duplicate this algorithm.
16227
- */
16228
- function fingerprint(f) {
16229
- return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
16647
+ const FAKE_GREEN_RULES = /* @__PURE__ */ new Set([
16648
+ "QA-TEST-003",
16649
+ "QA-PY-003",
16650
+ "QA-TEST-010",
16651
+ "QA-PY-006",
16652
+ "QA-TQUAL-002",
16653
+ "QA-PY-012"
16654
+ ]);
16655
+ const FLAKY_RULES = /* @__PURE__ */ new Set([
16656
+ "QA-TEST-004",
16657
+ "QA-PY-005",
16658
+ "QA-PW-118",
16659
+ "QA-PW-114",
16660
+ "QA-TEST-006",
16661
+ "QA-ENV-001"
16662
+ ]);
16663
+ const CI_TRUST_RULES = /* @__PURE__ */ new Set([
16664
+ "QA-CI-001",
16665
+ "QA-CI-002",
16666
+ "QA-CI-005",
16667
+ "QA-CI-007",
16668
+ "QA-CI-008"
16669
+ ]);
16670
+ /** Group findings by directory to name "solid" areas. */
16671
+ function dirOf(file) {
16672
+ const idx = file.lastIndexOf("/");
16673
+ return idx === -1 ? file : file.slice(0, idx);
16230
16674
  }
16231
- function buildBaseline(result, commit) {
16675
+ function buildHandover(scan, forensics) {
16676
+ const fakeGreen = [];
16677
+ const flaky = [];
16678
+ const ciTrust = [];
16679
+ const touchedDirs = /* @__PURE__ */ new Map();
16680
+ for (const f of scan.findings) {
16681
+ if (FAKE_GREEN_RULES.has(f.ruleId)) fakeGreen.push(f);
16682
+ else if (FLAKY_RULES.has(f.ruleId)) flaky.push(f);
16683
+ else if (CI_TRUST_RULES.has(f.ruleId)) ciTrust.push(f);
16684
+ const d = dirOf(f.file);
16685
+ touchedDirs.set(d, (touchedDirs.get(d) ?? 0) + 1);
16686
+ }
16687
+ const sections = [];
16688
+ const flakyFromRuns = forensics?.verdicts.filter((v) => v.passedOnRetry || v.everFailed) ?? [];
16689
+ const flakyItems = [];
16690
+ for (const v of flakyFromRuns.slice(0, 5)) flakyItems.push(`${v.title} (${v.file}) — ${v.attempts} attempt${v.attempts === 1 ? "" : "s"}${v.passedOnRetry ? ", TRUE-FLAKE" : ""} → see FLAKY.md`);
16691
+ for (const f of flaky.slice(0, 3)) flakyItems.push(`${f.file}:${f.line} — ${f.message}`);
16692
+ if (flakyItems.length > 0) sections.push({
16693
+ heading: "🔴 Known flaky / timing-sensitive",
16694
+ items: flakyItems
16695
+ });
16696
+ if (fakeGreen.length > 0) sections.push({
16697
+ heading: "🟡 Fake-green suspects (tests that can't fail)",
16698
+ items: fakeGreen.slice(0, 5).map((f) => `${f.file}:${f.line} — ${f.message}`)
16699
+ });
16700
+ if (ciTrust.length > 0) sections.push({
16701
+ heading: "⚠ CI trust warnings (green ≠ verified)",
16702
+ items: ciTrust.slice(0, 4).map((f) => `${f.file}:${f.line} — ${f.message}`)
16703
+ });
16704
+ if (new Set(scan.findings.map((f) => f.file)).size === 0 && scan.score !== null && scan.score >= 90 || scan.findings.length === 0) sections.push({
16705
+ heading: "🟢 Solid foundation",
16706
+ items: ["No tracked anti-patterns found in scanned specs — good place to start contributing."]
16707
+ });
16708
+ const totalIssues = fakeGreen.length + flaky.length + ciTrust.length + (forensics?.flakyTests ?? 0);
16232
16709
  return {
16233
- schemaVersion: 1,
16234
- capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
16235
- commit,
16236
- ...result.score !== null ? { score: result.score } : {},
16237
- findings: result.findings.map((f) => ({
16238
- ruleId: f.ruleId,
16239
- file: f.file,
16240
- message: f.message,
16241
- severity: f.severity,
16242
- ...f.detectorRevision !== void 0 ? { detectorRevision: f.detectorRevision } : {}
16243
- }))
16710
+ sections,
16711
+ summaryLine: totalIssues === 0 ? "Welcome aboard — the suite is in good shape." : `${totalIssues} thing${totalIssues === 1 ? "" : "s"} to know about before your first release sign-off.`
16244
16712
  };
16245
16713
  }
16714
+ function renderHandover(map) {
16715
+ const lines = [];
16716
+ lines.push(sectionHeader("WELCOME TO THE TEST SUITE — WHAT YOU NEED TO KNOW", ui$7));
16717
+ lines.push("");
16718
+ lines.push(map.summaryLine);
16719
+ for (const s of map.sections) {
16720
+ lines.push("");
16721
+ lines.push(s.heading);
16722
+ for (const item of s.items) lines.push(` • ${item}`);
16723
+ }
16724
+ lines.push("");
16725
+ lines.push("Generated by mjolnir handover — re-run after big refactors.");
16726
+ return lines.join("\n");
16727
+ }
16728
+ //#endregion
16729
+ //#region src/commands/impact.ts
16246
16730
  /**
16247
- * Bug-audit L7 / decision D3: overwriting an existing baseline used to
16248
- * happen silently — a stale re-capture on a dirty tree made `diff`
16249
- * report all old debt as new with no way to see what was lost. The
16250
- * previous baseline is now backed up to `<path>.bak` and the caller
16251
- * prints the notice.
16731
+ * `mjolnir impact` — Sprint 6 Task 23 (Master-Stabilization-Plan.md).
16732
+ *
16733
+ * Answers "what would have burned you": compares the current scan against
16734
+ * an earlier point in this repo's own git history and reports anti-patterns
16735
+ * that were actually removed (evidence: findings present at the base ref,
16736
+ * absent now, backed by a real file+message match) plus new debt introduced
16737
+ * since then. That is the entire honesty-safe surface this command can
16738
+ * stand behind with real evidence.
16739
+ *
16740
+ * HARD CONSTRAINT (Honesty Core, non-negotiable per the plan): every number
16741
+ * here is evidence-backed from data actually present on disk, or the field
16742
+ * is UNKNOWN. This command never estimates or extrapolates "hours saved" or
16743
+ * "CI minutes saved" — inventing such a number would be worse than useless,
16744
+ * it would be the exact kind of fake-precision this product exists to
16745
+ * catch in *other* tools. Local-only, zero network (verified by
16746
+ * tests/privacy-network-isolation.spec.ts, which scans this file too).
16252
16747
  */
16253
- function saveBaseline(result, commit, outPath) {
16254
- mkdirSync(dirname(outPath), { recursive: true });
16255
- const existed = existsSync(outPath);
16256
- let backupPath;
16257
- if (existed) {
16258
- backupPath = `${outPath}.bak`;
16259
- copyFileSync(outPath, backupPath);
16260
- }
16261
- writeFileAtomic(outPath, JSON.stringify(buildBaseline(result, commit), null, 2) + "\n");
16262
- return {
16263
- path: outPath,
16264
- replaced: existed,
16265
- ...backupPath !== void 0 ? { backupPath } : {}
16266
- };
16748
+ const ui$6 = plainContext();
16749
+ /** The S1-resolved absolute git binary, or the bare name to fail on. */
16750
+ function gitExe() {
16751
+ return resolveGitPath() ?? "git";
16267
16752
  }
16268
- function loadBaseline(path, onWarning) {
16269
- if (!existsSync(path)) return null;
16753
+ function git(root, args) {
16270
16754
  try {
16271
- const parsed = JSON.parse(readFileSync(path, "utf8"));
16272
- if (typeof parsed === "object" && parsed !== null && "findings" in parsed && Array.isArray(parsed.findings)) {
16273
- const file = parsed;
16274
- const version = parsed.schemaVersion;
16275
- if (version === void 0) onWarning?.("baseline file has no schemaVersion (pre-versioning format) — treated as v1.");
16276
- else if (version !== 1) {
16277
- onWarning?.(`baseline file declares schemaVersion ${JSON.stringify(version)}; this Mjölnir understands v1 — baseline ignored (upgrade Mjölnir to diff it).`);
16278
- return null;
16279
- }
16280
- const rawScore = parsed.score;
16281
- const score = typeof rawScore === "number" && Number.isFinite(rawScore) ? rawScore : void 0;
16282
- file.findings = file.findings.filter((f) => typeof f === "object" && f !== null && typeof f.ruleId === "string" && typeof f.file === "string" && typeof f.message === "string");
16283
- return score === void 0 ? file : {
16284
- ...file,
16285
- score
16286
- };
16287
- }
16755
+ return execFileSync(gitExe(), [
16756
+ "-C",
16757
+ root,
16758
+ ...args
16759
+ ], {
16760
+ encoding: "utf8",
16761
+ stdio: [
16762
+ "ignore",
16763
+ "pipe",
16764
+ "ignore"
16765
+ ],
16766
+ timeout: 3e4
16767
+ });
16768
+ } catch {
16288
16769
  return null;
16770
+ }
16771
+ }
16772
+ /** Like git(), but returns the raw bytes — no utf8 decode round-trip. */
16773
+ function gitBuffer(root, args) {
16774
+ try {
16775
+ return execFileSync(gitExe(), [
16776
+ "-C",
16777
+ root,
16778
+ ...args
16779
+ ], {
16780
+ encoding: "buffer",
16781
+ stdio: [
16782
+ "ignore",
16783
+ "pipe",
16784
+ "ignore"
16785
+ ],
16786
+ timeout: 3e4
16787
+ });
16289
16788
  } catch {
16290
16789
  return null;
16291
16790
  }
16292
16791
  }
16293
- function diffAgainstBaseline(result, baseline) {
16294
- if (!baseline) return {
16295
- hasBaseline: false,
16296
- newFindings: [],
16297
- resolvedFindings: [],
16298
- unchangedCount: 0
16792
+ /** Fingerprint a finding for cross-commit matching (line numbers shift). */
16793
+ function fingerprint(f) {
16794
+ return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
16795
+ }
16796
+ async function computeImpact(root, options) {
16797
+ const unknownFacts = ["CI minutes or engineer-hours saved: not computed — this repo does not store historical CI run duration locally, and this command never estimates a number it cannot prove."];
16798
+ if (!existsSync(join(root, ".git"))) return {
16799
+ hasComparison: false,
16800
+ unknownReason: "not-a-git-repo",
16801
+ resolved: [],
16802
+ introduced: [],
16803
+ unknownFacts
16299
16804
  };
16300
- const baseSet = /* @__PURE__ */ new Map();
16301
- for (const f of baseline.findings) baseSet.set(fingerprint(f), f);
16302
- const headKeys = /* @__PURE__ */ new Set();
16303
- const newFindings = [];
16304
- let unchangedCount = 0;
16305
- for (const f of result.findings) {
16306
- const key = fingerprint(f);
16307
- headKeys.add(key);
16308
- if (baseSet.has(key)) unchangedCount++;
16309
- else newFindings.push(f);
16805
+ let baseRef = options.since;
16806
+ if (!baseRef) baseRef = git(root, ["rev-parse", "HEAD~1"])?.trim() ?? git(root, [
16807
+ "merge-base",
16808
+ "HEAD",
16809
+ options.baseBranch ?? "main"
16810
+ ])?.trim() ?? void 0;
16811
+ else baseRef = git(root, ["rev-parse", baseRef])?.trim() ?? baseRef;
16812
+ if (!baseRef) return {
16813
+ hasComparison: false,
16814
+ unknownReason: "no-prior-commit",
16815
+ resolved: [],
16816
+ introduced: [],
16817
+ unknownFacts
16818
+ };
16819
+ const headRef = git(root, ["rev-parse", "HEAD"])?.trim() ?? "HEAD";
16820
+ if (baseRef === headRef) return {
16821
+ hasComparison: false,
16822
+ unknownReason: options.since ? "base-equals-head" : "no-prior-commit",
16823
+ baseRef,
16824
+ headRef,
16825
+ resolved: [],
16826
+ introduced: [],
16827
+ unknownFacts
16828
+ };
16829
+ let tmpDir;
16830
+ try {
16831
+ tmpDir = mkdtempSync(join(tmpdir(), "mjolnir-impact-"));
16832
+ } catch {
16833
+ return {
16834
+ hasComparison: false,
16835
+ unknownReason: "tree-materialize-failed",
16836
+ baseRef,
16837
+ headRef,
16838
+ resolved: [],
16839
+ introduced: [],
16840
+ unknownFacts
16841
+ };
16310
16842
  }
16311
- const resolvedFindings = [];
16312
- for (const [key, f] of baseSet) if (!headKeys.has(key)) {
16313
- const resolution = resolve$1({
16314
- entry: f,
16315
- baseline,
16316
- current: result,
16317
- registryRevisions: REGISTRY_REVISIONS
16318
- });
16319
- resolvedFindings.push({
16320
- ...f,
16321
- resolution
16322
- });
16843
+ let baseResult;
16844
+ let baseTreeTruncated;
16845
+ try {
16846
+ const treeListing = git(root, [
16847
+ "ls-tree",
16848
+ "-r",
16849
+ "--name-only",
16850
+ "-z",
16851
+ baseRef
16852
+ ]);
16853
+ if (treeListing === null) return {
16854
+ hasComparison: false,
16855
+ unknownReason: "tree-listing-failed",
16856
+ baseRef,
16857
+ headRef,
16858
+ resolved: [],
16859
+ introduced: [],
16860
+ unknownFacts
16861
+ };
16862
+ const paths = treeListing.split("\0").filter(Boolean);
16863
+ const MAX_FILES = 2e4;
16864
+ const truncated = paths.length > MAX_FILES;
16865
+ for (const relPath of paths.slice(0, MAX_FILES)) {
16866
+ const blob = gitBuffer(root, ["show", `${baseRef}:${relPath}`]);
16867
+ if (blob === null) continue;
16868
+ const dest = join(tmpDir, relPath);
16869
+ mkdirSync(dirname(dest), { recursive: true });
16870
+ writeFileSync(dest, blob);
16871
+ }
16872
+ if (truncated) {
16873
+ baseTreeTruncated = {
16874
+ scanned: Math.min(paths.length, MAX_FILES),
16875
+ total: paths.length
16876
+ };
16877
+ unknownFacts.push(`Base tree materialization truncated at ${MAX_FILES} of ${paths.length} paths — files beyond the cap were never scanned at base, so findings that exist only there are misreported here as new debt.`);
16878
+ }
16879
+ baseResult = await options.runScan(tmpDir);
16880
+ } catch {
16881
+ return {
16882
+ hasComparison: false,
16883
+ unknownReason: "tree-materialize-failed",
16884
+ baseRef,
16885
+ headRef,
16886
+ resolved: [],
16887
+ introduced: [],
16888
+ unknownFacts
16889
+ };
16890
+ } finally {
16891
+ try {
16892
+ rmSync(tmpDir, {
16893
+ recursive: true,
16894
+ force: true
16895
+ });
16896
+ } catch {}
16323
16897
  }
16898
+ const headResult = await options.runScan(root);
16899
+ const baseSet = /* @__PURE__ */ new Map();
16900
+ for (const f of baseResult.findings) baseSet.set(fingerprint(f), f);
16901
+ const headSet = /* @__PURE__ */ new Map();
16902
+ for (const f of headResult.findings) headSet.set(fingerprint(f), f);
16903
+ const resolved = [];
16904
+ for (const [key, f] of baseSet) if (!headSet.has(key)) resolved.push({
16905
+ ruleId: f.ruleId,
16906
+ file: f.file,
16907
+ message: f.message
16908
+ });
16909
+ const introduced = [];
16910
+ for (const [key, f] of headSet) if (!baseSet.has(key)) introduced.push({
16911
+ ruleId: f.ruleId,
16912
+ file: f.file,
16913
+ message: f.message
16914
+ });
16324
16915
  return {
16325
- hasBaseline: true,
16326
- baselineCapturedAt: baseline.capturedAt,
16327
- baselineCommit: baseline.commit,
16328
- ...baseline.score !== void 0 ? { baselineScore: baseline.score } : {},
16329
- newFindings,
16330
- resolvedFindings,
16331
- unchangedCount
16916
+ hasComparison: true,
16917
+ baseRef,
16918
+ headRef,
16919
+ ...baseTreeTruncated ? { baseTreeTruncated } : {},
16920
+ resolved: resolved.sort((a, b) => a.file.localeCompare(b.file)),
16921
+ introduced: introduced.sort((a, b) => a.file.localeCompare(b.file)),
16922
+ unknownFacts
16332
16923
  };
16333
16924
  }
16334
- function renderBaselineSaved(path, count, replaced) {
16335
- const lines = [
16336
- sectionHeader("BASELINE SAVED", ui$7),
16337
- "",
16338
- `Captured ${count} finding${count === 1 ? "" : "s"} to ${path}.`
16339
- ];
16340
- if (replaced?.backupPath !== void 0) lines.push(`Replaced an existing baseline — the previous one was saved to ${replaced.backupPath}.`);
16341
- lines.push(nextStep("mjolnir diff", ui$7) + " — see only what's new.");
16342
- return lines.join("\n");
16343
- }
16344
- function renderBaselineDiff(diff) {
16925
+ function renderImpact(report) {
16345
16926
  const lines = [];
16346
- lines.push(sectionHeader("DIFF AGAINST BASELINE", ui$7));
16927
+ lines.push(sectionHeader("IMPACT REPORT", ui$6));
16347
16928
  lines.push("");
16348
- if (!diff.hasBaseline) {
16349
- lines.push("UNKNOWN — no baseline found.");
16350
- lines.push(nextStep("mjolnir baseline", ui$7) + " to capture a comparison point.");
16929
+ if (!report.hasComparison) {
16930
+ lines.push(`UNKNOWN — no comparison could be made (${report.unknownReason ?? "unknown reason"}).`);
16931
+ lines.push("This is reported as UNKNOWN rather than a fabricated zero: mjolnir");
16932
+ lines.push("never invents a number it cannot prove.");
16933
+ lines.push("");
16934
+ for (const fact of report.unknownFacts) lines.push(`UNKNOWN: ${fact}`);
16351
16935
  return lines.join("\n");
16352
16936
  }
16353
- lines.push(`Baseline captured ${diff.baselineCapturedAt ?? "unknown time"} at commit ${diff.baselineCommit ?? "unknown"}.`);
16354
- lines.push(`${diff.unchangedCount} pre-existing finding${diff.unchangedCount === 1 ? "" : "s"} carried over — not reported as new.`);
16937
+ lines.push(`Comparing ${report.baseRef?.slice(0, 12)} → ${report.headRef?.slice(0, 12)}`);
16355
16938
  lines.push("");
16356
- if (diff.newFindings.length === 0) lines.push("NEW OR WORSENED DEBT: none. This change introduced nothing new.");
16939
+ if (report.baseTreeTruncated) {
16940
+ lines.push(`UNKNOWN: base tree truncated — scanned ${report.baseTreeTruncated.scanned} of ${report.baseTreeTruncated.total} paths.`);
16941
+ lines.push("Files beyond the cap were never scanned at base; their findings are misreported as new debt.");
16942
+ lines.push("");
16943
+ }
16944
+ if (report.resolved.length === 0) lines.push("FIXED SINCE BASE: none found.");
16357
16945
  else {
16358
- lines.push(`NEW OR WORSENED DEBT (${diff.newFindings.length}) — this is what should block this PR:`);
16359
- for (const f of diff.newFindings) lines.push(` + ${f.ruleId} (${f.severity}) · ${f.file}:${f.line} — ${f.message}`);
16946
+ lines.push(`FIXED SINCE BASE (${report.resolved.length}) — real, evidence-backed:`);
16947
+ for (const f of report.resolved) lines.push(` ✓ ${f.ruleId} · ${f.file} — ${f.message}`);
16360
16948
  }
16361
16949
  lines.push("");
16362
- if (diff.resolvedFindings.length > 0) {
16363
- const verified = diff.resolvedFindings.filter((f) => f.resolution.status === "VERIFIED-RESOLVED");
16364
- const unresolved = diff.resolvedFindings.filter((f) => f.resolution.status !== "VERIFIED-RESOLVED");
16365
- if (verified.length > 0) {
16366
- lines.push(`FIXED SINCE BASELINE (${verified.length}):`);
16367
- for (const f of verified) lines.push(` ✓ ${f.ruleId} (${f.severity}) · ${f.file} — ${f.message}`);
16368
- lines.push("");
16369
- }
16370
- if (unresolved.length > 0) {
16371
- lines.push(`DISAPPEARED — NOT CLASSIFIED AS FIXED (${unresolved.length}):`);
16372
- for (const f of unresolved) lines.push(` ${renderResolution(f.resolution)} · ${f.ruleId} (${f.severity}) · ${f.file} — ${f.message}`);
16373
- }
16950
+ if (report.introduced.length === 0) lines.push("NEW DEBT SINCE BASE: none found.");
16951
+ else {
16952
+ lines.push(`NEW DEBT SINCE BASE (${report.introduced.length}):`);
16953
+ for (const f of report.introduced) lines.push(` + ${f.ruleId} · ${f.file} — ${f.message}`);
16374
16954
  }
16955
+ lines.push("");
16956
+ for (const fact of report.unknownFacts) lines.push(`UNKNOWN: ${fact}`);
16375
16957
  return lines.join("\n");
16376
16958
  }
16377
16959
  //#endregion
@@ -16402,7 +16984,7 @@ function renderBaselineDiff(diff) {
16402
16984
  * fix recorded by `diff`), announced once and never repeated. Display-only:
16403
16985
  * it does not change scores, exit codes or the JSON schema.
16404
16986
  */
16405
- const ui$6 = plainContext();
16987
+ const ui$5 = plainContext();
16406
16988
  const DEFAULT_STATS_PATH = join(".mjolnir", "stats.json");
16407
16989
  const MILESTONE_MESSAGES = {
16408
16990
  "first-clean-scan": "MILESTONE: first flawless scan recorded for this repo (score 100, zero findings).",
@@ -16490,13 +17072,13 @@ function saveStats(stats, outPath) {
16490
17072
  }
16491
17073
  function renderStats(stats) {
16492
17074
  const lines = [];
16493
- lines.push(sectionHeader("ALL-TIME STATS (this machine, this repo)", ui$6));
17075
+ lines.push(sectionHeader("ALL-TIME STATS (this machine, this repo)", ui$5));
16494
17076
  lines.push("");
16495
17077
  if (!stats || stats.recordedFixEvents === 0) {
16496
17078
  lines.push("No fixes recorded yet.");
16497
17079
  lines.push("Fixes are counted here only when observed by mjolnir diff —", "capture a baseline first, then diff after making fixes:");
16498
- lines.push(nextStep("mjolnir baseline", ui$6));
16499
- lines.push(nextStep("mjolnir diff", ui$6));
17080
+ lines.push(nextStep("mjolnir baseline", ui$5));
17081
+ lines.push(nextStep("mjolnir diff", ui$5));
16500
17082
  lines.push("");
16501
17083
  lines.push("UNKNOWN: totals before tracking started. This command only counts");
16502
17084
  lines.push("what it has personally witnessed via mjolnir diff.");
@@ -16525,7 +17107,7 @@ function renderStats(stats) {
16525
17107
  *
16526
17108
  * Idempotent: existing files are reported, never overwritten.
16527
17109
  */
16528
- const ui$5 = plainContext();
17110
+ const ui$4 = plainContext();
16529
17111
  function runInit(rootDir, workspace, options = {}) {
16530
17112
  const steps = [];
16531
17113
  const nextCommands = [];
@@ -16575,7 +17157,7 @@ function runInit(rootDir, workspace, options = {}) {
16575
17157
  }
16576
17158
  function renderInit(result) {
16577
17159
  const lines = [];
16578
- lines.push(sectionHeader("MJÖLNIR INIT", ui$5));
17160
+ lines.push(sectionHeader("MJÖLNIR INIT", ui$4));
16579
17161
  lines.push("");
16580
17162
  for (const s of result.steps) {
16581
17163
  const icon = s.status === "advice" ? "·" : s.status === "exists" ? "=" : "-";
@@ -16584,7 +17166,7 @@ function renderInit(result) {
16584
17166
  if (result.nextCommands.length > 0) {
16585
17167
  lines.push("");
16586
17168
  lines.push("Next commands:");
16587
- for (const c of result.nextCommands) lines.push(nextStep(c, ui$5));
17169
+ for (const c of result.nextCommands) lines.push(nextStep(c, ui$4));
16588
17170
  }
16589
17171
  lines.push("");
16590
17172
  lines.push("Existing files are never overwritten — init is safe to re-run.");
@@ -16602,7 +17184,7 @@ function tryReadPackageJson(rootDir) {
16602
17184
  }
16603
17185
  //#endregion
16604
17186
  //#region src/commands/pw-report.ts
16605
- const ui$4 = plainContext();
17187
+ const ui$3 = plainContext();
16606
17188
  function summarizePwRun(report) {
16607
17189
  const slowest = [...report.verdicts].sort((a, b) => b.totalDurationMs - a.totalDurationMs).slice(0, 5).map((v) => ({
16608
17190
  title: v.title,
@@ -16622,7 +17204,7 @@ function summarizePwRun(report) {
16622
17204
  }
16623
17205
  function renderPwRunSummary(s) {
16624
17206
  const lines = [];
16625
- lines.push(sectionHeader("MJÖLNIR — RUN SUMMARY", ui$4));
17207
+ lines.push(sectionHeader("MJÖLNIR — RUN SUMMARY", ui$3));
16626
17208
  lines.push("");
16627
17209
  lines.push(`${s.total} tests · ${s.passed} passed · ${s.failed} failed · ${s.skipped} skipped`);
16628
17210
  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)`);
@@ -16653,7 +17235,7 @@ function renderPwRunSummary(s) {
16653
17235
  * Everything else stays suggestion-only. No AST surgery on heuristic
16654
17236
  * findings — false fixes would break the brand promise.
16655
17237
  */
16656
- const ui$3 = plainContext();
17238
+ const ui$2 = plainContext();
16657
17239
  const MAX_FILE_BYTES = 524288;
16658
17240
  /**
16659
17241
  * Path-containment guard (adversarial-audit wave; hardened per audit
@@ -17014,7 +17596,7 @@ function fixVerified(edit, fixedText, originalText) {
17014
17596
  }
17015
17597
  function renderFixReport(results, dryRun) {
17016
17598
  const lines = [];
17017
- lines.push(sectionHeader(dryRun ? "FIX PLAN (dry-run)" : "FIX REPORT", ui$3));
17599
+ lines.push(sectionHeader(dryRun ? "FIX PLAN (dry-run)" : "FIX REPORT", ui$2));
17018
17600
  lines.push("");
17019
17601
  if (results.length === 0) {
17020
17602
  lines.push("No safe auto-fixes available for these findings.");
@@ -17022,7 +17604,7 @@ function renderFixReport(results, dryRun) {
17022
17604
  return lines.join("\n");
17023
17605
  }
17024
17606
  for (const r of results) {
17025
- const icon = r.status === "applied" ? okIcon(ui$3) : r.status === "planned" ? "▸" : "✗";
17607
+ const icon = r.status === "applied" ? okIcon(ui$2) : r.status === "planned" ? "▸" : "✗";
17026
17608
  lines.push(`${icon} [${r.ruleId}] ${r.file}:${r.line} — ${r.description}`);
17027
17609
  }
17028
17610
  const applied = results.filter((r) => r.status === "applied").length;
@@ -17038,34 +17620,6 @@ function renderFixReport(results, dryRun) {
17038
17620
  return lines.join("\n");
17039
17621
  }
17040
17622
  //#endregion
17041
- //#region src/rules/measurement.ts
17042
- /** The rule's declared detector implementation revision (§07). */
17043
- function declaredDetectorRevision(rule) {
17044
- return rule.detectorRevision ?? 1;
17045
- }
17046
- /**
17047
- * A measurement exists AND was taken against the detector revision the
17048
- * rule declares now. A revision mismatch (stale) does NOT count — §07:
17049
- * stale → provisional → re-measure.
17050
- */
17051
- function hasValidMeasurement(rule) {
17052
- const m = MEASURED_FP[rule.id];
17053
- return m !== void 0 && m.detectorRevision === declaredDetectorRevision(rule);
17054
- }
17055
- /**
17056
- * The tier a rule effectively ships as: its declared tier, or — for the
17057
- * omitted-tier case — the measurement-dependent default (§11.2 Step 2).
17058
- */
17059
- function effectiveTier(rule) {
17060
- if (rule.tier !== void 0) return rule.tier;
17061
- if (hasValidMeasurement(rule)) return "core";
17062
- return "extended";
17063
- }
17064
- /** The §11.2 Step 2 PROVISIONAL display predicate. */
17065
- function isProvisional(rule) {
17066
- return effectiveTier(rule) === "extended" && !hasValidMeasurement(rule);
17067
- }
17068
- //#endregion
17069
17623
  //#region src/commands/doctor.ts
17070
17624
  /**
17071
17625
  * `mjolnir doctor` — self-audit of Mjolnir's own rule base.
@@ -17082,7 +17636,7 @@ function isProvisional(rule) {
17082
17636
  *
17083
17637
  * Exit codes reuse the frozen set: 0 healthy · 1 violations · 20 crash.
17084
17638
  */
17085
- const ui$2 = plainContext();
17639
+ const ui$1 = plainContext();
17086
17640
  const VALID_ID = RULE_ID_RE;
17087
17641
  function nonHiddenFiles(dir) {
17088
17642
  if (!existsSync(dir)) return [];
@@ -17281,7 +17835,7 @@ function runDoctorSelfAudit(fixturesRoot) {
17281
17835
  function renderDoctorReport(report) {
17282
17836
  const lines = [
17283
17837
  "",
17284
- sectionHeader("MJÖLNIR — SELF-AUDIT", ui$2),
17838
+ sectionHeader("MJÖLNIR — SELF-AUDIT", ui$1),
17285
17839
  ""
17286
17840
  ];
17287
17841
  for (const c of report.checks) {
@@ -17359,145 +17913,6 @@ function escapeMdCell(text) {
17359
17913
  return text.replaceAll("|", "\\|");
17360
17914
  }
17361
17915
  //#endregion
17362
- //#region src/commands/fixture-example.ts
17363
- /**
17364
- * Shared example-fixture selection for `mjolnir explain` (explain.ts)
17365
- * and the generated rule docs (rule-docs.ts) — one chooser so both
17366
- * surfaces always show the same example for the same rule. Extracted
17367
- * after the same bug-audit L9 fix had to be applied to both verbatim
17368
- * copies: duplicated fixture selection drifts, and a drift means
17369
- * `mjolnir explain <ID>` contradicts docs/rules/<ID>.md.
17370
- */
17371
- /**
17372
- * First (byte-stable) fixture file in a must-fire / must-not-fire
17373
- * directory, or null when the directory is absent or empty.
17374
- */
17375
- function firstFixtureFile(dir) {
17376
- if (!existsSync(dir)) return null;
17377
- const entries = readdirSync(dir).filter((f) => !f.startsWith(".")).sort();
17378
- return entries.length > 0 ? join(dir, entries[0]) : null;
17379
- }
17380
- //#endregion
17381
- //#region src/commands/explain.ts
17382
- /**
17383
- * `mjolnir explain <RULE-ID>` — implements Plan.md Sprint 1.3
17384
- * (Master-Stabilization-Plan Sprint 5, Task 19).
17385
- *
17386
- * For any registered rule, renders what is wrong, why it matters, the
17387
- * evidence level and confidence behind the verdict, the prescription,
17388
- * and how to verify the fix. This is a presentation layer only — no new
17389
- * detection logic. Every field it prints already exists on RuleMeta or
17390
- * comes from actually running the rule against its own committed
17391
- * must-fire fixture, so the example shown is real detector output, not
17392
- * hand-written prose that can drift from what the rule actually does.
17393
- */
17394
- const ui$1 = plainContext();
17395
- /**
17396
- * Runs the rule against its own must-fire fixture to get one real,
17397
- * concrete example finding. `fixturesRoot` defaults to this repo's own
17398
- * `tests/fixtures` — explain only has real examples to show when run
17399
- * from (or pointed at) a Mjolnir checkout; degrades honestly
17400
- * (exampleFinding left undefined) otherwise, same as `doctor`.
17401
- */
17402
- function explainRule(ruleId, fixturesRoot) {
17403
- const rule = getRule(ruleId);
17404
- if (!rule) return {
17405
- ok: false,
17406
- error: `Unknown rule ID "${ruleId}". Run \`mjolnir rules\` for the full catalog.`
17407
- };
17408
- const fixturePath = firstFixtureFile(join(fixturesRoot, ruleId, "must-fire"));
17409
- if (!fixturePath) return {
17410
- ok: true,
17411
- rule
17412
- };
17413
- let text;
17414
- try {
17415
- text = readFileSync(fixturePath, "utf8").replace(/\r\n/g, "\n");
17416
- } catch {
17417
- return {
17418
- ok: true,
17419
- rule
17420
- };
17421
- }
17422
- const normalizedPath = fixturePath.replaceAll("\\", "/");
17423
- let ast;
17424
- if (rule.appliesTo === "ci-workflows") try {
17425
- ast = parseWorkflow(text);
17426
- } catch {
17427
- return {
17428
- ok: true,
17429
- rule
17430
- };
17431
- }
17432
- let findings;
17433
- try {
17434
- const parsed = {
17435
- path: normalizedPath,
17436
- text,
17437
- ast
17438
- };
17439
- const codeText = computeCodeText(parsed, normalizedPath.endsWith(".py") ? "python" : normalizedPath.endsWith(".java") ? "java" : normalizedPath.endsWith(".cs") ? "csharp" : "typescript");
17440
- findings = rule.run({
17441
- ...parsed,
17442
- codeText
17443
- });
17444
- } catch {
17445
- return {
17446
- ok: true,
17447
- rule
17448
- };
17449
- }
17450
- const example = findings[0];
17451
- if (!example) return {
17452
- ok: true,
17453
- rule
17454
- };
17455
- return {
17456
- ok: true,
17457
- rule,
17458
- exampleFinding: example,
17459
- exampleFixturePath: fixturePath
17460
- };
17461
- }
17462
- function renderExplain(result) {
17463
- if (!result.ok || !result.rule) return `explain failed: ${result.error ?? "unknown error"}`;
17464
- const r = result.rule;
17465
- const evidenceLevel = r.evidenceLevel ?? deriveEvidenceLevel(r.findingType, r.confidence);
17466
- const lines = [];
17467
- lines.push(sectionHeader(`${r.id} — ${r.title}`, ui$1));
17468
- lines.push("");
17469
- lines.push(`Severity: ${r.severity}`);
17470
- lines.push(`Confidence: ${r.confidence}`);
17471
- lines.push(`Tier: ${effectiveTier(r)}${isProvisional(r) ? " (PROVISIONAL)" : ""}`);
17472
- lines.push(`Evidence: ${evidenceLevel}`);
17473
- lines.push(`QA impact: ${QA_IMPACT_LABELS[r.qaImpact]} (${r.qaImpact})`);
17474
- const measured = MEASURED_FP[r.id];
17475
- lines.push(measured ? `Measured FP: ${Math.round(measured.fpRate * 100)}% (${measured.n} hand-classified corpus verdicts)` : `Measured FP: not yet measured — this rule ships on assumption (see docs/FP-AUDIT.md)`);
17476
- if (r.falsePositiveRisk) lines.push(`FP risk: ${r.falsePositiveRisk} (author estimate)`);
17477
- if (r.languages?.length) lines.push(`Languages: ${r.languages.join(", ")}`);
17478
- if (r.frameworks?.length) lines.push(`Frameworks: ${r.frameworks.join(", ")}`);
17479
- lines.push("");
17480
- if (result.exampleFinding) {
17481
- const f = result.exampleFinding;
17482
- lines.push("WHAT WAS FOUND (real detector output, not a mockup)");
17483
- lines.push(` ${f.message}`);
17484
- lines.push("");
17485
- lines.push("WHY IT MATTERS");
17486
- lines.push(` ${f.why}`);
17487
- lines.push("");
17488
- lines.push("HOW TO FIX");
17489
- lines.push(` ${f.fix}`);
17490
- lines.push("");
17491
- lines.push(`Example from this rule's own must-fire fixture: ${result.exampleFixturePath ?? "(unknown path)"}`);
17492
- } else lines.push("No example available — run this command from a mjolnir checkout (or pass --fixtures-root) so the fixture that proves this rule works can be shown as a real example.");
17493
- lines.push("");
17494
- lines.push("HOW TO VERIFY THE FIX");
17495
- lines.push(" Re-run `mjolnir` on the changed file(s) — this finding should no longer appear. `mjolnir --scope changed` scopes the check to just what you touched.");
17496
- lines.push("");
17497
- lines.push(`Docs: mjolnir rules --md (full catalog, this rule included)`);
17498
- return lines.join("\n");
17499
- }
17500
- //#endregion
17501
17916
  //#region src/playwright/selector-health-types.ts
17502
17917
  /** Risk weights per locator shape (0 = resilient, higher = brittle). */
17503
17918
  const LOCATOR_RISK = {
@@ -17649,7 +18064,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
17649
18064
  * `scripts/sync-sarif-version.cjs` on release and guarded by
17650
18065
  * `tests/version-consistency.spec.ts` locally.
17651
18066
  */
17652
- const CLI_VERSION = "0.5.12";
18067
+ const CLI_VERSION = "0.5.14";
17653
18068
  function parseArgs(argv, onError) {
17654
18069
  const args = {
17655
18070
  target: ".",
@@ -18513,7 +18928,8 @@ const SUBCOMMANDS = /* @__PURE__ */ new Set([
18513
18928
  "doctor",
18514
18929
  "rules",
18515
18930
  "explain",
18516
- "doctor:playwright"
18931
+ "doctor:playwright",
18932
+ "mcp"
18517
18933
  ]);
18518
18934
  async function main(argv = process$1.argv.slice(2), io = {
18519
18935
  out,
@@ -18550,6 +18966,10 @@ async function main(argv = process$1.argv.slice(2), io = {
18550
18966
  if (argv[0] === "why") return runWhyCommand(argv.slice(1), io);
18551
18967
  if (argv[0] === "handoff") return runHandoffCommand(argv.slice(1), io);
18552
18968
  if (argv[0] === "install") return runInstallCommand(argv.slice(1), io);
18969
+ if (argv[0] === "mcp") {
18970
+ await runStdioTransport(process$1.stdin, process$1.stdout);
18971
+ return 0;
18972
+ }
18553
18973
  if (argv[0] === "help") return runHelpCommand(argv.slice(1), io);
18554
18974
  if (SUBCOMMANDS.has(argv[0] ?? "")) {
18555
18975
  err(`mjolnir: incomplete or unknown subcommand "${argv[0]}".`);