@mka-rainmaker/ama 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/dist/analyzers/baseline/analyzer.d.ts +47 -0
  4. package/dist/analyzers/baseline/analyzer.d.ts.map +1 -0
  5. package/dist/analyzers/baseline/analyzer.js +84 -0
  6. package/dist/analyzers/baseline/analyzer.js.map +1 -0
  7. package/dist/analyzers/baseline/c.d.ts +12 -0
  8. package/dist/analyzers/baseline/c.d.ts.map +1 -0
  9. package/dist/analyzers/baseline/c.js +56 -0
  10. package/dist/analyzers/baseline/c.js.map +1 -0
  11. package/dist/analyzers/baseline/config.d.ts +21 -0
  12. package/dist/analyzers/baseline/config.d.ts.map +1 -0
  13. package/dist/analyzers/baseline/config.js +32 -0
  14. package/dist/analyzers/baseline/config.js.map +1 -0
  15. package/dist/analyzers/baseline/csharp.d.ts +9 -0
  16. package/dist/analyzers/baseline/csharp.d.ts.map +1 -0
  17. package/dist/analyzers/baseline/csharp.js +107 -0
  18. package/dist/analyzers/baseline/csharp.js.map +1 -0
  19. package/dist/analyzers/baseline/go.d.ts +11 -0
  20. package/dist/analyzers/baseline/go.d.ts.map +1 -0
  21. package/dist/analyzers/baseline/go.js +66 -0
  22. package/dist/analyzers/baseline/go.js.map +1 -0
  23. package/dist/analyzers/baseline/java.d.ts +9 -0
  24. package/dist/analyzers/baseline/java.d.ts.map +1 -0
  25. package/dist/analyzers/baseline/java.js +50 -0
  26. package/dist/analyzers/baseline/java.js.map +1 -0
  27. package/dist/analyzers/baseline/javascript.d.ts +10 -0
  28. package/dist/analyzers/baseline/javascript.d.ts.map +1 -0
  29. package/dist/analyzers/baseline/javascript.js +55 -0
  30. package/dist/analyzers/baseline/javascript.js.map +1 -0
  31. package/dist/analyzers/baseline/kotlin.d.ts +11 -0
  32. package/dist/analyzers/baseline/kotlin.d.ts.map +1 -0
  33. package/dist/analyzers/baseline/kotlin.js +67 -0
  34. package/dist/analyzers/baseline/kotlin.js.map +1 -0
  35. package/dist/analyzers/baseline/paths.d.ts +6 -0
  36. package/dist/analyzers/baseline/paths.d.ts.map +1 -0
  37. package/dist/analyzers/baseline/paths.js +17 -0
  38. package/dist/analyzers/baseline/paths.js.map +1 -0
  39. package/dist/analyzers/baseline/php.d.ts +11 -0
  40. package/dist/analyzers/baseline/php.d.ts.map +1 -0
  41. package/dist/analyzers/baseline/php.js +76 -0
  42. package/dist/analyzers/baseline/php.js.map +1 -0
  43. package/dist/analyzers/baseline/python.d.ts +10 -0
  44. package/dist/analyzers/baseline/python.d.ts.map +1 -0
  45. package/dist/analyzers/baseline/python.js +63 -0
  46. package/dist/analyzers/baseline/python.js.map +1 -0
  47. package/dist/analyzers/baseline/rust.d.ts +10 -0
  48. package/dist/analyzers/baseline/rust.d.ts.map +1 -0
  49. package/dist/analyzers/baseline/rust.js +45 -0
  50. package/dist/analyzers/baseline/rust.js.map +1 -0
  51. package/dist/analyzers/baseline/swift.d.ts +11 -0
  52. package/dist/analyzers/baseline/swift.d.ts.map +1 -0
  53. package/dist/analyzers/baseline/swift.js +19 -0
  54. package/dist/analyzers/baseline/swift.js.map +1 -0
  55. package/dist/analyzers/baseline/treesitter.d.ts +11 -0
  56. package/dist/analyzers/baseline/treesitter.d.ts.map +1 -0
  57. package/dist/analyzers/baseline/treesitter.js +87 -0
  58. package/dist/analyzers/baseline/treesitter.js.map +1 -0
  59. package/dist/analyzers/baseline/walk.d.ts +26 -0
  60. package/dist/analyzers/baseline/walk.d.ts.map +1 -0
  61. package/dist/analyzers/baseline/walk.js +76 -0
  62. package/dist/analyzers/baseline/walk.js.map +1 -0
  63. package/dist/analyzers/registry.d.ts +19 -0
  64. package/dist/analyzers/registry.d.ts.map +1 -0
  65. package/dist/analyzers/registry.js +43 -0
  66. package/dist/analyzers/registry.js.map +1 -0
  67. package/dist/analyzers/sfc/analyzer.d.ts +17 -0
  68. package/dist/analyzers/sfc/analyzer.d.ts.map +1 -0
  69. package/dist/analyzers/sfc/analyzer.js +141 -0
  70. package/dist/analyzers/sfc/analyzer.js.map +1 -0
  71. package/dist/analyzers/sidecar/analyzer.d.ts +29 -0
  72. package/dist/analyzers/sidecar/analyzer.d.ts.map +1 -0
  73. package/dist/analyzers/sidecar/analyzer.js +114 -0
  74. package/dist/analyzers/sidecar/analyzer.js.map +1 -0
  75. package/dist/analyzers/sidecar/protocol.d.ts +508 -0
  76. package/dist/analyzers/sidecar/protocol.d.ts.map +1 -0
  77. package/dist/analyzers/sidecar/protocol.js +102 -0
  78. package/dist/analyzers/sidecar/protocol.js.map +1 -0
  79. package/dist/analyzers/types.d.ts +46 -0
  80. package/dist/analyzers/types.d.ts.map +1 -0
  81. package/dist/analyzers/types.js +2 -0
  82. package/dist/analyzers/types.js.map +1 -0
  83. package/dist/analyzers/typescript/analyzer.d.ts +126 -0
  84. package/dist/analyzers/typescript/analyzer.d.ts.map +1 -0
  85. package/dist/analyzers/typescript/analyzer.js +1600 -0
  86. package/dist/analyzers/typescript/analyzer.js.map +1 -0
  87. package/dist/cli/commands/cycles.d.ts +6 -0
  88. package/dist/cli/commands/cycles.d.ts.map +1 -0
  89. package/dist/cli/commands/cycles.js +27 -0
  90. package/dist/cli/commands/cycles.js.map +1 -0
  91. package/dist/cli/commands/files.d.ts +6 -0
  92. package/dist/cli/commands/files.d.ts.map +1 -0
  93. package/dist/cli/commands/files.js +33 -0
  94. package/dist/cli/commands/files.js.map +1 -0
  95. package/dist/cli/commands/impact.d.ts +18 -0
  96. package/dist/cli/commands/impact.d.ts.map +1 -0
  97. package/dist/cli/commands/impact.js +113 -0
  98. package/dist/cli/commands/impact.js.map +1 -0
  99. package/dist/cli/commands/lifecycle.d.ts +5 -0
  100. package/dist/cli/commands/lifecycle.d.ts.map +1 -0
  101. package/dist/cli/commands/lifecycle.js +83 -0
  102. package/dist/cli/commands/lifecycle.js.map +1 -0
  103. package/dist/cli/commands/query.d.ts +31 -0
  104. package/dist/cli/commands/query.d.ts.map +1 -0
  105. package/dist/cli/commands/query.js +187 -0
  106. package/dist/cli/commands/query.js.map +1 -0
  107. package/dist/cli/commands/search.d.ts +21 -0
  108. package/dist/cli/commands/search.d.ts.map +1 -0
  109. package/dist/cli/commands/search.js +160 -0
  110. package/dist/cli/commands/search.js.map +1 -0
  111. package/dist/cli/commands/status.d.ts +6 -0
  112. package/dist/cli/commands/status.d.ts.map +1 -0
  113. package/dist/cli/commands/status.js +63 -0
  114. package/dist/cli/commands/status.js.map +1 -0
  115. package/dist/cli/commands/sync.d.ts +6 -0
  116. package/dist/cli/commands/sync.d.ts.map +1 -0
  117. package/dist/cli/commands/sync.js +57 -0
  118. package/dist/cli/commands/sync.js.map +1 -0
  119. package/dist/cli/emit.d.ts +9 -0
  120. package/dist/cli/emit.d.ts.map +1 -0
  121. package/dist/cli/emit.js +10 -0
  122. package/dist/cli/emit.js.map +1 -0
  123. package/dist/cli/index.d.ts +37 -0
  124. package/dist/cli/index.d.ts.map +1 -0
  125. package/dist/cli/index.js +128 -0
  126. package/dist/cli/index.js.map +1 -0
  127. package/dist/cli/paths.d.ts +7 -0
  128. package/dist/cli/paths.d.ts.map +1 -0
  129. package/dist/cli/paths.js +10 -0
  130. package/dist/cli/paths.js.map +1 -0
  131. package/dist/cli/query-runner.d.ts +13 -0
  132. package/dist/cli/query-runner.d.ts.map +1 -0
  133. package/dist/cli/query-runner.js +33 -0
  134. package/dist/cli/query-runner.js.map +1 -0
  135. package/dist/graph/dispatch.d.ts +17 -0
  136. package/dist/graph/dispatch.d.ts.map +1 -0
  137. package/dist/graph/dispatch.js +82 -0
  138. package/dist/graph/dispatch.js.map +1 -0
  139. package/dist/graph/id.d.ts +19 -0
  140. package/dist/graph/id.d.ts.map +1 -0
  141. package/dist/graph/id.js +17 -0
  142. package/dist/graph/id.js.map +1 -0
  143. package/dist/graph/index.d.ts +6 -0
  144. package/dist/graph/index.d.ts.map +1 -0
  145. package/dist/graph/index.js +4 -0
  146. package/dist/graph/index.js.map +1 -0
  147. package/dist/graph/types.d.ts +71 -0
  148. package/dist/graph/types.d.ts.map +1 -0
  149. package/dist/graph/types.js +52 -0
  150. package/dist/graph/types.js.map +1 -0
  151. package/dist/indexer/debouncer.d.ts +32 -0
  152. package/dist/indexer/debouncer.d.ts.map +1 -0
  153. package/dist/indexer/debouncer.js +81 -0
  154. package/dist/indexer/debouncer.js.map +1 -0
  155. package/dist/indexer/ignore.d.ts +55 -0
  156. package/dist/indexer/ignore.d.ts.map +1 -0
  157. package/dist/indexer/ignore.js +170 -0
  158. package/dist/indexer/ignore.js.map +1 -0
  159. package/dist/indexer/indexer.d.ts +112 -0
  160. package/dist/indexer/indexer.d.ts.map +1 -0
  161. package/dist/indexer/indexer.js +392 -0
  162. package/dist/indexer/indexer.js.map +1 -0
  163. package/dist/indexer/watcher.d.ts +50 -0
  164. package/dist/indexer/watcher.d.ts.map +1 -0
  165. package/dist/indexer/watcher.js +86 -0
  166. package/dist/indexer/watcher.js.map +1 -0
  167. package/dist/mcp/build-info.d.ts +16 -0
  168. package/dist/mcp/build-info.d.ts.map +1 -0
  169. package/dist/mcp/build-info.js +54 -0
  170. package/dist/mcp/build-info.js.map +1 -0
  171. package/dist/mcp/http.d.ts +18 -0
  172. package/dist/mcp/http.d.ts.map +1 -0
  173. package/dist/mcp/http.js +145 -0
  174. package/dist/mcp/http.js.map +1 -0
  175. package/dist/mcp/server.d.ts +22 -0
  176. package/dist/mcp/server.d.ts.map +1 -0
  177. package/dist/mcp/server.js +401 -0
  178. package/dist/mcp/server.js.map +1 -0
  179. package/dist/mcp/session.d.ts +155 -0
  180. package/dist/mcp/session.d.ts.map +1 -0
  181. package/dist/mcp/session.js +319 -0
  182. package/dist/mcp/session.js.map +1 -0
  183. package/dist/query/service.d.ts +329 -0
  184. package/dist/query/service.d.ts.map +1 -0
  185. package/dist/query/service.js +959 -0
  186. package/dist/query/service.js.map +1 -0
  187. package/dist/runtime/entrypoint.d.ts +11 -0
  188. package/dist/runtime/entrypoint.d.ts.map +1 -0
  189. package/dist/runtime/entrypoint.js +22 -0
  190. package/dist/runtime/entrypoint.js.map +1 -0
  191. package/dist/runtime/quiet-sqlite-warning.d.ts +14 -0
  192. package/dist/runtime/quiet-sqlite-warning.d.ts.map +1 -0
  193. package/dist/runtime/quiet-sqlite-warning.js +26 -0
  194. package/dist/runtime/quiet-sqlite-warning.js.map +1 -0
  195. package/dist/runtime/wasm-tier.d.ts +2 -0
  196. package/dist/runtime/wasm-tier.d.ts.map +1 -0
  197. package/dist/runtime/wasm-tier.js +54 -0
  198. package/dist/runtime/wasm-tier.js.map +1 -0
  199. package/dist/store/memory.d.ts +54 -0
  200. package/dist/store/memory.d.ts.map +1 -0
  201. package/dist/store/memory.js +210 -0
  202. package/dist/store/memory.js.map +1 -0
  203. package/dist/store/sqlite.d.ts +38 -0
  204. package/dist/store/sqlite.d.ts.map +1 -0
  205. package/dist/store/sqlite.js +298 -0
  206. package/dist/store/sqlite.js.map +1 -0
  207. package/dist/store/types.d.ts +76 -0
  208. package/dist/store/types.d.ts.map +1 -0
  209. package/dist/store/types.js +2 -0
  210. package/dist/store/types.js.map +1 -0
  211. package/package.json +59 -0
