@astrale-os/cli 0.4.0-alpha.13

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 (219) hide show
  1. package/.check-workspace.cjs +40 -0
  2. package/README.md +151 -0
  3. package/dist/astrale.js +59749 -0
  4. package/package.json +90 -0
  5. package/src/command.ts +40 -0
  6. package/src/commands/__tests__/admin-instance.test.ts +73 -0
  7. package/src/commands/__tests__/auth-login.test.ts +178 -0
  8. package/src/commands/__tests__/auth-token.test.ts +223 -0
  9. package/src/commands/__tests__/call.test.ts +72 -0
  10. package/src/commands/__tests__/domain-list.test.ts +74 -0
  11. package/src/commands/__tests__/help-contract.test.ts +136 -0
  12. package/src/commands/__tests__/install-identity-override.test.ts +65 -0
  13. package/src/commands/__tests__/instance-bookmark.test.ts +101 -0
  14. package/src/commands/__tests__/instance-create-hosts.test.ts +29 -0
  15. package/src/commands/__tests__/instance-list-rows.test.ts +63 -0
  16. package/src/commands/__tests__/logs.test.ts +117 -0
  17. package/src/commands/__tests__/ls.test.ts +25 -0
  18. package/src/commands/__tests__/setup-plan.test.ts +61 -0
  19. package/src/commands/admin/status.ts +61 -0
  20. package/src/commands/admin/use.ts +77 -0
  21. package/src/commands/auth/login.ts +82 -0
  22. package/src/commands/auth/logout.ts +50 -0
  23. package/src/commands/auth/status.ts +86 -0
  24. package/src/commands/auth/token.ts +162 -0
  25. package/src/commands/browser.ts +207 -0
  26. package/src/commands/call.ts +300 -0
  27. package/src/commands/describe.ts +182 -0
  28. package/src/commands/domain/install.ts +420 -0
  29. package/src/commands/domain/list.ts +154 -0
  30. package/src/commands/domain/publish.ts +155 -0
  31. package/src/commands/get.ts +60 -0
  32. package/src/commands/identity/create.ts +26 -0
  33. package/src/commands/identity/delete.ts +18 -0
  34. package/src/commands/identity/export.ts +66 -0
  35. package/src/commands/identity/import.ts +101 -0
  36. package/src/commands/identity/list.ts +56 -0
  37. package/src/commands/identity/register.ts +170 -0
  38. package/src/commands/identity/sync.ts +32 -0
  39. package/src/commands/identity/unsync.ts +24 -0
  40. package/src/commands/identity/use.ts +18 -0
  41. package/src/commands/identity/whoami.ts +34 -0
  42. package/src/commands/idp/add.ts +150 -0
  43. package/src/commands/idp/list.ts +57 -0
  44. package/src/commands/idp/refresh.ts +38 -0
  45. package/src/commands/idp/remove.ts +36 -0
  46. package/src/commands/idp/show.ts +29 -0
  47. package/src/commands/instance/active.ts +64 -0
  48. package/src/commands/instance/bookmark.ts +72 -0
  49. package/src/commands/instance/create.ts +69 -0
  50. package/src/commands/instance/delete.ts +72 -0
  51. package/src/commands/instance/forget.ts +26 -0
  52. package/src/commands/instance/list.ts +149 -0
  53. package/src/commands/instance/status.ts +42 -0
  54. package/src/commands/instance/use.ts +210 -0
  55. package/src/commands/logs.ts +347 -0
  56. package/src/commands/ls.ts +229 -0
  57. package/src/commands/query.ts +32 -0
  58. package/src/commands/setup.ts +54 -0
  59. package/src/commands/status.ts +60 -0
  60. package/src/commands/studio.ts +401 -0
  61. package/src/commands/token.ts +77 -0
  62. package/src/commands/update.ts +267 -0
  63. package/src/commands/use.ts +87 -0
  64. package/src/errors.ts +65 -0
  65. package/src/kernel/__tests__/auth.test.ts +77 -0
  66. package/src/kernel/__tests__/errors.test.ts +43 -0
  67. package/src/kernel/__tests__/remote-routing.test.ts +70 -0
  68. package/src/kernel/auth.ts +234 -0
  69. package/src/kernel/ca-fetch.ts +119 -0
  70. package/src/kernel/client.ts +191 -0
  71. package/src/kernel/errors.ts +280 -0
  72. package/src/kernel/expand.ts +217 -0
  73. package/src/kernel/index.ts +14 -0
  74. package/src/kernel/options.ts +22 -0
  75. package/src/kernel/remote-routing.ts +88 -0
  76. package/src/kernel/run.ts +63 -0
  77. package/src/kernel/types.ts +14 -0
  78. package/src/lib/__tests__/admin-target.test.ts +112 -0
  79. package/src/lib/__tests__/binary.test.ts +56 -0
  80. package/src/lib/__tests__/command-dx.test.ts +58 -0
  81. package/src/lib/__tests__/concurrency.test.ts +62 -0
  82. package/src/lib/__tests__/config.test.ts +53 -0
  83. package/src/lib/__tests__/design.test.ts +99 -0
  84. package/src/lib/__tests__/domain-identity.test.ts +60 -0
  85. package/src/lib/__tests__/format.test.ts +22 -0
  86. package/src/lib/__tests__/fs-atomic.test.ts +104 -0
  87. package/src/lib/__tests__/identity.test.ts +79 -0
  88. package/src/lib/__tests__/idp-session.driver.ts +53 -0
  89. package/src/lib/__tests__/idp-session.test.ts +357 -0
  90. package/src/lib/__tests__/idp.test.ts +385 -0
  91. package/src/lib/__tests__/instance-candidates.test.ts +73 -0
  92. package/src/lib/__tests__/instance-target.test.ts +183 -0
  93. package/src/lib/__tests__/instance.test.ts +136 -0
  94. package/src/lib/__tests__/keys.test.ts +129 -0
  95. package/src/lib/__tests__/local-status.test.ts +202 -0
  96. package/src/lib/__tests__/output.test.ts +150 -0
  97. package/src/lib/__tests__/panel.test.ts +40 -0
  98. package/src/lib/__tests__/port.test.ts +44 -0
  99. package/src/lib/__tests__/prompt.test.ts +25 -0
  100. package/src/lib/__tests__/sdk-deps.test.ts +68 -0
  101. package/src/lib/__tests__/self.test.ts +272 -0
  102. package/src/lib/__tests__/studio-server-deps.test.ts +74 -0
  103. package/src/lib/__tests__/table.test.ts +53 -0
  104. package/src/lib/__tests__/update.test.ts +246 -0
  105. package/src/lib/__tests__/use-target.test.ts +56 -0
  106. package/src/lib/__tests__/validation.test.ts +34 -0
  107. package/src/lib/admin-domain.ts +25 -0
  108. package/src/lib/admin-instance.ts +26 -0
  109. package/src/lib/admin-target.ts +217 -0
  110. package/src/lib/binary.ts +131 -0
  111. package/src/lib/browser.ts +150 -0
  112. package/src/lib/command-dx.ts +161 -0
  113. package/src/lib/concurrency.ts +31 -0
  114. package/src/lib/config.ts +45 -0
  115. package/src/lib/domain-identity.ts +49 -0
  116. package/src/lib/env.ts +49 -0
  117. package/src/lib/format.ts +4 -0
  118. package/src/lib/fs-atomic.ts +126 -0
  119. package/src/lib/identity.ts +256 -0
  120. package/src/lib/idp-session.ts +134 -0
  121. package/src/lib/idp.ts +876 -0
  122. package/src/lib/instance-candidates.ts +49 -0
  123. package/src/lib/instance-target.ts +182 -0
  124. package/src/lib/instance.ts +395 -0
  125. package/src/lib/keys.ts +294 -0
  126. package/src/lib/local-status.ts +152 -0
  127. package/src/lib/log.ts +116 -0
  128. package/src/lib/login-flow.ts +164 -0
  129. package/src/lib/meta.ts +86 -0
  130. package/src/lib/output.ts +222 -0
  131. package/src/lib/panel.ts +61 -0
  132. package/src/lib/paths.ts +11 -0
  133. package/src/lib/port.ts +41 -0
  134. package/src/lib/proc.ts +82 -0
  135. package/src/lib/prompt.ts +136 -0
  136. package/src/lib/provision-instance.ts +170 -0
  137. package/src/lib/sdk-deps.ts +104 -0
  138. package/src/lib/self.ts +166 -0
  139. package/src/lib/skills.ts +171 -0
  140. package/src/lib/table.ts +62 -0
  141. package/src/lib/update.ts +315 -0
  142. package/src/lib/use-target.ts +24 -0
  143. package/src/lib/validation.ts +59 -0
  144. package/src/program.ts +200 -0
  145. package/src/registry.ts +59 -0
  146. package/src/setup/__tests__/util.test.ts +29 -0
  147. package/src/setup/engine.ts +83 -0
  148. package/src/setup/render.ts +109 -0
  149. package/src/setup/steps/admin.ts +78 -0
  150. package/src/setup/steps/agent-browser.ts +81 -0
  151. package/src/setup/steps/auth.ts +54 -0
  152. package/src/setup/steps/domain.ts +68 -0
  153. package/src/setup/steps/index.ts +18 -0
  154. package/src/setup/steps/instance.ts +119 -0
  155. package/src/setup/steps/skills-bridge.ts +70 -0
  156. package/src/setup/steps/skills.ts +59 -0
  157. package/src/setup/types.ts +61 -0
  158. package/src/setup/util.ts +34 -0
  159. package/src/test-utils.ts +18 -0
  160. package/studio/client/dist/assets/index-DOwzZAEK.css +1 -0
  161. package/studio/client/dist/assets/index-wtU0Zxhy.js +183 -0
  162. package/studio/client/dist/index.html +13 -0
  163. package/studio/package.json +62 -0
  164. package/studio/server/agent/ask.ts +68 -0
  165. package/studio/server/agent/bridge-mcp.ts +182 -0
  166. package/studio/server/agent/bridge.ts +188 -0
  167. package/studio/server/agent/claude.ts +666 -0
  168. package/studio/server/agent/mock.ts +186 -0
  169. package/studio/server/agent/prompt.ts +202 -0
  170. package/studio/server/agent/registry.ts +29 -0
  171. package/studio/server/agent/runner.ts +484 -0
  172. package/studio/server/agent/schema-map.ts +112 -0
  173. package/studio/server/agent/types.ts +120 -0
  174. package/studio/server/api.ts +574 -0
  175. package/studio/server/cache.ts +138 -0
  176. package/studio/server/detect.ts +81 -0
  177. package/studio/server/domain.ts +70 -0
  178. package/studio/server/index.ts +136 -0
  179. package/studio/server/introspect/anatomy-extras.ts +398 -0
  180. package/studio/server/introspect/anatomy.ts +108 -0
  181. package/studio/server/introspect/bundle.ts +57 -0
  182. package/studio/server/introspect/core-extractor.ts +119 -0
  183. package/studio/server/introspect/core.ts +44 -0
  184. package/studio/server/introspect/diff.ts +133 -0
  185. package/studio/server/introspect/extractor.ts +102 -0
  186. package/studio/server/introspect/hash.ts +21 -0
  187. package/studio/server/introspect/overlay-tsmorph.ts +874 -0
  188. package/studio/server/introspect/overlay.ts +57 -0
  189. package/studio/server/introspect/runtime.ts +99 -0
  190. package/studio/server/introspect/schema-refs.ts +46 -0
  191. package/studio/server/lifecycle.ts +38 -0
  192. package/studio/server/sse.ts +57 -0
  193. package/studio/server/state/baseline.ts +211 -0
  194. package/studio/server/state/catalog.ts +117 -0
  195. package/studio/server/state/comments.ts +321 -0
  196. package/studio/server/state/context.ts +167 -0
  197. package/studio/server/state/copy.ts +156 -0
  198. package/studio/server/state/create.ts +156 -0
  199. package/studio/server/state/documents.ts +70 -0
  200. package/studio/server/state/env.ts +161 -0
  201. package/studio/server/state/git.ts +75 -0
  202. package/studio/server/state/handoff.ts +55 -0
  203. package/studio/server/state/harness-gateway.ts +181 -0
  204. package/studio/server/state/harness-token.ts +0 -0
  205. package/studio/server/state/instance.ts +244 -0
  206. package/studio/server/state/integrations.ts +55 -0
  207. package/studio/server/state/layout.ts +55 -0
  208. package/studio/server/state/settings.ts +39 -0
  209. package/studio/server/state/store.ts +97 -0
  210. package/studio/server/state/updates.ts +63 -0
  211. package/studio/server/state/usage.ts +37 -0
  212. package/studio/server/state/views.ts +138 -0
  213. package/studio/server/state/visibility.ts +33 -0
  214. package/studio/server/watch.ts +81 -0
  215. package/studio/server/workspace-state.ts +26 -0
  216. package/studio/server/workspace-watch.ts +101 -0
  217. package/studio/shared/types.ts +873 -0
  218. package/studio/tsconfig.json +23 -0
  219. package/tsconfig.json +14 -0
