@astrale-os/cli 0.4.0-alpha.13 → 0.6.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +2 -2
  3. package/THIRD-PARTY-NOTICES.md +27 -0
  4. package/dist/astrale.js +24396 -9632
  5. package/package.json +24 -22
  6. package/src/command.ts +2 -0
  7. package/src/commands/__tests__/admin-instance.test.ts +3 -2
  8. package/src/commands/__tests__/domain-list.test.ts +6 -2
  9. package/src/commands/__tests__/help-contract.test.ts +27 -14
  10. package/src/commands/__tests__/install-identity-override.test.ts +2 -2
  11. package/src/commands/__tests__/ls.test.ts +1 -1
  12. package/src/commands/__tests__/read-commands.test.ts +201 -0
  13. package/src/commands/__tests__/view.test.ts +100 -0
  14. package/src/commands/call.ts +27 -44
  15. package/src/commands/describe.ts +57 -58
  16. package/src/commands/domain/install.ts +9 -9
  17. package/src/commands/domain/list.ts +2 -2
  18. package/src/commands/domain/publish.ts +3 -3
  19. package/src/commands/get.ts +48 -23
  20. package/src/commands/identity/register.ts +27 -33
  21. package/src/commands/instance/active.ts +2 -2
  22. package/src/commands/instance/create.ts +1 -1
  23. package/src/commands/instance/delete.ts +3 -3
  24. package/src/commands/instance/list.ts +8 -3
  25. package/src/commands/instance/status.ts +4 -3
  26. package/src/commands/instance/use.ts +2 -2
  27. package/src/commands/logs.ts +8 -8
  28. package/src/commands/ls.ts +77 -55
  29. package/src/commands/mutate.ts +191 -0
  30. package/src/commands/query.ts +307 -20
  31. package/src/commands/session/analyze.ts +50 -0
  32. package/src/commands/session/list.ts +36 -0
  33. package/src/commands/token.ts +4 -7
  34. package/src/commands/view-serve.ts +26 -0
  35. package/src/commands/view.ts +614 -0
  36. package/src/connect-core.test.ts +42 -0
  37. package/src/connect-core.ts +53 -0
  38. package/src/kernel/__tests__/auth.test.ts +1 -0
  39. package/src/kernel/__tests__/expand.test.ts +123 -0
  40. package/src/kernel/client.ts +33 -37
  41. package/src/kernel/expand.ts +55 -61
  42. package/src/kernel/graph.ts +96 -0
  43. package/src/kernel/index.ts +18 -3
  44. package/src/kernel/options.ts +1 -1
  45. package/src/kernel/run.ts +0 -13
  46. package/src/lib/__tests__/instance-target.test.ts +34 -0
  47. package/src/lib/__tests__/table.test.ts +1 -1
  48. package/src/lib/__tests__/view-open-intent.test.ts +308 -0
  49. package/src/lib/__tests__/view-snapshot.test.ts +77 -0
  50. package/src/lib/admin-domain.ts +8 -4
  51. package/src/lib/admin-instance.ts +5 -1
  52. package/src/lib/config.ts +1 -0
  53. package/src/lib/domain-identity.ts +1 -1
  54. package/src/lib/instance-target.ts +5 -0
  55. package/src/lib/instance.ts +11 -0
  56. package/src/lib/log.ts +12 -2
  57. package/src/lib/login-flow.ts +41 -4
  58. package/src/lib/provision-instance.ts +2 -2
  59. package/src/lib/self.ts +1 -3
  60. package/src/lib/view/open-intent.ts +97 -0
  61. package/src/lib/view/resolve.ts +104 -0
  62. package/src/lib/view/server.ts +294 -0
  63. package/src/lib/view/session.ts +123 -0
  64. package/src/lib/view/snapshot.ts +101 -0
  65. package/src/program.ts +16 -3
  66. package/src/registry.ts +1 -1
  67. package/src/setup/render.ts +1 -3
  68. package/src/setup/steps/instance.ts +2 -2
  69. package/src/telemetry/__tests__/gate.test.ts +63 -0
  70. package/src/telemetry/__tests__/recorder.test.ts +110 -0
  71. package/src/telemetry/__tests__/redact.test.ts +79 -0
  72. package/src/telemetry/__tests__/session.test.ts +166 -0
  73. package/src/telemetry/__tests__/trigger.test.ts +49 -0
  74. package/src/telemetry/adapters/__tests__/claude-code.test.ts +89 -0
  75. package/src/telemetry/adapters/__tests__/codex.test.ts +138 -0
  76. package/src/telemetry/adapters/__tests__/index.test.ts +88 -0
  77. package/src/telemetry/adapters/claude-code.ts +90 -0
  78. package/src/telemetry/adapters/codex.ts +160 -0
  79. package/src/telemetry/adapters/index.ts +45 -0
  80. package/src/telemetry/adapters/types.ts +21 -0
  81. package/src/telemetry/analyze.ts +227 -0
  82. package/src/telemetry/gate.ts +79 -0
  83. package/src/telemetry/recorder.ts +57 -0
  84. package/src/telemetry/redact.ts +53 -0
  85. package/src/telemetry/session.ts +88 -0
  86. package/src/telemetry/settings.ts +26 -0
  87. package/src/telemetry/store.ts +91 -0
  88. package/src/telemetry/trigger.ts +119 -0
  89. package/src/telemetry/types.ts +64 -0
  90. package/studio/client/dist/assets/index-CyN5G8IA.js +109 -0
  91. package/studio/client/dist/assets/index-DKKMHBBC.css +1 -0
  92. package/studio/client/dist/index.html +2 -2
  93. package/studio/server/agent/ask.ts +7 -2
  94. package/studio/server/agent/claude.ts +15 -3
  95. package/studio/server/agent/runner.ts +4 -1
  96. package/studio/server/agent/session-id.ts +13 -0
  97. package/studio/server/api.ts +50 -32
  98. package/studio/server/cache.ts +17 -6
  99. package/studio/server/client-package.test.ts +147 -0
  100. package/studio/server/client-package.ts +242 -0
  101. package/studio/server/index.ts +13 -0
  102. package/studio/server/introspect/anatomy-extras.test.ts +91 -0
  103. package/studio/server/introspect/anatomy-extras.ts +48 -49
  104. package/studio/server/introspect/anatomy.ts +7 -7
  105. package/studio/server/introspect/overlay-tsmorph.test.ts +104 -0
  106. package/studio/server/introspect/overlay-tsmorph.ts +230 -72
  107. package/studio/server/state/harness-gateway.ts +12 -3
  108. package/studio/server/state/harness-token.ts +0 -0
  109. package/studio/server/state/views.test.ts +90 -0
  110. package/studio/server/state/views.ts +396 -99
  111. package/studio/server/state/visibility.ts +10 -6
  112. package/studio/server/view-dev-server.test.ts +111 -0
  113. package/studio/server/view-dev-server.ts +372 -0
  114. package/studio/shared/types.ts +57 -10
  115. package/viewer/dist/index.html +93 -0
  116. package/viewer/dist/main.js +71 -0
  117. package/src/kernel/__tests__/remote-routing.test.ts +0 -70
  118. package/src/kernel/remote-routing.ts +0 -88
  119. package/studio/client/dist/assets/index-DOwzZAEK.css +0 -1
  120. package/studio/client/dist/assets/index-wtU0Zxhy.js +0 -183
  121. package/studio/tsconfig.json +0 -23
