ucn 3.7.47 → 3.8.0

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/core/project.js CHANGED
@@ -24,6 +24,29 @@ const callersModule = require('./callers');
24
24
  // Lazy-initialized per-language keyword sets (populated on first isKeyword call)
25
25
  let LANGUAGE_KEYWORDS = null;
26
26
 
27
+ /**
28
+ * Build a glob-style matcher: * matches any sequence, ? matches one char.
29
+ * Case-insensitive by default. Returns a function (string) => boolean.
30
+ */
31
+ function buildGlobMatcher(pattern, caseSensitive) {
32
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&')
33
+ .replace(/\*/g, '.*')
34
+ .replace(/\?/g, '.');
35
+ const regex = new RegExp('^' + escaped + '$', caseSensitive ? '' : 'i');
36
+ return (name) => regex.test(name);
37
+ }
38
+
39
+ const STRUCTURAL_TYPES = new Set(['function', 'class', 'call', 'method', 'type']);
40
+
41
+ /**
42
+ * Substring match. Case-insensitive by default.
43
+ */
44
+ function matchesSubstring(text, pattern, caseSensitive) {
45
+ if (!text) return false;
46
+ if (caseSensitive) return text.includes(pattern);
47
+ return text.toLowerCase().includes(pattern.toLowerCase());
48
+ }
49
+
27
50
  /**
28
51
  * ProjectIndex - Manages symbol table for a project
29
52
  */
@@ -47,6 +70,7 @@ class ProjectIndex {
47
70
  this.failedFiles = new Set(); // files that failed to index (e.g. large minified bundles)
48
71
  this._opContentCache = null; // per-operation file content cache (Map<filePath, string>)
49
72
  this._opUsagesCache = null; // per-operation findUsagesInCode cache (Map<"file:name", usages[]>)
73
+ this.calleeIndex = null; // name -> Set<filePath> — inverted call index (built lazily)
50
74
  }
51
75
 
52
76
  /**
@@ -407,10 +431,63 @@ class ProjectIndex {
407
431
  // Invalidate cached call data for this file
408
432
  this.callsCache.delete(filePath);
409
433
 
434
+ // Invalidate callee index (will be rebuilt lazily)
435
+ this.calleeIndex = null;
436
+
410
437
  // Invalidate attribute type cache for this file
411
438
  if (this._attrTypeCache) this._attrTypeCache.delete(filePath);
412
439
  }
413
440
 
441
+ /**
442
+ * Build inverted call index: callee name -> Set<filePath>.
443
+ * Built lazily on first findCallers call, from the calls cache.
444
+ * Enables O(relevant files) lookup instead of O(all files) scan.
445
+ */
446
+ buildCalleeIndex() {
447
+ const { getCachedCalls } = require('./callers');
448
+ this.calleeIndex = new Map();
449
+
450
+ for (const [filePath] of this.files) {
451
+ const calls = getCachedCalls(this, filePath);
452
+ if (!calls) continue;
453
+ for (const call of calls) {
454
+ const name = call.name;
455
+ if (!this.calleeIndex.has(name)) {
456
+ this.calleeIndex.set(name, new Set());
457
+ }
458
+ this.calleeIndex.get(name).add(filePath);
459
+ // Also index resolvedName and resolvedNames for alias resolution
460
+ if (call.resolvedName && call.resolvedName !== name) {
461
+ if (!this.calleeIndex.has(call.resolvedName)) {
462
+ this.calleeIndex.set(call.resolvedName, new Set());
463
+ }
464
+ this.calleeIndex.get(call.resolvedName).add(filePath);
465
+ }
466
+ if (call.resolvedNames) {
467
+ for (const rn of call.resolvedNames) {
468
+ if (rn !== name) {
469
+ if (!this.calleeIndex.has(rn)) {
470
+ this.calleeIndex.set(rn, new Set());
471
+ }
472
+ this.calleeIndex.get(rn).add(filePath);
473
+ }
474
+ }
475
+ }
476
+ }
477
+ }
478
+ }
479
+
480
+ /**
481
+ * Get the set of files that contain calls to a given name.
482
+ * Returns null if callee index is not available (falls back to full scan).
483
+ */
484
+ getCalleeFiles(name) {
485
+ if (!this.calleeIndex) {
486
+ this.buildCalleeIndex();
487
+ }
488
+ return this.calleeIndex.get(name) || null;
489
+ }
490
+
414
491
  /**
415
492
  * Resolve a Java package import to a project file.
416
493
  * Handles regular imports, static imports (strips member name), and wildcards (strips .*).
@@ -553,7 +630,7 @@ class ProjectIndex {
553
630
  const classNames = new Set();
554
631
  for (const [, fileEntry] of this.files) {
555
632
  for (const symbol of fileEntry.symbols) {
556
- if (['class', 'interface', 'struct', 'trait'].includes(symbol.type)) {
633
+ if (['class', 'interface', 'struct', 'trait', 'record'].includes(symbol.type)) {
557
634
  classNames.add(symbol.name);
558
635
  }
559
636
  }
@@ -561,7 +638,7 @@ class ProjectIndex {
561
638
 
562
639
  for (const [filePath, fileEntry] of this.files) {
563
640
  for (const symbol of fileEntry.symbols) {
564
- if (!['class', 'interface', 'struct', 'trait'].includes(symbol.type)) {
641
+ if (!['class', 'interface', 'struct', 'trait', 'record'].includes(symbol.type)) {
565
642
  continue;
566
643
  }
567
644
 
@@ -866,12 +943,10 @@ class ProjectIndex {
866
943
  }
867
944
  }
868
945
  candidate.importerCount = importerCount;
869
- candidate.usageCount = this.countSymbolUsages(candidate.def).total;
870
946
  }
871
- // Sort by import popularity first, then usage count
872
- tiedCandidates.sort((a, b) =>
873
- (b.importerCount - a.importerCount) || (b.usageCount - a.usageCount)
874
- );
947
+ // Sort by import popularity (cheap no file reads needed)
948
+ // Skip usage count (expensive) — import popularity is a strong enough signal
949
+ tiedCandidates.sort((a, b) => b.importerCount - a.importerCount);
875
950
  // Rebuild scored array: sorted tied candidates first, then rest
876
951
  const rest = scored.filter(s => s.score !== tiedScore);
877
952
  scored.length = 0;
@@ -1002,9 +1077,68 @@ class ProjectIndex {
1002
1077
  * @param {object} symbol - Symbol with file, name, etc.
1003
1078
  * @returns {object} { total, calls, definitions, imports, references }
1004
1079
  */
1005
- countSymbolUsages(symbol) {
1080
+ countSymbolUsages(symbol, options = {}) {
1006
1081
  const name = symbol.name;
1007
1082
  const defFile = symbol.file;
1083
+
1084
+ // Fast path: use callee index + import graph for counting (no file reads)
1085
+ // This is an approximation — counts files containing calls, not individual call sites.
1086
+ // Use options.detailed = true for exact per-call-site counting via AST.
1087
+ if (!options.detailed) {
1088
+ // Ensure callee index is built (lazy, reused across operations)
1089
+ if (!this.calleeIndex) this.buildCalleeIndex();
1090
+ const hasFilters = options.exclude && options.exclude.length > 0;
1091
+
1092
+ // Count calls from callee index (files containing calls to this name)
1093
+ const calleeFiles = this.calleeIndex.get(name);
1094
+ let calls = 0;
1095
+ if (calleeFiles) {
1096
+ // Count actual call entries from calls cache for accuracy
1097
+ const { getCachedCalls } = require('./callers');
1098
+ for (const fp of calleeFiles) {
1099
+ // Apply exclude filters
1100
+ if (hasFilters) {
1101
+ const fe = this.files.get(fp);
1102
+ if (fe && !this.matchesFilters(fe.relativePath, { exclude: options.exclude })) continue;
1103
+ }
1104
+ const fileCalls = getCachedCalls(this, fp);
1105
+ if (!fileCalls) continue;
1106
+ for (const c of fileCalls) {
1107
+ if (c.name === name || c.resolvedName === name ||
1108
+ (c.resolvedNames && c.resolvedNames.includes(name))) {
1109
+ calls++;
1110
+ }
1111
+ }
1112
+ }
1113
+ }
1114
+
1115
+ // Count definitions from symbol table
1116
+ const defs = this.symbols.get(name) || [];
1117
+ let definitions = defs.length;
1118
+ if (hasFilters) {
1119
+ definitions = defs.filter(d =>
1120
+ this.matchesFilters(d.relativePath, { exclude: options.exclude })
1121
+ ).length;
1122
+ }
1123
+
1124
+ // Count imports from import graph (files that import from defFile and use this name)
1125
+ let imports = 0;
1126
+ const importers = this.exportGraph.get(defFile) || [];
1127
+ for (const importer of importers) {
1128
+ const fe = this.files.get(importer);
1129
+ if (!fe) continue;
1130
+ if (hasFilters && !this.matchesFilters(fe.relativePath, { exclude: options.exclude })) continue;
1131
+ // Check if this file's importNames reference our symbol
1132
+ if (fe.importNames && fe.importNames.includes(name)) {
1133
+ imports++;
1134
+ }
1135
+ }
1136
+
1137
+ const total = calls + definitions + imports;
1138
+ return { total, calls, definitions, imports, references: 0 };
1139
+ }
1140
+
1141
+ // Detailed path: full AST-based counting (original algorithm)
1008
1142
  // Note: no 'g' flag - we only need to test for presence per line
1009
1143
  const regex = new RegExp('\\b' + escapeRegExp(name) + '\\b');
1010
1144
 
@@ -1029,8 +1163,8 @@ class ProjectIndex {
1029
1163
 
1030
1164
  while (queue.length > 0) {
1031
1165
  const file = queue.pop();
1032
- const importers = this.exportGraph.get(file) || [];
1033
- for (const importer of importers) {
1166
+ const importersArr = this.exportGraph.get(file) || [];
1167
+ for (const importer of importersArr) {
1034
1168
  if (!relevantFiles.has(importer)) {
1035
1169
  relevantFiles.add(importer);
1036
1170
  // If this importer re-exports the symbol, follow its importers too
@@ -1062,8 +1196,12 @@ class ProjectIndex {
1062
1196
  let imports = 0;
1063
1197
  let references = 0;
1064
1198
 
1199
+ const hasExclude = options.exclude && options.exclude.length > 0;
1065
1200
  for (const filePath of relevantFiles) {
1066
- if (!this.files.has(filePath)) continue;
1201
+ const fileEntry = this.files.get(filePath);
1202
+ if (!fileEntry) continue;
1203
+ // Apply exclude filters (e.g., test file exclusion)
1204
+ if (hasExclude && !this.matchesFilters(fileEntry.relativePath, { exclude: options.exclude })) continue;
1067
1205
 
1068
1206
  try {
1069
1207
  // Try AST-based counting first (with per-operation cache)
@@ -2534,6 +2672,97 @@ class ProjectIndex {
2534
2672
  };
2535
2673
  }
2536
2674
 
2675
+ /**
2676
+ * Detect circular dependencies in the import graph.
2677
+ * Uses DFS with 3-color marking to find all cycles.
2678
+ * @param {object} options - { file, exclude }
2679
+ * @returns {object} - { cycles, totalFiles, summary }
2680
+ */
2681
+ circularDeps(options = {}) {
2682
+ this._beginOp();
2683
+ try {
2684
+ const exclude = options.exclude || [];
2685
+ const fileFilter = options.file || null;
2686
+
2687
+ const WHITE = 0, GRAY = 1, BLACK = 2;
2688
+ const color = new Map();
2689
+ const cycles = [];
2690
+ const stack = [];
2691
+
2692
+ const shouldSkip = (file) => {
2693
+ if (!this.files.has(file)) return true;
2694
+ if (exclude.length > 0) {
2695
+ const entry = this.files.get(file);
2696
+ if (entry && !this.matchesFilters(entry.relativePath, { exclude })) return true;
2697
+ }
2698
+ return false;
2699
+ };
2700
+
2701
+ const dfs = (file) => {
2702
+ color.set(file, GRAY);
2703
+ stack.push(file);
2704
+
2705
+ const neighbors = [...new Set(this.importGraph.get(file) || [])];
2706
+
2707
+ for (const neighbor of neighbors) {
2708
+ if (shouldSkip(neighbor)) continue;
2709
+ const nc = color.get(neighbor) || WHITE;
2710
+ if (nc === GRAY) {
2711
+ const idx = stack.indexOf(neighbor);
2712
+ cycles.push(stack.slice(idx));
2713
+ } else if (nc === WHITE) {
2714
+ dfs(neighbor);
2715
+ }
2716
+ }
2717
+
2718
+ stack.pop();
2719
+ color.set(file, BLACK);
2720
+ };
2721
+
2722
+ for (const file of this.files.keys()) {
2723
+ if ((color.get(file) || WHITE) === WHITE && !shouldSkip(file)) {
2724
+ dfs(file);
2725
+ }
2726
+ }
2727
+
2728
+ // Convert to relative paths and deduplicate
2729
+ const seen = new Set();
2730
+ const uniqueCycles = [];
2731
+ for (const cycle of cycles) {
2732
+ const relCycle = cycle.map(f => this.files.get(f)?.relativePath || path.relative(this.root, f));
2733
+ // Normalize: rotate so lexicographically smallest file is first
2734
+ const sorted = relCycle.slice().sort();
2735
+ const minIdx = relCycle.indexOf(sorted[0]);
2736
+ const rotated = [...relCycle.slice(minIdx), ...relCycle.slice(0, minIdx)];
2737
+ const key = rotated.join('\0');
2738
+ if (!seen.has(key)) {
2739
+ seen.add(key);
2740
+ uniqueCycles.push({ files: rotated, length: rotated.length });
2741
+ }
2742
+ }
2743
+
2744
+ // Filter by file pattern
2745
+ let result = uniqueCycles;
2746
+ if (fileFilter) {
2747
+ result = uniqueCycles.filter(c => c.files.some(f => f.includes(fileFilter)));
2748
+ }
2749
+
2750
+ result.sort((a, b) => a.length - b.length || a.files[0].localeCompare(b.files[0]));
2751
+
2752
+ return {
2753
+ cycles: result,
2754
+ totalFiles: this.files.size,
2755
+ fileFilter: fileFilter || undefined,
2756
+ summary: {
2757
+ totalCycles: result.length,
2758
+ filesInCycles: new Set(result.flatMap(c => c.files)).size,
2759
+ }
2760
+ };
2761
+ } finally {
2762
+ this._endOp();
2763
+ }
2764
+ }
2765
+
2537
2766
  /**
2538
2767
  * Detect patterns that may cause incomplete results
2539
2768
  * Returns warnings about dynamic code patterns
@@ -3003,46 +3232,51 @@ class ProjectIndex {
3003
3232
  }
3004
3233
  this._clearTreeCache();
3005
3234
  } else {
3006
- const usages = this.usages(name, { codeOnly: true });
3235
+ // Use findCallers (benefits from callee index) instead of usages() for speed
3236
+ const callerResults = this.findCallers(name, {
3237
+ includeMethods: false,
3238
+ includeUncertain: false,
3239
+ targetDefinitions: [def],
3240
+ });
3007
3241
  const targetBindingId = def.bindingId;
3008
- const calls = usages.filter(u => {
3009
- if (u.usageType !== 'call' || u.isDefinition) return false;
3242
+ // Convert findCallers results to the format expected by analyzeCallSite
3243
+ const calls = callerResults.map(c => ({
3244
+ file: c.file,
3245
+ relativePath: c.relativePath,
3246
+ line: c.line,
3247
+ content: c.content,
3248
+ usageType: 'call',
3249
+ callerName: c.callerName,
3250
+ }));
3251
+ // Keep the same binding filter for backward compat (findCallers already handles this,
3252
+ // but cross-check with usages-based binding filter for safety)
3253
+ const filteredCalls = calls.filter(u => {
3010
3254
  const fileEntry = this.files.get(u.file);
3011
- if (fileEntry) {
3012
- // Filter by binding: skip calls from files that define their own version of this function
3013
- // For Go, also check sibling files in same directory (same package scope)
3014
- if (targetBindingId) {
3015
- let localBindings = (fileEntry.bindings || []).filter(b => b.name === name);
3016
- if (localBindings.length === 0 && fileEntry.language === 'go') {
3017
- const dir = path.dirname(u.file);
3018
- for (const [fp, fe] of this.files) {
3019
- if (fp !== u.file && path.dirname(fp) === dir) {
3020
- const sibling = (fe.bindings || []).filter(b => b.name === name);
3021
- localBindings = localBindings.concat(sibling);
3022
- }
3255
+ if (fileEntry && targetBindingId) {
3256
+ let localBindings = (fileEntry.bindings || []).filter(b => b.name === name);
3257
+ if (localBindings.length === 0 && fileEntry.language === 'go') {
3258
+ const dir = path.dirname(u.file);
3259
+ for (const [fp, fe] of this.files) {
3260
+ if (fp !== u.file && path.dirname(fp) === dir) {
3261
+ const sibling = (fe.bindings || []).filter(b => b.name === name);
3262
+ localBindings = localBindings.concat(sibling);
3023
3263
  }
3024
3264
  }
3025
- if (localBindings.length > 0 && !localBindings.some(b => b.id === targetBindingId)) {
3026
- return false; // This file/package has its own definition — call is to that, not our target
3027
- }
3028
3265
  }
3029
- // Cross-reference with findCallsInCode to filter local closures and built-ins
3030
- // (findCallsInCode has scope-aware filtering that findUsagesInCode lacks)
3031
- const parsedCalls = this.getCachedCalls(u.file);
3032
- if (parsedCalls && Array.isArray(parsedCalls)) {
3033
- const hasCall = parsedCalls.some(c => c.name === name && c.line === u.line);
3034
- if (!hasCall) return false;
3266
+ if (localBindings.length > 0 && !localBindings.some(b => b.id === targetBindingId)) {
3267
+ return false;
3035
3268
  }
3036
3269
  }
3037
3270
  return true;
3038
3271
  });
3272
+ // (findCallers already handles binding resolution and scope-aware filtering)
3039
3273
 
3040
3274
  // Analyze each call site, filtering out method calls for non-method definitions
3041
3275
  callSites = [];
3042
3276
  const defFileEntry = this.files.get(def.file);
3043
3277
  const defLang = defFileEntry?.language;
3044
3278
  const targetDir = defLang === 'go' ? path.basename(path.dirname(def.file)) : null;
3045
- for (const call of calls) {
3279
+ for (const call of filteredCalls) {
3046
3280
  const analysis = this.analyzeCallSite(call, name);
3047
3281
  // Skip method calls (obj.parse()) when target is a standalone function (parse())
3048
3282
  // For Go, allow calls where receiver matches the package directory name
@@ -3065,7 +3299,7 @@ class ProjectIndex {
3065
3299
  file: call.relativePath,
3066
3300
  line: call.line,
3067
3301
  expression: call.content.trim(),
3068
- callerName: this.findEnclosingFunction(call.file, call.line),
3302
+ callerName: call.callerName || this.findEnclosingFunction(call.file, call.line),
3069
3303
  ...analysis
3070
3304
  });
3071
3305
  }
@@ -3134,6 +3368,450 @@ class ProjectIndex {
3134
3368
  } finally { this._endOp(); }
3135
3369
  }
3136
3370
 
3371
+ /**
3372
+ * Transitive blast radius — walk UP the caller chain recursively.
3373
+ * Answers: "What breaks transitively if I change this function?"
3374
+ *
3375
+ * @param {string} name - Function name
3376
+ * @param {object} options - { depth, file, className, all, exclude, includeMethods, includeUncertain }
3377
+ * @returns {object|null} Blast radius tree with summary
3378
+ */
3379
+ blast(name, options = {}) {
3380
+ this._beginOp();
3381
+ try {
3382
+ const maxDepth = Math.max(0, options.depth ?? 3);
3383
+ const maxChildren = options.all ? Infinity : 10;
3384
+ const includeMethods = options.includeMethods ?? true;
3385
+ const includeUncertain = options.includeUncertain || false;
3386
+ const exclude = options.exclude || [];
3387
+
3388
+ const { def, definitions, warnings } = this.resolveSymbol(name, { file: options.file, className: options.className });
3389
+ if (!def) return null;
3390
+
3391
+ const visited = new Set();
3392
+ const affectedFunctions = new Set();
3393
+ const affectedFiles = new Set();
3394
+ let maxDepthReached = 0;
3395
+
3396
+ const buildCallerTree = (funcDef, currentDepth) => {
3397
+ const key = `${funcDef.file}:${funcDef.startLine}`;
3398
+ if (currentDepth > maxDepth) return null;
3399
+ if (visited.has(key)) {
3400
+ return {
3401
+ name: funcDef.name,
3402
+ file: funcDef.relativePath,
3403
+ line: funcDef.startLine,
3404
+ type: funcDef.type || 'function',
3405
+ children: [],
3406
+ alreadyShown: true
3407
+ };
3408
+ }
3409
+ visited.add(key);
3410
+
3411
+ if (currentDepth > maxDepthReached) maxDepthReached = currentDepth;
3412
+ if (currentDepth > 0) {
3413
+ affectedFunctions.add(key);
3414
+ affectedFiles.add(funcDef.file);
3415
+ }
3416
+
3417
+ const node = {
3418
+ name: funcDef.name,
3419
+ file: funcDef.relativePath,
3420
+ line: funcDef.startLine,
3421
+ type: funcDef.type || 'function',
3422
+ children: []
3423
+ };
3424
+
3425
+ if (currentDepth < maxDepth) {
3426
+ const callers = this.findCallers(funcDef.name, {
3427
+ includeMethods,
3428
+ includeUncertain,
3429
+ targetDefinitions: funcDef.bindingId ? [funcDef] : undefined,
3430
+ });
3431
+
3432
+ // Deduplicate callers by enclosing function (multiple call sites → one tree node)
3433
+ const uniqueCallers = new Map();
3434
+ for (const c of callers) {
3435
+ if (!c.callerName) continue; // skip module-level code
3436
+ // Apply exclude filter
3437
+ if (exclude.length > 0 && !this.matchesFilters(c.relativePath, { exclude })) continue;
3438
+ const callerKey = c.callerStartLine
3439
+ ? `${c.callerFile}:${c.callerStartLine}`
3440
+ : `${c.callerFile}:${c.callerName}`;
3441
+ if (!uniqueCallers.has(callerKey)) {
3442
+ uniqueCallers.set(callerKey, {
3443
+ name: c.callerName,
3444
+ file: c.callerFile,
3445
+ relativePath: c.relativePath,
3446
+ startLine: c.callerStartLine,
3447
+ endLine: c.callerEndLine,
3448
+ callSites: 1
3449
+ });
3450
+ } else {
3451
+ uniqueCallers.get(callerKey).callSites++;
3452
+ }
3453
+ }
3454
+
3455
+ // Resolve definitions and build child nodes
3456
+ const callerEntries = [];
3457
+ for (const [, caller] of uniqueCallers) {
3458
+ // Look up actual definition from symbol table
3459
+ const defs = this.symbols.get(caller.name);
3460
+ let callerDef = defs?.find(d => d.file === caller.file && d.startLine === caller.startLine);
3461
+
3462
+ if (!callerDef) {
3463
+ // Pseudo-definition for callers not in symbol table
3464
+ callerDef = {
3465
+ name: caller.name,
3466
+ file: caller.file,
3467
+ relativePath: caller.relativePath,
3468
+ startLine: caller.startLine,
3469
+ endLine: caller.endLine,
3470
+ type: 'function'
3471
+ };
3472
+ }
3473
+
3474
+ callerEntries.push({ def: callerDef, callSites: caller.callSites });
3475
+ }
3476
+
3477
+ // Stable sort by file + line
3478
+ callerEntries.sort((a, b) =>
3479
+ a.def.file.localeCompare(b.def.file) || a.def.startLine - b.def.startLine
3480
+ );
3481
+
3482
+ for (const { def: cDef, callSites } of callerEntries.slice(0, maxChildren)) {
3483
+ const childTree = buildCallerTree(cDef, currentDepth + 1);
3484
+ if (childTree) {
3485
+ childTree.callSites = callSites;
3486
+ node.children.push(childTree);
3487
+ }
3488
+ }
3489
+
3490
+ if (callerEntries.length > maxChildren) {
3491
+ node.truncatedChildren = callerEntries.length - maxChildren;
3492
+ // Count truncated callers in summary
3493
+ for (const { def: cDef } of callerEntries.slice(maxChildren)) {
3494
+ const key = `${cDef.file}:${cDef.startLine}`;
3495
+ if (!visited.has(key)) {
3496
+ affectedFunctions.add(key);
3497
+ affectedFiles.add(cDef.file);
3498
+ }
3499
+ }
3500
+ }
3501
+ }
3502
+
3503
+ return node;
3504
+ };
3505
+
3506
+ const tree = buildCallerTree(def, 0);
3507
+
3508
+ // Smart hints
3509
+ if (tree && tree.children.length === 0) {
3510
+ if (maxDepth === 0) {
3511
+ warnings.push({ message: 'depth=0: showing root function only. Increase depth to see callers.' });
3512
+ } else if (definitions.length > 1 && !options.file) {
3513
+ warnings.push({
3514
+ message: `Resolved to ${def.relativePath}:${def.startLine} which has no callers. ${definitions.length - 1} other definition(s) exist — specify a file to pick a different one.`
3515
+ });
3516
+ }
3517
+ }
3518
+
3519
+ return {
3520
+ root: name,
3521
+ file: def.relativePath,
3522
+ line: def.startLine,
3523
+ maxDepth,
3524
+ includeMethods,
3525
+ tree,
3526
+ summary: {
3527
+ totalAffected: affectedFunctions.size,
3528
+ totalFiles: affectedFiles.size,
3529
+ maxDepthReached
3530
+ },
3531
+ warnings: warnings.length > 0 ? warnings : undefined
3532
+ };
3533
+ } finally { this._endOp(); }
3534
+ }
3535
+
3536
+ /**
3537
+ * Reverse trace: walk UP the caller chain to entry points.
3538
+ * Like blast but focused on "how does execution reach this function?"
3539
+ * Marks leaf nodes (functions with no callers) as entry points.
3540
+ */
3541
+ reverseTrace(name, options = {}) {
3542
+ this._beginOp();
3543
+ try {
3544
+ const maxDepth = Math.max(0, options.depth ?? 5);
3545
+ const maxChildren = options.all ? Infinity : 10;
3546
+ const includeMethods = options.includeMethods ?? true;
3547
+ const includeUncertain = options.includeUncertain || false;
3548
+ const exclude = options.exclude || [];
3549
+
3550
+ const { def, definitions, warnings } = this.resolveSymbol(name, { file: options.file, className: options.className });
3551
+ if (!def) return null;
3552
+
3553
+ const visited = new Set();
3554
+ const entryPoints = [];
3555
+ let maxDepthReached = 0;
3556
+
3557
+ const buildCallerTree = (funcDef, currentDepth) => {
3558
+ const key = `${funcDef.file}:${funcDef.startLine}`;
3559
+ if (currentDepth > maxDepth) return null;
3560
+ if (visited.has(key)) {
3561
+ return {
3562
+ name: funcDef.name,
3563
+ file: funcDef.relativePath,
3564
+ line: funcDef.startLine,
3565
+ type: funcDef.type || 'function',
3566
+ children: [],
3567
+ alreadyShown: true
3568
+ };
3569
+ }
3570
+ visited.add(key);
3571
+ if (currentDepth > maxDepthReached) maxDepthReached = currentDepth;
3572
+
3573
+ const node = {
3574
+ name: funcDef.name,
3575
+ file: funcDef.relativePath,
3576
+ line: funcDef.startLine,
3577
+ type: funcDef.type || 'function',
3578
+ children: []
3579
+ };
3580
+
3581
+ if (currentDepth < maxDepth) {
3582
+ const callers = this.findCallers(funcDef.name, {
3583
+ includeMethods,
3584
+ includeUncertain,
3585
+ targetDefinitions: funcDef.bindingId ? [funcDef] : undefined,
3586
+ });
3587
+
3588
+ // Deduplicate callers by enclosing function
3589
+ const uniqueCallers = new Map();
3590
+ for (const c of callers) {
3591
+ if (!c.callerName) continue;
3592
+ if (exclude.length > 0 && !this.matchesFilters(c.relativePath, { exclude })) continue;
3593
+ const callerKey = c.callerStartLine
3594
+ ? `${c.callerFile}:${c.callerStartLine}`
3595
+ : `${c.callerFile}:${c.callerName}`;
3596
+ if (!uniqueCallers.has(callerKey)) {
3597
+ uniqueCallers.set(callerKey, {
3598
+ name: c.callerName,
3599
+ file: c.callerFile,
3600
+ relativePath: c.relativePath,
3601
+ startLine: c.callerStartLine,
3602
+ endLine: c.callerEndLine,
3603
+ callSites: 1
3604
+ });
3605
+ } else {
3606
+ uniqueCallers.get(callerKey).callSites++;
3607
+ }
3608
+ }
3609
+
3610
+ // Resolve definitions and build child nodes
3611
+ const callerEntries = [];
3612
+ for (const [, caller] of uniqueCallers) {
3613
+ const defs = this.symbols.get(caller.name);
3614
+ let callerDef = defs?.find(d => d.file === caller.file && d.startLine === caller.startLine);
3615
+ if (!callerDef) {
3616
+ callerDef = {
3617
+ name: caller.name,
3618
+ file: caller.file,
3619
+ relativePath: caller.relativePath,
3620
+ startLine: caller.startLine,
3621
+ endLine: caller.endLine,
3622
+ type: 'function'
3623
+ };
3624
+ }
3625
+ callerEntries.push({ def: callerDef, callSites: caller.callSites });
3626
+ }
3627
+
3628
+ callerEntries.sort((a, b) =>
3629
+ a.def.file.localeCompare(b.def.file) || a.def.startLine - b.def.startLine
3630
+ );
3631
+
3632
+ for (const { def: cDef, callSites } of callerEntries.slice(0, maxChildren)) {
3633
+ const childTree = buildCallerTree(cDef, currentDepth + 1);
3634
+ if (childTree) {
3635
+ childTree.callSites = callSites;
3636
+ node.children.push(childTree);
3637
+ }
3638
+ }
3639
+
3640
+ if (callerEntries.length > maxChildren) {
3641
+ node.truncatedChildren = callerEntries.length - maxChildren;
3642
+ // Count entry points in truncated branches so summary is accurate
3643
+ for (const { def: cDef } of callerEntries.slice(maxChildren)) {
3644
+ const key = `${cDef.file}:${cDef.startLine}`;
3645
+ if (!visited.has(key)) {
3646
+ const cCallers = this.findCallers(cDef.name, {
3647
+ includeMethods, includeUncertain,
3648
+ targetDefinitions: cDef.bindingId ? [cDef] : undefined,
3649
+ });
3650
+ if (cCallers.length === 0) {
3651
+ entryPoints.push({ name: cDef.name, file: cDef.relativePath || path.relative(this.root, cDef.file), line: cDef.startLine });
3652
+ }
3653
+ }
3654
+ }
3655
+ }
3656
+
3657
+ // Mark as entry point if no callers found (and not at depth limit)
3658
+ if (uniqueCallers.size === 0 && currentDepth > 0) {
3659
+ node.entryPoint = true;
3660
+ entryPoints.push({ name: funcDef.name, file: funcDef.relativePath, line: funcDef.startLine });
3661
+ }
3662
+ }
3663
+
3664
+ return node;
3665
+ };
3666
+
3667
+ const tree = buildCallerTree(def, 0);
3668
+
3669
+ // Also mark root as entry point if it has no callers
3670
+ if (tree && tree.children.length === 0 && maxDepth > 0) {
3671
+ tree.entryPoint = true;
3672
+ entryPoints.push({ name: def.name, file: def.relativePath, line: def.startLine });
3673
+ }
3674
+
3675
+ // Smart hints
3676
+ if (tree && tree.children.length === 0) {
3677
+ if (maxDepth === 0) {
3678
+ warnings.push({ message: 'depth=0: showing root function only. Increase depth to see callers.' });
3679
+ } else if (definitions.length > 1 && !options.file) {
3680
+ warnings.push({
3681
+ message: `Resolved to ${def.relativePath}:${def.startLine} which has no callers. ${definitions.length - 1} other definition(s) exist — specify a file to pick a different one.`
3682
+ });
3683
+ }
3684
+ }
3685
+
3686
+ return {
3687
+ root: name,
3688
+ file: def.relativePath,
3689
+ line: def.startLine,
3690
+ maxDepth,
3691
+ includeMethods,
3692
+ tree,
3693
+ entryPoints,
3694
+ summary: {
3695
+ totalEntryPoints: entryPoints.length,
3696
+ totalFunctions: visited.size - 1, // exclude root
3697
+ maxDepthReached
3698
+ },
3699
+ warnings: warnings.length > 0 ? warnings : undefined
3700
+ };
3701
+ } finally { this._endOp(); }
3702
+ }
3703
+
3704
+ /**
3705
+ * Find tests affected by a change to the given function.
3706
+ * Composes blast() (transitive callers) with test file scanning.
3707
+ */
3708
+ affectedTests(name, options = {}) {
3709
+ this._beginOp();
3710
+ try {
3711
+ // Step 1: Get all transitively affected functions via blast
3712
+ const blastResult = this.blast(name, {
3713
+ depth: options.depth ?? 3,
3714
+ file: options.file,
3715
+ className: options.className,
3716
+ all: true,
3717
+ exclude: options.exclude,
3718
+ includeMethods: options.includeMethods,
3719
+ includeUncertain: options.includeUncertain,
3720
+ });
3721
+ if (!blastResult) return null;
3722
+
3723
+ // Step 2: Collect all affected function names from the tree
3724
+ const affectedNames = new Set();
3725
+ affectedNames.add(name);
3726
+ const collectNames = (node) => {
3727
+ if (!node) return;
3728
+ affectedNames.add(node.name);
3729
+ for (const child of node.children || []) collectNames(child);
3730
+ };
3731
+ collectNames(blastResult.tree);
3732
+
3733
+ // Step 3: Build regex patterns for all names
3734
+ const namePatterns = new Map();
3735
+ for (const n of affectedNames) {
3736
+ const escaped = escapeRegExp(n);
3737
+ namePatterns.set(n, {
3738
+ regex: new RegExp('\\b' + escaped + '\\b'),
3739
+ callPattern: new RegExp(escaped + '\\s*\\('),
3740
+ });
3741
+ }
3742
+
3743
+ // Step 4: Scan test files once for all affected names
3744
+ const exclude = options.exclude;
3745
+ const excludeArr = exclude ? (Array.isArray(exclude) ? exclude : [exclude]) : [];
3746
+ const results = [];
3747
+ for (const [filePath, fileEntry] of this.files) {
3748
+ if (!isTestFile(fileEntry.relativePath, fileEntry.language)) continue;
3749
+ if (excludeArr.length > 0 && !this.matchesFilters(fileEntry.relativePath, { exclude: excludeArr })) continue;
3750
+ try {
3751
+ const content = this._readFile(filePath);
3752
+ const lines = content.split('\n');
3753
+ const fileMatches = new Map();
3754
+
3755
+ lines.forEach((line, idx) => {
3756
+ for (const [funcName, patterns] of namePatterns) {
3757
+ if (patterns.regex.test(line)) {
3758
+ let matchType = 'reference';
3759
+ if (/\b(describe|it|test|spec)\s*\(/.test(line)) {
3760
+ matchType = 'test-case';
3761
+ } else if (/\b(import|require|from)\b/.test(line)) {
3762
+ matchType = 'import';
3763
+ } else if (patterns.callPattern.test(line)) {
3764
+ matchType = 'call';
3765
+ }
3766
+ if (!fileMatches.has(funcName)) fileMatches.set(funcName, []);
3767
+ fileMatches.get(funcName).push({
3768
+ line: idx + 1, content: line.trim(),
3769
+ matchType, functionName: funcName
3770
+ });
3771
+ }
3772
+ }
3773
+ });
3774
+
3775
+ if (fileMatches.size > 0) {
3776
+ const coveredFunctions = [...fileMatches.keys()];
3777
+ const allMatches = [];
3778
+ for (const matches of fileMatches.values()) allMatches.push(...matches);
3779
+ allMatches.sort((a, b) => a.line - b.line);
3780
+ results.push({
3781
+ file: fileEntry.relativePath,
3782
+ coveredFunctions,
3783
+ matchCount: allMatches.length,
3784
+ matches: allMatches
3785
+ });
3786
+ }
3787
+ } catch (e) { /* skip unreadable */ }
3788
+ }
3789
+
3790
+ // Sort by coverage breadth then alphabetically
3791
+ results.sort((a, b) => b.coveredFunctions.length - a.coveredFunctions.length || a.file.localeCompare(b.file));
3792
+
3793
+ // Compute coverage stats
3794
+ const coveredSet = new Set();
3795
+ for (const r of results) for (const f of r.coveredFunctions) coveredSet.add(f);
3796
+ const uncovered = [...affectedNames].filter(n => !coveredSet.has(n));
3797
+
3798
+ return {
3799
+ root: blastResult.root, file: blastResult.file, line: blastResult.line,
3800
+ depth: blastResult.maxDepth,
3801
+ affectedFunctions: [...affectedNames],
3802
+ testFiles: results,
3803
+ summary: {
3804
+ totalAffected: affectedNames.size,
3805
+ totalTestFiles: results.length,
3806
+ coveredFunctions: coveredSet.size,
3807
+ uncoveredCount: uncovered.length,
3808
+ },
3809
+ uncovered,
3810
+ warnings: blastResult.warnings,
3811
+ };
3812
+ } finally { this._endOp(); }
3813
+ }
3814
+
3137
3815
  /** Plan a refactoring operation */
3138
3816
  plan(name, options) { return verifyModule.plan(this, name, options); }
3139
3817
 
@@ -3222,18 +3900,10 @@ class ProjectIndex {
3222
3900
  const isMethod = !!(primary.isMethod || primary.type === 'method' || primary.className);
3223
3901
  const includeMethods = options.includeMethods ?? isMethod;
3224
3902
 
3225
- // Get usage counts by type (exclude test files by default, matching usages command behavior)
3226
- const usageOpts = { codeOnly: true };
3227
- if (!options.includeTests) {
3228
- usageOpts.exclude = addTestExclusions(options.exclude);
3229
- }
3230
- const usages = this.usages(symbolName, usageOpts);
3231
- const usagesByType = {
3232
- definitions: usages.filter(u => u.isDefinition).length,
3233
- calls: usages.filter(u => u.usageType === 'call').length,
3234
- imports: usages.filter(u => u.usageType === 'import').length,
3235
- references: usages.filter(u => u.usageType === 'reference').length
3236
- };
3903
+ // Get usage counts by type (fast path uses callee index, no file reads)
3904
+ // Exclude test files by default (matching usages command behavior)
3905
+ const countExclude = !options.includeTests ? addTestExclusions(options.exclude) : options.exclude;
3906
+ const usagesByType = this.countSymbolUsages(primary, { exclude: countExclude });
3237
3907
 
3238
3908
  // Get callers and callees (only for functions)
3239
3909
  let callers = [];
@@ -3524,6 +4194,206 @@ class ProjectIndex {
3524
4194
  } finally { this._endOp(); }
3525
4195
  }
3526
4196
 
4197
+ /**
4198
+ * Structural search — query the symbol table and call index, not raw text.
4199
+ * Answers questions like "functions taking Request param", "all db.* calls",
4200
+ * "exported async functions", "decorated route handlers".
4201
+ *
4202
+ * @param {object} options
4203
+ * @param {string} [options.term] - Name filter (glob: * and ? supported)
4204
+ * @param {string} [options.type] - Symbol kind: function, class, call, method, type
4205
+ * @param {string} [options.param] - Parameter name or type substring
4206
+ * @param {string} [options.receiver] - Call receiver pattern (for type=call)
4207
+ * @param {string} [options.returns] - Return type substring
4208
+ * @param {string} [options.decorator] - Decorator/annotation name substring
4209
+ * @param {boolean} [options.exported] - Only exported symbols
4210
+ * @param {boolean} [options.unused] - Only symbols with zero callers
4211
+ * @param {string[]} [options.exclude] - Exclude file patterns
4212
+ * @param {string} [options.in] - Restrict to subdirectory
4213
+ * @param {string} [options.file] - File pattern filter
4214
+ * @param {number} [options.top] - Limit results
4215
+ * @returns {{ results: Array, meta: object }}
4216
+ */
4217
+ structuralSearch(options = {}) {
4218
+ this._beginOp();
4219
+ try {
4220
+ const { term, param, receiver, returns: returnType, decorator, exported, unused } = options;
4221
+ // Auto-infer type: --receiver implies type=call
4222
+ const type = options.type || (receiver ? 'call' : undefined);
4223
+ const results = [];
4224
+
4225
+ // Validate type if provided
4226
+ if (type && !STRUCTURAL_TYPES.has(type)) {
4227
+ return {
4228
+ results: [],
4229
+ meta: {
4230
+ mode: 'structural',
4231
+ query: { type },
4232
+ totalMatched: 0,
4233
+ shown: 0,
4234
+ error: `Invalid type "${type}". Valid types: ${[...STRUCTURAL_TYPES].join(', ')}`,
4235
+ }
4236
+ };
4237
+ }
4238
+
4239
+ // Build glob-style name matcher from term
4240
+ const nameMatcher = term ? buildGlobMatcher(term, options.caseSensitive) : null;
4241
+
4242
+ // Helper: check if file passes filters
4243
+ const passesFileFilter = (fileEntry) => {
4244
+ if (!fileEntry) return false;
4245
+ if (options.file) {
4246
+ const rp = fileEntry.relativePath;
4247
+ if (!rp.includes(options.file) && !rp.endsWith(options.file)) return false;
4248
+ }
4249
+ if ((options.exclude && options.exclude.length > 0) || options.in) {
4250
+ if (!this.matchesFilters(fileEntry.relativePath, { exclude: options.exclude, in: options.in })) return false;
4251
+ }
4252
+ return true;
4253
+ };
4254
+
4255
+ if (type === 'call') {
4256
+ // Search call sites from callee index
4257
+ const { getCachedCalls } = require('./callers');
4258
+ const seenFiles = new Set();
4259
+
4260
+ // If term is given, only scan files that might contain that call
4261
+ if (term && !term.includes('*') && !term.includes('?')) {
4262
+ // Exact or substring — use callee index for fast lookup
4263
+ this.buildCalleeIndex();
4264
+ const files = this.calleeIndex.get(term);
4265
+ if (files) for (const f of files) seenFiles.add(f);
4266
+ } else {
4267
+ // Scan all files
4268
+ for (const fp of this.files.keys()) seenFiles.add(fp);
4269
+ }
4270
+
4271
+ for (const filePath of seenFiles) {
4272
+ const fileEntry = this.files.get(filePath);
4273
+ if (!passesFileFilter(fileEntry)) continue;
4274
+ const calls = getCachedCalls(this, filePath);
4275
+ if (!calls) continue;
4276
+ for (const call of calls) {
4277
+ if (nameMatcher && !nameMatcher(call.name)) continue;
4278
+ if (receiver) {
4279
+ if (!call.receiver) continue;
4280
+ if (!matchesSubstring(call.receiver, receiver, options.caseSensitive)) continue;
4281
+ }
4282
+ results.push({
4283
+ kind: 'call',
4284
+ name: call.receiver ? `${call.receiver}.${call.name}` : call.name,
4285
+ file: fileEntry.relativePath,
4286
+ line: call.line,
4287
+ receiver: call.receiver || null,
4288
+ isMethod: call.isMethod || false,
4289
+ });
4290
+ }
4291
+ }
4292
+ } else {
4293
+ // Search symbols (functions, classes, methods, types)
4294
+ const functionTypes = new Set(['function', 'constructor', 'method', 'arrow', 'static', 'classmethod']);
4295
+ const classTypes = new Set(['class', 'struct', 'interface', 'impl', 'trait']);
4296
+ const typeTypes = new Set(['type', 'enum', 'interface', 'trait']);
4297
+ const methodTypes = new Set(['method', 'constructor']);
4298
+
4299
+ for (const [symbolName, definitions] of this.symbols) {
4300
+ if (nameMatcher && !nameMatcher(symbolName)) continue;
4301
+
4302
+ for (const def of definitions) {
4303
+ // Type filter
4304
+ if (type === 'function' && !functionTypes.has(def.type)) continue;
4305
+ if (type === 'class' && !classTypes.has(def.type)) continue;
4306
+ if (type === 'method' && !methodTypes.has(def.type) && !def.isMethod) continue;
4307
+ if (type === 'type' && !typeTypes.has(def.type)) continue;
4308
+
4309
+ // File filters
4310
+ const fileEntry = this.files.get(def.file);
4311
+ if (!passesFileFilter(fileEntry)) continue;
4312
+
4313
+ // Param filter: match param name or type
4314
+ if (param) {
4315
+ const cs = options.caseSensitive;
4316
+ const ps = def.paramsStructured || [];
4317
+ const paramStr = def.params || '';
4318
+ const hasMatch = ps.some(p =>
4319
+ matchesSubstring(p.name, param, cs) ||
4320
+ (p.type && matchesSubstring(p.type, param, cs))
4321
+ ) || matchesSubstring(paramStr, param, cs);
4322
+ if (!hasMatch) continue;
4323
+ }
4324
+
4325
+ // Return type filter
4326
+ if (returnType) {
4327
+ if (!def.returnType || !matchesSubstring(def.returnType, returnType, options.caseSensitive)) continue;
4328
+ }
4329
+
4330
+ // Decorator filter: checks decorators (Python), modifiers (Java annotations stored lowercase)
4331
+ if (decorator) {
4332
+ const cs = options.caseSensitive;
4333
+ const hasDecorator = (def.decorators && def.decorators.some(d => matchesSubstring(d, decorator, cs))) ||
4334
+ (def.modifiers && def.modifiers.some(m => matchesSubstring(m, decorator, cs)));
4335
+ if (!hasDecorator) continue;
4336
+ }
4337
+
4338
+ // Exported filter
4339
+ if (exported) {
4340
+ const mods = def.modifiers || [];
4341
+ const isExp = (fileEntry && fileEntry.exports.includes(symbolName)) ||
4342
+ mods.includes('export') || mods.includes('public') ||
4343
+ mods.some(m => m.startsWith('pub')) ||
4344
+ (fileEntry && fileEntry.language === 'go' && /^[A-Z]/.test(symbolName));
4345
+ if (!isExp) continue;
4346
+ }
4347
+
4348
+ // Unused filter (expensive — last check)
4349
+ if (unused) {
4350
+ this.buildCalleeIndex();
4351
+ if (this.calleeIndex.has(symbolName)) continue;
4352
+ }
4353
+
4354
+ // Merge decorators from both Python-style decorators and Java-style modifiers
4355
+ const allDecorators = def.decorators || null;
4356
+
4357
+ results.push({
4358
+ kind: def.type,
4359
+ name: symbolName,
4360
+ file: def.relativePath,
4361
+ line: def.startLine,
4362
+ params: def.params || null,
4363
+ returnType: def.returnType || null,
4364
+ decorators: allDecorators,
4365
+ className: def.className || null,
4366
+ exported: exported ? true : undefined,
4367
+ });
4368
+ }
4369
+ }
4370
+ }
4371
+
4372
+ // Sort by file, then line
4373
+ results.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
4374
+
4375
+ // Apply top limit
4376
+ const total = results.length;
4377
+ const top = options.top;
4378
+ if (top && top > 0 && results.length > top) {
4379
+ results.length = top;
4380
+ }
4381
+
4382
+ return {
4383
+ results,
4384
+ meta: {
4385
+ mode: 'structural',
4386
+ query: Object.fromEntries(Object.entries({
4387
+ type: type || 'any', term, param, receiver, returns: returnType,
4388
+ decorator, exported: exported || undefined, unused: unused || undefined,
4389
+ }).filter(([, v]) => v !== undefined && v !== null)),
4390
+ totalMatched: total,
4391
+ shown: results.length,
4392
+ }
4393
+ };
4394
+ } finally { this._endOp(); }
4395
+ }
4396
+
3527
4397
  // ========================================================================
3528
4398
  // PROJECT INFO
3529
4399
  // ========================================================================
@@ -3573,7 +4443,8 @@ class ProjectIndex {
3573
4443
  for (const [name, symbols] of this.symbols) {
3574
4444
  for (const sym of symbols) {
3575
4445
  if (sym.type === 'function' || sym.type === 'method' || sym.type === 'static' ||
3576
- sym.type === 'constructor' || sym.type === 'public' || sym.type === 'abstract') {
4446
+ sym.type === 'constructor' || sym.type === 'public' || sym.type === 'abstract' ||
4447
+ sym.type === 'classmethod') {
3577
4448
  const lineCount = sym.endLine - sym.startLine + 1;
3578
4449
  const relativePath = sym.relativePath || (sym.file ? path.relative(this.root, sym.file) : '');
3579
4450
  functions.push({
@@ -3638,10 +4509,11 @@ class ProjectIndex {
3638
4509
  if (fileFilter && !fileFilter.has(filePath)) continue;
3639
4510
  let functions = fileEntry.symbols.filter(s =>
3640
4511
  s.type === 'function' || s.type === 'method' || s.type === 'static' ||
3641
- s.type === 'constructor' || s.type === 'public' || s.type === 'abstract'
4512
+ s.type === 'constructor' || s.type === 'public' || s.type === 'abstract' ||
4513
+ s.type === 'classmethod'
3642
4514
  );
3643
4515
  const classes = fileEntry.symbols.filter(s =>
3644
- ['class', 'interface', 'type', 'enum', 'struct', 'trait', 'impl'].includes(s.type)
4516
+ ['class', 'interface', 'type', 'enum', 'struct', 'trait', 'impl', 'record', 'namespace'].includes(s.type)
3645
4517
  );
3646
4518
  const state = fileEntry.symbols.filter(s => s.type === 'state');
3647
4519