@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
@@ -0,0 +1,46 @@
1
+ /** Pure structural helpers for JSON trust boundaries. */
2
+
3
+ export type JsonRecord = Record<string, unknown>
4
+ export type JsonDecoder<T> = (value: unknown) => T | undefined
5
+
6
+ export function asJsonRecord(value: unknown): JsonRecord | undefined {
7
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
8
+ ? (value as JsonRecord)
9
+ : undefined
10
+ }
11
+
12
+ export function asJsonArray(value: unknown): unknown[] | undefined {
13
+ return Array.isArray(value) ? value : undefined
14
+ }
15
+
16
+ export function asString(value: unknown): string | undefined {
17
+ return typeof value === 'string' ? value : undefined
18
+ }
19
+
20
+ export function asBoolean(value: unknown): boolean | undefined {
21
+ return typeof value === 'boolean' ? value : undefined
22
+ }
23
+
24
+ export function asFiniteNumber(value: unknown): number | undefined {
25
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
26
+ }
27
+
28
+ export function asStringArray(value: unknown): string[] | undefined {
29
+ return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : undefined
30
+ }
31
+
32
+ export function asStringRecord(value: unknown): Record<string, string> | undefined {
33
+ const record = asJsonRecord(value)
34
+ if (!record || Object.values(record).some((item) => typeof item !== 'string')) return undefined
35
+ return record as Record<string, string>
36
+ }
37
+
38
+ /** Parse JSON as untrusted data. `undefined` means syntax failure. */
39
+ export function parseJson(text: string): unknown | undefined {
40
+ try {
41
+ const value: unknown = JSON.parse(text)
42
+ return value
43
+ } catch {
44
+ return undefined
45
+ }
46
+ }
@@ -26,7 +26,11 @@ export async function bootDomain(handle: DomainHandle): Promise<BootedDomain> {
26
26
  if (!loadBaseline(handle.root))
27
27
  captureBaseline(
28
28
  handle.root,
29
- bundle?.ir ?? null,
29
+ {
30
+ ir: bundle?.ir ?? null,
31
+ root: bundle?.schemaMode === 'canonical-admitted' ? (bundle.schemaRoot ?? null) : null,
32
+ revision: bundle?.schemaRevision ?? null,
33
+ },
30
34
  hashAnatomyFiles(handle.root, handle.schemaDirName),
31
35
  )
32
36
  const stop = watchDomain(handle)
@@ -0,0 +1,26 @@
1
+ import { expect, test } from 'bun:test'
2
+
3
+ import { broadcast, sseResponse } from './sse'
4
+
5
+ test('SSE response preserves headers and emits the initial workspace frame', async () => {
6
+ const response = sseResponse(['issues', 'shell'])
7
+ expect(response.status).toBe(200)
8
+ expect(response.headers.get('content-type')).toBe('text/event-stream')
9
+ expect(response.headers.get('cache-control')).toBe('no-cache, no-transform')
10
+ expect(response.headers.get('connection')).toBe('keep-alive')
11
+
12
+ const reader = response.body!.getReader()
13
+ const first = await reader.read()
14
+ expect(new TextDecoder().decode(first.value)).toBe(
15
+ 'data: {"type":"hello","domains":["issues","shell"]}\n\n',
16
+ )
17
+
18
+ expect(broadcast({ type: 'workspace', domains: ['issues', 'billing'] })).toBe(1)
19
+ const update = await reader.read()
20
+ expect(new TextDecoder().decode(update.value)).toBe(
21
+ 'data: {"type":"workspace","domains":["issues","billing"]}\n\n',
22
+ )
23
+
24
+ await reader.cancel()
25
+ expect(broadcast({ type: 'workspace', domains: [] })).toBe(0)
26
+ })
@@ -41,7 +41,9 @@ export function sseResponse(domains: string[]): Response {
41
41
  })
42
42
  }
43
43
 
