ucn 3.8.13 → 3.8.15

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 (44) 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/imports.js +50 -16
  14. package/core/output/analysis-ext.js +271 -0
  15. package/core/output/analysis.js +491 -0
  16. package/core/output/extraction.js +188 -0
  17. package/core/output/find.js +355 -0
  18. package/core/output/graph.js +399 -0
  19. package/core/output/refactoring.js +293 -0
  20. package/core/output/reporting.js +331 -0
  21. package/core/output/search.js +307 -0
  22. package/core/output/shared.js +271 -0
  23. package/core/output/tracing.js +416 -0
  24. package/core/output.js +15 -3293
  25. package/core/parallel-build.js +165 -0
  26. package/core/project.js +299 -3633
  27. package/core/registry.js +59 -0
  28. package/core/reporting.js +258 -0
  29. package/core/search.js +890 -0
  30. package/core/stacktrace.js +1 -1
  31. package/core/tracing.js +631 -0
  32. package/core/verify.js +10 -13
  33. package/eslint.config.js +43 -0
  34. package/jsconfig.json +10 -0
  35. package/languages/go.js +21 -2
  36. package/languages/html.js +8 -0
  37. package/languages/index.js +102 -40
  38. package/languages/java.js +13 -0
  39. package/languages/javascript.js +17 -1
  40. package/languages/python.js +14 -0
  41. package/languages/rust.js +13 -0
  42. package/languages/utils.js +1 -1
  43. package/mcp/server.js +45 -28
  44. package/package.json +8 -3
package/core/callers.js CHANGED
@@ -8,7 +8,7 @@
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
10
  const crypto = require('crypto');
11
- const { detectLanguage, getParser, getLanguageModule } = require('../languages');
11
+ const { detectLanguage, getParser, getLanguageModule, langTraits } = require('../languages');
12
12
  const { isTestFile } = require('./discovery');
13
13
  const { NON_CALLABLE_TYPES } = require('./shared');
14
14
  const { scoreEdge } = require('./confidence');
