From 1e1e0e7073cf5e7c884d5cf236f6efecef2cd2cd Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:10:19 -0400 Subject: [PATCH 01/24] docs(v3): publish canonical DX migration guide (#224) --- .../btst-backend-plugin-dev/REFERENCE.md | 52 +- .../skills/btst-backend-plugin-dev/SKILL.md | 8 +- .agents/skills/btst-build-config/SKILL.md | 19 +- .../btst-client-plugin-dev/REFERENCE.md | 2 +- CONTRIBUTING.md | 121 ++--- README.md | 6 +- docs/content/docs/api-reference.mdx | 22 +- docs/content/docs/auth.mdx | 14 +- docs/content/docs/breaking-changes.mdx | 496 +++++++++++++++--- docs/content/docs/cli.mdx | 4 +- docs/content/docs/databases/adapters.mdx | 24 +- docs/content/docs/how-it-works.mdx | 15 +- docs/content/docs/installation.mdx | 24 +- docs/content/docs/plugins/ai-chat.mdx | 14 +- docs/content/docs/plugins/blog.mdx | 12 +- docs/content/docs/plugins/cms.mdx | 10 +- docs/content/docs/plugins/comments.mdx | 10 +- docs/content/docs/plugins/development.mdx | 15 +- docs/content/docs/plugins/form-builder.mdx | 6 +- docs/content/docs/plugins/kanban.mdx | 10 +- docs/content/docs/plugins/media.mdx | 14 +- docs/content/docs/plugins/open-api.mdx | 6 +- docs/content/docs/plugins/ui-builder.mdx | 8 +- e2e/tests/helpers/mock-auth.ts | 4 +- e2e/tests/smoke.auth-blog.spec.ts | 2 +- package.json | 3 +- scripts/check-canonical-dx.mjs | 419 +++++++++++++++ .../files/nextjs/lib/authorization.server.ts | 4 +- .../codegen/files/nextjs/lib/stack-auth.ts | 4 +- .../app/lib/authorization.server.ts | 4 +- .../files/react-router/app/lib/stack-auth.ts | 4 +- .../tanstack/src/lib/authorization.server.ts | 4 +- .../files/tanstack/src/lib/stack-auth.ts | 4 +- 33 files changed, 1072 insertions(+), 292 deletions(-) create mode 100644 scripts/check-canonical-dx.mjs diff --git a/.agents/skills/btst-backend-plugin-dev/REFERENCE.md b/.agents/skills/btst-backend-plugin-dev/REFERENCE.md index 4bce341a5..1a9dfca3d 100644 --- a/.agents/skills/btst-backend-plugin-dev/REFERENCE.md +++ b/.agents/skills/btst-backend-plugin-dev/REFERENCE.md @@ -3,30 +3,30 @@ ## defineBackendPlugin shape (api/plugin.ts) ```typescript -export const myBackendPlugin = defineBackendPlugin({ - name: "my-plugin", - dbPlugin: dbSchema, - operations: (adapter) => ({ - createItem: defineOperation({ - input: CreateItemSchema, - permission: itemPermissions.item.create, - facts: () => undefined, - execute: ({ input }) => createItem(adapter, input), +export interface MyBackendPluginOptions { + hooks?: MyBackendHooks +} + +export const myBackendPlugin = (options: MyBackendPluginOptions = {}) => + defineBackendPlugin({ + id: "myPlugin", + dbPlugin: dbSchema, + operations: (adapter) => createMyOperations(adapter, options.hooks), + raw: (adapter) => ({ + prefetchForRoute: createItemPrefetchForRoute(adapter), }), - }), - raw: (adapter) => ({ - prefetchForRoute: createItemPrefetchForRoute(adapter), - }), - routes: (_adapter, _context, operations) => ({ - createItem: createEndpoint( - "/items", - { method: "POST", body: CreateItemSchema, requireRequest: true }, - operations.createItem.route((ctx) => ctx.body), - ), - }), -}) + routes: (_adapter, _context, operations) => ({ + createItem: createEndpoint( + "/items", + { method: "POST", body: CreateItemSchema, requireRequest: true }, + operations.createItem.route((ctx) => ctx.body), + ), + }), + }) -export type MyApiRouter = ReturnType +export type MyApiRouter = ReturnType< + ReturnType["routes"] +> ``` ## getters.ts @@ -114,16 +114,16 @@ export { serializeItem } from "./serializers" Invoke domain hooks from the operation lifecycle after authorization. Hooks can enforce domain invariants, publish side effects, and observe errors; they are not the authorization policy. -## Plugin stack() wiring (in stack.ts) +## Plugin `createBackendStack()` wiring (in stack.ts) ```typescript -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { myBackendPlugin } from "./src/plugins/my-plugin/api/plugin" -export const myStack = stack({ +export const myStack = createBackendStack({ basePath: "/api/data", plugins: { - myPlugin: myBackendPlugin, + myPlugin: myBackendPlugin(), }, adapter: (db) => createDrizzleAdapter(schema, db, {}), }) diff --git a/.agents/skills/btst-backend-plugin-dev/SKILL.md b/.agents/skills/btst-backend-plugin-dev/SKILL.md index b260393ca..5d3fd57f3 100644 --- a/.agents/skills/btst-backend-plugin-dev/SKILL.md +++ b/.agents/skills/btst-backend-plugin-dev/SKILL.md @@ -25,7 +25,7 @@ src/plugins/{name}/ - **`api/index.ts`** — re-export everything from getters + mutations for direct server-side import. - **`operations.ts`** — define the one maintained business inventory with input validation, exact permission descriptors, authoritative facts, domain execution, and lifecycle hooks. - Bind HTTP routes to same-key operations. Use `operationRouteMap` only for real route-name mismatches. -- The optional `raw` factory is narrow: first-party plugins expose only `prefetchForRoute` for SSG. Do not duplicate business getters or mutations on `stack().raw`. +- The optional `raw` factory is narrow: first-party plugins expose only `prefetchForRoute` for SSG. Do not duplicate business getters or mutations on `createBackendStack().raw`. - Use `myStack.forRequest(request).operations.*` for request work and `myStack.trusted.*` for explicitly trusted jobs. ## Key patterns @@ -61,7 +61,9 @@ already support. `myStack` is a module-level const. The `execute` closure runs lazily (only on HTTP request), so `myStack` is always initialised by then: ```typescript -export const myStack = stack({ ... }) +import { createBackendStack } from "@btst/stack/api" + +export const myStack = createBackendStack({ ... }) const myTool = tool({ execute: async (params) => { @@ -80,7 +82,7 @@ const myTool = tool({ - **Wrong adapter type** — use `import type { DBAdapter as Adapter } from "@btst/db"` in getters/mutations/plugin files. - **`"GET /path"` string keys** — routes use `createEndpoint()`, not string-keyed method/path objects. - **`ctx.json()`** — does not exist; return data directly from route handlers. -- **Business methods on `stack().raw`** — do not add them. Keep the composed lifecycle explicit through `forRequest(request).operations` or `trusted`, and reserve `raw` for narrow lower-level/SSG helpers. +- **Business methods on `createBackendStack().raw`** — do not add them. Keep the composed lifecycle explicit through `forRequest(request).operations` or `trusted`, and reserve `raw` for narrow lower-level/SSG helpers. - **Authorization in lifecycle hooks** — routine access control belongs in operation descriptors and the one shared rule. Hooks receive already-authorized context. - **Write ops in `getters.ts`** — write functions belong in `mutations.ts`, not `getters.ts`. diff --git a/.agents/skills/btst-build-config/SKILL.md b/.agents/skills/btst-build-config/SKILL.md index 45dc771dc..b255984a4 100644 --- a/.agents/skills/btst-build-config/SKILL.md +++ b/.agents/skills/btst-build-config/SKILL.md @@ -117,21 +117,14 @@ built-in plugin override. Configure `api`, `site`, and `queryClient` once in accept only plugin-specific options; their loaders and metadata receive shared runtime through the stack resolver. -### Override type registration (in each layout) - -```typescript -import type { YourPluginOverrides } from "@btst/stack/plugins/{name}/client" - -type LegacyPluginOverrides = { - "{name}": YourPluginOverrides, -} -``` +### Override type inference Resolved definitions contribute their public override type automatically under -their canonical programmatic ID (`blog`, `aiChat`). Keep a manual map only for -an unmigrated compatibility plugin, and delete it when that plugin adopts the -resolved contract. Do not recreate removed v2 framework, API, guard, or identity -fields in local intersection types. +their canonical programmatic ID (`blog`, `aiChat`). Register the definition in +`createClientStack({ plugins })` and pass that resolved stack to +`StackProvider`; never recreate a manual application override map or provider +generic. Do not restore removed framework, API, guard, or identity fields in +local intersection types. ## Adding shared UI components (@workspace/ui) diff --git a/.agents/skills/btst-client-plugin-dev/REFERENCE.md b/.agents/skills/btst-client-plugin-dev/REFERENCE.md index b50bc9311..9e3977a57 100644 --- a/.agents/skills/btst-client-plugin-dev/REFERENCE.md +++ b/.agents/skills/btst-client-plugin-dev/REFERENCE.md @@ -132,7 +132,7 @@ export function createMyQueryKeys(client: ResourceClient, headers?: HeadersInit) ## Programmatic id (client/constants.ts) ```typescript -export const MY_PLUGIN_ID = "my-plugin" as const +export const MY_PLUGIN_ID = "myPlugin" as const ``` ## defineClientPlugin shape (client/plugin.tsx) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f369e4ed8..c673435f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -102,31 +102,43 @@ If you want to publish a plugin as a standalone npm package (not merged into thi ### Plugin anatomy -A plugin has two halves that must be kept in sync: +A plugin may expose either or both of these independent halves: | Half | Entry point | Factory function | Import path | |------|-------------|------------------|-------------| | Backend | `api/plugin.ts` | `defineBackendPlugin` | `@btst/stack/plugins/api` | | Client | `client/plugin.tsx` | `defineClientPlugin` | `@btst/stack/plugins/client` | +Do not add a placeholder half for symmetry. OpenAPI is backend-only, Route Docs +is client-only, and UI Builder is client-only over CMS. When both halves exist, +their camelCase programmatic ID and registration key must agree; package and +URL slugs may remain kebab-case. + **Minimum backend shape:** ```typescript -import { defineBackendPlugin, createDbPlugin, createEndpoint, type Adapter } from "@btst/stack/plugins/api" - -export const myBackendPlugin = defineBackendPlugin({ - name: "my-plugin", // unique key — must match the key used in stack({ plugins: { ... } }) - dbPlugin: mySchema, // from createDbPlugin(...) - routes: (adapter: Adapter) => { - const listItems = createEndpoint("/items", { method: "GET" }, async () => { - return adapter.findMany({ model: "item" }) - }) - return { listItems } as const - }, -}) +import { defineBackendPlugin, createEndpoint } from "@btst/stack/plugins/api" + +export interface MyBackendPluginOptions { + hooks?: MyBackendHooks +} + +export const myBackendPlugin = (options: MyBackendPluginOptions = {}) => + defineBackendPlugin({ + id: "myPlugin", // camelCase programmatic ID; package and URL slugs may stay kebab-case + dbPlugin: mySchema, + operations: (adapter) => createMyOperations(adapter, options.hooks), + routes: (_adapter, _context, operations) => ({ + listItems: createEndpoint( + "/items", + { method: "GET", requireRequest: true }, + operations.listItems.route(() => ({})), + ), + }), + }) // Export the inferred router type — the client plugin imports this for end-to-end type safety -export type MyApiRouter = ReturnType +export type MyApiRouter = ReturnType["routes"]> ``` **Minimum client shape:** @@ -139,7 +151,7 @@ import { } from "@btst/stack/plugins/client" import { lazy } from "react" -export const MY_PLUGIN_ID = "my-plugin" as const +export const MY_PLUGIN_ID = "myPlugin" as const export interface MyClientConfig { title?: string @@ -334,10 +346,10 @@ export async function createItem(adapter: Adapter, input: CreateItemInput): Prom ```typescript // packages/stack/src/plugins/your-plugin/api/plugin.ts -import { defineBackendPlugin, createEndpoint, type Adapter } from "@btst/stack/plugins/api" +import { defineBackendPlugin, createEndpoint } from "@btst/stack/plugins/api" import { mySchema } from "../db" import { createItemSchema, updateItemSchema } from "../schemas" -import { listItems, getItemById } from "./getters" +import { createMyOperations } from "./operations" export interface MyBackendHooks { onBeforeCreateItem?: (data: unknown, ctx: { headers: Headers }) => Promise | void @@ -345,54 +357,37 @@ export interface MyBackendHooks { onErrorCreateItem?: (error: Error, ctx: { headers: Headers }) => Promise | void } -export const myBackendPlugin = (hooks?: MyBackendHooks) => +export interface MyBackendPluginOptions { + hooks?: MyBackendHooks +} + +export const myBackendPlugin = (options: MyBackendPluginOptions = {}) => defineBackendPlugin({ - name: "your-plugin", + id: "yourPlugin", dbPlugin: mySchema, - - routes: (adapter: Adapter) => { - const listItemsEndpoint = createEndpoint("/items", { method: "GET" }, async () => { - return listItems(adapter) - }) - - const createItemEndpoint = createEndpoint( + operations: (adapter) => createMyOperations(adapter, options.hooks), + routes: (_adapter, _context, operations) => ({ + listItemsEndpoint: createEndpoint( + "/items", + { method: "GET", requireRequest: true }, + operations.listItems.route(() => ({})), + ), + createItemEndpoint: createEndpoint( "/items", - { method: "POST", body: createItemSchema }, - async (ctx) => { - if (hooks?.onBeforeCreateItem) { - try { - await hooks.onBeforeCreateItem(ctx.body, { headers: ctx.headers }) - } catch (e) { - throw ctx.error(403, { message: e instanceof Error ? e.message : "Unauthorized" }) - } - } - const item = await adapter.create({ model: "item", data: { ...ctx.body, createdAt: new Date(), updatedAt: new Date() } }) - await hooks?.onAfterCreateItem?.(item, { headers: ctx.headers }) - return item - }, - ) - - const updateItemEndpoint = createEndpoint( + { method: "POST", body: createItemSchema, requireRequest: true }, + operations.createItem.route((ctx) => ctx.body), + ), + updateItemEndpoint: createEndpoint( "/items/:id", - { method: "PUT", body: updateItemSchema }, - async (ctx) => { - const updated = await adapter.update({ - model: "item", - where: [{ field: "id", value: ctx.params.id }], - update: { ...ctx.body, updatedAt: new Date() }, - }) - if (!updated) throw ctx.error(404, { message: "Item not found" }) - return updated - }, - ) - - const deleteItemEndpoint = createEndpoint("/items/:id", { method: "DELETE" }, async (ctx) => { - await adapter.delete({ model: "item", where: [{ field: "id", value: ctx.params.id }] }) - return { success: true } - }) - - return { listItemsEndpoint, createItemEndpoint, updateItemEndpoint, deleteItemEndpoint } as const - }, + { method: "PUT", body: updateItemSchema, requireRequest: true }, + operations.updateItem.route((ctx) => ({ id: ctx.params.id, data: ctx.body })), + ), + deleteItemEndpoint: createEndpoint( + "/items/:id", + { method: "DELETE", requireRequest: true }, + operations.deleteItem.route((ctx) => ({ id: ctx.params.id })), + ), + }), }) export type MyApiRouter = ReturnType["routes"]> @@ -426,7 +421,7 @@ import { lazy } from "react" import type { QueryClient } from "@tanstack/react-query" import type { MyApiRouter } from "../api/plugin" -export const MY_PLUGIN_ID = "your-plugin" as const +export const MY_PLUGIN_ID = "yourPlugin" as const export interface MyClientConfig { title?: string @@ -480,7 +475,7 @@ function myLoader(config: ResolvedMyClientConfig) { if (isConnectionError(error)) { console.warn( "[btst/your-plugin] route.loader() failed — no server at build time. " + - "Use myStack.raw['your-plugin'].prefetchForRoute() for SSG.", + "Use myStack.raw.yourPlugin.prefetchForRoute() for SSG.", ) } // Do not re-throw — let React Query store errors and Error Boundaries handle them during render diff --git a/README.md b/README.md index 2f8616bee..f80e40031 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,10 @@ Enable the features you need and keep building your product. | **Route Docs** | Auto-generated client route documentation with interactive navigation | | **Comments** | Commenting system with moderation, likes, and nested replies | -Each plugin ships **frontend + backend together**: -routes, APIs, database models, React components, SSR, and SEO — already wired. +Full-stack plugins ship separate frontend and backend definitions: routes, +APIs, database models, React components, SSR, and SEO—already wired. Intentional +one-sided plugins stay one-sided: OpenAPI is backend-only, Route Docs is +client-only, and UI Builder is client-only over CMS. **Want a specific plugin?** [Open an issue](https://github.com/better-stack-ai/better-stack/issues/new) and let us know! diff --git a/docs/content/docs/api-reference.mdx b/docs/content/docs/api-reference.mdx index 4c5448666..90746de7e 100644 --- a/docs/content/docs/api-reference.mdx +++ b/docs/content/docs/api-reference.mdx @@ -5,21 +5,21 @@ description: Autogenerated API reference for BTST ## Backend (`@btst/stack/api`) -### stack +### createBackendStack - + ### BackendPlugin -### BackendLibConfig +### BackendStackConfig - + -### BackendLib +### BackendStack - + ### toNodeHandler @@ -81,19 +81,17 @@ objects, or the shared API/site/QueryClient runtime. values through `providerConfig`; it is not a provider override and applications do not configure it on `StackProvider`. -`createStackClient` remains a deprecated forwarding alias during the RC migration. - ### ClientPlugin -### ClientLib +### ClientStack - + -### ClientLibConfig +### ResolvedClientStack - + ### SitemapEntry diff --git a/docs/content/docs/auth.mdx b/docs/content/docs/auth.mdx index 937d6fc3b..ce5a647c5 100644 --- a/docs/content/docs/auth.mdx +++ b/docs/content/docs/auth.mdx @@ -242,11 +242,11 @@ without constructing a synthetic `Request`. Existing backend-only adapters can keep the request-aware `getIdentity({ request, headers })` callback, but those adapters are not accepted by headers-only layout helpers until adapted. -Pass it to `stack({ auth: serverAuth })`. Every Blog HTTP route and its matching `myStack.forRequest(request).operations.blog.*` method use the same operation and rule. Operations load authoritative post, author, and current publish facts before authorization, so client-supplied ownership or visibility facts are never trusted. Trusted jobs can call `myStack.trusted.blog.*`; trusted calls skip user authorization but retain input validation, trusted fact derivation, domain behavior, and lifecycle hooks. +Pass it to `createBackendStack({ auth: serverAuth })`. Every Blog HTTP route and its matching `myStack.forRequest(request).operations.blog.*` method use the same operation and rule. Operations load authoritative post, author, and current publish facts before authorization, so client-supplied ownership or visibility facts are never trusted. Trusted jobs can call `myStack.trusted.blog.*`; trusted calls skip user authorization but retain input validation, trusted fact derivation, domain behavior, and lifecycle hooks. Ordinary denials become HTTP 401 for anonymous identities and 403 for authenticated identities. Identity validation and rule failures remain errors instead of being converted into denials. -`stack()` also checks the operation catalog at typecheck time. A server adapter +`createBackendStack()` also checks the operation catalog at typecheck time. A server adapter that registers a different permission catalog—or reuses the same stable id with an incompatible fact schema—cannot be composed with Blog operations. @@ -674,6 +674,8 @@ defineOperation({ ### Trusted server calls +{/* canonical-dx-guard: migration:start reason="removed server namespace example" */} + Before: ```ts @@ -681,6 +683,8 @@ await app.api.cms.createContentItem("article", body); await app.api.blog.getAllPosts(); ``` +{/* canonical-dx-guard: migration:end */} + After: ```ts @@ -703,13 +707,15 @@ After: ```ts aiChatBackendPlugin({ access: "authorized" }); -stack({ auth: serverAuth, plugins: { aiChat } }); +createBackendStack({ auth: serverAuth, plugins: { aiChat } }); ``` The backend `mode` alias and `getUserId` callback are removed. The client plugin's `mode` still controls its real conversation UI/persistence mode; it is not a security boundary. ### Lifecycle context and Comments authorship +{/* canonical-dx-guard: migration:start reason="removed lifecycle and identity bridge example" */} + Before: ```ts @@ -725,6 +731,8 @@ const onBeforeCreatePost = (_input: unknown, context: BlogApiContext) => { }; ``` +{/* canonical-dx-guard: migration:end */} + After: ```ts diff --git a/docs/content/docs/breaking-changes.mdx b/docs/content/docs/breaking-changes.mdx index 8e26535e2..1531e4caa 100644 --- a/docs/content/docs/breaking-changes.mdx +++ b/docs/content/docs/breaking-changes.mdx @@ -10,6 +10,403 @@ This page documents breaking changes between major versions and provides migrati --- +## v3 RC2 → RC3: Canonical stack and plugin DX + +RC3 removes the remaining duplicate runtime configuration and historical naming +seams. The migration is mechanical: rename the constructors, move shared client +runtime to one client stack, nest backend hooks, update programmatic IDs and +lifecycle names, and select the server surface whose trust contract matches the +caller. + +### 1. Rename both stack constructors + +{/* canonical-dx-guard: migration:start reason="removed constructor example" */} + +Before: + +```ts +import { stack } from "@btst/stack" +import { createStackClient } from "@btst/stack/client" + +const backend = stack({ /* ... */ }) +const client = createStackClient({ /* ... */ }) +``` + +{/* canonical-dx-guard: migration:end */} + +After: + +```ts +import { createBackendStack } from "@btst/stack/api" +import { createClientStack } from "@btst/stack/client" + +const backend = createBackendStack({ /* ... */ }) +const client = createClientStack({ /* ... */ }) +``` + +{/* canonical-dx-guard: migration:start reason="removed constructor names in migration prose" */} + +Use only `createBackendStack` and `createClientStack` in maintained code. The +earlier `stack` and `createStackClient` names are migration inputs, not parallel +constructor stories. + +{/* canonical-dx-guard: migration:end */} + +### 2. Move shared client runtime to one resolved stack + +{/* canonical-dx-guard: migration:start reason="duplicated client runtime example" */} + +Before, every client plugin and the provider repeated transport, site, cache, +and request values: + +```tsx +const blog = blogClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + siteBaseURL: baseURL, + siteBasePath: "/pages", + queryClient, + headers: requestHeaders, +}) + + + {children} + +``` + +{/* canonical-dx-guard: migration:end */} + +After, create one request-specific stack for loaders and metadata by supplying +`api.headers`, and a separate stable browser stack without request headers: + +```tsx +const clientStack = createClientStack({ + api: { baseURL, basePath: "/api/data" }, + site: { baseURL, basePath: "/pages" }, + queryClient, + plugins: { + blog: blogClientPlugin(), + }, +}) + + + {children} + +``` + +`createClientStack` is the only owner of API location, site location, +QueryClient, request headers, registered plugin definitions, and endpoint +replacement. `StackProvider` owns browser/framework services (`router`, +`auth`, `notify`, and `i18n`) plus genuine plugin browser customization. + +Never serialize the request-specific stack into the browser. Construct the SSR +stack with filtered request headers, then construct the browser stack from +browser-safe origins and the hydrated QueryClient. + +### 3. Delete manual provider generics and override maps + +{/* canonical-dx-guard: migration:start reason="removed provider generic example" */} + +Before: + +```tsx +type AppPluginOverrides = { + "ai-chat": AiChatPluginOverrides + blog: BlogPluginOverrides +} + + + basePath="/pages" + overrides={overrides} +> + {children} + +``` + +{/* canonical-dx-guard: migration:end */} + +After, the resolved definitions registered in `createClientStack({ plugins })` +infer the valid override keys and each value shape: + +```tsx + + {children} + +``` + +Omit `overrides` entirely when no plugin needs customization. +`usePluginOverrides()` supplies a safe empty value for an omitted optional +block; genuinely required plugin customization remains required by the +registered definition's inferred type. + +### 4. Rename programmatic plugin IDs to camelCase + +Package paths and URL slugs remain kebab-case. Only programmatic registration +IDs, provider keys, resource namespaces, and diagnostics change: + +| Package or URL slug | Removed programmatic ID | Canonical programmatic ID | +| --- | --- | --- | +| `ai-chat` | `ai-chat` | `aiChat` | +| `blog` | `blog` | `blog` | +| `cms` | `cms` | `cms` | +| `comments` | `comments` | `comments` | +| `form-builder` | `form-builder` | `formBuilder` | +| `kanban` | `kanban` | `kanban` | +| `media` | `media` | `media` | +| `open-api` | `open-api` | `openApi` | +| `route-docs` | `route-docs` | `routeDocs` | +| `ui-builder` | `ui-builder` | `uiBuilder` | + +{/* canonical-dx-guard: migration:start reason="removed plugin ID example" */} + +```diff +plugins: { +- "ai-chat": aiChatClientPlugin(), +- "form-builder": formBuilderClientPlugin(), +- "route-docs": routeDocsClientPlugin(), +- "ui-builder": uiBuilderClientPlugin(), ++ aiChat: aiChatClientPlugin(), ++ formBuilder: formBuilderClientPlugin(), ++ routeDocs: routeDocsClientPlugin(), ++ uiBuilder: uiBuilderClientPlugin(), +} +``` + +{/* canonical-dx-guard: migration:end */} + +### 5. Use one backend options object with nested hooks + +{/* canonical-dx-guard: migration:start reason="removed backend factory forms" */} + +Before: + +```ts +blogBackendPlugin(blogHooks) +commentsBackendPlugin({ allowEditing: false, onAfterPost }) +kanbanBackendPlugin(resolveUser, searchUsers, kanbanHooks) +``` + +{/* canonical-dx-guard: migration:end */} + +After: + +```ts +blogBackendPlugin({ hooks: blogHooks }) +commentsBackendPlugin({ + allowEditing: false, + resolveUser, + hooks: { onAfterCreateComment }, +}) +kanbanBackendPlugin({ resolveUser, searchUsers, hooks: kanbanHooks }) +``` + +Every backend plugin is a factory receiving at most one options object. +Optional-only factories allow `plugin()`. Required domain configuration stays +required and adjacent to `hooks`: AI Chat keeps its model, tools, and access +mode; CMS keeps content types; Comments keeps behavior and user resolution; +Kanban keeps user resolution and search; Media keeps storage, tenant, and +upload configuration; OpenAPI keeps its presentation/schema options. + +OpenAPI is intentionally backend-only. Route Docs is intentionally client-only. +UI Builder is intentionally client-only and composes over the registered CMS +backend/client contract; do not invent matching halves for symmetry. + +### 6. Rename every backend lifecycle callback + +The tables below come from the structured `*_LIFECYCLE_HOOK_MIGRATIONS` +inventories shipped by each plugin. Names absent from these tables did not +change. Keep all callbacks under the plugin factory's `hooks` field. + +{/* canonical-dx-guard: migration:start reason="complete removed lifecycle inventory" */} + +#### AI Chat + +| Removed name | Canonical name | +| --- | --- | +| `onBeforeToolsActivated` | `onBeforeActivateTools` | +| `onConversationsRead` | `onAfterListConversations` | +| `onConversationRead` | `onAfterGetConversation` | +| `onConversationCreated` | `onAfterCreateConversation` | +| `onConversationUpdated` | `onAfterUpdateConversation` | +| `onConversationDeleted` | `onAfterDeleteConversation` | +| `onChatError` | `onErrorChat` | +| `onListConversationsError` | `onErrorListConversations` | +| `onGetConversationError` | `onErrorGetConversation` | +| `onCreateConversationError` | `onErrorCreateConversation` | +| `onUpdateConversationError` | `onErrorUpdateConversation` | +| `onDeleteConversationError` | `onErrorDeleteConversation` | + +#### Blog + +| Removed name | Canonical name | +| --- | --- | +| `onBeforeNextPreviousPosts` | `onBeforeGetNextPreviousPosts` | +| `onPostsRead` | `onAfterListPosts` | +| `onPostCreated` | `onAfterCreatePost` | +| `onPostUpdated` | `onAfterUpdatePost` | +| `onPostDeleted` | `onAfterDeletePost` | +| `onNextPreviousPostsRead` | `onAfterGetNextPreviousPosts` | +| `onListPostsError` | `onErrorListPosts` | +| `onNextPreviousPostsError` | `onErrorGetNextPreviousPosts` | +| `onCreatePostError` | `onErrorCreatePost` | +| `onUpdatePostError` | `onErrorUpdatePost` | +| `onDeletePostError` | `onErrorDeletePost` | + +#### CMS + +| Removed name | Canonical name | +| --- | --- | +| `onBeforeCreate` | `onBeforeCreateContent` | +| `onAfterCreate` | `onAfterCreateContent` | +| `onBeforeUpdate` | `onBeforeUpdateContent` | +| `onAfterUpdate` | `onAfterUpdateContent` | +| `onBeforeDelete` | `onBeforeDeleteContent` | +| `onAfterDelete` | `onAfterDeleteContent` | +| `onError` | `onErrorExecuteContentOperation` | + +#### Comments + +| Removed name | Canonical name | +| --- | --- | +| `onBeforeList` | `onBeforeListComments` | +| `onBeforeCount` | `onBeforeCountComments` | +| `onBeforeListByAuthor` | `onBeforeListCommentsByAuthor` | +| `onBeforePost` | `onBeforeCreateComment` | +| `onAfterPost` | `onAfterCreateComment` | +| `onBeforeEdit` | `onBeforeUpdateComment` | +| `onAfterEdit` | `onAfterUpdateComment` | +| `onBeforeLike` | `onBeforeToggleCommentReaction` | +| `onBeforeStatusChange` | `onBeforeModerateComment` | +| `onAfterApprove` | `onAfterApproveComment` | +| `onBeforeDelete` | `onBeforeDeleteComment` | +| `onAfterDelete` | `onAfterDeleteComment` | + +#### Form Builder + +| Removed name | Canonical name | +| --- | --- | +| `onBeforeFormCreated` | `onBeforeCreateForm` | +| `onAfterFormCreated` | `onAfterCreateForm` | +| `onBeforeFormUpdated` | `onBeforeUpdateForm` | +| `onAfterFormUpdated` | `onAfterUpdateForm` | +| `onBeforeFormDeleted` | `onBeforeDeleteForm` | +| `onAfterFormDeleted` | `onAfterDeleteForm` | +| `onSubmissionError` | `onErrorSubmission` | +| `onBeforeSubmissionDeleted` | `onBeforeDeleteSubmission` | +| `onAfterSubmissionDeleted` | `onAfterDeleteSubmission` | + +#### Kanban + +| Removed name | Canonical name | +| --- | --- | +| `onBeforeReadBoard` | `onBeforeGetBoard` | +| `onBoardsRead` | `onAfterListBoards` | +| `onBoardRead` | `onAfterGetBoard` | +| `onBoardCreated` | `onAfterCreateBoard` | +| `onBoardUpdated` | `onAfterUpdateBoard` | +| `onBoardDeleted` | `onAfterDeleteBoard` | +| `onListBoardsError` | `onErrorListBoards` | +| `onReadBoardError` | `onErrorGetBoard` | +| `onCreateBoardError` | `onErrorCreateBoard` | +| `onUpdateBoardError` | `onErrorUpdateBoard` | +| `onDeleteBoardError` | `onErrorDeleteBoard` | +| `onColumnCreated` | `onAfterCreateColumn` | +| `onColumnUpdated` | `onAfterUpdateColumn` | +| `onColumnDeleted` | `onAfterDeleteColumn` | +| `onTaskCreated` | `onAfterCreateTask` | +| `onTaskUpdated` | `onAfterUpdateTask` | +| `onTaskDeleted` | `onAfterDeleteTask` | + +#### Media + +| Removed name | Canonical name | +| --- | --- | +| `onBeforeDelete` | `onBeforeDeleteAsset` | +| `onAfterDelete` | `onAfterDeleteAsset` | +| `onOperationError` | `onError` | + +{/* canonical-dx-guard: migration:end */} + +The lifecycle grammar is `onBefore`, +`onAfter`, and `onError`. Meaningful domain +events such as chat, submission receipt, moderation approval, and upload +finalization retain their domain vocabulary. Media storage-adapter callbacks +are transport contracts and are not part of this mapping. + +### 7. Select an explicit server trust surface + +{/* canonical-dx-guard: migration:start reason="removed ambiguous server namespaces" */} + +Before: + +```ts +await app.api.blog.updatePost(input) +await app.forRequest(request).api.blog.updatePost(input) +await app.internal.blog.updatePost(input) +``` + +{/* canonical-dx-guard: migration:end */} + +After: + +```ts +await app.forRequest(request).operations.blog.updatePost(input) +await app.trusted.blog.updatePost(input) +await app.raw.blog.prefetchForRoute("post", queryClient, { slug }) +``` + +`forRequest(request).operations` runs validation, trusted-fact derivation, +configured server authorization, domain behavior, and lifecycle hooks. +`trusted` skips only user authorization and keeps the rest of that operation +pipeline. `raw` is the explicit lower-level escape hatch and first-party +plugins expose only narrow SSG prefetch helpers there. Standalone exported +getters and mutations are also lower-level primitives whose caller owns +validation, authorization, and lifecycle composition. + +Omitting `createBackendStack({ auth })` preserves permissive compatibility. +Once server auth is configured, its schema-bound rules are authoritative and +missing or denied rules fail closed. Browser checks remain presentation only; +derive authorization facts from server data rather than client input. + +### 8. Apply endpoint and identity boundaries + +A path-only `endpoints..api` replacement inherits the top-level API +origin and request headers. A replacement `baseURL` must include a replacement +`basePath` and establishes a new transport boundary: server cookies, +authorization, proxy-authorization, and other request headers are not +inherited. Add only explicitly browser-safe `browserHeaders` or an explicit +Fetch `credentials` mode, and only when the destination implements that +plugin's BTST HTTP contract. Site endpoints follow the same path-only versus +complete-replacement rule independently. + +`initialIdentity` is tri-state: `undefined` means no server snapshot was +supplied and the client resolver may run immediately; `null` is an explicitly +hydrated anonymous snapshot; an identity object is an explicitly hydrated +authenticated snapshot. Serialize only schema-validated identity and trusted +deployment origins, never a server stack or request headers. + +BTST core is authentication-provider agnostic. Better Auth and Better Auth UI +are not core dependencies or hidden identity bridges; adapt the provider your +application already uses through `createClientAuth` and `createServerAuth`. +The separate Better Auth UI companion migration happens downstream of the +completed core DX migration. + +--- + ## v2 → v3: Framework entries and resolved client runtime BTST v3 has one supported framework-wiring path: framework entry factories @@ -82,9 +479,9 @@ request-aware examples when SSR authorization needs headers or session state. ### 2. Move shared runtime to the client stack -The Blog client plugin is the first built-in migrated to the resolved runtime -definition. Remove its shared runtime fields and configure API, site, and -QueryClient once in a shared factory: +Every maintained client plugin now uses the resolved runtime definition. +Remove shared runtime fields from each plugin factory and configure API, site, +and QueryClient once in a shared factory: ```ts title="lib/stack-client.ts" export const getStackClient = ( @@ -147,12 +544,9 @@ Wrap this client provider from `app/(request)/pages/layout.tsx` using `app/(static)/pages` and use header-free `getServerClientOrigins()` there. Both route groups keep the `/pages/*` URL. -For Blog, `apiBaseURL`, `apiBasePath`, site fields, `queryClient`, and request -headers are no longer plugin options. Its SSR loaders, metadata, browser hooks, -and mutations use the same resolved runtime. Some other first-party plugins -temporarily retain their legacy shared-runtime factory shape until their -follow-up migration tickets land; new plugin definitions should use the -resolved shape shown here. +`apiBaseURL`, `apiBasePath`, site fields, `queryClient`, and request headers are +no longer built-in plugin options. SSR loaders, metadata, browser hooks, and +mutations use the same resolved runtime for every maintained client plugin. ### 3. Replace render guards with `StackProvider.auth` @@ -257,78 +651,11 @@ onBeforeSubmission: async (_formSlug, data, ctx) => { ### 7. Rename normalized backend lifecycle hooks -RC3 removes the lifecycle spellings used by earlier v3 prereleases. Rename -each configured hook key using the tables below; callback parameters and return -values are otherwise unchanged. JavaScript applications must make the same -updates because removed hook keys are not invoked at runtime. - -#### Form Builder - -| Earlier v3 spelling | RC3 spelling | -| --- | --- | -| `onBeforeFormCreated` | `onBeforeCreateForm` | -| `onAfterFormCreated` | `onAfterCreateForm` | -| `onBeforeFormUpdated` | `onBeforeUpdateForm` | -| `onAfterFormUpdated` | `onAfterUpdateForm` | -| `onBeforeFormDeleted` | `onBeforeDeleteForm` | -| `onAfterFormDeleted` | `onAfterDeleteForm` | -| `onSubmissionError` | `onErrorSubmission` | -| `onBeforeSubmissionDeleted` | `onBeforeDeleteSubmission` | -| `onAfterSubmissionDeleted` | `onAfterDeleteSubmission` | - -Form Builder's `onBeforeCreateForm`, `onBeforeUpdateForm`, and -`onBeforeSubmission` hooks receive a separate mutable-data value and may return -its transformed replacement. Other lifecycle hooks observe their deeply -readonly operation context and perform domain side effects; they do not -transform operation input by returning a value. Submission receipt hooks such -as `onBeforeSubmission` and `onAfterSubmission` keep their domain-event names. - -Keep hook denials exception-based while renaming them: - -```diff -- onBeforeSubmissionDeleted: async (submissionId, ctx) => { -- if (!isAdmin(ctx.headers)) return false -+ onBeforeDeleteSubmission: async (submissionId, ctx) => { -+ if (!isAdmin(ctx.headers)) throw new Error("Admin access required") - } -``` - -#### Kanban - -| Earlier v3 spelling | RC3 spelling | -| --- | --- | -| `onBeforeReadBoard` | `onBeforeGetBoard` | -| `onBoardsRead` | `onAfterListBoards` | -| `onBoardRead` | `onAfterGetBoard` | -| `onBoardCreated` | `onAfterCreateBoard` | -| `onBoardUpdated` | `onAfterUpdateBoard` | -| `onBoardDeleted` | `onAfterDeleteBoard` | -| `onListBoardsError` | `onErrorListBoards` | -| `onReadBoardError` | `onErrorGetBoard` | -| `onCreateBoardError` | `onErrorCreateBoard` | -| `onUpdateBoardError` | `onErrorUpdateBoard` | -| `onDeleteBoardError` | `onErrorDeleteBoard` | -| `onColumnCreated` | `onAfterCreateColumn` | -| `onColumnUpdated` | `onAfterUpdateColumn` | -| `onColumnDeleted` | `onAfterDeleteColumn` | -| `onTaskCreated` | `onAfterCreateTask` | -| `onTaskUpdated` | `onAfterUpdateTask` | -| `onTaskDeleted` | `onAfterDeleteTask` | - -Task and column reorder or move operations continue to use the corresponding -update lifecycle; RC3 does not add separate reorder or move hook phases. - -#### Media - -| Earlier v3 spelling | RC3 spelling | -| --- | --- | -| `onBeforeDelete` | `onBeforeDeleteAsset` | -| `onAfterDelete` | `onAfterDeleteAsset` | -| `onOperationError` | `onError` | - -Media upload hooks keep their domain-event names. Storage-adapter callbacks are -transport contracts rather than public plugin lifecycle hooks and are not part -of this rename. +Use the complete seven-plugin mapping in +[Rename every backend lifecycle callback](#6-rename-every-backend-lifecycle-callback) +above. JavaScript applications must update removed keys too; removed callback +names are not invoked at runtime. Keep hook denials exception-based while +renaming them. ### 8. Replace the retired provider-specific auth scaffold @@ -369,7 +696,8 @@ export const serverAuth = createServerAuth({ }) ``` -Pass `clientAuth` to `StackProvider`, pass `serverAuth` to `stack()`, and keep +Pass `clientAuth` to `StackProvider`, pass `serverAuth` to +`createBackendStack({ auth: serverAuth })`, and keep sign-in, account, and organization routes in your chosen authentication framework. Remove the retired plugin from existing `btst init` commands and delete its generated imports, overrides, CSS import, and package dependencies. @@ -434,6 +762,8 @@ pnpm add @btst/stack@latest Update your backend configuration file (commonly `lib/better-stack.ts` → `lib/stack.ts`): +{/* canonical-dx-guard: migration:start reason="historical v2 constructor example" */} + ```diff - import { betterStack } from "@btst/stack"; + import { stack } from "@btst/stack"; @@ -443,6 +773,8 @@ Update your backend configuration file (commonly `lib/better-stack.ts` → `lib/ // ... your configuration }); ``` + +{/* canonical-dx-guard: migration:end */} diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index 2c918731d..8e8e183d4 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -42,8 +42,8 @@ Common flags: - `lib/query-client.ts` - API and pages catch-all routes using the framework entry factories - Global CSS imports (including plugin CSS) -- Pages layout with `QueryClientProvider` and one top-level - `StackProvider.router` / `StackProvider.api` configuration +- Pages layout with `QueryClientProvider`, one resolved client stack, and the + framework router passed to `StackProvider` If root layout patching is not safe for your file shape, the command prints manual patch instructions instead of applying a destructive rewrite. diff --git a/docs/content/docs/databases/adapters.mdx b/docs/content/docs/databases/adapters.mdx index b06a23050..a07e3e08c 100644 --- a/docs/content/docs/databases/adapters.mdx +++ b/docs/content/docs/databases/adapters.mdx @@ -45,18 +45,18 @@ See the [Installation guide](/installation#install-database-adapter) for detaile ## Usage -When you configure BTST, the `stack()` function collects all plugin database schemas and merges them into a unified schema. The adapter function receives this merged schema and returns an adapter that translates BTST's database operations to your ORM. +When you configure BTST, the `createBackendStack()` function collects all plugin database schemas and merges them into a unified schema. The adapter function receives this merged schema and returns an adapter that translates BTST's database operations to your ORM. ```ts title="lib/stack.ts" - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createPrismaAdapter } from "@btst/adapter-prisma" import { PrismaClient } from "@prisma/client" const prisma = new PrismaClient() - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Your plugins here @@ -73,7 +73,7 @@ When you configure BTST, the `stack()` function collects all plugin database sch ```ts title="lib/stack.ts" - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createDrizzleAdapter } from "@btst/adapter-drizzle" import { drizzle } from "drizzle-orm/postgres-js" // or "drizzle-orm/mysql2", "drizzle-orm/better-sqlite3", etc. import postgres from "postgres" @@ -81,7 +81,7 @@ When you configure BTST, the `stack()` function collects all plugin database sch const client = postgres(process.env.DATABASE_URL!) const drizzleDb = drizzle(client) - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Your plugins here @@ -95,7 +95,7 @@ When you configure BTST, the `stack()` function collects all plugin database sch ```ts title="lib/stack.ts" - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createKyselyAdapter } from "@btst/adapter-kysely" import { Kysely, PostgresDialect } from "kysely" import { Pool } from "pg" @@ -106,7 +106,7 @@ When you configure BTST, the `stack()` function collects all plugin database sch }) }) - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Your plugins here @@ -120,14 +120,14 @@ When you configure BTST, the `stack()` function collects all plugin database sch ```ts title="lib/stack.ts" - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createMongodbAdapter } from "@btst/adapter-mongodb" import { MongoClient } from "mongodb" const client = new MongoClient(process.env.MONGODB_URI!) const mongoDb = client.db() - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Your plugins here @@ -142,10 +142,10 @@ When you configure BTST, the `stack()` function collects all plugin database sch ```ts title="lib/stack.ts" // IMPORTANT: Memory adapter is used for development and testing only - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createMemoryAdapter } from "@btst/adapter-memory" - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Your plugins here @@ -168,7 +168,7 @@ The `adapter` function receives the merged database schema (`db`) containing all The adapter pattern follows this flow: -1. `stack()` merges all plugin schemas into a unified `db` object +1. `createBackendStack()` merges all plugin schemas into a unified `db` object 2. Your `adapter` function receives this `db` object 3. The adapter creator function (e.g., `createPrismaAdapter`, `createDrizzleAdapter`) returns an adapter instance 4. This adapter instance implements the common interface (create, update, findOne, etc.) diff --git a/docs/content/docs/how-it-works.mdx b/docs/content/docs/how-it-works.mdx index 42be2dd00..d287cb89d 100644 --- a/docs/content/docs/how-it-works.mdx +++ b/docs/content/docs/how-it-works.mdx @@ -11,7 +11,7 @@ Here's a high-level overview of how BTST works: The server handles database operations, API endpoints, data prefetching, routing, and server-side rendering. -**`stack`** manages the backend layer: +**`createBackendStack`** manages the backend layer: - **API Router**: Routes incoming requests to the appropriate plugin handlers. Returns a handler function that you mount at your API path. - **DB Adapter**: Translates BTST's database operations to your ORM (Prisma, Drizzle, Kysely, MongoDB). Plugins define schemas that get merged and passed to the adapter. @@ -57,14 +57,15 @@ customization. ## Plugins -Plugins are the building blocks of BTST. Each feature (like Blog) ships as **two separate plugins**—one for the backend, one for the client—that you register independently: +Plugins are the building blocks of BTST. Full-stack features such as Blog ship +separate backend and client definitions that you register independently: ```ts // Backend: lib/stack.ts -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { blogBackendPlugin } from "@btst/stack/plugins/blog/api" -const { handler } = stack({ +const { handler } = createBackendStack({ plugins: { blog: blogBackendPlugin({ /* config */ }) }, @@ -87,7 +88,7 @@ const stackClient = createClientStack({ }) ``` -**Backend plugins** (registered in `stack`): +**Backend plugins** (registered in `createBackendStack`): - Define database schemas (tables, columns, relations) - Register API route handlers for CRUD operations - Provide post-authorization domain lifecycle hooks @@ -100,6 +101,10 @@ const stackClient = createClientStack({ This separation keeps server-only code (database schemas, API handlers) out of your client bundle, and allows each plugin to be configured independently for its context. +One-sided plugins are intentional: OpenAPI is backend-only, Route Docs is +client-only, and UI Builder is client-only over the CMS backend/client +contract. Register only the side that exists; do not add a placeholder half. + ## Resolved Client Runtime and Provider Services Configure shared runtime once and pass the resolved result to the provider: diff --git a/docs/content/docs/installation.mdx b/docs/content/docs/installation.mdx index d3407a7ac..383302f4f 100644 --- a/docs/content/docs/installation.mdx +++ b/docs/content/docs/installation.mdx @@ -196,13 +196,13 @@ In order to use BTST, your application must meet the following requirements: ```ts title="lib/stack.ts" - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createPrismaAdapter } from "@btst/adapter-prisma" import { PrismaClient } from "@prisma/client" const prisma = new PrismaClient() - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Add your backend plugins here @@ -218,7 +218,7 @@ In order to use BTST, your application must meet the following requirements: ```ts title="lib/stack.ts" - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createDrizzleAdapter } from "@btst/adapter-drizzle" import { drizzle } from "drizzle-orm/postgres-js" // or "drizzle-orm/mysql2", "drizzle-orm/better-sqlite3", etc. import postgres from "postgres" @@ -226,7 +226,7 @@ In order to use BTST, your application must meet the following requirements: const client = postgres(process.env.DATABASE_URL!) const drizzleDb = drizzle(client) - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Add your backend plugins here @@ -240,7 +240,7 @@ In order to use BTST, your application must meet the following requirements: ```ts title="lib/stack.ts" - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createKyselyAdapter } from "@btst/adapter-kysely" import { Kysely, PostgresDialect } from "kysely" import { Pool } from "pg" @@ -251,7 +251,7 @@ In order to use BTST, your application must meet the following requirements: }) }) - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Add your backend plugins here @@ -265,14 +265,14 @@ In order to use BTST, your application must meet the following requirements: ```ts title="lib/stack.ts" - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createMongodbAdapter } from "@btst/adapter-mongodb" import { MongoClient } from "mongodb" const client = new MongoClient(process.env.MONGODB_URI!) const mongoDb = client.db() - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Add your backend plugins here @@ -288,10 +288,10 @@ In order to use BTST, your application must meet the following requirements: ```ts title="lib/stack.ts" // IMPORTANT: Memory adapter is used for development and testing only - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createMemoryAdapter } from "@btst/adapter-memory" - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Add your backend plugins here @@ -306,7 +306,7 @@ In order to use BTST, your application must meet the following requirements: **What happens here:** - - `stack()` collects all plugin database schemas and merges them into a unified `dbSchema` + - `createBackendStack()` collects all plugin database schemas and merges them into a unified `dbSchema` - The `basePath` determines where your API is mounted (e.g., `/api/data/*`) - The `adapter` function receives this merged schema (`db`) and returns an adapter that translates BTST's database operations to your ORM - The `handler` is a request handler function `(request: Request) => Promise` that processes all API calls @@ -434,7 +434,7 @@ In order to use BTST, your application must meet the following requirements: - Keep the API path in this route, `stack({ basePath })`, and + Keep the API path in this route, `createBackendStack({ basePath })`, and `createClientStack({ api: { basePath } })` identical. `StackProvider` receives that browser-safe projection through its `stack` prop. diff --git a/docs/content/docs/plugins/ai-chat.mdx b/docs/content/docs/plugins/ai-chat.mdx index c2b0e77e2..5f4f8e269 100644 --- a/docs/content/docs/plugins/ai-chat.mdx +++ b/docs/content/docs/plugins/ai-chat.mdx @@ -34,7 +34,7 @@ Follow these steps to add the AI Chat plugin to your BTST setup. Import and register the AI Chat backend plugin in your `stack.ts` file: ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { defineAuthorization } from "@btst/stack/authorization" import { createServerAuth } from "@btst/stack/authorization/server" import { aiChatBackendPlugin } from "@btst/stack/plugins/ai-chat/api" @@ -92,7 +92,7 @@ const serverAuth = createServerAuth({ }, }) -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", auth: serverAuth, plugins: { @@ -481,6 +481,8 @@ Customize post-authorization domain behavior with optional lifecycle hooks. Keep AI Chat lifecycle names use the action-first `onBefore`, `onAfter`, and `onError` grammar. Chat completion remains the domain event `onAfterChat`. +{/* canonical-dx-guard: migration:start reason="plugin lifecycle migration table" */} + | Before RC3 | RC3 | |---|---| | `onBeforeToolsActivated` | `onBeforeActivateTools` | @@ -496,6 +498,8 @@ AI Chat lifecycle names use the action-first `onBefore`, `onAfte | `onUpdateConversationError` | `onErrorUpdateConversation` | | `onDeleteConversationError` | `onErrorDeleteConversation` | +{/* canonical-dx-guard: migration:end */} + **Example usage:** ```ts title="lib/stack.ts" @@ -519,7 +523,7 @@ const chatHooks: AiChatBackendHooks = { }, } -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ plugins: { aiChat: aiChatBackendPlugin({ model: openai("gpt-4o"), @@ -1006,14 +1010,14 @@ For public chatbots without user authentication: ### Backend Setup ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { aiChatBackendPlugin } from "@btst/stack/plugins/ai-chat/api" import { openai } from "@ai-sdk/openai" // Example rate limiter (implement your own) const rateLimiter = new Map() -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { aiChat: aiChatBackendPlugin({ diff --git a/docs/content/docs/plugins/blog.mdx b/docs/content/docs/plugins/blog.mdx index 04bcfbdd0..a76a80ecb 100644 --- a/docs/content/docs/plugins/blog.mdx +++ b/docs/content/docs/plugins/blog.mdx @@ -43,11 +43,11 @@ Follow these steps to add the Blog plugin to your BTST setup. Import and register the blog backend plugin in your `stack.ts` file: ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { blogBackendPlugin } from "@btst/stack/plugins/blog/api" // ... your adapter imports -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { blog: blogBackendPlugin() @@ -419,6 +419,8 @@ platform objects. Blog lifecycle names use the action-first `onBefore`, `onAfter`, and `onError` grammar. +{/* canonical-dx-guard: migration:start reason="plugin lifecycle migration table" */} + | Removed name | Canonical name | | --- | --- | | `onBeforeNextPreviousPosts` | `onBeforeGetNextPreviousPosts` | @@ -433,6 +435,8 @@ Blog lifecycle names use the action-first `onBefore`, | `onUpdatePostError` | `onErrorUpdatePost` | | `onDeletePostError` | `onErrorDeletePost` | +{/* canonical-dx-guard: migration:end */} + **Example usage:** ```ts title="lib/stack.ts" @@ -453,7 +457,7 @@ const blogHooks: BlogBackendHooks = { }, } -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ plugins: { blog: blogBackendPlugin({ hooks: blogHooks }) }, @@ -661,7 +665,7 @@ request-time application behavior. ### Authorized operations -When `stack()` receives a one-rule server adapter, use the request-scoped API +When `createBackendStack()` receives a one-rule server adapter, use the request-scoped API for user-facing Blog work. These calls run the same operations as the HTTP routes, including input validation, trusted fact derivation, authorization, and lifecycle hooks: diff --git a/docs/content/docs/plugins/cms.mdx b/docs/content/docs/plugins/cms.mdx index 9eaf13da2..83e2ac51f 100644 --- a/docs/content/docs/plugins/cms.mdx +++ b/docs/content/docs/plugins/cms.mdx @@ -123,11 +123,11 @@ export type CMSTypes = { Register the CMS backend plugin with your content types: ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { cmsBackendPlugin } from "@btst/stack/plugins/cms/api" import { contentTypes } from "./cms-schemas" -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { cms: cmsBackendPlugin({ contentTypes }) @@ -571,6 +571,8 @@ CMS lifecycle names use the action-first `onBefore`, aggregate error phase remains one callback; its `operation` argument identifies the failed create, update, delete, list, or get operation. +{/* canonical-dx-guard: migration:start reason="plugin lifecycle migration table" */} + | Removed name | Canonical name | | --- | --- | | `onBeforeCreate` | `onBeforeCreateContent` | @@ -581,6 +583,8 @@ the failed create, update, delete, list, or get operation. | `onAfterDelete` | `onAfterDeleteContent` | | `onError` | `onErrorExecuteContentOperation` | +{/* canonical-dx-guard: migration:end */} + ## Type Safety The CMS plugin provides **end-to-end type safety** from schema definition to frontend rendering: @@ -720,7 +724,7 @@ const auth = createServerAuth({ }, }) -export const myStack = stack({ +export const myStack = createBackendStack({ basePath: "/api/data", plugins: { cms: cmsBackendPlugin({ contentTypes }) }, adapter: (db) => createMemoryAdapter(db)({}), diff --git a/docs/content/docs/plugins/comments.mdx b/docs/content/docs/plugins/comments.mdx index 498f1168a..dd5e2d096 100644 --- a/docs/content/docs/plugins/comments.mdx +++ b/docs/content/docs/plugins/comments.mdx @@ -30,11 +30,11 @@ your `stack.ts` file. The adapter can wrap any session/authentication library; Comments has no dependency on it. ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { commentsBackendPlugin } from "@btst/stack/plugins/comments/api" import { serverAuth } from "./authorization.server" -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", auth: serverAuth, plugins: { @@ -252,7 +252,7 @@ and resource facts their rule authorized, so overlapping changes cannot bypass the rule. Once authorization is installed, a missing Comments rule denies that action. -Omitting `stack({ auth })` preserves permissive server behavior for applications +Omitting `createBackendStack({ auth })` preserves permissive server behavior for applications that do not configure authorization. Use `createServerAuth()` to enable exact descriptor enforcement and default-deny missing rules. Client gates use the matching `createClientAuth()` adapter and exact descriptors shown above. @@ -399,6 +399,8 @@ Comments lifecycle names use the action-first `onBefore` and `onAfter` grammar. Approval remains a distinct moderation event; it is not flattened into a generic update callback. +{/* canonical-dx-guard: migration:start reason="plugin lifecycle migration table" */} + | Removed name | Canonical name | | --- | --- | | `onBeforeList` | `onBeforeListComments` | @@ -414,6 +416,8 @@ it is not flattened into a generic update callback. | `onBeforeDelete` | `onBeforeDeleteComment` | | `onAfterDelete` | `onAfterDeleteComment` | +{/* canonical-dx-guard: migration:end */} + All lifecycle hooks receive validated input, server-derived facts, resolved identity, and request in a deeply readonly context. Authorization runs first; diff --git a/docs/content/docs/plugins/development.mdx b/docs/content/docs/plugins/development.mdx index 09893fde9..001479c7a 100644 --- a/docs/content/docs/plugins/development.mdx +++ b/docs/content/docs/plugins/development.mdx @@ -7,11 +7,16 @@ Learn how to create custom plugins for BTST. Plugins extend your application wit ## Overview -A BTST plugin consists of two parts: +A BTST plugin may expose either or both of these independent parts: - **Backend Plugin** - Defines database schema and API endpoints - **Client Plugin** - Provides routes, React components, and hooks +Do not invent a matching half solely for symmetry. OpenAPI is the reference +backend-only plugin, Route Docs is client-only, and UI Builder is client-only +over CMS. Programmatic IDs use camelCase even when package and URL slugs use +kebab-case. + You can create plugins **inside your project** (like the [Todo example](#in-project-plugin-example)) or as a **standalone package** to publish on npm using the [Plugin Starter repository](https://github.com/better-stack-ai/plugin-starter). ## Core Concepts @@ -31,7 +36,7 @@ your-plugin/ │ ├── client.tsx # Client plugin with routes │ ├── hooks.tsx # React Query hooks │ ├── components.tsx # Page components -│ └── overrides.ts # Framework adapter types +│ └── overrides.ts # Plugin-specific browser customization ├── schema.ts # Database schema definition └── types.ts # Shared TypeScript types ``` @@ -253,7 +258,7 @@ it skips only user authorization. Once a plugin declares operations, every composed HTTP route must have a same-key operation and use `operations.operationKey.route(ctx => input)` as its endpoint handler. The generated handler owns execution of that exact -request-bound operation and carries a private transport identity; `stack()` +request-bound operation and carries a private transport identity; `createBackendStack()` validates both the inventory and exact binding during composition. It reports the plugin key, route key, method, and path for an undeclared or unbound route. This keeps a new route from silently bypassing the operation pipeline. Extra @@ -299,7 +304,7 @@ rationale, or route declared as both operation-backed and infrastructure fails composition. Public infrastructure still runs the handler's validation and security/domain checks; the declaration does not turn other routes public. -Register the same catalog in the application's server authorization. `stack()` +Register the same catalog in the application's server authorization. `createBackendStack()` rejects catalog mismatches at typecheck time. Call the composed operation—not the descriptor—from application code: @@ -963,7 +968,7 @@ This pattern ensures: ### Backend Registration ```ts -export const myStack = stack({ +export const myStack = createBackendStack({ basePath: "/api/data", plugins: { todos: todosBackendPlugin(), blog: blogBackendPlugin() }, adapter: (db) => createMemoryAdapter(db)({}), diff --git a/docs/content/docs/plugins/form-builder.mdx b/docs/content/docs/plugins/form-builder.mdx index be6c329fa..8425aee33 100644 --- a/docs/content/docs/plugins/form-builder.mdx +++ b/docs/content/docs/plugins/form-builder.mdx @@ -49,14 +49,14 @@ Ensure you followed the general [framework installation guide](/installation) fi Register the Form Builder backend plugin: ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { createPrismaAdapter } from "@btst/adapter-prisma" import { formBuilderBackendPlugin } from "@btst/stack/plugins/form-builder/api" import { PrismaClient } from "@prisma/client" const prisma = new PrismaClient() -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { formBuilder: formBuilderBackendPlugin({ @@ -438,7 +438,7 @@ export const serverAuth = createServerAuth({ }) ``` -Pass `serverAuth` as `stack({ auth: serverAuth, ... })`. On the client, create a +Pass `serverAuth` as `createBackendStack({ auth: serverAuth, ... })`. On the client, create a client binding from the same rule set and hydrate the request identity in the layout: diff --git a/docs/content/docs/plugins/kanban.mdx b/docs/content/docs/plugins/kanban.mdx index cef7a8adb..c8a29d271 100644 --- a/docs/content/docs/plugins/kanban.mdx +++ b/docs/content/docs/plugins/kanban.mdx @@ -41,11 +41,11 @@ Follow these steps to add the Kanban plugin to your BTST setup. Import and register the kanban backend plugin in your `stack.ts` file: ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { kanbanBackendPlugin } from "@btst/stack/plugins/kanban/api" // ... your adapter imports -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { kanban: kanbanBackendPlugin() @@ -59,7 +59,7 @@ export { handler, dbSchema } ``` The `kanbanBackendPlugin()` accepts optional lifecycle hooks for domain -validation and integrations. Configure authorization once on `stack({ auth })`; +validation and integrations. Configure authorization once on `createBackendStack({ auth })`; ordinary role and ownership rules do not belong in hooks. ### 2. Add Plugin to Client @@ -400,7 +400,7 @@ export const serverAuth = createServerAuth({ }) ``` -Pass `serverAuth` to `stack({ auth: serverAuth, ... })`. Create the client +Pass `serverAuth` to `createBackendStack({ auth: serverAuth, ... })`. Create the client binding from the same browser-safe definition. Resolve the request identity and trusted client origins in the request layout, then serialize only those plain values to the client provider. Request headers and the resolved request stack @@ -641,7 +641,7 @@ same operations. ```ts import { kanbanBackendPlugin } from "@btst/stack/plugins/kanban/api" -const app = stack({ +const app = createBackendStack({ auth: serverAuth, plugins: { kanban: kanbanBackendPlugin({ hooks }) diff --git a/docs/content/docs/plugins/media.mdx b/docs/content/docs/plugins/media.mdx index 6023609f1..23b507b7d 100644 --- a/docs/content/docs/plugins/media.mdx +++ b/docs/content/docs/plugins/media.mdx @@ -35,11 +35,11 @@ Follow these steps to add the Media plugin to your BTST setup. Import and register the media backend plugin in your `stack.ts` file: ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { mediaBackendPlugin } from "@btst/stack/plugins/media/api" import { localAdapter } from "@btst/stack/plugins/media/api/adapters/local" -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { media: mediaBackendPlugin({ @@ -416,7 +416,7 @@ const serverAuth = createServerAuth({ }, }) -export const appStack = stack({ +export const appStack = createBackendStack({ auth: serverAuth, plugins: { media: mediaBackendPlugin({ @@ -545,12 +545,12 @@ use the raw migration helpers for an intentional cross-tenant move. ### Configuration ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { mediaBackendPlugin } from "@btst/stack/plugins/media/api" import { s3Adapter } from "@btst/stack/plugins/media/api/adapters/s3" import { getSession } from "@/lib/auth" -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { media: mediaBackendPlugin({ @@ -582,13 +582,13 @@ an adapter-level concern, so use the retained standalone primitives instead of the removed raw business API: ```ts -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { createFolder, getFolderByName, } from "@btst/stack/plugins/media/api" -const app = stack({ /* ... */ }) +const app = createBackendStack({ /* ... */ }) async function getOrCreateProfileFolder(profileId: string) { const name = `blog-gen-${profileId}` diff --git a/docs/content/docs/plugins/open-api.mdx b/docs/content/docs/plugins/open-api.mdx index 6805cd040..d9054f2cb 100644 --- a/docs/content/docs/plugins/open-api.mdx +++ b/docs/content/docs/plugins/open-api.mdx @@ -37,12 +37,12 @@ Ensure you followed the general [framework installation guide](/installation) fi Import and register the OpenAPI backend plugin in your `stack.ts` file: ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { blogBackendPlugin } from "@btst/stack/plugins/blog/api" import { openApiBackendPlugin } from "@btst/stack/plugins/open-api/api" // ... your adapter imports -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { blog: blogBackendPlugin(), @@ -238,7 +238,7 @@ openApiBackendPlugin({ ### Schema shows empty or incomplete endpoints -The OpenAPI plugin reads the routes composed by `stack()`, so plugin registration +The OpenAPI plugin reads the routes composed by `createBackendStack()`, so plugin registration order does not affect completeness or generated ordering. A migrated business route without a same-key operation fails stack composition with its plugin, route, method, and path instead of appearing as an undeclared endpoint. diff --git a/docs/content/docs/plugins/ui-builder.mdx b/docs/content/docs/plugins/ui-builder.mdx index 5a730af7c..c936f27e5 100644 --- a/docs/content/docs/plugins/ui-builder.mdx +++ b/docs/content/docs/plugins/ui-builder.mdx @@ -50,7 +50,7 @@ Ensure you followed the general [framework installation guide](/installation) fi The UI Builder stores pages as CMS content items. Add the pre-configured content type to your CMS: ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { createServerAuth } from "@btst/stack/authorization/server" import { cmsBackendPlugin } from "@btst/stack/plugins/cms/api" import { @@ -67,7 +67,7 @@ const auth = createServerAuth({ }, }) -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { cms: cmsBackendPlugin({ @@ -228,6 +228,10 @@ uiBuilderClientPlugin({ }) ``` +UI Builder is intentionally client-only over CMS. The `cmsBackendPlugin` +supplies its persistence and HTTP contract; there is no UI Builder backend +plugin to register. + ## Component Registry The UI Builder uses a component registry to define available components. A default registry is provided with common components: diff --git a/e2e/tests/helpers/mock-auth.ts b/e2e/tests/helpers/mock-auth.ts index 052c4abb7..9ca0a5b6f 100644 --- a/e2e/tests/helpers/mock-auth.ts +++ b/e2e/tests/helpers/mock-auth.ts @@ -3,7 +3,7 @@ import type { BrowserContext } from "@playwright/test"; /** Headers for direct requests to the generated apps' request-aware backend. */ export function mockAuthHeaders(userId = "admin-e2e") { return { - cookie: `better-auth.session_token=mock-session-${userId}`, + cookie: `btst.example_session=mock-session-${userId}`, }; } @@ -14,7 +14,7 @@ export async function setMockAuthCookie( ) { await context.addCookies([ { - name: "better-auth.session_token", + name: "btst.example_session", value: `mock-session-${userId}`, domain: "localhost", path: "/", diff --git a/e2e/tests/smoke.auth-blog.spec.ts b/e2e/tests/smoke.auth-blog.spec.ts index dc567ef72..ef7beb288 100644 --- a/e2e/tests/smoke.auth-blog.spec.ts +++ b/e2e/tests/smoke.auth-blog.spec.ts @@ -14,7 +14,7 @@ import { expect, test, type BrowserContext } from "@playwright/test"; */ // Mock auth cookie name (replace with your actual auth cookie name) -const AUTH_COOKIE_NAME = "better-auth.session_token"; +const AUTH_COOKIE_NAME = "btst.example_session"; // API base path for authenticated endpoints // Note: Blog plugin defines routes at /posts, /tags, etc. diff --git a/package.json b/package.json index 2edf729f5..caee99fad 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "dev:dts": "turbo --filter \"./packages/*\" dev:dts", "clean": "turbo --filter \"./packages/*\" clean && rm -rf node_modules", "format": "biome format . --write", - "lint": "biome check .", + "lint": "node scripts/check-canonical-dx.mjs && biome check .", + "check:canonical-dx": "node scripts/check-canonical-dx.mjs", "lint:fix": "biome check . --fix --unsafe", "bump": "bumpp", "test": "turbo --filter \"./packages/*\" test", diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs new file mode 100644 index 000000000..063c6014c --- /dev/null +++ b/scripts/check-canonical-dx.mjs @@ -0,0 +1,419 @@ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, extname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const textExtensions = new Set([ + ".hbs", + ".js", + ".json", + ".jsx", + ".md", + ".mdx", + ".mjs", + ".ts", + ".tsx", +]); + +const guidanceTargets = [ + "README.md", + "CONTRIBUTING.md", + "docs/content/docs", + ".agents/skills", +]; +const generatedTargets = [ + "scripts/codegen/README.md", + "scripts/codegen/files", + "packages/cli/src/templates", + "packages/cli/scripts/fixtures/third-party-plugin.tsx", + "packages/stack/scripts/fixtures/registry/README.md", + "packages/stack/registry", + "playground/src", +]; + +const backendFactories = [ + "aiChatBackendPlugin", + "blogBackendPlugin", + "cmsBackendPlugin", + "commentsBackendPlugin", + "formBuilderBackendPlugin", + "kanbanBackendPlugin", + "mediaBackendPlugin", + "openApiBackendPlugin", +]; +const clientFactories = [ + "aiChatClientPlugin", + "blogClientPlugin", + "cmsClientPlugin", + "commentsClientPlugin", + "formBuilderClientPlugin", + "kanbanClientPlugin", + "mediaClientPlugin", + "routeDocsClientPlugin", + "uiBuilderClientPlugin", +]; +const pluginIds = [ + "aiChat", + "blog", + "cms", + "comments", + "formBuilder", + "kanban", + "media", + "openApi", + "routeDocs", + "uiBuilder", +]; +const removedPluginIds = [ + "ai-chat", + "form-builder", + "open-api", + "route-docs", + "ui-builder", +]; + +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +const pluginIdPattern = pluginIds.map(escapeRegExp).join("|"); +const removedPluginIdPattern = removedPluginIds.map(escapeRegExp).join("|"); + +function collectFiles(target) { + const absolute = join(root, target); + if (!statSync(absolute).isDirectory()) return [absolute]; + const files = []; + for (const entry of readdirSync(absolute, { withFileTypes: true })) { + if (entry.name === "node_modules") continue; + const child = join(absolute, entry.name); + if (entry.isDirectory()) { + files.push(...collectFiles(relative(root, child))); + } else if (textExtensions.has(extname(entry.name))) { + files.push(child); + } + } + return files; +} + +function stripMigrationBlocks(source, file) { + const startPattern = + /^\s*\{\/\* canonical-dx-guard: migration:start reason="[^"]+" \*\/\}\s*$/; + const endPattern = /^\s*\{\/\* canonical-dx-guard: migration:end \*\/\}\s*$/; + let insideMigration = false; + return source + .split("\n") + .map((line, index) => { + if (startPattern.test(line)) { + if (insideMigration) { + throw new Error( + `${file}:${index + 1}: nested canonical DX migration marker`, + ); + } + insideMigration = true; + return ""; + } + if (endPattern.test(line)) { + if (!insideMigration) { + throw new Error( + `${file}:${index + 1}: unmatched canonical DX migration marker`, + ); + } + insideMigration = false; + return ""; + } + return insideMigration ? "" : line; + }) + .join("\n"); +} + +function lineAt(source, index) { + return source.slice(0, index).split("\n").length; +} + +function recordMatches(failures, file, source, label, pattern) { + for (const match of source.matchAll(pattern)) { + failures.push({ + file, + line: lineAt(source, match.index ?? 0), + label, + match: match[0].replace(/\s+/g, " ").slice(0, 100), + }); + } +} + +function readTopLevelObject(source, openIndex) { + let depth = 0; + let quote; + let escaped = false; + let lineComment = false; + let blockComment = false; + let topLevel = ""; + + for (let index = openIndex; index < source.length; index += 1) { + const char = source[index]; + const next = source[index + 1]; + + if (lineComment) { + if (char === "\n") lineComment = false; + if (depth <= 1) topLevel += char; + continue; + } + if (blockComment) { + if (char === "*" && next === "/") { + blockComment = false; + index += 1; + } + if (depth <= 1) topLevel += char === "\n" ? "\n" : " "; + continue; + } + if (quote) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === quote) quote = undefined; + if (depth <= 1) topLevel += char; + else if (char === "\n") topLevel += "\n"; + continue; + } + if (char === "/" && next === "/") { + lineComment = true; + if (depth <= 1) topLevel += " "; + index += 1; + continue; + } + if (char === "/" && next === "*") { + blockComment = true; + if (depth <= 1) topLevel += " "; + index += 1; + continue; + } + if (char === '"' || char === "'" || char === "`") { + quote = char; + if (depth <= 1) topLevel += char; + continue; + } + if (char === "{") { + depth += 1; + topLevel += depth <= 1 ? char : " "; + continue; + } + if (char === "}") { + depth -= 1; + topLevel += depth <= 1 ? char : " "; + if (depth === 0) return { end: index, topLevel }; + continue; + } + topLevel += depth <= 1 || char === "\n" ? char : " "; + } + + return undefined; +} + +function checkFactoryCalls(failures, file, source, factory, kind) { + const callPattern = new RegExp(`\\b${factory}\\s*\\(`, "g"); + for (const match of source.matchAll(callPattern)) { + let cursor = (match.index ?? 0) + match[0].length; + while (/\s/.test(source[cursor] ?? "")) cursor += 1; + if (source[cursor] === ")") continue; + if (source[cursor] !== "{") { + const positionalArgument = source + .slice(cursor) + .match(/^[A-Za-z_$][\w$]*\s*(?=[,)])/); + if (kind === "client" || !positionalArgument) continue; + failures.push({ + file, + line: lineAt(source, match.index ?? 0), + label: `${kind} factory must receive one options object`, + match: factory, + }); + continue; + } + const object = readTopLevelObject(source, cursor); + if (!object) continue; + if ( + kind === "backend" && + /\bon(?:Before|After|Error)[A-Z][A-Za-z0-9]*\s*:/.test(object.topLevel) + ) { + failures.push({ + file, + line: lineAt(source, match.index ?? 0), + label: "backend lifecycle callbacks must be nested under hooks", + match: factory, + }); + } + if ( + kind === "client" && + /\b(?:apiBaseURL|apiBasePath|siteBaseURL|siteBasePath|queryClient|headers)\s*:/.test( + object.topLevel, + ) + ) { + failures.push({ + file, + line: lineAt(source, match.index ?? 0), + label: "client plugin duplicates stack-owned runtime", + match: factory, + }); + } + } +} + +function lifecycleInventory() { + const inventories = [ + "ai-chat", + "blog", + "cms", + "comments", + "form-builder", + "kanban", + "media", + ]; + const names = new Set(); + for (const plugin of inventories) { + const source = readFileSync( + join( + root, + `packages/stack/src/plugins/${plugin}/api/lifecycle-migrations.ts`, + ), + "utf8", + ); + const object = source.match( + /Object\.freeze\(\{([\s\S]*?)\}\s+as const\)/, + )?.[1]; + if (!object) + throw new Error(`Unable to read ${plugin} lifecycle inventory`); + for (const match of object.matchAll(/^\s*([A-Za-z0-9]+):/gm)) { + names.add(match[1]); + } + } + return [...names].filter( + (name) => + ![ + "onBeforeCreate", + "onAfterCreate", + "onBeforeUpdate", + "onAfterUpdate", + "onBeforeDelete", + "onAfterDelete", + "onError", + ].includes(name), + ); +} + +const guidanceFiles = guidanceTargets.flatMap(collectFiles); +const generatedFiles = generatedTargets.flatMap(collectFiles); +const allFiles = [...new Set([...guidanceFiles, ...generatedFiles])]; +const generatedSet = new Set(generatedFiles); +const removedLifecycleNames = lifecycleInventory(); +const failures = []; + +for (const absolute of allFiles) { + const file = relative(root, absolute); + const source = stripMigrationBlocks(readFileSync(absolute, "utf8"), file); + + recordMatches( + failures, + file, + source, + "removed constructor", + /\bcreateStackClient\b|\bstack\s*\(\s*\{/g, + ); + recordMatches( + failures, + file, + source, + "manual StackProvider generic", + /\bStackProvider[ \t]*).){0,2000}\b(?:api|basePath)=/gs, + ); + recordMatches( + failures, + file, + source, + "removed programmatic plugin ID", + new RegExp( + `["'](?:${removedPluginIdPattern})["']\\s*:\\s*\\w+(?:Backend|Client)Plugin`, + "g", + ), + ); + recordMatches( + failures, + file, + source, + "removed plugin registration or override key", + new RegExp( + `\\b(?:plugins|overrides)\\s*(?:=|:)\\s*\\{[\\s\\S]{0,3000}?["'](?:${removedPluginIdPattern})["']\\s*:|\\bplugin\\s*:\\s*["'](?:${removedPluginIdPattern})["']`, + "g", + ), + ); + recordMatches( + failures, + file, + source, + "removed intrinsic plugin ID", + new RegExp(`\\bid\\s*:\\s*["'](?:${removedPluginIdPattern})["']`, "g"), + ); + recordMatches( + failures, + file, + source, + "ambiguous server namespace", + new RegExp( + `\\.(?:api|internal)\\.(?:${pluginIdPattern})\\b|\\.forRequest\\([^)]*\\)\\.api\\b`, + "g", + ), + ); + + for (const factory of backendFactories) { + checkFactoryCalls(failures, file, source, factory, "backend"); + } + for (const factory of clientFactories) { + checkFactoryCalls(failures, file, source, factory, "client"); + } + for (const name of removedLifecycleNames) { + recordMatches( + failures, + file, + source, + "removed lifecycle callback", + new RegExp(`\\b${escapeRegExp(name)}\\b`, "g"), + ); + } + + if (generatedSet.has(absolute)) { + recordMatches( + failures, + file, + source, + "provider-specific identity bridge in generated source", + /better-auth\.session_token|@btst\/better-auth-ui|\bcreateBetterAuth\w*/g, + ); + } +} + +if (failures.length > 0) { + console.error( + "Canonical DX guard found legacy maintained guidance or sources:", + ); + for (const failure of failures) { + console.error( + `- ${failure.file}:${failure.line} ${failure.label}: ${failure.match}`, + ); + } + process.exitCode = 1; +} else { + console.log( + `Canonical DX guard passed (${allFiles.length} maintained guidance/generated files).`, + ); +} diff --git a/scripts/codegen/files/nextjs/lib/authorization.server.ts b/scripts/codegen/files/nextjs/lib/authorization.server.ts index 3d89cd09e..3559693d4 100644 --- a/scripts/codegen/files/nextjs/lib/authorization.server.ts +++ b/scripts/codegen/files/nextjs/lib/authorization.server.ts @@ -7,8 +7,8 @@ function getMockRequestIdentity(headers: Headers) { const token = (headers.get("cookie") ?? "") .split(";") .map((part) => part.trim()) - .find((part) => part.startsWith("better-auth.session_token=")) - ?.slice("better-auth.session_token=".length); + .find((part) => part.startsWith("btst.example_session=")) + ?.slice("btst.example_session=".length); if (!token?.startsWith("mock-session-")) return null; const id = token.slice("mock-session-".length); return { diff --git a/scripts/codegen/files/nextjs/lib/stack-auth.ts b/scripts/codegen/files/nextjs/lib/stack-auth.ts index c95a2d591..63c7ef37d 100644 --- a/scripts/codegen/files/nextjs/lib/stack-auth.ts +++ b/scripts/codegen/files/nextjs/lib/stack-auth.ts @@ -16,8 +16,8 @@ const exampleServerAuth = createServerAuth({ const token = cookie .split(";") .map((part) => part.trim()) - .find((part) => part.startsWith("better-auth.session_token=")) - ?.slice("better-auth.session_token=".length); + .find((part) => part.startsWith("btst.example_session=")) + ?.slice("btst.example_session=".length); if (!token?.startsWith("mock-session-")) return null; const id = token.slice("mock-session-".length); return { diff --git a/scripts/codegen/files/react-router/app/lib/authorization.server.ts b/scripts/codegen/files/react-router/app/lib/authorization.server.ts index b184bb59d..3cd0bd545 100644 --- a/scripts/codegen/files/react-router/app/lib/authorization.server.ts +++ b/scripts/codegen/files/react-router/app/lib/authorization.server.ts @@ -5,8 +5,8 @@ function getMockRequestIdentity(headers: Headers) { const token = (headers.get("cookie") ?? "") .split(";") .map((part) => part.trim()) - .find((part) => part.startsWith("better-auth.session_token=")) - ?.slice("better-auth.session_token=".length); + .find((part) => part.startsWith("btst.example_session=")) + ?.slice("btst.example_session=".length); if (!token?.startsWith("mock-session-")) return null; const id = token.slice("mock-session-".length); return { diff --git a/scripts/codegen/files/react-router/app/lib/stack-auth.ts b/scripts/codegen/files/react-router/app/lib/stack-auth.ts index a940573e5..11e2c8c70 100644 --- a/scripts/codegen/files/react-router/app/lib/stack-auth.ts +++ b/scripts/codegen/files/react-router/app/lib/stack-auth.ts @@ -13,8 +13,8 @@ const exampleServerAuth = createServerAuth({ const token = (headers.get("cookie") ?? "") .split(";") .map((part) => part.trim()) - .find((part) => part.startsWith("better-auth.session_token=")) - ?.slice("better-auth.session_token=".length); + .find((part) => part.startsWith("btst.example_session=")) + ?.slice("btst.example_session=".length); if (!token?.startsWith("mock-session-")) return null; const id = token.slice("mock-session-".length); return { diff --git a/scripts/codegen/files/tanstack/src/lib/authorization.server.ts b/scripts/codegen/files/tanstack/src/lib/authorization.server.ts index b184bb59d..3cd0bd545 100644 --- a/scripts/codegen/files/tanstack/src/lib/authorization.server.ts +++ b/scripts/codegen/files/tanstack/src/lib/authorization.server.ts @@ -5,8 +5,8 @@ function getMockRequestIdentity(headers: Headers) { const token = (headers.get("cookie") ?? "") .split(";") .map((part) => part.trim()) - .find((part) => part.startsWith("better-auth.session_token=")) - ?.slice("better-auth.session_token=".length); + .find((part) => part.startsWith("btst.example_session=")) + ?.slice("btst.example_session=".length); if (!token?.startsWith("mock-session-")) return null; const id = token.slice("mock-session-".length); return { diff --git a/scripts/codegen/files/tanstack/src/lib/stack-auth.ts b/scripts/codegen/files/tanstack/src/lib/stack-auth.ts index a940573e5..11e2c8c70 100644 --- a/scripts/codegen/files/tanstack/src/lib/stack-auth.ts +++ b/scripts/codegen/files/tanstack/src/lib/stack-auth.ts @@ -13,8 +13,8 @@ const exampleServerAuth = createServerAuth({ const token = (headers.get("cookie") ?? "") .split(";") .map((part) => part.trim()) - .find((part) => part.startsWith("better-auth.session_token=")) - ?.slice("better-auth.session_token=".length); + .find((part) => part.startsWith("btst.example_session=")) + ?.slice("btst.example_session=".length); if (!token?.startsWith("mock-session-")) return null; const id = token.slice("mock-session-".length); return { From dbea24461300fd9004ac5fe428f41f3dc95a1165 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:11:14 -0400 Subject: [PATCH 02/24] test(v3): validate migration guard markers (#224) --- scripts/check-canonical-dx.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs index 063c6014c..1953d19cd 100644 --- a/scripts/check-canonical-dx.mjs +++ b/scripts/check-canonical-dx.mjs @@ -97,7 +97,7 @@ function stripMigrationBlocks(source, file) { /^\s*\{\/\* canonical-dx-guard: migration:start reason="[^"]+" \*\/\}\s*$/; const endPattern = /^\s*\{\/\* canonical-dx-guard: migration:end \*\/\}\s*$/; let insideMigration = false; - return source + const stripped = source .split("\n") .map((line, index) => { if (startPattern.test(line)) { @@ -121,6 +121,10 @@ function stripMigrationBlocks(source, file) { return insideMigration ? "" : line; }) .join("\n"); + if (insideMigration) { + throw new Error(`${file}: unclosed canonical DX migration marker`); + } + return stripped; } function lineAt(source, index) { From 76a2f4af8cd865f3ee2712ee3c5e7c387340cfc8 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:17:03 -0400 Subject: [PATCH 03/24] docs(v3): address canonical DX review findings (#224) --- .../btst-backend-plugin-dev/REFERENCE.md | 9 +- CONTRIBUTING.md | 30 ++--- docs/content/docs/breaking-changes.mdx | 29 ++++- docs/content/docs/plugins/development.mdx | 4 +- scripts/check-canonical-dx.mjs | 118 +++++++++++++----- 5 files changed, 130 insertions(+), 60 deletions(-) diff --git a/.agents/skills/btst-backend-plugin-dev/REFERENCE.md b/.agents/skills/btst-backend-plugin-dev/REFERENCE.md index 1a9dfca3d..970538eda 100644 --- a/.agents/skills/btst-backend-plugin-dev/REFERENCE.md +++ b/.agents/skills/btst-backend-plugin-dev/REFERENCE.md @@ -15,13 +15,14 @@ export const myBackendPlugin = (options: MyBackendPluginOptions = {}) => raw: (adapter) => ({ prefetchForRoute: createItemPrefetchForRoute(adapter), }), - routes: (_adapter, _context, operations) => ({ - createItem: createEndpoint( + routes: (_adapter, _context, operations) => { + const createItem = createEndpoint( "/items", { method: "POST", body: CreateItemSchema, requireRequest: true }, operations.createItem.route((ctx) => ctx.body), - ), - }), + ) + return { createItem } as const + }, }) export type MyApiRouter = ReturnType< diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c673435f8..efcbe05f3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -128,13 +128,14 @@ export const myBackendPlugin = (options: MyBackendPluginOptions = {}) => id: "myPlugin", // camelCase programmatic ID; package and URL slugs may stay kebab-case dbPlugin: mySchema, operations: (adapter) => createMyOperations(adapter, options.hooks), - routes: (_adapter, _context, operations) => ({ - listItems: createEndpoint( + routes: (_adapter, _context, operations) => { + const listItems = createEndpoint( "/items", { method: "GET", requireRequest: true }, operations.listItems.route(() => ({})), - ), - }), + ) + return { listItems } as const + }, }) // Export the inferred router type — the client plugin imports this for end-to-end type safety @@ -366,28 +367,29 @@ export const myBackendPlugin = (options: MyBackendPluginOptions = {}) => id: "yourPlugin", dbPlugin: mySchema, operations: (adapter) => createMyOperations(adapter, options.hooks), - routes: (_adapter, _context, operations) => ({ - listItemsEndpoint: createEndpoint( + routes: (_adapter, _context, operations) => { + const listItems = createEndpoint( "/items", { method: "GET", requireRequest: true }, operations.listItems.route(() => ({})), - ), - createItemEndpoint: createEndpoint( + ) + const createItem = createEndpoint( "/items", { method: "POST", body: createItemSchema, requireRequest: true }, operations.createItem.route((ctx) => ctx.body), - ), - updateItemEndpoint: createEndpoint( + ) + const updateItem = createEndpoint( "/items/:id", { method: "PUT", body: updateItemSchema, requireRequest: true }, operations.updateItem.route((ctx) => ({ id: ctx.params.id, data: ctx.body })), - ), - deleteItemEndpoint: createEndpoint( + ) + const deleteItem = createEndpoint( "/items/:id", { method: "DELETE", requireRequest: true }, operations.deleteItem.route((ctx) => ({ id: ctx.params.id })), - ), - }), + ) + return { listItems, createItem, updateItem, deleteItem } as const + }, }) export type MyApiRouter = ReturnType["routes"]> diff --git a/docs/content/docs/breaking-changes.mdx b/docs/content/docs/breaking-changes.mdx index 1531e4caa..eef44518a 100644 --- a/docs/content/docs/breaking-changes.mdx +++ b/docs/content/docs/breaking-changes.mdx @@ -211,15 +211,24 @@ commentsBackendPlugin({ resolveUser, hooks: { onAfterCreateComment }, }) -kanbanBackendPlugin({ resolveUser, searchUsers, hooks: kanbanHooks }) +kanbanBackendPlugin({ hooks: kanbanHooks }) + + + {children} + ``` Every backend plugin is a factory receiving at most one options object. -Optional-only factories allow `plugin()`. Required domain configuration stays -required and adjacent to `hooks`: AI Chat keeps its model, tools, and access -mode; CMS keeps content types; Comments keeps behavior and user resolution; -Kanban keeps user resolution and search; Media keeps storage, tenant, and -upload configuration; OpenAPI keeps its presentation/schema options. +Optional-only factories allow `plugin()`. Required plugin configuration stays +required on its owning surface: AI Chat backend options keep its model, tools, +and access mode; CMS keeps content types; Comments keeps behavior and user +resolution; Media keeps storage, tenant, and upload configuration; OpenAPI +keeps its presentation/schema options. Kanban user resolution and search are +browser services and move to the inferred `StackProvider` override shown above. OpenAPI is intentionally backend-only. Route Docs is intentionally client-only. UI Builder is intentionally client-only and composes over the registered CMS @@ -730,6 +739,8 @@ BTST v2 introduces a rebranding from "Better Stack" to "BTST". This guide covers ### Summary of Changes +{/* canonical-dx-guard: migration:start reason="historical v2 constructor summary" */} + | v1 (Better Stack) | v2 (BTST) | |-------------------|-----------| | `betterStack()` | `stack()` | @@ -741,6 +752,8 @@ BTST v2 introduces a rebranding from "Better Stack" to "BTST". This guide covers | `better-stack-client.tsx` | `stack-client.tsx` | | `"Powered by Better Stack"` | `"Powered by BTST"` | +{/* canonical-dx-guard: migration:end */} + ### Migration Steps @@ -882,6 +895,8 @@ mv lib/better-stack-auth.ts lib/stack-auth.ts You can use these find-and-replace patterns to quickly update your codebase: +{/* canonical-dx-guard: migration:start reason="historical v2 constructor replacement" */} + | Find | Replace | |------|---------| | `betterStack(` | `stack(` | @@ -895,6 +910,8 @@ You can use these find-and-replace patterns to quickly update your codebase: | `from "./better-stack"` | `from "./stack"` | | `from "./better-stack-client"` | `from "./stack-client"` | +{/* canonical-dx-guard: migration:end */} + ### TypeScript Types The following types have been renamed: diff --git a/docs/content/docs/plugins/development.mdx b/docs/content/docs/plugins/development.mdx index 001479c7a..f15411a7b 100644 --- a/docs/content/docs/plugins/development.mdx +++ b/docs/content/docs/plugins/development.mdx @@ -403,9 +403,9 @@ export const todosBackendPlugin = () => defineBackendPlugin({ }) ``` -Use `app.forRequest(request).operations.todos.*` for request-driven server work and `app.trusted.todos.*` for explicitly trusted jobs. Trusted calls skip user authorization but retain validation, fact derivation, domain behavior, and lifecycle hooks. First-party `stack().raw` namespaces are narrow SSG prefetch surfaces only. +Use `app.forRequest(request).operations.todos.*` for request-driven server work and `app.trusted.todos.*` for explicitly trusted jobs. Trusted calls skip user authorization but retain validation, fact derivation, domain behavior, and lifecycle hooks. First-party backend stack `raw` namespaces are narrow SSG prefetch surfaces only. -Pure getters and mutations may remain exported lower-level adapter primitives for plugin internals and migrations. They do not promise authorization, validation, or lifecycle composition and should not be duplicated onto `stack().raw`. +Pure getters and mutations may remain exported lower-level adapter primitives for plugin internals and migrations. They do not promise authorization, validation, or lifecycle composition and should not be duplicated onto the backend stack's `raw` surface. ## Client Plugin diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs index 1953d19cd..1a7db1827 100644 --- a/scripts/check-canonical-dx.mjs +++ b/scripts/check-canonical-dx.mjs @@ -25,12 +25,21 @@ const generatedTargets = [ "scripts/codegen/README.md", "scripts/codegen/files", "packages/cli/src/templates", - "packages/cli/scripts/fixtures/third-party-plugin.tsx", + "packages/cli/scripts/fixtures", "packages/stack/scripts/fixtures/registry/README.md", "packages/stack/registry", "playground/src", ]; +// Exact historical inputs are immutable negative fixtures, not maintained +// examples. Their README and hash allowlist fail closed if their bytes change. +const guardExclusions = new Map([ + [ + "packages/cli/scripts/fixtures/legacy-next", + "immutable pre-migration CLI inputs documented by the fixture README", + ], +]); + const backendFactories = [ "aiChatBackendPlugin", "blogBackendPlugin", @@ -77,6 +86,9 @@ const pluginIdPattern = pluginIds.map(escapeRegExp).join("|"); const removedPluginIdPattern = removedPluginIds.map(escapeRegExp).join("|"); function collectFiles(target) { + for (const excluded of guardExclusions.keys()) { + if (target === excluded || target.startsWith(`${excluded}/`)) return []; + } const absolute = join(root, target); if (!statSync(absolute).isDirectory()) return [absolute]; const files = []; @@ -209,21 +221,25 @@ function readTopLevelObject(source, openIndex) { return undefined; } -function checkFactoryCalls(failures, file, source, factory, kind) { - const callPattern = new RegExp(`\\b${factory}\\s*\\(`, "g"); +function checkFactoryCalls( + failures, + file, + source, + factory, + kind, + contextualLifecycleNames = [], +) { + const callPattern = new RegExp(`\\b${factory}\\(`, "g"); for (const match of source.matchAll(callPattern)) { let cursor = (match.index ?? 0) + match[0].length; while (/\s/.test(source[cursor] ?? "")) cursor += 1; if (source[cursor] === ")") continue; if (source[cursor] !== "{") { - const positionalArgument = source - .slice(cursor) - .match(/^[A-Za-z_$][\w$]*\s*(?=[,)])/); - if (kind === "client" || !positionalArgument) continue; + if (kind === "client") continue; failures.push({ file, line: lineAt(source, match.index ?? 0), - label: `${kind} factory must receive one options object`, + label: `${kind} factory example must use one inline options object`, match: factory, }); continue; @@ -241,6 +257,23 @@ function checkFactoryCalls(failures, file, source, factory, kind) { match: factory, }); } + if (kind === "backend" && contextualLifecycleNames.length > 0) { + const callSource = source.slice(cursor, object.end + 1); + for (const name of contextualLifecycleNames) { + const propertyPattern = new RegExp( + `\\b${escapeRegExp(name)}\\b(?=\\s*(?:\\??:|,|\\}))`, + "g", + ); + for (const lifecycleMatch of callSource.matchAll(propertyPattern)) { + failures.push({ + file, + line: lineAt(source, cursor + (lifecycleMatch.index ?? 0)), + label: `${factory} uses a removed lifecycle callback`, + match: name, + }); + } + } + } if ( kind === "client" && /\b(?:apiBaseURL|apiBasePath|siteBaseURL|siteBasePath|queryClient|headers)\s*:/.test( @@ -259,16 +292,17 @@ function checkFactoryCalls(failures, file, source, factory, kind) { function lifecycleInventory() { const inventories = [ - "ai-chat", - "blog", - "cms", - "comments", - "form-builder", - "kanban", - "media", + ["ai-chat", "aiChatBackendPlugin"], + ["blog", "blogBackendPlugin"], + ["cms", "cmsBackendPlugin"], + ["comments", "commentsBackendPlugin"], + ["form-builder", "formBuilderBackendPlugin"], + ["kanban", "kanbanBackendPlugin"], + ["media", "mediaBackendPlugin"], ]; const names = new Set(); - for (const plugin of inventories) { + const namesByFactory = new Map(); + for (const [plugin, factory] of inventories) { const source = readFileSync( join( root, @@ -281,29 +315,38 @@ function lifecycleInventory() { )?.[1]; if (!object) throw new Error(`Unable to read ${plugin} lifecycle inventory`); - for (const match of object.matchAll(/^\s*([A-Za-z0-9]+):/gm)) { - names.add(match[1]); - } + const pluginNames = [...object.matchAll(/^\s*([A-Za-z0-9]+):/gm)].map( + (match) => match[1], + ); + for (const name of pluginNames) names.add(name); + namesByFactory.set(factory, pluginNames); } - return [...names].filter( - (name) => - ![ - "onBeforeCreate", - "onAfterCreate", - "onBeforeUpdate", - "onAfterUpdate", - "onBeforeDelete", - "onAfterDelete", - "onError", - ].includes(name), - ); + const contextualNames = new Set([ + "onBeforeCreate", + "onAfterCreate", + "onBeforeUpdate", + "onAfterUpdate", + "onBeforeDelete", + "onAfterDelete", + "onError", + ]); + return { + globalNames: [...names].filter((name) => !contextualNames.has(name)), + contextualNamesByFactory: new Map( + [...namesByFactory].map(([factory, pluginNames]) => [ + factory, + pluginNames.filter((name) => contextualNames.has(name)), + ]), + ), + }; } const guidanceFiles = guidanceTargets.flatMap(collectFiles); const generatedFiles = generatedTargets.flatMap(collectFiles); const allFiles = [...new Set([...guidanceFiles, ...generatedFiles])]; const generatedSet = new Set(generatedFiles); -const removedLifecycleNames = lifecycleInventory(); +const { globalNames: removedLifecycleNames, contextualNamesByFactory } = + lifecycleInventory(); const failures = []; for (const absolute of allFiles) { @@ -315,7 +358,7 @@ for (const absolute of allFiles) { file, source, "removed constructor", - /\bcreateStackClient\b|\bstack\s*\(\s*\{/g, + /\bcreateStackClient\b|\bstack\s*\(|import\s*\{[^}\n]*\bstack\b[^}\n]*\}\s*from\s*["']@btst\/stack(?:\/api)?["']/g, ); recordMatches( failures, @@ -380,7 +423,14 @@ for (const absolute of allFiles) { ); for (const factory of backendFactories) { - checkFactoryCalls(failures, file, source, factory, "backend"); + checkFactoryCalls( + failures, + file, + source, + factory, + "backend", + contextualNamesByFactory.get(factory), + ); } for (const factory of clientFactories) { checkFactoryCalls(failures, file, source, factory, "client"); From 03fcd7e9fac69f72b69770f03d004529d1ee3948 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:20:32 -0400 Subject: [PATCH 04/24] test(v3): cover contextual legacy hook forms (#224) --- scripts/check-canonical-dx.mjs | 135 ++++++++++++++++++++++++++++++--- 1 file changed, 123 insertions(+), 12 deletions(-) diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs index 1a7db1827..07e38f657 100644 --- a/scripts/check-canonical-dx.mjs +++ b/scripts/check-canonical-dx.mjs @@ -50,6 +50,15 @@ const backendFactories = [ "mediaBackendPlugin", "openApiBackendPlugin", ]; +const backendHookTypes = new Map([ + ["aiChatBackendPlugin", "AiChatBackendHooks"], + ["blogBackendPlugin", "BlogBackendHooks"], + ["cmsBackendPlugin", "CMSBackendHooks"], + ["commentsBackendPlugin", "CommentsBackendHooks"], + ["formBuilderBackendPlugin", "FormBuilderBackendHooks"], + ["kanbanBackendPlugin", "KanbanBackendHooks"], + ["mediaBackendPlugin", "MediaBackendHooks"], +]); const clientFactories = [ "aiChatClientPlugin", "blogClientPlugin", @@ -154,6 +163,33 @@ function recordMatches(failures, file, source, label, pattern) { } } +function recordLifecycleProperties( + failures, + file, + fullSource, + objectSource, + baseIndex, + factory, + names, +) { + for (const name of names) { + const propertyPattern = new RegExp( + `(?:^|[,{])\\s*${escapeRegExp(name)}\\b(?=\\s*(?:\\??:|\\(|,|\\}))`, + "gm", + ); + for (const match of objectSource.matchAll(propertyPattern)) { + const absoluteIndex = + baseIndex + (match.index ?? 0) + match[0].indexOf(name); + failures.push({ + file, + line: lineAt(fullSource, absoluteIndex), + label: `${factory} uses a removed lifecycle callback`, + match: name, + }); + } + } +} + function readTopLevelObject(source, openIndex) { let depth = 0; let quote; @@ -229,13 +265,25 @@ function checkFactoryCalls( kind, contextualLifecycleNames = [], ) { - const callPattern = new RegExp(`\\b${factory}\\(`, "g"); + const callPattern = new RegExp(`\\b${factory}[ \\t\\n]*\\(`, "g"); for (const match of source.matchAll(callPattern)) { let cursor = (match.index ?? 0) + match[0].length; while (/\s/.test(source[cursor] ?? "")) cursor += 1; if (source[cursor] === ")") continue; if (source[cursor] !== "{") { - if (kind === "client") continue; + const expression = source + .slice(cursor) + .match( + /^[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*(?:\s*\([^()\n]*\))?\s*(?=[,)])/, + )?.[0] + .trim(); + if (kind === "client" || !expression) continue; + if ( + /^[A-Za-z_$][\w$]*$/.test(expression) && + /(?:options?|config|opts)$/i.test(expression) + ) { + continue; + } failures.push({ file, line: lineAt(source, match.index ?? 0), @@ -259,18 +307,39 @@ function checkFactoryCalls( } if (kind === "backend" && contextualLifecycleNames.length > 0) { const callSource = source.slice(cursor, object.end + 1); - for (const name of contextualLifecycleNames) { - const propertyPattern = new RegExp( - `\\b${escapeRegExp(name)}\\b(?=\\s*(?:\\??:|,|\\}))`, + recordLifecycleProperties( + failures, + file, + source, + callSource, + cursor, + factory, + contextualLifecycleNames, + ); + + const hooksReference = object.topLevel + .match(/\bhooks\s*:\s*([A-Za-z_$][\w$]*)|\b(hooks)\s*(?=[,}])/) + ?.slice(1) + .find(Boolean); + if (hooksReference) { + const declarationPattern = new RegExp( + `\\b(?:const|let|var)\\s+${escapeRegExp(hooksReference)}(?:\\s*:[^=;]+)?\\s*=\\s*\\{`, "g", ); - for (const lifecycleMatch of callSource.matchAll(propertyPattern)) { - failures.push({ + for (const declaration of source.matchAll(declarationPattern)) { + const openIndex = + (declaration.index ?? 0) + declaration[0].lastIndexOf("{"); + const hooksObject = readTopLevelObject(source, openIndex); + if (!hooksObject) continue; + recordLifecycleProperties( + failures, file, - line: lineAt(source, cursor + (lifecycleMatch.index ?? 0)), - label: `${factory} uses a removed lifecycle callback`, - match: name, - }); + source, + source.slice(openIndex, hooksObject.end + 1), + openIndex, + factory, + contextualLifecycleNames, + ); } } } @@ -290,6 +359,36 @@ function checkFactoryCalls( } } +function checkTypedHookObjects( + failures, + file, + source, + factory, + typeName, + names, +) { + if (names.length === 0) return; + const declarationPattern = new RegExp( + `:\\s*${escapeRegExp(typeName)}\\s*=\\s*\\{`, + "g", + ); + for (const declaration of source.matchAll(declarationPattern)) { + const openIndex = + (declaration.index ?? 0) + declaration[0].lastIndexOf("{"); + const object = readTopLevelObject(source, openIndex); + if (!object) continue; + recordLifecycleProperties( + failures, + file, + source, + source.slice(openIndex, object.end + 1), + openIndex, + factory, + names, + ); + } +} + function lifecycleInventory() { const inventories = [ ["ai-chat", "aiChatBackendPlugin"], @@ -423,14 +522,26 @@ for (const absolute of allFiles) { ); for (const factory of backendFactories) { + const contextualNames = contextualNamesByFactory.get(factory) ?? []; checkFactoryCalls( failures, file, source, factory, "backend", - contextualNamesByFactory.get(factory), + contextualNames, ); + const hookType = backendHookTypes.get(factory); + if (hookType) { + checkTypedHookObjects( + failures, + file, + source, + factory, + hookType, + contextualNames, + ); + } } for (const factory of clientFactories) { checkFactoryCalls(failures, file, source, factory, "client"); From 70f2fc8af2d8f20a062923d42016ad24910675a5 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:21:52 -0400 Subject: [PATCH 05/24] refactor(v3): centralize canonical guard inventory (#224) --- scripts/check-canonical-dx.mjs | 76 +++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 33 deletions(-) diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs index 07e38f657..71832d89e 100644 --- a/scripts/check-canonical-dx.mjs +++ b/scripts/check-canonical-dx.mjs @@ -40,25 +40,44 @@ const guardExclusions = new Map([ ], ]); -const backendFactories = [ - "aiChatBackendPlugin", - "blogBackendPlugin", - "cmsBackendPlugin", - "commentsBackendPlugin", - "formBuilderBackendPlugin", - "kanbanBackendPlugin", - "mediaBackendPlugin", - "openApiBackendPlugin", +const backendPlugins = [ + { + factory: "aiChatBackendPlugin", + lifecycleSlug: "ai-chat", + hookType: "AiChatBackendHooks", + }, + { + factory: "blogBackendPlugin", + lifecycleSlug: "blog", + hookType: "BlogBackendHooks", + }, + { + factory: "cmsBackendPlugin", + lifecycleSlug: "cms", + hookType: "CMSBackendHooks", + }, + { + factory: "commentsBackendPlugin", + lifecycleSlug: "comments", + hookType: "CommentsBackendHooks", + }, + { + factory: "formBuilderBackendPlugin", + lifecycleSlug: "form-builder", + hookType: "FormBuilderBackendHooks", + }, + { + factory: "kanbanBackendPlugin", + lifecycleSlug: "kanban", + hookType: "KanbanBackendHooks", + }, + { + factory: "mediaBackendPlugin", + lifecycleSlug: "media", + hookType: "MediaBackendHooks", + }, + { factory: "openApiBackendPlugin" }, ]; -const backendHookTypes = new Map([ - ["aiChatBackendPlugin", "AiChatBackendHooks"], - ["blogBackendPlugin", "BlogBackendHooks"], - ["cmsBackendPlugin", "CMSBackendHooks"], - ["commentsBackendPlugin", "CommentsBackendHooks"], - ["formBuilderBackendPlugin", "FormBuilderBackendHooks"], - ["kanbanBackendPlugin", "KanbanBackendHooks"], - ["mediaBackendPlugin", "MediaBackendHooks"], -]); const clientFactories = [ "aiChatClientPlugin", "blogClientPlugin", @@ -287,7 +306,7 @@ function checkFactoryCalls( failures.push({ file, line: lineAt(source, match.index ?? 0), - label: `${kind} factory example must use one inline options object`, + label: `${kind} factory must receive one options object, not positional hooks`, match: factory, }); continue; @@ -390,22 +409,14 @@ function checkTypedHookObjects( } function lifecycleInventory() { - const inventories = [ - ["ai-chat", "aiChatBackendPlugin"], - ["blog", "blogBackendPlugin"], - ["cms", "cmsBackendPlugin"], - ["comments", "commentsBackendPlugin"], - ["form-builder", "formBuilderBackendPlugin"], - ["kanban", "kanbanBackendPlugin"], - ["media", "mediaBackendPlugin"], - ]; const names = new Set(); const namesByFactory = new Map(); - for (const [plugin, factory] of inventories) { + for (const { lifecycleSlug, factory } of backendPlugins) { + if (!lifecycleSlug) continue; const source = readFileSync( join( root, - `packages/stack/src/plugins/${plugin}/api/lifecycle-migrations.ts`, + `packages/stack/src/plugins/${lifecycleSlug}/api/lifecycle-migrations.ts`, ), "utf8", ); @@ -413,7 +424,7 @@ function lifecycleInventory() { /Object\.freeze\(\{([\s\S]*?)\}\s+as const\)/, )?.[1]; if (!object) - throw new Error(`Unable to read ${plugin} lifecycle inventory`); + throw new Error(`Unable to read ${lifecycleSlug} lifecycle inventory`); const pluginNames = [...object.matchAll(/^\s*([A-Za-z0-9]+):/gm)].map( (match) => match[1], ); @@ -521,7 +532,7 @@ for (const absolute of allFiles) { ), ); - for (const factory of backendFactories) { + for (const { factory, hookType } of backendPlugins) { const contextualNames = contextualNamesByFactory.get(factory) ?? []; checkFactoryCalls( failures, @@ -531,7 +542,6 @@ for (const absolute of allFiles) { "backend", contextualNames, ); - const hookType = backendHookTypes.get(factory); if (hookType) { checkTypedHookObjects( failures, From 248528dd7753a147cbbc319d8177b5b6cb4932b0 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:33:07 -0400 Subject: [PATCH 06/24] fix(v3): validate referenced plugin configs (#224) --- scripts/check-canonical-dx.mjs | 211 +++++++++++++++++++++------------ 1 file changed, 137 insertions(+), 74 deletions(-) diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs index 71832d89e..1660b9d16 100644 --- a/scripts/check-canonical-dx.mjs +++ b/scripts/check-canonical-dx.mjs @@ -171,6 +171,12 @@ function lineAt(source, index) { return source.slice(0, index).split("\n").length; } +function isInsideMarkdownInlineCode(source, index) { + const lineStart = source.lastIndexOf("\n", index - 1) + 1; + const prefix = source.slice(lineStart, index); + return (prefix.match(/(? 0) { + const objectSource = source.slice(openIndex, object.end + 1); + recordLifecycleProperties( + failures, + file, + source, + objectSource, + openIndex, + factory, + contextualLifecycleNames, + ); + + const hooksReference = object.topLevel + .match(/\bhooks\s*:\s*([A-Za-z_$][\w$]*)|\b(hooks)\s*(?=[,}])/) + ?.slice(1) + .find(Boolean); + if (hooksReference) { + for (const declaration of findObjectDeclarations( + source, + hooksReference, + )) { + recordLifecycleProperties( + failures, + file, + source, + source.slice(declaration.openIndex, declaration.object.end + 1), + declaration.openIndex, + factory, + contextualLifecycleNames, + ); + } + } + } + if ( + kind === "client" && + /(?:^|[,{])\s*(?:apiBaseURL|apiBasePath|siteBaseURL|siteBasePath|queryClient|headers)\b(?=\s*(?:\??:|,|}))/.test( + object.topLevel, + ) + ) { + failures.push({ + file, + line: lineAt(source, reportIndex), + label: "client plugin duplicates stack-owned runtime", + match: factory, + }); + } +} + function checkFactoryCalls( failures, file, @@ -286,7 +377,8 @@ function checkFactoryCalls( ) { const callPattern = new RegExp(`\\b${factory}[ \\t\\n]*\\(`, "g"); for (const match of source.matchAll(callPattern)) { - let cursor = (match.index ?? 0) + match[0].length; + const callIndex = match.index ?? 0; + let cursor = callIndex + match[0].length; while (/\s/.test(source[cursor] ?? "")) cursor += 1; if (source[cursor] === ")") continue; if (source[cursor] !== "{") { @@ -296,85 +388,56 @@ function checkFactoryCalls( /^[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*(?:\s*\([^()\n]*\))?\s*(?=[,)])/, )?.[0] .trim(); - if (kind === "client" || !expression) continue; - if ( - /^[A-Za-z_$][\w$]*$/.test(expression) && - /(?:options?|config|opts)$/i.test(expression) - ) { + if (!expression) continue; + const identifier = /^[A-Za-z_$][\w$]*$/.test(expression) + ? expression + : undefined; + const declarations = identifier + ? findObjectDeclarations(source, identifier) + : []; + if (declarations.length === 0) { + if ( + /\.mdx?$/.test(file) && + isInsideMarkdownInlineCode(source, callIndex) + ) { + continue; + } + failures.push({ + file, + line: lineAt(source, callIndex), + label: `${kind} factory options expression cannot be verified`, + match: `${factory}(${expression})`, + }); continue; } - failures.push({ - file, - line: lineAt(source, match.index ?? 0), - label: `${kind} factory must receive one options object, not positional hooks`, - match: factory, - }); + for (const declaration of declarations) { + inspectFactoryObject( + failures, + file, + source, + factory, + kind, + contextualLifecycleNames, + declaration.object, + declaration.openIndex, + declaration.openIndex, + ); + } continue; } const object = readTopLevelObject(source, cursor); if (!object) continue; - if ( - kind === "backend" && - /\bon(?:Before|After|Error)[A-Z][A-Za-z0-9]*\s*:/.test(object.topLevel) - ) { - failures.push({ - file, - line: lineAt(source, match.index ?? 0), - label: "backend lifecycle callbacks must be nested under hooks", - match: factory, - }); - } - if (kind === "backend" && contextualLifecycleNames.length > 0) { - const callSource = source.slice(cursor, object.end + 1); - recordLifecycleProperties( - failures, - file, - source, - callSource, - cursor, - factory, - contextualLifecycleNames, - ); - - const hooksReference = object.topLevel - .match(/\bhooks\s*:\s*([A-Za-z_$][\w$]*)|\b(hooks)\s*(?=[,}])/) - ?.slice(1) - .find(Boolean); - if (hooksReference) { - const declarationPattern = new RegExp( - `\\b(?:const|let|var)\\s+${escapeRegExp(hooksReference)}(?:\\s*:[^=;]+)?\\s*=\\s*\\{`, - "g", - ); - for (const declaration of source.matchAll(declarationPattern)) { - const openIndex = - (declaration.index ?? 0) + declaration[0].lastIndexOf("{"); - const hooksObject = readTopLevelObject(source, openIndex); - if (!hooksObject) continue; - recordLifecycleProperties( - failures, - file, - source, - source.slice(openIndex, hooksObject.end + 1), - openIndex, - factory, - contextualLifecycleNames, - ); - } - } - } - if ( - kind === "client" && - /\b(?:apiBaseURL|apiBasePath|siteBaseURL|siteBasePath|queryClient|headers)\s*:/.test( - object.topLevel, - ) - ) { - failures.push({ - file, - line: lineAt(source, match.index ?? 0), - label: "client plugin duplicates stack-owned runtime", - match: factory, - }); - } + inspectFactoryObject( + failures, + file, + source, + factory, + kind, + contextualLifecycleNames, + object, + cursor, + callIndex, + ); } } From 5b4470be75f3bc4729cfb9729858669ab0c3bad4 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:36:54 -0400 Subject: [PATCH 07/24] fix(v3): fail closed on opaque factory config (#224) --- scripts/check-canonical-dx.mjs | 99 +++++++++++++++++++++++++++------- 1 file changed, 81 insertions(+), 18 deletions(-) diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs index 1660b9d16..dc570b86d 100644 --- a/scripts/check-canonical-dx.mjs +++ b/scripts/check-canonical-dx.mjs @@ -45,49 +45,59 @@ const backendPlugins = [ factory: "aiChatBackendPlugin", lifecycleSlug: "ai-chat", hookType: "AiChatBackendHooks", + configType: "AiChatBackendConfig", }, { factory: "blogBackendPlugin", lifecycleSlug: "blog", hookType: "BlogBackendHooks", + configType: "BlogBackendOptions", }, { factory: "cmsBackendPlugin", lifecycleSlug: "cms", hookType: "CMSBackendHooks", + configType: "CMSBackendConfig", }, { factory: "commentsBackendPlugin", lifecycleSlug: "comments", hookType: "CommentsBackendHooks", + configType: "CommentsBackendOptions", }, { factory: "formBuilderBackendPlugin", lifecycleSlug: "form-builder", hookType: "FormBuilderBackendHooks", + configType: "FormBuilderBackendConfig", }, { factory: "kanbanBackendPlugin", lifecycleSlug: "kanban", hookType: "KanbanBackendHooks", + configType: "KanbanBackendOptions", }, { factory: "mediaBackendPlugin", lifecycleSlug: "media", hookType: "MediaBackendHooks", + configType: "MediaBackendConfig", }, - { factory: "openApiBackendPlugin" }, + { factory: "openApiBackendPlugin", configType: "OpenAPIOptions" }, ]; -const clientFactories = [ - "aiChatClientPlugin", - "blogClientPlugin", - "cmsClientPlugin", - "commentsClientPlugin", - "formBuilderClientPlugin", - "kanbanClientPlugin", - "mediaClientPlugin", - "routeDocsClientPlugin", - "uiBuilderClientPlugin", +const clientPlugins = [ + { factory: "aiChatClientPlugin", configType: "AiChatClientConfig" }, + { factory: "blogClientPlugin", configType: "BlogClientConfig" }, + { factory: "cmsClientPlugin", configType: "CMSClientConfig" }, + { factory: "commentsClientPlugin", configType: "CommentsClientConfig" }, + { + factory: "formBuilderClientPlugin", + configType: "FormBuilderClientConfig", + }, + { factory: "kanbanClientPlugin", configType: "KanbanClientConfig" }, + { factory: "mediaClientPlugin", configType: "MediaClientConfig" }, + { factory: "routeDocsClientPlugin", configType: "RouteDocsClientConfig" }, + { factory: "uiBuilderClientPlugin", configType: "UIBuilderClientConfig" }, ]; const pluginIds = [ "aiChat", @@ -177,6 +187,18 @@ function isInsideMarkdownInlineCode(source, index) { return (prefix.match(/(??`, + ).test(source); +} + function recordMatches(failures, file, source, label, pattern) { for (const match of source.matchAll(pattern)) { failures.push({ @@ -229,7 +251,7 @@ function readTopLevelObject(source, openIndex) { if (lineComment) { if (char === "\n") lineComment = false; - if (depth <= 1) topLevel += char; + if (depth <= 1) topLevel += char === "\n" ? "\n" : " "; continue; } if (blockComment) { @@ -308,9 +330,19 @@ function inspectFactoryObject( openIndex, reportIndex, ) { + if (/\.\.\.\s*[A-Za-z_$]/.test(object.topLevel)) { + failures.push({ + file, + line: lineAt(source, reportIndex), + label: `${kind} factory options contain an unverifiable spread`, + match: factory, + }); + } if ( kind === "backend" && - /\bon(?:Before|After|Error)[A-Z][A-Za-z0-9]*\s*:/.test(object.topLevel) + /(?:^|[,{])\s*(?:on(?:Before|After)[A-Z][A-Za-z0-9]*|onError(?:[A-Z][A-Za-z0-9]*)?)\b(?=\s*(?:\??:|\(|,|}))/.test( + object.topLevel, + ) ) { failures.push({ file, @@ -373,6 +405,7 @@ function checkFactoryCalls( source, factory, kind, + configType, contextualLifecycleNames = [], ) { const callPattern = new RegExp(`\\b${factory}[ \\t\\n]*\\(`, "g"); @@ -388,7 +421,22 @@ function checkFactoryCalls( /^[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*(?:\s*\([^()\n]*\))?\s*(?=[,)])/, )?.[0] .trim(); - if (!expression) continue; + if (!expression) { + if ( + /\.mdx?$/.test(file) && + isInsideMarkdownInlineCode(source, callIndex) + ) { + continue; + } + if (isInsideCommentProse(source, callIndex)) continue; + failures.push({ + file, + line: lineAt(source, callIndex), + label: `${kind} factory options expression cannot be parsed`, + match: factory, + }); + continue; + } const identifier = /^[A-Za-z_$][\w$]*$/.test(expression) ? expression : undefined; @@ -402,6 +450,12 @@ function checkFactoryCalls( ) { continue; } + if ( + identifier && + hasCanonicalConfigType(source, identifier, configType) + ) { + continue; + } failures.push({ file, line: lineAt(source, callIndex), @@ -426,7 +480,15 @@ function checkFactoryCalls( continue; } const object = readTopLevelObject(source, cursor); - if (!object) continue; + if (!object) { + failures.push({ + file, + line: lineAt(source, callIndex), + label: `${kind} factory options object cannot be parsed`, + match: factory, + }); + continue; + } inspectFactoryObject( failures, file, @@ -595,7 +657,7 @@ for (const absolute of allFiles) { ), ); - for (const { factory, hookType } of backendPlugins) { + for (const { factory, hookType, configType } of backendPlugins) { const contextualNames = contextualNamesByFactory.get(factory) ?? []; checkFactoryCalls( failures, @@ -603,6 +665,7 @@ for (const absolute of allFiles) { source, factory, "backend", + configType, contextualNames, ); if (hookType) { @@ -616,8 +679,8 @@ for (const absolute of allFiles) { ); } } - for (const factory of clientFactories) { - checkFactoryCalls(failures, file, source, factory, "client"); + for (const { factory, configType } of clientPlugins) { + checkFactoryCalls(failures, file, source, factory, "client", configType); } for (const name of removedLifecycleNames) { recordMatches( From 78a6d5623e769f15e7b7ba8f36a8b86583f80fdc Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:42:41 -0400 Subject: [PATCH 08/24] fix(v3): close canonical guard bypasses (#224) --- scripts/check-canonical-dx.mjs | 204 ++++++++++++++---- .../codegen/files/nextjs/lib/stack-client.tsx | 6 +- .../react-router/app/lib/stack-client.tsx | 6 +- .../files/tanstack/src/lib/stack-client.tsx | 6 +- 4 files changed, 171 insertions(+), 51 deletions(-) diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs index dc570b86d..831ab9271 100644 --- a/scripts/check-canonical-dx.mjs +++ b/scripts/check-canonical-dx.mjs @@ -188,15 +188,48 @@ function isInsideMarkdownInlineCode(source, index) { } function isInsideCommentProse(source, index) { - return /(?:\/\/|\/\*|\*)[^*\n]{0,200}$/.test( - source.slice(Math.max(0, index - 200), index), - ); -} + let quote; + let escaped = false; + let lineComment = false; + let blockComment = false; + for (let cursor = 0; cursor < index; cursor += 1) { + const char = source[cursor]; + const next = source[cursor + 1]; + if (lineComment) { + if (char === "\n") lineComment = false; + continue; + } + if (blockComment) { + if (char === "*" && next === "/") { + blockComment = false; + cursor += 1; + } + continue; + } + if (quote) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === quote) quote = undefined; + continue; + } + if (char === '"' || char === "'" || char === "`") quote = char; + else if (char === "/" && next === "/") { + lineComment = true; + cursor += 1; + } else if (char === "/" && next === "*") { + blockComment = true; + cursor += 1; + } + } + if (lineComment || blockComment) return true; -function hasCanonicalConfigType(source, identifier, configType) { - return new RegExp( - `\\b${escapeRegExp(identifier)}\\s*:\\s*(?:Readonly\\s*<\\s*)?${escapeRegExp(configType)}\\s*>?`, - ).test(source); + // Registry JSON stores source comments with encoded newlines and tabs. + const encodedLineStart = source.lastIndexOf("\\n", index); + if (encodedLineStart < 0) return false; + const encodedPrefix = source + .slice(encodedLineStart + 2, index) + .replaceAll("\\t", "\t"); + return /^\s*(?:\/\/|\*)/.test(encodedPrefix); } function recordMatches(failures, file, source, label, pattern) { @@ -239,6 +272,8 @@ function recordLifecycleProperties( function readTopLevelObject(source, openIndex) { let depth = 0; + let roundDepth = 0; + let squareDepth = 0; let quote; let escaped = false; let lineComment = false; @@ -266,7 +301,7 @@ function readTopLevelObject(source, openIndex) { if (escaped) escaped = false; else if (char === "\\") escaped = true; else if (char === quote) quote = undefined; - if (depth <= 1) topLevel += char; + if (depth <= 1 && roundDepth === 0 && squareDepth === 0) topLevel += char; else if (char === "\n") topLevel += "\n"; continue; } @@ -284,39 +319,113 @@ function readTopLevelObject(source, openIndex) { } if (char === '"' || char === "'" || char === "`") { quote = char; - if (depth <= 1) topLevel += char; + if (depth <= 1 && roundDepth === 0 && squareDepth === 0) topLevel += char; + continue; + } + if (char === "(") { + if (depth <= 1 && roundDepth === 0 && squareDepth === 0) topLevel += char; + else topLevel += " "; + roundDepth += 1; + continue; + } + if (char === ")") { + roundDepth = Math.max(0, roundDepth - 1); + if (depth <= 1 && roundDepth === 0 && squareDepth === 0) topLevel += char; + else topLevel += " "; + continue; + } + if (char === "[") { + if (depth <= 1 && roundDepth === 0 && squareDepth === 0) topLevel += char; + else topLevel += " "; + squareDepth += 1; + continue; + } + if (char === "]") { + squareDepth = Math.max(0, squareDepth - 1); + if (depth <= 1 && roundDepth === 0 && squareDepth === 0) topLevel += char; + else topLevel += " "; continue; } if (char === "{") { depth += 1; - topLevel += depth <= 1 ? char : " "; + topLevel += + depth <= 1 && roundDepth === 0 && squareDepth === 0 ? char : " "; continue; } if (char === "}") { depth -= 1; - topLevel += depth <= 1 ? char : " "; + topLevel += + depth <= 1 && roundDepth === 0 && squareDepth === 0 ? char : " "; if (depth === 0) return { end: index, topLevel }; continue; } - topLevel += depth <= 1 || char === "\n" ? char : " "; + topLevel += + (depth <= 1 && roundDepth === 0 && squareDepth === 0) || char === "\n" + ? char + : " "; } return undefined; } -function findObjectDeclarations(source, identifier) { - const declarations = []; - const declarationPattern = new RegExp( - `\\b(?:const|let|var)\\s+${escapeRegExp(identifier)}(?:\\s*:[^=;]+)?\\s*=\\s*\\{`, +function isCanonicalTypeAnnotation(annotation, configType) { + if (!annotation || !configType) return false; + const compact = annotation.replace(/\s+/g, ""); + return compact === configType || compact === `Readonly<${configType}>`; +} + +function resolveIdentifierBinding( + source, + file, + identifier, + configType, + callIndex, +) { + const candidates = []; + const markdownFence = /\.mdx?$/.test(file) + ? source.lastIndexOf("```", callIndex) + : -1; + const searchStart = Math.max(0, markdownFence); + const beforeCall = source.slice(searchStart, callIndex); + const variablePattern = new RegExp( + `\\b(?:const|let|var)\\s+${escapeRegExp(identifier)}\\b(?:\\s*:\\s*([^=;\\n]+))?\\s*=`, "g", ); - for (const declaration of source.matchAll(declarationPattern)) { - const openIndex = - (declaration.index ?? 0) + declaration[0].lastIndexOf("{"); - const object = readTopLevelObject(source, openIndex); - if (object) declarations.push({ object, openIndex }); + for (const binding of beforeCall.matchAll(variablePattern)) { + let valueIndex = searchStart + (binding.index ?? 0) + binding[0].length; + while (/\s/.test(source[valueIndex] ?? "")) valueIndex += 1; + const object = + source[valueIndex] === "{" + ? readTopLevelObject(source, valueIndex) + : undefined; + candidates.push({ + index: searchStart + (binding.index ?? 0), + object, + openIndex: object ? valueIndex : undefined, + typed: isCanonicalTypeAnnotation(binding[1], configType), + }); + } + + const functionPattern = + /(?:\bfunction(?:\s+[A-Za-z_$][\w$]*)?\s*|\b(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s*)?|(?:async\s+)?[A-Za-z_$][\w$]*\s*)\(([^()]*)\)\s*(?::[^={]+)?(?:=>\s*)?\{/g; + for (const signature of source.matchAll(functionPattern)) { + const signatureIndex = signature.index ?? 0; + if (signatureIndex >= callIndex) break; + const openIndex = signatureIndex + signature[0].lastIndexOf("{"); + const body = readTopLevelObject(source, openIndex); + if (!body || callIndex <= openIndex || callIndex >= body.end) continue; + const parameterPattern = new RegExp( + `(?:^|,)\\s*(?:\\.\\.\\.\\s*)?${escapeRegExp(identifier)}\\s*\\??(?:\\s*:\\s*([^,=]+))?(?=\\s*(?:,|$))`, + ); + const parameter = signature[1].match(parameterPattern); + if (!parameter) continue; + candidates.push({ + index: signatureIndex + signature[0].indexOf(signature[1]), + typed: isCanonicalTypeAnnotation(parameter[1], configType), + }); } - return declarations; + + return candidates.sort((left, right) => right.index - left.index)[0]; } function inspectFactoryObject( @@ -330,7 +439,7 @@ function inspectFactoryObject( openIndex, reportIndex, ) { - if (/\.\.\.\s*[A-Za-z_$]/.test(object.topLevel)) { + if (/\.\.\./.test(object.topLevel)) { failures.push({ file, line: lineAt(source, reportIndex), @@ -368,16 +477,20 @@ function inspectFactoryObject( ?.slice(1) .find(Boolean); if (hooksReference) { - for (const declaration of findObjectDeclarations( + const binding = resolveIdentifierBinding( source, + file, hooksReference, - )) { + undefined, + reportIndex, + ); + if (binding?.object && binding.openIndex !== undefined) { recordLifecycleProperties( failures, file, source, - source.slice(declaration.openIndex, declaration.object.end + 1), - declaration.openIndex, + source.slice(binding.openIndex, binding.object.end + 1), + binding.openIndex, factory, contextualLifecycleNames, ); @@ -440,22 +553,22 @@ function checkFactoryCalls( const identifier = /^[A-Za-z_$][\w$]*$/.test(expression) ? expression : undefined; - const declarations = identifier - ? findObjectDeclarations(source, identifier) - : []; - if (declarations.length === 0) { + const binding = identifier + ? resolveIdentifierBinding( + source, + file, + identifier, + configType, + callIndex, + ) + : undefined; + if (!binding) { if ( /\.mdx?$/.test(file) && isInsideMarkdownInlineCode(source, callIndex) ) { continue; } - if ( - identifier && - hasCanonicalConfigType(source, identifier, configType) - ) { - continue; - } failures.push({ file, line: lineAt(source, callIndex), @@ -464,7 +577,7 @@ function checkFactoryCalls( }); continue; } - for (const declaration of declarations) { + if (binding.object && binding.openIndex !== undefined) { inspectFactoryObject( failures, file, @@ -472,10 +585,17 @@ function checkFactoryCalls( factory, kind, contextualLifecycleNames, - declaration.object, - declaration.openIndex, - declaration.openIndex, + binding.object, + binding.openIndex, + binding.openIndex, ); + } else if (!binding.typed) { + failures.push({ + file, + line: lineAt(source, callIndex), + label: `${kind} factory options binding cannot be verified`, + match: `${factory}(${expression})`, + }); } continue; } diff --git a/scripts/codegen/files/nextjs/lib/stack-client.tsx b/scripts/codegen/files/nextjs/lib/stack-client.tsx index 897832f77..d119745a9 100644 --- a/scripts/codegen/files/nextjs/lib/stack-client.tsx +++ b/scripts/codegen/files/nextjs/lib/stack-client.tsx @@ -133,7 +133,7 @@ export const createAppClientStack = ( }, }), aiChat: aiChatClientPlugin({ - ...(requestIdentity ? { identityPartition: requestIdentity } : {}), + identityPartition: requestIdentity ?? undefined, mode: "authenticated", seo: { siteName: "BTST Chat", @@ -175,7 +175,7 @@ export const createAppClientStack = ( description: "Documentation for all client routes in this application", }), kanban: kanbanClientPlugin({ - ...(requestIdentity ? { identityPartition: requestIdentity } : {}), + identityPartition: requestIdentity ?? undefined, seo: { siteName: "BTST Kanban", description: "Manage your projects with kanban boards", @@ -203,7 +203,7 @@ export const createAppClientStack = ( }), media: mediaClientPlugin({ uploadMode: "direct", - ...(requestIdentity ? { identityPartition: requestIdentity } : {}), + identityPartition: requestIdentity ?? undefined, }), }, ...(crossOriginApiEndpoint diff --git a/scripts/codegen/files/react-router/app/lib/stack-client.tsx b/scripts/codegen/files/react-router/app/lib/stack-client.tsx index 375565cbf..8288a3869 100644 --- a/scripts/codegen/files/react-router/app/lib/stack-client.tsx +++ b/scripts/codegen/files/react-router/app/lib/stack-client.tsx @@ -130,7 +130,7 @@ export const createAppClientStack = ( }, }), aiChat: aiChatClientPlugin({ - ...(requestIdentity ? { identityPartition: requestIdentity } : {}), + identityPartition: requestIdentity ?? undefined, mode: "authenticated", }), cms: cmsClientPlugin(), @@ -141,7 +141,7 @@ export const createAppClientStack = ( description: "Documentation for all client routes in this application", }), kanban: kanbanClientPlugin({ - ...(requestIdentity ? { identityPartition: requestIdentity } : {}), + identityPartition: requestIdentity ?? undefined, seo: { siteName: "BTST Kanban", description: "Manage your projects with kanban boards", @@ -158,7 +158,7 @@ export const createAppClientStack = ( }), media: mediaClientPlugin({ uploadMode: "direct", - ...(requestIdentity ? { identityPartition: requestIdentity } : {}), + identityPartition: requestIdentity ?? undefined, }), }, ...(crossOriginApiEndpoint diff --git a/scripts/codegen/files/tanstack/src/lib/stack-client.tsx b/scripts/codegen/files/tanstack/src/lib/stack-client.tsx index 50af6ee01..e3707209a 100644 --- a/scripts/codegen/files/tanstack/src/lib/stack-client.tsx +++ b/scripts/codegen/files/tanstack/src/lib/stack-client.tsx @@ -130,7 +130,7 @@ export const createAppClientStack = ( }, }), aiChat: aiChatClientPlugin({ - ...(requestIdentity ? { identityPartition: requestIdentity } : {}), + identityPartition: requestIdentity ?? undefined, mode: "authenticated", }), cms: cmsClientPlugin(), @@ -141,7 +141,7 @@ export const createAppClientStack = ( description: "Documentation for all client routes in this application", }), kanban: kanbanClientPlugin({ - ...(requestIdentity ? { identityPartition: requestIdentity } : {}), + identityPartition: requestIdentity ?? undefined, seo: { siteName: "BTST Kanban", description: "Manage your projects with kanban boards", @@ -158,7 +158,7 @@ export const createAppClientStack = ( }), media: mediaClientPlugin({ uploadMode: "direct", - ...(requestIdentity ? { identityPartition: requestIdentity } : {}), + identityPartition: requestIdentity ?? undefined, }), }, ...(crossOriginApiEndpoint From 3911b60424673026d5eb707e0b72aa44c41e3322 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:44:27 -0400 Subject: [PATCH 09/24] fix(v3): scope canonical config bindings (#224) --- scripts/check-canonical-dx.mjs | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs index 831ab9271..1261c9561 100644 --- a/scripts/check-canonical-dx.mjs +++ b/scripts/check-canonical-dx.mjs @@ -187,11 +187,12 @@ function isInsideMarkdownInlineCode(source, index) { return (prefix.match(/(? callBlocks[blockIndex] !== block, + ) + ) { + continue; + } + let valueIndex = bindingIndex + binding[0].length; while (/\s/.test(source[valueIndex] ?? "")) valueIndex += 1; const object = source[valueIndex] === "{" ? readTopLevelObject(source, valueIndex) : undefined; candidates.push({ - index: searchStart + (binding.index ?? 0), + index: bindingIndex, object, openIndex: object ? valueIndex : undefined, typed: isCanonicalTypeAnnotation(binding[1], configType), From 127e50bd43daea0bc02903fc435b858a1c9ac015 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:46:05 -0400 Subject: [PATCH 10/24] fix(v3): reject opaque lifecycle hook spreads (#224) --- scripts/check-canonical-dx.mjs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs index 1261c9561..272955afb 100644 --- a/scripts/check-canonical-dx.mjs +++ b/scripts/check-canonical-dx.mjs @@ -464,6 +464,25 @@ function inspectFactoryObject( match: factory, }); } + if (kind === "backend") { + const hooksProperty = object.topLevel.match(/\bhooks\s*:/); + if (hooksProperty?.index !== undefined) { + let hooksValueIndex = + openIndex + hooksProperty.index + hooksProperty[0].length; + while (/\s/.test(source[hooksValueIndex] ?? "")) hooksValueIndex += 1; + if (source[hooksValueIndex] === "{") { + const hooksObject = readTopLevelObject(source, hooksValueIndex); + if (hooksObject && /\.\.\./.test(hooksObject.topLevel)) { + failures.push({ + file, + line: lineAt(source, hooksValueIndex), + label: "backend hooks contain an unverifiable spread", + match: factory, + }); + } + } + } + } if ( kind === "backend" && /(?:^|[,{])\s*(?:on(?:Before|After)[A-Z][A-Za-z0-9]*|onError(?:[A-Z][A-Za-z0-9]*)?)\b(?=\s*(?:\??:|\(|,|}))/.test( From 1f8b74a4b5df321f8bb18c52fc54345c19ebd3bc Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:58:26 -0400 Subject: [PATCH 11/24] fix(v3): validate lifecycle hook bindings (#224) --- docs/content/docs/breaking-changes.mdx | 10 ++ docs/content/docs/plugins/kanban.mdx | 9 +- scripts/check-canonical-dx.mjs | 181 +++++++++++++++++++------ 3 files changed, 156 insertions(+), 44 deletions(-) diff --git a/docs/content/docs/breaking-changes.mdx b/docs/content/docs/breaking-changes.mdx index eef44518a..7b38635f6 100644 --- a/docs/content/docs/breaking-changes.mdx +++ b/docs/content/docs/breaking-changes.mdx @@ -205,6 +205,16 @@ kanbanBackendPlugin(resolveUser, searchUsers, kanbanHooks) After: ```ts +import type { BlogBackendHooks } from "@btst/stack/plugins/blog/api" +import type { KanbanBackendHooks } from "@btst/stack/plugins/kanban/api" + +const blogHooks: BlogBackendHooks = { + // Use the canonical Blog hook names from the tables below. +} +const kanbanHooks: KanbanBackendHooks = { + // Use the canonical Kanban hook names from the tables below. +} + blogBackendPlugin({ hooks: blogHooks }) commentsBackendPlugin({ allowEditing: false, diff --git a/docs/content/docs/plugins/kanban.mdx b/docs/content/docs/plugins/kanban.mdx index c8a29d271..22eeb374e 100644 --- a/docs/content/docs/plugins/kanban.mdx +++ b/docs/content/docs/plugins/kanban.mdx @@ -639,7 +639,14 @@ HTTP endpoints, request-scoped calls, and trusted calls all adapt these same operations. ```ts -import { kanbanBackendPlugin } from "@btst/stack/plugins/kanban/api" +import { + kanbanBackendPlugin, + type KanbanBackendHooks, +} from "@btst/stack/plugins/kanban/api" + +const hooks: KanbanBackendHooks = { + // Add canonical lifecycle callbacks here. +} const app = createBackendStack({ auth: serverAuth, diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs index 272955afb..f99370def 100644 --- a/scripts/check-canonical-dx.mjs +++ b/scripts/check-canonical-dx.mjs @@ -261,7 +261,7 @@ function recordLifecycleProperties( ) { for (const name of names) { const propertyPattern = new RegExp( - `(?:^|[,{])\\s*${escapeRegExp(name)}\\b(?=\\s*(?:\\??:|\\(|,|\\}))`, + `(?:^|[,{])\\s*(?:async\\s+)?\\*?\\s*(?:${escapeRegExp(name)}\\b|["']${escapeRegExp(name)}["'])(?=\\s*(?:\\??:|\\(|,|\\}))`, "gm", ); for (const match of objectSource.matchAll(propertyPattern)) { @@ -293,15 +293,17 @@ function readTopLevelObject(source, openIndex) { if (lineComment) { if (char === "\n") lineComment = false; - if (depth <= 1) topLevel += char === "\n" ? "\n" : " "; + topLevel += char === "\n" ? "\n" : " "; continue; } if (blockComment) { if (char === "*" && next === "/") { blockComment = false; + topLevel += " "; index += 1; + continue; } - if (depth <= 1) topLevel += char === "\n" ? "\n" : " "; + topLevel += char === "\n" ? "\n" : " "; continue; } if (quote) { @@ -309,24 +311,25 @@ function readTopLevelObject(source, openIndex) { else if (char === "\\") escaped = true; else if (char === quote) quote = undefined; if (depth <= 1 && roundDepth === 0 && squareDepth === 0) topLevel += char; - else if (char === "\n") topLevel += "\n"; + else topLevel += char === "\n" ? "\n" : " "; continue; } if (char === "/" && next === "/") { lineComment = true; - if (depth <= 1) topLevel += " "; + topLevel += " "; index += 1; continue; } if (char === "/" && next === "*") { blockComment = true; - if (depth <= 1) topLevel += " "; + topLevel += " "; index += 1; continue; } if (char === '"' || char === "'" || char === "`") { quote = char; - if (depth <= 1 && roundDepth === 0 && squareDepth === 0) topLevel += char; + topLevel += + depth <= 1 && roundDepth === 0 && squareDepth === 0 ? char : " "; continue; } if (char === "(") { @@ -452,6 +455,7 @@ function inspectFactoryObject( factory, kind, contextualLifecycleNames, + hookType, object, openIndex, reportIndex, @@ -464,28 +468,9 @@ function inspectFactoryObject( match: factory, }); } - if (kind === "backend") { - const hooksProperty = object.topLevel.match(/\bhooks\s*:/); - if (hooksProperty?.index !== undefined) { - let hooksValueIndex = - openIndex + hooksProperty.index + hooksProperty[0].length; - while (/\s/.test(source[hooksValueIndex] ?? "")) hooksValueIndex += 1; - if (source[hooksValueIndex] === "{") { - const hooksObject = readTopLevelObject(source, hooksValueIndex); - if (hooksObject && /\.\.\./.test(hooksObject.topLevel)) { - failures.push({ - file, - line: lineAt(source, hooksValueIndex), - label: "backend hooks contain an unverifiable spread", - match: factory, - }); - } - } - } - } if ( kind === "backend" && - /(?:^|[,{])\s*(?:on(?:Before|After)[A-Z][A-Za-z0-9]*|onError(?:[A-Z][A-Za-z0-9]*)?)\b(?=\s*(?:\??:|\(|,|}))/.test( + /(?:^|[,{])\s*(?:async\s+)?\*?\s*(?:(?:on(?:Before|After)[A-Z][A-Za-z0-9]*|onError(?:[A-Z][A-Za-z0-9]*)?)\b|["'](?:on(?:Before|After)[A-Z][A-Za-z0-9]*|onError(?:[A-Z][A-Za-z0-9]*)?)["'])(?=\s*(?:\??:|\(|,|}))/.test( object.topLevel, ) ) { @@ -497,39 +482,137 @@ function inspectFactoryObject( }); } if (kind === "backend" && contextualLifecycleNames.length > 0) { - const objectSource = source.slice(openIndex, object.end + 1); recordLifecycleProperties( failures, file, source, - objectSource, + object.topLevel, openIndex, factory, contextualLifecycleNames, ); + } + + if (kind === "backend" && hookType) { + let hooksReference; + const hooksProperty = object.topLevel.match( + /(?:^|[,{])\s*(?:hooks|["']hooks["'])\s*:/m, + ); + if (hooksProperty?.index !== undefined) { + let hooksValueIndex = + openIndex + hooksProperty.index + hooksProperty[0].length; + while (/\s/.test(source[hooksValueIndex] ?? "")) hooksValueIndex += 1; + if (source[hooksValueIndex] === "{") { + const hooksObject = readTopLevelObject(source, hooksValueIndex); + if (hooksObject && /\.\.\./.test(hooksObject.topLevel)) { + failures.push({ + file, + line: lineAt(source, hooksValueIndex), + label: "backend hooks contain an unverifiable spread", + match: factory, + }); + } + if ( + hooksObject && + /(?:^|[,{])\s*\[[^\]]*\]\s*:/m.test(hooksObject.topLevel) + ) { + failures.push({ + file, + line: lineAt(source, hooksValueIndex), + label: "backend hooks computed key cannot be verified", + match: factory, + }); + } + if (hooksObject && contextualLifecycleNames.length > 0) { + recordLifecycleProperties( + failures, + file, + source, + hooksObject.topLevel, + hooksValueIndex, + factory, + contextualLifecycleNames, + ); + } + } else if ( + !/^undefined\s*(?=[,}])/.test( + object.topLevel.slice(hooksValueIndex - openIndex), + ) + ) { + hooksReference = source + .slice(hooksValueIndex) + .match(/^([A-Za-z_$][\w$]*)\b/)?.[1]; + if (!hooksReference) { + failures.push({ + file, + line: lineAt(source, hooksValueIndex), + label: "backend hooks value cannot be verified", + match: factory, + }); + } + } + } else if (/(?:^|[,{])\s*(hooks)\s*(?=[,}])/m.test(object.topLevel)) { + hooksReference = "hooks"; + } + + if (/(?:^|[,{])\s*\[[^\]]*\]\s*:/m.test(object.topLevel)) { + failures.push({ + file, + line: lineAt(source, reportIndex), + label: "backend factory computed option key cannot be verified", + match: factory, + }); + } - const hooksReference = object.topLevel - .match(/\bhooks\s*:\s*([A-Za-z_$][\w$]*)|\b(hooks)\s*(?=[,}])/) - ?.slice(1) - .find(Boolean); if (hooksReference) { const binding = resolveIdentifierBinding( source, file, hooksReference, - undefined, + hookType, reportIndex, ); if (binding?.object && binding.openIndex !== undefined) { - recordLifecycleProperties( - failures, + if (/\.\.\./.test(binding.object.topLevel)) { + failures.push({ + file, + line: lineAt(source, binding.openIndex), + label: "backend hooks contain an unverifiable spread", + match: factory, + }); + } + if (/(?:^|[,{])\s*\[[^\]]*\]\s*:/m.test(binding.object.topLevel)) { + failures.push({ + file, + line: lineAt(source, binding.openIndex), + label: "backend hooks computed key cannot be verified", + match: factory, + }); + } + if (contextualLifecycleNames.length > 0) { + recordLifecycleProperties( + failures, + file, + source, + binding.object.topLevel, + binding.openIndex, + factory, + contextualLifecycleNames, + ); + } + } else if (!binding?.typed) { + if ( + /\.mdx?$/.test(file) && + isInsideMarkdownInlineCode(source, reportIndex) + ) { + return; + } + failures.push({ file, - source, - source.slice(binding.openIndex, binding.object.end + 1), - binding.openIndex, - factory, - contextualLifecycleNames, - ); + line: lineAt(source, reportIndex), + label: "backend hooks binding cannot be verified", + match: `${factory}(${hooksReference})`, + }); } } } @@ -556,6 +639,7 @@ function checkFactoryCalls( kind, configType, contextualLifecycleNames = [], + hookType, ) { const callPattern = new RegExp(`\\b${factory}[ \\t\\n]*\\(`, "g"); for (const match of source.matchAll(callPattern)) { @@ -621,6 +705,7 @@ function checkFactoryCalls( factory, kind, contextualLifecycleNames, + hookType, binding.object, binding.openIndex, binding.openIndex, @@ -652,6 +737,7 @@ function checkFactoryCalls( factory, kind, contextualLifecycleNames, + hookType, object, cursor, callIndex, @@ -677,11 +763,19 @@ function checkTypedHookObjects( (declaration.index ?? 0) + declaration[0].lastIndexOf("{"); const object = readTopLevelObject(source, openIndex); if (!object) continue; + if (/(?:^|[,{])\s*\[[^\]]*\]\s*:/m.test(object.topLevel)) { + failures.push({ + file, + line: lineAt(source, openIndex), + label: "backend hooks computed key cannot be verified", + match: factory, + }); + } recordLifecycleProperties( failures, file, source, - source.slice(openIndex, object.end + 1), + object.topLevel, openIndex, factory, names, @@ -823,6 +917,7 @@ for (const absolute of allFiles) { "backend", configType, contextualNames, + hookType, ); if (hookType) { checkTypedHookObjects( From 447751a75f5411977ee4f4fa0df19b6c3b84d78e Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:16:42 -0400 Subject: [PATCH 12/24] fix(v3): fail closed on opaque plugin config (#224) --- scripts/check-canonical-dx.mjs | 167 +++++++++++++++------------------ 1 file changed, 77 insertions(+), 90 deletions(-) diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs index f99370def..1aaf66c93 100644 --- a/scripts/check-canonical-dx.mjs +++ b/scripts/check-canonical-dx.mjs @@ -45,59 +45,49 @@ const backendPlugins = [ factory: "aiChatBackendPlugin", lifecycleSlug: "ai-chat", hookType: "AiChatBackendHooks", - configType: "AiChatBackendConfig", }, { factory: "blogBackendPlugin", lifecycleSlug: "blog", hookType: "BlogBackendHooks", - configType: "BlogBackendOptions", }, { factory: "cmsBackendPlugin", lifecycleSlug: "cms", hookType: "CMSBackendHooks", - configType: "CMSBackendConfig", }, { factory: "commentsBackendPlugin", lifecycleSlug: "comments", hookType: "CommentsBackendHooks", - configType: "CommentsBackendOptions", }, { factory: "formBuilderBackendPlugin", lifecycleSlug: "form-builder", hookType: "FormBuilderBackendHooks", - configType: "FormBuilderBackendConfig", }, { factory: "kanbanBackendPlugin", lifecycleSlug: "kanban", hookType: "KanbanBackendHooks", - configType: "KanbanBackendOptions", }, { factory: "mediaBackendPlugin", lifecycleSlug: "media", hookType: "MediaBackendHooks", - configType: "MediaBackendConfig", }, - { factory: "openApiBackendPlugin", configType: "OpenAPIOptions" }, + { factory: "openApiBackendPlugin" }, ]; const clientPlugins = [ - { factory: "aiChatClientPlugin", configType: "AiChatClientConfig" }, - { factory: "blogClientPlugin", configType: "BlogClientConfig" }, - { factory: "cmsClientPlugin", configType: "CMSClientConfig" }, - { factory: "commentsClientPlugin", configType: "CommentsClientConfig" }, - { - factory: "formBuilderClientPlugin", - configType: "FormBuilderClientConfig", - }, - { factory: "kanbanClientPlugin", configType: "KanbanClientConfig" }, - { factory: "mediaClientPlugin", configType: "MediaClientConfig" }, - { factory: "routeDocsClientPlugin", configType: "RouteDocsClientConfig" }, - { factory: "uiBuilderClientPlugin", configType: "UIBuilderClientConfig" }, + { factory: "aiChatClientPlugin" }, + { factory: "blogClientPlugin" }, + { factory: "cmsClientPlugin" }, + { factory: "commentsClientPlugin" }, + { factory: "formBuilderClientPlugin" }, + { factory: "kanbanClientPlugin" }, + { factory: "mediaClientPlugin" }, + { factory: "routeDocsClientPlugin" }, + { factory: "uiBuilderClientPlugin" }, ]; const pluginIds = [ "aiChat", @@ -142,6 +132,26 @@ function collectFiles(target) { return files; } +function readGuardSource(absolute, file) { + const source = readFileSync(absolute, "utf8"); + if (!/^packages\/stack\/registry\/[^/]+\.json$/.test(file)) return source; + + const registry = JSON.parse(source); + if (!Array.isArray(registry.files)) return source; + const encodedSources = []; + const metadata = { + ...registry, + files: registry.files.map(({ content, ...entry }) => { + if (typeof content === "string") encodedSources.push(content); + return entry; + }), + }; + const isolatedSources = encodedSources + .map((encodedSource) => `{\n${encodedSource}\n}`) + .join("\n"); + return `${JSON.stringify(metadata)}\n${isolatedSources}`; +} + function stripMigrationBlocks(source, file) { const startPattern = /^\s*\{\/\* canonical-dx-guard: migration:start reason="[^"]+" \*\/\}\s*$/; @@ -223,7 +233,7 @@ function scanLexicalState(source, index) { } else if (char === "{") blocks.push(cursor); else if (char === "}") blocks.pop(); } - return { blockComment, blocks, lineComment }; + return { blockComment, blocks, lineComment, quote }; } function isInsideCommentProse(source, index) { @@ -277,6 +287,12 @@ function recordLifecycleProperties( } } +function hasComputedProperty(objectSource) { + return /(?:^|[,{])\s*(?:(?:get|set|async)\s+)?\*?\s*\[[^\]]*\]\s*(?::|\()/m.test( + objectSource, + ); +} + function readTopLevelObject(source, openIndex) { let depth = 0; let roundDepth = 0; @@ -378,33 +394,37 @@ function readTopLevelObject(source, openIndex) { return undefined; } -function isCanonicalTypeAnnotation(annotation, configType) { - if (!annotation || !configType) return false; - const compact = annotation.replace(/\s+/g, ""); - return compact === configType || compact === `Readonly<${configType}>`; -} - -function resolveIdentifierBinding( - source, - file, - identifier, - configType, - callIndex, -) { +function resolveIdentifierBinding(source, file, identifier, callIndex) { const candidates = []; - const callBlocks = scanLexicalState(source, callIndex).blocks; const markdownFence = /\.mdx?$/.test(file) ? source.lastIndexOf("```", callIndex) : -1; - const searchStart = Math.max(0, markdownFence); + const searchStart = + markdownFence >= 0 ? source.indexOf("\n", markdownFence) + 1 : 0; + const scopedSource = source.slice(searchStart); + const callBlocks = scanLexicalState( + scopedSource, + callIndex - searchStart, + ).blocks; const beforeCall = source.slice(searchStart, callIndex); const variablePattern = new RegExp( - `\\b(?:const|let|var)\\s+${escapeRegExp(identifier)}\\b(?:\\s*:\\s*([^=;\\n]+))?\\s*=`, + `\\b(?:const|let|var)\\s+${escapeRegExp(identifier)}\\b(?:\\s*:\\s*[^=;\\n]+)?\\s*=`, "g", ); for (const binding of beforeCall.matchAll(variablePattern)) { const bindingIndex = searchStart + (binding.index ?? 0); - const bindingBlocks = scanLexicalState(source, bindingIndex).blocks; + const bindingState = scanLexicalState( + scopedSource, + bindingIndex - searchStart, + ); + if ( + bindingState.lineComment || + bindingState.blockComment || + bindingState.quote + ) { + continue; + } + const bindingBlocks = bindingState.blocks; if ( bindingBlocks.some( (block, blockIndex) => callBlocks[blockIndex] !== block, @@ -422,26 +442,6 @@ function resolveIdentifierBinding( index: bindingIndex, object, openIndex: object ? valueIndex : undefined, - typed: isCanonicalTypeAnnotation(binding[1], configType), - }); - } - - const functionPattern = - /(?:\bfunction(?:\s+[A-Za-z_$][\w$]*)?\s*|\b(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s*)?|(?:async\s+)?[A-Za-z_$][\w$]*\s*)\(([^()]*)\)\s*(?::[^={]+)?(?:=>\s*)?\{/g; - for (const signature of source.matchAll(functionPattern)) { - const signatureIndex = signature.index ?? 0; - if (signatureIndex >= callIndex) break; - const openIndex = signatureIndex + signature[0].lastIndexOf("{"); - const body = readTopLevelObject(source, openIndex); - if (!body || callIndex <= openIndex || callIndex >= body.end) continue; - const parameterPattern = new RegExp( - `(?:^|,)\\s*(?:\\.\\.\\.\\s*)?${escapeRegExp(identifier)}\\s*\\??(?:\\s*:\\s*([^,=]+))?(?=\\s*(?:,|$))`, - ); - const parameter = signature[1].match(parameterPattern); - if (!parameter) continue; - candidates.push({ - index: signatureIndex + signature[0].indexOf(signature[1]), - typed: isCanonicalTypeAnnotation(parameter[1], configType), }); } @@ -468,6 +468,14 @@ function inspectFactoryObject( match: factory, }); } + if (hasComputedProperty(object.topLevel)) { + failures.push({ + file, + line: lineAt(source, reportIndex), + label: `${kind} factory computed option key cannot be verified`, + match: factory, + }); + } if ( kind === "backend" && /(?:^|[,{])\s*(?:async\s+)?\*?\s*(?:(?:on(?:Before|After)[A-Z][A-Za-z0-9]*|onError(?:[A-Z][A-Za-z0-9]*)?)\b|["'](?:on(?:Before|After)[A-Z][A-Za-z0-9]*|onError(?:[A-Z][A-Za-z0-9]*)?)["'])(?=\s*(?:\??:|\(|,|}))/.test( @@ -512,10 +520,7 @@ function inspectFactoryObject( match: factory, }); } - if ( - hooksObject && - /(?:^|[,{])\s*\[[^\]]*\]\s*:/m.test(hooksObject.topLevel) - ) { + if (hooksObject && hasComputedProperty(hooksObject.topLevel)) { failures.push({ file, line: lineAt(source, hooksValueIndex), @@ -555,21 +560,11 @@ function inspectFactoryObject( hooksReference = "hooks"; } - if (/(?:^|[,{])\s*\[[^\]]*\]\s*:/m.test(object.topLevel)) { - failures.push({ - file, - line: lineAt(source, reportIndex), - label: "backend factory computed option key cannot be verified", - match: factory, - }); - } - if (hooksReference) { const binding = resolveIdentifierBinding( source, file, hooksReference, - hookType, reportIndex, ); if (binding?.object && binding.openIndex !== undefined) { @@ -581,7 +576,7 @@ function inspectFactoryObject( match: factory, }); } - if (/(?:^|[,{])\s*\[[^\]]*\]\s*:/m.test(binding.object.topLevel)) { + if (hasComputedProperty(binding.object.topLevel)) { failures.push({ file, line: lineAt(source, binding.openIndex), @@ -600,7 +595,7 @@ function inspectFactoryObject( contextualLifecycleNames, ); } - } else if (!binding?.typed) { + } else { if ( /\.mdx?$/.test(file) && isInsideMarkdownInlineCode(source, reportIndex) @@ -618,7 +613,7 @@ function inspectFactoryObject( } if ( kind === "client" && - /(?:^|[,{])\s*(?:apiBaseURL|apiBasePath|siteBaseURL|siteBasePath|queryClient|headers)\b(?=\s*(?:\??:|,|}))/.test( + /(?:^|[,{])\s*(?:(?:apiBaseURL|apiBasePath|siteBaseURL|siteBasePath|queryClient|headers|credentials)\b|["'](?:apiBaseURL|apiBasePath|siteBaseURL|siteBasePath|queryClient|headers|credentials)["'])(?=\s*(?:\??:|,|}))/.test( object.topLevel, ) ) { @@ -637,7 +632,6 @@ function checkFactoryCalls( source, factory, kind, - configType, contextualLifecycleNames = [], hookType, ) { @@ -674,13 +668,7 @@ function checkFactoryCalls( ? expression : undefined; const binding = identifier - ? resolveIdentifierBinding( - source, - file, - identifier, - configType, - callIndex, - ) + ? resolveIdentifierBinding(source, file, identifier, callIndex) : undefined; if (!binding) { if ( @@ -710,7 +698,7 @@ function checkFactoryCalls( binding.openIndex, binding.openIndex, ); - } else if (!binding.typed) { + } else { failures.push({ file, line: lineAt(source, callIndex), @@ -763,7 +751,7 @@ function checkTypedHookObjects( (declaration.index ?? 0) + declaration[0].lastIndexOf("{"); const object = readTopLevelObject(source, openIndex); if (!object) continue; - if (/(?:^|[,{])\s*\[[^\]]*\]\s*:/m.test(object.topLevel)) { + if (hasComputedProperty(object.topLevel)) { failures.push({ file, line: lineAt(source, openIndex), @@ -836,7 +824,7 @@ const failures = []; for (const absolute of allFiles) { const file = relative(root, absolute); - const source = stripMigrationBlocks(readFileSync(absolute, "utf8"), file); + const source = stripMigrationBlocks(readGuardSource(absolute, file), file); recordMatches( failures, @@ -907,7 +895,7 @@ for (const absolute of allFiles) { ), ); - for (const { factory, hookType, configType } of backendPlugins) { + for (const { factory, hookType } of backendPlugins) { const contextualNames = contextualNamesByFactory.get(factory) ?? []; checkFactoryCalls( failures, @@ -915,7 +903,6 @@ for (const absolute of allFiles) { source, factory, "backend", - configType, contextualNames, hookType, ); @@ -930,8 +917,8 @@ for (const absolute of allFiles) { ); } } - for (const { factory, configType } of clientPlugins) { - checkFactoryCalls(failures, file, source, factory, "client", configType); + for (const { factory } of clientPlugins) { + checkFactoryCalls(failures, file, source, factory, "client"); } for (const name of removedLifecycleNames) { recordMatches( From 08d38388b4704b068e74c39ec13a3a57805e84a8 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:56:29 -0400 Subject: [PATCH 13/24] fix(v3): close canonical guide gaps (#224) --- CONTRIBUTING.md | 212 +++++++++++++++++++++++++++---- scripts/check-canonical-dx.mjs | 225 ++++++++++++++++++++++++++++++--- 2 files changed, 396 insertions(+), 41 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index efcbe05f3..b5278c128 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -199,7 +199,7 @@ choices; `resolve(runtime)` binds the shared runtime. **Backend hook naming conventions:** ```typescript -// Authorization hooks (throw to deny) +// Pre-execution lifecycle hooks (throw to stop execution after authorization) onBeforeCreateItem, onBeforeUpdateItem, onBeforeDeleteItem, onBeforeListItems // Lifecycle hooks (called after success) onAfterCreateItem, onAfterUpdateItem, onAfterDeleteItem, onAfterListItems @@ -218,6 +218,7 @@ packages/stack/src/plugins/your-plugin/ ├── db.ts # createDbPlugin(...) — database schema definition ├── types.ts # Shared TypeScript types (no framework dependencies) ├── schemas.ts # Zod validation schemas for request bodies +├── permissions.ts # Shared authorization descriptor catalog ├── query-keys.ts # React Query key factory (imports from api/query-key-defs.ts) ├── client.css # Plugin CSS (Tailwind source directives, component styles) ├── style.css # Full styles including Tailwind @source directives @@ -225,6 +226,7 @@ packages/stack/src/plugins/your-plugin/ │ ├── plugin.ts # defineBackendPlugin, RouteKey type, prefetchForRoute factory │ ├── getters.ts # Pure DB read functions — no hooks, no HTTP context │ ├── mutations.ts # Server-side write functions — no hooks, no HTTP context +│ ├── operations.ts # Validation, authorization, lifecycle, and execution │ ├── query-key-defs.ts # Shared query key shapes (prevents SSG/SSR key drift) │ ├── serializers.ts # Convert Date fields → ISO strings before setQueryData │ └── index.ts # Barrel re-export of all public backend surface @@ -240,7 +242,10 @@ packages/stack/src/plugins/your-plugin/ └── list-page.internal.tsx # Actual page content (useSuspenseQuery inside) ``` -Not every file is required for a minimal plugin. Start with `db.ts`, `types.ts`, `api/plugin.ts`, and `client/plugin.tsx`. Add the rest as the plugin grows. +Not every file is required for a minimal plugin. A backend plugin starts with +`db.ts`, `types.ts`, `permissions.ts`, `api/operations.ts`, and `api/plugin.ts`; +a client half starts with `client/plugin.tsx`. Add the optional query, style, +and component files as the plugin grows. --- @@ -252,7 +257,7 @@ Define your data models using `createDbPlugin`. Field types: `string`, `boolean` // packages/stack/src/plugins/your-plugin/db.ts import { createDbPlugin } from "@btst/stack/plugins/api" -export const mySchema = createDbPlugin("your-plugin", { +export const mySchema = createDbPlugin("yourPlugin", { item: { modelName: "item", fields: { @@ -292,18 +297,37 @@ export const createItemSchema = z.object({ export const updateItemSchema = createItemSchema.partial() ``` +```typescript +// packages/stack/src/plugins/your-plugin/permissions.ts +import { definePermissions, permission } from "@btst/stack/authorization" + +export const myPermissions = definePermissions("yourPlugin", { + item: { + list: permission(), + create: permission(), + update: permission(), + delete: permission(), + }, +}) +``` + --- ### 3. Backend plugin -**`api/getters.ts`** — pure DB reads, safe for SSG and scripts. Authorization hooks are **not** called here — callers are responsible for access control. +**`api/getters.ts`** — pure DB reads, safe for SSG and scripts. Operation +validation, authorization, and lifecycle hooks are **not** called here; callers +own those concerns. ```typescript // packages/stack/src/plugins/your-plugin/api/getters.ts -import type { Adapter } from "@btst/stack/plugins/api" +import type { DBAdapter as Adapter } from "@btst/db" import type { Item } from "../types" -/** Returns all items sorted newest-first. Authorization hooks are NOT called. */ +/** + * Returns all items sorted newest-first. + * Operation validation, authorization, and lifecycle hooks are NOT called. + */ export async function listItems(adapter: Adapter): Promise { return adapter.findMany({ model: "item", @@ -311,7 +335,10 @@ export async function listItems(adapter: Adapter): Promise { }) as Promise } -/** Returns a single item by ID, or null. Authorization hooks are NOT called. */ +/** + * Returns a single item by ID, or null. + * Operation validation, authorization, and lifecycle hooks are NOT called. + */ export async function getItemById(adapter: Adapter, id: string): Promise { return adapter.findOne({ model: "item", @@ -324,25 +351,158 @@ export async function getItemById(adapter: Adapter, id: string): Promise { return adapter.create({ model: "item", - data: { ...input, published: false, createdAt: new Date(), updatedAt: new Date() }, + data: { + ...input, + published: input.published ?? false, + createdAt: new Date(), + updatedAt: new Date(), + }, + }) +} + +/** + * @remarks Operation validation, authorization, and lifecycle hooks are NOT + * called. The caller owns those concerns. + */ +export async function updateItem( + adapter: Adapter, + id: string, + input: UpdateItemInput, +): Promise { + return adapter.update({ + model: "item", + where: [{ field: "id", value: id }], + update: { ...input, updatedAt: new Date() }, + }) +} + +/** + * @remarks Operation validation, authorization, and lifecycle hooks are NOT + * called. The caller owns those concerns. + */ +export async function deleteItem(adapter: Adapter, id: string): Promise { + await adapter.delete({ + model: "item", + where: [{ field: "id", value: id }], }) } ``` +**`api/operations.ts`** — the single validated, authorized operation inventory +used by HTTP routes, request-scoped server calls, and trusted jobs: + +```typescript +// packages/stack/src/plugins/your-plugin/api/operations.ts +import type { DBAdapter as Adapter } from "@btst/db" +import { defineOperation } from "@btst/stack/plugins/api" +import { z } from "zod" +import { myPermissions } from "../permissions" +import { createItemSchema, updateItemSchema } from "../schemas" +import type { Item } from "../types" +import { listItems } from "./getters" +import { + createItem, + deleteItem, + updateItem, +} from "./mutations" + +export interface MyBackendHooks { + onBeforeCreateItem?: (data: unknown, ctx: { headers?: Headers }) => Promise | void + onAfterCreateItem?: (item: unknown, ctx: { headers?: Headers }) => Promise | void + onErrorCreateItem?: (error: Error, ctx: { headers?: Headers }) => Promise | void +} + +const updateOperationSchema = z.object({ + id: z.string(), + data: updateItemSchema, +}) +const itemIdSchema = z.object({ id: z.string() }) + +function serializeItem(item: Item) { + return { + ...item, + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + } +} + +const hookContext = (request?: Request) => ({ + ...(request ? { headers: request.headers } : {}), +}) + +export function createMyOperations( + adapter: Adapter, + hooks?: MyBackendHooks, +) { + const listItemsOperation = defineOperation({ + input: z.object({}), + permission: myPermissions.item.list, + facts: () => undefined, + execute: async () => (await listItems(adapter)).map(serializeItem), + }) + + const createItemOperation = defineOperation({ + input: createItemSchema, + permission: myPermissions.item.create, + facts: () => undefined, + before: async ({ input, request }) => { + await hooks?.onBeforeCreateItem?.(input, hookContext(request)) + }, + execute: async ({ input }) => serializeItem(await createItem(adapter, input)), + after: async ({ result, request }) => { + await hooks?.onAfterCreateItem?.(result, hookContext(request)) + }, + onError: async ({ error, request }) => { + const cause = error instanceof Error ? error : new Error("Create item failed") + await hooks?.onErrorCreateItem?.(cause, hookContext(request)) + }, + }) + + const updateItemOperation = defineOperation({ + input: updateOperationSchema, + permission: myPermissions.item.update, + facts: () => undefined, + execute: async ({ input }) => { + const item = await updateItem(adapter, input.id, input.data) + if (!item) throw new Error("Item not found") + return serializeItem(item) + }, + }) + + const deleteItemOperation = defineOperation({ + input: itemIdSchema, + permission: myPermissions.item.delete, + facts: () => undefined, + execute: async ({ input }) => { + await deleteItem(adapter, input.id) + return { success: true } as const + }, + }) + + return { + listItems: listItemsOperation, + createItem: createItemOperation, + updateItem: updateItemOperation, + deleteItem: deleteItemOperation, + } as const +} +``` + **`api/plugin.ts`** — the main backend plugin definition: ```typescript @@ -350,13 +510,7 @@ export async function createItem(adapter: Adapter, input: CreateItemInput): Prom import { defineBackendPlugin, createEndpoint } from "@btst/stack/plugins/api" import { mySchema } from "../db" import { createItemSchema, updateItemSchema } from "../schemas" -import { createMyOperations } from "./operations" - -export interface MyBackendHooks { - onBeforeCreateItem?: (data: unknown, ctx: { headers: Headers }) => Promise | void - onAfterCreateItem?: (item: unknown, ctx: { headers: Headers }) => Promise | void - onErrorCreateItem?: (error: Error, ctx: { headers: Headers }) => Promise | void -} +import { createMyOperations, type MyBackendHooks } from "./operations" export interface MyBackendPluginOptions { hooks?: MyBackendHooks @@ -400,8 +554,16 @@ export type MyApiRouter = ReturnType["routes" ```typescript // packages/stack/src/plugins/your-plugin/api/index.ts export * from "./plugin" +export { createMyOperations, type MyBackendHooks } from "./operations" export { listItems, getItemById } from "./getters" -export { createItem, type CreateItemInput } from "./mutations" +export { + createItem, + deleteItem, + updateItem, + type CreateItemInput, + type UpdateItemInput, +} from "./mutations" +export { myPermissions } from "../permissions" ``` --- @@ -817,7 +979,7 @@ npm install @btst/stack ## Hooks - + ``` Preview locally: @@ -879,11 +1041,11 @@ Before opening a pull request for a new plugin, verify every item: **Plugin implementation** -- [ ] Backend plugin: `name`, `dbPlugin`, and `routes` are all present -- [ ] Client plugin: `name` and `routes` are present +- [ ] Backend plugin: camelCase `id`, `dbPlugin`, `operations`, and operation-bound `routes` are present +- [ ] Client plugin: the matching camelCase `id` and `resolve(runtime)` returning `routes` are present - [ ] `api/getters.ts` contains only pure DB reads — no HTTP context, no lifecycle hooks -- [ ] `api/getters.ts` has JSDoc noting "Authorization hooks are NOT called" -- [ ] `api/mutations.ts` (if present) has JSDoc noting "Authorization hooks are NOT called" +- [ ] `api/getters.ts` has JSDoc noting operation validation, authorization, and lifecycle hooks are not called +- [ ] `api/mutations.ts` (if present) has the same JSDoc and says the caller owns those concerns - [ ] `api/index.ts` re-exports all public backend surface (getters, mutations, types, router type) - [ ] `api/query-key-defs.ts` defines shared key shapes imported by both `query-keys.ts` and `prefetchForRoute` - [ ] `api/serializers.ts` converts `Date` fields to ISO strings before `setQueryData` diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs index 1aaf66c93..ea2927d3d 100644 --- a/scripts/check-canonical-dx.mjs +++ b/scripts/check-canonical-dx.mjs @@ -197,15 +197,93 @@ function isInsideMarkdownInlineCode(source, index) { return (prefix.match(/(?" && source[cursor - 1] === "=") { + return true; + } + if ( + (source[cursor] === "<" || source[cursor] === ">") && + /\s/.test(source.slice(cursor + 1, index)) + ) { + return true; + } + if (/[[({,;:=!?&|+\-*%^~]/.test(source[cursor])) return true; + const keyword = source + .slice(0, cursor + 1) + .match(/(?:^|\W)([A-Za-z_$][\w$]*)$/)?.[1]; + return /^(?:await|case|delete|in|instanceof|new|return|throw|typeof|void|yield)$/.test( + keyword ?? "", + ); +} + function scanLexicalState(source, index) { let quote; let escaped = false; + let regex = false; + let regexCharacterClass = false; let lineComment = false; let blockComment = false; const blocks = []; + const templateFrames = []; + const parenthesisFrames = []; + const controlStatementClosures = new Set(); for (let cursor = 0; cursor < index; cursor += 1) { const char = source[cursor]; const next = source[cursor + 1]; + const templateFrame = templateFrames.at(-1); + if ( + !quote && + templateFrame?.expressionDepth === undefined && + templateFrame + ) { + if (escaped) { + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === "`") { + templateFrames.pop(); + continue; + } + if (char === "$" && next === "{") { + templateFrame.expressionDepth = 0; + cursor += 1; + } + continue; + } + if (regex) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === "[") regexCharacterClass = true; + else if (char === "]") regexCharacterClass = false; + else if (char === "/" && !regexCharacterClass) regex = false; + continue; + } if (lineComment) { if (char === "\n") lineComment = false; continue; @@ -223,17 +301,58 @@ function scanLexicalState(source, index) { else if (char === quote) quote = undefined; continue; } - if (char === '"' || char === "'" || char === "`") quote = char; - else if (char === "/" && next === "/") { + if (char === '"' || char === "'") quote = char; + else if (char === "`") { + templateFrames.push({ expressionDepth: undefined }); + escaped = false; + } else if (char === "/" && next === "/") { lineComment = true; cursor += 1; } else if (char === "/" && next === "*") { blockComment = true; cursor += 1; - } else if (char === "{") blocks.push(cursor); - else if (char === "}") blocks.pop(); + } else if ( + char === "/" && + canStartRegexLiteral(source, cursor, controlStatementClosures) + ) { + regex = true; + regexCharacterClass = false; + escaped = false; + } else if (char === "(") { + const keyword = source + .slice(0, cursor) + .match(/(?:^|\W)([A-Za-z_$][\w$]*)\s*$/)?.[1]; + parenthesisFrames.push( + /^(?:catch|for|if|switch|while|with)$/.test(keyword ?? ""), + ); + } else if (char === ")") { + if (parenthesisFrames.pop()) controlStatementClosures.add(cursor); + } else if (char === "{") { + if (templateFrame?.expressionDepth !== undefined) { + templateFrame.expressionDepth += 1; + } + blocks.push(cursor); + } else if (char === "}" && templateFrame?.expressionDepth === 0) { + templateFrame.expressionDepth = undefined; + } else if (char === "}") { + if (templateFrame?.expressionDepth !== undefined) { + templateFrame.expressionDepth -= 1; + } + blocks.pop(); + } } - return { blockComment, blocks, lineComment, quote }; + return { + blockComment, + blocks, + lineComment, + quote: + quote ?? + (regex ? "/" : undefined) ?? + (templateFrames.length > 0 && + templateFrames.at(-1)?.expressionDepth === undefined + ? "`" + : undefined), + }; } function isInsideCommentProse(source, index) { @@ -293,6 +412,41 @@ function hasComputedProperty(objectSource) { ); } +function skipLexicalTrivia(source, start) { + let cursor = start; + while (cursor < source.length) { + while (/\s/.test(source[cursor] ?? "")) cursor += 1; + if (source[cursor] === "/" && source[cursor + 1] === "/") { + const lineEnd = source.indexOf("\n", cursor + 2); + cursor = lineEnd >= 0 ? lineEnd + 1 : source.length; + continue; + } + if (source[cursor] === "/" && source[cursor + 1] === "*") { + const commentEnd = source.indexOf("*/", cursor + 2); + cursor = commentEnd >= 0 ? commentEnd + 2 : source.length; + continue; + } + break; + } + return cursor; +} + +function hasExecutableIdentifierReference(source, start, end, identifier) { + const pattern = new RegExp(`\\b${escapeRegExp(identifier)}\\b`, "g"); + for (const match of source.slice(start, end).matchAll(pattern)) { + const index = start + (match.index ?? 0); + const state = scanLexicalState(source, index); + if ( + !state.lineComment && + !state.blockComment && + (!state.quote || state.quote === "/" || state.quote === "`") + ) { + return true; + } + } + return false; +} + function readTopLevelObject(source, openIndex) { let depth = 0; let roundDepth = 0; @@ -397,10 +551,9 @@ function readTopLevelObject(source, openIndex) { function resolveIdentifierBinding(source, file, identifier, callIndex) { const candidates = []; const markdownFence = /\.mdx?$/.test(file) - ? source.lastIndexOf("```", callIndex) - : -1; - const searchStart = - markdownFence >= 0 ? source.indexOf("\n", markdownFence) + 1 : 0; + ? markdownFenceContentStart(source, callIndex) + : undefined; + const searchStart = markdownFence ?? 0; const scopedSource = source.slice(searchStart); const callBlocks = scanLexicalState( scopedSource, @@ -439,13 +592,32 @@ function resolveIdentifierBinding(source, file, identifier, callIndex) { ? readTopLevelObject(source, valueIndex) : undefined; candidates.push({ + blockDepth: bindingBlocks.length, index: bindingIndex, object, openIndex: object ? valueIndex : undefined, + referencedBeforeCall: object + ? hasExecutableIdentifierReference( + scopedSource, + object.end + 1 - searchStart, + callIndex - searchStart, + identifier, + ) + : false, }); } - return candidates.sort((left, right) => right.index - left.index)[0]; + const deepestBlock = Math.max( + ...candidates.map((candidate) => candidate.blockDepth), + ); + const scopedCandidates = candidates + .filter((candidate) => candidate.blockDepth === deepestBlock) + .sort((left, right) => right.index - left.index); + const binding = scopedCandidates[0]; + if (binding && candidates.length > 1) { + binding.referencedBeforeCall = true; + } + return binding; } function inspectFactoryObject( @@ -459,6 +631,7 @@ function inspectFactoryObject( object, openIndex, reportIndex, + resolutionIndex = reportIndex, ) { if (/\.\.\./.test(object.topLevel)) { failures.push({ @@ -565,9 +738,13 @@ function inspectFactoryObject( source, file, hooksReference, - reportIndex, + resolutionIndex, ); - if (binding?.object && binding.openIndex !== undefined) { + if ( + binding?.object && + binding.openIndex !== undefined && + !binding.referencedBeforeCall + ) { if (/\.\.\./.test(binding.object.topLevel)) { failures.push({ file, @@ -635,11 +812,22 @@ function checkFactoryCalls( contextualLifecycleNames = [], hookType, ) { - const callPattern = new RegExp(`\\b${factory}[ \\t\\n]*\\(`, "g"); + const callPattern = new RegExp(`\\b${factory}\\b`, "g"); for (const match of source.matchAll(callPattern)) { const callIndex = match.index ?? 0; - let cursor = callIndex + match[0].length; - while (/\s/.test(source[cursor] ?? "")) cursor += 1; + const fenceStart = /\.mdx?$/.test(file) + ? markdownFenceContentStart(source, callIndex) + : undefined; + const callState = + fenceStart === undefined + ? scanLexicalState(source, callIndex) + : scanLexicalState(source.slice(fenceStart), callIndex - fenceStart); + if (callState.lineComment || callState.blockComment) { + continue; + } + let cursor = skipLexicalTrivia(source, callIndex + match[0].length); + if (source[cursor] !== "(") continue; + cursor = skipLexicalTrivia(source, cursor + 1); if (source[cursor] === ")") continue; if (source[cursor] !== "{") { const expression = source @@ -685,7 +873,11 @@ function checkFactoryCalls( }); continue; } - if (binding.object && binding.openIndex !== undefined) { + if ( + binding.object && + binding.openIndex !== undefined && + !binding.referencedBeforeCall + ) { inspectFactoryObject( failures, file, @@ -697,6 +889,7 @@ function checkFactoryCalls( binding.object, binding.openIndex, binding.openIndex, + callIndex, ); } else { failures.push({ From acfe7cd770f639d38256ae0e59fc79b9a09dff50 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:18:05 -0400 Subject: [PATCH 14/24] fix(v3): harden canonical guard alias parsing (#224) --- scripts/check-canonical-dx.mjs | 400 +++++++++++++++++++++++++-------- 1 file changed, 301 insertions(+), 99 deletions(-) diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs index ea2927d3d..27927a495 100644 --- a/scripts/check-canonical-dx.mjs +++ b/scripts/check-canonical-dx.mjs @@ -206,7 +206,12 @@ function markdownFenceContentStart(source, index) { : source.indexOf("\n", openingFence.index) + 1; } -function canStartRegexLiteral(source, index, controlStatementClosures) { +function canStartRegexLiteral( + source, + index, + controlStatementClosures, + allowAdjacentLessThan = false, +) { if (source[index + 1] === "=") return false; let cursor = index - 1; while (/\s/.test(source[cursor] ?? "")) cursor -= 1; @@ -223,6 +228,10 @@ function canStartRegexLiteral(source, index, controlStatementClosures) { if (source[cursor] === ">" && source[cursor - 1] === "=") { return true; } + if (allowAdjacentLessThan && source[cursor] === "<") { + if (/^<\/[A-Za-z][\w.:-]*\s*>/.test(source.slice(cursor))) return false; + return true; + } if ( (source[cursor] === "<" || source[cursor] === ">") && /\s/.test(source.slice(cursor + 1, index)) @@ -453,9 +462,13 @@ function readTopLevelObject(source, openIndex) { let squareDepth = 0; let quote; let escaped = false; + let regex = false; + let regexCharacterClass = false; let lineComment = false; let blockComment = false; let topLevel = ""; + const parenthesisFrames = []; + const controlStatementClosures = new Set(); for (let index = openIndex; index < source.length; index += 1) { const char = source[index]; @@ -476,6 +489,15 @@ function readTopLevelObject(source, openIndex) { topLevel += char === "\n" ? "\n" : " "; continue; } + if (regex) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === "[") regexCharacterClass = true; + else if (char === "]") regexCharacterClass = false; + else if (char === "/" && !regexCharacterClass) regex = false; + topLevel += char === "\n" ? "\n" : " "; + continue; + } if (quote) { if (escaped) escaped = false; else if (char === "\\") escaped = true; @@ -502,13 +524,30 @@ function readTopLevelObject(source, openIndex) { depth <= 1 && roundDepth === 0 && squareDepth === 0 ? char : " "; continue; } + if ( + char === "/" && + canStartRegexLiteral(source, index, controlStatementClosures, true) + ) { + regex = true; + regexCharacterClass = false; + escaped = false; + topLevel += " "; + continue; + } if (char === "(") { + const keyword = source + .slice(0, index) + .match(/(?:^|\W)([A-Za-z_$][\w$]*)\s*$/)?.[1]; + parenthesisFrames.push( + /^(?:catch|for|if|switch|while|with)$/.test(keyword ?? ""), + ); if (depth <= 1 && roundDepth === 0 && squareDepth === 0) topLevel += char; else topLevel += " "; roundDepth += 1; continue; } if (char === ")") { + if (parenthesisFrames.pop()) controlStatementClosures.add(index); roundDepth = Math.max(0, roundDepth - 1); if (depth <= 1 && roundDepth === 0 && squareDepth === 0) topLevel += char; else topLevel += " "; @@ -536,7 +575,11 @@ function readTopLevelObject(source, openIndex) { depth -= 1; topLevel += depth <= 1 && roundDepth === 0 && squareDepth === 0 ? char : " "; - if (depth === 0) return { end: index, topLevel }; + if (depth === 0) { + const continuation = skipLexicalTrivia(source, index + 1); + if (source[continuation] === "/") return undefined; + return { end: index, topLevel }; + } continue; } topLevel += @@ -548,6 +591,150 @@ function readTopLevelObject(source, openIndex) { return undefined; } +function readNamedImportDeclaration(source, importIndex) { + let cursor = skipLexicalTrivia(source, importIndex + "import".length); + if (source[cursor] !== "{") return undefined; + const specifiersStart = cursor + 1; + let quote; + let escaped = false; + let lineComment = false; + let blockComment = false; + for (cursor = specifiersStart; cursor < source.length; cursor += 1) { + const char = source[cursor]; + const next = source[cursor + 1]; + if (lineComment) { + if (char === "\n") lineComment = false; + continue; + } + if (blockComment) { + if (char === "*" && next === "/") { + blockComment = false; + cursor += 1; + } + continue; + } + if (quote) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === quote) quote = undefined; + continue; + } + if (char === "/" && next === "/") { + lineComment = true; + cursor += 1; + continue; + } + if (char === "/" && next === "*") { + blockComment = true; + cursor += 1; + continue; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + if (char !== "}") continue; + + const specifiers = source.slice(specifiersStart, cursor); + cursor = skipLexicalTrivia(source, cursor + 1); + if (!/^from\b/.test(source.slice(cursor))) return undefined; + cursor = skipLexicalTrivia(source, cursor + "from".length); + const moduleQuote = source[cursor]; + if (moduleQuote !== '"' && moduleQuote !== "'") return undefined; + const moduleStart = cursor + 1; + cursor = moduleStart; + escaped = false; + for (; cursor < source.length; cursor += 1) { + if (escaped) escaped = false; + else if (source[cursor] === "\\") escaped = true; + else if (source[cursor] === moduleQuote) { + return { + moduleName: source.slice(moduleStart, cursor), + specifiers, + }; + } + } + return undefined; + } + return undefined; +} + +let namedImportCache; + +function registrySourceStart(source, file, index) { + if (!file.startsWith("packages/stack/registry/")) return undefined; + const boundary = source.lastIndexOf("\n}\n{\n", index); + if (boundary >= 0) return boundary + 3; + const firstSource = source.indexOf("\n{\n"); + return firstSource >= 0 && firstSource < index ? firstSource + 1 : undefined; +} + +function namedImportDeclarations(source, file) { + if (namedImportCache?.source === source && namedImportCache.file === file) { + return namedImportCache.declarations; + } + const declarations = []; + const importPattern = /\bimport\b/g; + for (const importMatch of source.matchAll(importPattern)) { + const declarationIndex = importMatch.index ?? 0; + const declaration = readNamedImportDeclaration(source, declarationIndex); + if (!declaration) continue; + const fenceStart = /\.mdx?$/.test(file) + ? markdownFenceContentStart(source, declarationIndex) + : undefined; + if (/\.mdx?$/.test(file) && fenceStart === undefined) continue; + const registryStart = registrySourceStart(source, file, declarationIndex); + const lexicalStart = fenceStart ?? registryStart ?? 0; + const importState = scanLexicalState( + source.slice(lexicalStart), + declarationIndex - lexicalStart, + ); + if ( + importState.lineComment || + importState.blockComment || + importState.quote || + importState.blocks.length !== (registryStart === undefined ? 0 : 1) + ) { + continue; + } + declarations.push({ ...declaration, fenceStart, registryStart }); + } + namedImportCache = { declarations, file, source }; + return declarations; +} + +function factoryLocalNames(source, file, factory) { + const names = [{ name: factory }]; + const trivia = String.raw`(?:\s|\/\*[\s\S]*?\*\/|\/\/[^\n]*(?:\n|$))*`; + const aliasPattern = new RegExp( + `\\b${escapeRegExp(factory)}\\b${trivia}as\\b${trivia}([A-Za-z_$][\\w$]*)\\b`, + "g", + ); + for (const importDeclaration of namedImportDeclarations(source, file)) { + const moduleName = importDeclaration.moduleName; + const pluginSlug = factory + .replace(/(?:Backend|Client)Plugin$/, "") + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .toLowerCase(); + if ( + moduleName !== "@btst/stack" && + moduleName !== "@btst/stack/plugins/api" && + moduleName !== `@btst/stack/plugins/${pluginSlug}` && + !moduleName.startsWith(`@btst/stack/plugins/${pluginSlug}/`) + ) { + continue; + } + for (const alias of importDeclaration.specifiers.matchAll(aliasPattern)) { + names.push({ + fenceStart: importDeclaration.fenceStart, + name: alias[1], + registryStart: importDeclaration.registryStart, + }); + } + } + return names; +} + function resolveIdentifierBinding(source, file, identifier, callIndex) { const candidates = []; const markdownFence = /\.mdx?$/.test(file) @@ -812,117 +999,132 @@ function checkFactoryCalls( contextualLifecycleNames = [], hookType, ) { - const callPattern = new RegExp(`\\b${factory}\\b`, "g"); - for (const match of source.matchAll(callPattern)) { - const callIndex = match.index ?? 0; - const fenceStart = /\.mdx?$/.test(file) - ? markdownFenceContentStart(source, callIndex) - : undefined; - const callState = - fenceStart === undefined - ? scanLexicalState(source, callIndex) - : scanLexicalState(source.slice(fenceStart), callIndex - fenceStart); - if (callState.lineComment || callState.blockComment) { - continue; - } - let cursor = skipLexicalTrivia(source, callIndex + match[0].length); - if (source[cursor] !== "(") continue; - cursor = skipLexicalTrivia(source, cursor + 1); - if (source[cursor] === ")") continue; - if (source[cursor] !== "{") { - const expression = source - .slice(cursor) - .match( - /^[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*(?:\s*\([^()\n]*\))?\s*(?=[,)])/, - )?.[0] - .trim(); - if (!expression) { - if ( - /\.mdx?$/.test(file) && - isInsideMarkdownInlineCode(source, callIndex) - ) { - continue; - } - if (isInsideCommentProse(source, callIndex)) continue; - failures.push({ - file, - line: lineAt(source, callIndex), - label: `${kind} factory options expression cannot be parsed`, - match: factory, - }); + for (const localFactory of factoryLocalNames(source, file, factory)) { + const callPattern = new RegExp( + `\\b${escapeRegExp(localFactory.name)}\\b`, + "g", + ); + for (const match of source.matchAll(callPattern)) { + const callIndex = match.index ?? 0; + const fenceStart = /\.mdx?$/.test(file) + ? markdownFenceContentStart(source, callIndex) + : undefined; + if ( + Object.hasOwn(localFactory, "fenceStart") && + fenceStart !== localFactory.fenceStart + ) { continue; } - const identifier = /^[A-Za-z_$][\w$]*$/.test(expression) - ? expression - : undefined; - const binding = identifier - ? resolveIdentifierBinding(source, file, identifier, callIndex) - : undefined; - if (!binding) { + if (Object.hasOwn(localFactory, "registryStart")) { + const registryStart = registrySourceStart(source, file, callIndex); + if (registryStart !== localFactory.registryStart) continue; + } + const callState = + fenceStart === undefined + ? scanLexicalState(source, callIndex) + : scanLexicalState(source.slice(fenceStart), callIndex - fenceStart); + if (callState.lineComment || callState.blockComment) { + continue; + } + let cursor = skipLexicalTrivia(source, callIndex + match[0].length); + if (source[cursor] !== "(") continue; + cursor = skipLexicalTrivia(source, cursor + 1); + if (source[cursor] === ")") continue; + if (source[cursor] !== "{") { + const expression = source + .slice(cursor) + .match( + /^[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*(?:\s*\([^()\n]*\))?\s*(?=[,)])/, + )?.[0] + .trim(); + if (!expression) { + if ( + /\.mdx?$/.test(file) && + isInsideMarkdownInlineCode(source, callIndex) + ) { + continue; + } + if (isInsideCommentProse(source, callIndex)) continue; + failures.push({ + file, + line: lineAt(source, callIndex), + label: `${kind} factory options expression cannot be parsed`, + match: factory, + }); + continue; + } + const identifier = /^[A-Za-z_$][\w$]*$/.test(expression) + ? expression + : undefined; + const binding = identifier + ? resolveIdentifierBinding(source, file, identifier, callIndex) + : undefined; + if (!binding) { + if ( + /\.mdx?$/.test(file) && + isInsideMarkdownInlineCode(source, callIndex) + ) { + continue; + } + failures.push({ + file, + line: lineAt(source, callIndex), + label: `${kind} factory options expression cannot be verified`, + match: `${factory}(${expression})`, + }); + continue; + } if ( - /\.mdx?$/.test(file) && - isInsideMarkdownInlineCode(source, callIndex) + binding.object && + binding.openIndex !== undefined && + !binding.referencedBeforeCall ) { - continue; + inspectFactoryObject( + failures, + file, + source, + factory, + kind, + contextualLifecycleNames, + hookType, + binding.object, + binding.openIndex, + binding.openIndex, + callIndex, + ); + } else { + failures.push({ + file, + line: lineAt(source, callIndex), + label: `${kind} factory options binding cannot be verified`, + match: `${factory}(${expression})`, + }); } - failures.push({ - file, - line: lineAt(source, callIndex), - label: `${kind} factory options expression cannot be verified`, - match: `${factory}(${expression})`, - }); continue; } - if ( - binding.object && - binding.openIndex !== undefined && - !binding.referencedBeforeCall - ) { - inspectFactoryObject( - failures, - file, - source, - factory, - kind, - contextualLifecycleNames, - hookType, - binding.object, - binding.openIndex, - binding.openIndex, - callIndex, - ); - } else { + const object = readTopLevelObject(source, cursor); + if (!object) { failures.push({ file, line: lineAt(source, callIndex), - label: `${kind} factory options binding cannot be verified`, - match: `${factory}(${expression})`, + label: `${kind} factory options object cannot be parsed`, + match: factory, }); + continue; } - continue; - } - const object = readTopLevelObject(source, cursor); - if (!object) { - failures.push({ + inspectFactoryObject( + failures, file, - line: lineAt(source, callIndex), - label: `${kind} factory options object cannot be parsed`, - match: factory, - }); - continue; + source, + factory, + kind, + contextualLifecycleNames, + hookType, + object, + cursor, + callIndex, + ); } - inspectFactoryObject( - failures, - file, - source, - factory, - kind, - contextualLifecycleNames, - hookType, - object, - cursor, - callIndex, - ); } } From b72afc748efd12d4cac833ea023f9e25cae68487 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:43:12 -0400 Subject: [PATCH 15/24] fix(v3): resolve canonical aliases lexically (#224) --- scripts/check-canonical-dx.mjs | 150 +++++++++++++++++++++++++++++---- 1 file changed, 132 insertions(+), 18 deletions(-) diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs index 27927a495..436da89e0 100644 --- a/scripts/check-canonical-dx.mjs +++ b/scripts/check-canonical-dx.mjs @@ -1,6 +1,7 @@ import { readFileSync, readdirSync, statSync } from "node:fs"; import { dirname, extname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import ts from "typescript"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const textExtensions = new Set([ @@ -206,6 +207,16 @@ function markdownFenceContentStart(source, index) { : source.indexOf("\n", openingFence.index) + 1; } +function opensControlStatement(source, openIndex) { + const prefix = source.slice(0, openIndex); + const match = prefix.match(/([A-Za-z_$][\w$]*)\s*$/); + if (!match) return false; + if (!/^(?:catch|for|if|switch|while|with)$/.test(match[1])) return false; + let cursor = prefix.length - match[0].length - 1; + while (/\s/.test(source[cursor] ?? "")) cursor -= 1; + return source[cursor] !== "."; +} + function canStartRegexLiteral( source, index, @@ -322,18 +333,18 @@ function scanLexicalState(source, index) { cursor += 1; } else if ( char === "/" && - canStartRegexLiteral(source, cursor, controlStatementClosures) + canStartRegexLiteral( + source, + cursor, + controlStatementClosures, + templateFrame?.expressionDepth !== undefined, + ) ) { regex = true; regexCharacterClass = false; escaped = false; } else if (char === "(") { - const keyword = source - .slice(0, cursor) - .match(/(?:^|\W)([A-Za-z_$][\w$]*)\s*$/)?.[1]; - parenthesisFrames.push( - /^(?:catch|for|if|switch|while|with)$/.test(keyword ?? ""), - ); + parenthesisFrames.push(opensControlStatement(source, cursor)); } else if (char === ")") { if (parenthesisFrames.pop()) controlStatementClosures.add(cursor); } else if (char === "{") { @@ -445,11 +456,7 @@ function hasExecutableIdentifierReference(source, start, end, identifier) { for (const match of source.slice(start, end).matchAll(pattern)) { const index = start + (match.index ?? 0); const state = scanLexicalState(source, index); - if ( - !state.lineComment && - !state.blockComment && - (!state.quote || state.quote === "/" || state.quote === "`") - ) { + if (!state.lineComment && !state.blockComment && !state.quote) { return true; } } @@ -535,12 +542,7 @@ function readTopLevelObject(source, openIndex) { continue; } if (char === "(") { - const keyword = source - .slice(0, index) - .match(/(?:^|\W)([A-Za-z_$][\w$]*)\s*$/)?.[1]; - parenthesisFrames.push( - /^(?:catch|for|if|switch|while|with)$/.test(keyword ?? ""), - ); + parenthesisFrames.push(opensControlStatement(source, index)); if (depth <= 1 && roundDepth === 0 && squareDepth === 0) topLevel += char; else topLevel += " "; roundDepth += 1; @@ -735,6 +737,112 @@ function factoryLocalNames(source, file, factory) { return names; } +let typeScriptScopeCache; + +function aliasScopeBounds(source, alias) { + if (alias.fenceStart !== undefined) { + const closingFence = source.indexOf("\n```", alias.fenceStart); + return { + end: closingFence < 0 ? source.length : closingFence, + start: alias.fenceStart, + }; + } + if (alias.registryStart !== undefined) { + const start = source.indexOf("\n", alias.registryStart) + 1; + const nextSource = source.indexOf("\n}\n{\n", start); + return { + end: + nextSource >= 0 + ? nextSource + : source.endsWith("\n}") + ? source.length - 2 + : source.length, + start, + }; + } + return { end: source.length, start: 0 }; +} + +function typeScriptScope(source, alias) { + const { end, start } = aliasScopeBounds(source, alias); + if ( + typeScriptScopeCache?.source === source && + typeScriptScopeCache.start === start && + typeScriptScopeCache.end === end + ) { + return typeScriptScopeCache; + } + const text = source.slice(start, end); + const fileName = "/canonical-dx-guard.tsx"; + const options = { + jsx: ts.JsxEmit.Preserve, + module: ts.ModuleKind.ESNext, + noLib: true, + noResolve: true, + target: ts.ScriptTarget.Latest, + }; + const sourceFile = ts.createSourceFile( + fileName, + text, + options.target, + true, + ts.ScriptKind.TSX, + ); + const host = { + fileExists: (candidate) => candidate === fileName, + getCanonicalFileName: (candidate) => candidate, + getCurrentDirectory: () => "/", + getDefaultLibFileName: () => "", + getDirectories: () => [], + getNewLine: () => "\n", + getSourceFile: (candidate) => + candidate === fileName ? sourceFile : undefined, + readFile: (candidate) => (candidate === fileName ? text : undefined), + useCaseSensitiveFileNames: () => true, + writeFile: () => {}, + }; + const program = ts.createProgram([fileName], options, host); + typeScriptScopeCache = { + checker: program.getTypeChecker(), + end, + source, + sourceFile, + start, + }; + return typeScriptScopeCache; +} + +function identifierAt(sourceFile, index, name) { + let identifier; + function visit(node) { + if ( + ts.isIdentifier(node) && + node.text === name && + node.getStart(sourceFile) === index + ) { + identifier = node; + return; + } + ts.forEachChild(node, visit); + } + visit(sourceFile); + return identifier; +} + +function isImportedAliasShadowed(source, alias, callIndex) { + const scope = typeScriptScope(source, alias); + const identifier = identifierAt( + scope.sourceFile, + callIndex - scope.start, + alias.name, + ); + const declarations = identifier + ? scope.checker.getSymbolAtLocation(identifier)?.declarations + : undefined; + if (!declarations || declarations.length === 0) return false; + return declarations.some((declaration) => !ts.isImportSpecifier(declaration)); +} + function resolveIdentifierBinding(source, file, identifier, callIndex) { const candidates = []; const markdownFence = /\.mdx?$/.test(file) @@ -1026,6 +1134,12 @@ function checkFactoryCalls( if (callState.lineComment || callState.blockComment) { continue; } + if ( + localFactory.name !== factory && + isImportedAliasShadowed(source, localFactory, callIndex) + ) { + continue; + } let cursor = skipLexicalTrivia(source, callIndex + match[0].length); if (source[cursor] !== "(") continue; cursor = skipLexicalTrivia(source, cursor + 1); From bbe5d4da7f462a0d06e2e50b4dfba582512fa9e0 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:46:55 -0400 Subject: [PATCH 16/24] fix(v3): bound canonical alias fences (#224) --- scripts/check-canonical-dx.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs index 436da89e0..4b86d95a1 100644 --- a/scripts/check-canonical-dx.mjs +++ b/scripts/check-canonical-dx.mjs @@ -741,9 +741,11 @@ let typeScriptScopeCache; function aliasScopeBounds(source, alias) { if (alias.fenceStart !== undefined) { - const closingFence = source.indexOf("\n```", alias.fenceStart); + const fencePattern = /^ {0,3}```[^\n]*$/gm; + fencePattern.lastIndex = alias.fenceStart; + const closingFence = fencePattern.exec(source); return { - end: closingFence < 0 ? source.length : closingFence, + end: closingFence?.index ?? source.length, start: alias.fenceStart, }; } From bc8b2b78d40dca826cf76eccccfb0c584c9132dc Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:49:24 -0400 Subject: [PATCH 17/24] docs(v3): document canonical example exports (#224) --- .../btst-backend-plugin-dev/REFERENCE.md | 3 +++ CONTRIBUTING.md | 27 ++++++++++++++++--- scripts/check-canonical-dx.mjs | 16 +++++++---- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/.agents/skills/btst-backend-plugin-dev/REFERENCE.md b/.agents/skills/btst-backend-plugin-dev/REFERENCE.md index 970538eda..f4f1e70e8 100644 --- a/.agents/skills/btst-backend-plugin-dev/REFERENCE.md +++ b/.agents/skills/btst-backend-plugin-dev/REFERENCE.md @@ -3,7 +3,9 @@ ## defineBackendPlugin shape (api/plugin.ts) ```typescript +/** Configuration accepted by `myBackendPlugin`. */ export interface MyBackendPluginOptions { + /** Lifecycle callbacks composed around plugin operations. */ hooks?: MyBackendHooks } @@ -25,6 +27,7 @@ export const myBackendPlugin = (options: MyBackendPluginOptions = {}) => }, }) +/** Inferred router contract imported by the client plugin. */ export type MyApiRouter = ReturnType< ReturnType["routes"] > diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b5278c128..d3a80e1a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,7 +119,9 @@ URL slugs may remain kebab-case. ```typescript import { defineBackendPlugin, createEndpoint } from "@btst/stack/plugins/api" +/** Configuration accepted by `myBackendPlugin`. */ export interface MyBackendPluginOptions { + /** Lifecycle callbacks composed around plugin operations. */ hooks?: MyBackendHooks } @@ -138,7 +140,7 @@ export const myBackendPlugin = (options: MyBackendPluginOptions = {}) => }, }) -// Export the inferred router type — the client plugin imports this for end-to-end type safety +/** Inferred router contract imported by the client plugin for end-to-end type safety. */ export type MyApiRouter = ReturnType["routes"]> ``` @@ -354,8 +356,21 @@ export async function getItemById(adapter: Adapter, id: string): Promise Promise | void + /** Runs after an item is created. */ onAfterCreateItem?: (item: unknown, ctx: { headers?: Headers }) => Promise | void + /** Runs when item creation fails. */ onErrorCreateItem?: (error: Error, ctx: { headers?: Headers }) => Promise | void } @@ -512,7 +531,9 @@ import { mySchema } from "../db" import { createItemSchema, updateItemSchema } from "../schemas" import { createMyOperations, type MyBackendHooks } from "./operations" +/** Configuration accepted by `myBackendPlugin`. */ export interface MyBackendPluginOptions { + /** Lifecycle callbacks composed around plugin operations. */ hooks?: MyBackendHooks } diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs index 4b86d95a1..dd6a39c71 100644 --- a/scripts/check-canonical-dx.mjs +++ b/scripts/check-canonical-dx.mjs @@ -198,8 +198,12 @@ function isInsideMarkdownInlineCode(source, index) { return (prefix.match(/(? Date: Sat, 29 Aug 2026 11:59:40 -0400 Subject: [PATCH 18/24] fix(v3): inspect canonical factory call syntax (#224) --- scripts/check-canonical-dx.mjs | 67 ++++++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs index dd6a39c71..32cf5846c 100644 --- a/scripts/check-canonical-dx.mjs +++ b/scripts/check-canonical-dx.mjs @@ -467,6 +467,14 @@ function hasExecutableIdentifierReference(source, start, end, identifier) { return false; } +function hasExecutableSpread(source) { + for (const spread of source.matchAll(/\.\.\./g)) { + const state = scanLexicalState(source, spread.index ?? 0); + if (!state.lineComment && !state.blockComment && !state.quote) return true; + } + return false; +} + function readTopLevelObject(source, openIndex) { let depth = 0; let roundDepth = 0; @@ -837,16 +845,39 @@ function identifierAt(sourceFile, index, name) { return identifier; } -function isImportedAliasShadowed(source, alias, callIndex) { - const scope = typeScriptScope(source, alias); +function factoryCall(source, factoryScope, callIndex) { + const scope = typeScriptScope(source, factoryScope); const identifier = identifierAt( scope.sourceFile, callIndex - scope.start, - alias.name, + factoryScope.name, ); - const declarations = identifier - ? scope.checker.getSymbolAtLocation(identifier)?.declarations - : undefined; + if (!identifier) return undefined; + let expression = identifier; + while ( + ts.isParenthesizedExpression(expression.parent) || + ts.isNonNullExpression(expression.parent) || + ts.isAsExpression(expression.parent) || + ts.isTypeAssertionExpression(expression.parent) || + ts.isSatisfiesExpression(expression.parent) || + (ts.isBinaryExpression(expression.parent) && + expression.parent.operatorToken.kind === ts.SyntaxKind.CommaToken && + expression.parent.right === expression) + ) { + expression = expression.parent; + } + const call = expression.parent; + if (!ts.isCallExpression(call) || call.expression !== expression) { + return undefined; + } + return { + declarations: scope.checker.getSymbolAtLocation(identifier)?.declarations, + openIndex: scope.start + call.arguments.pos - 1, + }; +} + +function isFactoryNameShadowed(call) { + const declarations = call.declarations; if (!declarations || declarations.length === 0) return false; return declarations.some((declaration) => !ts.isImportSpecifier(declaration)); } @@ -936,7 +967,7 @@ function inspectFactoryObject( reportIndex, resolutionIndex = reportIndex, ) { - if (/\.\.\./.test(object.topLevel)) { + if (hasExecutableSpread(object.topLevel)) { failures.push({ file, line: lineAt(source, reportIndex), @@ -988,7 +1019,7 @@ function inspectFactoryObject( while (/\s/.test(source[hooksValueIndex] ?? "")) hooksValueIndex += 1; if (source[hooksValueIndex] === "{") { const hooksObject = readTopLevelObject(source, hooksValueIndex); - if (hooksObject && /\.\.\./.test(hooksObject.topLevel)) { + if (hooksObject && hasExecutableSpread(hooksObject.topLevel)) { failures.push({ file, line: lineAt(source, hooksValueIndex), @@ -1048,7 +1079,7 @@ function inspectFactoryObject( binding.openIndex !== undefined && !binding.referencedBeforeCall ) { - if (/\.\.\./.test(binding.object.topLevel)) { + if (hasExecutableSpread(binding.object.topLevel)) { failures.push({ file, line: lineAt(source, binding.openIndex), @@ -1125,6 +1156,7 @@ function checkFactoryCalls( const fenceStart = /\.mdx?$/.test(file) ? markdownFenceContentStart(source, callIndex) : undefined; + const registryStart = registrySourceStart(source, file, callIndex); if ( Object.hasOwn(localFactory, "fenceStart") && fenceStart !== localFactory.fenceStart @@ -1132,7 +1164,6 @@ function checkFactoryCalls( continue; } if (Object.hasOwn(localFactory, "registryStart")) { - const registryStart = registrySourceStart(source, file, callIndex); if (registryStart !== localFactory.registryStart) continue; } const callState = @@ -1142,15 +1173,13 @@ function checkFactoryCalls( if (callState.lineComment || callState.blockComment) { continue; } - if ( - localFactory.name !== factory && - isImportedAliasShadowed(source, localFactory, callIndex) - ) { - continue; - } - let cursor = skipLexicalTrivia(source, callIndex + match[0].length); - if (source[cursor] !== "(") continue; - cursor = skipLexicalTrivia(source, cursor + 1); + const call = factoryCall( + source, + { fenceStart, name: localFactory.name, registryStart }, + callIndex, + ); + if (!call || isFactoryNameShadowed(call)) continue; + let cursor = skipLexicalTrivia(source, call.openIndex + 1); if (source[cursor] === ")") continue; if (source[cursor] !== "{") { const expression = source From 629e1ca4768715adeb2ae427a9d5ec7212c35b99 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:04:43 -0400 Subject: [PATCH 19/24] fix(v3): cover namespace factory calls (#224) --- scripts/check-canonical-dx.mjs | 160 +++++++++++++++++++++++++++------ 1 file changed, 132 insertions(+), 28 deletions(-) diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs index 32cf5846c..ceb1ccc19 100644 --- a/scripts/check-canonical-dx.mjs +++ b/scripts/check-canonical-dx.mjs @@ -717,6 +717,19 @@ function namedImportDeclarations(source, file) { return declarations; } +function factoryModuleMatches(moduleName, factory) { + const pluginSlug = factory + .replace(/(?:Backend|Client)Plugin$/, "") + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .toLowerCase(); + return ( + moduleName === "@btst/stack" || + moduleName === "@btst/stack/plugins/api" || + moduleName === `@btst/stack/plugins/${pluginSlug}` || + moduleName.startsWith(`@btst/stack/plugins/${pluginSlug}/`) + ); +} + function factoryLocalNames(source, file, factory) { const names = [{ name: factory }]; const trivia = String.raw`(?:\s|\/\*[\s\S]*?\*\/|\/\/[^\n]*(?:\n|$))*`; @@ -725,19 +738,7 @@ function factoryLocalNames(source, file, factory) { "g", ); for (const importDeclaration of namedImportDeclarations(source, file)) { - const moduleName = importDeclaration.moduleName; - const pluginSlug = factory - .replace(/(?:Backend|Client)Plugin$/, "") - .replace(/([a-z0-9])([A-Z])/g, "$1-$2") - .toLowerCase(); - if ( - moduleName !== "@btst/stack" && - moduleName !== "@btst/stack/plugins/api" && - moduleName !== `@btst/stack/plugins/${pluginSlug}` && - !moduleName.startsWith(`@btst/stack/plugins/${pluginSlug}/`) - ) { - continue; - } + if (!factoryModuleMatches(importDeclaration.moduleName, factory)) continue; for (const alias of importDeclaration.specifiers.matchAll(aliasPattern)) { names.push({ fenceStart: importDeclaration.fenceStart, @@ -751,6 +752,39 @@ function factoryLocalNames(source, file, factory) { let typeScriptScopeCache; +function factoryScriptKind(file, source, factoryScope, callIndex) { + const extension = extname(file); + if (/^\.(?:cts|mts|ts)$/.test(extension)) return ts.ScriptKind.TS; + if (extension === ".tsx") return ts.ScriptKind.TSX; + if (/^\.(?:cjs|js|mjs)$/.test(extension)) return ts.ScriptKind.JS; + if (extension === ".jsx") return ts.ScriptKind.JSX; + if (factoryScope.fenceStart !== undefined) { + const openingEnd = factoryScope.fenceStart - 1; + const openingStart = source.lastIndexOf("\n", openingEnd - 1) + 1; + const language = source + .slice(openingStart, openingEnd) + .match(/```\s*([A-Za-z0-9-]+)/)?.[1] + ?.toLowerCase(); + if (language === "ts" || language === "typescript") { + return ts.ScriptKind.TS; + } + if (language === "js" || language === "javascript") { + return ts.ScriptKind.JS; + } + if (language === "jsx") return ts.ScriptKind.JSX; + if (language === "tsx") return ts.ScriptKind.TSX; + } + const lexicalStart = + factoryScope.fenceStart ?? factoryScope.registryStart ?? 0; + const callPrefix = source.slice( + Math.max(lexicalStart, callIndex - 200), + callIndex, + ); + return /<[^<>\n]+>\s*$/.test(callPrefix) + ? ts.ScriptKind.TS + : ts.ScriptKind.TSX; +} + function aliasScopeBounds(source, alias) { if (alias.fenceStart !== undefined) { const closingFence = markdownFences(source.slice(alias.fenceStart)).next() @@ -784,7 +818,8 @@ function typeScriptScope(source, alias) { if ( typeScriptScopeCache?.source === source && typeScriptScopeCache.start === start && - typeScriptScopeCache.end === end + typeScriptScopeCache.end === end && + typeScriptScopeCache.scriptKind === alias.scriptKind ) { return typeScriptScopeCache; } @@ -802,7 +837,7 @@ function typeScriptScope(source, alias) { text, options.target, true, - ts.ScriptKind.TSX, + alias.scriptKind, ); const host = { fileExists: (candidate) => candidate === fileName, @@ -823,37 +858,93 @@ function typeScriptScope(source, alias) { end, source, sourceFile, + scriptKind: alias.scriptKind, start, }; return typeScriptScopeCache; } -function identifierAt(sourceFile, index, name) { - let identifier; +function factoryReferenceAt(sourceFile, index, name) { + let reference; function visit(node) { if ( - ts.isIdentifier(node) && + ((ts.isIdentifier(node) && node.getStart(sourceFile) === index) || + (ts.isStringLiteralLike(node) && + node.getStart(sourceFile) + 1 === index)) && node.text === name && - node.getStart(sourceFile) === index + !reference ) { - identifier = node; + reference = node; return; } ts.forEachChild(node, visit); } visit(sourceFile); - return identifier; + return reference; +} + +function namespaceFactoryAccess(scope, reference, factory) { + const parent = reference.parent; + const access = + ts.isIdentifier(reference) && + ts.isPropertyAccessExpression(parent) && + parent.name === reference + ? parent + : ts.isStringLiteralLike(reference) && + ts.isElementAccessExpression(parent) && + parent.argumentExpression === reference + ? parent + : undefined; + if (!access) return undefined; + let receiver = access.expression; + while ( + ts.isPropertyAccessExpression(receiver) || + ts.isElementAccessExpression(receiver) || + ts.isParenthesizedExpression(receiver) || + ts.isNonNullExpression(receiver) || + ts.isAsExpression(receiver) + ) { + receiver = receiver.expression; + } + if (!ts.isIdentifier(receiver)) return undefined; + const declarations = + scope.checker.getSymbolAtLocation(receiver)?.declarations ?? []; + const namespaceImports = declarations.filter((declaration) => { + if (!ts.isNamespaceImport(declaration)) return false; + const importDeclaration = declaration.parent.parent; + return ( + ts.isImportDeclaration(importDeclaration) && + ts.isStringLiteralLike(importDeclaration.moduleSpecifier) && + factoryModuleMatches(importDeclaration.moduleSpecifier.text, factory) + ); + }); + return namespaceImports.length > 0 + ? { declarations: namespaceImports, expression: access } + : undefined; } function factoryCall(source, factoryScope, callIndex) { const scope = typeScriptScope(source, factoryScope); - const identifier = identifierAt( + const reference = factoryReferenceAt( scope.sourceFile, callIndex - scope.start, factoryScope.name, ); - if (!identifier) return undefined; - let expression = identifier; + if (!reference) return undefined; + const namespaceAccess = namespaceFactoryAccess( + scope, + reference, + factoryScope.factory, + ); + if ( + !namespaceAccess && + (!ts.isIdentifier(reference) || + ts.isPropertyAccessExpression(reference.parent) || + ts.isElementAccessExpression(reference.parent)) + ) { + return undefined; + } + let expression = namespaceAccess?.expression ?? reference; while ( ts.isParenthesizedExpression(expression.parent) || ts.isNonNullExpression(expression.parent) || @@ -871,7 +962,9 @@ function factoryCall(source, factoryScope, callIndex) { return undefined; } return { - declarations: scope.checker.getSymbolAtLocation(identifier)?.declarations, + declarations: + namespaceAccess?.declarations ?? + scope.checker.getSymbolAtLocation(reference)?.declarations, openIndex: scope.start + call.arguments.pos - 1, }; } @@ -879,7 +972,10 @@ function factoryCall(source, factoryScope, callIndex) { function isFactoryNameShadowed(call) { const declarations = call.declarations; if (!declarations || declarations.length === 0) return false; - return declarations.some((declaration) => !ts.isImportSpecifier(declaration)); + return declarations.some( + (declaration) => + !ts.isImportSpecifier(declaration) && !ts.isNamespaceImport(declaration), + ); } function resolveIdentifierBinding(source, file, identifier, callIndex) { @@ -1173,11 +1269,19 @@ function checkFactoryCalls( if (callState.lineComment || callState.blockComment) { continue; } - const call = factoryCall( + const factoryScope = { + factory, + fenceStart, + name: localFactory.name, + registryStart, + }; + factoryScope.scriptKind = factoryScriptKind( + file, source, - { fenceStart, name: localFactory.name, registryStart }, + factoryScope, callIndex, ); + const call = factoryCall(source, factoryScope, callIndex); if (!call || isFactoryNameShadowed(call)) continue; let cursor = skipLexicalTrivia(source, call.openIndex + 1); if (source[cursor] === ")") continue; From faa1619e997fa8d055113f8d36c49b1385beaca5 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:11:09 -0400 Subject: [PATCH 20/24] refactor(v3): index canonical calls by scope (#224) --- scripts/check-canonical-dx.mjs | 417 +++++++++++++++++---------------- 1 file changed, 213 insertions(+), 204 deletions(-) diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs index ceb1ccc19..ce90bf035 100644 --- a/scripts/check-canonical-dx.mjs +++ b/scripts/check-canonical-dx.mjs @@ -752,80 +752,85 @@ function factoryLocalNames(source, file, factory) { let typeScriptScopeCache; -function factoryScriptKind(file, source, factoryScope, callIndex) { - const extension = extname(file); - if (/^\.(?:cts|mts|ts)$/.test(extension)) return ts.ScriptKind.TS; - if (extension === ".tsx") return ts.ScriptKind.TSX; - if (/^\.(?:cjs|js|mjs)$/.test(extension)) return ts.ScriptKind.JS; - if (extension === ".jsx") return ts.ScriptKind.JSX; - if (factoryScope.fenceStart !== undefined) { - const openingEnd = factoryScope.fenceStart - 1; - const openingStart = source.lastIndexOf("\n", openingEnd - 1) + 1; - const language = source - .slice(openingStart, openingEnd) - .match(/```\s*([A-Za-z0-9-]+)/)?.[1] - ?.toLowerCase(); - if (language === "ts" || language === "typescript") { - return ts.ScriptKind.TS; +function factorySourceScopes(source, file) { + if (/\.mdx?$/.test(file)) { + const fences = [...markdownFences(source)]; + const scopes = []; + for (let index = 0; index < fences.length; index += 2) { + const opening = fences[index]; + if (opening?.index === undefined) continue; + const fenceStart = source.indexOf("\n", opening.index) + 1; + const closing = fences[index + 1]; + scopes.push({ + end: closing?.index ?? source.length, + fenceStart, + language: opening[0].match(/```\s*([A-Za-z0-9-]+)/)?.[1]?.toLowerCase(), + start: fenceStart, + }); } - if (language === "js" || language === "javascript") { - return ts.ScriptKind.JS; + return scopes; + } + if (/^packages\/stack\/registry\/[^/]+\.json$/.test(file)) { + const scopes = []; + let registryStart = source.indexOf("\n{\n"); + registryStart = registryStart < 0 ? -1 : registryStart + 1; + while (registryStart >= 0) { + const start = source.indexOf("\n", registryStart) + 1; + const nextSource = source.indexOf("\n}\n{\n", start); + scopes.push({ + end: + nextSource >= 0 + ? nextSource + : source.endsWith("\n}") + ? source.length - 2 + : source.length, + registryStart, + start, + }); + if (nextSource < 0) break; + registryStart = nextSource + 3; } - if (language === "jsx") return ts.ScriptKind.JSX; - if (language === "tsx") return ts.ScriptKind.TSX; + return scopes; } - const lexicalStart = - factoryScope.fenceStart ?? factoryScope.registryStart ?? 0; - const callPrefix = source.slice( - Math.max(lexicalStart, callIndex - 200), - callIndex, - ); - return /<[^<>\n]+>\s*$/.test(callPrefix) - ? ts.ScriptKind.TS - : ts.ScriptKind.TSX; + return [{ end: source.length, start: 0 }]; } -function aliasScopeBounds(source, alias) { - if (alias.fenceStart !== undefined) { - const closingFence = markdownFences(source.slice(alias.fenceStart)).next() - .value; - return { - end: - closingFence?.index === undefined - ? source.length - : alias.fenceStart + closingFence.index, - start: alias.fenceStart, - }; +function factoryScriptKinds(file, sourceScope) { + const extension = extname(file); + if (/^\.(?:cts|mts|ts)$/.test(extension)) return [ts.ScriptKind.TS]; + if (extension === ".tsx") return [ts.ScriptKind.TSX]; + if (/^\.(?:cjs|js|mjs)$/.test(extension)) return [ts.ScriptKind.JS]; + if (extension === ".jsx") return [ts.ScriptKind.JSX]; + if (sourceScope.language === "ts" || sourceScope.language === "typescript") { + return [ts.ScriptKind.TS]; } - if (alias.registryStart !== undefined) { - const start = source.indexOf("\n", alias.registryStart) + 1; - const nextSource = source.indexOf("\n}\n{\n", start); - return { - end: - nextSource >= 0 - ? nextSource - : source.endsWith("\n}") - ? source.length - 2 - : source.length, - start, - }; + if (sourceScope.language === "tsx") return [ts.ScriptKind.TSX]; + if (sourceScope.language === "js" || sourceScope.language === "javascript") { + return [ts.ScriptKind.JS]; } - return { end: source.length, start: 0 }; + if (sourceScope.language === "jsx") return [ts.ScriptKind.JSX]; + return [ts.ScriptKind.TSX, ts.ScriptKind.TS]; } -function typeScriptScope(source, alias) { - const { end, start } = aliasScopeBounds(source, alias); - if ( - typeScriptScopeCache?.source === source && - typeScriptScopeCache.start === start && - typeScriptScopeCache.end === end && - typeScriptScopeCache.scriptKind === alias.scriptKind - ) { - return typeScriptScopeCache; +function typeScriptSourceScope(source, sourceScope, scriptKind) { + if (typeScriptScopeCache?.source !== source) { + typeScriptScopeCache = { scopes: new Map(), source }; } - const text = source.slice(start, end); - const fileName = "/canonical-dx-guard.tsx"; + const cacheKey = `${sourceScope.start}:${sourceScope.end}:${scriptKind}`; + const cached = typeScriptScopeCache.scopes.get(cacheKey); + if (cached) return cached; + const text = source.slice(sourceScope.start, sourceScope.end); + const extension = + scriptKind === ts.ScriptKind.TS + ? "ts" + : scriptKind === ts.ScriptKind.JS + ? "js" + : scriptKind === ts.ScriptKind.JSX + ? "jsx" + : "tsx"; + const fileName = `/canonical-dx-guard.${extension}`; const options = { + allowJs: true, jsx: ts.JsxEmit.Preserve, module: ts.ModuleKind.ESNext, noLib: true, @@ -837,7 +842,7 @@ function typeScriptScope(source, alias) { text, options.target, true, - alias.scriptKind, + scriptKind, ); const host = { fileExists: (candidate) => candidate === fileName, @@ -853,128 +858,163 @@ function typeScriptScope(source, alias) { writeFile: () => {}, }; const program = ts.createProgram([fileName], options, host); - typeScriptScopeCache = { + const callExpressions = []; + function visit(node) { + if (ts.isCallExpression(node)) callExpressions.push(node); + ts.forEachChild(node, visit); + } + visit(sourceFile); + const parsedScope = { + callExpressions, checker: program.getTypeChecker(), - end, - source, + parseErrors: sourceFile.parseDiagnostics?.length ?? 0, sourceFile, - scriptKind: alias.scriptKind, - start, + start: sourceScope.start, }; - return typeScriptScopeCache; + typeScriptScopeCache.scopes.set(cacheKey, parsedScope); + return parsedScope; } -function factoryReferenceAt(sourceFile, index, name) { - let reference; - function visit(node) { +function unwrapFactoryReference(expression) { + let reference = expression; + while (true) { if ( - ((ts.isIdentifier(node) && node.getStart(sourceFile) === index) || - (ts.isStringLiteralLike(node) && - node.getStart(sourceFile) + 1 === index)) && - node.text === name && - !reference + ts.isParenthesizedExpression(reference) || + ts.isNonNullExpression(reference) || + ts.isAsExpression(reference) || + ts.isTypeAssertionExpression(reference) || + ts.isSatisfiesExpression(reference) ) { - reference = node; - return; + reference = reference.expression; + continue; } - ts.forEachChild(node, visit); + if ( + ts.isBinaryExpression(reference) && + reference.operatorToken.kind === ts.SyntaxKind.CommaToken + ) { + reference = reference.right; + continue; + } + return reference; } - visit(sourceFile); - return reference; } -function namespaceFactoryAccess(scope, reference, factory) { - const parent = reference.parent; - const access = - ts.isIdentifier(reference) && - ts.isPropertyAccessExpression(parent) && - parent.name === reference - ? parent - : ts.isStringLiteralLike(reference) && - ts.isElementAccessExpression(parent) && - parent.argumentExpression === reference - ? parent - : undefined; - if (!access) return undefined; - let receiver = access.expression; +function importModuleName(declaration) { + let current = declaration; + while (current && !ts.isImportDeclaration(current)) current = current.parent; + return current && ts.isStringLiteralLike(current.moduleSpecifier) + ? current.moduleSpecifier.text + : undefined; +} + +function isAllowedFactoryBinding(declarations, factory, importKind) { + if (!declarations || declarations.length === 0) + return importKind === undefined; + return declarations.every((declaration) => { + if (importKind === "namespace" && !ts.isNamespaceImport(declaration)) { + return false; + } + if (importKind !== "namespace" && !ts.isImportSpecifier(declaration)) { + return false; + } + const moduleName = importModuleName(declaration); + return ( + moduleName !== undefined && factoryModuleMatches(moduleName, factory) + ); + }); +} + +function namespaceFactoryDeclarations(parsedScope, access, factory) { + let receiver = unwrapFactoryReference(access.expression); while ( ts.isPropertyAccessExpression(receiver) || - ts.isElementAccessExpression(receiver) || - ts.isParenthesizedExpression(receiver) || - ts.isNonNullExpression(receiver) || - ts.isAsExpression(receiver) + ts.isElementAccessExpression(receiver) ) { - receiver = receiver.expression; + receiver = unwrapFactoryReference(receiver.expression); } if (!ts.isIdentifier(receiver)) return undefined; const declarations = - scope.checker.getSymbolAtLocation(receiver)?.declarations ?? []; - const namespaceImports = declarations.filter((declaration) => { - if (!ts.isNamespaceImport(declaration)) return false; - const importDeclaration = declaration.parent.parent; - return ( - ts.isImportDeclaration(importDeclaration) && - ts.isStringLiteralLike(importDeclaration.moduleSpecifier) && - factoryModuleMatches(importDeclaration.moduleSpecifier.text, factory) - ); - }); - return namespaceImports.length > 0 - ? { declarations: namespaceImports, expression: access } + parsedScope.checker.getSymbolAtLocation(receiver)?.declarations; + return isAllowedFactoryBinding(declarations, factory, "namespace") + ? declarations : undefined; } -function factoryCall(source, factoryScope, callIndex) { - const scope = typeScriptScope(source, factoryScope); - const reference = factoryReferenceAt( - scope.sourceFile, - callIndex - scope.start, - factoryScope.name, - ); - if (!reference) return undefined; - const namespaceAccess = namespaceFactoryAccess( - scope, - reference, - factoryScope.factory, +function activeFactoryNames(localFactories, sourceScope) { + return new Set( + localFactories + .filter( + (localFactory) => + !Object.hasOwn(localFactory, "fenceStart") || + (localFactory.fenceStart === sourceScope.fenceStart && + localFactory.registryStart === sourceScope.registryStart), + ) + .map((localFactory) => localFactory.name), ); - if ( - !namespaceAccess && - (!ts.isIdentifier(reference) || - ts.isPropertyAccessExpression(reference.parent) || - ts.isElementAccessExpression(reference.parent)) - ) { - return undefined; - } - let expression = namespaceAccess?.expression ?? reference; - while ( - ts.isParenthesizedExpression(expression.parent) || - ts.isNonNullExpression(expression.parent) || - ts.isAsExpression(expression.parent) || - ts.isTypeAssertionExpression(expression.parent) || - ts.isSatisfiesExpression(expression.parent) || - (ts.isBinaryExpression(expression.parent) && - expression.parent.operatorToken.kind === ts.SyntaxKind.CommaToken && - expression.parent.right === expression) - ) { - expression = expression.parent; - } - const call = expression.parent; - if (!ts.isCallExpression(call) || call.expression !== expression) { - return undefined; - } - return { - declarations: - namespaceAccess?.declarations ?? - scope.checker.getSymbolAtLocation(reference)?.declarations, - openIndex: scope.start + call.arguments.pos - 1, - }; } -function isFactoryNameShadowed(call) { - const declarations = call.declarations; - if (!declarations || declarations.length === 0) return false; - return declarations.some( - (declaration) => - !ts.isImportSpecifier(declaration) && !ts.isNamespaceImport(declaration), +function factoryCallsInScope( + source, + file, + sourceScope, + localFactories, + factory, +) { + const names = activeFactoryNames(localFactories, sourceScope); + const parsedScopes = factoryScriptKinds(file, sourceScope).map((scriptKind) => + typeScriptSourceScope(source, sourceScope, scriptKind), + ); + const fewestParseErrors = Math.min( + ...parsedScopes.map((parsedScope) => parsedScope.parseErrors), + ); + const calls = new Map(); + for (const parsedScope of parsedScopes) { + if (parsedScope.parseErrors !== fewestParseErrors) continue; + for (const call of parsedScope.callExpressions) { + const reference = unwrapFactoryReference(call.expression); + let declarations; + let reportNode; + if (ts.isIdentifier(reference) && names.has(reference.text)) { + declarations = + parsedScope.checker.getSymbolAtLocation(reference)?.declarations; + if (!isAllowedFactoryBinding(declarations, factory)) continue; + reportNode = reference; + } else if ( + ts.isPropertyAccessExpression(reference) && + reference.name.text === factory + ) { + declarations = namespaceFactoryDeclarations( + parsedScope, + reference, + factory, + ); + if (!declarations) continue; + reportNode = reference.name; + } else if ( + ts.isElementAccessExpression(reference) && + ts.isStringLiteralLike(reference.argumentExpression) && + reference.argumentExpression.text === factory + ) { + declarations = namespaceFactoryDeclarations( + parsedScope, + reference, + factory, + ); + if (!declarations) continue; + reportNode = reference.argumentExpression; + } else { + continue; + } + const openIndex = parsedScope.start + call.arguments.pos - 1; + calls.set(openIndex, { + openIndex, + reportIndex: + parsedScope.start + reportNode.getStart(parsedScope.sourceFile), + }); + } + } + return [...calls.values()].sort( + (left, right) => left.openIndex - right.openIndex, ); } @@ -1242,47 +1282,16 @@ function checkFactoryCalls( contextualLifecycleNames = [], hookType, ) { - for (const localFactory of factoryLocalNames(source, file, factory)) { - const callPattern = new RegExp( - `\\b${escapeRegExp(localFactory.name)}\\b`, - "g", - ); - for (const match of source.matchAll(callPattern)) { - const callIndex = match.index ?? 0; - const fenceStart = /\.mdx?$/.test(file) - ? markdownFenceContentStart(source, callIndex) - : undefined; - const registryStart = registrySourceStart(source, file, callIndex); - if ( - Object.hasOwn(localFactory, "fenceStart") && - fenceStart !== localFactory.fenceStart - ) { - continue; - } - if (Object.hasOwn(localFactory, "registryStart")) { - if (registryStart !== localFactory.registryStart) continue; - } - const callState = - fenceStart === undefined - ? scanLexicalState(source, callIndex) - : scanLexicalState(source.slice(fenceStart), callIndex - fenceStart); - if (callState.lineComment || callState.blockComment) { - continue; - } - const factoryScope = { - factory, - fenceStart, - name: localFactory.name, - registryStart, - }; - factoryScope.scriptKind = factoryScriptKind( - file, - source, - factoryScope, - callIndex, - ); - const call = factoryCall(source, factoryScope, callIndex); - if (!call || isFactoryNameShadowed(call)) continue; + const localFactories = factoryLocalNames(source, file, factory); + for (const sourceScope of factorySourceScopes(source, file)) { + for (const call of factoryCallsInScope( + source, + file, + sourceScope, + localFactories, + factory, + )) { + const callIndex = call.reportIndex; let cursor = skipLexicalTrivia(source, call.openIndex + 1); if (source[cursor] === ")") continue; if (source[cursor] !== "{") { From a456d264be8bf0ffe86968bfe6062038c920ee7b Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:15:17 -0400 Subject: [PATCH 21/24] refactor(v3): bound canonical migration guard (#224) --- scripts/check-canonical-dx.mjs | 1205 +++----------------------------- 1 file changed, 102 insertions(+), 1103 deletions(-) diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs index ce90bf035..2a23353e3 100644 --- a/scripts/check-canonical-dx.mjs +++ b/scripts/check-canonical-dx.mjs @@ -1,7 +1,6 @@ import { readFileSync, readdirSync, statSync } from "node:fs"; import { dirname, extname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import ts from "typescript"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const textExtensions = new Set([ @@ -79,16 +78,16 @@ const backendPlugins = [ }, { factory: "openApiBackendPlugin" }, ]; -const clientPlugins = [ - { factory: "aiChatClientPlugin" }, - { factory: "blogClientPlugin" }, - { factory: "cmsClientPlugin" }, - { factory: "commentsClientPlugin" }, - { factory: "formBuilderClientPlugin" }, - { factory: "kanbanClientPlugin" }, - { factory: "mediaClientPlugin" }, - { factory: "routeDocsClientPlugin" }, - { factory: "uiBuilderClientPlugin" }, +const clientFactories = [ + "aiChatClientPlugin", + "blogClientPlugin", + "cmsClientPlugin", + "commentsClientPlugin", + "formBuilderClientPlugin", + "kanbanClientPlugin", + "mediaClientPlugin", + "routeDocsClientPlugin", + "uiBuilderClientPlugin", ]; const pluginIds = [ "aiChat", @@ -147,10 +146,7 @@ function readGuardSource(absolute, file) { return entry; }), }; - const isolatedSources = encodedSources - .map((encodedSource) => `{\n${encodedSource}\n}`) - .join("\n"); - return `${JSON.stringify(metadata)}\n${isolatedSources}`; + return `${JSON.stringify(metadata)}\n${encodedSources.join("\n")}`; } function stripMigrationBlocks(source, file) { @@ -192,206 +188,6 @@ function lineAt(source, index) { return source.slice(0, index).split("\n").length; } -function isInsideMarkdownInlineCode(source, index) { - const lineStart = source.lastIndexOf("\n", index - 1) + 1; - const prefix = source.slice(lineStart, index); - return (prefix.match(/(?" && source[cursor - 1] === "=") { - return true; - } - if (allowAdjacentLessThan && source[cursor] === "<") { - if (/^<\/[A-Za-z][\w.:-]*\s*>/.test(source.slice(cursor))) return false; - return true; - } - if ( - (source[cursor] === "<" || source[cursor] === ">") && - /\s/.test(source.slice(cursor + 1, index)) - ) { - return true; - } - if (/[[({,;:=!?&|+\-*%^~]/.test(source[cursor])) return true; - const keyword = source - .slice(0, cursor + 1) - .match(/(?:^|\W)([A-Za-z_$][\w$]*)$/)?.[1]; - return /^(?:await|case|delete|in|instanceof|new|return|throw|typeof|void|yield)$/.test( - keyword ?? "", - ); -} - -function scanLexicalState(source, index) { - let quote; - let escaped = false; - let regex = false; - let regexCharacterClass = false; - let lineComment = false; - let blockComment = false; - const blocks = []; - const templateFrames = []; - const parenthesisFrames = []; - const controlStatementClosures = new Set(); - for (let cursor = 0; cursor < index; cursor += 1) { - const char = source[cursor]; - const next = source[cursor + 1]; - const templateFrame = templateFrames.at(-1); - if ( - !quote && - templateFrame?.expressionDepth === undefined && - templateFrame - ) { - if (escaped) { - escaped = false; - continue; - } - if (char === "\\") { - escaped = true; - continue; - } - if (char === "`") { - templateFrames.pop(); - continue; - } - if (char === "$" && next === "{") { - templateFrame.expressionDepth = 0; - cursor += 1; - } - continue; - } - if (regex) { - if (escaped) escaped = false; - else if (char === "\\") escaped = true; - else if (char === "[") regexCharacterClass = true; - else if (char === "]") regexCharacterClass = false; - else if (char === "/" && !regexCharacterClass) regex = false; - continue; - } - if (lineComment) { - if (char === "\n") lineComment = false; - continue; - } - if (blockComment) { - if (char === "*" && next === "/") { - blockComment = false; - cursor += 1; - } - continue; - } - if (quote) { - if (escaped) escaped = false; - else if (char === "\\") escaped = true; - else if (char === quote) quote = undefined; - continue; - } - if (char === '"' || char === "'") quote = char; - else if (char === "`") { - templateFrames.push({ expressionDepth: undefined }); - escaped = false; - } else if (char === "/" && next === "/") { - lineComment = true; - cursor += 1; - } else if (char === "/" && next === "*") { - blockComment = true; - cursor += 1; - } else if ( - char === "/" && - canStartRegexLiteral( - source, - cursor, - controlStatementClosures, - templateFrame?.expressionDepth !== undefined, - ) - ) { - regex = true; - regexCharacterClass = false; - escaped = false; - } else if (char === "(") { - parenthesisFrames.push(opensControlStatement(source, cursor)); - } else if (char === ")") { - if (parenthesisFrames.pop()) controlStatementClosures.add(cursor); - } else if (char === "{") { - if (templateFrame?.expressionDepth !== undefined) { - templateFrame.expressionDepth += 1; - } - blocks.push(cursor); - } else if (char === "}" && templateFrame?.expressionDepth === 0) { - templateFrame.expressionDepth = undefined; - } else if (char === "}") { - if (templateFrame?.expressionDepth !== undefined) { - templateFrame.expressionDepth -= 1; - } - blocks.pop(); - } - } - return { - blockComment, - blocks, - lineComment, - quote: - quote ?? - (regex ? "/" : undefined) ?? - (templateFrames.length > 0 && - templateFrames.at(-1)?.expressionDepth === undefined - ? "`" - : undefined), - }; -} - -function isInsideCommentProse(source, index) { - const state = scanLexicalState(source, index); - if (state.lineComment || state.blockComment) return true; - - // Registry JSON stores source comments with encoded newlines and tabs. - const encodedLineStart = source.lastIndexOf("\\n", index); - if (encodedLineStart < 0) return false; - const encodedPrefix = source - .slice(encodedLineStart + 2, index) - .replaceAll("\\t", "\t"); - return /^\s*(?:\/\/|\*)/.test(encodedPrefix); -} - function recordMatches(failures, file, source, label, pattern) { for (const match of source.matchAll(pattern)) { failures.push({ @@ -414,7 +210,7 @@ function recordLifecycleProperties( ) { for (const name of names) { const propertyPattern = new RegExp( - `(?:^|[,{])\\s*(?:async\\s+)?\\*?\\s*(?:${escapeRegExp(name)}\\b|["']${escapeRegExp(name)}["'])(?=\\s*(?:\\??:|\\(|,|\\}))`, + `(?:^|[,{])\\s*(?:async\\s+)?\\*?\\s*${escapeRegExp(name)}\\b(?=\\s*(?:\\??:|\\(|,|\\}))`, "gm", ); for (const match of objectSource.matchAll(propertyPattern)) { @@ -430,64 +226,13 @@ function recordLifecycleProperties( } } -function hasComputedProperty(objectSource) { - return /(?:^|[,{])\s*(?:(?:get|set|async)\s+)?\*?\s*\[[^\]]*\]\s*(?::|\()/m.test( - objectSource, - ); -} - -function skipLexicalTrivia(source, start) { - let cursor = start; - while (cursor < source.length) { - while (/\s/.test(source[cursor] ?? "")) cursor += 1; - if (source[cursor] === "/" && source[cursor + 1] === "/") { - const lineEnd = source.indexOf("\n", cursor + 2); - cursor = lineEnd >= 0 ? lineEnd + 1 : source.length; - continue; - } - if (source[cursor] === "/" && source[cursor + 1] === "*") { - const commentEnd = source.indexOf("*/", cursor + 2); - cursor = commentEnd >= 0 ? commentEnd + 2 : source.length; - continue; - } - break; - } - return cursor; -} - -function hasExecutableIdentifierReference(source, start, end, identifier) { - const pattern = new RegExp(`\\b${escapeRegExp(identifier)}\\b`, "g"); - for (const match of source.slice(start, end).matchAll(pattern)) { - const index = start + (match.index ?? 0); - const state = scanLexicalState(source, index); - if (!state.lineComment && !state.blockComment && !state.quote) { - return true; - } - } - return false; -} - -function hasExecutableSpread(source) { - for (const spread of source.matchAll(/\.\.\./g)) { - const state = scanLexicalState(source, spread.index ?? 0); - if (!state.lineComment && !state.blockComment && !state.quote) return true; - } - return false; -} - function readTopLevelObject(source, openIndex) { let depth = 0; - let roundDepth = 0; - let squareDepth = 0; let quote; let escaped = false; - let regex = false; - let regexCharacterClass = false; let lineComment = false; let blockComment = false; let topLevel = ""; - const parenthesisFrames = []; - const controlStatementClosures = new Set(); for (let index = openIndex; index < source.length; index += 1) { const char = source[index]; @@ -495,899 +240,162 @@ function readTopLevelObject(source, openIndex) { if (lineComment) { if (char === "\n") lineComment = false; - topLevel += char === "\n" ? "\n" : " "; + if (depth <= 1) topLevel += char; continue; } if (blockComment) { if (char === "*" && next === "/") { blockComment = false; - topLevel += " "; index += 1; - continue; } - topLevel += char === "\n" ? "\n" : " "; - continue; - } - if (regex) { - if (escaped) escaped = false; - else if (char === "\\") escaped = true; - else if (char === "[") regexCharacterClass = true; - else if (char === "]") regexCharacterClass = false; - else if (char === "/" && !regexCharacterClass) regex = false; - topLevel += char === "\n" ? "\n" : " "; + if (depth <= 1) topLevel += char === "\n" ? "\n" : " "; continue; } if (quote) { if (escaped) escaped = false; else if (char === "\\") escaped = true; else if (char === quote) quote = undefined; - if (depth <= 1 && roundDepth === 0 && squareDepth === 0) topLevel += char; - else topLevel += char === "\n" ? "\n" : " "; + if (depth <= 1) topLevel += char; + else if (char === "\n") topLevel += "\n"; continue; } if (char === "/" && next === "/") { lineComment = true; - topLevel += " "; + if (depth <= 1) topLevel += " "; index += 1; continue; } if (char === "/" && next === "*") { blockComment = true; - topLevel += " "; + if (depth <= 1) topLevel += " "; index += 1; continue; } if (char === '"' || char === "'" || char === "`") { quote = char; - topLevel += - depth <= 1 && roundDepth === 0 && squareDepth === 0 ? char : " "; - continue; - } - if ( - char === "/" && - canStartRegexLiteral(source, index, controlStatementClosures, true) - ) { - regex = true; - regexCharacterClass = false; - escaped = false; - topLevel += " "; - continue; - } - if (char === "(") { - parenthesisFrames.push(opensControlStatement(source, index)); - if (depth <= 1 && roundDepth === 0 && squareDepth === 0) topLevel += char; - else topLevel += " "; - roundDepth += 1; - continue; - } - if (char === ")") { - if (parenthesisFrames.pop()) controlStatementClosures.add(index); - roundDepth = Math.max(0, roundDepth - 1); - if (depth <= 1 && roundDepth === 0 && squareDepth === 0) topLevel += char; - else topLevel += " "; - continue; - } - if (char === "[") { - if (depth <= 1 && roundDepth === 0 && squareDepth === 0) topLevel += char; - else topLevel += " "; - squareDepth += 1; - continue; - } - if (char === "]") { - squareDepth = Math.max(0, squareDepth - 1); - if (depth <= 1 && roundDepth === 0 && squareDepth === 0) topLevel += char; - else topLevel += " "; + if (depth <= 1) topLevel += char; continue; } if (char === "{") { depth += 1; - topLevel += - depth <= 1 && roundDepth === 0 && squareDepth === 0 ? char : " "; + topLevel += depth <= 1 ? char : " "; continue; } if (char === "}") { depth -= 1; - topLevel += - depth <= 1 && roundDepth === 0 && squareDepth === 0 ? char : " "; - if (depth === 0) { - const continuation = skipLexicalTrivia(source, index + 1); - if (source[continuation] === "/") return undefined; - return { end: index, topLevel }; - } + topLevel += depth <= 1 ? char : " "; + if (depth === 0) return { end: index, topLevel }; continue; } - topLevel += - (depth <= 1 && roundDepth === 0 && squareDepth === 0) || char === "\n" - ? char - : " "; + topLevel += depth <= 1 || char === "\n" ? char : " "; } return undefined; } -function readNamedImportDeclaration(source, importIndex) { - let cursor = skipLexicalTrivia(source, importIndex + "import".length); - if (source[cursor] !== "{") return undefined; - const specifiersStart = cursor + 1; - let quote; - let escaped = false; - let lineComment = false; - let blockComment = false; - for (cursor = specifiersStart; cursor < source.length; cursor += 1) { - const char = source[cursor]; - const next = source[cursor + 1]; - if (lineComment) { - if (char === "\n") lineComment = false; - continue; - } - if (blockComment) { - if (char === "*" && next === "/") { - blockComment = false; - cursor += 1; - } - continue; - } - if (quote) { - if (escaped) escaped = false; - else if (char === "\\") escaped = true; - else if (char === quote) quote = undefined; - continue; - } - if (char === "/" && next === "/") { - lineComment = true; - cursor += 1; - continue; - } - if (char === "/" && next === "*") { - blockComment = true; - cursor += 1; - continue; - } - if (char === '"' || char === "'") { - quote = char; - continue; - } - if (char !== "}") continue; - - const specifiers = source.slice(specifiersStart, cursor); - cursor = skipLexicalTrivia(source, cursor + 1); - if (!/^from\b/.test(source.slice(cursor))) return undefined; - cursor = skipLexicalTrivia(source, cursor + "from".length); - const moduleQuote = source[cursor]; - if (moduleQuote !== '"' && moduleQuote !== "'") return undefined; - const moduleStart = cursor + 1; - cursor = moduleStart; - escaped = false; - for (; cursor < source.length; cursor += 1) { - if (escaped) escaped = false; - else if (source[cursor] === "\\") escaped = true; - else if (source[cursor] === moduleQuote) { - return { - moduleName: source.slice(moduleStart, cursor), - specifiers, - }; - } - } - return undefined; - } - return undefined; -} - -let namedImportCache; - -function registrySourceStart(source, file, index) { - if (!file.startsWith("packages/stack/registry/")) return undefined; - const boundary = source.lastIndexOf("\n}\n{\n", index); - if (boundary >= 0) return boundary + 3; - const firstSource = source.indexOf("\n{\n"); - return firstSource >= 0 && firstSource < index ? firstSource + 1 : undefined; -} - -function namedImportDeclarations(source, file) { - if (namedImportCache?.source === source && namedImportCache.file === file) { - return namedImportCache.declarations; - } - const declarations = []; - const importPattern = /\bimport\b/g; - for (const importMatch of source.matchAll(importPattern)) { - const declarationIndex = importMatch.index ?? 0; - const declaration = readNamedImportDeclaration(source, declarationIndex); - if (!declaration) continue; - const fenceStart = /\.mdx?$/.test(file) - ? markdownFenceContentStart(source, declarationIndex) - : undefined; - if (/\.mdx?$/.test(file) && fenceStart === undefined) continue; - const registryStart = registrySourceStart(source, file, declarationIndex); - const lexicalStart = fenceStart ?? registryStart ?? 0; - const importState = scanLexicalState( - source.slice(lexicalStart), - declarationIndex - lexicalStart, - ); - if ( - importState.lineComment || - importState.blockComment || - importState.quote || - importState.blocks.length !== (registryStart === undefined ? 0 : 1) - ) { - continue; - } - declarations.push({ ...declaration, fenceStart, registryStart }); - } - namedImportCache = { declarations, file, source }; - return declarations; -} - -function factoryModuleMatches(moduleName, factory) { - const pluginSlug = factory - .replace(/(?:Backend|Client)Plugin$/, "") - .replace(/([a-z0-9])([A-Z])/g, "$1-$2") - .toLowerCase(); - return ( - moduleName === "@btst/stack" || - moduleName === "@btst/stack/plugins/api" || - moduleName === `@btst/stack/plugins/${pluginSlug}` || - moduleName.startsWith(`@btst/stack/plugins/${pluginSlug}/`) - ); -} - -function factoryLocalNames(source, file, factory) { - const names = [{ name: factory }]; - const trivia = String.raw`(?:\s|\/\*[\s\S]*?\*\/|\/\/[^\n]*(?:\n|$))*`; - const aliasPattern = new RegExp( - `\\b${escapeRegExp(factory)}\\b${trivia}as\\b${trivia}([A-Za-z_$][\\w$]*)\\b`, - "g", - ); - for (const importDeclaration of namedImportDeclarations(source, file)) { - if (!factoryModuleMatches(importDeclaration.moduleName, factory)) continue; - for (const alias of importDeclaration.specifiers.matchAll(aliasPattern)) { - names.push({ - fenceStart: importDeclaration.fenceStart, - name: alias[1], - registryStart: importDeclaration.registryStart, - }); - } - } - return names; -} - -let typeScriptScopeCache; - -function factorySourceScopes(source, file) { - if (/\.mdx?$/.test(file)) { - const fences = [...markdownFences(source)]; - const scopes = []; - for (let index = 0; index < fences.length; index += 2) { - const opening = fences[index]; - if (opening?.index === undefined) continue; - const fenceStart = source.indexOf("\n", opening.index) + 1; - const closing = fences[index + 1]; - scopes.push({ - end: closing?.index ?? source.length, - fenceStart, - language: opening[0].match(/```\s*([A-Za-z0-9-]+)/)?.[1]?.toLowerCase(), - start: fenceStart, - }); - } - return scopes; - } - if (/^packages\/stack\/registry\/[^/]+\.json$/.test(file)) { - const scopes = []; - let registryStart = source.indexOf("\n{\n"); - registryStart = registryStart < 0 ? -1 : registryStart + 1; - while (registryStart >= 0) { - const start = source.indexOf("\n", registryStart) + 1; - const nextSource = source.indexOf("\n}\n{\n", start); - scopes.push({ - end: - nextSource >= 0 - ? nextSource - : source.endsWith("\n}") - ? source.length - 2 - : source.length, - registryStart, - start, - }); - if (nextSource < 0) break; - registryStart = nextSource + 3; - } - return scopes; - } - return [{ end: source.length, start: 0 }]; -} - -function factoryScriptKinds(file, sourceScope) { - const extension = extname(file); - if (/^\.(?:cts|mts|ts)$/.test(extension)) return [ts.ScriptKind.TS]; - if (extension === ".tsx") return [ts.ScriptKind.TSX]; - if (/^\.(?:cjs|js|mjs)$/.test(extension)) return [ts.ScriptKind.JS]; - if (extension === ".jsx") return [ts.ScriptKind.JSX]; - if (sourceScope.language === "ts" || sourceScope.language === "typescript") { - return [ts.ScriptKind.TS]; - } - if (sourceScope.language === "tsx") return [ts.ScriptKind.TSX]; - if (sourceScope.language === "js" || sourceScope.language === "javascript") { - return [ts.ScriptKind.JS]; - } - if (sourceScope.language === "jsx") return [ts.ScriptKind.JSX]; - return [ts.ScriptKind.TSX, ts.ScriptKind.TS]; -} - -function typeScriptSourceScope(source, sourceScope, scriptKind) { - if (typeScriptScopeCache?.source !== source) { - typeScriptScopeCache = { scopes: new Map(), source }; - } - const cacheKey = `${sourceScope.start}:${sourceScope.end}:${scriptKind}`; - const cached = typeScriptScopeCache.scopes.get(cacheKey); - if (cached) return cached; - const text = source.slice(sourceScope.start, sourceScope.end); - const extension = - scriptKind === ts.ScriptKind.TS - ? "ts" - : scriptKind === ts.ScriptKind.JS - ? "js" - : scriptKind === ts.ScriptKind.JSX - ? "jsx" - : "tsx"; - const fileName = `/canonical-dx-guard.${extension}`; - const options = { - allowJs: true, - jsx: ts.JsxEmit.Preserve, - module: ts.ModuleKind.ESNext, - noLib: true, - noResolve: true, - target: ts.ScriptTarget.Latest, - }; - const sourceFile = ts.createSourceFile( - fileName, - text, - options.target, - true, - scriptKind, - ); - const host = { - fileExists: (candidate) => candidate === fileName, - getCanonicalFileName: (candidate) => candidate, - getCurrentDirectory: () => "/", - getDefaultLibFileName: () => "", - getDirectories: () => [], - getNewLine: () => "\n", - getSourceFile: (candidate) => - candidate === fileName ? sourceFile : undefined, - readFile: (candidate) => (candidate === fileName ? text : undefined), - useCaseSensitiveFileNames: () => true, - writeFile: () => {}, - }; - const program = ts.createProgram([fileName], options, host); - const callExpressions = []; - function visit(node) { - if (ts.isCallExpression(node)) callExpressions.push(node); - ts.forEachChild(node, visit); - } - visit(sourceFile); - const parsedScope = { - callExpressions, - checker: program.getTypeChecker(), - parseErrors: sourceFile.parseDiagnostics?.length ?? 0, - sourceFile, - start: sourceScope.start, - }; - typeScriptScopeCache.scopes.set(cacheKey, parsedScope); - return parsedScope; -} - -function unwrapFactoryReference(expression) { - let reference = expression; - while (true) { - if ( - ts.isParenthesizedExpression(reference) || - ts.isNonNullExpression(reference) || - ts.isAsExpression(reference) || - ts.isTypeAssertionExpression(reference) || - ts.isSatisfiesExpression(reference) - ) { - reference = reference.expression; - continue; - } - if ( - ts.isBinaryExpression(reference) && - reference.operatorToken.kind === ts.SyntaxKind.CommaToken - ) { - reference = reference.right; - continue; - } - return reference; - } -} - -function importModuleName(declaration) { - let current = declaration; - while (current && !ts.isImportDeclaration(current)) current = current.parent; - return current && ts.isStringLiteralLike(current.moduleSpecifier) - ? current.moduleSpecifier.text - : undefined; -} - -function isAllowedFactoryBinding(declarations, factory, importKind) { - if (!declarations || declarations.length === 0) - return importKind === undefined; - return declarations.every((declaration) => { - if (importKind === "namespace" && !ts.isNamespaceImport(declaration)) { - return false; - } - if (importKind !== "namespace" && !ts.isImportSpecifier(declaration)) { - return false; - } - const moduleName = importModuleName(declaration); - return ( - moduleName !== undefined && factoryModuleMatches(moduleName, factory) - ); - }); -} - -function namespaceFactoryDeclarations(parsedScope, access, factory) { - let receiver = unwrapFactoryReference(access.expression); - while ( - ts.isPropertyAccessExpression(receiver) || - ts.isElementAccessExpression(receiver) - ) { - receiver = unwrapFactoryReference(receiver.expression); - } - if (!ts.isIdentifier(receiver)) return undefined; - const declarations = - parsedScope.checker.getSymbolAtLocation(receiver)?.declarations; - return isAllowedFactoryBinding(declarations, factory, "namespace") - ? declarations - : undefined; -} - -function activeFactoryNames(localFactories, sourceScope) { - return new Set( - localFactories - .filter( - (localFactory) => - !Object.hasOwn(localFactory, "fenceStart") || - (localFactory.fenceStart === sourceScope.fenceStart && - localFactory.registryStart === sourceScope.registryStart), - ) - .map((localFactory) => localFactory.name), - ); -} - -function factoryCallsInScope( - source, +function checkFactoryCalls( + failures, file, - sourceScope, - localFactories, + source, factory, + kind, + contextualLifecycleNames = [], ) { - const names = activeFactoryNames(localFactories, sourceScope); - const parsedScopes = factoryScriptKinds(file, sourceScope).map((scriptKind) => - typeScriptSourceScope(source, sourceScope, scriptKind), - ); - const fewestParseErrors = Math.min( - ...parsedScopes.map((parsedScope) => parsedScope.parseErrors), - ); - const calls = new Map(); - for (const parsedScope of parsedScopes) { - if (parsedScope.parseErrors !== fewestParseErrors) continue; - for (const call of parsedScope.callExpressions) { - const reference = unwrapFactoryReference(call.expression); - let declarations; - let reportNode; - if (ts.isIdentifier(reference) && names.has(reference.text)) { - declarations = - parsedScope.checker.getSymbolAtLocation(reference)?.declarations; - if (!isAllowedFactoryBinding(declarations, factory)) continue; - reportNode = reference; - } else if ( - ts.isPropertyAccessExpression(reference) && - reference.name.text === factory - ) { - declarations = namespaceFactoryDeclarations( - parsedScope, - reference, - factory, - ); - if (!declarations) continue; - reportNode = reference.name; - } else if ( - ts.isElementAccessExpression(reference) && - ts.isStringLiteralLike(reference.argumentExpression) && - reference.argumentExpression.text === factory + // This guard intentionally checks the direct factory style maintained by this + // repository. It is a deterministic migration check, not a JavaScript parser; + // TypeScript, Biome, and the docs build validate general source semantics. + const callPattern = new RegExp(`\\b${factory}[ \\t\\n]*\\(`, "g"); + for (const match of source.matchAll(callPattern)) { + let cursor = (match.index ?? 0) + match[0].length; + while (/\s/.test(source[cursor] ?? "")) cursor += 1; + if (source[cursor] === ")") continue; + if (source[cursor] !== "{") { + const expression = source + .slice(cursor) + .match( + /^[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*(?:\s*\([^()\n]*\))?\s*(?=[,)])/, + )?.[0] + .trim(); + if (kind === "client" || !expression) continue; + if ( + /^[A-Za-z_$][\w$]*$/.test(expression) && + /(?:options?|config|opts)$/i.test(expression) ) { - declarations = namespaceFactoryDeclarations( - parsedScope, - reference, - factory, - ); - if (!declarations) continue; - reportNode = reference.argumentExpression; - } else { continue; } - const openIndex = parsedScope.start + call.arguments.pos - 1; - calls.set(openIndex, { - openIndex, - reportIndex: - parsedScope.start + reportNode.getStart(parsedScope.sourceFile), + failures.push({ + file, + line: lineAt(source, match.index ?? 0), + label: `${kind} factory must receive one options object, not positional hooks`, + match: factory, }); - } - } - return [...calls.values()].sort( - (left, right) => left.openIndex - right.openIndex, - ); -} - -function resolveIdentifierBinding(source, file, identifier, callIndex) { - const candidates = []; - const markdownFence = /\.mdx?$/.test(file) - ? markdownFenceContentStart(source, callIndex) - : undefined; - const searchStart = markdownFence ?? 0; - const scopedSource = source.slice(searchStart); - const callBlocks = scanLexicalState( - scopedSource, - callIndex - searchStart, - ).blocks; - const beforeCall = source.slice(searchStart, callIndex); - const variablePattern = new RegExp( - `\\b(?:const|let|var)\\s+${escapeRegExp(identifier)}\\b(?:\\s*:\\s*[^=;\\n]+)?\\s*=`, - "g", - ); - for (const binding of beforeCall.matchAll(variablePattern)) { - const bindingIndex = searchStart + (binding.index ?? 0); - const bindingState = scanLexicalState( - scopedSource, - bindingIndex - searchStart, - ); - if ( - bindingState.lineComment || - bindingState.blockComment || - bindingState.quote - ) { continue; } - const bindingBlocks = bindingState.blocks; + const object = readTopLevelObject(source, cursor); + if (!object) continue; if ( - bindingBlocks.some( - (block, blockIndex) => callBlocks[blockIndex] !== block, + kind === "backend" && + /(?:^|[,{])\s*(?:async\s+)?\*?\s*on(?:Before|After|Error)[A-Z][A-Za-z0-9]*\s*(?::|\()/.test( + object.topLevel, ) ) { - continue; - } - let valueIndex = bindingIndex + binding[0].length; - while (/\s/.test(source[valueIndex] ?? "")) valueIndex += 1; - const object = - source[valueIndex] === "{" - ? readTopLevelObject(source, valueIndex) - : undefined; - candidates.push({ - blockDepth: bindingBlocks.length, - index: bindingIndex, - object, - openIndex: object ? valueIndex : undefined, - referencedBeforeCall: object - ? hasExecutableIdentifierReference( - scopedSource, - object.end + 1 - searchStart, - callIndex - searchStart, - identifier, - ) - : false, - }); - } - - const deepestBlock = Math.max( - ...candidates.map((candidate) => candidate.blockDepth), - ); - const scopedCandidates = candidates - .filter((candidate) => candidate.blockDepth === deepestBlock) - .sort((left, right) => right.index - left.index); - const binding = scopedCandidates[0]; - if (binding && candidates.length > 1) { - binding.referencedBeforeCall = true; - } - return binding; -} - -function inspectFactoryObject( - failures, - file, - source, - factory, - kind, - contextualLifecycleNames, - hookType, - object, - openIndex, - reportIndex, - resolutionIndex = reportIndex, -) { - if (hasExecutableSpread(object.topLevel)) { - failures.push({ - file, - line: lineAt(source, reportIndex), - label: `${kind} factory options contain an unverifiable spread`, - match: factory, - }); - } - if (hasComputedProperty(object.topLevel)) { - failures.push({ - file, - line: lineAt(source, reportIndex), - label: `${kind} factory computed option key cannot be verified`, - match: factory, - }); - } - if ( - kind === "backend" && - /(?:^|[,{])\s*(?:async\s+)?\*?\s*(?:(?:on(?:Before|After)[A-Z][A-Za-z0-9]*|onError(?:[A-Z][A-Za-z0-9]*)?)\b|["'](?:on(?:Before|After)[A-Z][A-Za-z0-9]*|onError(?:[A-Z][A-Za-z0-9]*)?)["'])(?=\s*(?:\??:|\(|,|}))/.test( - object.topLevel, - ) - ) { - failures.push({ - file, - line: lineAt(source, reportIndex), - label: "backend lifecycle callbacks must be nested under hooks", - match: factory, - }); - } - if (kind === "backend" && contextualLifecycleNames.length > 0) { - recordLifecycleProperties( - failures, - file, - source, - object.topLevel, - openIndex, - factory, - contextualLifecycleNames, - ); - } - - if (kind === "backend" && hookType) { - let hooksReference; - const hooksProperty = object.topLevel.match( - /(?:^|[,{])\s*(?:hooks|["']hooks["'])\s*:/m, - ); - if (hooksProperty?.index !== undefined) { - let hooksValueIndex = - openIndex + hooksProperty.index + hooksProperty[0].length; - while (/\s/.test(source[hooksValueIndex] ?? "")) hooksValueIndex += 1; - if (source[hooksValueIndex] === "{") { - const hooksObject = readTopLevelObject(source, hooksValueIndex); - if (hooksObject && hasExecutableSpread(hooksObject.topLevel)) { - failures.push({ - file, - line: lineAt(source, hooksValueIndex), - label: "backend hooks contain an unverifiable spread", - match: factory, - }); - } - if (hooksObject && hasComputedProperty(hooksObject.topLevel)) { - failures.push({ - file, - line: lineAt(source, hooksValueIndex), - label: "backend hooks computed key cannot be verified", - match: factory, - }); - } - if (hooksObject && contextualLifecycleNames.length > 0) { - recordLifecycleProperties( - failures, - file, - source, - hooksObject.topLevel, - hooksValueIndex, - factory, - contextualLifecycleNames, - ); - } - } else if ( - !/^undefined\s*(?=[,}])/.test( - object.topLevel.slice(hooksValueIndex - openIndex), - ) - ) { - hooksReference = source - .slice(hooksValueIndex) - .match(/^([A-Za-z_$][\w$]*)\b/)?.[1]; - if (!hooksReference) { - failures.push({ - file, - line: lineAt(source, hooksValueIndex), - label: "backend hooks value cannot be verified", - match: factory, - }); - } - } - } else if (/(?:^|[,{])\s*(hooks)\s*(?=[,}])/m.test(object.topLevel)) { - hooksReference = "hooks"; + failures.push({ + file, + line: lineAt(source, match.index ?? 0), + label: "backend lifecycle callbacks must be nested under hooks", + match: factory, + }); } - - if (hooksReference) { - const binding = resolveIdentifierBinding( - source, + if (kind === "backend" && contextualLifecycleNames.length > 0) { + const callSource = source.slice(cursor, object.end + 1); + recordLifecycleProperties( + failures, file, - hooksReference, - resolutionIndex, + source, + callSource, + cursor, + factory, + contextualLifecycleNames, ); - if ( - binding?.object && - binding.openIndex !== undefined && - !binding.referencedBeforeCall - ) { - if (hasExecutableSpread(binding.object.topLevel)) { - failures.push({ - file, - line: lineAt(source, binding.openIndex), - label: "backend hooks contain an unverifiable spread", - match: factory, - }); - } - if (hasComputedProperty(binding.object.topLevel)) { - failures.push({ - file, - line: lineAt(source, binding.openIndex), - label: "backend hooks computed key cannot be verified", - match: factory, - }); - } - if (contextualLifecycleNames.length > 0) { + + const hooksReference = object.topLevel + .match(/\bhooks\s*:\s*([A-Za-z_$][\w$]*)|\b(hooks)\s*(?=[,}])/) + ?.slice(1) + .find(Boolean); + if (hooksReference) { + const declarationPattern = new RegExp( + `\\b(?:const|let|var)\\s+${escapeRegExp(hooksReference)}(?:\\s*:[^=;]+)?\\s*=\\s*\\{`, + "g", + ); + for (const declaration of source.matchAll(declarationPattern)) { + const openIndex = + (declaration.index ?? 0) + declaration[0].lastIndexOf("{"); + const hooksObject = readTopLevelObject(source, openIndex); + if (!hooksObject) continue; recordLifecycleProperties( failures, file, source, - binding.object.topLevel, - binding.openIndex, + source.slice(openIndex, hooksObject.end + 1), + openIndex, factory, contextualLifecycleNames, ); } - } else { - if ( - /\.mdx?$/.test(file) && - isInsideMarkdownInlineCode(source, reportIndex) - ) { - return; - } - failures.push({ - file, - line: lineAt(source, reportIndex), - label: "backend hooks binding cannot be verified", - match: `${factory}(${hooksReference})`, - }); } } - } - if ( - kind === "client" && - /(?:^|[,{])\s*(?:(?:apiBaseURL|apiBasePath|siteBaseURL|siteBasePath|queryClient|headers|credentials)\b|["'](?:apiBaseURL|apiBasePath|siteBaseURL|siteBasePath|queryClient|headers|credentials)["'])(?=\s*(?:\??:|,|}))/.test( - object.topLevel, - ) - ) { - failures.push({ - file, - line: lineAt(source, reportIndex), - label: "client plugin duplicates stack-owned runtime", - match: factory, - }); - } -} - -function checkFactoryCalls( - failures, - file, - source, - factory, - kind, - contextualLifecycleNames = [], - hookType, -) { - const localFactories = factoryLocalNames(source, file, factory); - for (const sourceScope of factorySourceScopes(source, file)) { - for (const call of factoryCallsInScope( - source, - file, - sourceScope, - localFactories, - factory, - )) { - const callIndex = call.reportIndex; - let cursor = skipLexicalTrivia(source, call.openIndex + 1); - if (source[cursor] === ")") continue; - if (source[cursor] !== "{") { - const expression = source - .slice(cursor) - .match( - /^[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*(?:\s*\([^()\n]*\))?\s*(?=[,)])/, - )?.[0] - .trim(); - if (!expression) { - if ( - /\.mdx?$/.test(file) && - isInsideMarkdownInlineCode(source, callIndex) - ) { - continue; - } - if (isInsideCommentProse(source, callIndex)) continue; - failures.push({ - file, - line: lineAt(source, callIndex), - label: `${kind} factory options expression cannot be parsed`, - match: factory, - }); - continue; - } - const identifier = /^[A-Za-z_$][\w$]*$/.test(expression) - ? expression - : undefined; - const binding = identifier - ? resolveIdentifierBinding(source, file, identifier, callIndex) - : undefined; - if (!binding) { - if ( - /\.mdx?$/.test(file) && - isInsideMarkdownInlineCode(source, callIndex) - ) { - continue; - } - failures.push({ - file, - line: lineAt(source, callIndex), - label: `${kind} factory options expression cannot be verified`, - match: `${factory}(${expression})`, - }); - continue; - } - if ( - binding.object && - binding.openIndex !== undefined && - !binding.referencedBeforeCall - ) { - inspectFactoryObject( - failures, - file, - source, - factory, - kind, - contextualLifecycleNames, - hookType, - binding.object, - binding.openIndex, - binding.openIndex, - callIndex, - ); - } else { - failures.push({ - file, - line: lineAt(source, callIndex), - label: `${kind} factory options binding cannot be verified`, - match: `${factory}(${expression})`, - }); - } - continue; - } - const object = readTopLevelObject(source, cursor); - if (!object) { - failures.push({ - file, - line: lineAt(source, callIndex), - label: `${kind} factory options object cannot be parsed`, - match: factory, - }); - continue; - } - inspectFactoryObject( - failures, + if ( + kind === "client" && + /(?:^|[,{])\s*(?:apiBaseURL|apiBasePath|siteBaseURL|siteBasePath|queryClient|headers|credentials)\s*:/.test( + object.topLevel, + ) + ) { + failures.push({ file, - source, - factory, - kind, - contextualLifecycleNames, - hookType, - object, - cursor, - callIndex, - ); + line: lineAt(source, match.index ?? 0), + label: "client plugin duplicates stack-owned runtime", + match: factory, + }); } } } @@ -1410,19 +418,11 @@ function checkTypedHookObjects( (declaration.index ?? 0) + declaration[0].lastIndexOf("{"); const object = readTopLevelObject(source, openIndex); if (!object) continue; - if (hasComputedProperty(object.topLevel)) { - failures.push({ - file, - line: lineAt(source, openIndex), - label: "backend hooks computed key cannot be verified", - match: factory, - }); - } recordLifecycleProperties( failures, file, source, - object.topLevel, + source.slice(openIndex, object.end + 1), openIndex, factory, names, @@ -1563,7 +563,6 @@ for (const absolute of allFiles) { factory, "backend", contextualNames, - hookType, ); if (hookType) { checkTypedHookObjects( @@ -1576,7 +575,7 @@ for (const absolute of allFiles) { ); } } - for (const { factory } of clientPlugins) { + for (const factory of clientFactories) { checkFactoryCalls(failures, file, source, factory, "client"); } for (const name of removedLifecycleNames) { From 7b5396941dfc623277bc65c457348ecae42a94f9 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:18:15 -0400 Subject: [PATCH 22/24] fix(v3): scan direct factory calls through comments (#224) --- scripts/check-canonical-dx.mjs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs index 2a23353e3..5699b5b44 100644 --- a/scripts/check-canonical-dx.mjs +++ b/scripts/check-canonical-dx.mjs @@ -188,6 +188,28 @@ function lineAt(source, index) { return source.slice(0, index).split("\n").length; } +function skipTrivia(source, startIndex) { + let index = startIndex; + while (index < source.length) { + if (/\s/.test(source[index])) { + index += 1; + continue; + } + if (source.startsWith("//", index)) { + const lineEnd = source.indexOf("\n", index + 2); + return lineEnd < 0 ? source.length : skipTrivia(source, lineEnd + 1); + } + if (source.startsWith("/*", index)) { + const commentEnd = source.indexOf("*/", index + 2); + return commentEnd < 0 + ? source.length + : skipTrivia(source, commentEnd + 2); + } + break; + } + return index; +} + function recordMatches(failures, file, source, label, pattern) { for (const match of source.matchAll(pattern)) { failures.push({ @@ -304,10 +326,11 @@ function checkFactoryCalls( // This guard intentionally checks the direct factory style maintained by this // repository. It is a deterministic migration check, not a JavaScript parser; // TypeScript, Biome, and the docs build validate general source semantics. - const callPattern = new RegExp(`\\b${factory}[ \\t\\n]*\\(`, "g"); + const callPattern = new RegExp(`\\b${factory}\\b`, "g"); for (const match of source.matchAll(callPattern)) { - let cursor = (match.index ?? 0) + match[0].length; - while (/\s/.test(source[cursor] ?? "")) cursor += 1; + let cursor = skipTrivia(source, (match.index ?? 0) + match[0].length); + if (source[cursor] !== "(") continue; + cursor = skipTrivia(source, cursor + 1); if (source[cursor] === ")") continue; if (source[cursor] !== "{") { const expression = source From 9a4f4c5952de5b1eac6cab109b0eacc68d05b582 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:28:03 -0400 Subject: [PATCH 23/24] fix(v3): tighten bounded canonical matchers (#224) --- scripts/check-canonical-dx.mjs | 42 ++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs index 5699b5b44..4f8ec663e 100644 --- a/scripts/check-canonical-dx.mjs +++ b/scripts/check-canonical-dx.mjs @@ -232,7 +232,7 @@ function recordLifecycleProperties( ) { for (const name of names) { const propertyPattern = new RegExp( - `(?:^|[,{])\\s*(?:async\\s+)?\\*?\\s*${escapeRegExp(name)}\\b(?=\\s*(?:\\??:|\\(|,|\\}))`, + `(?:^|[,{])\\s*(?:async\\s+)?\\*?\\s*(?:${escapeRegExp(name)}\\b|["']${escapeRegExp(name)}["'])(?=\\s*(?:\\??:|\\(|,|\\}))`, "gm", ); for (const match of objectSource.matchAll(propertyPattern)) { @@ -358,7 +358,7 @@ function checkFactoryCalls( if (!object) continue; if ( kind === "backend" && - /(?:^|[,{])\s*(?:async\s+)?\*?\s*on(?:Before|After|Error)[A-Z][A-Za-z0-9]*\s*(?::|\()/.test( + /(?:^|[,{])\s*(?:async\s+)?\*?\s*(?:on(?:Before|After|Error)[A-Z][A-Za-z0-9]*\b|["']on(?:Before|After|Error)[A-Z][A-Za-z0-9]*["'])\s*(?::|\()/.test( object.topLevel, ) ) { @@ -370,16 +370,28 @@ function checkFactoryCalls( }); } if (kind === "backend" && contextualLifecycleNames.length > 0) { - const callSource = source.slice(cursor, object.end + 1); - recordLifecycleProperties( - failures, - file, - source, - callSource, - cursor, - factory, - contextualLifecycleNames, - ); + const inlineHooks = object.topLevel.match(/(?:^|[,{])\s*hooks\s*:/); + if (inlineHooks?.index !== undefined) { + const openIndex = skipTrivia( + source, + cursor + inlineHooks.index + inlineHooks[0].length, + ); + const hooksObject = + source[openIndex] === "{" + ? readTopLevelObject(source, openIndex) + : undefined; + if (hooksObject) { + recordLifecycleProperties( + failures, + file, + source, + hooksObject.topLevel, + openIndex, + factory, + contextualLifecycleNames, + ); + } + } const hooksReference = object.topLevel .match(/\bhooks\s*:\s*([A-Za-z_$][\w$]*)|\b(hooks)\s*(?=[,}])/) @@ -399,7 +411,7 @@ function checkFactoryCalls( failures, file, source, - source.slice(openIndex, hooksObject.end + 1), + hooksObject.topLevel, openIndex, factory, contextualLifecycleNames, @@ -409,7 +421,7 @@ function checkFactoryCalls( } if ( kind === "client" && - /(?:^|[,{])\s*(?:apiBaseURL|apiBasePath|siteBaseURL|siteBasePath|queryClient|headers|credentials)\s*:/.test( + /(?:^|[,{])\s*(?:(?:apiBaseURL|apiBasePath|siteBaseURL|siteBasePath|queryClient|headers|credentials)\b|["'](?:apiBaseURL|apiBasePath|siteBaseURL|siteBasePath|queryClient|headers|credentials)["'])\s*(?::|,|})/.test( object.topLevel, ) ) { @@ -445,7 +457,7 @@ function checkTypedHookObjects( failures, file, source, - source.slice(openIndex, object.end + 1), + object.topLevel, openIndex, factory, names, From c9edae77c0188d161ccdd10a81724281806d5443 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:28:16 -0400 Subject: [PATCH 24/24] fix(v3): catch flat aggregate error hooks (#224) --- scripts/check-canonical-dx.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs index 4f8ec663e..d03852c63 100644 --- a/scripts/check-canonical-dx.mjs +++ b/scripts/check-canonical-dx.mjs @@ -358,7 +358,7 @@ function checkFactoryCalls( if (!object) continue; if ( kind === "backend" && - /(?:^|[,{])\s*(?:async\s+)?\*?\s*(?:on(?:Before|After|Error)[A-Z][A-Za-z0-9]*\b|["']on(?:Before|After|Error)[A-Z][A-Za-z0-9]*["'])\s*(?::|\()/.test( + /(?:^|[,{])\s*(?:async\s+)?\*?\s*(?:on(?:Before|After)[A-Z][A-Za-z0-9]*\b|onError(?:[A-Z][A-Za-z0-9]*)?\b|["'](?:on(?:Before|After)[A-Z][A-Za-z0-9]*|onError(?:[A-Z][A-Za-z0-9]*)?)["'])\s*(?::|\()/.test( object.topLevel, ) ) {