From 5ae011b735e081fdc3d398614f4a00d75ed60c70 Mon Sep 17 00:00:00 2001 From: RoboClaw Date: Thu, 24 Sep 2026 11:25:18 -0700 Subject: [PATCH] fix(build): package preparation fails on unrelated test types (#157318) * fix(build): bound standalone package declaration graphs Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix(build): migrate declaration producer audit and fixture consumers Derive public audit roots from the existing canonical export mapper and supply the editor augmentation graph to maintained lint fixtures. Keep producer entries, public exports, diagnostics and runtime behavior unchanged. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> --------- Co-authored-by: steipete <58493+steipete@users.noreply.github.com> --- config/knip.config.ts | 161 ++++-------------- extensions/tsconfig.json | 2 + packages/acp-core/package.json | 2 +- packages/gateway-client/package.json | 2 +- packages/gateway-protocol/package.json | 2 +- packages/media-core/package.json | 2 +- packages/media-generation-core/package.json | 2 +- .../media-understanding-common/package.json | 2 +- packages/net-policy/package.json | 2 +- packages/normalization-core/package.json | 2 +- .../src/package-exports.test.ts | 10 +- packages/retry/package.json | 2 +- packages/sdk/package.json | 2 +- scripts/build-workspace-package.mts | 109 ++++++++++++ scripts/lib/managed-child-process.mts | 3 +- scripts/lib/tsdown-output-roots.mts | 1 + scripts/lib/workspace-package-entries.mts | 30 ++++ src/tsconfig.json | 2 + test/scripts/check-deadcode-exports.test.ts | 83 ++++++--- test/scripts/dist-artifact-ownership.test.ts | 3 +- .../native-declaration-boundary.test.ts | 50 ++++++ test/scripts/oxlint-config.test.ts | 30 ++++ test/scripts/tsdown-config.test.ts | 31 ++++ .../tsdown-declaration-resolution.test.ts | 112 ++++++++++++ tsconfig.json | 2 +- tsdown.config.ts | 45 ++--- ui/tsconfig.json | 2 + 27 files changed, 503 insertions(+), 193 deletions(-) create mode 100644 scripts/build-workspace-package.mts create mode 100644 scripts/lib/workspace-package-entries.mts diff --git a/config/knip.config.ts b/config/knip.config.ts index 9b0292d76e4a..4a0a2aaa054e 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { collectPluginSourceEntries } from "../scripts/lib/bundled-plugin-build-entries.mjs"; import { createManagedHandoffBuildConfig } from "../scripts/lib/managed-handoff-build-config.mts"; import { runtimeProcessBuildEntries } from "../scripts/lib/runtime-process-build-entries.mts"; +import { buildPackageDistEntriesFromExports } from "../scripts/lib/workspace-package-entries.mts"; import { controlUiSource } from "../src/plugins/package-manifest.js"; const BUNDLED_PLUGIN_ROOT_DIR = "extensions"; @@ -538,6 +539,20 @@ const rootToolingAndWorkspaceDependencies = [ "string-width", ] as const; +function workspacePackage(packageDir: string, extraEntries: readonly string[] = []) { + const workspace = path.join("packages", packageDir); + return { + // Package exports, not shell arguments, own these public source entrypoints. + entry: [ + ...Object.values(buildPackageDistEntriesFromExports(packageDir)).map( + (source) => path.relative(workspace, source).replaceAll("\\", "/") + "!", + ), + ...extraEntries, + ], + project: ["src/**/*.ts!"], + } as const; +} + function bundledPluginWorkspace(extraEntries: readonly string[] = []) { return { entry: [...bundledPluginEntries, ...extraEntries], @@ -743,10 +758,7 @@ const config = { ], project: ["src/**/*.ts!"], }, - "packages/sdk": { - entry: ["src/index.ts!"], - project: ["src/**/*.ts!"], - }, + "packages/sdk": workspacePackage("sdk"), "packages/agent-core": { entry: [ "src/index.ts!", @@ -763,132 +775,21 @@ const config = { ], project: ["src/**/*.ts!"], }, - "packages/gateway-client": { - // Mirror package.json exports; these subpaths are published surfaces. - entry: ["src/index.ts!", "src/readiness.ts!", "src/timeouts.ts!"], - project: ["src/**/*.ts!"], - }, - "packages/gateway-protocol": { - // Mirror package.json exports; these subpaths are published surfaces. - entry: [ - "src/index.ts!", - "src/client-info.ts!", - "src/connect-error-details.ts!", - "src/frame-guards.ts!", - "src/schema.ts!", - "src/startup-unavailable.ts!", - "src/version.ts!", - ], - project: ["src/**/*.ts!"], - }, - "packages/model-catalog-core": { - // Mirror the published export map so package-owned runtime dependencies - // are traced from the TypeScript sources instead of the JS fallback. - entry: [ - "src/index.ts!", - "src/configured-model-refs.ts!", - "src/model-catalog-normalize.ts!", - "src/model-catalog-refs.ts!", - "src/model-catalog-types.ts!", - "src/provider-id.ts!", - "src/provider-model-id-normalization.ts!", - "src/provider-model-id-normalize.ts!", - ], - project: ["src/**/*.ts!"], - }, - "packages/normalization-core": { - // Mirror package.json exports; root and UI builds consume these source subpaths directly. - entry: [ - "src/index.ts!", - "src/agent-id.ts!", - "src/boolean-coercion.ts!", - "src/browser-error-runtime.ts!", - "src/error-coercion.ts!", - "src/expect.ts!", - "src/json-coercion.ts!", - "src/number-coercion.ts!", - "src/phone-presentation.ts!", - "src/record-coerce.ts!", - "src/result.ts!", - "src/string-coerce.ts!", - "src/string-normalization.ts!", - "src/utf16-slice.ts!", - ], - project: ["src/**/*.ts!"], - }, - "packages/net-policy": { - entry: ["src/index.ts!", "src/ip.ts!"], - project: ["src/**/*.ts!"], - }, - "packages/markdown-core": { - entry: [ - "src/index.ts!", - "src/code-spans.ts!", - "src/fences.ts!", - "src/frontmatter.ts!", - "src/ir.ts!", - "src/render.ts!", - "src/render-aware-chunking.ts!", - "src/tables.ts!", - "src/types.ts!", - ], - project: ["src/**/*.ts!"], - }, - "packages/media-core": { - entry: [ - "src/index.ts!", - "src/attachment-classify.ts!", - "src/base64.ts!", - "src/constants.ts!", - "src/content-length.ts!", - "src/file-name.ts!", - "src/inbound-path-policy.ts!", - "src/inline-image-data-url.ts!", - "src/media-source-url.ts!", - "src/mime.ts!", - "src/read-byte-stream-with-limit.ts!", - ], - project: ["src/**/*.ts!"], - }, - "packages/acp-core": { - entry: [ - "src/index.ts!", - "src/meta.ts!", - "src/session.ts!", - "src/session-interaction-mode.ts!", - "src/session-lineage-meta.ts!", - "src/types.ts!", - "src/runtime/error-text.ts!", - "src/runtime/errors.ts!", - "src/runtime/session-identifiers.ts!", - "src/runtime/session-identity.ts!", - "src/runtime/types.ts!", - ], - project: ["src/**/*.ts!"], - }, - "packages/terminal-core": { - entry: [ - "src/index.ts!", - "src/ansi.ts!", - "src/decorative-emoji.ts!", - "src/health-style.ts!", - "src/links.ts!", - "src/note.ts!", - "src/osc-progress.ts!", - "src/palette.ts!", - "src/progress-line.ts!", - "src/prompt-select-styled.ts!", - "src/prompt-select-styled-params.ts!", - "src/prompt-style.ts!", - "src/restore.ts!", - "src/safe-text.ts!", - "src/stream-writer.ts!", - "src/table.ts!", - "src/terminal-link.ts!", - "src/theme.ts!", - ], - project: ["src/**/*.ts!"], - }, + "packages/gateway-client": workspacePackage("gateway-client"), + "packages/gateway-protocol": workspacePackage("gateway-protocol"), + "packages/model-catalog-core": workspacePackage("model-catalog-core"), + "packages/normalization-core": workspacePackage("normalization-core", [ + // extensions/qa-lab/web/vite.config.ts aliases error-runtime to this private browser implementation. + "src/browser-error-runtime.ts!", + ]), + "packages/net-policy": workspacePackage("net-policy"), + "packages/markdown-core": workspacePackage("markdown-core"), + "packages/media-core": workspacePackage("media-core"), + "packages/acp-core": workspacePackage("acp-core"), + "packages/terminal-core": workspacePackage("terminal-core"), + "packages/retry": workspacePackage("retry"), + "packages/media-generation-core": workspacePackage("media-generation-core"), + "packages/media-understanding-common": workspacePackage("media-understanding-common"), "packages/memory-host-sdk": { entry: ["src/*.ts!", "src/host/embeddings.types.ts!"], project: ["src/**/*.ts!"], diff --git a/extensions/tsconfig.json b/extensions/tsconfig.json index 04bb70f1b5e0..cfee4533bc83 100644 --- a/extensions/tsconfig.json +++ b/extensions/tsconfig.json @@ -1,5 +1,7 @@ { "extends": "../tsconfig.json", + // This editor/lint graph includes tests; retain their compatibility augmentation. + "files": ["../src/config/sessions/session-entry.test-compat.d.ts"], "include": [ "../src/**/*.d.ts", "../ui/**/*.d.ts", diff --git a/packages/acp-core/package.json b/packages/acp-core/package.json index dfdd76559caf..90ec4a0b47b1 100644 --- a/packages/acp-core/package.json +++ b/packages/acp-core/package.json @@ -69,6 +69,6 @@ "@openclaw/normalization-core": "workspace:*" }, "scripts": { - "build": "tsdown src/index.ts src/error-format.ts src/meta.ts src/session.ts src/session-interaction-mode.ts src/session-lineage-meta.ts src/types.ts src/runtime/error-text.ts src/runtime/errors.ts src/runtime/session-identifiers.ts src/runtime/session-identity.ts src/runtime/types.ts --no-config --platform node --format esm --dts --out-dir dist --clean" + "build": "node --import ../../scripts/tsx.mjs ../../scripts/build-workspace-package.mts acp-core" } } diff --git a/packages/gateway-client/package.json b/packages/gateway-client/package.json index 5ead6a72f0f2..5e0b1610fd10 100644 --- a/packages/gateway-client/package.json +++ b/packages/gateway-client/package.json @@ -62,7 +62,7 @@ } }, "scripts": { - "build": "tsdown src/index.ts src/browser.ts src/readiness.ts src/scope-upgrade.ts src/timeouts.ts src/websocket-data.ts --no-config --platform node --format esm --dts --out-dir dist --clean" + "build": "node --import ../../scripts/tsx.mjs ../../scripts/build-workspace-package.mts gateway-client" }, "dependencies": { "@openclaw/gateway-protocol": "workspace:*", diff --git a/packages/gateway-protocol/package.json b/packages/gateway-protocol/package.json index adae137ead88..774b4edf1873 100644 --- a/packages/gateway-protocol/package.json +++ b/packages/gateway-protocol/package.json @@ -89,7 +89,7 @@ } }, "scripts": { - "build": "tsdown src/index.ts src/client-info.ts src/connect-error-details.ts src/frame-guards.ts src/gateway-error-details.ts src/restart-unavailable.ts src/schema.ts src/startup-unavailable.ts src/version.ts src/system-agent-context.ts src/theme.ts --no-config --platform node --format esm --dts --out-dir dist --clean && node --import tsx ../../scripts/check-protocol-registry-types.mts", + "build": "node --import ../../scripts/tsx.mjs ../../scripts/build-workspace-package.mts gateway-protocol && node --import tsx ../../scripts/check-protocol-registry-types.mts", "prepack": "pnpm run build && node --import tsx ../../scripts/protocol-gen.ts --out ./protocol.schema.json" }, "dependencies": { diff --git a/packages/media-core/package.json b/packages/media-core/package.json index 395acee3015b..90cbc404edc8 100644 --- a/packages/media-core/package.json +++ b/packages/media-core/package.json @@ -70,6 +70,6 @@ "file-type": "22.1.0" }, "scripts": { - "build": "tsdown src/index.ts src/attachment-classify.ts src/base64.ts src/constants.ts src/content-length.ts src/file-name.ts src/inbound-path-policy.ts src/inline-image-data-url.ts src/media-source-url.ts src/mime.ts src/read-byte-stream-with-limit.ts --no-config --platform node --format esm --dts --out-dir dist --clean" + "build": "node --import ../../scripts/tsx.mjs ../../scripts/build-workspace-package.mts media-core" } } diff --git a/packages/media-generation-core/package.json b/packages/media-generation-core/package.json index c80fc9f92b10..f3a9ba57abc5 100644 --- a/packages/media-generation-core/package.json +++ b/packages/media-generation-core/package.json @@ -39,6 +39,6 @@ } }, "scripts": { - "build": "tsdown src/index.ts src/capability-model-ref.ts src/catalog.ts src/model-ref.ts src/normalization.ts --no-config --platform node --format esm --dts --out-dir dist --clean" + "build": "node --import ../../scripts/tsx.mjs ../../scripts/build-workspace-package.mts media-generation-core" } } diff --git a/packages/media-understanding-common/package.json b/packages/media-understanding-common/package.json index e00a0e6743f8..91a43d6e1ff1 100644 --- a/packages/media-understanding-common/package.json +++ b/packages/media-understanding-common/package.json @@ -59,6 +59,6 @@ } }, "scripts": { - "build": "tsdown src/active-model.ts src/defaults.ts src/errors.ts src/format.ts src/openai-compatible-video.ts src/output-extract.ts src/provider-id.ts src/provider-supports.ts src/types.ts src/video.ts --no-config --platform node --format esm --dts --out-dir dist --clean" + "build": "node --import ../../scripts/tsx.mjs ../../scripts/build-workspace-package.mts media-understanding-common" } } diff --git a/packages/net-policy/package.json b/packages/net-policy/package.json index ccea74e21c0a..e9dbeade1e5d 100644 --- a/packages/net-policy/package.json +++ b/packages/net-policy/package.json @@ -41,7 +41,7 @@ } }, "scripts": { - "build": "tsdown src/index.ts src/ip.ts src/ipv4.ts src/redact-sensitive-url.ts src/url-protocol.ts src/url-userinfo.ts --no-config --platform node --format esm --dts --out-dir dist --clean" + "build": "node --import ../../scripts/tsx.mjs ../../scripts/build-workspace-package.mts net-policy" }, "dependencies": { "@openclaw/normalization-core": "workspace:*", diff --git a/packages/normalization-core/package.json b/packages/normalization-core/package.json index 46d0e26aab87..ff3fee1e5e19 100644 --- a/packages/normalization-core/package.json +++ b/packages/normalization-core/package.json @@ -136,7 +136,7 @@ } }, "scripts": { - "build": "tsdown src/index.ts src/agent-id.ts src/agent-run-terminal-outcome.ts src/boolean-coercion.ts src/cjk-chars.ts src/code-points.ts src/error-coercion.ts src/expect.ts src/grapheme.ts src/home-dir.ts src/json-coercion.ts src/json-schema.ts src/markdown-plain-text.ts src/mountinfo-path.ts src/node-crypto.ts src/number-coercion.ts src/phone-presentation.ts src/promise-like.ts src/record-coerce.ts src/result.ts src/stable-node-path.ts src/stable-stringify.ts src/string-coerce.ts src/string-normalization.ts src/utf16-slice.ts --no-config --platform node --format esm --dts --out-dir dist --clean" + "build": "node --import ../../scripts/tsx.mjs ../../scripts/build-workspace-package.mts normalization-core" }, "dependencies": { "libphonenumber-js": "1.13.13", diff --git a/packages/normalization-core/src/package-exports.test.ts b/packages/normalization-core/src/package-exports.test.ts index 02059b71acea..9b4bd9995a30 100644 --- a/packages/normalization-core/src/package-exports.test.ts +++ b/packages/normalization-core/src/package-exports.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; +import { buildPackageDistEntriesFromExports } from "../../../scripts/lib/workspace-package-entries.mts"; type PackageManifest = { exports: Record< @@ -19,6 +20,11 @@ const manifest = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as Package describe("normalization-core package exports", () => { it("builds every focused export from its matching source entry", () => { + const entries = buildPackageDistEntriesFromExports("normalization-core"); + expect(manifest.scripts.build.split(/\s+/u).slice(-2)).toEqual([ + "../../scripts/build-workspace-package.mts", + "normalization-core", + ]); for (const [subpath, target] of Object.entries(manifest.exports)) { const entryName = subpath === "." ? "index" : subpath.slice(2); expect(target).toEqual({ @@ -26,7 +32,9 @@ describe("normalization-core package exports", () => { import: `./dist/${entryName}.mjs`, default: `./dist/${entryName}.mjs`, }); - expect(manifest.scripts.build.split(/\s+/u)).toContain(`src/${entryName}.ts`); + const source = `packages/normalization-core/src/${entryName}.ts`; + expect(entries[entryName]).toBe(source); + expect(fs.existsSync(source)).toBe(true); } }); }); diff --git a/packages/retry/package.json b/packages/retry/package.json index a184531ea602..1fb4ca6fcf74 100644 --- a/packages/retry/package.json +++ b/packages/retry/package.json @@ -16,6 +16,6 @@ } }, "scripts": { - "build": "tsdown src/index.ts --no-config --platform node --format esm --dts --out-dir dist --clean" + "build": "node --import ../../scripts/tsx.mjs ../../scripts/build-workspace-package.mts retry" } } diff --git a/packages/sdk/package.json b/packages/sdk/package.json index f10cbb3dea5c..75754f627634 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -16,7 +16,7 @@ } }, "scripts": { - "build": "tsdown src/index.ts --no-config --platform node --format esm --dts --out-dir dist --clean" + "build": "node --import ../../scripts/tsx.mjs ../../scripts/build-workspace-package.mts sdk" }, "dependencies": { "@openclaw/gateway-client": "workspace:*", diff --git a/scripts/build-workspace-package.mts b/scripts/build-workspace-package.mts new file mode 100644 index 000000000000..e6d14472c829 --- /dev/null +++ b/scripts/build-workspace-package.mts @@ -0,0 +1,109 @@ +// Standalone package builds share the root build's entry and declaration ownership. +import fs from "node:fs"; +import path from "node:path"; +import type { InlineConfig, PackageJsonWithPath, UserConfig } from "tsdown"; +import { isDirectRunUrl } from "./lib/direct-run.mjs"; +import { withDistArtifactOwnership } from "./lib/dist-artifact-ownership.mts"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; +import { tsdownPackageOutputRoot } from "./lib/tsdown-output-roots.mts"; +import { cleanTsdownOutputRoots, sanitizeTsdownBuildOutputRoots } from "./tsdown-build.mts"; + +export function selectWorkspacePackageBuildConfig( + configs: UserConfig[], + packageName: string, + target: string | false, +): InlineConfig { + const outDir = tsdownPackageOutputRoot(packageName); + const selected = configs.filter((config) => config.outDir === outDir); + if (selected.length !== 1) { + throw new Error(`Expected one build config for ${packageName}; found ${selected.length}`); + } + return { + ...selected[0], + config: false, + clean: false, + dts: true, + platform: "node", + format: "esm", + target, + concurrency: 1, + // Application folding and dependency policy do not belong to standalone libraries. + env: {}, + deps: {}, + inputOptions: undefined, + }; +} + +export async function buildWorkspacePackage(packageName: string) { + const root = resolveRepoRoot(import.meta.url); + const output = tsdownPackageOutputRoot(packageName); + const packageJsonPath = path.join(root, "packages", packageName, "package.json"); + const manifest: PackageJsonWithPath = { + ...JSON.parse(fs.readFileSync(packageJsonPath, "utf8")), + packageJsonPath, + }; + const { minVersion } = await import("semver"); + const nodeEngine = manifest.engines?.node; + const nodeVersion = nodeEngine ? minVersion(nodeEngine) : null; + if (nodeEngine && !nodeVersion) { + throw new Error(`Invalid Node engine for ${packageName}`); + } + const previousCwd = process.cwd(); + process.chdir(root); + try { + // The canonical config reads manifests relative to the repository root. + const { default: configs } = await import("../tsdown.config.ts"); + const config = selectWorkspacePackageBuildConfig( + configs, + packageName, + // No engine means no transformation, as in the package-local tsdown CLI. + nodeVersion ? `node${nodeVersion.version}` : false, + ); + const canonicalHooks = config.hooks; + config.hooks = async (hooks) => { + if (typeof canonicalHooks === "function") { + await canonicalHooks(hooks); + } else if (canonicalHooks) { + hooks.addHooks(canonicalHooks); + } + // tsdown indexes package completion before this hook, but creates dependency + // plugins afterward. Preserve its coordinator identity while supplying the + // standalone library's metadata to its native dependency-policy owner. + hooks.hook("build:prepare", ({ options }) => { + if (!options.pkg) { + throw new Error("Missing build package metadata"); + } + options.pkg.dependencies = manifest.dependencies; + options.pkg.peerDependencies = manifest.peerDependencies; + options.pkg.peerDependenciesMeta = manifest.peerDependenciesMeta; + options.pkg.optionalDependencies = manifest.optionalDependencies; + }); + }; + await withDistArtifactOwnership(root, async () => { + cleanTsdownOutputRoots({ + cwd: root, + roots: [output], + env: { ...process.env, OPENCLAW_RUN_NODE_SKIP_DTS_BUILD: "0" }, + }); + const { build } = await import("tsdown"); + const { bundles } = await build({ ...config, cwd: root }); + try { + sanitizeTsdownBuildOutputRoots(["--out-dir", output], root); + } finally { + for (const bundle of bundles) { + await bundle[Symbol.asyncDispose](); + } + } + }); + } finally { + process.chdir(previousCwd); + } +} + +if (isDirectRunUrl(process.argv[1], import.meta.url)) { + const [packageName, ...extra] = process.argv.slice(2); + if (!packageName || extra.length) { + throw new Error("Expected one workspace package name"); + } + await buildWorkspacePackage(packageName); +} diff --git a/scripts/lib/managed-child-process.mts b/scripts/lib/managed-child-process.mts index d2fae0b17782..6dec4115f671 100644 --- a/scripts/lib/managed-child-process.mts +++ b/scripts/lib/managed-child-process.mts @@ -165,7 +165,8 @@ export function hasUnjoinedWork(value: unknown): boolean { if ("processTreeState" in current && current.processTreeState !== "terminated") { return true; } - if (current instanceof AggregateError) { + // Rolldown preserves plugin failures in a plain Error.errors array. + if ("errors" in current && Array.isArray(current.errors)) { for (const error of current.errors) { pending.push(error); } diff --git a/scripts/lib/tsdown-output-roots.mts b/scripts/lib/tsdown-output-roots.mts index 707a04b07386..06a26231f1ba 100644 --- a/scripts/lib/tsdown-output-roots.mts +++ b/scripts/lib/tsdown-output-roots.mts @@ -13,6 +13,7 @@ const TSDOWN_PACKAGE_NAMES = [ "net-policy", "normalization-core", "retry", + "sdk", "terminal-core", "acp-core", ] as const; diff --git a/scripts/lib/workspace-package-entries.mts b/scripts/lib/workspace-package-entries.mts new file mode 100644 index 000000000000..76ff8fd64da2 --- /dev/null +++ b/scripts/lib/workspace-package-entries.mts @@ -0,0 +1,30 @@ +import fs from "node:fs"; +import path from "node:path"; + +/** Canonical source entries for workspace packages with dist ESM exports. */ +export function buildPackageDistEntriesFromExports(packageDir: string): Record { + const packageJsonPath = path.join("packages", packageDir, "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { + exports?: Record; + }; + const entries: Record = {}; + for (const [exportKey, value] of Object.entries(packageJson.exports ?? {})) { + const entry = + exportKey === "." ? "index" : exportKey.startsWith("./") ? exportKey.slice(2) : ""; + if (!entry || entry.includes("..")) { + continue; + } + const importPath = + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record).import + : value; + if (typeof importPath !== "string" || !importPath.startsWith("./dist/")) { + continue; + } + const sourcePath = importPath + .replace(/^\.\/dist\//u, `packages/${packageDir}/src/`) + .replace(/\.mjs$/u, ".ts"); + entries[entry] = sourcePath; + } + return Object.fromEntries(Object.entries(entries).toSorted(([a], [b]) => a.localeCompare(b))); +} diff --git a/src/tsconfig.json b/src/tsconfig.json index 56d15b0c4441..360ef9f1986a 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -1,5 +1,7 @@ { "extends": "../tsconfig.json", + // This editor/lint graph includes tests; retain their compatibility augmentation. + "files": ["config/sessions/session-entry.test-compat.d.ts"], "compilerOptions": { "allowJs": true }, diff --git a/test/scripts/check-deadcode-exports.test.ts b/test/scripts/check-deadcode-exports.test.ts index 9b59f313d3fe..aed2c13291b9 100644 --- a/test/scripts/check-deadcode-exports.test.ts +++ b/test/scripts/check-deadcode-exports.test.ts @@ -321,12 +321,56 @@ describe("check-deadcode-exports", () => { }); it.each([ - "packages/agent-core", - "packages/markdown-core", - "packages/media-core", - "packages/acp-core", - "packages/terminal-core", - ] as const)("mirrors the published entry map for %s", (workspace) => { + "gateway-client", + "gateway-protocol", + "sdk", + "retry", + "normalization-core", + "net-policy", + "media-understanding-common", + "media-generation-core", + "media-core", + "acp-core", + "markdown-core", + "terminal-core", + "model-catalog-core", + ] as const)("derives public audit roots from the %s export map", (packageDir) => { + const workspace = "packages/" + packageDir; + const manifest = JSON.parse(fs.readFileSync(workspace + "/package.json", "utf8")) as { + exports: Record; + }; + const expected = Object.values(manifest.exports) + .map(({ import: target }) => target.replace("./dist/", "src/").replace(/\.mjs$/u, ".ts!")) + .toSorted(); + if (packageDir === "normalization-core") { + // The QA Lab Vite alias is a distinct source consumer, not a package export. + expect(fs.readFileSync("extensions/qa-lab/web/vite.config.ts", "utf8")).toContain( + "../../../packages/normalization-core/src/browser-error-runtime.ts", + ); + expected.push("src/browser-error-runtime.ts!"); + expected.sort(); + } + const production = Object.entries(knipConfig.workspaces).find( + ([name]) => name === workspace, + )?.[1]; + const full = allExportsKnipConfig.workspaces[workspace]; + expect(production?.entry.toSorted(), workspace).toEqual(expected); + expect(production?.project).toEqual(["src/**/*.ts!"]); + expect(full?.entry).toEqual(expect.arrayContaining(expected)); + for (const entry of expected) { + expect(fs.existsSync(workspace + "/" + entry.slice(0, -1)), entry).toBe(true); + } + }); + + it("does not promote private implementation or build-only artifacts to public audit roots", () => { + expect(knipConfig.workspaces["packages/gateway-client"].entry).not.toContain( + "src/protocol-client.ts!", + ); + expect(knipConfig.workspaces["packages/acp-core"].entry).not.toContain("src/error-format.ts!"); + }); + + it("preserves the agent-core relocated source-entry contract", () => { + const workspace = "packages/agent-core"; const packageJson = JSON.parse( fs.readFileSync(new URL(`../../${workspace}/package.json`, import.meta.url), "utf8"), ) as { exports: Record }; @@ -339,21 +383,18 @@ describe("check-deadcode-exports", () => { new URL(`../../${workspace}/${entry.slice(0, -"!".length)}`, import.meta.url), ), ); - expect(missingConventionalEntries).toEqual( - workspace === "packages/agent-core" - ? ["src/harness/compaction.ts!", "src/harness/branch-summarization.ts!"] - : [], - ); - if (workspace === "packages/agent-core") { - for (const sourcePath of [ - "src/harness/compaction/compaction.ts", - "src/harness/compaction/branch-summarization.ts", - ]) { - expect( - fs.existsSync(new URL(`../../${workspace}/${sourcePath}`, import.meta.url)), - sourcePath, - ).toBe(true); - } + expect(missingConventionalEntries).toEqual([ + "src/harness/compaction.ts!", + "src/harness/branch-summarization.ts!", + ]); + for (const sourcePath of [ + "src/harness/compaction/compaction.ts", + "src/harness/compaction/branch-summarization.ts", + ]) { + expect( + fs.existsSync(new URL(`../../${workspace}/${sourcePath}`, import.meta.url)), + sourcePath, + ).toBe(true); } const expected = conventionalEntries .filter((entry) => !missingConventionalEntries.includes(entry)) diff --git a/test/scripts/dist-artifact-ownership.test.ts b/test/scripts/dist-artifact-ownership.test.ts index 42e8448f4a4a..7aad253a3630 100644 --- a/test/scripts/dist-artifact-ownership.test.ts +++ b/test/scripts/dist-artifact-ownership.test.ts @@ -573,7 +573,7 @@ describe.skipIf(process.platform === "win32")("dist artifact ownership", () => { expect(fs.existsSync(path.join(resolveDistArtifactLockPath(root), "unjoined"))).toBe(false); }); - it.for(["cause", "error", "cyclic aggregate"])( + it.for(["cause", "error", "cyclic aggregate", "bundler errors"])( "retains ownership for unjoined work nested in %s", async (kind, { signal }) => { // Retention deliberately keeps lock handles open; a joined child owns @@ -591,6 +591,7 @@ describe.skipIf(process.platform === "win32")("dist artifact ownership", () => { const aggregate = new AggregateError([], 'sibling cleanup'); aggregate.errors.push(aggregate, new Error('command failed', { cause: uncertainty })); const error = kind === 'cyclic aggregate' ? aggregate + : kind === 'bundler errors' ? Object.assign(new Error('Build failed'), { errors: [aggregate] }) : new Error('command failed', { cause: kind === 'cause' ? uncertainty : { error: uncertainty } }); const outcome = await withDistArtifactOwnership(process.cwd(), async () => { throw error; diff --git a/test/scripts/native-declaration-boundary.test.ts b/test/scripts/native-declaration-boundary.test.ts index a2b2af28b488..483c87b43b4b 100644 --- a/test/scripts/native-declaration-boundary.test.ts +++ b/test/scripts/native-declaration-boundary.test.ts @@ -7,6 +7,7 @@ import { portableRelativePath } from "../../scripts/lib/build-artifact-cache.mts import { BoundaryInputSnapshot } from "../../scripts/lib/extension-boundary-inputs.mts"; import { createDeclarationInputBoundary } from "../../scripts/lib/local-check-runtime.mts"; import { emitNativeDeclarations } from "../../scripts/lib/native-declaration-emitter.mts"; +import { readNativeTypeScriptConfig } from "../../scripts/lib/native-typescript-config.mts"; import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; import { installNativeAncestorTypes, @@ -17,6 +18,55 @@ import { const roots = useAutoCleanupTempDirTracker(afterEach); +it("keeps test-only ambient augmentation out of declaration roots but in test graphs", () => { + const root = fs.realpathSync.native(roots.make("native-declaration-production-roots-")); + // Use the actual production/test selection rules with a tiny semantic-free fixture. + const testConfigs = [ + "test/tsconfig/tsconfig.test.json", + "src/tsconfig.json", + "ui/tsconfig.json", + "extensions/tsconfig.json", + ]; + // Actual emit owners: tsgo:prod UI/plugin configs are noEmit typecheck graphs. + const productionConfigs = [ + "tsconfig.json", + "packages/plugin-sdk/tsconfig.json", + "extensions/browser/tsconfig.json", + ]; + for (const config of [ + ...productionConfigs, + ...testConfigs, + "extensions/tsconfig.package-boundary.paths.json", + "extensions/tsconfig.package-boundary.base.json", + ]) { + writeNativeFixtureFile(root, config, fs.readFileSync(config, "utf8")); + } + const productionAmbient = "src/types/production.d.ts"; + const testAmbient = "src/config/sessions/session-entry.test-compat.d.ts"; + writeNativeFixtureFile(root, productionAmbient, "declare const productionOrigin: string;"); + writeNativeFixtureFile(root, testAmbient, 'import "./runtime.js";'); + writeNativeFixtureFile(root, "src/config/sessions/runtime.ts", "export const value = 1;"); + for (const file of [ + "packages/example/src/index.ts", + "src/plugin-sdk/index.ts", + "extensions/browser/src/index.ts", + ]) { + writeNativeFixtureFile(root, file, "export const value = 1;"); + } + const configuredRoots = (configFileName: string) => + readNativeTypeScriptConfig({ cwd: root, configFileName }).fileNames.map((file) => + path.relative(root, file).replaceAll(path.sep, "/"), + ); + const production = configuredRoots("tsconfig.json"); + expect(production).toContain(productionAmbient); + for (const config of productionConfigs) { + expect(configuredRoots(config), config).not.toContain(testAmbient); + } + for (const config of testConfigs) { + expect(configuredRoots(config), config).toContain(testAmbient); + } +}); + it.each([true, false])( "diagnoses declaration escapes with an ancestor install=%s", (ancestorInstall) => { diff --git a/test/scripts/oxlint-config.test.ts b/test/scripts/oxlint-config.test.ts index 5b463854f510..089c3ddc8f56 100644 --- a/test/scripts/oxlint-config.test.ts +++ b/test/scripts/oxlint-config.test.ts @@ -132,6 +132,33 @@ function readJson(filePath: string): unknown { return JSON5.parse(fs.readFileSync(filePath, "utf8")); } +function writeSessionCompatibilityFixture(root: string) { + const directory = path.join(root, "src/config/sessions"); + fs.mkdirSync(directory, { recursive: true }); + // Real editor configs explicitly root this augmentation. Keep its real contents, + // with fixture-owned base modules rather than importing the whole session graph. + fs.copyFileSync( + "src/config/sessions/session-entry.test-compat.d.ts", + path.join(directory, "session-entry.test-compat.d.ts"), + ); + for (const [file, interfaces] of [ + ["types.ts", ["SessionEntry", "InternalSessionEntry"]], + [ + "session-accessor.types.ts", + [ + "SessionTranscriptRuntimeTarget", + "SessionTranscriptTurnPersistResult", + "SessionTranscriptReadTarget", + ], + ], + ] as const) { + fs.writeFileSync( + path.join(directory, file), + interfaces.map((name) => "export interface " + name + " { id: string; }").join("\n"), + ); + } +} + describe("oxlint config", () => { it("enforces namespace, evaluation, and unused-binding policies with the installed binary", () => { const tempRoot = fs.realpathSync(createTempDir("openclaw-oxlint-policy-")); @@ -262,6 +289,7 @@ describe("oxlint config", () => { fs.copyFileSync(file, target); } } + writeSessionCompatibilityFixture(tempRoot); fs.symlinkSync(path.resolve("node_modules"), path.join(tempRoot, "node_modules"), "junction"); const fixtures = { "src/imported.ts": "export function work(): Promise { return Promise.resolve(); }", @@ -376,6 +404,7 @@ describe("oxlint config", () => { fs.copyFileSync(file, target); } } + writeSessionCompatibilityFixture(tempRoot); fs.symlinkSync(path.resolve("node_modules"), path.join(tempRoot, "node_modules"), "junction"); const source = [ 'import { work } from "../packages/imported.js";', @@ -464,6 +493,7 @@ describe("oxlint config", () => { fs.copyFileSync(file, target); } } + writeSessionCompatibilityFixture(tempRoot); fs.symlinkSync(path.resolve("node_modules"), path.join(tempRoot, "node_modules"), "junction"); const supportFiles = [ "src/cli/diagnostics.test-support.ts", diff --git a/test/scripts/tsdown-config.test.ts b/test/scripts/tsdown-config.test.ts index 0a5379a4e70e..ecbf7b064506 100644 --- a/test/scripts/tsdown-config.test.ts +++ b/test/scripts/tsdown-config.test.ts @@ -1042,6 +1042,37 @@ console.log("relocated Bash parser works without native grammar package"); }, ); + it("gives every standalone declaration caller one canonical package config", () => { + for (const packageName of [ + "gateway-client", + "gateway-protocol", + "sdk", + "retry", + "normalization-core", + "net-policy", + "media-understanding-common", + "media-generation-core", + "media-core", + "acp-core", + ]) { + const manifest = JSON.parse( + fs.readFileSync(`packages/${packageName}/package.json`, "utf8"), + ) as { scripts: { build: string }; exports: Record }; + expect(manifest.scripts.build).toContain(`build-workspace-package.mts ${packageName}`); + const selected = configs.filter((config) => config.outDir === `packages/${packageName}/dist`); + expect(selected, packageName).toHaveLength(1); + const sources = Object.values(selected[0]!.entry ?? {}); + for (const entry of Object.values(manifest.exports)) { + expect(sources).toContain( + entry.import.replace("./dist/", `packages/${packageName}/src/`).replace(/\.mjs$/u, ".ts"), + ); + } + if (packageName === "acp-core") { + expect(sources).toContain("packages/acp-core/src/error-format.ts"); + } + } + }); + it("isolates runtime output from bounded declaration-only graphs", () => { const packageConfigs = configs.filter((entry) => entry.name === TSDOWN_PACKAGE_CONFIG_GROUP); const unifiedRuntimeConfig = configs.find( diff --git a/test/scripts/tsdown-declaration-resolution.test.ts b/test/scripts/tsdown-declaration-resolution.test.ts index 1e5261a9a008..7e41720ec8c6 100644 --- a/test/scripts/tsdown-declaration-resolution.test.ts +++ b/test/scripts/tsdown-declaration-resolution.test.ts @@ -88,6 +88,118 @@ function nestedFixture(groups: readonly string[] = TSDOWN_PLUGIN_SDK_DTS_CONFIG_ } describe("tsdown checkout declaration resolution", () => { + it("bounds standalone package builds while preserving public declarations and sibling outputs", () => { + const { root, write } = containedFixture(); + fs.symlinkSync( + fs.realpathSync("node_modules/semver"), + path.join(root, "node_modules/semver"), + "junction", + ); + write( + "scripts/build-workspace-package.mts", + fs.readFileSync("scripts/build-workspace-package.mts", "utf8"), + ); + const manifest = JSON.parse( + fs.readFileSync("packages/gateway-client/package.json", "utf8"), + ) as { + exports: Record; + dependencies?: Record; + }; + manifest.dependencies = { ...manifest.dependencies, "standalone-dependency": "1.0.0" }; + write("packages/gateway-client/package.json", JSON.stringify(manifest)); + write( + "node_modules/standalone-dependency/package.json", + JSON.stringify({ + name: "standalone-dependency", + version: "1.0.0", + type: "module", + exports: { "./value": { types: "./value.d.ts", import: "./value.js" } }, + }), + ); + write( + "node_modules/standalone-dependency/value.d.ts", + "export declare const externalValue: string;", + ); + write( + "node_modules/standalone-dependency/value.js", + 'export const externalValue = "package-owned";', + ); + for (const entry of Object.values(manifest.exports)) { + write( + entry.import.replace("./dist/", "packages/gateway-client/src/").replace(/\.mjs$/u, ".ts"), + 'export const marker = "bounded";\n', + ); + } + write( + "packages/gateway-client/src/index.ts", + ` + export const marker = "bounded"; + export { externalValue } from "standalone-dependency/value"; + declare const process: { env: { NODE_ENV?: string } }; + export function runtimeMode(env: { NODE_ENV?: string }) { + return env.NODE_ENV ?? process.env.NODE_ENV; + } + `, + ); + write("packages/sdk/src/index.ts", 'export const sdkMarker = "sdk";\n'); + // Upstream whole-project declaration emit rejects this unselected test helper (TS4094). + write( + "src/unrelated.test-support.ts", + "export const hidden = new (class { private value = 1; })();\n", + ); + write("packages/gateway-client/dist/obsolete.d.mts", "export declare const retired: 1;"); + write("dist/keep.txt", "root output"); + write("packages/gateway-protocol/dist/keep.txt", "sibling output"); + const result = runFixtureModule( + root, + ` +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { buildWorkspacePackage } from "./scripts/build-workspace-package.mts"; +import configs from "./tsdown.config.ts"; +const root = process.cwd(); +for (const [name, target] of [["sdk", undefined], ["gateway-client", ["node22.19.0"]]]) { + const config = configs.find(config => config.outDir === "packages/" + name + "/dist"); + const original = config.hooks; + config.hooks = async hooks => { + await original(hooks); + hooks.hook("build:prepare", ({ options }) => assert.deepEqual(options.target, target)); + }; +} +await buildWorkspacePackage("sdk"); +assert.match(fs.readFileSync("packages/sdk/dist/index.d.mts", "utf8"), /sdkMarker.*"sdk"/); +process.env.OPENCLAW_RUN_NODE_SKIP_DTS_BUILD = "1"; +const packageDir = path.join(root, "packages/gateway-client"); +process.chdir(packageDir); +await buildWorkspacePackage("gateway-client"); +assert.equal(process.cwd(), packageDir); +assert.equal(fs.existsSync("dist/obsolete.d.mts"), false); +fs.writeFileSync(path.join(root, "node_modules/standalone-dependency/value.js"), 'export const externalValue = "installed-update";'); +process.env.NODE_ENV = "runtime-mode"; +const output = await import(pathToFileURL(path.join(packageDir, "dist/index.mjs")).href); +assert.deepEqual( + { mode: output.runtimeMode({}), external: output.externalValue }, + { mode: "runtime-mode", external: "installed-update" }, +); +const manifest = JSON.parse(fs.readFileSync("package.json", "utf8")); +for (const entry of Object.values(manifest.exports)) { + assert.ok(fs.existsSync(entry.import), entry.import); + assert.match(fs.readFileSync(entry.types, "utf8"), /marker.*"bounded"/); +} +assert.equal(fs.readFileSync(path.join(root, "dist/keep.txt"), "utf8"), "root output"); +assert.equal(fs.readFileSync(path.join(root, "packages/gateway-protocol/dist/keep.txt"), "utf8"), "sibling output"); +fs.writeFileSync("src/index.ts", 'export const invalid: number = "selected";'); +await assert.rejects(buildWorkspacePackage("gateway-client"), /Native declaration emit failed/); +assert.equal(process.cwd(), packageDir); +console.log("standalone package boundary verified"); +`, + ); + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(result.stdout).toContain("standalone package boundary verified"); + }); + it.runIf(process.platform === "win32")( "starts the checkout compiler when its Windows executable uses an extended-length path", () => { diff --git a/tsconfig.json b/tsconfig.json index 76024fd2916c..af4d0ab4e67d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -318,5 +318,5 @@ } }, "include": ["src/**/*", "ui/**/*", "extensions/**/*", "packages/**/*"], - "exclude": ["node_modules", "dist", "**/dist/**"] + "exclude": ["node_modules", "dist", "**/dist/**", "**/*.test-compat.d.ts"] } diff --git a/tsdown.config.ts b/tsdown.config.ts index b1313c72bd04..23f42eeddcbb 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -44,6 +44,7 @@ import { createWorkerDeployBuildPlugin, WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID, } from "./scripts/lib/worker-deploy-build-plugin.mts"; +import { buildPackageDistEntriesFromExports } from "./scripts/lib/workspace-package-entries.mts"; type InputOptionsFactory = Extract, Function>; type InputOptionsArg = InputOptionsFactory extends ( @@ -555,33 +556,6 @@ function buildAgentCoreDistEntries(): Record { }; } -function buildPackageDistEntriesFromExports(packageDir: string): Record { - const packageJsonPath = path.join("packages", packageDir, "package.json"); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { - exports?: Record; - }; - const entries: Record = {}; - for (const [exportKey, value] of Object.entries(packageJson.exports ?? {})) { - const entry = - exportKey === "." ? "index" : exportKey.startsWith("./") ? exportKey.slice(2) : ""; - if (!entry || entry.includes("..")) { - continue; - } - const importPath = - typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record).import - : value; - if (typeof importPath !== "string" || !importPath.startsWith("./dist/")) { - continue; - } - const sourcePath = importPath - .replace(/^\.\/dist\//u, `packages/${packageDir}/src/`) - .replace(/\.mjs$/u, ".ts"); - entries[entry] = sourcePath; - } - return Object.fromEntries(Object.entries(entries).toSorted(([a], [b]) => a.localeCompare(b))); -} - function buildLlmCoreDistEntries(): Record { return { index: "packages/llm-core/src/index.ts", @@ -874,8 +848,23 @@ const configs: UserConfig[] = [ }), nodeWorkspacePackageBuildConfig("normalization-core"), nodeWorkspacePackageBuildConfig("retry"), + nodeWorkspacePackageBuildConfig("sdk", { + deps: withExternalPackageSubpaths({ + neverBundle: [ + "@openclaw/gateway-client", + "@openclaw/gateway-protocol", + "@openclaw/normalization-core", + ], + }), + }), nodeWorkspacePackageBuildConfig("media-core"), - nodeWorkspacePackageBuildConfig("acp-core"), + nodeWorkspacePackageBuildConfig("acp-core", { + entry: { + ...buildPackageDistEntriesFromExports("acp-core"), + // Preserve the standalone package build's non-exported redactor artifact. + "error-format": "packages/acp-core/src/error-format.ts", + }, + }), nodeWorkspacePackageBuildConfig("terminal-core", { deps: { neverBundle: shouldExternalizeTerminalCoreDependency, diff --git a/ui/tsconfig.json b/ui/tsconfig.json index 422aaa5fad9b..fa6e05116bb8 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -1,5 +1,7 @@ { "extends": "../tsconfig.json", + // This editor/lint graph includes tests; retain their compatibility augmentation. + "files": ["../src/config/sessions/session-entry.test-compat.d.ts"], "include": [ "**/*.ts", "**/*.tsx",