mjolnir-qa 0.5.20 → 0.5.23

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,27 @@ 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.23] — 2026-09-08
13
+
14
+ ### Changes since 0.5.22
15
+
16
+ - 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
17
+
18
+ ## [0.5.22] — 2026-09-07
19
+
20
+ ### Changes since 0.5.21
21
+
22
+ - chore: gitignore bench artifacts (machine-local timings + fixture scratch)
23
+ - 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
24
+ - chore(qa): eslint-ignore the verbatim QA evidence area (raw probes are committed DATA, certification protocol)
25
+ - chore(qa): commit FINAL-RELEASE certification evidence verbatim (cycle 0, RC 151186b) + lint/format exclusions for raw evidence area (certification plan 1788804968910 protocol)
26
+
27
+ ## [0.5.21] — 2026-09-07
28
+
29
+ ### Changes since 0.5.20
30
+
31
+ - Integrity layer: category + measurement consistency, measurement census, §27 design pass (Phases 1+4+7) (#56)
32
+
12
33
  ## [0.5.20] — 2026-09-07
13
34
 
14
35
  ### 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.23";
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.23",
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
@@ -18117,8 +18201,90 @@ function checkRevisionIntegrity(repoRoot, rules = RULES) {
18117
18201
  details.push(`${rules.length} rules attested: source identity and declared revision match the manifest (check A + check B, G4)`);
18118
18202
  return check("revision-integrity", "pass", details);
18119
18203
  }
18204
+ /**
18205
+ * Check 8 (certification-audit Phase 1.4, D6 — HARD-BLOCKING):
18206
+ * category-integrity. Every registry rule's category must be a member of
18207
+ * the closed RULE_CATEGORIES set (compile-time exhaustive via the D6
18208
+ * derivation, so this check is the runtime backstop for values arriving
18209
+ * from external rule sources), and no two rules may collide on an id.
18210
+ * Registry ids are already unique-checked by registry-sanity; this check
18211
+ * owns the category dimension.
18212
+ */
18213
+ function checkCategoryIntegrity(rules = RULES) {
18214
+ const details = [];
18215
+ const known = RULE_CATEGORIES;
18216
+ const unknown = rules.filter((r) => !known.includes(r.category));
18217
+ for (const r of unknown) details.push(`${r.id}: category "${r.category}" is not in RULE_CATEGORIES — registries may not invent categories (D6)`);
18218
+ const byCategory = /* @__PURE__ */ new Map();
18219
+ for (const r of rules) byCategory.set(r.category, (byCategory.get(r.category) ?? 0) + 1);
18220
+ return check("category-integrity", unknown.length === 0 ? "pass" : "fail", [`${byCategory.size} categories in use across ${rules.length} rules (closed set: ${RULE_CATEGORIES.length})`, ...details]);
18221
+ }
18222
+ /**
18223
+ * Check 10 (certification-audit Phase 4.1, D7 — HARD-BLOCKING):
18224
+ * measurement-consistency. Three invariants over the measurement surface:
18225
+ * (1) every MEASURED_FP entry's `n` equals the LIVE classified verdict
18226
+ * count (TP+FP rows in tests/corpus/verdicts/*.jsonl) — a snapshot
18227
+ * that drifted from the corpus is a lie about evidence;
18228
+ * (2) MEASURED_FP's detectorRevision equals the sidecar's
18229
+ * (tests/corpus/detector-revisions.json) — a measurement taken
18230
+ * against a different detector revision than attested is stale;
18231
+ * (3) every sidecar entry maps to a registered rule.
18232
+ * Mismatch → FAIL (certification-critical).
18233
+ */
18234
+ function checkMeasurementConsistency(verdictsDir, sidecarPath, rules = RULES, measuredFp = MEASURED_FP) {
18235
+ const details = [];
18236
+ const failures = [];
18237
+ const registered = new Map(rules.map((r) => [r.id, r]));
18238
+ const liveCounts = /* @__PURE__ */ new Map();
18239
+ if (existsSync(verdictsDir)) for (const f of readdirSync(verdictsDir)) {
18240
+ if (!f.endsWith(".jsonl")) continue;
18241
+ const lines = readFileSync(join(verdictsDir, f), "utf8").split("\n");
18242
+ for (const line of lines) {
18243
+ const trimmed = line.trim();
18244
+ if (trimmed.length === 0) continue;
18245
+ let row;
18246
+ try {
18247
+ row = JSON.parse(trimmed);
18248
+ } catch {
18249
+ failures.push(`${f}: unparseable verdict row (corpus integrity)`);
18250
+ continue;
18251
+ }
18252
+ if (row.ruleId === void 0) continue;
18253
+ if (row.verdict === "TP" || row.verdict === "FP") liveCounts.set(row.ruleId, (liveCounts.get(row.ruleId) ?? 0) + 1);
18254
+ }
18255
+ }
18256
+ else return check("measurement-consistency", "inconclusive", ["INCONCLUSIVE: verdicts directory missing — measurement consistency cannot be evaluated (certification-critical: never renders as pass)"]);
18257
+ let sidecar = {};
18258
+ if (existsSync(sidecarPath)) try {
18259
+ sidecar = JSON.parse(readFileSync(sidecarPath, "utf8"));
18260
+ } catch (e) {
18261
+ failures.push(`sidecar unreadable/malformed: ${errorText(e)} — regenerate the measurement artifacts`);
18262
+ }
18263
+ else {
18264
+ const needsSidecar = Object.keys(measuredFp).filter((id) => measuredFp[id]?.detectorRevision !== 1);
18265
+ 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`]);
18266
+ }
18267
+ for (const [id, m] of Object.entries(measuredFp)) {
18268
+ const side = sidecar[id];
18269
+ if (side !== void 0 && side.detectorRevision !== void 0) {
18270
+ 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)`);
18271
+ }
18272
+ const live = liveCounts.get(id);
18273
+ 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)`);
18274
+ if (live === void 0 && m.n > 0) failures.push(`${id}: MEASURED_FP.n=${m.n} but no classified verdicts exist in the live corpus`);
18275
+ }
18276
+ 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`);
18277
+ if (failures.length > 0) {
18278
+ details.push(...failures);
18279
+ return check("measurement-consistency", "fail", details);
18280
+ }
18281
+ const block = measurementBlock(rules);
18282
+ details.push(`${block.measured} measured / ${block.unmeasured} unmeasured / ${block.total} total (${block.quarantine} quarantine) — MEASURED_FP, live verdicts and sidecar revisions agree`);
18283
+ return check("measurement-consistency", "pass", details);
18284
+ }
18120
18285
  function runDoctorSelfAudit(fixturesRoot) {
18121
18286
  const verdictsDir = join(fixturesRoot, "..", "corpus", "verdicts");
18287
+ const repoRoot = join(fixturesRoot, "..", "..");
18122
18288
  const checks = [
18123
18289
  checkFixtureFirewall(fixturesRoot),
18124
18290
  checkRegistry(),
@@ -18127,12 +18293,15 @@ function runDoctorSelfAudit(fixturesRoot) {
18127
18293
  checkTierEnforcement(verdictsDir),
18128
18294
  checkAntiCreep(),
18129
18295
  checkQuarantineEnforcement(),
18296
+ checkCategoryIntegrity(),
18130
18297
  checkFixtureIntegrity(fixturesRoot),
18131
- checkRevisionIntegrity(join(fixturesRoot, "..", ".."))
18298
+ checkRevisionIntegrity(repoRoot),
18299
+ checkMeasurementConsistency(verdictsDir, join(repoRoot, "tests", "corpus", "detector-revisions.json"))
18132
18300
  ];
18133
18301
  return {
18134
18302
  checks,
18135
- healthy: checks.every((c) => c.status === "pass")
18303
+ healthy: checks.every((c) => c.status === "pass"),
18304
+ measurement: measurementBlock()
18136
18305
  };
18137
18306
  }
18138
18307
  function renderDoctorReport(report) {
@@ -18181,7 +18350,8 @@ function doctorReportJson(report, opts = {}) {
18181
18350
  schema: DOCTOR_REPORT_SCHEMA,
18182
18351
  healthy: report.healthy,
18183
18352
  summary,
18184
- checks
18353
+ checks,
18354
+ measurement: report.measurement
18185
18355
  };
18186
18356
  }
18187
18357
  //#endregion
@@ -18449,7 +18619,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
18449
18619
  * `scripts/sync-sarif-version.cjs` on release and guarded by
18450
18620
  * `tests/version-consistency.spec.ts` locally.
18451
18621
  */
18452
- const CLI_VERSION = "0.5.20";
18622
+ const CLI_VERSION = "0.5.23";
18453
18623
  function parseArgs(argv, onError) {
18454
18624
  const args = {
18455
18625
  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.23",
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
@@ -17599,8 +17683,90 @@ function checkRevisionIntegrity(repoRoot, rules = RULES) {
17599
17683
  details.push(`${rules.length} rules attested: source identity and declared revision match the manifest (check A + check B, G4)`);
17600
17684
  return check("revision-integrity", "pass", details);
17601
17685
  }
17686
+ /**
17687
+ * Check 8 (certification-audit Phase 1.4, D6 — HARD-BLOCKING):
17688
+ * category-integrity. Every registry rule's category must be a member of
17689
+ * the closed RULE_CATEGORIES set (compile-time exhaustive via the D6
17690
+ * derivation, so this check is the runtime backstop for values arriving
17691
+ * from external rule sources), and no two rules may collide on an id.
17692
+ * Registry ids are already unique-checked by registry-sanity; this check
17693
+ * owns the category dimension.
17694
+ */
17695
+ function checkCategoryIntegrity(rules = RULES) {
17696
+ const details = [];
17697
+ const known = RULE_CATEGORIES;
17698
+ const unknown = rules.filter((r) => !known.includes(r.category));
17699
+ for (const r of unknown) details.push(`${r.id}: category "${r.category}" is not in RULE_CATEGORIES — registries may not invent categories (D6)`);
17700
+ const byCategory = /* @__PURE__ */ new Map();
17701
+ for (const r of rules) byCategory.set(r.category, (byCategory.get(r.category) ?? 0) + 1);
17702
+ return check("category-integrity", unknown.length === 0 ? "pass" : "fail", [`${byCategory.size} categories in use across ${rules.length} rules (closed set: ${RULE_CATEGORIES.length})`, ...details]);
17703
+ }
17704
+ /**
17705
+ * Check 10 (certification-audit Phase 4.1, D7 — HARD-BLOCKING):
17706
+ * measurement-consistency. Three invariants over the measurement surface:
17707
+ * (1) every MEASURED_FP entry's `n` equals the LIVE classified verdict
17708
+ * count (TP+FP rows in tests/corpus/verdicts/*.jsonl) — a snapshot
17709
+ * that drifted from the corpus is a lie about evidence;
17710
+ * (2) MEASURED_FP's detectorRevision equals the sidecar's
17711
+ * (tests/corpus/detector-revisions.json) — a measurement taken
17712
+ * against a different detector revision than attested is stale;
17713
+ * (3) every sidecar entry maps to a registered rule.
17714
+ * Mismatch → FAIL (certification-critical).
17715
+ */
17716
+ function checkMeasurementConsistency(verdictsDir, sidecarPath, rules = RULES, measuredFp = MEASURED_FP) {
17717
+ const details = [];
17718
+ const failures = [];
17719
+ const registered = new Map(rules.map((r) => [r.id, r]));
17720
+ const liveCounts = /* @__PURE__ */ new Map();
17721
+ if (existsSync(verdictsDir)) for (const f of readdirSync(verdictsDir)) {
17722
+ if (!f.endsWith(".jsonl")) continue;
17723
+ const lines = readFileSync(join(verdictsDir, f), "utf8").split("\n");
17724
+ for (const line of lines) {
17725
+ const trimmed = line.trim();
17726
+ if (trimmed.length === 0) continue;
17727
+ let row;
17728
+ try {
17729
+ row = JSON.parse(trimmed);
17730
+ } catch {
17731
+ failures.push(`${f}: unparseable verdict row (corpus integrity)`);
17732
+ continue;
17733
+ }
17734
+ if (row.ruleId === void 0) continue;
17735
+ if (row.verdict === "TP" || row.verdict === "FP") liveCounts.set(row.ruleId, (liveCounts.get(row.ruleId) ?? 0) + 1);
17736
+ }
17737
+ }
17738
+ else return check("measurement-consistency", "inconclusive", ["INCONCLUSIVE: verdicts directory missing — measurement consistency cannot be evaluated (certification-critical: never renders as pass)"]);
17739
+ let sidecar = {};
17740
+ if (existsSync(sidecarPath)) try {
17741
+ sidecar = JSON.parse(readFileSync(sidecarPath, "utf8"));
17742
+ } catch (e) {
17743
+ failures.push(`sidecar unreadable/malformed: ${errorText(e)} — regenerate the measurement artifacts`);
17744
+ }
17745
+ else {
17746
+ const needsSidecar = Object.keys(measuredFp).filter((id) => measuredFp[id]?.detectorRevision !== 1);
17747
+ 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`]);
17748
+ }
17749
+ for (const [id, m] of Object.entries(measuredFp)) {
17750
+ const side = sidecar[id];
17751
+ if (side !== void 0 && side.detectorRevision !== void 0) {
17752
+ 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)`);
17753
+ }
17754
+ const live = liveCounts.get(id);
17755
+ 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)`);
17756
+ if (live === void 0 && m.n > 0) failures.push(`${id}: MEASURED_FP.n=${m.n} but no classified verdicts exist in the live corpus`);
17757
+ }
17758
+ 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`);
17759
+ if (failures.length > 0) {
17760
+ details.push(...failures);
17761
+ return check("measurement-consistency", "fail", details);
17762
+ }
17763
+ const block = measurementBlock(rules);
17764
+ details.push(`${block.measured} measured / ${block.unmeasured} unmeasured / ${block.total} total (${block.quarantine} quarantine) — MEASURED_FP, live verdicts and sidecar revisions agree`);
17765
+ return check("measurement-consistency", "pass", details);
17766
+ }
17602
17767
  function runDoctorSelfAudit(fixturesRoot) {
17603
17768
  const verdictsDir = join(fixturesRoot, "..", "corpus", "verdicts");
17769
+ const repoRoot = join(fixturesRoot, "..", "..");
17604
17770
  const checks = [
17605
17771
  checkFixtureFirewall(fixturesRoot),
17606
17772
  checkRegistry(),
@@ -17609,12 +17775,15 @@ function runDoctorSelfAudit(fixturesRoot) {
17609
17775
  checkTierEnforcement(verdictsDir),
17610
17776
  checkAntiCreep(),
17611
17777
  checkQuarantineEnforcement(),
17778
+ checkCategoryIntegrity(),
17612
17779
  checkFixtureIntegrity(fixturesRoot),
17613
- checkRevisionIntegrity(join(fixturesRoot, "..", ".."))
17780
+ checkRevisionIntegrity(repoRoot),
17781
+ checkMeasurementConsistency(verdictsDir, join(repoRoot, "tests", "corpus", "detector-revisions.json"))
17614
17782
  ];
17615
17783
  return {
17616
17784
  checks,
17617
- healthy: checks.every((c) => c.status === "pass")
17785
+ healthy: checks.every((c) => c.status === "pass"),
17786
+ measurement: measurementBlock()
17618
17787
  };
17619
17788
  }
17620
17789
  function renderDoctorReport(report) {
@@ -17663,7 +17832,8 @@ function doctorReportJson(report, opts = {}) {
17663
17832
  schema: DOCTOR_REPORT_SCHEMA,
17664
17833
  healthy: report.healthy,
17665
17834
  summary,
17666
- checks
17835
+ checks,
17836
+ measurement: report.measurement
17667
17837
  };
17668
17838
  }
17669
17839
  //#endregion
@@ -18085,7 +18255,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
18085
18255
  * `scripts/sync-sarif-version.cjs` on release and guarded by
18086
18256
  * `tests/version-consistency.spec.ts` locally.
18087
18257
  */
18088
- const CLI_VERSION = "0.5.20";
18258
+ const CLI_VERSION = "0.5.23";
18089
18259
  function parseArgs(argv, onError) {
18090
18260
  const args = {
18091
18261
  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.23",
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": {