mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 23:11:35 +00:00
* 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
698 lines
23 KiB
JavaScript
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();
|
|
}
|