mjolnir-qa 0.5.32 → 0.5.34

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.34] — 2026-09-08
13
+
14
+ ### Changes since 0
15
+
16
+ - p2: structural anti-dilution — deduction-mass ceilings close the padding vector (plan 1788853205786 P2, decision 4) (#64)
17
+
18
+ ## [0.5.33] — 2026-09-08
19
+
20
+ ### Changes since 0.5.32
21
+
22
+ - P1: distribution — root action, moving v1 tag, action-based ci install (#63)
23
+
12
24
  ## [0.5.32] — 2026-09-08
13
25
 
14
26
  ### Changes since 0.5.31
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,6 +238,9 @@ 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
 
@@ -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
package/dist/cli.d.mts CHANGED
@@ -201,6 +201,15 @@ interface ScanResult {
201
201
  testDeclarationCount?: number;
202
202
  /** Raw deduction total before normalization (Phase 5 — transparency). */
203
203
  rawDeductions?: number;
204
+ /**
205
+ * The evidence-discounted deduction mass the P2 anti-dilution ceiling
206
+ * caps against (equals rawDeductions today — E0 charges 0, E1 halves;
207
+ * the ceiling input is deliberately the full discount surface).
208
+ * Additive within schemaVersion 1; present so consumers recompute the
209
+ * ceiling from docs/SCORING.md formula v2 without re-deriving
210
+ * evidence levels.
211
+ */
212
+ effectiveDeductions?: number;
204
213
  /** Number of findings suppressed by active config entries (suppression transparency). */
205
214
  suppressionCount?: number;
206
215
  /**
@@ -728,7 +737,7 @@ declare const runScan: typeof runScan$1, buildUniversalRules: typeof buildUniver
728
737
  * `scripts/sync-sarif-version.cjs` on release and guarded by
729
738
  * `tests/version-consistency.spec.ts` locally.
730
739
  */
731
- declare const CLI_VERSION = "0.5.32";
740
+ declare const CLI_VERSION = "0.5.34";
732
741
  /** A usage-error detail: the offending token, when one exists. */
733
742
  interface UsageErrorDetail {
734
743
  /** The unknown flag or rejected value (e.g. `--nope`, `loud`). */
package/dist/cli.mjs CHANGED
@@ -10074,6 +10074,58 @@ function computeDimensions(findings) {
10074
10074
  for (const dim of byCategory.values()) dim.score = Math.max(0, 100 - deductions.get(dim.category));
10075
10075
  return [...byCategory.values()].sort((a, b) => a.category.localeCompare(b.category));
10076
10076
  }
10077
+ /**
10078
+ * Deduction-mass ceilings (product-gap-remediation master plan P2,
10079
+ * plan 1788853205786 — structural anti-dilution, decision 4).
10080
+ *
10081
+ * The Goodhart vector the density formula permits: fixed deduction mass
10082
+ * ÷ padded denominator → score → 99. A repo with 80 warning-points of
10083
+ * real findings and 10,000 test declarations reads 99+ ("one minor
10084
+ * issue") because the RATE is tiny — the padding is free. Density stays
10085
+ * as the legitimate differentiator for small masses (a lone warning in
10086
+ * a 10k suite must still read ≥ 95), but it can no longer dilute a
10087
+ * large mass: each absolute deduction-mass band caps the score. Padding
10088
+ * cannot move a ceiling — the vector is structurally closed. 80
10089
+ * warning-pts read ≤ 75 in ANY suite size.
10090
+ *
10091
+ * The error-severity floor (95) is the ≥ 8 band seen from the other
10092
+ * side: 1 error ≥ 8 pts, so it is subsumed and left standing as a named
10093
+ * special case for prose continuity. The > 0 band is the honesty guard
10094
+ * (clampWithFindings) — subsumed as well.
10095
+ *
10096
+ * Calibration (P2.4): these boundaries were chosen to hold the three
10097
+ * known data points (self 100, golden 49 via suiteVoided, demo 67) and
10098
+ * are then FROZEN with a changelog entry. Never tune them to flatter a
10099
+ * repo (docs/SCORING.md Correction section is the precedent).
10100
+ */
10101
+ const DEDUCTION_MASS_CEILINGS = [
10102
+ {
10103
+ minMass: 160,
10104
+ ceiling: 65
10105
+ },
10106
+ {
10107
+ minMass: 80,
10108
+ ceiling: 75
10109
+ },
10110
+ {
10111
+ minMass: 40,
10112
+ ceiling: 85
10113
+ },
10114
+ {
10115
+ minMass: 8,
10116
+ ceiling: 95
10117
+ }
10118
+ ];
10119
+ /**
10120
+ * The score ceiling for a given evidence-discounted deduction mass.
10121
+ * 0 → no ceiling (the 100 honesty guard lives in clampWithFindings);
10122
+ * below the smallest band → no ceiling beyond the named guards.
10123
+ * Exported for the JSON contract (effectiveDeductions) and the tests.
10124
+ */
10125
+ function massCeiling(totalDeduction) {
10126
+ for (const band of DEDUCTION_MASS_CEILINGS) if (totalDeduction >= band.minMass) return band.ceiling;
10127
+ return null;
10128
+ }
10077
10129
  function computeTotal(dimensions, findings, exposure) {
10078
10130
  if (findings.length === 0) return 100;
10079
10131
  let totalDeduction = 0;
@@ -10086,6 +10138,8 @@ function computeTotal(dimensions, findings, exposure) {
10086
10138
  const rate = totalDeduction / (declarations + 1);
10087
10139
  score = clampWithFindings(100 - Math.min(100, rate * 5), totalDeduction);
10088
10140
  } else score = clampWithFindings(100 - totalDeduction, totalDeduction);
10141
+ const ceiling = massCeiling(totalDeduction);
10142
+ if (ceiling !== null && score > ceiling) score = ceiling;
10089
10143
  if (findings.some((f) => f.severity === "error" && deductionFor(f) > 0) && score > 95) score = 95;
10090
10144
  return suiteVoided ? Math.min(score, 49) : score;
10091
10145
  }
@@ -12322,6 +12376,7 @@ async function runScan$1(args, hooks = {}) {
12322
12376
  });
12323
12377
  const dimensions = computeDimensions(findings);
12324
12378
  const rawDeductions = findings.reduce((sum, f) => sum + deductionFor(f), 0);
12379
+ const effectiveDeductions = rawDeductions;
12325
12380
  const total = computeTotal(dimensions, findings, {
12326
12381
  testDeclarations: testDeclarationCount,
12327
12382
  testFileCount,
@@ -12346,6 +12401,7 @@ async function runScan$1(args, hooks = {}) {
12346
12401
  testFileCount,
12347
12402
  testDeclarationCount,
12348
12403
  rawDeductions,
12404
+ effectiveDeductions,
12349
12405
  suppressionCount,
12350
12406
  ...pluginsLoaded.length > 0 ? { plugins: pluginsLoaded } : {},
12351
12407
  agenticProfile: computeAgenticProfile(fileProvenance, findings),
@@ -12900,6 +12956,10 @@ function appendScoreSection(lines, result, p, width, ascii) {
12900
12956
  lines.push(` ${scoreGauge(result.score, p, gaugeWidth, ascii)}`);
12901
12957
  lines.push(` ${p.dim(headlineFor(state, result.findings.length))}`);
12902
12958
  if (result.rawDeductions !== void 0 && result.testDeclarationCount) lines.push(` ${p.dim(`(${result.rawDeductions} raw pts / ${result.testDeclarationCount} test declarations — normalized)`)}`);
12959
+ if (result.effectiveDeductions !== void 0 && result.score !== null) {
12960
+ const ceiling = massCeiling(result.effectiveDeductions);
12961
+ if (ceiling !== null && result.score <= ceiling && result.rawDeductions !== void 0) lines.push(` ${p.dim(`(capped: deduction mass ${result.effectiveDeductions} pts — absolute ceiling ${ceiling})`)}`);
12962
+ }
12903
12963
  if (result.suppressionCount && result.suppressionCount > 0) lines.push(` ${p.dim(`(${result.suppressionCount} finding(s) suppressed by config)`)}`);
12904
12964
  lines.push("");
12905
12965
  }
@@ -13290,7 +13350,7 @@ function renderSarif(result, repoRootUri) {
13290
13350
  tool: { driver: {
13291
13351
  name: "Mjölnir",
13292
13352
  informationUri: "https://github.com/Sergey-Bar/Mjolnir",
13293
- version: "0.5.32",
13353
+ version: "0.5.34",
13294
13354
  rules: [...rules.values()].map((r) => {
13295
13355
  const meta = RULES.find((x) => x.id === r.id);
13296
13356
  return {
@@ -15741,7 +15801,7 @@ const SUMMARY_SCRIPT_V1 = [
15741
15801
  * the script via indentBlock(…, 10) inside the `run: |` scalar, so the
15742
15802
  * raw unindented substring never appears in a real v1 file. */
15743
15803
  function isKnownTemplate(content) {
15744
- return GATES.some((g) => content === TEMPLATE(g)) || content.includes(indentBlock(SUMMARY_SCRIPT_V1, 10));
15804
+ return GATES.some((g) => content === TEMPLATE(g)) || GATES.some((g) => content === ACTION_TEMPLATE(g)) || content.includes(indentBlock(SUMMARY_SCRIPT_V1, 10));
15745
15805
  }
15746
15806
  /** Indents an embedded script so it sits inside a YAML `run: |` block scalar. */
15747
15807
  function indentBlock(text, spaces) {
@@ -15854,6 +15914,127 @@ const GATES = [
15854
15914
  "error",
15855
15915
  "warning"
15856
15916
  ];
15917
+ /**
15918
+ * The action-ref the action-based template pins (P1: distribution).
15919
+ * `v1` is the major moving tag release.yml's action-tags job maintains on
15920
+ * every stable release — Marketplace convention. The generated workflow
15921
+ * pins the major, never @latest: a new release must not change gate
15922
+ * semantics without a commit of the consumer's.
15923
+ */
15924
+ const ACTION_REF = "Sergey-Bar/Mjolnir@v1";
15925
+ /**
15926
+ * The action-based workflow for one gate level (P1.3): the root
15927
+ * action.yml does checkout-independent scanning — setup-node, the scan
15928
+ * itself (writing mjolnir.json for the reporting steps), and the gate
15929
+ * via the action's `fail-on` input. The action owns the gate: with
15930
+ * fail-on error/warning its step exits 1 on findings at the gate, so the
15931
+ * workflow needs no separate gate step; partial scans never block (the
15932
+ * action downgrades exit 2 to a loud warning, per the frozen exit-code
15933
+ * contract). Reporting steps run `if: always()` exactly like the npx
15934
+ * template. Advisory mode adds an explicit advisory note as the last
15935
+ * step so the job summary says "never blocking" in plain words.
15936
+ */
15937
+ const ACTION_TEMPLATE = (gate) => `name: Mjölnir
15938
+
15939
+ on:
15940
+ pull_request:
15941
+
15942
+ concurrency:
15943
+ group: mjolnir-\${{ github.ref }}
15944
+ cancel-in-progress: true
15945
+
15946
+ permissions:
15947
+ contents: read
15948
+ pull-requests: write
15949
+
15950
+ jobs:
15951
+ scan:
15952
+ runs-on: ubuntu-latest
15953
+ # A hung scan must not sit for the 6-hour default.
15954
+ timeout-minutes: 10
15955
+ steps:
15956
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
15957
+ with:
15958
+ fetch-depth: 0 # needed for --scope changed merge-base
15959
+ # The action scans with the published mjolnir-qa package, pinned to
15960
+ # the EXACT version that generated this workflow — never a floating
15961
+ # tag: a new release must not change your gate semantics with no
15962
+ # commit of yours (same rule as the npx template). The fail-on
15963
+ # input is the gate: it fails the job on findings at the gate and
15964
+ # never on a partial scan (exit 2 downgrades to a warning — the
15965
+ # frozen exit-code contract). Advisory mode reports, never blocks.
15966
+ - name: Mjölnir verification trust scan
15967
+ id: mjolnir
15968
+ if: always()
15969
+ continue-on-error: ${gate === "advisory" ? "true" : "false"}
15970
+ uses: ${ACTION_REF}
15971
+ with:
15972
+ scope: changed
15973
+ format: json
15974
+ fail-on: ${gate === "advisory" ? "none" : gate}
15975
+ version: ${CLI_VERSION}
15976
+ # Reporting, not gating: runs even when the scan/gate failed, from
15977
+ # the same mjolnir.json the action wrote.
15978
+ - name: Annotations + Job Summary
15979
+ if: always()
15980
+ continue-on-error: true
15981
+ run: npx --yes mjolnir-qa@${CLI_VERSION} summary mjolnir.json
15982
+ - name: Render PR comment
15983
+ if: always()
15984
+ continue-on-error: true
15985
+ run: npx --yes mjolnir-qa@${CLI_VERSION} pr-comment . > mjolnir-comment.md
15986
+ # Best-effort: on a pull_request event from a fork the GITHUB_TOKEN is
15987
+ # read-only and this step will 403 for every external contributor. The
15988
+ # Job Summary above is the fallback that always renders.
15989
+ # (pull_request_target would fix the token but is a code-execution
15990
+ # risk — deliberately NOT used.)
15991
+ - name: Post or update PR comment
15992
+ if: always()
15993
+ continue-on-error: true
15994
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
15995
+ with:
15996
+ script: |
15997
+ const fs = require('fs');
15998
+ let body = '';
15999
+ try { body = fs.readFileSync('mjolnir-comment.md', 'utf8'); } catch (e) {}
16000
+ if (!body.trim()) {
16001
+ console.log('mjolnir-comment.md is empty or missing — nothing to post.');
16002
+ return;
16003
+ }
16004
+ const marker = '<!-- mjolnir-pr-comment -->';
16005
+ const listed = await github.rest.issues.listComments({
16006
+ owner: context.repo.owner,
16007
+ repo: context.repo.repo,
16008
+ issue_number: context.issue.number,
16009
+ });
16010
+ const existing = listed.data.find((c) => c.body?.startsWith(marker));
16011
+ if (existing) {
16012
+ await github.rest.issues.updateComment({
16013
+ owner: context.repo.owner,
16014
+ repo: context.repo.repo,
16015
+ comment_id: existing.id,
16016
+ body,
16017
+ });
16018
+ } else {
16019
+ await github.rest.issues.createComment({
16020
+ owner: context.repo.owner,
16021
+ repo: context.repo.repo,
16022
+ issue_number: context.issue.number,
16023
+ body,
16024
+ });
16025
+ }
16026
+ ${gate === "advisory" ? ` # Advisory mode: findings are reported in the Job Summary,
16027
+ # never blocking — the action ran with fail-on: none and
16028
+ # continue-on-error, so even a crashed scan cannot fail this job.
16029
+ - name: Gate (advisory)
16030
+ if: always()
16031
+ run: echo "Advisory mode — findings reported, never blocking."` : ` # Gate enforcement: the action step above IS the gate — fail-on
16032
+ # ${gate} exits 1 on findings at the gate and the step is
16033
+ # continue-on-error: false, so its failure fails the job. A partial
16034
+ # scan never blocks (the action downgrades exit 2 to a warning per
16035
+ # the frozen exit-code contract). The reporting steps above ran
16036
+ # first (if: always()), so a red gate never suppresses the report.`}
16037
+ `;
15857
16038
  /** Multiset line diff — counts only, for the refusal message. */
15858
16039
  function summarizeContentDiff(existing, incoming) {
15859
16040
  const remaining = /* @__PURE__ */ new Map();
@@ -15873,16 +16054,17 @@ function ciInstall(root, gate = "advisory", options = {}) {
15873
16054
  const target = join(wfDir, "mjolnir.yml");
15874
16055
  if (!existsSync(wfDir)) mkdirSync(wfDir, { recursive: true });
15875
16056
  const existed = existsSync(target);
16057
+ const template = options.action === false ? TEMPLATE(gate) : ACTION_TEMPLATE(gate);
15876
16058
  if (existed) {
15877
16059
  const current = readFileSync(target, "utf8");
15878
16060
  if (!isKnownTemplate(current) && !(options.force ?? false)) return {
15879
16061
  written: target,
15880
16062
  existed,
15881
16063
  refused: true,
15882
- diffSummary: summarizeContentDiff(current, TEMPLATE(gate))
16064
+ diffSummary: summarizeContentDiff(current, template)
15883
16065
  };
15884
16066
  }
15885
- writeFileSync(target, TEMPLATE(gate));
16067
+ writeFileSync(target, template);
15886
16068
  return {
15887
16069
  written: target,
15888
16070
  existed,
@@ -16037,9 +16219,13 @@ function writeBadge(result, options) {
16037
16219
  const HELP_ENTRIES = [
16038
16220
  {
16039
16221
  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"],
16222
+ summary: "generate the PR workflow (action-based by default; scan + annotations + gate)",
16223
+ usage: "mjolnir ci install [--gate advisory|error|warning] [--no-action] [--force]",
16224
+ examples: [
16225
+ "mjolnir ci install",
16226
+ "mjolnir ci install --gate error",
16227
+ "mjolnir ci install --no-action --gate error --force"
16228
+ ],
16043
16229
  next: "mjolnir --scope changed"
16044
16230
  },
16045
16231
  {
@@ -18662,7 +18848,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
18662
18848
  * `scripts/sync-sarif-version.cjs` on release and guarded by
18663
18849
  * `tests/version-consistency.spec.ts` locally.
18664
18850
  */
18665
- const CLI_VERSION = "0.5.32";
18851
+ const CLI_VERSION = "0.5.34";
18666
18852
  function parseArgs(argv, onError) {
18667
18853
  const args = {
18668
18854
  target: ".",
@@ -18835,9 +19021,11 @@ function runCiInstall(argv, io = {
18835
19021
  let gateArg;
18836
19022
  let gateSeen = false;
18837
19023
  let force = false;
19024
+ let noAction = false;
18838
19025
  const unknown = [];
18839
19026
  for (const arg of argv) if (arg === "--gate") gateSeen = true;
18840
19027
  else if (arg === "--force") force = true;
19028
+ else if (arg === "--no-action") noAction = true;
18841
19029
  else if (gateSeen && gateArg === void 0 && !arg.startsWith("--")) gateArg = arg;
18842
19030
  else unknown.push(arg);
18843
19031
  if (gateSeen && gateArg === void 0) {
@@ -18856,7 +19044,10 @@ function runCiInstall(argv, io = {
18856
19044
  io.err("Unknown gate level. Use: advisory | error | warning");
18857
19045
  return 10;
18858
19046
  }
18859
- const result = ciInstall(resolve("."), gateArg ?? "advisory", { force });
19047
+ const result = ciInstall(resolve("."), gateArg ?? "advisory", {
19048
+ force,
19049
+ action: !noAction
19050
+ });
18860
19051
  if (result.refused) {
18861
19052
  io.err(`Refusing to overwrite the customized workflow at ${result.written}.`);
18862
19053
  io.err("The file differs from the template Mjölnir would write:");
@@ -18865,8 +19056,9 @@ function runCiInstall(argv, io = {
18865
19056
  return 10;
18866
19057
  }
18867
19058
  io.out(`${result.existed ? "Updated" : "Created"} ${result.written}`);
18868
- io.out("Default mode: advisory — findings reported, never blocking.");
19059
+ 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
19060
  io.out("Change with: mjolnir ci install --gate error|warning|advisory");
19061
+ if (!noAction) io.out("Prefer the plain-npx workflow? Re-run with --no-action.");
18870
19062
  return 0;
18871
19063
  }
18872
19064
  /** Testable `suppressions` handler. */
@@ -10073,6 +10073,58 @@ function computeDimensions(findings) {
10073
10073
  for (const dim of byCategory.values()) dim.score = Math.max(0, 100 - deductions.get(dim.category));
10074
10074
  return [...byCategory.values()].sort((a, b) => a.category.localeCompare(b.category));
10075
10075
  }
10076
+ /**
10077
+ * Deduction-mass ceilings (product-gap-remediation master plan P2,
10078
+ * plan 1788853205786 — structural anti-dilution, decision 4).
10079
+ *
10080
+ * The Goodhart vector the density formula permits: fixed deduction mass
10081
+ * ÷ padded denominator → score → 99. A repo with 80 warning-points of
10082
+ * real findings and 10,000 test declarations reads 99+ ("one minor
10083
+ * issue") because the RATE is tiny — the padding is free. Density stays
10084
+ * as the legitimate differentiator for small masses (a lone warning in
10085
+ * a 10k suite must still read ≥ 95), but it can no longer dilute a
10086
+ * large mass: each absolute deduction-mass band caps the score. Padding
10087
+ * cannot move a ceiling — the vector is structurally closed. 80
10088
+ * warning-pts read ≤ 75 in ANY suite size.
10089
+ *
10090
+ * The error-severity floor (95) is the ≥ 8 band seen from the other
10091
+ * side: 1 error ≥ 8 pts, so it is subsumed and left standing as a named
10092
+ * special case for prose continuity. The > 0 band is the honesty guard
10093
+ * (clampWithFindings) — subsumed as well.
10094
+ *
10095
+ * Calibration (P2.4): these boundaries were chosen to hold the three
10096
+ * known data points (self 100, golden 49 via suiteVoided, demo 67) and
10097
+ * are then FROZEN with a changelog entry. Never tune them to flatter a
10098
+ * repo (docs/SCORING.md Correction section is the precedent).
10099
+ */
10100
+ const DEDUCTION_MASS_CEILINGS = [
10101
+ {
10102
+ minMass: 160,
10103
+ ceiling: 65
10104
+ },
10105
+ {
10106
+ minMass: 80,
10107
+ ceiling: 75
10108
+ },
10109
+ {
10110
+ minMass: 40,
10111
+ ceiling: 85
10112
+ },
10113
+ {
10114
+ minMass: 8,
10115
+ ceiling: 95
10116
+ }
10117
+ ];
10118
+ /**
10119
+ * The score ceiling for a given evidence-discounted deduction mass.
10120
+ * 0 → no ceiling (the 100 honesty guard lives in clampWithFindings);
10121
+ * below the smallest band → no ceiling beyond the named guards.
10122
+ * Exported for the JSON contract (effectiveDeductions) and the tests.
10123
+ */
10124
+ function massCeiling(totalDeduction) {
10125
+ for (const band of DEDUCTION_MASS_CEILINGS) if (totalDeduction >= band.minMass) return band.ceiling;
10126
+ return null;
10127
+ }
10076
10128
  function computeTotal(dimensions, findings, exposure) {
10077
10129
  if (findings.length === 0) return 100;
10078
10130
  let totalDeduction = 0;
@@ -10085,6 +10137,8 @@ function computeTotal(dimensions, findings, exposure) {
10085
10137
  const rate = totalDeduction / (declarations + 1);
10086
10138
  score = clampWithFindings(100 - Math.min(100, rate * 5), totalDeduction);
10087
10139
  } else score = clampWithFindings(100 - totalDeduction, totalDeduction);
10140
+ const ceiling = massCeiling(totalDeduction);
10141
+ if (ceiling !== null && score > ceiling) score = ceiling;
10088
10142
  if (findings.some((f) => f.severity === "error" && deductionFor(f) > 0) && score > 95) score = 95;
10089
10143
  return suiteVoided ? Math.min(score, 49) : score;
10090
10144
  }
@@ -12321,6 +12375,7 @@ async function runScan$1(args, hooks = {}) {
12321
12375
  });
12322
12376
  const dimensions = computeDimensions(findings);
12323
12377
  const rawDeductions = findings.reduce((sum, f) => sum + deductionFor(f), 0);
12378
+ const effectiveDeductions = rawDeductions;
12324
12379
  const total = computeTotal(dimensions, findings, {
12325
12380
  testDeclarations: testDeclarationCount,
12326
12381
  testFileCount,
@@ -12345,6 +12400,7 @@ async function runScan$1(args, hooks = {}) {
12345
12400
  testFileCount,
12346
12401
  testDeclarationCount,
12347
12402
  rawDeductions,
12403
+ effectiveDeductions,
12348
12404
  suppressionCount,
12349
12405
  ...pluginsLoaded.length > 0 ? { plugins: pluginsLoaded } : {},
12350
12406
  agenticProfile: computeAgenticProfile(fileProvenance, findings),
@@ -12899,6 +12955,10 @@ function appendScoreSection(lines, result, p, width, ascii) {
12899
12955
  lines.push(` ${scoreGauge(result.score, p, gaugeWidth, ascii)}`);
12900
12956
  lines.push(` ${p.dim(headlineFor(state, result.findings.length))}`);
12901
12957
  if (result.rawDeductions !== void 0 && result.testDeclarationCount) lines.push(` ${p.dim(`(${result.rawDeductions} raw pts / ${result.testDeclarationCount} test declarations — normalized)`)}`);
12958
+ if (result.effectiveDeductions !== void 0 && result.score !== null) {
12959
+ const ceiling = massCeiling(result.effectiveDeductions);
12960
+ if (ceiling !== null && result.score <= ceiling && result.rawDeductions !== void 0) lines.push(` ${p.dim(`(capped: deduction mass ${result.effectiveDeductions} pts — absolute ceiling ${ceiling})`)}`);
12961
+ }
12902
12962
  if (result.suppressionCount && result.suppressionCount > 0) lines.push(` ${p.dim(`(${result.suppressionCount} finding(s) suppressed by config)`)}`);
12903
12963
  lines.push("");
12904
12964
  }
@@ -13289,7 +13349,7 @@ function renderSarif(result, repoRootUri) {
13289
13349
  tool: { driver: {
13290
13350
  name: "Mjölnir",
13291
13351
  informationUri: "https://github.com/Sergey-Bar/Mjolnir",
13292
- version: "0.5.32",
13352
+ version: "0.5.34",
13293
13353
  rules: [...rules.values()].map((r) => {
13294
13354
  const meta = RULES.find((x) => x.id === r.id);
13295
13355
  return {
@@ -14838,7 +14898,7 @@ const SUMMARY_SCRIPT_V1 = [
14838
14898
  * the script via indentBlock(…, 10) inside the `run: |` scalar, so the
14839
14899
  * raw unindented substring never appears in a real v1 file. */
14840
14900
  function isKnownTemplate(content) {
14841
- return GATES.some((g) => content === TEMPLATE(g)) || content.includes(indentBlock(SUMMARY_SCRIPT_V1, 10));
14901
+ return GATES.some((g) => content === TEMPLATE(g)) || GATES.some((g) => content === ACTION_TEMPLATE(g)) || content.includes(indentBlock(SUMMARY_SCRIPT_V1, 10));
14842
14902
  }
14843
14903
  /** Indents an embedded script so it sits inside a YAML `run: |` block scalar. */
14844
14904
  function indentBlock(text, spaces) {
@@ -14951,6 +15011,127 @@ const GATES = [
14951
15011
  "error",
14952
15012
  "warning"
14953
15013
  ];
15014
+ /**
15015
+ * The action-ref the action-based template pins (P1: distribution).
15016
+ * `v1` is the major moving tag release.yml's action-tags job maintains on
15017
+ * every stable release — Marketplace convention. The generated workflow
15018
+ * pins the major, never @latest: a new release must not change gate
15019
+ * semantics without a commit of the consumer's.
15020
+ */
15021
+ const ACTION_REF = "Sergey-Bar/Mjolnir@v1";
15022
+ /**
15023
+ * The action-based workflow for one gate level (P1.3): the root
15024
+ * action.yml does checkout-independent scanning — setup-node, the scan
15025
+ * itself (writing mjolnir.json for the reporting steps), and the gate
15026
+ * via the action's `fail-on` input. The action owns the gate: with
15027
+ * fail-on error/warning its step exits 1 on findings at the gate, so the
15028
+ * workflow needs no separate gate step; partial scans never block (the
15029
+ * action downgrades exit 2 to a loud warning, per the frozen exit-code
15030
+ * contract). Reporting steps run `if: always()` exactly like the npx
15031
+ * template. Advisory mode adds an explicit advisory note as the last
15032
+ * step so the job summary says "never blocking" in plain words.
15033
+ */
15034
+ const ACTION_TEMPLATE = (gate) => `name: Mjölnir
15035
+
15036
+ on:
15037
+ pull_request:
15038
+
15039
+ concurrency:
15040
+ group: mjolnir-\${{ github.ref }}
15041
+ cancel-in-progress: true
15042
+
15043
+ permissions:
15044
+ contents: read
15045
+ pull-requests: write
15046
+
15047
+ jobs:
15048
+ scan:
15049
+ runs-on: ubuntu-latest
15050
+ # A hung scan must not sit for the 6-hour default.
15051
+ timeout-minutes: 10
15052
+ steps:
15053
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
15054
+ with:
15055
+ fetch-depth: 0 # needed for --scope changed merge-base
15056
+ # The action scans with the published mjolnir-qa package, pinned to
15057
+ # the EXACT version that generated this workflow — never a floating
15058
+ # tag: a new release must not change your gate semantics with no
15059
+ # commit of yours (same rule as the npx template). The fail-on
15060
+ # input is the gate: it fails the job on findings at the gate and
15061
+ # never on a partial scan (exit 2 downgrades to a warning — the
15062
+ # frozen exit-code contract). Advisory mode reports, never blocks.
15063
+ - name: Mjölnir verification trust scan
15064
+ id: mjolnir
15065
+ if: always()
15066
+ continue-on-error: ${gate === "advisory" ? "true" : "false"}
15067
+ uses: ${ACTION_REF}
15068
+ with:
15069
+ scope: changed
15070
+ format: json
15071
+ fail-on: ${gate === "advisory" ? "none" : gate}
15072
+ version: ${CLI_VERSION}
15073
+ # Reporting, not gating: runs even when the scan/gate failed, from
15074
+ # the same mjolnir.json the action wrote.
15075
+ - name: Annotations + Job Summary
15076
+ if: always()
15077
+ continue-on-error: true
15078
+ run: npx --yes mjolnir-qa@${CLI_VERSION} summary mjolnir.json
15079
+ - name: Render PR comment
15080
+ if: always()
15081
+ continue-on-error: true
15082
+ run: npx --yes mjolnir-qa@${CLI_VERSION} pr-comment . > mjolnir-comment.md
15083
+ # Best-effort: on a pull_request event from a fork the GITHUB_TOKEN is
15084
+ # read-only and this step will 403 for every external contributor. The
15085
+ # Job Summary above is the fallback that always renders.
15086
+ # (pull_request_target would fix the token but is a code-execution
15087
+ # risk — deliberately NOT used.)
15088
+ - name: Post or update PR comment
15089
+ if: always()
15090
+ continue-on-error: true
15091
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
15092
+ with:
15093
+ script: |
15094
+ const fs = require('fs');
15095
+ let body = '';
15096
+ try { body = fs.readFileSync('mjolnir-comment.md', 'utf8'); } catch (e) {}
15097
+ if (!body.trim()) {
15098
+ console.log('mjolnir-comment.md is empty or missing — nothing to post.');
15099
+ return;
15100
+ }
15101
+ const marker = '<!-- mjolnir-pr-comment -->';
15102
+ const listed = await github.rest.issues.listComments({
15103
+ owner: context.repo.owner,
15104
+ repo: context.repo.repo,
15105
+ issue_number: context.issue.number,
15106
+ });
15107
+ const existing = listed.data.find((c) => c.body?.startsWith(marker));
15108
+ if (existing) {
15109
+ await github.rest.issues.updateComment({
15110
+ owner: context.repo.owner,
15111
+ repo: context.repo.repo,
15112
+ comment_id: existing.id,
15113
+ body,
15114
+ });
15115
+ } else {
15116
+ await github.rest.issues.createComment({
15117
+ owner: context.repo.owner,
15118
+ repo: context.repo.repo,
15119
+ issue_number: context.issue.number,
15120
+ body,
15121
+ });
15122
+ }
15123
+ ${gate === "advisory" ? ` # Advisory mode: findings are reported in the Job Summary,
15124
+ # never blocking — the action ran with fail-on: none and
15125
+ # continue-on-error, so even a crashed scan cannot fail this job.
15126
+ - name: Gate (advisory)
15127
+ if: always()
15128
+ run: echo "Advisory mode — findings reported, never blocking."` : ` # Gate enforcement: the action step above IS the gate — fail-on
15129
+ # ${gate} exits 1 on findings at the gate and the step is
15130
+ # continue-on-error: false, so its failure fails the job. A partial
15131
+ # scan never blocks (the action downgrades exit 2 to a warning per
15132
+ # the frozen exit-code contract). The reporting steps above ran
15133
+ # first (if: always()), so a red gate never suppresses the report.`}
15134
+ `;
14954
15135
  /** Multiset line diff — counts only, for the refusal message. */
14955
15136
  function summarizeContentDiff(existing, incoming) {
14956
15137
  const remaining = /* @__PURE__ */ new Map();
@@ -14970,16 +15151,17 @@ function ciInstall(root, gate = "advisory", options = {}) {
14970
15151
  const target = join(wfDir, "mjolnir.yml");
14971
15152
  if (!existsSync(wfDir)) mkdirSync(wfDir, { recursive: true });
14972
15153
  const existed = existsSync(target);
15154
+ const template = options.action === false ? TEMPLATE(gate) : ACTION_TEMPLATE(gate);
14973
15155
  if (existed) {
14974
15156
  const current = readFileSync(target, "utf8");
14975
15157
  if (!isKnownTemplate(current) && !(options.force ?? false)) return {
14976
15158
  written: target,
14977
15159
  existed,
14978
15160
  refused: true,
14979
- diffSummary: summarizeContentDiff(current, TEMPLATE(gate))
15161
+ diffSummary: summarizeContentDiff(current, template)
14980
15162
  };
14981
15163
  }
14982
- writeFileSync(target, TEMPLATE(gate));
15164
+ writeFileSync(target, template);
14983
15165
  return {
14984
15166
  written: target,
14985
15167
  existed,
@@ -15228,9 +15410,13 @@ function writeBadge(result, options) {
15228
15410
  const HELP_ENTRIES = [
15229
15411
  {
15230
15412
  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"],
15413
+ summary: "generate the PR workflow (action-based by default; scan + annotations + gate)",
15414
+ usage: "mjolnir ci install [--gate advisory|error|warning] [--no-action] [--force]",
15415
+ examples: [
15416
+ "mjolnir ci install",
15417
+ "mjolnir ci install --gate error",
15418
+ "mjolnir ci install --no-action --gate error --force"
15419
+ ],
15234
15420
  next: "mjolnir --scope changed"
15235
15421
  },
15236
15422
  {
@@ -18298,7 +18484,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
18298
18484
  * `scripts/sync-sarif-version.cjs` on release and guarded by
18299
18485
  * `tests/version-consistency.spec.ts` locally.
18300
18486
  */
18301
- const CLI_VERSION = "0.5.32";
18487
+ const CLI_VERSION = "0.5.34";
18302
18488
  function parseArgs(argv, onError) {
18303
18489
  const args = {
18304
18490
  target: ".",
@@ -18471,9 +18657,11 @@ function runCiInstall(argv, io = {
18471
18657
  let gateArg;
18472
18658
  let gateSeen = false;
18473
18659
  let force = false;
18660
+ let noAction = false;
18474
18661
  const unknown = [];
18475
18662
  for (const arg of argv) if (arg === "--gate") gateSeen = true;
18476
18663
  else if (arg === "--force") force = true;
18664
+ else if (arg === "--no-action") noAction = true;
18477
18665
  else if (gateSeen && gateArg === void 0 && !arg.startsWith("--")) gateArg = arg;
18478
18666
  else unknown.push(arg);
18479
18667
  if (gateSeen && gateArg === void 0) {
@@ -18492,7 +18680,10 @@ function runCiInstall(argv, io = {
18492
18680
  io.err("Unknown gate level. Use: advisory | error | warning");
18493
18681
  return 10;
18494
18682
  }
18495
- const result = ciInstall(resolve("."), gateArg ?? "advisory", { force });
18683
+ const result = ciInstall(resolve("."), gateArg ?? "advisory", {
18684
+ force,
18685
+ action: !noAction
18686
+ });
18496
18687
  if (result.refused) {
18497
18688
  io.err(`Refusing to overwrite the customized workflow at ${result.written}.`);
18498
18689
  io.err("The file differs from the template Mjölnir would write:");
@@ -18501,8 +18692,9 @@ function runCiInstall(argv, io = {
18501
18692
  return 10;
18502
18693
  }
18503
18694
  io.out(`${result.existed ? "Updated" : "Created"} ${result.written}`);
18504
- io.out("Default mode: advisory — findings reported, never blocking.");
18695
+ 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
18696
  io.out("Change with: mjolnir ci install --gate error|warning|advisory");
18697
+ if (!noAction) io.out("Prefer the plain-npx workflow? Re-run with --no-action.");
18506
18698
  return 0;
18507
18699
  }
18508
18700
  /** Testable `suppressions` handler. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mjolnir-qa",
3
- "version": "0.5.32",
3
+ "version": "0.5.34",
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": {