mjolnir-qa 0.5.13 → 0.5.15

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;
@@ -13253,7 +13254,7 @@ function renderSarif(result, repoRootUri) {
13253
13254
  tool: { driver: {
13254
13255
  name: "Mjölnir",
13255
13256
  informationUri: "https://github.com/Sergey-Bar/Mjolnir",
13256
- version: "0.5.13",
13257
+ version: "0.5.15",
13257
13258
  rules: [...rules.values()].map((r) => {
13258
13259
  const meta = RULES.find((x) => x.id === r.id);
13259
13260
  return {
@@ -14675,307 +14676,185 @@ function executeHookInstall(entry) {
14675
14676
  }
14676
14677
  }
14677
14678
  //#endregion
14678
- //#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
+ }
14679
14684
  /**
14680
- * CI integration (Sprint-Plan W7): generates .github/workflows/mjolnir.yml
14681
- * from internal templates ONLY — no user-input interpolation (R3 supply-chain).
14682
- * Default gate: advisory (report, never block).
14683
- *
14684
- * Bug-audit hardening (H2): the previous template shipped the same
14685
- * `github.rest.checks` no-op this repo's own audit removed from mjolnir.yml,
14686
- * failed the job before the annotate/summary steps could run whenever the
14687
- * scan step exited non-zero, recommended floating `mjolnir-qa@latest`, and
14688
- * `ciInstall` silently overwrote hand-customized workflows. The template now
14689
- * mirrors the dogfooded `.github/workflows/mjolnir.yml` (pinned action SHAs,
14690
- * `if: always()` on reporting steps, a real gate step that reads
14691
- * `mjolnir.json`, partial scans never block) and `ciInstall` refuses to
14692
- * 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.
14693
14688
  */
14689
+ function hasValidMeasurement(rule) {
14690
+ const m = MEASURED_FP[rule.id];
14691
+ return m !== void 0 && m.detectorRevision === declaredDetectorRevision(rule);
14692
+ }
14694
14693
  /**
14695
- * The gate-check script embedded in generated workflows (and executed
14696
- * directly by tests against fixture JSONs). Semantics, kept in sync with
14697
- * the tool's own exit-code contract:
14698
- * - missing/unreadable `mjolnir.json` → fail (the scan step crashed; a
14699
- * silent pass here would turn a broken pipeline into a green one);
14700
- * - `partial: true` → never block (truncated results can neither prove
14701
- * nor disprove the gate — the "PARTIAL" banner in the summary is the
14702
- * honest signal);
14703
- * - `error` gate → block on any error-severity finding;
14704
- * - `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).
14705
14696
  */
14706
- function gateScript(gate) {
14707
- return [
14708
- "const fs = require(\"fs\");",
14709
- "let r;",
14710
- "try {",
14711
- " r = JSON.parse(fs.readFileSync(\"mjolnir.json\", \"utf8\"));",
14712
- "} catch (e) {",
14713
- " process.stderr.write(\"mjolnir.json is missing or unreadable - the scan step crashed before the gate could run. Failing instead of passing silently.\\n\");",
14714
- " process.exit(1);",
14715
- "}",
14716
- "if (r.partial === true) {",
14717
- " process.stdout.write(\"Scan was PARTIAL - some files were not analyzed; gate not enforced.\\n\");",
14718
- " process.exit(0);",
14719
- "}",
14720
- "const findings = Array.isArray(r.findings) ? r.findings : [];",
14721
- "const errors = findings.filter(function (f) { return f && f.severity === \"error\"; }).length;",
14722
- "const warnings = findings.filter(function (f) { return f && f.severity === \"warning\"; }).length;",
14723
- "process.stdout.write(\"Mjolnir gate: \" + errors + \" error(s), \" + warnings + \" warning(s).\\n\");",
14724
- `if (${gate === "error" ? "errors > 0" : "errors > 0 || warnings > 0"}) { process.exit(1); }`,
14725
- "process.exit(0);"
14726
- ].join("\n");
14697
+ function effectiveTier(rule) {
14698
+ if (rule.tier !== void 0) return rule.tier;
14699
+ if (hasValidMeasurement(rule)) return "core";
14700
+ return "extended";
14727
14701
  }
14702
+ /** The §11.2 Step 2 PROVISIONAL display predicate. */
14703
+ function isProvisional(rule) {
14704
+ return effectiveTier(rule) === "extended" && !hasValidMeasurement(rule);
14705
+ }
14706
+ //#endregion
14707
+ //#region src/commands/fixture-example.ts
14728
14708
  /**
14729
- * Template v2 (Terminal + CI UX Overhaul plan, M4): the summary step
14730
- * calls `mjolnir summary mjolnir.json` — annotations + step summary via
14731
- * ONE emitter — instead of the v1 inline SUMMARY_SCRIPT. The gate
14732
- * 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.
14733
14715
  */
14734
- /** Renders findings into `$GITHUB_STEP_SUMMARY`; tolerates a missing scan result.
14735
- * v1 inline script, retained ONLY for overwrite-refusal recognition of
14736
- * workflows generated by older versions (they are treated as ours, so
14737
- * a frictionless `ci install` upgrade stays possible). Exported for the
14738
- * recognition test that reconstructs the embedded form. */
14739
- const SUMMARY_SCRIPT_V1 = [
14740
- "const fs = require(\"fs\");",
14741
- "let r = {};",
14742
- "try { r = JSON.parse(fs.readFileSync(\"mjolnir.json\", \"utf8\")); } catch (e) {}",
14743
- "const findings = Array.isArray(r.findings) ? r.findings : [];",
14744
- "const lines = findings.map(function (f) {",
14745
- " return \"- **\" + f.ruleId + \"** (\" + f.severity + \") \" + f.file + \":\" + f.line + \" - \" + f.message;",
14746
- "});",
14747
- "const head = r.partial === true",
14748
- " ? \"Mjolnir scan was PARTIAL - some files may not have been analyzed.\"",
14749
- " : \"Mjolnir scan finished.\";",
14750
- "const body = [\"## Mjolnir findings\", head, \"\"].concat(lines).join(\"\\n\");",
14751
- "if (process.env.GITHUB_STEP_SUMMARY && lines.length > 0) {",
14752
- " fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, body + \"\\n\");",
14753
- "}",
14754
- "process.stdout.write(lines.length + \" finding(s)\\n\");"
14755
- ].join("\n");
14756
- /** True when a workflow was generated by any Mjölnir template (v1 or v2).
14757
- * The v1 needle is matched in its INDENTED form: the v1 template embedded
14758
- * the script via indentBlock(…, 10) inside the `run: |` scalar, so the
14759
- * raw unindented substring never appears in a real v1 file. */
14760
- function isKnownTemplate(content) {
14761
- 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;
14762
14724
  }
14763
- /** Indents an embedded script so it sits inside a YAML `run: |` block scalar. */
14764
- function indentBlock(text, spaces) {
14765
- const pad = " ".repeat(spaces);
14766
- 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
+ };
14767
14807
  }
14768
- /** The generated workflow for one gate level. Exported for template tests. */
14769
- const TEMPLATE = (gate) => `name: Mjölnir
14770
-
14771
- on:
14772
- pull_request:
14773
-
14774
- concurrency:
14775
- group: mjolnir-\${{ github.ref }}
14776
- cancel-in-progress: true
14777
-
14778
- permissions:
14779
- contents: read
14780
- pull-requests: write
14781
-
14782
- jobs:
14783
- scan:
14784
- runs-on: ubuntu-latest
14785
- # A hung scan must not sit for the 6-hour default.
14786
- timeout-minutes: 10
14787
- steps:
14788
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
14789
- with:
14790
- fetch-depth: 0 # needed for --scope changed merge-base
14791
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
14792
- with:
14793
- node-version: 22
14794
- # Scan with the PINNED version that generated this workflow — never
14795
- # a floating tag: a new release must not change your gate semantics
14796
- # with no commit of yours. To review PRs with the exact tool your
14797
- # repo develops against, add mjolnir-qa to devDependencies and drop
14798
- # the @version suffix so npx resolves the local install.
14799
- - name: Scan changed code (exit 1/2 is data — the gate step decides)
14800
- continue-on-error: true
14801
- run: npx --yes mjolnir-qa@${CLI_VERSION} . --scope changed --json > mjolnir.json
14802
- # Reporting, not gating: a crashed scan leaves mjolnir.json empty/missing
14803
- # and the summary step exits 2/10 — continue-on-error keeps the advisory
14804
- # job green, exactly like the v1 inline script did (the gate step decides).
14805
- - name: Annotations + Job Summary
14806
- if: always()
14807
- continue-on-error: true
14808
- run: npx --yes mjolnir-qa@${CLI_VERSION} summary mjolnir.json
14809
- - name: Render PR comment
14810
- if: always()
14811
- continue-on-error: true
14812
- run: npx --yes mjolnir-qa@${CLI_VERSION} pr-comment . > mjolnir-comment.md
14813
- # Best-effort: on a pull_request event from a fork the GITHUB_TOKEN is
14814
- # read-only and this step will 403 for every external contributor. The
14815
- # Job Summary above is the fallback that always renders.
14816
- # (pull_request_target would fix the token but is a code-execution
14817
- # risk — deliberately NOT used.)
14818
- - name: Post or update PR comment
14819
- if: always()
14820
- continue-on-error: true
14821
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
14822
- with:
14823
- script: |
14824
- const fs = require('fs');
14825
- let body = '';
14826
- try { body = fs.readFileSync('mjolnir-comment.md', 'utf8'); } catch (e) {}
14827
- if (!body.trim()) {
14828
- console.log('mjolnir-comment.md is empty or missing — nothing to post.');
14829
- return;
14830
- }
14831
- const marker = '<!-- mjolnir-pr-comment -->';
14832
- const { data: comments } = await github.rest.issues.listComments({
14833
- owner: context.repo.owner,
14834
- repo: context.repo.repo,
14835
- issue_number: context.issue.number,
14836
- });
14837
- const existing = comments.find((c) => c.body?.startsWith(marker));
14838
- if (existing) {
14839
- await github.rest.issues.updateComment({
14840
- owner: context.repo.owner,
14841
- repo: context.repo.repo,
14842
- comment_id: existing.id,
14843
- body,
14844
- });
14845
- } else {
14846
- await github.rest.issues.createComment({
14847
- owner: context.repo.owner,
14848
- repo: context.repo.repo,
14849
- issue_number: context.issue.number,
14850
- body,
14851
- });
14852
- }
14853
- ${gate === "advisory" ? ` # Advisory mode: findings are reported in the Job Summary and the
14854
- # PR comment, never blocking. The scan step's exit code is visible
14855
- # as the step outcome, but continue-on-error keeps the job green.
14856
- - name: Gate (advisory)
14857
- if: always()
14858
- run: echo "Advisory mode — findings reported, never blocking."` : ` # Gate enforcement: the scan step's own exit code is deliberately
14859
- # neutralized (continue-on-error) so reporting steps always run; THIS
14860
- # step is what fails the job. A partial scan never blocks.
14861
- - name: Gate (${gate})
14862
- if: always()
14863
- run: |
14864
- node -e '
14865
- ${indentBlock(gateScript(gate), 10)}
14866
- '`}
14867
- `;
14868
- /** Every gate variant, used to tell "our template" from "hand-customized". */
14869
- const GATES = [
14870
- "advisory",
14871
- "error",
14872
- "warning"
14873
- ];
14874
- /** Multiset line diff — counts only, for the refusal message. */
14875
- function summarizeContentDiff(existing, incoming) {
14876
- const remaining = /* @__PURE__ */ new Map();
14877
- for (const line of existing.split(/\r?\n/)) remaining.set(line, (remaining.get(line) ?? 0) + 1);
14878
- let added = 0;
14879
- for (const line of incoming.split(/\r?\n/)) {
14880
- const count = remaining.get(line) ?? 0;
14881
- if (count > 0) remaining.set(line, count - 1);
14882
- else added += 1;
14883
- }
14884
- let removed = 0;
14885
- for (const count of remaining.values()) removed += count;
14886
- 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`];
14887
- }
14888
- function ciInstall(root, gate = "advisory", options = {}) {
14889
- const wfDir = join(root, ".github", "workflows");
14890
- const target = join(wfDir, "mjolnir.yml");
14891
- if (!existsSync(wfDir)) mkdirSync(wfDir, { recursive: true });
14892
- const existed = existsSync(target);
14893
- if (existed) {
14894
- const current = readFileSync(target, "utf8");
14895
- if (!isKnownTemplate(current) && !(options.force ?? false)) return {
14896
- written: target,
14897
- existed,
14898
- refused: true,
14899
- diffSummary: summarizeContentDiff(current, TEMPLATE(gate))
14900
- };
14901
- }
14902
- writeFileSync(target, TEMPLATE(gate));
14903
- return {
14904
- written: target,
14905
- existed,
14906
- refused: false,
14907
- diffSummary: []
14908
- };
14909
- }
14910
- //#endregion
14911
- //#region src/forensics/triage.ts
14912
- const ui$12 = plainContext();
14913
- const QUARANTINE_MIN_ATTEMPTS = 2;
14914
- /** Deterministic triage table rows, worst first. */
14915
- function triageRows(report) {
14916
- return report.verdicts.filter((v) => v.everFailed || v.passedOnRetry).sort((a, b) => {
14917
- if (a.passedOnRetry !== b.passedOnRetry) return a.passedOnRetry ? -1 : 1;
14918
- if (a.attempts !== b.attempts) return b.attempts - a.attempts;
14919
- return b.totalDurationMs - a.totalDurationMs;
14920
- }).map((v) => ({
14921
- file: v.file,
14922
- title: v.title,
14923
- attempts: v.attempts,
14924
- passedOnRetry: v.passedOnRetry,
14925
- finalStatus: v.finalStatus,
14926
- totalDurationMs: v.totalDurationMs,
14927
- suggestedAction: suggestAction(v),
14928
- proposedQuarantine: v.attempts >= QUARANTINE_MIN_ATTEMPTS && v.everFailed
14929
- }));
14930
- }
14931
- function suggestAction(v) {
14932
- if (v.passedOnRetry && v.attempts >= QUARANTINE_MIN_ATTEMPTS) return "quarantine + ticket";
14933
- if (v.passedOnRetry) return "fix nondeterminism";
14934
- if (v.finalStatus === "failed" || v.finalStatus === "timedOut") return "fix now — failing";
14935
- return "investigate";
14936
- }
14937
- /** Terminal rendering of the triage proposal. */
14938
- function renderTriage(report) {
14939
- const rows = triageRows(report);
14940
- const lines = [];
14941
- lines.push(sectionHeader("FLAKY TRIAGE — auto-generated, do not edit", ui$12));
14942
- lines.push("");
14943
- if (rows.length === 0) {
14944
- lines.push("Nothing to triage — no failures or retries in this run.");
14945
- return lines.join("\n");
14946
- }
14947
- const quarantineCount = rows.filter((r) => r.proposedQuarantine).length;
14948
- for (const r of rows) {
14949
- const flag = r.passedOnRetry ? "TRUE-FLAKE" : "FAILING";
14950
- lines.push(`• [${flag}] ${r.title} (${r.file}) — ${r.attempts} attempt${r.attempts === 1 ? "" : "s"} → ${r.suggestedAction}`);
14951
- }
14952
- lines.push("");
14953
- lines.push(`Auto-quarantine proposal: ${quarantineCount} test${quarantineCount === 1 ? "" : "s"} (retried ≥${QUARANTINE_MIN_ATTEMPTS} and failed at least once).`);
14954
- lines.push("Quarantined tests should run nightly, not per-PR — quarantine is not deletion.");
14955
- return lines.join("\n");
14956
- }
14957
- /** TRIAGE.md — the meeting artifact. */
14958
- function renderTriageMd(report) {
14959
- 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);
14960
14822
  const lines = [];
14961
- 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));
14962
14828
  lines.push("");
