mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
* 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
259 lines
8.2 KiB
TypeScript
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 };
|
|
}
|