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,335 @@
import { describe, expect, it, vi } from "vitest";
import type { RegexEngineInfo } from "../model/flavour";
import type {
RegexExecutionRequest,
RegexExecutionResult,
RegexReplacementRequest,
RegexReplacementResult,
} from "../model/match";
import type {
RegexSyntaxRequest,
RegexSyntaxResult,
ReplacementSyntaxResult,
} from "../model/syntax";
import { WorkerRequestError } from "../execution/WorkerSupervisor";
import {
ComparisonOrchestrator,
type ComparisonEngineRunner,
type ComparisonSyntaxRunner,
} from "./ComparisonOrchestrator";
import type {
ComparisonFlavour,
RegexComparisonRequest,
} from "./comparison.types";
function syntax(request: RegexSyntaxRequest): RegexSyntaxResult {
return {
accepted: true,
root: {
id: `${request.flavour}-root`,
kind: "pattern",
range: { startUtf16: 0, endUtf16: request.pattern.length },
raw: request.pattern,
explanation: "Fixture syntax",
children: [],
properties: {
zeroWidth: false,
nullable: "unknown",
consumesInput: "conditional",
},
support: {
flavour: request.flavour,
status: "partial",
notes: [],
},
provenance: {
provider: `${request.flavour}-fixture`,
providerVersion: "1",
source: "parsed",
},
},
tokens: [],
captures: [
{
number: 1,
name: "word",
range: { startUtf16: 0, endUtf16: request.pattern.length },
repeated: false,
},
],
diagnostics: [],
provider: { id: `${request.flavour}-fixture`, version: "1" },
coverage: {
status: "partial",
summary: "Fixture",
unsupportedConstructs: [],
},
};
}
function engineInfo(flavour: ComparisonFlavour): RegexEngineInfo {
return {
flavour,
adapterVersion: "fixture",
engineName:
flavour === "ecmascript" ? "Native ECMAScript RegExp" : "PCRE2 WASM",
engineVersion: flavour === "ecmascript" ? "fixture-browser" : "10.47",
offsetUnit: flavour === "ecmascript" ? "utf16" : "utf8-byte",
capabilities: {
compilation: true,
matching: true,
replacement: true,
namedCaptures: true,
captureHistory: false,
actualTrace: false,
benchmark: false,
},
};
}
function execution(request: RegexExecutionRequest): RegexExecutionResult {
const nativeEnd = request.flavour === "pcre2" ? 5 : 4;
return {
accepted: true,
engine: engineInfo(request.flavour as ComparisonFlavour),
flags: {
userFlags: request.flags.join(""),
effectiveFlags: request.flags.join(""),
internallyAddedIndicesFlag: request.flavour === "ecmascript",
internallyAddedGlobalFlag: false,
},
matches: [
{
matchNumber: 1,
value: "café",
valueStatus: "complete",
range: { startUtf16: 0, endUtf16: 4 },
nativeRange: {
start: 0,
end: nativeEnd,
unit: request.flavour === "pcre2" ? "utf8-byte" : "utf16",
},
captures: [
{
groupNumber: 1,
groupName: "word",
value: "café",
status: "participated",
range: { startUtf16: 0, endUtf16: 4 },
nativeRange: {
start: 0,
end: nativeEnd,
unit: request.flavour === "pcre2" ? "utf8-byte" : "utf16",
},
},
],
},
],
diagnostics: [],
elapsedMs: 1,
truncated: false,
};
}
function request(
operation: "match" | "replace" = "match",
): RegexComparisonRequest {
return {
schemaVersion: 1,
patternModel: "shared",
operation,
subject: "café",
scanAll: true,
maximumMatches: 100,
maximumCaptureRows: 1_000,
maximumOutputBytes: 1_024,
timeoutMs: 500,
sides: [
{
flavour: "ecmascript",
flavourVersion: "ECMAScript 2025 syntax / current browser runtime",
pattern: "(?<word>.+)",
flags: ["g", "u"],
options: {},
...(operation === "replace" ? { replacement: "$<word>!" } : {}),
},
{
flavour: "pcre2",
flavourVersion: "PCRE2 10.47 8-bit WebAssembly",
pattern: "(?<word>.+)",
flags: ["g"],
options: {
matchLimit: 1_000_000,
depthLimit: 1_000,
heapLimitKib: 32_768,
},
...(operation === "replace" ? { replacement: "${word}!" } : {}),
},
],
};
}
function syntaxRunner(
parsePattern = async (value: RegexSyntaxRequest) => syntax(value),
): ComparisonSyntaxRunner {
return {
parsePattern,
parseReplacement: async (): Promise<ReplacementSyntaxResult> => ({
accepted: true,
tokens: [],
diagnostics: [],
}),
cancel: vi.fn(),
dispose: vi.fn(),
};
}
function engineRunner(
execute = async (value: RegexExecutionRequest) => execution(value),
): ComparisonEngineRunner {
return {
execute,
replace: async (
value: RegexReplacementRequest,
): Promise<RegexReplacementResult> => ({
execution: await execute(value),
output: `${value.subject}!`,
outputBytes: value.subject.length + 1,
outputTruncated: false,
truncated: false,
}),
cancel: vi.fn(),
dispose: vi.fn(),
};
}
describe("ComparisonOrchestrator", () => {
it("retains exact requests and aligns UTF-16 results while preserving native offsets", async () => {
let now = 0;
const runner = engineRunner();
const orchestrator = new ComparisonOrchestrator({
createSyntaxRunner: () => syntaxRunner(),
createEngineRunner: () => runner,
now: () => (now += 1),
isoNow: () => "2026-07-26T12:00:00.000Z",
});
const result = await orchestrator.compare(request());
expect(result.status).toBe("same-for-current-input");
expect(result.notComparable).toEqual([]);
expect(result.matchAlignments).toHaveLength(1);
expect(result.matchAlignments[0]).toMatchObject({
alignment: "normalized-range",
equal: true,
left: {
nativeRange: { end: 4, unit: "utf16" },
},
right: {
nativeRange: { end: 5, unit: "utf8-byte" },
},
});
expect(result.differences.map((difference) => difference.kind)).toEqual([
"effective-flags",
"offset-model",
]);
expect(result.sides[0].executionRequest).toMatchObject({
flavour: "ecmascript",
pattern: "(?<word>.+)",
subject: "café",
captureMetadata: [{ number: 1, name: "word" }],
});
expect(result.sides[1].requestIdentity).toMatch(/^request-[0-9a-f]{8}$/u);
expect(result.notices[0]?.message).toMatch(/not a proof/u);
orchestrator.dispose();
});
it("records an independent timeout instead of interpreting it as no match", async () => {
const runner = engineRunner(async (value) => {
if (value.flavour === "pcre2") {
throw new WorkerRequestError("timeout", "PCRE2 exceeded 500 ms");
}
return execution(value);
});
const orchestrator = new ComparisonOrchestrator({
createSyntaxRunner: () => syntaxRunner(),
createEngineRunner: () => runner,
now: () => 1,
isoNow: () => "2026-07-26T12:00:00.000Z",
});
const result = await orchestrator.compare(request());
expect(result.status).toBe("not-comparable");
expect(result.sides[0].runtime.status).toBe("complete");
expect(result.sides[1].runtime.status).toBe("timeout");
expect(result.notComparable).toContainEqual(
expect.objectContaining({
code: "execution-timeout",
flavour: "pcre2",
}),
);
expect(result.notComparable[0]?.message).toMatch(/not a no-match/u);
orchestrator.dispose();
});
it("keeps per-flavour replacement templates exact", async () => {
const seen: RegexReplacementRequest[] = [];
const runner = engineRunner();
runner.replace = async (value) => {
seen.push(value);
return {
execution: execution(value),
output:
value.flavour === "ecmascript"
? value.replacement
: value.replacement,
outputBytes: value.replacement.length,
outputTruncated: false,
truncated: false,
};
};
const orchestrator = new ComparisonOrchestrator({
createSyntaxRunner: () => syntaxRunner(),
createEngineRunner: () => runner,
now: () => 1,
isoNow: () => "2026-07-26T12:00:00.000Z",
});
const result = await orchestrator.compare(request("replace"));
expect(seen.map((value) => value.replacement)).toEqual([
"$<word>!",
"${word}!",
]);
expect(result.status).toBe("different-for-current-input");
expect(
result.differences.find(
(difference) => difference.kind === "replacement-output",
),
).toMatchObject({
left: "$<word>!",
right: "${word}!",
});
orchestrator.dispose();
});
it("refuses mismatched shared patterns before constructing a semantic result", async () => {
const input = request();
const invalid: RegexComparisonRequest = {
...input,
sides: [input.sides[0], { ...input.sides[1], pattern: "(?<word>\\w+)" }],
};
const orchestrator = new ComparisonOrchestrator({
createSyntaxRunner: () => syntaxRunner(),
createEngineRunner: () => engineRunner(),
now: () => 1,
isoNow: () => "2026-07-26T12:00:00.000Z",
});
await expect(orchestrator.compare(invalid)).rejects.toThrow(
/exact same pattern/u,
);
orchestrator.dispose();
});
});