14963
- 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(", ")}`);
14964
14839
  lines.push("");
14965
- if (rows.length === 0) {
14966
- lines.push("_No flaky or failing tests this run — nothing to triage._ 🎉");
14967
- return lines.join("\n");
14968
- }
14969
- lines.push("| Status | Test | File | Attempts | Duration | Suggested action | Quarantine? |");
14970
- lines.push("|--------|------|------|----------|----------|------------------|-------------|");
14971
- for (const r of rows) {
14972
- const status = r.passedOnRetry ? `${FLAKE_GLYPH} TRUE-FLAKE` : "❌ FAILING";
14973
- lines.push(`| ${status} | \`${r.title}\` | \`${r.file}\` | ${r.attempts} | ${(r.totalDurationMs / 1e3).toFixed(1)}s | ${r.suggestedAction} | ${r.proposedQuarantine ? "✅ propose" : "—"} |`);
14974
- }
14975
- 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);
14976
14853
  lines.push("");
14977
- 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.");
14978
14856
  lines.push("");
14857
+ lines.push(`Docs: mjolnir rules --md (full catalog, this rule included)`);
14979
14858
  return lines.join("\n");
14980
14859
  }
14981
14860
  //#endregion
@@ -15073,66 +14952,996 @@ function sweepStaleTempFiles(dir) {
15073
14952
  return swept;
15074
14953
  }
15075
14954
  //#endregion
