From f9c30e15bc2b1528b46474d7c3c360393c9f12b5 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Thu, 20 Aug 2026 23:08:58 -0400 Subject: [PATCH 1/3] feat(twister): declare what each link type is via kind --- .changeset/connector-link-kinds.md | 5 +++++ twister/src/tools/integrations.ts | 23 +++++++++++++++++++++++ twister/src/tools/link-kind.test.ts | 20 ++++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 .changeset/connector-link-kinds.md create mode 100644 twister/src/tools/link-kind.test.ts diff --git a/.changeset/connector-link-kinds.md b/.changeset/connector-link-kinds.md new file mode 100644 index 00000000..a2692666 --- /dev/null +++ b/.changeset/connector-link-kinds.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Added: `kind` to `LinkTypeConfig` so connectors can declare what each link type is: `calendar`, `task`, `team-task`, or `message`. Plot uses this to group connectors and to decide which channels of a connection a workspace can enable, so a composite connector's calendar and mail channels can be treated differently. The field is optional and defaults to `team-task`; declare it on every link type you publish. diff --git a/twister/src/tools/integrations.ts b/twister/src/tools/integrations.ts index c018e29d..32bbe596 100644 --- a/twister/src/tools/integrations.ts +++ b/twister/src/tools/integrations.ts @@ -63,6 +63,23 @@ export type StatusIcon = | "confirmed" | "tentative"; +/** + * What a link type fundamentally *is*. Plot uses this to group connectors and + * to decide which of a connection's channels a given workspace can enable — + * a composite connector's calendar channels and its mail channels can differ. + * + * - `calendar` — time-anchored events from a calendar. + * - `task` — a personal to-do item (a personal task manager's task). + * - `team-task` — work tracked with other people: issues, tickets, cards, + * documents, meeting notes, CRM records. + * - `message` — a conversation: email threads, chats, DMs. + * + * Declare this on every link type. The distinction between `task` and + * `team-task` is about the tool, not the payload: a Todoist task is `task`, + * an Asana task is `team-task`, even though both are `type: "task"`. + */ +export type LinkKind = "calendar" | "task" | "team-task" | "message"; + /** * Describes a link type that a connector creates. * Used for display in the UI (icons, labels). @@ -165,6 +182,12 @@ export type LinkTypeConfig = { * false — non-calendar link types (messages, issues, tasks, docs) omit it. */ includesSchedules?: boolean; + /** + * What this link type is (see {@link LinkKind}). Declare it on every link + * type — Plot groups connectors by it and uses it to decide which channels + * a workspace can enable. Omitting it makes Plot assume `team-task`. + */ + kind?: LinkKind; /** Default thread creation mode for this link type: 'all' | 'actionable' | 'manual' */ defaultCreateThreads?: string; /** diff --git a/twister/src/tools/link-kind.test.ts b/twister/src/tools/link-kind.test.ts new file mode 100644 index 00000000..e9ef142b --- /dev/null +++ b/twister/src/tools/link-kind.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import type { LinkKind, LinkTypeConfig } from "./integrations"; + +describe("LinkTypeConfig.kind", () => { + it("accepts each declared kind", () => { + const kinds: LinkKind[] = ["calendar", "task", "team-task", "message"]; + const configs: LinkTypeConfig[] = kinds.map((kind) => ({ + type: "example", + label: "Example", + kind, + })); + expect(configs.map((c) => c.kind)).toEqual(kinds); + }); + + it("leaves kind optional so existing connectors still type-check", () => { + const config: LinkTypeConfig = { type: "example", label: "Example" }; + expect(config.kind).toBeUndefined(); + }); +}); From 2ce9b5f4b1ac242e4cbe0b3b6e24ccd7a8d06722 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Thu, 20 Aug 2026 23:37:40 -0400 Subject: [PATCH 2/3] feat(connectors): declare a kind on every link type Declares the LinkKind added to LinkTypeConfig in the previous release across every connector in this repo, and adds a static test asserting every link type declares one going forward. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012x5Gr2fFrLpjyRbUF5zXwK --- connectors/airtable/src/airtable.ts | 2 + connectors/apple/src/calendar/channels.ts | 1 + connectors/apple/src/mail/channels.ts | 1 + connectors/apple/src/reminders/channels.ts | 1 + connectors/asana/src/asana.ts | 2 + connectors/attio/src/attio.ts | 3 + connectors/fellow/src/fellow.ts | 2 + connectors/github/src/github.ts | 2 + connectors/google-chat/src/google-chat.ts | 2 + connectors/google-drive/src/google-drive.ts | 10 +-- connectors/google/src/calendar/channels.ts | 1 + connectors/google/src/mail/channels.ts | 1 + connectors/google/src/tasks/channels.ts | 1 + connectors/granola/src/granola.ts | 1 + connectors/hubspot/src/hubspot.ts | 4 ++ connectors/jira/src/jira.ts | 2 + connectors/linear/src/linear.ts | 2 + connectors/ms-teams/src/ms-teams.ts | 2 + connectors/outlook/src/calendar/channels.ts | 1 + connectors/outlook/src/mail/channels.ts | 1 + connectors/slack/src/slack.ts | 2 + connectors/todoist/src/todoist.ts | 2 + connectors/trello/src/trello-channels.ts | 1 + connectors/trello/src/trello.ts | 1 + scripts/connector-link-kinds.test.ts | 75 +++++++++++++++++++++ 25 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 scripts/connector-link-kinds.test.ts diff --git a/connectors/airtable/src/airtable.ts b/connectors/airtable/src/airtable.ts index 80b63d9f..3c64eac6 100644 --- a/connectors/airtable/src/airtable.ts +++ b/connectors/airtable/src/airtable.ts @@ -111,6 +111,7 @@ export class Airtable extends Connector { { type: "task", label: "Task", + kind: "team-task" as const, noteLabel: "Comment", sharingModel: "channel" as const, logo: LOGO, @@ -178,6 +179,7 @@ export class Airtable extends Connector { { type: "task", label: "Task", + kind: "team-task" as const, noteLabel: "Comment", logo: LOGO, logoDark: LOGO, diff --git a/connectors/apple/src/calendar/channels.ts b/connectors/apple/src/calendar/channels.ts index 48203794..29bf7412 100644 --- a/connectors/apple/src/calendar/channels.ts +++ b/connectors/apple/src/calendar/channels.ts @@ -11,6 +11,7 @@ export const CALENDAR_LINK_TYPES: LinkTypeConfig[] = [ { type: "event", label: "Event", + kind: "calendar", sourceName: "iCloud Calendar", sharingModel: "thread", includesSchedules: true, diff --git a/connectors/apple/src/mail/channels.ts b/connectors/apple/src/mail/channels.ts index 53aa335b..ee6ed2a2 100644 --- a/connectors/apple/src/mail/channels.ts +++ b/connectors/apple/src/mail/channels.ts @@ -15,6 +15,7 @@ export const MAIL_LINK_TYPES: LinkTypeConfig[] = [ // "… email". `sourceName` brands the type per-product, so it reads // "iCloud email thread" rather than the full connector name. label: "Thread", + kind: "message", sourceName: "iCloud email", // The connector's word for a note on this thread, so in-thread composer // copy reads "Add a reply" (matching Gmail) rather than "Add a note". diff --git a/connectors/apple/src/reminders/channels.ts b/connectors/apple/src/reminders/channels.ts index 62afa4a2..19c8eedb 100644 --- a/connectors/apple/src/reminders/channels.ts +++ b/connectors/apple/src/reminders/channels.ts @@ -11,6 +11,7 @@ export const REMINDERS_LINK_TYPES: LinkTypeConfig[] = [ { type: "reminder", label: "Reminder", + kind: "task", sourceName: "iCloud Reminders", // Personal to-do list, no recipient roster — mirrors Google Tasks. sharingModel: "none" as const, diff --git a/connectors/asana/src/asana.ts b/connectors/asana/src/asana.ts index d681916a..858d1148 100644 --- a/connectors/asana/src/asana.ts +++ b/connectors/asana/src/asana.ts @@ -204,6 +204,7 @@ export class Asana extends Connector { { type: "task", label: "Task", + kind: "team-task" as const, noteLabel: "Comment", sharingModel: "channel" as const, supportsFileAttachments: true, @@ -357,6 +358,7 @@ export class Asana extends Connector { { type: "task", label: "Task", + kind: "team-task" as const, noteLabel: "Comment", // Channel-level configs fully shadow the twist-level linkTypes, // so the sharing model must be repeated here. diff --git a/connectors/attio/src/attio.ts b/connectors/attio/src/attio.ts index 3b4a3964..fff42381 100644 --- a/connectors/attio/src/attio.ts +++ b/connectors/attio/src/attio.ts @@ -189,6 +189,7 @@ export class Attio extends Connector { { type: "deal", label: "Deal", + kind: "team-task", sharingModel: "channel" as const, logo: "https://plot.day/assets/logo-attio.svg", logoDark: "https://plot.day/assets/logo-attio-dark.svg", @@ -198,6 +199,7 @@ export class Attio extends Connector { { type: "person", label: "Person", + kind: "team-task", sharingModel: "channel" as const, logo: "https://plot.day/assets/logo-attio.svg", logoDark: "https://plot.day/assets/logo-attio-dark.svg", @@ -207,6 +209,7 @@ export class Attio extends Connector { { type: "company", label: "Company", + kind: "team-task", sharingModel: "channel" as const, logo: "https://plot.day/assets/logo-attio.svg", logoDark: "https://plot.day/assets/logo-attio-dark.svg", diff --git a/connectors/fellow/src/fellow.ts b/connectors/fellow/src/fellow.ts index 7c26474d..c5bf7089 100644 --- a/connectors/fellow/src/fellow.ts +++ b/connectors/fellow/src/fellow.ts @@ -121,12 +121,14 @@ export class Fellow extends Connector { { type: "meeting", label: "Meeting", + kind: "team-task", sharingModel: "thread" as const, logo: "https://plot.day/assets/logo-fellow.svg", }, { type: "task", label: "Action Item", + kind: "team-task", sharingModel: "none" as const, logo: "https://plot.day/assets/logo-fellow.svg", supportsAssignee: true, diff --git a/connectors/github/src/github.ts b/connectors/github/src/github.ts index f49ea6a9..a3cca46d 100644 --- a/connectors/github/src/github.ts +++ b/connectors/github/src/github.ts @@ -215,6 +215,7 @@ export class GitHub extends Connector { { type: "pull_request", label: "Pull Request", + kind: "team-task" as const, noteLabel: "Comment", sharingModel: "channel" as const, logo: "https://api.iconify.design/logos/github-icon.svg", @@ -230,6 +231,7 @@ export class GitHub extends Connector { { type: "issue", label: "Issue", + kind: "team-task" as const, noteLabel: "Comment", sharingModel: "channel" as const, logo: "https://api.iconify.design/logos/github-icon.svg", diff --git a/connectors/google-chat/src/google-chat.ts b/connectors/google-chat/src/google-chat.ts index ed29575b..4031a3d2 100644 --- a/connectors/google-chat/src/google-chat.ts +++ b/connectors/google-chat/src/google-chat.ts @@ -109,6 +109,7 @@ export class GoogleChat extends Connector { { type: "thread", label: "Thread", + kind: "message" as const, noteLabel: "Message", sharingModel: "channel" as const, // Logo: full-color SVG from static assets (iconify has no logos/google-chat) @@ -122,6 +123,7 @@ export class GoogleChat extends Connector { { type: "dm", label: "Direct messages", + kind: "message" as const, noteLabel: "Message", sharingModel: "thread" as const, logo: "https://plot.day/assets/logo-google-chat.svg", diff --git a/connectors/google-drive/src/google-drive.ts b/connectors/google-drive/src/google-drive.ts index b2d3e0f9..db537696 100644 --- a/connectors/google-drive/src/google-drive.ts +++ b/connectors/google-drive/src/google-drive.ts @@ -139,11 +139,11 @@ export class GoogleDrive extends Connector { "Reads your contacts to show who shared or commented", ]; readonly linkTypes = [ - { type: "doc", label: "Document", noteLabel: "Comment", sharingModel: "thread" as const, logo: "https://api.iconify.design/simple-icons/googledocs.svg?color=%234285F4", logoMono: "https://api.iconify.design/simple-icons/googledocs.svg" }, - { type: "sheet", label: "Spreadsheet", noteLabel: "Comment", sharingModel: "thread" as const, logo: "https://api.iconify.design/simple-icons/googlesheets.svg?color=%2334A853", logoMono: "https://api.iconify.design/simple-icons/googlesheets.svg" }, - { type: "slide", label: "Presentation", noteLabel: "Comment", sharingModel: "thread" as const, logo: "https://api.iconify.design/simple-icons/googleslides.svg?color=%23FBBC04", logoMono: "https://api.iconify.design/simple-icons/googleslides.svg" }, - { type: "form", label: "Form", noteLabel: "Comment", sharingModel: "thread" as const, logo: "https://api.iconify.design/simple-icons/googleforms.svg?color=%23673AB7", logoMono: "https://api.iconify.design/simple-icons/googleforms.svg" }, - { type: "document", label: "File", noteLabel: "Comment", sharingModel: "thread" as const, logo: "https://api.iconify.design/logos/google-drive.svg", logoMono: "https://api.iconify.design/simple-icons/googledrive.svg" }, + { type: "doc", label: "Document", kind: "team-task" as const, noteLabel: "Comment", sharingModel: "thread" as const, logo: "https://api.iconify.design/simple-icons/googledocs.svg?color=%234285F4", logoMono: "https://api.iconify.design/simple-icons/googledocs.svg" }, + { type: "sheet", label: "Spreadsheet", kind: "team-task" as const, noteLabel: "Comment", sharingModel: "thread" as const, logo: "https://api.iconify.design/simple-icons/googlesheets.svg?color=%2334A853", logoMono: "https://api.iconify.design/simple-icons/googlesheets.svg" }, + { type: "slide", label: "Presentation", kind: "team-task" as const, noteLabel: "Comment", sharingModel: "thread" as const, logo: "https://api.iconify.design/simple-icons/googleslides.svg?color=%23FBBC04", logoMono: "https://api.iconify.design/simple-icons/googleslides.svg" }, + { type: "form", label: "Form", kind: "team-task" as const, noteLabel: "Comment", sharingModel: "thread" as const, logo: "https://api.iconify.design/simple-icons/googleforms.svg?color=%23673AB7", logoMono: "https://api.iconify.design/simple-icons/googleforms.svg" }, + { type: "document", label: "File", kind: "team-task" as const, noteLabel: "Comment", sharingModel: "thread" as const, logo: "https://api.iconify.design/logos/google-drive.svg", logoMono: "https://api.iconify.design/simple-icons/googledrive.svg" }, ]; build(build: ToolBuilder) { diff --git a/connectors/google/src/calendar/channels.ts b/connectors/google/src/calendar/channels.ts index 8a7bf4ad..326788b3 100644 --- a/connectors/google/src/calendar/channels.ts +++ b/connectors/google/src/calendar/channels.ts @@ -48,6 +48,7 @@ export const CALENDAR_LINK_TYPES: LinkTypeConfig[] = [ { type: "event", label: "Event", + kind: "calendar", // Per-product brand for aggregate connectors (the Google connector's // display name is "Gmail & Calendar"); standalone Google Calendar falls // back to its own display name anyway. diff --git a/connectors/google/src/mail/channels.ts b/connectors/google/src/mail/channels.ts index 54f7afc4..e867f591 100644 --- a/connectors/google/src/mail/channels.ts +++ b/connectors/google/src/mail/channels.ts @@ -17,6 +17,7 @@ export const GMAIL_LINK_TYPES: LinkTypeConfig[] = [ { type: "email", label: "Thread", + kind: "message", // Per-product brand for aggregate connectors (the Google connector's // display name is "Gmail & Calendar"); standalone Gmail falls back to its // own display name anyway. diff --git a/connectors/google/src/tasks/channels.ts b/connectors/google/src/tasks/channels.ts index acaf8c62..ac45ac1a 100644 --- a/connectors/google/src/tasks/channels.ts +++ b/connectors/google/src/tasks/channels.ts @@ -17,6 +17,7 @@ export const TASKS_LINK_TYPES: LinkTypeConfig[] = [ { type: "task", label: "Task", + kind: "task", // Per-product brand for aggregate connectors (the Google connector's // display name is "Gmail & Calendar"); standalone Google Tasks falls back // to its own display name anyway. diff --git a/connectors/granola/src/granola.ts b/connectors/granola/src/granola.ts index 125297db..b13206d2 100644 --- a/connectors/granola/src/granola.ts +++ b/connectors/granola/src/granola.ts @@ -126,6 +126,7 @@ export class Granola extends Connector { { type: "meeting", label: "Notes", + kind: "team-task", sharingModel: "thread" as const, logo: "https://plot.day/assets/logo-granola.png", }, diff --git a/connectors/hubspot/src/hubspot.ts b/connectors/hubspot/src/hubspot.ts index 3c714322..168af630 100644 --- a/connectors/hubspot/src/hubspot.ts +++ b/connectors/hubspot/src/hubspot.ts @@ -287,6 +287,7 @@ export class HubSpot extends Connector { { type: "deal", label: "Deal", + kind: "team-task", sharingModel: "channel" as const, logo: HUBSPOT_LOGO, statuses: dealStatuses, @@ -295,6 +296,7 @@ export class HubSpot extends Connector { { type: "contact", label: "Contact", + kind: "team-task", sharingModel: "channel" as const, logo: HUBSPOT_LOGO, statuses: [], @@ -303,6 +305,7 @@ export class HubSpot extends Connector { { type: "company", label: "Company", + kind: "team-task", sharingModel: "channel" as const, logo: HUBSPOT_LOGO, statuses: [], @@ -314,6 +317,7 @@ export class HubSpot extends Connector { { type: "task", label: "Task", + kind: "team-task", sharingModel: "channel" as const, logo: HUBSPOT_LOGO, statuses: TASK_STATUSES, diff --git a/connectors/jira/src/jira.ts b/connectors/jira/src/jira.ts index 128cce55..973ab7a0 100644 --- a/connectors/jira/src/jira.ts +++ b/connectors/jira/src/jira.ts @@ -97,6 +97,7 @@ export class Jira extends Connector { { type: "issue", label: "Issue", + kind: "team-task" as const, noteLabel: "Comment", sharingModel: "channel" as const, logo: "https://api.iconify.design/logos/jira.svg", @@ -178,6 +179,7 @@ export class Jira extends Connector { { type: "issue", label: "Issue", + kind: "team-task" as const, noteLabel: "Comment", // Channel-level configs fully shadow the twist-level linkTypes, // so sharingModel + logos must be repeated here (matches Linear). diff --git a/connectors/linear/src/linear.ts b/connectors/linear/src/linear.ts index 4a293097..21352da6 100644 --- a/connectors/linear/src/linear.ts +++ b/connectors/linear/src/linear.ts @@ -104,6 +104,7 @@ export class Linear extends Connector { { type: "issue", label: "Issue", + kind: "team-task" as const, noteLabel: "Comment", sharingModel: "channel" as const, composePlaceholder: "Create a Linear issue", @@ -198,6 +199,7 @@ export class Linear extends Connector { { type: "issue", label: "Issue", + kind: "team-task" as const, noteLabel: "Comment", // Channel-level configs fully shadow the twist-level linkTypes in // getTypeConfig(), so the sharing model must be repeated here — diff --git a/connectors/ms-teams/src/ms-teams.ts b/connectors/ms-teams/src/ms-teams.ts index 12ed9339..900be536 100644 --- a/connectors/ms-teams/src/ms-teams.ts +++ b/connectors/ms-teams/src/ms-teams.ts @@ -91,6 +91,7 @@ export class MsTeams extends Connector { { type: "thread", label: "Thread", + kind: "message" as const, noteLabel: "Message", sharingModel: "channel" as const, logo: "https://api.iconify.design/logos/microsoft-teams.svg", @@ -103,6 +104,7 @@ export class MsTeams extends Connector { { type: "dm", label: "Direct messages", + kind: "message" as const, noteLabel: "Message", sharingModel: "thread" as const, logo: "https://api.iconify.design/logos/microsoft-teams.svg", diff --git a/connectors/outlook/src/calendar/channels.ts b/connectors/outlook/src/calendar/channels.ts index 8058cae4..a686a9d9 100644 --- a/connectors/outlook/src/calendar/channels.ts +++ b/connectors/outlook/src/calendar/channels.ts @@ -29,6 +29,7 @@ export const OUTLOOK_CALENDAR_LINK_TYPES: LinkTypeConfig[] = [ { type: "event", label: "Event", + kind: "calendar", // Per-product brand for the aggregate Outlook connector (display name // "Outlook"); standalone Outlook Calendar falls back to its own display name. sourceName: "Outlook Calendar", diff --git a/connectors/outlook/src/mail/channels.ts b/connectors/outlook/src/mail/channels.ts index 94513cb4..47108323 100644 --- a/connectors/outlook/src/mail/channels.ts +++ b/connectors/outlook/src/mail/channels.ts @@ -20,6 +20,7 @@ export const OUTLOOK_MAIL_LINK_TYPES: LinkTypeConfig[] = [ { type: "email", label: "Thread", + kind: "message", // Per-product brand for the aggregate Outlook connector (display name // "Outlook"); standalone Outlook Mail falls back to its own display name. sourceName: "Outlook Mail", diff --git a/connectors/slack/src/slack.ts b/connectors/slack/src/slack.ts index 1dd66f3b..d654cb0b 100644 --- a/connectors/slack/src/slack.ts +++ b/connectors/slack/src/slack.ts @@ -303,6 +303,7 @@ export class Slack extends Connector { { type: "thread", label: "Thread", + kind: "message" as const, noteLabel: "Message", sharingModel: "channel" as const, supportsFileAttachments: true, @@ -315,6 +316,7 @@ export class Slack extends Connector { { type: "dm", label: "Direct messages", + kind: "message" as const, noteLabel: "Message", sharingModel: "thread" as const, supportsFileAttachments: true, diff --git a/connectors/todoist/src/todoist.ts b/connectors/todoist/src/todoist.ts index 7a631ab7..0431c514 100644 --- a/connectors/todoist/src/todoist.ts +++ b/connectors/todoist/src/todoist.ts @@ -124,6 +124,7 @@ export class Todoist extends Connector { { type: "task", label: "Task", + kind: "task" as const, noteLabel: "Comment", sharingModel: "thread" as const, composePlaceholder: "Create a Todoist task", @@ -202,6 +203,7 @@ export class Todoist extends Connector { { type: "task", label: "Task", + kind: "task" as const, noteLabel: "Comment", // Channel-level configs fully shadow the twist-level linkTypes // in getTypeConfig(), so the sharing model and capability flags diff --git a/connectors/trello/src/trello-channels.ts b/connectors/trello/src/trello-channels.ts index 44b82151..d4ab96ec 100644 --- a/connectors/trello/src/trello-channels.ts +++ b/connectors/trello/src/trello-channels.ts @@ -19,6 +19,7 @@ export function buildCardLinkType(lists: TrelloList[]): LinkTypeConfig { return { type: "card", label: "Card", + kind: "team-task", noteLabel: "Comment", sharingModel: "channel", composePlaceholder: "Create a Trello card", diff --git a/connectors/trello/src/trello.ts b/connectors/trello/src/trello.ts index f166c545..f1439ed9 100644 --- a/connectors/trello/src/trello.ts +++ b/connectors/trello/src/trello.ts @@ -32,6 +32,7 @@ export class Trello extends Connector { { type: "card", label: "Card", + kind: "team-task" as const, noteLabel: "Comment", sharingModel: "channel" as const, composePlaceholder: "Create a Trello card", diff --git a/scripts/connector-link-kinds.test.ts b/scripts/connector-link-kinds.test.ts new file mode 100644 index 00000000..d5194cc2 --- /dev/null +++ b/scripts/connector-link-kinds.test.ts @@ -0,0 +1,75 @@ +import { readFileSync, readdirSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +/** + * Every published connector must declare `kind` on every link type it + * exposes. Plot maps kinds to entitlement; a link type with no kind silently + * falls back to `team-task`, which is wrong for calendars and personal task + * managers and would gate them behind a paid plan. + * + * Static check: for each connector source file, find every object literal + * that pairs `type:` with `label:` (the shape unique to LinkTypeConfig) and + * assert a `kind:` appears within the same object. + */ +const CONNECTORS_DIR = join(import.meta.dirname, "..", "connectors"); + +function sourceFiles(dir: string): string[] { + const out: string[] = []; + const walk = (d: string) => { + for (const e of readdirSync(d, { withFileTypes: true })) { + if (["node_modules", "dist", "build"].includes(e.name)) continue; + const p = join(d, e.name); + if (e.isDirectory()) walk(p); + else if (e.name.endsWith(".ts") && !e.name.includes(".test.")) out.push(p); + } + }; + walk(dir); + return out; +} + +/** + * Link-type object literals: `type:` and `label:` belonging to the same + * object, tolerating up to one level of brace nesting inside it. A plain + * "no intervening brace" match would miss most real `LinkTypeConfig` + * objects in this repo, which almost always carry an inline `statuses: + * [...]`, `contactRoles: [...]`, or `compose: {...}` — each exactly one + * level deep — between `type:`/`label:` and the object's own closing `}`. + * + * Excludes `OptionDef` entries from the `Options` tool schema (`{ type: + * "text" | "number" | "boolean" | "select", label: ..., default: ... }`), + * which share this shape by coincidence — those four strings are reserved + * for option-field kinds and never appear as a connector's own link `type`. + * Unlike `LinkTypeConfig.type`, which is contextually typed against the + * `Connector.linkTypes` declaration, `OptionDef.type` is inferred through a + * generic and so is written with `as const`, which is how these are told + * apart here. + */ +function linkTypeBlocks(src: string): string[] { + const nestable = String.raw`(?:[^{}]|\{[^{}]*\})*`; + const pattern = new RegExp( + `\\{${nestable}\\btype:\\s*[^,]+,${nestable}\\blabel:\\s*"[^"]*"${nestable}\\}`, + "gs" + ); + return [...src.matchAll(pattern)] + .map((m) => m[0]) + .filter((block) => !/\btype:\s*"(?:text|number|boolean|select)"\s+as\s+const,/.test(block)); +} + +describe("public connectors declare a kind on every link type", () => { + const names = readdirSync(CONNECTORS_DIR, { withFileTypes: true }) + .filter((e) => e.isDirectory() && existsSync(join(CONNECTORS_DIR, e.name, "src"))) + .map((e) => e.name); + + it.each(names)("%s", (name) => { + const undeclared: string[] = []; + for (const file of sourceFiles(join(CONNECTORS_DIR, name, "src"))) { + for (const block of linkTypeBlocks(readFileSync(file, "utf8"))) { + if (!/\bkind:\s*"(calendar|task|team-task|message)"/.test(block)) { + undeclared.push(`${file}: ${block.slice(0, 80).replace(/\s+/g, " ")}`); + } + } + } + expect(undeclared, `link types missing kind in ${name}`).toEqual([]); + }); +}); From 6d77639e504b2f5cb3b2b0bda04ef7686a22821f Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Thu, 20 Aug 2026 23:52:31 -0400 Subject: [PATCH 3/3] fix(connectors): make the link-kind contract test nesting-proof Rewrite the static check to walk the TypeScript AST instead of matching source text with a regex, so a link type declared deep inside an object (e.g. behind a conditional spread) can't slip past the check the way a brace-counting regex could. Also rewords the test's doc comment to describe SDK behaviour only. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012x5Gr2fFrLpjyRbUF5zXwK --- scripts/connector-link-kinds.test.ts | 103 ++++++++++++++++++--------- 1 file changed, 70 insertions(+), 33 deletions(-) diff --git a/scripts/connector-link-kinds.test.ts b/scripts/connector-link-kinds.test.ts index d5194cc2..af551397 100644 --- a/scripts/connector-link-kinds.test.ts +++ b/scripts/connector-link-kinds.test.ts @@ -1,16 +1,18 @@ import { readFileSync, readdirSync, existsSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import * as ts from "typescript"; /** * Every published connector must declare `kind` on every link type it - * exposes. Plot maps kinds to entitlement; a link type with no kind silently - * falls back to `team-task`, which is wrong for calendars and personal task - * managers and would gate them behind a paid plan. + * exposes. Plot groups connectors by `kind` and uses it to decide which + * channels a workspace can enable — a link type with no kind silently falls + * back to `team-task`, which is wrong for calendars and personal task + * managers. * * Static check: for each connector source file, find every object literal - * that pairs `type:` with `label:` (the shape unique to LinkTypeConfig) and - * assert a `kind:` appears within the same object. + * that declares own `type` and `label` properties (the shape unique to + * `LinkTypeConfig`) and assert a `kind` property is declared there too. */ const CONNECTORS_DIR = join(import.meta.dirname, "..", "connectors"); @@ -29,31 +31,70 @@ function sourceFiles(dir: string): string[] { } /** - * Link-type object literals: `type:` and `label:` belonging to the same - * object, tolerating up to one level of brace nesting inside it. A plain - * "no intervening brace" match would miss most real `LinkTypeConfig` - * objects in this repo, which almost always carry an inline `statuses: - * [...]`, `contactRoles: [...]`, or `compose: {...}` — each exactly one - * level deep — between `type:`/`label:` and the object's own closing `}`. + * `OptionDef` entries from the `Options` tool schema (`{ type: "text" | + * "number" | "boolean" | "select", label: ..., default: ... }`, see + * `twister/src/options.ts`) share the `type`+`label` shape with + * `LinkTypeConfig` by coincidence: those four strings are a real closed + * union reserved for option-field kinds and never occur as a connector's + * own link `type`. + */ +const OPTION_DEF_TYPES = new Set(["text", "number", "boolean", "select"]); + +/** The string literal value of an expression, unwrapping a trailing `as const`/`as T`. */ +function stringLiteralValue(node: ts.Expression): string | undefined { + const expr = ts.isAsExpression(node) ? node.expression : node; + return ts.isStringLiteralLike(expr) ? expr.text : undefined; +} + +function propertyName(prop: ts.ObjectLiteralElementLike): string | undefined { + if (!ts.isPropertyAssignment(prop)) return undefined; + if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) return prop.name.text; + return undefined; +} + +/** + * Find every `LinkTypeConfig`-shaped object literal in `src` — one with its + * own `type` and `label` properties, at any nesting depth inside the file — + * and report those missing a sibling `kind` property. * - * Excludes `OptionDef` entries from the `Options` tool schema (`{ type: - * "text" | "number" | "boolean" | "select", label: ..., default: ... }`), - * which share this shape by coincidence — those four strings are reserved - * for option-field kinds and never appear as a connector's own link `type`. - * Unlike `LinkTypeConfig.type`, which is contextually typed against the - * `Connector.linkTypes` declaration, `OptionDef.type` is inferred through a - * generic and so is written with `as const`, which is how these are told - * apart here. + * Uses the TypeScript compiler API (rather than a regex over the source + * text) specifically so that nesting inside the object — an inline + * `statuses: [...]`, `contactRoles: [...]`, or `compose: {...}` — can never + * hide it from the check. Each object literal's own properties are read + * from the AST directly, independent of how deeply anything else in the + * object nests. */ -function linkTypeBlocks(src: string): string[] { - const nestable = String.raw`(?:[^{}]|\{[^{}]*\})*`; - const pattern = new RegExp( - `\\{${nestable}\\btype:\\s*[^,]+,${nestable}\\blabel:\\s*"[^"]*"${nestable}\\}`, - "gs" - ); - return [...src.matchAll(pattern)] - .map((m) => m[0]) - .filter((block) => !/\btype:\s*"(?:text|number|boolean|select)"\s+as\s+const,/.test(block)); +function undeclaredLinkTypes(file: string, src: string): string[] { + const sourceFile = ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true); + const undeclared: string[] = []; + + const visit = (node: ts.Node) => { + if (ts.isObjectLiteralExpression(node)) { + let hasLabel = false; + let hasKind = false; + let typeValue: string | undefined; + let sawType = false; + for (const prop of node.properties) { + const name = propertyName(prop); + if (name === "type" && ts.isPropertyAssignment(prop)) { + sawType = true; + typeValue = stringLiteralValue(prop.initializer); + } else if (name === "label") { + hasLabel = true; + } else if (name === "kind") { + hasKind = true; + } + } + const isOptionDef = typeValue !== undefined && OPTION_DEF_TYPES.has(typeValue); + if (sawType && hasLabel && !isOptionDef && !hasKind) { + const text = node.getText(sourceFile).replace(/\s+/g, " "); + undeclared.push(`${file}: ${text.slice(0, 80)}`); + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return undeclared; } describe("public connectors declare a kind on every link type", () => { @@ -64,11 +105,7 @@ describe("public connectors declare a kind on every link type", () => { it.each(names)("%s", (name) => { const undeclared: string[] = []; for (const file of sourceFiles(join(CONNECTORS_DIR, name, "src"))) { - for (const block of linkTypeBlocks(readFileSync(file, "utf8"))) { - if (!/\bkind:\s*"(calendar|task|team-task|message)"/.test(block)) { - undeclared.push(`${file}: ${block.slice(0, 80).replace(/\s+/g, " ")}`); - } - } + undeclared.push(...undeclaredLinkTypes(file, readFileSync(file, "utf8"))); } expect(undeclared, `link types missing kind in ${name}`).toEqual([]); });