ucn 3.7.2 → 3.7.4

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/README.md CHANGED
@@ -1,12 +1,40 @@
1
1
  # UCN - Universal Code Navigator
2
2
 
3
- UCN is designed to work with large files and codebases, helping AI agents ingest exactly the data they need. Its surgical output discourages agents from cutting corners, and without UCN, agents working with large codebases tend to skip parts of the code structure, assuming they have "enough data."
3
+ UCN gives AI agents call-graph-level understanding of code. Instead of reading entire files, agents ask structural questions like: "who calls this function", "what breaks if I change it", "what's unused", and get precise, AST-verified answers. UCN parses JS/TS, Python, Go, Rust, Java, and HTML inline scripts with tree-sitter, then exposes 28 navigation commands as a CLI tool, MCP server, or agent skill.
4
4
 
5
- Supported languages: JS/TS, Python, Go, Rust, Java. Also parses HTML files (inline scripts and event handlers).
5
+ Designed for large codebases where agents waste context on reading large files. UCN's surgical output means agents spend tokens on reasoning, not on ingesting thousands of lines to find three callers, discourages agents from cutting corners, as without UCN, agents working with large codebases tend to skip parts of the code structure, assuming they have "enough data".
6
+
7
+ Everything runs locally on your machine and nothing leaves your project.
6
8
 
7
9
  ---
8
10
 
9
- ## Three Ways to Use UCN
11
+ ## What UCN does
12
+
13
+ Precise answers without reading files.
14
+
15
+ ```
16
+ TASK COMMAND
17
+ ───────────────────── ─────────────────────
18
+
19
+ Pull one function from $ ucn fn handleRequest
20
+ a 2000-line file → 20 lines, just that function
21
+
22
+ Who calls this? Will they $ ucn impact handleRequest
23
+ break if I change it? → 8 call sites, with arguments
24
+
25
+ What happens when $ ucn trace main --depth=3
26
+ main() runs? → full call tree, no file reads
27
+
28
+ What can I safely delete? $ ucn deadcode
29
+ → unused functions, AST-verified
30
+
31
+ What depends on this file $ ucn graph src/routes.ts
32
+ before I move it? → imports and importers tree
33
+ ```
34
+
35
+ ---
36
+
37
+ ## Three Ways to it: ucn mcp, ucn skill, ucn cli
10
38
 
11
39
  ```
12
40
  ┌──────────────────────────────────────────────────────────────────────┐
@@ -15,7 +43,7 @@ Supported languages: JS/TS, Python, Go, Rust, Java. Also parses HTML files (inli
15
43
  │ $ ucn about myFunc Works standalone, no agent required. │
16
44
  │ │
17
45
  │ 2. MCP Server Any MCP-compatible AI agent connects │
18
- │ $ ucn --mcp and gets 28 tools automatically. │
46
+ │ $ ucn --mcp and gets 28 commands automatically. │
19
47
  │ │
20
48
  │ 3. Agent Skill Drop-in skill for Claude Code and │
21
49
  │ /ucn about myFunc OpenAI Codex CLI. No server needed. │
@@ -25,9 +53,9 @@ Supported languages: JS/TS, Python, Go, Rust, Java. Also parses HTML files (inli
25
53
 
26
54
  ---
27
55
 
28
- ## The Problem
56
+ ## How agents understand code today
29
57
 
30
- Typically, AI agents working with code do something like this:
58
+ AI agents working with code typically do this:
31
59
 
32
60
  ```
33
61
  grep "functionName" → 47 matches, 23 files
@@ -53,28 +81,7 @@ Typically, AI agents working with code do something like this:
53
81
 
54
82
  ---
55
83
 
56
- ## The Solution
57
-
58
- UCN parses the code with tree-sitter and offers semantic navigation tools.
59
-
60
- Instead of reading entire files, ask precise questions:
61
-
62
- ```
63
- ┌──────────────────────────────────────┐
64
- │ │
65
- │ "Who calls this function?" │──→ list of actual callers
66
- │ │
67
- │ "What breaks if I change this?" │──→ every call site, with arguments
68
- │ │
69
- │ "Show me this function and │──→ source + dependencies inline
70
- │ everything it depends on" │
71
- │ │
72
- └──────────────────────────────────────┘
73
- ```
74
-
75
- ---
76
-
77
- ## How It Works
84
+ ## How UCN works: tree-sitter, locally
78
85
 
