Skip to content

Commit 9465882

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-4634-driver-capabilities-liveness
2 parents 91130a1 + e6ac4bd commit 9465882

13 files changed

Lines changed: 274 additions & 44 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
BREAKING(spec): `@objectstack/spec/studio` 改名 `ActionLocationSchema``ActionContributionLocationSchema`;裸名 `ActionLocation(Schema)` 现在全包唯一地指 `@objectstack/spec/ui` 的应用 UI 位置词表 (#4737, #4535 C17)
6+
7+
`ActionLocationSchema` 曾由 `./studio``./ui` 各自导出一个声明 —— 同名、词表完全互斥的**两个概念**(#4411 陷阱):
8+
9+
| 入口 | 词表 | 语义 | 处置 |
10+
|:--|:--|:--|:--|
11+
| `./studio`(**改名**) | `toolbar` / `contextMenu` / `commandPalette`(3 值) | Studio IDE 外壳里插件 action contribution 出现的位置(唯一嵌入方 `ActionContributionSchema.location`) |`ActionContributionLocationSchema`,枚举值逐字不变;新增 `ActionContributionLocation` 类型导出(旧 const 从无 type 导出) |
12+
| `./ui`(**一字不动**) | `list_toolbar` / `list_item` / `record_header` / `record_more` / `record_related` / `record_section` / `global_nav`(7 值) | 运行中应用的 UI 上 action 渲染的位置,docblock 自宣全平台唯一真源 | 裸名唯一归属(objectui 按引用钉住 `ACTION_LOCATIONS` 并 re-export 类型族) |
13+
14+
## FROM → TO
15+
16+
```ts
17+
// FROM —— 编译期起以 TS2305 失败(实测 objectstack / cloud / objectui 三仓零外部 importer,预期无人受影响)
18+
import { ActionLocationSchema } from '@objectstack/spec/studio';
19+
20+
// TO —— 同一声明、同一词表,名字点明它唯一的语义
21+
import {
22+
ActionContributionLocationSchema,
23+
type ActionContributionLocation,
24+
} from '@objectstack/spec/studio';
25+
```
26+
27+
**要的是应用 UI 的 action 位置?** `import { ActionLocationSchema, type ActionLocation } from '@objectstack/spec/ui'` —— 本次未动。
28+
29+
不保留旧名别名:在 `./studio` 上 re-export 任何一侧的 `ActionLocationSchema` 都会重开本次关闭的陷阱(要么复活双源,要么把应用 UI 词表谎报成 Studio 清单词表)。
30+
31+
## 零元数据迁移
32+
33+
本次只动 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`

content/docs/references/studio/action.mdx

Lines changed: 0 additions & 29 deletions
This file was deleted.

content/docs/references/studio/meta.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
{
22
"title": "Studio Protocol",
33
"pages": [
4-
"action",
54
"flow-builder",
65
"object-designer",
76
"plugin",

content/docs/references/studio/plugin.mdx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,8 @@ modes: ['preview', 'design', 'data'],
100100
## TypeScript Usage
101101

102102
```typescript
103-
import { ActionContributionSchema, CommandContributionSchema, MetadataIconContributionSchema, MetadataViewerContributionSchema, PanelContributionSchema, PanelLocationSchema, SidebarGroupContributionSchema, StudioPluginContributionsSchema, StudioPluginManifestSchema, ViewModeSchema } from '@objectstack/spec/studio';
104-
import type { ActionContribution, CommandContribution, MetadataIconContribution, MetadataViewerContribution, PanelContribution, SidebarGroupContribution, StudioPluginContributions, StudioPluginManifest, ViewMode } from '@objectstack/spec/studio';
103+
import { ActionContributionSchema, ActionContributionLocationSchema, CommandContributionSchema, MetadataIconContributionSchema, MetadataViewerContributionSchema, PanelContributionSchema, PanelLocationSchema, SidebarGroupContributionSchema, StudioPluginContributionsSchema, StudioPluginManifestSchema, ViewModeSchema } from '@objectstack/spec/studio';
104+
import type { ActionContribution, ActionContributionLocation, CommandContribution, MetadataIconContribution, MetadataViewerContribution, PanelContribution, SidebarGroupContribution, StudioPluginContributions, StudioPluginManifest, ViewMode } from '@objectstack/spec/studio';
105105

106106
// Validate data
107107
const result = ActionContributionSchema.parse(data);
@@ -122,6 +122,17 @@ const result = ActionContributionSchema.parse(data);
122122
| **metadataTypes** | `string[]` || Applicable metadata types |
123123

124124

125+
---
126+
127+
## ActionContributionLocation
128+
129+
### Allowed Values
130+
131+
* `toolbar`
132+
* `contextMenu`
133+
* `commandPalette`
134+
135+
125136
---
126137

127138
## CommandContribution

packages/spec/api-surface.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4000,8 +4000,9 @@
40004000
],
40014001
"./studio": [
40024002
"ActionContribution (type)",
4003+
"ActionContributionLocation (type)",
4004+
"ActionContributionLocationSchema (const)",
40034005
"ActionContributionSchema (const)",
4004-
"ActionLocationSchema (const)",
40054006
"ActivationEvent (type)",
40064007
"ActivationEventSchema (const)",
40074008
"BUILT_IN_NODE_DESCRIPTORS (const)",

packages/spec/docs-import-surface.baseline.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@
8484
"shared/IsolationLevelEnum \u2014 no type export",
8585
"shared/MutationEventEnum \u2014 no type export",
8686
"shared/SortDirectionEnum \u2014 no type export",
87-
"studio/ActionLocation \u2014 no type export",
8887
"studio/PanelLocation \u2014 no type export",
8988
"system/AddFieldOperation \u2014 no type export",
9089
"system/CreateObjectOperation \u2014 no type export",

packages/spec/dual-source-exports.baseline.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
{
22
"_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).",
33
"entries": [
4-
"ActionLocationSchema \u2014 [./studio (const)] \u2260 [./ui (const)]",
54
"PackageDependency \u2014 [./cloud (type)] \u2260 [./kernel (type)]",
65
"PackageDependencySchema \u2014 [./cloud (const)] \u2260 [./kernel (const)]"
76
]

packages/spec/json-schema.manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1143,7 +1143,7 @@
11431143
"shared/TemplateExpressionInput",
11441144
"shared/ViewName",
11451145
"studio/ActionContribution",
1146-
"studio/ActionLocation",
1146+
"studio/ActionContributionLocation",
11471147
"studio/ActivationEvent",
11481148
"studio/CommandContribution",
11491149
"studio/ERDiagramConfig",

packages/spec/scripts/lib/renamed-defs.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,15 @@ export const RENAMED_DEFS: Readonly<Record<string, string>> = {
7979
// distinct concept (client/server offline sync) and the only side with
8080
// cross-repo consumers (objectui useOffline + re-export + parity ratchet).
8181
'integration/ConflictResolution': 'integration/ConnectorConflictResolution',
82+
83+
// #4737 / ADR-0112 D9a — `ActionLocation` was published by ./studio AND ./ui
84+
// for two disjoint concepts. The studio side (3-value IDE surface enum,
85+
// consumed only by `ActionContributionSchema.location`) takes the
86+
// domain-specific name (0-key carry: enum def, no authorable properties).
87+
// `ui/ActionLocation` keeps the bare name: its docblock declares it the
88+
// platform-wide canonical vocabulary and objectui pins it by reference
89+
// (spec-derived-unions.test.ts).
90+
'studio/ActionLocation': 'studio/ActionContributionLocation',
8291
};
8392

8493
/**
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
5+
// ─── [#4737] `ActionLocation` has ONE owner per name (./studio renamed) ──────
6+
//
7+
// Dual-source ledger #4535, cluster C17. Before this change `ActionLocationSchema`
8+
// was exported by TWO entry points for TWO different declarations:
9+
//
10+
// ./studio — 3-value Studio IDE surface enum (`toolbar`, `contextMenu`,
11+
// `commandPalette`), consumed only by `ActionContributionSchema.location`
12+
// inside a Studio plugin manifest;
13+
// ./ui — 7-value platform-canonical vocabulary for where an action
14+
// renders in a RUNNING APP's UI (`list_toolbar`, …, `global_nav`), whose
15+
// docblock declares it "single source of truth for the whole platform".
16+
//
17+
// Same name, disjoint vocabularies: which type a consumer got depended on
18+
// nothing but the import path — the #4411 trap. Resolution (maintainer-ruled,
19+
// #4737, ADR-0112 D9a): the STUDIO side is renamed
20+
// `ActionContributionLocationSchema` (+ a new `ActionContributionLocation`
21+
// type alias — the old const had no type export at all), with a RENAMED_DEFS
22+
// carry (`studio/ActionLocation` → `studio/ActionContributionLocation`,
23+
// 0 authorable keys — enum def). The ui side is UNTOUCHED: it is the only
24+
// side with cross-repo consumers (objectui pins `ACTION_LOCATIONS` by
25+
// reference in spec-derived-unions.test.ts and re-exports the type family),
26+
// so renaming it would replay the objectui#3235 downstream breakage.
27+
//
28+
// #4642 established that a compile-time conditional-type pin in this package
29+
// is a no-op (tsconfig excludes `**/*.test.ts`; vitest never enables
30+
// `typecheck`), so the load-bearing pin is the compiler-API test below, with
31+
// anti-vacuity guards; sabotage-verified in the PR (re-exporting the ui enum
32+
// from ./studio under the bare name — green to the dual-source gate, a lie to
33+
// authors — and resurrecting the old 3-value const each turn it red).
34+
describe('[#4737] studio ActionLocation dual-source retirement', () => {
35+
it('resolves the export surface: one owner per name, across every public entry', async () => {
36+
const ts = (await import('typescript')).default;
37+
const { resolve, relative } = await import('node:path');
38+
const { dirname } = await import('node:path');
39+
const { fileURLToPath } = await import('node:url');
40+
const { readFileSync } = await import('node:fs');
41+
42+
const specDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
43+
// Every public entry point, read from package.json's exports map so a
44+
// future entry cannot silently escape the uniqueness pins below.
45+
const pkg = JSON.parse(readFileSync(resolve(specDir, 'package.json'), 'utf8')) as {
46+
exports: Record<string, unknown>;
47+
};
48+
const entries: Record<string, string> = {};
49+
for (const sub of Object.keys(pkg.exports)) {
50+
if (sub === '.') entries[sub] = resolve(specDir, 'src/index.ts');
51+
else if (/^\.\/[a-z-]+$/.test(sub)) entries[sub] = resolve(specDir, `src/${sub.slice(2)}/index.ts`);
52+
// './openapi.json' / './package.json' are not TypeScript entry points.
53+
}
54+
// Anti-vacuity: the enumeration must have found the real surface.
55+
for (const needed of ['./studio', './ui']) {
56+
expect(Object.keys(entries), `exports map must include ${needed}`).toContain(needed);
57+
}
58+
expect(Object.keys(entries).length).toBeGreaterThan(10);
59+
60+
const program = ts.createProgram(Object.values(entries), {
61+
module: ts.ModuleKind.ESNext,
62+
moduleResolution: ts.ModuleResolutionKind.Bundler,
63+
skipLibCheck: true,
64+
noEmit: true,
65+
});
66+
const checker = program.getTypeChecker();
67+
const unalias = (s: import('typescript').Symbol) =>
68+
s.getFlags() & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(s) : s;
69+
70+
const exportsOf = (sub: string) => {
71+
const sf = program.getSourceFile(entries[sub]);
72+
const moduleSym = sf && checker.getSymbolAtLocation(sf);
73+
// Without this guard a resolution failure would make every assertion
74+
// below pass vacuously — the exact way a gate goes dormant (#4642).
75+
expect(moduleSym, `${sub} module symbol must resolve`).toBeTruthy();
76+
return checker.getExportsOfModule(moduleSym!);
77+
};
78+
79+
const originOf = (sym: import('typescript').Symbol, label: string) => {
80+
const decl = unalias(sym).declarations?.[0];
81+
expect(decl, `${label} must have a declaration`).toBeTruthy();
82+
const declFile = decl!.getSourceFile();
83+
return `${relative(specDir, declFile.fileName)}:${
84+
declFile.getLineAndCharacterOfPosition(decl!.getStart()).line + 1
85+
}`;
86+
};
87+
88+
/** Every entry that exports `name`, with each occurrence's declaration origin. */
89+
const holdersOf = (name: string) => {
90+
const out: Array<{ sub: string; origin: string }> = [];
91+
for (const sub of Object.keys(entries)) {
92+
for (const sym of exportsOf(sub).filter((e) => e.getName() === name)) {
93+
out.push({ sub, origin: originOf(sym, `${sub} ${name}`) });
94+
}
95+
}
96+
return out;
97+
};
98+
99+
// 1. The renamed-away side: `./studio` still has a non-trivial surface —
100+
// so the `not.toContain` cannot pass by resolving nothing — and no
101+
// longer names the old export, while surviving neighbours stand.
102+
const studioNames = exportsOf('./studio').map((e) => e.getName());
103+
expect(studioNames.length, './studio must export a non-trivial surface').toBeGreaterThan(40);
104+
for (const retired of ['ActionLocationSchema', 'ActionLocation']) {
105+
expect(studioNames, `./studio must not export ${retired}`).not.toContain(retired);
106+
}
107+
expect(studioNames).toContain('ActionContributionSchema');
108+
expect(studioNames).toContain('StudioPluginManifestSchema');
109+
110+
// 2. The renamed side: `ActionContributionLocation(Schema)` originates in
111+
// studio/plugin.zod.ts and is exported by ./studio alone (plus nothing
112+
// else — the rename must not fan out).
113+
for (const name of ['ActionContributionLocationSchema', 'ActionContributionLocation']) {
114+
const holders = holdersOf(name);
115+
expect(holders.length, `${name} must be exported (by ./studio)`).toBeGreaterThan(0);
116+
for (const h of holders) {
117+
expect(h.sub, `${name} must only be exported by ./studio`).toBe('./studio');
118+
expect(h.origin).toMatch(/^src\/studio\/plugin\.zod\.ts:\d+$/);
119+
}
120+
}
121+
122+
// 3. The bare `ActionLocation(Schema)` now has exactly ONE owner: ./ui,
123+
// declared in ui/action.zod.ts. Not just "same declaration everywhere"
124+
// — NO other entry may export the bare name at all. A re-export from
125+
// ./studio would share the declaration (green to the dual-source gate)
126+
// while telling Studio plugin authors that `list_toolbar` & co. are
127+
// valid manifest contribution locations — the C14/C15 lesson: a
128+
// re-export can lie about the domain even when the symbol is honest.
129+
for (const name of ['ActionLocationSchema', 'ActionLocation']) {
130+
const holders = holdersOf(name);
131+
expect(holders.map((h) => h.sub), `${name} must be owned by ./ui alone`).toEqual(['./ui']);
132+
expect(holders[0].origin).toMatch(/^src\/ui\/action\.zod\.ts:\d+$/);
133+
}
134+
135+
// 4. The canonical list constant travels with it: `ACTION_LOCATIONS` (the
136+
// array objectui pins by reference) stays ./ui-owned and distinct from
137+
// the studio declaration file.
138+
const listHolders = holdersOf('ACTION_LOCATIONS');
139+
expect(listHolders.map((h) => h.sub), 'ACTION_LOCATIONS must be owned by ./ui alone').toEqual(['./ui']);
140+
expect(listHolders[0].origin).toMatch(/^src\/ui\/action\.zod\.ts:\d+$/);
141+
});
142+
143+
it('keeps the runtime namespaces consistent with the compiler view', async () => {
144+
const studio = await import('./index');
145+
const ui = await import('../ui/index');
146+
147+
// Renamed-away side — the old const is gone from ./studio at runtime too.
148+
expect('ActionLocationSchema' in studio, 'studio must not export ActionLocationSchema').toBe(false);
149+
// Anti-vacuity: the namespace we just probed is real and non-trivial.
150+
expect('StudioPluginManifestSchema' in studio).toBe(true);
151+
152+
// Renamed side — the Studio IDE vocabulary, byte-for-byte unchanged.
153+
expect('ActionContributionLocationSchema' in studio).toBe(true);
154+
for (const loc of ['toolbar', 'contextMenu', 'commandPalette']) {
155+
expect(() => studio.ActionContributionLocationSchema.parse(loc)).not.toThrow();
156+
}
157+
// The two vocabularies were disjoint precisely here — the app-UI values
158+
// must NOT leak into the Studio contribution enum:
159+
expect(() => studio.ActionContributionLocationSchema.parse('list_toolbar')).toThrow();
160+
expect(() => studio.ActionContributionLocationSchema.parse('global_nav')).toThrow();
161+
162+
// ui side — untouched, and still the 7-value app-UI vocabulary.
163+
expect('ActionLocationSchema' in ui).toBe(true);
164+
expect('ACTION_LOCATIONS' in ui).toBe(true);
165+
for (const loc of ['list_toolbar', 'record_header', 'global_nav']) {
166+
expect(() => ui.ActionLocationSchema.parse(loc)).not.toThrow();
167+
}
168+
expect(() => ui.ActionLocationSchema.parse('toolbar')).toThrow();
169+
expect(() => ui.ActionLocationSchema.parse('commandPalette')).toThrow();
170+
});
171+
172+
it('still parses an authored plugin manifest through the renamed enum — the live path', async () => {
173+
const { StudioPluginManifestSchema } = await import('./plugin.zod');
174+
const manifestWith = (location: string) => ({
175+
id: 'objectstack.object-designer',
176+
name: 'Object Designer',
177+
contributes: {
178+
actions: [{ id: 'deploy', label: 'Deploy', location }],
179+
},
180+
});
181+
const parsed = StudioPluginManifestSchema.parse(manifestWith('toolbar'));
182+
expect(parsed.contributes.actions[0].location).toBe('toolbar');
183+
// The authored VALUE domain did not move an inch with the TS rename; the
184+
// SAME document differing only in this one value stays illegal (so this
185+
// negative cannot pass for an unrelated reason):
186+
expect(() => StudioPluginManifestSchema.parse(manifestWith('list_toolbar'))).toThrow();
187+
});
188+
});

0 commit comments

Comments
 (0)