mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-20 00:11:37 +00:00
Merge branch 'main' into fix/rust-axum-route-extraction-1504
This commit is contained in:
commit
02db84b4cd
7 changed files with 482 additions and 18 deletions
|
|
@ -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<string, unknown>):
|
||||
| {
|
||||
ok: true;
|
||||
|
|
@ -143,13 +162,19 @@ export function validateGroupImpactParams(params: Record<string, unknown>):
|
|||
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<GroupToolPort['impact']>[1],
|
||||
timeoutMs: number,
|
||||
): Promise<{ value: unknown; timedOut: boolean }> {
|
||||
const safeTimeoutMs = clampTimeout(timeoutMs);
|
||||
let timer: ReturnType<typeof setTimeout> | 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<typeof setTimeout> | 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<unknown | null>;
|
||||
context(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -2984,8 +2984,14 @@ export class LocalBackend {
|
|||
relationTypes: string[];
|
||||
minConfidence: number;
|
||||
includeTests: boolean;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<any | null> {
|
||||
// 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);
|
||||
|
|
|
|||
121
gitnexus/test/unit/group/cross-impact-phase2-timeout.test.ts
Normal file
121
gitnexus/test/unit/group/cross-impact-phase2-timeout.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
196
gitnexus/test/unit/u8-redos-resource-exhaustion.test.ts
Normal file
196
gitnexus/test/unit/u8-redos-resource-exhaustion.test.ts
Normal file
|
|
@ -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<T>(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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue