@astrale-os/cli 0.8.1-alpha.7 → 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 (64) hide show
  1. package/README.md +15 -3
  2. package/dist/astrale.js +86 -35
  3. package/package.json +5 -5
  4. package/src/commands/__tests__/domain-install-operation.test.ts +121 -0
  5. package/src/commands/__tests__/domain-install-owned.test.ts +66 -0
  6. package/src/commands/__tests__/install-direct.test.ts +3 -2
  7. package/src/commands/domain/install.ts +78 -15
  8. package/src/connection/__tests__/errors.test.ts +82 -6
  9. package/src/connection/command.ts +9 -1
  10. package/src/connection/errors.ts +19 -8
  11. package/src/connection/index.ts +1 -1
  12. package/src/connection/reasons.ts +26 -12
  13. package/src/program/__tests__/program.test.ts +1 -1
  14. package/studio/client/dist/assets/{elk-api-D0cBetPW.js → elk-api-D2xgMJvi.js} +1 -1
  15. package/studio/client/dist/assets/{index-BQnJ5sgd.css → index-BMdnsIJA.css} +1 -1
  16. package/studio/client/dist/assets/index-D-vRV8w7.js +8 -0
  17. package/studio/client/dist/assets/index-LGSWRrk8.js +81 -0
  18. package/studio/client/dist/index.html +2 -2
  19. package/studio/package.json +1 -1
  20. package/studio/server/agent/prompts/anchors.test.ts +74 -0
  21. package/studio/server/agent/prompts/anchors.ts +85 -15
  22. package/studio/server/agent/prompts/system.test.ts +12 -0
  23. package/studio/server/agent/prompts/system.ts +6 -6
  24. package/studio/server/api.ts +1 -5
  25. package/studio/server/cache.ts +5 -2
  26. package/studio/server/domain.test.ts +67 -0
  27. package/studio/server/domain.ts +29 -6
  28. package/studio/server/index.ts +1 -3
  29. package/studio/server/introspect/anatomy-extras.test.ts +104 -1
  30. package/studio/server/introspect/anatomy-extras.ts +340 -8
  31. package/studio/server/introspect/anatomy.test.ts +33 -0
  32. package/studio/server/introspect/anatomy.ts +22 -7
  33. package/studio/server/introspect/bundle.ts +7 -1
  34. package/studio/server/introspect/canonical-schema.test.ts +395 -0
  35. package/studio/server/introspect/canonical-schema.ts +751 -0
  36. package/studio/server/introspect/core-extractor.ts +30 -10
  37. package/studio/server/introspect/core.ts +4 -2
  38. package/studio/server/introspect/diff.test.ts +124 -0
  39. package/studio/server/introspect/diff.ts +252 -23
  40. package/studio/server/introspect/extractor.ts +46 -14
  41. package/studio/server/introspect/overlay-tsmorph.test.ts +164 -1
  42. package/studio/server/introspect/overlay-tsmorph.ts +381 -106
  43. package/studio/server/introspect/overlay.test.ts +72 -0
  44. package/studio/server/introspect/overlay.ts +16 -6
  45. package/studio/server/introspect/runtime.test.ts +217 -0
  46. package/studio/server/introspect/runtime.ts +18 -6
  47. package/studio/server/introspect/schema-refs.test.ts +100 -0
  48. package/studio/server/introspect/schema-refs.ts +23 -2
  49. package/studio/server/state/baseline.test.ts +51 -0
  50. package/studio/server/state/baseline.ts +31 -2
  51. package/studio/server/state/create.test.ts +37 -0
  52. package/studio/server/state/create.ts +21 -15
  53. package/studio/server/state/instance.test.ts +63 -0
  54. package/studio/server/state/instance.ts +65 -29
  55. package/studio/server/state/views.test.ts +209 -9
  56. package/studio/server/state/views.ts +176 -69
  57. package/studio/server/watch.test.ts +36 -0
  58. package/studio/server/watch.ts +24 -16
  59. package/studio/server/workspace-watch.ts +8 -3
  60. package/studio/shared/types.ts +164 -33
  61. package/studio/client/dist/assets/index-Dspir4w7.js +0 -81
  62. package/studio/client/dist/assets/index-bVD2KJgz.js +0 -8
  63. package/studio/server/view-dev-server.test.ts +0 -111
  64. package/studio/server/view-dev-server.ts +0 -372
