From db98a51dd1750b09b3905e18efac526f7ebf90f7 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:06:41 +0000 Subject: [PATCH 1/3] fix(process): normalize ports that end a sentence Contextual port normalization refused any port followed by a period, so common log lines such as "Listening on port: 8080." or "http://localhost:8080." kept their run-specific port and equivalent captures compared as changed. Accept a sentence-ending period while still refusing a period that continues a decimal, version, or host name, and bump the committed port-normalization version. Co-Authored-By: Claude Opus 5.5 --- docs/process-capture.md | 6 ++++-- src/domain/process/processScenario.ts | 2 +- .../capture/ProcessNormalization.test.ts | 19 +++++++++++++++++++ src/process/capture/ProcessNormalization.ts | 6 ++++-- .../processCaptureNormalization.test.ts | 2 +- 5 files changed, 29 insertions(+), 6 deletions(-) diff --git a/docs/process-capture.md b/docs/process-capture.md index d93a968f3..69a39c64f 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -112,8 +112,10 @@ same admitted PTY chunks rather than a second unbounded stream. 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. It preserves ordinary counters, dimensions, file -line numbers, and ambiguous bare host labels. Use explicit literal `patterns` +ports from 0 through 65535. A port may end a sentence, as in `port: 8080.`; +a period followed by a letter or digit continues a decimal, version, or host +name instead. It preserves ordinary counters, dimensions, file line numbers, +and ambiguous bare host labels. Use explicit literal `patterns` for a different application-specific spelling. Set `ports: false` to preserve all endpoint numbers in comparison text. Captures commit the port-normalization version so older broad numeric normalization cannot silently compare as the diff --git a/src/domain/process/processScenario.ts b/src/domain/process/processScenario.ts index 08672cdaf..a23e2bd4e 100644 --- a/src/domain/process/processScenario.ts +++ b/src/domain/process/processScenario.ts @@ -85,7 +85,7 @@ export const processComparisonContract = ( limits: scenario.limits, normalization: scenario.normalization, ...(scenario.normalization.ports - ? { port_normalization_version: "contextual-endpoints-v1" } + ? { port_normalization_version: "contextual-endpoints-v2" } : {}), }); diff --git a/src/process/capture/ProcessNormalization.test.ts b/src/process/capture/ProcessNormalization.test.ts index c28d1e42d..b07a228a3 100644 --- a/src/process/capture/ProcessNormalization.test.ts +++ b/src/process/capture/ProcessNormalization.test.ts @@ -99,6 +99,25 @@ it("normalizes only contextual ports before overlapping PID values and honors di ).toBe(input); }); +it("normalizes ports that end a sentence but not decimal or host continuations", () => { + const input = [ + "Listening on port: 8080.", + "Server at http://localhost:8080. Ready", + "Bound [::1]:3000.", + "listen=9000.\n", + "port: 80.5 localhost:8080.5 localhost:8080.example.test", + ].join("\n"); + expect(normalizeProcessText(input, scenario({}), "/temporary", rootPid)).toBe( + [ + "Listening on port: .", + "Server at http://localhost:. Ready", + "Bound [::1]:.", + "listen=.\n", + "port: 80.5 localhost:8080.5 localhost:8080.example.test", + ].join("\n"), + ); +}); + it("normalizes representative process identities and preserves null, zero, and unrelated numbers", () => { const before = JSON.stringify(samples); const normalized = normalizeProcessSamples(samples, scenario({}), rootPid); diff --git a/src/process/capture/ProcessNormalization.ts b/src/process/capture/ProcessNormalization.ts index d9dd68385..27c7d1f49 100644 --- a/src/process/capture/ProcessNormalization.ts +++ b/src/process/capture/ProcessNormalization.ts @@ -41,9 +41,11 @@ export const normalizeProcessText = ( return normalized; }; +// A port may end a sentence, but a period followed by a word character +// continues a decimal, version, or host name and is not a port boundary. const normalizePortTokens = (value: string): string => { const endpointNormalized = value.replaceAll( - /(\b[a-z][a-z0-9+.-]*:\/\/[^\s/?#"'<>]+|\blocalhost|\b(?:\d{1,3}\.){3}\d{1,3}|\[[\da-f:.%]+\]):(\d{1,5})(?![\w.@:])/giu, + /(\b[a-z][a-z0-9+.-]*:\/\/[^\s/?#"'<>]+|\blocalhost|\b(?:\d{1,3}\.){3}\d{1,3}|\[[\da-f:.%]+\]):(\d{1,5})(?![\w@:]|\.\w)/giu, (match: string, endpoint: string, port: string) => { if (Number(port) > 65_535) return match; const isEndpoint = endpoint.includes("://") @@ -54,7 +56,7 @@ const normalizePortTokens = (value: string): string => { }, ); return endpointNormalized.replaceAll( - /(\b(?:port|tcp_port|udp_port|listen)\b["']?\s*[:=]\s*)(\d{1,5})(?![\w.])/giu, + /(\b(?:port|tcp_port|udp_port|listen)\b["']?\s*[:=]\s*)(\d{1,5})(?!\w|\.\w)/giu, (match: string, prefix: string, port: string) => Number(port) <= 65_535 ? `${prefix}` : match, ); diff --git a/tests/boundary/process/processCaptureNormalization.test.ts b/tests/boundary/process/processCaptureNormalization.test.ts index 9006cd41e..697a06f63 100644 --- a/tests/boundary/process/processCaptureNormalization.test.ts +++ b/tests/boundary/process/processCaptureNormalization.test.ts @@ -63,7 +63,7 @@ itWithCaptureCapability( ), ).toBe(true); expect(capture.manifest.comparison_contract).toMatchObject({ - port_normalization_version: "contextual-endpoints-v1", + port_normalization_version: "contextual-endpoints-v2", }); expect(capture.exit).toMatchObject({ code: 0, reason: "exited" }); expect(capture.cleanup).toMatchObject({ From 184ed970b9021552a5454ced20d202d10cabcbb8 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:19:37 +0000 Subject: [PATCH 2/3] fix(process): keep ports before a chunk-final period PTY chunks are normalized independently. A period that ends a chunk may begin a decimal continued in the next chunk ("port: 80." then "5"), so leave such a port unnormalized instead of masking the decimal. Co-Authored-By: Claude Opus 5.5 --- docs/process-capture.md | 3 ++- src/process/capture/ProcessNormalization.test.ts | 7 +++++++ src/process/capture/ProcessNormalization.ts | 6 ++++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/process-capture.md b/docs/process-capture.md index 69a39c64f..24cdac571 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -114,7 +114,8 @@ 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.`; a period followed by a letter or digit continues a decimal, version, or host -name instead. It preserves ordinary counters, dimensions, file line numbers, +name instead. Each PTY chunk is normalized on its own, so a port whose period +ends the chunk is left unchanged because its continuation is not yet known. It preserves ordinary counters, dimensions, file line numbers, and ambiguous bare host labels. Use explicit literal `patterns` for a different application-specific spelling. Set `ports: false` to preserve all endpoint numbers in comparison text. Captures commit the port-normalization diff --git a/src/process/capture/ProcessNormalization.test.ts b/src/process/capture/ProcessNormalization.test.ts index b07a228a3..006d8bde4 100644 --- a/src/process/capture/ProcessNormalization.test.ts +++ b/src/process/capture/ProcessNormalization.test.ts @@ -118,6 +118,13 @@ it("normalizes ports that end a sentence but not decimal or host continuations", ); }); +it("keeps a port before a chunk-final period, whose continuation is unknown", () => { + for (const chunk of ["port: 80.", "http://localhost:8080.", "[::1]:3000."]) + expect( + normalizeProcessText(chunk, scenario({}), "/temporary", rootPid), + ).toBe(chunk); +}); + it("normalizes representative process identities and preserves null, zero, and unrelated numbers", () => { const before = JSON.stringify(samples); const normalized = normalizeProcessSamples(samples, scenario({}), rootPid); diff --git a/src/process/capture/ProcessNormalization.ts b/src/process/capture/ProcessNormalization.ts index 27c7d1f49..8423b5dd1 100644 --- a/src/process/capture/ProcessNormalization.ts +++ b/src/process/capture/ProcessNormalization.ts @@ -43,9 +43,11 @@ export const normalizeProcessText = ( // A port may end a sentence, but a period followed by a word character // continues a decimal, version, or host name and is not a port boundary. +// PTY chunks are normalized independently, so a period that ends the chunk +// stays unnormalized: its continuation may arrive in the next chunk. const normalizePortTokens = (value: string): string => { const endpointNormalized = value.replaceAll( - /(\b[a-z][a-z0-9+.-]*:\/\/[^\s/?#"'<>]+|\blocalhost|\b(?:\d{1,3}\.){3}\d{1,3}|\[[\da-f:.%]+\]):(\d{1,5})(?![\w@:]|\.\w)/giu, + /(\b[a-z][a-z0-9+.-]*:\/\/[^\s/?#"'<>]+|\blocalhost|\b(?:\d{1,3}\.){3}\d{1,3}|\[[\da-f:.%]+\]):(\d{1,5})(?![\w@:]|\.(?:\w|$))/giu, (match: string, endpoint: string, port: string) => { if (Number(port) > 65_535) return match; const isEndpoint = endpoint.includes("://") @@ -56,7 +58,7 @@ const normalizePortTokens = (value: string): string => { }, ); return endpointNormalized.replaceAll( - /(\b(?:port|tcp_port|udp_port|listen)\b["']?\s*[:=]\s*)(\d{1,5})(?!\w|\.\w)/giu, + /(\b(?:port|tcp_port|udp_port|listen)\b["']?\s*[:=]\s*)(\d{1,5})(?!\w|\.(?:\w|$))/giu, (match: string, prefix: string, port: string) => Number(port) <= 65_535 ? `${prefix}` : match, ); From ad813e473b094ab0d3ab30798b7d252e2f4beb94 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:28:19 +0000 Subject: [PATCH 3/3] fix(process): treat non-ASCII letters as port continuations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit \w is ASCII-only even with the u flag, so "port: 8080.β" was treated as a sentence-ending port. Use Unicode letter and number classes for the port boundary checks. Co-Authored-By: Claude Opus 5.5 --- src/process/capture/ProcessNormalization.test.ts | 2 ++ src/process/capture/ProcessNormalization.ts | 9 +++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/process/capture/ProcessNormalization.test.ts b/src/process/capture/ProcessNormalization.test.ts index 006d8bde4..8598a91f1 100644 --- a/src/process/capture/ProcessNormalization.test.ts +++ b/src/process/capture/ProcessNormalization.test.ts @@ -106,6 +106,7 @@ it("normalizes ports that end a sentence but not decimal or host continuations", "Bound [::1]:3000.", "listen=9000.\n", "port: 80.5 localhost:8080.5 localhost:8080.example.test", + "port: 8080.β localhost:8080.é port: 8080é", ].join("\n"); expect(normalizeProcessText(input, scenario({}), "/temporary", rootPid)).toBe( [ @@ -114,6 +115,7 @@ it("normalizes ports that end a sentence but not decimal or host continuations", "Bound [::1]:.", "listen=.\n", "port: 80.5 localhost:8080.5 localhost:8080.example.test", + "port: 8080.β localhost:8080.é port: 8080é", ].join("\n"), ); }); diff --git a/src/process/capture/ProcessNormalization.ts b/src/process/capture/ProcessNormalization.ts index 8423b5dd1..675ac9529 100644 --- a/src/process/capture/ProcessNormalization.ts +++ b/src/process/capture/ProcessNormalization.ts @@ -41,13 +41,14 @@ export const normalizeProcessText = ( return normalized; }; -// A port may end a sentence, but a period followed by a word character -// continues a decimal, version, or host name and is not a port boundary. +// A port may end a sentence, but a period followed by a letter, digit, or +// underscore in any script continues a decimal, version, or host name and is +// not a port boundary. // PTY chunks are normalized independently, so a period that ends the chunk // stays unnormalized: its continuation may arrive in the next chunk. const normalizePortTokens = (value: string): string => { const endpointNormalized = value.replaceAll( - /(\b[a-z][a-z0-9+.-]*:\/\/[^\s/?#"'<>]+|\blocalhost|\b(?:\d{1,3}\.){3}\d{1,3}|\[[\da-f:.%]+\]):(\d{1,5})(?![\w@:]|\.(?:\w|$))/giu, + /(\b[a-z][a-z0-9+.-]*:\/\/[^\s/?#"'<>]+|\blocalhost|\b(?:\d{1,3}\.){3}\d{1,3}|\[[\da-f:.%]+\]):(\d{1,5})(?![\p{L}\p{N}_@:]|\.(?:[\p{L}\p{N}_]|$))/giu, (match: string, endpoint: string, port: string) => { if (Number(port) > 65_535) return match; const isEndpoint = endpoint.includes("://") @@ -58,7 +59,7 @@ const normalizePortTokens = (value: string): string => { }, ); return endpointNormalized.replaceAll( - /(\b(?:port|tcp_port|udp_port|listen)\b["']?\s*[:=]\s*)(\d{1,5})(?!\w|\.(?:\w|$))/giu, + /(\b(?:port|tcp_port|udp_port|listen)\b["']?\s*[:=]\s*)(\d{1,5})(?![\p{L}\p{N}_]|\.(?:[\p{L}\p{N}_]|$))/giu, (match: string, prefix: string, port: string) => Number(port) <= 65_535 ? `${prefix}` : match, );