From 2b6f14f528297cf12c776c960af0859f5cfbefbf Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:10:02 +0800 Subject: [PATCH] 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. --- docs/process-capture.md | 8 +++ src/domain/process/processScenario.ts | 3 + .../capture/ProcessNormalization.test.ts | 52 +++++++++++---- src/process/capture/ProcessNormalization.ts | 10 +-- .../processCaptureNormalization.test.ts | 65 +++++++++++++++++++ 5 files changed, 121 insertions(+), 17 deletions(-) diff --git a/docs/process-capture.md b/docs/process-capture.md index 5cc246ec2..74f1ba35a 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -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.`; diff --git a/src/domain/process/processScenario.ts b/src/domain/process/processScenario.ts index 0ecf2c259..ff9197d15 100644 --- a/src/domain/process/processScenario.ts +++ b/src/domain/process/processScenario.ts @@ -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", }); diff --git a/src/process/capture/ProcessNormalization.test.ts b/src/process/capture/ProcessNormalization.test.ts index 15f94403a..9c9ba1951 100644 --- a/src/process/capture/ProcessNormalization.test.ts +++ b/src/process/capture/ProcessNormalization.test.ts @@ -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}', diff --git a/src/process/capture/ProcessNormalization.ts b/src/process/capture/ProcessNormalization.ts index f2aa88ded..9364ec95b 100644 --- a/src/process/capture/ProcessNormalization.ts +++ b/src/process/capture/ProcessNormalization.ts @@ -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; }; diff --git a/tests/boundary/process/processCaptureNormalization.test.ts b/tests/boundary/process/processCaptureNormalization.test.ts index 8156aff97..965dc2af5 100644 --- a/tests/boundary/process/processCaptureNormalization.test.ts +++ b/tests/boundary/process/processCaptureNormalization.test.ts @@ -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) => {