mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
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:
@@ -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.`;
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user