44
- export function broadcast(event: StudioEvent): void {
44
+ /** Broadcast an event and report how many clients were registered at dispatch start. */
45
+ export function broadcast(event: StudioEvent): number {
46
+ const recipients = clients.size
45
47
  const payload = encoder.encode(frame(event))
46
48
  for (const [id, c] of clients) {
47
49
  try {
@@ -50,6 +52,7 @@ export function broadcast(event: StudioEvent): void {
50
52
  clients.delete(id)
51
53
  }
52
54
  }
55
+ return recipients
53
56
  }
54
57
 
55
58
  function frame(event: StudioEvent): string {
@@ -3,7 +3,17 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
 
6
- import { hashAnatomyFiles } from './baseline'
6
+ import type { SchemaIR } from '../../shared/types'
7
+
8
+ import { STUDIO_SCHEMA_PROJECTION_VERSION } from '../../shared/types'
9
+ import {
10
+ BASELINE_FORMAT_VERSION,
11
+ captureBaseline,
12
+ computeChanges,
13
+ hashAnatomyFiles,
14
+ loadBaseline,
15
+ } from './baseline'
16
+ import { writeJson, writeState } from './store'
7
17
 
8
18
  const roots: string[] = []
9
19
 
@@ -49,3 +59,204 @@ test('hashes current implementation and semantic layer files alongside legacy an
49
59
  'views/risk/index.ts',
50
60
  ])
51
61
  })
