mjolnir-qa 0.5.12 → 0.5.14
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 +39 -0
- package/README.md +262 -244
- package/dist/cli.d.mts +1 -1
- package/dist/cli.mjs +2009 -1589
- package/dist/mcp/stdio.mjs +103 -40
- package/package.json +7 -1
package/dist/cli.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { Project, SyntaxKind, ts } from "ts-morph";
|
|
|
10
10
|
import { Language, Parser } from "web-tree-sitter";
|
|
11
11
|
import { parse } from "yaml";
|
|
12
12
|
import { createHash, randomBytes } from "node:crypto";
|
|
13
|
+
import { createInterface } from "node:readline";
|
|
13
14
|
import { tmpdir } from "node:os";
|
|
14
15
|
//#region \0rolldown/runtime.js
|
|
15
16
|
var __defProp = Object.defineProperty;
|
|
@@ -11597,7 +11598,7 @@ const ALLOWED_QA_IMPACTS = /* @__PURE__ */ new Set([
|
|
|
11597
11598
|
"HYGIENE"
|
|
11598
11599
|
]);
|
|
11599
11600
|
/** Consistent error message extraction (v8 branch-friendly form). */
|
|
11600
|
-
function errorMessage(err) {
|
|
11601
|
+
function errorMessage$1(err) {
|
|
11601
11602
|
if (err instanceof Error) return err.message;
|
|
11602
11603
|
return String(err);
|
|
11603
11604
|
}
|
|
@@ -11618,7 +11619,7 @@ async function loadLocalRules(root, gateOpen = false) {
|
|
|
11618
11619
|
try {
|
|
11619
11620
|
entries = readdirSync(dir);
|
|
11620
11621
|
} catch (err) {
|
|
11621
|
-
result.errors.push(`external rules directory "${LOCAL_RULES_DIR}/" could not be read: ${errorMessage(err)}`);
|
|
11622
|
+
result.errors.push(`external rules directory "${LOCAL_RULES_DIR}/" could not be read: ${errorMessage$1(err)}`);
|
|
11622
11623
|
return result;
|
|
11623
11624
|
}
|
|
11624
11625
|
for (const entry of entries.sort()) {
|
|
@@ -11640,7 +11641,7 @@ function loadJsonRule(path, result) {
|
|
|
11640
11641
|
try {
|
|
11641
11642
|
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
11642
11643
|
} catch (err) {
|
|
11643
|
-
result.errors.push(`external rule "${name}" is not valid JSON: ${errorMessage(err)}`);
|
|
11644
|
+
result.errors.push(`external rule "${name}" is not valid JSON: ${errorMessage$1(err)}`);
|
|
11644
11645
|
return;
|
|
11645
11646
|
}
|
|
11646
11647
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
@@ -11676,7 +11677,7 @@ function loadJsonRule(path, result) {
|
|
|
11676
11677
|
try {
|
|
11677
11678
|
regexes = patterns.map((p) => new RegExp(p, "g"));
|
|
11678
11679
|
} catch (err) {
|
|
11679
|
-
result.errors.push(`external rule ${id} declares an invalid regex: ${errorMessage(err)}`);
|
|
11680
|
+
result.errors.push(`external rule ${id} declares an invalid regex: ${errorMessage$1(err)}`);
|
|
11680
11681
|
return;
|
|
11681
11682
|
}
|
|
11682
11683
|
const severity = decl["severity"];
|
|
@@ -11771,7 +11772,7 @@ async function loadModuleRules(path, result) {
|
|
|
11771
11772
|
try {
|
|
11772
11773
|
mod = await import(pathToFileURL(path).href);
|
|
11773
11774
|
} catch (err) {
|
|
11774
|
-
result.errors.push(`external rule module "${name}" failed to load: ${errorMessage(err)}`);
|
|
11775
|
+
result.errors.push(`external rule module "${name}" failed to load: ${errorMessage$1(err)}`);
|
|
11775
11776
|
return;
|
|
11776
11777
|
}
|
|
11777
11778
|
const rules = mod?.rules;
|
|
@@ -12480,26 +12481,26 @@ function topFixes(findings, n = 3) {
|
|
|
12480
12481
|
}
|
|
12481
12482
|
//#endregion
|
|
12482
12483
|
//#region src/reporter/art.ts
|
|
12483
|
-
/**
|
|
12484
|
+
/**
|
|
12485
|
+
* The report's wordmark. A LOCKUP, not a picture of a hammer.
|
|
12486
|
+
*
|
|
12487
|
+
* This used to be eight rows of box-drawing that spelled out a hammer —
|
|
12488
|
+
* directly above the score instrument, which is also a hammer. Two
|
|
12489
|
+
* hammers, ten lines apart, and the smaller static one came first, so
|
|
12490
|
+
* appendScoreSection's own claim that "the hammer is the instrument: the
|
|
12491
|
+
* first thing the eye lands on" was false in every report the tool has
|
|
12492
|
+
* ever printed.
|
|
12493
|
+
*
|
|
12494
|
+
* There is one hammer now, and it is the one that means something: the
|
|
12495
|
+
* state-resolved instrument in HAMMER_STATES. The wordmark stays out of
|
|
12496
|
+
* its way.
|
|
12497
|
+
*/
|
|
12484
12498
|
const LOGO = `
|
|
12485
|
-
|
|
12486
|
-
║ ║
|
|
12487
|
-
╠═══════════╣ M J Ö L N I R
|
|
12488
|
-
║ ║ ║
|
|
12489
|
-
╚═════╩═════╝ VERIFICATION TRUST ENGINE
|
|
12490
|
-
║
|
|
12491
|
-
║
|
|
12499
|
+
M J Ö L N I R · VERIFICATION TRUST ENGINE
|
|
12492
12500
|
`;
|
|
12493
|
-
/** Plain-ASCII fallback
|
|
12494
|
-
* block-drawing LOGO above renders as mangled "?" glyphs. */
|
|
12501
|
+
/** Plain-ASCII fallback for cmd.exe/legacy consoles. */
|
|
12495
12502
|
const LOGO_ASCII = `
|
|
12496
|
-
|
|
12497
|
-
| |
|
|
12498
|
-
+-----------+ M J O L N I R
|
|
12499
|
-
| | |
|
|
12500
|
-
+-----+-----+ VERIFICATION TRUST ENGINE
|
|
12501
|
-
|
|
|
12502
|
-
|
|
|
12503
|
+
M J O L N I R - VERIFICATION TRUST ENGINE
|
|
12503
12504
|
`;
|
|
12504
12505
|
const TROPHY = String.raw`
|
|
12505
12506
|
___________
|
|
@@ -13000,7 +13001,12 @@ const CARD_GUTTER = " ";
|
|
|
13000
13001
|
function pushCard(lines, card, ui) {
|
|
13001
13002
|
const { p, width } = ui;
|
|
13002
13003
|
const contentWidth = Math.max(20, width - 2 - 4 - CARD_LABEL_PAD);
|
|
13003
|
-
|
|
13004
|
+
const header = ` ${severityIcon(card.severity, ui)} ${p.bold(card.loc)}`;
|
|
13005
|
+
if (measure(`${header} ${card.evidence}`) <= width) lines.push(`${header} ${p.dim(card.evidence)}`);
|
|
13006
|
+
else {
|
|
13007
|
+
lines.push(header);
|
|
13008
|
+
lines.push(`${CARD_GUTTER}${p.dim(card.evidence)}`);
|
|
13009
|
+
}
|
|
13004
13010
|
const fields = [
|
|
13005
13011
|
{
|
|
13006
13012
|
label: "Finding",
|
|
@@ -13040,7 +13046,7 @@ function pushCard(lines, card, ui) {
|
|
|
13040
13046
|
* overflow line; --verbose shows everything.
|
|
13041
13047
|
*/
|
|
13042
13048
|
function appendFindings(lines, result, counts, verbose, ui, tone) {
|
|
13043
|
-
const { p } = ui;
|
|
13049
|
+
const { p, width } = ui;
|
|
13044
13050
|
if (counts.total === 0) return;
|
|
13045
13051
|
if (result.findings.length === 0) {
|
|
13046
13052
|
lines.push(ui.p.dim(" filtered view: no findings in the selected category"));
|
|
@@ -13089,9 +13095,21 @@ function appendFindings(lines, result, counts, verbose, ui, tone) {
|
|
|
13089
13095
|
hiddenRules.add(unit.ruleId);
|
|
13090
13096
|
continue;
|
|
13091
13097
|
}
|
|
13092
|
-
|
|
13093
|
-
|
|
13094
|
-
|
|
13098
|
+
const groupHead = ` ${severityIcon(maxSeverity(unit.findings), ui)} ${p.bold(sanitizeData(unit.ruleId))} ${p.dim(`× ${n} — same fix applies`)}`;
|
|
13099
|
+
const groupEvidence = evidenceTag$1(first);
|
|
13100
|
+
if (measure(`${groupHead} ${groupEvidence}`) <= width) lines.push(`${groupHead} ${p.dim(groupEvidence)}`);
|
|
13101
|
+
else {
|
|
13102
|
+
lines.push(groupHead);
|
|
13103
|
+
lines.push(`${CARD_GUTTER}${p.dim(groupEvidence)}`);
|
|
13104
|
+
}
|
|
13105
|
+
const groupContentWidth = Math.max(20, width - 2 - 4 - CARD_LABEL_PAD);
|
|
13106
|
+
wrapLines(sanitizeData(first.fix), groupContentWidth).forEach((seg, i) => {
|
|
13107
|
+
const label = i === 0 ? p.accent("Fix".padEnd(CARD_LABEL_PAD)) : " ".repeat(CARD_LABEL_PAD);
|
|
13108
|
+
lines.push(`${CARD_GUTTER}${label}${p.dim(seg)}`);
|
|
13109
|
+
});
|
|
13110
|
+
for (const f of unit.findings) wrapLines(`· ${sanitizeData(f.file)}:${f.line} — ${sanitizeData(f.message)}`, groupContentWidth).forEach((seg, i) => {
|
|
13111
|
+
lines.push(`${CARD_GUTTER}${" ".repeat(CARD_LABEL_PAD)}${p.dim(i === 0 ? seg : ` ${seg}`)}`);
|
|
13112
|
+
});
|
|
13095
13113
|
lines.push("");
|
|
13096
13114
|
shown++;
|
|
13097
13115
|
continue;
|
|
@@ -13129,22 +13147,34 @@ function appendForgedBlock(lines, p, ascii) {
|
|
|
13129
13147
|
lines.push(p.forged(TROPHY));
|
|
13130
13148
|
lines.push("");
|
|
13131
13149
|
}
|
|
13150
|
+
/**
|
|
13151
|
+
* Pushes dimmed prose that respects the terminal width.
|
|
13152
|
+
*
|
|
13153
|
+
* The honesty footer used to be pushed as single unbroken strings — the
|
|
13154
|
+
* rule-coverage line alone is ~147 columns, so it overflowed every
|
|
13155
|
+
* default 80- or 100-column terminal and ignored `--width` entirely.
|
|
13156
|
+
* `wrapText` is the same helper the finding cards already use.
|
|
13157
|
+
*/
|
|
13158
|
+
function pushWrapped(lines, p, text, width) {
|
|
13159
|
+
const indent = " ";
|
|
13160
|
+
for (const line of wrapText(text, Math.max(20, width - 2))) lines.push(p.dim(`${indent}${line}`));
|
|
13161
|
+
}
|
|
13132
13162
|
function appendFooter(lines, result, ui) {
|
|
13133
|
-
const { p } = ui;
|
|
13163
|
+
const { p, width } = ui;
|
|
13134
13164
|
lines.push(...buildFooter({
|
|
13135
13165
|
ui,
|
|
13136
13166
|
complete: result.analysisStatus.discovery !== "partial",
|
|
13137
13167
|
durationMs: result.analysisStatus.durationMs
|
|
13138
13168
|
}));
|
|
13139
13169
|
const advisory = result.findings.filter((f) => (f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence)) === "E0").length;
|
|
13140
|
-
if (advisory > 0) lines
|
|
13170
|
+
if (advisory > 0) pushWrapped(lines, p, `${advisory} advisory finding${advisory === 1 ? "" : "s"} (E0 — observation only, no score impact)`, width);
|
|
13141
13171
|
if (result.findings.length > 0) {
|
|
13142
13172
|
const firedRuleIds = new Set(result.findings.map((f) => f.ruleId));
|
|
13143
13173
|
const measuredHere = [...firedRuleIds].filter((id) => MEASURED_FP[id] !== void 0).length;
|
|
13144
|
-
lines
|
|
13174
|
+
pushWrapped(lines, p, `Rule coverage: ${measuredHere}/${firedRuleIds.size} rules that fired here have a measured false-positive rate; the rest are heuristics. \`mjolnir rules --unmeasured\` lists them.`, width);
|
|
13145
13175
|
const verified = result.findings.filter((f) => f.runtimeCorroboration !== void 0).length;
|
|
13146
|
-
if (verified > 0) lines
|
|
13147
|
-
else lines
|
|
13176
|
+
if (verified > 0) pushWrapped(lines, p, `Runtime evidence: ${verified}/${result.findings.length} findings corroborated by a real run report (trust L3–L5); the rest are static-only.`, width);
|
|
13177
|
+
else pushWrapped(lines, p, `Runtime evidence: not available — no run report (mjolnir.report.json / test-results) next to the scan target; all findings are static-only (L0–L2).`, width);
|
|
13148
13178
|
}
|
|
13149
13179
|
const profile = result.agenticProfile;
|
|
13150
13180
|
if (profile && (profile.generatedMarkedFiles > 0 || profile.codegenLikeFiles > 0)) {
|
|
@@ -13224,7 +13254,7 @@ function renderSarif(result, repoRootUri) {
|
|
|
13224
13254
|
tool: { driver: {
|
|
13225
13255
|
name: "Mjölnir",
|
|
13226
13256
|
informationUri: "https://github.com/Sergey-Bar/Mjolnir",
|
|
13227
|
-
version: "0.5.
|
|
13257
|
+
version: "0.5.14",
|
|
13228
13258
|
rules: [...rules.values()].map((r) => {
|
|
13229
13259
|
const meta = RULES.find((x) => x.id === r.id);
|
|
13230
13260
|
return {
|
|
@@ -14646,307 +14676,185 @@ function executeHookInstall(entry) {
|
|
|
14646
14676
|
}
|
|
14647
14677
|
}
|
|
14648
14678
|
//#endregion
|
|
14649
|
-
//#region src/
|
|
14679
|
+
//#region src/rules/measurement.ts
|
|
14680
|
+
/** The rule's declared detector implementation revision (§07). */
|
|
14681
|
+
function declaredDetectorRevision(rule) {
|
|
14682
|
+
return rule.detectorRevision ?? 1;
|
|
14683
|
+
}
|
|
14650
14684
|
/**
|
|
14651
|
-
*
|
|
14652
|
-
*
|
|
14653
|
-
*
|
|
14654
|
-
*
|
|
14655
|
-
* Bug-audit hardening (H2): the previous template shipped the same
|
|
14656
|
-
* `github.rest.checks` no-op this repo's own audit removed from mjolnir.yml,
|
|
14657
|
-
* failed the job before the annotate/summary steps could run whenever the
|
|
14658
|
-
* scan step exited non-zero, recommended floating `mjolnir-qa@latest`, and
|
|
14659
|
-
* `ciInstall` silently overwrote hand-customized workflows. The template now
|
|
14660
|
-
* mirrors the dogfooded `.github/workflows/mjolnir.yml` (pinned action SHAs,
|
|
14661
|
-
* `if: always()` on reporting steps, a real gate step that reads
|
|
14662
|
-
* `mjolnir.json`, partial scans never block) and `ciInstall` refuses to
|
|
14663
|
-
* replace a customized workflow without an explicit `--force`.
|
|
14685
|
+
* A measurement exists AND was taken against the detector revision the
|
|
14686
|
+
* rule declares now. A revision mismatch (stale) does NOT count — §07:
|
|
14687
|
+
* stale → provisional → re-measure.
|
|
14664
14688
|
*/
|
|
14689
|
+
function hasValidMeasurement(rule) {
|
|
14690
|
+
const m = MEASURED_FP[rule.id];
|
|
14691
|
+
return m !== void 0 && m.detectorRevision === declaredDetectorRevision(rule);
|
|
14692
|
+
}
|
|
14665
14693
|
/**
|
|
14666
|
-
* The
|
|
14667
|
-
*
|
|
14668
|
-
* the tool's own exit-code contract:
|
|
14669
|
-
* - missing/unreadable `mjolnir.json` → fail (the scan step crashed; a
|
|
14670
|
-
* silent pass here would turn a broken pipeline into a green one);
|
|
14671
|
-
* - `partial: true` → never block (truncated results can neither prove
|
|
14672
|
-
* nor disprove the gate — the "PARTIAL" banner in the summary is the
|
|
14673
|
-
* honest signal);
|
|
14674
|
-
* - `error` gate → block on any error-severity finding;
|
|
14675
|
-
* - `warning` gate → block on warnings and errors.
|
|
14694
|
+
* The tier a rule effectively ships as: its declared tier, or — for the
|
|
14695
|
+
* omitted-tier case — the measurement-dependent default (§11.2 Step 2).
|
|
14676
14696
|
*/
|
|
14677
|
-
function
|
|
14678
|
-
return
|
|
14679
|
-
|
|
14680
|
-
|
|
14681
|
-
|
|
14682
|
-
|
|
14683
|
-
|
|
14684
|
-
|
|
14685
|
-
" process.exit(1);",
|
|
14686
|
-
"}",
|
|
14687
|
-
"if (r.partial === true) {",
|
|
14688
|
-
" process.stdout.write(\"Scan was PARTIAL - some files were not analyzed; gate not enforced.\\n\");",
|
|
14689
|
-
" process.exit(0);",
|
|
14690
|
-
"}",
|
|
14691
|
-
"const findings = Array.isArray(r.findings) ? r.findings : [];",
|
|
14692
|
-
"const errors = findings.filter(function (f) { return f && f.severity === \"error\"; }).length;",
|
|
14693
|
-
"const warnings = findings.filter(function (f) { return f && f.severity === \"warning\"; }).length;",
|
|
14694
|
-
"process.stdout.write(\"Mjolnir gate: \" + errors + \" error(s), \" + warnings + \" warning(s).\\n\");",
|
|
14695
|
-
`if (${gate === "error" ? "errors > 0" : "errors > 0 || warnings > 0"}) { process.exit(1); }`,
|
|
14696
|
-
"process.exit(0);"
|
|
14697
|
-
].join("\n");
|
|
14697
|
+
function effectiveTier(rule) {
|
|
14698
|
+
if (rule.tier !== void 0) return rule.tier;
|
|
14699
|
+
if (hasValidMeasurement(rule)) return "core";
|
|
14700
|
+
return "extended";
|
|
14701
|
+
}
|
|
14702
|
+
/** The §11.2 Step 2 PROVISIONAL display predicate. */
|
|
14703
|
+
function isProvisional(rule) {
|
|
14704
|
+
return effectiveTier(rule) === "extended" && !hasValidMeasurement(rule);
|
|
14698
14705
|
}
|
|
14706
|
+
//#endregion
|
|
14707
|
+
//#region src/commands/fixture-example.ts
|
|
14699
14708
|
/**
|
|
14700
|
-
*
|
|
14701
|
-
*
|
|
14702
|
-
*
|
|
14703
|
-
*
|
|
14709
|
+
* Shared example-fixture selection for `mjolnir explain` (explain.ts)
|
|
14710
|
+
* and the generated rule docs (rule-docs.ts) — one chooser so both
|
|
14711
|
+
* surfaces always show the same example for the same rule. Extracted
|
|
14712
|
+
* after the same bug-audit L9 fix had to be applied to both verbatim
|
|
14713
|
+
* copies: duplicated fixture selection drifts, and a drift means
|
|
14714
|
+
* `mjolnir explain <ID>` contradicts docs/rules/<ID>.md.
|
|
14704
14715
|
*/
|
|
14705
|
-
/**
|
|
14706
|
-
*
|
|
14707
|
-
*
|
|
14708
|
-
|
|
14709
|
-
|
|
14710
|
-
|
|
14711
|
-
|
|
14712
|
-
|
|
14713
|
-
"try { r = JSON.parse(fs.readFileSync(\"mjolnir.json\", \"utf8\")); } catch (e) {}",
|
|
14714
|
-
"const findings = Array.isArray(r.findings) ? r.findings : [];",
|
|
14715
|
-
"const lines = findings.map(function (f) {",
|
|
14716
|
-
" return \"- **\" + f.ruleId + \"** (\" + f.severity + \") \" + f.file + \":\" + f.line + \" - \" + f.message;",
|
|
14717
|
-
"});",
|
|
14718
|
-
"const head = r.partial === true",
|
|
14719
|
-
" ? \"Mjolnir scan was PARTIAL - some files may not have been analyzed.\"",
|
|
14720
|
-
" : \"Mjolnir scan finished.\";",
|
|
14721
|
-
"const body = [\"## Mjolnir findings\", head, \"\"].concat(lines).join(\"\\n\");",
|
|
14722
|
-
"if (process.env.GITHUB_STEP_SUMMARY && lines.length > 0) {",
|
|
14723
|
-
" fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, body + \"\\n\");",
|
|
14724
|
-
"}",
|
|
14725
|
-
"process.stdout.write(lines.length + \" finding(s)\\n\");"
|
|
14726
|
-
].join("\n");
|
|
14727
|
-
/** True when a workflow was generated by any Mjölnir template (v1 or v2).
|
|
14728
|
-
* The v1 needle is matched in its INDENTED form: the v1 template embedded
|
|
14729
|
-
* the script via indentBlock(…, 10) inside the `run: |` scalar, so the
|
|
14730
|
-
* raw unindented substring never appears in a real v1 file. */
|
|
14731
|
-
function isKnownTemplate(content) {
|
|
14732
|
-
return GATES.some((g) => content === TEMPLATE(g)) || content.includes(indentBlock(SUMMARY_SCRIPT_V1, 10));
|
|
14716
|
+
/**
|
|
14717
|
+
* First (byte-stable) fixture file in a must-fire / must-not-fire
|
|
14718
|
+
* directory, or null when the directory is absent or empty.
|
|
14719
|
+
*/
|
|
14720
|
+
function firstFixtureFile(dir) {
|
|
14721
|
+
if (!existsSync(dir)) return null;
|
|
14722
|
+
const entries = readdirSync(dir).filter((f) => !f.startsWith(".")).sort();
|
|
14723
|
+
return entries.length > 0 ? join(dir, entries[0]) : null;
|
|
14733
14724
|
}
|
|
14734
|
-
|
|
14735
|
-
|
|
14736
|
-
|
|
14737
|
-
|
|
14725
|
+
//#endregion
|
|
14726
|
+
//#region src/commands/explain.ts
|
|
14727
|
+
/**
|
|
14728
|
+
* `mjolnir explain <RULE-ID>` — implements Plan.md Sprint 1.3
|
|
14729
|
+
* (Master-Stabilization-Plan Sprint 5, Task 19).
|
|
14730
|
+
*
|
|
14731
|
+
* For any registered rule, renders what is wrong, why it matters, the
|
|
14732
|
+
* evidence level and confidence behind the verdict, the prescription,
|
|
14733
|
+
* and how to verify the fix. This is a presentation layer only — no new
|
|
14734
|
+
* detection logic. Every field it prints already exists on RuleMeta or
|
|
14735
|
+
* comes from actually running the rule against its own committed
|
|
14736
|
+
* must-fire fixture, so the example shown is real detector output, not
|
|
14737
|
+
* hand-written prose that can drift from what the rule actually does.
|
|
14738
|
+
*/
|
|
14739
|
+
const ui$12 = plainContext();
|
|
14740
|
+
/**
|
|
14741
|
+
* Runs the rule against its own must-fire fixture to get one real,
|
|
14742
|
+
* concrete example finding. `fixturesRoot` defaults to this repo's own
|
|
14743
|
+
* `tests/fixtures` — explain only has real examples to show when run
|
|
14744
|
+
* from (or pointed at) a Mjolnir checkout; degrades honestly
|
|
14745
|
+
* (exampleFinding left undefined) otherwise, same as `doctor`.
|
|
14746
|
+
*/
|
|
14747
|
+
function explainRule(ruleId, fixturesRoot) {
|
|
14748
|
+
const rule = getRule(ruleId);
|
|
14749
|
+
if (!rule) return {
|
|
14750
|
+
ok: false,
|
|
14751
|
+
error: `Unknown rule ID "${ruleId}". Run \`mjolnir rules\` for the full catalog.`
|
|
14752
|
+
};
|
|
14753
|
+
const fixturePath = firstFixtureFile(join(fixturesRoot, ruleId, "must-fire"));
|
|
14754
|
+
if (!fixturePath) return {
|
|
14755
|
+
ok: true,
|
|
14756
|
+
rule
|
|
14757
|
+
};
|
|
14758
|
+
let text;
|
|
14759
|
+
try {
|
|
14760
|
+
text = readFileSync(fixturePath, "utf8").replace(/\r\n/g, "\n");
|
|
14761
|
+
} catch {
|
|
14762
|
+
return {
|
|
14763
|
+
ok: true,
|
|
14764
|
+
rule
|
|
14765
|
+
};
|
|
14766
|
+
}
|
|
14767
|
+
const normalizedPath = fixturePath.replaceAll("\\", "/");
|
|
14768
|
+
let ast;
|
|
14769
|
+
if (rule.appliesTo === "ci-workflows") try {
|
|
14770
|
+
ast = parseWorkflow(text);
|
|
14771
|
+
} catch {
|
|
14772
|
+
return {
|
|
14773
|
+
ok: true,
|
|
14774
|
+
rule
|
|
14775
|
+
};
|
|
14776
|
+
}
|
|
14777
|
+
let findings;
|
|
14778
|
+
try {
|
|
14779
|
+
const parsed = {
|
|
14780
|
+
path: normalizedPath,
|
|
14781
|
+
text,
|
|
14782
|
+
ast
|
|
14783
|
+
};
|
|
14784
|
+
const codeText = computeCodeText(parsed, normalizedPath.endsWith(".py") ? "python" : normalizedPath.endsWith(".java") ? "java" : normalizedPath.endsWith(".cs") ? "csharp" : "typescript");
|
|
14785
|
+
findings = rule.run({
|
|
14786
|
+
...parsed,
|
|
14787
|
+
codeText
|
|
14788
|
+
});
|
|
14789
|
+
} catch {
|
|
14790
|
+
return {
|
|
14791
|
+
ok: true,
|
|
14792
|
+
rule
|
|
14793
|
+
};
|
|
14794
|
+
}
|
|
14795
|
+
const example = findings[0];
|
|
14796
|
+
if (!example) return {
|
|
14797
|
+
ok: true,
|
|
14798
|
+
rule
|
|
14799
|
+
};
|
|
14800
|
+
return {
|
|
14801
|
+
ok: true,
|
|
14802
|
+
rule,
|
|
14803
|
+
exampleFinding: example,
|
|
14804
|
+
exampleFixturePath: fixturePath,
|
|
14805
|
+
exampleFixtureRelPath: relative(fixturesRoot, fixturePath).replaceAll("\\", "/")
|
|
14806
|
+
};
|
|
14738
14807
|
}
|
|
14739
|
-
/**
|
|
14740
|
-
|
|
14741
|
-
|
|
14742
|
-
|
|
14743
|
-
|
|
14744
|
-
|
|
14745
|
-
|
|
14746
|
-
|
|
14747
|
-
|
|
14748
|
-
|
|
14749
|
-
|
|
14750
|
-
|
|
14751
|
-
|
|
14752
|
-
|
|
14753
|
-
jobs:
|
|
14754
|
-
scan:
|
|
14755
|
-
runs-on: ubuntu-latest
|
|
14756
|
-
# A hung scan must not sit for the 6-hour default.
|
|
14757
|
-
timeout-minutes: 10
|
|
14758
|
-
steps:
|
|
14759
|
-
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
14760
|
-
with:
|
|
14761
|
-
fetch-depth: 0 # needed for --scope changed merge-base
|
|
14762
|
-
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
14763
|
-
with:
|
|
14764
|
-
node-version: 22
|
|
14765
|
-
# Scan with the PINNED version that generated this workflow — never
|
|
14766
|
-
# a floating tag: a new release must not change your gate semantics
|
|
14767
|
-
# with no commit of yours. To review PRs with the exact tool your
|
|
14768
|
-
# repo develops against, add mjolnir-qa to devDependencies and drop
|
|
14769
|
-
# the @version suffix so npx resolves the local install.
|
|
14770
|
-
- name: Scan changed code (exit 1/2 is data — the gate step decides)
|
|
14771
|
-
continue-on-error: true
|
|
14772
|
-
run: npx --yes mjolnir-qa@${CLI_VERSION} . --scope changed --json > mjolnir.json
|
|
14773
|
-
# Reporting, not gating: a crashed scan leaves mjolnir.json empty/missing
|
|
14774
|
-
# and the summary step exits 2/10 — continue-on-error keeps the advisory
|
|
14775
|
-
# job green, exactly like the v1 inline script did (the gate step decides).
|
|
14776
|
-
- name: Annotations + Job Summary
|
|
14777
|
-
if: always()
|
|
14778
|
-
continue-on-error: true
|
|
14779
|
-
run: npx --yes mjolnir-qa@${CLI_VERSION} summary mjolnir.json
|
|
14780
|
-
- name: Render PR comment
|
|
14781
|
-
if: always()
|
|
14782
|
-
continue-on-error: true
|
|
14783
|
-
run: npx --yes mjolnir-qa@${CLI_VERSION} pr-comment . > mjolnir-comment.md
|
|
14784
|
-
# Best-effort: on a pull_request event from a fork the GITHUB_TOKEN is
|
|
14785
|
-
# read-only and this step will 403 for every external contributor. The
|
|
14786
|
-
# Job Summary above is the fallback that always renders.
|
|
14787
|
-
# (pull_request_target would fix the token but is a code-execution
|
|
14788
|
-
# risk — deliberately NOT used.)
|
|
14789
|
-
- name: Post or update PR comment
|
|
14790
|
-
if: always()
|
|
14791
|
-
continue-on-error: true
|
|
14792
|
-
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
14793
|
-
with:
|
|
14794
|
-
script: |
|
|
14795
|
-
const fs = require('fs');
|
|
14796
|
-
let body = '';
|
|
14797
|
-
try { body = fs.readFileSync('mjolnir-comment.md', 'utf8'); } catch (e) {}
|
|
14798
|
-
if (!body.trim()) {
|
|
14799
|
-
console.log('mjolnir-comment.md is empty or missing — nothing to post.');
|
|
14800
|
-
return;
|
|
14801
|
-
}
|
|
14802
|
-
const marker = '<!-- mjolnir-pr-comment -->';
|
|
14803
|
-
const { data: comments } = await github.rest.issues.listComments({
|
|
14804
|
-
owner: context.repo.owner,
|
|
14805
|
-
repo: context.repo.repo,
|
|
14806
|
-
issue_number: context.issue.number,
|
|
14807
|
-
});
|
|
14808
|
-
const existing = comments.find((c) => c.body?.startsWith(marker));
|
|
14809
|
-
if (existing) {
|
|
14810
|
-
await github.rest.issues.updateComment({
|
|
14811
|
-
owner: context.repo.owner,
|
|
14812
|
-
repo: context.repo.repo,
|
|
14813
|
-
comment_id: existing.id,
|
|
14814
|
-
body,
|
|
14815
|
-
});
|
|
14816
|
-
} else {
|
|
14817
|
-
await github.rest.issues.createComment({
|
|
14818
|
-
owner: context.repo.owner,
|
|
14819
|
-
repo: context.repo.repo,
|
|
14820
|
-
issue_number: context.issue.number,
|
|
14821
|
-
body,
|
|
14822
|
-
});
|
|
14823
|
-
}
|
|
14824
|
-
${gate === "advisory" ? ` # Advisory mode: findings are reported in the Job Summary and the
|
|
14825
|
-
# PR comment, never blocking. The scan step's exit code is visible
|
|
14826
|
-
# as the step outcome, but continue-on-error keeps the job green.
|
|
14827
|
-
- name: Gate (advisory)
|
|
14828
|
-
if: always()
|
|
14829
|
-
run: echo "Advisory mode — findings reported, never blocking."` : ` # Gate enforcement: the scan step's own exit code is deliberately
|
|
14830
|
-
# neutralized (continue-on-error) so reporting steps always run; THIS
|
|
14831
|
-
# step is what fails the job. A partial scan never blocks.
|
|
14832
|
-
- name: Gate (${gate})
|
|
14833
|
-
if: always()
|
|
14834
|
-
run: |
|
|
14835
|
-
node -e '
|
|
14836
|
-
${indentBlock(gateScript(gate), 10)}
|
|
14837
|
-
'`}
|
|
14838
|
-
`;
|
|
14839
|
-
/** Every gate variant, used to tell "our template" from "hand-customized". */
|
|
14840
|
-
const GATES = [
|
|
14841
|
-
"advisory",
|
|
14842
|
-
"error",
|
|
14843
|
-
"warning"
|
|
14844
|
-
];
|
|
14845
|
-
/** Multiset line diff — counts only, for the refusal message. */
|
|
14846
|
-
function summarizeContentDiff(existing, incoming) {
|
|
14847
|
-
const remaining = /* @__PURE__ */ new Map();
|
|
14848
|
-
for (const line of existing.split(/\r?\n/)) remaining.set(line, (remaining.get(line) ?? 0) + 1);
|
|
14849
|
-
let added = 0;
|
|
14850
|
-
for (const line of incoming.split(/\r?\n/)) {
|
|
14851
|
-
const count = remaining.get(line) ?? 0;
|
|
14852
|
-
if (count > 0) remaining.set(line, count - 1);
|
|
14853
|
-
else added += 1;
|
|
14854
|
-
}
|
|
14855
|
-
let removed = 0;
|
|
14856
|
-
for (const count of remaining.values()) removed += count;
|
|
14857
|
-
return [` - ${removed} line(s) of your file are not in the template and would be removed`, ` - ${added} template line(s) are not in your file and would be added`];
|
|
14858
|
-
}
|
|
14859
|
-
function ciInstall(root, gate = "advisory", options = {}) {
|
|
14860
|
-
const wfDir = join(root, ".github", "workflows");
|
|
14861
|
-
const target = join(wfDir, "mjolnir.yml");
|
|
14862
|
-
if (!existsSync(wfDir)) mkdirSync(wfDir, { recursive: true });
|
|
14863
|
-
const existed = existsSync(target);
|
|
14864
|
-
if (existed) {
|
|
14865
|
-
const current = readFileSync(target, "utf8");
|
|
14866
|
-
if (!isKnownTemplate(current) && !(options.force ?? false)) return {
|
|
14867
|
-
written: target,
|
|
14868
|
-
existed,
|
|
14869
|
-
refused: true,
|
|
14870
|
-
diffSummary: summarizeContentDiff(current, TEMPLATE(gate))
|
|
14871
|
-
};
|
|
14872
|
-
}
|
|
14873
|
-
writeFileSync(target, TEMPLATE(gate));
|
|
14874
|
-
return {
|
|
14875
|
-
written: target,
|
|
14876
|
-
existed,
|
|
14877
|
-
refused: false,
|
|
14878
|
-
diffSummary: []
|
|
14879
|
-
};
|
|
14880
|
-
}
|
|
14881
|
-
//#endregion
|
|
14882
|
-
//#region src/forensics/triage.ts
|
|
14883
|
-
const ui$12 = plainContext();
|
|
14884
|
-
const QUARANTINE_MIN_ATTEMPTS = 2;
|
|
14885
|
-
/** Deterministic triage table rows, worst first. */
|
|
14886
|
-
function triageRows(report) {
|
|
14887
|
-
return report.verdicts.filter((v) => v.everFailed || v.passedOnRetry).sort((a, b) => {
|
|
14888
|
-
if (a.passedOnRetry !== b.passedOnRetry) return a.passedOnRetry ? -1 : 1;
|
|
14889
|
-
if (a.attempts !== b.attempts) return b.attempts - a.attempts;
|
|
14890
|
-
return b.totalDurationMs - a.totalDurationMs;
|
|
14891
|
-
}).map((v) => ({
|
|
14892
|
-
file: v.file,
|
|
14893
|
-
title: v.title,
|
|
14894
|
-
attempts: v.attempts,
|
|
14895
|
-
passedOnRetry: v.passedOnRetry,
|
|
14896
|
-
finalStatus: v.finalStatus,
|
|
14897
|
-
totalDurationMs: v.totalDurationMs,
|
|
14898
|
-
suggestedAction: suggestAction(v),
|
|
14899
|
-
proposedQuarantine: v.attempts >= QUARANTINE_MIN_ATTEMPTS && v.everFailed
|
|
14900
|
-
}));
|
|
14901
|
-
}
|
|
14902
|
-
function suggestAction(v) {
|
|
14903
|
-
if (v.passedOnRetry && v.attempts >= QUARANTINE_MIN_ATTEMPTS) return "quarantine + ticket";
|
|
14904
|
-
if (v.passedOnRetry) return "fix nondeterminism";
|
|
14905
|
-
if (v.finalStatus === "failed" || v.finalStatus === "timedOut") return "fix now — failing";
|
|
14906
|
-
return "investigate";
|
|
14907
|
-
}
|
|
14908
|
-
/** Terminal rendering of the triage proposal. */
|
|
14909
|
-
function renderTriage(report) {
|
|
14910
|
-
const rows = triageRows(report);
|
|
14911
|
-
const lines = [];
|
|
14912
|
-
lines.push(sectionHeader("FLAKY TRIAGE — auto-generated, do not edit", ui$12));
|
|
14913
|
-
lines.push("");
|
|
14914
|
-
if (rows.length === 0) {
|
|
14915
|
-
lines.push("Nothing to triage — no failures or retries in this run.");
|
|
14916
|
-
return lines.join("\n");
|
|
14917
|
-
}
|
|
14918
|
-
const quarantineCount = rows.filter((r) => r.proposedQuarantine).length;
|
|
14919
|
-
for (const r of rows) {
|
|
14920
|
-
const flag = r.passedOnRetry ? "TRUE-FLAKE" : "FAILING";
|
|
14921
|
-
lines.push(`• [${flag}] ${r.title} (${r.file}) — ${r.attempts} attempt${r.attempts === 1 ? "" : "s"} → ${r.suggestedAction}`);
|
|
14922
|
-
}
|
|
14923
|
-
lines.push("");
|
|
14924
|
-
lines.push(`Auto-quarantine proposal: ${quarantineCount} test${quarantineCount === 1 ? "" : "s"} (retried ≥${QUARANTINE_MIN_ATTEMPTS} and failed at least once).`);
|
|
14925
|
-
lines.push("Quarantined tests should run nightly, not per-PR — quarantine is not deletion.");
|
|
14926
|
-
return lines.join("\n");
|
|
14927
|
-
}
|
|
14928
|
-
/** TRIAGE.md — the meeting artifact. */
|
|
14929
|
-
function renderTriageMd(report) {
|
|
14930
|
-
const rows = triageRows(report);
|
|
14808
|
+
/**
|
|
14809
|
+
* Default column budget when no width is supplied.
|
|
14810
|
+
*
|
|
14811
|
+
* `explain`'s prose used to be pushed as unbroken strings — the
|
|
14812
|
+
* "HOW TO VERIFY THE FIX" paragraph alone is 150 columns — so every
|
|
14813
|
+
* explanation overflowed a default terminal. Renderers here take a width
|
|
14814
|
+
* rather than reading process.stdout, so output stays a pure function of
|
|
14815
|
+
* its arguments (same rule the reporter's palette follows).
|
|
14816
|
+
*/
|
|
14817
|
+
const DEFAULT_EXPLAIN_WIDTH = 80;
|
|
14818
|
+
function renderExplain(result, width = DEFAULT_EXPLAIN_WIDTH) {
|
|
14819
|
+
if (!result.ok || !result.rule) return `explain failed: ${result.error ?? "unknown error"}`;
|
|
14820
|
+
const r = result.rule;
|
|
14821
|
+
const evidenceLevel = r.evidenceLevel ?? deriveEvidenceLevel(r.findingType, r.confidence);
|
|
14931
14822
|
const lines = [];
|
|
14932
|
-
|
|
14823
|
+
/** Pushes prose indented two columns, wrapped to the budget. */
|
|
14824
|
+
const pushBody = (text) => {
|
|
14825
|
+
for (const seg of wrapText(text, Math.max(20, width - 2))) lines.push(` ${seg}`);
|
|
14826
|
+
};
|
|
14827
|
+
lines.push(sectionHeader(`${r.id} — ${r.title}`, ui$12));
|
|
14933
14828
|
lines.push("");
|
|
14934
|
-
lines.push(
|
|
14829
|
+
lines.push(`Severity: ${r.severity}`);
|
|
14830
|
+
lines.push(`Confidence: ${r.confidence}`);
|
|
14831
|
+
lines.push(`Tier: ${effectiveTier(r)}${isProvisional(r) ? " (PROVISIONAL)" : ""}`);
|
|
14832
|
+
lines.push(`Evidence: ${evidenceLevel}`);
|
|
14833
|
+
lines.push(`QA impact: ${QA_IMPACT_LABELS[r.qaImpact]} (${r.qaImpact})`);
|
|
14834
|
+
const measured = MEASURED_FP[r.id];
|
|
14835
|
+
lines.push(measured ? `Measured FP: ${Math.round(measured.fpRate * 100)}% (${measured.n} hand-classified corpus verdicts)` : `Measured FP: not yet measured — this rule ships on assumption (see docs/FP-AUDIT.md)`);
|
|
14836
|
+
if (r.falsePositiveRisk) lines.push(`FP risk: ${r.falsePositiveRisk} (author estimate)`);
|
|
14837
|
+
if (r.languages?.length) lines.push(`Languages: ${r.languages.join(", ")}`);
|
|
14838
|
+
if (r.frameworks?.length) lines.push(`Frameworks: ${r.frameworks.join(", ")}`);
|
|
14935
14839
|
lines.push("");
|
|
14936
|
-
if (
|
|
14937
|
-
|
|
14938
|
-
|
|
14939
|
-
|
|
14940
|
-
|
|
14941
|
-
|
|
14942
|
-
|
|
14943
|
-
|
|
14944
|
-
lines.push(
|
|
14945
|
-
|
|
14946
|
-
|
|
14840
|
+
if (result.exampleFinding) {
|
|
14841
|
+
const f = result.exampleFinding;
|
|
14842
|
+
lines.push("WHAT WAS FOUND (real detector output, not a mockup)");
|
|
14843
|
+
pushBody(f.message);
|
|
14844
|
+
lines.push("");
|
|
14845
|
+
lines.push("WHY IT MATTERS");
|
|
14846
|
+
pushBody(f.why);
|
|
14847
|
+
lines.push("");
|
|
14848
|
+
lines.push("HOW TO FIX");
|
|
14849
|
+
pushBody(f.fix);
|
|
14850
|
+
lines.push("");
|
|
14851
|
+
pushBody(`Example from this rule's own must-fire fixture: ${result.exampleFixtureRelPath ?? result.exampleFixturePath ?? "(unknown path)"}`);
|
|
14852
|
+
} else for (const seg of wrapText("No example available — run this command from a mjolnir checkout (or pass --fixtures-root) so the fixture that proves this rule works can be shown as a real example.", width)) lines.push(seg);
|
|
14947
14853
|
lines.push("");
|
|
14948
|
-
lines.push(
|
|
14854
|
+
lines.push("HOW TO VERIFY THE FIX");
|
|
14855
|
+
pushBody("Re-run `mjolnir` on the changed file(s) — this finding should no longer appear. `mjolnir --scope changed` scopes the check to just what you touched.");
|
|
14949
14856
|
lines.push("");
|
|
14857
|
+
lines.push(`Docs: mjolnir rules --md (full catalog, this rule included)`);
|
|
14950
14858
|
return lines.join("\n");
|
|
14951
14859
|
}
|
|
14952
14860
|
//#endregion
|
|
@@ -15044,68 +14952,998 @@ function sweepStaleTempFiles(dir) {
|
|
|
15044
14952
|
return swept;
|
|
15045
14953
|
}
|
|
15046
14954
|
//#endregion
|
|
15047
|
-
//#region src/
|
|
15048
|
-
/**
|
|
15049
|
-
|
|
15050
|
-
|
|
15051
|
-
* Static JSON, no server. The badge makes falsifiable claims:
|
|
15052
|
-
* score + date + commit. Anyone can click through and verify.
|
|
15053
|
-
*/
|
|
15054
|
-
/**
|
|
15055
|
-
* Badge colors follow the SAME ScoreState bands as the terminal
|
|
15056
|
-
* (≥80 trusted / ≥50 warning / <50 critical / 100 forged) — this
|
|
15057
|
-
* retarget fixes the historical threshold drift (the badge used
|
|
15058
|
-
* ≥90/≥75/≥50 with four bands while the reporter used ≥80/≥50).
|
|
15059
|
-
*
|
|
15060
|
-
* Shields.io has no cyan or white-gold, so the mapping is documented
|
|
15061
|
-
* here: trusted → `important` (blue-family, closest to aurora-cyan),
|
|
15062
|
-
* forged → `success` (the strongest positive signal shields offers).
|
|
15063
|
-
* The badge is a peripheral surface; ScoreState remains the truth.
|
|
15064
|
-
*/
|
|
15065
|
-
function colorFor(score) {
|
|
15066
|
-
const band = deriveScoreState(score).band;
|
|
15067
|
-
if (band === "unmeasured") return "lightgrey";
|
|
15068
|
-
if (band === "forged") return "success";
|
|
15069
|
-
if (band === "trusted") return "important";
|
|
15070
|
-
return band === "warning" ? "yellow" : "red";
|
|
15071
|
-
}
|
|
15072
|
-
/** Build the shields.io endpoint payload from a scan result. */
|
|
15073
|
-
function buildBadge(result, commit) {
|
|
15074
|
-
const errors = result.findings.filter((f) => f.severity === "error").length;
|
|
15075
|
-
const score = result.score;
|
|
15076
|
-
return {
|
|
15077
|
-
schemaVersion: 1,
|
|
15078
|
-
label: "MJÖLNIR",
|
|
15079
|
-
message: score === null ? "no tests found" : score === 100 && errors === 0 ? "100/100 · forged" : `${score}/100 · ${errors} error${errors === 1 ? "" : "s"}`,
|
|
15080
|
-
color: colorFor(score),
|
|
15081
|
-
namedLogo: "vitest",
|
|
15082
|
-
...commit !== void 0 ? { commit } : {}
|
|
15083
|
-
};
|
|
14955
|
+
//#region src/engine/resolution.ts
|
|
14956
|
+
/** Correlation identity (v1-compatible): ruleId\0file\0message. */
|
|
14957
|
+
function fingerprint$2(entry) {
|
|
14958
|
+
return `${entry.ruleId}\u0000${entry.file}\u0000${entry.message}`;
|
|
15084
14959
|
}
|
|
15085
14960
|
/**
|
|
15086
|
-
*
|
|
15087
|
-
* (the falsifiable claim: "verified at commit X on date Y").
|
|
14961
|
+
* The §15 ordered algorithm. Pure; first match wins.
|
|
15088
14962
|
*/
|
|
15089
|
-
function
|
|
15090
|
-
|
|
15091
|
-
|
|
15092
|
-
|
|
15093
|
-
|
|
15094
|
-
|
|
15095
|
-
|
|
15096
|
-
|
|
15097
|
-
|
|
15098
|
-
|
|
15099
|
-
|
|
15100
|
-
|
|
15101
|
-
|
|
15102
|
-
|
|
15103
|
-
|
|
15104
|
-
|
|
15105
|
-
"
|
|
15106
|
-
|
|
15107
|
-
|
|
15108
|
-
|
|
14963
|
+
function resolve$1(input) {
|
|
14964
|
+
const { entry, baseline, current } = input;
|
|
14965
|
+
const fp = fingerprint$2(entry);
|
|
14966
|
+
const comparedAgainst = input.baselineCommit ?? baseline.commit ?? "unknown baseline";
|
|
14967
|
+
if (current.partial || current.analysisStatus.rules !== "complete") return {
|
|
14968
|
+
status: "INCONCLUSIVE",
|
|
14969
|
+
cause: "partial",
|
|
14970
|
+
comparedAgainst
|
|
14971
|
+
};
|
|
14972
|
+
if (input.crashedRuleIds?.has(entry.ruleId)) return {
|
|
14973
|
+
status: "INCONCLUSIVE",
|
|
14974
|
+
cause: "crash",
|
|
14975
|
+
comparedAgainst
|
|
14976
|
+
};
|
|
14977
|
+
if (input.skippedFiles?.has(entry.file)) return {
|
|
14978
|
+
status: "INCONCLUSIVE",
|
|
14979
|
+
cause: "skipped",
|
|
14980
|
+
comparedAgainst
|
|
14981
|
+
};
|
|
14982
|
+
if (input.suppressed?.has(`${entry.ruleId}\u0000${entry.file}`)) return {
|
|
14983
|
+
status: "SUPPRESSED",
|
|
14984
|
+
comparedAgainst
|
|
14985
|
+
};
|
|
14986
|
+
if (input.excludedFiles?.has(entry.file)) return {
|
|
14987
|
+
status: "DISAPPEARED-NON-FIX",
|
|
14988
|
+
cause: "excluded",
|
|
14989
|
+
comparedAgainst
|
|
14990
|
+
};
|
|
14991
|
+
const entryRev = entry.detectorRevision;
|
|
14992
|
+
if (entryRev === void 0) return {
|
|
14993
|
+
status: "INCONCLUSIVE",
|
|
14994
|
+
cause: "legacy-baseline",
|
|
14995
|
+
comparedAgainst
|
|
14996
|
+
};
|
|
14997
|
+
const registryRev = input.registryRevisions.get(entry.ruleId);
|
|
14998
|
+
if (registryRev === void 0) return {
|
|
14999
|
+
status: "DISAPPEARED-NON-FIX",
|
|
15000
|
+
cause: "retired",
|
|
15001
|
+
comparedAgainst
|
|
15002
|
+
};
|
|
15003
|
+
if (registryRev !== entryRev) return {
|
|
15004
|
+
status: "INCONCLUSIVE",
|
|
15005
|
+
cause: "revision-changed",
|
|
15006
|
+
comparedAgainst
|
|
15007
|
+
};
|
|
15008
|
+
if (current.findings.some((f) => fingerprint$2(f) === fp)) return {
|
|
15009
|
+
status: "STILL-PRESENT",
|
|
15010
|
+
comparedAgainst
|
|
15011
|
+
};
|
|
15012
|
+
return {
|
|
15013
|
+
status: "VERIFIED-RESOLVED",
|
|
15014
|
+
comparedAgainst
|
|
15015
|
+
};
|
|
15016
|
+
}
|
|
15017
|
+
/** The §15 rendering law: "FIXED" only for VERIFIED-RESOLVED. */
|
|
15018
|
+
function renderResolution(r) {
|
|
15019
|
+
switch (r.status) {
|
|
15020
|
+
case "VERIFIED-RESOLVED": return "FIXED SINCE BASELINE (verified by a complete same-revision scan)";
|
|
15021
|
+
case "STILL-PRESENT": return "STILL PRESENT";
|
|
15022
|
+
case "SUPPRESSED": return "SUPPRESSED (active ignore entry)";
|
|
15023
|
+
case "INCONCLUSIVE": return `INCONCLUSIVE (${r.cause})`;
|
|
15024
|
+
case "DISAPPEARED-NON-FIX": return `DISAPPEARED — NOT A FIX (${r.cause})`;
|
|
15025
|
+
}
|
|
15026
|
+
}
|
|
15027
|
+
//#endregion
|
|
15028
|
+
//#region src/commands/baseline.ts
|
|
15029
|
+
/**
|
|
15030
|
+
* `mjolnir baseline` / `mjolnir diff` — Sprint 6 Task 24
|
|
15031
|
+
* (Master-Stabilization-Plan.md).
|
|
15032
|
+
*
|
|
15033
|
+
* Implements Plan.md Phase 10 / §24's key insight: existing debt should
|
|
15034
|
+
* not block every PR — only NEW or WORSENED debt should. `baseline`
|
|
15035
|
+
* snapshots the current finding set to disk; `diff` compares the current
|
|
15036
|
+
* scan against that snapshot and reports only what changed.
|
|
15037
|
+
*
|
|
15038
|
+
* Findings are matched across scans by a stable fingerprint (ruleId +
|
|
15039
|
+
* file + message), not by line number — line numbers shift constantly as
|
|
15040
|
+
* a file is edited, so matching on them would report unrelated churn as
|
|
15041
|
+
* "new" debt and miss genuinely new findings that happen to land on a
|
|
15042
|
+
* previously-flagged line.
|
|
15043
|
+
*
|
|
15044
|
+
* Storage: .mjolnir/baseline.json (local; not gitignored — only
|
|
15045
|
+
* .mjolnir/logs/ is, see .gitignore). A team CAN commit this
|
|
15046
|
+
* file if they want a shared baseline; that's a deliberate choice this
|
|
15047
|
+
* command does not make for them.
|
|
15048
|
+
*/
|
|
15049
|
+
const ui$11 = plainContext();
|
|
15050
|
+
const DEFAULT_BASELINE_PATH = join(".mjolnir", "baseline.json");
|
|
15051
|
+
/**
|
|
15052
|
+
* Registry-declared detector revisions (§17): a baseline entry whose
|
|
15053
|
+
* revision differs from today's registry is INCONCLUSIVE(revision-
|
|
15054
|
+
* changed), never resolved. Omitted declarations mean revision 1 (the
|
|
15055
|
+
* documented RuleMeta default for first-generation detectors); a
|
|
15056
|
+
* ruleId ABSENT from this map means the rule is retired.
|
|
15057
|
+
*/
|
|
15058
|
+
const REGISTRY_REVISIONS = new Map(RULES.map((r) => [r.id, r.detectorRevision ?? 1]));
|
|
15059
|
+
/**
|
|
15060
|
+
* Correlation identity for before/after comparison (agent-handoff plan
|
|
15061
|
+
* §5.2): ruleId + file + message, deliberately EXCLUDING `line` — a
|
|
15062
|
+
* source edit that shifts a finding still correlates. file:line is an
|
|
15063
|
+
* occurrence location, not a durable identity; message rewording,
|
|
15064
|
+
* file renames and rule-id changes correlate as resolved+new
|
|
15065
|
+
* (documented limitation). Exported for the handoff verification
|
|
15066
|
+
* contract — do not duplicate this algorithm.
|
|
15067
|
+
*/
|
|
15068
|
+
function fingerprint$1(f) {
|
|
15069
|
+
return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
|
|
15070
|
+
}
|
|
15071
|
+
function buildBaseline(result, commit) {
|
|
15072
|
+
return {
|
|
15073
|
+
schemaVersion: 1,
|
|
15074
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
15075
|
+
commit,
|
|
15076
|
+
...result.score !== null ? { score: result.score } : {},
|
|
15077
|
+
findings: result.findings.map((f) => ({
|
|
15078
|
+
ruleId: f.ruleId,
|
|
15079
|
+
file: f.file,
|
|
15080
|
+
message: f.message,
|
|
15081
|
+
severity: f.severity,
|
|
15082
|
+
...f.detectorRevision !== void 0 ? { detectorRevision: f.detectorRevision } : {}
|
|
15083
|
+
}))
|
|
15084
|
+
};
|
|
15085
|
+
}
|
|
15086
|
+
/**
|
|
15087
|
+
* Bug-audit L7 / decision D3: overwriting an existing baseline used to
|
|
15088
|
+
* happen silently — a stale re-capture on a dirty tree made `diff`
|
|
15089
|
+
* report all old debt as new with no way to see what was lost. The
|
|
15090
|
+
* previous baseline is now backed up to `<path>.bak` and the caller
|
|
15091
|
+
* prints the notice.
|
|
15092
|
+
*/
|
|
15093
|
+
function saveBaseline(result, commit, outPath) {
|
|
15094
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
15095
|
+
const existed = existsSync(outPath);
|
|
15096
|
+
let backupPath;
|
|
15097
|
+
if (existed) {
|
|
15098
|
+
backupPath = `${outPath}.bak`;
|
|
15099
|
+
copyFileSync(outPath, backupPath);
|
|
15100
|
+
}
|
|
15101
|
+
writeFileAtomic(outPath, JSON.stringify(buildBaseline(result, commit), null, 2) + "\n");
|
|
15102
|
+
return {
|
|
15103
|
+
path: outPath,
|
|
15104
|
+
replaced: existed,
|
|
15105
|
+
...backupPath !== void 0 ? { backupPath } : {}
|
|
15106
|
+
};
|
|
15107
|
+
}
|
|
15108
|
+
function loadBaseline(path, onWarning) {
|
|
15109
|
+
if (!existsSync(path)) return null;
|
|
15110
|
+
try {
|
|
15111
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
15112
|
+
if (typeof parsed === "object" && parsed !== null && "findings" in parsed && Array.isArray(parsed.findings)) {
|
|
15113
|
+
const file = parsed;
|
|
15114
|
+
const version = parsed.schemaVersion;
|
|
15115
|
+
if (version === void 0) onWarning?.("baseline file has no schemaVersion (pre-versioning format) — treated as v1.");
|
|
15116
|
+
else if (version !== 1) {
|
|
15117
|
+
onWarning?.(`baseline file declares schemaVersion ${JSON.stringify(version)}; this Mjölnir understands v1 — baseline ignored (upgrade Mjölnir to diff it).`);
|
|
15118
|
+
return null;
|
|
15119
|
+
}
|
|
15120
|
+
const rawScore = parsed.score;
|
|
15121
|
+
const score = typeof rawScore === "number" && Number.isFinite(rawScore) ? rawScore : void 0;
|
|
15122
|
+
file.findings = file.findings.filter((f) => typeof f === "object" && f !== null && typeof f.ruleId === "string" && typeof f.file === "string" && typeof f.message === "string");
|
|
15123
|
+
return score === void 0 ? file : {
|
|
15124
|
+
...file,
|
|
15125
|
+
score
|
|
15126
|
+
};
|
|
15127
|
+
}
|
|
15128
|
+
return null;
|
|
15129
|
+
} catch {
|
|
15130
|
+
return null;
|
|
15131
|
+
}
|
|
15132
|
+
}
|
|
15133
|
+
function diffAgainstBaseline(result, baseline) {
|
|
15134
|
+
if (!baseline) return {
|
|
15135
|
+
hasBaseline: false,
|
|
15136
|
+
newFindings: [],
|
|
15137
|
+
resolvedFindings: [],
|
|
15138
|
+
unchangedCount: 0
|
|
15139
|
+
};
|
|
15140
|
+
const baseSet = /* @__PURE__ */ new Map();
|
|
15141
|
+
for (const f of baseline.findings) baseSet.set(fingerprint$1(f), f);
|
|
15142
|
+
const headKeys = /* @__PURE__ */ new Set();
|
|
15143
|
+
const newFindings = [];
|
|
15144
|
+
let unchangedCount = 0;
|
|
15145
|
+
for (const f of result.findings) {
|
|
15146
|
+
const key = fingerprint$1(f);
|
|
15147
|
+
headKeys.add(key);
|
|
15148
|
+
if (baseSet.has(key)) unchangedCount++;
|
|
15149
|
+
else newFindings.push(f);
|
|
15150
|
+
}
|
|
15151
|
+
const resolvedFindings = [];
|
|
15152
|
+
for (const [key, f] of baseSet) if (!headKeys.has(key)) {
|
|
15153
|
+
const resolution = resolve$1({
|
|
15154
|
+
entry: f,
|
|
15155
|
+
baseline,
|
|
15156
|
+
current: result,
|
|
15157
|
+
registryRevisions: REGISTRY_REVISIONS
|
|
15158
|
+
});
|
|
15159
|
+
resolvedFindings.push({
|
|
15160
|
+
...f,
|
|
15161
|
+
resolution
|
|
15162
|
+
});
|
|
15163
|
+
}
|
|
15164
|
+
return {
|
|
15165
|
+
hasBaseline: true,
|
|
15166
|
+
baselineCapturedAt: baseline.capturedAt,
|
|
15167
|
+
baselineCommit: baseline.commit,
|
|
15168
|
+
...baseline.score !== void 0 ? { baselineScore: baseline.score } : {},
|
|
15169
|
+
newFindings,
|
|
15170
|
+
resolvedFindings,
|
|
15171
|
+
unchangedCount
|
|
15172
|
+
};
|
|
15173
|
+
}
|
|
15174
|
+
function renderBaselineSaved(path, count, replaced) {
|
|
15175
|
+
const lines = [
|
|
15176
|
+
sectionHeader("BASELINE SAVED", ui$11),
|
|
15177
|
+
"",
|
|
15178
|
+
`Captured ${count} finding${count === 1 ? "" : "s"} to ${path}.`
|
|
15179
|
+
];
|
|
15180
|
+
if (replaced?.backupPath !== void 0) lines.push(`Replaced an existing baseline — the previous one was saved to ${replaced.backupPath}.`);
|
|
15181
|
+
lines.push(nextStep("mjolnir diff", ui$11) + " — see only what's new.");
|
|
15182
|
+
return lines.join("\n");
|
|
15183
|
+
}
|
|
15184
|
+
function renderBaselineDiff(diff) {
|
|
15185
|
+
const lines = [];
|
|
15186
|
+
lines.push(sectionHeader("DIFF AGAINST BASELINE", ui$11));
|
|
15187
|
+
lines.push("");
|
|
15188
|
+
if (!diff.hasBaseline) {
|
|
15189
|
+
lines.push("UNKNOWN — no baseline found.");
|
|
15190
|
+
lines.push(nextStep("mjolnir baseline", ui$11) + " to capture a comparison point.");
|
|
15191
|
+
return lines.join("\n");
|
|
15192
|
+
}
|
|
15193
|
+
lines.push(`Baseline captured ${diff.baselineCapturedAt ?? "unknown time"} at commit ${diff.baselineCommit ?? "unknown"}.`);
|
|
15194
|
+
lines.push(`${diff.unchangedCount} pre-existing finding${diff.unchangedCount === 1 ? "" : "s"} carried over — not reported as new.`);
|
|
15195
|
+
lines.push("");
|
|
15196
|
+
if (diff.newFindings.length === 0) lines.push("NEW OR WORSENED DEBT: none. This change introduced nothing new.");
|
|
15197
|
+
else {
|
|
15198
|
+
lines.push(`NEW OR WORSENED DEBT (${diff.newFindings.length}) — this is what should block this PR:`);
|
|
15199
|
+
for (const f of diff.newFindings) lines.push(` + ${f.ruleId} (${f.severity}) · ${f.file}:${f.line} — ${f.message}`);
|
|
15200
|
+
}
|
|
15201
|
+
lines.push("");
|
|
15202
|
+
if (diff.resolvedFindings.length > 0) {
|
|
15203
|
+
const verified = diff.resolvedFindings.filter((f) => f.resolution.status === "VERIFIED-RESOLVED");
|
|
15204
|
+
const unresolved = diff.resolvedFindings.filter((f) => f.resolution.status !== "VERIFIED-RESOLVED");
|
|
15205
|
+
if (verified.length > 0) {
|
|
15206
|
+
lines.push(`FIXED SINCE BASELINE (${verified.length}):`);
|
|
15207
|
+
for (const f of verified) lines.push(` ✓ ${f.ruleId} (${f.severity}) · ${f.file} — ${f.message}`);
|
|
15208
|
+
lines.push("");
|
|
15209
|
+
}
|
|
15210
|
+
if (unresolved.length > 0) {
|
|
15211
|
+
lines.push(`DISAPPEARED — NOT CLASSIFIED AS FIXED (${unresolved.length}):`);
|
|
15212
|
+
for (const f of unresolved) lines.push(` ${renderResolution(f.resolution)} · ${f.ruleId} (${f.severity}) · ${f.file} — ${f.message}`);
|
|
15213
|
+
}
|
|
15214
|
+
}
|
|
15215
|
+
return lines.join("\n");
|
|
15216
|
+
}
|
|
15217
|
+
//#endregion
|
|
15218
|
+
//#region src/mcp/server.ts
|
|
15219
|
+
/** Hard caps (§21 threat model: parameter size + resource bounds). */
|
|
15220
|
+
const MAX_PARAM_BYTES = 65536;
|
|
15221
|
+
const PROTOCOL_VERSION = "2025-06-18";
|
|
15222
|
+
/** JSON-RPC error codes (subset used here). */
|
|
15223
|
+
const MCP_ERRORS = {
|
|
15224
|
+
PARSE: -32700,
|
|
15225
|
+
INVALID_REQUEST: -32600,
|
|
15226
|
+
METHOD_NOT_FOUND: -32601,
|
|
15227
|
+
INVALID_PARAMS: -32602,
|
|
15228
|
+
INTERNAL: -32603
|
|
15229
|
+
};
|
|
15230
|
+
/** The tool catalog — 1:1 mappings to canonical verbs, nothing else. */
|
|
15231
|
+
const MCP_TOOLS = [
|
|
15232
|
+
{
|
|
15233
|
+
name: "scan",
|
|
15234
|
+
description: "Run the Mjölnir verification scan on a directory and return the canonical machine contract (findings with ruleId/detectorRevision/evidence/trust, completeness fields, deterministic digest). One scan in flight; zero network; plugin gate applies unchanged.",
|
|
15235
|
+
inputSchema: {
|
|
15236
|
+
type: "object",
|
|
15237
|
+
properties: {
|
|
15238
|
+
path: {
|
|
15239
|
+
type: "string",
|
|
15240
|
+
description: "Absolute or repo-relative directory to scan. Must exist."
|
|
15241
|
+
},
|
|
15242
|
+
maxDurationMs: {
|
|
15243
|
+
type: "number",
|
|
15244
|
+
description: "Optional analysis budget in ms (pipeline budget)."
|
|
15245
|
+
}
|
|
15246
|
+
},
|
|
15247
|
+
required: ["path"]
|
|
15248
|
+
}
|
|
15249
|
+
},
|
|
15250
|
+
{
|
|
15251
|
+
name: "explain",
|
|
15252
|
+
description: "Explain one rule: what it detects, why it matters, how to fix it, plus its measured FP rate and trust metadata. Read-only; never runs a scan.",
|
|
15253
|
+
inputSchema: {
|
|
15254
|
+
type: "object",
|
|
15255
|
+
properties: { ruleId: {
|
|
15256
|
+
type: "string",
|
|
15257
|
+
description: "e.g. QA-PW-101"
|
|
15258
|
+
} },
|
|
15259
|
+
required: ["ruleId"]
|
|
15260
|
+
}
|
|
15261
|
+
},
|
|
15262
|
+
{
|
|
15263
|
+
name: "diff",
|
|
15264
|
+
description: "Compare a completed scan result against the target's committed baseline (.mjolnir/baseline.json) using the §15 lifecycle resolution. Returns new findings and per-disappearance resolutions (VERIFIED-RESOLVED / INCONCLUSIVE(cause) / SUPPRESSED / DISAPPEARED-NON-FIX). Read-only.",
|
|
15265
|
+
inputSchema: {
|
|
15266
|
+
type: "object",
|
|
15267
|
+
properties: {
|
|
15268
|
+
path: {
|
|
15269
|
+
type: "string",
|
|
15270
|
+
description: "Scanned directory."
|
|
15271
|
+
},
|
|
15272
|
+
scanResult: {
|
|
15273
|
+
type: "object",
|
|
15274
|
+
description: "The canonical ScanResult JSON previously returned by the scan tool."
|
|
15275
|
+
}
|
|
15276
|
+
},
|
|
15277
|
+
required: ["path", "scanResult"]
|
|
15278
|
+
}
|
|
15279
|
+
}
|
|
15280
|
+
];
|
|
15281
|
+
/** Strict param validation — unknown/missing/mistyped ⇒ INVALID_PARAMS. */
|
|
15282
|
+
function validateParams(name, args) {
|
|
15283
|
+
if (JSON.stringify(args).length > MAX_PARAM_BYTES) return `parameters exceed ${MAX_PARAM_BYTES} bytes (threat model §21)`;
|
|
15284
|
+
if (name === "scan") {
|
|
15285
|
+
if (typeof args["path"] !== "string" || args["path"].length === 0) return "scan requires a non-empty string `path`";
|
|
15286
|
+
if (args["maxDurationMs"] !== void 0 && typeof args["maxDurationMs"] !== "number") return "`maxDurationMs` must be a number";
|
|
15287
|
+
}
|
|
15288
|
+
if (name === "explain") {
|
|
15289
|
+
if (typeof args["ruleId"] !== "string" || !/^QA-[A-Z]+-\d{3}$/.test(args["ruleId"])) return "explain requires `ruleId` matching /^QA-[A-Z]+-\\d{3}$/";
|
|
15290
|
+
}
|
|
15291
|
+
if (name === "diff") {
|
|
15292
|
+
if (typeof args["path"] !== "string" || args["path"].length === 0) return "diff requires a non-empty string `path`";
|
|
15293
|
+
if (typeof args["scanResult"] !== "object" || args["scanResult"] === null) return "diff requires the `scanResult` object from a previous scan tool call";
|
|
15294
|
+
}
|
|
15295
|
+
return null;
|
|
15296
|
+
}
|
|
15297
|
+
/**
|
|
15298
|
+
* Serialized queue for tool work (§21: ONE scan in flight). The queue
|
|
15299
|
+
* tail swallows each settled result so the chain never accumulates
|
|
15300
|
+
* rejections — the caller's own `next` still propagates errors to the
|
|
15301
|
+
* awaiting tool call (the `work, work` pair passes the rejection
|
|
15302
|
+
* through to `next` too, so `handleToolCall`'s catch converts it into
|
|
15303
|
+
* a structured INTERNAL response).
|
|
15304
|
+
*/
|
|
15305
|
+
let scanInFlight = Promise.resolve();
|
|
15306
|
+
function serializeScan(work) {
|
|
15307
|
+
const next = scanInFlight.then(work, work);
|
|
15308
|
+
scanInFlight = next.then(() => void 0, () => void 0);
|
|
15309
|
+
return next;
|
|
15310
|
+
}
|
|
15311
|
+
/**
|
|
15312
|
+
* Dispatch a tool call. Every tool maps 1:1 to canonical semantics —
|
|
15313
|
+
* the transport adds SHAPE, never MEANING.
|
|
15314
|
+
*/
|
|
15315
|
+
async function handleToolCall(call) {
|
|
15316
|
+
const paramError = validateParams(call.name, call.args);
|
|
15317
|
+
if (paramError !== null) return {
|
|
15318
|
+
jsonrpc: "2.0",
|
|
15319
|
+
id: call.id,
|
|
15320
|
+
error: {
|
|
15321
|
+
code: MCP_ERRORS.INVALID_PARAMS,
|
|
15322
|
+
message: paramError
|
|
15323
|
+
}
|
|
15324
|
+
};
|
|
15325
|
+
try {
|
|
15326
|
+
if (call.name === "scan") {
|
|
15327
|
+
const target = resolve(call.args["path"]);
|
|
15328
|
+
if (!existsSync(target)) return {
|
|
15329
|
+
jsonrpc: "2.0",
|
|
15330
|
+
id: call.id,
|
|
15331
|
+
error: {
|
|
15332
|
+
code: MCP_ERRORS.INVALID_PARAMS,
|
|
15333
|
+
message: `scan target does not exist: ${target}`
|
|
15334
|
+
}
|
|
15335
|
+
};
|
|
15336
|
+
const maxDurationMs = typeof call.args["maxDurationMs"] === "number" ? call.args["maxDurationMs"] : Number.POSITIVE_INFINITY;
|
|
15337
|
+
const result = await serializeScan(() => runScan({
|
|
15338
|
+
target,
|
|
15339
|
+
json: true,
|
|
15340
|
+
verbose: false,
|
|
15341
|
+
maxDurationMs,
|
|
15342
|
+
scopeChanged: false,
|
|
15343
|
+
format: "json"
|
|
15344
|
+
}));
|
|
15345
|
+
return {
|
|
15346
|
+
jsonrpc: "2.0",
|
|
15347
|
+
id: call.id,
|
|
15348
|
+
result: {
|
|
15349
|
+
...result,
|
|
15350
|
+
contract: buildMachineContract(result)
|
|
15351
|
+
}
|
|
15352
|
+
};
|
|
15353
|
+
}
|
|
15354
|
+
if (call.name === "explain") {
|
|
15355
|
+
const ruleId = call.args["ruleId"];
|
|
15356
|
+
const explanation = explainRule(ruleId, process.cwd());
|
|
15357
|
+
if (!explanation.ok) return {
|
|
15358
|
+
jsonrpc: "2.0",
|
|
15359
|
+
id: call.id,
|
|
15360
|
+
error: {
|
|
15361
|
+
code: MCP_ERRORS.INVALID_PARAMS,
|
|
15362
|
+
message: explanation.error
|
|
15363
|
+
}
|
|
15364
|
+
};
|
|
15365
|
+
return {
|
|
15366
|
+
jsonrpc: "2.0",
|
|
15367
|
+
id: call.id,
|
|
15368
|
+
result: explanation
|
|
15369
|
+
};
|
|
15370
|
+
}
|
|
15371
|
+
if (call.name === "diff") {
|
|
15372
|
+
const target = resolve(call.args["path"]);
|
|
15373
|
+
const scanResult = call.args["scanResult"];
|
|
15374
|
+
const baselinePath = join(target, DEFAULT_BASELINE_PATH);
|
|
15375
|
+
if (!existsSync(baselinePath)) return {
|
|
15376
|
+
jsonrpc: "2.0",
|
|
15377
|
+
id: call.id,
|
|
15378
|
+
result: {
|
|
15379
|
+
hasBaseline: false,
|
|
15380
|
+
note: "no committed baseline at .mjolnir/baseline.json — nothing to resolve against"
|
|
15381
|
+
}
|
|
15382
|
+
};
|
|
15383
|
+
const baseline = loadBaseline(baselinePath);
|
|
15384
|
+
if (baseline === null) return {
|
|
15385
|
+
jsonrpc: "2.0",
|
|
15386
|
+
id: call.id,
|
|
15387
|
+
result: {
|
|
15388
|
+
hasBaseline: false,
|
|
15389
|
+
note: "baseline failed to parse"
|
|
15390
|
+
}
|
|
15391
|
+
};
|
|
15392
|
+
const diff = diffAgainstBaseline(scanResult, baseline);
|
|
15393
|
+
return {
|
|
15394
|
+
jsonrpc: "2.0",
|
|
15395
|
+
id: call.id,
|
|
15396
|
+
result: {
|
|
15397
|
+
hasBaseline: true,
|
|
15398
|
+
baselineCapturedAt: diff.baselineCapturedAt,
|
|
15399
|
+
baselineCommit: diff.baselineCommit,
|
|
15400
|
+
newFindings: diff.newFindings,
|
|
15401
|
+
resolutions: diff.resolvedFindings.map((f) => ({
|
|
15402
|
+
ruleId: f.ruleId,
|
|
15403
|
+
file: f.file,
|
|
15404
|
+
message: f.message,
|
|
15405
|
+
resolution: f.resolution
|
|
15406
|
+
})),
|
|
15407
|
+
unchangedCount: diff.unchangedCount
|
|
15408
|
+
}
|
|
15409
|
+
};
|
|
15410
|
+
}
|
|
15411
|
+
return {
|
|
15412
|
+
jsonrpc: "2.0",
|
|
15413
|
+
id: call.id,
|
|
15414
|
+
error: {
|
|
15415
|
+
code: MCP_ERRORS.METHOD_NOT_FOUND,
|
|
15416
|
+
message: `unknown tool: ${call.name}`
|
|
15417
|
+
}
|
|
15418
|
+
};
|
|
15419
|
+
} catch (err) {
|
|
15420
|
+
return {
|
|
15421
|
+
jsonrpc: "2.0",
|
|
15422
|
+
id: call.id,
|
|
15423
|
+
error: {
|
|
15424
|
+
code: MCP_ERRORS.INTERNAL,
|
|
15425
|
+
message: errorMessage(err)
|
|
15426
|
+
}
|
|
15427
|
+
};
|
|
15428
|
+
}
|
|
15429
|
+
}
|
|
15430
|
+
/** Normalize any thrown value to a string (unit-tested both arms). */
|
|
15431
|
+
function errorMessage(err) {
|
|
15432
|
+
return err instanceof Error ? err.message : String(err);
|
|
15433
|
+
}
|
|
15434
|
+
/** JSON-RPC request shape validation (strict — §21). */
|
|
15435
|
+
function validateRequest(msg) {
|
|
15436
|
+
if (typeof msg !== "object" || msg === null) return {
|
|
15437
|
+
ok: false,
|
|
15438
|
+
errorCode: MCP_ERRORS.PARSE
|
|
15439
|
+
};
|
|
15440
|
+
const o = msg;
|
|
15441
|
+
if (o["jsonrpc"] !== "2.0") return {
|
|
15442
|
+
ok: false,
|
|
15443
|
+
errorCode: MCP_ERRORS.INVALID_REQUEST
|
|
15444
|
+
};
|
|
15445
|
+
if (typeof o["method"] !== "string") return {
|
|
15446
|
+
ok: false,
|
|
15447
|
+
errorCode: MCP_ERRORS.INVALID_REQUEST
|
|
15448
|
+
};
|
|
15449
|
+
if (o["id"] === void 0) return {
|
|
15450
|
+
ok: false,
|
|
15451
|
+
errorCode: MCP_ERRORS.INVALID_REQUEST
|
|
15452
|
+
};
|
|
15453
|
+
return {
|
|
15454
|
+
ok: true,
|
|
15455
|
+
id: o["id"],
|
|
15456
|
+
method: o["method"],
|
|
15457
|
+
params: o["params"]
|
|
15458
|
+
};
|
|
15459
|
+
}
|
|
15460
|
+
/**
|
|
15461
|
+
* Handle one JSON-RPC message (initialize / tools/list / tools/call).
|
|
15462
|
+
* Returns the response object, or null for notifications.
|
|
15463
|
+
*/
|
|
15464
|
+
async function handleMcpMessage(msg) {
|
|
15465
|
+
const req = validateRequest(msg);
|
|
15466
|
+
if (!req.ok) return {
|
|
15467
|
+
jsonrpc: "2.0",
|
|
15468
|
+
id: null,
|
|
15469
|
+
error: {
|
|
15470
|
+
code: req.errorCode,
|
|
15471
|
+
message: "malformed JSON-RPC request"
|
|
15472
|
+
}
|
|
15473
|
+
};
|
|
15474
|
+
if (req.method === "initialize") return {
|
|
15475
|
+
jsonrpc: "2.0",
|
|
15476
|
+
id: req.id,
|
|
15477
|
+
result: {
|
|
15478
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
15479
|
+
capabilities: { tools: {} },
|
|
15480
|
+
serverInfo: {
|
|
15481
|
+
name: "mjolnir-qa",
|
|
15482
|
+
version: CLI_VERSION
|
|
15483
|
+
}
|
|
15484
|
+
}
|
|
15485
|
+
};
|
|
15486
|
+
if (req.method === "tools/list") return {
|
|
15487
|
+
jsonrpc: "2.0",
|
|
15488
|
+
id: req.id,
|
|
15489
|
+
result: { tools: MCP_TOOLS }
|
|
15490
|
+
};
|
|
15491
|
+
if (req.method === "tools/call") {
|
|
15492
|
+
const params = req.params ?? {};
|
|
15493
|
+
const name = params["name"];
|
|
15494
|
+
if (typeof name !== "string") return {
|
|
15495
|
+
jsonrpc: "2.0",
|
|
15496
|
+
id: req.id,
|
|
15497
|
+
error: {
|
|
15498
|
+
code: MCP_ERRORS.INVALID_PARAMS,
|
|
15499
|
+
message: "tools/call requires a string `name`"
|
|
15500
|
+
}
|
|
15501
|
+
};
|
|
15502
|
+
const args = typeof params["arguments"] === "object" && params["arguments"] !== null ? params["arguments"] : {};
|
|
15503
|
+
const response = await handleToolCall({
|
|
15504
|
+
id: req.id,
|
|
15505
|
+
name,
|
|
15506
|
+
args
|
|
15507
|
+
});
|
|
15508
|
+
if (response.error !== void 0) return {
|
|
15509
|
+
jsonrpc: "2.0",
|
|
15510
|
+
id: req.id,
|
|
15511
|
+
error: response.error
|
|
15512
|
+
};
|
|
15513
|
+
const payload = response.result;
|
|
15514
|
+
return {
|
|
15515
|
+
jsonrpc: "2.0",
|
|
15516
|
+
id: req.id,
|
|
15517
|
+
result: {
|
|
15518
|
+
content: [{
|
|
15519
|
+
type: "text",
|
|
15520
|
+
text: JSON.stringify(payload, null, 2)
|
|
15521
|
+
}],
|
|
15522
|
+
structuredContent: payload
|
|
15523
|
+
}
|
|
15524
|
+
};
|
|
15525
|
+
}
|
|
15526
|
+
return {
|
|
15527
|
+
jsonrpc: "2.0",
|
|
15528
|
+
id: req.id,
|
|
15529
|
+
error: {
|
|
15530
|
+
code: MCP_ERRORS.METHOD_NOT_FOUND,
|
|
15531
|
+
message: `unknown method: ${req.method}`
|
|
15532
|
+
}
|
|
15533
|
+
};
|
|
15534
|
+
}
|
|
15535
|
+
//#endregion
|
|
15536
|
+
//#region src/mcp/transport.ts
|
|
15537
|
+
/**
|
|
15538
|
+
* MCP stdio transport loop (blueprint §21 — the framing around the
|
|
15539
|
+
* pure-transport server).
|
|
15540
|
+
*
|
|
15541
|
+
* Reads newline-delimited JSON-RPC 2.0 frames from `input`, writes one
|
|
15542
|
+
* response frame per request to `output`. Malformed input produces a
|
|
15543
|
+
* structured error response and the loop CONTINUES — the transport
|
|
15544
|
+
* stays alive by contract (§21). EOF resolves; the binary entrypoint
|
|
15545
|
+
* (stdio.ts) exits 0 after it.
|
|
15546
|
+
*/
|
|
15547
|
+
/**
|
|
15548
|
+
* Drive the transport over arbitrary streams (real stdio in the binary,
|
|
15549
|
+
* PassThrough in tests). Resolves on input EOF.
|
|
15550
|
+
*/
|
|
15551
|
+
async function runStdioTransport(input, output) {
|
|
15552
|
+
const rl = createInterface({ input });
|
|
15553
|
+
for await (const line of rl) await handleStdioLine(line, (s) => output.write(s));
|
|
15554
|
+
}
|
|
15555
|
+
/**
|
|
15556
|
+
* Process one newline-delimited JSON-RPC frame; write the response to
|
|
15557
|
+
* `write`. Never throws — malformed frames become structured errors and
|
|
15558
|
+
* the transport stays alive (§21).
|
|
15559
|
+
*/
|
|
15560
|
+
async function handleStdioLine(line, write) {
|
|
15561
|
+
const trimmed = line.trim();
|
|
15562
|
+
if (trimmed.length === 0) return;
|
|
15563
|
+
let parsed;
|
|
15564
|
+
try {
|
|
15565
|
+
parsed = JSON.parse(trimmed);
|
|
15566
|
+
} catch {
|
|
15567
|
+
write(`${JSON.stringify({
|
|
15568
|
+
jsonrpc: "2.0",
|
|
15569
|
+
id: null,
|
|
15570
|
+
error: {
|
|
15571
|
+
code: -32700,
|
|
15572
|
+
message: "parse error — input was not valid JSON"
|
|
15573
|
+
}
|
|
15574
|
+
})}\n`);
|
|
15575
|
+
return;
|
|
15576
|
+
}
|
|
15577
|
+
const response = await handleMcpMessage(parsed);
|
|
15578
|
+
write(`${JSON.stringify(response)}\n`);
|
|
15579
|
+
}
|
|
15580
|
+
//#endregion
|
|
15581
|
+
//#region src/integrations/ci-install.ts
|
|
15582
|
+
/**
|
|
15583
|
+
* CI integration (Sprint-Plan W7): generates .github/workflows/mjolnir.yml
|
|
15584
|
+
* from internal templates ONLY — no user-input interpolation (R3 supply-chain).
|
|
15585
|
+
* Default gate: advisory (report, never block).
|
|
15586
|
+
*
|
|
15587
|
+
* Bug-audit hardening (H2): the previous template shipped the same
|
|
15588
|
+
* `github.rest.checks` no-op this repo's own audit removed from mjolnir.yml,
|
|
15589
|
+
* failed the job before the annotate/summary steps could run whenever the
|
|
15590
|
+
* scan step exited non-zero, recommended floating `mjolnir-qa@latest`, and
|
|
15591
|
+
* `ciInstall` silently overwrote hand-customized workflows. The template now
|
|
15592
|
+
* mirrors the dogfooded `.github/workflows/mjolnir.yml` (pinned action SHAs,
|
|
15593
|
+
* `if: always()` on reporting steps, a real gate step that reads
|
|
15594
|
+
* `mjolnir.json`, partial scans never block) and `ciInstall` refuses to
|
|
15595
|
+
* replace a customized workflow without an explicit `--force`.
|
|
15596
|
+
*/
|
|
15597
|
+
/**
|
|
15598
|
+
* The gate-check script embedded in generated workflows (and executed
|
|
15599
|
+
* directly by tests against fixture JSONs). Semantics, kept in sync with
|
|
15600
|
+
* the tool's own exit-code contract:
|
|
15601
|
+
* - missing/unreadable `mjolnir.json` → fail (the scan step crashed; a
|
|
15602
|
+
* silent pass here would turn a broken pipeline into a green one);
|
|
15603
|
+
* - `partial: true` → never block (truncated results can neither prove
|
|
15604
|
+
* nor disprove the gate — the "PARTIAL" banner in the summary is the
|
|
15605
|
+
* honest signal);
|
|
15606
|
+
* - `error` gate → block on any error-severity finding;
|
|
15607
|
+
* - `warning` gate → block on warnings and errors.
|
|
15608
|
+
*/
|
|
15609
|
+
function gateScript(gate) {
|
|
15610
|
+
return [
|
|
15611
|
+
"const fs = require(\"fs\");",
|
|
15612
|
+
"let r;",
|
|
15613
|
+
"try {",
|
|
15614
|
+
" r = JSON.parse(fs.readFileSync(\"mjolnir.json\", \"utf8\"));",
|
|
15615
|
+
"} catch (e) {",
|
|
15616
|
+
" process.stderr.write(\"mjolnir.json is missing or unreadable - the scan step crashed before the gate could run. Failing instead of passing silently.\\n\");",
|
|
15617
|
+
" process.exit(1);",
|
|
15618
|
+
"}",
|
|
15619
|
+
"if (r.partial === true) {",
|
|
15620
|
+
" process.stdout.write(\"Scan was PARTIAL - some files were not analyzed; gate not enforced.\\n\");",
|
|
15621
|
+
" process.exit(0);",
|
|
15622
|
+
"}",
|
|
15623
|
+
"const findings = Array.isArray(r.findings) ? r.findings : [];",
|
|
15624
|
+
"const errors = findings.filter(function (f) { return f && f.severity === \"error\"; }).length;",
|
|
15625
|
+
"const warnings = findings.filter(function (f) { return f && f.severity === \"warning\"; }).length;",
|
|
15626
|
+
"process.stdout.write(\"Mjolnir gate: \" + errors + \" error(s), \" + warnings + \" warning(s).\\n\");",
|
|
15627
|
+
`if (${gate === "error" ? "errors > 0" : "errors > 0 || warnings > 0"}) { process.exit(1); }`,
|
|
15628
|
+
"process.exit(0);"
|
|
15629
|
+
].join("\n");
|
|
15630
|
+
}
|
|
15631
|
+
/**
|
|
15632
|
+
* Template v2 (Terminal + CI UX Overhaul plan, M4): the summary step
|
|
15633
|
+
* calls `mjolnir summary mjolnir.json` — annotations + step summary via
|
|
15634
|
+
* ONE emitter — instead of the v1 inline SUMMARY_SCRIPT. The gate
|
|
15635
|
+
* script is unchanged (reads `partial`, `findings[].severity`).
|
|
15636
|
+
*/
|
|
15637
|
+
/** Renders findings into `$GITHUB_STEP_SUMMARY`; tolerates a missing scan result.
|
|
15638
|
+
* v1 inline script, retained ONLY for overwrite-refusal recognition of
|
|
15639
|
+
* workflows generated by older versions (they are treated as ours, so
|
|
15640
|
+
* a frictionless `ci install` upgrade stays possible). Exported for the
|
|
15641
|
+
* recognition test that reconstructs the embedded form. */
|
|
15642
|
+
const SUMMARY_SCRIPT_V1 = [
|
|
15643
|
+
"const fs = require(\"fs\");",
|
|
15644
|
+
"let r = {};",
|
|
15645
|
+
"try { r = JSON.parse(fs.readFileSync(\"mjolnir.json\", \"utf8\")); } catch (e) {}",
|
|
15646
|
+
"const findings = Array.isArray(r.findings) ? r.findings : [];",
|
|
15647
|
+
"const lines = findings.map(function (f) {",
|
|
15648
|
+
" return \"- **\" + f.ruleId + \"** (\" + f.severity + \") \" + f.file + \":\" + f.line + \" - \" + f.message;",
|
|
15649
|
+
"});",
|
|
15650
|
+
"const head = r.partial === true",
|
|
15651
|
+
" ? \"Mjolnir scan was PARTIAL - some files may not have been analyzed.\"",
|
|
15652
|
+
" : \"Mjolnir scan finished.\";",
|
|
15653
|
+
"const body = [\"## Mjolnir findings\", head, \"\"].concat(lines).join(\"\\n\");",
|
|
15654
|
+
"if (process.env.GITHUB_STEP_SUMMARY && lines.length > 0) {",
|
|
15655
|
+
" fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, body + \"\\n\");",
|
|
15656
|
+
"}",
|
|
15657
|
+
"process.stdout.write(lines.length + \" finding(s)\\n\");"
|
|
15658
|
+
].join("\n");
|
|
15659
|
+
/** True when a workflow was generated by any Mjölnir template (v1 or v2).
|
|
15660
|
+
* The v1 needle is matched in its INDENTED form: the v1 template embedded
|
|
15661
|
+
* the script via indentBlock(…, 10) inside the `run: |` scalar, so the
|
|
15662
|
+
* raw unindented substring never appears in a real v1 file. */
|
|
15663
|
+
function isKnownTemplate(content) {
|
|
15664
|
+
return GATES.some((g) => content === TEMPLATE(g)) || content.includes(indentBlock(SUMMARY_SCRIPT_V1, 10));
|
|
15665
|
+
}
|
|
15666
|
+
/** Indents an embedded script so it sits inside a YAML `run: |` block scalar. */
|
|
15667
|
+
function indentBlock(text, spaces) {
|
|
15668
|
+
const pad = " ".repeat(spaces);
|
|
15669
|
+
return text.split("\n").map((l) => l.length > 0 ? pad + l : l).join("\n");
|
|
15670
|
+
}
|
|
15671
|
+
/** The generated workflow for one gate level. Exported for template tests. */
|
|
15672
|
+
const TEMPLATE = (gate) => `name: Mjölnir
|
|
15673
|
+
|
|
15674
|
+
on:
|
|
15675
|
+
pull_request:
|
|
15676
|
+
|
|
15677
|
+
concurrency:
|
|
15678
|
+
group: mjolnir-\${{ github.ref }}
|
|
15679
|
+
cancel-in-progress: true
|
|
15680
|
+
|
|
15681
|
+
permissions:
|
|
15682
|
+
contents: read
|
|
15683
|
+
pull-requests: write
|
|
15684
|
+
|
|
15685
|
+
jobs:
|
|
15686
|
+
scan:
|
|
15687
|
+
runs-on: ubuntu-latest
|
|
15688
|
+
# A hung scan must not sit for the 6-hour default.
|
|
15689
|
+
timeout-minutes: 10
|
|
15690
|
+
steps:
|
|
15691
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
15692
|
+
with:
|
|
15693
|
+
fetch-depth: 0 # needed for --scope changed merge-base
|
|
15694
|
+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
15695
|
+
with:
|
|
15696
|
+
node-version: 22
|
|
15697
|
+
# Scan with the PINNED version that generated this workflow — never
|
|
15698
|
+
# a floating tag: a new release must not change your gate semantics
|
|
15699
|
+
# with no commit of yours. To review PRs with the exact tool your
|
|
15700
|
+
# repo develops against, add mjolnir-qa to devDependencies and drop
|
|
15701
|
+
# the @version suffix so npx resolves the local install.
|
|
15702
|
+
- name: Scan changed code (exit 1/2 is data — the gate step decides)
|
|
15703
|
+
continue-on-error: true
|
|
15704
|
+
run: npx --yes mjolnir-qa@${CLI_VERSION} . --scope changed --json > mjolnir.json
|
|
15705
|
+
# Reporting, not gating: a crashed scan leaves mjolnir.json empty/missing
|
|
15706
|
+
# and the summary step exits 2/10 — continue-on-error keeps the advisory
|
|
15707
|
+
# job green, exactly like the v1 inline script did (the gate step decides).
|
|
15708
|
+
- name: Annotations + Job Summary
|
|
15709
|
+
if: always()
|
|
15710
|
+
continue-on-error: true
|
|
15711
|
+
run: npx --yes mjolnir-qa@${CLI_VERSION} summary mjolnir.json
|
|
15712
|
+
- name: Render PR comment
|
|
15713
|
+
if: always()
|
|
15714
|
+
continue-on-error: true
|
|
15715
|
+
run: npx --yes mjolnir-qa@${CLI_VERSION} pr-comment . > mjolnir-comment.md
|
|
15716
|
+
# Best-effort: on a pull_request event from a fork the GITHUB_TOKEN is
|
|
15717
|
+
# read-only and this step will 403 for every external contributor. The
|
|
15718
|
+
# Job Summary above is the fallback that always renders.
|
|
15719
|
+
# (pull_request_target would fix the token but is a code-execution
|
|
15720
|
+
# risk — deliberately NOT used.)
|
|
15721
|
+
- name: Post or update PR comment
|
|
15722
|
+
if: always()
|
|
15723
|
+
continue-on-error: true
|
|
15724
|
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
15725
|
+
with:
|
|
15726
|
+
script: |
|
|
15727
|
+
const fs = require('fs');
|
|
15728
|
+
let body = '';
|
|
15729
|
+
try { body = fs.readFileSync('mjolnir-comment.md', 'utf8'); } catch (e) {}
|
|
15730
|
+
if (!body.trim()) {
|
|
15731
|
+
console.log('mjolnir-comment.md is empty or missing — nothing to post.');
|
|
15732
|
+
return;
|
|
15733
|
+
}
|
|
15734
|
+
const marker = '<!-- mjolnir-pr-comment -->';
|
|
15735
|
+
const { data: comments } = await github.rest.issues.listComments({
|
|
15736
|
+
owner: context.repo.owner,
|
|
15737
|
+
repo: context.repo.repo,
|
|
15738
|
+
issue_number: context.issue.number,
|
|
15739
|
+
});
|
|
15740
|
+
const existing = comments.find((c) => c.body?.startsWith(marker));
|
|
15741
|
+
if (existing) {
|
|
15742
|
+
await github.rest.issues.updateComment({
|
|
15743
|
+
owner: context.repo.owner,
|
|
15744
|
+
repo: context.repo.repo,
|
|
15745
|
+
comment_id: existing.id,
|
|
15746
|
+
body,
|
|
15747
|
+
});
|
|
15748
|
+
} else {
|
|
15749
|
+
await github.rest.issues.createComment({
|
|
15750
|
+
owner: context.repo.owner,
|
|
15751
|
+
repo: context.repo.repo,
|
|
15752
|
+
issue_number: context.issue.number,
|
|
15753
|
+
body,
|
|
15754
|
+
});
|
|
15755
|
+
}
|
|
15756
|
+
${gate === "advisory" ? ` # Advisory mode: findings are reported in the Job Summary and the
|
|
15757
|
+
# PR comment, never blocking. The scan step's exit code is visible
|
|
15758
|
+
# as the step outcome, but continue-on-error keeps the job green.
|
|
15759
|
+
- name: Gate (advisory)
|
|
15760
|
+
if: always()
|
|
15761
|
+
run: echo "Advisory mode — findings reported, never blocking."` : ` # Gate enforcement: the scan step's own exit code is deliberately
|
|
15762
|
+
# neutralized (continue-on-error) so reporting steps always run; THIS
|
|
15763
|
+
# step is what fails the job. A partial scan never blocks.
|
|
15764
|
+
- name: Gate (${gate})
|
|
15765
|
+
if: always()
|
|
15766
|
+
run: |
|
|
15767
|
+
node -e '
|
|
15768
|
+
${indentBlock(gateScript(gate), 10)}
|
|
15769
|
+
'`}
|
|
15770
|
+
`;
|
|
15771
|
+
/** Every gate variant, used to tell "our template" from "hand-customized". */
|
|
15772
|
+
const GATES = [
|
|
15773
|
+
"advisory",
|
|
15774
|
+
"error",
|
|
15775
|
+
"warning"
|
|
15776
|
+
];
|
|
15777
|
+
/** Multiset line diff — counts only, for the refusal message. */
|
|
15778
|
+
function summarizeContentDiff(existing, incoming) {
|
|
15779
|
+
const remaining = /* @__PURE__ */ new Map();
|
|
15780
|
+
for (const line of existing.split(/\r?\n/)) remaining.set(line, (remaining.get(line) ?? 0) + 1);
|
|
15781
|
+
let added = 0;
|
|
15782
|
+
for (const line of incoming.split(/\r?\n/)) {
|
|
15783
|
+
const count = remaining.get(line) ?? 0;
|
|
15784
|
+
if (count > 0) remaining.set(line, count - 1);
|
|
15785
|
+
else added += 1;
|
|
15786
|
+
}
|
|
15787
|
+
let removed = 0;
|
|
15788
|
+
for (const count of remaining.values()) removed += count;
|
|
15789
|
+
return [` - ${removed} line(s) of your file are not in the template and would be removed`, ` - ${added} template line(s) are not in your file and would be added`];
|
|
15790
|
+
}
|
|
15791
|
+
function ciInstall(root, gate = "advisory", options = {}) {
|
|
15792
|
+
const wfDir = join(root, ".github", "workflows");
|
|
15793
|
+
const target = join(wfDir, "mjolnir.yml");
|
|
15794
|
+
if (!existsSync(wfDir)) mkdirSync(wfDir, { recursive: true });
|
|
15795
|
+
const existed = existsSync(target);
|
|
15796
|
+
if (existed) {
|
|
15797
|
+
const current = readFileSync(target, "utf8");
|
|
15798
|
+
if (!isKnownTemplate(current) && !(options.force ?? false)) return {
|
|
15799
|
+
written: target,
|
|
15800
|
+
existed,
|
|
15801
|
+
refused: true,
|
|
15802
|
+
diffSummary: summarizeContentDiff(current, TEMPLATE(gate))
|
|
15803
|
+
};
|
|
15804
|
+
}
|
|
15805
|
+
writeFileSync(target, TEMPLATE(gate));
|
|
15806
|
+
return {
|
|
15807
|
+
written: target,
|
|
15808
|
+
existed,
|
|
15809
|
+
refused: false,
|
|
15810
|
+
diffSummary: []
|
|
15811
|
+
};
|
|
15812
|
+
}
|
|
15813
|
+
//#endregion
|
|
15814
|
+
//#region src/forensics/triage.ts
|
|
15815
|
+
const ui$10 = plainContext();
|
|
15816
|
+
const QUARANTINE_MIN_ATTEMPTS = 2;
|
|
15817
|
+
/** Deterministic triage table rows, worst first. */
|
|
15818
|
+
function triageRows(report) {
|
|
15819
|
+
return report.verdicts.filter((v) => v.everFailed || v.passedOnRetry).sort((a, b) => {
|
|
15820
|
+
if (a.passedOnRetry !== b.passedOnRetry) return a.passedOnRetry ? -1 : 1;
|
|
15821
|
+
if (a.attempts !== b.attempts) return b.attempts - a.attempts;
|
|
15822
|
+
return b.totalDurationMs - a.totalDurationMs;
|
|
15823
|
+
}).map((v) => ({
|
|
15824
|
+
file: v.file,
|
|
15825
|
+
title: v.title,
|
|
15826
|
+
attempts: v.attempts,
|
|
15827
|
+
passedOnRetry: v.passedOnRetry,
|
|
15828
|
+
finalStatus: v.finalStatus,
|
|
15829
|
+
totalDurationMs: v.totalDurationMs,
|
|
15830
|
+
suggestedAction: suggestAction(v),
|
|
15831
|
+
proposedQuarantine: v.attempts >= QUARANTINE_MIN_ATTEMPTS && v.everFailed
|
|
15832
|
+
}));
|
|
15833
|
+
}
|
|
15834
|
+
function suggestAction(v) {
|
|
15835
|
+
if (v.passedOnRetry && v.attempts >= QUARANTINE_MIN_ATTEMPTS) return "quarantine + ticket";
|
|
15836
|
+
if (v.passedOnRetry) return "fix nondeterminism";
|
|
15837
|
+
if (v.finalStatus === "failed" || v.finalStatus === "timedOut") return "fix now — failing";
|
|
15838
|
+
return "investigate";
|
|
15839
|
+
}
|
|
15840
|
+
/** Terminal rendering of the triage proposal. */
|
|
15841
|
+
function renderTriage(report) {
|
|
15842
|
+
const rows = triageRows(report);
|
|
15843
|
+
const lines = [];
|
|
15844
|
+
lines.push(sectionHeader("FLAKY TRIAGE — auto-generated, do not edit", ui$10));
|
|
15845
|
+
lines.push("");
|
|
15846
|
+
if (rows.length === 0) {
|
|
15847
|
+
lines.push("Nothing to triage — no failures or retries in this run.");
|
|
15848
|
+
return lines.join("\n");
|
|
15849
|
+
}
|
|
15850
|
+
const quarantineCount = rows.filter((r) => r.proposedQuarantine).length;
|
|
15851
|
+
for (const r of rows) {
|
|
15852
|
+
const flag = r.passedOnRetry ? "TRUE-FLAKE" : "FAILING";
|
|
15853
|
+
lines.push(`• [${flag}] ${r.title} (${r.file}) — ${r.attempts} attempt${r.attempts === 1 ? "" : "s"} → ${r.suggestedAction}`);
|
|
15854
|
+
}
|
|
15855
|
+
lines.push("");
|
|
15856
|
+
lines.push(`Auto-quarantine proposal: ${quarantineCount} test${quarantineCount === 1 ? "" : "s"} (retried ≥${QUARANTINE_MIN_ATTEMPTS} and failed at least once).`);
|
|
15857
|
+
lines.push("Quarantined tests should run nightly, not per-PR — quarantine is not deletion.");
|
|
15858
|
+
return lines.join("\n");
|
|
15859
|
+
}
|
|
15860
|
+
/** TRIAGE.md — the meeting artifact. */
|
|
15861
|
+
function renderTriageMd(report) {
|
|
15862
|
+
const rows = triageRows(report);
|
|
15863
|
+
const lines = [];
|
|
15864
|
+
lines.push("# TRIAGE.md");
|
|
15865
|
+
lines.push("");
|
|
15866
|
+
lines.push("> Auto-generated by `mjolnir triage` from real run data. Do not edit by hand.");
|
|
15867
|
+
lines.push("");
|
|
15868
|
+
if (rows.length === 0) {
|
|
15869
|
+
lines.push("_No flaky or failing tests this run — nothing to triage._ 🎉");
|
|
15870
|
+
return lines.join("\n");
|
|
15871
|
+
}
|
|
15872
|
+
lines.push("| Status | Test | File | Attempts | Duration | Suggested action | Quarantine? |");
|
|
15873
|
+
lines.push("|--------|------|------|----------|----------|------------------|-------------|");
|
|
15874
|
+
for (const r of rows) {
|
|
15875
|
+
const status = r.passedOnRetry ? `${FLAKE_GLYPH} TRUE-FLAKE` : "❌ FAILING";
|
|
15876
|
+
lines.push(`| ${status} | \`${r.title}\` | \`${r.file}\` | ${r.attempts} | ${(r.totalDurationMs / 1e3).toFixed(1)}s | ${r.suggestedAction} | ${r.proposedQuarantine ? "✅ propose" : "—"} |`);
|
|
15877
|
+
}
|
|
15878
|
+
const q = rows.filter((r) => r.proposedQuarantine).length;
|
|
15879
|
+
lines.push("");
|
|
15880
|
+
lines.push(`**Auto-quarantine proposal: ${q}** — move them to the nightly suite and open tracked tickets.`);
|
|
15881
|
+
lines.push("");
|
|
15882
|
+
return lines.join("\n");
|
|
15883
|
+
}
|
|
15884
|
+
//#endregion
|
|
15885
|
+
//#region src/commands/badge.ts
|
|
15886
|
+
/**
|
|
15887
|
+
* `mjolnir badge` — evidentiary shields.io endpoint JSON (Tier 1 #5).
|
|
15888
|
+
*
|
|
15889
|
+
* Static JSON, no server. The badge makes falsifiable claims:
|
|
15890
|
+
* score + date + commit. Anyone can click through and verify.
|
|
15891
|
+
*/
|
|
15892
|
+
/**
|
|
15893
|
+
* Badge colors follow the SAME ScoreState bands as the terminal
|
|
15894
|
+
* (≥80 trusted / ≥50 warning / <50 critical / 100 forged) — this
|
|
15895
|
+
* retarget fixes the historical threshold drift (the badge used
|
|
15896
|
+
* ≥90/≥75/≥50 with four bands while the reporter used ≥80/≥50).
|
|
15897
|
+
*
|
|
15898
|
+
* Shields.io has no cyan or white-gold, so the mapping is documented
|
|
15899
|
+
* here: trusted → `important` (blue-family, closest to aurora-cyan),
|
|
15900
|
+
* forged → `success` (the strongest positive signal shields offers).
|
|
15901
|
+
* The badge is a peripheral surface; ScoreState remains the truth.
|
|
15902
|
+
*/
|
|
15903
|
+
function colorFor(score) {
|
|
15904
|
+
const band = deriveScoreState(score).band;
|
|
15905
|
+
if (band === "unmeasured") return "lightgrey";
|
|
15906
|
+
if (band === "forged") return "success";
|
|
15907
|
+
if (band === "trusted") return "important";
|
|
15908
|
+
return band === "warning" ? "yellow" : "red";
|
|
15909
|
+
}
|
|
15910
|
+
/** Build the shields.io endpoint payload from a scan result. */
|
|
15911
|
+
function buildBadge(result, commit) {
|
|
15912
|
+
const errors = result.findings.filter((f) => f.severity === "error").length;
|
|
15913
|
+
const score = result.score;
|
|
15914
|
+
return {
|
|
15915
|
+
schemaVersion: 1,
|
|
15916
|
+
label: "MJÖLNIR",
|
|
15917
|
+
message: score === null ? "no tests found" : score === 100 && errors === 0 ? "100/100 · forged" : `${score}/100 · ${errors} error${errors === 1 ? "" : "s"}`,
|
|
15918
|
+
color: colorFor(score),
|
|
15919
|
+
namedLogo: "vitest",
|
|
15920
|
+
...commit !== void 0 ? { commit } : {}
|
|
15921
|
+
};
|
|
15922
|
+
}
|
|
15923
|
+
/**
|
|
15924
|
+
* Full README-ready markdown snippet with commit-bound verification line
|
|
15925
|
+
* (the falsifiable claim: "verified at commit X on date Y").
|
|
15926
|
+
*/
|
|
15927
|
+
function renderBadgeSnippet(result, repoUrl = "https://github.com/Sergey-Bar/Mjolnir") {
|
|
15928
|
+
let commit = "unknown";
|
|
15929
|
+
try {
|
|
15930
|
+
commit = execSync("git rev-parse --short HEAD", {
|
|
15931
|
+
cwd: process.cwd(),
|
|
15932
|
+
encoding: "utf8",
|
|
15933
|
+
stdio: [
|
|
15934
|
+
"ignore",
|
|
15935
|
+
"pipe",
|
|
15936
|
+
"ignore"
|
|
15937
|
+
]
|
|
15938
|
+
}).trim();
|
|
15939
|
+
} catch {}
|
|
15940
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
15941
|
+
const errors = result.findings.filter((f) => f.severity === "error").length;
|
|
15942
|
+
return [
|
|
15943
|
+
"```markdown",
|
|
15944
|
+
"[](" + repoUrl + ")",
|
|
15945
|
+
"<!-- Mjölnir verified at commit " + commit + " on " + date + ": " + (result.score === null ? "no tests found" : result.score + "/100") + ", " + errors + " blocking error(s) -->",
|
|
15946
|
+
"```"
|
|
15109
15947
|
].join("\n");
|
|
15110
15948
|
}
|
|
15111
15949
|
function writeBadge(result, options) {
|
|
@@ -15236,1142 +16074,886 @@ const HELP_ENTRIES = [
|
|
|
15236
16074
|
{
|
|
15237
16075
|
verb: "rules",
|
|
15238
16076
|
summary: "rule catalog with trust metadata (md/json/unmeasured)",
|
|
15239
|
-
usage: "mjolnir rules [--md] [--unmeasured|--measured] [--external]",
|
|
15240
|
-
examples: ["mjolnir rules --md --unmeasured"]
|
|
15241
|
-
},
|
|
15242
|
-
{
|
|
15243
|
-
verb: "suppressions",
|
|
15244
|
-
summary: "list suppressed findings (governance transparency)",
|
|
15245
|
-
usage: "mjolnir suppressions",
|
|
15246
|
-
examples: ["mjolnir suppressions"]
|
|
15247
|
-
},
|
|
15248
|
-
{
|
|
15249
|
-
verb: "create-rule",
|
|
15250
|
-
summary: "scaffold a new rule + fixtures (must-fire, must-not-fire)",
|
|
15251
|
-
usage: "mjolnir create-rule <QA-XXX-nnn> --title \"Rule title\"",
|
|
15252
|
-
examples: ["mjolnir create-rule QA-PW-131 --title \"No request waits\""]
|
|
15253
|
-
},
|
|
15254
|
-
{
|
|
15255
|
-
verb: "doctor",
|
|
15256
|
-
summary: "self-audit of the rule base (fixture firewall, tiers, caps)",
|
|
15257
|
-
usage: "mjolnir doctor [repo-root]",
|
|
15258
|
-
examples: ["mjolnir doctor"]
|
|
15259
|
-
},
|
|
15260
|
-
{
|
|
15261
|
-
verb: "install",
|
|
15262
|
-
summary: "install the agent instruction surfaces + optional staged hook",
|
|
15263
|
-
usage: "mjolnir install [--staged-hook] [--dry-run] [--force]",
|
|
15264
|
-
examples: ["mjolnir install --dry-run", "mjolnir install --staged-hook"]
|
|
15265
|
-
}
|
|
15266
|
-
];
|
|
15267
|
-
/** Scan-flag entries documented per-flag via the overview. */
|
|
15268
|
-
const HELP_FLAGS = [
|
|
15269
|
-
{
|
|
15270
|
-
flag: "--json",
|
|
15271
|
-
summary: "machine-readable output"
|
|
15272
|
-
},
|
|
15273
|
-
{
|
|
15274
|
-
flag: "--format sarif",
|
|
15275
|
-
summary: "SARIF 2.1 for GitHub Code Scanning"
|
|
15276
|
-
},
|
|
15277
|
-
{
|
|
15278
|
-
flag: "--format mermaid",
|
|
15279
|
-
summary: "test-architecture diagram"
|
|
15280
|
-
},
|
|
15281
|
-
{
|
|
15282
|
-
flag: "--tone blunt",
|
|
15283
|
-
summary: "blunter, pattern-mocking messages"
|
|
15284
|
-
},
|
|
15285
|
-
{
|
|
15286
|
-
flag: "--verbose",
|
|
15287
|
-
summary: "show all findings"
|
|
15288
|
-
},
|
|
15289
|
-
{
|
|
15290
|
-
flag: "--scope changed",
|
|
15291
|
-
summary: "only new/changed lines vs merge-base"
|
|
15292
|
-
},
|
|
15293
|
-
{
|
|
15294
|
-
flag: "--max-duration <sec>",
|
|
15295
|
-
summary: "analysis time budget"
|
|
15296
|
-
},
|
|
15297
|
-
{
|
|
15298
|
-
flag: "--width <cols>",
|
|
15299
|
-
summary: "override terminal width"
|
|
15300
|
-
},
|
|
15301
|
-
{
|
|
15302
|
-
flag: "--ascii / --no-ascii",
|
|
15303
|
-
summary: "force glyph mode"
|
|
15304
|
-
},
|
|
15305
|
-
{
|
|
15306
|
-
flag: "--strict",
|
|
15307
|
-
summary: "include quarantine-tier rules"
|
|
15308
|
-
},
|
|
15309
|
-
{
|
|
15310
|
-
flag: "--debug",
|
|
15311
|
-
summary: "print swallowed rule crashes"
|
|
15312
|
-
},
|
|
15313
|
-
{
|
|
15314
|
-
flag: "--cache",
|
|
15315
|
-
summary: "reuse local per-file verdicts"
|
|
15316
|
-
},
|
|
15317
|
-
{
|
|
15318
|
-
flag: "--no-progress",
|
|
15319
|
-
summary: "no live scan-progress line on stderr"
|
|
15320
|
-
},
|
|
15321
|
-
{
|
|
15322
|
-
flag: "--score",
|
|
15323
|
-
summary: "print only the numeric score (or `unknown`)"
|
|
15324
|
-
},
|
|
15325
|
-
{
|
|
15326
|
-
flag: "--category <cat>",
|
|
15327
|
-
summary: "presentation filter (repeatable)"
|
|
15328
|
-
},
|
|
15329
|
-
{
|
|
15330
|
-
flag: "--staged",
|
|
15331
|
-
summary: "scan only git staged files"
|
|
15332
|
-
},
|
|
15333
|
-
{
|
|
15334
|
-
flag: "--blocking <level>",
|
|
15335
|
-
summary: "exit-status override: error|warning|none"
|
|
15336
|
-
},
|
|
15337
|
-
{
|
|
15338
|
-
flag: "--enable-plugins",
|
|
15339
|
-
summary: "allow npm/JS-module rules (default OFF)"
|
|
15340
|
-
}
|
|
15341
|
-
];
|
|
15342
|
-
const EXIT_CODE_TABLE = [
|
|
15343
|
-
["0", "clean — no findings or the requested artifact was produced"],
|
|
15344
|
-
["1", "errors found (or the diff/impact verdict says the PR should not merge)"],
|
|
15345
|
-
["2", "partial — the scan ran but was truncated, or input was unreadable"],
|
|
15346
|
-
["10", "usage — bad flags or arguments; help is printed"],
|
|
15347
|
-
["20", "crash — internal error; rerun with --debug for the stack trace"]
|
|
15348
|
-
];
|
|
15349
|
-
function findEntry(verb) {
|
|
15350
|
-
return HELP_ENTRIES.find((e) => e.verb === verb);
|
|
15351
|
-
}
|
|
15352
|
-
/** True when `mjolnir help <verb>` has a detailed page. */
|
|
15353
|
-
function hasVerbHelp(verb) {
|
|
15354
|
-
return findEntry(verb) !== void 0;
|
|
15355
|
-
}
|
|
15356
|
-
/** One per-verb help page: summary, usage, examples, next step. */
|
|
15357
|
-
function renderVerbHelp(verb) {
|
|
15358
|
-
const e = findEntry(verb);
|
|
15359
|
-
if (!e) return [
|
|
15360
|
-
` No detailed help for "${verb}".`,
|
|
15361
|
-
"",
|
|
15362
|
-
" $ mjolnir --help",
|
|
15363
|
-
""
|
|
15364
|
-
].join("\n");
|
|
15365
|
-
const lines = [];
|
|
15366
|
-
lines.push(` ${e.verb} — ${e.summary}`);
|
|
15367
|
-
lines.push("");
|
|
15368
|
-
lines.push(` Usage:`);
|
|
15369
|
-
lines.push(` ${e.usage}`);
|
|
15370
|
-
lines.push("");
|
|
15371
|
-
lines.push(` Examples:`);
|
|
15372
|
-
for (const ex of e.examples) lines.push(` $ ${ex}`);
|
|
15373
|
-
if (e.next) {
|
|
15374
|
-
lines.push("");
|
|
15375
|
-
lines.push(` Next step:`);
|
|
15376
|
-
lines.push(` $ ${e.next}`);
|
|
15377
|
-
}
|
|
15378
|
-
lines.push("");
|
|
15379
|
-
return lines.join("\n");
|
|
15380
|
-
}
|
|
15381
|
-
const DOCS_URL = "https://github.com/Sergey-Bar/Mjolnir#readme";
|
|
15382
|
-
/** The overview's grouped one-line sections, in display order. */
|
|
15383
|
-
const GROUPS = [
|
|
16077
|
+
usage: "mjolnir rules [--md] [--unmeasured|--measured] [--external]",
|
|
16078
|
+
examples: ["mjolnir rules --md --unmeasured"]
|
|
16079
|
+
},
|
|
15384
16080
|
{
|
|
15385
|
-
|
|
15386
|
-
|
|
16081
|
+
verb: "suppressions",
|
|
16082
|
+
summary: "list suppressed findings (governance transparency)",
|
|
16083
|
+
usage: "mjolnir suppressions",
|
|
16084
|
+
examples: ["mjolnir suppressions"]
|
|
15387
16085
|
},
|
|
15388
16086
|
{
|
|
15389
|
-
|
|
15390
|
-
|
|
15391
|
-
|
|
15392
|
-
|
|
15393
|
-
"pr-comment",
|
|
15394
|
-
"badge",
|
|
15395
|
-
"impact",
|
|
15396
|
-
"baseline",
|
|
15397
|
-
"diff"
|
|
15398
|
-
]
|
|
16087
|
+
verb: "create-rule",
|
|
16088
|
+
summary: "scaffold a new rule + fixtures (must-fire, must-not-fire)",
|
|
16089
|
+
usage: "mjolnir create-rule <QA-XXX-nnn> --title \"Rule title\"",
|
|
16090
|
+
examples: ["mjolnir create-rule QA-PW-131 --title \"No request waits\""]
|
|
15399
16091
|
},
|
|
15400
16092
|
{
|
|
15401
|
-
|
|
15402
|
-
|
|
15403
|
-
|
|
15404
|
-
|
|
15405
|
-
"pw-report",
|
|
15406
|
-
"doctor:playwright"
|
|
15407
|
-
]
|
|
16093
|
+
verb: "doctor",
|
|
16094
|
+
summary: "self-audit of the rule base (fixture firewall, tiers, caps)",
|
|
16095
|
+
usage: "mjolnir doctor [repo-root]",
|
|
16096
|
+
examples: ["mjolnir doctor"]
|
|
15408
16097
|
},
|
|
15409
16098
|
{
|
|
15410
|
-
|
|
15411
|
-
|
|
15412
|
-
|
|
15413
|
-
|
|
15414
|
-
"stats",
|
|
15415
|
-
"suppressions",
|
|
15416
|
-
"handover",
|
|
15417
|
-
"init",
|
|
15418
|
-
"doctor",
|
|
15419
|
-
"create-rule"
|
|
15420
|
-
]
|
|
16099
|
+
verb: "install",
|
|
16100
|
+
summary: "install the agent instruction surfaces + optional staged hook",
|
|
16101
|
+
usage: "mjolnir install [--staged-hook] [--dry-run] [--force]",
|
|
16102
|
+
examples: ["mjolnir install --dry-run", "mjolnir install --staged-hook"]
|
|
15421
16103
|
},
|
|
15422
16104
|
{
|
|
15423
|
-
|
|
15424
|
-
|
|
15425
|
-
|
|
15426
|
-
|
|
15427
|
-
"why",
|
|
15428
|
-
"handoff",
|
|
15429
|
-
"install"
|
|
15430
|
-
]
|
|
16105
|
+
verb: "mcp",
|
|
16106
|
+
summary: "run as an MCP server over stdio (scan / explain / diff tools)",
|
|
16107
|
+
usage: "mjolnir mcp",
|
|
16108
|
+
examples: ["mjolnir mcp"]
|
|
15431
16109
|
}
|
|
15432
16110
|
];
|
|
15433
|
-
|
|
15434
|
-
|
|
15435
|
-
|
|
15436
|
-
|
|
15437
|
-
|
|
15438
|
-
* caller decides (runHelpCommand passes a resolved palette; printUsage
|
|
15439
|
-
* stays plain).
|
|
15440
|
-
*/
|
|
15441
|
-
function renderRootHelp(schemaVersion = 1) {
|
|
15442
|
-
const byVerb = new Map(HELP_ENTRIES.map((e) => [e.verb, e]));
|
|
15443
|
-
const lines = [];
|
|
15444
|
-
lines.push("🔨 mjölnir — verification trust engine for test suites and CI pipelines");
|
|
15445
|
-
lines.push("");
|
|
15446
|
-
lines.push("Usage: mjolnir [path] [options] · mjolnir <subcommand> [args] · mjolnir help <verb>");
|
|
15447
|
-
lines.push("");
|
|
15448
|
-
lines.push("The product is one command in CI:");
|
|
15449
|
-
lines.push("");
|
|
15450
|
-
lines.push(" mjolnir --scope changed scan only what the branch touched; exit 1 on");
|
|
15451
|
-
lines.push(" new findings. `mjolnir ci install` writes the");
|
|
15452
|
-
lines.push(" workflow for you.");
|
|
15453
|
-
lines.push("");
|
|
15454
|
-
lines.push("Everything else is optional.");
|
|
15455
|
-
lines.push("");
|
|
15456
|
-
lines.push(" " + SCAN_SUMMARY_LINES[0]);
|
|
15457
|
-
lines.push(" mjolnir explain <RULE-ID> what/why/fix + measured FP rate for one rule");
|
|
15458
|
-
lines.push(" mjolnir rules --unmeasured the rules running on assumption, not measurement");
|
|
15459
|
-
lines.push("");
|
|
15460
|
-
lines.push("Options:");
|
|
15461
|
-
for (const f of HELP_FLAGS) {
|
|
15462
|
-
const pad = f.flag.padEnd(22);
|
|
15463
|
-
lines.push(` ${pad}${f.summary}`);
|
|
15464
|
-
}
|
|
15465
|
-
lines.push(" -v, --version print the installed version and exit");
|
|
15466
|
-
lines.push(" -h, --help show this help");
|
|
15467
|
-
lines.push("");
|
|
15468
|
-
for (const g of GROUPS) {
|
|
15469
|
-
lines.push(`Subcommands — ${g.title}:`);
|
|
15470
|
-
for (const verb of g.verbs) {
|
|
15471
|
-
const e = byVerb.get(verb);
|
|
15472
|
-
if (!e) continue;
|
|
15473
|
-
const usage = e.usage.replace(/^mjolnir /, "").padEnd(46);
|
|
15474
|
-
lines.push(` ${usage}${e.summary}`);
|
|
15475
|
-
}
|
|
15476
|
-
lines.push("");
|
|
15477
|
-
}
|
|
15478
|
-
lines.push("Copy-paste starts:");
|
|
15479
|
-
lines.push(" $ mjolnir score this repo's test suite");
|
|
15480
|
-
lines.push(" $ mjolnir --scope changed CI gate: only what the branch touched");
|
|
15481
|
-
lines.push(" $ mjolnir ci install write the PR workflow");
|
|
15482
|
-
lines.push(" $ mjolnir forensics test-results where the flakes hide");
|
|
15483
|
-
lines.push("");
|
|
15484
|
-
lines.push("Per-command help: mjolnir help <verb> (e.g. mjolnir help fix)");
|
|
15485
|
-
lines.push("");
|
|
15486
|
-
lines.push(`Exit codes: ${EXIT_CODE_TABLE.map(([c]) => c).join(" · ")}`);
|
|
15487
|
-
for (const [code, meaning] of EXIT_CODE_TABLE) lines.push(` ${code.padEnd(3)} ${meaning}`);
|
|
15488
|
-
lines.push("");
|
|
15489
|
-
lines.push(`Docs: ${DOCS_URL} (JSON schemaVersion ${schemaVersion}, additive-only)`);
|
|
15490
|
-
return lines.join("\n");
|
|
15491
|
-
}
|
|
15492
|
-
//#endregion
|
|
15493
|
-
//#region src/commands/debt.ts
|
|
15494
|
-
const ui$11 = plainContext();
|
|
15495
|
-
/** Cost model (documented, conservative): hours/quarter per occurrence. */
|
|
15496
|
-
const COST_MODEL = {
|
|
15497
|
-
"QA-TEST-004": {
|
|
15498
|
-
label: "Hard sleeps",
|
|
15499
|
-
hoursPerOccurrence: .4
|
|
15500
|
-
},
|
|
15501
|
-
"QA-PY-005": {
|
|
15502
|
-
label: "Hard sleeps",
|
|
15503
|
-
hoursPerOccurrence: .4
|
|
15504
|
-
},
|
|
15505
|
-
"QA-PW-118": {
|
|
15506
|
-
label: "Network-idle waits",
|
|
15507
|
-
hoursPerOccurrence: .3
|
|
15508
|
-
},
|
|
15509
|
-
"QA-TEST-002": {
|
|
15510
|
-
label: "Skipped tests",
|
|
15511
|
-
hoursPerOccurrence: .2
|
|
15512
|
-
},
|
|
15513
|
-
"QA-PY-002": {
|
|
15514
|
-
label: "Skipped tests",
|
|
15515
|
-
hoursPerOccurrence: .2
|
|
15516
|
-
},
|
|
15517
|
-
"QA-TEST-003": {
|
|
15518
|
-
label: "No-assertion tests",
|
|
15519
|
-
hoursPerOccurrence: .25
|
|
15520
|
-
},
|
|
15521
|
-
"QA-PY-003": {
|
|
15522
|
-
label: "No-assertion tests",
|
|
15523
|
-
hoursPerOccurrence: .25
|
|
16111
|
+
/** Scan-flag entries documented per-flag via the overview. */
|
|
16112
|
+
const HELP_FLAGS = [
|
|
16113
|
+
{
|
|
16114
|
+
flag: "--json",
|
|
16115
|
+
summary: "machine-readable output"
|
|
15524
16116
|
},
|
|
15525
|
-
|
|
15526
|
-
|
|
15527
|
-
|
|
16117
|
+
{
|
|
16118
|
+
flag: "--format sarif",
|
|
16119
|
+
summary: "SARIF 2.1 for GitHub Code Scanning"
|
|
15528
16120
|
},
|
|
15529
|
-
|
|
15530
|
-
|
|
15531
|
-
|
|
16121
|
+
{
|
|
16122
|
+
flag: "--format mermaid",
|
|
16123
|
+
summary: "test-architecture diagram"
|
|
15532
16124
|
},
|
|
15533
|
-
|
|
15534
|
-
|
|
15535
|
-
|
|
16125
|
+
{
|
|
16126
|
+
flag: "--tone blunt",
|
|
16127
|
+
summary: "blunter, pattern-mocking messages"
|
|
15536
16128
|
},
|
|
15537
|
-
|
|
15538
|
-
|
|
15539
|
-
|
|
16129
|
+
{
|
|
16130
|
+
flag: "--verbose",
|
|
16131
|
+
summary: "show all findings"
|
|
15540
16132
|
},
|
|
15541
|
-
"QA-ENV-001": {
|
|
15542
|
-
label: "Environment coupling",
|
|
15543
|
-
hoursPerOccurrence: .5
|
|
15544
|
-
}
|
|
15545
|
-
};
|
|
15546
|
-
function computeDebt(result) {
|
|
15547
|
-
const byLabel = /* @__PURE__ */ new Map();
|
|
15548
|
-
for (const f of result.findings) {
|
|
15549
|
-
const entry = COST_MODEL[f.ruleId];
|
|
15550
|
-
if (!entry) continue;
|
|
15551
|
-
const cur = byLabel.get(entry.label) ?? {
|
|
15552
|
-
count: 0,
|
|
15553
|
-
hours: 0,
|
|
15554
|
-
ruleIds: /* @__PURE__ */ new Set()
|
|
15555
|
-
};
|
|
15556
|
-
cur.count += 1;
|
|
15557
|
-
cur.hours += entry.hoursPerOccurrence;
|
|
15558
|
-
cur.ruleIds.add(f.ruleId);
|
|
15559
|
-
byLabel.set(entry.label, cur);
|
|
15560
|
-
}
|
|
15561
|
-
const classes = [...byLabel.entries()].map(([label, v]) => ({
|
|
15562
|
-
label,
|
|
15563
|
-
count: v.count,
|
|
15564
|
-
estHoursPerQuarter: Math.round(v.hours * 10) / 10,
|
|
15565
|
-
ruleIds: [...v.ruleIds].sort((a, b) => a.localeCompare(b))
|
|
15566
|
-
})).sort((a, b) => b.estHoursPerQuarter - a.estHoursPerQuarter);
|
|
15567
|
-
return {
|
|
15568
|
-
classes,
|
|
15569
|
-
totalHours: Math.round(classes.reduce((s, c) => s + c.estHoursPerQuarter, 0) * 10) / 10
|
|
15570
|
-
};
|
|
15571
|
-
}
|
|
15572
|
-
function renderDebt(result) {
|
|
15573
|
-
const { classes, totalHours } = computeDebt(result);
|
|
15574
|
-
const lines = [];
|
|
15575
|
-
lines.push(sectionHeader("TEST DEBT REGISTER", ui$11));
|
|
15576
|
-
lines.push("");
|
|
15577
|
-
if (classes.length === 0) {
|
|
15578
|
-
lines.push("No tracked debt classes found — the suite is clean.");
|
|
15579
|
-
return lines.join("\n");
|
|
15580
|
-
}
|
|
15581
|
-
const rows = ["DEBT CLASS", ""];
|
|
15582
|
-
rows[0] = `DEBT CLASS COUNT EST. HOURS/QUARTER`;
|
|
15583
|
-
for (const c of classes) rows.push(`${c.label.padEnd(26)} ${String(c.count).padStart(5)} ${c.estHoursPerQuarter.toFixed(1).padStart(8)}`);
|
|
15584
|
-
rows.push(`TOTAL ESTIMATED DRAG: ~${totalHours.toFixed(1)} engineer-hours/qtr`);
|
|
15585
|
-
for (const row of panel(rows, ui$11)) lines.push(row);
|
|
15586
|
-
lines.push("");
|
|
15587
|
-
lines.push("Cost model is conservative and documented in src/commands/debt.ts.");
|
|
15588
|
-
return lines.join("\n");
|
|
15589
|
-
}
|
|
15590
|
-
//#endregion
|
|
15591
|
-
//#region src/commands/rule-families.ts
|
|
15592
|
-
const RULE_FAMILIES = [
|
|
15593
16133
|
{
|
|
15594
|
-
|
|
15595
|
-
|
|
15596
|
-
category: "QA-TEST",
|
|
15597
|
-
appliesTo: "test-files"
|
|
16134
|
+
flag: "--scope changed",
|
|
16135
|
+
summary: "only new/changed lines vs merge-base"
|
|
15598
16136
|
},
|
|
15599
16137
|
{
|
|
15600
|
-
|
|
15601
|
-
|
|
15602
|
-
category: "QA-TQUAL",
|
|
15603
|
-
appliesTo: "test-files"
|
|
16138
|
+
flag: "--max-duration <sec>",
|
|
16139
|
+
summary: "analysis time budget"
|
|
15604
16140
|
},
|
|
15605
16141
|
{
|
|
15606
|
-
|
|
15607
|
-
|
|
15608
|
-
category: "QA-PW",
|
|
15609
|
-
appliesTo: "test-files"
|
|
16142
|
+
flag: "--width <cols>",
|
|
16143
|
+
summary: "override terminal width"
|
|
15610
16144
|
},
|
|
15611
16145
|
{
|
|
15612
|
-
|
|
15613
|
-
|
|
15614
|
-
category: "QA-CI",
|
|
15615
|
-
appliesTo: "ci-workflows"
|
|
16146
|
+
flag: "--ascii / --no-ascii",
|
|
16147
|
+
summary: "force glyph mode"
|
|
15616
16148
|
},
|
|
15617
16149
|
{
|
|
15618
|
-
|
|
15619
|
-
|
|
15620
|
-
category: "QA-PY",
|
|
15621
|
-
appliesTo: "python"
|
|
16150
|
+
flag: "--strict",
|
|
16151
|
+
summary: "include quarantine-tier rules"
|
|
15622
16152
|
},
|
|
15623
16153
|
{
|
|
15624
|
-
|
|
15625
|
-
|
|
15626
|
-
category: "QA-ENV",
|
|
15627
|
-
appliesTo: "test-files"
|
|
16154
|
+
flag: "--debug",
|
|
16155
|
+
summary: "print swallowed rule crashes"
|
|
15628
16156
|
},
|
|
15629
16157
|
{
|
|
15630
|
-
|
|
15631
|
-
|
|
15632
|
-
category: "QA-JV",
|
|
15633
|
-
appliesTo: "java"
|
|
16158
|
+
flag: "--cache",
|
|
16159
|
+
summary: "reuse local per-file verdicts"
|
|
15634
16160
|
},
|
|
15635
16161
|
{
|
|
15636
|
-
|
|
15637
|
-
|
|
15638
|
-
category: "QA-CS",
|
|
15639
|
-
appliesTo: "csharp"
|
|
16162
|
+
flag: "--no-progress",
|
|
16163
|
+
summary: "no live scan-progress line on stderr"
|
|
15640
16164
|
},
|
|
15641
16165
|
{
|
|
15642
|
-
|
|
15643
|
-
|
|
15644
|
-
category: "QA-CYP",
|
|
15645
|
-
appliesTo: "test-files"
|
|
16166
|
+
flag: "--score",
|
|
16167
|
+
summary: "print only the numeric score (or `unknown`)"
|
|
15646
16168
|
},
|
|
15647
16169
|
{
|
|
15648
|
-
|
|
15649
|
-
|
|
15650
|
-
category: "QA-SE",
|
|
15651
|
-
appliesTo: "test-files"
|
|
16170
|
+
flag: "--category <cat>",
|
|
16171
|
+
summary: "presentation filter (repeatable)"
|
|
15652
16172
|
},
|
|
15653
16173
|
{
|
|
15654
|
-
|
|
15655
|
-
|
|
15656
|
-
category: "QA-WDIO",
|
|
15657
|
-
appliesTo: "test-files"
|
|
16174
|
+
flag: "--staged",
|
|
16175
|
+
summary: "scan only git staged files"
|
|
15658
16176
|
},
|
|
15659
16177
|
{
|
|
15660
|
-
|
|
15661
|
-
|
|
15662
|
-
category: "QA-PPTR",
|
|
15663
|
-
appliesTo: "test-files"
|
|
16178
|
+
flag: "--blocking <level>",
|
|
16179
|
+
summary: "exit-status override: error|warning|none"
|
|
15664
16180
|
},
|
|
15665
16181
|
{
|
|
15666
|
-
|
|
15667
|
-
|
|
15668
|
-
category: "QA-APM",
|
|
15669
|
-
appliesTo: "test-files"
|
|
16182
|
+
flag: "--enable-plugins",
|
|
16183
|
+
summary: "allow npm/JS-module rules (default OFF)"
|
|
15670
16184
|
}
|
|
15671
16185
|
];
|
|
15672
|
-
|
|
15673
|
-
|
|
15674
|
-
|
|
15675
|
-
|
|
15676
|
-
|
|
15677
|
-
|
|
15678
|
-
|
|
15679
|
-
|
|
15680
|
-
|
|
15681
|
-
/**
|
|
15682
|
-
* `mjolnir create-rule <ID> --title "..."` — rule scaffold generator
|
|
15683
|
-
* (Tier 6 #34, Contribution Surface Engineering).
|
|
15684
|
-
*
|
|
15685
|
-
* Generates the four files every rule MUST ship with (anti-creep law):
|
|
15686
|
-
* src/rules/<family>/qa-<id-lower>.ts — the rule
|
|
15687
|
-
* tests/fixtures/<ID>/must-fire/ — at least one must-fire fixture
|
|
15688
|
-
* tests/fixtures/<ID>/must-not-fire/ — at least one must-not fixture
|
|
15689
|
-
* and prints the exact registry edit to make.
|
|
15690
|
-
*
|
|
15691
|
-
* The generated rule intentionally FAILS its fixtures until the author
|
|
15692
|
-
* implements it — you cannot ship a stub.
|
|
15693
|
-
*/
|
|
15694
|
-
const ui$10 = plainContext();
|
|
15695
|
-
function parseId(id) {
|
|
15696
|
-
if (!RULE_ID_RE.exec(id)) return null;
|
|
15697
|
-
const family = familyByToken(id.slice(3, id.length - 4));
|
|
15698
|
-
return {
|
|
15699
|
-
family: family.dir,
|
|
15700
|
-
dir: family.dir,
|
|
15701
|
-
category: family.category,
|
|
15702
|
-
appliesTo: family.appliesTo,
|
|
15703
|
-
num: id.slice(-3),
|
|
15704
|
-
lower: id.toLowerCase()
|
|
15705
|
-
};
|
|
15706
|
-
}
|
|
15707
|
-
function createRuleScaffold(input, rootDir) {
|
|
15708
|
-
const parsed = parseId(input.id);
|
|
15709
|
-
if (!parsed) return {
|
|
15710
|
-
ok: false,
|
|
15711
|
-
error: "Invalid rule ID. Expected QA-<FAMILY>-NNN with one of the registered families (TEST, TQUAL, PW, CI, PY, ENV, JV, CS, CYP, SE, WDIO, PPTR, APM).",
|
|
15712
|
-
files: [],
|
|
15713
|
-
registryEdit: ""
|
|
15714
|
-
};
|
|
15715
|
-
if (!input.title || input.title.trim().length === 0) return {
|
|
15716
|
-
ok: false,
|
|
15717
|
-
error: "A --title is required.",
|
|
15718
|
-
files: [],
|
|
15719
|
-
registryEdit: ""
|
|
15720
|
-
};
|
|
15721
|
-
const dirRel = join("src", "rules", parsed.dir);
|
|
15722
|
-
const ruleRel = join(dirRel, `${parsed.lower}.ts`);
|
|
15723
|
-
const absRule = join(rootDir, ruleRel);
|
|
15724
|
-
if (existsSync(absRule)) return {
|
|
15725
|
-
ok: false,
|
|
15726
|
-
error: `Rule file already exists: ${ruleRel}`,
|
|
15727
|
-
files: [],
|
|
15728
|
-
registryEdit: ""
|
|
15729
|
-
};
|
|
15730
|
-
mkdirSync(join(rootDir, "src", "rules", parsed.dir), { recursive: true });
|
|
15731
|
-
const files = [];
|
|
15732
|
-
writeFileAtomic(absRule, `/**
|
|
15733
|
-
* ${input.id} — ${input.title}.
|
|
15734
|
-
*
|
|
15735
|
-
* TODO(implement): replace the placeholder below. The rule currently
|
|
15736
|
-
* returns no findings on purpose so the fixture harness FAILS until
|
|
15737
|
-
* real detection logic lands (anti-creep law §18.1).
|
|
15738
|
-
*/
|
|
15739
|
-
|
|
15740
|
-
import { defineRule } from "../rule.js";
|
|
15741
|
-
import type { Finding } from "../../types.js";
|
|
15742
|
-
|
|
15743
|
-
export const ${camel(parsed.lower)} = defineRule({
|
|
15744
|
-
id: "${input.id}",
|
|
15745
|
-
category: "${parsed.category}",
|
|
15746
|
-
title: ${JSON.stringify(input.title)},
|
|
15747
|
-
severity: "warning",
|
|
15748
|
-
confidence: "medium",
|
|
15749
|
-
findingType: "heuristic-risk",
|
|
15750
|
-
qaImpact: "HYGIENE",
|
|
15751
|
-
appliesTo: ${JSON.stringify(parsed.appliesTo)},
|
|
15752
|
-
run(ctx) {
|
|
15753
|
-
const findings: Omit<Finding, "ruleId" | "category">[] = [];
|
|
15754
|
-
void ctx; // TODO: implement detection over ctx.path / ctx.text
|
|
15755
|
-
return findings;
|
|
15756
|
-
},
|
|
15757
|
-
});
|
|
15758
|
-
`);
|
|
15759
|
-
files.push(ruleRel);
|
|
15760
|
-
for (const direction of ["must-fire", "must-not-fire"]) {
|
|
15761
|
-
const dirRel = join("tests", "fixtures", input.id, direction);
|
|
15762
|
-
const absDir = join(rootDir, dirRel);
|
|
15763
|
-
mkdirSync(absDir, { recursive: true });
|
|
15764
|
-
const isPy = parsed.family === "python";
|
|
15765
|
-
const name = isPy ? `example.${direction}.py` : `example.${direction}.ts`;
|
|
15766
|
-
const content = isPy ? `# ${input.id} ${direction} fixture — replace with a real case.\n` : `// ${input.id} ${direction} fixture — replace with a real case.\n`;
|
|
15767
|
-
writeFileAtomic(join(absDir, name), content);
|
|
15768
|
-
files.push(join(dirRel, name));
|
|
15769
|
-
}
|
|
15770
|
-
const exportName = camel(parsed.lower);
|
|
15771
|
-
return {
|
|
15772
|
-
ok: true,
|
|
15773
|
-
files,
|
|
15774
|
-
registryEdit: [
|
|
15775
|
-
`// 1. Add import to src/rules/index.ts:`,
|
|
15776
|
-
`import { ${exportName} } from "./${parsed.dir}/${parsed.lower}.js";`,
|
|
15777
|
-
`// 2. Add to the RULES array:`,
|
|
15778
|
-
` ${exportName},`
|
|
15779
|
-
].join("\n")
|
|
15780
|
-
};
|
|
15781
|
-
}
|
|
15782
|
-
function camel(idLower) {
|
|
15783
|
-
return idLower.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
|
|
15784
|
-
}
|
|
15785
|
-
function renderScaffoldReport(result) {
|
|
15786
|
-
if (!result.ok) return `create-rule failed: ${result.error}`;
|
|
15787
|
-
const lines = [];
|
|
15788
|
-
lines.push(sectionHeader("RULE SCAFFOLD CREATED", ui$10));
|
|
15789
|
-
lines.push("");
|
|
15790
|
-
for (const f of result.files) lines.push(` + ${f}`);
|
|
15791
|
-
lines.push("");
|
|
15792
|
-
lines.push(result.registryEdit);
|
|
15793
|
-
lines.push("");
|
|
15794
|
-
lines.push("Next steps:", " 1. Implement detection logic in the rule's run()", " 2. Replace both fixture skeletons with real cases", " 3. Run: npm test — must-fire AND must-not-fire must pass", " 4. A rule whose fixtures fail CANNOT ship (anti-creep law)");
|
|
15795
|
-
lines.push("");
|
|
15796
|
-
lines.push("Note: running the test suite right now will show the new fixtures FAILING. This is intentional, not a bug in the scaffold — the rule above returns zero findings on purpose, so its must-fire fixture cannot pass until you implement real detection logic. A rule that ships as a silent no-op stub would violate the fixture-firewall law without anyone noticing.");
|
|
15797
|
-
return lines.join("\n");
|
|
15798
|
-
}
|
|
15799
|
-
//#endregion
|
|
15800
|
-
//#region src/commands/handover.ts
|
|
15801
|
-
const ui$9 = plainContext();
|
|
15802
|
-
const FAKE_GREEN_RULES = /* @__PURE__ */ new Set([
|
|
15803
|
-
"QA-TEST-003",
|
|
15804
|
-
"QA-PY-003",
|
|
15805
|
-
"QA-TEST-010",
|
|
15806
|
-
"QA-PY-006",
|
|
15807
|
-
"QA-TQUAL-002",
|
|
15808
|
-
"QA-PY-012"
|
|
15809
|
-
]);
|
|
15810
|
-
const FLAKY_RULES = /* @__PURE__ */ new Set([
|
|
15811
|
-
"QA-TEST-004",
|
|
15812
|
-
"QA-PY-005",
|
|
15813
|
-
"QA-PW-118",
|
|
15814
|
-
"QA-PW-114",
|
|
15815
|
-
"QA-TEST-006",
|
|
15816
|
-
"QA-ENV-001"
|
|
15817
|
-
]);
|
|
15818
|
-
const CI_TRUST_RULES = /* @__PURE__ */ new Set([
|
|
15819
|
-
"QA-CI-001",
|
|
15820
|
-
"QA-CI-002",
|
|
15821
|
-
"QA-CI-005",
|
|
15822
|
-
"QA-CI-007",
|
|
15823
|
-
"QA-CI-008"
|
|
15824
|
-
]);
|
|
15825
|
-
/** Group findings by directory to name "solid" areas. */
|
|
15826
|
-
function dirOf(file) {
|
|
15827
|
-
const idx = file.lastIndexOf("/");
|
|
15828
|
-
return idx === -1 ? file : file.slice(0, idx);
|
|
16186
|
+
const EXIT_CODE_TABLE = [
|
|
16187
|
+
["0", "clean — no findings or the requested artifact was produced"],
|
|
16188
|
+
["1", "errors found (or the diff/impact verdict says the PR should not merge)"],
|
|
16189
|
+
["2", "partial — the scan ran but was truncated, or input was unreadable"],
|
|
16190
|
+
["10", "usage — bad flags or arguments; help is printed"],
|
|
16191
|
+
["20", "crash — internal error; rerun with --debug for the stack trace"]
|
|
16192
|
+
];
|
|
16193
|
+
function findEntry(verb) {
|
|
16194
|
+
return HELP_ENTRIES.find((e) => e.verb === verb);
|
|
15829
16195
|
}
|
|
15830
|
-
|
|
15831
|
-
|
|
15832
|
-
|
|
15833
|
-
const ciTrust = [];
|
|
15834
|
-
const touchedDirs = /* @__PURE__ */ new Map();
|
|
15835
|
-
for (const f of scan.findings) {
|
|
15836
|
-
if (FAKE_GREEN_RULES.has(f.ruleId)) fakeGreen.push(f);
|
|
15837
|
-
else if (FLAKY_RULES.has(f.ruleId)) flaky.push(f);
|
|
15838
|
-
else if (CI_TRUST_RULES.has(f.ruleId)) ciTrust.push(f);
|
|
15839
|
-
const d = dirOf(f.file);
|
|
15840
|
-
touchedDirs.set(d, (touchedDirs.get(d) ?? 0) + 1);
|
|
15841
|
-
}
|
|
15842
|
-
const sections = [];
|
|
15843
|
-
const flakyFromRuns = forensics?.verdicts.filter((v) => v.passedOnRetry || v.everFailed) ?? [];
|
|
15844
|
-
const flakyItems = [];
|
|
15845
|
-
for (const v of flakyFromRuns.slice(0, 5)) flakyItems.push(`${v.title} (${v.file}) — ${v.attempts} attempt${v.attempts === 1 ? "" : "s"}${v.passedOnRetry ? ", TRUE-FLAKE" : ""} → see FLAKY.md`);
|
|
15846
|
-
for (const f of flaky.slice(0, 3)) flakyItems.push(`${f.file}:${f.line} — ${f.message}`);
|
|
15847
|
-
if (flakyItems.length > 0) sections.push({
|
|
15848
|
-
heading: "🔴 Known flaky / timing-sensitive",
|
|
15849
|
-
items: flakyItems
|
|
15850
|
-
});
|
|
15851
|
-
if (fakeGreen.length > 0) sections.push({
|
|
15852
|
-
heading: "🟡 Fake-green suspects (tests that can't fail)",
|
|
15853
|
-
items: fakeGreen.slice(0, 5).map((f) => `${f.file}:${f.line} — ${f.message}`)
|
|
15854
|
-
});
|
|
15855
|
-
if (ciTrust.length > 0) sections.push({
|
|
15856
|
-
heading: "⚠ CI trust warnings (green ≠ verified)",
|
|
15857
|
-
items: ciTrust.slice(0, 4).map((f) => `${f.file}:${f.line} — ${f.message}`)
|
|
15858
|
-
});
|
|
15859
|
-
if (new Set(scan.findings.map((f) => f.file)).size === 0 && scan.score !== null && scan.score >= 90 || scan.findings.length === 0) sections.push({
|
|
15860
|
-
heading: "🟢 Solid foundation",
|
|
15861
|
-
items: ["No tracked anti-patterns found in scanned specs — good place to start contributing."]
|
|
15862
|
-
});
|
|
15863
|
-
const totalIssues = fakeGreen.length + flaky.length + ciTrust.length + (forensics?.flakyTests ?? 0);
|
|
15864
|
-
return {
|
|
15865
|
-
sections,
|
|
15866
|
-
summaryLine: totalIssues === 0 ? "Welcome aboard — the suite is in good shape." : `${totalIssues} thing${totalIssues === 1 ? "" : "s"} to know about before your first release sign-off.`
|
|
15867
|
-
};
|
|
16196
|
+
/** True when `mjolnir help <verb>` has a detailed page. */
|
|
16197
|
+
function hasVerbHelp(verb) {
|
|
16198
|
+
return findEntry(verb) !== void 0;
|
|
15868
16199
|
}
|
|
15869
|
-
|
|
16200
|
+
/** One per-verb help page: summary, usage, examples, next step. */
|
|
16201
|
+
function renderVerbHelp(verb) {
|
|
16202
|
+
const e = findEntry(verb);
|
|
16203
|
+
if (!e) return [
|
|
16204
|
+
` No detailed help for "${verb}".`,
|
|
16205
|
+
"",
|
|
16206
|
+
" $ mjolnir --help",
|
|
16207
|
+
""
|
|
16208
|
+
].join("\n");
|
|
15870
16209
|
const lines = [];
|
|
15871
|
-
lines.push(
|
|
16210
|
+
lines.push(` ${e.verb} — ${e.summary}`);
|
|
15872
16211
|
lines.push("");
|
|
15873
|
-
lines.push(
|
|
15874
|
-
|
|
16212
|
+
lines.push(` Usage:`);
|
|
16213
|
+
lines.push(` ${e.usage}`);
|
|
16214
|
+
lines.push("");
|
|
16215
|
+
lines.push(` Examples:`);
|
|
16216
|
+
for (const ex of e.examples) lines.push(` $ ${ex}`);
|
|
16217
|
+
if (e.next) {
|
|
15875
16218
|
lines.push("");
|
|
15876
|
-
lines.push(
|
|
15877
|
-
|
|
16219
|
+
lines.push(` Next step:`);
|
|
16220
|
+
lines.push(` $ ${e.next}`);
|
|
15878
16221
|
}
|
|
15879
16222
|
lines.push("");
|
|
15880
|
-
lines.push("Generated by mjolnir handover — re-run after big refactors.");
|
|
15881
16223
|
return lines.join("\n");
|
|
15882
16224
|
}
|
|
15883
|
-
|
|
15884
|
-
|
|
16225
|
+
const DOCS_URL = "https://github.com/Sergey-Bar/Mjolnir#readme";
|
|
16226
|
+
/** The overview's grouped one-line sections, in display order. */
|
|
16227
|
+
const GROUPS = [
|
|
16228
|
+
{
|
|
16229
|
+
title: "Scan",
|
|
16230
|
+
verbs: []
|
|
16231
|
+
},
|
|
16232
|
+
{
|
|
16233
|
+
title: "CI & PRs",
|
|
16234
|
+
verbs: [
|
|
16235
|
+
"ci install",
|
|
16236
|
+
"summary",
|
|
16237
|
+
"pr-comment",
|
|
16238
|
+
"badge",
|
|
16239
|
+
"impact",
|
|
16240
|
+
"baseline",
|
|
16241
|
+
"diff"
|
|
16242
|
+
]
|
|
16243
|
+
},
|
|
16244
|
+
{
|
|
16245
|
+
title: "Forensics",
|
|
16246
|
+
verbs: [
|
|
16247
|
+
"forensics",
|
|
16248
|
+
"triage",
|
|
16249
|
+
"pw-report",
|
|
16250
|
+
"doctor:playwright"
|
|
16251
|
+
]
|
|
16252
|
+
},
|
|
16253
|
+
{
|
|
16254
|
+
title: "Maintenance",
|
|
16255
|
+
verbs: [
|
|
16256
|
+
"fix",
|
|
16257
|
+
"debt",
|
|
16258
|
+
"stats",
|
|
16259
|
+
"suppressions",
|
|
16260
|
+
"handover",
|
|
16261
|
+
"init",
|
|
16262
|
+
"doctor",
|
|
16263
|
+
"create-rule"
|
|
16264
|
+
]
|
|
16265
|
+
},
|
|
16266
|
+
{
|
|
16267
|
+
title: "Meta",
|
|
16268
|
+
verbs: [
|
|
16269
|
+
"rules",
|
|
16270
|
+
"explain",
|
|
16271
|
+
"why",
|
|
16272
|
+
"handoff",
|
|
16273
|
+
"install",
|
|
16274
|
+
"mcp"
|
|
16275
|
+
]
|
|
16276
|
+
}
|
|
16277
|
+
];
|
|
16278
|
+
const SCAN_SUMMARY_LINES = ["mjolnir [path] full-repo scan + WORTHINESS score"];
|
|
15885
16279
|
/**
|
|
15886
|
-
*
|
|
15887
|
-
*
|
|
15888
|
-
*
|
|
15889
|
-
*
|
|
15890
|
-
*
|
|
15891
|
-
* absent now, backed by a real file+message match) plus new debt introduced
|
|
15892
|
-
* since then. That is the entire honesty-safe surface this command can
|
|
15893
|
-
* stand behind with real evidence.
|
|
15894
|
-
*
|
|
15895
|
-
* HARD CONSTRAINT (Honesty Core, non-negotiable per the plan): every number
|
|
15896
|
-
* here is evidence-backed from data actually present on disk, or the field
|
|
15897
|
-
* is UNKNOWN. This command never estimates or extrapolates "hours saved" or
|
|
15898
|
-
* "CI minutes saved" — inventing such a number would be worse than useless,
|
|
15899
|
-
* it would be the exact kind of fake-precision this product exists to
|
|
15900
|
-
* catch in *other* tools. Local-only, zero network (verified by
|
|
15901
|
-
* tests/privacy-network-isolation.spec.ts, which scans this file too).
|
|
16280
|
+
* The redesigned root help (plan M2): grouped sections, one-line
|
|
16281
|
+
* descriptions, copy-pasteable examples, the frozen exit-code table and
|
|
16282
|
+
* the docs link. Content is identical whether colored or piped — the
|
|
16283
|
+
* caller decides (runHelpCommand passes a resolved palette; printUsage
|
|
16284
|
+
* stays plain).
|
|
15902
16285
|
*/
|
|
15903
|
-
|
|
15904
|
-
|
|
15905
|
-
|
|
15906
|
-
|
|
15907
|
-
|
|
15908
|
-
|
|
15909
|
-
|
|
15910
|
-
|
|
15911
|
-
|
|
15912
|
-
|
|
15913
|
-
|
|
15914
|
-
|
|
15915
|
-
|
|
15916
|
-
|
|
15917
|
-
|
|
15918
|
-
|
|
15919
|
-
|
|
15920
|
-
|
|
15921
|
-
|
|
15922
|
-
|
|
15923
|
-
|
|
15924
|
-
|
|
15925
|
-
|
|
15926
|
-
}
|
|
15927
|
-
/** Like git(), but returns the raw bytes — no utf8 decode round-trip. */
|
|
15928
|
-
function gitBuffer(root, args) {
|
|
15929
|
-
try {
|
|
15930
|
-
return execFileSync(gitExe(), [
|
|
15931
|
-
"-C",
|
|
15932
|
-
root,
|
|
15933
|
-
...args
|
|
15934
|
-
], {
|
|
15935
|
-
encoding: "buffer",
|
|
15936
|
-
stdio: [
|
|
15937
|
-
"ignore",
|
|
15938
|
-
"pipe",
|
|
15939
|
-
"ignore"
|
|
15940
|
-
],
|
|
15941
|
-
timeout: 3e4
|
|
15942
|
-
});
|
|
15943
|
-
} catch {
|
|
15944
|
-
return null;
|
|
15945
|
-
}
|
|
15946
|
-
}
|
|
15947
|
-
/** Fingerprint a finding for cross-commit matching (line numbers shift). */
|
|
15948
|
-
function fingerprint$2(f) {
|
|
15949
|
-
return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
|
|
15950
|
-
}
|
|
15951
|
-
async function computeImpact(root, options) {
|
|
15952
|
-
const unknownFacts = ["CI minutes or engineer-hours saved: not computed — this repo does not store historical CI run duration locally, and this command never estimates a number it cannot prove."];
|
|
15953
|
-
if (!existsSync(join(root, ".git"))) return {
|
|
15954
|
-
hasComparison: false,
|
|
15955
|
-
unknownReason: "not-a-git-repo",
|
|
15956
|
-
resolved: [],
|
|
15957
|
-
introduced: [],
|
|
15958
|
-
unknownFacts
|
|
15959
|
-
};
|
|
15960
|
-
let baseRef = options.since;
|
|
15961
|
-
if (!baseRef) baseRef = git(root, ["rev-parse", "HEAD~1"])?.trim() ?? git(root, [
|
|
15962
|
-
"merge-base",
|
|
15963
|
-
"HEAD",
|
|
15964
|
-
options.baseBranch ?? "main"
|
|
15965
|
-
])?.trim() ?? void 0;
|
|
15966
|
-
else baseRef = git(root, ["rev-parse", baseRef])?.trim() ?? baseRef;
|
|
15967
|
-
if (!baseRef) return {
|
|
15968
|
-
hasComparison: false,
|
|
15969
|
-
unknownReason: "no-prior-commit",
|
|
15970
|
-
resolved: [],
|
|
15971
|
-
introduced: [],
|
|
15972
|
-
unknownFacts
|
|
15973
|
-
};
|
|
15974
|
-
const headRef = git(root, ["rev-parse", "HEAD"])?.trim() ?? "HEAD";
|
|
15975
|
-
if (baseRef === headRef) return {
|
|
15976
|
-
hasComparison: false,
|
|
15977
|
-
unknownReason: options.since ? "base-equals-head" : "no-prior-commit",
|
|
15978
|
-
baseRef,
|
|
15979
|
-
headRef,
|
|
15980
|
-
resolved: [],
|
|
15981
|
-
introduced: [],
|
|
15982
|
-
unknownFacts
|
|
15983
|
-
};
|
|
15984
|
-
let tmpDir;
|
|
15985
|
-
try {
|
|
15986
|
-
tmpDir = mkdtempSync(join(tmpdir(), "mjolnir-impact-"));
|
|
15987
|
-
} catch {
|
|
15988
|
-
return {
|
|
15989
|
-
hasComparison: false,
|
|
15990
|
-
unknownReason: "tree-materialize-failed",
|
|
15991
|
-
baseRef,
|
|
15992
|
-
headRef,
|
|
15993
|
-
resolved: [],
|
|
15994
|
-
introduced: [],
|
|
15995
|
-
unknownFacts
|
|
15996
|
-
};
|
|
16286
|
+
function renderRootHelp(schemaVersion = 1) {
|
|
16287
|
+
const byVerb = new Map(HELP_ENTRIES.map((e) => [e.verb, e]));
|
|
16288
|
+
const lines = [];
|
|
16289
|
+
lines.push("🔨 mjölnir — verification trust engine for test suites and CI pipelines");
|
|
16290
|
+
lines.push("");
|
|
16291
|
+
lines.push("Usage: mjolnir [path] [options] · mjolnir <subcommand> [args] · mjolnir help <verb>");
|
|
16292
|
+
lines.push("");
|
|
16293
|
+
lines.push("The product is one command in CI:");
|
|
16294
|
+
lines.push("");
|
|
16295
|
+
lines.push(" mjolnir --scope changed scan only what the branch touched; exit 1 on");
|
|
16296
|
+
lines.push(" new findings. `mjolnir ci install` writes the");
|
|
16297
|
+
lines.push(" workflow for you.");
|
|
16298
|
+
lines.push("");
|
|
16299
|
+
lines.push("Everything else is optional.");
|
|
16300
|
+
lines.push("");
|
|
16301
|
+
lines.push(" " + SCAN_SUMMARY_LINES[0]);
|
|
16302
|
+
lines.push(" mjolnir explain <RULE-ID> what/why/fix + measured FP rate for one rule");
|
|
16303
|
+
lines.push(" mjolnir rules --unmeasured the rules running on assumption, not measurement");
|
|
16304
|
+
lines.push("");
|
|
16305
|
+
lines.push("Options:");
|
|
16306
|
+
for (const f of HELP_FLAGS) {
|
|
16307
|
+
const pad = f.flag.padEnd(22);
|
|
16308
|
+
lines.push(` ${pad}${f.summary}`);
|
|
15997
16309
|
}
|
|
15998
|
-
|
|
15999
|
-
|
|
16000
|
-
|
|
16001
|
-
|
|
16002
|
-
|
|
16003
|
-
|
|
16004
|
-
|
|
16005
|
-
|
|
16006
|
-
|
|
16007
|
-
|
|
16008
|
-
if (treeListing === null) return {
|
|
16009
|
-
hasComparison: false,
|
|
16010
|
-
unknownReason: "tree-listing-failed",
|
|
16011
|
-
baseRef,
|
|
16012
|
-
headRef,
|
|
16013
|
-
resolved: [],
|
|
16014
|
-
introduced: [],
|
|
16015
|
-
unknownFacts
|
|
16016
|
-
};
|
|
16017
|
-
const paths = treeListing.split("\0").filter(Boolean);
|
|
16018
|
-
const MAX_FILES = 2e4;
|
|
16019
|
-
const truncated = paths.length > MAX_FILES;
|
|
16020
|
-
for (const relPath of paths.slice(0, MAX_FILES)) {
|
|
16021
|
-
const blob = gitBuffer(root, ["show", `${baseRef}:${relPath}`]);
|
|
16022
|
-
if (blob === null) continue;
|
|
16023
|
-
const dest = join(tmpDir, relPath);
|
|
16024
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
16025
|
-
writeFileSync(dest, blob);
|
|
16026
|
-
}
|
|
16027
|
-
if (truncated) {
|
|
16028
|
-
baseTreeTruncated = {
|
|
16029
|
-
scanned: Math.min(paths.length, MAX_FILES),
|
|
16030
|
-
total: paths.length
|
|
16031
|
-
};
|
|
16032
|
-
unknownFacts.push(`Base tree materialization truncated at ${MAX_FILES} of ${paths.length} paths — files beyond the cap were never scanned at base, so findings that exist only there are misreported here as new debt.`);
|
|
16310
|
+
lines.push(" -v, --version print the installed version and exit");
|
|
16311
|
+
lines.push(" -h, --help show this help");
|
|
16312
|
+
lines.push("");
|
|
16313
|
+
for (const g of GROUPS) {
|
|
16314
|
+
lines.push(`Subcommands — ${g.title}:`);
|
|
16315
|
+
for (const verb of g.verbs) {
|
|
16316
|
+
const e = byVerb.get(verb);
|
|
16317
|
+
if (!e) continue;
|
|
16318
|
+
const usage = e.usage.replace(/^mjolnir /, "").padEnd(46);
|
|
16319
|
+
lines.push(` ${usage}${e.summary}`);
|
|
16033
16320
|
}
|
|
16034
|
-
|
|
16035
|
-
}
|
|
16036
|
-
|
|
16037
|
-
|
|
16038
|
-
|
|
16039
|
-
|
|
16040
|
-
|
|
16041
|
-
|
|
16042
|
-
|
|
16043
|
-
|
|
16321
|
+
lines.push("");
|
|
16322
|
+
}
|
|
16323
|
+
lines.push("Copy-paste starts:");
|
|
16324
|
+
lines.push(" $ mjolnir score this repo's test suite");
|
|
16325
|
+
lines.push(" $ mjolnir --scope changed CI gate: only what the branch touched");
|
|
16326
|
+
lines.push(" $ mjolnir ci install write the PR workflow");
|
|
16327
|
+
lines.push(" $ mjolnir forensics test-results where the flakes hide");
|
|
16328
|
+
lines.push("");
|
|
16329
|
+
lines.push("Per-command help: mjolnir help <verb> (e.g. mjolnir help fix)");
|
|
16330
|
+
lines.push("");
|
|
16331
|
+
lines.push(`Exit codes: ${EXIT_CODE_TABLE.map(([c]) => c).join(" · ")}`);
|
|
16332
|
+
for (const [code, meaning] of EXIT_CODE_TABLE) lines.push(` ${code.padEnd(3)} ${meaning}`);
|
|
16333
|
+
lines.push("");
|
|
16334
|
+
lines.push(`Docs: ${DOCS_URL} (JSON schemaVersion ${schemaVersion}, additive-only)`);
|
|
16335
|
+
return lines.join("\n");
|
|
16336
|
+
}
|
|
16337
|
+
//#endregion
|
|
16338
|
+
//#region src/commands/debt.ts
|
|
16339
|
+
const ui$9 = plainContext();
|
|
16340
|
+
/** Cost model (documented, conservative): hours/quarter per occurrence. */
|
|
16341
|
+
const COST_MODEL = {
|
|
16342
|
+
"QA-TEST-004": {
|
|
16343
|
+
label: "Hard sleeps",
|
|
16344
|
+
hoursPerOccurrence: .4
|
|
16345
|
+
},
|
|
16346
|
+
"QA-PY-005": {
|
|
16347
|
+
label: "Hard sleeps",
|
|
16348
|
+
hoursPerOccurrence: .4
|
|
16349
|
+
},
|
|
16350
|
+
"QA-PW-118": {
|
|
16351
|
+
label: "Network-idle waits",
|
|
16352
|
+
hoursPerOccurrence: .3
|
|
16353
|
+
},
|
|
16354
|
+
"QA-TEST-002": {
|
|
16355
|
+
label: "Skipped tests",
|
|
16356
|
+
hoursPerOccurrence: .2
|
|
16357
|
+
},
|
|
16358
|
+
"QA-PY-002": {
|
|
16359
|
+
label: "Skipped tests",
|
|
16360
|
+
hoursPerOccurrence: .2
|
|
16361
|
+
},
|
|
16362
|
+
"QA-TEST-003": {
|
|
16363
|
+
label: "No-assertion tests",
|
|
16364
|
+
hoursPerOccurrence: .25
|
|
16365
|
+
},
|
|
16366
|
+
"QA-PY-003": {
|
|
16367
|
+
label: "No-assertion tests",
|
|
16368
|
+
hoursPerOccurrence: .25
|
|
16369
|
+
},
|
|
16370
|
+
"QA-TEST-010": {
|
|
16371
|
+
label: "Empty test bodies",
|
|
16372
|
+
hoursPerOccurrence: .15
|
|
16373
|
+
},
|
|
16374
|
+
"QA-PY-006": {
|
|
16375
|
+
label: "Empty test bodies",
|
|
16376
|
+
hoursPerOccurrence: .15
|
|
16377
|
+
},
|
|
16378
|
+
"QA-TQUAL-001": {
|
|
16379
|
+
label: "Mock-only verification",
|
|
16380
|
+
hoursPerOccurrence: .3
|
|
16381
|
+
},
|
|
16382
|
+
"QA-PW-004": {
|
|
16383
|
+
label: "Brittle selectors",
|
|
16384
|
+
hoursPerOccurrence: .35
|
|
16385
|
+
},
|
|
16386
|
+
"QA-ENV-001": {
|
|
16387
|
+
label: "Environment coupling",
|
|
16388
|
+
hoursPerOccurrence: .5
|
|
16389
|
+
}
|
|
16390
|
+
};
|
|
16391
|
+
function computeDebt(result) {
|
|
16392
|
+
const byLabel = /* @__PURE__ */ new Map();
|
|
16393
|
+
for (const f of result.findings) {
|
|
16394
|
+
const entry = COST_MODEL[f.ruleId];
|
|
16395
|
+
if (!entry) continue;
|
|
16396
|
+
const cur = byLabel.get(entry.label) ?? {
|
|
16397
|
+
count: 0,
|
|
16398
|
+
hours: 0,
|
|
16399
|
+
ruleIds: /* @__PURE__ */ new Set()
|
|
16044
16400
|
};
|
|
16045
|
-
|
|
16046
|
-
|
|
16047
|
-
|
|
16048
|
-
|
|
16049
|
-
force: true
|
|
16050
|
-
});
|
|
16051
|
-
} catch {}
|
|
16401
|
+
cur.count += 1;
|
|
16402
|
+
cur.hours += entry.hoursPerOccurrence;
|
|
16403
|
+
cur.ruleIds.add(f.ruleId);
|
|
16404
|
+
byLabel.set(entry.label, cur);
|
|
16052
16405
|
}
|
|
16053
|
-
const
|
|
16054
|
-
|
|
16055
|
-
|
|
16056
|
-
|
|
16057
|
-
|
|
16058
|
-
|
|
16059
|
-
for (const [key, f] of baseSet) if (!headSet.has(key)) resolved.push({
|
|
16060
|
-
ruleId: f.ruleId,
|
|
16061
|
-
file: f.file,
|
|
16062
|
-
message: f.message
|
|
16063
|
-
});
|
|
16064
|
-
const introduced = [];
|
|
16065
|
-
for (const [key, f] of headSet) if (!baseSet.has(key)) introduced.push({
|
|
16066
|
-
ruleId: f.ruleId,
|
|
16067
|
-
file: f.file,
|
|
16068
|
-
message: f.message
|
|
16069
|
-
});
|
|
16406
|
+
const classes = [...byLabel.entries()].map(([label, v]) => ({
|
|
16407
|
+
label,
|
|
16408
|
+
count: v.count,
|
|
16409
|
+
estHoursPerQuarter: Math.round(v.hours * 10) / 10,
|
|
16410
|
+
ruleIds: [...v.ruleIds].sort((a, b) => a.localeCompare(b))
|
|
16411
|
+
})).sort((a, b) => b.estHoursPerQuarter - a.estHoursPerQuarter);
|
|
16070
16412
|
return {
|
|
16071
|
-
|
|
16072
|
-
|
|
16073
|
-
headRef,
|
|
16074
|
-
...baseTreeTruncated ? { baseTreeTruncated } : {},
|
|
16075
|
-
resolved: resolved.sort((a, b) => a.file.localeCompare(b.file)),
|
|
16076
|
-
introduced: introduced.sort((a, b) => a.file.localeCompare(b.file)),
|
|
16077
|
-
unknownFacts
|
|
16413
|
+
classes,
|
|
16414
|
+
totalHours: Math.round(classes.reduce((s, c) => s + c.estHoursPerQuarter, 0) * 10) / 10
|
|
16078
16415
|
};
|
|
16079
16416
|
}
|
|
16080
|
-
function
|
|
16417
|
+
function renderDebt(result) {
|
|
16418
|
+
const { classes, totalHours } = computeDebt(result);
|
|
16081
16419
|
const lines = [];
|
|
16082
|
-
lines.push(sectionHeader("
|
|
16420
|
+
lines.push(sectionHeader("TEST DEBT REGISTER", ui$9));
|
|
16083
16421
|
lines.push("");
|
|
16084
|
-
if (
|
|
16085
|
-
lines.push(
|
|
16086
|
-
lines.push("This is reported as UNKNOWN rather than a fabricated zero: mjolnir");
|
|
16087
|
-
lines.push("never invents a number it cannot prove.");
|
|
16088
|
-
lines.push("");
|
|
16089
|
-
for (const fact of report.unknownFacts) lines.push(`UNKNOWN: ${fact}`);
|
|
16422
|
+
if (classes.length === 0) {
|
|
16423
|
+
lines.push("No tracked debt classes found — the suite is clean.");
|
|
16090
16424
|
return lines.join("\n");
|
|
16091
16425
|
}
|
|
16092
|
-
|
|
16093
|
-
|
|
16094
|
-
|
|
16095
|
-
|
|
16096
|
-
|
|
16097
|
-
lines.push("");
|
|
16098
|
-
}
|
|
16099
|
-
if (report.resolved.length === 0) lines.push("FIXED SINCE BASE: none found.");
|
|
16100
|
-
else {
|
|
16101
|
-
lines.push(`FIXED SINCE BASE (${report.resolved.length}) — real, evidence-backed:`);
|
|
16102
|
-
for (const f of report.resolved) lines.push(` ✓ ${f.ruleId} · ${f.file} — ${f.message}`);
|
|
16103
|
-
}
|
|
16104
|
-
lines.push("");
|
|
16105
|
-
if (report.introduced.length === 0) lines.push("NEW DEBT SINCE BASE: none found.");
|
|
16106
|
-
else {
|
|
16107
|
-
lines.push(`NEW DEBT SINCE BASE (${report.introduced.length}):`);
|
|
16108
|
-
for (const f of report.introduced) lines.push(` + ${f.ruleId} · ${f.file} — ${f.message}`);
|
|
16109
|
-
}
|
|
16426
|
+
const rows = ["DEBT CLASS", ""];
|
|
16427
|
+
rows[0] = `DEBT CLASS COUNT EST. HOURS/QUARTER`;
|
|
16428
|
+
for (const c of classes) rows.push(`${c.label.padEnd(26)} ${String(c.count).padStart(5)} ${c.estHoursPerQuarter.toFixed(1).padStart(8)}`);
|
|
16429
|
+
rows.push(`TOTAL ESTIMATED DRAG: ~${totalHours.toFixed(1)} engineer-hours/qtr`);
|
|
16430
|
+
for (const row of panel(rows, ui$9)) lines.push(row);
|
|
16110
16431
|
lines.push("");
|
|
16111
|
-
|
|
16432
|
+
lines.push("Cost model is conservative and documented in src/commands/debt.ts.");
|
|
16112
16433
|
return lines.join("\n");
|
|
16113
16434
|
}
|
|
16114
16435
|
//#endregion
|
|
16115
|
-
//#region src/
|
|
16116
|
-
|
|
16117
|
-
|
|
16118
|
-
|
|
16119
|
-
|
|
16436
|
+
//#region src/commands/rule-families.ts
|
|
16437
|
+
const RULE_FAMILIES = [
|
|
16438
|
+
{
|
|
16439
|
+
token: "TEST",
|
|
16440
|
+
dir: "test",
|
|
16441
|
+
category: "QA-TEST",
|
|
16442
|
+
appliesTo: "test-files"
|
|
16443
|
+
},
|
|
16444
|
+
{
|
|
16445
|
+
token: "TQUAL",
|
|
16446
|
+
dir: "quality",
|
|
16447
|
+
category: "QA-TQUAL",
|
|
16448
|
+
appliesTo: "test-files"
|
|
16449
|
+
},
|
|
16450
|
+
{
|
|
16451
|
+
token: "PW",
|
|
16452
|
+
dir: "playwright",
|
|
16453
|
+
category: "QA-PW",
|
|
16454
|
+
appliesTo: "test-files"
|
|
16455
|
+
},
|
|
16456
|
+
{
|
|
16457
|
+
token: "CI",
|
|
16458
|
+
dir: "ci",
|
|
16459
|
+
category: "QA-CI",
|
|
16460
|
+
appliesTo: "ci-workflows"
|
|
16461
|
+
},
|
|
16462
|
+
{
|
|
16463
|
+
token: "PY",
|
|
16464
|
+
dir: "python",
|
|
16465
|
+
category: "QA-PY",
|
|
16466
|
+
appliesTo: "python"
|
|
16467
|
+
},
|
|
16468
|
+
{
|
|
16469
|
+
token: "ENV",
|
|
16470
|
+
dir: "quality",
|
|
16471
|
+
category: "QA-ENV",
|
|
16472
|
+
appliesTo: "test-files"
|
|
16473
|
+
},
|
|
16474
|
+
{
|
|
16475
|
+
token: "JV",
|
|
16476
|
+
dir: "java",
|
|
16477
|
+
category: "QA-JV",
|
|
16478
|
+
appliesTo: "java"
|
|
16479
|
+
},
|
|
16480
|
+
{
|
|
16481
|
+
token: "CS",
|
|
16482
|
+
dir: "csharp",
|
|
16483
|
+
category: "QA-CS",
|
|
16484
|
+
appliesTo: "csharp"
|
|
16485
|
+
},
|
|
16486
|
+
{
|
|
16487
|
+
token: "CYP",
|
|
16488
|
+
dir: "cypress",
|
|
16489
|
+
category: "QA-CYP",
|
|
16490
|
+
appliesTo: "test-files"
|
|
16491
|
+
},
|
|
16492
|
+
{
|
|
16493
|
+
token: "SE",
|
|
16494
|
+
dir: "selenium",
|
|
16495
|
+
category: "QA-SE",
|
|
16496
|
+
appliesTo: "test-files"
|
|
16497
|
+
},
|
|
16498
|
+
{
|
|
16499
|
+
token: "WDIO",
|
|
16500
|
+
dir: "webdriverio",
|
|
16501
|
+
category: "QA-WDIO",
|
|
16502
|
+
appliesTo: "test-files"
|
|
16503
|
+
},
|
|
16504
|
+
{
|
|
16505
|
+
token: "PPTR",
|
|
16506
|
+
dir: "puppeteer",
|
|
16507
|
+
category: "QA-PPTR",
|
|
16508
|
+
appliesTo: "test-files"
|
|
16509
|
+
},
|
|
16510
|
+
{
|
|
16511
|
+
token: "APM",
|
|
16512
|
+
dir: "apm",
|
|
16513
|
+
category: "QA-APM",
|
|
16514
|
+
appliesTo: "test-files"
|
|
16515
|
+
}
|
|
16516
|
+
];
|
|
16517
|
+
/** The full ID regex — derived from the table, so doctor and create-rule
|
|
16518
|
+
* can never disagree about which families exist. */
|
|
16519
|
+
const RULE_ID_RE = new RegExp(`^QA-(?:${RULE_FAMILIES.map((f) => f.token).join("|")})-\\d{3}$`);
|
|
16520
|
+
function familyByToken(token) {
|
|
16521
|
+
const upper = token.toUpperCase();
|
|
16522
|
+
return RULE_FAMILIES.find((f) => f.token === upper);
|
|
16523
|
+
}
|
|
16524
|
+
//#endregion
|
|
16525
|
+
//#region src/commands/create-rule.ts
|
|
16120
16526
|
/**
|
|
16121
|
-
*
|
|
16527
|
+
* `mjolnir create-rule <ID> --title "..."` — rule scaffold generator
|
|
16528
|
+
* (Tier 6 #34, Contribution Surface Engineering).
|
|
16529
|
+
*
|
|
16530
|
+
* Generates the four files every rule MUST ship with (anti-creep law):
|
|
16531
|
+
* src/rules/<family>/qa-<id-lower>.ts — the rule
|
|
16532
|
+
* tests/fixtures/<ID>/must-fire/ — at least one must-fire fixture
|
|
16533
|
+
* tests/fixtures/<ID>/must-not-fire/ — at least one must-not fixture
|
|
16534
|
+
* and prints the exact registry edit to make.
|
|
16535
|
+
*
|
|
16536
|
+
* The generated rule intentionally FAILS its fixtures until the author
|
|
16537
|
+
* implements it — you cannot ship a stub.
|
|
16122
16538
|
*/
|
|
16123
|
-
|
|
16124
|
-
|
|
16125
|
-
|
|
16126
|
-
const
|
|
16127
|
-
|
|
16128
|
-
|
|
16129
|
-
|
|
16130
|
-
|
|
16131
|
-
|
|
16132
|
-
|
|
16133
|
-
|
|
16134
|
-
cause: "crash",
|
|
16135
|
-
comparedAgainst
|
|
16136
|
-
};
|
|
16137
|
-
if (input.skippedFiles?.has(entry.file)) return {
|
|
16138
|
-
status: "INCONCLUSIVE",
|
|
16139
|
-
cause: "skipped",
|
|
16140
|
-
comparedAgainst
|
|
16141
|
-
};
|
|
16142
|
-
if (input.suppressed?.has(`${entry.ruleId}\u0000${entry.file}`)) return {
|
|
16143
|
-
status: "SUPPRESSED",
|
|
16144
|
-
comparedAgainst
|
|
16145
|
-
};
|
|
16146
|
-
if (input.excludedFiles?.has(entry.file)) return {
|
|
16147
|
-
status: "DISAPPEARED-NON-FIX",
|
|
16148
|
-
cause: "excluded",
|
|
16149
|
-
comparedAgainst
|
|
16150
|
-
};
|
|
16151
|
-
const entryRev = entry.detectorRevision;
|
|
16152
|
-
if (entryRev === void 0) return {
|
|
16153
|
-
status: "INCONCLUSIVE",
|
|
16154
|
-
cause: "legacy-baseline",
|
|
16155
|
-
comparedAgainst
|
|
16539
|
+
const ui$8 = plainContext();
|
|
16540
|
+
function parseId(id) {
|
|
16541
|
+
if (!RULE_ID_RE.exec(id)) return null;
|
|
16542
|
+
const family = familyByToken(id.slice(3, id.length - 4));
|
|
16543
|
+
return {
|
|
16544
|
+
family: family.dir,
|
|
16545
|
+
dir: family.dir,
|
|
16546
|
+
category: family.category,
|
|
16547
|
+
appliesTo: family.appliesTo,
|
|
16548
|
+
num: id.slice(-3),
|
|
16549
|
+
lower: id.toLowerCase()
|
|
16156
16550
|
};
|
|
16157
|
-
|
|
16158
|
-
|
|
16159
|
-
|
|
16160
|
-
|
|
16161
|
-
|
|
16551
|
+
}
|
|
16552
|
+
function createRuleScaffold(input, rootDir) {
|
|
16553
|
+
const parsed = parseId(input.id);
|
|
16554
|
+
if (!parsed) return {
|
|
16555
|
+
ok: false,
|
|
16556
|
+
error: "Invalid rule ID. Expected QA-<FAMILY>-NNN with one of the registered families (TEST, TQUAL, PW, CI, PY, ENV, JV, CS, CYP, SE, WDIO, PPTR, APM).",
|
|
16557
|
+
files: [],
|
|
16558
|
+
registryEdit: ""
|
|
16162
16559
|
};
|
|
16163
|
-
if (
|
|
16164
|
-
|
|
16165
|
-
|
|
16166
|
-
|
|
16560
|
+
if (!input.title || input.title.trim().length === 0) return {
|
|
16561
|
+
ok: false,
|
|
16562
|
+
error: "A --title is required.",
|
|
16563
|
+
files: [],
|
|
16564
|
+
registryEdit: ""
|
|
16167
16565
|
};
|
|
16168
|
-
|
|
16169
|
-
|
|
16170
|
-
|
|
16566
|
+
const dirRel = join("src", "rules", parsed.dir);
|
|
16567
|
+
const ruleRel = join(dirRel, `${parsed.lower}.ts`);
|
|
16568
|
+
const absRule = join(rootDir, ruleRel);
|
|
16569
|
+
if (existsSync(absRule)) return {
|
|
16570
|
+
ok: false,
|
|
16571
|
+
error: `Rule file already exists: ${ruleRel}`,
|
|
16572
|
+
files: [],
|
|
16573
|
+
registryEdit: ""
|
|
16171
16574
|
};
|
|
16575
|
+
mkdirSync(join(rootDir, "src", "rules", parsed.dir), { recursive: true });
|
|
16576
|
+
const files = [];
|
|
16577
|
+
writeFileAtomic(absRule, `/**
|
|
16578
|
+
* ${input.id} — ${input.title}.
|
|
16579
|
+
*
|
|
16580
|
+
* TODO(implement): replace the placeholder below. The rule currently
|
|
16581
|
+
* returns no findings on purpose so the fixture harness FAILS until
|
|
16582
|
+
* real detection logic lands (anti-creep law §18.1).
|
|
16583
|
+
*/
|
|
16584
|
+
|
|
16585
|
+
import { defineRule } from "../rule.js";
|
|
16586
|
+
import type { Finding } from "../../types.js";
|
|
16587
|
+
|
|
16588
|
+
export const ${camel(parsed.lower)} = defineRule({
|
|
16589
|
+
id: "${input.id}",
|
|
16590
|
+
category: "${parsed.category}",
|
|
16591
|
+
title: ${JSON.stringify(input.title)},
|
|
16592
|
+
severity: "warning",
|
|
16593
|
+
confidence: "medium",
|
|
16594
|
+
findingType: "heuristic-risk",
|
|
16595
|
+
qaImpact: "HYGIENE",
|
|
16596
|
+
appliesTo: ${JSON.stringify(parsed.appliesTo)},
|
|
16597
|
+
run(ctx) {
|
|
16598
|
+
const findings: Omit<Finding, "ruleId" | "category">[] = [];
|
|
16599
|
+
void ctx; // TODO: implement detection over ctx.path / ctx.text
|
|
16600
|
+
return findings;
|
|
16601
|
+
},
|
|
16602
|
+
});
|
|
16603
|
+
`);
|
|
16604
|
+
files.push(ruleRel);
|
|
16605
|
+
for (const direction of ["must-fire", "must-not-fire"]) {
|
|
16606
|
+
const dirRel = join("tests", "fixtures", input.id, direction);
|
|
16607
|
+
const absDir = join(rootDir, dirRel);
|
|
16608
|
+
mkdirSync(absDir, { recursive: true });
|
|
16609
|
+
const isPy = parsed.family === "python";
|
|
16610
|
+
const name = isPy ? `example.${direction}.py` : `example.${direction}.ts`;
|
|
16611
|
+
const content = isPy ? `# ${input.id} ${direction} fixture — replace with a real case.\n` : `// ${input.id} ${direction} fixture — replace with a real case.\n`;
|
|
16612
|
+
writeFileAtomic(join(absDir, name), content);
|
|
16613
|
+
files.push(join(dirRel, name));
|
|
16614
|
+
}
|
|
16615
|
+
const exportName = camel(parsed.lower);
|
|
16172
16616
|
return {
|
|
16173
|
-
|
|
16174
|
-
|
|
16617
|
+
ok: true,
|
|
16618
|
+
files,
|
|
16619
|
+
registryEdit: [
|
|
16620
|
+
`// 1. Add import to src/rules/index.ts:`,
|
|
16621
|
+
`import { ${exportName} } from "./${parsed.dir}/${parsed.lower}.js";`,
|
|
16622
|
+
`// 2. Add to the RULES array:`,
|
|
16623
|
+
` ${exportName},`
|
|
16624
|
+
].join("\n")
|
|
16175
16625
|
};
|
|
16176
16626
|
}
|
|
16177
|
-
|
|
16178
|
-
|
|
16179
|
-
|
|
16180
|
-
|
|
16181
|
-
|
|
16182
|
-
|
|
16183
|
-
|
|
16184
|
-
|
|
16185
|
-
}
|
|
16627
|
+
function camel(idLower) {
|
|
16628
|
+
return idLower.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
|
|
16629
|
+
}
|
|
16630
|
+
function renderScaffoldReport(result) {
|
|
16631
|
+
if (!result.ok) return `create-rule failed: ${result.error}`;
|
|
16632
|
+
const lines = [];
|
|
16633
|
+
lines.push(sectionHeader("RULE SCAFFOLD CREATED", ui$8));
|
|
16634
|
+
lines.push("");
|
|
16635
|
+
for (const f of result.files) lines.push(` + ${f}`);
|
|
16636
|
+
lines.push("");
|
|
16637
|
+
lines.push(result.registryEdit);
|
|
16638
|
+
lines.push("");
|
|
16639
|
+
lines.push("Next steps:", " 1. Implement detection logic in the rule's run()", " 2. Replace both fixture skeletons with real cases", " 3. Run: npm test — must-fire AND must-not-fire must pass", " 4. A rule whose fixtures fail CANNOT ship (anti-creep law)");
|
|
16640
|
+
lines.push("");
|
|
16641
|
+
lines.push("Note: running the test suite right now will show the new fixtures FAILING. This is intentional, not a bug in the scaffold — the rule above returns zero findings on purpose, so its must-fire fixture cannot pass until you implement real detection logic. A rule that ships as a silent no-op stub would violate the fixture-firewall law without anyone noticing.");
|
|
16642
|
+
return lines.join("\n");
|
|
16186
16643
|
}
|
|
16187
16644
|
//#endregion
|
|
16188
|
-
//#region src/commands/
|
|
16189
|
-
/**
|
|
16190
|
-
* `mjolnir baseline` / `mjolnir diff` — Sprint 6 Task 24
|
|
16191
|
-
* (Master-Stabilization-Plan.md).
|
|
16192
|
-
*
|
|
16193
|
-
* Implements Plan.md Phase 10 / §24's key insight: existing debt should
|
|
16194
|
-
* not block every PR — only NEW or WORSENED debt should. `baseline`
|
|
16195
|
-
* snapshots the current finding set to disk; `diff` compares the current
|
|
16196
|
-
* scan against that snapshot and reports only what changed.
|
|
16197
|
-
*
|
|
16198
|
-
* Findings are matched across scans by a stable fingerprint (ruleId +
|
|
16199
|
-
* file + message), not by line number — line numbers shift constantly as
|
|
16200
|
-
* a file is edited, so matching on them would report unrelated churn as
|
|
16201
|
-
* "new" debt and miss genuinely new findings that happen to land on a
|
|
16202
|
-
* previously-flagged line.
|
|
16203
|
-
*
|
|
16204
|
-
* Storage: .mjolnir/baseline.json (local; not gitignored — only
|
|
16205
|
-
* .mjolnir/logs/ is, see .gitignore). A team CAN commit this
|
|
16206
|
-
* file if they want a shared baseline; that's a deliberate choice this
|
|
16207
|
-
* command does not make for them.
|
|
16208
|
-
*/
|
|
16645
|
+
//#region src/commands/handover.ts
|
|
16209
16646
|
const ui$7 = plainContext();
|
|
16210
|
-
const
|
|
16211
|
-
|
|
16212
|
-
|
|
16213
|
-
|
|
16214
|
-
|
|
16215
|
-
|
|
16216
|
-
|
|
16217
|
-
|
|
16218
|
-
const
|
|
16219
|
-
|
|
16220
|
-
|
|
16221
|
-
|
|
16222
|
-
|
|
16223
|
-
|
|
16224
|
-
|
|
16225
|
-
|
|
16226
|
-
|
|
16227
|
-
|
|
16228
|
-
|
|
16229
|
-
|
|
16647
|
+
const FAKE_GREEN_RULES = /* @__PURE__ */ new Set([
|
|
16648
|
+
"QA-TEST-003",
|
|
16649
|
+
"QA-PY-003",
|
|
16650
|
+
"QA-TEST-010",
|
|
16651
|
+
"QA-PY-006",
|
|
16652
|
+
"QA-TQUAL-002",
|
|
16653
|
+
"QA-PY-012"
|
|
16654
|
+
]);
|
|
16655
|
+
const FLAKY_RULES = /* @__PURE__ */ new Set([
|
|
16656
|
+
"QA-TEST-004",
|
|
16657
|
+
"QA-PY-005",
|
|
16658
|
+
"QA-PW-118",
|
|
16659
|
+
"QA-PW-114",
|
|
16660
|
+
"QA-TEST-006",
|
|
16661
|
+
"QA-ENV-001"
|
|
16662
|
+
]);
|
|
16663
|
+
const CI_TRUST_RULES = /* @__PURE__ */ new Set([
|
|
16664
|
+
"QA-CI-001",
|
|
16665
|
+
"QA-CI-002",
|
|
16666
|
+
"QA-CI-005",
|
|
16667
|
+
"QA-CI-007",
|
|
16668
|
+
"QA-CI-008"
|
|
16669
|
+
]);
|
|
16670
|
+
/** Group findings by directory to name "solid" areas. */
|
|
16671
|
+
function dirOf(file) {
|
|
16672
|
+
const idx = file.lastIndexOf("/");
|
|
16673
|
+
return idx === -1 ? file : file.slice(0, idx);
|
|
16230
16674
|
}
|
|
16231
|
-
function
|
|
16675
|
+
function buildHandover(scan, forensics) {
|
|
16676
|
+
const fakeGreen = [];
|
|
16677
|
+
const flaky = [];
|
|
16678
|
+
const ciTrust = [];
|
|
16679
|
+
const touchedDirs = /* @__PURE__ */ new Map();
|
|
16680
|
+
for (const f of scan.findings) {
|
|
16681
|
+
if (FAKE_GREEN_RULES.has(f.ruleId)) fakeGreen.push(f);
|
|
16682
|
+
else if (FLAKY_RULES.has(f.ruleId)) flaky.push(f);
|
|
16683
|
+
else if (CI_TRUST_RULES.has(f.ruleId)) ciTrust.push(f);
|
|
16684
|
+
const d = dirOf(f.file);
|
|
16685
|
+
touchedDirs.set(d, (touchedDirs.get(d) ?? 0) + 1);
|
|
16686
|
+
}
|
|
16687
|
+
const sections = [];
|
|
16688
|
+
const flakyFromRuns = forensics?.verdicts.filter((v) => v.passedOnRetry || v.everFailed) ?? [];
|
|
16689
|
+
const flakyItems = [];
|
|
16690
|
+
for (const v of flakyFromRuns.slice(0, 5)) flakyItems.push(`${v.title} (${v.file}) — ${v.attempts} attempt${v.attempts === 1 ? "" : "s"}${v.passedOnRetry ? ", TRUE-FLAKE" : ""} → see FLAKY.md`);
|
|
16691
|
+
for (const f of flaky.slice(0, 3)) flakyItems.push(`${f.file}:${f.line} — ${f.message}`);
|
|
16692
|
+
if (flakyItems.length > 0) sections.push({
|
|
16693
|
+
heading: "🔴 Known flaky / timing-sensitive",
|
|
16694
|
+
items: flakyItems
|
|
16695
|
+
});
|
|
16696
|
+
if (fakeGreen.length > 0) sections.push({
|
|
16697
|
+
heading: "🟡 Fake-green suspects (tests that can't fail)",
|
|
16698
|
+
items: fakeGreen.slice(0, 5).map((f) => `${f.file}:${f.line} — ${f.message}`)
|
|
16699
|
+
});
|
|
16700
|
+
if (ciTrust.length > 0) sections.push({
|
|
16701
|
+
heading: "⚠ CI trust warnings (green ≠ verified)",
|
|
16702
|
+
items: ciTrust.slice(0, 4).map((f) => `${f.file}:${f.line} — ${f.message}`)
|
|
16703
|
+
});
|
|
16704
|
+
if (new Set(scan.findings.map((f) => f.file)).size === 0 && scan.score !== null && scan.score >= 90 || scan.findings.length === 0) sections.push({
|
|
16705
|
+
heading: "🟢 Solid foundation",
|
|
16706
|
+
items: ["No tracked anti-patterns found in scanned specs — good place to start contributing."]
|
|
16707
|
+
});
|
|
16708
|
+
const totalIssues = fakeGreen.length + flaky.length + ciTrust.length + (forensics?.flakyTests ?? 0);
|
|
16232
16709
|
return {
|
|
16233
|
-
|
|
16234
|
-
|
|
16235
|
-
commit,
|
|
16236
|
-
...result.score !== null ? { score: result.score } : {},
|
|
16237
|
-
findings: result.findings.map((f) => ({
|
|
16238
|
-
ruleId: f.ruleId,
|
|
16239
|
-
file: f.file,
|
|
16240
|
-
message: f.message,
|
|
16241
|
-
severity: f.severity,
|
|
16242
|
-
...f.detectorRevision !== void 0 ? { detectorRevision: f.detectorRevision } : {}
|
|
16243
|
-
}))
|
|
16710
|
+
sections,
|
|
16711
|
+
summaryLine: totalIssues === 0 ? "Welcome aboard — the suite is in good shape." : `${totalIssues} thing${totalIssues === 1 ? "" : "s"} to know about before your first release sign-off.`
|
|
16244
16712
|
};
|
|
16245
16713
|
}
|
|
16714
|
+
function renderHandover(map) {
|
|
16715
|
+
const lines = [];
|
|
16716
|
+
lines.push(sectionHeader("WELCOME TO THE TEST SUITE — WHAT YOU NEED TO KNOW", ui$7));
|
|
16717
|
+
lines.push("");
|
|
16718
|
+
lines.push(map.summaryLine);
|
|
16719
|
+
for (const s of map.sections) {
|
|
16720
|
+
lines.push("");
|
|
16721
|
+
lines.push(s.heading);
|
|
16722
|
+
for (const item of s.items) lines.push(` • ${item}`);
|
|
16723
|
+
}
|
|
16724
|
+
lines.push("");
|
|
16725
|
+
lines.push("Generated by mjolnir handover — re-run after big refactors.");
|
|
16726
|
+
return lines.join("\n");
|
|
16727
|
+
}
|
|
16728
|
+
//#endregion
|
|
16729
|
+
//#region src/commands/impact.ts
|
|
16246
16730
|
/**
|
|
16247
|
-
*
|
|
16248
|
-
*
|
|
16249
|
-
*
|
|
16250
|
-
*
|
|
16251
|
-
*
|
|
16731
|
+
* `mjolnir impact` — Sprint 6 Task 23 (Master-Stabilization-Plan.md).
|
|
16732
|
+
*
|
|
16733
|
+
* Answers "what would have burned you": compares the current scan against
|
|
16734
|
+
* an earlier point in this repo's own git history and reports anti-patterns
|
|
16735
|
+
* that were actually removed (evidence: findings present at the base ref,
|
|
16736
|
+
* absent now, backed by a real file+message match) plus new debt introduced
|
|
16737
|
+
* since then. That is the entire honesty-safe surface this command can
|
|
16738
|
+
* stand behind with real evidence.
|
|
16739
|
+
*
|
|
16740
|
+
* HARD CONSTRAINT (Honesty Core, non-negotiable per the plan): every number
|
|
16741
|
+
* here is evidence-backed from data actually present on disk, or the field
|
|
16742
|
+
* is UNKNOWN. This command never estimates or extrapolates "hours saved" or
|
|
16743
|
+
* "CI minutes saved" — inventing such a number would be worse than useless,
|
|
16744
|
+
* it would be the exact kind of fake-precision this product exists to
|
|
16745
|
+
* catch in *other* tools. Local-only, zero network (verified by
|
|
16746
|
+
* tests/privacy-network-isolation.spec.ts, which scans this file too).
|
|
16252
16747
|
*/
|
|
16253
|
-
|
|
16254
|
-
|
|
16255
|
-
|
|
16256
|
-
|
|
16257
|
-
if (existed) {
|
|
16258
|
-
backupPath = `${outPath}.bak`;
|
|
16259
|
-
copyFileSync(outPath, backupPath);
|
|
16260
|
-
}
|
|
16261
|
-
writeFileAtomic(outPath, JSON.stringify(buildBaseline(result, commit), null, 2) + "\n");
|
|
16262
|
-
return {
|
|
16263
|
-
path: outPath,
|
|
16264
|
-
replaced: existed,
|
|
16265
|
-
...backupPath !== void 0 ? { backupPath } : {}
|
|
16266
|
-
};
|
|
16748
|
+
const ui$6 = plainContext();
|
|
16749
|
+
/** The S1-resolved absolute git binary, or the bare name to fail on. */
|
|
16750
|
+
function gitExe() {
|
|
16751
|
+
return resolveGitPath() ?? "git";
|
|
16267
16752
|
}
|
|
16268
|
-
function
|
|
16269
|
-
if (!existsSync(path)) return null;
|
|
16753
|
+
function git(root, args) {
|
|
16270
16754
|
try {
|
|
16271
|
-
|
|
16272
|
-
|
|
16273
|
-
|
|
16274
|
-
|
|
16275
|
-
|
|
16276
|
-
|
|
16277
|
-
|
|
16278
|
-
|
|
16279
|
-
|
|
16280
|
-
|
|
16281
|
-
|
|
16282
|
-
|
|
16283
|
-
|
|
16284
|
-
|
|
16285
|
-
score
|
|
16286
|
-
};
|
|
16287
|
-
}
|
|
16755
|
+
return execFileSync(gitExe(), [
|
|
16756
|
+
"-C",
|
|
16757
|
+
root,
|
|
16758
|
+
...args
|
|
16759
|
+
], {
|
|
16760
|
+
encoding: "utf8",
|
|
16761
|
+
stdio: [
|
|
16762
|
+
"ignore",
|
|
16763
|
+
"pipe",
|
|
16764
|
+
"ignore"
|
|
16765
|
+
],
|
|
16766
|
+
timeout: 3e4
|
|
16767
|
+
});
|
|
16768
|
+
} catch {
|
|
16288
16769
|
return null;
|
|
16770
|
+
}
|
|
16771
|
+
}
|
|
16772
|
+
/** Like git(), but returns the raw bytes — no utf8 decode round-trip. */
|
|
16773
|
+
function gitBuffer(root, args) {
|
|
16774
|
+
try {
|
|
16775
|
+
return execFileSync(gitExe(), [
|
|
16776
|
+
"-C",
|
|
16777
|
+
root,
|
|
16778
|
+
...args
|
|
16779
|
+
], {
|
|
16780
|
+
encoding: "buffer",
|
|
16781
|
+
stdio: [
|
|
16782
|
+
"ignore",
|
|
16783
|
+
"pipe",
|
|
16784
|
+
"ignore"
|
|
16785
|
+
],
|
|
16786
|
+
timeout: 3e4
|
|
16787
|
+
});
|
|
16289
16788
|
} catch {
|
|
16290
16789
|
return null;
|
|
16291
16790
|
}
|
|
16292
16791
|
}
|
|
16293
|
-
|
|
16294
|
-
|
|
16295
|
-
|
|
16296
|
-
|
|
16297
|
-
|
|
16298
|
-
|
|
16792
|
+
/** Fingerprint a finding for cross-commit matching (line numbers shift). */
|
|
16793
|
+
function fingerprint(f) {
|
|
16794
|
+
return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
|
|
16795
|
+
}
|
|
16796
|
+
async function computeImpact(root, options) {
|
|
16797
|
+
const unknownFacts = ["CI minutes or engineer-hours saved: not computed — this repo does not store historical CI run duration locally, and this command never estimates a number it cannot prove."];
|
|
16798
|
+
if (!existsSync(join(root, ".git"))) return {
|
|
16799
|
+
hasComparison: false,
|
|
16800
|
+
unknownReason: "not-a-git-repo",
|
|
16801
|
+
resolved: [],
|
|
16802
|
+
introduced: [],
|
|
16803
|
+
unknownFacts
|
|
16299
16804
|
};
|
|
16300
|
-
|
|
16301
|
-
|
|
16302
|
-
|
|
16303
|
-
|
|
16304
|
-
|
|
16305
|
-
|
|
16306
|
-
|
|
16307
|
-
|
|
16308
|
-
|
|
16309
|
-
|
|
16805
|
+
let baseRef = options.since;
|
|
16806
|
+
if (!baseRef) baseRef = git(root, ["rev-parse", "HEAD~1"])?.trim() ?? git(root, [
|
|
16807
|
+
"merge-base",
|
|
16808
|
+
"HEAD",
|
|
16809
|
+
options.baseBranch ?? "main"
|
|
16810
|
+
])?.trim() ?? void 0;
|
|
16811
|
+
else baseRef = git(root, ["rev-parse", baseRef])?.trim() ?? baseRef;
|
|
16812
|
+
if (!baseRef) return {
|
|
16813
|
+
hasComparison: false,
|
|
16814
|
+
unknownReason: "no-prior-commit",
|
|
16815
|
+
resolved: [],
|
|
16816
|
+
introduced: [],
|
|
16817
|
+
unknownFacts
|
|
16818
|
+
};
|
|
16819
|
+
const headRef = git(root, ["rev-parse", "HEAD"])?.trim() ?? "HEAD";
|
|
16820
|
+
if (baseRef === headRef) return {
|
|
16821
|
+
hasComparison: false,
|
|
16822
|
+
unknownReason: options.since ? "base-equals-head" : "no-prior-commit",
|
|
16823
|
+
baseRef,
|
|
16824
|
+
headRef,
|
|
16825
|
+
resolved: [],
|
|
16826
|
+
introduced: [],
|
|
16827
|
+
unknownFacts
|
|
16828
|
+
};
|
|
16829
|
+
let tmpDir;
|
|
16830
|
+
try {
|
|
16831
|
+
tmpDir = mkdtempSync(join(tmpdir(), "mjolnir-impact-"));
|
|
16832
|
+
} catch {
|
|
16833
|
+
return {
|
|
16834
|
+
hasComparison: false,
|
|
16835
|
+
unknownReason: "tree-materialize-failed",
|
|
16836
|
+
baseRef,
|
|
16837
|
+
headRef,
|
|
16838
|
+
resolved: [],
|
|
16839
|
+
introduced: [],
|
|
16840
|
+
unknownFacts
|
|
16841
|
+
};
|
|
16310
16842
|
}
|
|
16311
|
-
|
|
16312
|
-
|
|
16313
|
-
|
|
16314
|
-
|
|
16315
|
-
|
|
16316
|
-
|
|
16317
|
-
|
|
16318
|
-
|
|
16319
|
-
|
|
16320
|
-
|
|
16321
|
-
|
|
16322
|
-
|
|
16843
|
+
let baseResult;
|
|
16844
|
+
let baseTreeTruncated;
|
|
16845
|
+
try {
|
|
16846
|
+
const treeListing = git(root, [
|
|
16847
|
+
"ls-tree",
|
|
16848
|
+
"-r",
|
|
16849
|
+
"--name-only",
|
|
16850
|
+
"-z",
|
|
16851
|
+
baseRef
|
|
16852
|
+
]);
|
|
16853
|
+
if (treeListing === null) return {
|
|
16854
|
+
hasComparison: false,
|
|
16855
|
+
unknownReason: "tree-listing-failed",
|
|
16856
|
+
baseRef,
|
|
16857
|
+
headRef,
|
|
16858
|
+
resolved: [],
|
|
16859
|
+
introduced: [],
|
|
16860
|
+
unknownFacts
|
|
16861
|
+
};
|
|
16862
|
+
const paths = treeListing.split("\0").filter(Boolean);
|
|
16863
|
+
const MAX_FILES = 2e4;
|
|
16864
|
+
const truncated = paths.length > MAX_FILES;
|
|
16865
|
+
for (const relPath of paths.slice(0, MAX_FILES)) {
|
|
16866
|
+
const blob = gitBuffer(root, ["show", `${baseRef}:${relPath}`]);
|
|
16867
|
+
if (blob === null) continue;
|
|
16868
|
+
const dest = join(tmpDir, relPath);
|
|
16869
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
16870
|
+
writeFileSync(dest, blob);
|
|
16871
|
+
}
|
|
16872
|
+
if (truncated) {
|
|
16873
|
+
baseTreeTruncated = {
|
|
16874
|
+
scanned: Math.min(paths.length, MAX_FILES),
|
|
16875
|
+
total: paths.length
|
|
16876
|
+
};
|
|
16877
|
+
unknownFacts.push(`Base tree materialization truncated at ${MAX_FILES} of ${paths.length} paths — files beyond the cap were never scanned at base, so findings that exist only there are misreported here as new debt.`);
|
|
16878
|
+
}
|
|
16879
|
+
baseResult = await options.runScan(tmpDir);
|
|
16880
|
+
} catch {
|
|
16881
|
+
return {
|
|
16882
|
+
hasComparison: false,
|
|
16883
|
+
unknownReason: "tree-materialize-failed",
|
|
16884
|
+
baseRef,
|
|
16885
|
+
headRef,
|
|
16886
|
+
resolved: [],
|
|
16887
|
+
introduced: [],
|
|
16888
|
+
unknownFacts
|
|
16889
|
+
};
|
|
16890
|
+
} finally {
|
|
16891
|
+
try {
|
|
16892
|
+
rmSync(tmpDir, {
|
|
16893
|
+
recursive: true,
|
|
16894
|
+
force: true
|
|
16895
|
+
});
|
|
16896
|
+
} catch {}
|
|
16323
16897
|
}
|
|
16898
|
+
const headResult = await options.runScan(root);
|
|
16899
|
+
const baseSet = /* @__PURE__ */ new Map();
|
|
16900
|
+
for (const f of baseResult.findings) baseSet.set(fingerprint(f), f);
|
|
16901
|
+
const headSet = /* @__PURE__ */ new Map();
|
|
16902
|
+
for (const f of headResult.findings) headSet.set(fingerprint(f), f);
|
|
16903
|
+
const resolved = [];
|
|
16904
|
+
for (const [key, f] of baseSet) if (!headSet.has(key)) resolved.push({
|
|
16905
|
+
ruleId: f.ruleId,
|
|
16906
|
+
file: f.file,
|
|
16907
|
+
message: f.message
|
|
16908
|
+
});
|
|
16909
|
+
const introduced = [];
|
|
16910
|
+
for (const [key, f] of headSet) if (!baseSet.has(key)) introduced.push({
|
|
16911
|
+
ruleId: f.ruleId,
|
|
16912
|
+
file: f.file,
|
|
16913
|
+
message: f.message
|
|
16914
|
+
});
|
|
16324
16915
|
return {
|
|
16325
|
-
|
|
16326
|
-
|
|
16327
|
-
|
|
16328
|
-
...
|
|
16329
|
-
|
|
16330
|
-
|
|
16331
|
-
|
|
16916
|
+
hasComparison: true,
|
|
16917
|
+
baseRef,
|
|
16918
|
+
headRef,
|
|
16919
|
+
...baseTreeTruncated ? { baseTreeTruncated } : {},
|
|
16920
|
+
resolved: resolved.sort((a, b) => a.file.localeCompare(b.file)),
|
|
16921
|
+
introduced: introduced.sort((a, b) => a.file.localeCompare(b.file)),
|
|
16922
|
+
unknownFacts
|
|
16332
16923
|
};
|
|
16333
16924
|
}
|
|
16334
|
-
function
|
|
16335
|
-
const lines = [
|
|
16336
|
-
sectionHeader("BASELINE SAVED", ui$7),
|
|
16337
|
-
"",
|
|
16338
|
-
`Captured ${count} finding${count === 1 ? "" : "s"} to ${path}.`
|
|
16339
|
-
];
|
|
16340
|
-
if (replaced?.backupPath !== void 0) lines.push(`Replaced an existing baseline — the previous one was saved to ${replaced.backupPath}.`);
|
|
16341
|
-
lines.push(nextStep("mjolnir diff", ui$7) + " — see only what's new.");
|
|
16342
|
-
return lines.join("\n");
|
|
16343
|
-
}
|
|
16344
|
-
function renderBaselineDiff(diff) {
|
|
16925
|
+
function renderImpact(report) {
|
|
16345
16926
|
const lines = [];
|
|
16346
|
-
lines.push(sectionHeader("
|
|
16927
|
+
lines.push(sectionHeader("IMPACT REPORT", ui$6));
|
|
16347
16928
|
lines.push("");
|
|
16348
|
-
if (!
|
|
16349
|
-
lines.push(
|
|
16350
|
-
lines.push(
|
|
16929
|
+
if (!report.hasComparison) {
|
|
16930
|
+
lines.push(`UNKNOWN — no comparison could be made (${report.unknownReason ?? "unknown reason"}).`);
|
|
16931
|
+
lines.push("This is reported as UNKNOWN rather than a fabricated zero: mjolnir");
|
|
16932
|
+
lines.push("never invents a number it cannot prove.");
|
|
16933
|
+
lines.push("");
|
|
16934
|
+
for (const fact of report.unknownFacts) lines.push(`UNKNOWN: ${fact}`);
|
|
16351
16935
|
return lines.join("\n");
|
|
16352
16936
|
}
|
|
16353
|
-
lines.push(`
|
|
16354
|
-
lines.push(`${diff.unchangedCount} pre-existing finding${diff.unchangedCount === 1 ? "" : "s"} carried over — not reported as new.`);
|
|
16937
|
+
lines.push(`Comparing ${report.baseRef?.slice(0, 12)} → ${report.headRef?.slice(0, 12)}`);
|
|
16355
16938
|
lines.push("");
|
|
16356
|
-
if (
|
|
16939
|
+
if (report.baseTreeTruncated) {
|
|
16940
|
+
lines.push(`UNKNOWN: base tree truncated — scanned ${report.baseTreeTruncated.scanned} of ${report.baseTreeTruncated.total} paths.`);
|
|
16941
|
+
lines.push("Files beyond the cap were never scanned at base; their findings are misreported as new debt.");
|
|
16942
|
+
lines.push("");
|
|
16943
|
+
}
|
|
16944
|
+
if (report.resolved.length === 0) lines.push("FIXED SINCE BASE: none found.");
|
|
16357
16945
|
else {
|
|
16358
|
-
lines.push(`
|
|
16359
|
-
for (const f of
|
|
16946
|
+
lines.push(`FIXED SINCE BASE (${report.resolved.length}) — real, evidence-backed:`);
|
|
16947
|
+
for (const f of report.resolved) lines.push(` ✓ ${f.ruleId} · ${f.file} — ${f.message}`);
|
|
16360
16948
|
}
|
|
16361
16949
|
lines.push("");
|
|
16362
|
-
if (
|
|
16363
|
-
|
|
16364
|
-
|
|
16365
|
-
|
|
16366
|
-
lines.push(`FIXED SINCE BASELINE (${verified.length}):`);
|
|
16367
|
-
for (const f of verified) lines.push(` ✓ ${f.ruleId} (${f.severity}) · ${f.file} — ${f.message}`);
|
|
16368
|
-
lines.push("");
|
|
16369
|
-
}
|
|
16370
|
-
if (unresolved.length > 0) {
|
|
16371
|
-
lines.push(`DISAPPEARED — NOT CLASSIFIED AS FIXED (${unresolved.length}):`);
|
|
16372
|
-
for (const f of unresolved) lines.push(` ${renderResolution(f.resolution)} · ${f.ruleId} (${f.severity}) · ${f.file} — ${f.message}`);
|
|
16373
|
-
}
|
|
16950
|
+
if (report.introduced.length === 0) lines.push("NEW DEBT SINCE BASE: none found.");
|
|
16951
|
+
else {
|
|
16952
|
+
lines.push(`NEW DEBT SINCE BASE (${report.introduced.length}):`);
|
|
16953
|
+
for (const f of report.introduced) lines.push(` + ${f.ruleId} · ${f.file} — ${f.message}`);
|
|
16374
16954
|
}
|
|
16955
|
+
lines.push("");
|
|
16956
|
+
for (const fact of report.unknownFacts) lines.push(`UNKNOWN: ${fact}`);
|
|
16375
16957
|
return lines.join("\n");
|
|
16376
16958
|
}
|
|
16377
16959
|
//#endregion
|
|
@@ -16402,7 +16984,7 @@ function renderBaselineDiff(diff) {
|
|
|
16402
16984
|
* fix recorded by `diff`), announced once and never repeated. Display-only:
|
|
16403
16985
|
* it does not change scores, exit codes or the JSON schema.
|
|
16404
16986
|
*/
|
|
16405
|
-
const ui$
|
|
16987
|
+
const ui$5 = plainContext();
|
|
16406
16988
|
const DEFAULT_STATS_PATH = join(".mjolnir", "stats.json");
|
|
16407
16989
|
const MILESTONE_MESSAGES = {
|
|
16408
16990
|
"first-clean-scan": "MILESTONE: first flawless scan recorded for this repo (score 100, zero findings).",
|
|
@@ -16490,13 +17072,13 @@ function saveStats(stats, outPath) {
|
|
|
16490
17072
|
}
|
|
16491
17073
|
function renderStats(stats) {
|
|
16492
17074
|
const lines = [];
|
|
16493
|
-
lines.push(sectionHeader("ALL-TIME STATS (this machine, this repo)", ui$
|
|
17075
|
+
lines.push(sectionHeader("ALL-TIME STATS (this machine, this repo)", ui$5));
|
|
16494
17076
|
lines.push("");
|
|
16495
17077
|
if (!stats || stats.recordedFixEvents === 0) {
|
|
16496
17078
|
lines.push("No fixes recorded yet.");
|
|
16497
17079
|
lines.push("Fixes are counted here only when observed by mjolnir diff —", "capture a baseline first, then diff after making fixes:");
|
|
16498
|
-
lines.push(nextStep("mjolnir baseline", ui$
|
|
16499
|
-
lines.push(nextStep("mjolnir diff", ui$
|
|
17080
|
+
lines.push(nextStep("mjolnir baseline", ui$5));
|
|
17081
|
+
lines.push(nextStep("mjolnir diff", ui$5));
|
|
16500
17082
|
lines.push("");
|
|
16501
17083
|
lines.push("UNKNOWN: totals before tracking started. This command only counts");
|
|
16502
17084
|
lines.push("what it has personally witnessed via mjolnir diff.");
|
|
@@ -16525,7 +17107,7 @@ function renderStats(stats) {
|
|
|
16525
17107
|
*
|
|
16526
17108
|
* Idempotent: existing files are reported, never overwritten.
|
|
16527
17109
|
*/
|
|
16528
|
-
const ui$
|
|
17110
|
+
const ui$4 = plainContext();
|
|
16529
17111
|
function runInit(rootDir, workspace, options = {}) {
|
|
16530
17112
|
const steps = [];
|
|
16531
17113
|
const nextCommands = [];
|
|
@@ -16575,7 +17157,7 @@ function runInit(rootDir, workspace, options = {}) {
|
|
|
16575
17157
|
}
|
|
16576
17158
|
function renderInit(result) {
|
|
16577
17159
|
const lines = [];
|
|
16578
|
-
lines.push(sectionHeader("MJÖLNIR INIT", ui$
|
|
17160
|
+
lines.push(sectionHeader("MJÖLNIR INIT", ui$4));
|
|
16579
17161
|
lines.push("");
|
|
16580
17162
|
for (const s of result.steps) {
|
|
16581
17163
|
const icon = s.status === "advice" ? "·" : s.status === "exists" ? "=" : "-";
|
|
@@ -16584,7 +17166,7 @@ function renderInit(result) {
|
|
|
16584
17166
|
if (result.nextCommands.length > 0) {
|
|
16585
17167
|
lines.push("");
|
|
16586
17168
|
lines.push("Next commands:");
|
|
16587
|
-
for (const c of result.nextCommands) lines.push(nextStep(c, ui$
|
|
17169
|
+
for (const c of result.nextCommands) lines.push(nextStep(c, ui$4));
|
|
16588
17170
|
}
|
|
16589
17171
|
lines.push("");
|
|
16590
17172
|
lines.push("Existing files are never overwritten — init is safe to re-run.");
|
|
@@ -16602,7 +17184,7 @@ function tryReadPackageJson(rootDir) {
|
|
|
16602
17184
|
}
|
|
16603
17185
|
//#endregion
|
|
16604
17186
|
//#region src/commands/pw-report.ts
|
|
16605
|
-
const ui$
|
|
17187
|
+
const ui$3 = plainContext();
|
|
16606
17188
|
function summarizePwRun(report) {
|
|
16607
17189
|
const slowest = [...report.verdicts].sort((a, b) => b.totalDurationMs - a.totalDurationMs).slice(0, 5).map((v) => ({
|
|
16608
17190
|
title: v.title,
|
|
@@ -16622,7 +17204,7 @@ function summarizePwRun(report) {
|
|
|
16622
17204
|
}
|
|
16623
17205
|
function renderPwRunSummary(s) {
|
|
16624
17206
|
const lines = [];
|
|
16625
|
-
lines.push(sectionHeader("MJÖLNIR — RUN SUMMARY", ui$
|
|
17207
|
+
lines.push(sectionHeader("MJÖLNIR — RUN SUMMARY", ui$3));
|
|
16626
17208
|
lines.push("");
|
|
16627
17209
|
lines.push(`${s.total} tests · ${s.passed} passed · ${s.failed} failed · ${s.skipped} skipped`);
|
|
16628
17210
|
if (s.retried > 0 || s.trueFlakes > 0) lines.push(`↻ ${s.retried} retried · ${FLAKE_GLYPH} ${s.trueFlakes} TRUE-FLAKE${s.trueFlakes === 1 ? "" : "S"} (passed only on attempt ≥2)`);
|
|
@@ -16653,7 +17235,7 @@ function renderPwRunSummary(s) {
|
|
|
16653
17235
|
* Everything else stays suggestion-only. No AST surgery on heuristic
|
|
16654
17236
|
* findings — false fixes would break the brand promise.
|
|
16655
17237
|
*/
|
|
16656
|
-
const ui$
|
|
17238
|
+
const ui$2 = plainContext();
|
|
16657
17239
|
const MAX_FILE_BYTES = 524288;
|
|
16658
17240
|
/**
|
|
16659
17241
|
* Path-containment guard (adversarial-audit wave; hardened per audit
|
|
@@ -17014,7 +17596,7 @@ function fixVerified(edit, fixedText, originalText) {
|
|
|
17014
17596
|
}
|
|
17015
17597
|
function renderFixReport(results, dryRun) {
|
|
17016
17598
|
const lines = [];
|
|
17017
|
-
lines.push(sectionHeader(dryRun ? "FIX PLAN (dry-run)" : "FIX REPORT", ui$
|
|
17599
|
+
lines.push(sectionHeader(dryRun ? "FIX PLAN (dry-run)" : "FIX REPORT", ui$2));
|
|
17018
17600
|
lines.push("");
|
|
17019
17601
|
if (results.length === 0) {
|
|
17020
17602
|
lines.push("No safe auto-fixes available for these findings.");
|
|
@@ -17022,7 +17604,7 @@ function renderFixReport(results, dryRun) {
|
|
|
17022
17604
|
return lines.join("\n");
|
|
17023
17605
|
}
|
|
17024
17606
|
for (const r of results) {
|
|
17025
|
-
const icon = r.status === "applied" ? okIcon(ui$
|
|
17607
|
+
const icon = r.status === "applied" ? okIcon(ui$2) : r.status === "planned" ? "▸" : "✗";
|
|
17026
17608
|
lines.push(`${icon} [${r.ruleId}] ${r.file}:${r.line} — ${r.description}`);
|
|
17027
17609
|
}
|
|
17028
17610
|
const applied = results.filter((r) => r.status === "applied").length;
|
|
@@ -17038,34 +17620,6 @@ function renderFixReport(results, dryRun) {
|
|
|
17038
17620
|
return lines.join("\n");
|
|
17039
17621
|
}
|
|
17040
17622
|
//#endregion
|
|
17041
|
-
//#region src/rules/measurement.ts
|
|
17042
|
-
/** The rule's declared detector implementation revision (§07). */
|
|
17043
|
-
function declaredDetectorRevision(rule) {
|
|
17044
|
-
return rule.detectorRevision ?? 1;
|
|
17045
|
-
}
|
|
17046
|
-
/**
|
|
17047
|
-
* A measurement exists AND was taken against the detector revision the
|
|
17048
|
-
* rule declares now. A revision mismatch (stale) does NOT count — §07:
|
|
17049
|
-
* stale → provisional → re-measure.
|
|
17050
|
-
*/
|
|
17051
|
-
function hasValidMeasurement(rule) {
|
|
17052
|
-
const m = MEASURED_FP[rule.id];
|
|
17053
|
-
return m !== void 0 && m.detectorRevision === declaredDetectorRevision(rule);
|
|
17054
|
-
}
|
|
17055
|
-
/**
|
|
17056
|
-
* The tier a rule effectively ships as: its declared tier, or — for the
|
|
17057
|
-
* omitted-tier case — the measurement-dependent default (§11.2 Step 2).
|
|
17058
|
-
*/
|
|
17059
|
-
function effectiveTier(rule) {
|
|
17060
|
-
if (rule.tier !== void 0) return rule.tier;
|
|
17061
|
-
if (hasValidMeasurement(rule)) return "core";
|
|
17062
|
-
return "extended";
|
|
17063
|
-
}
|
|
17064
|
-
/** The §11.2 Step 2 PROVISIONAL display predicate. */
|
|
17065
|
-
function isProvisional(rule) {
|
|
17066
|
-
return effectiveTier(rule) === "extended" && !hasValidMeasurement(rule);
|
|
17067
|
-
}
|
|
17068
|
-
//#endregion
|
|
17069
17623
|
//#region src/commands/doctor.ts
|
|
17070
17624
|
/**
|
|
17071
17625
|
* `mjolnir doctor` — self-audit of Mjolnir's own rule base.
|
|
@@ -17082,7 +17636,7 @@ function isProvisional(rule) {
|
|
|
17082
17636
|
*
|
|
17083
17637
|
* Exit codes reuse the frozen set: 0 healthy · 1 violations · 20 crash.
|
|
17084
17638
|
*/
|
|
17085
|
-
const ui$
|
|
17639
|
+
const ui$1 = plainContext();
|
|
17086
17640
|
const VALID_ID = RULE_ID_RE;
|
|
17087
17641
|
function nonHiddenFiles(dir) {
|
|
17088
17642
|
if (!existsSync(dir)) return [];
|
|
@@ -17281,7 +17835,7 @@ function runDoctorSelfAudit(fixturesRoot) {
|
|
|
17281
17835
|
function renderDoctorReport(report) {
|
|
17282
17836
|
const lines = [
|
|
17283
17837
|
"",
|
|
17284
|
-
sectionHeader("MJÖLNIR — SELF-AUDIT", ui$
|
|
17838
|
+
sectionHeader("MJÖLNIR — SELF-AUDIT", ui$1),
|
|
17285
17839
|
""
|
|
17286
17840
|
];
|
|
17287
17841
|
for (const c of report.checks) {
|
|
@@ -17359,145 +17913,6 @@ function escapeMdCell(text) {
|
|
|
17359
17913
|
return text.replaceAll("|", "\\|");
|
|
17360
17914
|
}
|
|
17361
17915
|
//#endregion
|
|
17362
|
-
//#region src/commands/fixture-example.ts
|
|
17363
|
-
/**
|
|
17364
|
-
* Shared example-fixture selection for `mjolnir explain` (explain.ts)
|
|
17365
|
-
* and the generated rule docs (rule-docs.ts) — one chooser so both
|
|
17366
|
-
* surfaces always show the same example for the same rule. Extracted
|
|
17367
|
-
* after the same bug-audit L9 fix had to be applied to both verbatim
|
|
17368
|
-
* copies: duplicated fixture selection drifts, and a drift means
|
|
17369
|
-
* `mjolnir explain <ID>` contradicts docs/rules/<ID>.md.
|
|
17370
|
-
*/
|
|
17371
|
-
/**
|
|
17372
|
-
* First (byte-stable) fixture file in a must-fire / must-not-fire
|
|
17373
|
-
* directory, or null when the directory is absent or empty.
|
|
17374
|
-
*/
|
|
17375
|
-
function firstFixtureFile(dir) {
|
|
17376
|
-
if (!existsSync(dir)) return null;
|
|
17377
|
-
const entries = readdirSync(dir).filter((f) => !f.startsWith(".")).sort();
|
|
17378
|
-
return entries.length > 0 ? join(dir, entries[0]) : null;
|
|
17379
|
-
}
|
|
17380
|
-
//#endregion
|
|
17381
|
-
//#region src/commands/explain.ts
|
|
17382
|
-
/**
|
|
17383
|
-
* `mjolnir explain <RULE-ID>` — implements Plan.md Sprint 1.3
|
|
17384
|
-
* (Master-Stabilization-Plan Sprint 5, Task 19).
|
|
17385
|
-
*
|
|
17386
|
-
* For any registered rule, renders what is wrong, why it matters, the
|
|
17387
|
-
* evidence level and confidence behind the verdict, the prescription,
|
|
17388
|
-
* and how to verify the fix. This is a presentation layer only — no new
|
|
17389
|
-
* detection logic. Every field it prints already exists on RuleMeta or
|
|
17390
|
-
* comes from actually running the rule against its own committed
|
|
17391
|
-
* must-fire fixture, so the example shown is real detector output, not
|
|
17392
|
-
* hand-written prose that can drift from what the rule actually does.
|
|
17393
|
-
*/
|
|
17394
|
-
const ui$1 = plainContext();
|
|
17395
|
-
/**
|
|
17396
|
-
* Runs the rule against its own must-fire fixture to get one real,
|
|
17397
|
-
* concrete example finding. `fixturesRoot` defaults to this repo's own
|
|
17398
|
-
* `tests/fixtures` — explain only has real examples to show when run
|
|
17399
|
-
* from (or pointed at) a Mjolnir checkout; degrades honestly
|
|
17400
|
-
* (exampleFinding left undefined) otherwise, same as `doctor`.
|
|
17401
|
-
*/
|
|
17402
|
-
function explainRule(ruleId, fixturesRoot) {
|
|
17403
|
-
const rule = getRule(ruleId);
|
|
17404
|
-
if (!rule) return {
|
|
17405
|
-
ok: false,
|
|
17406
|
-
error: `Unknown rule ID "${ruleId}". Run \`mjolnir rules\` for the full catalog.`
|
|
17407
|
-
};
|
|
17408
|
-
const fixturePath = firstFixtureFile(join(fixturesRoot, ruleId, "must-fire"));
|
|
17409
|
-
if (!fixturePath) return {
|
|
17410
|
-
ok: true,
|
|
17411
|
-
rule
|
|
17412
|
-
};
|
|
17413
|
-
let text;
|
|
17414
|
-
try {
|
|
17415
|
-
text = readFileSync(fixturePath, "utf8").replace(/\r\n/g, "\n");
|
|
17416
|
-
} catch {
|
|
17417
|
-
return {
|
|
17418
|
-
ok: true,
|
|
17419
|
-
rule
|
|
17420
|
-
};
|
|
17421
|
-
}
|
|
17422
|
-
const normalizedPath = fixturePath.replaceAll("\\", "/");
|
|
17423
|
-
let ast;
|
|
17424
|
-
if (rule.appliesTo === "ci-workflows") try {
|
|
17425
|
-
ast = parseWorkflow(text);
|
|
17426
|
-
} catch {
|
|
17427
|
-
return {
|
|
17428
|
-
ok: true,
|
|
17429
|
-
rule
|
|
17430
|
-
};
|
|
17431
|
-
}
|
|
17432
|
-
let findings;
|
|
17433
|
-
try {
|
|
17434
|
-
const parsed = {
|
|
17435
|
-
path: normalizedPath,
|
|
17436
|
-
text,
|
|
17437
|
-
ast
|
|
17438
|
-
};
|
|
17439
|
-
const codeText = computeCodeText(parsed, normalizedPath.endsWith(".py") ? "python" : normalizedPath.endsWith(".java") ? "java" : normalizedPath.endsWith(".cs") ? "csharp" : "typescript");
|
|
17440
|
-
findings = rule.run({
|
|
17441
|
-
...parsed,
|
|
17442
|
-
codeText
|
|
17443
|
-
});
|
|
17444
|
-
} catch {
|
|
17445
|
-
return {
|
|
17446
|
-
ok: true,
|
|
17447
|
-
rule
|
|
17448
|
-
};
|
|
17449
|
-
}
|
|
17450
|
-
const example = findings[0];
|
|
17451
|
-
if (!example) return {
|
|
17452
|
-
ok: true,
|
|
17453
|
-
rule
|
|
17454
|
-
};
|
|
17455
|
-
return {
|
|
17456
|
-
ok: true,
|
|
17457
|
-
rule,
|
|
17458
|
-
exampleFinding: example,
|
|
17459
|
-
exampleFixturePath: fixturePath
|
|
17460
|
-
};
|
|
17461
|
-
}
|
|
17462
|
-
function renderExplain(result) {
|
|
17463
|
-
if (!result.ok || !result.rule) return `explain failed: ${result.error ?? "unknown error"}`;
|
|
17464
|
-
const r = result.rule;
|
|
17465
|
-
const evidenceLevel = r.evidenceLevel ?? deriveEvidenceLevel(r.findingType, r.confidence);
|
|
17466
|
-
const lines = [];
|
|
17467
|
-
lines.push(sectionHeader(`${r.id} — ${r.title}`, ui$1));
|
|
17468
|
-
lines.push("");
|
|
17469
|
-
lines.push(`Severity: ${r.severity}`);
|
|
17470
|
-
lines.push(`Confidence: ${r.confidence}`);
|
|
17471
|
-
lines.push(`Tier: ${effectiveTier(r)}${isProvisional(r) ? " (PROVISIONAL)" : ""}`);
|
|
17472
|
-
lines.push(`Evidence: ${evidenceLevel}`);
|
|
17473
|
-
lines.push(`QA impact: ${QA_IMPACT_LABELS[r.qaImpact]} (${r.qaImpact})`);
|
|
17474
|
-
const measured = MEASURED_FP[r.id];
|
|
17475
|
-
lines.push(measured ? `Measured FP: ${Math.round(measured.fpRate * 100)}% (${measured.n} hand-classified corpus verdicts)` : `Measured FP: not yet measured — this rule ships on assumption (see docs/FP-AUDIT.md)`);
|
|
17476
|
-
if (r.falsePositiveRisk) lines.push(`FP risk: ${r.falsePositiveRisk} (author estimate)`);
|
|
17477
|
-
if (r.languages?.length) lines.push(`Languages: ${r.languages.join(", ")}`);
|
|
17478
|
-
if (r.frameworks?.length) lines.push(`Frameworks: ${r.frameworks.join(", ")}`);
|
|
17479
|
-
lines.push("");
|
|
17480
|
-
if (result.exampleFinding) {
|
|
17481
|
-
const f = result.exampleFinding;
|
|
17482
|
-
lines.push("WHAT WAS FOUND (real detector output, not a mockup)");
|
|
17483
|
-
lines.push(` ${f.message}`);
|
|
17484
|
-
lines.push("");
|
|
17485
|
-
lines.push("WHY IT MATTERS");
|
|
17486
|
-
lines.push(` ${f.why}`);
|
|
17487
|
-
lines.push("");
|
|
17488
|
-
lines.push("HOW TO FIX");
|
|
17489
|
-
lines.push(` ${f.fix}`);
|
|
17490
|
-
lines.push("");
|
|
17491
|
-
lines.push(`Example from this rule's own must-fire fixture: ${result.exampleFixturePath ?? "(unknown path)"}`);
|
|
17492
|
-
} else lines.push("No example available — run this command from a mjolnir checkout (or pass --fixtures-root) so the fixture that proves this rule works can be shown as a real example.");
|
|
17493
|
-
lines.push("");
|
|
17494
|
-
lines.push("HOW TO VERIFY THE FIX");
|
|
17495
|
-
lines.push(" Re-run `mjolnir` on the changed file(s) — this finding should no longer appear. `mjolnir --scope changed` scopes the check to just what you touched.");
|
|
17496
|
-
lines.push("");
|
|
17497
|
-
lines.push(`Docs: mjolnir rules --md (full catalog, this rule included)`);
|
|
17498
|
-
return lines.join("\n");
|
|
17499
|
-
}
|
|
17500
|
-
//#endregion
|
|
17501
17916
|
//#region src/playwright/selector-health-types.ts
|
|
17502
17917
|
/** Risk weights per locator shape (0 = resilient, higher = brittle). */
|
|
17503
17918
|
const LOCATOR_RISK = {
|
|
@@ -17649,7 +18064,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
|
|
|
17649
18064
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
17650
18065
|
* `tests/version-consistency.spec.ts` locally.
|
|
17651
18066
|
*/
|
|
17652
|
-
const CLI_VERSION = "0.5.
|
|
18067
|
+
const CLI_VERSION = "0.5.14";
|
|
17653
18068
|
function parseArgs(argv, onError) {
|
|
17654
18069
|
const args = {
|
|
17655
18070
|
target: ".",
|
|
@@ -18513,7 +18928,8 @@ const SUBCOMMANDS = /* @__PURE__ */ new Set([
|
|
|
18513
18928
|
"doctor",
|
|
18514
18929
|
"rules",
|
|
18515
18930
|
"explain",
|
|
18516
|
-
"doctor:playwright"
|
|
18931
|
+
"doctor:playwright",
|
|
18932
|
+
"mcp"
|
|
18517
18933
|
]);
|
|
18518
18934
|
async function main(argv = process$1.argv.slice(2), io = {
|
|
18519
18935
|
out,
|
|
@@ -18550,6 +18966,10 @@ async function main(argv = process$1.argv.slice(2), io = {
|
|
|
18550
18966
|
if (argv[0] === "why") return runWhyCommand(argv.slice(1), io);
|
|
18551
18967
|
if (argv[0] === "handoff") return runHandoffCommand(argv.slice(1), io);
|
|
18552
18968
|
if (argv[0] === "install") return runInstallCommand(argv.slice(1), io);
|
|
18969
|
+
if (argv[0] === "mcp") {
|
|
18970
|
+
await runStdioTransport(process$1.stdin, process$1.stdout);
|
|
18971
|
+
return 0;
|
|
18972
|
+
}
|
|
18553
18973
|
if (argv[0] === "help") return runHelpCommand(argv.slice(1), io);
|
|
18554
18974
|
if (SUBCOMMANDS.has(argv[0] ?? "")) {
|
|
18555
18975
|
err(`mjolnir: incomplete or unknown subcommand "${argv[0]}".`);
|