openclaw/scripts/check-wrapper-shadowing.mts
Vincent Koc d69a5e7489
fix(update): preserve recovery files after ownership refusal (#148890)
* fix(update): preserve filesystem authority during rollback

Fence removal and rollback against replacement objects and retain original authority failures.

* fix(update): preserve filesystem ownership through rollback

Retain the first ownership refusal through package copy, removal, and recovery
so cleanup cannot adopt replacement files or discard the original error. Keep
read-only-file removal with the native fs-safe/Node owner instead of retrying
with a pathname permission change.

Preserve the contributor implementation while reconciling current dependency
contracts and meaningful update, filesystem, and SDK fixtures.

Refs #148890, #143752.

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>

* fix(update): reconcile recovery-file protection with current CLI lifecycle

Merge fixed main ca6d427 after GitHub reported the original candidate conflicted. Preserve filesystem refusal behavior and migrate the five existing CLI regressions to the current package-lifecycle suite.

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>

* test(update): model native npm layout in recovery fixtures

Use the existing npm prefix layout owner for staged packages and launchers. The native Windows replay exposed six callbacks writing POSIX-only paths, causing package verification to stop before the intended recovery assertions. Preserve all cases and assertions.

* test(update): inject cleanup refusal at the filesystem owner

The fs-safe cleanup path no longer calls fs.rm. Intercept Root.remove for the exact staged subtree and retain its first refused removal, preserving all activation, recovery and sibling-retention assertions. Pass the captured stage prefix instead of inferring a POSIX layout.

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-09-24 17:08:38 -07:00

217 lines
7 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 { 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;
};
const failurePrefix = "check-wrapper-shadowing";
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 `${left.name}\0${left.wrapper}\0${left.wrapped}\0${left.via ?? ""}`.localeCompare(
`${right.name}\0${right.wrapper}\0${right.wrapped}\0${right.via ?? ""}`,
);
}
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>();
for (const sourceModule of modules.toSorted((left, right) =>
left.path.localeCompare(right.path),
)) {
const modulePath = normalizeRelativePath(sourceModule.path);
modulesByPath.set(
modulePath,
collectModuleExportNames(
sourceModule.content,
modulePath,
parser.parseSourceFile(modulePath, sourceModule.content),
),
);
}
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, async () => {
let exitCode = 1;
try {
exitCode = await main();
} catch (error) {
console.error(error);
}
if (exitCode !== 0) {
process.exitCode = exitCode;
console.error(`[${failurePrefix}] FAILED (exit ${exitCode})`);
}
});