ucn 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
  }
@@ -1610,8 +1620,13 @@ class ProjectIndex {
1610
1620
  try {
1611
1621
  const content = fs.readFileSync(importerPath, 'utf-8');
1612
1622
  const lines = content.split('\n');
1613
- const targetRelative = path.relative(this.root, targetPath);
1614
- const targetBasename = path.basename(targetPath, path.extname(targetPath));
1623
+ let targetBasename = path.basename(targetPath, path.extname(targetPath));
1624
+
1625
+ // For __init__.py, search for the package name (parent dir)
1626
+ // e.g., "from tools import X" → search for "tools" not "__init__"
1627
+ if (targetBasename === '__init__') {
1628
+ targetBasename = path.basename(path.dirname(targetPath));
1629
+ }
1615
1630
 
1616
1631
  for (let i = 0; i < lines.length; i++) {
1617
1632
  if (lines[i].includes(targetBasename) &&
@@ -1984,14 +1999,24 @@ class ProjectIndex {
1984
1999
  mods.includes('public') && mods.includes('static');
1985
2000
 
1986
2001
  // Python: Magic/dunder methods are called by the interpreter, not user code
1987
- const isPythonMagicMethod = lang === 'python' && /^__\w+__$/.test(name);
2002
+ // test_* functions/methods are called by pytest/unittest via reflection
2003
+ const isPythonEntryPoint = lang === 'python' &&
2004
+ (/^__\w+__$/.test(name) || /^test_/.test(name));
1988
2005
 
1989
2006
  // Rust: main() is entry point, #[test] functions are called by test runner
1990
2007
  const isRustEntryPoint = lang === 'rust' &&
1991
2008
  (name === 'main' || mods.includes('test'));
1992
2009
 
1993
- const isEntryPoint = isGoEntryPoint || isJavaEntryPoint ||
1994
- isPythonMagicMethod || isRustEntryPoint;
2010
+ // Go: Test*, Benchmark*, Example* functions are called by go test
2011
+ const isGoTestFunc = lang === 'go' &&
2012
+ /^(Test|Benchmark|Example)[A-Z]/.test(name);
2013
+
2014
+ // Java: @Test annotated methods are called by JUnit
2015
+ const isJavaTestMethod = lang === 'java' && mods.includes('test');
2016
+
2017
+ const isEntryPoint = isGoEntryPoint || isGoTestFunc ||
2018
+ isJavaEntryPoint || isJavaTestMethod ||
2019
+ isPythonEntryPoint || isRustEntryPoint;
1995
2020
 
1996
2021
  const isExported = fileEntry && (
1997
2022
  fileEntry.exports.includes(name) ||
@@ -2922,10 +2947,21 @@ class ProjectIndex {
2922
2947
  if (!def) {
2923
2948
  return { found: false, function: name };
2924
2949
  }
2925
- const expectedParamCount = def.paramsStructured?.length || 0;
2926
- const optionalCount = (def.paramsStructured || []).filter(p => p.optional || p.default !== undefined).length;
2950
+ // For Python/Rust methods, exclude self/cls from parameter count
2951
+ // (callers don't pass self/cls explicitly: obj.method(a, b) not obj.method(obj, a, b))
2952
+ const fileEntry = this.files.get(def.file);
2953
+ const lang = fileEntry?.language;
2954
+ let params = def.paramsStructured || [];
2955
+ if ((lang === 'python' || lang === 'rust') && params.length > 0) {
2956
+ const firstName = params[0].name;
2957
+ if (firstName === 'self' || firstName === 'cls' || firstName === '&self' || firstName === '&mut self') {
2958
+ params = params.slice(1);
2959
+ }
2960
+ }
2961
+ const expectedParamCount = params.length;
2962
+ const optionalCount = params.filter(p => p.optional || p.default !== undefined).length;
2927
2963
  const minArgs = expectedParamCount - optionalCount;
2928
- const hasRest = (def.paramsStructured || []).some(p => p.rest);
2964
+ const hasRest = params.some(p => p.rest);
2929
2965
 
2930
2966
  // Get all call sites
2931
2967
  const usages = this.usages(name, { codeOnly: true });
@@ -3012,11 +3048,11 @@ class ProjectIndex {
3012
3048
  file: def.relativePath,
3013
3049
  startLine: def.startLine,
3014
3050
  signature: this.formatSignature(def),
3015
- params: def.paramsStructured?.map(p => ({
3051
+ params: params.map(p => ({
3016
3052
  name: p.name,
3017
3053
  optional: p.optional || p.default !== undefined,
3018
3054
  hasDefault: p.default !== undefined
3019
- })) || [],
3055
+ })),
3020
3056
  expectedArgs: { min: minArgs, max: hasRest ? '∞' : expectedParamCount },
3021
3057
  totalCalls: valid.length + mismatches.length + uncertain.length,
3022
3058
  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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
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,255 @@ 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
+
5606
5856
  console.log('UCN v3 Test Suite');
5607
5857
  console.log('Run with: node --test test/parser.test.js');