ucn 3.7.3 → 3.7.5

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
@@ -4,9 +4,37 @@ UCN gives AI agents call-graph-level understanding of code. Instead of reading e
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
6
 
7
+ Everything runs locally on your machine and nothing leaves your project.
8
+
7
9
  ---
8
10
 
9
- ## Three Ways to it: ucn mcp, ucn skill, ucn cli
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 @@ Designed for large codebases where agents waste context on reading large files.
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. │
@@ -66,7 +94,7 @@ AI agents working with code typically do this:
66
94
 
67
95
  ┌───────────────────┐
68
96
  │ UCN MCP Server │
69
- │ 28 tools
97
+ │ 28 commands
70
98
  │ runs locally │
71
99
  └────────┬──────────┘
72
100
 
@@ -78,8 +106,6 @@ AI agents working with code typically do this:
78
106
  └─────────────────────────────────────┘
79
107
  ```
80
108
 
81
- No cloud. No API keys. Parses locally, stays local.
82
-
83
109
  ---
84
110
 
85
111
  ## Before and after UCN
@@ -88,7 +114,7 @@ No cloud. No API keys. Parses locally, stays local.
88
114
  WITHOUT UCN WITH UCN
89
115
  ────────────────────── ──────────────────────
90
116
 
91
- grep "processOrder" ucn_impact "processOrder"
117
+ grep "processOrder" ucn impact "processOrder"
92
118
  │ │
93
119
  ▼ ▼
94
120
  34 matches, mostly noise 8 call sites, grouped by file,
@@ -97,7 +123,7 @@ No cloud. No API keys. Parses locally, stays local.
97
123
  read service.ts (800 lines) │
98
124
  │ │
99
125
  ▼ │
100
- read handler.ts (600 lines) ucn_smart "processOrder"
126
+ read handler.ts (600 lines) ucn smart "processOrder"
101
127
  │ │
102
128
  ▼ ▼
103
129
  read batch.ts (400 lines) function + all dependencies
@@ -126,7 +152,7 @@ After editing code, before committing:
126
152
  WITHOUT UCN WITH UCN
127
153
  ────────────────────── ──────────────────────
128
154
 
129
- git diff ucn_diff_impact
155
+ git diff ucn diff_impact
130
156
  │ │
131
157
  ▼ ▼
132
158
  see changed lines, but which 13 modified functions
@@ -137,7 +163,7 @@ After editing code, before committing:
137
163
  to function boundaries Each function shown with:
138
164
  │ • which lines changed
139
165
  ▼ • every downstream caller
140
- ucn_impact on each function • caller context
166
+ ucn impact on each function • caller context
141
167
  you identified (repeat 5-10x) │
142
168
  │ ▼
143
169
  ▼ Done. Full blast radius.
@@ -169,7 +195,7 @@ After editing code, before committing:
169
195
 
170
196
 
171
197
  ┌─────────────────────────────────────────────────────────────────┐
172
- ucn_context "processOrder" │
198
+ ucn context "processOrder" │
173
199
  │ │
174
200
  │ Callers: │
175
201
  │ handleCheckout src/api/checkout.ts:45 │
@@ -515,38 +541,40 @@ ucn toc # Project overview
515
541
 
516
542
  ---
517
543
 
518
- ## All 28 UCN tools
544
+ ## All 28 Commands
545
+
546
+ All commands are accessible through a single `ucn` MCP tool with a `command` parameter.
519
547
 
520
548
  ```
521
549
  UNDERSTAND MODIFY SAFELY
522
550
  ───────────────────── ─────────────────────
523
- ucn_about everything in one ucn_impact all call sites
551
+ about everything in one impact all call sites
524
552
  call: definition, with arguments
525
553
  callers, callees,
526
- tests, source ucn_diff_impact what changed in a
554
+ tests, source diff_impact what changed in a
527
555
  git diff + who
528
- ucn_context callers + callees calls it
556
+ context callers + callees calls it
529
557
  (quick overview)
530
- ucn_verify check all sites
531
- ucn_smart function + helpers match signature
558
+ verify check all sites
559
+ smart function + helpers match signature
532
560
  expanded inline
533
- ucn_plan preview a refactor
534
- ucn_trace call tree — map before doing it
561
+ plan preview a refactor
562
+ trace call tree — map before doing it
535
563
  a whole pipeline
536
564
 
537
565
 
538
566
  FIND & NAVIGATE ARCHITECTURE
539
567
  ───────────────────── ─────────────────────
540
- ucn_find locate definitions ucn_imports file dependencies
541
- ucn_usages all occurrences ucn_exporters who depends on it
542
- ucn_fn extract a function ucn_graph dependency tree
543
- ucn_class extract a class ucn_related sibling functions
544
- ucn_toc project overview ucn_tests find tests
545
- ucn_deadcode unused functions ucn_stacktrace error trace context
546
- ucn_search text search ucn_api public API surface
547
- ucn_example best usage example ucn_typedef type definitions
548
- ucn_lines extract line range ucn_file_exports file's exports
549
- 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
550
578
  ```
551
579
 
552
580
  ---
package/cli/index.js CHANGED
@@ -1819,14 +1819,21 @@ Commands:
1819
1819
  about <name> Everything about a symbol
1820
1820
  usages <name> All usages grouped by type
1821
1821
  context <name> Callers + callees
1822
+ expand <N> Show code for item N from context
1822
1823
  smart <name> Function + dependencies
1823
1824
  impact <name> What breaks if changed
1824
1825
  trace <name> Call tree
1826
+ example <name> Best usage example
1827
+ related <name> Sibling functions
1825
1828
  imports <file> What file imports
1826
1829
  exporters <file> Who imports file
1827
1830
  tests <name> Find tests
1828
1831
  search <term> Text search
1829
1832
  typedef <name> Find type definitions
1833
+ deadcode Find unused functions/classes
1834
+ verify <name> Check call sites match signature
1835
+ plan <name> Preview refactoring
1836
+ stacktrace <text> Parse a stack trace
1830
1837
  api Show public symbols
1831
1838
  diff-impact What changed and who's affected
1832
1839
  stats Index statistics
@@ -1913,7 +1920,7 @@ function executeInteractiveCommand(index, command, arg) {
1913
1920
  console.log('Usage: context <name>');
1914
1921
  return;
1915
1922
  }
1916
- const ctx = index.context(arg, { includeUncertain: flags.includeUncertain });
1923
+ const ctx = index.context(arg, { includeUncertain: flags.includeUncertain, includeMethods: flags.includeMethods });
1917
1924
  if (!ctx) {
1918
1925
  console.log(`Symbol "${arg}" not found.`);
1919
1926
  } else {
@@ -2036,6 +2043,100 @@ function executeInteractiveCommand(index, command, arg) {
2036
2043
  break;
2037
2044
  }
2038
2045
 
2046
+ case 'expand': {
2047
+ if (!arg) {
2048
+ console.log('Usage: expand <number>');
2049
+ return;
2050
+ }
2051
+ const expandNum = parseInt(arg, 10);
2052
+ if (isNaN(expandNum)) {
2053
+ console.log(`Invalid item number: "${arg}"`);
2054
+ return;
2055
+ }
2056
+ const cached = loadExpandableItems(index.root);
2057
+ if (!cached || !cached.items || cached.items.length === 0) {
2058
+ console.log('No expandable items. Run context first.');
2059
+ return;
2060
+ }
2061
+ const expandMatch = cached.items.find(i => i.num === expandNum);
2062
+ if (!expandMatch) {
2063
+ console.log(`Item ${expandNum} not found. Available: 1-${cached.items.length}`);
2064
+ return;
2065
+ }
2066
+ printExpandedItem(expandMatch, cached.root || index.root);
2067
+ break;
2068
+ }
2069
+
2070
+ case 'deadcode': {
2071
+ const deadResult = index.deadcode({
2072
+ includeExported: flags.includeExported,
2073
+ includeDecorated: flags.includeDecorated,
2074
+ includeTests: flags.includeTests
2075
+ });
2076
+ console.log(output.formatDeadcode(deadResult));
2077
+ break;
2078
+ }
2079
+
2080
+ case 'related': {
2081
+ if (!arg) {
2082
+ console.log('Usage: related <name>');
2083
+ return;
2084
+ }
2085
+ const relResult = index.related(arg, { file: flags.file });
2086
+ console.log(output.formatRelated(relResult));
2087
+ break;
2088
+ }
2089
+
2090
+ case 'example': {
2091
+ if (!arg) {
2092
+ console.log('Usage: example <name>');
2093
+ return;
2094
+ }
2095
+ console.log(output.formatExample(index.example(arg), arg));
2096
+ break;
2097
+ }
2098
+
2099
+ case 'plan': {
2100
+ if (!arg) {
2101
+ console.log('Usage: plan <name> [--add-param=x] [--remove-param=x] [--rename-to=x]');
2102
+ return;
2103
+ }
2104
+ if (!flags.addParam && !flags.removeParam && !flags.renameTo) {
2105
+ console.log('Plan requires an operation: --add-param, --remove-param, or --rename-to');
2106
+ return;
2107
+ }
2108
+ const planResult = index.plan(arg, {
2109
+ addParam: flags.addParam,
2110
+ removeParam: flags.removeParam,
2111
+ renameTo: flags.renameTo,
2112
+ defaultValue: flags.defaultValue,
2113
+ file: flags.file
2114
+ });
2115
+ console.log(output.formatPlan(planResult));
2116
+ break;
2117
+ }
2118
+
2119
+ case 'verify': {
2120
+ if (!arg) {
2121
+ console.log('Usage: verify <name>');
2122
+ return;
2123
+ }
2124
+ const verifyResult = index.verify(arg, { file: flags.file });
2125
+ console.log(output.formatVerify(verifyResult));
2126
+ break;
2127
+ }
2128
+
2129
+ case 'stacktrace':
2130
+ case 'stack': {
2131
+ if (!arg) {
2132
+ console.log('Usage: stacktrace <stack text>');
2133
+ return;
2134
+ }
2135
+ const stackResult = index.parseStackTrace(arg);
2136
+ console.log(output.formatStackTrace(stackResult));
2137
+ break;
2138
+ }
2139
+
2039
2140
  default:
2040
2141
  console.log(`Unknown command: ${command}. Type "help" for available commands.`);
2041
2142
  }
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
@@ -1412,7 +1412,8 @@ class ProjectIndex {
1412
1412
  });
1413
1413
  }
1414
1414
  } catch (e) {
1415
- // Skip files that can't be processed
1415
+ // Expected: minified files exceed tree-sitter buffer, binary files fail to parse.
1416
+ // These are not actionable errors — silently skip.
1416
1417
  }
1417
1418
  }
1418
1419
 
@@ -1848,6 +1849,8 @@ class ProjectIndex {
1848
1849
 
1849
1850
  return result;
1850
1851
  } catch (e) {
1852
+ // Expected: file read/parse failures (minified, binary, buffer exceeded).
1853
+ // Return empty callees rather than crashing the entire query.
1851
1854
  return [];
1852
1855
  }
1853
1856
  }
@@ -1956,6 +1959,8 @@ class ProjectIndex {
1956
1959
  cleanHtmlScriptTags(extracted, detectLanguage(symbol.file));
1957
1960
  return extracted.join('\n');
1958
1961
  } catch (e) {
1962
+ // Expected: file may have been deleted or become unreadable since indexing.
1963
+ // Return empty string rather than crashing.
1959
1964
  return '';
1960
1965
  }
1961
1966
  }
@@ -2188,18 +2193,12 @@ class ProjectIndex {
2188
2193
  * @returns {Array} Imports with resolved paths
2189
2194
  */
2190
2195
  imports(filePath) {
2191
- const normalizedPath = path.isAbsolute(filePath)
2192
- ? filePath
2193
- : path.join(this.root, filePath);
2196
+ const resolved = this.resolveFilePathForQuery(filePath);
2197
+ if (typeof resolved !== 'string') return resolved;
2194
2198
 
2199
+ const normalizedPath = resolved;
2195
2200
  const fileEntry = this.files.get(normalizedPath);
2196
2201
  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
2202
  return { error: 'file-not-found', filePath };
2204
2203
  }
2205
2204
 
@@ -2275,24 +2274,10 @@ class ProjectIndex {
2275
2274
  * @returns {Array} Files that import this file
2276
2275
  */
2277
2276
  exporters(filePath) {
2278
- const normalizedPath = path.isAbsolute(filePath)
2279
- ? filePath
2280
- : path.join(this.root, filePath);
2277
+ const resolved = this.resolveFilePathForQuery(filePath);
2278
+ if (typeof resolved !== 'string') return resolved;
2281
2279
 
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
- }
2292
-
2293
- if (!this.files.has(targetPath)) {
2294
- return { error: 'file-not-found', filePath };
2295
- }
2280
+ const targetPath = resolved;
2296
2281
 
2297
2282
  const importers = this.exportGraph.get(targetPath) || [];
2298
2283
 
@@ -2374,6 +2359,9 @@ class ProjectIndex {
2374
2359
  // Note: no 'g' flag - we only need to test for presence per line
2375
2360
  // The 'i' flag is kept for case-insensitive matching
2376
2361
  const regex = new RegExp('\\b' + escapeRegExp(searchTerm) + '\\b', 'i');
2362
+ // Pre-compile patterns used inside per-line loop
2363
+ const callPattern = new RegExp(escapeRegExp(searchTerm) + '\\s*\\(');
2364
+ const strPattern = new RegExp("['\"`]" + escapeRegExp(searchTerm) + "['\"`]");
2377
2365
 
2378
2366
  for (const { path: testPath, entry } of testFiles) {
2379
2367
  try {
@@ -2388,12 +2376,11 @@ class ProjectIndex {
2388
2376
  matchType = 'test-case';
2389
2377
  } else if (/\b(import|require|from)\b/.test(line)) {
2390
2378
  matchType = 'import';
2391
- } else if (new RegExp(searchTerm + '\\s*\\(').test(line)) {
2379
+ } else if (callPattern.test(line)) {
2392
2380
  matchType = 'call';
2393
2381
  }
2394
2382
  // Detect if the match is inside a string literal (e.g., 'parseFile' or "parseFile")
2395
2383
  if (matchType === 'reference' || matchType === 'call') {
2396
- const strPattern = new RegExp("['\"`]" + escapeRegExp(searchTerm) + "['\"`]");
2397
2384
  if (strPattern.test(line)) {
2398
2385
  matchType = 'string-ref';
2399
2386
  }
@@ -2432,7 +2419,18 @@ class ProjectIndex {
2432
2419
  api(filePath, options = {}) {
2433
2420
  const results = [];
2434
2421
 
2435
- for (const [absPath, fileEntry] of (filePath ? [[this.findFile(filePath), this.files.get(this.findFile(filePath))]] : this.files.entries())) {
2422
+ let fileIterator;
2423
+ if (filePath) {
2424
+ const resolved = this.resolveFilePathForQuery(filePath);
2425
+ if (typeof resolved !== 'string') return resolved;
2426
+ const fileEntry = this.files.get(resolved);
2427
+ if (!fileEntry) return { error: 'file-not-found', filePath };
2428
+ fileIterator = [[resolved, fileEntry]];
2429
+ } else {
2430
+ fileIterator = this.files.entries();
2431
+ }
2432
+
2433
+ for (const [absPath, fileEntry] of fileIterator) {
2436
2434
  if (!fileEntry) continue;
2437
2435
 
2438
2436
  // Skip test files by default (test classes aren't part of public API)
@@ -2487,9 +2485,12 @@ class ProjectIndex {
2487
2485
  }
2488
2486
 
2489
2487
  /**
2490
- * Find a file by path (supports partial paths)
2488
+ * Resolve a file path query to an indexed file (with ambiguity detection)
2489
+ * @param {string} filePath - File path to resolve
2490
+ * @returns {string|{error: string, filePath: string, candidates?: string[]}}
2491
2491
  */
2492
- findFile(filePath) {
2492
+ resolveFilePathForQuery(filePath) {
2493
+ // 1. Exact absolute/relative path match
2493
2494
  const normalizedPath = path.isAbsolute(filePath)
2494
2495
  ? filePath
2495
2496
  : path.join(this.root, filePath);
@@ -2498,13 +2499,34 @@ class ProjectIndex {
2498
2499
  return normalizedPath;
2499
2500
  }
2500
2501
 
2501
- // Try partial match
2502
+ // 2. Collect ALL suffix/partial candidates
2503
+ const candidates = [];
2502
2504
  for (const [absPath, entry] of this.files) {
2503
- if (entry.relativePath === filePath || absPath.endsWith(filePath)) {
2504
- return absPath;
2505
+ if (entry.relativePath === filePath || absPath.endsWith('/' + filePath)) {
2506
+ candidates.push(absPath);
2505
2507
  }
2506
2508
  }
2507
2509
 
2510
+ if (candidates.length === 0) {
2511
+ return { error: 'file-not-found', filePath };
2512
+ }
2513
+ if (candidates.length === 1) {
2514
+ return candidates[0];
2515
+ }
2516
+ return {
2517
+ error: 'file-ambiguous',
2518
+ filePath,
2519
+ candidates: candidates.map(c => this.files.get(c)?.relativePath || path.relative(this.root, c))
2520
+ };
2521
+ }
2522
+
2523
+ /**
2524
+ * Find a file by path (supports partial paths)
2525
+ * Backward-compatible wrapper — returns null on error.
2526
+ */
2527
+ findFile(filePath) {
2528
+ const result = this.resolveFilePathForQuery(filePath);
2529
+ if (typeof result === 'string') return result;
2508
2530
  return null;
2509
2531
  }
2510
2532
 
@@ -2514,11 +2536,10 @@ class ProjectIndex {
2514
2536
  * @returns {Array} Exported symbols from that file
2515
2537
  */
2516
2538
  fileExports(filePath) {
2517
- const absPath = this.findFile(filePath);
2518
- if (!absPath) {
2519
- return { error: 'file-not-found', filePath };
2520
- }
2539
+ const resolved = this.resolveFilePathForQuery(filePath);
2540
+ if (typeof resolved !== 'string') return resolved;
2521
2541
 
2542
+ const absPath = resolved;
2522
2543
  const fileEntry = this.files.get(absPath);
2523
2544
  if (!fileEntry) {
2524
2545
  return [];
@@ -2960,24 +2981,10 @@ class ProjectIndex {
2960
2981
  const rawDepth = options.maxDepth ?? 5;
2961
2982
  const maxDepth = Math.max(0, rawDepth);
2962
2983
 
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
- }
2984
+ const resolved = this.resolveFilePathForQuery(filePath);
2985
+ if (typeof resolved !== 'string') return resolved;
2977
2986
 
2978
- if (!this.files.has(targetPath)) {
2979
- return { error: 'file-not-found', filePath };
2980
- }
2987
+ const targetPath = resolved;
2981
2988
 
2982
2989
  const buildSubgraph = (dir) => {
2983
2990
  const visited = new Set();
@@ -3176,7 +3183,8 @@ class ProjectIndex {
3176
3183
  }
3177
3184
  // Sort by number of shared parts
3178
3185
  related.similarNames.sort((a, b) => b.sharedParts.length - a.sharedParts.length);
3179
- if (!options.all) related.similarNames = related.similarNames.slice(0, 10);
3186
+ const similarLimit = options.top || (options.all ? Infinity : 10);
3187
+ if (related.similarNames.length > similarLimit) related.similarNames = related.similarNames.slice(0, similarLimit);
3180
3188
 
3181
3189
  // 3. Shared callers - functions called by the same callers
3182
3190
  const myCallers = new Set(this.findCallers(name).map(c => c.callerName).filter(Boolean));
@@ -3194,7 +3202,7 @@ class ProjectIndex {
3194
3202
  }
3195
3203
  }
3196
3204
  // Sort by shared caller count
3197
- const maxShared = options.all ? Infinity : 5;
3205
+ const maxShared = options.top || (options.all ? Infinity : 5);
3198
3206
  const sorted = Array.from(callerCounts.entries())
3199
3207
  .sort((a, b) => b[1] - a[1])
3200
3208
  .slice(0, maxShared);
@@ -3230,7 +3238,7 @@ class ProjectIndex {
3230
3238
  // Sort by shared callee count
3231
3239
  const sorted = Array.from(calleeCounts.entries())
3232
3240
  .sort((a, b) => b[1] - a[1])
3233
- .slice(0, options.all ? Infinity : 5);
3241
+ .slice(0, options.top || (options.all ? Infinity : 5));
3234
3242
  for (const [symName, count] of sorted) {
3235
3243
  const sym = this.symbols.get(symName)?.[0];
3236
3244
  if (sym) {
@@ -4408,7 +4416,8 @@ class ProjectIndex {
4408
4416
  });
4409
4417
  }
4410
4418
  } catch (e) {
4411
- // Skip unreadable files
4419
+ // Expected: binary/minified files fail to read or parse.
4420
+ // These are not actionable errors — silently skip.
4412
4421
  }
4413
4422
  }
4414
4423
 
package/mcp/server.js CHANGED
@@ -158,6 +158,31 @@ function toolError(message) {
158
158
  return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
159
159
  }
160
160
 
161
+ /**
162
+ * Resolve a file path via index and validate it's within the project root.
163
+ * Returns the resolved absolute path string, or a toolError response.
164
+ */
165
+ function resolveAndValidatePath(index, file) {
166
+ const resolved = index.resolveFilePathForQuery(file);
167
+ if (typeof resolved !== 'string') {
168
+ if (resolved.error === 'file-ambiguous') {
169
+ return toolError(`Ambiguous file "${file}". Candidates:\n${resolved.candidates.map(c => ' ' + c).join('\n')}`);
170
+ }
171
+ return toolError(`File not found: ${file}`);
172
+ }
173
+ // Path boundary check: ensure resolved path is within the project root
174
+ try {
175
+ const realPath = fs.realpathSync(resolved);
176
+ const realRoot = fs.realpathSync(index.root);
177
+ if (realPath !== realRoot && !realPath.startsWith(realRoot + path.sep)) {
178
+ return toolError(`File is outside project root: ${file}`);
179
+ }
180
+ } catch (e) {
181
+ return toolError(`Cannot resolve file path: ${file}`);
182
+ }
183
+ return resolved;
184
+ }
185
+
161
186
  function requireName(name) {
162
187
  if (!name || !name.trim()) {
163
188
  return toolError('Symbol name is required.');
@@ -371,9 +396,10 @@ server.registerTool(
371
396
  const err = requireName(name);
372
397
  if (err) return err;
373
398
  const index = getIndex(project_dir);
374
- const result = index.related(name, { file, all: top !== undefined });
399
+ const result = index.related(name, { file, top, all: top !== undefined });
375
400
  return toolResult(output.formatRelated(result, {
376
401
  showAll: top !== undefined,
402
+ top,
377
403
  allHint: 'Repeat with top set higher to show all.'
378
404
  }));
379
405
  }
@@ -464,6 +490,9 @@ server.registerTool(
464
490
  }
465
491
 
466
492
  const match = matches.length > 1 ? pickBestDefinition(matches) : matches[0];
493
+ // Validate file is within project root
494
+ const fnPathCheck = resolveAndValidatePath(index, match.relativePath || path.relative(index.root, match.file));
495
+ if (typeof fnPathCheck !== 'string') return fnPathCheck;
467
496
  const code = fs.readFileSync(match.file, 'utf-8');
468
497
  const codeLines = code.split('\n');
469
498
  const fnCode = codeLines.slice(match.startLine - 1, match.endLine).join('\n');
@@ -489,6 +518,9 @@ server.registerTool(
489
518
  }
490
519
 
491
520
  const match = matches.length > 1 ? pickBestDefinition(matches) : matches[0];
521
+ // Validate file is within project root
522
+ const clsPathCheck = resolveAndValidatePath(index, match.relativePath || path.relative(index.root, match.file));
523
+ if (typeof clsPathCheck !== 'string') return clsPathCheck;
492
524
 
493
525
  const code = fs.readFileSync(match.file, 'utf-8');
494
526
  const codeLines = code.split('\n');
@@ -499,6 +531,10 @@ server.registerTool(
499
531
  note = `Note: Found ${matches.length} definitions for "${name}". Showing ${match.relativePath}:${match.startLine}. Use file parameter to disambiguate.\n\n`;
500
532
  }
501
533
 
534
+ if (max_lines !== undefined && (!Number.isInteger(max_lines) || max_lines < 1)) {
535
+ return toolError(`Invalid max_lines: ${max_lines}. Must be a positive integer.`);
536
+ }
537
+
502
538
  const classLineCount = match.endLine - match.startLine + 1;
503
539
 
504
540
  // Large class: show summary by default, truncated source with max_lines
@@ -537,10 +573,9 @@ server.registerTool(
537
573
  return toolError('Line range is required (e.g. "10-20" or "15").');
538
574
  }
539
575
  const index = getIndex(project_dir);
540
- const filePath = index.findFile(file);
541
- if (!filePath) {
542
- return toolError(`File not found: ${file}`);
543
- }
576
+ const resolved = resolveAndValidatePath(index, file);
577
+ if (typeof resolved !== 'string') return resolved; // toolError response
578
+ const filePath = resolved;
544
579
 
545
580
  const parts = range.split('-');
546
581
  const start = parseInt(parts[0], 10);
@@ -552,12 +587,18 @@ server.registerTool(
552
587
  if (start < 1) {
553
588
  return toolError(`Invalid start line: ${start}. Line numbers must be >= 1`);
554
589
  }
590
+ if (end < 1) {
591
+ return toolError(`Invalid end line: ${end}. Line numbers must be >= 1`);
592
+ }
593
+ if (end < start) {
594
+ return toolError(`Invalid range: end line (${end}) must be >= start line (${start})`);
595
+ }
555
596
 
556
597
  const content = fs.readFileSync(filePath, 'utf-8');
557
598
  const fileLines = content.split('\n');
558
599
 
559
- const startLine = Math.min(start, end);
560
- const endLine = Math.max(start, end);
600
+ const startLine = start;
601
+ const endLine = end;
561
602
 
562
603
  if (startLine > fileLines.length) {
563
604
  return toolError(`Line ${startLine} is out of bounds. File has ${fileLines.length} lines.`);
@@ -616,6 +657,16 @@ server.registerTool(
616
657
  if (!filePath || !fs.existsSync(filePath)) {
617
658
  return toolError(`Cannot locate file for ${match.name}`);
618
659
  }
660
+ // Validate file is within project root
661
+ try {
662
+ const realPath = fs.realpathSync(filePath);
663
+ const realRoot = fs.realpathSync(index.root);
664
+ if (realPath !== realRoot && !realPath.startsWith(realRoot + path.sep)) {
665
+ return toolError(`File is outside project root: ${match.name}`);
666
+ }
667
+ } catch (e) {
668
+ return toolError(`Cannot resolve file path for ${match.name}`);
669
+ }
619
670
 
620
671
  const content = fs.readFileSync(filePath, 'utf-8');
621
672
  const fileLines = content.split('\n');
@@ -645,6 +696,7 @@ server.registerTool(
645
696
  const index = getIndex(project_dir);
646
697
  const result = index.imports(file);
647
698
  if (result?.error === 'file-not-found') return toolError(`File not found in project: ${file}`);
699
+ if (result?.error === 'file-ambiguous') return toolError(`Ambiguous file "${file}". Candidates:\n${result.candidates.map(c => ' ' + c).join('\n')}`);
648
700
  return toolResult(output.formatImports(result, file));
649
701
  }
650
702
 
@@ -655,6 +707,7 @@ server.registerTool(
655
707
  const index = getIndex(project_dir);
656
708
  const result = index.exporters(file);
657
709
  if (result?.error === 'file-not-found') return toolError(`File not found in project: ${file}`);
710
+ if (result?.error === 'file-ambiguous') return toolError(`Ambiguous file "${file}". Candidates:\n${result.candidates.map(c => ' ' + c).join('\n')}`);
658
711
  return toolResult(output.formatExporters(result, file));
659
712
  }
660
713
 
@@ -665,6 +718,7 @@ server.registerTool(
665
718
  const index = getIndex(project_dir);
666
719
  const result = index.fileExports(file);
667
720
  if (result?.error === 'file-not-found') return toolError(`File not found in project: ${file}`);
721
+ if (result?.error === 'file-ambiguous') return toolError(`Ambiguous file "${file}". Candidates:\n${result.candidates.map(c => ' ' + c).join('\n')}`);
668
722
  return toolResult(output.formatFileExports(result, file));
669
723
  }
670
724
 
@@ -675,6 +729,7 @@ server.registerTool(
675
729
  const index = getIndex(project_dir);
676
730
  const result = index.graph(file, { direction: direction || 'both', maxDepth: depth ?? 2 });
677
731
  if (result?.error === 'file-not-found') return toolError(`File not found in project: ${file}`);
732
+ if (result?.error === 'file-ambiguous') return toolError(`Ambiguous file "${file}". Candidates:\n${result.candidates.map(c => ' ' + c).join('\n')}`);
678
733
  return toolResult(output.formatGraph(result, {
679
734
  showAll: depth !== undefined,
680
735
  file,
@@ -713,6 +768,10 @@ server.registerTool(
713
768
  }
714
769
 
715
770
  case 'diff_impact': {
771
+ // Validate git ref format to prevent argument injection
772
+ if (base && !/^[a-zA-Z0-9._\-~\/^@{}:]+$/.test(base)) {
773
+ return toolError(`Invalid git ref format: ${base}`);
774
+ }
716
775
  const index = getIndex(project_dir);
717
776
  const result = index.diffImpact({
718
777
  base: base || 'HEAD',
@@ -745,8 +804,10 @@ server.registerTool(
745
804
 
746
805
  case 'api': {
747
806
  const index = getIndex(project_dir);
748
- const symbols = index.api(file || undefined);
749
- return toolResult(output.formatApi(symbols, file || '.'));
807
+ const result = index.api(file || undefined);
808
+ if (result?.error === 'file-not-found') return toolError(`File not found in project: ${file}`);
809
+ if (result?.error === 'file-ambiguous') return toolError(`Ambiguous file "${file}". Candidates:\n${result.candidates.map(c => ' ' + c).join('\n')}`);
810
+ return toolResult(output.formatApi(result, file || '.'));
750
811
  }
751
812
 
752
813
  case 'stats': {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "3.7.3",
3
+ "version": "3.7.5",
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": {
@@ -358,6 +358,112 @@ 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
+ },
414
+
415
+ // ========================================================================
416
+ // CATEGORY 3: Security (path traversal, argument injection)
417
+ // ========================================================================
418
+ {
419
+ category: 'Security',
420
+ tool: 'ucn',
421
+ desc: 'lines rejects path traversal (../../../../etc/passwd)',
422
+ args: { command: 'lines', project_dir: PROJECT_DIR, file: '../../../../etc/passwd', range: '1-5' },
423
+ assert: (res, text, isError) => (isError && (/not found/i.test(text) || /outside project/i.test(text))) || 'Expected error for path traversal'
424
+ },
425
+ {
426
+ category: 'Security',
427
+ tool: 'ucn',
428
+ desc: 'lines rejects path traversal (../../other-project/secret.js)',
429
+ args: { command: 'lines', project_dir: PROJECT_DIR, file: '../../other-project/secret.js', range: '1-5' },
430
+ assert: (res, text, isError) => (isError && (/not found/i.test(text) || /outside project/i.test(text))) || 'Expected error for path traversal'
431
+ },
432
+ {
433
+ category: 'Security',
434
+ tool: 'ucn',
435
+ desc: 'lines works with valid file',
436
+ args: { command: 'lines', project_dir: PROJECT_DIR, file: 'core/discovery.js', range: '1-3' },
437
+ assert: (res, text, isError) => (!isError && text.length > 0) || 'Expected valid output for core/discovery.js'
438
+ },
439
+ {
440
+ category: 'Security',
441
+ tool: 'ucn',
442
+ desc: 'diff_impact rejects --config argument injection',
443
+ args: { command: 'diff_impact', project_dir: PROJECT_DIR, base: '--config=malicious' },
444
+ assert: (res, text, isError) => (isError && /invalid git ref/i.test(text)) || 'Expected error for argument injection in base'
445
+ },
446
+ {
447
+ category: 'Security',
448
+ tool: 'ucn',
449
+ desc: 'diff_impact rejects -o flag injection',
450
+ args: { command: 'diff_impact', project_dir: PROJECT_DIR, base: '-o /tmp/evil' },
451
+ assert: (res, text, isError) => (isError && /invalid git ref/i.test(text)) || 'Expected error for flag injection in base'
452
+ },
453
+ {
454
+ category: 'Security',
455
+ tool: 'ucn',
456
+ desc: 'diff_impact accepts valid ref HEAD~3',
457
+ args: { command: 'diff_impact', project_dir: PROJECT_DIR, base: 'HEAD~3' },
458
+ assert: (res, text, isError) => true // Should not error on valid ref format
459
+ },
460
+ {
461
+ category: 'Security',
462
+ tool: 'ucn',
463
+ desc: 'diff_impact accepts valid ref origin/main',
464
+ args: { command: 'diff_impact', project_dir: PROJECT_DIR, base: 'origin/main' },
465
+ assert: (res, text, isError) => true // Should not error on valid ref format
466
+ },
361
467
  ];
362
468
 
363
469
  // ============================================================================
@@ -406,6 +512,15 @@ async function run() {
406
512
  const preview = text.substring(0, 120).replace(/\n/g, '\\n');
407
513
  status = 'PASS';
408
514
  detail = `${isError ? 'ERROR response' : 'OK'}: "${preview}" (${elapsed}ms)`;
515
+
516
+ // Run assertion if provided
517
+ if (t.assert && status === 'PASS') {
518
+ const assertResult = t.assert(res, text, isError);
519
+ if (assertResult !== true) {
520
+ status = 'FAIL';
521
+ detail = `ASSERTION: ${assertResult} (${elapsed}ms)`;
522
+ }
523
+ }
409
524
  } else {
410
525
  status = 'PASS';
411
526
  detail = `Empty result (${elapsed}ms)`;
@@ -13025,5 +13025,59 @@ it('FIX 99 — parseDiff handles quoted paths with special characters', () => {
13025
13025
  'Literal backslash-n in filename must be preserved, not converted to newline');
13026
13026
  });
13027
13027
 
13028
+ // ============================================================================
13029
+ // Interactive Mode Tests (Fix #100 — missing commands)
13030
+ // ============================================================================
13031
+
13032
+ describe('Interactive Mode', () => {
13033
+ const { execFileSync } = require('child_process');
13034
+ const cliPath = path.join(__dirname, '..', 'cli', 'index.js');
13035
+
13036
+ it('supports all commands without errors', () => {
13037
+ // Test each previously-missing command by piping into interactive mode
13038
+ // Each should not crash and should produce some output (not "Unknown command")
13039
+ const commands = [
13040
+ 'deadcode',
13041
+ 'related processData',
13042
+ 'example processData',
13043
+ 'verify processData',
13044
+ 'expand 1', // Will say "no expandable items" but won't crash
13045
+ ];
13046
+
13047
+ const input = commands.join('\n') + '\nquit\n';
13048
+
13049
+ const result = execFileSync('node', [cliPath, '--interactive', '.'], {
13050
+ input,
13051
+ encoding: 'utf-8',
13052
+ cwd: path.join(__dirname, '..'),
13053
+ timeout: 30000,
13054
+ stdio: ['pipe', 'pipe', 'pipe']
13055
+ });
13056
+
13057
+ // Verify no "Unknown command" errors for the previously-missing commands
13058
+ assert.ok(!result.includes('Unknown command: deadcode'), 'deadcode should be recognized in interactive mode');
13059
+ assert.ok(!result.includes('Unknown command: related'), 'related should be recognized in interactive mode');
13060
+ assert.ok(!result.includes('Unknown command: example'), 'example should be recognized in interactive mode');
13061
+ assert.ok(!result.includes('Unknown command: verify'), 'verify should be recognized in interactive mode');
13062
+ assert.ok(!result.includes('Unknown command: expand'), 'expand should be recognized in interactive mode');
13063
+ });
13064
+
13065
+ it('help lists all commands', () => {
13066
+ const result = execFileSync('node', [cliPath, '--interactive', '.'], {
13067
+ input: 'help\nquit\n',
13068
+ encoding: 'utf-8',
13069
+ cwd: path.join(__dirname, '..'),
13070
+ timeout: 30000,
13071
+ stdio: ['pipe', 'pipe', 'pipe']
13072
+ });
13073
+
13074
+ // Verify the help text includes all commands (including newly added ones)
13075
+ const expectedCommands = ['expand', 'deadcode', 'related', 'example', 'verify', 'plan', 'stacktrace'];
13076
+ for (const cmd of expectedCommands) {
13077
+ assert.ok(result.includes(cmd), `Interactive help should list "${cmd}"`);
13078
+ }
13079
+ });
13080
+ });
13081
+
13028
13082
  console.log('UCN v3 Test Suite');
13029
13083
  console.log('Run with: node --test test/parser.test.js');