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', + ); + }); +});