ucn 4.2.3 → 5.0.2

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.
Files changed (72) hide show
  1. package/.claude/skills/ucn/SKILL.md +89 -77
  2. package/.claude/skills/ucn/references/commands.md +62 -68
  3. package/.claude/skills/ucn/references/trust-contract.md +31 -6
  4. package/README.md +438 -305
  5. package/assets/demo.svg +31 -0
  6. package/cli/index.js +430 -1385
  7. package/core/account.js +144 -34
  8. package/core/analysis.js +182 -72
  9. package/core/ast-analysis.js +279 -0
  10. package/core/bridge.js +205 -24
  11. package/core/brief.js +27 -58
  12. package/core/build-worker.js +21 -140
  13. package/core/cache.js +513 -11
  14. package/core/callers.js +4920 -456
  15. package/core/check.js +13 -4
  16. package/core/command-contracts.js +402 -0
  17. package/core/compilation-database.js +276 -0
  18. package/core/confidence.js +4 -1
  19. package/core/deadcode.js +397 -19
  20. package/core/discovery.js +359 -46
  21. package/core/entrypoints.js +195 -41
  22. package/core/execute.js +887 -81
  23. package/core/graph-build.js +162 -7
  24. package/core/graph.js +53 -77
  25. package/core/imports.js +65 -6
  26. package/core/index-ir.js +138 -0
  27. package/core/ir.js +195 -0
  28. package/core/output/analysis.js +212 -22
  29. package/core/output/brief.js +23 -0
  30. package/core/output/check.js +4 -0
  31. package/core/output/doctor.js +37 -6
  32. package/core/output/endpoints.js +5 -2
  33. package/core/output/extraction.js +24 -12
  34. package/core/output/find.js +141 -36
  35. package/core/output/graph.js +11 -5
  36. package/core/output/public.js +462 -0
  37. package/core/output/refactoring.js +42 -10
  38. package/core/output/reporting.js +97 -20
  39. package/core/output/search.js +24 -16
  40. package/core/output/shared.js +22 -1
  41. package/core/output/tracing.js +30 -15
  42. package/core/output-budget.js +295 -0
  43. package/core/output.js +1 -0
  44. package/core/parallel-build.js +44 -11
  45. package/core/parser.js +3 -3
  46. package/core/project.js +384 -187
  47. package/core/public-command.js +47 -0
  48. package/core/registry.js +247 -117
  49. package/core/reporting.js +312 -290
  50. package/core/search.js +317 -185
  51. package/core/semantic-provider.js +110 -0
  52. package/core/stacktrace.js +25 -0
  53. package/core/tracing.js +101 -51
  54. package/core/trust-matrix.js +19 -40
  55. package/core/verify.js +534 -37
  56. package/languages/adapter.js +218 -0
  57. package/languages/c-family.js +2791 -0
  58. package/languages/c.js +3 -0
  59. package/languages/cpp.js +3 -0
  60. package/languages/csharp.js +1402 -0
  61. package/languages/go.js +60 -21
  62. package/languages/html.js +2 -2
  63. package/languages/index.js +85 -7
  64. package/languages/java.js +396 -13
  65. package/languages/javascript.js +199 -19
  66. package/languages/python.js +964 -22
  67. package/languages/rust.js +1317 -152
  68. package/languages/utils.js +40 -3
  69. package/mcp/server.js +254 -636
  70. package/package.json +39 -22
  71. package/eslint.config.js +0 -43
  72. package/jsconfig.json +0 -10
