mjolnir-qa 0.5.12 → 0.5.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12480,26 +12480,26 @@ function topFixes(findings, n = 3) {
12480
12480
  }
12481
12481
  //#endregion
12482
12482
  //#region src/reporter/art.ts
12483
- /** Block-character logo. Keep ≤ 62 cols wide. */
12483
+ /**
12484
+ * The report's wordmark. A LOCKUP, not a picture of a hammer.
12485
+ *
12486
+ * This used to be eight rows of box-drawing that spelled out a hammer —
12487
+ * directly above the score instrument, which is also a hammer. Two
12488
+ * hammers, ten lines apart, and the smaller static one came first, so
12489
+ * appendScoreSection's own claim that "the hammer is the instrument: the
12490
+ * first thing the eye lands on" was false in every report the tool has
12491
+ * ever printed.
12492
+ *
12493
+ * There is one hammer now, and it is the one that means something: the
12494
+ * state-resolved instrument in HAMMER_STATES. The wordmark stays out of
12495
+ * its way.
12496
+ */
12484
12497
  const LOGO = `
12485
- ╔═══════════╗
12486
- ║ ║
12487
- ╠═══════════╣ M J Ö L N I R
12488
- ║ ║ ║
12489
- ╚═════╩═════╝ VERIFICATION TRUST ENGINE
12490
- ║
12491
- ║
12498
+ M J Ö L N I R · VERIFICATION TRUST ENGINE
12492
12499
  `;
12493
- /** Plain-ASCII fallback logo for cmd.exe/legacy consoles where the
12494
- * block-drawing LOGO above renders as mangled "?" glyphs. */
12500
+ /** Plain-ASCII fallback for cmd.exe/legacy consoles. */
12495
12501
  const LOGO_ASCII = `
12496
- +-----------+
12497
- | |
12498
- +-----------+ M J O L N I R
12499
- | | |
12500
- +-----+-----+ VERIFICATION TRUST ENGINE
12501
- |
12502
- |
12502
+ M J O L N I R - VERIFICATION TRUST ENGINE
12503
12503
  `;
