ucn 3.8.13 → 3.8.14

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.
Files changed (43) hide show
  1. package/.claude/skills/ucn/SKILL.md +3 -1
  2. package/.github/workflows/ci.yml +13 -1
  3. package/README.md +1 -0
  4. package/cli/index.js +165 -246
  5. package/core/analysis.js +1400 -0
  6. package/core/build-worker.js +194 -0
  7. package/core/cache.js +105 -7
  8. package/core/callers.js +194 -64
  9. package/core/deadcode.js +22 -66
  10. package/core/discovery.js +9 -54
  11. package/core/execute.js +139 -54
  12. package/core/graph.js +615 -0
  13. package/core/output/analysis-ext.js +271 -0
  14. package/core/output/analysis.js +491 -0
  15. package/core/output/extraction.js +188 -0
  16. package/core/output/find.js +355 -0
  17. package/core/output/graph.js +399 -0
  18. package/core/output/refactoring.js +293 -0
  19. package/core/output/reporting.js +331 -0
  20. package/core/output/search.js +307 -0
  21. package/core/output/shared.js +271 -0
  22. package/core/output/tracing.js +416 -0
  23. package/core/output.js +15 -3293
  24. package/core/parallel-build.js +165 -0
  25. package/core/project.js +299 -3633
  26. package/core/registry.js +59 -0
  27. package/core/reporting.js +258 -0
  28. package/core/search.js +890 -0
  29. package/core/stacktrace.js +1 -1
  30. package/core/tracing.js +631 -0
  31. package/core/verify.js +10 -13
  32. package/eslint.config.js +43 -0
  33. package/jsconfig.json +10 -0
  34. package/languages/go.js +21 -2
  35. package/languages/html.js +8 -0
  36. package/languages/index.js +102 -40
  37. package/languages/java.js +13 -0
  38. package/languages/javascript.js +17 -1
  39. package/languages/python.js +14 -0
  40. package/languages/rust.js +13 -0
  41. package/languages/utils.js +1 -1
  42. package/mcp/server.js +45 -28
  43. package/package.json +8 -3
