From a566e329105a498dc445171424daf30ba2ef0a59 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 04:56:15 +0000 Subject: [PATCH] =?UTF-8?q?feat(spec)!:=20=E6=94=B9=E5=90=8D=20studio=20?= =?UTF-8?q?=E4=BE=A7=20ActionLocationSchema=20=E2=86=92=20ActionContributi?= =?UTF-8?q?onLocationSchema=20=E2=80=94=E2=80=94=20=E5=8F=8C=E6=BA=90?= =?UTF-8?q?=E6=B8=85=E8=B4=A6=20C17,=E8=A3=B8=E5=90=8D=E5=94=AF=E4=B8=80?= =?UTF-8?q?=E5=BD=92=20./ui=20(#4737)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - studio 3 值 IDE surface 枚举与 ui 7 值平台正典同名不同概念(#4411 陷阱), 按 ADR-0112 D9(a) 改名 studio 侧;ui 侧一字未动 - RENAMED_DEFS 承接 studio/ActionLocation → studio/ActionContributionLocation (0-key carry,枚举 def 无 authorable properties);零 tombstone、零 conversion - 新增 ActionContributionLocation 类型导出,docs-import-surface 基线 no-type-export 例外随之删行 - dual-source-exports 基线 6 → 5;幽灵页 references/studio/action.mdx 随 gen:docs 自愈(#4696 语境) - compiler-API 符号身份 pin(exports map 全入口枚举 + 防空转守卫 + holders 精确相等),S1/S2 sabotage 实证见 PR Claude-Session: https://claude.ai/code/session_0176qgxgCXTJCUv4YFLtusP9 Co-authored-by: Claude --- .changeset/action-location-dual-source-c17.md | 33 +++ content/docs/references/studio/action.mdx | 29 --- content/docs/references/studio/meta.json | 1 - content/docs/references/studio/plugin.mdx | 15 +- packages/spec/api-surface.json | 3 +- .../spec/docs-import-surface.baseline.json | 1 - .../spec/dual-source-exports.baseline.json | 1 - packages/spec/json-schema.manifest.json | 2 +- packages/spec/scripts/lib/renamed-defs.ts | 9 + .../studio/action-location-retirement.test.ts | 188 ++++++++++++++++++ packages/spec/src/studio/index.ts | 6 +- packages/spec/src/studio/plugin.test.ts | 8 +- packages/spec/src/studio/plugin.zod.ts | 22 +- 13 files changed, 274 insertions(+), 44 deletions(-) create mode 100644 .changeset/action-location-dual-source-c17.md delete mode 100644 content/docs/references/studio/action.mdx create mode 100644 packages/spec/src/studio/action-location-retirement.test.ts diff --git a/.changeset/action-location-dual-source-c17.md b/.changeset/action-location-dual-source-c17.md new file mode 100644 index 0000000000..e0e7c5e0b0 --- /dev/null +++ b/.changeset/action-location-dual-source-c17.md @@ -0,0 +1,33 @@ +--- +"@objectstack/spec": major +--- + +BREAKING(spec): `@objectstack/spec/studio` 改名 `ActionLocationSchema` → `ActionContributionLocationSchema`;裸名 `ActionLocation(Schema)` 现在全包唯一地指 `@objectstack/spec/ui` 的应用 UI 位置词表 (#4737, #4535 C17) + +`ActionLocationSchema` 曾由 `./studio` 与 `./ui` 各自导出一个声明 —— 同名、词表完全互斥的**两个概念**(#4411 陷阱): + +| 入口 | 词表 | 语义 | 处置 | +|:--|:--|:--|:--| +| `./studio`(**改名**) | `toolbar` / `contextMenu` / `commandPalette`(3 值) | Studio IDE 外壳里插件 action contribution 出现的位置(唯一嵌入方 `ActionContributionSchema.location`) | → `ActionContributionLocationSchema`,枚举值逐字不变;新增 `ActionContributionLocation` 类型导出(旧 const 从无 type 导出) | +| `./ui`(**一字不动**) | `list_toolbar` / `list_item` / `record_header` / `record_more` / `record_related` / `record_section` / `global_nav`(7 值) | 运行中应用的 UI 上 action 渲染的位置,docblock 自宣全平台唯一真源 | 裸名唯一归属(objectui 按引用钉住 `ACTION_LOCATIONS` 并 re-export 类型族) | + +## FROM → TO + +```ts +// FROM —— 编译期起以 TS2305 失败(实测 objectstack / cloud / objectui 三仓零外部 importer,预期无人受影响) +import { ActionLocationSchema } from '@objectstack/spec/studio'; + +// TO —— 同一声明、同一词表,名字点明它唯一的语义 +import { + ActionContributionLocationSchema, + type ActionContributionLocation, +} from '@objectstack/spec/studio'; +``` + +**要的是应用 UI 的 action 位置?** `import { ActionLocationSchema, type ActionLocation } from '@objectstack/spec/ui'` —— 本次未动。 + +不保留旧名别名:在 `./studio` 上 re-export 任何一侧的 `ActionLocationSchema` 都会重开本次关闭的陷阱(要么复活双源,要么把应用 UI 词表谎报成 Studio 清单词表)。 + +## 零元数据迁移 + +本次只动 TS 导出名与内部 JSON Schema def 名(`studio/ActionLocation` → `studio/ActionContributionLocation`,走 `RENAMED_DEFS` 承接表,0-key carry —— 枚举 def 无 authorable properties)。作者在 Studio 插件清单里写的 `contributes.actions[].location` 取值域(`toolbar` / `contextMenu` / `commandPalette`)逐字节不变,已有清单原样解析。无 tombstone(没有 key 退役)、无 ADR-0087 conversion —— `StudioPluginManifestSchema` 是根 schema,不在 stack 树上,conversion walker 到不了它(`converge-activation-event-schema` 先例论证)。发布的 JSON Schema `$id` 随之移动:`…/studio/ActionLocation.json` → `…/studio/ActionContributionLocation.json`。 diff --git a/content/docs/references/studio/action.mdx b/content/docs/references/studio/action.mdx deleted file mode 100644 index 6061950177..0000000000 --- a/content/docs/references/studio/action.mdx +++ /dev/null @@ -1,29 +0,0 @@ ---- -title: Action -description: Action protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -## TypeScript Usage - -```typescript -import { ActionLocationSchema } from '@objectstack/spec/studio'; - -// Validate data -const result = ActionLocationSchema.parse(data); -``` - ---- - -## ActionLocation - -### Allowed Values - -* `toolbar` -* `contextMenu` -* `commandPalette` - - ---- - diff --git a/content/docs/references/studio/meta.json b/content/docs/references/studio/meta.json index c8facffe04..4f882cd142 100644 --- a/content/docs/references/studio/meta.json +++ b/content/docs/references/studio/meta.json @@ -1,7 +1,6 @@ { "title": "Studio Protocol", "pages": [ - "action", "flow-builder", "object-designer", "plugin", diff --git a/content/docs/references/studio/plugin.mdx b/content/docs/references/studio/plugin.mdx index a55ef5b35e..3f75c95738 100644 --- a/content/docs/references/studio/plugin.mdx +++ b/content/docs/references/studio/plugin.mdx @@ -100,8 +100,8 @@ modes: ['preview', 'design', 'data'], ## TypeScript Usage ```typescript -import { ActionContributionSchema, CommandContributionSchema, MetadataIconContributionSchema, MetadataViewerContributionSchema, PanelContributionSchema, PanelLocationSchema, SidebarGroupContributionSchema, StudioPluginContributionsSchema, StudioPluginManifestSchema, ViewModeSchema } from '@objectstack/spec/studio'; -import type { ActionContribution, CommandContribution, MetadataIconContribution, MetadataViewerContribution, PanelContribution, SidebarGroupContribution, StudioPluginContributions, StudioPluginManifest, ViewMode } from '@objectstack/spec/studio'; +import { ActionContributionSchema, ActionContributionLocationSchema, CommandContributionSchema, MetadataIconContributionSchema, MetadataViewerContributionSchema, PanelContributionSchema, PanelLocationSchema, SidebarGroupContributionSchema, StudioPluginContributionsSchema, StudioPluginManifestSchema, ViewModeSchema } from '@objectstack/spec/studio'; +import type { ActionContribution, ActionContributionLocation, CommandContribution, MetadataIconContribution, MetadataViewerContribution, PanelContribution, SidebarGroupContribution, StudioPluginContributions, StudioPluginManifest, ViewMode } from '@objectstack/spec/studio'; // Validate data const result = ActionContributionSchema.parse(data); @@ -122,6 +122,17 @@ const result = ActionContributionSchema.parse(data); | **metadataTypes** | `string[]` | ✅ | Applicable metadata types | +--- + +## ActionContributionLocation + +### Allowed Values + +* `toolbar` +* `contextMenu` +* `commandPalette` + + --- ## CommandContribution diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 56fc9af45f..4074103808 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -4014,8 +4014,9 @@ ], "./studio": [ "ActionContribution (type)", + "ActionContributionLocation (type)", + "ActionContributionLocationSchema (const)", "ActionContributionSchema (const)", - "ActionLocationSchema (const)", "ActivationEvent (type)", "ActivationEventSchema (const)", "BUILT_IN_NODE_DESCRIPTORS (const)", diff --git a/packages/spec/docs-import-surface.baseline.json b/packages/spec/docs-import-surface.baseline.json index ce1ed1a8a7..41944bc97e 100644 --- a/packages/spec/docs-import-surface.baseline.json +++ b/packages/spec/docs-import-surface.baseline.json @@ -84,7 +84,6 @@ "shared/IsolationLevelEnum — no type export", "shared/MutationEventEnum — no type export", "shared/SortDirectionEnum — no type export", - "studio/ActionLocation — no type export", "studio/PanelLocation — no type export", "system/AddFieldOperation — no type export", "system/CreateObjectOperation — no type export", diff --git a/packages/spec/dual-source-exports.baseline.json b/packages/spec/dual-source-exports.baseline.json index 9d5f137d71..3bf927c986 100644 --- a/packages/spec/dual-source-exports.baseline.json +++ b/packages/spec/dual-source-exports.baseline.json @@ -1,7 +1,6 @@ { "_comment": "Accepted cross-entry DUAL-SOURCE exports of @objectstack/spec (#4446): names that two or more public entry points export for DIFFERENT declarations, so which type a consumer gets depends on the import path — the #4411 trap. Shrink-only ratchet, judged by symbol identity (a re-export of one declaration from many entries is fine and not listed). A NEW name here fails check:dual-source-exports: converge on one declaration and re-export it, or rename one side — growing this list needs maintainer sign-off and shows up as this file in the diff. An entry that stops being dual-source fails until its line is deleted. Regenerate with: tsx scripts/check-dual-source-exports.ts --update (after pnpm build).", "entries": [ - "ActionLocationSchema — [./studio (const)] ≠ [./ui (const)]", "EnvironmentArtifact — [./cloud (type)] ≠ [./system (type)]", "EnvironmentArtifactInput — [./cloud (type)] ≠ [./system (type)]", "EnvironmentArtifactSchema — [./cloud (const)] ≠ [./system (const)]", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 39de0cac2b..402f829371 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -1143,7 +1143,7 @@ "shared/TemplateExpressionInput", "shared/ViewName", "studio/ActionContribution", - "studio/ActionLocation", + "studio/ActionContributionLocation", "studio/ActivationEvent", "studio/CommandContribution", "studio/ERDiagramConfig", diff --git a/packages/spec/scripts/lib/renamed-defs.ts b/packages/spec/scripts/lib/renamed-defs.ts index 07d61ce29f..61a2349633 100644 --- a/packages/spec/scripts/lib/renamed-defs.ts +++ b/packages/spec/scripts/lib/renamed-defs.ts @@ -79,6 +79,15 @@ export const RENAMED_DEFS: Readonly> = { // distinct concept (client/server offline sync) and the only side with // cross-repo consumers (objectui useOffline + re-export + parity ratchet). 'integration/ConflictResolution': 'integration/ConnectorConflictResolution', + + // #4737 / ADR-0112 D9a — `ActionLocation` was published by ./studio AND ./ui + // for two disjoint concepts. The studio side (3-value IDE surface enum, + // consumed only by `ActionContributionSchema.location`) takes the + // domain-specific name (0-key carry: enum def, no authorable properties). + // `ui/ActionLocation` keeps the bare name: its docblock declares it the + // platform-wide canonical vocabulary and objectui pins it by reference + // (spec-derived-unions.test.ts). + 'studio/ActionLocation': 'studio/ActionContributionLocation', }; /** diff --git a/packages/spec/src/studio/action-location-retirement.test.ts b/packages/spec/src/studio/action-location-retirement.test.ts new file mode 100644 index 0000000000..1177055f3e --- /dev/null +++ b/packages/spec/src/studio/action-location-retirement.test.ts @@ -0,0 +1,188 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; + +// ─── [#4737] `ActionLocation` has ONE owner per name (./studio renamed) ────── +// +// Dual-source ledger #4535, cluster C17. Before this change `ActionLocationSchema` +// was exported by TWO entry points for TWO different declarations: +// +// ./studio — 3-value Studio IDE surface enum (`toolbar`, `contextMenu`, +// `commandPalette`), consumed only by `ActionContributionSchema.location` +// inside a Studio plugin manifest; +// ./ui — 7-value platform-canonical vocabulary for where an action +// renders in a RUNNING APP's UI (`list_toolbar`, …, `global_nav`), whose +// docblock declares it "single source of truth for the whole platform". +// +// Same name, disjoint vocabularies: which type a consumer got depended on +// nothing but the import path — the #4411 trap. Resolution (maintainer-ruled, +// #4737, ADR-0112 D9a): the STUDIO side is renamed +// `ActionContributionLocationSchema` (+ a new `ActionContributionLocation` +// type alias — the old const had no type export at all), with a RENAMED_DEFS +// carry (`studio/ActionLocation` → `studio/ActionContributionLocation`, +// 0 authorable keys — enum def). The ui side is UNTOUCHED: it is the only +// side with cross-repo consumers (objectui pins `ACTION_LOCATIONS` by +// reference in spec-derived-unions.test.ts and re-exports the type family), +// so renaming it would replay the objectui#3235 downstream breakage. +// +// #4642 established that a compile-time conditional-type pin in this package +// is a no-op (tsconfig excludes `**/*.test.ts`; vitest never enables +// `typecheck`), so the load-bearing pin is the compiler-API test below, with +// anti-vacuity guards; sabotage-verified in the PR (re-exporting the ui enum +// from ./studio under the bare name — green to the dual-source gate, a lie to +// authors — and resurrecting the old 3-value const each turn it red). +describe('[#4737] studio ActionLocation dual-source retirement', () => { + it('resolves the export surface: one owner per name, across every public entry', async () => { + const ts = (await import('typescript')).default; + const { resolve, relative } = await import('node:path'); + const { dirname } = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const { readFileSync } = await import('node:fs'); + + const specDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + // Every public entry point, read from package.json's exports map so a + // future entry cannot silently escape the uniqueness pins below. + const pkg = JSON.parse(readFileSync(resolve(specDir, 'package.json'), 'utf8')) as { + exports: Record; + }; + const entries: Record = {}; + for (const sub of Object.keys(pkg.exports)) { + if (sub === '.') entries[sub] = resolve(specDir, 'src/index.ts'); + else if (/^\.\/[a-z-]+$/.test(sub)) entries[sub] = resolve(specDir, `src/${sub.slice(2)}/index.ts`); + // './openapi.json' / './package.json' are not TypeScript entry points. + } + // Anti-vacuity: the enumeration must have found the real surface. + for (const needed of ['./studio', './ui']) { + expect(Object.keys(entries), `exports map must include ${needed}`).toContain(needed); + } + expect(Object.keys(entries).length).toBeGreaterThan(10); + + const program = ts.createProgram(Object.values(entries), { + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + skipLibCheck: true, + noEmit: true, + }); + const checker = program.getTypeChecker(); + const unalias = (s: import('typescript').Symbol) => + s.getFlags() & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(s) : s; + + const exportsOf = (sub: string) => { + const sf = program.getSourceFile(entries[sub]); + const moduleSym = sf && checker.getSymbolAtLocation(sf); + // Without this guard a resolution failure would make every assertion + // below pass vacuously — the exact way a gate goes dormant (#4642). + expect(moduleSym, `${sub} module symbol must resolve`).toBeTruthy(); + return checker.getExportsOfModule(moduleSym!); + }; + + const originOf = (sym: import('typescript').Symbol, label: string) => { + const decl = unalias(sym).declarations?.[0]; + expect(decl, `${label} must have a declaration`).toBeTruthy(); + const declFile = decl!.getSourceFile(); + return `${relative(specDir, declFile.fileName)}:${ + declFile.getLineAndCharacterOfPosition(decl!.getStart()).line + 1 + }`; + }; + + /** Every entry that exports `name`, with each occurrence's declaration origin. */ + const holdersOf = (name: string) => { + const out: Array<{ sub: string; origin: string }> = []; + for (const sub of Object.keys(entries)) { + for (const sym of exportsOf(sub).filter((e) => e.getName() === name)) { + out.push({ sub, origin: originOf(sym, `${sub} ${name}`) }); + } + } + return out; + }; + + // 1. The renamed-away side: `./studio` still has a non-trivial surface — + // so the `not.toContain` cannot pass by resolving nothing — and no + // longer names the old export, while surviving neighbours stand. + const studioNames = exportsOf('./studio').map((e) => e.getName()); + expect(studioNames.length, './studio must export a non-trivial surface').toBeGreaterThan(40); + for (const retired of ['ActionLocationSchema', 'ActionLocation']) { + expect(studioNames, `./studio must not export ${retired}`).not.toContain(retired); + } + expect(studioNames).toContain('ActionContributionSchema'); + expect(studioNames).toContain('StudioPluginManifestSchema'); + + // 2. The renamed side: `ActionContributionLocation(Schema)` originates in + // studio/plugin.zod.ts and is exported by ./studio alone (plus nothing + // else — the rename must not fan out). + for (const name of ['ActionContributionLocationSchema', 'ActionContributionLocation']) { + const holders = holdersOf(name); + expect(holders.length, `${name} must be exported (by ./studio)`).toBeGreaterThan(0); + for (const h of holders) { + expect(h.sub, `${name} must only be exported by ./studio`).toBe('./studio'); + expect(h.origin).toMatch(/^src\/studio\/plugin\.zod\.ts:\d+$/); + } + } + + // 3. The bare `ActionLocation(Schema)` now has exactly ONE owner: ./ui, + // declared in ui/action.zod.ts. Not just "same declaration everywhere" + // — NO other entry may export the bare name at all. A re-export from + // ./studio would share the declaration (green to the dual-source gate) + // while telling Studio plugin authors that `list_toolbar` & co. are + // valid manifest contribution locations — the C14/C15 lesson: a + // re-export can lie about the domain even when the symbol is honest. + for (const name of ['ActionLocationSchema', 'ActionLocation']) { + const holders = holdersOf(name); + expect(holders.map((h) => h.sub), `${name} must be owned by ./ui alone`).toEqual(['./ui']); + expect(holders[0].origin).toMatch(/^src\/ui\/action\.zod\.ts:\d+$/); + } + + // 4. The canonical list constant travels with it: `ACTION_LOCATIONS` (the + // array objectui pins by reference) stays ./ui-owned and distinct from + // the studio declaration file. + const listHolders = holdersOf('ACTION_LOCATIONS'); + expect(listHolders.map((h) => h.sub), 'ACTION_LOCATIONS must be owned by ./ui alone').toEqual(['./ui']); + expect(listHolders[0].origin).toMatch(/^src\/ui\/action\.zod\.ts:\d+$/); + }); + + it('keeps the runtime namespaces consistent with the compiler view', async () => { + const studio = await import('./index'); + const ui = await import('../ui/index'); + + // Renamed-away side — the old const is gone from ./studio at runtime too. + expect('ActionLocationSchema' in studio, 'studio must not export ActionLocationSchema').toBe(false); + // Anti-vacuity: the namespace we just probed is real and non-trivial. + expect('StudioPluginManifestSchema' in studio).toBe(true); + + // Renamed side — the Studio IDE vocabulary, byte-for-byte unchanged. + expect('ActionContributionLocationSchema' in studio).toBe(true); + for (const loc of ['toolbar', 'contextMenu', 'commandPalette']) { + expect(() => studio.ActionContributionLocationSchema.parse(loc)).not.toThrow(); + } + // The two vocabularies were disjoint precisely here — the app-UI values + // must NOT leak into the Studio contribution enum: + expect(() => studio.ActionContributionLocationSchema.parse('list_toolbar')).toThrow(); + expect(() => studio.ActionContributionLocationSchema.parse('global_nav')).toThrow(); + + // ui side — untouched, and still the 7-value app-UI vocabulary. + expect('ActionLocationSchema' in ui).toBe(true); + expect('ACTION_LOCATIONS' in ui).toBe(true); + for (const loc of ['list_toolbar', 'record_header', 'global_nav']) { + expect(() => ui.ActionLocationSchema.parse(loc)).not.toThrow(); + } + expect(() => ui.ActionLocationSchema.parse('toolbar')).toThrow(); + expect(() => ui.ActionLocationSchema.parse('commandPalette')).toThrow(); + }); + + it('still parses an authored plugin manifest through the renamed enum — the live path', async () => { + const { StudioPluginManifestSchema } = await import('./plugin.zod'); + const manifestWith = (location: string) => ({ + id: 'objectstack.object-designer', + name: 'Object Designer', + contributes: { + actions: [{ id: 'deploy', label: 'Deploy', location }], + }, + }); + const parsed = StudioPluginManifestSchema.parse(manifestWith('toolbar')); + expect(parsed.contributes.actions[0].location).toBe('toolbar'); + // The authored VALUE domain did not move an inch with the TS rename; the + // SAME document differing only in this one value stays illegal (so this + // negative cannot pass for an unrelated reason): + expect(() => StudioPluginManifestSchema.parse(manifestWith('list_toolbar'))).toThrow(); + }); +}); diff --git a/packages/spec/src/studio/index.ts b/packages/spec/src/studio/index.ts index f82f10b4dc..b50ef96c4e 100644 --- a/packages/spec/src/studio/index.ts +++ b/packages/spec/src/studio/index.ts @@ -17,7 +17,10 @@ export { MetadataViewerContributionSchema, SidebarGroupContributionSchema, ActionContributionSchema, - ActionLocationSchema, + // [#4737] Renamed from `ActionLocationSchema` — that bare name now belongs + // solely to `@objectstack/spec/ui`'s 7-value app-UI vocabulary. This is the + // 3-value Studio IDE surface enum for `ActionContributionSchema.location`. + ActionContributionLocationSchema, MetadataIconContributionSchema, PanelContributionSchema, PanelLocationSchema, @@ -34,6 +37,7 @@ export { type MetadataViewerContribution, type SidebarGroupContribution, type ActionContribution, + type ActionContributionLocation, type MetadataIconContribution, type PanelContribution, type CommandContribution, diff --git a/packages/spec/src/studio/plugin.test.ts b/packages/spec/src/studio/plugin.test.ts index 6ffea20fba..c43e79171c 100644 --- a/packages/spec/src/studio/plugin.test.ts +++ b/packages/spec/src/studio/plugin.test.ts @@ -3,7 +3,7 @@ import { ViewModeSchema, MetadataViewerContributionSchema, SidebarGroupContributionSchema, - ActionLocationSchema, + ActionContributionLocationSchema, ActionContributionSchema, MetadataIconContributionSchema, PanelLocationSchema, @@ -80,15 +80,15 @@ describe('SidebarGroupContributionSchema', () => { }); }); -describe('ActionLocationSchema', () => { +describe('ActionContributionLocationSchema', () => { it('should accept all valid locations', () => { ['toolbar', 'contextMenu', 'commandPalette'].forEach(loc => { - expect(() => ActionLocationSchema.parse(loc)).not.toThrow(); + expect(() => ActionContributionLocationSchema.parse(loc)).not.toThrow(); }); }); it('should reject invalid location', () => { - expect(() => ActionLocationSchema.parse('sidebar')).toThrow(); + expect(() => ActionContributionLocationSchema.parse('sidebar')).toThrow(); }); }); diff --git a/packages/spec/src/studio/plugin.zod.ts b/packages/spec/src/studio/plugin.zod.ts index 4fd4c03da5..73a8e8f9a4 100644 --- a/packages/spec/src/studio/plugin.zod.ts +++ b/packages/spec/src/studio/plugin.zod.ts @@ -138,8 +138,24 @@ export type SidebarGroupContribution = z.infer z.enum(['toolbar', 'contextMenu', 'commandPalette'])); +/** + * Where an action CONTRIBUTION appears inside the Studio IDE shell. + * + * [#4737] This was exported as `ActionLocationSchema` until v17 — the same + * name `@objectstack/spec/ui` exports for a DIFFERENT concept: the 7-value + * platform-canonical vocabulary for where an action renders in a running + * application's UI (`list_toolbar`, `record_header`, …, `global_nav`), whose + * docblock declares it "single source of truth for the whole platform". This + * one is the 3-value Studio IDE surface enum consumed only by + * `ActionContributionSchema.location` below. Same name, disjoint + * vocabularies: which type you got depended on nothing but the import path — + * the #4411 trap (dual-source ledger #4535, cluster C17). Per ADR-0112 D9(a) + * the studio side takes the domain-specific name; the ui side keeps the bare + * name. Do NOT re-export the ui enum under the old name here: that would tell + * studio plugin authors the app-UI vocabulary is valid in a manifest. + */ +export const ActionContributionLocationSchema = lazySchema(() => z.enum(['toolbar', 'contextMenu', 'commandPalette'])); +export type ActionContributionLocation = z.infer; /** * Declares an action that can be triggered on metadata items. @@ -159,7 +175,7 @@ export const ActionContributionSchema = lazySchema(() => strictObject({ icon: z.string().optional().describe('Lucide icon name'), /** Where this action appears */ - location: ActionLocationSchema.describe('UI location'), + location: ActionContributionLocationSchema.describe('UI location'), /** Metadata types this action applies to (empty = all types) */ metadataTypes: z.array(z.string()).default([]).describe('Applicable metadata types'),