openclaw/scripts/lib/extension-import-boundary-checker.mts
Peter Steinberger 4b9e0b3866
refactor: consolidate cross-directory duplicate code (#161352)
* refactor: consolidate cross-directory duplicate code

Reuse canonical parsing, bounded-map, session-scope and service-binding owners. Share Markdown breakpoint scanning and Responses web-search detection; route lazy WebSocket consumers through the existing portable transport and remove their obsolete build rewrites. Preserve SDK, wire and persisted-state contracts.

* refactor: reuse allow-from projections in channel setup

* refactor: share literal schema merging through an internal owner

Keep schema guards and provider-specific flattening intact. Isolate the shared web-search predicate from exported projection types so its private declaration does not enter SDK type closures.

* refactor: reuse shared listeners and remove forwarding helpers

Reuse existing listener, normalization, setup-visibility and cache-control owners. Preserve callback ordering, error propagation, string filtering and public SDK exports. Remove obsolete internal aliases and their stale test mocks.

* refactor: retain SDK-reachable setup visibility wrapper

Drop the setup-visibility cleanup group after the strict SDK comparison identified its namespace declaration in 123 reachable export closures. Preserve the existing API without acknowledgement or baseline changes.

* refactor: reuse canonical helpers across runtime consumers

Reuse result, numeric, string, filesystem, blocked-key, and map-eviction owners while preserving caller policy and output. Remove redundant private branches and repeated comments. Doctor inputs, transformations, warnings, and persisted state remain unchanged.

* refactor: consolidate shared runtime and tooling operations

Preserve per-caller validation, ordering, diagnostics and public SDK contracts while reusing canonical owners. Correct the Discord plural reply-count diagnostic with an existing handler regression.

* refactor: narrow consolidation to retained runtime owners

Drop the new SQLite scope and cache invalidation extractions after their local fixture preparation exceeded the maintainer quick-validation budget.

* refactor: keep Discord cleanup behavior neutral

Drop the optional diagnostic spelling change and its unvalidated new test under the maintainer quick-validation limit. Runtime import edges are unchanged; the reverted test only removes an import edge from the verified acyclic graph.

* fix: repair consolidation checks and restart fixture

Remove two obsolete imports, tighten assertion counts after cast removal, and load the real null-writer in the restart outcome VM fixture. The focused restart fixture passes all 19 existing native cases.

* refactor: drop unproven session-runtime clone groups

Keep the original Codex environment reader and session-runtime helpers after an unattributed worker teardown failure. Preserve the test assertions and leave worker lifecycle repairs to their owner.

* refactor: preserve main sandbox string filtering
2026-09-29 23:05:36 -07:00

259 lines
8.2 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 { createNativeTypeScriptParser } from "./native-typescript.mts";
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",
);
export function compareEntries(
left: Omit<BoundaryViolation, "resolvedPath">,
right: Omit<BoundaryViolation, "resolvedPath">,
): 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 () => {
using parser = createNativeTypeScriptParser({ cwd: repoRoot });
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(
parser.parseSourceFile(filePath, source),
{
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 };
}