@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.
- package/LICENSE +202 -0
- package/README.md +2 -2
- package/THIRD-PARTY-NOTICES.md +27 -0
- package/dist/astrale.js +24396 -9632
- package/package.json +24 -22
- package/src/command.ts +2 -0
- package/src/commands/__tests__/admin-instance.test.ts +3 -2
- package/src/commands/__tests__/domain-list.test.ts +6 -2
- package/src/commands/__tests__/help-contract.test.ts +27 -14
- package/src/commands/__tests__/install-identity-override.test.ts +2 -2
- package/src/commands/__tests__/ls.test.ts +1 -1
- package/src/commands/__tests__/read-commands.test.ts +201 -0
- package/src/commands/__tests__/view.test.ts +100 -0
- package/src/commands/call.ts +27 -44
- package/src/commands/describe.ts +57 -58
- package/src/commands/domain/install.ts +9 -9
- package/src/commands/domain/list.ts +2 -2
- package/src/commands/domain/publish.ts +3 -3
- package/src/commands/get.ts +48 -23
- package/src/commands/identity/register.ts +27 -33
- package/src/commands/instance/active.ts +2 -2
- package/src/commands/instance/create.ts +1 -1
- package/src/commands/instance/delete.ts +3 -3
- package/src/commands/instance/list.ts +8 -3
- package/src/commands/instance/status.ts +4 -3
- package/src/commands/instance/use.ts +2 -2
- package/src/commands/logs.ts +8 -8
- package/src/commands/ls.ts +77 -55
- package/src/commands/mutate.ts +191 -0
- package/src/commands/query.ts +307 -20
- package/src/commands/session/analyze.ts +50 -0
- package/src/commands/session/list.ts +36 -0
- package/src/commands/token.ts +4 -7
- package/src/commands/view-serve.ts +26 -0
- package/src/commands/view.ts +614 -0
- package/src/connect-core.test.ts +42 -0
- package/src/connect-core.ts +53 -0
- package/src/kernel/__tests__/auth.test.ts +1 -0
- package/src/kernel/__tests__/expand.test.ts +123 -0
- package/src/kernel/client.ts +33 -37
- package/src/kernel/expand.ts +55 -61
- package/src/kernel/graph.ts +96 -0
- package/src/kernel/index.ts +18 -3
- package/src/kernel/options.ts +1 -1
- package/src/kernel/run.ts +0 -13
- package/src/lib/__tests__/instance-target.test.ts +34 -0
- package/src/lib/__tests__/table.test.ts +1 -1
- package/src/lib/__tests__/view-open-intent.test.ts +308 -0
- package/src/lib/__tests__/view-snapshot.test.ts +77 -0
- package/src/lib/admin-domain.ts +8 -4
- package/src/lib/admin-instance.ts +5 -1
- package/src/lib/config.ts +1 -0
- package/src/lib/domain-identity.ts +1 -1
- package/src/lib/instance-target.ts +5 -0
- package/src/lib/instance.ts +11 -0
- package/src/lib/log.ts +12 -2
- package/src/lib/login-flow.ts +41 -4
- package/src/lib/provision-instance.ts +2 -2
- package/src/lib/self.ts +1 -3
- package/src/lib/view/open-intent.ts +97 -0
- package/src/lib/view/resolve.ts +104 -0
- package/src/lib/view/server.ts +294 -0
- package/src/lib/view/session.ts +123 -0
- package/src/lib/view/snapshot.ts +101 -0
- package/src/program.ts +16 -3
- package/src/registry.ts +1 -1
- package/src/setup/render.ts +1 -3
- package/src/setup/steps/instance.ts +2 -2
- package/src/telemetry/__tests__/gate.test.ts +63 -0
- package/src/telemetry/__tests__/recorder.test.ts +110 -0
- package/src/telemetry/__tests__/redact.test.ts +79 -0
- package/src/telemetry/__tests__/session.test.ts +166 -0
- package/src/telemetry/__tests__/trigger.test.ts +49 -0
- package/src/telemetry/adapters/__tests__/claude-code.test.ts +89 -0
- package/src/telemetry/adapters/__tests__/codex.test.ts +138 -0
- package/src/telemetry/adapters/__tests__/index.test.ts +88 -0
- package/src/telemetry/adapters/claude-code.ts +90 -0
- package/src/telemetry/adapters/codex.ts +160 -0
- package/src/telemetry/adapters/index.ts +45 -0
- package/src/telemetry/adapters/types.ts +21 -0
- package/src/telemetry/analyze.ts +227 -0
- package/src/telemetry/gate.ts +79 -0
- package/src/telemetry/recorder.ts +57 -0
- package/src/telemetry/redact.ts +53 -0
- package/src/telemetry/session.ts +88 -0
- package/src/telemetry/settings.ts +26 -0
- package/src/telemetry/store.ts +91 -0
- package/src/telemetry/trigger.ts +119 -0
- package/src/telemetry/types.ts +64 -0
- package/studio/client/dist/assets/index-CyN5G8IA.js +109 -0
- package/studio/client/dist/assets/index-DKKMHBBC.css +1 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/server/agent/ask.ts +7 -2
- package/studio/server/agent/claude.ts +15 -3
- package/studio/server/agent/runner.ts +4 -1
- package/studio/server/agent/session-id.ts +13 -0
- package/studio/server/api.ts +50 -32
- package/studio/server/cache.ts +17 -6
- package/studio/server/client-package.test.ts +147 -0
- package/studio/server/client-package.ts +242 -0
- package/studio/server/index.ts +13 -0
- package/studio/server/introspect/anatomy-extras.test.ts +91 -0
- package/studio/server/introspect/anatomy-extras.ts +48 -49
- package/studio/server/introspect/anatomy.ts +7 -7
- package/studio/server/introspect/overlay-tsmorph.test.ts +104 -0
- package/studio/server/introspect/overlay-tsmorph.ts +230 -72
- package/studio/server/state/harness-gateway.ts +12 -3
- package/studio/server/state/harness-token.ts +0 -0
- package/studio/server/state/views.test.ts +90 -0
- package/studio/server/state/views.ts +396 -99
- package/studio/server/state/visibility.ts +10 -6
- package/studio/server/view-dev-server.test.ts +111 -0
- package/studio/server/view-dev-server.ts +372 -0
- package/studio/shared/types.ts +57 -10
- package/viewer/dist/index.html +93 -0
- package/viewer/dist/main.js +71 -0
- package/src/kernel/__tests__/remote-routing.test.ts +0 -70
- package/src/kernel/remote-routing.ts +0 -88
- package/studio/client/dist/assets/index-DOwzZAEK.css +0 -1
- package/studio/client/dist/assets/index-wtU0Zxhy.js +0 -183
- 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
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
'
|
|
144
|
-
'
|
|
145
|
-
'
|
|
146
|
-
'
|
|
147
|
-
'
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
if (
|
|
171
|
-
|
|
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(
|
|
176
|
-
return classifyObjectLiteral(
|
|
188
|
+
if (Node.isObjectLiteralExpression(node)) {
|
|
189
|
+
return classifyObjectLiteral(node)
|
|
177
190
|
}
|
|
178
|
-
//
|
|
179
|
-
if (Node.isIdentifier(
|
|
180
|
-
const
|
|
181
|
-
if (
|
|
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
|
|
292
|
-
if (
|
|
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
|
|
440
|
-
|
|
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
|
|
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
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
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
|
|
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 (
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
|
719
|
-
const
|
|
720
|
-
const ns =
|
|
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 (
|
|
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 ??
|
|
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 {
|
|
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(
|
|
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)
|
|
Binary file
|
|
@@ -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
|
+
})
|