ucn 3.3.0 → 3.4.1

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/cli/index.js CHANGED
@@ -806,7 +806,7 @@ function runProjectCommand(rootDir, command, arg) {
806
806
 
807
807
  case 'verify': {
808
808
  requireArg(arg, 'Usage: ucn . verify <name>');
809
- const verifyResult = index.verify(arg);
809
+ const verifyResult = index.verify(arg, { file: flags.file });
810
810
  printOutput(verifyResult, r => JSON.stringify(r, null, 2), output.formatVerify);
811
811
  break;
812
812
  }
package/core/imports.js CHANGED
@@ -122,6 +122,15 @@ function resolveImport(importPath, fromFile, config = {}) {
122
122
  if (resolved) return resolved;
123
123
  }
124
124
 
125
+ // Python: non-relative package imports (e.g., "tools.analyzer" -> "tools/analyzer.py")
126
+ // Try resolving dotted module path from the project root
127
+ if (config.language === 'python' && config.root) {
128
+ const modulePath = importPath.replace(/\./g, '/');
129
+ const fullPath = path.join(config.root, modulePath);
130
+ const resolved = resolveFilePath(fullPath, getExtensions('python'));
131
+ if (resolved) return resolved;
132
+ }
133
+
125
134
  return null; // External package
126
135
  }
127
136
 
