chore(tooling): enforce no-floating-promises and drop DOM globals from Node-side typechecking (#104422)

* chore(lint): enforce no-floating-promises repo-wide

* feat(tooling): split DOM globals out of the Node-side typecheck program

tsconfig.core.json now compiles src/ + packages/ with lib ES2023; new
tsconfig.ui.json owns ui/ with DOM. Web-global names used by Node code
alias to canonical undici/stream-web types; highlight.js loads through a
validated narrow API because its d.ts force-includes lib.dom. Boundary,
sparse-guard, changed-lane routing, and profile lanes cover the new ui
graph.

* chore(deps): declare undici-types and teach knip about the highlight.js runtime require

* ci: retrigger dropped pull_request synchronize event

* refactor(types): derive web-global aliases from @types/node fetch globals, drop undici-types dep

* ci: retrigger after dependency guard cleared

* fix(scripts): add ui lane to changed-lanes declaration union
This commit is contained in:
Peter Steinberger
2026-07-11 07:28:20 -07:00
committed by GitHub
parent 13817a6108
commit ae12413ee2
20 changed files with 182 additions and 25 deletions

View File

@@ -56,7 +56,7 @@
- **Package manager**: pnpm (`pnpm install`)
- **Dev**: `pnpm openclaw ...` or `pnpm dev`
- **Type-check**: `pnpm tsgo` (core production), `pnpm tsgo:prod` (core + extension production), `pnpm check:test-types` (tests)
- **Type-check**: `pnpm tsgo` (core production), `pnpm tsgo:prod` (core + UI + extension production), `pnpm check:test-types` (tests)
- **Lint/format**: `pnpm check`
- **Tests**: `pnpm test`
- **Build**: `pnpm build`

View File

@@ -78,6 +78,7 @@
"typescript/no-empty-object-type": ["error", { "allowInterfaces": "with-single-extends" }],
"typescript/no-explicit-any": "error",
"typescript/no-extraneous-class": "error",
"typescript/no-floating-promises": "error",
"typescript/no-import-type-side-effects": "error",
"typescript/no-meaningless-void-operator": "error",
"typescript/no-misused-promises": "error",
@@ -209,15 +210,14 @@
"dist/",
"dist-runtime/",
"docs/_layouts/",
"**/a2ui.bundle.js",
"extensions/diffs/assets/viewer-runtime.js",
"extensions/diffs-language-pack/assets/viewer-runtime.js",
"extensions/canvas/src/host/a2ui/a2ui.bundle.js",
"node_modules/",
"patches/",
"pnpm-lock.yaml",
"skills/**",
"src/auto-reply/reply/export-html/template.js",
"src/canvas-host/a2ui/a2ui.bundle.js",
"vendor/",
"**/.cache/**",
"**/.openclaw-runtime-deps-copy-*/**",

View File

@@ -145,6 +145,9 @@ const config = {
"@mistralai/mistralai",
"cross-spawn",
"file-type",
// Loaded via createRequire in src/agents/utils/syntax-highlight.ts because its
// d.ts force-includes lib.dom; knip cannot see the dynamic require.
"highlight.js",
"playwright-core",
"partial-json",
"sqlite-vec",

View File

@@ -1960,7 +1960,7 @@
"tsgo:extensions": "node scripts/run-tsgo.mjs -p tsconfig.extensions.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/extensions.tsbuildinfo",
"tsgo:extensions:all": "node scripts/run-tsgo.mjs -b tsconfig.extensions.projects.json",
"tsgo:extensions:test": "node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.extensions.test.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/extensions-test.tsbuildinfo",
"tsgo:prod": "pnpm tsgo:core && pnpm tsgo:extensions",
"tsgo:prod": "pnpm tsgo:core && pnpm tsgo:ui && pnpm tsgo:extensions",
"tsgo:profile": "node scripts/profile-tsgo.mjs",
"tsgo:scripts": "node scripts/run-tsgo.mjs -p tsconfig.scripts.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/scripts.tsbuildinfo",
"tsgo:test": "pnpm tsgo:core:test && pnpm tsgo:extensions:test && pnpm tsgo:test:root",
@@ -1969,6 +1969,7 @@
"tsgo:test:root": "node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.test.root.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/test-root.tsbuildinfo",
"tsgo:test:src": "node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.test.src.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/test-src.tsbuildinfo",
"tsgo:test:ui": "node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.test.ui.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/test-ui.tsbuildinfo",
"tsgo:ui": "node scripts/run-tsgo.mjs -p tsconfig.ui.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/ui.tsbuildinfo",
"tui": "node scripts/run-node.mjs tui",
"tui:dev": "node scripts/run-with-env.mjs OPENCLAW_PROFILE=dev -- node scripts/run-node.mjs --dev tui",
"tui:pty:test:watch": "node --import tsx scripts/dev/tui-pty-test-watch.ts",

View File

@@ -1,6 +1,7 @@
export type ChangedLane =
| "core"
| "coreTests"
| "ui"
| "extensions"
| "extensionTests"
| "scripts"

View File

@@ -12,7 +12,8 @@ const RAW_SYNC_CHANGED_LANES_ENV = "OPENCLAW_CHANGED_LANES_RAW_SYNC";
const DOCS_PATH_RE = /^(?:docs\/|README\.md$|AGENTS\.md$|.*\.mdx?$)/u;
const APP_PATH_RE = /^(?:apps\/|Swabble\/|appcast\.xml$)/u;
const EXTENSION_PATH_RE = /^extensions\/[^/]+(?:\/|$)/u;
const CORE_PATH_RE = /^(?:src\/|ui\/|packages\/)/u;
const CORE_PATH_RE = /^(?:src\/|packages\/)/u;
const UI_PATH_RE = /^(?:ui\/|tsconfig\.ui\.json$)/u;
const SCRIPTS_TYPECHECK_PATH_RE =
/^(?:scripts\/.*\.(?:[cm]?ts|[cm]?tsx)|tsconfig\.scripts\.json)$/u;
const TEST_ROOT_TYPECHECK_PATH_RE =
@@ -58,7 +59,7 @@ export const RELEASE_METADATA_PATHS = new Set([
"package.json",
]);
/** @typedef {"core" | "coreTests" | "extensions" | "extensionTests" | "scripts" | "testRoot" | "apps" | "docs" | "tooling" | "liveDockerTooling" | "releaseMetadata" | "all"} ChangedLane */
/** @typedef {"core" | "coreTests" | "ui" | "extensions" | "extensionTests" | "scripts" | "testRoot" | "apps" | "docs" | "tooling" | "liveDockerTooling" | "releaseMetadata" | "all"} ChangedLane */
/**
* @typedef {{
@@ -87,6 +88,7 @@ export function createEmptyChangedLanes() {
return {
core: false,
coreTests: false,
ui: false,
extensions: false,
extensionTests: false,
scripts: false,
@@ -218,6 +220,18 @@ export function detectChangedLanes(changedPaths, options = {}) {
continue;
}
if (UI_PATH_RE.test(changedPath)) {
if (isChangedLaneTestPath(changedPath)) {
lanes.coreTests = true;
reasons.push(`${changedPath}: UI test`);
} else {
lanes.ui = true;
lanes.coreTests = true;
reasons.push(`${changedPath}: UI production`);
}
continue;
}
if (APP_PATH_RE.test(changedPath)) {
lanes.apps = true;
reasons.push(`${changedPath}: app surface`);

View File

@@ -385,6 +385,9 @@ export function createChangedCheckPlan(result, options = {}) {
if (lanes.coreTests) {
addTypecheck("typecheck core tests", ["tsgo:core:test"]);
}
if (lanes.ui) {
addTypecheck("typecheck UI", ["tsgo:ui"]);
}
if (lanes.extensions) {
addTypecheck("typecheck extensions", ["tsgo:extensions"]);
}
@@ -398,7 +401,7 @@ export function createChangedCheckPlan(result, options = {}) {
addTypecheck("typecheck test root", ["tsgo:test:root"]);
}
if (lanes.core || lanes.coreTests) {
if (lanes.core || lanes.coreTests || lanes.ui) {
const coreLintCommand = createTargetedCoreLintCommand(result.paths, baseEnv);
if (coreLintCommand) {
addCommand(

View File

@@ -10,6 +10,7 @@ const tsgoPath = path.join(repoRoot, "node_modules", ".bin", "tsgo");
const coreGraphs = [
{ name: "core", config: "tsconfig.core.json" },
{ name: "ui", config: "tsconfig.ui.json" },
{ name: "core-test", config: "test/tsconfig/tsconfig.core.test.json" },
{ name: "core-test-agents", config: "test/tsconfig/tsconfig.core.test.agents.json" },
{ name: "core-test-non-agents", config: "test/tsconfig/tsconfig.core.test.non-agents.json" },

View File

@@ -12,14 +12,13 @@ const CORE_TEST_CONFIGS = new Set([
]);
const CORE_PROD_CONFIGS = new Set(["tsconfig.core.json"]);
const UI_PROD_CONFIGS = new Set(["tsconfig.ui.json"]);
const TSGO_SPARSE_SKIP_ENV_KEY = "OPENCLAW_TSGO_SPARSE_SKIP";
const CORE_SPARSE_ROOTS = ["packages", "ui/config", "ui/src"];
const CORE_PROD_SPARSE_ROOTS = ["packages"];
const UI_PROD_SPARSE_ROOTS = ["packages", "src", "ui/config", "ui/src"];
const CORE_TEST_SPARSE_ROOTS = ["packages", "ui/config", "ui/src"];
const CORE_PROD_REQUIRED_PATHS = [
{
path: "apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json",
whenPresent: "ui/src/ui/tool-display.ts",
},
{
path: "scripts/lib/bundled-runtime-sidecar-paths.json",
whenPresent: "src/plugins/runtime-sidecar-paths.ts",
@@ -42,6 +41,13 @@ const CORE_PROD_REQUIRED_PATHS = [
},
];
const UI_PROD_REQUIRED_PATHS = [
{
path: "apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json",
whenPresent: "ui/src/lib/chat/tool-display.ts",
},
];
const CORE_TEST_REQUIRED_PATHS = [
"packages/plugin-package-contract/src/index.ts",
"ui/config/control-ui-chunking.ts",
@@ -85,7 +91,9 @@ export function getSparseTsgoGuardError(
const projectName = projectPath ? path.basename(projectPath) : null;
if (
!projectName ||
(!CORE_PROD_CONFIGS.has(projectName) && !CORE_TEST_CONFIGS.has(projectName)) ||
(!CORE_PROD_CONFIGS.has(projectName) &&
!UI_PROD_CONFIGS.has(projectName) &&
!CORE_TEST_CONFIGS.has(projectName)) ||
isMetadataOnlyCommand(args)
) {
return null;
@@ -118,8 +126,14 @@ export function getSparseTsgoGuardError(
}
function getRequiredSparseRootsForProject(projectName) {
if (CORE_PROD_CONFIGS.has(projectName) || CORE_TEST_CONFIGS.has(projectName)) {
return CORE_SPARSE_ROOTS;
if (CORE_PROD_CONFIGS.has(projectName)) {
return CORE_PROD_SPARSE_ROOTS;
}
if (UI_PROD_CONFIGS.has(projectName)) {
return UI_PROD_SPARSE_ROOTS;
}
if (CORE_TEST_CONFIGS.has(projectName)) {
return CORE_TEST_SPARSE_ROOTS;
}
return [];
}
@@ -129,6 +143,9 @@ function getRequiredPathsForProject(projectName, cwd, fileExists) {
if (CORE_PROD_CONFIGS.has(projectName)) {
requiredPaths.push(...conditionalRequiredPaths(CORE_PROD_REQUIRED_PATHS, cwd, fileExists));
}
if (UI_PROD_CONFIGS.has(projectName)) {
requiredPaths.push(...conditionalRequiredPaths(UI_PROD_REQUIRED_PATHS, cwd, fileExists));
}
if (CORE_TEST_CONFIGS.has(projectName)) {
requiredPaths.push(...CORE_TEST_REQUIRED_PATHS);
}

View File

@@ -21,6 +21,10 @@ const GRAPH_DEFINITIONS = {
config: "tsconfig.core.json",
description: "core production graph",
},
ui: {
config: "tsconfig.ui.json",
description: "UI production graph",
},
"core-test": {
config: "test/tsconfig/tsconfig.core.test.json",
description: "core colocated test graph",

View File

@@ -807,7 +807,12 @@ export class CodeModeHeadlessTimeoutError extends Error {
}
}
function createHeadlessAbortScope(signal: AbortSignal | undefined, wallClockMs: number) {
// Explicit return type: declaration emit cannot name the inferred AbortSignal
// in the DOM-free core lane (@types/node keeps it in a non-exported module).
function createHeadlessAbortScope(
signal: AbortSignal | undefined,
wallClockMs: number,
): { signal: AbortSignal; cleanup: () => void } {
const controller = new AbortController();
const onAbort = () => controller.abort(signal?.reason);
signal?.addEventListener("abort", onAbort, { once: true });

View File

@@ -4,9 +4,40 @@
* Highlight.js emits HTML spans; this module walks that small HTML subset and
* maps active scopes to caller-provided text formatters.
*/
import hljs from "highlight.js";
import { createRequire } from "node:module";
import { decodeHtmlEntityAt } from "./html.js";
type HighlightJs = {
getLanguage(name: string): unknown;
highlight(
code: string,
options: { language: string; ignoreIllegals?: boolean },
): { value: string };
highlightAuto(code: string, languageSubset?: string[]): { value: string };
};
function isHighlightJs(value: unknown): value is HighlightJs {
return (
typeof value === "object" &&
value !== null &&
"getLanguage" in value &&
typeof value.getLanguage === "function" &&
"highlight" in value &&
typeof value.highlight === "function" &&
"highlightAuto" in value &&
typeof value.highlightAuto === "function"
);
}
// highlight.js ships `/// <reference lib="dom" />` in its d.ts, which would
// silently re-inject DOM globals into the DOM-free core program. Load it
// untyped and validate the narrow API we use instead of importing its types.
const highlightJsModule: unknown = createRequire(import.meta.url)("highlight.js");
if (!isHighlightJs(highlightJsModule)) {
throw new TypeError("highlight.js did not expose the expected Node API");
}
const hljs = highlightJsModule;
/** Formatter applied to highlighted text segments. */
export type HighlightFormatter = (text: string) => string;
/** Mapping from highlight.js scope names to text formatters. */

View File

@@ -9,7 +9,9 @@ const silentConfigIoLogger = {
/**
* Creates config I/O for secrets commands with config-loader logging suppressed.
*/
export function createSecretsConfigIO(params: { env: NodeJS.ProcessEnv }) {
export function createSecretsConfigIO(params: {
env: NodeJS.ProcessEnv;
}): ReturnType<typeof createConfigIO> {
// Secrets command output is owned by the CLI command so --json stays machine-parseable.
return createConfigIO({
env: params.env,

20
src/types/node-runtime-globals.d.ts vendored Normal file
View File

@@ -0,0 +1,20 @@
// The core lane compiles with lib ES2023 (no DOM), so these web-global names
// no longer resolve. Derive them from the fetch globals @types/node already
// declares (backed by undici) instead of depending on undici-types directly;
// indexed-access derivation tracks @types/node across upgrades.
type BodyInit = Exclude<RequestInit["body"], undefined>;
type FormDataEntryValue = string | import("node:buffer").File;
type HeadersInit = Exclude<RequestInit["headers"], undefined>;
type ReadableStreamReadResult<T> =
| import("node:stream/web").ReadableStreamReadDoneResult<T>
| import("node:stream/web").ReadableStreamReadValueResult<T>;
type RequestCredentials = Exclude<RequestInit["credentials"], undefined>;
type RequestInfo = Parameters<typeof fetch>[0];
// Minimal surface for the one compile() caller; WebAssembly types otherwise
// live in lib.dom, which the core lane intentionally excludes.
declare namespace WebAssembly {
type Module = object;
function compile(bytes: ArrayBuffer | ArrayBufferView): Promise<Module>;
}

View File

@@ -598,6 +598,31 @@ describe("scripts/changed-lanes", () => {
});
});
it("routes UI production changes to UI prod and core test lanes", () => {
const result = detectChangedLanes(["ui/src/app.ts"]);
const plan = createChangedCheckPlan(result, { env: { PATH: "/usr/bin" } });
expectLanes(result.lanes, {
coreTests: true,
ui: true,
});
expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:ui");
expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:core:test");
expect(plan.commands.map((command) => command.args[0])).not.toContain("tsgo:core");
});
it("routes the UI production config to UI prod and core test lanes", () => {
const result = detectChangedLanes(["tsconfig.ui.json"]);
const plan = createChangedCheckPlan(result, { env: { PATH: "/usr/bin" } });
expectLanes(result.lanes, {
coreTests: true,
ui: true,
});
expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:ui");
expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:core:test");
});
it.each([
"scripts/control-ui-i18n.ts",
"scripts/lib/example.ts",
@@ -1851,6 +1876,7 @@ describe("scripts/changed-lanes", () => {
expect(result.lanes).toEqual({
core: false,
coreTests: false,
ui: false,
extensions: false,
extensionTests: false,
scripts: false,

View File

@@ -140,15 +140,14 @@ describe("oxlint config", () => {
"dist/",
"dist-runtime/",
"docs/_layouts/",
"**/a2ui.bundle.js",
"extensions/diffs/assets/viewer-runtime.js",
"extensions/diffs-language-pack/assets/viewer-runtime.js",
"extensions/canvas/src/host/a2ui/a2ui.bundle.js",
"node_modules/",
"patches/",
"pnpm-lock.yaml",
"skills/**",
"src/auto-reply/reply/export-html/template.js",
"src/canvas-host/a2ui/a2ui.bundle.js",
"vendor/",
"**/.cache/**",
"**/.openclaw-runtime-deps-copy-*/**",

View File

@@ -111,24 +111,41 @@ describe("run-tsgo sparse guard", () => {
`);
});
it("returns a helpful message for sparse core worktrees missing transitive project files", () => {
it("returns a helpful message for sparse UI worktrees missing transitive project files", () => {
const cwd = createTempDir("openclaw-run-tsgo-");
const uiToolDisplay = path.join(cwd, "ui/src/ui/tool-display.ts");
const uiToolDisplay = path.join(cwd, "ui/src/lib/chat/tool-display.ts");
fs.mkdirSync(path.dirname(uiToolDisplay), { recursive: true });
fs.writeFileSync(uiToolDisplay, "", "utf8");
expect(
getSparseTsgoGuardError(["-p", "tsconfig.core.json"], {
getSparseTsgoGuardError(["-p", "tsconfig.ui.json"], {
cwd,
isSparseCheckoutEnabled: () => true,
}),
).toMatchInlineSnapshot(`
"tsconfig.core.json cannot be typechecked from this sparse checkout because tracked project inputs are missing or only partially included:
"tsconfig.ui.json cannot be typechecked from this sparse checkout because tracked project inputs are missing or only partially included:
- apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json
Expand this worktree's sparse checkout to include those paths, or rerun in a full worktree."
`);
});
it("rejects sparse UI worktrees missing the transitive src root", () => {
const cwd = createTempDir("openclaw-run-tsgo-");
expect(
getSparseTsgoGuardError(["-p", "tsconfig.ui.json"], {
cwd,
fileExists: () => true,
isSparseCheckoutEnabled: () => true,
sparseCheckoutPatterns: ["/packages/", "/ui/config/", "/ui/src/"],
}),
).toMatchInlineSnapshot(`
"tsconfig.ui.json cannot be typechecked from this sparse checkout because tracked project inputs are missing or only partially included:
- src
Expand this worktree's sparse checkout to include those paths, or rerun in a full worktree."
`);
});
it("returns a helpful message for sparse core-test worktrees missing ui and packages files", () => {
const cwd = createTempDir("openclaw-run-tsgo-");

View File

@@ -1,10 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
// Node-side production code must not see browser globals; ui/ owns DOM via tsconfig.ui.json.
"lib": ["ES2023"],
"noUnusedLocals": true,
"noUnusedParameters": true,
"tsBuildInfoFile": ".artifacts/tsgo-cache/core.tsbuildinfo"
},
"include": ["src/**/*", "ui/**/*", "packages/**/*"],
"include": ["src/**/*", "packages/**/*"],
"exclude": ["node_modules", "dist", "**/dist/**", "**/*.test.ts", "**/*.test.tsx", "test/**"]
}

View File

@@ -2,6 +2,7 @@
"files": [],
"references": [
{ "path": "./tsconfig.core.json" },
{ "path": "./tsconfig.ui.json" },
{ "path": "./test/tsconfig/tsconfig.core.test.json" }
]
}

10
tsconfig.ui.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noUnusedLocals": true,
"noUnusedParameters": true,
"tsBuildInfoFile": ".artifacts/tsgo-cache/ui.tsbuildinfo"
},
"include": ["ui/**/*", "src/**/*.d.ts", "packages/**/*.d.ts"],
"exclude": ["node_modules", "dist", "**/dist/**", "**/*.test.ts", "**/*.test.tsx", "test/**"]
}