diff --git a/scripts/tsdown-build.mjs b/scripts/tsdown-build.mjs index 7a5ccdc4944..76531768410 100644 --- a/scripts/tsdown-build.mjs +++ b/scripts/tsdown-build.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -21,6 +21,8 @@ const DEFAULT_CAPTURE_BYTES = 8 * 1024 * 1024; const DEFAULT_HEARTBEAT_MS = 30_000; const TERMINATION_GRACE_MS = 5_000; const TSDOWN_OUTPUT_ROOTS = ["dist", "dist-runtime"]; +const GENERATED_SOURCE_DECLARATION_PATHSPEC = ":(glob)extensions/**/*.d.ts"; +const SOURCE_DECLARATION_SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs"]; function removeDistPluginNodeModulesSymlinks(rootDir) { const extensionsDir = path.join(rootDir, "extensions"); @@ -93,6 +95,52 @@ export function pruneStaleRootChunkFiles(params = {}) { } } +export function pruneUntrackedGeneratedSourceDeclarations(params = {}) { + const cwd = params.cwd ?? process.cwd(); + const fsImpl = params.fs ?? fs; + const spawnSyncImpl = params.spawnSync ?? spawnSync; + let result; + try { + result = spawnSyncImpl( + "git", + ["ls-files", "--others", "--exclude-standard", "--", GENERATED_SOURCE_DECLARATION_PATHSPEC], + { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }, + ); + } catch { + return 0; + } + if (result.status !== 0 || typeof result.stdout !== "string") { + return 0; + } + + let removed = 0; + for (const rawPath of result.stdout.split(/\r?\n/u)) { + const relativePath = rawPath.trim().replaceAll("\\", "/"); + if (!relativePath.startsWith("extensions/") || !relativePath.endsWith(".d.ts")) { + continue; + } + const declarationPath = path.join(cwd, relativePath); + const sourceBase = declarationPath.slice(0, -".d.ts".length); + const hasMatchingSource = SOURCE_DECLARATION_SOURCE_EXTENSIONS.some((extension) => + fsImpl.existsSync(`${sourceBase}${extension}`), + ); + if (!hasMatchingSource) { + continue; + } + try { + fsImpl.rmSync(declarationPath, { force: true }); + removed += 1; + } catch { + // Best-effort cleanup; tsdown will still report any remaining stale files. + } + } + return removed; +} + export function pruneSourceCheckoutBundledPluginNodeModules(params = {}) { const cwd = params.cwd ?? process.cwd(); const logger = params.logger ?? console; @@ -326,6 +374,7 @@ function isMainModule() { if (isMainModule()) { pruneSourceCheckoutBundledPluginNodeModules(); + pruneUntrackedGeneratedSourceDeclarations(); pruneStaleRuntimeSymlinks(); cleanTsdownOutputRoots(); const invocation = resolveTsdownBuildInvocation(); diff --git a/test/scripts/tsdown-build.test.ts b/test/scripts/tsdown-build.test.ts index 1573032c047..7757937d29b 100644 --- a/test/scripts/tsdown-build.test.ts +++ b/test/scripts/tsdown-build.test.ts @@ -7,6 +7,7 @@ import { createTsdownOutputScanner, pruneSourceCheckoutBundledPluginNodeModules, pruneStaleRootChunkFiles, + pruneUntrackedGeneratedSourceDeclarations, resolveTsdownBuildInvocation, runTsdownBuildInvocation, } from "../../scripts/tsdown-build.mjs"; @@ -138,6 +139,37 @@ describe("resolveTsdownBuildInvocation", () => { await expectPathMissing(path.join(rootDir, "dist-runtime")); await expect(fsPromises.readFile(unrelatedFile, "utf8")).resolves.toBe("keep\n"); }); + + it("prunes untracked generated declaration files that shadow source entries", async () => { + const rootDir = createTempDir("openclaw-tsdown-source-dts-"); + const signalDir = path.join(rootDir, "extensions", "signal"); + const signalSrcDir = path.join(signalDir, "src"); + await fsPromises.mkdir(signalSrcDir, { recursive: true }); + await fsPromises.writeFile(path.join(signalDir, "api.ts"), "export {};\n"); + await fsPromises.writeFile(path.join(signalDir, "api.d.ts"), "export {};\n"); + await fsPromises.writeFile(path.join(signalSrcDir, "probe.ts"), "export {};\n"); + await fsPromises.writeFile(path.join(signalSrcDir, "probe.d.ts"), "export {};\n"); + await fsPromises.writeFile( + path.join(signalSrcDir, "ambient.d.ts"), + "declare const x: string;\n", + ); + + const removed = pruneUntrackedGeneratedSourceDeclarations({ + cwd: rootDir, + spawnSync: () => ({ + status: 0, + stdout: + "extensions/signal/api.d.ts\nextensions/signal/src/probe.d.ts\nextensions/signal/src/ambient.d.ts\n", + }), + }); + + expect(removed).toBe(2); + await expectPathMissing(path.join(signalDir, "api.d.ts")); + await expectPathMissing(path.join(signalSrcDir, "probe.d.ts")); + await expect( + fsPromises.readFile(path.join(signalSrcDir, "ambient.d.ts"), "utf8"), + ).resolves.toBe("declare const x: string;\n"); + }); }); describe("createTsdownOutputScanner", () => {