12504
12504
  const TROPHY = String.raw`
12505
12505
  ___________
@@ -13000,7 +13000,12 @@ const CARD_GUTTER = " ";
13000
13000
  function pushCard(lines, card, ui) {
13001
13001
  const { p, width } = ui;
13002
13002
  const contentWidth = Math.max(20, width - 2 - 4 - CARD_LABEL_PAD);
13003
- lines.push(` ${severityIcon(card.severity, ui)} ${p.bold(card.loc)} ${p.dim(card.evidence)}`);
13003
+ const header = ` ${severityIcon(card.severity, ui)} ${p.bold(card.loc)}`;
13004
+ if (measure(`${header} ${card.evidence}`) <= width) lines.push(`${header} ${p.dim(card.evidence)}`);
13005
+ else {
13006
+ lines.push(header);
13007
+ lines.push(`${CARD_GUTTER}${p.dim(card.evidence)}`);
13008
+ }
13004
13009
  const fields = [
13005
13010
  {
13006
13011
  label: "Finding",
@@ -13040,7 +13045,7 @@ function pushCard(lines, card, ui) {
13040
13045
  * overflow line; --verbose shows everything.
13041
13046
  */
13042
13047
  function appendFindings(lines, result, counts, verbose, ui, tone) {
13043
- const { p } = ui;
13048
+ const { p, width } = ui;
13044
13049
  if (counts.total === 0) return;
13045
13050
  if (result.findings.length === 0) {
13046
13051
  lines.push(ui.p.dim(" filtered view: no findings in the selected category"));
@@ -13089,9 +13094,21 @@ function appendFindings(lines, result, counts, verbose, ui, tone) {
13089
13094
  hiddenRules.add(unit.ruleId);
13090
13095
  continue;
13091
13096
  }
13092
- lines.push(` ${severityIcon(maxSeverity(unit.findings), ui)} ${p.bold(sanitizeData(unit.ruleId))} ${p.dim(`× ${n} — same fix applies`)} ${p.dim(evidenceTag$1(first))}`);
13093
- lines.push(`${CARD_GUTTER}${p.accent("Fix".padEnd(CARD_LABEL_PAD))}${p.dim(sanitizeData(first.fix))}`);
13094
- for (const f of unit.findings) lines.push(`${CARD_GUTTER}${" ".repeat(CARD_LABEL_PAD)}${p.dim(`· ${sanitizeData(f.file)}:${f.line} — ${sanitizeData(f.message)}`)}`);
13097
+ const groupHead = ` ${severityIcon(maxSeverity(unit.findings), ui)} ${p.bold(sanitizeData(unit.ruleId))} ${p.dim(`× ${n} — same fix applies`)}`;
13098
+ const groupEvidence = evidenceTag$1(first);
13099
+ if (measure(`${groupHead} ${groupEvidence}`) <= width) lines.push(`${groupHead} ${p.dim(groupEvidence)}`);
13100
+ else {
13101
+ lines.push(groupHead);
13102
+ lines.push(`${CARD_GUTTER}${p.dim(groupEvidence)}`);
13103
+ }
13104
+ const groupContentWidth = Math.max(20, width - 2 - 4 - CARD_LABEL_PAD);
13105
+ wrapLines(sanitizeData(first.fix), groupContentWidth).forEach((seg, i) => {
13106
+ const label = i === 0 ? p.accent("Fix".padEnd(CARD_LABEL_PAD)) : " ".repeat(CARD_LABEL_PAD);
13107
+ lines.push(`${CARD_GUTTER}${label}${p.dim(seg)}`);
13108
+ });
13109
+ for (const f of unit.findings) wrapLines(`· ${sanitizeData(f.file)}:${f.line} — ${sanitizeData(f.message)}`, groupContentWidth).forEach((seg, i) => {
13110
+ lines.push(`${CARD_GUTTER}${" ".repeat(CARD_LABEL_PAD)}${p.dim(i === 0 ? seg : ` ${seg}`)}`);
13111
+ });
13095
13112
  lines.push("");
13096
13113
  shown++;
13097
13114
  continue;
@@ -13129,22 +13146,34 @@ function appendForgedBlock(lines, p, ascii) {
13129
13146
  lines.push(p.forged(TROPHY));
13130
13147
  lines.push("");
13131
13148
  }
13149
+ /**
13150
+ * Pushes dimmed prose that respects the terminal width.
13151
+ *
13152
+ * The honesty footer used to be pushed as single unbroken strings — the
13153
+ * rule-coverage line alone is ~147 columns, so it overflowed every
13154
+ * default 80- or 100-column terminal and ignored `--width` entirely.
13155
+ * `wrapText` is the same helper the finding cards already use.
13156
+ */
13157
+ function pushWrapped(lines, p, text, width) {
13158
+ const indent = " ";
13159
+ for (const line of wrapText(text, Math.max(20, width - 2))) lines.push(p.dim(`${indent}${line}`));
13160
+ }
13132
13161
  function appendFooter(lines, result, ui) {
13133
- const { p } = ui;
13162
+ const { p, width } = ui;
13134
13163
  lines.push(...buildFooter({
13135
13164
  ui,
13136
13165
  complete: result.analysisStatus.discovery !== "partial",
13137
13166
  durationMs: result.analysisStatus.durationMs
13138
13167
  }));
13139
13168
  const advisory = result.findings.filter((f) => (f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence)) === "E0").length;
13140
- if (advisory > 0) lines.push(p.dim(` ${advisory} advisory finding${advisory === 1 ? "" : "s"} (E0 — observation only, no score impact)`));
13169
+ if (advisory > 0) pushWrapped(lines, p, `${advisory} advisory finding${advisory === 1 ? "" : "s"} (E0 — observation only, no score impact)`, width);
13141
13170
  if (result.findings.length > 0) {
13142
13171
  const firedRuleIds = new Set(result.findings.map((f) => f.ruleId));
13143
13172
  const measuredHere = [...firedRuleIds].filter((id) => MEASURED_FP[id] !== void 0).length;
13144
- lines.push(p.dim(` Rule coverage: ${measuredHere}/${firedRuleIds.size} rules that fired here have a measured false-positive rate; the rest are heuristics. \`mjolnir rules --unmeasured\` lists them.`));
13173
+ pushWrapped(lines, p, `Rule coverage: ${measuredHere}/${firedRuleIds.size} rules that fired here have a measured false-positive rate; the rest are heuristics. \`mjolnir rules --unmeasured\` lists them.`, width);
13145
13174
  const verified = result.findings.filter((f) => f.runtimeCorroboration !== void 0).length;
