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.
- package/.claude/skills/ucn/SKILL.md +89 -77
- package/.claude/skills/ucn/references/commands.md +62 -68
- package/.claude/skills/ucn/references/trust-contract.md +31 -6
- package/README.md +438 -305
- package/assets/demo.svg +31 -0
- package/cli/index.js +430 -1385
- package/core/account.js +144 -34
- package/core/analysis.js +182 -72
- package/core/ast-analysis.js +279 -0
- package/core/bridge.js +205 -24
- package/core/brief.js +27 -58
- package/core/build-worker.js +21 -140
- package/core/cache.js +513 -11
- package/core/callers.js +4920 -456
- package/core/check.js +13 -4
- package/core/command-contracts.js +402 -0
- package/core/compilation-database.js +276 -0
- package/core/confidence.js +4 -1
- package/core/deadcode.js +397 -19
- package/core/discovery.js +359 -46
- package/core/entrypoints.js +195 -41
- package/core/execute.js +887 -81
- package/core/graph-build.js +162 -7
- package/core/graph.js +53 -77
- package/core/imports.js +65 -6
- package/core/index-ir.js +138 -0
- package/core/ir.js +195 -0
- package/core/output/analysis.js +212 -22
- package/core/output/brief.js +23 -0
- package/core/output/check.js +4 -0
- package/core/output/doctor.js +37 -6
- package/core/output/endpoints.js +5 -2
- package/core/output/extraction.js +24 -12
- package/core/output/find.js +141 -36
- package/core/output/graph.js +11 -5
- package/core/output/public.js +462 -0
- package/core/output/refactoring.js +42 -10
- package/core/output/reporting.js +97 -20
- package/core/output/search.js +24 -16
- package/core/output/shared.js +22 -1
- package/core/output/tracing.js +30 -15
- package/core/output-budget.js +295 -0
- package/core/output.js +1 -0
- package/core/parallel-build.js +44 -11
- package/core/parser.js +3 -3
- package/core/project.js +384 -187
- package/core/public-command.js +47 -0
- package/core/registry.js +247 -117
- package/core/reporting.js +312 -290
- package/core/search.js +317 -185
- package/core/semantic-provider.js +110 -0
- package/core/stacktrace.js +25 -0
- package/core/tracing.js +101 -51
- package/core/trust-matrix.js +19 -40
- package/core/verify.js +534 -37
- package/languages/adapter.js +218 -0
- package/languages/c-family.js +2791 -0
- package/languages/c.js +3 -0
- package/languages/cpp.js +3 -0
- package/languages/csharp.js +1402 -0
- package/languages/go.js +60 -21
- package/languages/html.js +2 -2
- package/languages/index.js +85 -7
- package/languages/java.js +396 -13
- package/languages/javascript.js +199 -19
- package/languages/python.js +964 -22
- package/languages/rust.js +1317 -152
- package/languages/utils.js +40 -3
- package/mcp/server.js +254 -636
- package/package.json +39 -22
- package/eslint.config.js +0 -43
- 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
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
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 (!
|
|
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 (
|
|
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
|
-
//
|
|
1071
|
-
//
|
|
1072
|
-
//
|
|
1073
|
-
|
|
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,
|