View File

@@ -0,0 +1,536 @@
import {
AVAILABLE_REGEX_FLAVOURS,
parseRegexFlags,
parseRegexOptions,
resolveRegexFlavourVersion,
} from "../flavours/flavour-registry";
import type {
RegexExecutionRequest,
RegexReplacementRequest,
} from "../model/match";
import type {
RegexSyntaxRequest,
RegexSyntaxResult,
ReplacementSyntaxResult,
} from "../model/syntax";
import {
DEFAULT_REGEX_LIMITS,
utf8ByteLength,
} from "../execution/request-limits";
import { EngineSupervisor } from "../execution/EngineSupervisor";
import { SyntaxSupervisor } from "../execution/SyntaxSupervisor";
import { WorkerRequestError } from "../execution/WorkerSupervisor";
import { compareCompletedResults } from "./compare-results";
import type {
ComparisonExecutionOutcome,
ComparisonFlavour,
ComparisonSideInput,
ComparisonSideOutcome,
ComparisonSyntaxOutcome,
ComparisonTaskFailure,
RegexComparisonRequest,
RegexComparisonResult,
} from "./comparison.types";
export interface ComparisonSyntaxRunner {
parsePattern(
request: RegexSyntaxRequest,
timeoutMs?: number,
): Promise<RegexSyntaxResult>;
parseReplacement(
request: {
readonly flavour: ComparisonFlavour;
readonly replacement: string;
readonly captureMetadata: RegexSyntaxResult["captures"];
},
timeoutMs?: number,
): Promise<ReplacementSyntaxResult>;
cancel(): void;
dispose(): void;
}
export interface ComparisonEngineRunner {
execute(
request: RegexExecutionRequest,
timeoutMs: number,
): ReturnType<EngineSupervisor["execute"]>;
replace(
request: RegexReplacementRequest,
timeoutMs: number,
): ReturnType<EngineSupervisor["replace"]>;
cancel(): void;
dispose(): void;
}
export interface ComparisonOrchestratorDependencies {
readonly createSyntaxRunner: (
flavour: ComparisonFlavour,
) => ComparisonSyntaxRunner;
readonly createEngineRunner: () => ComparisonEngineRunner;
readonly now: () => number;
readonly isoNow: () => string;
}
const DEFAULT_DEPENDENCIES: ComparisonOrchestratorDependencies = {
createSyntaxRunner: (flavour) =>
new SyntaxSupervisor(
`${flavour} comparison syntax`,
`regex-tools-comparison-${flavour}-syntax`,
),
createEngineRunner: () => new EngineSupervisor(),
now: () => performance.now(),
isoNow: () => new Date().toISOString(),
};
interface PreparedSide {
readonly input: ComparisonSideInput;
readonly syntaxRequest: RegexSyntaxRequest;
}
function requireBoundedInteger(
value: number,
label: string,
minimum: number,
maximum: number,
): number {
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
throw new RangeError(
`${label} must be an integer from ${minimum.toLocaleString()} to ${maximum.toLocaleString()}.`,
);
}
return value;
}
function validateSide(input: ComparisonSideInput): PreparedSide {
const definition = AVAILABLE_REGEX_FLAVOURS.require(input.flavour);
const version = resolveRegexFlavourVersion(
input.flavourVersion,
definition,
`${definition.label} comparison version`,
).definition;
const flags = parseRegexFlags(
input.flags,
definition,
`${definition.label} comparison flags`,
);
const options = parseRegexOptions(
input.options,
definition,
`${definition.label} comparison options`,
);
if (input.pattern.length > DEFAULT_REGEX_LIMITS.patternHardLengthUtf16) {
throw new RangeError(
`${definition.label} comparison pattern exceeds the ${DEFAULT_REGEX_LIMITS.patternHardLengthUtf16.toLocaleString()} UTF-16 unit limit.`,
);
}
if (
input.replacement !== undefined &&
input.replacement.length >
DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16
) {
throw new RangeError(
`${definition.label} comparison replacement exceeds the ${DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16.toLocaleString()} UTF-16 unit limit.`,
);
}
const normalized: ComparisonSideInput = {
flavour: input.flavour,
flavourVersion: version.value,
pattern: input.pattern,
flags,
options,
...(input.replacement === undefined
? {}
: { replacement: input.replacement }),
};
return {
input: normalized,
syntaxRequest: {
flavour: normalized.flavour,
flavourVersion: version.syntaxVersion,
pattern: normalized.pattern,
flags: normalized.flags,
options: normalized.options,
},
};
}
export function validateRegexComparisonRequest(
request: RegexComparisonRequest,
): {
readonly request: RegexComparisonRequest;
readonly sides: readonly [PreparedSide, PreparedSide];
} {
if (request.schemaVersion !== 1) {
throw new Error("Unsupported comparison request schema.");
}
if (
request.patternModel !== "shared" &&
request.patternModel !== "variants"
) {
throw new Error("Comparison pattern model is unsupported.");
}
if (request.operation !== "match" && request.operation !== "replace") {
throw new Error("Comparison operation is unsupported.");
}
const flavours = request.sides.map((side) => side.flavour);
if (
new Set(flavours).size !== 2 ||
!flavours.includes("ecmascript") ||
!flavours.includes("pcre2")
) {
throw new Error(
"This comparison vertical requires one ECMAScript and one PCRE2 side.",
);
}
const preparedInput = request.sides.map(validateSide);
const ecmascript = preparedInput.find(
(side) => side.input.flavour === "ecmascript",
);
const pcre2 = preparedInput.find((side) => side.input.flavour === "pcre2");
if (!ecmascript || !pcre2) {
throw new Error("Comparison sides could not be normalized.");
}
const prepared = [ecmascript, pcre2] as const;
if (
request.patternModel === "shared" &&
prepared[0].input.pattern !== prepared[1].input.pattern
) {
throw new Error(
"Shared-pattern comparison must send the exact same pattern to both engines.",
);
}
if (
request.operation === "replace" &&
prepared.some((side) => side.input.replacement === undefined)
) {
throw new Error(
"Replacement comparison requires an explicit replacement for each flavour.",
);
}
if (
utf8ByteLength(request.subject) >
DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes
) {
throw new RangeError(
`Comparison subject exceeds the ${DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes.toLocaleString()} UTF-8 byte limit.`,
);
}
const maximumMatches = requireBoundedInteger(
request.maximumMatches,
"Comparison match limit",
1,
DEFAULT_REGEX_LIMITS.maximumMatches,
);
const maximumCaptureRows = requireBoundedInteger(
request.maximumCaptureRows,
"Comparison capture-row limit",
1,
DEFAULT_REGEX_LIMITS.maximumCaptureRows,
);
const maximumOutputBytes = requireBoundedInteger(
request.maximumOutputBytes,
"Comparison replacement-output limit",
1,
DEFAULT_REGEX_LIMITS.maximumReplacementOutputBytes,
);
const timeoutMs = requireBoundedInteger(
request.timeoutMs,
"Comparison side timeout",
1,
DEFAULT_REGEX_LIMITS.advancedMaximumTimeoutMs,
);
const normalized: RegexComparisonRequest = {
...request,
scanAll: request.scanAll === true,
maximumMatches,
maximumCaptureRows,
maximumOutputBytes,
timeoutMs,
sides: [prepared[0].input, prepared[1].input],
};
return { request: normalized, sides: prepared };
}
function classifyFailure(error: unknown): ComparisonTaskFailure {
if (error instanceof WorkerRequestError) {
if (error.kind === "timeout") {
return { status: "timeout", message: error.message };
}
if (error.kind === "cancelled") {
return { status: "cancelled", message: error.message };
}
}
return {
status: "worker-failed",
message: error instanceof Error ? error.message : String(error),
};
}
function stableOptions(options: ComparisonSideInput["options"]): string {
return JSON.stringify(
Object.fromEntries(
Object.entries(options).sort(([left], [right]) =>
left.localeCompare(right),
),
),
);
}
function requestIdentity(
request: RegexExecutionRequest | RegexReplacementRequest,
): string {
const replacement =
"replacement" in request ? `\u0000${request.replacement}` : "";
const value = [
request.flavour,
request.flavourVersion ?? "",
request.pattern,
request.flags.join(""),
stableOptions(request.options ?? {}),
request.subject,
String(request.scanAll),
String(request.maximumMatches),
String(request.maximumCaptureRows),
JSON.stringify(request.captureMetadata),
"maximumOutputBytes" in request ? String(request.maximumOutputBytes) : "",
replacement,
].join("\u0001");
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return `request-${(hash >>> 0).toString(16).padStart(8, "0")}`;
}
function executionRequest(
request: RegexComparisonRequest,
prepared: PreparedSide,
captures: RegexSyntaxResult["captures"],
): RegexExecutionRequest {
return {
flavour: prepared.input.flavour,
flavourVersion: prepared.input.flavourVersion,
pattern: prepared.input.pattern,
flags: prepared.input.flags,
options: prepared.input.options,
subject: request.subject,
captureMetadata: captures,
scanAll: request.scanAll,
maximumMatches: request.maximumMatches,
maximumCaptureRows: request.maximumCaptureRows,
};
}
export class ComparisonOrchestrator {
readonly #dependencies: ComparisonOrchestratorDependencies;
readonly #syntax: ReadonlyMap<ComparisonFlavour, ComparisonSyntaxRunner>;
readonly #engine: ComparisonEngineRunner;
#generation = 0;
#disposed = false;
constructor(
dependencies: ComparisonOrchestratorDependencies = DEFAULT_DEPENDENCIES,
) {
this.#dependencies = dependencies;
this.#syntax = new Map(
(["ecmascript", "pcre2"] as const).map((flavour) => [
flavour,
dependencies.createSyntaxRunner(flavour),
]),
);
this.#engine = dependencies.createEngineRunner();
}
async compare(input: RegexComparisonRequest): Promise<RegexComparisonResult> {
if (this.#disposed) {
throw new Error("Comparison orchestrator has been disposed.");
}
this.cancel();
const generation = this.#generation;
const validated = validateRegexComparisonRequest(input);
const startedAt = this.#dependencies.isoNow();
const start = this.#dependencies.now();
const settled = await Promise.allSettled(
validated.sides.map((side) =>
this.#runSide(validated.request, side, generation),
),
);
const outcomes = settled.map((entry, index) => {
if (entry.status === "fulfilled") return entry.value;
const prepared = validated.sides[index];
if (!prepared) throw entry.reason;
return this.#failedSide(
validated.request,
prepared,
classifyFailure(entry.reason),
);
}) as unknown as readonly [ComparisonSideOutcome, ComparisonSideOutcome];
return compareCompletedResults(
validated.request,
startedAt,
this.#dependencies.now() - start,
outcomes,
);
}
cancel(): void {
this.#generation += 1;
for (const syntax of this.#syntax.values()) syntax.cancel();
this.#engine.cancel();
}
dispose(): void {
if (this.#disposed) return;
this.cancel();
this.#disposed = true;
for (const syntax of this.#syntax.values()) syntax.dispose();
this.#engine.dispose();
}
async #runSide(
request: RegexComparisonRequest,
prepared: PreparedSide,
generation: number,
): Promise<ComparisonSideOutcome> {
const syntaxRunner = this.#syntax.get(prepared.input.flavour);
if (!syntaxRunner) {
throw new Error(`Missing ${prepared.input.flavour} syntax runner.`);
}
const syntaxStart = this.#dependencies.now();
let patternSyntax: RegexSyntaxResult | undefined;
let replacementSyntax: ReplacementSyntaxResult | undefined;
let syntaxFailure: ComparisonTaskFailure | undefined;
try {
patternSyntax = await syntaxRunner.parsePattern(
prepared.syntaxRequest,
Math.min(1_000, request.timeoutMs),
);
if (request.operation === "replace") {
replacementSyntax = await syntaxRunner.parseReplacement(
{
flavour: prepared.input.flavour,
replacement: prepared.input.replacement ?? "",
captureMetadata: patternSyntax.captures,
},
Math.min(1_000, request.timeoutMs),
);
}
} catch (error) {
syntaxFailure = classifyFailure(error);
}
const syntax: ComparisonSyntaxOutcome = {
status: syntaxFailure?.status ?? "complete",
elapsedMs: this.#dependencies.now() - syntaxStart,
...(patternSyntax ? { pattern: patternSyntax } : {}),
...(replacementSyntax ? { replacement: replacementSyntax } : {}),
...(syntaxFailure ? { error: syntaxFailure.message } : {}),
};
const exactExecution = executionRequest(
request,
prepared,
patternSyntax?.captures ?? [],
);
const exactReplacement: RegexReplacementRequest | undefined =
request.operation === "replace"
? {
...exactExecution,
replacement: prepared.input.replacement ?? "",
maximumOutputBytes: request.maximumOutputBytes,
}
: undefined;
if (generation !== this.#generation) {
const runtime: ComparisonExecutionOutcome = {
status: "cancelled",
elapsedMs: 0,
error: "Comparison was cancelled before engine execution.",
};
return {
requestIdentity: requestIdentity(exactReplacement ?? exactExecution),
input: prepared.input,
syntaxRequest: prepared.syntaxRequest,
executionRequest: exactExecution,
...(exactReplacement ? { replacementRequest: exactReplacement } : {}),
syntax,
runtime,
};
}
const runtimeStart = this.#dependencies.now();
let runtime: ComparisonExecutionOutcome;
try {
if (exactReplacement) {
const replacement = await this.#engine.replace(
exactReplacement,
request.timeoutMs,
);
runtime = {
status: "complete",
elapsedMs: this.#dependencies.now() - runtimeStart,
replacement,
};
} else {
const execution = await this.#engine.execute(
exactExecution,
request.timeoutMs,
);
runtime = {
status: "complete",
elapsedMs: this.#dependencies.now() - runtimeStart,
execution,
};
}
} catch (error) {
const failure = classifyFailure(error);
runtime = {
status: failure.status,
elapsedMs: this.#dependencies.now() - runtimeStart,
error: failure.message,
};
}
const execution = runtime.replacement?.execution ?? runtime.execution;
return {
requestIdentity: requestIdentity(exactReplacement ?? exactExecution),
input: prepared.input,
syntaxRequest: prepared.syntaxRequest,
executionRequest: exactExecution,
...(exactReplacement ? { replacementRequest: exactReplacement } : {}),
syntax,
runtime,
...(execution ? { engine: execution.engine } : {}),
};
}
#failedSide(
request: RegexComparisonRequest,
prepared: PreparedSide,
failure: ComparisonTaskFailure,
): ComparisonSideOutcome {
const exactExecution = executionRequest(request, prepared, []);
const exactReplacement =
request.operation === "replace"
? {
...exactExecution,
replacement: prepared.input.replacement ?? "",
maximumOutputBytes: request.maximumOutputBytes,
}
: undefined;
return {
requestIdentity: requestIdentity(exactReplacement ?? exactExecution),
input: prepared.input,
syntaxRequest: prepared.syntaxRequest,
executionRequest: exactExecution,
...(exactReplacement ? { replacementRequest: exactReplacement } : {}),
syntax: {
status: "worker-failed",
elapsedMs: 0,
error: "Comparison side failed before syntax completion.",
},
runtime: {
status: failure.status,
elapsedMs: 0,
error: failure.message,
},
};
}
}