13146
- if (verified > 0) lines.push(p.dim(` Runtime evidence: ${verified}/${result.findings.length} findings corroborated by a real run report (trust L3–L5); the rest are static-only.`));
13147
- else lines.push(p.dim(` Runtime evidence: not available — no run report (mjolnir.report.json / test-results) next to the scan target; all findings are static-only (L0–L2).`));
13175
+ if (verified > 0) pushWrapped(lines, p, `Runtime evidence: ${verified}/${result.findings.length} findings corroborated by a real run report (trust L3–L5); the rest are static-only.`, width);
13176
+ else pushWrapped(lines, p, `Runtime evidence: not available — no run report (mjolnir.report.json / test-results) next to the scan target; all findings are static-only (L0–L2).`, width);
13148
13177
  }
13149
13178
  const profile = result.agenticProfile;
13150
13179
  if (profile && (profile.generatedMarkedFiles > 0 || profile.codegenLikeFiles > 0)) {
@@ -13224,7 +13253,7 @@ function renderSarif(result, repoRootUri) {
13224
13253
  tool: { driver: {
13225
13254
  name: "Mjölnir",
13226
13255
  informationUri: "https://github.com/Sergey-Bar/Mjolnir",
13227
- version: "0.5.12",
13256
+ version: "0.5.14",
13228
13257
  rules: [...rules.values()].map((r) => {
13229
13258
  const meta = RULES.find((x) => x.id === r.id);
13230
13259
  return {
@@ -15262,6 +15291,12 @@ const HELP_ENTRIES = [
15262
15291
  summary: "install the agent instruction surfaces + optional staged hook",
15263
15292
  usage: "mjolnir install [--staged-hook] [--dry-run] [--force]",
15264
15293
  examples: ["mjolnir install --dry-run", "mjolnir install --staged-hook"]
15294
+ },
15295
+ {
15296
+ verb: "mcp",
15297
+ summary: "run as an MCP server over stdio (scan / explain / diff tools)",
15298
+ usage: "mjolnir mcp",
15299
+ examples: ["mjolnir mcp"]
15265
15300
  }
15266
15301
  ];
15267
15302
  /** Scan-flag entries documented per-flag via the overview. */
@@ -15426,7 +15461,8 @@ const GROUPS = [
15426
15461
  "explain",
15427
15462
  "why",
15428
15463
  "handoff",
15429
- "install"
15464
+ "install",
15465
+ "mcp"
15430
15466
  ]
15431
15467
  }
15432
15468
  ];
@@ -17456,14 +17492,29 @@ function explainRule(ruleId, fixturesRoot) {
17456
17492
  ok: true,
17457
17493
  rule,
17458
17494
  exampleFinding: example,
17459
- exampleFixturePath: fixturePath
17495
+ exampleFixturePath: fixturePath,
17496
+ exampleFixtureRelPath: relative(fixturesRoot, fixturePath).replaceAll("\\", "/")
17460
17497
  };
17461
17498
  }
17462
- function renderExplain(result) {
17499
+ /**
17500
+ * Default column budget when no width is supplied.
17501
+ *
17502
+ * `explain`'s prose used to be pushed as unbroken strings — the
17503
+ * "HOW TO VERIFY THE FIX" paragraph alone is 150 columns — so every
17504
+ * explanation overflowed a default terminal. Renderers here take a width
17505
+ * rather than reading process.stdout, so output stays a pure function of
17506
+ * its arguments (same rule the reporter's palette follows).
17507
+ */
17508
+ const DEFAULT_EXPLAIN_WIDTH = 80;
17509
+ function renderExplain(result, width = DEFAULT_EXPLAIN_WIDTH) {
17463
17510
  if (!result.ok || !result.rule) return `explain failed: ${result.error ?? "unknown error"}`;
17464
17511
  const r = result.rule;
17465
17512
  const evidenceLevel = r.evidenceLevel ?? deriveEvidenceLevel(r.findingType, r.confidence);
17466
17513
  const lines = [];
17514
+ /** Pushes prose indented two columns, wrapped to the budget. */
17515
+ const pushBody = (text) => {
17516
+ for (const seg of wrapText(text, Math.max(20, width - 2))) lines.push(` ${seg}`);
17517
+ };
17467
17518
  lines.push(sectionHeader(`${r.id} — ${r.title}`, ui$1));
17468
17519
  lines.push("");
17469
17520
  lines.push(`Severity: ${r.severity}`);
@@ -17480,19 +17531,19 @@ function renderExplain(result) {
17480
17531
  if (result.exampleFinding) {
17481
17532
  const f = result.exampleFinding;
17482
17533
  lines.push("WHAT WAS FOUND (real detector output, not a mockup)");
17483
- lines.push(` ${f.message}`);
17534
+ pushBody(f.message);
17484
17535
  lines.push("");
17485
17536
  lines.push("WHY IT MATTERS");
17486
- lines.push(` ${f.why}`);
17537
+ pushBody(f.why);
17487
17538
  lines.push("");
17488
17539
  lines.push("HOW TO FIX");
17489
- lines.push(` ${f.fix}`);
17540
+ pushBody(f.fix);
17490
17541
  lines.push("");
17491
- lines.push(`Example from this rule's own must-fire fixture: ${result.exampleFixturePath ?? "(unknown path)"}`);
17492
- } else lines.push("No example available — run this command from a mjolnir checkout (or pass --fixtures-root) so the fixture that proves this rule works can be shown as a real example.");
17542
+ pushBody(`Example from this rule's own must-fire fixture: ${result.exampleFixtureRelPath ?? result.exampleFixturePath ?? "(unknown path)"}`);
17543
+ } 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);
17493
17544
  lines.push("");
17494
17545
  lines.push("HOW TO VERIFY THE FIX");
17495
- lines.push(" Re-run `mjolnir` on the changed file(s) — this finding should no longer appear. `mjolnir --scope changed` scopes the check to just what you touched.");
17546
+ 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.");
17496
17547
  lines.push("");
17497
17548
  lines.push(`Docs: mjolnir rules --md (full catalog, this rule included)`);
17498
17549
  return lines.join("\n");
@@ -17649,7 +17700,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
17649
17700
  * `scripts/sync-sarif-version.cjs` on release and guarded by
17650
17701
  * `tests/version-consistency.spec.ts` locally.
17651
17702
  */
17652
- const CLI_VERSION = "0.5.12";
17703
+ const CLI_VERSION = "0.5.14";
17653
17704
  function parseArgs(argv, onError) {
17654
17705
  const args = {
17655
17706
  target: ".",
@@ -18513,7 +18564,8 @@ const SUBCOMMANDS = /* @__PURE__ */ new Set([
18513
18564
  "doctor",
18514
18565
  "rules",
18515
18566
  "explain",
18516
- "doctor:playwright"
18567
+ "doctor:playwright",
18568
+ "mcp"
18517
18569
  ]);
18518
18570
  async function main(argv = process$1.argv.slice(2), io = {
18519
18571
  out,
@@ -18550,6 +18602,10 @@ async function main(argv = process$1.argv.slice(2), io = {
18550
18602
  if (argv[0] === "why") return runWhyCommand(argv.slice(1), io);
18551
18603
  if (argv[0] === "handoff") return runHandoffCommand(argv.slice(1), io);
18552
18604
  if (argv[0] === "install") return runInstallCommand(argv.slice(1), io);
18605
+ if (argv[0] === "mcp") {
18606
+ await runStdioTransport(process$1.stdin, process$1.stdout);
18607
+ return 0;
18608
+ }
18553
18609
  if (argv[0] === "help") return runHelpCommand(argv.slice(1), io);
18554
18610
  if (SUBCOMMANDS.has(argv[0] ?? "")) {
18555
18611
  err(`mjolnir: incomplete or unknown subcommand "${argv[0]}".`);
@@ -18983,7 +19039,14 @@ async function handleStdioLine(line, write) {
18983
19039
  //#endregion
18984
19040
  //#region src/mcp/stdio.ts
18985
19041
  /**
18986
- * `mjolnir mcp` — the MCP stdio binary entrypoint (blueprint §21).
19042
+ * The standalone MCP stdio binary (blueprint §21), built to
19043
+ * `dist/mcp/stdio.mjs` and run by `npm run mcp`.
19044
+ *
19045
+ * `mjolnir mcp` reaches the same transport through the CLI and is what
19046
+ * the README documents, since it needs no path into node_modules. This
19047
+ * entry stays because the packaged binary is addressable without the
19048
+ * CLI's arg parsing in front of it, which is what the spawned-binary
19049
+ * contract test exercises.
18987
19050
  *
18988
19051
  * Thin glue: wires real stdin/stdout into the transport loop, then exits.
18989
19052
  * All transport logic lives in ./transport.ts and is covered there; THIS
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mjolnir-qa",
3
- "version": "0.5.12",
3
+ "version": "0.5.14",
4
4
  "description": "Mjölnir — the Verification Trust Engine for QA. Audits test suites and CI pipelines, reports a worthiness score and prioritized findings.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -38,12 +38,17 @@
38
38
  "docs:capability": "tsx scripts/generate-capability-matrix.ts",
39
39
  "docs:machine-contract": "tsx scripts/generate-machine-contract-doc.ts",
40
40
  "docs:hero": "tsx scripts/generate-readme-hero.ts",
41
+ "docs:gauge": "tsx scripts/generate-readme-score-gauge.ts",
41
42
  "docs:formats": "tsx scripts/generate-site-formats.ts",
42
43
  "docs:forensics-samples": "tsx scripts/generate-forensics-samples.ts",
43
44
  "site:doctor": "npm --prefix site run doctor",
44
45
  "site:audit": "node site/scripts/audit-live.mjs",
45
46
  "site:lighthouse": "node site/scripts/lighthouse-run.mjs",
46
47
  "docs:demo": "tsx scripts/generate-readme-demo.ts",
48
+ "docs:video:capture": "tsx scripts/video/write-scripts.ts",
49
+ "docs:video:glyphs": "tsx scripts/video/check-glyphs.ts",
50
+ "docs:video:render": "tsx scripts/video/render.ts",
51
+ "docs:video": "npm run docs:video:capture && npm run docs:video:render",
47
52
  "docs:translations": "node scripts/check-readme-translations.mjs",
48
53
  "self-scan": "node dist/cli.mjs .",
49
54
  "prepare": "husky",
@@ -91,6 +96,7 @@
91
96
  "husky": "^9.1.7",
92
97
  "lighthouse": "^13.4.1",
93
98
  "lint-staged": "^17.3.0",
99
+ "playwright-core": "^1.63.0",
94
100
  "prettier": "^3.3.0",
95
101
  "tsdown": "^0.22.14",
96
102
  "tsx": "^4.16.0",