@astrale-os/cli 1.0.0-beta.0 → 1.0.0-beta.2

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 (180) hide show
  1. package/README.md +7 -11
  2. package/dist/astrale.js +3769 -3549
  3. package/dist/public/connect-core.js +396 -392
  4. package/dist/public/keys/index.js +371 -378
  5. package/dist/public/paths/index.js +371 -378
  6. package/dist/types/lib/idp.d.ts +6 -0
  7. package/dist/types/lib/meta.d.ts +2 -2
  8. package/package.json +6 -4
  9. package/src/admin/binding.ts +168 -0
  10. package/src/admin/catalog/__tests__/client.test.ts +2 -2
  11. package/src/admin/catalog/client.ts +4 -9
  12. package/src/admin/instance/__tests__/client.test.ts +5 -3
  13. package/src/admin/instance/client.ts +9 -27
  14. package/src/commands/__tests__/auth-login.test.ts +54 -1
  15. package/src/commands/__tests__/call.test.ts +5 -1
  16. package/src/commands/__tests__/domain-install-operation.test.ts +23 -7
  17. package/src/commands/__tests__/domain-install-owned.test.ts +5 -1
  18. package/src/commands/__tests__/domain-list.test.ts +83 -4
  19. package/src/commands/__tests__/install-identity-override.test.ts +30 -19
  20. package/src/commands/__tests__/studio-descriptor.test.ts +22 -0
  21. package/src/commands/__tests__/update.test.ts +73 -0
  22. package/src/commands/domain/install.ts +11 -15
  23. package/src/commands/domain/list.ts +17 -12
  24. package/src/commands/studio.ts +20 -0
  25. package/src/commands/update.ts +39 -13
  26. package/src/connection/__tests__/errors.test.ts +16 -12
  27. package/src/connection/__tests__/exchange.test.ts +6 -5
  28. package/src/connection/exchange.ts +5 -4
  29. package/src/lib/__tests__/domain-publication.test.ts +144 -0
  30. package/src/lib/__tests__/idp-session.test.ts +22 -0
  31. package/src/lib/__tests__/idp.test.ts +2 -0
  32. package/src/lib/__tests__/instance.test.ts +6 -0
  33. package/src/lib/__tests__/studio-server-deps.test.ts +53 -16
  34. package/src/lib/__tests__/update.test.ts +12 -0
  35. package/src/lib/domain-publication.ts +102 -0
  36. package/src/lib/idp.ts +5 -1
  37. package/src/lib/instance.ts +6 -1
  38. package/src/lib/login-flow.ts +13 -3
  39. package/src/lib/meta.ts +2 -2
  40. package/src/lib/update.ts +4 -4
  41. package/src/program/__tests__/program.test.ts +1 -1
  42. package/studio/client/dist/assets/{elk-api-D2xgMJvi.js → elk-api-lwhFo7vB.js} +1 -1
  43. package/studio/client/dist/assets/index-B-c-smo9.js +8 -0
  44. package/studio/client/dist/assets/index-BckHuAWk.js +81 -0
  45. package/studio/client/dist/assets/index-C4aNQ6dz.css +1 -0
  46. package/studio/client/dist/index.html +2 -2
  47. package/studio/package.json +3 -1
  48. package/studio/server/agent/bridge/stdio.ts +8 -1
  49. package/studio/server/agent/conversation.test.ts +10 -5
  50. package/studio/server/agent/conversation.ts +56 -8
  51. package/studio/server/agent/harness/codex/loadout.ts +18 -21
  52. package/studio/server/agent/harness/gateway/config.ts +20 -18
  53. package/studio/server/agent/harness/selection.ts +9 -1
  54. package/studio/server/agent/prompts/system.ts +3 -2
  55. package/studio/server/agent/prompts/turn.ts +13 -4
  56. package/studio/server/agent/run/completion.ts +2 -2
  57. package/studio/server/agent/run/preparation.ts +6 -5
  58. package/studio/server/agent/run/transcript.test.ts +35 -1
  59. package/studio/server/agent/run/transcript.ts +169 -3
  60. package/studio/server/agent/run/usage.test.ts +24 -0
  61. package/studio/server/agent/run/usage.ts +26 -1
  62. package/studio/server/api/canvas.ts +44 -0
  63. package/studio/server/api/comments.ts +85 -0
  64. package/studio/server/api/context.ts +61 -0
  65. package/studio/server/api/deployment.ts +39 -0
  66. package/studio/server/api/documents.ts +55 -0
  67. package/studio/server/api/domain.ts +59 -0
  68. package/studio/server/api/http.ts +45 -0
  69. package/studio/server/api/project.ts +57 -0
  70. package/studio/server/api/schema.ts +12 -0
  71. package/studio/server/api/views.ts +57 -0
  72. package/studio/server/api/workspace.ts +60 -0
  73. package/studio/server/api.test.ts +147 -0
  74. package/studio/server/api.ts +20 -422
  75. package/studio/server/cache.test.ts +111 -0
  76. package/studio/server/cache.ts +163 -9
  77. package/studio/server/cli-consumers.test.ts +494 -0
  78. package/studio/server/cli.test.ts +88 -0
  79. package/studio/server/cli.ts +240 -0
  80. package/studio/server/client-package.ts +25 -1
  81. package/studio/server/domain.test.ts +30 -2
  82. package/studio/server/domain.ts +14 -2
  83. package/studio/server/environment/dotenv-preview.test.ts +39 -0
  84. package/studio/server/environment/dotenv-preview.ts +27 -0
  85. package/studio/server/environment/files.test.ts +53 -0
  86. package/studio/server/{state/env.ts → environment/files.ts} +7 -48
  87. package/studio/server/{state → handoff}/copy.ts +21 -6
  88. package/studio/server/handoff/service.test.ts +22 -0
  89. package/studio/server/{state/handoff.ts → handoff/service.ts} +21 -14
  90. package/studio/server/instances/active.ts +72 -0
  91. package/studio/server/instances/deploy-record.test.ts +31 -0
  92. package/studio/server/instances/deploy-record.ts +37 -0
  93. package/studio/server/instances/deploy.ts +56 -0
  94. package/studio/server/{state/instance.test.ts → instances/probe.test.ts} +25 -21
  95. package/studio/server/instances/probe.ts +67 -0
  96. package/studio/server/instances/status.test.ts +12 -0
  97. package/studio/server/instances/status.ts +49 -0
  98. package/studio/server/introspect/anatomy/client-tree.ts +57 -0
  99. package/studio/server/introspect/anatomy/env-fields.ts +66 -0
  100. package/studio/server/introspect/anatomy/schema-definition.ts +53 -0
  101. package/studio/server/introspect/anatomy/source.ts +181 -0
  102. package/studio/server/introspect/anatomy/views/legacy.ts +232 -0
  103. package/studio/server/introspect/anatomy/views/routes.ts +105 -0
  104. package/studio/server/introspect/anatomy/views.ts +58 -0
  105. package/studio/server/introspect/anatomy-extras.test.ts +91 -4
  106. package/studio/server/introspect/anatomy-extras.ts +7 -726
  107. package/studio/server/introspect/anatomy.ts +46 -19
  108. package/studio/server/introspect/bundle.ts +15 -6
  109. package/studio/server/introspect/canonical-schema.test.ts +79 -0
  110. package/studio/server/introspect/canonical-schema.ts +80 -18
  111. package/studio/server/introspect/config-preview.test.ts +32 -0
  112. package/studio/server/introspect/config-preview.ts +119 -0
  113. package/studio/server/introspect/diff.test.ts +7 -11
  114. package/studio/server/introspect/diff.ts +28 -37
  115. package/studio/server/introspect/extractor.ts +8 -6
  116. package/studio/server/introspect/hash.ts +5 -2
  117. package/studio/server/introspect/overlay-tsmorph.ts +7 -1305
  118. package/studio/server/introspect/revision.test.ts +54 -0
  119. package/studio/server/introspect/revision.ts +49 -0
  120. package/studio/server/introspect/runtime.test.ts +63 -5
  121. package/studio/server/introspect/runtime.ts +49 -3
  122. package/studio/server/introspect/schema-ir-json.ts +181 -0
  123. package/studio/server/introspect/source-overlay/annotations.ts +14 -0
  124. package/studio/server/introspect/source-overlay/handlers.ts +681 -0
  125. package/studio/server/introspect/source-overlay/kernel-calls.ts +49 -0
  126. package/studio/server/introspect/source-overlay/project.ts +248 -0
  127. package/studio/server/introspect/source-overlay/spans.ts +360 -0
  128. package/studio/server/json.ts +46 -0
  129. package/studio/server/lifecycle.ts +5 -1
  130. package/studio/server/sse.test.ts +26 -0
  131. package/studio/server/sse.ts +4 -1
  132. package/studio/server/state/baseline.test.ts +212 -1
  133. package/studio/server/state/baseline.ts +145 -29
  134. package/studio/server/state/comments.test.ts +66 -1
  135. package/studio/server/state/comments.ts +173 -10
  136. package/studio/server/state/context.ts +34 -2
  137. package/studio/server/state/documents.ts +42 -1
  138. package/studio/server/state/integrations.ts +27 -1
  139. package/studio/server/state/layout.test.ts +34 -0
  140. package/studio/server/state/layout.ts +44 -17
  141. package/studio/server/state/settings.ts +29 -22
  142. package/studio/server/state/store.test.ts +122 -0
  143. package/studio/server/state/store.ts +66 -17
  144. package/studio/server/state/visibility.ts +22 -7
  145. package/studio/server/views/model.test.ts +57 -0
  146. package/studio/server/views/model.ts +66 -0
  147. package/studio/server/views/runtime.ts +40 -0
  148. package/studio/server/views/selection-repository.test.ts +31 -0
  149. package/studio/server/views/selection-repository.ts +60 -0
  150. package/studio/server/views/session.test.ts +73 -0
  151. package/studio/server/views/session.ts +166 -0
  152. package/studio/server/{state/views.test.ts → views/target.test.ts} +8 -159
  153. package/studio/server/views/target.ts +248 -0
  154. package/studio/server/watch.ts +1 -1
  155. package/studio/server/workspace/catalog.test.ts +17 -0
  156. package/studio/server/workspace/catalog.ts +43 -0
  157. package/studio/server/{state → workspace}/create.test.ts +1 -1
  158. package/studio/server/{state → workspace}/create.ts +1 -1
  159. package/studio/server/{state → workspace}/git.ts +1 -1
  160. package/studio/server/workspace/updates.ts +83 -0
  161. package/studio/server/workspace-state.ts +1 -1
  162. package/studio/shared/contracts/README.md +16 -0
  163. package/studio/shared/contracts/agent.ts +270 -0
  164. package/studio/shared/contracts/runtime.ts +50 -0
  165. package/studio/shared/contracts/schema.ts +462 -0
  166. package/studio/shared/contracts/surface.test.ts +319 -0
  167. package/studio/shared/contracts/workspace.ts +274 -0
  168. package/studio/shared/layout.test.ts +116 -0
  169. package/studio/shared/schema/identity.test.ts +58 -0
  170. package/studio/shared/schema/identity.ts +82 -0
  171. package/studio/shared/types.ts +11 -1099
  172. package/studio/tsconfig.json +1 -1
  173. package/viewer/dist/main.js +35 -35
  174. package/studio/client/dist/assets/index-BMdnsIJA.css +0 -1
  175. package/studio/client/dist/assets/index-D-vRV8w7.js +0 -8
  176. package/studio/client/dist/assets/index-LGSWRrk8.js +0 -81
  177. package/studio/server/state/catalog.ts +0 -117
  178. package/studio/server/state/instance.ts +0 -280
  179. package/studio/server/state/updates.ts +0 -63
  180. package/studio/server/state/views.ts +0 -535