@@ -0,0 +1,72 @@
1
+ import { expect, test } from 'bun:test'
2
+
3
+ import type { SchemaIR } from '../../shared/types'
4
+
5
+ import { buildOverlay } from './overlay'
6
+
7
+ test('preserves homonymous and cross-kind imports from the exact index', () => {
8
+ const ir = {
9
+ version: 'v1',
10
+ domain: 'app.example',
11
+ types: {},
12
+ interfaces: {},
13
+ classes: {},
14
+ functions: {},
15
+ imports: {},
16
+ importsByKey: {
17
+ 'a.example:interface.Shared': {
18
+ origin: 'a.example',
19
+ definition: 'interface',
20
+ ref: { origin: 'a.example', kind: 'interface', name: 'Shared' },
21
+ key: 'a.example:interface.Shared',
22
+ },
23
+ 'b.example:interface.Shared': {
24
+ origin: 'b.example',
25
+ definition: 'interface',
26
+ ref: { origin: 'b.example', kind: 'interface', name: 'Shared' },
27
+ key: 'b.example:interface.Shared',
28
+ },
29
+ 'a.example:class.Shared': {
30
+ origin: 'a.example',
31
+ definition: 'class',
32
+ ref: { origin: 'a.example', kind: 'class', name: 'Shared' },
33
+ key: 'a.example:class.Shared',
34
+ },
35
+ 'kernel.astrale.ai:interface.Identity': {
36
+ origin: 'kernel.astrale.ai',
37
+ definition: 'interface',
38
+ ref: { origin: 'kernel.astrale.ai', kind: 'interface', name: 'Identity' },
39
+ key: 'kernel.astrale.ai:interface.Identity',
40
+ },
41
+ },
42
+ } satisfies SchemaIR
43
+
44
+ const overlay = buildOverlay({ ir, domainRoot: '', schemaDir: '' })
45
+
46
+ expect(overlay.crossDomainImports).toEqual([
47
+ { name: 'Shared', origin: 'a.example', definition: 'interface' },
48
+ { name: 'Shared', origin: 'b.example', definition: 'interface' },
49
+ { name: 'Shared', origin: 'a.example', definition: 'class' },
50
+ ])
51
+ expect(overlay.mixins).toEqual([
52
+ { name: 'Identity', origin: 'kernel.astrale.ai', definition: 'interface' },
53
+ ])
54
+ })
55
+
56
+ test('retains the legacy short-name import fallback', () => {
57
+ const ir = {
58
+ version: 'legacy',
59
+ domain: 'app.example',
60
+ types: {},
61
+ interfaces: {},
62
+ classes: {},
63
+ functions: {},
64
+ imports: {
65
+ Identity: { origin: 'kernel.astrale.ai', definition: 'interface' },
66
+ },
67
+ } satisfies SchemaIR
68
+
69
+ expect(buildOverlay({ ir, domainRoot: '', schemaDir: '' }).mixins).toEqual([
70
+ { name: 'Identity', origin: 'kernel.astrale.ai', definition: 'interface' },
71
+ ])
72
+ })
@@ -2,7 +2,8 @@
2
2
  * overlay.ts — the Studio overlay over the DSL IR. Computes what the IR cannot
3
3
  * carry. The imports split + requires/postInstall are done here (pure / light
4
4
  * static parse). handlerLinks, sourceSpans (+JSDoc) and annotations are filled
5
- * by a ts-morph pass over schema/*.ts, domain.ts and runtime/index.ts.
5
+ * by a ts-morph pass over the current implementation/schema files, with legacy
6
+ * domain.ts and runtime/index.ts support retained.
6
7
  */
7
8
  import { existsSync, readFileSync } from 'node:fs'
8
9
  import { join } from 'node:path'
