diff --git a/gitnexus/src/core/group/cross-impact.ts b/gitnexus/src/core/group/cross-impact.ts index f8625cdc5..eab942a62 100644 --- a/gitnexus/src/core/group/cross-impact.ts +++ b/gitnexus/src/core/group/cross-impact.ts @@ -91,6 +91,25 @@ function clampCrossDepth(raw: unknown): { depth: number; warning?: string } { return { depth: d }; } +/** + * Clamp the impact timeout to a sane bounded range. Callers can feed this + * via tool params, so an unclamped value lets a single request hold a + * timer slot for an arbitrarily long duration (CodeQL js/resource- + * exhaustion). 100ms lower bound preserves test-suite scenarios that + * exercise tight timeouts; 5min upper bound is well above any legitimate + * single-impact compute. Applied at the validate boundary so the + * downstream `deadline` (Date.now() + timeoutMs) and the local-leg + * `setTimeout` see the same clamped value — earlier shapes had a 1hr + * outer cap and a 5min inner clamp that disagreed. + */ +export const IMPACT_TIMEOUT_MIN_MS = 100; +export const IMPACT_TIMEOUT_MAX_MS = 5 * 60 * 1_000; + +export function clampTimeout(timeoutMs: number): number { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return IMPACT_TIMEOUT_MIN_MS; + return Math.min(IMPACT_TIMEOUT_MAX_MS, Math.max(IMPACT_TIMEOUT_MIN_MS, Math.trunc(timeoutMs))); +} + export function validateGroupImpactParams(params: Record): | { ok: true; @@ -143,13 +162,19 @@ export function validateGroupImpactParams(params: Record): const service = normalizeServicePrefix(params.service); const subgroup = typeof params.subgroup === 'string' ? params.subgroup : undefined; - let timeoutMs = + // Clamp at the validate boundary so the downstream `deadline` (line + // ~366) and `safeLocalImpact`'s `setTimeout` both see a single + // bounded value. Without this, the outer deadline budgeted Phase-2 + // cross-repo fanout up to 1hr while only the inner setTimeout was + // capped to 5min — the two halves of CodeQL #184's mitigation + // disagreed. + const rawTimeoutMs = typeof params.timeoutMs === 'number' && params.timeoutMs > 0 ? params.timeoutMs : typeof params.timeout === 'number' && params.timeout > 0 ? params.timeout : DEFAULT_LOCAL_IMPACT_TIMEOUT_MS; - if (timeoutMs > 3_600_000) timeoutMs = 3_600_000; + const timeoutMs = clampTimeout(rawTimeoutMs); return { ok: true, @@ -191,12 +216,13 @@ async function safeLocalImpact( impactParams: Parameters[1], timeoutMs: number, ): Promise<{ value: unknown; timedOut: boolean }> { + const safeTimeoutMs = clampTimeout(timeoutMs); let timer: ReturnType | undefined; const impactP = port.impact(repo, impactParams).catch((err) => ({ error: err instanceof Error ? err.message : String(err), })); const timeoutP = new Promise<'timeout'>((resolve) => { - timer = setTimeout(() => resolve('timeout'), timeoutMs); + timer = setTimeout(() => resolve('timeout'), safeTimeoutMs); }); const won = await Promise.race([ impactP.then((v) => ({ tag: 'impact' as const, v })), @@ -212,6 +238,65 @@ async function safeLocalImpact( return { value: won.v, timedOut: false }; } +/** + * Race a single Phase-2 `impactByUid` call against a remaining-budget + * timer. The Codex adversarial review on PR #1331 surfaced that the + * fanout loop only checked `Date.now() > deadline` *between* neighbor + * calls — once `await port.impactByUid(...)` was reached, a hung + * neighbor could pin the request indefinitely, and slow neighbors + * could compound past the 5-min `IMPACT_TIMEOUT_MAX_MS` cap. + * + * This helper wraps each call: a `setTimeout(remainingMs)` aborts an + * `AbortController` whose signal is forwarded to `impactByUid`, and a + * `Promise.race` resolves to `{ timedOut: true }` when the timer + * fires before the call completes. Implementors that ignore the + * signal (current local backend) still see their await resolved by + * the race; full cooperative cancellation inside the BFS is a future + * follow-up. On rejection, the value is `null` (matching the + * fanout's existing `if (fan == null)` truncation contract). + * + * Exported for direct unit testing — the helper IS the load-bearing + * mitigation surface, so the U3 regression test pins it directly + * rather than driving the full `runGroupImpact` path. + */ +export async function safeNeighborImpact( + port: GroupToolPort, + repoId: string, + uid: string, + direction: string, + opts: { + maxDepth: number; + relationTypes: string[]; + minConfidence: number; + includeTests: boolean; + }, + remainingMs: number, +): Promise<{ value: unknown; timedOut: boolean }> { + const controller = new AbortController(); + let timer: ReturnType | undefined; + const callP = port + .impactByUid(repoId, uid, direction, { ...opts, signal: controller.signal }) + .catch(() => null); + const timeoutP = new Promise<'timeout'>((resolve) => { + timer = setTimeout( + () => { + controller.abort(); + resolve('timeout'); + }, + Math.max(0, remainingMs), + ); + }); + const won = await Promise.race([ + callP.then((v) => ({ tag: 'impact' as const, v })), + timeoutP.then(() => ({ tag: 'timeout' as const })), + ]); + if (timer !== undefined) clearTimeout(timer); + if (won.tag === 'timeout') { + return { value: null, timedOut: true }; + } + return { value: won.v, timedOut: false }; +} + export function collectImpactSymbolUids( local: unknown, servicePrefix: string | undefined, @@ -476,7 +561,8 @@ export async function runGroupImpact( if (seen.has(key)) continue; seen.add(key); - if (Date.now() > deadline) { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { truncatedRepos.push(n.neighborRepo); continue; } @@ -492,13 +578,25 @@ export async function runGroupImpact( continue; } - const fan = await deps.port.impactByUid(neighborHandle.id, n.neighborUid, direction, { - maxDepth, - relationTypes: relationTypes ?? [], - minConfidence, - includeTests, - }); - if (fan == null) { + // Phase-2 hardening: race each impactByUid against a per-call + // timeout derived from the remaining budget. Without this wrap a + // single hung neighbor would pin the request past the clamped + // timeout, which Codex's adversarial review on PR #1331 flagged + // as the still-open half of CodeQL #184 / js/resource-exhaustion. + const { value: fan, timedOut: neighborTimedOut } = await safeNeighborImpact( + deps.port, + neighborHandle.id, + n.neighborUid, + direction, + { + maxDepth, + relationTypes: relationTypes ?? [], + minConfidence, + includeTests, + }, + remainingMs, + ); + if (neighborTimedOut || fan == null) { truncatedRepos.push(n.neighborRepo); continue; } diff --git a/gitnexus/src/core/group/extractors/rust-workspace-extractor.ts b/gitnexus/src/core/group/extractors/rust-workspace-extractor.ts index 63fe7ea82..d58c3e08f 100644 --- a/gitnexus/src/core/group/extractors/rust-workspace-extractor.ts +++ b/gitnexus/src/core/group/extractors/rust-workspace-extractor.ts @@ -31,6 +31,32 @@ interface ImportedSymbol { filePath: string; } +/** + * Linear-time `[package].name = "..."` lookup. The previous regex + * `^\[package\]\s*\n(?:[^\[]*?\n)*?name\s*=\s*"([^"]+)"` had a nested + * lazy quantifier on `\n` that CodeQL js/redos flagged as exponential + * on inputs like `[package]\n` + many bare `\n`. We walk lines + * explicitly: scan from the first `[package]` header until we hit the + * next `[...]` section header, looking for the `name = "..."` line. + * O(n) with the line count. + * + * Exported so the U8 ReDoS regression test can drive the production + * line-walk directly with adversarial fixtures (multi-line strings, + * trailing sections, etc.) instead of duplicating it inline. + */ +export function parseCargoPackageName(content: string): string | null { + const lines = content.split('\n'); + const packageStart = lines.findIndex((l) => l.trim() === '[package]'); + if (packageStart < 0) return null; + for (let i = packageStart + 1; i < lines.length; i++) { + const line = lines[i].trimStart(); + if (line.startsWith('[')) break; // hit the next section header + const m = /^name\s*=\s*"([^"]+)"/.exec(line); + if (m) return m[1]; + } + return null; +} + /** * Parse a Cargo.toml to extract the crate name and workspace dependency * names. Uses simple line-based parsing — no TOML library needed for @@ -47,12 +73,9 @@ async function parseCrateManifest( return null; } - let name = ''; + const name = parseCargoPackageName(content) ?? ''; const workspaceDeps: string[] = []; - const nameMatch = content.match(/^\[package\]\s*\n(?:[^\[]*?\n)*?name\s*=\s*"([^"]+)"/m); - if (nameMatch) name = nameMatch[1]; - // Match dependencies that use workspace = true, which indicates they // are workspace-internal deps: // dep_name = { workspace = true } diff --git a/gitnexus/src/core/group/service.ts b/gitnexus/src/core/group/service.ts index de324e70b..d0473048f 100644 --- a/gitnexus/src/core/group/service.ts +++ b/gitnexus/src/core/group/service.ts @@ -65,6 +65,15 @@ export interface GroupToolPort { relationTypes: string[]; minConfidence: number; includeTests: boolean; + // Optional cancellation signal. Callers (notably the cross-impact + // Phase-2 fanout) wrap this call in a Promise.race against a + // setTimeout-driven AbortController so a single hung neighbor + // cannot exceed the request's clamped timeout budget. Implementors + // may honor the signal cooperatively or simply let the caller's + // race resolve the await — the latter is sufficient for the + // resource-exhaustion mitigation. When the signal is absent or + // already aborted at call time, behavior is unchanged. + signal?: AbortSignal; }, ): Promise; context( diff --git a/gitnexus/src/core/ingestion/cobol/cobol-preprocessor.ts b/gitnexus/src/core/ingestion/cobol/cobol-preprocessor.ts index 23cedb99b..34be6bc03 100644 --- a/gitnexus/src/core/ingestion/cobol/cobol-preprocessor.ts +++ b/gitnexus/src/core/ingestion/cobol/cobol-preprocessor.ts @@ -369,9 +369,20 @@ const RE_USE_AFTER = /\bUSE\s+(?:AFTER\s+)?(?:STANDARD\s+)?(?:EXCEPTION|ERROR)\s+ON\s+([A-Z][A-Z0-9-]+|INPUT|OUTPUT|I-O|EXTEND)\b/i; // SET statement (condition, index) -const RE_SET_TO_TRUE = /\bSET\s+((?:[A-Z][A-Z0-9-]+(?:\s+OF\s+[A-Z][A-Z0-9-]+)?\s+)+)TO\s+TRUE\b/i; -const RE_SET_INDEX = - /\bSET\s+((?:[A-Z][A-Z0-9-]+\s+)+)(TO|UP\s+BY|DOWN\s+BY)\s+(\d+|[A-Z][A-Z0-9-]+)/i; +// +// Catastrophic-backtracking note (CodeQL js/redos): the previous shape +// `((?:[A-Z][A-Z0-9-]+(?:\s+OF\s+[A-Z][A-Z0-9-]+)?\s+)+)TO\s+TRUE` +// nested `\s+` quantifiers across alternations and was exponential on +// inputs like "SET a OF a OF a ... TO TRUE". Replaced with a lazy +// dot-match bounded by the explicit `\s+TO\s+TRUE` suffix — `.+?` is +// O(n) with the trailing anchor, and the captured group is parsed +// downstream the same way as before. +// Exported so the U8 ReDoS regression test can pin the exact production +// pattern. Direct import is the only way to ensure the test's +// pathological-input timing assertion exercises the production regex +// instead of an inline copy that drifts. +export const RE_SET_TO_TRUE = /\bSET\s+(.+?)\s+TO\s+TRUE\b/i; +export const RE_SET_INDEX = /\bSET\s+(.+?)\s+(TO|UP\s+BY|DOWN\s+BY)\s+(\d+|[A-Z][A-Z0-9-]+)/i; // INITIALIZE statement — data reset (captures targets before REPLACING/WITH clause) const RE_INITIALIZE = /\bINITIALIZE\s+([\s\S]*?)(?=\bREPLACING\b|\bWITH\b|\.\s*$|$)/i; diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 11f7e61f3..b53034378 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -2984,8 +2984,14 @@ export class LocalBackend { relationTypes: string[]; minConfidence: number; includeTests: boolean; + signal?: AbortSignal; }, ): Promise { + // Honor an already-aborted signal at the entry boundary as a fast + // path. Cooperative cancellation inside _runImpactBFS is out of + // scope — the caller's Promise.race against the same signal + // resolves the await regardless of how long this body runs. + if (opts.signal?.aborted) return null; try { await this.refreshRepos(); await this.ensureInitialized(repoId); diff --git a/gitnexus/test/unit/group/cross-impact-phase2-timeout.test.ts b/gitnexus/test/unit/group/cross-impact-phase2-timeout.test.ts new file mode 100644 index 000000000..90b8a80a2 --- /dev/null +++ b/gitnexus/test/unit/group/cross-impact-phase2-timeout.test.ts @@ -0,0 +1,121 @@ +/** + * Phase-2 fanout timeout regression test. + * + * Codex adversarial review on PR #1331 surfaced that `validateGroupImpactParams` + * clamps `timeoutMs` and `safeLocalImpact` enforces it on the local leg, but + * the Phase-2 cross-repo fanout (`cross-impact.ts:521-526`) awaits each + * `port.impactByUid(...)` call without a per-call timeout. A single hung + * neighbor pins the request indefinitely; multiple slow neighbors compound + * past the clamped budget because each starts before `Date.now() > deadline`. + * + * This test pins the contract of the mitigation: a `safeNeighborImpact` + * helper that races `port.impactByUid` against a remaining-budget timer + * and returns `{ value: null, timedOut: true }` when the call cannot + * complete in time. + * + * Direct import + named symbol so this is a real regression net — no + * `??`-fallback or dynamic-import dance (the U8 false-green pattern). + */ +import { describe, expect, it } from 'vitest'; +import { safeNeighborImpact } from '../../../src/core/group/cross-impact.js'; +import type { GroupToolPort } from '../../../src/core/group/service.js'; + +const minimalOpts = { + maxDepth: 3, + relationTypes: [] as string[], + minConfidence: 0, + includeTests: false, +}; + +function makePort(impactByUid: GroupToolPort['impactByUid']): GroupToolPort { + return { + resolveRepo: async () => { + throw new Error('not used'); + }, + impact: async () => { + throw new Error('not used'); + }, + query: async () => { + throw new Error('not used'); + }, + context: async () => { + throw new Error('not used'); + }, + impactByUid, + }; +} + +describe('safeNeighborImpact — Phase-2 fanout per-call timeout', () => { + it('returns timedOut=true when impactByUid never resolves, within ~remainingMs', async () => { + // Hung neighbor: the promise never resolves. Without the timeout wrap + // this would hang the test runner. + const port = makePort(() => new Promise(() => {})); + const start = performance.now(); + const result = await safeNeighborImpact(port, 'repo-id', 'uid:1', 'upstream', minimalOpts, 150); + const elapsedMs = performance.now() - start; + expect(result.timedOut).toBe(true); + expect(result.value).toBeNull(); + // Allow generous slack for slow CI; the contract is "bounded", not + // "exactly remainingMs". A regression that drops the timeout entirely + // would hang far past 1500ms; a regression that uses the wrong unit + // (seconds vs ms) would fire much faster. + expect(elapsedMs).toBeGreaterThanOrEqual(140); + expect(elapsedMs).toBeLessThan(1500); + }); + + it('returns the resolved value and timedOut=false on a fast happy path', async () => { + const fakeFan = { byDepth: { 1: [{ id: 'u1' }] } }; + const port = makePort(async () => fakeFan); + const result = await safeNeighborImpact( + port, + 'repo-id', + 'uid:1', + 'upstream', + minimalOpts, + 1000, + ); + expect(result.timedOut).toBe(false); + expect(result.value).toBe(fakeFan); + }); + + it('returns timedOut=true immediately when remainingMs is 0 and the call still hangs', async () => { + // Defensive: even if the caller passes 0, the helper must not block. + const port = makePort(() => new Promise(() => {})); + const start = performance.now(); + const result = await safeNeighborImpact(port, 'repo-id', 'uid:1', 'upstream', minimalOpts, 0); + const elapsedMs = performance.now() - start; + expect(result.timedOut).toBe(true); + expect(result.value).toBeNull(); + // 0ms timeout fires on the next tick — should be well under 50ms even on slow CI. + expect(elapsedMs).toBeLessThan(50); + }); + + it('does not compound across calls — two hung neighbors complete within ~2× remainingMs total', async () => { + // The contract is per-call timeout. Two sequential hung calls should + // total ~2× remainingMs, not (numNeighbors × remainingMs² / 2) or + // anything compounding. A regression that shares one timer across + // calls would pass the first test but fail this one. + const port = makePort(() => new Promise(() => {})); + const start = performance.now(); + const r1 = await safeNeighborImpact(port, 'repo', 'u1', 'upstream', minimalOpts, 100); + const r2 = await safeNeighborImpact(port, 'repo', 'u2', 'upstream', minimalOpts, 100); + const elapsedMs = performance.now() - start; + expect(r1.timedOut).toBe(true); + expect(r2.timedOut).toBe(true); + expect(elapsedMs).toBeGreaterThanOrEqual(180); + expect(elapsedMs).toBeLessThan(1000); + }); + + it('propagates an immediate rejection from impactByUid as timedOut=false with null value', async () => { + // If the port itself rejects (rather than hangs), the helper should + // surface that as a non-timeout failure — the existing fanout block + // already handles `if (fan == null)` truncation, so returning null + // here keeps that path intact. + const port = makePort(async () => { + throw new Error('connection refused'); + }); + const result = await safeNeighborImpact(port, 'repo', 'u1', 'upstream', minimalOpts, 1000); + expect(result.timedOut).toBe(false); + expect(result.value).toBeNull(); + }); +}); diff --git a/gitnexus/test/unit/u8-redos-resource-exhaustion.test.ts b/gitnexus/test/unit/u8-redos-resource-exhaustion.test.ts new file mode 100644 index 000000000..6dd6d9955 --- /dev/null +++ b/gitnexus/test/unit/u8-redos-resource-exhaustion.test.ts @@ -0,0 +1,196 @@ +/** + * Regression tests for U8 — closes: + * #186 js/redos rust-workspace-extractor.ts + * #187 js/redos cobol-preprocessor.ts + * #184 js/resource-exhaustion cross-impact.ts + * + * These tests import the production symbols directly. A previous shape + * dynamic-imported names that did not exist (`extractRustWorkspace` vs. + * the real `extractRustWorkspaceLinks`) and `??`-fell-back to inline + * regex copies, so the tests stayed green even when the production + * fixes regressed. Static imports + named symbols make a regression in + * any of the three sites a hard test failure. + */ +import { describe, expect, it } from 'vitest'; +import { RE_SET_TO_TRUE, RE_SET_INDEX } from '../../src/core/ingestion/cobol/cobol-preprocessor.js'; +import { parseCargoPackageName } from '../../src/core/group/extractors/rust-workspace-extractor.js'; +import { + clampTimeout, + IMPACT_TIMEOUT_MIN_MS, + IMPACT_TIMEOUT_MAX_MS, +} from '../../src/core/group/cross-impact.js'; + +/** + * Time a single regex.exec call. Used by the linearity tests below to + * compute a 10k/5k ratio in addition to the absolute <500ms bound. + * + * Ratio assertions catch sub-exponential O(n²) regressions that fit + * inside the absolute cap on warm CI; the absolute cap catches + * catastrophic backtracking on cold CI. Two complementary signals. + */ +function timeRegex(re: RegExp, input: string): number { + // Reset regex.lastIndex for global/sticky regexes — ours are not, but + // be defensive in case future shape changes add the `g` flag. + re.lastIndex = 0; + const start = performance.now(); + re.exec(input); + return performance.now() - start; +} + +function timeFn(fn: () => T): number { + const start = performance.now(); + fn(); + return performance.now() - start; +} + +// Linear scaling is ~2.0× when input doubles; 3.0× allows generous +// slack for CI-runner GC and tier-up jitter. An O(n²) regression on a +// 2× input takes ~4× as long, well outside this bound. +const LINEAR_RATIO_BOUND = 3.0; + +/** + * Minimum elapsed time (in ms) below which `performance.now()` ratios + * are dominated by scheduler jitter and become meaningless. When both + * timed runs come in below this floor, we skip the ratio assertion — + * the absolute <500ms bound still catches catastrophic backtracking, + * and the next CI run will measure higher absolute times that the + * ratio assertion can evaluate reliably. + * + * Calibrated empirically: a flake on macOS reported ratio 5.29× + * between two sub-millisecond measurements (~0.5ms vs ~2.6ms), both + * genuinely linear but indistinguishable from noise. 5ms is a + * comfortable floor where individual measurements are well-separated + * from the ~10-100µs `performance.now()` resolution band. + */ +const RATIO_MEASUREMENT_FLOOR_MS = 5; + +/** + * Assert linear scaling between two timed runs on inputs that differ + * by 2×. When measurements are too small to be reliable, the ratio + * assertion is skipped (the absolute bound still fires elsewhere). + */ +function assertSubLinearRatio(elapsedSmall: number, elapsedLarge: number, label: string): void { + if (elapsedSmall < RATIO_MEASUREMENT_FLOOR_MS && elapsedLarge < RATIO_MEASUREMENT_FLOOR_MS) { + // Both runs completed faster than the noise floor — the ratio is + // not meaningful. The absolute <500ms bound elsewhere in this + // describe block still pins linearity; we skip rather than risk a + // flake on a genuinely-linear implementation. + return; + } + const ratio = elapsedLarge / Math.max(elapsedSmall, 0.001); + if (ratio >= LINEAR_RATIO_BOUND) { + throw new Error( + `${label}: ratio ${ratio.toFixed(2)}× exceeds bound ${LINEAR_RATIO_BOUND}× ` + + `(small=${elapsedSmall.toFixed(2)}ms, large=${elapsedLarge.toFixed(2)}ms)`, + ); + } +} + +describe('cobol-preprocessor RE_SET_TO_TRUE — linear time on pathological input', () => { + it('matches in <500ms on 50k repetitions of "A OF A " AND 100k/50k ratio is sub-linear when measurable', () => { + // 50k/100k repetitions chosen so timings exceed the + // RATIO_MEASUREMENT_FLOOR_MS noise floor on typical CI hardware. + // Pre-fix nested-quantifier shape would be exponential here; the + // post-fix `.+?` shape is linear (~2× when input doubles). + const inputSmall = 'SET ' + 'A OF A '.repeat(50_000) + 'TO TRUE'; + const inputLarge = 'SET ' + 'A OF A '.repeat(100_000) + 'TO TRUE'; + const elapsedSmall = timeRegex(RE_SET_TO_TRUE, inputSmall); + const elapsedLarge = timeRegex(RE_SET_TO_TRUE, inputLarge); + expect(RE_SET_TO_TRUE.exec(inputSmall)).not.toBeNull(); + expect(elapsedSmall).toBeLessThan(500); + expect(elapsedLarge).toBeLessThan(500); + assertSubLinearRatio(elapsedSmall, elapsedLarge, 'RE_SET_TO_TRUE'); + }); + + it('still matches a normal SET ... TO TRUE statement', () => { + const m = RE_SET_TO_TRUE.exec('SET WS-FLAG TO TRUE'); + expect(m).not.toBeNull(); + expect(m?.[1]).toBe('WS-FLAG'); + }); +}); + +describe('cobol-preprocessor RE_SET_INDEX — linear time on pathological input', () => { + it('rejects in <500ms on 50k tokens with no valid suffix AND 100k/50k ratio is sub-linear when measurable', () => { + // Forces backtracking against the (TO|UP\s+BY|DOWN\s+BY) alternation + // — the richer pathological surface of the two regexes. + const inputSmall = 'SET ' + 'A '.repeat(50_000) + 'X'; + const inputLarge = 'SET ' + 'A '.repeat(100_000) + 'X'; + const elapsedSmall = timeRegex(RE_SET_INDEX, inputSmall); + const elapsedLarge = timeRegex(RE_SET_INDEX, inputLarge); + expect(RE_SET_INDEX.exec(inputSmall)).toBeNull(); + expect(elapsedSmall).toBeLessThan(500); + expect(elapsedLarge).toBeLessThan(500); + assertSubLinearRatio(elapsedSmall, elapsedLarge, 'RE_SET_INDEX'); + }); + + it('still matches a normal SET INDEX statement', () => { + const m = RE_SET_INDEX.exec('SET WS-IDX TO 5'); + expect(m).not.toBeNull(); + expect(m?.[1]).toBe('WS-IDX'); + expect(m?.[2]).toBe('TO'); + expect(m?.[3]).toBe('5'); + }); +}); + +describe('rust-workspace parseCargoPackageName — linear-time line walk', () => { + it('extracts the package name in <500ms on 100k blank lines AND 200k/100k ratio is sub-linear when measurable', () => { + // 100k/200k blank lines chosen so timings exceed the + // RATIO_MEASUREMENT_FLOOR_MS noise floor. Earlier 10k/20k pairing + // produced sub-millisecond measurements where scheduler jitter + // dominated and the ratio became meaningless (a real macOS run + // saw 5.29× between two genuinely-linear sub-ms measurements). + const cargoTomlSmall = + '[package]\n' + '\n'.repeat(100_000) + 'name = "myrepo"\nversion = "0.1.0"\n'; + const cargoTomlLarge = + '[package]\n' + '\n'.repeat(200_000) + 'name = "myrepo"\nversion = "0.1.0"\n'; + const elapsedSmall = timeFn(() => parseCargoPackageName(cargoTomlSmall)); + const elapsedLarge = timeFn(() => parseCargoPackageName(cargoTomlLarge)); + expect(parseCargoPackageName(cargoTomlSmall)).toBe('myrepo'); + expect(elapsedSmall).toBeLessThan(500); + expect(elapsedLarge).toBeLessThan(500); + assertSubLinearRatio(elapsedSmall, elapsedLarge, 'parseCargoPackageName'); + }); + + it('returns null when [package] section is absent', () => { + expect(parseCargoPackageName('[workspace]\nmembers = ["a"]\n')).toBeNull(); + }); + + it('stops at the next section header (does not pick up a name= from a later section)', () => { + const toml = '[package]\nversion = "1.0"\n[other]\nname = "wrong"\n'; + expect(parseCargoPackageName(toml)).toBeNull(); + }); + + it('extracts the name from a normal [package] section', () => { + const toml = '[package]\nname = "real-crate"\nversion = "0.1.0"\n'; + expect(parseCargoPackageName(toml)).toBe('real-crate'); + }); +}); + +describe('cross-impact clampTimeout — bounds user-supplied impact timeouts', () => { + it('rejects negative and zero timeouts, returning MIN', () => { + expect(clampTimeout(0)).toBe(IMPACT_TIMEOUT_MIN_MS); + expect(clampTimeout(-1)).toBe(IMPACT_TIMEOUT_MIN_MS); + expect(clampTimeout(-999_999)).toBe(IMPACT_TIMEOUT_MIN_MS); + }); + + it('rejects NaN/Infinity, returning MIN', () => { + expect(clampTimeout(NaN)).toBe(IMPACT_TIMEOUT_MIN_MS); + expect(clampTimeout(Infinity)).toBe(IMPACT_TIMEOUT_MIN_MS); + expect(clampTimeout(-Infinity)).toBe(IMPACT_TIMEOUT_MIN_MS); + }); + + it('caps very large timeouts at MAX (5 minutes)', () => { + expect(clampTimeout(999_999_999)).toBe(IMPACT_TIMEOUT_MAX_MS); + expect(clampTimeout(IMPACT_TIMEOUT_MAX_MS + 1)).toBe(IMPACT_TIMEOUT_MAX_MS); + }); + + it('passes through a reasonable timeout unchanged (truncated to integer)', () => { + expect(clampTimeout(30_000)).toBe(30_000); + expect(clampTimeout(30_500.7)).toBe(30_500); + }); + + it('floors below-MIN positive values to MIN', () => { + expect(clampTimeout(50)).toBe(IMPACT_TIMEOUT_MIN_MS); + expect(clampTimeout(0.1)).toBe(IMPACT_TIMEOUT_MIN_MS); + }); +});