GitNexus/gitnexus/test/unit/scope-resolution/javascript/javascript-captures.test.ts
Adam B. 7376347058
feat(ingestion): add tRPC procedure detection and MCP chain/route surfacing (#3339)
* feat(ingestion): tRPC pattern detection — procedures, curried calls, route extraction

Three changes to properly index tRPC router files:

1. HOC-in-pair patterns: detect procedures like
   'create: procedure.mutation(async ({ input }) => {...})'
   as named Function nodes (pair > call_expression > arguments > arrow_function)

2. Curried call detection: capture 'workflow(db)(input)' chained calls
   where call_expression.function is itself a call_expression

3. tRPC route extraction: new route-extractors/trpc.ts detects
   .query()/.mutation()/.subscription() procedures, maps to /trpc/* routes
   with prefix inference from router variable names

4. Function/Const dedup: structural check skips Const nodes when
   variable_declarator value is arrow_function/function_expression

5. Process-route linking: match by (filePath, methodName) instead of
   filePath only, preventing shared flows across procedures in same router

(cherry picked from commit eced15e60a39a181a48bff8c5bbe278d26aa53b8)

* fix(route-extractors): line-by-line scanner for chained tRPC procedures

Original regex only matched direct patterns (list: proc.query()) but not
chained patterns (create: proc.input(z.object({...})).mutation()). Only
41/256 routes were detected on Jurialis.

Rewrote extractTrpcRoutes() as a line-by-line scanner that:
- Detects procedure keys starting with *Procedure builders
- Tracks currentProcedure forward until terminal .query()/.mutation()
- Handles .input() chaining naturally
- Deduplicates via seen Set on procedurePath

Result: 245 routes from 30 routers (was 41), 256 total after re-index.
(cherry picked from commit 49edf953e93e6b67b77932e866fcbfb8d089b1f3)

* feat(mcp): expose tRPC chains via context/query tools

Eight fixes to make the tRPC route->procedure->workflow->sub-workflow chain
visible through the standard MCP tools (context, query) that AI agents use,
instead of requiring raw Cypher queries.

- A context: order incoming/outgoing CALLS test-last, raise LIMIT 30->100
- B query: batched ENTRY_POINT_OF lookup, surface route URL on processes
- C query: mark process_symbols entry point with is_entry_point=true
- D entry-point-scoring: skip UTILITY_PATTERNS (get*/set*) penalty for
  symbols in tRPC router files so framework boost (3.0x) is preserved