@@ -0,0 +1,104 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { buildHandlerLinks } from './overlay-tsmorph'
7
+
8
+ test('resolves authorization hooks from imported remote method definitions', () => {
9
+ const root = mkdtempSync(join(tmpdir(), 'astrale-studio-handler-links-'))
10
+ const runtime = join(root, 'runtime')
11
+ mkdirSync(runtime)
12
+
13
+ writeFileSync(
14
+ join(runtime, 'index.ts'),
15
+ `
16
+ import { remoteClassMethods } from '@astrale-os/sdk'
17
+ import {
18
+ absentMethod,
19
+ guardedMethod,
20
+ noopMethod,
21
+ } from './methods.js'
22
+
23
+ const classMethods = remoteClassMethods()
24
+
25
+ export const methods = {
26
+ class: {
27
+ Issue: classMethods(schema, 'Issue', {
28
+ absent: absentMethod,
29
+ guarded: guardedMethod,
30
+ noop: noopMethod,
31
+ }),
32
+ },
33
+ }
34
+ `,
35
+ )
36
+ writeFileSync(
37
+ join(runtime, 'methods.ts'),
38
+ `
39
+ import { remoteMethod } from '@astrale-os/sdk'
40
+
41
+ const method = remoteMethod()
42
+ const run = () => 'ok'
43
+
44
+ export const guardedMethod = method(schema, 'Issue', 'guarded', {
45
+ authorize: ({ auth, kernel, self }) =>
46
+ kernel.auth.require({ who: auth.principal, on: self.path, perms: 1 }),
47
+ execute: () => run(),
48
+ })
49
+
50
+ export const noopMethod = method(schema, 'Issue', 'noop', {
51
+ authorize: async () => undefined,
52
+ execute: () => run(),
53
+ })
54
+
55
+ export const absentMethod = method(schema, 'Issue', 'absent', {
56
+ execute: () => run(),
57
+ })
58
+ `,
59
+ )
60
+
61
+ try {
62
+ const links = buildHandlerLinks({ ir: null, domainRoot: root })
63
+ expect(
64
+ links.map(({ method, authorize, auth, implemented, unlinked, handlerFile }) => ({
65
+ method,
66
+ authorize,
67
+ auth,
68
+ implemented,
69
+ unlinked: unlinked ?? false,
70
+ handlerFile,
71
+ })),
72
+ ).toEqual([
73
+ {
74
+ method: 'absent',
75
+ authorize: 'absent',
76
+ auth: 'required',
77
+ implemented: true,
78
+ unlinked: false,
79
+ handlerFile: 'runtime/methods.ts',
80
+ },
81
+ {
82
+ method: 'guarded',
83
+ authorize: 'custom',
84
+ auth: 'required',
85
+ implemented: true,
86
+ unlinked: false,
87
+ handlerFile: 'runtime/methods.ts',
88
+ },
89
+ {
90
+ method: 'noop',
91
+ authorize: 'noop',
92
+ auth: 'required',
93
+ implemented: true,
94
+ unlinked: false,
95
+ handlerFile: 'runtime/methods.ts',
96
+ },
97
+ ])
98
+ expect(links.find((link) => link.method === 'guarded')?.authorizeSnippet).toContain(
99
+ 'kernel.auth.require',
100
+ )
101
+ } finally {
102
+ rmSync(root, { recursive: true, force: true })
103
+ }
104
+ })
@@ -1,18 +1,8 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { dirname, isAbsolute, relative, resolve as resolvePath } from 'node:path'
3
3
  /**
4
- * overlay-tsmorph.ts — ts-morph extraction of the things the IR cannot carry:
5
- * - handlerLinks: schema method runtime handler file (follow the `execute`
6
- * import in runtime/index.ts; do NOT assume a folder/name convention).
7
- * - sourceSpans: file:line + JSDoc for each class/interface/edge/prop/method,
8
- * keyed by anchor ref (class.X / class.X.property.y / class.X.method.m / …).
9
- * - annotations: sharp-edge hints (ENUM_DROPPED_BY_UPDATE).
10
- *
11
- * Implementation notes:
12
- * - We open files with a throwaway ts-morph Project (no tsconfig, no type
13
- * checker required) so this stays cheap and tolerant of broken trees.
14
- * - Everything degrades gracefully: a missing file/dir yields [] / {}, an
15
- * unresolvable handler yields `{ unlinked: true }` rather than a wrong guess.
4
+ * ts-morph overlay for IR gaps: handler links, source spans, and annotations.
5
+ * It is tolerant by contract; unresolved files or handlers produce empty/unlinked output.
16
6
  */
