ucn 3.7.46 → 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.
@@ -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) |
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 };