ucn 4.0.1 → 4.1.0

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 (47) hide show
  1. package/.claude/skills/ucn/SKILL.md +31 -7
  2. package/README.md +89 -50
  3. package/cli/index.js +199 -94
  4. package/core/account.js +3 -1
  5. package/core/analysis.js +334 -316
  6. package/core/bridge.js +13 -8
  7. package/core/cache.js +109 -19
  8. package/core/callers.js +969 -77
  9. package/core/check.js +19 -8
  10. package/core/deadcode.js +368 -40
  11. package/core/discovery.js +31 -11
  12. package/core/entrypoints.js +149 -17
  13. package/core/execute.js +330 -43
  14. package/core/graph-build.js +61 -10
  15. package/core/graph.js +282 -61
  16. package/core/imports.js +72 -3
  17. package/core/output/analysis-ext.js +70 -10
  18. package/core/output/analysis.js +67 -33
  19. package/core/output/check.js +4 -1
  20. package/core/output/doctor.js +13 -3
  21. package/core/output/endpoints.js +8 -3
  22. package/core/output/extraction.js +12 -1
  23. package/core/output/find.js +23 -9
  24. package/core/output/graph.js +32 -9
  25. package/core/output/refactoring.js +147 -49
  26. package/core/output/reporting.js +104 -5
  27. package/core/output/search.js +30 -3
  28. package/core/output/shared.js +31 -4
  29. package/core/output/tracing.js +22 -11
  30. package/core/parser.js +8 -6
  31. package/core/project.js +167 -7
  32. package/core/registry.js +20 -16
  33. package/core/reporting.js +220 -84
  34. package/core/search.js +270 -55
  35. package/core/shared.js +285 -1
  36. package/core/stacktrace.js +23 -1
  37. package/core/tracing.js +278 -36
  38. package/core/verify.js +352 -349
  39. package/languages/go.js +29 -17
  40. package/languages/index.js +56 -0
  41. package/languages/java.js +133 -16
  42. package/languages/javascript.js +215 -27
  43. package/languages/python.js +113 -47
  44. package/languages/rust.js +89 -26
  45. package/languages/utils.js +35 -7
  46. package/mcp/server.js +72 -31
  47. package/package.json +4 -1
@@ -14,6 +14,7 @@ const {
14
14
  extractJSDocstring,
15
15
  buildTypeAnnotations,
16
16
  visitNameNodes,
17
+ sameNode,
17
18
  } = require('./utils');
18
19
  const { PARSE_OPTIONS, safeParse } = require('./index');
19
20
 
@@ -99,14 +100,37 @@ function getAssignmentName(leftNode) {
99
100
  }
100
101
 
101
102
  /**
102
- * Extract modifiers from function text
103
+ * Extract modifiers from a declaration NODE — AST tokens, never text
104
+ * (fix #249: the first-line regex fabricated export/async/default from
105
+ * string literals, comments, and `m?.default?.()` on one-line functions,
106
+ * leaking fake exports into api/fileExports and corrupting isAsync).
107
+ * Accepts the declaration or its export_statement wrapper.
103
108
  */