@@ -0,0 +1,279 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Small AST-derived analyses shared by the public trust surface.
5
+ *
6
+ * Keep these queries syntax-only. They deliberately do not attempt compiler
7
+ * binding or runtime prediction; their job is to replace source-text guesses
8
+ * with stable tree-sitter facts.
9
+ */
10
+
11
+ const { getParser, safeParse } = require('../languages');
12
+
13
+ const CALLABLE_NODES = new Set([
14
+ // JavaScript / TypeScript
15
+ 'function_declaration', 'generator_function_declaration',
16
+ 'function_expression', 'generator_function', 'arrow_function',
17
+ 'method_definition',
18
+ // Python
19
+ 'function_definition', 'lambda',
20
+ // Go
21
+ 'method_declaration', 'func_literal',
22
+ // Rust
23
+ 'function_item', 'closure_expression',
24
+ // Java / C / C++ / C#
25
+ 'method_declaration', 'constructor_declaration', 'lambda_expression',
26
+ 'function_definition',
27
+ 'local_function_statement', 'anonymous_method_expression',
28
+ 'operator_declaration', 'conversion_operator_declaration',
29
+ ]);
30
+
31
+ const BRANCH_NODES = new Set([
32
+ 'if_statement', 'if_expression', 'elif_clause',
33
+ 'for_statement', 'for_in_statement', 'for_expression',
34
+ 'foreach_statement', 'for_range_loop',
35
+ 'while_statement', 'while_expression', 'do_statement',
36
+ 'catch_clause', 'except_clause',
37
+ 'conditional_expression', 'ternary_expression',
38
+ 'switch_case', 'case_clause', 'case_statement',
39
+ 'switch_label', 'switch_section', 'expression_case',
40
+ 'communication_case', 'match_arm',
41
+ ]);
42
+
43
+ const NESTING_NODES = new Set([
44
+ 'if_statement', 'if_expression', 'elif_clause',
45
+ 'for_statement', 'for_in_statement', 'for_expression',
46
+ 'foreach_statement', 'for_range_loop',
47
+ 'while_statement', 'while_expression', 'do_statement',
48
+ 'try_statement', 'catch_clause', 'except_clause',
49
+ 'switch_statement', 'switch_expression', 'expression_switch_statement',
50
+ 'type_switch_statement', 'select_statement', 'match_expression',
51
+ ]);
52
+
53
+ const LITERAL_INDEX_NODES = new Set([
54
+ 'string', 'string_literal', 'raw_string_literal', 'interpreted_string_literal',
55
+ 'character', 'char_literal',
56
+ 'number', 'integer', 'integer_literal', 'int_literal',
57
+ 'number_literal', 'decimal_integer_literal', 'float', 'float_literal',
58
+ 'true', 'false', 'null', 'none',
59
+ ]);
60
+
61
+ function walkNamed(node, visit) {
62
+ if (!node) return;
63
+ if (visit(node) === false) return;
64
+ for (const child of node.namedChildren || []) walkNamed(child, visit);
65
+ }
66
+
67
+ function isDefaultBranch(node) {
68
+ const text = String(node.text || '').trimStart();
69
+ return text.startsWith('default') || text.startsWith('case _');
70
+ }
71
+
72
+ function findCallableForRange(root, startLine, endLine) {
73
+ const candidates = [];
74
+ walkNamed(root, node => {
75
+ if (!CALLABLE_NODES.has(node.type)) return true;
76
+ const start = node.startPosition.row + 1;
77
+ const end = node.endPosition.row + 1;
78
+ if (start < startLine || end > endLine) return true;
79
+ candidates.push({
80
+ node,
81
+ exactEnd: end === endLine ? 1 : 0,
82
+ exactStart: start === startLine ? 1 : 0,
83
+ span: end - start,
84
+ startDistance: Math.abs(start - startLine),
85
+ });
86
+ return true;
87
+ });
88
+ candidates.sort((a, b) =>
89
+ b.exactEnd - a.exactEnd ||
90
+ b.exactStart - a.exactStart ||
91
+ b.span - a.span ||
92
+ a.startDistance - b.startDistance);
93
+ return candidates[0]?.node || null;
94
+ }
95
+
96
+ /**
97
+ * Return AST structural branch count and control-flow nesting depth for one
98
+ * indexed callable. Formatting, comments, strings, optional chaining, and
99
+ * nullish coalescing cannot affect these values.
100
+ */
101
+ function computeAstComplexity(content, language, {
102
+ startLine = 1,
103
+ endLine = startLine,
104
+ } = {}) {
105
+ const lineCount = Math.max(0, endLine - startLine + 1);
106
+ try {
107
+ const parser = getParser(language);
108
+ if (!parser) {
109
+ return {
110
+ branches: null,
111
+ maxDepth: null,
112
+ lineCount,
113
+ measuredBy: 'unavailable-no-parser',
114
+ };
115
+ }
116
+ const tree = safeParse(parser, content);
117
+ const callable = findCallableForRange(tree.rootNode, startLine, endLine);
118
+ if (!callable) {
119
+ return {
120
+ branches: null,
121
+ maxDepth: null,
122
+ lineCount,
123
+ measuredBy: 'unavailable-no-callable-node',
124
+ };
125
+ }
126
+
127
+ let branches = 0;
128
+ let maxDepth = 0;
129
+ const visit = (node, depth) => {
130
+ if (node !== callable && CALLABLE_NODES.has(node.type)) return;
131
+
132
+ if (BRANCH_NODES.has(node.type) && !isDefaultBranch(node)) branches++;
133
+ const nextDepth = depth + (NESTING_NODES.has(node.type) ? 1 : 0);
134
+ if (nextDepth > maxDepth) maxDepth = nextDepth;
135
+ for (const child of node.namedChildren || []) visit(child, nextDepth);
136
+ };
137
+ visit(callable, 0);
138
+ return {
139
+ branches,
140
+ maxDepth,
141
+ lineCount,
142
+ measuredBy: 'tree-sitter-ast',
143
+ };
144
+ } catch (error) {
145
+ return {
146
+ branches: null,
147
+ maxDepth: null,
148
+ lineCount,
149
+ measuredBy: 'unavailable-parse-error',
150
+ };
151
+ }
152
+ }
153
+
154
+ function indexNodeForComputedCallee(callee) {
155
+ if (!callee) return null;
156
+ switch (callee.type) {
157
+ case 'subscript_expression':
158
+ return callee.childForFieldName('index') || callee.namedChild(1);
159
+ case 'index_expression':
160
+ return callee.childForFieldName('index') || callee.namedChild(1);
161
+ case 'subscript':
162
+ return callee.childForFieldName('subscript') || callee.namedChild(1);
163
+ case 'element_access_expression': {
164
+ const list = callee.childForFieldName('subscript') ||
165
+ (callee.namedChildren || []).find(child =>
166
+ child.type === 'bracketed_argument_list');
167
+ return list?.namedChild(0)?.namedChild(0) || list?.namedChild(0) || null;
168
+ }
169
+ default:
170
+ return null;
171
+ }
172
+ }
173
+
174
+ function computedReceiver(callee) {
175
+ if (!callee) return null;
176
+ const node = callee.childForFieldName('object') ||
177
+ callee.childForFieldName('operand') ||
178
+ callee.childForFieldName('value') ||
179
+ callee.childForFieldName('expression') ||
180
+ callee.namedChild(0);
181
+ return node?.type === 'identifier' ? node.text : null;
182
+ }
183
+
184
+ /**
185
+ * Find direct computed dispatch calls such as handlers[name](). Literal keys
186
+ * are excluded because they retain a statically visible member name.
187
+ */
188
+ function computedDispatchSites(content, language) {
189
+ try {
190
+ const parser = getParser(language);
191
+ if (!parser) return [];
192
+ const tree = safeParse(parser, content);
193
+ const sites = [];
194
+ const seen = new Set();
195
+ const selectedByLocal = new Map();
196
+ const calledLocals = new Set();
197
+ const scopeKey = node => {
198
+ let current = node?.parent;
199
+ while (current && !CALLABLE_NODES.has(current.type)) current = current.parent;
200
+ return current ? `${current.startIndex}:${current.endIndex}` : 'module';
201
+ };
202
+ const record = (node, callee, expression) => {
203
+ const indexNode = indexNodeForComputedCallee(callee);
204
+ if (!indexNode || LITERAL_INDEX_NODES.has(indexNode.type)) return;
205
+ const receiver = computedReceiver(callee);
206
+ if (!receiver) return;
207
+ const key = `${callee.startIndex}:${callee.endIndex}`;
208
+ if (seen.has(key)) return;
209
+ seen.add(key);
210
+ sites.push({
211
+ line: node.startPosition.row + 1,
212
+ receiver,
213
+ expression: expression || node.text,
214
+ });
215
+ };
216
+ walkNamed(tree.rootNode, node => {
217
+ let callee = null;
218
+ if (node.type === 'call_expression' || node.type === 'call' ||
219
+ node.type === 'invocation_expression') {
220
+ callee = node.childForFieldName('function');
221
+ }
222
+ record(node, callee);
223
+
224
+ if (callee?.type === 'identifier') {
225
+ calledLocals.add(`${scopeKey(node)}\0${callee.text}`);
226
+ }
227
+
228
+ // Two-step dispatch: `const h = handlers[key]; h()`. Record the
229
+ // dynamic access only when its bound local is actually invoked in
230
+ // the same callable scope. Ordinary indexing (`xs[i]`) is a value
231
+ // read and says nothing about runtime-selected call targets.
232
+ if (node.type === 'variable_declarator' ||
233
+ node.type === 'assignment_expression' ||
234
+ node.type === 'assignment') {
235
+ const left = node.childForFieldName('name') ||
236
+ node.childForFieldName('left');
237
+ const right = node.childForFieldName('value') ||
238
+ node.childForFieldName('right');
239
+ if (left?.type === 'identifier' && indexNodeForComputedCallee(right)) {
240
+ selectedByLocal.set(`${scopeKey(node)}\0${left.text}`, { node, right });
241
+ }
242
+ }
243
+ return true;
244
+ });
245
+ for (const [key, selection] of selectedByLocal) {
246
+ if (calledLocals.has(key)) record(selection.node, selection.right);
247
+ }
248
+ return sites;
249
+ } catch (error) {
250
+ return [];
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Project-level cached computed-dispatch inventory. ProjectIndex invalidates
256
+ * this memo on rebuilds and file removal so long-lived MCP sessions see edits.
257
+ */
258
+ function projectComputedDispatch(index) {
259
+ if (index._computedDispatchBlindspots) return index._computedDispatchBlindspots;
260
+ const byFile = new Map();
261
+ for (const [filePath, fileEntry] of index.files) {
262
+ try {
263
+ const sites = computedDispatchSites(index._readFile(filePath), fileEntry.language);
264
+ if (sites.length > 0) byFile.set(filePath, sites);
265
+ } catch (_) {
266
+ // Unreadable files are reported through the existing parse/read
267
+ // diagnostics; do not turn this optional scan into a query crash.
268
+ }
269
+ }
270
+ index._computedDispatchBlindspots = byFile;
271
+ index.computedDispatchDirty = true;
272
+ return byFile;
273
+ }
274
+
275
+ module.exports = {
276
+ computeAstComplexity,
277
+ computedDispatchSites,
278
+ projectComputedDispatch,
279
+ };
package/core/bridge.js CHANGED
@@ -133,6 +133,11 @@ const SERVER_RECEIVER_PATTERNS = {
133
133
  // route mount with method ALL. (Prefix concat with inner router routes
134
134
  // is deferred — too complex to track inner Router argument.)
135
135
  ],
136
+ csharp: [
137
+ { receiverPattern: /^(app|endpoints|routes)$/i,
138
+ methodPattern: /^Map(Get|Post|Put|Delete|Patch|Methods|Fallback)$/,
139
+ framework: 'aspnet-minimal' },
140
+ ],
136
141
  };
137
142
 
138
143
  // Client: receiver+method patterns and bare-call patterns.
@@ -195,6 +200,15 @@ const CLIENT_PATTERNS = {
195
200
  // reqwest::get("/path") is a path-call captured separately
196
201
  callableReceivers: new Set(),
197
202
  },
203
+ csharp: {
204
+ bareCalls: new Set(),
205
+ receivers: [
206
+ { receiverPattern: /^(client|http|httpClient)$/i,
207
+ methodPattern: /^(GetAsync|PostAsync|PutAsync|DeleteAsync|PatchAsync|SendAsync)$/,
208
+ framework: 'dotnet-httpclient' },
209
+ ],
210
+ callableReceivers: new Set(),
211
+ },
198
212
  };
