mjolnir-qa 4.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { $ as BADGE_BAND, A as plainContext, At as isLintFixtureDir, B as shouldUseAscii, Bt as DEDUCTIONS, C as runForensics, Ct as computeCodeText, D as nextStep, Dt as LIMITS, E as buildFooter, Et as DEFAULT_IGNORE_MATCHER, F as padTo, Ft as isRecord$3, G as countOrNull, Gt as deriveEvidenceLevel, H as atomicTempPath, Ht as RULE_CATEGORIES, I as palette, It as parseJsonFile, J as evidenceTag, K as deriveScoreState, Kt as isAdvisoryFinding, L as sanitizeData, Lt as ENGINE_VERSION, M as severityIcon, Mt as isSuppressionActive, N as box, Nt as loadConfig, O as okIcon, Ot as createIgnoreMatcher, P as measure, Pt as readFileBounded, Q as TRUST_RUNGS, R as scoreGauge, Rt as buildEvidenceGraph, S as renderSuppressions, St as parseYamlGuarded, T as FLAKE_GLYPH, Tt as detectFrameworks, U as sweepStaleTempFiles, Ut as SEVERITY_ORDER$1, V as wrapText, Vt as QA_IMPACT_LABELS, W as writeFileAtomic, Wt as TRUST_ORDER, X as testsAnalyzedCell, Y as headlineFor, Z as verdictFor, _t as SCAN_ADAPTERS, at as correlateFindings, b as loadLocalRules, bt as parseAzurePipeline, ct as capForTier, dt as massCeiling, et as SCORE, ft as RETIRED_RULE_IDS, gt as runGit, h as scan_pipeline_exports, ht as resolveGitPath, it as getReachableFiles, j as sectionHeader, jt as ConfigValidationError, k as panel, kt as globToRegExp, lt as computeDimensions, m as runScan$1, mt as getRule, n as KNOWN_RULE_IDS$1, nt as TINT, ot as classifyProvenance, pt as RULES, q as evidenceLevelOf, qt as isValidCategory, rt as buildDependencyGraph, st as computeAgenticProfile, tt as STATUS, ut as deductionFor, v as pluginsGateOpen, vt as SEARCHED_FOR, w as sanitizeErrorText, wt as parseTsFile, x as loadSuppressions, xt as parseWorkflow, y as renderGateNotice, yt as isAzurePipelineFixture, z as shouldColorize, zt as MEASURED_FP } from "./scan-pipeline-CCJdEKNa.mjs";
2
+ import { $ as BADGE_BAND, A as plainContext, At as ConfigValidationError, B as shouldUseAscii, Bt as MEASURED_FP, C as runForensics, Ct as parseTsFile, D as nextStep, Dt as createIgnoreMatcher, E as buildFooter, Et as LIMITS, F as padTo, Ft as isRecord, G as countOrNull, Gt as DEDUCTIONS, H as atomicTempPath, Ht as degradationsSince, I as palette, It as parseJsonFile, J as evidenceTag, Jt as SEVERITY_ORDER$1, K as deriveScoreState, Kt as QA_IMPACT_LABELS, L as sanitizeData, Lt as resolveGitPath, M as severityIcon, Mt as loadConfig, N as box, Nt as readFileBounded, O as okIcon, Ot as globToRegExp, P as measure, Pt as ENGINE_VERSION, Q as TRUST_RUNGS, Qt as isValidCategory, R as scoreGauge, Rt as runGit, S as renderSuppressions, St as computeCodeText, T as FLAKE_GLYPH, Tt as DEFAULT_IGNORE_MATCHER, U as sweepStaleTempFiles, Ut as recordDegradation, V as wrapText, Vt as beginDegradationWindow, W as writeFileAtomic, Wt as summarizeDegradations, X as testsAnalyzedCell, Xt as deriveEvidenceLevel, Y as headlineFor, Yt as TRUST_ORDER, Z as verdictFor, Zt as isAdvisoryFinding, _t as SEARCHED_FOR, at as correlateFindings, b as loadLocalRules, bt as parseWorkflow, ct as capForTier, dt as massCeiling, et as SCORE, ft as compareCodePoints, gt as SCAN_ADAPTERS, h as scan_pipeline_exports, ht as getRule, it as getReachableFiles, j as sectionHeader, jt as isSuppressionActive, k as panel, kt as isLintFixtureDir, lt as computeDimensions, m as runScan$1, mt as RULES, n as KNOWN_RULE_IDS$1, nt as TINT, ot as classifyProvenance, pt as RETIRED_RULE_IDS, q as evidenceLevelOf, qt as RULE_CATEGORIES, rt as buildDependencyGraph, st as computeAgenticProfile, tt as STATUS, ut as deductionFor, v as pluginsGateOpen, vt as isAzurePipelineFixture, w as sanitizeErrorText, wt as detectFrameworks, x as loadSuppressions, xt as parseYamlGuarded, y as renderGateNotice, yt as parseAzurePipeline, z as shouldColorize, zt as buildEvidenceGraph } from "./scan-pipeline-DT8DWAMl.mjs";
3
3
  import { accessSync, appendFileSync, chmodSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import process$1 from "node:process";
@@ -433,6 +433,16 @@ const HELP_ENTRIES = [
433
433
  usage: "mjolnir explain <RULE-ID> [--fixtures-root <dir>]",
434
434
  examples: ["mjolnir explain QA-TEST-001", "mjolnir rules --unmeasured"]
435
435
  },
436
+ {
437
+ verb: "capability",
438
+ summary: "the capability registry: every capability at the maturity the machine can prove",
439
+ usage: "mjolnir capability [--json] [--kind <k>] [--maturity M0..M5] [--id <substring>]",
440
+ examples: [
441
+ "mjolnir capability",
442
+ "mjolnir capability --maturity M2",
443
+ "mjolnir capability --json > registry.json"
444
+ ]
445
+ },
436
446
  {
437
447
  verb: "rules",
438
448
  summary: "rule catalog + empirical-measurement stats/health (md/json/stats/health)",
@@ -789,6 +799,7 @@ const GROUPS = [
789
799
  "trust-trend",
790
800
  "evidence-graph",
791
801
  "framework-maturity",
802
+ "capability",
792
803
  "suppression-gate",
793
804
  "business-case",
794
805
  "release-report",
@@ -1466,6 +1477,49 @@ function ciInstall(root, gate, options = {}) {
1466
1477
  diffSummary: []
1467
1478
  };
1468
1479
  }
1480
+ //#endregion
1481
+ //#region src/cli-io.ts
1482
+ const out = (...parts) => console.log(parts.map(String).join(" "));
1483
+ const err = (...parts) => console.error(parts.map(String).join(" "));
1484
+ function internalErrorMessage(err, emit, debug) {
1485
+ const message = err instanceof Error ? err.message : String(err);
1486
+ emit("mjolnir internal error — this is a bug in Mjölnir, not your repo:");
1487
+ emit(` ${message}`);
1488
+ if (debug && err instanceof Error && err.stack) emit(err.stack);
1489
+ emit("Rerun with --debug for the stack trace. Please report this:");
1490
+ emit(" https://github.com/Sergey-Bar/Mjolnir/issues");
1491
+ }
1492
+ /**
1493
+ * The ONE error-to-message derivation in `src/`.
1494
+ *
1495
+ * This tree carried four near-copies: two `errorMessage` (byte-identical) and
1496
+ * two `errorText`. The two `errorText` copies were not identical, which is the
1497
+ * part that mattered — `String(someObject)` renders `[object Object]`, so the
1498
+ * copy that lost the payload turned a hostile non-Error throw into a message
1499
+ * that names nothing. The richer arms are kept here, so every call site gets
1500
+ * the informative one and the difference stops being a per-file accident.
1501
+ *
1502
+ * The arms, in order:
1503
+ * Error -> `.message`. The 99% case, and the only one that
1504
+ * carries a stack the caller may still want.
1505
+ * string -> itself. A thrown string is already the message.
1506
+ * plain object -> `JSON.stringify`. A non-Error throw of `{ code:
1507
+ * "EISDIR" }` is a REAL failure shape in Node, and
1508
+ * `[object Object]` hides the only field that says
1509
+ * what went wrong.
1510
+ * anything else -> `String(err)`. Symbols and undefined included, so
1511
+ * nothing vanishes.
1512
+ *
1513
+ * The call this replaces was `err instanceof Error ? err.message : String(err)`
1514
+ * in `src/mcp/server.ts` and `src/commands/doctor.ts`; `tests/cli/summary.spec.ts`
1515
+ * already pins the object arm.
1516
+ */
1517
+ function errorMessage(err) {
1518
+ if (err instanceof Error) return err.message;
1519
+ if (typeof err === "string") return err;
1520
+ if (typeof err === "object" && err !== null) return JSON.stringify(err);
1521
+ return String(err);
1522
+ }
1469
1523
  /**
1470
1524
  * Fields that participate in the verification digest: identity (ruleId,
1471
1525
  * detectorRevision, file, line, column) + evidence (severity,
@@ -1669,6 +1723,11 @@ function explainRule(ruleId, fixturesRoot) {
1669
1723
  ok: false,
1670
1724
  error: `Unknown rule ID "${ruleId}". Run \`mjolnir rules\` for the full catalog.`
1671
1725
  };
1726
+ const window = beginDegradationWindow();
1727
+ const degraded = () => {
1728
+ const counts = summarizeDegradations(degradationsSince(window));
1729
+ return counts.length > 0 ? { exampleDegraded: counts } : {};
1730
+ };
1672
1731
  const fixturePath = firstFixtureFile(join(fixturesRoot, ruleId, "must-fire"));
1673
1732
  if (!fixturePath) return {
1674
1733
  ok: true,
@@ -1678,9 +1737,11 @@ function explainRule(ruleId, fixturesRoot) {
1678
1737
  try {
1679
1738
  text = readFileSync(fixturePath, "utf8").replace(/\r\n/g, "\n");
1680
1739
  } catch {
1740
+ recordDegradation("explain-fixture-unreadable");
1681
1741
  return {
1682
1742
  ok: true,
1683
- rule
1743
+ rule,
1744
+ ...degraded()
1684
1745
  };
1685
1746
  }
1686
1747
  const normalizedPath = fixturePath.replaceAll("\\", "/");
@@ -1690,9 +1751,11 @@ function explainRule(ruleId, fixturesRoot) {
1690
1751
  else try {
1691
1752
  ast = isAzurePipelineFixture(normalizedPath) ? parseAzurePipeline(text) : parseWorkflow(text);
1692
1753
  } catch {
1754
+ recordDegradation("explain-workflow-parse-failed");
1693
1755
  return {
1694
1756
  ok: true,
1695
- rule
1757
+ rule,
1758
+ ...degraded()
1696
1759
  };
1697
1760
  }
1698
1761
  }
@@ -1709,9 +1772,11 @@ function explainRule(ruleId, fixturesRoot) {
1709
1772
  codeText
1710
1773
  });
1711
1774
  } catch {
1775
+ recordDegradation("explain-rule-crash");
1712
1776
  return {
1713
1777
  ok: true,
1714
- rule
1778
+ rule,
1779
+ ...degraded()
1715
1780
  };
1716
1781
  }
1717
1782
  const example = findings[0];
@@ -1773,7 +1838,13 @@ function renderExplain(result, width = DEFAULT_EXPLAIN_WIDTH) {
1773
1838
  pushBody(f.fix);
1774
1839
  lines.push("");
1775
1840
  pushBody(`Example from this rule's own must-fire fixture: ${result.exampleFixtureRelPath ?? result.exampleFixturePath ?? "(unknown path)"}`);
1776
- } 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);
1841
+ } else {
1842
+ 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);
1843
+ for (const entry of result.exampleDegraded ?? []) {
1844
+ lines.push("");
1845
+ for (const seg of wrapText(`NOT SHOWN — ${entry.reason} (${entry.count}): the example is missing because the check did not complete, not because the rule found nothing.`, width)) lines.push(seg);
1846
+ }
1847
+ }
1777
1848
  lines.push("");
1778
1849
  lines.push("WHAT WOULD CHANGE THE VERDICT");
1779
1850
  for (const c of whatWouldChangeTheVerdict(r)) pushBody(`- ${c}`);
@@ -1852,7 +1923,7 @@ function whatWouldChangeTheVerdict(r) {
1852
1923
  /** Structural validation of the loaded JSON (hostile-input safe). */
1853
1924
  function parseScanJson(raw) {
1854
1925
  try {
1855
- return parseJsonFile(raw, "scan result", (v) => isRecord$3(v) && v.schemaVersion === 1 && Array.isArray(v.findings) && Boolean(v.analysisStatus) && typeof v.partial === "boolean");
1926
+ return parseJsonFile(raw, "scan result", (v) => isRecord(v) && v.schemaVersion === 1 && Array.isArray(v.findings) && Boolean(v.analysisStatus) && typeof v.partial === "boolean");
1856
1927
  } catch {
1857
1928
  return;
1858
1929
  }
@@ -3064,15 +3135,11 @@ async function handleToolCall(call) {
3064
3135
  id: call.id,
3065
3136
  error: {
3066
3137
  code: MCP_ERRORS.INTERNAL,
3067
- message: errorMessage$1(err)
3138
+ message: errorMessage(err)
3068
3139
  }
3069
3140
  };
3070
3141
  }
3071
3142
  }
3072
- /** Normalize any thrown value to a string (unit-tested both arms). */
3073
- function errorMessage$1(err) {
3074
- return err instanceof Error ? err.message : String(err);
3075
- }
3076
3143
  /** JSON-RPC request shape validation (strict — §21). */
3077
3144
  function validateRequest(msg) {
3078
3145
  if (typeof msg !== "object" || msg === null) return {
@@ -3175,24 +3242,6 @@ async function handleMcpMessage(msg) {
3175
3242
  };
3176
3243
  }
3177
3244
  //#endregion
3178
- //#region src/cli-io.ts
3179
- const out = (...parts) => console.log(parts.map(String).join(" "));
3180
- const err = (...parts) => console.error(parts.map(String).join(" "));
3181
- function internalErrorMessage(err, emit, debug) {
3182
- const message = err instanceof Error ? err.message : String(err);
3183
- emit("mjolnir internal error — this is a bug in Mjölnir, not your repo:");
3184
- emit(` ${message}`);
3185
- if (debug && err instanceof Error && err.stack) emit(err.stack);
3186
- emit("Rerun with --debug for the stack trace. Please report this:");
3187
- emit(" https://github.com/Sergey-Bar/Mjolnir/issues");
3188
- }
3189
- /**
3190
- * The single error-to-message derivation shared by every catch site:
3191
- * Error instances render their message; anything else is stringified
3192
- * honestly (a hostile non-Error throw must surface, not vanish).
3193
- */
3194
- const errorMessage = (err) => err instanceof Error ? err.message : String(err);
3195
- //#endregion
3196
3245
  //#region src/mcp/transport.ts
3197
3246
  /**
3198
3247
  * MCP stdio transport loop (blueprint §21 — the framing around the
@@ -4337,13 +4386,13 @@ function renderMermaid(result) {
4337
4386
  lines.push(" class NOTESTS unknown;");
4338
4387
  return lines.join("\n");
4339
4388
  }
4340
- const frameworks = [...result.frameworks].sort((a, b) => a.localeCompare(b));
4389
+ const frameworks = [...result.frameworks].sort(compareCodePoints);
4341
4390
  if (frameworks.length === 0) lines.push(` ${rootId} --> FW_unknown["Frameworks: UNKNOWN"]`);
4342
4391
  else for (const fw of frameworks) {
4343
4392
  const fwId = `FW_${sanitizeId(fw)}`;
4344
4393
  lines.push(` ${rootId} --> ${fwId}["${escapeLabel(fw)}"]`);
4345
4394
  }
4346
- const dimensions = [...result.dimensions].sort((a, b) => a.category.localeCompare(b.category));
4395
+ const dimensions = [...result.dimensions].sort((a, b) => compareCodePoints(a.category, b.category));
4347
4396
  const styleAssignments = [];
4348
4397
  for (const dim of dimensions) {
4349
4398
  const catId = `CAT_${sanitizeId(dim.category)}`;
@@ -4549,21 +4598,11 @@ const EVIDENCE_VALUES = /* @__PURE__ */ new Set([
4549
4598
  "E2"
4550
4599
  ]);
4551
4600
  const TRUST_VALUES = new Set(TRUST_ORDER);
4552
- /** Human message for any thrown value — never "undefined"/"[object Object]". */
4553
- function errorText$1(err) {
4554
- if (err instanceof Error) return err.message;
4555
- if (typeof err === "string") return err;
4556
- if (typeof err === "object" && err !== null) return JSON.stringify(err);
4557
- return String(err);
4558
- }
4559
- function isRecord$2(value) {
4560
- return typeof value === "object" && value !== null && !Array.isArray(value);
4561
- }
4562
4601
  function fail(message) {
4563
4602
  throw new Error(message);
4564
4603
  }
4565
4604
  function requireRecord(value, label) {
4566
- if (!isRecord$2(value)) fail(`${label} must be an object`);
4605
+ if (!isRecord(value)) fail(`${label} must be an object`);
4567
4606
  return value;
4568
4607
  }
4569
4608
  function requireString(value, label) {
@@ -4661,7 +4700,7 @@ function validateReportJson(text) {
4661
4700
  try {
4662
4701
  parsed = JSON.parse(text);
4663
4702
  } catch (err) {
4664
- throw new Error(`not valid JSON (${errorText$1(err)})`, { cause: err });
4703
+ throw new Error(`not valid JSON (${errorMessage(err)})`, { cause: err });
4665
4704
  }
4666
4705
  const doc = requireRecord(parsed, "the report is not an object");
4667
4706
  if (doc.schemaVersion !== 1) fail(`unsupported schemaVersion ${JSON.stringify(doc.schemaVersion)} — expected schemaVersion 1`);
@@ -4685,7 +4724,7 @@ function validateReportJson(text) {
4685
4724
  const partialMarkers = status["discovery"] !== "complete" || status["rules"] !== "complete" || Number(status["skippedFiles"]) > 0 || Number(status["rulesCrashed"] ?? 0) > 0 || Array.isArray(status["reasons"]) && status["reasons"].length > 0;
4686
4725
  if (doc.partial === false && partialMarkers) fail("partial=false conflicts with analysisStatus");
4687
4726
  if (doc.partial === true && typeof doc.score === "number" && doc.score === 100) fail("partial reports cannot have a perfect score");
4688
- if ((isRecord$2(doc.scopeIntegrity) ? doc.scopeIntegrity : void 0)?.["scopeVerdict"] === "PARTIAL" && doc.partial === false) fail("partial=false conflicts with scopeIntegrity");
4727
+ if ((isRecord(doc.scopeIntegrity) ? doc.scopeIntegrity : void 0)?.["scopeVerdict"] === "PARTIAL" && doc.partial === false) fail("partial=false conflicts with scopeIntegrity");
4689
4728
  return parsed;
4690
4729
  }
4691
4730
  /** Load + validate a saved report from disk. Throws on any problem. */
@@ -4701,7 +4740,7 @@ function reportExists(reportPath) {
4701
4740
  }
4702
4741
  function identityOf(result) {
4703
4742
  const identity = result.runIdentity;
4704
- return isRecord$2(identity) ? identity : void 0;
4743
+ return isRecord(identity) ? identity : void 0;
4705
4744
  }
4706
4745
  /**
4707
4746
  * Classify a loaded report. Pure — it reads the report and the caller's
@@ -4723,7 +4762,7 @@ function classifySavedReport(result, expected) {
4723
4762
  };
4724
4763
  const commit = typeof identity["commit"] === "string" ? identity["commit"] : void 0;
4725
4764
  const candidate = identity["candidate"];
4726
- const candidateManifestId = isRecord$2(candidate) && typeof candidate["manifestId"] === "string" ? candidate["manifestId"] : void 0;
4765
+ const candidateManifestId = isRecord(candidate) && typeof candidate["manifestId"] === "string" ? candidate["manifestId"] : void 0;
4727
4766
  if (expected !== void 0) {
4728
4767
  if (expected.scanId !== void 0 && expected.scanId !== scanId) return {
4729
4768
  state: "OPEN",
@@ -4893,7 +4932,7 @@ function buildArtifactIdentity(result, commit) {
4893
4932
  detectorRevisions: [...revisions.entries()].map(([ruleId, detectorRevision]) => ({
4894
4933
  ruleId,
4895
4934
  detectorRevision
4896
- })).sort((a, b) => a.ruleId.localeCompare(b.ruleId)),
4935
+ })).sort((a, b) => compareCodePoints(a.ruleId, b.ruleId)),
4897
4936
  evidenceInventory: {
4898
4937
  totalFindings: result.findings.length,
4899
4938
  corroborated,
@@ -5625,7 +5664,7 @@ async function runWhyCommand(argv, io = {
5625
5664
  try {
5626
5665
  result = loadSavedReport(reportPath);
5627
5666
  } catch (err) {
5628
- io.err(`mjolnir why: cannot read ${reportPath}: ${errorText$1(err)}`);
5667
+ io.err(`mjolnir why: cannot read ${reportPath}: ${errorMessage(err)}`);
5629
5668
  return 2;
5630
5669
  }
5631
5670
  } else {
@@ -5644,7 +5683,7 @@ async function runWhyCommand(argv, io = {
5644
5683
  strict: argv.includes("--strict")
5645
5684
  });
5646
5685
  } catch (err) {
5647
- io.err(`mjolnir why: scan failed: ${errorText$1(err)}`);
5686
+ io.err(`mjolnir why: scan failed: ${errorMessage(err)}`);
5648
5687
  return 20;
5649
5688
  }
5650
5689
  }
@@ -6007,7 +6046,7 @@ function computeDebt(result) {
6007
6046
  label,
6008
6047
  count: v.count,
6009
6048
  estHoursPerQuarter: Math.round(v.hours * 10) / 10,
6010
- ruleIds: [...v.ruleIds].sort((a, b) => a.localeCompare(b))
6049
+ ruleIds: [...v.ruleIds].sort(compareCodePoints)
6011
6050
  })).sort((a, b) => b.estHoursPerQuarter - a.estHoursPerQuarter);
6012
6051
  return {
6013
6052
  classes,
@@ -6542,8 +6581,8 @@ async function computeImpact(root, options) {
6542
6581
  baseRef,
6543
6582
  headRef,
6544
6583
  ...baseTreeTruncated ? { baseTreeTruncated } : {},
6545
- resolved: resolved.sort((a, b) => a.file.localeCompare(b.file)),
6546
- introduced: introduced.sort((a, b) => a.file.localeCompare(b.file)),
6584
+ resolved: resolved.sort((a, b) => compareCodePoints(a.file, b.file)),
6585
+ introduced: introduced.sort((a, b) => compareCodePoints(a.file, b.file)),
6547
6586
  unknownFacts
6548
6587
  };
6549
6588
  }
@@ -6802,7 +6841,7 @@ function tryReadPackageJson(rootDir) {
6802
6841
  const p = join(rootDir, "package.json");
6803
6842
  if (!existsSync(p)) return null;
6804
6843
  try {
6805
- return parseJsonFile(readFileSync(p, "utf8"), p, isRecord$3);
6844
+ return parseJsonFile(readFileSync(p, "utf8"), p, isRecord);
6806
6845
  } catch {
6807
6846
  return null;
6808
6847
  }
@@ -7598,7 +7637,7 @@ function computeSelectorHealth(root, ignoreMatcher = DEFAULT_IGNORE_MATCHER) {
7598
7637
  }
7599
7638
  };
7600
7639
  walk(root);
7601
- return specs.sort((a, b) => a.score - b.score || a.file.localeCompare(b.file));
7640
+ return specs.sort((a, b) => a.score - b.score || compareCodePoints(a.file, b.file));
7602
7641
  }
7603
7642
  //#endregion
7604
7643
  //#region src/cli-handlers.ts
@@ -8074,7 +8113,7 @@ async function runPrCommentCommand(argv, io = {
8074
8113
  }));
8075
8114
  return 0;
8076
8115
  } catch (err) {
8077
- io.err(`error: cannot read ${fromPath}: ${errorText$1(err)}`);
8116
+ io.err(`error: cannot read ${fromPath}: ${errorMessage(err)}`);
8078
8117
  return 10;
8079
8118
  }
8080
8119
  }
@@ -8567,7 +8606,7 @@ function computeDetectorHashes(rules, rulesDir, retiredIds = []) {
8567
8606
  return manifest;
8568
8607
  }
8569
8608
  function loadManifest(path) {
8570
- return parseJsonFile(readFileSync(path, "utf8"), path, (v) => isRecord$3(v));
8609
+ return parseJsonFile(readFileSync(path, "utf8"), path, (v) => isRecord(v));
8571
8610
  }
8572
8611
  //#endregion
8573
8612
  //#region src/commands/doctor.ts
@@ -8599,10 +8638,6 @@ const VALID_REASON_CODES = /* @__PURE__ */ new Set([
8599
8638
  "family-fallback-lockstep",
8600
8639
  "migration-deferred-next-measurement"
8601
8640
  ]);
8602
- /** Uniform error rendering for doctor details (Error or thrown-as-string). */
8603
- function errorText(e) {
8604
- return e instanceof Error ? e.message : String(e);
8605
- }
8606
8641
  const ui$8 = plainContext();
8607
8642
  /** Builds the check record: `ok` is always the pass-mirror of `status`. */
8608
8643
  function check(name, status, details) {
@@ -8744,7 +8779,7 @@ function checkTierEnforcement(verdictsDir, rules = RULES) {
8744
8779
  const lines = readFileSync(join(verdictsDir, f), "utf8").split("\n").filter((l) => l.trim().length > 0);
8745
8780
  for (const line of lines) try {
8746
8781
  const parsed = JSON.parse(line);
8747
- if (!isRecord$3(parsed)) continue;
8782
+ if (!isRecord(parsed)) continue;
8748
8783
  const entry = parsed;
8749
8784
  if (entry.verdict === "TP" || entry.verdict === "FP") {
8750
8785
  if (typeof entry.ruleId === "string") live.set(entry.ruleId, (live.get(entry.ruleId) ?? 0) + 1);
@@ -8752,7 +8787,7 @@ function checkTierEnforcement(verdictsDir, rules = RULES) {
8752
8787
  } catch {}
8753
8788
  }
8754
8789
  } catch (e) {
8755
- return check("tier-enforcement", "inconclusive", [`INCONCLUSIVE: live verdicts unreadable — ${errorText(e)} (owner ruling 2026-09-08, L4)`]);
8790
+ return check("tier-enforcement", "inconclusive", [`INCONCLUSIVE: live verdicts unreadable — ${errorMessage(e)} (owner ruling 2026-09-08, L4)`]);
8756
8791
  }
8757
8792
  if (live.size > 0) for (const [id, n] of live) classifiedPerRule.set(id, n);
8758
8793
  const coreRules = rules.filter((r) => effectiveTier(r) === "core");
@@ -8852,7 +8887,7 @@ function checkFixtureIntegrity(fixturesRoot, rules = RULES) {
8852
8887
  const allowlistPath = join(fixturesRoot, "typecheck-allowlist.json");
8853
8888
  if (existsSync(allowlistPath)) try {
8854
8889
  const raw = JSON.parse(readFileSync(allowlistPath, "utf8"));
8855
- if (!isRecord$3(raw)) {
8890
+ if (!isRecord(raw)) {
8856
8891
  ok = false;
8857
8892
  details.push("typecheck-allowlist.json is not an object");
8858
8893
  } else if (Array.isArray(raw.entries)) details.push(`Layer A typecheck allowlist: ${raw.entries.length} justified entr(ies)`);
@@ -8902,7 +8937,7 @@ function checkRevisionIntegrity(repoRoot, rules = RULES) {
8902
8937
  try {
8903
8938
  current = computeDetectorHashes(rules, rulesDir);
8904
8939
  } catch (e) {
8905
- return check("revision-integrity", "inconclusive", [`INCONCLUSIVE: detector hash computation failed — ${errorText(e)}`]);
8940
+ return check("revision-integrity", "inconclusive", [`INCONCLUSIVE: detector hash computation failed — ${errorMessage(e)}`]);
8906
8941
  }
8907
8942
  const failures = [];
8908
8943
  for (const rule of rules) {
@@ -8966,7 +9001,7 @@ function checkMeasurementConsistency(verdictsDir, sidecarPath, rules = RULES, me
8966
9001
  let row;
8967
9002
  try {
8968
9003
  const parsed = JSON.parse(trimmed);
8969
- if (!isRecord$3(parsed)) {
9004
+ if (!isRecord(parsed)) {
8970
9005
  failures.push(`${f}: unparseable verdict row (corpus integrity)`);
8971
9006
  continue;
8972
9007
  }
@@ -8983,10 +9018,10 @@ function checkMeasurementConsistency(verdictsDir, sidecarPath, rules = RULES, me
8983
9018
  let sidecar = {};
8984
9019
  if (existsSync(sidecarPath)) try {
8985
9020
  const parsed = JSON.parse(readFileSync(sidecarPath, "utf8"));
8986
- if (!isRecord$3(parsed)) failures.push("sidecar is not a valid object — regenerate the measurement artifacts");
9021
+ if (!isRecord(parsed)) failures.push("sidecar is not a valid object — regenerate the measurement artifacts");
8987
9022
  else sidecar = parsed;
8988
9023
  } catch (e) {
8989
- failures.push(`sidecar unreadable/malformed: ${errorText(e)} — regenerate the measurement artifacts`);
9024
+ failures.push(`sidecar unreadable/malformed: ${errorMessage(e)} — regenerate the measurement artifacts`);
8990
9025
  }
8991
9026
  else {
8992
9027
  const needsSidecar = Object.keys(measuredFp).filter((id) => measuredFp[id]?.detectorRevision !== 1);
@@ -9383,7 +9418,7 @@ function runSummaryCommand(argv, io = {
9383
9418
  try {
9384
9419
  result = loadSavedReport(reportPath);
9385
9420
  } catch (err) {
9386
- io.err(`mjolnir summary: cannot read ${reportPath}: ${errorText$1(err)}`);
9421
+ io.err(`mjolnir summary: cannot read ${reportPath}: ${errorMessage(err)}`);
9387
9422
  return 2;
9388
9423
  }
9389
9424
  const env = process.env;
@@ -9402,7 +9437,7 @@ function runSummaryCommand(argv, io = {
9402
9437
  if (stepSummaryPath) try {
9403
9438
  appendFileSync(stepSummaryPath, `${summary}\n`);
9404
9439
  } catch (err) {
9405
- io.err(`mjolnir summary: could not write $GITHUB_STEP_SUMMARY (${errorText$1(err)}); printing to stdout instead.`);
9440
+ io.err(`mjolnir summary: could not write $GITHUB_STEP_SUMMARY (${errorMessage(err)}); printing to stdout instead.`);
9406
9441
  io.out(summary);
9407
9442
  }
9408
9443
  else io.out(summary);
@@ -9710,7 +9745,7 @@ function runHandoffCommand(argv, io = {
9710
9745
  try {
9711
9746
  result = loadSavedReport(reportPath);
9712
9747
  } catch (err) {
9713
- io.err(`mjolnir handoff: cannot read ${reportPath}: ${errorText$1(err)}`);
9748
+ io.err(`mjolnir handoff: cannot read ${reportPath}: ${errorMessage(err)}`);
9714
9749
  return 2;
9715
9750
  }
9716
9751
  io.out(renderHandoff(result, {
@@ -11116,13 +11151,22 @@ async function runReleaseReportCommand(argv, io) {
11116
11151
  * So the execution block is empty and says so, and the findings live in the
11117
11152
  * `mjolnir` extension block where they are honestly labelled as static
11118
11153
  * analysis. The file is a findings report in a familiar shape, not a test run.
11154
+ *
11155
+ * The TOP-LEVEL status is the same claim and is now held to it. It used to be
11156
+ * computed (`partial ? "interrupted" : hasError ? "failed" : "passed"`), so
11157
+ * the clean case shipped `{status: "passed", totalTests: 0}` — a green test
11158
+ * run over zero tests, in the one field a Playwright consumer reads first. The
11159
+ * per-finding statuses were fixed; this one was not. It is now the literal
11160
+ * `interrupted`, and what the scan actually found is reported as
11161
+ * `mjolnir.scanOutcome` instead of being smuggled into the run status.
11119
11162
  */
11120
11163
  const ui$6 = plainContext();
11121
11164
  function buildPlaywrightReport(result) {
11122
11165
  const now = /* @__PURE__ */ new Date();
11123
11166
  const partial = result.partial === true;
11124
11167
  const hasError = result.findings.some((f) => f.severity === "error");
11125
- const status = partial ? "interrupted" : hasError ? "failed" : "passed";
11168
+ const status = "interrupted";
11169
+ const scanOutcome = partial ? "partial" : hasError ? "blocked" : "clean";
11126
11170
  return {
11127
11171
  version: 1,
11128
11172
  startTime: now.toISOString(),
@@ -11135,7 +11179,7 @@ function buildPlaywrightReport(result) {
11135
11179
  suites: [],
11136
11180
  mjolnir: {
11137
11181
  execution: "STATIC_ANALYSIS",
11138
- status,
11182
+ scanOutcome,
11139
11183
  partial,
11140
11184
  score: result.score,
11141
11185
  framework: result.frameworks[0] ?? "unknown",
@@ -11232,16 +11276,39 @@ function recordTrend(root, snapshot) {
11232
11276
  return false;
11233
11277
  }
11234
11278
  }
11235
- function loadTrend(root, limit = 20) {
11279
+ function loadTrendDetail(root, limit = 20) {
11236
11280
  const path = trendPath(root);
11237
- if (!existsSync(path)) return [];
11238
- return readFileSync(path, "utf8").trim().split("\n").filter(Boolean).map((line) => {
11239
- try {
11240
- return JSON.parse(line);
11241
- } catch {
11242
- return null;
11243
- }
11244
- }).filter((s) => s !== null).slice(-limit);
11281
+ if (!existsSync(path)) return {
11282
+ snapshots: [],
11283
+ corruptLines: 0
11284
+ };
11285
+ const lines = readFileSync(path, "utf8").trim().split("\n").filter(Boolean);
11286
+ let corruptLines = 0;
11287
+ return {
11288
+ snapshots: lines.map((line) => {
11289
+ try {
11290
+ return JSON.parse(line);
11291
+ } catch {
11292
+ recordDegradation("trend-record-unparseable");
11293
+ corruptLines++;
11294
+ return null;
11295
+ }
11296
+ }).filter((s) => s !== null).slice(-limit),
11297
+ corruptLines
11298
+ };
11299
+ }
11300
+ /**
11301
+ * Say what was discarded, on stderr, and report whether the history is
11302
+ * readable at all. Returns the snapshots so the caller does not read the
11303
+ * file twice.
11304
+ */
11305
+ function readTrendOrDisclose(root, limit, io) {
11306
+ const { snapshots, corruptLines } = loadTrendDetail(root, limit);
11307
+ if (corruptLines > 0) io.err(`mjolnir trend: ${corruptLines} unreadable line(s) in ${trendPath(root)} were discarded; the history below is not the whole file.`);
11308
+ return {
11309
+ snapshots,
11310
+ readable: corruptLines === 0 || snapshots.length > 0
11311
+ };
11245
11312
  }
11246
11313
  async function runTrendCommand(argv, io) {
11247
11314
  const subcommand = argv[0] ?? "show";
@@ -11286,8 +11353,12 @@ async function runTrendCommand(argv, io) {
11286
11353
  }
11287
11354
  if (subcommand === "show") {
11288
11355
  const limitStr = argv.find((a) => a.startsWith("--limit="))?.split("=")[1] ?? "20";
11289
- const snapshots = loadTrend(target, parseInt(limitStr, 10));
11356
+ const { snapshots, readable } = readTrendOrDisclose(target, parseInt(limitStr, 10), io);
11290
11357
  if (snapshots.length === 0) {
11358
+ if (!readable) {
11359
+ io.err(`mjolnir trend show: ${trendPath(target)} exists but no line in it could be parsed. That is unreadable history, not an empty history.`);
11360
+ return 20;
11361
+ }
11291
11362
  io.out("No trend data yet. Run 'mjolnir trend record' first.");
11292
11363
  return 0;
11293
11364
  }
@@ -11301,12 +11372,12 @@ async function runTrendCommand(argv, io) {
11301
11372
  return 0;
11302
11373
  }
11303
11374
  if (subcommand === "diff") {
11304
- const snapshots = loadTrend(target, 2);
11305
- if (snapshots.length < 2) {
11306
- io.out("Need at least 2 snapshots for diff. Run 'mjolnir trend record' more than once.");
11307
- return 0;
11308
- }
11375
+ const { snapshots, readable } = readTrendOrDisclose(target, 2, io);
11309
11376
  if (snapshots.length < 2) {
11377
+ if (!readable) {
11378
+ io.err(`mjolnir trend diff: ${trendPath(target)} exists but no line in it could be parsed, so there is no history to diff.`);
11379
+ return 20;
11380
+ }
11310
11381
  io.out("Need at least 2 snapshots for diff. Run 'mjolnir trend record' more than once.");
11311
11382
  return 0;
11312
11383
  }
@@ -11618,7 +11689,7 @@ function runtimeFailuresFromReport(parsed) {
11618
11689
  return evidence;
11619
11690
  }
11620
11691
  function buildQuarantineProposals(evidence) {
11621
- return evidence.filter((item) => item.attempts >= 2 && item.everFailed).sort((a, b) => b.attempts - a.attempts || a.file.localeCompare(b.file) || a.line - b.line).map((item, index) => ({
11692
+ return evidence.filter((item) => item.attempts >= 2 && item.everFailed).sort((a, b) => b.attempts - a.attempts || compareCodePoints(a.file, b.file) || a.line - b.line).map((item, index) => ({
11622
11693
  id: `Q-${String(index + 1).padStart(3, "0")}`,
11623
11694
  ruleId: item.ruleId,
11624
11695
  file: item.file,
@@ -12020,7 +12091,7 @@ async function runDashboardCommand(argv, io) {
12020
12091
  return 10;
12021
12092
  }
12022
12093
  try {
12023
- const { runScan } = await import("./scan-pipeline-Bg-46cLS.mjs");
12094
+ const { runScan } = await import("./scan-pipeline-DNYbnWjE.mjs");
12024
12095
  const result = await runScan({
12025
12096
  target,
12026
12097
  json: true,
@@ -12143,6 +12214,1282 @@ function runEnterpriseCommand(argv, io) {
12143
12214
  return 10;
12144
12215
  }
12145
12216
  //#endregion
12217
+ //#region src/frameworks/provider-capability-contract.ts
12218
+ const CI_PROVIDER_CAPABILITY_SCHEMA = "m30.ci-provider-capabilities@1";
12219
+ const CI_PROVIDER_IDS = [
12220
+ "github-actions",
12221
+ "azure-pipelines",
12222
+ "jenkins",
12223
+ "gitlab-ci"
12224
+ ];
12225
+ function unsupported(reason) {
12226
+ return {
12227
+ state: "unsupported",
12228
+ reason
12229
+ };
12230
+ }
12231
+ const CI_PROVIDER_CAPABILITY_RECORDS = [
12232
+ {
12233
+ schema: CI_PROVIDER_CAPABILITY_SCHEMA,
12234
+ provider: "github-actions",
12235
+ assurance: {
12236
+ mode: "bounded",
12237
+ certification: "not-claimed"
12238
+ },
12239
+ permissions: {
12240
+ defaultToken: "read-only",
12241
+ forkPullRequestToken: "read-only",
12242
+ untrustedInputSecrets: "unavailable",
12243
+ writeAccess: "not-granted"
12244
+ },
12245
+ capabilities: {
12246
+ triggers: {
12247
+ state: "supported",
12248
+ semantics: {
12249
+ source: "github-events",
12250
+ pathFilters: "narrow-only",
12251
+ untrustedInput: "data-only"
12252
+ }
12253
+ },
12254
+ forks: {
12255
+ state: "supported",
12256
+ semantics: {
12257
+ token: "read-only",
12258
+ secrets: "unavailable",
12259
+ checkoutCredentials: "not-persisted"
12260
+ }
12261
+ },
12262
+ conditions: {
12263
+ state: "supported",
12264
+ semantics: {
12265
+ evaluator: "github-expressions",
12266
+ untrustedExpressions: "data-only",
12267
+ skippedGate: "invalid"
12268
+ }
12269
+ },
12270
+ matrix: {
12271
+ state: "supported",
12272
+ semantics: {
12273
+ execution: "strategy-matrix",
12274
+ aggregation: "all-jobs-must-pass",
12275
+ toleratedFailures: "non-gates-only"
12276
+ }
12277
+ },
12278
+ artifacts: {
12279
+ state: "supported",
12280
+ semantics: {
12281
+ store: "run-artifacts",
12282
+ binding: "run-and-candidate-bound",
12283
+ untrustedUpload: "denied"
12284
+ }
12285
+ },
12286
+ cache: {
12287
+ state: "supported",
12288
+ semantics: {
12289
+ scope: "action-cache",
12290
+ keying: "integrity-keyed",
12291
+ forkWrite: "denied"
12292
+ }
12293
+ },
12294
+ approvals: {
12295
+ state: "supported",
12296
+ semantics: {
12297
+ mechanism: "required-checks",
12298
+ defaultMode: "advisory",
12299
+ staleApproval: "invalid"
12300
+ }
12301
+ },
12302
+ failureHandling: {
12303
+ state: "supported",
12304
+ semantics: {
12305
+ retry: "bounded-advisory",
12306
+ continueOnError: "non-gates-only",
12307
+ maskedGate: "blocking"
12308
+ }
12309
+ }
12310
+ }
12311
+ },
12312
+ {
12313
+ schema: CI_PROVIDER_CAPABILITY_SCHEMA,
12314
+ provider: "azure-pipelines",
12315
+ assurance: {
12316
+ mode: "bounded",
12317
+ certification: "not-claimed"
12318
+ },
12319
+ permissions: {
12320
+ defaultToken: "not-applicable",
12321
+ forkPullRequestToken: "unsupported",
12322
+ untrustedInputSecrets: "unavailable",
12323
+ writeAccess: "not-granted"
12324
+ },
12325
+ capabilities: {
12326
+ triggers: {
12327
+ state: "supported",
12328
+ semantics: {
12329
+ source: "azure-resource-triggers",
12330
+ pathFilters: "narrow-only",
12331
+ untrustedInput: "data-only"
12332
+ }
12333
+ },
12334
+ forks: unsupported("Azure fork pull-request token isolation is not part of this bounded contract."),
12335
+ conditions: {
12336
+ state: "supported",
12337
+ semantics: {
12338
+ evaluator: "azure-expressions",
12339
+ untrustedExpressions: "data-only",
12340
+ skippedGate: "invalid"
12341
+ }
12342
+ },
12343
+ matrix: {
12344
+ state: "supported",
12345
+ semantics: {
12346
+ execution: "jobs-matrix",
12347
+ aggregation: "all-jobs-must-pass",
12348
+ toleratedFailures: "non-gates-only"
12349
+ }
12350
+ },
12351
+ artifacts: {
12352
+ state: "supported",
12353
+ semantics: {
12354
+ store: "pipeline-artifacts",
12355
+ binding: "run-and-candidate-bound",
12356
+ untrustedUpload: "denied"
12357
+ }
12358
+ },
12359
+ cache: unsupported("Azure cache trust and restoration semantics are not part of this bounded contract."),
12360
+ approvals: {
12361
+ state: "supported",
12362
+ semantics: {
12363
+ mechanism: "branch-policy",
12364
+ defaultMode: "advisory",
12365
+ staleApproval: "invalid"
12366
+ }
12367
+ },
12368
+ failureHandling: {
12369
+ state: "supported",
12370
+ semantics: {
12371
+ retry: "bounded-advisory",
12372
+ continueOnError: "non-gates-only",
12373
+ maskedGate: "blocking"
12374
+ }
12375
+ }
12376
+ }
12377
+ },
12378
+ {
12379
+ schema: CI_PROVIDER_CAPABILITY_SCHEMA,
12380
+ provider: "jenkins",
12381
+ assurance: {
12382
+ mode: "bounded",
12383
+ certification: "not-claimed"
12384
+ },
12385
+ permissions: {
12386
+ defaultToken: "not-applicable",
12387
+ forkPullRequestToken: "unsupported",
12388
+ untrustedInputSecrets: "unavailable",
12389
+ writeAccess: "not-granted"
12390
+ },
12391
+ capabilities: {
12392
+ triggers: {
12393
+ state: "supported",
12394
+ semantics: {
12395
+ source: "jenkins-external-triggers",
12396
+ pathFilters: "narrow-only",
12397
+ untrustedInput: "data-only"
12398
+ }
12399
+ },
12400
+ forks: unsupported("Jenkins fork identity and token isolation are controller-specific and unsupported here."),
12401
+ conditions: unsupported("Untrusted Groovy condition execution is not part of this bounded contract."),
12402
+ matrix: {
12403
+ state: "supported",
12404
+ semantics: {
12405
+ execution: "parallel-axis",
12406
+ aggregation: "all-jobs-must-pass",
12407
+ toleratedFailures: "non-gates-only"
12408
+ }
12409
+ },
12410
+ artifacts: {
12411
+ state: "supported",
12412
+ semantics: {
12413
+ store: "workspace-archive",
12414
+ binding: "run-and-candidate-bound",
12415
+ untrustedUpload: "denied"
12416
+ }
12417
+ },
12418
+ cache: unsupported("Jenkins cache plugins and restoration trust are not part of this bounded contract."),
12419
+ approvals: {
12420
+ state: "supported",
12421
+ semantics: {
12422
+ mechanism: "manual-input",
12423
+ defaultMode: "advisory",
12424
+ staleApproval: "invalid"
12425
+ }
12426
+ },
12427
+ failureHandling: {
12428
+ state: "supported",
12429
+ semantics: {
12430
+ retry: "bounded-advisory",
12431
+ continueOnError: "non-gates-only",
12432
+ maskedGate: "blocking"
12433
+ }
12434
+ }
12435
+ }
12436
+ },
12437
+ {
12438
+ schema: CI_PROVIDER_CAPABILITY_SCHEMA,
12439
+ provider: "gitlab-ci",
12440
+ assurance: {
12441
+ mode: "bounded",
12442
+ certification: "not-claimed"
12443
+ },
12444
+ permissions: {
12445
+ defaultToken: "not-applicable",
12446
+ forkPullRequestToken: "unsupported",
12447
+ untrustedInputSecrets: "unavailable",
12448
+ writeAccess: "not-granted"
12449
+ },
12450
+ capabilities: {
12451
+ triggers: unsupported("GitLab provider execution is not claimed by the bounded M30 contract."),
12452
+ forks: unsupported("GitLab provider execution is not claimed by the bounded M30 contract."),
12453
+ conditions: unsupported("GitLab provider execution is not claimed by the bounded M30 contract."),
12454
+ matrix: unsupported("GitLab provider execution is not claimed by the bounded M30 contract."),
12455
+ artifacts: unsupported("GitLab provider execution is not claimed by the bounded M30 contract."),
12456
+ cache: unsupported("GitLab provider execution is not claimed by the bounded M30 contract."),
12457
+ approvals: unsupported("GitLab provider execution is not claimed by the bounded M30 contract."),
12458
+ failureHandling: unsupported("GitLab provider execution is not claimed by the bounded M30 contract.")
12459
+ }
12460
+ }
12461
+ ];
12462
+ new Map(CI_PROVIDER_CAPABILITY_RECORDS.map((record) => [record.provider, record]));
12463
+ //#endregion
12464
+ //#region src/v6/maturity.ts
12465
+ /**
12466
+ * v6 capability maturity ladder — `M0`–`M5` (ADR 0001).
12467
+ *
12468
+ * ONE implementation of the maturity axis. `L0`–`L5` is the *finding
12469
+ * trust level* (`src/types.ts:106-117`) and is never a maturity value;
12470
+ * the two axes are orthogonal (ADR 0011). The blueprint's rejected
12471
+ * `EXPERIMENTAL→…→CERTIFIED` ladder is not declared anywhere, so the
12472
+ * product has exactly two level vocabularies, each with one meaning.
12473
+ *
12474
+ * Rules this module encodes, because they are the whole point of the
12475
+ * ladder and are otherwise only prose:
12476
+ *
12477
+ * - Maturity is **granted by evidence, never by hand**. Promotion is a
12478
+ * machine transition; there is no `setMaturity` export.
12479
+ * - Every level below `M5` must expose a machine-readable
12480
+ * `nextLevelGap`. `nextLevelGap` is therefore required, not optional,
12481
+ * and a capability that cannot state its gap is a registry error.
12482
+ * - Maturity can be **demoted automatically** when a `revisitTrigger`
12483
+ * fires. That is the same rule the ecosystem census staleness trigger
12484
+ * uses (ADR 0010), which is why the trigger is a first-class field
12485
+ * here rather than a string on a doc.
12486
+ * - `M4` requires precision AND recall, never precision alone (Law 5:
12487
+ * precision must not hide poor recall, and recall must not hide poor
12488
+ * precision).
12489
+ * - `M5` is unreachable from inside the engine: it requires external
12490
+ * field evidence (`REMOTE_PROVEN`), which the zero-network default
12491
+ * never produces. A capability that claims `M5` without external
12492
+ * evidence is a false proof, not a fast promotion.
12493
+ */
12494
+ const MATURITY_LEVELS$1 = [
12495
+ "M0_UNKNOWN",
12496
+ "M1_DECLARED",
12497
+ "M2_IMPLEMENTED",
12498
+ "M3_FIXTURE_VERIFIED",
12499
+ "M4_CORPUS_VERIFIED",
12500
+ "M5_FIELD_PROVEN"
12501
+ ];
12502
+ /** The short `M0`–`M5` form. Two vocabularies, one meaning each. */
12503
+ const MATURITY_SHORT = {
12504
+ M0_UNKNOWN: "M0",
12505
+ M1_DECLARED: "M1",
12506
+ M2_IMPLEMENTED: "M2",
12507
+ M3_FIXTURE_VERIFIED: "M3",
12508
+ M4_CORPUS_VERIFIED: "M4",
12509
+ M5_FIELD_PROVEN: "M5"
12510
+ };
12511
+ /** The default for anything unclassified. Never a guess upward. */
12512
+ const DEFAULT_MATURITY = "M0_UNKNOWN";
12513
+ const RANK = {
12514
+ M0_UNKNOWN: 0,
12515
+ M1_DECLARED: 1,
12516
+ M2_IMPLEMENTED: 2,
12517
+ M3_FIXTURE_VERIFIED: 3,
12518
+ M4_CORPUS_VERIFIED: 4,
12519
+ M5_FIELD_PROVEN: 5
12520
+ };
12521
+ function isMaturity(value) {
12522
+ return typeof value === "string" && MATURITY_LEVELS$1.includes(value);
12523
+ }
12524
+ /** Unknown input ranks as `M0` — an unparseable claim is not a claim. */
12525
+ function maturityRank(value) {
12526
+ return isMaturity(value) ? RANK[value] : RANK[DEFAULT_MATURITY];
12527
+ }
12528
+ /** The next level up, or `null` at the ceiling. */
12529
+ function nextMaturity(value) {
12530
+ const rank = RANK[value];
12531
+ return rank >= MATURITY_LEVELS$1.length - 1 ? null : MATURITY_LEVELS$1[rank + 1] ?? null;
12532
+ }
12533
+ MATURITY_LEVELS$1.filter((level) => level !== "M5_FIELD_PROVEN");
12534
+ /**
12535
+ * Derive maturity from evidence. **The only way a capability gets a
12536
+ * level** — there is no `setMaturity` export, and a hand-written level in
12537
+ * a registry is a defect, not a default.
12538
+ *
12539
+ * The rule is monotone: each level requires its own criterion *and* every
12540
+ * criterion below it. A capability with corpus proof but no fixtures
12541
+ * lands at `M2`, because `M4`'s criterion is a chain, not a ceiling — a
12542
+ * real corpus run without a deterministic fixture quad is exactly the
12543
+ * state that must not be advertised as `M4`.
12544
+ */
12545
+ function deriveMaturityFromEvidence(evidence) {
12546
+ if (evidence.fieldProven) return "M5_FIELD_PROVEN";
12547
+ if (evidence.corpusVerified && evidence.fixtureQuadVerified && evidence.unitTested && evidence.implemented && evidence.declared) return "M4_CORPUS_VERIFIED";
12548
+ if (evidence.fixtureQuadVerified && evidence.implemented && evidence.declared) return "M3_FIXTURE_VERIFIED";
12549
+ if (evidence.implemented && evidence.unitTested && evidence.declared) return "M2_IMPLEMENTED";
12550
+ if (evidence.declared) return "M1_DECLARED";
12551
+ return "M0_UNKNOWN";
12552
+ }
12553
+ /**
12554
+ * Build the `nextLevelGap` for whatever the evidence currently supports.
12555
+ * `missing` is derived from the *unsatisfied* criteria of the next level
12556
+ * up, so a gap can never quietly go stale against the ladder.
12557
+ */
12558
+ function nextLevelGapFromEvidence(evidence, owner, revisitTrigger) {
12559
+ const maturity = deriveMaturityFromEvidence(evidence);
12560
+ const target = nextMaturity(maturity);
12561
+ if (target === null) return {
12562
+ maturity,
12563
+ nextLevelGap: null
12564
+ };
12565
+ const missing = [];
12566
+ if (target === "M1_DECLARED" && !evidence.declared) missing.push("registry entry with an owner and a claim");
12567
+ if (target === "M2_IMPLEMENTED") {
12568
+ if (!evidence.implemented) missing.push("implementation exists");
12569
+ if (!evidence.unitTested) missing.push("unit tests for the implementation");
12570
+ }
12571
+ if (target === "M3_FIXTURE_VERIFIED") {
12572
+ if (!evidence.fixtureQuadVerified) missing.push("positive + negative + boundary + adversarial fixtures pass deterministically in CI");
12573
+ }
12574
+ if (target === "M4_CORPUS_VERIFIED") {
12575
+ if (!evidence.corpusVerified) missing.push("real-repository corpus run, n >= 10 classified verdicts, Wilson CI recorded, precision AND recall measured, detectorRev locked");
12576
+ }
12577
+ if (target === "M5_FIELD_PROVEN") {
12578
+ if (!evidence.fieldProven) missing.push("independent real-world repositories, declared field support, stable confidence interval, candidate-bound evidence");
12579
+ }
12580
+ return {
12581
+ maturity,
12582
+ nextLevelGap: {
12583
+ target,
12584
+ missing,
12585
+ owner,
12586
+ revisitTrigger
12587
+ }
12588
+ };
12589
+ }
12590
+ [
12591
+ {
12592
+ id: "trustLevel",
12593
+ fields: ["trustLevel"],
12594
+ vocabulary: [...TRUST_ORDER],
12595
+ question: "How much do we trust this finding? (src/types.ts:106-117)"
12596
+ },
12597
+ {
12598
+ id: "maturity",
12599
+ fields: ["maturity"],
12600
+ vocabulary: MATURITY_LEVELS$1,
12601
+ question: "How much has this capability been proven? (ADR 0001)"
12602
+ },
12603
+ {
12604
+ id: "evidenceState",
12605
+ fields: ["evidenceState", "state"],
12606
+ vocabulary: [
12607
+ "FRESH",
12608
+ "STALE",
12609
+ "FOREIGN",
12610
+ "NOT_RUN",
12611
+ "PARTIAL",
12612
+ "CONTRADICTORY",
12613
+ "INVALID",
12614
+ "UNKNOWN"
12615
+ ],
12616
+ question: "Is this evidence current and admissible?"
12617
+ },
12618
+ {
12619
+ id: "determination",
12620
+ fields: ["determination", "verdict"],
12621
+ vocabulary: [
12622
+ "PASS",
12623
+ "FAILED",
12624
+ "BLOCKED",
12625
+ "INCONCLUSIVE",
12626
+ "UNPROVEN",
12627
+ "PARTIAL"
12628
+ ],
12629
+ question: "What is the verdict? (closed set, TRUST-CONSTITUTION §2)"
12630
+ },
12631
+ {
12632
+ id: "lifecycle",
12633
+ fields: ["lifecycle", "ruleStatus"],
12634
+ vocabulary: [
12635
+ "PROPOSED",
12636
+ "EXPERIMENTAL",
12637
+ "PROVISIONAL",
12638
+ "VERIFIED",
12639
+ "PROVEN",
12640
+ "DEPRECATED",
12641
+ "RETIRED"
12642
+ ],
12643
+ question: "Where is this rule in its career?"
12644
+ },
12645
+ {
12646
+ id: "severity",
12647
+ fields: [
12648
+ "severity",
12649
+ "trustImpact",
12650
+ "policySeverity"
12651
+ ],
12652
+ vocabulary: [
12653
+ "CRITICAL",
12654
+ "HIGH",
12655
+ "MEDIUM",
12656
+ "LOW",
12657
+ "INFO",
12658
+ "NONE"
12659
+ ],
12660
+ question: "How much does it matter? (technical severity stays separate)"
12661
+ }
12662
+ ].map((axis) => axis.id);
12663
+ //#endregion
12664
+ //#region src/v6/ecosystem-census.ts
12665
+ /**
12666
+ * The leading major of a version or range. `FRAMEWORK_INVENTORY`
12667
+ * records *validated versions* (`1.44`, `29`) while a manifest records a
12668
+ * *range* (`^29.7.0`); both sides normalise to a major so the staleness
12669
+ * trigger compares like with like. A trigger that fires on a
12670
+ * representation difference is a trigger people learn to ignore.
12671
+ */
12672
+ function versionMajor(raw) {
12673
+ const match = /\d+(?:\.\d+)*/.exec(raw);
12674
+ if (!match) return null;
12675
+ return (match[0] ?? "").split(".")[0] ?? null;
12676
+ }
12677
+ //#endregion
12678
+ //#region src/v6/ecosystem-probe.ts
12679
+ /**
12680
+ * Filesystem-backed census evidence + the discovery probe (ADR 0010).
12681
+ *
12682
+ * This module is the executable half of the census. Two jobs, both of
12683
+ * which exist because a census nobody diffs against reality is just a
12684
+ * nicer-looking hand-maintained list:
12685
+ *
12686
+ * 1. **Evidence resolution** — a `CensusEvidenceResolver` that observes
12687
+ * the real checkout, so a census entry's maturity is *derived* from
12688
+ * artifacts that exist rather than typed by a person. This is what
12689
+ * makes D1 mechanical: a capability ships only at the maturity the
12690
+ * machine proves.
12691
+ *
12692
+ * 2. **The discovery probe** — a walk over the checked-out corpus
12693
+ * (`tests/corpus/.cache/`) that reports every QA tool it sees,
12694
+ * including tools the census has never heard of. Those land as
12695
+ * `UNRECOGNIZED`, which is a first-class signal: a new tool the
12696
+ * engine sees in the wild and silently ignores is a release-blocking
12697
+ * honesty failure, not a backlog item.
12698
+ *
12699
+ * The resolver is deliberately **conservative**. Where no machine gate
12700
+ * exists to prove a criterion, the resolver returns `false` and the
12701
+ * entry lands lower. That direction of error is the safe one: an
12702
+ * under-claim is honest, an over-claim is a false proof (Law 1). Every
12703
+ * `false` returned here is therefore a real, named Wave-4 gate that does
12704
+ * not exist yet — see the `nextLevelGap` each entry carries.
12705
+ */
12706
+ const CORPUS_CACHE_DIR = join("tests", "corpus", ".cache");
12707
+ const FIXTURES_DIR = join("tests", "fixtures");
12708
+ const VERDICTS_DIR = join("tests", "corpus", "verdicts");
12709
+ const ADAPTERS_DIR = join("src", "adapters");
12710
+ const RULES_TESTS_DIR = join("tests", "rules");
12711
+ const ADAPTER_TESTS_DIR = join("tests", "adapters");
12712
+ /** `QA-PW-*` → `QA-PW`. The family prefix is the id's domain segment. */
12713
+ function ruleFamily$1(ruleId) {
12714
+ const parts = ruleId.split("-");
12715
+ return parts.length >= 2 ? `${parts[0]}-${parts[1]}` : ruleId;
12716
+ }
12717
+ /**
12718
+ * Census ids are `ec.<category>.<slug>`, so the rule family they map to
12719
+ * is derived from the slug. This mapping is the join key between the
12720
+ * census and the rule registry — ADR 0007 in practice: the census
12721
+ * projects the existing registry rather than restating it.
12722
+ */
12723
+ const SLUG_TO_FAMILY = {
12724
+ playwright: "QA-PW",
12725
+ cypress: "QA-CYP",
12726
+ selenium: "QA-SEL",
12727
+ jest: "QA-JV",
12728
+ vitest: "QA-JV",
12729
+ mocha: "QA-JV",
12730
+ jasmine: "QA-JV",
12731
+ pytest: "QA-PY",
12732
+ unittest: "QA-PY",
12733
+ "github-actions": "QA-CI",
12734
+ "azure-devops": "QA-CI",
12735
+ jenkins: "QA-CI",
12736
+ "gitlab-ci": "QA-CI",
12737
+ typescript: "QA-TEST",
12738
+ python: "QA-PY",
12739
+ java: "QA-JV",
12740
+ csharp: "QA-CS"
12741
+ };
12742
+ function familyForEntry(entry) {
12743
+ const slug = entry.id.split(".").pop() ?? "";
12744
+ return SLUG_TO_FAMILY[slug] ?? null;
12745
+ }
12746
+ function groupBy(ids) {
12747
+ const map = /* @__PURE__ */ new Map();
12748
+ for (const id of ids) {
12749
+ const family = ruleFamily$1(id);
12750
+ const bucket = map.get(family) ?? [];
12751
+ bucket.push(id);
12752
+ map.set(family, bucket);
12753
+ }
12754
+ return map;
12755
+ }
12756
+ function listFilesRecursive(dir, depth = 0) {
12757
+ if (depth > 4 || !existsSync(dir)) return [];
12758
+ const out = [];
12759
+ let entries;
12760
+ try {
12761
+ entries = readdirSync(dir, { withFileTypes: true });
12762
+ } catch {
12763
+ return [];
12764
+ }
12765
+ for (const entry of entries) {
12766
+ const full = join(dir, entry.name);
12767
+ if (entry.isDirectory()) out.push(...listFilesRecursive(full, depth + 1));
12768
+ else out.push(full);
12769
+ }
12770
+ return out;
12771
+ }
12772
+ /**
12773
+ * Build the evidence index from the real checkout. Pure with respect to
12774
+ * the census (it takes a root), so the same function serves the
12775
+ * generator, the gate and the test fixture.
12776
+ */
12777
+ function buildRepoEvidenceIndex(root) {
12778
+ const liveIds = RULES.map((rule) => rule.id);
12779
+ const rulesByFamily = groupBy(liveIds);
12780
+ const measured = [];
12781
+ for (const rule of RULES) {
12782
+ const measurement = MEASURED_FP[rule.id];
12783
+ if (measurement && measurement.detectorRevision === declaredDetectorRevision(rule)) measured.push(rule.id);
12784
+ }
12785
+ const measuredByFamily = groupBy(measured);
12786
+ const ruleSpecSources = listFilesRecursive(RULES_TESTS_DIR).filter((file) => file.endsWith(".ts")).map((file) => readFileSync(file, "utf8"));
12787
+ const testedByFamily = groupBy(liveIds.filter((id) => ruleSpecSources.some((source) => source.includes(id))));
12788
+ const quad = [];
12789
+ if (existsSync(join(root, FIXTURES_DIR))) for (const id of liveIds) {
12790
+ const dir = join(root, FIXTURES_DIR, id);
12791
+ if (!existsSync(dir)) continue;
12792
+ try {
12793
+ if (listFilesRecursive(dir).length >= 4) quad.push(id);
12794
+ } catch {}
12795
+ }
12796
+ const fixtureQuadByFamily = groupBy(quad);
12797
+ const corpusVerdictsByFamily = /* @__PURE__ */ new Map();
12798
+ const verdictsDir = join(root, VERDICTS_DIR);
12799
+ if (existsSync(verdictsDir)) for (const file of readdirSync(verdictsDir)) {
12800
+ if (!file.endsWith(".jsonl")) continue;
12801
+ for (const line of readFileSync(join(verdictsDir, file), "utf8").split("\n")) {
12802
+ if (!line.trim()) continue;
12803
+ try {
12804
+ const entry = JSON.parse(line);
12805
+ if (!entry.ruleId) continue;
12806
+ if (entry.verdict !== "TP" && entry.verdict !== "FP") continue;
12807
+ const family = ruleFamily$1(entry.ruleId);
12808
+ corpusVerdictsByFamily.set(family, (corpusVerdictsByFamily.get(family) ?? 0) + 1);
12809
+ } catch {}
12810
+ }
12811
+ }
12812
+ const adapters = /* @__PURE__ */ new Set();
12813
+ const adapterDir = join(root, ADAPTERS_DIR);
12814
+ if (existsSync(adapterDir)) {
12815
+ for (const file of readdirSync(adapterDir)) if (file.endsWith(".ts")) adapters.add(file.replace(/\.ts$/, ""));
12816
+ }
12817
+ const adapterSpecNames = /* @__PURE__ */ new Set();
12818
+ for (const dir of [ADAPTER_TESTS_DIR, join("tests", "frameworks")]) for (const file of listFilesRecursive(join(root, dir))) {
12819
+ if (!file.endsWith(".spec.ts")) continue;
12820
+ const source = readFileSync(file, "utf8");
12821
+ for (const adapter of adapters) if (source.includes(adapter)) adapterSpecNames.add(adapter);
12822
+ }
12823
+ const corpusRoot = join(root, CORPUS_CACHE_DIR);
12824
+ return {
12825
+ rulesByFamily,
12826
+ measuredByFamily,
12827
+ testedByFamily,
12828
+ fixtureQuadByFamily,
12829
+ corpusVerdictsByFamily,
12830
+ adapters,
12831
+ adapterSpecNames,
12832
+ corpusRepos: existsSync(corpusRoot) ? readdirSync(corpusRoot).filter((name) => {
12833
+ try {
12834
+ return statSync(join(corpusRoot, name)).isDirectory();
12835
+ } catch {
12836
+ return false;
12837
+ }
12838
+ }) : []
12839
+ };
12840
+ }
12841
+ /**
12842
+ * Build the resolver from the index. Every arm is conservative and each
12843
+ * `false` is a *named missing gate*, not an oversight — see the comments.
12844
+ */
12845
+ function createEvidenceResolver(index, root) {
12846
+ return {
12847
+ adapterExists(entry) {
12848
+ const family = familyForEntry(entry);
12849
+ if (family && (index.rulesByFamily.get(family)?.length ?? 0) > 0) return true;
12850
+ const slug = entry.id.split(".").pop() ?? "";
12851
+ return index.adapters.has(slug);
12852
+ },
12853
+ unitTested(entry) {
12854
+ const family = familyForEntry(entry);
12855
+ if (family && (index.testedByFamily.get(family)?.length ?? 0) > 0) return true;
12856
+ const slug = entry.id.split(".").pop() ?? "";
12857
+ return index.adapterSpecNames.has(slug);
12858
+ },
12859
+ fixtureQuadVerified() {
12860
+ return false;
12861
+ },
12862
+ corpusVerified() {
12863
+ return false;
12864
+ },
12865
+ fieldProven() {
12866
+ return false;
12867
+ },
12868
+ observedUpstreamMajor(entry) {
12869
+ return detectUpstreamMajor(entry, root);
12870
+ }
12871
+ };
12872
+ }
12873
+ /**
12874
+ * The npm package name(s) a census slug corresponds to, if any.
12875
+ *
12876
+ * Read from the entry's own `upstreamPackages` — the census is the
12877
+ * authority on naming, so a consumer holding its own copy of the answer
12878
+ * is the parallel-inventory drift ADR 0007 exists to prevent. An earlier
12879
+ * version kept a local map here and a heuristic on the slug, and the
12880
+ * consequence was concrete: `@percy/cypress` was read as `cypress` and
12881
+ * `@swc/jest` as `jest`, inventing staleness verdicts for plugins whose
12882
+ * versions do not track the runner.
12883
+ */
12884
+ function npmPackageNames(entry) {
12885
+ return entry.upstreamPackages.filter((pkg) => pkg.ecosystem === "npm" || pkg.ecosystem === "any").map((pkg) => pkg.name);
12886
+ }
12887
+ /**
12888
+ * Detect the upstream major version a corpus repository actually uses,
12889
+ * when it is discoverable from a manifest. Returns `null` when it is not
12890
+ * — and `null` means "no staleness verdict", never "up to date".
12891
+ */
12892
+ function detectUpstreamMajor(entry, root) {
12893
+ if (entry.handledUpstreamMajors.length === 0) return null;
12894
+ const upstream = npmPackageNames(entry);
12895
+ if (upstream.length === 0) return null;
12896
+ for (const repo of corpusReposOf(root)) {
12897
+ const pkgPath = join(root, CORPUS_CACHE_DIR, repo, "package.json");
12898
+ if (!existsSync(pkgPath)) continue;
12899
+ try {
12900
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
12901
+ const deps = {
12902
+ ...pkg.dependencies ?? {},
12903
+ ...pkg.devDependencies ?? {},
12904
+ ...pkg.peerDependencies ?? {}
12905
+ };
12906
+ for (const name of upstream) {
12907
+ const range = deps[name];
12908
+ if (range === void 0) continue;
12909
+ const major = versionMajor(range);
12910
+ if (major !== null) return major;
12911
+ }
12912
+ } catch {}
12913
+ }
12914
+ return null;
12915
+ }
12916
+ function corpusReposOf(root) {
12917
+ const corpusRoot = join(root, CORPUS_CACHE_DIR);
12918
+ if (!existsSync(corpusRoot)) return [];
12919
+ return readdirSync(corpusRoot).filter((name) => {
12920
+ try {
12921
+ return statSync(join(corpusRoot, name)).isDirectory();
12922
+ } catch {
12923
+ return false;
12924
+ }
12925
+ });
12926
+ }
12927
+ //#endregion
12928
+ //#region src/v6/capability-registry.ts
12929
+ /**
12930
+
12931
+ }
12932
+
12933
+ /**
12934
+ * The unified capability registry (ADR 0007) — Wave 1.
12935
+ *
12936
+ * **One registry, never a parallel one.** Before this module the
12937
+ * repository held six capability-adjacent primitives (framework
12938
+ * inventory, provider capability contract, universal pack contract, rule
12939
+ * registry census, the 136-cell support matrix, `src/capabilities.ts`), and
12940
+ * none of them agreed with the others. That is the drift Law 8 exists to
12941
+ * prevent, and it is why a hand-maintained support list is a documentation
12942
+ * defect *by construction* rather than by negligence.
12943
+ *
12944
+ * This module is the **loader**. It reads from the existing primitives and
12945
+ * assembles one entry per capability. It deliberately does not:
12946
+ *
12947
+ * - re-declare any framework, provider, language or rule list;
12948
+ * - contain a `setMaturity` export (maturity is derived — ADR 0001);
12949
+ * - let a consumer hand-write a claim (every claim resolves to a
12950
+ * `ProofRef`, and `claim-registry` is the only place a claim lives).
12951
+ *
12952
+ * A **capability id is not a rule id.** Capability ids live in this
12953
+ * namespace and *reference* `QA-*` ids, which are frozen forever
12954
+ * (ADR 0003). A capability is a claim about coverage; a rule is a
12955
+ * detector. Collapsing them would forbid the one-capability-several-rules
12956
+ * case that already exists.
12957
+ *
12958
+ * The 136-cell support matrix becomes a **projection** of this registry
12959
+ * rather than a second truth (§2.3), which is what makes a matrix cell
12960
+ * impossible to disagree with a capability entry.
12961
+ */
12962
+ function isRuleId(value) {
12963
+ return typeof value === "string" && /^QA-[A-Z]+-\d+$/.test(value);
12964
+ }
12965
+ const BLIND_EVIDENCE = {
12966
+ hasLiveRule: false,
12967
+ hasMeasurement: false,
12968
+ hasAdapter: false,
12969
+ hasFixtureQuad: false,
12970
+ hasCorpusMeasurement: false,
12971
+ hasFieldEvidence: false
12972
+ };
12973
+ function toMaturityEvidence(e) {
12974
+ return {
12975
+ declared: true,
12976
+ implemented: e.hasLiveRule || e.hasAdapter,
12977
+ unitTested: e.hasLiveRule,
12978
+ fixtureQuadVerified: e.hasFixtureQuad,
12979
+ corpusVerified: e.hasCorpusMeasurement,
12980
+ fieldProven: e.hasFieldEvidence
12981
+ };
12982
+ }
12983
+ const BLIND_REGISTRY_EVIDENCE = {
12984
+ registry: BLIND_EVIDENCE,
12985
+ files: {
12986
+ adapterExists: () => false,
12987
+ fixtureQuadExists: () => false,
12988
+ observedUpstreamMajor: () => null
12989
+ }
12990
+ };
12991
+ function ruleFamily(ruleId) {
12992
+ const parts = ruleId.split("-");
12993
+ return parts.length >= 2 ? `${parts[0]}-${parts[1]}` : ruleId;
12994
+ }
12995
+ function collectRuleFacts() {
12996
+ const group = (ids) => {
12997
+ const map = /* @__PURE__ */ new Map();
12998
+ for (const id of ids) {
12999
+ const family = ruleFamily(id);
13000
+ const bucket = map.get(family) ?? [];
13001
+ bucket.push(id);
13002
+ map.set(family, bucket);
13003
+ }
13004
+ return map;
13005
+ };
13006
+ const live = RULES.map((rule) => rule.id);
13007
+ const measured = RULES.filter((rule) => {
13008
+ const measurement = MEASURED_FP[rule.id];
13009
+ return measurement !== void 0 && measurement.detectorRevision === declaredDetectorRevision(rule);
13010
+ }).map((rule) => rule.id);
13011
+ return {
13012
+ liveByFamily: group(live),
13013
+ measuredByFamily: group(measured),
13014
+ tierByFamily: group(live.map((id) => effectiveTier(getRuleById(id)))),
13015
+ retired: new Set(RETIRED_RULE_IDS)
13016
+ };
13017
+ }
13018
+ function getRuleById(id) {
13019
+ const rule = RULES.find((candidate) => candidate.id === id);
13020
+ if (rule === void 0) throw new Error(`rule ${id} is in the registry index but not in RULES`);
13021
+ return rule;
13022
+ }
13023
+ /**
13024
+ * The framework-inventory id becomes a capability id in the
13025
+ * `test.framework.*` / `test.runner.*` namespace. The mapping is a pure
13026
+ * function of the inventory row, so a framework added to the inventory
13027
+ * appears in the registry without anybody typing its name a second time.
13028
+ */
13029
+ function frameworkCapabilityId(frameworkId) {
13030
+ return `test.framework.${frameworkId.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")}`;
13031
+ }
13032
+ function providerCapabilityId(provider) {
13033
+ return `ci.provider.${provider.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")}`;
13034
+ }
13035
+ function domainCapabilityId(domainId) {
13036
+ return `qa.domain.${domainId.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")}`;
13037
+ }
13038
+ function finalize(base, evidence, observedAt) {
13039
+ const { maturity, nextLevelGap } = nextLevelGapFromEvidence(toMaturityEvidence(evidence.registry), base.owner, `capability ${base.id} gains evidence for its next level`);
13040
+ return {
13041
+ ...base,
13042
+ proven: maturity,
13043
+ maturity,
13044
+ nextLevelGap,
13045
+ proof: proofFor(base, maturity, evidence, observedAt, collectRuleFacts())
13046
+ };
13047
+ }
13048
+ /**
13049
+ * The proof pointer.
13050
+ *
13051
+ * **Only a measured rule can carry `LOCAL_PROVEN`.** An adapter file on
13052
+ * disk proves the capability is *implemented*, which is M2, but it is not
13053
+ * an observation: nobody recorded when it was checked, against which
13054
+ * revision, with what result. Promoting it to `LOCAL_PROVEN` would be the
13055
+ * precise failure the wave-1 DoD forbids — "no capability advertised
13056
+ * above proven level" — and it is why the honest answer for most entries
13057
+ * today is `BLOCKED`.
13058
+ *
13059
+ * That `BLOCKED` is not a gap in this module. It is the measurement that
13060
+ * Wave 4 produces: a per-capability `detectorRev` binding with a recorded
13061
+ * observation date. Until then, every claim being unbound is the truth
13062
+ * (`GAP-V6-006`), and `claim-registry.json` shows exactly that.
13063
+ */
13064
+ function proofFor(base, maturity, evidence, observedAt, facts) {
13065
+ if (maturityRank(maturity) >= maturityRank("M2_IMPLEMENTED")) {
13066
+ const measured = evidence.registry.hasMeasurement ? base.rules.find((rule) => !facts.retired.has(rule)) : void 0;
13067
+ if (measured !== void 0 && observedAt !== "") return {
13068
+ status: "LOCAL_PROVEN",
13069
+ artifact: `docs/FP-AUDIT.md#${measured}`,
13070
+ digest: null,
13071
+ observedAt,
13072
+ authority: base.owner
13073
+ };
13074
+ }
13075
+ return {
13076
+ status: "BLOCKED",
13077
+ artifact: null,
13078
+ digest: null,
13079
+ observedAt: null,
13080
+ authority: "NONE"
13081
+ };
13082
+ }
13083
+ /**
13084
+ * Build the registry. The result is a function of the checkout plus the
13085
+ * supplied evidence, and of nothing else — which is what makes
13086
+ * `docs/capability-registry.json` byte-stable for a commit.
13087
+ */
13088
+ function buildCapabilityRegistry(options = {}) {
13089
+ const root = options.root ?? process.cwd();
13090
+ const evidence = options.evidence ?? BLIND_REGISTRY_EVIDENCE;
13091
+ const observedAt = options.observedAt ?? "1970-01-01";
13092
+ const facts = collectRuleFacts();
13093
+ const byId = /* @__PURE__ */ new Map();
13094
+ for (const framework of FRAMEWORK_INVENTORY) {
13095
+ if (framework.entityType === "CI_PROVIDER") continue;
13096
+ const id = frameworkCapabilityId(framework.frameworkId);
13097
+ const family = familyForFramework(framework.frameworkId);
13098
+ const live = facts.liveByFamily.get(family) ?? [];
13099
+ const adapter = framework.executorAdapterIds.length > 0 ? `src/adapters/${framework.executorAdapterIds[0]}.ts` : null;
13100
+ byId.set(id, finalize({
13101
+ id,
13102
+ name: framework.frameworkId,
13103
+ kind: kindForFramework(framework.entityType),
13104
+ owner: "framework-inventory",
13105
+ rules: live,
13106
+ censusId: null,
13107
+ frameworkId: framework.frameworkId,
13108
+ domains: [],
13109
+ blocksAxes: ["Discovery"],
13110
+ observedUpstreamMajor: evidence.files.observedUpstreamMajor(framework.frameworkId),
13111
+ adapter
13112
+ }, {
13113
+ registry: {
13114
+ ...evidence.registry,
13115
+ hasAdapter: evidence.registry.hasAdapter || adapter !== null,
13116
+ hasFixtureQuad: evidence.registry.hasFixtureQuad || live.some((rule) => evidence.files.fixtureQuadExists(rule))
13117
+ },
13118
+ files: evidence.files
13119
+ }, observedAt));
13120
+ }
13121
+ for (const provider of CI_PROVIDER_IDS) {
13122
+ const id = providerCapabilityId(provider);
13123
+ if (byId.has(id)) continue;
13124
+ const live = facts.liveByFamily.get("QA-CI") ?? [];
13125
+ const adapter = `src/adapters/${provider.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.ts`;
13126
+ byId.set(id, finalize({
13127
+ id,
13128
+ name: provider,
13129
+ kind: "ci-cd-provider",
13130
+ owner: "provider-capability-contract",
13131
+ rules: live,
13132
+ censusId: null,
13133
+ frameworkId: null,
13134
+ domains: ["ci-integrity"],
13135
+ blocksAxes: ["CI Integrity", "False-Green Detection"],
13136
+ observedUpstreamMajor: null,
13137
+ adapter
13138
+ }, {
13139
+ registry: {
13140
+ ...evidence.registry,
13141
+ hasAdapter: evidence.registry.hasAdapter || evidence.files.adapterExists(adapter),
13142
+ hasFixtureQuad: evidence.registry.hasFixtureQuad || live.some((rule) => evidence.files.fixtureQuadExists(rule))
13143
+ },
13144
+ files: evidence.files
13145
+ }, observedAt));
13146
+ }
13147
+ for (const domain of readDomainCells(root)) {
13148
+ const id = domainCapabilityId(domain.id);
13149
+ if (byId.has(id)) continue;
13150
+ byId.set(id, finalize({
13151
+ id,
13152
+ name: domain.label,
13153
+ kind: "domain",
13154
+ owner: domain.owner,
13155
+ rules: [],
13156
+ censusId: null,
13157
+ frameworkId: null,
13158
+ domains: [domain.id],
13159
+ blocksAxes: ["Domain Coverage"],
13160
+ observedUpstreamMajor: null,
13161
+ adapter: null,
13162
+ declaredDisposition: domain.disposition,
13163
+ blockedReason: domain.blockedReason,
13164
+ revisitTrigger: domain.revisitTrigger
13165
+ }, {
13166
+ registry: BLIND_EVIDENCE,
13167
+ files: evidence.files
13168
+ }, observedAt));
13169
+ }
13170
+ return {
13171
+ schemaVersion: 1,
13172
+ registryId: "mjolnir-capability-registry",
13173
+ observedAt,
13174
+ entries: [...byId.values()].sort((a, b) => compareCodePoints(a.id, b.id))
13175
+ };
13176
+ }
13177
+ const FRAMEWORK_FAMILY = {
13178
+ playwright: "QA-PW",
13179
+ cypress: "QA-CYP",
13180
+ selenium: "QA-SEL",
13181
+ jest: "QA-JV",
13182
+ vitest: "QA-JV",
13183
+ mocha: "QA-JV",
13184
+ jasmine: "QA-JV",
13185
+ pytest: "QA-PY",
13186
+ typescript: "QA-TEST",
13187
+ "github-actions": "QA-CI",
13188
+ "azure-pipelines": "QA-CI",
13189
+ jenkins: "QA-CI",
13190
+ "gitlab-ci": "QA-CI"
13191
+ };
13192
+ /** The rule family a framework's capability is evidenced by. */
13193
+ function familyForFramework(frameworkId) {
13194
+ return FRAMEWORK_FAMILY[frameworkId] ?? "QA-TEST";
13195
+ }
13196
+ function kindForFramework(entityType) {
13197
+ switch (entityType) {
13198
+ case "E2E_FRAMEWORK": return "test-framework";
13199
+ case "AUTOMATION_LIBRARY": return "test-runner";
13200
+ default: return "test-framework";
13201
+ }
13202
+ }
13203
+ /**
13204
+ * Read the `MATRIX-DOMAIN-*` cells from the support matrix.
13205
+ *
13206
+ * Returns an empty list when the matrix is absent rather than throwing: a
13207
+ * missing ledger means "no domain claims are derivable", which is a gap the
13208
+ * gate reports — not a crash in the middle of building a registry.
13209
+ */
13210
+ function readDomainCells(root = process.cwd()) {
13211
+ const path = join(root, "docs", "M26-SUPPORT-MATRIX.json");
13212
+ if (!existsSync(path)) return [];
13213
+ let cells;
13214
+ try {
13215
+ cells = JSON.parse(readFileSync(path, "utf8")).cells ?? [];
13216
+ } catch {
13217
+ return [];
13218
+ }
13219
+ const out = [];
13220
+ for (const cell of cells) {
13221
+ const cellId = typeof cell.cell_id === "string" ? cell.cell_id : "";
13222
+ if (!cellId.startsWith("MATRIX-DOMAIN-")) continue;
13223
+ const id = cellId.slice(14);
13224
+ if (id === "") continue;
13225
+ out.push({
13226
+ id,
13227
+ label: typeof cell.cell === "string" ? cell.cell : id,
13228
+ disposition: typeof cell.disposition === "string" ? cell.disposition : "UNKNOWN",
13229
+ owner: typeof cell.owner === "string" ? cell.owner : "unowned",
13230
+ blockedReason: typeof cell.blocked_reason === "string" ? cell.blocked_reason : null,
13231
+ revisitTrigger: typeof cell.revisit_trigger === "string" ? cell.revisit_trigger : null
13232
+ });
13233
+ }
13234
+ return out.sort((a, b) => compareCodePoints(a.id, b.id));
13235
+ }
13236
+ /**
13237
+ * Evidence resolved against the real working tree.
13238
+ *
13239
+ * `hasLiveRule` and `hasMeasurement` come from the **rule registry**, not
13240
+ * from the filesystem, and they are read here rather than inside the
13241
+ * builder so that a test can inject an evidence object and have it mean
13242
+ * something. That matters: an earlier version of the builder read the
13243
+ * rule facts itself and only honoured the *capability-level* arms from its
13244
+ * caller, so a test that injected "no measurement" still saw
13245
+ * `LOCAL_PROVEN`. An evidence seam that silently ignores half of itself is
13246
+ * not a seam.
13247
+ *
13248
+ * The `hasCorpusMeasurement` arm is **always false** here, because nothing
13249
+ * binds a *capability* to a locked `detectorRev` yet — that is Wave 4's
13250
+ * `rules:quality:check`. A capability claiming M4 on the strength of a
13251
+ * per-rule measurement is exactly the false proof ADR 0001 forbids, so
13252
+ * M3/M4 stay unreachable until the per-capability gate exists.
13253
+ */
13254
+ function realCapabilityEvidence() {
13255
+ const probe = createEvidenceResolver(buildRepoEvidenceIndex(process.cwd()), process.cwd());
13256
+ const facts = collectRuleFacts();
13257
+ const measured = new Set([...facts.measuredByFamily.values()].flat());
13258
+ return {
13259
+ registry: {
13260
+ hasLiveRule: new Set([...facts.liveByFamily.values()].flat()).size > 0,
13261
+ hasMeasurement: measured.size > 0,
13262
+ hasAdapter: false,
13263
+ hasFixtureQuad: false,
13264
+ hasCorpusMeasurement: false,
13265
+ hasFieldEvidence: false
13266
+ },
13267
+ files: {
13268
+ adapterExists: (relPath) => {
13269
+ const slug = relPath.replace(/^src\/adapters\//, "").replace(/\.ts$/, "");
13270
+ return probe.adapterExists(adapterProbeEntry(slug));
13271
+ },
13272
+ fixtureQuadExists: () => fixtureQuadExists(),
13273
+ observedUpstreamMajor: (frameworkId) => probe.observedUpstreamMajor(adapterProbeEntry(frameworkId))
13274
+ }
13275
+ };
13276
+ }
13277
+ function adapterProbeEntry(slug) {
13278
+ return {
13279
+ id: `ec.test-framework.${slug}`,
13280
+ name: slug,
13281
+ category: "test-framework",
13282
+ state: "SUPPORTED",
13283
+ owner: "capability-registry",
13284
+ observedAt: "1970-01-01",
13285
+ signals: [],
13286
+ adapter: `src/adapters/${slug}.ts`,
13287
+ blocksAxes: [],
13288
+ successor: null,
13289
+ removalDate: null,
13290
+ notApplicableReason: null,
13291
+ upstreamPackages: [{
13292
+ ecosystem: "npm",
13293
+ name: slug
13294
+ }],
13295
+ handledUpstreamMajors: [],
13296
+ revisitTrigger: "",
13297
+ maturity: "M1_DECLARED",
13298
+ nextLevelGap: null
13299
+ };
13300
+ }
13301
+ /**
13302
+ * A per-rule fixture quad is a real filesystem question, and the answer
13303
+ * here is deliberately **no**: the repository's fixture directories are a
13304
+ * proxy, and a proxy must not buy an M3 claim (Law 1). Wave 4 ships the
13305
+ * real gate.
13306
+ */
13307
+ function fixtureQuadExists() {
13308
+ return false;
13309
+ }
13310
+ /**
13311
+ * `registry:check` — the Wave 1 DoD gate.
13312
+ *
13313
+ * The single most important rule here is **no capability advertised above
13314
+ * proven level**. A capability whose `maturity` outranks what its own
13315
+ * evidence supports is a false proof, and this is the function every
13316
+ * surface calls before it renders a maturity badge.
13317
+ */
13318
+ function validateRegistry(registry) {
13319
+ const diagnostics = [];
13320
+ const seen = /* @__PURE__ */ new Set();
13321
+ const liveIds = new Set(RULES.map((rule) => rule.id));
13322
+ const retired = new Set(RETIRED_RULE_IDS);
13323
+ for (const entry of registry.entries) {
13324
+ if (seen.has(entry.id)) diagnostics.push({
13325
+ code: "DUPLICATE_ID",
13326
+ entryId: entry.id,
13327
+ message: "duplicate capability id"
13328
+ });
13329
+ seen.add(entry.id);
13330
+ if (entry.owner.trim() === "") diagnostics.push({
13331
+ code: "MISSING_OWNER",
13332
+ entryId: entry.id,
13333
+ message: "capability has no named owner"
13334
+ });
13335
+ for (const reference of entry.rules) {
13336
+ const rule = reference;
13337
+ if (!isRuleId(rule)) diagnostics.push({
13338
+ code: "UNKNOWN_RULE_REFERENCE",
13339
+ entryId: entry.id,
13340
+ message: `rule reference "${String(rule)}" is not a QA-* rule id (ADR 0003)`
13341
+ });
13342
+ else if (retired.has(rule)) diagnostics.push({
13343
+ code: "RETIRED_RULE_REFERENCE",
13344
+ entryId: entry.id,
13345
+ message: `depends on retired rule ${rule}`
13346
+ });
13347
+ else if (!liveIds.has(rule)) diagnostics.push({
13348
+ code: "UNKNOWN_RULE_REFERENCE",
13349
+ entryId: entry.id,
13350
+ message: `rule reference "${rule}" is not in the live registry`
13351
+ });
13352
+ }
13353
+ if (maturityRank(entry.maturity) > maturityRank(entry.proven)) diagnostics.push({
13354
+ code: "OVER_CLAIMED_MATURITY",
13355
+ entryId: entry.id,
13356
+ message: `advertises ${entry.maturity} but its own evidence proves only ${entry.proven}`
13357
+ });
13358
+ if (entry.nextLevelGap === null && entry.maturity !== "M5_FIELD_PROVEN") diagnostics.push({
13359
+ code: "MISSING_NEXT_LEVEL_GAP",
13360
+ entryId: entry.id,
13361
+ message: `${entry.maturity} must expose a nextLevelGap (D1)`
13362
+ });
13363
+ if (entry.proof.status !== "BLOCKED" && (entry.proof.artifact === null || entry.proof.observedAt === null)) diagnostics.push({
13364
+ code: "PROOF_WITHOUT_ARTIFACT",
13365
+ entryId: entry.id,
13366
+ message: `proof status ${entry.proof.status} requires both an artifact and an observedAt`
13367
+ });
13368
+ }
13369
+ return diagnostics;
13370
+ }
13371
+ //#endregion
13372
+ //#region src/commands/capability.ts
13373
+ /** Parse the flags. Anything flag-shaped that is not documented is a
13374
+ * usage error rather than a silently ignored argument, because
13375
+ * `capability --promote-m3` must not read as "show me everything". */
13376
+ function parseCapabilityArgs(argv) {
13377
+ const allowed = /* @__PURE__ */ new Set([
13378
+ "--json",
13379
+ "--kind",
13380
+ "--maturity",
13381
+ "--id"
13382
+ ]);
13383
+ const unknown = [];
13384
+ const query = {
13385
+ kind: null,
13386
+ maturity: null,
13387
+ id: null
13388
+ };
13389
+ let json = false;
13390
+ for (let index = 0; index < argv.length; index += 1) {
13391
+ const arg = argv[index] ?? "";
13392
+ if (!arg.startsWith("-")) {
13393
+ unknown.push(arg);
13394
+ continue;
13395
+ }
13396
+ const [flag, inlineValue] = arg.includes("=") ? [arg.slice(0, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1)] : [arg, void 0];
13397
+ if (!allowed.has(flag)) {
13398
+ unknown.push(arg);
13399
+ continue;
13400
+ }
13401
+ if (flag === "--json") {
13402
+ json = true;
13403
+ continue;
13404
+ }
13405
+ const value = inlineValue ?? argv[index + 1] ?? "";
13406
+ if (inlineValue === void 0) index += 1;
13407
+ if (flag === "--kind") query.kind = value;
13408
+ if (flag === "--maturity") query.maturity = value;
13409
+ if (flag === "--id") query.id = value;
13410
+ }
13411
+ return {
13412
+ query,
13413
+ json,
13414
+ unknown
13415
+ };
13416
+ }
13417
+ function selectCapabilities(entries, query) {
13418
+ return entries.filter((entry) => {
13419
+ if (query.id !== null && !entry.id.includes(query.id)) return false;
13420
+ if (query.kind !== null && entry.kind !== query.kind) return false;
13421
+ if (query.maturity !== null && MATURITY_SHORT[entry.maturity] !== query.maturity.toUpperCase()) return false;
13422
+ return true;
13423
+ });
13424
+ }
13425
+ function capabilityJson(entries, diagnostics) {
13426
+ return JSON.stringify({
13427
+ contract: "mjolnir.capability.v1",
13428
+ count: entries.length,
13429
+ capabilities: entries.map((entry) => ({
13430
+ capabilityId: entry.id,
13431
+ name: entry.name,
13432
+ kind: entry.kind,
13433
+ owner: entry.owner,
13434
+ maturity: MATURITY_SHORT[entry.maturity],
13435
+ rules: entry.rules,
13436
+ adapter: entry.adapter,
13437
+ proof: entry.proof,
13438
+ nextLevelGap: entry.nextLevelGap,
13439
+ blocksAxes: entry.blocksAxes
13440
+ })),
13441
+ violations: diagnostics.map((d) => ({
13442
+ code: d.code,
13443
+ capabilityId: d.entryId,
13444
+ message: d.message
13445
+ }))
13446
+ }, null, 2) + "\n";
13447
+ }
13448
+ function renderCapabilityText(entries, diagnostics) {
13449
+ const lines = [
13450
+ "Mjolnir capability registry",
13451
+ "",
13452
+ "Maturity is derived from evidence and cannot be set here. A capability is",
13453
+ "shown at the level the machine can prove, or not shown at all.",
13454
+ ""
13455
+ ];
13456
+ for (const entry of entries) {
13457
+ lines.push(`${entry.id} [${MATURITY_SHORT[entry.maturity]}]`);
13458
+ lines.push(` kind ${entry.kind}`);
13459
+ lines.push(` owner ${entry.owner}`);
13460
+ if (entry.rules.length > 0) lines.push(` rules ${entry.rules.join(", ")}`);
13461
+ if (entry.adapter !== null) lines.push(` adapter ${entry.adapter}`);
13462
+ lines.push(` proof ${entry.proof.status}${entry.proof.artifact === null ? "" : ` (${entry.proof.artifact})`}`);
13463
+ if (entry.nextLevelGap !== null) {
13464
+ lines.push(` next level ${entry.nextLevelGap.target}`);
13465
+ for (const missing of entry.nextLevelGap.missing) lines.push(` - ${missing}`);
13466
+ }
13467
+ lines.push("");
13468
+ }
13469
+ if (diagnostics.length > 0) {
13470
+ lines.push(`${diagnostics.length} violation(s):`);
13471
+ for (const diagnostic of diagnostics) lines.push(` ${diagnostic.code} ${diagnostic.entryId}: ${diagnostic.message}`);
13472
+ } else lines.push("No violations: no capability is advertised above its proven level.");
13473
+ return lines.join("\n");
13474
+ }
13475
+ function runCapabilityCommand(argv, io = {
13476
+ out,
13477
+ err
13478
+ }) {
13479
+ const { query, json, unknown } = parseCapabilityArgs(argv);
13480
+ if (unknown.length > 0) {
13481
+ io.err(`capability: unknown argument ${unknown[0] ?? ""}\n`);
13482
+ io.err(" usage: mjolnir capability [--json] [--kind <k>] [--maturity M0..M5] [--id <substring>]\n");
13483
+ io.err(" note: there is deliberately no --set or --promote. Maturity is derived\n from evidence, so it cannot be edited here.\n");
13484
+ return 10;
13485
+ }
13486
+ const registry = buildCapabilityRegistry({ evidence: realCapabilityEvidence() });
13487
+ const diagnostics = validateRegistry(registry);
13488
+ const entries = selectCapabilities(registry.entries, query);
13489
+ io.out(json ? capabilityJson(entries, diagnostics) : `${renderCapabilityText(entries, diagnostics)}\n`);
13490
+ return diagnostics.length > 0 ? 1 : 0;
13491
+ }
13492
+ //#endregion
12146
13493
  //#region src/commands/maturity.ts
12147
13494
  /**
12148
13495
  * `mjolnir maturity` — organizational QA-maturity signals.
@@ -12249,6 +13596,7 @@ function runMaturityCommand(argv, io) {
12249
13596
  return 20;
12250
13597
  }
12251
13598
  }
13599
+ if (subcommand === "capabilities") return runCapabilityCommand(argv.slice(1), io);
12252
13600
  io.err(`Unknown maturity subcommand: ${subcommand}`);
12253
13601
  return 10;
12254
13602
  }
@@ -12309,6 +13657,18 @@ const CLI_COMMAND_NAMES = [
12309
13657
  const CLI_NON_SCAN_COMMANDS = new Set(CLI_COMMAND_NAMES.filter((command) => command !== "scan"));
12310
13658
  //#endregion
12311
13659
  //#region src/engine/cross-file.ts
13660
+ /**
13661
+ * Cross-file analysis (Upgrade-Plan-v3 Phase 6, tier-3 item #2).
13662
+ *
13663
+ * First cross-file signal: duplicate test names across files in the same
13664
+ * suite. A duplicated test name means two tests report under one label —
13665
+ * CI dashboards, flake trackers, and forensics all conflate them. This is
13666
+ * undetectable per-file by design (each file alone is consistent), which
13667
+ * is exactly why it belongs in a cross-file pass.
13668
+ *
13669
+ * Pure function over discovered test files: no I/O beyond what the caller
13670
+ * already read; deterministic output ordered by first occurrence.
13671
+ */
12312
13672
  const JS_TEST_NAME_RE = /\b(?:test|it)\s*\(\s*['"`]([^'"`]+)['"`]\s*,/g;
12313
13673
  const PY_TEST_NAME_RE = /^def\s+(test_\w+)\s*\(/gm;
12314
13674
  /** Collect declared test names from one file's text. */
@@ -12340,7 +13700,7 @@ function findDuplicateTestNames(files) {
12340
13700
  name,
12341
13701
  files: [...filesSet].sort()
12342
13702
  });
12343
- return out.sort((a, b) => a.name.localeCompare(b.name));
13703
+ return out.sort((a, b) => compareCodePoints(a.name, b.name));
12344
13704
  }
12345
13705
  //#endregion
12346
13706
  //#region src/engine/cross-file-analysis.ts
@@ -12349,7 +13709,7 @@ function findDuplicateTestNames(files) {
12349
13709
  * Pure function — same input always produces same output.
12350
13710
  */
12351
13711
  function analyzeCrossFileSignals(files, findings, root) {
12352
- const orderedFiles = [...files].sort((a, b) => a.path.localeCompare(b.path));
13712
+ const orderedFiles = [...files].sort((a, b) => compareCodePoints(a.path, b.path));
12353
13713
  const duplicateTestNames = findDuplicateTestNames(orderedFiles).map((dup) => ({
12354
13714
  type: "duplicate-test-name",
12355
13715
  files: dup.files,
@@ -12402,7 +13762,7 @@ function detectSharedImports(files) {
12402
13762
  files: [...paths].sort(),
12403
13763
  message: `Module "${module}" imported by ${paths.size} files`
12404
13764
  });
12405
- return out.sort((a, b) => a.message.localeCompare(b.message));
13765
+ return out.sort((a, b) => compareCodePoints(a.message, b.message));
12406
13766
  }
12407
13767
  function detectCircularDependencies(files) {
12408
13768
  const importMap = /* @__PURE__ */ new Map();
@@ -14016,9 +15376,6 @@ function renderTrustTrend(trend) {
14016
15376
  * be derivable from the scan result, and every derivation must
14017
15377
  * be deterministic.
14018
15378
  */
14019
- function isRecord$1(value) {
14020
- return typeof value === "object" && value !== null && !Array.isArray(value);
14021
- }
14022
15379
  function optionalDeepEqual(actual, expected) {
14023
15380
  return actual === void 0 && expected === void 0 ? true : isDeepStrictEqual(actual, expected);
14024
15381
  }
@@ -14031,7 +15388,7 @@ function verifyMachineContract(result, contract = buildMachineContract(result))
14031
15388
  const violations = [];
14032
15389
  const checks = [];
14033
15390
  const expected = buildMachineContract(result);
14034
- const contractRecord = isRecord$1(contract) ? contract : Object.create(null);
15391
+ const contractRecord = isRecord(contract) ? contract : Object.create(null);
14035
15392
  const contractVersion = typeof contractRecord.contractVersion === "number" ? contractRecord.contractVersion : 0;
14036
15393
  const versionMatches = contractVersion === 1;
14037
15394
  if (!versionMatches) violations.push(`Unsupported contract version ${contractVersion}`);
@@ -14041,7 +15398,7 @@ function verifyMachineContract(result, contract = buildMachineContract(result))
14041
15398
  detail: versionMatches ? `Contract version ${contractVersion} confirmed` : `Unsupported contract version ${contractVersion}`
14042
15399
  });
14043
15400
  const suppliedSummary = contractRecord.summary;
14044
- const digestMatch = isRecord$1(suppliedSummary) && suppliedSummary.digest === expected.summary.digest;
15401
+ const digestMatch = isRecord(suppliedSummary) && suppliedSummary.digest === expected.summary.digest;
14045
15402
  if (!digestMatch) violations.push("Machine contract digest mismatch");
14046
15403
  checks.push({
14047
15404
  name: "digest-integrity",
@@ -14136,7 +15493,7 @@ function verifyEvidenceIntegrity(result) {
14136
15493
  }
14137
15494
  function verifyCompleteness(result, completeness, expected) {
14138
15495
  const violations = [];
14139
- if (!isRecord$1(completeness)) return ["Missing completeness"];
15496
+ if (!isRecord(completeness)) return ["Missing completeness"];
14140
15497
  const supplied = completeness;
14141
15498
  if (supplied.partial !== result.partial) violations.push("Partial flag mismatch");
14142
15499
  if (!isDeepStrictEqual(supplied, expected)) violations.push("Completeness projection mismatch");
@@ -14180,6 +15537,13 @@ function renderContractVerification(result) {
14180
15537
  * Deterministic sha256 fingerprint of the suppression set. Canonical
14181
15538
  * JSON sorted by ruleId+files+reason+expires so order in the config
14182
15539
  * file does not affect the hash.
15540
+ *
15541
+ * Code-unit order on the ruleId, not an ambient `localeCompare`: the sorted
15542
+ * array is serialized into the hash, so a locale-dependent collation would
15543
+ * make the SAME suppression file produce two different fingerprints on two
15544
+ * machines — and that fingerprint is what tells a reader their suppressions
15545
+ * changed. (Line 55 keeps a bare `.sort()`: that is `Array.prototype.sort`'s
15546
+ * own code-unit default, which is already locale-free.)
14183
15547
  */
14184
15548
  function suppressionFingerprint(suppressions) {
14185
15549
  const canonical = suppressions.map((s) => ({
@@ -14187,7 +15551,7 @@ function suppressionFingerprint(suppressions) {
14187
15551
  files: [...s.files ?? []].sort(),
14188
15552
  reason: s.reason,
14189
15553
  ...s.expires !== void 0 ? { expires: s.expires } : {}
14190
- })).sort((a, b) => a.ruleId.localeCompare(b.ruleId));
15554
+ })).sort((a, b) => compareCodePoints(a.ruleId, b.ruleId));
14191
15555
  return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
14192
15556
  }
14193
15557
  /**
@@ -14349,9 +15713,6 @@ const GITLAB_RESERVED_KEYS = /* @__PURE__ */ new Set([
14349
15713
  "variables",
14350
15714
  "workflow"
14351
15715
  ]);
14352
- function isRecord(value) {
14353
- return typeof value === "object" && value !== null && !Array.isArray(value);
14354
- }
14355
15716
  function conditionState(value) {
14356
15717
  if (value === void 0 || value === null || value === true) return "active";
14357
15718
  if (value === false) return "disabled";
@@ -15002,7 +16363,7 @@ async function runMilestoneScan(args, io, readSources, strict = false) {
15002
16363
  readSkipped++;
15003
16364
  }
15004
16365
  }
15005
- files.sort((left, right) => left.path.localeCompare(right.path));
16366
+ files.sort((left, right) => compareCodePoints(left.path, right.path));
15006
16367
  }
15007
16368
  return {
15008
16369
  result,
@@ -15043,7 +16404,7 @@ function loadPolicy(path) {
15043
16404
  }
15044
16405
  let record;
15045
16406
  try {
15046
- record = parseJsonFile(text, path, isRecord$3);
16407
+ record = parseJsonFile(text, path, isRecord);
15047
16408
  } catch (error) {
15048
16409
  throw new ConfigValidationError(`invalid suppression policy: ${path}`, { cause: error });
15049
16410
  }
@@ -15062,7 +16423,7 @@ function loadPolicy(path) {
15062
16423
  };
15063
16424
  }
15064
16425
  function isTrustSnapshot(value) {
15065
- return isRecord$3(value) && typeof value.scanId === "string" && typeof value.timestamp === "string" && (typeof value.score === "number" || value.score === null) && typeof value.findings === "number" && typeof value.errors === "number" && typeof value.warnings === "number" && typeof value.infos === "number" && typeof value.advisory === "number" && typeof value.trustLevel === "string" && typeof value.confidence === "number" && typeof value.evidenceCoverage === "number" && typeof value.inconclusiveRate === "number" && typeof value.partial === "boolean" && typeof value.frameworkCount === "number" && typeof value.ruleCount === "number";
16426
+ return isRecord(value) && typeof value.scanId === "string" && typeof value.timestamp === "string" && (typeof value.score === "number" || value.score === null) && typeof value.findings === "number" && typeof value.errors === "number" && typeof value.warnings === "number" && typeof value.infos === "number" && typeof value.advisory === "number" && typeof value.trustLevel === "string" && typeof value.confidence === "number" && typeof value.evidenceCoverage === "number" && typeof value.inconclusiveRate === "number" && typeof value.partial === "boolean" && typeof value.frameworkCount === "number" && typeof value.ruleCount === "number";
15066
16427
  }
15067
16428
  function loadTrustHistory(path) {
15068
16429
  if (!existsSync(path)) return [];
@@ -15088,46 +16449,46 @@ function isStringArray(value) {
15088
16449
  return Array.isArray(value) && value.every((entry) => typeof entry === "string");
15089
16450
  }
15090
16451
  function isValidAnalysisStatus(value) {
15091
- if (!isRecord$3(value)) return false;
16452
+ if (!isRecord(value)) return false;
15092
16453
  return (value.discovery === "complete" || value.discovery === "partial") && (value.rules === "complete" || value.rules === "partial") && isNonNegativeInteger(value.skippedFiles) && isFiniteNumber(value.durationMs) && value.durationMs >= 0 && (value.truncationReasons === void 0 || isStringArray(value.truncationReasons)) && (value.rulesCrashed === void 0 || isNonNegativeInteger(value.rulesCrashed));
15093
16454
  }
15094
16455
  function isValidDimension(value) {
15095
- return isRecord$3(value) && typeof value.category === "string" && isFiniteNumber(value.score) && value.score >= 0 && value.score <= 100 && isNonNegativeInteger(value.errors) && isNonNegativeInteger(value.warnings) && isNonNegativeInteger(value.infos);
16456
+ return isRecord(value) && typeof value.category === "string" && isFiniteNumber(value.score) && value.score >= 0 && value.score <= 100 && isNonNegativeInteger(value.errors) && isNonNegativeInteger(value.warnings) && isNonNegativeInteger(value.infos);
15096
16457
  }
15097
16458
  function isValidFinding(value) {
15098
- if (!isRecord$3(value)) return false;
16459
+ if (!isRecord(value)) return false;
15099
16460
  return typeof value.ruleId === "string" && value.ruleId.length > 0 && typeof value.category === "string" && typeof value.file === "string" && value.file.length > 0 && isPositiveInteger(value.line) && isPositiveInteger(value.column) && typeof value.message === "string" && value.message.length > 0 && typeof value.severity === "string" && typeof value.confidence === "string" && typeof value.findingType === "string" && typeof value.qaImpact === "string" && typeof value.why === "string" && typeof value.fix === "string";
15100
16461
  }
15101
16462
  function isValidTrustSummary(value) {
15102
- if (!isRecord$3(value)) return false;
16463
+ if (!isRecord(value)) return false;
15103
16464
  return TRUST_ORDER.includes(String(value.level)) && isFiniteNumber(value.confidence) && value.confidence >= 0 && value.confidence <= 1 && isFiniteNumber(value.evidenceCoverage) && value.evidenceCoverage >= 0 && value.evidenceCoverage <= 1 && isFiniteNumber(value.inconclusiveRate) && value.inconclusiveRate >= 0 && value.inconclusiveRate <= 1 && (value.measuredFpOfFiredRules === void 0 || isFiniteNumber(value.measuredFpOfFiredRules) && value.measuredFpOfFiredRules >= 0 && value.measuredFpOfFiredRules <= 1) && isStringArray(value.provisionalRuleIds) && (value.confidenceCeiling === void 0 || isFiniteNumber(value.confidenceCeiling) && value.confidenceCeiling >= 0 && value.confidenceCeiling <= 1) && isStringArray(value.ceilingReasons);
15104
16465
  }
15105
16466
  function isValidProvenance(value) {
15106
- if (!isRecord$3(value)) return false;
16467
+ if (!isRecord(value)) return false;
15107
16468
  return isNonNegativeInteger(value.testFiles) && isNonNegativeInteger(value.generatedMarkedFiles) && isNonNegativeInteger(value.codegenLikeFiles) && isFiniteNumber(value.shareMarkedGenerated) && value.shareMarkedGenerated >= 0 && value.shareMarkedGenerated <= 1 && isNonNegativeInteger(value.findingsInGeneratedFiles) && isNonNegativeInteger(value.findingsInUnmarkedFiles) && typeof value.note === "string";
15108
16469
  }
15109
16470
  function isValidForensicVerdicts(value) {
15110
- if (!isRecord$3(value) || !isRecord$3(value.byVerdict)) return false;
16471
+ if (!isRecord(value) || !isRecord(value.byVerdict)) return false;
15111
16472
  return isNonNegativeInteger(value.classifications) && isNonNegativeInteger(value.inconclusive) && Object.values(value.byVerdict).every((count) => isNonNegativeInteger(count));
15112
16473
  }
15113
16474
  function isValidSummary(value) {
15114
- if (!isRecord$3(value)) return false;
16475
+ if (!isRecord(value)) return false;
15115
16476
  return typeof value.digest === "string" && value.digest.length > 0 && isNonNegativeInteger(value.findings) && (value.score === null || isFiniteNumber(value.score) && value.score >= 0 && value.score <= 100) && isNonNegativeInteger(value.errors) && isNonNegativeInteger(value.warnings) && isNonNegativeInteger(value.infos) && isNonNegativeInteger(value.advisory);
15116
16477
  }
15117
16478
  function isValidAnnotation(value) {
15118
- if (!isRecord$3(value)) return false;
16479
+ if (!isRecord(value)) return false;
15119
16480
  return typeof value.path === "string" && value.path.length > 0 && isPositiveInteger(value.start_line) && (value.annotation_level === "failure" || value.annotation_level === "warning" || value.annotation_level === "notice") && typeof value.message === "string" && typeof value.ruleId === "string" && value.ruleId.length > 0 && (value.detectorRevision === void 0 || isPositiveInteger(value.detectorRevision)) && typeof value.advisory === "boolean";
15120
16481
  }
15121
16482
  function isValidCompleteness(value) {
15122
- if (!isRecord$3(value)) return false;
16483
+ if (!isRecord(value)) return false;
15123
16484
  return typeof value.partial === "boolean" && (value.discovery === "complete" || value.discovery === "partial") && (value.rules === "complete" || value.rules === "partial") && isNonNegativeInteger(value.skippedFiles) && isNonNegativeInteger(value.rulesCrashed) && isStringArray(value.truncationReasons) && typeof value.frameworkDetectionUnknown === "boolean" && isFiniteNumber(value.durationMs) && value.durationMs >= 0;
15124
16485
  }
15125
16486
  function isValidContract(value) {
15126
- if (!isRecord$3(value)) return false;
16487
+ if (!isRecord(value)) return false;
15127
16488
  return Number.isSafeInteger(value.contractVersion) && isValidSummary(value.summary) && Array.isArray(value.annotations) && value.annotations.length <= 50 && value.annotations.every((annotation) => isValidAnnotation(annotation)) && typeof value.annotationsTruncated === "boolean" && isValidCompleteness(value.completeness) && (value.trustSummary === void 0 || isValidTrustSummary(value.trustSummary)) && (value.provenance === void 0 || isValidProvenance(value.provenance)) && (value.forensicVerdicts === void 0 || isValidForensicVerdicts(value.forensicVerdicts));
15128
16489
  }
15129
16490
  function isScanResultDocument(value) {
15130
- if (!isRecord$3(value) || value.schemaVersion !== 1) return false;
16491
+ if (!isRecord(value) || value.schemaVersion !== 1) return false;
15131
16492
  return (value.score === null || isFiniteNumber(value.score) && value.score >= 0 && value.score <= 100) && typeof value.partial === "boolean" && isStringArray(value.frameworks) && Array.isArray(value.dimensions) && value.dimensions.every((dimension) => isValidDimension(dimension)) && Array.isArray(value.findings) && value.findings.every((finding) => isValidFinding(finding)) && isValidAnalysisStatus(value.analysisStatus) && (value.agenticProfile === void 0 || isValidProvenance(value.agenticProfile)) && (value.trustSummary === void 0 || isValidTrustSummary(value.trustSummary)) && (value.forensicVerdicts === void 0 || isValidForensicVerdicts(value.forensicVerdicts)) && isValidContract(value.contract);
15132
16493
  }
15133
16494
  function emitVerification(verification, json, io) {
@@ -15299,7 +16660,7 @@ async function runTrustTrendCommand(argv, io = {
15299
16660
  const snapshot = computeTrustSnapshot(scan.result, scan.result.runIdentity, scan.result.trustSummary, args.recordedAt ?? (/* @__PURE__ */ new Date()).toISOString());
15300
16661
  const merged = new Map(history.map((entry) => [`${entry.scanId}\u0000${entry.timestamp}`, entry]));
15301
16662
  merged.set(`${snapshot.scanId}\u0000${snapshot.timestamp}`, snapshot);
15302
- const snapshots = [...merged.values()].sort((left, right) => left.timestamp.localeCompare(right.timestamp) || left.scanId.localeCompare(right.scanId));
16663
+ const snapshots = [...merged.values()].sort((left, right) => compareCodePoints(left.timestamp, right.timestamp) || compareCodePoints(left.scanId, right.scanId));
15303
16664
  writeFileAtomic(historyPath, `${JSON.stringify(snapshots, null, 2)}\n`);
15304
16665
  const trend = analyzeTrustTrends(snapshots);
15305
16666
  if (args.json) io.out(JSON.stringify(trend, null, 2));
@@ -15366,8 +16727,8 @@ async function runCIIntegrityCommand(argv, io = {
15366
16727
  //#region src/cli.ts
15367
16728
  /**
15368
16729
  * Mjölnir CLI entry point (W1-02).
15369
- * Exit codes (§24.1, frozen): 0 clean · 1 findings ≥ gate · 2 partial ·
15370
- * 10 usage error · 20 internal error.
16730
+ * Exit codes (§24.1, frozen): 0 clean · 1 findings ≥ gate · 2 partial ·
16731
+ * 10 usage error · 20 internal error.
15371
16732
  */
15372
16733
  /**
15373
16734
  * Tool version for `mjolnir --version`.
@@ -15530,7 +16891,7 @@ function levenshtein(a, b) {
15530
16891
  };
15531
16892
  return walk(0, 0);
15532
16893
  }
15533
- /** Nearest known flags within distance ≤ 2, nearest first. */
16894
+ /** Nearest known flags within distance ≤ 2, nearest first. */
15534
16895
  function nearestFlags(flag, max = 3) {
15535
16896
  return KNOWN_SCAN_FLAGS.map((f) => ({
15536
16897
  f,