@@ -0,0 +1,194 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * core/build-worker.js - Worker thread for parallel index building
5
+ *
6
+ * Receives a chunk of file paths, parses each file, extracts symbols and calls,
7
+ * then sends results back to the main thread via MessagePort.
8
+ * Mirrors the indexFile() logic in project.js.
9
+ */
10
+
11
+ const { workerData } = require('worker_threads');
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const crypto = require('crypto');
15
+ const { detectLanguage, getParser, getLanguageModule, langTraits } = require('../languages');
16
+ const { parse } = require('./parser');
17
+ const { extractImports, extractExports } = require('./imports');
18
+
19
+ const { files, rootDir, existingHashes, signal, workerIndex, port } = workerData;
20
+ const signalArray = new Int32Array(signal);
21
+
22
+ function addSymbol(fileEntry, item, type) {
23
+ const symbol = {
24
+ name: item.name,
25
+ type,
26
+ file: fileEntry.path,
27
+ relativePath: fileEntry.relativePath,
28
+ startLine: item.startLine,
29
+ endLine: item.endLine,
30
+ params: item.params,
31
+ paramsStructured: item.paramsStructured,
32
+ returnType: item.returnType,
33
+ modifiers: item.modifiers,
34
+ docstring: item.docstring,
35
+ bindingId: `${fileEntry.relativePath}:${type}:${item.startLine}`,
36
+ };
37
+ if (item.generics) symbol.generics = item.generics;
38
+ if (item.extends) symbol.extends = item.extends;
39
+ if (item.implements) symbol.implements = item.implements;
40
+ if (item.indent !== undefined) symbol.indent = item.indent;
41
+ if (item.isNested) symbol.isNested = item.isNested;
42
+ if (item.isMethod) symbol.isMethod = item.isMethod;
43
+ if (item.receiver) symbol.receiver = item.receiver;
44
+ if (item.className) symbol.className = item.className;
45
+ if (item.memberType) symbol.memberType = item.memberType;
46
+ if (item.fieldType) symbol.fieldType = item.fieldType;
47
+ if (item.decorators && item.decorators.length > 0) symbol.decorators = item.decorators;
48
+ if (item.nameLine) symbol.nameLine = item.nameLine;
49
+ if (item.traitImpl) symbol.traitImpl = true;
50
+ if (item.isSignature) symbol.isSignature = true;
51
+
52
+ fileEntry.symbols.push(symbol);
53
+ fileEntry.bindings.push({
54
+ id: symbol.bindingId,
55
+ name: symbol.name,
56
+ type: symbol.type,
57
+ startLine: symbol.startLine,
58
+ });
59
+ }
60
+
61
+ function processFile(filePath) {
62
+ const stat = fs.statSync(filePath);
63
+ const existing = existingHashes[filePath];
64
+
65
+ // Fast path: skip when mtime+size both match
66
+ if (existing && existing.mtime === stat.mtimeMs && existing.size === stat.size) {
67
+ return { filePath, skipped: true };
68
+ }
69
+
70
+ const content = fs.readFileSync(filePath, 'utf-8');
71
+ const hash = crypto.createHash('md5').update(content).digest('hex');
72
+
73
+ // Content-based skip: mtime changed but content didn't (touch, git checkout)
74
+ if (existing && existing.hash === hash) {
75
+ return { filePath, skipped: true, mtimeUpdate: stat.mtimeMs, sizeUpdate: stat.size };
76
+ }
77
+
78
+ const language = detectLanguage(filePath);
79
+ if (!language) return { filePath, skipped: true };
80
+
81
+ // Parse content — safeParse cache ensures tree is shared across
82
+ // parse()/extractImports()/extractExports()/findCallsInCode() (1 parse per file)
83
+ const parsed = parse(content, language);
84
+ const { imports, dynamicCount, importAliases } = extractImports(content, language);
85
+ const { exports } = extractExports(content, language);
86
+
87
+ // Extract calls in the same pass (eliminates buildCalleeIndex re-parsing)
88
+ let calls = null;
89
+ const langModule = getLanguageModule(language);
90
+ if (langModule.findCallsInCode) {
91
+ const parser = getParser(language);
92
+ const callOpts = {};
93
+ if (langTraits(language)?.hasReceiverPackageCalls) {
94
+ callOpts.imports = imports.flatMap(i => i.names || []);
95
+ }
96
+ calls = langModule.findCallsInCode(content, parser, callOpts);
97
+ }
98
+
99
+ // Detect bundled/minified files (same logic as indexFile in project.js)
100
+ let lineCount = 1, longLineCount = 0, lineStart = 0;
101
+ for (let ci = 0; ci < content.length; ci++) {
102
+ if (content.charCodeAt(ci) === 10) {
103
+ if (ci - lineStart > 1000) longLineCount++;
104
+ lineStart = ci + 1;
105
+ lineCount++;
106
+ }
107
+ }
108
+ if (content.length - lineStart > 1000) longLineCount++;
109
+
110
+ const isBundled = (
111
+ content.includes('__webpack_require__') || content.includes('__webpack_modules__') ||
112
+ (lineCount > 0 && lineCount < 50 && content.length / lineCount > 500) ||
113
+ (lineCount > 0 && longLineCount > 0 && longLineCount / lineCount > 0.3)
114
+ );
115
+
116
+ const isGenerated = /^\/\/\s*Code generated\b|^\/\/\s*DO NOT EDIT|^\/\/ @generated|^# Generated by/m.test(
117
+ content.slice(0, 500)
118
+ );
119
+
120
+ const relativePath = path.relative(rootDir, filePath);
121
+
122
+ // Build fileEntry (mirrors indexFile)
123
+ const fileEntry = {
124
+ path: filePath,
125
+ relativePath,
126
+ language,
127
+ lines: lineCount,
128
+ hash,
129
+ mtime: stat.mtimeMs,
130
+ size: stat.size,
131
+ imports: imports.map(i => i.module),
132
+ importNames: imports.flatMap(i => i.names || []),
133
+ exports: exports.map(e => e.name),
134
+ exportDetails: exports,
135
+ symbols: [],
136
+ bindings: [],
137
+ dynamicImports: dynamicCount || 0,
138
+ };
139
+ if (importAliases) fileEntry.importAliases = importAliases;
140
+ if (isBundled) fileEntry.isBundled = true;
141
+ if (isGenerated) fileEntry.isGenerated = true;
142
+
143
+ // Build symbols (mirrors indexFile)
144
+ for (const fn of parsed.functions) {
145
+ if (fn.receiver && !fn.className) {
146
+ fn.className = fn.receiver.replace(/^\*/, '');
147
+ }
148
+ addSymbol(fileEntry, fn, fn.isConstructor ? 'constructor' : 'function');
149
+ }
150
+
151
+ for (const cls of parsed.classes) {
152
+ addSymbol(fileEntry, cls, cls.type || 'class');
153
+ if (cls.members) {
154
+ for (const m of cls.members) {
155
+ addSymbol(fileEntry, { ...m, className: cls.name, ...(cls.traitName && { traitImpl: true }) }, m.memberType || 'method');
156
+ }
157
+ }
158
+ }
159
+
160
+ for (const state of parsed.stateObjects) {
161
+ addSymbol(fileEntry, state, 'state');
162
+ }
163
+
164
+ return {
165
+ filePath,
166
+ fileEntry,
167
+ calls,
168
+ callsMtime: stat.mtimeMs,
169
+ callsHash: hash,
170
+ hadExisting: !!existing,
171
+ };
172
+ }
173
+
174
+ // Process all files
175
+ try {
176
+ const results = [];
177
+ for (const filePath of files) {
178
+ try {
179
+ results.push(processFile(filePath));
180
+ } catch (e) {
181
+ results.push({ filePath, error: e.message });
182
+ }
183
+ }
184
+
185
+ port.postMessage(results);
186
+ port.close();
187
+ Atomics.store(signalArray, workerIndex, 1);
188
+ Atomics.notify(signalArray, workerIndex);
189
+ } catch (e) {
190
+ // Worker-level error — signal completion with empty results
191
+ try { port.postMessage([]); port.close(); } catch (_) { /* ignore */ }
192
+ Atomics.store(signalArray, workerIndex, 1);
193
+ Atomics.notify(signalArray, workerIndex);
194
+ }
package/core/cache.js CHANGED
@@ -79,6 +79,18 @@ function saveCache(index, cachePath) {
79
79
  return result;
80
80
  };