79
86
  ```
80
87
  ┌──────────────────────────────────────────────┐
@@ -87,29 +94,27 @@ Instead of reading entire files, ask precise questions:
87
94
 
88
95
  ┌───────────────────┐
89
96
  │ UCN MCP Server │
90
- │ 28 tools
97
+ │ 28 commands
91
98
  │ runs locally │
92
99
  └────────┬──────────┘
93
100
 
94
101
  tree-sitter AST
95
102
 
96
- ┌───────────────────┴─────────────────┐
97
- │ Supported Languages │
98
- │ JS/TS, Python, Go, Rust, Java, HTML │
99
- └─────────────────────────────────────┘
103
+ ┌─────────────────┴───────────────────┐
104
+ │ Supported Languages │
105
+ │ JS/TS, Python, Go, Rust, Java, HTML │
106
+ └─────────────────────────────────────┘
100
107
  ```
101
108
 
102
- No cloud. No API keys. Parses locally, stays local.
103
-
104
109
  ---
105
110
 
106
- ## Before & After
111
+ ## Before and after UCN
107
112
 
108
113
  ```
109
114
  WITHOUT UCN WITH UCN
110
115
  ────────────────────── ──────────────────────
111
116
 
112
- grep "processOrder" ucn_impact "processOrder"
117
+ grep "processOrder" ucn impact "processOrder"
113
118
  │ │
114
119
  ▼ ▼
115
120
  34 matches, mostly noise 8 call sites, grouped by file,
@@ -118,7 +123,7 @@ No cloud. No API keys. Parses locally, stays local.
118
123
  read service.ts (800 lines) │
119
124
  │ │
120
125
  ▼ │
121
- read handler.ts (600 lines) ucn_smart "processOrder"
126
+ read handler.ts (600 lines) ucn smart "processOrder"
122
127
  │ │
123
128
  ▼ ▼
124
129
  read batch.ts (400 lines) function + all dependencies
@@ -141,13 +146,13 @@ No cloud. No API keys. Parses locally, stays local.
141
146
  Context spent on file contents Context spent on reasoning
142
147
  ```
143
148
 
144
- After editing code:
149
+ After editing code, before committing:
145
150
 
146
151
  ```
147
152
  WITHOUT UCN WITH UCN
148
153
  ────────────────────── ──────────────────────
149
154
 
150
- git diff ucn_diff_impact
155
+ git diff ucn diff_impact
151
156
  │ │
152
157
  ▼ ▼
153
158
  see changed lines, but which 13 modified functions
@@ -158,7 +163,7 @@ After editing code:
158
163
  to function boundaries Each function shown with:
159
164
  │ • which lines changed
160
165
  ▼ • every downstream caller
161
- ucn_impact on each function • caller context
166
+ ucn impact on each function • caller context
162
167
  you identified (repeat 5-10x) │
163
168
  │ ▼
164
169
  ▼ Done. Full blast radius.
@@ -170,7 +175,7 @@ After editing code:
170
175
 
171
176
  ---
172
177
 
173
- ## grep vs AST
178
+ ## Text search vs AST
174
179
 
175
180
  ```
176
181
  Code: processOrder(items, user)
@@ -190,7 +195,7 @@ After editing code:
190
195
 
191
196
 
192
197
  ┌─────────────────────────────────────────────────────────────────┐
193
- ucn_context "processOrder" │
198
+ ucn context "processOrder" │
194
199
  │ │
195
200
  │ Callers: │
196
201
  │ handleCheckout src/api/checkout.ts:45 │
@@ -206,11 +211,11 @@ After editing code:
206
211
  └─────────────────────────────────────────────────────────────────┘
