From 4f9ecbab1f858455189dd85e985cb489d359d508 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 19 Jun 2026 02:44:43 +0000 Subject: [PATCH] fix: enforce typecheck across the monorepo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root `tsc -b` was a no-op — `tsconfig.json` had `files: []` and no project references, so `pnpm typecheck` passed without checking any package and CI never caught type regressions (~20 had accumulated). Wire `pnpm typecheck` to `turbo run typecheck`, giving each package its own `tsc --noEmit` script and an explicit `include`. Cross-package imports resolve to source via the existing `paths` aliases, so checks need no prior build. `plugins/*` is added to the workspace so future plugins are typechecked automatically once they ship the script. Fix the latent type errors this surfaces: - declare the internal protocol RPC methods used by `broadcast`/`$call` (client-state sync, auth-revoke, anonymous-auth) and the hub's terminal/message notifications - align `RpcSharedStateHost.get` with its string-keyed implementation, removing the casts it forced at every call site - annotate deliberately-throwing dump test handlers so they don't infer a `never` return that broke `RpcDump` assignability - supply the required `icon` on dock test entries and other minor fixes --- AGENTS.md | 4 ++- package.json | 2 +- packages/devframe/package.json | 1 + .../adapters/mcp/__tests__/mcp-server.test.ts | 2 +- .../devframe/src/adapters/mcp/build-server.ts | 2 +- .../src/node/__tests__/host-functions.test.ts | 4 +-- .../devframe/src/node/host-diagnostics.ts | 5 +++- .../devframe/src/node/rpc-shared-state.ts | 8 ++--- .../src/rpc/dump/__tests__/dump.test.ts | 4 +-- .../src/rpc/dump/__tests__/static.test.ts | 6 ++-- packages/devframe/src/rpc/dump/error.ts | 2 +- packages/devframe/src/types/rpc-augments.ts | 29 +++++++++++++++++++ packages/devframe/src/types/rpc.ts | 4 +-- packages/devframe/tsconfig.json | 5 ++-- packages/hub/package.json | 1 + .../hub/src/node/__tests__/context.test.ts | 3 +- .../hub/src/node/__tests__/host-docks.test.ts | 3 ++ packages/hub/src/node/context.ts | 21 ++++++++++++++ packages/hub/tsconfig.json | 5 ++-- packages/nuxt/package.json | 1 + packages/nuxt/tsconfig.json | 4 +-- pnpm-workspace.yaml | 1 + tsconfig.base.json | 3 +- turbo.json | 5 +++- 24 files changed, 96 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2fb3123..38bb0cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,13 +22,15 @@ pnpm install # requires pnpm@11.x pnpm build # tsdown pnpm dev # tsdown --watch pnpm test # pnpm build && vitest (api snapshot guards against stale dist) -pnpm typecheck # tsc --noEmit +pnpm typecheck # turbo run typecheck (per-package tsc --noEmit) pnpm lint --fix # ESLint via @antfu/eslint-config pnpm start # tsx src/index.ts ``` The `pnpm test` script intentionally runs `build` first so `tsnapi` snapshots compare against fresh `dist/`. `tsdown-stale-guard` enforces this in `test/api-snapshot.test.ts`. +`pnpm typecheck` fans out through Turbo: every workspace package owns a `"typecheck": "tsc --noEmit"` script and its own `tsconfig.json` (extending `tsconfig.base.json` with an explicit `include`). Cross-package imports resolve to source through the `paths` aliases in `tsconfig.base.json`, so no prior build is needed. Any package added under `packages/*` or `plugins/*` is typechecked automatically once it ships that `typecheck` script — add one to every new package so it can't silently skip type errors. + ## Conventions - RPC functions must use `defineRpcFunction`; always namespace IDs (`my-plugin:fn-name`). diff --git a/package.json b/package.json index 35a6ecb..07f6cf3 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "test:e2e:ui": "turbo run build && playwright test --ui", "test:ecosystem": "tsx scripts/ecosystem-ci.ts", "release": "bumpp -r", - "typecheck": "tsc -b", + "typecheck": "turbo run typecheck", "postinstall": "npx simple-git-hooks && skills-npm" }, "devDependencies": { diff --git a/packages/devframe/package.json b/packages/devframe/package.json index d1a2d2f..93c0a16 100644 --- a/packages/devframe/package.json +++ b/packages/devframe/package.json @@ -62,6 +62,7 @@ "scripts": { "build": "tsdown", "watch": "tsdown --watch", + "typecheck": "tsc --noEmit", "prepack": "pnpm build && mkdir -p ./skills && cp -r ../../skills/devframe ./skills/devframe" }, "peerDependencies": { diff --git a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts index ecf705a..a69a943 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts @@ -149,7 +149,7 @@ describe('mcp adapter (in-memory)', () => { it('surfaces shared-state keys as MCP resources', async () => { const { ctx, client, cleanup } = await bootPair() try { - const state = await ctx.rpc.sharedState.get('my-plugin:counter' as any, { + const state = await ctx.rpc.sharedState.get('my-plugin:counter', { initialValue: { count: 7 }, }) diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index e969d50..822deb0 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -226,7 +226,7 @@ function registerResourceHandlers( } if (parsed.kind === 'state') { - const state = await ctx.rpc.sharedState.get(parsed.key as any) + const state = await ctx.rpc.sharedState.get(parsed.key) return { contents: [ { diff --git a/packages/devframe/src/node/__tests__/host-functions.test.ts b/packages/devframe/src/node/__tests__/host-functions.test.ts index e21f145..396ddcc 100644 --- a/packages/devframe/src/node/__tests__/host-functions.test.ts +++ b/packages/devframe/src/node/__tests__/host-functions.test.ts @@ -133,7 +133,7 @@ describe('rpcFunctionsHost', () => { it('should not throw in build mode', async () => { const host = new RpcFunctionsHost({ mode: 'build' } as DevframeNodeContext) await expect(host.broadcast({ - method: 'devframe:terminals:updated', + method: 'devframe:auth:revoked', args: [], })).resolves.toBeUndefined() }) @@ -141,7 +141,7 @@ describe('rpcFunctionsHost', () => { it('should not throw in dev mode when rpc group is not yet set', async () => { const host = new RpcFunctionsHost({ mode: 'dev' } as DevframeNodeContext) await expect(host.broadcast({ - method: 'devframe:terminals:updated', + method: 'devframe:auth:revoked', args: [], })).resolves.toBeUndefined() }) diff --git a/packages/devframe/src/node/host-diagnostics.ts b/packages/devframe/src/node/host-diagnostics.ts index 7b5c69a..1353bfa 100644 --- a/packages/devframe/src/node/host-diagnostics.ts +++ b/packages/devframe/src/node/host-diagnostics.ts @@ -14,7 +14,10 @@ export class DevframeDiagnosticsHost implements DevframeDiagnosticsHostType { ...opts, reporters: [devframeReporter, ...(opts.reporters ?? [])], } as Parameters[0] - return defineDiagnostics(merged) as ReturnType + // Runtime passthrough: the per-call `Codes` generic can't be threaded + // through this assigned arrow, so the narrow return type is restored by + // the property's declared signature at every call site. + return defineDiagnostics(merged) as any } constructor( diff --git a/packages/devframe/src/node/rpc-shared-state.ts b/packages/devframe/src/node/rpc-shared-state.ts index ef4b2da..eb94422 100644 --- a/packages/devframe/src/node/rpc-shared-state.ts +++ b/packages/devframe/src/node/rpc-shared-state.ts @@ -1,4 +1,4 @@ -import type { DevframeRpcSharedStates, RpcFunctionsHost, RpcSharedStateGetOptions, RpcSharedStateHost } from 'devframe/types' +import type { RpcFunctionsHost, RpcSharedStateGetOptions, RpcSharedStateHost } from 'devframe/types' import type { SharedState, SharedStatePatch } from 'devframe/utils/shared-state' import { createSharedState } from 'devframe/utils/shared-state' import { createDebug } from 'obug' @@ -97,7 +97,7 @@ export function createRpcSharedStateServerHost( handler: async (key: string) => { if (!sharedState.has(key)) return undefined - const state = await host.get(key as keyof DevframeRpcSharedStates) + const state = await host.get(key) return state.value() }, // Pre-compute snapshots for the build-mode static dump so the SPA @@ -111,7 +111,7 @@ export function createRpcSharedStateServerHost( name: 'devframe:rpc:server-state:set', type: 'query', handler: async (key: string, value: any, syncId: string) => { - const state = await host.get(key as keyof DevframeRpcSharedStates, { + const state = await host.get(key, { initialValue: value, }) state.mutate(() => value, syncId) @@ -124,7 +124,7 @@ export function createRpcSharedStateServerHost( handler: async (key: string, patches: SharedStatePatch[], syncId: string) => { if (!sharedState.has(key)) return - const state = await host.get(key as keyof DevframeRpcSharedStates) + const state = await host.get(key) state.patch(patches, syncId) }, }) diff --git a/packages/devframe/src/rpc/dump/__tests__/dump.test.ts b/packages/devframe/src/rpc/dump/__tests__/dump.test.ts index e3e6303..b203c36 100644 --- a/packages/devframe/src/rpc/dump/__tests__/dump.test.ts +++ b/packages/devframe/src/rpc/dump/__tests__/dump.test.ts @@ -118,7 +118,7 @@ describe('dumps', () => { dump: { inputs: [[]] as [][], }, - handler: () => { + handler: (): void => { const err = new TypeError('boom', { cause: new Error('inner') }) as Error & { tags?: unknown } err.tags = tags throw err @@ -152,7 +152,7 @@ describe('dumps', () => { dump: { inputs: [[]] as [][], }, - handler: () => { + handler: (): void => { // eslint-disable-next-line no-throw-literal throw 'just a string' }, diff --git a/packages/devframe/src/rpc/dump/__tests__/static.test.ts b/packages/devframe/src/rpc/dump/__tests__/static.test.ts index 1b1194a..f1428b5 100644 --- a/packages/devframe/src/rpc/dump/__tests__/static.test.ts +++ b/packages/devframe/src/rpc/dump/__tests__/static.test.ts @@ -223,7 +223,7 @@ describe('collectStaticRpcDump', () => { name: 'test:flaky', type: 'query', jsonSerializable: true, - handler: () => { + handler: (): void => { throw new TypeError('boom', { cause: new Error('inner') }) }, dump: { @@ -261,7 +261,7 @@ describe('collectStaticRpcDump', () => { name: 'test:flaky-roundtrip', type: 'query', // default jsonSerializable: false → structured-clone shards - handler: () => { + handler: (): void => { const err = new TypeError('boom', { cause: new Error('inner') }) as Error & { tags?: unknown } err.tags = tags throw err @@ -295,7 +295,7 @@ describe('collectStaticRpcDump', () => { name: 'test:flaky-non-json', type: 'query', jsonSerializable: true, - handler: () => { + handler: (): void => { const err = new Error('boom') as Error & { tags?: unknown } err.tags = new Map([['a', 1]]) throw err diff --git a/packages/devframe/src/rpc/dump/error.ts b/packages/devframe/src/rpc/dump/error.ts index 1c7268f..cd99b59 100644 --- a/packages/devframe/src/rpc/dump/error.ts +++ b/packages/devframe/src/rpc/dump/error.ts @@ -30,7 +30,7 @@ function serializeWithSeen(error: unknown, seen: WeakSet): RpcDumpRecord for (const key of Object.keys(error)) { if (key === 'name' || key === 'message' || key === 'cause') continue - out[key] = (error as Record)[key] + out[key] = (error as unknown as Record)[key] } return out } diff --git a/packages/devframe/src/types/rpc-augments.ts b/packages/devframe/src/types/rpc-augments.ts index 9403d08..48bbbe8 100644 --- a/packages/devframe/src/types/rpc-augments.ts +++ b/packages/devframe/src/types/rpc-augments.ts @@ -23,6 +23,27 @@ export interface DevframeRpcClientFunctions { * @internal */ 'devframe:streaming:upload-cancel': (channel: string, id: string) => Promise + /** + * Full shared-state snapshot pushed from server to subscribed clients. + * Wired by `RpcSharedStateHost`; do not register manually. + * + * @internal + */ + 'devframe:rpc:client-state:updated': (key: string, fullState: any, syncId: string) => Promise + /** + * Incremental shared-state patch pushed from server to subscribed clients. + * Wired by `RpcSharedStateHost`; do not register manually. + * + * @internal + */ + 'devframe:rpc:client-state:patch': (key: string, patches: any[], syncId: string) => Promise + /** + * Server→client notification that the client's auth token was revoked. + * Broadcast by `revokeActiveConnectionsForToken`; do not register manually. + * + * @internal + */ + 'devframe:auth:revoked': () => Promise } /** @@ -92,6 +113,14 @@ export interface DevframeRpcServerFunctions { * @internal */ 'devframe:streaming:upload-end': (channel: string, id: string, error?: { name: string, message: string }) => Promise + /** + * Anonymous-auth handshake the browser client issues on connect. The + * standalone server registers a noop auto-trust handler when `auth: false`; + * hosted adapters register the real handler. Do not register manually. + * + * @internal + */ + 'devframe:anonymous:auth': (payload: { authToken: string, ua: string, origin: string }) => Promise<{ isTrusted: boolean }> } /** diff --git a/packages/devframe/src/types/rpc.ts b/packages/devframe/src/types/rpc.ts index 643eb9d..79375a7 100644 --- a/packages/devframe/src/types/rpc.ts +++ b/packages/devframe/src/types/rpc.ts @@ -4,7 +4,7 @@ import type { DevframeNodeRpcSessionMeta } from 'devframe/rpc/transports/ws-serv import type { SharedState } from 'devframe/utils/shared-state' import type { StreamReader, StreamSink } from 'devframe/utils/streaming-channel' import type { DevframeNodeContext } from './context' -import type { DevframeRpcClientFunctions, DevframeRpcServerFunctions, DevframeRpcSharedStates } from './rpc-augments' +import type { DevframeRpcClientFunctions, DevframeRpcServerFunctions } from './rpc-augments' export type { DevframeNodeRpcSessionMeta } @@ -72,7 +72,7 @@ export interface RpcSharedStateGetOptions { } export interface RpcSharedStateHost { - get: (key: T, options?: RpcSharedStateGetOptions) => Promise> + get: (key: string, options?: RpcSharedStateGetOptions) => Promise> keys: () => string[] /** * Subscribe to new shared-state keys becoming available. Fires when diff --git a/packages/devframe/tsconfig.json b/packages/devframe/tsconfig.json index 8a6d5a7..e2c4338 100644 --- a/packages/devframe/tsconfig.json +++ b/packages/devframe/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "composite": true, "lib": ["esnext", "dom"] - } + }, + "include": ["src", "test", "scripts", "tsdown.config.ts"], + "exclude": ["dist", "node_modules"] } diff --git a/packages/hub/package.json b/packages/hub/package.json index 72bbbe8..66e3819 100644 --- a/packages/hub/package.json +++ b/packages/hub/package.json @@ -35,6 +35,7 @@ "scripts": { "build": "tsdown", "watch": "tsdown --watch", + "typecheck": "tsc --noEmit", "prepack": "pnpm run build" }, "peerDependencies": { diff --git a/packages/hub/src/node/__tests__/context.test.ts b/packages/hub/src/node/__tests__/context.test.ts index f8d2c32..e7b31fc 100644 --- a/packages/hub/src/node/__tests__/context.test.ts +++ b/packages/hub/src/node/__tests__/context.test.ts @@ -1,3 +1,4 @@ +import type { DevframeDockEntry } from '../../types/docks' import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -22,7 +23,7 @@ describe('createHubContext shared state', () => { host: createHost(), }) - const docks = await context.rpc.sharedState.get('devframe:docks') + const docks = await context.rpc.sharedState.get('devframe:docks') expect(docks.value().map(dock => dock.id)).toEqual([ '~terminals', '~messages', diff --git a/packages/hub/src/node/__tests__/host-docks.test.ts b/packages/hub/src/node/__tests__/host-docks.test.ts index f825e1a..e436cac 100644 --- a/packages/hub/src/node/__tests__/host-docks.test.ts +++ b/packages/hub/src/node/__tests__/host-docks.test.ts @@ -29,6 +29,7 @@ describe('devframeDockHost remote URL enrichment', () => { type: 'iframe', id: 'remote', title: 'Remote', + icon: 'ph:cube-duotone', url: 'https://remote.test/app#/inspect?tab=state', remote: true, }) @@ -47,6 +48,7 @@ describe('devframeDockHost remote URL enrichment', () => { type: 'iframe', id: 'remote', title: 'Remote', + icon: 'ph:cube-duotone', url: firstUrl, remote: true, }) @@ -66,6 +68,7 @@ describe('devframeDockHost remote URL enrichment', () => { type: 'iframe', id: 'remote', title: 'Remote', + icon: 'ph:cube-duotone', url: 'https://remote.test/app#section', remote: true, }) diff --git a/packages/hub/src/node/context.ts b/packages/hub/src/node/context.ts index ec9bf5d..4305f3f 100644 --- a/packages/hub/src/node/context.ts +++ b/packages/hub/src/node/context.ts @@ -13,6 +13,27 @@ import { DevframeMessagesHost as MessagesHostImpl } from './host-messages' import { DevframeTerminalsHost as TerminalsHostImpl } from './host-terminals' import { builtinHubRpcDeclarations } from './rpc-builtins' +declare module 'devframe/types' { + interface DevframeRpcClientFunctions { + /** + * Server→client notification that terminal sessions changed. Broadcast + * by the hub context; a hub-aware client re-reads terminal state in + * response. Do not register manually. + * + * @internal + */ + 'devframe:terminals:updated': () => Promise + /** + * Server→client notification that the message list changed. Broadcast + * by the hub context; a hub-aware client re-reads message state in + * response. Do not register manually. + * + * @internal + */ + 'devframe:messages:updated': () => Promise + } +} + /** * Hub-augmented node context — extends devframe's framework-neutral * `DevframeNodeContext` with the hub-level subsystems (`docks`, diff --git a/packages/hub/tsconfig.json b/packages/hub/tsconfig.json index 8a6d5a7..4ebe6d8 100644 --- a/packages/hub/tsconfig.json +++ b/packages/hub/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "composite": true, "lib": ["esnext", "dom"] - } + }, + "include": ["src", "tsdown.config.ts"], + "exclude": ["dist", "node_modules"] } diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 4fc1945..1f4ee05 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -32,6 +32,7 @@ "scripts": { "build": "tsdown", "watch": "tsdown --watch", + "typecheck": "tsc --noEmit", "prepack": "pnpm run build" }, "peerDependencies": { diff --git a/packages/nuxt/tsconfig.json b/packages/nuxt/tsconfig.json index 0fa2e8f..e9da139 100644 --- a/packages/nuxt/tsconfig.json +++ b/packages/nuxt/tsconfig.json @@ -1,9 +1,9 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "composite": true, "lib": ["esnext", "dom"], "types": ["node"] }, - "include": ["nuxt.d.ts", "src/**/*.ts", "../devframe/src/**/*.ts"] + "include": ["nuxt.d.ts", "src", "tsdown.config.ts"], + "exclude": ["dist", "node_modules"] } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 23d77b0..6f1ac45 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -15,6 +15,7 @@ trustPolicyExclude: - tinyexec@1.2.2 packages: - packages/* + - plugins/* - examples/* - docs overrides: diff --git a/tsconfig.base.json b/tsconfig.base.json index 1fac0a9..fa2d780 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,11 +1,9 @@ { "compilerOptions": { - "composite": true, "target": "esnext", "lib": [ "esnext" ], - "rootDir": ".", "module": "esnext", "moduleResolution": "Bundler", "paths": { @@ -131,6 +129,7 @@ ] }, "resolveJsonModule": true, + "allowImportingTsExtensions": true, "strict": true, "noEmit": true, "isolatedDeclarations": false, diff --git a/turbo.json b/turbo.json index 5eb83cf..a883327 100644 --- a/turbo.json +++ b/turbo.json @@ -1,7 +1,10 @@ { "$schema": "https://turbo.build/schema.json", - "globalDependencies": ["pnpm-lock.yaml"], + "globalDependencies": ["pnpm-lock.yaml", "tsconfig.base.json"], "tasks": { + "typecheck": { + "dependsOn": ["^typecheck"] + }, "devframe#build": { "outputLogs": "new-only", "outputs": ["dist/**"]