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