perf(redact-engine): line/col by binary search over a per-scan line index

lineColAt walked the input from offset 0 for every finding, so a match-dense
input (a pasted log full of emails and IPs) cost O(findings x bytes): 128 KiB
took ~400 ms and 900 KiB tens of seconds. The line starts are now indexed once
per scan, on the first finding, and each finding is a binary search.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-09-09 03:19:04 +00:00
co-authored by Claude Fable 5.1
parent 36693efae9
commit 4285556925
+24 -12
View File
@@ -163,18 +163,28 @@ export function normalizeWithMap(input: string): {
// ── Offset → line/col on the ORIGINAL text ──────────────────────────────────── // ── Offset → line/col on the ORIGINAL text ────────────────────────────────────
function lineColAt(original: string, offset: number): { line: number; col: number } { /** Start offset of every line, built once per scan and only when a finding needs it. */
let line = 1; function lineStarts(original: string): number[] {
let col = 1; const starts = [0];
for (let i = 0; i < offset && i < original.length; i++) { for (let i = 0; i < original.length; i++) if (original[i] === "\n") starts.push(i + 1);
if (original[i] === "\n") { return starts;
line += 1; }
col = 1;
} else { /**
col += 1; * Binary search over lineStarts: O(log lines) per finding. The previous walk
} * from offset 0 per finding made a match-dense input (a pasted log full of
* emails and IPs) cost O(findings x bytes) — seconds for a few hundred KiB.
*/
function lineColAt(starts: number[], original: string, offset: number): { line: number; col: number } {
const at = Math.min(Math.max(0, offset), original.length);
let lo = 0;
let hi = starts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (starts[mid] <= at) lo = mid;
else hi = mid - 1;
} }
return { line, col }; return { line: lo + 1, col: at - starts[lo] + 1 };
} }
// ── Safe preview masking ────────────────────────────────────────────────────── // ── Safe preview masking ──────────────────────────────────────────────────────
@@ -318,6 +328,7 @@ function emailAllowed(
export function scan(input: string, opts: ScanOptions = {}): ScanResult { export function scan(input: string, opts: ScanOptions = {}): ScanResult {
const repoVisibility: RepoVisibility = opts.repoVisibility ?? "unknown"; const repoVisibility: RepoVisibility = opts.repoVisibility ?? "unknown";
let starts: number[] | null = null; // line index, built on the first finding
// #1824: ?? only catches null/undefined, not NaN or <= 0. A bad value // #1824: ?? only catches null/undefined, not NaN or <= 0. A bad value
// (NaN from a malformed --max-bytes, or a negative) would make `byteLen > // (NaN from a malformed --max-bytes, or a negative) would make `byteLen >
// maxBytes` always false and silently disable the fail-closed oversize guard. // maxBytes` always false and silently disable the fail-closed oversize guard.
@@ -395,7 +406,8 @@ export function scan(input: string, opts: ScanOptions = {}): ScanResult {
if (seen.has(key)) continue; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
const { line, col } = lineColAt(input, origOffset); starts ??= lineStarts(input);
const { line, col } = lineColAt(starts, input, origOffset);
// Tool-fence degrade: only credential-category, only obvious doc examples. // Tool-fence degrade: only credential-category, only obvious doc examples.
let severity: Severity = pat.tier; let severity: Severity = pat.tier;