mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-16 23:52:40 -06:00
964c8c84c1
* refactor: consolidate coercion ownership Centralize four canonical coercion helpers, migrate exact core and plugin duplicates through narrow Plugin SDK facades, and enforce declaration and plugin-normalization ownership boundaries. The sweep adds eight focused SDK exports while deleting more production and tooling code than it adds. User-visible behavior is unchanged except for safer equivalent object and UI parsing at existing boundaries. * fix: guard integer option ownership Register resolveIntegerOption with the canonical function owner and extend the declaration-guard fixture so future local duplicates fail validation. * fix: keep integer helpers on numeric facade Remove the unshipped duplicate string-coerce exports and route every affected plugin consumer through the existing number-runtime contract. * fix: point numeric coercion to number runtime Make boundary and declaration diagnostics recommend the canonical numeric facade, with failing-before coverage for both guidance paths.
253 lines
7.9 KiB
TypeScript
253 lines
7.9 KiB
TypeScript
// Creates reusable import-boundary guards for bundled extension source trees.
|
|
import { promises as fs } from "node:fs";
|
|
import path from "node:path";
|
|
import pMap from "p-map";
|
|
import { BUNDLED_PLUGIN_PATH_PREFIX } from "./bundled-plugin-paths.mjs";
|
|
import {
|
|
collectModuleReferencesFromSource,
|
|
createCachedAsync,
|
|
formatGroupedInventoryHuman,
|
|
normalizeRepoPath,
|
|
resolveRepoSpecifier,
|
|
writeLine,
|
|
} from "./guard-inventory-utils.mjs";
|
|
import { resolveRepoRoot } from "./repo-root.mjs";
|
|
import { collectTypeScriptFilesFromRoots, resolveSourceRoots } from "./ts-guard-utils.mts";
|
|
|
|
type ModuleReference = { kind: string; line: number; specifier: string };
|
|
|
|
type BoundaryViolation = {
|
|
file: string;
|
|
line: number;
|
|
kind: string;
|
|
specifier: string;
|
|
resolvedPath: string;
|
|
reason: string;
|
|
};
|
|
|
|
type ResolvedPathContext = {
|
|
kind: string;
|
|
specifier: string;
|
|
file: string;
|
|
};
|
|
|
|
type SpecifierContext = {
|
|
filePath: string;
|
|
relativeFile: string;
|
|
resolvedPath: string | null;
|
|
};
|
|
|
|
type CollectEntriesContext = {
|
|
source: string;
|
|
filePath: string;
|
|
relativeFile: string;
|
|
references: ModuleReference[];
|
|
};
|
|
|
|
type BoundaryCheckerParams<Entry> = {
|
|
roots: string[];
|
|
repoRoot?: string;
|
|
sourceOptions?: Record<string, unknown>;
|
|
maxSourceBytes?: unknown;
|
|
boundaryLabel?: string;
|
|
skipSourcesWithoutBundledPluginPrefix?: boolean;
|
|
shouldSkipFile?: (relativeFile: string) => boolean;
|
|
acceptSpecifier?: (specifier: string, context: SpecifierContext) => boolean;
|
|
collectEntries?: (context: CollectEntriesContext) => Entry[];
|
|
allowResolvedPath?: (resolvedPath: string, context: ResolvedPathContext) => boolean;
|
|
compareEntries?: (left: Entry, right: Entry) => number;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
type BoundaryCheckerIo = {
|
|
stdout: { write(chunk: string): unknown };
|
|
stderr: { write(chunk: string): unknown };
|
|
};
|
|
|
|
const DEFAULT_REPO_ROOT = resolveRepoRoot(import.meta.url);
|
|
const DEFAULT_BOUNDARY_SOURCE_MAX_BYTES = 2 * 1024 * 1024;
|
|
// Escaped plugin paths must reach the scanner without lexing every unrelated escaped source.
|
|
const ESCAPED_BUNDLED_PLUGIN_PATH_PREFIX_RE = new RegExp(
|
|
Array.from(BUNDLED_PLUGIN_PATH_PREFIX, (character) => {
|
|
const hex = character.charCodeAt(0).toString(16).padStart(2, "0");
|
|
return (
|
|
"(?:" +
|
|
character +
|
|
"|\\\\(?:x" +
|
|
hex +
|
|
"|u00" +
|
|
hex +
|
|
"|u\\{0*" +
|
|
hex +
|
|
"\\}|" +
|
|
character +
|
|
"))(?:\\\\(?:\\r\\n|[\\r\\n\\u2028\\u2029]))*"
|
|
);
|
|
}).join(""),
|
|
"iu",
|
|
);
|
|
|
|
function compareEntries(left: BoundaryViolation, right: BoundaryViolation): number {
|
|
return (
|
|
left.file.localeCompare(right.file) ||
|
|
left.line - right.line ||
|
|
left.kind.localeCompare(right.kind) ||
|
|
left.specifier.localeCompare(right.specifier) ||
|
|
left.reason.localeCompare(right.reason)
|
|
);
|
|
}
|
|
|
|
function classifyResolvedExtensionReason(kind: string, boundaryLabel: string | undefined): string {
|
|
const verb =
|
|
kind === "export"
|
|
? "re-exports"
|
|
: kind === "dynamic-import"
|
|
? "dynamically imports"
|
|
: "imports";
|
|
return `${verb} bundled plugin file from ${boundaryLabel} boundary`;
|
|
}
|
|
|
|
function scanImportBoundaryViolations(
|
|
repoRoot: string,
|
|
references: ModuleReference[],
|
|
filePath: string,
|
|
boundaryLabel: string | undefined,
|
|
allowResolvedPath?: (resolvedPath: string, context: ResolvedPathContext) => boolean,
|
|
): BoundaryViolation[] {
|
|
const entries: BoundaryViolation[] = [];
|
|
const relativeFile = normalizeRepoPath(repoRoot, filePath);
|
|
|
|
for (const { kind, line, specifier } of references) {
|
|
const resolvedPath = resolveRepoSpecifier(repoRoot, specifier, filePath);
|
|
if (!resolvedPath?.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) {
|
|
continue;
|
|
}
|
|
if (allowResolvedPath?.(resolvedPath, { kind, specifier, file: relativeFile })) {
|
|
continue;
|
|
}
|
|
entries.push({
|
|
file: relativeFile,
|
|
line,
|
|
kind,
|
|
specifier,
|
|
resolvedPath,
|
|
reason: classifyResolvedExtensionReason(kind, boundaryLabel),
|
|
});
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
function normalizeMaxSourceBytes(value: unknown): number {
|
|
return typeof value === "number" && Number.isInteger(value) && value > 0
|
|
? value
|
|
: DEFAULT_BOUNDARY_SOURCE_MAX_BYTES;
|
|
}
|
|
|
|
function assertSourceFileWithinLimit(
|
|
repoRoot: string,
|
|
filePath: string,
|
|
bytes: number,
|
|
maxBytes: number,
|
|
): void {
|
|
if (bytes <= maxBytes) {
|
|
return;
|
|
}
|
|
throw new Error(
|
|
`extension import boundary source file exceeds ${maxBytes} byte limit: ${normalizeRepoPath(
|
|
repoRoot,
|
|
filePath,
|
|
)} (${bytes} bytes)`,
|
|
);
|
|
}
|
|
|
|
async function readBoundedSourceFile(
|
|
repoRoot: string,
|
|
filePath: string,
|
|
maxBytes: number,
|
|
): Promise<string> {
|
|
const stat = await fs.stat(filePath);
|
|
assertSourceFileWithinLimit(repoRoot, filePath, stat.size, maxBytes);
|
|
const source = await fs.readFile(filePath, "utf8");
|
|
assertSourceFileWithinLimit(repoRoot, filePath, Buffer.byteLength(source, "utf8"), maxBytes);
|
|
return source;
|
|
}
|
|
|
|
/** Create a boundary checker with cached inventory collection and a CLI-style main function. */
|
|
export function createExtensionImportBoundaryChecker<Entry = BoundaryViolation>(
|
|
params: BoundaryCheckerParams<Entry>,
|
|
) {
|
|
const repoRoot = path.resolve(params.repoRoot ?? DEFAULT_REPO_ROOT);
|
|
const scanRoots = resolveSourceRoots(repoRoot, params.roots);
|
|
const maxSourceBytes = normalizeMaxSourceBytes(params.maxSourceBytes);
|
|
|
|
const collectInventory = createCachedAsync(async () => {
|
|
const files = (await collectTypeScriptFilesFromRoots(scanRoots, params.sourceOptions))
|
|
.filter((filePath) => !params.shouldSkipFile?.(normalizeRepoPath(repoRoot, filePath)))
|
|
.toSorted((left, right) =>
|
|
normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)),
|
|
);
|
|
const entriesByFile = await pMap(
|
|
files,
|
|
async (filePath) => {
|
|
const source = await readBoundedSourceFile(repoRoot, filePath, maxSourceBytes);
|
|
const relativeFile = normalizeRepoPath(repoRoot, filePath);
|
|
if (
|
|
params.skipSourcesWithoutBundledPluginPrefix &&
|
|
!source.includes(BUNDLED_PLUGIN_PATH_PREFIX) &&
|
|
(!source.includes("\\") || !ESCAPED_BUNDLED_PLUGIN_PATH_PREFIX_RE.test(source))
|
|
) {
|
|
return [];
|
|
}
|
|
const references = collectModuleReferencesFromSource(source, {
|
|
fileName: filePath,
|
|
acceptSpecifier(specifier: string) {
|
|
const resolvedPath = resolveRepoSpecifier(repoRoot, specifier, filePath);
|
|
return params.acceptSpecifier
|
|
? params.acceptSpecifier(specifier, { filePath, relativeFile, resolvedPath })
|
|
: Boolean(resolvedPath?.startsWith(BUNDLED_PLUGIN_PATH_PREFIX));
|
|
},
|
|
});
|
|
return params.collectEntries
|
|
? params.collectEntries({ source, filePath, relativeFile, references })
|
|
: scanImportBoundaryViolations(
|
|
repoRoot,
|
|
references,
|
|
filePath,
|
|
params.boundaryLabel,
|
|
params.allowResolvedPath,
|
|
);
|
|
},
|
|
{ concurrency: 32, stopOnError: true },
|
|
);
|
|
const inventory = entriesByFile.flat() as Entry[];
|
|
const compare =
|
|
params.compareEntries ??
|
|
((left: Entry, right: Entry) =>
|
|
compareEntries(left as BoundaryViolation, right as BoundaryViolation));
|
|
return inventory.toSorted(compare);
|
|
}) as () => Promise<Entry[]>;
|
|
|
|
async function main(
|
|
args: string[] = process.argv.slice(2),
|
|
streams: BoundaryCheckerIo = { stdout: process.stdout, stderr: process.stderr },
|
|
): Promise<0 | 1> {
|
|
const json = args.includes("--json");
|
|
const inventory = await collectInventory();
|
|
|
|
if (json) {
|
|
writeLine(streams.stdout, JSON.stringify(inventory, null, 2));
|
|
} else {
|
|
writeLine(streams.stdout, formatGroupedInventoryHuman(params, inventory));
|
|
writeLine(
|
|
streams.stdout,
|
|
inventory.length === 0 ? "Boundary is clean." : "Boundary has violations.",
|
|
);
|
|
}
|
|
|
|
return inventory.length === 0 ? 0 : 1;
|
|
}
|
|
|
|
return { collectInventory, main };
|
|
}
|