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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/README.md +7 -11
  2. package/dist/astrale.js +3769 -3549
  3. package/dist/public/connect-core.js +396 -392
  4. package/dist/public/keys/index.js +371 -378
  5. package/dist/public/paths/index.js +371 -378
  6. package/dist/types/lib/idp.d.ts +6 -0
  7. package/dist/types/lib/meta.d.ts +2 -2
  8. package/package.json +6 -4
  9. package/src/admin/binding.ts +168 -0
  10. package/src/admin/catalog/__tests__/client.test.ts +2 -2
  11. package/src/admin/catalog/client.ts +4 -9
  12. package/src/admin/instance/__tests__/client.test.ts +5 -3
  13. package/src/admin/instance/client.ts +9 -27
  14. package/src/commands/__tests__/auth-login.test.ts +54 -1
  15. package/src/commands/__tests__/call.test.ts +5 -1
  16. package/src/commands/__tests__/domain-install-operation.test.ts +23 -7
  17. package/src/commands/__tests__/domain-install-owned.test.ts +5 -1
  18. package/src/commands/__tests__/domain-list.test.ts +83 -4
  19. package/src/commands/__tests__/install-identity-override.test.ts +30 -19
  20. package/src/commands/__tests__/studio-descriptor.test.ts +22 -0
  21. package/src/commands/__tests__/update.test.ts +73 -0
  22. package/src/commands/domain/install.ts +11 -15
  23. package/src/commands/domain/list.ts +17 -12
  24. package/src/commands/studio.ts +20 -0
  25. package/src/commands/update.ts +39 -13
  26. package/src/connection/__tests__/errors.test.ts +16 -12
  27. package/src/connection/__tests__/exchange.test.ts +6 -5
  28. package/src/connection/exchange.ts +5 -4
  29. package/src/lib/__tests__/domain-publication.test.ts +144 -0
  30. package/src/lib/__tests__/idp-session.test.ts +22 -0
  31. package/src/lib/__tests__/idp.test.ts +2 -0
  32. package/src/lib/__tests__/instance.test.ts +6 -0
  33. package/src/lib/__tests__/studio-server-deps.test.ts +53 -16
  34. package/src/lib/__tests__/update.test.ts +12 -0
  35. package/src/lib/domain-publication.ts +102 -0
  36. package/src/lib/idp.ts +5 -1
  37. package/src/lib/instance.ts +6 -1
  38. package/src/lib/login-flow.ts +13 -3
  39. package/src/lib/meta.ts +2 -2
  40. package/src/lib/update.ts +4 -4
  41. package/src/program/__tests__/program.test.ts +1 -1
  42. package/studio/client/dist/assets/{elk-api-D2xgMJvi.js → elk-api-lwhFo7vB.js} +1 -1
  43. package/studio/client/dist/assets/index-B-c-smo9.js +8 -0
  44. package/studio/client/dist/assets/index-BckHuAWk.js +81 -0
  45. package/studio/client/dist/assets/index-C4aNQ6dz.css +1 -0
  46. package/studio/client/dist/index.html +2 -2
  47. package/studio/package.json +3 -1
  48. package/studio/server/agent/bridge/stdio.ts +8 -1
  49. package/studio/server/agent/conversation.test.ts +10 -5
  50. package/studio/server/agent/conversation.ts +56 -8
  51. package/studio/server/agent/harness/codex/loadout.ts +18 -21
  52. package/studio/server/agent/harness/gateway/config.ts +20 -18
  53. package/studio/server/agent/harness/selection.ts +9 -1
  54. package/studio/server/agent/prompts/system.ts +3 -2
  55. package/studio/server/agent/prompts/turn.ts +13 -4
  56. package/studio/server/agent/run/completion.ts +2 -2
  57. package/studio/server/agent/run/preparation.ts +6 -5
  58. package/studio/server/agent/run/transcript.test.ts +35 -1
  59. package/studio/server/agent/run/transcript.ts +169 -3
  60. package/studio/server/agent/run/usage.test.ts +24 -0
  61. package/studio/server/agent/run/usage.ts +26 -1
  62. package/studio/server/api/canvas.ts +44 -0
  63. package/studio/server/api/comments.ts +85 -0
  64. package/studio/server/api/context.ts +61 -0
  65. package/studio/server/api/deployment.ts +39 -0
  66. package/studio/server/api/documents.ts +55 -0
  67. package/studio/server/api/domain.ts +59 -0
  68. package/studio/server/api/http.ts +45 -0
  69. package/studio/server/api/project.ts +57 -0
  70. package/studio/server/api/schema.ts +12 -0
  71. package/studio/server/api/views.ts +57 -0
  72. package/studio/server/api/workspace.ts +60 -0
  73. package/studio/server/api.test.ts +147 -0
  74. package/studio/server/api.ts +20 -422
  75. package/studio/server/cache.test.ts +111 -0
  76. package/studio/server/cache.ts +163 -9
  77. package/studio/server/cli-consumers.test.ts +494 -0
  78. package/studio/server/cli.test.ts +88 -0
  79. package/studio/server/cli.ts +240 -0
  80. package/studio/server/client-package.ts +25 -1
  81. package/studio/server/domain.test.ts +30 -2
  82. package/studio/server/domain.ts +14 -2
  83. package/studio/server/environment/dotenv-preview.test.ts +39 -0
  84. package/studio/server/environment/dotenv-preview.ts +27 -0
  85. package/studio/server/environment/files.test.ts +53 -0
  86. package/studio/server/{state/env.ts → environment/files.ts} +7 -48
  87. package/studio/server/{state → handoff}/copy.ts +21 -6
  88. package/studio/server/handoff/service.test.ts +22 -0
  89. package/studio/server/{state/handoff.ts → handoff/service.ts} +21 -14
  90. package/studio/server/instances/active.ts +72 -0
  91. package/studio/server/instances/deploy-record.test.ts +31 -0
  92. package/studio/server/instances/deploy-record.ts +37 -0
  93. package/studio/server/instances/deploy.ts +56 -0
  94. package/studio/server/{state/instance.test.ts → instances/probe.test.ts} +25 -21
  95. package/studio/server/instances/probe.ts +67 -0
  96. package/studio/server/instances/status.test.ts +12 -0
  97. package/studio/server/instances/status.ts +49 -0
  98. package/studio/server/introspect/anatomy/client-tree.ts +57 -0
  99. package/studio/server/introspect/anatomy/env-fields.ts +66 -0
  100. package/studio/server/introspect/anatomy/schema-definition.ts +53 -0
  101. package/studio/server/introspect/anatomy/source.ts +181 -0
  102. package/studio/server/introspect/anatomy/views/legacy.ts +232 -0
  103. package/studio/server/introspect/anatomy/views/routes.ts +105 -0
  104. package/studio/server/introspect/anatomy/views.ts +58 -0
  105. package/studio/server/introspect/anatomy-extras.test.ts +91 -4
  106. package/studio/server/introspect/anatomy-extras.ts +7 -726
  107. package/studio/server/introspect/anatomy.ts +46 -19
  108. package/studio/server/introspect/bundle.ts +15 -6
  109. package/studio/server/introspect/canonical-schema.test.ts +79 -0
  110. package/studio/server/introspect/canonical-schema.ts +80 -18
  111. package/studio/server/introspect/config-preview.test.ts +32 -0
  112. package/studio/server/introspect/config-preview.ts +119 -0
  113. package/studio/server/introspect/diff.test.ts +7 -11
  114. package/studio/server/introspect/diff.ts +28 -37
  115. package/studio/server/introspect/extractor.ts +8 -6
  116. package/studio/server/introspect/hash.ts +5 -2
  117. package/studio/server/introspect/overlay-tsmorph.ts +7 -1305
  118. package/studio/server/introspect/revision.test.ts +54 -0
  119. package/studio/server/introspect/revision.ts +49 -0
  120. package/studio/server/introspect/runtime.test.ts +63 -5
  121. package/studio/server/introspect/runtime.ts +49 -3
  122. package/studio/server/introspect/schema-ir-json.ts +181 -0
  123. package/studio/server/introspect/source-overlay/annotations.ts +14 -0
  124. package/studio/server/introspect/source-overlay/handlers.ts +681 -0
  125. package/studio/server/introspect/source-overlay/kernel-calls.ts +49 -0
  126. package/studio/server/introspect/source-overlay/project.ts +248 -0
  127. package/studio/server/introspect/source-overlay/spans.ts +360 -0
  128. package/studio/server/json.ts +46 -0
  129. package/studio/server/lifecycle.ts +5 -1
  130. package/studio/server/sse.test.ts +26 -0
  131. package/studio/server/sse.ts +4 -1
  132. package/studio/server/state/baseline.test.ts +212 -1
  133. package/studio/server/state/baseline.ts +145 -29
  134. package/studio/server/state/comments.test.ts +66 -1
  135. package/studio/server/state/comments.ts +173 -10
  136. package/studio/server/state/context.ts +34 -2
  137. package/studio/server/state/documents.ts +42 -1
  138. package/studio/server/state/integrations.ts +27 -1
  139. package/studio/server/state/layout.test.ts +34 -0
  140. package/studio/server/state/layout.ts +44 -17
  141. package/studio/server/state/settings.ts +29 -22
  142. package/studio/server/state/store.test.ts +122 -0
  143. package/studio/server/state/store.ts +66 -17
  144. package/studio/server/state/visibility.ts +22 -7
  145. package/studio/server/views/model.test.ts +57 -0
  146. package/studio/server/views/model.ts +66 -0
  147. package/studio/server/views/runtime.ts +40 -0
  148. package/studio/server/views/selection-repository.test.ts +31 -0
  149. package/studio/server/views/selection-repository.ts +60 -0
  150. package/studio/server/views/session.test.ts +73 -0
  151. package/studio/server/views/session.ts +166 -0
  152. package/studio/server/{state/views.test.ts → views/target.test.ts} +8 -159
  153. package/studio/server/views/target.ts +248 -0
  154. package/studio/server/watch.ts +1 -1
  155. package/studio/server/workspace/catalog.test.ts +17 -0
  156. package/studio/server/workspace/catalog.ts +43 -0
  157. package/studio/server/{state → workspace}/create.test.ts +1 -1
  158. package/studio/server/{state → workspace}/create.ts +1 -1
  159. package/studio/server/{state → workspace}/git.ts +1 -1
  160. package/studio/server/workspace/updates.ts +83 -0
  161. package/studio/server/workspace-state.ts +1 -1
  162. package/studio/shared/contracts/README.md +16 -0
  163. package/studio/shared/contracts/agent.ts +270 -0
  164. package/studio/shared/contracts/runtime.ts +50 -0
  165. package/studio/shared/contracts/schema.ts +462 -0
  166. package/studio/shared/contracts/surface.test.ts +319 -0
  167. package/studio/shared/contracts/workspace.ts +274 -0
  168. package/studio/shared/layout.test.ts +116 -0
  169. package/studio/shared/schema/identity.test.ts +58 -0
  170. package/studio/shared/schema/identity.ts +82 -0
  171. package/studio/shared/types.ts +11 -1099
  172. package/studio/tsconfig.json +1 -1
  173. package/viewer/dist/main.js +35 -35
  174. package/studio/client/dist/assets/index-BMdnsIJA.css +0 -1
  175. package/studio/client/dist/assets/index-D-vRV8w7.js +0 -8
  176. package/studio/client/dist/assets/index-LGSWRrk8.js +0 -81
  177. package/studio/server/state/catalog.ts +0 -117
  178. package/studio/server/state/instance.ts +0 -280
  179. package/studio/server/state/updates.ts +0 -63
  180. package/studio/server/state/views.ts +0 -535
