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 +1 -1
- package/core/imports.js +9 -0
- package/core/project.js +141 -18
- package/languages/java.js +4 -1
- package/languages/python.js +202 -3
- package/package.json +1 -1
- package/test/parser.test.js +585 -0
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
|
-
|
|
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
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1614
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1994
|
-
|
|
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
|
-
|
|
2926
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
}
|
package/languages/python.js
CHANGED
|
@@ -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
|
|
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
|
|
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
package/test/parser.test.js
CHANGED
|
@@ -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');
|