fix(process)!: version literal scenario replacement semantics

Keep caller-selected replacement text literal with a callback, preserving
empty-search positions instead of changing their split/join behavior.
Commit the replacement interpretation for captures using custom patterns so
legacy records cannot report this producer correction as a target change.

BREAKING CHANGE: Captures with custom normalization patterns use
literal-replacements-v1 and cannot be compared with legacy unversioned
pattern captures. Capture both sides with the same replacement semantics;
legacy captures remain readable and captures without patterns are unaffected.
This commit is contained in:
N0zoM1z0
2026-10-10 16:57:06 +08:00
committed by Ysopwnable
parent f51503ef1c
commit 2b6f14f528
5 changed files with 121 additions and 17 deletions
+8
View File
@@ -119,6 +119,14 @@ selected normalization. Original admitted PTY chunks are available through
comparison contract, so a capture whose lines were padded to full width does not
compare as the same contract.
Custom `normalization.patterns` replace literal text with the caller's literal
replacement, including dollar sequences such as `$&` and `$$`. An empty pattern
also matches the first and last string positions. Captures with custom patterns
commit `pattern_normalization_version: "literal-replacements-v1"`. Legacy captures
remain readable. Cross-version comparison is rejected; capture both sides with
the same replacement semantics. Captures without custom patterns keep their
existing comparison contract.
Port normalization recognizes explicit `port`, `tcp_port`, `udp_port`, and
`listen` fields, URL authorities, IP endpoints, and `localhost` endpoints, with
ports from 0 through 65535. A port may end a sentence, as in `port: 8080.`;
+3
View File
@@ -108,6 +108,9 @@ export const processComparisonContract = (
...(scenario.normalization.ports
? { port_normalization_version: "contextual-endpoints-v2" }
: {}),
...(scenario.normalization.patterns.length > 0
? { pattern_normalization_version: "literal-replacements-v1" }
: {}),
rendered_line_format: "trailing-spaces-trimmed-v1",
});
@@ -59,31 +59,30 @@ const scenario = (normalization: {
readonly pids?: boolean;
readonly paths?: boolean;
readonly ports?: boolean;
readonly patterns?: readonly {
readonly pattern: string;
readonly replacement: string;
}[];
}) =>
parseProcessScenario({
executable: "/usr/bin/node",
working_directory: "/workspace",
normalization: {
paths: true,
...normalization,
time_bucket_ms: 10,
patterns: [{ pattern: "marker", replacement: "selected" }],
...normalization,
},
});
it("replaces caller-declared patterns literally, without expanding replacement patterns", () => {
const declared = parseProcessScenario({
executable: "/usr/bin/node",
working_directory: "/workspace",
normalization: {
paths: false,
time_bucket_ms: 10,
patterns: [
{ pattern: "8080", replacement: "$&$&" },
{ pattern: "READY", replacement: "$$" },
{ pattern: "NAME", replacement: "$1" },
],
},
const declared = scenario({
paths: false,
patterns: [
{ pattern: "8080", replacement: "$&$&" },
{ pattern: "READY", replacement: "$$" },
{ pattern: "NAME", replacement: "$1" },
],
});
expect(
normalizeProcessText(
@@ -95,6 +94,33 @@ it("replaces caller-declared patterns literally, without expanding replacement p
).toBe("listening on $&$&; $$; $1=alpha; tail $1");
});
it("preserves empty-pattern boundaries, including empty text", () => {
const declared = scenario({
patterns: [{ pattern: "", replacement: "$$" }],
});
expect(normalizeProcessText("ab", declared, "/temporary", rootPid)).toBe(
"$$a$$b$$",
);
expect(normalizeProcessText("", declared, "/temporary", rootPid)).toBe("$$");
});
it.each(["$`", "$'"])(
"retains the selected replacement %s verbatim",
(replacement) => {
const declared = scenario({
patterns: [{ pattern: "marker", replacement }],
});
expect(
normalizeProcessText(
"before marker after",
declared,
"/temporary",
rootPid,
),
).toBe(`before ${replacement} after`);
},
);
it("preserves compact JSON, counters, line numbers, and ambiguous endpoint spellings", () => {
const input = [
'{"pid":32,"size":108,"height":480,"width":640,"score":20}',
+6 -4
View File
@@ -34,11 +34,13 @@ export const normalizeProcessText = (
normalized = normalizePortTokens(normalized);
if (scenario.normalization.pids)
normalized = normalizePidTokens(normalized, [pid]);
// Scenario replacements are literal text, so `$&`, `` $` ``, and `$1` must
// reach the captured output verbatim instead of expanding as a replacement
// pattern. split/join is literal on both sides.
// A callback keeps replacement text literal while preserving replaceAll's
// empty-pattern boundaries, which the process scenario contract permits.
for (const pattern of scenario.normalization.patterns)
normalized = normalized.split(pattern.pattern).join(pattern.replacement);
normalized = normalized.replaceAll(
pattern.pattern,
() => pattern.replacement,
);
return normalized;
};
@@ -11,6 +11,7 @@ import {
parseProcessScenario,
} from "../../../src/domain/process/processScenario.js";
import { parseProcessCapture } from "../../../src/domain/process/processCaptureParsing.js";
import { compareProcessCaptures } from "../../../src/domain/process/processComparison.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { itWithCaptureCapability } from "./processCaptureCapability.js";
@@ -66,6 +67,9 @@ itWithCaptureCapability(
port_normalization_version: "contextual-endpoints-v2",
rendered_line_format: "trailing-spaces-trimmed-v1",
});
expect(capture.manifest.comparison_contract).not.toHaveProperty(
"pattern_normalization_version",
);
expect(capture.exit).toMatchObject({ code: 0, reason: "exited" });
expect(capture.cleanup).toMatchObject({
owned_process_group: "verified",
@@ -74,6 +78,67 @@ itWithCaptureCapability(
},
);
itWithCaptureCapability.each([
{ pattern: "AB", replacement: "$&$&", expected: "$&$&" },
{
pattern: "AB",
replacement: "$$ $` $' $1",
expected: "$$ $` $' $1",
},
{ pattern: "", replacement: "-", expected: "-A-B-" },
])(
"preserves literal replacement $replacement and rejects legacy comparison semantics",
async ({ pattern, replacement, expected }) => {
const root = await createTestTempDirectory("rea-process-literal-");
const scenario = parseProcessScenario({
executable: process.execPath,
arguments: ["-e", 'process.stdout.write("AB")'],
working_directory: root,
normalization: {
paths: false,
pids: false,
ports: false,
patterns: [{ pattern, replacement }],
},
settle_ms: 100,
});
const result = await captureProcessScenario(scenario);
if (!result.ok) throw result.error;
const capture = parseProcessCapture(
createProcessCaptureEvidence(scenario, result.value).normalized_result,
);
expect(capture.frames.map(({ data }) => data).join("")).toBe(expected);
expect(
capture.frames.map(({ raw_data, data }) => raw_data ?? data).join(""),
).toBe("AB");
expect(capture.cleanup).toMatchObject({
owned_process_group: "verified",
temporary_root: "removed",
});
expect(compareProcessCaptures(capture, capture).terminal).toBe("unchanged");
const legacyContract = { ...capture.manifest.comparison_contract };
delete legacyContract.pattern_normalization_version;
const legacy = parseProcessCapture({
...capture,
manifest: {
...capture.manifest,
comparison_contract: legacyContract,
comparison_contract_sha256: digestProcessCommitment(legacyContract),
},
});
expect(() => compareProcessCaptures(legacy, capture)).toThrow(
expect.objectContaining({
issues: [
expect.objectContaining({
expected: ["pattern_normalization_version"],
}),
],
}),
);
},
20_000,
);
itWithLinuxCaptureCapability.each([false, true])(
"honors pids=%s in owned Linux capture samples and committed Evidence",
async (pids) => {