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 +75 -67
- package/core/output.js +1 -1
- package/core/project.js +57 -56
- package/mcp/server.js +28 -7
- package/package.json +18 -6
- package/test/mcp-edge-cases.js +62 -0
package/README.md
CHANGED
|
@@ -1,12 +1,40 @@
|
|
|
1
1
|
# UCN - Universal Code Navigator
|
|
2
2
|
|
|
3
|
-
UCN
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
|
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
|
-
##
|
|
56
|
+
## How agents understand code today
|
|
29
57
|
|
|
30
|
-
|
|
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
|
-
##
|
|
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
|
|
97
|
+
│ 28 commands │
|
|
91
98
|
│ runs locally │
|
|
92
99
|
└────────┬──────────┘
|
|
93
100
|
│
|
|
94
101
|
tree-sitter AST
|
|
95
102
|
│
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
111
|
+
## Before and after UCN
|
|
107
112
|
|
|
108
113
|
```
|
|
109
114
|
WITHOUT UCN WITH UCN
|
|
110
115
|
────────────────────── ──────────────────────
|
|
111
116
|
|
|
112
|
-
grep "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)
|
|
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
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
│
|
|
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:
|
|
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
|
-
##
|
|
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
|
-
##
|
|
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
|
|
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
|
|
504
|
-
│ │ UCN complements, doesn't
|
|
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
|
|
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
|
-
|
|
551
|
+
about everything in one impact all call sites
|
|
544
552
|
call: definition, with arguments
|
|
545
553
|
callers, callees,
|
|
546
|
-
tests, source
|
|
554
|
+
tests, source diff_impact what changed in a
|
|
547
555
|
git diff + who
|
|
548
|
-
|
|
556
|
+
context callers + callees calls it
|
|
549
557
|
(quick overview)
|
|
550
|
-
|
|
551
|
-
|
|
558
|
+
verify check all sites
|
|
559
|
+
smart function + helpers match signature
|
|
552
560
|
expanded inline
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
|
2192
|
-
|
|
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
|
|
2279
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
2518
|
-
if (
|
|
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
|
|
2964
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
541
|
-
if (
|
|
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 =
|
|
560
|
-
const endLine =
|
|
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
|
|
749
|
-
|
|
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.
|
|
4
|
-
"description": "Universal Code Navigator —
|
|
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": {
|
package/test/mcp-edge-cases.js
CHANGED
|
@@ -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)`;
|