diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_scan_history.py b/sdk/typescript/_bundled_plugin/scripts/workbench_scan_history.py index f2cb297e..323fdbe7 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_scan_history.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_scan_history.py @@ -256,7 +256,7 @@ def list_unmatched_scan_pairs( } batches = [] skipped = 0 - backfilled: set[str] = set() + matching_findings: dict[str, list[dict[str, Any]]] = {} for index, after in enumerate(available): previous = [ before @@ -267,21 +267,19 @@ def list_unmatched_scan_pairs( if not previous: continue for scan in (*previous, after): - if scan["id"] not in backfilled: + if scan["id"] not in matching_findings: backfill_finding_details(connection, scan) - backfilled.add(scan["id"]) + matching_findings[scan["id"]] = [ + _matching_input(row) + for row in _scan_findings(connection, scan["id"]).values() + ] batches.append( { - "afterFindings": [ - _matching_input(row) for row in _scan_findings(connection, after["id"]).values() - ], + "afterFindings": matching_findings[after["id"]], "afterScanId": after["id"], "beforeScans": [ { - "findings": [ - _matching_input(row) - for row in _scan_findings(connection, before["id"]).values() - ], + "findings": matching_findings[before["id"]], "scanId": before["id"], } for before in previous diff --git a/sdk/typescript/tests-ts/workbench-scan-history.test.ts b/sdk/typescript/tests-ts/workbench-scan-history.test.ts new file mode 100644 index 00000000..65529e87 --- /dev/null +++ b/sdk/typescript/tests-ts/workbench-scan-history.test.ts @@ -0,0 +1,66 @@ +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "bun:test"; +import { PLUGIN_ROOT } from "./plugin-root.js"; + +test("loads each scan's matching findings once across historical batches", () => { + const python = Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + expect(python).not.toBeNull(); + if (python === null) throw new Error("A Python interpreter is required."); + + const probe = [ + "import argparse, json, sqlite3, sys", + "sys.path.insert(0, sys.argv[1])", + "import workbench_scan_history as history", + "connection = sqlite3.connect(':memory:')", + "connection.row_factory = sqlite3.Row", + "connection.executescript('''", + "CREATE TABLE security_targets (id TEXT, current_path TEXT);", + "CREATE TABLE scans (id TEXT, target_path TEXT, target_id TEXT, status TEXT, started_at TEXT);", + "CREATE TABLE scan_comparisons (before_scan_id TEXT, after_scan_id TEXT);", + "CREATE TABLE finding_occurrences (id TEXT, finding_id TEXT, scan_id TEXT, details_json TEXT, remediation TEXT, severity TEXT, summary TEXT, title TEXT);", + "CREATE TABLE finding_triage (occurrence_id TEXT, status TEXT, close_reason TEXT);", + "CREATE TABLE finding_locations (occurrence_id TEXT, relative_path TEXT, role TEXT, sort_order INTEGER);", + "''')", + "for index in range(3):", + " scan = f'scan-{index}'", + " connection.execute('INSERT INTO scans VALUES (?, ?, NULL, ?, ?)', (scan, sys.argv[2], 'complete', str(index)))", + " connection.execute('INSERT INTO finding_occurrences VALUES (?, ?, ?, ?, ?, ?, ?, ?)', (scan, scan, scan, '{}', 'fix', 'high', 'summary', 'title'))", + "queries = []", + "connection.set_trace_callback(queries.append)", + "backfilled = []", + "result = history.list_unmatched_scan_pairs(connection, argparse.Namespace(repository=sys.argv[2], force=False), backfill_finding_details=lambda _connection, scan: backfilled.append(scan['id']), read_coverage=lambda _scan: {})", + "print(json.dumps({'result': result, 'backfilled': backfilled, 'findingQueries': sum('FROM finding_occurrences AS occurrences' in query for query in queries)}))", + ].join("\n"); + + const result = spawnSync( + python, + [ + "-I", + "-B", + "-c", + probe, + join(PLUGIN_ROOT, "scripts"), + join(tmpdir(), "codex-security-matching-fixture"), + ], + { encoding: "utf8", timeout: 10_000 }, + ); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toMatchObject({ + backfilled: ["scan-0", "scan-1", "scan-2"], + findingQueries: 3, + result: { + scanCount: 3, + batches: [ + { afterScanId: "scan-1", beforeScans: [{ scanId: "scan-0" }] }, + { + afterScanId: "scan-2", + beforeScans: [{ scanId: "scan-0" }, { scanId: "scan-1" }], + }, + ], + }, + }); +});