mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
fix(group): resolve HTTP consumers through configured clients and constant route tables (#3008)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* fix(group): resolve HTTP consumers through configured clients and constant route tables
Cross-repo linking found almost no frontend consumers because the Node/TS
consumer pattern required two things application code never has: a receiver
literally spelled `axios`, and an HTTP path that is a string literal at the
call site. Real apps call a configured instance and pass the path by reference
from a shared route table, so both halves of every call live in other files.
Widen the pattern to any identifier receiver with an HTTP-verb method, then
admit the match only after PROVING the receiver is an axios instance —
following local aliases, default/named imports and `export *` barrels back to
an `axios.create(...)`, including when that call is an argument to a factory
that decorates and returns the instance. The proof gate is load-bearing:
EXPRESS_SPEC matches `router.get('/x', handler)` as a provider, so admitting a
receiver on spelling alone would re-emit every Express route as a consumer of
itself.
Resolve the path argument through the existing language-agnostic constant fold
(`constant-resolver.ts`, #2391) via a new JS/TS binding, mirroring how
`python-const-resolver.ts` binds the same core. The binding adds the two
JS-shaped facts Python has no analogue for: object-literal route tables
flattened to dotted literal keys (`API_ROUTE_PATH.LINKS`), and export aliasing
(`export default`, `export { a as b }`, `export *`). Templates and `+` concats
fold partially, so a mixed path keeps its known prefix instead of collapsing to
`{param}/{param}/...`.
Cross-file facts come from a `prepareRepo` pre-pass, the hook FastAPI prefix
resolution already uses. The three JS/TS plugins share one pass via a WeakMap
keyed on the orchestrator's memoized file list.
Every resolution floors to `null` (skip) rather than a guess: an ambiguous
import specifier, an unprovable receiver, or a fold that overruns its depth
leaves the call site exactly as unmatched as before. An unresolved path is a
missing contract; a wrong one is a false cross-repo link.
Measured on a real Next.js frontend (874 source files): consumer contracts
7 -> 160, none lost.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(group): tighten the JS/TS HTTP consumer proof gates and bound the fold
Addresses the review findings on #3008. Widening the axios consumer query
moved precision out of the tree-sitter pattern and into runtime gates; most
of these are one of those gates leaking.
Keying
- scanBundle normalizes fileRel ONCE and uses that key for both the receiver
gate and the path fold. isHttpClientRef read the raw value while the fact
map is written under normalizeRel(rel), so any non POSIX path returned zero
consumers and a key miss is indistinguishable from "not a client".
Proof
- containsAxiosCreate (subtree containment) becomes bindsAxiosClient: the
instance must be the bound VALUE, or reachable inside the arguments of a
wrapping call whose result is bound. An object literal, ternary, array or
new X(...) binding no longer makes a cache or registry an HTTP consumer.
- A folded first argument must look like a path: no whitespace, not wholly
numeric, and not starting with an unresolved term. The check runs on the
${...} to {param} normalized shape, so a placeholder whose source contains
spaces does not drop an otherwise anchored path.
- A template or concat whose LEADING term never resolved returns null, which
is what the docstring always claimed.
- The literal receiver axios with a literal or template argument keeps its
pre-PR output verbatim, so the widening only adds detections.
Resolution
- resolveJsImport checks ambiguity across ALL candidate extensions, not within
one, so a .ts/.tsx or .ts/index.ts collision skips instead of picking a
winner. Two spellings of one module still resolve by precedence.
- A single segment bare specifier with no alias sigil never binds to a repo
file, so a Node builtin or npm package cannot be "proven" an axios client.
- resolveExportedMember walks every export * edge and returns null when two
barrels answer differently.
- Imports are collected in a hoisting pre-pass, so a client bound above its
own import statement is still proven.
Termination and cost
- MAX_EXPR_DEPTH and MAX_CONCAT_TERMS bound the path fold, flattenConcat walks
the left spine iteratively, and buildImportMap is explicit stack. A file
nesting template substitutions 4000 deep threw RangeError out of scan, which
sync.ts records as an unexplained missing repo with every contract dropped.
- MAX_FOLD_LENGTH applies to accumulated output, not per term, and to the raw
literal fallback. The per term cap was a 2048x amplifier and the result is
persisted into contractId.
- resolveJsImport is backed by a basename index and memoized per repo, and
resolveConstant accepts the key set instead of rebuilding it per fold.
2000 file repo with one bare npm import: 11074 ms to 1250 ms.
- prepareRepo measures its ceiling in bytes, parses inside the try, and skips
the parse pass entirely when the string axios appears in no candidate file.
It carries only file identities between its two passes, never their text.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV
* fix(group): let a path-shaped all-numeric consumer path through the gate
The shape gate rejected any wholly numeric path, which also dropped
`client.get('/123')`. The leading slash is the evidence that separates a
route from a constant that merely folded to digits: a bare "5000" out of
`CONFIG.TIMEOUT` still matches every one-segment provider route and is still
refused, while a path written as a path is kept and normalized to {param}
the same way it always was.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV
* style: apply prettier to the changed files
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV
* fix(group): decide the axios receiver on evidence, not only on its spelling
The bare name `axios` was trusted with no proof, which is right for the
convention and wrong for a file that binds that name itself:
`const axios = fakeFactory; const api = axios.create(); api.get('/x')` was
admitted as an HTTP consumer, and so was a test file whose `axios` is a mock
object with a `create` method.
extractJsModuleFacts now records whether the file declares its own top-level
`axios` binding, and the spelling is trusted only when it does not. The other
half of the same fact is that CommonJS was invisible: `const ax =
require('axios')` resolved to nothing at all, and the un-aliased form worked
only because `axios` happened to be the name the spelling shortcut trusted.
Requires are collected alongside imports now, so a receiver is admitted when
it IS the axios module (the bare spelling, or a declared import or require of
'axios' under any name) or when it traces to an `axios.create(...)` instance.
Verified across the receiver matrix: shadowed local, shadowed mock object,
CJS require aliased and not, ESM import aliased and not, express router and a
plain Map all land where they should.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
This commit is contained in:
parent
3f5fbb05e0
commit
031e123731
4 changed files with 2391 additions and 43 deletions
|
|
@ -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<Record<string, never>> = {
|
|||
`,
|
||||
};
|
||||
|
||||
// ─── Consumer: axios.get/post/... ────────────────────────────────────
|
||||
const AXIOS_SPEC: PatternSpec<Record<string, never>> = {
|
||||
// ─── Consumer: <httpClient>.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<Record<string, never>> = {
|
||||
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<Record<string, never>>;
|
||||
fetchNoOptions: CompiledPatterns<Record<string, never>>;
|
||||
fetchWithOptions: CompiledPatterns<Record<string, never>>;
|
||||
axios: CompiledPatterns<Record<string, never>>;
|
||||
httpClient: CompiledPatterns<Record<string, never>>;
|
||||
jqueryShorthand: CompiledPatterns<Record<string, never>>;
|
||||
jqueryAjax: CompiledPatterns<Record<string, never>>;
|
||||
axiosObject: CompiledPatterns<Record<string, never>>;
|
||||
|
|
@ -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<string, { name: string; module: string }> {
|
||||
const map = new Map<string, { name: string; module: string }>();
|
||||
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<string, { name: string; module:
|
|||
map.set(local.text, { name: nameNode.text, module });
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < n.namedChildCount; i++) {
|
||||
const c = n.namedChild(i);
|
||||
if (c) collect(c);
|
||||
}
|
||||
};
|
||||
collect(node);
|
||||
for (const c of n.namedChildren) inner.push(c);
|
||||
}
|
||||
}
|
||||
// An import statement cannot contain another one, and the inner loop has
|
||||
// already visited its whole subtree.
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const c = node.namedChild(i);
|
||||
if (c) walk(c);
|
||||
}
|
||||
};
|
||||
walk(tree.rootNode);
|
||||
for (const c of node.namedChildren) stack.push(c);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection[] {
|
||||
// ─── Repo pre-pass: cross-file route tables + HTTP client instances ──
|
||||
//
|
||||
// Both halves of a real consumer call live in files OTHER than the call site:
|
||||
// the client is created in `lib/axios.config.ts` and the path in
|
||||
// `shared/api-routes.ts`. A per-file scan cannot see either, which is why the
|
||||
// literal-only patterns matched almost nothing on application code. The
|
||||
// pre-pass builds a repo-wide fact map once so `scan` can resolve both.
|
||||
|
||||
interface NodeRepoContext {
|
||||
readonly facts: JsRepoFacts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared across the three JS/TS plugins.
|
||||
*
|
||||
* JS, TS and TSX are distinct `HttpLanguagePlugin` objects, and the
|
||||
* orchestrator caches `prepareRepo` output per plugin NAME — so a polyglot
|
||||
* frontend would build this identical map three times. All three are called
|
||||
* with the same memoized file-list array within one extraction run, so keying
|
||||
* on that array's identity collapses the work to a single pass. A later run
|
||||
* passes a different array and correctly rebuilds; the weak key lets the old
|
||||
* map be collected with it.
|
||||
*/
|
||||
const REPO_CONTEXT_BY_FILE_LIST = new WeakMap<readonly string[], NodeRepoContext>();
|
||||
|
||||
/**
|
||||
* 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<string, JsModuleFacts>();
|
||||
const parsers = new Map<unknown, Parser>();
|
||||
const parserFor = (language: unknown): Parser => {
|
||||
let parser = parsers.get(language);
|
||||
if (!parser) {
|
||||
parser = new Parser();
|
||||
parser.setLanguage(language as Parameters<Parser['setLanguage']>[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.<verb>(url)
|
||||
for (const match of runCompiledPatterns(bundle.axios, tree)) {
|
||||
// Consumer: <httpClient>.<verb>(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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -175,10 +175,18 @@ function computeFold(
|
|||
return null;
|
||||
}
|
||||
|
||||
function newState(repo: RepoConstants, resolveImport: ImportResolver): ResolveState {
|
||||
function newState(
|
||||
repo: RepoConstants,
|
||||
resolveImport: ImportResolver,
|
||||
repoKeys?: ReadonlySet<string>,
|
||||
): 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>,
|
||||
): 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>,
|
||||
): string | null {
|
||||
return foldExpr(fileKey, operands, newState(repo, resolveImport), 0);
|
||||
return foldExpr(fileKey, operands, newState(repo, resolveImport, repoKeys), 0);
|
||||
}
|
||||
|
|
|
|||
1213
gitnexus/src/core/ingestion/route-extractors/js-const-resolver.ts
Normal file
1213
gitnexus/src/core/ingestion/route-extractors/js-const-resolver.ts
Normal file
File diff suppressed because it is too large
Load diff
845
gitnexus/test/unit/group/js-http-consumer-resolution.test.ts
Normal file
845
gitnexus/test/unit/group/js-http-consumer-resolution.test.ts
Normal file
|
|
@ -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<string, string>, 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<string, string>,
|
||||
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<string, string>();
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue