-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathaction-location-retirement.test.ts
More file actions
188 lines (172 loc) · 9.94 KB
/
Copy pathaction-location-retirement.test.ts
File metadata and controls
188 lines (172 loc) · 9.94 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
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();
});
});