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.
Files changed (47) hide show
  1. package/.claude/skills/ucn/SKILL.md +114 -11
  2. package/README.md +152 -156
  3. package/cli/index.js +363 -37
  4. package/core/analysis.js +960 -37
  5. package/core/bridge.js +1111 -0
  6. package/core/brief.js +408 -0
  7. package/core/cache.js +213 -59
  8. package/core/callers.js +117 -41
  9. package/core/check.js +200 -0
  10. package/core/deadcode.js +31 -2
  11. package/core/discovery.js +57 -34
  12. package/core/entrypoints.js +638 -4
  13. package/core/execute.js +304 -5
  14. package/core/git-enrich.js +130 -0
  15. package/core/graph-build.js +4 -4
  16. package/core/graph.js +31 -12
  17. package/core/output/analysis.js +157 -25
  18. package/core/output/brief.js +100 -0
  19. package/core/output/check.js +79 -0
  20. package/core/output/doctor.js +85 -0
  21. package/core/output/endpoints.js +239 -0
  22. package/core/output/extraction.js +2 -0
  23. package/core/output/find.js +126 -39
  24. package/core/output/graph.js +48 -15
  25. package/core/output/refactoring.js +103 -5
  26. package/core/output/reporting.js +63 -23
  27. package/core/output/search.js +110 -17
  28. package/core/output/shared.js +56 -2
  29. package/core/output.js +4 -0
  30. package/core/parallel-build.js +10 -7
  31. package/core/parser.js +8 -2
  32. package/core/project.js +147 -41
  33. package/core/registry.js +30 -14
  34. package/core/reporting.js +465 -2
  35. package/core/search.js +139 -15
  36. package/core/shared.js +101 -5
  37. package/core/tracing.js +31 -12
  38. package/core/verify.js +982 -95
  39. package/languages/go.js +91 -6
  40. package/languages/html.js +10 -0
  41. package/languages/java.js +151 -35
  42. package/languages/javascript.js +290 -33
  43. package/languages/python.js +78 -11
  44. package/languages/rust.js +267 -12
  45. package/languages/utils.js +315 -3
  46. package/mcp/server.js +91 -16
  47. 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.some(imp => targetFiles.has(imp));
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.some(ti => targetFiles.has(ti))) {
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.some(imp => targetFiles.has(imp));
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.some(imp => targetFiles2.has(imp));
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.some(ti => targetFiles2.has(ti))) {
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
- try {
632
- const content = fs.readFileSync(filePath, 'utf-8');
633
- for (const { call, fileEntry, callerSymbol, isMethod, isFunctionReference, receiver, receiverType, _evidence } of pending) {
634
- const scored = scoreEdge(_evidence || {});
635
- callers.push({
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
- content: getLine(content, call.line),
640
- callerName: callerSymbol ? callerSymbol.name : null,
641
- callerFile: callerSymbol ? filePath : null,
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
- } catch (e) {
653
- // File may have been deleted between Phase 1 and Phase 2
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 = new Set(index.importGraph.get(def.file) || []);
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
- // Match: identifier = ClassName(...) or identifier: Type = ClassName(...)
1387
- const escapedName = call.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1388
- const assignMatch = sourceLine.match(
1389
- new RegExp(`(\\w+)\\s*(?::\\s*\\w+)?\\s*=\\s*${escapedName}\\s*\\(`)
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
- // Match: with ClassName(...) as identifier:
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
- const lines = content.split('\n');
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 (!content.includes(name)) continue;
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 based on its type
398
- * Checks both project root and immediate subdirectories for config files
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
- const extensions = [];
402
-
403
- // Helper to check for config files in a directory
404
- const checkDir = (dir) => {
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
- if (fs.existsSync(path.join(dir, 'Cargo.toml'))) {
420
- extensions.push('rs');
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
- if (fs.existsSync(path.join(dir, 'pom.xml')) ||
424
- fs.existsSync(path.join(dir, 'build.gradle'))) {
425
- extensions.push('java');
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
- if (extensions.length > 0) {
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
  };