mjolnir-qa 2.1.0 → 3.0.0

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/dist/cli.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { $ as massCeiling, A as box, At as isAdvisoryFinding, B as headlineFor, C as buildFooter, Ct as buildEvidenceGraph, D as plainContext, Dt as RULE_CATEGORIES, E as panel, Et as QA_IMPACT_LABELS, F as scoreGauge, G as buildDependencyGraph, H as EVIDENCE, I as shouldColorize, J as classifyProvenance, K as getReachableFiles, L as shouldUseAscii, M as padTo, N as palette, O as sectionHeader, Ot as SEVERITY_ORDER$1, P as sanitizeData, Q as deductionFor, R as wrapText, S as FLAKE_GLYPH, St as ENGINE_VERSION, T as okIcon, Tt as DEDUCTIONS, U as TINT, V as BADGE_BAND, W as TRUST, X as capForTier, Y as computeAgenticProfile, Z as computeDimensions, _t as ConfigValidationError, at as SEARCHED_FOR, b as renderSuppressions, bt as isRecord$2, ct as parseWorkflow, dt as parseTsFile, et as RETIRED_RULE_IDS, ft as detectFrameworks, gt as isLintFixtureDir, h as scan_pipeline_exports, ht as globToRegExp, it as SCAN_ADAPTERS, j as measure, jt as isValidCategory, k as severityIcon, kt as deriveEvidenceLevel, lt as parseYamlGuarded, m as runScan$1, mt as createIgnoreMatcher, n as KNOWN_RULE_IDS$1, nt as getRule, ot as isAzurePipelineFixture, pt as DEFAULT_IGNORE_MATCHER, q as correlateFindings, rt as resolveGitPath, st as parseAzurePipeline, tt as RULES, ut as computeCodeText, v as loadLocalRules, vt as isSuppressionActive, w as nextStep, wt as MEASURED_FP, x as runForensics, xt as parseJsonFile, y as loadSuppressions, yt as loadConfig, z as deriveScoreState } from "./scan-pipeline-MQGUSey3.mjs";
3
- import { accessSync, appendFileSync, chmodSync, closeSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
2
+ import { $ as correlateFindings, A as plainContext, At as parseJsonFile, B as shouldUseAscii, Bt as isValidCategory, C as runForensics, Ct as globToRegExp, D as nextStep, Dt as loadConfig, E as buildFooter, Et as isSuppressionActive, F as padTo, Ft as QA_IMPACT_LABELS, G as EVIDENCE, H as deriveScoreState, I as palette, It as RULE_CATEGORIES, J as atomicTempPath, K as TINT, L as sanitizeData, Lt as SEVERITY_ORDER$1, M as severityIcon, Mt as buildEvidenceGraph, N as box, Nt as MEASURED_FP, O as okIcon, Ot as readFileBounded, P as measure, Pt as DEDUCTIONS, Q as getReachableFiles, R as scoreGauge, Rt as deriveEvidenceLevel, S as renderSuppressions, St as createIgnoreMatcher, T as FLAKE_GLYPH, Tt as ConfigValidationError, U as headlineFor, V as wrapText, W as BADGE_BAND, X as writeFileAtomic, Y as sweepStaleTempFiles, Z as buildDependencyGraph, _t as computeCodeText, at as massCeiling, b as loadLocalRules, bt as DEFAULT_IGNORE_MATCHER, ct as getRule, dt as SCAN_ADAPTERS, et as classifyProvenance, ft as SEARCHED_FOR, gt as parseYamlGuarded, h as scan_pipeline_exports, ht as parseWorkflow, it as deductionFor, j as sectionHeader, jt as ENGINE_VERSION, k as panel, kt as isRecord$3, lt as resolveGitPath, m as runScan$1, mt as parseAzurePipeline, n as KNOWN_RULE_IDS$1, nt as capForTier, ot as RETIRED_RULE_IDS, pt as isAzurePipelineFixture, q as TRUST, rt as computeDimensions, st as RULES, tt as computeAgenticProfile, ut as runGit, v as pluginsGateOpen, vt as parseTsFile, w as sanitizeErrorText, wt as isLintFixtureDir, x as loadSuppressions, xt as LIMITS, y as renderGateNotice, yt as detectFrameworks, z as shouldColorize, zt as isAdvisoryFinding } from "./scan-pipeline-CAH9_Qgh.mjs";
3
+ import { accessSync, appendFileSync, chmodSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import process$1 from "node:process";
6
6
  import { fileURLToPath, pathToFileURL } from "node:url";
7
- import { createHash, randomBytes } from "node:crypto";
7
+ import { createHash } from "node:crypto";
8
8
  import ts, { ts as ts$1 } from "ts-morph";
9
9
  import { parse } from "yaml";
10
10
  import { execFileSync } from "node:child_process";
@@ -672,27 +672,21 @@ concurrency:
672
672
 
673
673
  permissions:
674
674
  contents: read
675
- pull-requests: write
676
675
 
677
676
  jobs:
678
677
  scan:
679
678
  runs-on: ubuntu-latest
680
- # A hung scan must not sit for the 6-hour default.
681
679
  timeout-minutes: 10
680
+ permissions:
681
+ contents: read
682
682
  steps:
683
683
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
684
684
  with:
685
- fetch-depth: 0 # needed for --scope changed merge-base
685
+ fetch-depth: 0
686
686
  persist-credentials: false
687
- # Scan with the pinned version. No subcommand: npx resolves
688
- # the package bin directly; passing mjolnir as an arg causes
689
- # npm 10+ npx to invoke it twice (unknown subcommand).
690
687
  - name: Scan changed code (exit 1/2 is data — the gate step decides)
691
688
  continue-on-error: true
692
689
  run: npx --yes https://registry.npmjs.org/mjolnir-qa/-/mjolnir-qa-${ENGINE_VERSION}.tgz . --scope changed --json > mjolnir.json
693
- # Reporting, not gating: a crashed scan leaves mjolnir.json empty/missing
694
- # and the summary step exits 2/10 — continue-on-error keeps the advisory
695
- # job green, exactly like the v1 inline script did (the gate step decides).
696
690
  - name: Annotations + Job Summary
697
691
  if: always()
698
692
  continue-on-error: true
@@ -700,32 +694,49 @@ jobs:
700
694
  - name: Render PR comment
701
695
  if: always()
702
696
  continue-on-error: true
703
- run: npx --yes https://registry.npmjs.org/mjolnir-qa/-/mjolnir-qa-${ENGINE_VERSION}.tgz pr-comment . > mjolnir-comment.md
704
- # Best-effort: on a pull_request event from a fork the GITHUB_TOKEN is
705
- # read-only and this step will 403 for every external contributor. The
706
- # Job Summary above is the fallback that always renders.
707
- # (pull_request_target would fix the token but is a code-execution
708
- # risk — deliberately NOT used.)
709
- - name: Post or update PR comment
697
+ run: npx --yes https://registry.npmjs.org/mjolnir-qa/-/mjolnir-qa-${ENGINE_VERSION}.tgz pr-comment --from mjolnir.json > mjolnir-comment.md
698
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
699
+ if: always()
700
+ with:
701
+ name: mjolnir-report
702
+ path: mjolnir-comment.md
703
+ if-no-files-found: warn
704
+ retention-days: 30
705
+ ${gate === "advisory" ? ` - name: Gate (advisory)
706
+ if: always()
707
+ run: echo "Advisory mode — findings reported, never blocking."` : ` - name: Gate (${gate})
708
+ if: always()
709
+ run: |
710
+ node -e '
711
+ ${indentBlock(gateScript(gate), 10)}
712
+ '`}
713
+
714
+ publish:
715
+ needs: scan
716
+ if: always() && needs.scan.result != 'cancelled'
717
+ runs-on: ubuntu-latest
718
+ timeout-minutes: 5
719
+ permissions:
720
+ contents: read
721
+ pull-requests: write
722
+ steps:
723
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
724
+ with:
725
+ name: mjolnir-report
726
+ - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
710
727
  if: always()
711
728
  continue-on-error: true
712
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
713
729
  with:
714
730
  script: |
715
731
  const fs = require('fs');
716
- let body = '';
717
- try { body = fs.readFileSync('mjolnir-comment.md', 'utf8'); } catch (e) {}
718
- if (!body.trim()) {
719
- console.log('mjolnir-comment.md is empty or missing — nothing to post.');
720
- return;
721
- }
722
- const marker = '<!-- mjolnir-pr-comment -->';
723
- const { data: comments } = await github.rest.issues.listComments({
724
- owner: context.repo.owner,
725
- repo: context.repo.repo,
726
- issue_number: context.issue.number,
727
- });
728
- const existing = comments.find((c) => c.body?.startsWith(marker));
732
+ const body = fs.readFileSync('mjolnir-comment.md', 'utf8');
733
+ if (!body.trim()) return;
734
+ const marker = '<!-- mjolnir-report:v2 -->';
735
+ const comments = await github.paginate(
736
+ github.rest.issues.listComments,
737
+ { owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, per_page: 100 },
738
+ );
739
+ const existing = comments.find((comment) => comment.body?.includes(marker));
729
740
  if (existing) {
730
741
  await github.rest.issues.updateComment({
731
742
  owner: context.repo.owner,
@@ -741,20 +752,9 @@ jobs:
741
752
  body,
742
753
  });
743
754
  }
744
- ${gate === "advisory" ? ` # Advisory mode: findings are reported in the Job Summary and the
745
- # PR comment, never blocking. The scan step's exit code is visible
746
- # as the step outcome, but continue-on-error keeps the job green.
747
- - name: Gate (advisory)
748
- if: always()
749
- run: echo "Advisory mode — findings reported, never blocking."` : ` # Gate enforcement: the scan step's own exit code is deliberately
750
- # neutralized (continue-on-error) so reporting steps always run; THIS
751
- # step is what fails the job. A partial scan never blocks.
752
- - name: Gate (${gate})
755
+ ${gate === "advisory" ? ` - name: Advisory note
753
756
  if: always()
754
- run: |
755
- node -e '
756
- ${indentBlock(gateScript(gate), 10)}
757
- '`}
757
+ run: echo "Advisory mode — findings are reported, never blocking."` : ""}
758
758
  `;
759
759
  /** Every gate variant, used to tell "our template" from "hand-customized". */
760
760
  const GATES = [
@@ -793,69 +793,77 @@ concurrency:
793
793
 
794
794
  permissions:
795
795
  contents: read
796
- pull-requests: write
797
796
 
798
797
  jobs:
799
798
  scan:
800
799
  runs-on: ubuntu-latest
801
- # A hung scan must not sit for the 6-hour default.
802
800
  timeout-minutes: 10
801
+ permissions:
802
+ contents: read
803
803
  steps:
804
804
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
805
805
  with:
806
- fetch-depth: 0 # needed for --scope changed merge-base
806
+ fetch-depth: 0
807
807
  persist-credentials: false
808
- # The action scans with the published mjolnir-qa package
809
- # pinned to this CLI release. The composite action is pinned via
810
- # ACTION_REF. The fail-on input is the gate: it fails the
811
- # job on findings at the gate and never on a partial scan
812
- # (exit 2 downgrades to a warning — the frozen exit-code
813
- # contract). Advisory mode reports, never blocks.
814
808
  - name: Mjölnir verification trust scan
815
809
  id: mjolnir
816
- if: always()
817
- continue-on-error: ${gate === "advisory" ? "true" : "false"}
810
+ continue-on-error: true
818
811
  uses: ${ACTION_REF}
819
812
  with:
820
813
  scope: changed
821
814
  format: json
822
815
  fail-on: ${gate === "advisory" ? "none" : gate}
823
816
  version: ${ENGINE_VERSION}
824
- # Reporting, not gating: runs even when the scan/gate failed, from
825
- # the same mjolnir.json the action wrote.
826
- - name: Annotations + Job Summary
817
+ pr-comment: "false"
818
+ trust-artifact: "false"
819
+ annotations: "true"
820
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
827
821
  if: always()
828
- continue-on-error: true
829
- run: npx --yes https://registry.npmjs.org/mjolnir-qa/-/mjolnir-qa-${ENGINE_VERSION}.tgz summary mjolnir.json
830
- - name: Render PR comment
822
+ with:
823
+ name: mjolnir-report
824
+ path: mjolnir-comment.md
825
+ if-no-files-found: warn
826
+ retention-days: 30
827
+ - name: Gate (${gate})
831
828
  if: always()
832
- continue-on-error: true
833
- run: npx --yes https://registry.npmjs.org/mjolnir-qa/-/mjolnir-qa-${ENGINE_VERSION}.tgz pr-comment . > mjolnir-comment.md
834
- # Best-effort: on a pull_request event from a fork the GITHUB_TOKEN is
835
- # read-only and this step will 403 for every external contributor. The
836
- # Job Summary above is the fallback that always renders.
837
- # (pull_request_target would fix the token but is a code-execution
838
- # risk — deliberately NOT used.)
839
- - name: Post or update PR comment
829
+ shell: bash
830
+ env:
831
+ MJ_SCAN_EXIT: \${{ steps.mjolnir.outputs.exit }}
832
+ run: |
833
+ if [ "$MJ_SCAN_EXIT" = "1" ]; then
834
+ exit 1
835
+ fi
836
+ if [ "$MJ_SCAN_EXIT" = "2" ]; then
837
+ echo "::warning::Mjolnir analysis is partial; reporting remains visible but the gate is not claimed."
838
+ fi
839
+ exit 0
840
+
841
+ publish:
842
+ needs: scan
843
+ if: always() && needs.scan.result != 'cancelled'
844
+ runs-on: ubuntu-latest
845
+ timeout-minutes: 5
846
+ permissions:
847
+ contents: read
848
+ pull-requests: write
849
+ steps:
850
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
851
+ with:
852
+ name: mjolnir-report
853
+ - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
840
854
  if: always()
841
855
  continue-on-error: true
842
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
843
856
  with:
844
857
  script: |
845
858
  const fs = require('fs');
846
- let body = '';
847
- try { body = fs.readFileSync('mjolnir-comment.md', 'utf8'); } catch (e) {}
848
- if (!body.trim()) {
849
- console.log('mjolnir-comment.md is empty or missing — nothing to post.');
850
- return;
851
- }
852
- const marker = '<!-- mjolnir-pr-comment -->';
853
- const listed = await github.rest.issues.listComments({
854
- owner: context.repo.owner,
855
- repo: context.repo.repo,
856
- issue_number: context.issue.number,
857
- });
858
- const existing = listed.data.find((c) => c.body?.startsWith(marker));
859
+ const body = fs.readFileSync('mjolnir-comment.md', 'utf8');
860
+ if (!body.trim()) return;
861
+ const marker = '<!-- mjolnir-report:v2 -->';
862
+ const comments = await github.paginate(
863
+ github.rest.issues.listComments,
864
+ { owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, per_page: 100 },
865
+ );
866
+ const existing = comments.find((comment) => comment.body?.includes(marker));
859
867
  if (existing) {
860
868
  await github.rest.issues.updateComment({
861
869
  owner: context.repo.owner,
@@ -871,17 +879,9 @@ jobs:
871
879
  body,
872
880
  });
873
881
  }
874
- ${gate === "advisory" ? ` # Advisory mode: findings are reported in the Job Summary,
875
- # never blocking — the action ran with fail-on: none and
876
- # continue-on-error, so even a crashed scan cannot fail this job.
877
- - name: Gate (advisory)
882
+ ${gate === "advisory" ? ` - name: Advisory note
878
883
  if: always()
879
- run: echo "Advisory mode — findings reported, never blocking."` : ` # Gate enforcement: the action step above IS the gate — fail-on
880
- # ${gate} exits 1 on findings at the gate and the step is
881
- # continue-on-error: false, so its failure fails the job. A partial
882
- # scan never blocks (the action downgrades exit 2 to a warning per
883
- # the frozen exit-code contract). The reporting steps above ran
884
- # first (if: always()), so a red gate never suppresses the report.`}
884
+ run: echo "Advisory mode — findings are reported, never blocking."` : ""}
885
885
  `;
886
886
  /** Multiset line diff — counts only, for the refusal message. */
887
887
  function summarizeContentDiff(existing, incoming) {
@@ -940,6 +940,10 @@ function digestView(f) {
940
940
  trustLevel: f.trustLevel ?? null,
941
941
  confidence: f.confidence,
942
942
  findingType: f.findingType,
943
+ qaImpact: f.qaImpact,
944
+ findingId: f.findingId ?? null,
945
+ rootCauseId: f.rootCauseId ?? null,
946
+ deduplicationGroup: f.deduplicationGroup ?? null,
943
947
  fixGroupId: f.fixGroupId ?? null
944
948
  };
945
949
  }
@@ -960,8 +964,12 @@ function canonicalScanJson(result) {
960
964
  rules: result.analysisStatus.rules,
961
965
  skippedFiles: result.analysisStatus.skippedFiles,
962
966
  rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
963
- truncationReasons: result.analysisStatus.truncationReasons ?? []
967
+ parseFallbacks: result.analysisStatus.parseFallbacks ?? 0,
968
+ truncationReasons: result.analysisStatus.truncationReasons ?? [],
969
+ reasons: result.analysisStatus.reasons ?? []
964
970
  },
971
+ scopeIntegrity: result.scopeIntegrity ?? null,
972
+ runIdentity: result.runIdentity ?? null,
965
973
  findings: result.findings.map(digestView)
966
974
  });
967
975
  }
@@ -1018,9 +1026,13 @@ function buildMachineContract(result) {
1018
1026
  rules: result.analysisStatus.rules,
1019
1027
  skippedFiles: result.analysisStatus.skippedFiles,
1020
1028
  rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
1029
+ ...result.analysisStatus.parseFallbacks !== void 0 ? { parseFallbacks: result.analysisStatus.parseFallbacks } : {},
1021
1030
  truncationReasons: result.analysisStatus.truncationReasons ?? [],
1031
+ ...result.analysisStatus.reasons !== void 0 ? { reasons: result.analysisStatus.reasons } : {},
1022
1032
  frameworkDetectionUnknown: result.frameworkDetectionUnknown,
1023
- durationMs: result.analysisStatus.durationMs
1033
+ durationMs: result.analysisStatus.durationMs,
1034
+ ...result.scopeIntegrity !== void 0 ? { scopeIntegrity: result.scopeIntegrity } : {},
1035
+ ...result.runIdentity !== void 0 ? { runIdentity: result.runIdentity } : {}
1024
1036
  },
1025
1037
  ...result.trustSummary !== void 0 ? { trustSummary: result.trustSummary } : {},
1026
1038
  ...result.agenticProfile !== void 0 ? { provenance: result.agenticProfile } : {},
@@ -1294,7 +1306,7 @@ function whatWouldChangeTheVerdict(r) {
1294
1306
  /** Structural validation of the loaded JSON (hostile-input safe). */
1295
1307
  function parseScanJson(raw) {
1296
1308
  try {
1297
- return parseJsonFile(raw, "scan result", (v) => isRecord$2(v) && v.schemaVersion === 1 && Array.isArray(v.findings) && Boolean(v.analysisStatus) && typeof v.partial === "boolean");
1309
+ return parseJsonFile(raw, "scan result", (v) => isRecord$3(v) && v.schemaVersion === 1 && Array.isArray(v.findings) && Boolean(v.analysisStatus) && typeof v.partial === "boolean");
1298
1310
  } catch {
1299
1311
  return;
1300
1312
  }
@@ -1409,122 +1421,6 @@ function pctOf(v) {
1409
1421
  return `${Math.round(v * 100)}%`;
1410
1422
  }
1411
1423
  //#endregion
1412
- //#region src/lib/fs-atomic.ts
1413
- /**
1414
- * Atomic file writes (audit S9).
1415
- *
1416
- * Every durability-critical write in Mjölnir (baseline, stats, badge,
1417
- * TRIAGE.md, scaffolded rule files) used to hand-roll
1418
- * `writeFileSync(path, data)` — a crash mid-write left a TRUNCATED file
1419
- * at the real path, and a subsequent read (diff, badge endpoint) served
1420
- * confident nonsense from it.
1421
- *
1422
- * `writeFileAtomic` writes to a temp sibling, then RENAMES. On the same
1423
- * volume rename is atomic: readers see either the complete old file or
1424
- * the complete new file, never a half-written one. The temp name is
1425
- * created with `wx` (exclusive) so concurrent writers cannot interleave,
1426
- * stale temps are cleaned up on failure, and on Windows the rename is
1427
- * retried briefly because a concurrent reader can hold the destination
1428
- * open (EBUSY/EPERM).
1429
- */
1430
- function atomicTempPath(path) {
1431
- return `${path}.mjolnir-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}.tmp`;
1432
- }
1433
- /**
1434
- * Atomically replace `path` with `data`.
1435
- */
1436
- function writeFileAtomic(path, data, opts = {}) {
1437
- const dir = dirname(path);
1438
- if (opts.mkdirs !== false && !existsSync(dir)) mkdirSync(dir, { recursive: true });
1439
- const tmp = atomicTempPath(path);
1440
- let fd;
1441
- try {
1442
- fd = openSync(tmp, "wx", opts.mode ?? 420);
1443
- writeSync(fd, data, null, opts.encoding ?? "utf8");
1444
- } finally {
1445
- if (fd !== void 0) closeSync(fd);
1446
- }
1447
- try {
1448
- renameWithWindowsRetry(tmp, path);
1449
- } catch (err) {
1450
- try {
1451
- if (existsSync(tmp)) unlinkSync(tmp);
1452
- } catch {}
1453
- throw err;
1454
- }
1455
- }
1456
- /**
1457
- * renameSync retry loop for Windows: a concurrent reader (another scan,
1458
- * a badge endpoint, an editor) holding the destination open makes
1459
- * rename fail with EBUSY/EPERM. A short bounded retry closes the race
1460
- * without turning an atomic swap into a partial write.
1461
- *
1462
- * Internal contract test hook: the platform check keeps this loop off
1463
- * the POSIX hot path; on win32 the EBUSY/EPERM arms are exercised by
1464
- * the fs-atomic-retry spec (mocked renameSync).
1465
- */
1466
- const RENAME_RETRIES = 8;
1467
- const RENAME_RETRY_DELAY_MS = 25;
1468
- /** Synchronous sleep that does not spin the CPU. */
1469
- function sleepSync(ms) {
1470
- try {
1471
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1472
- } catch {
1473
- const until = Date.now() + ms;
1474
- while (Date.now() < until);
1475
- }
1476
- }
1477
- function renameWithWindowsRetry(from, to) {
1478
- for (let attempt = 0;; attempt++) try {
1479
- renameSync(from, to);
1480
- return;
1481
- } catch (err) {
1482
- const code = err?.code;
1483
- if (process.platform === "win32" && (code === "EBUSY" || code === "EPERM") && attempt < RENAME_RETRIES) {
1484
- sleepSync(RENAME_RETRY_DELAY_MS);
1485
- continue;
1486
- }
1487
- throw err;
1488
- }
1489
- }
1490
- const STALE_TEMP_AGE_MS = 864e5;
1491
- const TEMP_PID_RE = /^.+\.mjolnir-([1-9]\d{0,9})-(\d{13})-[0-9a-f]{8}\.tmp$/;
1492
- function sweepStaleTempFiles(dir) {
1493
- let swept = 0;
1494
- let entries;
1495
- try {
1496
- entries = readdirSync(dir);
1497
- } catch {
1498
- return 0;
1499
- }
1500
- const cutoff = Date.now() - STALE_TEMP_AGE_MS;
1501
- for (const entry of entries) {
1502
- const match = TEMP_PID_RE.exec(entry);
1503
- if (!match || Number(match[2]) > cutoff) continue;
1504
- const path = join(dir, entry);
1505
- try {
1506
- const stat = lstatSync(path);
1507
- if (!stat.isFile() || stat.mtimeMs > cutoff) continue;
1508
- if (pidAlive(Number(match[1]))) continue;
1509
- unlinkSync(path);
1510
- swept++;
1511
- } catch {
1512
- continue;
1513
- }
1514
- }
1515
- return swept;
1516
- }
1517
- function pidAlive(pid) {
1518
- if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 2147483647) return true;
1519
- if (pid === process.pid) return true;
1520
- try {
1521
- process.kill(pid, 0);
1522
- return true;
1523
- } catch (err) {
1524
- return err?.code !== "ESRCH";
1525
- }
1526
- }
1527
- //#endregion
1528
1424
  //#region src/engine/finding-identity.ts
1529
1425
  /**
1530
1426
  * Finding Identity (ENGINE-002): deterministic, stable fingerprints for
@@ -2029,12 +1925,15 @@ function trustVerdictFor(v) {
2029
1925
  * today the honest command set is repeat/compare/inspect, not
2030
1926
  * "collect a trace", because trace.zip is not yet ingested.
2031
1927
  */
1928
+ function safeTriageText(value) {
1929
+ return value.replace(/[;&|`$()\\\r\n]/g, " ").slice(0, 200);
1930
+ }
2032
1931
  function nextActionFor(v) {
2033
1932
  switch (classifyVerdict(v)) {
2034
- case "RETRY-DEPENDENT": return `repeat execution of this test (3+ runs) to confirm the flake rate, then quarantine + ticket: ${v.file}`;
2035
- case "TIMEOUT": return `re-run in isolation to separate slowness from a hang: npx playwright test ${v.file} --timeout 60000`;
2036
- case "FAILING": return `reproduce locally: npx playwright test ${v.file} -g "${v.title.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}" — then fix and re-run`;
2037
- case "SKIPPED": return `inspect the skip condition in ${v.file} — a skip after a failure usually hides an environment problem`;
1933
+ case "RETRY-DEPENDENT": return `repeat execution of this test (3+ runs) to confirm the flake rate, then quarantine + ticket: ${safeTriageText(v.file)}`;
1934
+ case "TIMEOUT": return `re-run this test in isolation with a 60-second timeout to separate slowness from a hang: ${safeTriageText(v.file)}`;
1935
+ case "FAILING": return `reproduce this test in isolation, then fix and re-run: ${safeTriageText(v.file)} — ${safeTriageText(v.title)}`;
1936
+ case "SKIPPED": return `inspect the skip condition in ${safeTriageText(v.file)} — a skip after a failure usually hides an environment problem`;
2038
1937
  }
2039
1938
  }
2040
1939
  /** Deterministic guided rows, worst first (same order law as triageRows). */
@@ -2068,7 +1967,7 @@ function renderTriageWorkflow(report) {
2068
1967
  }
2069
1968
  for (const r of rows) {
2070
1969
  const flag = r.classification === "RETRY-DEPENDENT" ? "TRUE-FLAKE" : r.classification === "FAILING" ? "FAILING" : void 0;
2071
- lines.push(`[${r.classification}${flag ? ` · ${flag}` : ""}] ${r.test} (${r.file})`);
1970
+ lines.push(`[${r.classification}${flag ? ` · ${flag}` : ""}] ${safeTriageText(r.test)} (${safeTriageText(r.file)})`);
2072
1971
  for (const e of r.evidence) lines.push(` evidence: ${e}`);
2073
1972
  lines.push(` trust: ${r.trustVerdict}`);
2074
1973
  lines.push(` next: ${r.nextAction}`);
@@ -2240,7 +2139,16 @@ function renderPwRunSummary(s) {
2240
2139
  */
2241
2140
  /** Hard caps (§21 threat model: parameter size + resource bounds). */
2242
2141
  const MAX_PARAM_BYTES = 65536;
2142
+ const MAX_DURATION_MS$1 = 36e5;
2143
+ const DEFAULT_MAX_DURATION_MS$1 = 6e5;
2243
2144
  const PROTOCOL_VERSION = "2025-06-18";
2145
+ const CONFIGURED_ROOT = process.env.MJOLNIR_WORKSPACE_ROOT ? resolve(process.env.MJOLNIR_WORKSPACE_ROOT) : null;
2146
+ function isPathAllowed(path) {
2147
+ if (!CONFIGURED_ROOT) return true;
2148
+ const candidate = resolve(path);
2149
+ const rel = relative(CONFIGURED_ROOT, candidate);
2150
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
2151
+ }
2244
2152
  /** JSON-RPC error codes (subset used here). */
2245
2153
  const MCP_ERRORS = {
2246
2154
  PARSE: -32700,
@@ -2359,7 +2267,10 @@ function validateParams(name, args) {
2359
2267
  if (Buffer$1.byteLength(JSON.stringify(args), "utf8") > MAX_PARAM_BYTES) return `parameters exceed ${MAX_PARAM_BYTES} bytes (threat model §21)`;
2360
2268
  if (name === "scan") {
2361
2269
  if (typeof args["path"] !== "string" || args["path"].length === 0) return "scan requires a non-empty string `path`";
2362
- if (args["maxDurationMs"] !== void 0 && typeof args["maxDurationMs"] !== "number") return "`maxDurationMs` must be a number";
2270
+ if (args["maxDurationMs"] !== void 0) {
2271
+ const duration = args["maxDurationMs"];
2272
+ if (typeof duration !== "number" || !Number.isFinite(duration) || duration <= 0 || duration > MAX_DURATION_MS$1) return "`maxDurationMs` must be a finite positive number within the server budget";
2273
+ }
2363
2274
  }
2364
2275
  if (name === "explain") {
2365
2276
  if (typeof args["ruleId"] !== "string" || !/^QA-[A-Z]+-\d{3}$/.test(args["ruleId"])) return "explain requires `ruleId` matching /^QA-[A-Z]+-\\d{3}$/";
@@ -2404,6 +2315,24 @@ async function handleToolCall(call) {
2404
2315
  message: paramError
2405
2316
  }
2406
2317
  };
2318
+ const pathArgument = call.args["path"];
2319
+ if (typeof pathArgument === "string" && !isPathAllowed(pathArgument)) return {
2320
+ jsonrpc: "2.0",
2321
+ id: call.id,
2322
+ error: {
2323
+ code: MCP_ERRORS.INVALID_PARAMS,
2324
+ message: "path is outside the configured workspace root"
2325
+ }
2326
+ };
2327
+ const fixturesRoot = call.args["fixturesRoot"];
2328
+ if (typeof fixturesRoot === "string" && !isPathAllowed(fixturesRoot)) return {
2329
+ jsonrpc: "2.0",
2330
+ id: call.id,
2331
+ error: {
2332
+ code: MCP_ERRORS.INVALID_PARAMS,
2333
+ message: "fixturesRoot is outside the configured workspace root"
2334
+ }
2335
+ };
2407
2336
  try {
2408
2337
  if (call.name === "scan") {
2409
2338
  const target = resolve(call.args["path"]);
@@ -2415,7 +2344,7 @@ async function handleToolCall(call) {
2415
2344
  message: `scan target does not exist: ${target}`
2416
2345
  }
2417
2346
  };
2418
- const maxDurationMs = typeof call.args["maxDurationMs"] === "number" ? call.args["maxDurationMs"] : Number.POSITIVE_INFINITY;
2347
+ const maxDurationMs = typeof call.args["maxDurationMs"] === "number" ? call.args["maxDurationMs"] : DEFAULT_MAX_DURATION_MS$1;
2419
2348
  const result = await serializeScan(() => runScan$1({
2420
2349
  target,
2421
2350
  json: true,
@@ -3547,6 +3476,7 @@ const TRUST_LABELS = Object.fromEntries(TRUST_RUNGS.map((r) => [r.level, `${r.le
3547
3476
  function trustHeadline(s) {
3548
3477
  if (s.level === "L5" || s.level === "L4") return s.confidence >= .75 ? "Run evidence backs these findings — trust them." : "Run evidence exists, but the scan was incomplete — verify the gaps.";
3549
3478
  if (s.level === "L3") return s.confidence >= .75 ? "The relevant files executed — solid static + runtime signal." : "Files executed, evidence is thin — treat findings as leads.";
3479
+ if (s.level === "L0") return "Static signal only, with no runtime evidence — treat as an observation, not a verdict.";
3550
3480
  if (s.confidence >= .75) return "Deterministic static analysis — trustworthy, uncorroborated by a run.";
3551
3481
  return "Static signal only, incomplete analysis — treat as leads, not verdicts.";
3552
3482
  }
@@ -3554,8 +3484,10 @@ function trustHeadline(s) {
3554
3484
  function trustReasons(result, s) {
3555
3485
  const reasons = [];
3556
3486
  const corroborated = result.findings.filter((f) => f.runtimeCorroboration !== void 0).length;
3557
- if (result.findings.length === 0) reasons.push(result.partial ? "No findings so far, but the scan did not finish — the gaps are not proof of cleanliness." : "No findings fired on the analyzed surface — the scan found nothing to report.");
3558
- else reasons.push(`${result.findings.length} finding(s) fired; ${corroborated} corroborated by a real run report.`);
3487
+ if (result.findings.length === 0) {
3488
+ const incomplete = result.partial || result.analysisStatus.discovery !== "complete" || result.analysisStatus.rules !== "complete" || s.level === "L0" && s.evidenceCoverage === 0;
3489
+ reasons.push(incomplete ? "No findings so far, but the scan or evidence is incomplete — the gaps are not proof of cleanliness." : "No findings fired on the analyzed surface — the scan found nothing to report.");
3490
+ } else reasons.push(`${result.findings.length} finding(s) fired; ${corroborated} corroborated by a real run report.`);
3559
3491
  if (s.confidenceCeiling !== void 0) reasons.push(`confidence capped at ${pct$1(s.confidenceCeiling)} by: ${s.ceilingReasons.join(", ")}.`);
3560
3492
  if (s.provisionalRuleIds.length > 0) reasons.push(`${s.provisionalRuleIds.length} fired rule(s) unmeasured (author-estimated FP) — disclosed, not counted.`);
3561
3493
  else if (s.measuredFpOfFiredRules !== void 0) reasons.push(`every fired rule carries a measured false-positive rate (evidence-weighted ${pct$1(s.measuredFpOfFiredRules)}).`);
@@ -3580,7 +3512,7 @@ function topTrustRisks(findings, n = 3) {
3580
3512
  }
3581
3513
  /** NEXT ACTION — one concrete command, deterministic. */
3582
3514
  function nextAction(result) {
3583
- if (result.partial) return "re-run with a higher --max-duration to close the truncated surface";
3515
+ if (result.partial || result.analysisStatus.discovery !== "complete" || result.analysisStatus.rules !== "complete" || (result.analysisStatus.rulesCrashed ?? 0) > 0) return "re-run with a higher --max-duration to close the truncated surface";
3584
3516
  const risks = topTrustRisks(result.findings, 1);
3585
3517
  if (risks.length === 0) return result.testDeclarationCount === 0 ? "add tests — a repo without test declarations has nothing to verify" : "no findings on the analyzed surface — install advisory CI (`mjolnir ci install`) and re-scan the changed scope";
3586
3518
  return `mjolnir explain ${risks[0]?.ruleId ?? ""} — then fix the top risk first`;
@@ -3993,6 +3925,258 @@ function currentCommit(root, opts = {}) {
3993
3925
  }
3994
3926
  }
3995
3927
  //#endregion
3928
+ //#region src/commands/report-io.ts
3929
+ /**
3930
+ * Shared saved-report loading (agent-handoff plan §9.0).
3931
+ *
3932
+ * `summary`, `handoff` and `why` all consume a saved `--json` report.
3933
+ * One loader, one validation, one error shape — previously
3934
+ * validateReportJson was private to summary.ts; extracting it keeps
3935
+ * the three commands byte-identical in their loading behavior without
3936
+ * duplication. The exit-code mapping stays in the callers (they own
3937
+ * their io), but the error MESSAGES are identical because they come
3938
+ * from here.
3939
+ */
3940
+ const MAX_REPORT_BYTES = 33554432;
3941
+ const MAX_FINDINGS = 1e4;
3942
+ const CONFIDENCE_VALUES = /* @__PURE__ */ new Set([
3943
+ "high",
3944
+ "medium",
3945
+ "low"
3946
+ ]);
3947
+ const FINDING_TYPE_VALUES = /* @__PURE__ */ new Set([
3948
+ "deterministic-defect",
3949
+ "heuristic-risk",
3950
+ "observation"
3951
+ ]);
3952
+ const QA_IMPACT_VALUES = /* @__PURE__ */ new Set([
3953
+ "BLOCKS-RELEASE",
3954
+ "FLAKY-RISK",
3955
+ "FALSE-GREEN",
3956
+ "HYGIENE"
3957
+ ]);
3958
+ const EVIDENCE_VALUES = /* @__PURE__ */ new Set([
3959
+ "E0",
3960
+ "E1",
3961
+ "E2"
3962
+ ]);
3963
+ const TRUST_VALUES = /* @__PURE__ */ new Set([
3964
+ "L0",
3965
+ "L1",
3966
+ "L2",
3967
+ "L3",
3968
+ "L4",
3969
+ "L5"
3970
+ ]);
3971
+ /** Human message for any thrown value — never "undefined"/"[object Object]". */
3972
+ function errorText$1(err) {
3973
+ if (err instanceof Error) return err.message;
3974
+ if (typeof err === "string") return err;
3975
+ if (typeof err === "object" && err !== null) return JSON.stringify(err);
3976
+ return String(err);
3977
+ }
3978
+ function isRecord$2(value) {
3979
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3980
+ }
3981
+ function fail(message) {
3982
+ throw new Error(message);
3983
+ }
3984
+ function requireRecord(value, label) {
3985
+ if (!isRecord$2(value)) fail(`${label} must be an object`);
3986
+ return value;
3987
+ }
3988
+ function requireString(value, label) {
3989
+ if (typeof value !== "string" || value.length === 0) fail(`${label} must be a non-empty string`);
3990
+ return value;
3991
+ }
3992
+ function requireBoolean(value, label) {
3993
+ if (typeof value !== "boolean") fail(`${label} must be a boolean`);
3994
+ return value;
3995
+ }
3996
+ function requireFiniteNumber(value, label) {
3997
+ if (typeof value !== "number" || !Number.isFinite(value)) fail(`${label} must be a finite number`);
3998
+ return value;
3999
+ }
4000
+ function requireInteger(value, label, minimum = 0) {
4001
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) fail(`${label} must be an integer >= ${minimum}`);
4002
+ return value;
4003
+ }
4004
+ function validateDimension(value, index) {
4005
+ const dimension = requireRecord(value, `dimensions[${index}]`);
4006
+ const category = requireString(dimension.category, `dimensions[${index}].category`);
4007
+ if (!RULE_CATEGORIES.includes(category)) fail(`dimensions[${index}].category is unknown`);
4008
+ const score = requireFiniteNumber(dimension.score, `dimensions[${index}].score`);
4009
+ if (score < 0 || score > 100) fail(`dimensions[${index}].score must be between 0 and 100`);
4010
+ requireInteger(dimension.errors, `dimensions[${index}].errors`);
4011
+ requireInteger(dimension.warnings, `dimensions[${index}].warnings`);
4012
+ requireInteger(dimension.infos, `dimensions[${index}].infos`);
4013
+ }
4014
+ function validateFinding(value, index) {
4015
+ const finding = requireRecord(value, `findings[${index}]`);
4016
+ requireString(finding.ruleId, `findings[${index}].ruleId`);
4017
+ const category = requireString(finding.category, `findings[${index}].category`);
4018
+ if (!RULE_CATEGORIES.includes(category)) fail(`findings[${index}].category is unknown`);
4019
+ const severity = requireString(finding.severity, `findings[${index}].severity`);
4020
+ if (!SEVERITY_ORDER$1.includes(severity)) fail(`findings[${index}].severity is unknown`);
4021
+ const confidence = requireString(finding.confidence, `findings[${index}].confidence`);
4022
+ if (!CONFIDENCE_VALUES.has(confidence)) fail(`findings[${index}].confidence is unknown`);
4023
+ const findingType = requireString(finding.findingType, `findings[${index}].findingType`);
4024
+ if (!FINDING_TYPE_VALUES.has(findingType)) fail(`findings[${index}].findingType is unknown`);
4025
+ const qaImpact = requireString(finding.qaImpact, `findings[${index}].qaImpact`);
4026
+ if (!QA_IMPACT_VALUES.has(qaImpact)) fail(`findings[${index}].qaImpact is unknown`);
4027
+ requireString(finding.file, `findings[${index}].file`);
4028
+ requireInteger(finding.line, `findings[${index}].line`, 1);
4029
+ requireInteger(finding.column, `findings[${index}].column`, 1);
4030
+ requireString(finding.message, `findings[${index}].message`);
4031
+ requireString(finding.why, `findings[${index}].why`);
4032
+ requireString(finding.fix, `findings[${index}].fix`);
4033
+ if (finding.evidenceLevel !== void 0 && (typeof finding.evidenceLevel !== "string" || !EVIDENCE_VALUES.has(finding.evidenceLevel))) fail(`findings[${index}].evidenceLevel is unknown`);
4034
+ if (finding.trustLevel !== void 0 && (typeof finding.trustLevel !== "string" || !TRUST_VALUES.has(finding.trustLevel))) fail(`findings[${index}].trustLevel is unknown`);
4035
+ if (finding.detectorRevision !== void 0) requireInteger(finding.detectorRevision, `findings[${index}].detectorRevision`, 1);
4036
+ if (finding.measuredFpRate !== void 0) {
4037
+ const rate = requireFiniteNumber(finding.measuredFpRate, `findings[${index}].measuredFpRate`);
4038
+ if (rate < 0 || rate > 1) fail(`findings[${index}].measuredFpRate must be between 0 and 1`);
4039
+ }
4040
+ if (finding.measuredFpN !== void 0) requireInteger(finding.measuredFpN, `findings[${index}].measuredFpN`);
4041
+ }
4042
+ function validateAnalysisStatus(value) {
4043
+ const status = requireRecord(value, "analysisStatus");
4044
+ if (status.discovery !== "complete" && status.discovery !== "partial") fail("analysisStatus.discovery must be \"complete\" or \"partial\"");
4045
+ if (status.rules !== "complete" && status.rules !== "partial") fail("analysisStatus.rules must be \"complete\" or \"partial\"");
4046
+ requireInteger(status.skippedFiles, "analysisStatus.skippedFiles");
4047
+ requireFiniteNumber(status.durationMs, "analysisStatus.durationMs");
4048
+ if (status.rulesCrashed !== void 0) requireInteger(status.rulesCrashed, "analysisStatus.rulesCrashed");
4049
+ if (status.parseFallbacks !== void 0) requireInteger(status.parseFallbacks, "analysisStatus.parseFallbacks");
4050
+ if (status.truncationReasons !== void 0) {
4051
+ if (!Array.isArray(status.truncationReasons)) fail("analysisStatus.truncationReasons must be an array");
4052
+ status.truncationReasons.forEach((reason, index) => requireString(reason, `analysisStatus.truncationReasons[${index}]`));
4053
+ }
4054
+ if (status.reasons !== void 0) {
4055
+ if (!Array.isArray(status.reasons)) fail("analysisStatus.reasons must be an array");
4056
+ status.reasons.forEach((reason, index) => requireString(reason, `analysisStatus.reasons[${index}]`));
4057
+ }
4058
+ }
4059
+ function validateScopeIntegrity(value) {
4060
+ const scope = requireRecord(value, "scopeIntegrity");
4061
+ requireInteger(scope.discovered, "scopeIntegrity.discovered");
4062
+ requireInteger(scope.analyzed, "scopeIntegrity.analyzed");
4063
+ requireInteger(scope.ignored, "scopeIntegrity.ignored");
4064
+ requireInteger(scope.unrecognized, "scopeIntegrity.unrecognized");
4065
+ requireInteger(scope.parseFailed, "scopeIntegrity.parseFailed");
4066
+ requireInteger(scope.truncated, "scopeIntegrity.truncated");
4067
+ if (scope.scopeVerdict !== "PROVEN" && scope.scopeVerdict !== "PARTIAL") fail("scopeIntegrity.scopeVerdict must be \"PROVEN\" or \"PARTIAL\"");
4068
+ if (scope.reasons !== void 0) {
4069
+ if (!Array.isArray(scope.reasons)) fail("scopeIntegrity.reasons must be an array");
4070
+ scope.reasons.forEach((reason, index) => requireString(reason, `scopeIntegrity.reasons[${index}]`));
4071
+ }
4072
+ }
4073
+ /**
4074
+ * Parse-and-validate a saved report. Throws Error with a
4075
+ * human-explanatory message on invalid JSON, shape, or contract values.
4076
+ */
4077
+ function validateReportJson(text) {
4078
+ if (Buffer.byteLength(text, "utf8") > MAX_REPORT_BYTES) fail(`report exceeds the ${MAX_REPORT_BYTES}-byte limit`);
4079
+ let parsed;
4080
+ try {
4081
+ parsed = JSON.parse(text);
4082
+ } catch (err) {
4083
+ throw new Error(`not valid JSON (${errorText$1(err)})`, { cause: err });
4084
+ }
4085
+ const doc = requireRecord(parsed, "the report is not an object");
4086
+ if (doc.schemaVersion !== 1) fail(`unsupported schemaVersion ${JSON.stringify(doc.schemaVersion)} — expected schemaVersion 1`);
4087
+ requireBoolean(doc.partial, "partial");
4088
+ if (doc.score !== null) {
4089
+ const score = requireFiniteNumber(doc.score, "score");
4090
+ if (score < 0 || score > 100) fail("score must be null or between 0 and 100");
4091
+ }
4092
+ if (doc.reason !== void 0 && doc.reason !== "no-tests-found") fail("reason must be \"no-tests-found\" when present");
4093
+ if (doc.frameworks === void 0) fail("missing a \"frameworks\" array — is this a complete Mjölnir --json report?");
4094
+ if (!Array.isArray(doc.frameworks) || doc.frameworks.some((value) => typeof value !== "string")) fail("frameworks must be an array of strings");
4095
+ requireBoolean(doc.frameworkDetectionUnknown, "frameworkDetectionUnknown");
4096
+ if (!Array.isArray(doc.dimensions)) fail("dimensions must be an array");
4097
+ doc.dimensions.forEach(validateDimension);
4098
+ if (!Array.isArray(doc.findings)) fail("missing a \"findings\" array — is this a Mjölnir --json report?");
4099
+ if (doc.findings.length > MAX_FINDINGS) fail(`findings exceeds the ${MAX_FINDINGS}-finding limit`);
4100
+ doc.findings.forEach(validateFinding);
4101
+ validateAnalysisStatus(doc.analysisStatus);
4102
+ if (doc.scopeIntegrity !== void 0) validateScopeIntegrity(doc.scopeIntegrity);
4103
+ const status = requireRecord(doc.analysisStatus, "analysisStatus");
4104
+ const partialMarkers = status["discovery"] !== "complete" || status["rules"] !== "complete" || Number(status["skippedFiles"]) > 0 || Number(status["rulesCrashed"] ?? 0) > 0 || Array.isArray(status["reasons"]) && status["reasons"].length > 0;
4105
+ if (doc.partial === false && partialMarkers) fail("partial=false conflicts with analysisStatus");
4106
+ if (doc.partial === true && typeof doc.score === "number" && doc.score === 100) fail("partial reports cannot have a perfect score");
4107
+ if ((isRecord$2(doc.scopeIntegrity) ? doc.scopeIntegrity : void 0)?.["scopeVerdict"] === "PARTIAL" && doc.partial === false) fail("partial=false conflicts with scopeIntegrity");
4108
+ return parsed;
4109
+ }
4110
+ /** Load + validate a saved report from disk. Throws on any problem. */
4111
+ function loadSavedReport(reportPath) {
4112
+ const stat = statSync(reportPath);
4113
+ if (!stat.isFile()) fail(`report path is not a file: ${reportPath}`);
4114
+ if (stat.size > MAX_REPORT_BYTES) fail(`report exceeds the ${MAX_REPORT_BYTES}-byte limit`);
4115
+ return validateReportJson(readFileSync(reportPath, "utf8"));
4116
+ }
4117
+ /** True when the report file exists (callers own the not-found message). */
4118
+ function reportExists(reportPath) {
4119
+ return existsSync(reportPath);
4120
+ }
4121
+ //#endregion
4122
+ //#region src/integrations/github/evidence-sanitization.ts
4123
+ const MAX_MARKDOWN_TEXT_LENGTH = 500;
4124
+ /**
4125
+ * Characters that have special meaning in Markdown and must be escaped
4126
+ * to render as literal text.
4127
+ */
4128
+ const MARKDOWN_SPECIAL_RE = /[[\]*_`~\\|{}()<>#!]/g;
4129
+ /**
4130
+ * HTML tags and entities that could enable injection.
4131
+ */
4132
+ const HTML_INJECTION_RE = /<[^>]*>/g;
4133
+ const HTML_ENTITY_RE = /&\w+;|&#\d+;|&#x[\da-f]+;/gi;
4134
+ /**
4135
+ * Patterns that could form malformed or malicious links.
4136
+ */
4137
+ const MALFORMED_LINK_PATTERNS = [/\]\s*\(\s*(?:javascript|data|vbscript)\s*:/gi, /\]\s*\(\s*[^)]{0,200}<[^)]{0,200}>/g];
4138
+ const RTL_OVERRIDE_RE = /[\u200f\u202e\u2067\u2066\u2069\u2068]/g;
4139
+ /**
4140
+ * Escape Markdown special characters in text so they render as literals.
4141
+ */
4142
+ function escapeMarkdownChars(text) {
4143
+ return text.replace(MARKDOWN_SPECIAL_RE, "\\$&");
4144
+ }
4145
+ /**
4146
+ * Strip HTML tags and entities to prevent injection.
4147
+ */
4148
+ function stripHtml(text) {
4149
+ return text.replace(HTML_ENTITY_RE, "").replace(HTML_INJECTION_RE, "");
4150
+ }
4151
+ /**
4152
+ * Neutralize potential malformed link patterns.
4153
+ */
4154
+ function neutralizeMalformedLinks(text) {
4155
+ let result = text;
4156
+ for (const pattern of MALFORMED_LINK_PATTERNS) result = result.replace(pattern, (match) => `[sanitized: ${match.slice(0, 40)}]`);
4157
+ return result;
4158
+ }
4159
+ /**
4160
+ * Strip RTL override characters that could confuse display.
4161
+ */
4162
+ function stripRtlOverrides(text) {
4163
+ return text.replace(RTL_OVERRIDE_RE, "");
4164
+ }
4165
+ /**
4166
+ * Sanitize arbitrary text for safe inclusion in Markdown.
4167
+ * Escapes special chars, strips HTML, prevents link injection,
4168
+ * strips RTL overrides, and caps length.
4169
+ */
4170
+ function sanitizeForMarkdown(text) {
4171
+ let result = text;
4172
+ result = stripHtml(result);
4173
+ result = stripRtlOverrides(result);
4174
+ result = neutralizeMalformedLinks(result);
4175
+ result = escapeMarkdownChars(result);
4176
+ if (result.length > MAX_MARKDOWN_TEXT_LENGTH) result = result.slice(0, MAX_MARKDOWN_TEXT_LENGTH) + "\\.\\.\\.";
4177
+ return result;
4178
+ }
4179
+ //#endregion
3996
4180
  //#region src/commands/trust-report.ts
3997
4181
  /**
3998
4182
  * `mjolnir trust-report` — the Trust Artifact (Mega MVP Master Plan
@@ -4033,7 +4217,23 @@ function fallbackSummary(result) {
4033
4217
  }
4034
4218
  /** HTML-escape a text interpolation (the artifact is hostile-input-safe). */
4035
4219
  function esc(text) {
4036
- return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
4220
+ return [...text].filter((character) => {
4221
+ const code = character.codePointAt(0) ?? 0;
4222
+ return !(code <= 31 || code >= 127 && code <= 159 || code === 8207 || code === 8238 || code >= 8294 && code <= 8297);
4223
+ }).join("").slice(0, 1e3).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
4224
+ }
4225
+ function publicText(text) {
4226
+ return sanitizeForMarkdown(sanitizeErrorText(text, { maxLength: 1e3 }));
4227
+ }
4228
+ function publicJsonText(text) {
4229
+ return sanitizeErrorText(text, { maxLength: 1e3 });
4230
+ }
4231
+ function writeArtifact(path, content) {
4232
+ try {
4233
+ writeFileAtomic(path, content, { encoding: "utf8" });
4234
+ } catch (err) {
4235
+ throw new Error(`failed to write artifact ${path}: ${errorMessage(err)}`, { cause: err });
4236
+ }
4037
4237
  }
4038
4238
  function buildArtifactIdentity(result, commit) {
4039
4239
  const revisions = /* @__PURE__ */ new Map();
@@ -4061,13 +4261,13 @@ function buildArtifactIdentity(result, commit) {
4061
4261
  }
4062
4262
  /** The MD identity section lines (§9 R9). */
4063
4263
  function identityLinesMd(identity) {
4064
- const revs = identity.detectorRevisions.map((r) => `\`${r.ruleId}@${r.detectorRevision}\``).join(" · ");
4264
+ const revs = identity.detectorRevisions.map((r) => `\`${sanitizeForMarkdown(r.ruleId)}@${r.detectorRevision}\``).join(" · ");
4065
4265
  const levels = Object.entries(identity.evidenceInventory.byEvidenceLevel).map(([level, n]) => `${level}:${n}`).join(" · ");
4066
4266
  return [
4067
4267
  `## Artifact integrity`,
4068
4268
  "",
4069
- `- **scanId**: ${identity.scanId ?? "unbound (pre-R9 producer — regenerate)"}`,
4070
- `- **Commit**: ${identity.commit ?? "unknown (not bound)"}`,
4269
+ `- **scanId**: ${identity.scanId ? sanitizeForMarkdown(identity.scanId) : "unbound (pre-R9 producer — regenerate)"}`,
4270
+ `- **Commit**: ${identity.commit ? sanitizeForMarkdown(identity.commit) : "unknown (not bound)"}`,
4071
4271
  `- **Rule(rev) inventory**: ${revs === "" ? "none fired" : revs}`,
4072
4272
  `- **Evidence inventory**: ${identity.evidenceInventory.totalFindings} finding(s), ${identity.evidenceInventory.corroborated} runtime-corroborated, by level ${levels === "" ? "none" : levels}.`,
4073
4273
  ""
@@ -4079,7 +4279,7 @@ function renderTrustReportMarkdown(result, label, commit) {
4079
4279
  const lines = [];
4080
4280
  lines.push(`<!-- mjolnir-trust-report:v1 -->`);
4081
4281
  lines.push("");
4082
- lines.push(`# Mjölnir Trust Report — ${label}`);
4282
+ lines.push(`# Mjölnir Trust Report — ${publicText(label)}`);
4083
4283
  lines.push("");
4084
4284
  lines.push(`> Tests tell you what passed. Mjölnir tells you what you can trust.`);
4085
4285
  lines.push("");
@@ -4100,7 +4300,7 @@ function renderTrustReportMarkdown(result, label, commit) {
4100
4300
  lines.push(`| Tests analyzed | ${result.testDeclarationCount ?? 0} in ${result.testFileCount ?? 0} files |`);
4101
4301
  lines.push("");
4102
4302
  if (s.ceilingReasons.length > 0) {
4103
- lines.push(`Incompleteness factors: ${s.ceilingReasons.join(", ")}.`);
4303
+ lines.push(`Incompleteness factors: ${s.ceilingReasons.map(publicText).join(", ")}.`);
4104
4304
  lines.push("");
4105
4305
  }
4106
4306
  lines.push(`## Top trust risks`);
@@ -4112,7 +4312,7 @@ function renderTrustReportMarkdown(result, label, commit) {
4112
4312
  lines.push(`| ---- | -------- | -------- | ------- |`);
4113
4313
  for (const f of risks) {
4114
4314
  const ev = evidenceTag(f);
4115
- lines.push(`| ${f.ruleId} | ${f.file}:${f.line} | ${ev} | ${f.message.replaceAll("|", "\\|")} |`);
4315
+ lines.push(`| ${publicText(f.ruleId)} | ${publicText(f.file)}:${f.line} | ${ev} | ${publicText(f.message)} |`);
4116
4316
  }
4117
4317
  }
4118
4318
  lines.push("");
@@ -4126,7 +4326,7 @@ function renderTrustReportMarkdown(result, label, commit) {
4126
4326
  lines.push("");
4127
4327
  lines.push(`## Next action`);
4128
4328
  lines.push("");
4129
- lines.push(nextAction(result));
4329
+ lines.push(publicText(nextAction(result)));
4130
4330
  lines.push("");
4131
4331
  lines.push(...identityLinesMd(buildArtifactIdentity(result, commit)));
4132
4332
  lines.push(`---`);
@@ -4138,6 +4338,7 @@ function renderTrustReportMarkdown(result, label, commit) {
4138
4338
  /** Deterministic JSON artifact (agent-consumable twin of the MD). */
4139
4339
  function renderTrustReportJson(result, commit) {
4140
4340
  const s = fallbackSummary(result);
4341
+ const completionVerdict = result.partial || result.analysisStatus.discovery !== "complete" || result.analysisStatus.rules !== "complete" || (result.analysisStatus.rulesCrashed ?? 0) > 0 ? "INCOMPLETE" : result.score === null ? "UNKNOWN" : "COMPLETE";
4141
4342
  return JSON.stringify({
4142
4343
  schemaVersion: 1,
4143
4344
  artifact: "mjolnir-trust-report",
@@ -4154,6 +4355,10 @@ function renderTrustReportJson(result, commit) {
4154
4355
  ceilingReasons: s.ceilingReasons
4155
4356
  },
4156
4357
  score: result.score,
4358
+ partial: result.partial,
4359
+ analysisStatus: result.analysisStatus,
4360
+ scopeIntegrity: result.scopeIntegrity ?? null,
4361
+ verdict: completionVerdict,
4157
4362
  tests: {
4158
4363
  files: result.testFileCount ?? 0,
4159
4364
  declarations: result.testDeclarationCount ?? 0
@@ -4166,14 +4371,14 @@ function renderTrustReportJson(result, commit) {
4166
4371
  advisory: result.findings.filter((f) => isAdvisoryFinding(f)).length
4167
4372
  },
4168
4373
  topTrustRisks: topTrustRisks(result.findings, 10).map((f) => ({
4169
- ruleId: f.ruleId,
4170
- file: f.file,
4374
+ ruleId: publicJsonText(f.ruleId),
4375
+ file: publicJsonText(f.file),
4171
4376
  line: f.line,
4172
4377
  severity: f.severity,
4173
4378
  evidence: f.runtimeCorroboration === void 0 ? f.evidenceLevel ?? "E2" : f.runtimeCorroboration.level,
4174
- message: f.message
4379
+ message: publicJsonText(f.message)
4175
4380
  })),
4176
- nextAction: nextAction(result)
4381
+ nextAction: publicJsonText(nextAction(result))
4177
4382
  }, null, 2) + "\n";
4178
4383
  }
4179
4384
  /**
@@ -4290,26 +4495,21 @@ async function runTrustReportCommand(argv, io) {
4290
4495
  io.err("error: --commit requires the run's HEAD sha");
4291
4496
  return 10;
4292
4497
  }
4293
- let raw;
4294
- try {
4295
- raw = readFileSync(resolve(fromPath), "utf8");
4296
- } catch (err) {
4297
- io.err(`error: cannot read ${fromPath}: ${errorMessage(err)}`);
4298
- return 10;
4299
- }
4300
- let parsed;
4498
+ let scan;
4301
4499
  try {
4302
- parsed = JSON.parse(raw);
4500
+ scan = loadSavedReport(resolve(fromPath));
4303
4501
  } catch (err) {
4304
4502
  io.err(`error: cannot read ${fromPath}: ${errorMessage(err)}`);
4305
4503
  return 10;
4306
4504
  }
4307
- if (typeof parsed !== "object" || parsed === null || parsed.schemaVersion !== 1) {
4308
- io.err(`error: ${fromPath} is not a canonical mjolnir scan result (schemaVersion 1)`);
4505
+ if (commitArg && scan.runIdentity?.commit && scan.runIdentity.commit !== commitArg) {
4506
+ io.err(`error: report commit ${scan.runIdentity.commit} does not match --commit ${commitArg}`);
4309
4507
  return 10;
4310
4508
  }
4311
- const { contract: _contract, ...rest } = parsed;
4312
- const scan = rest;
4509
+ if (commitArg && scan.runIdentity) scan.runIdentity = {
4510
+ ...scan.runIdentity,
4511
+ commit: commitArg
4512
+ };
4313
4513
  const md = renderTrustReportMarkdown(scan, fromPath, commitArg ?? null);
4314
4514
  if (argv.includes("--stdout")) {
4315
4515
  io.out(md);
@@ -4319,9 +4519,9 @@ async function runTrustReportCommand(argv, io) {
4319
4519
  const outPath = resolve(dirname(fromPath), TRUST_REPORT_MD);
4320
4520
  const html = renderTrustReportHtml(scan, fromPath, commitArg ?? null);
4321
4521
  const json = renderTrustReportJson(scan, commitArg ?? null);
4322
- writeFileSync(outPath, md);
4323
- writeFileSync(resolve(dirname(fromPath), TRUST_REPORT_HTML), html);
4324
- writeFileSync(resolve(dirname(fromPath), TRUST_REPORT_JSON), json);
4522
+ writeArtifact(outPath, md);
4523
+ writeArtifact(resolve(dirname(fromPath), TRUST_REPORT_HTML), html);
4524
+ writeArtifact(resolve(dirname(fromPath), TRUST_REPORT_JSON), json);
4325
4525
  io.out(`trust report written: ${outPath} (plus ${TRUST_REPORT_HTML}, ${TRUST_REPORT_JSON} — same identity binding)`);
4326
4526
  return 0;
4327
4527
  } catch (err) {
@@ -4340,17 +4540,21 @@ async function runTrustReportCommand(argv, io) {
4340
4540
  target,
4341
4541
  json: true,
4342
4542
  verbose: false,
4343
- maxDurationMs: Number.POSITIVE_INFINITY,
4543
+ maxDurationMs: 6e5,
4344
4544
  scopeChanged: false,
4345
4545
  format: "json"
4346
4546
  });
4347
4547
  const commit = currentCommit(target, { nullable: true });
4548
+ if (commit && result.runIdentity) result.runIdentity = {
4549
+ ...result.runIdentity,
4550
+ commit
4551
+ };
4348
4552
  const md = renderTrustReportMarkdown(result, targetArg, commit);
4349
4553
  const json = renderTrustReportJson(result, commit);
4350
4554
  const html = renderTrustReportHtml(result, targetArg, commit);
4351
- writeFileSync(join(target, TRUST_REPORT_MD), md);
4352
- writeFileSync(join(target, TRUST_REPORT_JSON), json);
4353
- writeFileSync(join(target, TRUST_REPORT_HTML), html);
4555
+ writeArtifact(join(target, TRUST_REPORT_MD), md);
4556
+ writeArtifact(join(target, TRUST_REPORT_JSON), json);
4557
+ writeArtifact(join(target, TRUST_REPORT_HTML), html);
4354
4558
  io.out(`trust report written: ${TRUST_REPORT_MD}, ${TRUST_REPORT_JSON}, ${TRUST_REPORT_HTML} (deterministic — same scan, same bytes)`);
4355
4559
  return 0;
4356
4560
  } catch (err) {
@@ -4391,10 +4595,10 @@ function findingLineMd(f) {
4391
4595
  return `${icon} **${escMd(f.ruleId)}** \`${safePath}:${f.line}\` — ${escMd(f.message)} ${escMd(`[${evidenceTagLine(f)}]`)}\n Fix: ${fixBody}`;
4392
4596
  }
4393
4597
  /** Score cell with color-coded emoji and optional delta. */
4394
- function renderScoreBadge(score, delta) {
4598
+ function renderScoreBadge(score, delta, verdictOverride) {
4395
4599
  if (score === null) return "### Score\n*n/a*";
4396
4600
  deriveScoreState(score);
4397
- let text = `**${score}**/100 ${verdictFor(score)}`;
4601
+ let text = `**${score}**/100 ${verdictOverride ?? verdictFor(score)}`;
4398
4602
  if (delta !== void 0 && delta !== 0) text += ` (${delta > 0 ? "+" : ""}${delta})`;
4399
4603
  return `### Score\n${text}`;
4400
4604
  }
@@ -4462,6 +4666,10 @@ function renderArtifactIntegrity(identity) {
4462
4666
  lines.push("</details>");
4463
4667
  return lines;
4464
4668
  }
4669
+ function isIncompleteResult(result) {
4670
+ const status = result.analysisStatus;
4671
+ return result.partial || status === void 0 || status.discovery !== "complete" || status.rules !== "complete" || (status.rulesCrashed ?? 0) > 0 || result.scopeIntegrity?.scopeVerdict === "PARTIAL";
4672
+ }
4465
4673
  /**
4466
4674
  * Render the unified "Mjölnir Verification Report" PR comment.
4467
4675
  *
@@ -4479,7 +4687,8 @@ function renderUnifiedReport(result, options = {}) {
4479
4687
  provisionalRuleIds: [],
4480
4688
  ceilingReasons: []
4481
4689
  };
4482
- lines.push("## <img width=\"16\" src=\"https://raw.githubusercontent.com/Sergey-Bar/Mjolnir/main/assets/mjolnir-icon.svg\"/> Mjölnir Verification Report");
4690
+ const incomplete = isIncompleteResult(result) || !result.trustSummary || summary.level === "L0" && summary.evidenceCoverage === 0;
4691
+ lines.push("## <img width=\"16\" alt=\"Mjölnir\" src=\"https://raw.githubusercontent.com/Sergey-Bar/Mjolnir/main/assets/mjolnir-icon.svg\"/> Mjölnir Verification Report");
4483
4692
  lines.push("");
4484
4693
  lines.push("> Tests tell you what passed. Mjölnir tells you what you can trust.");
4485
4694
  lines.push("");
@@ -4487,6 +4696,11 @@ function renderUnifiedReport(result, options = {}) {
4487
4696
  lines.push("_Scope: only the lines this PR changed._");
4488
4697
  lines.push("");
4489
4698
  }
4699
+ if (incomplete) {
4700
+ const reasons = result.analysisStatus?.reasons ?? [];
4701
+ lines.push(`> **Analysis status: INCOMPLETE.** ${reasons.length > 0 ? reasons.map((reason) => escMd(reason)).join(", ") : "The scan did not complete a trustworthy analysis."}`);
4702
+ lines.push("");
4703
+ }
4490
4704
  const diff = options.diff;
4491
4705
  const usingDiff = diff?.hasBaseline === true;
4492
4706
  const findings = usingDiff ? diff.newFindings : result.findings;
@@ -4494,7 +4708,7 @@ function renderUnifiedReport(result, options = {}) {
4494
4708
  const warningCount = findings.filter((f) => f.severity === "warning").length;
4495
4709
  const baseScore = diff?.baselineScore;
4496
4710
  const delta = baseScore !== void 0 && result.score !== null ? result.score - baseScore : void 0;
4497
- lines.push("<table>");
4711
+ lines.push("<table role=\"presentation\">");
4498
4712
  lines.push("<tr>");
4499
4713
  lines.push("<td width=\"130\">");
4500
4714
  lines.push("");
@@ -4503,7 +4717,7 @@ function renderUnifiedReport(result, options = {}) {
4503
4717
  lines.push("</td>");
4504
4718
  lines.push("<td width=\"130\">");
4505
4719
  lines.push("");
4506
- lines.push(renderScoreBadge(result.score, delta));
4720
+ lines.push(renderScoreBadge(result.score, delta, incomplete ? "INCOMPLETE" : void 0));
4507
4721
  lines.push("");
4508
4722
  lines.push("</td>");
4509
4723
  lines.push("<td width=\"130\">");
@@ -4521,7 +4735,7 @@ function renderUnifiedReport(result, options = {}) {
4521
4735
  lines.push("");
4522
4736
  const state = deriveScoreState(result.score);
4523
4737
  const headlineFindings = usingDiff ? findings.length : result.findings.length;
4524
- lines.push(`**Headline:** ${headlineFor(state, headlineFindings)}`);
4738
+ lines.push(incomplete ? "**Headline:** Analysis is incomplete; this report is not a clean or worthy verdict." : `**Headline:** ${headlineFor(state, headlineFindings)}`);
4525
4739
  lines.push("");
4526
4740
  lines.push("---");
4527
4741
  lines.push("");
@@ -4534,7 +4748,7 @@ function renderUnifiedReport(result, options = {}) {
4534
4748
  lines.push("");
4535
4749
  }
4536
4750
  if (findings.length === 0) {
4537
- lines.push("✅ No new findings in this PR's changes.");
4751
+ lines.push(incomplete ? "⚠️ No findings were observed, but the analysis is incomplete; this is not proof of cleanliness." : usingDiff ? "✅ No new findings in this PR's changes." : "✅ No new findings in the analyzed surface.");
4538
4752
  lines.push("");
4539
4753
  } else {
4540
4754
  const errors = findings.filter((f) => f.severity === "error");
@@ -4599,7 +4813,7 @@ function renderUnifiedReport(result, options = {}) {
4599
4813
  lines.push("```");
4600
4814
  lines.push("");
4601
4815
  const repoLink = options.repoUrl ? ` — ${options.repoUrl}` : "";
4602
- lines.push(`<sub>Posted by GitHub Actions${repoLink} — blocks merging when the CI gate reports findings at the configured severity.</sub>`);
4816
+ lines.push(incomplete ? `<sub>Posted by GitHub Actions${repoLink} — analysis incomplete; no release verdict is claimed.</sub>` : `<sub>Posted by GitHub Actions${repoLink} — blocks merging when the CI gate reports findings at the configured severity.</sub>`);
4603
4817
  return lines.join("\n");
4604
4818
  }
4605
4819
  //#endregion
@@ -4626,57 +4840,6 @@ function renderPrComment(result, options = {}) {
4626
4840
  });
4627
4841
  }
4628
4842
  //#endregion
4629
- //#region src/commands/report-io.ts
4630
- /**
4631
- * Shared saved-report loading (agent-handoff plan §9.0).
4632
- *
4633
- * `summary`, `handoff` and `why` all consume a saved `--json` report.
4634
- * One loader, one validation, one error shape — previously
4635
- * validateReportJson was private to summary.ts; extracting it keeps
4636
- * the three commands byte-identical in their loading behavior without
4637
- * duplication. The exit-code mapping stays in the callers (they own
4638
- * their io), but the error MESSAGES are identical because they come
4639
- * from here.
4640
- */
4641
- /** Human message for any thrown value — never "undefined"/"[object Object]". */
4642
- function errorText$1(err) {
4643
- if (err instanceof Error) return err.message;
4644
- if (typeof err === "string") return err;
4645
- if (typeof err === "object" && err !== null) return JSON.stringify(err);
4646
- return String(err);
4647
- }
4648
- /**
4649
- * Parse-and-validate a saved report. Throws Error with a
4650
- * human-explanatory message on: invalid JSON, non-object document,
4651
- * wrong schemaVersion, missing findings array, missing frameworks
4652
- * array (certification F1: a schema-1 report is MORE than
4653
- * `{schemaVersion, findings}` — summary/handoff/why read
4654
- * `result.frameworks`, so a schema-incomplete stub must be rejected
4655
- * HERE, by the one shared loader, not crash downstream).
4656
- */
4657
- function validateReportJson(text) {
4658
- let parsed;
4659
- try {
4660
- parsed = JSON.parse(text);
4661
- } catch (err) {
4662
- throw new Error(`not valid JSON (${errorText$1(err)})`, { cause: err });
4663
- }
4664
- if (typeof parsed !== "object" || parsed === null) throw new Error("the file is a JSON value but not an object");
4665
- const doc = parsed;
4666
- if (doc.schemaVersion !== 1) throw new Error(`unsupported schemaVersion ${JSON.stringify(doc.schemaVersion)} — expected 1`);
4667
- if (!Array.isArray(doc.findings)) throw new Error("missing a \"findings\" array — is this a Mjölnir --json report?");
4668
- if (!Array.isArray(doc.frameworks)) throw new Error("missing a \"frameworks\" array — is this a complete Mjölnir --json report?");
4669
- return parsed;
4670
- }
4671
- /** Load + validate a saved report from disk. Throws on any problem. */
4672
- function loadSavedReport(reportPath) {
4673
- return validateReportJson(readFileSync(reportPath, "utf8"));
4674
- }
4675
- /** True when the report file exists (callers own the not-found message). */
4676
- function reportExists(reportPath) {
4677
- return existsSync(reportPath);
4678
- }
4679
- //#endregion
4680
4843
  //#region src/commands/why.ts
4681
4844
  /**
4682
4845
  * `mjolnir why <file>:<line>` — occurrence-level evidence/explanation
@@ -5108,22 +5271,11 @@ function buildBadge(result, commit) {
5108
5271
  * (the falsifiable claim: "verified at commit X on date Y").
5109
5272
  */
5110
5273
  function renderBadgeSnippet(result, repoUrl = "https://github.com/Sergey-Bar/Mjolnir") {
5111
- let commit = "unknown";
5112
- try {
5113
- commit = execFileSync("git", [
5114
- "rev-parse",
5115
- "--short",
5116
- "HEAD"
5117
- ], {
5118
- cwd: process.cwd(),
5119
- encoding: "utf8",
5120
- stdio: [
5121
- "ignore",
5122
- "pipe",
5123
- "ignore"
5124
- ]
5125
- }).trim();
5126
- } catch {}
5274
+ const commit = runGit(process.cwd(), [
5275
+ "rev-parse",
5276
+ "--short",
5277
+ "HEAD"
5278
+ ])?.trim() ?? "unknown";
5127
5279
  const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
5128
5280
  const errors = result.findings.filter((f) => f.severity === "error").length;
5129
5281
  return [
@@ -5378,7 +5530,7 @@ function createRuleScaffold(input, rootDir) {
5378
5530
  };
5379
5531
  mkdirSync(join(rootDir, "src", "rules", parsed.dir), { recursive: true });
5380
5532
  const files = [];
5381
- writeFileAtomic(absRule, `/**
5533
+ const ruleSrc = `/**
5382
5534
  * ${input.id} — ${input.title}.
5383
5535
  *
5384
5536
  * TODO(implement): replace the placeholder below. The rule currently
@@ -5404,7 +5556,8 @@ export const ${camel(parsed.lower)} = defineRule({
5404
5556
  return findings;
5405
5557
  },
5406
5558
  });
5407
- `);
5559
+ `;
5560
+ writeFileAtomic(absRule, ruleSrc);
5408
5561
  files.push(ruleRel);
5409
5562
  for (const direction of ["must-fire", "must-not-fire"]) {
5410
5563
  const dirRel = join("tests", "fixtures", input.id, direction);
@@ -5550,13 +5703,15 @@ function renderHandover(map) {
5550
5703
  * tests/privacy-network-isolation.spec.ts, which scans this file too).
5551
5704
  */
5552
5705
  const ui$13 = plainContext();
5553
- /** The S1-resolved absolute git binary, or the bare name to fail on. */
5706
+ /** The S1-resolved absolute git binary, or null when none is available. */
5554
5707
  function gitExe() {
5555
- return resolveGitPath() ?? "git";
5708
+ return resolveGitPath();
5556
5709
  }
5557
5710
  function git(root, args) {
5711
+ const executable = gitExe();
5712
+ if (!executable) return null;
5558
5713
  try {
5559
- return execFileSync(gitExe(), [
5714
+ return execFileSync(executable, [
5560
5715
  "-C",
5561
5716
  root,
5562
5717
  ...args
@@ -5575,8 +5730,10 @@ function git(root, args) {
5575
5730
  }
5576
5731
  /** Like git(), but returns the raw bytes — no utf8 decode round-trip. */
5577
5732
  function gitBuffer(root, args) {
5733
+ const executable = gitExe();
5734
+ if (!executable) return null;
5578
5735
  try {
5579
- return execFileSync(gitExe(), [
5736
+ return execFileSync(executable, [
5580
5737
  "-C",
5581
5738
  root,
5582
5739
  ...args
@@ -5610,7 +5767,16 @@ async function computeImpact(root, options) {
5610
5767
  "HEAD",
5611
5768
  options.baseBranch ?? "main"
5612
5769
  ])?.trim() ?? void 0;
5613
- else baseRef = git(root, ["rev-parse", baseRef])?.trim() ?? baseRef;
5770
+ else {
5771
+ baseRef = git(root, ["rev-parse", baseRef])?.trim();
5772
+ if (!baseRef) return {
5773
+ hasComparison: false,
5774
+ unknownReason: "base-ref-invalid",
5775
+ resolved: [],
5776
+ introduced: [],
5777
+ unknownFacts
5778
+ };
5779
+ }
5614
5780
  if (!baseRef) return {
5615
5781
  hasComparison: false,
5616
5782
  unknownReason: "no-prior-commit",
@@ -5618,7 +5784,15 @@ async function computeImpact(root, options) {
5618
5784
  introduced: [],
5619
5785
  unknownFacts
5620
5786
  };
5621
- const headRef = git(root, ["rev-parse", "HEAD"])?.trim() ?? "HEAD";
5787
+ const headRef = git(root, ["rev-parse", "HEAD"])?.trim();
5788
+ if (!headRef) return {
5789
+ hasComparison: false,
5790
+ unknownReason: "head-ref-invalid",
5791
+ baseRef,
5792
+ resolved: [],
5793
+ introduced: [],
5794
+ unknownFacts
5795
+ };
5622
5796
  if (baseRef === headRef) return {
5623
5797
  hasComparison: false,
5624
5798
  unknownReason: options.since ? "base-equals-head" : "no-prior-commit",
@@ -5979,7 +6153,7 @@ function tryReadPackageJson(rootDir) {
5979
6153
  const p = join(rootDir, "package.json");
5980
6154
  if (!existsSync(p)) return null;
5981
6155
  try {
5982
- return parseJsonFile(readFileSync(p, "utf8"), p, isRecord$2);
6156
+ return parseJsonFile(readFileSync(p, "utf8"), p, isRecord$3);
5983
6157
  } catch {
5984
6158
  return null;
5985
6159
  }
@@ -7217,6 +7391,33 @@ async function runPrCommentCommand(argv, io = {
7217
7391
  out,
7218
7392
  err
7219
7393
  }) {
7394
+ const fromIdx = argv.indexOf("--from");
7395
+ if (fromIdx !== -1) {
7396
+ const fromPath = argv[fromIdx + 1];
7397
+ if (!fromPath || fromPath.startsWith("-")) {
7398
+ io.err("error: --from requires a saved report path");
7399
+ return 10;
7400
+ }
7401
+ try {
7402
+ const saved = loadSavedReport(resolve(fromPath));
7403
+ const option = (flag) => {
7404
+ const index = argv.indexOf(flag);
7405
+ return index >= 0 ? argv[index + 1] : void 0;
7406
+ };
7407
+ const version = option("--version");
7408
+ const commit = option("--commit");
7409
+ const repoUrl = option("--repo-url");
7410
+ io.out(renderPrComment(saved, {
7411
+ ...version ? { version } : {},
7412
+ ...commit ? { commit } : {},
7413
+ ...repoUrl ? { repoUrl } : {}
7414
+ }));
7415
+ return 0;
7416
+ } catch (err) {
7417
+ io.err(`error: cannot read ${fromPath}: ${errorText$1(err)}`);
7418
+ return 10;
7419
+ }
7420
+ }
7220
7421
  const args = parseArgsOrUsage(argv, io);
7221
7422
  if (!args) return 10;
7222
7423
  try {
@@ -7376,7 +7577,12 @@ async function runRulesCommand(argv, io = {
7376
7577
  return 0;
7377
7578
  }
7378
7579
  const root = process$1.cwd();
7379
- const external = withExternal ? await loadLocalRules(root) : void 0;
7580
+ const gateOpen = pluginsGateOpen(argv.includes("--enable-plugins"));
7581
+ const external = withExternal ? await loadLocalRules(root, gateOpen) : void 0;
7582
+ if (external?.skipped.length) io.err(renderGateNotice(external.skipped.map((name) => ({
7583
+ kind: "js-module",
7584
+ name
7585
+ }))));
7380
7586
  let catalog = [...buildCatalog(), ...external ? buildCatalog(external.rules, { provenance: "external" }) : []];
7381
7587
  for (const w of external?.errors ?? []) io.err(`mjolnir: ${w}`);
7382
7588
  if (argv.includes("--unmeasured")) catalog = catalog.filter((e) => e.measuredFpRate === void 0);
@@ -7701,7 +7907,7 @@ function computeDetectorHashes(rules, rulesDir, retiredIds = []) {
7701
7907
  return manifest;
7702
7908
  }
7703
7909
  function loadManifest(path) {
7704
- return parseJsonFile(readFileSync(path, "utf8"), path, (v) => isRecord$2(v));
7910
+ return parseJsonFile(readFileSync(path, "utf8"), path, (v) => isRecord$3(v));
7705
7911
  }
7706
7912
  //#endregion
7707
7913
  //#region src/commands/doctor.ts
@@ -7878,7 +8084,7 @@ function checkTierEnforcement(verdictsDir, rules = RULES) {
7878
8084
  const lines = readFileSync(join(verdictsDir, f), "utf8").split("\n").filter((l) => l.trim().length > 0);
7879
8085
  for (const line of lines) try {
7880
8086
  const parsed = JSON.parse(line);
7881
- if (!isRecord$2(parsed)) continue;
8087
+ if (!isRecord$3(parsed)) continue;
7882
8088
  const entry = parsed;
7883
8089
  if (entry.verdict === "TP" || entry.verdict === "FP") {
7884
8090
  if (typeof entry.ruleId === "string") live.set(entry.ruleId, (live.get(entry.ruleId) ?? 0) + 1);
@@ -7986,7 +8192,7 @@ function checkFixtureIntegrity(fixturesRoot, rules = RULES) {
7986
8192
  const allowlistPath = join(fixturesRoot, "typecheck-allowlist.json");
7987
8193
  if (existsSync(allowlistPath)) try {
7988
8194
  const raw = JSON.parse(readFileSync(allowlistPath, "utf8"));
7989
- if (!isRecord$2(raw)) {
8195
+ if (!isRecord$3(raw)) {
7990
8196
  ok = false;
7991
8197
  details.push("typecheck-allowlist.json is not an object");
7992
8198
  } else if (Array.isArray(raw.entries)) details.push(`Layer A typecheck allowlist: ${raw.entries.length} justified entr(ies)`);
@@ -8100,7 +8306,7 @@ function checkMeasurementConsistency(verdictsDir, sidecarPath, rules = RULES, me
8100
8306
  let row;
8101
8307
  try {
8102
8308
  const parsed = JSON.parse(trimmed);
8103
- if (!isRecord$2(parsed)) {
8309
+ if (!isRecord$3(parsed)) {
8104
8310
  failures.push(`${f}: unparseable verdict row (corpus integrity)`);
8105
8311
  continue;
8106
8312
  }
@@ -8117,7 +8323,7 @@ function checkMeasurementConsistency(verdictsDir, sidecarPath, rules = RULES, me
8117
8323
  let sidecar = {};
8118
8324
  if (existsSync(sidecarPath)) try {
8119
8325
  const parsed = JSON.parse(readFileSync(sidecarPath, "utf8"));
8120
- if (!isRecord$2(parsed)) failures.push("sidecar is not a valid object — regenerate the measurement artifacts");
8326
+ if (!isRecord$3(parsed)) failures.push("sidecar is not a valid object — regenerate the measurement artifacts");
8121
8327
  else sidecar = parsed;
8122
8328
  } catch (e) {
8123
8329
  failures.push(`sidecar unreadable/malformed: ${errorText(e)} — regenerate the measurement artifacts`);
@@ -9101,19 +9307,12 @@ function hookBlock(version) {
9101
9307
  function resolveHookTarget(cwd) {
9102
9308
  const huskyDir = join(cwd, ".husky");
9103
9309
  if (existsSync(huskyDir)) return join(huskyDir, "pre-commit");
9104
- try {
9105
- const hooksPath = execFileSync("git", [
9106
- "-C",
9107
- cwd,
9108
- "config",
9109
- "core.hooksPath"
9110
- ], { stdio: [
9111
- "ignore",
9112
- "pipe",
9113
- "ignore"
9114
- ] }).toString().trim();
9115
- return join(cwd, hooksPath, "pre-commit");
9116
- } catch {}
9310
+ const hooksPath = runGit(cwd, ["config", "core.hooksPath"])?.trim();
9311
+ if (hooksPath) {
9312
+ const candidate = resolve(cwd, hooksPath);
9313
+ const relativePath = relative(cwd, candidate);
9314
+ if (relativePath === "" || !relativePath.startsWith("..") && !isAbsolute(relativePath)) return join(candidate, "pre-commit");
9315
+ }
9117
9316
  return join(cwd, ".git", "hooks", "pre-commit");
9118
9317
  }
9119
9318
  /**
@@ -9619,7 +9818,7 @@ function checkArtifactIntegrity(root) {
9619
9818
  what: "the artifact embeds its identity binding (scanId/commit/rule(rev)/evidence inventory)"
9620
9819
  },
9621
9820
  {
9622
- ok: report.includes("TRUST_REPORT_HTML") && report.includes("renderTrustReportHtml") && /writeFileSync\(join\(target,\s*TRUST_REPORT_HTML\)/.test(report),
9821
+ ok: report.includes("TRUST_REPORT_HTML") && report.includes("renderTrustReportHtml") && /write(?:FileSync|Artifact)\(join\(target,\s*TRUST_REPORT_HTML\)/.test(report),
9623
9822
  what: "the HTML artifact ships and the command writes all three formats"
9624
9823
  },
9625
9824
  {
@@ -10288,12 +10487,19 @@ async function runReportPlaywrightCommand(argv, io) {
10288
10487
  io.err(`mjolnir report playwright: target does not exist: ${target}`);
10289
10488
  return 10;
10290
10489
  }
10490
+ const root = resolve(target);
10491
+ const requestedOutput = resolve(outputPath);
10492
+ const outputRelative = relative(root, requestedOutput);
10493
+ if (isAbsolute(outputRelative) || outputRelative.startsWith("..")) {
10494
+ io.err("mjolnir report playwright: output must stay within the target root");
10495
+ return 10;
10496
+ }
10291
10497
  try {
10292
10498
  const result = await runScan$1({
10293
10499
  target,
10294
10500
  json: true,
10295
10501
  verbose: false,
10296
- maxDurationMs: Number.POSITIVE_INFINITY,
10502
+ maxDurationMs: 6e5,
10297
10503
  scopeChanged: false,
10298
10504
  format: "json",
10299
10505
  strict: false
@@ -10301,8 +10507,8 @@ async function runReportPlaywrightCommand(argv, io) {
10301
10507
  const report = buildPlaywrightReport(result);
10302
10508
  const outDir = resolve(".");
10303
10509
  if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
10304
- const fullPath = join(outDir, outputPath);
10305
- writeFileSync(fullPath, JSON.stringify(report, null, 2) + "\n");
10510
+ const fullPath = requestedOutput;
10511
+ writeFileAtomic(fullPath, JSON.stringify(report, null, 2) + "\n", { encoding: "utf8" });
10306
10512
  const header = sectionHeader("PLAYWRIGHT REPORT", ui$6);
10307
10513
  io.out(`${header}\n`);
10308
10514
  io.out(`Score: ${result.score !== null ? result.score + "/100" : "unknown"}`);
@@ -10787,20 +10993,44 @@ function runAnalyzeCommand(argv, io) {
10787
10993
  const findings = [];
10788
10994
  const fileImports = /* @__PURE__ */ new Map();
10789
10995
  const fileContents = /* @__PURE__ */ new Map();
10790
- function walk(dir) {
10996
+ let limited = false;
10997
+ const deadline = Date.now() + 6e5;
10998
+ function walk(dir, depth = 0) {
10999
+ if (limited || depth > LIMITS.maxDepth || Date.now() >= deadline) {
11000
+ limited = true;
11001
+ return;
11002
+ }
10791
11003
  for (const name of readdirSync(dir)) {
10792
11004
  const path = join(dir, name);
10793
11005
  if (name === "node_modules" || name === ".git") continue;
10794
- if (statSync(path).isDirectory()) walk(path);
10795
- else if (name.endsWith(".ts") || name.endsWith(".tsx") || name.endsWith(".js") || name.endsWith(".jsx")) try {
10796
- const content = readFileSync(path, "utf8");
11006
+ let stat;
11007
+ try {
11008
+ stat = lstatSync(path);
11009
+ } catch {
11010
+ continue;
11011
+ }
11012
+ if (stat.isSymbolicLink()) continue;
11013
+ if (stat.isDirectory()) {
11014
+ walk(path, depth + 1);
11015
+ if (limited) return;
11016
+ } else if (name.endsWith(".ts") || name.endsWith(".tsx") || name.endsWith(".js") || name.endsWith(".jsx")) {
11017
+ if (fileContents.size >= LIMITS.maxFilesPerAdapter) {
11018
+ limited = true;
11019
+ return;
11020
+ }
11021
+ const read = readFileBounded(path, LIMITS.maxFileBytes);
11022
+ if (!read.ok) {
11023
+ limited = true;
11024
+ continue;
11025
+ }
11026
+ const content = read.data.toString("utf8");
10797
11027
  const rel = relative(process.cwd(), path);
10798
11028
  fileContents.set(rel, content);
10799
11029
  const imports = collectImports(content);
10800
11030
  const importCounts = /* @__PURE__ */ new Map();
10801
11031
  for (const imp of imports) importCounts.set(imp, (importCounts.get(imp) ?? 0) + 1);
10802
11032
  fileImports.set(rel, importCounts);
10803
- } catch {}
11033
+ }
10804
11034
  }
10805
11035
  }
10806
11036
  walk(target);
@@ -10854,6 +11084,10 @@ function runAnalyzeCommand(argv, io) {
10854
11084
  }
10855
11085
  io.out("");
10856
11086
  }
11087
+ if (limited) {
11088
+ io.err("mjolnir analyze: resource limit reached; cross-file analysis is partial");
11089
+ return 2;
11090
+ }
10857
11091
  return 0;
10858
11092
  } catch (e) {
10859
11093
  internalErrorMessage(e, io.err, false);
@@ -10966,9 +11200,12 @@ function runCiAdapterCommand(argv, io) {
10966
11200
  * file with inline CSS and JavaScript, suitable for embedding
10967
11201
  * in team wikis or sharing via static hosting.
10968
11202
  */
11203
+ function escapeHtml(text) {
11204
+ return sanitizeErrorText(text, { maxLength: 1e3 }).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
11205
+ }
10969
11206
  function generateDashboardHtml(data) {
10970
11207
  const scoreColor = data.score === null ? "#888" : data.score >= 80 ? "#22c55e" : data.score >= 60 ? "#eab308" : "#ef4444";
10971
- const rows = data.findings.map((f) => `<tr><td>${f.ruleId}</td><td><span style="color:${f.severity === "error" ? "#ef4444" : f.severity === "warning" ? "#eab308" : "#22c55e"}">${f.severity}</span></td><td>${f.file}</td><td>${f.message}</td></tr>`).join("");
11208
+ const rows = data.findings.map((f) => `<tr><td>${escapeHtml(f.ruleId)}</td><td><span style="color:${f.severity === "error" ? "#ef4444" : f.severity === "warning" ? "#eab308" : "#22c55e"}">${escapeHtml(f.severity)}</span></td><td>${escapeHtml(f.file)}</td><td>${escapeHtml(f.message)}</td></tr>`).join("");
10972
11209
  return `<!DOCTYPE html>
10973
11210
  <html lang="en">
10974
11211
  <head>
@@ -10989,7 +11226,7 @@ function generateDashboardHtml(data) {
10989
11226
  </head>
10990
11227
  <body>
10991
11228
  <h1>🔍 Quality Dashboard</h1>
10992
- <p>Generated: ${data.generatedAt}</p>
11229
+ <p>Generated: ${escapeHtml(data.generatedAt)}</p>
10993
11230
  <div class="score">${data.score !== null ? data.score + "/100" : "N/A"}</div>
10994
11231
  <div class="kpi-grid">
10995
11232
  <div class="kpi"><div class="value">${data.totalFindings}</div><div class="label">Findings</div></div>
@@ -11010,13 +11247,20 @@ async function runDashboardCommand(argv, io) {
11010
11247
  io.err(`mjolnir dashboard: target does not exist: ${target}`);
11011
11248
  return 10;
11012
11249
  }
11250
+ const root = resolve(target);
11251
+ const resolvedOutput = resolve(outputPath);
11252
+ const outputRelative = relative(root, resolvedOutput);
11253
+ if (isAbsolute(outputRelative) || outputRelative.startsWith("..")) {
11254
+ io.err("mjolnir dashboard: output must stay within the target root");
11255
+ return 10;
11256
+ }
11013
11257
  try {
11014
- const { runScan } = await import("./scan-pipeline-C_Rs4u8N.mjs");
11258
+ const { runScan } = await import("./scan-pipeline-CRe7-MQn.mjs");
11015
11259
  const result = await runScan({
11016
11260
  target,
11017
11261
  json: true,
11018
11262
  verbose: false,
11019
- maxDurationMs: Number.POSITIVE_INFINITY,
11263
+ maxDurationMs: 6e5,
11020
11264
  scopeChanged: false,
11021
11265
  format: "json",
11022
11266
  strict: false
@@ -11036,8 +11280,8 @@ async function runDashboardCommand(argv, io) {
11036
11280
  generatedAt: (/* @__PURE__ */ new Date()).toISOString()
11037
11281
  };
11038
11282
  const html = generateDashboardHtml(data);
11039
- writeFileSync(outputPath, html);
11040
- io.out(`Dashboard written to ${outputPath}`);
11283
+ writeFileAtomic(resolvedOutput, html, { encoding: "utf8" });
11284
+ io.out(`Dashboard written to ${resolvedOutput}`);
11041
11285
  io.out(`Score: ${data.score !== null ? data.score + "/100" : "N/A"}`);
11042
11286
  io.out(`Findings: ${data.totalFindings} (${data.errorCount} errors, ${data.warningCount} warnings)`);
11043
11287
  return data.errorCount > 0 ? 1 : 0;
@@ -14281,7 +14525,7 @@ function loadPolicy(path) {
14281
14525
  }
14282
14526
  let record;
14283
14527
  try {
14284
- record = parseJsonFile(text, path, isRecord$2);
14528
+ record = parseJsonFile(text, path, isRecord$3);
14285
14529
  } catch (error) {
14286
14530
  throw new ConfigValidationError(`invalid suppression policy: ${path}`, { cause: error });
14287
14531
  }
@@ -14300,7 +14544,7 @@ function loadPolicy(path) {
14300
14544
  };
14301
14545
  }
14302
14546
  function isTrustSnapshot(value) {
14303
- return isRecord$2(value) && typeof value.scanId === "string" && typeof value.timestamp === "string" && (typeof value.score === "number" || value.score === null) && typeof value.findings === "number" && typeof value.errors === "number" && typeof value.warnings === "number" && typeof value.infos === "number" && typeof value.advisory === "number" && typeof value.trustLevel === "string" && typeof value.confidence === "number" && typeof value.evidenceCoverage === "number" && typeof value.inconclusiveRate === "number" && typeof value.partial === "boolean" && typeof value.frameworkCount === "number" && typeof value.ruleCount === "number";
14547
+ return isRecord$3(value) && typeof value.scanId === "string" && typeof value.timestamp === "string" && (typeof value.score === "number" || value.score === null) && typeof value.findings === "number" && typeof value.errors === "number" && typeof value.warnings === "number" && typeof value.infos === "number" && typeof value.advisory === "number" && typeof value.trustLevel === "string" && typeof value.confidence === "number" && typeof value.evidenceCoverage === "number" && typeof value.inconclusiveRate === "number" && typeof value.partial === "boolean" && typeof value.frameworkCount === "number" && typeof value.ruleCount === "number";
14304
14548
  }
14305
14549
  function loadTrustHistory(path) {
14306
14550
  if (!existsSync(path)) return [];
@@ -14326,18 +14570,18 @@ function isStringArray(value) {
14326
14570
  return Array.isArray(value) && value.every((entry) => typeof entry === "string");
14327
14571
  }
14328
14572
  function isValidAnalysisStatus(value) {
14329
- if (!isRecord$2(value)) return false;
14573
+ if (!isRecord$3(value)) return false;
14330
14574
  return (value.discovery === "complete" || value.discovery === "partial") && (value.rules === "complete" || value.rules === "partial") && isNonNegativeInteger(value.skippedFiles) && isFiniteNumber(value.durationMs) && value.durationMs >= 0 && (value.truncationReasons === void 0 || isStringArray(value.truncationReasons)) && (value.rulesCrashed === void 0 || isNonNegativeInteger(value.rulesCrashed));
14331
14575
  }
14332
14576
  function isValidDimension(value) {
14333
- return isRecord$2(value) && typeof value.category === "string" && isFiniteNumber(value.score) && value.score >= 0 && value.score <= 100 && isNonNegativeInteger(value.errors) && isNonNegativeInteger(value.warnings) && isNonNegativeInteger(value.infos);
14577
+ return isRecord$3(value) && typeof value.category === "string" && isFiniteNumber(value.score) && value.score >= 0 && value.score <= 100 && isNonNegativeInteger(value.errors) && isNonNegativeInteger(value.warnings) && isNonNegativeInteger(value.infos);
14334
14578
  }
14335
14579
  function isValidFinding(value) {
14336
- if (!isRecord$2(value)) return false;
14580
+ if (!isRecord$3(value)) return false;
14337
14581
  return typeof value.ruleId === "string" && value.ruleId.length > 0 && typeof value.category === "string" && typeof value.file === "string" && value.file.length > 0 && isPositiveInteger(value.line) && isPositiveInteger(value.column) && typeof value.message === "string" && value.message.length > 0 && typeof value.severity === "string" && typeof value.confidence === "string" && typeof value.findingType === "string" && typeof value.qaImpact === "string" && typeof value.why === "string" && typeof value.fix === "string";
14338
14582
  }
14339
14583
  function isValidTrustSummary(value) {
14340
- if (!isRecord$2(value)) return false;
14584
+ if (!isRecord$3(value)) return false;
14341
14585
  return [
14342
14586
  "L0",
14343
14587
  "L1",
@@ -14348,31 +14592,31 @@ function isValidTrustSummary(value) {
14348
14592
  ].includes(String(value.level)) && isFiniteNumber(value.confidence) && value.confidence >= 0 && value.confidence <= 1 && isFiniteNumber(value.evidenceCoverage) && value.evidenceCoverage >= 0 && value.evidenceCoverage <= 1 && isFiniteNumber(value.inconclusiveRate) && value.inconclusiveRate >= 0 && value.inconclusiveRate <= 1 && (value.measuredFpOfFiredRules === void 0 || isFiniteNumber(value.measuredFpOfFiredRules) && value.measuredFpOfFiredRules >= 0 && value.measuredFpOfFiredRules <= 1) && isStringArray(value.provisionalRuleIds) && (value.confidenceCeiling === void 0 || isFiniteNumber(value.confidenceCeiling) && value.confidenceCeiling >= 0 && value.confidenceCeiling <= 1) && isStringArray(value.ceilingReasons);
14349
14593
  }
14350
14594
  function isValidProvenance(value) {
14351
- if (!isRecord$2(value)) return false;
14595
+ if (!isRecord$3(value)) return false;
14352
14596
  return isNonNegativeInteger(value.testFiles) && isNonNegativeInteger(value.generatedMarkedFiles) && isNonNegativeInteger(value.codegenLikeFiles) && isFiniteNumber(value.shareMarkedGenerated) && value.shareMarkedGenerated >= 0 && value.shareMarkedGenerated <= 1 && isNonNegativeInteger(value.findingsInGeneratedFiles) && isNonNegativeInteger(value.findingsInUnmarkedFiles) && typeof value.note === "string";
14353
14597
  }
14354
14598
  function isValidForensicVerdicts(value) {
14355
- if (!isRecord$2(value) || !isRecord$2(value.byVerdict)) return false;
14599
+ if (!isRecord$3(value) || !isRecord$3(value.byVerdict)) return false;
14356
14600
  return isNonNegativeInteger(value.classifications) && isNonNegativeInteger(value.inconclusive) && Object.values(value.byVerdict).every((count) => isNonNegativeInteger(count));
14357
14601
  }
14358
14602
  function isValidSummary(value) {
14359
- if (!isRecord$2(value)) return false;
14603
+ if (!isRecord$3(value)) return false;
14360
14604
  return typeof value.digest === "string" && value.digest.length > 0 && isNonNegativeInteger(value.findings) && (value.score === null || isFiniteNumber(value.score) && value.score >= 0 && value.score <= 100) && isNonNegativeInteger(value.errors) && isNonNegativeInteger(value.warnings) && isNonNegativeInteger(value.infos) && isNonNegativeInteger(value.advisory);
14361
14605
  }
14362
14606
  function isValidAnnotation(value) {
14363
- if (!isRecord$2(value)) return false;
14607
+ if (!isRecord$3(value)) return false;
14364
14608
  return typeof value.path === "string" && value.path.length > 0 && isPositiveInteger(value.start_line) && (value.annotation_level === "failure" || value.annotation_level === "warning" || value.annotation_level === "notice") && typeof value.message === "string" && typeof value.ruleId === "string" && value.ruleId.length > 0 && (value.detectorRevision === void 0 || isPositiveInteger(value.detectorRevision)) && typeof value.advisory === "boolean";
14365
14609
  }
14366
14610
  function isValidCompleteness(value) {
14367
- if (!isRecord$2(value)) return false;
14611
+ if (!isRecord$3(value)) return false;
14368
14612
  return typeof value.partial === "boolean" && (value.discovery === "complete" || value.discovery === "partial") && (value.rules === "complete" || value.rules === "partial") && isNonNegativeInteger(value.skippedFiles) && isNonNegativeInteger(value.rulesCrashed) && isStringArray(value.truncationReasons) && typeof value.frameworkDetectionUnknown === "boolean" && isFiniteNumber(value.durationMs) && value.durationMs >= 0;
14369
14613
  }
14370
14614
  function isValidContract(value) {
14371
- if (!isRecord$2(value)) return false;
14615
+ if (!isRecord$3(value)) return false;
14372
14616
  return Number.isSafeInteger(value.contractVersion) && isValidSummary(value.summary) && Array.isArray(value.annotations) && value.annotations.length <= 50 && value.annotations.every((annotation) => isValidAnnotation(annotation)) && typeof value.annotationsTruncated === "boolean" && isValidCompleteness(value.completeness) && (value.trustSummary === void 0 || isValidTrustSummary(value.trustSummary)) && (value.provenance === void 0 || isValidProvenance(value.provenance)) && (value.forensicVerdicts === void 0 || isValidForensicVerdicts(value.forensicVerdicts));
14373
14617
  }
14374
14618
  function isScanResultDocument(value) {
14375
- if (!isRecord$2(value) || value.schemaVersion !== 1) return false;
14619
+ if (!isRecord$3(value) || value.schemaVersion !== 1) return false;
14376
14620
  return (value.score === null || isFiniteNumber(value.score) && value.score >= 0 && value.score <= 100) && typeof value.partial === "boolean" && isStringArray(value.frameworks) && Array.isArray(value.dimensions) && value.dimensions.every((dimension) => isValidDimension(dimension)) && Array.isArray(value.findings) && value.findings.every((finding) => isValidFinding(finding)) && isValidAnalysisStatus(value.analysisStatus) && (value.agenticProfile === void 0 || isValidProvenance(value.agenticProfile)) && (value.trustSummary === void 0 || isValidTrustSummary(value.trustSummary)) && (value.forensicVerdicts === void 0 || isValidForensicVerdicts(value.forensicVerdicts)) && isValidContract(value.contract);
14377
14621
  }
14378
14622
  function emitVerification(verification, json, io) {
@@ -14628,12 +14872,14 @@ async function runCIIntegrityCommand(argv, io = {
14628
14872
  * keeps every existing consumer stable.
14629
14873
  */
14630
14874
  const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isValidFindingRecord, discoverAndParseRuntimeReport, KNOWN_RULE_IDS, OVERLAP_META_BY_RULE_ID, EVIDENCE_OVERRIDES, SUITE_INVALIDATING_RULE_IDS } = scan_pipeline_exports;
14875
+ const DEFAULT_MAX_DURATION_MS = 6e5;
14876
+ const MAX_DURATION_MS = 36e5;
14631
14877
  function parseArgs(argv, onError) {
14632
14878
  const args = {
14633
14879
  target: ".",
14634
14880
  json: false,
14635
14881
  verbose: false,
14636
- maxDurationMs: Number.POSITIVE_INFINITY,
14882
+ maxDurationMs: DEFAULT_MAX_DURATION_MS,
14637
14883
  scopeChanged: false,
14638
14884
  format: "terminal"
14639
14885
  };
@@ -14679,7 +14925,7 @@ function parseArgs(argv, onError) {
14679
14925
  args.base = ref;
14680
14926
  } else if (a === "--max-duration") {
14681
14927
  const v = Number(argv[++i]);
14682
- if (!Number.isFinite(v) || v <= 0) return reject({
14928
+ if (!Number.isFinite(v) || v <= 0 || v * 1e3 > 36e5) return reject({
14683
14929
  flag: "--max-duration",
14684
14930
  token: argv[i]
14685
14931
  });
@@ -15047,4 +15293,4 @@ if (isEntryPoint()) try {
15047
15293
  process$1.exitCode = 20;
15048
15294
  }
15049
15295
  //#endregion
15050
- export { ENGINE_VERSION as CLI_VERSION, EVIDENCE_OVERRIDES, KNOWN_RULE_IDS, OVERLAP_META_BY_RULE_ID, SUITE_INVALIDATING_RULE_IDS, buildUniversalRules, discoverAndParseRuntimeReport, err, exitForFindings, fallbackWorkspace, internalErrorMessage, isEntryPoint, isValidFindingRecord, levenshtein, main, nearestFlags, out, parseArgs, parseArgsOrUsage, pathMatchesGlob, printUsage, renderScanOutput, runAnalyzeCommand, runBadgeCommand, runBaselineCommand, runBusinessCaseCommand, runCIIntegrityCommand, runCiAdapterCommand, runCiInstall, runContractVerifyCommand, runCreateRuleCommand, runCrossFileCommand, runDashboardCommand, runDebtCommand, runDiffCommand, runDoctorCommand, runDoctorPlaywright, runEnterpriseCommand, runEvidenceGraphCommand, runExecReportCommand, runExplainCommand, runFixCommand, runForensicsCommand, runFrameworkMaturityCommand, runHandoffCommand, runHandoverCommand, runHelpCommand, runImpactCommand, runInitCommand, runInstallCommand, runMaturityCommand, runMutationCommand, runPolicyCommand, runPrCommentCommand, runPwReportCommand, runQuarantineCommand, runReleaseReportCommand, runReleaseTrustCommand, runReportPlaywrightCommand, runRulesCommand, runScan, runScanCommand, runStatsCommand, runSummaryCommand, runSuppressionGateCommand, runSuppressions, runTrendCommand, runTriageCommand, runTrustReportCommand, runTrustTrendCommand, runVerifyCommand, runWhyCommand, usageErrorMessage, validateScanTarget };
15296
+ export { ENGINE_VERSION as CLI_VERSION, DEFAULT_MAX_DURATION_MS, EVIDENCE_OVERRIDES, KNOWN_RULE_IDS, MAX_DURATION_MS, OVERLAP_META_BY_RULE_ID, SUITE_INVALIDATING_RULE_IDS, buildUniversalRules, discoverAndParseRuntimeReport, err, exitForFindings, fallbackWorkspace, internalErrorMessage, isEntryPoint, isValidFindingRecord, levenshtein, main, nearestFlags, out, parseArgs, parseArgsOrUsage, pathMatchesGlob, printUsage, renderScanOutput, runAnalyzeCommand, runBadgeCommand, runBaselineCommand, runBusinessCaseCommand, runCIIntegrityCommand, runCiAdapterCommand, runCiInstall, runContractVerifyCommand, runCreateRuleCommand, runCrossFileCommand, runDashboardCommand, runDebtCommand, runDiffCommand, runDoctorCommand, runDoctorPlaywright, runEnterpriseCommand, runEvidenceGraphCommand, runExecReportCommand, runExplainCommand, runFixCommand, runForensicsCommand, runFrameworkMaturityCommand, runHandoffCommand, runHandoverCommand, runHelpCommand, runImpactCommand, runInitCommand, runInstallCommand, runMaturityCommand, runMutationCommand, runPolicyCommand, runPrCommentCommand, runPwReportCommand, runQuarantineCommand, runReleaseReportCommand, runReleaseTrustCommand, runReportPlaywrightCommand, runRulesCommand, runScan, runScanCommand, runStatsCommand, runSummaryCommand, runSuppressionGateCommand, runSuppressions, runTrendCommand, runTriageCommand, runTrustReportCommand, runTrustTrendCommand, runVerifyCommand, runWhyCommand, usageErrorMessage, validateScanTarget };