62
+
63
+ const schema = (domain: string): SchemaIR => ({
64
+ format: 'astrale.dsl',
65
+ version: 'v1',
66
+ domain,
67
+ types: {},
68
+ interfaces: {},
69
+ classes: {},
70
+ imports: {},
71
+ functions: {},
72
+ })
73
+
74
+ const canonicalRoot = {
75
+ format: 'astrale.dsl',
76
+ version: 'v1',
77
+ origin: 'notes.example.dev',
78
+ } as const
79
+
80
+ test('persists a versioned canonical baseline with revision and raw root', () => {
81
+ const root = mkdtempSync(join(tmpdir(), 'studio-versioned-baseline-'))
82
+ roots.push(root)
83
+ const revision = `sha256:${'b'.repeat(64)}` as const
84
+
85
+ captureBaseline(
86
+ root,
87
+ { ir: schema('notes.example.dev'), root: canonicalRoot, revision },
88
+ { source: 'hash' },
89
+ )
90
+
91
+ expect(loadBaseline(root)).toMatchObject({
92
+ formatVersion: BASELINE_FORMAT_VERSION,
93
+ revision,
94
+ root: canonicalRoot,
95
+ files: { source: 'hash' },
96
+ })
97
+ })
98
+
99
+ test('invalidates an unversioned baseline instead of diffing it through a new projection', () => {
100
+ const root = mkdtempSync(join(tmpdir(), 'studio-legacy-baseline-'))
101
+ roots.push(root)
102
+ writeJson(root, '.cache/baseline/meta.json', { capturedAt: '2026-01-01T00:00:00.000Z' })
103
+ writeJson(root, '.cache/baseline/ir.json', schema('notes.example.dev'))
104
+ writeJson(root, '.cache/baseline/files.json', {})
105
+
106
+ expect(loadBaseline(root)).toBeNull()
107
+ })
108
+
109
+ const corruptBaselineCases = [
110
+ {
111
+ name: 'missing meta',
112
+ mutate: (root: string) => rmSync(join(root, '.domain-studio/.cache/baseline/meta.json')),
113
+ },
114
+ {
115
+ name: 'syntactically invalid meta',
116
+ mutate: (root: string) => writeState(root, '.cache/baseline/meta.json', '{'),
117
+ },
118
+ {
119
+ name: 'structurally invalid meta',
120
+ mutate: (root: string) =>
121
+ writeJson(root, '.cache/baseline/meta.json', {
122
+ formatVersion: BASELINE_FORMAT_VERSION,
123
+ projectionVersion: STUDIO_SCHEMA_PROJECTION_VERSION,
124
+ }),
125
+ },
126
+ {
127
+ name: 'future baseline format',
128
+ mutate: (root: string) =>
129
+ writeJson(root, '.cache/baseline/meta.json', {
130
+ formatVersion: BASELINE_FORMAT_VERSION + 1,
131
+ projectionVersion: STUDIO_SCHEMA_PROJECTION_VERSION,
132
+ revision: `sha256:${'d'.repeat(64)}`,
133
+ }),
134
+ },
135
+ {
136
+ name: 'future projection format',
137
+ mutate: (root: string) =>
138
+ writeJson(root, '.cache/baseline/meta.json', {
139
+ formatVersion: BASELINE_FORMAT_VERSION,
140
+ projectionVersion: STUDIO_SCHEMA_PROJECTION_VERSION + 1,
141
+ revision: `sha256:${'d'.repeat(64)}`,
142
+ }),
143
+ },
144
+ {
145
+ name: 'missing IR',
146
+ mutate: (root: string) => rmSync(join(root, '.domain-studio/.cache/baseline/ir.json')),
147
+ },
148
+ {
149
+ name: 'syntactically invalid IR',
150
+ mutate: (root: string) => writeState(root, '.cache/baseline/ir.json', '{'),
151
+ },
152
+ {
153
+ name: 'structurally invalid IR',
154
+ mutate: (root: string) =>
155
+ writeJson(root, '.cache/baseline/ir.json', {
156
+ ...schema('notes.example.dev'),
157
+ classes: { Broken: null },
158
+ }),
159
+ },
160
+ {
161
+ name: 'missing canonical root',
162
+ mutate: (root: string) => rmSync(join(root, '.domain-studio/.cache/baseline/schema-root.json')),
163
+ },
164
+ {
165
+ name: 'syntactically invalid canonical root',
166
+ mutate: (root: string) => writeState(root, '.cache/baseline/schema-root.json', '{'),
167
+ },
168
+ {
169
+ name: 'structurally invalid canonical root',
170
+ mutate: (root: string) =>
171
+ writeJson(root, '.cache/baseline/schema-root.json', { canonical: true }),
172
+ },
173
+ {
174
+ name: 'missing file hashes',
175
+ mutate: (root: string) => rmSync(join(root, '.domain-studio/.cache/baseline/files.json')),
176
+ },
177
+ {
178
+ name: 'syntactically invalid file hashes',
179
+ mutate: (root: string) => writeState(root, '.cache/baseline/files.json', '{'),
180
+ },
181
+ {
182
+ name: 'structurally invalid file hashes',
183
+ mutate: (root: string) => writeJson(root, '.cache/baseline/files.json', { source: 42 }),
184
+ },
185
+ ] as const
186
+
187
+ for (const scenario of corruptBaselineCases) {
188
+ test(`invalidates a baseline with ${scenario.name}`, () => {
189
+ const root = mkdtempSync(join(tmpdir(), 'studio-corrupt-baseline-'))
190
+ roots.push(root)
191
+ const revision = `sha256:${'d'.repeat(64)}` as const
192
+ captureBaseline(root, { ir: schema('notes.example.dev'), root: canonicalRoot, revision }, {})
193
+
194
+ scenario.mutate(root)
195
+
196
+ expect(loadBaseline(root)).toBeNull()
197
+ })
198
+ }
199
+
200
+ test('keeps a valid current-format legacy baseline without a canonical root or revision', () => {
201
+ const root = mkdtempSync(join(tmpdir(), 'studio-legacy-v2-baseline-'))
202
+ roots.push(root)
203
+ const legacyIr = { ...schema('notes.example.dev'), version: 'legacy' }
204
+
205
+ captureBaseline(root, { ir: legacyIr, root: null, revision: null }, { source: 'hash' })
206
+
207
+ expect(loadBaseline(root)).toMatchObject({
208
+ formatVersion: BASELINE_FORMAT_VERSION,
209
+ ir: legacyIr,
210
+ root: null,
211
+ revision: null,
212
+ files: { source: 'hash' },
213
+ })
214
+ })
215
+
216
+ test('uses equal admitted revisions to suppress projection-only schema churn', () => {
217
+ const root = mkdtempSync(join(tmpdir(), 'studio-revision-baseline-'))
218
+ roots.push(root)
219
+ const revision = `sha256:${'c'.repeat(64)}` as const
220
+ captureBaseline(
221
+ root,
222
+ {
223
+ ir: schema('old-projection.example.dev'),
224
+ root: { ...canonicalRoot, origin: 'old-projection.example.dev' },
225
+ revision,
226
+ },
227
+ {},
228
+ )
229
+
230
+ const changes = computeChanges(
231
+ root,
232
+ schema('new-projection.example.dev'),
233
+ {},
234
+ {
235
+ currentRevision: revision,
236
+ git: { hasGit: false },
237
+ },
238
+ )
239
+ expect(changes.schemaChanges).toEqual([])
240
+ expect(changes.structuralStatus).toBe('none')
241
+ expect(changes.baselineRevision).toBe(revision)
242
+ expect(changes.currentRevision).toBe(revision)
243
+ })
244
+
245
+ test('uses caller-provided Git enrichment without importing workspace effects', () => {
246
+ const root = mkdtempSync(join(tmpdir(), 'studio-enriched-baseline-'))
247
+ roots.push(root)
248
+ captureBaseline(root, { ir: schema('notes.example.dev'), root: null, revision: null }, {})
249
+
250
+ const changes = computeChanges(
251
+ root,
252
+ schema('notes.example.dev'),
253
+ {},
254
+ {
255
+ git: { hasGit: true, diffText: 'diff --git a/schema/index.ts b/schema/index.ts' },
256
+ },
257
+ )
258
+
259
+ expect(changes.source).toBe('git')
260
+ expect(changes.hasGit).toBe(true)
261
+ expect(changes.schemaDiffText).toBe('diff --git a/schema/index.ts b/schema/index.ts')
262
+ })
@@ -1,26 +1,38 @@
1
1
  /**
2
2
  * baseline.ts — the PRIMARY change tracker. On first launch we capture a
3
- * snapshot (the schema IR + a content hash of the "anatomy + schema fileset")
3
+ * versioned snapshot (canonical revision/root, render IR, and a content hash of
4
+ * the "anatomy + schema fileset")
4
5
  * under `.domain-studio/.cache/baseline/`. Subsequent runs diff the live state
5
6
  * against that baseline to compute a ChangeSet. Git (git.ts) only enriches when
6
7
  * a real repo is present; the fixtures are not repos, so baseline stands alone.
7
8
  *
8
9
  * Layout under `.cache/baseline/`:
9
- * ir.json — the captured SchemaIR (or null)
10
- * files.json — { [relpathFromRoot]: sha256 }
11
- * meta.json — { capturedAt }
10
+ * ir.json — the captured render SchemaIR (or null)
11
+ * schema-root.json — the admitted portable V1 root (or null)
12
+ * files.json — { [relpathFromRoot]: sha256 }
13
+ * meta.json — format/projection versions, revision, capturedAt
12
14
  */
