ucn 3.7.45 → 3.7.47

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.
@@ -1,17 +1,17 @@
1
1
  ---
2
2
  name: ucn
3
- description: "Code relationship analyzer (callers, call trees, impact, dead code) via tree-sitter AST. PREFER over grep+read when you need: who calls a function, what breaks if you change it, or the full call chain of a pipeline. One `ucn about` replaces 3-4 grep+read cycles. One `ucn trace` maps an entire execution flow without reading any files. Works on JS/TS, Python, Go, Rust, Java, HTML. Skip for plain text search or codebases under 500 LOC."
3
+ description: "Code intelligence toolkit — extract functions, trace callers, analyze impact, detect dead code without reading whole files. PREFER over grep+read when you need: who calls a function, what breaks if you change it, or the full call chain of a pipeline. One `ucn about` replaces 3-4 grep+read cycles. One `ucn trace` maps an entire execution flow without reading any files. Works on JS/TS, Python, Go, Rust, Java, HTML. Skip for plain text search or codebases under 500 LOC."
4
4
  allowed-tools: Bash(ucn *), Bash(npx ucn *)
5
5
  argument-hint: "[command] [symbol-name] [--flags]"
6
6
  ---
7
7
 
8
8
  # UCN — Universal Code Navigator
9
9
 
10
- Understands code structure via tree-sitter ASTs: who calls what, what breaks if you change something, full call trees, dead code. Works on JS/TS, Python, Go, Rust, Java. Also parses HTML files (inline scripts and event handlers).
10
+ Extract functions, trace call chains, find callers, and detect dead code without reading entire files. Works on JS/TS, Python, Go, Rust, Java, and HTML (inline scripts and event handlers).
11
11
 
12
12
  ## When to Reach for UCN Instead of Grep/Read
13
13
 
14
- **Use UCN when your next action would be:**
14
+ **Use UCN when the next action would be:**
15
15
 
16
16
  - "Let me grep for all callers of this function" → `ucn impact <name>` — finds every call site, grouped by file, with args shown
17
17
  - "Let me read this 800-line file to find one function" → `ucn fn <name> --file=<hint>` — extracts just that function
@@ -145,6 +145,8 @@ ucn [target] <command> [name] [--flags]
145
145
  | `--detailed` | Show full symbol listing per file in `toc` |
146
146
  | `--top-level` | Show only top-level functions in `toc` (exclude nested/indented) |
147
147
  | `--top=N` | Limit result count (default: 10 for most commands) |
148
+ | `--limit=N` | Limit result count for `find`, `usages`, `search`, `deadcode`, `api`, `toc` |
149
+ | `--max-files=N` | Max files to index (for large projects with 10K+ files) |
148
150
  | `--max-lines=N` | Max source lines for `class` (large classes show summary by default) |
149
151
  | `--case-sensitive` | Case-sensitive text search (default: case-insensitive) |
150
152
  | `--exact` | Exact name match only in `find`/`typedef` (no substring) |
@@ -164,7 +166,7 @@ ucn trace problematic_function --depth=2 # See what it calls
164
166
  ```bash
165
167
  ucn impact the_function # Who will break?
166
168
  ucn smart the_function # See it + its helpers
167
- # ... make your changes ...
169
+ # ... make changes ...
168
170
  ucn verify the_function # Did all call sites survive?
169
171
  ```
170
172
 
package/README.md CHANGED
@@ -1,14 +1,14 @@
1
- # UCN Universal Code Navigator
1
+ # UCN - Universal Code Navigator
2
2
 
3
- AST-powered code intelligence from the terminal.
3
+ Code intelligence for AI agents and developers - understand, extract, and navigate code without reading whole files.
4
4
 