199
213
 
200
214
  // HTTP-method decorator/annotation/attribute patterns.
@@ -247,6 +261,7 @@ const PREFIX_ANNOTATIONS = new Set([
247
261
  'RequestMapping', // Spring class-level @RequestMapping("/api")
248
262
  'Path', // JAX-RS class-level @Path("/api")
249
263
  ]);
264
+ const CSHARP_PREFIX_ATTRIBUTES = new Set(['Route']);
250
265
 
251
266
  // ============================================================================
252
267
  // EXTRACT SERVER ROUTES
@@ -265,6 +280,21 @@ function extractServerRoutes(index) {
265
280
  }
266
281
 
267
282
  const routes = [];
283
+ const mountedPrefixes = collectProjectRouterMounts(index);
284
+ const pythonReceiverFrameworks = new Map();
285
+ for (const [filePath, entry] of index.files) {
286
+ if (entry.language !== 'python') continue;
287
+ const receiverMap = new Map();
288
+ for (const call of getCachedCalls(index, filePath) || []) {
289
+ if (!call.assignedTo) continue;
290
+ if (call.name === 'Flask' || call.name === 'Blueprint') {
291
+ receiverMap.set(call.assignedTo, 'flask');
292
+ } else if (call.name === 'FastAPI' || call.name === 'APIRouter') {
293
+ receiverMap.set(call.assignedTo, 'fastapi');
294
+ }
295
+ }
296
+ pythonReceiverFrameworks.set(filePath, receiverMap);
297
+ }
268
298
 