15076
- //#region src/commands/badge.ts
15077
- /**
15078
- * `mjolnir badge` — evidentiary shields.io endpoint JSON (Tier 1 #5).
15079
- *
15080
- * Static JSON, no server. The badge makes falsifiable claims:
15081
- * score + date + commit. Anyone can click through and verify.
15082
- */
15083
- /**
15084
- * Badge colors follow the SAME ScoreState bands as the terminal
15085
- * (≥80 trusted / ≥50 warning / <50 critical / 100 forged) — this
15086
- * retarget fixes the historical threshold drift (the badge used
15087
- * ≥90/≥75/≥50 with four bands while the reporter used ≥80/≥50).
15088
- *
15089
- * Shields.io has no cyan or white-gold, so the mapping is documented
15090
- * here: trusted → `important` (blue-family, closest to aurora-cyan),
15091
- * forged → `success` (the strongest positive signal shields offers).
15092
- * The badge is a peripheral surface; ScoreState remains the truth.
15093
- */
15094
- function colorFor(score) {
15095
- const band = deriveScoreState(score).band;
15096
- if (band === "unmeasured") return "lightgrey";
15097
- if (band === "forged") return "success";
15098
- if (band === "trusted") return "important";
15099
- return band === "warning" ? "yellow" : "red";
15100
- }
15101
- /** Build the shields.io endpoint payload from a scan result. */
15102
- function buildBadge(result, commit) {
15103
- const errors = result.findings.filter((f) => f.severity === "error").length;
15104
- const score = result.score;
15105
- return {
15106
- schemaVersion: 1,
15107
- label: "MJÖLNIR",
15108
- message: score === null ? "no tests found" : score === 100 && errors === 0 ? "100/100 · forged" : `${score}/100 · ${errors} error${errors === 1 ? "" : "s"}`,
15109
- color: colorFor(score),
15110
- namedLogo: "vitest",
15111
- ...commit !== void 0 ? { commit } : {}
15112
- };
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}`;
15113
14959
  }
15114
14960
  /**
15115
- * Full README-ready markdown snippet with commit-bound verification line
15116
- * (the falsifiable claim: "verified at commit X on date Y").
14961
+ * The §15 ordered algorithm. Pure; first match wins.
15117
14962
  */
15118
- function renderBadgeSnippet(result, repoUrl = "https://github.com/Sergey-Bar/Mjolnir") {
15119
- let commit = "unknown";
15120
- try {
15121
- commit = execSync("git rev-parse --short HEAD", {
15122
- cwd: process.cwd(),
15123
- encoding: "utf8",
15124
- stdio: [
15125
- "ignore",
15126
- "pipe",
15127
- "ignore"
15128
- ]
15129
- }).trim();
15130
- } catch {}
15131
- const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
15132
- const errors = result.findings.filter((f) => f.severity === "error").length;
15133
- return [
15134
- "```markdown",
15135
- "[![MJÖLNIR](https://img.shields.io/endpoint?url=<your-badge-json-url>)](" + repoUrl + ")",
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 + ")",
15136
15945
  "<!-- Mjölnir verified at commit " + commit + " on " + date + ": " + (result.score === null ? "no tests found" : result.score + "/100") + ", " + errors + " blocking error(s) -->",
15137
15946
  "```"
15138
15947
  ].join("\n");
@@ -15291,6 +16100,12 @@ const HELP_ENTRIES = [
15291
16100
  summary: "install the agent instruction surfaces + optional staged hook",
15292
16101
  usage: "mjolnir install [--staged-hook] [--dry-run] [--force]",
15293
16102
  examples: ["mjolnir install --dry-run", "mjolnir install --staged-hook"]
16103
+ },
16104
+ {
16105
+ verb: "mcp",
16106
+ summary: "run as an MCP server over stdio (scan / explain / diff tools)",
16107
+ usage: "mjolnir mcp",
16108
+ examples: ["mjolnir mcp"]
15294
16109
  }
15295
16110
  ];
15296
16111
  /** Scan-flag entries documented per-flag via the overview. */
@@ -15455,7 +16270,8 @@ const GROUPS = [
15455
16270
  "explain",
15456
16271
  "why",
15457
16272
  "handoff",
15458
- "install"
16273
+ "install",
16274
+ "mcp"
15459
16275
  ]
15460
16276
  }
15461
16277
  ];
@@ -15520,7 +16336,7 @@ function renderRootHelp(schemaVersion = 1) {
15520
16336
  }
15521
16337
  //#endregion
15522
16338
  //#region src/commands/debt.ts
15523
- const ui$11 = plainContext();
16339
+ const ui$9 = plainContext();
15524
16340
  /** Cost model (documented, conservative): hours/quarter per occurrence. */
