mjolnir-qa 2.0.0 → 2.0.2

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.
@@ -3,7 +3,7 @@ import { createInterface } from "node:readline";
3
3
  import { Buffer as Buffer$1 } from "node:buffer";
4
4
  import { createHash } from "node:crypto";
5
5
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
6
- import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
7
  import * as ts$2 from "ts-morph";
8
8
  import ts, { Project, SyntaxKind, ts as ts$1 } from "ts-morph";
9
9
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -109,31 +109,21 @@ function stampRuntimeCorroboration(findings, report, scanRoot) {
109
109
  source: report.source,
110
110
  testsExecuted
111
111
  };
112
- if (f.qaImpact === "FLAKY-RISK" && matched !== void 0 && !matched.skipped && (matched.passedOnRetry || matched.everFailed || matched.finalStatus === "timedOut")) corroboration.level = "defect";
112
+ if (f.qaImpact === "FLAKY-RISK" && matched !== void 0 && !matched.skipped && (matched.passedOnRetry || matched.finalStatus === "timedOut") && deriveTrustLevel(f, {
113
+ ...corroboration,
114
+ level: "defect"
115
+ }) === "L5") corroboration.level = "defect";
113
116
  f.runtimeCorroboration = corroboration;
114
117
  f.trustLevel = deriveTrustLevel(f, corroboration);
115
118
  corroborated++;
116
119
  }
117
120
  return corroborated;
118
121
  }
119
- /**
120
- * The test whose declaration span contains `line`. Playwright JSON
121
- * verdicts carry the spec's declaration line: the containing test is
122
- * the one with the greatest declaration line ≤ the finding's line in
123
- * the same file (specs are flat within a file). When the report cannot
124
- * place lines (JUnit, or some verdicts lack them) the only HONEST
125
- * claim is file-level corroboration — plus the unambiguous
126
- * single-test-file case. Claiming a specific test without range
127
- * knowledge would fabricate precision the report does not carry.
128
- */
129
122
  function findContainingTest(verdicts, line) {
130
- if (verdicts.length === 1) return verdicts[0];
123
+ if (!Number.isInteger(line) || line < 1) return void 0;
131
124
  if (verdicts.some((v) => v.line === void 0)) return void 0;
132
- const sorted = [...verdicts].sort((a, b) => (a.line ?? 0) - (b.line ?? 0));
133
- let match;
134
- for (const v of sorted) if ((v.line ?? 0) <= line) match = v;
135
- else break;
136
- return match;
125
+ const matches = verdicts.filter((v) => v.line === line);
126
+ return matches.length === 1 ? matches[0] : void 0;
137
127
  }
138
128
  /**
139
129
  * The deterministic L0–L5 derivation (see TRUST_ORDER). Static base
@@ -144,7 +134,7 @@ function findContainingTest(verdicts, line) {
144
134
  function deriveTrustLevel(finding, corroboration) {
145
135
  const level = finding.evidenceLevel ?? (finding.findingType === "observation" ? "E0" : finding.findingType === "heuristic-risk" ? "E1" : finding.confidence === "low" ? "E1" : "E2");
146
136
  if (!corroboration) return level === "E0" ? "L0" : level === "E1" ? "L1" : "L2";
147
- if (corroboration.level === "defect") return "L5";
137
+ if (corroboration.level === "defect") return level === "E0" ? "L4" : "L5";
148
138
  if (corroboration.level === "test") return "L4";
149
139
  return "L3";
150
140
  }
@@ -441,7 +431,7 @@ const MEASURED_FP = {
441
431
  "QA-PW-125": {
442
432
  fpRate: 0,
443
433
  n: 10,
444
- detectorRevision: 1,
434
+ detectorRevision: 2,
445
435
  ciLow: 0,
446
436
  ciHigh: .2775
447
437
  },
@@ -914,7 +904,7 @@ function buildEvidenceGraph(parts) {
914
904
  * scripts/sync-sarif-version.cjs and guarded by the version-consistency
915
905
  * spec. cli.ts re-exports this as CLI_VERSION.
916
906
  */
917
- const ENGINE_VERSION = "2.0.0";
907
+ const ENGINE_VERSION = "2.0.2";
918
908
  //#endregion
919
909
  //#region src/engine/contract-versions.ts
