Compare commits

...
Author SHA1 Message Date
N0zoM1z0 561877ad76 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.
2026-10-10 16:10:02 +08:00
N0zoM1z0 022c8b6f53 chore(process): integrate literal normalization with current main 2026-10-10 15:45:26 +08:00
openhands 96318b66a0 fix(process): normalize scenario replacements literally
capture_process_scenario declares normalization patterns as literal text, but\nreplaceAll expands \$&\, \$\`, and \$1\ in the replacement as a replacement\npattern. A caller normalizing text that contains a dollar sign silently gets\nduplicated, spliced, or dropped content in the recorded Evidence instead of\nthe requested literal replacement.\n\nUse split/join, which is literal on both sides and is already the idiom used\nfor exact-literal scenario rules in browserScenarioNormalization.ts.
2026-10-10 11:36:10 +05:30
5 changed files with 133 additions and 2 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,18 +59,68 @@ 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 = scenario({
paths: false,
patterns: [
{ pattern: "8080", replacement: "$&$&" },
{ pattern: "READY", replacement: "$$" },
{ pattern: "NAME", replacement: "$1" },
],
});
expect(
normalizeProcessText(
"listening on 8080; READY; NAME=alpha; tail NAME",
declared,
"/temporary",
rootPid,
),
).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 -1
View File
@@ -34,8 +34,13 @@ export const normalizeProcessText = (
normalized = normalizePortTokens(normalized);
if (scenario.normalization.pids)
normalized = normalizePidTokens(normalized, [pid]);
// 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.replaceAll(pattern.pattern, 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) => {