mjolnir-qa 0.5.19 → 0.5.20
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 +6 -0
- package/dist/cli.d.mts +22 -19
- package/dist/cli.mjs +121 -116
- package/dist/mcp/stdio.mjs +121 -116
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,12 @@ 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.20] — 2026-09-07
|
|
13
|
+
|
|
14
|
+
### Changes since 0.5.19
|
|
15
|
+
|
|
16
|
+
- Doctor status model + --json contract + certification CI gate (Phase 5, G2/G5/G6) (#55)
|
|
17
|
+
|
|
12
18
|
## [0.5.19] — 2026-09-07
|
|
13
19
|
|
|
14
20
|
### Changes since 0.5.18
|
package/dist/cli.d.mts
CHANGED
|
@@ -687,6 +687,26 @@ declare function isValidFindingRecord$1(f: unknown): f is Omit<Finding, "ruleId"
|
|
|
687
687
|
*/
|
|
688
688
|
declare function runScan$1(args: CliArgs, hooks?: ScanHooks): Promise<ScanResult>;
|
|
689
689
|
//#endregion
|
|
690
|
+
//#region src/cli-io.d.ts
|
|
691
|
+
/**
|
|
692
|
+
* Shared process IO sinks (certification-audit Phase 5, G6): the variadic
|
|
693
|
+
* console sinks and the internal-error reporter lived in cli.ts, which
|
|
694
|
+
* made every command module that wanted honest IO depend on the dispatch
|
|
695
|
+
* table (or duplicate it). Extracted so command modules (doctor-run et
|
|
696
|
+
* al.) and cli.ts share ONE implementation.
|
|
697
|
+
*/
|
|
698
|
+
/** The variadic IO signature shared by all command handlers. */
|
|
699
|
+
type Output = (...parts: unknown[]) => void;
|
|
700
|
+
declare const out: Output;
|
|
701
|
+
declare const err: Output;
|
|
702
|
+
declare function internalErrorMessage(err: unknown, emit: (s: string) => void, debug: boolean): void;
|
|
703
|
+
//#endregion
|
|
704
|
+
//#region src/commands/doctor-run.d.ts
|
|
705
|
+
declare function runDoctorCommand(argv: string[], io?: {
|
|
706
|
+
out: Output;
|
|
707
|
+
err: Output;
|
|
708
|
+
}): number;
|
|
709
|
+
//#endregion
|
|
690
710
|
//#region src/cli.d.ts
|
|
691
711
|
declare const runScan: typeof runScan$1, buildUniversalRules: typeof buildUniversalRules$1, fallbackWorkspace: typeof fallbackWorkspace$1, pathMatchesGlob: typeof pathMatchesGlob$1, isValidFindingRecord: typeof isValidFindingRecord$1, discoverRuntimeReport: typeof discoverRuntimeReport$1, KNOWN_RULE_IDS: ReadonlySet<string>, OVERLAP_META_BY_RULE_ID: ReadonlyMap<string, OverlapMeta>, EVIDENCE_OVERRIDES: ReadonlyMap<string, string>, SUITE_INVALIDATING_RULE_IDS: ReadonlySet<string>;
|
|
692
712
|
/**
|
|
@@ -699,7 +719,7 @@ declare const runScan: typeof runScan$1, buildUniversalRules: typeof buildUniver
|
|
|
699
719
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
700
720
|
* `tests/version-consistency.spec.ts` locally.
|
|
701
721
|
*/
|
|
702
|
-
declare const CLI_VERSION = "0.5.
|
|
722
|
+
declare const CLI_VERSION = "0.5.20";
|
|
703
723
|
/** A usage-error detail: the offending token, when one exists. */
|
|
704
724
|
interface UsageErrorDetail {
|
|
705
725
|
/** The unknown flag or rejected value (e.g. `--nope`, `loud`). */
|
|
@@ -718,9 +738,6 @@ declare function nearestFlags(flag: string, max?: number): string[];
|
|
|
718
738
|
* to stderr; findings/usage stay on their documented streams.
|
|
719
739
|
*/
|
|
720
740
|
declare function usageErrorMessage(detail: UsageErrorDetail): string;
|
|
721
|
-
type Output = (...parts: unknown[]) => void;
|
|
722
|
-
declare const out: Output;
|
|
723
|
-
declare const err: Output;
|
|
724
741
|
/** Testable `ci install` handler. Returns the process exit code. */
|
|
725
742
|
declare function runCiInstall(argv: string[], io?: {
|
|
726
743
|
out: Output;
|
|
@@ -741,11 +758,6 @@ declare function runDoctorPlaywright(argv: string[], io?: {
|
|
|
741
758
|
out: Output;
|
|
742
759
|
err?: Output;
|
|
743
760
|
}): Promise<number>;
|
|
744
|
-
/** Testable `doctor` handler — self-audit of Mjölnir's own rule base. */
|
|
745
|
-
declare function runDoctorCommand(argv: string[], io?: {
|
|
746
|
-
out: Output;
|
|
747
|
-
err: Output;
|
|
748
|
-
}): number;
|
|
749
761
|
/** Testable `rules` handler — rule catalog with Trust Metadata. */
|
|
750
762
|
declare function runRulesCommand(argv: string[], io?: {
|
|
751
763
|
out: Output;
|
|
@@ -852,15 +864,6 @@ declare function runHelpCommand(argv: string[], io?: {
|
|
|
852
864
|
out: Output;
|
|
853
865
|
err: Output;
|
|
854
866
|
}): number;
|
|
855
|
-
/**
|
|
856
|
-
* Friendly exit-20 path (plan M2): the crash says it's Mjölnir's bug,
|
|
857
|
-
* not the user's repo, carries the underlying message for a report, and
|
|
858
|
-
* prints the stack ONLY when `debug` is set (uniform across
|
|
859
|
-
* subcommands — they don't parse scan flags). Tests pin
|
|
860
|
-
* /internal error/i. Exported so the --debug stack arm is directly
|
|
861
|
-
* spec-coverable (spawning a real crash under --debug would be flaky).
|
|
862
|
-
*/
|
|
863
|
-
declare function internalErrorMessage(err: unknown, emit: (s: string) => void, debug: boolean): void;
|
|
864
867
|
declare function isEntryPoint(): boolean;
|
|
865
868
|
//#endregion
|
|
866
|
-
export { CLI_VERSION, type CliArgs, EVIDENCE_OVERRIDES, KNOWN_RULE_IDS, OVERLAP_META_BY_RULE_ID, Output, SUITE_INVALIDATING_RULE_IDS, type ScanHooks, UsageErrorDetail, buildUniversalRules, discoverRuntimeReport, err, exitForFindings, fallbackWorkspace, internalErrorMessage, isEntryPoint, isValidFindingRecord, levenshtein, main, nearestFlags, out, parseArgs, pathMatchesGlob, runBadgeCommand, runBaselineCommand, runCiInstall, runCreateRuleCommand, runDebtCommand, runDiffCommand, runDoctorCommand, runDoctorPlaywright, runExplainCommand, runFixCommand, runForensicsCommand, runHandoverCommand, runHelpCommand, runImpactCommand, runInitCommand, runPrCommentCommand, runPwReportCommand, runRulesCommand, runScan, runScanCommand, runStatsCommand, runSuppressions, runTriageCommand, usageErrorMessage };
|
|
869
|
+
export { CLI_VERSION, type CliArgs, EVIDENCE_OVERRIDES, KNOWN_RULE_IDS, OVERLAP_META_BY_RULE_ID, type Output, SUITE_INVALIDATING_RULE_IDS, type ScanHooks, UsageErrorDetail, buildUniversalRules, discoverRuntimeReport, err, exitForFindings, fallbackWorkspace, internalErrorMessage, isEntryPoint, isValidFindingRecord, levenshtein, main, nearestFlags, out, parseArgs, pathMatchesGlob, runBadgeCommand, runBaselineCommand, runCiInstall, runCreateRuleCommand, runDebtCommand, runDiffCommand, runDoctorCommand, runDoctorPlaywright, runExplainCommand, runFixCommand, runForensicsCommand, runHandoverCommand, runHelpCommand, runImpactCommand, runInitCommand, runPrCommentCommand, runPwReportCommand, runRulesCommand, runScan, runScanCommand, runStatsCommand, runSuppressions, runTriageCommand, usageErrorMessage };
|
package/dist/cli.mjs
CHANGED
|
@@ -13264,7 +13264,7 @@ function renderSarif(result, repoRootUri) {
|
|
|
13264
13264
|
tool: { driver: {
|
|
13265
13265
|
name: "Mjölnir",
|
|
13266
13266
|
informationUri: "https://github.com/Sergey-Bar/Mjolnir",
|
|
13267
|
-
version: "0.5.
|
|
13267
|
+
version: "0.5.20",
|
|
13268
13268
|
rules: [...rules.values()].map((r) => {
|
|
13269
13269
|
const meta = RULES.find((x) => x.id === r.id);
|
|
13270
13270
|
return {
|
|
@@ -17630,6 +17630,18 @@ function renderFixReport(results, dryRun) {
|
|
|
17630
17630
|
return lines.join("\n");
|
|
17631
17631
|
}
|
|
17632
17632
|
//#endregion
|
|
17633
|
+
//#region src/cli-io.ts
|
|
17634
|
+
const out = (...parts) => console.log(parts.map(String).join(" "));
|
|
17635
|
+
const err = (...parts) => console.error(parts.map(String).join(" "));
|
|
17636
|
+
function internalErrorMessage(err, emit, debug) {
|
|
17637
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
17638
|
+
emit("mjolnir internal error — this is a bug in Mjölnir, not your repo:");
|
|
17639
|
+
emit(` ${message}`);
|
|
17640
|
+
if (debug && err instanceof Error && err.stack) emit(err.stack);
|
|
17641
|
+
emit("Rerun with --debug for the stack trace. Please report this:");
|
|
17642
|
+
emit(" https://github.com/Sergey-Bar/Mjolnir/issues");
|
|
17643
|
+
}
|
|
17644
|
+
//#endregion
|
|
17633
17645
|
//#region src/engine/detector-hash.ts
|
|
17634
17646
|
/**
|
|
17635
17647
|
* Detector revision integrity engine (certification-audit Phase 3,
|
|
@@ -17837,6 +17849,15 @@ function errorText(e) {
|
|
|
17837
17849
|
return e instanceof Error ? e.message : String(e);
|
|
17838
17850
|
}
|
|
17839
17851
|
const ui$1 = plainContext();
|
|
17852
|
+
/** Builds the check record: `ok` is always the pass-mirror of `status`. */
|
|
17853
|
+
function check(name, status, details) {
|
|
17854
|
+
return {
|
|
17855
|
+
name,
|
|
17856
|
+
status,
|
|
17857
|
+
ok: status === "pass",
|
|
17858
|
+
details
|
|
17859
|
+
};
|
|
17860
|
+
}
|
|
17840
17861
|
const VALID_ID = RULE_ID_RE;
|
|
17841
17862
|
function nonHiddenFiles(dir) {
|
|
17842
17863
|
if (!existsSync(dir)) return [];
|
|
@@ -17859,11 +17880,7 @@ function checkFixtureFirewall(fixturesRoot) {
|
|
|
17859
17880
|
details.push(`${rule.id}: missing must-not-fire fixture`);
|
|
17860
17881
|
}
|
|
17861
17882
|
}
|
|
17862
|
-
return
|
|
17863
|
-
name: "fixture-firewall",
|
|
17864
|
-
ok,
|
|
17865
|
-
details
|
|
17866
|
-
};
|
|
17883
|
+
return check("fixture-firewall", ok ? "pass" : "fail", details);
|
|
17867
17884
|
}
|
|
17868
17885
|
/** Check 2: registry sanity — IDs unique, well-formed, titles distinct. */
|
|
17869
17886
|
function checkRegistry(rules = RULES) {
|
|
@@ -17886,20 +17903,12 @@ function checkRegistry(rules = RULES) {
|
|
|
17886
17903
|
details.push(`"${r.title}": duplicate title in family (${r.id})`);
|
|
17887
17904
|
}
|
|
17888
17905
|
}
|
|
17889
|
-
return
|
|
17890
|
-
name: "registry-sanity",
|
|
17891
|
-
ok,
|
|
17892
|
-
details
|
|
17893
|
-
};
|
|
17906
|
+
return check("registry-sanity", ok ? "pass" : "fail", details);
|
|
17894
17907
|
}
|
|
17895
17908
|
/** Check 3: Trust Metadata presence (informational until full coverage). */
|
|
17896
17909
|
function checkTrustMetadata(rules = RULES) {
|
|
17897
17910
|
const missing = rules.filter((r) => !r.languages?.length || !r.frameworks?.length || r.falsePositiveRisk === void 0);
|
|
17898
|
-
return {
|
|
17899
|
-
name: "trust-metadata",
|
|
17900
|
-
ok: missing.length === 0,
|
|
17901
|
-
details: missing.map((r) => `${r.id}: missing trust metadata`)
|
|
17902
|
-
};
|
|
17911
|
+
return check("trust-metadata", missing.length === 0 ? "pass" : "fail", missing.map((r) => `${r.id}: missing trust metadata`));
|
|
17903
17912
|
}
|
|
17904
17913
|
/**
|
|
17905
17914
|
* Check 4 (Honesty Core): evidence-level honesty. A rule that claims a
|
|
@@ -17918,11 +17927,7 @@ function checkEvidenceHonesty(rules = RULES) {
|
|
|
17918
17927
|
details.push(`${r.id}: declares ${r.evidenceLevel} but findingType=${r.findingType}/confidence=${r.confidence} supports at most ${derived}`);
|
|
17919
17928
|
}
|
|
17920
17929
|
}
|
|
17921
|
-
return
|
|
17922
|
-
name: "evidence-honesty",
|
|
17923
|
-
ok,
|
|
17924
|
-
details
|
|
17925
|
-
};
|
|
17930
|
+
return check("evidence-honesty", ok ? "pass" : "fail", details);
|
|
17926
17931
|
}
|
|
17927
17932
|
/**
|
|
17928
17933
|
* Check 5 (Phase 4 — Tempering Plan, ratcheted per audit H-2):
|
|
@@ -17963,11 +17968,7 @@ function checkTierEnforcement(verdictsDir, rules = RULES) {
|
|
|
17963
17968
|
const total = coreRules.length;
|
|
17964
17969
|
const ok = unmeasured <= 0;
|
|
17965
17970
|
details.unshift(ok ? `Ratchet (Law #3): ${unmeasured}/${total} core rules lack a measured FP rate — cap is 0 (Phase 1 closed the unmeasured-core hole)` : `BLOCKING: ${unmeasured}/${total} core rules unmeasured — exceeds the Law #3 ratchet cap of 0`);
|
|
17966
|
-
return
|
|
17967
|
-
name: "tier-enforcement",
|
|
17968
|
-
ok,
|
|
17969
|
-
details
|
|
17970
|
-
};
|
|
17971
|
+
return check("tier-enforcement", ok ? "pass" : "fail", details);
|
|
17971
17972
|
}
|
|
17972
17973
|
/**
|
|
17973
17974
|
* Check 6 (Phase 7 — Tempering Plan): anti-creep law enforcement.
|
|
@@ -17986,11 +17987,7 @@ function checkAntiCreep(rules = RULES) {
|
|
|
17986
17987
|
for (const r of overflow.slice(0, 5)) details.push(` overflow: ${r.id} — ${r.title}`);
|
|
17987
17988
|
if (overflow.length > 5) details.push(` … and ${overflow.length - 5} more`);
|
|
17988
17989
|
} else details.push(`Core tier: ${count}/65 rules (${65 - count} slots available)`);
|
|
17989
|
-
return
|
|
17990
|
-
name: "anti-creep",
|
|
17991
|
-
ok,
|
|
17992
|
-
details
|
|
17993
|
-
};
|
|
17990
|
+
return check("anti-creep", ok ? "pass" : "fail", details);
|
|
17994
17991
|
}
|
|
17995
17992
|
/**
|
|
17996
17993
|
* Check 7 (audit H-1): quarantine enforcement. The tier policy must cap
|
|
@@ -18010,11 +18007,7 @@ function checkQuarantineEnforcement(rules = RULES) {
|
|
|
18010
18007
|
}
|
|
18011
18008
|
}
|
|
18012
18009
|
details.unshift(`${quarantine.length} quarantine rules capped to severity=info, evidence=E0 — no quarantine rule may emit error`);
|
|
18013
|
-
return
|
|
18014
|
-
name: "quarantine-enforcement",
|
|
18015
|
-
ok,
|
|
18016
|
-
details
|
|
18017
|
-
};
|
|
18010
|
+
return check("quarantine-enforcement", ok ? "pass" : "fail", details);
|
|
18018
18011
|
}
|
|
18019
18012
|
/**
|
|
18020
18013
|
* Check 8 (certification-audit Phase 2.5, plan G3 Layer A support):
|
|
@@ -18067,11 +18060,7 @@ function checkFixtureIntegrity(fixturesRoot, rules = RULES) {
|
|
|
18067
18060
|
details.push("typecheck-allowlist.json is unreadable/malformed");
|
|
18068
18061
|
}
|
|
18069
18062
|
details.unshift(`fixture trees: ${fixtureDirs} rule dirs, ${fixtureFiles} fixture files — orphaned dirs and empty dirs are blocking`);
|
|
18070
|
-
return
|
|
18071
|
-
name: "fixture-integrity",
|
|
18072
|
-
ok,
|
|
18073
|
-
details
|
|
18074
|
-
};
|
|
18063
|
+
return check("fixture-integrity", ok ? "pass" : "fail", details);
|
|
18075
18064
|
}
|
|
18076
18065
|
/**
|
|
18077
18066
|
* Check 9 (certification-audit Phase 3.3, G4/D8v2 — HARD-BLOCKING):
|
|
@@ -18096,35 +18085,19 @@ function checkRevisionIntegrity(repoRoot, rules = RULES) {
|
|
|
18096
18085
|
const details = [];
|
|
18097
18086
|
const manifestPath = join(repoRoot, "tests", "corpus", "detector-hashes.json");
|
|
18098
18087
|
const rulesDir = join(repoRoot, "src", "rules");
|
|
18099
|
-
if (!existsSync(manifestPath)) return
|
|
18100
|
-
|
|
18101
|
-
ok: false,
|
|
18102
|
-
details: ["INCONCLUSIVE: tests/corpus/detector-hashes.json is missing — run `npm run detector-hashes:update` (certification-critical: an unevaluable check never renders as pass)"]
|
|
18103
|
-
};
|
|
18104
|
-
if (!existsSync(rulesDir)) return {
|
|
18105
|
-
name: "revision-integrity",
|
|
18106
|
-
ok: false,
|
|
18107
|
-
details: ["INCONCLUSIVE: src/rules is not present (installed package) — detector source identity cannot be verified here"]
|
|
18108
|
-
};
|
|
18088
|
+
if (!existsSync(manifestPath)) return check("revision-integrity", "inconclusive", ["INCONCLUSIVE: tests/corpus/detector-hashes.json is missing — run `npm run detector-hashes:update` (certification-critical: an unevaluable check never renders as pass)"]);
|
|
18089
|
+
if (!existsSync(rulesDir)) return check("revision-integrity", "inconclusive", ["INCONCLUSIVE: src/rules is not present (installed package) — detector source identity cannot be verified here"]);
|
|
18109
18090
|
let manifest;
|
|
18110
18091
|
try {
|
|
18111
18092
|
manifest = loadManifest(manifestPath);
|
|
18112
18093
|
} catch {
|
|
18113
|
-
return
|
|
18114
|
-
name: "revision-integrity",
|
|
18115
|
-
ok: false,
|
|
18116
|
-
details: ["INCONCLUSIVE: tests/corpus/detector-hashes.json is unreadable/malformed — regenerate with `npm run detector-hashes:update`"]
|
|
18117
|
-
};
|
|
18094
|
+
return check("revision-integrity", "inconclusive", ["INCONCLUSIVE: tests/corpus/detector-hashes.json is unreadable/malformed — regenerate with `npm run detector-hashes:update`"]);
|
|
18118
18095
|
}
|
|
18119
18096
|
let current;
|
|
18120
18097
|
try {
|
|
18121
18098
|
current = computeDetectorHashes(rules, rulesDir);
|
|
18122
18099
|
} catch (e) {
|
|
18123
|
-
return {
|
|
18124
|
-
name: "revision-integrity",
|
|
18125
|
-
ok: false,
|
|
18126
|
-
details: [`INCONCLUSIVE: detector hash computation failed — ${errorText(e)}`]
|
|
18127
|
-
};
|
|
18100
|
+
return check("revision-integrity", "inconclusive", [`INCONCLUSIVE: detector hash computation failed — ${errorText(e)}`]);
|
|
18128
18101
|
}
|
|
18129
18102
|
const failures = [];
|
|
18130
18103
|
for (const rule of rules) {
|
|
@@ -18139,18 +18112,10 @@ function checkRevisionIntegrity(repoRoot, rules = RULES) {
|
|
|
18139
18112
|
for (const id of Object.keys(manifest)) if (!rules.some((r) => r.id === id)) failures.push(`${id}: attested in the manifest but not in the registry`);
|
|
18140
18113
|
if (failures.length > 0) {
|
|
18141
18114
|
details.push(...failures);
|
|
18142
|
-
return
|
|
18143
|
-
name: "revision-integrity",
|
|
18144
|
-
ok: false,
|
|
18145
|
-
details
|
|
18146
|
-
};
|
|
18115
|
+
return check("revision-integrity", "fail", details);
|
|
18147
18116
|
}
|
|
18148
18117
|
details.push(`${rules.length} rules attested: source identity and declared revision match the manifest (check A + check B, G4)`);
|
|
18149
|
-
return
|
|
18150
|
-
name: "revision-integrity",
|
|
18151
|
-
ok: true,
|
|
18152
|
-
details
|
|
18153
|
-
};
|
|
18118
|
+
return check("revision-integrity", "pass", details);
|
|
18154
18119
|
}
|
|
18155
18120
|
function runDoctorSelfAudit(fixturesRoot) {
|
|
18156
18121
|
const verdictsDir = join(fixturesRoot, "..", "corpus", "verdicts");
|
|
@@ -18167,7 +18132,7 @@ function runDoctorSelfAudit(fixturesRoot) {
|
|
|
18167
18132
|
];
|
|
18168
18133
|
return {
|
|
18169
18134
|
checks,
|
|
18170
|
-
healthy: checks.every((c) => c.
|
|
18135
|
+
healthy: checks.every((c) => c.status === "pass")
|
|
18171
18136
|
};
|
|
18172
18137
|
}
|
|
18173
18138
|
function renderDoctorReport(report) {
|
|
@@ -18177,7 +18142,7 @@ function renderDoctorReport(report) {
|
|
|
18177
18142
|
""
|
|
18178
18143
|
];
|
|
18179
18144
|
for (const c of report.checks) {
|
|
18180
|
-
const mark = c.
|
|
18145
|
+
const mark = c.status === "pass" ? "✓" : c.status === "fail" ? "✗" : "? INCONCLUSIVE";
|
|
18181
18146
|
lines.push(`${mark} ${c.name}`);
|
|
18182
18147
|
for (const d of c.details.slice(0, 20)) lines.push(` ${d}`);
|
|
18183
18148
|
if (c.details.length > 20) lines.push(` … and ${c.details.length - 20} more`);
|
|
@@ -18186,6 +18151,88 @@ function renderDoctorReport(report) {
|
|
|
18186
18151
|
lines.push(report.healthy ? "Mjölnir self-audit: WORTHY" : "Mjölnir self-audit: VIOLATIONS FOUND");
|
|
18187
18152
|
return lines.join("\n");
|
|
18188
18153
|
}
|
|
18154
|
+
/** Versioned JSON schema name for `doctor --json` (G5). */
|
|
18155
|
+
const DOCTOR_REPORT_SCHEMA = "mjolnir.doctor-report@1";
|
|
18156
|
+
/**
|
|
18157
|
+
* Serializes the doctor report to the machine-readable contract (Phase 5,
|
|
18158
|
+
* G5): versioned `schema` field, stable key order (constructed once, here),
|
|
18159
|
+
* details limited exactly like the text render, paths already POSIX-relative
|
|
18160
|
+
* (the checks build them that way), and byte-identical across two runs on
|
|
18161
|
+
* the same tree — the CI certification job re-runs the command twice and
|
|
18162
|
+
* diffs, so any nondeterminism fails there.
|
|
18163
|
+
*/
|
|
18164
|
+
function doctorReportJson(report, opts = {}) {
|
|
18165
|
+
const maxDetails = opts.maxDetails ?? 20;
|
|
18166
|
+
const summary = {
|
|
18167
|
+
pass: 0,
|
|
18168
|
+
fail: 0,
|
|
18169
|
+
inconclusive: 0
|
|
18170
|
+
};
|
|
18171
|
+
const checks = report.checks.map((c) => {
|
|
18172
|
+
summary[c.status]++;
|
|
18173
|
+
return {
|
|
18174
|
+
name: c.name,
|
|
18175
|
+
status: c.status,
|
|
18176
|
+
ok: c.ok,
|
|
18177
|
+
details: c.details.length > maxDetails ? [...c.details.slice(0, maxDetails), `… and ${c.details.length - maxDetails} more`] : c.details
|
|
18178
|
+
};
|
|
18179
|
+
});
|
|
18180
|
+
return {
|
|
18181
|
+
schema: DOCTOR_REPORT_SCHEMA,
|
|
18182
|
+
healthy: report.healthy,
|
|
18183
|
+
summary,
|
|
18184
|
+
checks
|
|
18185
|
+
};
|
|
18186
|
+
}
|
|
18187
|
+
//#endregion
|
|
18188
|
+
//#region src/commands/doctor-run.ts
|
|
18189
|
+
/**
|
|
18190
|
+
* Doctor CLI entry (certification-audit Phase 5, G6): the command handler
|
|
18191
|
+
* lives beside the check implementations instead of in cli.ts — the CLI
|
|
18192
|
+
* file is a dispatch table, not a business-logic home.
|
|
18193
|
+
*
|
|
18194
|
+
* Exit-code contract (stable, e2e-locked):
|
|
18195
|
+
* 0 — WORTHY (every check pass)
|
|
18196
|
+
* 1 — VIOLATIONS FOUND (any fail OR inconclusive — G2: an
|
|
18197
|
+
* INCONCLUSIVE check never renders as pass, in text or JSON, and
|
|
18198
|
+
* never exits 0)
|
|
18199
|
+
* 2 — no fixtures directory at the target (not an mjolnir checkout)
|
|
18200
|
+
* 10 — usage error (unknown flag / flag-shaped positional)
|
|
18201
|
+
* 20 — internal error (crash; --debug prints the stack)
|
|
18202
|
+
*
|
|
18203
|
+
* `--json`: prints the versioned machine contract (doctorReportJson)
|
|
18204
|
+
* instead of the text render. stdout carries EXACTLY the JSON document —
|
|
18205
|
+
* no banners, no progress lines — so `doctor . --json > report.json` is
|
|
18206
|
+
* a clean file. The exit code is the gate; the JSON is the evidence.
|
|
18207
|
+
*/
|
|
18208
|
+
function runDoctorCommand(argv, io = {
|
|
18209
|
+
out,
|
|
18210
|
+
err
|
|
18211
|
+
}) {
|
|
18212
|
+
const json = argv.includes("--json");
|
|
18213
|
+
if (argv.filter((a) => a.startsWith("-") && a !== "--json").length > 0) {
|
|
18214
|
+
io.err("Usage: mjolnir doctor [--json] [repo-root]");
|
|
18215
|
+
return 10;
|
|
18216
|
+
}
|
|
18217
|
+
const targetArg = argv.find((a) => !a.startsWith("-")) ?? process.cwd();
|
|
18218
|
+
try {
|
|
18219
|
+
const fixturesRoot = resolve(join(targetArg, "tests", "fixtures"));
|
|
18220
|
+
if (!existsSync(fixturesRoot)) {
|
|
18221
|
+
io.err(`No fixtures directory at ${fixturesRoot}. Run from the mjolnir repo root.`);
|
|
18222
|
+
return 2;
|
|
18223
|
+
}
|
|
18224
|
+
const report = runDoctorSelfAudit(fixturesRoot);
|
|
18225
|
+
if (json) {
|
|
18226
|
+
io.out(JSON.stringify(doctorReportJson(report), null, 2));
|
|
18227
|
+
return report.healthy ? 0 : 1;
|
|
18228
|
+
}
|
|
18229
|
+
io.out(renderDoctorReport(report));
|
|
18230
|
+
return report.healthy ? 0 : 1;
|
|
18231
|
+
} catch (err) {
|
|
18232
|
+
internalErrorMessage(err, io.err, process.argv.includes("--debug"));
|
|
18233
|
+
return 20;
|
|
18234
|
+
}
|
|
18235
|
+
}
|
|
18189
18236
|
//#endregion
|
|
18190
18237
|
//#region src/commands/rules-catalog.ts
|
|
18191
18238
|
/**
|
|
@@ -18402,7 +18449,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
|
|
|
18402
18449
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
18403
18450
|
* `tests/version-consistency.spec.ts` locally.
|
|
18404
18451
|
*/
|
|
18405
|
-
const CLI_VERSION = "0.5.
|
|
18452
|
+
const CLI_VERSION = "0.5.20";
|
|
18406
18453
|
function parseArgs(argv, onError) {
|
|
18407
18454
|
const args = {
|
|
18408
18455
|
target: ".",
|
|
@@ -18567,8 +18614,6 @@ function parseArgsOrUsage(argv, io) {
|
|
|
18567
18614
|
if (!args && !reported) printUsage(io.out);
|
|
18568
18615
|
return args;
|
|
18569
18616
|
}
|
|
18570
|
-
const out = (...parts) => console.log(parts.map(String).join(" "));
|
|
18571
|
-
const err = (...parts) => console.error(parts.map(String).join(" "));
|
|
18572
18617
|
/** Testable `ci install` handler. Returns the process exit code. */
|
|
18573
18618
|
function runCiInstall(argv, io = {
|
|
18574
18619
|
out,
|
|
@@ -18690,30 +18735,6 @@ async function runDoctorPlaywright(argv, io = { out }) {
|
|
|
18690
18735
|
return 20;
|
|
18691
18736
|
}
|
|
18692
18737
|
}
|
|
18693
|
-
/** Testable `doctor` handler — self-audit of Mjölnir's own rule base. */
|
|
18694
|
-
function runDoctorCommand(argv, io = {
|
|
18695
|
-
out,
|
|
18696
|
-
err
|
|
18697
|
-
}) {
|
|
18698
|
-
if (argv.some((a) => a.startsWith("-"))) {
|
|
18699
|
-
io.err("Usage: mjolnir doctor [repo-root]");
|
|
18700
|
-
return 10;
|
|
18701
|
-
}
|
|
18702
|
-
const targetArg = argv[0] ?? process$1.cwd();
|
|
18703
|
-
try {
|
|
18704
|
-
const fixturesRoot = resolve(join(targetArg, "tests", "fixtures"));
|
|
18705
|
-
if (!existsSync(fixturesRoot)) {
|
|
18706
|
-
io.err(`No fixtures directory at ${fixturesRoot}. Run from the mjolnir repo root.`);
|
|
18707
|
-
return 2;
|
|
18708
|
-
}
|
|
18709
|
-
const report = runDoctorSelfAudit(fixturesRoot);
|
|
18710
|
-
io.out(renderDoctorReport(report));
|
|
18711
|
-
return report.healthy ? 0 : 1;
|
|
18712
|
-
} catch (err) {
|
|
18713
|
-
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
18714
|
-
return 20;
|
|
18715
|
-
}
|
|
18716
|
-
}
|
|
18717
18738
|
/** Testable `rules` handler — rule catalog with Trust Metadata. */
|
|
18718
18739
|
async function runRulesCommand(argv, io = {
|
|
18719
18740
|
out,
|
|
@@ -19349,22 +19370,6 @@ function runHelpCommand(argv, io = {
|
|
|
19349
19370
|
function printUsage(print) {
|
|
19350
19371
|
print(renderRootHelp());
|
|
19351
19372
|
}
|
|
19352
|
-
/**
|
|
19353
|
-
* Friendly exit-20 path (plan M2): the crash says it's Mjölnir's bug,
|
|
19354
|
-
* not the user's repo, carries the underlying message for a report, and
|
|
19355
|
-
* prints the stack ONLY when `debug` is set (uniform across
|
|
19356
|
-
* subcommands — they don't parse scan flags). Tests pin
|
|
19357
|
-
* /internal error/i. Exported so the --debug stack arm is directly
|
|
19358
|
-
* spec-coverable (spawning a real crash under --debug would be flaky).
|
|
19359
|
-
*/
|
|
19360
|
-
function internalErrorMessage(err, emit, debug) {
|
|
19361
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
19362
|
-
emit("mjolnir internal error — this is a bug in Mjölnir, not your repo:");
|
|
19363
|
-
emit(` ${message}`);
|
|
19364
|
-
if (debug && err instanceof Error && err.stack) emit(err.stack);
|
|
19365
|
-
emit("Rerun with --debug for the stack trace. Please report this:");
|
|
19366
|
-
emit(" https://github.com/Sergey-Bar/Mjolnir/issues");
|
|
19367
|
-
}
|
|
19368
19373
|
function isEntryPoint() {
|
|
19369
19374
|
const argv1 = process$1.argv[1];
|
|
19370
19375
|
if (!argv1) return false;
|
package/dist/mcp/stdio.mjs
CHANGED
|
@@ -13263,7 +13263,7 @@ function renderSarif(result, repoRootUri) {
|
|
|
13263
13263
|
tool: { driver: {
|
|
13264
13264
|
name: "Mjölnir",
|
|
13265
13265
|
informationUri: "https://github.com/Sergey-Bar/Mjolnir",
|
|
13266
|
-
version: "0.5.
|
|
13266
|
+
version: "0.5.20",
|
|
13267
13267
|
rules: [...rules.values()].map((r) => {
|
|
13268
13268
|
const meta = RULES.find((x) => x.id === r.id);
|
|
13269
13269
|
return {
|
|
@@ -17084,6 +17084,18 @@ function renderFixReport(results, dryRun) {
|
|
|
17084
17084
|
return lines.join("\n");
|
|
17085
17085
|
}
|
|
17086
17086
|
//#endregion
|
|
17087
|
+
//#region src/cli-io.ts
|
|
17088
|
+
const out = (...parts) => console.log(parts.map(String).join(" "));
|
|
17089
|
+
const err = (...parts) => console.error(parts.map(String).join(" "));
|
|
17090
|
+
function internalErrorMessage(err, emit, debug) {
|
|
17091
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
17092
|
+
emit("mjolnir internal error — this is a bug in Mjölnir, not your repo:");
|
|
17093
|
+
emit(` ${message}`);
|
|
17094
|
+
if (debug && err instanceof Error && err.stack) emit(err.stack);
|
|
17095
|
+
emit("Rerun with --debug for the stack trace. Please report this:");
|
|
17096
|
+
emit(" https://github.com/Sergey-Bar/Mjolnir/issues");
|
|
17097
|
+
}
|
|
17098
|
+
//#endregion
|
|
17087
17099
|
//#region src/rules/measurement.ts
|
|
17088
17100
|
/** The rule's declared detector implementation revision (§07). */
|
|
17089
17101
|
function declaredDetectorRevision(rule) {
|
|
@@ -17319,6 +17331,15 @@ function errorText(e) {
|
|
|
17319
17331
|
return e instanceof Error ? e.message : String(e);
|
|
17320
17332
|
}
|
|
17321
17333
|
const ui$2 = plainContext();
|
|
17334
|
+
/** Builds the check record: `ok` is always the pass-mirror of `status`. */
|
|
17335
|
+
function check(name, status, details) {
|
|
17336
|
+
return {
|
|
17337
|
+
name,
|
|
17338
|
+
status,
|
|
17339
|
+
ok: status === "pass",
|
|
17340
|
+
details
|
|
17341
|
+
};
|
|
17342
|
+
}
|
|
17322
17343
|
const VALID_ID = RULE_ID_RE;
|
|
17323
17344
|
function nonHiddenFiles(dir) {
|
|
17324
17345
|
if (!existsSync(dir)) return [];
|
|
@@ -17341,11 +17362,7 @@ function checkFixtureFirewall(fixturesRoot) {
|
|
|
17341
17362
|
details.push(`${rule.id}: missing must-not-fire fixture`);
|
|
17342
17363
|
}
|
|
17343
17364
|
}
|
|
17344
|
-
return
|
|
17345
|
-
name: "fixture-firewall",
|
|
17346
|
-
ok,
|
|
17347
|
-
details
|
|
17348
|
-
};
|
|
17365
|
+
return check("fixture-firewall", ok ? "pass" : "fail", details);
|
|
17349
17366
|
}
|
|
17350
17367
|
/** Check 2: registry sanity — IDs unique, well-formed, titles distinct. */
|
|
17351
17368
|
function checkRegistry(rules = RULES) {
|
|
@@ -17368,20 +17385,12 @@ function checkRegistry(rules = RULES) {
|
|
|
17368
17385
|
details.push(`"${r.title}": duplicate title in family (${r.id})`);
|
|
17369
17386
|
}
|
|
17370
17387
|
}
|
|
17371
|
-
return
|
|
17372
|
-
name: "registry-sanity",
|
|
17373
|
-
ok,
|
|
17374
|
-
details
|
|
17375
|
-
};
|
|
17388
|
+
return check("registry-sanity", ok ? "pass" : "fail", details);
|
|
17376
17389
|
}
|
|
17377
17390
|
/** Check 3: Trust Metadata presence (informational until full coverage). */
|
|
17378
17391
|
function checkTrustMetadata(rules = RULES) {
|
|
17379
17392
|
const missing = rules.filter((r) => !r.languages?.length || !r.frameworks?.length || r.falsePositiveRisk === void 0);
|
|
17380
|
-
return {
|
|
17381
|
-
name: "trust-metadata",
|
|
17382
|
-
ok: missing.length === 0,
|
|
17383
|
-
details: missing.map((r) => `${r.id}: missing trust metadata`)
|
|
17384
|
-
};
|
|
17393
|
+
return check("trust-metadata", missing.length === 0 ? "pass" : "fail", missing.map((r) => `${r.id}: missing trust metadata`));
|
|
17385
17394
|
}
|
|
17386
17395
|
/**
|
|
17387
17396
|
* Check 4 (Honesty Core): evidence-level honesty. A rule that claims a
|
|
@@ -17400,11 +17409,7 @@ function checkEvidenceHonesty(rules = RULES) {
|
|
|
17400
17409
|
details.push(`${r.id}: declares ${r.evidenceLevel} but findingType=${r.findingType}/confidence=${r.confidence} supports at most ${derived}`);
|
|
17401
17410
|
}
|
|
17402
17411
|
}
|
|
17403
|
-
return
|
|
17404
|
-
name: "evidence-honesty",
|
|
17405
|
-
ok,
|
|
17406
|
-
details
|
|
17407
|
-
};
|
|
17412
|
+
return check("evidence-honesty", ok ? "pass" : "fail", details);
|
|
17408
17413
|
}
|
|
17409
17414
|
/**
|
|
17410
17415
|
* Check 5 (Phase 4 — Tempering Plan, ratcheted per audit H-2):
|
|
@@ -17445,11 +17450,7 @@ function checkTierEnforcement(verdictsDir, rules = RULES) {
|
|
|
17445
17450
|
const total = coreRules.length;
|
|
17446
17451
|
const ok = unmeasured <= 0;
|
|
17447
17452
|
details.unshift(ok ? `Ratchet (Law #3): ${unmeasured}/${total} core rules lack a measured FP rate — cap is 0 (Phase 1 closed the unmeasured-core hole)` : `BLOCKING: ${unmeasured}/${total} core rules unmeasured — exceeds the Law #3 ratchet cap of 0`);
|
|
17448
|
-
return
|
|
17449
|
-
name: "tier-enforcement",
|
|
17450
|
-
ok,
|
|
17451
|
-
details
|
|
17452
|
-
};
|
|
17453
|
+
return check("tier-enforcement", ok ? "pass" : "fail", details);
|
|
17453
17454
|
}
|
|
17454
17455
|
/**
|
|
17455
17456
|
* Check 6 (Phase 7 — Tempering Plan): anti-creep law enforcement.
|
|
@@ -17468,11 +17469,7 @@ function checkAntiCreep(rules = RULES) {
|
|
|
17468
17469
|
for (const r of overflow.slice(0, 5)) details.push(` overflow: ${r.id} — ${r.title}`);
|
|
17469
17470
|
if (overflow.length > 5) details.push(` … and ${overflow.length - 5} more`);
|
|
17470
17471
|
} else details.push(`Core tier: ${count}/65 rules (${65 - count} slots available)`);
|
|
17471
|
-
return
|
|
17472
|
-
name: "anti-creep",
|
|
17473
|
-
ok,
|
|
17474
|
-
details
|
|
17475
|
-
};
|
|
17472
|
+
return check("anti-creep", ok ? "pass" : "fail", details);
|
|
17476
17473
|
}
|
|
17477
17474
|
/**
|
|
17478
17475
|
* Check 7 (audit H-1): quarantine enforcement. The tier policy must cap
|
|
@@ -17492,11 +17489,7 @@ function checkQuarantineEnforcement(rules = RULES) {
|
|
|
17492
17489
|
}
|
|
17493
17490
|
}
|
|
17494
17491
|
details.unshift(`${quarantine.length} quarantine rules capped to severity=info, evidence=E0 — no quarantine rule may emit error`);
|
|
17495
|
-
return
|
|
17496
|
-
name: "quarantine-enforcement",
|
|
17497
|
-
ok,
|
|
17498
|
-
details
|
|
17499
|
-
};
|
|
17492
|
+
return check("quarantine-enforcement", ok ? "pass" : "fail", details);
|
|
17500
17493
|
}
|
|
17501
17494
|
/**
|
|
17502
17495
|
* Check 8 (certification-audit Phase 2.5, plan G3 Layer A support):
|
|
@@ -17549,11 +17542,7 @@ function checkFixtureIntegrity(fixturesRoot, rules = RULES) {
|
|
|
17549
17542
|
details.push("typecheck-allowlist.json is unreadable/malformed");
|
|
17550
17543
|
}
|
|
17551
17544
|
details.unshift(`fixture trees: ${fixtureDirs} rule dirs, ${fixtureFiles} fixture files — orphaned dirs and empty dirs are blocking`);
|
|
17552
|
-
return
|
|
17553
|
-
name: "fixture-integrity",
|
|
17554
|
-
ok,
|
|
17555
|
-
details
|
|
17556
|
-
};
|
|
17545
|
+
return check("fixture-integrity", ok ? "pass" : "fail", details);
|
|
17557
17546
|
}
|
|
17558
17547
|
/**
|
|
17559
17548
|
* Check 9 (certification-audit Phase 3.3, G4/D8v2 — HARD-BLOCKING):
|
|
@@ -17578,35 +17567,19 @@ function checkRevisionIntegrity(repoRoot, rules = RULES) {
|
|
|
17578
17567
|
const details = [];
|
|
17579
17568
|
const manifestPath = join(repoRoot, "tests", "corpus", "detector-hashes.json");
|
|
17580
17569
|
const rulesDir = join(repoRoot, "src", "rules");
|
|
17581
|
-
if (!existsSync(manifestPath)) return
|
|
17582
|
-
|
|
17583
|
-
ok: false,
|
|
17584
|
-
details: ["INCONCLUSIVE: tests/corpus/detector-hashes.json is missing — run `npm run detector-hashes:update` (certification-critical: an unevaluable check never renders as pass)"]
|
|
17585
|
-
};
|
|
17586
|
-
if (!existsSync(rulesDir)) return {
|
|
17587
|
-
name: "revision-integrity",
|
|
17588
|
-
ok: false,
|
|
17589
|
-
details: ["INCONCLUSIVE: src/rules is not present (installed package) — detector source identity cannot be verified here"]
|
|
17590
|
-
};
|
|
17570
|
+
if (!existsSync(manifestPath)) return check("revision-integrity", "inconclusive", ["INCONCLUSIVE: tests/corpus/detector-hashes.json is missing — run `npm run detector-hashes:update` (certification-critical: an unevaluable check never renders as pass)"]);
|
|
17571
|
+
if (!existsSync(rulesDir)) return check("revision-integrity", "inconclusive", ["INCONCLUSIVE: src/rules is not present (installed package) — detector source identity cannot be verified here"]);
|
|
17591
17572
|
let manifest;
|
|
17592
17573
|
try {
|
|
17593
17574
|
manifest = loadManifest(manifestPath);
|
|
17594
17575
|
} catch {
|
|
17595
|
-
return
|
|
17596
|
-
name: "revision-integrity",
|
|
17597
|
-
ok: false,
|
|
17598
|
-
details: ["INCONCLUSIVE: tests/corpus/detector-hashes.json is unreadable/malformed — regenerate with `npm run detector-hashes:update`"]
|
|
17599
|
-
};
|
|
17576
|
+
return check("revision-integrity", "inconclusive", ["INCONCLUSIVE: tests/corpus/detector-hashes.json is unreadable/malformed — regenerate with `npm run detector-hashes:update`"]);
|
|
17600
17577
|
}
|
|
17601
17578
|
let current;
|
|
17602
17579
|
try {
|
|
17603
17580
|
current = computeDetectorHashes(rules, rulesDir);
|
|
17604
17581
|
} catch (e) {
|
|
17605
|
-
return {
|
|
17606
|
-
name: "revision-integrity",
|
|
17607
|
-
ok: false,
|
|
17608
|
-
details: [`INCONCLUSIVE: detector hash computation failed — ${errorText(e)}`]
|
|
17609
|
-
};
|
|
17582
|
+
return check("revision-integrity", "inconclusive", [`INCONCLUSIVE: detector hash computation failed — ${errorText(e)}`]);
|
|
17610
17583
|
}
|
|
17611
17584
|
const failures = [];
|
|
17612
17585
|
for (const rule of rules) {
|
|
@@ -17621,18 +17594,10 @@ function checkRevisionIntegrity(repoRoot, rules = RULES) {
|
|
|
17621
17594
|
for (const id of Object.keys(manifest)) if (!rules.some((r) => r.id === id)) failures.push(`${id}: attested in the manifest but not in the registry`);
|
|
17622
17595
|
if (failures.length > 0) {
|
|
17623
17596
|
details.push(...failures);
|
|
17624
|
-
return
|
|
17625
|
-
name: "revision-integrity",
|
|
17626
|
-
ok: false,
|
|
17627
|
-
details
|
|
17628
|
-
};
|
|
17597
|
+
return check("revision-integrity", "fail", details);
|
|
17629
17598
|
}
|
|
17630
17599
|
details.push(`${rules.length} rules attested: source identity and declared revision match the manifest (check A + check B, G4)`);
|
|
17631
|
-
return
|
|
17632
|
-
name: "revision-integrity",
|
|
17633
|
-
ok: true,
|
|
17634
|
-
details
|
|
17635
|
-
};
|
|
17600
|
+
return check("revision-integrity", "pass", details);
|
|
17636
17601
|
}
|
|
17637
17602
|
function runDoctorSelfAudit(fixturesRoot) {
|
|
17638
17603
|
const verdictsDir = join(fixturesRoot, "..", "corpus", "verdicts");
|
|
@@ -17649,7 +17614,7 @@ function runDoctorSelfAudit(fixturesRoot) {
|
|
|
17649
17614
|
];
|
|
17650
17615
|
return {
|
|
17651
17616
|
checks,
|
|
17652
|
-
healthy: checks.every((c) => c.
|
|
17617
|
+
healthy: checks.every((c) => c.status === "pass")
|
|
17653
17618
|
};
|
|
17654
17619
|
}
|
|
17655
17620
|
function renderDoctorReport(report) {
|
|
@@ -17659,7 +17624,7 @@ function renderDoctorReport(report) {
|
|
|
17659
17624
|
""
|
|
17660
17625
|
];
|
|
17661
17626
|
for (const c of report.checks) {
|
|
17662
|
-
const mark = c.
|
|
17627
|
+
const mark = c.status === "pass" ? "✓" : c.status === "fail" ? "✗" : "? INCONCLUSIVE";
|
|
17663
17628
|
lines.push(`${mark} ${c.name}`);
|
|
17664
17629
|
for (const d of c.details.slice(0, 20)) lines.push(` ${d}`);
|
|
17665
17630
|
if (c.details.length > 20) lines.push(` … and ${c.details.length - 20} more`);
|
|
@@ -17668,6 +17633,88 @@ function renderDoctorReport(report) {
|
|
|
17668
17633
|
lines.push(report.healthy ? "Mjölnir self-audit: WORTHY" : "Mjölnir self-audit: VIOLATIONS FOUND");
|
|
17669
17634
|
return lines.join("\n");
|
|
17670
17635
|
}
|
|
17636
|
+
/** Versioned JSON schema name for `doctor --json` (G5). */
|
|
17637
|
+
const DOCTOR_REPORT_SCHEMA = "mjolnir.doctor-report@1";
|
|
17638
|
+
/**
|
|
17639
|
+
* Serializes the doctor report to the machine-readable contract (Phase 5,
|
|
17640
|
+
* G5): versioned `schema` field, stable key order (constructed once, here),
|
|
17641
|
+
* details limited exactly like the text render, paths already POSIX-relative
|
|
17642
|
+
* (the checks build them that way), and byte-identical across two runs on
|
|
17643
|
+
* the same tree — the CI certification job re-runs the command twice and
|
|
17644
|
+
* diffs, so any nondeterminism fails there.
|
|
17645
|
+
*/
|
|
17646
|
+
function doctorReportJson(report, opts = {}) {
|
|
17647
|
+
const maxDetails = opts.maxDetails ?? 20;
|
|
17648
|
+
const summary = {
|
|
17649
|
+
pass: 0,
|
|
17650
|
+
fail: 0,
|
|
17651
|
+
inconclusive: 0
|
|
17652
|
+
};
|
|
17653
|
+
const checks = report.checks.map((c) => {
|
|
17654
|
+
summary[c.status]++;
|
|
17655
|
+
return {
|
|
17656
|
+
name: c.name,
|
|
17657
|
+
status: c.status,
|
|
17658
|
+
ok: c.ok,
|
|
17659
|
+
details: c.details.length > maxDetails ? [...c.details.slice(0, maxDetails), `… and ${c.details.length - maxDetails} more`] : c.details
|
|
17660
|
+
};
|
|
17661
|
+
});
|
|
17662
|
+
return {
|
|
17663
|
+
schema: DOCTOR_REPORT_SCHEMA,
|
|
17664
|
+
healthy: report.healthy,
|
|
17665
|
+
summary,
|
|
17666
|
+
checks
|
|
17667
|
+
};
|
|
17668
|
+
}
|
|
17669
|
+
//#endregion
|
|
17670
|
+
//#region src/commands/doctor-run.ts
|
|
17671
|
+
/**
|
|
17672
|
+
* Doctor CLI entry (certification-audit Phase 5, G6): the command handler
|
|
17673
|
+
* lives beside the check implementations instead of in cli.ts — the CLI
|
|
17674
|
+
* file is a dispatch table, not a business-logic home.
|
|
17675
|
+
*
|
|
17676
|
+
* Exit-code contract (stable, e2e-locked):
|
|
17677
|
+
* 0 — WORTHY (every check pass)
|
|
17678
|
+
* 1 — VIOLATIONS FOUND (any fail OR inconclusive — G2: an
|
|
17679
|
+
* INCONCLUSIVE check never renders as pass, in text or JSON, and
|
|
17680
|
+
* never exits 0)
|
|
17681
|
+
* 2 — no fixtures directory at the target (not an mjolnir checkout)
|
|
17682
|
+
* 10 — usage error (unknown flag / flag-shaped positional)
|
|
17683
|
+
* 20 — internal error (crash; --debug prints the stack)
|
|
17684
|
+
*
|
|
17685
|
+
* `--json`: prints the versioned machine contract (doctorReportJson)
|
|
17686
|
+
* instead of the text render. stdout carries EXACTLY the JSON document —
|
|
17687
|
+
* no banners, no progress lines — so `doctor . --json > report.json` is
|
|
17688
|
+
* a clean file. The exit code is the gate; the JSON is the evidence.
|
|
17689
|
+
*/
|
|
17690
|
+
function runDoctorCommand(argv, io = {
|
|
17691
|
+
out,
|
|
17692
|
+
err
|
|
17693
|
+
}) {
|
|
17694
|
+
const json = argv.includes("--json");
|
|
17695
|
+
if (argv.filter((a) => a.startsWith("-") && a !== "--json").length > 0) {
|
|
17696
|
+
io.err("Usage: mjolnir doctor [--json] [repo-root]");
|
|
17697
|
+
return 10;
|
|
17698
|
+
}
|
|
17699
|
+
const targetArg = argv.find((a) => !a.startsWith("-")) ?? process.cwd();
|
|
17700
|
+
try {
|
|
17701
|
+
const fixturesRoot = resolve(join(targetArg, "tests", "fixtures"));
|
|
17702
|
+
if (!existsSync(fixturesRoot)) {
|
|
17703
|
+
io.err(`No fixtures directory at ${fixturesRoot}. Run from the mjolnir repo root.`);
|
|
17704
|
+
return 2;
|
|
17705
|
+
}
|
|
17706
|
+
const report = runDoctorSelfAudit(fixturesRoot);
|
|
17707
|
+
if (json) {
|
|
17708
|
+
io.out(JSON.stringify(doctorReportJson(report), null, 2));
|
|
17709
|
+
return report.healthy ? 0 : 1;
|
|
17710
|
+
}
|
|
17711
|
+
io.out(renderDoctorReport(report));
|
|
17712
|
+
return report.healthy ? 0 : 1;
|
|
17713
|
+
} catch (err) {
|
|
17714
|
+
internalErrorMessage(err, io.err, process.argv.includes("--debug"));
|
|
17715
|
+
return 20;
|
|
17716
|
+
}
|
|
17717
|
+
}
|
|
17671
17718
|
//#endregion
|
|
17672
17719
|
//#region src/commands/rules-catalog.ts
|
|
17673
17720
|
/**
|
|
@@ -18038,7 +18085,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
|
|
|
18038
18085
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
18039
18086
|
* `tests/version-consistency.spec.ts` locally.
|
|
18040
18087
|
*/
|
|
18041
|
-
const CLI_VERSION = "0.5.
|
|
18088
|
+
const CLI_VERSION = "0.5.20";
|
|
18042
18089
|
function parseArgs(argv, onError) {
|
|
18043
18090
|
const args = {
|
|
18044
18091
|
target: ".",
|
|
@@ -18203,8 +18250,6 @@ function parseArgsOrUsage(argv, io) {
|
|
|
18203
18250
|
if (!args && !reported) printUsage(io.out);
|
|
18204
18251
|
return args;
|
|
18205
18252
|
}
|
|
18206
|
-
const out = (...parts) => console.log(parts.map(String).join(" "));
|
|
18207
|
-
const err = (...parts) => console.error(parts.map(String).join(" "));
|
|
18208
18253
|
/** Testable `ci install` handler. Returns the process exit code. */
|
|
18209
18254
|
function runCiInstall(argv, io = {
|
|
18210
18255
|
out,
|
|
@@ -18326,30 +18371,6 @@ async function runDoctorPlaywright(argv, io = { out }) {
|
|
|
18326
18371
|
return 20;
|
|
18327
18372
|
}
|
|
18328
18373
|
}
|
|
18329
|
-
/** Testable `doctor` handler — self-audit of Mjölnir's own rule base. */
|
|
18330
|
-
function runDoctorCommand(argv, io = {
|
|
18331
|
-
out,
|
|
18332
|
-
err
|
|
18333
|
-
}) {
|
|
18334
|
-
if (argv.some((a) => a.startsWith("-"))) {
|
|
18335
|
-
io.err("Usage: mjolnir doctor [repo-root]");
|
|
18336
|
-
return 10;
|
|
18337
|
-
}
|
|
18338
|
-
const targetArg = argv[0] ?? process$1.cwd();
|
|
18339
|
-
try {
|
|
18340
|
-
const fixturesRoot = resolve(join(targetArg, "tests", "fixtures"));
|
|
18341
|
-
if (!existsSync(fixturesRoot)) {
|
|
18342
|
-
io.err(`No fixtures directory at ${fixturesRoot}. Run from the mjolnir repo root.`);
|
|
18343
|
-
return 2;
|
|
18344
|
-
}
|
|
18345
|
-
const report = runDoctorSelfAudit(fixturesRoot);
|
|
18346
|
-
io.out(renderDoctorReport(report));
|
|
18347
|
-
return report.healthy ? 0 : 1;
|
|
18348
|
-
} catch (err) {
|
|
18349
|
-
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
18350
|
-
return 20;
|
|
18351
|
-
}
|
|
18352
|
-
}
|
|
18353
18374
|
/** Testable `rules` handler — rule catalog with Trust Metadata. */
|
|
18354
18375
|
async function runRulesCommand(argv, io = {
|
|
18355
18376
|
out,
|
|
@@ -18985,22 +19006,6 @@ function runHelpCommand(argv, io = {
|
|
|
18985
19006
|
function printUsage(print) {
|
|
18986
19007
|
print(renderRootHelp());
|
|
18987
19008
|
}
|
|
18988
|
-
/**
|
|
18989
|
-
* Friendly exit-20 path (plan M2): the crash says it's Mjölnir's bug,
|
|
18990
|
-
* not the user's repo, carries the underlying message for a report, and
|
|
18991
|
-
* prints the stack ONLY when `debug` is set (uniform across
|
|
18992
|
-
* subcommands — they don't parse scan flags). Tests pin
|
|
18993
|
-
* /internal error/i. Exported so the --debug stack arm is directly
|
|
18994
|
-
* spec-coverable (spawning a real crash under --debug would be flaky).
|
|
18995
|
-
*/
|
|
18996
|
-
function internalErrorMessage(err, emit, debug) {
|
|
18997
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
18998
|
-
emit("mjolnir internal error — this is a bug in Mjölnir, not your repo:");
|
|
18999
|
-
emit(` ${message}`);
|
|
19000
|
-
if (debug && err instanceof Error && err.stack) emit(err.stack);
|
|
19001
|
-
emit("Rerun with --debug for the stack trace. Please report this:");
|
|
19002
|
-
emit(" https://github.com/Sergey-Bar/Mjolnir/issues");
|
|
19003
|
-
}
|
|
19004
19009
|
function isEntryPoint() {
|
|
19005
19010
|
const argv1 = process$1.argv[1];
|
|
19006
19011
|
if (!argv1) return false;
|
package/package.json
CHANGED