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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/README.md +7 -11
  2. package/dist/astrale.js +3769 -3549
  3. package/dist/public/connect-core.js +396 -392
  4. package/dist/public/keys/index.js +371 -378
  5. package/dist/public/paths/index.js +371 -378
  6. package/dist/types/lib/idp.d.ts +6 -0
  7. package/dist/types/lib/meta.d.ts +2 -2
  8. package/package.json +6 -4
  9. package/src/admin/binding.ts +168 -0
  10. package/src/admin/catalog/__tests__/client.test.ts +2 -2
  11. package/src/admin/catalog/client.ts +4 -9
  12. package/src/admin/instance/__tests__/client.test.ts +5 -3
  13. package/src/admin/instance/client.ts +9 -27
  14. package/src/commands/__tests__/auth-login.test.ts +54 -1
  15. package/src/commands/__tests__/call.test.ts +5 -1
  16. package/src/commands/__tests__/domain-install-operation.test.ts +23 -7
  17. package/src/commands/__tests__/domain-install-owned.test.ts +5 -1
  18. package/src/commands/__tests__/domain-list.test.ts +83 -4
  19. package/src/commands/__tests__/install-identity-override.test.ts +30 -19
  20. package/src/commands/__tests__/studio-descriptor.test.ts +22 -0
  21. package/src/commands/__tests__/update.test.ts +73 -0
  22. package/src/commands/domain/install.ts +11 -15
  23. package/src/commands/domain/list.ts +17 -12
  24. package/src/commands/studio.ts +20 -0
  25. package/src/commands/update.ts +39 -13
  26. package/src/connection/__tests__/errors.test.ts +16 -12
  27. package/src/connection/__tests__/exchange.test.ts +6 -5
  28. package/src/connection/exchange.ts +5 -4
  29. package/src/lib/__tests__/domain-publication.test.ts +144 -0
  30. package/src/lib/__tests__/idp-session.test.ts +22 -0
  31. package/src/lib/__tests__/idp.test.ts +2 -0
  32. package/src/lib/__tests__/instance.test.ts +6 -0
  33. package/src/lib/__tests__/studio-server-deps.test.ts +53 -16
  34. package/src/lib/__tests__/update.test.ts +12 -0
  35. package/src/lib/domain-publication.ts +102 -0
  36. package/src/lib/idp.ts +5 -1
  37. package/src/lib/instance.ts +6 -1
  38. package/src/lib/login-flow.ts +13 -3
  39. package/src/lib/meta.ts +2 -2
  40. package/src/lib/update.ts +4 -4
  41. package/src/program/__tests__/program.test.ts +1 -1
  42. package/studio/client/dist/assets/{elk-api-D2xgMJvi.js → elk-api-lwhFo7vB.js} +1 -1
  43. package/studio/client/dist/assets/index-B-c-smo9.js +8 -0
  44. package/studio/client/dist/assets/index-BckHuAWk.js +81 -0
  45. package/studio/client/dist/assets/index-C4aNQ6dz.css +1 -0
  46. package/studio/client/dist/index.html +2 -2
  47. package/studio/package.json +3 -1
  48. package/studio/server/agent/bridge/stdio.ts +8 -1
  49. package/studio/server/agent/conversation.test.ts +10 -5
  50. package/studio/server/agent/conversation.ts +56 -8
  51. package/studio/server/agent/harness/codex/loadout.ts +18 -21
  52. package/studio/server/agent/harness/gateway/config.ts +20 -18
  53. package/studio/server/agent/harness/selection.ts +9 -1
  54. package/studio/server/agent/prompts/system.ts +3 -2
  55. package/studio/server/agent/prompts/turn.ts +13 -4
  56. package/studio/server/agent/run/completion.ts +2 -2
  57. package/studio/server/agent/run/preparation.ts +6 -5
  58. package/studio/server/agent/run/transcript.test.ts +35 -1
  59. package/studio/server/agent/run/transcript.ts +169 -3
  60. package/studio/server/agent/run/usage.test.ts +24 -0
  61. package/studio/server/agent/run/usage.ts +26 -1
  62. package/studio/server/api/canvas.ts +44 -0
  63. package/studio/server/api/comments.ts +85 -0
  64. package/studio/server/api/context.ts +61 -0
  65. package/studio/server/api/deployment.ts +39 -0
  66. package/studio/server/api/documents.ts +55 -0
  67. package/studio/server/api/domain.ts +59 -0
  68. package/studio/server/api/http.ts +45 -0
  69. package/studio/server/api/project.ts +57 -0
  70. package/studio/server/api/schema.ts +12 -0
  71. package/studio/server/api/views.ts +57 -0
  72. package/studio/server/api/workspace.ts +60 -0
  73. package/studio/server/api.test.ts +147 -0
  74. package/studio/server/api.ts +20 -422
  75. package/studio/server/cache.test.ts +111 -0
  76. package/studio/server/cache.ts +163 -9
  77. package/studio/server/cli-consumers.test.ts +494 -0
  78. package/studio/server/cli.test.ts +88 -0
  79. package/studio/server/cli.ts +240 -0
  80. package/studio/server/client-package.ts +25 -1
  81. package/studio/server/domain.test.ts +30 -2
  82. package/studio/server/domain.ts +14 -2
  83. package/studio/server/environment/dotenv-preview.test.ts +39 -0
  84. package/studio/server/environment/dotenv-preview.ts +27 -0
  85. package/studio/server/environment/files.test.ts +53 -0
  86. package/studio/server/{state/env.ts → environment/files.ts} +7 -48
  87. package/studio/server/{state → handoff}/copy.ts +21 -6
  88. package/studio/server/handoff/service.test.ts +22 -0
  89. package/studio/server/{state/handoff.ts → handoff/service.ts} +21 -14
  90. package/studio/server/instances/active.ts +72 -0
  91. package/studio/server/instances/deploy-record.test.ts +31 -0
  92. package/studio/server/instances/deploy-record.ts +37 -0
  93. package/studio/server/instances/deploy.ts +56 -0
  94. package/studio/server/{state/instance.test.ts → instances/probe.test.ts} +25 -21
  95. package/studio/server/instances/probe.ts +67 -0
  96. package/studio/server/instances/status.test.ts +12 -0
  97. package/studio/server/instances/status.ts +49 -0
  98. package/studio/server/introspect/anatomy/client-tree.ts +57 -0
  99. package/studio/server/introspect/anatomy/env-fields.ts +66 -0
  100. package/studio/server/introspect/anatomy/schema-definition.ts +53 -0
  101. package/studio/server/introspect/anatomy/source.ts +181 -0
  102. package/studio/server/introspect/anatomy/views/legacy.ts +232 -0
  103. package/studio/server/introspect/anatomy/views/routes.ts +105 -0
  104. package/studio/server/introspect/anatomy/views.ts +58 -0
  105. package/studio/server/introspect/anatomy-extras.test.ts +91 -4
  106. package/studio/server/introspect/anatomy-extras.ts +7 -726
  107. package/studio/server/introspect/anatomy.ts +46 -19
  108. package/studio/server/introspect/bundle.ts +15 -6
  109. package/studio/server/introspect/canonical-schema.test.ts +79 -0
  110. package/studio/server/introspect/canonical-schema.ts +80 -18
  111. package/studio/server/introspect/config-preview.test.ts +32 -0
  112. package/studio/server/introspect/config-preview.ts +119 -0
  113. package/studio/server/introspect/diff.test.ts +7 -11
  114. package/studio/server/introspect/diff.ts +28 -37
  115. package/studio/server/introspect/extractor.ts +8 -6
  116. package/studio/server/introspect/hash.ts +5 -2
  117. package/studio/server/introspect/overlay-tsmorph.ts +7 -1305
  118. package/studio/server/introspect/revision.test.ts +54 -0
  119. package/studio/server/introspect/revision.ts +49 -0
  120. package/studio/server/introspect/runtime.test.ts +63 -5
  121. package/studio/server/introspect/runtime.ts +49 -3
  122. package/studio/server/introspect/schema-ir-json.ts +181 -0
  123. package/studio/server/introspect/source-overlay/annotations.ts +14 -0
  124. package/studio/server/introspect/source-overlay/handlers.ts +681 -0
  125. package/studio/server/introspect/source-overlay/kernel-calls.ts +49 -0
  126. package/studio/server/introspect/source-overlay/project.ts +248 -0
  127. package/studio/server/introspect/source-overlay/spans.ts +360 -0
  128. package/studio/server/json.ts +46 -0
  129. package/studio/server/lifecycle.ts +5 -1
  130. package/studio/server/sse.test.ts +26 -0
  131. package/studio/server/sse.ts +4 -1
  132. package/studio/server/state/baseline.test.ts +212 -1
  133. package/studio/server/state/baseline.ts +145 -29
  134. package/studio/server/state/comments.test.ts +66 -1
  135. package/studio/server/state/comments.ts +173 -10
  136. package/studio/server/state/context.ts +34 -2
  137. package/studio/server/state/documents.ts +42 -1
  138. package/studio/server/state/integrations.ts +27 -1
  139. package/studio/server/state/layout.test.ts +34 -0
  140. package/studio/server/state/layout.ts +44 -17
  141. package/studio/server/state/settings.ts +29 -22
  142. package/studio/server/state/store.test.ts +122 -0
  143. package/studio/server/state/store.ts +66 -17
  144. package/studio/server/state/visibility.ts +22 -7
  145. package/studio/server/views/model.test.ts +57 -0
  146. package/studio/server/views/model.ts +66 -0
  147. package/studio/server/views/runtime.ts +40 -0
  148. package/studio/server/views/selection-repository.test.ts +31 -0
  149. package/studio/server/views/selection-repository.ts +60 -0
  150. package/studio/server/views/session.test.ts +73 -0
  151. package/studio/server/views/session.ts +166 -0
  152. package/studio/server/{state/views.test.ts → views/target.test.ts} +8 -159
  153. package/studio/server/views/target.ts +248 -0
  154. package/studio/server/watch.ts +1 -1
  155. package/studio/server/workspace/catalog.test.ts +17 -0
  156. package/studio/server/workspace/catalog.ts +43 -0
  157. package/studio/server/{state → workspace}/create.test.ts +1 -1
  158. package/studio/server/{state → workspace}/create.ts +1 -1
  159. package/studio/server/{state → workspace}/git.ts +1 -1
  160. package/studio/server/workspace/updates.ts +83 -0
  161. package/studio/server/workspace-state.ts +1 -1
  162. package/studio/shared/contracts/README.md +16 -0
  163. package/studio/shared/contracts/agent.ts +270 -0
  164. package/studio/shared/contracts/runtime.ts +50 -0
  165. package/studio/shared/contracts/schema.ts +462 -0
  166. package/studio/shared/contracts/surface.test.ts +319 -0
  167. package/studio/shared/contracts/workspace.ts +274 -0
  168. package/studio/shared/layout.test.ts +116 -0
  169. package/studio/shared/schema/identity.test.ts +58 -0
  170. package/studio/shared/schema/identity.ts +82 -0
  171. package/studio/shared/types.ts +11 -1099
  172. package/studio/tsconfig.json +1 -1
  173. package/viewer/dist/main.js +35 -35
  174. package/studio/client/dist/assets/index-BMdnsIJA.css +0 -1
  175. package/studio/client/dist/assets/index-D-vRV8w7.js +0 -8
  176. package/studio/client/dist/assets/index-LGSWRrk8.js +0 -81
  177. package/studio/server/state/catalog.ts +0 -117
  178. package/studio/server/state/instance.ts +0 -280
  179. package/studio/server/state/updates.ts +0 -63
  180. package/studio/server/state/views.ts +0 -535
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Kernel-operation token scan for resolved handler source files.
3
+ */
4
+ import { readFileSync } from 'node:fs'
5
+
6
+ type KernelToken = { token: string; label?: string }
7
+
8
+ /** Kernel-op idioms surfaced as `kernelCalls`; entries are longest-first. */
9
+ const KERNEL_TOKENS: KernelToken[] = [
10
+ { token: 'graph.createEdge' },
11
+ { token: 'graph.removeEdge' },
12
+ { token: 'function.mutate' },
13
+ { token: 'graph.children' },
14
+ { token: 'function.get' },
15
+ { token: 'graph.create' },
16
+ { token: 'graph.update' },
17
+ { token: 'graph.remove' },
18
+ { token: 'graph.mutate' },
19
+ { token: 'auth.revoke' },
20
+ { token: 'graph.links' },
21
+ { token: 'auth.grant' },
22
+ { token: 'auth.check' },
23
+ { token: 'graph.tree' },
24
+ { token: 'graph.node' },
25
+ { token: 'revokePerm', label: 'revokePerm (legacy)' },
26
+ { token: 'checkPerm', label: 'checkPerm (legacy)' },
27
+ { token: 'graph.get' },
28
+ { token: 'grantPerm', label: 'grantPerm (legacy)' },
29
+ ]
30
+
31
+ /** Scan handler file text for kernel-op tokens. */
32
+ export function scanKernelCalls(file: string): string[] {
33
+ let text: string
34
+ try {
35
+ text = readFileSync(file, 'utf8')
36
+ } catch {
37
+ return []
38
+ }
39
+ const found: string[] = []
40
+ let work = text
41
+ for (const entry of KERNEL_TOKENS) {
42
+ if (work.includes(entry.token)) {
43
+ found.push(entry.label ?? entry.token)
44
+ // Blank out matches so `::getLinks` doesn't also count as `::getLink`.
45
+ work = work.split(entry.token).join(' '.repeat(entry.token.length))
46
+ }
47
+ }
48
+ return found
49
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Tolerant ts-morph project construction and authored-source resolution.
3
+ *
4
+ * This module owns syntax/value lookup only. It does not interpret handlers or
5
+ * schema members.
6
+ */
7
+ import { existsSync, realpathSync } from 'node:fs'
8
+ import { dirname, isAbsolute, relative, resolve as resolvePath } from 'node:path'
9
+ import { Node, Project, SyntaxKind, type CallExpression, type SourceFile } from 'ts-morph'
10
+
11
+ /** A fresh, in-memory-ish ts-morph project: no tsconfig, tolerant of errors. */
12
+ export function newProject(): Project {
13
+ return new Project({
14
+ useInMemoryFileSystem: false,
15
+ skipFileDependencyResolution: true,
16
+ skipLoadingLibFiles: true,
17
+ compilerOptions: {
18
+ allowJs: true,
19
+ // `allowImportingTsExtensions` keeps `.ts`-suffixed imports from blowing up.
20
+ allowImportingTsExtensions: true,
21
+ },
22
+ })
23
+ }
24
+
25
+ /** Add a file to the project if it exists; returns undefined otherwise. */
26
+ export function tryAddFile(project: Project, file: string): SourceFile | undefined {
27
+ try {
28
+ if (!existsSync(file)) return undefined
29
+ return project.getSourceFile(file) ?? project.addSourceFileAtPath(file)
30
+ } catch {
31
+ return undefined
32
+ }
33
+ }
34
+
35
+ /** Path relative to `root`, POSIX-style ('schema/monitor.ts'), never absolute. */
36
+ export function relToRoot(root: string, file: string): string {
37
+ const abs = isAbsolute(file) ? file : resolvePath(root, file)
38
+ const canonical = (value: string) => {
39
+ try {
40
+ return realpathSync(value)
41
+ } catch {
42
+ return value
43
+ }
44
+ }
45
+ return relative(canonical(root), canonical(abs)).split('\\').join('/')
46
+ }
47
+
48
+ /** The call's callee identifier name, e.g. `method` / `classMethods` / `todo`. */
49
+ export function calleeName(call: CallExpression): string | undefined {
50
+ const expr = call.getExpression()
51
+ if (Node.isIdentifier(expr)) return expr.getText()
52
+ if (Node.isPropertyAccessExpression(expr)) return expr.getName()
53
+ return undefined
54
+ }
55
+
56
+ /** Remove syntax-only wrappers around a runtime value. */
57
+ export function unwrapExpression(node: Node): Node {
58
+ let current = node
59
+ while (
60
+ Node.isParenthesizedExpression(current) ||
61
+ Node.isAsExpression(current) ||
62
+ Node.isSatisfiesExpression(current) ||
63
+ Node.isTypeAssertion(current)
64
+ ) {
65
+ current = current.getExpression()
66
+ }
67
+ return current
68
+ }
69
+
70
+ /** Resolve a local or relatively imported identifier to its runtime value. */
71
+ export function valueOfIdentifier(id: Node): Node | undefined {
72
+ if (!Node.isIdentifier(id)) return undefined
73
+ const decl = firstValueDeclaration(id)
74
+ if (decl && Node.isVariableDeclaration(decl)) return decl.getInitializer()
75
+ return resolveImportedValue(id)
76
+ }
77
+
78
+ /** Follow a named relative import to the exported value that defines it. */
79
+ function resolveImportedValue(id: Node): Node | undefined {
80
+ if (!Node.isIdentifier(id)) return undefined
81
+ const localName = id.getText()
82
+ const sourceFile = id.getSourceFile()
83
+
84
+ for (const imp of sourceFile.getImportDeclarations()) {
85
+ for (const named of imp.getNamedImports()) {
86
+ const local = named.getAliasNode()?.getText() ?? named.getName()
87
+ if (local !== localName) continue
88
+ const target = resolveModuleFile(sourceFile, imp.getModuleSpecifierValue())
89
+ if (!target) return undefined
90
+ return findExportedValue(id.getProject(), target, named.getName(), new Set<string>())
91
+ }
92
+ }
93
+ return undefined
94
+ }
95
+
96
+ /** Find an exported variable/function value, following local and barrel exports. */
97
+ function findExportedValue(
98
+ project: Project,
99
+ file: string,
100
+ exportName: string,
101
+ seen: Set<string>,
102
+ ): Node | undefined {
103
+ const key = `${file}:${exportName}`
104
+ if (seen.has(key)) return undefined
105
+ seen.add(key)
106
+ const sf = tryAddFile(project, file)
107
+ if (!sf) return undefined
108
+
109
+ const localValue = (name: string, exportedOnly: boolean): Node | undefined => {
110
+ const variable = sf
111
+ .getVariableDeclarations()
112
+ .find(
113
+ (candidate) => candidate.getName() === name && (!exportedOnly || candidate.isExported()),
114
+ )
115
+ if (variable) return variable.getInitializer()
116
+ return sf
117
+ .getFunctions()
118
+ .find(
119
+ (candidate) => candidate.getName() === name && (!exportedOnly || candidate.isExported()),
120
+ )
121
+ }
122
+
123
+ const direct = localValue(exportName, true)
124
+ if (direct) return direct
125
+
126
+ for (const ex of sf.getExportDeclarations()) {
127
+ const modSpec = ex.getModuleSpecifierValue()
128
+ const target = modSpec ? resolveModuleFile(sf, modSpec) : undefined
129
+ const named = ex.getNamedExports()
130
+ if (named.length > 0) {
131
+ for (const specifier of named) {
132
+ const exposed = specifier.getAliasNode()?.getText() ?? specifier.getName()
133
+ if (exposed !== exportName) continue
134
+ const original = specifier.getName()
135
+ if (target) return findExportedValue(project, target, original, seen)
136
+ return localValue(original, false)
137
+ }
138
+ } else if (target) {
139
+ const found = findExportedValue(project, target, exportName, seen)
140
+ if (found) return found
141
+ }
142
+ }
143
+ return undefined
144
+ }
145
+
146
+ /** Get the value node of an object-literal property (handles shorthand). */
147
+ export function getProp(obj: Node, name: string): Node | undefined {
148
+ if (!Node.isObjectLiteralExpression(obj)) return undefined
149
+ const prop = obj.getProperty(name)
150
+ if (!prop) return undefined
151
+ if (Node.isPropertyAssignment(prop)) return prop.getInitializer()
152
+ if (Node.isShorthandPropertyAssignment(prop)) return prop.getNameNode()
153
+ if (Node.isMethodDeclaration(prop)) return prop
154
+ return prop
155
+ }
156
+
157
+ /** First value/declaration node a name resolves to (definition, not reference). */
158
+ export function firstValueDeclaration(node: Node): Node | undefined {
159
+ const idNode = Node.isIdentifier(node)
160
+ ? node
161
+ : node.getFirstDescendantByKind(SyntaxKind.Identifier)
162
+ if (!idNode || !Node.isIdentifier(idNode)) return undefined
163
+ const symbol = idNode.getSymbol()
164
+ if (!symbol) return undefined
165
+ const decls = symbol.getDeclarations()
166
+ return decls[0]
167
+ }
168
+
169
+ /** Resolve a relative module specifier to a concrete .ts file path on disk. */
170
+ export function resolveModuleFile(from: SourceFile, spec: string): string | undefined {
171
+ if (!spec.startsWith('.')) {
172
+ // Current SDK projects use package `imports` aliases such as
173
+ // `#actions/risk`. Resolve them from the authored file's directory through
174
+ // Bun so the overlay follows the same project-local map as runtime imports.
175
+ if (!spec.startsWith('#')) return undefined
176
+ try {
177
+ const resolved = Bun.resolveSync(spec, dirname(from.getFilePath()))
178
+ if (existsSync(resolved)) return resolved
179
+ } catch {
180
+ return undefined
181
+ }
182
+ return undefined
183
+ }
184
+ const baseDir = dirname(from.getFilePath())
185
+ const base = resolvePath(baseDir, spec)
186
+ const sourceBase = base.replace(/\.(?:[cm]?js|jsx)$/, '')
187
+ const candidates = [
188
+ base,
189
+ sourceBase.endsWith('.ts') ? sourceBase : `${sourceBase}.ts`,
190
+ sourceBase.endsWith('.tsx') ? sourceBase : `${sourceBase}.tsx`,
191
+ `${base}/index.ts`,
192
+ `${base}/index.tsx`,
193
+ ]
194
+ for (const c of candidates) if (existsSync(c)) return c
195
+ return undefined
196
+ }
197
+
198
+ /** Resolve a value to an object literal, following local/imported identifiers. */
199
+ export function resolveObjectLiteral(
200
+ node: Node,
201
+ seen = new Set<string>(),
202
+ ): import('ts-morph').ObjectLiteralExpression | undefined {
203
+ const value = unwrapExpression(node)
204
+ const key = `${value.getSourceFile().getFilePath()}:${value.getStart()}`
205
+ if (seen.has(key)) return undefined
206
+ seen.add(key)
207
+ if (Node.isObjectLiteralExpression(value)) return value
208
+ if (Node.isIdentifier(value)) {
209
+ const resolved = valueOfIdentifier(value)
210
+ if (resolved) return resolveObjectLiteral(resolved, seen)
211
+ }
212
+ return undefined
213
+ }
214
+
215
+ /** Value carried by an object-literal property, including shorthand/method syntax. */
216
+ export function objectPropertyValue(prop: Node): Node | undefined {
217
+ if (Node.isPropertyAssignment(prop)) return prop.getInitializer()
218
+ if (Node.isShorthandPropertyAssignment(prop)) return prop.getNameNode()
219
+ if (Node.isMethodDeclaration(prop)) return prop
220
+ return undefined
221
+ }
222
+
223
+ /** A string-literal property value, e.g. `as: 'page'` → 'page'. */
224
+ export function stringLiteralOfProp(obj: Node, name: string): string | undefined {
225
+ if (!Node.isObjectLiteralExpression(obj)) return undefined
226
+ const prop = obj.getProperty(name)
227
+ if (!prop || !Node.isPropertyAssignment(prop)) return undefined
228
+ const v = prop.getInitializer()
229
+ if (v && (Node.isStringLiteral(v) || Node.isNoSubstitutionTemplateLiteral(v)))
230
+ return v.getLiteralText()
231
+ return undefined
232
+ }
233
+
234
+ /** The key name of an object-literal property (assignment / shorthand / method). */
235
+ export function propertyKey(prop: Node): string | undefined {
236
+ if (
237
+ Node.isPropertyAssignment(prop) ||
238
+ Node.isShorthandPropertyAssignment(prop) ||
239
+ Node.isMethodDeclaration(prop)
240
+ ) {
241
+ const nameNode = prop.getNameNode()
242
+ if (Node.isStringLiteral(nameNode)) return nameNode.getLiteralText()
243
+ if (Node.isComputedPropertyName(nameNode)) return undefined // skip `[expr]: …`
244
+ return nameNode.getText()
245
+ }
246
+ // Spread (`...knobs`) — no single key.
247
+ return undefined
248
+ }
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Source coordinates and leading documentation for authored schema members.
3
+ */
4
+ import { existsSync } from 'node:fs'
5
+ import { dirname } from 'node:path'
6
+ import { Node, SyntaxKind, type CallExpression, type SourceFile } from 'ts-morph'
7
+
8
+ import type { SchemaIR, SourceSpan } from '../../../shared/types'
9
+
10
+ import {
11
+ calleeName,
12
+ newProject,
13
+ objectPropertyValue,
14
+ propertyKey,
15
+ relToRoot,
16
+ resolveObjectLiteral,
17
+ stringLiteralOfProp,
18
+ unwrapExpression,
19
+ valueOfIdentifier,
20
+ } from './project'
21
+
22
+ /**
23
+ * Harvest the leading JSDoc / line-comment block immediately above `node`,
24
+ * stripped of comment markers, collapsed to a single trimmed string.
25
+ */
26
+ function leadingDoc(node: Node): string | undefined {
27
+ // Prefer real JSDoc nodes when present (ts-morph exposes them on many decls).
28
+ const anyNode = node as unknown as { getJsDocs?: () => Array<{ getText: () => string }> }
29
+ if (typeof anyNode.getJsDocs === 'function') {
30
+ const docs = anyNode.getJsDocs()
31
+ if (docs.length > 0) {
32
+ const text = docs.map((d) => d.getText()).join('\n')
33
+ const cleaned = cleanComment(text)
34
+ if (cleaned) return cleaned
35
+ }
36
+ }
37
+ // Fall back to raw leading comment ranges (covers `//` line comments too).
38
+ const ranges = node.getLeadingCommentRanges()
39
+ if (ranges.length === 0) return undefined
40
+ const raw = ranges.map((r) => r.getText()).join('\n')
41
+ const cleaned = cleanComment(raw)
42
+ return cleaned || undefined
43
+ }
44
+
45
+ /** Strip `/** *​/`, `//`, leading `*` gutters; collapse to a tidy single line. */
46
+ function cleanComment(raw: string): string {
47
+ const lines = raw
48
+ .replace(/\/\*\*?/g, '')
49
+ .replace(/\*\//g, '')
50
+ .split('\n')
51
+ .map((l) =>
52
+ l
53
+ .replace(/^\s*\*\s?/, '')
54
+ .replace(/^\s*\/\/\s?/, '')
55
+ .trim(),
56
+ )
57
+ .filter((l) => l.length > 0)
58
+ return lines.join(' ').replace(/\s+/g, ' ').trim()
59
+ }
60
+
61
+ const DECL_HELPERS: Record<string, 'node' | 'interface' | 'edge' | 'function'> = {
62
+ nodeClass: 'node',
63
+ nodeInterface: 'interface',
64
+ edgeClass: 'edge',
65
+ fn: 'function',
66
+ }
67
+
68
+ /** Recognize both legacy `edgeClass(...)` and current
69
+ * `edgeClass.directed/undirected({...})` authoring forms. */
70
+ function declarationHelper(
71
+ call: CallExpression,
72
+ ): 'node' | 'interface' | 'edge' | 'function' | undefined {
73
+ const direct = calleeName(call)
74
+ if (direct && direct in DECL_HELPERS) return DECL_HELPERS[direct]
75
+ const expression = call.getExpression()
76
+ if (
77
+ Node.isPropertyAccessExpression(expression) &&
78
+ (expression.getName() === 'directed' || expression.getName() === 'undirected') &&
79
+ expression.getExpression().getText() === 'edgeClass'
80
+ ) {
81
+ return 'edge'
82
+ }
83
+ return undefined
84
+ }
85
+
86
+ /**
87
+ * A schema member's true NAME + section, resolved from the `defineSchema` map.
88
+ * The declaration helpers (nodeClass/nodeInterface/edgeClass) carry no name — a
89
+ * member is named by the KEY it is registered under, not by its variable — and a
90
+ * domain may register a class and an interface under the SAME name (the
91
+ * intentional same-name pattern: the `iUser` interface alongside the `User`
92
+ * class). So the map is the sole authority for both the anchor name and the
93
+ * interface-vs-class distinction; the variable identifier alone tells us neither.
94
+ */
95
+ interface MemberName {
96
+ schemaName: string
97
+ section: 'interface' | 'class' | 'function' // the `classes` map also holds edge classes
98
+ }
99
+
100
+ interface MemberNameMap {
101
+ /** Exact declaration initializer, robust to imported/local aliases. */
102
+ byValue: Map<string, MemberName>
103
+ /** Legacy fallback when a symbol cannot be resolved. */
104
+ byIdentifier: Map<string, MemberName>
105
+ }
106
+
107
+ /** Stable source coordinate for the value behind a local/imported alias. */
108
+ function memberValueKey(node: Node, seen = new Set<string>()): string | undefined {
109
+ const value = unwrapExpression(node)
110
+ const key = `${value.getSourceFile().getFilePath()}:${value.getStart()}`
111
+ if (seen.has(key)) return undefined
112
+ seen.add(key)
113
+ if (Node.isIdentifier(value)) {
114
+ const resolved = valueOfIdentifier(value)
115
+ if (resolved) return memberValueKey(resolved, seen)
116
+ }
117
+ return key
118
+ }
119
+
120
+ /** Map each registered member VARIABLE (as referenced in `defineSchema`) to its
121
+ * schema name + section, from the domain's own schema files — never node_modules
122
+ * (a dependency's `defineSchema` is not this domain's). */
123
+ function buildMemberNameMap(files: SourceFile[]): MemberNameMap {
124
+ const map: MemberNameMap = {
125
+ byValue: new Map<string, MemberName>(),
126
+ byIdentifier: new Map<string, MemberName>(),
127
+ }
128
+ for (const sf of files) {
129
+ if (sf.getFilePath().includes('/node_modules/')) continue
130
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
131
+ if (calleeName(call) !== 'defineSchema') continue
132
+ const input = call.getArguments()[1]
133
+ const cfg = input ? resolveObjectLiteral(input) : undefined
134
+ if (!cfg) continue
135
+ collectSchemaSection(map, cfg, 'interfaces', 'interface')
136
+ collectSchemaSection(map, cfg, 'classes', 'class')
137
+ collectSchemaSection(map, cfg, 'functions', 'function')
138
+ }
139
+ }
140
+ return map
141
+ }
142
+
143
+ /** Record `variable → { schemaName, section }` for one `defineSchema` section,
144
+ * handling both `Key: alias` and shorthand `Key`. */
145
+ function collectSchemaSection(
146
+ map: MemberNameMap,
147
+ cfg: Node,
148
+ prop: 'interfaces' | 'classes' | 'functions',
149
+ section: 'interface' | 'class' | 'function',
150
+ ): void {
151
+ const obj = getObjectProp(cfg, prop)
152
+ if (!obj) return
153
+ for (const p of obj.getProperties()) {
154
+ const schemaName = propertyKey(p)
155
+ const memberValue = objectPropertyValue(p)
156
+ if (!schemaName || !memberValue) continue
157
+ const member = { schemaName, section } satisfies MemberName
158
+ const valueKey = memberValueKey(memberValue)
159
+ if (valueKey) map.byValue.set(valueKey, member)
160
+ const unwrapped = unwrapExpression(memberValue)
161
+ if (Node.isIdentifier(unwrapped)) map.byIdentifier.set(unwrapped.getText(), member)
162
+ }
163
+ }
164
+
165
+ /**
166
+ * The anchor namespace ('class' | 'interface' | 'edge') for a declared member.
167
+ * The `defineSchema` SECTION is authoritative for interface-vs-class — a
168
+ * `nodeClass` whose name collides with a same-named interface must still anchor
169
+ * as a class. Within the class section, an `edgeClass` (or an IR edge type)
170
+ * anchors as 'edge'. Falls back to the declaration helper when the member isn't
171
+ * in a parseable `defineSchema` map (e.g. an imported kernel member).
172
+ */
173
+ function resolveMemberKind(
174
+ ir: SchemaIR | null,
175
+ name: string,
176
+ section: 'interface' | 'class' | 'function' | undefined,
177
+ helperKind: 'node' | 'interface' | 'edge' | 'function',
178
+ ): 'class' | 'interface' | 'edge' | 'function' {
179
+ if (section === 'function' || helperKind === 'function') return 'function'
180
+ if (section === 'interface') return 'interface'
181
+ const isEdge = helperKind === 'edge' || ir?.classes?.[name]?.type === 'edge'
182
+ if (section === 'class') return isEdge ? 'edge' : 'class'
183
+ if (helperKind === 'interface') return 'interface'
184
+ return isEdge ? 'edge' : 'class'
185
+ }
186
+
187
+ export function buildSourceSpans(args: {
188
+ ir: SchemaIR | null
189
+ schemaDir: string
190
+ }): Record<string, SourceSpan> {
191
+ const { ir, schemaDir } = args
192
+ if (!schemaDir || !existsSync(schemaDir)) return {}
193
+
194
+ // The domain root is the parent of the schema dir (spans are relative to it).
195
+ const domainRoot = dirname(schemaDir.replace(/\/$/, ''))
196
+
197
+ const project = newProject()
198
+ let files: string[] = []
199
+ try {
200
+ const added = project.addSourceFilesAtPaths(`${schemaDir.replace(/\/$/, '')}/**/*.ts`)
201
+ files = added.map((f) => f.getFilePath())
202
+ } catch {
203
+ return {}
204
+ }
205
+
206
+ const spans: Record<string, SourceSpan> = {}
207
+
208
+ const sourceFiles = files.map((f) => project.getSourceFile(f)).filter((f): f is SourceFile => !!f)
209
+ // The domain's `defineSchema` map is authoritative for each member's name +
210
+ // interface-vs-class kind, so an aliased interface (`iUser`→`User`) and a class
211
+ // sharing its name (`User`) each anchor correctly instead of colliding.
212
+ const memberNames = buildMemberNameMap(sourceFiles)
213
+
214
+ for (const sf of sourceFiles) {
215
+ const fileRel = relToRoot(domainRoot, sf.getFilePath())
216
+
217
+ for (const v of sf.getVariableDeclarations()) {
218
+ if (!v.isExported()) continue
219
+ const init = v.getInitializer()
220
+ if (!init || !Node.isCallExpression(init)) continue
221
+ const helperKind = declarationHelper(init)
222
+ if (!helperKind) continue
223
+ const valueKey = memberValueKey(init)
224
+ const member =
225
+ (valueKey ? memberNames.byValue.get(valueKey) : undefined) ??
226
+ memberNames.byIdentifier.get(v.getName())
227
+ const name = member?.schemaName ?? v.getName()
228
+ const ns = resolveMemberKind(ir, name, member?.section, helperKind)
229
+
230
+ const stmt = v.getVariableStatement() ?? v
231
+ spans[`${ns}.${name}`] = makeSpan(domainRoot, fileRel, stmt, v)
232
+
233
+ // The single object-literal argument: nodeClass({ properties, methods })
234
+ // and current edgeClass.directed({ source, target, properties }).
235
+ const cfgArg = init.getArguments()[0]
236
+ if (ns !== 'function' && cfgArg && Node.isObjectLiteralExpression(cfgArg)) {
237
+ collectPropsAndMethods(spans, ns, name, cfgArg, domainRoot, fileRel)
238
+ }
239
+
240
+ // Edges: endpoints are the first two args; props live in the third.
241
+ if (ns === 'edge') {
242
+ collectEdge(spans, name, init, domainRoot, fileRel)
243
+ }
244
+ }
245
+ }
246
+
247
+ return spans
248
+ }
249
+
250
+ /** Build a SourceSpan, harvesting leading doc from the declaration `docNode`. */
251
+ function makeSpan(domainRoot: string, fileRel: string, spanNode: Node, docNode: Node): SourceSpan {
252
+ const span: SourceSpan = {
253
+ file: fileRel,
254
+ startLine: spanNode.getStartLineNumber(),
255
+ endLine: spanNode.getEndLineNumber(),
256
+ }
257
+ const doc = leadingDoc(docNode) ?? leadingDoc(spanNode)
258
+ if (doc) span.doc = doc
259
+ return span
260
+ }
261
+
262
+ /** Record `<ns>.<Name>.property.<p>` and `.method.<m>` from a config object. */
263
+ function collectPropsAndMethods(
264
+ spans: Record<string, SourceSpan>,
265
+ ns: string,
266
+ name: string,
267
+ cfg: Node,
268
+ domainRoot: string,
269
+ fileRel: string,
270
+ ): void {
271
+ if (!Node.isObjectLiteralExpression(cfg)) return
272
+ const propsObj = getObjectProp(cfg, 'properties') ?? getObjectProp(cfg, 'props')
273
+ if (propsObj) {
274
+ for (const p of propsObj.getProperties()) {
275
+ const pName = propertyKey(p)
276
+ if (!pName) continue
277
+ spans[`${ns}.${name}.property.${pName}`] = makeSpan(domainRoot, fileRel, p, p)
278
+ }
279
+ }
280
+ const methodsObj = getObjectProp(cfg, 'methods')
281
+ if (methodsObj) {
282
+ for (const m of methodsObj.getProperties()) {
283
+ const mName = propertyKey(m)
284
+ if (!mName) continue
285
+ spans[`${ns}.${name}.method.${mName}`] = makeSpan(domainRoot, fileRel, m, m)
286
+ }
287
+ }
288
+ }
289
+
290
+ /** Record edge endpoint spans `edge.<Name>.endpoint.<role>` from the two args. */
291
+ function collectEdge(
292
+ spans: Record<string, SourceSpan>,
293
+ name: string,
294
+ init: CallExpression,
295
+ domainRoot: string,
296
+ fileRel: string,
297
+ ): void {
298
+ const argsList = init.getArguments()
299
+ const expression = init.getExpression()
300
+ const currentObjectForm =
301
+ Node.isPropertyAccessExpression(expression) &&
302
+ (expression.getName() === 'directed' || expression.getName() === 'undirected') &&
303
+ argsList[0] &&
304
+ Node.isObjectLiteralExpression(argsList[0])
305
+
306
+ if (currentObjectForm) {
307
+ const config = argsList[0]
308
+ if (Node.isObjectLiteralExpression(config)) {
309
+ for (const endpointName of ['source', 'target'] as const) {
310
+ const ep = getObjectProp(config, endpointName)
311
+ if (!ep) continue
312
+ const role = stringLiteralOfProp(ep, 'as') ?? stringLiteralOfProp(ep, 'role')
313
+ if (role) spans[`edge.${name}.endpoint.${role}`] = makeSpan(domainRoot, fileRel, ep, ep)
314
+ }
315
+ const properties = getObjectProp(config, 'properties') ?? getObjectProp(config, 'props')
316
+ if (properties) {
317
+ for (const p of properties.getProperties()) {
318
+ const pName = propertyKey(p)
319
+ if (!pName) continue
320
+ spans[`edge.${name}.property.${pName}`] = makeSpan(domainRoot, fileRel, p, p)
321
+ }
322
+ }
323
+ }
324
+ return
325
+ }
326
+
327
+ for (let i = 0; i < Math.min(2, argsList.length); i++) {
328
+ const ep = argsList[i]
329
+ if (!Node.isObjectLiteralExpression(ep)) continue
330
+ const role = stringLiteralOfProp(ep, 'as')
331
+ if (!role) continue
332
+ spans[`edge.${name}.endpoint.${role}`] = makeSpan(domainRoot, fileRel, ep, ep)
333
+ }
334
+ // Legacy edge props live in the third (config) arg.
335
+ const cfgArg = argsList[2]
336
+ if (cfgArg && Node.isObjectLiteralExpression(cfgArg)) {
337
+ const propsObj = getObjectProp(cfgArg, 'properties') ?? getObjectProp(cfgArg, 'props')
338
+ if (propsObj) {
339
+ for (const p of propsObj.getProperties()) {
340
+ const pName = propertyKey(p)
341
+ if (!pName) continue
342
+ spans[`edge.${name}.property.${pName}`] = makeSpan(domainRoot, fileRel, p, p)
343
+ }
344
+ }
345
+ }
346
+ }
347
+
348
+ /** The object-literal value of a named property, if it is itself an object. */
349
+ function getObjectProp(
350
+ obj: Node,
351
+ name: string,
352
+ ): import('ts-morph').ObjectLiteralExpression | undefined {
353
+ if (!Node.isObjectLiteralExpression(obj)) return undefined
354
+ const prop = obj.getProperty(name)
355
+ if (!prop) return undefined
356
+ let value: Node | undefined
357
+ if (Node.isPropertyAssignment(prop)) value = prop.getInitializer()
358
+ else if (Node.isShorthandPropertyAssignment(prop)) value = prop.getNameNode()
359
+ return value ? resolveObjectLiteral(value) : undefined
360
+ }