mjolnir-qa 0.5.3 → 0.5.4
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 +65 -0
- package/dist/cli.d.mts +53 -1
- package/dist/cli.mjs +952 -33
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,71 @@ 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.4] — 2026-09-06
|
|
13
|
+
|
|
14
|
+
### Agent Handoff + Minimized Reporting (plan 1788599400000)
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **`mjolnir why <file>:<line>`** — occurrence-level evidence query
|
|
19
|
+
(informational, NOT a gate): exact file+line match, severity icon,
|
|
20
|
+
message/why/fix, evidence level, trust level, measured FP rate
|
|
21
|
+
(or the honest "ships on assumption"), runtime corroboration when
|
|
22
|
+
present, and the suppression contract (reason required, 90-day
|
|
23
|
+
expiry). Saved-report mode (`--json <mjolnir.json>`) is
|
|
24
|
+
authoritative; live scan runs otherwise. Exit 0 match / 1 no match.
|
|
25
|
+
- **`mjolnir handoff [mjolnir.json]`** — the deterministic fix-handoff
|
|
26
|
+
artifact: per-rule remediation sections (what is wrong / why
|
|
27
|
+
Mjölnir believes it / evidence boundary by level / occurrences
|
|
28
|
+
capped at 25 / fix / constraints / occurrences list), a per-rule
|
|
29
|
+
fenced copy block and a one-shot handoff prompt, and the formal
|
|
30
|
+
verification contract (TARGET_RESOLVED / TARGET_REMAINS /
|
|
31
|
+
NEW_FINDINGS_INTRODUCED / VERIFICATION_NOT_RUN, correlated by the
|
|
32
|
+
fingerprint ruleId+file+message; the standing caveat that a clean
|
|
33
|
+
`--scope changed` run verifies the changed surface only). Generated
|
|
34
|
+
solely from Mjölnir's own rule metadata — offline, deterministic,
|
|
35
|
+
escapeMarkdown'd. Zero findings → exit 0, non-actionable clean
|
|
36
|
+
artifact with no prompt. `--category`/`--rules` are presentation
|
|
37
|
+
filters.
|
|
38
|
+
- **`mjolnir install`** — installs the agent instruction surfaces
|
|
39
|
+
(`.claude/commands/mjolnir.md`, `.kilo/command/mjolnir.md`,
|
|
40
|
+
`.cursor/rules/mjolnir.mdc`, marker-appended `AGENTS.md`): the
|
|
41
|
+
version-pinned trust loop brief (scan `--scope changed` before
|
|
42
|
+
finishing, never suppress to green, report files changed and checks
|
|
43
|
+
not run). `--staged-hook` adds a NON-BLOCKING pre-commit hook
|
|
44
|
+
(`mjolnir --staged --blocking warning`, reusing `.husky`/
|
|
45
|
+
`core.hooksPath` when present). Marker-based idempotency;
|
|
46
|
+
`--dry-run` writes nothing; refusal (exit 10) before overwriting
|
|
47
|
+
any non-Mjölnir file; `--force` overwrites only Mjölnir-marked
|
|
48
|
+
files; never @latest.
|
|
49
|
+
- **`--score`** — prints only the numeric score (`unknown` when no
|
|
50
|
+
tests exist — never a fake 0); pure rendering flag, exit code
|
|
51
|
+
unchanged; stderr note when --json was also requested.
|
|
52
|
+
- **`--category <cat>`** (repeatable) — presentation filter on the
|
|
53
|
+
terminal findings display (and handoff/why): NEVER filters the
|
|
54
|
+
scan, the JSON/SARIF output, or the score; the terminal prints
|
|
55
|
+
`filtered view: N of M findings shown; score reflects the full
|
|
56
|
+
scan`. Unknown categories are a usage error (exit 10).
|
|
57
|
+
- **`--staged`** — scan-surface restriction: intersects discovered
|
|
58
|
+
test files with the git staged list; score reflects the staged
|
|
59
|
+
surface and is labeled as such (`staged surface: N file(s)`); not a
|
|
60
|
+
git repo → honest degraded fallback; empty staged set → exit 0.
|
|
61
|
+
- **`--blocking error|warning|none`** — exit-status override only:
|
|
62
|
+
maps onto the existing gate model (none→advisory, error→errors
|
|
63
|
+
block, warning→errors+warnings block). Detection and rendering are
|
|
64
|
+
identical under all three values; E0 findings never block; partial
|
|
65
|
+
scans stay exit 2.
|
|
66
|
+
- **`fixGroupId`** (additive JSON field): the stable semantic identity
|
|
67
|
+
of a remediation group — intentionally distinct from `ruleId`
|
|
68
|
+
(which identifies the detector). Current strategy: one rule = one
|
|
69
|
+
group, so fixGroupId equals ruleId today; consumers must not rely
|
|
70
|
+
on that permanently.
|
|
71
|
+
|
|
72
|
+
### Changed
|
|
73
|
+
|
|
74
|
+
- help registry gained `why`, `handoff`, `install` and the new flags;
|
|
75
|
+
site/reference/cli.md documents the handoff trust model.
|
|
76
|
+
|
|
12
77
|
## [0.5.3] — 2026-09-05
|
|
13
78
|
|
|
14
79
|
### Terminal + CI UX Overhaul (plan 1788579907109)
|
package/dist/cli.d.mts
CHANGED
|
@@ -128,6 +128,21 @@ interface Finding {
|
|
|
128
128
|
/** How to fix — concrete action. */
|
|
129
129
|
fix: string;
|
|
130
130
|
docsUrl?: string;
|
|
131
|
+
/**
|
|
132
|
+
* Stable semantic identity of a remediation group: findings that can
|
|
133
|
+
* be reasoned about and potentially remediated as one root-cause unit
|
|
134
|
+
* (agent-handoff plan §5.1). This is intentionally a DIFFERENT
|
|
135
|
+
* concept from `ruleId` (which identifies the detector/rule).
|
|
136
|
+
*
|
|
137
|
+
* Current implementation strategy: fixGroupId = ruleId, because each
|
|
138
|
+
* rule currently represents one remediation group. Future rules may
|
|
139
|
+
* emit multiple findings belonging to one fix group, or multiple
|
|
140
|
+
* remediation groups. Consumers MUST NOT rely on
|
|
141
|
+
* fixGroupId === ruleId permanently.
|
|
142
|
+
*
|
|
143
|
+
* Additive within schemaVersion 1.
|
|
144
|
+
*/
|
|
145
|
+
fixGroupId?: string;
|
|
131
146
|
}
|
|
132
147
|
interface DimensionScore {
|
|
133
148
|
category: RuleCategory;
|
|
@@ -147,6 +162,16 @@ interface ScanResult {
|
|
|
147
162
|
/** Present when --scope changed was requested. */
|
|
148
163
|
scope?: "all" | "changed";
|
|
149
164
|
scopeDegraded?: string;
|
|
165
|
+
/**
|
|
166
|
+
* Present when --staged was requested (agent-handoff plan §5.7):
|
|
167
|
+
* the scan surface was restricted to git staged files, and `files`
|
|
168
|
+
* is how many survived the intersection. The score reflects THAT
|
|
169
|
+
* surface — never present it as a full-repo score. Additive within
|
|
170
|
+
* schemaVersion 1.
|
|
171
|
+
*/
|
|
172
|
+
staged?: {
|
|
173
|
+
files: number;
|
|
174
|
+
};
|
|
150
175
|
/** Detected test frameworks (0.2). Empty + unknown=true when undetectable. */
|
|
151
176
|
frameworks: string[];
|
|
152
177
|
frameworkDetectionUnknown: boolean;
|
|
@@ -491,7 +516,7 @@ interface UniversalRule {
|
|
|
491
516
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
492
517
|
* `tests/version-consistency.spec.ts` locally.
|
|
493
518
|
*/
|
|
494
|
-
declare const CLI_VERSION = "0.5.
|
|
519
|
+
declare const CLI_VERSION = "0.5.4";
|
|
495
520
|
declare function buildUniversalRules(root: string, strict?: boolean): Promise<{
|
|
496
521
|
rules: UniversalRule[];
|
|
497
522
|
pluginErrors: string[];
|
|
@@ -536,6 +561,33 @@ interface CliArgs {
|
|
|
536
561
|
* and auto-disabled in CI/machine formats; this flag is the manual off.
|
|
537
562
|
*/
|
|
538
563
|
noProgress?: boolean;
|
|
564
|
+
/**
|
|
565
|
+
* --category <cat> (repeatable): presentation filter — narrows the
|
|
566
|
+
* TERMINAL findings display (and handoff/why output) to the given
|
|
567
|
+
* rule categories. NEVER filters the scan, the JSON/SARIF output,
|
|
568
|
+
* or the score (agent-handoff plan §5.5).
|
|
569
|
+
*/
|
|
570
|
+
categories?: RuleCategory[];
|
|
571
|
+
/**
|
|
572
|
+
* --score: print only the numeric score (or `unknown` when the repo
|
|
573
|
+
* has no tests) instead of the full report. Pure rendering flag:
|
|
574
|
+
* scan semantics and exit codes are unchanged (plan §5.6).
|
|
575
|
+
*/
|
|
576
|
+
scoreOnly?: boolean;
|
|
577
|
+
/**
|
|
578
|
+
* --staged: scan-surface restriction (plan §5.7) — intersect the
|
|
579
|
+
* discovered test files with the git staged file list. Does NOT
|
|
580
|
+
* change rule semantics; score implications come only from the
|
|
581
|
+
* narrowed surface (labeled as such in the report).
|
|
582
|
+
*/
|
|
583
|
+
staged?: boolean;
|
|
584
|
+
/**
|
|
585
|
+
* --blocking error|warning|none (plan §5.8): invocation-level gate
|
|
586
|
+
* override. Controls PROCESS EXIT BEHAVIOR ONLY via the existing
|
|
587
|
+
* exitForFindings mechanism — detection, rendering, JSON and score
|
|
588
|
+
* are identical under all three values. Overrides config.gate.
|
|
589
|
+
*/
|
|
590
|
+
blocking?: "error" | "warning" | "none";
|
|
539
591
|
}
|
|
540
592
|
/** A usage-error detail: the offending token, when one exists. */
|
|
541
593
|
interface UsageErrorDetail {
|
package/dist/cli.mjs
CHANGED
|
@@ -25,6 +25,15 @@ const QA_IMPACT_LABELS = {
|
|
|
25
25
|
"FALSE-GREEN": "False-green risk",
|
|
26
26
|
HYGIENE: "Test hygiene debt"
|
|
27
27
|
};
|
|
28
|
+
/** The closed set of rule categories (plan §5.5). `--category` values
|
|
29
|
+
* are validated against this list — unknown categories are a usage
|
|
30
|
+
* error, not a silent no-op. */
|
|
31
|
+
const RULE_CATEGORIES = [
|
|
32
|
+
"QA-TEST",
|
|
33
|
+
"QA-TQUAL",
|
|
34
|
+
"QA-PW",
|
|
35
|
+
"QA-CI"
|
|
36
|
+
];
|
|
28
37
|
/**
|
|
29
38
|
* Honest default evidence level for a finding (Honesty Core Phase 1).
|
|
30
39
|
* Derivation is deterministic and conservative:
|
|
@@ -9501,10 +9510,24 @@ function renderTerminal(result, opts) {
|
|
|
9501
9510
|
score: result.score
|
|
9502
9511
|
}, p, width, ascii);
|
|
9503
9512
|
appendFrameworks(lines, result, ui);
|
|
9513
|
+
if (result.staged !== void 0) {
|
|
9514
|
+
lines.push(ui.p.dim(` staged surface: ${result.staged.files} file(s) scanned; score reflects that surface`));
|
|
9515
|
+
lines.push("");
|
|
9516
|
+
}
|
|
9504
9517
|
appendDimensions(lines, result, ui);
|
|
9505
9518
|
appendDeductions(lines, result, counts, ui);
|
|
9506
|
-
|
|
9507
|
-
|
|
9519
|
+
const filtered = opts.visibleFindings;
|
|
9520
|
+
const filtering = filtered !== void 0 && filtered.length < result.findings.length;
|
|
9521
|
+
const display = filtering ? {
|
|
9522
|
+
...result,
|
|
9523
|
+
findings: filtered
|
|
9524
|
+
} : result;
|
|
9525
|
+
appendFixThisFirst(lines, display, ui);
|
|
9526
|
+
if (filtering) {
|
|
9527
|
+
lines.push(ui.p.dim(` filtered view: ${filtered?.length} of ${result.findings.length} findings shown; score reflects the full scan`));
|
|
9528
|
+
lines.push("");
|
|
9529
|
+
}
|
|
9530
|
+
appendFindings(lines, display, counts, opts.verbose === true, ui, opts.tone);
|
|
9508
9531
|
if (counts.total === 0 && result.score === 100) appendForgedBlock(lines, p, ascii);
|
|
9509
9532
|
appendFooter(lines, result, ui);
|
|
9510
9533
|
return lines.join("\n");
|
|
@@ -9710,6 +9733,11 @@ function pushCard(lines, card, ui) {
|
|
|
9710
9733
|
function appendFindings(lines, result, counts, verbose, ui, tone) {
|
|
9711
9734
|
const { p } = ui;
|
|
9712
9735
|
if (counts.total === 0) return;
|
|
9736
|
+
if (result.findings.length === 0) {
|
|
9737
|
+
lines.push(ui.p.dim(" filtered view: no findings in the selected category"));
|
|
9738
|
+
lines.push("");
|
|
9739
|
+
return;
|
|
9740
|
+
}
|
|
9713
9741
|
const byRule = /* @__PURE__ */ new Map();
|
|
9714
9742
|
for (const f of result.findings) {
|
|
9715
9743
|
const list = byRule.get(f.ruleId) ?? [];
|
|
@@ -9887,7 +9915,7 @@ function renderSarif(result, repoRootUri) {
|
|
|
9887
9915
|
tool: { driver: {
|
|
9888
9916
|
name: "Mjölnir",
|
|
9889
9917
|
informationUri: "https://github.com/Sergey-Bar/Mjolnir",
|
|
9890
|
-
version: "0.5.
|
|
9918
|
+
version: "0.5.4",
|
|
9891
9919
|
rules: [...rules.values()].map((r) => {
|
|
9892
9920
|
const meta = RULES.find((x) => x.id === r.id);
|
|
9893
9921
|
return {
|
|
@@ -10308,6 +10336,52 @@ function renderPrComment(result, options = {}) {
|
|
|
10308
10336
|
return lines.join("\n");
|
|
10309
10337
|
}
|
|
10310
10338
|
//#endregion
|
|
10339
|
+
//#region src/commands/report-io.ts
|
|
10340
|
+
/**
|
|
10341
|
+
* Shared saved-report loading (agent-handoff plan §9.0).
|
|
10342
|
+
*
|
|
10343
|
+
* `summary`, `handoff` and `why` all consume a saved `--json` report.
|
|
10344
|
+
* One loader, one validation, one error shape — previously
|
|
10345
|
+
* validateReportJson was private to summary.ts; extracting it keeps
|
|
10346
|
+
* the three commands byte-identical in their loading behavior without
|
|
10347
|
+
* duplication. The exit-code mapping stays in the callers (they own
|
|
10348
|
+
* their io), but the error MESSAGES are identical because they come
|
|
10349
|
+
* from here.
|
|
10350
|
+
*/
|
|
10351
|
+
/** Human message for any thrown value — never "undefined"/"[object Object]". */
|
|
10352
|
+
function errorText(err) {
|
|
10353
|
+
if (err instanceof Error) return err.message;
|
|
10354
|
+
if (typeof err === "string") return err;
|
|
10355
|
+
if (typeof err === "object" && err !== null) return JSON.stringify(err);
|
|
10356
|
+
return String(err);
|
|
10357
|
+
}
|
|
10358
|
+
/**
|
|
10359
|
+
* Parse-and-validate a saved report. Throws Error with a
|
|
10360
|
+
* human-explanatory message on: invalid JSON, non-object document,
|
|
10361
|
+
* wrong schemaVersion, missing findings array.
|
|
10362
|
+
*/
|
|
10363
|
+
function validateReportJson(text) {
|
|
10364
|
+
let parsed;
|
|
10365
|
+
try {
|
|
10366
|
+
parsed = JSON.parse(text);
|
|
10367
|
+
} catch (err) {
|
|
10368
|
+
throw new Error(`not valid JSON (${errorText(err)})`, { cause: err });
|
|
10369
|
+
}
|
|
10370
|
+
if (typeof parsed !== "object" || parsed === null) throw new Error("the file is a JSON value but not an object");
|
|
10371
|
+
const doc = parsed;
|
|
10372
|
+
if (doc.schemaVersion !== 1) throw new Error(`unsupported schemaVersion ${JSON.stringify(doc.schemaVersion)} — expected 1`);
|
|
10373
|
+
if (!Array.isArray(doc.findings)) throw new Error("missing a \"findings\" array — is this a Mjölnir --json report?");
|
|
10374
|
+
return parsed;
|
|
10375
|
+
}
|
|
10376
|
+
/** Load + validate a saved report from disk. Throws on any problem. */
|
|
10377
|
+
function loadSavedReport(reportPath) {
|
|
10378
|
+
return validateReportJson(readFileSync(reportPath, "utf8"));
|
|
10379
|
+
}
|
|
10380
|
+
/** True when the report file exists (callers own the not-found message). */
|
|
10381
|
+
function reportExists(reportPath) {
|
|
10382
|
+
return existsSync(reportPath);
|
|
10383
|
+
}
|
|
10384
|
+
//#endregion
|
|
10311
10385
|
//#region src/commands/summary.ts
|
|
10312
10386
|
/**
|
|
10313
10387
|
* `mjolnir summary [report.json]` — CI annotations + step summary
|
|
@@ -10332,29 +10406,7 @@ function renderPrComment(result, options = {}) {
|
|
|
10332
10406
|
* `--path-prefix <dir>` re-scopes for subdirectory scans.
|
|
10333
10407
|
*/
|
|
10334
10408
|
const DETAILS_PER_SEVERITY_CAP = 25;
|
|
10335
|
-
|
|
10336
|
-
function errorText(err) {
|
|
10337
|
-
if (err instanceof Error) return err.message;
|
|
10338
|
-
if (typeof err === "string") return err;
|
|
10339
|
-
if (typeof err === "object" && err !== null) return JSON.stringify(err);
|
|
10340
|
-
return String(err);
|
|
10341
|
-
}
|
|
10342
|
-
/** Parse-and-validate a saved report. Module-private: the command is
|
|
10343
|
-
* the only consumer; tests exercise it through runSummaryCommand. */
|
|
10344
|
-
function validateReportJson(text) {
|
|
10345
|
-
let parsed;
|
|
10346
|
-
try {
|
|
10347
|
-
parsed = JSON.parse(text);
|
|
10348
|
-
} catch (err) {
|
|
10349
|
-
throw new Error(`not valid JSON (${errorText(err)})`, { cause: err });
|
|
10350
|
-
}
|
|
10351
|
-
if (typeof parsed !== "object" || parsed === null) throw new Error("the file is a JSON value but not an object");
|
|
10352
|
-
const doc = parsed;
|
|
10353
|
-
if (doc.schemaVersion !== 1) throw new Error(`unsupported schemaVersion ${JSON.stringify(doc.schemaVersion)} — expected 1`);
|
|
10354
|
-
if (!Array.isArray(doc.findings)) throw new Error("missing a \"findings\" array — is this a Mjölnir --json report?");
|
|
10355
|
-
return parsed;
|
|
10356
|
-
}
|
|
10357
|
-
function scoreBar(score, width = 20) {
|
|
10409
|
+
function scoreBar$1(score, width = 20) {
|
|
10358
10410
|
const filled = Math.round(score / 100 * width);
|
|
10359
10411
|
return `${"█".repeat(filled)}${"░".repeat(Math.max(0, width - filled))}`;
|
|
10360
10412
|
}
|
|
@@ -10373,7 +10425,7 @@ function renderStepSummary(result, options = {}) {
|
|
|
10373
10425
|
lines.push(`Score: **${result.score}/100** · ${verdict} (${state.band}) · ${headlineFor(state, result.findings.length)}`);
|
|
10374
10426
|
lines.push("");
|
|
10375
10427
|
lines.push("```text");
|
|
10376
|
-
lines.push(`${scoreBar(result.score)} ${result.score}/100`);
|
|
10428
|
+
lines.push(`${scoreBar$1(result.score)} ${result.score}/100`);
|
|
10377
10429
|
lines.push("```");
|
|
10378
10430
|
}
|
|
10379
10431
|
lines.push("");
|
|
@@ -10488,7 +10540,7 @@ function runSummaryCommand(argv, io = {
|
|
|
10488
10540
|
}
|
|
10489
10541
|
let result;
|
|
10490
10542
|
try {
|
|
10491
|
-
result =
|
|
10543
|
+
result = loadSavedReport(reportPath);
|
|
10492
10544
|
} catch (err) {
|
|
10493
10545
|
io.err(`mjolnir summary: cannot read ${reportPath}: ${errorText(err)}`);
|
|
10494
10546
|
return 2;
|
|
@@ -10516,6 +10568,766 @@ function runSummaryCommand(argv, io = {
|
|
|
10516
10568
|
return 0;
|
|
10517
10569
|
}
|
|
10518
10570
|
//#endregion
|
|
10571
|
+
//#region src/commands/why.ts
|
|
10572
|
+
/**
|
|
10573
|
+
* `mjolnir why <file>:<line>` — occurrence-level evidence/explanation
|
|
10574
|
+
* query (agent-handoff plan M2).
|
|
10575
|
+
*
|
|
10576
|
+
* Role: an INFORMATIONAL query, not a gate. It works regardless of
|
|
10577
|
+
* verdict or tier, shows evidence tags, measured FP rates, runtime
|
|
10578
|
+
* corroboration, why/fix, and suppression guidance. Matching is exact
|
|
10579
|
+
* file + exact line — the occurrence location as reported, not a
|
|
10580
|
+
* durable identity (the fingerprint contract, baseline.ts, governs
|
|
10581
|
+
* before/after correlation instead).
|
|
10582
|
+
*
|
|
10583
|
+
* Two modes:
|
|
10584
|
+
* - `--json <mjolnir.json>`: the saved report is AUTHORITATIVE — the
|
|
10585
|
+
* query runs against exactly what the saved scan found (deterministic,
|
|
10586
|
+
* offline).
|
|
10587
|
+
* - live (default): runs a fresh scan of the target.
|
|
10588
|
+
*
|
|
10589
|
+
* Exit codes: 0 match · 1 no finding at that location · 10 usage ·
|
|
10590
|
+
* 2 invalid saved report · 20 crash.
|
|
10591
|
+
*/
|
|
10592
|
+
/** Exact file + exact line match over a finding list. Pure. */
|
|
10593
|
+
function explainAt(findings, file, line) {
|
|
10594
|
+
const normalized = file.replace(/\\/g, "/");
|
|
10595
|
+
return {
|
|
10596
|
+
findings: findings.filter((f) => f.file === normalized && f.line === line),
|
|
10597
|
+
file: normalized,
|
|
10598
|
+
line
|
|
10599
|
+
};
|
|
10600
|
+
}
|
|
10601
|
+
/** Parse the `<file>:<line>` positional (split at the LAST colon). */
|
|
10602
|
+
function parseFileLine(token) {
|
|
10603
|
+
const idx = token.lastIndexOf(":");
|
|
10604
|
+
if (idx === -1) return null;
|
|
10605
|
+
const file = token.slice(0, idx);
|
|
10606
|
+
const line = Number(token.slice(idx + 1));
|
|
10607
|
+
if (!file || !Number.isInteger(line) || line < 1) return null;
|
|
10608
|
+
return {
|
|
10609
|
+
file,
|
|
10610
|
+
line
|
|
10611
|
+
};
|
|
10612
|
+
}
|
|
10613
|
+
function evidenceLines(f, ui) {
|
|
10614
|
+
const lines = [];
|
|
10615
|
+
if (f.evidenceLevel !== void 0 || f.trustLevel !== void 0) {
|
|
10616
|
+
const parts = [];
|
|
10617
|
+
if (f.evidenceLevel !== void 0) parts.push(`evidence ${f.evidenceLevel}`);
|
|
10618
|
+
if (f.trustLevel !== void 0) parts.push(`trust ${f.trustLevel}`);
|
|
10619
|
+
lines.push(` Evidence: ${parts.join(" · ")}`);
|
|
10620
|
+
}
|
|
10621
|
+
if (f.measuredFpRate !== void 0) {
|
|
10622
|
+
const pct = Math.round(f.measuredFpRate * 100);
|
|
10623
|
+
const n = f.measuredFpN !== void 0 ? ` over ${f.measuredFpN} classified verdicts` : "";
|
|
10624
|
+
lines.push(` Measured FP rate: ${pct}%${n}`);
|
|
10625
|
+
} else lines.push(" Measured FP rate: none — this rule ships on assumption.");
|
|
10626
|
+
if (f.runtimeCorroboration !== void 0) {
|
|
10627
|
+
const c = f.runtimeCorroboration;
|
|
10628
|
+
const label = c.level === "defect" ? "defect corroborated by the run report" : c.level === "test" ? "the containing test executed in the run report" : "the containing file executed in the run report";
|
|
10629
|
+
lines.push(` Runtime corroboration: ${label} (${c.source})`);
|
|
10630
|
+
}
|
|
10631
|
+
return lines;
|
|
10632
|
+
}
|
|
10633
|
+
const SUPPRESSION_HINT = "Suppression (only with cause): an `ignore` entry in mjolnir.config.json — reason REQUIRED, expires after 90 days. Prefer fixing the root cause.";
|
|
10634
|
+
/** Render the why answer. Pure over (match, ui). */
|
|
10635
|
+
function renderWhy(match, ui = plainContext()) {
|
|
10636
|
+
const { p } = ui;
|
|
10637
|
+
if (match.findings.length === 0) return [
|
|
10638
|
+
sectionHeader(`WHY — ${match.file}:${match.line}`, ui),
|
|
10639
|
+
"",
|
|
10640
|
+
` No finding at ${match.file}:${match.line} in this report.`,
|
|
10641
|
+
"",
|
|
10642
|
+
p.dim(" Locations are exact (file + line as reported). If the code"),
|
|
10643
|
+
p.dim(" moved since the scan, re-run mjolnir to refresh locations."),
|
|
10644
|
+
""
|
|
10645
|
+
].join("\n");
|
|
10646
|
+
const lines = [
|
|
10647
|
+
sectionHeader(`WHY — ${match.file}:${match.line}`, ui),
|
|
10648
|
+
"",
|
|
10649
|
+
`${match.findings.length} finding${match.findings.length === 1 ? "" : "s"} at this location:`,
|
|
10650
|
+
""
|
|
10651
|
+
];
|
|
10652
|
+
for (const f of match.findings) {
|
|
10653
|
+
lines.push(` ${severityIcon(f.severity, ui)} ${p.bold(escapeMarkdown(f.ruleId))} — ${escapeMarkdown(f.message)}`);
|
|
10654
|
+
lines.push(` Why it matters: ${escapeMarkdown(f.why)}`);
|
|
10655
|
+
lines.push(` Fix: ${escapeMarkdown(f.fix)}`);
|
|
10656
|
+
for (const line of evidenceLines(f, ui)) lines.push(line);
|
|
10657
|
+
lines.push(SUPPRESSION_HINT);
|
|
10658
|
+
lines.push("");
|
|
10659
|
+
}
|
|
10660
|
+
lines.push(nextStep("mjolnir explain <RULE-ID>", ui) + " — full rule context.");
|
|
10661
|
+
return lines.join("\n");
|
|
10662
|
+
}
|
|
10663
|
+
/**
|
|
10664
|
+
* Testable why command core. Returns the process exit code.
|
|
10665
|
+
* Live mode awaits the real scan; saved-report mode is synchronous.
|
|
10666
|
+
*/
|
|
10667
|
+
async function runWhyCommand(argv, io = {
|
|
10668
|
+
out: (line) => console.log(line),
|
|
10669
|
+
err: (line) => console.error(line)
|
|
10670
|
+
}) {
|
|
10671
|
+
const locationToken = argv.find((a) => !a.startsWith("-"));
|
|
10672
|
+
if (!locationToken) {
|
|
10673
|
+
io.err("Usage: mjolnir why <file>:<line> [--json <mjolnir.json>]");
|
|
10674
|
+
return 10;
|
|
10675
|
+
}
|
|
10676
|
+
const location = parseFileLine(locationToken);
|
|
10677
|
+
if (!location) {
|
|
10678
|
+
io.err(`mjolnir why: cannot parse location "${locationToken}" — expected <file>:<line>`);
|
|
10679
|
+
return 10;
|
|
10680
|
+
}
|
|
10681
|
+
const jsonIdx = argv.indexOf("--json");
|
|
10682
|
+
const reportPath = jsonIdx !== -1 ? argv[jsonIdx + 1] : void 0;
|
|
10683
|
+
const targetIdx = argv.findIndex((a, i) => !a.startsWith("-") && i !== 0 && (jsonIdx === -1 || i !== jsonIdx + 1));
|
|
10684
|
+
const target = targetIdx !== -1 ? argv[targetIdx] : ".";
|
|
10685
|
+
let result;
|
|
10686
|
+
if (reportPath !== void 0) {
|
|
10687
|
+
if (!reportExists(reportPath)) {
|
|
10688
|
+
io.err(`mjolnir why: report file not found: ${reportPath}`);
|
|
10689
|
+
io.err(" Run the scan with --json first: mjolnir --json > mjolnir.json");
|
|
10690
|
+
return 10;
|
|
10691
|
+
}
|
|
10692
|
+
try {
|
|
10693
|
+
result = loadSavedReport(reportPath);
|
|
10694
|
+
} catch (err) {
|
|
10695
|
+
io.err(`mjolnir why: cannot read ${reportPath}: ${errorText(err)}`);
|
|
10696
|
+
return 2;
|
|
10697
|
+
}
|
|
10698
|
+
} else {
|
|
10699
|
+
if (!existsSync(target)) {
|
|
10700
|
+
io.err(`mjolnir why: scan target does not exist: ${target}`);
|
|
10701
|
+
return 10;
|
|
10702
|
+
}
|
|
10703
|
+
try {
|
|
10704
|
+
result = await runScan({
|
|
10705
|
+
target,
|
|
10706
|
+
json: false,
|
|
10707
|
+
verbose: false,
|
|
10708
|
+
maxDurationMs: Number.POSITIVE_INFINITY,
|
|
10709
|
+
scopeChanged: false,
|
|
10710
|
+
format: "terminal",
|
|
10711
|
+
strict: argv.includes("--strict")
|
|
10712
|
+
});
|
|
10713
|
+
} catch (err) {
|
|
10714
|
+
io.err(`mjolnir why: scan failed: ${errorText(err)}`);
|
|
10715
|
+
return 20;
|
|
10716
|
+
}
|
|
10717
|
+
}
|
|
10718
|
+
const match = explainAt(result.findings, location.file, location.line);
|
|
10719
|
+
const catIdxs = [];
|
|
10720
|
+
argv.forEach((a, i) => {
|
|
10721
|
+
if (a === "--category") catIdxs.push(i + 1);
|
|
10722
|
+
});
|
|
10723
|
+
const categories = catIdxs.map((i) => argv[i]).filter((c) => c !== void 0);
|
|
10724
|
+
const filtered = categories.length > 0 ? match.findings.filter((f) => categories.includes(f.category)) : match.findings;
|
|
10725
|
+
io.out(renderWhy({
|
|
10726
|
+
...match,
|
|
10727
|
+
findings: filtered
|
|
10728
|
+
}));
|
|
10729
|
+
return filtered.length > 0 ? 0 : 1;
|
|
10730
|
+
}
|
|
10731
|
+
//#endregion
|
|
10732
|
+
//#region src/commands/handoff.ts
|
|
10733
|
+
const OCCURRENCE_CAP = 25;
|
|
10734
|
+
const EVIDENCE_BOUNDARY = {
|
|
10735
|
+
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.",
|
|
10736
|
+
E1: "REQUIRES CONFIRMATION before editing. This is a heuristic finding — the detector's observation alone does not prove the defect. Establish the runtime/product context first, and treat the fix as a hypothesis to validate.",
|
|
10737
|
+
E0: "Observation only — informational by definition. This finding can never deduct points or gate CI. Decide whether it matters in context; do NOT 'fix' it blindly."
|
|
10738
|
+
};
|
|
10739
|
+
function evidenceBoundary(f) {
|
|
10740
|
+
const level = f.evidenceLevel ?? "E1";
|
|
10741
|
+
const base = EVIDENCE_BOUNDARY[level];
|
|
10742
|
+
if (f.runtimeCorroboration !== void 0) return `${base}\n${f.runtimeCorroboration.level === "defect" ? "Runtime corroboration: the run report directly corroborates this defect." : f.runtimeCorroboration.level === "test" ? "Runtime corroboration: the containing test executed in the run report." : "Runtime corroboration: the containing file executed in the run report."}`;
|
|
10743
|
+
return base;
|
|
10744
|
+
}
|
|
10745
|
+
function fpLine(f) {
|
|
10746
|
+
if (f.measuredFpRate === void 0) return "Measured FP rate: none — this rule ships on assumption (no measured false-positive rate).";
|
|
10747
|
+
return `Measured FP rate: ${Math.round(f.measuredFpRate * 100)}%${f.measuredFpN !== void 0 ? ` over ${f.measuredFpN} classified verdicts` : ""}.`;
|
|
10748
|
+
}
|
|
10749
|
+
function scoreBar(score, width = 20) {
|
|
10750
|
+
const filled = Math.round(score / 100 * width);
|
|
10751
|
+
return `${"█".repeat(filled)}${"░".repeat(Math.max(0, width - filled))}`;
|
|
10752
|
+
}
|
|
10753
|
+
function scopeNote(options) {
|
|
10754
|
+
const parts = [];
|
|
10755
|
+
if (options.categories && options.categories.length > 0) parts.push(`categories: ${options.categories.join(", ")}`);
|
|
10756
|
+
if (options.rules && options.rules.length > 0) parts.push(`rules: ${options.rules.join(", ")}`);
|
|
10757
|
+
return parts.length > 0 ? ` (filtered — ${parts.join("; ")})` : "";
|
|
10758
|
+
}
|
|
10759
|
+
/**
|
|
10760
|
+
* The verification procedure block — the Trust contract rendered into
|
|
10761
|
+
* every artifact (plan §5.3). `version` is pinned at render time.
|
|
10762
|
+
*/
|
|
10763
|
+
function verificationBlock(version) {
|
|
10764
|
+
return [
|
|
10765
|
+
"## Verification procedure",
|
|
10766
|
+
"",
|
|
10767
|
+
"1. Before editing (recommended): `mjolnir baseline` captures the pre-fix report.",
|
|
10768
|
+
"2. After the fixes: `npx mjolnir-qa@" + version + " . --scope changed` re-verifies the targeted surface.",
|
|
10769
|
+
"3. Correlate before/after by finding fingerprint (ruleId + file + message — line numbers are occurrence locations, not identity; edits may move lines).",
|
|
10770
|
+
"",
|
|
10771
|
+
"Outcomes:",
|
|
10772
|
+
"",
|
|
10773
|
+
"- **TARGET_RESOLVED** — every fingerprint in this document is absent from the post-fix scan.",
|
|
10774
|
+
"- **TARGET_REMAINS** — at least one target fingerprint is still present. Report it honestly; do not suppress to clear the report.",
|
|
10775
|
+
"- **NEW_FINDINGS_INTRODUCED** — the post-fix scan contains fingerprints absent from the pre-fix report. Report them; do not silently accept them.",
|
|
10776
|
+
"- **VERIFICATION_NOT_RUN** — no post-fix scan, or a partial scan. Never claim a fix is verified without it.",
|
|
10777
|
+
"",
|
|
10778
|
+
"> ⚠ A clean `--scope changed` run re-verifies the targeted changed-scope remediation. It is NOT a statement that the entire repository is clean — run a full scan for that.",
|
|
10779
|
+
"",
|
|
10780
|
+
"Report files changed. Report checks not run. Report unresolved findings honestly."
|
|
10781
|
+
];
|
|
10782
|
+
}
|
|
10783
|
+
/** The per-rule remediation copy block (fenced, self-contained). */
|
|
10784
|
+
function ruleCopyBlock(group, version) {
|
|
10785
|
+
const f = group.findings[0];
|
|
10786
|
+
const occurrences = group.findings.slice(0, OCCURRENCE_CAP).map((x) => `- \`${escapeMarkdown(x.file)}:${x.line}\``).join("\n");
|
|
10787
|
+
const overflow = group.findings.length > OCCURRENCE_CAP ? `\n- … and ${group.findings.length - OCCURRENCE_CAP} more — see the JSON report.` : "";
|
|
10788
|
+
return [
|
|
10789
|
+
"```text",
|
|
10790
|
+
`Remediation task: ${escapeMarkdown(group.ruleId)} (fix group: ${escapeMarkdown(group.fixGroupId ?? group.ruleId)})`,
|
|
10791
|
+
"",
|
|
10792
|
+
`What was detected: ${escapeMarkdown(f.message)}`,
|
|
10793
|
+
"",
|
|
10794
|
+
`Evidence boundary: ${evidenceBoundary(f).replace(/\n/g, " ")}`,
|
|
10795
|
+
escapeMarkdown(fpLine(f)),
|
|
10796
|
+
"",
|
|
10797
|
+
`What should change: ${escapeMarkdown(f.fix)}`,
|
|
10798
|
+
"",
|
|
10799
|
+
"Constraints:",
|
|
10800
|
+
"- Make the smallest behavior-preserving change that fixes the root cause.",
|
|
10801
|
+
"- Preserve public interfaces, failure semantics, and repository conventions.",
|
|
10802
|
+
"- Adapt identifiers and framework details instead of copying blindly.",
|
|
10803
|
+
"- Do NOT disable the rule or suppress matching code merely to obtain a green scan.",
|
|
10804
|
+
"",
|
|
10805
|
+
"Occurrences (validate each):",
|
|
10806
|
+
occurrences,
|
|
10807
|
+
overflow,
|
|
10808
|
+
"",
|
|
10809
|
+
"Verification:",
|
|
10810
|
+
` npx mjolnir-qa@${version} . --scope changed`,
|
|
10811
|
+
"",
|
|
10812
|
+
"Expected verification behavior: the fingerprint (ruleId + file + message) of every occurrence above disappears from the post-fix scan, and no NEW fingerprint appears.",
|
|
10813
|
+
"",
|
|
10814
|
+
"Report files changed. Report checks not run. Report unresolved findings honestly.",
|
|
10815
|
+
"```"
|
|
10816
|
+
].join("\n");
|
|
10817
|
+
}
|
|
10818
|
+
/** The full deterministic handoff artifact. Pure over (result, options). */
|
|
10819
|
+
function renderHandoff(result, options = {}, version = CLI_VERSION) {
|
|
10820
|
+
const lines = [];
|
|
10821
|
+
lines.push("### 🔨 Mjölnir — Fix Handoff");
|
|
10822
|
+
lines.push("");
|
|
10823
|
+
if (result.score !== null) {
|
|
10824
|
+
const state = deriveScoreState(result.score);
|
|
10825
|
+
const verdict = verdictFor(result.score);
|
|
10826
|
+
lines.push(`Score: **${result.score}/100** · ${verdict} (${state.band}) · ${headlineFor(state, result.findings.length)}`);
|
|
10827
|
+
lines.push("");
|
|
10828
|
+
lines.push("```text");
|
|
10829
|
+
lines.push(`${scoreBar(result.score)} ${result.score}/100`);
|
|
10830
|
+
lines.push("```");
|
|
10831
|
+
} else lines.push("Score: **not measurable** — no test files found (`reason: no-tests-found`).");
|
|
10832
|
+
lines.push("");
|
|
10833
|
+
if (result.partial) {
|
|
10834
|
+
lines.push("> ⚠ Partial scan: the budget expired or files were skipped — the finding list may be incomplete. Treat VERIFICATION claims accordingly.");
|
|
10835
|
+
lines.push("");
|
|
10836
|
+
}
|
|
10837
|
+
if (result.findings.length === 0) {
|
|
10838
|
+
lines.push("Zero findings — nothing to fix.");
|
|
10839
|
+
lines.push("");
|
|
10840
|
+
lines.push("No remediation prompt is included. Do not modify the repository on the basis of this document.");
|
|
10841
|
+
lines.push("");
|
|
10842
|
+
if (result.partial) {
|
|
10843
|
+
lines.push("> The scan was partial — the absence of findings is only as trustworthy as the scan's coverage.");
|
|
10844
|
+
lines.push("");
|
|
10845
|
+
}
|
|
10846
|
+
return lines.join("\n");
|
|
10847
|
+
}
|
|
10848
|
+
lines.push(`This document turns ${result.findings.length} finding${result.findings.length === 1 ? "" : "s"} into a remediation plan. Work top-down (errors before warnings); validate each occurrence according to its evidence level before editing.`);
|
|
10849
|
+
lines.push("");
|
|
10850
|
+
const groups = /* @__PURE__ */ new Map();
|
|
10851
|
+
for (const f of result.findings) {
|
|
10852
|
+
const gid = f.fixGroupId ?? f.ruleId;
|
|
10853
|
+
const g = groups.get(gid);
|
|
10854
|
+
if (g) g.findings.push(f);
|
|
10855
|
+
else groups.set(gid, {
|
|
10856
|
+
ruleId: f.ruleId,
|
|
10857
|
+
fixGroupId: gid,
|
|
10858
|
+
findings: [f]
|
|
10859
|
+
});
|
|
10860
|
+
}
|
|
10861
|
+
const ruleFilter = options.rules && options.rules.length > 0 ? new Set(options.rules) : void 0;
|
|
10862
|
+
const catFilter = options.categories && options.categories.length > 0 ? new Set(options.categories) : void 0;
|
|
10863
|
+
const selected = [...groups.values()].filter((g) => !ruleFilter || ruleFilter.has(g.ruleId)).map((g) => ({
|
|
10864
|
+
...g,
|
|
10865
|
+
findings: catFilter ? g.findings.filter((f) => catFilter.has(f.category)) : g.findings
|
|
10866
|
+
})).filter((g) => g.findings.length > 0);
|
|
10867
|
+
if (selected.length === 0) {
|
|
10868
|
+
lines.push("No findings match the requested filters — no remediation prompt is included.");
|
|
10869
|
+
lines.push("");
|
|
10870
|
+
return lines.join("\n");
|
|
10871
|
+
}
|
|
10872
|
+
const sevOrder = {
|
|
10873
|
+
error: 0,
|
|
10874
|
+
warning: 1,
|
|
10875
|
+
info: 2
|
|
10876
|
+
};
|
|
10877
|
+
const sortable = selected;
|
|
10878
|
+
sortable.sort((a, b) => {
|
|
10879
|
+
const sa = sevOrder[a.findings[0].severity];
|
|
10880
|
+
const sb = sevOrder[b.findings[0].severity];
|
|
10881
|
+
if (sa !== sb) return sa - sb;
|
|
10882
|
+
if (b.findings.length !== a.findings.length) return b.findings.length - a.findings.length;
|
|
10883
|
+
return a.ruleId < b.ruleId ? -1 : 1;
|
|
10884
|
+
});
|
|
10885
|
+
lines.push("## How to use this document");
|
|
10886
|
+
lines.push("");
|
|
10887
|
+
lines.push("- Validate each occurrence according to its evidence level before editing.", "- Apply the smallest behavior-preserving fix.", "- Re-run the verification procedure below; correlate by fingerprint.", "- Never suppress a finding merely to obtain a green scan (suppressions live in `mjolnir.config.json`, require a reason, and expire after 90 days).");
|
|
10888
|
+
lines.push("");
|
|
10889
|
+
lines.push(scopeNote(options) ? scopeNote(options).trim() : "");
|
|
10890
|
+
if (!scopeNote(options)) lines.pop();
|
|
10891
|
+
lines.push("");
|
|
10892
|
+
for (const g of sortable) {
|
|
10893
|
+
const first = g.findings[0];
|
|
10894
|
+
lines.push(`### ${escapeMarkdown(g.ruleId)} — ${first.severity} × ${g.findings.length} (fix group: ${escapeMarkdown(g.fixGroupId)})`);
|
|
10895
|
+
lines.push("");
|
|
10896
|
+
lines.push(`**What is wrong:** ${escapeMarkdown(first.message)}`);
|
|
10897
|
+
lines.push("");
|
|
10898
|
+
lines.push(`**Why Mjölnir believes it:** ${escapeMarkdown(first.why)}`);
|
|
10899
|
+
lines.push("");
|
|
10900
|
+
lines.push(`**How trustworthy (evidence boundary):**`);
|
|
10901
|
+
lines.push("");
|
|
10902
|
+
lines.push(evidenceBoundary(first));
|
|
10903
|
+
lines.push("");
|
|
10904
|
+
lines.push(escapeMarkdown(fpLine(first)));
|
|
10905
|
+
lines.push("");
|
|
10906
|
+
lines.push(`**What should change:** ${escapeMarkdown(first.fix)}`);
|
|
10907
|
+
lines.push("");
|
|
10908
|
+
lines.push("**What must NOT change:** behavior unrelated to this finding — public interfaces, failure semantics, accessibility, and repository conventions stay intact.");
|
|
10909
|
+
lines.push("");
|
|
10910
|
+
lines.push(`**Occurrences (${g.findings.length}):**`);
|
|
10911
|
+
lines.push("");
|
|
10912
|
+
for (const f of g.findings.slice(0, OCCURRENCE_CAP)) lines.push(`- \`${escapeMarkdown(f.file)}:${f.line}\` — ${escapeMarkdown(f.message)}`);
|
|
10913
|
+
if (g.findings.length > OCCURRENCE_CAP) lines.push(`- … and ${g.findings.length - OCCURRENCE_CAP} more — see the JSON report.`);
|
|
10914
|
+
lines.push("");
|
|
10915
|
+
lines.push(ruleCopyBlock(g, version));
|
|
10916
|
+
lines.push("");
|
|
10917
|
+
}
|
|
10918
|
+
lines.push("## One-shot handoff prompt");
|
|
10919
|
+
lines.push("");
|
|
10920
|
+
lines.push("```text");
|
|
10921
|
+
lines.push(`Work through the ${selected.length} remediation group(s) above IN ORDER. For each:`);
|
|
10922
|
+
lines.push("- Validate the occurrences according to that group's evidence boundary (E2 = deterministic, act after a location check; E1/E0 = confirm in context first, never assume the observation alone proves the defect).");
|
|
10923
|
+
lines.push("- Apply the smallest behavior-preserving fix from the group's instruction.");
|
|
10924
|
+
lines.push("- Do NOT suppress findings merely to make the scan green.");
|
|
10925
|
+
lines.push(`- Re-run: npx mjolnir-qa@${version} . --scope changed`);
|
|
10926
|
+
lines.push("- Correlate before/after by fingerprint (ruleId + file + message).");
|
|
10927
|
+
lines.push("- Report: files changed, checks not run, and any findings that remain (TARGET_REMAINS) or newly appeared (NEW_FINDINGS_INTRODUCED).");
|
|
10928
|
+
lines.push("- Stop and ask the user when an evidence boundary cannot be resolved.");
|
|
10929
|
+
lines.push("```");
|
|
10930
|
+
lines.push("");
|
|
10931
|
+
lines.push(...verificationBlock(version));
|
|
10932
|
+
lines.push("");
|
|
10933
|
+
lines.push(`_Generated by [Mjölnir](https://github.com/Sergey-Bar/Mjolnir) — evidence and verification; the agent remains responsible for every change._`);
|
|
10934
|
+
return lines.join("\n");
|
|
10935
|
+
}
|
|
10936
|
+
const KNOWN_FLAGS = /* @__PURE__ */ new Set([
|
|
10937
|
+
"--category",
|
|
10938
|
+
"--rules",
|
|
10939
|
+
"--help",
|
|
10940
|
+
"-h"
|
|
10941
|
+
]);
|
|
10942
|
+
/**
|
|
10943
|
+
* Testable handoff command core. Returns the process exit code.
|
|
10944
|
+
* `mjolnir handoff [mjolnir.json] [--category <cat>]... [--rules <ids>]`
|
|
10945
|
+
*/
|
|
10946
|
+
function runHandoffCommand(argv, io = {
|
|
10947
|
+
out: (line) => console.log(line),
|
|
10948
|
+
err: (line) => console.error(line)
|
|
10949
|
+
}) {
|
|
10950
|
+
for (let i = 0; i < argv.length; i++) {
|
|
10951
|
+
const a = argv[i];
|
|
10952
|
+
if (!a.startsWith("-")) continue;
|
|
10953
|
+
if (a === "--category" || a === "--rules") {
|
|
10954
|
+
const val = argv[i + 1];
|
|
10955
|
+
if (val === void 0 || val.startsWith("-")) {
|
|
10956
|
+
io.err(usageErrorMessage({
|
|
10957
|
+
flag: a,
|
|
10958
|
+
token: val
|
|
10959
|
+
}));
|
|
10960
|
+
return 10;
|
|
10961
|
+
}
|
|
10962
|
+
i++;
|
|
10963
|
+
continue;
|
|
10964
|
+
}
|
|
10965
|
+
if (KNOWN_FLAGS.has(a)) continue;
|
|
10966
|
+
io.err(usageErrorMessage({ token: a }));
|
|
10967
|
+
return 10;
|
|
10968
|
+
}
|
|
10969
|
+
const positional = [];
|
|
10970
|
+
const categories = [];
|
|
10971
|
+
let rules;
|
|
10972
|
+
for (let i = 0; i < argv.length; i++) {
|
|
10973
|
+
const a = argv[i];
|
|
10974
|
+
if (a === "--category") {
|
|
10975
|
+
categories.push(argv[i + 1]);
|
|
10976
|
+
i++;
|
|
10977
|
+
continue;
|
|
10978
|
+
}
|
|
10979
|
+
if (a === "--rules") {
|
|
10980
|
+
rules = argv[i + 1].split(",").map((r) => r.trim()).filter((r) => r.length > 0);
|
|
10981
|
+
i++;
|
|
10982
|
+
continue;
|
|
10983
|
+
}
|
|
10984
|
+
if (!a.startsWith("-")) positional.push(a);
|
|
10985
|
+
}
|
|
10986
|
+
const reportPath = positional[0] ?? "mjolnir.json";
|
|
10987
|
+
if (!reportExists(reportPath)) {
|
|
10988
|
+
io.err(`mjolnir handoff: report file not found: ${reportPath}`);
|
|
10989
|
+
io.err(" Run the scan with --json first: mjolnir --json > mjolnir.json");
|
|
10990
|
+
return 10;
|
|
10991
|
+
}
|
|
10992
|
+
let result;
|
|
10993
|
+
try {
|
|
10994
|
+
result = loadSavedReport(reportPath);
|
|
10995
|
+
} catch (err) {
|
|
10996
|
+
io.err(`mjolnir handoff: cannot read ${reportPath}: ${errorText(err)}`);
|
|
10997
|
+
return 2;
|
|
10998
|
+
}
|
|
10999
|
+
io.out(renderHandoff(result, {
|
|
11000
|
+
categories,
|
|
11001
|
+
...rules ? { rules } : {}
|
|
11002
|
+
}));
|
|
11003
|
+
return 0;
|
|
11004
|
+
}
|
|
11005
|
+
//#endregion
|
|
11006
|
+
//#region src/commands/install-agents.ts
|
|
11007
|
+
/**
|
|
11008
|
+
* `mjolnir install` — instruction-surface installer (agent-handoff
|
|
11009
|
+
* plan M4).
|
|
11010
|
+
*
|
|
11011
|
+
* Directory probes detect INSTRUCTION SURFACES, not agents: the probe
|
|
11012
|
+
* is a deterministic, offline existence check. All detected surfaces
|
|
11013
|
+
* are written; none take precedence over another.
|
|
11014
|
+
*
|
|
11015
|
+
* Safety contract (plan §17):
|
|
11016
|
+
* - Writes are planned by a pure `planInstall()` and executed by
|
|
11017
|
+
* `executeInstall()`; `--dry-run` never executes.
|
|
11018
|
+
* - A target file that exists WITHOUT a Mjölnir marker is user-owned →
|
|
11019
|
+
* refusal (exit 10). `--force` overwrites ONLY marker-identified
|
|
11020
|
+
* Mjölnir-owned files.
|
|
11021
|
+
* - Re-running rewrites Mjölnir-owned regions idempotently; identical
|
|
11022
|
+
* content → no write at all.
|
|
11023
|
+
* - Content is deterministic (no timestamps) and version-pinned to the
|
|
11024
|
+
* running CLI_VERSION — never @latest.
|
|
11025
|
+
*
|
|
11026
|
+
* Exit codes: 0 installed/updated/nothing-detected (honest message) ·
|
|
11027
|
+
* 10 refusal/usage · 20 crash.
|
|
11028
|
+
*/
|
|
11029
|
+
const MARKER_OPEN = "<!-- mjolnir:managed";
|
|
11030
|
+
const MARKER_CLOSE = "<!-- /mjolnir:managed -->";
|
|
11031
|
+
function agentBrief(surface, cwdNote) {
|
|
11032
|
+
return [
|
|
11033
|
+
`# Mjölnir — verification trust loop (${surface})`,
|
|
11034
|
+
"",
|
|
11035
|
+
`${MARKER_OPEN} v${CLI_VERSION} -->`,
|
|
11036
|
+
"",
|
|
11037
|
+
`Run \`${cwdNote}npx mjolnir-qa@${CLI_VERSION} . --scope changed\` before finishing any task that touched test code.`,
|
|
11038
|
+
"",
|
|
11039
|
+
"- Exit 0 = clean. Exit 1 = new findings on changed lines: fix them (smallest behavior-preserving change) or justify them to the user.",
|
|
11040
|
+
`- npx mjolnir-qa@${CLI_VERSION} why <file>:<line> explains any finding (evidence level, measured FP rate, fix).`,
|
|
11041
|
+
`- npx mjolnir-qa@${CLI_VERSION} handoff renders a full remediation plan from a saved --json report.`,
|
|
11042
|
+
"- NEVER suppress a finding merely to obtain a green scan (suppressions live in mjolnir.config.json, require a reason, and expire after 90 days).",
|
|
11043
|
+
"- After fixing: re-run the scan, report files changed, report checks not run, report unresolved findings honestly.",
|
|
11044
|
+
"",
|
|
11045
|
+
`<!-- /mjolnir:managed -->`,
|
|
11046
|
+
""
|
|
11047
|
+
].join("\n");
|
|
11048
|
+
}
|
|
11049
|
+
/** Enumerate every detected surface with its planned content. Pure. */
|
|
11050
|
+
function detectSurfaces(cwd) {
|
|
11051
|
+
const surfaces = [];
|
|
11052
|
+
if (existsSync(join(cwd, ".claude"))) surfaces.push({
|
|
11053
|
+
name: "Claude Code command surface",
|
|
11054
|
+
dir: join(cwd, ".claude", "commands"),
|
|
11055
|
+
file: join(cwd, ".claude", "commands", "mjolnir.md"),
|
|
11056
|
+
content: agentBrief("Claude Code command", "/"),
|
|
11057
|
+
mode: "whole-file"
|
|
11058
|
+
});
|
|
11059
|
+
if (existsSync(join(cwd, ".kilo"))) surfaces.push({
|
|
11060
|
+
name: "Kilo command surface",
|
|
11061
|
+
dir: join(cwd, ".kilo", "command"),
|
|
11062
|
+
file: join(cwd, ".kilo", "command", "mjolnir.md"),
|
|
11063
|
+
content: agentBrief("Kilo command", ""),
|
|
11064
|
+
mode: "whole-file"
|
|
11065
|
+
});
|
|
11066
|
+
if (existsSync(join(cwd, ".cursor"))) surfaces.push({
|
|
11067
|
+
name: "Cursor rule surface",
|
|
11068
|
+
dir: join(cwd, ".cursor", "rules"),
|
|
11069
|
+
file: join(cwd, ".cursor", "rules", "mjolnir.mdc"),
|
|
11070
|
+
content: agentBrief("Cursor rule", ""),
|
|
11071
|
+
mode: "whole-file"
|
|
11072
|
+
});
|
|
11073
|
+
if (existsSync(join(cwd, "AGENTS.md"))) surfaces.push({
|
|
11074
|
+
name: "AGENTS.md instruction surface",
|
|
11075
|
+
dir: cwd,
|
|
11076
|
+
file: join(cwd, "AGENTS.md"),
|
|
11077
|
+
content: agentBrief("AGENTS.md", ""),
|
|
11078
|
+
mode: "append-block"
|
|
11079
|
+
});
|
|
11080
|
+
return surfaces;
|
|
11081
|
+
}
|
|
11082
|
+
function hasMjolnirMarker(content) {
|
|
11083
|
+
return content.includes("<!-- mjolnir:managed") && content.includes("<!-- /mjolnir:managed -->");
|
|
11084
|
+
}
|
|
11085
|
+
function mergedBlock(existing, content) {
|
|
11086
|
+
const openIdx = existing.indexOf(MARKER_OPEN);
|
|
11087
|
+
const closeIdx = existing.indexOf(MARKER_CLOSE);
|
|
11088
|
+
if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) {
|
|
11089
|
+
const body = content.slice(content.indexOf(MARKER_OPEN), content.indexOf(MARKER_CLOSE) + 25);
|
|
11090
|
+
return existing.slice(0, openIdx) + body + existing.slice(closeIdx + 25);
|
|
11091
|
+
}
|
|
11092
|
+
return `${existing}${existing.endsWith("\n") ? "" : "\n"}\n${content}`;
|
|
11093
|
+
}
|
|
11094
|
+
/** Pure plan: what install WOULD do. Zero I/O. */
|
|
11095
|
+
function planInstall(cwd, options = {}) {
|
|
11096
|
+
const surfaces = detectSurfaces(cwd);
|
|
11097
|
+
const entries = [];
|
|
11098
|
+
for (const s of surfaces) {
|
|
11099
|
+
if (s.mode === "append-block") {
|
|
11100
|
+
const existing = readFileSync(s.file, "utf8");
|
|
11101
|
+
if (hasMjolnirMarker(existing)) {
|
|
11102
|
+
const merged = mergedBlock(existing, s.content);
|
|
11103
|
+
entries.push(merged === existing ? {
|
|
11104
|
+
action: "no-op",
|
|
11105
|
+
surface: s.name,
|
|
11106
|
+
file: s.file,
|
|
11107
|
+
content: existing
|
|
11108
|
+
} : {
|
|
11109
|
+
action: "update-in-place",
|
|
11110
|
+
surface: s.name,
|
|
11111
|
+
file: s.file,
|
|
11112
|
+
content: merged
|
|
11113
|
+
});
|
|
11114
|
+
continue;
|
|
11115
|
+
}
|
|
11116
|
+
entries.push({
|
|
11117
|
+
action: "create",
|
|
11118
|
+
surface: s.name,
|
|
11119
|
+
file: s.file,
|
|
11120
|
+
content: mergedBlock(existing, s.content)
|
|
11121
|
+
});
|
|
11122
|
+
continue;
|
|
11123
|
+
}
|
|
11124
|
+
if (existsSync(s.file)) {
|
|
11125
|
+
const existing = readFileSync(s.file, "utf8");
|
|
11126
|
+
if (existing === s.content) {
|
|
11127
|
+
entries.push({
|
|
11128
|
+
action: "no-op",
|
|
11129
|
+
surface: s.name,
|
|
11130
|
+
file: s.file,
|
|
11131
|
+
content: existing
|
|
11132
|
+
});
|
|
11133
|
+
continue;
|
|
11134
|
+
}
|
|
11135
|
+
if (!hasMjolnirMarker(existing)) {
|
|
11136
|
+
entries.push({
|
|
11137
|
+
action: "refuse",
|
|
11138
|
+
surface: s.name,
|
|
11139
|
+
file: s.file,
|
|
11140
|
+
reason: "existing file is not Mjölnir-managed (no marker) — pass --force ONLY after reviewing it"
|
|
11141
|
+
});
|
|
11142
|
+
continue;
|
|
11143
|
+
}
|
|
11144
|
+
if (options.force !== true) {
|
|
11145
|
+
entries.push({
|
|
11146
|
+
action: "refuse",
|
|
11147
|
+
surface: s.name,
|
|
11148
|
+
file: s.file,
|
|
11149
|
+
reason: "Mjölnir-managed file has local edits — pass --force to overwrite"
|
|
11150
|
+
});
|
|
11151
|
+
continue;
|
|
11152
|
+
}
|
|
11153
|
+
entries.push({
|
|
11154
|
+
action: "update-in-place",
|
|
11155
|
+
surface: s.name,
|
|
11156
|
+
file: s.file,
|
|
11157
|
+
content: s.content
|
|
11158
|
+
});
|
|
11159
|
+
continue;
|
|
11160
|
+
}
|
|
11161
|
+
entries.push({
|
|
11162
|
+
action: "create",
|
|
11163
|
+
surface: s.name,
|
|
11164
|
+
file: s.file,
|
|
11165
|
+
content: s.content
|
|
11166
|
+
});
|
|
11167
|
+
}
|
|
11168
|
+
return {
|
|
11169
|
+
entries,
|
|
11170
|
+
detected: surfaces.length
|
|
11171
|
+
};
|
|
11172
|
+
}
|
|
11173
|
+
/** Execute a plan. Returns the number of files written. */
|
|
11174
|
+
function executeInstall(entries) {
|
|
11175
|
+
let written = 0;
|
|
11176
|
+
for (const e of entries) {
|
|
11177
|
+
if (e.action === "refuse" || e.action === "no-op") continue;
|
|
11178
|
+
const dir = join(e.file, "..");
|
|
11179
|
+
mkdirSync(dir, { recursive: true });
|
|
11180
|
+
writeFileSync(e.file, e.content);
|
|
11181
|
+
written++;
|
|
11182
|
+
}
|
|
11183
|
+
return written;
|
|
11184
|
+
}
|
|
11185
|
+
/**
|
|
11186
|
+
* Testable install command core. Returns the process exit code.
|
|
11187
|
+
* `mjolnir install [--dry-run] [--force]` — probes the given cwd
|
|
11188
|
+
* (production default: process.cwd()).
|
|
11189
|
+
*/
|
|
11190
|
+
function runInstallCommand(argv, io = {
|
|
11191
|
+
out: (line) => console.log(line),
|
|
11192
|
+
err: (line) => console.error(line)
|
|
11193
|
+
}, cwd = process.cwd()) {
|
|
11194
|
+
const dryRun = argv.includes("--dry-run");
|
|
11195
|
+
const force = argv.includes("--force");
|
|
11196
|
+
const stagedHook = argv.includes("--staged-hook");
|
|
11197
|
+
for (const a of argv) {
|
|
11198
|
+
if (a === "--dry-run" || a === "--force" || a === "--staged-hook") continue;
|
|
11199
|
+
if (a === "--help" || a === "-h") continue;
|
|
11200
|
+
io.err(usageMessageFor(a));
|
|
11201
|
+
return 10;
|
|
11202
|
+
}
|
|
11203
|
+
const { entries, detected } = planInstall(cwd, { force });
|
|
11204
|
+
if (detected === 0 && !stagedHook) {
|
|
11205
|
+
io.out("No instruction surfaces detected — nothing to install. Surfaces probed: .claude/ (Claude Code), .kilo/ (Kilo), .cursor/ (Cursor), AGENTS.md.");
|
|
11206
|
+
return 0;
|
|
11207
|
+
}
|
|
11208
|
+
if (dryRun) {
|
|
11209
|
+
io.out("Install plan (dry run — nothing written):");
|
|
11210
|
+
for (const e of entries) if (e.action === "refuse") io.err(` REFUSE ${e.file}: ${e.reason}`);
|
|
11211
|
+
else io.out(` ${e.action} ${e.file}`);
|
|
11212
|
+
if (stagedHook) {
|
|
11213
|
+
const hook = planHookInstall(cwd);
|
|
11214
|
+
io.out(` ${hook.action} ${hook.file} (non-blocking pre-commit hook)`);
|
|
11215
|
+
}
|
|
11216
|
+
return 0;
|
|
11217
|
+
}
|
|
11218
|
+
let refused = false;
|
|
11219
|
+
for (const e of entries) {
|
|
11220
|
+
if (e.action === "refuse") {
|
|
11221
|
+
io.err(`mjolnir install: refusing ${e.file} — ${e.reason}`);
|
|
11222
|
+
refused = true;
|
|
11223
|
+
continue;
|
|
11224
|
+
}
|
|
11225
|
+
if (e.action === "no-op") continue;
|
|
11226
|
+
}
|
|
11227
|
+
const written = executeInstall(entries);
|
|
11228
|
+
for (const e of entries) if (e.action !== "refuse" && e.action !== "no-op") io.out(` ${e.action}: ${e.surface} → ${e.file} (mjolnir-qa@${CLI_VERSION})`);
|
|
11229
|
+
if (stagedHook) {
|
|
11230
|
+
const hook = planHookInstall(cwd);
|
|
11231
|
+
executeHookInstall(hook);
|
|
11232
|
+
io.out(` ${hook.action}: non-blocking pre-commit hook → ${hook.file} (mjolnir-qa@${CLI_VERSION} --staged --blocking warning)`);
|
|
11233
|
+
}
|
|
11234
|
+
for (const e of entries) if (e.action !== "refuse" && e.action !== "no-op") io.out(` ${e.action}: ${e.surface} → ${e.file} (mjolnir-qa@${CLI_VERSION})`);
|
|
11235
|
+
if (refused) {
|
|
11236
|
+
io.err("Some surfaces were skipped — see refusals above. Nothing was overwritten.");
|
|
11237
|
+
return 10;
|
|
11238
|
+
}
|
|
11239
|
+
io.out(`Installed on ${detected} instruction surface(s); ${written} file(s) written.`);
|
|
11240
|
+
return 0;
|
|
11241
|
+
}
|
|
11242
|
+
function usageMessageFor(token) {
|
|
11243
|
+
return `mjolnir install: unknown argument "${token}" — supported: --dry-run, --force`;
|
|
11244
|
+
}
|
|
11245
|
+
const HOOK_MARKER_OPEN = "# mjolnir:managed pre-commit (non-blocking)";
|
|
11246
|
+
const HOOK_MARKER_CLOSE = "# /mjolnir:managed pre-commit";
|
|
11247
|
+
function hookBlock(version) {
|
|
11248
|
+
return [
|
|
11249
|
+
`${HOOK_MARKER_OPEN} v${version}`,
|
|
11250
|
+
`# Advisory: surfaces staged-file findings without blocking the commit.`,
|
|
11251
|
+
`mjolnir --staged --blocking warning || true`,
|
|
11252
|
+
HOOK_MARKER_CLOSE
|
|
11253
|
+
].join("\n");
|
|
11254
|
+
}
|
|
11255
|
+
/** The hook file an existing hook manager (husky / core.hooksPath) owns. */
|
|
11256
|
+
function resolveHookTarget(cwd) {
|
|
11257
|
+
const huskyDir = join(cwd, ".husky");
|
|
11258
|
+
if (existsSync(huskyDir)) return join(huskyDir, "pre-commit");
|
|
11259
|
+
try {
|
|
11260
|
+
const hooksPath = execFileSync("git", [
|
|
11261
|
+
"-C",
|
|
11262
|
+
cwd,
|
|
11263
|
+
"config",
|
|
11264
|
+
"core.hooksPath"
|
|
11265
|
+
], { stdio: [
|
|
11266
|
+
"ignore",
|
|
11267
|
+
"pipe",
|
|
11268
|
+
"ignore"
|
|
11269
|
+
] }).toString().trim();
|
|
11270
|
+
return join(cwd, hooksPath, "pre-commit");
|
|
11271
|
+
} catch {}
|
|
11272
|
+
return join(cwd, ".git", "hooks", "pre-commit");
|
|
11273
|
+
}
|
|
11274
|
+
/**
|
|
11275
|
+
* Pure plan for `--staged-hook` (plan M5). Reuses husky/core.hooksPath
|
|
11276
|
+
* when present; otherwise the default .git/hooks path. Marker-based:
|
|
11277
|
+
* an existing hook WITHOUT the marker is user-owned → refuse.
|
|
11278
|
+
*/
|
|
11279
|
+
function planHookInstall(cwd) {
|
|
11280
|
+
const file = resolveHookTarget(cwd);
|
|
11281
|
+
if (!existsSync(file)) return {
|
|
11282
|
+
action: "create",
|
|
11283
|
+
file
|
|
11284
|
+
};
|
|
11285
|
+
let existing;
|
|
11286
|
+
try {
|
|
11287
|
+
existing = readFileSync(file, "utf8");
|
|
11288
|
+
} catch {
|
|
11289
|
+
return {
|
|
11290
|
+
action: "refuse",
|
|
11291
|
+
file,
|
|
11292
|
+
reason: "existing hook is unreadable"
|
|
11293
|
+
};
|
|
11294
|
+
}
|
|
11295
|
+
if (existing.includes(HOOK_MARKER_OPEN)) return {
|
|
11296
|
+
action: "update",
|
|
11297
|
+
file
|
|
11298
|
+
};
|
|
11299
|
+
return {
|
|
11300
|
+
action: "append",
|
|
11301
|
+
file
|
|
11302
|
+
};
|
|
11303
|
+
}
|
|
11304
|
+
function executeHookInstall(entry) {
|
|
11305
|
+
switch (entry.action) {
|
|
11306
|
+
case "create":
|
|
11307
|
+
mkdirSync(join(entry.file, ".."), { recursive: true });
|
|
11308
|
+
writeFileSync(entry.file, `#!/bin/sh\n${hookBlock(CLI_VERSION)}\n`);
|
|
11309
|
+
return true;
|
|
11310
|
+
case "append": {
|
|
11311
|
+
const existing = readFileSync(entry.file, "utf8");
|
|
11312
|
+
const sep = existing.endsWith("\n") ? "" : "\n";
|
|
11313
|
+
writeFileSync(entry.file, `${existing}${sep}\n${hookBlock(CLI_VERSION)}\n`);
|
|
11314
|
+
return true;
|
|
11315
|
+
}
|
|
11316
|
+
case "update": {
|
|
11317
|
+
const existing = readFileSync(entry.file, "utf8");
|
|
11318
|
+
const openIdx = existing.indexOf(HOOK_MARKER_OPEN);
|
|
11319
|
+
const closeIdx = existing.indexOf(HOOK_MARKER_CLOSE);
|
|
11320
|
+
if (openIdx !== -1 && closeIdx !== -1) writeFileSync(entry.file, existing.slice(0, openIdx) + hookBlock(CLI_VERSION) + existing.slice(closeIdx + 29));
|
|
11321
|
+
else {
|
|
11322
|
+
const sep = existing.endsWith("\n") ? "" : "\n";
|
|
11323
|
+
writeFileSync(entry.file, `${existing}${sep}\n${hookBlock(CLI_VERSION)}\n`);
|
|
11324
|
+
}
|
|
11325
|
+
return true;
|
|
11326
|
+
}
|
|
11327
|
+
default: return false;
|
|
11328
|
+
}
|
|
11329
|
+
}
|
|
11330
|
+
//#endregion
|
|
10519
11331
|
//#region src/scope/changed.ts
|
|
10520
11332
|
/**
|
|
10521
11333
|
* Changed-scope engine (Sprint-Plan W6, Product-MVP §9 `--scope changed`).
|
|
@@ -10724,6 +11536,23 @@ function filterToChanged(findings, diff) {
|
|
|
10724
11536
|
return false;
|
|
10725
11537
|
});
|
|
10726
11538
|
}
|
|
11539
|
+
/**
|
|
11540
|
+
* --staged (agent-handoff plan §5.7): the staged file names, as a
|
|
11541
|
+
* *scan-surface restriction*. Returns null when git data is
|
|
11542
|
+
* unavailable (degraded — callers fall back to the full surface with
|
|
11543
|
+
* an honest stderr note). Empty list = genuinely nothing staged.
|
|
11544
|
+
*/
|
|
11545
|
+
function computeStagedFiles(root) {
|
|
11546
|
+
if (!existsSync(join(root, ".git"))) return null;
|
|
11547
|
+
const raw = git$1(root, [
|
|
11548
|
+
"diff",
|
|
11549
|
+
"--cached",
|
|
11550
|
+
"--name-only",
|
|
11551
|
+
"-z"
|
|
11552
|
+
]);
|
|
11553
|
+
if (raw === null) return null;
|
|
11554
|
+
return raw.split("\0").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
11555
|
+
}
|
|
10727
11556
|
//#endregion
|
|
10728
11557
|
//#region src/engine/rule-runner.ts
|
|
10729
11558
|
/**
|
|
@@ -11915,6 +12744,18 @@ const HELP_ENTRIES = [
|
|
|
11915
12744
|
usage: "mjolnir init [--interactive]",
|
|
11916
12745
|
examples: ["mjolnir init"]
|
|
11917
12746
|
},
|
|
12747
|
+
{
|
|
12748
|
+
verb: "why",
|
|
12749
|
+
summary: "why did Mjölnir flag <file>:<line>? evidence + fix (not a gate)",
|
|
12750
|
+
usage: "mjolnir why <file>:<line> [path] [--json <mjolnir.json>]",
|
|
12751
|
+
examples: ["mjolnir why e2e/a.spec.ts:3", "mjolnir why e2e/a.spec.ts:3 --json mjolnir.json"]
|
|
12752
|
+
},
|
|
12753
|
+
{
|
|
12754
|
+
verb: "handoff",
|
|
12755
|
+
summary: "deterministic fix-handoff artifact from a saved --json report",
|
|
12756
|
+
usage: "mjolnir handoff [mjolnir.json] [--category <cat>] [--rules <ids>]",
|
|
12757
|
+
examples: ["mjolnir --json > mjolnir.json && mjolnir handoff mjolnir.json", "mjolnir handoff mjolnir.json --category QA-PW"]
|
|
12758
|
+
},
|
|
11918
12759
|
{
|
|
11919
12760
|
verb: "explain",
|
|
11920
12761
|
summary: "what/why/fix + measured FP rate for one rule",
|
|
@@ -11944,6 +12785,12 @@ const HELP_ENTRIES = [
|
|
|
11944
12785
|
summary: "self-audit of the rule base (fixture firewall, tiers, caps)",
|
|
11945
12786
|
usage: "mjolnir doctor [repo-root]",
|
|
11946
12787
|
examples: ["mjolnir doctor"]
|
|
12788
|
+
},
|
|
12789
|
+
{
|
|
12790
|
+
verb: "install",
|
|
12791
|
+
summary: "install the agent instruction surfaces + optional staged hook",
|
|
12792
|
+
usage: "mjolnir install [--staged-hook] [--dry-run] [--force]",
|
|
12793
|
+
examples: ["mjolnir install --dry-run", "mjolnir install --staged-hook"]
|
|
11947
12794
|
}
|
|
11948
12795
|
];
|
|
11949
12796
|
/** Scan-flag entries documented per-flag via the overview. */
|
|
@@ -11999,6 +12846,22 @@ const HELP_FLAGS = [
|
|
|
11999
12846
|
{
|
|
12000
12847
|
flag: "--no-progress",
|
|
12001
12848
|
summary: "no live scan-progress line on stderr"
|
|
12849
|
+
},
|
|
12850
|
+
{
|
|
12851
|
+
flag: "--score",
|
|
12852
|
+
summary: "print only the numeric score (or `unknown`)"
|
|
12853
|
+
},
|
|
12854
|
+
{
|
|
12855
|
+
flag: "--category <cat>",
|
|
12856
|
+
summary: "presentation filter (repeatable)"
|
|
12857
|
+
},
|
|
12858
|
+
{
|
|
12859
|
+
flag: "--staged",
|
|
12860
|
+
summary: "scan only git staged files"
|
|
12861
|
+
},
|
|
12862
|
+
{
|
|
12863
|
+
flag: "--blocking <level>",
|
|
12864
|
+
summary: "exit-status override: error|warning|none"
|
|
12002
12865
|
}
|
|
12003
12866
|
];
|
|
12004
12867
|
const EXIT_CODE_TABLE = [
|
|
@@ -12083,7 +12946,13 @@ const GROUPS = [
|
|
|
12083
12946
|
},
|
|
12084
12947
|
{
|
|
12085
12948
|
title: "Meta",
|
|
12086
|
-
verbs: [
|
|
12949
|
+
verbs: [
|
|
12950
|
+
"rules",
|
|
12951
|
+
"explain",
|
|
12952
|
+
"why",
|
|
12953
|
+
"handoff",
|
|
12954
|
+
"install"
|
|
12955
|
+
]
|
|
12087
12956
|
}
|
|
12088
12957
|
];
|
|
12089
12958
|
const SCAN_SUMMARY_LINES = ["mjolnir [path] full-repo scan + WORTHINESS score"];
|
|
@@ -12742,6 +13611,15 @@ function renderImpact(report) {
|
|
|
12742
13611
|
*/
|
|
12743
13612
|
const ui$8 = plainContext();
|
|
12744
13613
|
const DEFAULT_BASELINE_PATH = join(".mjolnir", "baseline.json");
|
|
13614
|
+
/**
|
|
13615
|
+
* Correlation identity for before/after comparison (agent-handoff plan
|
|
13616
|
+
* §5.2): ruleId + file + message, deliberately EXCLUDING `line` — a
|
|
13617
|
+
* source edit that shifts a finding still correlates. file:line is an
|
|
13618
|
+
* occurrence location, not a durable identity; message rewording,
|
|
13619
|
+
* file renames and rule-id changes correlate as resolved+new
|
|
13620
|
+
* (documented limitation). Exported for the handoff verification
|
|
13621
|
+
* contract — do not duplicate this algorithm.
|
|
13622
|
+
*/
|
|
12745
13623
|
function fingerprint(f) {
|
|
12746
13624
|
return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
|
|
12747
13625
|
}
|
|
@@ -14618,7 +15496,7 @@ function computeSelectorHealth(root, ignoreMatcher = DEFAULT_IGNORE_MATCHER) {
|
|
|
14618
15496
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
14619
15497
|
* `tests/version-consistency.spec.ts` locally.
|
|
14620
15498
|
*/
|
|
14621
|
-
const CLI_VERSION = "0.5.
|
|
15499
|
+
const CLI_VERSION = "0.5.4";
|
|
14622
15500
|
const UNIVERSAL_RULES = RULES.map(asUniversal);
|
|
14623
15501
|
/** Registered rule IDs — used to warn on unknown severityOverrides keys (M4). */
|
|
14624
15502
|
const KNOWN_RULE_IDS = new Set(RULES.map((r) => r.id));
|
|
@@ -14736,7 +15614,23 @@ function parseArgs(argv, onError) {
|
|
|
14736
15614
|
else if (a === "--record-milestones") args.recordMilestones = true;
|
|
14737
15615
|
else if (a === "--cache") args.cache = true;
|
|
14738
15616
|
else if (a === "--no-progress") args.noProgress = true;
|
|
14739
|
-
else if (a === "--
|
|
15617
|
+
else if (a === "--category") {
|
|
15618
|
+
const cat = argv[++i];
|
|
15619
|
+
if (cat === void 0 || !RULE_CATEGORIES.includes(cat)) return reject({
|
|
15620
|
+
flag: "--category",
|
|
15621
|
+
token: cat
|
|
15622
|
+
});
|
|
15623
|
+
args.categories = [...args.categories ?? [], cat];
|
|
15624
|
+
} else if (a === "--score") args.scoreOnly = true;
|
|
15625
|
+
else if (a === "--staged") args.staged = true;
|
|
15626
|
+
else if (a === "--blocking") {
|
|
15627
|
+
const level = argv[++i];
|
|
15628
|
+
if (level === "error" || level === "warning" || level === "none") args.blocking = level;
|
|
15629
|
+
else return reject({
|
|
15630
|
+
flag: "--blocking",
|
|
15631
|
+
token: level
|
|
15632
|
+
});
|
|
15633
|
+
} else if (a === "--help" || a === "-h") return null;
|
|
14740
15634
|
else if (!a.startsWith("-")) args.target = a;
|
|
14741
15635
|
else return reject({ token: a });
|
|
14742
15636
|
}
|
|
@@ -14916,6 +15810,17 @@ async function runScan(args, hooks = {}) {
|
|
|
14916
15810
|
testFiles: wfBucket
|
|
14917
15811
|
});
|
|
14918
15812
|
ctx.testFiles.push(...wfBucket);
|
|
15813
|
+
let stagedSurface = false;
|
|
15814
|
+
if (args.staged) {
|
|
15815
|
+
const staged = computeStagedFiles(scanRoot.root);
|
|
15816
|
+
if (staged === null) hooks.onConfigWarning?.("mjolnir: --staged ignored — not a git repository (scanning the full surface).");
|
|
15817
|
+
else {
|
|
15818
|
+
const stagedSet = new Set(staged.map((s) => s.replace(/\\/g, "/")));
|
|
15819
|
+
ctx.testFiles = ctx.testFiles.filter((f) => stagedSet.has(relative(scanRoot.root, f).replace(/\\/g, "/")));
|
|
15820
|
+
stagedSurface = true;
|
|
15821
|
+
if (ctx.testFiles.length === 0) hooks.onConfigWarning?.("mjolnir: --staged — no staged files match the scan surface.");
|
|
15822
|
+
}
|
|
15823
|
+
}
|
|
14919
15824
|
hooks.onProgress?.({
|
|
14920
15825
|
phase: "discover",
|
|
14921
15826
|
done: ctx.testFiles.length,
|
|
@@ -15067,8 +15972,10 @@ async function runScan(args, hooks = {}) {
|
|
|
15067
15972
|
});
|
|
15068
15973
|
const elapsed = Date.now() - started;
|
|
15069
15974
|
const hasTests = testFileCount > 0;
|
|
15975
|
+
for (const f of findings) f.fixGroupId = f.ruleId;
|
|
15070
15976
|
const result = {
|
|
15071
15977
|
schemaVersion: 1,
|
|
15978
|
+
...stagedSurface ? { staged: { files: testFileCount } } : {},
|
|
15072
15979
|
partial: discoveryTruncated || rulesPartial || skippedFiles > 0,
|
|
15073
15980
|
score: hasTests ? total : null,
|
|
15074
15981
|
...hasTests ? {} : { reason: "no-tests-found" },
|
|
@@ -15370,13 +16277,22 @@ async function runScanCommand(argv, io = {
|
|
|
15370
16277
|
for (const line of crashLog.slice(0, 50)) io.err(` ${line}`);
|
|
15371
16278
|
if (crashLog.length > 50) io.err(` … and ${crashLog.length - 50} more`);
|
|
15372
16279
|
}
|
|
16280
|
+
if (args.scoreOnly) {
|
|
16281
|
+
if (args.json) io.err("--score overrides --json; stdout is the bare score.");
|
|
16282
|
+
const { config: scoreConfig } = loadConfig(target, { knownRuleIds: KNOWN_RULE_IDS });
|
|
16283
|
+
io.out(result.score === null ? "unknown" : String(result.score));
|
|
16284
|
+
return exitForFindings(result.findings, args.blocking === "none" ? "advisory" : args.blocking ?? scoreConfig.gate ?? "error");
|
|
16285
|
+
}
|
|
15373
16286
|
if (args.format === "sarif") io.out(renderSarif(result));
|
|
15374
16287
|
else if (args.format === "mermaid") io.out(renderMermaid(result));
|
|
15375
16288
|
else if (args.json) io.out(JSON.stringify(result, null, 2));
|
|
15376
16289
|
else {
|
|
16290
|
+
const categories = args.categories;
|
|
16291
|
+
const visible = categories && categories.length > 0 ? result.findings.filter((f) => categories.includes(f.category)) : result.findings;
|
|
15377
16292
|
io.out(renderTerminal(result, {
|
|
15378
16293
|
isTTY: process$1.stdout.isTTY ?? false,
|
|
15379
16294
|
verbose: args.verbose,
|
|
16295
|
+
...categories && categories.length > 0 ? { visibleFindings: visible } : {},
|
|
15380
16296
|
...args.width !== void 0 ? { width: args.width } : {},
|
|
15381
16297
|
...args.ascii !== void 0 ? { ascii: args.ascii } : {},
|
|
15382
16298
|
...args.tone !== void 0 ? { tone: args.tone } : {}
|
|
@@ -15394,7 +16310,7 @@ async function runScanCommand(argv, io = {
|
|
|
15394
16310
|
if (result.partial) return 2;
|
|
15395
16311
|
const { config, warnings } = loadConfig(target, { knownRuleIds: KNOWN_RULE_IDS });
|
|
15396
16312
|
for (const w of warnings) io.err(w);
|
|
15397
|
-
return exitForFindings(result.findings, config.gate ?? "error");
|
|
16313
|
+
return exitForFindings(result.findings, args.blocking === "none" ? "advisory" : args.blocking ?? config.gate ?? "error");
|
|
15398
16314
|
} catch (err) {
|
|
15399
16315
|
if (err instanceof ConfigValidationError) {
|
|
15400
16316
|
io.err(err.message);
|
|
@@ -15789,8 +16705,11 @@ async function main(argv = process$1.argv.slice(2), io = {
|
|
|
15789
16705
|
if (argv[0] === "rules") return runRulesCommand(argv.slice(1));
|
|
15790
16706
|
if (argv[0] === "explain") return runExplainCommand(argv.slice(1));
|
|
15791
16707
|
if (argv[0] === "doctor:playwright") return runDoctorPlaywright(argv);
|
|
16708
|
+
if (argv[0] === "why") return runWhyCommand(argv.slice(1), io);
|
|
16709
|
+
if (argv[0] === "handoff") return runHandoffCommand(argv.slice(1), io);
|
|
16710
|
+
if (argv[0] === "install") return runInstallCommand(argv.slice(1), io);
|
|
15792
16711
|
if (argv[0] === "help") return runHelpCommand(argv.slice(1), io);
|
|
15793
|
-
return runScanCommand(argv);
|
|
16712
|
+
return runScanCommand(argv, io);
|
|
15794
16713
|
}
|
|
15795
16714
|
/**
|
|
15796
16715
|
* `mjolnir help` / `mjolnir help <verb>` (plan M2). `--help`/`-h` and
|
package/package.json
CHANGED