@@ -21,13 +22,19 @@ export function buildOverlay({ ir, domainRoot, schemaDir }: OverlayArgs): Schema
21
22
  const origin = ir?.domain ?? ''
22
23
  const mixins: CrossDomainImport[] = []
23
24
  const crossDomainImports: CrossDomainImport[] = []
24
- for (const [name, d] of Object.entries(ir?.imports ?? {})) {
25
+ const imports =
26
+ ir?.importsByKey !== undefined
27
+ ? Object.values(ir.importsByKey).flatMap((descriptor) =>
28
+ descriptor.ref ? [[descriptor.ref.name, descriptor] as const] : [],
29
+ )
30
+ : Object.entries(ir?.imports ?? {})
31
+ for (const [name, d] of imports) {
25
32
  const entry: CrossDomainImport = { name, origin: d.origin, definition: d.definition }
26
33
  if (d.origin === 'kernel.astrale.ai') mixins.push(entry)
27
34
  else crossDomainImports.push(entry)
28
35
  }
29
36
 
30
- const { requires, postInstall } = parseDomainTs(domainRoot, origin)
37
+ const { requires, postInstall } = parseCompositionEntry(domainRoot, origin)
31
38
 
32
39
  return {
33
40
  origin,
@@ -41,11 +48,14 @@ export function buildOverlay({ ir, domainRoot, schemaDir }: OverlayArgs): Schema
41
48
  }
42
49
  }
43
50
 
