@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
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * core-extractor.ts — the Bun-executed island for a domain's CORE (genesis) data.
3
3
  * Spawned as a short-lived subprocess by runtime.ts (cwd = domain dir, so the
4
- * domain's own node_modules resolve @astrale-os/*). It imports the domain's
5
- * worker-safe `domain.ts`, finds the `defineCore(schema, { nodes, edges })`
6
- * output wired in as `domain.core`, and prints its resolved nodes/edges as JSON.
4
+ * domain's own node_modules resolve @astrale-os/*). It imports the pure schema
5
+ * entry and reads canonical V1 `schema.core` directly. Importing the composition
6
+ * entry and discovering legacy `defineCore(...)` output is retained only as a
7
+ * fallback.
7
8
  *
8
- * bun core-extractor.ts <domainFile> <domainDir>
9
+ * bun core-extractor.ts <schemaIndexPath> <domainFile> <domainDir>
9
10
  *
10
11
  * Contract mirrors extractor.ts exactly: NEVER crash. A thrown error prints
11
12
  * { ok:false } and exits 0 — the driver treats it as a render state. A domain
@@ -24,11 +25,13 @@
24
25
  export {} // module marker — see header note
25
26
  import { isAbsolute, resolve } from 'node:path'
26
27
 
27
- const domainDir = process.argv[3] ?? process.cwd()
28
- // the driver passes an absolute path; resolve a relative one against the domain dir
29
- // (a bare relative path would otherwise resolve against THIS script's location).
30
- const rawFile = process.argv[2]
31
- const domainFile = rawFile && !isAbsolute(rawFile) ? resolve(domainDir, rawFile) : rawFile
28
+ import { findCanonicalDomainSchemaExport, projectCanonicalCore } from './canonical-schema'
29
+
30
+ const domainDir = process.argv[4] ?? process.cwd()
31
+ const resolveInput = (file: string | undefined): string | undefined =>
32
+ file && !isAbsolute(file) ? resolve(domainDir, file) : file
33
+ const schemaFile = resolveInput(process.argv[2])
34
+ const domainFile = resolveInput(process.argv[3])
32
35
 
33
36
  type AnyRec = Record<string, any>
34
37
 
@@ -72,7 +75,24 @@ function classNameMap(schema: AnyRec): Map<any, string> {
72
75
  }
73
76
 
74
77
  async function main() {
75
- if (!domainFile) throw new Error('core-extractor: missing <domainFile>')
78
+ if (!schemaFile) throw new Error('core-extractor: missing <schemaIndexPath>')
79
+
80
+ // Current SDK roots embed genesis data in the canonical, portable schema.
81
+ // Do not import implementation.ts/domain.ts when that source of truth exists.
82
+ try {
83
+ const schemaModule: AnyRec = await import(schemaFile)
84
+ const canonical = findCanonicalDomainSchemaExport(schemaModule)
85
+ if (canonical) {
86
+ const core = projectCanonicalCore(canonical)
87
+ const empty = core.nodes.length === 0 && core.edges.length === 0
88
+ process.stdout.write(JSON.stringify({ ok: true, core: empty ? null : core }))
89
+ return
90
+ }
91
+ } catch {
92
+ // A legacy composition entry may still expose a valid defineCore result.
93
+ }
94
+
95
+ if (!domainFile) throw new Error('core-extractor: missing <domainFile> legacy fallback')
76
96
  const mod: AnyRec = await import(domainFile)
77
97
  const core = findCore(mod)
78
98
  if (!core) {
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * core.ts — assembles a domain's StudioCore: the genesis node/edge graph
3
- * extracted from `defineCore`, gated on installed deps. Never throws; a failed
4
- * import or a domain with no core both become a well-formed (empty) render state.
3
+ * extracted from canonical `schema.core` (or legacy `defineCore` fallback), gated
4
+ * on installed deps. Never throws; a failed import or a domain with no core both
5
+ * become a well-formed (empty) render state.
5
6
  */
6
7
  import type { StudioCore } from '../../shared/types'
7
8
 
@@ -26,6 +27,7 @@ export async function buildCore(handle: DomainHandle): Promise<StudioCore> {
26
27
  }
27
28
 
28
29
  const r = await coreExtract(
30
+ handle.schemaIndex,
29
31
  handle.domainFile,
30
32
  handle.root,
31
33
  readSettings(handle.root).introspectTimeoutMs,
@@ -0,0 +1,124 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import type { IrClass, IrFunction, SchemaIR } from '../../shared/types'
4
+
5
+ import { classify, diffSchemas } from './diff'
6
+
7
+ const callable = (returnsType = 'string'): IrFunction => ({
8
+ name: 'inspect',
9
+ input: { type: 'object', properties: {}, additionalProperties: false },
10
+ params: {},
11
+ output: { mode: 'value', schema: { type: returnsType } },
12
+ returns: { type: returnsType },
13
+ static: true,
14
+ inheritance: 'default',
15
+ auth: 'authenticated',
16
+ })
17
+
18
+ const schema = (functions: SchemaIR['functions']): SchemaIR => ({
19
+ version: 'v1',
20
+ domain: 'example.test',
21
+ types: {},
22
+ interfaces: {},
23
+ classes: {},
24
+ imports: {},
25
+ functions,
26
+ })
27
+
28
+ const withClass = (member: IrClass): SchemaIR => ({
29
+ ...schema({}),
30
+ classes: { [member.name]: member },
31
+ })
32
+
33
+ describe('canonical standalone Function diffs', () => {
34
+ test('classifies addition as additive and removal as breaking', () => {
35
+ const empty = schema({})
36
+ const populated = schema({ inspect: callable() })
37
+ expect(diffSchemas(empty, populated)).toEqual([
38
+ { kind: 'function-added', target: 'inspect', breaking: false },
39
+ ])
40
+ const removed = diffSchemas(populated, empty)
41
+ expect(removed).toEqual([{ kind: 'function-removed', target: 'inspect', breaking: true }])
42
+ expect(classify(removed)).toBe('breaking')
43
+ })
44
+
45
+ test('detects a standalone Function signature change', () => {
46
+ expect(
47
+ diffSchemas(schema({ inspect: callable() }), schema({ inspect: callable('number') })),
48
+ ).toEqual([{ kind: 'function-signature-changed', target: 'inspect', breaking: true }])
49
+ })
50
+
51
+ test('detects auth and output-mode changes in callable contracts', () => {
52
+ const before = callable()
53
+ const after: IrFunction = {
54
+ ...before,
55
+ auth: 'authorized',
56
+ output: { mode: 'stream', item: { type: 'string' } },
57
+ }
58
+
59
+ expect(diffSchemas(schema({ inspect: before }), schema({ inspect: after }))).toEqual([
60
+ { kind: 'function-signature-changed', target: 'inspect', breaking: true },
61
+ ])
62
+ })
63
+
64
+ test('uses canonical required membership and compares complete value schemas', () => {
65
+ const member = (required: string[], minLength: number): IrClass => ({
66
+ type: 'node',
67
+ name: 'Person',
68
+ properties: { name: { type: 'string', minLength } },
69
+ required,
70
+ methods: {},
71
+ })
72
+
73
+ expect(diffSchemas(withClass(member([], 1)), withClass(member(['name'], 2)))).toEqual([
74
+ {
75
+ kind: 'prop-schema-changed',
76
+ target: 'Person.name',
77
+ detail: 'value constraints changed',
78
+ breaking: true,
79
+ },
80
+ {
81
+ kind: 'prop-required-changed',
82
+ target: 'Person.name',
83
+ detail: 'optional → required',
84
+ breaking: true,
85
+ },
86
+ ])
87
+ })
88
+
89
+ test('tracks canonical views, policies, dependencies, topology, and Core', () => {
90
+ const before: SchemaIR = {
91
+ ...withClass({
92
+ type: 'edge',
93
+ name: 'owns',
94
+ properties: {},
95
+ required: [],
96
+ methods: {},
97
+ orientation: 'directed',
98
+ endpoints: [],
99
+ }),
100
+ views: { home: { name: 'home', target: { kind: 'domain' }, auth: 'public' } },
101
+ policies: { canRead: { anyOf: [] } },
102
+ dependencies: [{ origin: 'kernel.astrale.ai', revision: 'sha256:one' }],
103
+ core: { nodes: {}, edges: [] },
104
+ }
105
+ const after: SchemaIR = {
106
+ ...before,
107
+ classes: {
108
+ owns: { ...before.classes.owns, orientation: 'undirected' },
109
+ },
110
+ views: { home: { ...before.views!.home, auth: 'required' } },
111
+ policies: { canRead: { anyOf: [{ name: 'owner' }] } },
112
+ dependencies: [{ origin: 'kernel.astrale.ai', revision: 'sha256:two' }],
113
+ core: { nodes: { root: {} }, edges: [] },
114
+ }
115
+
116
+ expect(diffSchemas(before, after).map((change) => change.kind)).toEqual([
117
+ 'edge-contract-changed',
118
+ 'view-changed',
119
+ 'policy-changed',
120
+ 'dependency-changed',
121
+ 'core-changed',
122
+ ])
123
+ })
124
+ })
@@ -2,12 +2,15 @@
2
2
  * diff.ts — structural diff of two Schema IRs + breaking/additive classification.
3
3
  * Shared by change tracking (baseline) and data versioning. Pure.
4
4
  *
5
- * Optionality is encoded in JSON Schema as a `type` array containing 'null'.
6
- * BREAKING: any removal/rename, prop type change, optional→required, method
7
- * signature change. ADDITIVE: only additions, or required→optional.
5
+ * Canonical V1 keeps optionality in the owning `required` arrays and nullability
6
+ * in each value schema. Legacy nullable projections remain supported. Contract
7
+ * changes are classified conservatively: removals, tightened value schemas,
8
+ * callable/auth/policy changes, topology changes, dependency revisions and Core
9
+ * changes are breaking; pure additions and descriptions are additive.
8
10
  */
9
11
  import type {
10
12
  IrClass,
13
+ IrFunction,
11
14
  IrInterface,
12
15
  IrMethod,
13
16
  JsonSchema,
@@ -31,23 +34,171 @@ export function baseType(s: JsonSchema | undefined): string {
31
34
  return s.enum ? `enum(${s.enum.join(',')})` : 'unknown'
32
35
  }
33
36
 
34
- function methodSig(m: IrMethod): string {
35
- const params = Object.entries(m.params ?? {})
36
- .map(([k, v]) => `${k}:${baseType(v)}${isOptional(v) ? '?' : ''}`)
37
- .sort()
38
- .join(',')
39
- return `static=${m.static};in=${params};out=${baseType(m.returns)};inh=${m.inheritance}`
37
+ function requiredParams(m: IrMethod | IrFunction): string[] {
38
+ return (
39
+ m.requiredParams ??
40
+ Object.entries(m.params ?? {}).flatMap(([name, schema]) => (isOptional(schema) ? [] : [name]))
41
+ )
42
+ }
43
+
44
+ function callableContract(m: IrMethod | IrFunction): unknown {
45
+ const required = requiredParams(m)
46
+ return {
47
+ input:
48
+ m.input ??
49
+ ({
50
+ type: 'object',
51
+ properties: m.params ?? {},
52
+ required,
53
+ additionalProperties: false,
54
+ } satisfies JsonSchema),
55
+ required,
56
+ output: m.output ?? { mode: 'value', schema: m.returns },
57
+ static: m.static,
58
+ inheritance: m.inheritance,
59
+ auth: m.auth,
60
+ policy: m.policy,
61
+ }
40
62
  }
41
63
 
42
64
  export function diffSchemas(prev: SchemaIR | null, next: SchemaIR | null): SchemaChange[] {
43
65
  const changes: SchemaChange[] = []
44
66
  if (!prev || !next) return changes
45
67
 
68
+ if (
69
+ !same(
70
+ { format: prev.format, version: prev.version, domain: prev.domain },
71
+ {
72
+ format: next.format,
73
+ version: next.version,
74
+ domain: next.domain,
75
+ },
76
+ )
77
+ ) {
78
+ changes.push({ kind: 'schema-metadata-changed', target: next.domain, breaking: true })
79
+ }
80
+
81
+ diffValueRecord(prev.types ?? {}, next.types ?? {}, 'type', changes)
82
+ diffValueRecord(
83
+ prev.importsByKey ?? prev.imports ?? {},
84
+ next.importsByKey ?? next.imports ?? {},
85
+ 'import',
86
+ changes,
87
+ )
46
88
  diffMembers(prev.interfaces ?? {}, next.interfaces ?? {}, 'interface', changes)
47
89
  diffMembers(prev.classes ?? {}, next.classes ?? {}, 'class', changes)
90
+ diffFunctions(prev.functions ?? {}, next.functions ?? {}, changes)
91
+ diffViews(prev.views ?? {}, next.views ?? {}, changes)
92
+ diffValueRecord(prev.policies ?? {}, next.policies ?? {}, 'policy', changes)
93
+ diffDependencies(prev.dependencies ?? [], next.dependencies ?? [], changes)
94
+ if (!same(prev.core, next.core) && (prev.core !== undefined || next.core !== undefined)) {
95
+ changes.push({ kind: 'core-changed', target: next.domain, breaking: prev.core !== undefined })
96
+ }
48
97
  return changes
49
98
  }
50
99
 
100
+ function diffValueRecord(
101
+ prev: Record<string, unknown>,
102
+ next: Record<string, unknown>,
103
+ scope: 'type' | 'import' | 'policy',
104
+ out: SchemaChange[],
105
+ ): void {
106
+ for (const name of sortedKeys(next)) {
107
+ if (!(name in prev)) {
108
+ out.push({ kind: `${scope}-added`, target: name, breaking: false })
109
+ }
110
+ }
111
+ for (const name of sortedKeys(prev)) {
112
+ if (!(name in next)) {
113
+ out.push({ kind: `${scope}-removed`, target: name, breaking: true })
114
+ } else if (!same(prev[name], next[name])) {
115
+ out.push({ kind: `${scope}-changed`, target: name, breaking: true })
116
+ }
117
+ }
118
+ }
119
+
120
+ function diffFunctions(
121
+ prev: Record<string, IrFunction>,
122
+ next: Record<string, IrFunction>,
123
+ out: SchemaChange[],
124
+ ): void {
125
+ for (const name of sortedKeys(next)) {
126
+ if (!prev[name]) out.push({ kind: 'function-added', target: name, breaking: false })
127
+ }
128
+ for (const name of sortedKeys(prev)) {
129
+ if (!next[name]) {
130
+ out.push({ kind: 'function-removed', target: name, breaking: true })
131
+ continue
132
+ }
133
+ if (!same(callableContract(prev[name]), callableContract(next[name]))) {
134
+ out.push({ kind: 'function-signature-changed', target: name, breaking: true })
135
+ }
136
+ if (prev[name].description !== next[name].description) {
137
+ out.push({ kind: 'function-metadata-changed', target: name, breaking: false })
138
+ }
139
+ }
140
+ }
141
+
142
+ function diffViews(
143
+ prev: NonNullable<SchemaIR['views']>,
144
+ next: NonNullable<SchemaIR['views']>,
145
+ out: SchemaChange[],
146
+ ): void {
147
+ for (const name of sortedKeys(next)) {
148
+ if (!(name in prev)) out.push({ kind: 'view-added', target: name, breaking: false })
149
+ }
150
+ for (const name of sortedKeys(prev)) {
151
+ if (!(name in next)) {
152
+ out.push({ kind: 'view-removed', target: name, breaking: true })
153
+ continue
154
+ }
155
+ if (
156
+ !same(
157
+ { target: prev[name].target, auth: prev[name].auth },
158
+ {
159
+ target: next[name].target,
160
+ auth: next[name].auth,
161
+ },
162
+ )
163
+ ) {
164
+ out.push({ kind: 'view-changed', target: name, breaking: true })
165
+ }
166
+ if (prev[name].description !== next[name].description) {
167
+ out.push({ kind: 'view-metadata-changed', target: name, breaking: false })
168
+ }
169
+ }
170
+ }
171
+
172
+ function diffDependencies(
173
+ prev: NonNullable<SchemaIR['dependencies']>,
174
+ next: NonNullable<SchemaIR['dependencies']>,
175
+ out: SchemaChange[],
176
+ ): void {
177
+ const before = Object.fromEntries(
178
+ prev.map((dependency) => [dependency.origin, dependency.revision]),
179
+ )
180
+ const after = Object.fromEntries(
181
+ next.map((dependency) => [dependency.origin, dependency.revision]),
182
+ )
183
+ for (const origin of sortedKeys(after)) {
184
+ if (!(origin in before)) {
185
+ out.push({ kind: 'dependency-added', target: origin, breaking: true })
186
+ }
187
+ }
188
+ for (const origin of sortedKeys(before)) {
189
+ if (!(origin in after)) {
190
+ out.push({ kind: 'dependency-removed', target: origin, breaking: true })
191
+ } else if (before[origin] !== after[origin]) {
192
+ out.push({
193
+ kind: 'dependency-changed',
194
+ target: origin,
195
+ detail: `${before[origin]} → ${after[origin]}`,
196
+ breaking: true,
197
+ })
198
+ }
199
+ }
200
+ }
201
+
51
202
  function memberKind(m: IrClass | IrInterface): 'class' | 'edge' | 'interface' {
52
203
  if ((m as IrInterface).type === 'interface') return 'interface'
53
204
  return (m as IrClass).type === 'edge' ? 'edge' : 'class'
@@ -59,13 +210,13 @@ function diffMembers(
59
210
  _scope: 'class' | 'interface',
60
211
  out: SchemaChange[],
61
212
  ): void {
62
- for (const name of Object.keys(next)) {
213
+ for (const name of sortedKeys(next)) {
63
214
  if (!prev[name]) {
64
215
  const k = memberKind(next[name])
65
216
  out.push({ kind: `${k}-added` as SchemaChange['kind'], target: name, breaking: false })
66
217
  }
67
218
  }
68
- for (const name of Object.keys(prev)) {
219
+ for (const name of sortedKeys(prev)) {
69
220
  if (!next[name]) {
70
221
  const k = memberKind(prev[name])
71
222
  out.push({ kind: `${k}-removed` as SchemaChange['kind'], target: name, breaking: true })
@@ -83,24 +234,35 @@ function diffMemberBody(
83
234
  ): void {
84
235
  const pa = a.properties ?? {}
85
236
  const pb = b.properties ?? {}
86
- for (const p of Object.keys(pb)) {
87
- if (!pa[p]) out.push({ kind: 'prop-added', target: `${name}.${p}`, breaking: false })
237
+ for (const p of sortedKeys(pb)) {
238
+ if (!pa[p]) {
239
+ const required = propertyRequired(b, p)
240
+ out.push({
241
+ kind: 'prop-added',
242
+ target: `${name}.${p}`,
243
+ detail: required ? 'required' : 'optional',
244
+ breaking: required,
245
+ })
246
+ }
88
247
  }
89
- for (const p of Object.keys(pa)) {
248
+ for (const p of sortedKeys(pa)) {
90
249
  if (!pb[p]) {
91
250
  out.push({ kind: 'prop-removed', target: `${name}.${p}`, breaking: true })
92
251
  continue
93
252
  }
94
- if (baseType(pa[p]) !== baseType(pb[p])) {
253
+ if (!same(pa[p], pb[p])) {
254
+ const baseChanged = baseType(pa[p]) !== baseType(pb[p])
95
255
  out.push({
96
- kind: 'prop-type-changed',
256
+ kind: baseChanged ? 'prop-type-changed' : 'prop-schema-changed',
97
257
  target: `${name}.${p}`,
98
- detail: `${baseType(pa[p])} → ${baseType(pb[p])}`,
258
+ detail: baseChanged
259
+ ? `${baseType(pa[p])} → ${baseType(pb[p])}`
260
+ : 'value constraints changed',
99
261
  breaking: true,
100
262
  })
101
263
  }
102
- const wasOpt = isOptional(pa[p])
103
- const nowOpt = isOptional(pb[p])
264
+ const wasOpt = !propertyRequired(a, p)
265
+ const nowOpt = !propertyRequired(b, p)
104
266
  if (wasOpt !== nowOpt) {
105
267
  out.push({
106
268
  kind: 'prop-required-changed',
@@ -113,20 +275,87 @@ function diffMemberBody(
113
275
 
114
276
  const ma = a.methods ?? {}
115
277
  const mb = b.methods ?? {}
116
- for (const m of Object.keys(mb)) {
117
- if (!ma[m]) out.push({ kind: 'method-added', target: `${name}.${m}`, breaking: false })
278
+ for (const m of sortedKeys(mb)) {
279
+ if (!ma[m]) {
280
+ out.push({
281
+ kind: 'method-added',
282
+ target: `${name}.${m}`,
283
+ breaking: b.type === 'interface' && mb[m].inheritance === 'abstract',
284
+ })
285
+ }
118
286
  }
119
- for (const m of Object.keys(ma)) {
287
+ for (const m of sortedKeys(ma)) {
120
288
  if (!mb[m]) {
121
289
  out.push({ kind: 'method-removed', target: `${name}.${m}`, breaking: true })
122
290
  continue
123
291
  }
124
- if (methodSig(ma[m]) !== methodSig(mb[m])) {
292
+ if (!same(callableContract(ma[m]), callableContract(mb[m]))) {
125
293
  out.push({ kind: 'method-signature-changed', target: `${name}.${m}`, breaking: true })
126
294
  }
295
+ if (ma[m].description !== mb[m].description) {
296
+ out.push({ kind: 'method-metadata-changed', target: `${name}.${m}`, breaking: false })
297
+ }
298
+ }
299
+
300
+ const kind = memberKind(b)
301
+ if (!same(memberContract(a), memberContract(b))) {
302
+ out.push({
303
+ kind: `${kind}-contract-changed`,
304
+ target: name,
305
+ breaking: true,
306
+ })
307
+ }
308
+ if (!same(memberMetadata(a), memberMetadata(b))) {
309
+ out.push({ kind: 'definition-metadata-changed', target: name, breaking: false })
310
+ }
311
+ }
312
+
313
+ function propertyRequired(member: IrClass | IrInterface, name: string): boolean {
314
+ return member.required ? member.required.includes(name) : !isOptional(member.properties?.[name])
315
+ }
316
+
317
+ function memberContract(member: IrClass | IrInterface): unknown {
318
+ const cls = member as IrClass
319
+ const iface = member as IrInterface
320
+ return {
321
+ type: member.type,
322
+ origin: member.origin,
323
+ ref: member.ref,
324
+ family: iface.family,
325
+ extends: iface.extendsRefs ?? iface.extends,
326
+ implements: cls.implementsRefs ?? cls.implements,
327
+ endpoints: member.endpoints,
328
+ orientation: member.orientation,
329
+ constraints: member.constraints,
330
+ propertyMetadata: member.propertyMetadata,
331
+ data: member.data,
332
+ policies: cls.policies,
127
333
  }
128
334
  }
129
335
 
336
+ function memberMetadata(member: IrClass | IrInterface): unknown {
337
+ return { description: member.description, icon: (member as IrClass).icon }
338
+ }
339
+
340
+ function sortedKeys(value: Record<string, unknown>): string[] {
341
+ return Object.keys(value).sort((left, right) => left.localeCompare(right))
342
+ }
343
+
344
+ function same(left: unknown, right: unknown): boolean {
345
+ return JSON.stringify(canonical(left)) === JSON.stringify(canonical(right))
346
+ }
347
+
348
+ function canonical(value: unknown): unknown {
349
+ if (Array.isArray(value)) return value.map(canonical)
350
+ if (value === null || typeof value !== 'object') return value
351
+ return Object.fromEntries(
352
+ Object.entries(value as Record<string, unknown>)
353
+ .filter(([, item]) => item !== undefined)
354
+ .sort(([left], [right]) => left.localeCompare(right))
355
+ .map(([key, item]) => [key, canonical(item)]),
356
+ )
357
+ }
358
+
130
359
  export function classify(changes: SchemaChange[]): 'none' | 'additive' | 'breaking' {
131
360
  if (changes.length === 0) return 'none'
132
361
  return changes.some((c) => c.breaking) ? 'breaking' : 'additive'
@@ -1,15 +1,22 @@
1
1
  /**
2
2
  * extractor.ts — the Bun-executed island. Spawned as a short-lived subprocess by
3
3
  * runtime.ts. It imports ONLY the domain's pure `schema/index.ts` module graph
4
- * (never astrale.config.ts or domain.ts deps → integrations), reads the
5
- * compiled `D.$.ir`, and prints the SchemaIR as JSON to stdout.
4
+ * (never astrale.config.ts or the composition entry → integrations). Canonical
5
+ * DomainSchema V1 roots are projected directly; legacy `D.$.ir` / serialize
6
+ * domains retain their existing fallback.
6
7
  *
7
8
  * bun extractor.ts <schemaIndexPath> <domainDir>
8
9
  *
9
- * `compileDomain`/`serialize` are PURE (no IO, no env), so importing the schema
10
- * is side-effect-free. A thrown error prints { ok:false } and exits 0 — the
11
- * driver treats it as a render state, never a crash.
10
+ * A thrown error prints { ok:false } and exits 0 — the driver treats it as a
11
+ * render state, never a crash.
12
12
  */
13
+ import {
14
+ closureFromSdk,
15
+ findCanonicalDomainSchemaExport,
16
+ normalizeLegacySchemaIR,
17
+ projectCanonicalSchema,
18
+ } from './canonical-schema'
19
+
13
20
  const schemaPath = process.argv[2]
14
21
  const domainDir = process.argv[3] ?? process.cwd()
15
22
 
@@ -17,18 +24,36 @@ async function main() {
17
24
  if (!schemaPath) throw new Error('extractor: missing <schemaIndexPath>')
18
25
  const mod: Record<string, any> = await import(schemaPath)
19
26
 
20
- // Lazily resolve the domain's own kernel-dsl serializer (used by the fallback
21
- // path AND to recover imported-interface bodies). Cached after first load.
22
- let dsl: Record<string, any> | null = null
23
- const loadDsl = async (): Promise<Record<string, any>> => {
24
- if (dsl) return dsl
27
+ // Resolve through the DOMAIN's dependency graph. This prevents Studio's own
28
+ // SDK version from accepting or resolving a root authored by another cohort.
29
+ let sdk: Record<string, any> | null = null
30
+ const loadSdkSchema = async (): Promise<Record<string, any>> => {
31
+ if (sdk) return sdk
25
32
  const loaded: Record<string, any> = await import(
26
33
  Bun.resolveSync('@astrale-os/sdk/schema', domainDir)
27
34
  )
28
- dsl = loaded
35
+ sdk = loaded
29
36
  return loaded
30
37
  }
31
38
 
39
+ const canonicalRoot = findCanonicalDomainSchemaExport(mod)
40
+ if (canonicalRoot) {
41
+ const domainSdk = await loadSdkSchema()
42
+ const projected = projectCanonicalSchema(
43
+ canonicalRoot,
44
+ closureFromSdk(domainSdk, canonicalRoot),
45
+ )
46
+ process.stdout.write(
47
+ JSON.stringify({
48
+ ok: true,
49
+ ir: projected.ir,
50
+ root: canonicalRoot,
51
+ importedInterfaces: projected.importedInterfaces,
52
+ }),
53
+ )
54
+ return
55
+ }
56
+
32
57
  // Prefer the conventional `D` (compiled), but accept a compiled domain exported
33
58
  // under ANY name — e.g. ai-gateway exports its `compileDomain(...)` result as
34
59
  // `Gateway`, not `D`. A compiled domain is recognized by shape (`.$.ir`).
@@ -56,7 +81,7 @@ async function main() {
56
81
  'schema entry exports no compiled domain (a `compileDomain(...)` value with `.$.ir`, e.g. `D`) nor a raw `schema`',
57
82
  )
58
83
  }
59
- const d = await loadDsl()
84
+ const d = await loadSdkSchema()
60
85
  if (typeof d.serialize !== 'function') throw new Error('kernel-dsl.serialize unavailable')
61
86
  ir = d.serialize(schema)
62
87
  }
@@ -70,7 +95,7 @@ async function main() {
70
95
  const importSchemas: any[] = Array.isArray(mod?.schema?.imports) ? mod.schema.imports : []
71
96
  if (importSchemas.length > 0) {
72
97
  try {
73
- const d = await loadDsl()
98
+ const d = await loadSdkSchema()
74
99
  if (typeof d.serialize === 'function') {
75
100
  for (const imp of importSchemas) {
76
101
  try {
@@ -88,7 +113,14 @@ async function main() {
88
113
  }
89
114
  }
90
115
 
91
- process.stdout.write(JSON.stringify({ ok: true, ir, importedInterfaces }))
116
+ process.stdout.write(
117
+ JSON.stringify({
118
+ ok: true,
119
+ ir: normalizeLegacySchemaIR(ir),
120
+ root: null,
121
+ importedInterfaces,
122
+ }),
123
+ )
92
124
  }
93
125
 
94
126
  main().catch((err: any) => {