gitnexus 1.6.13-rc.74 → 1.6.13-rc.76

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.
@@ -1,19 +1,30 @@
1
1
  import Go from 'tree-sitter-go';
2
- import { compilePatterns, runCompiledPatterns, unquoteLiteral, } from '../tree-sitter-scanner.js';
2
+ import { goImportPackageName } from '../../../ingestion/languages/go/import-package-name.js';
3
+ import { stringLiteral } from '../../../ingestion/route-extractors/go-shared.js';
4
+ import { compilePatterns, runCompiledPatterns, } from '../tree-sitter-scanner.js';
3
5
  /**
4
6
  * Go HTTP plugin. Handles:
5
- * - gin / echo / chi framework routing — `r.GET("/path", handler)`
7
+ * - gin / echo framework routing — `r.GET("/path", handler)`, including
8
+ * prefixes from route groups bound in the same function (`r.Group("/api")`)
6
9
  * - net/http stdlib — `http.HandleFunc("/path", handler)`
7
10
  * - net/http consumer — `http.Get(...)`, `http.NewRequest("METHOD", ...)`
8
11
  * - resty consumer — `client.R().Delete("/path")`
9
12
  */
10
13
  // ─── Provider: framework routing ──────────────────────────────────────
11
- // Matches `\w+\.GET(...)` etc. (gin, echo, chi all share this shape).
12
- // Captures the HTTP method (field name), path literal, and the handler —
13
- // anchored to the LAST argument (`@handler .`) so a variadic middleware
14
- // chain (`r.GET("/x", mw, handler)`, gin/echo/chi style) binds the real
15
- // handler, not a middleware identifier (which would otherwise over-match
16
- // and attach the route to the wrong symbol — see #2276 review).
14
+ // Matches `\w+\.GET(...)` etc. (gin and echo share this shape).
15
+ // Captures the receiver, the HTTP method (field name), and the path literal
16
+ // (either Go string form; stringLiteral decodes both, as ingestion does). The
17
+ // query does not anchor the path with `.`: tree-sitter counts comments as
18
+ // named children, so `GET(/* c */ "/p", h)` would fail the anchor. scan instead
19
+ // requires the path to be the first argument in code (comments skipped) and
20
+ // picks the handler out of the remaining code arguments. Which argument that is depends on the framework:
21
+ // gin is `GET(path, middleware..., handler)` (last), echo is
22
+ // `GET(path, handler, middleware...)` (first) — see readFrameworkImports and
23
+ // the per-call choice in scan below.
24
+ // The handler must be an identifier, an inline func literal, or a method
25
+ // value / package-qualified function (`h.ListUsers`, `handlers.ListUsers`);
26
+ // anything else there means the call cannot be attributed to a symbol, so it
27
+ // is dropped rather than guessed (variadic-middleware over-match, #2276).
17
28
  const FRAMEWORK_ROUTE_PATTERNS = compilePatterns({
18
29
  name: 'go-framework-route',
19
30
  language: Go,
@@ -23,15 +34,407 @@ const FRAMEWORK_ROUTE_PATTERNS = compilePatterns({
23
34
  query: `
24
35
  (call_expression
25
36
  function: (selector_expression
37
+ operand: (_) @receiver
26
38
  field: (field_identifier) @http_method (#match? @http_method "^(GET|POST|PUT|DELETE|PATCH)$"))
27
39
  arguments: (argument_list
28
- (interpreted_string_literal) @path
29
- [(identifier) (func_literal)] @handler
30
- .))
40
+ [(interpreted_string_literal) (raw_string_literal)] @path))
31
41
  `,
32
42
  },
33
43
  ],
34
44
  });