920
910
  /**
@@ -1052,12 +1042,12 @@ function discoverWorkspace(rootDir) {
1052
1042
  else if (rawWorkspaces && typeof rawWorkspaces === "object" && Array.isArray(rawWorkspaces.packages)) globs = rawWorkspaces.packages.filter((w) => typeof w === "string");
1053
1043
  return {
1054
1044
  root,
1055
- name: typeof pkg["name"] === "string" ? pkg["name"] : basename(root),
1045
+ name: typeof pkg["name"] === "string" ? pkg["name"] : basename$1(root),
1056
1046
  packageJson: pkg,
1057
1047
  workspaceGlobs: globs
1058
1048
  };
1059
1049
  }
1060
- function basename(p) {
1050
+ function basename$1(p) {
1061
1051
  const parts = p.split(/[\\/]/);
1062
1052
  return parts[parts.length - 1];
1063
1053
  }
@@ -1713,41 +1703,25 @@ function getCodeOnlyText(file) {
1713
1703
  try {
1714
1704
  const text = file.text;
1715
1705
  const ranges = [];
1716
- for (const d of sf.getDescendantsOfKind(ts$1.SyntaxKind.StringLiteral)) {
1717
- const s = d.getStart();
1718
- ranges.push({
1719
- start: s,
1720
- end: s + d.getWidth()
1721
- });
1722
- }
1723
- for (const d of sf.getDescendantsOfKind(ts$1.SyntaxKind.NoSubstitutionTemplateLiteral)) {
1724
- const s = d.getStart();
1725
- ranges.push({
1726
- start: s,
1727
- end: s + d.getWidth()
1728
- });
1729
- }
1730
- for (const d of sf.getDescendantsOfKind(ts$1.SyntaxKind.TemplateHead)) {
1731
- const s = d.getStart();
1732
- ranges.push({
1733
- start: s,
1734
- end: s + d.getWidth()
1735
- });
1736
- }
1737
- for (const d of sf.getDescendantsOfKind(ts$1.SyntaxKind.TemplateMiddle)) {
1738
- const s = d.getStart();
1739
- ranges.push({
1740
- start: s,
1741
- end: s + d.getWidth()
1742
- });
1743
- }
1744
- for (const d of sf.getDescendantsOfKind(ts$1.SyntaxKind.TemplateTail)) {
1745
- const s = d.getStart();
1746
- ranges.push({
1747
- start: s,
1748
- end: s + d.getWidth()
1749
- });
1750
- }
1706
+ const compiler = sf.compilerNode;
1707
+ const stringAndTemplateKinds = {
1708
+ [ts$1.SyntaxKind.StringLiteral]: true,
1709
+ [ts$1.SyntaxKind.NoSubstitutionTemplateLiteral]: true,
1710
+ [ts$1.SyntaxKind.TemplateHead]: true,
1711
+ [ts$1.SyntaxKind.TemplateMiddle]: true,
1712
+ [ts$1.SyntaxKind.TemplateTail]: true
1713
+ };
1714
+ const collect = (node) => {
1715
+ if (stringAndTemplateKinds[node.kind]) {
1716
+ const s = node.getStart(compiler);
1717
+ ranges.push({
1718
+ start: s,
1719
+ end: s + node.getWidth()
1720
+ });
1721
+ }
1722
+ ts$1.forEachChild(node, collect);
1723
+ };
1724
+ ts$1.forEachChild(compiler, collect);
1751
1725
  for (const r of commentAndStringRanges({
1752
1726
  ...file,
1753
1727
  ast: sf
@@ -6677,7 +6651,7 @@ const swallowedVerificationFailure = defineRule({
6677
6651
  autofix: false,
6678
6652
  detectionStrategy: "FRAMEWORK",
6679
6653
  detectionNotes: "Groovy try/catch block scan (string-aware brace matching) over the Jenkinsfile text",
6680
- introduced: "2.0.0",
6654
+ introduced: "1.1.1",
6681
6655
  tier: "quarantine",
6682
6656
  run(ctx) {
6683
6657
  const findings = [];
@@ -8105,6 +8079,7 @@ const pwGlobalSetupSharedState = defineRule({
8105
8079
  },
8106
8080
  detectionNotes: "regex heuristic",
8107
8081
  introduced: "0.3.0",
8082
+ detectorRevision: 2,
8108
8083
  run(ctx) {
8109
8084
  const text = ctx.text;
8110
8085
  const findings = [];
@@ -8112,6 +8087,7 @@ const pwGlobalSetupSharedState = defineRule({
8112
8087
  const re = /(?:execSync|exec|spawn|query|request)\s*\(\s*[`'"][^`'"]*(?:migrate|migration|seed|TRUNCATE|DROP\s+(?:TABLE|DATABASE)|DELETE\s+FROM)[^`'"]*[`'"]/gi;
8113
8088
  let m;
8114
8089
  while ((m = re.exec(text)) !== null) {
8090
+ if (isMasked(ctx, m.index)) continue;
8115
8091
  const lineStart = text.lastIndexOf("\n", m.index) + 1;
8116
8092
  const lineEnd = text.indexOf("\n", m.index);
8117
8093
  const prevLineStart = text.lastIndexOf("\n", lineStart - 2) + 1;
@@ -11190,6 +11166,10 @@ const RULES = [
11190
11166
  seCSharpSleepLookup,
11191
11167
  sePythonSleepLookup
11192
11168
  ];
11169
+ /**
11170
+ * Look up a rule by its ID in the active registry.
11171
+ * Returns undefined if the ID is not found (including retired IDs).
11172
+ */
11193
11173
  function getRule(id) {
11194
11174
  return RULES.find((r) => r.id === id);
11195
11175
  }