View File

@@ -0,0 +1,666 @@
import type {
CaptureResult,
RegexExecutionResult,
RegexMatchResult,
} from "../model/match";
import type {
CaptureAlignment,
ComparisonDifference,
ComparisonDifferenceKind,
ComparisonFlavour,
ComparisonNotComparableReason,
ComparisonSideOutcome,
MatchAlignment,
RegexComparisonRequest,
RegexComparisonResult,
} from "./comparison.types";
const MAXIMUM_RETAINED_DIFFERENCES = 5_000;
const MAXIMUM_RETAINED_MATCH_ALIGNMENTS = 2_000;
const MAXIMUM_DIFFERENCE_VALUE_UTF16 = 1_024;
function differenceValue(value: string | undefined): string | undefined {
if (value === undefined || value.length <= MAXIMUM_DIFFERENCE_VALUE_UTF16) {
return value;
}
return `${value.slice(0, MAXIMUM_DIFFERENCE_VALUE_UTF16)}… (${value.length.toLocaleString()} UTF-16 units; complete value retained in its side result)`;
}
class DifferenceCollector {
readonly retained: ComparisonDifference[] = [];
total = 0;
semanticTotal = 0;
add(
kind: ComparisonDifferenceKind,
summary: string,
left?: string,
right?: string,
): void {
this.total += 1;
if (kind !== "effective-flags" && kind !== "offset-model") {
this.semanticTotal += 1;
}
if (this.retained.length >= MAXIMUM_RETAINED_DIFFERENCES) return;
this.retained.push({
kind,
summary,
...(left === undefined ? {} : { left }),
...(right === undefined ? {} : { right }),
});
}
}
function rangeKey(
value:
| {
readonly range?: {
readonly startUtf16: number;
readonly endUtf16: number;
};
}
| undefined,
): string | undefined {
const range = value?.range;
return range ? `${range.startUtf16}:${range.endUtf16}` : undefined;
}
function displayRange(
value:
| {
readonly range?: {
readonly startUtf16: number;
readonly endUtf16: number;
};
}
| undefined,
): string {
const range = value?.range;
return range ? `${range.startUtf16}${range.endUtf16}` : "unavailable";
}
function captureIdentity(capture: CaptureResult): string {
return capture.groupName
? `name:${capture.groupName}`
: `number:${capture.groupNumber}`;
}
function takeMatching<T>(
values: readonly T[],
used: Set<number>,
predicate: (value: T) => boolean,
): { readonly value: T; readonly index: number } | undefined {
for (let index = 0; index < values.length; index += 1) {
if (used.has(index)) continue;
const value = values[index];
if (value !== undefined && predicate(value)) return { value, index };
}
return undefined;
}
function captureEqual(left: CaptureResult, right: CaptureResult): boolean {
return (
left.groupNumber === right.groupNumber &&
left.groupName === right.groupName &&
left.status === right.status &&
rangeKey(left) === rangeKey(right) &&
left.value === right.value
);
}
function compareCapture(
left: CaptureResult | undefined,
right: CaptureResult | undefined,
alignment: CaptureAlignment["alignment"],
differences: DifferenceCollector,
): CaptureAlignment {
if (!left || !right) {
differences.add(
"capture-presence",
"A capture is present on only one side.",
left ? captureIdentity(left) : "absent",
right ? captureIdentity(right) : "absent",
);
return {
alignment,
...(left ? { left } : {}),
...(right ? { right } : {}),
equal: false,
};
}
if (
left.groupNumber !== right.groupNumber ||
left.groupName !== right.groupName
) {
differences.add(
"capture-identity",
"Aligned captures have different group identities.",
captureIdentity(left),
captureIdentity(right),
);
}
if (left.status !== right.status) {
differences.add(
"capture-status",
"Aligned captures have different participation states.",
left.status,
right.status,
);
}
if (rangeKey(left) !== rangeKey(right)) {
differences.add(
"capture-range",
"Aligned captures have different normalized UTF-16 ranges.",
displayRange(left),
displayRange(right),
);
}
if (left.value !== right.value) {
differences.add(
"capture-value",
"Aligned captures have different retained values.",
differenceValue(left.value) ?? "unavailable",
differenceValue(right.value) ?? "unavailable",
);
}
return {
alignment,
left,
right,
equal: captureEqual(left, right),
};
}
function alignCaptures(
left: readonly CaptureResult[],
right: readonly CaptureResult[],
differences: DifferenceCollector,
): readonly CaptureAlignment[] {
const alignments: CaptureAlignment[] = [];
const usedRight = new Set<number>();
const deferredLeft: CaptureResult[] = [];
for (const capture of left) {
const key = rangeKey(capture);
const sameRange = key
? (takeMatching(
right,
usedRight,
(candidate) =>
rangeKey(candidate) === key &&
captureIdentity(candidate) === captureIdentity(capture),
) ??
takeMatching(
right,
usedRight,
(candidate) => rangeKey(candidate) === key,
))
: undefined;
if (!sameRange) {
deferredLeft.push(capture);
continue;
}
usedRight.add(sameRange.index);
alignments.push(
compareCapture(capture, sameRange.value, "normalized-range", differences),
);
}
const stillDeferred: CaptureResult[] = [];
for (const capture of deferredLeft) {
const sameIdentity = takeMatching(
right,
usedRight,
(candidate) => captureIdentity(candidate) === captureIdentity(capture),
);
if (!sameIdentity) {
stillDeferred.push(capture);
continue;
}
usedRight.add(sameIdentity.index);
alignments.push(
compareCapture(
capture,
sameIdentity.value,
"capture-identity",
differences,
),
);
}
const remainingRight = right
.map((value, index) => ({ value, index }))
.filter(({ index }) => !usedRight.has(index));
const paired = Math.min(stillDeferred.length, remainingRight.length);
for (let index = 0; index < paired; index += 1) {
const leftCapture = stillDeferred[index];
const rightCapture = remainingRight[index];
if (!leftCapture || !rightCapture) continue;
usedRight.add(rightCapture.index);
alignments.push(
compareCapture(
leftCapture,
rightCapture.value,
"ordinal-fallback",
differences,
),
);
}
for (const capture of stillDeferred.slice(paired)) {
alignments.push(
compareCapture(capture, undefined, "left-only", differences),
);
}
for (const { value, index } of remainingRight.slice(paired)) {
usedRight.add(index);
alignments.push(
compareCapture(undefined, value, "right-only", differences),
);
}
return alignments;
}
function matchEqual(
left: RegexMatchResult,
right: RegexMatchResult,
captures: readonly CaptureAlignment[],
): boolean {
return (
rangeKey(left) === rangeKey(right) &&
left.value === right.value &&
left.valueStatus === right.valueStatus &&
captures.every((capture) => capture.equal)
);
}
function compareMatch(
left: RegexMatchResult | undefined,
right: RegexMatchResult | undefined,
alignment: MatchAlignment["alignment"],
differences: DifferenceCollector,
): MatchAlignment {
if (!left || !right) {
differences.add(
"match-presence",
"A match is present on only one side.",
left ? `match ${left.matchNumber} at ${displayRange(left)}` : "absent",
right ? `match ${right.matchNumber} at ${displayRange(right)}` : "absent",
);
return {
alignment,
...(left ? { left } : {}),
...(right ? { right } : {}),
captures: [],
equal: false,
};
}
if (rangeKey(left) !== rangeKey(right)) {
differences.add(
"match-range",
"Aligned matches have different normalized UTF-16 ranges.",
displayRange(left),
displayRange(right),
);
}
if (left.value !== right.value || left.valueStatus !== right.valueStatus) {
differences.add(
"match-value",
"Aligned matches have different retained values.",
differenceValue(left.value),
differenceValue(right.value),
);
}
const captures = alignCaptures(left.captures, right.captures, differences);
return {
alignment,
left,
right,
captures,
equal: matchEqual(left, right, captures),
};
}
function alignMatches(
left: readonly RegexMatchResult[],
right: readonly RegexMatchResult[],
differences: DifferenceCollector,
): readonly MatchAlignment[] {
const alignments: MatchAlignment[] = [];
const usedRight = new Set<number>();
const deferredLeft: RegexMatchResult[] = [];
for (const match of left) {
const key = rangeKey(match);
const sameRange = takeMatching(
right,
usedRight,
(candidate) => rangeKey(candidate) === key,
);
if (!sameRange) {
deferredLeft.push(match);
continue;
}
usedRight.add(sameRange.index);
alignments.push(
compareMatch(match, sameRange.value, "normalized-range", differences),
);
}
const remainingRight = right
.map((value, index) => ({ value, index }))
.filter(({ index }) => !usedRight.has(index));
const paired = Math.min(deferredLeft.length, remainingRight.length);
for (let index = 0; index < paired; index += 1) {
const leftMatch = deferredLeft[index];
const rightMatch = remainingRight[index];
if (!leftMatch || !rightMatch) continue;
usedRight.add(rightMatch.index);
alignments.push(
compareMatch(
leftMatch,
rightMatch.value,
"ordinal-fallback",
differences,
),
);
}
for (const match of deferredLeft.slice(paired)) {
alignments.push(compareMatch(match, undefined, "left-only", differences));
}
for (const { value, index } of remainingRight.slice(paired)) {
usedRight.add(index);
alignments.push(compareMatch(undefined, value, "right-only", differences));
}
return alignments;
}
function executionFor(
side: ComparisonSideOutcome,
): RegexExecutionResult | undefined {
return side.runtime.replacement?.execution ?? side.runtime.execution;
}
function addReason(
values: ComparisonNotComparableReason[],
reason: ComparisonNotComparableReason,
): void {
if (
values.some(
(candidate) =>
candidate.code === reason.code &&
candidate.flavour === reason.flavour &&
candidate.message === reason.message,
)
) {
return;
}
values.push(reason);
}
function taskReasons(
side: ComparisonSideOutcome,
reasons: ComparisonNotComparableReason[],
): void {
const flavour = side.input.flavour;
if (side.syntax.status === "timeout") {
addReason(reasons, {
code: "syntax-timeout",
flavour,
message: `${flavour} syntax parsing timed out.`,
});
} else if (side.syntax.status !== "complete") {
addReason(reasons, {
code: "syntax-worker-failed",
flavour,
message: `${flavour} syntax parsing did not complete: ${side.syntax.error ?? side.syntax.status}.`,
});
} else if (
side.syntax.pattern?.accepted === false ||
side.syntax.replacement?.accepted === false
) {
addReason(reasons, {
code: "syntax-rejected",
flavour,
message: `${flavour} syntax provider rejected the exact pattern or replacement snapshot.`,
});
}
if (side.runtime.status === "timeout") {
addReason(reasons, {
code: "execution-timeout",
flavour,
message: `${flavour} execution timed out; timeout is not a no-match result.`,
});
return;
}
if (side.runtime.status === "cancelled") {
addReason(reasons, {
code: "execution-cancelled",
flavour,
message: `${flavour} execution was cancelled and has no semantic result.`,
});
return;
}
if (side.runtime.status !== "complete") {
addReason(reasons, {
code: "execution-worker-failed",
flavour,
message: `${flavour} worker did not complete: ${side.runtime.error ?? side.runtime.status}.`,
});
return;
}
const execution = executionFor(side);
if (!execution?.accepted) {
addReason(reasons, {
code: "compile-rejected",
flavour,
message: `${flavour} engine rejected the exact pattern snapshot.`,
});
return;
}
if (execution.truncated) {
addReason(reasons, {
code: "result-truncated",
flavour,
message: `${flavour} results reached a configured match or capture limit.`,
});
}
if (
execution.matches.some(
(match) =>
match.valueStatus === "truncated" ||
match.captures.some((capture) => capture.status === "truncated"),
)
) {
addReason(reasons, {
code: "value-truncated",
flavour,
message: `${flavour} retained value previews are incomplete.`,
});
}
if (side.runtime.replacement?.truncated) {
addReason(reasons, {
code: "replacement-truncated",
flavour,
message: `${flavour} replacement output or its match collection is incomplete.`,
});
}
}
function compareMetadata(
left: ComparisonSideOutcome,
right: ComparisonSideOutcome,
differences: DifferenceCollector,
): void {
const leftSyntax = left.syntax.pattern;
const rightSyntax = right.syntax.pattern;
if (
leftSyntax &&
rightSyntax &&
leftSyntax.accepted !== rightSyntax.accepted
) {
differences.add(
"syntax-acceptance",
"The syntax providers disagree on acceptance.",
leftSyntax.accepted ? "accepted" : "rejected",
rightSyntax.accepted ? "accepted" : "rejected",
);
}
const leftExecution = executionFor(left);
const rightExecution = executionFor(right);
if (
leftExecution &&
rightExecution &&
leftExecution.accepted !== rightExecution.accepted
) {
differences.add(
"compile-acceptance",
"The execution engines disagree on compilation.",
leftExecution.accepted ? "accepted" : "rejected",
rightExecution.accepted ? "accepted" : "rejected",
);
}
if (
leftExecution &&
rightExecution &&
leftExecution.flags.effectiveFlags !== rightExecution.flags.effectiveFlags
) {
differences.add(
"effective-flags",
"The exact effective engine flags differ.",
leftExecution.flags.effectiveFlags || "none",
rightExecution.flags.effectiveFlags || "none",
);
}
if (
leftExecution &&
rightExecution &&
leftExecution.engine.offsetUnit !== rightExecution.engine.offsetUnit
) {
differences.add(
"offset-model",
"Native offset units differ; alignments use normalized editor UTF-16 ranges.",
leftExecution.engine.offsetUnit,
rightExecution.engine.offsetUnit,
);
}
}
export function compareCompletedResults(
request: RegexComparisonRequest,
startedAt: string,
elapsedMs: number,
sides: readonly [ComparisonSideOutcome, ComparisonSideOutcome],
): RegexComparisonResult {
const [left, right] = sides;
const differences = new DifferenceCollector();
const notComparable: ComparisonNotComparableReason[] = [];
taskReasons(left, notComparable);
taskReasons(right, notComparable);
compareMetadata(left, right, differences);
const leftExecution = executionFor(left);
const rightExecution = executionFor(right);
let allAlignments: readonly MatchAlignment[] = [];
if (leftExecution?.accepted && rightExecution?.accepted) {
if (leftExecution.matches.length !== rightExecution.matches.length) {
differences.add(
"match-count",
"The engines returned different match counts.",
leftExecution.matches.length.toLocaleString(),
rightExecution.matches.length.toLocaleString(),
);
}
allAlignments = alignMatches(
leftExecution.matches,
rightExecution.matches,
differences,
);
const leftReplacement = left.runtime.replacement;
const rightReplacement = right.runtime.replacement;
if (
leftReplacement &&
rightReplacement &&
leftReplacement.output !== rightReplacement.output
) {
differences.add(
"replacement-output",
"The engine-native replacement outputs differ.",
differenceValue(leftReplacement.output),
differenceValue(rightReplacement.output),
);
}
}
const syntaxAcceptanceDiffers =
left.syntax.pattern !== undefined &&
right.syntax.pattern !== undefined &&
left.syntax.pattern.accepted !== right.syntax.pattern.accepted;
const compileAcceptanceDiffers =
leftExecution !== undefined &&
rightExecution !== undefined &&
leftExecution.accepted !== rightExecution.accepted;
if (syntaxAcceptanceDiffers || compileAcceptanceDiffers) {
addReason(notComparable, {
code: "flavour-specific-syntax",
message:
"At least one exact snapshot is accepted on only one side; no runtime equivalence claim is possible.",
});
}
const status =
notComparable.length > 0
? "not-comparable"
: differences.semanticTotal === 0
? "same-for-current-input"
: "different-for-current-input";
const notices =
status === "same-for-current-input"
? [
{
confidence: "likely-equivalent-for-input" as const,
message:
"The completed, untruncated results agree for this subject only. This is test evidence, not a proof that the patterns are equivalent.",
},
]
: status === "different-for-current-input"
? [
{
confidence: "requires-manual-review" as const,
message:
"The exact engine results differ for this subject. Review syntax, flags, ranges and captures before porting.",
},
]
: [
{
confidence:
compileAcceptanceDiffers || syntaxAcceptanceDiffers
? ("no-direct-equivalent" as const)
: ("requires-manual-review" as const),
message:
"The run is not comparable because one or more authoritative results are incomplete or rejected.",
},
];
return {
schemaVersion: 1,
request,
startedAt,
elapsedMs,
sides,
status,
notComparable,
differences: differences.retained,
totalDifferences: differences.total,
differencesTruncated: differences.retained.length < differences.total,
matchAlignments: allAlignments.slice(0, MAXIMUM_RETAINED_MATCH_ALIGNMENTS),
totalMatchAlignments: allAlignments.length,
matchAlignmentsTruncated:
allAlignments.length > MAXIMUM_RETAINED_MATCH_ALIGNMENTS,
notices,
};
}
export function comparisonSideLabel(flavour: ComparisonFlavour): string {
return flavour === "ecmascript" ? "ECMAScript" : "PCRE2";
}