44
- function parseDomainTs(root: string, origin: string): { requires: string[]; postInstall?: string } {
45
- const f = join(root, 'domain.ts')
51
+ function parseCompositionEntry(
52
+ root: string,
53
+ origin: string,
54
+ ): { requires: string[]; postInstall?: string } {
55
+ const f = ['implementation.ts', 'domain.ts'].map((file) => join(root, file)).find(existsSync)
46
56
  let requires: string[] = []
47
57
  let postInstall: string | undefined
48
- if (existsSync(f)) {
58
+ if (f) {
49
59
  const src = readFileSync(f, 'utf8')
50
60
  const rm = src.match(/requires\s*:\s*\[([^\]]*)\]/)
51
61
  if (rm) requires = [...rm[1].matchAll(/['"`]([^'"`]+)['"`]/g)].map((x) => x[1])
@@ -0,0 +1,217 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import type { DomainHandle } from '../domain'
7
+
8
+ import { buildBundle } from './bundle'
9
+ import { schemaHashOf } from './hash'
10
+ import { coreExtract, runtimeExtract } from './runtime'
11
+
12
+ const roots: string[] = []
13
+
14
+ afterEach(() => {
15
+ while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true })
16
+ })
17
+
18
+ function fixtureRoot(label: string): string {
19
+ const root = mkdtempSync(join(tmpdir(), `studio-${label}-`))
20
+ roots.push(root)
21
+ mkdirSync(join(root, 'schema'), { recursive: true })
22
+ writeFileSync(join(root, 'package.json'), '{"type":"module"}\n')
23
+ writeFileSync(join(root, 'astrale.config.ts'), 'export default {}\n')
24
+ return root
25
+ }
26
+
27
+ function linkCurrentSdk(root: string): void {
28
+ const sdk = realpathSync(join(import.meta.dir, '../../../node_modules/@astrale-os/sdk'))
29
+ const scope = join(root, 'node_modules', '@astrale-os')
30
+ mkdirSync(scope, { recursive: true })
31
+ symlinkSync(sdk, join(scope, 'sdk'), 'dir')
32
+ }
33
+
34
+ function currentFixture(): DomainHandle {
35
+ const root = fixtureRoot('canonical-runtime')
36
+ linkCurrentSdk(root)
37
+ const schemaIndex = join(root, 'schema', 'index.ts')
38
+ const domainFile = join(root, 'implementation.ts')
39
+ writeFileSync(
40
+ schemaIndex,
41
+ `
42
+ import {
43
+ defineSchema, domain, edge, edgeClass, fn, node, nodeClass,
44
+ nodeInterface, output, property, view,
45
+ } from '@astrale-os/sdk/schema'
46
+
47
+ const Named = nodeInterface()
48
+ export const Directory = defineSchema('directory.runtime.test', {
49
+ interfaces: { Named },
50
+ })
51
+
52
+ const rename = fn({
53
+ input: {
54
+ type: 'object', properties: { title: { type: 'string' } },
55
+ required: ['title'], additionalProperties: false,
56
+ },
57
+ output: { type: 'boolean' },
58
+ auth: 'authenticated',
59
+ })
60
+ const Document = nodeClass({
61
+ implements: [Named],
62
+ properties: { title: property({ type: 'string' }, { required: true }) },
63
+ methods: { rename },
64
+ })
65
+ const owned_by = edgeClass.directed({
66
+ source: { as: 'document', accepts: [Document], outgoing: '0..*' },
67
+ target: { as: 'owner', accepts: [Document], incoming: '0..*' },
68
+ })
69
+ const welcome = node(Document, { title: 'Welcome' })
70
+
71
+ export const schema = defineSchema('documents.runtime.test', {
72
+ dependencies: [Directory],
73
+ classes: { Document, owned_by },
74
+ functions: {
75
+ exportAll: fn({
76
+ input: { type: 'object', properties: {}, required: [], additionalProperties: false },
77
+ output: output.binary(),
78
+ auth: 'authenticated',
79
+ }),
80
+ },
81
+ views: { editor: view({ target: Document, auth: 'optional' }) },
82
+ core: {
83
+ nodes: { welcome },
84
+ edges: [edge(welcome, owned_by, domain(), {})],
85
+ },
86
+ })
87
+ `,
88
+ )
89
+ // Canonical core extraction must not import the effectful composition entry.
90
+ writeFileSync(domainFile, `throw new Error('composition entry was imported')\n`)
91
+ return {
92
+ id: 'documents-runtime-test',
93
+ root,
94
+ configFile: join(root, 'astrale.config.ts'),
95
+ domainFile,
96
+ schemaDirName: 'schema',
97
+ schemaDir: join(root, 'schema'),
98
+ schemaIndex,
99
+ }
100
+ }
101
+
102
+ describe('current SDK schema extractor', () => {
103
+ test('resolves the domain SDK, returns its raw root, and projects current members', async () => {
104
+ const handle = currentFixture()
105
+ const result = await runtimeExtract(handle.schemaIndex, handle.root)
106
+
107
+ expect(result.ok).toBe(true)
108
+ expect(result.root).toMatchObject({
109
+ format: 'astrale.dsl',
110
+ version: 'v1',
111
+ origin: 'documents.runtime.test',
112
+ })
113
+ expect(result.ir).toMatchObject({
114
+ format: 'astrale.dsl',
115
+ domain: 'documents.runtime.test',
116
+ functions: {
117
+ exportAll: {
118
+ static: true,
119
+ inheritance: 'default',
120
+ output: { mode: 'binary' },
121
+ },
122
+ },
123
+ views: {
124
+ editor: {
125
+ auth: 'optional',
126
+ target: { kind: 'definition' },
127
+ },
128
+ },
129
+ })
130
+ expect(result.importedInterfaces?.Named).toMatchObject({
131
+ name: 'Named',
132
+ origin: 'directory.runtime.test',
133
+ })
134
+ })
135
+
136
+ test('hashes the raw canonical root instead of the render projection', async () => {
137
+ const handle = currentFixture()
138
+ const extracted = await runtimeExtract(handle.schemaIndex, handle.root)
139
+ expect(extracted.ok).toBe(true)
140
+
141
+ const bundle = await buildBundle(handle)
142
+ expect(bundle.schemaRoot).toEqual(extracted.root)
143
+ expect(bundle.schemaHash).toBe(schemaHashOf(extracted.root))
144
+ expect(bundle.schemaHash).not.toBe(schemaHashOf(extracted.ir))
145
+ })
146
+
147
+ test('extracts canonical core without importing implementation.ts', async () => {
148
+ const handle = currentFixture()
149
+ const result = await coreExtract(handle.schemaIndex, handle.domainFile, handle.root)
150
+
151
+ expect(result).toEqual({
152
+ ok: true,
153
+ core: {
154
+ domain: 'documents.runtime.test',
155
+ nodes: [
156
+ {
157
+ path: '/:documents.runtime.test:core.welcome',
158
+ className: 'Document',
159
+ data: { 'documents.runtime.test:class.Document.property.title': 'Welcome' },
160
+ },
161
+ ],
162
+ edges: [
163
+ {
164
+ from: '/:documents.runtime.test:core.welcome',
165
+ to: '/:documents.runtime.test',
166
+ edgeName: 'owned_by',
167
+ data: {},
168
+ },
169
+ ],
170
+ },
171
+ })
172
+ })
173
+ })
174
+
175
+ test('core extractor preserves the legacy defineCore fallback', async () => {
176
+ const root = fixtureRoot('legacy-core')
177
+ const schemaIndex = join(root, 'schema', 'index.ts')
178
+ const domainFile = join(root, 'domain.ts')
179
+ writeFileSync(schemaIndex, `export const schema = { domain: 'legacy.runtime.test' }\n`)
180
+ writeFileSync(
181
+ domainFile,
182
+ `
183
+ const Item = { config: { name: 'Item' } }
184
+ const linked = { config: { name: 'linked' } }
185
+ const schema = { interfaces: {}, classes: { Item, linked }, imports: [] }
186
+ export const core = {
187
+ domain: 'legacy.runtime.test', schema,
188
+ __nodes: [
189
+ { path: '/legacy/left', def: Item, data: { name: 'Left' } },
190
+ { path: '/legacy/right', def: Item, data: { name: 'Right' } },
191
+ ],
192
+ __edges: [
193
+ { from: '/legacy/left', to: '/legacy/right', edge: linked, data: { order: 1 } },
194
+ ],
195
+ }
196
+ `,
197
+ )
198
+
199
+ expect(await coreExtract(schemaIndex, domainFile, root)).toEqual({
200
+ ok: true,
201
+ core: {
202
+ domain: 'legacy.runtime.test',
203
+ nodes: [
204
+ { path: '/legacy/left', className: 'Item', data: { name: 'Left' } },
205
+ { path: '/legacy/right', className: 'Item', data: { name: 'Right' } },
206
+ ],
207
+ edges: [
208
+ {
209
+ from: '/legacy/left',
210
+ to: '/legacy/right',
211
+ edgeName: 'linked',
212
+ data: { order: 1 },
213
+ },
214
+ ],
215
+ },
216
+ })
217
+ })
@@ -2,7 +2,8 @@
2
2
  * runtime.ts — the introspection driver. Spawns the Bun extractor island in a
3
3
  * short-lived subprocess (cwd = domain dir so the domain's own node_modules
4
4
  * resolve the @astrale-os/* packages), with a hard timeout. Returns the raw
5
- * SchemaIR or an error render-state — never throws.
5
+ * render IR plus the raw canonical root (when present), or an error render-state
6
+ * — never throws.
6
7
  */
7
8
  import type { IrInterface, SchemaIR, StudioCore } from '../../shared/types'
8
9
 
@@ -12,6 +13,8 @@ const CORE_EXTRACTOR = new URL('./core-extractor.ts', import.meta.url).pathname
12
13
  export interface RuntimeExtractResult {
13
14
  ok: boolean
14
15
  ir: SchemaIR | null
16
+ /** Exact canonical V1 root. Null for legacy `D.$.ir` domains and failures. */
17
+ root: unknown | null
15
18
  /** member bodies of imported (kernel + cross-domain) interfaces, by name */
16
19
  importedInterfaces?: Record<string, IrInterface>
17
20
  error?: { message: string }
@@ -38,19 +41,26 @@ export async function runtimeExtract(
38
41
  return {
39
42
  ok: false,
40
43
  ir: null,
44
+ root: null,
41
45
  error: { message: err.trim() || 'extractor produced no output' },
42
46
  }
43
47
  }
44
48
  const parsed = JSON.parse(out)
45
49
  if (!parsed.ok)
46
- return { ok: false, ir: null, error: parsed.error ?? { message: 'extraction failed' } }
50
+ return {
51
+ ok: false,
52
+ ir: null,
53
+ root: null,
54
+ error: parsed.error ?? { message: 'extraction failed' },
55
+ }
47
56
  return {
48
57
  ok: true,
49
58
  ir: parsed.ir as SchemaIR,
59
+ root: parsed.root ?? null,
50
60
  importedInterfaces: (parsed.importedInterfaces ?? {}) as Record<string, IrInterface>,
51
61
  }
52
62
  } catch (e: any) {
53
- return { ok: false, ir: null, error: { message: String(e?.message ?? e) } }
63
+ return { ok: false, ir: null, root: null, error: { message: String(e?.message ?? e) } }
54
64
  }
55
65
  }
56
66
 
@@ -62,16 +72,18 @@ export interface CoreExtractResult {
62
72
  }
63
73
 
64
74
  /**
65
- * Spawn the core-extractor island over a domain's `domain.ts` (cwd = domainDir),
66
- * with a hard timeout. Returns the resolved core graph or an error — never throws.
75
+ * Spawn the core-extractor island over the pure schema entry first, retaining the
76
+ * composition entry only as the legacy `defineCore` fallback. Returns the
77
+ * resolved core graph or an error — never throws.
67
78
  */
68
79
  export async function coreExtract(
80
+ schemaIndexPath: string,
69
81
  domainFile: string,
70
82
  domainDir: string,
71
83
  timeoutMs = 20000,
72
84
  ): Promise<CoreExtractResult> {
73
85
  try {
74
- const proc = Bun.spawn(['bun', 'run', CORE_EXTRACTOR, domainFile, domainDir], {
86
+ const proc = Bun.spawn(['bun', 'run', CORE_EXTRACTOR, schemaIndexPath, domainFile, domainDir], {
75
87
  cwd: domainDir,
76
88
  stdout: 'pipe',
77
89
  stderr: 'pipe',
@@ -0,0 +1,100 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import type { IrInterface, StudioSchemaBundle } from '../../shared/types'
4
+
5
+ import { schemaRefs } from './schema-refs'
6
+
7
+ function importedInterface(
8
+ name: string,
9
+ origin: string,
10
+ properties: string[] = [],
11
+ methods: string[] = [],
12
+ ): IrInterface {
13
+ return {
14
+ type: 'interface',
15
+ name,
16
+ origin,
17
+ ref: { origin, kind: 'interface', name },
18
+ properties: Object.fromEntries(properties.map((property) => [property, { type: 'string' }])),
19
+ methods: Object.fromEntries(
20
+ methods.map((method) => [
21
+ method,
22
+ {
23
+ name: method,
24
+ params: {},
25
+ returns: {},
26
+ static: false,
27
+ inheritance: 'default',
28
+ },
29
+ ]),
30
+ ),
31
+ }
32
+ }
33
+
34
+ function bundleWithImports(
35
+ exact: NonNullable<StudioSchemaBundle['ir']>['importedInterfacesByKey'],
36
+ legacy: Record<string, IrInterface>,
37
+ ): StudioSchemaBundle {
38
+ return {
39
+ ir: {
40
+ domain: 'root.example.dev',
41
+ interfaces: {},
42
+ classes: {},
43
+ functions: {},
44
+ ...(exact === undefined ? {} : { importedInterfacesByKey: exact }),
45
+ },
46
+ importedInterfaces: legacy,
47
+ } as unknown as StudioSchemaBundle
48
+ }
49
+
50
+ describe('schema refs for imported interfaces', () => {
51
+ test('enumerates every exact homonym without selecting a name-keyed winner', () => {
52
+ const refs = schemaRefs(
53
+ bundleWithImports(
54
+ {
55
+ 'directory.example.dev:interface.Named': importedInterface(
56
+ 'Named',
57
+ 'directory.example.dev',
58
+ ['directoryName'],
59
+ ),
60
+ 'people.example.dev:interface.Named': importedInterface(
61
+ 'Named',
62
+ 'people.example.dev',
63
+ [],
64
+ ['rename'],
65
+ ),
66
+ },
67
+ {
68
+ Named: importedInterface('Named', 'legacy.invalid', ['legacyOnly']),
69
+ },
70
+ ),
71
+ )
72
+
73
+ expect(refs).toContain('interface.directory.example.dev:interface.Named')
74
+ expect(refs).toContain('interface.directory.example.dev:interface.Named.property.directoryName')
75
+ expect(refs).toContain('interface.people.example.dev:interface.Named.method.rename')
76
+ expect(refs).not.toContain('interface.Named')
77
+ expect(refs).not.toContain('interface.Named.property.legacyOnly')
78
+ })
79
+
80
+ test('uses the name-keyed imported interface only when the exact index is absent', () => {
81
+ expect(
82
+ schemaRefs(
83
+ bundleWithImports(undefined, {
84
+ Named: importedInterface('Named', 'legacy.example.dev', ['name']),
85
+ }),
86
+ ),
87
+ ).toEqual(['interface.Named', 'interface.Named.property.name'])
88
+
89
+ expect(
90
+ schemaRefs(
91
+ bundleWithImports(
92
+ {},
93
+ {
94
+ Named: importedInterface('Named', 'legacy.example.dev', ['name']),
95
+ },
96
+ ),
97
+ ),
98
+ ).toEqual([])
99
+ })
100
+ })
@@ -37,10 +37,31 @@ export function schemaRefs(bundle: StudioSchemaBundle): string[] {
37
37
  if (ep.name) refs.add(`edge.${name}.endpoint.${ep.name}`)
38
38
  }
39
39
 
40
+ for (const name of Object.keys(ir.functions ?? {})) refs.add(`function.${name}`)
41
+
40
42
  // Imported interfaces (kernel mixins + cross-domain) are rendered — and so are
41
43
  // commentable — in the Inherited section, keyed under the `interface.` namespace.
42
- for (const [name, iface] of Object.entries(bundle.importedInterfaces ?? {}))
43
- addMember(`interface.${name}`, iface.properties, iface.methods)
44
+ // Canonical bundles retain every exact `(origin, kind, name)` identity. Iterate
45
+ // all of them: a short-name map must never pick one homonym by traversal order.
46
+ // The name-keyed bundle field remains a legacy-only fallback.
47
+ if (ir.importedInterfacesByKey !== undefined) {
48
+ for (const [key, iface] of Object.entries(ir.importedInterfacesByKey)) {
49
+ const anchor = importedInterfaceAnchor(key)
50
+ if (anchor) addMember(anchor, iface.properties, iface.methods)
51
+ }
52
+ } else {
53
+ for (const [name, iface] of Object.entries(bundle.importedInterfaces ?? {}))
54
+ addMember(`interface.${name}`, iface.properties, iface.methods)
55
+ }
44
56
 
45
57
  return [...refs]
46
58
  }
59
+
60
+ function importedInterfaceAnchor(key: string): string | null {
61
+ const separator = key.lastIndexOf(':')
62
+ if (separator <= 0) return null
63
+ const ref = key.slice(separator + 1)
64
+ if (!ref.startsWith('interface.')) return null
65
+ const name = ref.slice('interface.'.length)
66
+ return /^[A-Za-z_$][\w$]*$/.test(name) ? `interface.${key}` : null
67
+ }
@@ -0,0 +1,51 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { hashAnatomyFiles } from './baseline'
7
+
8
+ const roots: string[] = []
9
+
10
+ afterEach(() => {
11
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
12
+ })
13
+
14
+ test('hashes current implementation and semantic layer files alongside legacy anatomy', () => {
15
+ const root = mkdtempSync(join(tmpdir(), 'studio-current-baseline-'))
16
+ roots.push(root)
17
+ for (const dir of [
18
+ 'schema',
19
+ 'actions/risk',
20
+ 'handlers/risk',
21
+ 'queries/risk',
22
+ 'ui/risk',
23
+ 'views/risk',
24
+ ]) {
25
+ mkdirSync(join(root, dir), { recursive: true })
26
+ }
27
+ for (const file of [
28
+ 'schema/index.ts',
29
+ 'implementation.ts',
30
+ 'actions/risk/index.ts',
31
+ 'handlers/risk/index.ts',
32
+ 'queries/risk/index.ts',
33
+ 'ui/risk/screen.tsx',
34
+ 'views/risk/index.ts',
35
+ 'package.json',
36
+ ]) {
37
+ writeFileSync(join(root, file), `${file}\n`)
38
+ }
39
+ writeFileSync(join(root, 'README.md'), 'not part of the anatomy fileset\n')
40
+
41
+ expect(Object.keys(hashAnatomyFiles(root, 'schema')).sort()).toEqual([
42
+ 'actions/risk/index.ts',
43
+ 'handlers/risk/index.ts',
44
+ 'implementation.ts',
45
+ 'package.json',
46
+ 'queries/risk/index.ts',
47
+ 'schema/index.ts',
48
+ 'ui/risk/screen.tsx',
49
+ 'views/risk/index.ts',
50
+ ])
51
+ })
@@ -31,8 +31,37 @@ const SKIP_DIRS = new Set(['node_modules', '.dist', 'dist', '.domain-studio', '.
31
31
  * is NOT listed here — see hashAnatomyFiles. Everything else is a fixed contract.
32
32
  */
33
33
  export const ANATOMY_GLOBS = {
34
- dirs: ['runtime', 'views', 'functions', 'client/src'],
35
- files: ['domain.ts', 'deps.ts', 'env.ts', 'package.json', 'astrale.config.ts'],
34
+ dirs: [
35
+ 'actions',
36
+ 'capabilities',
37
+ 'core',
38
+ 'runtime',
39
+ 'views',
40
+ 'functions',
41
+ 'handlers',
42
+ 'integrations',
43
+ 'migrations',
44
+ 'mutations',
45
+ 'queries',
46
+ 'rules',
47
+ 'scripts',
48
+ 'states',
49
+ 'ui',
50
+ 'utils',
51
+ 'workflows',
52
+ 'client/src',
53
+ ],
54
+ files: [
55
+ 'implementation.ts',
56
+ 'domain.ts',
57
+ 'index.ts',
58
+ 'core.ts',
59
+ 'deps.ts',
60
+ 'env.ts',
61
+ 'package.json',
62
+ 'pnpm-workspace.yaml',
63
+ 'astrale.config.ts',
64
+ ],
36
65
  } as const
37
66
 
38
67
  function sha256(buf: Buffer): string {
@@ -0,0 +1,37 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { annotateOrigin } from './create'
7
+
8
+ const roots: string[] = []
9
+
10
+ afterEach(() => {
11
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
12
+ })
13
+
14
+ test('annotates the schema definition behind a current barrel', () => {
15
+ const root = mkdtempSync(join(tmpdir(), 'studio-create-origin-'))
16
+ roots.push(root)
17
+ mkdirSync(join(root, 'schema', 'application'), { recursive: true })
18
+ const barrel = join(root, 'schema', 'index.ts')
19
+ const definition = join(root, 'schema', 'application', 'index.ts')
20
+ writeFileSync(barrel, `export { schema } from './application/index.js'\n`)
21
+ writeFileSync(
22
+ definition,
23
+ `export const ORIGIN = 'application.example.dev' as const
24
+ const input = { classes: {} } as const
25
+ export const schema = defineSchema(ORIGIN, input)
26
+ `,
27
+ )
28
+
29
+ annotateOrigin(root)
30
+ annotateOrigin(root)
31
+
32
+ expect(readFileSync(barrel, 'utf8')).not.toContain('ORIGIN —')
33
+ const source = readFileSync(definition, 'utf8')
34
+ expect(source).toContain('// ORIGIN —')
35
+ expect(source.indexOf('// ORIGIN —')).toBeLessThan(source.indexOf('defineSchema(ORIGIN'))
36
+ expect(source.match(/\/\/ ORIGIN —/g)).toHaveLength(1)
37
+ })