@@ -11907,7 +11887,7 @@ function hasBlocker(findings) {
11907
11887
  function analyzeMonorepo(packages, config) {
11908
11888
  const results = packages.map((p) => ({
11909
11889
  ...p,
11910
- verdict: verdictOf(p.score)
11890
+ verdict: hasBlocker(p.findings) ? "fail" : verdictOf(p.score)
11911
11891
  }));
11912
11892
  if (results.length === 0) return {
11913
11893
  packages: results,
@@ -12014,7 +11994,7 @@ function configurableAggregation(results, config) {
12014
11994
  */
12015
11995
  const CACHE_VERSION = 2;
12016
11996
  /** Entry cap: a monorepo-scale suite stays far below this; bounded file. */
12017
- const MAX_ENTRIES = 4096;
11997
+ const MAX_ENTRIES$1 = 4096;
12018
11998
  /**
12019
11999
  * Audit M5: total byte budget. The old cap counted only ENTRIES, so
12020
12000
  * 4096 files × ~100KB of findings each could still produce a
@@ -12159,7 +12139,7 @@ function createScanCache(root) {
12159
12139
  entryBytes.set(key, newBytes);
12160
12140
  totalBytes = totalBytes - replacedBytes + newBytes;
12161
12141
  let count = Object.keys(entries).length;
12162
- while ((count > MAX_ENTRIES || totalBytes > MAX_TOTAL_BYTES) && count > 1) {
12142
+ while ((count > MAX_ENTRIES$1 || totalBytes > MAX_TOTAL_BYTES) && count > 1) {
12163
12143
  const oldest = Object.keys(entries)[0];
12164
12144
  totalBytes -= entryBytes.get(oldest);
12165
12145
  delete entries[oldest];
@@ -12601,6 +12581,7 @@ const ui$5 = plainContext();
12601
12581
  const MAX_RECORDS = 1e5;
12602
12582
  function analyze(records, source) {
12603
12583
  const verdicts = [];
12584
+ const networkObservations = [];
12604
12585
  let failed = 0;
12605
12586
  let skipped = 0;
12606
12587
  let retried = 0;
@@ -12610,6 +12591,16 @@ function analyze(records, source) {
12610
12591
  const attempts = rec.attempts;
12611
12592
  const finalStatus = attempts[attempts.length - 1]?.status ?? "skipped";
12612
12593
  const totalDurationMs = attempts.reduce((s, a) => s + a.durationMs, 0);
12594
+ if (rec.evidenceKind === "network-observation") {
12595
+ networkObservations.push({
12596
+ file: rec.file,
12597
+ title: rec.title,
12598
+ outcome: finalStatus === "passed" ? "succeeded" : finalStatus === "skipped" ? "unknown" : "failed",
12599
+ durationMs: totalDurationMs,
12600
+ ...rec.errors !== void 0 ? { errors: rec.errors } : {}
12601
+ });
12602
+ continue;
12603
+ }
12613
12604
  totalDuration += totalDurationMs;
12614
12605
  const everFailed = attempts.some((a) => a.status === "failed" || a.status === "timedOut");
12615
12606
  const passedOnRetry = finalStatus === "passed" && attempts.length >= 2 && everFailed;
@@ -12650,9 +12641,14 @@ function analyze(records, source) {
12650
12641
  flakyTests: flaky,
12651
12642
  totalDurationMs: totalDuration,
12652
12643
  verdicts,
12653
- analysisComplete: true,
12644
+ ...networkObservations.length > 0 ? {
12645
+ totalNetworkObservations: networkObservations.length,
12646
+ failedNetworkObservations: networkObservations.filter((observation) => observation.outcome === "failed").length,
12647
+ networkObservations
12648
+ } : {},
12649
+ analysisComplete: records.length <= MAX_RECORDS,
12654
12650
  skippedReports: 0,
12655
- incompleteReasons: []
12651
+ incompleteReasons: records.length > MAX_RECORDS ? ["record-count-limit"] : []
12656
12652
  };
12657
12653
  }
12658
12654
  /** Flakiness Leaderboard: flakiest first, then slowest. */
@@ -12667,16 +12663,36 @@ function bar(ms, maxMs, width = 20) {
12667
12663
  const filled = Math.round(ms / maxMs * width);
12668
12664
  return "█".repeat(Math.min(width, filled)) + "░".repeat(Math.max(0, width - filled));
12669
12665
  }
12666
+ function renderNetworkObservations(report) {
12667
+ if (!report.networkObservations?.length) return [];
12668
+ const failures = report.networkObservations.filter((observation) => observation.outcome === "failed");
12669
+ return [
12670
+ `${report.networkObservations.length} network observations · ${failures.length} network failures (not test outcomes)`,
12671
+ ...failures.slice(0, 25).map((observation) => `NETWORK FAILURE ${observation.title} · ${(observation.durationMs / 1e3).toFixed(1)}s`),
12672
+ ""
12673
+ ];
12674
+ }
12675
+ function partialAnalysisMessage(report) {
12676
+ const skippedReasons = report.incompleteReasons.filter((reason) => reason !== "record-count-limit");
12677
+ const partialReasons = report.incompleteReasons.filter((reason) => reason === "record-count-limit").map(() => "record count limit reached");
12678
+ if (report.skippedReports > 0) {
12679
+ const skippedReasonText = skippedReasons.length > 0 ? ` (${skippedReasons.join(", ")})` : "";
12680
+ const suffix = partialReasons.length > 0 ? `; ${partialReasons.join(", ")}.` : ".";
12681
+ return `Analysis is partial — ${report.skippedReports} report(s) skipped${skippedReasonText}${suffix}`;
12682
+ }
12683
+ return `${partialReasons.length > 0 ? partialReasons.join(", ") : report.incompleteReasons.join(", ")}; analysis is partial.`;
12684
+ }
12670
12685
  function renderLeaderboard(report) {
12671
12686
  const lines = [];
12672
12687
  lines.push(sectionHeader("FLAKINESS LEADERBOARD", ui$5));
12673
12688
  lines.push("");
12674
12689
  lines.push(`${report.totalTests} tests · ${report.failed} failed · ${report.flakyTests} flaky · ${report.retriedTests} retried`);
12675
12690
  lines.push("");
12691
+ lines.push(...renderNetworkObservations(report));
12676
12692
  const top = leaderboard(report);
12677
12693
  if (top.length === 0) {
12678
- lines.push("No failures or retries found — nothing suspicious this run.");
12679
- if (!report.analysisComplete) lines.push(`⚠ Analysis is partial — ${report.skippedReports} report(s) skipped (${report.incompleteReasons.join(", ")}).`);
12694
+ lines.push(report.totalTests === 0 && (report.totalNetworkObservations ?? 0) > 0 ? "No test outcomes available from network observations." : (report.totalNetworkObservations ?? 0) > 0 ? "No flaky or failing tests detected; network evidence is listed separately." : "No failures or retries found — nothing suspicious this run.");
12695
+ if (!report.analysisComplete) lines.push(`⚠ ${partialAnalysisMessage(report)}`);
12680
12696
  return lines.join("\n");
12681
12697
  }
12682
12698
  const maxMs = Math.max(...top.map((v) => v.totalDurationMs), 1);
@@ -12686,7 +12702,7 @@ function renderLeaderboard(report) {
12686
12702
  }
12687
12703
  if (!report.analysisComplete) {
12688
12704
  lines.push("");
12689
- lines.push(`⚠ Analysis is partial — ${report.skippedReports} report(s) skipped (${report.incompleteReasons.join(", ")}).`);
12705
+ lines.push(`⚠ ${partialAnalysisMessage(report)}`);
12690
12706
  }
12691
12707
  return lines.join("\n");
12692
12708
  }
@@ -12700,12 +12716,13 @@ function renderFlakyMd(report) {
12700
12716
  lines.push("");
12701
12717
  lines.push(`**${report.totalTests}** tests analyzed · **${report.flakyTests}** true flakes · **${report.retriedTests}** retried · **${report.failed}** failing`);
12702
12718
  lines.push("");
12719
+ lines.push(...renderNetworkObservations(report));
12703
12720
  const top = leaderboard(report);
12704
12721
  if (top.length === 0) {
12705
- lines.push("_No flaky or failing tests detected in this run._");
12722
+ lines.push(report.totalTests === 0 && (report.totalNetworkObservations ?? 0) > 0 ? "_No test outcomes available from network observations._" : "_No flaky or failing tests detected in this run._");
12706
12723
  if (!report.analysisComplete) {
12707
12724
  lines.push("");
12708
- lines.push(`> ⚠ Analysis is partial — ${report.skippedReports} report(s) skipped (${report.incompleteReasons.join(", ")}).`);
12725
+ lines.push(`> ⚠ ${partialAnalysisMessage(report)}`);
12709
12726
  }
12710
12727
  return lines.join("\n");
12711
12728
  }
@@ -12717,7 +12734,7 @@ function renderFlakyMd(report) {
12717
12734
  }
12718
12735
  if (!report.analysisComplete) {
12719
12736
  lines.push("");
12720
- lines.push(`> ⚠ Analysis is partial — ${report.skippedReports} report(s) skipped (${report.incompleteReasons.join(", ")}).`);
12737
+ lines.push(`> ⚠ ${partialAnalysisMessage(report)}`);
12721
12738
  }
12722
12739
  lines.push("");
12723
12740
  return lines.join("\n");
@@ -13236,6 +13253,92 @@ function parseVitestJson(json) {
13236
13253
  return out;
13237
13254
  }
13238
13255
  //#endregion
13256
+ //#region src/forensics/parse-har.ts
13257
+ const MAX_ENTRIES = 2e4;
13258
+ function sanitizeTitleUrl(url) {
13259
+ try {
13260
+ const parsed = new URL(url);
13261
+ parsed.username = "";
13262
+ parsed.password = "";
13263
+ parsed.search = "";
13264
+ parsed.hash = "";
13265
+ return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
13266
+ } catch {
13267
+ return url.split(/[?#]/, 1)[0] ?? url;
13268
+ }
13269
+ }
13270
+ function serializeTransportError(value) {
13271
+ if (typeof value === "string") return value;
13272
+ try {
13273
+ const serialized = JSON.stringify(value);
13274
+ return serialized === void 0 ? String(value) : serialized;
13275
+ } catch {
13276
+ return "[unserializable transport error]";
13277
+ }
13278
+ }
13279
+ function isValidHttpStatus(status) {
13280
+ return typeof status === "number" && Number.isInteger(status) && status >= 100 && status <= 599;
13281
+ }
13282
+ /**
13283
+ * Sniff: a HAR root is `{ log: { entries: [...] } }` — distinct from
13284
+ * every other JSON shape the forensics layer accepts (Playwright/Jest/
13285
+ * Vitest all key on `testResults` or `suites`).
13286
+ */
13287
+ function looksLikeHarJson(json) {
13288
+ if (!json || typeof json !== "object") return false;
13289
+ const log = json.log;
13290
+ if (!log || typeof log !== "object") return false;
13291
+ return Array.isArray(log.entries);
13292
+ }
13293
+ function parseHarJsonDetailed(json) {
13294
+ if (!json || typeof json !== "object") return {
13295
+ records: [],
13296
+ truncated: false
13297
+ };
13298
+ const entries = json.log?.entries;
13299
+ if (!Array.isArray(entries)) return {
13300
+ records: [],
13301
+ truncated: false
13302
+ };
13303
+ const out = [];
13304
+ const truncated = entries.length > MAX_ENTRIES;
13305
+ for (const entry of entries.slice(0, MAX_ENTRIES)) {
13306
+ if (!entry || typeof entry !== "object") continue;
13307
+ const method = typeof entry.request?.method === "string" ? entry.request.method : "GET";
13308
+ const url = typeof entry.request?.url === "string" ? entry.request.url : void 0;
13309
+ if (url === void 0 || url.length === 0) continue;
13310
+ const status = typeof entry.response?.status === "number" ? entry.response.status : void 0;
13311
+ const transportError = entry.response?._error ?? entry.response?.error ?? void 0;
13312
+ const hasTransportError = transportError !== void 0 && transportError !== null;
13313
+ const hasValidStatus = isValidHttpStatus(status);
13314
+ if (!hasValidStatus && !hasTransportError) continue;
13315
+ const httpFailed = hasValidStatus && status >= 400;
13316
+ const errors = [];
13317
+ if (hasTransportError) errors.push(sanitizeErrorText(serializeTransportError(transportError)));
13318
+ if (httpFailed) {
13319
+ const text = typeof entry.response?.statusText === "string" && entry.response.statusText.length > 0 ? entry.response.statusText : `HTTP ${status}`;
13320
+ errors.push(sanitizeErrorText(`${status} ${text}`));
13321
+ }
13322
+ const durationMs = typeof entry.time === "number" && Number.isFinite(entry.time) ? Math.max(0, Math.round(entry.time)) : 0;
13323
+ const attempt = {
13324
+ index: 1,
13325
+ status: hasTransportError || httpFailed ? "failed" : "passed",
13326
+ durationMs
13327
+ };
13328
+ out.push({
13329
+ file: "har",
13330
+ evidenceKind: "network-observation",
13331
+ title: `${method} ${sanitizeTitleUrl(url)}`,
13332
+ attempts: [attempt],
13333
+ ...errors.length > 0 ? { errors } : {}
13334
+ });
13335
+ }
13336
+ return {
13337
+ records: out,
13338
+ truncated
13339
+ };
13340
+ }
13341
+ //#endregion
13239
13342
  //#region src/forensics/run.ts
13240
13343
  /**
13241
13344
  * `mjolnir forensics <dir-or-file>` — runtime evidence entry point (R4).
@@ -13252,6 +13355,16 @@ function parseVitestJson(json) {
13252
13355
  const MAX_FILES = 500;
13253
13356
  const MAX_REPORT_FILE_BYTES = 1048576;
13254
13357
  const MAX_CUMULATIVE_BYTES = 52428800;
13358
+ /**
13359
+ * Run forensics analysis on test result artifacts.
13360
+ *
13361
+ * Parses test result files (Playwright JSON, JUnit XML, HAR, etc.),
13362
+ * analyzes them for flakiness, determinism, and trust signals, and
13363
+ * returns a structured report plus rendered output strings.
13364
+ *
13365
+ * @param target - path to the test result artifact or directory
13366
+ * @param options - writeFlakyMd (default true) controls FLAKY.md output
13367
+ */
13255
13368
  function runForensics(target, options = {}) {
13256
13369
  const records = [];
13257
13370
  let source = "playwright-json";
@@ -13264,7 +13377,7 @@ function runForensics(target, options = {}) {
13264
13377
  output: [
13265
13378
  renderLeaderboard(report),
13266
13379
  "",
13267
- renderFlakyMdHint()
13380
+ renderFlakyMdNotWritten(options)
13268
13381
  ].join("\n")
13269
13382
  };
13270
13383
  }
@@ -13299,15 +13412,8 @@ function runForensics(target, options = {}) {
13299
13412
  ].join("\n")
13300
13413
  };
13301
13414
  }
13302
- const report = analyze([record], "playwright-trace");
13303
- return {
13304
- report,
13305
- output: [
13306
- renderLeaderboard(report),
13307
- "",
13308
- renderFlakyMdHint()
13309
- ].join("\n")
13310
- };
13415
+ records.push(record);
13416
+ source = "playwright-trace";
13311
13417
  } catch {
13312
13418
  const report = analyze([], "playwright-trace");
13313
13419
  report.analysisComplete = false;
@@ -13322,10 +13428,15 @@ function runForensics(target, options = {}) {
13322
13428
  ].join("\n")
13323
13429
  };
13324
13430
  }
13325
- try {
13431
+ else try {
13326
13432
  const parsed = parseFile(target, readFileSync(target, "utf8"));
13327
13433
  records.push(...parsed.records);
13328
13434
  source = parsed.source;
13435
+ if (parsed.parseFailed) {
13436
+ skippedReports = 1;
13437
+ incompleteReasons.push("parse-failure");
13438
+ }
13439
+ if (parsed.truncated && !incompleteReasons.includes("entry-count-limit")) incompleteReasons.push("entry-count-limit");
13329
13440
  } catch {
13330
13441
  skippedReports = 1;
13331
13442
  incompleteReasons.push("parse-failure");
@@ -13357,13 +13468,18 @@ function runForensics(target, options = {}) {
13357
13468
  if (/\.(?:zip|trace|ndjson)$/i.test(full)) {
13358
13469
  const record = parseTraceArtifact(readFileSync(full), traceArtifactName(full));
13359
13470
  if (record === void 0) continue;
13360
- if (records.length === 0) source = "playwright-trace";
13471
+ if (records.length === 0 || source === "har") source = "playwright-trace";
13361
13472
  records.push(record);
13362
13473
  continue;
13363
13474
  }
13364
13475
  const parsed = parseFile(full, readFileSync(full, "utf8"));
13476
+ if (parsed.parseFailed) {
13477
+ skippedReports++;
13478
+ if (!incompleteReasons.includes("parse-failure")) incompleteReasons.push("parse-failure");
13479
+ }
13480
+ if (parsed.truncated && !incompleteReasons.includes("entry-count-limit")) incompleteReasons.push("entry-count-limit");
13365
13481
  if (parsed.records.length === 0) continue;
13366
- if (records.length === 0) source = parsed.source;
13482
+ if (records.length === 0 || source === "har" && parsed.source !== "har") source = parsed.source;
13367
13483
  records.push(...parsed.records);
13368
13484
  } catch {
13369
13485
  skippedReports++;
@@ -13380,10 +13496,10 @@ function runForensics(target, options = {}) {
13380
13496
  }
13381
13497
  }
13382
13498
  const report = analyze(records, source);
13383
- if (skippedReports > 0) {
13499
+ if (skippedReports > 0 || incompleteReasons.length > 0) {
13384
13500
  report.analysisComplete = false;
13385
13501
  report.skippedReports = skippedReports;
13386
- report.incompleteReasons = incompleteReasons;
13502
+ report.incompleteReasons = [.../* @__PURE__ */ new Set([...report.incompleteReasons, ...incompleteReasons])];
13387
13503
  }
13388
13504
  let flakyMdPath;
13389
13505
  if ((options.writeFlakyMd ?? true) && report.totalTests > 0) {
@@ -13400,7 +13516,7 @@ function runForensics(target, options = {}) {
13400
13516
  output: [
13401
13517
  renderLeaderboard(report),
13402
13518
  "",
13403
- flakyMdPath !== void 0 ? renderFlakyMdHint() : renderFlakyMdNotWritten(options)
13519
+ flakyMdPath !== void 0 ? renderFlakyMdHint() : report.totalTests === 0 && (report.totalNetworkObservations ?? 0) > 0 && options.writeFlakyMd !== false ? "FLAKY.md was not written — network observations do not establish test outcomes." : renderFlakyMdNotWritten(options)
13404
13520
  ].join("\n"),
13405
13521
  flakyMdPath
13406
13522
  };
@@ -13430,14 +13546,33 @@ function parseFile(path, text) {
13430
13546
  source: "junit-xml"
13431
13547
  };
13432
13548
  let json;
13549
+ const isHarFile = /\.har$/i.test(path);
13433
13550
  try {
13434
13551
  json = JSON.parse(text);
13435
13552
  } catch {
13436
13553
  return {
13437
13554
  records: [],
13438
- source: "playwright-json"
13555
+ source: isHarFile ? "har" : "playwright-json",
13556
+ parseFailed: true
13439
13557
  };
13440
13558
  }
13559
+ if (looksLikeHarJson(json)) {
13560
+ const parsed = parseHarJsonDetailed(json);
13561
+ return {
13562
+ records: parsed.records,
13563
+ source: "har",
13564
+ truncated: parsed.truncated
13565
+ };
13566
+ }
13567
+ if (isHarFile || json !== null && typeof json === "object" && [
13568
+ "suites",
13569
+ "specs",
13570
+ "testResults"
13571
+ ].some((key) => key in json && !Array.isArray(json[key]))) return {
13572
+ records: [],
13573
+ source: isHarFile ? "har" : "playwright-json",
13574
+ parseFailed: true
13575
+ };
13441
13576
  if (looksLikeJestJson(json)) return {
13442
13577
  records: parseJestJson(json),
13443
13578
  source: "jest-json"
@@ -13466,7 +13601,7 @@ function listFiles(dir) {
13466
13601
  if (e.isDirectory()) {
13467
13602
  if (["node_modules", ".git"].includes(e.name)) continue;
13468
13603
  walk(full, depth + 1);
13469
- } else if (e.isFile() && /\.(?:json|xml|zip|trace|ndjson)$/i.test(e.name)) out.push(full);
13604
+ } else if (e.isFile() && /\.(?:json|xml|zip|trace|ndjson|har)$/i.test(e.name)) out.push(full);
13470
13605
  }
13471
13606
  };
13472
13607
  walk(dir, 0);
@@ -13813,18 +13948,22 @@ function loadJsonRule(path, result) {
13813
13948
  for (const re of regexes) {
13814
13949
  const run = new RegExp(re.source, "g");
13815
13950
  let m;
13816
- while ((m = run.exec(view)) !== null) findings.push({
13817
- severity,
13818
- confidence,
13819
- findingType: "heuristic-risk",
13820
- qaImpact,
13821
- file: ctx.path,
13822
- line: 1 + view.slice(0, m.index).split("\n").length - 1,
13823
- column: m.index - (view.lastIndexOf("\n", m.index - 1) + 1) + 1,
13824
- message,
13825
- why,
13826
- fix
13827
- });
13951
+ while ((m = run.exec(view)) !== null) {
13952
+ if (findings.length >= 1e4) throw new Error(`External rule ${id} exceeded its match limit`);
13953
+ if (m[0].length === 0) run.lastIndex = m.index + 1;
13954
+ findings.push({
13955
+ severity,
13956
+ confidence,
13957
+ findingType: "heuristic-risk",
13958
+ qaImpact,
13959
+ file: ctx.path,
13960
+ line: 1 + view.slice(0, m.index).split("\n").length - 1,
13961
+ column: m.index - (view.lastIndexOf("\n", m.index - 1) + 1) + 1,
13962
+ message,
13963
+ why,
13964
+ fix
13965
+ });
13966
+ }
13828
13967
  }
13829
13968
  return findings;
13830
13969
  }
@@ -14044,20 +14183,27 @@ function pathMatchesGlob(path, glob) {
14044
14183
  * Plan §16 + WI-11: locate a runtime run report next to the scan
14045
14184
  * target, using the exact conventions the forensics ingestion already
14046
14185
  * accepts. Zero-config search over conventional artifact names at
14047
- * depth ≤ 2 (src/discovery/evidence-discovery.ts); the FIRST parsable
14048
- * candidate wins (priority: mjolnir-report > playwright-json >
14049
- * test-results-dir > junit-file). Validates each candidate by attempting
14050
- * to parse it and checking that it produces at least one test. Returns
14051
- * the parsed report alongside the path to avoid double-parsing.
14186
+ * depth ≤ 2 (src/discovery/evidence-discovery.ts). The FIRST parsable
14187
+ * candidate with `totalTests > 0` is kept as a fallback; the loop
14188
+ * continues and returns a later candidate when one with
14189
+ * `analysisComplete === true` is found. Returns the parsed report
14190
+ * alongside the path to avoid double-parsing.
14052
14191
  */
14053
14192
  function discoverAndParseRuntimeReport(scanRoot) {
14193
+ let fallback;
14054
14194
  for (const c of discoverEvidenceCandidates(scanRoot)) try {
14055
14195
  const fr = runForensics(c.path, { writeFlakyMd: false });
14056
- if (fr.report.totalTests > 0) return {
14196
+ if (fr.report.totalTests <= 0) continue;
14197
+ if (!fallback) fallback = {
14198
+ path: c.path,
14199
+ report: fr.report
14200
+ };
14201
+ if (fr.report.analysisComplete === true) return {
14057
14202
  path: c.path,
14058
14203
  report: fr.report
14059
14204
  };
14060
14205
  } catch {}
14206
+ return fallback;
14061
14207
  }
14062
14208
  /**
14063
14209
  * Audit W10 — runtime shape validation at the rule→Finding boundary.
@@ -14161,6 +14307,7 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14161
14307
  continue;
14162
14308
  }
14163
14309
  let fileBudgetExceeded = false;
14310
+ let fileRuleFailed = false;
14164
14311
  const relPath = relative(workspace.root, path).replaceAll("\\", "/");
14165
14312
  if (!isCiAdapter) {
14166
14313
  const lang = {
@@ -14227,6 +14374,7 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14227
14374
  } : parsedFile;
14228
14375
  adapter.runRules(activeRules, fileForRules, (f, ruleId, category) => {
14229
14376
  if (!isValidFindingRecord(f)) {
14377
+ fileRuleFailed = true;
14230
14378
  onRuleCrash?.(ruleId, relPath, /* @__PURE__ */ new Error(`malformed finding record rejected (severity/line/message must be present, severity ∈ error|warning|info): ${JSON.stringify(f)}`));
14231
14379
  return;
14232
14380
  }
@@ -14236,6 +14384,7 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14236
14384
  category
14237
14385
  });
14238
14386
  }, (ruleId, error) => {
14387
+ fileRuleFailed = true;
14239
14388
  onRuleCrash?.(ruleId, relPath, error);
14240
14389
  }, {
14241
14390
  deadline: Math.min(deadline, Date.now() + LIMITS$1.maxFileAnalysisMs),
@@ -14246,7 +14395,7 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14246
14395
  fileBudgetExceeded = true;
14247
14396
  }
14248
14397
  });
14249
- cache.store(cacheKey, findings.slice(findingsStart), fileBudgetExceeded);
14398
+ if (!fileRuleFailed) cache.store(cacheKey, findings.slice(findingsStart), fileBudgetExceeded);
14250
14399
  } catch {
14251
14400
  skippedFiles++;
14252
14401
  parseFailed++;
@@ -14263,6 +14412,31 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14263
14412
  scanned
14264
14413
  };
