From 0284f3d7174c1669d1769c1ce637d0f45013528e Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 24 Jun 2026 18:21:07 -0700 Subject: [PATCH 1/2] fix(ssr): move credential query-key factory + fetchers to non-client modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preventively closes the same 'use client' SSR client-reference-stub class that crashed the tables page. Server-evaluated modules (the credential block def, the workflow-comparison helpers) imported workspaceCredentialKeys / fetchWorkspaceCredentialList / fetchCredentialSetById from 'use client' hook modules, where they resolve to client-reference stubs on the server (a future server call path would throw 'X is not a function'). Extract them into non-client hooks/queries/utils/{credential-keys, fetch-workspace-credentials,fetch-credential-set}.ts (mirroring folder-keys.ts / fetch-workflow-envelope.ts) and import from there. No behavior change — these values were only ever called from browser paths. --- .../secrets-manager/secrets-manager.tsx | 7 ++-- apps/sim/blocks/blocks/credential.ts | 3 +- apps/sim/hooks/queries/credential-sets.ts | 14 +------- apps/sim/hooks/queries/credentials.ts | 34 ++----------------- apps/sim/hooks/queries/invitations.ts | 2 +- apps/sim/hooks/queries/organization.ts | 2 +- .../hooks/queries/utils/credential-keys.ts | 24 +++++++++++++ .../queries/utils/fetch-credential-set.ts | 21 ++++++++++++ .../utils/fetch-workspace-credentials.ts | 20 +++++++++++ .../comparison/format-description.test.ts | 2 +- .../workflows/comparison/resolve-values.ts | 2 +- 11 files changed, 76 insertions(+), 55 deletions(-) create mode 100644 apps/sim/hooks/queries/utils/credential-keys.ts create mode 100644 apps/sim/hooks/queries/utils/fetch-credential-set.ts create mode 100644 apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx index e2581174df8..ffb2becd85a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx @@ -17,11 +17,7 @@ import type { WorkspaceEnvironmentData } from '@/lib/environment/api' import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { SecretValueField } from '@/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field' import { isValidEnvVarName } from '@/executor/constants' -import { - useWorkspaceCredentials, - type WorkspaceCredential, - workspaceCredentialKeys, -} from '@/hooks/queries/credentials' +import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials' import { usePersonalEnvironment, useRemoveWorkspaceEnvironment, @@ -29,6 +25,7 @@ import { useUpsertWorkspaceEnvironment, useWorkspaceEnvironment, } from '@/hooks/queries/environment' +import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace' import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' diff --git a/apps/sim/blocks/blocks/credential.ts b/apps/sim/blocks/blocks/credential.ts index fc324d721a4..830a8b65b65 100644 --- a/apps/sim/blocks/blocks/credential.ts +++ b/apps/sim/blocks/blocks/credential.ts @@ -2,7 +2,8 @@ import { CredentialIcon } from '@/components/icons' import { getServiceConfigByProviderId } from '@/lib/oauth/utils' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import type { BlockConfig } from '@/blocks/types' -import { fetchWorkspaceCredentialList, workspaceCredentialKeys } from '@/hooks/queries/credentials' +import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' +import { fetchWorkspaceCredentialList } from '@/hooks/queries/utils/fetch-workspace-credentials' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' interface CredentialBlockOutput { diff --git a/apps/sim/hooks/queries/credential-sets.ts b/apps/sim/hooks/queries/credential-sets.ts index d828e42544b..2a1086444a5 100644 --- a/apps/sim/hooks/queries/credential-sets.ts +++ b/apps/sim/hooks/queries/credential-sets.ts @@ -19,7 +19,6 @@ import { createCredentialSetContract, createCredentialSetInvitationContract, deleteCredentialSetContract, - getCredentialSetContract, leaveCredentialSetContract, listCredentialSetInvitationDetailsContract, listCredentialSetInvitationsContract, @@ -29,6 +28,7 @@ import { removeCredentialSetMemberContract, resendCredentialSetInvitationContract, } from '@/lib/api/contracts' +import { fetchCredentialSetById } from '@/hooks/queries/utils/fetch-credential-set' export type { CreateCredentialSetData, @@ -76,18 +76,6 @@ export function useCredentialSets(organizationId?: string, enabled = true) { }) } -export async function fetchCredentialSetById( - id: string, - signal?: AbortSignal -): Promise { - if (!id) return null - const data = await requestJson(getCredentialSetContract, { - params: { id }, - signal, - }) - return data.credentialSet ?? null -} - export function useCredentialSetDetail(id?: string, enabled = true) { return useQuery({ queryKey: credentialSetKeys.detail(id), diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index a2dda84d906..9e20147faab 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -20,6 +20,8 @@ import { type WorkspaceCredentialType, } from '@/lib/api/contracts' import { environmentKeys } from '@/hooks/queries/environment' +import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' +import { fetchWorkspaceCredentialList } from '@/hooks/queries/utils/fetch-workspace-credentials' /** * Key prefix for OAuth credential queries. @@ -34,38 +36,6 @@ export type { WorkspaceCredentialType, } -export const workspaceCredentialKeys = { - all: ['workspaceCredentials'] as const, - lists: () => [...workspaceCredentialKeys.all, 'list'] as const, - list: (workspaceId?: string, type?: string, providerId?: string) => - [ - ...workspaceCredentialKeys.lists(), - workspaceId ?? 'none', - type ?? 'all', - providerId ?? 'all', - ] as const, - details: () => [...workspaceCredentialKeys.all, 'detail'] as const, - detail: (credentialId?: string) => - [...workspaceCredentialKeys.details(), credentialId ?? 'none'] as const, - members: (credentialId?: string) => - [...workspaceCredentialKeys.detail(credentialId), 'members'] as const, -} - -/** - * Fetch workspace credential list from API. - * Used by the prefetch function for hover-based cache warming. - */ -export async function fetchWorkspaceCredentialList( - workspaceId: string, - signal?: AbortSignal -): Promise { - const data = await requestJson(listWorkspaceCredentialsContract, { - query: { workspaceId }, - signal, - }) - return data.credentials ?? [] -} - /** * Prefetch workspace credentials into a QueryClient cache. * Use on hover to warm data before navigation. diff --git a/apps/sim/hooks/queries/invitations.ts b/apps/sim/hooks/queries/invitations.ts index 17e0bfe3963..86a772a5e70 100644 --- a/apps/sim/hooks/queries/invitations.ts +++ b/apps/sim/hooks/queries/invitations.ts @@ -11,8 +11,8 @@ import { resendInvitationContract, } from '@/lib/api/contracts/invitations' import { updateWorkspacePermissionsContract } from '@/lib/api/contracts/workspaces' -import { workspaceCredentialKeys } from '@/hooks/queries/credentials' import { organizationKeys } from '@/hooks/queries/organization' +import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' import { workspaceKeys } from '@/hooks/queries/workspace' export const invitationKeys = { diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts index 11352d88b97..f005c7eaeed 100644 --- a/apps/sim/hooks/queries/organization.ts +++ b/apps/sim/hooks/queries/organization.ts @@ -42,8 +42,8 @@ import { import { client } from '@/lib/auth/auth-client' import { isEnterprise, isPaid, isTeam } from '@/lib/billing/plan-helpers' import { hasPaidSubscriptionStatus } from '@/lib/billing/subscriptions/utils' -import { workspaceCredentialKeys } from '@/hooks/queries/credentials' import { subscriptionKeys } from '@/hooks/queries/subscription' +import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' import { workspaceKeys } from '@/hooks/queries/workspace' const logger = createLogger('OrganizationQueries') diff --git a/apps/sim/hooks/queries/utils/credential-keys.ts b/apps/sim/hooks/queries/utils/credential-keys.ts new file mode 100644 index 00000000000..728cbd34ceb --- /dev/null +++ b/apps/sim/hooks/queries/utils/credential-keys.ts @@ -0,0 +1,24 @@ +/** + * React Query key factory for workspace credentials. + * + * Lives in this standalone (non-`'use client'`) module — like + * {@link file://./folder-keys.ts} — so server-evaluated code (block + * definitions, server prefetch) can import it without pulling client-reference + * stubs from the `'use client'` `@/hooks/queries/credentials` module. + */ +export const workspaceCredentialKeys = { + all: ['workspaceCredentials'] as const, + lists: () => [...workspaceCredentialKeys.all, 'list'] as const, + list: (workspaceId?: string, type?: string, providerId?: string) => + [ + ...workspaceCredentialKeys.lists(), + workspaceId ?? 'none', + type ?? 'all', + providerId ?? 'all', + ] as const, + details: () => [...workspaceCredentialKeys.all, 'detail'] as const, + detail: (credentialId?: string) => + [...workspaceCredentialKeys.details(), credentialId ?? 'none'] as const, + members: (credentialId?: string) => + [...workspaceCredentialKeys.detail(credentialId), 'members'] as const, +} diff --git a/apps/sim/hooks/queries/utils/fetch-credential-set.ts b/apps/sim/hooks/queries/utils/fetch-credential-set.ts new file mode 100644 index 00000000000..c9523d53633 --- /dev/null +++ b/apps/sim/hooks/queries/utils/fetch-credential-set.ts @@ -0,0 +1,21 @@ +import { requestJson } from '@/lib/api/client/request' +import { type CredentialSet, getCredentialSetContract } from '@/lib/api/contracts' + +/** + * Fetches a credential set by id (returns `null` for an empty id). + * + * Lives in this standalone (non-`'use client'`) module so server-reachable + * workflow-comparison helpers can import it without pulling client-reference + * stubs from the `'use client'` `@/hooks/queries/credential-sets` module. + */ +export async function fetchCredentialSetById( + id: string, + signal?: AbortSignal +): Promise { + if (!id) return null + const data = await requestJson(getCredentialSetContract, { + params: { id }, + signal, + }) + return data.credentialSet ?? null +} diff --git a/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts b/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts new file mode 100644 index 00000000000..9fd8efd7b6f --- /dev/null +++ b/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts @@ -0,0 +1,20 @@ +import { requestJson } from '@/lib/api/client/request' +import { listWorkspaceCredentialsContract, type WorkspaceCredential } from '@/lib/api/contracts' + +/** + * Fetches the workspace credential list. + * + * Lives in this standalone (non-`'use client'`) module so block definitions and + * server prefetch can import it without pulling client-reference stubs from the + * `'use client'` `@/hooks/queries/credentials` module. + */ +export async function fetchWorkspaceCredentialList( + workspaceId: string, + signal?: AbortSignal +): Promise { + const data = await requestJson(listWorkspaceCredentialsContract, { + query: { workspaceId }, + signal, + }) + return data.credentials ?? [] +} diff --git a/apps/sim/lib/workflows/comparison/format-description.test.ts b/apps/sim/lib/workflows/comparison/format-description.test.ts index f186a9d5a4f..22fb5b2fa05 100644 --- a/apps/sim/lib/workflows/comparison/format-description.test.ts +++ b/apps/sim/lib/workflows/comparison/format-description.test.ts @@ -36,7 +36,7 @@ vi.mock('@/lib/workflows/subblocks/context', () => ({ buildSelectorContextFromBlock: vi.fn(() => ({})), })) -vi.mock('@/hooks/queries/credential-sets', () => ({ +vi.mock('@/hooks/queries/utils/fetch-credential-set', () => ({ fetchCredentialSetById: vi.fn(), })) diff --git a/apps/sim/lib/workflows/comparison/resolve-values.ts b/apps/sim/lib/workflows/comparison/resolve-values.ts index bc9d6c7a753..fa9dbb37f9a 100644 --- a/apps/sim/lib/workflows/comparison/resolve-values.ts +++ b/apps/sim/lib/workflows/comparison/resolve-values.ts @@ -4,8 +4,8 @@ import { buildSelectorContextFromBlock } from '@/lib/workflows/subblocks/context import { getBlock } from '@/blocks/registry' import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types' import { CREDENTIAL_SET, isUuid } from '@/executor/constants' -import { fetchCredentialSetById } from '@/hooks/queries/credential-sets' import { fetchOAuthCredentialDetail } from '@/hooks/queries/oauth/oauth-credentials' +import { fetchCredentialSetById } from '@/hooks/queries/utils/fetch-credential-set' import { getSelectorDefinition, loadAllSelectorOptions } from '@/hooks/selectors/registry' import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution' import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' From f2ccd0b5f758507c2ace6d9ec3ac89ad0a32eda9 Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 24 Jun 2026 18:30:54 -0700 Subject: [PATCH 2/2] docs+ci: codify the 'use client' server-import rule + add check:client-boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the Next.js rule that server code can only render a 'use client' export as a component, never call it (server imports resolve to client-reference stubs that throw — the tables-page crash). Add the rule to .claude/rules/sim-queries.md + a cross-ref in sim-architecture.md. Add scripts/check-client-boundary-imports.ts (wired into CI as check:client-boundary) that flags any value import from a 'use client' module in a server-evaluated, non-JSX surface (prefetch / route handler / trigger / block definition), so this class can't silently recur. Escape hatch: // client-boundary-allow: . --- .claude/rules/sim-architecture.md | 4 + .claude/rules/sim-queries.md | 11 ++ .github/workflows/test-build.yml | 3 + package.json | 1 + scripts/check-client-boundary-imports.ts | 233 +++++++++++++++++++++++ 5 files changed, 252 insertions(+) create mode 100644 scripts/check-client-boundary-imports.ts diff --git a/.claude/rules/sim-architecture.md b/.claude/rules/sim-architecture.md index bc52fd37001..a0cfbfcd050 100644 --- a/.claude/rules/sim-architecture.md +++ b/.claude/rules/sim-architecture.md @@ -38,6 +38,10 @@ packages/ # @sim/* — audit, auth, db, logger, realtime-protocol - `apps/* → packages/*` only. Packages never import from `apps/*`. - `apps/realtime` avoids Next.js, React, the block/tool registry, provider SDKs, and the executor; never add `@/lib/webhooks/providers/*`, `@/executor/*`, `@/blocks/*`, or `@/tools/*` imports to any package it consumes. CI enforces this via `scripts/check-monorepo-boundaries.ts` and `scripts/check-realtime-prune-graph.ts`. +## The `'use client'` server boundary + +Every export of a `'use client'` module becomes a *client reference* on the server — server-evaluated code (RSC pages/layouts, `prefetch.ts`, route handlers, block definitions, triggers) can only *render* it as a component or pass it as a prop, never *call* it (doing so throws at runtime, e.g. `tableKeys.list is not a function`; `next build` does not catch it). Keep server-importable query primitives (key factories, fetchers, mappers, constants) in non-`'use client'` modules — see `.claude/rules/sim-queries.md`. Enforced by `scripts/check-client-boundary-imports.ts`. + ## Feature Organization Features live under `app/workspace/[workspaceId]/`: diff --git a/.claude/rules/sim-queries.md b/.claude/rules/sim-queries.md index 25707c740af..d1db9437ff0 100644 --- a/.claude/rules/sim-queries.md +++ b/.claude/rules/sim-queries.md @@ -27,6 +27,17 @@ Never use inline query keys — always use the factory. **Every identifier the `queryFn` forwards into the fetch MUST appear in the `queryKey`.** (Query-machinery identifiers — `signal`, `pageParam` — are exempt; they aren't fetch-scoping args.) If the fetch is scoped by `workspaceId`, `cursor`, `limit`, an org id, etc., those values must be part of the key — otherwise distinct fetch args share one cache entry (a cross-tenant / per-param cache collision). The lone exception is a globally-unique id used as the key while a second fetch arg is only an authz scope that cannot collide; annotate those with `// rq-lint-allow: `. Enforced by the `key-fetch-arg-drift` check in `scripts/check-react-query-patterns.ts`. +## Server-importable query primitives must NOT live in a `'use client'` module + +Next.js rewrites **every** export of a `'use client'` module into a *client reference* in the server bundle. Server-evaluated code — RSC `page.tsx`/`layout.tsx`, `prefetch.ts`, route handlers, **block definitions**, triggers/workers — can only *render* such an export as a component or pass it as a prop; **calling** one throws at runtime (`Attempted to call X from the server but X is on the client` — for an object export it surfaces as `X.list is not a function`). `next build` does **not** catch this — only SSR/runtime does. + +So any **query-key factory, standalone `requestJson` fetcher, mapper, or constant** that a server module imports must live in a **non-`'use client'`** module: + +- key factories → `hooks/queries/utils/-keys.ts` (see `folder-keys.ts`, `table-keys.ts`, `credential-keys.ts`) +- standalone fetchers/mappers → `hooks/queries/utils/fetch-*.ts` / `*-list-query.ts` (see `fetch-workflow-envelope.ts`, `fetch-credential-set.ts`) + +The `'use client'` hook module then imports these back for its hooks. **Never** define a server-imported factory/fetcher directly in a `'use client'` hooks file — it crashes SSR (this caused the tables-page crash). Enforced for prefetch/route/trigger/block files by `scripts/check-client-boundary-imports.ts` (`bun run check:client-boundary`, run in CI). Escape hatch for a genuinely browser-only path: `// client-boundary-allow: ` on the line above the import. + ## File Structure ```typescript diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 40f83645146..c4ffd7449ea 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -122,6 +122,9 @@ jobs: - name: React Query pattern audit run: bun run check:react-query + - name: Client boundary import audit + run: bun run check:client-boundary + - name: Verify realtime prune graph run: bun run check:realtime-prune diff --git a/package.json b/package.json index ce15ab78107..54f25a8da65 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "check:realtime-prune": "bun run scripts/check-realtime-prune-graph.ts", "check:zustand-v5": "bun run scripts/check-zustand-v5-selectors.ts", "check:react-query": "bun run scripts/check-react-query-patterns.ts --check", + "check:client-boundary": "bun run scripts/check-client-boundary-imports.ts --check", "check:utils": "bun run scripts/check-utils-enforcement.ts", "check:migrations": "bun run scripts/check-migrations-safety.ts", "mship-contracts:generate": "bun run scripts/sync-mothership-stream-contract.ts", diff --git a/scripts/check-client-boundary-imports.ts b/scripts/check-client-boundary-imports.ts new file mode 100644 index 00000000000..6d0ec2d6758 --- /dev/null +++ b/scripts/check-client-boundary-imports.ts @@ -0,0 +1,233 @@ +#!/usr/bin/env bun +/** + * Guards against the Next.js `'use client'` server-import foot-gun. + * + * Next.js rewrites EVERY export of a `'use client'` module into a client + * reference in the server bundle. Server-evaluated code can only *render* such + * an export as a component or pass it as a prop — *calling* one throws at + * runtime ("Attempted to call X from the server but X is on the client"). The + * crash for an object export looks like `tableKeys.list is not a function`. + * `next build` does NOT catch this; only SSR/runtime does. + * + * This script flags any **value** import (not `import type`) that resolves to a + * `'use client'` module from a server-evaluated, non-JSX surface — the places + * that never legitimately render a client component and so only ever import a + * client module to (illegally) call its values: + * + * - `apps/sim/app/** /prefetch*.ts` (RSC server prefetch) + * - `apps/sim/app/api/** /route.ts(x)` (route handlers) + * - `apps/sim/triggers/**` (trigger.dev tasks/pollers/webhooks) + * - `apps/sim/blocks/**` (block definitions — evaluated server-side) + * + * Fix: move the imported query-key factory / standalone fetcher / mapper / + * constant into a non-`'use client'` module (e.g. `hooks/queries/utils/*-keys.ts` + * or `hooks/queries/utils/fetch-*.ts`) and import it from there. See the rule in + * `.claude/rules/sim-queries.md`. + * + * Escape hatch: `// client-boundary-allow: ` on the line directly above + * the import (reason required). Use only for a genuinely browser-only code path. + * + * Usage: + * bun run scripts/check-client-boundary-imports.ts # report + * bun run scripts/check-client-boundary-imports.ts --check # CI gate (fail on any) + */ +import { readdir, readFile } from 'node:fs/promises' +import path from 'node:path' + +const ROOT = path.resolve(import.meta.dir, '..') +const APP_DIR = path.join(ROOT, 'apps/sim') + +/** Server-evaluated, non-JSX surfaces. A file matches if its path passes one. */ +function isServerSurface(rel: string): boolean { + if (/(^|\/)prefetch[^/]*\.ts$/.test(rel)) return true + if (/^app\/api\/.+\/route\.tsx?$/.test(rel)) return true + if (/^triggers\//.test(rel)) return true + if (/^blocks\//.test(rel)) return true + return false +} + +const SOURCE_EXTENSIONS = ['.ts', '.tsx'] +const ALLOW_DIRECTIVE = 'client-boundary-allow' + +async function listFiles(dir: string): Promise { + const out: string[] = [] + let entries: Awaited> + try { + entries = await readdir(dir, { withFileTypes: true }) + } catch { + return out + } + for (const entry of entries) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === '.next') continue + out.push(...(await listFiles(full))) + } else if (SOURCE_EXTENSIONS.includes(path.extname(entry.name))) { + out.push(full) + } + } + return out +} + +const useClientCache = new Map() + +async function isUseClientModule(absFile: string): Promise { + const cached = useClientCache.get(absFile) + if (cached !== undefined) return cached + let content: string + try { + content = await readFile(absFile, 'utf8') + } catch { + useClientCache.set(absFile, false) + return false + } + // The directive must be the first statement (comments/blank lines may precede it). + let isClient = false + for (const raw of content.split('\n')) { + const line = raw.trim() + if (line === '' || line.startsWith('//') || line.startsWith('/*') || line.startsWith('*')) { + continue + } + isClient = line === "'use client'" || line === '"use client"' + break + } + useClientCache.set(absFile, isClient) + return isClient +} + +/** Resolve an import specifier to an absolute source file, or null if external/unresolved. */ +async function resolveSpecifier(spec: string, fromFile: string): Promise { + let base: string + if (spec.startsWith('@/')) { + base = path.join(APP_DIR, spec.slice(2)) + } else if (spec.startsWith('./') || spec.startsWith('../')) { + base = path.resolve(path.dirname(fromFile), spec) + } else { + return null // external package + } + const candidates = [ + base, + ...SOURCE_EXTENSIONS.map((ext) => base + ext), + ...SOURCE_EXTENSIONS.map((ext) => path.join(base, `index${ext}`)), + ] + for (const candidate of candidates) { + if (!SOURCE_EXTENSIONS.includes(path.extname(candidate))) continue + try { + await readFile(candidate, 'utf8') + return candidate + } catch {} + } + return null +} + +interface ImportInfo { + line: number + specifier: string + clause: string +} + +/** Parse `import ... from '...'` statements, skipping side-effect-only imports. */ +function parseImports(content: string): ImportInfo[] { + const lines = content.split('\n') + const imports: ImportInfo[] = [] + const re = /^\s*import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"]/ + for (let i = 0; i < lines.length; i++) { + if (!/^\s*import\b/.test(lines[i]) || !lines[i].includes('import')) continue + // Join up to 12 following lines to capture multi-line import clauses. + const block = lines.slice(i, i + 12).join('\n') + const match = re.exec(block) + if (!match) continue + imports.push({ line: i + 1, clause: match[1], specifier: match[2] }) + } + return imports +} + +/** True when the import brings in at least one runtime VALUE (not purely types). */ +function importsAValue(clause: string): boolean { + const trimmed = clause.trim() + if (trimmed.startsWith('type ')) return false // `import type { ... }` / `import type X` + const braceStart = trimmed.indexOf('{') + // A default or namespace binding outside the braces is always a value. + const beforeBrace = braceStart === -1 ? trimmed : trimmed.slice(0, braceStart) + if (beforeBrace.replace(/[,\s]/g, '').length > 0) return true + if (braceStart === -1) return true + const inner = trimmed.slice(braceStart + 1, trimmed.lastIndexOf('}')) + // A named import is a value unless every member is `type`-prefixed. + return inner + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .some((member) => !member.startsWith('type ')) +} + +function hasAllowDirective(content: string, importLine: number): boolean { + const lines = content.split('\n') + for (let i = importLine - 2; i >= 0 && i >= importLine - 5; i--) { + const line = lines[i]?.trim() ?? '' + if (line === '' || line.startsWith('//') || line.startsWith('*') || line.startsWith('/*')) { + if (line.includes(ALLOW_DIRECTIVE)) { + const reason = + line + .split(ALLOW_DIRECTIVE)[1] + ?.replace(/^[:\s]+/, '') + .trim() ?? '' + return reason.length > 0 + } + continue + } + break + } + return false +} + +interface Violation { + file: string + line: number + specifier: string +} + +async function main() { + const checkMode = process.argv.includes('--check') + const allFiles = await listFiles(APP_DIR) + const violations: Violation[] = [] + + for (const absFile of allFiles) { + const rel = path.relative(APP_DIR, absFile) + if (!isServerSurface(rel)) continue + // A server file that is itself `'use client'` is a client component — out of scope. + if (await isUseClientModule(absFile)) continue + + const content = await readFile(absFile, 'utf8') + for (const imp of parseImports(content)) { + if (!importsAValue(imp.clause)) continue + const resolved = await resolveSpecifier(imp.specifier, absFile) + if (!resolved) continue + if (!(await isUseClientModule(resolved))) continue + if (hasAllowDirective(content, imp.line)) continue + violations.push({ file: rel, line: imp.line, specifier: imp.specifier }) + } + } + + if (violations.length === 0) { + console.log( + "✓ Client-boundary import check passed (no server file imports a value from a 'use client' module)." + ) + return + } + + console.error( + `\n✗ ${violations.length} server file(s) import a runtime value from a 'use client' module.\n` + + ` On the server these resolve to client-reference stubs and throw when called (e.g. 'X.list is not a function').\n` + + ` Move the imported factory/fetcher/constant into a non-'use client' module (hooks/queries/utils/*-keys.ts or fetch-*.ts).\n` + + ` See .claude/rules/sim-queries.md. Escape hatch: // ${ALLOW_DIRECTIVE}: above the import.\n` + ) + for (const v of violations) { + console.error(` ${v.file}:${v.line} imports from '${v.specifier}'`) + } + if (checkMode) process.exit(1) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +})