gitnexus 1.6.10-rc.217 → 1.6.10-rc.218

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,7 +1,10 @@
1
+ import Parser from 'tree-sitter';
1
2
  import JavaScript from 'tree-sitter-javascript';
2
3
  import TypeScript from 'tree-sitter-typescript';
3
4
  import { compilePatterns, runCompiledPatterns, unquoteLiteral, } from '../tree-sitter-scanner.js';
5
+ import { MAX_FOLD_LENGTH } from '../../../ingestion/route-extractors/constant-resolver.js';
4
6
  import { DATA_ROUTE_TABLE_SOURCE, scanDataRouteTables, } from '../../../ingestion/route-extractors/data-route-table.js';
7
+ import { buildJsRepoFacts, extractJsModuleFacts, isAxiosNamespace, isHttpClientRef, resolveJsPathExpression, } from '../../../ingestion/route-extractors/js-const-resolver.js';
5
8
  /**
6
9
  * Node.js / TypeScript HTTP plugin family. Handles:
7
10
  * - NestJS `@Controller('prefix')` classes with `@Get(':id')` methods
@@ -79,15 +82,28 @@ const FETCH_WITH_OPTIONS_SPEC = {
79
82
  value: (string) @http_method))))
80
83
  `,
81
84
  };
82
- // ─── Consumer: axios.get/post/... ────────────────────────────────────
83
- const AXIOS_SPEC = {
85
+ // ─── Consumer: <httpClient>.get/post/... ─────────────────────────────
86
+ // Widened from a literal `axios` receiver with a literal path. Application
87
+ // code satisfies neither: it calls through a configured instance
88
+ // (`const api = axios.create({ baseURL })`, imported at the call site under
89
+ // whatever name the app chose) and passes the path by reference from a shared
90
+ // route table (`api.get(API_ROUTE_PATH.LINKS)`). The query therefore matches
91
+ // ANY identifier receiver with an HTTP-verb method and ANY first argument;
92
+ // `scanBundle` admits a match only after PROVING the receiver is an axios
93
+ // instance and resolving the argument to a path.
94
+ //
95
+ // The proof gate is load-bearing, not belt-and-braces: EXPRESS_SPEC above
96
+ // matches `router.get('/x', handler)` / `app.post(...)` as PROVIDERS. A
97
+ // receiver admitted on spelling alone would re-emit every Express route in the
98
+ // repo as a consumer of itself, on both sides of every cross-repo pair.
99
+ const HTTP_CLIENT_SPEC = {
84
100
  meta: {},
85
101
  query: `
86
102
  (call_expression
87
103
  function: (member_expression
88
- object: (identifier) @obj (#eq? @obj "axios")
104
+ object: (identifier) @obj
89
105
  property: (property_identifier) @http_method (#match? @http_method "^(get|post|put|delete|patch)$"))
90
- arguments: (arguments . [(string) (template_string)] @path))
106
+ arguments: (arguments . (_) @path))
91
107
  `,
92
108
  };
93
109
  // ─── Consumer: jQuery shorthand $.get(url) / $.post(url, ...) ────────
@@ -141,7 +157,7 @@ function compileBundle(language, name) {
141
157
  express: mk(EXPRESS_SPEC, 'express'),
142
158
  fetchNoOptions: mk(FETCH_NO_OPTIONS_SPEC, 'fetch-no-options'),
143
159
  fetchWithOptions: mk(FETCH_WITH_OPTIONS_SPEC, 'fetch-with-options'),
144
- axios: mk(AXIOS_SPEC, 'axios'),
160
+ httpClient: mk(HTTP_CLIENT_SPEC, 'http-client'),
145
161
  jqueryShorthand: mk(JQUERY_SHORTHAND_SPEC, 'jquery-shorthand'),
146
162
  jqueryAjax: mk(JQUERY_AJAX_SPEC, 'jquery-ajax'),
147
163
  axiosObject: mk(AXIOS_OBJECT_SPEC, 'axios-object'),
@@ -281,12 +297,22 @@ function findDecoratedMethod(decoratorNode) {
281
297
  */