45
+ /** Named children that are code, not comments (tree-sitter names comments). */
46
+ function codeChildren(node) {
47
+ return node ? node.namedChildren.filter((c) => c.type !== 'comment') : [];
48
+ }
49
+ /** Argument forms a route handler may take. */
50
+ const HANDLER_ARG_TYPES = new Set([
51
+ 'identifier',
52
+ 'func_literal',
53
+ 'selector_expression',
54
+ ]);
55
+ /**
56
+ * The file's framework import aliases: which local qualifiers resolve to
57
+ * echo and to gin. Matched on the import path rather than the local name, so
58
+ * an aliased import still counts; an unaliased import is keyed by its
59
+ * conventional package name (`goImportPackageName`). `_` and `.` imports bind
60
+ * no qualifier this file can route through. An empty set means the file
61
+ * proves nothing about that framework. Echo's verb calls take the handler as
62
+ * the FIRST argument after the path (`GET(path, handler, middleware...)`),
63
+ * gin's as the LAST (`GET(path, middleware..., handler)`) — `scan` picks the
64
+ * rule per call from these sets.
65
+ */
66
+ function readFrameworkImports(root) {
67
+ // Imports sit only at file scope; skip bodies.
68
+ const specs = root.namedChildren
69
+ .filter((node) => node.type === 'import_declaration')
70
+ .flatMap((decl) => decl.descendantsOfType('import_spec'));
71
+ const echo = new Set();
72
+ const gin = new Set();
73
+ for (const spec of specs) {
74
+ const importPath = stringLiteral(spec.childForFieldName('path'));
75
+ if (importPath === null)
76
+ continue;
77
+ const local = spec.childForFieldName('name')?.text ?? goImportPackageName(importPath);
78
+ if (local === '_' || local === '.')
79
+ continue;
80
+ if (importPath.includes('labstack/echo'))
81
+ echo.add(local);
82
+ else if (importPath.includes('gin-gonic/gin'))
83
+ gin.add(local);
84
+ }
85
+ return { echo, gin };
86
+ }
87
+ // ─── Route groups: `v1 := r.Group("/api/v1")` ─────────────────────────
88
+ // gin (`*gin.RouterGroup`) and echo (`*echo.Group`) routes registered on a
89
+ // group inherit every enclosing `Group(prefix)`. The prefix is recovered by
90
+ // walking the route's receiver back through its bindings, lexically, inside
91
+ // the enclosing function declaration only: a group handed to another
92
+ // function (`registerAdmin(v1)`) arrives as a parameter and contributes no
93
+ // prefix there, and a receiver bound to anything but a literal-prefix
94
+ // `Group(...)` call contributes none either — the route keeps its literal path.
95
+ // Statement-scoped bindings count too: an `if`/`switch` initializer, a `for`
96
+ // clause (including `range`), a type-switch guard, and declarations inside a
97
+ // switch case all scope over their statement the same way Go scopes them, so
98
+ // they shadow an outer group of the same name instead of being skipped. A
99
+ // select case's receive binding (`case g := <-ch:`) stops the walk entirely:
100
+ // what arrives from the channel is statically unknown, so the route keeps its
101
+ // literal path rather than inheriting an outer group.
102
+ const MAX_GROUP_DEPTH = 32;
103
+ function joinRoutePath(prefix, relative) {
104
+ let joined = relative;
105
+ if (prefix && relative) {
106
+ joined = `${prefix.replace(/\/+$/, '')}/${relative.replace(/^\/+/, '')}`;
107
+ }
108
+ else if (prefix) {
109
+ joined = prefix;
110
+ }
111
+ // Collapse duplicate slashes on the FINAL result — every return branch, not
112
+ // just the join — because ingestion's normalizeExtractedRoutePath collapses
113
+ // all "//" while the downstream contract-id normalizer does not: a path
114
+ // that keeps "//" would split into two contract ids across the strategies.
115
+ const collapsed = joined.replace(/\/+/g, '/');
116
+ // Force a leading "/" for the same reason: ingestion's
117
+ // normalizeExtractedRoutePath always adds one, while normalizeHttpPath (the
118
+ // shared contract-id normalizer) does not — a literal "x" or a slashless
119
+ // Group("api") prefix would emit `...::x` here and `...::/x` there. Gin
120
+ // also refuses a registration path that does not start with "/".
121
+ return collapsed.startsWith('/') ? collapsed : `/${collapsed}`;
122
+ }
123
+ /** `parent.Group("/p", mw...)` → its receiver and literal prefix; null otherwise. */
124
+ function asGroupCall(node) {
125
+ if (node.type !== 'call_expression')
126
+ return null;
127
+ const fn = node.childForFieldName('function');
128
+ if (fn?.type !== 'selector_expression' || fn.childForFieldName('field')?.text !== 'Group') {
129
+ return null;
130
+ }
131
+ const parent = fn.childForFieldName('operand');
132
+ const first = codeChildren(node.childForFieldName('arguments'))[0];
133
+ // Only a string literal carries a prefix, and it must decode to the text
134
+ // the runtime registers: stringLiteral applies Go unescaping (both `"…"`
135
+ // with escapes and raw `` `…` `` strings). A non-literal argument (a
136
+ // variable, concatenation) or an undecodable string contributes no prefix.
137
+ const prefix = first ? stringLiteral(first) : null;
138
+ if (!parent || prefix === null)
139
+ return null;
140
+ return { parent, prefix };
141
+ }
142
+ /** The expression `name` is assigned by `stmt` (`:=`, `=`, or `var`), if any. */
143
+ function boundValue(stmt, name) {
144
+ const pick = (names, values) => {
145
+ const i = names.findIndex((n) => n.type === 'identifier' && n.text === name);
146
+ if (i < 0)
147
+ return undefined;
148
+ return codeChildren(values)[i] ?? null;
149
+ };
150
+ switch (stmt.type) {
151
+ case 'short_var_declaration':
152
+ case 'assignment_statement':
153
+ return pick(codeChildren(stmt.childForFieldName('left')), stmt.childForFieldName('right'));
154
+ case 'var_spec':
155
+ return pick(stmt.childrenForFieldName('name'), stmt.childForFieldName('value'));
156
+ case 'var_declaration': {
157
+ const specs = stmt.namedChildren.flatMap((c) => c.type === 'var_spec_list' ? c.namedChildren : [c]);
158
+ for (const spec of specs) {
159
+ if (spec.type !== 'var_spec')
160
+ continue;
161
+ const value = pick(spec.childrenForFieldName('name'), spec.childForFieldName('value'));
162
+ if (value !== undefined)
163
+ return value;
164
+ }
165
+ return undefined;
166
+ }
167
+ default:
168
+ return undefined;
169
+ }
170
+ }
171
+ /**
172
+ * A binding whose value cannot be established statically: an earlier
173
+ * statement writes the name inside a nested scope (`{ g = r.Group("/new") }`,
174
+ * a branch or loop body), so which value reaches the use depends on control
175
+ * flow. Callers decline the route instead of picking the older binding.
176
+ */
177
+ const CONFLICT = Symbol('conflicting-binding');
178
+ function isParamBinding(b) {
179
+ return typeof b === 'object' && b !== null && 'param' in b;
180
+ }
181
+ /** The parameter_declaration of `fn` (parameters or method receiver) declaring `name`. */
182
+ function paramDeclaring(fn, name) {
183
+ for (const list of [fn.childForFieldName('parameters'), fn.childForFieldName('receiver')]) {
184
+ for (const decl of codeChildren(list)) {
185
+ if (decl.type !== 'parameter_declaration' && decl.type !== 'variadic_parameter_declaration') {
186
+ continue;
187
+ }
188
+ if (decl.childrenForFieldName('name').some((n) => n.text === name))
189
+ return decl;
190
+ }
191
+ }
192
+ return null;
193
+ }
194
+ /** Whether `inner` lies within `outer`'s source span. */
195
+ function within(outer, inner) {
196
+ return !!outer && outer.startIndex <= inner.startIndex && inner.endIndex <= outer.endIndex;
197
+ }
198
+ /**
199
+ * Whether `stmt` writes `name` with a plain assignment somewhere inside it
200
+ * (`g = …` in a nested block, branch, loop, or closure body). A nested `:=`
201
+ * declares a new variable and is not a write to the outer one.
202
+ */
203
+ function writesNameInside(stmt, name) {
204
+ return [stmt, ...stmt.descendantsOfType('assignment_statement')].some((a) => a.type === 'assignment_statement' && declaresName(a.childForFieldName('left'), name));
205
+ }
206
+ /** Whether an identifier or expression_list (e.g. a range left side) declares `name`. */
207
+ function declaresName(node, name) {
208
+ if (!node)
209
+ return false;
210
+ if (node.type === 'identifier')
211
+ return node.text === name;
212
+ return node.namedChildren.some((n) => n.type === 'identifier' && n.text === name);
213
+ }
214
+ /**
215
+ * The value last bound to identifier `ident` before its use: the nearest
216
+ * binding site in the enclosing scopes, walking outward — preceding statements
217
+ * in blocks and switch/select cases (`expression_case`/`type_case`/
218
+ * `communication_case`/`default_case` act as statement containers), then
219
+ * statement-scoped bindings (`if`/`switch` initializers, `for` clauses
220
+ * including `range`, type-switch guards), up to the enclosing function
221
+ * declaration. Returns null when the name is a parameter, is bound without a
222
+ * value, is received from a channel by a select case head (the received value
223
+ * is statically unknown, so the walk stops instead of escaping to an outer
224
+ * group), or is not bound in scope.
225
+ */
226
+ function findBinding(ident) {
227
+ const binding = lookupBinding(ident);
228
+ return isParamBinding(binding) ? null : (binding ?? null);
229
+ }
230
+ /**
231
+ * findBinding's walk, keeping "declared without a traceable value" (null: a
232
+ * `func` literal parameter, `var x T`, a select receive) apart from "not
233
+ * declared before reaching the enclosing function declaration" (undefined).
234
+ * CONFLICT means a preceding statement writes the name in a nested scope, so
235
+ * the value at the use is control-flow dependent. A parameter or method
236
+ * receiver of the enclosing function returns its declaration (ParamBinding):
237
+ * no value, but a static type.
238
+ */
239
+ function lookupBinding(ident) {
240
+ const name = ident.text;
241
+ let child = ident;
242
+ for (let node = ident.parent; node; child = node, node = node.parent) {
243
+ if (node.type === 'function_declaration' ||
244
+ node.type === 'method_declaration' ||
245
+ node.type === 'func_literal') {
246
+ const param = paramDeclaring(node, name);
247
+ if (param)
248
+ return { param };
249
+ if (node.type === 'func_literal')
250
+ continue;
251
+ return undefined;
252
+ }
253
+ // Inside a grouped `var ( a = …; b = a.Group(…) )`, the specs before the
254
+ // one holding the use are already in scope; the current and later specs
255
+ // are not (`var g = g.Group(…)` reads the outer g).
256
+ if (node.type === 'var_spec_list') {
257
+ const specs = codeChildren(node);
258
+ const useIndex = specs.findIndex((s) => s.id === child.id);
259
+ for (const spec of specs.slice(0, useIndex).reverse()) {
260
+ const value = boundValue(spec, name);
261
+ if (value !== undefined)
262
+ return value;
263
+ }
264
+ continue;
265
+ }
266
+ if (node.type === 'block' ||
267
+ node.type === 'expression_case' ||
268
+ node.type === 'type_case' ||
269
+ node.type === 'communication_case' ||
270
+ node.type === 'default_case') {
271
+ // A select case head can rebind the name (`case g := <-ch:` or
272
+ // `case g = <-ch:`); the received value is statically unknown, so the
273
+ // walk must STOP with no traceable value — the same decline the
274
+ // ingestion-side route bindings record (value null) — instead of
275
+ // escaping to an outer group of the same name.
276
+ if (node.type === 'communication_case') {
277
+ for (const head of node.children) {
278
+ if (head.type === 'receive_statement' &&
279
+ declaresName(head.childForFieldName('left'), name)) {
280
+ return null;
281
+ }
282
+ }
283
+ }
284
+ const stmts = codeChildren(node);
285
+ const useIndex = stmts.findIndex((s) => s.id === child.id);
286
+ for (const stmt of stmts.slice(0, useIndex).reverse()) {
287
+ const value = boundValue(stmt, name);
288
+ if (value !== undefined)
289
+ return value;
290
+ // A write nested in an earlier statement (block, branch, loop) may or
291
+ // may not run before the use: the reaching value is unprovable.
292
+ if (writesNameInside(stmt, name))
293
+ return CONFLICT;
294
+ }
295
+ continue;
296
+ }
297
+ // Statement-scoped bindings enclose the use the same way Go scopes them.
298
+ if (node.type === 'if_statement' || node.type === 'expression_switch_statement') {
299
+ // A use inside the initializer itself (`if g := g.Group(…); …`) reads
300
+ // the OUTER binding: the new one only scopes over what follows it.
301
+ const init = node.childForFieldName('initializer');
302
+ if (init && !within(init, ident)) {
303
+ const value = boundValue(init, name);
304
+ if (value !== undefined)
305
+ return value;
306
+ }
307
+ continue;
308
+ }
309
+ if (node.type === 'for_statement') {
310
+ const clause = codeChildren(node)[0];
311
+ // A write in the loop's post statement, condition, or body runs between
312
+ // iterations, so from the second pass on the body sees that value
313
+ // instead of the one it entered with (`for ; c; g = r.Group("/post")`):
314
+ // the prefix is control-flow dependent, so decline it.
315
+ if (within(node.childForFieldName('body'), ident)) {
316
+ const loopParts = [
317
+ node.childForFieldName('body'),
318
+ clause?.type === 'for_clause' ? clause.childForFieldName('update') : null,
319
+ clause?.type === 'for_clause' ? clause.childForFieldName('condition') : null,
320
+ ];
321
+ if (loopParts.some((part) => part && writesNameInside(part, name)))
322
+ return CONFLICT;
323
+ }
324
+ if (clause?.type === 'for_clause') {
325
+ // Only the initializer binds before the body; an absent initializer
326
+ // (`for ; c; i++`) binds nothing.
327
+ const init = clause.childForFieldName('initializer');
328
+ if (init && !within(init, ident)) {
329
+ const value = boundValue(init, name);
330
+ if (value !== undefined)
331
+ return value;
332
+ }
333
+ }
334
+ else if (clause?.type === 'range_clause') {
335
+ if (declaresName(clause.childForFieldName('left'), name) &&
336
+ !within(clause.childForFieldName('right'), ident)) {
337
+ return clause.childForFieldName('right') ?? null;
338
+ }
339
+ }
340
+ continue;
341
+ }
342
+ if (node.type === 'type_switch_statement') {
343
+ // `switch g := x.(type)` — the guard list is the `alias` field, which
344
+ // only the `:=` form has (bare `switch x.(type)` parses with none),
345
+ // matching how the ingestion-side route-bindings read it. The switched
346
+ // value is the operand right after the guard list.
347
+ const guard = node.childForFieldName('alias');
348
+ const switched = codeChildren(node)[1] ?? null;
349
+ if (guard?.type === 'expression_list' &&
350
+ declaresName(guard, name) &&
351
+ !within(switched, ident)) {
352
+ return switched;
353
+ }
354
+ continue;
355
+ }
356
+ }
357
+ return undefined;
358
+ }
359
+ /**
360
+ * Whether a mixed-import file's route receiver traces back to echo's
361
+ * constructor — `e := echo.New()` / `echo.Default()` with `echo` resolving to
362
+ * one of the file's verified echo import aliases — either directly or through
363
+ * enclosing `Group(...)` calls (`users := api.Group(…)` ← `api := e.Group(…)`
364
+ * ← `echo.New()`), the normal shape of grouped routes (review #7, #10).
365
+ * A parameter or method receiver counts by its declared type instead:
366
+ * `e *echo.Echo` / `g *echo.Group` (with `echo` one of those aliases) proves
367
+ * echo; any other type — gin's, or one the file cannot tie to echo — does not.
368
+ * Unrelated packages' `New()`, a local that shadows the echo import name, and
369
+ * anything else return false so the caller keeps the conservative
370
+ * last-argument fallback instead of guessing. Returns null when the Group chain exceeds MAX_GROUP_DEPTH or a
371
+ * binding on it is control-flow dependent (CONFLICT): the framework is then
372
+ * unprovable either way, so the caller declines the route.
373
+ */
374
+ function receiverBindsToEchoConstructor(receiver, echoAliases, depth = 0) {
375
+ if (depth > MAX_GROUP_DEPTH)
376
+ return null;
377
+ // An identifier resolves through its binding; a chained `X.Group(…).Group(…)`
378
+ // operand is already a call and is inspected as-is.
379
+ const value = receiver.type === 'identifier' ? lookupBinding(receiver) : receiver;
380
+ if (value === CONFLICT)
381
+ return null;
382
+ if (isParamBinding(value))
383
+ return isEchoRouterType(value.param.childForFieldName('type'), echoAliases);
384
+ if (value?.type !== 'call_expression')
385
+ return false;
386
+ const fn = value.childForFieldName('function');
387
+ if (fn?.type !== 'selector_expression')
388
+ return false;
389
+ const field = fn.childForFieldName('field')?.text;
390
+ const operand = fn.childForFieldName('operand');
391
+ if (!operand)
392
+ return false;
393
+ if (field === 'New' || field === 'Default') {
394
+ return operand.type === 'identifier' && echoAliases.has(operand.text) && !isLocalName(operand);
395
+ }
396
+ if (field === 'Group') {
397
+ return receiverBindsToEchoConstructor(operand, echoAliases, depth + 1);
398
+ }
399
+ return false;
400
+ }
401
+ /** `*echo.Echo` / `echo.Echo` / `*echo.Group` with `echo` a verified echo import alias. */
402
+ function isEchoRouterType(type, echoAliases) {
403
+ const named = type?.type === 'pointer_type' ? codeChildren(type)[0] : type;
404
+ if (named?.type !== 'qualified_type')
405
+ return false;
406
+ const pkg = named.childForFieldName('package')?.text;
407
+ const typeName = named.childForFieldName('name')?.text;
408
+ return !!pkg && echoAliases.has(pkg) && (typeName === 'Echo' || typeName === 'Group');
409
+ }
410
+ /**
411
+ * Whether `ident` names a local value rather than an imported package: a
412
+ * declaration in scope (with or without a value — `var echo Factory` shadows
413
+ * too) or a parameter/receiver of an enclosing function. Go lets either
414
+ * shadow a package qualifier (`func f(echo *Factory) { echo.New() }`).
415
+ */
416
+ function isLocalName(ident) {
417
+ // lookupBinding covers parameters and receivers too (ParamBinding).
418
+ return lookupBinding(ident) !== undefined;
419
+ }
420
+ /**
421
+ * Joined `Group(...)` prefix of a route receiver; '' when it cannot be traced.
422
+ * Returns null when the chain exceeds MAX_GROUP_DEPTH, or when a binding on it
423
+ * is control-flow dependent (CONFLICT): a partial or stale prefix would emit
424
+ * a wrong path, so the caller declines the route instead.
425
+ */
426
+ function groupPrefix(receiver, depth = 0) {
427
+ if (depth > MAX_GROUP_DEPTH)
428
+ return null;
429
+ const value = receiver.type === 'identifier' ? findBinding(receiver) : receiver;
430
+ if (value === CONFLICT)
431
+ return null;
432
+ const group = value ? asGroupCall(value) : null;
433
+ if (!group)
434
+ return '';
435
+ const outer = groupPrefix(group.parent, depth + 1);
436
+ return outer === null ? null : joinRoutePath(outer, group.prefix);
437
+ }
35
438
  // ─── Provider: net/http `http.HandleFunc("/p", handler)` ─────────────