14265
14414
  }
14415
+ /**
14416
+ * Summarize forensic classifications from an ingested runtime report
14417
+ * (plan §10.4, WAVE 5). Returns undefined when no verdict carries a
14418
+ * classification — the machine contract slot stays absent rather than
14419
+ * reporting all-zero counts.
14420
+ */
14421
+ function summarizeForensicVerdicts(report) {
14422
+ if (report.analysisComplete !== true) return void 0;
14423
+ const byVerdict = {};
14424
+ let classifications = 0;
14425
+ let inconclusive = 0;
14426
+ for (const v of report.verdicts) {
14427
+ if (!v.forensic) continue;
14428
+ classifications++;
14429
+ const label = v.forensic.verdict;
14430
+ byVerdict[label] = (byVerdict[label] ?? 0) + 1;
14431
+ if (label === "inconclusive") inconclusive++;
14432
+ }
14433
+ if (classifications === 0) return void 0;
14434
+ return {
14435
+ classifications,
14436
+ byVerdict,
14437
+ inconclusive
14438
+ };
14439
+ }
14266
14440
  function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, declarationsByFile, testDeclarationCount, tierByRuleId, REVISION_BY_RULE_ID) {
14267
14441
  let scopeInfo = { scope: "all" };
14268
14442
  if (args.scopeChanged) {
@@ -14311,9 +14485,11 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
14311
14485
  for (const f of findings) f.fixGroupId = f.ruleId;
14312
14486
  const discoveredReport = discoverAndParseRuntimeReport(scanRoot.root);
14313
14487
  const runtimeReportPath = discoveredReport?.path;
14314
- if (discoveredReport) try {
14488
+ let forensicVerdicts;
14489
+ if (discoveredReport && discoveredReport.report.analysisComplete === true) try {
14315
14490
  buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
14316
14491
  stampRuntimeCorroboration(findings, discoveredReport.report, workspace.root);
14492
+ forensicVerdicts = summarizeForensicVerdicts(discoveredReport.report);
14317
14493
  } catch {}
14318
14494
  return {
14319
14495
  testDeclarationCount,
@@ -14321,6 +14497,7 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
14321
14497
  suppressionCount,
14322
14498
  frameworks,
14323
14499
  runtimeReportPath,
14500
+ forensicVerdicts,
14324
14501
  config
14325
14502
  };
14326
14503
  }
@@ -14329,42 +14506,45 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
14329
14506
  * Uses the dependency graph to assign findings to their nearest package
14330
14507
  * manifest, falling back to the workspace root.
14331
14508
  */
14332
- function partitionFindingsByPackage(findings, depGraph, workspaceRoot) {
14333
- const packagePaths = depGraph.allPaths;
14334
- if (packagePaths.length === 0) return [];
14335
- const normalizedRoot = workspaceRoot.replaceAll("\\", "/");
14509
+ function partitionFindingsByPackage(findings, depGraph, workspaceRoot, declarationsByFile, analysisComplete) {
14336
14510
  const packageMap = /* @__PURE__ */ new Map();
14337
- for (const manifestPath of packagePaths) {
14338
- const relPath = manifestPath.replaceAll("\\", "/").replace(normalizedRoot, "").replace(/^\//, "");
14339
- const pathSegments = relPath.split("/");
14340
- const packageName = pathSegments.length > 1 ? pathSegments[1] ?? "root" : "root";
14341
- packageMap.set(manifestPath, {
14342
- packageName,
14343
- path: relPath.replace("/package.json", "").replace("/pyproject.toml", "").replace("/pom.xml", ""),
14344
- findings: []
14345
- });
14346
- }
14347
- for (const f of findings) {
14348
- const normalizedFile = f.file.replaceAll("\\", "/");
14349
- let bestMatch;
14350
- let bestLen = 0;
14351
- for (const manifestPath of packagePaths) {
14352
- const relToRoot = manifestPath.replaceAll("\\", "/").replace(/[/\\][^/\\]+$/, "").replace(normalizedRoot, "").replace(/^\//, "");
14353
- if (relToRoot && normalizedFile.startsWith(relToRoot + "/")) {
14354
- if (relToRoot.length > bestLen) {
14355
- bestLen = relToRoot.length;
14356
- bestMatch = manifestPath;
14357
- }
14358
- }
14359
- }
14360
- const target = bestMatch ? packageMap.get(bestMatch) : packageMap.values().next().value;
14361
- if (target) target.findings.push(f);
14511
+ const addPackage = (path) => {
14512
+ const entry = {
14513
+ packageName: path === "" ? "root" : basename(path),
14514
+ path: path || ".",
14515
+ findings: [],
14516
+ testDeclarations: 0,
14517
+ testFileCount: 0
14518
+ };
14519
+ packageMap.set(path, entry);
14520
+ return entry;
14521
+ };
14522
+ for (const manifestPath of depGraph.allPaths) {
14523
+ const path = relative(workspaceRoot, dirname(manifestPath)).replaceAll("\\", "/");
14524
+ if (path === ".." || path.startsWith("../")) continue;
14525
+ if (!packageMap.has(path)) addPackage(path);
14526
+ }
14527
+ const packageForFile = (file) => {
14528
+ const normalizedFile = file.replaceAll("\\", "/");
14529
+ let bestPath = "";
14530
+ for (const path of packageMap.keys()) if (path.length > bestPath.length && normalizedFile.startsWith(path + "/")) bestPath = path;
14531
+ return packageMap.get(bestPath) ?? addPackage("");
14532
+ };
14533
+ for (const f of findings) packageForFile(f.file).findings.push(f);
14534
+ for (const [file, declarations] of declarationsByFile) {
14535
+ const target = packageForFile(file);
14536
+ target.testDeclarations += declarations;
14537
+ target.testFileCount++;
14362
14538
  }
14363
14539
  return [...packageMap.values()].map((p) => ({
14364
14540
  packageName: p.packageName,
14365
14541
  path: p.path,
14366
14542
  findings: p.findings,
14367
- score: p.findings.length === 0 ? 100 : null
14543
+ score: analysisComplete && p.testDeclarations > 0 && p.testFileCount > 0 ? computeTotal(computeDimensions(p.findings), p.findings, {
14544
+ testDeclarations: p.testDeclarations,
14545
+ testFileCount: p.testFileCount,
14546
+ suiteInvalidatingRuleIds: SUITE_INVALIDATING_RULE_IDS
14547
+ }) : null
14368
14548
  }));
14369
14549
  }
14370
14550
  function assembleScanResult(o) {
@@ -14461,6 +14641,7 @@ function assembleScanResult(o) {
14461
14641
  ...o.pluginsLoaded.length > 0 ? { plugins: o.pluginsLoaded } : {},
14462
14642
  ...suiteInvalidatedBy.length > 0 ? { suiteInvalidatedBy } : {},
14463
14643
  agenticProfile: computeAgenticProfile(o.fileProvenance, o.findings),
14644
+ ...o.forensicVerdicts !== void 0 ? { forensicVerdicts: o.forensicVerdicts } : {},
14464
14645
  ...o.args.cache ? { cache: {
14465
14646
  hits: o.cache.stats.hits,
14466
14647
  misses: o.cache.stats.misses,
@@ -14487,7 +14668,7 @@ function assembleScanResult(o) {
14487
14668
  };
14488
14669
  }
14489
14670
  if (o.args.monorepo && o.dependencyGraph && o.dependencyGraph.size > 1) {
14490
- const packages = partitionFindingsByPackage(o.findings, o.dependencyGraph, o.workspace.root);
14671
+ const packages = partitionFindingsByPackage(o.findings, o.dependencyGraph, o.workspace.root, o.declarationsByFile, !result.partial && scopeReasons.length === 0 && !o.args.scopeChanged);
14491
14672
  if (packages.length > 1) {
14492
14673
  const monorepoResult = analyzeMonorepo(packages, { weightingStrategy: "worst-package" });
14493
14674
  result.monorepoAnalysis = {
@@ -14551,7 +14732,7 @@ async function runScan(args, hooks = {}) {
14551
14732
  const REVISION_BY_RULE_ID = new Map(activeRules.map((r) => [r.id, r.detectorRevision ?? MEASURED_FP[r.id]?.detectorRevision ?? 1]));
14552
14733
  const cache = args.cache ? createScanCache(workspace.root) : disabledScanCache;
14553
14734
  const depGraph = buildDependencyGraph(workspace.root);
14554
- const rulesDigest = computeRulesDigest(activeRules);
14735
+ const rulesDigest = `complete-file-v1:${computeRulesDigest(activeRules)}`;
14555
14736
  if (args.cache && args.verbose) {
14556
14737
  const safety = isIncrementalSafe([]);
14557
14738
  if (!safety.safe) hooks.onConfigWarning?.(`incremental: ${safety.reasons.join("; ")}`);
@@ -14637,6 +14818,7 @@ async function runScan(args, hooks = {}) {
14637
14818
  suppressionCount: postScan.suppressionCount,
14638
14819
  frameworks: postScan.frameworks,
14639
14820
  runtimeReportPath: postScan.runtimeReportPath,
14821
+ forensicVerdicts: postScan.forensicVerdicts,
14640
14822
  config: postScan.config,
14641
14823
  fileProvenance,
14642
14824
  started,
@@ -14693,10 +14875,14 @@ function canonicalScanJson(result) {
14693
14875
  });
14694
14876
  }
14695
14877
  /** Advisory when the DERIVED evidence level is E0 (same rule as scoring). */
14878
+ function effectiveEvidenceLevel(f) {
14879
+ return f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence);
14880
+ }
14696
14881
  function isAdvisory(f) {
14697
- return (f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence)) === "E0";
14882
+ return effectiveEvidenceLevel(f) === "E0";
14698
14883
  }
14699
- function levelFor(f) {
14884
+ function levelFor(f, evidenceLevel = effectiveEvidenceLevel(f)) {
14885
+ if (evidenceLevel === "E0") return "notice";
14700
14886
  if (f.severity === "error") return "failure";
14701
14887
  if (f.severity === "warning") return "warning";
14702
14888
  return "notice";
@@ -14710,15 +14896,18 @@ function countBy(findings, predicate) {
14710
14896
  */
14711
14897
  function buildMachineContract(result) {
14712
14898
  const digest = createHash("sha256").update(canonicalScanJson(result)).digest("hex");
14713
- const annotations = result.findings.map((f) => ({
14714
- path: f.file,
14715
- start_line: f.line,
14716
- annotation_level: levelFor(f),
14717
- message: `${f.ruleId}: ${f.message}`,
14718
- ruleId: f.ruleId,
14719
- ...f.detectorRevision !== void 0 ? { detectorRevision: f.detectorRevision } : {},
14720
- advisory: isAdvisory(f)
14721
- }));
14899
+ const annotations = result.findings.map((f) => {
14900
+ const evidenceLevel = effectiveEvidenceLevel(f);
14901
+ return {
14902
+ path: f.file,
14903
+ start_line: f.line,
14904
+ annotation_level: levelFor(f, evidenceLevel),
14905
+ message: `${f.ruleId}: ${f.message}`,
14906
+ ruleId: f.ruleId,
14907
+ ...f.detectorRevision !== void 0 ? { detectorRevision: f.detectorRevision } : {},
14908
+ advisory: evidenceLevel === "E0"
14909
+ };
14910
+ });
14722
14911
  return {
14723
14912
  contractVersion: 1,
14724
14913
  summary: {
@@ -14743,7 +14932,8 @@ function buildMachineContract(result) {
14743
14932
  durationMs: result.analysisStatus.durationMs
14744
14933
  },
14745
14934
  ...result.trustSummary !== void 0 ? { trustSummary: result.trustSummary } : {},
14746
- ...result.agenticProfile !== void 0 ? { provenance: result.agenticProfile } : {}
14935
+ ...result.agenticProfile !== void 0 ? { provenance: result.agenticProfile } : {},
14936
+ ...result.forensicVerdicts !== void 0 ? { forensicVerdicts: result.forensicVerdicts } : {}
14747
14937
  };
14748
14938
  }
14749
14939
  //#endregion