mjolnir-qa 0.5.31 → 0.5.33
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 +12 -0
- package/README.md +25 -7
- package/dist/cli.d.mts +1 -1
- package/dist/cli.mjs +142 -10
- package/dist/mcp/stdio.mjs +142 -10
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,18 @@ Rule behavior changes (new rules, FP-rate changes against the corpus,
|
|
|
9
9
|
severity changes) are first-class entries here — rule IDs are immutable
|
|
10
10
|
once shipped, so this file is the record of what changed between versions.
|
|
11
11
|
|
|
12
|
+
## [0.5.33] — 2026-09-08
|
|
13
|
+
|
|
14
|
+
### Changes since 0.5.32
|
|
15
|
+
|
|
16
|
+
- P1: distribution — root action, moving v1 tag, action-based ci install (#63)
|
|
17
|
+
|
|
18
|
+
## [0.5.32] — 2026-09-08
|
|
19
|
+
|
|
20
|
+
### Changes since 0.5.31
|
|
21
|
+
|
|
22
|
+
- P0: repo state + truth drift — single measured count with drift lock (#62)
|
|
23
|
+
|
|
12
24
|
## [0.5.31] — 2026-09-08
|
|
13
25
|
|
|
14
26
|
### Changes since 0.5.30
|
package/README.md
CHANGED
|
@@ -188,14 +188,16 @@ not drown a first PR:
|
|
|
188
188
|
npx mjolnir-qa@latest --scope changed
|
|
189
189
|
```
|
|
190
190
|
|
|
191
|
-
`mjolnir ci install` writes that as a GitHub Actions workflow —
|
|
192
|
-
|
|
191
|
+
`mjolnir ci install` writes that as a GitHub Actions workflow — the
|
|
192
|
+
[action](https://github.com/Sergey-Bar/Mjolnir#readme) (Marketplace-grade,
|
|
193
|
+
pinned to the `v1` major tag) by default, or plain `npx` with
|
|
194
|
+
`--no-action`. Advisory by default, never blocking until you say so.
|
|
193
195
|
|
|
194
196
|
| Command | What it does |
|
|
195
197
|
| ----------------------------------- | ------------------------------------------------ |
|
|
196
198
|
| `mjolnir` | Full-repo scan + worthiness score |
|
|
197
199
|
| `mjolnir --scope changed` | Only what your branch introduced — the CI form |
|
|
198
|
-
| `mjolnir ci install` | Generate the advisory PR workflow
|
|
200
|
+
| `mjolnir ci install` | Generate the advisory PR workflow (action-based) |
|
|
199
201
|
| `mjolnir explain QA-CI-001` | What / why / fix + measured FP rate for one rule |
|
|
200
202
|
| `mjolnir why src/a.spec.ts:42` | Why this exact line was flagged — never a gate |
|
|
201
203
|
| `mjolnir forensics ./test-results/` | Runtime evidence from a real run |
|
|
@@ -236,12 +238,15 @@ of them.
|
|
|
236
238
|
|
|
237
239
|
Requires **Node.js ≥ 22.18**. Runs on Windows, macOS and Linux. Install
|
|
238
240
|
globally with `npm i -g mjolnir-qa` if you prefer it over `npx`.
|
|
241
|
+
(Why ≥ 22.18? The build toolchain sets the floor — tsdown targets it and
|
|
242
|
+
the release pipeline smoke-tests against it; the runtime dependencies
|
|
243
|
+
have no such requirement.)
|
|
239
244
|
|
|
240
245
|
---
|
|
241
246
|
|
|
242
247
|
## What Mjölnir finds
|
|
243
248
|
|
|
244
|
-
|
|
249
|
+
**<!-- census:total-rules -->99 rules<!-- /census:total-rules -->** in four families — **test hygiene**, **test quality**,
|
|
245
250
|
**Playwright**, **CI integrity** — over TypeScript/JavaScript, Python,
|
|
246
251
|
Java, C# and GitHub Actions YAML, covering Playwright in all four bindings
|
|
247
252
|
plus pytest, JUnit, TestNG, NUnit, xUnit, MSTest, Jest, Vitest and Mocha,
|
|
@@ -433,9 +438,9 @@ Rung by rung: [docs/TERMINOLOGY.md](docs/TERMINOLOGY.md).
|
|
|
433
438
|
|
|
434
439
|
### How much of this is measured
|
|
435
440
|
|
|
436
|
-
|
|
441
|
+
**<!-- census:measured-of-total -->78 of 99<!-- /census:measured-of-total --> rules carry a false-positive rate measured against real OSS code**
|
|
437
442
|
(≥ 10 hand-classified findings each — [docs/FP-AUDIT.md](docs/FP-AUDIT.md)).
|
|
438
|
-
The other 21 ship on the author's estimate and say so, per rule, in
|
|
443
|
+
The other <!-- census:unmeasured -->21<!-- /census:unmeasured --> ship on the author's estimate and say so, per rule, in
|
|
439
444
|
`mjolnir explain`; `mjolnir rules --unmeasured` lists them, and every scan
|
|
440
445
|
footer reports how many of the rules that actually _fired_ are measured.
|
|
441
446
|
|
|
@@ -538,6 +543,19 @@ One command generates the PR workflow — advisory by default:
|
|
|
538
543
|
mjolnir ci install
|
|
539
544
|
```
|
|
540
545
|
|
|
546
|
+
Prefer the Marketplace action over a generated workflow? It is one line:
|
|
547
|
+
|
|
548
|
+
```yaml
|
|
549
|
+
- uses: Sergey-Bar/Mjolnir@v1
|
|
550
|
+
with:
|
|
551
|
+
scope: changed
|
|
552
|
+
fail-on: error
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
Pin `@v1` to follow the major line or an exact tag (`@v0.5.32`) for a
|
|
556
|
+
reproducible gate — [docs/DISTRIBUTION-KIT.md](docs/DISTRIBUTION-KIT.md)
|
|
557
|
+
covers Marketplace, Smithery and the MCP registries.
|
|
558
|
+
|
|
541
559
|
Or wire it into GitHub Code Scanning natively via SARIF:
|
|
542
560
|
|
|
543
561
|
```yaml
|
|
@@ -658,7 +676,7 @@ artifacts.
|
|
|
658
676
|
product does what the requirement asked for.
|
|
659
677
|
- **A 100 is not proof of a good suite.** Whether your suite covers your
|
|
660
678
|
actual risk is a different question, and this tool does not answer it.
|
|
661
|
-
-
|
|
679
|
+
- **<!-- census:unmeasured-of-total -->21 of 99<!-- /census:unmeasured-of-total --> rules ship on an estimate**, not a measured rate — disclosed
|
|
662
680
|
per rule, not buried here.
|
|
663
681
|
- **E1 is not E2.** Heuristic findings are worth reading, not worth
|
|
664
682
|
applying blindly.
|
package/dist/cli.d.mts
CHANGED
|
@@ -728,7 +728,7 @@ declare const runScan: typeof runScan$1, buildUniversalRules: typeof buildUniver
|
|
|
728
728
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
729
729
|
* `tests/version-consistency.spec.ts` locally.
|
|
730
730
|
*/
|
|
731
|
-
declare const CLI_VERSION = "0.5.
|
|
731
|
+
declare const CLI_VERSION = "0.5.33";
|
|
732
732
|
/** A usage-error detail: the offending token, when one exists. */
|
|
733
733
|
interface UsageErrorDetail {
|
|
734
734
|
/** The unknown flag or rejected value (e.g. `--nope`, `loud`). */
|
package/dist/cli.mjs
CHANGED
|
@@ -13290,7 +13290,7 @@ function renderSarif(result, repoRootUri) {
|
|
|
13290
13290
|
tool: { driver: {
|
|
13291
13291
|
name: "Mjölnir",
|
|
13292
13292
|
informationUri: "https://github.com/Sergey-Bar/Mjolnir",
|
|
13293
|
-
version: "0.5.
|
|
13293
|
+
version: "0.5.33",
|
|
13294
13294
|
rules: [...rules.values()].map((r) => {
|
|
13295
13295
|
const meta = RULES.find((x) => x.id === r.id);
|
|
13296
13296
|
return {
|
|
@@ -15741,7 +15741,7 @@ const SUMMARY_SCRIPT_V1 = [
|
|
|
15741
15741
|
* the script via indentBlock(…, 10) inside the `run: |` scalar, so the
|
|
15742
15742
|
* raw unindented substring never appears in a real v1 file. */
|
|
15743
15743
|
function isKnownTemplate(content) {
|
|
15744
|
-
return GATES.some((g) => content === TEMPLATE(g)) || content.includes(indentBlock(SUMMARY_SCRIPT_V1, 10));
|
|
15744
|
+
return GATES.some((g) => content === TEMPLATE(g)) || GATES.some((g) => content === ACTION_TEMPLATE(g)) || content.includes(indentBlock(SUMMARY_SCRIPT_V1, 10));
|
|
15745
15745
|
}
|
|
15746
15746
|
/** Indents an embedded script so it sits inside a YAML `run: |` block scalar. */
|
|
15747
15747
|
function indentBlock(text, spaces) {
|
|
@@ -15854,6 +15854,127 @@ const GATES = [
|
|
|
15854
15854
|
"error",
|
|
15855
15855
|
"warning"
|
|
15856
15856
|
];
|
|
15857
|
+
/**
|
|
15858
|
+
* The action-ref the action-based template pins (P1: distribution).
|
|
15859
|
+
* `v1` is the major moving tag release.yml's action-tags job maintains on
|
|
15860
|
+
* every stable release — Marketplace convention. The generated workflow
|
|
15861
|
+
* pins the major, never @latest: a new release must not change gate
|
|
15862
|
+
* semantics without a commit of the consumer's.
|
|
15863
|
+
*/
|
|
15864
|
+
const ACTION_REF = "Sergey-Bar/Mjolnir@v1";
|
|
15865
|
+
/**
|
|
15866
|
+
* The action-based workflow for one gate level (P1.3): the root
|
|
15867
|
+
* action.yml does checkout-independent scanning — setup-node, the scan
|
|
15868
|
+
* itself (writing mjolnir.json for the reporting steps), and the gate
|
|
15869
|
+
* via the action's `fail-on` input. The action owns the gate: with
|
|
15870
|
+
* fail-on error/warning its step exits 1 on findings at the gate, so the
|
|
15871
|
+
* workflow needs no separate gate step; partial scans never block (the
|
|
15872
|
+
* action downgrades exit 2 to a loud warning, per the frozen exit-code
|
|
15873
|
+
* contract). Reporting steps run `if: always()` exactly like the npx
|
|
15874
|
+
* template. Advisory mode adds an explicit advisory note as the last
|
|
15875
|
+
* step so the job summary says "never blocking" in plain words.
|
|
15876
|
+
*/
|
|
15877
|
+
const ACTION_TEMPLATE = (gate) => `name: Mjölnir
|
|
15878
|
+
|
|
15879
|
+
on:
|
|
15880
|
+
pull_request:
|
|
15881
|
+
|
|
15882
|
+
concurrency:
|
|
15883
|
+
group: mjolnir-\${{ github.ref }}
|
|
15884
|
+
cancel-in-progress: true
|
|
15885
|
+
|
|
15886
|
+
permissions:
|
|
15887
|
+
contents: read
|
|
15888
|
+
pull-requests: write
|
|
15889
|
+
|
|
15890
|
+
jobs:
|
|
15891
|
+
scan:
|
|
15892
|
+
runs-on: ubuntu-latest
|
|
15893
|
+
# A hung scan must not sit for the 6-hour default.
|
|
15894
|
+
timeout-minutes: 10
|
|
15895
|
+
steps:
|
|
15896
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
15897
|
+
with:
|
|
15898
|
+
fetch-depth: 0 # needed for --scope changed merge-base
|
|
15899
|
+
# The action scans with the published mjolnir-qa package, pinned to
|
|
15900
|
+
# the EXACT version that generated this workflow — never a floating
|
|
15901
|
+
# tag: a new release must not change your gate semantics with no
|
|
15902
|
+
# commit of yours (same rule as the npx template). The fail-on
|
|
15903
|
+
# input is the gate: it fails the job on findings at the gate and
|
|
15904
|
+
# never on a partial scan (exit 2 downgrades to a warning — the
|
|
15905
|
+
# frozen exit-code contract). Advisory mode reports, never blocks.
|
|
15906
|
+
- name: Mjölnir verification trust scan
|
|
15907
|
+
id: mjolnir
|
|
15908
|
+
if: always()
|
|
15909
|
+
continue-on-error: ${gate === "advisory" ? "true" : "false"}
|
|
15910
|
+
uses: ${ACTION_REF}
|
|
15911
|
+
with:
|
|
15912
|
+
scope: changed
|
|
15913
|
+
format: json
|
|
15914
|
+
fail-on: ${gate === "advisory" ? "none" : gate}
|
|
15915
|
+
version: ${CLI_VERSION}
|
|
15916
|
+
# Reporting, not gating: runs even when the scan/gate failed, from
|
|
15917
|
+
# the same mjolnir.json the action wrote.
|
|
15918
|
+
- name: Annotations + Job Summary
|
|
15919
|
+
if: always()
|
|
15920
|
+
continue-on-error: true
|
|
15921
|
+
run: npx --yes mjolnir-qa@${CLI_VERSION} summary mjolnir.json
|
|
15922
|
+
- name: Render PR comment
|
|
15923
|
+
if: always()
|
|
15924
|
+
continue-on-error: true
|
|
15925
|
+
run: npx --yes mjolnir-qa@${CLI_VERSION} pr-comment . > mjolnir-comment.md
|
|
15926
|
+
# Best-effort: on a pull_request event from a fork the GITHUB_TOKEN is
|
|
15927
|
+
# read-only and this step will 403 for every external contributor. The
|
|
15928
|
+
# Job Summary above is the fallback that always renders.
|
|
15929
|
+
# (pull_request_target would fix the token but is a code-execution
|
|
15930
|
+
# risk — deliberately NOT used.)
|
|
15931
|
+
- name: Post or update PR comment
|
|
15932
|
+
if: always()
|
|
15933
|
+
continue-on-error: true
|
|
15934
|
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
15935
|
+
with:
|
|
15936
|
+
script: |
|
|
15937
|
+
const fs = require('fs');
|
|
15938
|
+
let body = '';
|
|
15939
|
+
try { body = fs.readFileSync('mjolnir-comment.md', 'utf8'); } catch (e) {}
|
|
15940
|
+
if (!body.trim()) {
|
|
15941
|
+
console.log('mjolnir-comment.md is empty or missing — nothing to post.');
|
|
15942
|
+
return;
|
|
15943
|
+
}
|
|
15944
|
+
const marker = '<!-- mjolnir-pr-comment -->';
|
|
15945
|
+
const listed = await github.rest.issues.listComments({
|
|
15946
|
+
owner: context.repo.owner,
|
|
15947
|
+
repo: context.repo.repo,
|
|
15948
|
+
issue_number: context.issue.number,
|
|
15949
|
+
});
|
|
15950
|
+
const existing = listed.data.find((c) => c.body?.startsWith(marker));
|
|
15951
|
+
if (existing) {
|
|
15952
|
+
await github.rest.issues.updateComment({
|
|
15953
|
+
owner: context.repo.owner,
|
|
15954
|
+
repo: context.repo.repo,
|
|
15955
|
+
comment_id: existing.id,
|
|
15956
|
+
body,
|
|
15957
|
+
});
|
|
15958
|
+
} else {
|
|
15959
|
+
await github.rest.issues.createComment({
|
|
15960
|
+
owner: context.repo.owner,
|
|
15961
|
+
repo: context.repo.repo,
|
|
15962
|
+
issue_number: context.issue.number,
|
|
15963
|
+
body,
|
|
15964
|
+
});
|
|
15965
|
+
}
|
|
15966
|
+
${gate === "advisory" ? ` # Advisory mode: findings are reported in the Job Summary,
|
|
15967
|
+
# never blocking — the action ran with fail-on: none and
|
|
15968
|
+
# continue-on-error, so even a crashed scan cannot fail this job.
|
|
15969
|
+
- name: Gate (advisory)
|
|
15970
|
+
if: always()
|
|
15971
|
+
run: echo "Advisory mode — findings reported, never blocking."` : ` # Gate enforcement: the action step above IS the gate — fail-on
|
|
15972
|
+
# ${gate} exits 1 on findings at the gate and the step is
|
|
15973
|
+
# continue-on-error: false, so its failure fails the job. A partial
|
|
15974
|
+
# scan never blocks (the action downgrades exit 2 to a warning per
|
|
15975
|
+
# the frozen exit-code contract). The reporting steps above ran
|
|
15976
|
+
# first (if: always()), so a red gate never suppresses the report.`}
|
|
15977
|
+
`;
|
|
15857
15978
|
/** Multiset line diff — counts only, for the refusal message. */
|
|
15858
15979
|
function summarizeContentDiff(existing, incoming) {
|
|
15859
15980
|
const remaining = /* @__PURE__ */ new Map();
|
|
@@ -15873,16 +15994,17 @@ function ciInstall(root, gate = "advisory", options = {}) {
|
|
|
15873
15994
|
const target = join(wfDir, "mjolnir.yml");
|
|
15874
15995
|
if (!existsSync(wfDir)) mkdirSync(wfDir, { recursive: true });
|
|
15875
15996
|
const existed = existsSync(target);
|
|
15997
|
+
const template = options.action === false ? TEMPLATE(gate) : ACTION_TEMPLATE(gate);
|
|
15876
15998
|
if (existed) {
|
|
15877
15999
|
const current = readFileSync(target, "utf8");
|
|
15878
16000
|
if (!isKnownTemplate(current) && !(options.force ?? false)) return {
|
|
15879
16001
|
written: target,
|
|
15880
16002
|
existed,
|
|
15881
16003
|
refused: true,
|
|
15882
|
-
diffSummary: summarizeContentDiff(current,
|
|
16004
|
+
diffSummary: summarizeContentDiff(current, template)
|
|
15883
16005
|
};
|
|
15884
16006
|
}
|
|
15885
|
-
writeFileSync(target,
|
|
16007
|
+
writeFileSync(target, template);
|
|
15886
16008
|
return {
|
|
15887
16009
|
written: target,
|
|
15888
16010
|
existed,
|
|
@@ -16037,9 +16159,13 @@ function writeBadge(result, options) {
|
|
|
16037
16159
|
const HELP_ENTRIES = [
|
|
16038
16160
|
{
|
|
16039
16161
|
verb: "ci install",
|
|
16040
|
-
summary: "generate the PR workflow (scan + annotations + gate)",
|
|
16041
|
-
usage: "mjolnir ci install [--gate advisory|error|warning] [--force]",
|
|
16042
|
-
examples: [
|
|
16162
|
+
summary: "generate the PR workflow (action-based by default; scan + annotations + gate)",
|
|
16163
|
+
usage: "mjolnir ci install [--gate advisory|error|warning] [--no-action] [--force]",
|
|
16164
|
+
examples: [
|
|
16165
|
+
"mjolnir ci install",
|
|
16166
|
+
"mjolnir ci install --gate error",
|
|
16167
|
+
"mjolnir ci install --no-action --gate error --force"
|
|
16168
|
+
],
|
|
16043
16169
|
next: "mjolnir --scope changed"
|
|
16044
16170
|
},
|
|
16045
16171
|
{
|
|
@@ -18662,7 +18788,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
|
|
|
18662
18788
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
18663
18789
|
* `tests/version-consistency.spec.ts` locally.
|
|
18664
18790
|
*/
|
|
18665
|
-
const CLI_VERSION = "0.5.
|
|
18791
|
+
const CLI_VERSION = "0.5.33";
|
|
18666
18792
|
function parseArgs(argv, onError) {
|
|
18667
18793
|
const args = {
|
|
18668
18794
|
target: ".",
|
|
@@ -18835,9 +18961,11 @@ function runCiInstall(argv, io = {
|
|
|
18835
18961
|
let gateArg;
|
|
18836
18962
|
let gateSeen = false;
|
|
18837
18963
|
let force = false;
|
|
18964
|
+
let noAction = false;
|
|
18838
18965
|
const unknown = [];
|
|
18839
18966
|
for (const arg of argv) if (arg === "--gate") gateSeen = true;
|
|
18840
18967
|
else if (arg === "--force") force = true;
|
|
18968
|
+
else if (arg === "--no-action") noAction = true;
|
|
18841
18969
|
else if (gateSeen && gateArg === void 0 && !arg.startsWith("--")) gateArg = arg;
|
|
18842
18970
|
else unknown.push(arg);
|
|
18843
18971
|
if (gateSeen && gateArg === void 0) {
|
|
@@ -18856,7 +18984,10 @@ function runCiInstall(argv, io = {
|
|
|
18856
18984
|
io.err("Unknown gate level. Use: advisory | error | warning");
|
|
18857
18985
|
return 10;
|
|
18858
18986
|
}
|
|
18859
|
-
const result = ciInstall(resolve("."), gateArg ?? "advisory", {
|
|
18987
|
+
const result = ciInstall(resolve("."), gateArg ?? "advisory", {
|
|
18988
|
+
force,
|
|
18989
|
+
action: !noAction
|
|
18990
|
+
});
|
|
18860
18991
|
if (result.refused) {
|
|
18861
18992
|
io.err(`Refusing to overwrite the customized workflow at ${result.written}.`);
|
|
18862
18993
|
io.err("The file differs from the template Mjölnir would write:");
|
|
@@ -18865,8 +18996,9 @@ function runCiInstall(argv, io = {
|
|
|
18865
18996
|
return 10;
|
|
18866
18997
|
}
|
|
18867
18998
|
io.out(`${result.existed ? "Updated" : "Created"} ${result.written}`);
|
|
18868
|
-
io.out("Default mode: advisory — findings reported, never blocking.");
|
|
18999
|
+
io.out(noAction ? "Plain-npx template (—no-action). Default mode: advisory — findings reported, never blocking." : "Action-based template: uses Sergey-Bar/Mjolnir@v1 (major moving tag).");
|
|
18869
19000
|
io.out("Change with: mjolnir ci install --gate error|warning|advisory");
|
|
19001
|
+
if (!noAction) io.out("Prefer the plain-npx workflow? Re-run with --no-action.");
|
|
18870
19002
|
return 0;
|
|
18871
19003
|
}
|
|
18872
19004
|
/** Testable `suppressions` handler. */
|
package/dist/mcp/stdio.mjs
CHANGED
|
@@ -13289,7 +13289,7 @@ function renderSarif(result, repoRootUri) {
|
|
|
13289
13289
|
tool: { driver: {
|
|
13290
13290
|
name: "Mjölnir",
|
|
13291
13291
|
informationUri: "https://github.com/Sergey-Bar/Mjolnir",
|
|
13292
|
-
version: "0.5.
|
|
13292
|
+
version: "0.5.33",
|
|
13293
13293
|
rules: [...rules.values()].map((r) => {
|
|
13294
13294
|
const meta = RULES.find((x) => x.id === r.id);
|
|
13295
13295
|
return {
|
|
@@ -14838,7 +14838,7 @@ const SUMMARY_SCRIPT_V1 = [
|
|
|
14838
14838
|
* the script via indentBlock(…, 10) inside the `run: |` scalar, so the
|
|
14839
14839
|
* raw unindented substring never appears in a real v1 file. */
|
|
14840
14840
|
function isKnownTemplate(content) {
|
|
14841
|
-
return GATES.some((g) => content === TEMPLATE(g)) || content.includes(indentBlock(SUMMARY_SCRIPT_V1, 10));
|
|
14841
|
+
return GATES.some((g) => content === TEMPLATE(g)) || GATES.some((g) => content === ACTION_TEMPLATE(g)) || content.includes(indentBlock(SUMMARY_SCRIPT_V1, 10));
|
|
14842
14842
|
}
|
|
14843
14843
|
/** Indents an embedded script so it sits inside a YAML `run: |` block scalar. */
|
|
14844
14844
|
function indentBlock(text, spaces) {
|
|
@@ -14951,6 +14951,127 @@ const GATES = [
|
|
|
14951
14951
|
"error",
|
|
14952
14952
|
"warning"
|
|
14953
14953
|
];
|
|
14954
|
+
/**
|
|
14955
|
+
* The action-ref the action-based template pins (P1: distribution).
|
|
14956
|
+
* `v1` is the major moving tag release.yml's action-tags job maintains on
|
|
14957
|
+
* every stable release — Marketplace convention. The generated workflow
|
|
14958
|
+
* pins the major, never @latest: a new release must not change gate
|
|
14959
|
+
* semantics without a commit of the consumer's.
|
|
14960
|
+
*/
|
|
14961
|
+
const ACTION_REF = "Sergey-Bar/Mjolnir@v1";
|
|
14962
|
+
/**
|
|
14963
|
+
* The action-based workflow for one gate level (P1.3): the root
|
|
14964
|
+
* action.yml does checkout-independent scanning — setup-node, the scan
|
|
14965
|
+
* itself (writing mjolnir.json for the reporting steps), and the gate
|
|
14966
|
+
* via the action's `fail-on` input. The action owns the gate: with
|
|
14967
|
+
* fail-on error/warning its step exits 1 on findings at the gate, so the
|
|
14968
|
+
* workflow needs no separate gate step; partial scans never block (the
|
|
14969
|
+
* action downgrades exit 2 to a loud warning, per the frozen exit-code
|
|
14970
|
+
* contract). Reporting steps run `if: always()` exactly like the npx
|
|
14971
|
+
* template. Advisory mode adds an explicit advisory note as the last
|
|
14972
|
+
* step so the job summary says "never blocking" in plain words.
|
|
14973
|
+
*/
|
|
14974
|
+
const ACTION_TEMPLATE = (gate) => `name: Mjölnir
|
|
14975
|
+
|
|
14976
|
+
on:
|
|
14977
|
+
pull_request:
|
|
14978
|
+
|
|
14979
|
+
concurrency:
|
|
14980
|
+
group: mjolnir-\${{ github.ref }}
|
|
14981
|
+
cancel-in-progress: true
|
|
14982
|
+
|
|
14983
|
+
permissions:
|
|
14984
|
+
contents: read
|
|
14985
|
+
pull-requests: write
|
|
14986
|
+
|
|
14987
|
+
jobs:
|
|
14988
|
+
scan:
|
|
14989
|
+
runs-on: ubuntu-latest
|
|
14990
|
+
# A hung scan must not sit for the 6-hour default.
|
|
14991
|
+
timeout-minutes: 10
|
|
14992
|
+
steps:
|
|
14993
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
14994
|
+
with:
|
|
14995
|
+
fetch-depth: 0 # needed for --scope changed merge-base
|
|
14996
|
+
# The action scans with the published mjolnir-qa package, pinned to
|
|
14997
|
+
# the EXACT version that generated this workflow — never a floating
|
|
14998
|
+
# tag: a new release must not change your gate semantics with no
|
|
14999
|
+
# commit of yours (same rule as the npx template). The fail-on
|
|
15000
|
+
# input is the gate: it fails the job on findings at the gate and
|
|
15001
|
+
# never on a partial scan (exit 2 downgrades to a warning — the
|
|
15002
|
+
# frozen exit-code contract). Advisory mode reports, never blocks.
|
|
15003
|
+
- name: Mjölnir verification trust scan
|
|
15004
|
+
id: mjolnir
|
|
15005
|
+
if: always()
|
|
15006
|
+
continue-on-error: ${gate === "advisory" ? "true" : "false"}
|
|
15007
|
+
uses: ${ACTION_REF}
|
|
15008
|
+
with:
|
|
15009
|
+
scope: changed
|
|
15010
|
+
format: json
|
|
15011
|
+
fail-on: ${gate === "advisory" ? "none" : gate}
|
|
15012
|
+
version: ${CLI_VERSION}
|
|
15013
|
+
# Reporting, not gating: runs even when the scan/gate failed, from
|
|
15014
|
+
# the same mjolnir.json the action wrote.
|
|
15015
|
+
- name: Annotations + Job Summary
|
|
15016
|
+
if: always()
|
|
15017
|
+
continue-on-error: true
|
|
15018
|
+
run: npx --yes mjolnir-qa@${CLI_VERSION} summary mjolnir.json
|
|
15019
|
+
- name: Render PR comment
|
|
15020
|
+
if: always()
|
|
15021
|
+
continue-on-error: true
|
|
15022
|
+
run: npx --yes mjolnir-qa@${CLI_VERSION} pr-comment . > mjolnir-comment.md
|
|
15023
|
+
# Best-effort: on a pull_request event from a fork the GITHUB_TOKEN is
|
|
15024
|
+
# read-only and this step will 403 for every external contributor. The
|
|
15025
|
+
# Job Summary above is the fallback that always renders.
|
|
15026
|
+
# (pull_request_target would fix the token but is a code-execution
|
|
15027
|
+
# risk — deliberately NOT used.)
|
|
15028
|
+
- name: Post or update PR comment
|
|
15029
|
+
if: always()
|
|
15030
|
+
continue-on-error: true
|
|
15031
|
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
15032
|
+
with:
|
|
15033
|
+
script: |
|
|
15034
|
+
const fs = require('fs');
|
|
15035
|
+
let body = '';
|
|
15036
|
+
try { body = fs.readFileSync('mjolnir-comment.md', 'utf8'); } catch (e) {}
|
|
15037
|
+
if (!body.trim()) {
|
|
15038
|
+
console.log('mjolnir-comment.md is empty or missing — nothing to post.');
|
|
15039
|
+
return;
|
|
15040
|
+
}
|
|
15041
|
+
const marker = '<!-- mjolnir-pr-comment -->';
|
|
15042
|
+
const listed = await github.rest.issues.listComments({
|
|
15043
|
+
owner: context.repo.owner,
|
|
15044
|
+
repo: context.repo.repo,
|
|
15045
|
+
issue_number: context.issue.number,
|
|
15046
|
+
});
|
|
15047
|
+
const existing = listed.data.find((c) => c.body?.startsWith(marker));
|
|
15048
|
+
if (existing) {
|
|
15049
|
+
await github.rest.issues.updateComment({
|
|
15050
|
+
owner: context.repo.owner,
|
|
15051
|
+
repo: context.repo.repo,
|
|
15052
|
+
comment_id: existing.id,
|
|
15053
|
+
body,
|
|
15054
|
+
});
|
|
15055
|
+
} else {
|
|
15056
|
+
await github.rest.issues.createComment({
|
|
15057
|
+
owner: context.repo.owner,
|
|
15058
|
+
repo: context.repo.repo,
|
|
15059
|
+
issue_number: context.issue.number,
|
|
15060
|
+
body,
|
|
15061
|
+
});
|
|
15062
|
+
}
|
|
15063
|
+
${gate === "advisory" ? ` # Advisory mode: findings are reported in the Job Summary,
|
|
15064
|
+
# never blocking — the action ran with fail-on: none and
|
|
15065
|
+
# continue-on-error, so even a crashed scan cannot fail this job.
|
|
15066
|
+
- name: Gate (advisory)
|
|
15067
|
+
if: always()
|
|
15068
|
+
run: echo "Advisory mode — findings reported, never blocking."` : ` # Gate enforcement: the action step above IS the gate — fail-on
|
|
15069
|
+
# ${gate} exits 1 on findings at the gate and the step is
|
|
15070
|
+
# continue-on-error: false, so its failure fails the job. A partial
|
|
15071
|
+
# scan never blocks (the action downgrades exit 2 to a warning per
|
|
15072
|
+
# the frozen exit-code contract). The reporting steps above ran
|
|
15073
|
+
# first (if: always()), so a red gate never suppresses the report.`}
|
|
15074
|
+
`;
|
|
14954
15075
|
/** Multiset line diff — counts only, for the refusal message. */
|
|
14955
15076
|
function summarizeContentDiff(existing, incoming) {
|
|
14956
15077
|
const remaining = /* @__PURE__ */ new Map();
|
|
@@ -14970,16 +15091,17 @@ function ciInstall(root, gate = "advisory", options = {}) {
|
|
|
14970
15091
|
const target = join(wfDir, "mjolnir.yml");
|
|
14971
15092
|
if (!existsSync(wfDir)) mkdirSync(wfDir, { recursive: true });
|
|
14972
15093
|
const existed = existsSync(target);
|
|
15094
|
+
const template = options.action === false ? TEMPLATE(gate) : ACTION_TEMPLATE(gate);
|
|
14973
15095
|
if (existed) {
|
|
14974
15096
|
const current = readFileSync(target, "utf8");
|
|
14975
15097
|
if (!isKnownTemplate(current) && !(options.force ?? false)) return {
|
|
14976
15098
|
written: target,
|
|
14977
15099
|
existed,
|
|
14978
15100
|
refused: true,
|
|
14979
|
-
diffSummary: summarizeContentDiff(current,
|
|
15101
|
+
diffSummary: summarizeContentDiff(current, template)
|
|
14980
15102
|
};
|
|
14981
15103
|
}
|
|
14982
|
-
writeFileSync(target,
|
|
15104
|
+
writeFileSync(target, template);
|
|
14983
15105
|
return {
|
|
14984
15106
|
written: target,
|
|
14985
15107
|
existed,
|
|
@@ -15228,9 +15350,13 @@ function writeBadge(result, options) {
|
|
|
15228
15350
|
const HELP_ENTRIES = [
|
|
15229
15351
|
{
|
|
15230
15352
|
verb: "ci install",
|
|
15231
|
-
summary: "generate the PR workflow (scan + annotations + gate)",
|
|
15232
|
-
usage: "mjolnir ci install [--gate advisory|error|warning] [--force]",
|
|
15233
|
-
examples: [
|
|
15353
|
+
summary: "generate the PR workflow (action-based by default; scan + annotations + gate)",
|
|
15354
|
+
usage: "mjolnir ci install [--gate advisory|error|warning] [--no-action] [--force]",
|
|
15355
|
+
examples: [
|
|
15356
|
+
"mjolnir ci install",
|
|
15357
|
+
"mjolnir ci install --gate error",
|
|
15358
|
+
"mjolnir ci install --no-action --gate error --force"
|
|
15359
|
+
],
|
|
15234
15360
|
next: "mjolnir --scope changed"
|
|
15235
15361
|
},
|
|
15236
15362
|
{
|
|
@@ -18298,7 +18424,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
|
|
|
18298
18424
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
18299
18425
|
* `tests/version-consistency.spec.ts` locally.
|
|
18300
18426
|
*/
|
|
18301
|
-
const CLI_VERSION = "0.5.
|
|
18427
|
+
const CLI_VERSION = "0.5.33";
|
|
18302
18428
|
function parseArgs(argv, onError) {
|
|
18303
18429
|
const args = {
|
|
18304
18430
|
target: ".",
|
|
@@ -18471,9 +18597,11 @@ function runCiInstall(argv, io = {
|
|
|
18471
18597
|
let gateArg;
|
|
18472
18598
|
let gateSeen = false;
|
|
18473
18599
|
let force = false;
|
|
18600
|
+
let noAction = false;
|
|
18474
18601
|
const unknown = [];
|
|
18475
18602
|
for (const arg of argv) if (arg === "--gate") gateSeen = true;
|
|
18476
18603
|
else if (arg === "--force") force = true;
|
|
18604
|
+
else if (arg === "--no-action") noAction = true;
|
|
18477
18605
|
else if (gateSeen && gateArg === void 0 && !arg.startsWith("--")) gateArg = arg;
|
|
18478
18606
|
else unknown.push(arg);
|
|
18479
18607
|
if (gateSeen && gateArg === void 0) {
|
|
@@ -18492,7 +18620,10 @@ function runCiInstall(argv, io = {
|
|
|
18492
18620
|
io.err("Unknown gate level. Use: advisory | error | warning");
|
|
18493
18621
|
return 10;
|
|
18494
18622
|
}
|
|
18495
|
-
const result = ciInstall(resolve("."), gateArg ?? "advisory", {
|
|
18623
|
+
const result = ciInstall(resolve("."), gateArg ?? "advisory", {
|
|
18624
|
+
force,
|
|
18625
|
+
action: !noAction
|
|
18626
|
+
});
|
|
18496
18627
|
if (result.refused) {
|
|
18497
18628
|
io.err(`Refusing to overwrite the customized workflow at ${result.written}.`);
|
|
18498
18629
|
io.err("The file differs from the template Mjölnir would write:");
|
|
@@ -18501,8 +18632,9 @@ function runCiInstall(argv, io = {
|
|
|
18501
18632
|
return 10;
|
|
18502
18633
|
}
|
|
18503
18634
|
io.out(`${result.existed ? "Updated" : "Created"} ${result.written}`);
|
|
18504
|
-
io.out("Default mode: advisory — findings reported, never blocking.");
|
|
18635
|
+
io.out(noAction ? "Plain-npx template (—no-action). Default mode: advisory — findings reported, never blocking." : "Action-based template: uses Sergey-Bar/Mjolnir@v1 (major moving tag).");
|
|
18505
18636
|
io.out("Change with: mjolnir ci install --gate error|warning|advisory");
|
|
18637
|
+
if (!noAction) io.out("Prefer the plain-npx workflow? Re-run with --no-action.");
|
|
18506
18638
|
return 0;
|
|
18507
18639
|
}
|
|
18508
18640
|
/** Testable `suppressions` handler. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mjolnir-qa",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.33",
|
|
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": {
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"corpus:sample": "tsx scripts/corpus-sample.ts",
|
|
37
37
|
"fp-audit:generate": "tsx scripts/generate-fp-audit-table.ts",
|
|
38
38
|
"docs:rules": "tsx scripts/generate-rule-docs.ts",
|
|
39
|
+
"docs:counts": "tsx scripts/generate-counts.ts",
|
|
39
40
|
"docs:capability": "tsx scripts/generate-capability-matrix.ts",
|
|
40
41
|
"docs:machine-contract": "tsx scripts/generate-machine-contract-doc.ts",
|
|
41
42
|
"docs:hero": "tsx scripts/generate-readme-hero.ts",
|