13
15
  import { createHash } from 'node:crypto'
14
16
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
15
17
  import { join, relative, resolve } from 'node:path'
16
18
 
17
- import type { ChangeSet, FileChange, SchemaChange, SchemaIR } from '../../shared/types'
19
+ import type {
20
+ ChangeSet,
21
+ FileChange,
22
+ SchemaChange,
23
+ SchemaIR,
24
+ SchemaRevision,
25
+ } from '../../shared/types'
18
26
 
19
- import { classify, diffSchemas } from '../introspect/diff'
20
- import { detectGit, gitDiff } from './git'
21
- import { readJson, writeJson } from './store'
27
+ import { isSchemaRevision, STUDIO_SCHEMA_PROJECTION_VERSION } from '../../shared/types'
28
+ import { isCanonicalDomainSchemaV1 } from '../introspect/canonical-schema'
29
+ import { diffSchemas, structuralStatusOf } from '../introspect/diff'
30
+ import { decodeSchemaIR } from '../introspect/schema-ir-json'
31
+ import { asFiniteNumber, asJsonRecord, asStringRecord, parseJson } from '../json'
32
+ import { readState, writeJson } from './store'
22
33
 
23
34
  const BASE = '.cache/baseline'
35
+ export const BASELINE_FORMAT_VERSION = 2
24
36
 
25
37
  /** Directories to never descend into when hashing the fileset. */
26
38
  const SKIP_DIRS = new Set(['node_modules', '.dist', 'dist', '.domain-studio', '.git'])