17
7
  import { Node, Project, SyntaxKind, type CallExpression, type SourceFile } from 'ts-morph'
18
8
 
@@ -38,7 +28,7 @@ function newProject(): Project {
38
28
  function tryAddFile(project: Project, file: string): SourceFile | undefined {
39
29
  try {
40
30
  if (!existsSync(file)) return undefined
41
- return project.addSourceFileAtPath(file)
31
+ return project.getSourceFile(file) ?? project.addSourceFileAtPath(file)
42
32
  } catch {
43
33
  return undefined
44
34
  }
@@ -114,12 +104,7 @@ function stringArg(call: CallExpression, index: number): string | undefined {
114
104
 
115
105
  // ───────────────────────────── handler links ─────────────────────────────
116
106
 
117
- /**
118
- * The shape of a "wire one method" call we recognise, after normalising the two
119
- * fixture styles:
120
- * my-domain: method(schema, 'Owner', 'name', { authorize, execute })
121
- * evaluation: classMethods(schema, 'Owner', { name: todo('Owner.name'), … })
122
- */
107
+ /** Recognized method-wiring calls after normalizing single-method and grouped helpers. */
123
108
  interface WiredMethod {
124
109
  owner: string
125
110
  method: string
@@ -136,15 +121,29 @@ const GROUP_HELPERS = new Set([
136
121
  'remoteInterfaceMethods',
137
122
  ])
138
123
 
139
- /** Kernel-op tokens we surface as `kernelCalls`, longest-first to avoid overlap. */
140
- const KERNEL_TOKENS = [
141
- '::getLinks',
142
- '::getLink',
143
- '::update',
144
- '::link',
145
- '::create',
146
- 'createNode',
147
- 'grantPerm',
124
+ type KernelToken = { token: string; label?: string }
125
+
126
+ /** Kernel-op idioms surfaced as `kernelCalls`; entries are longest-first. */
127
+ const KERNEL_TOKENS: KernelToken[] = [
128
+ { token: 'graph.createEdge' },
129
+ { token: 'graph.removeEdge' },
130
+ { token: 'function.mutate' },
131
+ { token: 'graph.children' },
132
+ { token: 'function.get' },
133
+ { token: 'graph.create' },
134
+ { token: 'graph.update' },
135
+ { token: 'graph.remove' },
136
+ { token: 'graph.mutate' },
137
+ { token: 'auth.revoke' },
138
+ { token: 'graph.links' },
139
+ { token: 'auth.grant' },
140
+ { token: 'auth.check' },
141
+ { token: 'graph.tree' },
142
+ { token: 'graph.node' },
143
+ { token: 'revokePerm', label: 'revokePerm (legacy)' },
144
+ { token: 'checkPerm', label: 'checkPerm (legacy)' },
145
+ { token: 'graph.get' },
146
+ { token: 'grantPerm', label: 'grantPerm (legacy)' },
148
147
  ]
149
148
 
150
149
  /**
@@ -164,28 +163,129 @@ interface ConfigResolution {
164
163
  unlinkedReason?: 'no-config'
165
164
  }
166
165
 
167
- function resolveConfigObject(configNode: Node): ConfigResolution {
168
- // Case A: a call expression, e.g. `todo('Owner.name')`. Follow the helper to
169
- // its returned object literal (the local `todo` factory in evaluation).
170
- if (Node.isCallExpression(configNode)) {
171
- const obj = followFactoryToObject(configNode)
166
+ function resolveConfigObject(configNode: Node, seen = new Set<string>()): ConfigResolution {
167
+ const node = unwrapExpression(configNode)
168
+ const key = `${node.getSourceFile().getFilePath()}:${node.getStart()}`
169
+ if (seen.has(key)) return { implemented: false, unlinkedReason: 'no-config' }
170
+ seen.add(key)
171
+
172
+ if (Node.isCallExpression(node)) {
173
+ // Curried SDK helpers carry the handler config in their final argument:
174
+ // `remoteMethod()(schema, 'Issue', 'delete', { authorize, execute })`.
175
+ const lastArg = node.getArguments().at(-1)
176
+ if (lastArg) {
177
+ const candidate = unwrapExpression(lastArg)
178
+ if (Node.isObjectLiteralExpression(candidate) || Node.isIdentifier(candidate)) {
179
+ return resolveConfigObject(candidate, seen)
180
+ }
181
+ }
182
+
183
+ // Local factories such as `todo('Owner.name')` return the config object themselves.
184
+ const obj = followFactoryToObject(node)
172
185
  if (obj) return classifyObjectLiteral(obj)
173
186
  return { implemented: false, unlinkedReason: 'no-config' }
174
187
  }
175
- if (Node.isObjectLiteralExpression(configNode)) {
176
- return classifyObjectLiteral(configNode)
188
+ if (Node.isObjectLiteralExpression(node)) {
189
+ return classifyObjectLiteral(node)
177
190
  }
178
- // An identifier referencing a config defined elsewhere try to resolve it.
179
- if (Node.isIdentifier(configNode)) {
180
- const decl = firstValueDeclaration(configNode)
181
- if (decl && Node.isVariableDeclaration(decl)) {
182
- const init = decl.getInitializer()
183
- if (init) return resolveConfigObject(init)
184
- }
191
+ // Handler maps normally reference a local or imported `remoteMethod` value.
192
+ if (Node.isIdentifier(node)) {
193
+ const value = valueOfIdentifier(node)
194
+ if (value) return resolveConfigObject(value, seen)
185
195
  }
186
196
  return { implemented: false, unlinkedReason: 'no-config' }
187
197
  }
188
198
 
199
+ /** Remove syntax-only wrappers around a runtime value. */
200
+ function unwrapExpression(node: Node): Node {
201
+ let current = node
202
+ while (
203
+ Node.isParenthesizedExpression(current) ||
204
+ Node.isAsExpression(current) ||
205
+ Node.isSatisfiesExpression(current) ||
206
+ Node.isTypeAssertion(current)
207
+ ) {
208
+ current = current.getExpression()
209
+ }
210
+ return current
211
+ }
212
+
213
+ /** Resolve a local or relatively imported identifier to its runtime value. */
214
+ function valueOfIdentifier(id: Node): Node | undefined {
215
+ if (!Node.isIdentifier(id)) return undefined
216
+ const decl = firstValueDeclaration(id)
217
+ if (decl && Node.isVariableDeclaration(decl)) return decl.getInitializer()
218
+ return resolveImportedValue(id)
219
+ }
220
+
221
+ /** Follow a named relative import to the exported value that defines it. */
222
+ function resolveImportedValue(id: Node): Node | undefined {
223
+ if (!Node.isIdentifier(id)) return undefined
224
+ const localName = id.getText()
225
+ const sourceFile = id.getSourceFile()
226
+
227
+ for (const imp of sourceFile.getImportDeclarations()) {
228
+ for (const named of imp.getNamedImports()) {
229
+ const local = named.getAliasNode()?.getText() ?? named.getName()
230
+ if (local !== localName) continue
231
+ const target = resolveModuleFile(sourceFile, imp.getModuleSpecifierValue())
232
+ if (!target) return undefined
233
+ return findExportedValue(id.getProject(), target, named.getName(), new Set<string>())
234
+ }
235
+ }
236
+ return undefined
237
+ }
238
+
239
+ /** Find an exported variable/function value, following local and barrel exports. */
240
+ function findExportedValue(
241
+ project: Project,
242
+ file: string,
243
+ exportName: string,
244
+ seen: Set<string>,
245
+ ): Node | undefined {
246
+ const key = `${file}:${exportName}`
247
+ if (seen.has(key)) return undefined
248
+ seen.add(key)
249
+ const sf = tryAddFile(project, file)
250
+ if (!sf) return undefined
251
+
252
+ const localValue = (name: string, exportedOnly: boolean): Node | undefined => {
253
+ const variable = sf
254
+ .getVariableDeclarations()
255
+ .find(
256
+ (candidate) => candidate.getName() === name && (!exportedOnly || candidate.isExported()),
257
+ )
258
+ if (variable) return variable.getInitializer()
259
+ return sf
260
+ .getFunctions()
261
+ .find(
262
+ (candidate) => candidate.getName() === name && (!exportedOnly || candidate.isExported()),
263
+ )
264
+ }
265
+
266
+ const direct = localValue(exportName, true)
267
+ if (direct) return direct
268
+
269
+ for (const ex of sf.getExportDeclarations()) {
270
+ const modSpec = ex.getModuleSpecifierValue()
271
+ const target = modSpec ? resolveModuleFile(sf, modSpec) : undefined
272
+ const named = ex.getNamedExports()
273
+ if (named.length > 0) {
274
+ for (const specifier of named) {
275
+ const exposed = specifier.getAliasNode()?.getText() ?? specifier.getName()
276
+ if (exposed !== exportName) continue
277
+ const original = specifier.getName()
278
+ if (target) return findExportedValue(project, target, original, seen)
279
+ return localValue(original, false)
280
+ }
281
+ } else if (target) {
282
+ const found = findExportedValue(project, target, exportName, seen)
283
+ if (found) return found
284
+ }
285
+ }
286
+ return undefined
287
+ }
288
+
189
289
  /** Given `todo('x')`, find the factory's `return { … }` object literal. */
190
290
  function followFactoryToObject(call: CallExpression): Node | undefined {
191
291
  const expr = call.getExpression()
@@ -288,11 +388,8 @@ function functionBodyOf(node: Node): Node | null | undefined {
288
388
  if (Node.isArrowFunction(node) || Node.isFunctionExpression(node)) fn = node
289
389
  else if (Node.isMethodDeclaration(node)) fn = node
290
390
  else if (Node.isIdentifier(node)) {
291
- const decl = firstValueDeclaration(node)
292
- if (decl && Node.isVariableDeclaration(decl)) {
293
- const init = decl.getInitializer()
294
- if (init) return functionBodyOf(init)
295
- }
391
+ const value = valueOfIdentifier(node)
392
+ if (value) return functionBodyOf(value)
296
393
  return undefined
297
394
  }
298
395
  if (!fn) return undefined
@@ -435,9 +532,11 @@ function resolveModuleFile(from: SourceFile, spec: string): string | undefined {
435
532
  if (!spec.startsWith('.')) return undefined
436
533
  const baseDir = dirname(from.getFilePath())
437
534
  const base = resolvePath(baseDir, spec)
535
+ const sourceBase = base.replace(/\.(?:[cm]?js|jsx)$/, '')
438
536
  const candidates = [
439
- base.endsWith('.ts') ? base : `${base}.ts`,
440
- base.endsWith('.tsx') ? base : `${base}.tsx`,
537
+ base,
538
+ sourceBase.endsWith('.ts') ? sourceBase : `${sourceBase}.ts`,
539
+ sourceBase.endsWith('.tsx') ? sourceBase : `${sourceBase}.tsx`,
441
540
  `${base}/index.ts`,
442
541
  `${base}/index.tsx`,
443
542
  ]
@@ -506,11 +605,11 @@ function scanKernelCalls(file: string): string[] {
506
605
  }
507
606
  const found: string[] = []
508
607
  let work = text
509
- for (const token of KERNEL_TOKENS) {
510
- if (work.includes(token)) {
511
- found.push(token)
608
+ for (const entry of KERNEL_TOKENS) {
609
+ if (work.includes(entry.token)) {
610
+ found.push(entry.label ?? entry.token)
512
611
  // Blank out matches so `::getLinks` doesn't also count as `::getLink`.
513
- work = work.split(token).join(' '.repeat(token.length))
612
+ work = work.split(entry.token).join(' '.repeat(entry.token.length))
514
613
  }
515
614
  }
516
615
  return found
@@ -663,23 +762,78 @@ const DECL_HELPERS: Record<string, 'node' | 'interface' | 'edge'> = {
663
762
  }
664
763
 
665
764
  /**
666
- * Decide the anchor namespace ('class' | 'interface' | 'edge') for a declared
667
- * name, preferring the IR's authority (an edgeClass is a class whose IR type is
668
- * 'edge'); fall back to the declaration helper used.
765
+ * A schema member's true NAME + section, resolved from the `defineSchema` map.
766
+ * The declaration helpers (nodeClass/nodeInterface/edgeClass) carry no name a
767
+ * member is named by the KEY it is registered under, not by its variable — and a
768
+ * domain may register a class and an interface under the SAME name (the
769
+ * intentional same-name pattern: the `iUser` interface alongside the `User`
770
+ * class). So the map is the sole authority for both the anchor name and the
771
+ * interface-vs-class distinction; the variable identifier alone tells us neither.
772
+ */
773
+ interface MemberName {
774
+ schemaName: string
775
+ section: 'interface' | 'class' // the `classes` map also holds edge classes
776
+ }
777
+
778
+ /** Map each registered member VARIABLE (as referenced in `defineSchema`) to its
779
+ * schema name + section, from the domain's own schema files — never node_modules
780
+ * (a dependency's `defineSchema` is not this domain's). */
781
+ function buildMemberNameMap(files: SourceFile[]): Map<string, MemberName> {
782
+ const map = new Map<string, MemberName>()
783
+ for (const sf of files) {
784
+ if (sf.getFilePath().includes('/node_modules/')) continue
785
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
786
+ if (calleeName(call) !== 'defineSchema') continue
787
+ const cfg = call.getArguments()[1]
788
+ if (!cfg || !Node.isObjectLiteralExpression(cfg)) continue
789
+ collectSchemaSection(map, cfg, 'interfaces', 'interface')
790
+ collectSchemaSection(map, cfg, 'classes', 'class')
791
+ }
792
+ }
793
+ return map
794
+ }
795
+
796
+ /** Record `variable → { schemaName, section }` for one `defineSchema` section
797
+ * (`interfaces` / `classes`), handling both `Key: alias` and shorthand `Key`. */
798
+ function collectSchemaSection(
799
+ map: Map<string, MemberName>,
800
+ cfg: Node,
801
+ prop: 'interfaces' | 'classes',
802
+ section: 'interface' | 'class',
803
+ ): void {
804
+ const obj = getObjectProp(cfg, prop)
805
+ if (!obj) return
806
+ for (const p of obj.getProperties()) {
807
+ if (Node.isShorthandPropertyAssignment(p)) {
808
+ map.set(p.getName(), { schemaName: p.getName(), section })
809
+ } else if (Node.isPropertyAssignment(p)) {
810
+ const init = p.getInitializer()
811
+ if (init && Node.isIdentifier(init)) {
812
+ map.set(init.getText(), { schemaName: p.getName(), section })
813
+ }
814
+ }
815
+ }
816
+ }
817
+
818
+ /**
819
+ * The anchor namespace ('class' | 'interface' | 'edge') for a declared member.
820
+ * The `defineSchema` SECTION is authoritative for interface-vs-class — a
821
+ * `nodeClass` whose name collides with a same-named interface must still anchor
822
+ * as a class. Within the class section, an `edgeClass` (or an IR edge type)
823
+ * anchors as 'edge'. Falls back to the declaration helper when the member isn't
824
+ * in a parseable `defineSchema` map (e.g. an imported kernel member).
669
825
  */
670
- function anchorKindFor(
826
+ function resolveMemberKind(
671
827
  ir: SchemaIR | null,
672
828
  name: string,
829
+ section: 'interface' | 'class' | undefined,
673
830
  helperKind: 'node' | 'interface' | 'edge',
674
831
  ): 'class' | 'interface' | 'edge' {
675
- if (ir) {
676
- if (ir.interfaces?.[name]) return 'interface'
677
- const cls = ir.classes?.[name]
678
- if (cls) return cls.type === 'edge' ? 'edge' : 'class'
679
- }
832
+ if (section === 'interface') return 'interface'
833
+ const isEdge = helperKind === 'edge' || ir?.classes?.[name]?.type === 'edge'
834
+ if (section === 'class') return isEdge ? 'edge' : 'class'
680
835
  if (helperKind === 'interface') return 'interface'
681
- if (helperKind === 'edge') return 'edge'
682
- return 'class'
836
+ return isEdge ? 'edge' : 'class'
683
837
  }
684
838
 
685
839
  export function buildSourceSpans(args: {
@@ -703,10 +857,14 @@ export function buildSourceSpans(args: {
703
857
 
704
858
  const spans: Record<string, SourceSpan> = {}
705
859
 
706
- for (const filePath of files) {
707
- const sf = project.getSourceFile(filePath)
708
- if (!sf) continue
709
- const fileRel = relToRoot(domainRoot, filePath)
860
+ const sourceFiles = files.map((f) => project.getSourceFile(f)).filter((f): f is SourceFile => !!f)
861
+ // The domain's `defineSchema` map is authoritative for each member's name +
862
+ // interface-vs-class kind, so an aliased interface (`iUser`→`User`) and a class
863
+ // sharing its name (`User`) each anchor correctly instead of colliding.
864
+ const memberNames = buildMemberNameMap(sourceFiles)
865
+
866
+ for (const sf of sourceFiles) {
867
+ const fileRel = relToRoot(domainRoot, sf.getFilePath())
710
868
 
711
869
  for (const v of sf.getVariableDeclarations()) {
712
870
  if (!v.isExported()) continue
@@ -715,9 +873,9 @@ export function buildSourceSpans(args: {
715
873
  const helper = calleeName(init)
716
874
  if (!helper || !(helper in DECL_HELPERS)) continue
717
875
  const helperKind = DECL_HELPERS[helper]
718
- const name = v.getName()
719
- const kind = anchorKindFor(ir, name, helperKind)
720
- const ns = kind // 'class' | 'interface' | 'edge'
876
+ const member = memberNames.get(v.getName())
877
+ const name = member?.schemaName ?? v.getName()
878
+ const ns = resolveMemberKind(ir, name, member?.section, helperKind)
721
879
 
722
880
  const stmt = v.getVariableStatement() ?? v
723
881
  spans[`${ns}.${name}`] = makeSpan(domainRoot, fileRel, stmt, v)
@@ -729,7 +887,7 @@ export function buildSourceSpans(args: {
729
887
  }
730
888
 
731
889
  // Edges: endpoints are the first two args; props live in the third.
732
- if (kind === 'edge') {
890
+ if (ns === 'edge') {
733
891
  collectEdge(spans, name, init, domainRoot, fileRel)
734
892
  }
735
893
  }
@@ -45,7 +45,8 @@ function normalize(input: any): HarnessGatewayConfig {
45
45
  const model = typeof input?.model === 'string' ? input.model.trim() : ''
46
46
  // back-compat: a pre-union config stored only `apiKey` → treat as a static token
47
47
  const authInput =
48
- input?.auth ?? (typeof input?.apiKey === 'string' ? { mode: 'token', token: input.apiKey } : undefined)
48
+ input?.auth ??
49
+ (typeof input?.apiKey === 'string' ? { mode: 'token', token: input.apiKey } : undefined)
49
50
  return {
50
51
  enabled: input?.enabled === true,
51
52
  baseUrl: typeof input?.baseUrl === 'string' ? input.baseUrl.trim() : '',
@@ -86,7 +87,12 @@ export function getHarnessGatewayState(root: string): HarnessGatewayState {
86
87
  const local = readLocal(root)
87
88
  const global = readGlobal()
88
89
  if (local) {
89
- return { local, global, effective: local.enabled ? local : null, source: local.enabled ? 'domain' : 'none' }
90
+ return {
91
+ local,
92
+ global,
93
+ effective: local.enabled ? local : null,
94
+ source: local.enabled ? 'domain' : 'none',
95
+ }
90
96
  }
91
97
  return {
92
98
  local: null,
@@ -104,7 +110,10 @@ export interface SetHarnessGatewayInput {
104
110
  /** Persist the config to the chosen scope. Writing GLOBAL also clears any local
105
111
  * override so this domain (and every un-overridden one) inherits it — that is the
106
112
  * "apply to all domains" intent. */
107
- export function setHarnessGateway(root: string, input: SetHarnessGatewayInput): HarnessGatewayState {
113
+ export function setHarnessGateway(
114
+ root: string,
115
+ input: SetHarnessGatewayInput,
116
+ ): HarnessGatewayState {
108
117
  const cfg = normalize(input.config)
109
118
  if (input.scope === 'global') {
110
119
  writeGlobal(cfg)
@@ -0,0 +1,90 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import type { RememberedViewTarget, ViewTargetCandidate } from '../../shared/types'
4
+
5
+ import {
6
+ conciseCliFailure,
7
+ localViewUrl,
8
+ reconcileRememberedTarget,
9
+ targetFromRow,
10
+ targetQuery,
11
+ } from './views'
12
+
13
+ describe('managed local view URLs', () => {
14
+ test('mounts each view beneath the Studio-assigned server origin', () => {
15
+ expect(localViewUrl('http://127.0.0.1:5173', '/ui/issues')).toBe(
16
+ 'http://127.0.0.1:5173/ui/issues',
17
+ )
18
+ expect(localViewUrl('http://127.0.0.1:61001/ignored', 'ui/issue')).toBe(
19
+ 'http://127.0.0.1:61001/ui/issue',
20
+ )
21
+ })
22
+ })
23
+
24
+ test('reduces CLI stack output to the actionable kernel failure', () => {
25
+ expect(
26
+ conciseCliFailure(
27
+ `275 | if (code === KERNEL_ERROR_CODES.INVALID_REQUEST)\n276 |\nPermissionDeniedError: Permission denied: READ on /:issues.astrale.ai:view.issue\n details: {\n method: \"View:resolve\"\n}\n at mapServerError (errors.ts:280:61)\nBun v1.3.14`,
28
+ ),
29
+ ).toBe('PermissionDeniedError: Permission denied: READ on /:issues.astrale.ai:view.issue')
30
+ })
31
+
32
+ describe('target candidates', () => {
33
+ const remembered: RememberedViewTarget = {
34
+ id: 'gone-id',
35
+ className: 'Issue',
36
+ classOrigin: 'issues.astrale.ai',
37
+ label: 'Deleted issue',
38
+ }
39
+ const candidate: ViewTargetCandidate = {
40
+ id: 'live-id',
41
+ ref: '@live-id',
42
+ className: 'Issue',
43
+ classOrigin: 'issues.astrale.ai',
44
+ label: 'Live issue',
45
+ }
46
+
47
+ test('turns a missing remembered node into an explicit stale selection', () => {
48
+ expect(reconcileRememberedTarget(remembered, [candidate])).toEqual({
49
+ selected: null,
50
+ stale: remembered,
51
+ })
52
+ expect(reconcileRememberedTarget({ ...remembered, id: 'live-id' }, [candidate])).toEqual({
53
+ selected: candidate,
54
+ stale: null,
55
+ })
56
+ })
57
+
58
+ test('builds friendly candidates from generic qualified node properties', () => {
59
+ expect(
60
+ targetFromRow(
61
+ {
62
+ id: 'iss-1',
63
+ props: {
64
+ 'kernel.astrale.ai:interface.Named.property.name': 'Broken authorization badge',
65
+ 'kernel.astrale.ai:interface.Descriptable.property.description': 'Studio regression',
66
+ 'kernel.astrale.ai:interface.Statused.property.status': 'open',
67
+ },
68
+ },
69
+ 'Issue',
70
+ 'issues.astrale.ai',
71
+ ),
72
+ ).toEqual({
73
+ id: 'iss-1',
74
+ ref: '@iss-1',
75
+ className: 'Issue',
76
+ classOrigin: 'issues.astrale.ai',
77
+ label: 'Broken authorization badge',
78
+ description: 'Studio regression',
79
+ status: 'open',
80
+ })
81
+ })
82
+
83
+ test('queries exact class instances through instance_of', () => {
84
+ const query = targetQuery('Issue', 'issues.astrale.ai')
85
+ expect(query).toContain('(n:Node)-[:instance_of]->(c:Class)-[:of_domain]->(d:Domain)')
86
+ expect(query).toContain('"Issue"')
87
+ expect(query).toContain('"issues.astrale.ai"')
88
+ expect(query).toContain('LIMIT 201')
89
+ })
90
+ })