Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/action-location-dual-source-c17.md
Original file line number Diff line number Diff line change
@@ -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`。
29 changes: 0 additions & 29 deletions content/docs/references/studio/action.mdx

This file was deleted.

1 change: 0 additions & 1 deletion content/docs/references/studio/meta.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"title": "Studio Protocol",
"pages": [
"action",
"flow-builder",
"object-designer",
"plugin",
Expand Down
15 changes: 13 additions & 2 deletions content/docs/references/studio/plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -122,6 +122,17 @@ const result = ActionContributionSchema.parse(data);
| **metadataTypes** | `string[]` | ✅ | Applicable metadata types |


---

## ActionContributionLocation

### Allowed Values

* `toolbar`
* `contextMenu`
* `commandPalette`


---

## CommandContribution
Expand Down
3 changes: 2 additions & 1 deletion packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -4000,8 +4000,9 @@
],
"./studio": [
"ActionContribution (type)",
"ActionContributionLocation (type)",
"ActionContributionLocationSchema (const)",
"ActionContributionSchema (const)",
"ActionLocationSchema (const)",
"ActivationEvent (type)",
"ActivationEventSchema (const)",
"BUILT_IN_NODE_DESCRIPTORS (const)",
Expand Down
1 change: 0 additions & 1 deletion packages/spec/docs-import-surface.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@
"shared/IsolationLevelEnum \u2014 no type export",
"shared/MutationEventEnum \u2014 no type export",
"shared/SortDirectionEnum \u2014 no type export",
"studio/ActionLocation \u2014 no type export",
"studio/PanelLocation \u2014 no type export",
"system/AddFieldOperation \u2014 no type export",
"system/CreateObjectOperation \u2014 no type export",
Expand Down
1 change: 0 additions & 1 deletion packages/spec/dual-source-exports.baseline.json
Original file line number Diff line number Diff line change
@@ -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 \u2014 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 \u2014 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 \u2014 [./studio (const)] \u2260 [./ui (const)]",
"PackageDependency \u2014 [./cloud (type)] \u2260 [./kernel (type)]",
"PackageDependencySchema \u2014 [./cloud (const)] \u2260 [./kernel (const)]"
]
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/json-schema.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1143,7 +1143,7 @@
"shared/TemplateExpressionInput",
"shared/ViewName",
"studio/ActionContribution",
"studio/ActionLocation",
"studio/ActionContributionLocation",
"studio/ActivationEvent",
"studio/CommandContribution",
"studio/ERDiagramConfig",
Expand Down
9 changes: 9 additions & 0 deletions packages/spec/scripts/lib/renamed-defs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ export const RENAMED_DEFS: Readonly<Record<string, string>> = {
// 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',
};

/**
Expand Down
188 changes: 188 additions & 0 deletions packages/spec/src/studio/action-location-retirement.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
};
const entries: Record<string, string> = {};
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();
});
});
6 changes: 5 additions & 1 deletion packages/spec/src/studio/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,6 +37,7 @@ export {
type MetadataViewerContribution,
type SidebarGroupContribution,
type ActionContribution,
type ActionContributionLocation,
type MetadataIconContribution,
type PanelContribution,
type CommandContribution,
Expand Down
8 changes: 4 additions & 4 deletions packages/spec/src/studio/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
ViewModeSchema,
MetadataViewerContributionSchema,
SidebarGroupContributionSchema,
ActionLocationSchema,
ActionContributionLocationSchema,
ActionContributionSchema,
MetadataIconContributionSchema,
PanelLocationSchema,
Expand Down Expand Up @@ -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();
});
});

Expand Down
Loading
Loading