ucn 3.8.23 → 3.8.25
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 +114 -11
- package/README.md +152 -156
- package/cli/index.js +363 -37
- package/core/analysis.js +936 -32
- package/core/bridge.js +1111 -0
- package/core/brief.js +408 -0
- package/core/cache.js +105 -5
- package/core/callers.js +72 -18
- package/core/check.js +200 -0
- package/core/discovery.js +57 -34
- package/core/entrypoints.js +638 -4
- package/core/execute.js +304 -5
- package/core/git-enrich.js +130 -0
- package/core/graph.js +24 -2
- package/core/output/analysis.js +157 -25
- package/core/output/brief.js +100 -0
- package/core/output/check.js +79 -0
- package/core/output/doctor.js +85 -0
- package/core/output/endpoints.js +239 -0
- package/core/output/extraction.js +2 -0
- package/core/output/find.js +126 -39
- package/core/output/graph.js +48 -15
- package/core/output/refactoring.js +103 -5
- package/core/output/reporting.js +63 -23
- package/core/output/search.js +110 -17
- package/core/output/shared.js +56 -2
- package/core/output.js +4 -0
- package/core/parser.js +8 -2
- package/core/project.js +39 -3
- package/core/registry.js +30 -14
- package/core/reporting.js +465 -2
- package/core/search.js +130 -10
- package/core/shared.js +101 -5
- package/core/tracing.js +16 -6
- package/core/verify.js +982 -95
- package/languages/go.js +91 -6
- package/languages/html.js +10 -0
- package/languages/java.js +151 -35
- package/languages/javascript.js +290 -33
- package/languages/python.js +78 -11
- package/languages/rust.js +267 -12
- package/languages/utils.js +315 -3
- package/mcp/server.js +91 -16
- package/package.json +9 -1
package/languages/python.js
CHANGED
|
@@ -10,7 +10,8 @@ const {
|
|
|
10
10
|
traverseTreeCached,
|
|
11
11
|
nodeToLocation,
|
|
12
12
|
parseStructuredParams,
|
|
13
|
-
extractPythonDocstring
|
|
13
|
+
extractPythonDocstring,
|
|
14
|
+
paramTypesFromStructured
|
|
14
15
|
} = require('./utils');
|
|
15
16
|
const { PARSE_OPTIONS, safeParse } = require('./index');
|
|
16
17
|
|
|
@@ -117,16 +118,19 @@ function _processFunction(node, functions, processedRanges, lines, code) {
|
|
|
117
118
|
// Only set when different from startLine (i.e., when decorators push startLine earlier)
|
|
118
119
|
const nameLine = nameNode.startPosition.row + 1;
|
|
119
120
|
|
|
121
|
+
const paramsStructured = parseStructuredParams(paramsNode, 'python');
|
|
122
|
+
const paramTypes = paramTypesFromStructured(paramsStructured);
|
|
120
123
|
functions.push({
|
|
121
124
|
name: nameNode.text,
|
|
122
125
|
params: extractPythonParams(paramsNode),
|
|
123
|
-
paramsStructured
|
|
126
|
+
paramsStructured,
|
|
124
127
|
startLine: decoratorStartLine,
|
|
125
128
|
endLine,
|
|
126
129
|
indent,
|
|
127
130
|
isAsync,
|
|
128
131
|
modifiers: isAsync ? ['async'] : [],
|
|
129
132
|
...(returnType && { returnType }),
|
|
133
|
+
...(paramTypes && { paramTypes }),
|
|
130
134
|
...(docstring && { docstring }),
|
|
131
135
|
...(decorators.length > 0 && { decorators }),
|
|
132
136
|
...(nameLine !== decoratorStartLine && { nameLine })
|
|
@@ -365,16 +369,21 @@ function extractClassMembers(classNode, code) {
|
|
|
365
369
|
// nameLine: where the name identifier lives (differs from startLine when decorated)
|
|
366
370
|
const nameLine = nameNode.startPosition.row + 1;
|
|
367
371
|
|
|
372
|
+
const paramsStructured = parseStructuredParams(paramsNode, 'python');
|
|
373
|
+
const paramTypes = paramTypesFromStructured(paramsStructured);
|
|
368
374
|
members.push({
|
|
369
375
|
name,
|
|
370
376
|
params: extractPythonParams(paramsNode),
|
|
371
|
-
paramsStructured
|
|
377
|
+
paramsStructured,
|
|
372
378
|
startLine,
|
|
373
379
|
endLine,
|
|
374
380
|
memberType,
|
|
375
381
|
isAsync,
|
|
376
382
|
isMethod: true, // Mark as method for context() lookups
|
|
383
|
+
// Match top-level Python functions: `async def` → ['async'] modifiers.
|
|
384
|
+
modifiers: isAsync ? ['async'] : [],
|
|
377
385
|
...(returnType && { returnType }),
|
|
386
|
+
...(paramTypes && { paramTypes }),
|
|
378
387
|
...(docstring && { docstring }),
|
|
379
388
|
...(memberDecorators.length > 0 && { decorators: memberDecorators }),
|
|
380
389
|
...(nameLine !== startLine && { nameLine })
|
|
@@ -450,6 +459,37 @@ function findCallsInCode(code, parser) {
|
|
|
450
459
|
const localVarTypes = new Map(); // Track local variable types: varName -> typeName (for receiverType inference)
|
|
451
460
|
const localVarTypesStack = []; // Stack for function-scoped save/restore of localVarTypes
|
|
452
461
|
|
|
462
|
+
// Helper: extract first string-arg literal from a call node.
|
|
463
|
+
// Used by route extraction to capture path arg of requests.get('/users'), httpx.get('/users') etc.
|
|
464
|
+
// Handles both plain strings and f-strings (returns interp:true with literal prefix).
|
|
465
|
+
const { extractStringArg: _extractStringArg } = require('./utils');
|
|
466
|
+
const getFirstStringArg = (callNode) => {
|
|
467
|
+
const argsNode = callNode.childForFieldName('arguments');
|
|
468
|
+
if (!argsNode) return null;
|
|
469
|
+
for (let i = 0; i < argsNode.namedChildCount; i++) {
|
|
470
|
+
const arg = argsNode.namedChild(i);
|
|
471
|
+
if (arg.type === 'comment') continue;
|
|
472
|
+
// Handle f-string explicitly
|
|
473
|
+
if (arg.type === 'string') {
|
|
474
|
+
// f-string detection: tree-sitter-python wraps interpolations as 'interpolation' children.
|
|
475
|
+
// If any interpolation child exists, this is interpolated; extract literal prefix.
|
|
476
|
+
let interp = false;
|
|
477
|
+
let prefix = '';
|
|
478
|
+
for (let j = 0; j < arg.namedChildCount; j++) {
|
|
479
|
+
const sc = arg.namedChild(j);
|
|
480
|
+
if (sc.type === 'interpolation') { interp = true; break; }
|
|
481
|
+
if (sc.type === 'string_content') prefix += sc.text;
|
|
482
|
+
}
|
|
483
|
+
if (interp) {
|
|
484
|
+
return { value: prefix + (prefix.endsWith('*') ? '' : '*'), interp: true };
|
|
485
|
+
}
|
|
486
|
+
return _extractStringArg(arg);
|
|
487
|
+
}
|
|
488
|
+
return _extractStringArg(arg);
|
|
489
|
+
}
|
|
490
|
+
return null;
|
|
491
|
+
};
|
|
492
|
+
|
|
453
493
|
// Helper to check if a node is a non-callable literal
|
|
454
494
|
const isNonCallableInit = (node) => {
|
|
455
495
|
// Primitive literals
|
|
@@ -615,13 +655,15 @@ function findCallsInCode(code, parser) {
|
|
|
615
655
|
if (funcNode.type === 'identifier') {
|
|
616
656
|
// Direct call: foo()
|
|
617
657
|
const resolvedName = aliases.get(funcNode.text);
|
|
658
|
+
const firstArg = getFirstStringArg(node);
|
|
618
659
|
calls.push({
|
|
619
660
|
name: funcNode.text,
|
|
620
661
|
...(resolvedName && { resolvedName }),
|
|
621
662
|
line: node.startPosition.row + 1,
|
|
622
663
|
isMethod: false,
|
|
623
664
|
enclosingFunction,
|
|
624
|
-
uncertain
|
|
665
|
+
uncertain,
|
|
666
|
+
...(firstArg && { firstStringArg: firstArg.value, firstStringArgInterp: firstArg.interp })
|
|
625
667
|
});
|
|
626
668
|
} else if (funcNode.type === 'attribute') {
|
|
627
669
|
// Method/attribute call: obj.foo() or self.attr.foo()
|
|
@@ -653,6 +695,7 @@ function findCallsInCode(code, parser) {
|
|
|
653
695
|
}
|
|
654
696
|
|
|
655
697
|
const receiverType = receiver ? localVarTypes.get(receiver) : undefined;
|
|
698
|
+
const firstArg = getFirstStringArg(node);
|
|
656
699
|
calls.push({
|
|
657
700
|
name: attrNode.text,
|
|
658
701
|
line: node.startPosition.row + 1,
|
|
@@ -661,7 +704,8 @@ function findCallsInCode(code, parser) {
|
|
|
661
704
|
...(receiverType && { receiverType }),
|
|
662
705
|
...(selfAttribute && { selfAttribute }),
|
|
663
706
|
enclosingFunction,
|
|
664
|
-
uncertain
|
|
707
|
+
uncertain,
|
|
708
|
+
...(firstArg && { firstStringArg: firstArg.value, firstStringArgInterp: firstArg.interp })
|
|
665
709
|
});
|
|
666
710
|
}
|
|
667
711
|
}
|
|
@@ -1198,17 +1242,39 @@ function extractConstructorName(node) {
|
|
|
1198
1242
|
return null;
|
|
1199
1243
|
}
|
|
1200
1244
|
|
|
1245
|
+
/**
|
|
1246
|
+
* Classify a Python symbol as a runtime entry point of a specific kind.
|
|
1247
|
+
* Returns 'test' | 'framework' | null.
|
|
1248
|
+
*
|
|
1249
|
+
* - 'test': pytest discovery (`test_*` functions, methods on `Test*` classes,
|
|
1250
|
+
* `setUp`/`tearDown` lifecycle, pytest plugin hooks).
|
|
1251
|
+
* - 'framework': dunder methods (`__init__`, `__repr__`, etc.) — invoked by
|
|
1252
|
+
* the Python runtime as part of the type protocol.
|
|
1253
|
+
*
|
|
1254
|
+
* Note: Python has no fn-level `main` entry point convention (the
|
|
1255
|
+
* `if __name__ == '__main__':` guard wraps statements, not a function).
|
|
1256
|
+
*
|
|
1257
|
+
* Used by tracing/search so `affectedTests` only tags genuine test functions.
|
|
1258
|
+
*/
|
|
1259
|
+
function getEntryPointKind(symbol) {
|
|
1260
|
+
const { name } = symbol;
|
|
1261
|
+
// Test entries first — pytest naming + unittest lifecycle hooks
|
|
1262
|
+
if (/^test_/.test(name)) return 'test';
|
|
1263
|
+
if (/^(setUp|tearDown)(Class|Module)?$/.test(name)) return 'test';
|
|
1264
|
+
if (/^pytest_/.test(name)) return 'test';
|
|
1265
|
+
// Methods inside a class whose name starts with Test (unittest/pytest discovery)
|
|
1266
|
+
if (symbol.isMethod && symbol.className && /^Test[A-Z_0-9]?/.test(symbol.className)) return 'test';
|
|
1267
|
+
// Dunder methods are framework entries (Python protocol)
|
|
1268
|
+
if (/^__\w+__$/.test(name)) return 'framework';
|
|
1269
|
+
return null;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1201
1272
|
/**
|
|
1202
1273
|
* Check if a symbol is a Python-convention entry point.
|
|
1203
1274
|
* These are invoked by the Python runtime, test runners, or frameworks.
|
|
1204
1275
|
*/
|
|
1205
1276
|
function isEntryPoint(symbol) {
|
|
1206
|
-
|
|
1207
|
-
if (/^__\w+__$/.test(name)) return true;
|
|
1208
|
-
if (/^test_/.test(name)) return true;
|
|
1209
|
-
if (/^(setUp|tearDown)(Class|Module)?$/.test(name)) return true;
|
|
1210
|
-
if (/^pytest_/.test(name)) return true;
|
|
1211
|
-
return false;
|
|
1277
|
+
return getEntryPointKind(symbol) !== null;
|
|
1212
1278
|
}
|
|
1213
1279
|
|
|
1214
1280
|
module.exports = {
|
|
@@ -1221,5 +1287,6 @@ module.exports = {
|
|
|
1221
1287
|
findUsagesInCode,
|
|
1222
1288
|
findInstanceAttributeTypes,
|
|
1223
1289
|
isEntryPoint,
|
|
1290
|
+
getEntryPointKind,
|
|
1224
1291
|
parse
|
|
1225
1292
|
};
|
package/languages/rust.js
CHANGED
|
@@ -93,11 +93,98 @@ function extractAttributes(node, codeOrLines) {
|
|
|
93
93
|
return attributes;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Extract attributes WITH their argument tokens (for routing decorator detection).
|
|
98
|
+
* Returns array of { name, args: rawArgString } objects.
|
|
99
|
+
* #[get("/users")] → [{ name: 'get', args: '"/users"' }]
|
|
100
|
+
* #[tokio::main] → [{ name: 'tokio::main', args: null }]
|
|
101
|
+
*
|
|
102
|
+
* @param {Node} node - Function AST node
|
|
103
|
+
* @param {string|string[]} codeOrLines - Source code or pre-split lines
|
|
104
|
+
* @returns {Array<{name: string, args: string|null}>}
|
|
105
|
+
*/
|
|
106
|
+
function extractAttributesWithArgs(node, codeOrLines) {
|
|
107
|
+
const result = [];
|
|
108
|
+
const lines = Array.isArray(codeOrLines) ? codeOrLines : codeOrLines.split('\n');
|
|
109
|
+
|
|
110
|
+
const startLine = node.startPosition.row;
|
|
111
|
+
for (let i = startLine - 1; i >= 0 && i >= startLine - 5; i--) {
|
|
112
|
+
const line = lines[i]?.trim();
|
|
113
|
+
if (!line) break;
|
|
114
|
+
if (line.startsWith('#[')) {
|
|
115
|
+
// Match #[name(...args...)] or #[name]
|
|
116
|
+
// Need to handle nested parens; use a simple bracket-matching approach.
|
|
117
|
+
const m = line.match(/^#\[(.+)\]\s*$/);
|
|
118
|
+
if (m) {
|
|
119
|
+
const attrContent = m[1];
|
|
120
|
+
const parenIdx = attrContent.indexOf('(');
|
|
121
|
+
if (parenIdx === -1) {
|
|
122
|
+
result.unshift({ name: attrContent.trim(), args: null });
|
|
123
|
+
} else {
|
|
124
|
+
const name = attrContent.slice(0, parenIdx).trim();
|
|
125
|
+
// Extract content within outer parens (find matching close)
|
|
126
|
+
let depth = 0;
|
|
127
|
+
let endIdx = -1;
|
|
128
|
+
for (let k = parenIdx; k < attrContent.length; k++) {
|
|
129
|
+
const ch = attrContent[k];
|
|
130
|
+
if (ch === '(') depth++;
|
|
131
|
+
else if (ch === ')') {
|
|
132
|
+
depth--;
|
|
133
|
+
if (depth === 0) { endIdx = k; break; }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const args = endIdx > parenIdx
|
|
137
|
+
? attrContent.slice(parenIdx + 1, endIdx).trim()
|
|
138
|
+
: attrContent.slice(parenIdx + 1).trim();
|
|
139
|
+
result.unshift({ name, args });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} else if (!line.startsWith('//')) {
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
|
|
96
149
|
// --- Module-scope constants for state object detection ---
|
|
97
150
|
const _STATE_PATTERN = /^([A-Z][A-Z0-9_]+|DEFAULT_[A-Z_]+)$/;
|
|
98
151
|
|
|
99
152
|
// --- Single-pass helpers: extracted from find* callbacks ---
|
|
100
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Walk up AST ancestors to detect whether `node` is enclosed in a
|
|
156
|
+
* `#[cfg(test)]` (or `#[cfg(any(test, ...))]`) module. Used to flag
|
|
157
|
+
* functions inside a `mod tests` block as test entry points even when
|
|
158
|
+
* they don't carry a direct `#[test]` attribute (BUG-CY).
|
|
159
|
+
*/
|
|
160
|
+
function _isInsideCfgTestModule(node, lines) {
|
|
161
|
+
let parent = node.parent;
|
|
162
|
+
while (parent) {
|
|
163
|
+
if (parent.type === 'mod_item') {
|
|
164
|
+
const startRow = parent.startPosition.row;
|
|
165
|
+
// Look at preceding lines for #[cfg(test)] or #[cfg(any(test,...))] / #[cfg(all(...,test,...))]
|
|
166
|
+
for (let i = startRow - 1; i >= 0 && i >= startRow - 5; i--) {
|
|
167
|
+
const line = lines[i]?.trim();
|
|
168
|
+
if (!line) break;
|
|
169
|
+
if (line.startsWith('#[')) {
|
|
170
|
+
// Match #[cfg(...)] forms that include a `test` predicate.
|
|
171
|
+
// Conservatively look for the literal token `test` inside the cfg(...) args.
|
|
172
|
+
const m = line.match(/#\[\s*cfg\s*\(([^\]]*)\)\s*\]/);
|
|
173
|
+
if (m) {
|
|
174
|
+
const args = m[1];
|
|
175
|
+
// Word-boundary match for `test` to avoid matching e.g. `testing_module`.
|
|
176
|
+
if (/\btest\b/.test(args)) return true;
|
|
177
|
+
}
|
|
178
|
+
} else if (!line.startsWith('//')) {
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
parent = parent.parent;
|
|
184
|
+
}
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
101
188
|
/**
|
|
102
189
|
* Process a node for function extraction (single-pass helper)
|
|
103
190
|
* Returns true if node was matched, false otherwise
|
|
@@ -138,6 +225,8 @@ function _processFunction(node, functions, processedRanges, lines, code) {
|
|
|
138
225
|
const docstring = extractRustDocstring(lines, startLine);
|
|
139
226
|
const generics = extractGenerics(node);
|
|
140
227
|
const attributes = extractAttributes(node, lines);
|
|
228
|
+
const attributesWithArgs = extractAttributesWithArgs(node, lines);
|
|
229
|
+
const inCfgTest = _isInsideCfgTestModule(node, lines);
|
|
141
230
|
|
|
142
231
|
const modifiers = [];
|
|
143
232
|
if (visibility) modifiers.push(visibility);
|
|
@@ -149,6 +238,9 @@ function _processFunction(node, functions, processedRanges, lines, code) {
|
|
|
149
238
|
for (const attr of attributes) {
|
|
150
239
|
modifiers.push(attr);
|
|
151
240
|
}
|
|
241
|
+
// Mark functions inside #[cfg(test)] modules — they are test-only code
|
|
242
|
+
// even if they lack a direct #[test] attribute (helpers used by tests).
|
|
243
|
+
if (inCfgTest) modifiers.push('cfg_test_module');
|
|
152
244
|
|
|
153
245
|
functions.push({
|
|
154
246
|
name: nameNode.text,
|
|
@@ -160,7 +252,8 @@ function _processFunction(node, functions, processedRanges, lines, code) {
|
|
|
160
252
|
modifiers,
|
|
161
253
|
...(returnType && { returnType }),
|
|
162
254
|
...(docstring && { docstring }),
|
|
163
|
-
...(generics && { generics })
|
|
255
|
+
...(generics && { generics }),
|
|
256
|
+
...(attributesWithArgs.length > 0 && { attributesWithArgs })
|
|
164
257
|
});
|
|
165
258
|
}
|
|
166
259
|
return true;
|
|
@@ -695,9 +788,11 @@ function extractImplMembers(implNode, codeOrLines, typeName) {
|
|
|
695
788
|
|
|
696
789
|
// Extract attributes (#[test], #[inline], etc.) for impl members
|
|
697
790
|
const attributes = extractAttributes(child, codeOrLines);
|
|
791
|
+
const inCfgTest = _isInsideCfgTestModule(child, Array.isArray(codeOrLines) ? codeOrLines : codeOrLines.split('\n'));
|
|
698
792
|
const modifiers = [];
|
|
699
793
|
if (visibility) modifiers.push(visibility);
|
|
700
794
|
for (const attr of attributes) modifiers.push(attr);
|
|
795
|
+
if (inCfgTest) modifiers.push('cfg_test_module');
|
|
701
796
|
|
|
702
797
|
members.push({
|
|
703
798
|
name: nameNode.text,
|
|
@@ -760,6 +855,57 @@ function parse(code, parser) {
|
|
|
760
855
|
return { language: 'rust', totalLines: lines.length, functions, classes, stateObjects, imports: [], exports: [] };
|
|
761
856
|
}
|
|
762
857
|
|
|
858
|
+
/**
|
|
859
|
+
* Walk a Rust call chain to find its root constructor type.
|
|
860
|
+
*
|
|
861
|
+
* Examples:
|
|
862
|
+
* Router::new() → 'Router'
|
|
863
|
+
* Router::new().route(...) → 'Router'
|
|
864
|
+
* Router::new().nest(...).route(...) → 'Router' (recursively unwraps method chain)
|
|
865
|
+
* axum::Router::new().route(...) → 'Router'
|
|
866
|
+
* foo() → null (not a constructor pattern)
|
|
867
|
+
*
|
|
868
|
+
* Returns the root type name when the chain begins with `<Type>::new()` or
|
|
869
|
+
* `<Type>::*` (associated function call). Returns null otherwise.
|
|
870
|
+
*
|
|
871
|
+
* Used to detect axum's chained Router pattern where `.route(...)` is called on
|
|
872
|
+
* the result of `Router::new()` rather than a named variable.
|
|
873
|
+
*
|
|
874
|
+
* @param {Node} callNode - call_expression node
|
|
875
|
+
* @returns {string|null} root type name, or null
|
|
876
|
+
*/
|
|
877
|
+
function _findRustChainRootType(callNode) {
|
|
878
|
+
if (!callNode || callNode.type !== 'call_expression') return null;
|
|
879
|
+
const funcNode = callNode.childForFieldName('function');
|
|
880
|
+
if (!funcNode) return null;
|
|
881
|
+
|
|
882
|
+
// Base case: scoped path like Router::new or axum::Router::new
|
|
883
|
+
if (funcNode.type === 'scoped_identifier') {
|
|
884
|
+
const segments = funcNode.text.split('::');
|
|
885
|
+
// Need at least Type::method (associated function call)
|
|
886
|
+
if (segments.length < 2) return null;
|
|
887
|
+
// The type is the second-to-last segment (last is the method)
|
|
888
|
+
const typeName = segments[segments.length - 2];
|
|
889
|
+
// Must be a Capitalized type name (filter out module::func calls)
|
|
890
|
+
if (!/^[A-Z]/.test(typeName)) return null;
|
|
891
|
+
return typeName;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Recursive case: chained method call on prior call result
|
|
895
|
+
// Router::new().route(...) → unwrap .route(...) and recurse on Router::new()
|
|
896
|
+
if (funcNode.type === 'field_expression') {
|
|
897
|
+
const valueNode = funcNode.childForFieldName('value');
|
|
898
|
+
if (valueNode?.type === 'call_expression') {
|
|
899
|
+
return _findRustChainRootType(valueNode);
|
|
900
|
+
}
|
|
901
|
+
// Chain rooted at a named identifier: skip — we detect this elsewhere
|
|
902
|
+
// via the existing receiver-name path in bridge.js.
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
return null;
|
|
907
|
+
}
|
|
908
|
+
|
|
763
909
|
/**
|
|
764
910
|
* Find all function calls in Rust code using tree-sitter AST
|
|
765
911
|
* @param {string} code - Source code to analyze
|
|
@@ -773,6 +919,29 @@ function findCallsInCode(code, parser) {
|
|
|
773
919
|
// Track variable -> type mappings per function scope (scopeStartLine -> Map<varName, typeName>)
|
|
774
920
|
const scopeTypes = new Map();
|
|
775
921
|
|
|
922
|
+
// Helper: extract first string-arg literal from a call_expression node.
|
|
923
|
+
// Used by route extraction to capture path arg of client.get("/users") and
|
|
924
|
+
// detect format!() macro interpolation: format!("/users/{}", id).
|
|
925
|
+
const { extractStringArg: _extractStringArg } = require('./utils');
|
|
926
|
+
const getFirstStringArg = (callNode) => {
|
|
927
|
+
const argsNode = callNode.childForFieldName('arguments');
|
|
928
|
+
if (!argsNode) return null;
|
|
929
|
+
for (let i = 0; i < argsNode.namedChildCount; i++) {
|
|
930
|
+
const arg = argsNode.namedChild(i);
|
|
931
|
+
if (arg.type === 'comment') continue;
|
|
932
|
+
// format!() macro inside an arg: client.get(format!("/users/{}", id))
|
|
933
|
+
if (arg.type === 'macro_invocation') {
|
|
934
|
+
const macroNode = arg.childForFieldName('macro');
|
|
935
|
+
const macroName = macroNode ? macroNode.text.replace(/!$/, '') : '';
|
|
936
|
+
if (macroName === 'format') {
|
|
937
|
+
return _extractStringArg(arg);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
return _extractStringArg(arg);
|
|
941
|
+
}
|
|
942
|
+
return null;
|
|
943
|
+
};
|
|
944
|
+
|
|
776
945
|
// Helper to check if a node creates a function scope
|
|
777
946
|
const isFunctionNode = (node) => {
|
|
778
947
|
return ['function_item', 'closure_expression'].includes(node.type);
|
|
@@ -877,11 +1046,13 @@ function findCallsInCode(code, parser) {
|
|
|
877
1046
|
|
|
878
1047
|
if (funcNode.type === 'identifier') {
|
|
879
1048
|
// Direct call: foo()
|
|
1049
|
+
const firstArg = getFirstStringArg(node);
|
|
880
1050
|
calls.push({
|
|
881
1051
|
name: funcNode.text,
|
|
882
1052
|
line: node.startPosition.row + 1,
|
|
883
1053
|
isMethod: false,
|
|
884
|
-
enclosingFunction
|
|
1054
|
+
enclosingFunction,
|
|
1055
|
+
...(firstArg && { firstStringArg: firstArg.value, firstStringArgInterp: firstArg.interp })
|
|
885
1056
|
});
|
|
886
1057
|
} else if (funcNode.type === 'field_expression') {
|
|
887
1058
|
// Method call: obj.method()
|
|
@@ -889,15 +1060,37 @@ function findCallsInCode(code, parser) {
|
|
|
889
1060
|
const valueNode = funcNode.childForFieldName('value');
|
|
890
1061
|
|
|
891
1062
|
if (fieldNode) {
|
|
892
|
-
|
|
1063
|
+
let receiver = (valueNode?.type === 'identifier' || valueNode?.type === 'self') ? valueNode.text : undefined;
|
|
1064
|
+
// Detect chained Router::new()-rooted method calls. axum's canonical
|
|
1065
|
+
// idiom is `Router::new().route("/p", get(h)).route(...)` where the
|
|
1066
|
+
// receiver of `.route(...)` is itself a call_expression. Walk the
|
|
1067
|
+
// chain to its root: if the chain originates at Router::new() or
|
|
1068
|
+
// any Router-typed call, set a synthetic receiver string so the
|
|
1069
|
+
// bridge layer can recognize this as a Router method invocation.
|
|
1070
|
+
if (!receiver && valueNode?.type === 'call_expression') {
|
|
1071
|
+
const rootType = _findRustChainRootType(valueNode);
|
|
1072
|
+
if (rootType) {
|
|
1073
|
+
// Synthetic marker — ROUTER_CHAIN:<RootTypeName>. The
|
|
1074
|
+
// <RootTypeName> portion lets the bridge match
|
|
1075
|
+
// /^router/i case-insensitively.
|
|
1076
|
+
receiver = rootType;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
893
1079
|
const receiverType = (receiver && receiver !== 'self') ? getReceiverType(receiver) : undefined;
|
|
1080
|
+
const firstArg = getFirstStringArg(node);
|
|
1081
|
+
// RUST-2: For chained calls like `a().b().parse::<T>().ok()`,
|
|
1082
|
+
// each method should report the line where its OWN identifier
|
|
1083
|
+
// appears, not the line where the outer expression begins.
|
|
1084
|
+
// Tree-sitter gives us fieldNode (the identifier) — use its
|
|
1085
|
+
// startPosition.row instead of the wrapping call_expression's.
|
|
894
1086
|
calls.push({
|
|
895
1087
|
name: fieldNode.text,
|
|
896
|
-
line:
|
|
1088
|
+
line: fieldNode.startPosition.row + 1,
|
|
897
1089
|
isMethod: true,
|
|
898
1090
|
receiver,
|
|
899
1091
|
...(receiverType && { receiverType }),
|
|
900
|
-
enclosingFunction
|
|
1092
|
+
enclosingFunction,
|
|
1093
|
+
...(firstArg && { firstStringArg: firstArg.value, firstStringArgInterp: firstArg.interp })
|
|
901
1094
|
});
|
|
902
1095
|
}
|
|
903
1096
|
} else if (funcNode.type === 'scoped_identifier') {
|
|
@@ -906,18 +1099,57 @@ function findCallsInCode(code, parser) {
|
|
|
906
1099
|
const pathText = funcNode.text;
|
|
907
1100
|
const segments = pathText.split('::');
|
|
908
1101
|
const name = segments[segments.length - 1];
|
|
1102
|
+
const firstArg = getFirstStringArg(node);
|
|
909
1103
|
calls.push({
|
|
910
1104
|
name: name,
|
|
911
1105
|
line: node.startPosition.row + 1,
|
|
912
1106
|
isMethod: segments.length > 1,
|
|
913
1107
|
isPathCall: true, // Distinguishes Type::func()/module::func() from obj.method()
|
|
914
1108
|
receiver: segments.length > 1 ? segments.slice(0, -1).join('::') : undefined,
|
|
915
|
-
enclosingFunction
|
|
1109
|
+
enclosingFunction,
|
|
1110
|
+
...(firstArg && { firstStringArg: firstArg.value, firstStringArgInterp: firstArg.interp })
|
|
916
1111
|
});
|
|
917
1112
|
}
|
|
918
1113
|
return true;
|
|
919
1114
|
}
|
|
920
1115
|
|
|
1116
|
+
// R3-NEW-3: Detect Rust struct expressions as constructor calls.
|
|
1117
|
+
// Foo { x: 1 } → call(name='Foo', isConstructor:true)
|
|
1118
|
+
// path::Foo { ... } → call(name='Foo', isConstructor:true) — strip path
|
|
1119
|
+
// Foo::Variant { } (enum struct variant) → name=Variant, receiver=Foo
|
|
1120
|
+
//
|
|
1121
|
+
// Detection happens as a separate AST node visit, so it doesn't conflict
|
|
1122
|
+
// with existing call/method handlers.
|
|
1123
|
+
if (node.type === 'struct_expression') {
|
|
1124
|
+
const nameNode = node.childForFieldName('name');
|
|
1125
|
+
if (nameNode) {
|
|
1126
|
+
let typeName = null;
|
|
1127
|
+
if (nameNode.type === 'type_identifier') {
|
|
1128
|
+
typeName = nameNode.text;
|
|
1129
|
+
} else if (nameNode.type === 'scoped_type_identifier') {
|
|
1130
|
+
// path::Foo or Enum::Variant — emit as the rightmost name.
|
|
1131
|
+
const innerNameNode = nameNode.childForFieldName('name');
|
|
1132
|
+
if (innerNameNode) {
|
|
1133
|
+
typeName = innerNameNode.text;
|
|
1134
|
+
} else {
|
|
1135
|
+
// Fallback: split by ::
|
|
1136
|
+
const parts = nameNode.text.split('::');
|
|
1137
|
+
typeName = parts[parts.length - 1];
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
if (typeName) {
|
|
1141
|
+
const enclosingFunction = getCurrentEnclosingFunction();
|
|
1142
|
+
calls.push({
|
|
1143
|
+
name: typeName,
|
|
1144
|
+
line: node.startPosition.row + 1,
|
|
1145
|
+
isMethod: false,
|
|
1146
|
+
isConstructor: true,
|
|
1147
|
+
enclosingFunction
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
921
1153
|
// Handle macro invocations: println!(), vec![]
|
|
922
1154
|
if (node.type === 'macro_invocation') {
|
|
923
1155
|
const macroNode = node.childForFieldName('macro');
|
|
@@ -950,9 +1182,10 @@ function findCallsInCode(code, parser) {
|
|
|
950
1182
|
const receiver = (valueNode?.type === 'identifier' || valueNode?.type === 'self') ? valueNode.text : undefined;
|
|
951
1183
|
const receiverType = (receiver && receiver !== 'self') ? getReceiverType(receiver) : undefined;
|
|
952
1184
|
const enclosingFunction = getCurrentEnclosingFunction();
|
|
1185
|
+
// RUST-2: use the field identifier's line, not the wrapping field_expression's
|
|
953
1186
|
calls.push({
|
|
954
1187
|
name: fieldNode.text,
|
|
955
|
-
line:
|
|
1188
|
+
line: fieldNode.startPosition.row + 1,
|
|
956
1189
|
isMethod: true,
|
|
957
1190
|
receiver,
|
|
958
1191
|
...(receiverType && { receiverType }),
|
|
@@ -1499,16 +1732,37 @@ function findUsagesInCode(code, name, parser) {
|
|
|
1499
1732
|
return usages;
|
|
1500
1733
|
}
|
|
1501
1734
|
|
|
1735
|
+
/**
|
|
1736
|
+
* Classify a Rust symbol as a runtime entry point of a specific kind.
|
|
1737
|
+
* Returns 'test' | 'main' | 'framework' | null.
|
|
1738
|
+
*
|
|
1739
|
+
* - 'test': harness-invoked — #[test], #[bench], or anything inside a
|
|
1740
|
+
* #[cfg(test)] module (which only compiles for `cargo test`).
|
|
1741
|
+
* - 'main': program entry — fn main()
|
|
1742
|
+
* - 'framework': trait-impl methods (invoked by the trait contract holder)
|
|
1743
|
+
*
|
|
1744
|
+
* Used by tracing/search to distinguish test-coverage producers from runtime
|
|
1745
|
+
* entry points so `affectedTests` doesn't mis-tag fn main() as a test case.
|
|
1746
|
+
*/
|
|
1747
|
+
function getEntryPointKind(symbol) {
|
|
1748
|
+
const m = symbol.modifiers || [];
|
|
1749
|
+
// Test entries first — #[test]/#[bench] take precedence even over fn main().
|
|
1750
|
+
if (m.includes('test') || m.includes('bench')) return 'test';
|
|
1751
|
+
// Functions inside #[cfg(test)] mod blocks — test-only code, even if they
|
|
1752
|
+
// lack a direct #[test] attribute (e.g. shared helpers in `mod tests`).
|
|
1753
|
+
if (m.includes('cfg_test_module')) return 'test';
|
|
1754
|
+
if (symbol.name === 'main') return 'main';
|
|
1755
|
+
// Trait-impl methods are framework entry points (invoked by trait holder).
|
|
1756
|
+
if (symbol.isMethod && symbol.className && symbol.traitImpl) return 'framework';
|
|
1757
|
+
return null;
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1502
1760
|
/**
|
|
1503
1761
|
* Check if a symbol is a Rust-convention entry point.
|
|
1504
1762
|
* These are invoked by the Rust runtime, test harness, or required by trait contracts.
|
|
1505
1763
|
*/
|
|
1506
1764
|
function isEntryPoint(symbol) {
|
|
1507
|
-
|
|
1508
|
-
if (symbol.name === 'main') return true;
|
|
1509
|
-
if (m.includes('test') || m.includes('bench')) return true;
|
|
1510
|
-
if (symbol.isMethod && symbol.className && symbol.traitImpl) return true;
|
|
1511
|
-
return false;
|
|
1765
|
+
return getEntryPointKind(symbol) !== null;
|
|
1512
1766
|
}
|
|
1513
1767
|
|
|
1514
1768
|
module.exports = {
|
|
@@ -1520,5 +1774,6 @@ module.exports = {
|
|
|
1520
1774
|
findExportsInCode,
|
|
1521
1775
|
findUsagesInCode,
|
|
1522
1776
|
isEntryPoint,
|
|
1777
|
+
getEntryPointKind,
|
|
1523
1778
|
parse
|
|
1524
1779
|
};
|