Files
openclaw/scripts/check-coercion-helper-declarations.mts
T
2026-08-11 10:22:01 -07:00

484 lines
16 KiB
TypeScript

#!/usr/bin/env node
// Prevents local primitive-coercion helpers from regrowing after consolidation.
import fs from "node:fs";
import path from "node:path";
import ts from "typescript";
import { isCodeFile, listRepoFilesSync } from "./check-file-utils.js";
import { isDirectRunUrl } from "./lib/direct-run.mjs";
import { runWithFailedTrailer } from "./lib/failed-trailer.mts";
import { resolveRepoRoot } from "./lib/repo-root.mjs";
import { toLine, unwrapExpression } from "./lib/ts-guard-utils.mts";
export const BANNED_COERCION_HELPER_NAMES = [
"asObject",
"asFiniteNumber",
"asNonArrayRecord",
"asNonNegativeFiniteNumber",
"asNullableRecord",
"asOptionalRecord",
"asPositiveFiniteNumber",
"asRecord",
"asString",
"coerceErrorMessage",
"isRecord",
"isStringRecord",
"normalizeBoundedOptionalString",
"normalizeOptionalLowercaseString",
"normalizeOptionalString",
"normalizeString",
"optionalString",
"parseBooleanValue",
"parseDateFirstTimestampMs",
"parseDateStringTimestampMs",
"parseFiniteNumber",
"readBoolean",
"readNonBlankString",
"readNonEmptyStringPreservingWhitespace",
"readNumber",
"readOptionalString",
"readString",
"readStringField",
"readStringValue",
"timestampMs",
"toError",
"toLintErrorObject",
"toErrorObject",
] as const;
export type BannedCoercionHelperName = (typeof BANNED_COERCION_HELPER_NAMES)[number];
const BANNED_HELPER_NAMES: ReadonlySet<string> = new Set(BANNED_COERCION_HELPER_NAMES);
// One tracked-tree scan covers root configs plus config, Actions, skills, apps, plugins, and packages.
const SCAN_ROOTS = ["."];
const GENERATED_OR_FIXTURE_PATH_RE =
/(?:^|\/)(?:\.generated|__generated__|build|coverage|dist|generated|fixtures|node_modules|test-fixtures|vendor)(?:\/|$)|\.generated\.[^/]+$|\.(?:bundle|min)\.[cm]?[jt]sx?$/u;
export type CoercionHelperDeclaration = {
file: string;
kind: "field" | "function" | "method" | "property" | "variable";
line: number;
name: BannedCoercionHelperName;
};
export type CoercionHelperCarveOut = {
count: number;
file: string;
name: BannedCoercionHelperName;
reason: string;
};
function canonicalOwnerCarveOuts(
file: string,
names: readonly BannedCoercionHelperName[],
): CoercionHelperCarveOut[] {
return names.map((name) => ({
file,
name,
count: 1,
reason: "Canonical coercion helper owned by this module.",
}));
}
export const COERCION_HELPER_CARVE_OUTS: readonly CoercionHelperCarveOut[] = [
...canonicalOwnerCarveOuts("packages/normalization-core/src/string-coerce.ts", [
"normalizeBoundedOptionalString",
"normalizeOptionalLowercaseString",
"normalizeOptionalString",
"readNonBlankString",
"readNonEmptyStringPreservingWhitespace",
"readStringValue",
]),
...canonicalOwnerCarveOuts("packages/normalization-core/src/number-coercion.ts", [
"asFiniteNumber",
"asNonNegativeFiniteNumber",
"asPositiveFiniteNumber",
"parseDateFirstTimestampMs",
"parseDateStringTimestampMs",
"parseFiniteNumber",
]),
...canonicalOwnerCarveOuts("packages/normalization-core/src/record-coerce.ts", [
"asNonArrayRecord",
"asNullableRecord",
"asOptionalRecord",
"asRecord",
"isRecord",
"isStringRecord",
"readStringField",
]),
...canonicalOwnerCarveOuts("packages/normalization-core/src/error-coercion.ts", [
"coerceErrorMessage",
"toErrorObject",
]),
...canonicalOwnerCarveOuts("scripts/lib/error-format.mts", [
"coerceErrorMessage",
"toErrorObject",
]),
...canonicalOwnerCarveOuts("src/utils/boolean.ts", ["parseBooleanValue"]),
{
file: "ui/src/test-helpers/control-ui-e2e.ts",
name: "isRecord",
count: 1,
reason: "Serialized mock Gateway closure cannot capture module imports.",
},
{
file: "scripts/check-built-plugin-control-plane-modules.mts",
name: "isRecord",
count: 1,
reason: "Copied standalone build guard cannot rely on workspace package resolution.",
},
{
file: "scripts/copy-bundled-plugin-metadata.mts",
name: "isRecord",
count: 1,
reason: "Copied standalone metadata closure cannot rely on workspace package resolution.",
},
{
file: "scripts/lib/kova-report-gate.mts",
name: "isRecord",
count: 1,
reason: "Copied standalone report gate cannot rely on workspace package resolution.",
},
{
file: "scripts/lib/plugin-npm-package-manifest.mts",
name: "isRecord",
count: 1,
reason: "Copied standalone package-manifest closure cannot resolve workspace packages.",
},
{
file: "scripts/lib/record-shared.mjs",
name: "isRecord",
count: 1,
reason: "Plain-Node shared helper serves MJS and E2E callers without package resolution.",
},
{
file: "scripts/lib/static-extension-assets.mts",
name: "asRecord",
count: 1,
reason: "Copied standalone asset closure cannot rely on workspace package resolution.",
},
{
file: "scripts/pr-lib/process-group-runner.mjs",
name: "toError",
count: 1,
reason:
"Bootstrap process supervisor preserves fallback errors without workspace dependencies.",
},
{
file: "scripts/lib/bounded-response.mjs",
name: "toLintErrorObject",
count: 1,
reason: "Standalone copied response reader cannot resolve workspace packages.",
},
{
file: "scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs",
name: "toLintErrorObject",
count: 1,
reason: "Installed-image runtime smoke runs as a copied standalone closure.",
},
{
file: "scripts/e2e/lib/openai-web-search-minimal/client.mjs",
name: "toLintErrorObject",
count: 1,
reason: "Minimal copied E2E client runs without workspace package resolution.",
},
{
file: "scripts/stage-bundled-plugin-runtime.mts",
name: "isRecord",
count: 1,
reason: "Copied standalone runtime-staging closure cannot resolve workspace packages.",
},
];
type CarveOutMismatch = CoercionHelperCarveOut & {
actualCount: number;
lines: number[];
};
type CoercionHelperAudit = {
excessDeclarations: CoercionHelperDeclaration[];
invalidCarveOuts: string[];
staleCarveOuts: CarveOutMismatch[];
};
type ScriptIo = {
stderr: { write(value: string): unknown };
stdout: { write(value: string): unknown };
};
function carveOutKey(entry: Pick<CoercionHelperCarveOut, "file" | "name">) {
return `${entry.file}\0${entry.name}`;
}
function unwrapCallableInitializer(expression: ts.Expression) {
let current = unwrapExpression(expression);
while (ts.isSatisfiesExpression(current)) {
current = unwrapExpression(current.expression);
}
return current;
}
/** Returns true for tracked source files governed by the declaration guard. */
export function isGovernedCoercionHelperPath(filePath: string) {
return (
isCodeFile(filePath) &&
!/\.d\.[cm]?ts$/u.test(filePath) &&
!GENERATED_OR_FIXTURE_PATH_RE.test(filePath)
);
}
function propertyNameText(name: ts.PropertyName | undefined): string | undefined {
if (!name) {
return undefined;
}
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
return name.text;
}
return undefined;
}
function isCallableInitializer(expression: ts.Expression): boolean {
const initializer = unwrapCallableInitializer(expression);
return ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer);
}
function unwrapDirectAliasInitializer(expression: ts.Expression): ts.Expression | undefined {
let current = expression;
while (true) {
if (ts.isParenthesizedExpression(current) || ts.isNonNullExpression(current)) {
current = current.expression;
continue;
}
if (ts.isSatisfiesExpression(current)) {
current = current.expression;
continue;
}
if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) {
return undefined;
}
return current;
}
}
/** Finds banned callable declarations in one source file. */
export function findBannedCoercionHelperDeclarations(
source: string,
file = "source.ts",
): CoercionHelperDeclaration[] {
if (![...BANNED_HELPER_NAMES].some((name) => source.includes(name))) {
return [];
}
const scriptKind = file.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, scriptKind);
const declarations: CoercionHelperDeclaration[] = [];
const visit = (node: ts.Node): void => {
if (ts.isFunctionDeclaration(node) && node.name && BANNED_HELPER_NAMES.has(node.name.text)) {
declarations.push({
file,
kind: "function",
line: toLine(sourceFile, node.name),
name: node.name.text as BannedCoercionHelperName,
});
} else if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
BANNED_HELPER_NAMES.has(node.name.text) &&
node.initializer
) {
const aliasInitializer = unwrapDirectAliasInitializer(node.initializer);
if (
isCallableInitializer(node.initializer) ||
(aliasInitializer !== undefined &&
(ts.isIdentifier(aliasInitializer) || ts.isPropertyAccessExpression(aliasInitializer)))
) {
declarations.push({
file,
kind: "variable",
line: toLine(sourceFile, node.name),
name: node.name.text as BannedCoercionHelperName,
});
}
} else if (ts.isMethodDeclaration(node)) {
const name = propertyNameText(node.name);
if (name && BANNED_HELPER_NAMES.has(name)) {
declarations.push({
file,
kind: "method",
line: toLine(sourceFile, node.name),
name: name as BannedCoercionHelperName,
});
}
} else if (ts.isPropertyDeclaration(node) && node.initializer) {
const name = propertyNameText(node.name);
if (name && BANNED_HELPER_NAMES.has(name) && isCallableInitializer(node.initializer)) {
declarations.push({
file,
kind: "field",
line: toLine(sourceFile, node.name),
name: name as BannedCoercionHelperName,
});
}
} else if (ts.isPropertyAssignment(node)) {
const name = propertyNameText(node.name);
if (name && BANNED_HELPER_NAMES.has(name) && isCallableInitializer(node.initializer)) {
declarations.push({
file,
kind: "property",
line: toLine(sourceFile, node.name),
name: name as BannedCoercionHelperName,
});
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return declarations;
}
/** Checks exact file/name/count carve-outs and rejects stale or excess entries. */
export function auditCoercionHelperDeclarations(
declarations: readonly CoercionHelperDeclaration[],
carveOuts: readonly CoercionHelperCarveOut[],
): CoercionHelperAudit {
const invalidCarveOuts: string[] = [];
const carveOutByKey = new Map<string, CoercionHelperCarveOut>();
for (const carveOut of carveOuts) {
const key = carveOutKey(carveOut);
if (carveOutByKey.has(key)) {
invalidCarveOuts.push(`${carveOut.file} [${carveOut.name}] is listed more than once`);
continue;
}
if (!BANNED_HELPER_NAMES.has(carveOut.name)) {
invalidCarveOuts.push(`${carveOut.file} [${carveOut.name}] is not a banned helper name`);
}
if (!Number.isInteger(carveOut.count) || carveOut.count < 1) {
invalidCarveOuts.push(`${carveOut.file} [${carveOut.name}] must have a positive count`);
}
if (!carveOut.reason.trim()) {
invalidCarveOuts.push(`${carveOut.file} [${carveOut.name}] needs a non-empty reason`);
}
carveOutByKey.set(key, carveOut);
}
const declarationsByKey = new Map<string, CoercionHelperDeclaration[]>();
for (const declaration of declarations) {
const key = carveOutKey(declaration);
const current = declarationsByKey.get(key) ?? [];
current.push(declaration);
declarationsByKey.set(key, current);
}
const excessDeclarations: CoercionHelperDeclaration[] = [];
for (const [key, actual] of declarationsByKey) {
const allowedCount = carveOutByKey.get(key)?.count ?? 0;
if (actual.length > allowedCount) {
excessDeclarations.push(...actual.slice(allowedCount));
}
}
const staleCarveOuts = carveOuts
.map((carveOut): CarveOutMismatch | null => {
const actual = declarationsByKey.get(carveOutKey(carveOut)) ?? [];
return actual.length < carveOut.count
? {
...carveOut,
actualCount: actual.length,
lines: actual.map((entry) => entry.line),
}
: null;
})
.filter((entry): entry is CarveOutMismatch => entry !== null);
return {
excessDeclarations: excessDeclarations.toSorted(
(left, right) =>
left.file.localeCompare(right.file) ||
left.line - right.line ||
left.name.localeCompare(right.name),
),
invalidCarveOuts,
staleCarveOuts,
};
}
function writeLine(stream: ScriptIo["stdout"] | ScriptIo["stderr"], value: string) {
stream.write(`${value}\n`);
}
/** Runs the full tracked-source declaration guard. */
export function runCoercionHelperDeclarationGuard(
options: {
carveOuts?: readonly CoercionHelperCarveOut[];
io?: ScriptIo;
repoRoot?: string;
} = {},
) {
const repoRoot = options.repoRoot ?? resolveRepoRoot(import.meta.url);
const io = options.io ?? { stderr: process.stderr, stdout: process.stdout };
const carveOuts = options.carveOuts ?? COERCION_HELPER_CARVE_OUTS;
const relativeFiles = listRepoFilesSync(repoRoot, {
roots: SCAN_ROOTS,
includeFile: isGovernedCoercionHelperPath,
});
const declarations = relativeFiles.flatMap((file) => {
const absolutePath = path.join(repoRoot, file);
if (!fs.existsSync(absolutePath)) {
return [];
}
return findBannedCoercionHelperDeclarations(fs.readFileSync(absolutePath, "utf8"), file);
});
const audit = auditCoercionHelperDeclarations(declarations, carveOuts);
const failed =
audit.excessDeclarations.length > 0 ||
audit.invalidCarveOuts.length > 0 ||
audit.staleCarveOuts.length > 0;
if (!failed) {
writeLine(
io.stdout,
`Coercion helper declaration guard passed (${declarations.length} allowlisted declarations).`,
);
return 0;
}
if (audit.invalidCarveOuts.length > 0) {
writeLine(io.stderr, "Invalid coercion-helper carve-outs:");
for (const message of audit.invalidCarveOuts) {
writeLine(io.stderr, `- ${message}`);
}
}
if (audit.excessDeclarations.length > 0) {
writeLine(io.stderr, "Banned local coercion-helper declarations:");
for (const declaration of audit.excessDeclarations) {
writeLine(
io.stderr,
`- ${declaration.file}:${declaration.line} ${declaration.name} (${declaration.kind} declaration)`,
);
}
}
if (audit.staleCarveOuts.length > 0) {
writeLine(io.stderr, "Stale coercion-helper carve-outs:");
for (const carveOut of audit.staleCarveOuts) {
writeLine(
io.stderr,
`- ${carveOut.file} [${carveOut.name}] expected ${carveOut.count}, found ${carveOut.actualCount}; remove or reduce the carve-out`,
);
}
}
writeLine(
io.stderr,
"Core/package/UI/workspace-script code: use the matching @openclaw/normalization-core coercion subpath.",
);
writeLine(
io.stderr,
"Plugin production code: use openclaw/plugin-sdk/string-coerce-runtime, number-runtime, or error-runtime.",
);
writeLine(
io.stderr,
"Dependency-free, copied, generated, or serialized code: use an existing dependency-light seam or a precise semantic name with an exact reasoned carve-out.",
);
return 1;
}
if (isDirectRunUrl(process.argv[1], import.meta.url)) {
await runWithFailedTrailer("check:coercion-helpers", () => {
process.exitCode = runCoercionHelperDeclarationGuard();
});
}