Merge remote-tracking branch 'origin/staging' into mship-fixes

# Conflicts:
#	apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx
#	apps/sim/lib/copilot/request/handlers/types.ts
#	apps/sim/lib/copilot/request/tools/files.ts
#	apps/sim/lib/copilot/resources/extraction.ts
#	apps/sim/lib/logs/log-views.ts
#	apps/sim/lib/table/application/views.ts
#	apps/sim/lib/table/views/service.test.ts
#	packages/logger/src/index.ts
#	scripts/check-api-validation-contracts.ts
This commit is contained in:
Siddharth Ganesan
2026-08-15 11:12:42 -07:00
1608 changed files with 157707 additions and 18426 deletions
+2 -2
View File
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
const BASELINE = {
totalRoutes: 1107,
zodRoutes: 1107,
totalRoutes: 1123,
zodRoutes: 1123,
nonZodRoutes: 0,
} as const
+7 -2
View File
@@ -59,12 +59,17 @@ const SPEC_FILES = OPENAPI_SPEC_FILES
* A stale entry — one whose contract no longer exists, or which has since
* been documented — also fails, so the list cannot rot into a blanket
* exemption.
*
* Being unpublished is about *addressability*, not about behaviour: both
* entries below answer in the canonical `{ error: { code, message } }` envelope
* like every documented route, and what a caller needs in order to perform the
* transfer is published on `transfer.url` in `contracts/v2/uploads.ts`.
*/
const UNDOCUMENTED_V2_ROUTES: Readonly<Record<string, string>> = {
'PUT /api/v2/uploads/{uploadId}':
'Local-storage data plane for a signed whole-object upload. Authenticated by the short-lived upload-token minted by the documented session-create operation, not by an API key; carries no v2 feature gate and returns bare error bodies rather than the canonical v2 envelope. The URL is handed to the client by the session response and is never constructed from docs.',
'Local-storage data plane for a signed whole-object upload. Authenticated by the short-lived upload-token minted by the documented session-create operation, not by an API key, so it carries neither the v2 API-key security scheme nor the rate-limit and feature-gate responses `checkV2Conventions` requires of a published operation. On a cloud deployment the same field points at object storage instead, so the endpoint is described by `transfer.url` — which publishes its method, headers, success status, and error codes — rather than by an operation of its own.',
'PUT /api/v2/uploads/{uploadId}/parts/{partNumber}':
'Local-storage data plane for a signed multipart part upload. Authenticated by a per-part signed `token` query param minted by the documented part-URL operation, not by an API key; same non-canonical envelope and self-describing URL as the whole-object PUT above.',
'Local-storage data plane for a signed multipart part upload. Authenticated by a per-part signed `token` query param minted by the documented part-URL operation, not by an API key; same reasoning and same published `transfer.url` contract as the whole-object PUT above.',
}
/**
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bun
/**
* Asserts that no tracked source file contains a raw `U+0000`.
*
* Git classifies a file as binary the moment its contents hold a NUL byte, so a
* single stray `U+0000` written as a literal turns the whole file into
* `Bin 0 -> 4102 bytes` in every diff — a reviewer sees not one line of it, and
* `git grep`, formatters, and editors treat it as opaque or silently normalize
* the byte away. `apps/sim/lib/api/server/nul-byte-boundary.test.ts` shipped
* exactly that way, and two older files had done the same unnoticed.
*
* The escape `'\u0000'` produces an identical string at runtime, so this costs
* nothing to satisfy. `.gitattributes` forces source files to diff as text as a
* second layer, which makes a violation visible; this audit is what keeps one
* from landing in the first place.
*/
import { spawnSync } from 'node:child_process'
import path from 'node:path'
const ROOT = path.resolve(import.meta.dir, '..')
/** Extensions whose contents are source text a human reads in review. */
const SOURCE_EXTENSIONS = [
'*.ts',
'*.tsx',
'*.js',
'*.jsx',
'*.mjs',
'*.cjs',
'*.json',
'*.md',
'*.mdx',
'*.css',
'*.yml',
'*.yaml',
'*.toml',
'*.sql',
'*.sh',
]
const listed = spawnSync('git', ['ls-files', '-z', '--', ...SOURCE_EXTENSIONS], {
cwd: ROOT,
encoding: 'buffer',
maxBuffer: 256 * 1024 * 1024,
})
if (listed.status !== 0) {
console.error(`Source-text audit failed: \`git ls-files\` exited ${listed.status}.`)
process.exit(1)
}
const files = listed.stdout
.toString('utf8')
.split('\0')
.filter((entry) => entry.length > 0)
const offenders: string[] = []
for (const file of files) {
const source = Bun.file(path.join(ROOT, file))
if (!(await source.exists())) continue
const bytes = await source.bytes()
if (bytes.includes(0)) offenders.push(file)
}
if (offenders.length > 0) {
console.error(
`Source-text audit failed: ${offenders.length} tracked source file(s) contain a raw NUL byte,\n` +
'which makes git treat them as binary and hides their contents from review.\n\n' +
offenders.map((file) => ` ${file}`).join('\n') +
"\n\n Write the character as the escape '\\u0000' instead — the runtime string is identical."
)
process.exit(1)
}
console.log(`Source-text audit passed (${files.length} files, no raw NUL bytes).`)
@@ -0,0 +1,382 @@
{
"generatedFrom": "app/workspace page/layout module graphs",
"tolerance": {
"modules": 25,
"percent": 2
},
"entries": {
"app/workspace/[workspaceId]/chat/[chatId]/layout.tsx": {
"modules": 5,
"gateways": {}
},
"app/workspace/[workspaceId]/chat/[chatId]/page.tsx": {
"modules": 2919,
"gateways": {
"apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1327,
"apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 982,
"apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 847,
"apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 844,
"apps/sim/triggers/registry.ts": 446,
"apps/sim/blocks/registry.ts": 309,
"apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 295,
"apps/sim/lib/auth/index.ts": 204
}
},
"app/workspace/[workspaceId]/files/[fileId]/page.tsx": {
"modules": 1931,
"gateways": {
"apps/sim/triggers/registry.ts": 446,
"apps/sim/blocks/registry.ts": 337,
"apps/sim/app/workspace/[workspaceId]/files/files.tsx": 280,
"apps/sim/lib/auth/index.ts": 210,
"apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 143,
"apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 129,
"apps/sim/lib/api/contracts/index.ts": 107,
"apps/sim/lib/webhooks/providers/index.ts": 100
}
},
"app/workspace/[workspaceId]/files/[fileId]/view/page.tsx": {
"modules": 59,
"gateways": {
"apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx": 58,
"apps/sim/hooks/queries/workspace-files.ts": 55
}
},
"app/workspace/[workspaceId]/files/page.tsx": {
"modules": 1931,
"gateways": {
"apps/sim/triggers/registry.ts": 446,
"apps/sim/blocks/registry.ts": 337,
"apps/sim/app/workspace/[workspaceId]/files/files.tsx": 282,
"apps/sim/lib/auth/index.ts": 210,
"apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 143,
"apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 129,
"apps/sim/lib/api/contracts/index.ts": 107,
"apps/sim/lib/webhooks/providers/index.ts": 100
}
},
"app/workspace/[workspaceId]/home/layout.tsx": {
"modules": 6,
"gateways": {}
},
"app/workspace/[workspaceId]/home/page.tsx": {
"modules": 2919,
"gateways": {
"apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1327,
"apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 982,
"apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 847,
"apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 844,
"apps/sim/triggers/registry.ts": 446,
"apps/sim/blocks/registry.ts": 309,
"apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 295,
"apps/sim/lib/auth/index.ts": 204
}
},
"app/workspace/[workspaceId]/integrations/[block]/page.tsx": {
"modules": 1284,
"gateways": {
"apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1259,
"apps/sim/blocks/registry.ts": 935,
"apps/sim/triggers/index.ts": 482,
"apps/sim/lib/api/contracts/index.ts": 127,
"apps/sim/stores/workflows/registry/store.ts": 67,
"apps/sim/hooks/queries/deployments.ts": 61,
"apps/sim/lib/api/contracts/tools/index.ts": 60,
"apps/sim/lib/workflows/comparison/compare.ts": 58
}
},
"app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx": {
"modules": 1266,
"gateways": {
"apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1265,
"apps/sim/triggers/registry.ts": 481,
"apps/sim/blocks/registry.ts": 339,
"apps/sim/lib/api/contracts/index.ts": 134,
"apps/sim/stores/workflows/registry/store.ts": 64,
"apps/sim/hooks/queries/deployments.ts": 61,
"apps/sim/lib/api/contracts/tools/index.ts": 60,
"apps/sim/lib/workflows/comparison/compare.ts": 58
}
},
"app/workspace/[workspaceId]/integrations/page.tsx": {
"modules": 1269,
"gateways": {
"apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 993,
"apps/sim/blocks/registry.ts": 936,
"apps/sim/triggers/index.ts": 482,
"apps/sim/lib/api/contracts/index.ts": 129,
"apps/sim/stores/workflows/registry/store.ts": 67,
"apps/sim/hooks/queries/deployments.ts": 61,
"apps/sim/lib/api/contracts/tools/index.ts": 60,
"apps/sim/lib/workflows/comparison/compare.ts": 58
}
},
"app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx": {
"modules": 1482,
"gateways": {
"apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1203,
"apps/sim/triggers/registry.ts": 481,
"apps/sim/blocks/registry.ts": 330,
"apps/sim/blocks/registry-maps.ts": 327,
"apps/sim/lib/api/contracts/index.ts": 119,
"apps/sim/connectors/registry.ts": 62,
"apps/sim/lib/api/contracts/tools/index.ts": 60,
"apps/sim/app/workspace/[workspaceId]/components/index.ts": 52
}
},
"app/workspace/[workspaceId]/knowledge/[id]/page.tsx": {
"modules": 1483,
"gateways": {
"apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1204,
"apps/sim/triggers/registry.ts": 481,
"apps/sim/blocks/registry.ts": 330,
"apps/sim/blocks/registry-maps.ts": 327,
"apps/sim/lib/api/contracts/index.ts": 119,
"apps/sim/connectors/registry.ts": 62,
"apps/sim/lib/api/contracts/tools/index.ts": 60,
"apps/sim/app/workspace/[workspaceId]/components/index.ts": 52
}
},
"app/workspace/[workspaceId]/knowledge/page.tsx": {
"modules": 2138,
"gateways": {
"apps/sim/triggers/registry.ts": 446,
"apps/sim/blocks/registry.ts": 325,
"apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 273,
"apps/sim/lib/knowledge/application/knowledge-bases.ts": 218,
"apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 168,
"apps/sim/lib/auth/index.ts": 158,
"apps/sim/lib/knowledge/orchestration/index.ts": 141,
"apps/sim/lib/knowledge/orchestration/connectors.ts": 136
}
},
"app/workspace/[workspaceId]/layout.tsx": {
"modules": 1974,
"gateways": {
"apps/sim/triggers/registry.ts": 446,
"apps/sim/blocks/registry.ts": 324,
"apps/sim/lib/auth/index.ts": 272,
"apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts": 270,
"apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 262,
"apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 165,
"apps/sim/lib/api/contracts/index.ts": 109,
"apps/sim/lib/webhooks/providers/index.ts": 100
}
},
"app/workspace/[workspaceId]/logs/page.tsx": {
"modules": 1711,
"gateways": {
"apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1434,
"apps/sim/triggers/registry.ts": 481,
"apps/sim/app/workspace/[workspaceId]/logs/components/index.ts": 419,
"apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/index.ts": 363,
"apps/sim/blocks/registry.ts": 326,
"apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 319,
"apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 282,
"apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 252
}
},
"app/workspace/[workspaceId]/page.tsx": {
"modules": 5,
"gateways": {}
},
"app/workspace/[workspaceId]/settings/[section]/layout.tsx": {
"modules": 4,
"gateways": {}
},
"app/workspace/[workspaceId]/settings/[section]/page.tsx": {
"modules": 2003,
"gateways": {
"apps/sim/triggers/registry.ts": 446,
"apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 420,
"apps/sim/blocks/registry.ts": 325,
"apps/sim/lib/auth/index.ts": 282,
"apps/sim/lib/api/contracts/index.ts": 105,
"apps/sim/lib/webhooks/providers/index.ts": 100,
"apps/sim/lib/api/contracts/tools/index.ts": 59,
"apps/sim/lib/workflows/lifecycle.ts": 48
}
},
"app/workspace/[workspaceId]/settings/billing/credit-usage/layout.tsx": {
"modules": 4,
"gateways": {}
},
"app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx": {
"modules": 1588,
"gateways": {
"apps/sim/lib/auth/index.ts": 1455,
"apps/sim/triggers/index.ts": 447,
"apps/sim/blocks/registry.ts": 339,
"apps/sim/blocks/registry-maps.ts": 336,
"apps/sim/lib/api/contracts/index.ts": 122,
"apps/sim/lib/webhooks/providers/index.ts": 100,
"apps/sim/stores/workflows/registry/store.ts": 64,
"apps/sim/lib/api/contracts/tools/index.ts": 60
}
},
"app/workspace/[workspaceId]/settings/layout.tsx": {
"modules": 3,
"gateways": {}
},
"app/workspace/[workspaceId]/settings/page.tsx": {
"modules": 1,
"gateways": {}
},
"app/workspace/[workspaceId]/settings/secrets/[credentialId]/page.tsx": {
"modules": 1297,
"gateways": {
"apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx": 1296,
"apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 1005,
"apps/sim/components/permissions/index.ts": 992,
"apps/sim/components/permissions/add-people-modal.tsx": 983,
"apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 981,
"apps/sim/triggers/registry.ts": 481,
"apps/sim/blocks/registry.ts": 343,
"apps/sim/blocks/registry-maps.ts": 340
}
},
"app/workspace/[workspaceId]/skills/[skillId]/page.tsx": {
"modules": 1388,
"gateways": {
"apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1387,
"apps/sim/triggers/registry.ts": 481,
"apps/sim/blocks/registry.ts": 342,
"apps/sim/blocks/registry-maps.ts": 340,
"apps/sim/lib/api/contracts/index.ts": 125,
"apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 89,
"apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 86,
"apps/sim/lib/api/contracts/tools/index.ts": 60
}
},
"app/workspace/[workspaceId]/skills/new/page.tsx": {
"modules": 1386,
"gateways": {
"apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1385,
"apps/sim/triggers/registry.ts": 481,
"apps/sim/blocks/registry.ts": 342,
"apps/sim/blocks/registry-maps.ts": 340,
"apps/sim/lib/api/contracts/index.ts": 125,
"apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 89,
"apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 86,
"apps/sim/lib/api/contracts/tools/index.ts": 60
}
},
"app/workspace/[workspaceId]/skills/page.tsx": {
"modules": 1250,
"gateways": {
"apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 974,
"apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 962,
"apps/sim/blocks/registry.ts": 950,
"apps/sim/blocks/registry-maps.ts": 948,
"apps/sim/triggers/index.ts": 482,
"apps/sim/lib/api/contracts/index.ts": 134,
"apps/sim/stores/workflows/registry/store.ts": 68,
"apps/sim/hooks/queries/deployments.ts": 62
}
},
"app/workspace/[workspaceId]/tables/[tableId]/page.tsx": {
"modules": 2204,
"gateways": {
"apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 577,
"apps/sim/triggers/registry.ts": 446,
"apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 331,
"apps/sim/blocks/registry.ts": 309,
"apps/sim/lib/auth/index.ts": 303,
"apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 289,
"apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 261,
"apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 232
}
},
"app/workspace/[workspaceId]/tables/page.tsx": {
"modules": 1788,
"gateways": {
"apps/sim/triggers/registry.ts": 446,
"apps/sim/blocks/registry.ts": 336,
"apps/sim/lib/auth/index.ts": 293,
"apps/sim/app/workspace/[workspaceId]/tables/tables.tsx": 126,
"apps/sim/lib/api/contracts/index.ts": 111,
"apps/sim/lib/webhooks/providers/index.ts": 100,
"apps/sim/lib/api/contracts/tools/index.ts": 60,
"apps/sim/app/workspace/[workspaceId]/components/index.ts": 50
}
},
"app/workspace/[workspaceId]/upgrade/page.tsx": {
"modules": 267,
"gateways": {
"apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx": 260,
"apps/sim/app/workspace/[workspaceId]/upgrade/hooks/index.ts": 213,
"apps/sim/lib/billing/client/upgrade.ts": 205,
"apps/sim/hooks/queries/organization.ts": 201,
"apps/sim/hooks/queries/workspace.ts": 193,
"apps/sim/lib/api/contracts/index.ts": 191,
"apps/sim/lib/api/contracts/tools/index.ts": 61,
"apps/sim/lib/api/contracts/v1/index.ts": 38
}
},
"app/workspace/[workspaceId]/w/[workflowId]/layout.tsx": {
"modules": 2160,
"gateways": {
"apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 2159,
"apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 540,
"apps/sim/triggers/registry.ts": 481,
"apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 458,
"apps/sim/blocks/registry.ts": 326,
"apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 285,
"apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 141,
"apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 134
}
},
"app/workspace/[workspaceId]/w/[workflowId]/page.tsx": {
"modules": 2187,
"gateways": {
"apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2186,
"apps/sim/triggers/registry.ts": 481,
"apps/sim/blocks/registry.ts": 326,
"apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 305,
"apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 267,
"apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 224,
"apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 140,
"apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 133
}
},
"app/workspace/[workspaceId]/w/page.tsx": {
"modules": 2160,
"gateways": {
"apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 904,
"apps/sim/triggers/registry.ts": 481,
"apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 458,
"apps/sim/blocks/registry.ts": 326,
"apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 285,
"apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 141,
"apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 134,
"apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 129
}
},
"app/workspace/layout.tsx": {
"modules": 1208,
"gateways": {
"apps/sim/app/workspace/providers/socket-provider.tsx": 1198,
"apps/sim/triggers/registry.ts": 481,
"apps/sim/blocks/registry.ts": 343,
"apps/sim/blocks/registry-maps.ts": 340,
"apps/sim/lib/api/contracts/index.ts": 138,
"apps/sim/stores/workflows/registry/store.ts": 65,
"apps/sim/hooks/queries/deployments.ts": 62,
"apps/sim/lib/api/contracts/tools/index.ts": 60
}
},
"app/workspace/page.tsx": {
"modules": 1202,
"gateways": {
"apps/sim/lib/auth/stale-session-recovery.ts": 971,
"apps/sim/triggers/index.ts": 482,
"apps/sim/blocks/registry.ts": 343,
"apps/sim/blocks/registry-maps.ts": 340,
"apps/sim/lib/api/contracts/index.ts": 137,
"apps/sim/stores/workflows/registry/store.ts": 64,
"apps/sim/lib/api/contracts/tools/index.ts": 60,
"apps/sim/hooks/queries/deployments.ts": 58
}
}
}
}
+388 -31
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bun
/**
* Fails if a workspace route can reach the executable tool registry.
* Fails if a workspace route can reach the executable tool registry, or if any
* route's module graph grows past its recorded baseline.
*
* `@/tools/registry` is a barrel over 4,300+ tools whose `ToolConfig`s hold
* closures (`request.headers`, `transformResponse`, `directExecution`). Those
@@ -19,11 +20,21 @@
* `formatParameterLabel`. Neither import looks remotely suspicious at the call
* site, which is why this is a lint and not a convention.
*
* The registry is only the loudest instance of the problem. The same walk yields
* each entry's exact module count, so `--check` additionally ratchets those
* counts against `check-tool-registry-boundary.baseline.json` (mirroring
* `check-react-query-patterns.ts`): an entry may not exceed its recorded size by
* more than `max(25 modules, 2%)`. A regression is reported with the dominator
* chain that grew — the import edge every one of the new modules must pass
* through — because a bare number is not actionable.
*
* Usage:
* bun run scripts/check-tool-registry-boundary.ts
* bun run scripts/check-tool-registry-boundary.ts --verbose # print counts
* bun run scripts/check-tool-registry-boundary.ts # registry gate only
* bun run scripts/check-tool-registry-boundary.ts --check # + graph-weight ratchet
* bun run scripts/check-tool-registry-boundary.ts --verbose # print counts
* bun run scripts/check-tool-registry-boundary.ts --update-baseline
*/
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -74,8 +85,14 @@ const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs']
* `REQUIRE_RE` matters for the same reason: this codebase uses lazy
* `require('@/…')` to break import cycles (`tools/params.ts` reaches `@/blocks`
* that way), and those edges are as real as static ones.
*
* In `IMPORT_RE` the `from` clause is optional AND lazily optional (`??`). A
* greedy `?` tries to match the clause before trying to skip it, so a bare
* side-effect import (`import '@/executor'`) was swallowed as the prefix of the
* *next* statement's `from`: that edge was dropped and the next one attributed to
* the wrong importer.
*/
const IMPORT_RE = /(?:^|\n)\s*import\s+(?!type\b)(?:[\s\S]*?from\s*)?['"]([^'"]+)['"]/g
const IMPORT_RE = /(?:^|\n)\s*import\s+(?!type\b)(?:[\s\S]*?from\s*)??['"]([^'"]+)['"]/g
const REEXPORT_RE =
/(?:^|\n)\s*export\s+(?!type\b)(?:\*(?:\s+as\s+[\w$]+)?|\{[\s\S]*?\})\s*from\s*['"]([^'"]+)['"]/g
const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g
@@ -105,41 +122,75 @@ function resolveSpecifier(specifier: string, importer: string): string | null {
return null
}
const NO_DEPS: readonly string[] = []
/**
* Resolved value-import edges out of one file.
*
* Memoized across entries: the 34 entries overlap heavily (every route drags in
* the same shell), so without this each file is re-read and re-scanned once per
* entry that reaches it. The cache is what pays for the dominator analysis added
* below — the whole check got faster, not slower.
*/
const depsCache = new Map<string, readonly string[]>()
function depsOf(file: string): readonly string[] {
const cached = depsCache.get(file)
if (cached) return cached
let source: string
try {
source = readFileSync(file, 'utf8')
} catch {
depsCache.set(file, NO_DEPS)
return NO_DEPS
}
const deps: string[] = []
const seen = new Set<string>()
for (const pattern of [IMPORT_RE, REEXPORT_RE, DYNAMIC_IMPORT_RE, REQUIRE_RE]) {
pattern.lastIndex = 0
let match = pattern.exec(source)
while (match !== null) {
const resolved = resolveSpecifier(match[1], file)
if (resolved && !seen.has(resolved)) {
seen.add(resolved)
deps.push(resolved)
}
match = pattern.exec(source)
}
}
depsCache.set(file, deps)
return deps
}
interface Walk {
entry: string
reachable: Set<string>
importedBy: Map<string, string>
}
/**
* Breadth-first so `importedBy` records the *shortest* path to each module —
* a depth-first parent chain reports whatever winding route the stack happened
* to take, which reads as noise in a failure message.
*/
function walk(entry: string): Walk {
const reachable = new Set<string>()
const reachable = new Set<string>([entry])
const importedBy = new Map<string, string>()
const queue = [entry]
reachable.add(entry)
while (queue.length > 0) {
const file = queue.pop() as string
let source: string
try {
source = readFileSync(file, 'utf8')
} catch {
continue
}
for (const pattern of [IMPORT_RE, REEXPORT_RE, DYNAMIC_IMPORT_RE, REQUIRE_RE]) {
pattern.lastIndex = 0
let match = pattern.exec(source)
while (match !== null) {
const resolved = resolveSpecifier(match[1], file)
if (resolved && !reachable.has(resolved)) {
reachable.add(resolved)
importedBy.set(resolved, file)
queue.push(resolved)
}
match = pattern.exec(source)
}
for (let head = 0; head < queue.length; head++) {
const file = queue[head]
for (const resolved of depsOf(file)) {
if (reachable.has(resolved)) continue
reachable.add(resolved)
importedBy.set(resolved, file)
queue.push(resolved)
}
}
return { reachable, importedBy }
return { entry, reachable, importedBy }
}
/** Walks parent links back to the entry so the offending edge is obvious. */
@@ -153,9 +204,234 @@ function explainChain({ importedBy }: Walk, target: string): string[] {
return chain.reverse()
}
const BASELINE_PATH = join(SCRIPT_DIR, 'check-tool-registry-boundary.baseline.json')
/**
* A graph may grow by `max(TOLERANCE_MODULES, TOLERANCE_PERCENT%)` before failing.
*
* Both halves are needed. A pure percentage lets the 2,186-module workflow route
* absorb 40 modules while pinning the 263-module upgrade route to 5, which would
* fail on an ordinary component addition. A pure absolute number is the same
* trade in reverse. The floor is sized so adding a feature's worth of components
* and hooks is free, while every regression this guard has actually seen — the
* `listTables` prefetch at +444, the registry at +4,700 — is far outside it.
*/
const TOLERANCE_MODULES = 25
const TOLERANCE_PERCENT = 2
/** Smallest dominated subtree worth naming as a gateway in the baseline. */
const GATEWAY_MIN_MODULES = 30
/** Gateways recorded per entry, largest first. */
const GATEWAY_LIMIT = 8
interface BaselineEntry {
modules: number
/** Module → number of modules reachable *only* through it. See `gatewaysFor`. */
gateways: Record<string, number>
}
interface Baseline {
generatedFrom: string
tolerance: { modules: number; percent: number }
entries: Record<string, BaselineEntry>
}
function allowanceFor(baselineModules: number): number {
return Math.max(TOLERANCE_MODULES, Math.ceil((baselineModules * TOLERANCE_PERCENT) / 100))
}
interface Dominators {
/** Immediate dominator of each module; the entry maps to itself. */
idom: Map<string, string>
/** Size of each module's dominator subtree — its exclusive cost to this entry. */
weight: Map<string, number>
}
/**
* Dominator tree of the entry's import graph (Cooper–Harvey–Kennedy).
*
* The point is the weight: a module's dominator-subtree size is exactly how many
* modules would leave the graph if its incoming edge were cut. That turns "this
* page gained 444 modules" into "this page gained 444 modules through
* `lib/table/index.ts`", which names the import to delete.
*
* Computed lazily — only for entries that regress, plus every entry during
* `--update-baseline`.
*/
function dominators({ entry, reachable }: Walk): Dominators {
const succ = new Map<string, string[]>()
for (const file of reachable) {
succ.set(
file,
depsOf(file).filter((dep) => reachable.has(dep))
)
}
// Iterative DFS postorder, then reverse it for the RPO numbering the
// algorithm's `intersect` walks against.
const postorder: string[] = []
const visited = new Set<string>([entry])
const stack: Array<{ node: string; next: number }> = [{ node: entry, next: 0 }]
while (stack.length > 0) {
const frame = stack[stack.length - 1]
const children = succ.get(frame.node) as string[]
if (frame.next < children.length) {
const child = children[frame.next++]
if (!visited.has(child)) {
visited.add(child)
stack.push({ node: child, next: 0 })
}
} else {
postorder.push(frame.node)
stack.pop()
}
}
const rpo = [...postorder].reverse()
const rpoIndex = new Map<string, number>()
rpo.forEach((node, index) => rpoIndex.set(node, index))
const preds = new Map<string, string[]>()
for (const [file, children] of succ) {
if (!rpoIndex.has(file)) continue
for (const child of children) {
if (!rpoIndex.has(child)) continue
const list = preds.get(child)
if (list) list.push(file)
else preds.set(child, [file])
}
}
const idom = new Map<string, string>([[entry, entry]])
const intersect = (a: string, b: string): string => {
let left = a
let right = b
while (left !== right) {
while ((rpoIndex.get(left) as number) > (rpoIndex.get(right) as number))
left = idom.get(left) as string
while ((rpoIndex.get(right) as number) > (rpoIndex.get(left) as number))
right = idom.get(right) as string
}
return left
}
let changed = true
while (changed) {
changed = false
for (let i = 1; i < rpo.length; i++) {
const node = rpo[i]
let candidate: string | null = null
for (const pred of preds.get(node) ?? []) {
if (!idom.has(pred)) continue
candidate = candidate === null ? pred : intersect(candidate, pred)
}
if (candidate !== null && idom.get(node) !== candidate) {
idom.set(node, candidate)
changed = true
}
}
}
// A node's dominator parent always has a smaller RPO index, so folding sizes
// from the deepest index upward completes every subtree in one pass.
const weight = new Map<string, number>()
for (const node of rpo) weight.set(node, 1)
for (let i = rpo.length - 1; i >= 1; i--) {
const node = rpo[i]
const parent = idom.get(node)
if (!parent || parent === node) continue
weight.set(parent, (weight.get(parent) as number) + (weight.get(node) as number))
}
return { idom, weight }
}
/**
* The heaviest gateway modules of an entry, collapsed to one per chain.
*
* In a pass-through chain `a → b → c` every link dominates the same subtree, so
* reporting all three says the same thing three times. `weight[idom] > weight + 1`
* keeps only the topmost link of each chain — the branch point nearest the entry,
* which is the edge a developer can actually delete.
*/
function gatewaysFor(walkResult: Walk, doms: Dominators): Array<[string, number]> {
const gateways: Array<[string, number]> = []
for (const [node, size] of doms.weight) {
if (node === walkResult.entry || size < GATEWAY_MIN_MODULES) continue
const parent = doms.idom.get(node)
if (!parent || parent === node) continue
if (parent !== walkResult.entry && (doms.weight.get(parent) as number) <= size + 1) continue
gateways.push([relative(ROOT, node), size])
}
gateways.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
return gateways.slice(0, GATEWAY_LIMIT)
}
/** Dominator-tree ancestry of a module: the edges every path from the entry crosses. */
function dominatorChain(walkResult: Walk, doms: Dominators, target: string): string[] {
const chain: string[] = []
let current = target
for (let guard = 0; guard < 10_000; guard++) {
chain.push(relative(ROOT, current))
if (current === walkResult.entry) break
const parent = doms.idom.get(current)
if (!parent || parent === current) break
current = parent
}
return chain.reverse()
}
function loadBaseline(): Baseline | null {
try {
return JSON.parse(readFileSync(BASELINE_PATH, 'utf8')) as Baseline
} catch {
return null
}
}
const RERECORD_COMMAND = 'bun run scripts/check-tool-registry-boundary.ts --update-baseline'
const REBASELINE_HINT = `If the growth is intentional, re-record it: ${RERECORD_COMMAND}`
/**
* Reports a regressed entry by naming the gateways that grew, not just the delta.
*
* Baseline gateways are matched by path, so an edge that is new (absent from the
* baseline) and an edge that got heavier are both surfaced, largest growth first.
*/
function reportRegression(entry: string, walkResult: Walk, before: BaselineEntry, after: number) {
const allowance = allowanceFor(before.modules)
console.error(
`\n❌ ${entry} grew to ${after} modules (baseline ${before.modules}, +${after - before.modules}, allowed +${allowance})`
)
const doms = dominators(walkResult)
const growth = gatewaysFor(walkResult, doms)
.map(([module, size]) => ({ module, size, delta: size - (before.gateways[module] ?? 0) }))
.filter((row) => row.delta > 0)
.sort((a, b) => b.delta - a.delta)
if (growth.length === 0) {
console.error(
' No single import edge accounts for it — the growth is spread across many small modules.'
)
console.error(` Compare with --verbose to see which entries moved. ${REBASELINE_HINT}`)
return
}
for (const row of growth.slice(0, 3)) {
console.error(` +${row.delta} modules reach it only via ${row.module} (${row.size} total):`)
const chain = dominatorChain(walkResult, doms, join(ROOT, row.module))
for (const step of chain) console.error(` ${step}`)
}
console.error(` ${REBASELINE_HINT}`)
}
function main() {
const verbose = process.argv.includes('--verbose')
const check = process.argv.includes('--check')
const update = process.argv.includes('--update-baseline')
const failures: string[] = []
let ratchetFailures = 0
const entryRoot = join(APP, ENTRY_ROOT)
if (!existsSync(entryRoot)) {
@@ -170,9 +446,10 @@ function main() {
process.exit(1)
}
const walked = new Map<string, Walk>()
for (const entry of entries) {
const entryPath = join(APP, entry)
const result = walk(entryPath)
const result = walk(join(APP, entry))
walked.set(entry, result)
if (result.reachable.has(FORBIDDEN)) {
failures.push(entry)
console.error(`\n❌ ${entry} can reach @/tools/registry via:`)
@@ -184,6 +461,83 @@ function main() {
}
}
if (update) {
const baseline: Baseline = {
generatedFrom: `${ENTRY_ROOT} page/layout module graphs`,
tolerance: { modules: TOLERANCE_MODULES, percent: TOLERANCE_PERCENT },
entries: {},
}
for (const entry of entries) {
const result = walked.get(entry) as Walk
baseline.entries[entry] = {
modules: result.reachable.size,
gateways: Object.fromEntries(gatewaysFor(result, dominators(result))),
}
}
writeFileSync(BASELINE_PATH, `${JSON.stringify(baseline, null, 2)}\n`)
console.log(
`✓ Baseline written for ${entries.length} entries: ${relative(ROOT, BASELINE_PATH)}`
)
process.exit(failures.length > 0 ? 1 : 0)
}
if (check) {
const baseline = loadBaseline()
if (!baseline) {
console.error(
`\n❌ Missing ${relative(ROOT, BASELINE_PATH)}. Refusing to pass without a ratchet — ` +
'generate it with --update-baseline.'
)
process.exit(1)
}
const shrunk: string[] = []
const unbaselined: string[] = []
let regressed = 0
for (const entry of entries) {
const result = walked.get(entry) as Walk
const before = baseline.entries[entry]
if (!before) {
unbaselined.push(`${entry} (${result.reachable.size} modules)`)
continue
}
const after = result.reachable.size
if (after > before.modules + allowanceFor(before.modules)) {
regressed++
reportRegression(entry, result, before, after)
} else if (after < before.modules - allowanceFor(before.modules)) {
shrunk.push(`${entry}: ${before.modules} → ${after} (−${before.modules - after})`)
}
}
const removed = Object.keys(baseline.entries).filter((entry) => !entries.includes(entry))
if (shrunk.length > 0) {
console.log(`\nℹ ${shrunk.length} entr(ies) shrank below baseline:`)
for (const line of shrunk) console.log(` ${line}`)
console.log(` Lock the win in, or it can be spent again: ${RERECORD_COMMAND}`)
}
if (unbaselined.length > 0) {
console.log(`\nℹ ${unbaselined.length} new entr(ies) not yet in the baseline (unratcheted):`)
for (const line of unbaselined) console.log(` ${line}`)
console.log(` ${REBASELINE_HINT}`)
}
if (removed.length > 0) {
console.log(`\nℹ ${removed.length} baseline entr(ies) no longer exist: ${removed.join(', ')}`)
}
if (regressed > 0) {
ratchetFailures = regressed
console.error(
`\n${regressed} route(s) exceeded their module-graph baseline by more than max(${TOLERANCE_MODULES}, ${TOLERANCE_PERCENT}%).`
)
console.error(
'Every module in a page graph is parsed and shipped, so this is a real page-weight cost.'
)
}
}
if (failures.length > 0) {
console.error(
`\n${failures.length} route(s) reach the executable tool registry, which adds ~4,700 modules to each.`
@@ -195,10 +549,13 @@ function main() {
'(outputs), or `@/tools/tool-ids` (existence/resolution). Only code that executes a tool'
)
console.error('may import `getTool`. See .agents/skills/tool-registry-boundary/SKILL.md.')
process.exit(1)
}
if (failures.length > 0 || ratchetFailures > 0) process.exit(1)
console.log(`✓ tool registry stays out of ${entries.length} workspace page/layout graphs`)
if (check)
console.log(`✓ ${entries.length} page/layout graphs within their module-count baseline`)
}
main()
+3
View File
@@ -29,6 +29,9 @@ const ALLOWLISTED_FILES = new Set([
'packages/utils/src/id.test.ts',
'packages/utils/src/object.test.ts',
'packages/utils/src/retry.test.ts',
// Published standalone CLIs: `@sim/utils` is private, so they carry local
// copies rather than a dependency that only resolves inside the monorepo.
'packages/sim-cli/src/helpers.ts',
'packages/cli/src/index.ts',
'packages/ts-sdk/src/index.ts',
// CJS bundle — cannot use ES module imports
+1
View File
@@ -65,6 +65,7 @@ const HANDWRITTEN_INTEGRATION_DOCS = new Set([
'hubspot-setup',
'linear-service-account',
'monday-service-account',
'netsuite-service-account',
'notion-service-account',
'pipedrive-service-account',
'salesforce-service-account',
+570
View File
@@ -0,0 +1,570 @@
#!/usr/bin/env bun
/**
* Generates the Sim CLI's view of the public v2 API from the Zod route
* contracts, so the terminal and the server cannot describe the same endpoint
* differently.
*
* The contracts under `apps/sim/lib/api/contracts/v2/**` are the single source
* of truth: the routes validate against them, so a shape that disagrees with a
* contract is a shape the server would reject. Everything downstream is derived
* rather than restated.
*
* The CLI cannot import the contracts directly — `packages/*` must never depend
* on `apps/*` (scripts/check-monorepo-boundaries.ts). This script bridges that
* at build time instead: it reads the contracts here and emits a file of plain
* type declarations with no imports at all, so nothing about the package
* boundary changes.
*
* Deliberately NOT generated: the OpenAPI documents under `apps/docs`. They
* carry hand-written descriptions, examples, and error responses that Zod
* schemas do not encode. `scripts/check-openapi-specs.ts` reconciles those
* against the same contracts instead, field by field, so the prose survives
* while drift still fails CI.
*
* Usage:
* bun run scripts/generate-v2-cli-api.ts # write the generated file
* bun run scripts/generate-v2-cli-api.ts --check # fail if it is stale
*/
import { spawnSync } from 'node:child_process'
import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { z } from 'zod'
const ROOT = path.resolve(import.meta.dir, '..')
const CONTRACTS_DIR = path.join(ROOT, 'apps/sim/lib/api/contracts/v2')
const OUTPUT = path.join(ROOT, 'packages/sim-cli/src/generated/v2-api.ts')
const DOCS_DIR = path.join(ROOT, 'apps/docs')
/**
* OpenAPI documents to read operation summaries from, discovered rather than
* listed — same reason as {@link contractModules}.
*
* A new spec file (`openapi-v2-resources.json` arrived with the MCP/skills/
* folders/credentials endpoints) would otherwise go unread, and the only symptom
* would be `--help` quietly falling back to `METHOD /path` for a whole domain.
*
* `openapi.json` is the retired single-document spec, superseded by the split
* files; it is excluded by name because it still exists on disk and would
* contribute stale duplicates.
*/
function specFiles(): string[] {
return readdirSync(DOCS_DIR, { withFileTypes: true })
.filter(
(entry) =>
entry.isFile() &&
entry.name.startsWith('openapi') &&
entry.name.endsWith('.json') &&
entry.name !== 'openapi.json'
)
.map((entry) => entry.name)
.sort()
}
/**
* `METHOD /api/v2/{id}/…` → the spec's one-line summary.
*
* The contracts carry validation, not prose, so `--help` text has to come from
* somewhere else. The specs already hold a hand-written summary per operation
* and `check:openapi` guarantees every contract has one, so reading them here
* reuses documentation that is already written and already verified rather than
* inventing a second place to describe the same endpoint.
*/
function loadSummaries(): Map<string, string> {
const summaries = new Map<string, string>()
for (const file of specFiles()) {
let spec: Record<string, any>
try {
spec = JSON.parse(readFileSync(path.join(DOCS_DIR, file), 'utf8'))
} catch {
// A missing spec is not fatal: the CLI falls back to `METHOD path`, and
// `check:openapi` is what actually enforces the specs' presence.
continue
}
for (const [specPath, methods] of Object.entries(spec.paths ?? {})) {
for (const [method, operation] of Object.entries(methods as Record<string, any>)) {
const summary = operation?.summary
if (typeof summary === 'string') {
summaries.set(`${method.toUpperCase()} ${specPath}`, summary)
}
}
}
}
return summaries
}
/**
* Every contract module under `contracts/v2`, discovered rather than listed.
*
* A hardcoded list is the wrong shape for this: adding a v2 domain would leave
* its operations silently absent from the CLI, with no error and nothing in
* `--check` to notice, because the generated file would still match a generator
* that never looked. Discovery makes a new domain appear on the next
* regeneration, which is the property the whole pipeline is built on.
*
* `shared.ts` holds the response-envelope helpers, not contracts; it is skipped
* because it exports no route contract, not because it is named here.
*/
function contractModules(): string[] {
return readdirSync(CONTRACTS_DIR, { withFileTypes: true })
.filter(
(entry) =>
entry.isFile() &&
entry.name.endsWith('.ts') &&
!entry.name.endsWith('.test.ts') &&
entry.name !== 'index.ts'
)
.map((entry) => entry.name.replace(/\.ts$/, ''))
.sort()
}
interface RouteContract {
method: string
path: string
params?: z.ZodType
query?: z.ZodType
body?: z.ZodType
headers?: z.ZodType
response: { mode: string; schema?: z.ZodType }
}
interface Operation {
/** `listTables` — derived from the export name. */
name: string
domain: string
contract: RouteContract
}
function isRouteContract(value: unknown): value is RouteContract {
if (!value || typeof value !== 'object') return false
const candidate = value as Partial<RouteContract>
return (
typeof candidate.method === 'string' &&
typeof candidate.path === 'string' &&
typeof candidate.response === 'object'
)
}
/** `v2ListTablesContract` → `listTables`. */
function operationName(exportName: string): string {
const stripped = exportName.replace(/^v2/, '').replace(/Contract$/, '')
return stripped.charAt(0).toLowerCase() + stripped.slice(1)
}
function pascal(name: string): string {
return name.charAt(0).toUpperCase() + name.slice(1)
}
async function collectOperations(): Promise<Operation[]> {
const operations: Operation[] = []
for (const domain of contractModules()) {
const mod: Record<string, unknown> = await import(path.join(CONTRACTS_DIR, `${domain}.ts`))
for (const [exportName, value] of Object.entries(mod)) {
if (!exportName.endsWith('Contract') || !isRouteContract(value)) continue
operations.push({ name: operationName(exportName), domain, contract: value })
}
}
// Import order is stable, but sort anyway so a reordered export list does not
// show up as a spurious diff in the generated file.
return operations.sort((a, b) => a.name.localeCompare(b.name))
}
type JsonSchema = Record<string, any>
/**
* Emits a TypeScript type for the subset of JSON Schema that `z.toJSONSchema`
* produces from these contracts.
*
* Hand-rolled rather than pulled from `json-schema-to-typescript`: the input is
* a known, narrow subset (no `patternProperties`, no draft-04 quirks), and the
* output is committed and read by humans, so controlling the formatting is
* worth more here than covering spec corners that never appear. An unhandled
* construct throws rather than degrading to `any` — silence is how a generated
* client drifts from its server.
*
* `refs` maps a `$defs` key to the TypeScript alias hoisted for it. Zod factors
* a schema out into `$defs` when it is recursive, which the table view's filter
* grammar is — a predicate holds predicates — so it cannot be inlined.
*/
function toTypeScript(schema: JsonSchema, indent = 0, refs?: Map<string, string>): string {
if (typeof schema.$ref === 'string') {
const key = schema.$ref.replace('#/$defs/', '')
const name = refs?.get(key)
if (!name) throw new Error(`Unresolved $ref: ${schema.$ref}`)
return name
}
const pad = ' '.repeat(indent + 1)
const closePad = ' '.repeat(indent)
if (schema.const !== undefined) return JSON.stringify(schema.const)
if (schema.enum) return schema.enum.map((v: unknown) => JSON.stringify(v)).join(' | ')
const variants = schema.anyOf ?? schema.oneOf
if (variants) {
return variants.map((v: JsonSchema) => toTypeScript(v, indent, refs)).join(' | ')
}
if (schema.allOf) {
return schema.allOf.map((v: JsonSchema) => toTypeScript(v, indent, refs)).join(' & ')
}
switch (schema.type) {
case 'string':
return 'string'
case 'number':
case 'integer':
return 'number'
case 'boolean':
return 'boolean'
case 'null':
return 'null'
case 'array':
return schema.items ? `Array<${toTypeScript(schema.items, indent, refs)}>` : 'unknown[]'
case 'object': {
const properties: Record<string, JsonSchema> = schema.properties ?? {}
const required: string[] = schema.required ?? []
const keys = Object.keys(properties)
if (keys.length === 0) {
// A bare object with only `additionalProperties` is a record.
const value =
schema.additionalProperties && typeof schema.additionalProperties === 'object'
? toTypeScript(schema.additionalProperties, indent, refs)
: 'unknown'
return `Record<string, ${value}>`
}
const lines = keys.map((key) => {
const optional = required.includes(key) ? '' : '?'
const safeKey = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key)
return `${pad}${safeKey}${optional}: ${toTypeScript(properties[key], indent + 1, refs)}`
})
return `{\n${lines.join('\n')}\n${closePad}}`
}
}
// `z.unknown()` / `z.any()` render as a schema carrying no constraints. A
// `.describe()` on one adds annotation keys without narrowing the type, so
// those are not constraints either.
const ANNOTATION_KEYS = new Set(['$schema', 'description', 'title', 'default', 'examples'])
if (Object.keys(schema).every((k) => ANNOTATION_KEYS.has(k))) return 'unknown'
throw new Error(`Unhandled JSON Schema construct: ${JSON.stringify(schema).slice(0, 200)}`)
}
/**
* A type plus any aliases that must be declared before it.
*
* A recursive schema cannot be written inline, so Zod lifts it into `$defs` and
* points at it; those become real named types, which TypeScript resolves
* recursively without complaint.
*/
interface GeneratedType {
type: string
declarations: string[]
}
function schemaToType(schema: z.ZodType, io: 'input' | 'output', name: string): GeneratedType {
const json = z.toJSONSchema(schema, { io, unrepresentable: 'any' }) as JsonSchema
const defs = json.$defs as Record<string, JsonSchema> | undefined
if (!defs) return { type: toTypeScript(json), declarations: [] }
// Named after the type that owns them, so two operations lifting their own
// `__schema0` cannot collide in the single generated module.
const refs = new Map(Object.keys(defs).map((key, index) => [key, `${name}Ref${index}`]))
const declarations = Object.entries(defs).map(
([key, def]) => `type ${refs.get(key)} = ${toTypeScript(def, 0, refs)}\n`
)
const { $defs, ...root } = json
return { type: toTypeScript(root, 0, refs), declarations }
}
/** Path params the CLI must substitute, e.g. `/api/v2/workflows/[id]` → `['id']`. */
function pathParams(routePath: string): string[] {
return [...routePath.matchAll(/\[([^\]]+)\]/g)].map((m) => m[1])
}
/**
* The kind a request field reduces to for the CLI's purposes.
*
* Everything from argv arrives as a string, so this is what tells the runtime
* how to turn `"50"` into `50`, a bare `--flag` into `true`, and `'{"a":1}'`
* into an object. `unknown` covers `z.unknown()`/`z.any()`, which the CLI can
* only accept as JSON.
*/
type FieldKind =
| 'string'
| 'number'
| 'integer'
| 'boolean'
| 'enum'
| 'array'
| 'object'
| 'unknown'
function fieldKind(schema: JsonSchema): FieldKind {
if (schema.enum) return 'enum'
const variants = schema.anyOf ?? schema.oneOf
if (variants) {
// Nullable is spelled as a union with `null`; a single non-null branch is
// the field's real kind. A genuine multi-branch union has no single flag
// shape, so it falls through to `unknown` and is taken as JSON.
const concrete = variants.filter((v: JsonSchema) => v.type !== 'null')
return concrete.length === 1 ? fieldKind(concrete[0]) : 'unknown'
}
const type = Array.isArray(schema.type)
? schema.type.find((t: string) => t !== 'null')
: schema.type
switch (type) {
case 'string':
case 'number':
case 'integer':
case 'boolean':
case 'array':
case 'object':
return type
default:
return 'unknown'
}
}
/**
* Describes one request slot's fields for the runtime that builds flags.
*
* Emitted as data rather than baked into types because the CLI has to *iterate*
* these at startup to construct commands — a type alone cannot be walked.
*/
/**
* Whether the slot is a union, whose branches the CLI cannot turn into flags.
*
* Distinct from "the map came out empty": the shared fields of a union are
* emitted as a map, so emptiness alone no longer identifies one, and the
* runtime still has to know the rest of the body must come in as JSON.
*/
function isUnionSlot(schema: z.ZodType): boolean {
const json = z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) as JsonSchema
return Object.keys(json.properties ?? {}).length === 0 && Boolean(json.anyOf ?? json.oneOf)
}
function renderSlotMap(schema: z.ZodType | undefined, indent: string): string | null {
if (!schema) return null
const json = z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) as JsonSchema
let properties: Record<string, JsonSchema> = json.properties ?? {}
let required = new Set<string>(json.required ?? [])
// A union has no properties of its own, but the fields every branch agrees on
// are still known and still have to be sent — `workspaceId` is required by
// both branches of the row-insert body and comes from the profile, so
// dropping it left `tables rows create` rejected as invalid input.
if (Object.keys(properties).length === 0) {
const branches = (json.anyOf ?? json.oneOf) as JsonSchema[] | undefined
if (branches?.length) {
const shared = branches.reduce<string[]>(
(keys, branch) => keys.filter((key) => branch.properties?.[key] !== undefined),
Object.keys(branches[0].properties ?? {})
)
properties = Object.fromEntries(shared.map((key) => [key, branches[0].properties[key]]))
required = new Set(shared.filter((key) => branches.every((b) => b.required?.includes(key))))
}
}
const keys = Object.keys(properties)
// A union body (e.g. single-row vs batch insert) has no flat field list. The
// caller marks it `opaqueBody` so the runtime can offer the whole body as one
// JSON flag instead.
if (keys.length === 0) return null
// A schema carrying `.meta({ id })` is lifted into `$defs` and referenced, so
// the property here is a bare `$ref` with no type to classify. Left
// unresolved every such field reads as `unknown` and the CLI demands JSON for
// what is really a plain string flag.
const defs = (json.$defs ?? {}) as Record<string, JsonSchema>
const deref = (schema: JsonSchema): JsonSchema => {
let current = schema
for (let depth = 0; typeof current.$ref === 'string' && depth < 10; depth++) {
const resolved = defs[current.$ref.replace('#/$defs/', '')]
if (!resolved) break
current = resolved
}
return current
}
const lines = keys.map((key) => {
const property = deref(properties[key])
const parts = [`kind: '${fieldKind(property)}'`]
if (required.has(key)) parts.push('required: true')
if (property.enum) {
parts.push(
`values: [${property.enum.map((v: unknown) => JSON.stringify(v)).join(', ')}] as const`
)
}
if (property.default !== undefined) parts.push(`default: ${JSON.stringify(property.default)}`)
return `${indent} ${JSON.stringify(key)}: { ${parts.join(', ')} },`
})
return `{\n${lines.join('\n')}\n${indent}}`
}
function render(operations: Operation[]): string {
const out: string[] = []
const summaries = loadSummaries()
out.push('/**')
out.push(' * GENERATED FILE — DO NOT EDIT.')
out.push(' *')
out.push(' * Emitted from the Zod route contracts in')
out.push(' * `apps/sim/lib/api/contracts/v2/**` by `scripts/generate-v2-cli-api.ts`.')
out.push(' * Regenerate with `bun run generate:cli-api`; CI fails when this file is')
out.push(' * stale, so edit the contract rather than this file.')
out.push(' *')
out.push(' * Contains only type declarations and one const table — no imports, so the')
out.push(' * `packages/* must not import apps/*` boundary is preserved.')
out.push(' */')
out.push('')
for (const op of operations) {
const Name = pascal(op.name)
const { contract } = op
out.push(`/** \`${contract.method} ${contract.path}\` */`)
for (const slot of ['params', 'query', 'body', 'headers'] as const) {
const schema = contract[slot]
if (!schema) continue
const slotName = `${Name}${pascal(slot)}`
const generated = schemaToType(schema, 'input', slotName)
out.push(...generated.declarations)
out.push(`export type ${slotName} = ${generated.type}`)
out.push('')
}
if (contract.response.mode === 'json' && contract.response.schema) {
const generated = schemaToType(contract.response.schema, 'output', `${Name}Response`)
out.push(...generated.declarations)
out.push(`export type ${Name}Response = ${generated.type}`)
} else {
out.push(`/** Non-JSON response (\`${contract.response.mode}\`). */`)
out.push(`export type ${Name}Response = never`)
}
out.push('')
}
out.push('/**')
out.push(' * Every v2 operation, keyed by name.')
out.push(' *')
out.push(' * `query` and `body` describe each field well enough for the CLI to build a')
out.push(' * flag for it and coerce the string argv gives back: its kind, whether it is')
out.push(' * required, its enum values, and its server-side default. A slot the contract')
out.push(' * does not declare — or one whose shape is a union with no flat field list —')
out.push(' * is absent, and the runtime falls back to taking it as JSON.')
out.push(' *')
out.push(" * `summary` is the operation's one-line description, lifted from the OpenAPI")
out.push(' * specs so `--help` reuses prose that is already written and already checked.')
out.push(' */')
out.push('export const V2_OPERATIONS = {')
for (const op of operations) {
const params = pathParams(op.contract.path)
out.push(` ${op.name}: {`)
out.push(` method: '${op.contract.method}',`)
out.push(` path: '${op.contract.path}',`)
out.push(` pathParams: [${params.map((p) => `'${p}'`).join(', ')}] as const,`)
out.push(` responseMode: '${op.contract.response.mode}',`)
// OpenAPI writes `{id}` where the contract writes `[id]`.
const summary = summaries.get(
`${op.contract.method} ${op.contract.path.replace(/\[([^\]]+)\]/g, '{$1}')}`
)
if (summary) out.push(` summary: ${JSON.stringify(summary)},`)
for (const slot of ['query', 'body'] as const) {
const map = renderSlotMap(op.contract[slot], ' ')
if (map) out.push(` ${slot}: ${map},`)
// A declared slot with no flat field list still has to be sendable.
// Absence alone cannot say so: it means both "no body" and "a body the
// generator could not describe", and reading it as the former left
// `tables rows create` unable to send anything at all.
if (slot === 'body' && op.contract.body && isUnionSlot(op.contract.body)) {
out.push(` opaqueBody: true,`)
}
}
out.push(' },')
}
out.push('} as const')
out.push('')
out.push('export type V2OperationName = keyof typeof V2_OPERATIONS')
out.push('')
return out.join('\n')
}
/**
* Runs the emitted source through Biome so the generated file is a fixed point
* of the repo's formatter.
*
* Without this the file is rewritten on the way into a commit: lint-staged runs
* `biome check --write` on explicit paths, which bypasses the `files.includes`
* exclusion in biome.json. The result was a generated file that no longer
* matched its generator, so `--check` failed in CI complaining about contract
* drift that had not happened. Formatting here means the hook has nothing left
* to change.
*/
function format(source: string): string {
const result = spawnSync(
path.join(ROOT, 'node_modules/.bin/biome'),
['format', `--stdin-file-path=${OUTPUT}`],
{ input: source, encoding: 'utf8' }
)
if (result.status !== 0 || !result.stdout) {
// Fail loudly: silently emitting unformatted output would reintroduce the
// exact hook-rewrites-generated-file loop this exists to close.
throw new Error(
`biome failed to format the generated output (status ${result.status}): ${result.stderr ?? ''}`
)
}
return result.stdout
}
async function main() {
const args = new Set(process.argv.slice(2))
const operations = await collectOperations()
const generated = format(render(operations))
if (args.has('--check')) {
let current = ''
try {
current = readFileSync(OUTPUT, 'utf8')
} catch {
console.error(`${path.relative(ROOT, OUTPUT)} is missing. Run: bun run generate:cli-api`)
process.exit(1)
}
if (current !== generated) {
console.error(
`${path.relative(ROOT, OUTPUT)} is stale. Run: bun run generate:cli-api\n\n` +
'The v2 contracts changed without the CLI being regenerated.'
)
process.exit(1)
}
console.log(`${path.relative(ROOT, OUTPUT)} is up to date (${operations.length} operations).`)
return
}
writeFileSync(OUTPUT, generated)
console.log(
`Wrote ${path.relative(ROOT, OUTPUT)} — ${operations.length} operations from ${contractModules().length} contract modules.`
)
}
main()
+138 -2
View File
@@ -6,6 +6,12 @@ import { filesAuditOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/o
import { knowledgeOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/knowledge'
import { logsOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/logs'
import { resourcesOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/resources'
import {
FOLDER_TREE_TOO_LARGE,
RUN_RETENTION,
WORKSPACE_API_KEY_DENIED,
WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND,
} from '../../apps/sim/lib/api/contracts/v2/openapi/shared'
import { tablesOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/tables'
import { workflowsOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/workflows'
import { generateOpenApiDocument, serializeOpenApiDocument } from './generator'
@@ -31,7 +37,7 @@ const EXPECTED_OPERATION_COUNTS = new Map<string, number>([
['apps/docs/openapi-v2-tables.json', 44],
['apps/docs/openapi-v2-knowledge.json', 21],
['apps/docs/openapi-v2-billing.json', 2],
['apps/docs/openapi-v2-resources.json', 22],
['apps/docs/openapi-v2-resources.json', 26],
])
function getOperation(spec: JsonObject, path: string, method: string): JsonObject {
@@ -163,7 +169,7 @@ describe('generated OpenAPI documents', () => {
})
}
}
expect(totalOperations).toBe(135)
expect(totalOperations).toBe(139)
})
it('documents mixed workflow execution and resume responses', () => {
@@ -324,3 +330,133 @@ describe('generated OpenAPI documents', () => {
}
})
})
/**
* Documented error sets.
*
* The 413 sweep runs over all seven documents rather than the two families it
* first audited: the gaps the narrower scope was written around are closed, and
* leaving it narrow would let a new body-carrying operation in any other family
* ship without publishing the 413 its body read raises.
*/
describe('documented error sets', () => {
/**
* A v2 JSON route whose contract declares a body reads that body through
* `parseJsonBody` under `DEFAULT_MAX_JSON_BODY_BYTES` *before* schema
* validation, with the builders supplying `V2_PARSE_DEFAULTS`. So an
* oversized body is a real 413 on every one of them, and an operation that
* does not publish it is documenting a response its callers can hit. The
* converse does not hold — several bodyless folder reads publish 413 because
* materializing an oversized folder tree raises one — so this is one
* directional.
*/
it.each(
DOCUMENTS.flatMap((document) =>
document.routes
.filter((route) => route.contract.body !== undefined)
.map((route) => [route.operation.operationId, route.operation.errors] as const)
)
)('%s publishes the 413 its body read can raise', (_operationId, errors) => {
expect(errors).toContain('PayloadTooLarge')
})
/**
* The file list resolves its `folderPath` filter through the capped folder
* path index, so an oversized workspace tree is a 413 here exactly as it is on
* the knowledge, workflow, and table lists.
*/
it('publishes the folder-tree 413 the file list can raise', () => {
const listFiles = filesAuditOpenApiDocument.routes.find(
(route) => route.operation.operationId === 'listFiles'
)?.operation
expect(listFiles?.errors).toContain('PayloadTooLarge')
expect(listFiles?.description).toContain(FOLDER_TREE_TOO_LARGE)
})
/**
* `listAuditLogs` has no not-found path to publish. It throws only
* `validation` (a bad cursor, a workspaceId outside the organization),
* `resolveEnterpriseAuditAccess` returns 403 shapes only, and an empty
* selection is an empty page. `getAuditLog` does 404 and keeps it.
*/
it('does not publish a 404 the audit-log list cannot emit', () => {
const spec = generateOpenApiDocument(filesAuditOpenApiDocument)
expect(
Object.keys(getOperation(spec, '/api/v2/audit-logs', 'get').responses as JsonObject)
).not.toContain('404')
expect(
Object.keys(getOperation(spec, '/api/v2/audit-logs/{id}', 'get').responses as JsonObject)
).toContain('404')
})
/**
* `files.share.update` denies the workspace key through its principal-kind
* list, which raises `PrincipalKindAuthorizationError` — not one of the
* cross-tenant errors the concealment policy rewrites — so the caller sees
* 403. The description claimed 404.
*/
it('describes the file-share workspace-key refusal as the 403 it renders', () => {
const description = filesAuditOpenApiDocument.routes.find(
(route) => route.operation.operationId === 'upsertFileShare'
)?.operation.description
expect(description).toContain(WORKSPACE_API_KEY_DENIED)
expect(description).not.toContain(WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND)
})
})
/**
* Shared parameter vocabulary.
*
* `cursor` and `sortOrder` appear on dozens of operations across the seven
* documents, and each is sourced from one schema in `contracts/v2/shared.ts`. A
* caller reading two families back to back cannot tell a reworded copy from a
* different contract, so a divergence is a defect rather than a style choice.
* This pins each to one string; a list that hand-rolls its own `cursor` fails
* here.
*
* `startDate`/`endDate` are deliberately excluded: the run-window pair and the
* billing usage window share a name but filter different sequences.
*/
describe('shared parameter descriptions do not fork', () => {
const SINGLE_VOICE_PARAMETERS = ['cursor', 'sortOrder'] as const
const descriptionsByParameter = new Map<string, Set<string>>()
for (const document of DOCUMENTS) {
const spec = generateOpenApiDocument(document)
for (const operation of operations(spec)) {
for (const parameter of (operation.parameters ?? []) as JsonObject[]) {
const name = parameter.name as string
if (!SINGLE_VOICE_PARAMETERS.includes(name as (typeof SINGLE_VOICE_PARAMETERS)[number])) {
continue
}
const seen = descriptionsByParameter.get(name) ?? new Set<string>()
seen.add(parameter.description as string)
descriptionsByParameter.set(name, seen)
}
}
}
it.each(SINGLE_VOICE_PARAMETERS)('publishes one description for %s', (name) => {
expect([...(descriptionsByParameter.get(name) ?? [])]).toHaveLength(1)
})
})
/**
* The run-retention window is the one fact that explains an empty run list on a
* workflow reporting a non-zero `runCount`, and it is published on both reads
* over `workflow_execution_logs` from one constant. Pinning both keeps a future
* trim from silently dropping it off one of them.
*/
describe('run retention is published on both run reads', () => {
it.each([
[logsOpenApiDocument, 'listLogs'],
[workflowsOpenApiDocument, 'listWorkflowRunsV2'],
] as const)('%#: names the retention window', (document, operationId) => {
const description = document.routes.find((route) => route.operation.operationId === operationId)
?.operation.description
expect(description).toContain(RUN_RETENTION)
})
})
+20
View File
@@ -241,6 +241,20 @@ function objectProperties(
}
}
/**
* Whether a request slice declares no keys at all, i.e. `z.object({}).strict()`.
*
* A v2 contract states that an endpoint takes no query params by declaring
* `query: noInputSchema` rather than by omitting `query`, because an omitted
* slice is the one `parseRequest` skips validating entirely. That distinction is
* load-bearing at runtime and invisible to the spec: either way the operation
* publishes zero parameters.
*/
function declaresNoKeys(schema: ApiSchema): boolean {
const def = (schema as { def?: { type?: string; shape?: Record<string, unknown> } }).def
return def?.type === 'object' && Object.keys(def.shape ?? {}).length === 0
}
function parametersFor(
schema: ApiSchema | undefined,
location: 'path' | 'query' | 'header',
@@ -248,6 +262,12 @@ function parametersFor(
label: string
): JsonObject[] {
if (!schema) return []
/**
* Short-circuited ahead of `objectProperties`, which would otherwise demand
* the `.meta({ id })` and the non-empty `properties` an empty schema has
* nothing to supply, and would register a component no operation references.
*/
if (declaresNoKeys(schema)) return []
const { properties, required } = objectProperties(schema, components, label)
return Object.entries(properties).map(([name, property]) => {
invariant(property && typeof property === 'object', `${label}.${name} is not a schema`)
+7
View File
@@ -12,5 +12,12 @@ export default defineConfig({
test: {
environment: 'node',
include: ['scripts/openapi/**/*.test.ts'],
/**
* The determinism check serializes all seven published documents twice —
* roughly 2MB of JSON — so it was running against vitest's 5s default
* rather than a budget anyone chose. It already sat just under that on
* staging, and this branch's richer descriptions tip it over.
*/
testTimeout: 30_000,
},
})