104
- function extractModifiers(text) {
109
+ function extractModifiers(node) {
105
110
  const mods = [];
106
- const firstLine = text.split('\n')[0];
107
- if (/\bexport\b/.test(firstLine)) mods.push('export');
108
- if (/\basync\b/.test(firstLine)) mods.push('async');
109
- if (/\bdefault\b/.test(firstLine)) mods.push('default');
111
+ if (!node) return mods;
112
+ let decl = node;
113
+ if (node.type === 'export_statement') {
114
+ mods.push('export');
115
+ decl = node.childForFieldName('declaration') || node;
116
+ }
117
+ // Resolve to the actual function-shaped node: `const x = async () => {}`
118
+ // keeps its async token on the arrow, not the declaration.
119
+ if (decl.type === 'lexical_declaration' || decl.type === 'variable_declaration') {
120
+ const declarator = decl.namedChildren.find(c => c.type === 'variable_declarator');
121
+ const value = declarator && declarator.childForFieldName('value');
122
+ if (value) decl = value;
123
+ }
124
+ let isAsync = false;
125
+ for (let i = 0; i < decl.childCount; i++) {
126
+ if (decl.child(i).type === 'async') { isAsync = true; break; }
127
+ }
128
+ if (isAsync) mods.push('async');
129
+ if (node.type === 'export_statement') {
130
+ for (let i = 0; i < node.childCount; i++) {
131
+ if (node.child(i).type === 'default') { mods.push('default'); break; }
132
+ }
133
+ }
110
134
  return mods;
111
135
  }
112
136
 
@@ -277,8 +301,8 @@ function _processFunction(node, functions, processedRanges, lines) {
277
301
  const typeAnno = buildTypeAnnotations(paramsStructured, returnType, lines, startLine, true);
278
302
  // Check parent for export status (function_declaration inside export_statement)
279
303
  const modifiers = node.parent && node.parent.type === 'export_statement'
280
- ? extractModifiers(node.parent.text)
281
- : extractModifiers(node.text);
304
+ ? extractModifiers(node.parent)
305
+ : extractModifiers(node);
282
306
  // Feature B: explicit isAsync flag (auditAsync needs to know whether
283
307
  // the fn was declared `async function`).
284
308
  const isAsync = modifiers.includes('async');
@@ -364,8 +388,8 @@ function _processFunction(node, functions, processedRanges, lines) {
364
388
  const typeAnno = buildTypeAnnotations(paramsStructured, returnType, lines, startLine, true);
365
389
  // Check parent for export status (lexical_declaration inside export_statement)
366
390
  const modifiers = node.parent && node.parent.type === 'export_statement'
367
- ? extractModifiers(node.parent.text)
368
- : extractModifiers(node.text);
391
+ ? extractModifiers(node.parent)
392
+ : extractModifiers(node);
369
393
  // Feature B: detect async — for arrow/fn-expressions the `async`
370
394
  // keyword precedes the parameter list on the value node, NOT on
371
395
  // the lexical_declaration text. extractModifiers walked the full
@@ -417,8 +441,8 @@ function _processFunction(node, functions, processedRanges, lines) {
417
441
  const paramsStructured = parseStructuredParams(paramsNode, 'javascript');
418
442
  const typeAnno = buildTypeAnnotations(paramsStructured, returnType, lines, startLine, true);
419
443
  const modifiers = node.parent && node.parent.type === 'export_statement'
420
- ? extractModifiers(node.parent.text)
421
- : extractModifiers(node.text);
444
+ ? extractModifiers(node.parent)
445
+ : extractModifiers(node);
422
446
 
423
447
  functions.push({
424
448
  name: nameNode.text,
@@ -509,6 +533,48 @@ function _processFunction(node, functions, processedRanges, lines) {
509
533
  ...(docstring && { docstring })
510
534
  });
511
535
  }
536
+ } else if (rightNode.type === 'object') {
537
+ // CJS export object maps (fix #252): the functions in
538
+ // `module.exports = { doThing(x) {...}, h: function() {...} }`
539
+ // are the module's public API — prototype and exports.h
540
+ // assignments were indexed while this shape was invisible
541
+ // to fn/find/toc.
542
+ const lhsText = leftNode.text;
543
+ if (lhsText === 'module.exports' || lhsText === 'exports' ||
544
+ lhsText.startsWith('module.exports.') || lhsText.startsWith('exports.')) {
545
+ processedRanges.add(rangeKey);
546
+ for (let pi = 0; pi < rightNode.namedChildCount; pi++) {
547
+ const prop = rightNode.namedChild(pi);
548
+ let nameNode = null;
549
+ let fnNode = null;
550
+ if (prop.type === 'method_definition') {
551
+ nameNode = prop.childForFieldName('name');
552
+ fnNode = prop;
553
+ } else if (prop.type === 'pair') {
554
+ const v = prop.childForFieldName('value');
555
+ if (v && (v.type === 'function_expression' ||
556
+ v.type === 'arrow_function' || v.type === 'generator_function')) {
557
+ nameNode = prop.childForFieldName('key');
558
+ fnNode = v;
559
+ }
560
+ }
561
+ if (!nameNode || !fnNode) continue;
562
+ const paramsNode = fnNode.childForFieldName('parameters');
563
+ const { startLine, endLine, indent } = nodeToLocation(prop, lines);
564
+ const paramsStructured = parseStructuredParams(paramsNode, 'javascript');
565
+ functions.push({
566
+ name: nameNode.text,
567
+ params: extractParams(paramsNode),
568
+ paramsStructured,
569
+ startLine,
570
+ endLine,
571
+ indent,
572
+ isArrow: fnNode.type === 'arrow_function',
573
+ isGenerator: isGenerator(fnNode),
574
+ modifiers: [],
575
+ });
576
+ }
577
+ }
512
578
  }
513
579
  }
514
580
  return true;
@@ -959,6 +1025,13 @@ function extractClassMembers(classNode, codeOrLines) {
959
1025
  const typeAnno = buildTypeAnnotations(paramsStructured, returnType, code, startLine, true);
960
1026
 
961
1027
  const decoratorsWithArgs = extractDecoratorsWithArgs(child);
1028
+ // TS accessibility keywords (fix #247): `private`/`protected`
1029
+ // members are not public API — without this, deadcode's
1030
+ // exported-member check treated them as implicitly public and
1031
+ // hid them from the default audit. `public` is the default
1032
+ // and stays unrecorded (recording it would read as an export
1033
+ // marker in symbolIsExported).
1034
+ const accessMatch = text.match(/^\s*(private|protected)\s/);
962
1035
  members.push({
963
1036
  name,
964
1037
  params: extractParams(paramsNode),
@@ -966,9 +1039,14 @@ function extractClassMembers(classNode, codeOrLines) {
966
1039
  startLine,
967
1040
  endLine,
968
1041
  memberType,
1042
+ ...(accessMatch && { modifiers: [accessMatch[1]] }),
969
1043
  isAsync,
970
1044
  isGenerator: isGen,
971
1045
  isMethod: true, // Mark as method for context() lookups
1046
+ // TS method OVERLOAD signatures (body-less method_signature
1047
+ // in a class body) mirror the standalone-function marker
1048
+ // (fix #230) — pickBestDefinition prefers the implementation.
1049
+ ...(child.type === 'method_signature' && { isSignature: true }),
972
1050
  ...typeAnno,
973
1051
  ...(docstring && { docstring }),
974
1052
  ...(decorators.length > 0 && { decorators }),
@@ -1657,6 +1735,25 @@ function findCallsInCode(code, parser) {
1657
1735
  localVarTypes.set(left.text, ctorName);
1658
1736
  }
1659
1737
  }
1738
+ // Handler-registration references (fix #252, the #221 family's
1739
+ // missing shape): `window.onload = secondPageInit` /
1740
+ // `element.onclick = handler` establish the call relationship
1741
+ // through property assignment — plain assignment RHS and
1742
+ // argument-position references were captured, the
1743
+ // member-expression LHS shape recorded nothing, so search
1744
+ // --unused claimed live handlers dead.
1745
+ if (left?.type === 'member_expression' && right?.type === 'identifier' &&
1746
+ !SKIP_IDENTS.has(right.text) && !nonCallableNames.has(right.text)) {
1747
+ calls.push({
1748
+ name: right.text,
1749
+ line: right.startPosition.row + 1,
1750
+ isMethod: false,
1751
+ isFunctionReference: true,
1752
+ isPotentialCallback: true,
1753
+ ...(isShadowedByLocal(right, right.text) && { localShadow: true }),
1754
+ enclosingFunction: getCurrentEnclosingFunction(),
1755
+ });
1756
+ }
1660
1757
  }
1661
1758
 
1662
1759
  // Handle regular function calls: foo(), obj.foo(), foo.call()
@@ -1694,6 +1791,21 @@ function findCallsInCode(code, parser) {
1694
1791
  ...(firstArg && { firstStringArg: firstArg.value, firstStringArgInterp: firstArg.interp }),
1695
1792
  ...(optionsMethod && { optionsMethod })
1696
1793
  });
1794
+ } else if (funcNode.type === 'super') {
1795
+ // super(config) — the subclass constructor invoking the
1796
+ // parent class's constructor (fix #238; these sites were
1797
+ // invisible to every command). Recorded as a super-received
1798
+ // 'constructor' method call so the super walk resolves it to
1799
+ // the parent class's constructor definition.
1800
+ calls.push({
1801
+ name: 'constructor',
1802
+ line: node.startPosition.row + 1,
1803
+ isMethod: true,
1804
+ receiver: 'super',
1805
+ argCount: node.childForFieldName('arguments')?.namedChildCount ?? 0,
1806
+ enclosingFunction,
1807
+ uncertain: false,
1808
+ });
1697
1809
  } else if (funcNode.type === 'member_expression') {
1698
1810
  // Method call: obj.foo() or foo.call/apply/bind()
1699
1811
  const propNode = funcNode.childForFieldName('property');
@@ -2238,6 +2350,21 @@ function findImportsInCode(code, parser) {
2238
2350
  const text = child.text;
2239
2351
  modulePath = text.slice(1, -1);
2240
2352
  }
2353
+ // TS import-equals: `import x = require('./y')` — the
2354
+ // dependency edge was invisible to imports/exporters/graph/
2355
+ // circularDeps (fix #245; `export = fn` was already captured).
2356
+ if (child.type === 'import_require_clause') {
2357
+ let alias = null, src = null;
2358
+ for (let j = 0; j < child.namedChildCount; j++) {
2359
+ const c = child.namedChild(j);
2360
+ if (c.type === 'identifier') alias = c.text;
2361
+ if (c.type === 'string') src = c.text.slice(1, -1);
2362
+ }
2363
+ if (src) {
2364
+ imports.push({ module: src, names: alias ? [alias] : [], type: 'require', line });
2365
+ }
2366
+ return true;
2367
+ }
2241
2368
  if (child.type === 'import_clause') {
2242
2369
  // Process import clause
2243
2370
  for (let j = 0; j < child.namedChildCount; j++) {
@@ -2475,11 +2602,42 @@ function findExportsInCode(code, parser) {
2475
2602
  if (nameNode) {
2476
2603
  exports.push({ name: nameNode.text, type: isDefaultExport ? 'default' : 'named', line });
2477
2604
  }
2478
- } else if (child.type === 'class_declaration') {
2605
+ } else if (child.type === 'class_declaration' || child.type === 'abstract_class_declaration') {
2606
+ // tree-sitter-typescript emits abstract_class_declaration
2607
+ // for `export abstract class X` — the symbol extractor
2608
+ // knew the node type, the export scanner did not (fix
2609
+ // #245: the class was never recorded as an export).
2479
2610
  const nameNode = child.childForFieldName('name');
2480
2611
  if (nameNode) {
2481
2612
  exports.push({ name: nameNode.text, type: isDefaultExport ? 'default' : 'named', line });
2482
2613
  }
2614
+ } else if (child.type === 'ambient_declaration') {
2615
+ // export declare function/class/const X — the ambient
2616
+ // wrapper holds the real declaration (fix #245).
2617
+ for (let j = 0; j < child.namedChildCount; j++) {
2618
+ const inner = child.namedChild(j);
2619
+ const nameNode = inner.childForFieldName?.('name');
2620
+ if (nameNode) {
2621
+ exports.push({ name: nameNode.text, type: 'named', line });
2622
+ } else if (inner.type === 'lexical_declaration' || inner.type === 'variable_declaration') {
2623
+ for (let k = 0; k < inner.namedChildCount; k++) {
2624
+ const d = inner.namedChild(k);
2625
+ const n = d.type === 'variable_declarator' && d.childForFieldName('name');
2626
+ if (n && n.type === 'identifier') {
2627
+ exports.push({ name: n.text, type: 'named', line, isVariable: true, declKind: 'declare' });
2628
+ }
2629
+ }
2630
+ }
2631
+ }
2632
+ } else if (child.type === 'internal_module' || child.type === 'module') {
2633
+ // export namespace Geo { ... } — the NAMESPACE is the
2634
+ // importable name; its inner members are reached as
2635
+ // Geo.member (fix #245: only inner names were listed,
2636
+ // none of them importable).
2637
+ const nameNode = child.childForFieldName('name');
2638
+ if (nameNode) {
2639
+ exports.push({ name: nameNode.text, type: 'named', line });
2640
+ }
2483
2641
  } else if (child.type === 'type_alias_declaration') {
2484
2642
  // export type X = ...
2485
2643
  const nameNode = child.childForFieldName('name');
@@ -2554,6 +2712,14 @@ function findExportsInCode(code, parser) {
2554
2712
  } else if (prop.type === 'pair') {
2555
2713
  const key = prop.childForFieldName('key');
2556
2714
  if (key) exports.push({ name: key.text, type: 'module.exports', line });
2715
+ } else if (prop.type === 'method_definition') {
2716
+ // Shorthand methods are exports too
2717
+ // (fix #252 — `module.exports =
2718
+ // { doThing(x) {} }` was invisible to the
2719
+ // export list, so deadcode audited a
2720
+ // require()-reachable function).
2721
+ const mName = prop.childForFieldName('name');
2722
+ if (mName) exports.push({ name: mName.text, type: 'module.exports', line });
2557
2723
  }
2558
2724
  }
2559
2725
  } else if (rightNode && rightNode.type === 'identifier') {
@@ -2594,18 +2760,23 @@ function findExportsInCode(code, parser) {
2594
2760
  * @param {string} code - Source code
2595
2761
  * @param {string} name - Symbol name to find
2596
2762
  * @param {object} parser - Tree-sitter parser instance
2763
+ * @param {object} [tree] - Pre-parsed tree (per-operation cache); parsed here when absent
2597
2764
  * @returns {Array<{line: number, column: number, usageType: string}>}
2598
2765
  */
2599
- function findUsagesInCode(code, name, parser) {
2600
- const tree = parseTree(parser, code);
2766
+ function findUsagesInCode(code, name, parser, tree) {
2767
+ tree = tree || parseTree(parser, code);
2601
2768
  const usages = [];
2602
2769
 
2603
2770
  visitNameNodes(tree, code, name, (node) => {
2604
2771
  // Look for identifier, property_identifier (method names in obj.method() calls),
2605
- // type_identifier (TypeScript type annotations), and shorthand_property_identifier_pattern
2606
- // (destructured names in `const { name } = require(...)`)
2772
+ // type_identifier (TypeScript type annotations), shorthand_property_identifier_pattern
2773
+ // (destructured names in `const { name } = require(...)`), and
2774
+ // shorthand_property_identifier (value-position shorthand — CJS export
2775
+ // objects `module.exports = { helper }` and option objects `f({ helper })`
2776
+ // reference the symbol but produced no usage record at all, fix #241)
2607
2777
  const isIdentifier = node.type === 'identifier' || node.type === 'property_identifier' ||
2608
- node.type === 'type_identifier' || node.type === 'shorthand_property_identifier_pattern';
2778
+ node.type === 'type_identifier' || node.type === 'shorthand_property_identifier_pattern' ||
2779
+ node.type === 'shorthand_property_identifier';
2609
2780
  if (!isIdentifier || node.text !== name) {
2610
2781
  return true;
2611
2782
  }
@@ -2626,38 +2797,55 @@ function findUsagesInCode(code, name, parser) {
2626
2797
  }
2627
2798
  // Call: identifier is function in call_expression
2628
2799
  else if (parent.type === 'call_expression' &&
2629
- parent.childForFieldName('function') === node) {
2800
+ sameNode(parent.childForFieldName('function'), node)) {
2630
2801
  usageType = 'call';
2631
2802
  }
2632
2803
  // New expression: identifier is constructor
2633
2804
  else if (parent.type === 'new_expression' &&
2634
- parent.childForFieldName('constructor') === node) {
2805
+ sameNode(parent.childForFieldName('constructor'), node)) {
2635
2806
  usageType = 'call';
2636
2807
  }
2637
2808
  // Definition: function name in declaration
2638
2809
  else if ((parent.type === 'function_declaration' ||
2639
2810
  parent.type === 'generator_function_declaration') &&
2640
- parent.childForFieldName('name') === node) {
2811
+ sameNode(parent.childForFieldName('name'), node)) {
2641
2812
  usageType = 'definition';
2642
2813
  }
2643
- // Definition: variable name in declarator (left side of =)
2814
+ // Definition: variable name in declarator (left side of =).
2815
+ // When the right side is require()/import() the line IS the import
2816
+ // of the symbol — `const Service = require('./service')` classified
2817
+ // as 'definition' made the project's only import invisible (fix #241).
2644
2818
  else if (parent.type === 'variable_declarator' &&
2645
- parent.childForFieldName('name') === node) {
2819
+ sameNode(parent.childForFieldName('name'), node)) {
2646
2820
  usageType = 'definition';
2821
+ let value = parent.childForFieldName('value');
2822
+ if (value && value.type === 'await_expression') {
2823
+ value = value.namedChild(0);
2824
+ }
2825
+ // Unwrap require('./x').member — still an import binding
2826
+ if (value && value.type === 'member_expression') {
2827
+ value = value.childForFieldName('object');
2828
+ }
2829
+ if (value && value.type === 'call_expression') {
2830
+ const func = value.childForFieldName('function');
2831
+ if (func && (func.text === 'require' || func.type === 'import')) {
2832
+ usageType = 'import';
2833
+ }
2834
+ }
2647
2835
  }
2648
2836
  // Definition: class name
2649
2837
  else if (parent.type === 'class_declaration' &&
2650
- parent.childForFieldName('name') === node) {
2838
+ sameNode(parent.childForFieldName('name'), node)) {
2651
2839
  usageType = 'definition';
2652
2840
  }
2653
2841
  // Definition: method name
2654
2842
  else if (parent.type === 'method_definition' &&
2655
- parent.childForFieldName('name') === node) {
2843
+ sameNode(parent.childForFieldName('name'), node)) {
2656
2844
  usageType = 'definition';
2657
2845
  }
2658
2846
  // Definition: function expression name (named function expressions)
2659
2847
  else if (parent.type === 'function' &&
2660
- parent.childForFieldName('name') === node) {
2848
+ sameNode(parent.childForFieldName('name'), node)) {
2661
2849
  usageType = 'definition';
2662
2850
  }
2663
2851
  // Require: identifier is the name in require('...')
@@ -2689,7 +2877,7 @@ function findUsagesInCode(code, name, parser) {
2689
2877
  }
2690
2878
  // Property access (method call): a.name() - the name after dot
2691
2879
  else if (parent.type === 'member_expression' &&
2692
- parent.childForFieldName('property') === node) {
2880
+ sameNode(parent.childForFieldName('property'), node)) {
2693
2881
  // Skip built-in objects and common module names (JSON.parse, path.parse, etc.)
2694
2882
  const object = parent.childForFieldName('object');
2695
2883
  const builtins = [
@@ -13,6 +13,7 @@ const {
13
13
  extractPythonDocstring,
14
14
  paramTypesFromStructured,
15
15
  visitNameNodes,
16
+ sameNode,
16
17
  } = require('./utils');
17
18
  const { PARSE_OPTIONS, safeParse } = require('./index');
18
19
 
@@ -58,10 +59,13 @@ function getIndent(node, code) {
58
59
  * Extract Python parameters
59
60
  */
60
61
  function extractPythonParams(paramsNode) {
62
+ // Distinguish "we have no node" (genuinely unknown) from "node is empty".
63
+ // Returning '...' for empty parens conflated zero-param functions with
64
+ // unknown signatures in JSON output (fix #241; go/rust got this in #238,
65
+ // the shared utils.extractParams already had it).
61
66
  if (!paramsNode) return '...';
62
67
  const text = paramsNode.text;
63
68
  let params = text.replace(/^\(|\)$/g, '').trim();
64
- if (!params) return '...';
65
69
  return params;
66
70
  }
67
71
 
@@ -297,6 +301,52 @@ function extractDecorators(node) {
297
301
  return decorators;
298
302
  }
299
303
 
304
+
305
+ /**
306
+ * Type aliases (fix #251 — rule 7: TS/Rust/Go aliases are indexed, Python's
307
+ * were invisible to typedef/find): PEP 695 `type X = int` and annotated
308
+ * `X: TypeAlias = ...` assignments become 'type' symbols with aliasOf.
309
+ */
310
+ function _processTypeAlias(node, classes, processedRanges, lines) {
311
+ let name = null;
312
+ let valueText = null;
313
+ if (node.type === 'type_alias_statement') {
314
+ // grammar shape: type <left> = <right>
315
+ const left = node.namedChild(0);
316
+ const right = node.namedChild(1);
317
+ if (!left) return false;
318
+ name = left.text.replace(/\[.*\]$/, ''); // strip PEP 695 type params
319
+ valueText = right ? right.text : null;
320
+ } else if (node.type === 'expression_statement') {
321
+ const child = node.namedChild(0);
322
+ if (!child || child.type !== 'assignment') return false;
323
+ const typeNode = child.childForFieldName('type');
324
+ if (!typeNode || !/\bTypeAlias\b/.test(typeNode.text)) return false;
325
+ const leftNode = child.childForFieldName('left');
326
+ const rightNode = child.childForFieldName('right');
327
+ if (!leftNode || leftNode.type !== 'identifier') return false;
328
+ name = leftNode.text;
329
+ valueText = rightNode ? rightNode.text : null;
330
+ } else {
331
+ return false;
332
+ }
333
+ if (!name) return false;
334
+ const rangeKey = `${node.startIndex}-${node.endIndex}`;
335
+ if (processedRanges.has(rangeKey)) return true;
336
+ processedRanges.add(rangeKey);
337
+ const { startLine, endLine } = nodeToLocation(node, lines);
338
+ classes.push({
339
+ name,
340
+ type: 'type',
341
+ startLine,
342
+ endLine,
343
+ methods: [],
344
+ members: [],
345
+ ...(valueText && { aliasOf: valueText.trim() }),
346
+ });
347
+ return true;
348
+ }
349
+
300
350
  /**
301
351
  * Find all classes in Python code using tree-sitter
302
352
  */
@@ -306,7 +356,8 @@ function findClasses(code, parser) {
306
356
  const classes = [];
307
357
  const processedRanges = new Set();
308
358
  traverseTreeCached(tree.rootNode, (node) => {
309
- _processClass(node, classes, processedRanges, lines);
359
+ _processClass(node, classes, processedRanges, lines) ||
360
+ _processTypeAlias(node, classes, processedRanges, lines);
310
361
  return true;
311
362
  });
312
363
  classes.sort((a, b) => a.startLine - b.startLine);
@@ -460,7 +511,8 @@ function parse(code, parser) {
460
511
 
461
512
  traverseTreeCached(tree.rootNode, (node) => {
462
513
  _processFunction(node, functions, processedFn, lines, code);
463
- _processClass(node, classes, processedCls, lines);
514
+ _processClass(node, classes, processedCls, lines) ||
515
+ _processTypeAlias(node, classes, processedCls, lines);
464
516
  _processState(node, stateObjects, lines);
465
517
  _processModuleAssign(node, moduleAssigned);
466
518
  return true;
@@ -1344,10 +1396,11 @@ function findExportsInCode(code, parser) {
1344
1396
  * @param {string} code - Source code
1345
1397
  * @param {string} name - Symbol name to find
1346
1398
  * @param {object} parser - Tree-sitter parser instance
1399
+ * @param {object} [tree] - Pre-parsed tree (per-operation cache); parsed here when absent
1347
1400
  * @returns {Array<{line: number, column: number, usageType: string}>}
1348
1401
  */
1349
- function findUsagesInCode(code, name, parser) {
1350
- const tree = parseTree(parser, code);
1402
+ function findUsagesInCode(code, name, parser, tree) {
1403
+ tree = tree || parseTree(parser, code);
1351
1404
  const usages = [];
1352
1405
 
1353
1406
  visitNameNodes(tree, code, name, (node) => {
@@ -1378,17 +1431,17 @@ function findUsagesInCode(code, name, parser) {
1378
1431
  }
1379
1432
  // Call: name()
1380
1433
  else if (parent.type === 'call' &&
1381
- parent.childForFieldName('function') === node) {
1434
+ sameNode(parent.childForFieldName('function'), node)) {
1382
1435
  usageType = 'call';
1383
1436
  }
1384
1437
  // Definition: def name(...):
1385
1438
  else if (parent.type === 'function_definition' &&
1386
- parent.childForFieldName('name') === node) {
1439
+ sameNode(parent.childForFieldName('name'), node)) {
1387
1440
  usageType = 'definition';
1388
1441
  }
1389
1442
  // Definition: class name:
1390
1443
  else if (parent.type === 'class_definition' &&
1391
- parent.childForFieldName('name') === node) {
1444
+ sameNode(parent.childForFieldName('name'), node)) {
1392
1445
  usageType = 'definition';
1393
1446
  }
1394
1447
  // Definition: parameter
@@ -1400,17 +1453,17 @@ function findUsagesInCode(code, name, parser) {
1400
1453
  }
1401
1454
  // Definition: assignment target (x = ...)
1402
1455
  else if (parent.type === 'assignment' &&
1403
- parent.childForFieldName('left') === node) {
1456
+ sameNode(parent.childForFieldName('left'), node)) {
1404
1457
  usageType = 'definition';
1405
1458
  }
1406
1459
  // Definition: for loop variable
1407
1460
  else if (parent.type === 'for_statement' &&
1408
- parent.childForFieldName('left') === node) {
1461
+ sameNode(parent.childForFieldName('left'), node)) {
1409
1462
  usageType = 'definition';
1410
1463
  }
1411
1464
  // Method call: obj.name()
1412
1465
  else if (parent.type === 'attribute' &&
1413
- parent.childForFieldName('attribute') === node) {
1466
+ sameNode(parent.childForFieldName('attribute'), node)) {
1414
1467
  const grandparent = parent.parent;
1415
1468
  if (grandparent && grandparent.type === 'call') {
1416
1469
  usageType = 'call';
@@ -1423,6 +1476,19 @@ function findUsagesInCode(code, name, parser) {
1423
1476
  usages.push({ line, column, usageType, receiver: object.text });
1424
1477
  return true;
1425
1478
  }
1479
+ // self.attr receiver (unittest setUp idiom: self.w = Widget(3);
1480
+ // self.w.render()) — record the ATTR name so the instance-type
1481
+ // map built from the assignment line ('w' → Widget) matches
1482
+ // (fix #244: class-scoped tests dropped these calls entirely).
1483
+ if (object && object.type === 'attribute') {
1484
+ const innerObj = object.childForFieldName('object');
1485
+ const innerAttr = object.childForFieldName('attribute');
1486
+ if (innerObj && innerObj.type === 'identifier' &&
1487
+ ['self', 'cls'].includes(innerObj.text) && innerAttr) {
1488
+ usages.push({ line, column, usageType, receiver: innerAttr.text });
1489
+ return true;
1490
+ }
1491
+ }
1426
1492
  }
1427
1493
  }
1428
1494
 
@@ -1458,43 +1524,43 @@ function findInstanceAttributeTypes(code, parser) {
1458
1524
 
1459
1525
  const attrTypes = new Map();
1460
1526
 
1461
- // Check for @dataclass decorator scan annotated class-level fields
1527
+ // Scan annotated class-level fields: name: Type [= ...]. Originally
1528
+ // @dataclass-only (fix #28); a BARE class-body annotation is the
1529
+ // same compiler-visible typing evidence (`engine: Engine` inside
1530
+ // class Car types self.engine — pyright enforces it), so those scan
1531
+ // for every class (fix #234). Annotations WITH a value stay
1532
+ // dataclass-only: `tracker: BracketTracker = None` in a plain class
1533
+ // is routinely rebound to something else per-instance, while a
1534
+ // dataclass field default is constructor-managed.
1462
1535
  const parentNode = node.parent;
1463
- if (parentNode?.type === 'decorated_definition') {
1464
- for (let d = 0; d < parentNode.childCount; d++) {
1465
- const dec = parentNode.child(d);
1466
- if (dec.type !== 'decorator') continue;
1467
- // Match @dataclass or @dataclasses.dataclass
1468
- const decText = dec.text;
1469
- if (decText.startsWith('@dataclass') || decText.includes('.dataclass')) {
1470
- // Scan class body for annotated fields: name: Type = ...
1471
- for (let i = 0; i < body.childCount; i++) {
1472
- const stmt = body.child(i);
1473
- if (stmt.type !== 'expression_statement') continue;
1474
- const assign = stmt.firstChild;
1475
- if (!assign || assign.type !== 'assignment') continue;
1476
-
1477
- // Must have a type annotation
1478
- const typeNode = assign.childForFieldName('type');
1479
- if (!typeNode) continue;
1480
-
1481
- // Extract type name from annotation
1482
- const typeIdent = typeNode.type === 'type' ? typeNode.firstChild : typeNode;
1483
- if (!typeIdent || typeIdent.type !== 'identifier') continue;
1484
- const typeName = typeIdent.text;
1485
-
1486
- // Skip primitives and lowercase types
1487
- if (PRIMITIVE_TYPES.has(typeName)) continue;
1488
- if (typeName[0] < 'A' || typeName[0] > 'Z') continue;
1489
-
1490
- // Field name from LHS
1491
- const lhs = assign.childForFieldName('left');
1492
- if (!lhs || lhs.type !== 'identifier') continue;
1493
- attrTypes.set(lhs.text, typeName);
1494
- }
1495
- break;
1496
- }
1497
- }
1536
+ const isDataclass = parentNode?.type === 'decorated_definition' &&
1537
+ Array.from({ length: parentNode.childCount }, (_, d) => parentNode.child(d))
1538
+ .some(dec => dec.type === 'decorator' &&
1539
+ (dec.text.startsWith('@dataclass') || dec.text.includes('.dataclass')));
1540
+ for (let i = 0; i < body.childCount; i++) {
1541
+ const stmt = body.child(i);
1542
+ if (stmt.type !== 'expression_statement') continue;
1543
+ const assign = stmt.firstChild;
1544
+ if (!assign || assign.type !== 'assignment') continue;
1545
+
1546
+ // Must have a type annotation
1547
+ const typeNode = assign.childForFieldName('type');
1548
+ if (!typeNode) continue;
1549
+ if (!isDataclass && assign.childForFieldName('right')) continue;
1550
+
1551
+ // Extract type name from annotation
1552
+ const typeIdent = typeNode.type === 'type' ? typeNode.firstChild : typeNode;
1553
+ if (!typeIdent || typeIdent.type !== 'identifier') continue;
1554
+ const typeName = typeIdent.text;
1555
+
1556
+ // Skip primitives and lowercase types
1557
+ if (PRIMITIVE_TYPES.has(typeName)) continue;
1558
+ if (typeName[0] < 'A' || typeName[0] > 'Z') continue;
1559
+
1560
+ // Field name from LHS
1561
+ const lhs = assign.childForFieldName('left');
1562
+ if (!lhs || lhs.type !== 'identifier') continue;
1563
+ attrTypes.set(lhs.text, typeName);
1498
1564
  }
1499
1565
 
1500
1566
  // Scan __init__ for self.X = ClassName(...) assignments