package/core/project.js CHANGED
@@ -249,8 +249,14 @@ class ProjectIndex {
249
249
 
250
250
  for (const [filePath, fileEntry] of this.files) {
251
251
  const importedFiles = [];
252
+ const seenModules = new Set();
252
253
 
253
254
  for (const importModule of fileEntry.imports) {
255
+ // Deduplicate: same module imported multiple times in one file
256
+ // (e.g., lazy imports inside different functions)
257
+ if (seenModules.has(importModule)) continue;
258
+ seenModules.add(importModule);
259
+
254
260
  let resolved = resolveImport(importModule, filePath, {
255
261
  aliases: this.config.aliases,
256
262
  language: fileEntry.language,
@@ -344,10 +350,14 @@ class ProjectIndex {
344
350
  */
345
351
  matchesFilters(filePath, filters = {}) {
346
352
  // Check exclusions (patterns like 'test', 'mock', 'spec')
353
+ // Uses path-segment boundary matching to avoid false positives
354
+ // (e.g. 'test' should NOT match 'backtester', but should match 'tests/', 'test_foo', '_test.')
347
355
  if (filters.exclude && filters.exclude.length > 0) {
348
356
  const lowerPath = filePath.toLowerCase();
349
357
  for (const pattern of filters.exclude) {
350
- if (lowerPath.includes(pattern.toLowerCase())) {
358
+ const escaped = pattern.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
359
+ const regex = new RegExp(`(^|/)${escaped}|[_.\\-]${escaped}([_.\\-/]|$)`);
360
+ if (regex.test(lowerPath)) {
351
361
  return false;
352
362
  }
353
363
  }
@@ -999,11 +1009,25 @@ class ProjectIndex {
999
1009
 
1000
1010
  // Smart method call handling
1001
1011
  if (call.isMethod) {
1002
- // Always skip this/self/cls calls (internal state access, not function calls)
1003
- if (['this', 'self', 'cls'].includes(call.receiver)) continue;
1004
- // Go doesn't use this/self/cls - always include Go method calls
1005
- // For other languages, skip method calls unless explicitly requested
1006
- if (fileEntry.language !== 'go' && !options.includeMethods) continue;
1012
+ if (call.selfAttribute && fileEntry.language === 'python') {
1013
+ // self.attr.method() — resolve via attribute type inference
1014
+ const callerSymbol = this.findEnclosingFunction(filePath, call.line, true);
1015
+ if (!callerSymbol?.className) continue;
1016
+ const attrTypes = this.getInstanceAttributeTypes(filePath, callerSymbol.className);
1017
+ if (!attrTypes) continue;
1018
+ const targetClass = attrTypes.get(call.selfAttribute);
1019
+ if (!targetClass) continue;
1020
+ // Check if any definition of searched function belongs to targetClass
1021
+ const matchesDef = definitions.some(d => d.className === targetClass);
1022
+ if (!matchesDef) continue;
1023
+ // Falls through to add as caller
1024
+ } else {
1025
+ // Always skip this/self/cls calls (internal state access, not function calls)
1026
+ if (['this', 'self', 'cls'].includes(call.receiver)) continue;
1027
+ // Go doesn't use this/self/cls - always include Go method calls
1028
+ // For other languages, skip method calls unless explicitly requested
1029
+ if (fileEntry.language !== 'go' && !options.includeMethods) continue;
1030
+ }
1007
1031
  }
1008
1032
 
1009
1033
  // Skip definition lines
@@ -1100,6 +1124,7 @@ class ProjectIndex {
1100
1124
  const language = fileEntry?.language;
1101
1125
 
1102
1126
  const callees = new Map(); // key -> { name, bindingId, count }
1127
+ let selfAttrCalls = null; // collected for Python self.attr.method() resolution
1103
1128
 
1104
1129
  for (const call of calls) {
1105
1130
  // Filter to calls within this function's scope using enclosingFunction
@@ -1109,14 +1134,26 @@ class ProjectIndex {
1109
1134
 
1110
1135
  // Smart method call handling:
1111
1136
  // - Go: include all method calls (Go doesn't use this/self/cls)
1137
+ // - Python self.attr.method(): resolve via selfAttribute (handled below)
1112
1138
  // - Other languages: skip method calls unless explicitly requested
1113
1139
  if (call.isMethod) {
1114
- if (language !== 'go' && !options.includeMethods) continue;
1140
+ if (call.selfAttribute && language === 'python') {
1141
+ // Will be resolved in second pass below
1142
+ } else if (language !== 'go' && !options.includeMethods) {
1143
+ continue;
1144
+ }
1115
1145
  }
1116
1146
 
1117
1147
  // Skip keywords and built-ins
1118
1148
  if (this.isKeyword(call.name, language)) continue;
1119
1149
 
1150
+ // Collect selfAttribute calls for second-pass resolution
1151
+ if (call.selfAttribute && language === 'python') {
1152
+ if (!selfAttrCalls) selfAttrCalls = [];
1153
+ selfAttrCalls.push(call);
1154
+ continue;
1155
+ }
1156
+
1120
1157
  // Resolve binding within this file (without mutating cached call objects)
1121
1158
  let calleeKey = call.bindingId || call.name;
1122
1159
  let bindingResolved = call.bindingId;
@@ -1169,6 +1206,36 @@ class ProjectIndex {
1169
1206
  }
1170
1207
  }
1171
1208
 
1209
+ // Second pass: resolve Python self.attr.method() calls
1210
+ if (selfAttrCalls && def.className) {
1211
+ const attrTypes = this.getInstanceAttributeTypes(def.file, def.className);
1212
+ if (attrTypes) {
1213
+ for (const call of selfAttrCalls) {
1214
+ const targetClass = attrTypes.get(call.selfAttribute);
1215
+ if (!targetClass) continue;
1216
+
1217
+ // Find method in symbol table where className matches
1218
+ const symbols = this.symbols.get(call.name);
1219
+ if (!symbols) continue;
1220
+
1221
+ const match = symbols.find(s => s.className === targetClass);
1222
+ if (!match) continue;
1223
+
1224
+ const key = match.bindingId || `${targetClass}.${call.name}`;
1225
+ const existing = callees.get(key);
1226
+ if (existing) {
1227
+ existing.count += 1;
1228
+ } else {
1229
+ callees.set(key, {
1230
+ name: call.name,
1231
+ bindingId: match.bindingId,
1232
+ count: 1
1233
+ });
1234
+ }
1235
+ }
1236
+ }
1237
+ }
1238
+
1172
1239
  // Look up each callee in the symbol table
1173
1240
  // For methods, prefer callees from: 1) same file, 2) same package, 3) same receiver type
1174
1241
  const result = [];
@@ -1480,8 +1547,9 @@ class ProjectIndex {
1480
1547
  const fileEntry = this.files.get(filePath);
1481
1548
  if (!fileEntry) return null;
1482
1549
 
1550
+ const nonCallableTypes = new Set(['class', 'struct', 'interface', 'type', 'state']);
1483
1551
  for (const symbol of fileEntry.symbols) {
1484
- if (symbol.type === 'function' &&
1552
+ if (!nonCallableTypes.has(symbol.type) &&
1485
1553
  symbol.startLine <= lineNum &&
1486
1554
  symbol.endLine >= lineNum) {
1487
1555
  if (returnSymbol) {
@@ -1493,6 +1561,35 @@ class ProjectIndex {
1493
1561
  return null;
1494
1562
  }
1495
1563
 
1564
+ /**
1565
+ * Get instance attribute types for a class in a file.
1566
+ * Returns Map<attrName, typeName> for a given className.
1567
+ * Caches results per file.
1568
+ */
1569
+ getInstanceAttributeTypes(filePath, className) {
1570
+ if (!this._attrTypeCache) this._attrTypeCache = new Map();
1571
+
1572
+ let fileCache = this._attrTypeCache.get(filePath);
1573
+ if (!fileCache) {
1574
+ const fileEntry = this.files.get(filePath);
1575
+ if (!fileEntry || fileEntry.language !== 'python') return null;
1576
+
1577
+ const langModule = getLanguageModule('python');
1578
+ if (!langModule?.findInstanceAttributeTypes) return null;
1579
+
1580
+ try {
1581
+ const content = fs.readFileSync(filePath, 'utf-8');
1582
+ const parser = getParser('python');
1583
+ fileCache = langModule.findInstanceAttributeTypes(content, parser);
1584
+ this._attrTypeCache.set(filePath, fileCache);
1585
+ } catch {
1586
+ return null;
1587
+ }
1588
+ }
1589
+
1590
+ return fileCache.get(className) || null;
1591
+ }
1592
+
1496
1593
  /**
1497
1594
  * Extract type names from a function definition
1498
1595
  */
@@ -1610,8 +1707,13 @@ class ProjectIndex {
1610
1707
  try {
1611
1708
  const content = fs.readFileSync(importerPath, 'utf-8');
1612
1709
  const lines = content.split('\n');
1613
- const targetRelative = path.relative(this.root, targetPath);
1614
- const targetBasename = path.basename(targetPath, path.extname(targetPath));
1710
+ let targetBasename = path.basename(targetPath, path.extname(targetPath));
1711
+
1712
+ // For __init__.py, search for the package name (parent dir)
1713
+ // e.g., "from tools import X" → search for "tools" not "__init__"
1714
+ if (targetBasename === '__init__') {
1715
+ targetBasename = path.basename(path.dirname(targetPath));
1716
+ }
1615
1717
 
1616
1718
  for (let i = 0; i < lines.length; i++) {
1617
1719
  if (lines[i].includes(targetBasename) &&
@@ -1984,14 +2086,24 @@ class ProjectIndex {
1984
2086
  mods.includes('public') && mods.includes('static');
1985
2087
 
1986
2088
  // Python: Magic/dunder methods are called by the interpreter, not user code
1987
- const isPythonMagicMethod = lang === 'python' && /^__\w+__$/.test(name);
2089
+ // test_* functions/methods are called by pytest/unittest via reflection
2090
+ const isPythonEntryPoint = lang === 'python' &&
2091
+ (/^__\w+__$/.test(name) || /^test_/.test(name));
1988
2092
 
1989
2093
  // Rust: main() is entry point, #[test] functions are called by test runner
1990
2094
  const isRustEntryPoint = lang === 'rust' &&
1991
2095
  (name === 'main' || mods.includes('test'));
1992
2096
 
1993
- const isEntryPoint = isGoEntryPoint || isJavaEntryPoint ||
1994
- isPythonMagicMethod || isRustEntryPoint;
2097
+ // Go: Test*, Benchmark*, Example* functions are called by go test
2098
+ const isGoTestFunc = lang === 'go' &&
2099
+ /^(Test|Benchmark|Example)[A-Z]/.test(name);
2100
+
2101
+ // Java: @Test annotated methods are called by JUnit
2102
+ const isJavaTestMethod = lang === 'java' && mods.includes('test');
2103
+
2104
+ const isEntryPoint = isGoEntryPoint || isGoTestFunc ||
2105
+ isJavaEntryPoint || isJavaTestMethod ||
2106
+ isPythonEntryPoint || isRustEntryPoint;
1995
2107
 
1996
2108
  const isExported = fileEntry && (
1997
2109
  fileEntry.exports.includes(name) ||
@@ -2922,10 +3034,21 @@ class ProjectIndex {
2922
3034
  if (!def) {
2923
3035
  return { found: false, function: name };
2924
3036
  }
2925
- const expectedParamCount = def.paramsStructured?.length || 0;
2926
- const optionalCount = (def.paramsStructured || []).filter(p => p.optional || p.default !== undefined).length;
3037
+ // For Python/Rust methods, exclude self/cls from parameter count
3038
+ // (callers don't pass self/cls explicitly: obj.method(a, b) not obj.method(obj, a, b))
3039
+ const fileEntry = this.files.get(def.file);
3040
+ const lang = fileEntry?.language;
3041
+ let params = def.paramsStructured || [];
3042
+ if ((lang === 'python' || lang === 'rust') && params.length > 0) {
3043
+ const firstName = params[0].name;
3044
+ if (firstName === 'self' || firstName === 'cls' || firstName === '&self' || firstName === '&mut self') {
3045
+ params = params.slice(1);
3046
+ }
3047
+ }
3048
+ const expectedParamCount = params.length;
3049
+ const optionalCount = params.filter(p => p.optional || p.default !== undefined).length;
2927
3050
  const minArgs = expectedParamCount - optionalCount;
2928
- const hasRest = (def.paramsStructured || []).some(p => p.rest);
3051
+ const hasRest = params.some(p => p.rest);
2929
3052
 
2930
3053
  // Get all call sites
2931
3054
  const usages = this.usages(name, { codeOnly: true });
@@ -3012,11 +3135,11 @@ class ProjectIndex {
3012
3135
  file: def.relativePath,
3013
3136
  startLine: def.startLine,
3014
3137
  signature: this.formatSignature(def),
3015
- params: def.paramsStructured?.map(p => ({
3138
+ params: params.map(p => ({
3016
3139
  name: p.name,
3017
3140
  optional: p.optional || p.default !== undefined,
3018
3141
  hasDefault: p.default !== undefined
3019
- })) || [],
3142
+ })),
3020
3143
  expectedArgs: { min: minArgs, max: hasRest ? '∞' : expectedParamCount },
3021
3144
  totalCalls: valid.length + mismatches.length + uncertain.length,
3022
3145
  valid: valid.length,
package/languages/java.js CHANGED
@@ -39,7 +39,10 @@ function extractModifiers(node) {
39
39
  for (let i = 0; i < modifiersNode.namedChildCount; i++) {
40
40
  const mod = modifiersNode.namedChild(i);
41
41
  if (mod.type === 'marker_annotation' || mod.type === 'annotation') {
42
- continue; // Skip annotations for modifiers
42
+ // Store annotation name (without @) as modifier (e.g., @Test -> 'test', @Override -> 'override')
43
+ const annoText = mod.text.replace(/^@/, '').split('(')[0].toLowerCase();
44
+ modifiers.push(annoText);
45
+ continue;
43
46
  }
44
47
  modifiers.push(mod.text);
45
48
  }
@@ -400,9 +400,14 @@ function findCallsInCode(code, parser) {
400
400
  traverseTree(tree.rootNode, (node) => {
401
401
  // Track function entry
402
402
  if (isFunctionNode(node)) {
403
+ // Use decorated_definition start line if present, to match symbol index
404
+ let startLine = node.startPosition.row + 1;
405
+ if (node.parent && node.parent.type === 'decorated_definition') {
406
+ startLine = node.parent.startPosition.row + 1;
407
+ }
403
408
  functionStack.push({
404
409
  name: extractFunctionName(node),
405
- startLine: node.startPosition.row + 1,
410
+ startLine,
406
411
  endLine: node.endPosition.row + 1
407
412
  });
408
413
  }
@@ -425,16 +430,32 @@ function findCallsInCode(code, parser) {
425
430
  uncertain
426
431
  });
427
432
  } else if (funcNode.type === 'attribute') {
428
- // Method/attribute call: obj.foo()
433
+ // Method/attribute call: obj.foo() or self.attr.foo()
429
434
  const attrNode = funcNode.childForFieldName('attribute');
430
435
  const objNode = funcNode.childForFieldName('object');
431
436
 
432
437
  if (attrNode) {
438
+ let receiver = objNode?.type === 'identifier' ? objNode.text : undefined;
439
+ let selfAttribute = undefined;
440
+
441
+ // Detect self.X.method() pattern: objNode is attribute access on self/cls
442
+ if (objNode?.type === 'attribute') {
443
+ const innerObj = objNode.childForFieldName('object');
444
+ const innerAttr = objNode.childForFieldName('attribute');
445
+ if (innerObj?.type === 'identifier' &&
446
+ ['self', 'cls'].includes(innerObj.text) &&
447
+ innerAttr) {
448
+ selfAttribute = innerAttr.text;
449
+ receiver = innerObj.text;
450
+ }
451
+ }
452
+
433
453
  calls.push({
434
454
  name: attrNode.text,
435
455
  line: node.startPosition.row + 1,
436
456
  isMethod: true,
437
- receiver: objNode?.type === 'identifier' ? objNode.text : undefined,
457
+ receiver,
458
+ ...(selfAttribute && { selfAttribute }),
438
459
  enclosingFunction,
439
460
  uncertain
440
461
  });
@@ -699,6 +720,183 @@ function findUsagesInCode(code, name, parser) {
699
720
  return usages;
700
721
  }
701
722
 
723
+ /**
724
+ * Find instance attribute types from __init__ constructor assignments.
725
+ * Parses self.X = ClassName(...) patterns in __init__ methods.
726
+ * @param {string} code - Source code to analyze
727
+ * @param {object} parser - Tree-sitter parser instance
728
+ * @returns {Map<string, Map<string, string>>} className -> (attrName -> typeName)
729
+ */
730
+ function findInstanceAttributeTypes(code, parser) {
731
+ const tree = parseTree(parser, code);
732
+ const result = new Map(); // className -> Map(attrName -> typeName)
733
+
734
+ const PRIMITIVE_TYPES = new Set(['int', 'float', 'str', 'bool', 'bytes', 'list', 'dict', 'set', 'tuple', 'None', 'Any', 'object']);
735
+
736
+ traverseTree(tree.rootNode, (node) => {
737
+ if (node.type !== 'class_definition') return true;
738
+
739
+ const classNameNode = node.childForFieldName('name');
740
+ if (!classNameNode) return true;
741
+ const className = classNameNode.text;
742
+
743
+ const body = node.childForFieldName('body');
744
+ if (!body) return false;
745
+
746
+ const attrTypes = new Map();
747
+
748
+ // Check for @dataclass decorator — scan annotated class-level fields
749
+ const parentNode = node.parent;
750
+ if (parentNode?.type === 'decorated_definition') {
751
+ for (let d = 0; d < parentNode.childCount; d++) {
752
+ const dec = parentNode.child(d);
753
+ if (dec.type !== 'decorator') continue;
754
+ // Match @dataclass or @dataclasses.dataclass
755
+ const decText = dec.text;
756
+ if (decText.startsWith('@dataclass') || decText.includes('.dataclass')) {
757
+ // Scan class body for annotated fields: name: Type = ...
758
+ for (let i = 0; i < body.childCount; i++) {
759
+ const stmt = body.child(i);
760
+ if (stmt.type !== 'expression_statement') continue;
761
+ const assign = stmt.firstChild;
762
+ if (!assign || assign.type !== 'assignment') continue;
763
+
764
+ // Must have a type annotation
765
+ const typeNode = assign.childForFieldName('type');
766
+ if (!typeNode) continue;
767
+
768
+ // Extract type name from annotation
769
+ const typeIdent = typeNode.type === 'type' ? typeNode.firstChild : typeNode;
770
+ if (!typeIdent || typeIdent.type !== 'identifier') continue;
771
+ const typeName = typeIdent.text;
772
+
773
+ // Skip primitives and lowercase types
774
+ if (PRIMITIVE_TYPES.has(typeName)) continue;
775
+ if (typeName[0] < 'A' || typeName[0] > 'Z') continue;
776
+
777
+ // Field name from LHS
778
+ const lhs = assign.childForFieldName('left');
779
+ if (!lhs || lhs.type !== 'identifier') continue;
780
+ attrTypes.set(lhs.text, typeName);
781
+ }
782
+ break;
783
+ }
784
+ }
785
+ }
786
+
787
+ // Scan __init__ for self.X = ClassName(...) assignments
788
+ for (let i = 0; i < body.childCount; i++) {
789
+ let child = body.child(i);
790
+ // Handle decorated_definition wrapper
791
+ if (child.type === 'decorated_definition') {
792
+ for (let j = 0; j < child.childCount; j++) {
793
+ if (child.child(j).type === 'function_definition') {
794
+ child = child.child(j);
795
+ break;
796
+ }
797
+ }
798
+ }
799
+ if (child.type !== 'function_definition') continue;
800
+
801
+ const fnName = child.childForFieldName('name');
802
+ if (!fnName || fnName.text !== '__init__') continue;
803
+
804
+ // Found __init__, now scan for self.X = ClassName(...) assignments
805
+ const initBody = child.childForFieldName('body');
806
+ if (!initBody) continue;
807
+
808
+ traverseTree(initBody, (stmt) => {
809
+ if (stmt.type !== 'expression_statement') return true;
810
+
811
+ const assign = stmt.firstChild;
812
+ if (!assign || assign.type !== 'assignment') return true;
813
+
814
+ // LHS: self.X
815
+ const lhs = assign.childForFieldName('left');
816
+ if (!lhs || lhs.type !== 'attribute') return true;
817
+ const lhsObj = lhs.childForFieldName('object');
818
+ const lhsAttr = lhs.childForFieldName('attribute');
819
+ if (!lhsObj || lhsObj.text !== 'self' || !lhsAttr) return true;
820
+
821
+ const attrName = lhsAttr.text;
822
+
823
+ // RHS: ClassName(...) or param or ClassName(...)
824
+ const rhs = assign.childForFieldName('right');
825
+ if (!rhs) return true;
826
+
827
+ const typeName = extractConstructorName(rhs);
828
+ if (typeName) {
829
+ attrTypes.set(attrName, typeName);
830
+ }
831
+
832
+ return true;
833
+ });
834
+ }
835
+
836
+ if (attrTypes.size > 0) {
837
+ result.set(className, attrTypes);
838
+ }
839
+
840
+ return false; // don't descend into nested classes from traverseTree
841
+ });
842
+
843
+ return result;
844
+ }
845
+
846
+ /**
847
+ * Extract constructor class name from an expression node.
848
+ * Handles: ClassName(...), param or ClassName(...), (param or ClassName(...)),
849
+ * expr if cond else ClassName(...)
850
+ */
851
+ function extractConstructorName(node) {
852
+ if (!node) return null;
853
+
854
+ // Direct call: ClassName(...)
855
+ if (node.type === 'call') {
856
+ const func = node.childForFieldName('function');
857
+ if (func?.type === 'identifier') {
858
+ const name = func.text;
859
+ // Only uppercase-first names (constructor heuristic)
860
+ if (name[0] >= 'A' && name[0] <= 'Z') return name;
861
+ }
862
+ return null;
863
+ }
864
+
865
+ // Boolean fallback: param or ClassName(...)
866
+ if (node.type === 'boolean_operator') {
867
+ // Check operator is 'or'
868
+ const op = node.child(1);
869
+ if (op?.text === 'or') {
870
+ const right = node.child(2);
871
+ return extractConstructorName(right);
872
+ }
873
+ }
874
+
875
+ // Conditional expression: expr if cond else ClassName(...)
876
+ if (node.type === 'conditional_expression') {
877
+ // Children: [0]=truthy, [1]='if', [2]=condition, [3]='else', [4]=else_value
878
+ // Try else branch first (usually has the constructor fallback)
879
+ const elseVal = node.child(4);
880
+ const fromElse = extractConstructorName(elseVal);
881
+ if (fromElse) return fromElse;
882
+ // Also try truthy branch
883
+ const truthyVal = node.child(0);
884
+ return extractConstructorName(truthyVal);
885
+ }
886
+
887
+ // Parenthesized expression
888
+ if (node.type === 'parenthesized_expression') {
889
+ for (let i = 0; i < node.childCount; i++) {
890
+ const child = node.child(i);
891
+ if (child.type !== '(' && child.type !== ')') {
892
+ return extractConstructorName(child);
893
+ }
894
+ }
895
+ }
896
+
897
+ return null;
898
+ }
899
+
702
900
  module.exports = {
703
901
  findFunctions,
704
902
  findClasses,
@@ -707,5 +905,6 @@ module.exports = {
707
905
  findImportsInCode,
708
906
  findExportsInCode,
709
907
  findUsagesInCode,
908
+ findInstanceAttributeTypes,
710
909
  parse
711
910
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "3.3.0",
3
+ "version": "3.4.1",
4
4
  "description": "Code navigation built by AI, for AI. Reduces context usage when working with large codebases.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -5603,5 +5603,590 @@ impl Rgb {
5603
5603
  });
5604
5604
  });
5605
5605
 
5606
+ // Regression: verify should exclude self/cls from Python method parameter count
5607
+ describe('Regression: verify excludes Python self/cls from param count', () => {
5608
+ it('should not count self as a required argument for Python methods', () => {
5609
+ const tmpDir = path.join(os.tmpdir(), `ucn-test-verify-self-${Date.now()}`);
5610
+ fs.mkdirSync(tmpDir, { recursive: true });
5611
+
5612
+ try {
5613
+ fs.writeFileSync(path.join(tmpDir, 'setup.py'), '');
5614
+ fs.writeFileSync(path.join(tmpDir, 'calculator.py'), `
5615
+ class Calculator:
5616
+ def add(self, a, b):
5617
+ return a + b
5618
+
5619
+ def multiply(self, x, y, z=1):
5620
+ return x * y * z
5621
+
5622
+ @classmethod
5623
+ def from_string(cls, s):
5624
+ return cls()
5625
+ `);
5626
+ fs.writeFileSync(path.join(tmpDir, 'main.py'), `
5627
+ from calculator import Calculator
5628
+
5629
+ c = Calculator()
5630
+ c.add(1, 2)
5631
+ c.add(3, 4)
5632
+ c.multiply(2, 3)
5633
+ c.multiply(2, 3, 4)
5634
+ Calculator.from_string("test")
5635
+ `);
5636
+
5637
+ const index = new ProjectIndex(tmpDir);
5638
+ index.build(null, { quiet: true });
5639
+
5640
+ // verify add: 2 params (not 3 counting self)
5641
+ const addResult = index.verify('add');
5642
+ assert.ok(addResult.found, 'add should be found');
5643
+ assert.strictEqual(addResult.expectedArgs.min, 2, 'add should expect min 2 args (not 3)');
5644
+ assert.strictEqual(addResult.expectedArgs.max, 2, 'add should expect max 2 args (not 3)');
5645
+ assert.strictEqual(addResult.mismatches, 0, `add should have 0 mismatches, got ${addResult.mismatches}`);
5646
+
5647
+ // verify multiply: 2-3 params (not 3-4 counting self)
5648
+ const mulResult = index.verify('multiply');
5649
+ assert.ok(mulResult.found, 'multiply should be found');
5650
+ assert.strictEqual(mulResult.expectedArgs.min, 2, 'multiply should expect min 2 args');
5651
+ assert.strictEqual(mulResult.expectedArgs.max, 3, 'multiply should expect max 3 args');
5652
+ assert.strictEqual(mulResult.mismatches, 0, `multiply should have 0 mismatches, got ${mulResult.mismatches}`);
5653
+
5654
+ // verify from_string: cls should also be excluded
5655
+ const clsResult = index.verify('from_string');
5656
+ assert.ok(clsResult.found, 'from_string should be found');
5657
+ assert.strictEqual(clsResult.expectedArgs.min, 1, 'from_string should expect 1 arg (not 2)');
5658
+ assert.strictEqual(clsResult.mismatches, 0, `from_string should have 0 mismatches, got ${clsResult.mismatches}`);
5659
+
5660
+ // params list should not include self/cls
5661
+ assert.ok(!addResult.params.some(p => p.name === 'self'), 'params should not include self');
5662
+ assert.ok(!clsResult.params.some(p => p.name === 'cls'), 'params should not include cls');
5663
+ } finally {
5664
+ fs.rmSync(tmpDir, { recursive: true, force: true });
5665
+ }
5666
+ });
5667
+ });
5668
+
5669
+ // Regression: deadcode should treat test_* as entry points in Python
5670
+ describe('Regression: deadcode treats Python test_* as entry points', () => {
5671
+ it('should not flag test_* functions as dead code', () => {
5672
+ const tmpDir = path.join(os.tmpdir(), `ucn-test-deadcode-tests-${Date.now()}`);
5673
+ fs.mkdirSync(tmpDir, { recursive: true });
5674
+
5675
+ try {
5676
+ fs.writeFileSync(path.join(tmpDir, 'setup.py'), '');
5677
+ fs.writeFileSync(path.join(tmpDir, 'app.py'), `
5678
+ def helper():
5679
+ return 42
5680
+
5681
+ def unused_func():
5682
+ return 0
5683
+ `);
5684
+ fs.writeFileSync(path.join(tmpDir, 'test_app.py'), `
5685
+ from app import helper
5686
+
5687
+ def test_helper_returns_42():
5688
+ assert helper() == 42
5689
+
5690
+ def test_helper_type():
5691
+ assert isinstance(helper(), int)
5692
+ `);
5693
+
5694
+ const index = new ProjectIndex(tmpDir);
5695
+ index.build(null, { quiet: true });
5696
+
5697
+ const dead = index.deadcode({ includeTests: true });
5698
+ const deadNames = dead.map(d => d.name);
5699
+
5700
+ // test_* functions should NOT be in dead code
5701
+ assert.ok(!deadNames.includes('test_helper_returns_42'),
5702
+ 'test_helper_returns_42 should not be flagged as dead code');
5703
+ assert.ok(!deadNames.includes('test_helper_type'),
5704
+ 'test_helper_type should not be flagged as dead code');
5705
+
5706
+ // unused_func should still be flagged
5707
+ assert.ok(deadNames.includes('unused_func'),
5708
+ 'unused_func should be flagged as dead code');
5709
+ } finally {
5710
+ fs.rmSync(tmpDir, { recursive: true, force: true });
5711
+ }
5712
+ });
5713
+ });
5714
+
5715
+ // Regression: Python non-relative package imports should resolve to local files
5716
+ describe('Regression: Python package imports resolve to local files', () => {
5717
+ it('should resolve "tools.analyzer" to tools/analyzer.py', () => {
5718
+ const tmpDir = path.join(os.tmpdir(), `ucn-test-py-pkg-imports-${Date.now()}`);
5719
+ const toolsDir = path.join(tmpDir, 'tools');
5720
+ fs.mkdirSync(toolsDir, { recursive: true });
5721
+
5722
+ try {
5723
+ fs.writeFileSync(path.join(tmpDir, 'setup.py'), '');
5724
+ fs.writeFileSync(path.join(toolsDir, '__init__.py'), '');
5725
+ fs.writeFileSync(path.join(toolsDir, 'analyzer.py'), `
5726
+ class Analyzer:
5727
+ def analyze(self, data):
5728
+ return len(data)
5729
+ `);
5730
+ fs.writeFileSync(path.join(toolsDir, 'helper.py'), `
5731
+ def compute():
5732
+ return 42
5733
+ `);
5734
+ fs.writeFileSync(path.join(tmpDir, 'main.py'), `
5735
+ from tools.analyzer import Analyzer
5736
+ from tools.helper import compute
5737
+
5738
+ a = Analyzer()
5739
+ a.analyze([1, 2, 3])
5740
+ compute()
5741
+ `);
5742
+
5743
+ const index = new ProjectIndex(tmpDir);
5744
+ index.build(null, { quiet: true });
5745
+
5746
+ // imports for main.py should resolve tools.analyzer
5747
+ const mainImports = index.importGraph.get(path.join(tmpDir, 'main.py')) || [];
5748
+ assert.ok(mainImports.some(i => i.includes('analyzer.py')),
5749
+ `main.py should import tools/analyzer.py, got ${mainImports.map(i => path.relative(tmpDir, i))}`);
5750
+ assert.ok(mainImports.some(i => i.includes('helper.py')),
5751
+ `main.py should import tools/helper.py, got ${mainImports.map(i => path.relative(tmpDir, i))}`);
5752
+
5753
+ // exporters for analyzer.py should include main.py
5754
+ const exporters = index.exporters('tools/analyzer.py');
5755
+ assert.ok(exporters.some(e => e.file.includes('main.py')),
5756
+ `tools/analyzer.py should be exported to main.py, got ${JSON.stringify(exporters)}`);
5757
+ } finally {
5758
+ fs.rmSync(tmpDir, { recursive: true, force: true });
5759
+ }
5760
+ });
5761
+ });
5762
+
5763
+ describe('Regression: exporters deduplicates repeated imports of same module', () => {
5764
+ it('should not duplicate exporters when a file imports same module multiple times', () => {
5765
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-dedup-'));
5766
+ const pkgDir = path.join(tmpDir, 'pkg');
5767
+ fs.mkdirSync(pkgDir, { recursive: true });
5768
+
5769
+ try {
5770
+ fs.writeFileSync(path.join(tmpDir, 'setup.py'), '');
5771
+ fs.writeFileSync(path.join(pkgDir, '__init__.py'), '');
5772
+ fs.writeFileSync(path.join(pkgDir, 'db.py'), `
5773
+ def get_connection():
5774
+ pass
5775
+
5776
+ def insert_record():
5777
+ pass
5778
+
5779
+ def delete_record():
5780
+ pass
5781
+ `);
5782
+ // File with multiple function-body imports of same module
5783
+ fs.writeFileSync(path.join(tmpDir, 'app.py'), `
5784
+ def cmd_add():
5785
+ from pkg.db import get_connection, insert_record
5786
+ conn = get_connection()
5787
+ insert_record()
5788
+
5789
+ def cmd_remove():
5790
+ from pkg.db import get_connection, delete_record
5791
+ conn = get_connection()
5792
+ delete_record()
5793
+
5794
+ def cmd_list():
5795
+ from pkg.db import get_connection
5796
+ conn = get_connection()
5797
+ `);
5798
+
5799
+ const index = new ProjectIndex(tmpDir);
5800
+ index.build(null, { quiet: true });
5801
+
5802
+ // exporters for db.py should list app.py exactly once
5803
+ const exporters = index.exporters('pkg/db.py');
5804
+ const appEntries = exporters.filter(e => e.file.includes('app.py'));
5805
+ assert.strictEqual(appEntries.length, 1,
5806
+ `pkg/db.py should have exactly 1 exporter entry for app.py, got ${appEntries.length}`);
5807
+
5808
+ // importGraph should also be deduplicated
5809
+ const appImports = index.importGraph.get(path.join(tmpDir, 'app.py')) || [];
5810
+ const dbImports = appImports.filter(i => i.includes('db.py'));
5811
+ assert.strictEqual(dbImports.length, 1,
5812
+ `app.py importGraph should have db.py once, got ${dbImports.length}`);
5813
+ } finally {
5814
+ fs.rmSync(tmpDir, { recursive: true, force: true });
5815
+ }
5816
+ });
5817
+ });
5818
+
5819
+ describe('Regression: exporters shows line numbers for __init__.py', () => {
5820
+ it('should find import line for package __init__.py using parent dir name', () => {
5821
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-init-'));
5822
+ const pkgDir = path.join(tmpDir, 'mypackage');
5823
+ fs.mkdirSync(pkgDir, { recursive: true });
5824
+
5825
+ try {
5826
+ fs.writeFileSync(path.join(tmpDir, 'setup.py'), '');
5827
+ fs.writeFileSync(path.join(pkgDir, '__init__.py'), `
5828
+ CONFIG = {'debug': False}
5829
+
5830
+ def load_config():
5831
+ return CONFIG
5832
+ `);
5833
+ fs.writeFileSync(path.join(tmpDir, 'main.py'), `
5834
+ import os
5835
+ from mypackage import load_config
5836
+
5837
+ config = load_config()
5838
+ `);
5839
+
5840
+ const index = new ProjectIndex(tmpDir);
5841
+ index.build(null, { quiet: true });
5842
+
5843
+ const exporters = index.exporters('mypackage/__init__.py');
5844
+ const mainEntry = exporters.find(e => e.file.includes('main.py'));
5845
+ assert.ok(mainEntry, 'main.py should be an exporter of mypackage/__init__.py');
5846
+ assert.ok(mainEntry.importLine !== null,
5847
+ `Should find import line for __init__.py, got null`);
5848
+ assert.strictEqual(mainEntry.importLine, 3,
5849
+ `Import line should be 3 (from mypackage import ...), got ${mainEntry.importLine}`);
5850
+ } finally {
5851
+ fs.rmSync(tmpDir, { recursive: true, force: true });
5852
+ }
5853
+ });
5854
+ });
5855
+
5856
+ describe('Regression: Python self.attr.method() resolution', () => {
5857
+ it('findCallsInCode should detect selfAttribute for self.X.method()', () => {
5858
+ const { getParser, getLanguageModule } = require('../languages');
5859
+ const parser = getParser('python');
5860
+ const langModule = getLanguageModule('python');
5861
+
5862
+ const code = `class ReportGenerator:
5863
+ def __init__(self, analyzer):
5864
+ self.analyzer = analyzer
5865
+ self.name = "test"
5866
+
5867
+ def generate(self):
5868
+ result = self.analyzer.analyze(data)
5869
+ self.save(result)
5870
+ helper(result)
5871
+ self.name.upper()
5872
+ `;
5873
+ const calls = langModule.findCallsInCode(code, parser);
5874
+
5875
+ // self.analyzer.analyze() should have selfAttribute
5876
+ const analyzeCall = calls.find(c => c.name === 'analyze');
5877
+ assert.ok(analyzeCall, 'Should find analyze call');
5878
+ assert.strictEqual(analyzeCall.selfAttribute, 'analyzer');
5879
+ assert.strictEqual(analyzeCall.receiver, 'self');
5880
+ assert.strictEqual(analyzeCall.isMethod, true);
5881
+
5882
+ // self.save() should NOT have selfAttribute (direct self method)
5883
+ const saveCall = calls.find(c => c.name === 'save');
5884
+ assert.ok(saveCall, 'Should find save call');
5885
+ assert.strictEqual(saveCall.selfAttribute, undefined);
5886
+ assert.strictEqual(saveCall.receiver, 'self');
5887
+
5888
+ // helper() should be a regular function call
5889
+ const helperCall = calls.find(c => c.name === 'helper');
5890
+ assert.ok(helperCall, 'Should find helper call');
5891
+ assert.strictEqual(helperCall.isMethod, false);
5892
+ assert.strictEqual(helperCall.selfAttribute, undefined);
5893
+
5894
+ // self.name.upper() should have selfAttribute but string method
5895
+ const upperCall = calls.find(c => c.name === 'upper');
5896
+ assert.ok(upperCall, 'Should find upper call');
5897
+ assert.strictEqual(upperCall.selfAttribute, 'name');
5898
+ });
5899
+
5900
+ it('findInstanceAttributeTypes should parse __init__ assignments', () => {
5901
+ const { getParser, getLanguageModule } = require('../languages');
5902
+ const parser = getParser('python');
5903
+ const langModule = getLanguageModule('python');
5904
+
5905
+ const code = `class ReportGenerator:
5906
+ def __init__(self, analyzer=None, db=None):
5907
+ self.analyzer = InstrumentAnalyzer(config)
5908
+ self.db = db or DatabaseClient()
5909
+ self.name = "test"
5910
+ self.count = 0
5911
+ self.items = []
5912
+ self.scanner = (param or MarketScanner())
5913
+
5914
+ class OtherClass:
5915
+ def __init__(self):
5916
+ self.helper = HelperTool()
5917
+ `;
5918
+ const result = langModule.findInstanceAttributeTypes(code, parser);
5919
+
5920
+ // ReportGenerator
5921
+ const rg = result.get('ReportGenerator');
5922
+ assert.ok(rg, 'Should find ReportGenerator');
5923
+ assert.strictEqual(rg.get('analyzer'), 'InstrumentAnalyzer');
5924
+ assert.strictEqual(rg.get('db'), 'DatabaseClient');
5925
+ assert.strictEqual(rg.get('scanner'), 'MarketScanner');
5926
+ assert.strictEqual(rg.has('name'), false, 'Should skip string literals');
5927
+ assert.strictEqual(rg.has('count'), false, 'Should skip number literals');
5928
+ assert.strictEqual(rg.has('items'), false, 'Should skip list literals');
5929
+
5930
+ // OtherClass
5931
+ const oc = result.get('OtherClass');
5932
+ assert.ok(oc, 'Should find OtherClass');
5933
+ assert.strictEqual(oc.get('helper'), 'HelperTool');
5934
+ });
5935
+
5936
+ it('findCallees should resolve self.attr.method() to target class', () => {
5937
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-selfattr-'));
5938
+ try {
5939
+ fs.writeFileSync(path.join(tmpDir, 'analyzer.py'), `class InstrumentAnalyzer:
5940
+ def __init__(self, config):
5941
+ self.config = config
5942
+
5943
+ def analyze_instrument(self, data):
5944
+ return process(data)
5945
+
5946
+ def get_summary(self):
5947
+ return "summary"
5948
+ `);
5949
+ fs.writeFileSync(path.join(tmpDir, 'report.py'), `from analyzer import InstrumentAnalyzer
5950
+
5951
+ class ReportGenerator:
5952
+ def __init__(self, config):
5953
+ self.analyzer = InstrumentAnalyzer(config)
5954
+
5955
+ def generate_report(self, data):
5956
+ result = self.analyzer.analyze_instrument(data)
5957
+ summary = self.analyzer.get_summary()
5958
+ return format_output(result, summary)
5959
+
5960
+ def format_output(result, summary):
5961
+ return str(result) + summary
5962
+ `);
5963
+
5964
+ const index = new ProjectIndex(tmpDir);
5965
+ index.build('**/*.py', { quiet: true });
5966
+
5967
+ // Find generate_report definition
5968
+ const defs = index.symbols.get('generate_report');
5969
+ assert.ok(defs && defs.length > 0, 'Should find generate_report');
5970
+
5971
+ const callees = index.findCallees(defs[0]);
5972
+ const calleeNames = callees.map(c => c.name);
5973
+
5974
+ assert.ok(calleeNames.includes('analyze_instrument'),
5975
+ `Should resolve self.analyzer.analyze_instrument(), got: ${calleeNames.join(', ')}`);
5976
+ assert.ok(calleeNames.includes('get_summary'),
5977
+ `Should resolve self.analyzer.get_summary(), got: ${calleeNames.join(', ')}`);
5978
+ assert.ok(calleeNames.includes('format_output'),
5979
+ `Should include direct call format_output(), got: ${calleeNames.join(', ')}`);
5980
+
5981
+ // Verify the resolved callee points to InstrumentAnalyzer's method
5982
+ const analyzeCallee = callees.find(c => c.name === 'analyze_instrument');
5983
+ assert.strictEqual(analyzeCallee.className, 'InstrumentAnalyzer',
5984
+ 'Resolved callee should belong to InstrumentAnalyzer class');
5985
+ } finally {
5986
+ fs.rmSync(tmpDir, { recursive: true, force: true });
5987
+ }
5988
+ });
5989
+
5990
+ it('findCallers should find callers through self.attr.method()', () => {
5991
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-selfattr-callers-'));
5992
+ try {
5993
+ fs.writeFileSync(path.join(tmpDir, 'analyzer.py'), `class InstrumentAnalyzer:
5994
+ def __init__(self, config):
5995
+ self.config = config
5996
+
5997
+ def analyze_instrument(self, data):
5998
+ return process(data)
5999
+ `);
6000
+ fs.writeFileSync(path.join(tmpDir, 'report.py'), `from analyzer import InstrumentAnalyzer
6001
+
6002
+ class ReportGenerator:
6003
+ def __init__(self, config):
6004
+ self.analyzer = InstrumentAnalyzer(config)
6005
+
6006
+ def generate_report(self, data):
6007
+ result = self.analyzer.analyze_instrument(data)
6008
+ return result
6009
+ `);
6010
+
6011
+ const index = new ProjectIndex(tmpDir);
6012
+ index.build('**/*.py', { quiet: true });
6013
+
6014
+ const callers = index.findCallers('analyze_instrument');
6015
+ assert.ok(callers.length >= 1,
6016
+ `Should find at least 1 caller for analyze_instrument, got ${callers.length}`);
6017
+
6018
+ const reportCaller = callers.find(c => c.callerName === 'generate_report');
6019
+ assert.ok(reportCaller,
6020
+ `Should find generate_report as caller, got: ${callers.map(c => c.callerName).join(', ')}`);
6021
+ } finally {
6022
+ fs.rmSync(tmpDir, { recursive: true, force: true });
6023
+ }
6024
+ });
6025
+ });
6026
+
6027
+ describe('Regression: Python decorated function callees', () => {
6028
+ it('findCallees should work for @property and other decorated methods', () => {
6029
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-decorated-'));
6030
+ try {
6031
+ fs.writeFileSync(path.join(tmpDir, 'models.py'), `class Headers:
6032
+ def get(self, key):
6033
+ return self.data.get(key)
6034
+
6035
+ class Response:
6036
+ def __init__(self, headers):
6037
+ self.headers = Headers(headers)
6038
+
6039
+ @property
6040
+ def charset_encoding(self):
6041
+ content_type = self.headers.get("Content-Type")
6042
+ return parse_charset(content_type)
6043
+
6044
+ @staticmethod
6045
+ def create(data):
6046
+ return Response(data)
6047
+
6048
+ def normal_method(self):
6049
+ return self.headers.get("Accept")
6050
+ `);
6051
+
6052
+ const index = new ProjectIndex(tmpDir);
6053
+ index.build('**/*.py', { quiet: true });
6054
+
6055
+ // @property method should have callees
6056
+ const propDefs = index.symbols.get('charset_encoding');
6057
+ assert.ok(propDefs && propDefs.length > 0, 'Should find charset_encoding');
6058
+ const propCallees = index.findCallees(propDefs[0]);
6059
+ const propCalleeNames = propCallees.map(c => c.name);
6060
+ assert.ok(propCalleeNames.includes('parse_charset') || propCalleeNames.includes('get'),
6061
+ `@property should have callees, got: ${propCalleeNames.join(', ')}`);
6062
+
6063
+ // Normal method should also have callees
6064
+ const normalDefs = index.symbols.get('normal_method');
6065
+ assert.ok(normalDefs && normalDefs.length > 0, 'Should find normal_method');
6066
+ const normalCallees = index.findCallees(normalDefs[0]);
6067
+ assert.ok(normalCallees.length > 0,
6068
+ `Normal method should have callees, got: ${normalCallees.map(c => c.name).join(', ')}`);
6069
+ } finally {
6070
+ fs.rmSync(tmpDir, { recursive: true, force: true });
6071
+ }
6072
+ });
6073
+ });
6074
+
6075
+ describe('Regression: Python conditional expression in attribute types', () => {
6076
+ it('findInstanceAttributeTypes should resolve conditional expressions', () => {
6077
+ const Parser = require('tree-sitter');
6078
+ const Python = require('tree-sitter-python');
6079
+ const parser = new Parser();
6080
+ parser.setLanguage(Python);
6081
+ const pythonParser = require('../languages/python');
6082
+
6083
+ const code = `
6084
+ class Live:
6085
+ def __init__(self, renderable=None, console=None):
6086
+ self._live_render = renderable if renderable else LiveRender(Text())
6087
+ self.console = console or Console()
6088
+ self.plain = "hello"
6089
+ `;
6090
+ const result = pythonParser.findInstanceAttributeTypes(code, parser);
6091
+ assert.ok(result.has('Live'), 'Should find Live class');
6092
+ const attrs = result.get('Live');
6093
+ assert.strictEqual(attrs.get('_live_render'), 'LiveRender', 'Should resolve conditional to LiveRender');
6094
+ assert.strictEqual(attrs.get('console'), 'Console', 'Should still resolve boolean or pattern');
6095
+ assert.ok(!attrs.has('plain'), 'Should skip string literals');
6096
+ });
6097
+ });
6098
+
6099
+ describe('Regression: Python @dataclass field annotation types', () => {
6100
+ it('findInstanceAttributeTypes should extract types from @dataclass annotated fields', () => {
6101
+ const Parser = require('tree-sitter');
6102
+ const Python = require('tree-sitter-python');
6103
+ const parser = new Parser();
6104
+ parser.setLanguage(Python);
6105
+ const pythonParser = require('../languages/python');
6106
+
6107
+ const code = `
6108
+ from dataclasses import dataclass, field
6109
+
6110
+ @dataclass
6111
+ class Line:
6112
+ depth: int = 0
6113
+ bracket_tracker: BracketTracker = field(default_factory=BracketTracker)
6114
+ inside_brackets: bool = False
6115
+ comments: list = field(default_factory=list)
6116
+ mode: Mode = Mode.DEFAULT
6117
+ `;
6118
+ const result = pythonParser.findInstanceAttributeTypes(code, parser);
6119
+ assert.ok(result.has('Line'), 'Should find Line class');
6120
+ const attrs = result.get('Line');
6121
+ assert.strictEqual(attrs.get('bracket_tracker'), 'BracketTracker', 'Should extract BracketTracker from annotation');
6122
+ assert.strictEqual(attrs.get('mode'), 'Mode', 'Should extract Mode from annotation');
6123
+ assert.ok(!attrs.has('depth'), 'Should skip int primitive');
6124
+ assert.ok(!attrs.has('inside_brackets'), 'Should skip bool primitive');
6125
+ assert.ok(!attrs.has('comments'), 'Should skip list primitive');
6126
+ });
6127
+
6128
+ it('findInstanceAttributeTypes should not scan non-dataclass classes for field annotations', () => {
6129
+ const Parser = require('tree-sitter');
6130
+ const Python = require('tree-sitter-python');
6131
+ const parser = new Parser();
6132
+ parser.setLanguage(Python);
6133
+ const pythonParser = require('../languages/python');
6134
+
6135
+ const code = `
6136
+ class RegularClass:
6137
+ name: str = "default"
6138
+ tracker: BracketTracker = None
6139
+
6140
+ def __init__(self):
6141
+ self.helper = Helper()
6142
+ `;
6143
+ const result = pythonParser.findInstanceAttributeTypes(code, parser);
6144
+ // Should find Helper from __init__ but NOT BracketTracker from class-level annotation
6145
+ assert.ok(result.has('RegularClass'), 'Should find RegularClass');
6146
+ const attrs = result.get('RegularClass');
6147
+ assert.strictEqual(attrs.get('helper'), 'Helper', 'Should find Helper from __init__');
6148
+ assert.ok(!attrs.has('tracker'), 'Should NOT extract from non-dataclass class annotations');
6149
+ });
6150
+
6151
+ it('findCallees should resolve @dataclass attribute method calls', () => {
6152
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-dataclass-'));
6153
+ try {
6154
+ fs.writeFileSync(path.join(tmpDir, 'linegen.py'), `
6155
+ from dataclasses import dataclass, field
6156
+
6157
+ class BracketTracker:
6158
+ def any_open_brackets(self):
6159
+ return len(self.brackets) > 0
6160
+
6161
+ def mark(self, leaf):
6162
+ self.brackets.append(leaf)
6163
+
6164
+ @dataclass
6165
+ class Line:
6166
+ bracket_tracker: BracketTracker = field(default_factory=BracketTracker)
6167
+
6168
+ def should_split(self):
6169
+ if self.bracket_tracker.any_open_brackets():
6170
+ return True
6171
+ self.bracket_tracker.mark(None)
6172
+ return False
6173
+ `);
6174
+ const index = new ProjectIndex(tmpDir);
6175
+ index.build('**/*.py', { quiet: true });
6176
+
6177
+ const defs = index.symbols.get('should_split');
6178
+ assert.ok(defs && defs.length > 0, 'Should find should_split');
6179
+ const callees = index.findCallees(defs[0]);
6180
+ const calleeNames = callees.map(c => c.name);
6181
+ assert.ok(calleeNames.includes('any_open_brackets'),
6182
+ `Should resolve self.bracket_tracker.any_open_brackets(), got: ${calleeNames.join(', ')}`);
6183
+ assert.ok(calleeNames.includes('mark'),
6184
+ `Should resolve self.bracket_tracker.mark(), got: ${calleeNames.join(', ')}`);
6185
+ } finally {
6186
+ fs.rmSync(tmpDir, { recursive: true, force: true });
6187
+ }
6188
+ });
6189
+ });
6190
+
5606
6191
  console.log('UCN v3 Test Suite');
5607
6192
  console.log('Run with: node --test test/parser.test.js');