mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
881 lines
29 KiB
TypeScript
881 lines
29 KiB
TypeScript
#!/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<Omit<AliasingReExport, "path">>;
|
|
definitions: Set<string>;
|
|
exportedNames: Set<string>;
|
|
namedReExports: NamedReExport[];
|
|
starExportSpecifiers: string[];
|
|
valueDefinitions: Map<string, ExportedValueDefinition>;
|
|
};
|
|
|
|
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<string>) {
|
|
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<string, ImportedSymbolReference>,
|
|
namespaceImportsByLocalName: ReadonlyMap<string, string>,
|
|
) {
|
|
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<string, ImportedSymbolReference>,
|
|
namespaceImportsByLocalName: ReadonlyMap<string, string>,
|
|
) {
|
|
const references = new Map<string, ImportedSymbolReference>();
|
|
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<ts.ParameterDeclaration>,
|
|
args: ts.NodeArray<ts.Expression>,
|
|
) {
|
|
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<string, string>,
|
|
) {
|
|
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<string, string>,
|
|
) {
|
|
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<string, string>,
|
|
) {
|
|
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<string, string>();
|
|
const importedSymbolsByLocalName = new Map<string, ImportedSymbolReference>();
|
|
const namespaceImportsByLocalName = new Map<string, string>();
|
|
const localConstDeclarations = new Map<string, ts.VariableDeclaration[]>();
|
|
const localFunctions = new Map<string, ts.FunctionDeclaration[]>();
|
|
const directlyExportedNames = new Set<string>();
|
|
const locallyExportedNames = new Set<string>();
|
|
const exportedNames = new Set<string>();
|
|
const aliasingReExports: Array<Omit<AliasingReExport, "path">> = [];
|
|
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<string>();
|
|
for (const declaration of statement.declarationList.declarations) {
|
|
const declarationNames = new Set<string>();
|
|
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<string>();
|
|
const valueDefinitions = new Map<string, ExportedValueDefinition>();
|
|
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<string, ModuleExports>,
|
|
) {
|
|
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<string, ModuleExports>,
|
|
visiting = new Set<string>(),
|
|
): Set<string> {
|
|
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<string, Set<string>>();
|
|
const sdkExportNames = new Set<string>();
|
|
const modulesByPath = new Map<string, ModuleExports>();
|
|
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<string>();
|
|
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})`);
|
|
}
|
|
});
|