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.
- package/.claude/skills/ucn/SKILL.md +3 -1
- package/.github/workflows/ci.yml +13 -1
- package/README.md +1 -0
- package/cli/index.js +165 -246
- package/core/analysis.js +1400 -0
- package/core/build-worker.js +194 -0
- package/core/cache.js +105 -7
- package/core/callers.js +194 -64
- package/core/deadcode.js +22 -66
- package/core/discovery.js +9 -54
- package/core/execute.js +139 -54
- package/core/graph.js +615 -0
- package/core/output/analysis-ext.js +271 -0
- package/core/output/analysis.js +491 -0
- package/core/output/extraction.js +188 -0
- package/core/output/find.js +355 -0
- package/core/output/graph.js +399 -0
- package/core/output/refactoring.js +293 -0
- package/core/output/reporting.js +331 -0
- package/core/output/search.js +307 -0
- package/core/output/shared.js +271 -0
- package/core/output/tracing.js +416 -0
- package/core/output.js +15 -3293
- package/core/parallel-build.js +165 -0
- package/core/project.js +299 -3633
- package/core/registry.js +59 -0
- package/core/reporting.js +258 -0
- package/core/search.js +890 -0
- package/core/stacktrace.js +1 -1
- package/core/tracing.js +631 -0
- package/core/verify.js +10 -13
- package/eslint.config.js +43 -0
- package/jsconfig.json +10 -0
- package/languages/go.js +21 -2
- package/languages/html.js +8 -0
- package/languages/index.js +102 -40
- package/languages/java.js +13 -0
- package/languages/javascript.js +17 -1
- package/languages/python.js +14 -0
- package/languages/rust.js +13 -0
- package/languages/utils.js +1 -1
- package/mcp/server.js +45 -28
- 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
|
|
116
|
+
// Save callsCache sharded by directory for lazy loading
|
|
104
117
|
if (callsCacheData.length > 0) {
|
|
105
|
-
const
|
|
106
|
-
|
|
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
|
|
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 };
|