@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.
- package/README.md +7 -11
- package/dist/astrale.js +3769 -3549
- package/dist/public/connect-core.js +396 -392
- package/dist/public/keys/index.js +371 -378
- package/dist/public/paths/index.js +371 -378
- package/dist/types/lib/idp.d.ts +6 -0
- package/dist/types/lib/meta.d.ts +2 -2
- package/package.json +6 -4
- package/src/admin/binding.ts +168 -0
- package/src/admin/catalog/__tests__/client.test.ts +2 -2
- package/src/admin/catalog/client.ts +4 -9
- package/src/admin/instance/__tests__/client.test.ts +5 -3
- package/src/admin/instance/client.ts +9 -27
- package/src/commands/__tests__/auth-login.test.ts +54 -1
- package/src/commands/__tests__/call.test.ts +5 -1
- package/src/commands/__tests__/domain-install-operation.test.ts +23 -7
- package/src/commands/__tests__/domain-install-owned.test.ts +5 -1
- package/src/commands/__tests__/domain-list.test.ts +83 -4
- package/src/commands/__tests__/install-identity-override.test.ts +30 -19
- package/src/commands/__tests__/studio-descriptor.test.ts +22 -0
- package/src/commands/__tests__/update.test.ts +73 -0
- package/src/commands/domain/install.ts +11 -15
- package/src/commands/domain/list.ts +17 -12
- package/src/commands/studio.ts +20 -0
- package/src/commands/update.ts +39 -13
- package/src/connection/__tests__/errors.test.ts +16 -12
- package/src/connection/__tests__/exchange.test.ts +6 -5
- package/src/connection/exchange.ts +5 -4
- package/src/lib/__tests__/domain-publication.test.ts +144 -0
- package/src/lib/__tests__/idp-session.test.ts +22 -0
- package/src/lib/__tests__/idp.test.ts +2 -0
- package/src/lib/__tests__/instance.test.ts +6 -0
- package/src/lib/__tests__/studio-server-deps.test.ts +53 -16
- package/src/lib/__tests__/update.test.ts +12 -0
- package/src/lib/domain-publication.ts +102 -0
- package/src/lib/idp.ts +5 -1
- package/src/lib/instance.ts +6 -1
- package/src/lib/login-flow.ts +13 -3
- package/src/lib/meta.ts +2 -2
- package/src/lib/update.ts +4 -4
- package/src/program/__tests__/program.test.ts +1 -1
- package/studio/client/dist/assets/{elk-api-D2xgMJvi.js → elk-api-lwhFo7vB.js} +1 -1
- package/studio/client/dist/assets/index-B-c-smo9.js +8 -0
- package/studio/client/dist/assets/index-BckHuAWk.js +81 -0
- package/studio/client/dist/assets/index-C4aNQ6dz.css +1 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/package.json +3 -1
- package/studio/server/agent/bridge/stdio.ts +8 -1
- package/studio/server/agent/conversation.test.ts +10 -5
- package/studio/server/agent/conversation.ts +56 -8
- package/studio/server/agent/harness/codex/loadout.ts +18 -21
- package/studio/server/agent/harness/gateway/config.ts +20 -18
- package/studio/server/agent/harness/selection.ts +9 -1
- package/studio/server/agent/prompts/system.ts +3 -2
- package/studio/server/agent/prompts/turn.ts +13 -4
- package/studio/server/agent/run/completion.ts +2 -2
- package/studio/server/agent/run/preparation.ts +6 -5
- package/studio/server/agent/run/transcript.test.ts +35 -1
- package/studio/server/agent/run/transcript.ts +169 -3
- package/studio/server/agent/run/usage.test.ts +24 -0
- package/studio/server/agent/run/usage.ts +26 -1
- package/studio/server/api/canvas.ts +44 -0
- package/studio/server/api/comments.ts +85 -0
- package/studio/server/api/context.ts +61 -0
- package/studio/server/api/deployment.ts +39 -0
- package/studio/server/api/documents.ts +55 -0
- package/studio/server/api/domain.ts +59 -0
- package/studio/server/api/http.ts +45 -0
- package/studio/server/api/project.ts +57 -0
- package/studio/server/api/schema.ts +12 -0
- package/studio/server/api/views.ts +57 -0
- package/studio/server/api/workspace.ts +60 -0
- package/studio/server/api.test.ts +147 -0
- package/studio/server/api.ts +20 -422
- package/studio/server/cache.test.ts +111 -0
- package/studio/server/cache.ts +163 -9
- package/studio/server/cli-consumers.test.ts +494 -0
- package/studio/server/cli.test.ts +88 -0
- package/studio/server/cli.ts +240 -0
- package/studio/server/client-package.ts +25 -1
- package/studio/server/domain.test.ts +30 -2
- package/studio/server/domain.ts +14 -2
- package/studio/server/environment/dotenv-preview.test.ts +39 -0
- package/studio/server/environment/dotenv-preview.ts +27 -0
- package/studio/server/environment/files.test.ts +53 -0
- package/studio/server/{state/env.ts → environment/files.ts} +7 -48
- package/studio/server/{state → handoff}/copy.ts +21 -6
- package/studio/server/handoff/service.test.ts +22 -0
- package/studio/server/{state/handoff.ts → handoff/service.ts} +21 -14
- package/studio/server/instances/active.ts +72 -0
- package/studio/server/instances/deploy-record.test.ts +31 -0
- package/studio/server/instances/deploy-record.ts +37 -0
- package/studio/server/instances/deploy.ts +56 -0
- package/studio/server/{state/instance.test.ts → instances/probe.test.ts} +25 -21
- package/studio/server/instances/probe.ts +67 -0
- package/studio/server/instances/status.test.ts +12 -0
- package/studio/server/instances/status.ts +49 -0
- package/studio/server/introspect/anatomy/client-tree.ts +57 -0
- package/studio/server/introspect/anatomy/env-fields.ts +66 -0
- package/studio/server/introspect/anatomy/schema-definition.ts +53 -0
- package/studio/server/introspect/anatomy/source.ts +181 -0
- package/studio/server/introspect/anatomy/views/legacy.ts +232 -0
- package/studio/server/introspect/anatomy/views/routes.ts +105 -0
- package/studio/server/introspect/anatomy/views.ts +58 -0
- package/studio/server/introspect/anatomy-extras.test.ts +91 -4
- package/studio/server/introspect/anatomy-extras.ts +7 -726
- package/studio/server/introspect/anatomy.ts +46 -19
- package/studio/server/introspect/bundle.ts +15 -6
- package/studio/server/introspect/canonical-schema.test.ts +79 -0
- package/studio/server/introspect/canonical-schema.ts +80 -18
- package/studio/server/introspect/config-preview.test.ts +32 -0
- package/studio/server/introspect/config-preview.ts +119 -0
- package/studio/server/introspect/diff.test.ts +7 -11
- package/studio/server/introspect/diff.ts +28 -37
- package/studio/server/introspect/extractor.ts +8 -6
- package/studio/server/introspect/hash.ts +5 -2
- package/studio/server/introspect/overlay-tsmorph.ts +7 -1305
- package/studio/server/introspect/revision.test.ts +54 -0
- package/studio/server/introspect/revision.ts +49 -0
- package/studio/server/introspect/runtime.test.ts +63 -5
- package/studio/server/introspect/runtime.ts +49 -3
- package/studio/server/introspect/schema-ir-json.ts +181 -0
- package/studio/server/introspect/source-overlay/annotations.ts +14 -0
- package/studio/server/introspect/source-overlay/handlers.ts +681 -0
- package/studio/server/introspect/source-overlay/kernel-calls.ts +49 -0
- package/studio/server/introspect/source-overlay/project.ts +248 -0
- package/studio/server/introspect/source-overlay/spans.ts +360 -0
- package/studio/server/json.ts +46 -0
- package/studio/server/lifecycle.ts +5 -1
- package/studio/server/sse.test.ts +26 -0
- package/studio/server/sse.ts +4 -1
- package/studio/server/state/baseline.test.ts +212 -1
- package/studio/server/state/baseline.ts +145 -29
- package/studio/server/state/comments.test.ts +66 -1
- package/studio/server/state/comments.ts +173 -10
- package/studio/server/state/context.ts +34 -2
- package/studio/server/state/documents.ts +42 -1
- package/studio/server/state/integrations.ts +27 -1
- package/studio/server/state/layout.test.ts +34 -0
- package/studio/server/state/layout.ts +44 -17
- package/studio/server/state/settings.ts +29 -22
- package/studio/server/state/store.test.ts +122 -0
- package/studio/server/state/store.ts +66 -17
- package/studio/server/state/visibility.ts +22 -7
- package/studio/server/views/model.test.ts +57 -0
- package/studio/server/views/model.ts +66 -0
- package/studio/server/views/runtime.ts +40 -0
- package/studio/server/views/selection-repository.test.ts +31 -0
- package/studio/server/views/selection-repository.ts +60 -0
- package/studio/server/views/session.test.ts +73 -0
- package/studio/server/views/session.ts +166 -0
- package/studio/server/{state/views.test.ts → views/target.test.ts} +8 -159
- package/studio/server/views/target.ts +248 -0
- package/studio/server/watch.ts +1 -1
- package/studio/server/workspace/catalog.test.ts +17 -0
- package/studio/server/workspace/catalog.ts +43 -0
- package/studio/server/{state → workspace}/create.test.ts +1 -1
- package/studio/server/{state → workspace}/create.ts +1 -1
- package/studio/server/{state → workspace}/git.ts +1 -1
- package/studio/server/workspace/updates.ts +83 -0
- package/studio/server/workspace-state.ts +1 -1
- package/studio/shared/contracts/README.md +16 -0
- package/studio/shared/contracts/agent.ts +270 -0
- package/studio/shared/contracts/runtime.ts +50 -0
- package/studio/shared/contracts/schema.ts +462 -0
- package/studio/shared/contracts/surface.test.ts +319 -0
- package/studio/shared/contracts/workspace.ts +274 -0
- package/studio/shared/layout.test.ts +116 -0
- package/studio/shared/schema/identity.test.ts +58 -0
- package/studio/shared/schema/identity.ts +82 -0
- package/studio/shared/types.ts +11 -1099
- package/studio/tsconfig.json +1 -1
- package/viewer/dist/main.js +35 -35
- package/studio/client/dist/assets/index-BMdnsIJA.css +0 -1
- package/studio/client/dist/assets/index-D-vRV8w7.js +0 -8
- package/studio/client/dist/assets/index-LGSWRrk8.js +0 -81
- package/studio/server/state/catalog.ts +0 -117
- package/studio/server/state/instance.ts +0 -280
- package/studio/server/state/updates.ts +0 -63
- 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
|
-
|
|
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
|
+
})
|
package/studio/server/sse.ts
CHANGED
|
@@ -41,7 +41,9 @@ export function sseResponse(domains: string[]): Response {
|
|
|
41
41
|
})
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
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 {
|
|
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 (
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 {
|
|
19
|
+
import type {
|
|
20
|
+
ChangeSet,
|
|
21
|
+
FileChange,
|
|
22
|
+
SchemaChange,
|
|
23
|
+
SchemaIR,
|
|
24
|
+
SchemaRevision,
|
|
25
|
+
} from '../../shared/types'
|
|
18
26
|
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
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
|
-
|
|
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`, {
|
|
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
|
-
/**
|
|
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
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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}
|
|
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: {
|
|
314
|
+
opts: {
|
|
315
|
+
currentRevision?: SchemaRevision | null
|
|
316
|
+
git: { hasGit: boolean; diffText?: string }
|
|
317
|
+
},
|
|
206
318
|
): ChangeSet {
|
|
207
|
-
const { hasGit } =
|
|
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
|
-
|
|
330
|
+
structuralStatus: 'none',
|
|
331
|
+
...(opts.currentRevision ? { currentRevision: opts.currentRevision } : {}),
|
|
219
332
|
}
|
|
220
333
|
}
|
|
221
334
|
|
|
222
|
-
const schemaChanges =
|
|
223
|
-
|
|
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
|
-
|
|
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
|
+
})
|