mjolnir-qa 0.5.20 → 0.5.24

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/CHANGELOG.md CHANGED
@@ -9,6 +9,33 @@ Rule behavior changes (new rules, FP-rate changes against the corpus,
9
9
  severity changes) are first-class entries here — rule IDs are immutable
10
10
  once shipped, so this file is the record of what changed between versions.
11
11
 
12
+ ## [0.5.24] — 2026-09-08
13
+
14
+ ### Changes since 0.5.23
15
+
16
+ - L4 ruling: tier-enforcement INCONCLUSIVE without live verdicts (MEASURED_FP = historical artifact only) (#58)
17
+
18
+ ## [0.5.23] — 2026-09-08
19
+
20
+ ### Changes since 0.5.22
21
+
22
+ - docs(corpus): D14 as amended by owner — re-baseline-per-wave is blocking, PARTIAL repos are tracked debt (never baselines, never a certification gate); zero-PARTIAL is the end state, not the gate
23
+
24
+ ## [0.5.22] — 2026-09-07
25
+
26
+ ### Changes since 0.5.21
27
+
28
+ - chore: gitignore bench artifacts (machine-local timings + fixture scratch)
29
+ - test(corpus): D14 re-baseline after Wave-1 merges — QA-PW-124 now adapter-gated (configRule metadata), QA-TEST-003/010 surface on repos whose baselines predate the TS project split, corpus regen of partial scans pending quiet-machine rerun
30
+ - chore(qa): eslint-ignore the verbatim QA evidence area (raw probes are committed DATA, certification protocol)
31
+ - chore(qa): commit FINAL-RELEASE certification evidence verbatim (cycle 0, RC 151186b) + lint/format exclusions for raw evidence area (certification plan 1788804968910 protocol)
32
+
33
+ ## [0.5.21] — 2026-09-07
34
+
35
+ ### Changes since 0.5.20
36
+
37
+ - Integrity layer: category + measurement consistency, measurement census, §27 design pass (Phases 1+4+7) (#56)
38
+
12
39
  ## [0.5.20] — 2026-09-07
13
40
 
14
41
  ### Changes since 0.5.19
package/README.md CHANGED
@@ -200,6 +200,7 @@ and you're done. Everything else is optional.
200
200
  | `mjolnir badge` | shields.io endpoint JSON + snippet |
201
201
  | `mjolnir rules --md` | Full rule catalog (JSON or Markdown) |
202
202
  | `mjolnir doctor` | Self-audit of Mjölnir's own rule base |
203
+ | `mjolnir doctor --json` | The same self-audit as a machine-readable contract |
203
204
  | `mjolnir create-rule <ID>` | Scaffold a new rule + fixtures |
204
205
  | `mjolnir --format mermaid` | Test-architecture diagram for a PR comment |
205
206
 
package/dist/cli.d.mts CHANGED
@@ -32,8 +32,17 @@ type EvidenceLevel = (typeof EVIDENCE_ORDER)[number];
32
32
  * for the QA engineer's actual job, in their vocabulary.
33
33
  */
34
34
  type QaImpact = "BLOCKS-RELEASE" | "FLAKY-RISK" | "FALSE-GREEN" | "HYGIENE";
35
- /** Rule namespaces are frozen public API (§18.4). IDs are never reused. */
36
- type RuleCategory = "QA-TEST" | "QA-TQUAL" | "QA-PW" | "QA-CI" | "QA-PY" | "QA-ENV" | "QA-JV" | "QA-CS" | "QA-CYP" | "QA-SE" | "QA-WDIO" | "QA-PPTR" | "QA-APM";
35
+ /**
36
+ * The closed set of rule categories (plan §5.5, certification-audit D6).
37
+ * Declared FIRST as the single source of truth: the RuleCategory type is
38
+ * DERIVED from this list (compile-time exhaustiveness — a category added
39
+ * to the type without the value list, or vice versa, cannot compile).
40
+ * `--category` values are validated against this list — unknown
41
+ * categories are a usage error, not a silent no-op. Rule namespaces are
42
+ * frozen public API (§18.4): IDs are never reused.
43
+ */
44
+ declare const RULE_CATEGORIES: readonly ["QA-TEST", "QA-TQUAL", "QA-PW", "QA-CI", "QA-PY", "QA-ENV", "QA-JV", "QA-CS", "QA-CYP", "QA-SE", "QA-WDIO", "QA-PPTR", "QA-APM"];
45
+ type RuleCategory = (typeof RULE_CATEGORIES)[number];
37
46
  /**
38
47
  * Trust levels (Verification Trust Evolution Plan §16): the OVERALL
39
48
  * trust a consumer can place in one finding, combining the static
@@ -719,7 +728,7 @@ declare const runScan: typeof runScan$1, buildUniversalRules: typeof buildUniver
719
728
  * `scripts/sync-sarif-version.cjs` on release and guarded by
720
729
  * `tests/version-consistency.spec.ts` locally.
721
730
  */
722
- declare const CLI_VERSION = "0.5.20";
731
+ declare const CLI_VERSION = "0.5.24";
723
732
  /** A usage-error detail: the offending token, when one exists. */
724
733
  interface UsageErrorDetail {
725
734
  /** The unknown flag or rejected value (e.g. `--nope`, `loud`). */
package/dist/cli.mjs CHANGED
@@ -38,16 +38,42 @@ const QA_IMPACT_LABELS = {
38
38
  "FALSE-GREEN": "False-green risk",
39
39
  HYGIENE: "Test hygiene debt"
40
40
  };
41
- /** The closed set of rule categories (plan §5.5). `--category` values
42
- * are validated against this list — unknown categories are a usage
43
- * error, not a silent no-op. */
41
+ /**
42
+ * The closed set of rule categories (plan §5.5, certification-audit D6).
43
+ * Declared FIRST as the single source of truth: the RuleCategory type is
44
+ * DERIVED from this list (compile-time exhaustiveness — a category added
45
+ * to the type without the value list, or vice versa, cannot compile).
46
+ * `--category` values are validated against this list — unknown
47
+ * categories are a usage error, not a silent no-op. Rule namespaces are
48
+ * frozen public API (§18.4): IDs are never reused.
49
+ */
44
50
  const RULE_CATEGORIES = [
45
51
  "QA-TEST",
46
52
  "QA-TQUAL",
47
53
  "QA-PW",
48
- "QA-CI"
54
+ "QA-CI",
55
+ "QA-PY",
56
+ "QA-ENV",
57
+ "QA-JV",
58
+ "QA-CS",
59
+ "QA-CYP",
60
+ "QA-SE",
61
+ "QA-WDIO",
62
+ "QA-PPTR",
63
+ "QA-APM"
49
64
  ];
50
65
  /**
66
+ * Category argument validation shared by every verb accepting
67
+ * `--category` (scan/why/handoff — certification-audit Phase 1.2): the
68
+ * three verbs must never disagree about what a valid category is. A
69
+ * missing value or an unknown value is a usage error (exit 10) at the
70
+ * caller — this helper returns the verdict, the caller renders the
71
+ * rejection.
72
+ */
73
+ function isValidCategory(value) {
74
+ return value !== void 0 && RULE_CATEGORIES.includes(value);
75
+ }
76
+ /**
51
77
  * Honest default evidence level for a finding (Honesty Core Phase 1).
52
78
  * Derivation is deterministic and conservative:
53
79
  * observation → E0 (never proof)
@@ -13264,7 +13290,7 @@ function renderSarif(result, repoRootUri) {
13264
13290
  tool: { driver: {
13265
13291
  name: "Mjölnir",
13266
13292
  informationUri: "https://github.com/Sergey-Bar/Mjolnir",
13267
- version: "0.5.20",
13293
+ version: "0.5.24",
13268
13294
  rules: [...rules.values()].map((r) => {
13269
13295
  const meta = RULES.find((x) => x.id === r.id);
13270
13296
  return {
@@ -14026,6 +14052,20 @@ async function runWhyCommand(argv, io = {
14026
14052
  out: (line) => console.log(line),
14027
14053
  err: (line) => console.error(line)
14028
14054
  }) {
14055
+ const catIdxs = [];
14056
+ argv.forEach((a, i) => {
14057
+ if (a === "--category") catIdxs.push(i + 1);
14058
+ });
14059
+ const categories = [];
14060
+ for (const i of catIdxs) {
14061
+ const value = argv[i];
14062
+ if (!isValidCategory(value)) {
14063
+ io.err(`mjolnir why: unknown --category: ${value === void 0 ? "(missing value)" : value}`);
14064
+ io.err(` Valid categories: ${RULE_CATEGORIES.join(", ")}`);
14065
+ return 10;
14066
+ }
14067
+ categories.push(value);
14068
+ }
14029
14069
  const locationToken = argv.find((a) => !a.startsWith("-"));
14030
14070
  if (!locationToken) {
14031
14071
  io.err("Usage: mjolnir why <file>:<line> [--json <mjolnir.json>]");
@@ -14038,7 +14078,10 @@ async function runWhyCommand(argv, io = {
14038
14078
  }
14039
14079
  const jsonIdx = argv.indexOf("--json");
14040
14080
  const reportPath = jsonIdx !== -1 ? argv[jsonIdx + 1] : void 0;
14041
- const targetIdx = argv.findIndex((a, i) => !a.startsWith("-") && i !== 0 && (jsonIdx === -1 || i !== jsonIdx + 1));
14081
+ const valuePositions = /* @__PURE__ */ new Set();
14082
+ if (jsonIdx !== -1) valuePositions.add(jsonIdx + 1);
14083
+ for (const i of catIdxs) valuePositions.add(i);
14084
+ const targetIdx = argv.findIndex((a, i) => !a.startsWith("-") && i !== 0 && !valuePositions.has(i));
14042
14085
  const target = targetIdx !== -1 ? argv[targetIdx] : ".";
14043
14086
  let result;
14044
14087
  if (reportPath !== void 0) {
@@ -14074,11 +14117,6 @@ async function runWhyCommand(argv, io = {
14074
14117
  }
14075
14118
  }
14076
14119
  const match = explainAt(result.findings, location.file, location.line);
14077
- const catIdxs = [];
14078
- argv.forEach((a, i) => {
14079
- if (a === "--category") catIdxs.push(i + 1);
14080
- });
14081
- const categories = catIdxs.map((i) => argv[i]).filter((c) => c !== void 0);
14082
14120
  const filtered = categories.length > 0 ? match.findings.filter((f) => categories.includes(f.category)) : match.findings;
14083
14121
  io.out(renderWhy({
14084
14122
  ...match,
@@ -14088,6 +14126,28 @@ async function runWhyCommand(argv, io = {
14088
14126
  }
14089
14127
  //#endregion
14090
14128
  //#region src/commands/handoff.ts
14129
+ /**
14130
+ * `mjolnir handoff [mjolnir.json]` — the deterministic fix-handoff
14131
+ * artifact (agent-handoff plan M3).
14132
+ *
14133
+ * Reads a saved `--json` report (shared loader, report-io.ts) and
14134
+ * renders a self-contained Markdown remediation plan an agent (or a
14135
+ * human) can execute offline. Every instruction is derived from
14136
+ * Mjölnir's own rule metadata — the generator produces INSTRUCTIONS,
14137
+ * never executable commands, and no prompt text is fetched or copied
14138
+ * from any external source.
14139
+ *
14140
+ * The artifact embeds the formal verification contract (plan §5.3):
14141
+ * what was detected, what should change, what justifies it (evidence
14142
+ * boundary per level), what to re-run, what counts as verified
14143
+ * (TARGET_RESOLVED / TARGET_REMAINS / NEW_FINDINGS_INTRODUCED /
14144
+ * VERIFICATION_NOT_RUN, correlated via the fingerprint rule), and the
14145
+ * standing caveat that a clean `--scope changed` run verifies the
14146
+ * changed-scope surface only — not that the repository is clean.
14147
+ *
14148
+ * Exit codes (frozen contract): 0 on success · 10 missing file ·
14149
+ * 2 unreadable/invalid JSON.
14150
+ */
14091
14151
  const OCCURRENCE_CAP = 25;
14092
14152
  const EVIDENCE_BOUNDARY = {
14093
14153
  E2: "The detector's evidence is deterministic for this pattern. Check the location, then apply the prescribed fix — the finding is a deterministic defect at its boundary.",
@@ -14317,6 +14377,11 @@ function runHandoffCommand(argv, io = {
14317
14377
  }));
14318
14378
  return 10;
14319
14379
  }
14380
+ if (a === "--category" && !isValidCategory(val)) {
14381
+ io.err(`mjolnir handoff: unknown --category: ${val}`);
14382
+ io.err(` Valid categories: ${RULE_CATEGORIES.join(", ")}`);
14383
+ return 10;
14384
+ }
14320
14385
  i++;
14321
14386
  continue;
14322
14387
  }
@@ -17930,6 +17995,25 @@ function checkEvidenceHonesty(rules = RULES) {
17930
17995
  return check("evidence-honesty", ok ? "pass" : "fail", details);
17931
17996
  }
17932
17997
  /**
17998
+ * Measurement census (Phase 4.3): measured = rules with a valid
17999
+ * MEASURED_FP entry; unmeasured = the rest; quarantine = measured rules
18000
+ * whose effective tier is quarantine. One function, used by the doctor
18001
+ * report AND the JSON contract — a single reproducible answer.
18002
+ */
18003
+ function measurementBlock(rules = RULES) {
18004
+ const measured = rules.filter((r) => {
18005
+ const m = MEASURED_FP[r.id];
18006
+ return m !== void 0 && m.detectorRevision === declaredDetectorRevision(r);
18007
+ });
18008
+ const quarantine = measured.filter((r) => effectiveTier(r) === "quarantine");
18009
+ return {
18010
+ measured: measured.length,
18011
+ unmeasured: rules.length - measured.length,
18012
+ total: rules.length,
18013
+ quarantine: quarantine.length
18014
+ };
18015
+ }
18016
+ /**
17933
18017
  * Check 5 (Phase 4 — Tempering Plan, ratcheted per audit H-2):
17934
18018
  * tier enforcement. Core-tier rules must have a measured FP rate
17935
18019
  * (n ≥ 10 classified verdicts in tests/corpus/verdicts/) at a matching
@@ -17941,14 +18025,16 @@ function checkEvidenceHonesty(rules = RULES) {
17941
18025
  */
17942
18026
  function checkTierEnforcement(verdictsDir, rules = RULES) {
17943
18027
  const details = [];
18028
+ if (!existsSync(verdictsDir)) return check("tier-enforcement", "inconclusive", ["INCONCLUSIVE: live verdicts unavailable (tests/corpus/verdicts missing — installed package) — tier claims cannot be proven without live evidence; MEASURED_FP is a historical artifact and does not substitute (owner ruling 2026-09-08, L4)"]);
17944
18029
  const classifiedPerRule = /* @__PURE__ */ new Map();
17945
18030
  const byId = new Map(rules.map((r) => [r.id, r]));
17946
18031
  for (const [id, m] of Object.entries(MEASURED_FP)) {
17947
18032
  const rule = byId.get(id);
17948
18033
  if (rule && m.detectorRevision === declaredDetectorRevision(rule)) classifiedPerRule.set(id, m.n);
17949
18034
  }
18035
+ let live;
17950
18036
  try {
17951
- const live = /* @__PURE__ */ new Map();
18037
+ live = /* @__PURE__ */ new Map();
17952
18038
  const files = readdirSync(verdictsDir).filter((f) => f.endsWith(".jsonl"));
17953
18039
  for (const f of files) {
17954
18040
  const lines = readFileSync(join(verdictsDir, f), "utf8").split("\n").filter((l) => l.trim().length > 0);
@@ -17957,8 +18043,10 @@ function checkTierEnforcement(verdictsDir, rules = RULES) {
17957
18043
  if (entry.verdict === "TP" || entry.verdict === "FP") live.set(entry.ruleId, (live.get(entry.ruleId) ?? 0) + 1);
17958
18044
  } catch {}
17959
18045
  }
17960
- if (live.size > 0) for (const [id, n] of live) classifiedPerRule.set(id, n);
17961
- } catch {}
18046
+ } catch (e) {
18047
+ return check("tier-enforcement", "inconclusive", [`INCONCLUSIVE: live verdicts unreadable — ${errorText(e)} (owner ruling 2026-09-08, L4)`]);
18048
+ }
18049
+ if (live.size > 0) for (const [id, n] of live) classifiedPerRule.set(id, n);
17962
18050
  const coreRules = rules.filter((r) => effectiveTier(r) === "core");
17963
18051
  for (const r of coreRules) {
17964
18052
  const n = classifiedPerRule.get(r.id) ?? 0;
@@ -18117,8 +18205,90 @@ function checkRevisionIntegrity(repoRoot, rules = RULES) {
18117
18205
  details.push(`${rules.length} rules attested: source identity and declared revision match the manifest (check A + check B, G4)`);
18118
18206
  return check("revision-integrity", "pass", details);
18119
18207
  }
18208
+ /**
18209
+ * Check 8 (certification-audit Phase 1.4, D6 — HARD-BLOCKING):
18210
+ * category-integrity. Every registry rule's category must be a member of
18211
+ * the closed RULE_CATEGORIES set (compile-time exhaustive via the D6
18212
+ * derivation, so this check is the runtime backstop for values arriving
18213
+ * from external rule sources), and no two rules may collide on an id.
18214
+ * Registry ids are already unique-checked by registry-sanity; this check
18215
+ * owns the category dimension.
18216
+ */
18217
+ function checkCategoryIntegrity(rules = RULES) {
18218
+ const details = [];
18219
+ const known = RULE_CATEGORIES;
18220
+ const unknown = rules.filter((r) => !known.includes(r.category));
18221
+ for (const r of unknown) details.push(`${r.id}: category "${r.category}" is not in RULE_CATEGORIES — registries may not invent categories (D6)`);
18222
+ const byCategory = /* @__PURE__ */ new Map();
18223
+ for (const r of rules) byCategory.set(r.category, (byCategory.get(r.category) ?? 0) + 1);
18224
+ return check("category-integrity", unknown.length === 0 ? "pass" : "fail", [`${byCategory.size} categories in use across ${rules.length} rules (closed set: ${RULE_CATEGORIES.length})`, ...details]);
18225
+ }
18226
+ /**
18227
+ * Check 10 (certification-audit Phase 4.1, D7 — HARD-BLOCKING):
18228
+ * measurement-consistency. Three invariants over the measurement surface:
18229
+ * (1) every MEASURED_FP entry's `n` equals the LIVE classified verdict
18230
+ * count (TP+FP rows in tests/corpus/verdicts/*.jsonl) — a snapshot
18231
+ * that drifted from the corpus is a lie about evidence;
18232
+ * (2) MEASURED_FP's detectorRevision equals the sidecar's
18233
+ * (tests/corpus/detector-revisions.json) — a measurement taken
18234
+ * against a different detector revision than attested is stale;
18235
+ * (3) every sidecar entry maps to a registered rule.
18236
+ * Mismatch → FAIL (certification-critical).
18237
+ */
18238
+ function checkMeasurementConsistency(verdictsDir, sidecarPath, rules = RULES, measuredFp = MEASURED_FP) {
18239
+ const details = [];
18240
+ const failures = [];
18241
+ const registered = new Map(rules.map((r) => [r.id, r]));
18242
+ const liveCounts = /* @__PURE__ */ new Map();
18243
+ if (existsSync(verdictsDir)) for (const f of readdirSync(verdictsDir)) {
18244
+ if (!f.endsWith(".jsonl")) continue;
18245
+ const lines = readFileSync(join(verdictsDir, f), "utf8").split("\n");
18246
+ for (const line of lines) {
18247
+ const trimmed = line.trim();
18248
+ if (trimmed.length === 0) continue;
18249
+ let row;
18250
+ try {
18251
+ row = JSON.parse(trimmed);
18252
+ } catch {
18253
+ failures.push(`${f}: unparseable verdict row (corpus integrity)`);
18254
+ continue;
18255
+ }
18256
+ if (row.ruleId === void 0) continue;
18257
+ if (row.verdict === "TP" || row.verdict === "FP") liveCounts.set(row.ruleId, (liveCounts.get(row.ruleId) ?? 0) + 1);
18258
+ }
18259
+ }
18260
+ else return check("measurement-consistency", "inconclusive", ["INCONCLUSIVE: verdicts directory missing — measurement consistency cannot be evaluated (certification-critical: never renders as pass)"]);
18261
+ let sidecar = {};
18262
+ if (existsSync(sidecarPath)) try {
18263
+ sidecar = JSON.parse(readFileSync(sidecarPath, "utf8"));
18264
+ } catch (e) {
18265
+ failures.push(`sidecar unreadable/malformed: ${errorText(e)} — regenerate the measurement artifacts`);
18266
+ }
18267
+ else {
18268
+ const needsSidecar = Object.keys(measuredFp).filter((id) => measuredFp[id]?.detectorRevision !== 1);
18269
+ if (needsSidecar.length > 0) return check("measurement-consistency", "inconclusive", [`INCONCLUSIVE: sidecar tests/corpus/detector-revisions.json missing but ${needsSidecar.length} measured rule(s) declare revisions — cannot verify measurement freshness`]);
18270
+ }
18271
+ for (const [id, m] of Object.entries(measuredFp)) {
18272
+ const side = sidecar[id];
18273
+ if (side !== void 0 && side.detectorRevision !== void 0) {
18274
+ if (side.detectorRevision !== m.detectorRevision) failures.push(`${id}: MEASURED_FP revision ${m.detectorRevision} ≠ sidecar ${side.detectorRevision} — the measurement is stale (§07: re-measure or bump)`);
18275
+ }
18276
+ const live = liveCounts.get(id);
18277
+ if (live !== void 0 && live !== m.n) failures.push(`${id}: MEASURED_FP.n=${m.n} but the live corpus has ${live} classified verdict(s) — regenerate (npm run fp-audit:generate)`);
18278
+ if (live === void 0 && m.n > 0) failures.push(`${id}: MEASURED_FP.n=${m.n} but no classified verdicts exist in the live corpus`);
18279
+ }
18280
+ for (const id of Object.keys(sidecar)) if (!registered.has(id)) failures.push(`${id}: sidecar entry for an unregistered rule — remove it from tests/corpus/detector-revisions.json`);
18281
+ if (failures.length > 0) {
18282
+ details.push(...failures);
18283
+ return check("measurement-consistency", "fail", details);
18284
+ }
18285
+ const block = measurementBlock(rules);
18286
+ details.push(`${block.measured} measured / ${block.unmeasured} unmeasured / ${block.total} total (${block.quarantine} quarantine) — MEASURED_FP, live verdicts and sidecar revisions agree`);
18287
+ return check("measurement-consistency", "pass", details);
18288
+ }
18120
18289
  function runDoctorSelfAudit(fixturesRoot) {
18121
18290
  const verdictsDir = join(fixturesRoot, "..", "corpus", "verdicts");
18291
+ const repoRoot = join(fixturesRoot, "..", "..");
18122
18292
  const checks = [
18123
18293
  checkFixtureFirewall(fixturesRoot),
18124
18294
  checkRegistry(),
@@ -18127,12 +18297,15 @@ function runDoctorSelfAudit(fixturesRoot) {
18127
18297
  checkTierEnforcement(verdictsDir),
18128
18298
  checkAntiCreep(),
18129
18299
  checkQuarantineEnforcement(),
18300
+ checkCategoryIntegrity(),
18130
18301
  checkFixtureIntegrity(fixturesRoot),
18131
- checkRevisionIntegrity(join(fixturesRoot, "..", ".."))
18302
+ checkRevisionIntegrity(repoRoot),
18303
+ checkMeasurementConsistency(verdictsDir, join(repoRoot, "tests", "corpus", "detector-revisions.json"))
18132
18304
  ];
18133
18305
  return {
18134
18306
  checks,
18135
- healthy: checks.every((c) => c.status === "pass")
18307
+ healthy: checks.every((c) => c.status === "pass"),
18308
+ measurement: measurementBlock()
18136
18309
  };
18137
18310
  }
18138
18311
  function renderDoctorReport(report) {
@@ -18181,7 +18354,8 @@ function doctorReportJson(report, opts = {}) {
18181
18354
  schema: DOCTOR_REPORT_SCHEMA,
18182
18355
  healthy: report.healthy,
18183
18356
  summary,
18184
- checks
18357
+ checks,
18358
+ measurement: report.measurement
18185
18359
  };
18186
18360
  }
18187
18361
  //#endregion
@@ -18449,7 +18623,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
18449
18623
  * `scripts/sync-sarif-version.cjs` on release and guarded by
18450
18624
  * `tests/version-consistency.spec.ts` locally.
18451
18625
  */
18452
- const CLI_VERSION = "0.5.20";
18626
+ const CLI_VERSION = "0.5.24";
18453
18627
  function parseArgs(argv, onError) {
18454
18628
  const args = {
18455
18629
  target: ".",
@@ -37,16 +37,42 @@ const QA_IMPACT_LABELS = {
37
37
  "FALSE-GREEN": "False-green risk",
38
38
  HYGIENE: "Test hygiene debt"
39
39
  };
40
- /** The closed set of rule categories (plan §5.5). `--category` values
41
- * are validated against this list — unknown categories are a usage
42
- * error, not a silent no-op. */
40
+ /**
41
+ * The closed set of rule categories (plan §5.5, certification-audit D6).
42
+ * Declared FIRST as the single source of truth: the RuleCategory type is
43
+ * DERIVED from this list (compile-time exhaustiveness — a category added
44
+ * to the type without the value list, or vice versa, cannot compile).
45
+ * `--category` values are validated against this list — unknown
46
+ * categories are a usage error, not a silent no-op. Rule namespaces are
47
+ * frozen public API (§18.4): IDs are never reused.
48
+ */
43
49
  const RULE_CATEGORIES = [
44
50
  "QA-TEST",
45
51
  "QA-TQUAL",
46
52
  "QA-PW",
47
- "QA-CI"
53
+ "QA-CI",
54
+ "QA-PY",
55
+ "QA-ENV",
56
+ "QA-JV",
57
+ "QA-CS",
58
+ "QA-CYP",
59
+ "QA-SE",
60
+ "QA-WDIO",
61
+ "QA-PPTR",
62
+ "QA-APM"
48
63
  ];
49
64
  /**
65
+ * Category argument validation shared by every verb accepting
66
+ * `--category` (scan/why/handoff — certification-audit Phase 1.2): the
67
+ * three verbs must never disagree about what a valid category is. A
68
+ * missing value or an unknown value is a usage error (exit 10) at the
69
+ * caller — this helper returns the verdict, the caller renders the
70
+ * rejection.
71
+ */
72
+ function isValidCategory(value) {
73
+ return value !== void 0 && RULE_CATEGORIES.includes(value);
74
+ }
75
+ /**
50
76
  * Honest default evidence level for a finding (Honesty Core Phase 1).
51
77
  * Derivation is deterministic and conservative:
52
78
  * observation → E0 (never proof)
@@ -13263,7 +13289,7 @@ function renderSarif(result, repoRootUri) {
13263
13289
  tool: { driver: {
13264
13290
  name: "Mjölnir",
13265
13291
  informationUri: "https://github.com/Sergey-Bar/Mjolnir",
13266
- version: "0.5.20",
13292
+ version: "0.5.24",
13267
13293
  rules: [...rules.values()].map((r) => {
13268
13294
  const meta = RULES.find((x) => x.id === r.id);
13269
13295
  return {
@@ -14025,6 +14051,20 @@ async function runWhyCommand(argv, io = {
14025
14051
  out: (line) => console.log(line),
14026
14052
  err: (line) => console.error(line)
14027
14053
  }) {
14054
+ const catIdxs = [];
14055
+ argv.forEach((a, i) => {
14056
+ if (a === "--category") catIdxs.push(i + 1);
14057
+ });
14058
+ const categories = [];
14059
+ for (const i of catIdxs) {
14060
+ const value = argv[i];
14061
+ if (!isValidCategory(value)) {
14062
+ io.err(`mjolnir why: unknown --category: ${value === void 0 ? "(missing value)" : value}`);
14063
+ io.err(` Valid categories: ${RULE_CATEGORIES.join(", ")}`);
14064
+ return 10;
14065
+ }
14066
+ categories.push(value);
14067
+ }
14028
14068
  const locationToken = argv.find((a) => !a.startsWith("-"));
14029
14069
  if (!locationToken) {
14030
14070
  io.err("Usage: mjolnir why <file>:<line> [--json <mjolnir.json>]");
@@ -14037,7 +14077,10 @@ async function runWhyCommand(argv, io = {
14037
14077
  }
14038
14078
  const jsonIdx = argv.indexOf("--json");
14039
14079
  const reportPath = jsonIdx !== -1 ? argv[jsonIdx + 1] : void 0;
14040
- const targetIdx = argv.findIndex((a, i) => !a.startsWith("-") && i !== 0 && (jsonIdx === -1 || i !== jsonIdx + 1));
14080
+ const valuePositions = /* @__PURE__ */ new Set();
14081
+ if (jsonIdx !== -1) valuePositions.add(jsonIdx + 1);
14082
+ for (const i of catIdxs) valuePositions.add(i);
14083
+ const targetIdx = argv.findIndex((a, i) => !a.startsWith("-") && i !== 0 && !valuePositions.has(i));
14041
14084
  const target = targetIdx !== -1 ? argv[targetIdx] : ".";
14042
14085
  let result;
14043
14086
  if (reportPath !== void 0) {
@@ -14073,11 +14116,6 @@ async function runWhyCommand(argv, io = {
14073
14116
  }
14074
14117
  }
14075
14118
  const match = explainAt(result.findings, location.file, location.line);
14076
- const catIdxs = [];
14077
- argv.forEach((a, i) => {
14078
- if (a === "--category") catIdxs.push(i + 1);
14079
- });
14080
- const categories = catIdxs.map((i) => argv[i]).filter((c) => c !== void 0);
14081
14119
  const filtered = categories.length > 0 ? match.findings.filter((f) => categories.includes(f.category)) : match.findings;
14082
14120
  io.out(renderWhy({
14083
14121
  ...match,
@@ -14087,6 +14125,28 @@ async function runWhyCommand(argv, io = {
14087
14125
  }
14088
14126
  //#endregion
14089
14127
  //#region src/commands/handoff.ts
14128
+ /**
14129
+ * `mjolnir handoff [mjolnir.json]` — the deterministic fix-handoff
14130
+ * artifact (agent-handoff plan M3).
14131
+ *
14132
+ * Reads a saved `--json` report (shared loader, report-io.ts) and
14133
+ * renders a self-contained Markdown remediation plan an agent (or a
14134
+ * human) can execute offline. Every instruction is derived from
14135
+ * Mjölnir's own rule metadata — the generator produces INSTRUCTIONS,
14136
+ * never executable commands, and no prompt text is fetched or copied
14137
+ * from any external source.
14138
+ *
14139
+ * The artifact embeds the formal verification contract (plan §5.3):
14140
+ * what was detected, what should change, what justifies it (evidence
14141
+ * boundary per level), what to re-run, what counts as verified
14142
+ * (TARGET_RESOLVED / TARGET_REMAINS / NEW_FINDINGS_INTRODUCED /
14143
+ * VERIFICATION_NOT_RUN, correlated via the fingerprint rule), and the
14144
+ * standing caveat that a clean `--scope changed` run verifies the
14145
+ * changed-scope surface only — not that the repository is clean.
14146
+ *
14147
+ * Exit codes (frozen contract): 0 on success · 10 missing file ·
14148
+ * 2 unreadable/invalid JSON.
14149
+ */
14090
14150
  const OCCURRENCE_CAP = 25;
14091
14151
  const EVIDENCE_BOUNDARY = {
14092
14152
  E2: "The detector's evidence is deterministic for this pattern. Check the location, then apply the prescribed fix — the finding is a deterministic defect at its boundary.",
@@ -14316,6 +14376,11 @@ function runHandoffCommand(argv, io = {
14316
14376
  }));
14317
14377
  return 10;
14318
14378
  }
14379
+ if (a === "--category" && !isValidCategory(val)) {
14380
+ io.err(`mjolnir handoff: unknown --category: ${val}`);
14381
+ io.err(` Valid categories: ${RULE_CATEGORIES.join(", ")}`);
14382
+ return 10;
14383
+ }
14319
14384
  i++;
14320
14385
  continue;
14321
14386
  }
@@ -17412,6 +17477,25 @@ function checkEvidenceHonesty(rules = RULES) {
17412
17477
  return check("evidence-honesty", ok ? "pass" : "fail", details);
17413
17478
  }
17414
17479
  /**
17480
+ * Measurement census (Phase 4.3): measured = rules with a valid
17481
+ * MEASURED_FP entry; unmeasured = the rest; quarantine = measured rules
17482
+ * whose effective tier is quarantine. One function, used by the doctor
17483
+ * report AND the JSON contract — a single reproducible answer.
17484
+ */
17485
+ function measurementBlock(rules = RULES) {
17486
+ const measured = rules.filter((r) => {
17487
+ const m = MEASURED_FP[r.id];
17488
+ return m !== void 0 && m.detectorRevision === declaredDetectorRevision(r);
17489
+ });
17490
+ const quarantine = measured.filter((r) => effectiveTier(r) === "quarantine");
17491
+ return {
17492
+ measured: measured.length,
17493
+ unmeasured: rules.length - measured.length,
17494
+ total: rules.length,
17495
+ quarantine: quarantine.length
17496
+ };
17497
+ }
17498
+ /**
17415
17499
  * Check 5 (Phase 4 — Tempering Plan, ratcheted per audit H-2):
17416
17500
  * tier enforcement. Core-tier rules must have a measured FP rate
17417
17501
  * (n ≥ 10 classified verdicts in tests/corpus/verdicts/) at a matching
@@ -17423,14 +17507,16 @@ function checkEvidenceHonesty(rules = RULES) {
17423
17507
  */
17424
17508
  function checkTierEnforcement(verdictsDir, rules = RULES) {
17425
17509
  const details = [];
17510
+ if (!existsSync(verdictsDir)) return check("tier-enforcement", "inconclusive", ["INCONCLUSIVE: live verdicts unavailable (tests/corpus/verdicts missing — installed package) — tier claims cannot be proven without live evidence; MEASURED_FP is a historical artifact and does not substitute (owner ruling 2026-09-08, L4)"]);
17426
17511
  const classifiedPerRule = /* @__PURE__ */ new Map();
17427
17512
  const byId = new Map(rules.map((r) => [r.id, r]));
17428
17513
  for (const [id, m] of Object.entries(MEASURED_FP)) {
17429
17514
  const rule = byId.get(id);
17430
17515
  if (rule && m.detectorRevision === declaredDetectorRevision(rule)) classifiedPerRule.set(id, m.n);
17431
17516
  }
17517
+ let live;
17432
17518
  try {
17433
- const live = /* @__PURE__ */ new Map();
17519
+ live = /* @__PURE__ */ new Map();
17434
17520
  const files = readdirSync(verdictsDir).filter((f) => f.endsWith(".jsonl"));
17435
17521
  for (const f of files) {
17436
17522
  const lines = readFileSync(join(verdictsDir, f), "utf8").split("\n").filter((l) => l.trim().length > 0);
@@ -17439,8 +17525,10 @@ function checkTierEnforcement(verdictsDir, rules = RULES) {
17439
17525
  if (entry.verdict === "TP" || entry.verdict === "FP") live.set(entry.ruleId, (live.get(entry.ruleId) ?? 0) + 1);
17440
17526
  } catch {}
17441
17527
  }
17442
- if (live.size > 0) for (const [id, n] of live) classifiedPerRule.set(id, n);
17443
- } catch {}
17528
+ } catch (e) {
17529
+ return check("tier-enforcement", "inconclusive", [`INCONCLUSIVE: live verdicts unreadable — ${errorText(e)} (owner ruling 2026-09-08, L4)`]);
17530
+ }
17531
+ if (live.size > 0) for (const [id, n] of live) classifiedPerRule.set(id, n);
17444
17532
  const coreRules = rules.filter((r) => effectiveTier(r) === "core");
17445
17533
  for (const r of coreRules) {
17446
17534
  const n = classifiedPerRule.get(r.id) ?? 0;
@@ -17599,8 +17687,90 @@ function checkRevisionIntegrity(repoRoot, rules = RULES) {
17599
17687
  details.push(`${rules.length} rules attested: source identity and declared revision match the manifest (check A + check B, G4)`);
17600
17688
  return check("revision-integrity", "pass", details);
17601
17689
  }
17690
+ /**
17691
+ * Check 8 (certification-audit Phase 1.4, D6 — HARD-BLOCKING):
17692
+ * category-integrity. Every registry rule's category must be a member of
17693
+ * the closed RULE_CATEGORIES set (compile-time exhaustive via the D6
17694
+ * derivation, so this check is the runtime backstop for values arriving
17695
+ * from external rule sources), and no two rules may collide on an id.
17696
+ * Registry ids are already unique-checked by registry-sanity; this check
17697
+ * owns the category dimension.
17698
+ */
17699
+ function checkCategoryIntegrity(rules = RULES) {
17700
+ const details = [];
17701
+ const known = RULE_CATEGORIES;
17702
+ const unknown = rules.filter((r) => !known.includes(r.category));
17703
+ for (const r of unknown) details.push(`${r.id}: category "${r.category}" is not in RULE_CATEGORIES — registries may not invent categories (D6)`);
17704
+ const byCategory = /* @__PURE__ */ new Map();
17705
+ for (const r of rules) byCategory.set(r.category, (byCategory.get(r.category) ?? 0) + 1);
17706
+ return check("category-integrity", unknown.length === 0 ? "pass" : "fail", [`${byCategory.size} categories in use across ${rules.length} rules (closed set: ${RULE_CATEGORIES.length})`, ...details]);
17707
+ }
17708
+ /**
17709
+ * Check 10 (certification-audit Phase 4.1, D7 — HARD-BLOCKING):
17710
+ * measurement-consistency. Three invariants over the measurement surface:
17711
+ * (1) every MEASURED_FP entry's `n` equals the LIVE classified verdict
17712
+ * count (TP+FP rows in tests/corpus/verdicts/*.jsonl) — a snapshot
17713
+ * that drifted from the corpus is a lie about evidence;
17714
+ * (2) MEASURED_FP's detectorRevision equals the sidecar's
17715
+ * (tests/corpus/detector-revisions.json) — a measurement taken
17716
+ * against a different detector revision than attested is stale;
17717
+ * (3) every sidecar entry maps to a registered rule.
17718
+ * Mismatch → FAIL (certification-critical).
17719
+ */
17720
+ function checkMeasurementConsistency(verdictsDir, sidecarPath, rules = RULES, measuredFp = MEASURED_FP) {
17721
+ const details = [];
17722
+ const failures = [];
17723
+ const registered = new Map(rules.map((r) => [r.id, r]));
17724
+ const liveCounts = /* @__PURE__ */ new Map();
17725
+ if (existsSync(verdictsDir)) for (const f of readdirSync(verdictsDir)) {
17726
+ if (!f.endsWith(".jsonl")) continue;
17727
+ const lines = readFileSync(join(verdictsDir, f), "utf8").split("\n");
17728
+ for (const line of lines) {
17729
+ const trimmed = line.trim();
17730
+ if (trimmed.length === 0) continue;
17731
+ let row;
17732
+ try {
17733
+ row = JSON.parse(trimmed);
17734
+ } catch {
17735
+ failures.push(`${f}: unparseable verdict row (corpus integrity)`);
17736
+ continue;
17737
+ }
17738
+ if (row.ruleId === void 0) continue;
17739
+ if (row.verdict === "TP" || row.verdict === "FP") liveCounts.set(row.ruleId, (liveCounts.get(row.ruleId) ?? 0) + 1);
17740
+ }
17741
+ }
17742
+ else return check("measurement-consistency", "inconclusive", ["INCONCLUSIVE: verdicts directory missing — measurement consistency cannot be evaluated (certification-critical: never renders as pass)"]);
17743
+ let sidecar = {};
17744
+ if (existsSync(sidecarPath)) try {
17745
+ sidecar = JSON.parse(readFileSync(sidecarPath, "utf8"));
17746
+ } catch (e) {
17747
+ failures.push(`sidecar unreadable/malformed: ${errorText(e)} — regenerate the measurement artifacts`);
17748
+ }
17749
+ else {
17750
+ const needsSidecar = Object.keys(measuredFp).filter((id) => measuredFp[id]?.detectorRevision !== 1);
17751
+ if (needsSidecar.length > 0) return check("measurement-consistency", "inconclusive", [`INCONCLUSIVE: sidecar tests/corpus/detector-revisions.json missing but ${needsSidecar.length} measured rule(s) declare revisions — cannot verify measurement freshness`]);
17752
+ }
17753
+ for (const [id, m] of Object.entries(measuredFp)) {
17754
+ const side = sidecar[id];
17755
+ if (side !== void 0 && side.detectorRevision !== void 0) {
17756
+ if (side.detectorRevision !== m.detectorRevision) failures.push(`${id}: MEASURED_FP revision ${m.detectorRevision} ≠ sidecar ${side.detectorRevision} — the measurement is stale (§07: re-measure or bump)`);
17757
+ }
17758
+ const live = liveCounts.get(id);
17759
+ if (live !== void 0 && live !== m.n) failures.push(`${id}: MEASURED_FP.n=${m.n} but the live corpus has ${live} classified verdict(s) — regenerate (npm run fp-audit:generate)`);
17760
+ if (live === void 0 && m.n > 0) failures.push(`${id}: MEASURED_FP.n=${m.n} but no classified verdicts exist in the live corpus`);
17761
+ }
17762
+ for (const id of Object.keys(sidecar)) if (!registered.has(id)) failures.push(`${id}: sidecar entry for an unregistered rule — remove it from tests/corpus/detector-revisions.json`);
17763
+ if (failures.length > 0) {
17764
+ details.push(...failures);
17765
+ return check("measurement-consistency", "fail", details);
17766
+ }
17767
+ const block = measurementBlock(rules);
17768
+ details.push(`${block.measured} measured / ${block.unmeasured} unmeasured / ${block.total} total (${block.quarantine} quarantine) — MEASURED_FP, live verdicts and sidecar revisions agree`);
17769
+ return check("measurement-consistency", "pass", details);
17770
+ }
17602
17771
  function runDoctorSelfAudit(fixturesRoot) {
17603
17772
  const verdictsDir = join(fixturesRoot, "..", "corpus", "verdicts");
17773
+ const repoRoot = join(fixturesRoot, "..", "..");
17604
17774
  const checks = [
17605
17775
  checkFixtureFirewall(fixturesRoot),
17606
17776
  checkRegistry(),
@@ -17609,12 +17779,15 @@ function runDoctorSelfAudit(fixturesRoot) {
17609
17779
  checkTierEnforcement(verdictsDir),
17610
17780
  checkAntiCreep(),
17611
17781
  checkQuarantineEnforcement(),
17782
+ checkCategoryIntegrity(),
17612
17783
  checkFixtureIntegrity(fixturesRoot),
17613
- checkRevisionIntegrity(join(fixturesRoot, "..", ".."))
17784
+ checkRevisionIntegrity(repoRoot),
17785
+ checkMeasurementConsistency(verdictsDir, join(repoRoot, "tests", "corpus", "detector-revisions.json"))
17614
17786
  ];
17615
17787
  return {
17616
17788
  checks,
17617
- healthy: checks.every((c) => c.status === "pass")
17789
+ healthy: checks.every((c) => c.status === "pass"),
17790
+ measurement: measurementBlock()
17618
17791
  };
17619
17792
  }
17620
17793
  function renderDoctorReport(report) {
@@ -17663,7 +17836,8 @@ function doctorReportJson(report, opts = {}) {
17663
17836
  schema: DOCTOR_REPORT_SCHEMA,
17664
17837
  healthy: report.healthy,
17665
17838
  summary,
17666
- checks
17839
+ checks,
17840
+ measurement: report.measurement
17667
17841
  };
17668
17842
  }
17669
17843
  //#endregion
@@ -18085,7 +18259,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
18085
18259
  * `scripts/sync-sarif-version.cjs` on release and guarded by
18086
18260
  * `tests/version-consistency.spec.ts` locally.
18087
18261
  */
18088
- const CLI_VERSION = "0.5.20";
18262
+ const CLI_VERSION = "0.5.24";
18089
18263
  function parseArgs(argv, onError) {
18090
18264
  const args = {
18091
18265
  target: ".",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mjolnir-qa",
3
- "version": "0.5.20",
3
+ "version": "0.5.24",
4
4
  "description": "Mjölnir — the Verification Trust Engine for QA. Audits test suites and CI pipelines, reports a worthiness score and prioritized findings.",
5
5
  "type": "module",
6
6
  "engines": {