feat: publish Regex Tools 0.2.0
This commit is contained in:
335
src/regex/comparison/ComparisonOrchestrator.test.ts
Normal file
335
src/regex/comparison/ComparisonOrchestrator.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
536
src/regex/comparison/ComparisonOrchestrator.ts
Normal file
536
src/regex/comparison/ComparisonOrchestrator.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
666
src/regex/comparison/compare-results.ts
Normal file
666
src/regex/comparison/compare-results.ts
Normal 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";
|
||||
}
|
||||
174
src/regex/comparison/comparison.types.ts
Normal file
174
src/regex/comparison/comparison.types.ts
Normal 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[];
|
||||
}
|
||||
Reference in New Issue
Block a user