diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index f54866b70db..2441c80d020 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -0cd9a43c490bb5511890171543a3029754d44c9f1fe1ebf6f5c845fb49f44452 plugin-sdk-api-baseline.json -66e1a9dff2b6c170dd1caceef1f15ad63c18f89c897d98f502cac1f2f46d26c2 plugin-sdk-api-baseline.jsonl +884e6fd12b7a8086a11f547e15201f46dea0f2dc46735fad055d4f1b96d5fb82 plugin-sdk-api-baseline.json +100f6b29793abf858f94cb8c292afc0dc56573f4e264d27496a96e17f8de4c1e plugin-sdk-api-baseline.jsonl diff --git a/src/plugin-sdk/telegram-command-config.test.ts b/src/plugin-sdk/telegram-command-config.test.ts index bb0c5dcbde2..7772e1e1991 100644 --- a/src/plugin-sdk/telegram-command-config.test.ts +++ b/src/plugin-sdk/telegram-command-config.test.ts @@ -1,22 +1,74 @@ -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { TELEGRAM_COMMAND_NAME_PATTERN as bundledTelegramCommandNamePattern } from "../../extensions/telegram/src/command-config.ts"; + +const getBundledChannelContractSurfaceModule = vi.fn(() => null); vi.mock("../channels/plugins/contract-surfaces.js", () => ({ - getBundledChannelContractSurfaceModule: vi.fn(() => null), + getBundledChannelContractSurfaceModule, })); -let telegramCommandConfig: typeof import("./telegram-command-config.js"); - -beforeAll(async () => { +async function loadTelegramCommandConfig() { vi.resetModules(); - telegramCommandConfig = await import("./telegram-command-config.js"); -}); + getBundledChannelContractSurfaceModule.mockClear(); + return import("./telegram-command-config.js"); +} describe("telegram command config fallback", () => { - it("keeps command validation available when the bundled contract surface is unavailable", () => { - expect(telegramCommandConfig.TELEGRAM_COMMAND_NAME_PATTERN.test("hello_world")).toBe(true); - expect(telegramCommandConfig.normalizeTelegramCommandName("/Hello-World")).toBe( - "hello_world", + it("keeps the fallback regex in parity with the bundled telegram contract", async () => { + const telegramCommandConfig = await loadTelegramCommandConfig(); + + expect(telegramCommandConfig.TELEGRAM_COMMAND_NAME_PATTERN.toString()).toBe( + bundledTelegramCommandNamePattern.toString(), ); + }); + + it("keeps import-time regex access side-effect free", async () => { + const telegramCommandConfig = await loadTelegramCommandConfig(); + + expect(getBundledChannelContractSurfaceModule).not.toHaveBeenCalled(); + expect(telegramCommandConfig.TELEGRAM_COMMAND_NAME_PATTERN.test("hello_world")).toBe(true); + expect(getBundledChannelContractSurfaceModule).not.toHaveBeenCalled(); + }); + + it("lazy-loads the contract pattern only when callers opt in", async () => { + const contractPattern = /^[a-z]+$/; + getBundledChannelContractSurfaceModule.mockReturnValueOnce({ + TELEGRAM_COMMAND_NAME_PATTERN: contractPattern, + normalizeTelegramCommandName: (value: string) => `contract:${value.trim().toLowerCase()}`, + normalizeTelegramCommandDescription: (value: string) => `desc:${value.trim()}`, + resolveTelegramCustomCommands: () => ({ + commands: [{ command: "from_contract", description: "from contract" }], + issues: [], + }), + }); + const telegramCommandConfig = await loadTelegramCommandConfig(); + + expect(getBundledChannelContractSurfaceModule).not.toHaveBeenCalled(); + expect(telegramCommandConfig.TELEGRAM_COMMAND_NAME_PATTERN.test("hello_world")).toBe(true); + expect(getBundledChannelContractSurfaceModule).not.toHaveBeenCalled(); + expect(telegramCommandConfig.getTelegramCommandNamePattern()).toBe(contractPattern); + expect(getBundledChannelContractSurfaceModule).toHaveBeenCalledTimes(1); + expect(telegramCommandConfig.getTelegramCommandNamePattern()).toBe( + telegramCommandConfig.getTelegramCommandNamePattern(), + ); + expect(telegramCommandConfig.normalizeTelegramCommandName(" Hello ")).toBe("contract:hello"); + expect(telegramCommandConfig.normalizeTelegramCommandDescription(" hi ")).toBe("desc:hi"); + expect( + telegramCommandConfig.resolveTelegramCustomCommands({ + commands: [{ command: "/ignored", description: "ignored" }], + }), + ).toEqual({ + commands: [{ command: "from_contract", description: "from contract" }], + issues: [], + }); + expect(getBundledChannelContractSurfaceModule).toHaveBeenCalledTimes(1); + }); + + it("keeps command validation available when the bundled contract surface is unavailable", async () => { + const telegramCommandConfig = await loadTelegramCommandConfig(); + + expect(telegramCommandConfig.TELEGRAM_COMMAND_NAME_PATTERN.test("hello_world")).toBe(true); + expect(telegramCommandConfig.normalizeTelegramCommandName("/Hello-World")).toBe("hello_world"); expect(telegramCommandConfig.normalizeTelegramCommandDescription(" hi ")).toBe("hi"); expect( @@ -48,5 +100,6 @@ describe("telegram command config fallback", () => { }, ], }); + expect(getBundledChannelContractSurfaceModule).toHaveBeenCalledTimes(1); }); }); diff --git a/src/plugin-sdk/telegram-command-config.ts b/src/plugin-sdk/telegram-command-config.ts index 274e97f57ff..5def9989ce5 100644 --- a/src/plugin-sdk/telegram-command-config.ts +++ b/src/plugin-sdk/telegram-command-config.ts @@ -27,6 +27,7 @@ type TelegramCommandConfigContract = { }; const FALLBACK_TELEGRAM_COMMAND_NAME_PATTERN = /^[a-z0-9_]{1,32}$/; +let cachedTelegramCommandConfigContract: TelegramCommandConfigContract | null = null; function fallbackNormalizeTelegramCommandName(value: string): string { const trimmed = value.trim(); @@ -121,15 +122,23 @@ const FALLBACK_TELEGRAM_COMMAND_CONFIG_CONTRACT: TelegramCommandConfigContract = }; function loadTelegramCommandConfigContract(): TelegramCommandConfigContract { - const contract = getBundledChannelContractSurfaceModule({ - pluginId: "telegram", - preferredBasename: "contract-surfaces.ts", - }); - return contract ?? FALLBACK_TELEGRAM_COMMAND_CONFIG_CONTRACT; + cachedTelegramCommandConfigContract ??= + getBundledChannelContractSurfaceModule({ + pluginId: "telegram", + preferredBasename: "contract-surfaces.ts", + }) ?? FALLBACK_TELEGRAM_COMMAND_CONFIG_CONTRACT; + return cachedTelegramCommandConfigContract; } -export const TELEGRAM_COMMAND_NAME_PATTERN = - loadTelegramCommandConfigContract().TELEGRAM_COMMAND_NAME_PATTERN; +export function getTelegramCommandNamePattern(): RegExp { + return loadTelegramCommandConfigContract().TELEGRAM_COMMAND_NAME_PATTERN; +} + +/** + * @deprecated Use `getTelegramCommandNamePattern()` when you need the live + * bundled contract value. This export remains an import-time-safe fallback. + */ +export const TELEGRAM_COMMAND_NAME_PATTERN = FALLBACK_TELEGRAM_COMMAND_NAME_PATTERN; export function normalizeTelegramCommandName(value: string): string { return loadTelegramCommandConfigContract().normalizeTelegramCommandName(value); diff --git a/src/plugins/contracts/plugin-sdk-subpaths.test.ts b/src/plugins/contracts/plugin-sdk-subpaths.test.ts index f21146e69b4..3f6d84e63ed 100644 --- a/src/plugins/contracts/plugin-sdk-subpaths.test.ts +++ b/src/plugins/contracts/plugin-sdk-subpaths.test.ts @@ -162,6 +162,14 @@ function expectSourceOmitsImportPattern(subpath: string, specifier: string) { expect(source).not.toMatch(new RegExp(`\\bimport\\(\\s*["']${escapedSpecifier}["']\\s*\\)`, "u")); } +function isGeneratedBundledFacadeSubpath(subpath: string): boolean { + const source = readPluginSdkSource(subpath); + return ( + source.startsWith("// Generated by scripts/generate-plugin-sdk-facades.mjs.") && + sourceMentionsIdentifier(source, "loadBundledPluginPublicSurfaceModuleSync") + ); +} + describe("plugin-sdk subpath exports", () => { it("keeps the curated public list free of internal implementation subpaths", () => { for (const deniedSubpath of [ @@ -185,14 +193,21 @@ describe("plugin-sdk subpath exports", () => { } }); - it("keeps removed bundled-channel prefixes out of the public sdk list", () => { + it("keeps removed bundled-channel aliases out of the public sdk list", () => { + const removedChannelAliases = new Set(["discord", "signal", "slack", "telegram", "whatsapp"]); + const banned = pluginSdkSubpaths.filter((subpath) => removedChannelAliases.has(subpath)); + expect(banned).toEqual([]); + }); + + it("keeps generated bundled-channel facades out of the public sdk list", () => { const bannedPrefixes = ["discord", "signal", "slack", "telegram", "whatsapp"]; const banned = pluginSdkSubpaths.filter((subpath) => bannedPrefixes.some( (prefix) => - subpath === prefix || - subpath.startsWith(`${prefix}-`) || - subpath.startsWith(`${prefix}.`), + (subpath === prefix || + subpath.startsWith(`${prefix}-`) || + subpath.startsWith(`${prefix}.`)) && + isGeneratedBundledFacadeSubpath(subpath), ), ); expect(banned).toEqual([]);