mjolnir-qa 0.5.33 → 0.5.35

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.35] — 2026-09-08
13
+
14
+ ### Changes since 0
15
+
16
+ - P3a: GitLab CI — Code Quality report, MR recipe, exit-code discipline (#65)
17
+
18
+ ## [0.5.34] — 2026-09-08
19
+
20
+ ### Changes since 0
21
+
22
+ - p2: structural anti-dilution — deduction-mass ceilings close the padding vector (plan 1788853205786 P2, decision 4) (#64)
23
+
12
24
  ## [0.5.33] — 2026-09-08
13
25
 
14
26
  ### Changes since 0.5.32
package/README.md CHANGED
@@ -203,6 +203,7 @@ pinned to the `v1` major tag) by default, or plain `npx` with
203
203
  | `mjolnir forensics ./test-results/` | Runtime evidence from a real run |
204
204
  | `mjolnir handoff` | Remediation plan for a coding agent |
205
205
  | `mjolnir --json` / `--format sarif` | Machine-readable / GitHub Code Scanning |
206
+ | `mjolnir --format codequality` | GitLab Code Quality report (MR widget artifact) |
206
207
  | `mjolnir --strict` | Also run quarantine-tier rules (higher FP risk) |
207
208
 
208
209
  <details>
@@ -565,6 +566,10 @@ Or wire it into GitHub Code Scanning natively via SARIF:
565
566
  sarif_file: mjolnir.sarif
566
567
  ```
567
568
 
569
+ On GitLab, `--format codequality` emits the Code Quality report the MR
570
+ widget and diff annotations consume
571
+ ([docs/GITLAB-CI.md](docs/GITLAB-CI.md)).
572
+
568
573
  Editor and pipeline setup: [docs/SARIF-INTEGRATION.md](docs/SARIF-INTEGRATION.md).
569
574
 
570
575
  ### Changed-scope attribution
@@ -718,6 +723,7 @@ Rule IDs (`QA-<FAMILY>-NNN`) are immutable once shipped and never reused.
718
723
  | [docs/VERSIONING.md](docs/VERSIONING.md) | Semver policy, frozen surfaces, deprecation cycle |
719
724
  | [docs/machine-contract.md](docs/machine-contract.md) | The canonical machine-readable result |
720
725
  | [docs/SARIF-INTEGRATION.md](docs/SARIF-INTEGRATION.md) | SARIF output + editor/CI setup |
726
+ | [docs/GITLAB-CI.md](docs/GITLAB-CI.md) | GitLab: Code Quality report, MR recipe, gate |
721
727
  | [docs/rules/](docs/rules/) | Generated per-rule catalog |
722
728
  | [CONTRIBUTING.md](CONTRIBUTING.md) | Dev setup + contribution workflow |
723
729
  | [SUPPORT.md](SUPPORT.md) | Where to ask, report and get help |
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
  /**
@@ -556,7 +565,7 @@ interface CliArgs {
556
565
  verbose: boolean;
557
566
  maxDurationMs: number;
558
567
  scopeChanged: boolean;
559
- format: "terminal" | "json" | "sarif" | "mermaid";
568
+ format: "terminal" | "json" | "sarif" | "mermaid" | "codequality";
560
569
  /** --width override for terminal box/gauge wrapping (Sprint 5 Task 22). */
561
570
  width?: number;
562
571
  /** --ascii / --no-ascii override for shouldUseAscii()'s heuristic. */
@@ -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.33";
740
+ declare const CLI_VERSION = "0.5.35";
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.33",
13353
+ version: "0.5.35",
13294
13354
  rules: [...rules.values()].map((r) => {
13295
13355
  const meta = RULES.find((x) => x.id === r.id);
13296
13356
  return {
@@ -13362,6 +13422,62 @@ function renderSarif(result, repoRootUri) {
13362
13422
  return JSON.stringify(sarif, null, 2);
13363
13423
  }
13364
13424
  //#endregion
13425
+ //#region src/reporter/codequality.ts
13426
+ /**
13427
+ * GitLab Code Quality reporter (product-gap-remediation master plan P3a,
13428
+ * plan 1788853205786 — flag 2, decision 2: GitLab is the in-plan second
13429
+ * platform).
13430
+ *
13431
+ * Emits the Code Quality report schema GitLab's `codequality` artifact
13432
+ * type consumes (docs.gitlab.com/ee/ci/testing/code_quality.html) so
13433
+ * findings render as MR widgets and diff annotations inside existing
13434
+ * governance — the same distribution logic as SARIF for GitHub, never a
13435
+ * second truth: this is a deterministic projection of the same
13436
+ * ScanResult the JSON contract carries.
13437
+ *
13438
+ * Usage: mjolnir --format codequality > gl-code-quality-report.json
13439
+ *
13440
+ * Severity map (documented in docs/GITLAB-CI.md):
13441
+ * error → major (categorical defects — the gate's core)
13442
+ * warning → minor
13443
+ * info → info
13444
+ *
13445
+ * Fingerprint stability: GitLab deduplicates MR findings across runs by
13446
+ * `fingerprint` — an unstable fingerprint would resurrect closed
13447
+ * findings on every push (the exact PR-noise the MR flow exists to
13448
+ * avoid). The fingerprint is sha256 over ruleId + repo-relative path +
13449
+ * line + column + message — the same finding shape the --why command
13450
+ * addresses, so a moved line legitimately re-reports and an unchanged
13451
+ * finding stays silent.
13452
+ */
13453
+ function codeQualitySeverity(severity) {
13454
+ if (severity === "error") return "major";
13455
+ if (severity === "warning") return "minor";
13456
+ return "info";
13457
+ }
13458
+ /**
13459
+ * sha256 hex over the identifying tuple. Exported for the contract test:
13460
+ * the fingerprint MUST be stable across runs for an unchanged finding
13461
+ * (GitLab dedup contract) and MUST change when the finding's identity
13462
+ * changes (otherwise an MR could hide a moved-and-still-broken line).
13463
+ */
13464
+ function codeQualityFingerprint(f) {
13465
+ return createHash("sha256").update(`${f.ruleId}\u0000${f.file}\u0000${f.line}\u0000${f.column}\u0000${f.message}`).digest("hex");
13466
+ }
13467
+ function renderCodeQuality(result) {
13468
+ const issues = result.findings.map((f) => ({
13469
+ description: f.message,
13470
+ check_name: f.ruleId,
13471
+ fingerprint: codeQualityFingerprint(f),
13472
+ severity: codeQualitySeverity(f.severity),
13473
+ location: {
13474
+ path: f.file.replaceAll("\\", "/"),
13475
+ lines: { begin: f.line }
13476
+ }
13477
+ }));
13478
+ return JSON.stringify(issues, null, 2) + "\n";
13479
+ }
13480
+ //#endregion
13365
13481
  //#region src/reporter/mermaid.ts
13366
13482
  function sanitizeId(raw) {
13367
13483
  return raw.replace(/[^a-z0-9]/gi, "_");
@@ -16324,6 +16440,10 @@ const HELP_FLAGS = [
16324
16440
  flag: "--format sarif",
16325
16441
  summary: "SARIF 2.1 for GitHub Code Scanning"
16326
16442
  },
16443
+ {
16444
+ flag: "--format codequality",
16445
+ summary: "GitLab Code Quality report (MR widget artifact)"
16446
+ },
16327
16447
  {
16328
16448
  flag: "--format mermaid",
16329
16449
  summary: "test-architecture diagram"
@@ -18788,7 +18908,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
18788
18908
  * `scripts/sync-sarif-version.cjs` on release and guarded by
18789
18909
  * `tests/version-consistency.spec.ts` locally.
18790
18910
  */
18791
- const CLI_VERSION = "0.5.33";
18911
+ const CLI_VERSION = "0.5.35";
18792
18912
  function parseArgs(argv, onError) {
18793
18913
  const args = {
18794
18914
  target: ".",
@@ -18811,6 +18931,7 @@ function parseArgs(argv, onError) {
18811
18931
  const fmt = argv[++i];
18812
18932
  if (fmt === "sarif") args.format = "sarif";
18813
18933
  else if (fmt === "mermaid") args.format = "mermaid";
18934
+ else if (fmt === "codequality") args.format = "codequality";
18814
18935
  else if (fmt === "json") {
18815
18936
  args.format = "json";
18816
18937
  args.json = true;
@@ -19195,6 +19316,7 @@ async function runScanCommand(argv, io = {
19195
19316
  }
19196
19317
  if (args.format === "sarif") io.out(renderSarif(result, pathToFileURL(target).href));
19197
19318
  else if (args.format === "mermaid") io.out(renderMermaid(result));
19319
+ else if (args.format === "codequality") io.out(renderCodeQuality(result));
19198
19320
  else if (args.json) io.out(JSON.stringify({
19199
19321
  ...result,
19200
19322
  contract: buildMachineContract(result)
@@ -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.33",
13352
+ version: "0.5.35",
13293
13353
  rules: [...rules.values()].map((r) => {
13294
13354
  const meta = RULES.find((x) => x.id === r.id);
13295
13355
  return {
@@ -13361,6 +13421,62 @@ function renderSarif(result, repoRootUri) {
13361
13421
  return JSON.stringify(sarif, null, 2);
13362
13422
  }
13363
13423
  //#endregion
13424
+ //#region src/reporter/codequality.ts
13425
+ /**
13426
+ * GitLab Code Quality reporter (product-gap-remediation master plan P3a,
13427
+ * plan 1788853205786 — flag 2, decision 2: GitLab is the in-plan second
13428
+ * platform).
13429
+ *
13430
+ * Emits the Code Quality report schema GitLab's `codequality` artifact
13431
+ * type consumes (docs.gitlab.com/ee/ci/testing/code_quality.html) so
13432
+ * findings render as MR widgets and diff annotations inside existing
13433
+ * governance — the same distribution logic as SARIF for GitHub, never a
13434
+ * second truth: this is a deterministic projection of the same
13435
+ * ScanResult the JSON contract carries.
13436
+ *
13437
+ * Usage: mjolnir --format codequality > gl-code-quality-report.json
13438
+ *
13439
+ * Severity map (documented in docs/GITLAB-CI.md):
13440
+ * error → major (categorical defects — the gate's core)
13441
+ * warning → minor
13442
+ * info → info
13443
+ *
13444
+ * Fingerprint stability: GitLab deduplicates MR findings across runs by
13445
+ * `fingerprint` — an unstable fingerprint would resurrect closed
13446
+ * findings on every push (the exact PR-noise the MR flow exists to
13447
+ * avoid). The fingerprint is sha256 over ruleId + repo-relative path +
13448
+ * line + column + message — the same finding shape the --why command
13449
+ * addresses, so a moved line legitimately re-reports and an unchanged
13450
+ * finding stays silent.
13451
+ */
13452
+ function codeQualitySeverity(severity) {
13453
+ if (severity === "error") return "major";
13454
+ if (severity === "warning") return "minor";
13455
+ return "info";
13456
+ }
13457
+ /**
13458
+ * sha256 hex over the identifying tuple. Exported for the contract test:
13459
+ * the fingerprint MUST be stable across runs for an unchanged finding
13460
+ * (GitLab dedup contract) and MUST change when the finding's identity
13461
+ * changes (otherwise an MR could hide a moved-and-still-broken line).
13462
+ */
13463
+ function codeQualityFingerprint(f) {
13464
+ return createHash("sha256").update(`${f.ruleId}\u0000${f.file}\u0000${f.line}\u0000${f.column}\u0000${f.message}`).digest("hex");
13465
+ }
13466
+ function renderCodeQuality(result) {
13467
+ const issues = result.findings.map((f) => ({
13468
+ description: f.message,
13469
+ check_name: f.ruleId,
13470
+ fingerprint: codeQualityFingerprint(f),
13471
+ severity: codeQualitySeverity(f.severity),
13472
+ location: {
13473
+ path: f.file.replaceAll("\\", "/"),
13474
+ lines: { begin: f.line }
13475
+ }
13476
+ }));
13477
+ return JSON.stringify(issues, null, 2) + "\n";
13478
+ }
13479
+ //#endregion
13364
13480
  //#region src/reporter/mermaid.ts
13365
13481
  function sanitizeId(raw) {
13366
13482
  return raw.replace(/[^a-z0-9]/gi, "_");
@@ -15515,6 +15631,10 @@ const HELP_FLAGS = [
15515
15631
  flag: "--format sarif",
15516
15632
  summary: "SARIF 2.1 for GitHub Code Scanning"
15517
15633
  },
15634
+ {
15635
+ flag: "--format codequality",
15636
+ summary: "GitLab Code Quality report (MR widget artifact)"
15637
+ },
15518
15638
  {
15519
15639
  flag: "--format mermaid",
15520
15640
  summary: "test-architecture diagram"
@@ -18424,7 +18544,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
18424
18544
  * `scripts/sync-sarif-version.cjs` on release and guarded by
18425
18545
  * `tests/version-consistency.spec.ts` locally.
18426
18546
  */
18427
- const CLI_VERSION = "0.5.33";
18547
+ const CLI_VERSION = "0.5.35";
18428
18548
  function parseArgs(argv, onError) {
18429
18549
  const args = {
18430
18550
  target: ".",
@@ -18447,6 +18567,7 @@ function parseArgs(argv, onError) {
18447
18567
  const fmt = argv[++i];
18448
18568
  if (fmt === "sarif") args.format = "sarif";
18449
18569
  else if (fmt === "mermaid") args.format = "mermaid";
18570
+ else if (fmt === "codequality") args.format = "codequality";
18450
18571
  else if (fmt === "json") {
18451
18572
  args.format = "json";
18452
18573
  args.json = true;
@@ -18831,6 +18952,7 @@ async function runScanCommand(argv, io = {
18831
18952
  }
18832
18953
  if (args.format === "sarif") io.out(renderSarif(result, pathToFileURL(target).href));
18833
18954
  else if (args.format === "mermaid") io.out(renderMermaid(result));
18955
+ else if (args.format === "codequality") io.out(renderCodeQuality(result));
18834
18956
  else if (args.json) io.out(JSON.stringify({
18835
18957
  ...result,
18836
18958
  contract: buildMachineContract(result)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mjolnir-qa",
3
- "version": "0.5.33",
3
+ "version": "0.5.35",
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": {