@@ -0,0 +1,959 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ /**
4
+ * Reverse-traversed by {@link QueryService.impactAnalysis}: every edge kind whose
5
+ * *source* depends on its *target*, so a change to the target can break the source.
6
+ * Excludes structural `Defines` and the file-level `Imports`/`ImportsType` (those are
7
+ * file-granularity — covered by {@link QueryService.affected}). (ama-8sw)
8
+ */
9
+ const IMPACT_EDGE_KINDS = [
10
+ "Calls",
11
+ "References",
12
+ "UsesType",
13
+ "Returns",
14
+ "Instantiates",
15
+ "Implements",
16
+ "Inherits",
17
+ "Overrides",
18
+ ];
19
+ // key:"quoted value" | key:bare | "quoted text" | bare-word.
20
+ const SEARCH_TOKEN = /(\w+):"([^"]*)"|(\w+):(\S+)|"([^"]+)"|(\S+)/g;
21
+ /**
22
+ * Parse a search string into free text plus `path:`/`kind:`/`lang:`/`name:`
23
+ * filters, honouring quotes for values with spaces. Unknown `key:value` tokens
24
+ * (e.g. a `http://…` URL) are kept verbatim as free text rather than dropped.
25
+ */
26
+ export function parseSearchQuery(raw) {
27
+ const result = { text: "" };
28
+ const text = [];
29
+ for (const m of raw.matchAll(SEARCH_TOKEN)) {
30
+ const key = m[1] ?? m[3];
31
+ const value = m[2] ?? m[4];
32
+ if (key !== undefined && value !== undefined) {
33
+ switch (key.toLowerCase()) {
34
+ case "path":
35
+ result.path = value;
36
+ break;
37
+ case "kind":
38
+ result.kind = value;
39
+ break;
40
+ case "lang":
41
+ result.lang = value;
42
+ break;
43
+ case "name":
44
+ result.name = value;
45
+ break;
46
+ default:
47
+ text.push(`${key}:${value}`); // unknown filter — keep as text
48
+ }
49
+ }
50
+ else {
51
+ const bare = m[5] ?? m[6];
52
+ if (bare !== undefined)
53
+ text.push(bare);
54
+ }
55
+ }
56
+ result.text = text.join(" ");
57
+ return result;
58
+ }
59
+ /** Source-file extension → language name, for the `lang:` search filter. A small
60
+ * presentation-layer map (the authoritative extension set lives in each analyzer,
61
+ * which the query layer must not import). */
62
+ const LANGUAGE_BY_EXT = {
63
+ ".ts": "typescript",
64
+ ".tsx": "typescript",
65
+ ".mts": "typescript",
66
+ ".cts": "typescript",
67
+ ".js": "javascript",
68
+ ".jsx": "javascript",
69
+ ".mjs": "javascript",
70
+ ".cjs": "javascript",
71
+ ".py": "python",
72
+ ".go": "go",
73
+ ".rs": "rust",
74
+ ".java": "java",
75
+ ".cs": "csharp",
76
+ ".php": "php",
77
+ ".c": "c",
78
+ ".cpp": "cpp",
79
+ ".cc": "cpp",
80
+ ".cxx": "cpp",
81
+ ".hpp": "cpp",
82
+ ".hh": "cpp",
83
+ ".h": "cpp",
84
+ ".kt": "kotlin",
85
+ ".kts": "kotlin",
86
+ ".swift": "swift",
87
+ };
88
+ function languageForFile(file) {
89
+ const dot = file.lastIndexOf(".");
90
+ return dot === -1 ? undefined : LANGUAGE_BY_EXT[file.slice(dot).toLowerCase()];
91
+ }
92
+ /** Relevance weight per node kind — top-level definitions outrank members and
93
+ * variables when match quality ties. */
94
+ const KIND_BONUS = {
95
+ Class: 8,
96
+ Interface: 8,
97
+ Function: 8,
98
+ Enum: 6,
99
+ TypeAlias: 6,
100
+ Module: 6,
101
+ Route: 4,
102
+ Method: 2,
103
+ Property: 1,
104
+ Variable: 1,
105
+ };
106
+ /** Whether a path looks like a test file — `tests/`/`__tests__/` dirs or a
107
+ * `.test.`/`.spec.` name. Used by test-impact filtering (ama-5gs.9) and search
108
+ * demotion. */
109
+ export function isTestFile(file) {
110
+ const f = file.toLowerCase();
111
+ return /(^|\/)(tests?|__tests__)\//.test(f) || /\.(test|spec)\./.test(f);
112
+ }
113
+ /** Test and generated/build files — real but rarely the symbol you searched for,
114
+ * so a match there is demoted below an equivalent match in source. */
115
+ function isDeprioritizedFile(file) {
116
+ const f = file.toLowerCase();
117
+ return (isTestFile(file) ||
118
+ f.endsWith(".d.ts") ||
119
+ /\.generated\./.test(f) ||
120
+ /(^|\/)(dist|build|coverage)\//.test(f));
121
+ }
122
+ /**
123
+ * A relevance score for a symbol against the free-text part of a search. Higher
124
+ * is better: exact name/qualified-name match dominates, then prefix, then
125
+ * substring; a brevity bonus favours the more specific (shorter) name; a kind
126
+ * bonus lifts top-level definitions; test/generated files are demoted. With no
127
+ * free text (a filters-only query) only the kind/demotion terms apply. (ama-m8k.2)
128
+ */
129
+ function scoreSymbol(node, query) {
130
+ let score = KIND_BONUS[node.kind] ?? 0;
131
+ if (isDeprioritizedFile(node.file))
132
+ score -= 50;
133
+ if (query) {
134
+ const q = query.toLowerCase();
135
+ const name = node.name.toLowerCase();
136
+ const qn = node.qualifiedName.toLowerCase();
137
+ if (name === q || qn === q)
138
+ score += 100;
139
+ else if (name.startsWith(q))
140
+ score += 60;
141
+ else if (name.includes(q))
142
+ score += 30;
143
+ else if (qn.includes(q))
144
+ score += 12; // matched only via the qualified name
145
+ score += Math.max(0, 16 - name.length); // brevity: a shorter name is more specific
146
+ }
147
+ return score;
148
+ }
149
+ /**
150
+ * Order relationship results (callers, callees, importers, …) by relevance with no
151
+ * free-text term: the query-less `scoreSymbol` lifts top-level definitions over
152
+ * members/variables and demotes test/generated files, so a symbol's real source
153
+ * relationships surface above its test-file ones. Ties break alphabetically. (ama-bc2)
154
+ */
155
+ function rankNodes(nodes) {
156
+ return [...nodes].sort((a, b) => scoreSymbol(b, "") - scoreSymbol(a, "") || a.qualifiedName.localeCompare(b.qualifiedName));
157
+ }
158
+ /** The edge kinds that mean "X invokes Y": a plain call and a `new Y()`
159
+ * construction. find_callers/find_callees report both, labelled by `via`. */
160
+ const CALL_EDGE_KINDS = ["Calls", "Instantiates"];
161
+ /** The edge kinds that mean "X uses type Y" — a param/property annotation and a
162
+ * return type. find_types_used/find_type_users report both; find_returns is the
163
+ * return half alone. (ama-37c) */
164
+ const TYPE_EDGE_KINDS = ["UsesType", "Returns"];
165
+ /** explore() output budget (ama-m8k.4): deep-dive the top this-many matches,
166
+ * scan up to this-many to report the true total, and show this-many blast-radius
167
+ * symbols. A broad term ("node") otherwise floods — 134k chars in one call. */
168
+ const EXPLORE_MATCH_LIMIT = 15;
169
+ const EXPLORE_SCAN_LIMIT = 200;
170
+ const EXPLORE_BLAST_LIMIT = 40;
171
+ /** Default result cap for search_symbol/search_code — shared so the MCP layer can
172
+ * request one extra and tell the agent when the result was truncated. (ama-b4q) */
173
+ export const DEFAULT_SEARCH_LIMIT = 50;
174
+ /** Cap on the secondary `dependents` preview in a file skeleton. The outline
175
+ * (symbols) stays complete; dependents is bounded so a foundational file's skeleton
176
+ * doesn't dwarf the file it summarizes — `dependentsTotal` carries the full count
177
+ * and find_importers gives the complete list. (ama-2by) */
178
+ export const SKELETON_DEPENDENTS_LIMIT = 25;
179
+ /** Question words and glue dropped before tokenizing an `explore` question, so its
180
+ * search terms are the content words (`baseline`, `import`), not `how`/`are`. */
181
+ const EXPLORE_STOPWORDS = new Set([
182
+ "how",
183
+ "what",
184
+ "where",
185
+ "why",
186
+ "when",
187
+ "who",
188
+ "which",
189
+ "the",
190
+ "are",
191
+ "was",
192
+ "were",
193
+ "does",
194
+ "did",
195
+ "and",
196
+ "for",
197
+ "with",
198
+ "this",
199
+ "that",
200
+ "its",
201
+ "into",
202
+ "from",
203
+ "use",
204
+ "used",
205
+ "uses",
206
+ "work",
207
+ "works",
208
+ "get",
209
+ "set",
210
+ "via",
211
+ "between",
212
+ "about",
213
+ "you",
214
+ ]);
215
+ /** Content tokens of an `explore` question: lowercased words ≥ 3 chars that aren't
216
+ * stopwords, de-duplicated. A verbose NL question rarely matches a symbol name as
217
+ * one string, so explore searches these terms and unions the hits. (ama-30q) */
218
+ function exploreTerms(question) {
219
+ const terms = question
220
+ .toLowerCase()
221
+ .split(/[^a-z0-9_]+/)
222
+ .filter((t) => t.length >= 3 && !EXPLORE_STOPWORDS.has(t));
223
+ return [...new Set(terms)];
224
+ }
225
+ /** Pair a neighbour node with the metadata of the edge it was reached by. */
226
+ function neighbor(symbol, edge) {
227
+ const n = { symbol, via: edge.kind };
228
+ if (edge.at)
229
+ n.at = edge.at;
230
+ if (edge.sites)
231
+ n.sites = edge.sites;
232
+ if (edge.provenance)
233
+ n.provenance = edge.provenance;
234
+ return n;
235
+ }
236
+ /** {@link rankNodes} for edge neighbours — ranks by the neighbour symbol. */
237
+ function rankNeighbors(neighbors) {
238
+ return [...neighbors].sort((a, b) => scoreSymbol(b.symbol, "") - scoreSymbol(a.symbol, "") ||
239
+ a.symbol.qualifiedName.localeCompare(b.symbol.qualifiedName));
240
+ }
241
+ /** The 1-based start of a contiguous leading comment block immediately above
242
+ * `declLine` (a JSDoc block or line comments, no blank line between), or `declLine`
243
+ * itself when there's none — so a symbol's view reunites it with its documentation.
244
+ * The blank-line stop mirrors how TypeScript binds a doc comment to a declaration.
245
+ * Shared by get_code_snippet (to show the docs) and search_code (to search them).
246
+ * (ama-43e, ama-jxp) */
247
+ function commentAwareStart(lines, declLine) {
248
+ let start = declLine;
249
+ for (let i = declLine - 2; i >= 0; i--) {
250
+ const t = lines[i]?.trim() ?? "";
251
+ if (!(t.startsWith("//") || t.startsWith("/*") || t.startsWith("*") || t.endsWith("*/"))) {
252
+ break;
253
+ }
254
+ start = i + 1;
255
+ }
256
+ return start;
257
+ }
258
+ /**
259
+ * Read-side of the graph: the four MVP questions an agent asks, answered from
260
+ * the store. A "symbol reference" is either an exact node id (e.g.
261
+ * "src/a.ts#Foo.bar") or a bare name ("bar"); names may resolve to several nodes.
262
+ */
263
+ export class QueryService {
264
+ store;
265
+ root;
266
+ constructor(store,
267
+ /** Absolute repo root, used to read source for snippets. */
268
+ root) {
269
+ this.store = store;
270
+ this.root = root;
271
+ }
272
+ /**
273
+ * Symbols whose name matches `query`, answered by the store's name index
274
+ * (substring in-memory, FTS5 prefix in SQLite). Kind is filtered on top.
275
+ */
276
+ searchSymbol(query, opts = {}) {
277
+ const { text, path: pathFilter, kind: kindFilter, lang, name } = parseSearchQuery(query);
278
+ const limit = opts.limit ?? DEFAULT_SEARCH_LIMIT;
279
+ const kind = kindFilter ?? opts.kind;
280
+ // A completely empty query (no text and no filters) has nothing to match — don't
281
+ // fall through to allNodes() and hand back arbitrary symbols. A *filters-only*
282
+ // query (e.g. `kind:Class`) is still valid: it has a filter. (ama-k3d)
283
+ if (!text && !pathFilter && !kind && !lang && !name)
284
+ return [];
285
+ // Free text searches the name index (relevance-ordered); a filters-only query
286
+ // (e.g. `path:src/api kind:Class`) scans every node since there's no name term.
287
+ const candidates = text
288
+ ? this.store.searchByName(text, Number.MAX_SAFE_INTEGER)
289
+ : this.store.allNodes();
290
+ const hits = [];
291
+ for (const node of candidates) {
292
+ if (kind && node.kind.toLowerCase() !== kind.toLowerCase())
293
+ continue;
294
+ if (pathFilter && !node.file.toLowerCase().includes(pathFilter.toLowerCase()))
295
+ continue;
296
+ if (lang && languageForFile(node.file) !== lang.toLowerCase())
297
+ continue;
298
+ if (name &&
299
+ !node.name.toLowerCase().includes(name.toLowerCase()) &&
300
+ !node.qualifiedName.toLowerCase().includes(name.toLowerCase())) {
301
+ continue;
302
+ }
303
+ hits.push(node);
304
+ }
305
+ // Rank by relevance (match quality, kind, test/generated demotion) then slice —
306
+ // so the best matches survive the limit, not just the first ones found. (ama-m8k.2)
307
+ const scored = hits.map((node) => ({ node, score: scoreSymbol(node, text) }));
308
+ scored.sort((a, b) => b.score - a.score || a.node.qualifiedName.localeCompare(b.node.qualifiedName));
309
+ return scored.slice(0, limit).map((s) => s.node);
310
+ }
311
+ /**
312
+ * searchSymbol plus a confidence signal: low-confidence when there is a free-text
313
+ * term and results, but none is an exact or name-prefix match — the matches are
314
+ * loose substring/qualified hits, so the caller likely mistyped or should refine
315
+ * rather than trust the top hit. (ama-b79)
316
+ */
317
+ searchSymbolWithConfidence(query, opts = {}) {
318
+ const results = this.searchSymbol(query, opts);
319
+ const t = parseSearchQuery(query).text.trim().toLowerCase();
320
+ const strong = results.some((r) => {
321
+ const name = r.name.toLowerCase();
322
+ return name === t || name.startsWith(t) || r.qualifiedName.toLowerCase() === t;
323
+ });
324
+ return { results, lowConfidence: t !== "" && results.length > 0 && !strong };
325
+ }
326
+ /** Every indexed file's metadata, sorted by repo-relative path. */
327
+ files() {
328
+ return [...this.store.allFiles()].sort((a, b) => a.path.localeCompare(b.path));
329
+ }
330
+ /**
331
+ * Full-text search over symbol *bodies* (not names): symbols whose verbatim
332
+ * source contains `query`, case-insensitively. Each file is read once and
333
+ * sliced per symbol; File nodes are excluded so a hit points at the containing
334
+ * symbol. This is the in-memory tier — a plain substring scan, not an FTS
335
+ * index (the SQLite store can specialize it later).
336
+ */
337
+ searchCode(query, opts = {}) {
338
+ return this.scanCode(query, opts).results;
339
+ }
340
+ /** search_code that also reports whether it fell back from an exact-phrase match to
341
+ * term-matching (the literal phrase wasn't found) — so the MCP layer can warn the
342
+ * agent that the hits match the query's words separately, not the phrase. (ama-dve) */
343
+ searchCodeWithConfidence(query, opts = {}) {
344
+ return this.scanCode(query, opts);
345
+ }
346
+ scanCode(query, opts) {
347
+ const needle = query.toLowerCase();
348
+ const terms = exploreTerms(query); // for the fallback when the literal phrase misses
349
+ // A blank query has nothing to find — `body.includes("")` is true for every body,
350
+ // so without this guard search_code returns arbitrary symbols. (ama-d36)
351
+ if (needle.trim() === "")
352
+ return { results: [], viaTerms: false };
353
+ const limit = opts.limit ?? DEFAULT_SEARCH_LIMIT;
354
+ const byFile = new Map();
355
+ for (const node of this.store.allNodes()) {
356
+ if (!node.range || node.kind === "File")
357
+ continue;
358
+ const group = byFile.get(node.file) ?? [];
359
+ group.push(node);
360
+ byFile.set(node.file, group);
361
+ }
362
+ const phrase = [];
363
+ const byTerms = [];
364
+ for (const [file, nodes] of byFile) {
365
+ let lines;
366
+ try {
367
+ lines = fs.readFileSync(path.resolve(this.root, file), "utf8").split("\n");
368
+ }
369
+ catch {
370
+ continue; // a file that vanished since indexing — skip it
371
+ }
372
+ for (const node of nodes) {
373
+ if (!node.range)
374
+ continue;
375
+ // Include the leading doc comment so a concept search matches what a symbol
376
+ // documents, not only what its code spells out. (ama-jxp)
377
+ const body = lines
378
+ .slice(commentAwareStart(lines, node.range.startLine) - 1, node.range.endLine)
379
+ .join("\n")
380
+ .toLowerCase();
381
+ if (body.includes(needle)) {
382
+ phrase.push(node);
383
+ if (phrase.length >= limit)
384
+ return { results: phrase, viaTerms: false };
385
+ }
386
+ else if (terms.length >= 2) {
387
+ const hits = terms.reduce((n, t) => (body.includes(t) ? n + 1 : n), 0);
388
+ if (hits > 0)
389
+ byTerms.push({ node, hits });
390
+ }
391
+ }
392
+ }
393
+ // Prefer a literal contiguous-phrase match (grep semantics, no regression). Only
394
+ // when there's none fall back to the symbols whose bodies mention the most query
395
+ // terms, so a conceptual multi-word query isn't a dead end. (ama-ejh)
396
+ if (phrase.length > 0)
397
+ return { results: phrase, viaTerms: false };
398
+ const results = byTerms
399
+ .sort((a, b) => b.hits - a.hits || a.node.qualifiedName.localeCompare(b.node.qualifiedName))
400
+ .slice(0, limit)
401
+ .map((m) => m.node);
402
+ return { results, viaTerms: results.length > 0 };
403
+ }
404
+ /** Symbols that call or construct the referenced symbol, each labelled by the
405
+ * edge kind (`Calls` / `Instantiates`) and its call-site location. */
406
+ findCallers(ref) {
407
+ const callers = new Map();
408
+ for (const target of this.resolve(ref)) {
409
+ for (const kind of CALL_EDGE_KINDS) {
410
+ for (const edge of this.store.edgesTo(target.id, kind)) {
411
+ const caller = this.store.getNode(edge.from);
412
+ if (caller && !callers.has(caller.id))
413
+ callers.set(caller.id, neighbor(caller, edge));
414
+ }
415
+ }
416
+ }
417
+ return rankNeighbors([...callers.values()]);
418
+ }
419
+ /** Symbols the referenced symbol calls or constructs, each labelled by the edge
420
+ * kind (`Calls` / `Instantiates`) and its call-site location. */
421
+ findCallees(ref) {
422
+ const callees = new Map();
423
+ for (const source of this.resolve(ref)) {
424
+ for (const kind of CALL_EDGE_KINDS) {
425
+ for (const edge of this.store.edgesFrom(source.id, kind)) {
426
+ const callee = this.store.getNode(edge.to);
427
+ if (callee && !callees.has(callee.id))
428
+ callees.set(callee.id, neighbor(callee, edge));
429
+ }
430
+ }
431
+ }
432
+ return rankNeighbors([...callees.values()]);
433
+ }
434
+ /** The handler symbols a route refers to (route → References → handler). */
435
+ findHandlers(ref) {
436
+ const handlers = new Map();
437
+ for (const route of this.resolve(ref)) {
438
+ for (const edge of this.store.edgesFrom(route.id, "References")) {
439
+ const handler = this.store.getNode(edge.to);
440
+ if (handler && !handlers.has(handler.id))
441
+ handlers.set(handler.id, neighbor(handler, edge));
442
+ }
443
+ }
444
+ return rankNeighbors([...handlers.values()]);
445
+ }
446
+ /**
447
+ * Everything that points at a symbol via a References edge: the readers of a
448
+ * module-level Variable (ama-6k0), the routes that map to a handler (rme.1), and
449
+ * any other dispatch reference. The general "who refers to this" — answers the
450
+ * question `find_callers` can't, since reads aren't calls. (ama-pfm)
451
+ */
452
+ findReferrers(ref) {
453
+ const referrers = new Map();
454
+ for (const target of this.resolve(ref)) {
455
+ for (const edge of this.store.edgesTo(target.id, "References")) {
456
+ const referrer = this.store.getNode(edge.from);
457
+ if (referrer && !referrers.has(referrer.id)) {
458
+ referrers.set(referrer.id, neighbor(referrer, edge));
459
+ }
460
+ }
461
+ }
462
+ return rankNeighbors([...referrers.values()]);
463
+ }
464
+ /** The routes that map to a handler — the route-domain framing of
465
+ * {@link findReferrers} (a route References its handler). */
466
+ findRoutes(ref) {
467
+ return this.findReferrers(ref);
468
+ }
469
+ /** The supertype methods a method overrides or implements (method → super). */
470
+ findOverrides(ref) {
471
+ const result = new Map();
472
+ for (const source of this.resolve(ref)) {
473
+ for (const edge of this.store.edgesFrom(source.id, "Overrides")) {
474
+ const target = this.store.getNode(edge.to);
475
+ if (target && !result.has(target.id))
476
+ result.set(target.id, neighbor(target, edge));
477
+ }
478
+ }
479
+ return rankNeighbors([...result.values()]);
480
+ }
481
+ /** The subtype methods that override a method (who overrides this — incoming). */
482
+ findOverriddenBy(ref) {
483
+ const result = new Map();
484
+ for (const target of this.resolve(ref)) {
485
+ for (const edge of this.store.edgesTo(target.id, "Overrides")) {
486
+ const source = this.store.getNode(edge.from);
487
+ if (source && !result.has(source.id))
488
+ result.set(source.id, neighbor(source, edge));
489
+ }
490
+ }
491
+ return rankNeighbors([...result.values()]);
492
+ }
493
+ /** Classes that implement the referenced interface. */
494
+ findImplementations(ref) {
495
+ const targets = this.resolve(ref);
496
+ const implementers = new Map();
497
+ for (const target of targets) {
498
+ for (const edge of this.store.edgesTo(target.id, "Implements")) {
499
+ const cls = this.store.getNode(edge.from);
500
+ if (cls)
501
+ implementers.set(cls.id, cls);
502
+ }
503
+ }
504
+ return rankNodes([...implementers.values()]);
505
+ }
506
+ /** Interfaces the referenced class implements. */
507
+ findInterfaces(ref) {
508
+ const sources = this.resolve(ref);
509
+ const interfaces = new Map();
510
+ for (const source of sources) {
511
+ for (const edge of this.store.edgesFrom(source.id, "Implements")) {
512
+ const iface = this.store.getNode(edge.to);
513
+ if (iface)
514
+ interfaces.set(iface.id, iface);
515
+ }
516
+ }
517
+ return rankNodes([...interfaces.values()]);
518
+ }
519
+ /** Edges representing an import of/by `id` — value (`Imports`) and type-only
520
+ * (`ImportsType`) alike — so importer/dependent/affected queries still count an
521
+ * `import type` (it's a compile-time dependency). (ama-bhf) */
522
+ importEdgesTo(id) {
523
+ return [...this.store.edgesTo(id, "Imports"), ...this.store.edgesTo(id, "ImportsType")];
524
+ }
525
+ importEdgesFrom(id) {
526
+ return [...this.store.edgesFrom(id, "Imports"), ...this.store.edgesFrom(id, "ImportsType")];
527
+ }
528
+ /** Files that import (or re-export) the referenced symbol. */
529
+ findImporters(ref) {
530
+ const targets = this.resolve(ref);
531
+ const importers = new Map();
532
+ for (const target of targets) {
533
+ for (const edge of this.importEdgesTo(target.id)) {
534
+ const file = this.store.getNode(edge.from);
535
+ if (file)
536
+ importers.set(file.id, file);
537
+ }
538
+ }
539
+ return rankNodes([...importers.values()]);
540
+ }
541
+ /** Symbols the referenced file imports (or re-exports). */
542
+ findImports(ref) {
543
+ const sources = this.resolve(ref);
544
+ const imports = new Map();
545
+ for (const source of sources) {
546
+ for (const edge of this.importEdgesFrom(source.id)) {
547
+ const imported = this.store.getNode(edge.to);
548
+ if (imported)
549
+ imports.set(imported.id, imported);
550
+ }
551
+ }
552
+ return rankNodes([...imports.values()]);
553
+ }
554
+ /** Symbols that use the referenced type in a parameter, return, or property. */
555
+ findTypeUsers(ref) {
556
+ const users = new Map();
557
+ for (const target of this.resolve(ref)) {
558
+ for (const kind of TYPE_EDGE_KINDS) {
559
+ for (const edge of this.store.edgesTo(target.id, kind)) {
560
+ const user = this.store.getNode(edge.from);
561
+ if (user)
562
+ users.set(user.id, user);
563
+ }
564
+ }
565
+ }
566
+ return rankNodes([...users.values()]);
567
+ }
568
+ /** Types the referenced symbol uses in a parameter, return, or property. */
569
+ findTypesUsed(ref) {
570
+ const types = new Map();
571
+ for (const source of this.resolve(ref)) {
572
+ for (const kind of TYPE_EDGE_KINDS) {
573
+ for (const edge of this.store.edgesFrom(source.id, kind)) {
574
+ const type = this.store.getNode(edge.to);
575
+ if (type)
576
+ types.set(type.id, type);
577
+ }
578
+ }
579
+ }
580
+ return rankNodes([...types.values()]);
581
+ }
582
+ /** The named types a symbol returns (function/method → its return type). */
583
+ findReturns(ref) {
584
+ const types = new Map();
585
+ for (const source of this.resolve(ref)) {
586
+ for (const edge of this.store.edgesFrom(source.id, "Returns")) {
587
+ const type = this.store.getNode(edge.to);
588
+ if (type)
589
+ types.set(type.id, type);
590
+ }
591
+ }
592
+ return rankNodes([...types.values()]);
593
+ }
594
+ /**
595
+ * Everything about one node in a single call: its definition plus full
596
+ * source, callers, callees, and dependents — a higher-order composition of
597
+ * the individual query methods so an agent gets the whole picture at once.
598
+ * Undefined when the ref resolves to nothing.
599
+ */
600
+ node(ref) {
601
+ const matches = this.resolve(ref);
602
+ const primary = matches[0];
603
+ if (!primary)
604
+ return undefined;
605
+ // Resolve once, then describe *that* node by its id. Querying relationships with
606
+ // the raw `ref` re-resolves it, and an ambiguous ref (e.g. "analyze") aggregates
607
+ // callers/callees across every same-named symbol — so the view showed one node
608
+ // (the interface) with another's callees (the implementations'). (ama-d5o)
609
+ const id = primary.id;
610
+ return {
611
+ node: primary,
612
+ snippet: this.getCodeSnippet(id),
613
+ members: this.definedBy(id),
614
+ callers: this.findCallers(id).map((c) => c.symbol),
615
+ callees: this.findCallees(id).map((c) => c.symbol),
616
+ referrers: this.findReferrers(id).map((c) => c.symbol),
617
+ dependents: this.findImporters(id),
618
+ overrides: this.findOverrides(id).map((c) => c.symbol),
619
+ overriddenBy: this.findOverriddenBy(id).map((c) => c.symbol),
620
+ interfaces: this.findInterfaces(id),
621
+ implementations: this.findImplementations(id),
622
+ alternatives: matches.slice(1),
623
+ };
624
+ }
625
+ /** The symbols a node directly defines — its `Defines` targets (a class's
626
+ * methods/properties, an interface's members, …) — in source order. (ama-as5) */
627
+ definedBy(id) {
628
+ const members = [];
629
+ for (const edge of this.store.edgesFrom(id, "Defines")) {
630
+ const member = this.store.getNode(edge.to);
631
+ if (member)
632
+ members.push(member);
633
+ }
634
+ return members.sort((a, b) => (a.range?.startLine ?? 0) - (b.range?.startLine ?? 0));
635
+ }
636
+ /**
637
+ * A file's skeleton: the symbols it defines (its outline, in source order) plus
638
+ * the files that depend on it — so an agent grasps a file's shape and reverse
639
+ * dependencies from one call instead of reading the whole file. `ref` is a File
640
+ * id (repo-relative path) or basename; non-file matches are ignored.
641
+ */
642
+ fileSkeleton(ref) {
643
+ const file = this.resolve(ref).find((n) => n.kind === "File");
644
+ if (!file)
645
+ return undefined;
646
+ const symbols = [...this.store.allNodes()]
647
+ .filter((n) => n.file === file.file && n.id !== file.id)
648
+ .sort((a, b) => (a.range?.startLine ?? 0) - (b.range?.startLine ?? 0));
649
+ // Dependents = files importing *any* symbol the file defines, plus the file
650
+ // itself (import * / export *). Imports edges target the imported declaration,
651
+ // not the file node, so importers of named symbols would otherwise be missed.
652
+ const dependents = new Map();
653
+ for (const target of [file, ...symbols]) {
654
+ for (const edge of this.store.edgesTo(target.id, "Imports")) {
655
+ const importer = this.store.getNode(edge.from);
656
+ if (importer && importer.id !== file.id)
657
+ dependents.set(importer.id, importer);
658
+ }
659
+ }
660
+ const allDependents = [...dependents.values()];
661
+ // Imports = the files this file depends on (outgoing). The Imports edge runs from
662
+ // the file to the imported declaration; dedup to that declaration's File node. (ama-1jv)
663
+ const imports = new Map();
664
+ for (const edge of this.store.edgesFrom(file.id, "Imports")) {
665
+ const target = this.store.getNode(edge.to);
666
+ const targetFile = target && this.store.getNode(target.file);
667
+ if (targetFile && targetFile.id !== file.id)
668
+ imports.set(targetFile.id, targetFile);
669
+ }
670
+ return {
671
+ file,
672
+ symbols,
673
+ imports: [...imports.values()],
674
+ dependents: allDependents.slice(0, SKELETON_DEPENDENTS_LIMIT),
675
+ dependentsTotal: allDependents.length,
676
+ };
677
+ }
678
+ /**
679
+ * The transitive blast radius of a symbol: everything affected by changing it,
680
+ * found by walking the reverse dependency edges breadth-first (callers, type
681
+ * users, referrers, implementers, subclasses, …). `maxDepth` bounds the traversal
682
+ * (default unbounded); a visited set makes cycles and recursion safe. The seed
683
+ * symbol(s) the ref resolves to are excluded. Following every {@link
684
+ * IMPACT_EDGE_KINDS} edge — not just `Calls` — is what lets the blast radius of a
685
+ * type/interface/constant surface, since those are referenced, not called. (ama-8sw)
686
+ */
687
+ impactAnalysis(ref, maxDepth = Number.POSITIVE_INFINITY) {
688
+ const seen = new Set(this.resolve(ref).map((n) => n.id));
689
+ const affected = new Map();
690
+ let frontier = [...seen];
691
+ for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) {
692
+ const next = [];
693
+ for (const id of frontier) {
694
+ for (const kind of IMPACT_EDGE_KINDS) {
695
+ for (const edge of this.store.edgesTo(id, kind)) {
696
+ if (seen.has(edge.from))
697
+ continue;
698
+ seen.add(edge.from);
699
+ const dependent = this.store.getNode(edge.from);
700
+ if (dependent) {
701
+ affected.set(dependent.id, dependent);
702
+ next.push(dependent.id);
703
+ }
704
+ }
705
+ }
706
+ }
707
+ frontier = next;
708
+ }
709
+ return [...affected.values()];
710
+ }
711
+ /**
712
+ * A census of the graph: how many nodes of each kind and edges of each kind
713
+ * the index holds. Each edge is counted once, at its source node.
714
+ */
715
+ getGraphSchema() {
716
+ const nodes = {};
717
+ const edges = {};
718
+ const edgeProvenance = { resolved: 0, heuristic: 0, dispatch: 0 };
719
+ for (const node of this.store.allNodes()) {
720
+ nodes[node.kind] = (nodes[node.kind] ?? 0) + 1;
721
+ for (const edge of this.store.edgesFrom(node.id)) {
722
+ edges[edge.kind] = (edges[edge.kind] ?? 0) + 1;
723
+ edgeProvenance[edge.provenance ?? "resolved"]++;
724
+ }
725
+ }
726
+ return { nodes, edges, edgeProvenance };
727
+ }
728
+ /**
729
+ * The files affected by changing the given files: the transitive set of files
730
+ * that import from them — directly (a module import) or by importing a symbol
731
+ * they define — walked breadth-first. The input files are excluded. Answers
732
+ * "which files (and tests) should I recheck?". Non-file refs and unknowns
733
+ * contribute nothing.
734
+ */
735
+ affected(refs, opts = {}) {
736
+ const seeds = new Set();
737
+ for (const ref of refs) {
738
+ for (const node of this.resolve(ref)) {
739
+ if (node.kind === "File")
740
+ seeds.add(node.id);
741
+ }
742
+ }
743
+ const seen = new Set(seeds);
744
+ const result = new Map();
745
+ let frontier = [...seeds];
746
+ while (frontier.length > 0) {
747
+ const next = [];
748
+ for (const fileId of frontier) {
749
+ for (const importer of this.fileImporters(fileId)) {
750
+ if (seen.has(importer.id))
751
+ continue;
752
+ seen.add(importer.id);
753
+ result.set(importer.id, importer);
754
+ next.push(importer.id);
755
+ }
756
+ }
757
+ frontier = next;
758
+ }
759
+ const all = [...result.values()];
760
+ // Test-impact mode: keep only the affected test files — "which tests to run
761
+ // for this change". (ama-5gs.9)
762
+ return opts.testsOnly ? all.filter((n) => isTestFile(n.file)) : all;
763
+ }
764
+ /**
765
+ * Files that import from `fileId`: importers of the module itself (a star
766
+ * re-export or namespace import targets the File node) plus importers of each
767
+ * symbol the file defines.
768
+ */
769
+ fileImporters(fileId) {
770
+ const importers = new Map();
771
+ const collect = (targetId) => {
772
+ for (const edge of this.importEdgesTo(targetId)) {
773
+ const file = this.store.getNode(edge.from);
774
+ if (file)
775
+ importers.set(file.id, file);
776
+ }
777
+ };
778
+ collect(fileId);
779
+ for (const edge of this.store.edgesFrom(fileId, "Defines")) {
780
+ collect(edge.to);
781
+ }
782
+ return [...importers.values()];
783
+ }
784
+ /** The files this file imports: each Imports edge's target resolved to the file
785
+ * it lives in (a File node's id is its path, so that file is the target). */
786
+ fileImports(fileId) {
787
+ const deps = new Set();
788
+ for (const edge of this.store.edgesFrom(fileId, "Imports")) {
789
+ const target = this.store.getNode(edge.to);
790
+ if (target && target.file !== fileId)
791
+ deps.add(target.file);
792
+ }
793
+ return [...deps];
794
+ }
795
+ /**
796
+ * File-level import cycles, each a strongly-connected component of two or more
797
+ * files that (transitively) import each other — the high-signal answer for
798
+ * untangling a module graph. Tarjan's SCC over the file import graph; a
799
+ * single-node component (no self-import) is not a cycle and is omitted. (ama-m8k.7)
800
+ */
801
+ circularImports() {
802
+ const files = [...this.store.allNodes()].filter((n) => n.kind === "File");
803
+ const adjacency = new Map(files.map((f) => [f.id, this.fileImports(f.id)]));
804
+ let counter = 0;
805
+ const index = new Map();
806
+ const low = new Map();
807
+ const onStack = new Set();
808
+ const stack = [];
809
+ const components = [];
810
+ const connect = (v) => {
811
+ const vIndex = counter++;
812
+ index.set(v, vIndex);
813
+ low.set(v, vIndex);
814
+ stack.push(v);
815
+ onStack.add(v);
816
+ for (const w of adjacency.get(v) ?? []) {
817
+ const wIndex = index.get(w);
818
+ if (wIndex === undefined) {
819
+ connect(w);
820
+ low.set(v, Math.min(low.get(v) ?? vIndex, low.get(w) ?? vIndex));
821
+ }
822
+ else if (onStack.has(w)) {
823
+ low.set(v, Math.min(low.get(v) ?? vIndex, wIndex));
824
+ }
825
+ }
826
+ if (low.get(v) === index.get(v)) {
827
+ const component = [];
828
+ let w;
829
+ do {
830
+ w = stack.pop();
831
+ if (w === undefined)
832
+ break;
833
+ onStack.delete(w);
834
+ component.push(w);
835
+ } while (w !== v);
836
+ if (component.length > 1)
837
+ components.push(component);
838
+ }
839
+ };
840
+ for (const f of files)
841
+ if (!index.has(f.id))
842
+ connect(f.id);
843
+ const byId = new Map(files.map((f) => [f.id, f]));
844
+ return components
845
+ .map((component) => component
846
+ .flatMap((id) => {
847
+ const node = byId.get(id);
848
+ return node ? [node] : [];
849
+ })
850
+ .sort((a, b) => a.id.localeCompare(b.id)))
851
+ .sort((x, y) => (x[0]?.id ?? "").localeCompare(y[0]?.id ?? ""));
852
+ }
853
+ /**
854
+ * A one-call overview answering "what's going on around X?": symbols whose
855
+ * name matches `question`, grouped by file, each with its callers and callees,
856
+ * plus the combined transitive blast radius. Composes searchSymbol,
857
+ * findCallers/findCallees, and impactAnalysis — no new graph logic.
858
+ */
859
+ explore(question, opts = {}) {
860
+ const limit = opts.limit ?? EXPLORE_MATCH_LIMIT;
861
+ // Rank generously to count how many matched, but only deep-dive the top
862
+ // `limit` — relationships and the blast radius are O(matches) and explode on
863
+ // broad terms (e.g. "node" matched hundreds, 134k chars of output). (ama-m8k.4)
864
+ // Tokenize the question and union per-term hits so a verbose NL question
865
+ // ("how are baseline import edges resolved") matches, not just a bare symbol
866
+ // name. An empty token set (all stopwords, or a filters-only query) falls back
867
+ // to the raw search so `path:`/`kind:` filters still work. (ama-30q)
868
+ const terms = exploreTerms(question);
869
+ const ranked = terms.length > 0
870
+ ? this.searchByTerms(terms, EXPLORE_SCAN_LIMIT)
871
+ : this.searchSymbol(question, { limit: EXPLORE_SCAN_LIMIT });
872
+ const matches = ranked.slice(0, limit);
873
+ const byFile = {};
874
+ for (const match of matches) {
875
+ const group = byFile[match.file] ?? [];
876
+ group.push(match);
877
+ byFile[match.file] = group;
878
+ }
879
+ const relationships = matches.map((match) => ({
880
+ symbol: match.qualifiedName || match.name,
881
+ callers: this.findCallers(match.id).map((c) => c.symbol),
882
+ callees: this.findCallees(match.id).map((c) => c.symbol),
883
+ }));
884
+ const blast = new Map();
885
+ for (const match of matches) {
886
+ for (const affected of this.impactAnalysis(match.id))
887
+ blast.set(affected.id, affected);
888
+ }
889
+ const blastRanked = rankNodes([...blast.values()]);
890
+ return {
891
+ question,
892
+ byFile,
893
+ relationships,
894
+ blastRadius: blastRanked.slice(0, EXPLORE_BLAST_LIMIT),
895
+ totalMatches: ranked.length,
896
+ totalBlastRadius: blastRanked.length,
897
+ };
898
+ }
899
+ /** Search each term and union the hits, ranked by how many distinct terms a
900
+ * symbol matches (a symbol hitting several is more on-topic), then by its best
901
+ * per-term relevance position. Each per-term `searchSymbol` keeps its own
902
+ * relevance order, so a single-term question behaves exactly as before. (ama-30q) */
903
+ searchByTerms(terms, limit) {
904
+ const byId = new Map();
905
+ for (const term of terms) {
906
+ this.searchSymbol(term, { limit }).forEach((node, i) => {
907
+ const entry = byId.get(node.id);
908
+ if (entry) {
909
+ entry.hits++;
910
+ entry.best = Math.min(entry.best, i);
911
+ }
912
+ else {
913
+ byId.set(node.id, { node, hits: 1, best: i });
914
+ }
915
+ });
916
+ }
917
+ return [...byId.values()]
918
+ .sort((a, b) => b.hits - a.hits || a.best - b.best)
919
+ .map((entry) => entry.node);
920
+ }
921
+ /** Verbatim source for a symbol, or undefined if it has no known location. */
922
+ getCodeSnippet(ref) {
923
+ const node = this.resolve(ref).find((n) => n.range);
924
+ if (!node || !node.range)
925
+ return undefined;
926
+ let source;
927
+ try {
928
+ source = fs.readFileSync(path.resolve(this.root, node.file), "utf8");
929
+ }
930
+ catch {
931
+ return undefined; // the file vanished since indexing — no snippet, but no throw
932
+ }
933
+ const lines = source.split("\n");
934
+ // Carry the symbol's leading doc comment, usually its most useful part. (ama-43e)
935
+ const startLine = commentAwareStart(lines, node.range.startLine);
936
+ const text = lines.slice(startLine - 1, node.range.endLine).join("\n");
937
+ return {
938
+ id: node.id,
939
+ file: node.file,
940
+ startLine,
941
+ endLine: node.range.endLine,
942
+ text,
943
+ };
944
+ }
945
+ /**
946
+ * Resolve a reference to node(s), most-specific first: exact id, then simple
947
+ * name (e.g. "compute"), then dotted qualified name (e.g. "Service.compute").
948
+ */
949
+ resolve(ref) {
950
+ const byId = this.store.getNode(ref);
951
+ if (byId)
952
+ return [byId];
953
+ const byName = this.store.nodesByName(ref);
954
+ if (byName.length)
955
+ return byName;
956
+ return [...this.store.allNodes()].filter((n) => n.qualifiedName === ref);
957
+ }
958
+ }
959
+ //# sourceMappingURL=service.js.map