Files
openclaw/scripts/check-script-declarations.mjs
Peter SteinbergerandGitHub 3d1b771035 fix(ci): catch script declaration drift before merge (#110248)
* fix(ci): verify script declaration contracts

* fix(ci): harden declaration export analysis

* fix(ci): cover opaque script module exports

* test(ci): cover cyclic script declaration barrels
2026-07-18 02:54:26 +01:00

698 lines
23 KiB
JavaScript

#!/usr/bin/env node
// Verifies that typed script declarations expose every runtime value export.
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import path from "node:path";
import ts from "typescript";
import { isDirectRunUrl } from "./lib/direct-run.mjs";
const SCRIPT_SOURCE_RE = /^scripts\/.+\.mjs$/u;
const SCRIPT_DECLARATION_RE = /^scripts\/.+\.d\.mts$/u;
const TYPED_SOURCE_RE = /\.(?:[cm]?ts|tsx)$/u;
const SKIPPED_DIRS = new Set([".artifacts", ".git", ".worktrees", "dist", "node_modules"]);
function normalizePath(value) {
return value.split(path.sep).join("/").replace(/^\.\//u, "");
}
function listFilesFromGit(root) {
try {
return execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
cwd: root,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
})
.split("\0")
.map(normalizePath)
.filter(Boolean);
} catch {
return null;
}
}
function listUntrackedFilesFromGit(root) {
try {
return execFileSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], {
cwd: root,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
})
.split("\0")
.map(normalizePath)
.filter(Boolean);
} catch {
return [];
}
}
function listFilesFromDirectory(root, fsImpl) {
const files = [];
const visit = (relativeDir) => {
const absoluteDir = path.join(root, relativeDir);
for (const entry of fsImpl.readdirSync(absoluteDir, { withFileTypes: true })) {
if (entry.isDirectory() && SKIPPED_DIRS.has(entry.name)) {
continue;
}
const relativePath = normalizePath(path.join(relativeDir, entry.name));
if (entry.isDirectory()) {
visit(relativePath);
} else if (entry.isFile()) {
files.push(relativePath);
}
}
};
visit("");
return files;
}
function listRepositoryFiles(root, options) {
if (options.files) {
return [...new Set(options.files.map(normalizePath))].toSorted((left, right) =>
left.localeCompare(right),
);
}
const gitFiles = listFilesFromGit(root);
return (gitFiles ?? listFilesFromDirectory(root, options.fsImpl))
.filter((relativePath) => options.fsImpl.existsSync(path.join(root, relativePath)))
.toSorted((left, right) => left.localeCompare(right));
}
function listTypedMjsImporters(root, files, fsImpl, explicitFiles) {
if (explicitFiles) {
return files.filter((relativePath) => TYPED_SOURCE_RE.test(relativePath));
}
try {
return execFileSync(
"git",
[
"grep",
"-l",
"-z",
"-F",
".mjs",
"--",
":(glob)**/*.ts",
":(glob)**/*.tsx",
":(glob)**/*.mts",
":(glob)**/*.cts",
],
{ cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 },
)
.split("\0")
.map(normalizePath)
.filter(Boolean)
.concat(
listUntrackedFilesFromGit(root).filter((relativePath) => {
if (!TYPED_SOURCE_RE.test(relativePath)) {
return false;
}
return fsImpl.readFileSync(path.join(root, relativePath), "utf8").includes(".mjs");
}),
);
} catch (error) {
if (error?.status === 1) {
return [];
}
return files.filter((relativePath) => TYPED_SOURCE_RE.test(relativePath));
}
}
function resolveTypedScriptImports(root, files, fsImpl, explicitFiles) {
const requiredSources = new Set();
for (const relativePath of listTypedMjsImporters(root, files, fsImpl, explicitFiles)) {
const importerPath = path.join(root, relativePath);
if (!fsImpl.existsSync(importerPath)) {
continue;
}
const sourceText = fsImpl.readFileSync(importerPath, "utf8");
const imports = ts.preProcessFile(sourceText, true, true).importedFiles;
for (const imported of imports) {
if (!imported.fileName.startsWith(".") || !imported.fileName.endsWith(".mjs")) {
continue;
}
const absoluteTarget = path.resolve(path.dirname(importerPath), imported.fileName);
const relativeTarget = normalizePath(path.relative(root, absoluteTarget));
if (SCRIPT_SOURCE_RE.test(relativeTarget)) {
requiredSources.add(relativeTarget);
}
}
}
return requiredSources;
}
function hasModifier(node, kind) {
return node.modifiers?.some((modifier) => modifier.kind === kind) === true;
}
function collectBindingNames(name, names) {
if (ts.isIdentifier(name)) {
names.add(name.text);
return;
}
for (const element of name.elements) {
if (ts.isBindingElement(element)) {
collectBindingNames(element.name, names);
}
}
}
function collectLocalBindings(sourceFile, filePath) {
const bindings = new Map();
const setValueBinding = (name) => {
bindings.set(name, { kind: "value", origins: new Set([createBindingOrigin(filePath, name)]) });
};
for (const statement of sourceFile.statements) {
if (ts.isVariableStatement(statement)) {
const names = new Set();
for (const declaration of statement.declarationList.declarations) {
collectBindingNames(declaration.name, names);
}
for (const name of names) {
setValueBinding(name);
}
continue;
}
if (
(ts.isFunctionDeclaration(statement) ||
ts.isClassDeclaration(statement) ||
ts.isEnumDeclaration(statement) ||
ts.isModuleDeclaration(statement)) &&
statement.name
) {
setValueBinding(statement.name.getText(sourceFile));
continue;
}
if (
(ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) &&
statement.name
) {
if (!bindings.has(statement.name.text)) {
bindings.set(statement.name.text, { kind: "type" });
}
continue;
}
if (!ts.isImportDeclaration(statement) || !statement.importClause) {
continue;
}
const specifier = ts.isStringLiteral(statement.moduleSpecifier)
? statement.moduleSpecifier.text
: null;
if (!specifier) {
continue;
}
const clause = statement.importClause;
if (clause.name) {
bindings.set(clause.name.text, {
kind: clause.isTypeOnly ? "type" : "import",
importedName: "default",
specifier,
});
}
if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
bindings.set(clause.namedBindings.name.text, {
kind: clause.isTypeOnly ? "type" : "namespace-import",
specifier,
});
} else if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
for (const element of clause.namedBindings.elements) {
bindings.set(element.name.text, {
kind: clause.isTypeOnly || element.isTypeOnly ? "type" : "import",
importedName: element.propertyName?.text ?? element.name.text,
specifier,
});
}
}
}
return bindings;
}
function resolveReexport(importerPath, specifier, fsImpl) {
if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) {
return null;
}
const base = path.resolve(path.dirname(importerPath), specifier);
const declarationCandidates = [];
if (specifier.endsWith(".mjs")) {
declarationCandidates.push(`${base.slice(0, -".mjs".length)}.d.mts`);
} else if (specifier.endsWith(".cjs")) {
declarationCandidates.push(`${base.slice(0, -".cjs".length)}.d.cts`);
} else if (specifier.endsWith(".js")) {
declarationCandidates.push(`${base.slice(0, -".js".length)}.d.ts`);
}
declarationCandidates.push(`${base}.d.ts`, `${base}.d.mts`);
const candidates = /\.d\.[cm]?ts$/u.test(importerPath)
? [...declarationCandidates, base]
: [base, ...declarationCandidates];
return candidates.find((candidate) => fsImpl.existsSync(candidate)) ?? null;
}
function mergeOrigins(target, origins) {
for (const origin of origins) {
target.add(origin);
}
}
function createBindingOrigin(moduleId, name) {
return JSON.stringify(["binding", moduleId, name]);
}
function createNamespaceOrigin(moduleId) {
return JSON.stringify(["namespace", moduleId]);
}
function isExternalModuleSpecifier(specifier) {
return !specifier.startsWith(".") && !path.isAbsolute(specifier);
}
function canUseOpaqueModuleBinding(target, importedName) {
return (
target.endsWith(".cjs") ||
target.endsWith(".node") ||
(target.endsWith(".json") && importedName === "default")
);
}
function createOpaqueExternalOrigin(importerPath, specifier, kind, name = null) {
return JSON.stringify(["opaque-external", importerPath, specifier, kind, name]);
}
function isOpaqueExternalOrigin(origin) {
return origin.startsWith('["opaque-external",');
}
function resolveLocalBindingOrigins(binding, importerPath, fsImpl, state, issues) {
if (!binding || binding.kind === "type") {
return null;
}
if (binding.kind === "value") {
return binding.origins;
}
const target = resolveReexport(importerPath, binding.specifier, fsImpl);
if (!target) {
if (isExternalModuleSpecifier(binding.specifier)) {
if (/\.d\.[cm]?ts$/u.test(importerPath) && binding.kind !== "namespace-import") {
issues.push({
filePath: importerPath,
specifier: binding.specifier,
reason: "unresolved external declaration import",
});
return null;
}
return new Set([
createOpaqueExternalOrigin(
importerPath,
binding.specifier,
binding.kind === "namespace-import" ? "namespace" : "binding",
binding.kind === "namespace-import" ? null : binding.importedName,
),
]);
}
issues.push({
filePath: importerPath,
specifier: binding.specifier,
reason: "unresolved exported import",
});
return null;
}
if (binding.kind === "namespace-import") {
return new Set([createNamespaceOrigin(target)]);
}
const targetResult = collectValueExports(target, fsImpl, state);
issues.push(...targetResult.issues);
if (targetResult.ambiguous.has(binding.importedName)) {
issues.push({
filePath: importerPath,
specifier: `${binding.specifier}:${binding.importedName}`,
reason: "ambiguous exported import",
});
}
const origins = targetResult.exports.get(binding.importedName);
if (origins) {
return origins;
}
if (
!/\.d\.[cm]?ts$/u.test(importerPath) &&
canUseOpaqueModuleBinding(target, binding.importedName)
) {
return new Set([createBindingOrigin(target, binding.importedName)]);
}
issues.push({
filePath: importerPath,
specifier: `${binding.specifier}:${binding.importedName}`,
reason: "unresolved imported value",
});
return null;
}
function collectValueExports(filePath, fsImpl, state) {
const normalizedFilePath = path.resolve(filePath);
const cached = state.cache.get(normalizedFilePath);
if (cached) {
return cached;
}
if (state.visiting.has(normalizedFilePath)) {
// Full cyclic star resolution requires a fixed point. Fail closed instead of
// caching a partial map; script barrels can break the cycle with explicit exports.
return {
ambiguous: new Set(),
exports: new Map(),
issues: [
{
filePath: normalizedFilePath,
specifier: normalizedFilePath,
reason: "cyclic star re-export",
},
],
};
}
state.visiting.add(normalizedFilePath);
const sourceText = fsImpl.readFileSync(normalizedFilePath, "utf8");
const sourceFile = ts.createSourceFile(
normalizedFilePath,
sourceText,
ts.ScriptTarget.Latest,
true,
);
const localBindings = collectLocalBindings(sourceFile, normalizedFilePath);
const isDeclaration = /\.d\.[cm]?ts$/u.test(normalizedFilePath);
const explicitExports = new Map();
const explicitAmbiguous = new Set();
const starResults = [];
const issues = [];
const setExplicitExport = (name, origins) => {
explicitExports.set(name, origins ?? new Set([createBindingOrigin(normalizedFilePath, name)]));
};
for (const statement of sourceFile.statements) {
if (ts.isExportAssignment(statement)) {
const binding = ts.isIdentifier(statement.expression)
? localBindings.get(statement.expression.text)
: null;
setExplicitExport("default", binding?.kind === "value" ? binding.origins : undefined);
continue;
}
if (ts.isExportDeclaration(statement)) {
if (statement.isTypeOnly) {
continue;
}
if (statement.exportClause && ts.isNamedExports(statement.exportClause)) {
for (const element of statement.exportClause.elements) {
if (element.isTypeOnly) {
continue;
}
const exportedName = element.name.text;
const sourceName = element.propertyName?.text ?? exportedName;
const specifier = statement.moduleSpecifier;
if (specifier && ts.isStringLiteral(specifier)) {
const target = resolveReexport(normalizedFilePath, specifier.text, fsImpl);
const targetResult = target ? collectValueExports(target, fsImpl, state) : null;
if (targetResult) {
issues.push(...targetResult.issues);
}
const origins = targetResult?.exports.get(sourceName);
if (origins) {
explicitExports.set(exportedName, origins);
} else if (targetResult?.ambiguous.has(sourceName)) {
explicitAmbiguous.add(exportedName);
issues.push({
filePath: normalizedFilePath,
specifier: `${specifier.text}:${sourceName}`,
reason: "ambiguous named re-export",
});
} else if (!target && isExternalModuleSpecifier(specifier.text)) {
if (isDeclaration) {
issues.push({
filePath: normalizedFilePath,
specifier: specifier.text,
reason: "unresolved external declaration re-export",
});
} else {
explicitExports.set(
exportedName,
new Set([
createOpaqueExternalOrigin(
normalizedFilePath,
specifier.text,
"binding",
sourceName,
),
]),
);
}
} else if (!target) {
if (!isDeclaration) {
explicitExports.set(
exportedName,
new Set([createBindingOrigin(specifier.text, sourceName)]),
);
} else {
issues.push({
filePath: normalizedFilePath,
specifier: specifier.text,
reason: "unresolved named re-export",
});
}
} else if (!isDeclaration && canUseOpaqueModuleBinding(target, sourceName)) {
explicitExports.set(exportedName, new Set([createBindingOrigin(target, sourceName)]));
} else {
issues.push({
filePath: normalizedFilePath,
specifier: `${specifier.text}:${sourceName}`,
reason: "unresolved named re-export",
});
}
} else {
const origins = resolveLocalBindingOrigins(
localBindings.get(sourceName),
normalizedFilePath,
fsImpl,
state,
issues,
);
if (origins) {
explicitExports.set(exportedName, origins);
}
}
}
continue;
}
if (statement.exportClause && ts.isNamespaceExport(statement.exportClause)) {
const specifier = statement.moduleSpecifier;
const target =
specifier && ts.isStringLiteral(specifier)
? resolveReexport(normalizedFilePath, specifier.text, fsImpl)
: null;
const externalSpecifier =
specifier && ts.isStringLiteral(specifier) && isExternalModuleSpecifier(specifier.text)
? specifier.text
: null;
setExplicitExport(
statement.exportClause.name.text,
new Set([
target
? createNamespaceOrigin(target)
: externalSpecifier
? createOpaqueExternalOrigin(normalizedFilePath, externalSpecifier, "namespace")
: createNamespaceOrigin(specifier?.getText(sourceFile) ?? normalizedFilePath),
]),
);
continue;
}
const specifier = statement.moduleSpecifier;
if (specifier && ts.isStringLiteral(specifier)) {
const target = resolveReexport(normalizedFilePath, specifier.text, fsImpl);
if (target) {
const targetResult = collectValueExports(target, fsImpl, state);
starResults.push(targetResult);
issues.push(...targetResult.issues);
} else {
issues.push({
filePath: normalizedFilePath,
specifier: specifier.text,
reason: "unresolved star re-export",
});
}
}
continue;
}
if (!hasModifier(statement, ts.SyntaxKind.ExportKeyword)) {
continue;
}
if (
hasModifier(statement, ts.SyntaxKind.DefaultKeyword) &&
(ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement))
) {
const binding = statement.name ? localBindings.get(statement.name.text) : null;
setExplicitExport("default", binding?.kind === "value" ? binding.origins : undefined);
continue;
}
if (ts.isVariableStatement(statement)) {
const names = new Set();
for (const declaration of statement.declarationList.declarations) {
collectBindingNames(declaration.name, names);
}
for (const name of names) {
setExplicitExport(name);
}
} else if (
(ts.isFunctionDeclaration(statement) ||
ts.isClassDeclaration(statement) ||
ts.isEnumDeclaration(statement) ||
ts.isModuleDeclaration(statement)) &&
statement.name
) {
setExplicitExport(statement.name.getText(sourceFile));
}
}
const exports = new Map(explicitExports);
const starOrigins = new Map();
const ambiguous = new Set(explicitAmbiguous);
for (const targetResult of starResults) {
for (const name of targetResult.ambiguous) {
if (name !== "default" && !explicitExports.has(name)) {
ambiguous.add(name);
}
}
for (const [name, origins] of targetResult.exports) {
if (name === "default" || explicitExports.has(name) || explicitAmbiguous.has(name)) {
continue;
}
const merged = starOrigins.get(name) ?? new Set();
mergeOrigins(merged, origins);
starOrigins.set(name, merged);
}
}
for (const [name, origins] of starOrigins) {
// ESM omits a name when multiple star exports resolve to different bindings.
if (origins.size > 1) {
ambiguous.add(name);
if ([...origins].some(isOpaqueExternalOrigin)) {
issues.push({
filePath: normalizedFilePath,
specifier: name,
reason: "opaque external star collision",
});
}
} else if (!ambiguous.has(name)) {
exports.set(name, origins);
}
}
const result = { ambiguous, exports, issues };
state.visiting.delete(normalizedFilePath);
state.cache.set(normalizedFilePath, result);
return result;
}
function analyzeValueExportContract(filePath, fsImpl) {
const result = collectValueExports(filePath, fsImpl, {
cache: new Map(),
visiting: new Set(),
});
const names = [...result.exports.keys()].toSorted((left, right) => left.localeCompare(right));
return {
contract: Buffer.from(names.map((name) => `${name}\n`).join(""), "utf8"),
issues: result.issues,
};
}
/** Generates the canonical byte representation of a module's runtime value exports. */
export function generateValueExportContract(filePath, options = {}) {
const fsImpl = options.fsImpl ?? { existsSync, readFileSync };
const result = analyzeValueExportContract(filePath, fsImpl);
if (result.issues.length > 0) {
throw new Error(result.issues.map((issue) => issue.reason).join(", "));
}
return result.contract;
}
function formatContract(contract) {
return contract.toString("utf8").trim().split("\n").filter(Boolean);
}
/** Regenerates and byte-compares every script runtime/declaration value-export contract. */
export function verifyScriptDeclarationContracts(options = {}) {
const root = path.resolve(options.root ?? process.cwd());
const fsImpl = options.fsImpl ?? {
existsSync,
readFileSync,
readdirSync,
};
const files = listRepositoryFiles(root, { ...options, fsImpl });
const sourcePaths = new Set(
files
.filter((relativePath) => SCRIPT_DECLARATION_RE.test(relativePath))
.map((declarationPath) => declarationPath.slice(0, -".d.mts".length) + ".mjs"),
);
for (const sourcePath of resolveTypedScriptImports(root, files, fsImpl, Boolean(options.files))) {
sourcePaths.add(sourcePath);
}
const issues = [];
const pairs = [];
for (const sourcePath of [...sourcePaths].toSorted((left, right) => left.localeCompare(right))) {
const declarationPath = sourcePath.slice(0, -".mjs".length) + ".d.mts";
if (!fsImpl.existsSync(path.join(root, sourcePath))) {
issues.push(`${sourcePath}: missing runtime source`);
continue;
}
if (!fsImpl.existsSync(path.join(root, declarationPath))) {
issues.push(`${sourcePath}: missing ${declarationPath}`);
continue;
}
pairs.push({ sourcePath, declarationPath });
}
for (const { sourcePath, declarationPath } of pairs) {
const runtimeAnalysis = analyzeValueExportContract(path.join(root, sourcePath), fsImpl);
const declarationAnalysis = analyzeValueExportContract(
path.join(root, declarationPath),
fsImpl,
);
const analysisIssues = [...runtimeAnalysis.issues, ...declarationAnalysis.issues];
if (analysisIssues.length > 0) {
for (const issue of analysisIssues) {
const issuePath = normalizePath(path.relative(root, issue.filePath));
issues.push(`${issuePath}: ${issue.reason} ${JSON.stringify(issue.specifier)}`);
}
continue;
}
const runtimeContract = runtimeAnalysis.contract;
const declarationContract = declarationAnalysis.contract;
if (runtimeContract.equals(declarationContract)) {
continue;
}
const runtimeExports = new Set(formatContract(runtimeContract));
const declarationExports = new Set(formatContract(declarationContract));
const missing = [...runtimeExports].filter((name) => !declarationExports.has(name));
const extra = [...declarationExports].filter((name) => !runtimeExports.has(name));
issues.push(
`${declarationPath}: value-export contract drift` +
(missing.length > 0 ? `; missing ${missing.join(", ")}` : "") +
(extra.length > 0 ? `; extra ${extra.join(", ")}` : ""),
);
}
return { checked: pairs.length, issues };
}
function main() {
const result = verifyScriptDeclarationContracts();
if (result.issues.length > 0) {
for (const issue of result.issues) {
console.error(`[script-declarations] ${issue}`);
}
console.error(
"[script-declarations] update the adjacent .d.mts contract before merging script exports",
);
process.exitCode = 1;
return;
}
console.log(`[script-declarations] ${result.checked} declaration contracts match`);
}
if (isDirectRunUrl(process.argv[1], import.meta.url)) {
main();
}