diff --git a/tests/benchmarks/resolution/tracer/jvm-tracer.sh b/tests/benchmarks/resolution/tracer/jvm-tracer.sh index 563a5e75..8eeab852 100644 --- a/tests/benchmarks/resolution/tracer/jvm-tracer.sh +++ b/tests/benchmarks/resolution/tracer/jvm-tracer.sh @@ -142,17 +142,13 @@ case "$LANG" in # Match lines like: public void method(...) { # The first sed pass matches all method/constructor opening braces, # so a second pass is unnecessary (it would double-inject traceCall). - sedi -E '/\)\s*\{$/{ - /class |interface /!{ - a\ CallTracer.traceCall(); - } - }' "$javafile" + sedi_append_unless '/\)[[:space:]]*\{$/' '/class |interface /' \ + ' CallTracer.traceCall();' "$javafile" done # Add dump call at end of main - sedi '/public static void main/,/\}/ { - /^\s*\}/ i\ CallTracer.dump(); - }' "$TMP_DIR/Main.java" 2>/dev/null || true + sedi_insert_before_end '/public static void main/' '/\}/' '/^[[:space:]]*\}/' \ + ' CallTracer.dump();' "$TMP_DIR/Main.java" 2>/dev/null || true # Compile and run cd "$TMP_DIR" @@ -171,15 +167,13 @@ case "$LANG" in # Inject CallTracer.traceCall() into every function body for ktfile in "$TMP_DIR"/*.kt; do - sedi -E '/fun [a-zA-Z].*\{[[:space:]]*$/{ - /class |interface |object /!a\ CallTracer.traceCall(); - }' "$ktfile" + sedi_append_unless '/fun [a-zA-Z].*\{[[:space:]]*$/' '/class |interface |object /' \ + ' CallTracer.traceCall();' "$ktfile" done # Inject dump call before main's closing brace - sedi '/^fun main/,/^\}/ { - /^\}/ i\ CallTracer.dump() - }' "$TMP_DIR/Main.kt" + sedi_insert_before_end '/^fun main/' '/^\}/' '/^\}/' \ + ' CallTracer.dump()' "$TMP_DIR/Main.kt" # Suppress println to keep stdout clean for JSON for ktfile in "$TMP_DIR"/*.kt; do @@ -198,15 +192,13 @@ case "$LANG" in # Inject CallTracer.traceCall() into every def body for scfile in "$TMP_DIR"/*.scala; do base="$(basename "$scfile")" - sedi -E '/def [a-zA-Z].*\{[[:space:]]*$/{ - /class |trait |object .*extends/!a\ CallTracer.traceCall(); - }' "$scfile" + sedi_append_unless '/def [a-zA-Z].*\{[[:space:]]*$/' '/class |trait |object .*extends/' \ + ' CallTracer.traceCall();' "$scfile" done # Inject dump call before main's closing brace - sedi '/def main/,/^\s*\}/ { - /^\s*\}/ i\ CallTracer.dump() - }' "$TMP_DIR/Main.scala" + sedi_insert_before_end '/def main/' '/^[[:space:]]*\}/' '/^[[:space:]]*\}/' \ + ' CallTracer.dump()' "$TMP_DIR/Main.scala" # Suppress println to keep stdout clean for JSON for scfile in "$TMP_DIR"/*.scala; do @@ -231,15 +223,13 @@ case "$LANG" in # Inject CallTracer.traceCall() into every method body for grfile in "$TMP_DIR"/*.groovy; do - sedi -E '/\)\s*\{[[:space:]]*$/{ - /class |interface /!a\ CallTracer.traceCall(); - }' "$grfile" + sedi_append_unless '/\)[[:space:]]*\{[[:space:]]*$/' '/class |interface /' \ + ' CallTracer.traceCall();' "$grfile" done # Inject dump call before main's closing brace - sedi '/static void main/,/^\s*\}/ { - /^\s*\}/ i\ CallTracer.dump() - }' "$TMP_DIR/Main.groovy" + sedi_insert_before_end '/static void main/' '/^[[:space:]]*\}/' '/^[[:space:]]*\}/' \ + ' CallTracer.dump()' "$TMP_DIR/Main.groovy" # Suppress println to keep stdout clean for JSON for grfile in "$TMP_DIR"/*.groovy; do diff --git a/tests/benchmarks/resolution/tracer/native-tracer.sh b/tests/benchmarks/resolution/tracer/native-tracer.sh index 1546d31d..71d0014c 100644 --- a/tests/benchmarks/resolution/tracer/native-tracer.sh +++ b/tests/benchmarks/resolution/tracer/native-tracer.sh @@ -598,12 +598,12 @@ CSTRACE done # Inject Dump at end of Main - sedi '/public static void Main/,/^\s*\}/ { - /^\s*\}/ i\ CallTracer.Dump(); - }' "$TMP_DIR/Program.cs" 2>/dev/null || true + sedi_insert_before_end '/public static void Main/' '/^[[:space:]]*\}/' '/^[[:space:]]*\}/' \ + ' CallTracer.Dump();' "$TMP_DIR/Program.cs" 2>/dev/null || true # Also call RunWithValidation if it exists - sedi '/CallTracer.Dump/i\ RunWithValidation();' "$TMP_DIR/Program.cs" 2>/dev/null || true + sedi_insert_before '/CallTracer.Dump/' ' RunWithValidation();' \ + "$TMP_DIR/Program.cs" 2>/dev/null || true # Redirect Console.WriteLine in fixture code to stderr for csfile in "$TMP_DIR"/*.cs; do @@ -702,9 +702,8 @@ SWTRACE # Inject dump at end of main (top-level code or main function) if grep -q 'func main' "$TMP_DIR/main.swift" 2>/dev/null; then - sedi '/^func main/,/^\}/ { - /^\}/ i\ CallTracer.shared.dump() - }' "$TMP_DIR/main.swift" + sedi_insert_before_end '/^func main/' '/^\}/' '/^\}/' \ + ' CallTracer.shared.dump()' "$TMP_DIR/main.swift" else echo 'CallTracer.shared.dump()' >> "$TMP_DIR/main.swift" fi @@ -800,9 +799,8 @@ DARTTRACE ' } finally { CallTracer.instance.traceReturn(); }' # Inject dump at end of main - sedi '/^void main/,/^\}/ { - /^\}/ i\ CallTracer.instance.dump(); - }' "$TMP_DIR/main.dart" 2>/dev/null || true + sedi_insert_before_end '/^void main/' '/^\}/' '/^\}/' \ + ' CallTracer.instance.dump();' "$TMP_DIR/main.dart" 2>/dev/null || true # Redirect print to stderr in fixture files (trace_support.dart excluded # because its dump() must write JSON to stdout; main.dart is NOT excluded @@ -912,9 +910,8 @@ ZIGTRACE ' trace_support.traceCall("%s", "%s"); defer trace_support.traceReturn();' # Inject dump at end of main - sedi '/^pub fn main/,/^\}/ { - /^\}/ i\ trace_support.dumpTrace(); - }' "$TMP_DIR/main.zig" 2>/dev/null || true + sedi_insert_before_end '/^pub fn main/' '/^\}/' '/^\}/' \ + ' trace_support.dumpTrace();' "$TMP_DIR/main.zig" 2>/dev/null || true cd "$TMP_DIR" if zig build-exe main.zig 2>/dev/null; then diff --git a/tests/benchmarks/resolution/tracer/tracer-common.sh b/tests/benchmarks/resolution/tracer/tracer-common.sh index e2a4001d..44f47f3b 100644 --- a/tests/benchmarks/resolution/tracer/tracer-common.sh +++ b/tests/benchmarks/resolution/tracer/tracer-common.sh @@ -16,3 +16,42 @@ sedi() { sed -i '' "$@" fi } + +# GNU sed accepts the single-line "i\text" / "a\text" shortcut for its insert +# and append commands; BSD sed (macOS) rejects it and requires the text on +# the line *after* the backslash (see #1759). The three helpers below build +# that portable multi-line form internally so call sites never hand-roll the +# GNU-only shortcut (see #1913). + +# Insert TEXT on the line immediately before the first line matching PATTERN. +sedi_insert_before() { + local pattern="$1" text="$2" file="$3" + sedi "${pattern} i\\ +${text}" "$file" +} + +# Insert TEXT immediately before the first line matching INSERT_PATTERN, +# scoped to the START_PATTERN,END_PATTERN address range. Used to splice a +# dump()-style call just before a function's closing brace without touching +# unrelated braces elsewhere in the file. INSERT_PATTERN is taken separately +# from END_PATTERN because some callers close the range on a looser match +# (e.g. any line containing `}`) than the line they actually inject before. +sedi_insert_before_end() { + local start_pattern="$1" end_pattern="$2" insert_pattern="$3" text="$4" file="$5" + sedi "${start_pattern},${end_pattern} { +${insert_pattern} i\\ +${text} +}" "$file" +} + +# Append TEXT after every line matching OUTER_PATTERN, except lines that also +# match NEGATE_PATTERN (used to skip class/interface/object declaration lines +# that share the same brace-opening shape as method signatures). Runs with +# extended regex (-E) since every current caller relies on ERE alternation. +sedi_append_unless() { + local outer_pattern="$1" negate_pattern="$2" text="$3" file="$4" + sedi -E "${outer_pattern}{ +${negate_pattern}!a\\ +${text} +}" "$file" +} diff --git a/tests/benchmarks/resolution/tracer/tracer-common.test.ts b/tests/benchmarks/resolution/tracer/tracer-common.test.ts new file mode 100644 index 00000000..5c8e4f01 --- /dev/null +++ b/tests/benchmarks/resolution/tracer/tracer-common.test.ts @@ -0,0 +1,159 @@ +/** + * Regression coverage for #1913: the sed-injection helpers in + * tracer-common.sh must produce output that is byte-identical on GNU sed + * (Linux CI) and BSD sed (macOS dev boxes), which — unlike GNU — rejects the + * single-line `a\text` / `i\text` shortcut and requires the text on the line + * after the backslash. + * + * These tests exercise the shared helpers directly with bash + sed (whatever + * sed the host provides), independent of any per-language compiler toolchain + * (javac/kotlinc/scalac/groovyc/dotnet/dart/zig), so they run identically in + * every environment regardless of which toolchains happen to be installed. + */ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const TRACER_DIR = __dirname; +const TRACER_COMMON = path.join(TRACER_DIR, 'tracer-common.sh'); + +function runHelper(fnCall: string, file: string): string { + execFileSync('bash', ['-c', `source "${TRACER_COMMON}"; ${fnCall}`]); + return fs.readFileSync(file, 'utf8'); +} + +describe('tracer-common.sh sed-injection helpers (#1913)', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tracer-common-test-'))); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('sedi_insert_before inserts text directly before the matching line (native-tracer.sh csharp RunWithValidation site)', () => { + const file = path.join(tmpDir, 'Program.cs'); + fs.writeFileSync( + file, + [ + 'class Program {', + ' public static void Main() {', + ' CallTracer.Dump();', + ' }', + '}', + '', + ].join('\n'), + ); + + const out = runHelper( + `sedi_insert_before '/CallTracer.Dump/' ' RunWithValidation();' "${file}"`, + file, + ); + + expect(out).toContain(' RunWithValidation();\n CallTracer.Dump();'); + }); + + it('sedi_insert_before_end inserts before the matching brace within a range (jvm/native dump-injection sites)', () => { + const file = path.join(tmpDir, 'main.dart'); + fs.writeFileSync(file, ['void main() {', ' print(1);', '}', ''].join('\n')); + + const out = runHelper( + `sedi_insert_before_end '/^void main/' '/^\\}/' '/^\\}/' ' CallTracer.instance.dump();' "${file}"`, + file, + ); + + expect(out.split('\n')).toEqual([ + 'void main() {', + ' print(1);', + ' CallTracer.instance.dump();', + '}', + '', + ]); + }); + + it('sedi_insert_before_end honors distinct range-end and insert-line patterns (jvm-tracer.sh java dump site)', () => { + // java's dump site scopes the range with a loose /\}/ end-address but + // only inserts before a line that is *purely* a closing brace, so the + // range and insertion patterns must be tracked independently. + const file = path.join(tmpDir, 'Main.java'); + fs.writeFileSync( + file, + [ + 'public class Main {', + ' public static void main(String[] args) {', + ' foo();', + ' }', + '}', + '', + ].join('\n'), + ); + + const out = runHelper( + `sedi_insert_before_end '/public static void main/' '/\\}/' '/^[[:space:]]*\\}/' ' CallTracer.dump();' "${file}"`, + file, + ); + + expect(out).toContain(' foo();\n CallTracer.dump();\n }\n}'); + }); + + it('sedi_append_unless appends after matching lines except negated ones, including a space before the brace (jvm-tracer.sh java/groovy per-method sites)', () => { + const file = path.join(tmpDir, 'BaseService.java'); + fs.writeFileSync( + file, + [ + 'public abstract class BaseService {', + '', + ' protected void log(String message) {', + ' System.out.println(message);', + ' }', + '}', + '', + ].join('\n'), + ); + + const out = runHelper( + `sedi_append_unless '/\\)[[:space:]]*\\{$/' '/class |interface /' ' CallTracer.traceCall();' "${file}"`, + file, + ); + + // The method signature line (ending in ") {") gets the call appended... + expect(out).toContain( + ' protected void log(String message) {\n CallTracer.traceCall();', + ); + // ...but the class declaration line (also ending in "{") is excluded. + expect(out.split('\n')[0]).toBe('public abstract class BaseService {'); + }); + + it('rejects a regression back to the GNU-only single-line a\\/i\\ shortcut in the tracer scripts', () => { + // Every dump()/traceCall() injection now goes through the shared helpers + // above, so the raw sed scripts embedded directly in the language + // tracers should contain zero remaining single-line "i\text"/"a\text" + // occurrences (the exact defect #1913 fixed) — this guards against + // future injection sites reintroducing the GNU-only shortcut. + const guardedFiles = ['jvm-tracer.sh', 'native-tracer.sh', 'go-tracer.sh']; + + for (const name of guardedFiles) { + const code = fs + .readFileSync(path.join(TRACER_DIR, name), 'utf8') + .split('\n') + // Drop comment lines so prose that *describes* the banned form (as + // this very fix's changelog comments do) isn't mistaken for it. + .filter((line) => !line.trim().startsWith('#')) + .join('\n'); + // \S excludes the newline that must immediately follow a portable + // "i\" / "a\" — so this only matches the banned single-line form + // where real text follows the backslash on the same line. + const offender = code.match(/[ai]\\\S/); + expect( + offender, + `${name} contains a GNU-only single-line a\\/i\\ form: ${offender}`, + ).toBeNull(); + } + }); +});