|
| 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