@@ -140,30 +152,128 @@ export function hashAnatomyFiles(root: string, schemaDirName: string): Record<st
140
152
  }
141
153
 
142
154
  export interface Baseline {
155
+ formatVersion: typeof BASELINE_FORMAT_VERSION
156
+ projectionVersion: number
143
157
  ir: SchemaIR | null
158
+ /** Admitted canonical root only; null for legacy/preview snapshots. */
159
+ root: unknown | null
160
+ revision: SchemaRevision | null
144
161
  files: Record<string, string>
145
162
  capturedAt?: string
146
163
  }
147
164
 
165
+ export interface BaselineSchemaSnapshot {
166
+ ir: SchemaIR | null
167
+ /** Admitted canonical root only; preview roots must be passed as null. */
168
+ root: unknown | null
169
+ revision: SchemaRevision | null
170
+ }
171
+
172
+ interface BaselineMeta {
173
+ formatVersion: number
174
+ projectionVersion: number
175
+ revision: SchemaRevision | null
176
+ capturedAt?: string
177
+ }
178
+
179
+ function decodeBaselineMeta(value: unknown): BaselineMeta | undefined {
180
+ const record = asJsonRecord(value)
181
+ if (!record) return undefined
182
+ const formatVersion = asFiniteNumber(record.formatVersion)
183
+ const projectionVersion = asFiniteNumber(record.projectionVersion)
184
+ const revision =
185
+ record.revision === null
186
+ ? null
187
+ : isSchemaRevision(record.revision)
188
+ ? record.revision
189
+ : undefined
190
+ const capturedAt = record.capturedAt
191
+ if (
192
+ formatVersion === undefined ||
193
+ projectionVersion === undefined ||
194
+ revision === undefined ||
195
+ (capturedAt !== undefined && typeof capturedAt !== 'string')
196
+ ) {
197
+ return undefined
198
+ }
199
+ return {
200
+ formatVersion,
201
+ projectionVersion,
202
+ revision,
203
+ ...(capturedAt === undefined ? {} : { capturedAt }),
204
+ }
205
+ }
206
+
207
+ function decodeNullableSchemaIR(value: unknown): SchemaIR | null | undefined {
208
+ return value === null ? null : decodeSchemaIR(value)
209
+ }
210
+
211
+ function decodeNullableSchemaRoot(value: unknown): unknown | null | undefined {
212
+ return value === null ? null : isCanonicalDomainSchemaV1(value) ? value : undefined
213
+ }
214
+
215
+ function readBaselineJson<T>(
216
+ root: string,
217
+ subpath: string,
218
+ decode: (value: unknown) => T | undefined,
219
+ ): T | undefined {
220
+ const raw = readState(root, subpath)
221
+ if (raw === null) return undefined
222
+ const parsed = parseJson(raw)
223
+ return parsed === undefined ? undefined : decode(parsed)
224
+ }
225
+
148
226
  /** Persist a baseline snapshot under `.cache/baseline/`. All writes go through the store. */
149
227
  export function captureBaseline(
150
228
  root: string,
151
- ir: SchemaIR | null,
229
+ snapshot: BaselineSchemaSnapshot,
152
230
  fileHashes: Record<string, string>,
153
231
  ): void {
154
- writeJson(root, `${BASE}/ir.json`, ir)
232
+ writeJson(root, `${BASE}/ir.json`, snapshot.ir)
233
+ writeJson(root, `${BASE}/schema-root.json`, snapshot.root)
155
234
  writeJson(root, `${BASE}/files.json`, fileHashes)
156
- writeJson(root, `${BASE}/meta.json`, { capturedAt: new Date().toISOString() })
235
+ writeJson(root, `${BASE}/meta.json`, {
236
+ formatVersion: BASELINE_FORMAT_VERSION,
237
+ projectionVersion: STUDIO_SCHEMA_PROJECTION_VERSION,
238
+ revision: snapshot.revision,
239
+ capturedAt: new Date().toISOString(),
240
+ } satisfies BaselineMeta)
157
241
  }
158
242
 
