feat: publish Regex Tools 0.2.0

This commit is contained in:
2026-07-27 01:06:57 +02:00
parent 873b9b218d
commit 4951c966d4
180 changed files with 35344 additions and 503 deletions

View 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();
});
});

View 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();
}
}

View 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,
};

View 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");
});
});

View 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.",
);
}

View 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;
}

View 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);
});
});

View 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,
};
}

View 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");
});
});

View 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 analysers ${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 analysers ${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 engines 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 engines 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.`,
]
: []),
],
};
}

View 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 }),
);
});
});

View 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,
),
}
: {}),
};
}