207
212
  ```
208
213
 
209
- The tradeoff: grep works on any language and any text. UCN only works on supported languages but gives structural understanding within those.
214
+ The tradeoff: text search works on any language and any text. UCN only works on 5 languages + HTML, but gives structural understanding within those.
210
215
 
211
216
  ---
212
217
 
213
- ## See It in Action
218
+ ## UCN commands in action
214
219
 
215
220
  Extract a function from a large file without reading it:
216
221
 
@@ -461,7 +466,7 @@ ucn --interactive # Multiple queries, index stays in memory
461
466
 
462
467
  ---
463
468
 
464
- ## Workflows
469
+ ## UCN workflows
465
470
 
466
471
  Investigating a bug:
467
472
  ```bash
@@ -492,7 +497,7 @@ ucn toc # Project overview
492
497
 
493
498
  ---
494
499
 
495
- ## Limitations (and how we handle them)
500
+ ## Limitations
496
501
 
497
502
  ```
498
503
  ┌──────────────────────────┬──────────────────────────────────────────┐
@@ -500,8 +505,9 @@ ucn toc # Project overview
500
505
  ├──────────────────────────┼──────────────────────────────────────────┤
501
506
  │ │ │
502
507
  │ 5 languages + HTML │ JS/TS, Python, Go, Rust, Java. │
503
- │ (no C, Ruby, PHP, etc.) │ Agents fall back to grep for the rest.
504
- │ │ UCN complements, doesn't replace.
508
+ │ (no C, Ruby, PHP, etc.) │ Agents fall back to text search for
509
+ │ │ the rest. UCN complements, doesn't
510
+ │ │ replace. │
505
511
  │ │ │
506
512
  ├──────────────────────────┼──────────────────────────────────────────┤
507
513
  │ │ │
@@ -535,38 +541,40 @@ ucn toc # Project overview
535
541
 
536
542
  ---
537
543
 
538
- ## All 28 Tools
544
+ ## All 28 Commands
545
+
546
+ All commands are accessible through a single `ucn` MCP tool with a `command` parameter.
539
547
 
540
548
  ```
541
549
  UNDERSTAND MODIFY SAFELY
542
550
  ───────────────────── ─────────────────────
543
- ucn_about everything in one ucn_impact all call sites
551
+ about everything in one impact all call sites
544
552
  call: definition, with arguments
545
553
  callers, callees,
546
- tests, source ucn_diff_impact what changed in a
554
+ tests, source diff_impact what changed in a
547
555
  git diff + who
548
- ucn_context callers + callees calls it
556
+ context callers + callees calls it
549
557
  (quick overview)
550
- ucn_verify check all sites
551
- ucn_smart function + helpers match signature
558
+ verify check all sites
559
+ smart function + helpers match signature
552
560
  expanded inline
553
- ucn_plan preview a refactor
554
- ucn_trace call tree — map before doing it
561
+ plan preview a refactor
562
+ trace call tree — map before doing it
555
563
  a whole pipeline
556
564
 
557
565
 
558
566
  FIND & NAVIGATE ARCHITECTURE
559
567
  ───────────────────── ─────────────────────
