@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.
- package/README.md +130 -0
- package/dist/chunk-5I2DBHOQ.js +1042 -0
- package/dist/chunk-5I2DBHOQ.js.map +1 -0
- package/dist/chunk-CPYJACC5.js +1838 -0
- package/dist/chunk-CPYJACC5.js.map +1 -0
- package/dist/chunk-ZVCMIM4T.js +216 -0
- package/dist/chunk-ZVCMIM4T.js.map +1 -0
- package/dist/cypher/query-parser.d.ts +253 -0
- package/dist/cypher/query-parser.js +17 -0
- package/dist/cypher/query-parser.js.map +1 -0
- package/dist/graph-schema.d.ts +84 -0
- package/dist/graph-schema.js +17 -0
- package/dist/graph-schema.js.map +1 -0
- package/dist/graph-store.d.ts +938 -0
- package/dist/graph-store.js +16 -0
- package/dist/graph-store.js.map +1 -0
- package/dist/index.d.ts +1953 -0
- package/dist/index.js +3509 -0
- package/dist/index.js.map +1 -0
- package/grammars/tree-sitter-bash.wasm +0 -0
- package/grammars/tree-sitter-c.wasm +0 -0
- package/grammars/tree-sitter-c_sharp.wasm +0 -0
- package/grammars/tree-sitter-cpp.wasm +0 -0
- package/grammars/tree-sitter-go.wasm +0 -0
- package/grammars/tree-sitter-java.wasm +0 -0
- package/grammars/tree-sitter-javascript.wasm +0 -0
- package/grammars/tree-sitter-kotlin.wasm +0 -0
- package/grammars/tree-sitter-php.wasm +0 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-ruby.wasm +0 -0
- package/grammars/tree-sitter-rust.wasm +0 -0
- package/grammars/tree-sitter-swift.wasm +0 -0
- package/grammars/tree-sitter-tsx.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- package/package.json +79 -0
- package/src/co-change.test.ts +175 -0
- package/src/co-change.ts +167 -0
- package/src/cypher/query-parser.test.ts +1107 -0
- package/src/cypher/query-parser.ts +1692 -0
- package/src/detect-changes.test.ts +533 -0
- package/src/detect-changes.ts +367 -0
- package/src/engine/emit.ts +556 -0
- package/src/engine/engine.test.ts +1417 -0
- package/src/engine/engine.ts +182 -0
- package/src/engine/extractors.ts +486 -0
- package/src/engine/fixtures.ts +364 -0
- package/src/engine/language-sniff.ts +56 -0
- package/src/engine/parser-backend.ts +206 -0
- package/src/engine/utf16-offsets.ts +68 -0
- package/src/git-invoker.test.ts +116 -0
- package/src/git-invoker.ts +426 -0
- package/src/graph-schema.test.ts +541 -0
- package/src/graph-schema.ts +383 -0
- package/src/graph-store-pr2.test.ts +1879 -0
- package/src/graph-store.test.ts +1420 -0
- package/src/graph-store.ts +3489 -0
- package/src/index-status.test.ts +303 -0
- package/src/index-status.ts +135 -0
- package/src/index.ts +384 -0
- package/src/lsp/byte-position.ts +173 -0
- package/src/lsp/characterization.test.ts +174 -0
- package/src/lsp/client.test.ts +275 -0
- package/src/lsp/client.ts +484 -0
- package/src/lsp/config.ts +219 -0
- package/src/lsp/degradation.ts +86 -0
- package/src/lsp/fixtures/fake-server.mjs +198 -0
- package/src/lsp/framing.test.ts +180 -0
- package/src/lsp/framing.ts +177 -0
- package/src/lsp/resolution.test.ts +497 -0
- package/src/lsp/resolution.ts +483 -0
- package/src/lsp/status.ts +140 -0
- package/src/lsp/types.ts +167 -0
- package/src/reindex.test.ts +1038 -0
- package/src/reindex.ts +908 -0
- package/src/row-types.ts +45 -0
- package/src/semantic/canonical-text.test.ts +150 -0
- package/src/semantic/canonical-text.ts +219 -0
- package/src/semantic/config.ts +235 -0
- package/src/semantic/index.ts +78 -0
- package/src/semantic/minhash.test.ts +197 -0
- package/src/semantic/minhash.ts +261 -0
- package/src/semantic/semantic-query.ts +173 -0
- package/src/semantic/semantic.test.ts +1315 -0
- package/src/semantic/similarity.ts +268 -0
- package/src/semantic/types.ts +145 -0
- 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
|
+
}
|