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.
Files changed (44) hide show
  1. package/.claude/skills/ucn/SKILL.md +114 -11
  2. package/README.md +152 -156
  3. package/cli/index.js +363 -37
  4. package/core/analysis.js +936 -32
  5. package/core/bridge.js +1111 -0
  6. package/core/brief.js +408 -0
  7. package/core/cache.js +105 -5
  8. package/core/callers.js +72 -18
  9. package/core/check.js +200 -0
  10. package/core/discovery.js +57 -34
  11. package/core/entrypoints.js +638 -4
  12. package/core/execute.js +304 -5
  13. package/core/git-enrich.js +130 -0
  14. package/core/graph.js +24 -2
  15. package/core/output/analysis.js +157 -25
  16. package/core/output/brief.js +100 -0
  17. package/core/output/check.js +79 -0
  18. package/core/output/doctor.js +85 -0
  19. package/core/output/endpoints.js +239 -0
  20. package/core/output/extraction.js +2 -0
  21. package/core/output/find.js +126 -39
  22. package/core/output/graph.js +48 -15
  23. package/core/output/refactoring.js +103 -5
  24. package/core/output/reporting.js +63 -23
  25. package/core/output/search.js +110 -17
  26. package/core/output/shared.js +56 -2
  27. package/core/output.js +4 -0
  28. package/core/parser.js +8 -2
  29. package/core/project.js +39 -3
  30. package/core/registry.js +30 -14
  31. package/core/reporting.js +465 -2
  32. package/core/search.js +130 -10
  33. package/core/shared.js +101 -5
  34. package/core/tracing.js +16 -6
  35. package/core/verify.js +982 -95
  36. package/languages/go.js +91 -6
  37. package/languages/html.js +10 -0
  38. package/languages/java.js +151 -35
  39. package/languages/javascript.js +290 -33
  40. package/languages/python.js +78 -11
  41. package/languages/rust.js +267 -12
  42. package/languages/utils.js +315 -3
  43. package/mcp/server.js +91 -16
  44. package/package.json +9 -1
@@ -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: parseStructuredParams(paramsNode, 'python'),
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: parseStructuredParams(paramsNode, 'python'),
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
- const { name } = symbol;
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
- const receiver = (valueNode?.type === 'identifier' || valueNode?.type === 'self') ? valueNode.text : undefined;
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: node.startPosition.row + 1,
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: node.startPosition.row + 1,
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
- const m = symbol.modifiers || [];
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
  };