159
- /** Load a previously-captured baseline, or null if none exists. Never throws. */
243
+ /**
244
+ * Load a current baseline. Older/unrecognised formats and render-projection
245
+ * versions are explicitly invalidated so adapter changes cannot masquerade as
246
+ * authored schema changes. The lifecycle will seed a replacement snapshot.
247
+ */
160
248
  export function loadBaseline(root: string): Baseline | null {
161
- const irPath = join(resolve(root), '.domain-studio', BASE, 'meta.json')
162
- if (!existsSync(irPath)) return null
163
- const ir = readJson<SchemaIR | null>(root, `${BASE}/ir.json`, null)
164
- const files = readJson<Record<string, string>>(root, `${BASE}/files.json`, {})
165
- const meta = readJson<{ capturedAt?: string }>(root, `${BASE}/meta.json`, {})
166
- return { ir, files, capturedAt: meta.capturedAt }
249
+ const meta = readBaselineJson(root, `${BASE}/meta.json`, decodeBaselineMeta)
250
+ if (
251
+ !meta ||
252
+ meta.formatVersion !== BASELINE_FORMAT_VERSION ||
253
+ meta.projectionVersion !== STUDIO_SCHEMA_PROJECTION_VERSION ||
254
+ !isSchemaRevisionOrNull(meta.revision)
255
+ ) {
256
+ return null
257
+ }
258
+ const ir = readBaselineJson(root, `${BASE}/ir.json`, decodeNullableSchemaIR)
259
+ const schemaRoot = readBaselineJson(root, `${BASE}/schema-root.json`, decodeNullableSchemaRoot)
260
+ const files = readBaselineJson(root, `${BASE}/files.json`, asStringRecord)
261
+ if (ir === undefined || schemaRoot === undefined || files === undefined) return null
262
+ if (meta.revision !== null && (schemaRoot === null || ir === null)) return null
263
+ if (meta.revision === null && schemaRoot !== null) return null
264
+ return {
265
+ formatVersion: BASELINE_FORMAT_VERSION,
266
+ projectionVersion: STUDIO_SCHEMA_PROJECTION_VERSION,
267
+ ir,
268
+ root: schemaRoot,
269
+ revision: meta.revision ?? null,
270
+ files,
271
+ capturedAt: meta.capturedAt,
272
+ }
273
+ }
274
+
275
+ function isSchemaRevisionOrNull(value: unknown): value is SchemaRevision | null {
276
+ return value === null || isSchemaRevision(value)
167
277
  }
168
278
 
169
279
  /** Compare two file-hash maps into added/modified/removed FileChange[]. */
@@ -185,9 +295,8 @@ function summarizeSchemaChanges(changes: SchemaChange[]): string {
185
295
  if (changes.length === 0) return 'No schema changes.'
186
296
  return changes
187
297
  .map((c) => {
188
- const flag = c.breaking ? 'breaking' : 'additive'
189
298
  const detail = c.detail ? `: ${c.detail}` : ''
190
- return `${c.kind} ${c.target}${detail} (${flag})`
299
+ return `${c.kind} ${c.target}${detail}`
191
300
  })
192
301
  .join('\n')
193
302
  }
@@ -202,9 +311,12 @@ export function computeChanges(
202
311
  root: string,
203
312
  currentIr: SchemaIR | null,
204
313
  currentFiles: Record<string, string>,
205
- opts: { schemaDirName: string },
314
+ opts: {
315
+ currentRevision?: SchemaRevision | null
316
+ git: { hasGit: boolean; diffText?: string }
317
+ },
206
318
  ): ChangeSet {
207
- const { hasGit } = detectGit(root)
319
+ const { hasGit } = opts.git
208
320
  const source: ChangeSet['source'] = hasGit ? 'git' : 'baseline'
209
321
  const baseline = loadBaseline(root)
210
322
 
@@ -215,17 +327,19 @@ export function computeChanges(
215
327
  hasBaseline: false,
216
328
  schemaChanges: [],
217
329
  fileChanges: [],
218
- classification: 'none',
330
+ structuralStatus: 'none',
331
+ ...(opts.currentRevision ? { currentRevision: opts.currentRevision } : {}),
219
332
  }
220
333
  }
221
334
 
