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 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 — advisory by
192
- default, never blocking until you say so.
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
- **99 rules** in four families — **test hygiene**, **test quality**,
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
- **78 of 99 rules carry a false-positive rate measured against real OSS code**
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
- - **21 of 99 rules ship on an estimate**, not a measured rate — disclosed
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.31";
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.31",
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, TEMPLATE(gate))
16004
+ diffSummary: summarizeContentDiff(current, template)
15883
16005
  };
15884
16006
  }
15885
- writeFileSync(target, TEMPLATE(gate));
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: ["mjolnir ci install", "mjolnir ci install --gate error --force"],
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.31";
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", { force });
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. */
@@ -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.31",
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, TEMPLATE(gate))
15101
+ diffSummary: summarizeContentDiff(current, template)
14980
15102
  };
14981
15103
  }
14982
- writeFileSync(target, TEMPLATE(gate));
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: ["mjolnir ci install", "mjolnir ci install --gate error --force"],
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.31";
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", { force });
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.31",
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",