@remnic/coding-graph 9.3.759

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 (86) hide show
  1. package/README.md +130 -0
  2. package/dist/chunk-5I2DBHOQ.js +1042 -0
  3. package/dist/chunk-5I2DBHOQ.js.map +1 -0
  4. package/dist/chunk-CPYJACC5.js +1838 -0
  5. package/dist/chunk-CPYJACC5.js.map +1 -0
  6. package/dist/chunk-ZVCMIM4T.js +216 -0
  7. package/dist/chunk-ZVCMIM4T.js.map +1 -0
  8. package/dist/cypher/query-parser.d.ts +253 -0
  9. package/dist/cypher/query-parser.js +17 -0
  10. package/dist/cypher/query-parser.js.map +1 -0
  11. package/dist/graph-schema.d.ts +84 -0
  12. package/dist/graph-schema.js +17 -0
  13. package/dist/graph-schema.js.map +1 -0
  14. package/dist/graph-store.d.ts +938 -0
  15. package/dist/graph-store.js +16 -0
  16. package/dist/graph-store.js.map +1 -0
  17. package/dist/index.d.ts +1953 -0
  18. package/dist/index.js +3509 -0
  19. package/dist/index.js.map +1 -0
  20. package/grammars/tree-sitter-bash.wasm +0 -0
  21. package/grammars/tree-sitter-c.wasm +0 -0
  22. package/grammars/tree-sitter-c_sharp.wasm +0 -0
  23. package/grammars/tree-sitter-cpp.wasm +0 -0
  24. package/grammars/tree-sitter-go.wasm +0 -0
  25. package/grammars/tree-sitter-java.wasm +0 -0
  26. package/grammars/tree-sitter-javascript.wasm +0 -0
  27. package/grammars/tree-sitter-kotlin.wasm +0 -0
  28. package/grammars/tree-sitter-php.wasm +0 -0
  29. package/grammars/tree-sitter-python.wasm +0 -0
  30. package/grammars/tree-sitter-ruby.wasm +0 -0
  31. package/grammars/tree-sitter-rust.wasm +0 -0
  32. package/grammars/tree-sitter-swift.wasm +0 -0
  33. package/grammars/tree-sitter-tsx.wasm +0 -0
  34. package/grammars/tree-sitter-typescript.wasm +0 -0
  35. package/package.json +79 -0
  36. package/src/co-change.test.ts +175 -0
  37. package/src/co-change.ts +167 -0
  38. package/src/cypher/query-parser.test.ts +1107 -0
  39. package/src/cypher/query-parser.ts +1692 -0
  40. package/src/detect-changes.test.ts +533 -0
  41. package/src/detect-changes.ts +367 -0
  42. package/src/engine/emit.ts +556 -0
  43. package/src/engine/engine.test.ts +1417 -0
  44. package/src/engine/engine.ts +182 -0
  45. package/src/engine/extractors.ts +486 -0
  46. package/src/engine/fixtures.ts +364 -0
  47. package/src/engine/language-sniff.ts +56 -0
  48. package/src/engine/parser-backend.ts +206 -0
  49. package/src/engine/utf16-offsets.ts +68 -0
  50. package/src/git-invoker.test.ts +116 -0
  51. package/src/git-invoker.ts +426 -0
  52. package/src/graph-schema.test.ts +541 -0
  53. package/src/graph-schema.ts +383 -0
  54. package/src/graph-store-pr2.test.ts +1879 -0
  55. package/src/graph-store.test.ts +1420 -0
  56. package/src/graph-store.ts +3489 -0
  57. package/src/index-status.test.ts +303 -0
  58. package/src/index-status.ts +135 -0
  59. package/src/index.ts +384 -0
  60. package/src/lsp/byte-position.ts +173 -0
  61. package/src/lsp/characterization.test.ts +174 -0
  62. package/src/lsp/client.test.ts +275 -0
  63. package/src/lsp/client.ts +484 -0
  64. package/src/lsp/config.ts +219 -0
  65. package/src/lsp/degradation.ts +86 -0
  66. package/src/lsp/fixtures/fake-server.mjs +198 -0
  67. package/src/lsp/framing.test.ts +180 -0
  68. package/src/lsp/framing.ts +177 -0
  69. package/src/lsp/resolution.test.ts +497 -0
  70. package/src/lsp/resolution.ts +483 -0
  71. package/src/lsp/status.ts +140 -0
  72. package/src/lsp/types.ts +167 -0
  73. package/src/reindex.test.ts +1038 -0
  74. package/src/reindex.ts +908 -0
  75. package/src/row-types.ts +45 -0
  76. package/src/semantic/canonical-text.test.ts +150 -0
  77. package/src/semantic/canonical-text.ts +219 -0
  78. package/src/semantic/config.ts +235 -0
  79. package/src/semantic/index.ts +78 -0
  80. package/src/semantic/minhash.test.ts +197 -0
  81. package/src/semantic/minhash.ts +261 -0
  82. package/src/semantic/semantic-query.ts +173 -0
  83. package/src/semantic/semantic.test.ts +1315 -0
  84. package/src/semantic/similarity.ts +268 -0
  85. package/src/semantic/types.ts +145 -0
  86. package/src/semantic/vectors.ts +235 -0
