feat: publish Regex Tools 0.2.0
This commit is contained in:
161
src/regex/analysis/AnalysisSupervisor.test.ts
Normal file
161
src/regex/analysis/AnalysisSupervisor.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
WorkerLike,
|
||||
WorkerRequestError,
|
||||
} from "../execution/WorkerSupervisor";
|
||||
import {
|
||||
WORKER_PROTOCOL_VERSION,
|
||||
type WorkerRequest,
|
||||
type WorkerResponse,
|
||||
} from "../execution/worker-protocol";
|
||||
import { AnalysisSupervisor } from "./AnalysisSupervisor";
|
||||
import type {
|
||||
AnalysisWorkerOperation,
|
||||
AnalysisWorkerResult,
|
||||
} from "./analysis.types";
|
||||
|
||||
class ResponsiveWorker implements WorkerLike {
|
||||
onmessage: ((event: MessageEvent<unknown>) => void) | null = null;
|
||||
onerror: ((event: ErrorEvent) => void) | null = null;
|
||||
onmessageerror: ((event: MessageEvent<unknown>) => void) | null = null;
|
||||
terminated = false;
|
||||
|
||||
postMessage(value: unknown): void {
|
||||
const request = value as WorkerRequest<AnalysisWorkerOperation>;
|
||||
let payload: AnalysisWorkerResult;
|
||||
if (request.payload.kind === "identity") {
|
||||
payload = {
|
||||
kind: "identity",
|
||||
result: {
|
||||
flavour: "ecmascript",
|
||||
engineName: "Native ECMAScript RegExp",
|
||||
engineVersion: "Fixture runtime",
|
||||
runtimeVersion: "Fixture runtime",
|
||||
nativeOffsetUnit: "utf16",
|
||||
},
|
||||
};
|
||||
} else if (request.payload.kind === "benchmark-sample") {
|
||||
payload = {
|
||||
kind: "benchmark-sample",
|
||||
result: {
|
||||
accepted: true,
|
||||
effectiveFlags: "dg",
|
||||
subjectBytes: 1,
|
||||
subjectUtf16: 1,
|
||||
compileMs: 1,
|
||||
firstMatchMs: 1,
|
||||
allMatchesMs: 1,
|
||||
replacementMs: 1,
|
||||
throughputBytesPerSecond: 1_000,
|
||||
matchCount: 1,
|
||||
matched: true,
|
||||
matchCollectionTruncated: false,
|
||||
replacementOutputUtf16: 1,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
payload = {
|
||||
kind: "growth-probe",
|
||||
result: {
|
||||
accepted: true,
|
||||
effectiveFlags: "d",
|
||||
subjectBytes: 1,
|
||||
subjectUtf16: 1,
|
||||
executionMs: 1,
|
||||
matchCount: 0,
|
||||
matched: false,
|
||||
matchCollectionTruncated: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
const response: WorkerResponse<AnalysisWorkerResult> = {
|
||||
protocolVersion: WORKER_PROTOCOL_VERSION,
|
||||
requestId: request.requestId,
|
||||
generation: request.generation,
|
||||
ok: true,
|
||||
payload,
|
||||
};
|
||||
globalThis.queueMicrotask(() => {
|
||||
if (!this.terminated) {
|
||||
this.onmessage?.(new MessageEvent("message", { data: response }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.terminated = true;
|
||||
}
|
||||
}
|
||||
|
||||
class SilentWorker implements WorkerLike {
|
||||
onmessage: ((event: MessageEvent<unknown>) => void) | null = null;
|
||||
onerror: ((event: ErrorEvent) => void) | null = null;
|
||||
onmessageerror: ((event: MessageEvent<unknown>) => void) | null = null;
|
||||
terminated = false;
|
||||
|
||||
postMessage(): void {}
|
||||
|
||||
terminate(): void {
|
||||
this.terminated = true;
|
||||
}
|
||||
}
|
||||
|
||||
describe("AnalysisSupervisor", () => {
|
||||
it("routes typed identity, benchmark and growth responses", async () => {
|
||||
const created: ResponsiveWorker[] = [];
|
||||
const supervisor = new AnalysisSupervisor(() => {
|
||||
const next = new ResponsiveWorker();
|
||||
created.push(next);
|
||||
return next;
|
||||
});
|
||||
|
||||
await expect(supervisor.identity(100)).resolves.toEqual(
|
||||
expect.objectContaining({ engineVersion: "Fixture runtime" }),
|
||||
);
|
||||
await expect(
|
||||
supervisor.benchmarkSample(
|
||||
{
|
||||
flavour: "ecmascript",
|
||||
pattern: "a",
|
||||
flags: [],
|
||||
subject: "a",
|
||||
replacement: "x",
|
||||
scanAll: false,
|
||||
maximumMatches: 10,
|
||||
},
|
||||
100,
|
||||
),
|
||||
).resolves.toEqual(expect.objectContaining({ compileMs: 1 }));
|
||||
await expect(
|
||||
supervisor.growthProbe(
|
||||
{
|
||||
flavour: "ecmascript",
|
||||
pattern: "a",
|
||||
flags: [],
|
||||
subject: "b",
|
||||
scanAll: false,
|
||||
maximumMatches: 10,
|
||||
},
|
||||
100,
|
||||
),
|
||||
).resolves.toEqual(expect.objectContaining({ matched: false }));
|
||||
|
||||
expect(created).toHaveLength(1);
|
||||
supervisor.dispose();
|
||||
expect(created[0]?.terminated).toBe(true);
|
||||
});
|
||||
|
||||
it("terminates and rejects an active request on cancellation", async () => {
|
||||
const worker = new SilentWorker();
|
||||
const supervisor = new AnalysisSupervisor(() => worker);
|
||||
const pending = supervisor.identity(1_000);
|
||||
|
||||
supervisor.cancel();
|
||||
|
||||
await expect(pending).rejects.toMatchObject({
|
||||
kind: "cancelled",
|
||||
} satisfies Partial<WorkerRequestError>);
|
||||
expect(worker.terminated).toBe(true);
|
||||
supervisor.dispose();
|
||||
});
|
||||
});
|
||||
94
src/regex/analysis/AnalysisSupervisor.ts
Normal file
94
src/regex/analysis/AnalysisSupervisor.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
WorkerSupervisor,
|
||||
type WorkerFactory,
|
||||
} from "../execution/WorkerSupervisor";
|
||||
import type {
|
||||
AnalysisEngineIdentity,
|
||||
AnalysisWorkerOperation,
|
||||
AnalysisWorkerResult,
|
||||
BenchmarkSubject,
|
||||
BenchmarkWorkerSample,
|
||||
GrowthProbeSubject,
|
||||
GrowthWorkerSample,
|
||||
} from "./analysis.types";
|
||||
|
||||
export interface AnalysisWorkerClient {
|
||||
identity(timeoutMs: number): Promise<AnalysisEngineIdentity>;
|
||||
benchmarkSample(
|
||||
request: BenchmarkSubject,
|
||||
timeoutMs: number,
|
||||
): Promise<BenchmarkWorkerSample>;
|
||||
growthProbe(
|
||||
request: GrowthProbeSubject,
|
||||
timeoutMs: number,
|
||||
): Promise<GrowthWorkerSample>;
|
||||
cancel(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
const createAnalysisWorker: WorkerFactory = () =>
|
||||
new Worker(new URL("../../workers/analysis.worker.ts", import.meta.url), {
|
||||
type: "module",
|
||||
name: "regex-tools-ecmascript-analysis",
|
||||
});
|
||||
|
||||
export class AnalysisSupervisor implements AnalysisWorkerClient {
|
||||
readonly #supervisor: WorkerSupervisor<
|
||||
AnalysisWorkerOperation,
|
||||
AnalysisWorkerResult
|
||||
>;
|
||||
|
||||
constructor(workerFactory: WorkerFactory = createAnalysisWorker) {
|
||||
this.#supervisor = new WorkerSupervisor(
|
||||
"ECMAScript analysis worker",
|
||||
workerFactory,
|
||||
);
|
||||
}
|
||||
|
||||
async identity(timeoutMs: number): Promise<AnalysisEngineIdentity> {
|
||||
const response = await this.#supervisor.run(
|
||||
{ kind: "identity" },
|
||||
timeoutMs,
|
||||
);
|
||||
if (response.kind !== "identity") {
|
||||
throw new Error("Analysis worker returned the wrong identity response.");
|
||||
}
|
||||
return response.result;
|
||||
}
|
||||
|
||||
async benchmarkSample(
|
||||
request: BenchmarkSubject,
|
||||
timeoutMs: number,
|
||||
): Promise<BenchmarkWorkerSample> {
|
||||
const response = await this.#supervisor.run(
|
||||
{ kind: "benchmark-sample", request },
|
||||
timeoutMs,
|
||||
);
|
||||
if (response.kind !== "benchmark-sample") {
|
||||
throw new Error("Analysis worker returned the wrong benchmark response.");
|
||||
}
|
||||
return response.result;
|
||||
}
|
||||
|
||||
async growthProbe(
|
||||
request: GrowthProbeSubject,
|
||||
timeoutMs: number,
|
||||
): Promise<GrowthWorkerSample> {
|
||||
const response = await this.#supervisor.run(
|
||||
{ kind: "growth-probe", request },
|
||||
timeoutMs,
|
||||
);
|
||||
if (response.kind !== "growth-probe") {
|
||||
throw new Error("Analysis worker returned the wrong growth response.");
|
||||
}
|
||||
return response.result;
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.#supervisor.cancel();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.#supervisor.dispose();
|
||||
}
|
||||
}
|
||||
36
src/regex/analysis/analysis-limits.ts
Normal file
36
src/regex/analysis/analysis-limits.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { DEFAULT_REGEX_LIMITS } from "../execution/request-limits";
|
||||
import type { BenchmarkSettings, GrowthSettings } from "./analysis.types";
|
||||
|
||||
export const ANALYSIS_LIMITS = {
|
||||
maximumWarmupIterations: 100,
|
||||
maximumMeasuredIterations: 1_000,
|
||||
maximumGrowthSteps: 24,
|
||||
maximumGrowthFragmentUtf16: 4_096,
|
||||
maximumGrowthAffixUtf16: 16_384,
|
||||
maximumGrowthRepetitions: 10_000_000,
|
||||
maximumReplacementBenchmarkOutputUtf16: 8 * 1024 * 1024,
|
||||
minimumSampleTimeoutMs: 25,
|
||||
maximumSampleTimeoutMs: DEFAULT_REGEX_LIMITS.advancedMaximumTimeoutMs,
|
||||
maximumAnalysisWallTimeMs: DEFAULT_REGEX_LIMITS.maximumBenchmarkWallTimeMs,
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_BENCHMARK_SETTINGS: BenchmarkSettings = {
|
||||
warmupIterations: 3,
|
||||
measuredIterations: 15,
|
||||
sampleTimeoutMs: 2_000,
|
||||
maximumWallTimeMs: DEFAULT_REGEX_LIMITS.maximumBenchmarkWallTimeMs,
|
||||
};
|
||||
|
||||
export const DEFAULT_GROWTH_SETTINGS: GrowthSettings = {
|
||||
prefix: "",
|
||||
repeatedFragment: "a",
|
||||
suffix: "!",
|
||||
startingRepetitions: 16,
|
||||
maximumRepetitions: 16_384,
|
||||
multiplier: 2,
|
||||
maximumSteps: 11,
|
||||
sampleTimeoutMs: 1_000,
|
||||
maximumWallTimeMs: DEFAULT_REGEX_LIMITS.maximumBenchmarkWallTimeMs,
|
||||
maximumSubjectBytes: 1024 * 1024,
|
||||
normalizedGrowthThreshold: 4,
|
||||
};
|
||||
336
src/regex/analysis/analysis-runner.test.ts
Normal file
336
src/regex/analysis/analysis-runner.test.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WorkerRequestError } from "../execution/WorkerSupervisor";
|
||||
import {
|
||||
runGrowthAnalysis,
|
||||
runRegexBenchmark,
|
||||
validateBenchmarkRequest,
|
||||
validateGrowthRequest,
|
||||
} from "./analysis-runner";
|
||||
import type { AnalysisWorkerClient } from "./AnalysisSupervisor";
|
||||
import type {
|
||||
BenchmarkWorkerSample,
|
||||
GrowthWorkerSample,
|
||||
RegexBenchmarkRequest,
|
||||
GrowthAnalysisRequest,
|
||||
} from "./analysis.types";
|
||||
|
||||
function benchmarkSample(
|
||||
milliseconds: number,
|
||||
overrides: Partial<BenchmarkWorkerSample> = {},
|
||||
): BenchmarkWorkerSample {
|
||||
return {
|
||||
accepted: true,
|
||||
effectiveFlags: "dg",
|
||||
subjectBytes: 3,
|
||||
subjectUtf16: 3,
|
||||
compileMs: milliseconds,
|
||||
firstMatchMs: milliseconds + 1,
|
||||
allMatchesMs: milliseconds + 2,
|
||||
replacementMs: milliseconds + 3,
|
||||
throughputBytesPerSecond: milliseconds * 100,
|
||||
matchCount: 1,
|
||||
matched: true,
|
||||
matchCollectionTruncated: false,
|
||||
replacementOutputUtf16: 3,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function growthSample(
|
||||
executionMs: number,
|
||||
overrides: Partial<GrowthWorkerSample> = {},
|
||||
): GrowthWorkerSample {
|
||||
return {
|
||||
accepted: true,
|
||||
effectiveFlags: "d",
|
||||
subjectBytes: 1,
|
||||
subjectUtf16: 1,
|
||||
executionMs,
|
||||
matchCount: 0,
|
||||
matched: false,
|
||||
matchCollectionTruncated: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function client(
|
||||
overrides: Partial<AnalysisWorkerClient> = {},
|
||||
): AnalysisWorkerClient {
|
||||
return {
|
||||
identity: vi.fn().mockResolvedValue({
|
||||
flavour: "ecmascript",
|
||||
engineName: "Native ECMAScript RegExp",
|
||||
engineVersion: "Test Browser 1",
|
||||
runtimeVersion: "Test Browser 1",
|
||||
nativeOffsetUnit: "utf16",
|
||||
}),
|
||||
benchmarkSample: vi.fn().mockResolvedValue(benchmarkSample(1)),
|
||||
growthProbe: vi.fn().mockResolvedValue(growthSample(1)),
|
||||
cancel: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function benchmarkRequest(
|
||||
overrides: Partial<RegexBenchmarkRequest> = {},
|
||||
): RegexBenchmarkRequest {
|
||||
return {
|
||||
flavour: "ecmascript",
|
||||
pattern: "a+",
|
||||
flags: ["g"],
|
||||
subject: "aaa",
|
||||
replacement: "x",
|
||||
scanAll: true,
|
||||
maximumMatches: 10_000,
|
||||
settings: {
|
||||
warmupIterations: 1,
|
||||
measuredIterations: 3,
|
||||
sampleTimeoutMs: 100,
|
||||
maximumWallTimeMs: 1_000,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function growthRequest(
|
||||
overrides: Partial<GrowthAnalysisRequest> = {},
|
||||
): GrowthAnalysisRequest {
|
||||
return {
|
||||
flavour: "ecmascript",
|
||||
pattern: "(a+)+$",
|
||||
flags: [],
|
||||
scanAll: false,
|
||||
maximumMatches: 10_000,
|
||||
settings: {
|
||||
prefix: "",
|
||||
repeatedFragment: "a",
|
||||
suffix: "!",
|
||||
startingRepetitions: 16,
|
||||
maximumRepetitions: 64,
|
||||
multiplier: 2,
|
||||
maximumSteps: 3,
|
||||
sampleTimeoutMs: 100,
|
||||
maximumWallTimeMs: 1_000,
|
||||
maximumSubjectBytes: 1_024,
|
||||
normalizedGrowthThreshold: 4,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("bounded analysis runner", () => {
|
||||
it("keeps cold use, warm-up and measured statistics separate", async () => {
|
||||
const samples = [
|
||||
benchmarkSample(9),
|
||||
benchmarkSample(99),
|
||||
benchmarkSample(1),
|
||||
benchmarkSample(2),
|
||||
benchmarkSample(10),
|
||||
];
|
||||
const worker = client({
|
||||
benchmarkSample: vi
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve(samples.shift()!)),
|
||||
});
|
||||
const phases: string[] = [];
|
||||
|
||||
const result = await runRegexBenchmark(benchmarkRequest(), worker, {
|
||||
onProgress: (value) => phases.push(value.phase),
|
||||
});
|
||||
|
||||
expect(result.status).toBe("complete");
|
||||
expect(result.coldSample?.compileMs).toBe(9);
|
||||
expect(result.completedWarmups).toBe(1);
|
||||
expect(result.completedMeasuredIterations).toBe(3);
|
||||
expect(result.warmStatistics.compileMs).toEqual({
|
||||
count: 3,
|
||||
minimum: 1,
|
||||
median: 2,
|
||||
p95: 10,
|
||||
maximum: 10,
|
||||
});
|
||||
expect(result.identity?.engineVersion).toBe("Test Browser 1");
|
||||
expect(phases).toEqual([
|
||||
"starting-worker",
|
||||
"cold-sample",
|
||||
"warming-up",
|
||||
"measuring",
|
||||
"measuring",
|
||||
"measuring",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns partial measured samples and a distinct timeout state", async () => {
|
||||
let calls = 0;
|
||||
const worker = client({
|
||||
benchmarkSample: vi.fn().mockImplementation(() => {
|
||||
calls += 1;
|
||||
if (calls === 3) {
|
||||
return Promise.reject(
|
||||
new WorkerRequestError("timeout", "sample timed out"),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(benchmarkSample(calls));
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await runRegexBenchmark(
|
||||
benchmarkRequest({
|
||||
settings: {
|
||||
warmupIterations: 0,
|
||||
measuredIterations: 3,
|
||||
sampleTimeoutMs: 100,
|
||||
maximumWallTimeMs: 1_000,
|
||||
},
|
||||
}),
|
||||
worker,
|
||||
);
|
||||
|
||||
expect(result.status).toBe("timeout");
|
||||
expect(result.completedMeasuredIterations).toBe(1);
|
||||
expect(result.stoppedReason).toContain("worker was terminated");
|
||||
});
|
||||
|
||||
it("does not mislabel an aggregate wall limit as a regex timeout", async () => {
|
||||
let time = 0;
|
||||
const clock = { now: () => time };
|
||||
const worker = client({
|
||||
identity: vi.fn().mockImplementation(async () => {
|
||||
time = 950;
|
||||
return {
|
||||
flavour: "ecmascript",
|
||||
engineName: "Native ECMAScript RegExp",
|
||||
engineVersion: "Test Browser 1",
|
||||
runtimeVersion: "Test Browser 1",
|
||||
nativeOffsetUnit: "utf16",
|
||||
} as const;
|
||||
}),
|
||||
benchmarkSample: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new WorkerRequestError("timeout", "wall stop")),
|
||||
});
|
||||
|
||||
const result = await runRegexBenchmark(
|
||||
benchmarkRequest({
|
||||
settings: {
|
||||
warmupIterations: 0,
|
||||
measuredIterations: 1,
|
||||
sampleTimeoutMs: 100,
|
||||
maximumWallTimeMs: 1_000,
|
||||
},
|
||||
}),
|
||||
worker,
|
||||
{},
|
||||
clock,
|
||||
);
|
||||
|
||||
expect(result.status).toBe("wall-time-limit");
|
||||
expect(result.stoppedReason).toContain("aggregate wall-time");
|
||||
});
|
||||
|
||||
it("rejects out-of-bound benchmark and growth settings before worker use", () => {
|
||||
expect(() =>
|
||||
validateBenchmarkRequest(
|
||||
benchmarkRequest({
|
||||
settings: {
|
||||
warmupIterations: 101,
|
||||
measuredIterations: 1,
|
||||
sampleTimeoutMs: 100,
|
||||
maximumWallTimeMs: 1_000,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(/Warm-up iterations/u);
|
||||
expect(() =>
|
||||
validateGrowthRequest(
|
||||
growthRequest({
|
||||
settings: {
|
||||
...growthRequest().settings,
|
||||
repeatedFragment: "",
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(/must not be empty/u);
|
||||
});
|
||||
|
||||
it("stops on observed normalized growth and records dynamic evidence", async () => {
|
||||
const probes = [growthSample(0.5), growthSample(10)];
|
||||
const worker = client({
|
||||
growthProbe: vi
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve(probes.shift()!)),
|
||||
});
|
||||
|
||||
const result = await runGrowthAnalysis(growthRequest(), worker);
|
||||
|
||||
expect(result.status).toBe("complete");
|
||||
expect(result.stopReason).toBe("growth-threshold");
|
||||
expect(result.samples).toHaveLength(2);
|
||||
expect(result.samples[1]?.normalizedGrowth).toBeGreaterThan(4);
|
||||
expect(result.dynamicFindings[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
rule: "observed-growth",
|
||||
evidence: "dynamically-observed",
|
||||
confidence: "medium",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("distinguishes an observed timeout from a crash", async () => {
|
||||
const worker = client({
|
||||
growthProbe: vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new WorkerRequestError("timeout", "probe timed out"),
|
||||
),
|
||||
});
|
||||
|
||||
const result = await runGrowthAnalysis(growthRequest(), worker);
|
||||
|
||||
expect(result.status).toBe("timeout");
|
||||
expect(result.stopReason).toBe("timeout");
|
||||
expect(result.samples[0]?.status).toBe("timeout");
|
||||
expect(result.dynamicFindings[0]?.rule).toBe("observed-timeout");
|
||||
});
|
||||
|
||||
it("reports a worker crash without relabelling it as a timeout", async () => {
|
||||
const worker = client({
|
||||
growthProbe: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new WorkerRequestError("crash", "fixture crash")),
|
||||
});
|
||||
|
||||
const result = await runGrowthAnalysis(growthRequest(), worker);
|
||||
|
||||
expect(result.status).toBe("error");
|
||||
expect(result.stopReason).toBe("crash");
|
||||
expect(result.samples[0]?.status).toBe("crash");
|
||||
expect(result.dynamicFindings).toEqual([]);
|
||||
expect(result.stoppedReason).toContain("fixture crash");
|
||||
});
|
||||
|
||||
it("preflights generated bytes and honours cancellation without allocating a subject", async () => {
|
||||
const worker = client();
|
||||
const tooLarge = await runGrowthAnalysis(
|
||||
growthRequest({
|
||||
settings: {
|
||||
...growthRequest().settings,
|
||||
prefix: "prefix",
|
||||
maximumSubjectBytes: 8,
|
||||
},
|
||||
}),
|
||||
worker,
|
||||
);
|
||||
expect(tooLarge.stopReason).toBe("maximum-subject-size");
|
||||
expect(worker.growthProbe).not.toHaveBeenCalled();
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const cancelled = await runGrowthAnalysis(growthRequest(), client(), {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(cancelled.status).toBe("cancelled");
|
||||
expect(cancelled.stopReason).toBe("cancelled");
|
||||
});
|
||||
});
|
||||
819
src/regex/analysis/analysis-runner.ts
Normal file
819
src/regex/analysis/analysis-runner.ts
Normal file
@@ -0,0 +1,819 @@
|
||||
import { WorkerRequestError } from "../execution/WorkerSupervisor";
|
||||
import {
|
||||
DEFAULT_REGEX_LIMITS,
|
||||
utf8ByteLength,
|
||||
} from "../execution/request-limits";
|
||||
import { ANALYSIS_LIMITS } from "./analysis-limits";
|
||||
import type { AnalysisWorkerClient } from "./AnalysisSupervisor";
|
||||
import type {
|
||||
AnalysisProgress,
|
||||
AnalysisRunOptions,
|
||||
AnalysisRunStatus,
|
||||
BenchmarkWorkerSample,
|
||||
GrowthAnalysisRequest,
|
||||
GrowthAnalysisResult,
|
||||
GrowthSample,
|
||||
GrowthStopReason,
|
||||
RegexBenchmarkRequest,
|
||||
RegexBenchmarkResult,
|
||||
RegexRiskFinding,
|
||||
} from "./analysis.types";
|
||||
import { summarizeBenchmarkSamples } from "./statistics";
|
||||
|
||||
interface Clock {
|
||||
now(): number;
|
||||
}
|
||||
|
||||
const SYSTEM_CLOCK: Clock = {
|
||||
now: () => performance.now(),
|
||||
};
|
||||
|
||||
function assertInteger(
|
||||
value: number,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(
|
||||
`${label} must be an integer from ${minimum.toLocaleString()} to ${maximum.toLocaleString()}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertFiniteRange(
|
||||
value: number,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): void {
|
||||
if (!Number.isFinite(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(
|
||||
`${label} must be from ${minimum.toLocaleString()} to ${maximum.toLocaleString()}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateBenchmarkRequest(request: RegexBenchmarkRequest): void {
|
||||
if (request.flavour !== "ecmascript") {
|
||||
throw new RangeError("Benchmarking currently supports ECMAScript only.");
|
||||
}
|
||||
if (request.pattern.length > DEFAULT_REGEX_LIMITS.patternHardLengthUtf16) {
|
||||
throw new RangeError("Pattern exceeds the configured hard limit.");
|
||||
}
|
||||
if (
|
||||
utf8ByteLength(request.subject) >
|
||||
DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes
|
||||
) {
|
||||
throw new RangeError(
|
||||
"Benchmark subject exceeds the configured hard limit.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
request.replacement.length >
|
||||
DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16
|
||||
) {
|
||||
throw new RangeError(
|
||||
"Benchmark replacement exceeds the configured hard limit.",
|
||||
);
|
||||
}
|
||||
assertInteger(
|
||||
request.maximumMatches,
|
||||
"Maximum matches",
|
||||
1,
|
||||
DEFAULT_REGEX_LIMITS.maximumMatches,
|
||||
);
|
||||
assertInteger(
|
||||
request.settings.warmupIterations,
|
||||
"Warm-up iterations",
|
||||
0,
|
||||
ANALYSIS_LIMITS.maximumWarmupIterations,
|
||||
);
|
||||
assertInteger(
|
||||
request.settings.measuredIterations,
|
||||
"Measured iterations",
|
||||
1,
|
||||
ANALYSIS_LIMITS.maximumMeasuredIterations,
|
||||
);
|
||||
assertInteger(
|
||||
request.settings.sampleTimeoutMs,
|
||||
"Sample timeout",
|
||||
ANALYSIS_LIMITS.minimumSampleTimeoutMs,
|
||||
ANALYSIS_LIMITS.maximumSampleTimeoutMs,
|
||||
);
|
||||
assertInteger(
|
||||
request.settings.maximumWallTimeMs,
|
||||
"Benchmark wall time",
|
||||
request.settings.sampleTimeoutMs,
|
||||
ANALYSIS_LIMITS.maximumAnalysisWallTimeMs,
|
||||
);
|
||||
if (
|
||||
request.settings.warmupIterations +
|
||||
request.settings.measuredIterations +
|
||||
1 >
|
||||
DEFAULT_REGEX_LIMITS.maximumBenchmarkIterations
|
||||
) {
|
||||
throw new RangeError(
|
||||
`Benchmark exceeds the ${DEFAULT_REGEX_LIMITS.maximumBenchmarkIterations.toLocaleString()}-iteration limit.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateGrowthRequest(request: GrowthAnalysisRequest): void {
|
||||
if (request.flavour !== "ecmascript") {
|
||||
throw new RangeError(
|
||||
"Dynamic growth analysis currently supports ECMAScript only.",
|
||||
);
|
||||
}
|
||||
if (request.pattern.length > DEFAULT_REGEX_LIMITS.patternHardLengthUtf16) {
|
||||
throw new RangeError("Pattern exceeds the configured hard limit.");
|
||||
}
|
||||
assertInteger(
|
||||
request.maximumMatches,
|
||||
"Maximum matches",
|
||||
1,
|
||||
DEFAULT_REGEX_LIMITS.maximumMatches,
|
||||
);
|
||||
if (request.settings.repeatedFragment.length === 0) {
|
||||
throw new RangeError("Repeated fragment must not be empty.");
|
||||
}
|
||||
if (
|
||||
request.settings.repeatedFragment.length >
|
||||
ANALYSIS_LIMITS.maximumGrowthFragmentUtf16
|
||||
) {
|
||||
throw new RangeError(
|
||||
`Repeated fragment exceeds ${ANALYSIS_LIMITS.maximumGrowthFragmentUtf16.toLocaleString()} UTF-16 units.`,
|
||||
);
|
||||
}
|
||||
for (const [label, value] of [
|
||||
["Growth prefix", request.settings.prefix],
|
||||
["Growth suffix", request.settings.suffix],
|
||||
] as const) {
|
||||
if (value.length > ANALYSIS_LIMITS.maximumGrowthAffixUtf16) {
|
||||
throw new RangeError(
|
||||
`${label} exceeds ${ANALYSIS_LIMITS.maximumGrowthAffixUtf16.toLocaleString()} UTF-16 units.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
assertInteger(
|
||||
request.settings.startingRepetitions,
|
||||
"Starting repetitions",
|
||||
1,
|
||||
ANALYSIS_LIMITS.maximumGrowthRepetitions,
|
||||
);
|
||||
assertInteger(
|
||||
request.settings.maximumRepetitions,
|
||||
"Maximum repetitions",
|
||||
request.settings.startingRepetitions,
|
||||
ANALYSIS_LIMITS.maximumGrowthRepetitions,
|
||||
);
|
||||
assertFiniteRange(request.settings.multiplier, "Growth multiplier", 1.1, 10);
|
||||
assertInteger(
|
||||
request.settings.maximumSteps,
|
||||
"Maximum growth steps",
|
||||
1,
|
||||
ANALYSIS_LIMITS.maximumGrowthSteps,
|
||||
);
|
||||
assertInteger(
|
||||
request.settings.sampleTimeoutMs,
|
||||
"Sample timeout",
|
||||
ANALYSIS_LIMITS.minimumSampleTimeoutMs,
|
||||
ANALYSIS_LIMITS.maximumSampleTimeoutMs,
|
||||
);
|
||||
assertInteger(
|
||||
request.settings.maximumWallTimeMs,
|
||||
"Growth wall time",
|
||||
request.settings.sampleTimeoutMs,
|
||||
ANALYSIS_LIMITS.maximumAnalysisWallTimeMs,
|
||||
);
|
||||
assertInteger(
|
||||
request.settings.maximumSubjectBytes,
|
||||
"Maximum generated subject bytes",
|
||||
1,
|
||||
DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes,
|
||||
);
|
||||
assertFiniteRange(
|
||||
request.settings.normalizedGrowthThreshold,
|
||||
"Normalized growth threshold",
|
||||
1.1,
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
function errorKind(
|
||||
error: unknown,
|
||||
): "timeout" | "cancelled" | "crash" | "error" {
|
||||
if (error instanceof WorkerRequestError) {
|
||||
if (error.kind === "timeout") return "timeout";
|
||||
if (error.kind === "cancelled") return "cancelled";
|
||||
if (error.kind === "crash") return "crash";
|
||||
return "error";
|
||||
}
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"kind" in error &&
|
||||
typeof error.kind === "string"
|
||||
) {
|
||||
if (
|
||||
error.kind === "timeout" ||
|
||||
error.kind === "cancelled" ||
|
||||
error.kind === "crash"
|
||||
) {
|
||||
return error.kind;
|
||||
}
|
||||
}
|
||||
return "error";
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function aborted(signal: AbortSignal | undefined): boolean {
|
||||
return signal?.aborted === true;
|
||||
}
|
||||
|
||||
function timeoutWithinDeadline(
|
||||
configuredTimeoutMs: number,
|
||||
deadline: number,
|
||||
clock: Clock,
|
||||
): number {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.min(configuredTimeoutMs, Math.floor(deadline - clock.now())),
|
||||
);
|
||||
}
|
||||
|
||||
function benchmarkResult(
|
||||
request: RegexBenchmarkRequest,
|
||||
started: number,
|
||||
clock: Clock,
|
||||
values: {
|
||||
readonly status: AnalysisRunStatus;
|
||||
readonly identity?: RegexBenchmarkResult["identity"];
|
||||
readonly coldStartMs?: number;
|
||||
readonly coldSample?: BenchmarkWorkerSample;
|
||||
readonly warmSamples: readonly BenchmarkWorkerSample[];
|
||||
readonly completedWarmups: number;
|
||||
readonly stoppedReason?: string;
|
||||
readonly warnings: readonly string[];
|
||||
},
|
||||
): RegexBenchmarkResult {
|
||||
return {
|
||||
status: values.status,
|
||||
...(values.identity ? { identity: values.identity } : {}),
|
||||
...(values.coldStartMs === undefined
|
||||
? {}
|
||||
: { coldStartMs: values.coldStartMs }),
|
||||
...(values.coldSample ? { coldSample: values.coldSample } : {}),
|
||||
warmSamples: values.warmSamples,
|
||||
warmStatistics: summarizeBenchmarkSamples(values.warmSamples),
|
||||
completedWarmups: values.completedWarmups,
|
||||
completedMeasuredIterations: values.warmSamples.length,
|
||||
requestedSettings: request.settings,
|
||||
wallTimeMs: Math.max(0, clock.now() - started),
|
||||
...(values.stoppedReason ? { stoppedReason: values.stoppedReason } : {}),
|
||||
warnings: values.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function progress(options: AnalysisRunOptions, value: AnalysisProgress): void {
|
||||
options.onProgress?.(value);
|
||||
}
|
||||
|
||||
export async function runRegexBenchmark(
|
||||
request: RegexBenchmarkRequest,
|
||||
client: AnalysisWorkerClient,
|
||||
options: AnalysisRunOptions = {},
|
||||
clock: Clock = SYSTEM_CLOCK,
|
||||
): Promise<RegexBenchmarkResult> {
|
||||
validateBenchmarkRequest(request);
|
||||
const started = clock.now();
|
||||
const deadline = started + request.settings.maximumWallTimeMs;
|
||||
let identity: RegexBenchmarkResult["identity"];
|
||||
let coldStartMs: number | undefined;
|
||||
let coldSample: BenchmarkWorkerSample | undefined;
|
||||
let completedWarmups = 0;
|
||||
const warmSamples: BenchmarkWorkerSample[] = [];
|
||||
let requestWasWallLimited = false;
|
||||
const nextRequestTimeout = () => {
|
||||
requestWasWallLimited =
|
||||
deadline - clock.now() < request.settings.sampleTimeoutMs;
|
||||
return timeoutWithinDeadline(
|
||||
request.settings.sampleTimeoutMs,
|
||||
deadline,
|
||||
clock,
|
||||
);
|
||||
};
|
||||
const warnings = [
|
||||
"One browser, pattern and subject are not a complete performance characterization.",
|
||||
"Native browser and WebAssembly engine timings are not directly comparable.",
|
||||
"Displayed precision does not imply nanosecond measurement accuracy.",
|
||||
];
|
||||
|
||||
const partial = (
|
||||
status: AnalysisRunStatus,
|
||||
stoppedReason: string,
|
||||
): RegexBenchmarkResult =>
|
||||
benchmarkResult(request, started, clock, {
|
||||
status,
|
||||
identity,
|
||||
coldStartMs,
|
||||
coldSample,
|
||||
warmSamples,
|
||||
completedWarmups,
|
||||
stoppedReason,
|
||||
warnings,
|
||||
});
|
||||
|
||||
try {
|
||||
if (aborted(options.signal)) {
|
||||
return partial("cancelled", "Benchmark cancelled before it started.");
|
||||
}
|
||||
progress(options, {
|
||||
phase: "starting-worker",
|
||||
completed: 0,
|
||||
total: 1,
|
||||
message: "Starting a fresh ECMAScript analysis worker…",
|
||||
});
|
||||
const coldStart = clock.now();
|
||||
identity = await client.identity(nextRequestTimeout());
|
||||
coldStartMs = Math.max(0, clock.now() - coldStart);
|
||||
|
||||
if (clock.now() >= deadline) {
|
||||
return partial(
|
||||
"wall-time-limit",
|
||||
"Benchmark stopped at its aggregate wall-time limit.",
|
||||
);
|
||||
}
|
||||
progress(options, {
|
||||
phase: "cold-sample",
|
||||
completed: 0,
|
||||
total: 1,
|
||||
message: "Measuring the first engine use separately…",
|
||||
});
|
||||
coldSample = await client.benchmarkSample(request, nextRequestTimeout());
|
||||
if (!coldSample.accepted) {
|
||||
return partial(
|
||||
"error",
|
||||
coldSample.compileError ??
|
||||
"The native ECMAScript engine rejected the benchmark pattern.",
|
||||
);
|
||||
}
|
||||
|
||||
for (let index = 0; index < request.settings.warmupIterations; index += 1) {
|
||||
if (aborted(options.signal)) {
|
||||
return partial("cancelled", "Benchmark cancelled during warm-up.");
|
||||
}
|
||||
if (clock.now() >= deadline) {
|
||||
return partial(
|
||||
"wall-time-limit",
|
||||
"Benchmark stopped at its aggregate wall-time limit.",
|
||||
);
|
||||
}
|
||||
progress(options, {
|
||||
phase: "warming-up",
|
||||
completed: index,
|
||||
total: request.settings.warmupIterations,
|
||||
message: `Warm-up ${index + 1} of ${request.settings.warmupIterations}…`,
|
||||
});
|
||||
const sample = await client.benchmarkSample(
|
||||
request,
|
||||
nextRequestTimeout(),
|
||||
);
|
||||
if (!sample.accepted) {
|
||||
return partial(
|
||||
"error",
|
||||
sample.compileError ??
|
||||
"The native ECMAScript engine rejected a warm-up sample.",
|
||||
);
|
||||
}
|
||||
completedWarmups += 1;
|
||||
}
|
||||
|
||||
for (
|
||||
let index = 0;
|
||||
index < request.settings.measuredIterations;
|
||||
index += 1
|
||||
) {
|
||||
if (aborted(options.signal)) {
|
||||
return partial("cancelled", "Benchmark cancelled while measuring.");
|
||||
}
|
||||
if (clock.now() >= deadline) {
|
||||
return partial(
|
||||
"wall-time-limit",
|
||||
"Benchmark stopped at its aggregate wall-time limit.",
|
||||
);
|
||||
}
|
||||
progress(options, {
|
||||
phase: "measuring",
|
||||
completed: index,
|
||||
total: request.settings.measuredIterations,
|
||||
message: `Measured sample ${index + 1} of ${request.settings.measuredIterations}…`,
|
||||
});
|
||||
const sample = await client.benchmarkSample(
|
||||
request,
|
||||
nextRequestTimeout(),
|
||||
);
|
||||
if (!sample.accepted) {
|
||||
return partial(
|
||||
"error",
|
||||
sample.compileError ??
|
||||
"The native ECMAScript engine rejected a measured sample.",
|
||||
);
|
||||
}
|
||||
warmSamples.push(sample);
|
||||
}
|
||||
} catch (error) {
|
||||
const kind = errorKind(error);
|
||||
if (kind === "timeout") {
|
||||
if (requestWasWallLimited || clock.now() >= deadline) {
|
||||
return partial(
|
||||
"wall-time-limit",
|
||||
"Benchmark stopped at its aggregate wall-time limit.",
|
||||
);
|
||||
}
|
||||
return partial(
|
||||
"timeout",
|
||||
"A benchmark sample timed out; its worker was terminated.",
|
||||
);
|
||||
}
|
||||
if (kind === "cancelled" || aborted(options.signal)) {
|
||||
return partial(
|
||||
"cancelled",
|
||||
"Benchmark cancelled; its worker was terminated.",
|
||||
);
|
||||
}
|
||||
return partial("error", `Benchmark worker failed: ${errorMessage(error)}`);
|
||||
}
|
||||
|
||||
for (const sample of [coldSample, ...warmSamples]) {
|
||||
if (sample?.matchCollectionTruncated) {
|
||||
warnings.push(
|
||||
`Match collection reached the ${request.maximumMatches.toLocaleString()}-match benchmark limit.`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const skippedReplacement = [coldSample, ...warmSamples].find(
|
||||
(sample) => sample?.replacementSkippedReason,
|
||||
)?.replacementSkippedReason;
|
||||
if (skippedReplacement) {
|
||||
warnings.push(`Replacement timing skipped: ${skippedReplacement}`);
|
||||
}
|
||||
return benchmarkResult(request, started, clock, {
|
||||
status: "complete",
|
||||
identity,
|
||||
coldStartMs,
|
||||
coldSample,
|
||||
warmSamples,
|
||||
completedWarmups,
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
|
||||
function generatedSubjectSize(
|
||||
request: GrowthAnalysisRequest,
|
||||
repetitions: number,
|
||||
): { readonly utf16: number; readonly bytes: number } {
|
||||
return {
|
||||
utf16:
|
||||
request.settings.prefix.length +
|
||||
request.settings.repeatedFragment.length * repetitions +
|
||||
request.settings.suffix.length,
|
||||
bytes:
|
||||
utf8ByteLength(request.settings.prefix) +
|
||||
utf8ByteLength(request.settings.repeatedFragment) * repetitions +
|
||||
utf8ByteLength(request.settings.suffix),
|
||||
};
|
||||
}
|
||||
|
||||
function dynamicFinding(
|
||||
request: GrowthAnalysisRequest,
|
||||
rule: "observed-growth" | "observed-timeout",
|
||||
details: {
|
||||
readonly confidence: "medium" | "high";
|
||||
readonly severity: "warning" | "high";
|
||||
readonly explanation: string;
|
||||
readonly exampleRisk: string;
|
||||
},
|
||||
): RegexRiskFinding {
|
||||
return {
|
||||
id: `risk-${rule}-0-${request.pattern.length}`,
|
||||
flavour: "ecmascript",
|
||||
range: { startUtf16: 0, endUtf16: request.pattern.length },
|
||||
rule,
|
||||
title:
|
||||
rule === "observed-timeout"
|
||||
? "Observed timeout under selected limits"
|
||||
: "Observed disproportionate growth",
|
||||
explanation: details.explanation,
|
||||
exampleRisk: details.exampleRisk,
|
||||
confidence: details.confidence,
|
||||
severity: details.severity,
|
||||
limitations:
|
||||
"This observation applies only to the generated subjects, browser runtime and limits shown in this result.",
|
||||
suggestedInvestigation:
|
||||
"Repeat with representative production boundaries, retain strict worker limits, and simplify ambiguous repetition where possible.",
|
||||
evidence: "dynamically-observed",
|
||||
};
|
||||
}
|
||||
|
||||
function growthResult(
|
||||
request: GrowthAnalysisRequest,
|
||||
started: number,
|
||||
clock: Clock,
|
||||
values: {
|
||||
readonly status: AnalysisRunStatus;
|
||||
readonly identity?: GrowthAnalysisResult["identity"];
|
||||
readonly samples: readonly GrowthSample[];
|
||||
readonly stopReason: GrowthStopReason;
|
||||
readonly stoppedReason: string;
|
||||
readonly dynamicFindings: readonly RegexRiskFinding[];
|
||||
},
|
||||
): GrowthAnalysisResult {
|
||||
return {
|
||||
status: values.status,
|
||||
...(values.identity ? { identity: values.identity } : {}),
|
||||
samples: values.samples,
|
||||
stopReason: values.stopReason,
|
||||
stoppedReason: values.stoppedReason,
|
||||
wallTimeMs: Math.max(0, clock.now() - started),
|
||||
dynamicFindings: values.dynamicFindings,
|
||||
settings: request.settings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runGrowthAnalysis(
|
||||
request: GrowthAnalysisRequest,
|
||||
client: AnalysisWorkerClient,
|
||||
options: AnalysisRunOptions = {},
|
||||
clock: Clock = SYSTEM_CLOCK,
|
||||
): Promise<GrowthAnalysisResult> {
|
||||
validateGrowthRequest(request);
|
||||
const started = clock.now();
|
||||
const deadline = started + request.settings.maximumWallTimeMs;
|
||||
const samples: GrowthSample[] = [];
|
||||
const dynamicFindings: RegexRiskFinding[] = [];
|
||||
let identity: GrowthAnalysisResult["identity"];
|
||||
let currentRepetitions = request.settings.startingRepetitions;
|
||||
let requestWasWallLimited = false;
|
||||
const nextRequestTimeout = () => {
|
||||
requestWasWallLimited =
|
||||
deadline - clock.now() < request.settings.sampleTimeoutMs;
|
||||
return timeoutWithinDeadline(
|
||||
request.settings.sampleTimeoutMs,
|
||||
deadline,
|
||||
clock,
|
||||
);
|
||||
};
|
||||
|
||||
const finish = (
|
||||
status: AnalysisRunStatus,
|
||||
stopReason: GrowthStopReason,
|
||||
stoppedReason: string,
|
||||
) =>
|
||||
growthResult(request, started, clock, {
|
||||
status,
|
||||
identity,
|
||||
samples,
|
||||
stopReason,
|
||||
stoppedReason,
|
||||
dynamicFindings,
|
||||
});
|
||||
|
||||
try {
|
||||
if (aborted(options.signal)) {
|
||||
return finish(
|
||||
"cancelled",
|
||||
"cancelled",
|
||||
"Growth analysis cancelled before it started.",
|
||||
);
|
||||
}
|
||||
identity = await client.identity(nextRequestTimeout());
|
||||
|
||||
while (samples.length < request.settings.maximumSteps) {
|
||||
if (aborted(options.signal)) {
|
||||
return finish(
|
||||
"cancelled",
|
||||
"cancelled",
|
||||
"Growth analysis cancelled; its worker was terminated.",
|
||||
);
|
||||
}
|
||||
if (clock.now() >= deadline) {
|
||||
return finish(
|
||||
"wall-time-limit",
|
||||
"wall-time-limit",
|
||||
"Growth analysis stopped at its aggregate wall-time limit.",
|
||||
);
|
||||
}
|
||||
if (currentRepetitions > request.settings.maximumRepetitions) {
|
||||
return finish(
|
||||
"complete",
|
||||
"maximum-repetitions",
|
||||
"Growth analysis reached the configured repetition bound.",
|
||||
);
|
||||
}
|
||||
const size = generatedSubjectSize(request, currentRepetitions);
|
||||
if (
|
||||
!Number.isSafeInteger(size.bytes) ||
|
||||
!Number.isSafeInteger(size.utf16) ||
|
||||
size.bytes > request.settings.maximumSubjectBytes
|
||||
) {
|
||||
return finish(
|
||||
"complete",
|
||||
"maximum-subject-size",
|
||||
"Growth analysis stopped before exceeding the generated-subject byte limit.",
|
||||
);
|
||||
}
|
||||
|
||||
progress(options, {
|
||||
phase: "growth-probe",
|
||||
completed: samples.length,
|
||||
total: request.settings.maximumSteps,
|
||||
message: `Probing ${currentRepetitions.toLocaleString()} fragment repetitions…`,
|
||||
});
|
||||
const subject =
|
||||
request.settings.prefix +
|
||||
request.settings.repeatedFragment.repeat(currentRepetitions) +
|
||||
request.settings.suffix;
|
||||
let workerSample;
|
||||
try {
|
||||
workerSample = await client.growthProbe(
|
||||
{
|
||||
flavour: "ecmascript",
|
||||
pattern: request.pattern,
|
||||
flags: request.flags,
|
||||
subject,
|
||||
scanAll: request.scanAll,
|
||||
maximumMatches: request.maximumMatches,
|
||||
},
|
||||
nextRequestTimeout(),
|
||||
);
|
||||
} catch (error) {
|
||||
const kind = errorKind(error);
|
||||
if (kind === "timeout") {
|
||||
if (requestWasWallLimited || clock.now() >= deadline) {
|
||||
return finish(
|
||||
"wall-time-limit",
|
||||
"wall-time-limit",
|
||||
"Growth analysis stopped at its aggregate wall-time limit.",
|
||||
);
|
||||
}
|
||||
samples.push({
|
||||
repetitions: currentRepetitions,
|
||||
inputUtf16: size.utf16,
|
||||
inputBytes: size.bytes,
|
||||
status: "timeout",
|
||||
message: "Worker timed out and was terminated.",
|
||||
});
|
||||
dynamicFindings.push(
|
||||
dynamicFinding(request, "observed-timeout", {
|
||||
confidence: "high",
|
||||
severity: "high",
|
||||
explanation: `Execution did not finish within ${request.settings.sampleTimeoutMs.toLocaleString()} ms at ${currentRepetitions.toLocaleString()} generated repetitions.`,
|
||||
exampleRisk:
|
||||
"The selected generated near-miss exhausted the configured per-sample wall time.",
|
||||
}),
|
||||
);
|
||||
return finish(
|
||||
"timeout",
|
||||
"timeout",
|
||||
"Observed timeout under selected limits; the worker was terminated.",
|
||||
);
|
||||
}
|
||||
if (kind === "cancelled" || aborted(options.signal)) {
|
||||
return finish(
|
||||
"cancelled",
|
||||
"cancelled",
|
||||
"Growth analysis cancelled; its worker was terminated.",
|
||||
);
|
||||
}
|
||||
samples.push({
|
||||
repetitions: currentRepetitions,
|
||||
inputUtf16: size.utf16,
|
||||
inputBytes: size.bytes,
|
||||
status: "crash",
|
||||
message: errorMessage(error),
|
||||
});
|
||||
return finish(
|
||||
"error",
|
||||
"crash",
|
||||
`Growth worker failed: ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!workerSample.accepted) {
|
||||
samples.push({
|
||||
repetitions: currentRepetitions,
|
||||
inputUtf16: size.utf16,
|
||||
inputBytes: size.bytes,
|
||||
status: "compile-error",
|
||||
message: workerSample.compileError,
|
||||
});
|
||||
return finish(
|
||||
"error",
|
||||
"compile-error",
|
||||
workerSample.compileError ??
|
||||
"The native ECMAScript engine rejected the pattern.",
|
||||
);
|
||||
}
|
||||
|
||||
const previous = samples.at(-1);
|
||||
const executionMs = workerSample.executionMs ?? 0;
|
||||
let normalizedGrowth: number | undefined;
|
||||
if (
|
||||
previous?.status === "complete" &&
|
||||
previous.executionMs !== undefined &&
|
||||
previous.executionMs >= 0.25 &&
|
||||
executionMs >= 1 &&
|
||||
previous.inputBytes > 0
|
||||
) {
|
||||
const inputRatio = size.bytes / previous.inputBytes;
|
||||
const timeRatio = executionMs / previous.executionMs;
|
||||
if (inputRatio > 0) normalizedGrowth = timeRatio / inputRatio;
|
||||
}
|
||||
samples.push({
|
||||
repetitions: currentRepetitions,
|
||||
inputUtf16: size.utf16,
|
||||
inputBytes: size.bytes,
|
||||
status: "complete",
|
||||
executionMs,
|
||||
matched: workerSample.matched,
|
||||
matchCount: workerSample.matchCount,
|
||||
matchCollectionTruncated: workerSample.matchCollectionTruncated,
|
||||
...(normalizedGrowth === undefined ? {} : { normalizedGrowth }),
|
||||
});
|
||||
|
||||
if (
|
||||
normalizedGrowth !== undefined &&
|
||||
normalizedGrowth >= request.settings.normalizedGrowthThreshold
|
||||
) {
|
||||
dynamicFindings.push(
|
||||
dynamicFinding(request, "observed-growth", {
|
||||
confidence: "medium",
|
||||
severity: "warning",
|
||||
explanation: `Observed time growth was ${normalizedGrowth.toFixed(2)}× the input growth between the last two generated samples.`,
|
||||
exampleRisk:
|
||||
"Execution time increased disproportionately for the selected generated subject family.",
|
||||
}),
|
||||
);
|
||||
return finish(
|
||||
"complete",
|
||||
"growth-threshold",
|
||||
"Growth analysis stopped at the configured normalized-growth threshold.",
|
||||
);
|
||||
}
|
||||
if (currentRepetitions >= request.settings.maximumRepetitions) {
|
||||
return finish(
|
||||
"complete",
|
||||
"maximum-repetitions",
|
||||
"Growth analysis reached the configured repetition bound.",
|
||||
);
|
||||
}
|
||||
const next = Math.min(
|
||||
request.settings.maximumRepetitions,
|
||||
Math.max(
|
||||
currentRepetitions + 1,
|
||||
Math.ceil(currentRepetitions * request.settings.multiplier),
|
||||
),
|
||||
);
|
||||
currentRepetitions = next;
|
||||
}
|
||||
} catch (error) {
|
||||
const kind = errorKind(error);
|
||||
if (kind === "timeout") {
|
||||
if (requestWasWallLimited || clock.now() >= deadline) {
|
||||
return finish(
|
||||
"wall-time-limit",
|
||||
"wall-time-limit",
|
||||
"Growth analysis stopped at its aggregate wall-time limit.",
|
||||
);
|
||||
}
|
||||
return finish(
|
||||
"timeout",
|
||||
"timeout",
|
||||
"The analysis worker timed out while starting and was terminated.",
|
||||
);
|
||||
}
|
||||
if (kind === "cancelled" || aborted(options.signal)) {
|
||||
return finish(
|
||||
"cancelled",
|
||||
"cancelled",
|
||||
"Growth analysis cancelled; its worker was terminated.",
|
||||
);
|
||||
}
|
||||
return finish(
|
||||
"error",
|
||||
"crash",
|
||||
`Growth worker failed: ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return finish(
|
||||
"complete",
|
||||
"maximum-steps",
|
||||
"Growth analysis reached the configured step limit.",
|
||||
);
|
||||
}
|
||||
266
src/regex/analysis/analysis.types.ts
Normal file
266
src/regex/analysis/analysis.types.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import type { RegexFlavourId } from "../model/flavour";
|
||||
import type { NormalizedRegexNode, SourceRange } from "../model/syntax";
|
||||
|
||||
export type RegexRiskConfidence = "low" | "medium" | "high";
|
||||
|
||||
export type RegexRiskSeverity = "notice" | "warning" | "high";
|
||||
|
||||
export type RegexRiskEvidence = "static" | "dynamically-observed";
|
||||
|
||||
export type RegexRiskRule =
|
||||
| "nested-quantifier"
|
||||
| "ambiguous-repeated-alternatives"
|
||||
| "overlapping-alternatives"
|
||||
| "repeated-wildcard"
|
||||
| "backreference-in-repetition"
|
||||
| "nullable-repeated-expression"
|
||||
| "repeated-lookaround-body"
|
||||
| "unanchored-expensive-prefix"
|
||||
| "excessive-captures"
|
||||
| "excessive-nesting"
|
||||
| "potential-replacement-expansion"
|
||||
| "observed-growth"
|
||||
| "observed-timeout";
|
||||
|
||||
export interface RegexRiskFinding {
|
||||
readonly id: string;
|
||||
readonly flavour: RegexFlavourId;
|
||||
readonly range: SourceRange;
|
||||
readonly rule: RegexRiskRule;
|
||||
readonly title: string;
|
||||
readonly explanation: string;
|
||||
readonly exampleRisk: string;
|
||||
readonly confidence: RegexRiskConfidence;
|
||||
readonly severity: RegexRiskSeverity;
|
||||
readonly limitations: string;
|
||||
readonly suggestedInvestigation: string;
|
||||
readonly evidence: RegexRiskEvidence;
|
||||
}
|
||||
|
||||
export interface StaticRiskRequest {
|
||||
readonly flavour: RegexFlavourId;
|
||||
readonly root: NormalizedRegexNode;
|
||||
readonly flags: readonly string[];
|
||||
readonly scanAll: boolean;
|
||||
readonly replacement?: string;
|
||||
}
|
||||
|
||||
export interface StaticRiskReport {
|
||||
readonly flavour: RegexFlavourId;
|
||||
readonly analyser: {
|
||||
readonly id: "regex-tools-ecmascript-heuristics";
|
||||
readonly version: "1";
|
||||
readonly support: "experimental-ecmascript-2025" | "unsupported-flavour";
|
||||
};
|
||||
readonly findings: readonly RegexRiskFinding[];
|
||||
readonly analysedNodes: number;
|
||||
readonly findingsTruncated: boolean;
|
||||
readonly traversalTruncated: boolean;
|
||||
readonly summary: string;
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
export interface AnalysisEngineIdentity {
|
||||
readonly flavour: "ecmascript";
|
||||
readonly engineName: "Native ECMAScript RegExp";
|
||||
readonly engineVersion: string;
|
||||
readonly runtimeVersion: string;
|
||||
readonly nativeOffsetUnit: "utf16";
|
||||
}
|
||||
|
||||
export interface BenchmarkSubject {
|
||||
readonly flavour: "ecmascript";
|
||||
readonly pattern: string;
|
||||
readonly flags: readonly string[];
|
||||
readonly subject: string;
|
||||
readonly replacement: string;
|
||||
readonly scanAll: boolean;
|
||||
readonly maximumMatches: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkWorkerSample {
|
||||
readonly accepted: boolean;
|
||||
readonly compileError?: string;
|
||||
readonly effectiveFlags: string;
|
||||
readonly subjectBytes: number;
|
||||
readonly subjectUtf16: number;
|
||||
readonly compileMs: number;
|
||||
readonly firstMatchMs?: number;
|
||||
readonly allMatchesMs?: number;
|
||||
readonly replacementMs?: number;
|
||||
readonly replacementSkippedReason?: string;
|
||||
readonly throughputBytesPerSecond?: number;
|
||||
readonly matchCount: number;
|
||||
readonly matched: boolean;
|
||||
readonly matchCollectionTruncated: boolean;
|
||||
readonly replacementOutputUtf16?: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkSettings {
|
||||
readonly warmupIterations: number;
|
||||
readonly measuredIterations: number;
|
||||
readonly sampleTimeoutMs: number;
|
||||
readonly maximumWallTimeMs: number;
|
||||
}
|
||||
|
||||
export interface RegexBenchmarkRequest extends BenchmarkSubject {
|
||||
readonly settings: BenchmarkSettings;
|
||||
}
|
||||
|
||||
export interface MetricStatistics {
|
||||
readonly count: number;
|
||||
readonly minimum: number;
|
||||
readonly median: number;
|
||||
readonly p95: number;
|
||||
readonly maximum: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkMetricSummary {
|
||||
readonly compileMs?: MetricStatistics;
|
||||
readonly firstMatchMs?: MetricStatistics;
|
||||
readonly allMatchesMs?: MetricStatistics;
|
||||
readonly replacementMs?: MetricStatistics;
|
||||
readonly throughputBytesPerSecond?: MetricStatistics;
|
||||
}
|
||||
|
||||
export type AnalysisRunStatus =
|
||||
"complete" | "timeout" | "cancelled" | "wall-time-limit" | "error";
|
||||
|
||||
export interface RegexBenchmarkResult {
|
||||
readonly status: AnalysisRunStatus;
|
||||
readonly identity?: AnalysisEngineIdentity;
|
||||
readonly coldStartMs?: number;
|
||||
readonly coldSample?: BenchmarkWorkerSample;
|
||||
readonly warmSamples: readonly BenchmarkWorkerSample[];
|
||||
readonly warmStatistics: BenchmarkMetricSummary;
|
||||
readonly completedWarmups: number;
|
||||
readonly completedMeasuredIterations: number;
|
||||
readonly requestedSettings: BenchmarkSettings;
|
||||
readonly wallTimeMs: number;
|
||||
readonly stoppedReason?: string;
|
||||
readonly warnings: readonly string[];
|
||||
}
|
||||
|
||||
export interface GrowthProbeSubject {
|
||||
readonly flavour: "ecmascript";
|
||||
readonly pattern: string;
|
||||
readonly flags: readonly string[];
|
||||
readonly subject: string;
|
||||
readonly scanAll: boolean;
|
||||
readonly maximumMatches: number;
|
||||
}
|
||||
|
||||
export interface GrowthWorkerSample {
|
||||
readonly accepted: boolean;
|
||||
readonly compileError?: string;
|
||||
readonly effectiveFlags: string;
|
||||
readonly subjectBytes: number;
|
||||
readonly subjectUtf16: number;
|
||||
readonly executionMs?: number;
|
||||
readonly matchCount: number;
|
||||
readonly matched: boolean;
|
||||
readonly matchCollectionTruncated: boolean;
|
||||
}
|
||||
|
||||
export interface GrowthSettings {
|
||||
readonly prefix: string;
|
||||
readonly repeatedFragment: string;
|
||||
readonly suffix: string;
|
||||
readonly startingRepetitions: number;
|
||||
readonly maximumRepetitions: number;
|
||||
readonly multiplier: number;
|
||||
readonly maximumSteps: number;
|
||||
readonly sampleTimeoutMs: number;
|
||||
readonly maximumWallTimeMs: number;
|
||||
readonly maximumSubjectBytes: number;
|
||||
readonly normalizedGrowthThreshold: number;
|
||||
}
|
||||
|
||||
export type GrowthSampleStatus =
|
||||
"complete" | "timeout" | "crash" | "compile-error";
|
||||
|
||||
export interface GrowthSample {
|
||||
readonly repetitions: number;
|
||||
readonly inputUtf16: number;
|
||||
readonly inputBytes: number;
|
||||
readonly status: GrowthSampleStatus;
|
||||
readonly executionMs?: number;
|
||||
readonly matched?: boolean;
|
||||
readonly matchCount?: number;
|
||||
readonly matchCollectionTruncated?: boolean;
|
||||
readonly normalizedGrowth?: number;
|
||||
readonly message?: string;
|
||||
}
|
||||
|
||||
export type GrowthStopReason =
|
||||
| "maximum-repetitions"
|
||||
| "maximum-steps"
|
||||
| "maximum-subject-size"
|
||||
| "growth-threshold"
|
||||
| "timeout"
|
||||
| "compile-error"
|
||||
| "crash"
|
||||
| "cancelled"
|
||||
| "wall-time-limit";
|
||||
|
||||
export interface GrowthAnalysisRequest {
|
||||
readonly flavour: "ecmascript";
|
||||
readonly pattern: string;
|
||||
readonly flags: readonly string[];
|
||||
readonly scanAll: boolean;
|
||||
readonly maximumMatches: number;
|
||||
readonly settings: GrowthSettings;
|
||||
}
|
||||
|
||||
export interface GrowthAnalysisResult {
|
||||
readonly status: AnalysisRunStatus;
|
||||
readonly identity?: AnalysisEngineIdentity;
|
||||
readonly samples: readonly GrowthSample[];
|
||||
readonly stopReason: GrowthStopReason;
|
||||
readonly stoppedReason: string;
|
||||
readonly wallTimeMs: number;
|
||||
readonly dynamicFindings: readonly RegexRiskFinding[];
|
||||
readonly settings: GrowthSettings;
|
||||
}
|
||||
|
||||
export type AnalysisWorkerOperation =
|
||||
| { readonly kind: "identity" }
|
||||
| {
|
||||
readonly kind: "benchmark-sample";
|
||||
readonly request: BenchmarkSubject;
|
||||
}
|
||||
| {
|
||||
readonly kind: "growth-probe";
|
||||
readonly request: GrowthProbeSubject;
|
||||
};
|
||||
|
||||
export type AnalysisWorkerResult =
|
||||
| {
|
||||
readonly kind: "identity";
|
||||
readonly result: AnalysisEngineIdentity;
|
||||
}
|
||||
| {
|
||||
readonly kind: "benchmark-sample";
|
||||
readonly result: BenchmarkWorkerSample;
|
||||
}
|
||||
| {
|
||||
readonly kind: "growth-probe";
|
||||
readonly result: GrowthWorkerSample;
|
||||
};
|
||||
|
||||
export interface AnalysisProgress {
|
||||
readonly phase:
|
||||
| "starting-worker"
|
||||
| "cold-sample"
|
||||
| "warming-up"
|
||||
| "measuring"
|
||||
| "growth-probe";
|
||||
readonly completed: number;
|
||||
readonly total: number;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export interface AnalysisRunOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly onProgress?: (progress: AnalysisProgress) => void;
|
||||
}
|
||||
180
src/regex/analysis/ecmascript-analysis.test.ts
Normal file
180
src/regex/analysis/ecmascript-analysis.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
benchmarkEcmaScriptSample,
|
||||
ecmaScriptAnalysisIdentity,
|
||||
probeEcmaScriptGrowth,
|
||||
} from "./ecmascript-analysis";
|
||||
|
||||
function benchmarkRequest(
|
||||
overrides: Partial<Parameters<typeof benchmarkEcmaScriptSample>[0]> = {},
|
||||
) {
|
||||
return {
|
||||
flavour: "ecmascript" as const,
|
||||
pattern: "\\d+",
|
||||
flags: ["g"],
|
||||
subject: "12 345",
|
||||
replacement: "[$&]",
|
||||
scanAll: true,
|
||||
maximumMatches: 10_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("native ECMAScript analysis worker primitives", () => {
|
||||
it("retains exact runtime identity and separates benchmark phases", () => {
|
||||
const identity = ecmaScriptAnalysisIdentity();
|
||||
const sample = benchmarkEcmaScriptSample(benchmarkRequest());
|
||||
|
||||
expect(identity).toEqual(
|
||||
expect.objectContaining({
|
||||
flavour: "ecmascript",
|
||||
engineName: "Native ECMAScript RegExp",
|
||||
nativeOffsetUnit: "utf16",
|
||||
}),
|
||||
);
|
||||
expect(sample).toEqual(
|
||||
expect.objectContaining({
|
||||
accepted: true,
|
||||
effectiveFlags: expect.stringContaining("g"),
|
||||
matchCount: 2,
|
||||
matched: true,
|
||||
matchCollectionTruncated: false,
|
||||
replacementOutputUtf16: 10,
|
||||
}),
|
||||
);
|
||||
expect(sample.compileMs).toBeGreaterThanOrEqual(0);
|
||||
expect(sample.firstMatchMs).toBeGreaterThanOrEqual(0);
|
||||
expect(sample.allMatchesMs).toBeGreaterThanOrEqual(0);
|
||||
expect(sample.replacementMs).toBeGreaterThanOrEqual(0);
|
||||
expect(sample.throughputBytesPerSecond).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("bounds zero-length global iteration", () => {
|
||||
const sample = benchmarkEcmaScriptSample(
|
||||
benchmarkRequest({
|
||||
pattern: "(?=.)",
|
||||
flags: ["g", "u"],
|
||||
subject: "😀x",
|
||||
replacement: "",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sample.matchCount).toBe(2);
|
||||
expect(sample.matchCollectionTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves single sticky execution unless scan-all is explicit", () => {
|
||||
const single = benchmarkEcmaScriptSample(
|
||||
benchmarkRequest({
|
||||
pattern: "a",
|
||||
flags: ["y"],
|
||||
subject: "aa",
|
||||
replacement: "x",
|
||||
scanAll: false,
|
||||
}),
|
||||
);
|
||||
const scanAll = benchmarkEcmaScriptSample(
|
||||
benchmarkRequest({
|
||||
pattern: "a",
|
||||
flags: ["y"],
|
||||
subject: "aa",
|
||||
replacement: "x",
|
||||
scanAll: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(single.matchCount).toBe(1);
|
||||
expect(scanAll.matchCount).toBe(2);
|
||||
expect(scanAll.replacementMs).toBeUndefined();
|
||||
expect(scanAll.replacementSkippedReason).toContain(
|
||||
"multi-match sticky replacement",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns compile rejection as data", () => {
|
||||
const sample = benchmarkEcmaScriptSample(
|
||||
benchmarkRequest({ pattern: "(", subject: "" }),
|
||||
);
|
||||
const growth = probeEcmaScriptGrowth({
|
||||
flavour: "ecmascript",
|
||||
pattern: "(",
|
||||
flags: [],
|
||||
subject: "",
|
||||
scanAll: false,
|
||||
maximumMatches: 10,
|
||||
});
|
||||
|
||||
expect(sample.accepted).toBe(false);
|
||||
expect(sample.compileError).toBeTruthy();
|
||||
expect(growth).toEqual(
|
||||
expect.objectContaining({
|
||||
accepted: false,
|
||||
compileError: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips replacement timing before a conservative output can grow beyond its bound", () => {
|
||||
const sample = benchmarkEcmaScriptSample(
|
||||
benchmarkRequest({
|
||||
pattern: "a",
|
||||
flags: ["g"],
|
||||
subject: "a".repeat(4_096),
|
||||
replacement: "$`$'",
|
||||
maximumMatches: 10_000,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sample.replacementMs).toBeUndefined();
|
||||
expect(sample.replacementSkippedReason).toContain(
|
||||
"Conservative output estimate",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not run unbounded replacement work after match collection truncates", () => {
|
||||
const sample = benchmarkEcmaScriptSample(
|
||||
benchmarkRequest({
|
||||
pattern: "a",
|
||||
flags: ["g"],
|
||||
subject: "aaaa",
|
||||
maximumMatches: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sample.matchCollectionTruncated).toBe(true);
|
||||
expect(sample.replacementMs).toBeUndefined();
|
||||
expect(sample.replacementSkippedReason).toContain(
|
||||
"Match collection reached its bound",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects flags outside the supported ECMAScript set", () => {
|
||||
expect(() =>
|
||||
benchmarkEcmaScriptSample(
|
||||
benchmarkRequest({ flags: ["g", "unsupported"] }),
|
||||
),
|
||||
).toThrow(/Unsupported ECMAScript analysis flag/u);
|
||||
});
|
||||
|
||||
it("reports bounded growth probes without materializing match values", () => {
|
||||
const sample = probeEcmaScriptGrowth({
|
||||
flavour: "ecmascript",
|
||||
pattern: "a+$",
|
||||
flags: [],
|
||||
subject: "aaaa!",
|
||||
scanAll: false,
|
||||
maximumMatches: 10,
|
||||
});
|
||||
|
||||
expect(sample).toEqual(
|
||||
expect.objectContaining({
|
||||
accepted: true,
|
||||
subjectBytes: 5,
|
||||
subjectUtf16: 5,
|
||||
matched: false,
|
||||
matchCount: 0,
|
||||
}),
|
||||
);
|
||||
expect(sample.executionMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
299
src/regex/analysis/ecmascript-analysis.ts
Normal file
299
src/regex/analysis/ecmascript-analysis.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { utf8ByteLength } from "../execution/request-limits";
|
||||
import { ANALYSIS_LIMITS } from "./analysis-limits";
|
||||
import type {
|
||||
AnalysisEngineIdentity,
|
||||
BenchmarkSubject,
|
||||
BenchmarkWorkerSample,
|
||||
GrowthProbeSubject,
|
||||
GrowthWorkerSample,
|
||||
} from "./analysis.types";
|
||||
|
||||
const FLAG_ORDER = "dgimsuvy";
|
||||
|
||||
function runtimeIdentity(): string {
|
||||
return typeof navigator === "undefined"
|
||||
? "Unknown ECMAScript runtime"
|
||||
: navigator.userAgent;
|
||||
}
|
||||
|
||||
export function ecmaScriptAnalysisIdentity(): AnalysisEngineIdentity {
|
||||
const identity = runtimeIdentity();
|
||||
return {
|
||||
flavour: "ecmascript",
|
||||
engineName: "Native ECMAScript RegExp",
|
||||
engineVersion: identity,
|
||||
runtimeVersion: identity,
|
||||
nativeOffsetUnit: "utf16",
|
||||
};
|
||||
}
|
||||
|
||||
function hasIndicesSupport(): boolean {
|
||||
try {
|
||||
return new RegExp("", "d").hasIndices;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function effectiveFlags(flags: readonly string[], scanAll: boolean): string {
|
||||
for (const flag of flags) {
|
||||
if (!FLAG_ORDER.includes(flag)) {
|
||||
throw new RangeError(
|
||||
`Unsupported ECMAScript analysis flag ${JSON.stringify(flag)}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const unique = new Set(flags);
|
||||
if (hasIndicesSupport()) unique.add("d");
|
||||
if (scanAll && !unique.has("g") && !unique.has("y")) unique.add("g");
|
||||
return FLAG_ORDER.split("")
|
||||
.filter((flag) => unique.has(flag))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function advanceStringIndex(
|
||||
subject: string,
|
||||
index: number,
|
||||
unicode: boolean,
|
||||
): number {
|
||||
if (!unicode) return index + 1;
|
||||
const first = subject.charCodeAt(index);
|
||||
if (first < 0xd800 || first > 0xdbff || index + 1 >= subject.length) {
|
||||
return index + 1;
|
||||
}
|
||||
const second = subject.charCodeAt(index + 1);
|
||||
return second >= 0xdc00 && second <= 0xdfff ? index + 2 : index + 1;
|
||||
}
|
||||
|
||||
function collectMatches(
|
||||
expression: RegExp,
|
||||
subject: string,
|
||||
iterate: boolean,
|
||||
maximumMatches: number,
|
||||
): {
|
||||
readonly count: number;
|
||||
readonly matched: boolean;
|
||||
readonly truncated: boolean;
|
||||
} {
|
||||
const unicode = expression.unicode || expression.flags.includes("v");
|
||||
expression.lastIndex = 0;
|
||||
let count = 0;
|
||||
while (true) {
|
||||
const match = expression.exec(subject);
|
||||
if (!match) break;
|
||||
count += 1;
|
||||
if (count >= maximumMatches) {
|
||||
return { count, matched: true, truncated: iterate };
|
||||
}
|
||||
if (!iterate) break;
|
||||
if (match[0].length === 0) {
|
||||
expression.lastIndex = advanceStringIndex(
|
||||
subject,
|
||||
expression.lastIndex,
|
||||
unicode,
|
||||
);
|
||||
if (expression.lastIndex > subject.length) break;
|
||||
}
|
||||
}
|
||||
return { count, matched: count > 0, truncated: false };
|
||||
}
|
||||
|
||||
function assertWorkerInput(
|
||||
request: BenchmarkSubject | GrowthProbeSubject,
|
||||
): void {
|
||||
if (request.flavour !== "ecmascript") {
|
||||
throw new RangeError("The analysis worker supports ECMAScript only.");
|
||||
}
|
||||
if (request.pattern.length > 64 * 1024) {
|
||||
throw new RangeError("Pattern exceeds the 64 Ki UTF-16 analysis limit.");
|
||||
}
|
||||
if (utf8ByteLength(request.subject) > 16 * 1024 * 1024) {
|
||||
throw new RangeError("Subject exceeds the 16 MiB analysis limit.");
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(request.maximumMatches) ||
|
||||
request.maximumMatches < 1 ||
|
||||
request.maximumMatches > 10_000
|
||||
) {
|
||||
throw new RangeError("Analysis match limit must be between 1 and 10,000.");
|
||||
}
|
||||
}
|
||||
|
||||
function replacementWorstCaseUtf16(
|
||||
replacement: string,
|
||||
subjectUtf16: number,
|
||||
matchCount: number,
|
||||
): number {
|
||||
let references = 0;
|
||||
for (let index = 0; index < replacement.length - 1; index += 1) {
|
||||
if (
|
||||
replacement[index] === "$" &&
|
||||
/[`'&0-9<]/u.test(replacement[index + 1] ?? "")
|
||||
) {
|
||||
references += 1;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
const literal = replacement.length;
|
||||
return (
|
||||
subjectUtf16 +
|
||||
matchCount * (literal + references * Math.max(1, subjectUtf16))
|
||||
);
|
||||
}
|
||||
|
||||
function compileFailure(
|
||||
effective: string,
|
||||
subject: string,
|
||||
compileMs: number,
|
||||
error: unknown,
|
||||
): BenchmarkWorkerSample {
|
||||
return {
|
||||
accepted: false,
|
||||
compileError:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "The native ECMAScript engine rejected the pattern.",
|
||||
effectiveFlags: effective,
|
||||
subjectBytes: utf8ByteLength(subject),
|
||||
subjectUtf16: subject.length,
|
||||
compileMs,
|
||||
matchCount: 0,
|
||||
matched: false,
|
||||
matchCollectionTruncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function benchmarkEcmaScriptSample(
|
||||
request: BenchmarkSubject,
|
||||
): BenchmarkWorkerSample {
|
||||
assertWorkerInput(request);
|
||||
if (request.replacement.length > 64 * 1024) {
|
||||
throw new RangeError(
|
||||
"Replacement exceeds the 64 Ki UTF-16 analysis limit.",
|
||||
);
|
||||
}
|
||||
const flags = effectiveFlags(request.flags, request.scanAll);
|
||||
const compileStart = performance.now();
|
||||
let compiled: RegExp;
|
||||
try {
|
||||
compiled = new RegExp(request.pattern, flags);
|
||||
} catch (error) {
|
||||
return compileFailure(
|
||||
flags,
|
||||
request.subject,
|
||||
performance.now() - compileStart,
|
||||
error,
|
||||
);
|
||||
}
|
||||
const compileMs = performance.now() - compileStart;
|
||||
const iterate = flags.includes("g") || request.scanAll;
|
||||
|
||||
const firstStart = performance.now();
|
||||
compiled.exec(request.subject);
|
||||
const firstMatchMs = performance.now() - firstStart;
|
||||
|
||||
const allExpression = new RegExp(request.pattern, flags);
|
||||
const allStart = performance.now();
|
||||
const collection = collectMatches(
|
||||
allExpression,
|
||||
request.subject,
|
||||
iterate,
|
||||
request.maximumMatches,
|
||||
);
|
||||
const allMatchesMs = performance.now() - allStart;
|
||||
const subjectBytes = utf8ByteLength(request.subject);
|
||||
const throughputBytesPerSecond =
|
||||
allMatchesMs > 0 ? (subjectBytes / allMatchesMs) * 1_000 : undefined;
|
||||
|
||||
const worstCaseOutput = replacementWorstCaseUtf16(
|
||||
request.replacement,
|
||||
request.subject.length,
|
||||
collection.count,
|
||||
);
|
||||
let replacementMs: number | undefined;
|
||||
let replacementOutputUtf16: number | undefined;
|
||||
let replacementSkippedReason: string | undefined;
|
||||
if (flags.includes("y") && request.scanAll) {
|
||||
replacementSkippedReason =
|
||||
"Explicit multi-match sticky replacement is not timed with native String.replace because that API replaces only one sticky match.";
|
||||
} else if (collection.truncated) {
|
||||
replacementSkippedReason =
|
||||
"Match collection reached its bound, so total replacement work and output are not known.";
|
||||
} else if (
|
||||
worstCaseOutput > ANALYSIS_LIMITS.maximumReplacementBenchmarkOutputUtf16
|
||||
) {
|
||||
replacementSkippedReason = `Conservative output estimate exceeds ${ANALYSIS_LIMITS.maximumReplacementBenchmarkOutputUtf16.toLocaleString()} UTF-16 units.`;
|
||||
} else {
|
||||
const replacementExpression = new RegExp(request.pattern, flags);
|
||||
const replacementStart = performance.now();
|
||||
const output = request.subject.replace(
|
||||
replacementExpression,
|
||||
request.replacement,
|
||||
);
|
||||
replacementMs = performance.now() - replacementStart;
|
||||
replacementOutputUtf16 = output.length;
|
||||
}
|
||||
|
||||
return {
|
||||
accepted: true,
|
||||
effectiveFlags: flags,
|
||||
subjectBytes,
|
||||
subjectUtf16: request.subject.length,
|
||||
compileMs,
|
||||
firstMatchMs,
|
||||
allMatchesMs,
|
||||
...(replacementMs === undefined ? {} : { replacementMs }),
|
||||
...(replacementSkippedReason ? { replacementSkippedReason } : {}),
|
||||
...(throughputBytesPerSecond === undefined
|
||||
? {}
|
||||
: { throughputBytesPerSecond }),
|
||||
matchCount: collection.count,
|
||||
matched: collection.matched,
|
||||
matchCollectionTruncated: collection.truncated,
|
||||
...(replacementOutputUtf16 === undefined ? {} : { replacementOutputUtf16 }),
|
||||
};
|
||||
}
|
||||
|
||||
export function probeEcmaScriptGrowth(
|
||||
request: GrowthProbeSubject,
|
||||
): GrowthWorkerSample {
|
||||
assertWorkerInput(request);
|
||||
const flags = effectiveFlags(request.flags, request.scanAll);
|
||||
let expression: RegExp;
|
||||
try {
|
||||
expression = new RegExp(request.pattern, flags);
|
||||
} catch (error) {
|
||||
return {
|
||||
accepted: false,
|
||||
compileError:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "The native ECMAScript engine rejected the pattern.",
|
||||
effectiveFlags: flags,
|
||||
subjectBytes: utf8ByteLength(request.subject),
|
||||
subjectUtf16: request.subject.length,
|
||||
matchCount: 0,
|
||||
matched: false,
|
||||
matchCollectionTruncated: false,
|
||||
};
|
||||
}
|
||||
const iterate = flags.includes("g") || request.scanAll;
|
||||
const started = performance.now();
|
||||
const collection = collectMatches(
|
||||
expression,
|
||||
request.subject,
|
||||
iterate,
|
||||
request.maximumMatches,
|
||||
);
|
||||
const executionMs = performance.now() - started;
|
||||
return {
|
||||
accepted: true,
|
||||
effectiveFlags: flags,
|
||||
subjectBytes: utf8ByteLength(request.subject),
|
||||
subjectUtf16: request.subject.length,
|
||||
executionMs,
|
||||
matchCount: collection.count,
|
||||
matched: collection.matched,
|
||||
matchCollectionTruncated: collection.truncated,
|
||||
};
|
||||
}
|
||||
178
src/regex/analysis/static-risk.test.ts
Normal file
178
src/regex/analysis/static-risk.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EcmaScriptSyntaxProvider } from "../syntax/providers/ecmascript/EcmaScriptSyntaxProvider";
|
||||
import type { NormalizedRegexNode } from "../model/syntax";
|
||||
import {
|
||||
analyseStaticRisk,
|
||||
MAXIMUM_STATIC_ANALYSIS_NODES,
|
||||
} from "./static-risk";
|
||||
|
||||
const provider = new EcmaScriptSyntaxProvider();
|
||||
|
||||
async function analyse(
|
||||
pattern: string,
|
||||
overrides: {
|
||||
readonly flags?: readonly string[];
|
||||
readonly scanAll?: boolean;
|
||||
readonly replacement?: string;
|
||||
} = {},
|
||||
) {
|
||||
const flags = overrides.flags ?? [];
|
||||
const syntax = await provider.parsePattern({
|
||||
flavour: "ecmascript",
|
||||
flavourVersion: "2025",
|
||||
pattern,
|
||||
flags,
|
||||
options: {},
|
||||
});
|
||||
expect(syntax.accepted).toBe(true);
|
||||
return analyseStaticRisk({
|
||||
flavour: "ecmascript",
|
||||
root: syntax.root,
|
||||
flags,
|
||||
scanAll: overrides.scanAll ?? false,
|
||||
replacement: overrides.replacement,
|
||||
});
|
||||
}
|
||||
|
||||
describe("ECMAScript static risk analysis", () => {
|
||||
it("identifies nested, nullable and unanchored repetition without claiming vulnerability", async () => {
|
||||
const report = await analyse("(a*)+$");
|
||||
const rules = report.findings.map((finding) => finding.rule);
|
||||
|
||||
expect(rules).toEqual(
|
||||
expect.arrayContaining([
|
||||
"nested-quantifier",
|
||||
"nullable-repeated-expression",
|
||||
"unanchored-expensive-prefix",
|
||||
]),
|
||||
);
|
||||
expect(report.findings[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
flavour: "ecmascript",
|
||||
evidence: "static",
|
||||
confidence: expect.stringMatching(/low|medium|high/u),
|
||||
limitations: expect.any(String),
|
||||
suggestedInvestigation: expect.any(String),
|
||||
}),
|
||||
);
|
||||
expect(report.summary).toMatch(/potential/u);
|
||||
expect(JSON.stringify(report)).not.toMatch(
|
||||
/\bdefinitely vulnerable\b|\bguaranteed linear\b/iu,
|
||||
);
|
||||
});
|
||||
|
||||
it("detects ambiguous alternatives, wildcards and repeated backreferences", async () => {
|
||||
const alternatives = await analyse("(?:a|aa)+$");
|
||||
expect(alternatives.findings.map((finding) => finding.rule)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"ambiguous-repeated-alternatives",
|
||||
"overlapping-alternatives",
|
||||
]),
|
||||
);
|
||||
|
||||
const wildcard = await analyse("(?:.*)+x");
|
||||
expect(wildcard.findings.map((finding) => finding.rule)).toContain(
|
||||
"repeated-wildcard",
|
||||
);
|
||||
|
||||
const backreference = await analyse("(a)(?:\\1+)+$");
|
||||
expect(backreference.findings.map((finding) => finding.rule)).toContain(
|
||||
"backreference-in-repetition",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports repeated lookaround bodies and replacement expansion", async () => {
|
||||
const report = await analyse("(?=(a+)+)a", {
|
||||
flags: ["g"],
|
||||
replacement: "$`$'",
|
||||
});
|
||||
|
||||
expect(report.findings.map((finding) => finding.rule)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"repeated-lookaround-body",
|
||||
"potential-replacement-expansion",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the required cautious empty result wording", async () => {
|
||||
const report = await analyse("^\\d{4}-\\d{2}-\\d{2}$");
|
||||
|
||||
expect(report.findings).toEqual([]);
|
||||
expect(report.summary).toBe(
|
||||
"No issue found by this analyser. This is not a safety guarantee.",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not apply ECMAScript heuristics to another flavour", async () => {
|
||||
const syntax = await provider.parsePattern({
|
||||
flavour: "ecmascript",
|
||||
pattern: "(a+)+",
|
||||
flags: [],
|
||||
options: {},
|
||||
});
|
||||
const report = analyseStaticRisk({
|
||||
flavour: "pcre2",
|
||||
root: syntax.root,
|
||||
flags: [],
|
||||
scanAll: false,
|
||||
});
|
||||
|
||||
expect(report.analyser.support).toBe("unsupported-flavour");
|
||||
expect(report.findings).toEqual([]);
|
||||
expect(report.summary).toContain("unavailable for pcre2");
|
||||
});
|
||||
|
||||
it("bounds normalized-tree traversal independently of render limits", () => {
|
||||
const literal = (index: number): NormalizedRegexNode => ({
|
||||
id: `literal-${index}`,
|
||||
kind: "literal",
|
||||
range: { startUtf16: index, endUtf16: index + 1 },
|
||||
raw: "a",
|
||||
explanation: "literal",
|
||||
children: [],
|
||||
properties: {
|
||||
zeroWidth: false,
|
||||
nullable: false,
|
||||
minimumLength: 1,
|
||||
maximumLength: 1,
|
||||
consumesInput: true,
|
||||
},
|
||||
support: {
|
||||
flavour: "ecmascript",
|
||||
status: "supported",
|
||||
notes: [],
|
||||
},
|
||||
provenance: {
|
||||
provider: "fixture",
|
||||
providerVersion: "1",
|
||||
source: "parsed",
|
||||
},
|
||||
});
|
||||
const root: NormalizedRegexNode = {
|
||||
...literal(0),
|
||||
id: "root",
|
||||
kind: "pattern",
|
||||
range: {
|
||||
startUtf16: 0,
|
||||
endUtf16: MAXIMUM_STATIC_ANALYSIS_NODES + 10,
|
||||
},
|
||||
raw: "a".repeat(MAXIMUM_STATIC_ANALYSIS_NODES + 10),
|
||||
children: Array.from(
|
||||
{ length: MAXIMUM_STATIC_ANALYSIS_NODES + 10 },
|
||||
(_, index) => literal(index),
|
||||
),
|
||||
};
|
||||
|
||||
const report = analyseStaticRisk({
|
||||
flavour: "ecmascript",
|
||||
root,
|
||||
flags: [],
|
||||
scanAll: false,
|
||||
});
|
||||
|
||||
expect(report.analysedNodes).toBe(MAXIMUM_STATIC_ANALYSIS_NODES);
|
||||
expect(report.traversalTruncated).toBe(true);
|
||||
expect(report.limitations.at(-1)).toContain("Traversal stopped");
|
||||
});
|
||||
});
|
||||
573
src/regex/analysis/static-risk.ts
Normal file
573
src/regex/analysis/static-risk.ts
Normal file
@@ -0,0 +1,573 @@
|
||||
import type { RegexFlavourId } from "../model/flavour";
|
||||
import type { NormalizedRegexNode } from "../model/syntax";
|
||||
import type {
|
||||
RegexRiskConfidence,
|
||||
RegexRiskFinding,
|
||||
RegexRiskRule,
|
||||
RegexRiskSeverity,
|
||||
StaticRiskReport,
|
||||
StaticRiskRequest,
|
||||
} from "./analysis.types";
|
||||
|
||||
export const MAXIMUM_STATIC_ANALYSIS_NODES = 20_000;
|
||||
export const MAXIMUM_STATIC_RISK_FINDINGS = 100;
|
||||
const EXCESSIVE_CAPTURE_THRESHOLD = 100;
|
||||
const EXCESSIVE_NESTING_THRESHOLD = 32;
|
||||
const MAXIMUM_FIRST_SIGNATURES = 8;
|
||||
|
||||
type FirstSignature =
|
||||
`literal:${string}` | `class:${string}` | "any" | "unknown";
|
||||
|
||||
interface NodeRecord {
|
||||
readonly node: NormalizedRegexNode;
|
||||
readonly depth: number;
|
||||
readonly parent?: NormalizedRegexNode;
|
||||
}
|
||||
|
||||
interface NodeSummary {
|
||||
readonly containsRepeatableQuantifier: boolean;
|
||||
readonly firstRepeatableQuantifier?: NormalizedRegexNode;
|
||||
readonly firstBackreference?: NormalizedRegexNode;
|
||||
readonly firstWildcard?: NormalizedRegexNode;
|
||||
readonly firstAmbiguousAlternation?: NormalizedRegexNode;
|
||||
readonly firstSignatures: readonly FirstSignature[];
|
||||
}
|
||||
|
||||
interface FindingDetails {
|
||||
readonly node: NormalizedRegexNode;
|
||||
readonly rule: RegexRiskRule;
|
||||
readonly title: string;
|
||||
readonly explanation: string;
|
||||
readonly exampleRisk: string;
|
||||
readonly confidence: RegexRiskConfidence;
|
||||
readonly severity: RegexRiskSeverity;
|
||||
readonly limitations: string;
|
||||
readonly suggestedInvestigation: string;
|
||||
}
|
||||
|
||||
function isRepeatableQuantifier(node: NormalizedRegexNode): boolean {
|
||||
return (
|
||||
node.kind === "quantifier" &&
|
||||
(node.quantifier?.maximum === null || (node.quantifier?.maximum ?? 0) > 1)
|
||||
);
|
||||
}
|
||||
|
||||
function isLookaround(node: NormalizedRegexNode): boolean {
|
||||
return (
|
||||
node.kind === "lookahead" ||
|
||||
node.kind === "negative-lookahead" ||
|
||||
node.kind === "lookbehind" ||
|
||||
node.kind === "negative-lookbehind"
|
||||
);
|
||||
}
|
||||
|
||||
function limitedUnique<T>(values: readonly T[], maximum: number): readonly T[] {
|
||||
return [...new Set(values)].slice(0, maximum);
|
||||
}
|
||||
|
||||
function ownFirstSignature(node: NormalizedRegexNode): FirstSignature[] {
|
||||
if (node.kind === "dot") return ["any"];
|
||||
if (node.kind === "literal") {
|
||||
return [`literal:${node.raw.slice(0, 2)}`];
|
||||
}
|
||||
if (node.kind === "escaped-literal") {
|
||||
if (/^\\[dDsSwWpP]/u.test(node.raw)) {
|
||||
return [`class:${node.raw.slice(0, 2)}`];
|
||||
}
|
||||
return [`literal:${node.raw}`];
|
||||
}
|
||||
if (
|
||||
node.kind === "character-class" ||
|
||||
node.kind === "character-class-range" ||
|
||||
node.kind === "unicode-property"
|
||||
) {
|
||||
return [`class:${node.raw}`];
|
||||
}
|
||||
if (node.kind === "backreference") return ["unknown"];
|
||||
return [];
|
||||
}
|
||||
|
||||
function signaturesOverlap(
|
||||
left: readonly FirstSignature[],
|
||||
right: readonly FirstSignature[],
|
||||
): boolean {
|
||||
for (const leftSignature of left) {
|
||||
for (const rightSignature of right) {
|
||||
if (leftSignature === "unknown" || rightSignature === "unknown") {
|
||||
continue;
|
||||
}
|
||||
if (leftSignature === "any" || rightSignature === "any") return true;
|
||||
if (leftSignature === rightSignature) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasOverlappingChildren(
|
||||
node: NormalizedRegexNode,
|
||||
summaries: ReadonlyMap<NormalizedRegexNode, NodeSummary>,
|
||||
): boolean {
|
||||
const alternativesContainer =
|
||||
node.kind === "disjunction" ||
|
||||
node.kind === "capture-group" ||
|
||||
node.kind === "named-capture-group" ||
|
||||
node.kind === "noncapture-group" ||
|
||||
node.kind === "inline-flags" ||
|
||||
isLookaround(node);
|
||||
if (!alternativesContainer || node.children.length < 2) return false;
|
||||
for (let leftIndex = 0; leftIndex < node.children.length; leftIndex += 1) {
|
||||
const left =
|
||||
summaries.get(node.children[leftIndex]!)?.firstSignatures ?? [];
|
||||
for (
|
||||
let rightIndex = leftIndex + 1;
|
||||
rightIndex < node.children.length;
|
||||
rightIndex += 1
|
||||
) {
|
||||
const right =
|
||||
summaries.get(node.children[rightIndex]!)?.firstSignatures ?? [];
|
||||
if (signaturesOverlap(left, right)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function firstSignaturesFor(
|
||||
node: NormalizedRegexNode,
|
||||
summaries: ReadonlyMap<NormalizedRegexNode, NodeSummary>,
|
||||
): readonly FirstSignature[] {
|
||||
const own = ownFirstSignature(node);
|
||||
if (own.length > 0) return own;
|
||||
if (node.children.length === 0) return [];
|
||||
|
||||
const isSequence = node.kind === "sequence" || node.kind === "alternative";
|
||||
if (isSequence) {
|
||||
const signatures: FirstSignature[] = [];
|
||||
for (const child of node.children) {
|
||||
signatures.push(...(summaries.get(child)?.firstSignatures ?? []));
|
||||
if (child.properties.nullable !== true) break;
|
||||
}
|
||||
return limitedUnique(signatures, MAXIMUM_FIRST_SIGNATURES);
|
||||
}
|
||||
|
||||
return limitedUnique(
|
||||
node.children.flatMap(
|
||||
(child) => summaries.get(child)?.firstSignatures ?? [],
|
||||
),
|
||||
MAXIMUM_FIRST_SIGNATURES,
|
||||
);
|
||||
}
|
||||
|
||||
function collectRecords(root: NormalizedRegexNode): {
|
||||
readonly records: readonly NodeRecord[];
|
||||
readonly truncated: boolean;
|
||||
} {
|
||||
const records: NodeRecord[] = [];
|
||||
const stack: NodeRecord[] = [{ node: root, depth: 0 }];
|
||||
while (stack.length > 0 && records.length < MAXIMUM_STATIC_ANALYSIS_NODES) {
|
||||
const record = stack.pop();
|
||||
if (!record) break;
|
||||
records.push(record);
|
||||
for (let index = record.node.children.length - 1; index >= 0; index -= 1) {
|
||||
const child = record.node.children[index];
|
||||
if (!child) continue;
|
||||
stack.push({
|
||||
node: child,
|
||||
depth: record.depth + 1,
|
||||
parent: record.node,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { records, truncated: stack.length > 0 };
|
||||
}
|
||||
|
||||
function buildSummaries(
|
||||
records: readonly NodeRecord[],
|
||||
): ReadonlyMap<NormalizedRegexNode, NodeSummary> {
|
||||
const summaries = new Map<NormalizedRegexNode, NodeSummary>();
|
||||
for (let index = records.length - 1; index >= 0; index -= 1) {
|
||||
const node = records[index]?.node;
|
||||
if (!node) continue;
|
||||
const childSummaries = node.children
|
||||
.map((child) => summaries.get(child))
|
||||
.filter((summary): summary is NodeSummary => summary !== undefined);
|
||||
const overlapping = hasOverlappingChildren(node, summaries);
|
||||
summaries.set(node, {
|
||||
containsRepeatableQuantifier:
|
||||
isRepeatableQuantifier(node) ||
|
||||
childSummaries.some((summary) => summary.containsRepeatableQuantifier),
|
||||
firstRepeatableQuantifier:
|
||||
(isRepeatableQuantifier(node) ? node : undefined) ??
|
||||
childSummaries.find((summary) => summary.firstRepeatableQuantifier)
|
||||
?.firstRepeatableQuantifier,
|
||||
firstBackreference:
|
||||
(node.kind === "backreference" ? node : undefined) ??
|
||||
childSummaries.find((summary) => summary.firstBackreference)
|
||||
?.firstBackreference,
|
||||
firstWildcard:
|
||||
(node.kind === "dot" ? node : undefined) ??
|
||||
childSummaries.find((summary) => summary.firstWildcard)?.firstWildcard,
|
||||
firstAmbiguousAlternation:
|
||||
(overlapping ? node : undefined) ??
|
||||
childSummaries.find((summary) => summary.firstAmbiguousAlternation)
|
||||
?.firstAmbiguousAlternation,
|
||||
firstSignatures: firstSignaturesFor(node, summaries),
|
||||
});
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
function finding(
|
||||
flavour: RegexFlavourId,
|
||||
sequence: number,
|
||||
details: FindingDetails,
|
||||
): RegexRiskFinding {
|
||||
return {
|
||||
id: `risk-${details.rule}-${details.node.range.startUtf16}-${details.node.range.endUtf16}-${sequence}`,
|
||||
flavour,
|
||||
range: details.node.range,
|
||||
rule: details.rule,
|
||||
title: details.title,
|
||||
explanation: details.explanation,
|
||||
exampleRisk: details.exampleRisk,
|
||||
confidence: details.confidence,
|
||||
severity: details.severity,
|
||||
limitations: details.limitations,
|
||||
suggestedInvestigation: details.suggestedInvestigation,
|
||||
evidence: "static",
|
||||
};
|
||||
}
|
||||
|
||||
function startsAtInput(root: NormalizedRegexNode): boolean {
|
||||
return root.raw.startsWith("^");
|
||||
}
|
||||
|
||||
function severityOrder(severity: RegexRiskSeverity): number {
|
||||
if (severity === "high") return 0;
|
||||
if (severity === "warning") return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function unsupportedReport(request: StaticRiskRequest): StaticRiskReport {
|
||||
return {
|
||||
flavour: request.flavour,
|
||||
analyser: {
|
||||
id: "regex-tools-ecmascript-heuristics",
|
||||
version: "1",
|
||||
support: "unsupported-flavour",
|
||||
},
|
||||
findings: [],
|
||||
analysedNodes: 0,
|
||||
findingsTruncated: false,
|
||||
traversalTruncated: false,
|
||||
summary: `Static risk analysis is unavailable for ${request.flavour}.`,
|
||||
limitations: [
|
||||
"The current analyser is scoped only to the normalized ECMAScript 2025 grammar.",
|
||||
"No ECMAScript heuristic is applied to another flavour.",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function analyseStaticRisk(
|
||||
request: StaticRiskRequest,
|
||||
): StaticRiskReport {
|
||||
if (
|
||||
request.flavour !== "ecmascript" ||
|
||||
request.root.support.flavour !== "ecmascript"
|
||||
) {
|
||||
return unsupportedReport(request);
|
||||
}
|
||||
|
||||
const { records, truncated: traversalTruncated } = collectRecords(
|
||||
request.root,
|
||||
);
|
||||
const summaries = buildSummaries(records);
|
||||
const details: FindingDetails[] = [];
|
||||
let captureCount = 0;
|
||||
let deepest = records[0];
|
||||
const expensivePrefixCandidates: NormalizedRegexNode[] = [];
|
||||
|
||||
for (const record of records) {
|
||||
const { node, depth } = record;
|
||||
if (node.capture) captureCount += 1;
|
||||
if (!deepest || depth > deepest.depth) deepest = record;
|
||||
const summary = summaries.get(node);
|
||||
if (!summary) continue;
|
||||
|
||||
if (isRepeatableQuantifier(node)) {
|
||||
const body = node.children[0];
|
||||
const bodySummary = body ? summaries.get(body) : undefined;
|
||||
const innerQuantifier =
|
||||
bodySummary?.firstRepeatableQuantifier === node
|
||||
? undefined
|
||||
: bodySummary?.firstRepeatableQuantifier;
|
||||
if (innerQuantifier) {
|
||||
details.push({
|
||||
node,
|
||||
rule: "nested-quantifier",
|
||||
title: "Potential nested-quantifier risk",
|
||||
explanation:
|
||||
"A repeated expression contains another repeatable expression. Some failing inputs can make a backtracking engine revisit the same partitions.",
|
||||
exampleRisk:
|
||||
"Long near-misses may take disproportionately longer than matching inputs.",
|
||||
confidence: "high",
|
||||
severity: "high",
|
||||
limitations:
|
||||
"This structural heuristic does not model engine optimizations, atomicity or every surrounding constraint.",
|
||||
suggestedInvestigation:
|
||||
"Run bounded growth analysis with a representative near-miss and consider removing one repetition layer.",
|
||||
});
|
||||
expensivePrefixCandidates.push(node);
|
||||
}
|
||||
if (body?.properties.nullable === true) {
|
||||
details.push({
|
||||
node,
|
||||
rule: "nullable-repeated-expression",
|
||||
title: "Potential nullable-repetition risk",
|
||||
explanation:
|
||||
"The repeated body can match an empty string, so many repetition paths can describe the same input position.",
|
||||
exampleRisk:
|
||||
"Backtracking work or zero-length iteration can grow without consuming input.",
|
||||
confidence: "high",
|
||||
severity: "high",
|
||||
limitations:
|
||||
"Native engines can simplify some nullable repetitions; this analyser does not inspect the compiled program.",
|
||||
suggestedInvestigation:
|
||||
"Require the body to consume input, or test the exact pattern under strict timeout limits.",
|
||||
});
|
||||
expensivePrefixCandidates.push(node);
|
||||
}
|
||||
if (bodySummary?.firstAmbiguousAlternation) {
|
||||
details.push({
|
||||
node,
|
||||
rule: "ambiguous-repeated-alternatives",
|
||||
title: "Potential ambiguous repeated alternatives",
|
||||
explanation:
|
||||
"Alternatives under this repetition can begin with overlapping input, leaving multiple possible partitions.",
|
||||
exampleRisk:
|
||||
"A long suffix failure can force the engine to reconsider many alternative boundaries.",
|
||||
confidence: "medium",
|
||||
severity: "high",
|
||||
limitations:
|
||||
"Overlap is approximated from first consuming constructs; later tokens and engine optimizations are not proven.",
|
||||
suggestedInvestigation:
|
||||
"Make alternatives prefix-distinct, factor shared prefixes, and probe representative near-misses.",
|
||||
});
|
||||
expensivePrefixCandidates.push(node);
|
||||
}
|
||||
if (bodySummary?.firstWildcard) {
|
||||
details.push({
|
||||
node: bodySummary.firstWildcard,
|
||||
rule: "repeated-wildcard",
|
||||
title: "Potential repeated-wildcard risk",
|
||||
explanation:
|
||||
"A wildcard occurs inside a repeated region and may consume the same text using multiple boundaries.",
|
||||
exampleRisk:
|
||||
"Later required text can trigger broad backtracking across the wildcard.",
|
||||
confidence: "medium",
|
||||
severity: "warning",
|
||||
limitations:
|
||||
"Anchors, following literals and engine search optimizations can substantially change the observed cost.",
|
||||
suggestedInvestigation:
|
||||
"Use a more specific character class or a bounded repetition, then compare bounded growth results.",
|
||||
});
|
||||
expensivePrefixCandidates.push(node);
|
||||
}
|
||||
if (bodySummary?.firstBackreference) {
|
||||
details.push({
|
||||
node: bodySummary.firstBackreference,
|
||||
rule: "backreference-in-repetition",
|
||||
title: "Potential repeated-backreference risk",
|
||||
explanation:
|
||||
"A backreference is evaluated inside repetition, coupling later work to previously captured text.",
|
||||
exampleRisk:
|
||||
"Multiple capture and repetition choices can amplify failing-input work.",
|
||||
confidence: "medium",
|
||||
severity: "warning",
|
||||
limitations:
|
||||
"The analyser does not resolve every capture path or prove that competing paths are reachable.",
|
||||
suggestedInvestigation:
|
||||
"Test realistic capture lengths and near-misses with the bounded growth runner.",
|
||||
});
|
||||
expensivePrefixCandidates.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
node.children.length > 1 &&
|
||||
summary.firstAmbiguousAlternation === node
|
||||
) {
|
||||
details.push({
|
||||
node,
|
||||
rule: "overlapping-alternatives",
|
||||
title: "Possibly overlapping alternatives",
|
||||
explanation:
|
||||
"At least two alternatives can begin with the same approximated input class.",
|
||||
exampleRisk:
|
||||
"The engine may need to try more than one branch when later tokens fail.",
|
||||
confidence: "low",
|
||||
severity: "notice",
|
||||
limitations:
|
||||
"Only first-token overlap is checked. This is neither an equivalence test nor proof that costly backtracking occurs.",
|
||||
suggestedInvestigation:
|
||||
"Review whether the alternatives can be made prefix-distinct or whether their shared prefix can be factored out.",
|
||||
});
|
||||
}
|
||||
|
||||
if (isLookaround(node) && summary.containsRepeatableQuantifier) {
|
||||
details.push({
|
||||
node,
|
||||
rule: "repeated-lookaround-body",
|
||||
title: "Potential expensive lookaround",
|
||||
explanation:
|
||||
"This lookaround evaluates a body containing repetition without consuming input at the assertion site.",
|
||||
exampleRisk:
|
||||
"An unanchored search may re-evaluate the repeated assertion at many candidate positions.",
|
||||
confidence: "medium",
|
||||
severity: "warning",
|
||||
limitations:
|
||||
"The analyser does not know which assertion results the runtime caches or optimizes.",
|
||||
suggestedInvestigation:
|
||||
"Anchor or narrow the assertion where possible and test a representative non-match.",
|
||||
});
|
||||
expensivePrefixCandidates.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
if (captureCount > EXCESSIVE_CAPTURE_THRESHOLD) {
|
||||
details.push({
|
||||
node: request.root,
|
||||
rule: "excessive-captures",
|
||||
title: "Large capture set",
|
||||
explanation: `The pattern contains ${captureCount.toLocaleString()} capturing groups, above this analyser’s ${EXCESSIVE_CAPTURE_THRESHOLD.toLocaleString()}-group review threshold.`,
|
||||
exampleRisk:
|
||||
"Capturing can increase result materialization, offset conversion and replacement work.",
|
||||
confidence: "high",
|
||||
severity: "notice",
|
||||
limitations:
|
||||
"The threshold is an application review limit, not an engine vulnerability boundary.",
|
||||
suggestedInvestigation:
|
||||
"Convert groups that are not consumed by results or backreferences to non-capturing groups.",
|
||||
});
|
||||
}
|
||||
|
||||
if (deepest && deepest.depth > EXCESSIVE_NESTING_THRESHOLD) {
|
||||
details.push({
|
||||
node: deepest.node,
|
||||
rule: "excessive-nesting",
|
||||
title: "Deeply nested pattern",
|
||||
explanation: `The normalized tree reaches depth ${deepest.depth.toLocaleString()}, above this analyser’s ${EXCESSIVE_NESTING_THRESHOLD.toLocaleString()}-level review threshold.`,
|
||||
exampleRisk:
|
||||
"Deep nesting can increase compiler, stack or explanation work even when matching remains quick.",
|
||||
confidence: "high",
|
||||
severity: "notice",
|
||||
limitations:
|
||||
"Normalized AST depth is not identical to the native engine’s internal program depth.",
|
||||
suggestedInvestigation:
|
||||
"Simplify unnecessary grouping and retain strict compile and execution limits.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!startsAtInput(request.root) && expensivePrefixCandidates[0]) {
|
||||
details.push({
|
||||
node: expensivePrefixCandidates[0],
|
||||
rule: "unanchored-expensive-prefix",
|
||||
title: "Potential unanchored search amplification",
|
||||
explanation:
|
||||
"The pattern is not start-anchored and contains a structurally expensive region, so the engine may retry it at many subject positions.",
|
||||
exampleRisk:
|
||||
"A long non-match can multiply per-position backtracking by the subject length.",
|
||||
confidence: request.flags.includes("y") ? "low" : "medium",
|
||||
severity: "warning",
|
||||
limitations:
|
||||
"Sticky execution, runtime prefix scans and calling-code start offsets can avoid retries that are possible from the pattern alone.",
|
||||
suggestedInvestigation:
|
||||
"Use an appropriate start anchor or sticky execution when the intended match must begin at a known position.",
|
||||
});
|
||||
}
|
||||
|
||||
const replacement = request.replacement ?? "";
|
||||
const repeatedExecution = request.scanAll || request.flags.includes("g");
|
||||
if (
|
||||
replacement.length > 0 &&
|
||||
repeatedExecution &&
|
||||
(replacement.includes("$`") ||
|
||||
replacement.includes("$'") ||
|
||||
replacement.length > 4_096)
|
||||
) {
|
||||
details.push({
|
||||
node: request.root,
|
||||
rule: "potential-replacement-expansion",
|
||||
title: "Potentially large replacement output",
|
||||
explanation:
|
||||
replacement.includes("$`") || replacement.includes("$'")
|
||||
? "The replacement refers to an entire subject prefix or suffix and can do so for every selected match."
|
||||
: "A long replacement template can be emitted once for every selected match.",
|
||||
exampleRisk:
|
||||
"Output size and replacement time can grow much faster than the input preview suggests.",
|
||||
confidence: "medium",
|
||||
severity: "warning",
|
||||
limitations:
|
||||
"The finding uses pattern and template structure; it does not predict the actual match count for every subject.",
|
||||
suggestedInvestigation:
|
||||
"Use bounded replacement previews and test output size on representative maximum inputs.",
|
||||
});
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const unique = details.filter((item) => {
|
||||
const key = `${item.rule}:${item.node.range.startUtf16}:${item.node.range.endUtf16}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
unique.sort(
|
||||
(left, right) =>
|
||||
severityOrder(left.severity) - severityOrder(right.severity) ||
|
||||
left.node.range.startUtf16 - right.node.range.startUtf16 ||
|
||||
left.rule.localeCompare(right.rule),
|
||||
);
|
||||
const findingsTruncated = unique.length > MAXIMUM_STATIC_RISK_FINDINGS;
|
||||
const findings = unique
|
||||
.slice(0, MAXIMUM_STATIC_RISK_FINDINGS)
|
||||
.map((item, index) => finding(request.flavour, index + 1, item))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
severityOrder(left.severity) - severityOrder(right.severity) ||
|
||||
left.range.startUtf16 - right.range.startUtf16 ||
|
||||
left.rule.localeCompare(right.rule),
|
||||
);
|
||||
|
||||
return {
|
||||
flavour: request.flavour,
|
||||
analyser: {
|
||||
id: "regex-tools-ecmascript-heuristics",
|
||||
version: "1",
|
||||
support: "experimental-ecmascript-2025",
|
||||
},
|
||||
findings,
|
||||
analysedNodes: records.length,
|
||||
findingsTruncated,
|
||||
traversalTruncated,
|
||||
summary:
|
||||
findings.length === 0
|
||||
? "No issue found by this analyser. This is not a safety guarantee."
|
||||
: `${findings.length.toLocaleString()} potential ${
|
||||
findings.length === 1 ? "risk" : "risks"
|
||||
} found by ECMAScript-specific structural heuristics.`,
|
||||
limitations: [
|
||||
"Findings are advisory and do not model the native engine’s complete compiled program or optimizations.",
|
||||
"Absence of a finding does not mean that a pattern is safe or linear.",
|
||||
"Dynamic probes characterize only the generated inputs and limits selected by the user.",
|
||||
...(traversalTruncated
|
||||
? [
|
||||
`Traversal stopped at ${MAXIMUM_STATIC_ANALYSIS_NODES.toLocaleString()} normalized nodes.`,
|
||||
]
|
||||
: []),
|
||||
...(findingsTruncated
|
||||
? [
|
||||
`Findings stopped at ${MAXIMUM_STATIC_RISK_FINDINGS.toLocaleString()} entries.`,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
75
src/regex/analysis/statistics.test.ts
Normal file
75
src/regex/analysis/statistics.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { metricStatistics, summarizeBenchmarkSamples } from "./statistics";
|
||||
import type { BenchmarkWorkerSample } from "./analysis.types";
|
||||
|
||||
function sample(
|
||||
compileMs: number,
|
||||
overrides: Partial<BenchmarkWorkerSample> = {},
|
||||
): BenchmarkWorkerSample {
|
||||
return {
|
||||
accepted: true,
|
||||
effectiveFlags: "g",
|
||||
subjectBytes: 10,
|
||||
subjectUtf16: 10,
|
||||
compileMs,
|
||||
firstMatchMs: compileMs + 1,
|
||||
allMatchesMs: compileMs + 2,
|
||||
replacementMs: compileMs + 3,
|
||||
throughputBytesPerSecond: 100 / (compileMs + 1),
|
||||
matchCount: 1,
|
||||
matched: true,
|
||||
matchCollectionTruncated: false,
|
||||
replacementOutputUtf16: 10,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("analysis statistics", () => {
|
||||
it("computes a conventional median and nearest-rank p95", () => {
|
||||
expect(metricStatistics([4, 1, 3, 2])).toEqual({
|
||||
count: 4,
|
||||
minimum: 1,
|
||||
median: 2.5,
|
||||
p95: 4,
|
||||
maximum: 4,
|
||||
});
|
||||
expect(
|
||||
metricStatistics(Array.from({ length: 100 }, (_, index) => index + 1))
|
||||
?.p95,
|
||||
).toBe(95);
|
||||
});
|
||||
|
||||
it("ignores absent, negative and non-finite measurements", () => {
|
||||
expect(
|
||||
metricStatistics([
|
||||
undefined,
|
||||
Number.NaN,
|
||||
-1,
|
||||
0,
|
||||
Number.POSITIVE_INFINITY,
|
||||
]),
|
||||
).toEqual({
|
||||
count: 1,
|
||||
minimum: 0,
|
||||
median: 0,
|
||||
p95: 0,
|
||||
maximum: 0,
|
||||
});
|
||||
expect(metricStatistics([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("summarizes only measurements actually produced by the worker", () => {
|
||||
const summary = summarizeBenchmarkSamples([
|
||||
sample(1),
|
||||
sample(3, {
|
||||
replacementMs: undefined,
|
||||
replacementSkippedReason: "bounded output estimate",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(summary.compileMs?.median).toBe(2);
|
||||
expect(summary.replacementMs).toEqual(
|
||||
expect.objectContaining({ count: 1, median: 4 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
61
src/regex/analysis/statistics.ts
Normal file
61
src/regex/analysis/statistics.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type {
|
||||
BenchmarkMetricSummary,
|
||||
BenchmarkWorkerSample,
|
||||
MetricStatistics,
|
||||
} from "./analysis.types";
|
||||
|
||||
function finite(values: readonly (number | undefined)[]): number[] {
|
||||
return values.filter(
|
||||
(value): value is number =>
|
||||
value !== undefined && Number.isFinite(value) && value >= 0,
|
||||
);
|
||||
}
|
||||
|
||||
export function metricStatistics(
|
||||
values: readonly (number | undefined)[],
|
||||
): MetricStatistics | undefined {
|
||||
const ordered = finite(values).sort((left, right) => left - right);
|
||||
if (ordered.length === 0) return undefined;
|
||||
const middle = Math.floor(ordered.length / 2);
|
||||
const median =
|
||||
ordered.length % 2 === 0
|
||||
? ((ordered[middle - 1] ?? 0) + (ordered[middle] ?? 0)) / 2
|
||||
: (ordered[middle] ?? 0);
|
||||
const p95Index = Math.max(0, Math.ceil(ordered.length * 0.95) - 1);
|
||||
return {
|
||||
count: ordered.length,
|
||||
minimum: ordered[0] ?? 0,
|
||||
median,
|
||||
p95: ordered[p95Index] ?? ordered.at(-1) ?? 0,
|
||||
maximum: ordered.at(-1) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeBenchmarkSamples(
|
||||
samples: readonly BenchmarkWorkerSample[],
|
||||
): BenchmarkMetricSummary {
|
||||
const metric = (
|
||||
select: (sample: BenchmarkWorkerSample) => number | undefined,
|
||||
) => metricStatistics(samples.map(select));
|
||||
return {
|
||||
...(metric((sample) => sample.compileMs)
|
||||
? { compileMs: metric((sample) => sample.compileMs) }
|
||||
: {}),
|
||||
...(metric((sample) => sample.firstMatchMs)
|
||||
? { firstMatchMs: metric((sample) => sample.firstMatchMs) }
|
||||
: {}),
|
||||
...(metric((sample) => sample.allMatchesMs)
|
||||
? { allMatchesMs: metric((sample) => sample.allMatchesMs) }
|
||||
: {}),
|
||||
...(metric((sample) => sample.replacementMs)
|
||||
? { replacementMs: metric((sample) => sample.replacementMs) }
|
||||
: {}),
|
||||
...(metric((sample) => sample.throughputBytesPerSecond)
|
||||
? {
|
||||
throughputBytesPerSecond: metric(
|
||||
(sample) => sample.throughputBytesPerSecond,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user