@@ -83,7 +83,7 @@ function getCachedCalls(index, filePath, options = {}) {
83
83
  // Pass import alias names to Go parser for package vs method call disambiguation
84
84
  // importNames contains resolved alias names (e.g., 'utilversion' for renamed imports)
85
85
  const callOpts = {};
86
- if (language === 'go') {
86
+ if (langTraits(language)?.hasReceiverPackageCalls) {
87
87
  const fileEntry = index.files.get(filePath);
88
88
  if (fileEntry?.importNames) {
89
89
  callOpts.imports = fileEntry.importNames;
@@ -136,6 +136,7 @@ function findCallers(index, name, options = {}) {
136
136
  const pendingByFile = new Map(); // filePath -> [{ call, fileEntry, callerSymbol, isMethod, isFunctionReference, receiver }]
137
137
  let pendingCount = 0;
138
138
  const maxResults = options.maxResults;
139
+ const localTypeCache = new Map(); // `${filePath}:${startLine}` -> localTypes Map or null
139
140
 
140
141
  // Use inverted callee index to skip files that don't contain calls to this name
141
142
  const calleeFiles = index.getCalleeFiles(name);
@@ -163,7 +164,7 @@ function findCallers(index, name, options = {}) {
163
164
 
164
165
  // Go unexported visibility: lowercase functions are package-private.
165
166
  // Only allow callers from the same package directory.
166
- if (fileEntry.language === 'go' && /^[a-z]/.test(name)) {
167
+ if (langTraits(fileEntry.language)?.exportVisibility === 'capitalization' && /^[a-z]/.test(name)) {
167
168
  const targetDefs = options.targetDefinitions || definitions;
168
169
  const targetPkgDirs = new Set(
169
170
  targetDefs.filter(d => d.file).map(d => path.dirname(d.file))
@@ -173,9 +174,9 @@ function findCallers(index, name, options = {}) {
173
174
  }
174
175
  }
175
176
 
176
- // Go/Java/Rust receiver disambiguation for callbacks (e.g. dc.worker)
177
+ // Nominal type receiver disambiguation for callbacks (e.g. dc.worker)
177
178
  if (call.isMethod && call.receiver &&
178
- (fileEntry.language === 'go' || fileEntry.language === 'java' || fileEntry.language === 'rust')) {
179
+ langTraits(fileEntry.language)?.typeSystem === 'nominal') {
179
180
  const targetDefs = options.targetDefinitions || definitions;
180
181
  const targetTypes = new Set();
181
182
  for (const td of targetDefs) {
@@ -211,12 +212,16 @@ function findCallers(index, name, options = {}) {
211
212
  if (!bindingId && !skipLocalBinding) {
212
213
  let bindings = (fileEntry.bindings || []).filter(b => b.name === call.name);
213
214
  // For Go, also check sibling files in same directory (same package scope)
214
- if (bindings.length === 0 && fileEntry.language === 'go') {
215
+ if (bindings.length === 0 && langTraits(fileEntry.language)?.packageScope === 'directory') {
215
216
  const dir = path.dirname(filePath);
216
- for (const [fp, fe] of index.files) {
217
- if (fp !== filePath && path.dirname(fp) === dir) {
218
- const sibling = (fe.bindings || []).filter(b => b.name === call.name);
219
- bindings = bindings.concat(sibling);
217
+ const siblings = index.dirToFiles?.get(dir) || [];
218
+ for (const fp of siblings) {
219
+ if (fp !== filePath) {
220
+ const fe = index.files.get(fp);
221
+ if (fe) {
222
+ const sibling = (fe.bindings || []).filter(b => b.name === call.name);
223
+ bindings = bindings.concat(sibling);
224
+ }
220
225
  }
221
226
  }
222
227
  }
@@ -278,7 +283,7 @@ function findCallers(index, name, options = {}) {
278
283
  // over-reporting is preferred to losing callers. These languages' nominal
279
284
  // type systems also make method links more reliable.
280
285
  if (bindings.length === 0 && call.isMethod &&
281
- fileEntry.language !== 'go' && fileEntry.language !== 'java' && fileEntry.language !== 'rust') {
286
+ langTraits(fileEntry.language)?.typeSystem === 'structural') {
282
287
  const hasReceiverEvidence = call.receiver &&
283
288
  (fileEntry.bindings || []).some(b => b.name === call.receiver);
284
289
  if (!hasReceiverEvidence) {
@@ -348,7 +353,7 @@ function findCallers(index, name, options = {}) {
348
353
  // Java method calls are always obj.method() - include by default
349
354
  // Rust Type::method() calls - include by default (associated functions)
350
355
  // For other languages, skip method calls unless explicitly requested
351
- if (fileEntry.language !== 'go' && fileEntry.language !== 'java' && fileEntry.language !== 'rust' && !options.includeMethods) continue;
356
+ if (langTraits(fileEntry.language)?.methodCallInclusion === 'explicit' && !options.includeMethods) continue;
352
357
  }
353
358
  }
354
359
 
@@ -375,7 +380,7 @@ function findCallers(index, name, options = {}) {
375
380
  // user_b importing from b.js being reported as a caller of a.js:process.
376
381
  // Go/Java/Rust are excluded — they use package/module scoping, not file imports.
377
382
  if (!bindingId && options.targetDefinitions && definitions.length > 1 &&
378
- fileEntry.language !== 'go' && fileEntry.language !== 'java' && fileEntry.language !== 'rust') {
383
+ langTraits(fileEntry.language)?.typeSystem === 'structural') {
379
384
  const targetFiles = new Set(targetDefs.map(d => d.file).filter(Boolean));
380
385
  if (targetFiles.size > 0 && !targetFiles.has(filePath)) {
381
386
  const imports = index.importGraph.get(filePath) || [];
@@ -397,7 +402,7 @@ function findCallers(index, name, options = {}) {
397
402
 
398
403
  // Go unexported visibility: lowercase functions are package-private.
399
404
  // Only allow callers from the same package directory.
400
- if (fileEntry.language === 'go' && /^[a-z]/.test(name)) {
405
+ if (langTraits(fileEntry.language)?.exportVisibility === 'capitalization' && /^[a-z]/.test(name)) {
401
406
  const targetPkgDirs = new Set(
402
407
  targetDefs.filter(d => d.file).map(d => path.dirname(d.file))
403
408
  );
@@ -412,7 +417,7 @@ function findCallers(index, name, options = {}) {
412
417
  // Rust path calls (module::func(), Type::new()) bypass this filter — they're
413
418
  // scoped_identifier calls that can target both standalone functions and impl methods.
414
419
  if (!bindingId && !resolvedBySameClass && !call.isPathCall &&
415
- (fileEntry.language === 'go' || fileEntry.language === 'java' || fileEntry.language === 'rust')) {
420
+ langTraits(fileEntry.language)?.typeSystem === 'nominal') {
416
421
  const targetHasClass = targetDefs.some(d => d.className);
417
422
  if (call.isMethod && !targetHasClass) {
418
423
  // Method call but target is a standalone function — skip
@@ -424,19 +429,65 @@ function findCallers(index, name, options = {}) {
424
429
  }
425
430
  }
426
431
 
432
+ // Go package-qualified call filter: when a non-method call has a receiver
433
+ // that is an import alias (e.g., fmt.Errorf()), verify the caller imports
434
+ // a project file containing the target. Catches stdlib (single-segment imports
435
+ // like "fmt", "os") and third-party calls (import graph has no edge to target).
436
+ if (!call.isMethod && call.receiver && !bindingId &&
437
+ langTraits(fileEntry.language)?.hasReceiverPackageCalls) {
438
+ const callerFileImports = fileEntry.imports || [];
439
+ const importModule = callerFileImports.find(mod => {
440
+ const parts = mod.split('/');
441
+ const last = parts[parts.length - 1];
442
+ const pkgName = (/^v\d+$/.test(last) && parts.length > 1) ? parts[parts.length - 2] : last;
443
+ return pkgName === call.receiver;
444
+ });
445
+ if (importModule) {
446
+ if (!importModule.includes('/')) {
447
+ // Single-segment import — Go stdlib, always external
448
+ continue;
449
+ }
450
+ // Multi-segment import — verify via import graph
451
+ const callerImportedFiles = index.importGraph.get(filePath) || [];
452
+ const targetFiles = new Set(targetDefs.map(d => d.file).filter(Boolean));
453
+ if (!targetFiles.has(filePath)) {
454
+ const hasImportEdge = callerImportedFiles.some(imp => targetFiles.has(imp));
455
+ if (!hasImportEdge) {
456
+ // No import edge — allow same-package (same directory) calls
457
+ const callerDir = path.dirname(filePath);
458
+ const samePackage = targetDefs.some(d => d.file && path.dirname(d.file) === callerDir);
459
+ if (!samePackage) continue;
460
+ }
461
+ }
462
+ }
463
+ }
464
+
427
465
  // Receiver-class disambiguation:
428
466
  // When the target definition has a class/receiver type, filter callers
429
467
  // whose receiverType is known to be a different type.
430
468
  // All languages use receiverType when available (constructor/annotation inference).
431
469
  // Go/Java/Rust additionally fall back to variable name matching.
432
470
  if (call.isMethod && call.receiver && !resolvedBySameClass && !bindingId &&
433
- (call.receiverType || fileEntry.language === 'java' || fileEntry.language === 'go' || fileEntry.language === 'rust')) {
471
+ (call.receiverType || langTraits(fileEntry.language)?.typeSystem === 'nominal')) {
434
472
  // Build target type set from both className (Java) and receiver (Go/Rust)
435
473
  const targetTypes = new Set();
436
474
  for (const td of targetDefs) {
437
475
  if (td.className) targetTypes.add(td.className);
438
476
  if (td.receiver) targetTypes.add(td.receiver.replace(/^\*/, ''));
439
477
  }
478
+ // Expand targetTypes with types that embed the target (Go/Java/Rust)
479
+ // e.g., if target is Base.Start() and Child embeds Base, accept Child.Start() callers
480
+ if (targetTypes.size > 0 && langTraits(fileEntry.language)?.typeSystem === 'nominal') {
481
+ for (const tt of [...targetTypes]) {
482
+ const children = index.extendedByGraph?.get(tt);
483
+ if (children) {
484
+ for (const child of children) {
485
+ const cName = typeof child === 'string' ? child : child.name;
486
+ if (cName) targetTypes.add(cName);
487
+ }
488
+ }
489
+ }
490
+ }
440
491
  if (targetTypes.size > 0) {
441
492
  // Use inferred receiverType when available (Go/Java/Rust parameter type tracking)
442
493
  const knownType = call.receiverType;
@@ -450,32 +501,68 @@ function findCallers(index, name, options = {}) {
450
501
  continue;
451
502
  }
452
503
  }
453
- } else if (options.targetDefinitions && definitions.length > 1) {
454
- // No inferred type — fall back to receiver variable name matching
455
- // only when we have multiple definitions to disambiguate
456
- const receiverLower = call.receiver.toLowerCase();
457
- const matchesTarget = [...targetTypes].some(cn => cn.toLowerCase() === receiverLower);
458
- if (!matchesTarget) {
459
- // Rust/Go path calls (Type::method() / pkg.Method()): receiver IS the type name
460
- // If it doesn't match target, it's definitely a different type — filter it
461
- if (call.isPathCall && /^[A-Z]/.test(call.receiver)) {
462
- isUncertain = true;
463
- if (!options.includeUncertain) {
464
- if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
465
- continue;
504
+ } else {
505
+ // No parser-inferred type — try local type inference
506
+ // for Go/Java/Rust (nominal type systems)
507
+ let inferredMatch = false;
508
+ let inferredMismatch = false;
509
+ if (langTraits(fileEntry.language)?.typeSystem === 'nominal') {
510
+ const callerSym = index.findEnclosingFunction(filePath, call.line, true);
511
+ if (callerSym && callerSym.startLine != null && callerSym.endLine != null) {
512
+ const cacheKey = `${filePath}:${callerSym.startLine}`;
513
+ let localTypes = localTypeCache.get(cacheKey);
514
+ if (localTypes === undefined) {
515
+ const callsForFile = getCachedCalls(index, filePath);
516
+ localTypes = callsForFile ? _buildTypedLocalTypeMap(index,
517
+ { file: filePath, startLine: callerSym.startLine, endLine: callerSym.endLine },
518
+ callsForFile) : null;
519
+ localTypeCache.set(cacheKey, localTypes);
520
+ }
521
+ if (localTypes) {
522
+ const inferredType = localTypes.get(call.receiver);
523
+ if (inferredType) {
524
+ if (targetTypes.has(inferredType)) {
525
+ inferredMatch = true;
526
+ } else {
527
+ inferredMismatch = true;
528
+ }
529
+ }
466
530
  }
467
531
  }
468
- const nonTargetClasses = new Set();
469
- for (const d of definitions) {
470
- const t = d.className || (d.receiver && d.receiver.replace(/^\*/, ''));
471
- if (t && !targetTypes.has(t)) nonTargetClasses.add(t);
532
+ }
533
+ if (inferredMismatch) {
534
+ isUncertain = true;
535
+ if (!options.includeUncertain) {
536
+ if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
537
+ continue;
472
538
  }
473
- const matchesOther = [...nonTargetClasses].some(cn => cn.toLowerCase() === receiverLower);
474
- if (matchesOther) {
475
- isUncertain = true;
476
- if (!options.includeUncertain) {
477
- if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
478
- continue;
539
+ }
540
+ // Still no type — fall back to receiver name matching when multiple defs exist
541
+ if (!inferredMatch && !inferredMismatch && definitions.length > 1) {
542
+ const receiverLower = call.receiver.toLowerCase();
543
+ const matchesTarget = [...targetTypes].some(cn => cn.toLowerCase() === receiverLower);
544
+ if (!matchesTarget) {
545
+ // Rust/Go path calls (Type::method() / pkg.Method()): receiver IS the type name
546
+ // If it doesn't match target, it's definitely a different type — filter it
547
+ if (call.isPathCall && /^[A-Z]/.test(call.receiver)) {
548
+ isUncertain = true;
549
+ if (!options.includeUncertain) {
550
+ if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
551
+ continue;
552
+ }
553
+ }
554
+ const nonTargetClasses = new Set();
555
+ for (const d of definitions) {
556
+ const t = d.className || (d.receiver && d.receiver.replace(/^\*/, ''));
557
+ if (t && !targetTypes.has(t)) nonTargetClasses.add(t);
558
+ }
559
+ const matchesOther = [...nonTargetClasses].some(cn => cn.toLowerCase() === receiverLower);
560
+ if (matchesOther) {
561
+ isUncertain = true;
562
+ if (!options.includeUncertain) {
563
+ if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
564
+ continue;
565
+ }
479
566
  }
480
567
  }
481
568
  }
@@ -515,7 +602,7 @@ function findCallers(index, name, options = {}) {
515
602
  // Method calls where binding resolution was skipped (non-self receiver)
516
603
  // and the receiver has no binding evidence → uncertain (JS/TS/Python only)
517
604
  skipLocalBinding && call.isMethod && !resolvedBySameClass &&
518
- fileEntry.language !== 'go' && fileEntry.language !== 'java' && fileEntry.language !== 'rust' &&
605
+ langTraits(fileEntry.language)?.typeSystem === 'structural' &&
519
606
  !(call.receiver && (fileEntry.bindings || []).some(b => b.name === call.receiver))
520
607
  ),
521
608
  hasReceiverType: !!call.receiverType,
@@ -604,9 +691,9 @@ function findCallees(index, def, options = {}) {
604
691
  // Build local variable type map for receiver resolution
605
692
  // Scans for patterns like: bt = Backtester(...) → bt maps to Backtester
606
693
  let localTypes = null;
607
- if (language === 'python' || language === 'javascript') {
694
+ if (langTraits(language)?.typeSystem === 'structural') {
608
695
  localTypes = _buildLocalTypeMap(index, def, calls);
609
- } else if (language === 'go' || language === 'java' || language === 'rust') {
696
+ } else if (langTraits(language)?.typeSystem === 'nominal') {
610
697
  localTypes = _buildTypedLocalTypeMap(index, def, calls);
611
698
  }
612
699
 
@@ -644,10 +731,23 @@ function findCallees(index, def, options = {}) {
644
731
  const symbols = index.symbols.get(call.name);
645
732
  const isCallable = (s) => !NON_CALLABLE_TYPES.has(s.type) ||
646
733
  (s.type === 'field' && s.fieldType && /^func\b/.test(s.fieldType));
647
- const match = symbols?.find(s =>
734
+ let match = symbols?.find(s =>
648
735
  isCallable(s) && (
649
736
  s.className === typeName ||
650
737
  (s.receiver && s.receiver.replace(/^\*/, '') === typeName)));
738
+ // Walk embedding/inheritance chain if no direct match (nominal type systems)
739
+ if (!match && langTraits(language)?.typeSystem === 'nominal') {
740
+ const parentNames = index._getInheritanceParents?.(typeName, def.file);
741
+ if (parentNames) {
742
+ for (const pName of parentNames) {
743
+ match = symbols?.find(s =>
744
+ isCallable(s) && (
745
+ s.className === pName ||
746
+ (s.receiver && s.receiver.replace(/^\*/, '') === pName)));
747
+ if (match) break;
748
+ }
749
+ }
750
+ }
651
751
  if (match) {
652
752
  const key = match.bindingId || `${typeName}.${call.name}`;
653
753
  const existing = callees.get(key);
@@ -667,10 +767,23 @@ function findCallees(index, def, options = {}) {
667
767
  const symbols = index.symbols.get(call.name);
668
768
  const isCallableRT = (s) => !NON_CALLABLE_TYPES.has(s.type) ||
669
769
  (s.type === 'field' && s.fieldType && /^func\b/.test(s.fieldType));
670
- const match = symbols?.find(s =>
770
+ let match = symbols?.find(s =>
671
771
  isCallableRT(s) && (
672
772
  (s.receiver && s.receiver.replace(/^\*/, '') === typeName) ||
673
773
  s.className === typeName));
774
+ // Walk embedding/inheritance chain if no direct match (nominal type systems)
775
+ if (!match && langTraits(language)?.typeSystem === 'nominal') {
776
+ const parentNames = index._getInheritanceParents?.(typeName, def.file);
777
+ if (parentNames) {
778
+ for (const pName of parentNames) {
779
+ match = symbols?.find(s =>
780
+ isCallableRT(s) && (
781
+ (s.receiver && s.receiver.replace(/^\*/, '') === pName) ||
782
+ s.className === pName));
783
+ if (match) break;
784
+ }
785
+ }
786
+ }
674
787
  if (match) {
675
788
  const key = match.bindingId || `${typeName}.${call.name}`;
676
789
  const existing = callees.get(key);
@@ -682,30 +795,44 @@ function findCallees(index, def, options = {}) {
682
795
  continue;
683
796
  }
684
797
  // No match found with inferred type — fall through to include as unresolved
685
- } else if (language === 'go' && call.receiver) {
798
+ } else if (langTraits(language)?.hasReceiverPackageCalls && call.receiver) {
686
799
  // Go package-qualified calls: klog.Infof(), wait.UntilWithContext()
687
800
  // Check if receiver is an import alias and resolve to correct package
688
801
  const goImports = fileEntry?.imports || [];
689
802
  // Find import whose package name matches the receiver
803
+ // Handle Go version suffixes: k8s.io/klog/v2 → klog, not v2
690
804
  const importModule = goImports.find(mod => {
691
- const pkgName = mod.split('/').pop();
805
+ const parts = mod.split('/');
806
+ const last = parts[parts.length - 1];
807
+ const pkgName = (/^v\d+$/.test(last) && parts.length > 1) ? parts[parts.length - 2] : last;
692
808
  return pkgName === call.receiver;
693
809
  });
694
810
  if (importModule) {
695
811
  // Receiver is an import alias — resolve to definitions from that package
696
812
  const symbols = index.symbols.get(call.name);
697
813
  if (symbols) {
698
- // Match by checking if the definition's directory path matches the import path suffix
814
+ // Match by checking if the definition's directory path matches the import path suffix.
815
+ // Pick the symbol with the LONGEST suffix match to avoid false positives
816
+ // (e.g., import "k8s.io/client-go/kubernetes/scheme" should prefer a definition
817
+ // in .../client-go/kubernetes/scheme/ over one in .../kubeadm/scheme/).
699
818
  const importParts = importModule.split('/');
700
- const match = symbols.find(s => {
819
+ let bestMatch = null;
820
+ let bestMatchLen = 0;
821
+ for (const s of symbols) {
701
822
  const sDir = path.dirname(s.relativePath || path.relative(index.root, s.file));
702
- // Try matching progressively shorter suffixes of the import path
703
- for (let i = importParts.length - 1; i >= 0; i--) {
823
+ for (let i = 0; i < importParts.length; i++) {
704
824
  const suffix = importParts.slice(i).join('/');
705
- if (sDir === suffix || sDir.endsWith('/' + suffix)) return true;
825
+ if (sDir === suffix || sDir.endsWith('/' + suffix)) {
826
+ const matchLen = importParts.length - i;
827
+ if (matchLen > bestMatchLen) {
828
+ bestMatchLen = matchLen;
829
+ bestMatch = s;
830
+ }
831
+ break; // this symbol's best suffix found, try next
832
+ }
706
833
  }
707
- return false;
708
- });
834
+ }
835
+ const match = bestMatch;
709
836
  if (match) {
710
837
  const key = match.bindingId || `${call.receiver}.${call.name}`;
711
838
  const existing = callees.get(key);
@@ -717,8 +844,10 @@ function findCallees(index, def, options = {}) {
717
844
  continue;
718
845
  }
719
846
  }
847
+ // Import resolved but no project definition matches — external call, skip
848
+ continue;
720
849
  }
721
- } else if (language !== 'go' && language !== 'java' && language !== 'rust' && !options.includeMethods) {
850
+ } else if (langTraits(language)?.methodCallInclusion === 'explicit' && !options.includeMethods) {
722
851
  continue;
723
852
  }
724
853
  }
@@ -781,7 +910,7 @@ function findCallees(index, def, options = {}) {
781
910
  if (!call.bindingId && fileEntry?.bindings) {
782
911
  let bindings = fileEntry.bindings.filter(b => b.name === call.name);
783
912
  // For Go, also check sibling files in same directory (same package scope)
784
- if (bindings.length === 0 && language === 'go') {
913
+ if (bindings.length === 0 && langTraits(language)?.packageScope === 'directory') {
785
914
  const dir = path.dirname(def.file);
786
915
  for (const [fp, fe] of index.files) {
787
916
  if (fp !== def.file && path.dirname(fp) === dir) {
@@ -793,7 +922,7 @@ function findCallees(index, def, options = {}) {
793
922
  // Method call with no binding for the method name:
794
923
  // Different strategies by language family:
795
924
  if (bindings.length === 0 && call.isMethod) {
796
- if (language !== 'go' && language !== 'java' && language !== 'rust') {
925
+ if (langTraits(language)?.typeSystem === 'structural') {
797
926
  // JS/TS/Python: mark uncertain unless receiver has import/binding
798
927
  // evidence in file scope AND that binding can plausibly have this method.
799
928
  // Prevents false positives like m.get() → repository.get() when m is
@@ -818,7 +947,7 @@ function findCallees(index, def, options = {}) {
818
947
  // Go: if receiverType is known, check if it matches exactly one def
819
948
  // This resolves ambiguity like Framework.Run vs Scheduler.Run
820
949
  const rType = call.receiverType || localTypes?.get(call.receiver);
821
- if (rType && (language === 'go' || language === 'java' || language === 'rust')) {
950
+ if (rType && langTraits(language)?.typeSystem === 'nominal') {
822
951
  const matchingDef = defs.find(d =>
823
952
  d.className === rType ||
824
953
  (d.receiver && d.receiver.replace(/^\*/, '') === rType));
@@ -840,7 +969,7 @@ function findCallees(index, def, options = {}) {
840
969
  // matches the binding's class. Prevents plt.close() → ReportGenerator.close()
841
970
  // when close is defined in the same file as a class method.
842
971
  if (call.isMethod && call.receiver && bindings[0].type === 'method' &&
843
- language !== 'go' && language !== 'java' && language !== 'rust') {
972
+ langTraits(language)?.typeSystem === 'structural') {
844
973
  // The binding is a class method — check if the receiver could be an instance
845
974
  const bindingSym = index.symbols.get(call.name)?.find(
846
975
  s => s.bindingId === bindings[0].id);
@@ -1269,6 +1398,7 @@ function _buildLocalTypeMap(index, def, calls) {
1269
1398
  */
1270
1399
  function _buildTypedLocalTypeMap(index, def, calls) {
1271
1400
  const localTypes = new Map();
1401
+ let _cachedLines = null;
1272
1402
 
1273
1403
  for (const call of calls) {
1274
1404
  if (call.line < def.startLine || call.line > def.endLine) continue;
@@ -1282,12 +1412,12 @@ function _buildTypedLocalTypeMap(index, def, calls) {
1282
1412
  // Handles: x := NewFoo(), x, err := NewFoo(), x := pkg.NewFoo(), x, err := pkg.NewFoo()
1283
1413
  const newName = call.isMethod ? call.name : call.name;
1284
1414
  if (/^New[A-Z]/.test(newName) && !call.isPotentialCallback) {
1285
- let content;
1286
- try {
1287
- content = index._readFile(def.file);
1288
- } catch { continue; }
1289
- const lines = content.split('\n');
1290
- const sourceLine = lines[call.line - 1];
1415
+ if (!_cachedLines) {
1416
+ try {
1417
+ _cachedLines = index._readFile(def.file).split('\n');
1418
+ } catch { continue; }
1419
+ }
1420
+ const sourceLine = _cachedLines[call.line - 1];
1291
1421
  if (!sourceLine) continue;
1292
1422
  // Match: x := [pkg.]NewFoo( or x, err := [pkg.]NewFoo( or x, _ := [pkg.]NewFoo(
1293
1423
  const assignMatch = sourceLine.match(
package/core/deadcode.js CHANGED
@@ -5,9 +5,8 @@
5
5
  * as the first argument instead of using `this`.
6
6
  */
7
7
 
8
- const { detectLanguage, getParser, getLanguageModule, safeParse } = require('../languages');
8
+ const { detectLanguage, getParser, getLanguageModule, safeParse, langTraits } = require('../languages');
9
9
  const { isTestFile } = require('./discovery');
10
- const { escapeRegExp } = require('./shared');
11
10
  const { isFrameworkEntrypoint } = require('./entrypoints');
12
11
 
13
12
  /** Check if a position in a line is inside a string literal (quotes/backticks) */
@@ -206,13 +205,27 @@ function deadcode(index, options = {}) {
206
205
  }
207
206
 
208
207
  // Pre-filter: names in the callee index have call sites → definitely used → not dead.
209
- const potentiallyDeadNames = new Set();
208
+ let potentiallyDeadNames = new Set();
210
209
  for (const name of callableNames) {
211
210
  if (!index.calleeIndex.has(name)) {
212
211
  potentiallyDeadNames.add(name);
213
212
  }
214
213
  }
215
214
 
215
+ // When --file is provided, pre-filter to only names of symbols in the target scope.
216
+ // The text scan below is O(potentiallyDeadNames × files) — narrowing the name set
217
+ // avoids scanning all files for names that will be filtered out at the result stage.
218
+ if (options.file) {
219
+ const filteredNames = new Set();
220
+ for (const name of potentiallyDeadNames) {
221
+ const syms = index.symbols.get(name) || [];
222
+ if (syms.some(s => s.relativePath && s.relativePath.includes(options.file))) {
223
+ filteredNames.add(name);
224
+ }
225
+ }
226
+ potentiallyDeadNames = filteredNames;
227
+ }
228
+
216
229
  // Build usage index for potentially dead names using text scan (no tree-sitter reparsing).
217
230
  // The callee index already covers all call-based usages. For remaining names, a word-boundary
218
231
  // text scan catches imports, exports, shorthand properties, type refs, and variable refs.
@@ -267,7 +280,7 @@ function deadcode(index, options = {}) {
267
280
  }
268
281
  }
269
282
  }
270
- } catch {}
283
+ } catch { /* skip unreadable files */ }
271
284
  }
272
285
  }
273
286
 
@@ -305,67 +318,10 @@ function deadcode(index, options = {}) {
305
318
 
306
319
  const mods = symbol.modifiers || [];
307
320
 
308
- // Language-specific entry points (called by runtime, no AST-visible callers)
309
- // Go: main() and init() are called by runtime
310
- const isGoEntryPoint = lang === 'go' && (name === 'main' || name === 'init');
311
-
312
- // Java: public static void main(String[] args) is the entry point
313
- const isJavaEntryPoint = lang === 'java' && name === 'main' &&
314
- mods.includes('public') && mods.includes('static');
315
-
316
- // Python: Magic/dunder methods are called by the interpreter, not user code
317
- // test_* functions/methods are called by pytest/unittest via reflection
318
- // setUp/tearDown are unittest.TestCase framework methods called by test runner
319
- // pytest_* are pytest plugin hooks called by the framework
320
- const isPythonEntryPoint = lang === 'python' &&
321
- (/^__\w+__$/.test(name) || /^test_/.test(name) ||
322
- /^(setUp|tearDown)(Class|Module)?$/.test(name) ||
323
- /^pytest_/.test(name));
324
-
325
- // Rust: main() is entry point, #[test] and #[bench] functions are called by test/bench runner
326
- const isRustEntryPoint = lang === 'rust' &&
327
- (name === 'main' || mods.includes('test') || mods.includes('bench'));
328
-
329
- // Rust: trait impl methods are invoked via trait dispatch, not direct calls
330
- // They can never be "dead" - the trait contract requires them to exist
331
- const isRustTraitImpl = lang === 'rust' && symbol.isMethod &&
332
- symbol.className && symbol.traitImpl;
333
-
334
- // Go: Test*, Benchmark*, Example* functions are called by go test
335
- const isGoTestFunc = lang === 'go' &&
336
- /^(Test|Benchmark|Example)[A-Z]/.test(name);
337
-
338
- // Java: @Test annotated methods are called by JUnit
339
- const isJavaTestMethod = lang === 'java' && mods.includes('test');
340
-
341
- // Java: @Override methods are invoked via polymorphic dispatch
342
- // They implement interface/superclass contracts and can't be dead
343
- const isJavaOverride = lang === 'java' && mods.includes('override');
344
-
345
- // Skip trait impl / @Override methods entirely - they're required by the type system
346
- if (isRustTraitImpl || isJavaOverride) {
347
- continue;
348
- }
349
-
350
- // JavaScript/TypeScript: framework lifecycle methods called by runtime
351
- // React class components, Web Components, Angular, Vue
352
- const jsLifecycleMethods = new Set([
353
- // React class component lifecycle
354
- 'render', 'componentDidMount', 'componentDidUpdate', 'componentWillUnmount',
355
- 'getDerivedStateFromProps', 'getDerivedStateFromError', 'componentDidCatch',
356
- 'getSnapshotBeforeUpdate', 'shouldComponentUpdate',
357
- // Web Components lifecycle
358
- 'connectedCallback', 'disconnectedCallback', 'attributeChangedCallback', 'adoptedCallback'
359
- ]);
360
- const isJsEntryPoint = (lang === 'javascript' || lang === 'typescript' || lang === 'tsx') &&
361
- symbol.isMethod && jsLifecycleMethods.has(name);
362
-
363
- const isEntryPoint = isGoEntryPoint || isGoTestFunc ||
364
- isJavaEntryPoint || isJavaTestMethod ||
365
- isPythonEntryPoint || isRustEntryPoint || isJsEntryPoint;
366
-
367
- // Entry points are always excluded — they're invoked by the runtime, not user code
368
- if (isEntryPoint) {
321
+ // Language-specific entry points (called by runtime/test runner, not user code)
322
+ // Each language module declares its own isEntryPoint() rules.
323
+ const langModule = getLanguageModule(lang);
324
+ if (langModule.isEntryPoint?.(symbol)) {
369
325
  continue;
370
326
  }
371
327
 
@@ -384,7 +340,7 @@ function deadcode(index, options = {}) {
384
340
  fileEntry.exports.includes(name) ||
385
341
  mods.includes('export') ||
386
342
  mods.includes('public') ||
387
- (lang === 'go' && /^[A-Z]/.test(name))
343
+ (langTraits(lang)?.exportVisibility === 'capitalization' && /^[A-Z]/.test(name))
388
344
  );
389
345
 
390
346
  // Skip exported unless requested