mjolnir-qa 0.5.5 → 0.5.7
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 +19 -0
- package/README.md +3 -3
- package/dist/cli.d.mts +10 -1
- package/dist/cli.mjs +616 -178
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -74,6 +74,25 @@ scan`. Unknown categories are a usage error (exit 10).
|
|
|
74
74
|
- help registry gained `why`, `handoff`, `install` and the new flags;
|
|
75
75
|
site/reference/cli.md documents the handoff trust model.
|
|
76
76
|
|
|
77
|
+
## [0.5.7] — 2026-09-06
|
|
78
|
+
|
|
79
|
+
### Changes since 0.5.6
|
|
80
|
+
|
|
81
|
+
- Merge pull request #40 from Sergey-Bar/eng/machine-contract-2.1
|
|
82
|
+
- test: plural + unknown-cause arms for inconclusive resolution rendering
|
|
83
|
+
- feat: machine verification contract + finding detectorRevision + lifecycle resolution (blueprint §12-§15, §17, §25)
|
|
84
|
+
|
|
85
|
+
## [0.5.6] — 2026-09-06
|
|
86
|
+
|
|
87
|
+
### Changes since 0.5.5
|
|
88
|
+
|
|
89
|
+
- Merge pull request #39 from Sergey-Bar/eng/verification-trust-2.0
|
|
90
|
+
- docs: regenerate readme SVGs under the revision-2 CI-rule measurements
|
|
91
|
+
- docs: regenerate rule-doc occurrence tables (yarnpkg-berry corpus lane)
|
|
92
|
+
- fix: eslint pragma for the file-wide consumer fallback regex
|
|
93
|
+
- fix: rev-2 enforcement arm in QA-CI-008 was dead code + coverage arms for CI rules
|
|
94
|
+
- ci-family trust repair: M2 detector re-adjudication, revision 2 re-measurement, workflow corpus lane
|
|
95
|
+
|
|
77
96
|
## [0.5.5] — 2026-09-06
|
|
78
97
|
|
|
79
98
|
### Audit Remediation 1.0 (engine correctness + trust boundary)
|
package/README.md
CHANGED
|
@@ -284,13 +284,13 @@ firewall.
|
|
|
284
284
|
|
|
285
285
|
### How much of this is measured
|
|
286
286
|
|
|
287
|
-
**
|
|
288
|
-
[docs/FP-AUDIT.md](docs/FP-AUDIT.md)). The other
|
|
287
|
+
**76 of 99 rules carry a false-positive rate measured against real OSS code** (≥ 10 hand-classified findings each; see
|
|
288
|
+
[docs/FP-AUDIT.md](docs/FP-AUDIT.md)). The other 23 ship on the author's
|
|
289
289
|
estimate. Every scan footer tells you how many of the rules that _fired_
|
|
290
290
|
are measured; `mjolnir rules --unmeasured` lists the ones that aren't;
|
|
291
291
|
every rule's `mjolnir explain` page states its status. We publish the rate
|
|
292
292
|
even when it's ugly — QA-CS-103 audits at 95% and is quarantined for it.
|
|
293
|
-
Growing that
|
|
293
|
+
Growing that number is the project's continuing work.
|
|
294
294
|
|
|
295
295
|
### Rule tiers and language maturity
|
|
296
296
|
|
package/dist/cli.d.mts
CHANGED
|
@@ -103,6 +103,15 @@ interface Finding {
|
|
|
103
103
|
measuredFpRate?: number;
|
|
104
104
|
/** Classified (TP+FP) verdicts behind `measuredFpRate`. */
|
|
105
105
|
measuredFpN?: number;
|
|
106
|
+
/**
|
|
107
|
+
* Detector revision of the rule that produced this finding, stamped
|
|
108
|
+
* from the registry at scan time (blueprint §13, G-16). Identity
|
|
109
|
+
* participates: "same ruleId, different detectorRevision" is formally
|
|
110
|
+
* a different detector for comparison purposes. Additive within
|
|
111
|
+
* schemaVersion 1; absent means the producer predates the field
|
|
112
|
+
* (revision-unknown).
|
|
113
|
+
*/
|
|
114
|
+
detectorRevision?: number;
|
|
106
115
|
/**
|
|
107
116
|
* Runtime corroboration from a real run report (plan §16), stamped
|
|
108
117
|
* when a report was available and matched this finding's file/test.
|
|
@@ -690,7 +699,7 @@ declare const runScan: typeof runScan$1, buildUniversalRules: typeof buildUniver
|
|
|
690
699
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
691
700
|
* `tests/version-consistency.spec.ts` locally.
|
|
692
701
|
*/
|
|
693
|
-
declare const CLI_VERSION = "0.5.
|
|
702
|
+
declare const CLI_VERSION = "0.5.7";
|
|
694
703
|
/** A usage-error detail: the offending token, when one exists. */
|
|
695
704
|
interface UsageErrorDetail {
|
|
696
705
|
/** The unknown flag or rejected value (e.g. `--nope`, `loud`). */
|
package/dist/cli.mjs
CHANGED
|
@@ -1766,8 +1766,10 @@ function parseWorkflow(text) {
|
|
|
1766
1766
|
const step = s;
|
|
1767
1767
|
return {
|
|
1768
1768
|
...typeof step["name"] === "string" ? { name: step["name"] } : {},
|
|
1769
|
+
...typeof step["id"] === "string" ? { id: step["id"] } : {},
|
|
1769
1770
|
...typeof step["run"] === "string" ? { run: step["run"] } : {},
|
|
1770
1771
|
...typeof step["uses"] === "string" ? { uses: step["uses"] } : {},
|
|
1772
|
+
...typeof step["if"] === "string" ? { if: step["if"] } : {},
|
|
1771
1773
|
...step["with"] && typeof step["with"] === "object" && !Array.isArray(step["with"]) ? { with: { ...step["with"] } } : {},
|
|
1772
1774
|
...typeof step["continue-on-error"] === "boolean" || typeof step["continue-on-error"] === "string" ? { "continue-on-error": step["continue-on-error"] } : {}
|
|
1773
1775
|
};
|
|
@@ -3546,7 +3548,7 @@ const VERIFICATION_GATE_RE = new RegExp([
|
|
|
3546
3548
|
String.raw`\bplaywright\s+test\b`,
|
|
3547
3549
|
String.raw`\b(?:pytest|tox|nox)\b`,
|
|
3548
3550
|
String.raw`\bpython\s+-m\s+(?:pytest|unittest)\b`,
|
|
3549
|
-
String.raw`\bmvn
|
|
3551
|
+
String.raw`\bmvn[wd]?\b[^\n]+\b(?:test|verify)\b`,
|
|
3550
3552
|
String.raw`\b(?:\./)?gradlew?\b[^\n]+\btest\b`,
|
|
3551
3553
|
String.raw`\bdotnet\s+test\b`,
|
|
3552
3554
|
String.raw`\bgo\s+test\b`,
|
|
@@ -3578,6 +3580,45 @@ function stepIsVerificationGate(step) {
|
|
|
3578
3580
|
}
|
|
3579
3581
|
return false;
|
|
3580
3582
|
}
|
|
3583
|
+
/**
|
|
3584
|
+
* detectorRevision 2 (M2, 2026-09-04): legitimate continue-on-error shapes
|
|
3585
|
+
* the rev-1 detector flagged. Each class was proven by adjudication
|
|
3586
|
+
* (docs/FP-AUDIT.md notes, 2026-09-02):
|
|
3587
|
+
* 1. Re-run idiom (appsmith ci-test-playwright.yml): the gate runs under
|
|
3588
|
+
* continue-on-error ONLY to convert attempt 1 into a non-failing
|
|
3589
|
+
* outcome so a follow-up step can re-run the SAME gate; the follow-up
|
|
3590
|
+
* runs unconditionally and its failure fails the job — the gate still
|
|
3591
|
+
* blocks.
|
|
3592
|
+
* 2. Non-gate shapes that matched the rev-1 run-text regex: report
|
|
3593
|
+
* aggregation (`test --merge-reports`), test-ID collection, shard
|
|
3594
|
+
* rebalancing helpers — they execute no verification.
|
|
3595
|
+
* 3. Steps whose `if:`/name explicitly mark them advisory/non-blocking
|
|
3596
|
+
* inside jobs that run the real gates (vault custom-linter,
|
|
3597
|
+
* github-docs sync-sdk-docs).
|
|
3598
|
+
*/
|
|
3599
|
+
const RERUN_OUTCOME_RE = /steps\.[\w-]+\.outcome\s*==\s*['"]?failure['"]?|steps\.[\w-]+\.conclusion\s*==\s*['"]?failure['"]?/;
|
|
3600
|
+
function jobReRunsTheMaskedGate(steps, maskedIndex) {
|
|
3601
|
+
return steps.some((s, i) => {
|
|
3602
|
+
if (i === maskedIndex) return false;
|
|
3603
|
+
const cond = typeof s?.if === "string" ? s.if : "";
|
|
3604
|
+
if (!RERUN_OUTCOME_RE.test(cond)) return false;
|
|
3605
|
+
return stepIsVerificationGate(s);
|
|
3606
|
+
});
|
|
3607
|
+
}
|
|
3608
|
+
/** Explicit advisory/non-verification shapes in run text. */
|
|
3609
|
+
const NON_GATE_RUN_RE = /--merge-reports\b|\bmerge-reports\b|\bcollect[- ]only\b|list[- ]tests\b|\brebalance\b/i;
|
|
3610
|
+
/**
|
|
3611
|
+
* Steps whose own name declares the non-blocking intent (adjudicated FP:
|
|
3612
|
+
* vault code-checker.yml "Check custom linters (non-blocking)" — the
|
|
3613
|
+
* workflow's own vocabulary marks the advisory contract, so the green
|
|
3614
|
+
* check hides nothing the author claimed would gate).
|
|
3615
|
+
*/
|
|
3616
|
+
const NON_BLOCKING_NAME_RE = /\(\s*non-?blocking\s*\)|\[.*non-?blocking.*\]/i;
|
|
3617
|
+
function stepIsGateExcludingNonVerification(step) {
|
|
3618
|
+
if (step.name && NON_BLOCKING_NAME_RE.test(step.name)) return false;
|
|
3619
|
+
if (step.run && NON_GATE_RUN_RE.test(step.run)) return false;
|
|
3620
|
+
return stepIsVerificationGate(step);
|
|
3621
|
+
}
|
|
3581
3622
|
const continueOnError = defineRule({
|
|
3582
3623
|
id: "QA-CI-001",
|
|
3583
3624
|
category: "QA-CI",
|
|
@@ -3604,13 +3645,13 @@ const continueOnError = defineRule({
|
|
|
3604
3645
|
for (const [jobName, job] of Object.entries(jobs)) {
|
|
3605
3646
|
const steps = job?.steps ?? [];
|
|
3606
3647
|
if (job && job["continue-on-error"] === true) {
|
|
3607
|
-
if (steps.some(
|
|
3648
|
+
if (steps.some(stepIsGateExcludingNonVerification)) findings.push({
|
|
3608
3649
|
severity: "error",
|
|
3609
3650
|
confidence: "high",
|
|
3610
3651
|
findingType: "deterministic-defect",
|
|
3611
3652
|
qaImpact: "FALSE-GREEN",
|
|
3612
3653
|
file: ctx.path,
|
|
3613
|
-
line: findLine$
|
|
3654
|
+
line: findLine$2(ctx.text, new RegExp(`^\\s{2,6}${escapeRe$1(jobName)}:`, "m")),
|
|
3614
3655
|
column: 1,
|
|
3615
3656
|
message: `Job \`${jobName}\` runs a verification gate under \`continue-on-error: true\`.`,
|
|
3616
3657
|
why: "This job can fail every day and CI will still show green. The checkmark on this workflow cannot be trusted.",
|
|
@@ -3619,7 +3660,8 @@ const continueOnError = defineRule({
|
|
|
3619
3660
|
}
|
|
3620
3661
|
for (const [i, step] of steps.entries()) {
|
|
3621
3662
|
if (!step || step["continue-on-error"] !== true) continue;
|
|
3622
|
-
if (!
|
|
3663
|
+
if (!stepIsGateExcludingNonVerification(step)) continue;
|
|
3664
|
+
if (jobReRunsTheMaskedGate(steps, i)) continue;
|
|
3623
3665
|
findings.push({
|
|
3624
3666
|
severity: "error",
|
|
3625
3667
|
confidence: "high",
|
|
@@ -3681,7 +3723,7 @@ function locateStepContinueOnError(text, step) {
|
|
|
3681
3723
|
if (anchorAt !== -1) return lineOf(text, anchorAt);
|
|
3682
3724
|
return 1;
|
|
3683
3725
|
}
|
|
3684
|
-
function findLine$
|
|
3726
|
+
function findLine$2(text, re) {
|
|
3685
3727
|
const m = re.exec(text);
|
|
3686
3728
|
if (!m) return 1;
|
|
3687
3729
|
return lineOf(text, m.index);
|
|
@@ -3691,7 +3733,7 @@ function lineOf(text, index) {
|
|
|
3691
3733
|
for (let i = 0; i < index; i++) if (text[i] === "\n") line++;
|
|
3692
3734
|
return line;
|
|
3693
3735
|
}
|
|
3694
|
-
function escapeRe$
|
|
3736
|
+
function escapeRe$1(s) {
|
|
3695
3737
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3696
3738
|
}
|
|
3697
3739
|
//#endregion
|
|
@@ -3716,6 +3758,7 @@ const swallowedExitCode = defineRule({
|
|
|
3716
3758
|
detectionStrategy: "LEXICAL",
|
|
3717
3759
|
introduced: "0.1.0",
|
|
3718
3760
|
tier: "extended",
|
|
3761
|
+
detectorRevision: 2,
|
|
3719
3762
|
run(ctx) {
|
|
3720
3763
|
const findings = [];
|
|
3721
3764
|
const re = /\|\|\s*true\b|:\s*(?:npm|yarn|pnpm|make|pytest|go)\b[^`\n]*\|\|\s*echo/g;
|
|
@@ -3767,6 +3810,7 @@ const retryMasking = defineRule({
|
|
|
3767
3810
|
detectionStrategy: "LEXICAL",
|
|
3768
3811
|
introduced: "0.1.0",
|
|
3769
3812
|
tier: "extended",
|
|
3813
|
+
detectorRevision: 2,
|
|
3770
3814
|
run(ctx) {
|
|
3771
3815
|
const findings = [];
|
|
3772
3816
|
const doc = ctx.ast;
|
|
@@ -3782,38 +3826,74 @@ const retryMasking = defineRule({
|
|
|
3782
3826
|
findingType: "deterministic-defect",
|
|
3783
3827
|
qaImpact: "FLAKY-RISK",
|
|
3784
3828
|
file: ctx.path,
|
|
3785
|
-
line:
|
|
3829
|
+
line: findStepUsesLine(ctx.text, step.uses, command),
|
|
3786
3830
|
column: 1,
|
|
3787
3831
|
message: `Job \`${jobName}\` wraps a test command in an automatic retry action.`,
|
|
3788
3832
|
why: "Retrying tests until they pass hides flaky and intermittent failures — the green check no longer means the suite passed.",
|
|
3789
3833
|
fix: "Remove the retry wrapper; investigate the underlying flakiness instead."
|
|
3790
3834
|
});
|
|
3791
3835
|
}
|
|
3792
|
-
if (step?.run
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3836
|
+
if (step?.run) {
|
|
3837
|
+
const LOOP_RE = /\bfor\b[^\n]*\$\(\s*(?:seq|range)\b|\bfor\b[^\n]*\{\d+\.\.\d+\}|\bfor\s+\w+\s+in\s+\d+(?:[,\t ]+\d+)*[;\s]*do\b|\bwhile\b[^\n]*\btrue\b|\bwhile\s+:;|\bmax_attempts\b|\buntil\b[^\n]*\bsucceed\b/i;
|
|
3838
|
+
const TEST_GATE_RE = /\b(?:npm|yarn|pnpm|bun)\s+(?:run\s+)?(?:test|t)\b|\bnpx\s+(?:vitest|jest|mocha|ava|playwright\s+test)\b|\b(?:vitest|jest|mocha|ava|tap)\b|\bplaywright\s+test\b|\b(?:pytest|tox|nox)\b|\bpython\s+-m\s+(?:pytest|unittest)\b|\bmvn[wd]?\b[^\n]+\b(?:test|verify)\b|\b(?:[.]\/)?gradlew?\b[^\n]+\btest\b|\bdotnet\s+test\b|\bgo\s+test\b|\bcargo\s+test\b|\bmake\s+[\w./\\-]*test\b/i;
|
|
3839
|
+
const isCurlProbe = /\b(?:curl|wget)\b/.test(step.run) && !TEST_GATE_RE.test(step.run);
|
|
3840
|
+
if (LOOP_RE.test(step.run) && TEST_GATE_RE.test(step.run) && !isCurlProbe) findings.push({
|
|
3841
|
+
severity: "warning",
|
|
3842
|
+
confidence: "medium",
|
|
3843
|
+
findingType: "heuristic-risk",
|
|
3844
|
+
qaImpact: "FLAKY-RISK",
|
|
3845
|
+
file: ctx.path,
|
|
3846
|
+
line: findStepLoopLine(ctx.text, step.run, LOOP_RE),
|
|
3847
|
+
column: 1,
|
|
3848
|
+
message: `Job \`${jobName}\` contains a shell retry loop around tests.`,
|
|
3849
|
+
why: "Retry-until-pass loops mask intermittent failures instead of surfacing them.",
|
|
3850
|
+
fix: "Run tests once; track and fix flakes explicitly."
|
|
3851
|
+
});
|
|
3852
|
+
}
|
|
3804
3853
|
}
|
|
3805
3854
|
return findings;
|
|
3806
3855
|
}
|
|
3807
3856
|
});
|
|
3808
|
-
|
|
3857
|
+
/**
|
|
3858
|
+
* Line of the loop construct belonging to THIS step.
|
|
3859
|
+
*
|
|
3860
|
+
* detectorRevision 2: anchors at the step's first run line, then finds the
|
|
3861
|
+
* loop construct at or after it — a file-wide LOOP_RE search reported every
|
|
3862
|
+
* matching step on the first loop occurrence in the file.
|
|
3863
|
+
*/
|
|
3864
|
+
function findStepLoopLine(text, run, loopRe) {
|
|
3865
|
+
const firstLine = run.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
|
|
3866
|
+
const anchorAt = text.indexOf(firstLine);
|
|
3867
|
+
const re = new RegExp(loopRe.source, "gi");
|
|
3868
|
+
re.lastIndex = Math.max(0, anchorAt);
|
|
3809
3869
|
const m = re.exec(text);
|
|
3810
3870
|
if (!m) return 1;
|
|
3811
3871
|
let line = 1;
|
|
3812
3872
|
for (let i = 0; i < m.index; i++) if (text[i] === "\n") line++;
|
|
3813
3873
|
return line;
|
|
3814
3874
|
}
|
|
3815
|
-
|
|
3816
|
-
|
|
3875
|
+
/**
|
|
3876
|
+
* Line of the retry-action `uses:` belonging to THIS step.
|
|
3877
|
+
*
|
|
3878
|
+
* detectorRevision 2: anchors at the step's own `with.command` text and
|
|
3879
|
+
* takes the nearest `uses:` occurrence at or before it — a file-wide search
|
|
3880
|
+
* reported every wrapping step on the first occurrence when several jobs
|
|
3881
|
+
* use the same retry action at the same version.
|
|
3882
|
+
*/
|
|
3883
|
+
function findStepUsesLine(text, uses, command) {
|
|
3884
|
+
const needle = uses.trim();
|
|
3885
|
+
const firstCmdLine = command.trim().split("\n")[0]?.trim();
|
|
3886
|
+
const at = nearestBefore(text, Math.max(0, text.indexOf(firstCmdLine)), needle);
|
|
3887
|
+
if (at === -1) return 1;
|
|
3888
|
+
let line = 1;
|
|
3889
|
+
for (let i = 0; i < at; i++) if (text[i] === "\n") line++;
|
|
3890
|
+
return line;
|
|
3891
|
+
}
|
|
3892
|
+
/** The nearest occurrence of `needle` ending at or before `at`. */
|
|
3893
|
+
function nearestBefore(text, at, needle) {
|
|
3894
|
+
const windowStart = Math.max(0, at - 2e3);
|
|
3895
|
+
const rel = text.slice(windowStart, at + 1).lastIndexOf(needle);
|
|
3896
|
+
return rel !== -1 ? windowStart + rel : -1;
|
|
3817
3897
|
}
|
|
3818
3898
|
//#endregion
|
|
3819
3899
|
//#region src/rules/ci/qa-ci-005-report-never-generated.ts
|
|
@@ -3825,15 +3905,76 @@ function escapeRe$1(s) {
|
|
|
3825
3905
|
* that no previous step produces — the gate reads an empty or stale file
|
|
3826
3906
|
* and passes vacuously. Product-MVP §35: "Required report not produced".
|
|
3827
3907
|
*/
|
|
3908
|
+
/**
|
|
3909
|
+
* Producer vocabulary for coverage reports, one regex over run text.
|
|
3910
|
+
*
|
|
3911
|
+
* detectorRevision 2 (M2, 2026-09-04): the rev-1 regex knew only
|
|
3912
|
+
* `vitest|jest|nyc --coverage`. Adjudication against the real corpus
|
|
3913
|
+
* (docs/FP-AUDIT.md rows) proved four blindness classes, each a real
|
|
3914
|
+
* producer the regex could not see:
|
|
3915
|
+
* 1. Java/JaCoCo — `mvn verify` with the jacoco-maven-plugin, Gradle
|
|
3916
|
+
* `jacocoTestReport`
|
|
3917
|
+
* 2. Python — `pytest --cov` (pytest-cov), `coverage run/combine/json/xml`
|
|
3918
|
+
* (coverage.py), `COVERAGE_FILE` env wiring
|
|
3919
|
+
* 3. Go / .NET / Rust — `go test -coverprofile`, `dotnet test --collect`,
|
|
3920
|
+
* `cargo llvm-cov` / `cargo tarpaulin`
|
|
3921
|
+
* 4. Script-name coverage — `npm run test:coverage` and friends, where the
|
|
3922
|
+
* coverage flag lives inside package.json, invisible from the workflow
|
|
3923
|
+
*
|
|
3924
|
+
* Known residual limitation (documented, not fixable single-file): a
|
|
3925
|
+
* producer in a DIFFERENT workflow (reusable workflows, artifact producers
|
|
3926
|
+
* from `workflow_run` jobs) or inside tox.ini / vitest.config is invisible;
|
|
3927
|
+
* cross-workflow consumers remain flaggable — measured rates carry those
|
|
3928
|
+
* verdicts (streamlit ai-test-coverage.yml class).
|
|
3929
|
+
*/
|
|
3930
|
+
/**
|
|
3931
|
+
* Producer vocabulary for coverage reports, tested one literal at a time
|
|
3932
|
+
* over run/`with` text.
|
|
3933
|
+
*
|
|
3934
|
+
* detectorRevision 2 (M2, 2026-09-04): the rev-1 regex knew only
|
|
3935
|
+
* `vitest|jest|nyc --coverage`. Adjudication against the real corpus
|
|
3936
|
+
* (docs/FP-AUDIT.md rows) proved four blindness classes, each a real
|
|
3937
|
+
* producer the regex could not see:
|
|
3938
|
+
* 1. Java/JaCoCo — `mvn verify` with the jacoco-maven-plugin, Gradle
|
|
3939
|
+
* `jacocoTestReport`
|
|
3940
|
+
* 2. Python — `pytest --cov` (pytest-cov), `coverage run/combine/json/xml`
|
|
3941
|
+
* (coverage.py), `COVERAGE_FILE` env wiring
|
|
3942
|
+
* 3. Go / .NET / Rust — `go test -coverprofile`, `dotnet test --collect`,
|
|
3943
|
+
* `cargo llvm-cov` / `cargo tarpaulin`
|
|
3944
|
+
* 4. Script-name coverage — `npm run test:coverage` and friends, where the
|
|
3945
|
+
* coverage flag lives inside package.json, invisible from the workflow
|
|
3946
|
+
*
|
|
3947
|
+
* Known residual limitation (documented, not fixable single-file): a
|
|
3948
|
+
* producer in a DIFFERENT workflow (reusable workflows, artifact producers
|
|
3949
|
+
* from `workflow_run` jobs) or inside tox.ini / vitest.config is invisible;
|
|
3950
|
+
* cross-workflow consumers remain flaggable — measured rates carry those
|
|
3951
|
+
* verdicts (streamlit ai-test-coverage.yml class).
|
|
3952
|
+
*/
|
|
3953
|
+
const COVERAGE_PRODUCERS = [
|
|
3954
|
+
/\b(?:npx\s+)?(?:vitest|jest|nyc)\b[^\n]*--coverage\b/i,
|
|
3955
|
+
/--coverage\b/i,
|
|
3956
|
+
/\b(?:npm|yarn|pnpm|bun)\s+(?:run\s+)?[\w:@/.-]*coverage[\w:@/.-]*/i,
|
|
3957
|
+
/\bmvn[wd]?\b[^\n]*\b(?:jacoco|verify)\b/i,
|
|
3958
|
+
/\b(?:[.]\/)?gradlew?\b[^\n]*\bjacoco\w*\b/i,
|
|
3959
|
+
/\bjacoco\w*Report\b/i,
|
|
3960
|
+
/\bpytest\b[^\n]*--cov\b/i,
|
|
3961
|
+
/\bcoverage\s+(?:run|combine|json|xml|report)\b/i,
|
|
3962
|
+
/\bCOVERAGE_FILE\b/i,
|
|
3963
|
+
/\bpytest-cov\b/i,
|
|
3964
|
+
/\bgo\s+test\b[^\n]*-cover(?:profile|mode|age)\b/i,
|
|
3965
|
+
/\bdotnet\s+test\b[^\n]*(?:--collect|--coverage|collect coverage)/i,
|
|
3966
|
+
/\bcargo\s+(?:llvm-cov|tarpaulin)\b/i,
|
|
3967
|
+
/\b(?:coverage|lcov|cobertura)[\w./-]*\.(?:info|xml|json|lcov)\b/i
|
|
3968
|
+
];
|
|
3828
3969
|
/** Known report-consumption patterns and the commands that produce them. */
|
|
3829
3970
|
const CONSUMERS = [{
|
|
3830
3971
|
re: /codecov|coveralls/i,
|
|
3831
3972
|
stepRe: /upload-artifact/i,
|
|
3832
|
-
|
|
3973
|
+
producers: COVERAGE_PRODUCERS,
|
|
3833
3974
|
label: "coverage artifact"
|
|
3834
3975
|
}, {
|
|
3835
3976
|
re: /codecov|coveralls/i,
|
|
3836
|
-
|
|
3977
|
+
producers: COVERAGE_PRODUCERS,
|
|
3837
3978
|
label: "coverage upload"
|
|
3838
3979
|
}];
|
|
3839
3980
|
const reportNeverGenerated = defineRule({
|
|
@@ -3852,27 +3993,34 @@ const reportNeverGenerated = defineRule({
|
|
|
3852
3993
|
detectionStrategy: "LEXICAL",
|
|
3853
3994
|
introduced: "0.1.0",
|
|
3854
3995
|
tier: "quarantine",
|
|
3996
|
+
detectorRevision: 2,
|
|
3855
3997
|
run(ctx) {
|
|
3856
3998
|
const findings = [];
|
|
3857
3999
|
const doc = ctx.ast;
|
|
3858
4000
|
if (!doc?.jobs) return findings;
|
|
3859
4001
|
const jobEntries = Object.entries(doc.jobs);
|
|
3860
|
-
const
|
|
4002
|
+
const stepText = (s) => [s.run ?? "", ...s.uses?.includes("upload-artifact") ? [] : Object.values(s.with ?? {}).filter((v) => typeof v === "string")].join("\n");
|
|
4003
|
+
const workflowRunText = jobEntries.map(([, j]) => (j?.steps ?? []).map(stepText).join("\n")).join("\n");
|
|
3861
4004
|
for (const [jobName, job] of jobEntries) {
|
|
3862
4005
|
const steps = job?.steps ?? [];
|
|
3863
|
-
const allRunText = steps.map(
|
|
4006
|
+
const allRunText = steps.map(stepText).join("\n");
|
|
3864
4007
|
for (const consumer of CONSUMERS) {
|
|
3865
4008
|
if (!(steps.some((s) => {
|
|
3866
4009
|
if (s.uses && consumer.re.test(s.uses)) return true;
|
|
3867
4010
|
if (consumer.stepRe && s.uses && consumer.stepRe.test(s.uses) && s.with && typeof s.with["path"] === "string" && /coverage|lcov/i.test(s.with["path"])) return true;
|
|
3868
4011
|
return false;
|
|
3869
4012
|
}) || consumer.re.test(allRunText))) continue;
|
|
3870
|
-
|
|
4013
|
+
const coverageDataDownload = steps.some((s) => s.uses?.includes("download-artifact") === true && s.with !== void 0 && [
|
|
4014
|
+
"pattern",
|
|
4015
|
+
"path",
|
|
4016
|
+
"name"
|
|
4017
|
+
].some((k) => typeof s.with?.[k] === "string" && /coverage/i.test(s.with[k])));
|
|
4018
|
+
if (!(consumer.producers.some((p) => p.test(allRunText) || p.test(workflowRunText)) || coverageDataDownload)) findings.push({
|
|
3871
4019
|
severity: "error",
|
|
3872
4020
|
confidence: "high",
|
|
3873
4021
|
findingType: "deterministic-defect",
|
|
3874
4022
|
file: ctx.path,
|
|
3875
|
-
line:
|
|
4023
|
+
line: findJobConsumerLine(ctx.text, jobName, consumer.re),
|
|
3876
4024
|
column: 1,
|
|
3877
4025
|
message: `Job \`${jobName}\` consumes a ${consumer.label} that no step generates.`,
|
|
3878
4026
|
why: "The gate reads a report that is never produced — it passes on empty/stale data while appearing to verify something.",
|
|
@@ -3884,11 +4032,27 @@ const reportNeverGenerated = defineRule({
|
|
|
3884
4032
|
return findings;
|
|
3885
4033
|
}
|
|
3886
4034
|
});
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
4035
|
+
/**
|
|
4036
|
+
* Line of the consumer signal belonging to THIS job.
|
|
4037
|
+
*
|
|
4038
|
+
* detectorRevision 2: `findLine` searched the whole file and always
|
|
4039
|
+
* returned the FIRST consumer occurrence — every consuming job in a
|
|
4040
|
+
* multi-job workflow reported the same line, so distinct findings
|
|
4041
|
+
* collapsed into one verdict position. Anchoring at the job's own
|
|
4042
|
+
* declaration keeps each job's finding on its own line.
|
|
4043
|
+
*/
|
|
4044
|
+
function findJobConsumerLine(text, jobName, consumerRe) {
|
|
4045
|
+
const declMatch = new RegExp(`^\\s{2,6}${jobName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:`, "m").exec(text);
|
|
4046
|
+
const re = new RegExp(consumerRe.source, "gi");
|
|
4047
|
+
re.lastIndex = declMatch ? declMatch.index : 0;
|
|
4048
|
+
const anchored = re.exec(text);
|
|
4049
|
+
if (anchored) return lineOfIndex(text, anchored.index);
|
|
4050
|
+
const anywhere = new RegExp(consumerRe.source, "i").exec(text);
|
|
4051
|
+
return anywhere ? lineOfIndex(text, anywhere.index) : 1;
|
|
4052
|
+
}
|
|
4053
|
+
function lineOfIndex(text, index) {
|
|
3890
4054
|
let line = 1;
|
|
3891
|
-
for (let i = 0; i <
|
|
4055
|
+
for (let i = 0; i < index; i++) if (text[i] === "\n") line++;
|
|
3892
4056
|
return line;
|
|
3893
4057
|
}
|
|
3894
4058
|
//#endregion
|
|
@@ -3901,6 +4065,18 @@ function findLine$2(text, re) {
|
|
|
3901
4065
|
* failure-tolerant pattern can flip a red job green — the classic
|
|
3902
4066
|
* false-green trick.
|
|
3903
4067
|
*/
|
|
4068
|
+
/**
|
|
4069
|
+
* Failure-enforcement shapes that legitimately follow tolerated steps: a
|
|
4070
|
+
* later `if: always()` step that checks the recorded status and exits
|
|
4071
|
+
* non-zero on failure. Adjudication (hashicorp/vault build.yml:889,
|
|
4072
|
+
* ci.yml:492, test-go.yml:992 — 2026-09-02) proved the rev-1 detector
|
|
4073
|
+
* blind to this canonical honest pattern: the tolerated steps' outcomes
|
|
4074
|
+
* ARE enforced after them, so nothing is masked.
|
|
4075
|
+
*/
|
|
4076
|
+
const ENFORCED_FAILURE_LATER = /(?:result|status)[^\n]*!=\s*['"]?success['"]?|exit\s+1\b|FAILURE|has\s*failed/i;
|
|
4077
|
+
function laterStepEnforcesFailure(steps, fromIndex) {
|
|
4078
|
+
return steps.slice(fromIndex + 1).some((s) => typeof s?.if === "string" && /always\s*\(\)/.test(s.if) && /!=\s*['"]?success['"]?|==\s*['"]?failure['"]?|result/i.test(s.if) && typeof s?.run === "string" && ENFORCED_FAILURE_LATER.test(s.run));
|
|
4079
|
+
}
|
|
3904
4080
|
const alwaysSuccessStep = defineRule({
|
|
3905
4081
|
id: "QA-CI-008",
|
|
3906
4082
|
category: "QA-CI",
|
|
@@ -3917,6 +4093,7 @@ const alwaysSuccessStep = defineRule({
|
|
|
3917
4093
|
detectionStrategy: "LEXICAL",
|
|
3918
4094
|
introduced: "0.1.0",
|
|
3919
4095
|
tier: "quarantine",
|
|
4096
|
+
detectorRevision: 2,
|
|
3920
4097
|
run(ctx) {
|
|
3921
4098
|
const findings = [];
|
|
3922
4099
|
const doc = ctx.ast;
|
|
@@ -3926,9 +4103,11 @@ const alwaysSuccessStep = defineRule({
|
|
|
3926
4103
|
if (steps.length < 2) continue;
|
|
3927
4104
|
const last = steps[steps.length - 1];
|
|
3928
4105
|
if (!last?.run) continue;
|
|
3929
|
-
const suspicious = /^\s*exit\s+0\s*$/m.test(last.run) || /^\s*(?:echo|printf)\b[^&|;]*$/.test(last.run.trim()) || /^\s*true\s*$/.test(last.run.trim());
|
|
3930
|
-
const
|
|
3931
|
-
|
|
4106
|
+
const suspicious = !/exit\s+1\b|!=\s*['"]?success['"]?|==\s*['"]?failure['"]?/.test(last.run) && (/^\s*exit\s+0\s*$/m.test(last.run) || /^\s*(?:echo|printf)\b[^&|;]*$/.test(last.run.trim()) || /^\s*true\s*$/.test(last.run.trim()));
|
|
4107
|
+
const earlierTolerantGate = steps.slice(0, -1).some((s) => (s?.["continue-on-error"] === true || /\|\|\s*true\b/.test(s?.run ?? "")) && (looksLikeVerificationGate(s?.run ?? "") || s?.uses !== void 0 && /playwright|cypress|codecov\/codecov-action/i.test(s.uses)));
|
|
4108
|
+
const toleratedIdx = steps.findIndex((s) => (s?.["continue-on-error"] === true || /\|\|\s*true\b/.test(s?.run ?? "")) && (looksLikeVerificationGate(s?.run ?? "") || s?.uses !== void 0 && /playwright|cypress|codecov\/codecov-action/i.test(s.uses)));
|
|
4109
|
+
if (toleratedIdx !== -1 && laterStepEnforcesFailure(steps, toleratedIdx)) continue;
|
|
4110
|
+
if (suspicious && earlierTolerantGate) findings.push({
|
|
3932
4111
|
severity: "error",
|
|
3933
4112
|
confidence: "high",
|
|
3934
4113
|
findingType: "deterministic-defect",
|
|
@@ -3981,6 +4160,8 @@ const exitCodeNotPropagated = defineRule({
|
|
|
3981
4160
|
detectionStrategy: "FRAMEWORK",
|
|
3982
4161
|
detectionNotes: "regex pattern on parsed workflow AST",
|
|
3983
4162
|
introduced: "0.4.0",
|
|
4163
|
+
detectorRevision: 2,
|
|
4164
|
+
tier: "extended",
|
|
3984
4165
|
run(ctx) {
|
|
3985
4166
|
const findings = [];
|
|
3986
4167
|
const doc = ctx.ast;
|
|
@@ -4008,11 +4189,13 @@ const exitCodeNotPropagated = defineRule({
|
|
|
4008
4189
|
});
|
|
4009
4190
|
}
|
|
4010
4191
|
if (/set\s+(?:-[A-Za-df-z]*e[A-Za-z]*|-o\s+errexit)\b/.test(run)) continue;
|
|
4192
|
+
const strippedRun = stripQuoted(run);
|
|
4011
4193
|
const seqRe = new RegExp(`(?:${TEST_CMD$1.source})[^\\n;]*;\\s*[^\\n]+`, "g");
|
|
4012
4194
|
let sm;
|
|
4013
|
-
while ((sm = seqRe.exec(
|
|
4195
|
+
while ((sm = seqRe.exec(strippedRun)) !== null) {
|
|
4014
4196
|
const seg = sm[0];
|
|
4015
4197
|
if (/&&|\|\|/.test(seg)) continue;
|
|
4198
|
+
if (/^\s*(?:do|then|else|fi|done|elif|esac)\b|^\s*\}/.test(seg.slice(seg.indexOf(";") + 1))) continue;
|
|
4016
4199
|
const afterSemi = seg.slice(seg.indexOf(";") + 1);
|
|
4017
4200
|
if (TEST_CMD$1.test(afterSemi)) continue;
|
|
4018
4201
|
findings.push({
|
|
@@ -4032,6 +4215,17 @@ const exitCodeNotPropagated = defineRule({
|
|
|
4032
4215
|
return findings;
|
|
4033
4216
|
}
|
|
4034
4217
|
});
|
|
4218
|
+
/**
|
|
4219
|
+
* Replaces the CONTENT of double- and single-quoted segments with spaces
|
|
4220
|
+
* (length-preserving) so shell separators inside strings — a JS test file
|
|
4221
|
+
* echoed through `tee`, e.g. `echo "it('x'); expect(y)" | tee t.js` — can
|
|
4222
|
+
* never anchor a `;`-sequence finding. Quotes are shell-sensitive; this is
|
|
4223
|
+
* deliberately conservative: only quote-delimited, same-line segments are
|
|
4224
|
+
* stripped.
|
|
4225
|
+
*/
|
|
4226
|
+
function stripQuoted(text) {
|
|
4227
|
+
return text.replace(/"[^"\n]*"|'[^'\n]*'/g, (m) => " ".repeat(m.length));
|
|
4228
|
+
}
|
|
4035
4229
|
function findLine$1(text, needle) {
|
|
4036
4230
|
const idx = text.indexOf(needle);
|
|
4037
4231
|
if (idx === -1) return 1;
|
|
@@ -4057,7 +4251,19 @@ const TEST_CMD = /\b(?:npm|yarn|pnpm)\s+(?:run\s+)?test\b|\b(?:jest|vitest|pytes
|
|
|
4057
4251
|
* `if:` conditions that skip the job on pull requests.
|
|
4058
4252
|
* NOTE: `!=` only — `github.event_name == 'pull_request'` is the OPPOSITE
|
|
4059
4253
|
* (run ONLY on PRs) and must never match here.
|
|
4060
|
-
|
|
4254
|
+
*
|
|
4255
|
+
* detectorRevision 2 (M2, 2026-09-04): a condition that CONTAINS a
|
|
4256
|
+
* push-match alternative can still RUN on PRs via `||` branches —
|
|
4257
|
+
* `github.event_name == 'push' || ... || (pull_request && !draft)` runs on
|
|
4258
|
+
* non-draft PRs (adjudicated FPs: nocodb jest-unit-test.yml, grafana
|
|
4259
|
+
* pr-frontend-unit-tests.yml, streamlit publish jobs). The condition is a
|
|
4260
|
+
* skip only when EVERY PR-shaped alternative excludes PRs: i.e. the
|
|
4261
|
+
* condition contains a PR-inclusive branch (== pull_request, !draft,
|
|
4262
|
+
* labeled trigger) or consists solely of push/schedule/dispatch
|
|
4263
|
+
* alternatives — matched below only when no PR-inclusive alternative
|
|
4264
|
+
* exists anywhere in the condition.
|
|
4265
|
+
*/
|
|
4266
|
+
const PR_INCLUSIVE_RE = /github\.event_name\s*==\s*['"]?pull_request\b|pull_request[^\n]*!\s*=\s*['"]?draft|!\s*github\.event\.pull_request|github\.event\.label\.name|!=\s*['"]?draft['"]?|head\.repo\.fork\s*==\s*['"]?false['"]?/;
|
|
4061
4267
|
const SKIP_ON_PR = /github\.event_name\s*!=\s*['"]?pull_request|github\.event_name\s*==\s*['"]?(?:push|schedule|workflow_dispatch)\b|!\s*github\.event\b|github\.ref\s*==\s*['"]?refs\/heads\/(?:main|master)\b/;
|
|
4062
4268
|
const nonBlockingTestJob = defineRule({
|
|
4063
4269
|
id: "QA-CI-010",
|
|
@@ -4076,6 +4282,7 @@ const nonBlockingTestJob = defineRule({
|
|
|
4076
4282
|
detectionNotes: "regex heuristic on parsed workflow AST",
|
|
4077
4283
|
introduced: "0.4.0",
|
|
4078
4284
|
tier: "quarantine",
|
|
4285
|
+
detectorRevision: 2,
|
|
4079
4286
|
run(ctx) {
|
|
4080
4287
|
const findings = [];
|
|
4081
4288
|
const doc = ctx.ast;
|
|
@@ -4083,6 +4290,7 @@ const nonBlockingTestJob = defineRule({
|
|
|
4083
4290
|
for (const [jobName, job] of Object.entries(doc.jobs)) {
|
|
4084
4291
|
if (!(job?.steps ?? []).some((s) => TEST_CMD.test(s?.run ?? ""))) continue;
|
|
4085
4292
|
const cond = typeof job?.if === "string" ? job.if : "";
|
|
4293
|
+
if (cond && PR_INCLUSIVE_RE.test(cond)) continue;
|
|
4086
4294
|
if (cond && SKIP_ON_PR.test(cond)) findings.push({
|
|
4087
4295
|
severity: "error",
|
|
4088
4296
|
confidence: "medium",
|
|
@@ -8589,46 +8797,53 @@ function getRule(id) {
|
|
|
8589
8797
|
//#region src/rules/measured-fp.generated.ts
|
|
8590
8798
|
const MEASURED_FP = {
|
|
8591
8799
|
"QA-CI-001": {
|
|
8592
|
-
fpRate: .
|
|
8800
|
+
fpRate: .105,
|
|
8593
8801
|
n: 19,
|
|
8594
8802
|
detectorRevision: 2,
|
|
8595
|
-
ciLow: .
|
|
8596
|
-
ciHigh: .
|
|
8803
|
+
ciLow: .0294,
|
|
8804
|
+
ciHigh: .3139
|
|
8597
8805
|
},
|
|
8598
8806
|
"QA-CI-002": {
|
|
8599
|
-
fpRate: .
|
|
8600
|
-
n:
|
|
8601
|
-
detectorRevision:
|
|
8602
|
-
ciLow: .
|
|
8603
|
-
ciHigh: .
|
|
8807
|
+
fpRate: .111,
|
|
8808
|
+
n: 18,
|
|
8809
|
+
detectorRevision: 2,
|
|
8810
|
+
ciLow: .031,
|
|
8811
|
+
ciHigh: .328
|
|
8604
8812
|
},
|
|
8605
8813
|
"QA-CI-005": {
|
|
8606
|
-
fpRate: .
|
|
8607
|
-
n:
|
|
8608
|
-
detectorRevision:
|
|
8609
|
-
ciLow: .
|
|
8610
|
-
ciHigh: .
|
|
8814
|
+
fpRate: .077,
|
|
8815
|
+
n: 13,
|
|
8816
|
+
detectorRevision: 2,
|
|
8817
|
+
ciLow: .0137,
|
|
8818
|
+
ciHigh: .3331
|
|
8611
8819
|
},
|
|
8612
8820
|
"QA-CI-007": {
|
|
8613
|
-
fpRate:
|
|
8821
|
+
fpRate: 0,
|
|
8614
8822
|
n: 11,
|
|
8615
|
-
detectorRevision:
|
|
8616
|
-
ciLow:
|
|
8617
|
-
ciHigh: .
|
|
8823
|
+
detectorRevision: 2,
|
|
8824
|
+
ciLow: 0,
|
|
8825
|
+
ciHigh: .2588
|
|
8618
8826
|
},
|
|
8619
8827
|
"QA-CI-008": {
|
|
8620
|
-
fpRate: .
|
|
8621
|
-
n:
|
|
8622
|
-
detectorRevision:
|
|
8623
|
-
ciLow: .
|
|
8624
|
-
ciHigh: .
|
|
8828
|
+
fpRate: .1,
|
|
8829
|
+
n: 10,
|
|
8830
|
+
detectorRevision: 2,
|
|
8831
|
+
ciLow: .0179,
|
|
8832
|
+
ciHigh: .4042
|
|
8833
|
+
},
|
|
8834
|
+
"QA-CI-009": {
|
|
8835
|
+
fpRate: 0,
|
|
8836
|
+
n: 10,
|
|
8837
|
+
detectorRevision: 2,
|
|
8838
|
+
ciLow: 0,
|
|
8839
|
+
ciHigh: .2775
|
|
8625
8840
|
},
|
|
8626
8841
|
"QA-CI-010": {
|
|
8627
|
-
fpRate: .
|
|
8842
|
+
fpRate: .1,
|
|
8628
8843
|
n: 10,
|
|
8629
|
-
detectorRevision:
|
|
8630
|
-
ciLow: .
|
|
8631
|
-
ciHigh: .
|
|
8844
|
+
detectorRevision: 2,
|
|
8845
|
+
ciLow: .0179,
|
|
8846
|
+
ciHigh: .4042
|
|
8632
8847
|
},
|
|
8633
8848
|
"QA-CS-101": {
|
|
8634
8849
|
fpRate: 0,
|
|
@@ -8659,18 +8874,18 @@ const MEASURED_FP = {
|
|
|
8659
8874
|
ciHigh: .495
|
|
8660
8875
|
},
|
|
8661
8876
|
"QA-CS-108": {
|
|
8662
|
-
fpRate:
|
|
8663
|
-
n:
|
|
8877
|
+
fpRate: .87,
|
|
8878
|
+
n: 23,
|
|
8664
8879
|
detectorRevision: 1,
|
|
8665
|
-
ciLow: .
|
|
8666
|
-
ciHigh:
|
|
8880
|
+
ciLow: .6787,
|
|
8881
|
+
ciHigh: .9546
|
|
8667
8882
|
},
|
|
8668
8883
|
"QA-CS-110": {
|
|
8669
|
-
fpRate:
|
|
8670
|
-
n:
|
|
8884
|
+
fpRate: .769,
|
|
8885
|
+
n: 26,
|
|
8671
8886
|
detectorRevision: 1,
|
|
8672
|
-
ciLow: .
|
|
8673
|
-
ciHigh:
|
|
8887
|
+
ciLow: .5795,
|
|
8888
|
+
ciHigh: .8897
|
|
8674
8889
|
},
|
|
8675
8890
|
"QA-CS-111": {
|
|
8676
8891
|
fpRate: 1,
|
|
@@ -8688,17 +8903,17 @@ const MEASURED_FP = {
|
|
|
8688
8903
|
},
|
|
8689
8904
|
"QA-JV-101": {
|
|
8690
8905
|
fpRate: 0,
|
|
8691
|
-
n:
|
|
8906
|
+
n: 23,
|
|
8692
8907
|
detectorRevision: 1,
|
|
8693
8908
|
ciLow: 0,
|
|
8694
|
-
ciHigh: .
|
|
8909
|
+
ciHigh: .1431
|
|
8695
8910
|
},
|
|
8696
8911
|
"QA-JV-102": {
|
|
8697
|
-
fpRate: .
|
|
8698
|
-
n:
|
|
8912
|
+
fpRate: .261,
|
|
8913
|
+
n: 23,
|
|
8699
8914
|
detectorRevision: 1,
|
|
8700
|
-
ciLow: .
|
|
8701
|
-
ciHigh: .
|
|
8915
|
+
ciLow: .1255,
|
|
8916
|
+
ciHigh: .4647
|
|
8702
8917
|
},
|
|
8703
8918
|
"QA-JV-103": {
|
|
8704
8919
|
fpRate: .259,
|
|
@@ -8722,25 +8937,25 @@ const MEASURED_FP = {
|
|
|
8722
8937
|
ciHigh: .301
|
|
8723
8938
|
},
|
|
8724
8939
|
"QA-JV-108": {
|
|
8725
|
-
fpRate:
|
|
8726
|
-
n:
|
|
8940
|
+
fpRate: .87,
|
|
8941
|
+
n: 23,
|
|
8727
8942
|
detectorRevision: 1,
|
|
8728
|
-
ciLow: .
|
|
8729
|
-
ciHigh:
|
|
8943
|
+
ciLow: .6787,
|
|
8944
|
+
ciHigh: .9546
|
|
8730
8945
|
},
|
|
8731
8946
|
"QA-JV-109": {
|
|
8732
8947
|
fpRate: 0,
|
|
8733
|
-
n:
|
|
8948
|
+
n: 18,
|
|
8734
8949
|
detectorRevision: 1,
|
|
8735
8950
|
ciLow: 0,
|
|
8736
|
-
ciHigh: .
|
|
8951
|
+
ciHigh: .1759
|
|
8737
8952
|
},
|
|
8738
8953
|
"QA-JV-110": {
|
|
8739
|
-
fpRate:
|
|
8740
|
-
n:
|
|
8954
|
+
fpRate: .833,
|
|
8955
|
+
n: 24,
|
|
8741
8956
|
detectorRevision: 1,
|
|
8742
|
-
ciLow: .
|
|
8743
|
-
ciHigh:
|
|
8957
|
+
ciLow: .6415,
|
|
8958
|
+
ciHigh: .9332
|
|
8744
8959
|
},
|
|
8745
8960
|
"QA-JV-111": {
|
|
8746
8961
|
fpRate: 1,
|
|
@@ -8764,11 +8979,11 @@ const MEASURED_FP = {
|
|
|
8764
8979
|
ciHigh: .4042
|
|
8765
8980
|
},
|
|
8766
8981
|
"QA-PW-004": {
|
|
8767
|
-
fpRate: .
|
|
8768
|
-
n:
|
|
8982
|
+
fpRate: .429,
|
|
8983
|
+
n: 14,
|
|
8769
8984
|
detectorRevision: 1,
|
|
8770
|
-
ciLow: .
|
|
8771
|
-
ciHigh: .
|
|
8985
|
+
ciLow: .2138,
|
|
8986
|
+
ciHigh: .6741
|
|
8772
8987
|
},
|
|
8773
8988
|
"QA-PW-005": {
|
|
8774
8989
|
fpRate: 1,
|
|
@@ -8785,11 +9000,11 @@ const MEASURED_FP = {
|
|
|
8785
9000
|
ciHigh: .1611
|
|
8786
9001
|
},
|
|
8787
9002
|
"QA-PW-103": {
|
|
8788
|
-
fpRate:
|
|
8789
|
-
n:
|
|
9003
|
+
fpRate: .29,
|
|
9004
|
+
n: 69,
|
|
8790
9005
|
detectorRevision: 1,
|
|
8791
|
-
ciLow: .
|
|
8792
|
-
ciHigh:
|
|
9006
|
+
ciLow: .1962,
|
|
9007
|
+
ciHigh: .4057
|
|
8793
9008
|
},
|
|
8794
9009
|
"QA-PW-104": {
|
|
8795
9010
|
fpRate: 0,
|
|
@@ -8806,11 +9021,11 @@ const MEASURED_FP = {
|
|
|
8806
9021
|
ciHigh: 1
|
|
8807
9022
|
},
|
|
8808
9023
|
"QA-PW-107": {
|
|
8809
|
-
fpRate:
|
|
8810
|
-
n:
|
|
9024
|
+
fpRate: .952,
|
|
9025
|
+
n: 21,
|
|
8811
9026
|
detectorRevision: 1,
|
|
8812
|
-
ciLow: .
|
|
8813
|
-
ciHigh:
|
|
9027
|
+
ciLow: .7733,
|
|
9028
|
+
ciHigh: .9915
|
|
8814
9029
|
},
|
|
8815
9030
|
"QA-PW-108": {
|
|
8816
9031
|
fpRate: 1,
|
|
@@ -8849,10 +9064,10 @@ const MEASURED_FP = {
|
|
|
8849
9064
|
},
|
|
8850
9065
|
"QA-PW-117": {
|
|
8851
9066
|
fpRate: 0,
|
|
8852
|
-
n:
|
|
9067
|
+
n: 24,
|
|
8853
9068
|
detectorRevision: 1,
|
|
8854
9069
|
ciLow: 0,
|
|
8855
|
-
ciHigh: .
|
|
9070
|
+
ciHigh: .138
|
|
8856
9071
|
},
|
|
8857
9072
|
"QA-PW-118": {
|
|
8858
9073
|
fpRate: 1,
|
|
@@ -8883,11 +9098,11 @@ const MEASURED_FP = {
|
|
|
8883
9098
|
ciHigh: .2425
|
|
8884
9099
|
},
|
|
8885
9100
|
"QA-PW-122": {
|
|
8886
|
-
fpRate: .
|
|
8887
|
-
n:
|
|
9101
|
+
fpRate: .063,
|
|
9102
|
+
n: 80,
|
|
8888
9103
|
detectorRevision: 1,
|
|
8889
|
-
ciLow: .
|
|
8890
|
-
ciHigh: .
|
|
9104
|
+
ciLow: .027,
|
|
9105
|
+
ciHigh: .1381
|
|
8891
9106
|
},
|
|
8892
9107
|
"QA-PW-123": {
|
|
8893
9108
|
fpRate: .455,
|
|
@@ -8904,11 +9119,11 @@ const MEASURED_FP = {
|
|
|
8904
9119
|
ciHigh: .2775
|
|
8905
9120
|
},
|
|
8906
9121
|
"QA-PW-141": {
|
|
8907
|
-
fpRate: .
|
|
8908
|
-
n:
|
|
9122
|
+
fpRate: .091,
|
|
9123
|
+
n: 33,
|
|
8909
9124
|
detectorRevision: 1,
|
|
8910
|
-
ciLow: .
|
|
8911
|
-
ciHigh: .
|
|
9125
|
+
ciLow: .0314,
|
|
9126
|
+
ciHigh: .2357
|
|
8912
9127
|
},
|
|
8913
9128
|
"QA-PW-142": {
|
|
8914
9129
|
fpRate: .182,
|
|
@@ -8918,25 +9133,25 @@ const MEASURED_FP = {
|
|
|
8918
9133
|
ciHigh: .477
|
|
8919
9134
|
},
|
|
8920
9135
|
"QA-PW-143": {
|
|
8921
|
-
fpRate: .
|
|
8922
|
-
n:
|
|
9136
|
+
fpRate: .063,
|
|
9137
|
+
n: 80,
|
|
8923
9138
|
detectorRevision: 1,
|
|
8924
|
-
ciLow: .
|
|
8925
|
-
ciHigh: .
|
|
9139
|
+
ciLow: .027,
|
|
9140
|
+
ciHigh: .1381
|
|
8926
9141
|
},
|
|
8927
9142
|
"QA-PW-144": {
|
|
8928
|
-
fpRate: .
|
|
8929
|
-
n:
|
|
9143
|
+
fpRate: .143,
|
|
9144
|
+
n: 21,
|
|
8930
9145
|
detectorRevision: 1,
|
|
8931
|
-
ciLow: .
|
|
8932
|
-
ciHigh: .
|
|
9146
|
+
ciLow: .0498,
|
|
9147
|
+
ciHigh: .3464
|
|
8933
9148
|
},
|
|
8934
9149
|
"QA-PW-145": {
|
|
8935
|
-
fpRate:
|
|
8936
|
-
n:
|
|
9150
|
+
fpRate: .5,
|
|
9151
|
+
n: 40,
|
|
8937
9152
|
detectorRevision: 1,
|
|
8938
|
-
ciLow: .
|
|
8939
|
-
ciHigh:
|
|
9153
|
+
ciLow: .352,
|
|
9154
|
+
ciHigh: .648
|
|
8940
9155
|
},
|
|
8941
9156
|
"QA-PW-146": {
|
|
8942
9157
|
fpRate: .118,
|
|
@@ -8960,25 +9175,25 @@ const MEASURED_FP = {
|
|
|
8960
9175
|
ciHigh: .2099
|
|
8961
9176
|
},
|
|
8962
9177
|
"QA-PY-003": {
|
|
8963
|
-
fpRate: .
|
|
8964
|
-
n:
|
|
9178
|
+
fpRate: .467,
|
|
9179
|
+
n: 30,
|
|
8965
9180
|
detectorRevision: 3,
|
|
8966
|
-
ciLow: .
|
|
8967
|
-
ciHigh: .
|
|
9181
|
+
ciLow: .3023,
|
|
9182
|
+
ciHigh: .6386
|
|
8968
9183
|
},
|
|
8969
9184
|
"QA-PY-004": {
|
|
8970
|
-
fpRate: .
|
|
8971
|
-
n:
|
|
9185
|
+
fpRate: .533,
|
|
9186
|
+
n: 30,
|
|
8972
9187
|
detectorRevision: 3,
|
|
8973
|
-
ciLow: .
|
|
8974
|
-
ciHigh: .
|
|
9188
|
+
ciLow: .3614,
|
|
9189
|
+
ciHigh: .6977
|
|
8975
9190
|
},
|
|
8976
9191
|
"QA-PY-005": {
|
|
8977
|
-
fpRate: .
|
|
8978
|
-
n:
|
|
9192
|
+
fpRate: .13,
|
|
9193
|
+
n: 23,
|
|
8979
9194
|
detectorRevision: 1,
|
|
8980
|
-
ciLow: .
|
|
8981
|
-
ciHigh: .
|
|
9195
|
+
ciLow: .0454,
|
|
9196
|
+
ciHigh: .3213
|
|
8982
9197
|
},
|
|
8983
9198
|
"QA-PY-006": {
|
|
8984
9199
|
fpRate: 1,
|
|
@@ -9023,39 +9238,46 @@ const MEASURED_FP = {
|
|
|
9023
9238
|
ciHigh: .4042
|
|
9024
9239
|
},
|
|
9025
9240
|
"QA-PY-012": {
|
|
9026
|
-
fpRate: .
|
|
9027
|
-
n:
|
|
9241
|
+
fpRate: .4,
|
|
9242
|
+
n: 30,
|
|
9028
9243
|
detectorRevision: 1,
|
|
9029
|
-
ciLow: .
|
|
9030
|
-
ciHigh: .
|
|
9244
|
+
ciLow: .2459,
|
|
9245
|
+
ciHigh: .5768
|
|
9031
9246
|
},
|
|
9032
9247
|
"QA-PY-103": {
|
|
9033
|
-
fpRate: .
|
|
9034
|
-
n:
|
|
9248
|
+
fpRate: .08,
|
|
9249
|
+
n: 25,
|
|
9035
9250
|
detectorRevision: 1,
|
|
9036
|
-
ciLow: .
|
|
9037
|
-
ciHigh: .
|
|
9251
|
+
ciLow: .0222,
|
|
9252
|
+
ciHigh: .2497
|
|
9253
|
+
},
|
|
9254
|
+
"QA-PY-105": {
|
|
9255
|
+
fpRate: 0,
|
|
9256
|
+
n: 12,
|
|
9257
|
+
detectorRevision: 2,
|
|
9258
|
+
ciLow: 0,
|
|
9259
|
+
ciHigh: .2425
|
|
9038
9260
|
},
|
|
9039
9261
|
"QA-TEST-001": {
|
|
9040
|
-
fpRate: .
|
|
9041
|
-
n:
|
|
9262
|
+
fpRate: .6,
|
|
9263
|
+
n: 20,
|
|
9042
9264
|
detectorRevision: 1,
|
|
9043
|
-
ciLow: .
|
|
9044
|
-
ciHigh: .
|
|
9265
|
+
ciLow: .3866,
|
|
9266
|
+
ciHigh: .7812
|
|
9045
9267
|
},
|
|
9046
9268
|
"QA-TEST-002": {
|
|
9047
|
-
fpRate: .
|
|
9048
|
-
n:
|
|
9269
|
+
fpRate: .619,
|
|
9270
|
+
n: 21,
|
|
9049
9271
|
detectorRevision: 1,
|
|
9050
|
-
ciLow: .
|
|
9051
|
-
ciHigh: .
|
|
9272
|
+
ciLow: .4088,
|
|
9273
|
+
ciHigh: .7925
|
|
9052
9274
|
},
|
|
9053
9275
|
"QA-TEST-003": {
|
|
9054
|
-
fpRate: .
|
|
9055
|
-
n:
|
|
9276
|
+
fpRate: .218,
|
|
9277
|
+
n: 78,
|
|
9056
9278
|
detectorRevision: 1,
|
|
9057
|
-
ciLow: .
|
|
9058
|
-
ciHigh: .
|
|
9279
|
+
ciLow: .1408,
|
|
9280
|
+
ciHigh: .3216
|
|
9059
9281
|
},
|
|
9060
9282
|
"QA-TEST-004": {
|
|
9061
9283
|
fpRate: .3,
|
|
@@ -9072,11 +9294,11 @@ const MEASURED_FP = {
|
|
|
9072
9294
|
ciHigh: .6462
|
|
9073
9295
|
},
|
|
9074
9296
|
"QA-TEST-010": {
|
|
9075
|
-
fpRate: .
|
|
9076
|
-
n:
|
|
9297
|
+
fpRate: .581,
|
|
9298
|
+
n: 31,
|
|
9077
9299
|
detectorRevision: 1,
|
|
9078
|
-
ciLow: .
|
|
9079
|
-
ciHigh: .
|
|
9300
|
+
ciLow: .4077,
|
|
9301
|
+
ciHigh: .7358
|
|
9080
9302
|
},
|
|
9081
9303
|
"QA-TQUAL-001": {
|
|
9082
9304
|
fpRate: 1,
|
|
@@ -9086,11 +9308,11 @@ const MEASURED_FP = {
|
|
|
9086
9308
|
ciHigh: 1
|
|
9087
9309
|
},
|
|
9088
9310
|
"QA-TQUAL-002": {
|
|
9089
|
-
fpRate: .
|
|
9090
|
-
n:
|
|
9311
|
+
fpRate: .531,
|
|
9312
|
+
n: 32,
|
|
9091
9313
|
detectorRevision: 1,
|
|
9092
|
-
ciLow: .
|
|
9093
|
-
ciHigh: .
|
|
9314
|
+
ciLow: .3645,
|
|
9315
|
+
ciHigh: .6913
|
|
9094
9316
|
},
|
|
9095
9317
|
"QA-TQUAL-009": {
|
|
9096
9318
|
fpRate: .786,
|
|
@@ -9100,11 +9322,11 @@ const MEASURED_FP = {
|
|
|
9100
9322
|
ciHigh: .9243
|
|
9101
9323
|
},
|
|
9102
9324
|
"QA-TQUAL-011": {
|
|
9103
|
-
fpRate: .
|
|
9104
|
-
n:
|
|
9325
|
+
fpRate: .24,
|
|
9326
|
+
n: 25,
|
|
9105
9327
|
detectorRevision: 1,
|
|
9106
|
-
ciLow: .
|
|
9107
|
-
ciHigh: .
|
|
9328
|
+
ciLow: .115,
|
|
9329
|
+
ciHigh: .4343
|
|
9108
9330
|
}
|
|
9109
9331
|
};
|
|
9110
9332
|
//#endregion
|
|
@@ -11185,6 +11407,7 @@ async function runScan$1(args, hooks = {}) {
|
|
|
11185
11407
|
});
|
|
11186
11408
|
const tierByRuleId = tiers;
|
|
11187
11409
|
const pluginsLoaded = pluginMeta;
|
|
11410
|
+
const REVISION_BY_RULE_ID = new Map(activeRules.map((r) => [r.id, r.detectorRevision ?? MEASURED_FP[r.id]?.detectorRevision ?? 1]));
|
|
11188
11411
|
const cache = args.cache ? createScanCache(workspace.root) : disabledScanCache;
|
|
11189
11412
|
const rulesDigest = computeRulesDigest(activeRules);
|
|
11190
11413
|
for (const perr of pluginErrors) findings.push({
|
|
@@ -11397,6 +11620,10 @@ async function runScan$1(args, hooks = {}) {
|
|
|
11397
11620
|
f.measuredFpN = m.n;
|
|
11398
11621
|
}
|
|
11399
11622
|
}
|
|
11623
|
+
for (const f of findings) {
|
|
11624
|
+
const rev = REVISION_BY_RULE_ID.get(f.ruleId);
|
|
11625
|
+
if (rev !== void 0) f.detectorRevision = rev;
|
|
11626
|
+
}
|
|
11400
11627
|
enforceTierPolicy(findings, tierByRuleId);
|
|
11401
11628
|
for (const f of findings) f.fixGroupId = f.ruleId;
|
|
11402
11629
|
const runtimeReportPath = discoverRuntimeReport$1(scanRoot.root);
|
|
@@ -11454,6 +11681,103 @@ async function runScan$1(args, hooks = {}) {
|
|
|
11454
11681
|
await releaseTreeSitterResources();
|
|
11455
11682
|
return result;
|
|
11456
11683
|
}
|
|
11684
|
+
/**
|
|
11685
|
+
* Fields that participate in the verification digest: identity (ruleId,
|
|
11686
|
+
* detectorRevision, file, line, column) + evidence (severity,
|
|
11687
|
+
* evidenceLevel, trustLevel, confidence, findingType) + lifecycle-adjacent
|
|
11688
|
+
* metadata (fixGroupId). PRESENTATION (message/why/fix) is excluded by
|
|
11689
|
+
* design: rewording a detector's prose must not change the digest of a
|
|
11690
|
+
* scan whose semantics are identical.
|
|
11691
|
+
*/
|
|
11692
|
+
function digestView(f) {
|
|
11693
|
+
return {
|
|
11694
|
+
ruleId: f.ruleId,
|
|
11695
|
+
detectorRevision: f.detectorRevision ?? null,
|
|
11696
|
+
file: f.file,
|
|
11697
|
+
line: f.line,
|
|
11698
|
+
column: f.column,
|
|
11699
|
+
severity: f.severity,
|
|
11700
|
+
evidenceLevel: f.evidenceLevel ?? null,
|
|
11701
|
+
trustLevel: f.trustLevel ?? null,
|
|
11702
|
+
confidence: f.confidence,
|
|
11703
|
+
findingType: f.findingType,
|
|
11704
|
+
fixGroupId: f.fixGroupId ?? null
|
|
11705
|
+
};
|
|
11706
|
+
}
|
|
11707
|
+
/**
|
|
11708
|
+
* Deterministic canonical serialization: findings in ScanResult order
|
|
11709
|
+
* (already compareFindings-sorted), stable key insertion order, no
|
|
11710
|
+
* indentation. JSON.stringify of plain objects with fixed key order is
|
|
11711
|
+
* deterministic across runs and platforms. `durationMs` is EXCLUDED —
|
|
11712
|
+
* it is wall-clock, not semantics; including it would make two runs of
|
|
11713
|
+
* the same repo produce different digests.
|
|
11714
|
+
*/
|
|
11715
|
+
function canonicalScanJson(result) {
|
|
11716
|
+
return JSON.stringify({
|
|
11717
|
+
score: result.score,
|
|
11718
|
+
partial: result.partial,
|
|
11719
|
+
analysisStatus: {
|
|
11720
|
+
discovery: result.analysisStatus.discovery,
|
|
11721
|
+
rules: result.analysisStatus.rules,
|
|
11722
|
+
skippedFiles: result.analysisStatus.skippedFiles,
|
|
11723
|
+
rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
|
|
11724
|
+
truncationReasons: result.analysisStatus.truncationReasons ?? []
|
|
11725
|
+
},
|
|
11726
|
+
findings: result.findings.map(digestView)
|
|
11727
|
+
});
|
|
11728
|
+
}
|
|
11729
|
+
/** Advisory when the DERIVED evidence level is E0 (same rule as scoring). */
|
|
11730
|
+
function isAdvisory(f) {
|
|
11731
|
+
return (f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence)) === "E0";
|
|
11732
|
+
}
|
|
11733
|
+
function levelFor(f) {
|
|
11734
|
+
if (f.severity === "error") return "failure";
|
|
11735
|
+
if (f.severity === "warning") return "warning";
|
|
11736
|
+
return "notice";
|
|
11737
|
+
}
|
|
11738
|
+
function countBy(findings, predicate) {
|
|
11739
|
+
return findings.filter(predicate).length;
|
|
11740
|
+
}
|
|
11741
|
+
/**
|
|
11742
|
+
* Project a canonical ScanResult into the machine contract. Pure:
|
|
11743
|
+
* same input → same output, always (§16 semantic integrity, §25.1).
|
|
11744
|
+
*/
|
|
11745
|
+
function buildMachineContract(result) {
|
|
11746
|
+
const digest = createHash("sha256").update(canonicalScanJson(result)).digest("hex");
|
|
11747
|
+
const annotations = result.findings.map((f) => ({
|
|
11748
|
+
path: f.file,
|
|
11749
|
+
start_line: f.line,
|
|
11750
|
+
annotation_level: levelFor(f),
|
|
11751
|
+
message: `${f.ruleId}: ${f.message}`,
|
|
11752
|
+
ruleId: f.ruleId,
|
|
11753
|
+
...f.detectorRevision !== void 0 ? { detectorRevision: f.detectorRevision } : {},
|
|
11754
|
+
advisory: isAdvisory(f)
|
|
11755
|
+
}));
|
|
11756
|
+
return {
|
|
11757
|
+
contractVersion: 1,
|
|
11758
|
+
summary: {
|
|
11759
|
+
digest: `sha256:${digest}`,
|
|
11760
|
+
findings: result.findings.length,
|
|
11761
|
+
score: result.score,
|
|
11762
|
+
errors: countBy(result.findings, (f) => f.severity === "error"),
|
|
11763
|
+
warnings: countBy(result.findings, (f) => f.severity === "warning"),
|
|
11764
|
+
infos: countBy(result.findings, (f) => f.severity === "info"),
|
|
11765
|
+
advisory: countBy(result.findings, isAdvisory)
|
|
11766
|
+
},
|
|
11767
|
+
annotations: annotations.slice(0, 50),
|
|
11768
|
+
annotationsTruncated: result.findings.length > 50,
|
|
11769
|
+
completeness: {
|
|
11770
|
+
partial: result.partial,
|
|
11771
|
+
discovery: result.analysisStatus.discovery,
|
|
11772
|
+
rules: result.analysisStatus.rules,
|
|
11773
|
+
skippedFiles: result.analysisStatus.skippedFiles,
|
|
11774
|
+
rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
|
|
11775
|
+
truncationReasons: result.analysisStatus.truncationReasons ?? [],
|
|
11776
|
+
frameworkDetectionUnknown: result.frameworkDetectionUnknown,
|
|
11777
|
+
durationMs: result.analysisStatus.durationMs
|
|
11778
|
+
}
|
|
11779
|
+
};
|
|
11780
|
+
}
|
|
11457
11781
|
//#endregion
|
|
11458
11782
|
//#region src/scorer/prioritize.ts
|
|
11459
11783
|
/**
|
|
@@ -12251,7 +12575,7 @@ function renderSarif(result, repoRootUri) {
|
|
|
12251
12575
|
tool: { driver: {
|
|
12252
12576
|
name: "Mjölnir",
|
|
12253
12577
|
informationUri: "https://github.com/Sergey-Bar/Mjolnir",
|
|
12254
|
-
version: "0.5.
|
|
12578
|
+
version: "0.5.7",
|
|
12255
12579
|
rules: [...rules.values()].map((r) => {
|
|
12256
12580
|
const meta = RULES.find((x) => x.id === r.id);
|
|
12257
12581
|
return {
|
|
@@ -12666,8 +12990,17 @@ function renderPrComment(result, options = {}) {
|
|
|
12666
12990
|
lines.push("");
|
|
12667
12991
|
lines.push(`_Advisory only — this comment never blocks merging. Generated by [Mjölnir](${options.repoUrl ?? "https://github.com/Sergey-Bar/Mjolnir"})._`);
|
|
12668
12992
|
if (usingDiff && diff.resolvedFindings.length > 0) {
|
|
12669
|
-
|
|
12670
|
-
|
|
12993
|
+
const verified = diff.resolvedFindings.filter((f) => f.resolution.status === "VERIFIED-RESOLVED");
|
|
12994
|
+
const inconclusive = diff.resolvedFindings.filter((f) => f.resolution.status === "INCONCLUSIVE");
|
|
12995
|
+
if (verified.length > 0) {
|
|
12996
|
+
lines.push("");
|
|
12997
|
+
lines.push(`✨ ${verified.length} pre-existing finding${verified.length === 1 ? "" : "s"} verified as fixed in this PR.`);
|
|
12998
|
+
}
|
|
12999
|
+
if (inconclusive.length > 0) {
|
|
13000
|
+
const causes = [...new Set(inconclusive.map((f) => f.resolution.cause ?? "unknown"))].join(", ");
|
|
13001
|
+
lines.push("");
|
|
13002
|
+
lines.push(`ℹ ${inconclusive.length} pre-existing finding${inconclusive.length === 1 ? "" : "s"} disappeared, but this scan can't confirm a fix (${causes}).`);
|
|
13003
|
+
}
|
|
12671
13004
|
}
|
|
12672
13005
|
return lines.join("\n");
|
|
12673
13006
|
}
|
|
@@ -14963,7 +15296,7 @@ function gitBuffer(root, args) {
|
|
|
14963
15296
|
}
|
|
14964
15297
|
}
|
|
14965
15298
|
/** Fingerprint a finding for cross-commit matching (line numbers shift). */
|
|
14966
|
-
function fingerprint$
|
|
15299
|
+
function fingerprint$2(f) {
|
|
14967
15300
|
return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
|
|
14968
15301
|
}
|
|
14969
15302
|
async function computeImpact(root, options) {
|
|
@@ -15070,9 +15403,9 @@ async function computeImpact(root, options) {
|
|
|
15070
15403
|
}
|
|
15071
15404
|
const headResult = await options.runScan(root);
|
|
15072
15405
|
const baseSet = /* @__PURE__ */ new Map();
|
|
15073
|
-
for (const f of baseResult.findings) baseSet.set(fingerprint$
|
|
15406
|
+
for (const f of baseResult.findings) baseSet.set(fingerprint$2(f), f);
|
|
15074
15407
|
const headSet = /* @__PURE__ */ new Map();
|
|
15075
|
-
for (const f of headResult.findings) headSet.set(fingerprint$
|
|
15408
|
+
for (const f of headResult.findings) headSet.set(fingerprint$2(f), f);
|
|
15076
15409
|
const resolved = [];
|
|
15077
15410
|
for (const [key, f] of baseSet) if (!headSet.has(key)) resolved.push({
|
|
15078
15411
|
ruleId: f.ruleId,
|
|
@@ -15130,6 +15463,79 @@ function renderImpact(report) {
|
|
|
15130
15463
|
return lines.join("\n");
|
|
15131
15464
|
}
|
|
15132
15465
|
//#endregion
|
|
15466
|
+
//#region src/engine/resolution.ts
|
|
15467
|
+
/** Correlation identity (v1-compatible): ruleId\0file\0message. */
|
|
15468
|
+
function fingerprint$1(entry) {
|
|
15469
|
+
return `${entry.ruleId}\u0000${entry.file}\u0000${entry.message}`;
|
|
15470
|
+
}
|
|
15471
|
+
/**
|
|
15472
|
+
* The §15 ordered algorithm. Pure; first match wins.
|
|
15473
|
+
*/
|
|
15474
|
+
function resolve$1(input) {
|
|
15475
|
+
const { entry, baseline, current } = input;
|
|
15476
|
+
const fp = fingerprint$1(entry);
|
|
15477
|
+
const comparedAgainst = input.baselineCommit ?? baseline.commit ?? "unknown baseline";
|
|
15478
|
+
if (current.partial || current.analysisStatus.rules !== "complete") return {
|
|
15479
|
+
status: "INCONCLUSIVE",
|
|
15480
|
+
cause: "partial",
|
|
15481
|
+
comparedAgainst
|
|
15482
|
+
};
|
|
15483
|
+
if (input.crashedRuleIds?.has(entry.ruleId)) return {
|
|
15484
|
+
status: "INCONCLUSIVE",
|
|
15485
|
+
cause: "crash",
|
|
15486
|
+
comparedAgainst
|
|
15487
|
+
};
|
|
15488
|
+
if (input.skippedFiles?.has(entry.file)) return {
|
|
15489
|
+
status: "INCONCLUSIVE",
|
|
15490
|
+
cause: "skipped",
|
|
15491
|
+
comparedAgainst
|
|
15492
|
+
};
|
|
15493
|
+
if (input.suppressed?.has(`${entry.ruleId}\u0000${entry.file}`)) return {
|
|
15494
|
+
status: "SUPPRESSED",
|
|
15495
|
+
comparedAgainst
|
|
15496
|
+
};
|
|
15497
|
+
if (input.excludedFiles?.has(entry.file)) return {
|
|
15498
|
+
status: "DISAPPEARED-NON-FIX",
|
|
15499
|
+
cause: "excluded",
|
|
15500
|
+
comparedAgainst
|
|
15501
|
+
};
|
|
15502
|
+
const entryRev = entry.detectorRevision;
|
|
15503
|
+
if (entryRev === void 0) return {
|
|
15504
|
+
status: "INCONCLUSIVE",
|
|
15505
|
+
cause: "legacy-baseline",
|
|
15506
|
+
comparedAgainst
|
|
15507
|
+
};
|
|
15508
|
+
const registryRev = input.registryRevisions.get(entry.ruleId);
|
|
15509
|
+
if (registryRev === void 0) return {
|
|
15510
|
+
status: "DISAPPEARED-NON-FIX",
|
|
15511
|
+
cause: "retired",
|
|
15512
|
+
comparedAgainst
|
|
15513
|
+
};
|
|
15514
|
+
if (registryRev !== entryRev) return {
|
|
15515
|
+
status: "INCONCLUSIVE",
|
|
15516
|
+
cause: "revision-changed",
|
|
15517
|
+
comparedAgainst
|
|
15518
|
+
};
|
|
15519
|
+
if (current.findings.some((f) => fingerprint$1(f) === fp)) return {
|
|
15520
|
+
status: "STILL-PRESENT",
|
|
15521
|
+
comparedAgainst
|
|
15522
|
+
};
|
|
15523
|
+
return {
|
|
15524
|
+
status: "VERIFIED-RESOLVED",
|
|
15525
|
+
comparedAgainst
|
|
15526
|
+
};
|
|
15527
|
+
}
|
|
15528
|
+
/** The §15 rendering law: "FIXED" only for VERIFIED-RESOLVED. */
|
|
15529
|
+
function renderResolution(r) {
|
|
15530
|
+
switch (r.status) {
|
|
15531
|
+
case "VERIFIED-RESOLVED": return "FIXED SINCE BASELINE (verified by a complete same-revision scan)";
|
|
15532
|
+
case "STILL-PRESENT": return "STILL PRESENT";
|
|
15533
|
+
case "SUPPRESSED": return "SUPPRESSED (active ignore entry)";
|
|
15534
|
+
case "INCONCLUSIVE": return `INCONCLUSIVE (${r.cause})`;
|
|
15535
|
+
case "DISAPPEARED-NON-FIX": return `DISAPPEARED — NOT A FIX (${r.cause})`;
|
|
15536
|
+
}
|
|
15537
|
+
}
|
|
15538
|
+
//#endregion
|
|
15133
15539
|
//#region src/commands/baseline.ts
|
|
15134
15540
|
/**
|
|
15135
15541
|
* `mjolnir baseline` / `mjolnir diff` — Sprint 6 Task 24
|
|
@@ -15154,6 +15560,14 @@ function renderImpact(report) {
|
|
|
15154
15560
|
const ui$7 = plainContext();
|
|
15155
15561
|
const DEFAULT_BASELINE_PATH = join(".mjolnir", "baseline.json");
|
|
15156
15562
|
/**
|
|
15563
|
+
* Registry-declared detector revisions (§17): a baseline entry whose
|
|
15564
|
+
* revision differs from today's registry is INCONCLUSIVE(revision-
|
|
15565
|
+
* changed), never resolved. Omitted declarations mean revision 1 (the
|
|
15566
|
+
* documented RuleMeta default for first-generation detectors); a
|
|
15567
|
+
* ruleId ABSENT from this map means the rule is retired.
|
|
15568
|
+
*/
|
|
15569
|
+
const REGISTRY_REVISIONS = new Map(RULES.map((r) => [r.id, r.detectorRevision ?? 1]));
|
|
15570
|
+
/**
|
|
15157
15571
|
* Correlation identity for before/after comparison (agent-handoff plan
|
|
15158
15572
|
* §5.2): ruleId + file + message, deliberately EXCLUDING `line` — a
|
|
15159
15573
|
* source edit that shifts a finding still correlates. file:line is an
|
|
@@ -15175,7 +15589,8 @@ function buildBaseline(result, commit) {
|
|
|
15175
15589
|
ruleId: f.ruleId,
|
|
15176
15590
|
file: f.file,
|
|
15177
15591
|
message: f.message,
|
|
15178
|
-
severity: f.severity
|
|
15592
|
+
severity: f.severity,
|
|
15593
|
+
...f.detectorRevision !== void 0 ? { detectorRevision: f.detectorRevision } : {}
|
|
15179
15594
|
}))
|
|
15180
15595
|
};
|
|
15181
15596
|
}
|
|
@@ -15245,7 +15660,18 @@ function diffAgainstBaseline(result, baseline) {
|
|
|
15245
15660
|
else newFindings.push(f);
|
|
15246
15661
|
}
|
|
15247
15662
|
const resolvedFindings = [];
|
|
15248
|
-
for (const [key, f] of baseSet) if (!headKeys.has(key))
|
|
15663
|
+
for (const [key, f] of baseSet) if (!headKeys.has(key)) {
|
|
15664
|
+
const resolution = resolve$1({
|
|
15665
|
+
entry: f,
|
|
15666
|
+
baseline,
|
|
15667
|
+
current: result,
|
|
15668
|
+
registryRevisions: REGISTRY_REVISIONS
|
|
15669
|
+
});
|
|
15670
|
+
resolvedFindings.push({
|
|
15671
|
+
...f,
|
|
15672
|
+
resolution
|
|
15673
|
+
});
|
|
15674
|
+
}
|
|
15249
15675
|
return {
|
|
15250
15676
|
hasBaseline: true,
|
|
15251
15677
|
baselineCapturedAt: baseline.capturedAt,
|
|
@@ -15285,8 +15711,17 @@ function renderBaselineDiff(diff) {
|
|
|
15285
15711
|
}
|
|
15286
15712
|
lines.push("");
|
|
15287
15713
|
if (diff.resolvedFindings.length > 0) {
|
|
15288
|
-
|
|
15289
|
-
|
|
15714
|
+
const verified = diff.resolvedFindings.filter((f) => f.resolution.status === "VERIFIED-RESOLVED");
|
|
15715
|
+
const unresolved = diff.resolvedFindings.filter((f) => f.resolution.status !== "VERIFIED-RESOLVED");
|
|
15716
|
+
if (verified.length > 0) {
|
|
15717
|
+
lines.push(`FIXED SINCE BASELINE (${verified.length}):`);
|
|
15718
|
+
for (const f of verified) lines.push(` ✓ ${f.ruleId} (${f.severity}) · ${f.file} — ${f.message}`);
|
|
15719
|
+
lines.push("");
|
|
15720
|
+
}
|
|
15721
|
+
if (unresolved.length > 0) {
|
|
15722
|
+
lines.push(`DISAPPEARED — NOT CLASSIFIED AS FIXED (${unresolved.length}):`);
|
|
15723
|
+
for (const f of unresolved) lines.push(` ${renderResolution(f.resolution)} · ${f.ruleId} (${f.severity}) · ${f.file} — ${f.message}`);
|
|
15724
|
+
}
|
|
15290
15725
|
}
|
|
15291
15726
|
return lines.join("\n");
|
|
15292
15727
|
}
|
|
@@ -16565,7 +17000,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
|
|
|
16565
17000
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
16566
17001
|
* `tests/version-consistency.spec.ts` locally.
|
|
16567
17002
|
*/
|
|
16568
|
-
const CLI_VERSION = "0.5.
|
|
17003
|
+
const CLI_VERSION = "0.5.7";
|
|
16569
17004
|
function parseArgs(argv, onError) {
|
|
16570
17005
|
const args = {
|
|
16571
17006
|
target: ".",
|
|
@@ -16992,7 +17427,10 @@ async function runScanCommand(argv, io = {
|
|
|
16992
17427
|
}
|
|
16993
17428
|
if (args.format === "sarif") io.out(renderSarif(result));
|
|
16994
17429
|
else if (args.format === "mermaid") io.out(renderMermaid(result));
|
|
16995
|
-
else if (args.json) io.out(JSON.stringify(
|
|
17430
|
+
else if (args.json) io.out(JSON.stringify({
|
|
17431
|
+
...result,
|
|
17432
|
+
contract: buildMachineContract(result)
|
|
17433
|
+
}, null, 2));
|
|
16996
17434
|
else {
|
|
16997
17435
|
const categories = args.categories;
|
|
16998
17436
|
const visible = categories && categories.length > 0 ? result.findings.filter((f) => categories.includes(f.category)) : result.findings;
|
|
@@ -17268,7 +17706,7 @@ async function runDiffCommand(argv, io = {
|
|
|
17268
17706
|
const statsPath = join(target, DEFAULT_STATS_PATH);
|
|
17269
17707
|
const stats = recordResolved(loadStats(statsPath), diff);
|
|
17270
17708
|
if (!saveStats(stats, statsPath)) io.err(" (warning: stats could not be written — read-only filesystem? counters not recorded)");
|
|
17271
|
-
if (diff.resolvedFindings.
|
|
17709
|
+
if (diff.resolvedFindings.some((f) => f.resolution.status === "VERIFIED-RESOLVED")) {
|
|
17272
17710
|
const milestone = recordMilestones(stats, ["first-debt-reduction"]);
|
|
17273
17711
|
if (milestone.newlyAnnounced.length > 0) {
|
|
17274
17712
|
if (saveStats(milestone.stats, statsPath)) for (const id of milestone.newlyAnnounced) io.out(MILESTONE_MESSAGES[id]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mjolnir-qa",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
4
4
|
"description": "Mjölnir — the Verification Trust Engine for QA. Audits test suites and CI pipelines, reports a worthiness score and prioritized findings.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"fp-audit:generate": "tsx scripts/generate-fp-audit-table.ts",
|
|
36
36
|
"docs:rules": "tsx scripts/generate-rule-docs.ts",
|
|
37
37
|
"docs:capability": "tsx scripts/generate-capability-matrix.ts",
|
|
38
|
+
"docs:machine-contract": "tsx scripts/generate-machine-contract-doc.ts",
|
|
38
39
|
"docs:hero": "tsx scripts/generate-readme-hero.ts",
|
|
39
40
|
"docs:formats": "tsx scripts/generate-site-formats.ts",
|
|
40
41
|
"docs:forensics-samples": "tsx scripts/generate-forensics-samples.ts",
|