ucn 3.8.22 → 3.8.25
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 +114 -11
- package/README.md +152 -156
- package/cli/index.js +363 -37
- package/core/analysis.js +960 -37
- package/core/bridge.js +1111 -0
- package/core/brief.js +408 -0
- package/core/cache.js +213 -59
- package/core/callers.js +117 -41
- package/core/check.js +200 -0
- package/core/deadcode.js +31 -2
- package/core/discovery.js +57 -34
- package/core/entrypoints.js +638 -4
- package/core/execute.js +304 -5
- package/core/git-enrich.js +130 -0
- package/core/graph-build.js +4 -4
- package/core/graph.js +31 -12
- package/core/output/analysis.js +157 -25
- package/core/output/brief.js +100 -0
- package/core/output/check.js +79 -0
- package/core/output/doctor.js +85 -0
- package/core/output/endpoints.js +239 -0
- package/core/output/extraction.js +2 -0
- package/core/output/find.js +126 -39
- package/core/output/graph.js +48 -15
- package/core/output/refactoring.js +103 -5
- package/core/output/reporting.js +63 -23
- package/core/output/search.js +110 -17
- package/core/output/shared.js +56 -2
- package/core/output.js +4 -0
- package/core/parallel-build.js +10 -7
- package/core/parser.js +8 -2
- package/core/project.js +147 -41
- package/core/registry.js +30 -14
- package/core/reporting.js +465 -2
- package/core/search.js +139 -15
- package/core/shared.js +101 -5
- package/core/tracing.js +31 -12
- package/core/verify.js +982 -95
- package/languages/go.js +91 -6
- package/languages/html.js +10 -0
- package/languages/java.js +151 -35
- package/languages/javascript.js +290 -33
- package/languages/python.js +78 -11
- package/languages/rust.js +267 -12
- package/languages/utils.js +315 -3
- package/mcp/server.js +91 -16
- package/package.json +10 -2
package/core/callers.js
CHANGED
|
@@ -13,6 +13,14 @@ const { isTestFile } = require('./discovery');
|
|
|
13
13
|
const { NON_CALLABLE_TYPES } = require('./shared');
|
|
14
14
|
const { scoreEdge } = require('./confidence');
|
|
15
15
|
|
|
16
|
+
/** Set.some() helper — like Array.some() but for Sets */
|
|
17
|
+
function setSome(set, predicate) {
|
|
18
|
+
for (const item of set) {
|
|
19
|
+
if (predicate(item)) return true;
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
/**
|
|
17
25
|
* Extract a single line from content without splitting the entire string.
|
|
18
26
|
* @param {string} content - Full file content
|
|
@@ -40,6 +48,11 @@ function getLine(content, lineNum) {
|
|
|
40
48
|
*/
|
|
41
49
|
function getCachedCalls(index, filePath, options = {}) {
|
|
42
50
|
try {
|
|
51
|
+
// Trigger lazy calls cache load if prepared but not yet loaded
|
|
52
|
+
if (index._callsCachePrepared && !index._callsCacheLoaded) {
|
|
53
|
+
const { ensureCallsCacheLoaded } = require('./cache');
|
|
54
|
+
ensureCallsCacheLoaded(index);
|
|
55
|
+
}
|
|
43
56
|
const cached = index.callsCache.get(filePath);
|
|
44
57
|
|
|
45
58
|
// Fast path: check mtime first (stat is much faster than read+hash)
|
|
@@ -91,6 +104,8 @@ function getCachedCalls(index, filePath, options = {}) {
|
|
|
91
104
|
}
|
|
92
105
|
const calls = langModule.findCallsInCode(content, parser, callOpts);
|
|
93
106
|
|
|
107
|
+
// Remove old callee index entries before overwriting cache
|
|
108
|
+
if (cached) index._removeFromCalleeIndex(filePath, cached.calls);
|
|
94
109
|
index.callsCache.set(filePath, {
|
|
95
110
|
mtime,
|
|
96
111
|
hash,
|
|
@@ -98,6 +113,8 @@ function getCachedCalls(index, filePath, options = {}) {
|
|
|
98
113
|
content: options.includeContent ? content : undefined
|
|
99
114
|
});
|
|
100
115
|
index.callsCacheDirty = true;
|
|
116
|
+
// Incrementally update callee index with new calls
|
|
117
|
+
index._addToCalleeIndex(filePath, calls);
|
|
101
118
|
|
|
102
119
|
if (options.includeContent) {
|
|
103
120
|
return { calls, content };
|
|
@@ -143,6 +160,11 @@ function findCallers(index, name, options = {}) {
|
|
|
143
160
|
const pendingByFile = new Map(); // filePath -> [{ call, fileEntry, callerSymbol, isMethod, isFunctionReference, receiver }]
|
|
144
161
|
let pendingCount = 0;
|
|
145
162
|
const maxResults = options.maxResults;
|
|
163
|
+
// BUG-H1: when consumers (like `about`) need an accurate truncation header
|
|
164
|
+
// ("showing N of <total>"), they pass needsTotal:true so Phase 1 runs to
|
|
165
|
+
// completion. Phase 2 still only enriches the first `maxResults` items —
|
|
166
|
+
// file reads stay bounded, but the candidate count reflects the true total.
|
|
167
|
+
const needsTotal = !!options.needsTotal;
|
|
146
168
|
const localTypeCache = new Map(); // `${filePath}:${startLine}` -> localTypes Map or null
|
|
147
169
|
|
|
148
170
|
// Use inverted callee index to skip files that don't contain calls to this name
|
|
@@ -152,8 +174,8 @@ function findCallers(index, name, options = {}) {
|
|
|
152
174
|
: index.files;
|
|
153
175
|
|
|
154
176
|
for (const [filePath, fileEntry] of fileIterator) {
|
|
155
|
-
// Early exit when maxResults is reached
|
|
156
|
-
if (maxResults && pendingCount >= maxResults) break;
|
|
177
|
+
// Early exit when maxResults is reached (skip when caller needs the true total)
|
|
178
|
+
if (maxResults && !needsTotal && pendingCount >= maxResults) break;
|
|
157
179
|
try {
|
|
158
180
|
const calls = getCachedCalls(index, filePath);
|
|
159
181
|
if (!calls) continue;
|
|
@@ -390,14 +412,14 @@ function findCallers(index, name, options = {}) {
|
|
|
390
412
|
langTraits(fileEntry.language)?.typeSystem === 'structural') {
|
|
391
413
|
const targetFiles = new Set(targetDefs.map(d => d.file).filter(Boolean));
|
|
392
414
|
if (targetFiles.size > 0 && !targetFiles.has(filePath)) {
|
|
393
|
-
const imports = index.importGraph.get(filePath)
|
|
394
|
-
const importsTarget = imports
|
|
415
|
+
const imports = index.importGraph.get(filePath);
|
|
416
|
+
const importsTarget = imports && setSome(imports, imp => targetFiles.has(imp));
|
|
395
417
|
if (!importsTarget) {
|
|
396
418
|
// Check one level of re-exports (barrel files)
|
|
397
419
|
let foundViaReexport = false;
|
|
398
|
-
for (const imp of imports) {
|
|
399
|
-
const transImports = index.importGraph.get(imp)
|
|
400
|
-
if (transImports
|
|
420
|
+
if (imports) for (const imp of imports) {
|
|
421
|
+
const transImports = index.importGraph.get(imp);
|
|
422
|
+
if (transImports && setSome(transImports, ti => targetFiles.has(ti))) {
|
|
401
423
|
foundViaReexport = true;
|
|
402
424
|
break;
|
|
403
425
|
}
|
|
@@ -455,10 +477,10 @@ function findCallers(index, name, options = {}) {
|
|
|
455
477
|
continue;
|
|
456
478
|
}
|
|
457
479
|
// Multi-segment import — verify via import graph
|
|
458
|
-
const callerImportedFiles = index.importGraph.get(filePath)
|
|
480
|
+
const callerImportedFiles = index.importGraph.get(filePath);
|
|
459
481
|
const targetFiles = new Set(targetDefs.map(d => d.file).filter(Boolean));
|
|
460
482
|
if (!targetFiles.has(filePath)) {
|
|
461
|
-
const hasImportEdge = callerImportedFiles
|
|
483
|
+
const hasImportEdge = callerImportedFiles && setSome(callerImportedFiles, imp => targetFiles.has(imp));
|
|
462
484
|
if (!hasImportEdge) {
|
|
463
485
|
// No import edge — allow same-package (same directory) calls
|
|
464
486
|
const callerDir = path.dirname(filePath);
|
|
@@ -583,13 +605,13 @@ function findCallers(index, name, options = {}) {
|
|
|
583
605
|
// Check import graph evidence: does this file import from the target definition's file?
|
|
584
606
|
const targetDefs2 = options.targetDefinitions || definitions;
|
|
585
607
|
const targetFiles2 = new Set(targetDefs2.map(d => d.file).filter(Boolean));
|
|
586
|
-
const callerImports = index.importGraph.get(filePath)
|
|
587
|
-
let hasImportLink = targetFiles2.has(filePath) || callerImports
|
|
608
|
+
const callerImports = index.importGraph.get(filePath);
|
|
609
|
+
let hasImportLink = targetFiles2.has(filePath) || (callerImports && setSome(callerImports, imp => targetFiles2.has(imp)));
|
|
588
610
|
// Check one level of re-exports (barrel files) for import evidence
|
|
589
|
-
if (!hasImportLink) {
|
|
611
|
+
if (!hasImportLink && callerImports) {
|
|
590
612
|
for (const imp of callerImports) {
|
|
591
|
-
const transImports = index.importGraph.get(imp)
|
|
592
|
-
if (transImports
|
|
613
|
+
const transImports = index.importGraph.get(imp);
|
|
614
|
+
if (transImports && setSome(transImports, ti => targetFiles2.has(ti))) {
|
|
593
615
|
hasImportLink = true;
|
|
594
616
|
break;
|
|
595
617
|
}
|
|
@@ -626,34 +648,83 @@ function findCallers(index, name, options = {}) {
|
|
|
626
648
|
}
|
|
627
649
|
}
|
|
628
650
|
|
|
651
|
+
// True total candidate count from Phase 1 (before any Phase 2 truncation).
|
|
652
|
+
// Used by callers that need accurate "showing N of <total>" headers.
|
|
653
|
+
const totalCount = pendingCount;
|
|
654
|
+
// When needsTotal is set with a maxResults cap, only enrich the first
|
|
655
|
+
// `maxResults` candidates in Phase 2 — file reads stay bounded.
|
|
656
|
+
const enrichLimit = (needsTotal && maxResults) ? maxResults : Infinity;
|
|
657
|
+
let enrichedCount = 0;
|
|
658
|
+
|
|
659
|
+
// BUG-H1: shadow records for un-enriched candidates so post-call filters
|
|
660
|
+
// (exclude / minConfidence) can produce an accurate total without forcing
|
|
661
|
+
// a Phase-2 file read for every candidate. Each shadow has just enough
|
|
662
|
+
// info to drive the filter predicates: relativePath + confidence.
|
|
663
|
+
const shadowEntries = [];
|
|
664
|
+
|
|
629
665
|
// Phase 2: Read content only for files with matching calls (eliminates ~98% of file reads)
|
|
630
|
-
for (const [filePath, pending] of pendingByFile) {
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
666
|
+
outer: for (const [filePath, pending] of pendingByFile) {
|
|
667
|
+
let content = null;
|
|
668
|
+
for (const { call, fileEntry, callerSymbol, isMethod, isFunctionReference, receiver, receiverType, _evidence } of pending) {
|
|
669
|
+
const scored = scoreEdge(_evidence || {});
|
|
670
|
+
if (enrichedCount >= enrichLimit) {
|
|
671
|
+
// Push shadow only — no file read needed.
|
|
672
|
+
shadowEntries.push({
|
|
636
673
|
file: filePath,
|
|
637
674
|
relativePath: fileEntry.relativePath,
|
|
638
675
|
line: call.line,
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
callerStartLine: callerSymbol ? callerSymbol.startLine : null,
|
|
643
|
-
callerEndLine: callerSymbol ? callerSymbol.endLine : null,
|
|
644
|
-
isMethod,
|
|
676
|
+
confidence: scored.confidence,
|
|
677
|
+
resolution: scored.resolution,
|
|
678
|
+
isMethod: call.isMethod || false,
|
|
645
679
|
...(isFunctionReference && { isFunctionReference: true }),
|
|
646
680
|
...(receiver !== undefined && { receiver }),
|
|
647
681
|
...(receiverType && { receiverType }),
|
|
648
|
-
confidence: scored.confidence,
|
|
649
|
-
resolution: scored.resolution,
|
|
650
682
|
});
|
|
683
|
+
continue;
|
|
651
684
|
}
|
|
652
|
-
|
|
653
|
-
|
|
685
|
+
// First time we hit this file's enrichment loop — read the file once.
|
|
686
|
+
if (content === null) {
|
|
687
|
+
try { content = fs.readFileSync(filePath, 'utf-8'); }
|
|
688
|
+
catch (e) { content = ''; /* deleted/unreadable; skip enrichment for rest */ break; }
|
|
689
|
+
}
|
|
690
|
+
callers.push({
|
|
691
|
+
file: filePath,
|
|
692
|
+
relativePath: fileEntry.relativePath,
|
|
693
|
+
line: call.line,
|
|
694
|
+
content: getLine(content, call.line),
|
|
695
|
+
callerName: callerSymbol ? callerSymbol.name : null,
|
|
696
|
+
callerFile: callerSymbol ? filePath : null,
|
|
697
|
+
callerStartLine: callerSymbol ? callerSymbol.startLine : null,
|
|
698
|
+
callerEndLine: callerSymbol ? callerSymbol.endLine : null,
|
|
699
|
+
isMethod,
|
|
700
|
+
...(isFunctionReference && { isFunctionReference: true }),
|
|
701
|
+
...(receiver !== undefined && { receiver }),
|
|
702
|
+
...(receiverType && { receiverType }),
|
|
703
|
+
confidence: scored.confidence,
|
|
704
|
+
resolution: scored.resolution,
|
|
705
|
+
});
|
|
706
|
+
enrichedCount++;
|
|
654
707
|
}
|
|
655
708
|
}
|
|
656
709
|
|
|
710
|
+
// Tag the returned array with the true total candidate count (only meaningful
|
|
711
|
+
// when needsTotal:true was passed). Defined as non-enumerable so JSON.stringify
|
|
712
|
+
// won't surprise consumers; defaults to callers.length when not set.
|
|
713
|
+
Object.defineProperty(callers, 'totalCount', {
|
|
714
|
+
value: needsTotal ? totalCount : callers.length,
|
|
715
|
+
enumerable: false,
|
|
716
|
+
writable: true,
|
|
717
|
+
configurable: true,
|
|
718
|
+
});
|
|
719
|
+
// Attach shadow entries so consumers can compute post-filter totals without
|
|
720
|
+
// re-running findCallers. Empty when needsTotal:false or all candidates fit.
|
|
721
|
+
Object.defineProperty(callers, 'shadowEntries', {
|
|
722
|
+
value: shadowEntries,
|
|
723
|
+
enumerable: false,
|
|
724
|
+
writable: true,
|
|
725
|
+
configurable: true,
|
|
726
|
+
});
|
|
727
|
+
|
|
657
728
|
return callers;
|
|
658
729
|
} finally { index._endOp(); }
|
|
659
730
|
}
|
|
@@ -1175,7 +1246,7 @@ function findCallees(index, def, options = {}) {
|
|
|
1175
1246
|
const defFileEntry = fileEntry;
|
|
1176
1247
|
const callerIsTest = defFileEntry && isTestFile(defFileEntry.relativePath, defFileEntry.language);
|
|
1177
1248
|
// Pre-compute import graph for callee confidence scoring
|
|
1178
|
-
const callerImportSet =
|
|
1249
|
+
const callerImportSet = index.importGraph.get(def.file) || new Set();
|
|
1179
1250
|
|
|
1180
1251
|
for (const { name: calleeName, bindingId, count, isConstructor } of callees.values()) {
|
|
1181
1252
|
const symbols = index.symbols.get(calleeName);
|
|
@@ -1366,6 +1437,7 @@ function _buildLocalTypeMap(index, def, calls) {
|
|
|
1366
1437
|
}
|
|
1367
1438
|
const lines = content.split('\n');
|
|
1368
1439
|
const localTypes = new Map();
|
|
1440
|
+
const regexCache = new Map();
|
|
1369
1441
|
|
|
1370
1442
|
for (const call of calls) {
|
|
1371
1443
|
// Only look at calls within this function's scope
|
|
@@ -1383,18 +1455,21 @@ function _buildLocalTypeMap(index, def, calls) {
|
|
|
1383
1455
|
const sourceLine = lines[call.line - 1];
|
|
1384
1456
|
if (!sourceLine) continue;
|
|
1385
1457
|
|
|
1386
|
-
//
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1458
|
+
// Memoize compiled regex per call name (same name → same pattern)
|
|
1459
|
+
let patterns = regexCache.get(call.name);
|
|
1460
|
+
if (!patterns) {
|
|
1461
|
+
const esc = call.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1462
|
+
patterns = {
|
|
1463
|
+
assign: new RegExp(`(\\w+)\\s*(?::\\s*\\w+)?\\s*=\\s*${esc}\\s*\\(`),
|
|
1464
|
+
with: new RegExp(`with\\s+${esc}\\s*\\([^)]*\\)\\s+as\\s+(\\w+)`)
|
|
1465
|
+
};
|
|
1466
|
+
regexCache.set(call.name, patterns);
|
|
1467
|
+
}
|
|
1468
|
+
const assignMatch = sourceLine.match(patterns.assign);
|
|
1391
1469
|
if (assignMatch) {
|
|
1392
1470
|
localTypes.set(assignMatch[1], call.name);
|
|
1393
1471
|
}
|
|
1394
|
-
|
|
1395
|
-
const withMatch = sourceLine.match(
|
|
1396
|
-
new RegExp(`with\\s+${escapedName}\\s*\\([^)]*\\)\\s+as\\s+(\\w+)`)
|
|
1397
|
-
);
|
|
1472
|
+
const withMatch = sourceLine.match(patterns.with);
|
|
1398
1473
|
if (withMatch) {
|
|
1399
1474
|
localTypes.set(withMatch[1], call.name);
|
|
1400
1475
|
}
|
|
@@ -1432,10 +1507,11 @@ function _buildTypedLocalTypeMap(index, def, calls) {
|
|
|
1432
1507
|
// Handles: x := NewFoo(), x, err := NewFoo(), x := pkg.NewFoo(), x, err := pkg.NewFoo()
|
|
1433
1508
|
const newName = call.isMethod ? call.name : call.name;
|
|
1434
1509
|
if (/^New[A-Z]/.test(newName) && !call.isPotentialCallback) {
|
|
1510
|
+
if (_cachedLines === false) continue; // File unreadable, skip all
|
|
1435
1511
|
if (!_cachedLines) {
|
|
1436
1512
|
try {
|
|
1437
1513
|
_cachedLines = index._readFile(def.file).split('\n');
|
|
1438
|
-
} catch { continue; }
|
|
1514
|
+
} catch { _cachedLines = false; continue; }
|
|
1439
1515
|
}
|
|
1440
1516
|
const sourceLine = _cachedLines[call.line - 1];
|
|
1441
1517
|
if (!sourceLine) continue;
|
package/core/check.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/check.js — Pre-commit summary command.
|
|
3
|
+
*
|
|
4
|
+
* Composes diff-impact + verify + affected-tests into a single output.
|
|
5
|
+
* Tells the caller, in one shot:
|
|
6
|
+
* - which functions changed
|
|
7
|
+
* - which call sites might break (signature drift)
|
|
8
|
+
* - which tests are likely affected
|
|
9
|
+
* - which new functions look orphaned
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const { diffImpact } = require('./analysis');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Run the pre-commit check.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} index - ProjectIndex
|
|
20
|
+
* @param {object} options - { base, staged, file, limit }
|
|
21
|
+
* @returns {object}
|
|
22
|
+
*/
|
|
23
|
+
function check(index, options = {}) {
|
|
24
|
+
let dr;
|
|
25
|
+
try {
|
|
26
|
+
dr = diffImpact(index, {
|
|
27
|
+
base: options.base || 'HEAD',
|
|
28
|
+
staged: !!options.staged,
|
|
29
|
+
file: options.file,
|
|
30
|
+
});
|
|
31
|
+
} catch (e) {
|
|
32
|
+
// Not a git repo, or git command failed — treat as empty
|
|
33
|
+
return {
|
|
34
|
+
base: options.base || 'HEAD',
|
|
35
|
+
staged: !!options.staged,
|
|
36
|
+
empty: true,
|
|
37
|
+
reason: e && e.message ? e.message : 'diff failed',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// diffImpact returns { base, functions, newFunctions, deletedFunctions }
|
|
42
|
+
const modified = (dr && Array.isArray(dr.functions)) ? dr.functions : [];
|
|
43
|
+
const added = (dr && Array.isArray(dr.newFunctions)) ? dr.newFunctions : [];
|
|
44
|
+
const deleted = (dr && Array.isArray(dr.deletedFunctions)) ? dr.deletedFunctions : [];
|
|
45
|
+
|
|
46
|
+
const allChanged = [
|
|
47
|
+
...modified.map(f => ({ ...f, _kind: 'modified' })),
|
|
48
|
+
...added.map(f => ({ ...f, _kind: 'added' })),
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
if (!dr || (modified.length === 0 && added.length === 0 && deleted.length === 0)) {
|
|
52
|
+
return {
|
|
53
|
+
base: options.base || 'HEAD',
|
|
54
|
+
staged: !!options.staged,
|
|
55
|
+
empty: true,
|
|
56
|
+
reason: dr && dr.error ? dr.error : 'no changes detected',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const limit = options.limit && options.limit > 0 ? options.limit : null;
|
|
61
|
+
const changed = limit ? allChanged.slice(0, limit) : allChanged;
|
|
62
|
+
|
|
63
|
+
const items = [];
|
|
64
|
+
const reachable = computeReachableSet(index);
|
|
65
|
+
|
|
66
|
+
// For each changed function, run verify and gather caller summary
|
|
67
|
+
for (const fn of changed) {
|
|
68
|
+
const filePath = fn.relativePath || fn.file || '';
|
|
69
|
+
let verifyResult = null;
|
|
70
|
+
try {
|
|
71
|
+
verifyResult = index.verify(fn.name, { file: filePath });
|
|
72
|
+
} catch (e) {
|
|
73
|
+
verifyResult = null;
|
|
74
|
+
}
|
|
75
|
+
// Note: verify() returns `mismatches` as a COUNT and `mismatchDetails` as the array.
|
|
76
|
+
const mismatches = verifyResult && Array.isArray(verifyResult.mismatchDetails)
|
|
77
|
+
? verifyResult.mismatchDetails
|
|
78
|
+
: [];
|
|
79
|
+
|
|
80
|
+
// For modified functions, the existing diffImpact result has `callers` already
|
|
81
|
+
let callers = Array.isArray(fn.callers) ? fn.callers : [];
|
|
82
|
+
if (callers.length === 0 && fn._kind === 'added') {
|
|
83
|
+
try {
|
|
84
|
+
callers = index.findCallers(fn.name, { includeMethods: true, includeUncertain: false }) || [];
|
|
85
|
+
} catch (e) { /* skip */ }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const item = {
|
|
89
|
+
name: fn.name,
|
|
90
|
+
file: filePath,
|
|
91
|
+
line: fn.startLine || fn.line,
|
|
92
|
+
kind: fn._kind,
|
|
93
|
+
callerCount: callers.length,
|
|
94
|
+
signatureMismatches: mismatches.length,
|
|
95
|
+
...(mismatches.length > 0 && { mismatches: mismatches.slice(0, 5) }),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Orphan = newly added with zero callers AND not detected as an entry point.
|
|
99
|
+
// (A new helper called by another new helper is still NOT orphan; reachability
|
|
100
|
+
// is rebuilt as the user iterates, and false positives here cause noise.)
|
|
101
|
+
if (item.kind === 'added' && callers.length === 0) {
|
|
102
|
+
// Check entry points: if the symbol is a known entry-point pattern, not orphan
|
|
103
|
+
let isEntry = false;
|
|
104
|
+
try {
|
|
105
|
+
const ep = require('./entrypoints');
|
|
106
|
+
if (typeof ep.detectEntrypoints === 'function') {
|
|
107
|
+
const eps = ep.detectEntrypoints(index) || [];
|
|
108
|
+
isEntry = eps.some(e => e.name === fn.name && (e.file === filePath || e.relativePath === filePath));
|
|
109
|
+
}
|
|
110
|
+
} catch (e) { /* skip */ }
|
|
111
|
+
item.orphan = !isEntry;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
items.push(item);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Surface deleted functions inline — they don't have line/file but still matter
|
|
118
|
+
for (const d of deleted) {
|
|
119
|
+
items.push({
|
|
120
|
+
name: d.name || '(unnamed)',
|
|
121
|
+
file: d.relativePath || d.file || '',
|
|
122
|
+
line: d.startLine || 0,
|
|
123
|
+
kind: 'deleted',
|
|
124
|
+
callerCount: 0,
|
|
125
|
+
signatureMismatches: 0,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Affected tests (top-level summary, capped)
|
|
130
|
+
let testFiles = [];
|
|
131
|
+
let testCount = 0;
|
|
132
|
+
for (const fn of changed.slice(0, 10)) {
|
|
133
|
+
try {
|
|
134
|
+
const t = index.affectedTests(fn.name, { depth: 2 });
|
|
135
|
+
if (t && t.testFiles) {
|
|
136
|
+
for (const tf of t.testFiles) {
|
|
137
|
+
if (!testFiles.find(x => x.file === tf.file)) {
|
|
138
|
+
testFiles.push(tf);
|
|
139
|
+
testCount += tf.testCount || 0;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} catch (e) { /* skip */ }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Action items
|
|
147
|
+
const actions = [];
|
|
148
|
+
for (const it of items) {
|
|
149
|
+
if (it.signatureMismatches > 0) {
|
|
150
|
+
actions.push({
|
|
151
|
+
severity: 'warn',
|
|
152
|
+
kind: 'signature_drift',
|
|
153
|
+
message: `${it.name}: ${it.signatureMismatches} call site(s) need updating`,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (it.orphan) {
|
|
157
|
+
actions.push({
|
|
158
|
+
severity: 'warn',
|
|
159
|
+
kind: 'orphan_new',
|
|
160
|
+
message: `${it.name} is new but has no callers and is not an entry point`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (testFiles.length > 0) {
|
|
165
|
+
const filesList = testFiles.slice(0, 5).map(t => t.file).join(' ');
|
|
166
|
+
actions.push({
|
|
167
|
+
severity: 'info',
|
|
168
|
+
kind: 'tests_to_run',
|
|
169
|
+
message: `Run tests: ${filesList}`,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
base: options.base || 'HEAD',
|
|
175
|
+
staged: !!options.staged,
|
|
176
|
+
changed: items,
|
|
177
|
+
totalChanged: allChanged.length + deleted.length,
|
|
178
|
+
truncated: !!(limit && allChanged.length > limit),
|
|
179
|
+
testFiles,
|
|
180
|
+
totalTestFiles: testFiles.length,
|
|
181
|
+
totalTests: testCount,
|
|
182
|
+
actions,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function symbolKey(file, line) {
|
|
187
|
+
return `${file}:${line}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function computeReachableSet(index) {
|
|
191
|
+
try {
|
|
192
|
+
const ep = require('./entrypoints');
|
|
193
|
+
if (typeof ep.computeReachability === 'function') {
|
|
194
|
+
return ep.computeReachability(index);
|
|
195
|
+
}
|
|
196
|
+
} catch (e) { /* fall through */ }
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = { check };
|
package/core/deadcode.js
CHANGED
|
@@ -212,6 +212,28 @@ function deadcode(index, options = {}) {
|
|
|
212
212
|
}
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
// Pre-filter exported symbols from the scan set when not auditing exports.
|
|
216
|
+
// Go exports ~63K capitalized names on K8s — scanning these in Phase 2 only to
|
|
217
|
+
// skip them in Phase 3 wastes O(63K × 11K files) = ~700M comparisons.
|
|
218
|
+
if (!options.includeExported) {
|
|
219
|
+
const narrowed = new Set();
|
|
220
|
+
for (const name of potentiallyDeadNames) {
|
|
221
|
+
const syms = index.symbols.get(name) || [];
|
|
222
|
+
// Keep the name only if at least one definition is NOT exported
|
|
223
|
+
const allExported = syms.every(s => {
|
|
224
|
+
const fe = index.files.get(s.file);
|
|
225
|
+
const lang = fe?.language;
|
|
226
|
+
if (!fe) return false;
|
|
227
|
+
return fe.exports.includes(name) ||
|
|
228
|
+
(s.modifiers || []).includes('export') ||
|
|
229
|
+
(s.modifiers || []).includes('public') ||
|
|
230
|
+
(langTraits(lang)?.exportVisibility === 'capitalization' && /^[A-Z]/.test(name));
|
|
231
|
+
});
|
|
232
|
+
if (!allExported) narrowed.add(name);
|
|
233
|
+
}
|
|
234
|
+
potentiallyDeadNames = narrowed;
|
|
235
|
+
}
|
|
236
|
+
|
|
215
237
|
// When --file is provided, pre-filter to only names of symbols in the target scope.
|
|
216
238
|
// The text scan below is O(potentiallyDeadNames × files) — narrowing the name set
|
|
217
239
|
// avoids scanning all files for names that will be filtered out at the result stage.
|
|
@@ -236,9 +258,16 @@ function deadcode(index, options = {}) {
|
|
|
236
258
|
for (const [filePath, fileEntry] of index.files) {
|
|
237
259
|
try {
|
|
238
260
|
const content = index._readFile(filePath);
|
|
239
|
-
|
|
261
|
+
// Fast pre-filter: extract identifiers from file, intersect with target names.
|
|
262
|
+
// One regex pass over content (O(content)) vs O(names × content) substring searches.
|
|
263
|
+
const fileIdentifiers = new Set(content.match(/\b[a-zA-Z_]\w*\b/g));
|
|
264
|
+
const namesInFile = [];
|
|
240
265
|
for (const name of potentiallyDeadNames) {
|
|
241
|
-
if (
|
|
266
|
+
if (fileIdentifiers.has(name)) namesInFile.push(name);
|
|
267
|
+
}
|
|
268
|
+
if (namesInFile.length === 0) continue;
|
|
269
|
+
const lines = content.split('\n');
|
|
270
|
+
for (const name of namesInFile) {
|
|
242
271
|
const nameLen = name.length;
|
|
243
272
|
for (let i = 0; i < lines.length; i++) {
|
|
244
273
|
const line = lines[i];
|
package/core/discovery.js
CHANGED
|
@@ -393,43 +393,68 @@ function findProjectRoot(startDir) {
|
|
|
393
393
|
return path.resolve(startDir);
|
|
394
394
|
}
|
|
395
395
|
|
|
396
|
+
// All file extensions for languages UCN supports as code analysis (excludes .rb/.php/.c/.cpp etc.
|
|
397
|
+
// which are extensions UCN scans but doesn't analyze). When build manifests can't tell us
|
|
398
|
+
// what's in a project, we scan all of these — the file extension alone determines language.
|
|
399
|
+
const ALL_SUPPORTED_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'py', 'go', 'java', 'rs', 'html', 'htm'];
|
|
400
|
+
|
|
401
|
+
// Build-manifest hints: when present, we know the project has files of that language
|
|
402
|
+
// regardless of whether sources are visible at the time of scan. Used as hints, not gates —
|
|
403
|
+
// any source file extension is included whether or not its manifest is present.
|
|
404
|
+
const MANIFEST_HINTS = {
|
|
405
|
+
'package.json': ['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'html', 'htm'],
|
|
406
|
+
'pyproject.toml': ['py'],
|
|
407
|
+
'setup.py': ['py'],
|
|
408
|
+
'requirements.txt': ['py'],
|
|
409
|
+
'go.mod': ['go'],
|
|
410
|
+
'Cargo.toml': ['rs'],
|
|
411
|
+
'pom.xml': ['java'],
|
|
412
|
+
'build.gradle': ['java'],
|
|
413
|
+
'build.gradle.kts': ['java'],
|
|
414
|
+
};
|
|
415
|
+
|
|
396
416
|
/**
|
|
397
|
-
* Auto-detect the glob pattern for a project
|
|
398
|
-
*
|
|
417
|
+
* Auto-detect the glob pattern for a project.
|
|
418
|
+
*
|
|
419
|
+
* Discovery rule: build manifests are HINTS, not gates. We always scan ALL supported
|
|
420
|
+
* language extensions (JS/TS/Python/Go/Rust/Java/HTML). Manifests only inform metadata
|
|
421
|
+
* (e.g., flagging a project as "has Go") and never exclude files.
|
|
422
|
+
*
|
|
423
|
+
* This means a polyglot project with only `package.json` still discovers .py/.go/.rs
|
|
424
|
+
* files — language is determined by extension, not by manifest presence.
|
|
425
|
+
*
|
|
426
|
+
* Manifests are still useful for:
|
|
427
|
+
* - Project root detection (see findProjectRoot / PROJECT_MARKERS)
|
|
428
|
+
* - Conditional ignores (vendor/target/Pods/etc. — see CONDITIONAL_IGNORES)
|
|
429
|
+
* - Language hints in stats output
|
|
399
430
|
*/
|
|
400
431
|
function detectProjectPattern(projectRoot) {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
if (fs.existsSync(path.join(dir, 'package.json'))) {
|
|
406
|
-
extensions.push('js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'html', 'htm');
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
if (fs.existsSync(path.join(dir, 'pyproject.toml')) ||
|
|
410
|
-
fs.existsSync(path.join(dir, 'setup.py')) ||
|
|
411
|
-
fs.existsSync(path.join(dir, 'requirements.txt'))) {
|
|
412
|
-
extensions.push('py');
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
if (fs.existsSync(path.join(dir, 'go.mod'))) {
|
|
416
|
-
extensions.push('go');
|
|
417
|
-
}
|
|
432
|
+
// Always scan all supported language extensions. Build manifests no longer gate
|
|
433
|
+
// language inclusion — file extension alone determines what gets analyzed.
|
|
434
|
+
return `**/*.{${ALL_SUPPORTED_EXTENSIONS.join(',')}}`;
|
|
435
|
+
}
|
|
418
436
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
437
|
+
/**
|
|
438
|
+
* Detect which manifest hints are present in a project root or immediate subdirectories.
|
|
439
|
+
* Returns array of detected language extension hints. Used purely informationally —
|
|
440
|
+
* not as a gate on which files get scanned.
|
|
441
|
+
*
|
|
442
|
+
* @param {string} projectRoot - Project root directory
|
|
443
|
+
* @returns {string[]} Array of language extension strings (e.g., ['js', 'py', 'go'])
|
|
444
|
+
*/
|
|
445
|
+
function detectManifestHints(projectRoot) {
|
|
446
|
+
const hints = new Set();
|
|
422
447
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
448
|
+
const checkDir = (dir) => {
|
|
449
|
+
for (const [marker, exts] of Object.entries(MANIFEST_HINTS)) {
|
|
450
|
+
if (fs.existsSync(path.join(dir, marker))) {
|
|
451
|
+
for (const ext of exts) hints.add(ext);
|
|
452
|
+
}
|
|
426
453
|
}
|
|
427
454
|
};
|
|
428
455
|
|
|
429
|
-
// Check project root
|
|
430
456
|
checkDir(projectRoot);
|
|
431
457
|
|
|
432
|
-
// Also check immediate subdirectories for multi-language projects (e.g., web/, frontend/, server/)
|
|
433
458
|
try {
|
|
434
459
|
const entries = fs.readdirSync(projectRoot, { withFileTypes: true });
|
|
435
460
|
for (const entry of entries) {
|
|
@@ -442,12 +467,7 @@ function detectProjectPattern(projectRoot) {
|
|
|
442
467
|
// Ignore errors reading directory
|
|
443
468
|
}
|
|
444
469
|
|
|
445
|
-
|
|
446
|
-
const unique = [...new Set(extensions)];
|
|
447
|
-
return `**/*.{${unique.join(',')}}`;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
return '**/*.{js,jsx,ts,tsx,py,go,java,rs,rb,php,c,cpp,h,hpp,html,htm}';
|
|
470
|
+
return [...hints];
|
|
451
471
|
}
|
|
452
472
|
|
|
453
473
|
/**
|
|
@@ -541,11 +561,14 @@ module.exports = {
|
|
|
541
561
|
shouldIgnore,
|
|
542
562
|
findProjectRoot,
|
|
543
563
|
detectProjectPattern,
|
|
564
|
+
detectManifestHints,
|
|
544
565
|
getFileStats,
|
|
545
566
|
isTestFile,
|
|
546
567
|
findTestFileFor,
|
|
547
568
|
parseGitignore,
|
|
548
569
|
DEFAULT_IGNORES,
|
|
549
570
|
PROJECT_MARKERS,
|
|
550
|
-
TEST_PATTERNS
|
|
571
|
+
TEST_PATTERNS,
|
|
572
|
+
ALL_SUPPORTED_EXTENSIONS,
|
|
573
|
+
MANIFEST_HINTS
|
|
551
574
|
};
|