282
298
  function buildImportMap(tree) {
283
299
  const map = new Map();
284
- const walk = (node) => {
300
+ // Both walks are explicit-stack, not recursive. They visit EVERY node of the
301
+ // file, so their depth is the source's nesting depth — and `scan` may not
302
+ // throw: a `RangeError` here escapes to `sync.ts`, which records the repo as
303
+ // an unexplained "missing repo" and drops every contract of every kind for
304
+ // it, silently. A file nesting template substitutions ~4 000 deep (well
305
+ // inside what tree-sitter will parse) was enough.
306
+ const stack = [tree.rootNode];
307
+ while (stack.length > 0) {
308
+ const node = stack.pop();
285
309
  if (node.type === 'import_statement') {
286
310
  const sourceNode = node.childForFieldName('source');
287
311
  const module = sourceNode ? unquoteLiteral(sourceNode.text) : null;
288
312
  if (module !== null) {
289
- const collect = (n) => {
313
+ const inner = [node];
314
+ while (inner.length > 0) {
315
+ const n = inner.pop();
290
316
  if (n.type === 'import_specifier') {
291
317
  const nameNode = n.childForFieldName('name');
292
318
  const aliasNode = n.childForFieldName('alias');
@@ -295,26 +321,213 @@ function buildImportMap(tree) {
295
321
  map.set(local.text, { name: nameNode.text, module });
296
322
  }
297
323
  }
298
- for (let i = 0; i < n.namedChildCount; i++) {
299
- const c = n.namedChild(i);
300
- if (c)
301
- collect(c);
302
- }
303
- };
304
- collect(node);
324
+ for (const c of n.namedChildren)
325
+ inner.push(c);
326
+ }
305
327
  }
328
+ // An import statement cannot contain another one, and the inner loop has
329
+ // already visited its whole subtree.
330
+ continue;
306
331
  }
307
- for (let i = 0; i < node.namedChildCount; i++) {
308
- const c = node.namedChild(i);
309
- if (c)
310
- walk(c);
332
+ for (const c of node.namedChildren)
333
+ stack.push(c);
334
+ }
335
+ return map;
336
+ }
337
+ /**
338
+ * Shared across the three JS/TS plugins.
339
+ *
340
+ * JS, TS and TSX are distinct `HttpLanguagePlugin` objects, and the
341
+ * orchestrator caches `prepareRepo` output per plugin NAME — so a polyglot
342
+ * frontend would build this identical map three times. All three are called
343
+ * with the same memoized file-list array within one extraction run, so keying
344
+ * on that array's identity collapses the work to a single pass. A later run
345
+ * passes a different array and correctly rebuilds; the weak key lets the old
346
+ * map be collected with it.
347
+ */
348
+ const REPO_CONTEXT_BY_FILE_LIST = new WeakMap();
349
+ /**
350
+ * Skip ceiling for the pre-pass, mirroring the analyzer's default
351
+ * `--max-file-size`. A minified bundle is megabytes on one line and defines no
352
+ * route table a human wrote; parsing it costs far more than it can return.
353
+ */
354
+ const MAX_PREPASS_FILE_BYTES = 512 * 1024;
355
+ /** Repo-relative path in the same POSIX form the fact map is keyed by. */
356
+ function normalizeRel(rel) {
357
+ return rel.replace(/\\/g, '/').replace(/^\.\//, '');
358
+ }
359
+ /** The grammar a JS/TS-family file should be parsed with, or null if not one. */
360
+ function grammarForFile(rel) {
361
+ const lower = rel.toLowerCase();
362
+ if (lower.endsWith('.tsx'))
363
+ return TypeScript.tsx;
364
+ if (/\.[cm]?ts$/.test(lower))
365
+ return TypeScript.typescript;
366
+ if (/\.[cm]?jsx?$/.test(lower))
367
+ return JavaScript;
368
+ return null;
369
+ }
370
+ function buildNodeRepoContext(args) {
371
+ const cached = REPO_CONTEXT_BY_FILE_LIST.get(args.files);
372
+ if (cached)
373
+ return cached;
374
+ const byFile = new Map();
375
+ const parsers = new Map();
376
+ const parserFor = (language) => {
377
+ let parser = parsers.get(language);
378
+ if (!parser) {
379
+ parser = new Parser();
380
+ parser.setLanguage(language);
381
+ parsers.set(language, parser);
311
382
  }
383
+ return parser;
312
384
  };
313
- walk(tree.rootNode);
314
- return map;
385
+ // Cost gate, in the spirit of the sibling `python.ts` pre-pass: every fact
386
+ // this map holds exists to prove a receiver is an axios instance or to fold a
387
+ // path for one. A repo where the string `axios` appears nowhere can prove no
388
+ // receiver, so every parse below is dead work — and parsing is the expensive
389
+ // half (measured 4.36 s / +258 MB RSS over 827 TypeScript files, on top of
390
+ // the parse `getScanInput` already does).
391
+ // Only the file's identity is carried between the passes, never its text: a
392
+ // large monorepo's whole source tree held in one array at once is the shape
393
+ // that produced the analyzer's scale problems, and the second read is cheap
394
+ // beside the parse it gates.
395
+ const eligible = [];
396
+ let sawAxios = false;
397
+ for (const rel of args.files) {
398
+ const language = grammarForFile(rel);
399
+ if (language === null)
400
+ continue;
401
+ const content = args.readFile(rel);
402
+ // `MAX_PREPASS_FILE_BYTES` is a BYTE ceiling; `String.length` counts UTF-16
403
+ // code units, which under-counts every multi-byte source.
404
+ if (content === null || Buffer.byteLength(content, 'utf8') > MAX_PREPASS_FILE_BYTES)
405
+ continue;
406
+ if (!sawAxios && content.includes('axios'))
407
+ sawAxios = true;
408
+ eligible.push({ rel, language });
409
+ }
410
+ if (sawAxios) {
411
+ for (const { rel, language } of eligible) {
412
+ try {
413
+ const content = args.readFile(rel);
414
+ if (content === null)
415
+ continue;
416
+ // `parseSource` belongs INSIDE the guard: `safe-parse.ts` throws
417
+ // `ParseTimeoutError` and makes catching it a per-caller obligation, and
418
+ // `prepareRepo` is contractually non-throwing. One escape here left the
419
+ // fact map unwritten for the WHOLE repo — and, because the orchestrator
420
+ // caches per plugin NAME, made all three JS/TS plugins re-walk it and
421
+ // fail the same way before falling back to literal-only scanning.
422
+ const tree = args.parseSource(parserFor(language), content);
423
+ if (!tree)
424
+ continue;
425
+ byFile.set(normalizeRel(rel), extractJsModuleFacts(tree));
426
+ }
427
+ catch {
428
+ // One malformed file must never abort the pre-pass — it simply stays
429
+ // unresolved, exactly as it is without this pass at all.
430
+ }
431
+ }
432
+ }
433
+ const ctx = { facts: buildJsRepoFacts(byFile) };
434
+ REPO_CONTEXT_BY_FILE_LIST.set(args.files, ctx);
435
+ return ctx;
436
+ }
437
+ /** The repo facts to resolve against, or null when there was no pre-pass. */
438
+ function resolveFactsFor(repoContext, fileRel) {
439
+ const ctx = repoContext;
440
+ if (!ctx || fileRel === undefined)
441
+ return null;
442
+ return ctx.facts;
443
+ }
444
+ /**
445
+ * Whether a folded first argument is plausibly a URL path.
446
+ *
447
+ * The query now captures ANY first argument, and "it folded to a string" is not
448
+ * "it is a path" — `normalizeConsumerPath` is a canonicalizer, not a validator,
449
+ * and it happily turns non-paths into contracts that exact-match real provider
450
+ * routes:
451
+ *
452
+ * api.get(CONFIG.TIMEOUT) // "5000" -> http::GET::/{param}
453
+ * api.post(MSG.ERROR) // "Could not reach the …" -> http::POST::/could not reach the server
454
+ *
455
+ * `/{param}` matches every one-segment provider route in the group, and
456
+ * `matching.exclude_links_param_only_paths` defaults to `false`. A path whose
457
+ * leading term is an unresolved placeholder is refused for the same reason —
458
+ * nothing pins where it starts. (`resolveJsPathExpression` already refuses those
459
+ * it folded itself; this also covers the literal fallback below.)
460
+ */
461
+ function looksLikeHttpPath(path) {
462
+ if (path === '')
463
+ return false;
464
+ if (/^https?:\/\//i.test(path))
465
+ return true;
466
+ // A `${…}` term is a runtime value that `normalizeConsumerPath` rewrites to
467
+ // `{param}`; its SOURCE text can be any expression (`${draft ? 'a' : 'b'}`,
468
+ // `${id ?? ''}`), so the checks below have to run against the normalized
469
+ // shape. Testing the raw source dropped every partially folded path whose
470
+ // unresolved term happened to contain a space.
471
+ const shape = path.replace(/\$\{[^}]+\}/g, '{param}');
472
+ if (/\s/.test(shape))
473
+ return false;
474
+ if (shape.startsWith('{param}'))
475
+ return false;
476
+ // An all-digit string is a path only when it is written as one. A leading
477
+ // slash is that evidence: `client.get('/123')` is a route whose segment the
478
+ // consumer normalizer reads as `{param}`, while a bare `"5000"` folded out of
479
+ // `CONFIG.TIMEOUT` is a timeout that would match every one-segment provider.
480
+ if (!shape.startsWith('/'))
481
+ return !/^\d+$/.test(shape);
482
+ return true;
315
483
  }
316
- function scanBundle(bundle, tree) {
484
+ /**
485
+ * The path a consumer call's first argument denotes.
486
+ *
487
+ * Prefers full resolution against the repo facts; falls back to the raw
488
+ * literal for a string/template node so a repo with no pre-pass (or an
489
+ * unresolvable reference) behaves exactly as it did before.
490
+ *
491
+ * `fileKey` is already `normalizeRel`-ed by the caller — see `scanBundle`.
492
+ *
493
+ * `legacyShape` marks the exact combination this pattern matched BEFORE it was
494
+ * widened: the literal receiver `axios` with a string or template-string first
495
+ * argument. That combination keeps its old output verbatim, so this PR adds
496
+ * detections without removing any — `axios.get(`${API_BASE}/users`)` still
497
+ * yields `/{param}/users`. Everything the widened query NEWLY admits (any other
498
+ * receiver, or any non-literal argument) has to clear the gates.
499
+ */
500
+ function resolveConsumerPath(pathNode, facts, fileKey, legacyShape) {
501
+ if (facts && fileKey !== undefined) {
502
+ const resolved = resolveJsPathExpression(fileKey, pathNode, facts);
503
+ if (resolved !== null && looksLikeHttpPath(resolved))
504
+ return resolved;
505
+ }
506
+ // The fallback is deliberately gated on node TYPE: `unquoteLiteral` returns
507
+ // unrecognized input unchanged, so handing it a `member_expression` would
508
+ // yield the literal text `API_ROUTE_PATH.LINKS` as if it were a URL path.
509
+ if (pathNode.type !== 'string' && pathNode.type !== 'template_string')
510
+ return null;
511
+ const literal = unquoteLiteral(pathNode.text);
512
+ // The fold bails past `MAX_FOLD_LENGTH`; the raw source it falls back to has
513
+ // no such bound and lands in `contractId` and `meta.path` all the same.
514
+ if (literal === null || literal.length > MAX_FOLD_LENGTH)
515
+ return null;
516
+ return legacyShape || looksLikeHttpPath(literal) ? literal : null;
517
+ }
518
+ function scanBundle(bundle, tree, repoContext, fileRel) {
317
519
  const out = [];
520
+ // Repo-wide constant / HTTP-client facts, when the orchestrator ran the
521
+ // `prepareRepo` pre-pass. Absent for a bare `scan(tree)` call, in which case
522
+ // every cross-file resolution below floors to the literal-only behavior.
523
+ const facts = resolveFactsFor(repoContext, fileRel);
524
+ // The fact map is keyed by `normalizeRel(rel)`. Normalizing at ONE place and
525
+ // using that value for every read keeps the two sides in step: the receiver
526
+ // gate used to read the raw `fileRel`, and `isHttpClientRef` cannot tell a key
527
+ // miss from "not a client", so any non-POSIX path (glob v13 has no
528
+ // `posix: true` and its walker joins with the platform separator; graph rows
529
+ // are a second unnormalized source) silently returned zero consumers.
530
+ const fileKey = fileRel === undefined ? undefined : normalizeRel(fileRel);
318
531
  // Local-binding → { declared export name, module } for the file's named
319
532
  // imports, so an express handler that is an imported (possibly aliased)
320
533
  // symbol resolves to the real definition rather than its local alias text.
@@ -450,24 +663,52 @@ function scanBundle(bundle, tree) {
450
663
  confidence: 0.7,
451
664
  });
452
665
  }
453
- // Consumer: axios.<verb>(url)
454
- for (const match of runCompiledPatterns(bundle.axios, tree)) {
666
+ // Consumer: <httpClient>.<verb>(url) — `axios` itself, or any receiver the
667
+ // repo pre-pass proves is an axios instance.
668
+ for (const match of runCompiledPatterns(bundle.httpClient, tree)) {
455
669
  const methodNode = match.captures.http_method;
456
670
  const pathNode = match.captures.path;
457
- if (!methodNode || !pathNode)
458
- continue;
459
- const path = unquoteLiteral(pathNode.text);
460
- if (path === null)
671
+ const objNode = match.captures.obj;
672
+ if (!methodNode || !pathNode || !objNode)
461
673
  continue;
462
- out.push({
463
- role: 'consumer',
464
- framework: 'axios',
465
- method: methodNode.text.toUpperCase(),
466
- path,
467
- name: null,
468
- line: pathNode.startPosition.row + 1,
469
- confidence: 0.7,
470
- });
674
+ // Receiver gate. `axios.get(...)` needs no proof; anything else must be
675
+ // traced to an `axios.create(...)` binding, or it is not ours to claim.
676
+ const receiver = objNode.text;
677
+ // Cross-file resolution is the only work in this file that walks a
678
+ // repo-wide graph, and `HttpLanguagePlugin.scan` may not throw: a single
679
+ // hostile call site must cost its own detection, not the repo's whole
680
+ // contract set (`sync.ts` catches a throw here as an unexplained "missing
681
+ // repo", silently, for every contract type).
682
+ try {
683
+ // The receiver is admitted when it IS the axios module — the bare
684
+ // spelling this pattern trusted before it was widened, or a declared
685
+ // import/require of 'axios' under any name — or when it traces to an
686
+ // `axios.create(...)` instance. Nothing else.
687
+ const isModule = facts === null || fileKey === undefined
688
+ ? receiver === 'axios'
689
+ : isAxiosNamespace(fileKey, receiver, facts);
690
+ if (!isModule) {
691
+ if (!facts || fileKey === undefined)
692
+ continue;
693
+ if (!isHttpClientRef(fileKey, receiver, facts))
694
+ continue;
695
+ }
696
+ const path = resolveConsumerPath(pathNode, facts, fileKey, isModule && (pathNode.type === 'string' || pathNode.type === 'template_string'));
697
+ if (path === null)
698
+ continue;
699
+ out.push({
700
+ role: 'consumer',
701
+ framework: 'axios',
702
+ method: methodNode.text.toUpperCase(),
703
+ path,
704
+ name: null,
705
+ line: pathNode.startPosition.row + 1,
706
+ confidence: 0.7,
707
+ });
708
+ }
709
+ catch {
710
+ // Unresolvable is the same outcome as unresolved — skip this call site.
711
+ }
471
712
  }
472
713
  // Consumer: jQuery shorthand $.get(url) / $.post(url, ...)
473
714
  for (const match of runCompiledPatterns(bundle.jqueryShorthand, tree)) {
@@ -554,15 +795,18 @@ function scanBundle(bundle, tree) {
554
795
  export const JAVASCRIPT_HTTP_PLUGIN = {
555
796
  name: 'javascript-http',
556
797
  language: JavaScript,
557
- scan: (tree) => scanBundle(JAVASCRIPT_BUNDLE, tree),
798
+ prepareRepo: buildNodeRepoContext,
799
+ scan: (tree, repoContext, fileRel) => scanBundle(JAVASCRIPT_BUNDLE, tree, repoContext, fileRel),
558
800
  };
559
801
  export const TYPESCRIPT_HTTP_PLUGIN = {
560
802
  name: 'typescript-http',
561
803
  language: TypeScript.typescript,
562
- scan: (tree) => scanBundle(TYPESCRIPT_BUNDLE, tree),
804
+ prepareRepo: buildNodeRepoContext,
805
+ scan: (tree, repoContext, fileRel) => scanBundle(TYPESCRIPT_BUNDLE, tree, repoContext, fileRel),
563
806
  };
564
807
  export const TSX_HTTP_PLUGIN = {
565
808
  name: 'tsx-http',
566
809
  language: TypeScript.tsx,
567
- scan: (tree) => scanBundle(TSX_BUNDLE, tree),
810
+ prepareRepo: buildNodeRepoContext,
811
+ scan: (tree, repoContext, fileRel) => scanBundle(TSX_BUNDLE, tree, repoContext, fileRel),
568
812
  };
@@ -80,10 +80,10 @@ export type ImportResolver = (importingFileKey: string, moduleSpec: string, repo
80
80
  * value, folding `+` concatenation and following import chains via
81
81
  * `resolveImport`, or `null` when it cannot be fully folded.
82
82
  */
83
- export declare function resolveConstant(fileKey: string, name: string, repo: RepoConstants, resolveImport: ImportResolver): string | null;
83
+ export declare function resolveConstant(fileKey: string, name: string, repo: RepoConstants, resolveImport: ImportResolver, repoKeys?: ReadonlySet<string>): string | null;
84
84
  /**
85
85
  * Resolve an inline operand list (an unnamed `+`-expression captured directly at
86
86
  * a decorator/annotation argument, e.g. `@router.get(API_V1 + "/widgets")`)
87
87
  * against `fileKey`.
88
88
  */
89
- export declare function resolveOperands(fileKey: string, operands: readonly Operand[], repo: RepoConstants, resolveImport: ImportResolver): string | null;
89
+ export declare function resolveOperands(fileKey: string, operands: readonly Operand[], repo: RepoConstants, resolveImport: ImportResolver, repoKeys?: ReadonlySet<string>): string | null;
@@ -104,10 +104,14 @@ function computeFold(fileKey, name, state, depth) {
104
104
  }
105
105
  return null;
106
106
  }
107
- function newState(repo, resolveImport) {
107
+ function newState(repo, resolveImport, repoKeys) {
108
108
  return {
109
109
  repo,
110
- repoKeys: new Set(repo.keys()),
110
+ // Materializing the key set here is O(files), and this runs once per fold —
111
+ // which is once per import hop, not once per scan. A binding that already
112
+ // holds the set (every one of them does; it is a projection of the same map
113
+ // it builds `repo` from) passes it in and skips the copy entirely.
114
+ repoKeys: repoKeys ?? new Set(repo.keys()),
111
115
  resolveImport,
112
116
  visited: new Set(),
113
117
  memo: new Map(),
@@ -118,14 +122,14 @@ function newState(repo, resolveImport) {
118
122
  * value, folding `+` concatenation and following import chains via
119
123
  * `resolveImport`, or `null` when it cannot be fully folded.
120
124
  */
121
- export function resolveConstant(fileKey, name, repo, resolveImport) {
122
- return foldName(fileKey, name, newState(repo, resolveImport), 0);
125
+ export function resolveConstant(fileKey, name, repo, resolveImport, repoKeys) {
126
+ return foldName(fileKey, name, newState(repo, resolveImport, repoKeys), 0);
123
127
  }
124
128
  /**
125
129
  * Resolve an inline operand list (an unnamed `+`-expression captured directly at
126
130
  * a decorator/annotation argument, e.g. `@router.get(API_V1 + "/widgets")`)
127
131
  * against `fileKey`.
128
132
  */
129
- export function resolveOperands(fileKey, operands, repo, resolveImport) {
130
- return foldExpr(fileKey, operands, newState(repo, resolveImport), 0);
133
+ export function resolveOperands(fileKey, operands, repo, resolveImport, repoKeys) {
134
+ return foldExpr(fileKey, operands, newState(repo, resolveImport, repoKeys), 0);
131
135
  }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * JavaScript/TypeScript binding for the language-agnostic constant resolver.
3
+ *
4
+ * Supplies the two JS-specific pieces the shared fold in `constant-resolver.ts`
5
+ * needs — {@link resolveJsImport} (import specifier → file key, honoring
6
+ * relative paths, extensionless imports, directory `index` files and bare
7
+ * alias-style specifiers) and {@link extractJsModuleFacts} (tree →
8
+ * {@link ModuleConstants} plus the export/HTTP-client facts below) — mirroring
9
+ * how `python-const-resolver.ts` binds the same core for Python (#2391).
10
+ *
11
+ * Two JS-shaped facts the Python binding has no analogue for:
12
+ *
13
+ * 1. **Object-literal path tables.** Python route constants are module-level
14
+ * scalars (`API_V1 = "/v1"`); the JS convention is one frozen table —
15
+ * `export const API_ROUTE_PATH = { LINKS: "/links", … } as const` — read at
16
+ * the call site as `API_ROUTE_PATH.LINKS`. The extractor flattens such a
17
+ * table into DOTTED literal keys (`API_ROUTE_PATH.LINKS` → `/links`) so the
18
+ * agnostic fold, which does a plain `literals.get(name)`, resolves a member
19
+ * reference with no changes to the core.
20
+ *
21
+ * 2. **Export aliasing.** `export default routeApiClient` and
22
+ * `export { a as b }` mean the name an importer writes is often not the
23
+ * name the defining file bound. {@link JsModuleFacts.exports} maps the
24
+ * EXPORTED name (including `default`) to the local one so a cross-file
25
+ * chase lands on the right binding.
26
+ *
27
+ * Both stay in this binding — the shared core keeps knowing nothing about any
28
+ * language.
29
+ *
30
+ * Keying matches the Python binding: the repo map is keyed by unique POSIX file
31
+ * path, and an import that cannot be pinned to exactly one file resolves to
32
+ * `null` (skip) rather than an arbitrary winner. An unresolved path is a
33
+ * missing contract; a wrongly-resolved one is a false cross-repo link, which is
34
+ * strictly worse.
35
+ */
36
+ import type Parser from 'tree-sitter';
37
+ import { type ImportResolver, type ModuleConstants, type Operand, type RepoConstants } from './constant-resolver.js';
38
+ export type { ImportBinding, ModuleConstants, Operand, RepoConstants, } from './constant-resolver.js';
39
+ /**
40
+ * Per-file facts beyond the agnostic {@link ModuleConstants}: which exported
41
+ * name maps to which local binding, and which local bindings hold an HTTP
42
+ * client instance.
43
+ */
44
+ export interface JsModuleFacts {
45
+ /** String constants, dotted table members, `+`-expressions and imports. */
46
+ readonly constants: ModuleConstants;
47
+ /** Exported name (incl. `default`) → local binding name in this file. */
48
+ readonly exports: Map<string, string>;
49
+ /**
50
+ * Module specifiers this file re-exports wholesale (`export * from './m'`).
51
+ * A directory barrel is built almost entirely out of these, and a barrel is
52
+ * what application code imports — so without following them, every name
53
+ * reached through one resolves to nothing.
54
+ */
55
+ readonly starExports: string[];
56
+ /**
57
+ * Local names proven to hold an HTTP client INSTANCE — bound directly to
58
+ * `axios.create(...)`, or to another local name that is one. Cross-file
59
+ * chains are followed at query time by {@link isHttpClientRef}, not here.
60
+ */
61
+ readonly clients: Set<string>;
62
+ /**
63
+ * True when this file declares its own top-level binding named `axios` that
64
+ * is NOT the axios module.
65
+ *
66
+ * The bare spelling `axios` is trusted without proof — it predates this
67
+ * binding and is what the original query matched on. That is right for
68
+ * `import axios from 'axios'` and for `const axios = require('axios')`, and
69
+ * wrong for `const axios = fakeFactory`, where the spelling is the only
70
+ * evidence and it is false. One flag, because the shortcut only ever applies
71
+ * to this one name.
72
+ */
73
+ readonly axiosShadowed: boolean;
74
+ }
75
+ /**
76
+ * Repo-wide facts, with everything the shared fold needs precomputed.
77
+ *
78
+ * `constants`, `keys` and `resolveImport` are derived from `byFile` and built
79
+ * ONCE by {@link buildJsRepoFacts}, never per lookup: materializing a key set
80
+ * at each call site makes every resolution O(files) and the whole scan
81
+ * quadratic in a repo's file count. That was only half true before —
82
+ * `resolveConstant` rebuilt its own key set on every fold regardless, so the
83
+ * mitigation this comment describes was not in force for any resolution that
84
+ * went through the shared core. It now takes `keys` as an argument.
85
+ */
86
+ export interface JsRepoFacts {
87
+ readonly byFile: ReadonlyMap<string, JsModuleFacts>;
88
+ readonly constants: RepoConstants;
89
+ readonly keys: ReadonlySet<string>;
90
+ /**
91
+ * {@link resolveJsImport} bound to a prebuilt basename index and memoized for
92
+ * the lifetime of the facts. Every resolution inside this module goes through
93
+ * it rather than the bare export: the widened consumer query matches every
94
+ * `<identifier>.<verb>(…)` call in the repo, so an unindexed lookup ran once
95
+ * per call site over every repo key.
96
+ */
97
+ readonly resolveImport: ImportResolver;
98
+ }
99
+ /** Build the {@link JsRepoFacts} projections from per-file facts. */
100
+ export declare function buildJsRepoFacts(byFile: ReadonlyMap<string, JsModuleFacts>): JsRepoFacts;
101
+ /**
102
+ * Standalone {@link ImportResolver} — the same rules as {@link resolveImportWith}
103
+ * with the basename index built on the spot.
104
+ *
105
+ * Production goes through `JsRepoFacts.resolveImport`, which holds one index
106
+ * for the whole repo and memoizes; this export exists so the resolution rules
107
+ * can be exercised directly against a key set.
108
+ */
109
+ export declare const resolveJsImport: ImportResolver;
110
+ /**
111
+ * Parse a `+`-concatenation / template string into an operand list the shared
112
+ * fold can resolve, or `null` when a term is not a string literal or a
113
+ * resolvable name reference.
114
+ *
115
+ * Handles the two shapes a JS route path is built with:
116
+ * `BASE + "/users"` → [ref BASE, literal /users]
117
+ * `` `${BASE}/users/${id}` `` → [ref BASE, literal /users/, ref id]
118
+ *
119
+ * A member reference inside either (`${API_ROUTE_PATH.LISTS}`) becomes a
120
+ * dotted `ref`, which the flattened table above resolves directly.
121
+ */
122
+ export declare function parseJsConstOperands(node: Parser.SyntaxNode, depth?: number): Operand[] | null;
123
+ /**
124
+ * The dotted name a member expression denotes (`A.B.C`), or `null` for a
125
+ * computed / non-identifier chain (`A[key]`, `fn().B`) that has no stable
126
+ * textual key.
127
+ */
128
+ export declare function dottedNameOf(node: Parser.SyntaxNode): string | null;
129
+ /**
130
+ * Extract one file's {@link JsModuleFacts} from its parsed tree.
131
+ *
132
+ * Only TOP-LEVEL declarations are collected. A route table or an API client
133
+ * defined inside a function body is not a module constant, and treating it as
134
+ * one would let an unrelated same-named local shadow the real export.
135
+ */
136
+ export declare function extractJsModuleFacts(tree: Parser.Tree): JsModuleFacts;
137
+ /**
138
+ * Resolve a path reference at a call site to its literal string, or `null`.
139
+ *
140
+ * `ref` is the dotted name as written (`API_ROUTE_PATH.LINKS`, or a bare
141
+ * `BASE_PATH`). Resolution order:
142
+ *
143
+ * 1. The dotted name as a constant of the CURRENT file — hits when the table
144
+ * is declared in the same file (flattened to dotted literal keys).
145
+ * 2. The base name as an IMPORT of the current file — hop to the defining
146
+ * file and look the dotted name up there, re-hopping through barrels that
147
+ * re-export it, bounded by {@link MAX_REEXPORT_HOPS}.
148
+ *
149
+ * Returns `null` on anything it cannot fully fold, which leaves the call site
150
+ * exactly as unmatched as it is today — never a guessed path.
151
+ */
152
+ export declare function resolveJsMemberPath(fileKey: string, ref: string, facts: JsRepoFacts): string | null;
153
+ /**
154
+ * Resolve the first argument of an HTTP call to a path string, or `null` when
155
+ * the expression is not a path shape this binding understands.
156
+ *
157
+ * Accepts a plain literal, a constant reference (`BASE_PATH`), a table member
158
+ * (`API_ROUTE_PATH.LINKS`), a template string, and a `+`-concatenation of any
159
+ * of those. Template and concat forms fold PARTIALLY — see
160
+ * {@link foldTermOrPlaceholder}.
161
+ *
162
+ * A reference that resolves to nothing returns `null` (skip), and so does a
163
+ * mixed expression whose leading term is unresolved — see {@link FoldedPath}.
164
+ */
165
+ export declare function resolveJsPathExpression(fileKey: string, node: Parser.SyntaxNode, facts: JsRepoFacts): string | null;
166
+ /**
167
+ * Whether `name`, as referenced in `fileKey`, holds an HTTP client instance.
168
+ *
169
+ * Chases local aliases and import/export hops so the common app shape —
170
+ * `axios.create()` in `lib/axios.config.ts`, `export default apiClient`,
171
+ * `import apiClient from '@/lib/axios.config'` at the call site — is proven
172
+ * rather than pattern-matched on the receiver's spelling.
173
+ *
174
+ * Deliberately conservative: an unproven receiver returns `false`, which keeps
175
+ * today's behavior for it. The alternative — trusting any identifier with an
176
+ * HTTP-verb method — would classify every Express `router.get('/x', handler)`
177
+ * provider as a consumer of itself.
178
+ */
179
+ /**
180
+ * Whether `name`, as a receiver in `fileKey`, IS the axios module — as opposed
181
+ * to an instance built from it, which is {@link isHttpClientRef}'s question.
182
+ *
183
+ * Two ways to be it. The bare spelling `axios` predates the widened query — it
184
+ * is what the original `(#eq? @obj "axios")` pattern matched — so it stays
185
+ * trusted by default, and a file with no facts keeps exactly that behavior; it
186
+ * is withdrawn only where the file itself binds that name to something else.
187
+ * The other way is a declared import or `require` of `'axios'` under any local
188
+ * name, which is proof rather than convention and covers the aliased form the
189
+ * spelling rule cannot see.
190
+ */
191
+ export declare function isAxiosNamespace(fileKey: string, name: string, facts: JsRepoFacts): boolean;
192
+ export declare function isHttpClientRef(fileKey: string, name: string, facts: JsRepoFacts): boolean;