@astrale-os/cli 0.8.1-alpha.6 → 1.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +18 -5
  2. package/dist/astrale.js +2661 -2714
  3. package/dist/public/connect-core.js +2019 -3050
  4. package/dist/public/keys/index.js +1851 -2885
  5. package/dist/public/paths/index.js +1830 -2872
  6. package/dist/types/connection/auth.d.ts +3 -0
  7. package/dist/types/lib/instance.d.ts +10 -0
  8. package/package.json +9 -9
  9. package/src/commands/__tests__/domain-install-operation.test.ts +121 -0
  10. package/src/commands/__tests__/domain-install-owned.test.ts +66 -0
  11. package/src/commands/__tests__/domain-uninstall.test.ts +53 -0
  12. package/src/commands/__tests__/install-direct.test.ts +3 -2
  13. package/src/commands/__tests__/install-identity-override.test.ts +14 -3
  14. package/src/commands/__tests__/instance-bookmark.test.ts +66 -1
  15. package/src/commands/__tests__/instance-list-rows.test.ts +1 -0
  16. package/src/commands/__tests__/instance-use.test.ts +67 -0
  17. package/src/commands/__tests__/view-build.test.ts +58 -0
  18. package/src/commands/domain/install.ts +84 -20
  19. package/src/commands/domain/uninstall.ts +128 -0
  20. package/src/commands/instance/active.ts +13 -1
  21. package/src/commands/instance/bookmark.ts +26 -3
  22. package/src/commands/instance/list.ts +18 -4
  23. package/src/commands/instance/use.ts +54 -7
  24. package/src/commands/view.ts +28 -16
  25. package/src/connection/.spec/architecture.md +5 -0
  26. package/src/connection/.spec/laws/connection.ts +20 -0
  27. package/src/connection/.spec/layout.ts +1 -0
  28. package/src/connection/__tests__/auth.test.ts +27 -1
  29. package/src/connection/__tests__/ca-fetch.test.ts +8 -1
  30. package/src/connection/__tests__/errors.test.ts +483 -33
  31. package/src/connection/__tests__/exchange.test.ts +46 -5
  32. package/src/connection/__tests__/reasons.test.ts +78 -0
  33. package/src/connection/auth.ts +11 -9
  34. package/src/connection/command.ts +9 -1
  35. package/src/connection/errors.ts +149 -159
  36. package/src/connection/exchange.ts +14 -2
  37. package/src/connection/index.ts +1 -1
  38. package/src/connection/reasons.ts +193 -0
  39. package/src/lib/__tests__/instance.test.ts +51 -1
  40. package/src/lib/__tests__/view-assets.test.ts +33 -1
  41. package/src/lib/__tests__/view-server.test.ts +68 -0
  42. package/src/lib/ca-fetch.ts +9 -3
  43. package/src/lib/instance.ts +31 -0
  44. package/src/lib/view/assets.ts +16 -2
  45. package/src/program/__tests__/program.test.ts +2 -1
  46. package/src/program/build.ts +2 -1
  47. package/studio/client/dist/assets/{elk-api-D0cBetPW.js → elk-api-D2xgMJvi.js} +1 -1
  48. package/studio/client/dist/assets/{index-BQnJ5sgd.css → index-BMdnsIJA.css} +1 -1
  49. package/studio/client/dist/assets/index-D-vRV8w7.js +8 -0
  50. package/studio/client/dist/assets/index-LGSWRrk8.js +81 -0
  51. package/studio/client/dist/index.html +2 -2
  52. package/studio/package.json +8 -9
  53. package/studio/server/agent/prompts/anchors.test.ts +74 -0
  54. package/studio/server/agent/prompts/anchors.ts +85 -15
  55. package/studio/server/agent/prompts/system.test.ts +12 -0
  56. package/studio/server/agent/prompts/system.ts +6 -6
  57. package/studio/server/api.ts +1 -5
  58. package/studio/server/cache.ts +5 -2
  59. package/studio/server/domain.test.ts +67 -0
  60. package/studio/server/domain.ts +29 -6
  61. package/studio/server/index.ts +1 -3
  62. package/studio/server/introspect/anatomy-extras.test.ts +104 -1
  63. package/studio/server/introspect/anatomy-extras.ts +340 -8
  64. package/studio/server/introspect/anatomy.test.ts +33 -0
  65. package/studio/server/introspect/anatomy.ts +22 -7
  66. package/studio/server/introspect/bundle.ts +7 -1
  67. package/studio/server/introspect/canonical-schema.test.ts +395 -0
  68. package/studio/server/introspect/canonical-schema.ts +751 -0
  69. package/studio/server/introspect/core-extractor.ts +30 -10
  70. package/studio/server/introspect/core.ts +4 -2
  71. package/studio/server/introspect/diff.test.ts +124 -0
  72. package/studio/server/introspect/diff.ts +252 -23
  73. package/studio/server/introspect/extractor.ts +46 -14
  74. package/studio/server/introspect/overlay-tsmorph.test.ts +164 -1
  75. package/studio/server/introspect/overlay-tsmorph.ts +381 -106
  76. package/studio/server/introspect/overlay.test.ts +72 -0
  77. package/studio/server/introspect/overlay.ts +16 -6
  78. package/studio/server/introspect/runtime.test.ts +217 -0
  79. package/studio/server/introspect/runtime.ts +18 -6
  80. package/studio/server/introspect/schema-refs.test.ts +100 -0
  81. package/studio/server/introspect/schema-refs.ts +23 -2
  82. package/studio/server/state/baseline.test.ts +51 -0
  83. package/studio/server/state/baseline.ts +31 -2
  84. package/studio/server/state/create.test.ts +37 -0
  85. package/studio/server/state/create.ts +21 -15
  86. package/studio/server/state/instance.test.ts +63 -0
  87. package/studio/server/state/instance.ts +65 -29
  88. package/studio/server/state/views.test.ts +209 -9
  89. package/studio/server/state/views.ts +176 -69
  90. package/studio/server/watch.test.ts +36 -0
  91. package/studio/server/watch.ts +24 -16
  92. package/studio/server/workspace-watch.ts +8 -3
  93. package/studio/shared/types.ts +164 -33
  94. package/viewer/dist/main.js +57 -57
  95. package/studio/client/dist/assets/index-Dspir4w7.js +0 -81
  96. package/studio/client/dist/assets/index-bVD2KJgz.js +0 -8
  97. package/studio/server/view-dev-server.test.ts +0 -111
  98. package/studio/server/view-dev-server.ts +0 -372