View File

@@ -0,0 +1,174 @@
import type { RegexEngineInfo, RegexEngineOptions } from "../model/flavour";
import type {
CaptureResult,
RegexExecutionRequest,
RegexExecutionResult,
RegexMatchResult,
RegexReplacementRequest,
RegexReplacementResult,
} from "../model/match";
import type {
RegexSyntaxRequest,
RegexSyntaxResult,
ReplacementSyntaxResult,
} from "../model/syntax";
export type ComparisonFlavour = "ecmascript" | "pcre2";
export type ComparisonOperation = "match" | "replace";
export type ComparisonPatternModel = "shared" | "variants";
export interface ComparisonSideInput {
readonly flavour: ComparisonFlavour;
readonly flavourVersion: string;
readonly pattern: string;
readonly flags: readonly string[];
readonly options: RegexEngineOptions;
readonly replacement?: string;
}
export interface RegexComparisonRequest {
readonly schemaVersion: 1;
readonly patternModel: ComparisonPatternModel;
readonly operation: ComparisonOperation;
readonly subject: string;
readonly scanAll: boolean;
readonly maximumMatches: number;
readonly maximumCaptureRows: number;
readonly maximumOutputBytes: number;
readonly timeoutMs: number;
readonly sides: readonly [ComparisonSideInput, ComparisonSideInput];
}
export type ComparisonTaskStatus =
"complete" | "timeout" | "cancelled" | "worker-failed";
export interface ComparisonTaskFailure {
readonly status: Exclude<ComparisonTaskStatus, "complete">;
readonly message: string;
}
export interface ComparisonSyntaxOutcome {
readonly status: ComparisonTaskStatus;
readonly elapsedMs: number;
readonly pattern?: RegexSyntaxResult;
readonly replacement?: ReplacementSyntaxResult;
readonly error?: string;
}
export interface ComparisonExecutionOutcome {
readonly status: ComparisonTaskStatus;
readonly elapsedMs: number;
readonly execution?: RegexExecutionResult;
readonly replacement?: RegexReplacementResult;
readonly error?: string;
}
export interface ComparisonSideOutcome {
/**
* A deterministic display key for the exact request. It is not a
* cryptographic digest; the complete request sent to the worker is retained
* below and remains authoritative.
*/
readonly requestIdentity: string;
readonly input: ComparisonSideInput;
readonly syntaxRequest: RegexSyntaxRequest;
readonly executionRequest: RegexExecutionRequest;
readonly replacementRequest?: RegexReplacementRequest;
readonly syntax: ComparisonSyntaxOutcome;
readonly runtime: ComparisonExecutionOutcome;
readonly engine?: RegexEngineInfo;
}
export type ComparisonNotComparableCode =
| "syntax-timeout"
| "syntax-worker-failed"
| "syntax-rejected"
| "flavour-specific-syntax"
| "execution-timeout"
| "execution-cancelled"
| "execution-worker-failed"
| "compile-rejected"
| "result-truncated"
| "replacement-truncated"
| "value-truncated";
export interface ComparisonNotComparableReason {
readonly code: ComparisonNotComparableCode;
readonly flavour?: ComparisonFlavour;
readonly message: string;
}
export type ComparisonDifferenceKind =
| "syntax-acceptance"
| "compile-acceptance"
| "effective-flags"
| "offset-model"
| "match-count"
| "match-presence"
| "match-range"
| "match-value"
| "capture-presence"
| "capture-identity"
| "capture-status"
| "capture-range"
| "capture-value"
| "replacement-output";
export interface ComparisonDifference {
readonly kind: ComparisonDifferenceKind;
readonly summary: string;
readonly left?: string;
readonly right?: string;
}
export type MatchAlignmentKind =
"normalized-range" | "ordinal-fallback" | "left-only" | "right-only";
export type CaptureAlignmentKind =
| "normalized-range"
| "capture-identity"
| "ordinal-fallback"
| "left-only"
| "right-only";
export interface CaptureAlignment {
readonly alignment: CaptureAlignmentKind;
readonly left?: CaptureResult;
readonly right?: CaptureResult;
readonly equal: boolean;
}
export interface MatchAlignment {
readonly alignment: MatchAlignmentKind;
readonly left?: RegexMatchResult;
readonly right?: RegexMatchResult;
readonly captures: readonly CaptureAlignment[];
readonly equal: boolean;
}
export interface ComparisonNotice {
readonly confidence:
| "possible-port"
| "likely-equivalent-for-input"
| "requires-manual-review"
| "no-direct-equivalent";
readonly message: string;
}
export interface RegexComparisonResult {
readonly schemaVersion: 1;
readonly request: RegexComparisonRequest;
readonly startedAt: string;
readonly elapsedMs: number;
readonly sides: readonly [ComparisonSideOutcome, ComparisonSideOutcome];
readonly status:
"not-comparable" | "same-for-current-input" | "different-for-current-input";
readonly notComparable: readonly ComparisonNotComparableReason[];
readonly differences: readonly ComparisonDifference[];
readonly totalDifferences: number;
readonly differencesTruncated: boolean;
readonly matchAlignments: readonly MatchAlignment[];
readonly totalMatchAlignments: number;
readonly matchAlignmentsTruncated: boolean;
readonly notices: readonly ComparisonNotice[];
}