mjolnir-qa 0.5.13 → 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 +13 -0
- package/README.md +187 -66
- package/dist/cli.d.mts +1 -1
- package/dist/cli.mjs +1594 -1218
- package/dist/mcp/stdio.mjs +24 -5
- package/package.json +2 -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;
|
|
@@ -13253,7 +13254,7 @@ function renderSarif(result, repoRootUri) {
|
|
|
13253
13254
|
tool: { driver: {
|
|
13254
13255
|
name: "Mjölnir",
|
|
13255
13256
|
informationUri: "https://github.com/Sergey-Bar/Mjolnir",
|
|
13256
|
-
version: "0.5.
|
|
13257
|
+
version: "0.5.14",
|
|
13257
13258
|
rules: [...rules.values()].map((r) => {
|
|
13258
13259
|
const meta = RULES.find((x) => x.id === r.id);
|
|
13259
13260
|
return {
|
|
@@ -14675,307 +14676,185 @@ function executeHookInstall(entry) {
|
|
|
14675
14676
|
}
|
|
14676
14677
|
}
|
|
14677
14678
|
//#endregion
|
|
14678
|
-
//#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
|
+
}
|
|
14679
14684
|
/**
|
|
14680
|
-
*
|
|
14681
|
-
*
|
|
14682
|
-
*
|
|
14683
|
-
*
|
|
14684
|
-
* Bug-audit hardening (H2): the previous template shipped the same
|
|
14685
|
-
* `github.rest.checks` no-op this repo's own audit removed from mjolnir.yml,
|
|
14686
|
-
* failed the job before the annotate/summary steps could run whenever the
|
|
14687
|
-
* scan step exited non-zero, recommended floating `mjolnir-qa@latest`, and
|
|
14688
|
-
* `ciInstall` silently overwrote hand-customized workflows. The template now
|
|
14689
|
-
* mirrors the dogfooded `.github/workflows/mjolnir.yml` (pinned action SHAs,
|
|
14690
|
-
* `if: always()` on reporting steps, a real gate step that reads
|
|
14691
|
-
* `mjolnir.json`, partial scans never block) and `ciInstall` refuses to
|
|
14692
|
-
* 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.
|
|
14693
14688
|
*/
|
|
14689
|
+
function hasValidMeasurement(rule) {
|
|
14690
|
+
const m = MEASURED_FP[rule.id];
|
|
14691
|
+
return m !== void 0 && m.detectorRevision === declaredDetectorRevision(rule);
|
|
14692
|
+
}
|
|
14694
14693
|
/**
|
|
14695
|
-
* The
|
|
14696
|
-
*
|
|
14697
|
-
* the tool's own exit-code contract:
|
|
14698
|
-
* - missing/unreadable `mjolnir.json` → fail (the scan step crashed; a
|
|
14699
|
-
* silent pass here would turn a broken pipeline into a green one);
|
|
14700
|
-
* - `partial: true` → never block (truncated results can neither prove
|
|
14701
|
-
* nor disprove the gate — the "PARTIAL" banner in the summary is the
|
|
14702
|
-
* honest signal);
|
|
14703
|
-
* - `error` gate → block on any error-severity finding;
|
|
14704
|
-
* - `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).
|
|
14705
14696
|
*/
|
|
14706
|
-
function
|
|
14707
|
-
return
|
|
14708
|
-
|
|
14709
|
-
|
|
14710
|
-
"try {",
|
|
14711
|
-
" r = JSON.parse(fs.readFileSync(\"mjolnir.json\", \"utf8\"));",
|
|
14712
|
-
"} catch (e) {",
|
|
14713
|
-
" process.stderr.write(\"mjolnir.json is missing or unreadable - the scan step crashed before the gate could run. Failing instead of passing silently.\\n\");",
|
|
14714
|
-
" process.exit(1);",
|
|
14715
|
-
"}",
|
|
14716
|
-
"if (r.partial === true) {",
|
|
14717
|
-
" process.stdout.write(\"Scan was PARTIAL - some files were not analyzed; gate not enforced.\\n\");",
|
|
14718
|
-
" process.exit(0);",
|
|
14719
|
-
"}",
|
|
14720
|
-
"const findings = Array.isArray(r.findings) ? r.findings : [];",
|
|
14721
|
-
"const errors = findings.filter(function (f) { return f && f.severity === \"error\"; }).length;",
|
|
14722
|
-
"const warnings = findings.filter(function (f) { return f && f.severity === \"warning\"; }).length;",
|
|
14723
|
-
"process.stdout.write(\"Mjolnir gate: \" + errors + \" error(s), \" + warnings + \" warning(s).\\n\");",
|
|
14724
|
-
`if (${gate === "error" ? "errors > 0" : "errors > 0 || warnings > 0"}) { process.exit(1); }`,
|
|
14725
|
-
"process.exit(0);"
|
|
14726
|
-
].join("\n");
|
|
14697
|
+
function effectiveTier(rule) {
|
|
14698
|
+
if (rule.tier !== void 0) return rule.tier;
|
|
14699
|
+
if (hasValidMeasurement(rule)) return "core";
|
|
14700
|
+
return "extended";
|
|
14727
14701
|
}
|
|
14702
|
+
/** The §11.2 Step 2 PROVISIONAL display predicate. */
|
|
14703
|
+
function isProvisional(rule) {
|
|
14704
|
+
return effectiveTier(rule) === "extended" && !hasValidMeasurement(rule);
|
|
14705
|
+
}
|
|
14706
|
+
//#endregion
|
|
14707
|
+
//#region src/commands/fixture-example.ts
|
|
14728
14708
|
/**
|
|
14729
|
-
*
|
|
14730
|
-
*
|
|
14731
|
-
*
|
|
14732
|
-
*
|
|
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.
|
|
14733
14715
|
*/
|
|
14734
|
-
/**
|
|
14735
|
-
*
|
|
14736
|
-
*
|
|
14737
|
-
|
|
14738
|
-
|
|
14739
|
-
|
|
14740
|
-
|
|
14741
|
-
|
|
14742
|
-
"try { r = JSON.parse(fs.readFileSync(\"mjolnir.json\", \"utf8\")); } catch (e) {}",
|
|
14743
|
-
"const findings = Array.isArray(r.findings) ? r.findings : [];",
|
|
14744
|
-
"const lines = findings.map(function (f) {",
|
|
14745
|
-
" return \"- **\" + f.ruleId + \"** (\" + f.severity + \") \" + f.file + \":\" + f.line + \" - \" + f.message;",
|
|
14746
|
-
"});",
|
|
14747
|
-
"const head = r.partial === true",
|
|
14748
|
-
" ? \"Mjolnir scan was PARTIAL - some files may not have been analyzed.\"",
|
|
14749
|
-
" : \"Mjolnir scan finished.\";",
|
|
14750
|
-
"const body = [\"## Mjolnir findings\", head, \"\"].concat(lines).join(\"\\n\");",
|
|
14751
|
-
"if (process.env.GITHUB_STEP_SUMMARY && lines.length > 0) {",
|
|
14752
|
-
" fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, body + \"\\n\");",
|
|
14753
|
-
"}",
|
|
14754
|
-
"process.stdout.write(lines.length + \" finding(s)\\n\");"
|
|
14755
|
-
].join("\n");
|
|
14756
|
-
/** True when a workflow was generated by any Mjölnir template (v1 or v2).
|
|
14757
|
-
* The v1 needle is matched in its INDENTED form: the v1 template embedded
|
|
14758
|
-
* the script via indentBlock(…, 10) inside the `run: |` scalar, so the
|
|
14759
|
-
* raw unindented substring never appears in a real v1 file. */
|
|
14760
|
-
function isKnownTemplate(content) {
|
|
14761
|
-
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;
|
|
14762
14724
|
}
|
|
14763
|
-
|
|
14764
|
-
|
|
14765
|
-
|
|
14766
|
-
|
|
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
|
+
};
|
|
14767
14807
|
}
|
|
14768
|
-
/**
|
|
14769
|
-
|
|
14770
|
-
|
|
14771
|
-
|
|
14772
|
-
|
|
14773
|
-
|
|
14774
|
-
|
|
14775
|
-
|
|
14776
|
-
|
|
14777
|
-
|
|
14778
|
-
|
|
14779
|
-
|
|
14780
|
-
|
|
14781
|
-
|
|
14782
|
-
jobs:
|
|
14783
|
-
scan:
|
|
14784
|
-
runs-on: ubuntu-latest
|
|
14785
|
-
# A hung scan must not sit for the 6-hour default.
|
|
14786
|
-
timeout-minutes: 10
|
|
14787
|
-
steps:
|
|
14788
|
-
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
14789
|
-
with:
|
|
14790
|
-
fetch-depth: 0 # needed for --scope changed merge-base
|
|
14791
|
-
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
14792
|
-
with:
|
|
14793
|
-
node-version: 22
|
|
14794
|
-
# Scan with the PINNED version that generated this workflow — never
|
|
14795
|
-
# a floating tag: a new release must not change your gate semantics
|
|
14796
|
-
# with no commit of yours. To review PRs with the exact tool your
|
|
14797
|
-
# repo develops against, add mjolnir-qa to devDependencies and drop
|
|
14798
|
-
# the @version suffix so npx resolves the local install.
|
|
14799
|
-
- name: Scan changed code (exit 1/2 is data — the gate step decides)
|
|
14800
|
-
continue-on-error: true
|
|
14801
|
-
run: npx --yes mjolnir-qa@${CLI_VERSION} . --scope changed --json > mjolnir.json
|
|
14802
|
-
# Reporting, not gating: a crashed scan leaves mjolnir.json empty/missing
|
|
14803
|
-
# and the summary step exits 2/10 — continue-on-error keeps the advisory
|
|
14804
|
-
# job green, exactly like the v1 inline script did (the gate step decides).
|
|
14805
|
-
- name: Annotations + Job Summary
|
|
14806
|
-
if: always()
|
|
14807
|
-
continue-on-error: true
|
|
14808
|
-
run: npx --yes mjolnir-qa@${CLI_VERSION} summary mjolnir.json
|
|
14809
|
-
- name: Render PR comment
|
|
14810
|
-
if: always()
|
|
14811
|
-
continue-on-error: true
|
|
14812
|
-
run: npx --yes mjolnir-qa@${CLI_VERSION} pr-comment . > mjolnir-comment.md
|
|
14813
|
-
# Best-effort: on a pull_request event from a fork the GITHUB_TOKEN is
|
|
14814
|
-
# read-only and this step will 403 for every external contributor. The
|
|
14815
|
-
# Job Summary above is the fallback that always renders.
|
|
14816
|
-
# (pull_request_target would fix the token but is a code-execution
|
|
14817
|
-
# risk — deliberately NOT used.)
|
|
14818
|
-
- name: Post or update PR comment
|
|
14819
|
-
if: always()
|
|
14820
|
-
continue-on-error: true
|
|
14821
|
-
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
14822
|
-
with:
|
|
14823
|
-
script: |
|
|
14824
|
-
const fs = require('fs');
|
|
14825
|
-
let body = '';
|
|
14826
|
-
try { body = fs.readFileSync('mjolnir-comment.md', 'utf8'); } catch (e) {}
|
|
14827
|
-
if (!body.trim()) {
|
|
14828
|
-
console.log('mjolnir-comment.md is empty or missing — nothing to post.');
|
|
14829
|
-
return;
|
|
14830
|
-
}
|
|
14831
|
-
const marker = '<!-- mjolnir-pr-comment -->';
|
|
14832
|
-
const { data: comments } = await github.rest.issues.listComments({
|
|
14833
|
-
owner: context.repo.owner,
|
|
14834
|
-
repo: context.repo.repo,
|
|
14835
|
-
issue_number: context.issue.number,
|
|
14836
|
-
});
|
|
14837
|
-
const existing = comments.find((c) => c.body?.startsWith(marker));
|
|
14838
|
-
if (existing) {
|
|
14839
|
-
await github.rest.issues.updateComment({
|
|
14840
|
-
owner: context.repo.owner,
|
|
14841
|
-
repo: context.repo.repo,
|
|
14842
|
-
comment_id: existing.id,
|
|
14843
|
-
body,
|
|
14844
|
-
});
|
|
14845
|
-
} else {
|
|
14846
|
-
await github.rest.issues.createComment({
|
|
14847
|
-
owner: context.repo.owner,
|
|
14848
|
-
repo: context.repo.repo,
|
|
14849
|
-
issue_number: context.issue.number,
|
|
14850
|
-
body,
|
|
14851
|
-
});
|
|
14852
|
-
}
|
|
14853
|
-
${gate === "advisory" ? ` # Advisory mode: findings are reported in the Job Summary and the
|
|
14854
|
-
# PR comment, never blocking. The scan step's exit code is visible
|
|
14855
|
-
# as the step outcome, but continue-on-error keeps the job green.
|
|
14856
|
-
- name: Gate (advisory)
|
|
14857
|
-
if: always()
|
|
14858
|
-
run: echo "Advisory mode — findings reported, never blocking."` : ` # Gate enforcement: the scan step's own exit code is deliberately
|
|
14859
|
-
# neutralized (continue-on-error) so reporting steps always run; THIS
|
|
14860
|
-
# step is what fails the job. A partial scan never blocks.
|
|
14861
|
-
- name: Gate (${gate})
|
|
14862
|
-
if: always()
|
|
14863
|
-
run: |
|
|
14864
|
-
node -e '
|
|
14865
|
-
${indentBlock(gateScript(gate), 10)}
|
|
14866
|
-
'`}
|
|
14867
|
-
`;
|
|
14868
|
-
/** Every gate variant, used to tell "our template" from "hand-customized". */
|
|
14869
|
-
const GATES = [
|
|
14870
|
-
"advisory",
|
|
14871
|
-
"error",
|
|
14872
|
-
"warning"
|
|
14873
|
-
];
|
|
14874
|
-
/** Multiset line diff — counts only, for the refusal message. */
|
|
14875
|
-
function summarizeContentDiff(existing, incoming) {
|
|
14876
|
-
const remaining = /* @__PURE__ */ new Map();
|
|
14877
|
-
for (const line of existing.split(/\r?\n/)) remaining.set(line, (remaining.get(line) ?? 0) + 1);
|
|
14878
|
-
let added = 0;
|
|
14879
|
-
for (const line of incoming.split(/\r?\n/)) {
|
|
14880
|
-
const count = remaining.get(line) ?? 0;
|
|
14881
|
-
if (count > 0) remaining.set(line, count - 1);
|
|
14882
|
-
else added += 1;
|
|
14883
|
-
}
|
|
14884
|
-
let removed = 0;
|
|
14885
|
-
for (const count of remaining.values()) removed += count;
|
|
14886
|
-
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`];
|
|
14887
|
-
}
|
|
14888
|
-
function ciInstall(root, gate = "advisory", options = {}) {
|
|
14889
|
-
const wfDir = join(root, ".github", "workflows");
|
|
14890
|
-
const target = join(wfDir, "mjolnir.yml");
|
|
14891
|
-
if (!existsSync(wfDir)) mkdirSync(wfDir, { recursive: true });
|
|
14892
|
-
const existed = existsSync(target);
|
|
14893
|
-
if (existed) {
|
|
14894
|
-
const current = readFileSync(target, "utf8");
|
|
14895
|
-
if (!isKnownTemplate(current) && !(options.force ?? false)) return {
|
|
14896
|
-
written: target,
|
|
14897
|
-
existed,
|
|
14898
|
-
refused: true,
|
|
14899
|
-
diffSummary: summarizeContentDiff(current, TEMPLATE(gate))
|
|
14900
|
-
};
|
|
14901
|
-
}
|
|
14902
|
-
writeFileSync(target, TEMPLATE(gate));
|
|
14903
|
-
return {
|
|
14904
|
-
written: target,
|
|
14905
|
-
existed,
|
|
14906
|
-
refused: false,
|
|
14907
|
-
diffSummary: []
|
|
14908
|
-
};
|
|
14909
|
-
}
|
|
14910
|
-
//#endregion
|
|
14911
|
-
//#region src/forensics/triage.ts
|
|
14912
|
-
const ui$12 = plainContext();
|
|
14913
|
-
const QUARANTINE_MIN_ATTEMPTS = 2;
|
|
14914
|
-
/** Deterministic triage table rows, worst first. */
|
|
14915
|
-
function triageRows(report) {
|
|
14916
|
-
return report.verdicts.filter((v) => v.everFailed || v.passedOnRetry).sort((a, b) => {
|
|
14917
|
-
if (a.passedOnRetry !== b.passedOnRetry) return a.passedOnRetry ? -1 : 1;
|
|
14918
|
-
if (a.attempts !== b.attempts) return b.attempts - a.attempts;
|
|
14919
|
-
return b.totalDurationMs - a.totalDurationMs;
|
|
14920
|
-
}).map((v) => ({
|
|
14921
|
-
file: v.file,
|
|
14922
|
-
title: v.title,
|
|
14923
|
-
attempts: v.attempts,
|
|
14924
|
-
passedOnRetry: v.passedOnRetry,
|
|
14925
|
-
finalStatus: v.finalStatus,
|
|
14926
|
-
totalDurationMs: v.totalDurationMs,
|
|
14927
|
-
suggestedAction: suggestAction(v),
|
|
14928
|
-
proposedQuarantine: v.attempts >= QUARANTINE_MIN_ATTEMPTS && v.everFailed
|
|
14929
|
-
}));
|
|
14930
|
-
}
|
|
14931
|
-
function suggestAction(v) {
|
|
14932
|
-
if (v.passedOnRetry && v.attempts >= QUARANTINE_MIN_ATTEMPTS) return "quarantine + ticket";
|
|
14933
|
-
if (v.passedOnRetry) return "fix nondeterminism";
|
|
14934
|
-
if (v.finalStatus === "failed" || v.finalStatus === "timedOut") return "fix now — failing";
|
|
14935
|
-
return "investigate";
|
|
14936
|
-
}
|
|
14937
|
-
/** Terminal rendering of the triage proposal. */
|
|
14938
|
-
function renderTriage(report) {
|
|
14939
|
-
const rows = triageRows(report);
|
|
14940
|
-
const lines = [];
|
|
14941
|
-
lines.push(sectionHeader("FLAKY TRIAGE — auto-generated, do not edit", ui$12));
|
|
14942
|
-
lines.push("");
|
|
14943
|
-
if (rows.length === 0) {
|
|
14944
|
-
lines.push("Nothing to triage — no failures or retries in this run.");
|
|
14945
|
-
return lines.join("\n");
|
|
14946
|
-
}
|
|
14947
|
-
const quarantineCount = rows.filter((r) => r.proposedQuarantine).length;
|
|
14948
|
-
for (const r of rows) {
|
|
14949
|
-
const flag = r.passedOnRetry ? "TRUE-FLAKE" : "FAILING";
|
|
14950
|
-
lines.push(`• [${flag}] ${r.title} (${r.file}) — ${r.attempts} attempt${r.attempts === 1 ? "" : "s"} → ${r.suggestedAction}`);
|
|
14951
|
-
}
|
|
14952
|
-
lines.push("");
|
|
14953
|
-
lines.push(`Auto-quarantine proposal: ${quarantineCount} test${quarantineCount === 1 ? "" : "s"} (retried ≥${QUARANTINE_MIN_ATTEMPTS} and failed at least once).`);
|
|
14954
|
-
lines.push("Quarantined tests should run nightly, not per-PR — quarantine is not deletion.");
|
|
14955
|
-
return lines.join("\n");
|
|
14956
|
-
}
|
|
14957
|
-
/** TRIAGE.md — the meeting artifact. */
|
|
14958
|
-
function renderTriageMd(report) {
|
|
14959
|
-
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);
|
|
14960
14822
|
const lines = [];
|
|
14961
|
-
|
|
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));
|
|
14962
14828
|
lines.push("");
|
|
14963
|
-
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(", ")}`);
|
|
14964
14839
|
lines.push("");
|
|
14965
|
-
if (
|
|
14966
|
-
|
|
14967
|
-
|
|
14968
|
-
|
|
14969
|
-
|
|
14970
|
-
|
|
14971
|
-
|
|
14972
|
-
|
|
14973
|
-
lines.push(
|
|
14974
|
-
|
|
14975
|
-
|
|
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);
|
|
14976
14853
|
lines.push("");
|
|
14977
|
-
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.");
|
|
14978
14856
|
lines.push("");
|
|
14857
|
+
lines.push(`Docs: mjolnir rules --md (full catalog, this rule included)`);
|
|
14979
14858
|
return lines.join("\n");
|
|
14980
14859
|
}
|
|
14981
14860
|
//#endregion
|
|
@@ -15073,66 +14952,996 @@ function sweepStaleTempFiles(dir) {
|
|
|
15073
14952
|
return swept;
|
|
15074
14953
|
}
|
|
15075
14954
|
//#endregion
|
|
15076
|
-
//#region src/
|
|
15077
|
-
/**
|
|
15078
|
-
|
|
15079
|
-
|
|
15080
|
-
* Static JSON, no server. The badge makes falsifiable claims:
|
|
15081
|
-
* score + date + commit. Anyone can click through and verify.
|
|
15082
|
-
*/
|
|
15083
|
-
/**
|
|
15084
|
-
* Badge colors follow the SAME ScoreState bands as the terminal
|
|
15085
|
-
* (≥80 trusted / ≥50 warning / <50 critical / 100 forged) — this
|
|
15086
|
-
* retarget fixes the historical threshold drift (the badge used
|
|
15087
|
-
* ≥90/≥75/≥50 with four bands while the reporter used ≥80/≥50).
|
|
15088
|
-
*
|
|
15089
|
-
* Shields.io has no cyan or white-gold, so the mapping is documented
|
|
15090
|
-
* here: trusted → `important` (blue-family, closest to aurora-cyan),
|
|
15091
|
-
* forged → `success` (the strongest positive signal shields offers).
|
|
15092
|
-
* The badge is a peripheral surface; ScoreState remains the truth.
|
|
15093
|
-
*/
|
|
15094
|
-
function colorFor(score) {
|
|
15095
|
-
const band = deriveScoreState(score).band;
|
|
15096
|
-
if (band === "unmeasured") return "lightgrey";
|
|
15097
|
-
if (band === "forged") return "success";
|
|
15098
|
-
if (band === "trusted") return "important";
|
|
15099
|
-
return band === "warning" ? "yellow" : "red";
|
|
15100
|
-
}
|
|
15101
|
-
/** Build the shields.io endpoint payload from a scan result. */
|
|
15102
|
-
function buildBadge(result, commit) {
|
|
15103
|
-
const errors = result.findings.filter((f) => f.severity === "error").length;
|
|
15104
|
-
const score = result.score;
|
|
15105
|
-
return {
|
|
15106
|
-
schemaVersion: 1,
|
|
15107
|
-
label: "MJÖLNIR",
|
|
15108
|
-
message: score === null ? "no tests found" : score === 100 && errors === 0 ? "100/100 · forged" : `${score}/100 · ${errors} error${errors === 1 ? "" : "s"}`,
|
|
15109
|
-
color: colorFor(score),
|
|
15110
|
-
namedLogo: "vitest",
|
|
15111
|
-
...commit !== void 0 ? { commit } : {}
|
|
15112
|
-
};
|
|
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}`;
|
|
15113
14959
|
}
|
|
15114
14960
|
/**
|
|
15115
|
-
*
|
|
15116
|
-
* (the falsifiable claim: "verified at commit X on date Y").
|
|
14961
|
+
* The §15 ordered algorithm. Pure; first match wins.
|
|
15117
14962
|
*/
|
|
15118
|
-
function
|
|
15119
|
-
|
|
15120
|
-
|
|
15121
|
-
|
|
15122
|
-
|
|
15123
|
-
|
|
15124
|
-
|
|
15125
|
-
|
|
15126
|
-
|
|
15127
|
-
|
|
15128
|
-
|
|
15129
|
-
|
|
15130
|
-
|
|
15131
|
-
|
|
15132
|
-
|
|
15133
|
-
|
|
15134
|
-
"
|
|
15135
|
-
|
|
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 + ")",
|
|
15136
15945
|
"<!-- Mjölnir verified at commit " + commit + " on " + date + ": " + (result.score === null ? "no tests found" : result.score + "/100") + ", " + errors + " blocking error(s) -->",
|
|
15137
15946
|
"```"
|
|
15138
15947
|
].join("\n");
|
|
@@ -15291,6 +16100,12 @@ const HELP_ENTRIES = [
|
|
|
15291
16100
|
summary: "install the agent instruction surfaces + optional staged hook",
|
|
15292
16101
|
usage: "mjolnir install [--staged-hook] [--dry-run] [--force]",
|
|
15293
16102
|
examples: ["mjolnir install --dry-run", "mjolnir install --staged-hook"]
|
|
16103
|
+
},
|
|
16104
|
+
{
|
|
16105
|
+
verb: "mcp",
|
|
16106
|
+
summary: "run as an MCP server over stdio (scan / explain / diff tools)",
|
|
16107
|
+
usage: "mjolnir mcp",
|
|
16108
|
+
examples: ["mjolnir mcp"]
|
|
15294
16109
|
}
|
|
15295
16110
|
];
|
|
15296
16111
|
/** Scan-flag entries documented per-flag via the overview. */
|
|
@@ -15455,7 +16270,8 @@ const GROUPS = [
|
|
|
15455
16270
|
"explain",
|
|
15456
16271
|
"why",
|
|
15457
16272
|
"handoff",
|
|
15458
|
-
"install"
|
|
16273
|
+
"install",
|
|
16274
|
+
"mcp"
|
|
15459
16275
|
]
|
|
15460
16276
|
}
|
|
15461
16277
|
];
|
|
@@ -15520,7 +16336,7 @@ function renderRootHelp(schemaVersion = 1) {
|
|
|
15520
16336
|
}
|
|
15521
16337
|
//#endregion
|
|
15522
16338
|
//#region src/commands/debt.ts
|
|
15523
|
-
const ui$
|
|
16339
|
+
const ui$9 = plainContext();
|
|
15524
16340
|
/** Cost model (documented, conservative): hours/quarter per occurrence. */
|
|
15525
16341
|
const COST_MODEL = {
|
|
15526
16342
|
"QA-TEST-004": {
|
|
@@ -15601,7 +16417,7 @@ function computeDebt(result) {
|
|
|
15601
16417
|
function renderDebt(result) {
|
|
15602
16418
|
const { classes, totalHours } = computeDebt(result);
|
|
15603
16419
|
const lines = [];
|
|
15604
|
-
lines.push(sectionHeader("TEST DEBT REGISTER", ui$
|
|
16420
|
+
lines.push(sectionHeader("TEST DEBT REGISTER", ui$9));
|
|
15605
16421
|
lines.push("");
|
|
15606
16422
|
if (classes.length === 0) {
|
|
15607
16423
|
lines.push("No tracked debt classes found — the suite is clean.");
|
|
@@ -15611,7 +16427,7 @@ function renderDebt(result) {
|
|
|
15611
16427
|
rows[0] = `DEBT CLASS COUNT EST. HOURS/QUARTER`;
|
|
15612
16428
|
for (const c of classes) rows.push(`${c.label.padEnd(26)} ${String(c.count).padStart(5)} ${c.estHoursPerQuarter.toFixed(1).padStart(8)}`);
|
|
15613
16429
|
rows.push(`TOTAL ESTIMATED DRAG: ~${totalHours.toFixed(1)} engineer-hours/qtr`);
|
|
15614
|
-
for (const row of panel(rows, ui$
|
|
16430
|
+
for (const row of panel(rows, ui$9)) lines.push(row);
|
|
15615
16431
|
lines.push("");
|
|
15616
16432
|
lines.push("Cost model is conservative and documented in src/commands/debt.ts.");
|
|
15617
16433
|
return lines.join("\n");
|
|
@@ -15684,723 +16500,460 @@ const RULE_FAMILIES = [
|
|
|
15684
16500
|
dir: "webdriverio",
|
|
15685
16501
|
category: "QA-WDIO",
|
|
15686
16502
|
appliesTo: "test-files"
|
|
15687
|
-
},
|
|
15688
|
-
{
|
|
15689
|
-
token: "PPTR",
|
|
15690
|
-
dir: "puppeteer",
|
|
15691
|
-
category: "QA-PPTR",
|
|
15692
|
-
appliesTo: "test-files"
|
|
15693
|
-
},
|
|
15694
|
-
{
|
|
15695
|
-
token: "APM",
|
|
15696
|
-
dir: "apm",
|
|
15697
|
-
category: "QA-APM",
|
|
15698
|
-
appliesTo: "test-files"
|
|
15699
|
-
}
|
|
15700
|
-
];
|
|
15701
|
-
/** The full ID regex — derived from the table, so doctor and create-rule
|
|
15702
|
-
* can never disagree about which families exist. */
|
|
15703
|
-
const RULE_ID_RE = new RegExp(`^QA-(?:${RULE_FAMILIES.map((f) => f.token).join("|")})-\\d{3}$`);
|
|
15704
|
-
function familyByToken(token) {
|
|
15705
|
-
const upper = token.toUpperCase();
|
|
15706
|
-
return RULE_FAMILIES.find((f) => f.token === upper);
|
|
15707
|
-
}
|
|
15708
|
-
//#endregion
|
|
15709
|
-
//#region src/commands/create-rule.ts
|
|
15710
|
-
/**
|
|
15711
|
-
* `mjolnir create-rule <ID> --title "..."` — rule scaffold generator
|
|
15712
|
-
* (Tier 6 #34, Contribution Surface Engineering).
|
|
15713
|
-
*
|
|
15714
|
-
* Generates the four files every rule MUST ship with (anti-creep law):
|
|
15715
|
-
* src/rules/<family>/qa-<id-lower>.ts — the rule
|
|
15716
|
-
* tests/fixtures/<ID>/must-fire/ — at least one must-fire fixture
|
|
15717
|
-
* tests/fixtures/<ID>/must-not-fire/ — at least one must-not fixture
|
|
15718
|
-
* and prints the exact registry edit to make.
|
|
15719
|
-
*
|
|
15720
|
-
* The generated rule intentionally FAILS its fixtures until the author
|
|
15721
|
-
* implements it — you cannot ship a stub.
|
|
15722
|
-
*/
|
|
15723
|
-
const ui$10 = plainContext();
|
|
15724
|
-
function parseId(id) {
|
|
15725
|
-
if (!RULE_ID_RE.exec(id)) return null;
|
|
15726
|
-
const family = familyByToken(id.slice(3, id.length - 4));
|
|
15727
|
-
return {
|
|
15728
|
-
family: family.dir,
|
|
15729
|
-
dir: family.dir,
|
|
15730
|
-
category: family.category,
|
|
15731
|
-
appliesTo: family.appliesTo,
|
|
15732
|
-
num: id.slice(-3),
|
|
15733
|
-
lower: id.toLowerCase()
|
|
15734
|
-
};
|
|
15735
|
-
}
|
|
15736
|
-
function createRuleScaffold(input, rootDir) {
|
|
15737
|
-
const parsed = parseId(input.id);
|
|
15738
|
-
if (!parsed) return {
|
|
15739
|
-
ok: false,
|
|
15740
|
-
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).",
|
|
15741
|
-
files: [],
|
|
15742
|
-
registryEdit: ""
|
|
15743
|
-
};
|
|
15744
|
-
if (!input.title || input.title.trim().length === 0) return {
|
|
15745
|
-
ok: false,
|
|
15746
|
-
error: "A --title is required.",
|
|
15747
|
-
files: [],
|
|
15748
|
-
registryEdit: ""
|
|
15749
|
-
};
|
|
15750
|
-
const dirRel = join("src", "rules", parsed.dir);
|
|
15751
|
-
const ruleRel = join(dirRel, `${parsed.lower}.ts`);
|
|
15752
|
-
const absRule = join(rootDir, ruleRel);
|
|
15753
|
-
if (existsSync(absRule)) return {
|
|
15754
|
-
ok: false,
|
|
15755
|
-
error: `Rule file already exists: ${ruleRel}`,
|
|
15756
|
-
files: [],
|
|
15757
|
-
registryEdit: ""
|
|
15758
|
-
};
|
|
15759
|
-
mkdirSync(join(rootDir, "src", "rules", parsed.dir), { recursive: true });
|
|
15760
|
-
const files = [];
|
|
15761
|
-
writeFileAtomic(absRule, `/**
|
|
15762
|
-
* ${input.id} — ${input.title}.
|
|
15763
|
-
*
|
|
15764
|
-
* TODO(implement): replace the placeholder below. The rule currently
|
|
15765
|
-
* returns no findings on purpose so the fixture harness FAILS until
|
|
15766
|
-
* real detection logic lands (anti-creep law §18.1).
|
|
15767
|
-
*/
|
|
15768
|
-
|
|
15769
|
-
import { defineRule } from "../rule.js";
|
|
15770
|
-
import type { Finding } from "../../types.js";
|
|
15771
|
-
|
|
15772
|
-
export const ${camel(parsed.lower)} = defineRule({
|
|
15773
|
-
id: "${input.id}",
|
|
15774
|
-
category: "${parsed.category}",
|
|
15775
|
-
title: ${JSON.stringify(input.title)},
|
|
15776
|
-
severity: "warning",
|
|
15777
|
-
confidence: "medium",
|
|
15778
|
-
findingType: "heuristic-risk",
|
|
15779
|
-
qaImpact: "HYGIENE",
|
|
15780
|
-
appliesTo: ${JSON.stringify(parsed.appliesTo)},
|
|
15781
|
-
run(ctx) {
|
|
15782
|
-
const findings: Omit<Finding, "ruleId" | "category">[] = [];
|
|
15783
|
-
void ctx; // TODO: implement detection over ctx.path / ctx.text
|
|
15784
|
-
return findings;
|
|
15785
|
-
},
|
|
15786
|
-
});
|
|
15787
|
-
`);
|
|
15788
|
-
files.push(ruleRel);
|
|
15789
|
-
for (const direction of ["must-fire", "must-not-fire"]) {
|
|
15790
|
-
const dirRel = join("tests", "fixtures", input.id, direction);
|
|
15791
|
-
const absDir = join(rootDir, dirRel);
|
|
15792
|
-
mkdirSync(absDir, { recursive: true });
|
|
15793
|
-
const isPy = parsed.family === "python";
|
|
15794
|
-
const name = isPy ? `example.${direction}.py` : `example.${direction}.ts`;
|
|
15795
|
-
const content = isPy ? `# ${input.id} ${direction} fixture — replace with a real case.\n` : `// ${input.id} ${direction} fixture — replace with a real case.\n`;
|
|
15796
|
-
writeFileAtomic(join(absDir, name), content);
|
|
15797
|
-
files.push(join(dirRel, name));
|
|
15798
|
-
}
|
|
15799
|
-
const exportName = camel(parsed.lower);
|
|
15800
|
-
return {
|
|
15801
|
-
ok: true,
|
|
15802
|
-
files,
|
|
15803
|
-
registryEdit: [
|
|
15804
|
-
`// 1. Add import to src/rules/index.ts:`,
|
|
15805
|
-
`import { ${exportName} } from "./${parsed.dir}/${parsed.lower}.js";`,
|
|
15806
|
-
`// 2. Add to the RULES array:`,
|
|
15807
|
-
` ${exportName},`
|
|
15808
|
-
].join("\n")
|
|
15809
|
-
};
|
|
15810
|
-
}
|
|
15811
|
-
function camel(idLower) {
|
|
15812
|
-
return idLower.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
|
|
15813
|
-
}
|
|
15814
|
-
function renderScaffoldReport(result) {
|
|
15815
|
-
if (!result.ok) return `create-rule failed: ${result.error}`;
|
|
15816
|
-
const lines = [];
|
|
15817
|
-
lines.push(sectionHeader("RULE SCAFFOLD CREATED", ui$10));
|
|
15818
|
-
lines.push("");
|
|
15819
|
-
for (const f of result.files) lines.push(` + ${f}`);
|
|
15820
|
-
lines.push("");
|
|
15821
|
-
lines.push(result.registryEdit);
|
|
15822
|
-
lines.push("");
|
|
15823
|
-
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)");
|
|
15824
|
-
lines.push("");
|
|
15825
|
-
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.");
|
|
15826
|
-
return lines.join("\n");
|
|
15827
|
-
}
|
|
15828
|
-
//#endregion
|
|
15829
|
-
//#region src/commands/handover.ts
|
|
15830
|
-
const ui$9 = plainContext();
|
|
15831
|
-
const FAKE_GREEN_RULES = /* @__PURE__ */ new Set([
|
|
15832
|
-
"QA-TEST-003",
|
|
15833
|
-
"QA-PY-003",
|
|
15834
|
-
"QA-TEST-010",
|
|
15835
|
-
"QA-PY-006",
|
|
15836
|
-
"QA-TQUAL-002",
|
|
15837
|
-
"QA-PY-012"
|
|
15838
|
-
]);
|
|
15839
|
-
const FLAKY_RULES = /* @__PURE__ */ new Set([
|
|
15840
|
-
"QA-TEST-004",
|
|
15841
|
-
"QA-PY-005",
|
|
15842
|
-
"QA-PW-118",
|
|
15843
|
-
"QA-PW-114",
|
|
15844
|
-
"QA-TEST-006",
|
|
15845
|
-
"QA-ENV-001"
|
|
15846
|
-
]);
|
|
15847
|
-
const CI_TRUST_RULES = /* @__PURE__ */ new Set([
|
|
15848
|
-
"QA-CI-001",
|
|
15849
|
-
"QA-CI-002",
|
|
15850
|
-
"QA-CI-005",
|
|
15851
|
-
"QA-CI-007",
|
|
15852
|
-
"QA-CI-008"
|
|
15853
|
-
]);
|
|
15854
|
-
/** Group findings by directory to name "solid" areas. */
|
|
15855
|
-
function dirOf(file) {
|
|
15856
|
-
const idx = file.lastIndexOf("/");
|
|
15857
|
-
return idx === -1 ? file : file.slice(0, idx);
|
|
15858
|
-
}
|
|
15859
|
-
function buildHandover(scan, forensics) {
|
|
15860
|
-
const fakeGreen = [];
|
|
15861
|
-
const flaky = [];
|
|
15862
|
-
const ciTrust = [];
|
|
15863
|
-
const touchedDirs = /* @__PURE__ */ new Map();
|
|
15864
|
-
for (const f of scan.findings) {
|
|
15865
|
-
if (FAKE_GREEN_RULES.has(f.ruleId)) fakeGreen.push(f);
|
|
15866
|
-
else if (FLAKY_RULES.has(f.ruleId)) flaky.push(f);
|
|
15867
|
-
else if (CI_TRUST_RULES.has(f.ruleId)) ciTrust.push(f);
|
|
15868
|
-
const d = dirOf(f.file);
|
|
15869
|
-
touchedDirs.set(d, (touchedDirs.get(d) ?? 0) + 1);
|
|
15870
|
-
}
|
|
15871
|
-
const sections = [];
|
|
15872
|
-
const flakyFromRuns = forensics?.verdicts.filter((v) => v.passedOnRetry || v.everFailed) ?? [];
|
|
15873
|
-
const flakyItems = [];
|
|
15874
|
-
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`);
|
|
15875
|
-
for (const f of flaky.slice(0, 3)) flakyItems.push(`${f.file}:${f.line} — ${f.message}`);
|
|
15876
|
-
if (flakyItems.length > 0) sections.push({
|
|
15877
|
-
heading: "🔴 Known flaky / timing-sensitive",
|
|
15878
|
-
items: flakyItems
|
|
15879
|
-
});
|
|
15880
|
-
if (fakeGreen.length > 0) sections.push({
|
|
15881
|
-
heading: "🟡 Fake-green suspects (tests that can't fail)",
|
|
15882
|
-
items: fakeGreen.slice(0, 5).map((f) => `${f.file}:${f.line} — ${f.message}`)
|
|
15883
|
-
});
|
|
15884
|
-
if (ciTrust.length > 0) sections.push({
|
|
15885
|
-
heading: "⚠ CI trust warnings (green ≠ verified)",
|
|
15886
|
-
items: ciTrust.slice(0, 4).map((f) => `${f.file}:${f.line} — ${f.message}`)
|
|
15887
|
-
});
|
|
15888
|
-
if (new Set(scan.findings.map((f) => f.file)).size === 0 && scan.score !== null && scan.score >= 90 || scan.findings.length === 0) sections.push({
|
|
15889
|
-
heading: "🟢 Solid foundation",
|
|
15890
|
-
items: ["No tracked anti-patterns found in scanned specs — good place to start contributing."]
|
|
15891
|
-
});
|
|
15892
|
-
const totalIssues = fakeGreen.length + flaky.length + ciTrust.length + (forensics?.flakyTests ?? 0);
|
|
15893
|
-
return {
|
|
15894
|
-
sections,
|
|
15895
|
-
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.`
|
|
15896
|
-
};
|
|
15897
|
-
}
|
|
15898
|
-
function renderHandover(map) {
|
|
15899
|
-
const lines = [];
|
|
15900
|
-
lines.push(sectionHeader("WELCOME TO THE TEST SUITE — WHAT YOU NEED TO KNOW", ui$9));
|
|
15901
|
-
lines.push("");
|
|
15902
|
-
lines.push(map.summaryLine);
|
|
15903
|
-
for (const s of map.sections) {
|
|
15904
|
-
lines.push("");
|
|
15905
|
-
lines.push(s.heading);
|
|
15906
|
-
for (const item of s.items) lines.push(` • ${item}`);
|
|
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"
|
|
15907
16515
|
}
|
|
15908
|
-
|
|
15909
|
-
|
|
15910
|
-
|
|
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);
|
|
15911
16523
|
}
|
|
15912
16524
|
//#endregion
|
|
15913
|
-
//#region src/commands/
|
|
16525
|
+
//#region src/commands/create-rule.ts
|
|
15914
16526
|
/**
|
|
15915
|
-
* `mjolnir
|
|
16527
|
+
* `mjolnir create-rule <ID> --title "..."` — rule scaffold generator
|
|
16528
|
+
* (Tier 6 #34, Contribution Surface Engineering).
|
|
15916
16529
|
*
|
|
15917
|
-
*
|
|
15918
|
-
*
|
|
15919
|
-
*
|
|
15920
|
-
*
|
|
15921
|
-
*
|
|
15922
|
-
* stand behind with real evidence.
|
|
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.
|
|
15923
16535
|
*
|
|
15924
|
-
*
|
|
15925
|
-
*
|
|
15926
|
-
* is UNKNOWN. This command never estimates or extrapolates "hours saved" or
|
|
15927
|
-
* "CI minutes saved" — inventing such a number would be worse than useless,
|
|
15928
|
-
* it would be the exact kind of fake-precision this product exists to
|
|
15929
|
-
* catch in *other* tools. Local-only, zero network (verified by
|
|
15930
|
-
* tests/privacy-network-isolation.spec.ts, which scans this file too).
|
|
16536
|
+
* The generated rule intentionally FAILS its fixtures until the author
|
|
16537
|
+
* implements it — you cannot ship a stub.
|
|
15931
16538
|
*/
|
|
15932
16539
|
const ui$8 = plainContext();
|
|
15933
|
-
|
|
15934
|
-
|
|
15935
|
-
|
|
15936
|
-
|
|
15937
|
-
|
|
15938
|
-
|
|
15939
|
-
|
|
15940
|
-
|
|
15941
|
-
|
|
15942
|
-
|
|
15943
|
-
|
|
15944
|
-
encoding: "utf8",
|
|
15945
|
-
stdio: [
|
|
15946
|
-
"ignore",
|
|
15947
|
-
"pipe",
|
|
15948
|
-
"ignore"
|
|
15949
|
-
],
|
|
15950
|
-
timeout: 3e4
|
|
15951
|
-
});
|
|
15952
|
-
} catch {
|
|
15953
|
-
return null;
|
|
15954
|
-
}
|
|
15955
|
-
}
|
|
15956
|
-
/** Like git(), but returns the raw bytes — no utf8 decode round-trip. */
|
|
15957
|
-
function gitBuffer(root, args) {
|
|
15958
|
-
try {
|
|
15959
|
-
return execFileSync(gitExe(), [
|
|
15960
|
-
"-C",
|
|
15961
|
-
root,
|
|
15962
|
-
...args
|
|
15963
|
-
], {
|
|
15964
|
-
encoding: "buffer",
|
|
15965
|
-
stdio: [
|
|
15966
|
-
"ignore",
|
|
15967
|
-
"pipe",
|
|
15968
|
-
"ignore"
|
|
15969
|
-
],
|
|
15970
|
-
timeout: 3e4
|
|
15971
|
-
});
|
|
15972
|
-
} catch {
|
|
15973
|
-
return null;
|
|
15974
|
-
}
|
|
15975
|
-
}
|
|
15976
|
-
/** Fingerprint a finding for cross-commit matching (line numbers shift). */
|
|
15977
|
-
function fingerprint$2(f) {
|
|
15978
|
-
return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
|
|
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()
|
|
16550
|
+
};
|
|
15979
16551
|
}
|
|
15980
|
-
|
|
15981
|
-
const
|
|
15982
|
-
if (!
|
|
15983
|
-
|
|
15984
|
-
|
|
15985
|
-
|
|
15986
|
-
|
|
15987
|
-
unknownFacts
|
|
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: ""
|
|
15988
16559
|
};
|
|
15989
|
-
|
|
15990
|
-
|
|
15991
|
-
"
|
|
15992
|
-
|
|
15993
|
-
|
|
15994
|
-
])?.trim() ?? void 0;
|
|
15995
|
-
else baseRef = git(root, ["rev-parse", baseRef])?.trim() ?? baseRef;
|
|
15996
|
-
if (!baseRef) return {
|
|
15997
|
-
hasComparison: false,
|
|
15998
|
-
unknownReason: "no-prior-commit",
|
|
15999
|
-
resolved: [],
|
|
16000
|
-
introduced: [],
|
|
16001
|
-
unknownFacts
|
|
16560
|
+
if (!input.title || input.title.trim().length === 0) return {
|
|
16561
|
+
ok: false,
|
|
16562
|
+
error: "A --title is required.",
|
|
16563
|
+
files: [],
|
|
16564
|
+
registryEdit: ""
|
|
16002
16565
|
};
|
|
16003
|
-
const
|
|
16004
|
-
|
|
16005
|
-
|
|
16006
|
-
|
|
16007
|
-
|
|
16008
|
-
|
|
16009
|
-
|
|
16010
|
-
|
|
16011
|
-
unknownFacts
|
|
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: ""
|
|
16012
16574
|
};
|
|
16013
|
-
|
|
16014
|
-
|
|
16015
|
-
|
|
16016
|
-
|
|
16017
|
-
|
|
16018
|
-
|
|
16019
|
-
|
|
16020
|
-
|
|
16021
|
-
|
|
16022
|
-
|
|
16023
|
-
|
|
16024
|
-
|
|
16025
|
-
|
|
16026
|
-
|
|
16027
|
-
|
|
16028
|
-
|
|
16029
|
-
|
|
16030
|
-
|
|
16031
|
-
|
|
16032
|
-
|
|
16033
|
-
|
|
16034
|
-
|
|
16035
|
-
|
|
16036
|
-
|
|
16037
|
-
|
|
16038
|
-
|
|
16039
|
-
|
|
16040
|
-
|
|
16041
|
-
|
|
16042
|
-
|
|
16043
|
-
|
|
16044
|
-
|
|
16045
|
-
|
|
16046
|
-
|
|
16047
|
-
const
|
|
16048
|
-
const
|
|
16049
|
-
|
|
16050
|
-
|
|
16051
|
-
|
|
16052
|
-
const dest = join(tmpDir, relPath);
|
|
16053
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
16054
|
-
writeFileSync(dest, blob);
|
|
16055
|
-
}
|
|
16056
|
-
if (truncated) {
|
|
16057
|
-
baseTreeTruncated = {
|
|
16058
|
-
scanned: Math.min(paths.length, MAX_FILES),
|
|
16059
|
-
total: paths.length
|
|
16060
|
-
};
|
|
16061
|
-
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.`);
|
|
16062
|
-
}
|
|
16063
|
-
baseResult = await options.runScan(tmpDir);
|
|
16064
|
-
} catch {
|
|
16065
|
-
return {
|
|
16066
|
-
hasComparison: false,
|
|
16067
|
-
unknownReason: "tree-materialize-failed",
|
|
16068
|
-
baseRef,
|
|
16069
|
-
headRef,
|
|
16070
|
-
resolved: [],
|
|
16071
|
-
introduced: [],
|
|
16072
|
-
unknownFacts
|
|
16073
|
-
};
|
|
16074
|
-
} finally {
|
|
16075
|
-
try {
|
|
16076
|
-
rmSync(tmpDir, {
|
|
16077
|
-
recursive: true,
|
|
16078
|
-
force: true
|
|
16079
|
-
});
|
|
16080
|
-
} catch {}
|
|
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));
|
|
16081
16614
|
}
|
|
16082
|
-
const
|
|
16083
|
-
const baseSet = /* @__PURE__ */ new Map();
|
|
16084
|
-
for (const f of baseResult.findings) baseSet.set(fingerprint$2(f), f);
|
|
16085
|
-
const headSet = /* @__PURE__ */ new Map();
|
|
16086
|
-
for (const f of headResult.findings) headSet.set(fingerprint$2(f), f);
|
|
16087
|
-
const resolved = [];
|
|
16088
|
-
for (const [key, f] of baseSet) if (!headSet.has(key)) resolved.push({
|
|
16089
|
-
ruleId: f.ruleId,
|
|
16090
|
-
file: f.file,
|
|
16091
|
-
message: f.message
|
|
16092
|
-
});
|
|
16093
|
-
const introduced = [];
|
|
16094
|
-
for (const [key, f] of headSet) if (!baseSet.has(key)) introduced.push({
|
|
16095
|
-
ruleId: f.ruleId,
|
|
16096
|
-
file: f.file,
|
|
16097
|
-
message: f.message
|
|
16098
|
-
});
|
|
16615
|
+
const exportName = camel(parsed.lower);
|
|
16099
16616
|
return {
|
|
16100
|
-
|
|
16101
|
-
|
|
16102
|
-
|
|
16103
|
-
|
|
16104
|
-
|
|
16105
|
-
|
|
16106
|
-
|
|
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")
|
|
16107
16625
|
};
|
|
16108
16626
|
}
|
|
16109
|
-
function
|
|
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}`;
|
|
16110
16632
|
const lines = [];
|
|
16111
|
-
lines.push(sectionHeader("
|
|
16633
|
+
lines.push(sectionHeader("RULE SCAFFOLD CREATED", ui$8));
|
|
16112
16634
|
lines.push("");
|
|
16113
|
-
|
|
16114
|
-
lines.push(`UNKNOWN — no comparison could be made (${report.unknownReason ?? "unknown reason"}).`);
|
|
16115
|
-
lines.push("This is reported as UNKNOWN rather than a fabricated zero: mjolnir");
|
|
16116
|
-
lines.push("never invents a number it cannot prove.");
|
|
16117
|
-
lines.push("");
|
|
16118
|
-
for (const fact of report.unknownFacts) lines.push(`UNKNOWN: ${fact}`);
|
|
16119
|
-
return lines.join("\n");
|
|
16120
|
-
}
|
|
16121
|
-
lines.push(`Comparing ${report.baseRef?.slice(0, 12)} → ${report.headRef?.slice(0, 12)}`);
|
|
16635
|
+
for (const f of result.files) lines.push(` + ${f}`);
|
|
16122
16636
|
lines.push("");
|
|
16123
|
-
|
|
16124
|
-
lines.push(`UNKNOWN: base tree truncated — scanned ${report.baseTreeTruncated.scanned} of ${report.baseTreeTruncated.total} paths.`);
|
|
16125
|
-
lines.push("Files beyond the cap were never scanned at base; their findings are misreported as new debt.");
|
|
16126
|
-
lines.push("");
|
|
16127
|
-
}
|
|
16128
|
-
if (report.resolved.length === 0) lines.push("FIXED SINCE BASE: none found.");
|
|
16129
|
-
else {
|
|
16130
|
-
lines.push(`FIXED SINCE BASE (${report.resolved.length}) — real, evidence-backed:`);
|
|
16131
|
-
for (const f of report.resolved) lines.push(` ✓ ${f.ruleId} · ${f.file} — ${f.message}`);
|
|
16132
|
-
}
|
|
16637
|
+
lines.push(result.registryEdit);
|
|
16133
16638
|
lines.push("");
|
|
16134
|
-
|
|
16135
|
-
else {
|
|
16136
|
-
lines.push(`NEW DEBT SINCE BASE (${report.introduced.length}):`);
|
|
16137
|
-
for (const f of report.introduced) lines.push(` + ${f.ruleId} · ${f.file} — ${f.message}`);
|
|
16138
|
-
}
|
|
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)");
|
|
16139
16640
|
lines.push("");
|
|
16140
|
-
|
|
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.");
|
|
16141
16642
|
return lines.join("\n");
|
|
16142
16643
|
}
|
|
16143
16644
|
//#endregion
|
|
16144
|
-
//#region src/
|
|
16145
|
-
/** Correlation identity (v1-compatible): ruleId\0file\0message. */
|
|
16146
|
-
function fingerprint$1(entry) {
|
|
16147
|
-
return `${entry.ruleId}\u0000${entry.file}\u0000${entry.message}`;
|
|
16148
|
-
}
|
|
16149
|
-
/**
|
|
16150
|
-
* The §15 ordered algorithm. Pure; first match wins.
|
|
16151
|
-
*/
|
|
16152
|
-
function resolve$1(input) {
|
|
16153
|
-
const { entry, baseline, current } = input;
|
|
16154
|
-
const fp = fingerprint$1(entry);
|
|
16155
|
-
const comparedAgainst = input.baselineCommit ?? baseline.commit ?? "unknown baseline";
|
|
16156
|
-
if (current.partial || current.analysisStatus.rules !== "complete") return {
|
|
16157
|
-
status: "INCONCLUSIVE",
|
|
16158
|
-
cause: "partial",
|
|
16159
|
-
comparedAgainst
|
|
16160
|
-
};
|
|
16161
|
-
if (input.crashedRuleIds?.has(entry.ruleId)) return {
|
|
16162
|
-
status: "INCONCLUSIVE",
|
|
16163
|
-
cause: "crash",
|
|
16164
|
-
comparedAgainst
|
|
16165
|
-
};
|
|
16166
|
-
if (input.skippedFiles?.has(entry.file)) return {
|
|
16167
|
-
status: "INCONCLUSIVE",
|
|
16168
|
-
cause: "skipped",
|
|
16169
|
-
comparedAgainst
|
|
16170
|
-
};
|
|
16171
|
-
if (input.suppressed?.has(`${entry.ruleId}\u0000${entry.file}`)) return {
|
|
16172
|
-
status: "SUPPRESSED",
|
|
16173
|
-
comparedAgainst
|
|
16174
|
-
};
|
|
16175
|
-
if (input.excludedFiles?.has(entry.file)) return {
|
|
16176
|
-
status: "DISAPPEARED-NON-FIX",
|
|
16177
|
-
cause: "excluded",
|
|
16178
|
-
comparedAgainst
|
|
16179
|
-
};
|
|
16180
|
-
const entryRev = entry.detectorRevision;
|
|
16181
|
-
if (entryRev === void 0) return {
|
|
16182
|
-
status: "INCONCLUSIVE",
|
|
16183
|
-
cause: "legacy-baseline",
|
|
16184
|
-
comparedAgainst
|
|
16185
|
-
};
|
|
16186
|
-
const registryRev = input.registryRevisions.get(entry.ruleId);
|
|
16187
|
-
if (registryRev === void 0) return {
|
|
16188
|
-
status: "DISAPPEARED-NON-FIX",
|
|
16189
|
-
cause: "retired",
|
|
16190
|
-
comparedAgainst
|
|
16191
|
-
};
|
|
16192
|
-
if (registryRev !== entryRev) return {
|
|
16193
|
-
status: "INCONCLUSIVE",
|
|
16194
|
-
cause: "revision-changed",
|
|
16195
|
-
comparedAgainst
|
|
16196
|
-
};
|
|
16197
|
-
if (current.findings.some((f) => fingerprint$1(f) === fp)) return {
|
|
16198
|
-
status: "STILL-PRESENT",
|
|
16199
|
-
comparedAgainst
|
|
16200
|
-
};
|
|
16201
|
-
return {
|
|
16202
|
-
status: "VERIFIED-RESOLVED",
|
|
16203
|
-
comparedAgainst
|
|
16204
|
-
};
|
|
16205
|
-
}
|
|
16206
|
-
/** The §15 rendering law: "FIXED" only for VERIFIED-RESOLVED. */
|
|
16207
|
-
function renderResolution(r) {
|
|
16208
|
-
switch (r.status) {
|
|
16209
|
-
case "VERIFIED-RESOLVED": return "FIXED SINCE BASELINE (verified by a complete same-revision scan)";
|
|
16210
|
-
case "STILL-PRESENT": return "STILL PRESENT";
|
|
16211
|
-
case "SUPPRESSED": return "SUPPRESSED (active ignore entry)";
|
|
16212
|
-
case "INCONCLUSIVE": return `INCONCLUSIVE (${r.cause})`;
|
|
16213
|
-
case "DISAPPEARED-NON-FIX": return `DISAPPEARED — NOT A FIX (${r.cause})`;
|
|
16214
|
-
}
|
|
16215
|
-
}
|
|
16216
|
-
//#endregion
|
|
16217
|
-
//#region src/commands/baseline.ts
|
|
16218
|
-
/**
|
|
16219
|
-
* `mjolnir baseline` / `mjolnir diff` — Sprint 6 Task 24
|
|
16220
|
-
* (Master-Stabilization-Plan.md).
|
|
16221
|
-
*
|
|
16222
|
-
* Implements Plan.md Phase 10 / §24's key insight: existing debt should
|
|
16223
|
-
* not block every PR — only NEW or WORSENED debt should. `baseline`
|
|
16224
|
-
* snapshots the current finding set to disk; `diff` compares the current
|
|
16225
|
-
* scan against that snapshot and reports only what changed.
|
|
16226
|
-
*
|
|
16227
|
-
* Findings are matched across scans by a stable fingerprint (ruleId +
|
|
16228
|
-
* file + message), not by line number — line numbers shift constantly as
|
|
16229
|
-
* a file is edited, so matching on them would report unrelated churn as
|
|
16230
|
-
* "new" debt and miss genuinely new findings that happen to land on a
|
|
16231
|
-
* previously-flagged line.
|
|
16232
|
-
*
|
|
16233
|
-
* Storage: .mjolnir/baseline.json (local; not gitignored — only
|
|
16234
|
-
* .mjolnir/logs/ is, see .gitignore). A team CAN commit this
|
|
16235
|
-
* file if they want a shared baseline; that's a deliberate choice this
|
|
16236
|
-
* command does not make for them.
|
|
16237
|
-
*/
|
|
16645
|
+
//#region src/commands/handover.ts
|
|
16238
16646
|
const ui$7 = plainContext();
|
|
16239
|
-
const
|
|
16240
|
-
|
|
16241
|
-
|
|
16242
|
-
|
|
16243
|
-
|
|
16244
|
-
|
|
16245
|
-
|
|
16246
|
-
|
|
16247
|
-
const
|
|
16248
|
-
|
|
16249
|
-
|
|
16250
|
-
|
|
16251
|
-
|
|
16252
|
-
|
|
16253
|
-
|
|
16254
|
-
|
|
16255
|
-
|
|
16256
|
-
|
|
16257
|
-
|
|
16258
|
-
|
|
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);
|
|
16259
16674
|
}
|
|
16260
|
-
function
|
|
16261
|
-
|
|
16262
|
-
|
|
16263
|
-
|
|
16264
|
-
|
|
16265
|
-
|
|
16266
|
-
|
|
16267
|
-
|
|
16268
|
-
|
|
16269
|
-
|
|
16270
|
-
|
|
16271
|
-
|
|
16272
|
-
|
|
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);
|
|
16709
|
+
return {
|
|
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.`
|
|
16273
16712
|
};
|
|
16274
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
|
|
16275
16730
|
/**
|
|
16276
|
-
*
|
|
16277
|
-
*
|
|
16278
|
-
*
|
|
16279
|
-
*
|
|
16280
|
-
*
|
|
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).
|
|
16281
16747
|
*/
|
|
16282
|
-
|
|
16283
|
-
|
|
16284
|
-
|
|
16285
|
-
|
|
16286
|
-
if (existed) {
|
|
16287
|
-
backupPath = `${outPath}.bak`;
|
|
16288
|
-
copyFileSync(outPath, backupPath);
|
|
16289
|
-
}
|
|
16290
|
-
writeFileAtomic(outPath, JSON.stringify(buildBaseline(result, commit), null, 2) + "\n");
|
|
16291
|
-
return {
|
|
16292
|
-
path: outPath,
|
|
16293
|
-
replaced: existed,
|
|
16294
|
-
...backupPath !== void 0 ? { backupPath } : {}
|
|
16295
|
-
};
|
|
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";
|
|
16296
16752
|
}
|
|
16297
|
-
function
|
|
16298
|
-
if (!existsSync(path)) return null;
|
|
16753
|
+
function git(root, args) {
|
|
16299
16754
|
try {
|
|
16300
|
-
|
|
16301
|
-
|
|
16302
|
-
|
|
16303
|
-
|
|
16304
|
-
|
|
16305
|
-
|
|
16306
|
-
|
|
16307
|
-
|
|
16308
|
-
|
|
16309
|
-
|
|
16310
|
-
|
|
16311
|
-
|
|
16312
|
-
|
|
16313
|
-
|
|
16314
|
-
score
|
|
16315
|
-
};
|
|
16316
|
-
}
|
|
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 {
|
|
16317
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
|
+
});
|
|
16318
16788
|
} catch {
|
|
16319
16789
|
return null;
|
|
16320
16790
|
}
|
|
16321
16791
|
}
|
|
16322
|
-
|
|
16323
|
-
|
|
16324
|
-
|
|
16325
|
-
|
|
16326
|
-
|
|
16327
|
-
|
|
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
|
|
16328
16804
|
};
|
|
16329
|
-
|
|
16330
|
-
|
|
16331
|
-
|
|
16332
|
-
|
|
16333
|
-
|
|
16334
|
-
|
|
16335
|
-
|
|
16336
|
-
|
|
16337
|
-
|
|
16338
|
-
|
|
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
|
+
};
|
|
16339
16842
|
}
|
|
16340
|
-
|
|
16341
|
-
|
|
16342
|
-
|
|
16343
|
-
|
|
16344
|
-
|
|
16345
|
-
|
|
16346
|
-
|
|
16347
|
-
|
|
16348
|
-
|
|
16349
|
-
|
|
16350
|
-
|
|
16351
|
-
|
|
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 {}
|
|
16352
16897
|
}
|
|
16353
|
-
|
|
16354
|
-
|
|
16355
|
-
|
|
16356
|
-
|
|
16357
|
-
|
|
16358
|
-
|
|
16359
|
-
|
|
16360
|
-
|
|
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
|
+
});
|
|
16915
|
+
return {
|
|
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
|
|
16361
16923
|
};
|
|
16362
16924
|
}
|
|
16363
|
-
function
|
|
16364
|
-
const lines = [
|
|
16365
|
-
sectionHeader("BASELINE SAVED", ui$7),
|
|
16366
|
-
"",
|
|
16367
|
-
`Captured ${count} finding${count === 1 ? "" : "s"} to ${path}.`
|
|
16368
|
-
];
|
|
16369
|
-
if (replaced?.backupPath !== void 0) lines.push(`Replaced an existing baseline — the previous one was saved to ${replaced.backupPath}.`);
|
|
16370
|
-
lines.push(nextStep("mjolnir diff", ui$7) + " — see only what's new.");
|
|
16371
|
-
return lines.join("\n");
|
|
16372
|
-
}
|
|
16373
|
-
function renderBaselineDiff(diff) {
|
|
16925
|
+
function renderImpact(report) {
|
|
16374
16926
|
const lines = [];
|
|
16375
|
-
lines.push(sectionHeader("
|
|
16927
|
+
lines.push(sectionHeader("IMPACT REPORT", ui$6));
|
|
16376
16928
|
lines.push("");
|
|
16377
|
-
if (!
|
|
16378
|
-
lines.push(
|
|
16379
|
-
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}`);
|
|
16380
16935
|
return lines.join("\n");
|
|
16381
16936
|
}
|
|
16382
|
-
lines.push(`
|
|
16383
|
-
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)}`);
|
|
16384
16938
|
lines.push("");
|
|
16385
|
-
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.");
|
|
16386
16945
|
else {
|
|
16387
|
-
lines.push(`
|
|
16388
|
-
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}`);
|
|
16389
16948
|
}
|
|
16390
16949
|
lines.push("");
|
|
16391
|
-
if (
|
|
16392
|
-
|
|
16393
|
-
|
|
16394
|
-
|
|
16395
|
-
lines.push(`FIXED SINCE BASELINE (${verified.length}):`);
|
|
16396
|
-
for (const f of verified) lines.push(` ✓ ${f.ruleId} (${f.severity}) · ${f.file} — ${f.message}`);
|
|
16397
|
-
lines.push("");
|
|
16398
|
-
}
|
|
16399
|
-
if (unresolved.length > 0) {
|
|
16400
|
-
lines.push(`DISAPPEARED — NOT CLASSIFIED AS FIXED (${unresolved.length}):`);
|
|
16401
|
-
for (const f of unresolved) lines.push(` ${renderResolution(f.resolution)} · ${f.ruleId} (${f.severity}) · ${f.file} — ${f.message}`);
|
|
16402
|
-
}
|
|
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}`);
|
|
16403
16954
|
}
|
|
16955
|
+
lines.push("");
|
|
16956
|
+
for (const fact of report.unknownFacts) lines.push(`UNKNOWN: ${fact}`);
|
|
16404
16957
|
return lines.join("\n");
|
|
16405
16958
|
}
|
|
16406
16959
|
//#endregion
|
|
@@ -16431,7 +16984,7 @@ function renderBaselineDiff(diff) {
|
|
|
16431
16984
|
* fix recorded by `diff`), announced once and never repeated. Display-only:
|
|
16432
16985
|
* it does not change scores, exit codes or the JSON schema.
|
|
16433
16986
|
*/
|
|
16434
|
-
const ui$
|
|
16987
|
+
const ui$5 = plainContext();
|
|
16435
16988
|
const DEFAULT_STATS_PATH = join(".mjolnir", "stats.json");
|
|
16436
16989
|
const MILESTONE_MESSAGES = {
|
|
16437
16990
|
"first-clean-scan": "MILESTONE: first flawless scan recorded for this repo (score 100, zero findings).",
|
|
@@ -16519,13 +17072,13 @@ function saveStats(stats, outPath) {
|
|
|
16519
17072
|
}
|
|
16520
17073
|
function renderStats(stats) {
|
|
16521
17074
|
const lines = [];
|
|
16522
|
-
lines.push(sectionHeader("ALL-TIME STATS (this machine, this repo)", ui$
|
|
17075
|
+
lines.push(sectionHeader("ALL-TIME STATS (this machine, this repo)", ui$5));
|
|
16523
17076
|
lines.push("");
|
|
16524
17077
|
if (!stats || stats.recordedFixEvents === 0) {
|
|
16525
17078
|
lines.push("No fixes recorded yet.");
|
|
16526
17079
|
lines.push("Fixes are counted here only when observed by mjolnir diff —", "capture a baseline first, then diff after making fixes:");
|
|
16527
|
-
lines.push(nextStep("mjolnir baseline", ui$
|
|
16528
|
-
lines.push(nextStep("mjolnir diff", ui$
|
|
17080
|
+
lines.push(nextStep("mjolnir baseline", ui$5));
|
|
17081
|
+
lines.push(nextStep("mjolnir diff", ui$5));
|
|
16529
17082
|
lines.push("");
|
|
16530
17083
|
lines.push("UNKNOWN: totals before tracking started. This command only counts");
|
|
16531
17084
|
lines.push("what it has personally witnessed via mjolnir diff.");
|
|
@@ -16554,7 +17107,7 @@ function renderStats(stats) {
|
|
|
16554
17107
|
*
|
|
16555
17108
|
* Idempotent: existing files are reported, never overwritten.
|
|
16556
17109
|
*/
|
|
16557
|
-
const ui$
|
|
17110
|
+
const ui$4 = plainContext();
|
|
16558
17111
|
function runInit(rootDir, workspace, options = {}) {
|
|
16559
17112
|
const steps = [];
|
|
16560
17113
|
const nextCommands = [];
|
|
@@ -16604,7 +17157,7 @@ function runInit(rootDir, workspace, options = {}) {
|
|
|
16604
17157
|
}
|
|
16605
17158
|
function renderInit(result) {
|
|
16606
17159
|
const lines = [];
|
|
16607
|
-
lines.push(sectionHeader("MJÖLNIR INIT", ui$
|
|
17160
|
+
lines.push(sectionHeader("MJÖLNIR INIT", ui$4));
|
|
16608
17161
|
lines.push("");
|
|
16609
17162
|
for (const s of result.steps) {
|
|
16610
17163
|
const icon = s.status === "advice" ? "·" : s.status === "exists" ? "=" : "-";
|
|
@@ -16613,7 +17166,7 @@ function renderInit(result) {
|
|
|
16613
17166
|
if (result.nextCommands.length > 0) {
|
|
16614
17167
|
lines.push("");
|
|
16615
17168
|
lines.push("Next commands:");
|
|
16616
|
-
for (const c of result.nextCommands) lines.push(nextStep(c, ui$
|
|
17169
|
+
for (const c of result.nextCommands) lines.push(nextStep(c, ui$4));
|
|
16617
17170
|
}
|
|
16618
17171
|
lines.push("");
|
|
16619
17172
|
lines.push("Existing files are never overwritten — init is safe to re-run.");
|
|
@@ -16631,7 +17184,7 @@ function tryReadPackageJson(rootDir) {
|
|
|
16631
17184
|
}
|
|
16632
17185
|
//#endregion
|
|
16633
17186
|
//#region src/commands/pw-report.ts
|
|
16634
|
-
const ui$
|
|
17187
|
+
const ui$3 = plainContext();
|
|
16635
17188
|
function summarizePwRun(report) {
|
|
16636
17189
|
const slowest = [...report.verdicts].sort((a, b) => b.totalDurationMs - a.totalDurationMs).slice(0, 5).map((v) => ({
|
|
16637
17190
|
title: v.title,
|
|
@@ -16651,7 +17204,7 @@ function summarizePwRun(report) {
|
|
|
16651
17204
|
}
|
|
16652
17205
|
function renderPwRunSummary(s) {
|
|
16653
17206
|
const lines = [];
|
|
16654
|
-
lines.push(sectionHeader("MJÖLNIR — RUN SUMMARY", ui$
|
|
17207
|
+
lines.push(sectionHeader("MJÖLNIR — RUN SUMMARY", ui$3));
|
|
16655
17208
|
lines.push("");
|
|
16656
17209
|
lines.push(`${s.total} tests · ${s.passed} passed · ${s.failed} failed · ${s.skipped} skipped`);
|
|
16657
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)`);
|
|
@@ -16682,7 +17235,7 @@ function renderPwRunSummary(s) {
|
|
|
16682
17235
|
* Everything else stays suggestion-only. No AST surgery on heuristic
|
|
16683
17236
|
* findings — false fixes would break the brand promise.
|
|
16684
17237
|
*/
|
|
16685
|
-
const ui$
|
|
17238
|
+
const ui$2 = plainContext();
|
|
16686
17239
|
const MAX_FILE_BYTES = 524288;
|
|
16687
17240
|
/**
|
|
16688
17241
|
* Path-containment guard (adversarial-audit wave; hardened per audit
|
|
@@ -17043,7 +17596,7 @@ function fixVerified(edit, fixedText, originalText) {
|
|
|
17043
17596
|
}
|
|
17044
17597
|
function renderFixReport(results, dryRun) {
|
|
17045
17598
|
const lines = [];
|
|
17046
|
-
lines.push(sectionHeader(dryRun ? "FIX PLAN (dry-run)" : "FIX REPORT", ui$
|
|
17599
|
+
lines.push(sectionHeader(dryRun ? "FIX PLAN (dry-run)" : "FIX REPORT", ui$2));
|
|
17047
17600
|
lines.push("");
|
|
17048
17601
|
if (results.length === 0) {
|
|
17049
17602
|
lines.push("No safe auto-fixes available for these findings.");
|
|
@@ -17051,7 +17604,7 @@ function renderFixReport(results, dryRun) {
|
|
|
17051
17604
|
return lines.join("\n");
|
|
17052
17605
|
}
|
|
17053
17606
|
for (const r of results) {
|
|
17054
|
-
const icon = r.status === "applied" ? okIcon(ui$
|
|
17607
|
+
const icon = r.status === "applied" ? okIcon(ui$2) : r.status === "planned" ? "▸" : "✗";
|
|
17055
17608
|
lines.push(`${icon} [${r.ruleId}] ${r.file}:${r.line} — ${r.description}`);
|
|
17056
17609
|
}
|
|
17057
17610
|
const applied = results.filter((r) => r.status === "applied").length;
|
|
@@ -17067,34 +17620,6 @@ function renderFixReport(results, dryRun) {
|
|
|
17067
17620
|
return lines.join("\n");
|
|
17068
17621
|
}
|
|
17069
17622
|
//#endregion
|
|
17070
|
-
//#region src/rules/measurement.ts
|
|
17071
|
-
/** The rule's declared detector implementation revision (§07). */
|
|
17072
|
-
function declaredDetectorRevision(rule) {
|
|
17073
|
-
return rule.detectorRevision ?? 1;
|
|
17074
|
-
}
|
|
17075
|
-
/**
|
|
17076
|
-
* A measurement exists AND was taken against the detector revision the
|
|
17077
|
-
* rule declares now. A revision mismatch (stale) does NOT count — §07:
|
|
17078
|
-
* stale → provisional → re-measure.
|
|
17079
|
-
*/
|
|
17080
|
-
function hasValidMeasurement(rule) {
|
|
17081
|
-
const m = MEASURED_FP[rule.id];
|
|
17082
|
-
return m !== void 0 && m.detectorRevision === declaredDetectorRevision(rule);
|
|
17083
|
-
}
|
|
17084
|
-
/**
|
|
17085
|
-
* The tier a rule effectively ships as: its declared tier, or — for the
|
|
17086
|
-
* omitted-tier case — the measurement-dependent default (§11.2 Step 2).
|
|
17087
|
-
*/
|
|
17088
|
-
function effectiveTier(rule) {
|
|
17089
|
-
if (rule.tier !== void 0) return rule.tier;
|
|
17090
|
-
if (hasValidMeasurement(rule)) return "core";
|
|
17091
|
-
return "extended";
|
|
17092
|
-
}
|
|
17093
|
-
/** The §11.2 Step 2 PROVISIONAL display predicate. */
|
|
17094
|
-
function isProvisional(rule) {
|
|
17095
|
-
return effectiveTier(rule) === "extended" && !hasValidMeasurement(rule);
|
|
17096
|
-
}
|
|
17097
|
-
//#endregion
|
|
17098
17623
|
//#region src/commands/doctor.ts
|
|
17099
17624
|
/**
|
|
17100
17625
|
* `mjolnir doctor` — self-audit of Mjolnir's own rule base.
|
|
@@ -17111,7 +17636,7 @@ function isProvisional(rule) {
|
|
|
17111
17636
|
*
|
|
17112
17637
|
* Exit codes reuse the frozen set: 0 healthy · 1 violations · 20 crash.
|
|
17113
17638
|
*/
|
|
17114
|
-
const ui$
|
|
17639
|
+
const ui$1 = plainContext();
|
|
17115
17640
|
const VALID_ID = RULE_ID_RE;
|
|
17116
17641
|
function nonHiddenFiles(dir) {
|
|
17117
17642
|
if (!existsSync(dir)) return [];
|
|
@@ -17310,7 +17835,7 @@ function runDoctorSelfAudit(fixturesRoot) {
|
|
|
17310
17835
|
function renderDoctorReport(report) {
|
|
17311
17836
|
const lines = [
|
|
17312
17837
|
"",
|
|
17313
|
-
sectionHeader("MJÖLNIR — SELF-AUDIT", ui$
|
|
17838
|
+
sectionHeader("MJÖLNIR — SELF-AUDIT", ui$1),
|
|
17314
17839
|
""
|
|
17315
17840
|
];
|
|
17316
17841
|
for (const c of report.checks) {
|
|
@@ -17388,160 +17913,6 @@ function escapeMdCell(text) {
|
|
|
17388
17913
|
return text.replaceAll("|", "\\|");
|
|
17389
17914
|
}
|
|
17390
17915
|
//#endregion
|
|
17391
|
-
//#region src/commands/fixture-example.ts
|
|
17392
|
-
/**
|
|
17393
|
-
* Shared example-fixture selection for `mjolnir explain` (explain.ts)
|
|
17394
|
-
* and the generated rule docs (rule-docs.ts) — one chooser so both
|
|
17395
|
-
* surfaces always show the same example for the same rule. Extracted
|
|
17396
|
-
* after the same bug-audit L9 fix had to be applied to both verbatim
|
|
17397
|
-
* copies: duplicated fixture selection drifts, and a drift means
|
|
17398
|
-
* `mjolnir explain <ID>` contradicts docs/rules/<ID>.md.
|
|
17399
|
-
*/
|
|
17400
|
-
/**
|
|
17401
|
-
* First (byte-stable) fixture file in a must-fire / must-not-fire
|
|
17402
|
-
* directory, or null when the directory is absent or empty.
|
|
17403
|
-
*/
|
|
17404
|
-
function firstFixtureFile(dir) {
|
|
17405
|
-
if (!existsSync(dir)) return null;
|
|
17406
|
-
const entries = readdirSync(dir).filter((f) => !f.startsWith(".")).sort();
|
|
17407
|
-
return entries.length > 0 ? join(dir, entries[0]) : null;
|
|
17408
|
-
}
|
|
17409
|
-
//#endregion
|
|
17410
|
-
//#region src/commands/explain.ts
|
|
17411
|
-
/**
|
|
17412
|
-
* `mjolnir explain <RULE-ID>` — implements Plan.md Sprint 1.3
|
|
17413
|
-
* (Master-Stabilization-Plan Sprint 5, Task 19).
|
|
17414
|
-
*
|
|
17415
|
-
* For any registered rule, renders what is wrong, why it matters, the
|
|
17416
|
-
* evidence level and confidence behind the verdict, the prescription,
|
|
17417
|
-
* and how to verify the fix. This is a presentation layer only — no new
|
|
17418
|
-
* detection logic. Every field it prints already exists on RuleMeta or
|
|
17419
|
-
* comes from actually running the rule against its own committed
|
|
17420
|
-
* must-fire fixture, so the example shown is real detector output, not
|
|
17421
|
-
* hand-written prose that can drift from what the rule actually does.
|
|
17422
|
-
*/
|
|
17423
|
-
const ui$1 = plainContext();
|
|
17424
|
-
/**
|
|
17425
|
-
* Runs the rule against its own must-fire fixture to get one real,
|
|
17426
|
-
* concrete example finding. `fixturesRoot` defaults to this repo's own
|
|
17427
|
-
* `tests/fixtures` — explain only has real examples to show when run
|
|
17428
|
-
* from (or pointed at) a Mjolnir checkout; degrades honestly
|
|
17429
|
-
* (exampleFinding left undefined) otherwise, same as `doctor`.
|
|
17430
|
-
*/
|
|
17431
|
-
function explainRule(ruleId, fixturesRoot) {
|
|
17432
|
-
const rule = getRule(ruleId);
|
|
17433
|
-
if (!rule) return {
|
|
17434
|
-
ok: false,
|
|
17435
|
-
error: `Unknown rule ID "${ruleId}". Run \`mjolnir rules\` for the full catalog.`
|
|
17436
|
-
};
|
|
17437
|
-
const fixturePath = firstFixtureFile(join(fixturesRoot, ruleId, "must-fire"));
|
|
17438
|
-
if (!fixturePath) return {
|
|
17439
|
-
ok: true,
|
|
17440
|
-
rule
|
|
17441
|
-
};
|
|
17442
|
-
let text;
|
|
17443
|
-
try {
|
|
17444
|
-
text = readFileSync(fixturePath, "utf8").replace(/\r\n/g, "\n");
|
|
17445
|
-
} catch {
|
|
17446
|
-
return {
|
|
17447
|
-
ok: true,
|
|
17448
|
-
rule
|
|
17449
|
-
};
|
|
17450
|
-
}
|
|
17451
|
-
const normalizedPath = fixturePath.replaceAll("\\", "/");
|
|
17452
|
-
let ast;
|
|
17453
|
-
if (rule.appliesTo === "ci-workflows") try {
|
|
17454
|
-
ast = parseWorkflow(text);
|
|
17455
|
-
} catch {
|
|
17456
|
-
return {
|
|
17457
|
-
ok: true,
|
|
17458
|
-
rule
|
|
17459
|
-
};
|
|
17460
|
-
}
|
|
17461
|
-
let findings;
|
|
17462
|
-
try {
|
|
17463
|
-
const parsed = {
|
|
17464
|
-
path: normalizedPath,
|
|
17465
|
-
text,
|
|
17466
|
-
ast
|
|
17467
|
-
};
|
|
17468
|
-
const codeText = computeCodeText(parsed, normalizedPath.endsWith(".py") ? "python" : normalizedPath.endsWith(".java") ? "java" : normalizedPath.endsWith(".cs") ? "csharp" : "typescript");
|
|
17469
|
-
findings = rule.run({
|
|
17470
|
-
...parsed,
|
|
17471
|
-
codeText
|
|
17472
|
-
});
|
|
17473
|
-
} catch {
|
|
17474
|
-
return {
|
|
17475
|
-
ok: true,
|
|
17476
|
-
rule
|
|
17477
|
-
};
|
|
17478
|
-
}
|
|
17479
|
-
const example = findings[0];
|
|
17480
|
-
if (!example) return {
|
|
17481
|
-
ok: true,
|
|
17482
|
-
rule
|
|
17483
|
-
};
|
|
17484
|
-
return {
|
|
17485
|
-
ok: true,
|
|
17486
|
-
rule,
|
|
17487
|
-
exampleFinding: example,
|
|
17488
|
-
exampleFixturePath: fixturePath,
|
|
17489
|
-
exampleFixtureRelPath: relative(fixturesRoot, fixturePath).replaceAll("\\", "/")
|
|
17490
|
-
};
|
|
17491
|
-
}
|
|
17492
|
-
/**
|
|
17493
|
-
* Default column budget when no width is supplied.
|
|
17494
|
-
*
|
|
17495
|
-
* `explain`'s prose used to be pushed as unbroken strings — the
|
|
17496
|
-
* "HOW TO VERIFY THE FIX" paragraph alone is 150 columns — so every
|
|
17497
|
-
* explanation overflowed a default terminal. Renderers here take a width
|
|
17498
|
-
* rather than reading process.stdout, so output stays a pure function of
|
|
17499
|
-
* its arguments (same rule the reporter's palette follows).
|
|
17500
|
-
*/
|
|
17501
|
-
const DEFAULT_EXPLAIN_WIDTH = 80;
|
|
17502
|
-
function renderExplain(result, width = DEFAULT_EXPLAIN_WIDTH) {
|
|
17503
|
-
if (!result.ok || !result.rule) return `explain failed: ${result.error ?? "unknown error"}`;
|
|
17504
|
-
const r = result.rule;
|
|
17505
|
-
const evidenceLevel = r.evidenceLevel ?? deriveEvidenceLevel(r.findingType, r.confidence);
|
|
17506
|
-
const lines = [];
|
|
17507
|
-
/** Pushes prose indented two columns, wrapped to the budget. */
|
|
17508
|
-
const pushBody = (text) => {
|
|
17509
|
-
for (const seg of wrapText(text, Math.max(20, width - 2))) lines.push(` ${seg}`);
|
|
17510
|
-
};
|
|
17511
|
-
lines.push(sectionHeader(`${r.id} — ${r.title}`, ui$1));
|
|
17512
|
-
lines.push("");
|
|
17513
|
-
lines.push(`Severity: ${r.severity}`);
|
|
17514
|
-
lines.push(`Confidence: ${r.confidence}`);
|
|
17515
|
-
lines.push(`Tier: ${effectiveTier(r)}${isProvisional(r) ? " (PROVISIONAL)" : ""}`);
|
|
17516
|
-
lines.push(`Evidence: ${evidenceLevel}`);
|
|
17517
|
-
lines.push(`QA impact: ${QA_IMPACT_LABELS[r.qaImpact]} (${r.qaImpact})`);
|
|
17518
|
-
const measured = MEASURED_FP[r.id];
|
|
17519
|
-
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)`);
|
|
17520
|
-
if (r.falsePositiveRisk) lines.push(`FP risk: ${r.falsePositiveRisk} (author estimate)`);
|
|
17521
|
-
if (r.languages?.length) lines.push(`Languages: ${r.languages.join(", ")}`);
|
|
17522
|
-
if (r.frameworks?.length) lines.push(`Frameworks: ${r.frameworks.join(", ")}`);
|
|
17523
|
-
lines.push("");
|
|
17524
|
-
if (result.exampleFinding) {
|
|
17525
|
-
const f = result.exampleFinding;
|
|
17526
|
-
lines.push("WHAT WAS FOUND (real detector output, not a mockup)");
|
|
17527
|
-
pushBody(f.message);
|
|
17528
|
-
lines.push("");
|
|
17529
|
-
lines.push("WHY IT MATTERS");
|
|
17530
|
-
pushBody(f.why);
|
|
17531
|
-
lines.push("");
|
|
17532
|
-
lines.push("HOW TO FIX");
|
|
17533
|
-
pushBody(f.fix);
|
|
17534
|
-
lines.push("");
|
|
17535
|
-
pushBody(`Example from this rule's own must-fire fixture: ${result.exampleFixtureRelPath ?? result.exampleFixturePath ?? "(unknown path)"}`);
|
|
17536
|
-
} 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);
|
|
17537
|
-
lines.push("");
|
|
17538
|
-
lines.push("HOW TO VERIFY THE FIX");
|
|
17539
|
-
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.");
|
|
17540
|
-
lines.push("");
|
|
17541
|
-
lines.push(`Docs: mjolnir rules --md (full catalog, this rule included)`);
|
|
17542
|
-
return lines.join("\n");
|
|
17543
|
-
}
|
|
17544
|
-
//#endregion
|
|
17545
17916
|
//#region src/playwright/selector-health-types.ts
|
|
17546
17917
|
/** Risk weights per locator shape (0 = resilient, higher = brittle). */
|
|
17547
17918
|
const LOCATOR_RISK = {
|
|
@@ -17693,7 +18064,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
|
|
|
17693
18064
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
17694
18065
|
* `tests/version-consistency.spec.ts` locally.
|
|
17695
18066
|
*/
|
|
17696
|
-
const CLI_VERSION = "0.5.
|
|
18067
|
+
const CLI_VERSION = "0.5.14";
|
|
17697
18068
|
function parseArgs(argv, onError) {
|
|
17698
18069
|
const args = {
|
|
17699
18070
|
target: ".",
|
|
@@ -18557,7 +18928,8 @@ const SUBCOMMANDS = /* @__PURE__ */ new Set([
|
|
|
18557
18928
|
"doctor",
|
|
18558
18929
|
"rules",
|
|
18559
18930
|
"explain",
|
|
18560
|
-
"doctor:playwright"
|
|
18931
|
+
"doctor:playwright",
|
|
18932
|
+
"mcp"
|
|
18561
18933
|
]);
|
|
18562
18934
|
async function main(argv = process$1.argv.slice(2), io = {
|
|
18563
18935
|
out,
|
|
@@ -18594,6 +18966,10 @@ async function main(argv = process$1.argv.slice(2), io = {
|
|
|
18594
18966
|
if (argv[0] === "why") return runWhyCommand(argv.slice(1), io);
|
|
18595
18967
|
if (argv[0] === "handoff") return runHandoffCommand(argv.slice(1), io);
|
|
18596
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
|
+
}
|
|
18597
18973
|
if (argv[0] === "help") return runHelpCommand(argv.slice(1), io);
|
|
18598
18974
|
if (SUBCOMMANDS.has(argv[0] ?? "")) {
|
|
18599
18975
|
err(`mjolnir: incomplete or unknown subcommand "${argv[0]}".`);
|