@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.
- package/README.md +15 -3
- package/dist/astrale.js +86 -35
- package/package.json +5 -5
- package/src/commands/__tests__/domain-install-operation.test.ts +121 -0
- package/src/commands/__tests__/domain-install-owned.test.ts +66 -0
- package/src/commands/__tests__/install-direct.test.ts +3 -2
- package/src/commands/domain/install.ts +78 -15
- package/src/connection/__tests__/errors.test.ts +82 -6
- package/src/connection/command.ts +9 -1
- package/src/connection/errors.ts +19 -8
- package/src/connection/index.ts +1 -1
- package/src/connection/reasons.ts +26 -12
- package/src/program/__tests__/program.test.ts +1 -1
- package/studio/client/dist/assets/{elk-api-D0cBetPW.js → elk-api-D2xgMJvi.js} +1 -1
- package/studio/client/dist/assets/{index-BQnJ5sgd.css → index-BMdnsIJA.css} +1 -1
- package/studio/client/dist/assets/index-D-vRV8w7.js +8 -0
- package/studio/client/dist/assets/index-LGSWRrk8.js +81 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/package.json +1 -1
- package/studio/server/agent/prompts/anchors.test.ts +74 -0
- package/studio/server/agent/prompts/anchors.ts +85 -15
- package/studio/server/agent/prompts/system.test.ts +12 -0
- package/studio/server/agent/prompts/system.ts +6 -6
- package/studio/server/api.ts +1 -5
- package/studio/server/cache.ts +5 -2
- package/studio/server/domain.test.ts +67 -0
- package/studio/server/domain.ts +29 -6
- package/studio/server/index.ts +1 -3
- package/studio/server/introspect/anatomy-extras.test.ts +104 -1
- package/studio/server/introspect/anatomy-extras.ts +340 -8
- package/studio/server/introspect/anatomy.test.ts +33 -0
- package/studio/server/introspect/anatomy.ts +22 -7
- package/studio/server/introspect/bundle.ts +7 -1
- package/studio/server/introspect/canonical-schema.test.ts +395 -0
- package/studio/server/introspect/canonical-schema.ts +751 -0
- package/studio/server/introspect/core-extractor.ts +30 -10
- package/studio/server/introspect/core.ts +4 -2
- package/studio/server/introspect/diff.test.ts +124 -0
- package/studio/server/introspect/diff.ts +252 -23
- package/studio/server/introspect/extractor.ts +46 -14
- package/studio/server/introspect/overlay-tsmorph.test.ts +164 -1
- package/studio/server/introspect/overlay-tsmorph.ts +381 -106
- package/studio/server/introspect/overlay.test.ts +72 -0
- package/studio/server/introspect/overlay.ts +16 -6
- package/studio/server/introspect/runtime.test.ts +217 -0
- package/studio/server/introspect/runtime.ts +18 -6
- package/studio/server/introspect/schema-refs.test.ts +100 -0
- package/studio/server/introspect/schema-refs.ts +23 -2
- package/studio/server/state/baseline.test.ts +51 -0
- package/studio/server/state/baseline.ts +31 -2
- package/studio/server/state/create.test.ts +37 -0
- package/studio/server/state/create.ts +21 -15
- package/studio/server/state/instance.test.ts +63 -0
- package/studio/server/state/instance.ts +65 -29
- package/studio/server/state/views.test.ts +209 -9
- package/studio/server/state/views.ts +176 -69
- package/studio/server/watch.test.ts +36 -0
- package/studio/server/watch.ts +24 -16
- package/studio/server/workspace-watch.ts +8 -3
- package/studio/shared/types.ts +164 -33
- package/studio/client/dist/assets/index-Dspir4w7.js +0 -81
- package/studio/client/dist/assets/index-bVD2KJgz.js +0 -8
- package/studio/server/view-dev-server.test.ts +0 -111
- 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
|
|
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
|
-
|
|
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 } =
|
|
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
|
|
45
|
-
|
|
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 (
|
|
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
|
-
*
|
|
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 {
|
|
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
|
|
66
|
-
*
|
|
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
|
-
|
|
43
|
-
|
|
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: [
|
|
35
|
-
|
|
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
|
+
})
|