@@ -10,24 +10,32 @@
10
10
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
11
11
  import { join, relative } from 'node:path'
12
12
 
13
- import type { DomainAnatomy, DomainOverview } from '../../shared/types'
13
+ import type { DomainAnatomy, DomainOverview, SchemaIR } from '../../shared/types'
14
14
 
15
+ import { asJsonRecord, asString, parseJson } from '../json'
15
16
  import { readSettings } from '../state/settings'
16
17
  import { buildClientTree, buildEnvFields, buildViews, findSchemaDefinition } from './anatomy-extras'
18
+ import { readConfigPreview } from './config-preview'
17
19
 
18
20
  export interface AnatomyArgs {
19
21
  root: string
20
22
  schemaDirName: string
21
23
  clientDir?: string
24
+ canonicalViews?: NonNullable<SchemaIR['views']>
22
25
  }
23
26
 
24
- export function buildAnatomy({ root, schemaDirName, clientDir }: AnatomyArgs): DomainAnatomy {
27
+ export function buildAnatomy({
28
+ root,
29
+ schemaDirName,
30
+ clientDir,
31
+ canonicalViews,
32
+ }: AnatomyArgs): DomainAnatomy {
25
33
  const schema = findSchemaDefinition(root, schemaDirName)
26
34
  const authoredClientDir =
27
35
  clientDir ?? (existsSync(join(root, 'ui')) ? join(root, 'ui') : undefined)
28
36
  return {
29
37
  overview: buildOverview(root, schemaDirName, authoredClientDir, schema?.origin),
30
- views: buildViews(root, schemaDirName),
38
+ views: buildViews(root, schemaDirName, canonicalViews),
31
39
  client: buildClientTree(root, clientDir ?? null),
32
40
  env: buildEnvFields(root),
33
41
  detectedIntegrations: detectIntegrations(root),
@@ -40,7 +48,7 @@ function buildOverview(
40
48
  clientDir?: string,
41
49
  schemaOrigin?: string,
42
50
  ): DomainOverview {
43
- const pkg = readJsonSafe(join(root, 'package.json'))
51
+ const pkg = readPackageJsonSafe(join(root, 'package.json'))
44
52
  const astraleDeps: Record<string, string> = {}
45
53
  for (const [k, v] of Object.entries({
46
54
  ...(pkg?.dependencies ?? {}),
@@ -49,16 +57,7 @@ function buildOverview(
49
57
  if (k.startsWith('@astrale-os/')) astraleDeps[k] = String(v)
50
58
  }
51
59
 
52
- const config = readTextSafe(join(root, 'astrale.config.ts'))
53
- let adapter: DomainOverview['adapter'] = 'unknown'
54
- if (/\bastrale\s*\(/.test(config)) adapter = 'astrale'
55
- else if (/\bcloudflare\s*\(/.test(config)) adapter = 'cloudflare'
56
-
57
- const instance = config.match(/instance\s*:\s*['"]([^'"]+)['"]/)?.[1]
58
- const route = config.match(/route\s*:\s*['"]([^'"]+)['"]/)?.[1]
59
- const devSecrets =
60
- config.match(/dev\s*:\s*\{[^}]*secrets\s*:\s*['"]([^'"]+)['"]/)?.[1] ??
61
- config.match(/secrets\s*:\s*['"]([^'"]+)['"]/)?.[1]
60
+ const config = readConfigPreview(root)
62
61
 
63
62
  const compositionFile = existsSync(join(root, 'implementation.ts'))
64
63
  ? 'implementation.ts'
@@ -77,9 +76,9 @@ function buildOverview(
77
76
  return {
78
77
  origin,
79
78
  compositionFile,
80
- adapter,
81
- prodTarget: instance ? `instance: ${instance}` : route ? `route: ${route}` : undefined,
82
- devSecrets,
79
+ adapter: config.adapter,
80
+ prodTarget: config.prodTarget,
81
+ devSecrets: config.devSecrets,
83
82
  postInstall: undefined,
84
83
  requires: [],
85
84
  packageName: pkg?.name,
@@ -114,9 +113,37 @@ function readTextSafe(f: string): string {
114
113
  }
115
114
  }
116
115
 
117
- function readJsonSafe(f: string): any {
116
+ interface PackageOverview {
117
+ name?: string
118
+ version?: string
119
+ dependencies?: Record<string, string>
120
+ devDependencies?: Record<string, string>
121
+ }
122
+
123
+ function decodeDependencyMap(value: unknown): Record<string, string> | undefined {
124
+ const record = asJsonRecord(value)
125
+ if (!record) return undefined
126
+ return Object.fromEntries(
127
+ Object.entries(record).filter(
128
+ (entry): entry is [string, string] => typeof entry[1] === 'string',
129
+ ),
130
+ )
131
+ }
132
+
133
+ function readPackageJsonSafe(f: string): PackageOverview | null {
118
134
  try {
119
- return JSON.parse(readFileSync(f, 'utf8'))
135
+ const record = asJsonRecord(parseJson(readFileSync(f, 'utf8')))
136
+ if (!record) return null
137
+ const name = asString(record.name)
138
+ const version = asString(record.version)
139
+ const dependencies = decodeDependencyMap(record.dependencies)
140
+ const devDependencies = decodeDependencyMap(record.devDependencies)
141
+ return {
142
+ ...(name === undefined ? {} : { name }),
143
+ ...(version === undefined ? {} : { version }),
144
+ ...(dependencies === undefined ? {} : { dependencies }),
145
+ ...(devDependencies === undefined ? {} : { devDependencies }),
146
+ }
120
147
  } catch {
121
148
  return null
122
149
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * bundle.ts — assembles the StudioSchemaBundle: runtime IR (PRIMARY) + ts-morph
3
- * overlay + schemaHash + deps-installed precondition. Never throws; a failed
3
+ * overlay + render fingerprint + deps-installed precondition. Never throws; a failed
4
4
  * runtime import becomes a render-state error (with the overlay still computed
5
5
  * statically, so anchors/handler-links survive a mid-edit compile break).
6
6
  */
@@ -8,7 +8,7 @@ import type { StudioSchemaBundle } from '../../shared/types'
8
8
 
9
9
  import { type DomainHandle, depsInstalled } from '../domain'
10
10
  import { readSettings } from '../state/settings'
11
- import { schemaHashOf } from './hash'
11
+ import { renderFingerprintOf } from './hash'
12
12
  import { buildOverlay } from './overlay'
13
13
  import { runtimeExtract } from './runtime'
14
14
 
@@ -16,6 +16,8 @@ export async function buildBundle(handle: DomainHandle): Promise<StudioSchemaBun
16
16
  const installed = depsInstalled(handle.root)
17
17
  let ir = null
18
18
  let schemaRoot: unknown | undefined
19
+ let schemaMode: StudioSchemaBundle['schemaMode'] = 'unavailable'
20
+ let schemaRevision: StudioSchemaBundle['schemaRevision']
19
21
  let importedInterfaces: StudioSchemaBundle['importedInterfaces']
20
22
  let error: StudioSchemaBundle['error'] = null
21
23
  let extractedBy: StudioSchemaBundle['extractedBy'] = 'runtime-bun'
@@ -29,6 +31,8 @@ export async function buildBundle(handle: DomainHandle): Promise<StudioSchemaBun
29
31
  if (r.ok) {
30
32
  ir = r.ir
31
33
  if (r.root !== null) schemaRoot = r.root
34
+ schemaMode = r.schemaMode
35
+ if (r.revision !== null) schemaRevision = r.revision
32
36
  importedInterfaces = r.importedInterfaces
33
37
  } else {
34
38
  error = { message: r.error?.message ?? 'schema failed to compile' }
@@ -44,13 +48,18 @@ export async function buildBundle(handle: DomainHandle): Promise<StudioSchemaBun
44
48
 
45
49
  const overlay = buildOverlay({ ir, domainRoot: handle.root, schemaDir: handle.schemaDir })
46
50
  if (ir) handle.origin = ir.domain
51
+ const renderFingerprint =
52
+ schemaRoot !== undefined
53
+ ? renderFingerprintOf(schemaRoot)
54
+ : ir
55
+ ? renderFingerprintOf(ir)
56
+ : 'sha-none'
47
57
 
48
58
  return {
49
59
  domainId: handle.id,
50
- // The Kernel installs the canonical DomainSchema root, not the Studio's
51
- // lossy render projection. Legacy domains have no root and retain IR hashing.
52
- schemaHash:
53
- schemaRoot !== undefined ? schemaHashOf(schemaRoot) : ir ? schemaHashOf(ir) : 'sha-none',
60
+ renderFingerprint,
61
+ schemaMode,
62
+ ...(schemaRevision === undefined ? {} : { schemaRevision }),
54
63
  extractedBy,
55
64
  depsInstalled: installed,
56
65
  ir,
@@ -344,6 +344,85 @@ describe('canonical DomainSchema V1 projection', () => {
344
344
  expect(projected.importedInterfaces.Named).toBeUndefined()
345
345
  })
346
346
 
347
+ test('collects an exact external Definition referenced only by a top-level Policy', () => {
348
+ const policyOrigin = 'authorization.example.dev'
349
+ const subjectRef = { origin: policyOrigin, kind: 'class', name: 'Subject' } as const
350
+ const objectRef = { origin: policyOrigin, kind: 'class', name: 'Object' } as const
351
+ const policyOnlyRef = { origin: policyOrigin, kind: 'class', name: 'may_access' } as const
352
+ const policyDependency = {
353
+ format: 'astrale.dsl',
354
+ version: 'v1',
355
+ origin: policyOrigin,
356
+ dependencies: [],
357
+ types: {},
358
+ interfaces: {},
359
+ classes: {
360
+ Subject: {
361
+ family: 'node',
362
+ implements: [],
363
+ properties: emptyProperties,
364
+ propertyMetadata: {},
365
+ methods: {},
366
+ policies: {},
367
+ },
368
+ Object: {
369
+ family: 'node',
370
+ implements: [],
371
+ properties: emptyProperties,
372
+ propertyMetadata: {},
373
+ methods: {},
374
+ policies: {},
375
+ },
376
+ may_access: {
377
+ family: 'edge',
378
+ implements: [],
379
+ properties: emptyProperties,
380
+ propertyMetadata: {},
381
+ orientation: 'directed',
382
+ endpoints: {
383
+ source: { role: 'subject', accepts: [subjectRef], outgoing: '0..*' },
384
+ target: { role: 'object', accepts: [objectRef], incoming: '0..*' },
385
+ },
386
+ policies: {},
387
+ constraints: {},
388
+ },
389
+ },
390
+ functions: {},
391
+ policies: {},
392
+ views: {},
393
+ core: { nodes: {}, edges: [] },
394
+ } satisfies CanonicalDomainSchemaV1
395
+ const policyOnlyRoot = {
396
+ format: 'astrale.dsl',
397
+ version: 'v1',
398
+ origin: 'policy-consumer.example.dev',
399
+ dependencies: [{ origin: policyDependency.origin, revision: `sha256:${'2'.repeat(64)}` }],
400
+ types: {},
401
+ interfaces: {},
402
+ classes: {},
403
+ functions: {},
404
+ policies: {
405
+ canRead: {
406
+ match: {
407
+ source: { kind: 'subject' },
408
+ class: policyOnlyRef,
409
+ target: { kind: 'object' },
410
+ },
411
+ },
412
+ },
413
+ views: {},
414
+ core: { nodes: {}, edges: [] },
415
+ } satisfies CanonicalDomainSchemaV1
416
+
417
+ const projected = projectCanonicalSchema(policyOnlyRoot, [policyDependency])
418
+ expect(projected.ir.importsByKey?.['authorization.example.dev:class.may_access']).toEqual({
419
+ origin: policyDependency.origin,
420
+ definition: 'class',
421
+ ref: policyOnlyRef,
422
+ key: 'authorization.example.dev:class.may_access',
423
+ })
424
+ })
425
+
347
426
  test('projects canonical core paths and the owning Domain endpoint', () => {
348
427
  expect(projectCanonicalCore(root)).toEqual({
349
428
  domain: root.origin,
@@ -9,13 +9,11 @@ import type {
9
9
  IrCallableOutput,
10
10
  IrClass,
11
11
  IrDefinitionKey,
12
- IrDefinitionRef,
13
12
  IrEndpoint,
14
13
  IrFunction,
15
14
  IrImportDescriptor,
16
15
  IrInterface,
17
16
  IrMethod,
18
- IrSchemaKey,
19
17
  IrSchemaRef,
20
18
  IrView,
21
19
  JsonSchema,
@@ -23,6 +21,9 @@ import type {
23
21
  StudioCore,
24
22
  } from '../../shared/types'
25
23
 
24
+ import { definitionRefKey, isIrDefinitionRef, schemaRefKey } from '../../shared/schema/identity'
25
+ import { isSchemaRevision } from '../../shared/types'
26
+
26
27
  type AnyRecord = Record<string, unknown>
27
28
 
28
29
  export type CanonicalDomainSchemaV1 = AnyRecord & {
@@ -36,6 +37,20 @@ export interface CanonicalSchemaProjection {
36
37
  importedInterfaces: Record<string, IrInterface>
37
38
  }
38
39
 
40
+ export type CanonicalSchemaAdmission =
41
+ | {
42
+ status: 'admitted'
43
+ root: CanonicalDomainSchemaV1
44
+ closure: CanonicalDomainSchemaV1[]
45
+ revision: `sha256:${string}`
46
+ }
47
+ | {
48
+ status: 'preview'
49
+ root: CanonicalDomainSchemaV1
50
+ closure: CanonicalDomainSchemaV1[]
51
+ revision: null
52
+ }
53
+
39
54
  export function isCanonicalDomainSchemaV1(value: unknown): value is CanonicalDomainSchemaV1 {
40
55
  const candidate = asRecord(value)
41
56
  return (
@@ -103,6 +118,65 @@ export function closureFromSdk(
103
118
  return []
104
119
  }
105
120
 
121
+ /**
122
+ * Re-admit a structurally V1-looking export through the Domain's own SDK cohort.
123
+ * Retained Builder context supplies the exact dependency closure. If that proof
124
+ * is unavailable or admission fails, Studio may still render the document as a
125
+ * structural preview, but it must not assign a canonical revision to it.
126
+ */
127
+ export function admitCanonicalSchemaFromSdk(
128
+ sdkModule: Record<string, unknown>,
129
+ candidate: CanonicalDomainSchemaV1,
130
+ ): CanonicalSchemaAdmission {
131
+ const retainedClosure = closureFromSdk(sdkModule, candidate)
132
+ const schemaApi = asRecord(sdkModule.schema)
133
+ const accept = schemaApi?.accept
134
+ const revision = schemaApi?.revision
135
+ if (!schemaApi || typeof accept !== 'function' || typeof revision !== 'function') {
136
+ return { status: 'preview', root: candidate, closure: retainedClosure, revision: null }
137
+ }
138
+
139
+ try {
140
+ const revisions = new Map<string, string>()
141
+ for (const dependency of retainedClosure) {
142
+ const value = Reflect.apply(revision, schemaApi, [dependency])
143
+ if (!isSchemaRevision(value)) throw new TypeError('SDK returned an invalid schema revision')
144
+ revisions.set(dependency.origin, value)
145
+ }
146
+ const lookup = {
147
+ get(origin: string, expected: string): CanonicalDomainSchemaV1 | undefined {
148
+ const dependency = retainedClosure.find((entry) => entry.origin === origin)
149
+ return dependency && revisions.get(origin) === expected ? dependency : undefined
150
+ },
151
+ }
152
+ const accepted = Reflect.apply(accept, schemaApi, [candidate, lookup])
153
+ if (!isCanonicalDomainSchemaV1(accepted) || accepted.origin !== candidate.origin) {
154
+ throw new TypeError('SDK admission returned a different schema root')
155
+ }
156
+ const acceptedRevision = Reflect.apply(revision, schemaApi, [accepted])
157
+ if (!isSchemaRevision(acceptedRevision)) {
158
+ throw new TypeError('SDK returned an invalid schema revision')
159
+ }
160
+
161
+ // Resolve the newly accepted value once more so its retained closure, rather
162
+ // than Studio's structural candidate, is what feeds exact-ref projection.
163
+ const resolve = schemaApi.resolve
164
+ let closure = retainedClosure
165
+ if (typeof resolve === 'function') {
166
+ const resolved = asRecord(Reflect.apply(resolve, schemaApi, [accepted]))
167
+ const proof = asRecord(resolved?.$)
168
+ if (proof?.schema !== accepted) throw new TypeError('SDK resolution did not retain the root')
169
+ const admitted = admitClosure(proof.closure, accepted)
170
+ if (!admitted) throw new TypeError('SDK resolution returned an invalid dependency closure')
171
+ closure = admitted
172
+ }
173
+
174
+ return { status: 'admitted', root: accepted, closure, revision: acceptedRevision }
175
+ } catch {
176
+ return { status: 'preview', root: candidate, closure: retainedClosure, revision: null }
177
+ }
178
+ }
179
+
106
180
  /** Add fields required by the current shared contract to a legacy serializer IR. */
107
181
  export function normalizeLegacySchemaIR(value: unknown): SchemaIR {
108
182
  const ir = asRecord(value)
@@ -174,8 +248,8 @@ export function projectCanonicalSchema(
174
248
 
175
249
  for (let index = 0; index < pending.length; index++) {
176
250
  const ref = pending[index]
177
- if (!isDefinitionRef(ref) || ref.origin === origin) continue
178
- const key = definitionKey(ref)
251
+ if (!isIrDefinitionRef(ref) || ref.origin === origin) continue
252
+ const key = definitionRefKey(ref)
179
253
  if (visited.has(key)) continue
180
254
  visited.add(key)
181
255
 
@@ -556,7 +630,7 @@ function collectDefinitionRefs(root: CanonicalDomainSchemaV1): IrSchemaRef[] {
556
630
  addRef(declaration?.source, refs)
557
631
  addRef(declaration?.target, refs)
558
632
  }
559
- collectPolicyRefs(root.policies, refs)
633
+ for (const [, policy] of entriesOf(root.policies)) collectPolicyRefs(policy, refs)
560
634
  return uniqueRefs(refs)
561
635
  }
562
636
 
@@ -636,25 +710,13 @@ function addRef(value: unknown, refs: IrSchemaRef[]): void {
636
710
  function uniqueRefs(refs: readonly IrSchemaRef[]): IrSchemaRef[] {
637
711
  const seen = new Set<string>()
638
712
  return refs.filter((ref) => {
639
- const key = refKey(ref)
713
+ const key = schemaRefKey(ref)
640
714
  if (seen.has(key)) return false
641
715
  seen.add(key)
642
716
  return true
643
717
  })
644
718
  }
645
719
 
646
- function refKey(ref: IrSchemaRef): IrSchemaKey {
647
- return `${ref.origin}:${ref.kind}.${ref.name}`
648
- }
649
-
650
- function isDefinitionRef(ref: IrSchemaRef): ref is IrDefinitionRef {
651
- return ref.kind === 'class' || ref.kind === 'interface'
652
- }
653
-
654
- function definitionKey(ref: IrDefinitionRef): IrDefinitionKey {
655
- return `${ref.origin}:${ref.kind}.${ref.name}`
656
- }
657
-
658
720
  function coreEndpoint(value: unknown, rootOrigin: string): string {
659
721
  const endpoint = asRecord(value)
660
722
  if (endpoint?.kind === 'domain') return `/:${rootOrigin}`
@@ -0,0 +1,32 @@
1
+ import { expect, test } from 'bun:test'
2
+
3
+ import { parseConfigPreview } from './config-preview'
4
+
5
+ test('config preview ignores examples in comments and preserves URL literals', () => {
6
+ const preview = parseConfigPreview(`
7
+ // cloudflare({ prod: { route: 'commented.invalid', secrets: '.env.fake' } })
8
+ /* astrale({ prod: { instance: 'commented-instance' } }) */
9
+ export default deploy(domain, cloudflare({
10
+ dev: { client: { dir: 'client' }, secrets: '.env.dev' },
11
+ prod: {
12
+ client: { dir: 'client' },
13
+ route: 'https://services.example.dev/path',
14
+ secrets: '.env.prod',
15
+ },
16
+ }))
17
+ `)
18
+
19
+ expect(preview).toEqual({
20
+ adapter: 'cloudflare',
21
+ prodTarget: 'route: https://services.example.dev/path',
22
+ devSecrets: '.env.dev',
23
+ configuredSecretFiles: ['.env.dev', '.env.prod'],
24
+ })
25
+ })
26
+
27
+ test('config preview reports dynamic configuration as unknown instead of inventing authority', () => {
28
+ expect(parseConfigPreview(`export default makeDeployment(domain)`)).toEqual({
29
+ adapter: 'unknown',
30
+ configuredSecretFiles: [],
31
+ })
32
+ })
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Static, non-authoritative preview of astrale.config.ts.
3
+ *
4
+ * Deployment configuration is owned by the SDK adapter and is executable
5
+ * TypeScript. Studio deliberately does not execute it during anatomy/env reads;
6
+ * these few fields are only display/editor hints and may be unknown when the
7
+ * configuration is computed dynamically.
8
+ */
9
+ import { readFileSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+
12
+ export interface ConfigPreview {
13
+ adapter: 'astrale' | 'cloudflare' | 'unknown'
14
+ prodTarget?: string
15
+ devSecrets?: string
16
+ configuredSecretFiles: string[]
17
+ }
18
+
19
+ function withoutComments(source: string): string {
20
+ let output = ''
21
+ let quote: "'" | '"' | '`' | null = null
22
+ let escaped = false
23
+ for (let index = 0; index < source.length; index++) {
24
+ const char = source[index]!
25
+ const next = source[index + 1]
26
+ if (quote) {
27
+ output += char
28
+ if (escaped) escaped = false
29
+ else if (char === '\\') escaped = true
30
+ else if (char === quote) quote = null
31
+ continue
32
+ }
33
+ if (char === "'" || char === '"' || char === '`') {
34
+ quote = char
35
+ output += char
36
+ continue
37
+ }
38
+ if (char === '/' && next === '/') {
39
+ while (index + 1 < source.length && source[index + 1] !== '\n') index++
40
+ continue
41
+ }
42
+ if (char === '/' && next === '*') {
43
+ index += 2
44
+ while (index < source.length && !(source[index] === '*' && source[index + 1] === '/')) {
45
+ if (source[index] === '\n') output += '\n'
46
+ index++
47
+ }
48
+ index++
49
+ continue
50
+ }
51
+ output += char
52
+ }
53
+ return output
54
+ }
55
+
56
+ function stringField(source: string, name: string): string | undefined {
57
+ const match = new RegExp(`\\b${name}\\s*:\\s*(['"])(.*?)\\1`).exec(source)
58
+ return match?.[2]
59
+ }
60
+
61
+ function objectBody(source: string, name: string): string | undefined {
62
+ const start = new RegExp(`\\b${name}\\s*:\\s*\\{`).exec(source)
63
+ if (!start) return undefined
64
+ const opening = source.indexOf('{', start.index)
65
+ let depth = 1
66
+ let quote: "'" | '"' | '`' | null = null
67
+ let escaped = false
68
+ for (let index = opening + 1; index < source.length; index++) {
69
+ const char = source[index]!
70
+ if (quote) {
71
+ if (escaped) escaped = false
72
+ else if (char === '\\') escaped = true
73
+ else if (char === quote) quote = null
74
+ continue
75
+ }
76
+ if (char === "'" || char === '"' || char === '`') {
77
+ quote = char
78
+ continue
79
+ }
80
+ if (char === '{') depth++
81
+ else if (char === '}' && --depth === 0) return source.slice(opening + 1, index)
82
+ }
83
+ return undefined
84
+ }
85
+
86
+ export function parseConfigPreview(source: string): ConfigPreview {
87
+ const active = withoutComments(source)
88
+ const adapter: ConfigPreview['adapter'] = /\bastrale\s*\(/.test(active)
89
+ ? 'astrale'
90
+ : /\bcloudflare\s*\(/.test(active)
91
+ ? 'cloudflare'
92
+ : 'unknown'
93
+ const prod = objectBody(active, 'prod') ?? active
94
+ const dev = objectBody(active, 'dev') ?? ''
95
+ const instance = stringField(prod, 'instance')
96
+ const route = stringField(prod, 'route')
97
+ const configuredSecretFiles = [...active.matchAll(/\bsecrets\s*:\s*(['"])(.*?)\1/g)].map(
98
+ (match) => match[2]!,
99
+ )
100
+ const devSecrets = stringField(dev, 'secrets')
101
+ return {
102
+ adapter,
103
+ ...(instance
104
+ ? { prodTarget: `instance: ${instance}` }
105
+ : route
106
+ ? { prodTarget: `route: ${route}` }
107
+ : {}),
108
+ ...(devSecrets ? { devSecrets } : {}),
109
+ configuredSecretFiles: [...new Set(configuredSecretFiles)],
110
+ }
111
+ }
112
+
113
+ export function readConfigPreview(root: string): ConfigPreview {
114
+ try {
115
+ return parseConfigPreview(readFileSync(join(root, 'astrale.config.ts'), 'utf8'))
116
+ } catch {
117
+ return { adapter: 'unknown', configuredSecretFiles: [] }
118
+ }
119
+ }
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'
2
2
 
3
3
  import type { IrClass, IrFunction, SchemaIR } from '../../shared/types'
4
4
 
5
- import { classify, diffSchemas } from './diff'
5
+ import { diffSchemas, structuralStatusOf } from './diff'
6
6
 
7
7
  const callable = (returnsType = 'string'): IrFunction => ({
8
8
  name: 'inspect',
@@ -31,21 +31,19 @@ const withClass = (member: IrClass): SchemaIR => ({
31
31
  })
32
32
 
33
33
  describe('canonical standalone Function diffs', () => {
34
- test('classifies addition as additive and removal as breaking', () => {
34
+ test('reports additions and removals without claiming Runtime compatibility', () => {
35
35
  const empty = schema({})
36
36
  const populated = schema({ inspect: callable() })
37
- expect(diffSchemas(empty, populated)).toEqual([
38
- { kind: 'function-added', target: 'inspect', breaking: false },
39
- ])
37
+ expect(diffSchemas(empty, populated)).toEqual([{ kind: 'function-added', target: 'inspect' }])
40
38
  const removed = diffSchemas(populated, empty)
41
- expect(removed).toEqual([{ kind: 'function-removed', target: 'inspect', breaking: true }])
42
- expect(classify(removed)).toBe('breaking')
39
+ expect(removed).toEqual([{ kind: 'function-removed', target: 'inspect' }])
40
+ expect(structuralStatusOf(removed)).toBe('changed')
43
41
  })
44
42
 
45
43
  test('detects a standalone Function signature change', () => {
46
44
  expect(
47
45
  diffSchemas(schema({ inspect: callable() }), schema({ inspect: callable('number') })),
48
- ).toEqual([{ kind: 'function-signature-changed', target: 'inspect', breaking: true }])
46
+ ).toEqual([{ kind: 'function-signature-changed', target: 'inspect' }])
49
47
  })
50
48
 
51
49
  test('detects auth and output-mode changes in callable contracts', () => {
@@ -57,7 +55,7 @@ describe('canonical standalone Function diffs', () => {
57
55
  }
58
56
 
59
57
  expect(diffSchemas(schema({ inspect: before }), schema({ inspect: after }))).toEqual([
60
- { kind: 'function-signature-changed', target: 'inspect', breaking: true },
58
+ { kind: 'function-signature-changed', target: 'inspect' },
61
59
  ])
62
60
  })
63
61
 
@@ -75,13 +73,11 @@ describe('canonical standalone Function diffs', () => {
75
73
  kind: 'prop-schema-changed',
76
74
  target: 'Person.name',
77
75
  detail: 'value constraints changed',
78
- breaking: true,
79
76
  },
80
77
  {
81
78
  kind: 'prop-required-changed',
82
79
  target: 'Person.name',
83
80
  detail: 'optional → required',
84
- breaking: true,
85
81
  },
86
82
  ])
87
83
  })