#!/usr/bin/env node import fs from "node:fs/promises"; import path from "node:path"; import ts from "typescript"; import { z } from "zod"; import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { collectTypeScriptFilesFromRoots, isTestLikeTypeScriptFile, resolveSourceRoots, runAsScript, toLine, unwrapExpression, } from "./lib/ts-guard-utils.mts"; export type ExportNameCollision = { name: string; files: string[]; sdk?: true; }; export type SourceModule = { content: string; includeDefinitions?: boolean; path: string; }; export type ImportedSymbolReference = { importedName: string; localName: string; moduleSpecifier: string; }; export type NamedReExport = { exportedName: string; importedName: string; moduleSpecifier: string; }; export type AliasingReExport = NamedReExport & { line: number; path: string; }; export type ExportedValueDefinition = { importedReferences: ImportedSymbolReference[]; name: string; }; export type ModuleExports = { aliasingReExports: Array>; definitions: Set; exportedNames: Set; namedReExports: NamedReExport[]; starExportSpecifiers: string[]; valueDefinitions: Map; }; const exportNameCollisionSchema = z .object({ name: z.string(), files: z.array(z.string()), sdk: z.literal(true).optional(), }) .strict(); const exportNameCollisionBaselineSchema = z.array(exportNameCollisionSchema); const baselineRelativePath = "scripts/lib/export-name-collision-baseline.json"; const baselineRegenCommand = "pnpm lint:tmp:export-name-collisions:gen"; const failurePrefix = "check-export-name-collisions"; const extraExcludedFileSuffixes = [".test-support.ts", ".test-helpers.ts", ".d.ts"]; function normalizeRelativePath(filePath: string) { return filePath.replaceAll(path.sep, "/"); } export function isExcludedExportCollisionSource(filePath: string) { const normalized = normalizeRelativePath(filePath); const segments = normalized.split("/"); return ( segments.includes("test") || segments.includes("__fixtures__") || isTestLikeTypeScriptFile(normalized, extraExcludedFileSuffixes) ); } function hasModifier(node: ts.Node, kind: ts.SyntaxKind) { return ts.canHaveModifiers(node) && ts.getModifiers(node)?.some((item) => item.kind === kind); } function collectBindingNames(name: ts.BindingName, names: Set) { if (ts.isIdentifier(name)) { names.add(name.text); return; } for (const element of name.elements) { if (ts.isBindingElement(element)) { collectBindingNames(element.name, names); } } } function resolveImportedReference( expression: ts.Expression, importedSymbolsByLocalName: ReadonlyMap, namespaceImportsByLocalName: ReadonlyMap, ) { const target = unwrapExpression(expression); if (ts.isIdentifier(target)) { return importedSymbolsByLocalName.get(target.text); } if (!ts.isPropertyAccessExpression(target) && !ts.isElementAccessExpression(target)) { return undefined; } const namespaceName = ts.isPropertyAccessExpression(target) ? target.name.text : ts.isElementAccessExpression(target) && target.argumentExpression && ts.isStringLiteral(target.argumentExpression) ? target.argumentExpression.text : null; if (!namespaceName || !ts.isIdentifier(target.expression)) { return undefined; } const moduleSpecifier = namespaceImportsByLocalName.get(target.expression.text); return moduleSpecifier ? { importedName: namespaceName, localName: `${target.expression.text}.${namespaceName}`, moduleSpecifier, } : undefined; } function collectImportedReferences( node: ts.Node, importedSymbolsByLocalName: ReadonlyMap, namespaceImportsByLocalName: ReadonlyMap, ) { const references = new Map(); const addReference = (reference: ImportedSymbolReference | undefined) => { if (reference) { references.set( `${reference.moduleSpecifier}\0${reference.importedName}\0${reference.localName}`, reference, ); } }; const visit = (current: ts.Node): void => { if (ts.isCallExpression(current)) { addReference( resolveImportedReference( current.expression, importedSymbolsByLocalName, namespaceImportsByLocalName, ), ); } ts.forEachChild(current, visit); }; visit(node); return [...references.values()].toSorted((left, right) => `${left.moduleSpecifier}\0${left.importedName}\0${left.localName}`.localeCompare( `${right.moduleSpecifier}\0${right.importedName}\0${right.localName}`, ), ); } function parametersAreForwarded( parameters: ts.NodeArray, args: ts.NodeArray, ) { if (parameters.length !== args.length) { return false; } return parameters.every((parameter, index) => { if (!ts.isIdentifier(parameter.name)) { return false; } const argument = args[index]; if (!argument) { return false; } if (parameter.dotDotDotToken) { if (!ts.isSpreadElement(argument)) { return false; } const forwarded = unwrapExpression(argument.expression); return ts.isIdentifier(forwarded) && forwarded.text === parameter.name.text; } if (ts.isSpreadElement(argument)) { return false; } const forwarded = unwrapExpression(argument); return ts.isIdentifier(forwarded) && forwarded.text === parameter.name.text; }); } function isAwaitedZeroArgumentCall(expression: ts.Expression) { const unwrapped = unwrapExpression(expression); if (!ts.isAwaitExpression(unwrapped)) { return false; } const awaited = unwrapExpression(unwrapped.expression); return ts.isCallExpression(awaited) && awaited.arguments.length === 0; } function returnCall(statement: ts.Statement) { if (!ts.isReturnStatement(statement) || !statement.expression) { return null; } const expression = unwrapExpression(statement.expression); return ts.isCallExpression(expression) ? expression : null; } function isStaticImportForwarder( call: ts.CallExpression, functionName: string, importedNamesByLocalName: ReadonlyMap, ) { const callee = unwrapExpression(call.expression); return ( ts.isIdentifier(callee) && callee.text !== functionName && importedNamesByLocalName.get(callee.text) === functionName ); } function isLazyModuleForwarderCall( call: ts.CallExpression, functionName: string, moduleObjectName?: string, ) { const callee = unwrapExpression(call.expression); if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== functionName) { return false; } const target = unwrapExpression(callee.expression); if (moduleObjectName) { return ts.isIdentifier(target) && target.text === moduleObjectName; } return isAwaitedZeroArgumentCall(target); } function isForwardingOnlyFunction( declaration: ts.FunctionDeclaration | ts.ArrowFunction, functionName: string, importedNamesByLocalName: ReadonlyMap, ) { const body = declaration.body; if (!body) { return false; } if (!ts.isBlock(body)) { const expression = unwrapExpression(body); return ( ts.isCallExpression(expression) && parametersAreForwarded(declaration.parameters, expression.arguments) && (isStaticImportForwarder(expression, functionName, importedNamesByLocalName) || isLazyModuleForwarderCall(expression, functionName)) ); } if (body.statements.length === 1) { const statement = body.statements[0]; if (!statement) { return false; } const call = returnCall(statement); return Boolean( call && parametersAreForwarded(declaration.parameters, call.arguments) && (isStaticImportForwarder(call, functionName, importedNamesByLocalName) || isLazyModuleForwarderCall(call, functionName)), ); } if (body.statements.length !== 2) { return false; } const loadStatement = body.statements[0]; const returnStatement = body.statements[1]; if ( !loadStatement || !returnStatement || !ts.isVariableStatement(loadStatement) || !(loadStatement.declarationList.flags & ts.NodeFlags.Const) || loadStatement.declarationList.declarations.length !== 1 ) { return false; } const declarationItem = loadStatement.declarationList.declarations[0]; if ( !declarationItem || !ts.isIdentifier(declarationItem.name) || !declarationItem.initializer || !isAwaitedZeroArgumentCall(declarationItem.initializer) ) { return false; } const call = returnCall(returnStatement); return Boolean( call && parametersAreForwarded(declaration.parameters, call.arguments) && isLazyModuleForwarderCall(call, functionName, declarationItem.name.text), ); } function isForwardingOnlyConst( declaration: ts.VariableDeclaration, exportName: string, importedNamesByLocalName: ReadonlyMap, ) { if (!ts.isIdentifier(declaration.name) || !declaration.initializer) { return false; } const initializer = unwrapExpression(declaration.initializer); if (ts.isIdentifier(initializer)) { return importedNamesByLocalName.get(initializer.text) === exportName; } return ( ts.isArrowFunction(initializer) && isForwardingOnlyFunction(initializer, exportName, importedNamesByLocalName) ); } /** Collects value exports and locally defined exported functions/consts from one module. */ export function collectModuleExportNames(content: string, fileName = "source.ts"): ModuleExports { const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); const importedNamesByLocalName = new Map(); const importedSymbolsByLocalName = new Map(); const namespaceImportsByLocalName = new Map(); const localConstDeclarations = new Map(); const localFunctions = new Map(); const directlyExportedNames = new Set(); const locallyExportedNames = new Set(); const exportedNames = new Set(); const aliasingReExports: Array> = []; const namedReExports: NamedReExport[] = []; const pendingLocalReExports: Array<{ exportedName: string; localName: string }> = []; const starExportSpecifiers: string[] = []; for (const statement of sourceFile.statements) { if (ts.isImportDeclaration(statement)) { const bindings = statement.importClause?.namedBindings; if (bindings && ts.isNamedImports(bindings)) { for (const specifier of bindings.elements) { if (!statement.importClause?.isTypeOnly && !specifier.isTypeOnly) { const importedName = specifier.propertyName?.text ?? specifier.name.text; const moduleSpecifier = ts.isStringLiteral(statement.moduleSpecifier) ? statement.moduleSpecifier.text : ""; importedNamesByLocalName.set(specifier.name.text, importedName); importedSymbolsByLocalName.set(specifier.name.text, { importedName, localName: specifier.name.text, moduleSpecifier, }); } } } else if ( bindings && ts.isNamespaceImport(bindings) && !statement.importClause?.isTypeOnly && ts.isStringLiteral(statement.moduleSpecifier) ) { namespaceImportsByLocalName.set(bindings.name.text, statement.moduleSpecifier.text); } continue; } if ( ts.isImportEqualsDeclaration(statement) && !statement.isTypeOnly && ts.isExternalModuleReference(statement.moduleReference) && statement.moduleReference.expression && ts.isStringLiteral(statement.moduleReference.expression) ) { namespaceImportsByLocalName.set( statement.name.text, statement.moduleReference.expression.text, ); continue; } if (ts.isFunctionDeclaration(statement) && statement.name) { const name = statement.name.text; const declarations = localFunctions.get(name) ?? []; declarations.push(statement); localFunctions.set(name, declarations); if ( hasModifier(statement, ts.SyntaxKind.ExportKeyword) && !hasModifier(statement, ts.SyntaxKind.DefaultKeyword) ) { directlyExportedNames.add(name); exportedNames.add(name); } continue; } if (ts.isVariableStatement(statement)) { const isConst = Boolean(statement.declarationList.flags & ts.NodeFlags.Const); if (!isConst) { continue; } const statementNames = new Set(); for (const declaration of statement.declarationList.declarations) { const declarationNames = new Set(); collectBindingNames(declaration.name, declarationNames); for (const name of declarationNames) { statementNames.add(name); const declarations = localConstDeclarations.get(name) ?? []; declarations.push(declaration); localConstDeclarations.set(name, declarations); } } for (const name of statementNames) { if (hasModifier(statement, ts.SyntaxKind.ExportKeyword)) { directlyExportedNames.add(name); exportedNames.add(name); } } continue; } const moduleSpecifier = ts.isExportDeclaration(statement) ? statement.moduleSpecifier : undefined; if ( ts.isExportDeclaration(statement) && !statement.exportClause && moduleSpecifier && ts.isStringLiteral(moduleSpecifier) ) { starExportSpecifiers.push(moduleSpecifier.text); continue; } if ( ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause) && !statement.isTypeOnly ) { for (const specifier of statement.exportClause.elements) { if (specifier.isTypeOnly) { continue; } const localName = specifier.propertyName?.text ?? specifier.name.text; if (statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)) { const reExport = { exportedName: specifier.name.text, importedName: localName, moduleSpecifier: statement.moduleSpecifier.text, }; namedReExports.push(reExport); if (reExport.exportedName !== reExport.importedName) { aliasingReExports.push({ ...reExport, line: toLine(sourceFile, specifier) }); } } else if (!statement.moduleSpecifier) { pendingLocalReExports.push({ exportedName: specifier.name.text, localName }); } if (specifier.name.text !== localName) { continue; } exportedNames.add(specifier.name.text); if (!statement.moduleSpecifier) { locallyExportedNames.add(localName); } } } } for (const reExport of pendingLocalReExports) { const imported = importedSymbolsByLocalName.get(reExport.localName); if (imported) { namedReExports.push({ exportedName: reExport.exportedName, importedName: imported.importedName, moduleSpecifier: imported.moduleSpecifier, }); } } const definitions = new Set(); const valueDefinitions = new Map(); for (const name of new Set([...directlyExportedNames, ...locallyExportedNames])) { const constDeclarations = localConstDeclarations.get(name); if (constDeclarations) { const [constDeclaration] = constDeclarations; if (constDeclarations.length === 1 && constDeclaration) { const importedReferences = collectImportedReferences( constDeclaration, importedSymbolsByLocalName, namespaceImportsByLocalName, ); const initializer = constDeclaration.initializer ? unwrapExpression(constDeclaration.initializer) : undefined; if (initializer) { const aliasSource = resolveImportedReference( initializer, importedSymbolsByLocalName, namespaceImportsByLocalName, ); if (aliasSource) { importedReferences.push(aliasSource); } } valueDefinitions.set(name, { importedReferences: importedReferences.toSorted((left, right) => `${left.moduleSpecifier}\0${left.importedName}\0${left.localName}`.localeCompare( `${right.moduleSpecifier}\0${right.importedName}\0${right.localName}`, ), ), name, }); } if ( constDeclarations.length === 1 && constDeclaration && isForwardingOnlyConst(constDeclaration, name, importedNamesByLocalName) ) { continue; } definitions.add(name); continue; } const functionDeclarations = localFunctions.get(name); if (!functionDeclarations) { continue; } const implementation = functionDeclarations.find((declaration) => declaration.body); if (implementation?.body) { valueDefinitions.set(name, { importedReferences: collectImportedReferences( implementation.body, importedSymbolsByLocalName, namespaceImportsByLocalName, ), name, }); } // Lazy runtime facades are mandated by AGENTS.md. Exempt only exact same-name // argument forwarding so those boundaries do not become duplicate behavior. if ( implementation && isForwardingOnlyFunction(implementation, name, importedNamesByLocalName) ) { continue; } definitions.add(name); } return { aliasingReExports, definitions, exportedNames, namedReExports: namedReExports.toSorted((left, right) => `${left.exportedName}\0${left.importedName}\0${left.moduleSpecifier}`.localeCompare( `${right.exportedName}\0${right.importedName}\0${right.moduleSpecifier}`, ), ), starExportSpecifiers, valueDefinitions, }; } export function resolveExportModulePath( sourcePath: string, specifier: string, modulesByPath: ReadonlyMap, ) { if (!specifier.startsWith(".")) { return null; } const unresolved = path.posix.normalize( path.posix.join(path.posix.dirname(sourcePath), specifier), ); const extensionless = unresolved.replace(/\.(?:c|m)?(?:j|t)s$/u, ""); const candidates = [ `${extensionless}.ts`, `${extensionless}.mts`, `${extensionless}.js`, `${extensionless}.mjs`, `${extensionless}/index.ts`, `${extensionless}/index.mts`, `${extensionless}/index.js`, `${extensionless}/index.mjs`, ]; return candidates.find((candidate) => modulesByPath.has(candidate)) ?? null; } function collectTransitiveExportNames( modulePath: string, modulesByPath: ReadonlyMap, visiting = new Set(), ): Set { if (visiting.has(modulePath)) { return new Set(); } const moduleExports = modulesByPath.get(modulePath); if (!moduleExports) { return new Set(); } const nextVisiting = new Set(visiting).add(modulePath); const names = new Set(moduleExports.exportedNames); for (const specifier of moduleExports.starExportSpecifiers) { const targetPath = resolveExportModulePath(modulePath, specifier, modulesByPath); if (!targetPath) { continue; } for (const name of collectTransitiveExportNames(targetPath, modulesByPath, nextVisiting)) { names.add(name); } } return names; } // Per-module test-hook namespaces are an intentional same-name family: each module // exports its own `testing`/`testApi` object and tests import it qualified from that // exact module. Flagging them would push burn-down work to "fix" a deliberate idiom. const intentionalSameNameFamilies = new Set(["testing", "testApi"]); function analyzeExportNames(modules: SourceModule[]) { const aliasingReExports: AliasingReExport[] = []; const filesByName = new Map>(); const sdkExportNames = new Set(); const modulesByPath = new Map(); for (const sourceModule of modules.toSorted((left, right) => left.path.localeCompare(right.path), )) { const relativePath = normalizeRelativePath(sourceModule.path); const moduleExports = collectModuleExportNames(sourceModule.content, relativePath); modulesByPath.set(relativePath, moduleExports); if (sourceModule.includeDefinitions !== false && !relativePath.startsWith("src/plugin-sdk/")) { aliasingReExports.push( ...moduleExports.aliasingReExports.map((reExport) => ({ ...reExport, path: relativePath, })), ); } if (sourceModule.includeDefinitions !== false) { for (const name of moduleExports.definitions) { const files = filesByName.get(name) ?? new Set(); files.add(relativePath); filesByName.set(name, files); } } } for (const modulePath of modulesByPath.keys()) { if (!modulePath.startsWith("src/plugin-sdk/")) { continue; } for (const name of collectTransitiveExportNames(modulePath, modulesByPath)) { sdkExportNames.add(name); } } const collisions: ExportNameCollision[] = []; for (const [name, fileSet] of filesByName) { if (fileSet.size < 2 || intentionalSameNameFamilies.has(name)) { continue; } const collision: ExportNameCollision = { name, files: [...fileSet].toSorted(), }; if (sdkExportNames.has(name)) { collision.sdk = true; } collisions.push(collision); } return { aliasingReExports: aliasingReExports.toSorted( (left, right) => left.path.localeCompare(right.path) || left.line - right.line || left.exportedName.localeCompare(right.exportedName), ), collisions: collisions.toSorted((left, right) => left.name.localeCompare(right.name)), }; } /** Finds direct renamed re-exports outside the Plugin SDK boundary. */ export function findAliasingReExports(modules: SourceModule[]) { return analyzeExportNames(modules).aliasingReExports; } /** Finds duplicate exported function/const definitions across source modules. */ export function findExportNameCollisions(modules: SourceModule[]): ExportNameCollision[] { return analyzeExportNames(modules).collisions; } type CollisionChange = { baseline?: ExportNameCollision; current?: ExportNameCollision; }; /** Compares every collision cluster so additions fail and removals ratchet debt down. */ export function compareExportNameCollisionDebt( current: ExportNameCollision[], baseline: ExportNameCollision[], ) { const currentByName = new Map(current.map((collision) => [collision.name, collision])); const baselineByName = new Map(baseline.map((collision) => [collision.name, collision])); const regressions: CollisionChange[] = []; const improvements: CollisionChange[] = []; const names = [...new Set([...currentByName.keys(), ...baselineByName.keys()])].toSorted(); for (const name of names) { const currentCollision = currentByName.get(name); const baselineCollision = baselineByName.get(name); if (!baselineCollision) { regressions.push({ current: currentCollision }); continue; } if (!currentCollision) { improvements.push({ baseline: baselineCollision }); continue; } const baselineFiles = new Set(baselineCollision.files); const currentFiles = new Set(currentCollision.files); const hasAddedFile = currentCollision.files.some((file) => !baselineFiles.has(file)); const hasRemovedFile = baselineCollision.files.some((file) => !currentFiles.has(file)); if (hasAddedFile || (currentCollision.sdk === true && baselineCollision.sdk !== true)) { regressions.push({ baseline: baselineCollision, current: currentCollision }); } if (hasRemovedFile || (baselineCollision.sdk === true && currentCollision.sdk !== true)) { improvements.push({ baseline: baselineCollision, current: currentCollision }); } } return { regressions, improvements }; } function resolveBaselinePath(repoRoot: string) { return path.join(repoRoot, ...baselineRelativePath.split("/")); } async function collectRepositoryModules(repoRoot: string) { const sourceCollectOptions = { fileExtensions: [".ts", ".mts", ".js", ".mjs"], includeTests: true, skipDirectories: ["test", "__fixtures__"], }; const supportCollectOptions = { ...sourceCollectOptions, fileExtensions: [".ts", ".mts"], }; const [collectedFiles, collectedSupportFiles] = await Promise.all([ collectTypeScriptFilesFromRoots(resolveSourceRoots(repoRoot, ["src"]), sourceCollectOptions), // Package modules are resolution-only: Plugin SDK barrels can export their // names, but the collision rule itself remains scoped to src/ definitions. collectTypeScriptFilesFromRoots( resolveSourceRoots(repoRoot, ["packages"]), supportCollectOptions, ), ]); const files = collectedFiles.filter((filePath) => !isExcludedExportCollisionSource(filePath)); const supportFiles = collectedSupportFiles.filter( (filePath) => !isExcludedExportCollisionSource(filePath), ); const modules = await Promise.all( [ ...files.map((filePath) => ({ filePath, includeDefinitions: true })), ...supportFiles.map((filePath) => ({ filePath, includeDefinitions: false })), ].map(async ({ filePath, includeDefinitions }) => ({ content: await fs.readFile(filePath, "utf8"), includeDefinitions, path: normalizeRelativePath(path.relative(repoRoot, filePath)), })), ); return modules; } async function collectRepositoryExportAnalysis(repoRoot: string) { return analyzeExportNames(await collectRepositoryModules(repoRoot)); } export async function collectRepositoryCollisions(repoRoot: string) { return (await collectRepositoryExportAnalysis(repoRoot)).collisions; } async function readBaseline(repoRoot: string) { try { return exportNameCollisionBaselineSchema.parse( JSON.parse(await fs.readFile(resolveBaselinePath(repoRoot), "utf8")), ); } catch (error) { if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { return null; } throw error; } } async function writeBaseline(repoRoot: string, collisions: ExportNameCollision[]) { await fs.writeFile(resolveBaselinePath(repoRoot), `${JSON.stringify(collisions, null, 2)}\n`); return collisions.length; } function formatCollision(collision: ExportNameCollision | undefined) { return JSON.stringify(collision); } function printAliasingReExports(reExports: AliasingReExport[]) { if (reExports.length === 0) { return; } console.log("Aliasing re-exports outside src/plugin-sdk/ (informational):"); for (const reExport of reExports) { console.log( `- ${reExport.path}:${reExport.line}: ${reExport.importedName} as ${reExport.exportedName} from ${JSON.stringify(reExport.moduleSpecifier)}`, ); } } export async function main() { const repoRoot = resolveRepoRoot(import.meta.url); if (process.argv.includes("--update-debt-baseline")) { const analysis = await collectRepositoryExportAnalysis(repoRoot); const count = await writeBaseline(repoRoot, analysis.collisions); console.log(`Wrote ${baselineRelativePath} (${count} entries)`); printAliasingReExports(analysis.aliasingReExports); return 0; } const baseline = await readBaseline(repoRoot); if (!baseline) { console.error( `Missing ${baselineRelativePath}; run \`${baselineRegenCommand}\` and commit it.`, ); return 1; } const analysis = await collectRepositoryExportAnalysis(repoRoot); const debt = compareExportNameCollisionDebt(analysis.collisions, baseline); printAliasingReExports(analysis.aliasingReExports); if (debt.regressions.length === 0 && debt.improvements.length === 0) { console.log("export name collision guard passed."); return 0; } if (debt.regressions.length > 0) { console.error( `Found new exported function/const name collisions beyond ${baselineRelativePath}:`, ); for (const regression of debt.regressions) { console.error(`- ${formatCollision(regression.current)}`); } console.error( `Give each behavior one exported spelling. If the debt increase is intentional, run \`${baselineRegenCommand}\` and commit the generated baseline.`, ); } if (debt.improvements.length > 0) { console.error(`Export name collision debt dropped below ${baselineRelativePath}:`); for (const improvement of debt.improvements) { console.error( `- ${improvement.baseline?.name}: ${formatCollision(improvement.baseline)} -> ${formatCollision(improvement.current)}`, ); } console.error(`Run \`${baselineRegenCommand}\` to ratchet the baseline down and commit it.`); } return 1; } runAsScript(import.meta.url, async () => { let exitCode = 1; try { exitCode = await main(); } catch (error) { console.error(error); } if (exitCode !== 0) { process.exitCode = exitCode; console.error(`[${failurePrefix}] FAILED (exit ${exitCode})`); } });