560
- ucn_find locate definitions ucn_imports file dependencies
561
- ucn_usages all occurrences ucn_exporters who depends on it
562
- ucn_fn extract a function ucn_graph dependency tree
563
- ucn_class extract a class ucn_related sibling functions
564
- ucn_toc project overview ucn_tests find tests
565
- ucn_deadcode unused functions ucn_stacktrace error trace context
566
- ucn_search text search ucn_api public API surface
567
- ucn_example best usage example ucn_typedef type definitions
568
- ucn_lines extract line range ucn_file_exports file's exports
569
- ucn_expand drill into context ucn_stats project size stats
568
+ find locate definitions imports file dependencies
569
+ usages all occurrences exporters who depends on it
570
+ fn extract a function graph dependency tree
571
+ class extract a class related sibling functions
572
+ toc project overview tests find tests
573
+ deadcode unused functions stacktrace error trace context
574
+ search text search api public API surface
575
+ example best usage example typedef type definitions
576
+ lines extract line range file_exports file's exports
577
+ expand drill into context stats project size stats
570
578
  ```
571
579
 
572
580
  ---
package/core/output.js CHANGED
@@ -829,7 +829,7 @@ function formatRelated(related, options = {}) {
829
829
  // Same file
830
830
  let relatedTruncated = false;
831
831
  if (related.sameFile.length > 0) {
832
- const maxSameFile = options.showAll ? Infinity : 8;
832
+ const maxSameFile = options.top || (options.showAll ? Infinity : 8);
833
833
  lines.push(`SAME FILE (${related.sameFile.length}):`);
834
834
  for (const f of related.sameFile.slice(0, maxSameFile)) {
835
835
  const params = f.params ? `(${f.params})` : '';
package/core/project.js CHANGED
@@ -2188,18 +2188,12 @@ class ProjectIndex {
2188
2188
  * @returns {Array} Imports with resolved paths
2189
2189
  */
2190
2190
  imports(filePath) {
2191
- const normalizedPath = path.isAbsolute(filePath)
2192
- ? filePath
2193
- : path.join(this.root, filePath);
2191
+ const resolved = this.resolveFilePathForQuery(filePath);
2192
+ if (typeof resolved !== 'string') return resolved;
2194
2193
 
2194
+ const normalizedPath = resolved;
2195
2195
  const fileEntry = this.files.get(normalizedPath);
2196
2196
  if (!fileEntry) {
2197
- // Try to find by relative path
2198
- for (const [absPath, entry] of this.files) {
2199
- if (entry.relativePath === filePath || absPath.endsWith(filePath)) {
2200
- return this.imports(absPath);
2201
- }
2202
- }
2203
2197
  return { error: 'file-not-found', filePath };
2204
2198
  }
2205
2199
 
@@ -2275,24 +2269,10 @@ class ProjectIndex {
2275
2269
  * @returns {Array} Files that import this file
2276
2270
  */
2277
2271
  exporters(filePath) {
2278
- const normalizedPath = path.isAbsolute(filePath)
2279
- ? filePath
2280
- : path.join(this.root, filePath);
2281
-
2282
- // Try to find the file
2283
- let targetPath = normalizedPath;
2284
- if (!this.files.has(normalizedPath)) {
2285
- for (const [absPath, entry] of this.files) {
2286
- if (entry.relativePath === filePath || absPath.endsWith(filePath)) {
2287
- targetPath = absPath;
2288
- break;
2289
- }
2290
- }
2291
- }
2272
+ const resolved = this.resolveFilePathForQuery(filePath);
2273
+ if (typeof resolved !== 'string') return resolved;
2292
2274
 
2293
- if (!this.files.has(targetPath)) {
2294
- return { error: 'file-not-found', filePath };
2295
- }
2275
+ const targetPath = resolved;
2296
2276
 
2297
2277
  const importers = this.exportGraph.get(targetPath) || [];
2298
2278
 
@@ -2432,7 +2412,18 @@ class ProjectIndex {
2432
2412
  api(filePath, options = {}) {
2433
2413
  const results = [];
2434
2414
 
2435
- for (const [absPath, fileEntry] of (filePath ? [[this.findFile(filePath), this.files.get(this.findFile(filePath))]] : this.files.entries())) {
2415
+ let fileIterator;
2416
+ if (filePath) {
2417
+ const resolved = this.resolveFilePathForQuery(filePath);
2418
+ if (typeof resolved !== 'string') return resolved;
2419
+ const fileEntry = this.files.get(resolved);
2420
+ if (!fileEntry) return { error: 'file-not-found', filePath };
2421
+ fileIterator = [[resolved, fileEntry]];
2422
+ } else {
2423
+ fileIterator = this.files.entries();
2424
+ }
2425
+
2426
+ for (const [absPath, fileEntry] of fileIterator) {
2436
2427
  if (!fileEntry) continue;
2437
2428
 
2438
2429
  // Skip test files by default (test classes aren't part of public API)
@@ -2487,9 +2478,12 @@ class ProjectIndex {
2487
2478
  }
2488
2479
 
2489
2480
  /**
2490
- * Find a file by path (supports partial paths)
2481
+ * Resolve a file path query to an indexed file (with ambiguity detection)
2482
+ * @param {string} filePath - File path to resolve
2483
+ * @returns {string|{error: string, filePath: string, candidates?: string[]}}
2491
2484
  */
2492
- findFile(filePath) {
2485
+ resolveFilePathForQuery(filePath) {
2486
+ // 1. Exact absolute/relative path match
2493
2487
  const normalizedPath = path.isAbsolute(filePath)
2494
2488
  ? filePath
2495
2489
  : path.join(this.root, filePath);
@@ -2498,13 +2492,34 @@ class ProjectIndex {
2498
2492
  return normalizedPath;
2499
2493
  }
2500
2494
 
2501
- // Try partial match
2495
+ // 2. Collect ALL suffix/partial candidates
2496
+ const candidates = [];
2502
2497
  for (const [absPath, entry] of this.files) {
2503
- if (entry.relativePath === filePath || absPath.endsWith(filePath)) {
2504
- return absPath;
2498
+ if (entry.relativePath === filePath || absPath.endsWith('/' + filePath)) {
2499
+ candidates.push(absPath);
2505
2500
  }
2506
2501
  }
2507
2502
 
2503
+ if (candidates.length === 0) {
2504
+ return { error: 'file-not-found', filePath };
2505
+ }
2506
+ if (candidates.length === 1) {
2507
+ return candidates[0];
2508
+ }
2509
+ return {
2510
+ error: 'file-ambiguous',
2511
+ filePath,
2512
+ candidates: candidates.map(c => this.files.get(c)?.relativePath || path.relative(this.root, c))
2513
+ };
2514
+ }
2515
+
2516
+ /**
2517
+ * Find a file by path (supports partial paths)
2518
+ * Backward-compatible wrapper — returns null on error.
2519
+ */
2520
+ findFile(filePath) {
2521
+ const result = this.resolveFilePathForQuery(filePath);
2522
+ if (typeof result === 'string') return result;
2508
2523
  return null;
2509
2524
  }
2510
2525
 
@@ -2514,11 +2529,10 @@ class ProjectIndex {
2514
2529
  * @returns {Array} Exported symbols from that file
2515
2530
  */
2516
2531
  fileExports(filePath) {
2517
- const absPath = this.findFile(filePath);
2518
- if (!absPath) {
2519
- return { error: 'file-not-found', filePath };
2520
- }
2532
+ const resolved = this.resolveFilePathForQuery(filePath);
2533
+ if (typeof resolved !== 'string') return resolved;
2521
2534
 
2535
+ const absPath = resolved;
2522
2536
  const fileEntry = this.files.get(absPath);
2523
2537
  if (!fileEntry) {
2524
2538
  return [];
@@ -2960,24 +2974,10 @@ class ProjectIndex {
2960
2974
  const rawDepth = options.maxDepth ?? 5;
2961
2975
  const maxDepth = Math.max(0, rawDepth);
2962
2976
 
2963
- const absPath = path.isAbsolute(filePath)
2964
- ? filePath
2965
- : path.resolve(this.root, filePath);
2966
-
2967
- // Try to find file if not exact match
2968
- let targetPath = absPath;
2969
- if (!this.files.has(absPath)) {
2970
- for (const [p, entry] of this.files) {
2971
- if (entry.relativePath === filePath || p.endsWith(filePath)) {
2972
- targetPath = p;
2973
- break;
2974
- }
2975
- }
2976
- }
2977
+ const resolved = this.resolveFilePathForQuery(filePath);
2978
+ if (typeof resolved !== 'string') return resolved;
2977
2979
 
2978
- if (!this.files.has(targetPath)) {
2979
- return { error: 'file-not-found', filePath };
2980
- }
2980
+ const targetPath = resolved;
2981
2981
 
2982
2982
  const buildSubgraph = (dir) => {
2983
2983
  const visited = new Set();
@@ -3176,7 +3176,8 @@ class ProjectIndex {
3176
3176
  }
3177
3177
  // Sort by number of shared parts
3178
3178
  related.similarNames.sort((a, b) => b.sharedParts.length - a.sharedParts.length);
3179
- if (!options.all) related.similarNames = related.similarNames.slice(0, 10);
3179
+ const similarLimit = options.top || (options.all ? Infinity : 10);
3180
+ if (related.similarNames.length > similarLimit) related.similarNames = related.similarNames.slice(0, similarLimit);
3180
3181
 
3181
3182
  // 3. Shared callers - functions called by the same callers
3182
3183
  const myCallers = new Set(this.findCallers(name).map(c => c.callerName).filter(Boolean));
@@ -3194,7 +3195,7 @@ class ProjectIndex {
3194
3195
  }
3195
3196
  }
3196
3197
  // Sort by shared caller count
3197
- const maxShared = options.all ? Infinity : 5;
3198
+ const maxShared = options.top || (options.all ? Infinity : 5);
3198
3199
  const sorted = Array.from(callerCounts.entries())
3199
3200
  .sort((a, b) => b[1] - a[1])
3200
3201
  .slice(0, maxShared);
@@ -3230,7 +3231,7 @@ class ProjectIndex {
3230
3231
  // Sort by shared callee count
3231
3232
  const sorted = Array.from(calleeCounts.entries())
3232
3233
  .sort((a, b) => b[1] - a[1])
3233
- .slice(0, options.all ? Infinity : 5);
3234
+ .slice(0, options.top || (options.all ? Infinity : 5));
3234
3235
  for (const [symName, count] of sorted) {
3235
3236
  const sym = this.symbols.get(symName)?.[0];
3236
3237
  if (sym) {
package/mcp/server.js CHANGED
@@ -371,9 +371,10 @@ server.registerTool(
371
371
  const err = requireName(name);
372
372
  if (err) return err;
373
373
  const index = getIndex(project_dir);
374
- const result = index.related(name, { file, all: top !== undefined });
374
+ const result = index.related(name, { file, top, all: top !== undefined });
375
375
  return toolResult(output.formatRelated(result, {
376
376
  showAll: top !== undefined,
377
+ top,
377
378
  allHint: 'Repeat with top set higher to show all.'
378
379
  }));
379
380
  }
@@ -499,6 +500,10 @@ server.registerTool(
499
500
  note = `Note: Found ${matches.length} definitions for "${name}". Showing ${match.relativePath}:${match.startLine}. Use file parameter to disambiguate.\n\n`;
500
501
  }
501
502
 
503
+ if (max_lines !== undefined && (!Number.isInteger(max_lines) || max_lines < 1)) {
504
+ return toolError(`Invalid max_lines: ${max_lines}. Must be a positive integer.`);
505
+ }
506
+
502
507
  const classLineCount = match.endLine - match.startLine + 1;
503
508
 
504
509
  // Large class: show summary by default, truncated source with max_lines
@@ -537,10 +542,14 @@ server.registerTool(
537
542
  return toolError('Line range is required (e.g. "10-20" or "15").');
538
543
  }
539
544
  const index = getIndex(project_dir);
540
- const filePath = index.findFile(file);
541
- if (!filePath) {
545
+ const resolved = index.resolveFilePathForQuery(file);
546
+ if (typeof resolved !== 'string') {
547
+ if (resolved.error === 'file-ambiguous') {
548
+ return toolError(`Ambiguous file "${file}". Candidates:\n${resolved.candidates.map(c => ' ' + c).join('\n')}`);
549
+ }
542
550
  return toolError(`File not found: ${file}`);
543
551
  }
552
+ const filePath = resolved;
544
553
 
545
554
  const parts = range.split('-');
546
555
  const start = parseInt(parts[0], 10);
@@ -552,12 +561,18 @@ server.registerTool(
552
561
  if (start < 1) {
553
562
  return toolError(`Invalid start line: ${start}. Line numbers must be >= 1`);
554
563
  }
564
+ if (end < 1) {
565
+ return toolError(`Invalid end line: ${end}. Line numbers must be >= 1`);
566
+ }
567
+ if (end < start) {
568
+ return toolError(`Invalid range: end line (${end}) must be >= start line (${start})`);
569
+ }
555
570
 
556
571
  const content = fs.readFileSync(filePath, 'utf-8');
557
572
  const fileLines = content.split('\n');
558
573
 
559
- const startLine = Math.min(start, end);
560
- const endLine = Math.max(start, end);
574
+ const startLine = start;
575
+ const endLine = end;
561
576
 
562
577
  if (startLine > fileLines.length) {
563
578
  return toolError(`Line ${startLine} is out of bounds. File has ${fileLines.length} lines.`);
@@ -645,6 +660,7 @@ server.registerTool(
645
660
  const index = getIndex(project_dir);
646
661
  const result = index.imports(file);
647
662
  if (result?.error === 'file-not-found') return toolError(`File not found in project: ${file}`);
663
+ if (result?.error === 'file-ambiguous') return toolError(`Ambiguous file "${file}". Candidates:\n${result.candidates.map(c => ' ' + c).join('\n')}`);
648
664
  return toolResult(output.formatImports(result, file));
649
665
  }
650
666
 
@@ -655,6 +671,7 @@ server.registerTool(
655
671
  const index = getIndex(project_dir);
656
672
  const result = index.exporters(file);
657
673
  if (result?.error === 'file-not-found') return toolError(`File not found in project: ${file}`);
674
+ if (result?.error === 'file-ambiguous') return toolError(`Ambiguous file "${file}". Candidates:\n${result.candidates.map(c => ' ' + c).join('\n')}`);
658
675
  return toolResult(output.formatExporters(result, file));
659
676
  }
660
677
 
@@ -665,6 +682,7 @@ server.registerTool(
665
682
  const index = getIndex(project_dir);
666
683
  const result = index.fileExports(file);
667
684
  if (result?.error === 'file-not-found') return toolError(`File not found in project: ${file}`);
685
+ if (result?.error === 'file-ambiguous') return toolError(`Ambiguous file "${file}". Candidates:\n${result.candidates.map(c => ' ' + c).join('\n')}`);
668
686
  return toolResult(output.formatFileExports(result, file));
669
687
  }
670
688
 
@@ -675,6 +693,7 @@ server.registerTool(
675
693
  const index = getIndex(project_dir);
676
694
  const result = index.graph(file, { direction: direction || 'both', maxDepth: depth ?? 2 });
677
695
  if (result?.error === 'file-not-found') return toolError(`File not found in project: ${file}`);
696
+ if (result?.error === 'file-ambiguous') return toolError(`Ambiguous file "${file}". Candidates:\n${result.candidates.map(c => ' ' + c).join('\n')}`);
678
697
  return toolResult(output.formatGraph(result, {
679
698
  showAll: depth !== undefined,
680
699
  file,
@@ -745,8 +764,10 @@ server.registerTool(
745
764
 
746
765
  case 'api': {
747
766
  const index = getIndex(project_dir);
748
- const symbols = index.api(file || undefined);
749
- return toolResult(output.formatApi(symbols, file || '.'));
767
+ const result = index.api(file || undefined);
768
+ if (result?.error === 'file-not-found') return toolError(`File not found in project: ${file}`);
769
+ if (result?.error === 'file-ambiguous') return toolError(`Ambiguous file "${file}". Candidates:\n${result.candidates.map(c => ' ' + c).join('\n')}`);
770
+ return toolResult(output.formatApi(result, file || '.'));
750
771
  }
751
772
 
752
773
  case 'stats': {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "3.7.2",
4
- "description": "Universal Code Navigator — function relationships, call trees, and impact analysis across large codebases without reading entire files.",
3
+ "version": "3.7.4",
4
+ "description": "Universal Code Navigator — AST-based call graph analysis for AI agents. Find callers, trace impact, detect dead code across JS/TS, Python, Go, Rust, Java, and HTML. CLI, MCP server, and agent skill.",
5
5
  "main": "index.js",
6
6
  "bin": {
7
7
  "ucn": "cli/index.js",
@@ -11,19 +11,31 @@
11
11
  "test": "node --test test/parser.test.js test/accuracy.test.js test/systematic-test.js test/mcp-edge-cases.js"
12
12
  },
13
13
  "keywords": [
14
+ "mcp",
15
+ "mcp-server",
16
+ "model-context-protocol",
14
17
  "code-navigation",
18
+ "code-analysis",
19
+ "static-analysis",
20
+ "call-graph",
21
+ "callers",
22
+ "impact-analysis",
23
+ "dead-code",
24
+ "deadcode",
15
25
  "ast",
16
- "parser",
17
26
  "tree-sitter",
27
+ "parser",
28
+ "skill",
29
+ "agent-skill",
30
+ "cli",
31
+ "ai-agent",
18
32
  "javascript",
19
33
  "typescript",
20
34
  "python",
21
35
  "go",
22
36
  "rust",
23
37
  "java",
24
- "html",
25
- "ai",
26
- "agent"
38
+ "html"
27
39
  ],
28
40
  "author": "Constantin-Mihail Leoca (https://github.com/mleoca)",
29
41
  "repository": {
@@ -358,6 +358,59 @@ const tests = [
358
358
  desc: 'lines - extract lines 1-5 from discovery.js',
359
359
  args: { command: 'lines', project_dir: PROJECT_DIR, file: 'core/discovery.js', range: '1-5' }
360
360
  },
361
+
362
+ // ========================================================================
363
+ // Correctness Assertions
364
+ // ========================================================================
365
+ {
366
+ category: 'Correctness',
367
+ tool: 'ucn',
368
+ desc: 'api(file=nonexistent) returns isError',
369
+ args: { command: 'api', project_dir: PROJECT_DIR, file: 'nonexistent/path/to/file.js' },
370
+ assert: (res, text, isError) => isError === true || 'Expected isError: true for nonexistent file'
371
+ },
372
+ {
373
+ category: 'Correctness',
374
+ tool: 'ucn',
375
+ desc: 'api(file=nonexistent) message contains "not found"',
376
+ args: { command: 'api', project_dir: PROJECT_DIR, file: 'nonexistent.js' },
377
+ assert: (res, text, isError) => (isError && /not found/i.test(text)) || 'Expected file-not-found error message'
378
+ },
379
+ {
380
+ category: 'Correctness',
381
+ tool: 'ucn',
382
+ desc: 'lines(range="5-0") returns validation error',
383
+ args: { command: 'lines', project_dir: PROJECT_DIR, file: 'core/discovery.js', range: '5-0' },
384
+ assert: (res, text, isError) => isError === true || 'Expected isError: true for invalid range'
385
+ },
386
+ {
387
+ category: 'Correctness',
388
+ tool: 'ucn',
389
+ desc: 'class(max_lines=-1) returns validation error',
390
+ args: { command: 'class', project_dir: PROJECT_DIR, name: 'ProjectIndex', max_lines: -1 },
391
+ assert: (res, text, isError) => isError === true || 'Expected isError: true for negative max_lines'
392
+ },
393
+ {
394
+ category: 'Correctness',
395
+ tool: 'ucn',
396
+ desc: 'lines - unique partial file resolves successfully',
397
+ args: { command: 'lines', project_dir: PROJECT_DIR, file: 'core/discovery.js', range: '1-3' },
398
+ assert: (res, text, isError) => isError === false || 'Expected success for unique partial file'
399
+ },
400
+ {
401
+ category: 'Correctness',
402
+ tool: 'ucn',
403
+ desc: 'file_exports(file=utils.js) returns ambiguity error',
404
+ args: { command: 'file_exports', project_dir: PROJECT_DIR, file: 'utils.js' },
405
+ assert: (res, text, isError) => (isError && /ambiguous/i.test(text)) || 'Expected file-ambiguous error for utils.js'
406
+ },
407
+ {
408
+ category: 'Correctness',
409
+ tool: 'ucn',
410
+ desc: 'imports(file=utils.js) returns ambiguity error',
411
+ args: { command: 'imports', project_dir: PROJECT_DIR, file: 'utils.js' },
412
+ assert: (res, text, isError) => (isError && /ambiguous/i.test(text)) || 'Expected file-ambiguous error for utils.js'
413
+ },
361
414
  ];
362
415
 
363
416
  // ============================================================================
@@ -406,6 +459,15 @@ async function run() {
406
459
  const preview = text.substring(0, 120).replace(/\n/g, '\\n');
407
460
  status = 'PASS';
408
461
  detail = `${isError ? 'ERROR response' : 'OK'}: "${preview}" (${elapsed}ms)`;
462
+
463
+ // Run assertion if provided
464
+ if (t.assert && status === 'PASS') {
465
+ const assertResult = t.assert(res, text, isError);
466
+ if (assertResult !== true) {
467
+ status = 'FAIL';
468
+ detail = `ASSERTION: ${assertResult} (${elapsed}ms)`;
469
+ }
470
+ }
409
471
  } else {
410
472
  status = 'PASS';
411
473
  detail = `Empty result (${elapsed}ms)`;