15525
16341
  const COST_MODEL = {
15526
16342
  "QA-TEST-004": {
@@ -15601,7 +16417,7 @@ function computeDebt(result) {
15601
16417
  function renderDebt(result) {
15602
16418
  const { classes, totalHours } = computeDebt(result);
15603
16419
  const lines = [];
15604
- lines.push(sectionHeader("TEST DEBT REGISTER", ui$11));
16420
+ lines.push(sectionHeader("TEST DEBT REGISTER", ui$9));
15605
16421
  lines.push("");
15606
16422
  if (classes.length === 0) {
15607
16423
  lines.push("No tracked debt classes found — the suite is clean.");
@@ -15611,7 +16427,7 @@ function renderDebt(result) {
15611
16427
  rows[0] = `DEBT CLASS COUNT EST. HOURS/QUARTER`;
15612
16428
  for (const c of classes) rows.push(`${c.label.padEnd(26)} ${String(c.count).padStart(5)} ${c.estHoursPerQuarter.toFixed(1).padStart(8)}`);
15613
16429
  rows.push(`TOTAL ESTIMATED DRAG: ~${totalHours.toFixed(1)} engineer-hours/qtr`);
15614
- for (const row of panel(rows, ui$11)) lines.push(row);
16430
+ for (const row of panel(rows, ui$9)) lines.push(row);
15615
16431
  lines.push("");
15616
16432
  lines.push("Cost model is conservative and documented in src/commands/debt.ts.");
15617
16433
  return lines.join("\n");
@@ -15684,723 +16500,460 @@ const RULE_FAMILIES = [
15684
16500
  dir: "webdriverio",
15685
16501
  category: "QA-WDIO",
15686
16502
  appliesTo: "test-files"
15687
- },
15688
- {
15689
- token: "PPTR",
15690
- dir: "puppeteer",
15691
- category: "QA-PPTR",
15692
- appliesTo: "test-files"
15693
- },
15694
- {
15695
- token: "APM",
15696
- dir: "apm",
15697
- category: "QA-APM",
15698
- appliesTo: "test-files"
15699
- }
15700
- ];
15701
- /** The full ID regex — derived from the table, so doctor and create-rule
15702
- * can never disagree about which families exist. */
15703
- const RULE_ID_RE = new RegExp(`^QA-(?:${RULE_FAMILIES.map((f) => f.token).join("|")})-\\d{3}$`);
15704
- function familyByToken(token) {
15705
- const upper = token.toUpperCase();
15706
- return RULE_FAMILIES.find((f) => f.token === upper);
15707
- }
15708
- //#endregion
15709
- //#region src/commands/create-rule.ts
15710
- /**
15711
- * `mjolnir create-rule <ID> --title "..."` — rule scaffold generator
15712
- * (Tier 6 #34, Contribution Surface Engineering).
15713
- *
15714
- * Generates the four files every rule MUST ship with (anti-creep law):
15715
- * src/rules/<family>/qa-<id-lower>.ts — the rule
15716
- * tests/fixtures/<ID>/must-fire/ — at least one must-fire fixture
15717
- * tests/fixtures/<ID>/must-not-fire/ — at least one must-not fixture
15718
- * and prints the exact registry edit to make.
15719
- *
15720
- * The generated rule intentionally FAILS its fixtures until the author
15721
- * implements it — you cannot ship a stub.
15722
- */
15723
- const ui$10 = plainContext();
15724
- function parseId(id) {
15725
- if (!RULE_ID_RE.exec(id)) return null;
15726
- const family = familyByToken(id.slice(3, id.length - 4));
15727
- return {
15728
- family: family.dir,
15729
- dir: family.dir,
15730
- category: family.category,
15731
- appliesTo: family.appliesTo,
15732
- num: id.slice(-3),
15733
- lower: id.toLowerCase()
15734
- };
15735
- }
15736
- function createRuleScaffold(input, rootDir) {
15737
- const parsed = parseId(input.id);
15738
- if (!parsed) return {
15739
- ok: false,
15740
- 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).",
15741
- files: [],
15742
- registryEdit: ""
15743
- };
15744
- if (!input.title || input.title.trim().length === 0) return {
15745
- ok: false,
15746
- error: "A --title is required.",
15747
- files: [],
15748
- registryEdit: ""
15749
- };
15750
- const dirRel = join("src", "rules", parsed.dir);
15751
- const ruleRel = join(dirRel, `${parsed.lower}.ts`);
15752
- const absRule = join(rootDir, ruleRel);
15753
- if (existsSync(absRule)) return {
15754
- ok: false,
15755
- error: `Rule file already exists: ${ruleRel}`,
15756
- files: [],
15757
- registryEdit: ""
15758
- };
15759
- mkdirSync(join(rootDir, "src", "rules", parsed.dir), { recursive: true });
15760
- const files = [];
15761
- writeFileAtomic(absRule, `/**
15762
- * ${input.id} — ${input.title}.
15763
- *
15764
- * TODO(implement): replace the placeholder below. The rule currently
15765
- * returns no findings on purpose so the fixture harness FAILS until
15766
- * real detection logic lands (anti-creep law §18.1).
15767
- */
15768
-
15769
- import { defineRule } from "../rule.js";
15770
- import type { Finding } from "../../types.js";
15771
-
15772
- export const ${camel(parsed.lower)} = defineRule({
15773
- id: "${input.id}",
15774
- category: "${parsed.category}",
15775
- title: ${JSON.stringify(input.title)},
15776
- severity: "warning",
15777
- confidence: "medium",
15778
- findingType: "heuristic-risk",
15779
- qaImpact: "HYGIENE",
15780
- appliesTo: ${JSON.stringify(parsed.appliesTo)},
15781
- run(ctx) {
15782
- const findings: Omit<Finding, "ruleId" | "category">[] = [];
15783
- void ctx; // TODO: implement detection over ctx.path / ctx.text
15784
- return findings;
15785
- },
15786
- });
15787
- `);
15788
- files.push(ruleRel);
15789
- for (const direction of ["must-fire", "must-not-fire"]) {
15790
- const dirRel = join("tests", "fixtures", input.id, direction);
15791
- const absDir = join(rootDir, dirRel);
15792
- mkdirSync(absDir, { recursive: true });
15793
- const isPy = parsed.family === "python";
15794
- const name = isPy ? `example.${direction}.py` : `example.${direction}.ts`;
15795
- const content = isPy ? `# ${input.id} ${direction} fixture — replace with a real case.\n` : `// ${input.id} ${direction} fixture — replace with a real case.\n`;
15796
- writeFileAtomic(join(absDir, name), content);
15797
- files.push(join(dirRel, name));
15798
- }
15799
- const exportName = camel(parsed.lower);
15800
- return {
15801
- ok: true,
15802
- files,
15803
- registryEdit: [
15804
- `// 1. Add import to src/rules/index.ts:`,
15805
- `import { ${exportName} } from "./${parsed.dir}/${parsed.lower}.js";`,
15806
- `// 2. Add to the RULES array:`,
15807
- ` ${exportName},`
15808
- ].join("\n")
15809
- };
15810
- }
15811
- function camel(idLower) {
15812
- return idLower.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
15813
- }
15814
- function renderScaffoldReport(result) {
15815
- if (!result.ok) return `create-rule failed: ${result.error}`;
15816
- const lines = [];
15817
- lines.push(sectionHeader("RULE SCAFFOLD CREATED", ui$10));
15818
- lines.push("");
15819
- for (const f of result.files) lines.push(` + ${f}`);
15820
- lines.push("");
15821
- lines.push(result.registryEdit);
15822
- lines.push("");
15823
- 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)");
15824
- lines.push("");
15825
- 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.");
15826
- return lines.join("\n");
15827
- }
15828
- //#endregion
15829
- //#region src/commands/handover.ts
15830
- const ui$9 = plainContext();
15831
- const FAKE_GREEN_RULES = /* @__PURE__ */ new Set([
15832
- "QA-TEST-003",
15833
- "QA-PY-003",
15834
- "QA-TEST-010",
15835
- "QA-PY-006",
15836
- "QA-TQUAL-002",
15837
- "QA-PY-012"
15838
- ]);
15839
- const FLAKY_RULES = /* @__PURE__ */ new Set([
15840
- "QA-TEST-004",
15841
- "QA-PY-005",
15842
- "QA-PW-118",
15843
- "QA-PW-114",
15844
- "QA-TEST-006",
15845
- "QA-ENV-001"
15846
- ]);
15847
- const CI_TRUST_RULES = /* @__PURE__ */ new Set([
15848
- "QA-CI-001",
15849
- "QA-CI-002",
15850
- "QA-CI-005",
15851
- "QA-CI-007",
15852
- "QA-CI-008"
15853
- ]);
15854
- /** Group findings by directory to name "solid" areas. */
15855
- function dirOf(file) {
15856
- const idx = file.lastIndexOf("/");
15857
- return idx === -1 ? file : file.slice(0, idx);
15858
- }
15859
- function buildHandover(scan, forensics) {
15860
- const fakeGreen = [];
15861
- const flaky = [];
15862
- const ciTrust = [];
15863
- const touchedDirs = /* @__PURE__ */ new Map();
15864
- for (const f of scan.findings) {
15865
- if (FAKE_GREEN_RULES.has(f.ruleId)) fakeGreen.push(f);
15866
- else if (FLAKY_RULES.has(f.ruleId)) flaky.push(f);
15867
- else if (CI_TRUST_RULES.has(f.ruleId)) ciTrust.push(f);
15868
- const d = dirOf(f.file);
15869
- touchedDirs.set(d, (touchedDirs.get(d) ?? 0) + 1);
15870
- }
15871
- const sections = [];
15872
- const flakyFromRuns = forensics?.verdicts.filter((v) => v.passedOnRetry || v.everFailed) ?? [];
15873
- const flakyItems = [];
15874
- 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`);
15875
- for (const f of flaky.slice(0, 3)) flakyItems.push(`${f.file}:${f.line} — ${f.message}`);
15876
- if (flakyItems.length > 0) sections.push({
15877
- heading: "🔴 Known flaky / timing-sensitive",
15878
- items: flakyItems
15879
- });
15880
- if (fakeGreen.length > 0) sections.push({
15881
- heading: "🟡 Fake-green suspects (tests that can't fail)",
15882
- items: fakeGreen.slice(0, 5).map((f) => `${f.file}:${f.line} — ${f.message}`)
15883
- });
15884
- if (ciTrust.length > 0) sections.push({
15885
- heading: "⚠ CI trust warnings (green ≠ verified)",
15886
- items: ciTrust.slice(0, 4).map((f) => `${f.file}:${f.line} — ${f.message}`)
15887
- });
15888
- if (new Set(scan.findings.map((f) => f.file)).size === 0 && scan.score !== null && scan.score >= 90 || scan.findings.length === 0) sections.push({
15889
- heading: "🟢 Solid foundation",
15890
- items: ["No tracked anti-patterns found in scanned specs — good place to start contributing."]
15891
- });
15892
- const totalIssues = fakeGreen.length + flaky.length + ciTrust.length + (forensics?.flakyTests ?? 0);
15893
- return {
15894
- sections,
15895
- 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.`
15896
- };
15897
- }
15898
- function renderHandover(map) {
15899
- const lines = [];
15900
- lines.push(sectionHeader("WELCOME TO THE TEST SUITE — WHAT YOU NEED TO KNOW", ui$9));
15901
- lines.push("");
15902
- lines.push(map.summaryLine);
15903
- for (const s of map.sections) {
15904
- lines.push("");
15905
- lines.push(s.heading);
15906
- for (const item of s.items) lines.push(` • ${item}`);
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"
15907
16515
  }
15908
- lines.push("");
15909
- lines.push("Generated by mjolnir handover — re-run after big refactors.");
15910
- return lines.join("\n");
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);
15911
16523
  }
15912
16524
  //#endregion
15913
- //#region src/commands/impact.ts
16525
+ //#region src/commands/create-rule.ts
15914
16526
  /**
15915
- * `mjolnir impact` — Sprint 6 Task 23 (Master-Stabilization-Plan.md).
16527
+ * `mjolnir create-rule <ID> --title "..."` — rule scaffold generator
16528
+ * (Tier 6 #34, Contribution Surface Engineering).
15916
16529
  *
15917
- * Answers "what would have burned you": compares the current scan against
15918
- * an earlier point in this repo's own git history and reports anti-patterns
15919
- * that were actually removed (evidence: findings present at the base ref,
15920
- * absent now, backed by a real file+message match) plus new debt introduced
15921
- * since then. That is the entire honesty-safe surface this command can
15922
- * stand behind with real evidence.
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.
15923
16535
  *
15924
- * HARD CONSTRAINT (Honesty Core, non-negotiable per the plan): every number
15925
- * here is evidence-backed from data actually present on disk, or the field
15926
- * is UNKNOWN. This command never estimates or extrapolates "hours saved" or
15927
- * "CI minutes saved" — inventing such a number would be worse than useless,
15928
- * it would be the exact kind of fake-precision this product exists to
15929
- * catch in *other* tools. Local-only, zero network (verified by
15930
- * tests/privacy-network-isolation.spec.ts, which scans this file too).
16536
+ * The generated rule intentionally FAILS its fixtures until the author
16537
+ * implements it — you cannot ship a stub.
15931
16538
  */
15932
16539
  const ui$8 = plainContext();
15933
- /** The S1-resolved absolute git binary, or the bare name to fail on. */
15934
- function gitExe() {
15935
- return resolveGitPath() ?? "git";
15936
- }
15937
- function git(root, args) {
15938
- try {
15939
- return execFileSync(gitExe(), [
15940
- "-C",
15941
- root,
15942
- ...args
15943
- ], {
15944
- encoding: "utf8",
15945
- stdio: [
15946
- "ignore",
15947
- "pipe",
15948
- "ignore"
15949
- ],
15950
- timeout: 3e4
15951
- });
15952
- } catch {
15953
- return null;
15954
- }
15955
- }
15956
- /** Like git(), but returns the raw bytes — no utf8 decode round-trip. */
15957
- function gitBuffer(root, args) {
15958
- try {
15959
- return execFileSync(gitExe(), [
15960
- "-C",
15961
- root,
15962
- ...args
15963
- ], {
15964
- encoding: "buffer",
15965
- stdio: [
15966
- "ignore",
15967
- "pipe",
15968
- "ignore"
15969
- ],
15970
- timeout: 3e4
15971
- });
15972
- } catch {
15973
- return null;
15974
- }
15975
- }
15976
- /** Fingerprint a finding for cross-commit matching (line numbers shift). */
15977
- function fingerprint$2(f) {
15978
- return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
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()
16550
+ };
15979
16551
  }
15980
- async function computeImpact(root, options) {
15981
- 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."];
15982
- if (!existsSync(join(root, ".git"))) return {
15983
- hasComparison: false,
15984
- unknownReason: "not-a-git-repo",
15985
- resolved: [],
15986
- introduced: [],
15987
- unknownFacts
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: ""
15988
16559
  };
15989
- let baseRef = options.since;
15990
- if (!baseRef) baseRef = git(root, ["rev-parse", "HEAD~1"])?.trim() ?? git(root, [
15991
- "merge-base",
15992
- "HEAD",
15993
- options.baseBranch ?? "main"
15994
- ])?.trim() ?? void 0;
15995
- else baseRef = git(root, ["rev-parse", baseRef])?.trim() ?? baseRef;
15996
- if (!baseRef) return {
15997
- hasComparison: false,
15998
- unknownReason: "no-prior-commit",
15999
- resolved: [],
16000
- introduced: [],
16001
- unknownFacts
16560
+ if (!input.title || input.title.trim().length === 0) return {
16561
+ ok: false,
16562
+ error: "A --title is required.",
16563
+ files: [],
16564
+ registryEdit: ""
16002
16565
  };
16003
- const headRef = git(root, ["rev-parse", "HEAD"])?.trim() ?? "HEAD";
16004
- if (baseRef === headRef) return {
16005
- hasComparison: false,
16006
- unknownReason: options.since ? "base-equals-head" : "no-prior-commit",
16007
- baseRef,
16008
- headRef,
16009
- resolved: [],
16010
- introduced: [],
16011
- unknownFacts
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: ""
16012
16574
  };
16013
- let tmpDir;
16014
- try {
16015
- tmpDir = mkdtempSync(join(tmpdir(), "mjolnir-impact-"));
16016
- } catch {
16017
- return {
16018
- hasComparison: false,
16019
- unknownReason: "tree-materialize-failed",
16020
- baseRef,
16021
- headRef,
16022
- resolved: [],
16023
- introduced: [],
16024
- unknownFacts
16025
- };
16026
- }
16027
- let baseResult;
16028
- let baseTreeTruncated;
16029
- try {
16030
- const treeListing = git(root, [
16031
- "ls-tree",
16032
- "-r",
16033
- "--name-only",
16034
- "-z",
16035
- baseRef
16036
- ]);
16037
- if (treeListing === null) return {
16038
- hasComparison: false,
16039
- unknownReason: "tree-listing-failed",
16040
- baseRef,
16041
- headRef,
16042
- resolved: [],
16043
- introduced: [],
16044
- unknownFacts
16045
- };
16046
- const paths = treeListing.split("\0").filter(Boolean);
16047
- const MAX_FILES = 2e4;
16048
- const truncated = paths.length > MAX_FILES;
16049
- for (const relPath of paths.slice(0, MAX_FILES)) {
16050
- const blob = gitBuffer(root, ["show", `${baseRef}:${relPath}`]);
16051
- if (blob === null) continue;
16052
- const dest = join(tmpDir, relPath);
16053
- mkdirSync(dirname(dest), { recursive: true });
16054
- writeFileSync(dest, blob);
16055
- }
16056
- if (truncated) {
16057
- baseTreeTruncated = {
16058
- scanned: Math.min(paths.length, MAX_FILES),
16059
- total: paths.length
16060
- };
16061
- 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.`);
16062
- }
16063
- baseResult = await options.runScan(tmpDir);
16064
- } catch {
16065
- return {
16066
- hasComparison: false,
16067
- unknownReason: "tree-materialize-failed",
16068
- baseRef,
16069
- headRef,
16070
- resolved: [],
16071
- introduced: [],
16072
- unknownFacts
16073
- };
16074
- } finally {
16075
- try {
16076
- rmSync(tmpDir, {
16077
- recursive: true,
16078
- force: true
16079
- });
16080
- } catch {}
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));
16081
16614
  }
16082
- const headResult = await options.runScan(root);
16083
- const baseSet = /* @__PURE__ */ new Map();
16084
- for (const f of baseResult.findings) baseSet.set(fingerprint$2(f), f);
16085
- const headSet = /* @__PURE__ */ new Map();
16086
- for (const f of headResult.findings) headSet.set(fingerprint$2(f), f);
16087
- const resolved = [];
16088
- for (const [key, f] of baseSet) if (!headSet.has(key)) resolved.push({
16089
- ruleId: f.ruleId,
16090
- file: f.file,
16091
- message: f.message
16092
- });
16093
- const introduced = [];
16094
- for (const [key, f] of headSet) if (!baseSet.has(key)) introduced.push({
16095
- ruleId: f.ruleId,
16096
- file: f.file,
16097
- message: f.message
16098
- });
16615
+ const exportName = camel(parsed.lower);
16099
16616
  return {
16100
- hasComparison: true,
16101
- baseRef,
16102
- headRef,
16103
- ...baseTreeTruncated ? { baseTreeTruncated } : {},
16104
- resolved: resolved.sort((a, b) => a.file.localeCompare(b.file)),
16105
- introduced: introduced.sort((a, b) => a.file.localeCompare(b.file)),
16106
- unknownFacts
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")
16107
16625
  };
16108
16626
  }
16109
- function renderImpact(report) {
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}`;
16110
16632
  const lines = [];
16111
- lines.push(sectionHeader("IMPACT REPORT", ui$8));
16633
+ lines.push(sectionHeader("RULE SCAFFOLD CREATED", ui$8));
16112
16634
  lines.push("");
16113
- if (!report.hasComparison) {
16114
- lines.push(`UNKNOWN — no comparison could be made (${report.unknownReason ?? "unknown reason"}).`);
16115
- lines.push("This is reported as UNKNOWN rather than a fabricated zero: mjolnir");
16116
- lines.push("never invents a number it cannot prove.");
16117
- lines.push("");
16118
- for (const fact of report.unknownFacts) lines.push(`UNKNOWN: ${fact}`);
16119
- return lines.join("\n");
16120
- }
16121
- lines.push(`Comparing ${report.baseRef?.slice(0, 12)} → ${report.headRef?.slice(0, 12)}`);
16635
+ for (const f of result.files) lines.push(` + ${f}`);
16122
16636
  lines.push("");
16123
- if (report.baseTreeTruncated) {
16124
- lines.push(`UNKNOWN: base tree truncated — scanned ${report.baseTreeTruncated.scanned} of ${report.baseTreeTruncated.total} paths.`);
16125
- lines.push("Files beyond the cap were never scanned at base; their findings are misreported as new debt.");
16126
- lines.push("");
16127
- }
16128
- if (report.resolved.length === 0) lines.push("FIXED SINCE BASE: none found.");
16129
- else {
16130
- lines.push(`FIXED SINCE BASE (${report.resolved.length}) — real, evidence-backed:`);
16131
- for (const f of report.resolved) lines.push(` ✓ ${f.ruleId} · ${f.file} — ${f.message}`);
16132
- }
16637
+ lines.push(result.registryEdit);
16133
16638
  lines.push("");
16134
- if (report.introduced.length === 0) lines.push("NEW DEBT SINCE BASE: none found.");
16135
- else {
16136
- lines.push(`NEW DEBT SINCE BASE (${report.introduced.length}):`);
16137
- for (const f of report.introduced) lines.push(` + ${f.ruleId} · ${f.file} — ${f.message}`);
16138
- }
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)");
16139
16640
  lines.push("");
16140
- for (const fact of report.unknownFacts) lines.push(`UNKNOWN: ${fact}`);
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.");
16141
16642
  return lines.join("\n");
16142
16643
  }
16143
16644
  //#endregion
16144
- //#region src/engine/resolution.ts
16145
- /** Correlation identity (v1-compatible): ruleId\0file\0message. */
16146
- function fingerprint$1(entry) {
16147
- return `${entry.ruleId}\u0000${entry.file}\u0000${entry.message}`;
16148
- }
16149
- /**
16150
- * The §15 ordered algorithm. Pure; first match wins.
16151
- */
16152
- function resolve$1(input) {
16153
- const { entry, baseline, current } = input;
16154
- const fp = fingerprint$1(entry);
16155
- const comparedAgainst = input.baselineCommit ?? baseline.commit ?? "unknown baseline";
16156
- if (current.partial || current.analysisStatus.rules !== "complete") return {
16157
- status: "INCONCLUSIVE",
16158
- cause: "partial",
16159
- comparedAgainst
16160
- };
16161
- if (input.crashedRuleIds?.has(entry.ruleId)) return {
16162
- status: "INCONCLUSIVE",
16163
- cause: "crash",
16164
- comparedAgainst
16165
- };
16166
- if (input.skippedFiles?.has(entry.file)) return {
16167
- status: "INCONCLUSIVE",
16168
- cause: "skipped",
16169
- comparedAgainst
16170
- };
16171
- if (input.suppressed?.has(`${entry.ruleId}\u0000${entry.file}`)) return {
16172
- status: "SUPPRESSED",
16173
- comparedAgainst
16174
- };
16175
- if (input.excludedFiles?.has(entry.file)) return {
16176
- status: "DISAPPEARED-NON-FIX",
16177
- cause: "excluded",
16178
- comparedAgainst
16179
- };
16180
- const entryRev = entry.detectorRevision;
16181
- if (entryRev === void 0) return {
16182
- status: "INCONCLUSIVE",
16183
- cause: "legacy-baseline",
16184
- comparedAgainst
16185
- };
16186
- const registryRev = input.registryRevisions.get(entry.ruleId);
16187
- if (registryRev === void 0) return {
16188
- status: "DISAPPEARED-NON-FIX",
16189
- cause: "retired",
16190
- comparedAgainst
16191
- };
16192
- if (registryRev !== entryRev) return {
16193
- status: "INCONCLUSIVE",
16194
- cause: "revision-changed",
16195
- comparedAgainst
16196
- };
16197
- if (current.findings.some((f) => fingerprint$1(f) === fp)) return {
16198
- status: "STILL-PRESENT",
16199
- comparedAgainst
16200
- };
16201
- return {
16202
- status: "VERIFIED-RESOLVED",
16203
- comparedAgainst
16204
- };
16205
- }
16206
- /** The §15 rendering law: "FIXED" only for VERIFIED-RESOLVED. */
16207
- function renderResolution(r) {
16208
- switch (r.status) {
16209
- case "VERIFIED-RESOLVED": return "FIXED SINCE BASELINE (verified by a complete same-revision scan)";
16210
- case "STILL-PRESENT": return "STILL PRESENT";
16211
- case "SUPPRESSED": return "SUPPRESSED (active ignore entry)";
16212
- case "INCONCLUSIVE": return `INCONCLUSIVE (${r.cause})`;
16213
- case "DISAPPEARED-NON-FIX": return `DISAPPEARED — NOT A FIX (${r.cause})`;
16214
- }
16215
- }
16216
- //#endregion
16217
- //#region src/commands/baseline.ts
16218
- /**
16219
- * `mjolnir baseline` / `mjolnir diff` — Sprint 6 Task 24
16220
- * (Master-Stabilization-Plan.md).
16221
- *
16222
- * Implements Plan.md Phase 10 / §24's key insight: existing debt should
16223
- * not block every PR — only NEW or WORSENED debt should. `baseline`
16224
- * snapshots the current finding set to disk; `diff` compares the current
16225
- * scan against that snapshot and reports only what changed.
16226
- *
16227
- * Findings are matched across scans by a stable fingerprint (ruleId +
16228
- * file + message), not by line number — line numbers shift constantly as
16229
- * a file is edited, so matching on them would report unrelated churn as
16230
- * "new" debt and miss genuinely new findings that happen to land on a
16231
- * previously-flagged line.
16232
- *
16233
- * Storage: .mjolnir/baseline.json (local; not gitignored — only
16234
- * .mjolnir/logs/ is, see .gitignore). A team CAN commit this
16235
- * file if they want a shared baseline; that's a deliberate choice this
16236
- * command does not make for them.
16237
- */
16645
+ //#region src/commands/handover.ts
16238
16646
  const ui$7 = plainContext();
16239
- const DEFAULT_BASELINE_PATH = join(".mjolnir", "baseline.json");
16240
- /**
16241
- * Registry-declared detector revisions (§17): a baseline entry whose
16242
- * revision differs from today's registry is INCONCLUSIVE(revision-
16243
- * changed), never resolved. Omitted declarations mean revision 1 (the
16244
- * documented RuleMeta default for first-generation detectors); a
16245
- * ruleId ABSENT from this map means the rule is retired.
16246
- */
16247
- const REGISTRY_REVISIONS = new Map(RULES.map((r) => [r.id, r.detectorRevision ?? 1]));
16248
- /**
16249
- * Correlation identity for before/after comparison (agent-handoff plan
16250
- * §5.2): ruleId + file + message, deliberately EXCLUDING `line` — a
16251
- * source edit that shifts a finding still correlates. file:line is an
16252
- * occurrence location, not a durable identity; message rewording,
16253
- * file renames and rule-id changes correlate as resolved+new
16254
- * (documented limitation). Exported for the handoff verification
16255
- * contract — do not duplicate this algorithm.
16256
- */
16257
- function fingerprint(f) {
16258
- 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);
16259
16674
  }
16260
- function buildBaseline(result, commit) {
16261
- return {
16262
- schemaVersion: 1,
16263
- capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
16264
- commit,
16265
- ...result.score !== null ? { score: result.score } : {},
16266
- findings: result.findings.map((f) => ({
16267
- ruleId: f.ruleId,
16268
- file: f.file,
16269
- message: f.message,
16270
- severity: f.severity,
16271
- ...f.detectorRevision !== void 0 ? { detectorRevision: f.detectorRevision } : {}
16272
- }))
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);
16709
+ return {
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.`
16273
16712
  };
16274
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
16275
16730
  /**
16276
- * Bug-audit L7 / decision D3: overwriting an existing baseline used to
16277
- * happen silently — a stale re-capture on a dirty tree made `diff`
16278
- * report all old debt as new with no way to see what was lost. The
16279
- * previous baseline is now backed up to `<path>.bak` and the caller
16280
- * 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).
16281
16747
  */
16282
- function saveBaseline(result, commit, outPath) {
16283
- mkdirSync(dirname(outPath), { recursive: true });
16284
- const existed = existsSync(outPath);
16285
- let backupPath;
16286
- if (existed) {
16287
- backupPath = `${outPath}.bak`;
16288
- copyFileSync(outPath, backupPath);
16289
- }
16290
- writeFileAtomic(outPath, JSON.stringify(buildBaseline(result, commit), null, 2) + "\n");
16291
- return {
16292
- path: outPath,
16293
- replaced: existed,
16294
- ...backupPath !== void 0 ? { backupPath } : {}
16295
- };
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";
16296
16752
  }
16297
- function loadBaseline(path, onWarning) {
16298
- if (!existsSync(path)) return null;
16753
+ function git(root, args) {
16299
16754
  try {
16300
- const parsed = JSON.parse(readFileSync(path, "utf8"));
16301
- if (typeof parsed === "object" && parsed !== null && "findings" in parsed && Array.isArray(parsed.findings)) {
16302
- const file = parsed;
16303
- const version = parsed.schemaVersion;
16304
- if (version === void 0) onWarning?.("baseline file has no schemaVersion (pre-versioning format) — treated as v1.");
16305
- else if (version !== 1) {
16306
- onWarning?.(`baseline file declares schemaVersion ${JSON.stringify(version)}; this Mjölnir understands v1 — baseline ignored (upgrade Mjölnir to diff it).`);
16307
- return null;
16308
- }
16309
- const rawScore = parsed.score;
16310
- const score = typeof rawScore === "number" && Number.isFinite(rawScore) ? rawScore : void 0;
16311
- file.findings = file.findings.filter((f) => typeof f === "object" && f !== null && typeof f.ruleId === "string" && typeof f.file === "string" && typeof f.message === "string");
16312
- return score === void 0 ? file : {
16313
- ...file,
16314
- score
16315
- };
16316
- }
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 {
16317
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
+ });
16318
16788
  } catch {
16319
16789
  return null;
16320
16790
  }
16321
16791
  }
16322
- function diffAgainstBaseline(result, baseline) {
16323
- if (!baseline) return {
16324
- hasBaseline: false,
16325
- newFindings: [],
16326
- resolvedFindings: [],
16327
- 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
16328
16804
  };
16329
- const baseSet = /* @__PURE__ */ new Map();
16330
- for (const f of baseline.findings) baseSet.set(fingerprint(f), f);
16331
- const headKeys = /* @__PURE__ */ new Set();
16332
- const newFindings = [];
16333
- let unchangedCount = 0;
16334
- for (const f of result.findings) {
16335
- const key = fingerprint(f);
16336
- headKeys.add(key);
16337
- if (baseSet.has(key)) unchangedCount++;
16338
- 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
+ };
16339
16842
  }
16340
- const resolvedFindings = [];
16341
- for (const [key, f] of baseSet) if (!headKeys.has(key)) {
16342
- const resolution = resolve$1({
16343
- entry: f,
16344
- baseline,
16345
- current: result,
16346
- registryRevisions: REGISTRY_REVISIONS
16347
- });
16348
- resolvedFindings.push({
16349
- ...f,
16350
- resolution
16351
- });
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 {}
16352
16897
  }
16353
- return {
16354
- hasBaseline: true,
16355
- baselineCapturedAt: baseline.capturedAt,
16356
- baselineCommit: baseline.commit,
16357
- ...baseline.score !== void 0 ? { baselineScore: baseline.score } : {},
16358
- newFindings,
16359
- resolvedFindings,
16360
- unchangedCount
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
+ });
16915
+ return {
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
16361
16923
  };
16362
16924
  }
16363
- function renderBaselineSaved(path, count, replaced) {
16364
- const lines = [
16365
- sectionHeader("BASELINE SAVED", ui$7),
16366
- "",
16367
- `Captured ${count} finding${count === 1 ? "" : "s"} to ${path}.`
16368
- ];
16369
- if (replaced?.backupPath !== void 0) lines.push(`Replaced an existing baseline — the previous one was saved to ${replaced.backupPath}.`);
16370
- lines.push(nextStep("mjolnir diff", ui$7) + " — see only what's new.");
16371
- return lines.join("\n");
16372
- }
16373
- function renderBaselineDiff(diff) {
16925
+ function renderImpact(report) {
16374
16926
  const lines = [];
16375
- lines.push(sectionHeader("DIFF AGAINST BASELINE", ui$7));
16927
+ lines.push(sectionHeader("IMPACT REPORT", ui$6));
16376
16928
  lines.push("");
16377
- if (!diff.hasBaseline) {
16378
- lines.push("UNKNOWN — no baseline found.");
16379
- 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}`);
16380
16935
  return lines.join("\n");
16381
16936
  }
16382
- lines.push(`Baseline captured ${diff.baselineCapturedAt ?? "unknown time"} at commit ${diff.baselineCommit ?? "unknown"}.`);
16383
- 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)}`);
16384
16938
  lines.push("");
16385
- 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.");
16386
16945
  else {
16387
- lines.push(`NEW OR WORSENED DEBT (${diff.newFindings.length}) — this is what should block this PR:`);
16388
- 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}`);
16389
16948
  }
