From 9c74c4180a82f781631b0303fa15a44549bf082e Mon Sep 17 00:00:00 2001 From: GautamSharma99 Date: Thu, 30 Jul 2026 19:28:28 +0530 Subject: [PATCH] fix: bound scan history matching --- sdk/typescript/README.md | 4 + .../_bundled_plugin/scripts/workbench_cli.py | 5 + .../scripts/workbench_constants.py | 3 + .../_bundled_plugin/scripts/workbench_db.py | 9 +- .../scripts/workbench_scan_history.py | 179 ++++++-- sdk/typescript/src/cli.ts | 294 ++++++++++--- sdk/typescript/tests-ts/cli-workbench.test.ts | 385 +++++++++++++----- 7 files changed, 687 insertions(+), 192 deletions(-) diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 9e1f4a51..71046f8d 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -440,6 +440,10 @@ checkout so a fixed vulnerability can be checked again. cause; `scans match --all` matches all completed scans of the current repository, including other worktrees and clones. Saved matches appear in `scans show` and are reused unless `--force` is passed. Scans without sealed artifacts are skipped. +Automatic history matching loads at most 64 scan pairs per workbench page and +32 findings or 512 KiB per finding page. Each model comparison receives one +bounded page from each scan; cross-page matches are reconciled before the pair +is saved. Match results larger than 1 MiB are rejected with an actionable error. `scans compare BEFORE_SCAN_ID AFTER_SCAN_ID` automatically matches findings by root cause, reuses saved matches, and reports findings as new, persisting, diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_cli.py b/sdk/typescript/_bundled_plugin/scripts/workbench_cli.py index 8e0c38e9..3a4498af 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_cli.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_cli.py @@ -152,6 +152,11 @@ def parse_args(description: str) -> argparse.Namespace: list_unmatched_scan_pairs = subparsers.add_parser("list-unmatched-scan-pairs") list_unmatched_scan_pairs.add_argument("--repository", required=True) list_unmatched_scan_pairs.add_argument("--force", action="store_true") + list_unmatched_scan_pairs.add_argument("--offset", type=non_negative_int, default=0) + + get_scan_matching_inputs = subparsers.add_parser("get-scan-matching-inputs") + get_scan_matching_inputs.add_argument("--scan-id", required=True) + get_scan_matching_inputs.add_argument("--offset", type=non_negative_int, default=0) register_cli_scan = subparsers.add_parser("register-cli-scan") register_cli_scan.add_argument("--scan-dir", required=True) diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py b/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py index 4ddd91f3..08b5e704 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py @@ -42,6 +42,9 @@ PATCH_ARTIFACT_MAX_BYTES = 2 * 1024 * 1024 FINDINGS_RESULT_LIMIT = 20 FINDINGS_PAGE_MAX = 20 +MATCHING_PAIR_PAGE_MAX = 64 +MATCHING_FINDING_PAGE_MAX = 32 +MATCHING_INPUT_PAGE_BYTES = 512 * 1024 FINDING_DETAILS_PREVIEW_BYTES = 16_000 FINDING_ROOT_CAUSE_PREVIEW_BYTES = 2_000 FINDING_VALIDATION_PREVIEW_BYTES = 3_000 diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py index ebee840c..b6c1ad34 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py @@ -3568,9 +3568,16 @@ def main() -> None: result = scan_history.list_unmatched_scan_pairs( connection, args, - backfill_finding_details=backfill_legacy_finding_details, read_coverage=coverage_for_comparison, ) + elif args.command == "get-scan-matching-inputs": + result = scan_history.get_scan_matching_inputs( + connection, + args, + require_scan=require_scan, + read_coverage=coverage_for_comparison, + backfill_finding_details=backfill_legacy_finding_details, + ) elif args.command == "register-cli-scan": result = register_cli_scan(connection, args) elif args.command == "get-scan-recipe": diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_scan_history.py b/sdk/typescript/_bundled_plugin/scripts/workbench_scan_history.py index c92f1490..3896faa2 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_scan_history.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_scan_history.py @@ -3,6 +3,7 @@ import argparse import fnmatch import json +import math import os import sqlite3 from pathlib import Path, PurePosixPath @@ -10,7 +11,12 @@ from urllib.parse import urlsplit from report_projection import SEVERITY_ORDER -from workbench_constants import FINDINGS_PAGE_MAX +from workbench_constants import ( + FINDINGS_PAGE_MAX, + MATCHING_FINDING_PAGE_MAX, + MATCHING_INPUT_PAGE_BYTES, + MATCHING_PAIR_PAGE_MAX, +) from workbench_target import git_output @@ -271,7 +277,6 @@ def list_unmatched_scan_pairs( connection: sqlite3.Connection, args: argparse.Namespace, *, - backfill_finding_details: Callable[[sqlite3.Connection, sqlite3.Row], None], read_coverage: Callable[[sqlite3.Row], dict[str, Any]], ) -> dict[str, Any]: repository = Path(args.repository).expanduser().resolve() @@ -302,42 +307,42 @@ def list_unmatched_scan_pairs( (row["before_scan_id"], row["after_scan_id"]) for row in connection.execute("SELECT before_scan_id, after_scan_id FROM scan_comparisons") } - batches = [] - skipped = 0 - backfilled: set[str] = set() - for index, after in enumerate(available): - previous = [ - before - for before in available[:index] - if args.force or (before["id"], after["id"]) not in saved_pairs - ] - skipped += index - len(previous) - if not previous: - continue - for scan in (*previous, after): - if scan["id"] not in backfilled: - backfill_finding_details(connection, scan) - backfilled.add(scan["id"]) - batches.append( - { - "afterFindings": [ - _matching_input(row) for row in _scan_findings(connection, after["id"]).values() - ], - "afterScanId": after["id"], - "beforeScans": [ - { - "findings": [ - _matching_input(row) - for row in _scan_findings(connection, before["id"]).values() - ], - "scanId": before["id"], - } - for before in previous - ], - } + available_indexes = {scan["id"]: index for index, scan in enumerate(available)} + skipped = ( + 0 + if args.force + else sum( + 1 + for before_id, after_id in saved_pairs + if before_id in available_indexes + and after_id in available_indexes + and available_indexes[before_id] < available_indexes[after_id] ) + ) + pair_count = len(available) * (len(available) - 1) // 2 + page_end = min(args.offset + MATCHING_PAIR_PAGE_MAX, pair_count) + pair_index = min(args.offset, pair_count) + after_index = (1 + math.isqrt(1 + 8 * pair_index)) // 2 + before_index = pair_index - after_index * (after_index - 1) // 2 + pairs = [] + while pair_index < page_end: + before = available[before_index] + after = available[after_index] + if args.force or (before["id"], after["id"]) not in saved_pairs: + pairs.append( + { + "afterScanId": after["id"], + "beforeScanId": before["id"], + } + ) + pair_index += 1 + before_index += 1 + if before_index == after_index: + after_index += 1 + before_index = 0 return { - "batches": batches, + "nextOffset": page_end if page_end < pair_count else None, + "pairs": pairs, "repository": str(repository), "scanCount": len(selected), "skippedPairs": skipped, @@ -345,6 +350,76 @@ def list_unmatched_scan_pairs( } +def get_scan_matching_inputs( + connection: sqlite3.Connection, + args: argparse.Namespace, + *, + require_scan: Callable[[sqlite3.Connection, str], sqlite3.Row], + read_coverage: Callable[[sqlite3.Row], dict[str, Any]], + backfill_finding_details: Callable[[sqlite3.Connection, sqlite3.Row], None], +) -> dict[str, Any]: + scan = require_scan(connection, args.scan_id) + if scan["status"] != "complete": + raise SystemExit("Only completed scans can be matched.") + read_coverage(scan) + backfill_finding_details(connection, scan) + if connection.execute( + """ + SELECT 1 + FROM finding_occurrences + WHERE scan_id = ? AND length(CAST(details_json AS BLOB)) > ? + LIMIT 1 + """, + (scan["id"], MATCHING_INPUT_PAGE_BYTES), + ).fetchone(): + raise SystemExit( + "A scan finding exceeds the 512 KiB automatic matching input limit." + ) + total_findings = connection.execute( + "SELECT COUNT(*) FROM finding_occurrences WHERE scan_id = ?", + (scan["id"],), + ).fetchone()[0] + rows = _scan_finding_page( + connection, + scan["id"], + offset=args.offset, + limit=MATCHING_FINDING_PAGE_MAX, + ) + findings = [] + encoded_bytes = 2 + next_offset = min(args.offset, total_findings) + for row in rows: + finding = _matching_input(row) + item_bytes = len( + json.dumps( + finding, + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + ) + if item_bytes > MATCHING_INPUT_PAGE_BYTES: + raise SystemExit( + "A scan finding exceeds the 512 KiB automatic matching input limit." + ) + separator_bytes = 1 if findings else 0 + if encoded_bytes + separator_bytes + item_bytes > MATCHING_INPUT_PAGE_BYTES: + if not findings: + raise SystemExit( + "A scan finding exceeds the 512 KiB automatic matching input limit." + ) + break + findings.append(finding) + encoded_bytes += separator_bytes + item_bytes + next_offset += 1 + return { + "findings": findings, + "nextOffset": next_offset if next_offset < total_findings else None, + "scanId": scan["id"], + "totalFindings": total_findings, + } + + def compare_scans( connection: sqlite3.Connection, args: argparse.Namespace, @@ -781,12 +856,44 @@ def _scan_findings(connection: sqlite3.Connection, scan_id: str) -> dict[str, sq FROM finding_occurrences AS occurrences LEFT JOIN finding_triage AS triage ON triage.occurrence_id = occurrences.id WHERE occurrences.scan_id = ? + ORDER BY occurrences.id """, (scan_id,), ) return {row["finding_id"]: row for row in rows} +def _scan_finding_page( + connection: sqlite3.Connection, + scan_id: str, + *, + offset: int, + limit: int, +) -> list[sqlite3.Row]: + return list( + connection.execute( + """ + SELECT occurrences.*, + COALESCE(triage.status, 'open') AS triage_status, triage.close_reason, + ( + SELECT locations.relative_path + FROM finding_locations AS locations + WHERE locations.occurrence_id = occurrences.id + ORDER BY CASE WHEN locations.role = 'root_control' THEN 0 ELSE 1 END, + locations.sort_order + LIMIT 1 + ) AS relative_path + FROM finding_occurrences AS occurrences + LEFT JOIN finding_triage AS triage ON triage.occurrence_id = occurrences.id + WHERE occurrences.scan_id = ? + ORDER BY occurrences.id + LIMIT ? OFFSET ? + """, + (scan_id, limit, offset), + ) + ) + + def scan_covers_path( scan: sqlite3.Row, *, diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 1abe8302..7dae35a9 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -77,6 +77,7 @@ import { import { matchScanFindings, type ScanComparisonInput, + type ScanComparisonResult, } from "./scan-comparison.js"; import { renderScanHistory, @@ -230,20 +231,29 @@ interface ExportArguments { pythonPath?: string; } -interface MatchingBatch { +interface MatchingPair { afterScanId: string; - afterFindings: ScanComparisonInput["after"]; - beforeScans: { scanId: string; findings: ScanComparisonInput["before"] }[]; + beforeScanId: string; } -type MatchingPlan = JsonObject & { +type MatchingPlanPage = JsonObject & { + nextOffset: number | null; + pairs: (JsonObject & MatchingPair)[]; repository: string; scanCount: number; unavailableScans: number; skippedPairs: number; - batches: (JsonObject & MatchingBatch)[]; }; +type MatchingInputPage = JsonObject & { + findings: ScanComparisonInput["before"]; + nextOffset: number | null; + scanId: string; + totalFindings: number; +}; + +const MAX_MATCH_RESULT_BYTES = 1024 * 1024; + interface SkillCommandOutput { readonly command: "validate" | "patch"; readonly stdout: Writable; @@ -1960,72 +1970,60 @@ async function matchAllScans( dependencies: CliDependencies, force: boolean, ): Promise { - const result = (await dependencies.runWorkbench([ - "list-unmatched-scan-pairs", - "--repository", - dependencies.currentDirectory(), - ...(force ? ["--force"] : []), - ])) as MatchingPlan; - const { repository, scanCount, unavailableScans, skippedPairs, batches } = - result; - + let repository = dependencies.currentDirectory(); + let scanCount = 0; + let unavailableScans = 0; + let skippedPairs = 0; let matchedPairs = 0; let findingMatches = 0; - for (const { afterScanId, afterFindings, beforeScans } of batches) { - const before = beforeScans.flatMap(({ findings }) => findings); - const matching = - before.length === 0 || afterFindings.length === 0 - ? { matches: [], uncertain: [] } - : await dependencies.matchFindings( - { before, after: afterFindings }, - { allowHistoricalUncertainty: true }, - ); - const comparisons = beforeScans.map(({ scanId, findings }) => { - const beforeIds = new Set( - findings.map(({ occurrenceId }) => occurrenceId), - ); - const matches = matching.matches.flatMap((match) => { - const beforeOccurrenceIds = match.beforeOccurrenceIds.filter((id) => - beforeIds.has(id), - ); - return beforeOccurrenceIds.length === 0 - ? [] - : [{ ...match, beforeOccurrenceIds }]; - }); - const uncertain = matching.uncertain.filter(({ beforeOccurrenceId }) => - beforeIds.has(beforeOccurrenceId), - ); - const matchedAfter = new Set( - matches.flatMap(({ afterOccurrenceIds }) => afterOccurrenceIds), + let offset = 0; + let firstPage = true; + while (true) { + const page = (await dependencies.runWorkbench([ + "list-unmatched-scan-pairs", + "--repository", + dependencies.currentDirectory(), + "--offset", + String(offset), + ...(force ? ["--force"] : []), + ])) as MatchingPlanPage; + if (firstPage) { + ({ repository, scanCount, unavailableScans, skippedPairs } = page); + firstPage = false; + } + for (const { beforeScanId, afterScanId } of page.pairs) { + const matching = await matchScanPairInBatches( + dependencies, + beforeScanId, + afterScanId, ); - if ( - uncertain.some(({ afterOccurrenceId }) => - matchedAfter.has(afterOccurrenceId), - ) - ) { - throw new CodexSecurityError( - "Scan matching returned conflicting confirmed and uncertain findings.", - ); + const serialized = JSON.stringify(matching); + if (Buffer.byteLength(serialized) > MAX_MATCH_RESULT_BYTES) { + throw oversizedAutomaticMatchError(); } - return { scanId, matches, uncertain }; - }); - for (const { scanId, matches, uncertain } of comparisons) { await dependencies.runWorkbench([ "save-scan-comparison", "--before-scan-id", - scanId, + beforeScanId, "--after-scan-id", afterScanId, "--matches-json", - JSON.stringify({ matches, uncertain }), + serialized, ]); matchedPairs += 1; - findingMatches += matches.reduce( + findingMatches += matching.matches.reduce( (count, { beforeOccurrenceIds, afterOccurrenceIds }) => count + beforeOccurrenceIds.length * afterOccurrenceIds.length, 0, ); } + if (page.nextOffset === null) break; + if (!Number.isSafeInteger(page.nextOffset) || page.nextOffset <= offset) { + throw new CodexSecurityError( + "Scan matching returned an invalid pagination cursor.", + ); + } + offset = page.nextOffset; } return { repository, @@ -2037,6 +2035,194 @@ async function matchAllScans( }; } +async function matchScanPairInBatches( + dependencies: CliDependencies, + beforeScanId: string, + afterScanId: string, +): Promise { + let beforePage = await matchingInputPage(dependencies, beforeScanId, 0); + const firstAfterPage = await matchingInputPage(dependencies, afterScanId, 0); + if ( + beforePage.findings.length === 0 || + firstAfterPage.findings.length === 0 + ) { + return { matches: [], uncertain: [] }; + } + + const confirmed: ScanComparisonResult["matches"] = []; + const uncertain = new Map< + string, + ScanComparisonResult["uncertain"][number] + >(); + let retainedBytes = 0; + while (true) { + let afterPage = firstAfterPage; + while (true) { + const result = await dependencies.matchFindings( + { + before: beforePage.findings, + after: afterPage.findings, + }, + { allowHistoricalUncertainty: true }, + ); + retainedBytes += Buffer.byteLength(JSON.stringify(result)); + if (retainedBytes > MAX_MATCH_RESULT_BYTES) { + throw oversizedAutomaticMatchError(); + } + confirmed.push(...result.matches); + for (const candidate of result.uncertain) { + const key = JSON.stringify([ + candidate.beforeOccurrenceId, + candidate.afterOccurrenceId, + ]); + const existing = uncertain.get(key); + if (existing === undefined || candidate.reason < existing.reason) { + uncertain.set(key, candidate); + } + } + if (afterPage.nextOffset === null) break; + afterPage = await matchingInputPage( + dependencies, + afterScanId, + afterPage.nextOffset, + ); + } + if (beforePage.nextOffset === null) break; + beforePage = await matchingInputPage( + dependencies, + beforeScanId, + beforePage.nextOffset, + ); + } + return reconcileMatchingBatches(confirmed, [...uncertain.values()]); +} + +function oversizedAutomaticMatchError(): CodexSecurityError { + return new CodexSecurityError( + "Automatic scan matching produced more than 1 MiB of match data. Review this scan pair manually.", + ); +} + +async function matchingInputPage( + dependencies: CliDependencies, + scanId: string, + offset: number, +): Promise { + const page = (await dependencies.runWorkbench([ + "get-scan-matching-inputs", + "--scan-id", + scanId, + "--offset", + String(offset), + ])) as MatchingInputPage; + if ( + page.scanId !== scanId || + !Array.isArray(page.findings) || + (page.nextOffset !== null && + (!Number.isSafeInteger(page.nextOffset) || page.nextOffset <= offset)) + ) { + throw new CodexSecurityError( + "Scan matching returned an invalid finding page.", + ); + } + return page; +} + +function reconcileMatchingBatches( + confirmed: ScanComparisonResult["matches"], + uncertain: ScanComparisonResult["uncertain"], +): ScanComparisonResult { + const parents = new Map(); + const find = (value: string): string => { + const parent = parents.get(value); + if (parent === undefined) { + parents.set(value, value); + return value; + } + if (parent === value) return value; + const root = find(parent); + parents.set(value, root); + return root; + }; + const union = (left: string, right: string): void => { + const leftRoot = find(left); + const rightRoot = find(right); + if (leftRoot !== rightRoot) { + parents.set( + leftRoot < rightRoot ? rightRoot : leftRoot, + leftRoot < rightRoot ? leftRoot : rightRoot, + ); + } + }; + for (const match of confirmed) { + const nodes = [ + ...match.beforeOccurrenceIds.map((id) => `before:${id}`), + ...match.afterOccurrenceIds.map((id) => `after:${id}`), + ]; + for (const node of nodes.slice(1)) union(nodes[0]!, node); + } + + const groups = new Map< + string, + { + before: Set; + after: Set; + reasons: Set; + } + >(); + for (const match of confirmed) { + const root = find(`before:${match.beforeOccurrenceIds[0]!}`); + const group = groups.get(root) ?? { + before: new Set(), + after: new Set(), + reasons: new Set(), + }; + match.beforeOccurrenceIds.forEach((id) => group.before.add(id)); + match.afterOccurrenceIds.forEach((id) => group.after.add(id)); + group.reasons.add(match.reason); + groups.set(root, group); + } + const matches = [...groups.values()] + .map(({ before, after, reasons }) => ({ + beforeOccurrenceIds: [...before].sort(), + afterOccurrenceIds: [...after].sort(), + confidence: "high" as const, + reason: [...reasons].sort()[0]!, + })) + .sort( + (left, right) => + left.beforeOccurrenceIds[0]!.localeCompare( + right.beforeOccurrenceIds[0]!, + ) || + left.afterOccurrenceIds[0]!.localeCompare(right.afterOccurrenceIds[0]!), + ); + const matchedBefore = new Set( + matches.flatMap(({ beforeOccurrenceIds }) => beforeOccurrenceIds), + ); + const matchedAfter = new Set( + matches.flatMap(({ afterOccurrenceIds }) => afterOccurrenceIds), + ); + if ( + uncertain.some( + ({ beforeOccurrenceId, afterOccurrenceId }) => + matchedBefore.has(beforeOccurrenceId) || + matchedAfter.has(afterOccurrenceId), + ) + ) { + throw new CodexSecurityError( + "Scan matching returned conflicting confirmed and uncertain findings.", + ); + } + return { + matches, + uncertain: uncertain.sort( + (left, right) => + left.beforeOccurrenceId.localeCompare(right.beforeOccurrenceId) || + left.afterOccurrenceId.localeCompare(right.afterOccurrenceId), + ), + }; +} + function staysWithinWindowsDeviceRoot(input: string, root: string): boolean { let depth = 0; for (const segment of input.slice(root.length).split(/[\\/]+/u)) { diff --git a/sdk/typescript/tests-ts/cli-workbench.test.ts b/sdk/typescript/tests-ts/cli-workbench.test.ts index 90156a43..655070e6 100644 --- a/sdk/typescript/tests-ts/cli-workbench.test.ts +++ b/sdk/typescript/tests-ts/cli-workbench.test.ts @@ -247,21 +247,11 @@ describe("CLI workbench", () => { test("matches all scans once per later scan", async () => { const finding = (occurrenceId: string) => ({ occurrenceId }); - const batches = [ - { - afterScanId: "scan-b", - afterFindings: [finding("b")], - beforeScans: [{ scanId: "scan-a", findings: [finding("a")] }], - }, - { - afterScanId: "scan-c", - afterFindings: [finding("c"), finding("c-shared")], - beforeScans: [ - { scanId: "scan-a", findings: [finding("a")] }, - { scanId: "scan-b", findings: [finding("b")] }, - ], - }, - ]; + const findings = new Map([ + ["scan-a", [finding("a")]], + ["scan-b", [finding("b")]], + ["scan-c", [finding("c"), finding("c-shared")]], + ]); const calls: Array = []; let matcherCalls = 0; const stdout = capture(); @@ -274,66 +264,96 @@ describe("CLI workbench", () => { dependencies({ onWorkbench: (args): JsonObject => { calls.push(args); - return args[0] === "list-unmatched-scan-pairs" - ? { - repository: "/current/repository", - scanCount: 5, - unavailableScans: 2, - skippedPairs: 1, - batches, - } - : {}; + if (args[0] === "list-unmatched-scan-pairs") { + return { + repository: "/current/repository", + scanCount: 5, + unavailableScans: 2, + skippedPairs: 1, + nextOffset: null, + pairs: [ + { beforeScanId: "scan-a", afterScanId: "scan-b" }, + { beforeScanId: "scan-a", afterScanId: "scan-c" }, + { beforeScanId: "scan-b", afterScanId: "scan-c" }, + ], + }; + } + if (args[0] === "get-scan-matching-inputs") { + const scanId = args[2]!; + return { + scanId, + findings: findings.get(scanId)!, + nextOffset: null, + totalFindings: findings.get(scanId)!.length, + }; + } + return {}; }, onMatch: async (input) => { matcherCalls += 1; - return input.after[0]?.occurrenceId === "b" - ? { - matches: [ - { - beforeOccurrenceIds: ["a"], - afterOccurrenceIds: ["b"], - confidence: "high", - reason: "Same root cause.", - }, - ], - uncertain: [], - } - : { - matches: [ - { - beforeOccurrenceIds: ["a", "b"], - afterOccurrenceIds: ["c"], - confidence: "high", - reason: "Same root cause.", - }, - { - beforeOccurrenceIds: ["a"], - afterOccurrenceIds: ["c-shared"], - confidence: "high", - reason: "Same root cause.", - }, - ], - uncertain: [ - { - beforeOccurrenceId: "b", - afterOccurrenceId: "c-shared", - reason: "Possibly the same root cause.", - }, - ], - }; + const before = input.before[0]?.occurrenceId; + const after = input.after[0]?.occurrenceId; + if (before === "a" && after === "b") { + return { + matches: [ + { + beforeOccurrenceIds: ["a"], + afterOccurrenceIds: ["b"], + confidence: "high", + reason: "Same root cause.", + }, + ], + uncertain: [], + }; + } + if (before === "a" && after === "c") { + return { + matches: [ + { + beforeOccurrenceIds: ["a"], + afterOccurrenceIds: ["c"], + confidence: "high", + reason: "Same root cause.", + }, + { + beforeOccurrenceIds: ["a"], + afterOccurrenceIds: ["c-shared"], + confidence: "high", + reason: "Same root cause.", + }, + ], + uncertain: [], + }; + } + return { + matches: [ + { + beforeOccurrenceIds: ["b"], + afterOccurrenceIds: ["c"], + confidence: "high", + reason: "Same root cause.", + }, + ], + uncertain: [], + }; }, }), ), ).toBe(0); - expect(matcherCalls).toBe(2); + expect(matcherCalls).toBe(3); expect(calls[0]).toEqual([ "list-unmatched-scan-pairs", "--repository", "/current/repository", + "--offset", + "0", "--force", ]); + const saves = calls.filter( + ([command]) => command === "save-scan-comparison", + ); expect( - calls.slice(1).map((args) => ({ + saves.map((args) => ({ before: args[2], after: args[4], result: JSON.parse(args[6]!), @@ -345,8 +365,10 @@ describe("CLI workbench", () => { after: "scan-c", result: { matches: [ - { beforeOccurrenceIds: ["a"], afterOccurrenceIds: ["c"] }, - { beforeOccurrenceIds: ["a"], afterOccurrenceIds: ["c-shared"] }, + { + beforeOccurrenceIds: ["a"], + afterOccurrenceIds: ["c", "c-shared"], + }, ], uncertain: [], }, @@ -356,7 +378,7 @@ describe("CLI workbench", () => { after: "scan-c", result: { matches: [{ beforeOccurrenceIds: ["b"] }], - uncertain: [{ beforeOccurrenceId: "b" }], + uncertain: [], }, }, ]); @@ -370,31 +392,183 @@ describe("CLI workbench", () => { }); }); + test("pages match-all history and reconciles bounded finding batches", async () => { + const calls: Array = []; + const matcherInputs: Array<{ + before: string[]; + after: string[]; + }> = []; + const pages = new Map([ + ["before:0", { findings: [{ occurrenceId: "b-1" }], nextOffset: 1 }], + ["before:1", { findings: [{ occurrenceId: "b-2" }], nextOffset: null }], + ["after:0", { findings: [{ occurrenceId: "a-1" }], nextOffset: 1 }], + ["after:1", { findings: [{ occurrenceId: "a-2" }], nextOffset: null }], + ]); + const stdout = capture(); + + expect( + await main( + ["scans", "match", "--all", "--json"], + stdout.stream, + capture().stream, + dependencies({ + onWorkbench: (args): JsonObject => { + calls.push(args); + if (args[0] === "list-unmatched-scan-pairs") { + const offset = Number(args[4]); + return { + repository: "/repo", + scanCount: 2, + unavailableScans: 0, + skippedPairs: 0, + nextOffset: offset === 0 ? 64 : null, + pairs: + offset === 0 + ? [] + : [{ beforeScanId: "before", afterScanId: "after" }], + }; + } + if (args[0] === "get-scan-matching-inputs") { + const scanId = args[2]!; + const offset = Number(args[4]); + return { + scanId, + totalFindings: 2, + ...pages.get(`${scanId}:${offset}`)!, + }; + } + return {}; + }, + onMatch: async (input) => { + const before = input.before.map(({ occurrenceId }) => occurrenceId); + const after = input.after.map(({ occurrenceId }) => occurrenceId); + matcherInputs.push({ before, after }); + if ( + (before[0] === "b-1" && after[0] === "a-1") || + (before[0] === "b-1" && after[0] === "a-2") || + (before[0] === "b-2" && after[0] === "a-2") + ) { + return { + matches: [ + { + beforeOccurrenceIds: before, + afterOccurrenceIds: after, + confidence: "high", + reason: `${before[0]} matches ${after[0]}`, + }, + ], + uncertain: [], + }; + } + return { matches: [], uncertain: [] }; + }, + }), + ), + ).toBe(0); + + expect( + calls + .filter(([command]) => command === "list-unmatched-scan-pairs") + .map((args) => args[4]), + ).toEqual(["0", "64"]); + expect(matcherInputs).toEqual([ + { before: ["b-1"], after: ["a-1"] }, + { before: ["b-1"], after: ["a-2"] }, + { before: ["b-2"], after: ["a-1"] }, + { before: ["b-2"], after: ["a-2"] }, + ]); + const save = calls.find(([command]) => command === "save-scan-comparison")!; + expect(JSON.parse(save[6]!)).toEqual({ + matches: [ + { + beforeOccurrenceIds: ["b-1", "b-2"], + afterOccurrenceIds: ["a-1", "a-2"], + confidence: "high", + reason: "b-1 matches a-1", + }, + ], + uncertain: [], + }); + expect(JSON.parse(stdout.text())).toMatchObject({ + matchedPairs: 1, + findingMatches: 4, + }); + }); + + test("rejects oversized accumulated match-all results before persistence", async () => { + const calls: Array = []; + const stderr = capture(); + expect( + await main( + ["scans", "match", "--all"], + capture().stream, + stderr.stream, + dependencies({ + onWorkbench: (args): JsonObject => { + calls.push(args); + if (args[0] === "list-unmatched-scan-pairs") { + return { + repository: "/repo", + scanCount: 2, + unavailableScans: 0, + skippedPairs: 0, + nextOffset: null, + pairs: [{ beforeScanId: "before", afterScanId: "after" }], + }; + } + const scanId = args[2]!; + return { + scanId, + findings: [{ occurrenceId: scanId }], + nextOffset: null, + totalFindings: 1, + }; + }, + onMatch: async () => ({ + matches: [ + { + beforeOccurrenceIds: ["before"], + afterOccurrenceIds: ["after"], + confidence: "high", + reason: "x".repeat(1024 * 1024), + }, + ], + uncertain: [], + }), + }), + ), + ).toBe(2); + expect(stderr.text()).toContain("more than 1 MiB"); + expect(calls.some(([command]) => command === "save-scan-comparison")).toBe( + false, + ); + }); + test("saves empty comparisons without starting Codex", async () => { const calls: Array = []; const deps = dependencies({ onWorkbench: (args): JsonObject => { calls.push(args); - return args[0] === "list-unmatched-scan-pairs" - ? { - repository: "/repo", - scanCount: 2, - unavailableScans: 0, - skippedPairs: 0, - batches: [ - { - afterScanId: "after", - afterFindings: [], - beforeScans: [ - { - scanId: "before", - findings: [{ occurrenceId: "before" }], - }, - ], - }, - ], - } - : {}; + if (args[0] === "list-unmatched-scan-pairs") { + return { + repository: "/repo", + scanCount: 2, + unavailableScans: 0, + skippedPairs: 0, + nextOffset: null, + pairs: [{ beforeScanId: "before", afterScanId: "after" }], + }; + } + if (args[0] === "get-scan-matching-inputs") { + const scanId = args[2]!; + return { + scanId, + findings: scanId === "before" ? [{ occurrenceId: "before" }] : [], + nextOffset: null, + totalFindings: scanId === "before" ? 1 : 0, + }; + } + return {}; }, }); deps.matchFindings = async () => { @@ -409,7 +583,8 @@ describe("CLI workbench", () => { deps, ), ).toBe(0); - expect(JSON.parse(calls[1]![6]!)).toEqual({ matches: [], uncertain: [] }); + const save = calls.find(([command]) => command === "save-scan-comparison")!; + expect(JSON.parse(save[6]!)).toEqual({ matches: [], uncertain: [] }); }); test("does not save conflicting confirmed and uncertain matches", async () => { @@ -423,22 +598,28 @@ describe("CLI workbench", () => { dependencies({ onWorkbench: (args): JsonObject => { calls.push(args); + if (args[0] === "list-unmatched-scan-pairs") { + return { + repository: "/repo", + scanCount: 2, + unavailableScans: 0, + skippedPairs: 0, + nextOffset: null, + pairs: [{ beforeScanId: "before", afterScanId: "after" }], + }; + } + const scanId = args[2]!; return { - batches: [ - { - afterScanId: "after", - afterFindings: [{ occurrenceId: "after" }], - beforeScans: [ - { - scanId: "before", - findings: [ - { occurrenceId: "confirmed" }, - { occurrenceId: "uncertain" }, - ], - }, - ], - }, - ], + scanId, + findings: + scanId === "before" + ? [ + { occurrenceId: "confirmed" }, + { occurrenceId: "uncertain" }, + ] + : [{ occurrenceId: "after" }], + nextOffset: null, + totalFindings: scanId === "before" ? 2 : 1, }; }, onMatch: async () => ({ @@ -462,7 +643,9 @@ describe("CLI workbench", () => { ), ).toBe(2); expect(stderr.text()).toContain("conflicting confirmed and uncertain"); - expect(calls).toHaveLength(1); + expect(calls.some(([command]) => command === "save-scan-comparison")).toBe( + false, + ); }); test("force recomputes saved matches", async () => {