@@ -15,7 +15,8 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
15
15
  import { join } from 'node:path'
16
16
 
17
17
  import { getBundle } from '../cache'
18
- import { registerDomain } from '../domain'
18
+ import { isDomainDir, registerDomain } from '../domain'
19
+ import { findSchemaDefinition } from '../introspect/anatomy-extras'
19
20
  import { bootDomain } from '../lifecycle'
20
21
  import { stoppers, workspaceRoot, workspaceSchemaDirName } from '../workspace-state'
21
22
 
@@ -87,7 +88,8 @@ export async function createDomain(
87
88
  ...(instance ? ['--instance', instance] : []),
88
89
  ]
89
90
  const scaffold = await run('npx', scaffoldArgs, root)
90
- if (!existsSync(join(dir, 'domain.ts')) || !existsSync(join(dir, 'astrale.config.ts'))) {
91
+ const schemaDirName = workspaceSchemaDirName()
92
+ if (!isDomainDir(dir, schemaDirName)) {
91
93
  return {
92
94
  ok: false,
93
95
  error: 'Scaffolding did not produce a domain. See the log.',
@@ -97,7 +99,7 @@ export async function createDomain(
97
99
 
98
100
  // 1b. Flag the placeholder origin for the agent (we ask for the name only, so the
99
101
  // origin is `<name>.example.dev` until someone — or the agent — sets the real one).
100
- annotateOrigin(dir)
102
+ annotateOrigin(dir, schemaDirName)
101
103
 
102
104
  // 2. Install deps so the domain is fully introspectable + deployable. Best-effort:
103
105
  // a scaffolded-but-uninstalled domain still loads (static fallback), so a failed
@@ -106,11 +108,11 @@ export async function createDomain(
106
108
 
107
109
  // 3. Register + boot with deps present. The live watcher may have already booted a
108
110
  // deps-less fallback for this dir mid-install — stop it and boot fresh.
109
- const handle = registerDomain(dir, workspaceSchemaDirName())
111
+ const handle = registerDomain(dir, schemaDirName)
110
112
  if (!handle) {
111
113
  return {
112
114
  ok: false,
113
- error: 'Scaffold incomplete — the domain triple is missing.',
115
+ error: 'Scaffold incomplete — config, composition entry, or schema index is missing.',
114
116
  output: combine(scaffold, install),
115
117
  }
116
118
  }
@@ -129,27 +131,31 @@ function combine(a: { output: string }, b: { output: string }): string {
129
131
  }
130
132
 
131
133
  /**
132
- * Drop an agent-actionable marker on the origin line of the scaffolded
133
- * schema/index.ts. We only ask for the NAME, so the origin starts as the
134
+ * Drop an agent-actionable marker on the origin line of the scaffolded schema.
135
+ * The public schema/index.ts is now commonly a barrel, so the definition is
136
+ * located statically below it. We only ask for the NAME, so the origin starts as the
134
137
  * `<name>.example.dev` placeholder — this comment tells a human (or the studio's
135
138
  * agent) it's a placeholder to change, and that the studio re-parses the literal
136
139
  * as the source of truth so the rename takes effect on save. Best-effort: the
137
140
  * template already explains the origin, so a parse miss is harmless.
138
141
  */
139
- function annotateOrigin(dir: string): void {
140
- const file = join(dir, workspaceSchemaDirName(), 'index.ts')
142
+ export function annotateOrigin(dir: string, schemaDirName = 'schema'): void {
143
+ const definition = findSchemaDefinition(dir, schemaDirName)
144
+ if (!definition) return
145
+ const file = definition.file
141
146
  try {
142
147
  const src = readFileSync(file, 'utf8')
143
148
  if (src.includes('ORIGIN —')) return // already annotated (idempotent)
144
- const m = src.match(/^([ \t]*)export const schema = defineSchema\(/m)
145
- if (!m) return
146
- const pad = m[1] ?? ''
149
+ const lines = src.split('\n')
150
+ const index = Math.max(0, definition.line - 1)
151
+ const pad = lines[index]?.match(/^[ \t]*/)?.[0] ?? ''
147
152
  const note =
148
153
  `${pad}// ORIGIN — the domain's permanent identity in the graph. It was set from the name as a\n` +
149
- `${pad}// PLACEHOLDER below; change it to your real domain (e.g. "crm.acme.dev"), ideally BEFORE\n` +
154
+ `${pad}// PLACEHOLDER; change the literal or constant used here (e.g. to "crm.acme.dev"), ideally BEFORE\n` +
150
155
  `${pad}// the first deploy (the origin is hard to change once installed). The studio parses this\n` +
151
- `${pad}// literal as the source of truth, so the rename refreshes on save — no other file to touch.\n`
152
- writeFileSync(file, src.replace(m[0], note + m[0]))
156
+ `${pad}// schema statically as the source of truth, so the rename refreshes on save.\n`
157
+ lines.splice(index, 0, note.trimEnd())
158
+ writeFileSync(file, lines.join('\n'))
153
159
  } catch {
154
160
  /* best-effort */
155
161
  }
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { schemaHashOf } from '../introspect/hash'
4
+ import {
5
+ installedBundleRootHash,
6
+ installedDomainCommand,
7
+ installedDomainProbeResult,
8
+ } from './instance'
9
+
10
+ describe('installed Domain introspection', () => {
11
+ test('uses the public bundle introspection command for the selected instance', () => {
12
+ expect(installedDomainCommand('issues.astrale.ai', 'staging')).toEqual([
13
+ 'astrale',
14
+ 'introspect',
15
+ 'issues.astrale.ai',
16
+ '--bundle',
17
+ '--json',
18
+ '-i',
19
+ 'staging',
20
+ ])
21
+ })
22
+
23
+ test('hashes the installed Bundle root with the same deterministic hash as local schema', () => {
24
+ const root = {
25
+ version: 'v1',
26
+ domain: 'issues.astrale.ai',
27
+ classes: { Issue: { properties: { title: { type: 'string' } } } },
28
+ }
29
+ const probe = installedDomainProbeResult(
30
+ JSON.stringify({
31
+ state: 'ready',
32
+ bundle: { format: 'astrale.dsl.bundle', version: 'v1', root, closure: [] },
33
+ }),
34
+ '',
35
+ 0,
36
+ )
37
+ expect(probe.state).toBe('installed')
38
+ expect(installedBundleRootHash(probe)).toBe(schemaHashOf(root))
39
+ })
40
+
41
+ test('does not mistake the removed Domain Node shape for installation introspection', () => {
42
+ expect(
43
+ installedDomainProbeResult(
44
+ JSON.stringify({ path: '/issues.astrale.ai', props: { schema: '{"domain":"old"}' } }),
45
+ '',
46
+ 0,
47
+ ),
48
+ ).toEqual({ state: 'unknown', root: null })
49
+ })
50
+
51
+ test('recognizes the current CLI not-installed diagnostic', () => {
52
+ expect(
53
+ installedDomainProbeResult(
54
+ '',
55
+ JSON.stringify({
56
+ error: 'DOMAIN_NOT_INSTALLED',
57
+ message: 'Domain issues.astrale.ai is not installed on this Kernel.',
58
+ }),
59
+ 1,
60
+ ),
61
+ ).toEqual({ state: 'not-installed', root: null })
62
+ })
63
+ })
@@ -4,8 +4,9 @@
4
4
  * The ACTIVE instance is GLOBAL (not per-domain) and the `astrale` CLI owns it —
5
5
  * we never keep our own copy: `listInstances` reads `astrale instance list`,
6
6
  * `setActiveInstance` runs `astrale instance use`. Install + drift are GROUND
7
- * TRUTH, queried from the target instance (`astrale get /<origin>`) — NOT a local
8
- * record — so a deploy done outside the studio (CLI/terminal) is still seen.
7
+ * TRUTH, queried from the target instance (`astrale introspect <origin>`) — NOT
8
+ * a local record — so a deploy done outside the studio (CLI/terminal) is still
9
+ * seen.
9
10
  *
10
11
  * Deploy (`pnpm prod`) = the managed astrale adapter: deploy + auto-install on
11
12
  * the configured instance; we capture the printed service URL.
@@ -117,18 +118,70 @@ export function lastDeploy(root: string): DeployRecord | null {
117
118
  }
118
119
 
119
120
  /**
120
- * Ask the target instance for the domain node (`astrale get /<origin> -i <instance>`).
121
- * Installed ⇒ the kernel returns the Domain node (with the installed schema in
122
- * `props.schema`); `NOT_FOUND` ⇒ not installed; anything else (offline / not
123
- * authed / unknown instance) ⇒ unknown (never falsely "not installed").
121
+ * Ask the target instance for its public installation introspection. Installed
122
+ * ⇒ the Kernel returns the admitted Bundle, whose root is the exact schema value
123
+ * hashed locally; `DOMAIN_NOT_INSTALLED` ⇒ not installed; anything else
124
+ * (offline / not authed / unknown instance) ⇒ unknown (never falsely
125
+ * "not installed").
124
126
  */
127
+ export function installedDomainCommand(origin: string, instance: string): string[] {
128
+ return ['astrale', 'introspect', origin, '--bundle', '--json', '-i', instance]
129
+ }
130
+
131
+ type InstalledDomainProbe =
132
+ | { state: 'installed'; root: unknown }
133
+ | { state: 'not-installed' | 'unknown'; root: null }
134
+
135
+ function jsonObject(text: string): Record<string, unknown> | null {
136
+ try {
137
+ const value = JSON.parse(text)
138
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null
139
+ } catch {
140
+ return null
141
+ }
142
+ }
143
+
144
+ export function installedDomainProbeResult(
145
+ stdout: string,
146
+ stderr: string,
147
+ exitCode: number,
148
+ ): InstalledDomainProbe {
149
+ const output = jsonObject(stdout)
150
+ const diagnostic = jsonObject(stderr)
151
+ const error = output?.error ?? diagnostic?.error
152
+ if (
153
+ error === 'DOMAIN_NOT_INSTALLED' ||
154
+ error === 'NOT_FOUND' ||
155
+ /\b(?:DOMAIN_NOT_INSTALLED|NOT_FOUND)\b/.test(`${stdout}\n${stderr}`)
156
+ ) {
157
+ return { state: 'not-installed', root: null }
158
+ }
159
+
160
+ if (exitCode === 0) {
161
+ const bundle = output?.bundle
162
+ if (
163
+ bundle !== null &&
164
+ typeof bundle === 'object' &&
165
+ !Array.isArray(bundle) &&
166
+ Object.prototype.hasOwnProperty.call(bundle, 'root')
167
+ ) {
168
+ return { state: 'installed', root: (bundle as { root: unknown }).root }
169
+ }
170
+ }
171
+ return { state: 'unknown', root: null }
172
+ }
173
+
174
+ export function installedBundleRootHash(probe: InstalledDomainProbe): string | null {
175
+ return probe.state === 'installed' ? schemaHashOf(probe.root) : null
176
+ }
177
+
125
178
  async function getInstalledDomain(
126
179
  origin: string,
127
180
  instance: string,
128
181
  timeoutMs = 8000,
129
- ): Promise<{ state: 'installed' | 'not-installed' | 'unknown'; schema: unknown | null }> {
182
+ ): Promise<InstalledDomainProbe> {
130
183
  try {
131
- const proc = Bun.spawn(['astrale', 'get', `/${origin}`, '-i', instance, '--json'], {
184
+ const proc = Bun.spawn(installedDomainCommand(origin, instance), {
132
185
  stdout: 'pipe',
133
186
  stderr: 'pipe',
134
187
  })
@@ -144,30 +197,13 @@ async function getInstalledDomain(
144
197
  new Response(proc.stdout).text(),
145
198
  new Response(proc.stderr).text(),
146
199
  ])
147
- await proc.exited
148
- let parsed: any = null
149
- try {
150
- parsed = JSON.parse(out)
151
- } catch {
152
- /* non-JSON */
153
- }
154
- if (parsed?.error === 'NOT_FOUND' || /\bNOT_FOUND\b/.test(`${out}\n${err}`))
155
- return { state: 'not-installed', schema: null }
156
- if (parsed?.path && parsed?.props) {
157
- let schema: unknown = null
158
- try {
159
- schema = parsed.props.schema ? JSON.parse(parsed.props.schema) : null
160
- } catch {
161
- /* schema absent/unparseable — still installed */
162
- }
163
- return { state: 'installed', schema }
164
- }
165
- return { state: 'unknown', schema: null }
200
+ const code = await proc.exited
201
+ return installedDomainProbeResult(out, err, code)
166
202
  } finally {
167
203
  clearTimeout(timer)
168
204
  }
169
205
  } catch {
170
- return { state: 'unknown', schema: null }
206
+ return { state: 'unknown', root: null }
171
207
  }
172
208
  }
173
209
 
@@ -187,7 +223,7 @@ export async function instanceStatus(
187
223
  const probe = await getInstalledDomain(origin, deployTarget)
188
224
  if (probe.state === 'installed') {
189
225
  install = 'installed'
190
- installedHash = probe.schema ? schemaHashOf(probe.schema) : null
226
+ installedHash = installedBundleRootHash(probe)
191
227
  drift =
192
228
  installedHash && localHash
193
229
  ? installedHash === localHash
@@ -1,24 +1,224 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
 
3
- import type { RememberedViewTarget, ViewTargetCandidate } from '../../shared/types'
3
+ import type {
4
+ RememberedViewTarget,
5
+ StudioSchemaBundle,
6
+ ViewInfo,
7
+ ViewTargetCandidate,
8
+ } from '../../shared/types'
4
9
 
5
10
  import {
6
11
  conciseCliFailure,
7
- localViewUrl,
12
+ readyViewSession,
8
13
  reconcileRememberedTarget,
9
14
  targetDefinition,
10
15
  targetFromRow,
16
+ viewDefinitionBindings,
17
+ viewSessionArgs,
11
18
  } from './views'
12
19
 
13
- describe('managed local view URLs', () => {
14
- test('mounts each view beneath the Studio-assigned server origin', () => {
15
- expect(localViewUrl('http://127.0.0.1:5173', '/ui/issues')).toBe(
16
- 'http://127.0.0.1:5173/ui/issues',
17
- )
18
- expect(localViewUrl('http://127.0.0.1:61001/ignored', 'ui/issue')).toBe(
19
- 'http://127.0.0.1:61001/ui/issue',
20
+ describe('view session command', () => {
21
+ test('opens the installed ViewPath without rejected placement overrides', () => {
22
+ const args = viewSessionArgs('issues.astrale.ai', 'issue-detail', 'staging', '@issue-1')
23
+ expect(args).toEqual([
24
+ 'view',
25
+ '/:issues.astrale.ai:view.issue-detail',
26
+ '--target',
27
+ '@issue-1',
28
+ '--no-open',
29
+ '--json',
30
+ '-i',
31
+ 'staging',
32
+ ])
33
+ expect(args).not.toContain('--view-url')
34
+ expect(args).not.toContain('--handshake')
35
+ })
36
+
37
+ test('omits --target for a standalone view', () => {
38
+ expect(viewSessionArgs('issues.astrale.ai', 'dashboard', 'local')).toEqual([
39
+ 'view',
40
+ '/:issues.astrale.ai:view.dashboard',
41
+ '--no-open',
42
+ '--json',
43
+ '-i',
44
+ 'local',
45
+ ])
46
+ })
47
+
48
+ test('preserves canonical interface View target coordinates', () => {
49
+ const view = { slug: 'users', kind: 'unknown' } satisfies ViewInfo
50
+ const bundle = {
51
+ ir: {
52
+ views: {
53
+ users: {
54
+ name: 'users',
55
+ auth: 'required',
56
+ target: {
57
+ kind: 'definition',
58
+ definitions: [{ origin: 'kernel.astrale.ai', kind: 'interface', name: 'Identity' }],
59
+ },
60
+ },
61
+ },
62
+ },
63
+ } as unknown as StudioSchemaBundle
64
+
65
+ expect(viewDefinitionBindings('shell.astrale.ai', view, bundle)).toEqual([
66
+ {
67
+ className: 'Identity',
68
+ classOrigin: 'kernel.astrale.ai',
69
+ kind: 'interface',
70
+ },
71
+ ])
72
+ expect(targetDefinition('Identity', 'kernel.astrale.ai', 'interface')).toBe(
73
+ '/:kernel.astrale.ai:interface.Identity',
20
74
  )
21
75
  })
76
+
77
+ test('preserves every exact canonical homonym and ignores short-name metadata', () => {
78
+ const view = {
79
+ slug: 'named',
80
+ kind: 'unknown',
81
+ viewFor: 'WrongLegacyTarget',
82
+ } satisfies ViewInfo
83
+ const bundle = {
84
+ ir: {
85
+ views: {
86
+ named: {
87
+ name: 'named',
88
+ auth: 'required',
89
+ target: {
90
+ kind: 'definition',
91
+ definitions: [
92
+ { origin: 'directory.example.dev', kind: 'interface', name: 'Named' },
93
+ { origin: 'people.example.dev', kind: 'interface', name: 'Named' },
94
+ { origin: 'catalog.example.dev', kind: 'class', name: 'Named' },
95
+ ],
96
+ },
97
+ },
98
+ },
99
+ },
100
+ } as unknown as StudioSchemaBundle
101
+
102
+ expect(viewDefinitionBindings('shell.astrale.ai', view, bundle)).toEqual([
103
+ { className: 'Named', classOrigin: 'directory.example.dev', kind: 'interface' },
104
+ { className: 'Named', classOrigin: 'people.example.dev', kind: 'interface' },
105
+ { className: 'Named', classOrigin: 'catalog.example.dev', kind: 'class' },
106
+ ])
107
+ })
108
+
109
+ test('treats an exact Domain target as authoritative over legacy viewFor', () => {
110
+ const view = { slug: 'home', kind: 'unknown', viewFor: 'User' } satisfies ViewInfo
111
+ const bundle = {
112
+ ir: {
113
+ views: {
114
+ home: { name: 'home', auth: 'required', target: { kind: 'domain' } },
115
+ },
116
+ },
117
+ } as unknown as StudioSchemaBundle
118
+
119
+ expect(viewDefinitionBindings('shell.astrale.ai', view, bundle)).toEqual([])
120
+ })
121
+
122
+ test('uses every exact fallback candidate across origin and definition-kind collisions', () => {
123
+ const view = { slug: 'legacy-named', kind: 'unknown', viewFor: 'Named' } satisfies ViewInfo
124
+ const bundle = {
125
+ ir: {
126
+ domain: 'shell.astrale.ai',
127
+ views: {},
128
+ classes: {
129
+ Named: {
130
+ origin: 'shell.astrale.ai',
131
+ ref: { origin: 'shell.astrale.ai', kind: 'class', name: 'Named' },
132
+ },
133
+ },
134
+ interfaces: {
135
+ Named: {
136
+ origin: 'shell.astrale.ai',
137
+ ref: { origin: 'shell.astrale.ai', kind: 'interface', name: 'Named' },
138
+ },
139
+ },
140
+ imports: {
141
+ Named: { origin: 'legacy.invalid', definition: 'class' },
142
+ },
143
+ importsByKey: {
144
+ 'directory.example.dev:interface.Named': {
145
+ origin: 'directory.example.dev',
146
+ definition: 'interface',
147
+ },
148
+ 'catalog.example.dev:class.Named': {
149
+ origin: 'catalog.example.dev',
150
+ definition: 'class',
151
+ },
152
+ },
153
+ },
154
+ } as unknown as StudioSchemaBundle
155
+
156
+ expect(viewDefinitionBindings('shell.astrale.ai', view, bundle)).toEqual([
157
+ { className: 'Named', classOrigin: 'shell.astrale.ai', kind: 'class' },
158
+ { className: 'Named', classOrigin: 'shell.astrale.ai', kind: 'interface' },
159
+ { className: 'Named', classOrigin: 'directory.example.dev', kind: 'interface' },
160
+ { className: 'Named', classOrigin: 'catalog.example.dev', kind: 'class' },
161
+ ])
162
+ })
163
+
164
+ test('uses the short-name import only for legacy bundles without an exact index', () => {
165
+ const view = { slug: 'legacy-users', kind: 'unknown', viewFor: 'User' } satisfies ViewInfo
166
+ const legacy = {
167
+ ir: {
168
+ domain: 'shell.astrale.ai',
169
+ views: {},
170
+ classes: {},
171
+ interfaces: {},
172
+ imports: {
173
+ User: { origin: 'accounts.example.dev', definition: 'interface' },
174
+ },
175
+ },
176
+ } as unknown as StudioSchemaBundle
177
+ const exact = {
178
+ ir: {
179
+ ...legacy.ir,
180
+ importsByKey: {},
181
+ },
182
+ } as unknown as StudioSchemaBundle
183
+
184
+ expect(viewDefinitionBindings('shell.astrale.ai', view, legacy)).toEqual([
185
+ { className: 'User', classOrigin: 'accounts.example.dev', kind: 'interface' },
186
+ ])
187
+ expect(viewDefinitionBindings('shell.astrale.ai', view, exact)).toEqual([])
188
+ })
189
+
190
+ test('reads the verified placement URL from the current CLI session route', () => {
191
+ expect(
192
+ readyViewSession(
193
+ {
194
+ session: {
195
+ id: 'v-a1b2',
196
+ pageUrl: 'http://127.0.0.1:4419/s/nonce/',
197
+ view: { route: { href: 'https://issues.example/ui/issue' } },
198
+ },
199
+ },
200
+ null,
201
+ ),
202
+ ).toEqual({
203
+ status: 'ready',
204
+ sessionId: 'v-a1b2',
205
+ pageUrl: 'http://127.0.0.1:4419/s/nonce/',
206
+ viewUrl: 'https://issues.example/ui/issue',
207
+ target: null,
208
+ })
209
+ expect(
210
+ readyViewSession(
211
+ {
212
+ session: {
213
+ id: 'v-old',
214
+ pageUrl: 'http://127.0.0.1:4419/s/old/',
215
+ view: { url: 'https://legacy.invalid/view' },
216
+ },
217
+ } as never,
218
+ null,
219
+ ),
220
+ ).toBeNull()
221
+ })
22
222
  })
23
223
 
24
224
  test('reduces CLI stack output to the actionable kernel failure', () => {