16390
16949
  lines.push("");
16391
- if (diff.resolvedFindings.length > 0) {
16392
- const verified = diff.resolvedFindings.filter((f) => f.resolution.status === "VERIFIED-RESOLVED");
16393
- const unresolved = diff.resolvedFindings.filter((f) => f.resolution.status !== "VERIFIED-RESOLVED");
16394
- if (verified.length > 0) {
16395
- lines.push(`FIXED SINCE BASELINE (${verified.length}):`);
16396
- for (const f of verified) lines.push(` ✓ ${f.ruleId} (${f.severity}) · ${f.file} — ${f.message}`);
16397
- lines.push("");
16398
- }
16399
- if (unresolved.length > 0) {
16400
- lines.push(`DISAPPEARED — NOT CLASSIFIED AS FIXED (${unresolved.length}):`);
16401
- for (const f of unresolved) lines.push(` ${renderResolution(f.resolution)} · ${f.ruleId} (${f.severity}) · ${f.file} — ${f.message}`);
16402
- }
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}`);
16403
16954
  }
16955
+ lines.push("");
16956
+ for (const fact of report.unknownFacts) lines.push(`UNKNOWN: ${fact}`);
16404
16957
  return lines.join("\n");
16405
16958
  }
16406
16959
  //#endregion
@@ -16431,7 +16984,7 @@ function renderBaselineDiff(diff) {
16431
16984
  * fix recorded by `diff`), announced once and never repeated. Display-only:
16432
16985
  * it does not change scores, exit codes or the JSON schema.
16433
16986
  */
16434
- const ui$6 = plainContext();
16987
+ const ui$5 = plainContext();
16435
16988
  const DEFAULT_STATS_PATH = join(".mjolnir", "stats.json");
16436
16989
  const MILESTONE_MESSAGES = {
16437
16990
  "first-clean-scan": "MILESTONE: first flawless scan recorded for this repo (score 100, zero findings).",
@@ -16519,13 +17072,13 @@ function saveStats(stats, outPath) {
16519
17072
  }
16520
17073
  function renderStats(stats) {
16521
17074
  const lines = [];
16522
- 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));
16523
17076
  lines.push("");
16524
17077
  if (!stats || stats.recordedFixEvents === 0) {
16525
17078
  lines.push("No fixes recorded yet.");
16526
17079
  lines.push("Fixes are counted here only when observed by mjolnir diff —", "capture a baseline first, then diff after making fixes:");
16527
- lines.push(nextStep("mjolnir baseline", ui$6));
16528
- lines.push(nextStep("mjolnir diff", ui$6));
17080
+ lines.push(nextStep("mjolnir baseline", ui$5));
17081
+ lines.push(nextStep("mjolnir diff", ui$5));
16529
17082
  lines.push("");
16530
17083
  lines.push("UNKNOWN: totals before tracking started. This command only counts");
16531
17084
  lines.push("what it has personally witnessed via mjolnir diff.");
@@ -16554,7 +17107,7 @@ function renderStats(stats) {
16554
17107
  *
16555
17108
  * Idempotent: existing files are reported, never overwritten.
16556
17109
  */
16557
- const ui$5 = plainContext();
17110
+ const ui$4 = plainContext();
16558
17111
  function runInit(rootDir, workspace, options = {}) {
16559
17112
  const steps = [];
16560
17113
  const nextCommands = [];
@@ -16604,7 +17157,7 @@ function runInit(rootDir, workspace, options = {}) {
16604
17157
  }
16605
17158
  function renderInit(result) {
16606
17159
  const lines = [];
16607
- lines.push(sectionHeader("MJÖLNIR INIT", ui$5));
17160
+ lines.push(sectionHeader("MJÖLNIR INIT", ui$4));
16608
17161
  lines.push("");
16609
17162
  for (const s of result.steps) {
16610
17163
  const icon = s.status === "advice" ? "·" : s.status === "exists" ? "=" : "-";
@@ -16613,7 +17166,7 @@ function renderInit(result) {
16613
17166
  if (result.nextCommands.length > 0) {
16614
17167
  lines.push("");
16615
17168
  lines.push("Next commands:");
16616
- 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));
16617
17170
  }
16618
17171
  lines.push("");
16619
17172
  lines.push("Existing files are never overwritten — init is safe to re-run.");
@@ -16631,7 +17184,7 @@ function tryReadPackageJson(rootDir) {
16631
17184
  }
16632
17185
  //#endregion
16633
17186
  //#region src/commands/pw-report.ts
16634
- const ui$4 = plainContext();
17187
+ const ui$3 = plainContext();
16635
17188
  function summarizePwRun(report) {
16636
17189
  const slowest = [...report.verdicts].sort((a, b) => b.totalDurationMs - a.totalDurationMs).slice(0, 5).map((v) => ({
16637
17190
  title: v.title,
@@ -16651,7 +17204,7 @@ function summarizePwRun(report) {
16651
17204
  }
16652
17205
  function renderPwRunSummary(s) {
16653
17206
  const lines = [];
16654
- lines.push(sectionHeader("MJÖLNIR — RUN SUMMARY", ui$4));
17207
+ lines.push(sectionHeader("MJÖLNIR — RUN SUMMARY", ui$3));
16655
17208
  lines.push("");
16656
17209
  lines.push(`${s.total} tests · ${s.passed} passed · ${s.failed} failed · ${s.skipped} skipped`);
16657
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)`);
@@ -16682,7 +17235,7 @@ function renderPwRunSummary(s) {
16682
17235
  * Everything else stays suggestion-only. No AST surgery on heuristic
16683
17236
  * findings — false fixes would break the brand promise.
16684
17237
  */
16685
- const ui$3 = plainContext();
17238
+ const ui$2 = plainContext();
16686
17239
  const MAX_FILE_BYTES = 524288;
16687
17240
  /**
16688
17241
  * Path-containment guard (adversarial-audit wave; hardened per audit
@@ -17043,7 +17596,7 @@ function fixVerified(edit, fixedText, originalText) {
17043
17596
  }
17044
17597
  function renderFixReport(results, dryRun) {
17045
17598
  const lines = [];
17046
- 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));
17047
17600
  lines.push("");
17048
17601
  if (results.length === 0) {
17049
17602
  lines.push("No safe auto-fixes available for these findings.");
@@ -17051,7 +17604,7 @@ function renderFixReport(results, dryRun) {
17051
17604
  return lines.join("\n");
17052
17605
  }
17053
17606
  for (const r of results) {
17054
- const icon = r.status === "applied" ? okIcon(ui$3) : r.status === "planned" ? "▸" : "✗";
17607
+ const icon = r.status === "applied" ? okIcon(ui$2) : r.status === "planned" ? "▸" : "✗";
17055
17608
  lines.push(`${icon} [${r.ruleId}] ${r.file}:${r.line} — ${r.description}`);
17056
17609
  }
17057
17610
  const applied = results.filter((r) => r.status === "applied").length;
@@ -17067,34 +17620,6 @@ function renderFixReport(results, dryRun) {
17067
17620
  return lines.join("\n");
17068
17621
  }
17069
17622
  //#endregion
17070
- //#region src/rules/measurement.ts
17071
- /** The rule's declared detector implementation revision (§07). */
17072
- function declaredDetectorRevision(rule) {
17073
- return rule.detectorRevision ?? 1;
17074
- }
17075
- /**
17076
- * A measurement exists AND was taken against the detector revision the
17077
- * rule declares now. A revision mismatch (stale) does NOT count — §07:
17078
- * stale → provisional → re-measure.
17079
- */
17080
- function hasValidMeasurement(rule) {
17081
- const m = MEASURED_FP[rule.id];
17082
- return m !== void 0 && m.detectorRevision === declaredDetectorRevision(rule);
17083
- }
17084
- /**
17085
- * The tier a rule effectively ships as: its declared tier, or — for the
17086
- * omitted-tier case — the measurement-dependent default (§11.2 Step 2).
17087
- */
17088
- function effectiveTier(rule) {
17089
- if (rule.tier !== void 0) return rule.tier;
17090
- if (hasValidMeasurement(rule)) return "core";
17091
- return "extended";
17092
- }
17093
- /** The §11.2 Step 2 PROVISIONAL display predicate. */
17094
- function isProvisional(rule) {
17095
- return effectiveTier(rule) === "extended" && !hasValidMeasurement(rule);
17096
- }
17097
- //#endregion
17098
17623
  //#region src/commands/doctor.ts
17099
17624
  /**
17100
17625
  * `mjolnir doctor` — self-audit of Mjolnir's own rule base.
@@ -17111,7 +17636,7 @@ function isProvisional(rule) {
17111
17636
  *
17112
17637
  * Exit codes reuse the frozen set: 0 healthy · 1 violations · 20 crash.
17113
17638
  */
17114
- const ui$2 = plainContext();
17639
+ const ui$1 = plainContext();
17115
17640
  const VALID_ID = RULE_ID_RE;
17116
17641
  function nonHiddenFiles(dir) {
17117
17642
  if (!existsSync(dir)) return [];
@@ -17310,7 +17835,7 @@ function runDoctorSelfAudit(fixturesRoot) {
17310
17835
  function renderDoctorReport(report) {
17311
17836
  const lines = [
17312
17837
  "",
17313
- sectionHeader("MJÖLNIR — SELF-AUDIT", ui$2),
17838
+ sectionHeader("MJÖLNIR — SELF-AUDIT", ui$1),
17314
17839
  ""
17315
17840
  ];
17316
17841
  for (const c of report.checks) {
@@ -17388,160 +17913,6 @@ function escapeMdCell(text) {
17388
17913
  return text.replaceAll("|", "\\|");
17389
17914
  }
17390
17915
  //#endregion
17391
- //#region src/commands/fixture-example.ts
17392
- /**
17393
- * Shared example-fixture selection for `mjolnir explain` (explain.ts)
17394
- * and the generated rule docs (rule-docs.ts) — one chooser so both
17395
- * surfaces always show the same example for the same rule. Extracted
17396
- * after the same bug-audit L9 fix had to be applied to both verbatim
17397
- * copies: duplicated fixture selection drifts, and a drift means
17398
- * `mjolnir explain <ID>` contradicts docs/rules/<ID>.md.
17399
- */
17400
- /**
17401
- * First (byte-stable) fixture file in a must-fire / must-not-fire
17402
- * directory, or null when the directory is absent or empty.
17403
- */
17404
- function firstFixtureFile(dir) {
17405
- if (!existsSync(dir)) return null;
17406
- const entries = readdirSync(dir).filter((f) => !f.startsWith(".")).sort();
17407
- return entries.length > 0 ? join(dir, entries[0]) : null;
17408
- }
17409
- //#endregion
17410
- //#region src/commands/explain.ts
17411
- /**
17412
- * `mjolnir explain <RULE-ID>` — implements Plan.md Sprint 1.3
17413
- * (Master-Stabilization-Plan Sprint 5, Task 19).
17414
- *
17415
- * For any registered rule, renders what is wrong, why it matters, the
17416
- * evidence level and confidence behind the verdict, the prescription,
17417
- * and how to verify the fix. This is a presentation layer only — no new
17418
- * detection logic. Every field it prints already exists on RuleMeta or
17419
- * comes from actually running the rule against its own committed
17420
- * must-fire fixture, so the example shown is real detector output, not
17421
- * hand-written prose that can drift from what the rule actually does.
17422
- */
17423
- const ui$1 = plainContext();
17424
- /**
17425
- * Runs the rule against its own must-fire fixture to get one real,
17426
- * concrete example finding. `fixturesRoot` defaults to this repo's own
17427
- * `tests/fixtures` — explain only has real examples to show when run
17428
- * from (or pointed at) a Mjolnir checkout; degrades honestly
17429
- * (exampleFinding left undefined) otherwise, same as `doctor`.
17430
- */
17431
- function explainRule(ruleId, fixturesRoot) {
17432
- const rule = getRule(ruleId);
17433
- if (!rule) return {
17434
- ok: false,
17435
- error: `Unknown rule ID "${ruleId}". Run \`mjolnir rules\` for the full catalog.`
17436
- };
17437
- const fixturePath = firstFixtureFile(join(fixturesRoot, ruleId, "must-fire"));
17438
- if (!fixturePath) return {
17439
- ok: true,
17440
- rule
17441
- };
17442
- let text;
17443
- try {
17444
- text = readFileSync(fixturePath, "utf8").replace(/\r\n/g, "\n");
17445
- } catch {
17446
- return {
17447
- ok: true,
17448
- rule
17449
- };
17450
- }
17451
- const normalizedPath = fixturePath.replaceAll("\\", "/");
17452
- let ast;
17453
- if (rule.appliesTo === "ci-workflows") try {
17454
- ast = parseWorkflow(text);
17455
- } catch {
17456
- return {
17457
- ok: true,
17458
- rule
17459
- };
17460
- }
17461
- let findings;
17462
- try {
17463
- const parsed = {
17464
- path: normalizedPath,
17465
- text,
17466
- ast
17467
- };
17468
- const codeText = computeCodeText(parsed, normalizedPath.endsWith(".py") ? "python" : normalizedPath.endsWith(".java") ? "java" : normalizedPath.endsWith(".cs") ? "csharp" : "typescript");
17469
- findings = rule.run({
17470
- ...parsed,
17471
- codeText
17472
- });
17473
- } catch {
17474
- return {
17475
- ok: true,
17476
- rule
17477
- };
17478
- }
17479
- const example = findings[0];
17480
- if (!example) return {
17481
- ok: true,
17482
- rule
17483
- };
17484
- return {
17485
- ok: true,
17486
- rule,
17487
- exampleFinding: example,
17488
- exampleFixturePath: fixturePath,
17489
- exampleFixtureRelPath: relative(fixturesRoot, fixturePath).replaceAll("\\", "/")
17490
- };
17491
- }
17492
- /**
17493
- * Default column budget when no width is supplied.
17494
- *
17495
- * `explain`'s prose used to be pushed as unbroken strings — the
17496
- * "HOW TO VERIFY THE FIX" paragraph alone is 150 columns — so every
17497
- * explanation overflowed a default terminal. Renderers here take a width
17498
- * rather than reading process.stdout, so output stays a pure function of
17499
- * its arguments (same rule the reporter's palette follows).
17500
- */
17501
- const DEFAULT_EXPLAIN_WIDTH = 80;
17502
- function renderExplain(result, width = DEFAULT_EXPLAIN_WIDTH) {
17503
- if (!result.ok || !result.rule) return `explain failed: ${result.error ?? "unknown error"}`;
17504
- const r = result.rule;
17505
- const evidenceLevel = r.evidenceLevel ?? deriveEvidenceLevel(r.findingType, r.confidence);
17506
- const lines = [];
17507
- /** Pushes prose indented two columns, wrapped to the budget. */
17508
- const pushBody = (text) => {
17509
- for (const seg of wrapText(text, Math.max(20, width - 2))) lines.push(` ${seg}`);
17510
- };
17511
- lines.push(sectionHeader(`${r.id} — ${r.title}`, ui$1));
17512
- lines.push("");
17513
- lines.push(`Severity: ${r.severity}`);
17514
- lines.push(`Confidence: ${r.confidence}`);
17515
- lines.push(`Tier: ${effectiveTier(r)}${isProvisional(r) ? " (PROVISIONAL)" : ""}`);
17516
- lines.push(`Evidence: ${evidenceLevel}`);
17517
- lines.push(`QA impact: ${QA_IMPACT_LABELS[r.qaImpact]} (${r.qaImpact})`);
17518
- const measured = MEASURED_FP[r.id];
17519
- 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)`);
17520
- if (r.falsePositiveRisk) lines.push(`FP risk: ${r.falsePositiveRisk} (author estimate)`);
17521
- if (r.languages?.length) lines.push(`Languages: ${r.languages.join(", ")}`);
17522
- if (r.frameworks?.length) lines.push(`Frameworks: ${r.frameworks.join(", ")}`);
17523
- lines.push("");
17524
- if (result.exampleFinding) {
17525
- const f = result.exampleFinding;
17526
- lines.push("WHAT WAS FOUND (real detector output, not a mockup)");
17527
- pushBody(f.message);
17528
- lines.push("");
17529
- lines.push("WHY IT MATTERS");
17530
- pushBody(f.why);
17531
- lines.push("");
17532
- lines.push("HOW TO FIX");
17533
- pushBody(f.fix);
17534
- lines.push("");
17535
- pushBody(`Example from this rule's own must-fire fixture: ${result.exampleFixtureRelPath ?? result.exampleFixturePath ?? "(unknown path)"}`);
17536
- } 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);
17537
- lines.push("");
17538
- lines.push("HOW TO VERIFY THE FIX");
17539
- 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.");
17540
- lines.push("");
17541
- lines.push(`Docs: mjolnir rules --md (full catalog, this rule included)`);
17542
- return lines.join("\n");
17543
- }
17544
- //#endregion
17545
17916
  //#region src/playwright/selector-health-types.ts
17546
17917
  /** Risk weights per locator shape (0 = resilient, higher = brittle). */
17547
17918
  const LOCATOR_RISK = {
@@ -17693,7 +18064,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
17693
18064
  * `scripts/sync-sarif-version.cjs` on release and guarded by
17694
18065
  * `tests/version-consistency.spec.ts` locally.
17695
18066
  */
17696
- const CLI_VERSION = "0.5.13";
18067
+ const CLI_VERSION = "0.5.15";
17697
18068
  function parseArgs(argv, onError) {
17698
18069
  const args = {
17699
18070
  target: ".",
@@ -18557,7 +18928,8 @@ const SUBCOMMANDS = /* @__PURE__ */ new Set([
18557
18928
  "doctor",
18558
18929
  "rules",
18559
18930
  "explain",
18560
- "doctor:playwright"
18931
+ "doctor:playwright",
18932
+ "mcp"
18561
18933
  ]);
18562
18934
  async function main(argv = process$1.argv.slice(2), io = {
18563
18935
  out,
@@ -18594,6 +18966,10 @@ async function main(argv = process$1.argv.slice(2), io = {
18594
18966
  if (argv[0] === "why") return runWhyCommand(argv.slice(1), io);
18595
18967
  if (argv[0] === "handoff") return runHandoffCommand(argv.slice(1), io);
18596
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
+ }
18597
18973
  if (argv[0] === "help") return runHelpCommand(argv.slice(1), io);
18598
18974
  if (SUBCOMMANDS.has(argv[0] ?? "")) {
18599
18975
  err(`mjolnir: incomplete or unknown subcommand "${argv[0]}".`);