gitnexus 1.6.13-rc.25 → 1.6.13-rc.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -187,8 +187,8 @@ Your AI agent gets **17 tools** (15 per-repo + 2 group) automatically:
187
187
  | Tool | What It Does |
188
188
  | ---------------- | ---------------------------------------------------------------------- |
189
189
  | `list_repos` | Discover all indexed repositories (paginated — `limit`/`offset`) |
190
- | `query` | Process-grouped hybrid search (BM25 + semantic + RRF) |
191
- | `context` | 360-degree symbol view — categorized refs, process participation |
190
+ | `query` | Process-grouped hybrid search (BM25 + semantic + RRF); optional `chain_depth` expands each result's call chain |
191
+ | `context` | 360-degree symbol view — categorized refs, process participation, HTTP routes, `is_entry_point` flag; optional `chain_depth` call-chain expansion |
192
192
  | `impact` | Blast radius analysis with depth grouping and confidence |
193
193
  | `trace` | Shortest directed path between two symbols (call + class-member edges) |
194
194
  | `detect_changes` | Git-diff impact — maps changed lines to affected processes |
@@ -40,6 +40,7 @@ export interface GroupToolPort {
40
40
  limit?: number;
41
41
  max_symbols?: number;
42
42
  include_content?: boolean;
43
+ chain_depth?: number;
43
44
  }): Promise<unknown>;