269
299
  // 1) Decorator/annotation/attribute-based routes (NestJS, Flask/FastAPI, Spring, JAX-RS, Actix).
270
300
  // Iterate symbols once and look at their decoratorsWithArgs/annotationsWithArgs/attributesWithArgs.
@@ -299,7 +329,8 @@ function extractServerRoutes(index) {
299
329
  classPrefix = classPrefixByFileClass.get(`${sym.file}:${sym.className}`) || '';
300
330
  }
301
331
 
302
- const declRoutes = collectMethodRoutes(sym, lang, classPrefix);
332
+ const declRoutes = collectMethodRoutes(sym, lang, classPrefix, fileEntry,
333
+ pythonReceiverFrameworks.get(sym.file));
303
334
  for (const r of declRoutes) {
304
335
  routes.push({
305
336
  method: r.method,
@@ -323,9 +354,19 @@ function extractServerRoutes(index) {
323
354
  const lang = fileEntry.language;
324
355
  const calls = getCachedCalls(index, filePath);
325
356
  if (!calls || calls.length === 0) continue;
357
+ const routerReceivers = collectRouterReceivers(calls, lang);
326
358
 
359
+ const groupPrefixes = new Map();
327
360
  for (const call of calls) {
328
- const r = matchCallPatternRoute(call, lang);
361
+ if (!/^(Group|group|nest)$/.test(call.name) ||
362
+ !call.assignedTo || !call.firstStringArg ||
363
+ !routerReceivers.has(call.assignedTo)) continue;
364
+ const parent = groupPrefixes.get(call.receiver) || '';
365
+ groupPrefixes.set(call.assignedTo, joinRoutePath(parent, call.firstStringArg));
366
+ }
367
+
368
+ for (const call of calls) {
369
+ const r = matchCallPatternRoute(call, lang, routerReceivers);
329
370
  if (!r) continue;
330
371
 
331
372
  // Resolve handler name from arg position 1 if call.firstStringArg is set.
@@ -333,17 +374,24 @@ function extractServerRoutes(index) {
333
374
  // call on the same line — we look for it.
334
375
  const handlerName = findHandlerCallback(calls, call.line, call) || '<anonymous>';
335
376
 
336
- routes.push({
337
- method: r.method,
338
- path: r.path,
339
- normalizedPath: normalizePath(r.path),
340
- handler: handlerName,
341
- file: fileEntry.relativePath || filePath,
342
- absoluteFile: filePath,
343
- line: call.line,
344
- framework: r.framework,
345
- raw: `${r.method} ${r.path}`,
346
- });
377
+ const receiverKey = `${filePath}:${call.receiver || ''}`;
378
+ const prefixes = mountedPrefixes.get(receiverKey) ||
379
+ (groupPrefixes.has(call.receiver) ? [groupPrefixes.get(call.receiver)] : ['']);
380
+ for (const prefix of prefixes) {
381
+ const fullPath = prefix ? joinRoutePath(prefix, r.path) : r.path;
382
+ routes.push({
383
+ method: r.method,
384
+ path: fullPath,
385
+ normalizedPath: normalizePath(fullPath),
386
+ handler: handlerName,
387
+ file: fileEntry.relativePath || filePath,
388
+ absoluteFile: filePath,
389
+ line: call.line,
390
+ framework: r.framework,
391
+ ...(prefix && { mountPrefix: prefix }),
392
+ raw: `${r.method} ${fullPath}`,
393
+ });
394
+ }
347
395
  }
348
396
  }
349
397
 
@@ -385,13 +433,21 @@ function collectClassPrefixes(sym, lang) {
385
433
  }
386
434
  }
387
435
  }
436
+ if (lang === 'csharp' && sym.attributesWithArgs) {
437
+ for (const attribute of sym.attributesWithArgs) {
438
+ if (CSHARP_PREFIX_ATTRIBUTES.has(attribute.name) && attribute.arg != null) {
439
+ prefixes.push(attribute.arg);
440
+ }
441
+ }
442
+ }
388
443
  return prefixes;
389
444
  }
390
445
 
391
446
  /**
392
447
  * Return zero or more route objects {method, path, framework, raw} for a method/function symbol.
393
448
  */
394
- function collectMethodRoutes(sym, lang, classPrefix) {
449
+ function collectMethodRoutes(sym, lang, classPrefix, fileEntry = null,
450
+ pythonReceiverFramework = null) {
395
451
  const out = [];
396
452
 
397
453
  // ── JS/TS decorators (NestJS) ────────────────────────────────────
@@ -414,7 +470,7 @@ function collectMethodRoutes(sym, lang, classPrefix) {
414
470
  if (lang === 'python' && sym.decorators) {
415
471
  for (const decRaw of sym.decorators) {
416
472
  // Decorator text in Python is the full source: "app.route('/users', methods=['GET'])"
417
- const r = parsePythonDecoratorFull(decRaw);
473
+ const r = parsePythonDecoratorFull(decRaw, fileEntry, pythonReceiverFramework);
418
474
  if (r) {
419
475
  out.push({
420
476
  method: r.method,
@@ -492,6 +548,21 @@ function collectMethodRoutes(sym, lang, classPrefix) {
492
548
  }
493
549
  }
494
550
 
551
+ // ── C# attributes (ASP.NET Core) ─────────────────────────────────
552
+ if (lang === 'csharp' && sym.attributesWithArgs) {
553
+ for (const attribute of sym.attributesWithArgs) {
554
+ const match = attribute.name.match(
555
+ /^Http(Get|Post|Put|Delete|Patch|Head|Options)$/);
556
+ if (!match) continue;
557
+ const sub = attribute.arg || '';
558
+ out.push({
559
+ method: match[1].toUpperCase(),
560
+ path: joinRoutePath(classPrefix, sub) || '/',
561
+ framework: 'aspnet',
562
+ });
563
+ }
564
+ }
565
+
495
566
  return out;
496
567
  }
497
568
 
@@ -502,7 +573,7 @@ function collectMethodRoutes(sym, lang, classPrefix) {
502
573
  * "router.post('/items')"
503
574
  * Returns { method, path, framework } or null.
504
575
  */
505
- function parsePythonDecoratorFull(raw) {
576
+ function parsePythonDecoratorFull(raw, fileEntry = null, receiverFramework = null) {
506
577
  if (typeof raw !== 'string') return null;
507
578
  // Match receiver.verb('path', ...)
508
579
  const m = raw.match(/^([A-Za-z_][A-Za-z0-9_]*)\.([a-z]+)\s*\(\s*(['"])([^'"]*)\3/);
@@ -522,11 +593,53 @@ function parsePythonDecoratorFull(raw) {
522
593
  return { method: 'GET', path: pathStr, framework: 'flask' };
523
594
  }
524
595
  if (['get','post','put','delete','patch','options','head'].includes(verb)) {
525
- return { method: verb.toUpperCase(), path: pathStr, framework: 'fastapi' };
596
+ const modules = (fileEntry?.imports || []).map(value =>
597
+ typeof value === 'string' ? value : String(value?.module || ''));
598
+ const framework = receiverFramework?.get(m[1]) ||
599
+ (modules.some(module => /^flask\b/.test(module)) &&
600
+ !modules.some(module => /^fastapi\b/.test(module))
601
+ ? 'flask' : modules.some(module => /^fastapi\b/.test(module)) &&
602
+ !modules.some(module => /^flask\b/.test(module))
603
+ ? 'fastapi' : 'unknown-python');
604
+ return { method: verb.toUpperCase(), path: pathStr, framework };
526
605
  }
527
606
  return null;
528
607
  }
529
608
 
609
+ /** Map exported router receiver variables to their literal project mounts. */
610
+ function collectProjectRouterMounts(index) {
611
+ const mounts = new Map();
612
+ for (const [filePath, fileEntry] of index.files) {
613
+ if (!['javascript', 'typescript', 'tsx'].includes(fileEntry.language)) continue;
614
+ const calls = getCachedCalls(index, filePath) || [];
615
+ for (const call of calls) {
616
+ if (call.name !== 'use' || !call.receiver || !call.firstStringArg ||
617
+ (call.argCount != null && call.argCount < 2)) continue;
618
+ const refs = calls.filter(candidate => candidate.line === call.line &&
619
+ candidate !== call && (candidate.isFunctionReference || candidate.isPotentialCallback));
620
+ for (const ref of refs) {
621
+ const binding = (fileEntry.importBindings || []).find(item => item.name === ref.name);
622
+ const rel = binding && fileEntry.moduleResolved?.[binding.module];
623
+ if (!rel) continue;
624
+ const targetFile = path.join(index.root, rel);
625
+ const targetEntry = index.files.get(targetFile);
626
+ if (!targetEntry) continue;
627
+ const exportedReceivers = (targetEntry.exportDetails || [])
628
+ .filter(exp => exp.type === 'module.exports' || exp.isDefault ||
629
+ exp.kind === 'default' || exp.type === 'export-default')
630
+ .map(exp => exp.localName || exp.name).filter(Boolean);
631
+ for (const receiver of exportedReceivers) {
632
+ const key = `${targetFile}:${receiver}`;
633
+ const list = mounts.get(key) || [];
634
+ if (!list.includes(call.firstStringArg)) list.push(call.firstStringArg);
635
+ mounts.set(key, list);
636
+ }
637
+ }
638
+ }
639
+ }
640
+ return mounts;
641
+ }
642
+
530
643
  /**
531
644
  * Spring @RequestMapping(method = RequestMethod.GET) — extract method.
532
645
  * Returns 'GET' / 'POST' / etc. or null.
@@ -537,10 +650,51 @@ function parseSpringRequestMappingMethod(argsRaw) {
537
650
  return m ? m[1] : null;
538
651
  }
539
652
 
653
+ /** Infer router values from AST call/assignment evidence, independent of the
654
+ * variable's spelling. Legacy receiver-name patterns remain seeds for code
655
+ * where the factory assignment is outside the indexed file. */
656
+ function collectRouterReceivers(calls, lang) {
657
+ const receivers = new Set();
658
+ const patterns = SERVER_RECEIVER_PATTERNS[lang] || [];
659
+ for (const call of calls) {
660
+ if (call.receiver && patterns.some(pattern =>
661
+ pattern.receiverPattern.test(call.receiver))) {
662
+ receivers.add(call.receiver);
663
+ }
664
+ if (!call.assignedTo) continue;
665
+ const receiver = String(call.receiver || '');
666
+ const factory = (
667
+ (['javascript', 'typescript'].includes(lang) &&
668
+ /^(Router|express|fastify|Koa)$/.test(call.name)) ||
669
+ (lang === 'go' && (
670
+ /^(NewRouter|NewServeMux|Default)$/.test(call.name) ||
671
+ (call.name === 'New' && /^(gin|echo|chi|fiber|mux|http)$/i.test(receiver)))) ||
672
+ (lang === 'rust' && call.name === 'new' && /Router$/.test(receiver)) ||
673
+ (lang === 'csharp' && call.name === 'Build')
674
+ );
675
+ if (factory) receivers.add(call.assignedTo);
676
+ }
677
+
678
+ // Router groups/nests produce another router. Iterate because nested
679
+ // groups are common (`admin := api.Group(...); v2 := admin.Group(...)`).
680
+ let changed = true;
681
+ while (changed) {
682
+ changed = false;
683
+ for (const call of calls) {
684
+ if (!call.assignedTo || !call.receiver ||
685
+ !/^(Group|group|nest|NewGroup)$/.test(call.name) ||
686
+ !receivers.has(call.receiver) || receivers.has(call.assignedTo)) continue;
687
+ receivers.add(call.assignedTo);
688
+ changed = true;
689
+ }
690
+ }
691
+ return receivers;
692
+ }
693
+
540
694
  /**
541
695
  * Match a call against server route patterns. Returns {method, path, framework} or null.
542
696
  */
543
- function matchCallPatternRoute(call, lang) {
697
+ function matchCallPatternRoute(call, lang, routerReceivers = null) {
544
698
  if (!call.firstStringArg) return null;
545
699
 
546
700
  // Path-call patterns (Rust): handled outside (router.route, router.nest captured below)
@@ -550,7 +704,8 @@ function matchCallPatternRoute(call, lang) {
550
704
  // For Express/Gin/etc, the call is method-call: app.get('/path', handler)
551
705
  for (const p of patterns) {
552
706
  if (!call.receiver) continue;
553
- if (!p.receiverPattern.test(call.receiver)) continue;
707
+ if (!routerReceivers?.has(call.receiver) &&
708
+ !p.receiverPattern.test(call.receiver)) continue;
554
709
  if (!p.methodPattern.test(call.name)) continue;
555
710
 
556
711
  // BUG M5: Express has dual-purpose APIs where 1-arg .get/.set are config
@@ -566,6 +721,10 @@ function matchCallPatternRoute(call, lang) {
566
721
  // axum router.route('/path', get(handler)) — method comes from the *second* arg's verb,
567
722
  // which we don't have direct access to here. Fall back to ALL.
568
723
  let method = call.name.toUpperCase();
724
+ if (p.framework === 'aspnet-minimal') {
725
+ const mapped = call.name.match(/^Map(Get|Post|Put|Delete|Patch)$/);
726
+ method = mapped ? mapped[1].toUpperCase() : 'ALL';
727
+ }
569
728
  if (method === 'ROUTE' || method === 'HANDLE' || method === 'HANDLEFUNC' || method === 'USE' || method === 'ANY') {
570
729
  method = 'ALL';
571
730
  }
@@ -705,6 +864,18 @@ function extractClientRequests(index) {
705
864
  const r = matchClientRequest(call, lang, calls);
706
865
  if (!r) continue;
707
866
 
867
+ // Python's common `session.get("key")` / `s.get("key")`
868
+ // dictionary and ORM idioms are not HTTP requests. Without
869
+ // receiver-type evidence, require the string to have a URL/path
870
+ // shape before claiming an outbound request. This deliberately
871
+ // keeps absolute URLs and normal relative API paths while
872
+ // rejecting cache/session keys (UCN5-162).
873
+ if (lang === 'python' &&
874
+ !call.firstStringArg.startsWith('/') &&
875
+ !call.firstStringArg.includes('://')) {
876
+ continue;
877
+ }
878
+
708
879
  const callerName = call.enclosingFunction?.name || '<top-level>';
709
880
  const callerStartLine = call.enclosingFunction?.startLine;
710
881
 
@@ -773,7 +944,15 @@ function matchClientRequest(call, lang, allCallsInFile) {
773
944
  // we tag as ALL.
774
945
  let method;
775
946
  let inferred = false;
776
- if (methodName === 'uri') {
947
+ if (lang === 'csharp' && methodName.endsWith('async')) {
948
+ const verb = methodName.slice(0, -'async'.length);
949
+ if (verb === 'send') {
950
+ method = 'ALL';
951
+ inferred = true;
952
+ } else {
953
+ method = verb.toUpperCase();
954
+ }
955
+ } else if (methodName === 'uri') {
777
956
  // Java pattern: rest of the chain — we can't easily extract method, use ALL
778
957
  method = 'ALL';
779
958
  inferred = true;
@@ -1067,10 +1246,12 @@ function endpoints(index, options = {}) {
1067
1246
  }
1068
1247
 
1069
1248
  return {
1070
- // Advisory when bridging (v4 two-tier surface): route↔request
1071
- // matching is heuristic (per-match tiers EXACT/PARTIAL/UNCERTAIN),
1072
- // not a verified claim. Route/request EXTRACTION is AST-based.
1073
- ...(opts.bridge && { advisory: 'heuristic-route-matching' }),
1249
+ // Endpoint extraction is AST-based but bounded to known framework
1250
+ // patterns; a missing route is never proof that no endpoint exists.
1251
+ // Bridge matching adds a second heuristic layer.
1252
+ advisory: opts.bridge
1253
+ ? 'heuristic-route-matching-and-incomplete-inventory'
1254
+ : 'incomplete-endpoint-inventory',
1074
1255
  routes,
1075
1256
  requests,
1076
1257
  bridges,