81
81
 
82
+ // Persist calleeIndex if built (paths must be absolute from this.files keys)
83
+ let calleeIndexData;
84
+ if (index.calleeIndex && index.calleeIndex.size > 0) {
85
+ calleeIndexData = [];
86
+ for (const [name, files] of index.calleeIndex) {
87
+ const relFiles = [...files].map(f =>
88
+ path.isAbsolute(f) ? path.relative(root, f) : f
89
+ );
90
+ calleeIndexData.push([name, relFiles]);
91
+ }
92
+ }
93
+
82
94
  const cacheData = {
83
95
  version: 7, // v7: strip symbols/bindings from file entries (dedup ~45% cache reduction)
84
96
  ucnVersion: UCN_VERSION, // Invalidate cache when UCN is updated
@@ -95,15 +107,44 @@ function saveCache(index, cachePath) {
95
107
  extendedByGraph: Array.from(index.extendedByGraph.entries()),
96
108
  failedFiles: index.failedFiles
97
109
  ? Array.from(index.failedFiles).map(f => path.relative(root, f))
98
- : []
110
+ : [],
111
+ ...(calleeIndexData && { calleeIndex: calleeIndexData })
99
112
  };
100
113
 
101
114
  fs.writeFileSync(cacheFile, JSON.stringify(cacheData));
102
115
 
103
- // Save callsCache to a separate file (lazy-loaded on demand)
116
+ // Save callsCache sharded by directory for lazy loading
104
117
  if (callsCacheData.length > 0) {
105
- const callsCacheFile = path.join(path.dirname(cacheFile), 'calls-cache.json');
106
- fs.writeFileSync(callsCacheFile, JSON.stringify(callsCacheData));
118
+ const callsDir = path.join(path.dirname(cacheFile), 'calls');
119
+ // Clean up old shards and legacy monolithic file
120
+ if (fs.existsSync(callsDir)) {
121
+ fs.rmSync(callsDir, { recursive: true, force: true });
122
+ }
123
+ const legacyFile = path.join(path.dirname(cacheFile), 'calls-cache.json');
124
+ if (fs.existsSync(legacyFile)) {
125
+ fs.rmSync(legacyFile, { force: true });
126
+ }
127
+ fs.mkdirSync(callsDir, { recursive: true });
128
+
129
+ // Group by directory
130
+ const shards = new Map();
131
+ for (const [relPath, entry] of callsCacheData) {
132
+ const dir = path.dirname(relPath) || '.';
133
+ if (!shards.has(dir)) shards.set(dir, []);
134
+ shards.get(dir).push([relPath, entry]);
135
+ }
136
+
137
+ // Write one shard per directory
138
+ const shardManifest = [];
139
+ for (const [dir, entries] of shards) {
140
+ const hash = crypto.createHash('md5').update(dir).digest('hex').slice(0, 10);
141
+ const shardFile = path.join(callsDir, `${hash}.json`);
142
+ fs.writeFileSync(shardFile, JSON.stringify(entries));
143
+ shardManifest.push([dir, hash, entries.length]);
144
+ }
145
+
146
+ // Write manifest for lazy loading
147
+ fs.writeFileSync(path.join(callsDir, 'manifest.json'), JSON.stringify(shardManifest));
107
148
  }
108
149
 
109
150
  return cacheFile;
@@ -208,6 +249,11 @@ function loadCache(index, cachePath) {
208
249
  loadCallsCache(index);
209
250
  }
210
251
 
252
+ // Build directory→files index from loaded data
253
+ if (typeof index._buildDirIndex === 'function') {
254
+ index._buildDirIndex();
255
+ }
256
+
211
257
  // Restore failedFiles if present (convert relative paths back to absolute)
212
258
  if (Array.isArray(cacheData.failedFiles)) {
213
259
  index.failedFiles = new Set(
@@ -215,6 +261,17 @@ function loadCache(index, cachePath) {
215
261
  );
216
262
  }
217
263
 
264
+ // Restore calleeIndex if persisted
265
+ if (Array.isArray(cacheData.calleeIndex)) {
266
+ index.calleeIndex = new Map();
267
+ for (const [name, files] of cacheData.calleeIndex) {
268
+ if (!Array.isArray(files)) continue;
269
+ index.calleeIndex.set(name, new Set(
270
+ files.map(f => path.isAbsolute(f) ? f : path.join(root, f))
271
+ ));
272
+ }
273
+ }
274
+
218
275
  // Only rebuild graphs if config changed (e.g., aliases modified)
219
276
  const currentConfigHash = crypto.createHash('md5')
220
277
  .update(JSON.stringify(index.config || {})).digest('hex');
@@ -294,15 +351,36 @@ function loadCallsCache(index) {
294
351
  if (index._callsCacheLoaded) return false; // Already attempted, file didn't exist
295
352
  index._callsCacheLoaded = true;
296
353
 
297
- const callsCacheFile = path.join(index.root, '.ucn-cache', 'calls-cache.json');
354
+ const cacheDir = path.join(index.root, '.ucn-cache');
355
+
356
+ // Try sharded format first (calls/manifest.json)
357
+ const manifestFile = path.join(cacheDir, 'calls', 'manifest.json');
358
+ if (fs.existsSync(manifestFile)) {
359
+ try {
360
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf-8'));
361
+ // Store manifest for lazy loading
362
+ index._callsManifest = new Map();
363
+ for (const [dir, hash, count] of manifest) {
364
+ index._callsManifest.set(dir, { hash, count, loaded: false });
365
+ }
366
+ // Eagerly load all shards (matches previous behavior)
367
+ for (const [, { hash }] of index._callsManifest) {
368
+ _loadCallsShard(index, hash);
369
+ }
370
+ return true;
371
+ } catch (e) {
372
+ // Corrupted manifest — fall through to legacy
373
+ }
374
+ }
375
+
376
+ // Legacy format: single calls-cache.json
377
+ const callsCacheFile = path.join(cacheDir, 'calls-cache.json');
298
378
  if (!fs.existsSync(callsCacheFile)) return false;
299
379
 
300
380
  try {
301
381
  const data = JSON.parse(fs.readFileSync(callsCacheFile, 'utf-8'));
302
382
  if (Array.isArray(data)) {
303
- // Convert relative paths back to absolute
304
383
  const absData = data.map(([relPath, entry]) => {
305
- // Handle both relative (v6+) and absolute (legacy) paths
306
384
  const absPath = path.isAbsolute(relPath) ? relPath : path.join(index.root, relPath);
307
385
  return [absPath, entry];
308
386
  });
@@ -315,4 +393,24 @@ function loadCallsCache(index) {
315
393
  return false;
316
394
  }
317
395
 
396
+ /**
397
+ * Load a single calls shard by hash.
398
+ * @param {object} index - ProjectIndex instance
399
+ * @param {string} hash - Shard hash from manifest
400
+ */
401
+ function _loadCallsShard(index, hash) {
402
+ const shardFile = path.join(index.root, '.ucn-cache', 'calls', `${hash}.json`);
403
+ try {
404
+ const data = JSON.parse(fs.readFileSync(shardFile, 'utf-8'));
405
+ if (!Array.isArray(data)) return;
406
+ for (const [relPath, entry] of data) {
407
+ if (!relPath || !entry) continue;
408
+ const absPath = path.isAbsolute(relPath) ? relPath : path.join(index.root, relPath);
409
+ index.callsCache.set(absPath, entry);
410
+ }
411
+ } catch (e) {
412
+ // Corrupted shard — skip
413
+ }
414
+ }
415
+
318
416
  module.exports = { saveCache, loadCache, loadCallsCache, isCacheStale };