44
45
  impactByUid(repoId: string, uid: string, direction: string, opts: {
45
46
  maxDepth: number;
@@ -53,6 +54,7 @@ export interface GroupToolPort {
53
54
  uid?: string;
54
55
  file_path?: string;
55
56
  include_content?: boolean;
57
+ chain_depth?: number;
56
58
  }): Promise<unknown>;
57
59
  trace?(repo: GroupRepoHandle, params: {
58
60
  from?: string;
@@ -6,6 +6,7 @@ import fsp from 'node:fs/promises';
6
6
  import path from 'node:path';
7
7
  import { checkStaleness } from '../git-staleness.js';
8
8
  import { stalenessStatus } from '../staleness-status.js';
9
+ import { mapConcurrent } from '../../lib/utils.js';
9
10
  import { canonicalizePath, loadMeta, readRegistryStrict, registryPathEquals, } from '../../storage/repo-manager.js';
10
11
  import { crossRepoCompleteness } from './completeness.js';
11
12
  import { recordedMatchStages, recordedRepoList } from './completeness.js';
@@ -246,6 +247,39 @@ function rejectRetiredSyncParams(params) {
246
247
  }
247
248
  return null;
248
249
  }
250
+ /** Advertised MCP query page bounds (tools.ts schema). Mirrored so core stays MCP-free. */
251
+ const GROUP_QUERY_DEFAULT_LIMIT = 10;
252
+ const GROUP_QUERY_MAX_LIMIT = 100;
253
+ const GROUP_QUERY_DEFAULT_MAX_SYMBOLS = 25;
254
+ const GROUP_QUERY_MAX_SYMBOLS = 200;
255
+ const GROUP_CHAIN_MAX_DEPTH = 3;
256
+ /** Cap per-member fan-out for group query/context. Complements LocalBackend's per-query BFS cap. */
257
+ const GROUP_MEMBER_CONCURRENCY = 4;
258
+ function parseGroupQueryBound(value, field, fallback, max) {
259
+ if (value === undefined)
260
+ return { ok: true, value: fallback };
261
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > max) {
262
+ return {
263
+ ok: false,
264
+ error: `Invalid "${field}": expected an integer in [1, ${max}], got ${describeValue(value)}.`,
265
+ };
266
+ }
267
+ return { ok: true, value };
268
+ }
269
+ function parseGroupChainDepth(value) {
270
+ if (value === undefined)
271
+ return { ok: true, value: undefined };
272
+ if (typeof value !== 'number' ||
273
+ !Number.isInteger(value) ||
274
+ value < 0 ||
275
+ value > GROUP_CHAIN_MAX_DEPTH) {
276
+ return {
277
+ ok: false,
278
+ error: `Invalid "chain_depth": expected an integer in [0, ${GROUP_CHAIN_MAX_DEPTH}], got ${describeValue(value)}.`,
279
+ };
280
+ }
281
+ return { ok: true, value };
282
+ }
249
283
  export class GroupService {
250
284
  port;
251
285
  constructor(port) {
@@ -453,6 +487,11 @@ export class GroupService {
453
487
  if (!uid && !target) {
454
488
  return { group: name, error: 'target or uid is required', results: [] };
455
489
  }
490
+ const parsedChainDepth = parseGroupChainDepth(params.chain_depth);
491
+ if (parsedChainDepth.ok === false) {
492
+ return { group: name, error: parsedChainDepth.error, results: [] };
493
+ }
494
+ const chain_depth = parsedChainDepth.value;
456
495
  const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
457
496
  let config;
458
497
  try {
@@ -476,7 +515,7 @@ export class GroupService {
476
515
  };
477
516
  }
478
517
  const memberEntries = Object.entries(config.repos).filter(([repoPath]) => repoInSubgroup(repoPath, subgroup, subgroupExact));
479
- const results = await Promise.all(memberEntries.map(async ([repoPath, registryName]) => {
518
+ const results = (await mapConcurrent(memberEntries, async ([repoPath, registryName]) => {
480
519
  try {
481
520
  const repoObj = await this.port.resolveRepo(registryName);
482
521
  const payload = await this.port.context(repoObj, {
@@ -484,6 +523,7 @@ export class GroupService {
484
523
  uid,
485
524
  file_path,
486
525
  include_content,
526
+ chain_depth,
487
527
  });
488
528
  if (servicePrefix) {
489
529
  const st = payload?.status;
@@ -501,7 +541,12 @@ export class GroupService {
501
541
  payload: { error: e instanceof Error ? e.message : String(e) },
502
542
  };
503
543
  }
504
- }));
544
+ }, { concurrency: GROUP_MEMBER_CONCURRENCY })).map((result, index) => {
545
+ if (result)
546
+ return result;
547
+ const [repoPath, registryName] = memberEntries[index];
548
+ return { repoPath, registryName, payload: {} };
549
+ });
505
550
  return {
506
551
  group: name,
507
552
  target: target || uid,
@@ -520,7 +565,18 @@ export class GroupService {
520
565
  return { error: 'service must not be an empty string' };
521
566
  }
522
567
  const servicePrefix = normalizeServicePrefix(params.service);
523
- const limit = typeof params.limit === 'number' && params.limit > 0 ? params.limit : 5;
568
+ const parsedLimit = parseGroupQueryBound(params.limit, 'limit', GROUP_QUERY_DEFAULT_LIMIT, GROUP_QUERY_MAX_LIMIT);
569
+ if (parsedLimit.ok === false)
570
+ return { error: parsedLimit.error };
571
+ const parsedMaxSymbols = parseGroupQueryBound(params.max_symbols, 'max_symbols', GROUP_QUERY_DEFAULT_MAX_SYMBOLS, GROUP_QUERY_MAX_SYMBOLS);
572
+ if (parsedMaxSymbols.ok === false)
573
+ return { error: parsedMaxSymbols.error };
574
+ const parsedChainDepth = parseGroupChainDepth(params.chain_depth);
575
+ if (parsedChainDepth.ok === false)
576
+ return { error: parsedChainDepth.error };
577
+ const limit = parsedLimit.value;
578
+ const max_symbols = parsedMaxSymbols.value;
579
+ const chain_depth = parsedChainDepth.value;
524
580
  const subgroup = typeof params.subgroup === 'string' ? params.subgroup : undefined;
525
581
  const subgroupExact = params.subgroupExact === true;
526
582
  const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
@@ -534,14 +590,15 @@ export class GroupService {
534
590
  throw err;
535
591
  }
536
592
  const memberEntries = Object.entries(config.repos).filter(([repoPath]) => repoInSubgroup(repoPath, subgroup, subgroupExact));
537
- const perRepo = await Promise.all(memberEntries.map(async ([repoPath, registryName]) => {
593
+ const perRepo = (await mapConcurrent(memberEntries, async ([repoPath, registryName]) => {
538
594
  try {
539
595
  const repoObj = await this.port.resolveRepo(registryName);
540
596
  const queryResult = (await this.port.query(repoObj, {
541
597
  query: queryText,
542
598
  limit,
543
- max_symbols: 10,
599
+ max_symbols,
544
600
  include_content: false,
601
+ chain_depth,
545
602
  }));
546
603
  const processes = servicePrefix
547
604
  ? filterQueryByServicePrefix(queryResult, servicePrefix).processes
@@ -556,7 +613,12 @@ export class GroupService {
556
613
  catch {
557
614
  return { repo: repoPath, score: 0, processes: [] };
558
615
  }
559
- }));
616
+ }, { concurrency: GROUP_MEMBER_CONCURRENCY })).map((result, index) => {
617
+ if (result)
618
+ return result;
619
+ const [repoPath] = memberEntries[index];
620
+ return { repo: repoPath, score: 0, processes: [] };
621
+ });
560
622
  const allProcesses = perRepo.flatMap((r) => r.processes);
561
623
  allProcesses.sort((a, b) => b._rrf_score - a._rrf_score);
562
624
  const topN = allProcesses.slice(0, limit);
@@ -31,6 +31,8 @@ interface RouteHandlerResolutionContext {
31
31
  readonly files: readonly RouteResolutionFile[];
32
32
  readonly resolveImportTarget: (parsedImport: ParsedImport, fromFile: string) => string | null;
33
33
  readonly isExportedSymbol: (nodeId: string) => boolean;
34
+ /** 0-based graph-node startLine for same-name tRPC handler disambiguation. */
35
+ readonly nodeStartLine?: (nodeId: string) => number | undefined;
34
36
  }
35
37
  /** Record one exported graph node into the incremental ExportedTypeMap. */
36
38
  export declare const accumulateExportedTypesFromParsedNode: (result: ExportedTypeMap, node: {
@@ -20,8 +20,46 @@ import { normalizeFetchURL, routeMatches } from './route-extractors/nextjs.js';
20
20
  import { normalizeExtractedRoutePath, normalizeRouteMethod, routeNodeKey, } from './route-extractors/route-path.js';
21
21
  import { extractReturnTypeName } from './type-extractors/shared.js';
22
22
  import { DATA_ROUTE_TABLE_SOURCE } from './route-extractors/data-route-table.js';
23
+ import { toZeroBasedLine } from './utils/line-base.js';
23
24
  const MAX_EXPORTS_PER_FILE = 500;
24
25
  const MAX_TYPE_NAME_LENGTH = 256;
26
+ /** Same Function/Method gate as data-route resolution (`routeCallables`). */
27
+ function routeCallableDefs(defs) {
28
+ return defs.filter((def) => def.type === 'Function' || def.type === 'Method');
29
+ }
30
+ /**
31
+ * Pick a same-file route handler from `lookupExactAll` hits.
32
+ *
33
+ * Graph nodes store 0-based `startLine`; `ExtractedRoute.lineNumber` is 1-based
34
+ * (`i + 1` in the tRPC scanner). File-index lookup is not callable-only, so
35
+ * drop Property/Variable/Const (and other non-callables) first. When several
36
+ * callables share a name, prefer the unique def whose node startLine equals
37
+ * `toZeroBasedLine(route.lineNumber)`. If that exact match is missing (common
38
+ * when the arrow starts on the line after `.mutation(`), take the unique def
39
+ * whose startLine is the nearest `>=` target. Zero or 2+ winners (or no line
40
+ * reader) → `undefined` (fail-open).
41
+ */
42
+ function pickSameFileHandler(defs, route, getStartLine) {
43
+ defs = routeCallableDefs(defs);
44
+ if (defs.length === 1)
45
+ return defs[0];
46
+ if (defs.length > 1 && getStartLine !== undefined) {
47
+ const targetLine = toZeroBasedLine(route.lineNumber);
48
+ const withLines = defs
49
+ .map((def) => ({ def, startLine: Number(getStartLine(def.nodeId)) }))
50
+ .filter((entry) => Number.isFinite(entry.startLine));
51
+ const exact = withLines.filter((entry) => entry.startLine === targetLine);
52
+ if (exact.length === 1)
53
+ return exact[0].def;
54
+ const atOrAfter = withLines.filter((entry) => entry.startLine >= targetLine);
55
+ if (atOrAfter.length === 0)
56
+ return undefined;
57
+ const nearestLine = Math.min(...atOrAfter.map((entry) => entry.startLine));
58
+ const nearest = atOrAfter.filter((entry) => entry.startLine === nearestLine);
59
+ return nearest.length === 1 ? nearest[0].def : undefined;
60
+ }
61
+ return undefined;
62
+ }
25
63
  /** Record one exported graph node into the incremental ExportedTypeMap. */
26
64
  export const accumulateExportedTypesFromParsedNode = (result, node, symbolTable) => {
27
65
  if (!node.properties?.isExported)
@@ -165,8 +203,32 @@ export const processRoutesFromExtracted = async (graph, extractedRoutes, model,
165
203
  onProgress?.(i, extractedRoutes.length);
166
204
  await yieldToEventLoop();
167
205
  }
168
- if (!route.controllerName || !route.methodName)
206
+ if (!route.methodName)
207
+ continue;
208
+ // tRPC routes carry NO controller: a router is an object binding, not a
209
+ // class, so the extractor leaves controllerName unset and names the
210
+ // handler from the callback identifier when present, otherwise from its
211
+ // object-literal key. Bind the same-file symbol directly.
212
+ // No unique match → skip, fail-open. Laravel routes always set
213
+ // controllerName and Django routes leave methodName null, so this
214
+ // branch is tRPC-only by construction — the laravel guessed-method
215
+ // fallback below never sees a controller-less route.
216
+ if (!route.controllerName) {
217
+ const handler = pickSameFileHandler(model.symbols.lookupExactAll(route.filePath, route.methodName), route, (id) => graph.getNode(id)?.properties.startLine);
218
+ if (!handler)
219
+ continue;
220
+ const sourceId = generateId('File', route.filePath);
221
+ const relId = generateId('CALLS', sourceId + ':route->' + handler.nodeId);
222
+ graph.addRelationship({
223
+ id: relId,
224
+ sourceId,
225
+ targetId: handler.nodeId,
226
+ type: 'CALLS',
227
+ confidence: ROUTE_EDGE_CONFIDENCE,
228
+ reason: 'trpc-route',
229
+ });
169
230
  continue;
231
+ }
170
232
  // Resolve the controller class. Qualified-first: when the routes file
171
233
  // disambiguated the controller (a `use` import or inline `::class` FQN, both
172
234
  // normalized to the registry's dot-joined key shape by the extractor), look
@@ -249,8 +311,9 @@ export function resolveRouteHandlerSymbols(model, extractedRoutes, decoratorRout
249
311
  // Mirrors the routes phase `addRoute` first-writer-wins so the handler we
250
312
  // stamp always belongs to the route that actually won the Route node.
251
313
  const claimed = new Set();
252
- // Resolve a single same-file symbol by name, refusing to guess on ambiguity:
253
- // exactly one match → its nodeId; zero or many → undefined (fail-open).
314
+ // Resolve a single same-file symbol by name. Exactly one match → its nodeId.
315
+ // Zero or many matches → undefined (fail-open). tRPC same-name handlers
316
+ // use pickSameFileHandler at the controller-less call site instead.
254
317
  const uniqueSymbolId = (filePath, name) => {
255
318
  const defs = model.symbols.lookupExactAll(filePath, name);
256
319
  return defs.length === 1 ? defs[0]?.nodeId : undefined;
@@ -259,7 +322,7 @@ export function resolveRouteHandlerSymbols(model, extractedRoutes, decoratorRout
259
322
  const byId = new Map(defs.map((def) => [def.nodeId, def]));
260
323
  return byId.size === 1 ? byId.values().next().value : undefined;
261
324
  };
262
- const routeCallables = (defs) => defs.filter((def) => def.type === 'Function' || def.type === 'Method');
325
+ const routeCallables = (defs) => routeCallableDefs(defs);
263
326
  const exportedRouteCallables = (defs) => routeContext === undefined
264
327
  ? []
265
328
  : routeCallables(defs).filter((def) => routeContext.isExportedSymbol(def.nodeId));
@@ -352,6 +415,9 @@ export function resolveRouteHandlerSymbols(model, extractedRoutes, decoratorRout
352
415
  if (controllerDef)
353
416
  methodId = uniqueSymbolId(controllerDef.filePath, route.methodName);
354
417
  }
418
+ else if (!route.controllerName && route.methodName) {
419
+ methodId = pickSameFileHandler(model.symbols.lookupExactAll(route.filePath, route.methodName), route, routeContext?.nodeStartLine)?.nodeId;
420
+ }
355
421
  claim(route.routePath, route.prefix ?? null, route.httpMethod, methodId);
356
422
  }
357
423
  const dataHandlerByRoute = new Map();
@@ -95,8 +95,24 @@ export function calculateEntryPointScore(name, language, isExported, callerCount
95
95
  }
96
96
  // Name pattern scoring
97
97
  let nameMultiplier = 1.0;
98
+ // tRPC procedures often use accessor-style names
99
+ // (`getX`, `setX`, `isX`) that legitimately ARE entry points. Without this
100
+ // exemption, the 0.3× utility penalty cancels the 3.0× tRPC framework boost
101
+ // (net 0.9, below baseline), preventing procedures like `setSettings`
102
+ // from becoming Process entry points and hiding them from `query` results.
103
+ // Only accessor/predicate names skip the penalty — helpers such as
104
+ // `formatDate`, `_internal`, or `parseInput` in the same router file still
105
+ // match UTILITY_PATTERNS. The 3.0× tRPC framework boost stays path-based.
106
+ // The exemption follows detectFrameworkFromPath so T3 (`/api/routers/`) and
107
+ // `/app/trpc/routers/` layouts get the same treatment as `/trpc/routers/`.
108
+ // Optional chaining still guards paths that fail the tRPC predicates
109
+ // (e.g. generic `/routers/*.js` without `/trpc/` or `/api/routers/`).
110
+ // JS/JSX routers that match those predicates get `framework: 'trpc'`
111
+ // the same way TS/TSX routers do.
112
+ const frameworkHint = filePath ? detectFrameworkFromPath(filePath) : null;
113
+ const trpcAccessorExemption = frameworkHint?.framework === 'trpc' && /^(get|set|is|has|can|should|will|did)[A-Z]/.test(name);
98
114
  // Check negative patterns first (utilities get penalized)
99
- if (UTILITY_PATTERNS.some((p) => p.test(name))) {
115
+ if (!trpcAccessorExemption && UTILITY_PATTERNS.some((p) => p.test(name))) {
100
116
  nameMultiplier = 0.3; // Significant penalty
101
117
  reasons.push('utility-pattern');
102
118
  }
@@ -108,14 +124,10 @@ export function calculateEntryPointScore(name, language, isExported, callerCount
108
124
  reasons.push('entry-pattern');
109
125
  }
110
126
  }
111
- // Framework detection bonus (Phase 2)
112
127
  let frameworkMultiplier = 1.0;
113
- if (filePath) {
114
- const frameworkHint = detectFrameworkFromPath(filePath);
115
- if (frameworkHint) {
116
- frameworkMultiplier = frameworkHint.entryPointMultiplier;
117
- reasons.push(`framework:${frameworkHint.reason}`);
118
- }
128
+ if (frameworkHint) {
129
+ frameworkMultiplier = frameworkHint.entryPointMultiplier;
130
+ reasons.push(`framework:${frameworkHint.reason}`);
119
131
  }
120
132
  // Calculate final score
121
133
  const finalScore = baseScore * exportMultiplier * nameMultiplier * frameworkMultiplier;
@@ -55,6 +55,14 @@ export function detectFrameworkFromPath(filePath) {
55
55
  (p.endsWith('layout.tsx') || p.endsWith('layout.ts'))) {
56
56
  return { framework: 'nextjs-app', entryPointMultiplier: 2.0, reason: 'nextjs-layout' };
57
57
  }
58
+ // tRPC router files — before the expo-router /app/ catch-all so
59
+ // `src/app/trpc/routers/*.ts` is tRPC, not an Expo screen. `/routers/`
60
+ // alone also matches Vue Router / Next.js-style folders and must not
61
+ // take the 3.0 entry-point multiplier.
62
+ if ((p.endsWith('.ts') || p.endsWith('.tsx') || p.endsWith('.js') || p.endsWith('.jsx')) &&
63
+ ((p.includes('/trpc/') && p.includes('/routers/')) || p.includes('/api/routers/'))) {
64
+ return { framework: 'trpc', entryPointMultiplier: 3.0, reason: 'trpc-router' };
65
+ }
58
66
  // Expo Router - screen/layout/api files in app/ directory
59
67
  if (p.includes('/app/') &&
60
68
  (p.endsWith('.tsx') || p.endsWith('.ts') || p.endsWith('.jsx') || p.endsWith('.js'))) {
@@ -406,6 +406,17 @@ interface LanguageProviderConfig {
406
406
  * language's grammar, available for re-parsing included files.
407
407
  * Default: undefined (no route extraction). */
408
408
  readonly extractRoutes?: (tree: Parser.Tree, filePath: string, reader: (relativePath: string) => string | null, parser?: Parser | null) => ExtractedRoute[];
409
+ /**
410
+ * Extract routes from file text without an AST.
411
+ *
412
+ * Content-based (regex / line scan), not tree-sitter. tRPC uses this hook:
413
+ * procedure routers are recognized from source text (`publicProcedure.query`)
414
+ * rather than grammar captures. The parse worker calls this when the hook is
415
+ * defined. Providers that need a path-gate apply it inside the hook.
416
+ *
417
+ * Default: undefined (no text-route extraction).
418
+ */
419
+ readonly extractTextRoutes?: (filePath: string, content: string) => ExtractedRoute[];
409
420
  /**
410
421
  * Extract routes that a parsed file declares in its own AST.
411
422
  *
@@ -49,7 +49,7 @@ import { getTreeSitterBufferSize } from '../../constants.js';
49
49
  import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
50
50
  import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
51
51
  import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
52
- import { deriveDefaultExportHocName, isBlockedDefaultExportHoc, isDefaultExportHocFunctionNode, } from '../../ts-js-hoc-utils.js';
52
+ import { deriveDefaultExportHocName, isBlockedDefaultExportHoc, isBlockedPairCallbackRegistration, isDefaultExportHocFunctionNode, } from '../../ts-js-hoc-utils.js';
53
53
  /** JS function-like node types that may carry a synthesized `this` binding.
54
54
  * Kept in sync with the `@scope.function` patterns in `query.ts`. */
55
55
  export const FUNCTION_NODE_TYPES = [
@@ -1019,6 +1019,13 @@ export function emitJsScopeCaptures(sourceText, filePath, cachedTree) {
1019
1019
  if (arrowNode !== null && isBlockedDefaultExportHoc(arrowNode)) {
1020
1020
  continue;
1021
1021
  }
1022
+ // Pair-value built-in registrations (`{ timer: setTimeout(() => …) }`,
1023
+ // `{ later: promise.then(() => …) }`) bind handles/values, not
1024
+ // callables. See `isBlockedPairCallbackRegistration` for why this
1025
+ // gate lives emit-side rather than in the query predicates.
1026
+ if (arrowNode !== null && isBlockedPairCallbackRegistration(arrowNode)) {
1027
+ continue;
1028
+ }
1022
1029
  // #2723 — see the matching filter in `typescript/captures.ts`.
1023
1030
  if (arrowNode !== null && isShadowedCjsExportAssignment(arrowNode, tree.rootNode)) {
1024
1031
  continue;
@@ -234,6 +234,200 @@ export const JAVASCRIPT_SCOPE_QUERY = `
234
234
  key: (string (string_fragment) @declaration.name)
235
235
  value: (function_expression) @declaration.function)
236
236
 
237
+ ;; HOC-wrapped pair values: create: procedure.mutation(async ({ input }) => { ... }).
238
+ ;; tRPC, Express route definitions, and similar frameworks use this pattern where
239
+ ;; an object property's value is a call_expression (like .mutation()/.query()) that
240
+ ;; wraps an arrow_function or function_expression as a callback argument.
241
+ ;; Without these patterns, tRPC procedures are invisible — the arrow registers as
242
+ ;; anonymous and all calls inside fall back to file-level attribution.
243
+ ;;
244
+ ;; AST shape:
245
+ ;; pair
246
+ ;; key: property_identifier "create"
247
+ ;; value: call_expression
248
+ ;; function: identifier | member_expression
249
+ ;; arguments: arguments
250
+ ;; arrow_function | function_expression
251
+ ;;
252
+ ;; Anchor discipline: same as direct pairs — on the inner arrow/function, not
253
+ ;; the outer call_expression. The arrow's range matches its @scope.function range,
254
+ ;; so pass2AttachDeclarations auto-hoists the binding to the parent scope.
255
+ ((pair
256
+ key: (property_identifier) @declaration.name
257
+ value: (call_expression
258
+ function: (identifier) @hoc
259
+ arguments: (arguments
260
+ (arrow_function) @declaration.function)))
261
+ ${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
262
+
263
+ ((pair
264
+ key: (property_identifier) @declaration.name
265
+ value: (call_expression
266
+ function: (identifier) @hoc
267
+ arguments: (arguments
268
+ (function_expression) @declaration.function)))
269
+ ${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
270
+
271
+ ; Member-expression variants exclude callback-taking array methods —
272
+ ; '{ visible: items.filter(item => item.active) }' is a value holding an
273
+ ; array, not a Function — same exclusion as the HOC variable rules below.
274
+ ((pair
275
+ key: (property_identifier) @declaration.name
276
+ value: (call_expression
277
+ function: (member_expression
278
+ property: (property_identifier) @callee)
279
+ arguments: (arguments
280
+ (arrow_function) @declaration.function)))
281
+ ${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
282
+
283
+ ((pair
284
+ key: (property_identifier) @declaration.name
285
+ value: (call_expression
286
+ function: (member_expression
287
+ property: (property_identifier) @callee)
288
+ arguments: (arguments
289
+ (function_expression) @declaration.function)))
290
+ ${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
291
+
292
+ ;; String-key pair variants: { 'create': procedure.mutation(async () => ...) }.
293
+ ;; Quoted object keys are identical shapes to the identifier-key pairs above —
294
+ ;; a tRPC router that quotes its keys (lint-enforced or JSON-ish style) would
295
+ ;; otherwise leave its procedures anonymous and file-level attributed.
296
+ ;; Quoted-key identifier callees ({ 'handler': wrap(() => {}) }) match these
297
+ ;; string-key rules, not the identifier-key block above; member-expression
298
+ ;; callees keep the array-method exclusion.
299
+ ((pair
300
+ key: (string (string_fragment) @declaration.name)
301
+ value: (call_expression
302
+ function: (identifier) @hoc
303
+ arguments: (arguments
304
+ (arrow_function) @declaration.function)))
305
+ ${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
306
+
307
+ ((pair
308
+ key: (string (string_fragment) @declaration.name)
309
+ value: (call_expression
310
+ function: (identifier) @hoc
311
+ arguments: (arguments
312
+ (function_expression) @declaration.function)))
313
+ ${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
314
+
315
+ ((pair
316
+ key: (string (string_fragment) @declaration.name)
317
+ value: (call_expression
318
+ function: (member_expression
319
+ property: (property_identifier) @callee)
320
+ arguments: (arguments
321
+ (arrow_function) @declaration.function)))
322
+ ${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
323
+
324
+ ((pair
325
+ key: (string (string_fragment) @declaration.name)
326
+ value: (call_expression
327
+ function: (member_expression
328
+ property: (property_identifier) @callee)
329
+ arguments: (arguments
330
+ (function_expression) @declaration.function)))
331
+ ${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
332
+
333
+ ;; Curried pair HOC: create: publicProcedure.mutation(withAuth(async () => {})).
334
+ ;; Existing pair-HOC rules require the arrow to be a DIRECT argument of the
335
+ ;; pair's call_expression. They miss mutation's argument being another
336
+ ;; call_expression (withAuth(...)). Object-pair only — do NOT add a
337
+ ;; variable-level nested-HOC rule (\`const X = memo(forwardRef(...))\` must
338
+ ;; stay a Variable; see typescript-hoc-wrapped.test.ts).
339
+ ((pair
340
+ key: (property_identifier) @declaration.name
341
+ value: (call_expression
342
+ function: (identifier) @hoc
343
+ arguments: (arguments
344
+ (call_expression
345
+ function: (identifier)
346
+ arguments: (arguments
347
+ (arrow_function) @declaration.function)))))
348
+ ${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
349
+
350
+ ((pair
351
+ key: (property_identifier) @declaration.name
352
+ value: (call_expression
353
+ function: (identifier) @hoc
354
+ arguments: (arguments
355
+ (call_expression
356
+ function: (identifier)
357
+ arguments: (arguments
358
+ (function_expression) @declaration.function)))))
359
+ ${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
360
+
361
+ ((pair
362
+ key: (property_identifier) @declaration.name
363
+ value: (call_expression
364
+ function: (member_expression
365
+ property: (property_identifier) @callee)
366
+ arguments: (arguments
367
+ (call_expression
368
+ function: (identifier)
369
+ arguments: (arguments
370
+ (arrow_function) @declaration.function)))))
371
+ ${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
372
+
373
+ ((pair
374
+ key: (property_identifier) @declaration.name
375
+ value: (call_expression
376
+ function: (member_expression
377
+ property: (property_identifier) @callee)
378
+ arguments: (arguments
379
+ (call_expression
380
+ function: (identifier)
381
+ arguments: (arguments
382
+ (function_expression) @declaration.function)))))
383
+ ${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
384
+
385
+ ((pair
386
+ key: (string (string_fragment) @declaration.name)
387
+ value: (call_expression
388
+ function: (identifier) @hoc
389
+ arguments: (arguments
390
+ (call_expression
391
+ function: (identifier)
392
+ arguments: (arguments
393
+ (arrow_function) @declaration.function)))))
394
+ ${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
395
+
396
+ ((pair
397
+ key: (string (string_fragment) @declaration.name)
398
+ value: (call_expression
399
+ function: (identifier) @hoc
400
+ arguments: (arguments
401
+ (call_expression
402
+ function: (identifier)
403
+ arguments: (arguments
404
+ (function_expression) @declaration.function)))))
405
+ ${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
406
+
407
+ ((pair
408
+ key: (string (string_fragment) @declaration.name)
409
+ value: (call_expression
410
+ function: (member_expression
411
+ property: (property_identifier) @callee)
412
+ arguments: (arguments
413
+ (call_expression
414
+ function: (identifier)
415
+ arguments: (arguments
416
+ (arrow_function) @declaration.function)))))
417
+ ${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
418
+
419
+ ((pair
420
+ key: (string (string_fragment) @declaration.name)
421
+ value: (call_expression
422
+ function: (member_expression
423
+ property: (property_identifier) @callee)
424
+ arguments: (arguments
425
+ (call_expression
426
+ function: (identifier)
427
+ arguments: (arguments
428
+ (function_expression) @declaration.function)))))
429
+ ${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
430
+
237
431
  ;; HOC-wrapped variable declarations: const X = HOC((args) => { ... }).
238
432
  ;; Covers React.forwardRef, memo, useCallback, useMemo, observer,
239
433
  ;; debounce, and any user-defined HOC factory.
@@ -39,7 +39,7 @@ import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
39
39
  import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
40
40
  import { synthesizeCjsModuleExports } from './cjs-module-exports.js';
41
41
  import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
42
- import { deriveDefaultExportHocName, isBlockedDefaultExportHoc, isDefaultExportHocFunctionNode, } from '../../ts-js-hoc-utils.js';
42
+ import { deriveDefaultExportHocName, isBlockedDefaultExportHoc, isBlockedPairCallbackRegistration, isDefaultExportHocFunctionNode, } from '../../ts-js-hoc-utils.js';
43
43
  /** tree-sitter-typescript node types for function-like scopes that may
44
44
  * carry a synthesized `this` binding. Kept in sync with the
45
45
  * `@scope.function` patterns in `query.ts`. */
@@ -477,6 +477,13 @@ export function emitTsScopeCaptures(sourceText, filePath, cachedTree) {
477
477
  if (arrowNode !== null && isBlockedDefaultExportHoc(arrowNode)) {
478
478
  continue;
479
479
  }
480
+ // Pair-value built-in registrations (`{ timer: setTimeout(() => …) }`,
481
+ // `{ later: promise.then(() => …) }`) bind handles/values, not
482
+ // callables. See `isBlockedPairCallbackRegistration` for why this
483
+ // gate lives emit-side rather than in the query predicates.
484
+ if (arrowNode !== null && isBlockedPairCallbackRegistration(arrowNode)) {
485
+ continue;
486
+ }
480
487
  // #2723: a CJS export assignment must not register a SECOND module-scope
481
488
  // declaration for a name the file already declares lexically — the name
482
489
  // would become ambiguous and the resolver would drop the intra-module