openclaw/scripts/check-wrapper-shadowing.mts
Peter Steinberger 3cb68020b6
refactor(scripts): deslop shared tooling
## What Problem This Solves

Repository scripts retain private copies of shared tooling helpers and an unused translation subprocess runner, increasing maintenance work across sibling scripts.

## User Impact

Internal tooling cleanup: existing flags, diagnostics, generated bytes, JSON formats, exit codes, and cleanup policies stay the same. The build wrapper additionally detects forbidden dynamic-import warnings when compiler output splits the warning marker across chunks.

## Why This Change Was Made

- Remove the retired i18n process runner. Its final production caller moved to the shared formatter in #95534; retain real CLI/privacy/provider tests and run their subprocesses with the existing dependency.
- Share Docker resource, signal-trap, platform, workspace-staging, and plugin-selection helpers while retaining each caller's environment precedence and lifecycle decisions.
- Reuse existing E2E fixture JSON/assertion and package-path helpers; retain distinct write modes, recovery ordering, and mounted/frozen harness contracts.
- Share macOS guest desktop-user/home resolution through its existing owner while preserving per-caller timeout policy.
- Route release/mobile flags through the existing version flag specifications, preserving split-only values, duplicate/mode rules, help timing, and error text. Reuse existing retry sleep and comparator owners and remove an unreachable iOS output branch.
- Reuse existing guard entrypoint, failure-trailer, diagnostic-line, and metadata-normalization owners.

Measured reduction: **732 net production lines**, with test changes counted separately. PR tooling and its protected import closure, CI planners/shards, baselines, and generated artifacts are unchanged.

## Fixes Found Along the Way

The tsdown scanner checked each raw output chunk for its warning marker. It now checks the existing combined-line buffer. The regression exercises every split inside the marker and fails on the original source for the intended missing-warning assertion.

## Evidence

- Blacksmith Testbox `tbx_01m3tp4sabwd0807kj9jkqqmbr`: frozen dependency install and candidate source-byte verification; all nine changed test files and 59 selected sibling files passed, including real CLI, package fixture, recovery, Docker harness, and Parallels transport contracts.
- Generated channel metadata is byte-identical; differential metadata normalization cases passed.
- SDK surface check passed. Independent isolated Codex review completed with no P0–P2 findings.
- Initial changed checks caught an invalid direct source import in a proposed snapshot-distance consolidation; that independent change was withdrawn. No boundary exception was added. The final changed checks pass, including script/test lint and Docker shell/scheduler checks; madge reports 0 cycles and the runtime import check reports 0 cycles.
- Per-file single-worker wall time for changed suites: translation 9.92s; Docker helper 38.88s; live Docker auth 1.71s; mobile ref 2.36s; mobile release 6.23s; release preparation 2.42s; version 2.04s; installer 15.01s; tsdown 2.70s. The existing Docker helper suite executes shell/process cleanup and container-command boundary fixtures; new assertions reuse those fixtures. New parser/scanner cases use no sleeps or polling.

Hosted CI will be verified against this PR's exact pushed head. No live deployment or release was performed.
2026-10-01 05:14:07 +00:00

214 lines
7.2 KiB
TypeScript