@@ -1,1307 +1,9 @@
1
- import { existsSync, readFileSync, realpathSync } from 'node:fs'
2
- import { dirname, isAbsolute, relative, resolve as resolvePath } from 'node:path'
3
1
  /**
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.
2
+ * Compatibility facade for the ts-morph source overlay.
3
+ *
4
+ * Keep this path stable for the overlay composer and focused tests. The
5
+ * implementation lives under source-overlay, split by responsibility.
6
6
  */
7
- import { Node, Project, SyntaxKind, type CallExpression, type SourceFile } from 'ts-morph'
8
-
9
- import type { HandlerLink, SchemaAnnotation, SchemaIR, SourceSpan } from '../../shared/types'
10
-
11
- // ───────────────────────────── shared helpers ─────────────────────────────
12
-
13
- /** A fresh, in-memory-ish ts-morph project: no tsconfig, tolerant of errors. */
14
- function newProject(): Project {
15
- return new Project({
16
- useInMemoryFileSystem: false,
17
- skipFileDependencyResolution: true,
18
- skipLoadingLibFiles: true,
19
- compilerOptions: {
20
- allowJs: true,
21
- // `allowImportingTsExtensions` keeps `.ts`-suffixed imports from blowing up.
22
- allowImportingTsExtensions: true,
23
- },
24
- })
25
- }
26
-
27
- /** Add a file to the project if it exists; returns undefined otherwise. */
28
- function tryAddFile(project: Project, file: string): SourceFile | undefined {
29
- try {
30
- if (!existsSync(file)) return undefined
31
- return project.getSourceFile(file) ?? project.addSourceFileAtPath(file)
32
- } catch {
33
- return undefined
34
- }
35
- }
36
-
37
- /** Path relative to `root`, POSIX-style ('schema/monitor.ts'), never absolute. */
38
- function relToRoot(root: string, file: string): string {
39
- const abs = isAbsolute(file) ? file : resolvePath(root, file)
40
- const canonical = (value: string) => {
41
- try {
42
- return realpathSync(value)
43
- } catch {
44
- return value
45
- }
46
- }
47
- return relative(canonical(root), canonical(abs)).split('\\').join('/')
48
- }
49
-
50
- /** 1-based start line of a node. */
51
- function startLine(node: Node): number {
52
- return node.getStartLineNumber()
53
- }
54
-
55
- /**
56
- * Harvest the leading JSDoc / line-comment block immediately above `node`,
57
- * stripped of comment markers, collapsed to a single trimmed string.
58
- */
59
- function leadingDoc(node: Node): string | undefined {
60
- // Prefer real JSDoc nodes when present (ts-morph exposes them on many decls).
61
- const anyNode = node as unknown as { getJsDocs?: () => Array<{ getText: () => string }> }
62
- if (typeof anyNode.getJsDocs === 'function') {
63
- const docs = anyNode.getJsDocs()
64
- if (docs.length > 0) {
65
- const text = docs.map((d) => d.getText()).join('\n')
66
- const cleaned = cleanComment(text)
67
- if (cleaned) return cleaned
68
- }
69
- }
70
- // Fall back to raw leading comment ranges (covers `//` line comments too).
71
- const ranges = node.getLeadingCommentRanges()
72
- if (ranges.length === 0) return undefined
73
- const raw = ranges.map((r) => r.getText()).join('\n')
74
- const cleaned = cleanComment(raw)
75
- return cleaned || undefined
76
- }
77
-
78
- /** Strip `/** *​/`, `//`, leading `*` gutters; collapse to a tidy single line. */
79
- function cleanComment(raw: string): string {
80
- const lines = raw
81
- .replace(/\/\*\*?/g, '')
82
- .replace(/\*\//g, '')
83
- .split('\n')
84
- .map((l) =>
85
- l
86
- .replace(/^\s*\*\s?/, '')
87
- .replace(/^\s*\/\/\s?/, '')
88
- .trim(),
89
- )
90
- .filter((l) => l.length > 0)
91
- return lines.join(' ').replace(/\s+/g, ' ').trim()
92
- }
93
-
94
- /** The call's callee identifier name, e.g. `method` / `classMethods` / `todo`. */
95
- function calleeName(call: CallExpression): string | undefined {
96
- const expr = call.getExpression()
97
- if (Node.isIdentifier(expr)) return expr.getText()
98
- if (Node.isPropertyAccessExpression(expr)) return expr.getName()
99
- return undefined
100
- }
101
-
102
- /** Unwrap a string literal argument to its value. */
103
- function stringArg(call: CallExpression, index: number): string | undefined {
104
- const arg = call.getArguments()[index]
105
- if (!arg) return undefined
106
- if (Node.isStringLiteral(arg) || Node.isNoSubstitutionTemplateLiteral(arg)) {
107
- return arg.getLiteralText()
108
- }
109
- return undefined
110
- }
111
-
112
- // ───────────────────────────── handler links ─────────────────────────────
113
-
114
- /** Recognized method-wiring calls after normalizing single-method and grouped helpers. */
115
- interface WiredMethod {
116
- owner: string
117
- method: string
118
- /** the object literal / call expression carrying the handler config */
119
- config: Node
120
- wiringLine: number
121
- wiringFile: string
122
- ownerKind?: 'class' | 'interface' | 'function'
123
- style?: 'legacy-config' | 'direct-handler'
124
- }
125
-
126
- const SINGLE_METHOD_HELPERS = new Set(['method', 'remoteMethod'])
127
- const GROUP_HELPERS = new Set([
128
- 'classMethods',
129
- 'interfaceMethods',
130
- 'remoteClassMethods',
131
- 'remoteInterfaceMethods',
132
- ])
133
-
134
- type KernelToken = { token: string; label?: string }
135
-
136
- /** Kernel-op idioms surfaced as `kernelCalls`; entries are longest-first. */
137
- const KERNEL_TOKENS: KernelToken[] = [
138
- { token: 'graph.createEdge' },
139
- { token: 'graph.removeEdge' },
140
- { token: 'function.mutate' },
141
- { token: 'graph.children' },
142
- { token: 'function.get' },
143
- { token: 'graph.create' },
144
- { token: 'graph.update' },
145
- { token: 'graph.remove' },
146
- { token: 'graph.mutate' },
147
- { token: 'auth.revoke' },
148
- { token: 'graph.links' },
149
- { token: 'auth.grant' },
150
- { token: 'auth.check' },
151
- { token: 'graph.tree' },
152
- { token: 'graph.node' },
153
- { token: 'revokePerm', label: 'revokePerm (legacy)' },
154
- { token: 'checkPerm', label: 'checkPerm (legacy)' },
155
- { token: 'graph.get' },
156
- { token: 'grantPerm', label: 'grantPerm (legacy)' },
157
- ]
158
-
159
- /**
160
- * Resolve a method-config node (object literal `{ authorize, execute }` or a
161
- * call like `todo('…')`) down to: the symbol that carries the real logic, plus
162
- * whether it is a NotImplemented stub and the authorize flavour.
163
- */
164
- interface ConfigResolution {
165
- /** the node whose `execute` body we follow to a handler file */
166
- executeNode?: Node
167
- implemented: boolean
168
- /** declared auth policy; defaults to 'required' when the prop is absent. */
169
- auth?: 'public' | 'optional' | 'required'
170
- /** authorize hook shape: 'absent' | 'noop' | 'custom'. */
171
- authorize?: 'absent' | 'noop' | 'custom'
172
- authorizeSnippet?: string
173
- unlinkedReason?: 'no-config'
174
- }
175
-
176
- function resolveConfigObject(configNode: Node, seen = new Set<string>()): ConfigResolution {
177
- const node = unwrapExpression(configNode)
178
- const key = `${node.getSourceFile().getFilePath()}:${node.getStart()}`
179
- if (seen.has(key)) return { implemented: false, unlinkedReason: 'no-config' }
180
- seen.add(key)
181
-
182
- if (Node.isCallExpression(node)) {
183
- // Curried SDK helpers carry the handler config in their final argument:
184
- // `remoteMethod()(schema, 'Issue', 'delete', { authorize, execute })`.
185
- const lastArg = node.getArguments().at(-1)
186
- if (lastArg) {
187
- const candidate = unwrapExpression(lastArg)
188
- if (Node.isObjectLiteralExpression(candidate) || Node.isIdentifier(candidate)) {
189
- return resolveConfigObject(candidate, seen)
190
- }
191
- }
192
-
193
- // Local factories such as `todo('Owner.name')` return the config object themselves.
194
- const obj = followFactoryToObject(node)
195
- if (obj) return classifyObjectLiteral(obj)
196
- return { implemented: false, unlinkedReason: 'no-config' }
197
- }
198
- if (Node.isObjectLiteralExpression(node)) {
199
- return classifyObjectLiteral(node)
200
- }
201
- // Handler maps normally reference a local or imported `remoteMethod` value.
202
- if (Node.isIdentifier(node)) {
203
- const value = valueOfIdentifier(node)
204
- if (value) return resolveConfigObject(value, seen)
205
- }
206
- return { implemented: false, unlinkedReason: 'no-config' }
207
- }
208
-
209
- /** Remove syntax-only wrappers around a runtime value. */
210
- function unwrapExpression(node: Node): Node {
211
- let current = node
212
- while (
213
- Node.isParenthesizedExpression(current) ||
214
- Node.isAsExpression(current) ||
215
- Node.isSatisfiesExpression(current) ||
216
- Node.isTypeAssertion(current)
217
- ) {
218
- current = current.getExpression()
219
- }
220
- return current
221
- }
222
-
223
- /** Resolve a local or relatively imported identifier to its runtime value. */
224
- function valueOfIdentifier(id: Node): Node | undefined {
225
- if (!Node.isIdentifier(id)) return undefined
226
- const decl = firstValueDeclaration(id)
227
- if (decl && Node.isVariableDeclaration(decl)) return decl.getInitializer()
228
- return resolveImportedValue(id)
229
- }
230
-
231
- /** Follow a named relative import to the exported value that defines it. */
232
- function resolveImportedValue(id: Node): Node | undefined {
233
- if (!Node.isIdentifier(id)) return undefined
234
- const localName = id.getText()
235
- const sourceFile = id.getSourceFile()
236
-
237
- for (const imp of sourceFile.getImportDeclarations()) {
238
- for (const named of imp.getNamedImports()) {
239
- const local = named.getAliasNode()?.getText() ?? named.getName()
240
- if (local !== localName) continue
241
- const target = resolveModuleFile(sourceFile, imp.getModuleSpecifierValue())
242
- if (!target) return undefined
243
- return findExportedValue(id.getProject(), target, named.getName(), new Set<string>())
244
- }
245
- }
246
- return undefined
247
- }
248
-
249
- /** Find an exported variable/function value, following local and barrel exports. */
250
- function findExportedValue(
251
- project: Project,
252
- file: string,
253
- exportName: string,
254
- seen: Set<string>,
255
- ): Node | undefined {
256
- const key = `${file}:${exportName}`
257
- if (seen.has(key)) return undefined
258
- seen.add(key)
259
- const sf = tryAddFile(project, file)
260
- if (!sf) return undefined
261
-
262
- const localValue = (name: string, exportedOnly: boolean): Node | undefined => {
263
- const variable = sf
264
- .getVariableDeclarations()
265
- .find(
266
- (candidate) => candidate.getName() === name && (!exportedOnly || candidate.isExported()),
267
- )
268
- if (variable) return variable.getInitializer()
269
- return sf
270
- .getFunctions()
271
- .find(
272
- (candidate) => candidate.getName() === name && (!exportedOnly || candidate.isExported()),
273
- )
274
- }
275
-
276
- const direct = localValue(exportName, true)
277
- if (direct) return direct
278
-
279
- for (const ex of sf.getExportDeclarations()) {
280
- const modSpec = ex.getModuleSpecifierValue()
281
- const target = modSpec ? resolveModuleFile(sf, modSpec) : undefined
282
- const named = ex.getNamedExports()
283
- if (named.length > 0) {
284
- for (const specifier of named) {
285
- const exposed = specifier.getAliasNode()?.getText() ?? specifier.getName()
286
- if (exposed !== exportName) continue
287
- const original = specifier.getName()
288
- if (target) return findExportedValue(project, target, original, seen)
289
- return localValue(original, false)
290
- }
291
- } else if (target) {
292
- const found = findExportedValue(project, target, exportName, seen)
293
- if (found) return found
294
- }
295
- }
296
- return undefined
297
- }
298
-
299
- /** Given `todo('x')`, find the factory's `return { … }` object literal. */
300
- function followFactoryToObject(call: CallExpression): Node | undefined {
301
- const expr = call.getExpression()
302
- if (!Node.isIdentifier(expr)) return undefined
303
- const decl = firstValueDeclaration(expr)
304
- if (!decl) return undefined
305
- let body: Node | undefined
306
- if (Node.isVariableDeclaration(decl)) {
307
- body = decl.getInitializer()
308
- } else if (Node.isFunctionDeclaration(decl)) {
309
- body = decl
310
- }
311
- if (!body) return undefined
312
- // Arrow returning an object literal directly: `(name) => ({ … })`.
313
- if (Node.isArrowFunction(body)) {
314
- const arrowBody = body.getBody()
315
- if (Node.isParenthesizedExpression(arrowBody)) {
316
- const inner = arrowBody.getExpression()
317
- if (Node.isObjectLiteralExpression(inner)) return inner
318
- }
319
- if (Node.isObjectLiteralExpression(arrowBody)) return arrowBody
320
- // Block body with a `return { … }`.
321
- const ret = arrowBody.getFirstDescendantByKind?.(SyntaxKind.ReturnStatement)
322
- const retExpr = ret?.getExpression()
323
- if (retExpr && Node.isObjectLiteralExpression(retExpr)) return retExpr
324
- }
325
- if (Node.isFunctionDeclaration(body) || Node.isFunctionExpression(body)) {
326
- const ret = body.getFirstDescendantByKind(SyntaxKind.ReturnStatement)
327
- const retExpr = ret?.getExpression()
328
- if (retExpr && Node.isObjectLiteralExpression(retExpr)) return retExpr
329
- }
330
- return undefined
331
- }
332
-
333
- /** Inspect `{ auth, authorize, execute }` for the auth policy, authorize shape,
334
- * parsed permission checks, source snippets, and execute stub-ness. */
335
- function classifyObjectLiteral(obj: Node): ConfigResolution {
336
- if (!Node.isObjectLiteralExpression(obj)) {
337
- return { implemented: false, unlinkedReason: 'no-config' }
338
- }
339
- const executeProp = getProp(obj, 'execute')
340
- const authorizeProp = getProp(obj, 'authorize')
341
-
342
- const auth = authPolicy(obj)
343
- const authorize = authorizeProp ? authorizeFlavour(authorizeProp) : 'absent'
344
- const authorizeSnippet = authorizeProp ? snippetOf(authorizeProp) : undefined
345
- const common = { auth, authorize, authorizeSnippet } as const
346
-
347
- if (!executeProp) {
348
- return { implemented: false, ...common, unlinkedReason: 'no-config' }
349
- }
350
- const implemented = !isStubFunction(executeProp)
351
- return { executeNode: executeProp, implemented, ...common }
352
- }
353
-
354
- /** The declared `auth` policy on a method config; defaults to 'required'. */
355
- function authPolicy(obj: Node): 'public' | 'optional' | 'required' {
356
- const v = stringLiteralOfProp(obj, 'auth')
357
- return v === 'public' || v === 'optional' ? v : 'required'
358
- }
359
-
360
- const SNIPPET_CAP = 1200
361
-
362
- /** Source text of a node, capped for a hover preview. */
363
- function snippetOf(node: Node): string {
364
- const t = node.getText()
365
- return t.length > SNIPPET_CAP ? `${t.slice(0, SNIPPET_CAP)}\n/* … */` : t
366
- }
367
-
368
- /** Get the value node of an object-literal property (handles shorthand). */
369
- function getProp(obj: Node, name: string): Node | undefined {
370
- if (!Node.isObjectLiteralExpression(obj)) return undefined
371
- const prop = obj.getProperty(name)
372
- if (!prop) return undefined
373
- if (Node.isPropertyAssignment(prop)) return prop.getInitializer()
374
- if (Node.isShorthandPropertyAssignment(prop)) return prop.getNameNode()
375
- if (Node.isMethodDeclaration(prop)) return prop
376
- return prop
377
- }
378
-
379
- /** 'noop' when the authorize body just returns undefined/void, else 'custom'.
380
- * Follows identifier references (e.g. `authorize: allow` → `const allow = …`). */
381
- function authorizeFlavour(node: Node): 'noop' | 'custom' {
382
- const fnBody = functionBodyOf(node)
383
- if (fnBody === undefined) return 'custom' // opaque (unresolved identifier) → assume real
384
- if (fnBody === null) return 'noop' // expression arrow returning `undefined`
385
- const text = fnBody.getText().replace(/[{}]/g, '').trim()
386
- // Empty block, or a single `return;` / `return undefined;`.
387
- if (text === '' || /^return\s*(undefined)?\s*;?$/.test(text)) return 'noop'
388
- return 'custom'
389
- }
390
-
391
- /**
392
- * For a function-like node return its block body (Node), or `null` if it is an
393
- * expression-bodied arrow whose expression is `undefined`/void, or `undefined`
394
- * if it is not function-like.
395
- */
396
- function functionBodyOf(node: Node): Node | null | undefined {
397
- let fn: Node | undefined
398
- if (Node.isArrowFunction(node) || Node.isFunctionExpression(node)) fn = node
399
- else if (Node.isMethodDeclaration(node)) fn = node
400
- else if (Node.isIdentifier(node)) {
401
- const value = valueOfIdentifier(node)
402
- if (value) return functionBodyOf(value)
403
- return undefined
404
- }
405
- if (!fn) return undefined
406
- const body = (fn as unknown as { getBody?: () => Node | undefined }).getBody?.()
407
- if (!body) return undefined
408
- if (Node.isBlock(body)) return body
409
- // Expression body: `async () => undefined`.
410
- if (body.getKind() === SyntaxKind.UndefinedKeyword || body.getText() === 'undefined') {
411
- return null
412
- }
413
- return body
414
- }
415
-
416
- /** A handler body that is a NotImplemented stub: only throws / `todo()`. */
417
- function isStubFunction(node: Node): boolean {
418
- const body = functionBodyOf(node)
419
- if (!body || body === null) return false
420
- if (!Node.isBlock(body)) return false
421
- const statements = body.getStatements()
422
- if (statements.length === 0) return false
423
- // Stub iff every statement is a throw, and there is at least one throw.
424
- let throws = 0
425
- for (const st of statements) {
426
- if (Node.isThrowStatement(st)) {
427
- throws++
428
- continue
429
- }
430
- return false
431
- }
432
- return throws > 0
433
- }
434
-
435
- /** First value/declaration node a name resolves to (definition, not reference). */
436
- function firstValueDeclaration(node: Node): Node | undefined {
437
- const idNode = Node.isIdentifier(node)
438
- ? node
439
- : node.getFirstDescendantByKind(SyntaxKind.Identifier)
440
- if (!idNode || !Node.isIdentifier(idNode)) return undefined
441
- const symbol = idNode.getSymbol()
442
- if (!symbol) return undefined
443
- const decls = symbol.getDeclarations()
444
- return decls[0]
445
- }
446
-
447
- /**
448
- * Follow an `execute` arrow → the delegated logic function it calls → that
449
- * function's defining file:line, chasing through barrel re-exports.
450
- */
451
- function resolveExecuteTarget(executeNode: Node): { file: string; line: number } | undefined {
452
- // Find the call inside the execute body that targets an imported symbol.
453
- const body = functionBodyOf(executeNode)
454
- const searchRoot = body && Node.isBlock(body) ? body : executeNode
455
- const calls = searchRoot.getDescendantsOfKind(SyntaxKind.CallExpression)
456
- for (const call of calls) {
457
- const expr = call.getExpression()
458
- if (!Node.isIdentifier(expr)) continue
459
- const resolved = resolveImportedFunction(expr)
460
- if (resolved) return resolved
461
- }
462
- return undefined
463
- }
464
-
465
- /** Resolve an identifier (a delegated logic fn) to its defining file:line. */
466
- function resolveImportedFunction(id: Node): { file: string; line: number } | undefined {
467
- if (!Node.isIdentifier(id)) return undefined
468
- const symbol = id.getSymbol()
469
- if (!symbol) return undefined
470
- // Walk through alias chains (import { x as y } / barrel re-export `export { x }`).
471
- let current = symbol
472
- const seen = new Set<string>()
473
- for (let i = 0; i < 12; i++) {
474
- const decls = current.getDeclarations()
475
- for (const decl of decls) {
476
- // A concrete function/variable declaration in a real file → done.
477
- if (Node.isFunctionDeclaration(decl)) {
478
- return { file: decl.getSourceFile().getFilePath(), line: decl.getStartLineNumber() }
479
- }
480
- if (Node.isVariableDeclaration(decl)) {
481
- const init = decl.getInitializer()
482
- if (init && (Node.isArrowFunction(init) || Node.isFunctionExpression(init))) {
483
- return { file: decl.getSourceFile().getFilePath(), line: decl.getStartLineNumber() }
484
- }
485
- }
486
- }
487
- // Try to hop to the aliased symbol (import specifier / export specifier).
488
- const aliased = trySymbolHop(current)
489
- if (!aliased) break
490
- const key = aliased
491
- .getDeclarations()
492
- .map((d) => d.getSourceFile().getFilePath() + ':' + d.getStartLineNumber())
493
- .join(',')
494
- if (seen.has(key)) break
495
- seen.add(key)
496
- current = aliased
497
- }
498
- // Last resort: resolve the module specifier of the import and grep the file.
499
- return resolveViaModuleSpecifier(id)
500
- }
501
-
502
- /** Hop one alias link via ts-morph's getAliasedSymbol, if any. */
503
- function trySymbolHop(symbol: import('ts-morph').Symbol): import('ts-morph').Symbol | undefined {
504
- try {
505
- const aliased = symbol.getAliasedSymbol?.()
506
- if (aliased && aliased !== symbol) return aliased
507
- } catch {
508
- /* not an alias */
509
- }
510
- return undefined
511
- }
512
-
513
- /**
514
- * Fallback resolution: find the import declaration that brought `id` in, load
515
- * the target module (and barrel re-exports), and locate the exported function.
516
- */
517
- function resolveViaModuleSpecifier(id: Node): { file: string; line: number } | undefined {
518
- if (!Node.isIdentifier(id)) return undefined
519
- const sourceFile = id.getSourceFile()
520
- const name = id.getText()
521
- for (const imp of sourceFile.getImportDeclarations()) {
522
- // Match the local binding name, capturing the original export name.
523
- let exportName: string | undefined
524
- for (const named of imp.getNamedImports()) {
525
- const local = named.getAliasNode()?.getText() ?? named.getName()
526
- if (local === name) {
527
- exportName = named.getName()
528
- break
529
- }
530
- }
531
- if (!exportName) continue
532
- const modPath = resolveModuleFile(sourceFile, imp.getModuleSpecifierValue())
533
- if (!modPath) continue
534
- const found = findExportInFile(modPath, exportName, new Set())
535
- if (found) return found
536
- }
537
- return undefined
538
- }
539
-
540
- /** Resolve a relative module specifier to a concrete .ts file path on disk. */
541
- function resolveModuleFile(from: SourceFile, spec: string): string | undefined {
542
- if (!spec.startsWith('.')) {
543
- // Current SDK projects use package `imports` aliases such as
544
- // `#actions/risk`. Resolve them from the authored file's directory through
545
- // Bun so the overlay follows the same project-local map as runtime imports.
546
- if (!spec.startsWith('#')) return undefined
547
- try {
548
- const resolved = Bun.resolveSync(spec, dirname(from.getFilePath()))
549
- if (existsSync(resolved)) return resolved
550
- } catch {
551
- return undefined
552
- }
553
- return undefined
554
- }
555
- const baseDir = dirname(from.getFilePath())
556
- const base = resolvePath(baseDir, spec)
557
- const sourceBase = base.replace(/\.(?:[cm]?js|jsx)$/, '')
558
- const candidates = [
559
- base,
560
- sourceBase.endsWith('.ts') ? sourceBase : `${sourceBase}.ts`,
561
- sourceBase.endsWith('.tsx') ? sourceBase : `${sourceBase}.tsx`,
562
- `${base}/index.ts`,
563
- `${base}/index.tsx`,
564
- ]
565
- for (const c of candidates) if (existsSync(c)) return c
566
- return undefined
567
- }
568
-
569
- /**
570
- * Find where `exportName` is *defined* within `file`, following barrel
571
- * `export { x } from './y'` / `export * from './y'` re-exports.
572
- */
573
- function findExportInFile(
574
- file: string,
575
- exportName: string,
576
- seen: Set<string>,
577
- ): { file: string; line: number } | undefined {
578
- if (seen.has(file)) return undefined
579
- seen.add(file)
580
- const project = newProject()
581
- const sf = tryAddFile(project, file)
582
- if (!sf) return undefined
583
-
584
- // 1) A local function declaration with that name.
585
- for (const fn of sf.getFunctions()) {
586
- if (fn.getName() === exportName && fn.isExported()) {
587
- return { file, line: fn.getStartLineNumber() }
588
- }
589
- }
590
- // 2) A local exported variable bound to a function.
591
- for (const v of sf.getVariableDeclarations()) {
592
- if (v.getName() === exportName && v.isExported()) {
593
- return { file, line: v.getStartLineNumber() }
594
- }
595
- }
596
- // 3) Re-export: `export { a, b as exportName } from './mod'`.
597
- for (const ex of sf.getExportDeclarations()) {
598
- const modSpec = ex.getModuleSpecifierValue()
599
- const target = modSpec ? resolveModuleFile(sf, modSpec) : undefined
600
- const named = ex.getNamedExports()
601
- if (named.length > 0) {
602
- for (const ne of named) {
603
- const exposed = ne.getAliasNode()?.getText() ?? ne.getName()
604
- if (exposed !== exportName) continue
605
- const original = ne.getName()
606
- if (target) {
607
- const found = findExportInFile(target, original, seen)
608
- if (found) return found
609
- }
610
- }
611
- } else if (target) {
612
- // `export * from './mod'` — search the target for the same name.
613
- const found = findExportInFile(target, exportName, seen)
614
- if (found) return found
615
- }
616
- }
617
- return undefined
618
- }
619
-
620
- /** Scan handler file text for kernel-op tokens. */
621
- function scanKernelCalls(file: string): string[] {
622
- let text: string
623
- try {
624
- text = readFileSync(file, 'utf8')
625
- } catch {
626
- return []
627
- }
628
- const found: string[] = []
629
- let work = text
630
- for (const entry of KERNEL_TOKENS) {
631
- if (work.includes(entry.token)) {
632
- found.push(entry.label ?? entry.token)
633
- // Blank out matches so `::getLinks` doesn't also count as `::getLink`.
634
- work = work.split(entry.token).join(' '.repeat(entry.token.length))
635
- }
636
- }
637
- return found
638
- }
639
-
640
- /**
641
- * Collect every (owner, method, config) wiring from runtime/index.ts, across
642
- * both fixture styles, then build a HandlerLink per method.
643
- */
644
- export function buildHandlerLinks(args: {
645
- ir: SchemaIR | null
646
- domainRoot: string
647
- }): HandlerLink[] {
648
- const { ir, domainRoot } = args
649
- if (!domainRoot) return []
650
- const project = newProject()
651
- const legacyWiringRel = 'runtime/index.ts'
652
- const legacySf = tryAddFile(project, resolvePath(domainRoot, legacyWiringRel))
653
-
654
- const interfaceNames = new Set(ir ? Object.keys(ir.interfaces ?? {}) : [])
655
-
656
- const wired: WiredMethod[] = []
657
-
658
- if (legacySf) {
659
- for (const call of legacySf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
660
- const name = calleeName(call)
661
- if (!name) continue
662
-
663
- // Style A: single-method helper — method(schema, 'Owner', 'name', { … }).
664
- if (SINGLE_METHOD_HELPERS.has(name)) {
665
- const owner = stringArg(call, 1)
666
- const method = stringArg(call, 2)
667
- const config = call.getArguments()[3]
668
- if (owner && method && config) {
669
- wired.push({
670
- owner,
671
- method,
672
- config,
673
- wiringLine: call.getStartLineNumber(),
674
- wiringFile: legacyWiringRel,
675
- style: 'legacy-config',
676
- })
677
- }
678
- continue
679
- }
680
-
681
- // Style B: group helper — classMethods(schema, 'Owner', { name: cfg, … }).
682
- if (GROUP_HELPERS.has(name)) {
683
- const owner = stringArg(call, 1)
684
- const mapArg = call.getArguments()[2]
685
- if (!owner || !mapArg || !Node.isObjectLiteralExpression(mapArg)) continue
686
- for (const prop of mapArg.getProperties()) {
687
- const valueNode = objectPropertyValue(prop)
688
- const methodName = propertyKey(prop)
689
- if (!methodName || !valueNode) continue
690
- wired.push({
691
- owner,
692
- method: methodName,
693
- config: valueNode,
694
- wiringLine: prop.getStartLineNumber(),
695
- wiringFile: legacyWiringRel,
696
- style: 'legacy-config',
697
- })
698
- }
699
- continue
700
- }
701
- }
702
- }
703
-
704
- // Current SDK layout: `implementation.ts` owns a plain
705
- // `handlers: { classes, interfaces, functions }` object passed to
706
- // `defineDomain` / `defineDomain.public`. Start from that call so inline
707
- // handler maps and arbitrarily named handler variables are both visible.
708
- // Class/interface entries point directly at handler functions; there is no
709
- // legacy `{ authorize, execute }` wrapper.
710
- for (const wiringRel of ['implementation.ts', 'domain.ts']) {
711
- const sf = tryAddFile(project, resolvePath(domainRoot, wiringRel))
712
- if (!sf) continue
713
-
714
- const handlerObjects: import('ts-morph').ObjectLiteralExpression[] = []
715
- const seenHandlerObjects = new Set<string>()
716
- const addHandlerObject = (candidate: Node | undefined) => {
717
- const handlersObject = candidate ? resolveObjectLiteral(candidate) : undefined
718
- if (!handlersObject) return
719
- const key = `${handlersObject.getSourceFile().getFilePath()}:${handlersObject.getStart()}`
720
- if (seenHandlerObjects.has(key)) return
721
- seenHandlerObjects.add(key)
722
- handlerObjects.push(handlersObject)
723
- }
724
-
725
- for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
726
- if (!isDefineDomainCall(call)) continue
727
- const input = call.getArguments()[0]
728
- const inputObject = input ? resolveObjectLiteral(input) : undefined
729
- addHandlerObject(inputObject ? getProp(inputObject, 'handlers') : undefined)
730
- }
731
-
732
- // Tolerate pre-composition fixtures that expose a conventional `handlers`
733
- // variable without a parseable defineDomain call.
734
- const handlersDecl = sf.getVariableDeclaration('handlers')
735
- addHandlerObject(handlersDecl?.getInitializer())
736
-
737
- for (const handlersObject of handlerObjects) {
738
- const handlersFile = relToRoot(domainRoot, handlersObject.getSourceFile().getFilePath())
739
- collectDirectHandlerSection(wired, handlersObject, 'classes', 'class', handlersFile)
740
- collectDirectHandlerSection(wired, handlersObject, 'interfaces', 'interface', handlersFile)
741
- collectDirectFunctionHandlers(wired, handlersObject, ir?.domain ?? 'domain', handlersFile)
742
- }
743
- }
744
-
745
- // Deduplicate (owner, method): the group helper restates entries the single
746
- // helper already produced (e.g. my-domain wires each method, then groups them).
747
- // Prefer the entry whose config resolves to a real object (the single helper),
748
- // falling back to the group entry (which references a variable / todo()).
749
- const byKey = new Map<string, WiredMethod>()
750
- for (const w of wired) {
751
- const key = `${w.ownerKind ?? 'legacy'}:${w.owner}.${w.method}`
752
- const existing = byKey.get(key)
753
- if (!existing) {
754
- byKey.set(key, w)
755
- continue
756
- }
757
- // Prefer the one whose config is an object literal (richer info).
758
- const existingIsObj = Node.isObjectLiteralExpression(existing.config)
759
- const candidateIsObj = Node.isObjectLiteralExpression(w.config)
760
- if (candidateIsObj && !existingIsObj) byKey.set(key, w)
761
- }
762
-
763
- const links: HandlerLink[] = []
764
- for (const w of byKey.values()) {
765
- const ownerKind: 'class' | 'interface' | 'function' =
766
- w.ownerKind ?? (interfaceNames.has(w.owner) ? 'interface' : 'class')
767
- const isStatic = irMethodStatic(ir, w.owner, w.method, ownerKind)
768
-
769
- const link: HandlerLink = {
770
- owner: w.owner,
771
- ownerKind,
772
- method: w.method,
773
- static: isStatic,
774
- wiringFile: w.wiringFile,
775
- wiringLine: w.wiringLine,
776
- implemented: true,
777
- }
778
-
779
- let target: { file: string; line: number } | undefined
780
- if (w.style === 'direct-handler') {
781
- const direct = resolveDirectHandler(w.config)
782
- link.implemented = direct.implemented
783
- target = direct.target
784
- applyCanonicalMethodAuth(link, ir, w.owner, w.method, ownerKind)
785
- } else {
786
- const resolution = resolveConfigObject(w.config)
787
- link.implemented = resolution.implemented
788
- if (resolution.auth) link.auth = resolution.auth
789
- if (resolution.authorize) link.authorize = resolution.authorize
790
- if (resolution.authorizeSnippet) link.authorizeSnippet = resolution.authorizeSnippet
791
- if (resolution.executeNode) target = resolveExecuteTarget(resolution.executeNode)
792
- }
793
-
794
- if (target) {
795
- link.handlerFile = relToRoot(domainRoot, target.file)
796
- link.handlerLine = target.line
797
- const kernelCalls = scanKernelCalls(target.file)
798
- if (kernelCalls.length > 0) link.kernelCalls = kernelCalls
799
- } else {
800
- link.unlinked = true
801
- }
802
-
803
- links.push(link)
804
- }
805
-
806
- // Stable order: by owner, then method.
807
- links.sort((a, b) =>
808
- a.owner === b.owner ? a.method.localeCompare(b.method) : a.owner.localeCompare(b.owner),
809
- )
810
- return links
811
- }
812
-
813
- /** Whether a call is the current SDK composition entrypoint. */
814
- function isDefineDomainCall(call: CallExpression): boolean {
815
- const expression = unwrapExpression(call.getExpression())
816
- if (Node.isIdentifier(expression)) return expression.getText() === 'defineDomain'
817
- if (!Node.isPropertyAccessExpression(expression) || expression.getName() !== 'public') {
818
- return false
819
- }
820
- const receiver = unwrapExpression(expression.getExpression())
821
- return Node.isIdentifier(receiver) && receiver.getText() === 'defineDomain'
822
- }
823
-
824
- /** Resolve a value to an object literal, following local/imported identifiers. */
825
- function resolveObjectLiteral(
826
- node: Node,
827
- seen = new Set<string>(),
828
- ): import('ts-morph').ObjectLiteralExpression | undefined {
829
- const value = unwrapExpression(node)
830
- const key = `${value.getSourceFile().getFilePath()}:${value.getStart()}`
831
- if (seen.has(key)) return undefined
832
- seen.add(key)
833
- if (Node.isObjectLiteralExpression(value)) return value
834
- if (Node.isIdentifier(value)) {
835
- const resolved = valueOfIdentifier(value)
836
- if (resolved) return resolveObjectLiteral(resolved, seen)
837
- }
838
- return undefined
839
- }
840
-
841
- /** Value carried by an object-literal property, including shorthand/method syntax. */
842
- function objectPropertyValue(prop: Node): Node | undefined {
843
- if (Node.isPropertyAssignment(prop)) return prop.getInitializer()
844
- if (Node.isShorthandPropertyAssignment(prop)) return prop.getNameNode()
845
- if (Node.isMethodDeclaration(prop)) return prop
846
- return undefined
847
- }
848
-
849
- /** Collect current `handlers.classes/interfaces.Owner.method = fn` bindings. */
850
- function collectDirectHandlerSection(
851
- out: WiredMethod[],
852
- handlers: import('ts-morph').ObjectLiteralExpression,
853
- section: 'classes' | 'interfaces',
854
- ownerKind: 'class' | 'interface',
855
- wiringFile: string,
856
- ): void {
857
- const sectionValue = getProp(handlers, section)
858
- const sectionObject = sectionValue ? resolveObjectLiteral(sectionValue) : undefined
859
- if (!sectionObject) return
860
- for (const ownerProp of sectionObject.getProperties()) {
861
- const owner = propertyKey(ownerProp)
862
- const ownerValue = objectPropertyValue(ownerProp)
863
- const ownerObject = ownerValue ? resolveObjectLiteral(ownerValue) : undefined
864
- if (!owner || !ownerObject) continue
865
- for (const methodProp of ownerObject.getProperties()) {
866
- const method = propertyKey(methodProp)
867
- const handler = objectPropertyValue(methodProp)
868
- if (!method || !handler) continue
869
- out.push({
870
- owner,
871
- ownerKind,
872
- method,
873
- config: handler,
874
- wiringLine: methodProp.getStartLineNumber(),
875
- wiringFile,
876
- style: 'direct-handler',
877
- })
878
- }
879
- }
880
- }
881
-
882
- /** Collect current standalone `handlers.functions.name = fn` bindings. */
883
- function collectDirectFunctionHandlers(
884
- out: WiredMethod[],
885
- handlers: import('ts-morph').ObjectLiteralExpression,
886
- owner: string,
887
- wiringFile: string,
888
- ): void {
889
- const functionsValue = getProp(handlers, 'functions')
890
- const functions = functionsValue ? resolveObjectLiteral(functionsValue) : undefined
891
- if (!functions) return
892
- for (const functionProp of functions.getProperties()) {
893
- const method = propertyKey(functionProp)
894
- const handler = objectPropertyValue(functionProp)
895
- if (!method || !handler) continue
896
- out.push({
897
- owner,
898
- ownerKind: 'function',
899
- method,
900
- config: handler,
901
- wiringLine: functionProp.getStartLineNumber(),
902
- wiringFile,
903
- style: 'direct-handler',
904
- })
905
- }
906
- }
907
-
908
- /** Follow a direct SDK handler to its defining source node. */
909
- function resolveDirectHandler(config: Node): {
910
- implemented: boolean
911
- target?: { file: string; line: number }
912
- } {
913
- let value = unwrapExpression(config)
914
- if (Node.isIdentifier(value)) value = valueOfIdentifier(value) ?? value
915
- value = unwrapExpression(value)
916
- const concrete =
917
- Node.isArrowFunction(value) ||
918
- Node.isFunctionExpression(value) ||
919
- Node.isFunctionDeclaration(value) ||
920
- Node.isMethodDeclaration(value) ||
921
- Node.isCallExpression(value)
922
- if (!concrete) return { implemented: false }
923
- return {
924
- implemented: Node.isCallExpression(value) ? true : !isStubFunction(value),
925
- target: { file: value.getSourceFile().getFilePath(), line: value.getStartLineNumber() },
926
- }
927
- }
928
-
929
- /** Map the canonical callable auth declaration onto the existing Studio badge model. */
930
- function applyCanonicalMethodAuth(
931
- link: HandlerLink,
932
- ir: SchemaIR | null,
933
- owner: string,
934
- method: string,
935
- ownerKind: 'class' | 'interface' | 'function',
936
- ): void {
937
- const callable =
938
- ownerKind === 'function'
939
- ? ir?.functions?.[method]
940
- : ownerKind === 'interface'
941
- ? ir?.interfaces?.[owner]?.methods?.[method]
942
- : ir?.classes?.[owner]?.methods?.[method]
943
- const auth = callable?.auth
944
- if (auth) link.callableAuth = auth
945
- if (auth === 'anonymous') {
946
- link.auth = 'public'
947
- link.authorize = 'absent'
948
- } else if (auth === 'authorized') {
949
- link.auth = 'required'
950
- link.authorize = 'custom'
951
- } else if (auth === 'authenticated') {
952
- link.auth = 'required'
953
- link.authorize = 'absent'
954
- }
955
- }
956
-
957
- /** Look up a method's `static` flag from the IR (class or interface bucket). */
958
- function irMethodStatic(
959
- ir: SchemaIR | null,
960
- owner: string,
961
- method: string,
962
- ownerKind: 'class' | 'interface' | 'function',
963
- ): boolean {
964
- if (!ir) return false
965
- if (ownerKind === 'function') return true
966
- const bucket = ownerKind === 'interface' ? ir.interfaces?.[owner] : ir.classes?.[owner]
967
- const m = bucket?.methods?.[method]
968
- return m ? m.static === true : false
969
- }
970
-
971
- // ───────────────────────────── source spans ─────────────────────────────
972
-
973
- const DECL_HELPERS: Record<string, 'node' | 'interface' | 'edge' | 'function'> = {
974
- nodeClass: 'node',
975
- nodeInterface: 'interface',
976
- edgeClass: 'edge',
977
- fn: 'function',
978
- }
979
-
980
- /** Recognize both legacy `edgeClass(...)` and current
981
- * `edgeClass.directed/undirected({...})` authoring forms. */
982
- function declarationHelper(
983
- call: CallExpression,
984
- ): 'node' | 'interface' | 'edge' | 'function' | undefined {
985
- const direct = calleeName(call)
986
- if (direct && direct in DECL_HELPERS) return DECL_HELPERS[direct]
987
- const expression = call.getExpression()
988
- if (
989
- Node.isPropertyAccessExpression(expression) &&
990
- (expression.getName() === 'directed' || expression.getName() === 'undirected') &&
991
- expression.getExpression().getText() === 'edgeClass'
992
- ) {
993
- return 'edge'
994
- }
995
- return undefined
996
- }
997
-
998
- /**
999
- * A schema member's true NAME + section, resolved from the `defineSchema` map.
1000
- * The declaration helpers (nodeClass/nodeInterface/edgeClass) carry no name — a
1001
- * member is named by the KEY it is registered under, not by its variable — and a
1002
- * domain may register a class and an interface under the SAME name (the
1003
- * intentional same-name pattern: the `iUser` interface alongside the `User`
1004
- * class). So the map is the sole authority for both the anchor name and the
1005
- * interface-vs-class distinction; the variable identifier alone tells us neither.
1006
- */
1007
- interface MemberName {
1008
- schemaName: string
1009
- section: 'interface' | 'class' | 'function' // the `classes` map also holds edge classes
1010
- }
1011
-
1012
- interface MemberNameMap {
1013
- /** Exact declaration initializer, robust to imported/local aliases. */
1014
- byValue: Map<string, MemberName>
1015
- /** Legacy fallback when a symbol cannot be resolved. */
1016
- byIdentifier: Map<string, MemberName>
1017
- }
1018
-
1019
- /** Stable source coordinate for the value behind a local/imported alias. */
1020
- function memberValueKey(node: Node, seen = new Set<string>()): string | undefined {
1021
- const value = unwrapExpression(node)
1022
- const key = `${value.getSourceFile().getFilePath()}:${value.getStart()}`
1023
- if (seen.has(key)) return undefined
1024
- seen.add(key)
1025
- if (Node.isIdentifier(value)) {
1026
- const resolved = valueOfIdentifier(value)
1027
- if (resolved) return memberValueKey(resolved, seen)
1028
- }
1029
- return key
1030
- }
1031
-
1032
- /** Map each registered member VARIABLE (as referenced in `defineSchema`) to its
1033
- * schema name + section, from the domain's own schema files — never node_modules
1034
- * (a dependency's `defineSchema` is not this domain's). */
1035
- function buildMemberNameMap(files: SourceFile[]): MemberNameMap {
1036
- const map: MemberNameMap = {
1037
- byValue: new Map<string, MemberName>(),
1038
- byIdentifier: new Map<string, MemberName>(),
1039
- }
1040
- for (const sf of files) {
1041
- if (sf.getFilePath().includes('/node_modules/')) continue
1042
- for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1043
- if (calleeName(call) !== 'defineSchema') continue
1044
- const input = call.getArguments()[1]
1045
- const cfg = input ? resolveObjectLiteral(input) : undefined
1046
- if (!cfg) continue
1047
- collectSchemaSection(map, cfg, 'interfaces', 'interface')
1048
- collectSchemaSection(map, cfg, 'classes', 'class')
1049
- collectSchemaSection(map, cfg, 'functions', 'function')
1050
- }
1051
- }
1052
- return map
1053
- }
1054
-
1055
- /** Record `variable → { schemaName, section }` for one `defineSchema` section,
1056
- * handling both `Key: alias` and shorthand `Key`. */
1057
- function collectSchemaSection(
1058
- map: MemberNameMap,
1059
- cfg: Node,
1060
- prop: 'interfaces' | 'classes' | 'functions',
1061
- section: 'interface' | 'class' | 'function',
1062
- ): void {
1063
- const obj = getObjectProp(cfg, prop)
1064
- if (!obj) return
1065
- for (const p of obj.getProperties()) {
1066
- const schemaName = propertyKey(p)
1067
- const memberValue = objectPropertyValue(p)
1068
- if (!schemaName || !memberValue) continue
1069
- const member = { schemaName, section } satisfies MemberName
1070
- const valueKey = memberValueKey(memberValue)
1071
- if (valueKey) map.byValue.set(valueKey, member)
1072
- const unwrapped = unwrapExpression(memberValue)
1073
- if (Node.isIdentifier(unwrapped)) map.byIdentifier.set(unwrapped.getText(), member)
1074
- }
1075
- }
1076
-
1077
- /**
1078
- * The anchor namespace ('class' | 'interface' | 'edge') for a declared member.
1079
- * The `defineSchema` SECTION is authoritative for interface-vs-class — a
1080
- * `nodeClass` whose name collides with a same-named interface must still anchor
1081
- * as a class. Within the class section, an `edgeClass` (or an IR edge type)
1082
- * anchors as 'edge'. Falls back to the declaration helper when the member isn't
1083
- * in a parseable `defineSchema` map (e.g. an imported kernel member).
1084
- */
1085
- function resolveMemberKind(
1086
- ir: SchemaIR | null,
1087
- name: string,
1088
- section: 'interface' | 'class' | 'function' | undefined,
1089
- helperKind: 'node' | 'interface' | 'edge' | 'function',
1090
- ): 'class' | 'interface' | 'edge' | 'function' {
1091
- if (section === 'function' || helperKind === 'function') return 'function'
1092
- if (section === 'interface') return 'interface'
1093
- const isEdge = helperKind === 'edge' || ir?.classes?.[name]?.type === 'edge'
1094
- if (section === 'class') return isEdge ? 'edge' : 'class'
1095
- if (helperKind === 'interface') return 'interface'
1096
- return isEdge ? 'edge' : 'class'
1097
- }
1098
-
1099
- export function buildSourceSpans(args: {
1100
- ir: SchemaIR | null
1101
- schemaDir: string
1102
- }): Record<string, SourceSpan> {
1103
- const { ir, schemaDir } = args
1104
- if (!schemaDir || !existsSync(schemaDir)) return {}
1105
-
1106
- // The domain root is the parent of the schema dir (spans are relative to it).
1107
- const domainRoot = dirname(schemaDir.replace(/\/$/, ''))
1108
-
1109
- const project = newProject()
1110
- let files: string[] = []
1111
- try {
1112
- const added = project.addSourceFilesAtPaths(`${schemaDir.replace(/\/$/, '')}/**/*.ts`)
1113
- files = added.map((f) => f.getFilePath())
1114
- } catch {
1115
- return {}
1116
- }
1117
-
1118
- const spans: Record<string, SourceSpan> = {}
1119
-
1120
- const sourceFiles = files.map((f) => project.getSourceFile(f)).filter((f): f is SourceFile => !!f)
1121
- // The domain's `defineSchema` map is authoritative for each member's name +
1122
- // interface-vs-class kind, so an aliased interface (`iUser`→`User`) and a class
1123
- // sharing its name (`User`) each anchor correctly instead of colliding.
1124
- const memberNames = buildMemberNameMap(sourceFiles)
1125
-
1126
- for (const sf of sourceFiles) {
1127
- const fileRel = relToRoot(domainRoot, sf.getFilePath())
1128
-
1129
- for (const v of sf.getVariableDeclarations()) {
1130
- if (!v.isExported()) continue
1131
- const init = v.getInitializer()
1132
- if (!init || !Node.isCallExpression(init)) continue
1133
- const helperKind = declarationHelper(init)
1134
- if (!helperKind) continue
1135
- const valueKey = memberValueKey(init)
1136
- const member =
1137
- (valueKey ? memberNames.byValue.get(valueKey) : undefined) ??
1138
- memberNames.byIdentifier.get(v.getName())
1139
- const name = member?.schemaName ?? v.getName()
1140
- const ns = resolveMemberKind(ir, name, member?.section, helperKind)
1141
-
1142
- const stmt = v.getVariableStatement() ?? v
1143
- spans[`${ns}.${name}`] = makeSpan(domainRoot, fileRel, stmt, v)
1144
-
1145
- // The single object-literal argument: nodeClass({ properties, methods })
1146
- // and current edgeClass.directed({ source, target, properties }).
1147
- const cfgArg = init.getArguments()[0]
1148
- if (ns !== 'function' && cfgArg && Node.isObjectLiteralExpression(cfgArg)) {
1149
- collectPropsAndMethods(spans, ns, name, cfgArg, domainRoot, fileRel)
1150
- }
1151
-
1152
- // Edges: endpoints are the first two args; props live in the third.
1153
- if (ns === 'edge') {
1154
- collectEdge(spans, name, init, domainRoot, fileRel)
1155
- }
1156
- }
1157
- }
1158
-
1159
- return spans
1160
- }
1161
-
1162
- /** Build a SourceSpan, harvesting leading doc from the declaration `docNode`. */
1163
- function makeSpan(domainRoot: string, fileRel: string, spanNode: Node, docNode: Node): SourceSpan {
1164
- const span: SourceSpan = {
1165
- file: fileRel,
1166
- startLine: spanNode.getStartLineNumber(),
1167
- endLine: spanNode.getEndLineNumber(),
1168
- }
1169
- const doc = leadingDoc(docNode) ?? leadingDoc(spanNode)
1170
- if (doc) span.doc = doc
1171
- return span
1172
- }
1173
-
1174
- /** Record `<ns>.<Name>.property.<p>` and `.method.<m>` from a config object. */
1175
- function collectPropsAndMethods(
1176
- spans: Record<string, SourceSpan>,
1177
- ns: string,
1178
- name: string,
1179
- cfg: Node,
1180
- domainRoot: string,
1181
- fileRel: string,
1182
- ): void {
1183
- if (!Node.isObjectLiteralExpression(cfg)) return
1184
- const propsObj = getObjectProp(cfg, 'properties') ?? getObjectProp(cfg, 'props')
1185
- if (propsObj) {
1186
- for (const p of propsObj.getProperties()) {
1187
- const pName = propertyKey(p)
1188
- if (!pName) continue
1189
- spans[`${ns}.${name}.property.${pName}`] = makeSpan(domainRoot, fileRel, p, p)
1190
- }
1191
- }
1192
- const methodsObj = getObjectProp(cfg, 'methods')
1193
- if (methodsObj) {
1194
- for (const m of methodsObj.getProperties()) {
1195
- const mName = propertyKey(m)
1196
- if (!mName) continue
1197
- spans[`${ns}.${name}.method.${mName}`] = makeSpan(domainRoot, fileRel, m, m)
1198
- }
1199
- }
1200
- }
1201
-
1202
- /** Record edge endpoint spans `edge.<Name>.endpoint.<role>` from the two args. */
1203
- function collectEdge(
1204
- spans: Record<string, SourceSpan>,
1205
- name: string,
1206
- init: CallExpression,
1207
- domainRoot: string,
1208
- fileRel: string,
1209
- ): void {
1210
- const argsList = init.getArguments()
1211
- const expression = init.getExpression()
1212
- const currentObjectForm =
1213
- Node.isPropertyAccessExpression(expression) &&
1214
- (expression.getName() === 'directed' || expression.getName() === 'undirected') &&
1215
- argsList[0] &&
1216
- Node.isObjectLiteralExpression(argsList[0])
1217
-
1218
- if (currentObjectForm) {
1219
- const config = argsList[0]
1220
- if (Node.isObjectLiteralExpression(config)) {
1221
- for (const endpointName of ['source', 'target'] as const) {
1222
- const ep = getObjectProp(config, endpointName)
1223
- if (!ep) continue
1224
- const role = stringLiteralOfProp(ep, 'as') ?? stringLiteralOfProp(ep, 'role')
1225
- if (role) spans[`edge.${name}.endpoint.${role}`] = makeSpan(domainRoot, fileRel, ep, ep)
1226
- }
1227
- const properties = getObjectProp(config, 'properties') ?? getObjectProp(config, 'props')
1228
- if (properties) {
1229
- for (const p of properties.getProperties()) {
1230
- const pName = propertyKey(p)
1231
- if (!pName) continue
1232
- spans[`edge.${name}.property.${pName}`] = makeSpan(domainRoot, fileRel, p, p)
1233
- }
1234
- }
1235
- }
1236
- return
1237
- }
1238
-
1239
- for (let i = 0; i < Math.min(2, argsList.length); i++) {
1240
- const ep = argsList[i]
1241
- if (!Node.isObjectLiteralExpression(ep)) continue
1242
- const role = stringLiteralOfProp(ep, 'as')
1243
- if (!role) continue
1244
- spans[`edge.${name}.endpoint.${role}`] = makeSpan(domainRoot, fileRel, ep, ep)
1245
- }
1246
- // Legacy edge props live in the third (config) arg.
1247
- const cfgArg = argsList[2]
1248
- if (cfgArg && Node.isObjectLiteralExpression(cfgArg)) {
1249
- const propsObj = getObjectProp(cfgArg, 'properties') ?? getObjectProp(cfgArg, 'props')
1250
- if (propsObj) {
1251
- for (const p of propsObj.getProperties()) {
1252
- const pName = propertyKey(p)
1253
- if (!pName) continue
1254
- spans[`edge.${name}.property.${pName}`] = makeSpan(domainRoot, fileRel, p, p)
1255
- }
1256
- }
1257
- }
1258
- }
1259
-
1260
- /** The object-literal value of a named property, if it is itself an object. */
1261
- function getObjectProp(
1262
- obj: Node,
1263
- name: string,
1264
- ): import('ts-morph').ObjectLiteralExpression | undefined {
1265
- if (!Node.isObjectLiteralExpression(obj)) return undefined
1266
- const prop = obj.getProperty(name)
1267
- if (!prop) return undefined
1268
- let value: Node | undefined
1269
- if (Node.isPropertyAssignment(prop)) value = prop.getInitializer()
1270
- else if (Node.isShorthandPropertyAssignment(prop)) value = prop.getNameNode()
1271
- return value ? resolveObjectLiteral(value) : undefined
1272
- }
1273
-
1274
- /** A string-literal property value, e.g. `as: 'page'` → 'page'. */
1275
- function stringLiteralOfProp(obj: Node, name: string): string | undefined {
1276
- if (!Node.isObjectLiteralExpression(obj)) return undefined
1277
- const prop = obj.getProperty(name)
1278
- if (!prop || !Node.isPropertyAssignment(prop)) return undefined
1279
- const v = prop.getInitializer()
1280
- if (v && (Node.isStringLiteral(v) || Node.isNoSubstitutionTemplateLiteral(v)))
1281
- return v.getLiteralText()
1282
- return undefined
1283
- }
1284
-
1285
- /** The key name of an object-literal property (assignment / shorthand / method). */
1286
- function propertyKey(prop: Node): string | undefined {
1287
- if (
1288
- Node.isPropertyAssignment(prop) ||
1289
- Node.isShorthandPropertyAssignment(prop) ||
1290
- Node.isMethodDeclaration(prop)
1291
- ) {
1292
- const nameNode = prop.getNameNode()
1293
- if (Node.isStringLiteral(nameNode)) return nameNode.getLiteralText()
1294
- if (Node.isComputedPropertyName(nameNode)) return undefined // skip `[expr]: …`
1295
- return nameNode.getText()
1296
- }
1297
- // Spread (`...knobs`) — no single key.
1298
- return undefined
1299
- }
1300
-
1301
- // ───────────────────────────── annotations ─────────────────────────────
1302
-
1303
- export function buildSchemaAnnotations(_args: { ir: SchemaIR | null }): SchemaAnnotation[] {
1304
- // The current Kernel admits updated values against their exact Value Schema;
1305
- // enum properties therefore need no Studio-specific compatibility warning.
1306
- return []
1307
- }
7
+ export { buildSchemaAnnotations } from './source-overlay/annotations'
8
+ export { buildHandlerLinks } from './source-overlay/handlers'
9
+ export { buildSourceSpans } from './source-overlay/spans'