From 031e123731b9e335a5931befdd0ea0e4ed886371 Mon Sep 17 00:00:00 2001 From: DuduPhudu <34869259+ReidenXerx@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:34:23 +0300 Subject: [PATCH] fix(group): resolve HTTP consumers through configured clients and constant route tables (#3008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(group): resolve HTTP consumers through configured clients and constant route tables Cross-repo linking found almost no frontend consumers because the Node/TS consumer pattern required two things application code never has: a receiver literally spelled `axios`, and an HTTP path that is a string literal at the call site. Real apps call a configured instance and pass the path by reference from a shared route table, so both halves of every call live in other files. Widen the pattern to any identifier receiver with an HTTP-verb method, then admit the match only after PROVING the receiver is an axios instance — following local aliases, default/named imports and `export *` barrels back to an `axios.create(...)`, including when that call is an argument to a factory that decorates and returns the instance. The proof gate is load-bearing: EXPRESS_SPEC matches `router.get('/x', handler)` as a provider, so admitting a receiver on spelling alone would re-emit every Express route as a consumer of itself. Resolve the path argument through the existing language-agnostic constant fold (`constant-resolver.ts`, #2391) via a new JS/TS binding, mirroring how `python-const-resolver.ts` binds the same core. The binding adds the two JS-shaped facts Python has no analogue for: object-literal route tables flattened to dotted literal keys (`API_ROUTE_PATH.LINKS`), and export aliasing (`export default`, `export { a as b }`, `export *`). Templates and `+` concats fold partially, so a mixed path keeps its known prefix instead of collapsing to `{param}/{param}/...`. Cross-file facts come from a `prepareRepo` pre-pass, the hook FastAPI prefix resolution already uses. The three JS/TS plugins share one pass via a WeakMap keyed on the orchestrator's memoized file list. Every resolution floors to `null` (skip) rather than a guess: an ambiguous import specifier, an unprovable receiver, or a fold that overruns its depth leaves the call site exactly as unmatched as before. An unresolved path is a missing contract; a wrong one is a false cross-repo link. Measured on a real Next.js frontend (874 source files): consumer contracts 7 -> 160, none lost. Co-Authored-By: Claude Opus 5 (1M context) * fix(group): tighten the JS/TS HTTP consumer proof gates and bound the fold Addresses the review findings on #3008. Widening the axios consumer query moved precision out of the tree-sitter pattern and into runtime gates; most of these are one of those gates leaking. Keying - scanBundle normalizes fileRel ONCE and uses that key for both the receiver gate and the path fold. isHttpClientRef read the raw value while the fact map is written under normalizeRel(rel), so any non POSIX path returned zero consumers and a key miss is indistinguishable from "not a client". Proof - containsAxiosCreate (subtree containment) becomes bindsAxiosClient: the instance must be the bound VALUE, or reachable inside the arguments of a wrapping call whose result is bound. An object literal, ternary, array or new X(...) binding no longer makes a cache or registry an HTTP consumer. - A folded first argument must look like a path: no whitespace, not wholly numeric, and not starting with an unresolved term. The check runs on the ${...} to {param} normalized shape, so a placeholder whose source contains spaces does not drop an otherwise anchored path. - A template or concat whose LEADING term never resolved returns null, which is what the docstring always claimed. - The literal receiver axios with a literal or template argument keeps its pre-PR output verbatim, so the widening only adds detections. Resolution - resolveJsImport checks ambiguity across ALL candidate extensions, not within one, so a .ts/.tsx or .ts/index.ts collision skips instead of picking a winner. Two spellings of one module still resolve by precedence. - A single segment bare specifier with no alias sigil never binds to a repo file, so a Node builtin or npm package cannot be "proven" an axios client. - resolveExportedMember walks every export * edge and returns null when two barrels answer differently. - Imports are collected in a hoisting pre-pass, so a client bound above its own import statement is still proven. Termination and cost - MAX_EXPR_DEPTH and MAX_CONCAT_TERMS bound the path fold, flattenConcat walks the left spine iteratively, and buildImportMap is explicit stack. A file nesting template substitutions 4000 deep threw RangeError out of scan, which sync.ts records as an unexplained missing repo with every contract dropped. - MAX_FOLD_LENGTH applies to accumulated output, not per term, and to the raw literal fallback. The per term cap was a 2048x amplifier and the result is persisted into contractId. - resolveJsImport is backed by a basename index and memoized per repo, and resolveConstant accepts the key set instead of rebuilding it per fold. 2000 file repo with one bare npm import: 11074 ms to 1250 ms. - prepareRepo measures its ceiling in bytes, parses inside the try, and skips the parse pass entirely when the string axios appears in no candidate file. It carries only file identities between its two passes, never their text. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV * fix(group): let a path-shaped all-numeric consumer path through the gate The shape gate rejected any wholly numeric path, which also dropped `client.get('/123')`. The leading slash is the evidence that separates a route from a constant that merely folded to digits: a bare "5000" out of `CONFIG.TIMEOUT` still matches every one-segment provider route and is still refused, while a path written as a path is kept and normalized to {param} the same way it always was. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV * style: apply prettier to the changed files Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV * fix(group): decide the axios receiver on evidence, not only on its spelling The bare name `axios` was trusted with no proof, which is right for the convention and wrong for a file that binds that name itself: `const axios = fakeFactory; const api = axios.create(); api.get('/x')` was admitted as an HTTP consumer, and so was a test file whose `axios` is a mock object with a `create` method. extractJsModuleFacts now records whether the file declares its own top-level `axios` binding, and the spelling is trusted only when it does not. The other half of the same fact is that CommonJS was invisible: `const ax = require('axios')` resolved to nothing at all, and the un-aliased form worked only because `axios` happened to be the name the spelling shortcut trusted. Requires are collected alongside imports now, so a receiver is admitted when it IS the axios module (the bare spelling, or a declared import or require of 'axios' under any name) or when it traces to an `axios.create(...)` instance. Verified across the receiver matrix: shadowed local, shadowed mock object, CJS require aliased and not, ESM import aliased and not, express router and a plain Map all land where they should. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Gergő Magyar Co-authored-by: Gergo Magyar --- .../group/extractors/http-patterns/node.ts | 358 ++++- .../route-extractors/constant-resolver.ts | 18 +- .../route-extractors/js-const-resolver.ts | 1213 +++++++++++++++++ .../group/js-http-consumer-resolution.test.ts | 845 ++++++++++++ 4 files changed, 2391 insertions(+), 43 deletions(-) create mode 100644 gitnexus/src/core/ingestion/route-extractors/js-const-resolver.ts create mode 100644 gitnexus/test/unit/group/js-http-consumer-resolution.test.ts diff --git a/gitnexus/src/core/group/extractors/http-patterns/node.ts b/gitnexus/src/core/group/extractors/http-patterns/node.ts index edd0921f1..fa7c453df 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/node.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/node.ts @@ -9,11 +9,21 @@ import { type LanguagePatterns, type PatternSpec, } from '../tree-sitter-scanner.js'; -import type { HttpDetection, HttpLanguagePlugin } from './types.js'; +import type { HttpDetection, HttpLanguagePlugin, RepoContext } from './types.js'; +import { MAX_FOLD_LENGTH } from '../../../ingestion/route-extractors/constant-resolver.js'; import { DATA_ROUTE_TABLE_SOURCE, scanDataRouteTables, } from '../../../ingestion/route-extractors/data-route-table.js'; +import { + buildJsRepoFacts, + extractJsModuleFacts, + isAxiosNamespace, + isHttpClientRef, + resolveJsPathExpression, + type JsModuleFacts, + type JsRepoFacts, +} from '../../../ingestion/route-extractors/js-const-resolver.js'; /** * Node.js / TypeScript HTTP plugin family. Handles: @@ -98,15 +108,28 @@ const FETCH_WITH_OPTIONS_SPEC: PatternSpec> = { `, }; -// ─── Consumer: axios.get/post/... ──────────────────────────────────── -const AXIOS_SPEC: PatternSpec> = { +// ─── Consumer: .get/post/... ───────────────────────────── +// Widened from a literal `axios` receiver with a literal path. Application +// code satisfies neither: it calls through a configured instance +// (`const api = axios.create({ baseURL })`, imported at the call site under +// whatever name the app chose) and passes the path by reference from a shared +// route table (`api.get(API_ROUTE_PATH.LINKS)`). The query therefore matches +// ANY identifier receiver with an HTTP-verb method and ANY first argument; +// `scanBundle` admits a match only after PROVING the receiver is an axios +// instance and resolving the argument to a path. +// +// The proof gate is load-bearing, not belt-and-braces: EXPRESS_SPEC above +// matches `router.get('/x', handler)` / `app.post(...)` as PROVIDERS. A +// receiver admitted on spelling alone would re-emit every Express route in the +// repo as a consumer of itself, on both sides of every cross-repo pair. +const HTTP_CLIENT_SPEC: PatternSpec> = { meta: {}, query: ` (call_expression function: (member_expression - object: (identifier) @obj (#eq? @obj "axios") + object: (identifier) @obj property: (property_identifier) @http_method (#match? @http_method "^(get|post|put|delete|patch)$")) - arguments: (arguments . [(string) (template_string)] @path)) + arguments: (arguments . (_) @path)) `, }; @@ -158,7 +181,7 @@ interface NodePatternBundle { express: CompiledPatterns>; fetchNoOptions: CompiledPatterns>; fetchWithOptions: CompiledPatterns>; - axios: CompiledPatterns>; + httpClient: CompiledPatterns>; jqueryShorthand: CompiledPatterns>; jqueryAjax: CompiledPatterns>; axiosObject: CompiledPatterns>; @@ -177,7 +200,7 @@ function compileBundle(language: unknown, name: string): NodePatternBundle { express: mk(EXPRESS_SPEC, 'express'), fetchNoOptions: mk(FETCH_NO_OPTIONS_SPEC, 'fetch-no-options'), fetchWithOptions: mk(FETCH_WITH_OPTIONS_SPEC, 'fetch-with-options'), - axios: mk(AXIOS_SPEC, 'axios'), + httpClient: mk(HTTP_CLIENT_SPEC, 'http-client'), jqueryShorthand: mk(JQUERY_SHORTHAND_SPEC, 'jquery-shorthand'), jqueryAjax: mk(JQUERY_AJAX_SPEC, 'jquery-ajax'), axiosObject: mk(AXIOS_OBJECT_SPEC, 'axios-object'), @@ -309,12 +332,22 @@ function findDecoratedMethod(decoratorNode: Parser.SyntaxNode): Parser.SyntaxNod */ function buildImportMap(tree: Parser.Tree): Map { const map = new Map(); - const walk = (node: Parser.SyntaxNode): void => { + // Both walks are explicit-stack, not recursive. They visit EVERY node of the + // file, so their depth is the source's nesting depth — and `scan` may not + // throw: a `RangeError` here escapes to `sync.ts`, which records the repo as + // an unexplained "missing repo" and drops every contract of every kind for + // it, silently. A file nesting template substitutions ~4 000 deep (well + // inside what tree-sitter will parse) was enough. + const stack: Parser.SyntaxNode[] = [tree.rootNode]; + while (stack.length > 0) { + const node = stack.pop() as Parser.SyntaxNode; if (node.type === 'import_statement') { const sourceNode = node.childForFieldName('source'); const module = sourceNode ? unquoteLiteral(sourceNode.text) : null; if (module !== null) { - const collect = (n: Parser.SyntaxNode): void => { + const inner: Parser.SyntaxNode[] = [node]; + while (inner.length > 0) { + const n = inner.pop() as Parser.SyntaxNode; if (n.type === 'import_specifier') { const nameNode = n.childForFieldName('name'); const aliasNode = n.childForFieldName('alias'); @@ -323,25 +356,234 @@ function buildImportMap(tree: Parser.Tree): Map(); + +/** + * Skip ceiling for the pre-pass, mirroring the analyzer's default + * `--max-file-size`. A minified bundle is megabytes on one line and defines no + * route table a human wrote; parsing it costs far more than it can return. + */ +const MAX_PREPASS_FILE_BYTES = 512 * 1024; + +/** Repo-relative path in the same POSIX form the fact map is keyed by. */ +function normalizeRel(rel: string): string { + return rel.replace(/\\/g, '/').replace(/^\.\//, ''); +} + +/** The grammar a JS/TS-family file should be parsed with, or null if not one. */ +function grammarForFile(rel: string): unknown | null { + const lower = rel.toLowerCase(); + if (lower.endsWith('.tsx')) return TypeScript.tsx; + if (/\.[cm]?ts$/.test(lower)) return TypeScript.typescript; + if (/\.[cm]?jsx?$/.test(lower)) return JavaScript; + return null; +} + +function buildNodeRepoContext(args: { + files: string[]; + readFile: (rel: string) => string | null; + parseSource: (parser: Parser, src: string) => Parser.Tree | null; +}): NodeRepoContext { + const cached = REPO_CONTEXT_BY_FILE_LIST.get(args.files); + if (cached) return cached; + + const byFile = new Map(); + const parsers = new Map(); + const parserFor = (language: unknown): Parser => { + let parser = parsers.get(language); + if (!parser) { + parser = new Parser(); + parser.setLanguage(language as Parameters[0]); + parsers.set(language, parser); + } + return parser; + }; + + // Cost gate, in the spirit of the sibling `python.ts` pre-pass: every fact + // this map holds exists to prove a receiver is an axios instance or to fold a + // path for one. A repo where the string `axios` appears nowhere can prove no + // receiver, so every parse below is dead work — and parsing is the expensive + // half (measured 4.36 s / +258 MB RSS over 827 TypeScript files, on top of + // the parse `getScanInput` already does). + // Only the file's identity is carried between the passes, never its text: a + // large monorepo's whole source tree held in one array at once is the shape + // that produced the analyzer's scale problems, and the second read is cheap + // beside the parse it gates. + const eligible: Array<{ rel: string; language: unknown }> = []; + let sawAxios = false; + for (const rel of args.files) { + const language = grammarForFile(rel); + if (language === null) continue; + const content = args.readFile(rel); + // `MAX_PREPASS_FILE_BYTES` is a BYTE ceiling; `String.length` counts UTF-16 + // code units, which under-counts every multi-byte source. + if (content === null || Buffer.byteLength(content, 'utf8') > MAX_PREPASS_FILE_BYTES) continue; + if (!sawAxios && content.includes('axios')) sawAxios = true; + eligible.push({ rel, language }); + } + + if (sawAxios) { + for (const { rel, language } of eligible) { + try { + const content = args.readFile(rel); + if (content === null) continue; + // `parseSource` belongs INSIDE the guard: `safe-parse.ts` throws + // `ParseTimeoutError` and makes catching it a per-caller obligation, and + // `prepareRepo` is contractually non-throwing. One escape here left the + // fact map unwritten for the WHOLE repo — and, because the orchestrator + // caches per plugin NAME, made all three JS/TS plugins re-walk it and + // fail the same way before falling back to literal-only scanning. + const tree = args.parseSource(parserFor(language), content); + if (!tree) continue; + byFile.set(normalizeRel(rel), extractJsModuleFacts(tree)); + } catch { + // One malformed file must never abort the pre-pass — it simply stays + // unresolved, exactly as it is without this pass at all. + } + } + } + + const ctx: NodeRepoContext = { facts: buildJsRepoFacts(byFile) }; + REPO_CONTEXT_BY_FILE_LIST.set(args.files, ctx); + return ctx; +} + +/** The repo facts to resolve against, or null when there was no pre-pass. */ +function resolveFactsFor( + repoContext: RepoContext | undefined, + fileRel: string | undefined, +): JsRepoFacts | null { + const ctx = repoContext as NodeRepoContext | undefined; + if (!ctx || fileRel === undefined) return null; + return ctx.facts; +} + +/** + * Whether a folded first argument is plausibly a URL path. + * + * The query now captures ANY first argument, and "it folded to a string" is not + * "it is a path" — `normalizeConsumerPath` is a canonicalizer, not a validator, + * and it happily turns non-paths into contracts that exact-match real provider + * routes: + * + * api.get(CONFIG.TIMEOUT) // "5000" -> http::GET::/{param} + * api.post(MSG.ERROR) // "Could not reach the …" -> http::POST::/could not reach the server + * + * `/{param}` matches every one-segment provider route in the group, and + * `matching.exclude_links_param_only_paths` defaults to `false`. A path whose + * leading term is an unresolved placeholder is refused for the same reason — + * nothing pins where it starts. (`resolveJsPathExpression` already refuses those + * it folded itself; this also covers the literal fallback below.) + */ +function looksLikeHttpPath(path: string): boolean { + if (path === '') return false; + if (/^https?:\/\//i.test(path)) return true; + // A `${…}` term is a runtime value that `normalizeConsumerPath` rewrites to + // `{param}`; its SOURCE text can be any expression (`${draft ? 'a' : 'b'}`, + // `${id ?? ''}`), so the checks below have to run against the normalized + // shape. Testing the raw source dropped every partially folded path whose + // unresolved term happened to contain a space. + const shape = path.replace(/\$\{[^}]+\}/g, '{param}'); + if (/\s/.test(shape)) return false; + if (shape.startsWith('{param}')) return false; + // An all-digit string is a path only when it is written as one. A leading + // slash is that evidence: `client.get('/123')` is a route whose segment the + // consumer normalizer reads as `{param}`, while a bare `"5000"` folded out of + // `CONFIG.TIMEOUT` is a timeout that would match every one-segment provider. + if (!shape.startsWith('/')) return !/^\d+$/.test(shape); + return true; +} + +/** + * The path a consumer call's first argument denotes. + * + * Prefers full resolution against the repo facts; falls back to the raw + * literal for a string/template node so a repo with no pre-pass (or an + * unresolvable reference) behaves exactly as it did before. + * + * `fileKey` is already `normalizeRel`-ed by the caller — see `scanBundle`. + * + * `legacyShape` marks the exact combination this pattern matched BEFORE it was + * widened: the literal receiver `axios` with a string or template-string first + * argument. That combination keeps its old output verbatim, so this PR adds + * detections without removing any — `axios.get(`${API_BASE}/users`)` still + * yields `/{param}/users`. Everything the widened query NEWLY admits (any other + * receiver, or any non-literal argument) has to clear the gates. + */ +function resolveConsumerPath( + pathNode: Parser.SyntaxNode, + facts: JsRepoFacts | null, + fileKey: string | undefined, + legacyShape: boolean, +): string | null { + if (facts && fileKey !== undefined) { + const resolved = resolveJsPathExpression(fileKey, pathNode, facts); + if (resolved !== null && looksLikeHttpPath(resolved)) return resolved; + } + // The fallback is deliberately gated on node TYPE: `unquoteLiteral` returns + // unrecognized input unchanged, so handing it a `member_expression` would + // yield the literal text `API_ROUTE_PATH.LINKS` as if it were a URL path. + if (pathNode.type !== 'string' && pathNode.type !== 'template_string') return null; + const literal = unquoteLiteral(pathNode.text); + // The fold bails past `MAX_FOLD_LENGTH`; the raw source it falls back to has + // no such bound and lands in `contractId` and `meta.path` all the same. + if (literal === null || literal.length > MAX_FOLD_LENGTH) return null; + return legacyShape || looksLikeHttpPath(literal) ? literal : null; +} + +function scanBundle( + bundle: NodePatternBundle, + tree: Parser.Tree, + repoContext?: RepoContext, + fileRel?: string, +): HttpDetection[] { const out: HttpDetection[] = []; + // Repo-wide constant / HTTP-client facts, when the orchestrator ran the + // `prepareRepo` pre-pass. Absent for a bare `scan(tree)` call, in which case + // every cross-file resolution below floors to the literal-only behavior. + const facts = resolveFactsFor(repoContext, fileRel); + // The fact map is keyed by `normalizeRel(rel)`. Normalizing at ONE place and + // using that value for every read keeps the two sides in step: the receiver + // gate used to read the raw `fileRel`, and `isHttpClientRef` cannot tell a key + // miss from "not a client", so any non-POSIX path (glob v13 has no + // `posix: true` and its walker joins with the platform separator; graph rows + // are a second unnormalized source) silently returned zero consumers. + const fileKey = fileRel === undefined ? undefined : normalizeRel(fileRel); // Local-binding → { declared export name, module } for the file's named // imports, so an express handler that is an imported (possibly aliased) // symbol resolves to the real definition rather than its local alias text. @@ -471,22 +713,57 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection }); } - // Consumer: axios.(url) - for (const match of runCompiledPatterns(bundle.axios, tree)) { + // Consumer: .(url) — `axios` itself, or any receiver the + // repo pre-pass proves is an axios instance. + for (const match of runCompiledPatterns(bundle.httpClient, tree)) { const methodNode = match.captures.http_method; const pathNode = match.captures.path; - if (!methodNode || !pathNode) continue; - const path = unquoteLiteral(pathNode.text); - if (path === null) continue; - out.push({ - role: 'consumer', - framework: 'axios', - method: methodNode.text.toUpperCase(), - path, - name: null, - line: pathNode.startPosition.row + 1, - confidence: 0.7, - }); + const objNode = match.captures.obj; + if (!methodNode || !pathNode || !objNode) continue; + + // Receiver gate. `axios.get(...)` needs no proof; anything else must be + // traced to an `axios.create(...)` binding, or it is not ours to claim. + const receiver = objNode.text; + + // Cross-file resolution is the only work in this file that walks a + // repo-wide graph, and `HttpLanguagePlugin.scan` may not throw: a single + // hostile call site must cost its own detection, not the repo's whole + // contract set (`sync.ts` catches a throw here as an unexplained "missing + // repo", silently, for every contract type). + try { + // The receiver is admitted when it IS the axios module — the bare + // spelling this pattern trusted before it was widened, or a declared + // import/require of 'axios' under any name — or when it traces to an + // `axios.create(...)` instance. Nothing else. + const isModule = + facts === null || fileKey === undefined + ? receiver === 'axios' + : isAxiosNamespace(fileKey, receiver, facts); + if (!isModule) { + if (!facts || fileKey === undefined) continue; + if (!isHttpClientRef(fileKey, receiver, facts)) continue; + } + + const path = resolveConsumerPath( + pathNode, + facts, + fileKey, + isModule && (pathNode.type === 'string' || pathNode.type === 'template_string'), + ); + if (path === null) continue; + + out.push({ + role: 'consumer', + framework: 'axios', + method: methodNode.text.toUpperCase(), + path, + name: null, + line: pathNode.startPosition.row + 1, + confidence: 0.7, + }); + } catch { + // Unresolvable is the same outcome as unresolved — skip this call site. + } } // Consumer: jQuery shorthand $.get(url) / $.post(url, ...) @@ -574,17 +851,20 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection export const JAVASCRIPT_HTTP_PLUGIN: HttpLanguagePlugin = { name: 'javascript-http', language: JavaScript, - scan: (tree) => scanBundle(JAVASCRIPT_BUNDLE, tree), + prepareRepo: buildNodeRepoContext, + scan: (tree, repoContext, fileRel) => scanBundle(JAVASCRIPT_BUNDLE, tree, repoContext, fileRel), }; export const TYPESCRIPT_HTTP_PLUGIN: HttpLanguagePlugin = { name: 'typescript-http', language: TypeScript.typescript, - scan: (tree) => scanBundle(TYPESCRIPT_BUNDLE, tree), + prepareRepo: buildNodeRepoContext, + scan: (tree, repoContext, fileRel) => scanBundle(TYPESCRIPT_BUNDLE, tree, repoContext, fileRel), }; export const TSX_HTTP_PLUGIN: HttpLanguagePlugin = { name: 'tsx-http', language: TypeScript.tsx, - scan: (tree) => scanBundle(TSX_BUNDLE, tree), + prepareRepo: buildNodeRepoContext, + scan: (tree, repoContext, fileRel) => scanBundle(TSX_BUNDLE, tree, repoContext, fileRel), }; diff --git a/gitnexus/src/core/ingestion/route-extractors/constant-resolver.ts b/gitnexus/src/core/ingestion/route-extractors/constant-resolver.ts index c96406d87..9b70b458c 100644 --- a/gitnexus/src/core/ingestion/route-extractors/constant-resolver.ts +++ b/gitnexus/src/core/ingestion/route-extractors/constant-resolver.ts @@ -175,10 +175,18 @@ function computeFold( return null; } -function newState(repo: RepoConstants, resolveImport: ImportResolver): ResolveState { +function newState( + repo: RepoConstants, + resolveImport: ImportResolver, + repoKeys?: ReadonlySet, +): ResolveState { return { repo, - repoKeys: new Set(repo.keys()), + // Materializing the key set here is O(files), and this runs once per fold — + // which is once per import hop, not once per scan. A binding that already + // holds the set (every one of them does; it is a projection of the same map + // it builds `repo` from) passes it in and skips the copy entirely. + repoKeys: repoKeys ?? new Set(repo.keys()), resolveImport, visited: new Set(), memo: new Map(), @@ -195,8 +203,9 @@ export function resolveConstant( name: string, repo: RepoConstants, resolveImport: ImportResolver, + repoKeys?: ReadonlySet, ): string | null { - return foldName(fileKey, name, newState(repo, resolveImport), 0); + return foldName(fileKey, name, newState(repo, resolveImport, repoKeys), 0); } /** @@ -209,6 +218,7 @@ export function resolveOperands( operands: readonly Operand[], repo: RepoConstants, resolveImport: ImportResolver, + repoKeys?: ReadonlySet, ): string | null { - return foldExpr(fileKey, operands, newState(repo, resolveImport), 0); + return foldExpr(fileKey, operands, newState(repo, resolveImport, repoKeys), 0); } diff --git a/gitnexus/src/core/ingestion/route-extractors/js-const-resolver.ts b/gitnexus/src/core/ingestion/route-extractors/js-const-resolver.ts new file mode 100644 index 000000000..131055deb --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/js-const-resolver.ts @@ -0,0 +1,1213 @@ +/** + * JavaScript/TypeScript binding for the language-agnostic constant resolver. + * + * Supplies the two JS-specific pieces the shared fold in `constant-resolver.ts` + * needs — {@link resolveJsImport} (import specifier → file key, honoring + * relative paths, extensionless imports, directory `index` files and bare + * alias-style specifiers) and {@link extractJsModuleFacts} (tree → + * {@link ModuleConstants} plus the export/HTTP-client facts below) — mirroring + * how `python-const-resolver.ts` binds the same core for Python (#2391). + * + * Two JS-shaped facts the Python binding has no analogue for: + * + * 1. **Object-literal path tables.** Python route constants are module-level + * scalars (`API_V1 = "/v1"`); the JS convention is one frozen table — + * `export const API_ROUTE_PATH = { LINKS: "/links", … } as const` — read at + * the call site as `API_ROUTE_PATH.LINKS`. The extractor flattens such a + * table into DOTTED literal keys (`API_ROUTE_PATH.LINKS` → `/links`) so the + * agnostic fold, which does a plain `literals.get(name)`, resolves a member + * reference with no changes to the core. + * + * 2. **Export aliasing.** `export default routeApiClient` and + * `export { a as b }` mean the name an importer writes is often not the + * name the defining file bound. {@link JsModuleFacts.exports} maps the + * EXPORTED name (including `default`) to the local one so a cross-file + * chase lands on the right binding. + * + * Both stay in this binding — the shared core keeps knowing nothing about any + * language. + * + * Keying matches the Python binding: the repo map is keyed by unique POSIX file + * path, and an import that cannot be pinned to exactly one file resolves to + * `null` (skip) rather than an arbitrary winner. An unresolved path is a + * missing contract; a wrongly-resolved one is a false cross-repo link, which is + * strictly worse. + */ + +import type Parser from 'tree-sitter'; +import { + MAX_FOLD_LENGTH, + resolveConstant as foldConstant, + type ImportResolver, + type ModuleConstants, + type Operand, + type RepoConstants, +} from './constant-resolver.js'; + +export type { + ImportBinding, + ModuleConstants, + Operand, + RepoConstants, +} from './constant-resolver.js'; + +/** Extensions an extensionless JS/TS import may resolve to, in resolution order. */ +const JS_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts'] as const; + +/** + * Bound on the re-export chase in {@link resolveJsMemberPath}. Mirrors the + * fold's own `MAX_RESOLVE_DEPTH`: a barrel that re-exports through more hops + * than this floors to `null` (skip), never to a guess. + */ +const MAX_REEXPORT_HOPS = 8; + +/** The synthetic local name a bare `export default ` binds to. */ +const DEFAULT_LOCAL = '__default__'; + +/** + * Per-file facts beyond the agnostic {@link ModuleConstants}: which exported + * name maps to which local binding, and which local bindings hold an HTTP + * client instance. + */ +export interface JsModuleFacts { + /** String constants, dotted table members, `+`-expressions and imports. */ + readonly constants: ModuleConstants; + /** Exported name (incl. `default`) → local binding name in this file. */ + readonly exports: Map; + /** + * Module specifiers this file re-exports wholesale (`export * from './m'`). + * A directory barrel is built almost entirely out of these, and a barrel is + * what application code imports — so without following them, every name + * reached through one resolves to nothing. + */ + readonly starExports: string[]; + /** + * Local names proven to hold an HTTP client INSTANCE — bound directly to + * `axios.create(...)`, or to another local name that is one. Cross-file + * chains are followed at query time by {@link isHttpClientRef}, not here. + */ + readonly clients: Set; + /** + * True when this file declares its own top-level binding named `axios` that + * is NOT the axios module. + * + * The bare spelling `axios` is trusted without proof — it predates this + * binding and is what the original query matched on. That is right for + * `import axios from 'axios'` and for `const axios = require('axios')`, and + * wrong for `const axios = fakeFactory`, where the spelling is the only + * evidence and it is false. One flag, because the shortcut only ever applies + * to this one name. + */ + readonly axiosShadowed: boolean; +} + +/** + * Repo-wide facts, with everything the shared fold needs precomputed. + * + * `constants`, `keys` and `resolveImport` are derived from `byFile` and built + * ONCE by {@link buildJsRepoFacts}, never per lookup: materializing a key set + * at each call site makes every resolution O(files) and the whole scan + * quadratic in a repo's file count. That was only half true before — + * `resolveConstant` rebuilt its own key set on every fold regardless, so the + * mitigation this comment describes was not in force for any resolution that + * went through the shared core. It now takes `keys` as an argument. + */ +export interface JsRepoFacts { + readonly byFile: ReadonlyMap; + readonly constants: RepoConstants; + readonly keys: ReadonlySet; + /** + * {@link resolveJsImport} bound to a prebuilt basename index and memoized for + * the lifetime of the facts. Every resolution inside this module goes through + * it rather than the bare export: the widened consumer query matches every + * `.(…)` call in the repo, so an unindexed lookup ran once + * per call site over every repo key. + */ + readonly resolveImport: ImportResolver; +} + +/** + * Repo keys bucketed by final path segment. + * + * A tail lookup only ever matches keys whose last segment equals the + * candidate's last segment, so the bucket is the entire search space — turning + * an O(files) sweep per candidate into one map hit. `import-resolvers/utils.ts` + * already ships `buildSuffixIndex` for the same job, but it keeps only a first + * winner per suffix; this index has to SEE a collision to refuse it (below), so + * it keeps the whole bucket. + */ +type BasenameIndex = ReadonlyMap; + +function buildBasenameIndex(repoKeys: ReadonlySet): BasenameIndex { + const index = new Map(); + for (const key of repoKeys) { + const base = key.slice(key.lastIndexOf('/') + 1); + const bucket = index.get(base); + if (bucket) bucket.push(key); + else index.set(base, [key]); + } + return index; +} + +/** Build the {@link JsRepoFacts} projections from per-file facts. */ +export function buildJsRepoFacts(byFile: ReadonlyMap): JsRepoFacts { + const constants = new Map(); + for (const [key, value] of byFile) constants.set(key, value.constants); + const keys = new Set(byFile.keys()); + const index = buildBasenameIndex(keys); + const memo = new Map(); + const resolveImport: ImportResolver = (importingFileKey, moduleSpec, repoKeys) => { + // Only the relative arm reads `importingFileKey`, but keying on both is a + // string concat and keeps the memo correct if that ever stops being true. + // + // `repoKeys` is deliberately NOT part of the key: every caller inside this + // module passes `facts.keys`, which is fixed for the lifetime of these + // facts and is the set `index` was built from. A caller passing a different + // set would get an answer computed against `facts.keys` — so don't. + const memoKey = `${importingFileKey}\u0000${moduleSpec}`; + const cached = memo.get(memoKey); + if (cached !== undefined) return cached; + const resolved = resolveImportWith(index, importingFileKey, moduleSpec, repoKeys); + memo.set(memoKey, resolved); + return resolved; + }; + return { byFile, constants, keys, resolveImport }; +} + +function dirOf(fileKey: string): string { + const slash = fileKey.lastIndexOf('/'); + return slash >= 0 ? fileKey.slice(0, slash) : ''; +} + +/** Collapse `a/b/../c` and `./` segments in a POSIX-ish path. */ +function normalizePosix(path: string): string { + const out: string[] = []; + for (const seg of path.split('/')) { + if (seg === '' || seg === '.') continue; + if (seg === '..') { + if (out.length > 0 && out[out.length - 1] !== '..') out.pop(); + else out.push('..'); + } else { + out.push(seg); + } + } + return out.join('/'); +} + +/** + * Candidate file keys for a module path with no extension: the path itself + * (already-suffixed imports), each known extension, and the directory-`index` + * forms. Order matters only for the relative case, where the first existing + * candidate wins — matching bundler/`tsc` resolution order closely enough that + * a repo with both `x.ts` and `x.js` picks the TypeScript source. + */ +function candidatesFor(modPath: string): string[] { + const out = [modPath]; + for (const ext of JS_EXTENSIONS) out.push(`${modPath}${ext}`); + for (const ext of JS_EXTENSIONS) out.push(`${modPath}/index${ext}`); + return out; +} + +/** + * The MODULE a repo key denotes: the key without its extension, and without a + * trailing `/index`. + * + * `x/routes.ts` and `x/routes/index.ts` are two spellings of the same module + * `x/routes` — Node and `tsc` both pick the file over the directory, so a tail + * matching both is not ambiguous, it just has a precedence order. Two + * DIFFERENT identities sharing one tail is the real ambiguity, and that is what + * {@link resolveImportWith} refuses. + */ +function moduleIdentityOf(key: string): string { + for (const ext of JS_EXTENSIONS) { + if (!key.endsWith(ext)) continue; + const withoutExt = key.slice(0, -ext.length); + return withoutExt.endsWith('/index') ? withoutExt.slice(0, -'/index'.length) : withoutExt; + } + return key; +} + +/** + * The JS/TS {@link ImportResolver}. + * + * Relative specifiers (`./api-routes`, `../shared/api-routes`) resolve against + * the importing file's directory and must hit an existing key exactly. + * + * An alias-style specifier (`@/api-modules/shared/api-routes`, `~/x/y`) or a + * multi-segment bare one is matched by UNIQUE PATH SUFFIX, the same strategy + * the Python binding uses for absolute imports. This deliberately resolves + * aliases without reading `tsconfig.json`: an alias prefix is arbitrary (`@/`, + * `~/`, `#app/`, any `paths` key), but the segments AFTER it are a real path + * tail, and matching that tail against the indexed file set answers the + * question directly. + * + * Two rules keep that from inventing resolutions: + * + * - **A tail claimed by two distinct modules returns `null`**, checked across + * EVERY candidate extension rather than within one. Returning on the first + * extension that matched let precedence pre-empt the guard, so a `.ts`/`.tsx` + * or `.ts`/`.js` collision — every Next.js repo — picked an arbitrary winner + * while this docstring promised a skip. + * - **A single-segment bare specifier never matches a repo file.** `axios`, + * `lodash` and the Node builtin `http` are npm/runtime modules, not ours to + * resolve; without this, a repo holding `src/lib/http.ts` "proved" that + * `import http from 'http'` was an axios client. An alias tail always has a + * sigil or a `/`, so this costs the feature nothing. + */ +function resolveImportWith( + index: BasenameIndex, + importingFileKey: string, + moduleSpec: string, + repoKeys: ReadonlySet, +): string | null { + if (moduleSpec === '') return null; + + if (moduleSpec.startsWith('./') || moduleSpec.startsWith('../')) { + const base = dirOf(importingFileKey); + const joined = normalizePosix(`${base}/${moduleSpec}`); + // A `../` chain that climbs above the repo root leaves a leading `..` + // segment; that import escapes the indexed tree and cannot be pinned. + if (joined === '' || joined.startsWith('..')) return null; + for (const candidate of candidatesFor(joined)) { + if (repoKeys.has(candidate)) return candidate; + } + return null; + } + + // Strip a leading alias sigil so `@/a/b` and `~/a/b` reduce to the tail + // `a/b`. A scoped package (`@scope/pkg`) keeps its `@` and simply fails to + // match any repo file below, which is the desired outcome. + const aliased = /^[@~#]\//.test(moduleSpec); + const tail = aliased ? moduleSpec.slice(2) : moduleSpec; + if (tail === '' || tail.startsWith('.')) return null; + if (!aliased && !tail.includes('/')) return null; // bare npm package / Node builtin + + let winner: string | null = null; + let winnerRank = Number.POSITIVE_INFINITY; + let identity: string | null = null; + const candidates = candidatesFor(tail); + for (let rank = 0; rank < candidates.length; rank++) { + const candidate = candidates[rank]; + const bucket = index.get(candidate.slice(candidate.lastIndexOf('/') + 1)); + if (bucket === undefined) continue; + for (const key of bucket) { + if (key !== candidate && !key.endsWith(`/${candidate}`)) continue; + const keyIdentity = moduleIdentityOf(key); + if (identity === null) identity = keyIdentity; + else if (identity !== keyIdentity) return null; // two modules share this tail + if (rank < winnerRank) { + winner = key; + winnerRank = rank; + } + } + } + return winner; +} + +/** + * Standalone {@link ImportResolver} — the same rules as {@link resolveImportWith} + * with the basename index built on the spot. + * + * Production goes through `JsRepoFacts.resolveImport`, which holds one index + * for the whole repo and memoizes; this export exists so the resolution rules + * can be exercised directly against a key set. + */ +export const resolveJsImport: ImportResolver = (importingFileKey, moduleSpec, repoKeys) => + resolveImportWith(buildBasenameIndex(repoKeys), importingFileKey, moduleSpec, repoKeys); + +/** Unwrap TS `x as const` / `x satisfies T` to the underlying expression. */ +function unwrapTsExpression(node: Parser.SyntaxNode): Parser.SyntaxNode { + let cur = node; + while (cur.type === 'as_expression' || cur.type === 'satisfies_expression') { + const inner = cur.namedChild(0); + if (!inner) break; + cur = inner; + } + return cur; +} + +/** + * The literal string a node denotes, or `null` when it is not a plain literal. + * A template string counts only when it has no `${…}` substitution — an + * interpolated one is an expression, handled by {@link parseJsConstOperands}. + */ +function literalStringOf(node: Parser.SyntaxNode): string | null { + const n = unwrapTsExpression(node); + if (n.type === 'string') { + const fragments = n.namedChildren.filter((c) => c.type === 'string_fragment'); + if (fragments.length === 0) return n.namedChildren.length === 0 ? '' : null; + return fragments.map((f) => f.text).join(''); + } + if (n.type === 'template_string') { + if (n.namedChildren.some((c) => c.type === 'template_substitution')) return null; + const fragments = n.namedChildren.filter((c) => c.type === 'string_fragment'); + return fragments.map((f) => f.text).join(''); + } + return null; +} + +/** The static key a property name node denotes (`FOO`, `'foo'`, `"foo"`). */ +function staticKeyOf(node: Parser.SyntaxNode): string | null { + if (node.type === 'property_identifier' || node.type === 'identifier') return node.text; + if (node.type === 'string') return literalStringOf(node); + return null; +} + +/** + * Flatten an object literal into dotted `prefix.KEY` → literal entries. + * Nested objects recurse (`API.USERS.ME`); a computed key, a spread, or a + * non-string value is skipped — the table's other entries stay usable. + */ +function flattenObjectLiteral( + obj: Parser.SyntaxNode, + prefix: string, + into: Map, + depth = 0, +): void { + if (depth > MAX_REEXPORT_HOPS) return; + for (const pair of obj.namedChildren) { + if (pair.type !== 'pair') continue; + const keyNode = pair.childForFieldName('key'); + const valueNode = pair.childForFieldName('value'); + if (!keyNode || !valueNode) continue; + const key = staticKeyOf(keyNode); + if (key === null) continue; + const value = unwrapTsExpression(valueNode); + const literal = literalStringOf(value); + if (literal !== null) { + into.set(`${prefix}.${key}`, literal); + } else if (value.type === 'object') { + flattenObjectLiteral(value, `${prefix}.${key}`, into, depth + 1); + } + } +} + +/** + * Parse a `+`-concatenation / template string into an operand list the shared + * fold can resolve, or `null` when a term is not a string literal or a + * resolvable name reference. + * + * Handles the two shapes a JS route path is built with: + * `BASE + "/users"` → [ref BASE, literal /users] + * `` `${BASE}/users/${id}` `` → [ref BASE, literal /users/, ref id] + * + * A member reference inside either (`${API_ROUTE_PATH.LISTS}`) becomes a + * dotted `ref`, which the flattened table above resolves directly. + */ +export function parseJsConstOperands(node: Parser.SyntaxNode, depth = 0): Operand[] | null { + if (depth > MAX_EXPR_DEPTH) return null; + const n = unwrapTsExpression(node); + + const literal = literalStringOf(n); + if (literal !== null) return [{ kind: 'literal', value: literal }]; + + if (n.type === 'identifier') return [{ kind: 'ref', name: n.text }]; + + if (n.type === 'member_expression') { + const dotted = dottedNameOf(n); + return dotted === null ? null : [{ kind: 'ref', name: dotted }]; + } + + if (n.type === 'binary_expression') { + const operator = n.childForFieldName('operator'); + if (operator?.text !== '+') return null; + const left = n.childForFieldName('left'); + const right = n.childForFieldName('right'); + if (!left || !right) return null; + const l = parseJsConstOperands(left, depth + 1); + const r = parseJsConstOperands(right, depth + 1); + return l === null || r === null ? null : [...l, ...r]; + } + + if (n.type === 'template_string') { + const out: Operand[] = []; + for (const child of n.namedChildren) { + if (child.type === 'string_fragment') { + out.push({ kind: 'literal', value: child.text }); + } else if (child.type === 'template_substitution') { + const inner = child.namedChild(0); + if (!inner) return null; + const parsed = parseJsConstOperands(inner, depth + 1); + if (parsed === null) return null; + out.push(...parsed); + } + } + return out; + } + + return null; +} + +/** + * The dotted name a member expression denotes (`A.B.C`), or `null` for a + * computed / non-identifier chain (`A[key]`, `fn().B`) that has no stable + * textual key. + */ +export function dottedNameOf(node: Parser.SyntaxNode): string | null { + const parts: string[] = []; + let cur: Parser.SyntaxNode | null = node; + while (cur && cur.type === 'member_expression') { + const property = cur.childForFieldName('property'); + if (!property || property.type !== 'property_identifier') return null; + parts.unshift(property.text); + cur = cur.childForFieldName('object'); + } + if (!cur || cur.type !== 'identifier') return null; + parts.unshift(cur.text); + return parts.join('.'); +} + +/** + * How many wrapping calls {@link bindsAxiosClient} will look through. A factory + * is one hop (`setupInterceptors(axios.create())`); a couple more costs nothing + * and bounds the walk. + */ +const MAX_CLIENT_WRAP_DEPTH = 4; + +/** True when a node is `axios.create(...)`, allowing an aliased axios import. */ +function isAxiosCreateCall( + node: Parser.SyntaxNode, + imports: ReadonlyMap, + axiosShadowed: boolean, +): boolean { + if (node.type !== 'call_expression') return false; + const fn = node.childForFieldName('function'); + if (!fn || fn.type !== 'member_expression') return false; + if (fn.childForFieldName('property')?.text !== 'create') return false; + const object = fn.childForFieldName('object'); + if (!object || object.type !== 'identifier') return false; + // `import axios from 'axios'` is the overwhelming convention, but the local + // name is the importer's choice (`import ax from 'axios'`), so trust the + // module specifier over the spelling whenever the file declares one. The + // bare spelling is the fallback, and it is only evidence while the file has + // not bound that name to something else. + if (imports.get(object.text)?.module === 'axios') return true; + return object.text === 'axios' && !axiosShadowed; +} + +/** The module a `require('…')` initializer names, or `null` if it is not one. */ +function requireSpecifierOf(node: Parser.SyntaxNode): string | null { + const n = unwrapTsExpression(node); + if (n.type !== 'call_expression') return null; + if (n.childForFieldName('function')?.text !== 'require') return null; + const args = n.childForFieldName('arguments'); + const first = args?.namedChild(0); + return first ? literalStringOf(first) : null; +} + +/** + * Whether an initializer BINDS an axios instance — i.e. the instance is the + * VALUE of the binding, not merely present somewhere inside it. + * + * A direct `const api = axios.create(...)` is the textbook form, but the shape + * real applications ship is a factory that decorates the instance and hands it + * back: + * + * const routeApiClient = setupClientInterceptors({ + * axiosInstance: axios.create({ baseURL: API_URL }), + * }); + * + * Requiring the call to be the whole initializer would reject that — and it is + * the single binding every call site in such an app goes through. So a wrapping + * CALL whose result is bound counts, and the instance may be one of its + * arguments or a property of a directly-passed object literal. + * + * What does NOT count is the instance being an INGREDIENT of the bound value. + * The premise "an expression that builds an axios instance and binds the result + * is an HTTP client" is only true when the instance is the result; a plain + * subtree scan also admitted + * + * const registry = { http: axios.create(), version: 'v1' }; // object literal + * const client = MOCK ? memoryStore : axios.create(); // ternary branch + * new Map([['api', axios.create()]]); // constructor arg + * new LRUCache({ fetchMethod: axios.create().get }); // constructor arg + * + * and `.get`/`.delete` are the two most common non-HTTP method names in JS, so + * every one of those made an ordinary cache or registry an HTTP consumer. Those + * node types are simply not walked here. + * + * A nested function body is still skipped wherever it appears — a callback that + * builds its own client does not vouch for the outer name. + */ +function bindsAxiosClient( + node: Parser.SyntaxNode, + imports: ReadonlyMap, + axiosShadowed: boolean, + depth = 0, +): boolean { + if (depth > MAX_CLIENT_WRAP_DEPTH) return false; + const n = unwrapTsExpression(node); + + if (isAxiosCreateCall(n, imports, axiosShadowed)) return true; + + // Transparent wrappers around the value itself. + if ( + n.type === 'await_expression' || + n.type === 'parenthesized_expression' || + n.type === 'non_null_expression' + ) { + const inner = n.namedChild(0); + return inner !== null && bindsAxiosClient(inner, imports, axiosShadowed, depth + 1); + } + + if (n.type !== 'call_expression') return false; + + const args = n.childForFieldName('arguments'); + if (!args) return false; + for (const arg of args.namedChildren) { + if (argumentHoldsAxiosClient(arg, imports, axiosShadowed, depth + 1)) return true; + } + return false; +} + +/** + * Whether a wrapping call's ARGUMENT carries the instance. + * + * Inside an argument the instance may sit in an options object at any nesting + * (`createClient({ transport: { instance: axios.create() } })`) or in a list of + * decorators (`compose([axios.create(), withAuth])`). That is safe because the + * bound value is still the call's RESULT. It is the mirror of what + * {@link bindsAxiosClient} refuses: an object, array, ternary or `new` as the + * bound value itself never reaches here. + */ +function argumentHoldsAxiosClient( + node: Parser.SyntaxNode, + imports: ReadonlyMap, + axiosShadowed: boolean, + depth: number, +): boolean { + if (depth > MAX_CLIENT_WRAP_DEPTH) return false; + const n = unwrapTsExpression(node); + if (bindsAxiosClient(n, imports, axiosShadowed, depth)) return true; + + if (n.type === 'object') { + for (const pair of n.namedChildren) { + if (pair.type !== 'pair') continue; + const value = pair.childForFieldName('value'); + if (value && argumentHoldsAxiosClient(value, imports, axiosShadowed, depth + 1)) return true; + } + return false; + } + + if (n.type === 'array') { + for (const element of n.namedChildren) { + if (argumentHoldsAxiosClient(element, imports, axiosShadowed, depth + 1)) return true; + } + } + return false; +} + +/** + * Record one `name = value` binding into the accumulating facts. + * Shared by plain declarations and their `export const` form. + */ +function recordBinding( + name: string, + valueNode: Parser.SyntaxNode, + literals: Map, + exprs: Map, + clients: Set, + imports: ReadonlyMap, + axiosShadowed: boolean, +): void { + const value = unwrapTsExpression(valueNode); + + if (bindsAxiosClient(value, imports, axiosShadowed)) { + clients.add(name); + return; + } + + // `const client = someOtherClient` — an alias. Recorded as a client-chase + // edge (below) and as a constant ref, since one of the two will resolve. + if (value.type === 'identifier') { + exprs.set(name, [{ kind: 'ref', name: value.text }]); + return; + } + + if (value.type === 'object') { + flattenObjectLiteral(value, name, literals); + return; + } + + const literal = literalStringOf(value); + if (literal !== null) { + literals.set(name, literal); + return; + } + + const operands = parseJsConstOperands(value); + if (operands !== null) exprs.set(name, operands); +} + +/** Record every `variable_declarator` in a declaration node. */ +function recordDeclaration( + decl: Parser.SyntaxNode, + literals: Map, + exprs: Map, + clients: Set, + imports: ReadonlyMap, + axiosShadowed: boolean, + exports: Map | null, +): void { + for (const declarator of decl.namedChildren) { + if (declarator.type !== 'variable_declarator') continue; + const nameNode = declarator.childForFieldName('name'); + const valueNode = declarator.childForFieldName('value'); + if (!nameNode || nameNode.type !== 'identifier' || !valueNode) continue; + recordBinding(nameNode.text, valueNode, literals, exprs, clients, imports, axiosShadowed); + exports?.set(nameNode.text, nameNode.text); + } +} + +/** Record one `import … from 'm'` statement's local bindings. */ +function recordImportStatement( + stmt: Parser.SyntaxNode, + imports: Map, +): void { + const source = stmt.childForFieldName('source'); + const moduleSpec = source ? literalStringOf(source) : null; + if (moduleSpec === null) return; + for (const clause of stmt.namedChildren) { + if (clause.type !== 'import_clause') continue; + for (const spec of clause.namedChildren) { + // `import Default from 'm'` + if (spec.type === 'identifier') { + imports.set(spec.text, { module: moduleSpec, originalName: 'default' }); + } else if (spec.type === 'namespace_import') { + const alias = spec.namedChild(0); + // `import * as NS from 'm'` — `NS.X` resolves to the target's `X`. + if (alias) imports.set(alias.text, { module: moduleSpec, originalName: '*' }); + } else if (spec.type === 'named_imports') { + for (const named of spec.namedChildren) { + if (named.type !== 'import_specifier') continue; + const nameNode = named.childForFieldName('name'); + const aliasNode = named.childForFieldName('alias'); + if (!nameNode) continue; + const local = (aliasNode ?? nameNode).text; + imports.set(local, { module: moduleSpec, originalName: nameNode.text }); + } + } + } + } +} + +/** + * Extract one file's {@link JsModuleFacts} from its parsed tree. + * + * Only TOP-LEVEL declarations are collected. A route table or an API client + * defined inside a function body is not a module constant, and treating it as + * one would let an unrelated same-named local shadow the real export. + */ +export function extractJsModuleFacts(tree: Parser.Tree): JsModuleFacts { + const literals = new Map(); + const exprs = new Map(); + const imports = new Map(); + const exports = new Map(); + const starExports: string[] = []; + const clients = new Set(); + + // Imports first. ES module bindings are hoisted — `const c = ax.create(…)` + // above `import ax from 'axios'` is legal and binds the same `ax` — but + // `bindsAxiosClient` consults `imports` as each declaration is recorded, so + // in source order an import declared later was simply not there yet and the + // client went unproven. + // + // CommonJS `const ax = require('axios')` is collected here too. It is the + // same binding by another spelling, and without it an aliased require + // resolved to nothing at all while the un-aliased one worked only because + // `axios` happens to be the name the spelling shortcut trusts. + let axiosShadowed = false; + for (const stmt of tree.rootNode.namedChildren) { + if (stmt.type === 'import_statement') { + recordImportStatement(stmt, imports); + continue; + } + const decl = stmt.type === 'export_statement' ? stmt.childForFieldName('declaration') : stmt; + if ( + decl === null || + (decl.type !== 'lexical_declaration' && decl.type !== 'variable_declaration') + ) { + continue; + } + for (const declarator of decl.namedChildren) { + if (declarator.type !== 'variable_declarator') continue; + const nameNode = declarator.childForFieldName('name'); + const valueNode = declarator.childForFieldName('value'); + if (!nameNode || nameNode.type !== 'identifier') continue; + const required = valueNode === null ? null : requireSpecifierOf(valueNode); + if (required !== null) { + imports.set(nameNode.text, { module: required, originalName: 'default' }); + } else if (nameNode.text === 'axios') { + axiosShadowed = true; + } + } + } + + for (const stmt of tree.rootNode.namedChildren) { + if (stmt.type === 'lexical_declaration' || stmt.type === 'variable_declaration') { + recordDeclaration(stmt, literals, exprs, clients, imports, axiosShadowed, null); + continue; + } + + if (stmt.type === 'import_statement') continue; // hoisted above + + if (stmt.type !== 'export_statement') continue; + + const source = stmt.childForFieldName('source'); + const reexportFrom = source ? literalStringOf(source) : null; + const declaration = stmt.childForFieldName('declaration'); + const value = stmt.childForFieldName('value'); + + // `export const X = …` / `export default ` + if (declaration) { + if ( + declaration.type === 'lexical_declaration' || + declaration.type === 'variable_declaration' + ) { + recordDeclaration(declaration, literals, exprs, clients, imports, axiosShadowed, exports); + } + continue; + } + + if (value) { + // `export default routeApiClient` / `export default axios.create(...)` + if (value.type === 'identifier') { + exports.set('default', value.text); + } else { + recordBinding(DEFAULT_LOCAL, value, literals, exprs, clients, imports, axiosShadowed); + exports.set('default', DEFAULT_LOCAL); + } + continue; + } + + // `export * from './m'` / `export * as NS from './m'`. Neither has an + // export_clause; the namespace form additionally binds a local alias. + if (reexportFrom !== null && !stmt.namedChildren.some((c) => c.type === 'export_clause')) { + const namespaceAlias = stmt.namedChildren.find((c) => c.type === 'namespace_export'); + const alias = namespaceAlias?.namedChild(0)?.text; + if (alias !== undefined) { + imports.set(alias, { module: reexportFrom, originalName: '*' }); + exports.set(alias, alias); + } else { + starExports.push(reexportFrom); + } + continue; + } + + // `export { a, b as c }` and `export { a } from './m'` + for (const clause of stmt.namedChildren) { + if (clause.type !== 'export_clause') continue; + for (const spec of clause.namedChildren) { + if (spec.type !== 'export_specifier') continue; + const nameNode = spec.childForFieldName('name'); + const aliasNode = spec.childForFieldName('alias'); + if (!nameNode) continue; + const exported = (aliasNode ?? nameNode).text; + if (reexportFrom !== null) { + imports.set(exported, { module: reexportFrom, originalName: nameNode.text }); + exports.set(exported, exported); + } else { + exports.set(exported, nameNode.text); + } + } + } + } + + return { constants: { literals, exprs, imports }, exports, starExports, clients, axiosShadowed }; +} + +/** + * Resolve a path reference at a call site to its literal string, or `null`. + * + * `ref` is the dotted name as written (`API_ROUTE_PATH.LINKS`, or a bare + * `BASE_PATH`). Resolution order: + * + * 1. The dotted name as a constant of the CURRENT file — hits when the table + * is declared in the same file (flattened to dotted literal keys). + * 2. The base name as an IMPORT of the current file — hop to the defining + * file and look the dotted name up there, re-hopping through barrels that + * re-export it, bounded by {@link MAX_REEXPORT_HOPS}. + * + * Returns `null` on anything it cannot fully fold, which leaves the call site + * exactly as unmatched as it is today — never a guessed path. + */ +export function resolveJsMemberPath( + fileKey: string, + ref: string, + facts: JsRepoFacts, +): string | null { + const direct = foldConstant(fileKey, ref, facts.constants, facts.resolveImport, facts.keys); + if (direct !== null) return direct; + + const dot = ref.indexOf('.'); + if (dot < 0) return null; + const base = ref.slice(0, dot); + const member = ref.slice(dot + 1); + + const binding = facts.byFile.get(fileKey)?.constants.imports.get(base); + if (!binding) return null; + const targetKey = facts.resolveImport(fileKey, binding.module, facts.keys); + if (targetKey === null) return null; + + // `import * as NS from 'm'` — `NS.TABLE.KEY` addresses the target's own + // `TABLE.KEY`, so the namespace alias drops out of the reference entirely. + if (binding.originalName === '*') { + const nextDot = member.indexOf('.'); + if (nextDot < 0) return null; + return resolveExportedMember( + targetKey, + member.slice(0, nextDot), + member.slice(nextDot + 1), + facts, + 0, + new Set(), + ); + } + + return resolveExportedMember(targetKey, binding.originalName, member, facts, 0, new Set()); +} + +/** + * Resolve `.` against a module's PUBLIC surface, following + * whatever indirection stands between the name and its definition. + * + * Three ways a module can expose a name, tried in order: + * 1. it defines it (possibly under a different local name — `export { a as b }`) + * 2. it re-exports it explicitly (`export { a } from './m'`) + * 3. it re-exports a whole module (`export * from './m'`) + * + * The third is the one that matters in practice: application code imports a + * DIRECTORY (`@/api-modules/shared`), whose `index.ts` is nothing but + * `export * from './api-routes'`. Stopping at the barrel resolves nothing at + * all, so the star edges have to be walked. `seen` makes mutually-importing + * barrels terminate instead of recursing forever. + */ +function resolveExportedMember( + fileKey: string, + exported: string, + member: string, + facts: JsRepoFacts, + depth: number, + seen: Set, +): string | null { + if (depth > MAX_REEXPORT_HOPS) return null; + const guard = `${fileKey}::${exported}.${member}`; + if (seen.has(guard)) return null; + seen.add(guard); + + const file = facts.byFile.get(fileKey); + if (!file) return null; + + const local = file.exports.get(exported) ?? exported; + const here = foldConstant( + fileKey, + `${local}.${member}`, + facts.constants, + facts.resolveImport, + facts.keys, + ); + if (here !== null) return here; + + const binding = file.constants.imports.get(exported); + if (binding) { + const targetKey = facts.resolveImport(fileKey, binding.module, facts.keys); + if (targetKey !== null) { + const viaImport = resolveExportedMember( + targetKey, + binding.originalName === '*' ? exported : binding.originalName, + member, + facts, + depth + 1, + seen, + ); + if (viaImport !== null) return viaImport; + } + } + + // Every star edge is walked, not just up to the first hit: two barrels + // re-exporting the same name is ambiguous in JS itself, so answering with + // whichever module happens to come first in `starExports` would be a guess + // dressed as a resolution. + let viaStar: string | null = null; + for (const spec of file.starExports) { + const targetKey = facts.resolveImport(fileKey, spec, facts.keys); + if (targetKey === null) continue; + const found = resolveExportedMember(targetKey, exported, member, facts, depth + 1, seen); + if (found === null) continue; + if (viaStar !== null && viaStar !== found) return null; + viaStar = found; + } + + return viaStar; +} + +/** The local binding an exported name refers to in `fileKey` (identity if unaliased). */ +function resolveExportLocal(facts: JsRepoFacts, fileKey: string, exported: string): string { + return facts.byFile.get(fileKey)?.exports.get(exported) ?? exported; +} + +/** + * Recursion ceiling for the path-expression fold, and the matching term cap for + * a `+` chain. + * + * Nothing on this path was bounded before: `flattenConcat` recursed once per + * term, mutually with {@link foldTermOrPlaceholder}, on the SCAN side — which + * `prepareRepo`'s `try/catch` does not cover and which `HttpLanguagePlugin.scan` + * contractually may not throw from. ~6 400 concat terms (38 KB of source) threw + * `RangeError: Maximum call stack size exceeded` out of `extract()`, and + * `sync.ts` turns that into an unexplained "missing repo" with every contract of + * every kind — HTTP, gRPC, topics, includes — dropped for that repo and nothing + * logged. A hand-written route path is a handful of terms. + */ +const MAX_EXPR_DEPTH = 64; +const MAX_CONCAT_TERMS = 256; + +/** One folded term, and whether its text is KNOWN rather than a placeholder. */ +interface FoldedTerm { + readonly text: string; + readonly concrete: boolean; +} + +/** + * A folded path expression, and whether its FIRST term was concrete. + * + * `anchored` is what separates a partially-folded path from a fabricated one. + * `${API_ROUTE_PATH.LISTS}/${eventId}/add` is anchored — its leading segment is + * a resolved route constant and the rest is honest `{param}`s. `${base}${suffix}` + * and `${BASE}/users` are not: nothing pins where the path starts, so consumer + * normalization squashes them to `/{param}{param}` and `/{param}/users`, which + * exact-match real provider routes and invent cross-repo links. The docstring + * on {@link resolveJsPathExpression} always claimed at least one literal segment + * was required; only now is it true. + */ +interface FoldedPath { + readonly text: string; + readonly anchored: boolean; +} + +/** + * Resolve one term of a partially-foldable path, re-emitting it as a + * `${…}` placeholder when it cannot be folded. + * + * The placeholder is deliberate, not a fallback wart: consumer-side path + * normalization rewrites `${…}` to `{param}`, which is exactly the right + * reading for a term that IS a runtime value (`${eventId}`). Re-emitting keeps + * a mixed path like `` `${API_ROUTE_PATH.LISTS}/${eventId}/add` `` resolvable to + * `/curator-lists/{param}/add` instead of collapsing its known prefix to + * `{param}/{param}/add`. + */ +function foldTermOrPlaceholder( + fileKey: string, + node: Parser.SyntaxNode, + facts: JsRepoFacts, + depth: number, +): FoldedTerm | null { + if (depth > MAX_EXPR_DEPTH) return null; + const n = unwrapTsExpression(node); + + const literal = literalStringOf(n); + if (literal !== null) return { text: literal, concrete: true }; + + // A template nested inside a substitution — `` `${BASE}${`/${id}/unlike`}` `` + // is a real shape. Recursing keeps its literal segments; emitting it verbatim + // would collapse the whole inner template to one `{param}` and lose them. + if (n.type === 'template_string' || n.type === 'binary_expression') { + const nested = foldPathExpression(fileKey, n, facts, depth + 1); + if (nested !== null) return { text: nested.text, concrete: nested.anchored }; + } + + const dotted = n.type === 'identifier' ? n.text : dottedNameOf(n); + if (dotted !== null) { + const resolved = resolveJsMemberPath(fileKey, dotted, facts); + if (resolved !== null) return { text: resolved, concrete: true }; + return { text: `\${${dotted}}`, concrete: false }; + } + + return { text: `\${${n.text}}`, concrete: false }; +} + +/** Flatten a left-nested `a + b + c` chain into its terms, or `null` if not all `+`. */ +function flattenConcat(node: Parser.SyntaxNode, depth: number): Parser.SyntaxNode[] | null { + if (depth > MAX_EXPR_DEPTH) return null; + const n = unwrapTsExpression(node); + if (n.type !== 'binary_expression') return [n]; + + // The LEFT spine is walked iteratively: `a + b + c + …` parses left-nested, + // so recursing once per term is one stack frame per term. Only a `+` on the + // right can still nest, and that recursion is depth-capped. + const reversed: Parser.SyntaxNode[] = []; + let cur: Parser.SyntaxNode = n; + for (;;) { + if (reversed.length > MAX_CONCAT_TERMS) return null; + if (cur.childForFieldName('operator')?.text !== '+') return null; + const left = cur.childForFieldName('left'); + const right = cur.childForFieldName('right'); + if (!left || !right) return null; + reversed.push(right); + const nextLeft = unwrapTsExpression(left); + if (nextLeft.type !== 'binary_expression') { + reversed.push(nextLeft); + break; + } + cur = nextLeft; + } + + const out: Parser.SyntaxNode[] = []; + for (let i = reversed.length - 1; i >= 0; i--) { + const term = reversed[i]; + if (unwrapTsExpression(term).type !== 'binary_expression') { + out.push(term); + continue; + } + const nested = flattenConcat(term, depth + 1); + if (nested === null) return null; + out.push(...nested); + if (out.length > MAX_CONCAT_TERMS) return null; + } + return out; +} + +/** + * The fold behind {@link resolveJsPathExpression}, carrying the recursion depth + * and reporting whether the result is anchored. + * + * `MAX_FOLD_LENGTH` is checked on the ACCUMULATED text, not per term. The + * shared core caps each folded constant at that length; joining an unbounded + * number of them made the cap a ~2048x amplifier instead of a ceiling (each + * `${A}` costs 4 source characters and can yield 8 192), and the result is not + * transient — it becomes `contractId` and `meta.path` in `contracts.json` and + * `bridge.lbug`. Measured 200 KB of source to 941 MB of heap before this. + */ +function foldPathExpression( + fileKey: string, + node: Parser.SyntaxNode, + facts: JsRepoFacts, + depth: number, +): FoldedPath | null { + if (depth > MAX_EXPR_DEPTH) return null; + const n = unwrapTsExpression(node); + + const literal = literalStringOf(n); + if (literal !== null) return { text: literal, anchored: true }; + + if (n.type === 'identifier' || n.type === 'member_expression') { + const dotted = n.type === 'identifier' ? n.text : dottedNameOf(n); + if (dotted === null) return null; + const resolved = resolveJsMemberPath(fileKey, dotted, facts); + return resolved === null ? null : { text: resolved, anchored: true }; + } + + const terms: Parser.SyntaxNode[] = []; + if (n.type === 'template_string') { + for (const child of n.namedChildren) { + if (child.type === 'string_fragment') { + terms.push(child); + } else if (child.type === 'template_substitution') { + const inner = child.namedChild(0); + if (inner === null) return null; + terms.push(inner); + } + } + } else if (n.type === 'binary_expression') { + const flattened = flattenConcat(n, depth); + if (flattened === null) return null; + terms.push(...flattened); + } else { + return null; + } + + let out = ''; + let anchored: boolean | null = null; + for (const term of terms) { + const folded = + term.type === 'string_fragment' + ? { text: term.text, concrete: true } + : foldTermOrPlaceholder(fileKey, term, facts, depth + 1); + if (folded === null) return null; + if (anchored === null) anchored = folded.concrete; + out += folded.text; + if (out.length > MAX_FOLD_LENGTH) return null; + } + return anchored === null ? null : { text: out, anchored }; +} + +/** + * Resolve the first argument of an HTTP call to a path string, or `null` when + * the expression is not a path shape this binding understands. + * + * Accepts a plain literal, a constant reference (`BASE_PATH`), a table member + * (`API_ROUTE_PATH.LINKS`), a template string, and a `+`-concatenation of any + * of those. Template and concat forms fold PARTIALLY — see + * {@link foldTermOrPlaceholder}. + * + * A reference that resolves to nothing returns `null` (skip), and so does a + * mixed expression whose leading term is unresolved — see {@link FoldedPath}. + */ +export function resolveJsPathExpression( + fileKey: string, + node: Parser.SyntaxNode, + facts: JsRepoFacts, +): string | null { + const folded = foldPathExpression(fileKey, node, facts, 0); + return folded !== null && folded.anchored ? folded.text : null; +} + +/** + * Whether `name`, as referenced in `fileKey`, holds an HTTP client instance. + * + * Chases local aliases and import/export hops so the common app shape — + * `axios.create()` in `lib/axios.config.ts`, `export default apiClient`, + * `import apiClient from '@/lib/axios.config'` at the call site — is proven + * rather than pattern-matched on the receiver's spelling. + * + * Deliberately conservative: an unproven receiver returns `false`, which keeps + * today's behavior for it. The alternative — trusting any identifier with an + * HTTP-verb method — would classify every Express `router.get('/x', handler)` + * provider as a consumer of itself. + */ +/** + * Whether `name`, as a receiver in `fileKey`, IS the axios module — as opposed + * to an instance built from it, which is {@link isHttpClientRef}'s question. + * + * Two ways to be it. The bare spelling `axios` predates the widened query — it + * is what the original `(#eq? @obj "axios")` pattern matched — so it stays + * trusted by default, and a file with no facts keeps exactly that behavior; it + * is withdrawn only where the file itself binds that name to something else. + * The other way is a declared import or `require` of `'axios'` under any local + * name, which is proof rather than convention and covers the aliased form the + * spelling rule cannot see. + */ +export function isAxiosNamespace(fileKey: string, name: string, facts: JsRepoFacts): boolean { + const file = facts.byFile.get(fileKey); + if (file === undefined) return name === 'axios'; + if (file.constants.imports.get(name)?.module === 'axios') return true; + return name === 'axios' && !file.axiosShadowed; +} + +export function isHttpClientRef(fileKey: string, name: string, facts: JsRepoFacts): boolean { + let currentKey = fileKey; + let currentName = name; + + for (let hop = 0; hop < MAX_REEXPORT_HOPS; hop++) { + const file = facts.byFile.get(currentKey); + if (!file) return false; + + if (file.clients.has(currentName)) return true; + + // Local alias: `const client = configuredClient`. + const expr = file.constants.exprs.get(currentName); + if (expr && expr.length === 1 && expr[0].kind === 'ref') { + currentName = expr[0].name; + continue; + } + + const binding = file.constants.imports.get(currentName); + if (!binding) return false; + const targetKey = facts.resolveImport(currentKey, binding.module, facts.keys); + if (targetKey === null) return false; + + currentKey = targetKey; + currentName = resolveExportLocal(facts, targetKey, binding.originalName); + } + return false; +} diff --git a/gitnexus/test/unit/group/js-http-consumer-resolution.test.ts b/gitnexus/test/unit/group/js-http-consumer-resolution.test.ts new file mode 100644 index 000000000..8957825dc --- /dev/null +++ b/gitnexus/test/unit/group/js-http-consumer-resolution.test.ts @@ -0,0 +1,845 @@ +import { describe, expect, it } from 'vitest'; +import Parser from 'tree-sitter'; +import JavaScript from 'tree-sitter-javascript'; +import TypeScript from 'tree-sitter-typescript'; +import { + TYPESCRIPT_HTTP_PLUGIN, + JAVASCRIPT_HTTP_PLUGIN, +} from '../../../src/core/group/extractors/http-patterns/node.js'; +import { resolveJsImport } from '../../../src/core/ingestion/route-extractors/js-const-resolver.js'; +import type { HttpDetection } from '../../../src/core/group/extractors/http-patterns/types.js'; + +const tsParser = new Parser(); +tsParser.setLanguage(TypeScript.typescript); + +// Compiled tree-sitter queries are grammar-bound, so a plugin must be driven +// with a tree parsed by ITS grammar. +const jsParser = new Parser(); +jsParser.setLanguage(JavaScript); + +/** + * Drive the plugin the way the orchestrator does: a `prepareRepo` pre-pass over + * a virtual repo, then a per-file `scan` with the resulting context. + */ +function scanRepo(files: Record, target: string): HttpDetection[] { + const paths = Object.keys(files); + const repoContext = TYPESCRIPT_HTTP_PLUGIN.prepareRepo?.({ + repoPath: '/repo', + files: paths, + parser: tsParser, + readFile: (rel) => files[rel] ?? null, + parseSource: (parser, src) => parser.parse(src), + }); + return TYPESCRIPT_HTTP_PLUGIN.scan(tsParser.parse(files[target]), repoContext, target); +} + +const consumers = (detections: HttpDetection[]) => detections.filter((d) => d.role === 'consumer'); + +/** `scanRepo`, but the pre-pass may fail on chosen files. */ +function scanRepoWithParse( + files: Record, + target: string, + parseSource: (parser: Parser, src: string) => Parser.Tree | null, +): HttpDetection[] { + const repoContext = TYPESCRIPT_HTTP_PLUGIN.prepareRepo?.({ + repoPath: '/repo', + files: Object.keys(files), + parser: tsParser, + readFile: (rel) => files[rel] ?? null, + parseSource, + }); + return TYPESCRIPT_HTTP_PLUGIN.scan(tsParser.parse(files[target]), repoContext, target); +} + +// The shape the finding was reported against: a configured client in one file, +// a frozen route table in another, and call sites that reference both by name. +const AXIOS_CONFIG = ` + import axios from 'axios'; + const axiosInstance = axios.create({ baseURL: process.env.API_URL }); + const routeApiClient = axiosInstance; + export default routeApiClient; +`; + +const API_ROUTES = ` + export const API_ROUTE_PATH = { + LINKS: "/links", + EVENTS: "/events", + CURATOR_LISTS: "/curator-lists", + } as const; +`; + +describe('JS/TS HTTP consumer resolution', () => { + it('resolves a configured client and a table path imported from other files', () => { + const detections = scanRepo( + { + 'src/lib/axios.config.ts': AXIOS_CONFIG, + 'src/api-modules/shared/api-routes.ts': API_ROUTES, + 'src/api-modules/curators/curators.api.ts': ` + import routeApiClient from '@/lib/axios.config'; + import { API_ROUTE_PATH } from '@/api-modules/shared/api-routes'; + export async function getLists() { + return routeApiClient.get(API_ROUTE_PATH.CURATOR_LISTS, {}); + } + `, + }, + 'src/api-modules/curators/curators.api.ts', + ); + + expect(consumers(detections)).toContainEqual( + expect.objectContaining({ role: 'consumer', method: 'GET', path: '/curator-lists' }), + ); + }); + + it('resolves relative imports for the client and the route table', () => { + const detections = scanRepo( + { + 'src/lib/axios.config.ts': AXIOS_CONFIG, + 'src/api/routes.ts': API_ROUTES, + 'src/api/links.api.ts': ` + import client from '../lib/axios.config'; + import { API_ROUTE_PATH } from './routes'; + export const load = () => client.post(API_ROUTE_PATH.LINKS); + `, + }, + 'src/api/links.api.ts', + ); + + expect(consumers(detections)).toContainEqual( + expect.objectContaining({ method: 'POST', path: '/links' }), + ); + }); + + it('folds a template partially, keeping the resolved prefix', () => { + const detections = scanRepo( + { + 'src/lib/axios.config.ts': AXIOS_CONFIG, + 'src/api/routes.ts': API_ROUTES, + 'src/api/curators.api.ts': ` + import client from '../lib/axios.config'; + import { API_ROUTE_PATH } from './routes'; + export const add = (eventId: string) => + client.post(\`\${API_ROUTE_PATH.CURATOR_LISTS}/\${eventId}/add-to-list\`); + `, + }, + 'src/api/curators.api.ts', + ); + + // The unresolvable `${eventId}` stays a placeholder for consumer-side + // normalization to read as {param}; the known prefix is no longer lost. + expect(consumers(detections)).toContainEqual( + expect.objectContaining({ path: '/curator-lists/${eventId}/add-to-list' }), + ); + }); + + it('resolves a `+` concatenation against an imported base constant', () => { + const detections = scanRepo( + { + 'src/lib/axios.config.ts': AXIOS_CONFIG, + 'src/api/base.ts': `export const BASE = "/api/v1";`, + 'src/api/users.api.ts': ` + import client from '../lib/axios.config'; + import { BASE } from './base'; + export const list = () => client.get(BASE + "/users"); + `, + }, + 'src/api/users.api.ts', + ); + + expect(consumers(detections)).toContainEqual( + expect.objectContaining({ method: 'GET', path: '/api/v1/users' }), + ); + }); + + it('follows a barrel re-export to the defining module', () => { + const detections = scanRepo( + { + 'src/lib/axios.config.ts': AXIOS_CONFIG, + 'src/api/routes.ts': API_ROUTES, + 'src/api/index.ts': `export { API_ROUTE_PATH } from './routes';`, + 'src/api/events.api.ts': ` + import client from '../lib/axios.config'; + import { API_ROUTE_PATH } from './index'; + export const list = () => client.get(API_ROUTE_PATH.EVENTS); + `, + }, + 'src/api/events.api.ts', + ); + + expect(consumers(detections)).toContainEqual( + expect.objectContaining({ method: 'GET', path: '/events' }), + ); + }); + + // ─── Shapes real applications actually ship ──────────────────────── + + it('proves a client built by a factory wrapper, not just a bare axios.create', () => { + const detections = scanRepo( + { + // The shape Sourcerer-fe ships: the instance is an argument to a + // decorator that returns the configured client. + 'src/lib/axios.config.ts': ` + import axios from 'axios'; + const routeApiClient = setupClientInterceptors({ + axiosInstance: axios.create({ baseURL: API_URL }), + onError: (e) => e, + }); + export default routeApiClient; + `, + 'src/api/routes.ts': API_ROUTES, + 'src/api/links.api.ts': ` + import routeApiClient from '@/lib/axios.config'; + import { API_ROUTE_PATH } from '@/api/routes'; + export const load = () => routeApiClient.get(API_ROUTE_PATH.LINKS); + `, + }, + 'src/api/links.api.ts', + ); + + expect(consumers(detections)).toContainEqual( + expect.objectContaining({ method: 'GET', path: '/links' }), + ); + }); + + it('follows `export *` through a directory barrel', () => { + const detections = scanRepo( + { + 'src/lib/axios.config.ts': AXIOS_CONFIG, + 'src/api-modules/shared/api-routes.ts': API_ROUTES, + 'src/api-modules/shared/index.ts': ` + export * from "./api-routes"; + export * from "./query-keys"; + `, + 'src/api-modules/shared/query-keys.ts': `export const QUERY_KEYS = { A: "a" };`, + 'src/api-modules/curators/curators.api.ts': ` + import client from '@/lib/axios.config'; + import { API_ROUTE_PATH } from '@/api-modules/shared'; + export const get = () => client.get(API_ROUTE_PATH.CURATOR_LISTS); + `, + }, + 'src/api-modules/curators/curators.api.ts', + ); + + expect(consumers(detections)).toContainEqual( + expect.objectContaining({ method: 'GET', path: '/curator-lists' }), + ); + }); + + it('recognizes an aliased axios import', () => { + const detections = scanRepo( + { + 'src/lib/client.ts': ` + import ax from 'axios'; + export default ax.create({ baseURL: '/' }); + `, + 'src/api/routes.ts': API_ROUTES, + 'src/api/events.api.ts': ` + import client from '../lib/client'; + import { API_ROUTE_PATH } from './routes'; + export const list = () => client.get(API_ROUTE_PATH.EVENTS); + `, + }, + 'src/api/events.api.ts', + ); + + expect(consumers(detections)).toContainEqual( + expect.objectContaining({ method: 'GET', path: '/events' }), + ); + }); + + it('keeps literal segments of a template nested inside a substitution', () => { + const detections = scanRepo( + { + 'src/lib/axios.config.ts': AXIOS_CONFIG, + 'src/api/routes.ts': API_ROUTES, + 'src/api/events.api.ts': ` + import client from '../lib/axios.config'; + import { API_ROUTE_PATH } from './routes'; + export const unlike = (id: string) => + client.delete(\`\${API_ROUTE_PATH.EVENTS}\${\`/\${id}/unlike\`}\`); + `, + }, + 'src/api/events.api.ts', + ); + + expect(consumers(detections)).toContainEqual( + expect.objectContaining({ path: '/events/${id}/unlike' }), + ); + }); + + it('does not let a client built inside a callback vouch for the outer name', () => { + const detections = scanRepo( + { + 'src/thing.ts': ` + const thing = configure(() => axios.create({ baseURL: '/' })); + export const read = () => thing.get('/users'); + `, + }, + 'src/thing.ts', + ); + + expect(consumers(detections)).toEqual([]); + }); + + // ─── Precision guards ────────────────────────────────────────────── + + it('does NOT emit an Express provider route as a consumer of itself', () => { + const detections = scanRepo( + { + 'src/server.ts': ` + import express from 'express'; + const router = express.Router(); + router.get('/users', listUsers); + app.post('/orders', createOrder); + `, + }, + 'src/server.ts', + ); + + expect(consumers(detections)).toEqual([]); + // …while still being seen as providers. + expect(detections.filter((d) => d.role === 'provider').map((d) => d.path)).toEqual( + expect.arrayContaining(['/users', '/orders']), + ); + }); + + it('does NOT claim an unproven receiver that merely has a .get method', () => { + const detections = scanRepo( + { + 'src/cache.ts': ` + const cache = new Map(); + const store = { get: (k: string) => k }; + export const read = () => cache.get('/users') ?? store.get('/orders'); + `, + }, + 'src/cache.ts', + ); + + expect(consumers(detections)).toEqual([]); + }); + + it('refuses to resolve an import whose specifier matches two files', () => { + const detections = scanRepo( + { + 'src/lib/axios.config.ts': AXIOS_CONFIG, + 'a/shared/routes.ts': API_ROUTES, + 'b/shared/routes.ts': `export const API_ROUTE_PATH = { LINKS: "/other-links" } as const;`, + 'src/api/links.api.ts': ` + import client from '../lib/axios.config'; + import { API_ROUTE_PATH } from 'shared/routes'; + export const load = () => client.get(API_ROUTE_PATH.LINKS); + `, + }, + 'src/api/links.api.ts', + ); + + // Two candidates for `shared/routes` — an unresolved path is correct here; + // guessing either one would invent a cross-repo link. + expect(consumers(detections)).toEqual([]); + }); + + // ─── Backward compatibility ──────────────────────────────────────── + + it('still detects a bare axios call with a literal path and no repo context', () => { + const detections = JAVASCRIPT_HTTP_PLUGIN.scan( + jsParser.parse(`axios.get('/legacy'); axios.post('/legacy', body);`), + ); + + expect(consumers(detections)).toEqual([ + expect.objectContaining({ method: 'GET', path: '/legacy' }), + expect.objectContaining({ method: 'POST', path: '/legacy' }), + ]); + }); + + it('preserves the raw template when there is no repo context to fold against', () => { + const detections = JAVASCRIPT_HTTP_PLUGIN.scan(jsParser.parse('axios.get(`/users/${id}`);')); + + expect(consumers(detections)).toContainEqual(expect.objectContaining({ path: '/users/${id}' })); + }); + + it('drops a non-literal path it cannot resolve rather than emitting its text', () => { + const detections = JAVASCRIPT_HTTP_PLUGIN.scan( + jsParser.parse(`axios.get(API_ROUTE_PATH.LINKS);`), + ); + + expect(consumers(detections)).toEqual([]); + }); + + // ─── Review findings: precision, termination and keying ──────────── + + it('keys the fact map the same way on a platform that hands it backslashes', () => { + // glob v13 is called without `posix: true` and its walker joins with the + // platform separator, so on Windows every path here arrives backslashed. + const files = { + 'src\\lib\\axios.config.ts': AXIOS_CONFIG, + 'src\\api\\routes.ts': API_ROUTES, + 'src\\api\\links.api.ts': ` + import client from '../lib/axios.config'; + import { API_ROUTE_PATH } from './routes'; + export const load = () => client.get(API_ROUTE_PATH.LINKS); + `, + }; + + expect(consumers(scanRepo(files, 'src\\api\\links.api.ts'))).toContainEqual( + expect.objectContaining({ method: 'GET', path: '/links' }), + ); + }); + + it('does NOT treat a container that merely HOLDS an axios instance as a client', () => { + const detections = scanRepo( + { + 'src/stores.ts': ` + import axios from 'axios'; + const registry = { http: axios.create({ baseURL: '/' }), version: 'v1' }; + const picked = MOCK ? memoryStore : axios.create({ baseURL: '/' }); + const pool = new Map([['api', axios.create({ baseURL: '/' })]]); + export const read = () => [ + registry.get('/settings'), + picked.get('/feature-flags'), + pool.get('/tenant'), + ]; + `, + }, + 'src/stores.ts', + ); + + expect(consumers(detections)).toEqual([]); + }); + + it('still proves the factory shape the containment rule existed for', () => { + const detections = scanRepo( + { + 'src/lib/client.ts': ` + import axios from 'axios'; + export default withRetries(setupInterceptors(axios.create({ baseURL: '/' }))); + `, + 'src/api/routes.ts': API_ROUTES, + 'src/api/links.api.ts': ` + import client from '../lib/client'; + import { API_ROUTE_PATH } from './routes'; + export const load = () => client.get(API_ROUTE_PATH.LINKS); + `, + }, + 'src/api/links.api.ts', + ); + + expect(consumers(detections)).toContainEqual( + expect.objectContaining({ method: 'GET', path: '/links' }), + ); + }); + + it('refuses a resolved constant that is not path-shaped', () => { + const detections = scanRepo( + { + 'src/lib/axios.config.ts': AXIOS_CONFIG, + 'src/api/strings.ts': ` + export const CONFIG = { TIMEOUT: "5000" } as const; + export const MSG = { ERROR: "Could not reach the server" } as const; + `, + 'src/api/calls.api.ts': ` + import api from '../lib/axios.config'; + import { CONFIG, MSG } from './strings'; + export const a = () => api.get(CONFIG.TIMEOUT); + export const b = () => api.post(MSG.ERROR); + `, + }, + 'src/api/calls.api.ts', + ); + + // "5000" normalizes to /{param} and matches every one-segment provider + // route in the group; the message normalizes to a path with spaces in it. + expect(consumers(detections)).toEqual([]); + }); + + it('keeps an all-numeric path that is written as a path', () => { + const detections = scanRepo( + { + 'src/lib/axios.config.ts': AXIOS_CONFIG, + 'src/api/legacy.api.ts': ` + import api from '../lib/axios.config'; + export const load = () => api.get('/123'); + `, + }, + 'src/api/legacy.api.ts', + ); + + // The leading slash is what separates a route from a folded timeout; the + // consumer normalizer reads the segment as {param} either way. + expect(consumers(detections)).toContainEqual( + expect.objectContaining({ method: 'GET', path: '/123' }), + ); + }); + + it('refuses a path whose leading term never resolved', () => { + const detections = scanRepo( + { + 'src/lib/axios.config.ts': AXIOS_CONFIG, + 'src/api/unanchored.api.ts': ` + import client from '../lib/axios.config'; + const BASE = process.env.NEXT_PUBLIC_API_URL; + export const a = (x, y) => client.get(\`\${x}\${y}\`); + export const b = () => client.get(BASE + '/users'); + `, + }, + 'src/api/unanchored.api.ts', + ); + + // `${x}${y}` squashes to /{param}{param} and `${BASE}/users` to + // /{param}/users — both exact-match real provider routes. + expect(consumers(detections)).toEqual([]); + }); + + it('caps the folded output instead of building a path of unbounded length', () => { + const pad = 'a'.repeat(4000); + const detections = scanRepo( + { + 'src/lib/axios.config.ts': AXIOS_CONFIG, + 'src/api/big.api.ts': ` + import client from '../lib/axios.config'; + const PAD = "/${pad}"; + export const load = () => client.get(PAD + PAD + PAD); + `, + }, + 'src/api/big.api.ts', + ); + + // Each term is under the core's 8 192-char cap; their concatenation is not, + // and the result is persisted into contractId / meta.path. + expect(consumers(detections)).toEqual([]); + }); + + it('terminates on expressions deep enough to overflow the stack', () => { + // `scan` is contractually non-throwing: `sync.ts` turns a throw here into an + // unexplained "missing repo" that silently drops every contract of every + // kind for that repo. Both shapes recursed once per term before this. + // 3 000 is near this tree-sitter build's own parse ceiling for a `+` chain; + // nested templates parse to ~6 000, and at 4 000 the unbounded fold threw + // `RangeError: Maximum call stack size exceeded` straight out of `scan`. + const chain = Array.from({ length: 3000 }, (_, i) => `"/s${i}"`).join(' + '); + let nested = '`/x`'; + for (let i = 0; i < 4000; i++) nested = '`${' + nested + '}`'; + + expect(() => scanRepo({ 'src/a.ts': `axios.get(${chain});` }, 'src/a.ts')).not.toThrow(); + expect(() => scanRepo({ 'src/b.ts': `axios.get(${nested});` }, 'src/b.ts')).not.toThrow(); + }); + + it('survives a file whose parse throws, and still resolves the rest of the repo', () => { + const detections = scanRepoWithParse( + { + 'src/lib/axios.config.ts': AXIOS_CONFIG, + 'src/api/routes.ts': API_ROUTES, + 'src/api/poison.ts': `export const X = "/x";`, + 'src/api/links.api.ts': ` + import client from '../lib/axios.config'; + import { API_ROUTE_PATH } from './routes'; + export const load = () => client.get(API_ROUTE_PATH.LINKS); + `, + }, + 'src/api/links.api.ts', + (parser, src) => { + if (src.includes('"/x"')) throw new Error('ParseTimeoutError'); + return parser.parse(src); + }, + ); + + expect(consumers(detections)).toContainEqual( + expect.objectContaining({ method: 'GET', path: '/links' }), + ); + }); + + it('sees an axios import declared below the binding that uses it', () => { + const detections = scanRepo( + { + // ES module bindings are hoisted, so this is legal and binds the same `ax`. + 'src/lib/late.ts': ` + const client = ax.create({ baseURL: '/' }); + import ax from 'axios'; + export default client; + `, + 'src/api/routes.ts': API_ROUTES, + 'src/api/links.api.ts': ` + import client from '../lib/late'; + import { API_ROUTE_PATH } from './routes'; + export const load = () => client.get(API_ROUTE_PATH.LINKS); + `, + }, + 'src/api/links.api.ts', + ); + + expect(consumers(detections)).toContainEqual( + expect.objectContaining({ method: 'GET', path: '/links' }), + ); + }); + + it('keeps a partially folded path whose unresolved term contains spaces', () => { + const detections = scanRepo( + { + 'src/lib/axios.config.ts': AXIOS_CONFIG, + 'src/api/routes.ts': API_ROUTES, + 'src/api/events.api.ts': ` + import client from '../lib/axios.config'; + import { API_ROUTE_PATH } from './routes'; + export const list = (draft: boolean, page?: number) => [ + client.get(\`\${API_ROUTE_PATH.EVENTS}/\${draft ? 'draft' : 'live'}\`), + client.get(\`\${API_ROUTE_PATH.LINKS}/\${page ?? 1}\`), + ]; + `, + }, + 'src/api/events.api.ts', + ); + + // The placeholder is a runtime value that consumer normalization reads as + // {param}; its source text is not part of the path shape. + expect(consumers(detections).map((d) => d.path)).toEqual( + expect.arrayContaining(["/events/${draft ? 'draft' : 'live'}", '/links/${page ?? 1}']), + ); + }); + + it('does not remove a detection the literal axios receiver already produced', () => { + // Before the query was widened this shape matched and normalized to + // /{param}/users. Anchoring applies to what the widening newly admits, not + // to output that already shipped. + const detections = JAVASCRIPT_HTTP_PLUGIN.scan( + jsParser.parse('axios.get(`${API_BASE}/users`); axios.get(`${a}${b}`);'), + ); + + expect(consumers(detections).map((d) => d.path)).toEqual(['${API_BASE}/users', '${a}${b}']); + }); + + it('caps the literal fallback of an oversized template too', () => { + const pad = 'a'.repeat(9000); + const detections = scanRepo( + { + 'src/lib/axios.config.ts': AXIOS_CONFIG, + 'src/api/big.api.ts': ` + import client from '../lib/axios.config'; + export const load = (id: string) => client.get(\`/${pad}\${id}\`); + `, + }, + 'src/api/big.api.ts', + ); + + expect(consumers(detections)).toEqual([]); + }); + + it('proves a client handed to a factory inside a nested options object', () => { + const detections = scanRepo( + { + 'src/lib/client.ts': ` + import axios from 'axios'; + export default createClient({ transport: { instance: axios.create({}) } }); + `, + 'src/lib/composed.ts': ` + import axios from 'axios'; + export default compose([axios.create({}), withAuth]); + `, + 'src/api/routes.ts': API_ROUTES, + 'src/api/links.api.ts': ` + import nested from '../lib/client'; + import composed from '../lib/composed'; + import { API_ROUTE_PATH } from './routes'; + export const a = () => nested.get(API_ROUTE_PATH.LINKS); + export const b = () => composed.get(API_ROUTE_PATH.EVENTS); + `, + }, + 'src/api/links.api.ts', + ); + + expect(consumers(detections).map((d) => d.path)).toEqual( + expect.arrayContaining(['/links', '/events']), + ); + }); + + it('does NOT trust the spelling `axios` when the file binds that name itself', () => { + const detections = scanRepo( + { + 'src/shadow.ts': ` + const axios = fakeFactory; + const api = axios.create(); + export const a = () => api.get('/x'); + export const b = () => axios.get('/y'); + `, + 'src/mock.ts': ` + const axios = { create: () => ({ get: (u: string) => u }) }; + const api = axios.create(); + export const c = () => api.get('/z'); + `, + }, + 'src/shadow.ts', + ); + + // The spelling is the only evidence here, and it is false. + expect(consumers(detections)).toEqual([]); + expect( + consumers( + scanRepo( + { + 'src/mock.ts': ` + const axios = { create: () => ({ get: (u: string) => u }) }; + const api = axios.create(); + export const c = () => api.get('/z'); + `, + }, + 'src/mock.ts', + ), + ), + ).toEqual([]); + }); + + it('resolves a CommonJS require of axios, aliased or not', () => { + const cjs = (local: string) => ` + const ${local} = require('axios'); + const api = ${local}.create({ baseURL: '/' }); + export const viaInstance = () => api.get('/instance'); + export const viaModule = () => ${local}.get('/module'); + `; + + for (const local of ['axios', 'ax']) { + const detections = scanRepo({ 'src/cjs.ts': cjs(local) }, 'src/cjs.ts'); + expect(consumers(detections).map((d) => d.path)).toEqual( + expect.arrayContaining(['/instance', '/module']), + ); + } + }); + + it('resolves the axios module used directly under an import alias', () => { + const detections = scanRepo( + { + 'src/aliased.ts': ` + import ax from 'axios'; + export const f = () => ax.get('/health'); + `, + }, + 'src/aliased.ts', + ); + + expect(consumers(detections)).toContainEqual( + expect.objectContaining({ method: 'GET', path: '/health' }), + ); + }); + + it('refuses a name two `export *` barrels both provide', () => { + const detections = scanRepo( + { + 'src/lib/axios.config.ts': AXIOS_CONFIG, + 'src/api/a.ts': `export const API_ROUTE_PATH = { LINKS: "/links-a" } as const;`, + 'src/api/b.ts': `export const API_ROUTE_PATH = { LINKS: "/links-b" } as const;`, + 'src/api/index.ts': ` + export * from './a'; + export * from './b'; + `, + 'src/api/links.api.ts': ` + import client from '../lib/axios.config'; + import { API_ROUTE_PATH } from './index'; + export const load = () => client.get(API_ROUTE_PATH.LINKS); + `, + }, + 'src/api/links.api.ts', + ); + + expect(consumers(detections)).toEqual([]); + }); + + it('does NOT bind a Node builtin specifier to a same-named repo file', () => { + const detections = scanRepo( + { + 'src/lib/http.ts': ` + import axios from 'axios'; + export default axios.create({ baseURL: '/' }); + `, + 'src/api/health.ts': ` + import http from 'http'; + export const ping = () => http.get('http://example.com/health'); + `, + }, + 'src/api/health.ts', + ); + + expect(consumers(detections)).toEqual([]); + }); + + it('measures the pre-pass ceiling in bytes, not UTF-16 code units', () => { + // Under 512 Ki code units, over 512 KiB of UTF-8 — the ceiling mirrors the + // analyzer's byte-size limit, so this file must be skipped. + const detections = scanRepo( + { + 'src/lib/huge.ts': ` + import axios from 'axios'; + // ${'á'.repeat(300_000)} + export default axios.create({ baseURL: '/' }); + `, + 'src/api/links.api.ts': ` + import client from '../lib/huge'; + export const load = () => client.get('/links'); + `, + }, + 'src/api/links.api.ts', + ); + + expect(consumers(detections)).toEqual([]); + }); +}); + +describe('resolveJsImport', () => { + const keys = (...paths: string[]) => new Set(paths); + + it('refuses a tail two different modules claim, across extensions', () => { + expect( + resolveJsImport( + 'src/x.ts', + '@/shared/routes', + keys('a/shared/routes.ts', 'b/shared/routes.ts'), + ), + ).toBeNull(); + expect( + resolveJsImport( + 'src/x.ts', + '@/shared/routes', + keys('a/shared/routes.ts', 'b/shared/routes.tsx'), + ), + ).toBeNull(); + expect( + resolveJsImport( + 'src/x.ts', + '@/shared/routes', + keys('a/shared/routes.ts', 'b/shared/routes.js'), + ), + ).toBeNull(); + expect( + resolveJsImport( + 'src/x.ts', + '@/shared/routes', + keys('a/shared/routes.ts', 'b/shared/routes/index.ts'), + ), + ).toBeNull(); + }); + + it('keeps extension precedence when the matches are one module', () => { + // `x/routes.ts` and `x/routes/index.ts` are two spellings of `x/routes`; + // Node and tsc both pick the file, so this is precedence, not ambiguity. + expect( + resolveJsImport('src/a.ts', '@/x/routes', keys('src/x/routes.ts', 'src/x/routes/index.ts')), + ).toBe('src/x/routes.ts'); + expect(resolveJsImport('src/a.ts', '@/x/routes', keys('src/x/routes.tsx'))).toBe( + 'src/x/routes.tsx', + ); + }); + + it('never resolves a single-segment bare specifier to a repo file', () => { + // A bare npm package or Node builtin is not ours to resolve — and this is + // also the hot path: the unindexed sweep that ran here cost 19.6x on a + // 4 000-file repo whose only trigger was `import _ from 'lodash'`. + expect(resolveJsImport('src/a.ts', 'http', keys('src/lib/http.ts'))).toBeNull(); + expect(resolveJsImport('src/a.ts', 'axios', keys('src/lib/axios.ts'))).toBeNull(); + expect(resolveJsImport('src/a.ts', 'lodash', keys('src/lodash.ts'))).toBeNull(); + }); + + it('still resolves alias and relative specifiers', () => { + expect(resolveJsImport('src/a/b.ts', './c', keys('src/a/c.ts'))).toBe('src/a/c.ts'); + expect(resolveJsImport('src/a/b.ts', '@/lib/http', keys('src/lib/http.ts'))).toBe( + 'src/lib/http.ts', + ); + expect(resolveJsImport('src/a/b.ts', 'lib/http', keys('src/lib/http.ts'))).toBe( + 'src/lib/http.ts', + ); + }); +});