36
439
  const HANDLE_FUNC_PATTERNS = compilePatterns({
37
440
  name: 'go-handle-func',
@@ -120,29 +523,96 @@ export const GO_HTTP_PLUGIN = {
120
523
  language: Go,
121
524
  scan(tree) {
122
525
  const out = [];
123
- // Framework providers: r.GET/POST/... with handler identifier
526
+ // Framework providers: r.GET/POST/... on an engine or (nested) route group
527
+ const imports = readFrameworkImports(tree.rootNode);
528
+ const echoOnly = imports.echo.size > 0 && imports.gin.size === 0;
529
+ const mixed = imports.echo.size > 0 && imports.gin.size > 0;
530
+ // Handler names declared more than once in this file (`(h *A) List` and
531
+ // `(o *B) List`): the emitted name is field-only, so these must resolve
532
+ // only when unique in the file instead of taking the first same-named row.
533
+ const declaredNames = new Map();
534
+ for (const decl of tree.rootNode.descendantsOfType([
535
+ 'function_declaration',
536
+ 'method_declaration',
537
+ ])) {
538
+ const declName = decl.childForFieldName('name')?.text;
539
+ if (declName)
540
+ declaredNames.set(declName, (declaredNames.get(declName) ?? 0) + 1);
541
+ }
124
542
  for (const match of runCompiledPatterns(FRAMEWORK_ROUTE_PATTERNS, tree)) {
125
543
  const methodNode = match.captures.http_method;
126
544
  const pathNode = match.captures.path;
127
- const handlerNode = match.captures.handler;
545
+ const receiverNode = match.captures.receiver;
128
546
  if (!methodNode || !pathNode)
129
547
  continue;
130
- const path = unquoteLiteral(pathNode.text);
131
- if (path === null)
548
+ const literalPath = stringLiteral(pathNode);
549
+ if (literalPath === null)
550
+ continue;
551
+ const argList = pathNode.parent;
552
+ if (argList?.type !== 'argument_list')
553
+ continue;
554
+ const args = codeChildren(argList);
555
+ if (args[0]?.id !== pathNode.id)
556
+ continue;
557
+ // The path is the first code argument, so everything after it is a handler or
558
+ // middleware candidate: echo's verb calls take the FIRST of those, gin's
559
+ // the LAST (see FRAMEWORK_ROUTE_PATTERNS / readFrameworkImports). The
560
+ // rule is chosen per call: an echo-only file is unambiguous; a
561
+ // mixed-import file takes the first argument only when the receiver
562
+ // provably traces to echo's constructor (directly or through enclosing
563
+ // Group() calls) — everything else keeps the last-argument anchor,
564
+ // gin's order and the safer default when the file proves nothing.
565
+ const rest = args.slice(1);
566
+ if (rest.length === 0)
132
567
  continue;
568
+ const echoOrder = mixed
569
+ ? receiverNode
570
+ ? receiverBindsToEchoConstructor(receiverNode, imports.echo)
571
+ : false
572
+ : echoOnly;
573
+ // A Group chain deeper than MAX_GROUP_DEPTH proves neither the full
574
+ // prefix nor the framework order: decline rather than emit a guess.
575
+ if (echoOrder === null)
576
+ continue;
577
+ const prefix = receiverNode ? groupPrefix(receiverNode) : '';
578
+ if (prefix === null)
579
+ continue;
580
+ const handlerNode = echoOrder ? rest[0] : rest[rest.length - 1];
581
+ if (!HANDLER_ARG_TYPES.has(handlerNode.type))
582
+ continue;
583
+ const path = receiverNode ? joinRoutePath(prefix, literalPath) : literalPath;
133
584
  // An inline `func(){…}` handler has no name → emit `name: null` and a
134
585
  // `line` so it resolves to its containing/closure symbol by line-span
135
- // containment (like a consumer). A named identifier handler keeps its
136
- // name and resolves by name; `line` is harmless there.
586
+ // containment (like a consumer). A named handler keeps its name and
587
+ // resolves by name; `line` is harmless there. For a method value or a
588
+ // package-qualified function (`h.List`, `pkg.List`) that name is the
589
+ // field, and the operand (usually a local variable, not the receiver
590
+ // type) does not prove where `List` is declared — so the detection is
591
+ // marked qualifiedHandler: resolve only to a repo-wide unique `List`,
592
+ // never to a same-named local method that merely shares the name.
137
593
  const isInlineHandler = handlerNode?.type === 'func_literal';
594
+ const isQualified = handlerNode?.type === 'selector_expression';
595
+ const handlerName = isQualified
596
+ ? (handlerNode.childForFieldName('field')?.text ?? null)
597
+ : (handlerNode?.text ?? null);
138
598
  out.push({
139
599
  role: 'provider',
140
600
  framework: 'go-framework',
141
601
  method: methodNode.text.toUpperCase(),
142
602
  path,
143
- name: isInlineHandler ? null : (handlerNode?.text ?? null),
603
+ name: isInlineHandler ? null : handlerName,
144
604
  line: (handlerNode ?? pathNode).startPosition.row + 1,
145
605
  confidence: 0.8,
606
+ ...(isQualified ? { qualifiedHandler: true } : {}),
607
+ // A bare name declared more than once in this file (a function and a
608
+ // method of the same name) resolves only when the file holds exactly
609
+ // one match, rather than binding to whichever row the graph lists first.
610
+ ...(!isQualified &&
611
+ !isInlineHandler &&
612
+ handlerName &&
613
+ (declaredNames.get(handlerName) ?? 0) > 1
614
+ ? { strictHandlerResolution: true }
615
+ : {}),
146
616
  });
147
617
  }
148
618
  // net/http HandleFunc: default method GET
@@ -151,7 +621,7 @@ export const GO_HTTP_PLUGIN = {
151
621
  const handlerNode = match.captures.handler;
152
622
  if (!pathNode)
153
623
  continue;
154
- const path = unquoteLiteral(pathNode.text);
624
+ const path = stringLiteral(pathNode);
155
625
  if (path === null)
156
626
  continue;
157
627
  // Inline `func(){…}` handler → resolve by containment (see go-framework
@@ -176,7 +646,7 @@ export const GO_HTTP_PLUGIN = {
176
646
  const httpMethod = HTTP_CLIENT_METHOD_TO_HTTP[fnNode.text];
177
647
  if (!httpMethod)
178
648
  continue;
179
- const path = unquoteLiteral(pathNode.text);
649
+ const path = stringLiteral(pathNode);
180
650
  if (path === null)
181
651
  continue;
182
652
  out.push({
@@ -195,8 +665,8 @@ export const GO_HTTP_PLUGIN = {
195
665
  const pathNode = match.captures.path;
196
666
  if (!methodNode || !pathNode)
197
667
  continue;
198
- const method = unquoteLiteral(methodNode.text);
199
- const path = unquoteLiteral(pathNode.text);
668
+ const method = stringLiteral(methodNode);
669
+ const path = stringLiteral(pathNode);
200
670
  if (method === null || path === null)
201
671
  continue;
202
672
  out.push({
@@ -215,7 +685,7 @@ export const GO_HTTP_PLUGIN = {
215
685
  const pathNode = match.captures.path;
216
686
  if (!methodNode || !pathNode)
217
687
  continue;
218
- const path = unquoteLiteral(pathNode.text);
688
+ const path = stringLiteral(pathNode);
219
689
  if (path === null)
220
690
  continue;
221
691
  out.push({
@@ -58,6 +58,14 @@ export interface HttpDetection {
58
58
  };
59
59
  /** Resolve only from the registration file or exact import target; never guess repo-wide. */
60
60
  strictHandlerResolution?: boolean;
61
+ /**
62
+ * The handler was designated through a qualifier the plugin cannot tie to a
63
+ * declaration (`recv.name`, `pkg.name`): `name` alone does not prove the
64
+ * handler lives in the registration file. Skip the file-scoped name lookup
65
+ * — a same-named but unrelated local symbol would win it — and accept only
66
+ * a repo-wide unique match, else keep the file-level fallback.
67
+ */
68
+ qualifiedHandler?: boolean;
61
69
  /**
62
70
  * The plugin saw a provider handler designator but could not prove its owner.
63
71
  * Prevents the orchestrator from treating it as an anonymous inline handler
@@ -580,13 +580,19 @@ export class HttpRouteExtractor {
580
580
  return null;
581
581
  return resolveSymbolByNameUnique(d.handlerImport.name);
582
582
  }
583
- const byName = d.strictHandlerResolution
584
- ? resolveFileSymbolByNameUnique(syms, d.name)
585
- : resolveSymbolByName(syms, d.name);
586
- if (byName)
587
- return byName;
588
- if (d.strictHandlerResolution)
589
- return null;
583
+ // A qualified designator (`recv.name`) does not prove the handler is
584
+ // declared in this file, so the file-first rung could bind an
585
+ // unrelated same-named local symbol: go straight to the unique
586
+ // repo-wide match.
587
+ if (!d.qualifiedHandler) {
588
+ const byName = d.strictHandlerResolution
589
+ ? resolveFileSymbolByNameUnique(syms, d.name)
590
+ : resolveSymbolByName(syms, d.name);
591
+ if (byName)
592
+ return byName;
593
+ if (d.strictHandlerResolution)
594
+ return null;
595
+ }
590
596
  const byGlobal = await resolveSymbolByNameUnique(d.name);
591
597
  if (byGlobal)
592
598
  return byGlobal;
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import { goImportPackageName } from '../languages/go/import-package-name.js';
26
26
  import { GoRouteBindings } from '../languages/go/route-bindings.js';
27
+ import { stringLiteral } from './go-shared.js';
27
28
  import { normalizeExtractedRoutePath } from './route-path.js';
28
29
  export const GIN_ROUTE_SOURCE = 'gin-route';
29
30
  export const ECHO_ROUTE_SOURCE = 'echo-route';
@@ -38,68 +39,6 @@ const VERBS = new Set([
38
39
  ]);
39
40
  const FUNCTION_TYPE_LIST = ['function_declaration', 'method_declaration', 'func_literal'];
40
41
  const FUNCTION_TYPES = new Set(FUNCTION_TYPE_LIST);
41
- function stringLiteral(node) {
42
- if (!node || node.hasError)
43
- return null;
44
- const body = node.text.slice(1, -1);
45
- // Go discards carriage returns in raw strings, including CRLF source files.
46
- if (node.type === 'raw_string_literal')
47
- return body.replace(/\r/g, '');
48
- if (node.type !== 'interpreted_string_literal')
49
- return null;
50
- if (!body.includes('\\'))
51
- return body;
52
- const simple = {
53
- a: '\x07',
54
- b: '\b',
55
- f: '\f',
56
- n: '\n',
57
- r: '\r',
58
- t: '\t',
59
- v: '\v',
60
- '\\': '\\',
61
- '"': '"',
62
- };
63
- const chunks = [];
64
- const tokens = /\\(?:[abfnrtv\\"]|[0-7]{3}|x[\da-fA-F]{2}|u[\da-fA-F]{4}|U[\da-fA-F]{8})|[^\\"\n]+/g;
65
- let consumed = 0;
66
- for (const match of body.matchAll(tokens)) {
67
- if (match.index !== consumed)
68
- return null;
69
- const token = match[0];
70
- consumed += token.length;
71
- if (!token.startsWith('\\')) {
72
- chunks.push(Buffer.from(token));
73
- }
74
- else if (simple[token[1]] !== undefined) {
75
- chunks.push(Buffer.from(simple[token[1]]));
76
- }
77
- else {
78
- const octal = /[0-7]/.test(token[1]);
79
- const value = Number.parseInt(token.slice(octal ? 1 : 2), octal ? 8 : 16);
80
- if (octal || token[1] === 'x') {
81
- // Octal and hex escapes encode bytes, not Unicode code points.
82
- if (value > 255)
83
- return null;
84
- chunks.push(Buffer.from([value]));
85
- }
86
- else {
87
- if (value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff))
88
- return null;
89
- chunks.push(Buffer.from(String.fromCodePoint(value)));
90
- }
91
- }
92
- }
93
- if (consumed !== body.length)
94
- return null;
95
- try {
96
- // Arbitrary non-UTF-8 Go byte strings cannot be represented losslessly in a URL.
97
- return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks));
98
- }
99
- catch {
100
- return null;
101
- }
102
- }
103
42
  /** The framework this file routes with, when exactly one is imported. */
104
43
  function readImports(root) {
105
44
  let gin = null;
@@ -0,0 +1,23 @@
1
+ import type { SyntaxNode } from 'tree-sitter';
2
+ /**
3
+ * Go string-literal decoding shared by the Go route extractors on both
4
+ * layers: the ingestion extractor (`go-gin-echo.ts`, Strategy A) and the
5
+ * group-mode HTTP plugin (`group/extractors/http-patterns/go.ts`,
6
+ * Strategy B). Both sides must produce the SAME text for a route literal,
7
+ * or the same route lands under two different contract ids that never
8
+ * collide in the merge — a wrong-path duplicate that survives alongside
9
+ * the graph's correct entry.
10
+ *
11
+ * Go's semantics, per `strconv.Unquote`:
12
+ * - interpreted (`"…"`) strings decode escapes (`\n`, `\x2f`, `ሴ`,
13
+ * `\101`, …); hex and octal escapes encode BYTES, not code points;
14
+ * - raw (`` `…` ``) strings have no escapes (a backslash is literal),
15
+ * and Go discards carriage returns in them, including CRLF source files;
16
+ * - a string that cannot be decoded to valid UTF-8 text (invalid escape,
17
+ * non-UTF-8 bytes) returns null — callers decline instead of guessing,
18
+ * since a URL cannot carry those bytes losslessly.
19
+ *
20
+ * Nodes that are not string literals (an identifier prefix, a rune
21
+ * literal, an errored subtree) also return null.
22
+ */
23
+ export declare function stringLiteral(node: SyntaxNode | null | undefined): string | null;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Go string-literal decoding shared by the Go route extractors on both
3
+ * layers: the ingestion extractor (`go-gin-echo.ts`, Strategy A) and the
4
+ * group-mode HTTP plugin (`group/extractors/http-patterns/go.ts`,
5
+ * Strategy B). Both sides must produce the SAME text for a route literal,
6
+ * or the same route lands under two different contract ids that never
7
+ * collide in the merge — a wrong-path duplicate that survives alongside
8
+ * the graph's correct entry.
9
+ *
10
+ * Go's semantics, per `strconv.Unquote`:
11
+ * - interpreted (`"…"`) strings decode escapes (`\n`, `\x2f`, `ሴ`,
12
+ * `\101`, …); hex and octal escapes encode BYTES, not code points;
13
+ * - raw (`` `…` ``) strings have no escapes (a backslash is literal),
14
+ * and Go discards carriage returns in them, including CRLF source files;
15
+ * - a string that cannot be decoded to valid UTF-8 text (invalid escape,
16
+ * non-UTF-8 bytes) returns null — callers decline instead of guessing,
17
+ * since a URL cannot carry those bytes losslessly.
18
+ *
19
+ * Nodes that are not string literals (an identifier prefix, a rune
20
+ * literal, an errored subtree) also return null.
21
+ */
22
+ export function stringLiteral(node) {
23
+ if (!node || node.hasError)
24
+ return null;
25
+ const body = node.text.slice(1, -1);
26
+ // Go discards carriage returns in raw strings, including CRLF source files.
27
+ if (node.type === 'raw_string_literal')
28
+ return body.replace(/\r/g, '');
29
+ if (node.type !== 'interpreted_string_literal')
30
+ return null;
31
+ if (!body.includes('\\'))
32
+ return body;
33
+ const simple = {
34
+ a: '\x07',
35
+ b: '\b',
36
+ f: '\f',
37
+ n: '\n',
38
+ r: '\r',
39
+ t: '\t',
40
+ v: '\v',
41
+ '\\': '\\',
42
+ '"': '"',
43
+ };
44
+ const chunks = [];
45
+ const tokens = /\\(?:[abfnrtv\\"]|[0-7]{3}|x[\da-fA-F]{2}|u[\da-fA-F]{4}|U[\da-fA-F]{8})|[^\\"\n]+/g;
46
+ let consumed = 0;
47
+ for (const match of body.matchAll(tokens)) {
48
+ if (match.index !== consumed)
49
+ return null;
50
+ const token = match[0];
51
+ consumed += token.length;
52
+ if (!token.startsWith('\\')) {
53
+ chunks.push(Buffer.from(token));
54
+ }
55
+ else if (simple[token[1]] !== undefined) {
56
+ chunks.push(Buffer.from(simple[token[1]]));
57
+ }
58
+ else {
59
+ const octal = /[0-7]/.test(token[1]);
60
+ const value = Number.parseInt(token.slice(octal ? 1 : 2), octal ? 8 : 16);
61
+ if (octal || token[1] === 'x') {
62
+ // Octal and hex escapes encode bytes, not Unicode code points.
63
+ if (value > 255)
64
+ return null;
65
+ chunks.push(Buffer.from([value]));
66
+ }
67
+ else {
68
+ if (value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff))
69
+ return null;
70
+ chunks.push(Buffer.from(String.fromCodePoint(value)));
71
+ }
72
+ }
73
+ }
74
+ if (consumed !== body.length)
75
+ return null;
76
+ try {
77
+ // Arbitrary non-UTF-8 Go byte strings cannot be represented losslessly in a URL.
78
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks));
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.13-rc.74",
3
+ "version": "1.6.13-rc.76",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
@@ -52,6 +52,7 @@
52
52
  "serve": "tsx src/cli/index.ts serve",
53
53
  "dev": "tsx watch src/cli/index.ts",
54
54
  "test": "vitest run",
55
+ "typecheck:tests": "tsc --noEmit -p tsconfig.test.json",
55
56
  "test:unit": "vitest run test/unit",
56
57
  "pretest:integration": "node scripts/build.js",
57
58
  "test:integration": "vitest run test/integration",