mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
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
This commit is contained in:
@@ -2050,6 +2050,7 @@ jobs:
|
||||
case "$TASK" in
|
||||
guards)
|
||||
pnpm check:no-conflict-markers
|
||||
pnpm check:script-declarations
|
||||
pnpm tool-display:check
|
||||
pnpm check:host-env-policy:swift
|
||||
pnpm dup:check:coverage
|
||||
|
||||
@@ -1585,6 +1585,7 @@
|
||||
"check:opengrep-rule-metadata": "node security/opengrep/check-rule-metadata.mjs",
|
||||
"check:protocol-coverage": "node scripts/check-protocol-event-coverage.mjs",
|
||||
"check:runtime-sidecar-loaders": "node --import tsx scripts/check-runtime-sidecar-loaders.mjs",
|
||||
"check:script-declarations": "node scripts/check-script-declarations.mjs",
|
||||
"check:static-import-sccs": "pnpm check:madge-import-cycles",
|
||||
"check:temp-path-guardrails": "node --import tsx scripts/check-temp-path-guardrails.ts",
|
||||
"check:test-types": "pnpm tsgo:test",
|
||||
|
||||
@@ -506,6 +506,9 @@ export function createChangedCheckPlan(result, options = {}) {
|
||||
const lanes = result.lanes;
|
||||
const runAll = lanes.all;
|
||||
const shouldRunAndroidVersionSync = hasAndroidVersionSyncPath(result.paths);
|
||||
if (lanes.scripts || lanes.tooling || lanes.testRoot) {
|
||||
add("script declaration contracts", ["check:script-declarations"]);
|
||||
}
|
||||
|
||||
if (lanes.releaseMetadata) {
|
||||
add("release metadata guard", [
|
||||
@@ -545,7 +548,6 @@ export function createChangedCheckPlan(result, options = {}) {
|
||||
if (shouldRunControlUiI18nVerify(result.paths)) {
|
||||
addLint("Control UI i18n catalog", ["lint:ui:i18n"]);
|
||||
}
|
||||
|
||||
if (lanes.core) {
|
||||
addTypecheck("typecheck core", ["tsgo:core"]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/// <reference types="node" />
|
||||
export function generateValueExportContract(filePath: string): Buffer;
|
||||
export function verifyScriptDeclarationContracts(options?: { root?: string; files?: string[] }): {
|
||||
checked: number;
|
||||
issues: string[];
|
||||
};
|
||||
@@ -0,0 +1,697 @@
|
||||
#!/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();
|
||||
}
|
||||
@@ -110,6 +110,7 @@ export async function main(argv = process.argv.slice(2)) {
|
||||
{ name: "duplicate scan target coverage", args: ["dup:check:coverage"] },
|
||||
{ name: "npm shrinkwrap guard", args: ["deps:shrinkwrap:check"] },
|
||||
{ name: "package patch guard", args: ["deps:patches:check"] },
|
||||
{ name: "script declaration contracts", args: ["check:script-declarations"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -28,7 +28,6 @@ export function runGatewaySuspensionPostRestartClient(
|
||||
options: { statePath: string; token: string; url: string; timeoutMs?: number },
|
||||
deps?: { fetchImpl?: typeof fetch },
|
||||
): Promise<void>;
|
||||
export function responseError(method: string, response: GatewayFrame): Error;
|
||||
export function runGatewayNetworkClient(
|
||||
options: { token: string; url: string; timeoutMs?: number },
|
||||
deps?: {
|
||||
|
||||
@@ -23,12 +23,3 @@ export function resolveRemoteTargetRefSha(
|
||||
targetRef: string,
|
||||
executeGit?: (args: string[]) => string,
|
||||
): string;
|
||||
export type WorkflowRunCheckSuite = {
|
||||
status?: string;
|
||||
conclusion?: string | null;
|
||||
workflowRun?: { url?: string } | null;
|
||||
};
|
||||
export function selectWorkflowRunCheckSuite(
|
||||
nodes: WorkflowRunCheckSuite[],
|
||||
parentRunId: unknown,
|
||||
): WorkflowRunCheckSuite | undefined;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
export const PROOF_OVERRIDE_LABEL: "proof: override";
|
||||
export const PROOF_SUFFICIENT_LABEL: "proof: sufficient";
|
||||
export const NEEDS_PR_CONTEXT_LABEL: "triage: needs-pr-context";
|
||||
export const DEFAULT_GITHUB_API_TIMEOUT_MS: 30000;
|
||||
|
||||
type PullRequest = Record<string, unknown>;
|
||||
type Comment = Record<string, unknown>;
|
||||
type Evaluation = {
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
export const ACTIONS_ARTIFACT_API_VERSION: "2026-03-10";
|
||||
export const DEFAULT_MAX_ACTIONS_ARTIFACT_BYTES: number;
|
||||
export const DEFAULT_MAX_ACTIONS_ARTIFACT_EXPANDED_BYTES: number;
|
||||
|
||||
export type ArtifactArchivePolicy = {
|
||||
expectedEntries?: readonly string[];
|
||||
minEntries?: number;
|
||||
|
||||
@@ -14,6 +14,8 @@ export type BundledPluginBuildEntryParams = {
|
||||
|
||||
export const NON_PACKAGED_BUNDLED_PLUGIN_DIRS: Set<string>;
|
||||
export const DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV: string;
|
||||
export function collectPluginSourceEntries(packageJson: unknown): string[];
|
||||
export function collectTopLevelPublicSurfaceEntries(pluginDir: string): string[];
|
||||
export function collectRootPackageExcludedExtensionDirs(
|
||||
params?: BundledPluginBuildEntryParams,
|
||||
): Set<string>;
|
||||
|
||||
@@ -2,6 +2,10 @@ import type {
|
||||
ExecFileSyncOptions,
|
||||
ExecFileSyncOptionsWithBufferEncoding,
|
||||
ExecFileSyncOptionsWithStringEncoding,
|
||||
SpawnSyncOptions,
|
||||
SpawnSyncOptionsWithBufferEncoding,
|
||||
SpawnSyncOptionsWithStringEncoding,
|
||||
SpawnSyncReturns,
|
||||
} from "node:child_process";
|
||||
|
||||
export function plainGhEnv(env?: NodeJS.ProcessEnv): {
|
||||
@@ -32,4 +36,16 @@ export function execGhApiRead(
|
||||
endpoint: string,
|
||||
options?: ExecFileSyncOptions,
|
||||
): string | Uint8Array<ArrayBuffer>;
|
||||
export function spawnPlainGh(
|
||||
args: readonly string[],
|
||||
options: SpawnSyncOptionsWithStringEncoding,
|
||||
): SpawnSyncReturns<string>;
|
||||
export function spawnPlainGh(
|
||||
args: readonly string[],
|
||||
options?: SpawnSyncOptionsWithBufferEncoding,
|
||||
): SpawnSyncReturns<Buffer>;
|
||||
export function spawnPlainGh(
|
||||
args: readonly string[],
|
||||
options?: SpawnSyncOptions,
|
||||
): SpawnSyncReturns<string | Buffer>;
|
||||
export const PLAIN_GH_SYSTEM_CANDIDATES: string[];
|
||||
|
||||
@@ -1,92 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Builds the manifest for paired baseline/candidate Telegram Desktop proof artifacts.
|
||||
*/
|
||||
export function buildTelegramDesktopProofManifest({
|
||||
baseline,
|
||||
baselineRef,
|
||||
baselineSha,
|
||||
candidate,
|
||||
candidateRef,
|
||||
candidateSha,
|
||||
scenarioLabel,
|
||||
}: {
|
||||
baseline: unknown;
|
||||
baselineRef: unknown;
|
||||
baselineSha: unknown;
|
||||
candidate: unknown;
|
||||
candidateRef: unknown;
|
||||
candidateSha: unknown;
|
||||
scenarioLabel: unknown;
|
||||
}): {
|
||||
schemaVersion: number;
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
scenario: unknown;
|
||||
comparison: {
|
||||
baseline: {
|
||||
expected: string;
|
||||
status: string;
|
||||
ref?: unknown;
|
||||
sha?: unknown;
|
||||
};
|
||||
candidate: {
|
||||
expected: string;
|
||||
status: string;
|
||||
fixed: boolean;
|
||||
ref?: unknown;
|
||||
sha?: unknown;
|
||||
};
|
||||
pass: boolean;
|
||||
};
|
||||
artifacts: (
|
||||
| {
|
||||
alt: string;
|
||||
inline: boolean;
|
||||
kind: string;
|
||||
label: string;
|
||||
lane: string;
|
||||
path: string;
|
||||
targetPath: string;
|
||||
width: number;
|
||||
required?: undefined;
|
||||
}
|
||||
| {
|
||||
kind: string;
|
||||
label: string;
|
||||
lane: string;
|
||||
path: string;
|
||||
required: boolean;
|
||||
targetPath: string;
|
||||
alt?: undefined;
|
||||
inline?: undefined;
|
||||
width?: undefined;
|
||||
}
|
||||
| {
|
||||
alt: string;
|
||||
inline: boolean;
|
||||
kind: string;
|
||||
label: string;
|
||||
lane: string;
|
||||
path: string;
|
||||
required: boolean;
|
||||
targetPath: string;
|
||||
width?: undefined;
|
||||
}
|
||||
| {
|
||||
kind: string;
|
||||
label: string;
|
||||
lane: string;
|
||||
path: string;
|
||||
targetPath: string;
|
||||
alt?: undefined;
|
||||
inline?: undefined;
|
||||
width?: undefined;
|
||||
required?: undefined;
|
||||
}
|
||||
)[];
|
||||
};
|
||||
export function writeTelegramDesktopProofEvidence(rawArgs?: string[]): {
|
||||
manifest: {
|
||||
schemaVersion: number;
|
||||
|
||||
@@ -150,8 +150,6 @@ export function createGatewayClient({
|
||||
request: (method: unknown, params: unknown, timeoutMs?: number) => Promise<unknown>;
|
||||
waitOpen: () => Promise<unknown>;
|
||||
};
|
||||
/** Maximum time to wait for a spawned gateway to become reachable. */
|
||||
export const READY_TIMEOUT_MS: 120000;
|
||||
declare function defaultKillProcess(pid: unknown, signal: unknown): boolean;
|
||||
declare function defaultRunTaskkill(
|
||||
command: string,
|
||||
|
||||
@@ -48,5 +48,3 @@ export function resolveOpenClawNpmResumeRun(options: {
|
||||
runId: string;
|
||||
runGh?: (args: string[]) => string;
|
||||
}): OpenClawNpmResumeIdentity;
|
||||
|
||||
export function main(argv?: string[]): void;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
export function usage(): string;
|
||||
export function shouldPrintHelp(argv: unknown): boolean;
|
||||
/**
|
||||
* Parses CPU profile file paths and --limit.
|
||||
@@ -8,4 +7,3 @@ export function parseArgs(argv: unknown): {
|
||||
files: unknown[];
|
||||
limit: number;
|
||||
};
|
||||
export function summarizeProfile(file: unknown, limit: unknown): void;
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
export function githubReleaseBodySize(body: unknown): {
|
||||
characters: number;
|
||||
bytes: number;
|
||||
};
|
||||
export function fitsGithubReleaseBody(body: unknown): boolean;
|
||||
export function extractChangelogReleaseSections(changelog: string): {
|
||||
version: string;
|
||||
source: string;
|
||||
@@ -12,7 +7,6 @@ export function extractChangelogSection(changelog: unknown, version: unknown): u
|
||||
export function releaseNotesVersionForTag(tag: unknown): unknown;
|
||||
export function formatShippedBaselineExclusions(baselines: ShippedBaselineExclusion[]): string;
|
||||
export function parseShippedBaselineExclusions(section: string): ShippedBaselineExclusion[];
|
||||
export function tagPinnedContributionRecordUrl(repository: unknown, tag: unknown): string;
|
||||
export function dedicatedSectionVersionForTag(tag: unknown): unknown;
|
||||
export function releaseNotesSectionForTag(
|
||||
changelog: unknown,
|
||||
|
||||
@@ -17,4 +17,3 @@ export function collectTempCreationFindingsFromDiff(
|
||||
source: unknown;
|
||||
}
|
||||
)[];
|
||||
export function main(argv: unknown, io: unknown): Promise<1 | 0>;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const runNodeWatchedPaths: string[];
|
||||
export function isBuildRelevantRunNodePath(repoPath: string): boolean;
|
||||
export function isRestartRelevantRunNodePath(repoPath: string): boolean;
|
||||
export function listRequiredRuntimePostBuildOutputs(deps: Record<string, unknown>): string[];
|
||||
export function resolveBuildRequirement(deps: {
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
|
||||
@@ -70,14 +70,6 @@ export function buildVitestRunPlans(
|
||||
|
||||
export function buildFullSuiteVitestRunPlans(args: string[], cwd?: string): VitestRunPlan[];
|
||||
|
||||
export function shouldUseLocalFullSuiteParallelByDefault(
|
||||
env?: Record<string, string | undefined>,
|
||||
): boolean;
|
||||
|
||||
export function shouldExpandLocalFullSuiteShardsByDefault(
|
||||
env?: Record<string, string | undefined>,
|
||||
): boolean;
|
||||
|
||||
export function resolveParallelFullSuiteConcurrency(
|
||||
specCount: number,
|
||||
env?: Record<string, string | undefined>,
|
||||
|
||||
@@ -1,17 +1,3 @@
|
||||
export function buildTestboxLeaseFingerprint(
|
||||
repoRoot: unknown,
|
||||
args: unknown,
|
||||
): {
|
||||
version: number;
|
||||
baseSha: string;
|
||||
headSha: string;
|
||||
workingTreeClean: boolean;
|
||||
dependencyDigest: string;
|
||||
environmentDigest: string;
|
||||
workflow: unknown;
|
||||
job: unknown;
|
||||
ref: unknown;
|
||||
};
|
||||
export function testboxLeaseStaleReasons(saved: unknown, current: unknown): string[];
|
||||
export function prepareTestboxLeaseFreshness({
|
||||
args,
|
||||
|
||||
@@ -69,6 +69,5 @@ export function workflowRunQueryPaths(
|
||||
},
|
||||
page?: number,
|
||||
): string[];
|
||||
export function main(argv?: string[]): void;
|
||||
export const SCHEDULED_HOSTED_WORKFLOWS: string[];
|
||||
export const HOSTED_GATE_MAX_AGE_HOURS: 24;
|
||||
|
||||
@@ -883,6 +883,15 @@ describe("scripts/changed-lanes", () => {
|
||||
|
||||
expect(result.lanes.scripts).toBe(true);
|
||||
expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:scripts");
|
||||
expect(plan.commands.map((command) => command.args[0])).toContain("check:script-declarations");
|
||||
});
|
||||
|
||||
it("keeps the declaration guard when another change selects the full lane", () => {
|
||||
const result = detectChangedLanes(["package.json", "scripts/example.mjs"]);
|
||||
const plan = createChangedCheckPlan(result);
|
||||
|
||||
expect(result.lanes.all).toBe(true);
|
||||
expect(plan.commands.map((command) => command.args[0])).toContain("check:script-declarations");
|
||||
});
|
||||
|
||||
it("routes Control UI i18n tooling changes through keyless catalog verification", () => {
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { verifyScriptDeclarationContracts } from "../../scripts/check-script-declarations.mjs";
|
||||
|
||||
describe("script declaration contracts", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const tempDir of tempDirs.splice(0)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("fails deliberate export drift and passes after regenerating the declaration", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-script-declarations-"));
|
||||
tempDirs.push(root);
|
||||
fs.mkdirSync(path.join(root, "scripts"), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, "test"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "example.mjs"),
|
||||
"export function stable() {}\nexport function added() {}\n",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "example.d.mts"),
|
||||
"export function stable(): void;\n",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "test", "consumer.ts"),
|
||||
'import { added } from "../scripts/example.mjs";\nvoid added;\n',
|
||||
);
|
||||
const files = ["scripts/example.d.mts", "scripts/example.mjs", "test/consumer.ts"];
|
||||
|
||||
expect(verifyScriptDeclarationContracts({ root, files })).toEqual({
|
||||
checked: 1,
|
||||
issues: ["scripts/example.d.mts: value-export contract drift; missing added"],
|
||||
});
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "example.d.mts"),
|
||||
"export function added(): void;\nexport function stable(): void;\n",
|
||||
);
|
||||
|
||||
expect(verifyScriptDeclarationContracts({ root, files })).toEqual({ checked: 1, issues: [] });
|
||||
});
|
||||
|
||||
it("requires declarations for scripts imported by typed sources", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-script-declarations-"));
|
||||
tempDirs.push(root);
|
||||
fs.mkdirSync(path.join(root, "scripts"), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, "test"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "scripts", "missing.mjs"), "export const value = 1;\n");
|
||||
fs.writeFileSync(
|
||||
path.join(root, "test", "consumer.ts"),
|
||||
'import { value } from "../scripts/missing.mjs";\nvoid value;\n',
|
||||
);
|
||||
|
||||
expect(
|
||||
verifyScriptDeclarationContracts({
|
||||
root,
|
||||
files: ["scripts/missing.mjs", "test/consumer.ts"],
|
||||
}),
|
||||
).toEqual({
|
||||
checked: 0,
|
||||
issues: ["scripts/missing.mjs: missing scripts/missing.d.mts"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects declaration sidecars whose runtime script is missing", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-script-declarations-"));
|
||||
tempDirs.push(root);
|
||||
fs.mkdirSync(path.join(root, "scripts"), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, "test"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "scripts", "orphan.d.mts"), "export const value: 1;\n");
|
||||
fs.writeFileSync(
|
||||
path.join(root, "test", "consumer.ts"),
|
||||
'import { value } from "../scripts/orphan.mjs";\nvoid value;\n',
|
||||
);
|
||||
|
||||
expect(
|
||||
verifyScriptDeclarationContracts({
|
||||
root,
|
||||
files: ["scripts/orphan.d.mts", "test/consumer.ts"],
|
||||
}),
|
||||
).toEqual({
|
||||
checked: 0,
|
||||
issues: ["scripts/orphan.mjs: missing runtime source"],
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores tracked declaration pairs deleted from the working tree", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-script-declarations-"));
|
||||
tempDirs.push(root);
|
||||
fs.mkdirSync(path.join(root, "scripts"), { recursive: true });
|
||||
execFileSync("git", ["init", "-q"], { cwd: root });
|
||||
const runtimePath = path.join(root, "scripts", "removed.mjs");
|
||||
const declarationPath = path.join(root, "scripts", "removed.d.mts");
|
||||
fs.writeFileSync(runtimePath, "export const value = 1;\n");
|
||||
fs.writeFileSync(declarationPath, "export const value: 1;\n");
|
||||
execFileSync("git", ["add", "scripts/removed.mjs", "scripts/removed.d.mts"], { cwd: root });
|
||||
fs.rmSync(runtimePath);
|
||||
fs.rmSync(declarationPath);
|
||||
|
||||
expect(verifyScriptDeclarationContracts({ root })).toEqual({ checked: 0, issues: [] });
|
||||
});
|
||||
|
||||
it("applies ESM default and ambiguity rules to star re-exports", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-script-declarations-"));
|
||||
tempDirs.push(root);
|
||||
fs.mkdirSync(path.join(root, "scripts"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "left.mjs"),
|
||||
"export default 1;\nexport const shared = 1;\nexport const left = 1;\n",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "right.mjs"),
|
||||
"export const shared = 2;\nexport const right = 1;\n",
|
||||
);
|
||||
fs.writeFileSync(path.join(root, "scripts", "left-alias.mjs"), 'export * from "./left.mjs";\n');
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "barrel.mjs"),
|
||||
'export * from "./left.mjs";\nexport * from "./left-alias.mjs";\nexport * from "./right.mjs";\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "barrel.d.mts"),
|
||||
"export const left: 1;\nexport const right: 1;\n",
|
||||
);
|
||||
|
||||
expect(
|
||||
verifyScriptDeclarationContracts({
|
||||
root,
|
||||
files: [
|
||||
"scripts/barrel.d.mts",
|
||||
"scripts/barrel.mjs",
|
||||
"scripts/left-alias.mjs",
|
||||
"scripts/left.mjs",
|
||||
"scripts/right.mjs",
|
||||
],
|
||||
}),
|
||||
).toEqual({ checked: 1, issues: [] });
|
||||
});
|
||||
|
||||
it("propagates ambiguity through nested star re-exports", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-script-declarations-"));
|
||||
tempDirs.push(root);
|
||||
fs.mkdirSync(path.join(root, "scripts"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "scripts", "left.mjs"), "export const shared = 1;\n");
|
||||
fs.writeFileSync(path.join(root, "scripts", "right.mjs"), "export const shared = 2;\n");
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "inner.mjs"),
|
||||
'export * from "./left.mjs";\nexport * from "./right.mjs";\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "outer.mjs"),
|
||||
'export * from "./inner.mjs";\nexport * from "./left.mjs";\n',
|
||||
);
|
||||
fs.writeFileSync(path.join(root, "scripts", "outer.d.mts"), "export {};\n");
|
||||
|
||||
expect(
|
||||
verifyScriptDeclarationContracts({
|
||||
root,
|
||||
files: [
|
||||
"scripts/inner.mjs",
|
||||
"scripts/left.mjs",
|
||||
"scripts/outer.d.mts",
|
||||
"scripts/outer.mjs",
|
||||
"scripts/right.mjs",
|
||||
],
|
||||
}),
|
||||
).toEqual({ checked: 1, issues: [] });
|
||||
});
|
||||
|
||||
it("fails closed on cyclic star graphs", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-script-declarations-"));
|
||||
tempDirs.push(root);
|
||||
fs.mkdirSync(path.join(root, "scripts"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "scripts", "a.mjs"), 'export * from "./b.mjs";\n');
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "b.mjs"),
|
||||
'export const value = 1;\nexport * from "./a.mjs";\n',
|
||||
);
|
||||
fs.writeFileSync(path.join(root, "scripts", "a.d.mts"), "export const value: 1;\n");
|
||||
fs.writeFileSync(path.join(root, "scripts", "b.d.mts"), "export const value: 1;\n");
|
||||
|
||||
const result = verifyScriptDeclarationContracts({
|
||||
root,
|
||||
files: ["scripts/a.d.mts", "scripts/a.mjs", "scripts/b.d.mts", "scripts/b.mjs"],
|
||||
});
|
||||
expect(result.checked).toBe(2);
|
||||
expect(result.issues).toHaveLength(2);
|
||||
expect(result.issues.every((issue) => issue.includes("cyclic star re-export"))).toBe(true);
|
||||
});
|
||||
|
||||
it("fails closed on explicit re-exports of ambiguous bindings", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-script-declarations-"));
|
||||
tempDirs.push(root);
|
||||
fs.mkdirSync(path.join(root, "scripts"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "scripts", "left.mjs"), "export const shared = 1;\n");
|
||||
fs.writeFileSync(path.join(root, "scripts", "right.mjs"), "export const shared = 2;\n");
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "inner.mjs"),
|
||||
'export * from "./left.mjs";\nexport * from "./right.mjs";\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "barrel.mjs"),
|
||||
'export { shared } from "./inner.mjs";\n',
|
||||
);
|
||||
fs.writeFileSync(path.join(root, "scripts", "barrel.d.mts"), "export {};\n");
|
||||
|
||||
expect(
|
||||
verifyScriptDeclarationContracts({
|
||||
root,
|
||||
files: [
|
||||
"scripts/barrel.d.mts",
|
||||
"scripts/barrel.mjs",
|
||||
"scripts/inner.mjs",
|
||||
"scripts/left.mjs",
|
||||
"scripts/right.mjs",
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
checked: 1,
|
||||
issues: ['scripts/barrel.mjs: ambiguous named re-export "./inner.mjs:shared"'],
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when a star re-export cannot be resolved", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-script-declarations-"));
|
||||
tempDirs.push(root);
|
||||
fs.mkdirSync(path.join(root, "scripts"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "scripts", "barrel.mjs"), 'export * from "package";\n');
|
||||
fs.writeFileSync(path.join(root, "scripts", "barrel.d.mts"), "export {};\n");
|
||||
|
||||
expect(
|
||||
verifyScriptDeclarationContracts({
|
||||
root,
|
||||
files: ["scripts/barrel.d.mts", "scripts/barrel.mjs"],
|
||||
}),
|
||||
).toEqual({
|
||||
checked: 1,
|
||||
issues: ['scripts/barrel.mjs: unresolved star re-export "package"'],
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves declaration stars to declarations before runtime modules", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-script-declarations-"));
|
||||
tempDirs.push(root);
|
||||
fs.mkdirSync(path.join(root, "scripts"), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, "lib"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, "lib", "helper.mjs"),
|
||||
"export const one = 1;\nexport const two = 2;\n",
|
||||
);
|
||||
fs.writeFileSync(path.join(root, "lib", "helper.d.mts"), "export const one: 1;\n");
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "barrel.mjs"),
|
||||
'export * from "../lib/helper.mjs";\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "barrel.d.mts"),
|
||||
'export * from "../lib/helper.mjs";\n',
|
||||
);
|
||||
|
||||
expect(
|
||||
verifyScriptDeclarationContracts({
|
||||
root,
|
||||
files: ["lib/helper.d.mts", "lib/helper.mjs", "scripts/barrel.d.mts", "scripts/barrel.mjs"],
|
||||
}),
|
||||
).toEqual({
|
||||
checked: 1,
|
||||
issues: ["scripts/barrel.d.mts: value-export contract drift; missing two"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not count type-only named or default declaration exports", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-script-declarations-"));
|
||||
tempDirs.push(root);
|
||||
fs.mkdirSync(path.join(root, "scripts"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "types.mjs"),
|
||||
"export const Foo = 1;\nexport default 1;\n",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "types.d.mts"),
|
||||
"interface Foo {}\nexport { Foo };\nexport default interface Default {}\n",
|
||||
);
|
||||
|
||||
expect(
|
||||
verifyScriptDeclarationContracts({
|
||||
root,
|
||||
files: ["scripts/types.d.mts", "scripts/types.mjs"],
|
||||
}),
|
||||
).toEqual({
|
||||
checked: 1,
|
||||
issues: ["scripts/types.d.mts: value-export contract drift; missing default, Foo"],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves local binding identity through renamed star paths", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-script-declarations-"));
|
||||
tempDirs.push(root);
|
||||
fs.mkdirSync(path.join(root, "scripts"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "origin.mjs"),
|
||||
"const value = 1;\nexport { value as left, value as right };\n",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "left.mjs"),
|
||||
'export { left as shared } from "./origin.mjs";\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "right.mjs"),
|
||||
'export { right as shared } from "./origin.mjs";\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "barrel.mjs"),
|
||||
'export * from "./left.mjs";\nexport * from "./right.mjs";\n',
|
||||
);
|
||||
fs.writeFileSync(path.join(root, "scripts", "barrel.d.mts"), "export const shared: 1;\n");
|
||||
|
||||
expect(
|
||||
verifyScriptDeclarationContracts({
|
||||
root,
|
||||
files: [
|
||||
"scripts/barrel.d.mts",
|
||||
"scripts/barrel.mjs",
|
||||
"scripts/left.mjs",
|
||||
"scripts/origin.mjs",
|
||||
"scripts/right.mjs",
|
||||
],
|
||||
}),
|
||||
).toEqual({ checked: 1, issues: [] });
|
||||
});
|
||||
|
||||
it("preserves statically named exports from external modules", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-script-declarations-"));
|
||||
tempDirs.push(root);
|
||||
fs.mkdirSync(path.join(root, "scripts"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "external.mjs"),
|
||||
'import { readFile } from "fs/promises";\nexport { readFile };\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "external.d.mts"),
|
||||
"export declare const readFile: unknown;\n",
|
||||
);
|
||||
|
||||
expect(
|
||||
verifyScriptDeclarationContracts({
|
||||
root,
|
||||
files: ["scripts/external.d.mts", "scripts/external.mjs"],
|
||||
}),
|
||||
).toEqual({ checked: 1, issues: [] });
|
||||
});
|
||||
|
||||
it("fails closed on external declaration re-exports with unknown value provenance", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-script-declarations-"));
|
||||
tempDirs.push(root);
|
||||
fs.mkdirSync(path.join(root, "scripts"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "scripts", "external.mjs"), "export const Foo = 1;\n");
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "external.d.mts"),
|
||||
'export { Foo } from "types-only-package";\n',
|
||||
);
|
||||
|
||||
expect(
|
||||
verifyScriptDeclarationContracts({
|
||||
root,
|
||||
files: ["scripts/external.d.mts", "scripts/external.mjs"],
|
||||
}),
|
||||
).toEqual({
|
||||
checked: 1,
|
||||
issues: [
|
||||
'scripts/external.d.mts: unresolved external declaration re-export "types-only-package"',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed on re-exported external declaration imports", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-script-declarations-"));
|
||||
tempDirs.push(root);
|
||||
fs.mkdirSync(path.join(root, "scripts"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "scripts", "external.mjs"), "export const Foo = 1;\n");
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "external.d.mts"),
|
||||
'import { Foo } from "types-only-package";\nexport { Foo };\n',
|
||||
);
|
||||
|
||||
expect(
|
||||
verifyScriptDeclarationContracts({
|
||||
root,
|
||||
files: ["scripts/external.d.mts", "scripts/external.mjs"],
|
||||
}),
|
||||
).toEqual({
|
||||
checked: 1,
|
||||
issues: [
|
||||
'scripts/external.d.mts: unresolved external declaration import "types-only-package"',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts statically value-bearing external namespace declaration imports", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-script-declarations-"));
|
||||
tempDirs.push(root);
|
||||
fs.mkdirSync(path.join(root, "scripts"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "external.mjs"),
|
||||
'import * as pkg from "pkg";\nexport { pkg };\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "external.d.mts"),
|
||||
'import * as pkg from "pkg";\nexport { pkg };\n',
|
||||
);
|
||||
|
||||
expect(
|
||||
verifyScriptDeclarationContracts({
|
||||
root,
|
||||
files: ["scripts/external.d.mts", "scripts/external.mjs"],
|
||||
}),
|
||||
).toEqual({ checked: 1, issues: [] });
|
||||
});
|
||||
|
||||
it("retains runtime exports imported from opaque local modules", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-script-declarations-"));
|
||||
tempDirs.push(root);
|
||||
fs.mkdirSync(path.join(root, "scripts"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "scripts", "config.json"), '{"enabled":true}\n');
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "config.mjs"),
|
||||
'import config from "./config.json" with { type: "json" };\nexport { config };\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "config.d.mts"),
|
||||
"export declare const config: { enabled: boolean };\n",
|
||||
);
|
||||
|
||||
expect(
|
||||
verifyScriptDeclarationContracts({
|
||||
root,
|
||||
files: ["scripts/config.d.mts", "scripts/config.json", "scripts/config.mjs"],
|
||||
}),
|
||||
).toEqual({ checked: 1, issues: [] });
|
||||
});
|
||||
|
||||
it("fails closed on missing exports from analyzable local modules", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-script-declarations-"));
|
||||
tempDirs.push(root);
|
||||
fs.mkdirSync(path.join(root, "scripts"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "scripts", "origin.mjs"), "export const value = 1;\n");
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "barrel.mjs"),
|
||||
'import { typo } from "./origin.mjs";\nexport { typo };\n',
|
||||
);
|
||||
fs.writeFileSync(path.join(root, "scripts", "barrel.d.mts"), "export const typo: 1;\n");
|
||||
|
||||
expect(
|
||||
verifyScriptDeclarationContracts({
|
||||
root,
|
||||
files: ["scripts/barrel.d.mts", "scripts/barrel.mjs", "scripts/origin.mjs"],
|
||||
}),
|
||||
).toEqual({
|
||||
checked: 1,
|
||||
issues: ['scripts/barrel.mjs: unresolved imported value "./origin.mjs:typo"'],
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed on named JSON imports", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-script-declarations-"));
|
||||
tempDirs.push(root);
|
||||
fs.mkdirSync(path.join(root, "scripts"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "scripts", "config.json"), '{"enabled":true}\n');
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "config.mjs"),
|
||||
'import { enabled } from "./config.json" with { type: "json" };\nexport { enabled };\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "config.d.mts"),
|
||||
"export declare const enabled: boolean;\n",
|
||||
);
|
||||
|
||||
expect(
|
||||
verifyScriptDeclarationContracts({
|
||||
root,
|
||||
files: ["scripts/config.d.mts", "scripts/config.json", "scripts/config.mjs"],
|
||||
}),
|
||||
).toEqual({
|
||||
checked: 1,
|
||||
issues: ['scripts/config.mjs: unresolved imported value "./config.json:enabled"'],
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves CommonJS imports to adjacent declaration sidecars", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-script-declarations-"));
|
||||
tempDirs.push(root);
|
||||
fs.mkdirSync(path.join(root, "scripts"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "scripts", "helper.cjs"), "exports.value = 1;\n");
|
||||
fs.writeFileSync(path.join(root, "scripts", "helper.d.cts"), "export const value: 1;\n");
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "barrel.mjs"),
|
||||
'export { value } from "./helper.cjs";\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "barrel.d.mts"),
|
||||
'export { value } from "./helper.cjs";\n',
|
||||
);
|
||||
|
||||
expect(
|
||||
verifyScriptDeclarationContracts({
|
||||
root,
|
||||
files: [
|
||||
"scripts/barrel.d.mts",
|
||||
"scripts/barrel.mjs",
|
||||
"scripts/helper.cjs",
|
||||
"scripts/helper.d.cts",
|
||||
],
|
||||
}),
|
||||
).toEqual({ checked: 1, issues: [] });
|
||||
});
|
||||
|
||||
it("distinguishes namespace objects from named external bindings", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-script-declarations-"));
|
||||
tempDirs.push(root);
|
||||
fs.mkdirSync(path.join(root, "scripts"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "namespace.mjs"),
|
||||
'export * as shared from "pkg";\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "named.mjs"),
|
||||
'export { namespace as shared } from "pkg";\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "scripts", "barrel.mjs"),
|
||||
'export * from "./namespace.mjs";\nexport * from "./named.mjs";\n',
|
||||
);
|
||||
fs.writeFileSync(path.join(root, "scripts", "barrel.d.mts"), "export {};\n");
|
||||
|
||||
expect(
|
||||
verifyScriptDeclarationContracts({
|
||||
root,
|
||||
files: [
|
||||
"scripts/barrel.d.mts",
|
||||
"scripts/barrel.mjs",
|
||||
"scripts/named.mjs",
|
||||
"scripts/namespace.mjs",
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
checked: 1,
|
||||
issues: ['scripts/barrel.mjs: opaque external star collision "shared"'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3196,6 +3196,7 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
|
||||
|
||||
expect(workflow).toContain("check-guards");
|
||||
expect(workflow).toContain("check-shrinkwrap");
|
||||
expect(preflightGuards).toContain("pnpm check:script-declarations");
|
||||
expect(shrinkwrapGuards).toContain("pnpm deps:shrinkwrap:check");
|
||||
expect(preflightGuards).toContain("pnpm deps:patches:check");
|
||||
expect(parsedWorkflow.jobs.preflight.outputs.diff_base_revision).toBe(
|
||||
|
||||
Reference in New Issue
Block a user