mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
c23d66e3b5
* refactor: consolidate coercion ownership * test: align shard check with weighted planning * chore: refresh plugin SDK API baseline
753 lines
25 KiB
TypeScript
753 lines
25 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 { getPropertyNameText, toLine, unwrapExpression } from "./lib/ts-guard-utils.mts";
|
|
|
|
const ABSOLUTE_LEGACY_COERCION_HELPER_NAMES = [
|
|
"asObject",
|
|
"asString",
|
|
"normalizeString",
|
|
"optionalString",
|
|
"readBoolean",
|
|
"readNumber",
|
|
"readOptionalString",
|
|
"readString",
|
|
"timestampMs",
|
|
] as const;
|
|
|
|
export type CoercionHelperDeclarationKind =
|
|
| "field"
|
|
| "function"
|
|
| "method"
|
|
| "property"
|
|
| "variable";
|
|
|
|
export const CANONICAL_COERCION_HELPER_OWNERS = [
|
|
{
|
|
file: "packages/normalization-core/src/agent-id.ts",
|
|
kind: "function",
|
|
names: ["isValidAgentId", "normalizeAgentId"],
|
|
},
|
|
{
|
|
file: "packages/normalization-core/src/string-coerce.ts",
|
|
kind: "function",
|
|
names: [
|
|
"hasNonEmptyString",
|
|
"lowercasePreservingWhitespace",
|
|
"localeLowercasePreservingWhitespace",
|
|
"normalizeBoundedOptionalString",
|
|
"normalizeFastMode",
|
|
"normalizeLowercaseStringOrEmpty",
|
|
"normalizeNullableString",
|
|
"normalizeOptionalLowercaseString",
|
|
"normalizeOptionalString",
|
|
"normalizeOptionalStringifiedId",
|
|
"normalizeOptionalThreadValue",
|
|
"normalizeStringifiedOptionalString",
|
|
"normalizeStringifiedEntries",
|
|
"readNonBlankString",
|
|
"readNonEmptyStringPreservingWhitespace",
|
|
"readStringValue",
|
|
"resolvePrimaryStringValue",
|
|
],
|
|
},
|
|
{
|
|
file: "packages/normalization-core/src/string-normalization.ts",
|
|
kind: "function",
|
|
names: [
|
|
"filterStringEntries",
|
|
"normalizeArrayBackedTrimmedStringList",
|
|
"normalizeAtHashSlug",
|
|
"normalizeCsvOrLooseStringList",
|
|
"normalizeHyphenSlug",
|
|
"normalizeOptionalTrimmedStringList",
|
|
"normalizeSingleOrTrimmedStringList",
|
|
"normalizeSortedUniqueStringEntries",
|
|
"normalizeSortedUniqueTrimmedStringList",
|
|
"normalizeStringEntries",
|
|
"normalizeStringEntriesLower",
|
|
"normalizeTrimmedStringList",
|
|
"normalizeUniqueSingleOrTrimmedStringList",
|
|
"normalizeUniqueStringEntries",
|
|
"normalizeUniqueStringEntriesLower",
|
|
"normalizeUniqueTrimmedStringList",
|
|
"sortUniqueStrings",
|
|
"uniqueStrings",
|
|
"uniqueValues",
|
|
],
|
|
},
|
|
{
|
|
file: "packages/normalization-core/src/number-coercion.ts",
|
|
kind: "function",
|
|
names: [
|
|
"addTimerTimeoutGraceMs",
|
|
"asDateTimestampMs",
|
|
"asFiniteNumber",
|
|
"asFiniteNumberInRange",
|
|
"asNonNegativeFiniteNumber",
|
|
"asPositiveFiniteNumber",
|
|
"asPositiveSafeInteger",
|
|
"asSafeIntegerInRange",
|
|
"clampTimerTimeoutMs",
|
|
"clampPositiveTimerTimeoutMs",
|
|
"finiteSecondsToTimerSafeMilliseconds",
|
|
"isFutureDateTimestampMs",
|
|
"nonNegativeSecondsToSafeMilliseconds",
|
|
"parseDateFirstTimestampMs",
|
|
"parseDateStringTimestampMs",
|
|
"parseFiniteNumber",
|
|
"parseStrictFiniteNumber",
|
|
"parseStrictInteger",
|
|
"parseStrictNonNegativeInteger",
|
|
"parseStrictPositiveInteger",
|
|
"positiveSecondsToSafeMilliseconds",
|
|
"resolveDateTimestampMs",
|
|
"resolveExpiresAtMsFromDurationMs",
|
|
"resolveExpiresAtMsFromDurationOrEpoch",
|
|
"resolveExpiresAtMsFromDurationSeconds",
|
|
"resolveExpiresAtMsFromEpochSeconds",
|
|
"resolveIntegerOption",
|
|
"resolveNonNegativeIntegerOption",
|
|
"resolveOptionalIntegerOption",
|
|
"resolvePositiveTimerTimeoutMs",
|
|
"resolveTimerTimeoutMs",
|
|
"resolveTimestampMsToIsoString",
|
|
"timestampMsToIsoFileStamp",
|
|
"timestampMsToIsoString",
|
|
],
|
|
},
|
|
{
|
|
file: "packages/normalization-core/src/boolean-coercion.ts",
|
|
kind: "function",
|
|
names: ["parseBoolean"],
|
|
},
|
|
{
|
|
file: "packages/normalization-core/src/record-coerce.ts",
|
|
kind: "function",
|
|
names: [
|
|
"asNonArrayRecord",
|
|
"asNullableObjectRecord",
|
|
"asNullableRecord",
|
|
"asOptionalObjectRecord",
|
|
"asOptionalRecord",
|
|
"asRecord",
|
|
"filterStringRecord",
|
|
"isRecord",
|
|
"isStringRecord",
|
|
"readStringField",
|
|
],
|
|
},
|
|
{
|
|
file: "packages/normalization-core/src/json-coercion.ts",
|
|
kind: "function",
|
|
names: ["safeParseJson", "safeParseJsonRecord"],
|
|
},
|
|
{
|
|
file: "packages/normalization-core/src/error-coercion.ts",
|
|
kind: "function",
|
|
names: [
|
|
"coerceErrorMessage",
|
|
"stringifyNonErrorCause",
|
|
"toErrorObject",
|
|
"toStringifiedError",
|
|
"toStructuredErrorObject",
|
|
],
|
|
},
|
|
{
|
|
file: "scripts/lib/error-format.mts",
|
|
kind: "function",
|
|
names: ["coerceErrorMessage", "toErrorObject", "toStringifiedError"],
|
|
},
|
|
{
|
|
file: "scripts/lib/arg-utils.runtime.mjs",
|
|
kind: "function",
|
|
names: [
|
|
"classifyBoundedUnsignedDecimal",
|
|
"parsePermissiveBooleanToken",
|
|
"parseStrictBooleanArg",
|
|
],
|
|
},
|
|
{
|
|
file: "src/utils/boolean.ts",
|
|
kind: "function",
|
|
names: ["asBoolean", "parseBooleanValue"],
|
|
},
|
|
] as const satisfies readonly {
|
|
file: string;
|
|
kind: CoercionHelperDeclarationKind;
|
|
names: readonly string[];
|
|
}[];
|
|
|
|
export const CANONICAL_COERCION_MODULES = [
|
|
"packages/normalization-core/src/agent-id.ts",
|
|
"packages/normalization-core/src/string-coerce.ts",
|
|
"packages/normalization-core/src/string-normalization.ts",
|
|
"packages/normalization-core/src/number-coercion.ts",
|
|
"packages/normalization-core/src/record-coerce.ts",
|
|
"packages/normalization-core/src/json-coercion.ts",
|
|
"packages/normalization-core/src/error-coercion.ts",
|
|
"packages/normalization-core/src/boolean-coercion.ts",
|
|
"scripts/lib/error-format.mts",
|
|
"src/utils/boolean.ts",
|
|
] as const;
|
|
|
|
const MIXED_CANONICAL_COERCION_MODULES = ["scripts/lib/arg-utils.runtime.mjs"] as const;
|
|
|
|
export const DEFERRED_CANONICAL_COERCION_EXPORTS = [
|
|
{
|
|
file: "packages/normalization-core/src/error-coercion.ts",
|
|
name: "formatErrorMessage",
|
|
reason: "Structural formatter shares its public name with redacting owner adapters.",
|
|
},
|
|
{
|
|
file: "scripts/lib/error-format.mts",
|
|
name: "formatErrorMessage",
|
|
reason: "Dependency-light scripts retain a deliberately smaller formatting policy.",
|
|
},
|
|
] as const satisfies readonly { file: string; name: string; reason: string }[];
|
|
|
|
const EXCEPTIONAL_COERCION_HELPER_CARVE_OUTS = [
|
|
{
|
|
file: "ui/src/test-helpers/control-ui-e2e.ts",
|
|
name: "isRecord",
|
|
kind: "function",
|
|
reason: "Serialized mock Gateway closure cannot capture module imports.",
|
|
},
|
|
{
|
|
file: "scripts/lib/kova-report-gate.mts",
|
|
name: "isRecord",
|
|
kind: "function",
|
|
reason: "Copied standalone report gate cannot rely on workspace package resolution.",
|
|
},
|
|
{
|
|
file: "scripts/lib/record-shared.mjs",
|
|
name: "isRecord",
|
|
kind: "function",
|
|
reason: "Plain-Node shared helper serves MJS and E2E callers without package resolution.",
|
|
},
|
|
{
|
|
file: "scripts/pr-lib/process-group-runner.mjs",
|
|
name: "toError",
|
|
kind: "function",
|
|
reason:
|
|
"Bootstrap process supervisor preserves fallback errors without workspace dependencies.",
|
|
},
|
|
{
|
|
file: "scripts/lib/bounded-response.mjs",
|
|
name: "toLintErrorObject",
|
|
kind: "function",
|
|
reason: "Standalone copied response reader cannot resolve workspace packages.",
|
|
},
|
|
] as const satisfies readonly {
|
|
file: string;
|
|
kind: CoercionHelperDeclarationKind;
|
|
name: string;
|
|
reason: string;
|
|
}[];
|
|
|
|
type CanonicalCoercionHelperName =
|
|
(typeof CANONICAL_COERCION_HELPER_OWNERS)[number]["names"][number];
|
|
type AbsoluteLegacyCoercionHelperName = (typeof ABSOLUTE_LEGACY_COERCION_HELPER_NAMES)[number];
|
|
type ExceptionalCoercionHelperName =
|
|
(typeof EXCEPTIONAL_COERCION_HELPER_CARVE_OUTS)[number]["name"];
|
|
export type BannedCoercionHelperName =
|
|
| AbsoluteLegacyCoercionHelperName
|
|
| CanonicalCoercionHelperName
|
|
| ExceptionalCoercionHelperName;
|
|
|
|
export const BANNED_COERCION_HELPER_NAMES: readonly BannedCoercionHelperName[] = [
|
|
...new Set<BannedCoercionHelperName>([
|
|
...ABSOLUTE_LEGACY_COERCION_HELPER_NAMES,
|
|
...CANONICAL_COERCION_HELPER_OWNERS.flatMap(({ names }) => names),
|
|
...EXCEPTIONAL_COERCION_HELPER_CARVE_OUTS.map(({ name }) => name),
|
|
]),
|
|
];
|
|
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: CoercionHelperDeclarationKind;
|
|
line: number;
|
|
name: BannedCoercionHelperName;
|
|
};
|
|
|
|
export type CanonicalCoercionExportClassification = {
|
|
file: string;
|
|
name: string;
|
|
reason?: string;
|
|
status: "deferred" | "enforced";
|
|
};
|
|
|
|
export type CanonicalCoercionExportAudit = {
|
|
invalidClassifications: string[];
|
|
staleClassifications: CanonicalCoercionExportClassification[];
|
|
unclassifiedExports: Array<{ file: string; name: string }>;
|
|
};
|
|
|
|
export type CoercionHelperCarveOut = {
|
|
file: string;
|
|
kind: CoercionHelperDeclarationKind;
|
|
name: BannedCoercionHelperName;
|
|
reason: string;
|
|
};
|
|
|
|
function canonicalOwnerCarveOuts(
|
|
owner: (typeof CANONICAL_COERCION_HELPER_OWNERS)[number],
|
|
): CoercionHelperCarveOut[] {
|
|
return owner.names.map((name) => ({
|
|
file: owner.file,
|
|
kind: owner.kind,
|
|
name,
|
|
reason: "Canonical coercion helper owned by this module.",
|
|
}));
|
|
}
|
|
|
|
export const COERCION_HELPER_CARVE_OUTS: readonly CoercionHelperCarveOut[] = [
|
|
...CANONICAL_COERCION_HELPER_OWNERS.flatMap(canonicalOwnerCarveOuts),
|
|
...EXCEPTIONAL_COERCION_HELPER_CARVE_OUTS,
|
|
];
|
|
|
|
type CoercionHelperAudit = {
|
|
excessDeclarations: CoercionHelperDeclaration[];
|
|
invalidCarveOuts: string[];
|
|
staleCarveOuts: CoercionHelperCarveOut[];
|
|
};
|
|
|
|
type ScriptIo = {
|
|
stderr: { write(value: string): unknown };
|
|
stdout: { write(value: string): unknown };
|
|
};
|
|
|
|
const COERCION_HELPER_DECLARATION_KINDS = new Set<CoercionHelperDeclarationKind>([
|
|
"field",
|
|
"function",
|
|
"method",
|
|
"property",
|
|
"variable",
|
|
]);
|
|
|
|
function carveOutKey(entry: Pick<CoercionHelperCarveOut, "file" | "kind" | "name">) {
|
|
return `${entry.file}\0${entry.name}\0${entry.kind}`;
|
|
}
|
|
|
|
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 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 = getPropertyNameText(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 = getPropertyNameText(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 = getPropertyNameText(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;
|
|
}
|
|
|
|
function hasExportModifier(node: ts.Node) {
|
|
return (ts.canHaveModifiers(node) ? (ts.getModifiers(node) ?? []) : []).some(
|
|
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
|
|
);
|
|
}
|
|
|
|
/** Finds directly declared callable exports in one selected canonical module. */
|
|
export function findExportedCallableNames(source: string, file = "source.ts") {
|
|
const scriptKind = file.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, scriptKind);
|
|
const callableLocals = new Set<string>();
|
|
const exportedNames = new Set<string>();
|
|
|
|
for (const statement of sourceFile.statements) {
|
|
if (ts.isFunctionDeclaration(statement) && statement.name) {
|
|
callableLocals.add(statement.name.text);
|
|
if (hasExportModifier(statement)) {
|
|
exportedNames.add(statement.name.text);
|
|
}
|
|
continue;
|
|
}
|
|
if (!ts.isVariableStatement(statement)) {
|
|
continue;
|
|
}
|
|
for (const declaration of statement.declarationList.declarations) {
|
|
if (!ts.isIdentifier(declaration.name) || !declaration.initializer) {
|
|
continue;
|
|
}
|
|
const alias = unwrapDirectAliasInitializer(declaration.initializer);
|
|
if (
|
|
!isCallableInitializer(declaration.initializer) &&
|
|
(!alias || (!ts.isIdentifier(alias) && !ts.isPropertyAccessExpression(alias)))
|
|
) {
|
|
continue;
|
|
}
|
|
callableLocals.add(declaration.name.text);
|
|
if (hasExportModifier(statement)) {
|
|
exportedNames.add(declaration.name.text);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const statement of sourceFile.statements) {
|
|
if (
|
|
!ts.isExportDeclaration(statement) ||
|
|
statement.moduleSpecifier ||
|
|
!statement.exportClause ||
|
|
!ts.isNamedExports(statement.exportClause)
|
|
) {
|
|
continue;
|
|
}
|
|
for (const element of statement.exportClause.elements) {
|
|
const localName = element.propertyName?.text ?? element.name.text;
|
|
if (!element.isTypeOnly && callableLocals.has(localName)) {
|
|
exportedNames.add(element.name.text);
|
|
}
|
|
}
|
|
}
|
|
return [...exportedNames].toSorted();
|
|
}
|
|
|
|
/** Requires every selected callable export to be enforced or explicitly deferred. */
|
|
export function auditCanonicalCoercionExports(
|
|
exportsByFile: ReadonlyMap<string, readonly string[]>,
|
|
classifications: readonly CanonicalCoercionExportClassification[],
|
|
): CanonicalCoercionExportAudit {
|
|
const invalidClassifications: string[] = [];
|
|
const byKey = new Map<string, CanonicalCoercionExportClassification>();
|
|
for (const classification of classifications) {
|
|
const key = `${classification.file}\0${classification.name}`;
|
|
if (byKey.has(key)) {
|
|
invalidClassifications.push(
|
|
`${classification.file} [${classification.name}] is classified more than once`,
|
|
);
|
|
continue;
|
|
}
|
|
if (classification.status === "deferred" && !classification.reason?.trim()) {
|
|
invalidClassifications.push(
|
|
`${classification.file} [${classification.name}] needs a non-empty deferred reason`,
|
|
);
|
|
}
|
|
byKey.set(key, classification);
|
|
}
|
|
const unclassifiedExports = [...exportsByFile].flatMap(([file, names]) =>
|
|
names.flatMap((name) => (byKey.has(`${file}\0${name}`) ? [] : [{ file, name }])),
|
|
);
|
|
const staleClassifications = classifications.filter(
|
|
({ file, name }) => !(exportsByFile.get(file) ?? []).includes(name),
|
|
);
|
|
return { invalidClassifications, staleClassifications, unclassifiedExports };
|
|
}
|
|
|
|
/** Checks exact file/name/kind 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 (!COERCION_HELPER_DECLARATION_KINDS.has(carveOut.kind)) {
|
|
invalidCarveOuts.push(
|
|
`${carveOut.file} [${carveOut.name}] has invalid kind ${carveOut.kind}`,
|
|
);
|
|
}
|
|
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) {
|
|
if (carveOutByKey.has(key)) {
|
|
excessDeclarations.push(...actual.slice(1));
|
|
} else {
|
|
excessDeclarations.push(...actual);
|
|
}
|
|
}
|
|
const staleCarveOuts = carveOuts.filter(
|
|
(carveOut) => !declarationsByKey.has(carveOutKey(carveOut)),
|
|
);
|
|
|
|
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`);
|
|
}
|
|
|
|
function auditDefaultCanonicalExports(repoRoot: string): CanonicalCoercionExportAudit {
|
|
const canonicalModules = new Set<string>(CANONICAL_COERCION_MODULES);
|
|
const mixedModules = new Set<string>(MIXED_CANONICAL_COERCION_MODULES);
|
|
const auditedModules = [...CANONICAL_COERCION_MODULES, ...MIXED_CANONICAL_COERCION_MODULES];
|
|
const exportsByFile = new Map(
|
|
auditedModules.map((file) => {
|
|
const source = fs.readFileSync(path.join(repoRoot, file), "utf8");
|
|
const exportedNames = findExportedCallableNames(source, file);
|
|
if (!mixedModules.has(file)) {
|
|
return [file, exportedNames] as const;
|
|
}
|
|
const registeredNames = new Set<string>(
|
|
CANONICAL_COERCION_HELPER_OWNERS.filter((owner) => owner.file === file).flatMap(
|
|
(owner) => owner.names,
|
|
),
|
|
);
|
|
return [file, exportedNames.filter((name) => registeredNames.has(name))] as const;
|
|
}),
|
|
);
|
|
const classifications: CanonicalCoercionExportClassification[] = [
|
|
...CANONICAL_COERCION_HELPER_OWNERS.filter(
|
|
({ file }) => canonicalModules.has(file) || mixedModules.has(file),
|
|
).flatMap(({ file, names }) =>
|
|
names.map((name) => ({ file, name, status: "enforced" as const })),
|
|
),
|
|
...DEFERRED_CANONICAL_COERCION_EXPORTS.map(({ file, name, reason }) => ({
|
|
file,
|
|
name,
|
|
reason,
|
|
status: "deferred" as const,
|
|
})),
|
|
];
|
|
return auditCanonicalCoercionExports(exportsByFile, classifications);
|
|
}
|
|
|
|
/** 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 exportAudit =
|
|
options.carveOuts === undefined
|
|
? auditDefaultCanonicalExports(repoRoot)
|
|
: { invalidClassifications: [], staleClassifications: [], unclassifiedExports: [] };
|
|
const failed =
|
|
audit.excessDeclarations.length > 0 ||
|
|
audit.invalidCarveOuts.length > 0 ||
|
|
audit.staleCarveOuts.length > 0 ||
|
|
exportAudit.invalidClassifications.length > 0 ||
|
|
exportAudit.staleClassifications.length > 0 ||
|
|
exportAudit.unclassifiedExports.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}] has no ${carveOut.kind} declaration; remove the carve-out`,
|
|
);
|
|
}
|
|
}
|
|
if (exportAudit.invalidClassifications.length > 0) {
|
|
writeLine(io.stderr, "Invalid canonical-export classifications:");
|
|
for (const message of exportAudit.invalidClassifications) {
|
|
writeLine(io.stderr, `- ${message}`);
|
|
}
|
|
}
|
|
if (exportAudit.unclassifiedExports.length > 0) {
|
|
writeLine(io.stderr, "Unclassified canonical callable exports:");
|
|
for (const entry of exportAudit.unclassifiedExports) {
|
|
writeLine(io.stderr, `- ${entry.file} [${entry.name}]`);
|
|
}
|
|
}
|
|
if (exportAudit.staleClassifications.length > 0) {
|
|
writeLine(io.stderr, "Stale canonical-export classifications:");
|
|
for (const entry of exportAudit.staleClassifications) {
|
|
writeLine(io.stderr, `- ${entry.file} [${entry.name}] (${entry.status})`);
|
|
}
|
|
}
|
|
writeLine(
|
|
io.stderr,
|
|
"Core/package/UI/workspace-script code: use the matching @openclaw/normalization-core export or module.",
|
|
);
|
|
writeLine(
|
|
io.stderr,
|
|
"Bundled plugin production code: use the matching openclaw/plugin-sdk runtime; number-runtime is bundled/private-local, not a third-party typed contract.",
|
|
);
|
|
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();
|
|
});
|
|
}
|