mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
chore: detect export name collisions (#121300)
* chore(scripts): add export-name-collision check with debt baseline * chore(scripts): allowlist per-module test-hook export idiom * chore(scripts): recognize const forwarders, harness files, and JS sources in collision check
This commit is contained in:
committed by
GitHub
parent
c3a0d3e58a
commit
64695e5024
@@ -2450,6 +2450,9 @@ jobs:
|
||||
- check_name: check-prompt-snapshots
|
||||
group: prompt-snapshots
|
||||
runner: blacksmith-8vcpu-ubuntu-2404
|
||||
- check_name: check-export-name-collisions
|
||||
group: export-name-collisions
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- check_name: check-session-accessor-boundary
|
||||
group: session-accessor-boundary
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -2649,6 +2652,15 @@ jobs:
|
||||
run_check "prompt:snapshots:check" pnpm prompt:snapshots:check
|
||||
fi
|
||||
;;
|
||||
export-name-collisions)
|
||||
if [ ! -f scripts/check-export-name-collisions.mts ]; then
|
||||
echo "[skip] export name collision check is not present in this checkout"
|
||||
elif ! node -e 'const pkg = require("./package.json"); process.exit(pkg.scripts?.["lint:tmp:export-name-collisions"] ? 0 : 1);'; then
|
||||
echo "[skip] export name collision script is not present in package.json"
|
||||
else
|
||||
run_check "lint:tmp:export-name-collisions" pnpm run lint:tmp:export-name-collisions
|
||||
fi
|
||||
;;
|
||||
session-accessor-boundary)
|
||||
if [ ! -f scripts/check-session-accessor-boundary.mts ]; then
|
||||
echo "[skip] session accessor boundary check is not present in this checkout"
|
||||
|
||||
@@ -1672,6 +1672,8 @@
|
||||
"lint:swift": "./scripts/lint-swift.sh",
|
||||
"lint:tmp:channel-agnostic-boundaries": "node --import tsx scripts/check-channel-agnostic-boundaries.mts",
|
||||
"lint:tmp:dynamic-import-warts": "node --import tsx scripts/check-dynamic-import-warts.mts",
|
||||
"lint:tmp:export-name-collisions": "node --import tsx scripts/check-export-name-collisions.mts",
|
||||
"lint:tmp:export-name-collisions:gen": "node --import tsx scripts/check-export-name-collisions.mts --update-debt-baseline",
|
||||
"lint:tmp:no-random-messaging": "node --import tsx scripts/check-no-random-messaging-tmp.mts",
|
||||
"lint:tmp:no-raw-channel-fetch": "node --import tsx scripts/check-no-raw-channel-fetch.mts",
|
||||
"lint:tmp:no-raw-http2-imports": "node --import tsx scripts/check-no-raw-http2-imports.mts",
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
#!/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,
|
||||
unwrapExpression,
|
||||
} from "./lib/ts-guard-utils.mts";
|
||||
|
||||
export type ExportNameCollision = {
|
||||
name: string;
|
||||
files: string[];
|
||||
sdk?: true;
|
||||
};
|
||||
|
||||
type SourceModule = {
|
||||
content: string;
|
||||
includeDefinitions?: boolean;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type ModuleExports = {
|
||||
definitions: Set<string>;
|
||||
exportedNames: Set<string>;
|
||||
starExportSpecifiers: string[];
|
||||
};
|
||||
|
||||
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 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 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 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) {
|
||||
importedNamesByLocalName.set(
|
||||
specifier.name.text,
|
||||
specifier.propertyName?.text ?? specifier.name.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;
|
||||
// Renamed re-exports are deliberately outside this guard's first slice.
|
||||
if (specifier.name.text !== localName) {
|
||||
continue;
|
||||
}
|
||||
exportedNames.add(specifier.name.text);
|
||||
if (!statement.moduleSpecifier) {
|
||||
locallyExportedNames.add(localName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const definitions = new Set<string>();
|
||||
for (const name of new Set([...directlyExportedNames, ...locallyExportedNames])) {
|
||||
const constDeclarations = localConstDeclarations.get(name);
|
||||
if (constDeclarations) {
|
||||
const [constDeclaration] = constDeclarations;
|
||||
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);
|
||||
// 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 { definitions, exportedNames, starExportSpecifiers };
|
||||
}
|
||||
|
||||
function resolveStarExportPath(
|
||||
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 = resolveStarExportPath(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"]);
|
||||
|
||||
/** Finds duplicate exported function/const definitions across source modules. */
|
||||
export function findExportNameCollisions(modules: SourceModule[]): ExportNameCollision[] {
|
||||
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) {
|
||||
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 collisions.toSorted((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
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("/"));
|
||||
}
|
||||
|
||||
export async function collectRepositoryCollisions(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 findExportNameCollisions(modules);
|
||||
}
|
||||
|
||||
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) {
|
||||
const collisions = await collectRepositoryCollisions(repoRoot);
|
||||
await fs.writeFile(resolveBaselinePath(repoRoot), `${JSON.stringify(collisions, null, 2)}\n`);
|
||||
return collisions.length;
|
||||
}
|
||||
|
||||
function formatCollision(collision: ExportNameCollision | undefined) {
|
||||
return JSON.stringify(collision);
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
const repoRoot = resolveRepoRoot(import.meta.url);
|
||||
if (process.argv.includes("--update-debt-baseline")) {
|
||||
const count = await writeBaseline(repoRoot);
|
||||
console.log(`Wrote ${baselineRelativePath} (${count} entries)`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const baseline = await readBaseline(repoRoot);
|
||||
if (!baseline) {
|
||||
console.error(
|
||||
`Missing ${baselineRelativePath}; run \`${baselineRegenCommand}\` and commit it.`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
const current = await collectRepositoryCollisions(repoRoot);
|
||||
const debt = compareExportNameCollisionDebt(current, baseline);
|
||||
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})`);
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,7 +40,7 @@ export function resolveSourceRoots(repoRoot: string, relativeRoots: string[]) {
|
||||
return relativeRoots.map((root) => path.join(repoRoot, ...root.split("/").filter(Boolean)));
|
||||
}
|
||||
|
||||
function isTestLikeTypeScriptFile(filePath: string, extraTestSuffixes: string[] = []) {
|
||||
export function isTestLikeTypeScriptFile(filePath: string, extraTestSuffixes: string[] = []) {
|
||||
return [...baseTestSuffixes, ...extraTestSuffixes].some((suffix) => filePath.endsWith(suffix));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectModuleExportNames,
|
||||
collectRepositoryCollisions,
|
||||
compareExportNameCollisionDebt,
|
||||
findExportNameCollisions,
|
||||
isExcludedExportCollisionSource,
|
||||
} from "../../scripts/check-export-name-collisions.mts";
|
||||
import { withTempDir } from "../../src/test-utils/temp-dir.js";
|
||||
|
||||
describe("export name collision guard", () => {
|
||||
it.each([
|
||||
["src/example.test.ts", true],
|
||||
["src/example.e2e.test.ts", true],
|
||||
["src/example.test-support.ts", true],
|
||||
["src/example.test-helpers.ts", true],
|
||||
["src/example.test-utils.ts", true],
|
||||
["src/example.test-harness.ts", true],
|
||||
["src/example.e2e-harness.ts", true],
|
||||
["src/example.d.ts", true],
|
||||
["src/test/example.ts", true],
|
||||
["src/nested/__fixtures__/example.mts", true],
|
||||
["src/example.ts", false],
|
||||
["src/example.mts", false],
|
||||
])("classifies source exclusion %s", (filePath, expected) => {
|
||||
expect(isExcludedExportCollisionSource(filePath)).toBe(expected);
|
||||
});
|
||||
|
||||
it("finds exported function and const definitions across modules", () => {
|
||||
expect(
|
||||
findExportNameCollisions([
|
||||
{ path: "src/alpha.ts", content: "export function sharedBehavior() {}" },
|
||||
{ path: "src/beta.ts", content: "export const sharedBehavior = () => {};" },
|
||||
{
|
||||
path: "src/gamma.ts",
|
||||
content: "async function listedBehavior() {}\nexport { listedBehavior };",
|
||||
},
|
||||
{
|
||||
path: "src/delta.mts",
|
||||
content: "export async function listedBehavior() {}",
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{ name: "listedBehavior", files: ["src/delta.mts", "src/gamma.ts"] },
|
||||
{ name: "sharedBehavior", files: ["src/alpha.ts", "src/beta.ts"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores types, pure re-exports, imports exported locally, and renamed exports", () => {
|
||||
const result = collectModuleExportNames(`
|
||||
import { importedValue } from "./other.js";
|
||||
interface LocalShape {}
|
||||
type LocalType = string;
|
||||
export { importedValue };
|
||||
export { remoteValue } from "./remote.js";
|
||||
export { remoteValue as renamedValue } from "./remote.js";
|
||||
export * from "./barrel.js";
|
||||
export interface ExportedShape {}
|
||||
export type ExportedType = string;
|
||||
`);
|
||||
expect([...result.definitions]).toEqual([]);
|
||||
expect([...result.exportedNames]).toEqual(["importedValue", "remoteValue"]);
|
||||
});
|
||||
|
||||
it("exempts exact function and const same-name forwarders", () => {
|
||||
const forwarders = [
|
||||
`
|
||||
import { resolveThing as resolveThingImpl } from "./thing.js";
|
||||
export function resolveThing(first: string, second?: number) {
|
||||
return resolveThingImpl(first, second);
|
||||
}
|
||||
`,
|
||||
`
|
||||
import { resolveThing as resolveThingImpl } from "./thing.js";
|
||||
export const resolveThing = resolveThingImpl;
|
||||
`,
|
||||
`
|
||||
import { resolveThing as resolveThingImpl } from "./thing.js";
|
||||
export const resolveThing = (first: string, second?: number) =>
|
||||
resolveThingImpl(first, second);
|
||||
`,
|
||||
`
|
||||
export const runThing = async (...args: unknown[]) => {
|
||||
const runtime = await loadRuntime();
|
||||
return runtime.runThing(...args);
|
||||
};
|
||||
`,
|
||||
`
|
||||
export async function runThing(...args: unknown[]) {
|
||||
return (await loadRuntime()).runThing(...args);
|
||||
}
|
||||
`,
|
||||
`
|
||||
export async function runThing(...args: unknown[]) {
|
||||
const runtime = await loadRuntime();
|
||||
return runtime.runThing(...args);
|
||||
}
|
||||
`,
|
||||
];
|
||||
for (const content of forwarders) {
|
||||
expect([...collectModuleExportNames(content).definitions]).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "extra call",
|
||||
body: `
|
||||
prepare();
|
||||
return resolveThingImpl(...args);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "added argument",
|
||||
body: "return resolveThingImpl(...args, fallback);",
|
||||
},
|
||||
{
|
||||
name: "changed argument order",
|
||||
params: "first: string, second: string",
|
||||
body: "return resolveThingImpl(second, first);",
|
||||
},
|
||||
{
|
||||
name: "layered argument",
|
||||
params: "params: Record<string, unknown>",
|
||||
body: "return resolveThingImpl({ ...params, enabled: true });",
|
||||
},
|
||||
{
|
||||
name: "conditional",
|
||||
body: "return ready ? resolveThingImpl(...args) : fallback;",
|
||||
},
|
||||
])("keeps $name wrappers as real definitions", ({ params = "...args: unknown[]", body }) => {
|
||||
const result = collectModuleExportNames(`
|
||||
import { resolveThing as resolveThingImpl } from "./thing.js";
|
||||
export function resolveThing(${params}) {
|
||||
${body}
|
||||
}
|
||||
`);
|
||||
expect([...result.definitions]).toEqual(["resolveThing"]);
|
||||
});
|
||||
|
||||
it("keeps const arrows that add arguments as real definitions", () => {
|
||||
const result = collectModuleExportNames(`
|
||||
import { resolveThing as resolveThingImpl } from "./thing.js";
|
||||
export const resolveThing = (...args: unknown[]) => resolveThingImpl(...args, fallback);
|
||||
`);
|
||||
expect([...result.definitions]).toEqual(["resolveThing"]);
|
||||
});
|
||||
|
||||
it("discovers JavaScript source collisions", async () => {
|
||||
await withTempDir("openclaw-export-collisions-", async (repoRoot) => {
|
||||
const sourceRoot = path.join(repoRoot, "src");
|
||||
await fs.mkdir(sourceRoot);
|
||||
await Promise.all([
|
||||
fs.writeFile(path.join(sourceRoot, "alpha.js"), "export const sharedValue = 1;\n"),
|
||||
fs.writeFile(path.join(sourceRoot, "beta.mjs"), "export const sharedValue = 2;\n"),
|
||||
]);
|
||||
expect(await collectRepositoryCollisions(repoRoot)).toEqual([
|
||||
{ name: "sharedValue", files: ["src/alpha.js", "src/beta.mjs"] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("deduplicates overloads inside one module", () => {
|
||||
expect(
|
||||
findExportNameCollisions([
|
||||
{
|
||||
path: "src/overloads.ts",
|
||||
content: `
|
||||
export function convert(value: string): string;
|
||||
export function convert(value: number): number;
|
||||
export function convert(value: string | number) { return value; }
|
||||
`,
|
||||
},
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("marks collisions exposed by a Plugin SDK module", () => {
|
||||
expect(
|
||||
findExportNameCollisions([
|
||||
{ path: "src/one.ts", content: "export const publicCollision = 1;" },
|
||||
{ path: "src/two.ts", content: "export function publicCollision() {}" },
|
||||
{
|
||||
path: "src/plugin-sdk/public.ts",
|
||||
content: 'export * from "./public-star.js";',
|
||||
},
|
||||
{
|
||||
path: "src/plugin-sdk/public-star.ts",
|
||||
content: 'export * from "../../packages/public.js";',
|
||||
},
|
||||
{
|
||||
path: "packages/public.ts",
|
||||
content: "export const publicCollision = true;",
|
||||
includeDefinitions: false,
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
name: "publicCollision",
|
||||
files: ["src/one.ts", "src/two.ts"],
|
||||
sdk: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("export name collision debt baseline", () => {
|
||||
it("separates new debt from baseline improvements", () => {
|
||||
expect(
|
||||
compareExportNameCollisionDebt(
|
||||
[
|
||||
{ name: "added", files: ["src/a.ts", "src/b.ts"] },
|
||||
{ name: "expanded", files: ["src/a.ts", "src/b.ts", "src/c.ts"], sdk: true },
|
||||
],
|
||||
[
|
||||
{ name: "expanded", files: ["src/a.ts", "src/b.ts"] },
|
||||
{ name: "removed", files: ["src/c.ts", "src/d.ts"] },
|
||||
],
|
||||
),
|
||||
).toEqual({
|
||||
regressions: [
|
||||
{ current: { name: "added", files: ["src/a.ts", "src/b.ts"] } },
|
||||
{
|
||||
baseline: { name: "expanded", files: ["src/a.ts", "src/b.ts"] },
|
||||
current: {
|
||||
name: "expanded",
|
||||
files: ["src/a.ts", "src/b.ts", "src/c.ts"],
|
||||
sdk: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
improvements: [{ baseline: { name: "removed", files: ["src/c.ts", "src/d.ts"] } }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3509,6 +3509,11 @@ NODE
|
||||
group: "session-accessor-boundary",
|
||||
runner: "blacksmith-4vcpu-ubuntu-2404",
|
||||
});
|
||||
expect(workflow.jobs["check-additional-shard"].strategy.matrix.include).toContainEqual({
|
||||
check_name: "check-export-name-collisions",
|
||||
group: "export-name-collisions",
|
||||
runner: "blacksmith-4vcpu-ubuntu-2404",
|
||||
});
|
||||
expect(workflow.jobs["check-additional-shard"].strategy.matrix.include).toContainEqual({
|
||||
check_name: "check-sqlite-session-schema-baseline",
|
||||
group: "sqlite-session-schema-baseline",
|
||||
@@ -3798,6 +3803,25 @@ NODE
|
||||
);
|
||||
});
|
||||
|
||||
it("runs the export name collision ratchet as a visible additional check", () => {
|
||||
const workflow = readCiWorkflow();
|
||||
const additionalJob = workflow.jobs["check-additional-shard"];
|
||||
const matrixRows = additionalJob.strategy.matrix.include;
|
||||
expect(matrixRows).toContainEqual({
|
||||
check_name: "check-export-name-collisions",
|
||||
group: "export-name-collisions",
|
||||
runner: "blacksmith-4vcpu-ubuntu-2404",
|
||||
});
|
||||
|
||||
const runStep = additionalJob.steps.find(
|
||||
(step: WorkflowStep) => step.name === "Run additional check shard",
|
||||
);
|
||||
expect(runStep.run).toContain("export-name-collisions)");
|
||||
expect(runStep.run).toContain(
|
||||
'run_check "lint:tmp:export-name-collisions" pnpm run lint:tmp:export-name-collisions',
|
||||
);
|
||||
});
|
||||
|
||||
it("runs the transcript reader ratchet as a visible additional check", () => {
|
||||
const workflow = readCiWorkflow();
|
||||
const additionalJob = workflow.jobs["check-additional-shard"];
|
||||
|
||||
Reference in New Issue
Block a user