#!/usr/bin/env node
import path from "node:path";
import {
collectModuleExportNames,
isExcludedExportCollisionSource,
resolveExportModulePath,
type ModuleExports,
type SourceModule,
} from "./check-export-name-collisions.mts";
import { runWithFailedTrailer } from "./lib/failed-trailer.mts";
import { createNativeTypeScriptParser } from "./lib/native-typescript.mts";
import { resolveRepoRoot } from "./lib/repo-root.mjs";
import { collectSourceFileContents } from "./lib/source-file-scan-cache.mts";
import { runAsScript } from "./lib/ts-guard-utils.mts";
export type WrapperShadowingViolation = {
name: string;
wrapped: string;
wrapper: string;
via?: string;
};
function normalizeRelativePath(filePath: string) {
return filePath.replaceAll(path.sep, "/");
}
export function isExcludedWrapperShadowingSource(filePath: string) {
const normalized = normalizeRelativePath(filePath);
const segments = normalized.split("/");
return (
isExcludedExportCollisionSource(normalized) ||
segments.some((segment) =>
["__mocks__", "__tests__", "test-helpers", "test-support"].includes(segment),
) ||
/-test-(?:helpers|support)\.[cm]?[jt]s$/u.test(normalized)
);
}
function compareViolations(left: WrapperShadowingViolation, right: WrapperShadowingViolation) {
return violationKey(left).localeCompare(violationKey(right));
}
function violationKey(violation: WrapperShadowingViolation) {
return `${violation.name}\0${violation.wrapper}\0${violation.wrapped}\0${violation.via ?? ""}`;
}
function resolveSourceModulePath(
sourcePath: string,
specifier: string,
modulesByPath: ReadonlyMap<string, ModuleExports>,
) {
const pluginSdkPrefix = specifier.startsWith("openclaw/plugin-sdk/")
? "openclaw/plugin-sdk/"
: specifier.startsWith("@openclaw/plugin-sdk/")
? "@openclaw/plugin-sdk/"
: null;
if (!pluginSdkPrefix) {
return resolveExportModulePath(sourcePath, specifier, modulesByPath);
}
return resolveExportModulePath(
"src/plugin-sdk/importer.ts",
`./${specifier.slice(pluginSdkPrefix.length)}`,
modulesByPath,
);
}
function resolveWrappedDefinition(
wrapperPath: string,
exportName: string,
moduleSpecifier: string,
modulesByPath: ReadonlyMap<string, ModuleExports>,
) {
const importedPath = resolveSourceModulePath(wrapperPath, moduleSpecifier, modulesByPath);
if (!importedPath) {
return null;
}
const reachablePaths = new Set([importedPath]);
const wrappedPaths = new Set<string>();
// Set iteration visits newly discovered modules once, including cyclic barrels.
for (const modulePath of reachablePaths) {
const moduleExports = modulesByPath.get(modulePath);
if (!moduleExports) {
continue;
}
if (moduleExports.valueDefinitions.has(exportName)) {
wrappedPaths.add(modulePath);
if (wrappedPaths.size > 1) {
return null;
}
continue;
}
const namedExports = moduleExports.namedReExports.filter(
(reExport) => reExport.exportedName === exportName,
);
// An explicit binding shadows stars, even when its renamed target is outside
// this same-name guard. Falling through would attribute a different function.
const specifiers =
namedExports.length > 0
? namedExports
.filter((reExport) => reExport.importedName === exportName)
.map((reExport) => reExport.moduleSpecifier)
: moduleExports.starExportSpecifiers;
for (const specifier of specifiers) {
const target = resolveSourceModulePath(modulePath, specifier, modulesByPath);
if (target) {
reachablePaths.add(target);
}
}
}
const [wrapped] = wrappedPaths;
return wrapped ? { wrapped, ...(wrapped !== importedPath ? { via: importedPath } : {}) } : null;
}
/** Finds exported wrappers that shadow the same imported source symbol. */
export function findWrapperShadowingViolations(modules: SourceModule[]) {
using parser = createNativeTypeScriptParser();
const modulesByPath = new Map<string, ModuleExports>();
const sortedModules = modules.toSorted((left, right) => left.path.localeCompare(right.path));
// Reload native roots once per bounded batch, retaining only the export graph.
const batchSize = 32;
for (let offset = 0; offset < sortedModules.length; offset += batchSize) {
const batch = sortedModules.slice(offset, offset + batchSize);
const sourceFiles = parser.parseSourceFiles(
batch.map((sourceModule) => ({
fileName: normalizeRelativePath(sourceModule.path),
text: sourceModule.content,
})),
);
for (const [index, sourceFile] of sourceFiles.entries()) {
const sourceModule = batch[index]!;
const modulePath = normalizeRelativePath(sourceModule.path);
modulesByPath.set(
modulePath,
collectModuleExportNames(sourceModule.content, modulePath, sourceFile),
);
}
}
const violations = new Map<string, WrapperShadowingViolation>();
for (const [wrapperPath, moduleExports] of modulesByPath) {
for (const [name, definition] of moduleExports.valueDefinitions) {
for (const reference of definition.importedReferences) {
if (reference.importedName !== name) {
continue;
}
const wrappedDefinition = resolveWrappedDefinition(
wrapperPath,
name,
reference.moduleSpecifier,
modulesByPath,
);
if (!wrappedDefinition || wrappedDefinition.wrapped === wrapperPath) {
continue;
}
const violation: WrapperShadowingViolation = {
name,
wrapped: wrappedDefinition.wrapped,
wrapper: wrapperPath,
...(wrappedDefinition.via ? { via: wrappedDefinition.via } : {}),
};
violations.set(violationKey(violation), violation);
}
}
}
return [...violations.values()].toSorted(compareViolations);
}
export async function collectRepositoryWrapperShadowing(repoRoot: string) {
const files = await collectSourceFileContents({
repoRoot,
scanRoots: ["src"],
scanExtensions: new Set([".ts", ".mts", ".js", ".mjs"]),
ignoredDirNames: new Set(["node_modules", "test", "__fixtures__"]),
});
const modules = files
.filter(({ relativeFile }) => !isExcludedWrapperShadowingSource(relativeFile))
.map(({ content, relativeFile }) => ({ content, path: relativeFile }));
return findWrapperShadowingViolations(modules);
}
export async function main(
repoRoot = resolveRepoRoot(import.meta.url),
argv = process.argv.slice(2),
) {
if (argv.length > 0) {
console.error(`Unknown argument(s): ${argv.join(", ")}`);
return 2;
}
const violations = await collectRepositoryWrapperShadowing(repoRoot);
if (violations.length === 0) {
console.log("wrapper shadowing guard passed.");
return 0;
}
console.error("Found same-name wrapper shadowing:");
for (const violation of violations) {
console.error(`- ${JSON.stringify(violation)}`);
}
console.error(
"Keep the canonical name on the behavior-complete outer function; rename wrapped implementations with a distinguishing suffix, or use a pure re-export when no behavior is added.",
);
return 1;
}
runAsScript(import.meta.url, () =>
runWithFailedTrailer("check-wrapper-shadowing", async () => {
process.exitCode = await main();
}),
);