@@ -0,0 +1,874 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { dirname, isAbsolute, relative, resolve as resolvePath } from 'node:path'
3
+ /**
4
+ * overlay-tsmorph.ts — ts-morph extraction of the things the IR cannot carry:
5
+ * - handlerLinks: schema method → runtime handler file (follow the `execute`
6
+ * import in runtime/index.ts; do NOT assume a folder/name convention).
7
+ * - sourceSpans: file:line + JSDoc for each class/interface/edge/prop/method,
8
+ * keyed by anchor ref (class.X / class.X.property.y / class.X.method.m / …).
9
+ * - annotations: sharp-edge hints (ENUM_DROPPED_BY_UPDATE).
10
+ *
11
+ * Implementation notes:
12
+ * - We open files with a throwaway ts-morph Project (no tsconfig, no type
13
+ * checker required) so this stays cheap and tolerant of broken trees.
14
+ * - Everything degrades gracefully: a missing file/dir yields [] / {}, an
15
+ * unresolvable handler yields `{ unlinked: true }` rather than a wrong guess.
16
+ */
17
+ import { Node, Project, SyntaxKind, type CallExpression, type SourceFile } from 'ts-morph'
18
+
19
+ import type { HandlerLink, SchemaAnnotation, SchemaIR, SourceSpan } from '../../shared/types'
20
+
21
+ // ───────────────────────────── shared helpers ─────────────────────────────
22
+
23
+ /** A fresh, in-memory-ish ts-morph project: no tsconfig, tolerant of errors. */
24
+ function newProject(): Project {
25
+ return new Project({
26
+ useInMemoryFileSystem: false,
27
+ skipFileDependencyResolution: true,
28
+ skipLoadingLibFiles: true,
29
+ compilerOptions: {
30
+ allowJs: true,
31
+ // `allowImportingTsExtensions` keeps `.ts`-suffixed imports from blowing up.
32
+ allowImportingTsExtensions: true,
33
+ },
34
+ })
35
+ }
36
+
37
+ /** Add a file to the project if it exists; returns undefined otherwise. */
38
+ function tryAddFile(project: Project, file: string): SourceFile | undefined {
39
+ try {
40
+ if (!existsSync(file)) return undefined
41
+ return project.addSourceFileAtPath(file)
42
+ } catch {
43
+ return undefined
44
+ }
45
+ }
46
+
47
+ /** Path relative to `root`, POSIX-style ('schema/monitor.ts'), never absolute. */
48
+ function relToRoot(root: string, file: string): string {
49
+ const abs = isAbsolute(file) ? file : resolvePath(root, file)
50
+ return relative(root, abs).split('\\').join('/')
51
+ }
52
+
53
+ /** 1-based start line of a node. */
54
+ function startLine(node: Node): number {
55
+ return node.getStartLineNumber()
56
+ }
57
+
58
+ /**
59
+ * Harvest the leading JSDoc / line-comment block immediately above `node`,
60
+ * stripped of comment markers, collapsed to a single trimmed string.
61
+ */
62
+ function leadingDoc(node: Node): string | undefined {
63
+ // Prefer real JSDoc nodes when present (ts-morph exposes them on many decls).
64
+ const anyNode = node as unknown as { getJsDocs?: () => Array<{ getText: () => string }> }
65
+ if (typeof anyNode.getJsDocs === 'function') {
66
+ const docs = anyNode.getJsDocs()
67
+ if (docs.length > 0) {
68
+ const text = docs.map((d) => d.getText()).join('\n')
69
+ const cleaned = cleanComment(text)
70
+ if (cleaned) return cleaned
71
+ }
72
+ }
73
+ // Fall back to raw leading comment ranges (covers `//` line comments too).
74
+ const ranges = node.getLeadingCommentRanges()
75
+ if (ranges.length === 0) return undefined
76
+ const raw = ranges.map((r) => r.getText()).join('\n')
77
+ const cleaned = cleanComment(raw)
78
+ return cleaned || undefined
79
+ }
80
+
81
+ /** Strip `/** *​/`, `//`, leading `*` gutters; collapse to a tidy single line. */
82
+ function cleanComment(raw: string): string {
83
+ const lines = raw
84
+ .replace(/\/\*\*?/g, '')
85
+ .replace(/\*\//g, '')
86
+ .split('\n')
87
+ .map((l) =>
88
+ l
89
+ .replace(/^\s*\*\s?/, '')
90
+ .replace(/^\s*\/\/\s?/, '')
91
+ .trim(),
92
+ )
93
+ .filter((l) => l.length > 0)
94
+ return lines.join(' ').replace(/\s+/g, ' ').trim()
95
+ }
96
+
97
+ /** The call's callee identifier name, e.g. `method` / `classMethods` / `todo`. */
98
+ function calleeName(call: CallExpression): string | undefined {
99
+ const expr = call.getExpression()
100
+ if (Node.isIdentifier(expr)) return expr.getText()
101
+ if (Node.isPropertyAccessExpression(expr)) return expr.getName()
102
+ return undefined
103
+ }
104
+
105
+ /** Unwrap a string literal argument to its value. */
106
+ function stringArg(call: CallExpression, index: number): string | undefined {
107
+ const arg = call.getArguments()[index]
108
+ if (!arg) return undefined
109
+ if (Node.isStringLiteral(arg) || Node.isNoSubstitutionTemplateLiteral(arg)) {
110
+ return arg.getLiteralText()
111
+ }
112
+ return undefined
113
+ }
114
+
115
+ // ───────────────────────────── handler links ─────────────────────────────
116
+
117
+ /**
118
+ * The shape of a "wire one method" call we recognise, after normalising the two
119
+ * fixture styles:
120
+ * my-domain: method(schema, 'Owner', 'name', { authorize, execute })
121
+ * evaluation: classMethods(schema, 'Owner', { name: todo('Owner.name'), … })
122
+ */
123
+ interface WiredMethod {
124
+ owner: string
125
+ method: string
126
+ /** the object literal / call expression carrying the handler config */
127
+ config: Node
128
+ wiringLine: number
129
+ }
130
+
131
+ const SINGLE_METHOD_HELPERS = new Set(['method', 'remoteMethod'])
132
+ const GROUP_HELPERS = new Set([
133
+ 'classMethods',
134
+ 'interfaceMethods',
135
+ 'remoteClassMethods',
136
+ 'remoteInterfaceMethods',
137
+ ])
138
+
139
+ /** Kernel-op tokens we surface as `kernelCalls`, longest-first to avoid overlap. */
140
+ const KERNEL_TOKENS = [
141
+ '::getLinks',
142
+ '::getLink',
143
+ '::update',
144
+ '::link',
145
+ '::create',
146
+ 'createNode',
147
+ 'grantPerm',
148
+ ]
149
+
150
+ /**
151
+ * Resolve a method-config node (object literal `{ authorize, execute }` or a
152
+ * call like `todo('…')`) down to: the symbol that carries the real logic, plus
153
+ * whether it is a NotImplemented stub and the authorize flavour.
154
+ */
155
+ interface ConfigResolution {
156
+ /** the node whose `execute` body we follow to a handler file */
157
+ executeNode?: Node
158
+ implemented: boolean
159
+ /** declared auth policy; defaults to 'required' when the prop is absent. */
160
+ auth?: 'public' | 'optional' | 'required'
161
+ /** authorize hook shape: 'absent' | 'noop' | 'custom'. */
162
+ authorize?: 'absent' | 'noop' | 'custom'
163
+ authorizeSnippet?: string
164
+ unlinkedReason?: 'no-config'
165
+ }
166
+
167
+ function resolveConfigObject(configNode: Node): ConfigResolution {
168
+ // Case A: a call expression, e.g. `todo('Owner.name')`. Follow the helper to
169
+ // its returned object literal (the local `todo` factory in evaluation).
170
+ if (Node.isCallExpression(configNode)) {
171
+ const obj = followFactoryToObject(configNode)
172
+ if (obj) return classifyObjectLiteral(obj)
173
+ return { implemented: false, unlinkedReason: 'no-config' }
174
+ }
175
+ if (Node.isObjectLiteralExpression(configNode)) {
176
+ return classifyObjectLiteral(configNode)
177
+ }
178
+ // An identifier referencing a config defined elsewhere — try to resolve it.
179
+ if (Node.isIdentifier(configNode)) {
180
+ const decl = firstValueDeclaration(configNode)
181
+ if (decl && Node.isVariableDeclaration(decl)) {
182
+ const init = decl.getInitializer()
183
+ if (init) return resolveConfigObject(init)
184
+ }
185
+ }
186
+ return { implemented: false, unlinkedReason: 'no-config' }
187
+ }
188
+
189
+ /** Given `todo('x')`, find the factory's `return { … }` object literal. */
190
+ function followFactoryToObject(call: CallExpression): Node | undefined {
191
+ const expr = call.getExpression()
192
+ if (!Node.isIdentifier(expr)) return undefined
193
+ const decl = firstValueDeclaration(expr)
194
+ if (!decl) return undefined
195
+ let body: Node | undefined
196
+ if (Node.isVariableDeclaration(decl)) {
197
+ body = decl.getInitializer()
198
+ } else if (Node.isFunctionDeclaration(decl)) {
199
+ body = decl
200
+ }
201
+ if (!body) return undefined
202
+ // Arrow returning an object literal directly: `(name) => ({ … })`.
203
+ if (Node.isArrowFunction(body)) {
204
+ const arrowBody = body.getBody()
205
+ if (Node.isParenthesizedExpression(arrowBody)) {
206
+ const inner = arrowBody.getExpression()
207
+ if (Node.isObjectLiteralExpression(inner)) return inner
208
+ }
209
+ if (Node.isObjectLiteralExpression(arrowBody)) return arrowBody
210
+ // Block body with a `return { … }`.
211
+ const ret = arrowBody.getFirstDescendantByKind?.(SyntaxKind.ReturnStatement)
212
+ const retExpr = ret?.getExpression()
213
+ if (retExpr && Node.isObjectLiteralExpression(retExpr)) return retExpr
214
+ }
215
+ if (Node.isFunctionDeclaration(body) || Node.isFunctionExpression(body)) {
216
+ const ret = body.getFirstDescendantByKind(SyntaxKind.ReturnStatement)
217
+ const retExpr = ret?.getExpression()
218
+ if (retExpr && Node.isObjectLiteralExpression(retExpr)) return retExpr
219
+ }
220
+ return undefined
221
+ }
222
+
223
+ /** Inspect `{ auth, authorize, execute }` for the auth policy, authorize shape,
224
+ * parsed permission checks, source snippets, and execute stub-ness. */
225
+ function classifyObjectLiteral(obj: Node): ConfigResolution {
226
+ if (!Node.isObjectLiteralExpression(obj)) {
227
+ return { implemented: false, unlinkedReason: 'no-config' }
228
+ }
229
+ const executeProp = getProp(obj, 'execute')
230
+ const authorizeProp = getProp(obj, 'authorize')
231
+
232
+ const auth = authPolicy(obj)
233
+ const authorize = authorizeProp ? authorizeFlavour(authorizeProp) : 'absent'
234
+ const authorizeSnippet = authorizeProp ? snippetOf(authorizeProp) : undefined
235
+ const common = { auth, authorize, authorizeSnippet } as const
236
+
237
+ if (!executeProp) {
238
+ return { implemented: false, ...common, unlinkedReason: 'no-config' }
239
+ }
240
+ const implemented = !isStubFunction(executeProp)
241
+ return { executeNode: executeProp, implemented, ...common }
242
+ }
243
+
244
+ /** The declared `auth` policy on a method config; defaults to 'required'. */
245
+ function authPolicy(obj: Node): 'public' | 'optional' | 'required' {
246
+ const v = stringLiteralOfProp(obj, 'auth')
247
+ return v === 'public' || v === 'optional' ? v : 'required'
248
+ }
249
+
250
+ const SNIPPET_CAP = 1200
251
+
252
+ /** Source text of a node, capped for a hover preview. */
253
+ function snippetOf(node: Node): string {
254
+ const t = node.getText()
255
+ return t.length > SNIPPET_CAP ? `${t.slice(0, SNIPPET_CAP)}\n/* … */` : t
256
+ }
257
+
258
+ /** Get the value node of an object-literal property (handles shorthand). */
259
+ function getProp(obj: Node, name: string): Node | undefined {
260
+ if (!Node.isObjectLiteralExpression(obj)) return undefined
261
+ const prop = obj.getProperty(name)
262
+ if (!prop) return undefined
263
+ if (Node.isPropertyAssignment(prop)) return prop.getInitializer()
264
+ if (Node.isShorthandPropertyAssignment(prop)) return prop.getNameNode()
265
+ if (Node.isMethodDeclaration(prop)) return prop
266
+ return prop
267
+ }
268
+
269
+ /** 'noop' when the authorize body just returns undefined/void, else 'custom'.
270
+ * Follows identifier references (e.g. `authorize: allow` → `const allow = …`). */
271
+ function authorizeFlavour(node: Node): 'noop' | 'custom' {
272
+ const fnBody = functionBodyOf(node)
273
+ if (fnBody === undefined) return 'custom' // opaque (unresolved identifier) → assume real
274
+ if (fnBody === null) return 'noop' // expression arrow returning `undefined`
275
+ const text = fnBody.getText().replace(/[{}]/g, '').trim()
276
+ // Empty block, or a single `return;` / `return undefined;`.
277
+ if (text === '' || /^return\s*(undefined)?\s*;?$/.test(text)) return 'noop'
278
+ return 'custom'
279
+ }
280
+
281
+ /**
282
+ * For a function-like node return its block body (Node), or `null` if it is an
283
+ * expression-bodied arrow whose expression is `undefined`/void, or `undefined`
284
+ * if it is not function-like.
285
+ */
286
+ function functionBodyOf(node: Node): Node | null | undefined {
287
+ let fn: Node | undefined
288
+ if (Node.isArrowFunction(node) || Node.isFunctionExpression(node)) fn = node
289
+ else if (Node.isMethodDeclaration(node)) fn = node
290
+ else if (Node.isIdentifier(node)) {
291
+ const decl = firstValueDeclaration(node)
292
+ if (decl && Node.isVariableDeclaration(decl)) {
293
+ const init = decl.getInitializer()
294
+ if (init) return functionBodyOf(init)
295
+ }
296
+ return undefined
297
+ }
298
+ if (!fn) return undefined
299
+ const body = (fn as unknown as { getBody?: () => Node | undefined }).getBody?.()
300
+ if (!body) return undefined
301
+ if (Node.isBlock(body)) return body
302
+ // Expression body: `async () => undefined`.
303
+ if (body.getKind() === SyntaxKind.UndefinedKeyword || body.getText() === 'undefined') {
304
+ return null
305
+ }
306
+ return body
307
+ }
308
+
309
+ /** A handler body that is a NotImplemented stub: only throws / `todo()`. */
310
+ function isStubFunction(node: Node): boolean {
311
+ const body = functionBodyOf(node)
312
+ if (!body || body === null) return false
313
+ if (!Node.isBlock(body)) return false
314
+ const statements = body.getStatements()
315
+ if (statements.length === 0) return false
316
+ // Stub iff every statement is a throw, and there is at least one throw.
317
+ let throws = 0
318
+ for (const st of statements) {
319
+ if (Node.isThrowStatement(st)) {
320
+ throws++
321
+ continue
322
+ }
323
+ return false
324
+ }
325
+ return throws > 0
326
+ }
327
+
328
+ /** First value/declaration node a name resolves to (definition, not reference). */
329
+ function firstValueDeclaration(node: Node): Node | undefined {
330
+ const idNode = Node.isIdentifier(node)
331
+ ? node
332
+ : node.getFirstDescendantByKind(SyntaxKind.Identifier)
333
+ if (!idNode || !Node.isIdentifier(idNode)) return undefined
334
+ const symbol = idNode.getSymbol()
335
+ if (!symbol) return undefined
336
+ const decls = symbol.getDeclarations()
337
+ return decls[0]
338
+ }
339
+
340
+ /**
341
+ * Follow an `execute` arrow → the delegated logic function it calls → that
342
+ * function's defining file:line, chasing through barrel re-exports.
343
+ */
344
+ function resolveExecuteTarget(executeNode: Node): { file: string; line: number } | undefined {
345
+ // Find the call inside the execute body that targets an imported symbol.
346
+ const body = functionBodyOf(executeNode)
347
+ const searchRoot = body && Node.isBlock(body) ? body : executeNode
348
+ const calls = searchRoot.getDescendantsOfKind(SyntaxKind.CallExpression)
349
+ for (const call of calls) {
350
+ const expr = call.getExpression()
351
+ if (!Node.isIdentifier(expr)) continue
352
+ const resolved = resolveImportedFunction(expr)
353
+ if (resolved) return resolved
354
+ }
355
+ return undefined
356
+ }
357
+
358
+ /** Resolve an identifier (a delegated logic fn) to its defining file:line. */
359
+ function resolveImportedFunction(id: Node): { file: string; line: number } | undefined {
360
+ if (!Node.isIdentifier(id)) return undefined
361
+ const symbol = id.getSymbol()
362
+ if (!symbol) return undefined
363
+ // Walk through alias chains (import { x as y } / barrel re-export `export { x }`).
364
+ let current = symbol
365
+ const seen = new Set<string>()
366
+ for (let i = 0; i < 12; i++) {
367
+ const decls = current.getDeclarations()
368
+ for (const decl of decls) {
369
+ // A concrete function/variable declaration in a real file → done.
370
+ if (Node.isFunctionDeclaration(decl)) {
371
+ return { file: decl.getSourceFile().getFilePath(), line: decl.getStartLineNumber() }
372
+ }
373
+ if (Node.isVariableDeclaration(decl)) {
374
+ const init = decl.getInitializer()
375
+ if (init && (Node.isArrowFunction(init) || Node.isFunctionExpression(init))) {
376
+ return { file: decl.getSourceFile().getFilePath(), line: decl.getStartLineNumber() }
377
+ }
378
+ }
379
+ }
380
+ // Try to hop to the aliased symbol (import specifier / export specifier).
381
+ const aliased = trySymbolHop(current)
382
+ if (!aliased) break
383
+ const key = aliased
384
+ .getDeclarations()
385
+ .map((d) => d.getSourceFile().getFilePath() + ':' + d.getStartLineNumber())
386
+ .join(',')
387
+ if (seen.has(key)) break
388
+ seen.add(key)
389
+ current = aliased
390
+ }
391
+ // Last resort: resolve the module specifier of the import and grep the file.
392
+ return resolveViaModuleSpecifier(id)
393
+ }
394
+
395
+ /** Hop one alias link via ts-morph's getAliasedSymbol, if any. */
396
+ function trySymbolHop(symbol: import('ts-morph').Symbol): import('ts-morph').Symbol | undefined {
397
+ try {
398
+ const aliased = symbol.getAliasedSymbol?.()
399
+ if (aliased && aliased !== symbol) return aliased
400
+ } catch {
401
+ /* not an alias */
402
+ }
403
+ return undefined
404
+ }
405
+
406
+ /**
407
+ * Fallback resolution: find the import declaration that brought `id` in, load
408
+ * the target module (and barrel re-exports), and locate the exported function.
409
+ */
410
+ function resolveViaModuleSpecifier(id: Node): { file: string; line: number } | undefined {
411
+ if (!Node.isIdentifier(id)) return undefined
412
+ const sourceFile = id.getSourceFile()
413
+ const name = id.getText()
414
+ for (const imp of sourceFile.getImportDeclarations()) {
415
+ // Match the local binding name, capturing the original export name.
416
+ let exportName: string | undefined
417
+ for (const named of imp.getNamedImports()) {
418
+ const local = named.getAliasNode()?.getText() ?? named.getName()
419
+ if (local === name) {
420
+ exportName = named.getName()
421
+ break
422
+ }
423
+ }
424
+ if (!exportName) continue
425
+ const modPath = resolveModuleFile(sourceFile, imp.getModuleSpecifierValue())
426
+ if (!modPath) continue
427
+ const found = findExportInFile(modPath, exportName, new Set())
428
+ if (found) return found
429
+ }
430
+ return undefined
431
+ }
432
+
433
+ /** Resolve a relative module specifier to a concrete .ts file path on disk. */
434
+ function resolveModuleFile(from: SourceFile, spec: string): string | undefined {
435
+ if (!spec.startsWith('.')) return undefined
436
+ const baseDir = dirname(from.getFilePath())
437
+ const base = resolvePath(baseDir, spec)
438
+ const candidates = [
439
+ base.endsWith('.ts') ? base : `${base}.ts`,
440
+ base.endsWith('.tsx') ? base : `${base}.tsx`,
441
+ `${base}/index.ts`,
442
+ `${base}/index.tsx`,
443
+ ]
444
+ for (const c of candidates) if (existsSync(c)) return c
445
+ return undefined
446
+ }
447
+
448
+ /**
449
+ * Find where `exportName` is *defined* within `file`, following barrel
450
+ * `export { x } from './y'` / `export * from './y'` re-exports.
451
+ */
452
+ function findExportInFile(
453
+ file: string,
454
+ exportName: string,
455
+ seen: Set<string>,
456
+ ): { file: string; line: number } | undefined {
457
+ if (seen.has(file)) return undefined
458
+ seen.add(file)
459
+ const project = newProject()
460
+ const sf = tryAddFile(project, file)
461
+ if (!sf) return undefined
462
+
463
+ // 1) A local function declaration with that name.
464
+ for (const fn of sf.getFunctions()) {
465
+ if (fn.getName() === exportName && fn.isExported()) {
466
+ return { file, line: fn.getStartLineNumber() }
467
+ }
468
+ }
469
+ // 2) A local exported variable bound to a function.
470
+ for (const v of sf.getVariableDeclarations()) {
471
+ if (v.getName() === exportName && v.isExported()) {
472
+ return { file, line: v.getStartLineNumber() }
473
+ }
474
+ }
475
+ // 3) Re-export: `export { a, b as exportName } from './mod'`.
476
+ for (const ex of sf.getExportDeclarations()) {
477
+ const modSpec = ex.getModuleSpecifierValue()
478
+ const target = modSpec ? resolveModuleFile(sf, modSpec) : undefined
479
+ const named = ex.getNamedExports()
480
+ if (named.length > 0) {
481
+ for (const ne of named) {
482
+ const exposed = ne.getAliasNode()?.getText() ?? ne.getName()
483
+ if (exposed !== exportName) continue
484
+ const original = ne.getName()
485
+ if (target) {
486
+ const found = findExportInFile(target, original, seen)
487
+ if (found) return found
488
+ }
489
+ }
490
+ } else if (target) {
491
+ // `export * from './mod'` — search the target for the same name.
492
+ const found = findExportInFile(target, exportName, seen)
493
+ if (found) return found
494
+ }
495
+ }
496
+ return undefined
497
+ }
498
+
499
+ /** Scan handler file text for kernel-op tokens. */
500
+ function scanKernelCalls(file: string): string[] {
501
+ let text: string
502
+ try {
503
+ text = readFileSync(file, 'utf8')
504
+ } catch {
505
+ return []
506
+ }
507
+ const found: string[] = []
508
+ let work = text
509
+ for (const token of KERNEL_TOKENS) {
510
+ if (work.includes(token)) {
511
+ found.push(token)
512
+ // Blank out matches so `::getLinks` doesn't also count as `::getLink`.
513
+ work = work.split(token).join(' '.repeat(token.length))
514
+ }
515
+ }
516
+ return found
517
+ }
518
+
519
+ /**
520
+ * Collect every (owner, method, config) wiring from runtime/index.ts, across
521
+ * both fixture styles, then build a HandlerLink per method.
522
+ */
523
+ export function buildHandlerLinks(args: {
524
+ ir: SchemaIR | null
525
+ domainRoot: string
526
+ }): HandlerLink[] {
527
+ const { ir, domainRoot } = args
528
+ if (!domainRoot) return []
529
+ const wiringRel = 'runtime/index.ts'
530
+ const wiringAbs = resolvePath(domainRoot, wiringRel)
531
+ const project = newProject()
532
+ const sf = tryAddFile(project, wiringAbs)
533
+ if (!sf) return []
534
+
535
+ const interfaceNames = new Set(ir ? Object.keys(ir.interfaces ?? {}) : [])
536
+
537
+ const wired: WiredMethod[] = []
538
+
539
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
540
+ const name = calleeName(call)
541
+ if (!name) continue
542
+
543
+ // Style A: single-method helper — method(schema, 'Owner', 'name', { … }).
544
+ if (SINGLE_METHOD_HELPERS.has(name)) {
545
+ const owner = stringArg(call, 1)
546
+ const method = stringArg(call, 2)
547
+ const config = call.getArguments()[3]
548
+ if (owner && method && config) {
549
+ wired.push({ owner, method, config, wiringLine: call.getStartLineNumber() })
550
+ }
551
+ continue
552
+ }
553
+
554
+ // Style B: group helper — classMethods(schema, 'Owner', { name: cfg, … }).
555
+ if (GROUP_HELPERS.has(name)) {
556
+ const owner = stringArg(call, 1)
557
+ const mapArg = call.getArguments()[2]
558
+ if (!owner || !mapArg || !Node.isObjectLiteralExpression(mapArg)) continue
559
+ for (const prop of mapArg.getProperties()) {
560
+ let methodName: string | undefined
561
+ let valueNode: Node | undefined
562
+ if (Node.isPropertyAssignment(prop)) {
563
+ methodName = prop.getName()
564
+ valueNode = prop.getInitializer()
565
+ } else if (Node.isShorthandPropertyAssignment(prop)) {
566
+ methodName = prop.getName()
567
+ valueNode = prop.getNameNode()
568
+ }
569
+ if (!methodName || !valueNode) continue
570
+ wired.push({
571
+ owner,
572
+ method: methodName,
573
+ config: valueNode,
574
+ wiringLine: prop.getStartLineNumber(),
575
+ })
576
+ }
577
+ continue
578
+ }
579
+ }
580
+
581
+ // Deduplicate (owner, method): the group helper restates entries the single
582
+ // helper already produced (e.g. my-domain wires each method, then groups them).
583
+ // Prefer the entry whose config resolves to a real object (the single helper),
584
+ // falling back to the group entry (which references a variable / todo()).
585
+ const byKey = new Map<string, WiredMethod>()
586
+ for (const w of wired) {
587
+ const key = `${w.owner}.${w.method}`
588
+ const existing = byKey.get(key)
589
+ if (!existing) {
590
+ byKey.set(key, w)
591
+ continue
592
+ }
593
+ // Prefer the one whose config is an object literal (richer info).
594
+ const existingIsObj = Node.isObjectLiteralExpression(existing.config)
595
+ const candidateIsObj = Node.isObjectLiteralExpression(w.config)
596
+ if (candidateIsObj && !existingIsObj) byKey.set(key, w)
597
+ }
598
+
599
+ const links: HandlerLink[] = []
600
+ for (const w of byKey.values()) {
601
+ const ownerKind: 'class' | 'interface' = interfaceNames.has(w.owner) ? 'interface' : 'class'
602
+ const isStatic = irMethodStatic(ir, w.owner, w.method, ownerKind)
603
+
604
+ const link: HandlerLink = {
605
+ owner: w.owner,
606
+ ownerKind,
607
+ method: w.method,
608
+ static: isStatic,
609
+ wiringFile: wiringRel,
610
+ wiringLine: w.wiringLine,
611
+ implemented: true,
612
+ }
613
+
614
+ const resolution = resolveConfigObject(w.config)
615
+ link.implemented = resolution.implemented
616
+ if (resolution.auth) link.auth = resolution.auth
617
+ if (resolution.authorize) link.authorize = resolution.authorize
618
+ if (resolution.authorizeSnippet) link.authorizeSnippet = resolution.authorizeSnippet
619
+
620
+ let target: { file: string; line: number } | undefined
621
+ if (resolution.executeNode) {
622
+ target = resolveExecuteTarget(resolution.executeNode)
623
+ }
624
+
625
+ if (target) {
626
+ link.handlerFile = relToRoot(domainRoot, target.file)
627
+ link.handlerLine = target.line
628
+ const kernelCalls = scanKernelCalls(target.file)
629
+ if (kernelCalls.length > 0) link.kernelCalls = kernelCalls
630
+ } else {
631
+ link.unlinked = true
632
+ }
633
+
634
+ links.push(link)
635
+ }
636
+
637
+ // Stable order: by owner, then method.
638
+ links.sort((a, b) =>
639
+ a.owner === b.owner ? a.method.localeCompare(b.method) : a.owner.localeCompare(b.owner),
640
+ )
641
+ return links
642
+ }
643
+
644
+ /** Look up a method's `static` flag from the IR (class or interface bucket). */
645
+ function irMethodStatic(
646
+ ir: SchemaIR | null,
647
+ owner: string,
648
+ method: string,
649
+ ownerKind: 'class' | 'interface',
650
+ ): boolean {
651
+ if (!ir) return false
652
+ const bucket = ownerKind === 'interface' ? ir.interfaces?.[owner] : ir.classes?.[owner]
653
+ const m = bucket?.methods?.[method]
654
+ return m ? m.static === true : false
655
+ }
656
+
657
+ // ───────────────────────────── source spans ─────────────────────────────
658
+
659
+ const DECL_HELPERS: Record<string, 'node' | 'interface' | 'edge'> = {
660
+ nodeClass: 'node',
661
+ nodeInterface: 'interface',
662
+ edgeClass: 'edge',
663
+ }
664
+
665
+ /**
666
+ * Decide the anchor namespace ('class' | 'interface' | 'edge') for a declared
667
+ * name, preferring the IR's authority (an edgeClass is a class whose IR type is
668
+ * 'edge'); fall back to the declaration helper used.
669
+ */
670
+ function anchorKindFor(
671
+ ir: SchemaIR | null,
672
+ name: string,
673
+ helperKind: 'node' | 'interface' | 'edge',
674
+ ): 'class' | 'interface' | 'edge' {
675
+ if (ir) {
676
+ if (ir.interfaces?.[name]) return 'interface'
677
+ const cls = ir.classes?.[name]
678
+ if (cls) return cls.type === 'edge' ? 'edge' : 'class'
679
+ }
680
+ if (helperKind === 'interface') return 'interface'
681
+ if (helperKind === 'edge') return 'edge'
682
+ return 'class'
683
+ }
684
+
685
+ export function buildSourceSpans(args: {
686
+ ir: SchemaIR | null
687
+ schemaDir: string
688
+ }): Record<string, SourceSpan> {
689
+ const { ir, schemaDir } = args
690
+ if (!schemaDir || !existsSync(schemaDir)) return {}
691
+
692
+ // The domain root is the parent of the schema dir (spans are relative to it).
693
+ const domainRoot = dirname(schemaDir.replace(/\/$/, ''))
694
+
695
+ const project = newProject()
696
+ let files: string[] = []
697
+ try {
698
+ const added = project.addSourceFilesAtPaths(`${schemaDir.replace(/\/$/, '')}/**/*.ts`)
699
+ files = added.map((f) => f.getFilePath())
700
+ } catch {
701
+ return {}
702
+ }
703
+
704
+ const spans: Record<string, SourceSpan> = {}
705
+
706
+ for (const filePath of files) {
707
+ const sf = project.getSourceFile(filePath)
708
+ if (!sf) continue
709
+ const fileRel = relToRoot(domainRoot, filePath)
710
+
711
+ for (const v of sf.getVariableDeclarations()) {
712
+ if (!v.isExported()) continue
713
+ const init = v.getInitializer()
714
+ if (!init || !Node.isCallExpression(init)) continue
715
+ const helper = calleeName(init)
716
+ if (!helper || !(helper in DECL_HELPERS)) continue
717
+ const helperKind = DECL_HELPERS[helper]
718
+ const name = v.getName()
719
+ const kind = anchorKindFor(ir, name, helperKind)
720
+ const ns = kind // 'class' | 'interface' | 'edge'
721
+
722
+ const stmt = v.getVariableStatement() ?? v
723
+ spans[`${ns}.${name}`] = makeSpan(domainRoot, fileRel, stmt, v)
724
+
725
+ // The single object-literal argument: nodeClass({ props, methods }) etc.
726
+ const cfgArg = init.getArguments()[0]
727
+ if (cfgArg && Node.isObjectLiteralExpression(cfgArg)) {
728
+ collectPropsAndMethods(spans, ns, name, cfgArg, domainRoot, fileRel)
729
+ }
730
+
731
+ // Edges: endpoints are the first two args; props live in the third.
732
+ if (kind === 'edge') {
733
+ collectEdge(spans, name, init, domainRoot, fileRel)
734
+ }
735
+ }
736
+ }
737
+
738
+ return spans
739
+ }
740
+
741
+ /** Build a SourceSpan, harvesting leading doc from the declaration `docNode`. */
742
+ function makeSpan(domainRoot: string, fileRel: string, spanNode: Node, docNode: Node): SourceSpan {
743
+ const span: SourceSpan = {
744
+ file: fileRel,
745
+ startLine: spanNode.getStartLineNumber(),
746
+ endLine: spanNode.getEndLineNumber(),
747
+ }
748
+ const doc = leadingDoc(docNode) ?? leadingDoc(spanNode)
749
+ if (doc) span.doc = doc
750
+ return span
751
+ }
752
+
753
+ /** Record `<ns>.<Name>.property.<p>` and `.method.<m>` from a config object. */
754
+ function collectPropsAndMethods(
755
+ spans: Record<string, SourceSpan>,
756
+ ns: string,
757
+ name: string,
758
+ cfg: Node,
759
+ domainRoot: string,
760
+ fileRel: string,
761
+ ): void {
762
+ if (!Node.isObjectLiteralExpression(cfg)) return
763
+ const propsObj = getObjectProp(cfg, 'props')
764
+ if (propsObj) {
765
+ for (const p of propsObj.getProperties()) {
766
+ const pName = propertyKey(p)
767
+ if (!pName) continue
768
+ spans[`${ns}.${name}.property.${pName}`] = makeSpan(domainRoot, fileRel, p, p)
769
+ }
770
+ }
771
+ const methodsObj = getObjectProp(cfg, 'methods')
772
+ if (methodsObj) {
773
+ for (const m of methodsObj.getProperties()) {
774
+ const mName = propertyKey(m)
775
+ if (!mName) continue
776
+ spans[`${ns}.${name}.method.${mName}`] = makeSpan(domainRoot, fileRel, m, m)
777
+ }
778
+ }
779
+ }
780
+
781
+ /** Record edge endpoint spans `edge.<Name>.endpoint.<role>` from the two args. */
782
+ function collectEdge(
783
+ spans: Record<string, SourceSpan>,
784
+ name: string,
785
+ init: CallExpression,
786
+ domainRoot: string,
787
+ fileRel: string,
788
+ ): void {
789
+ const argsList = init.getArguments()
790
+ for (let i = 0; i < Math.min(2, argsList.length); i++) {
791
+ const ep = argsList[i]
792
+ if (!Node.isObjectLiteralExpression(ep)) continue
793
+ const role = stringLiteralOfProp(ep, 'as')
794
+ if (!role) continue
795
+ spans[`edge.${name}.endpoint.${role}`] = makeSpan(domainRoot, fileRel, ep, ep)
796
+ }
797
+ // Edge props live in the third (config) arg's `props`.
798
+ const cfgArg = argsList[2]
799
+ if (cfgArg && Node.isObjectLiteralExpression(cfgArg)) {
800
+ const propsObj = getObjectProp(cfgArg, 'props')
801
+ if (propsObj) {
802
+ for (const p of propsObj.getProperties()) {
803
+ const pName = propertyKey(p)
804
+ if (!pName) continue
805
+ spans[`edge.${name}.property.${pName}`] = makeSpan(domainRoot, fileRel, p, p)
806
+ }
807
+ }
808
+ }
809
+ }
810
+
811
+ /** The object-literal value of a named property, if it is itself an object. */
812
+ function getObjectProp(
813
+ obj: Node,
814
+ name: string,
815
+ ): import('ts-morph').ObjectLiteralExpression | undefined {
816
+ if (!Node.isObjectLiteralExpression(obj)) return undefined
817
+ const prop = obj.getProperty(name)
818
+ if (!prop) return undefined
819
+ let value: Node | undefined
820
+ if (Node.isPropertyAssignment(prop)) value = prop.getInitializer()
821
+ else if (Node.isShorthandPropertyAssignment(prop)) value = prop.getNameNode()
822
+ if (value && Node.isObjectLiteralExpression(value)) return value
823
+ return undefined
824
+ }
825
+
826
+ /** A string-literal property value, e.g. `as: 'page'` → 'page'. */
827
+ function stringLiteralOfProp(obj: Node, name: string): string | undefined {
828
+ if (!Node.isObjectLiteralExpression(obj)) return undefined
829
+ const prop = obj.getProperty(name)
830
+ if (!prop || !Node.isPropertyAssignment(prop)) return undefined
831
+ const v = prop.getInitializer()
832
+ if (v && (Node.isStringLiteral(v) || Node.isNoSubstitutionTemplateLiteral(v)))
833
+ return v.getLiteralText()
834
+ return undefined
835
+ }
836
+
837
+ /** The key name of an object-literal property (assignment / shorthand / method). */
838
+ function propertyKey(prop: Node): string | undefined {
839
+ if (
840
+ Node.isPropertyAssignment(prop) ||
841
+ Node.isShorthandPropertyAssignment(prop) ||
842
+ Node.isMethodDeclaration(prop)
843
+ ) {
844
+ const nameNode = prop.getNameNode()
845
+ if (Node.isStringLiteral(nameNode)) return nameNode.getLiteralText()
846
+ if (Node.isComputedPropertyName(nameNode)) return undefined // skip `[expr]: …`
847
+ return nameNode.getText()
848
+ }
849
+ // Spread (`...knobs`) — no single key.
850
+ return undefined
851
+ }
852
+
853
+ // ───────────────────────────── annotations ─────────────────────────────
854
+
855
+ export function buildSchemaAnnotations(args: { ir: SchemaIR | null }): SchemaAnnotation[] {
856
+ const { ir } = args
857
+ if (!ir) return []
858
+ const out: SchemaAnnotation[] = []
859
+ for (const cls of Object.values(ir.classes ?? {})) {
860
+ const ns = cls.type === 'edge' ? 'edge' : 'class'
861
+ for (const [propName, schema] of Object.entries(cls.properties ?? {})) {
862
+ if (Array.isArray(schema?.enum) && schema.enum.length > 0) {
863
+ out.push({
864
+ target: `${ns}.${cls.name}.property.${propName}`,
865
+ severity: 'warn',
866
+ code: 'ENUM_DROPPED_BY_UPDATE',
867
+ message:
868
+ 'z.enum props are silently dropped by ::update — track as a plain string if it must be updated.',
869
+ })
870
+ }
871
+ }
872
+ }
873
+ return out
874
+ }