5
- UCN answers structural code questions instantly:
6
- - Who calls this function?
7
- - What breaks if I change this signature?
8
- - What changed in this diff, and who depends on it?
9
- - What code is safe to delete?
5
+ Precise answers to structural code questions:
6
+ - Who calls this function? → without grepping the whole project
7
+ - What breaks if I change this? → every call site, with arguments
8
+ - What does this function do? extracted with dependencies inline
9
+ - What code is safe to delete? → verified unused symbols
10
10
 
11
- Instead of reading full files, UCN gives precise, AST-verified answers.
11
+ One command replaces 3-4 grep+read cycles. Powered by tree-sitter.
12
12
 
13
13
  [![npm](https://img.shields.io/npm/v/ucn)](https://www.npmjs.com/package/ucn)
14
14
  [![license](https://img.shields.io/npm/l/ucn)](LICENSE)
@@ -20,22 +20,21 @@ Instead of reading full files, UCN gives precise, AST-verified answers.
20
20
  ```bash
21
21
  npm install -g ucn
22
22
 
23
+ ucn toc # project overview
24
+ ucn fn handleRequest # extract a function without reading the file
23
25
  ucn about handleRequest # full picture: definition, callers, callees, tests
24
26
  ucn impact handleRequest # all call sites with arguments
25
27
  ucn trace main --depth=3 # call tree, no file reads
26
28
  ucn deadcode # unused functions, AST-verified
27
- ucn fn handleRequest # extract a function without reading the file
28
- ucn toc # project overview
29
- ucn --interactive # REPL mode, index stays in memory
30
29
  ```
31
30
 
32
- Parses JS/TS, Python, Go, Rust, Java, and HTML with tree-sitter. Runs locally.
31
+ Supports JS/TS, Python, Go, Rust, Java, and HTML. Runs locally.
33
32
 
34
33
  ```
35
- Terminal AI Agents Agent Skills
36
- │ │
37
- CLI MCP Skill
38
- └────────────────────┼───────────────────────┘
34
+ Terminal AI Agents Agent Skills
35
+ │ │
36
+ CLI MCP Skill
37
+ └────────────────────┼────────────────────┘
39
38
 
40
39
  ┌──────┴──────┐
41
40
  │ UCN Engine │
@@ -48,7 +47,7 @@ Parses JS/TS, Python, Go, Rust, Java, and HTML with tree-sitter. Runs locally.
48
47
 
49
48
  ## Why UCN
50
49
 
51
- UCN uses tree-sitter to parse code into an AST, then builds a call graph and symbol table on top of it. Instead of matching text, it understands which functions call which, what depends on what, and what's unused. Everything runs locally.
50
+ AI agents waste tokens reading entire files to find one function, or grep for callers and miss half of them. UCN builds a structural index of the codebase - it knows which functions call which, what depends on what, and what's unused. One command gives what would take 3-4 file reads and greps.
52
51
 
53
52
  "What happens when `build()` runs?"
54
53
 
@@ -139,7 +138,7 @@ VS Code uses `.vscode/mcp.json`:
139
138
 
140
139
  </details>
141
140
 
142
- All 28 commands ship as a single MCP tool, under 2KB of schema in the agent's context.
141
+ All 28 commands ship as a single MCP tool - under 2KB of context.
143
142
 
144
143
  ### Agent Skill (no server needed)
145
144
 
@@ -294,13 +293,13 @@ ucn deadcode --exclude=test # what can be deleted?
294
293
 
295
294
  ## Limitations
296
295
 
297
- UCN is static AST analysis, not runtime instrumentation.
296
+ UCN analyzes code structure statically - it doesn't run code.
298
297
 
299
- - **5 languages + HTML** JS/TS, Python, Go, Rust, Java. Falls back to text search for others.
300
- - **Static analysis only** Can't follow `eval()`, `getattr()`, reflection, or other dynamic dispatch.
301
- - **Duck-typed methods** `obj.method()` in JS/TS/Python is marked "uncertain" when the receiver type is ambiguous. Go/Rust/Java resolve with high confidence.
302
- - **Single project scope** Follows imports within the project but not into `node_modules` or `site-packages`.
303
- - **First-query index time** A few seconds on large projects. Cached incrementally after that.
298
+ - **5 languages + HTML** - JS/TS, Python, Go, Rust, Java. Falls back to text search for others.
299
+ - **Static analysis only** - Can't follow `eval()`, `getattr()`, reflection, or other dynamic dispatch.
300
+ - **Duck-typed methods** - `obj.method()` in JS/TS/Python is marked "uncertain" when the receiver type is ambiguous. Go/Rust/Java resolve with high confidence.
301
+ - **Single project scope** - Follows imports within the project but not into `node_modules` or `site-packages`.
302
+ - **First-query index time** - A few seconds on large projects. Cached incrementally after that.
304
303
 
305
304
  ---
306
305
 
package/cli/index.js CHANGED
@@ -102,6 +102,8 @@ function parseFlags(tokens) {
102
102
  regex: tokens.includes('--no-regex') ? false : undefined,
103
103
  functions: tokens.includes('--functions'),
104
104
  className: getValueFlag('--class-name'),
105
+ limit: parseInt(getValueFlag('--limit') || '0') || undefined,
106
+ maxFiles: parseInt(getValueFlag('--max-files') || '0') || undefined,
105
107
  };
106
108
  }
107
109
 
@@ -126,7 +128,7 @@ const knownFlags = new Set([
126
128
  '--default', '--top', '--no-follow-symlinks',
127
129
  '--base', '--staged', '--stack',
128
130
  '--regex', '--no-regex', '--functions',
129
- '--max-lines', '--class-name'
131
+ '--max-lines', '--class-name', '--limit', '--max-files'
130
132
  ]);
131
133
 
132
134
  // Handle help flag
@@ -396,7 +398,7 @@ function runProjectCommand(rootDir, command, arg) {
396
398
  // If cache was loaded but stale, force rebuild to avoid duplicates
397
399
  let needsCacheSave = false;
398
400
  if (!usedCache) {
399
- index.build(null, { quiet: flags.quiet, forceRebuild: cacheWasLoaded, followSymlinks: flags.followSymlinks });
401
+ index.build(null, { quiet: flags.quiet, forceRebuild: cacheWasLoaded, followSymlinks: flags.followSymlinks, maxFiles: flags.maxFiles });
400
402
  needsCacheSave = flags.cache;
401
403
  }
402
404
 
@@ -408,8 +410,9 @@ function runProjectCommand(rootDir, command, arg) {
408
410
  // ── Commands using shared executor ───────────────────────────────
409
411
 
410
412
  case 'toc': {
411
- const { ok, result, error } = execute(index, 'toc', flags);
413
+ const { ok, result, error, note } = execute(index, 'toc', flags);
412
414
  if (!ok) fail(error);
415
+ if (note) console.error(note);
413
416
  printOutput(result, output.formatTocJson, r => output.formatToc(r, {
414
417
  detailedHint: 'Add --detailed to list all functions, or "ucn . about <name>" for full details on a symbol',
415
418
  uncertainHint: 'use --include-uncertain to include all'
@@ -429,8 +432,9 @@ function runProjectCommand(rootDir, command, arg) {
429
432
  }
430
433
 
431
434
  case 'usages': {
432
- const { ok, result, error } = execute(index, 'usages', { name: arg, ...flags });
435
+ const { ok, result, error, note } = execute(index, 'usages', { name: arg, ...flags });
433
436
  if (!ok) fail(error);
437
+ if (note) console.error(note);
434
438
  printOutput(result,
435
439
  r => output.formatUsagesJson(r, arg),
436
440
  r => output.formatUsages(r, arg)
@@ -665,8 +669,9 @@ function runProjectCommand(rootDir, command, arg) {
665
669
  }
666
670
 
667
671
  case 'api': {
668
- const { ok, result, error } = execute(index, 'api', { file: arg });
672
+ const { ok, result, error, note } = execute(index, 'api', { file: arg || flags.file, limit: flags.limit });
669
673
  if (!ok) fail(error);
674
+ if (note) console.error(note);
670
675
  printOutput(result,
671
676
  r => output.formatApiJson(r, arg),
672
677
  r => output.formatApi(r, arg)
@@ -685,8 +690,9 @@ function runProjectCommand(rootDir, command, arg) {
685
690
  }
686
691
 
687
692
  case 'deadcode': {
688
- const { ok, result, error } = execute(index, 'deadcode', { ...flags, in: flags.in || subdirScope });
693
+ const { ok, result, error, note } = execute(index, 'deadcode', { ...flags, in: flags.in || subdirScope });
689
694
  if (!ok) fail(error);
695
+ if (note) console.error(note);
690
696
  printOutput(result,
691
697
  output.formatDeadcodeJson,
692
698
  r => output.formatDeadcode(r, {
@@ -1040,6 +1046,8 @@ Common Flags:
1040
1046
  --direction=X Graph direction: imports, importers, or both (default: both)
1041
1047
  --all Expand truncated sections (about, trace, graph, related)
1042
1048
  --top=N Limit results (find, deadcode)
1049
+ --limit=N Limit result count (find, usages, search, deadcode, api, toc)
1050
+ --max-files=N Max files to index (large projects)
1043
1051
  --context=N Lines of context around matches
1044
1052
  --json Machine-readable output
1045
1053
  --code-only Filter out comments and strings
package/core/cache.js CHANGED
@@ -30,10 +30,10 @@ function saveCache(index, cachePath) {
30
30
 
31
31
  const cacheFile = cachePath || path.join(cacheDir, 'index.json');
32
32
 
33
- // Prepare callsCache for serialization (exclude content to save space)
33
+ // Prepare callsCache for serialization (exclude content, use relative paths)
34
34
  const callsCacheData = [];
35
35
  for (const [filePath, entry] of index.callsCache) {
36
- callsCacheData.push([filePath, {
36
+ callsCacheData.push([path.relative(index.root, filePath), {
37
37
  mtime: entry.mtime,
38
38
  hash: entry.hash,
39
39
  calls: entry.calls
@@ -41,23 +41,71 @@ function saveCache(index, cachePath) {
41
41
  }]);
42
42
  }
43
43
 
44
+ // Hash config to detect when graph rebuild is needed on load
45
+ const configHash = crypto.createHash('md5')
46
+ .update(JSON.stringify(index.config || {})).digest('hex');
47
+
48
+ // Strip redundant fields from symbols and file entries to reduce cache size.
49
+ // v6: All paths stored as relative paths (saves ~60% on large codebases).
50
+ // symbol.file = path.join(root, symbol.relativePath) — reconstructable
51
+ // symbol.bindingId = relativePath:type:startLine — reconstructable
52
+ // fileEntry.path = Map key — redundant
53
+ // fileEntry.relativePath = now the Map key — redundant
54
+ const root = index.root;
55
+ const strippedSymbols = [];
56
+ for (const [name, defs] of index.symbols) {
57
+ const stripped = defs.map(s => {
58
+ const { file, bindingId, ...rest } = s;
59
+ return rest;
60
+ });
61
+ strippedSymbols.push([name, stripped]);
62
+ }
63
+ // Files: use relativePath as key, strip path, relativePath, symbols, and bindings from entries.
64
+ // symbols/bindings are already stored in the top-level symbols map — no need to duplicate.
65
+ const strippedFiles = [];
66
+ for (const [, entry] of index.files) {
67
+ const { path: _p, relativePath: rp, symbols: _s, bindings: _b, ...rest } = entry;
68
+ strippedFiles.push([rp, rest]);
69
+ }
70
+
71
+ // Convert graph paths from absolute to relative
72
+ const relGraph = (graph) => {
73
+ const result = [];
74
+ for (const [absKey, absValues] of graph) {
75
+ const relKey = path.relative(root, absKey);
76
+ const relValues = absValues.map(v => path.relative(root, v));
77
+ result.push([relKey, relValues]);
78
+ }
79
+ return result;
80
+ };
81
+
44
82
  const cacheData = {
45
- version: 4, // v4: className, memberType, isMethod for all languages
83
+ version: 7, // v7: strip symbols/bindings from file entries (dedup ~45% cache reduction)
46
84
  ucnVersion: UCN_VERSION, // Invalidate cache when UCN is updated
47
- root: index.root,
85
+ configHash,
86
+ root,
48
87
  buildTime: index.buildTime,
49
88
  timestamp: Date.now(),
50
- files: Array.from(index.files.entries()),
51
- symbols: Array.from(index.symbols.entries()),
52
- importGraph: Array.from(index.importGraph.entries()),
53
- exportGraph: Array.from(index.exportGraph.entries()),
89
+ files: strippedFiles,
90
+ symbols: strippedSymbols,
91
+ importGraph: relGraph(index.importGraph),
92
+ exportGraph: relGraph(index.exportGraph),
93
+ // extendsGraph/extendedByGraph use class names as keys (not file paths)
54
94
  extendsGraph: Array.from(index.extendsGraph.entries()),
55
95
  extendedByGraph: Array.from(index.extendedByGraph.entries()),
56
- callsCache: callsCacheData,
57
- failedFiles: index.failedFiles ? Array.from(index.failedFiles) : []
96
+ failedFiles: index.failedFiles
97
+ ? Array.from(index.failedFiles).map(f => path.relative(root, f))
98
+ : []
58
99
  };
59
100
 
60
101
  fs.writeFileSync(cacheFile, JSON.stringify(cacheData));
102
+
103
+ // Save callsCache to a separate file (lazy-loaded on demand)
104
+ if (callsCacheData.length > 0) {
105
+ const callsCacheFile = path.join(path.dirname(cacheFile), 'calls-cache.json');
106
+ fs.writeFileSync(callsCacheFile, JSON.stringify(callsCacheData));
107
+ }
108
+
61
109
  return cacheFile;
62
110
  }
63
111
 
@@ -78,9 +126,8 @@ function loadCache(index, cachePath) {
78
126
  const cacheData = JSON.parse(fs.readFileSync(cacheFile, 'utf-8'));
79
127
 
80
128
  // Check version compatibility
81
- // v4 adds className, memberType, isMethod for all languages
82
- // Only accept exactly version 4 (or future versions handled explicitly)
83
- if (cacheData.version !== 4) {
129
+ // v7: symbols/bindings stripped from file entries (dedup)
130
+ if (cacheData.version !== 7) {
84
131
  return false;
85
132
  }
86
133
 
@@ -97,13 +144,57 @@ function loadCache(index, cachePath) {
97
144
  return false;
98
145
  }
99
146
 
100
- index.files = new Map(cacheData.files);
147
+ const root = cacheData.root || index.root;
148
+
149
+ // Reconstruct files Map: relative key → absolute key, restore path and relativePath
150
+ // Initialize symbols/bindings arrays (will be populated from top-level symbols)
151
+ index.files = new Map();
152
+ for (const [relPath, entry] of cacheData.files) {
153
+ const absPath = path.join(root, relPath);
154
+ entry.path = absPath;
155
+ entry.relativePath = relPath;
156
+ if (!entry.symbols) entry.symbols = [];
157
+ if (!entry.bindings) entry.bindings = [];
158
+ index.files.set(absPath, entry);
159
+ }
160
+
161
+ // Reconstruct symbols: restore file and bindingId from relativePath
162
+ // Also rebuild fileEntry.symbols and fileEntry.bindings from top-level data
101
163
  index.symbols = new Map(cacheData.symbols);
102
- index.importGraph = new Map(cacheData.importGraph);
103
- index.exportGraph = new Map(cacheData.exportGraph);
164
+ for (const [, defs] of index.symbols) {
165
+ for (const s of defs) {
166
+ if (!s.file && s.relativePath) s.file = path.join(root, s.relativePath);
167
+ if (!s.bindingId && s.relativePath && s.type && s.startLine) {
168
+ s.bindingId = `${s.relativePath}:${s.type}:${s.startLine}`;
169
+ }
170
+ // Rebuild fileEntry.symbols and bindings from top-level symbols
171
+ const fileEntry = index.files.get(s.file);
172
+ if (fileEntry) {
173
+ fileEntry.symbols.push(s);
174
+ fileEntry.bindings.push({
175
+ id: s.bindingId,
176
+ name: s.name,
177
+ type: s.type,
178
+ startLine: s.startLine
179
+ });
180
+ }
181
+ }
182
+ }
183
+
184
+ // Reconstruct graphs: relative paths → absolute paths
185
+ const absGraph = (data) => {
186
+ const m = new Map();
187
+ for (const [relKey, relValues] of data) {
188
+ m.set(path.join(root, relKey), relValues.map(v => path.join(root, v)));
189
+ }
190
+ return m;
191
+ };
192
+ index.importGraph = absGraph(cacheData.importGraph);
193
+ index.exportGraph = absGraph(cacheData.exportGraph);
104
194
  index.buildTime = cacheData.buildTime;
105
195
 
106
196
  // Restore optional graphs if present
197
+ // extendsGraph/extendedByGraph use class names as keys (not file paths)
107
198
  if (Array.isArray(cacheData.extendsGraph)) {
108
199
  index.extendsGraph = new Map(cacheData.extendsGraph);
109
200
  }
@@ -111,19 +202,26 @@ function loadCache(index, cachePath) {
111
202
  index.extendedByGraph = new Map(cacheData.extendedByGraph);
112
203
  }
113
204
 
114
- // Restore callsCache if present (v2+)
115
- if (Array.isArray(cacheData.callsCache)) {
116
- index.callsCache = new Map(cacheData.callsCache);
205
+ // Eagerly load callsCache from separate file.
206
+ // Prevents 10K cold tree-sitter re-parses (2GB+ peak) when findCallers runs.
207
+ if (index.callsCache.size === 0) {
208
+ loadCallsCache(index);
117
209
  }
118
210
 
119
- // Restore failedFiles if present
211
+ // Restore failedFiles if present (convert relative paths back to absolute)
120
212
  if (Array.isArray(cacheData.failedFiles)) {
121
- index.failedFiles = new Set(cacheData.failedFiles);
213
+ index.failedFiles = new Set(
214
+ cacheData.failedFiles.map(f => path.isAbsolute(f) ? f : path.join(root, f))
215
+ );
122
216
  }
123
217
 
124
- // Rebuild derived graphs to ensure consistency with current config
125
- index.buildImportGraph();
126
- index.buildInheritanceGraph();
218
+ // Only rebuild graphs if config changed (e.g., aliases modified)
219
+ const currentConfigHash = crypto.createHash('md5')
220
+ .update(JSON.stringify(index.config || {})).digest('hex');
221
+ if (currentConfigHash !== cacheData.configHash) {
222
+ index.buildImportGraph();
223
+ index.buildInheritanceGraph();
224
+ }
127
225
 
128
226
  return true;
129
227
  } catch (e) {
@@ -137,32 +235,9 @@ function loadCache(index, cachePath) {
137
235
  * @returns {boolean} - True if cache needs rebuilding
138
236
  */
139
237
  function isCacheStale(index) {
140
- // Check for new files added to project
141
- // Use same ignores as build() .gitignore + .ucn.json exclude
142
- const pattern = detectProjectPattern(index.root);
143
- const globOpts = { root: index.root };
144
- const gitignorePatterns = parseGitignore(index.root);
145
- const configExclude = index.config.exclude || [];
146
- if (gitignorePatterns.length > 0 || configExclude.length > 0) {
147
- globOpts.ignores = [...DEFAULT_IGNORES, ...gitignorePatterns, ...configExclude];
148
- }
149
- const currentFiles = expandGlob(pattern, globOpts);
150
- const cachedPaths = new Set(index.files.keys());
151
-
152
- for (const file of currentFiles) {
153
- if (!cachedPaths.has(file) && !(index.failedFiles && index.failedFiles.has(file))) {
154
- return true; // New file found
155
- }
156
- }
157
-
158
- // Check existing cached files for modifications/deletions
238
+ // Fast path: check cached files for modifications/deletions first (stat-only).
239
+ // This returns early without the expensive directory walk when any file changed.
159
240
  for (const [filePath, fileEntry] of index.files) {
160
- // File deleted
161
- if (!fs.existsSync(filePath)) {
162
- return true;
163
- }
164
-
165
- // File modified - check size first, then mtime, then hash
166
241
  try {
167
242
  const stat = fs.statSync(filePath);
168
243
 
@@ -183,11 +258,61 @@ function isCacheStale(index) {
183
258
  return true;
184
259
  }
185
260
  } catch (e) {
186
- return true;
261
+ return true; // File deleted or inaccessible
262
+ }
263
+ }
264
+
265
+ // Slow path: glob the project to detect new files added since last build.
266
+ // Only reached when all cached files are unchanged.
267
+ const pattern = detectProjectPattern(index.root);
268
+ const globOpts = { root: index.root };
269
+ const gitignorePatterns = parseGitignore(index.root);
270
+ const configExclude = index.config.exclude || [];
271
+ if (gitignorePatterns.length > 0 || configExclude.length > 0) {
272
+ globOpts.ignores = [...DEFAULT_IGNORES, ...gitignorePatterns, ...configExclude];
273
+ }
274
+ const currentFiles = expandGlob(pattern, globOpts);
275
+ const cachedPaths = new Set(index.files.keys());
276
+
277
+ for (const file of currentFiles) {
278
+ if (!cachedPaths.has(file) && !(index.failedFiles && index.failedFiles.has(file))) {
279
+ return true; // New file found
187
280
  }
188
281
  }
189
282
 
190
283
  return false;
191
284
  }
192
285
 
193
- module.exports = { saveCache, loadCache, isCacheStale };
286
+ /**
287
+ * Load callsCache from separate file on demand.
288
+ * Only loads if callsCache is empty (not already populated from inline or prior load).
289
+ * @param {object} index - ProjectIndex instance
290
+ * @returns {boolean} - True if loaded successfully
291
+ */
292
+ function loadCallsCache(index) {
293
+ if (index.callsCache.size > 0) return true; // Already populated
294
+ if (index._callsCacheLoaded) return false; // Already attempted, file didn't exist
295
+ index._callsCacheLoaded = true;
296
+
297
+ const callsCacheFile = path.join(index.root, '.ucn-cache', 'calls-cache.json');
298
+ if (!fs.existsSync(callsCacheFile)) return false;
299
+
300
+ try {
301
+ const data = JSON.parse(fs.readFileSync(callsCacheFile, 'utf-8'));
302
+ if (Array.isArray(data)) {
303
+ // Convert relative paths back to absolute
304
+ const absData = data.map(([relPath, entry]) => {
305
+ // Handle both relative (v6+) and absolute (legacy) paths
306
+ const absPath = path.isAbsolute(relPath) ? relPath : path.join(index.root, relPath);
307
+ return [absPath, entry];
308
+ });
309
+ index.callsCache = new Map(absData);
310
+ return true;
311
+ }
312
+ } catch (e) {
313
+ // Corrupted file — ignore
314
+ }
315
+ return false;
316
+ }
317
+
318
+ module.exports = { saveCache, loadCache, loadCallsCache, isCacheStale };