222
- const schemaChanges = diffSchemas(baseline.ir ?? null, currentIr)
223
- const classification = classify(schemaChanges)
335
+ const schemaChanges =
336
+ baseline.revision && opts.currentRevision === baseline.revision
337
+ ? []
338
+ : diffSchemas(baseline.ir ?? null, currentIr)
339
+ const structuralStatus = structuralStatusOf(schemaChanges)
224
340
  const fileChanges = diffFiles(baseline.files ?? {}, currentFiles)
225
341
 
226
- const schemaDiffText = hasGit
227
- ? (gitDiff(root, opts.schemaDirName) ?? undefined)
228
- : summarizeSchemaChanges(schemaChanges)
342
+ const schemaDiffText = hasGit ? opts.git.diffText : summarizeSchemaChanges(schemaChanges)
229
343
 
230
344
  return {
231
345
  source,
@@ -234,7 +348,9 @@ export function computeChanges(
234
348
  schemaChanges,
235
349
  fileChanges,
236
350
  schemaDiffText,
237
- classification,
351
+ structuralStatus,
352
+ ...(baseline.revision ? { baselineRevision: baseline.revision } : {}),
353
+ ...(opts.currentRevision ? { currentRevision: opts.currentRevision } : {}),
238
354
  baselineCapturedAt: baseline.capturedAt,
239
355
  }
240
356
  }
@@ -3,7 +3,8 @@ import { mkdtempSync, rmSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
 
6
- import { readComments, upsertComment } from './comments'
6
+ import { mergeReply, readComments, upsertComment } from './comments'
7
+ import { writeJson } from './store'
7
8
 
8
9
  const roots: string[] = []
9
10
 
@@ -27,3 +28,67 @@ test('missing comment stores do not share entries across domains', () => {
27
28
  ])
28
29
  expect(readComments(rootB).comments).toEqual([])
29
30
  })
31
+
32
+ test('normalizes malformed nested comment data and ignores future fields', () => {
33
+ const root = mkdtempSync(join(tmpdir(), 'studio-comments-corrupt-'))
34
+ roots.push(root)
35
+ writeJson(root, 'comments.json', {
36
+ schemaVersion: 'render-fingerprint',
37
+ futureStoreField: { version: 2 },
38
+ comments: [
39
+ {
40
+ id: 'kept',
41
+ anchors: ['Thing'],
42
+ anchorRefs: [
43
+ { ref: 'class.Thing', kind: 'schema', futureAnchorField: true },
44
+ { ref: 42, kind: 'schema' },
45
+ ],
46
+ status: 'open',
47
+ thread: [
48
+ {
49
+ id: 'entry',
50
+ role: 'author',
51
+ type: 'text',
52
+ text: 'A valid question',
53
+ futureEntryField: true,
54
+ },
55
+ { id: 'broken', role: 'nobody', type: 'text', text: 'ignored' },
56
+ ],
57
+ createdAt: '2026-08-20T00:00:00.000Z',
58
+ futureCommentField: true,
59
+ },
60
+ { id: 42, thread: [] },
61
+ ],
62
+ })
63
+
64
+ expect(readComments(root)).toEqual({
65
+ schemaVersion: 'render-fingerprint',
66
+ comments: [
67
+ {
68
+ id: 'kept',
69
+ anchors: ['Thing'],
70
+ anchorRefs: [{ ref: 'class.Thing', kind: 'schema' }],
71
+ status: 'open',
72
+ thread: [
73
+ {
74
+ id: 'entry',
75
+ role: 'author',
76
+ type: 'text',
77
+ text: 'A valid question',
78
+ },
79
+ ],
80
+ createdAt: '2026-08-20T00:00:00.000Z',
81
+ kind: 'question',
82
+ },
83
+ ],
84
+ })
85
+ })
86
+
87
+ test('rejects a syntactically valid machine-state block with the wrong root shape', () => {
88
+ const root = mkdtempSync(join(tmpdir(), 'studio-comments-paste-'))
89
+ roots.push(root)
90
+
91
+ expect(() => mergeReply(root, 'render-fingerprint', '```json\n[]\n```')).toThrow(
92
+ 'invalid machine-state json block',
93
+ )
94
+ })