- E fts-schema: index Route nodes so /trpc/* URLs are keyword-searchable
- F tools: bump max_symbols default 10->25 to fit procedure->workflow chain
- G context: optional chain_depth (0-3) param walks CALLS edges in both
  directions and returns layered chain field
- H LadybugDB bug workaround: WHERE r.type IN [...] silently drops edges
  on relationship properties; replace with OR chains (7 occurrences in
  local-backend.ts, pdg-impact.ts, graph-queries.ts)

Validated on Jurialis: setProviderCap procedure now appears as caller of
setProviderCapWorkflow, /trpc/cabinet.setProviderCap Route is searchable,
chain_depth=3 returns the layered call graph.

(cherry picked from commit c24df0adce91e80f191a5c5d2e8b14e9da677366)

* feat(mcp): surface is_entry_point + routes in context, raise query limit

context() is the mandatory pre-edit tool (AGENTS.md). Until now an agent
had to issue a separate query() call just to learn whether its symbol is
a process entry point or which HTTP route it handles. These two fields
make context() self-sufficient for the bmad-dev flow.

- context: query STEP_IN_PROCESS now returns p.entryPointId; new
  ENTRY_POINT_OF lookup attributes routes only from processes where the
  symbol is the entry point (middle steps do not own the route) plus any
  direct Route->symbol handler edge (tRPC procedures outside any Process)
- context: new top-level is_entry_point (true only) and routes[] fields
  ({url, method?}), emitted only when non-empty to keep the diff additive
- query: raise default limit 5->10 so dense domains (tRPC action router
  with 20 chains, data-export with 9 flows) surface more of their flow
  set without an explicit param

(cherry picked from commit 004f5b0ba985fcef3dfac5e67a2f2f7262184215)

* docs(mcp): document chain_depth, routes, and entry-point flag; neutralize examples in comments

* fix(mcp): address code-review findings on tRPC entry points, queries, and dispatch

- entry-point scoring: optional-chain framework detection and normalize
  path separators before router-pattern matching (P1 crash on .js routers)
- tRPC extractor: emit controllerName as null (callers resolve via
  lookupClassByName; a router object stringified as name corrupted lookups)
- route dedup: key seenRoutes by method:url so GET/POST pairs survive
- legacy TS queries: drop curried-call patterns (arity-corrupting for
  overload resolution) and require non-array callee on pair member calls
- registry-primary TS query: mirror the non-array-callee predicate on the
  new pair member-expression patterns (fixes query compile error)
- group tool port: forward chain_depth into per-tool context args

* fix(mcp): nested tRPC router paths, controller-less route binding, query chain_depth

- trpc extractor: brace-depth nesting stack composes sibling router paths (bare router() import style included); merge-prefix dot normalization; strict publicProcedure allowlist gate; drop phantom router metadata

- call-processor: bind controller-less tRPC routes to same-file handlers via exact single-match symbol lookup; ambiguous or missing handlers skipped

- query/group query: optional chain_depth (0-3) enriches ranked processes with their context chain; fix _computeContextChain layer docstring

- tree-sitter TS/JS scope queries capture string-key function declarations; tRPC pattern scoring narrowed to server router files

- parse-cache schema bump to v103 for extractor and route-binding changes

- add trpc route extractor regression tests (9 cases incl. sibling nesting and merge prefix)

* Address PR review feedback (#3339)

Close remaining review threads: compact tRPC keys, comment-safe terminals,
quoted-key identifier HOC captures, group-query default alignment, and
LadybugDB label scalars on context chains.

* chore(autofix): apply prettier + eslint fixes via /autofix command

* test: pin PARSE_CACHE schema bump to 103 (PR #3339 review fixes)

* fix(ingestion): bind same-name tRPC handlers and surface HANDLES_ROUTE

Same-name procedures resolve by startLine, the extractor keeps nested paths
through multiline schemas, and context/query UNION HANDLES_ROUTE for leaf
procedures that never become Process entries.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Clamp group query bounds, drop HOC pair false positives, emit tRPC
terminal lines and hyphenated quoted keys, cap chain BFS concurrency,
and restrict the router utility exemption to accessor names.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Score JS/JSX tRPC routers like TypeScript, ignore inner db.query and
unrelated .merge calls, mask regex braces, and assert clamp tests invoke
the query mock.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(ingestion,mcp): emit-side HOC callback guards + MCP schema prettier (PR #3339 round 2)

* Address PR review feedback (#3339)

Treat `/` after return-style keywords as a regex, drop the synthetic
appRouter path prefix, and bind the create mutation via an inline callback.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Qualify query() docs so is_entry_point is promised only when the entry
symbol is among the search hits.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Emit tRPC terminals only at procedure paren depth, drop the filename
prefix on bare appRouter = router(), mask regex after if (), and cap
groupQuery member fan-out.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

De-duplicate context-chain BFS nodes reached through multiple frontier
edges, and bind tRPC identifier callbacks (`.mutation(handler)`) to the
handler symbol instead of the procedure key.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Apply LIMIT 50 after DISTINCT neighbors in the context-chain BFS, and
treat the official lowercase `procedure` builder as a tRPC procedure key.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Keep the later duplicate tRPC object-literal key so route binding matches
the handler JavaScript actually ships.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Compose same-file identifier-mounted tRPC subrouters so admin: adminRouter
emits the live admin.list path instead of an unprefixed /trpc/list.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Memoize tRPC identifier-mount paths so a depth-N chain stays linear, and
gate it with the build-free measure.mjs / baselines.json harness.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Compose identifier mounts without phantom /trpc URLs, bind wrapped and
multiline handlers, fail-close missing bench budgets, and reject query
page bounds before search.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Reject advertised MCP query bounds in group mode and chain_depth
instead of clamping or accepting non-integers.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Reject invalid groupContext chain_depth and ignore non-callable
same-file tRPC handler candidates.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Memoize live tRPC mount paths so the depth-N chain bench stays linear,
and render invalid group/MCP bounds without throwing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Keep t.merge('prefix.', namedRouter) procedures live by recording a
zero-hop mount so the unmounted-router drop does not hide them.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Recognize type-annotated `const adminRouter: AppRouter = t.router(`
bindings so identifier mounts still compose.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 09:44:42 +01:00

211 lines
8.9 KiB
TypeScript

/**
* Coverage for the JavaScript scope-captures orchestrator, focused on the
* #1876 array-method-callback narrowing.
*
* `const x = arr.map(a => …)` must NOT produce a `@declaration.function`
* named `x` (the binding holds a value, not a callable) — only the
* `@declaration.const`. Identifier-callee HOCs (`forwardRef`, `useMemo`)
* and direct arrow assignments keep their `@declaration.function`.
*
* Runs against tree-sitter-javascript so it catches grammar drift before
* the integration parity gate.
*/
import { describe, it, expect } from 'vitest';
import { emitJsScopeCaptures } from '../../../../src/core/ingestion/languages/javascript/captures.js';
function matchesFor(src: string) {
return emitJsScopeCaptures(src, 'test.js');
}
/** True when some match carries `tag` and its @declaration.name is `name`. */
function hasDecl(src: string, tag: string, name: string): boolean {
return matchesFor(src).some((m) => m[tag] !== undefined && m['@declaration.name']?.text === name);
}
/** Count matches carrying `tag` (any name). */
function countTag(src: string, tag: string): number {
return matchesFor(src).filter((m) => m[tag] !== undefined).length;
}
describe('emitJsScopeCaptures — #1876 array-method-callback narrowing', () => {
it('keeps callable-value bindings lexical across sibling functions', () => {
const invokes = matchesFor(
[
'function target() {}',
'function unrelated(target) { target(); }',
'function entry() { target(); }',
].join('\n'),
).filter((match) => match['@callable-flow.invoke'] !== undefined);
expect(invokes.map((match) => match['@callable-flow.invoke']!.range.startLine)).toEqual([2]);
});
it('does not emit @declaration.function for `const x = arr.map(a => …)`', () => {
const src = 'const exportData = accountsList.map(account => ({ id: account.id }));';
expect(hasDecl(src, '@declaration.const', 'exportData')).toBe(true);
expect(hasDecl(src, '@declaration.function', 'exportData')).toBe(false);
// Exactly one binding-bearing declaration for the name.
expect(countTag(src, '@declaration.function')).toBe(0);
});
// Every method in ARRAY_CALLBACK_METHODS except `map` (covered above).
it.each([
'filter',
'find',
'findIndex',
'findLast',
'findLastIndex',
'reduce',
'reduceRight',
'forEach',
'some',
'every',
'flatMap',
'sort',
])('suppresses the Function def for array method .%s()', (method) => {
const src = `const x = arr.${method}((a) => a);`;
expect(hasDecl(src, '@declaration.function', 'x')).toBe(false);
expect(hasDecl(src, '@declaration.const', 'x')).toBe(true);
});
it('keeps @declaration.function for an identifier-callee HOC (forwardRef)', () => {
const src = 'const Button = forwardRef((props, ref) => null);';
expect(hasDecl(src, '@declaration.function', 'Button')).toBe(true);
});
it('keeps @declaration.function for useMemo (identifier callee, unchanged this round)', () => {
const src = 'const value = useMemo(() => compute(), []);';
expect(hasDecl(src, '@declaration.function', 'value')).toBe(true);
});
it('keeps dual classification for a direct arrow `const fn = () => {}`', () => {
const src = 'const fn = () => { doThing(); };';
expect(hasDecl(src, '@declaration.function', 'fn')).toBe(true);
expect(hasDecl(src, '@declaration.const', 'fn')).toBe(true);
});
it('keeps @declaration.function for a non-array fluent-API member call (accepted limitation)', () => {
const src = 'const q = qb.where((row) => row.ok);';
expect(hasDecl(src, '@declaration.function', 'q')).toBe(true);
});
it('suppresses an in-set method name on a NON-array receiver (accepted receiver-blind limitation)', () => {
// The predicate keys on the method NAME only, never the receiver type —
// tree-sitter has no type info. So `.map` on an RxJS observable (or
// Map/Set `.forEach`, a query builder `.sort`, a lodash chain `.filter`)
// is also treated as a callback and loses its Function def. Accepted: the
// binding holds the call's result value, so a value def is correct anyway.
const src = 'const stream = source$.map((event) => handle(event));';
expect(hasDecl(src, '@declaration.function', 'stream')).toBe(false);
expect(hasDecl(src, '@declaration.const', 'stream')).toBe(true);
});
it('suppresses the outer .map() callback in a chained array call', () => {
const src = 'const x = arr.filter((a) => a).map((b) => b);';
expect(hasDecl(src, '@declaration.function', 'x')).toBe(false);
expect(hasDecl(src, '@declaration.const', 'x')).toBe(true);
});
it('suppresses through an export_statement wrapper', () => {
const src = 'export const x = arr.map((a) => a);';
expect(hasDecl(src, '@declaration.function', 'x')).toBe(false);
expect(hasDecl(src, '@declaration.const', 'x')).toBe(true);
});
it('suppresses a function_expression callback', () => {
const src = 'const x = arr.map(function (a) { return a; });';
expect(hasDecl(src, '@declaration.function', 'x')).toBe(false);
expect(hasDecl(src, '@declaration.const', 'x')).toBe(true);
});
it('suppresses an optional-chained array call `arr?.map(...)`', () => {
const src = 'const x = arr?.map((a) => a);';
expect(hasDecl(src, '@declaration.function', 'x')).toBe(false);
});
it('suppresses a parenthesized callee `(arr.map)(cb)`', () => {
const src = 'const x = (arr.map)((a) => a);';
expect(hasDecl(src, '@declaration.function', 'x')).toBe(false);
});
it('suppresses a computed callee `arr["map"](cb)`', () => {
const src = 'const x = arr["map"]((a) => a);';
expect(hasDecl(src, '@declaration.function', 'x')).toBe(false);
});
it('suppresses export-default array-method wrappers', () => {
const src = 'export default arr.map((a) => a);';
expect(countTag(src, '@declaration.function')).toBe(0);
});
it('suppresses obvious built-in callback wrappers in export default', () => {
const src = 'export default setTimeout(() => work());';
expect(countTag(src, '@declaration.function')).toBe(0);
});
it('rewrites export-default HOC names to the file stem', () => {
const matches = emitJsScopeCaptures(
'export default React.memo((props) => props);',
'routes/health-check.jsx',
);
expect(
matches.some(
(m) =>
m['@declaration.function'] !== undefined &&
m['@declaration.name']?.text === 'health-check',
),
).toBe(true);
});
it('names create: publicProcedure.mutation(withAuth(async () => {}))', () => {
const src = 'const r = { create: publicProcedure.mutation(withAuth(async () => {})) };';
expect(hasDecl(src, '@declaration.function', 'create')).toBe(true);
});
it('does not name variable-level nested HOC memo(forwardRef(...)) as Function', () => {
const src = 'const Wrapped = memo(forwardRef(() => null));';
expect(hasDecl(src, '@declaration.function', 'Wrapped')).toBe(false);
expect(hasDecl(src, '@declaration.const', 'Wrapped')).toBe(true);
});
});
// ---------------------------------------------------------------------------
// JSX-element-as-call-argument arity (#1956 tri-review U3): a JSX component used
// as a call argument, e.g. `render(<Foo a={1} b={2} />)`, must NOT inherit the
// enclosing call's arity. The JSX element is itself a `@reference.call.*` anchor;
// the call-arity walk-up would ascend from it into the enclosing call_expression
// and mis-attribute that call's arity. An early guard skips arity synthesis when
// the call anchor is a JSX element (restoring the pre-#1951 range-based behavior).
// ---------------------------------------------------------------------------
/** Arity text for the call-site match whose callee `@reference.name` is `name`;
* `'NONE'` when no such call-site match exists, `undefined` when it exists with
* no `@reference.arity`. */
function callArity(src: string, name: string, file = 'test.jsx'): string | undefined | 'NONE' {
const matches = emitJsScopeCaptures(src, file).filter(
(m) =>
Object.keys(m).some((k) => k.startsWith('@reference.call')) &&
m['@reference.name']?.text === name,
);
if (matches.length === 0) return 'NONE';
return matches[0]['@reference.arity']?.text;
}
describe('emitJsScopeCaptures — JSX-as-call-arg arity (#1956 U3)', () => {
it('does not attribute the enclosing call arity to a JSX component reference', () => {
const src = 'render(<Foo a={1} b={2} />);';
// The Foo JSX component ref must carry NO arity (was wrongly 1 before the fix).
expect(callArity(src, 'Foo')).toBeUndefined();
// The enclosing render() call keeps its real arity (1 argument: the element).
expect(callArity(src, 'render')).toBe('1');
});
it('keeps arity on a plain (non-JSX) call (regression guard)', () => {
expect(callArity('foo(1, 2);', 'foo', 'test.js')).toBe('2');
});
it('emits no arity for a standalone JSX element not used as a call argument', () => {
expect(callArity('const x = <Foo />;', 'Foo')).toBeUndefined();
});
});