mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-28 06:22:22 +00:00
* docs(scope): clarify lexical binding ownership and wrapper provenance Checkpoint for #3630. Publishing the implemented source, regression tests, and benchmark harness as a draft PR; full-suite and final scaling validation remain in progress. * fix(scope): publish #3630 docs/plans * fix(scope): publish #3630 gitnexus/bench/lexical-binding-provenance * fix(scope): publish #3630 gitnexus/bench/lexical-binding-provenance/fixtures * fix(scope): publish #3630 gitnexus/bench/lexical-binding-provenance/results * fix(scope): publish #3630 gitnexus/src/core/ingestion * fix(scope): publish #3630 gitnexus/src/core/ingestion/languages/typescript * fix(scope): publish #3630 gitnexus/src/core/ingestion/languages * fix(scope): publish #3630 gitnexus/src/core/ingestion/pipeline-phases * fix(scope): publish #3630 gitnexus/src/core/ingestion/route-extractors * fix(scope): publish #3630 gitnexus/src/core/ingestion/workers * fix(scope): publish #3630 gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/lib * fix(scope): publish #3630 gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity * fix(scope): publish #3630 gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/packages/http * fix(scope): publish #3630 gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/packages/http/src * fix(scope): publish #3630 gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/server * fix(scope): publish #3630 gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/web * fix(scope): publish #3630 gitnexus/test/integration/resolvers * fix(scope): publish #3630 gitnexus/test/unit * fix(scope): publish #3630 gitnexus/test/unit/group * fix(scope): publish #3630 gitnexus/test/unit/scope-resolution * fix(scope): publish #3630 gitnexus/test/unit/scope-resolution/typescript * fix(scope): publish #3630 gitnexus-shared/src/scope-resolution * fix(scope): treat wrapped assignment targets as writes `(app) = new Map()`, `(app as T) = x` and `app! = x` assign the same binding as `app = x`, but route-fact invalidation and the shared pattern walker stopped at the wrapper, so Express provenance survived the write and produced a false route. Recurse through parenthesized, as/satisfies and non-null targets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(routes): resolve wrappers through overloads, declarator lists and barrels Three wrapper shapes lost their identity: - Bodyless overload signatures were indexed as separate definitions sharing the implementation's binding, so the binding looked ambiguous and both local and imported calls were rejected. Signatures now declare no callable. - Facts for `const base = 0, request = (url) => fetch(url)` followed the first declarator. The captured name now selects its own declarator. - An import resolved to a workspace barrel stopped at index.ts. Workers now record named, star and imported-then-exported re-exports, and resolution follows them to the declaring module: local and named exports shadow `export *`, star exports never carry `default`, and conflicting star exports block the name. No repository-wide name fallback is restored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(scope): assert closure ownership against the declaration The closure-write assertion compared one lookup with itself, which also passes when both are undefined. Compare it with the outer declaration and the shadowing parameter, and keep the memoization check explicit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(routes): share lexical helpers and release per-file HTTP contexts - Share binding keys, parameter patterns and transparent expression wrappers from lexical-bindings instead of four local copies; drop the unused moduleStem export; name the inferred-source predicates in lookup. - Key the per-file HTTP context by its tree rather than its ParsedFile. Workers retain ParsedFiles for the whole chunk, which kept every file's AST and traversal maps alive until the chunk flushed. - Index re-exports by name, memoize barrel walks per starting export, and keep facts without import targets as-is instead of copying them. - Move TS-only wrapper cases into the TS describe.each instead of branching inside the per-language loop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(routes): keep wrapper identity for HOCs, CommonJS, object keys and fetch packages Four definition shapes that main linked by name lost their wrapper identity: - `const load = useCallback((url) => fetch(url))` and curried HOCs produced no facts because definition lookup stopped at the call; it now follows the callable argument, as the structural definition capture does. - `module.exports = { request }`, `exports.x = …` and `module.exports.x = …` were not exports, so destructured `require` callers missed the wrapper. CommonJS export assignments are recorded when `module`/`exports` are the unshadowed globals, including functions defined inline in the export object. - An object-literal key or method name was resolved as a lexical binding and collided with a same-named module wrapper. Only binding identifiers now name a definition's binding. - `import fetch from 'node-fetch'` no longer counted as fetch. Imports of known fetch implementations (node-fetch, cross-fetch, isomorphic-fetch, the fetch member of undici and whatwg-fetch) are fetch evidence again; parameters and other local names still are not. Reference identifiers are also resolved only when their name is bound to a callable or class region; other identifiers cannot add a reachability edge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(routes): drive the forwarding fallback case from data and cover re-export merges Replace the boolean-switched forwarding fallback case with table rows instead of branching inside the test, and assert that barrel re-export facts survive worker sub-batch merges. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(bench): reject stale or dirty call-graph exports The committed comparison named a candidate revision that is not on this branch, and nothing stopped it. Comparison now requires both exports to come from clean checkouts and the candidate export to come from the checkout running the comparison. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(bench): regenerate the call-graph comparison on the reviewed head Clean baseline565e50276and clean candidate8a5f7b738on the frozen 432-file corpus (fingerprint 88be1faf...): exactly one CALLS target changes, the annotated closure probe `declaredOwner.writeAndRead` now calls `Base.m` instead of `Foo.m`. The other 431 CALLS and all 11 METHOD_OVERRIDES edges are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(bench): emit export provenance and review notes from the comparison The comparison report carried export provenance and the expected-change explanation only as hand-added fields, which a regeneration dropped. Emit both exports' revisions, dirty flags and fingerprints, the unchanged CALLS and METHOD_OVERRIDES counts, and an optional `--note` explanation directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(bench): record calibrated scaling reports and the regenerated comparison Duration-calibrated-v2 runs (5 warmups, 15 measured batches, 500 ms pilot target) pinned to the same CPU, baseline then candidate back to back: - Baseline565e50276exceeds the 1.8 normalized ceiling on six arms at 4x input: position-index 7.92, position-deep-completed 4.65, nested-helpers 4.82, nested-helper-chain 14.05, reverse-chain 4.31, name-collisions 2.61 (medians). - Candidate94f1a055bpasses --check: every arm stays at or below 1.32 at 4x for both minimum and median, with correct semantic guards on every arm. The comparison is regenerated from clean exports with its provenance and review note emitted by the tool: one expected CALLS target change, 431 CALLS and 11 METHOD_OVERRIDES unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(scope): replace recursion with explicit stacks in new traversals The binding work added five recursive traversals whose depth follows input shape: region summaries (callable nesting), barrel resolution (re-export chain length), pattern binding names (destructuring depth), route-fact write invalidation (pattern depth), and the position index's max-end tree. Each now uses an explicit stack with the same visiting order and results. Regression cases cover the inputs the recursive versions could not handle: a 20,000-deep destructuring pattern and a 50,000-module re-export chain both threw RangeError before this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(routes): avoid a full-tree script walk when building lexical bindings Route-fact extraction ran 1.78x slower than main on a 512-function file (87 ms vs 49 ms) because createLexicalBindings walked every AST node in script to detect collapsed parameter/body environments, which only functions with parameter defaults or computed keys can have. - Find functions with the native type search, skip plain parameter lists without any subtree search, and walk only the formals and bodies of the rest. Detection is unchanged, except that functions inside syntax-error regions are now also checked, which can only add fail-closed ambiguity. - Read each node's id and start position once per lookup instead of repeatedly crossing into native getters. The same extraction now takes about 56 ms against 51 ms on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(routes): walk wrapper evidence with a tree cursor The wrapper context visited every node through namedChildren, which materializes each child as a SyntaxNode and records a parent edge by node id. A tree cursor reads node types and offsets natively, so only the node types the handlers inspect become SyntaxNodes. Declared identifiers are keyed by start offset, and the walk records the ancestor facts later queries need (stored binding, default export, declarator of a name) instead of a parent map. On the 512-wrapper scaling input, context construction drops from about 58 ms to 48 ms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(bench): regenerate the call-graph comparison on the cursor walk The candidate export at6061ca71bhas the same fingerprint as the previous candidate: the traversal rewrite changes no edge. The one expected target change and the 431 unchanged CALLS and 11 METHOD_OVERRIDES remain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(bench): record final scaling reports on the cursor walk Baseline565e50276and candidate61de364ffran back to back on CPU 23 with the duration-calibrated-v2 protocol. Every candidate check stays at or below a 1.32 normalized factor against the 1.8 ceiling; the baseline fails six of nine arms. Workload fingerprints agree except the documented name-collision false positive in the baseline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(plan): checkpoint the final review, scaling and suite results for #3630 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(routes): treat export aliases of one wrapper as one star-export target Star-export candidates were deduplicated by the resolved export name, so `export { request, request as send }` reached through two barrels counted as two targets and blocked the import, dropping the consumer's FETCHES edge. Candidates are now keyed by the declaration each export name names, matching ECMAScript ResolveExport's binding comparison; distinct declarations exported under the same name stay ambiguous. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(bench): regenerate scaling and call-graph reports on the alias fix Baseline565e50276and candidate4d62bd045ran back to back on CPU 23. Every candidate check stays at or below 1.22 against the 1.8 ceiling; the baseline still fails six of nine arms. The CALLS/METHOD_OVERRIDES export is unchanged (fingerprint 4db24f57), so the comparison only records the new candidate revision. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(plan): record the export-alias fix and the regenerated reports for #3630 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(scope): keep the type a write gives an undeclared name A constructor write to a name no scope declares (`client = new ApiClient()` in sloppy JavaScript) declares nothing, so the lookup stays absent. The carried constructor fact was dropped on that exit, which lost the receiver type main had inferred for later reads in the writing scope. The absent result now carries it, and findReceiverTypeBinding uses it before the namespace fallbacks. Only complete-claim providers carry such a fact. Tests pin the carried type per inferred source, the undeclared write, and preservePosition across a module redirect. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(routes): close the wrapper identity gaps found in review - CommonJS wrappers captured as their assignment (`exports.x = function`, `module.exports.x = () =>`) produce facts again: findFunction follows the assignment's right side. - Name-only fallback considers only definitions that can be the target: an unresolvable import names a module-level export, an undeclared global a top-level function. Nested and unexported helpers no longer erase a wrapper's edges. - Only possible wrappers and module-level functions are emitted, and the routes phase builds the index only when routes and candidate calls exist. - A rebound binding (later assignment, `var` initializer over a function declaration, reassigned export) proves no wrapper across files. - `(<T>x) = ...` is a write; transparent wrappers skip type arguments. - Calls inside recovered syntax are still collected; instance-field initializers resolve after the module body, so a later declaration is visible. - Local exports are always recorded, so a star export conflicting with a non-callable export is ambiguous, and fully resolved barrel frames are shared across walks. - HTTP hooks run per file under a guard; a failing file or one without a ParsedFile still records its function names so fallback declines. - namesFetch is iterative, large patterns no longer spread into push, the facts cache is keyed by the name node, and the provider context cache checks its ParsedFile. Tests cover each case; the behavior rows fail on the previous source. The TS-only call cases no longer branch inside the test file, call targets are asserted exactly, the function-type list is drift-checked, and the persistence test joins the cross-platform worker list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(routes): describe wrapper fallback, barrel and recall boundaries Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(bench): count the new CommonJS fixture's dropped write site `lib/cjs-member.js` in the fetch-wrapper target-identity fixture writes `module.exports.arrowRequest`, a member write whose receiver is not typed. That adds one `receiver-unresolved` write (190 -> 191 all-kind drops, 19 -> 20 writes). Call drops and shape-arm states are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(bench): regenerate scaling and call-graph reports on the review fixes Baseline565e50276and candidate528ea1478ran back to back on CPU 23. Every candidate check stays at or below 1.27 against the 1.8 ceiling; the baseline still fails six of nine arms. The CALLS/METHOD_OVERRIDES export is unchanged (fingerprint 4db24f57), so the comparison only records the new candidate revision. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(plan): checkpoint the review fixes and regenerated reports for #3630 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(routes): share CommonJS checks and trim per-node work - `isCommonJsGlobal`/`isModuleExports` in lexical-bindings replace the two copies in fetch-wrapper and framework-route-facts (both resolve the same scope from the same start position). - LEXICAL_FUNCTION_TYPES is derived from the TypeScript FUNCTION_NODE_TYPES instead of a third hand-kept copy; the drift test still pins it. - `unwrap` reads UNWRAPPED_TYPES, the set the cursor walk mirrors, and the class-body region is no longer shadowed under the name `deferred`. - framework-route-facts reads each node's type once per loop iteration. - The position index returns the latest-starting scope when it already contains the point, before the max-end tree search; that entry is the search's rightmost match. - carried() returns a shared empty set for argument forms that cannot carry a value, the resolution file set is built without intermediate arrays, and the routes phase reads the call list once. No behavior change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(bench): regenerate scaling and call-graph reports on the refactor Baseline565e50276and candidate3db8657baran back to back on CPU 23. Every candidate check stays at or below 1.32 against the 1.8 ceiling; the baseline still fails six of nine arms. The CALLS/METHOD_OVERRIDES export is unchanged (fingerprint 4db24f57). The later main merge (e2a92dab8) touches no measured file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(plan): checkpoint the simplification pass and its reports for #3630 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Address PR review feedback (#3632) - Destructuring defaults (`{ fetch = globalThis.fetch } = {}`, renamed, array and local forms) are alias evidence for their leaf binding and no longer count a second initialization, which also invalidated bindings like `[send = other] = values`. - `export default (request)` records the default export: the value is unwrapped before the identifier check. - The HTTP fallback records and the collapsed-environment test row now say what they stand for. Regression rows cover each form in JS, TS and TSX and fail on the previous source; two fixture consumers cover imported wrappers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(bench): refresh lexical binding reports after review fixes Regenerate clean baseline and candidate measurements atc263c4f72. All candidate scaling checks pass, the semantic delta remains one CALLS target, and receiver-resolution counts match the existing baseline. * docs(plan): checkpoint refreshed review-fix measurements Recordc263c4f72review fixes, the regenerated scaling and semantic results, and the passing receiver and CI checks. --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
718 lines
27 KiB
TypeScript
718 lines
27 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import Parser from 'tree-sitter';
|
|
import JavaScript from 'tree-sitter-javascript';
|
|
import TypeScript from 'tree-sitter-typescript';
|
|
import { createFetchWrapperContext } from '../../src/core/ingestion/route-extractors/fetch-wrapper.js';
|
|
|
|
import { buildFetchWrapperIndex } from '../../src/core/ingestion/route-extractors/fetch-wrapper-index.js';
|
|
import { extractParsedFile } from '../../src/core/ingestion/scope-extractor-bridge.js';
|
|
import {
|
|
typescriptProvider,
|
|
javascriptProvider,
|
|
} from '../../src/core/ingestion/languages/typescript.js';
|
|
|
|
for (const [language, grammar] of [
|
|
['JavaScript', JavaScript],
|
|
['TypeScript', TypeScript.typescript],
|
|
['TSX', TypeScript.tsx],
|
|
] as const) {
|
|
describe(`${language} wrapper provenance`, () => {
|
|
const parser = new Parser();
|
|
parser.setLanguage(grammar);
|
|
const analyze = (source: string, lineOffset = 0) => {
|
|
const tree = parser.parse(source);
|
|
const fullSource = '\n'.repeat(lineOffset) + source;
|
|
const parsedFile = extractParsedFile(
|
|
language === 'JavaScript' ? javascriptProvider : typescriptProvider,
|
|
fullSource,
|
|
language === 'TSX' ? 'source.tsx' : 'source.ts',
|
|
undefined,
|
|
parser.parse(fullSource),
|
|
)!;
|
|
return { tree, parsedFile, context: createFetchWrapperContext(tree, parsedFile, lineOffset) };
|
|
};
|
|
it.each([
|
|
['injected fetch', 'function load(url, fetch) { fetch(url); }', false, false],
|
|
[
|
|
'platform default',
|
|
'function load(url, fetch = globalThis.fetch) { fetch(url); }',
|
|
true,
|
|
true,
|
|
],
|
|
// A destructuring default is alias evidence for its leaf binding, counted once.
|
|
[
|
|
'destructured platform default',
|
|
'function load(url, { fetch = globalThis.fetch } = {}) { fetch(url); }',
|
|
true,
|
|
true,
|
|
],
|
|
[
|
|
'renamed destructured platform default',
|
|
'function load(url, { fetch: send = globalThis.fetch } = {}) { send(url); }',
|
|
true,
|
|
true,
|
|
],
|
|
[
|
|
'array destructured platform default',
|
|
'function load(url, [send = globalThis.fetch] = []) { send(url); }',
|
|
true,
|
|
true,
|
|
],
|
|
[
|
|
'destructured local default',
|
|
'function load(url, options) { const { fetch = globalThis.fetch } = options; fetch(url); }',
|
|
true,
|
|
true,
|
|
],
|
|
[
|
|
'destructured unknown default',
|
|
'function load(url, { fetch = other } = {}) { fetch(url); }',
|
|
false,
|
|
false,
|
|
],
|
|
['self default', 'function load(url, fetch = fetch) { fetch(url); }', false, false],
|
|
[
|
|
'shadowed default root',
|
|
'function load(url, globalThis, fetch = globalThis.fetch) { fetch(url); }',
|
|
false,
|
|
false,
|
|
],
|
|
[
|
|
'sibling URL shadow',
|
|
'function load(url) { { const url = "fixed"; } fetch(url); }',
|
|
true,
|
|
true,
|
|
],
|
|
[
|
|
'sibling platform shadow',
|
|
'function load(url) { { const globalThis = {}; } globalThis.fetch(url); }',
|
|
true,
|
|
true,
|
|
],
|
|
[
|
|
'sibling alias shadow',
|
|
'function load(url) { const send = globalThis.fetch; { const send = other; } send(url); }',
|
|
true,
|
|
true,
|
|
],
|
|
[
|
|
'alias reassignment',
|
|
'function load(url) { let send = globalThis.fetch; send = other; send(url); }',
|
|
false,
|
|
false,
|
|
],
|
|
['URL reassignment', 'function load(url) { url = "fixed"; fetch(url); }', true, false],
|
|
[
|
|
'parenthesized alias reassignment',
|
|
'function load(url) { let send = globalThis.fetch; (send) = other; send(url); }',
|
|
false,
|
|
false,
|
|
],
|
|
[
|
|
'parenthesized URL reassignment',
|
|
'function load(url) { (url) = "fixed"; fetch(url); }',
|
|
true,
|
|
false,
|
|
],
|
|
[
|
|
'unused recursive helper',
|
|
'function load(url) { function helper() { fetch(url); helper(); } }',
|
|
false,
|
|
false,
|
|
],
|
|
[
|
|
'unused mutual recursion',
|
|
'function load(url) { function a() { b(); } function b() { fetch(url); a(); } }',
|
|
false,
|
|
false,
|
|
],
|
|
[
|
|
'rooted recursion',
|
|
'function load(url) { function a() { b(); } function b() { fetch(url); a(); } a(); }',
|
|
true,
|
|
true,
|
|
],
|
|
['IIFE', 'function load(url) { (() => fetch(url))(); }', true, true],
|
|
['inline callback', 'function load(url) { retry(() => fetch(url)); }', true, true],
|
|
['escaping anonymous', 'function load(url) { return () => fetch(url); }', true, true],
|
|
[
|
|
'stored anonymous property',
|
|
'function load(url) { object.callback = () => fetch(url); }',
|
|
true,
|
|
true,
|
|
],
|
|
['unused arrow', 'function load(url) { const later = () => fetch(url); }', false, false],
|
|
[
|
|
'unused wrapped arrow',
|
|
'function load(url) { const later = (() => fetch(url)); }',
|
|
false,
|
|
false,
|
|
],
|
|
[
|
|
'referenced arrow',
|
|
'function load(url) { const later = () => fetch(url); return later; }',
|
|
true,
|
|
true,
|
|
],
|
|
[
|
|
'class static block',
|
|
'function load(url) { class C { static { fetch(url); } } }',
|
|
true,
|
|
true,
|
|
],
|
|
[
|
|
'class static field',
|
|
'function load(url) { class C { static x = fetch(url); } }',
|
|
true,
|
|
true,
|
|
],
|
|
['class computed key', 'function load(url) { class C { [fetch(url)]() {} } }', true, true],
|
|
[
|
|
'class heritage',
|
|
'function load(url) { class C extends factory(fetch(url)) {} }',
|
|
true,
|
|
true,
|
|
],
|
|
[
|
|
'deferred method',
|
|
'function load(url) { class C { method() { fetch(url); } } }',
|
|
false,
|
|
false,
|
|
],
|
|
[
|
|
'deferred instance field',
|
|
'function load(url) { class C { x = fetch(url); } }',
|
|
false,
|
|
false,
|
|
],
|
|
[
|
|
'referenced class',
|
|
'function load(url) { class C { method() { fetch(url); } } return new C(); }',
|
|
true,
|
|
true,
|
|
],
|
|
[
|
|
'class expression static',
|
|
'function load(url) { const C = class { static x = fetch(url); }; }',
|
|
true,
|
|
true,
|
|
],
|
|
[
|
|
'class expression deferred',
|
|
'function load(url) { const C = class { x = fetch(url); }; }',
|
|
false,
|
|
false,
|
|
],
|
|
[
|
|
'referenced class expression',
|
|
'function load(url) { const C = class { x = fetch(url); }; return C; }',
|
|
true,
|
|
true,
|
|
],
|
|
[
|
|
'collapsed parameter environments',
|
|
'function load(url, send = () => fetch(url)) { var fetch = () => 0; return send(); }',
|
|
false,
|
|
false,
|
|
],
|
|
])('%s', (_shape, source, seed, forwardsUrl) => {
|
|
const tree = parser.parse(source);
|
|
const parsedFile = extractParsedFile(
|
|
language === 'JavaScript' ? javascriptProvider : typescriptProvider,
|
|
source,
|
|
language === 'TSX' ? 'source.tsx' : 'source.ts',
|
|
undefined,
|
|
tree,
|
|
)!;
|
|
expect(
|
|
createFetchWrapperContext(tree, parsedFile).facts(tree.rootNode.firstNamedChild!),
|
|
).toMatchObject({ seed, forwardsUrl });
|
|
});
|
|
|
|
it.each([
|
|
['parameter', 'function load(request) { request("/api/x"); }', 'binding'],
|
|
['local nonwrapper', 'const request = custom; request("/api/x");', 'binding'],
|
|
['future local', 'request("/api/x"); const request = custom;', 'blocked'],
|
|
[
|
|
'mutated wrapper',
|
|
'function request(url) { return fetch(url); } request = custom; request("/api/x");',
|
|
'blocked',
|
|
],
|
|
[
|
|
'mutated import',
|
|
'import { request } from "./api"; request = custom; request("/api/x");',
|
|
'blocked',
|
|
],
|
|
[
|
|
'namespace member behavior',
|
|
'import * as api from "./api"; api.request = custom; api.request("/api/x");',
|
|
'member',
|
|
],
|
|
])('retains contradictory target evidence: %s', (_shape, source, kind) => {
|
|
const { context } = analyze(source);
|
|
expect(context.calls()).toMatchObject([{ target: { kind } }]);
|
|
});
|
|
|
|
it('records bare import targets while preserving member-call behavior', () => {
|
|
const { context } = analyze(
|
|
'import request from "@workspace/api"; import { load as send } from "@/service"; import * as api from "./client.js"; request("/a"); send("/b"); api.load("/c");',
|
|
);
|
|
expect(context.calls().map((call) => call.target)).toEqual([
|
|
{ kind: 'import', specifier: '@workspace/api', importedName: 'default' },
|
|
{ kind: 'import', specifier: '@/service', importedName: 'load' },
|
|
{ kind: 'member' },
|
|
]);
|
|
});
|
|
|
|
it('keeps negative definitions, exports and declaration identities across serialization', () => {
|
|
const { tree, context } = analyze(
|
|
'export function request(url) { return fetch(url); } export { request as send }; export default request; function outer() { function request(value) { return value; } }',
|
|
);
|
|
const functions = tree.rootNode.descendantsOfType('function_declaration');
|
|
const facts = functions.map((fn) => context.facts(fn)!);
|
|
expect(facts).toHaveLength(3);
|
|
expect(facts[0]).toMatchObject({
|
|
moduleScope: true,
|
|
exportedNames: ['request', 'send', 'default'],
|
|
seed: true,
|
|
});
|
|
expect(facts[2]).toMatchObject({
|
|
moduleScope: false,
|
|
exportedNames: [],
|
|
seed: false,
|
|
forwardsUrl: false,
|
|
});
|
|
expect(facts[0].bindingId).not.toBe(facts[2].bindingId);
|
|
expect(new Set(facts.map((fact) => fact.definitionId)).size).toBe(3);
|
|
expect(JSON.parse(JSON.stringify(facts))).toEqual(facts);
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
'after a non-callable initializer',
|
|
'const base = 0, request = (url) => fetch(url);',
|
|
'request',
|
|
true,
|
|
],
|
|
[
|
|
'after another callable',
|
|
'const first = (value) => value, request = (url) => fetch(url);',
|
|
'request',
|
|
true,
|
|
],
|
|
[
|
|
'for the first of several callables',
|
|
'const first = (value) => value, request = (url) => fetch(url);',
|
|
'first',
|
|
false,
|
|
],
|
|
])('selects the captured declarator %s', (_shape, source, name, wraps) => {
|
|
const { tree, context } = analyze(source);
|
|
const declaration = tree.rootNode.firstNamedChild!;
|
|
const nameNode = declaration
|
|
.descendantsOfType('identifier')
|
|
.find((node) => node.text === name)!;
|
|
const facts = context.facts(declaration, nameNode);
|
|
expect(facts).toMatchObject({ seed: wraps, forwardsUrl: wraps });
|
|
expect(facts?.bindingId).toBe(context.bindings.bindingId(nameNode));
|
|
});
|
|
|
|
it.each([
|
|
['a hook callback', 'const load = useCallback((url) => fetch(url), []); load("/api/x");'],
|
|
['a curried wrapper', 'const load = wrap(other((url) => fetch(url))); load("/api/x");'],
|
|
])('follows the callable inside %s', (_shape, source) => {
|
|
const { tree, context } = analyze(source);
|
|
const declaration = tree.rootNode.firstNamedChild!;
|
|
const nameNode = declaration.descendantsOfType('identifier')[0];
|
|
const facts = context.facts(declaration, nameNode);
|
|
expect(facts).toMatchObject({ seed: true, forwardsUrl: true });
|
|
expect(facts?.bindingId).toBe(context.bindings.bindingId(nameNode));
|
|
expect(context.calls()).toMatchObject([
|
|
{ target: { kind: 'binding', bindingId: facts?.bindingId } },
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
['an object-literal key', 'const api = { request: (url) => request(url) };', 'pair'],
|
|
['a class method name', 'class Api { request(url) { return url; } }', 'method_definition'],
|
|
])('keeps %s out of lexical binding identity', (_shape, other, nodeType) => {
|
|
const { tree, parsedFile, context } = analyze(
|
|
`export function request(url) { return fetch(url); }\n${other}\nrequest("/api/x");`,
|
|
);
|
|
const wrapper = tree.rootNode.descendantsOfType('function_declaration')[0];
|
|
const owner = tree.rootNode.descendantsOfType(nodeType)[0];
|
|
const key = owner.childForFieldName('key') ?? owner.childForFieldName('name')!;
|
|
const facts = [context.facts(wrapper)!, context.facts(owner, key)!];
|
|
expect(facts[1]).toMatchObject({ moduleScope: false, exportedNames: [] });
|
|
expect(facts[1].bindingId).toBeUndefined();
|
|
const index = buildFetchWrapperIndex(
|
|
facts.map((fact) => ({ filePath: parsedFile.filePath, functionName: 'request', ...fact })),
|
|
);
|
|
expect(context.calls().map((call) => index.isWrapperCall(call))).toEqual([true]);
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
'object, member and nested member forms',
|
|
'function request(url) { return fetch(url); }\nmodule.exports = { request, send: request };\nexports.other = request;\nmodule.exports.third = request;',
|
|
['request', 'send', 'other', 'third'],
|
|
],
|
|
[
|
|
'a whole-module export',
|
|
'function request(url) { return fetch(url); }\nmodule.exports = request;',
|
|
['default'],
|
|
],
|
|
[
|
|
'a shadowed module object',
|
|
'function request(url) { return fetch(url); }\nfunction install(module) { module.exports = { request }; }',
|
|
[],
|
|
],
|
|
])('records CommonJS exports of a declaration: %s', (_shape, source, names) => {
|
|
const { tree, context } = analyze(source);
|
|
expect(context.facts(tree.rootNode.firstNamedChild!)?.exportedNames.sort()).toEqual(
|
|
[...names].sort(),
|
|
);
|
|
});
|
|
|
|
// The query captures these wrappers as their assignment, not as the function.
|
|
it.each([
|
|
[
|
|
'an exports member',
|
|
'exports.request = function (url) { return fetch(url); };',
|
|
['request'],
|
|
],
|
|
['a module.exports member', 'module.exports.request = (url) => fetch(url);', ['request']],
|
|
[
|
|
'the whole module',
|
|
'module.exports = async function (url) { return fetch(url); };',
|
|
['default'],
|
|
],
|
|
])('records a wrapper captured as its CommonJS assignment: %s', (_shape, source, names) => {
|
|
const { tree, context } = analyze(source);
|
|
const assignment = tree.rootNode.descendantsOfType('assignment_expression')[0];
|
|
const property = assignment.childForFieldName('left')?.childForFieldName('property');
|
|
expect(context.facts(assignment, property ?? undefined)).toMatchObject({
|
|
moduleScope: true,
|
|
exportedNames: names,
|
|
seed: true,
|
|
forwardsUrl: true,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
'an aliased export reassigned later',
|
|
'function request(url) { return fetch(url); } export { request as send }; request = JSON.stringify;',
|
|
['send'],
|
|
],
|
|
[
|
|
'a reassigned let export',
|
|
'export let request = (url) => fetch(url); request = String;',
|
|
['request'],
|
|
],
|
|
[
|
|
'a declaration rebound by a var initializer',
|
|
'function request(url) { return fetch(url); } var request = ipc.send;',
|
|
[],
|
|
],
|
|
])('keeps a rebound binding from proving a wrapper: %s', (_shape, source, names) => {
|
|
const { tree, context } = analyze(source);
|
|
const [callable] = tree.rootNode.descendantsOfType([
|
|
'function_declaration',
|
|
'arrow_function',
|
|
]);
|
|
expect(context.facts(callable)).toMatchObject({
|
|
exportedNames: names,
|
|
seed: false,
|
|
forwardsUrl: false,
|
|
forwardsTo: [],
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
'inside recovered syntax',
|
|
'import { request } from "./http";\nfoo(() => { request("/api/a"); }, ',
|
|
{ kind: 'import', specifier: './http', importedName: 'request' },
|
|
],
|
|
[
|
|
'in an instance field initializer before the declaration',
|
|
'export class Store { data = request("/api/a"); }\nconst request = (url) => fetch(url);',
|
|
{ kind: 'binding' },
|
|
],
|
|
[
|
|
'in a static field initializer before the declaration',
|
|
'export class Store { static data = request("/api/a"); }\nconst request = (url) => fetch(url);',
|
|
{ kind: 'blocked' },
|
|
],
|
|
])('resolves a call %s', (_shape, source, target) => {
|
|
const { context } = analyze(source);
|
|
expect(context.calls()).toMatchObject([{ fetchURL: '/api/a', target }]);
|
|
});
|
|
|
|
it('keys cached facts by the name node as well as the callable', () => {
|
|
const { tree, context } = analyze('function request(url) { return fetch(url); }');
|
|
const fn = tree.rootNode.descendantsOfType('function_declaration')[0];
|
|
const [parameter] = fn.descendantsOfType('identifier').filter((node) => node.text === 'url');
|
|
const byName = context.facts(fn);
|
|
expect(byName?.bindingId).toBeDefined();
|
|
expect(context.facts(fn, parameter)?.bindingId).not.toBe(byName?.bindingId);
|
|
expect(context.facts(fn)).toBe(byName);
|
|
});
|
|
|
|
it('records a function defined inline in a CommonJS export object', () => {
|
|
const { tree, context } = analyze('module.exports = { inline: (url) => fetch(url) };');
|
|
const pair = tree.rootNode.descendantsOfType('pair')[0];
|
|
expect(context.facts(pair, pair.childForFieldName('key')!)).toMatchObject({
|
|
moduleScope: true,
|
|
exportedNames: ['inline'],
|
|
seed: true,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
['a default node-fetch import', 'import fetch from "node-fetch";', 'fetch', true],
|
|
['a named undici fetch', 'import { fetch as get } from "undici";', 'get', true],
|
|
['a required cross-fetch module', 'const nf = require("cross-fetch");', 'nf', true],
|
|
[
|
|
'a whatwg-fetch member',
|
|
'import { fetch as polyfill } from "whatwg-fetch";',
|
|
'polyfill',
|
|
true,
|
|
],
|
|
['a local module named fetch', 'import fetch from "./local-fetch";', 'fetch', false],
|
|
[
|
|
'another member of a fetch package',
|
|
'import { Headers as fetch } from "node-fetch";',
|
|
'fetch',
|
|
false,
|
|
],
|
|
])(
|
|
'treats %s as fetch evidence only for known fetch exports',
|
|
(_shape, header, local, wraps) => {
|
|
const { tree, context } = analyze(
|
|
`${header}\nfunction load(url) { return ${local}(url); }`,
|
|
);
|
|
expect(context.facts(tree.rootNode.lastNamedChild!)).toMatchObject({
|
|
seed: wraps,
|
|
forwardsUrl: wraps,
|
|
});
|
|
},
|
|
);
|
|
|
|
it('keeps distinct declarations even when a binding is shared', () => {
|
|
const { tree, context } = analyze(
|
|
'function request(url) { return fetch(url); } function request(url) { return custom(url); }',
|
|
);
|
|
const facts = tree.rootNode.namedChildren.map((fn) => context.facts(fn)!);
|
|
expect(facts[0].bindingId).toBe(facts[1].bindingId);
|
|
expect(facts[0].definitionId).not.toBe(facts[1].definitionId);
|
|
});
|
|
|
|
it('retains source offsets and provider context identity', () => {
|
|
const { tree, parsedFile, context } = analyze(
|
|
'export function request(url) { return fetch(url); }\nrequest("/api/x");',
|
|
12,
|
|
);
|
|
const definition = tree.rootNode.firstNamedChild!;
|
|
const provider = language === 'JavaScript' ? javascriptProvider : typescriptProvider;
|
|
const first = provider.extractFetchWrapperFacts!(definition, undefined, parsedFile, 12);
|
|
expect(provider.extractFetchWrapperFacts!(definition, undefined, parsedFile, 12)).toBe(first);
|
|
expect(context.facts(definition)).toEqual(first);
|
|
expect(first!.definitionId).toContain(':13:');
|
|
expect(context.calls()).toMatchObject([
|
|
{ lineNumber: 14, target: { kind: 'binding', bindingId: first!.bindingId } },
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
['a plain', 'function request(url) { return fetch(url); }\nexport default request;'],
|
|
[
|
|
'a parenthesized',
|
|
'function request(url) { return fetch(url); }\nexport default (request);',
|
|
],
|
|
])('records %s default export of a declaration', (_shape, source) => {
|
|
const { tree, context } = analyze(source);
|
|
const request = tree.rootNode.descendantsOfType('function_declaration')[0];
|
|
expect(context.facts(request)).toMatchObject({ exportedNames: ['default'], seed: true });
|
|
});
|
|
|
|
it('recognizes an anonymous default export', () => {
|
|
const { tree, context } = analyze('export default function (url) { return fetch(url); }');
|
|
expect(context.facts(tree.rootNode.firstNamedChild!)).toMatchObject({
|
|
moduleScope: true,
|
|
exportedNames: ['default'],
|
|
seed: true,
|
|
forwardsUrl: true,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
it.each(['deep', 'deep-arrow', 'wide'] as const)(
|
|
'keeps native AST traversal bounded for %s helpers',
|
|
(shape) => {
|
|
const count = 128;
|
|
const source = (
|
|
shape !== 'wide'
|
|
? [
|
|
'function load(url) {',
|
|
...Array.from({ length: count }, (_, i) =>
|
|
shape === 'deep' ? `function helper${i}() {` : `const helper${i} = () => {`,
|
|
),
|
|
'return fetch(url);',
|
|
...Array.from({ length: count }, (_, i) => `}; return helper${count - i - 1}();`),
|
|
'}',
|
|
]
|
|
: [
|
|
'function load(url) {',
|
|
...Array.from({ length: count }, (_, i) => `function helper${i}() { fetch(url); }`),
|
|
...Array.from({ length: count }, (_, i) => `helper${i}();`),
|
|
'}',
|
|
]
|
|
).join('\n');
|
|
const parser = new Parser();
|
|
parser.setLanguage(TypeScript.typescript);
|
|
const tree = parser.parse(source);
|
|
const parsedFile = extractParsedFile(typescriptProvider, source, 'nested.ts', undefined, tree)!;
|
|
const functions = tree.rootNode.descendantsOfType(['function_declaration', 'arrow_function']);
|
|
const depths = new Map<number, number>();
|
|
const pending = [{ node: tree.rootNode, depth: 0 }];
|
|
while (pending.length) {
|
|
const { node, depth } = pending.pop()!;
|
|
depths.set(node.id, depth);
|
|
for (const child of node.namedChildren) pending.push({ node: child, depth: depth + 1 });
|
|
}
|
|
let prototype = Object.getPrototypeOf(tree.rootNode);
|
|
while (!Object.hasOwn(prototype, 'parent')) prototype = Object.getPrototypeOf(prototype);
|
|
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'parent')!;
|
|
const childDescriptor = Object.getOwnPropertyDescriptor(prototype, 'namedChild')!;
|
|
let parentTraversalSteps = 0;
|
|
let childTraversalSteps = 0;
|
|
// tree-sitter's native ts_node_parent descends from the root on every call.
|
|
// Count that depth-weighted work only after parsing and scope extraction.
|
|
Object.defineProperty(prototype, 'parent', {
|
|
...descriptor,
|
|
get(this: Parser.SyntaxNode) {
|
|
parentTraversalSteps += depths.get(this.id) ?? 0;
|
|
return descriptor.get!.call(this);
|
|
},
|
|
});
|
|
// ts_node__child starts its sibling iterator at zero for every indexed read.
|
|
Object.defineProperty(prototype, 'namedChild', {
|
|
...childDescriptor,
|
|
value(this: Parser.SyntaxNode, index: number) {
|
|
childTraversalSteps += index + 1;
|
|
return childDescriptor.value.call(this, index);
|
|
},
|
|
});
|
|
try {
|
|
// The instruments must fire before a zero count can mean anything.
|
|
void functions[0].parent;
|
|
void functions[0].namedChild(0);
|
|
expect(parentTraversalSteps).toBeGreaterThan(0);
|
|
expect(childTraversalSteps).toBeGreaterThan(0);
|
|
parentTraversalSteps = 0;
|
|
childTraversalSteps = 0;
|
|
const context = createFetchWrapperContext(tree, parsedFile);
|
|
const facts = functions.map((fn) =>
|
|
context.facts(fn, fn.childForFieldName('name') ?? undefined),
|
|
);
|
|
expect(facts.every((fact) => fact?.seed)).toBe(true);
|
|
expect(facts[0]?.forwardsUrl).toBe(true);
|
|
expect(parentTraversalSteps).toBeLessThanOrEqual(32 * count);
|
|
expect(childTraversalSteps).toBeLessThanOrEqual(64 * count);
|
|
} finally {
|
|
Object.defineProperty(prototype, 'parent', descriptor);
|
|
Object.defineProperty(prototype, 'namedChild', childDescriptor);
|
|
}
|
|
},
|
|
);
|
|
|
|
describe.each([
|
|
['TypeScript', TypeScript.typescript, 'source.ts'],
|
|
['TSX', TypeScript.tsx, 'source.tsx'],
|
|
] as const)('%s-only wrapper syntax', (_language, grammar, filePath) => {
|
|
const analyze = (source: string) => {
|
|
const parser = new Parser();
|
|
parser.setLanguage(grammar);
|
|
const tree = parser.parse(source);
|
|
const parsedFile = extractParsedFile(typescriptProvider, source, filePath, undefined, tree)!;
|
|
return { tree, context: createFetchWrapperContext(tree, parsedFile) };
|
|
};
|
|
|
|
it('associates bodyless overload signatures with their implementation', () => {
|
|
const { tree, context } = analyze(
|
|
'export function request(url: string): Promise<Response>;\nexport function request(url: URL): Promise<Response>;\nexport function request(url: any) { return fetch(url); }\nrequest("/api/x");',
|
|
);
|
|
const signatures = tree.rootNode.descendantsOfType('function_signature');
|
|
const implementation = tree.rootNode.descendantsOfType('function_declaration')[0];
|
|
expect(signatures.map((signature) => context.facts(signature))).toEqual([undefined, undefined]);
|
|
const facts = context.facts(implementation)!;
|
|
expect(facts).toMatchObject({ seed: true, forwardsUrl: true, exportedNames: ['request'] });
|
|
expect(context.calls()).toMatchObject([
|
|
{ target: { kind: 'binding', bindingId: facts.bindingId } },
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
'keeps stored asserted callbacks deferred',
|
|
'function load(url) { const later = (() => fetch(url)) as () => unknown; }',
|
|
false,
|
|
],
|
|
[
|
|
'evaluates supported decorator expressions while deferring methods',
|
|
'function load(url) { @decorate(fetch(url)) class C { @decorate(fetch(url)) method() { return custom(url); } } }',
|
|
true,
|
|
],
|
|
])('%s', (_shape, source, wraps) => {
|
|
const { tree, context } = analyze(source);
|
|
expect(context.facts(tree.rootNode.firstNamedChild!)).toMatchObject({
|
|
seed: wraps,
|
|
forwardsUrl: wraps,
|
|
});
|
|
});
|
|
|
|
it('keeps a nested overloaded helper reachable through its one implementation', () => {
|
|
const { tree, context } = analyze(
|
|
'function load(url: string) { function helper(u: string): void; function helper(u: any) { fetch(u); } helper(url); }',
|
|
);
|
|
const facts = context.facts(tree.rootNode.firstNamedChild!)!;
|
|
expect(facts.seed).toBe(true);
|
|
expect(facts.forwardsTo).toMatchObject([{ callee: 'helper', target: { kind: 'binding' } }]);
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
'an asserted alias write invalidates the alias',
|
|
'function load(url: string) { let send = globalThis.fetch; (send as unknown) = other; send(url); }',
|
|
false,
|
|
],
|
|
[
|
|
'a non-null asserted alias write invalidates the alias',
|
|
'function load(url: string) { let send = globalThis.fetch; send! = other; send(url); }',
|
|
false,
|
|
],
|
|
])('%s', (_shape, source, wraps) => {
|
|
const { tree, context } = analyze(source);
|
|
expect(context.facts(tree.rootNode.firstNamedChild!)).toMatchObject({
|
|
seed: wraps,
|
|
forwardsUrl: wraps,
|
|
});
|
|
});
|
|
});
|
|
|
|
it('invalidates an alias written through an angle-bracket assertion', () => {
|
|
const source =
|
|
'function load(url: string) { let send = globalThis.fetch; (<any>send) = other; send(url); }';
|
|
const parser = new Parser();
|
|
parser.setLanguage(TypeScript.typescript);
|
|
const tree = parser.parse(source);
|
|
const parsedFile = extractParsedFile(typescriptProvider, source, 'source.ts', undefined, tree);
|
|
const load = tree.rootNode.descendantsOfType('function_declaration')[0];
|
|
expect(parsedFile && createFetchWrapperContext(tree, parsedFile).facts(load)).toMatchObject({
|
|
seed: false,
|
|
forwardsUrl: false,
|
|
});
|
|
});
|