diff --git a/docs/SURFACE.md b/docs/SURFACE.md index e8e1120e6..cb0865562 100644 --- a/docs/SURFACE.md +++ b/docs/SURFACE.md @@ -115,6 +115,15 @@ No process runs between events: the handler wakes, executes to its next await, p and the other provider namespaces remain follow-up work; see [the generator notes](../packages/surface/src/helpers/README.md). + A namespace is not a promise of a whole vendor API. The generated catalog + records the exact sorted adapter `resources` alongside each provider's + `supported` flag, so authors and preflight tooling can inspect the generated + resource surface without loading an adapter's private catalog. Bespoke + aliases such as `stripe.createInvoice` remain outside that catalog. In particular, + GitLab now lists `issues`, `merge-requests`, `refs`, `merge`, and + `close-merge-request` in addition to comments and discussions; the earlier + comment-only gap was closed by the pinned relay-helpers release. + The initial local memory slice supports `recall` and `why` in authored flows, with no journal step for either read. Script scope is stable across runs of the same flow file and name; reads cannot widen it to another flow. The diff --git a/packages/sdk/src/helper-reference.ts b/packages/sdk/src/helper-reference.ts index fa7d1bf2e..37749e614 100644 --- a/packages/sdk/src/helper-reference.ts +++ b/packages/sdk/src/helper-reference.ts @@ -27,7 +27,8 @@ export function helperNamespacesUsed(body: string, root: string): ReadonlySet(); - walk(program, (node) => { + const scope = { rootFunctionFound: false }; + walkReferences(program, root, false, scope, (node) => { if (node.type !== 'MemberExpression') return; const object = node.object as AstNode | undefined; if (object?.type !== 'Identifier' || object.name !== root) return; @@ -111,20 +112,144 @@ type AstNode = { [key: string]: unknown; }; -/** Depth-first over every child node, without pulling in a second package. */ -function walk(node: AstNode, visit: (node: AstNode) => void): void { - visit(node); +/** + * Depth-first over references that still resolve to the outer flow context. + * + * A nested callback/method parameter or lexical destructuring can reuse the + * context's spelling while binding an unrelated value. Counting its members + * would refuse the flow for a mount it never touches. Acorn gives us binding + * shapes, so scopes are tracked as syntax instead of guessed from text. + */ +function walkReferences( + node: AstNode, + root: string, + shadowed: boolean, + state: { rootFunctionFound: boolean }, + visit: (node: AstNode) => void, +): void { + if (isFunction(node)) { + const parameters = Array.isArray(node.params) ? node.params.filter(isNode) : []; + const bindsParameter = parameters.some(parameter => patternBinds(parameter, root)); + let bodyHidden = shadowed; + const isRootFunction = bindsParameter && !state.rootFunctionFound; + if (isRootFunction) state.rootFunctionFound = true; + else if (bindsParameter || (isNode(node.id) && patternBinds(node.id, root)) || functionVarBinds(node, root)) { + bodyHidden = true; + } + // Parameter initializers run before the function body and are outside a + // body-level `var` scope. Keep them visible unless the parameter list + // itself binds the root name (in which case all parameter references are + // local to that parameter environment). + const parameterHidden = shadowed || (bindsParameter && !isRootFunction); + for (const parameter of parameters) walkReferences(parameter, root, parameterHidden, state, visit); + const body = isNode(node.body) ? node.body : undefined; + if (body !== undefined) walkReferences(body, root, bodyHidden, state, visit); + return; + } + let hidden = shadowed; + if (node.type === 'BlockStatement' && blockBinds(node, root)) { + hidden = true; + } else if (node.type === 'CatchClause' && isNode(node.param) && patternBinds(node.param, root)) { + hidden = true; + } + + if (!hidden) visit(node); for (const key of Object.keys(node)) { if (key === 'type' || key === 'start' || key === 'end' || key === 'loc') continue; const child = node[key]; if (Array.isArray(child)) { - for (const entry of child) if (isNode(entry)) walk(entry, visit); + for (const entry of child) if (isNode(entry)) walkReferences(entry, root, hidden, state, visit); } else if (isNode(child)) { - walk(child, visit); + walkReferences(child, root, hidden, state, visit); } } } +function isFunction(node: AstNode): boolean { + return node.type === 'ArrowFunctionExpression' + || node.type === 'FunctionExpression' + || node.type === 'FunctionDeclaration'; +} + +/** Lexical declarations bind across their complete block, including TDZ. */ +function blockBinds(node: AstNode, root: string): boolean { + const statements = Array.isArray(node.body) ? node.body.filter(isNode) : []; + return statements.some((statement) => { + if (statement.type === 'VariableDeclaration' && statement.kind !== 'var') { + const declarations = Array.isArray(statement.declarations) + ? statement.declarations.filter(isNode) : []; + return declarations.some(declaration => isNode(declaration.id) && patternBinds(declaration.id, root)); + } + return (statement.type === 'FunctionDeclaration' || statement.type === 'ClassDeclaration') + && isNode(statement.id) && patternBinds(statement.id, root); + }); +} + +/** Identifier occurrences that introduce bindings, including nested patterns. */ +function patternBinds(pattern: AstNode, root: string): boolean { + switch (pattern.type) { + case 'Identifier': + return pattern.name === root; + case 'AssignmentPattern': + return isNode(pattern.left) && patternBinds(pattern.left, root); + case 'RestElement': + return isNode(pattern.argument) && patternBinds(pattern.argument, root); + case 'ArrayPattern': { + const elements = Array.isArray(pattern.elements) ? pattern.elements : []; + return elements.some(element => isNode(element) && patternBinds(element, root)); + } + case 'ObjectPattern': { + const properties = Array.isArray(pattern.properties) ? pattern.properties : []; + return properties.some(property => { + if (!isNode(property)) return false; + if (property.type === 'RestElement') { + return isNode(property.argument) && patternBinds(property.argument, root); + } + // Object keys are labels, not bindings. Only the value side introduces + // the local (including a default-value AssignmentPattern's left side). + return property.type === 'Property' + && isNode(property.value) + && patternBinds(property.value, root); + }); + } + default: + return false; + } +} + +/** `var` is function-scoped, so a nested function's declaration hides the + * outer flow context for its entire body, including code before the + * declaration. Nested functions have their own var scopes and are skipped. */ +function functionVarBinds(node: AstNode, root: string): boolean { + const body = isNode(node.body) ? node.body : undefined; + if (body === undefined) return false; + let found = false; + const scan = (current: AstNode): void => { + if (found) return; + if (current !== body && isFunction(current)) return; + if (current.type === 'VariableDeclaration' && current.kind === 'var') { + const declarations = Array.isArray(current.declarations) + ? current.declarations.filter(isNode) : []; + if (declarations.some(declaration => isNode(declaration.id) && patternBinds(declaration.id, root))) { + found = true; + return; + } + } + for (const key of Object.keys(current)) { + if (key === 'type' || key === 'start' || key === 'end' || key === 'loc') continue; + const child = current[key]; + if (Array.isArray(child)) { + for (const entry of child) if (isNode(entry)) scan(entry); + } else if (isNode(child)) { + scan(child); + } + if (found) return; + } + }; + scan(body); + return found; +} + function isNode(value: unknown): value is AstNode { return typeof value === 'object' && value !== null && typeof (value as { type?: unknown }).type === 'string'; } diff --git a/packages/sdk/src/hosted-extension-sandbox.ts b/packages/sdk/src/hosted-extension-sandbox.ts index 6af09a00c..214c4728b 100644 --- a/packages/sdk/src/hosted-extension-sandbox.ts +++ b/packages/sdk/src/hosted-extension-sandbox.ts @@ -109,7 +109,7 @@ const ADDRESS_SPACE_BYTES = 16 * 1024 * 1024 * 1024; const DATA_BYTES = 3 * 1024 * 1024 * 1024; const SURFACE_RUNTIME_SHA256 = OBJECT_FREEZE({ 'flow.js': '4aaeacc55de3074f4d121ce7253c3be50a93757e6540ba8159889a9450d1c05c', - 'helpers/providers.js': '7bc62eccaa3a9e786ae0a689bf74160585149e91feef8208e17ef8eca51eed7f', + 'helpers/providers.js': '4eb06d0d85ca0a3434bb2dbba7407e3d95eef2dfbedaeb0e2de0c0c0ea812457', 'provider-trigger.js': 'e2664c65397f93fb486eb6f1e756c7cec3f88b3851d79c23567cad986f80f1ff', 'schedule.js': '8fe72f176a75ec0b5f26e12db7a597575c259a2e2cbb59690f9dc20a5e63940b', 'triggers.js': '4a3515b571a318f6c7a5661f9310bc9af43e3faf20ea39903a9b363c51258e4c', diff --git a/packages/sdk/tests/helper-reference.test.ts b/packages/sdk/tests/helper-reference.test.ts index bfcfcade8..d21df5198 100644 --- a/packages/sdk/tests/helper-reference.test.ts +++ b/packages/sdk/tests/helper-reference.test.ts @@ -15,6 +15,7 @@ const refusals = (body: Function): string[] => preflightHelpers({ header: {}, body }, mountFacts).diagnostics .filter((d) => d.severity === 'refusal') .map((d) => d.kind); +const fromSource = (source: string): Function => new Function(`return ${source};`)() as Function; describe('helper references are read as syntax, not text', () => { it('does not demand a mount for a helper named inside a string', () => { @@ -132,6 +133,67 @@ describe('helper references are read as syntax, not text', () => { expect(refusals(holder.gen as never)).toContain('helper_provider.mount_required'); }); + it('does not treat destructured locals as the outer flow context', () => { + for (const source of [ + '(f) => { { const { f } = { f: { gitlab: { issues: 42 } } }; return f.gitlab.issues; } }', + '(f) => { { const [f] = [{ gitlab: { issues: 42 } }]; return f.gitlab.issues; } }', + ]) expect(refusals(fromSource(source)), source).toEqual([]); + }); + + it('does not treat object or class method parameters as the outer flow context', () => { + for (const source of [ + '(f) => ({ read(f) { return f.gitlab.issues; } }).read({ gitlab: { issues: 42 } })', + '(f) => new class { read(f) { return f.gitlab.issues; } }().read({ gitlab: { issues: 42 } })', + '(f) => ({ read({ f }) { return f.gitlab.issues; } }).read({ f: { gitlab: { issues: 42 } } })', + ]) expect(refusals(fromSource(source)), source).toEqual([]); + }); + + it('only treats binding targets as shadowing in object/default patterns', () => { + for (const source of [ + '(f) => { const callback = ({ f: x }) => f.gitlab.issues; return callback; }', + '(f) => { const callback = (x = f) => f.gitlab.issues; return callback; }', + ]) expect(refusals(fromSource(source)), source).toContain('helper_provider.mount_required'); + }); + + it('treats nested function-scoped var declarations as shadowing', () => { + expect(refusals(fromSource( + '(f) => { const callback = () => { var f; return f.gitlab.issues; }; return callback(); }', + ))).toEqual([]); + expect(refusals(fromSource( + '(f) => { function callback() { var f; return f.gitlab.issues; } return callback(); }', + ))).toEqual([]); + }); + + it('still reads the outer context outside a nested var scope', () => { + expect(refusals(fromSource( + '(f) => { const callback = () => { var f; return f.gitlab.issues; }; callback(); return f.gitlab.issues; }', + ))).toContain('helper_provider.mount_required'); + }); + + it('keeps outer helper references in parameter defaults outside var scope', () => { + expect(refusals(fromSource( + '(f) => { function read(x = f.gitlab.issues) { var f = local; return f.gitlab; } }', + ))).toContain('helper_provider.mount_required'); + }); + + it('keeps root-function parameter defaults visible', () => { + expect(refusals(fromSource('(f, x = f.gitlab.issues) => x'))) + .toContain('helper_provider.mount_required'); + }); + + it('does not let a deeper nested var scope hide an outer helper', () => { + expect(refusals(fromSource( + '(f) => { const outer = () => { const inner = () => { var f; return f.gitlab; }; return f.gitlab.issues; }; return outer(); }', + ))).toContain('helper_provider.mount_required'); + }); + + it('keeps reading the outer context outside a shadowing scope', () => { + const source = '(f) => { { const { f } = { f: { gitlab: {} } }; void f.gitlab; } return f.gitlab.issues; }'; + expect(refusals(fromSource(source))).toContain('helper_provider.mount_required'); + expect(refusals(fromSource('(f) => { if (f) return f.gitlab.issues; }'))) + .toContain('helper_provider.mount_required'); + }); + // `flowRequirements` has its OWN parameter extraction, mirroring // `preflightHelpers`. The shapes above are asserted through preflight, so // without these the mirrored regex could be reverted with the suite green. diff --git a/packages/surface/src/helpers/README.md b/packages/surface/src/helpers/README.md index 05d64f644..6f164ffe8 100644 --- a/packages/surface/src/helpers/README.md +++ b/packages/surface/src/helpers/README.md @@ -32,11 +32,17 @@ Use `--out-dir /tmp/generated-helpers` to inspect output without changing source CI regenerates from the installed pinned package and compares every generated TypeScript file byte-for-byte, including the namespace index. -Follow-up for the full slice N: add GitHub, Notion, Linear, and Stripe once their -methods have runtime dispatch support; consume mapping/discovery resources and -generate the remaining providers. The current runtime implements only Slack. -The uniform upstream clients expose resource `read`/`list`/`write` methods, -not `stripe.createInvoice` or `notion.appendBlock`; those aliases need an agreed -runtime contract before this types-only generator can expose them. GitHub's -bespoke `createIssue` also requires `owner` in addition to `repo`, `title`, and -`body`. No new provider methods or resource methods are advertised in this proof. +Runtime dispatch is no longer Slack-only: every provider in `providers.ts` whose +`supported` is not `false` binds its upstream client's resource +`read`/`list`/`write` methods plus the bespoke aliases the generator knows +(`stripe.createInvoice`, `notion.appendBlock`, GitHub's `createIssue`, which +requires `owner` in addition to `repo`, `title`, and `body`). `path` stays a +synchronous path builder and is never dispatched. + +`supported` answers whether the pinned upstream writeback client exists. The +adjacent sorted `resources` list is the exact adapter catalog; bespoke aliases +such as `stripe.createInvoice` remain outside it. The list is not a claim that +the whole vendor API is reachable. Keeping both fields in the generated file +makes catalog growth and regression reviewable as ordinary source diffs. +GitLab currently exposes `issues`, `merge-requests`, `refs`, `merge`, and +`close-merge-request` in addition to comments and discussions. diff --git a/packages/surface/src/helpers/providers.ts b/packages/surface/src/helpers/providers.ts index 9d29e4d52..8777ce1f5 100644 --- a/packages/surface/src/helpers/providers.ts +++ b/packages/surface/src/helpers/providers.ts @@ -1,305 +1,548 @@ // GENERATED by scripts/generate-helpers.mjs — do not edit. // Run `npm run gen --prefix packages/surface` from the repository root. +// `resources` is the exact sorted pinned adapter catalog; bespoke aliases are excluded. export const helperProviders = [ { "provider": "airtable", "namespace": "airtable", "mockEnv": "RELAYFLOWS_AIRTABLE_MOCK", - "supported": false + "supported": false, + "resources": [] }, { "provider": "asana", "namespace": "asana", "mockEnv": "RELAYFLOWS_ASANA_MOCK", - "supported": true + "supported": true, + "resources": [ + "projects", + "sections", + "tasks" + ] }, { "provider": "azure-blob", "namespace": "azureBlob", "mockEnv": "RELAYFLOWS_AZURE_BLOB_MOCK", - "supported": true + "supported": true, + "resources": [ + "blobs", + "event-subscriptions" + ] }, { "provider": "box", "namespace": "box", "mockEnv": "RELAYFLOWS_BOX_MOCK", - "supported": true + "supported": true, + "resources": [ + "files", + "webhooks" + ] }, { "provider": "calendly", "namespace": "calendly", "mockEnv": "RELAYFLOWS_CALENDLY_MOCK", - "supported": true + "supported": true, + "resources": [ + "event-types", + "invitees", + "scheduled-events" + ] }, { "provider": "clickup", "namespace": "clickup", "mockEnv": "RELAYFLOWS_CLICKUP_MOCK", - "supported": true + "supported": true, + "resources": [ + "comments", + "folders", + "lists", + "tasks" + ] }, { "provider": "cloudflare", "namespace": "cloudflare", "mockEnv": "RELAYFLOWS_CLOUDFLARE_MOCK", - "supported": true + "supported": true, + "resources": [ + "d1-databases", + "dns-records", + "kv-namespaces", + "notification-events", + "notification-policies", + "notification-webhooks", + "pages-projects", + "queues", + "r2-buckets", + "tunnels", + "worker-usage", + "workers-scripts", + "zones" + ] }, { "provider": "confluence", "namespace": "confluence", "mockEnv": "RELAYFLOWS_CONFLUENCE_MOCK", - "supported": true + "supported": true, + "resources": [ + "pages" + ] }, { "provider": "daytona", "namespace": "daytona", "mockEnv": "RELAYFLOWS_DAYTONA_MOCK", - "supported": true + "supported": true, + "resources": [ + "usage" + ] }, { "provider": "docker-hub", "namespace": "dockerHub", "mockEnv": "RELAYFLOWS_DOCKER_HUB_MOCK", - "supported": false + "supported": false, + "resources": [] }, { "provider": "dropbox", "namespace": "dropbox", "mockEnv": "RELAYFLOWS_DROPBOX_MOCK", - "supported": true + "supported": true, + "resources": [ + "cursors", + "files", + "folders", + "shared-folders", + "shared-links" + ] }, { "provider": "fathom", "namespace": "fathom", "mockEnv": "RELAYFLOWS_FATHOM_MOCK", - "supported": false + "supported": false, + "resources": [] }, { "provider": "gcp", "namespace": "gcp", "mockEnv": "RELAYFLOWS_GCP_MOCK", - "supported": false + "supported": false, + "resources": [] }, { "provider": "gcs", "namespace": "gcs", "mockEnv": "RELAYFLOWS_GCS_MOCK", - "supported": true + "supported": true, + "resources": [ + "notifications", + "objects" + ] }, { "provider": "github", "namespace": "github", "mockEnv": "RELAYFLOWS_GITHUB_MOCK", - "supported": true + "supported": true, + "resources": [ + "close-pull-request", + "issue-comments", + "issues", + "merge", + "pull-requests", + "refs", + "replies", + "reviews" + ] }, { "provider": "gitlab", "namespace": "gitlab", "mockEnv": "RELAYFLOWS_GITLAB_MOCK", - "supported": true + "supported": true, + "resources": [ + "close-merge-request", + "comments", + "discussions", + "issues", + "merge", + "merge-requests", + "refs" + ] }, { "provider": "gmail", "namespace": "gmail", "mockEnv": "RELAYFLOWS_GMAIL_MOCK", - "supported": true + "supported": true, + "resources": [ + "drafts", + "threads", + "watches" + ] }, { "provider": "google-calendar", "namespace": "googleCalendar", "mockEnv": "RELAYFLOWS_GOOGLE_CALENDAR_MOCK", - "supported": true + "supported": true, + "resources": [ + "events" + ] }, { "provider": "google-drive", "namespace": "googleDrive", "mockEnv": "RELAYFLOWS_GOOGLE_DRIVE_MOCK", - "supported": true + "supported": true, + "resources": [ + "channels", + "files" + ] }, { "provider": "granola", "namespace": "granola", "mockEnv": "RELAYFLOWS_GRANOLA_MOCK", - "supported": true + "supported": true, + "resources": [ + "folders", + "notes" + ] }, { "provider": "hubspot", "namespace": "hubspot", "mockEnv": "RELAYFLOWS_HUBSPOT_MOCK", - "supported": true + "supported": true, + "resources": [ + "companies", + "contacts", + "deals", + "tickets" + ] }, { "provider": "intercom", "namespace": "intercom", "mockEnv": "RELAYFLOWS_INTERCOM_MOCK", - "supported": true + "supported": true, + "resources": [ + "companies", + "contacts", + "conversations" + ] }, { "provider": "jira", "namespace": "jira", "mockEnv": "RELAYFLOWS_JIRA_MOCK", - "supported": true + "supported": true, + "resources": [ + "comments", + "issues", + "projects", + "transitions" + ] }, { "provider": "linear", "namespace": "linear", "mockEnv": "RELAYFLOWS_LINEAR_MOCK", - "supported": true + "supported": true, + "resources": [ + "agent-activities", + "comments", + "issues", + "labels", + "project-issue-assignments", + "projects" + ] }, { "provider": "mailgun", "namespace": "mailgun", "mockEnv": "RELAYFLOWS_MAILGUN_MOCK", - "supported": true + "supported": true, + "resources": [ + "lists", + "members", + "messages" + ] }, { "provider": "mixpanel", "namespace": "mixpanel", "mockEnv": "RELAYFLOWS_MIXPANEL_MOCK", - "supported": true + "supported": true, + "resources": [ + "cohorts", + "events", + "profiles" + ] }, { "provider": "neon", "namespace": "neon", "mockEnv": "RELAYFLOWS_NEON_MOCK", - "supported": false + "supported": false, + "resources": [] }, { "provider": "notion", "namespace": "notion", "mockEnv": "RELAYFLOWS_NOTION_MOCK", - "supported": true + "supported": true, + "resources": [ + "comments", + "content", + "pages", + "properties" + ] }, { "provider": "onedrive", "namespace": "onedrive", "mockEnv": "RELAYFLOWS_ONEDRIVE_MOCK", - "supported": true + "supported": true, + "resources": [ + "items", + "subscriptions" + ] }, { "provider": "pipedrive", "namespace": "pipedrive", "mockEnv": "RELAYFLOWS_PIPEDRIVE_MOCK", - "supported": true + "supported": true, + "resources": [ + "activities", + "deals", + "organizations", + "persons" + ] }, { "provider": "postgres", "namespace": "postgres", "mockEnv": "RELAYFLOWS_POSTGRES_MOCK", - "supported": true + "supported": true, + "resources": [ + "listeners", + "rows" + ] }, { "provider": "posthog", "namespace": "posthog", "mockEnv": "RELAYFLOWS_POSTHOG_MOCK", - "supported": false + "supported": false, + "resources": [] }, { "provider": "ramp", "namespace": "ramp", "mockEnv": "RELAYFLOWS_RAMP_MOCK", - "supported": true + "supported": true, + "resources": [ + "accounting-accounts", + "accounting-fields", + "bills", + "business", + "departments", + "entities", + "item-receipts", + "locations", + "merchants", + "purchase-orders", + "receipts", + "reimbursements", + "repayments", + "spend-programs", + "transactions", + "transfers", + "users", + "vendor-agreements", + "vendors" + ] }, { "provider": "recall", "namespace": "recall", "mockEnv": "RELAYFLOWS_RECALL_MOCK", - "supported": true + "supported": true, + "resources": [ + "recordings" + ] }, { "provider": "reddit", "namespace": "reddit", "mockEnv": "RELAYFLOWS_REDDIT_MOCK", - "supported": true + "supported": true, + "resources": [ + "posts", + "subreddits" + ] }, { "provider": "redis", "namespace": "redis", "mockEnv": "RELAYFLOWS_REDIS_MOCK", - "supported": true + "supported": true, + "resources": [ + "keys", + "listeners" + ] }, { "provider": "s3", "namespace": "s3", "mockEnv": "RELAYFLOWS_S3_MOCK", - "supported": true + "supported": true, + "resources": [ + "objects", + "queues" + ] }, { "provider": "salesforce", "namespace": "salesforce", "mockEnv": "RELAYFLOWS_SALESFORCE_MOCK", - "supported": true + "supported": true, + "resources": [ + "accounts", + "cases", + "contacts", + "leads", + "opportunities" + ] }, { "provider": "segment", "namespace": "segment", "mockEnv": "RELAYFLOWS_SEGMENT_MOCK", - "supported": false + "supported": false, + "resources": [] }, { "provider": "sendgrid", "namespace": "sendgrid", "mockEnv": "RELAYFLOWS_SENDGRID_MOCK", - "supported": true + "supported": true, + "resources": [ + "contacts", + "mail" + ] }, { "provider": "sharepoint", "namespace": "sharepoint", "mockEnv": "RELAYFLOWS_SHAREPOINT_MOCK", - "supported": true + "supported": true, + "resources": [ + "items", + "subscriptions" + ] }, { "provider": "shopify", "namespace": "shopify", "mockEnv": "RELAYFLOWS_SHOPIFY_MOCK", - "supported": false + "supported": false, + "resources": [] }, { "provider": "shortcut", "namespace": "shortcut", "mockEnv": "RELAYFLOWS_SHORTCUT_MOCK", - "supported": true + "supported": true, + "resources": [ + "categories", + "custom-fields", + "epics", + "groups", + "iterations", + "labels", + "milestones", + "projects", + "stories" + ] }, { "provider": "slack", "namespace": "slack", "mockEnv": "RELAYFLOWS_SLACK_MOCK", - "supported": true + "supported": true, + "resources": [ + "direct-messages", + "messages", + "reactions", + "replies" + ] }, { "provider": "stripe", "namespace": "stripe", "mockEnv": "RELAYFLOWS_STRIPE_MOCK", - "supported": true + "supported": true, + "resources": [] }, { "provider": "teams", "namespace": "teams", "mockEnv": "RELAYFLOWS_TEAMS_MOCK", - "supported": true + "supported": true, + "resources": [ + "messages", + "replies" + ] }, { "provider": "telegram", "namespace": "telegram", "mockEnv": "RELAYFLOWS_TELEGRAM_MOCK", - "supported": true + "supported": true, + "resources": [ + "callback-queries", + "commands", + "inline-queries", + "menu-button", + "messages", + "reactions" + ] }, { "provider": "webhook-server", "namespace": "webhookServer", "mockEnv": "RELAYFLOWS_WEBHOOK_SERVER_MOCK", - "supported": false + "supported": false, + "resources": [] }, { "provider": "x", "namespace": "x", "mockEnv": "RELAYFLOWS_X_MOCK", - "supported": false + "supported": false, + "resources": [] }, { "provider": "zendesk", "namespace": "zendesk", "mockEnv": "RELAYFLOWS_ZENDESK_MOCK", - "supported": true + "supported": true, + "resources": [ + "comments", + "tickets", + "users" + ] } ] as const; diff --git a/packages/surface/tests/helpers.snapshot.test.ts b/packages/surface/tests/helpers.snapshot.test.ts index 3f19e44c4..31ae96030 100644 --- a/packages/surface/tests/helpers.snapshot.test.ts +++ b/packages/surface/tests/helpers.snapshot.test.ts @@ -1,9 +1,24 @@ import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { expect, it } from 'vitest'; +import { helperProviders } from '../src/helpers/providers.js'; it('regenerates helpers byte-identically from the pinned adapter', () => { const guard = fileURLToPath(new URL('../scripts/check-generated-helpers.mjs', import.meta.url)); expect(execFileSync(process.execPath, [guard], { encoding: 'utf8' })) .toContain('HELPERS_GENERATED_OK airtable.ts, asana.ts, azure-blob.ts'); }); + +it('publishes the exact sorted resource catalog, including current GitLab parity', () => { + for (const provider of helperProviders) { + expect(provider.resources).toEqual([...provider.resources].sort()); + if (!provider.supported) expect(provider.resources).toEqual([]); + } + expect(helperProviders.find(provider => provider.provider === 'gitlab')).toMatchObject({ + supported: true, + resources: [ + 'close-merge-request', 'comments', 'discussions', 'issues', 'merge', + 'merge-requests', 'refs', + ], + }); +}); diff --git a/scripts/generate-helpers.mjs b/scripts/generate-helpers.mjs index c33a41a33..52b9c57f2 100644 --- a/scripts/generate-helpers.mjs +++ b/scripts/generate-helpers.mjs @@ -25,6 +25,7 @@ if (values['adapters-dir']) { } const camel = name => name.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); const title = name => camel(name).replace(/^./, c => c.toUpperCase()); +const resourcesOf = provider => Object.keys(catalog[provider] ?? {}).sort(); const header = '// GENERATED by scripts/generate-helpers.mjs — do not edit.\n' + '// Run `npm run gen --prefix packages/surface` from the repository root.\n'; const destination = resolve(values['out-dir']); @@ -85,9 +86,12 @@ files['index.ts'] = header + '\nimport type { SlackHelper } from "./slack.js";\n + providers.filter(p => p !== 'slack').map(p => ` ${camel(p)}: create${title(p)}Helper(dispatch),`).join('\n') + '\n };\n}\n'; files['clients.ts'] = header + '\nimport * as upstream from "@relayfile/relay-helpers";\nimport * as custom from "../helper-clients.js";\nimport type { HelperFactory } from "../effect-transport.js";\n\nexport const helperClients: Readonly> = {\n' + providers.filter(p => p !== 'slack' && (p === 'stripe' || catalog[p])).map(p => ` "${p}": ${['github', 'notion', 'stripe'].includes(p) ? `custom.${camel(p)}Client` : typeof upstream[`${camel(p)}Client`] === 'function' ? `upstream.${camel(p)}Client` : `(options) => upstream.providerClient("${p}", options)`},`).join('\n') + '\n};\n'; -files['providers.ts'] = header + '\nexport const helperProviders = ' + JSON.stringify(providers.map(p => ({ +files['providers.ts'] = header + + '\n// `resources` is the exact sorted pinned adapter catalog; bespoke aliases are excluded.\n' + + 'export const helperProviders = ' + JSON.stringify(providers.map(p => ({ provider: p, namespace: camel(p), mockEnv: `RELAYFLOWS_${p.replaceAll('-', '_').toUpperCase()}_MOCK`, supported: p === 'stripe' || catalog[p] !== undefined, + resources: resourcesOf(p), })), null, 2) + ' as const;\n'; for (const [name, content] of Object.entries(files)) writeFileSync(join(destination, name), content); console.log(`Generated ${providers.length} provider helpers (${providers.filter(p => !catalog[p] && p !== 'stripe').length} without upstream writeback clients)`);