@@ -0,0 +1,556 @@
1
+ /**
2
+ * FileIR emitter — runs tree-sitter queries against a parsed tree and
3
+ * assembles the neutral intermediate representation.
4
+ *
5
+ * Determinism (rule 38): every collection is sorted before it leaves this
6
+ * module. Sort keys are chosen so output is byte-identical across runs:
7
+ * symbols → (startByte, name)
8
+ * imports → (startByte, module)
9
+ * exports → (startByte, name)
10
+ * callSites → (startByte, firstCandidate)
11
+ * routes → (startByte, pathTemplate)
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import { Query, type Language, type Node as TSNode } from "web-tree-sitter";
15
+ import type {
16
+ CallSiteIR,
17
+ ExportIR,
18
+ FileIR,
19
+ ImportIR,
20
+ RouteIR,
21
+ SymbolIR,
22
+ } from "@remnic/core/coding/coding-graph-types";
23
+ import type { CodingGraphLanguage } from "@remnic/core";
24
+
25
+ import {
26
+ EXTRACTORS,
27
+ kindFromCapture,
28
+ type DefKind,
29
+ } from "./extractors.js";
30
+ import { buildUtf16ToByteOffsetMap, utf16ToByte } from "./utf16-offsets.js";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Content hashing — SHA-256 of the raw bytes (rule 23).
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export function hashContent(content: Uint8Array): string {
37
+ return createHash("sha256").update(content).digest("hex");
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Module-specifier cleanup — strips quotes/brackets from the captured text.
42
+ // ---------------------------------------------------------------------------
43
+
44
+ function cleanModuleSpecifier(raw: string): string {
45
+ let s = raw.trim();
46
+ // Strip C/C++ system includes: <stdio.h>
47
+ if (s.startsWith("<") && s.endsWith(">")) return s.slice(1, -1);
48
+ // Strip double/single/backtick quotes.
49
+ if (s.length >= 2) {
50
+ const f = s[0];
51
+ const l = s[s.length - 1];
52
+ if ((f === '"' || f === "'" || f === "`") && f === l) return s.slice(1, -1);
53
+ }
54
+ return s;
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Symbol extraction with qualified-name computation.
59
+ //
60
+ // Qualified names are computed via a nesting stack: definitions are sorted by
61
+ // startByte; for each definition we pop the stack until the top contains the
62
+ // current definition's start byte. The qualified name is the join of the
63
+ // stack's names plus the current name. This correctly handles sibling methods
64
+ // in a class, nested classes, etc.
65
+ // ---------------------------------------------------------------------------
66
+
67
+ interface RawDef {
68
+ readonly kind: DefKind;
69
+ readonly name: string;
70
+ readonly startByte: number;
71
+ readonly endByte: number;
72
+ /** Go receiver type (e.g. "Server" from `func (s *Server) Start()`). */
73
+ readonly receiverType?: string;
74
+ }
75
+
76
+ function extractSymbols(root: TSNode, language: Language, lang: CodingGraphLanguage): SymbolIR[] {
77
+ const extractor = EXTRACTORS[lang];
78
+ const query = new Query(language, extractor.definitionsQuery);
79
+ try {
80
+ const matches = query.matches(root);
81
+
82
+ const rawDefs: RawDef[] = [];
83
+ for (const match of matches) {
84
+ let kind: DefKind | null = null;
85
+ let nameNode: TSNode | null = null;
86
+ let defNode: TSNode | null = null;
87
+ let receiverType = "";
88
+ for (const cap of match.captures) {
89
+ const k = kindFromCapture(cap.name);
90
+ if (k) {
91
+ kind = k;
92
+ defNode = cap.node;
93
+ } else if (cap.name === "name") {
94
+ nameNode = cap.node;
95
+ } else if (cap.name === "__receiver.type") {
96
+ receiverType = cap.node.text;
97
+ }
98
+ }
99
+ if (!kind || !defNode || !nameNode) continue;
100
+ rawDefs.push({
101
+ kind,
102
+ name: nameNode.text,
103
+ startByte: defNode.startIndex,
104
+ endByte: defNode.endIndex,
105
+ receiverType: receiverType || undefined,
106
+ });
107
+ }
108
+
109
+ // Deduplicate: a function_item inside an impl block matches both the
110
+ // general function_item pattern and the impl-scoped method pattern.
111
+ // Keep the method version (which carries the receiver type for parent
112
+ // qualification). Same startByte+endByte+name guarantees it's the same
113
+ // AST node matched by two query patterns, not two distinct definitions.
114
+ const seen = new Map<string, RawDef>();
115
+ for (const def of rawDefs) {
116
+ const key = `${def.startByte}:${def.endByte}:${def.name}`;
117
+ const existing = seen.get(key);
118
+ if (!existing || (def.receiverType && !existing.receiverType)) {
119
+ seen.set(key, def);
120
+ }
121
+ }
122
+ const deduped = [...seen.values()];
123
+ deduped.sort((a, b) => a.startByte - b.startByte || a.name.localeCompare(b.name));
124
+
125
+ // The stack stores each def's computed qualifiedName so that nested
126
+ // definitions inside a receiver-qualified method (e.g. Config.new.helper)
127
+ // get the full parent chain, not just the short method name.
128
+ const stack: { endByte: number; qualifiedName: string }[] = [];
129
+ const symbols: SymbolIR[] = [];
130
+ for (const def of deduped) {
131
+ while (stack.length > 0 && stack[stack.length - 1].endByte <= def.startByte) {
132
+ stack.pop();
133
+ }
134
+ // Go/Rust methods sit outside their receiver struct, so byte-span
135
+ // nesting cannot determine the parent. Use the captured receiver
136
+ // type instead.
137
+ // The last stack entry's qualifiedName already contains the full
138
+ // ancestor chain (e.g. "Server.start"), so use it directly rather
139
+ // than joining all entries (which would duplicate ancestors).
140
+ const parentQualifiedName =
141
+ def.receiverType ??
142
+ (stack.length > 0 ? stack[stack.length - 1].qualifiedName : undefined);
143
+ const qualifiedName = parentQualifiedName
144
+ ? `${parentQualifiedName}.${def.name}`
145
+ : def.name;
146
+ const symbol: SymbolIR = parentQualifiedName
147
+ ? {
148
+ kind: def.kind,
149
+ name: def.name,
150
+ qualifiedName,
151
+ span: { startByte: def.startByte, endByte: def.endByte },
152
+ parentQualifiedName,
153
+ }
154
+ : {
155
+ kind: def.kind,
156
+ name: def.name,
157
+ qualifiedName,
158
+ span: { startByte: def.startByte, endByte: def.endByte },
159
+ };
160
+ symbols.push(symbol);
161
+ stack.push({ endByte: def.endByte, qualifiedName });
162
+ }
163
+ return symbols;
164
+ } finally {
165
+ query.delete();
166
+ }
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Import extraction — group captures by @__import.stmt node.
171
+ // ---------------------------------------------------------------------------
172
+
173
+ function extractImports(root: TSNode, language: Language, lang: CodingGraphLanguage): ImportIR[] {
174
+ const extractor = EXTRACTORS[lang];
175
+ if (!extractor.importsQuery) return [];
176
+ const query = new Query(language, extractor.importsQuery);
177
+ try {
178
+ const matches = query.matches(root);
179
+
180
+ // Group by import-statement node start index (unique per node in tree).
181
+ // Group by (statement-start + module) so multi-module statements like
182
+ // Python `import os, sys` produce separate import entries rather than
183
+ // collapsing to a single module. Single-module statements like
184
+ // `import { foo, bar } from "module"` still group correctly because
185
+ // all captures share the same module.
186
+ const groups = new Map<
187
+ string,
188
+ { module: string; names: Set<string>; startByte: number; endByte: number }
189
+ >();
190
+
191
+ for (const match of matches) {
192
+ let moduleText = "";
193
+ let stmtStart = -1;
194
+ let stmtEnd = -1;
195
+ const names: string[] = [];
196
+
197
+ for (const cap of match.captures) {
198
+ if (cap.name === "import.module") {
199
+ moduleText = cleanModuleSpecifier(cap.node.text);
200
+ } else if (cap.name === "import.name") {
201
+ names.push(cap.node.text);
202
+ } else if (cap.name === "__import.stmt") {
203
+ stmtStart = cap.node.startIndex;
204
+ stmtEnd = cap.node.endIndex;
205
+ }
206
+ }
207
+
208
+ if (stmtStart < 0) {
209
+ // Fallback: use the first capture's parent chain to find an import node.
210
+ const firstCap = match.captures[0];
211
+ if (firstCap) {
212
+ stmtStart = firstCap.node.startIndex;
213
+ stmtEnd = firstCap.node.endIndex;
214
+ } else {
215
+ continue;
216
+ }
217
+ }
218
+
219
+ const key = `${stmtStart}:${moduleText}`;
220
+ const existing = groups.get(key);
221
+ if (existing) {
222
+ for (const n of names) existing.names.add(n);
223
+ } else {
224
+ groups.set(key, {
225
+ module: moduleText,
226
+ names: new Set(names),
227
+ startByte: stmtStart,
228
+ endByte: stmtEnd,
229
+ });
230
+ }
231
+ }
232
+
233
+ return Array.from(groups.values())
234
+ .map((g) => ({
235
+ module: g.module,
236
+ importedNames: Array.from(g.names).sort(),
237
+ span: { startByte: g.startByte, endByte: g.endByte },
238
+ }))
239
+ .sort((a, b) => a.span.startByte - b.span.startByte || a.module.localeCompare(b.module));
240
+ } finally {
241
+ query.delete();
242
+ }
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Export extraction.
247
+ // ---------------------------------------------------------------------------
248
+
249
+ function extractExports(root: TSNode, language: Language, lang: CodingGraphLanguage): ExportIR[] {
250
+ const extractor = EXTRACTORS[lang];
251
+ if (!extractor.exportsQuery) return [];
252
+ const query = new Query(language, extractor.exportsQuery);
253
+ try {
254
+ const captures = query.captures(root);
255
+ // Dedup a CommonJS pair overlap (#1659 review): a pair
256
+ // `{ key: value }` whose value is an identifier is matched by BOTH
257
+ // the value-identifier pattern (captures the real symbol) AND the
258
+ // non-identifier fallback (captures the key). The fallback's
259
+ // #not-match? regex is ASCII-only, so a Unicode identifier value
260
+ // (e.g. Universität) defeats it and both patterns fire on the same
261
+ // pair, duplicating the export. web-tree-sitter's query regex
262
+ // engine does not support \p{L}, so dedup here: if a pair already
263
+ // exported its value identifier, drop the spurious key capture.
264
+ const valueExportedPairs = new Set<number>();
265
+ const pairOf = (node: TSNode): TSNode | null => {
266
+ let cur: TSNode | null = node;
267
+ for (let i = 0; i < 5 && cur; i++) {
268
+ if (cur.type === "pair") return cur;
269
+ cur = cur.parent;
270
+ }
271
+ return null;
272
+ };
273
+ for (const cap of captures) {
274
+ if (cap.name !== "export.name") continue;
275
+ const pair = pairOf(cap.node);
276
+ if (pair && cap.node.type === "identifier") {
277
+ valueExportedPairs.add(pair.id);
278
+ }
279
+ }
280
+ const exports: ExportIR[] = [];
281
+ for (const cap of captures) {
282
+ if (cap.name !== "export.name") continue;
283
+ const pair = pairOf(cap.node);
284
+ if (
285
+ pair &&
286
+ cap.node.type === "property_identifier" &&
287
+ valueExportedPairs.has(pair.id)
288
+ ) {
289
+ continue; // value identifier is the real export; drop the alias key
290
+ }
291
+ exports.push({
292
+ name: cap.node.text,
293
+ span: { startByte: cap.node.startIndex, endByte: cap.node.endIndex },
294
+ });
295
+ }
296
+ return exports.sort(
297
+ (a, b) => a.span.startByte - b.span.startByte || a.name.localeCompare(b.name),
298
+ );
299
+ } finally {
300
+ query.delete();
301
+ }
302
+ }
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // Call-site extraction.
306
+ // ---------------------------------------------------------------------------
307
+
308
+ function extractCallSites(root: TSNode, language: Language, lang: CodingGraphLanguage): CallSiteIR[] {
309
+ const extractor = EXTRACTORS[lang];
310
+ if (!extractor.callSitesQuery) return [];
311
+ const query = new Query(language, extractor.callSitesQuery);
312
+ try {
313
+ const captures = query.captures(root);
314
+ const callSites: CallSiteIR[] = [];
315
+ for (const cap of captures) {
316
+ if (cap.name === "call.callee") {
317
+ callSites.push({
318
+ calleeNameCandidates: [cap.node.text],
319
+ span: { startByte: cap.node.startIndex, endByte: cap.node.endIndex },
320
+ });
321
+ }
322
+ }
323
+ return callSites.sort(
324
+ (a, b) => a.span.startByte - b.span.startByte ||
325
+ (a.calleeNameCandidates[0] ?? "").localeCompare(b.calleeNameCandidates[0] ?? ""),
326
+ );
327
+ } finally {
328
+ query.delete();
329
+ }
330
+ }
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // Route extraction (Express/Fastify/Flask/etc.).
334
+ // ---------------------------------------------------------------------------
335
+
336
+
337
+ // Common HTTP client variable names that should NOT produce routes.
338
+ // These objects have methods named get/post/etc. that match the route
339
+ // verb pattern but are client-side calls, not server route registrations.
340
+ // Without this exclusion, httpClient.get("/api", opts, cb) would produce
341
+ // a spurious route with handler=cb, marking cb as is_route_handler and
342
+ // hiding it from dead-code detection (chatgpt-codex-connector #1688 P2).
343
+ const HTTP_CLIENT_OBJECT_PATTERNS = /^(http|https|client|httpClient|axios|fetch|request|req|res|\$|superagent|got)$/;
344
+
345
+ function extractRoutes(root: TSNode, language: Language, lang: CodingGraphLanguage): RouteIR[] {
346
+ const extractor = EXTRACTORS[lang];
347
+ if (!extractor.routesQuery) return [];
348
+ const query = new Query(language, extractor.routesQuery);
349
+ try {
350
+ const matches = query.matches(root);
351
+ const routes: RouteIR[] = [];
352
+ for (const match of matches) {
353
+ let verb = "";
354
+ let pathTemplate = "";
355
+ let handler = "";
356
+ let startByte = 0;
357
+ let endByte = 0;
358
+ let argsNode: TSNode | null = null;
359
+ let routeObject = "";
360
+ for (const cap of match.captures) {
361
+ if (cap.name === "route.verb") {
362
+ verb = cap.node.text.toUpperCase();
363
+ startByte = cap.node.parent?.startIndex ?? cap.node.startIndex;
364
+ // Extract the receiver object name for the HTTP-client exclusion.
365
+ const memberExpr = cap.node.parent;
366
+ const objectNode = memberExpr?.childForFieldName("object");
367
+ if (objectNode) {
368
+ // Normalize nested receivers to their tail property so a call
369
+ // like this.client.get("/api", opts, cb) is caught by the HTTP-
370
+ // client exclusion. objectNode.text for `this.client` is
371
+ // "this.client", which misses the ^client$ pattern; descend to
372
+ // the rightmost property (chatgpt-codex-connector #1688 P2:
373
+ // 'Normalize receiver names before client-route filtering').
374
+ let receiver = objectNode;
375
+ for (
376
+ let prop = receiver.childForFieldName("property");
377
+ prop;
378
+ prop = receiver.childForFieldName("property")
379
+ ) {
380
+ receiver = prop;
381
+ }
382
+ routeObject = receiver.text;
383
+ }
384
+ } else if (cap.name === "route.path") {
385
+ pathTemplate = cleanModuleSpecifier(cap.node.text);
386
+ } else if (cap.name === "route.handler") {
387
+ // Python route handlers (function names) and legacy JS patterns.
388
+ handler = cap.node.type === "identifier"
389
+ ? cap.node.text
390
+ : (findHandlerName(cap.node) ?? "anonymous");
391
+ endByte = cap.node.endIndex;
392
+ } else if (cap.name === "route.args") {
393
+ argsNode = cap.node;
394
+ endByte = cap.node.endIndex;
395
+ }
396
+ }
397
+ // Extract handler from the last argument when we captured the args
398
+ // node (JS routes). Handles middleware: handler is the LAST arg (#1659 #5).
399
+ if (argsNode) {
400
+ handler = extractHandlerFromArgs(argsNode);
401
+ }
402
+ // Guards: (1) path-prefix — routes start with "/" or "*";
403
+ // (2) HTTP-client exclusion — objects named http/client/axios/etc.
404
+ // are clients, not routers. Together these filter the most common
405
+ // non-route call expressions that match the verb+string-arg pattern
406
+ // (chatgpt-codex-connector #1688 P2: 'Reject client callbacks').
407
+ const isRoutePath = pathTemplate.startsWith("/") || pathTemplate.startsWith("*");
408
+ const isHttpClient = HTTP_CLIENT_OBJECT_PATTERNS.test(routeObject);
409
+ if (verb && pathTemplate && handler && isRoutePath && !isHttpClient) {
410
+ routes.push({
411
+ verb,
412
+ pathTemplate,
413
+ handlerQualifiedName: handler,
414
+ span: { startByte, endByte },
415
+ });
416
+ }
417
+ }
418
+ return routes.sort(
419
+ (a, b) => a.span.startByte - b.span.startByte || a.pathTemplate.localeCompare(b.pathTemplate),
420
+ );
421
+ } finally {
422
+ query.delete();
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Extract the route handler name from the LAST argument of an arguments
428
+ * node. Handles middleware: app.get("/path", requireAuth, getUsers) →
429
+ * handler=getUsers (the last arg), not requireAuth (issue #1659 #5).
430
+ */
431
+ function extractHandlerFromArgs(argsNode: TSNode): string {
432
+ // Collect the real (non-comment) named args, skipping trailing inline/
433
+ // block comments. tree-sitter treats comments as named children, so
434
+ // `app.get("/users", getUsers /* auth */)` would otherwise select the
435
+ // comment as the last arg, miss the real handler, and leave it
436
+ // un-protected by the route-handler exclusion (a false dead-code hit).
437
+ // (chatgpt-codex-connector #1659 review: 'Skip comments when selecting
438
+ // route handler'.)
439
+ const realArgs: TSNode[] = [];
440
+ for (let i = 0; i < argsNode.namedChildCount; i++) {
441
+ const child = argsNode.namedChild(i);
442
+ if (child && child.type !== "comment") realArgs.push(child);
443
+ }
444
+ if (realArgs.length < 2) return "";
445
+ const lastArg = realArgs[realArgs.length - 1]!;
446
+ if (lastArg.type === "identifier") {
447
+ return lastArg.text;
448
+ }
449
+ if (lastArg.type === "function_expression") {
450
+ return findHandlerName(lastArg) ?? "anonymous";
451
+ }
452
+ if (lastArg.type === "arrow_function") {
453
+ return "anonymous";
454
+ }
455
+ // Non-handler last arg (object, number, call expression, etc.) —
456
+ // not a route handler. Return empty so the caller skips the route
457
+ // (cursor Bugbot: 'Spurious routes from client calls').
458
+ return "";
459
+ }
460
+
461
+ /**
462
+ * Try to find a handler name from a function/arrow expression node.
463
+ * For named function expressions: `(function foo() {})` → "foo".
464
+ * For arrow functions assigned to a variable, the variable name is not in this node;
465
+ * the caller would need the parent. For now, return "anonymous" unless we find a name.
466
+ */
467
+ function findHandlerName(node: TSNode): string | null {
468
+ // function_expression may have a name child (identifier)
469
+ for (const child of node.namedChildren) {
470
+ if (child && child.type === "identifier") return child.text;
471
+ }
472
+ // Python function_definition has a name field
473
+ const nameChild = node.childForFieldName("name");
474
+ if (nameChild) return nameChild.text;
475
+ return null;
476
+ }
477
+
478
+ // ---------------------------------------------------------------------------
479
+ // Top-level emitter.
480
+ // ---------------------------------------------------------------------------
481
+
482
+ /**
483
+ * Assemble a FileIR from a parsed tree. All collections are sorted for
484
+ * deterministic output (rule 38).
485
+ *
486
+ * `contentStr` is the UTF-8 string that was passed to the parser. It is used
487
+ * to build a UTF-16→byte offset map so all spans are converted from UTF-16
488
+ * code-unit offsets (what web-tree-sitter returns) to UTF-8 byte offsets
489
+ * (what on-disk files use). For ASCII-only content the two are identical;
490
+ * multibyte content (comments, strings, identifiers) needs the conversion
491
+ * (issue #1659 item 3).
492
+ */
493
+ export function emitFileIR(
494
+ filePath: string,
495
+ lang: CodingGraphLanguage,
496
+ content: Uint8Array,
497
+ root: TSNode,
498
+ language: Language,
499
+ contentStr: string,
500
+ ): FileIR {
501
+ const symbols = extractSymbols(root, language, lang);
502
+ const imports = extractImports(root, language, lang);
503
+ const exports = extractExports(root, language, lang);
504
+ const callSites = extractCallSites(root, language, lang);
505
+ const routes = extractRoutes(root, language, lang);
506
+
507
+ // Convert UTF-16 code-unit offsets → UTF-8 byte offsets (issue #1659 #3).
508
+ // Spans are readonly, so rebuild each object with converted offsets.
509
+ const offsetMap = buildUtf16ToByteOffsetMap(contentStr);
510
+ const convSymbols = symbols.map((s) => ({
511
+ ...s,
512
+ span: {
513
+ startByte: utf16ToByte(offsetMap, s.span.startByte),
514
+ endByte: utf16ToByte(offsetMap, s.span.endByte),
515
+ },
516
+ }));
517
+ const convImports = imports.map((i) => ({
518
+ ...i,
519
+ span: {
520
+ startByte: utf16ToByte(offsetMap, i.span.startByte),
521
+ endByte: utf16ToByte(offsetMap, i.span.endByte),
522
+ },
523
+ }));
524
+ const convExports = exports.map((e) => ({
525
+ ...e,
526
+ span: {
527
+ startByte: utf16ToByte(offsetMap, e.span.startByte),
528
+ endByte: utf16ToByte(offsetMap, e.span.endByte),
529
+ },
530
+ }));
531
+ const convCallSites = callSites.map((c) => ({
532
+ ...c,
533
+ span: {
534
+ startByte: utf16ToByte(offsetMap, c.span.startByte),
535
+ endByte: utf16ToByte(offsetMap, c.span.endByte),
536
+ },
537
+ }));
538
+ const convRoutes = routes.map((r) => ({
539
+ ...r,
540
+ span: {
541
+ startByte: utf16ToByte(offsetMap, r.span.startByte),
542
+ endByte: utf16ToByte(offsetMap, r.span.endByte),
543
+ },
544
+ }));
545
+
546
+ return {
547
+ path: filePath,
548
+ language: lang,
549
+ contentHash: hashContent(content),
550
+ symbols: convSymbols,
551
+ imports: convImports,
552
+ exports: convExports,
553
+ callSites: convCallSites,
554
+ routes: convRoutes,
555
+ };
556
+ }