ucn 4.1.3 → 4.2.2

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/check.js CHANGED
@@ -11,7 +11,33 @@
11
11
 
12
12
  'use strict';
13
13
 
14
- const { diffImpact } = require('./analysis');
14
+ const { diffImpact, composeAccount, callNotResolvedEntries } = require('./analysis');
15
+
16
+ function summarizeAccount(account) {
17
+ if (!account) {
18
+ return {
19
+ available: false,
20
+ textComplete: false,
21
+ semanticComplete: false,
22
+ safeToDelete: false,
23
+ };
24
+ }
25
+ const contract = account.contract || {};
26
+ return {
27
+ available: true,
28
+ conserved: account.conserved === true,
29
+ textComplete: contract.textComplete === true,
30
+ observedTextZero: contract.observedTextZero === true,
31
+ semanticComplete: false,
32
+ safeToDelete: false,
33
+ unparsedFiles: account.unparsed ? account.unparsed.fileCount || 0 : 0,
34
+ unreadableFiles: Array.isArray(account.unreadableFiles) ? account.unreadableFiles.length : 0,
35
+ unaccounted: account.unaccounted || 0,
36
+ filtered: account.filtered ? account.filtered.total || 0 : 0,
37
+ beyondTextCallers: account.beyondText ? account.beyondText.count || 0 : 0,
38
+ requiresUsageReview: contract.requiresUsageReview === true,
39
+ };
40
+ }
15
41
 
16
42
  /**
17
43
  * Run the pre-commit check.
@@ -61,12 +87,11 @@ function check(index, options = {}) {
61
87
  const changed = limit ? allChanged.slice(0, limit) : allChanged;
62
88
 
63
89
  const items = [];
64
- const reachable = computeReachableSet(index);
65
90
 
66
91
  // For each changed function, run verify and gather caller summary
67
92
  for (const fn of changed) {
68
93
  const filePath = fn.relativePath || fn.file || '';
69
- let verifyResult = null;
94
+ let verifyResult;
70
95
  try {
71
96
  verifyResult = index.verify(fn.name, { file: filePath });
72
97
  } catch (e) {
@@ -81,12 +106,22 @@ function check(index, options = {}) {
81
106
  // bands already (confirmed `callers` + visible `unverifiedCallers`).
82
107
  let callers = Array.isArray(fn.callers) ? fn.callers : [];
83
108
  let unverifiedCallers = Array.isArray(fn.unverifiedCallers) ? fn.unverifiedCallers : [];
109
+ let account = fn.account || null;
84
110
  if (callers.length === 0 && fn._kind === 'added') {
85
111
  try {
86
- const raw = index.findCallers(fn.name, { includeMethods: true, collectAccount: true }) || [];
112
+ const definitions = (index.symbols.get(fn.name) || []).filter(d =>
113
+ (d.relativePath === filePath || d.file === fn.filePath) &&
114
+ (!fn.startLine || d.startLine === fn.startLine));
115
+ const raw = index.findCallers(fn.name, {
116
+ includeMethods: true,
117
+ collectAccount: true,
118
+ targetDefinitions: definitions.length > 0 ? definitions : undefined,
119
+ }) || [];
87
120
  callers = raw.filter(c => c.tier !== 'unverified');
88
121
  unverifiedCallers = raw.filter(c => c.tier === 'unverified')
89
122
  .concat(raw.unverifiedEntries || []);
123
+ account = composeAccount(index, fn.name, raw);
124
+ unverifiedCallers = unverifiedCallers.concat(callNotResolvedEntries(index, account));
90
125
  } catch (e) { /* skip */ }
91
126
  }
92
127
 
@@ -98,6 +133,7 @@ function check(index, options = {}) {
98
133
  callerCount: callers.length,
99
134
  unverifiedCallerCount: unverifiedCallers.length,
100
135
  signatureMismatches: mismatches.length,
136
+ account: summarizeAccount(account),
101
137
  ...(mismatches.length > 0 && { mismatches: mismatches.slice(0, 5) }),
102
138
  };
103
139
 
@@ -134,6 +170,13 @@ function check(index, options = {}) {
134
170
  callerCount: 0,
135
171
  unverifiedCallerCount: (d.remainingCallSites || []).length,
136
172
  signatureMismatches: 0,
173
+ account: {
174
+ available: false,
175
+ reason: 'deleted-target-cannot-be-reconciled-against-the-current-index',
176
+ textComplete: false,
177
+ semanticComplete: false,
178
+ safeToDelete: false,
179
+ },
137
180
  });
138
181
  }
139
182
 
@@ -157,6 +200,34 @@ function check(index, options = {}) {
157
200
  // Action items
158
201
  const actions = [];
159
202
  for (const it of items) {
203
+ if (!it.account || !it.account.textComplete) {
204
+ actions.push({
205
+ severity: 'error',
206
+ kind: 'incomplete_account',
207
+ message: `${it.name}: caller evidence is not text-complete; inspect warnings and usages manually`,
208
+ });
209
+ }
210
+ if (it.unverifiedCallerCount > 0) {
211
+ actions.push({
212
+ severity: 'warn',
213
+ kind: 'unverified_callers',
214
+ message: `${it.name}: review ${it.unverifiedCallerCount} unverified caller candidate(s)`,
215
+ });
216
+ }
217
+ if (it.account && it.account.filtered > 0) {
218
+ actions.push({
219
+ severity: 'warn',
220
+ kind: 'filtered_evidence',
221
+ message: `${it.name}: ${it.account.filtered} caller edge(s) were hidden by filters`,
222
+ });
223
+ }
224
+ if (it.account && it.account.requiresUsageReview) {
225
+ actions.push({
226
+ severity: 'warn',
227
+ kind: 'non_call_usages',
228
+ message: `${it.name}: non-call references require a usages review`,
229
+ });
230
+ }
160
231
  if (it.signatureMismatches > 0) {
161
232
  actions.push({
162
233
  severity: 'warn',
@@ -168,10 +239,17 @@ function check(index, options = {}) {
168
239
  actions.push({
169
240
  severity: 'warn',
170
241
  kind: 'orphan_new',
171
- message: `${it.name} is new but has no callers (confirmed or unverified) and is not an entry point`,
242
+ message: `${it.name} is new with no observed caller candidates and is not a detected entry point; review usages before treating it as orphaned`,
172
243
  });
173
244
  }
174
245
  }
246
+ if (limit && allChanged.length > limit) {
247
+ actions.push({
248
+ severity: 'error',
249
+ kind: 'truncated_change_set',
250
+ message: `${allChanged.length - limit} changed function(s) were not checked; rerun without --limit`,
251
+ });
252
+ }
175
253
  if (testFiles.length > 0) {
176
254
  const filesList = testFiles.slice(0, 5).map(t => t.file).join(' ');
177
255
  actions.push({
@@ -181,6 +259,16 @@ function check(index, options = {}) {
181
259
  });
182
260
  }
183
261
 
262
+ const incompleteAccounts = items.filter(it => !it.account || !it.account.textComplete).length;
263
+ const unverifiedCallSites = items.reduce((sum, it) => sum + (it.unverifiedCallerCount || 0), 0);
264
+ const signatureMismatches = items.reduce((sum, it) => sum + (it.signatureMismatches || 0), 0);
265
+ const filteredEdges = items.reduce((sum, it) => sum + (it.account ? it.account.filtered || 0 : 0), 0);
266
+ const usageReviewSymbols = items.filter(it => it.account && it.account.requiresUsageReview).length;
267
+ const reviewRequired = incompleteAccounts > 0 || unverifiedCallSites > 0 ||
268
+ signatureMismatches > 0 || filteredEdges > 0 || usageReviewSymbols > 0 ||
269
+ actions.some(a => a.kind === 'orphan_new') ||
270
+ !!(limit && allChanged.length > limit);
271
+
184
272
  return {
185
273
  base: options.base || 'HEAD',
186
274
  staged: !!options.staged,
@@ -191,21 +279,21 @@ function check(index, options = {}) {
191
279
  totalTestFiles: testFiles.length,
192
280
  totalTests: testCount,
193
281
  actions,
282
+ trust: {
283
+ status: incompleteAccounts > 0 || signatureMismatches > 0
284
+ ? 'BLOCKED'
285
+ : reviewRequired ? 'REVIEW_REQUIRED' : 'READY_FOR_TOOLCHAIN',
286
+ accountsChecked: items.filter(it => it.account && it.account.available).length,
287
+ incompleteAccounts,
288
+ unverifiedCallSites,
289
+ signatureMismatches,
290
+ filteredEdges,
291
+ usageReviewSymbols,
292
+ semanticComplete: false,
293
+ safeToDelete: false,
294
+ requiresCompilerAndTests: true,
295
+ },
194
296
  };
195
297
  }
196
298
 
197
- function symbolKey(file, line) {
198
- return `${file}:${line}`;
199
- }
200
-
201
- function computeReachableSet(index) {
202
- try {
203
- const ep = require('./entrypoints');
204
- if (typeof ep.computeReachability === 'function') {
205
- return ep.computeReachability(index);
206
- }
207
- } catch (e) { /* fall through */ }
208
- return null;
209
- }
210
-
211
299
  module.exports = { check };
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * core/confidence.js - Deterministic edge confidence scoring
3
3
  *
4
- * Assigns a 0.0-1.0 confidence score to each caller/callee edge based on
5
- * how the call was resolved. Rule-based, no ML.
4
+ * Assigns an ordinal 0.0-1.0 evidence weight to each caller/callee edge based
5
+ * on how the call was resolved. Rule-based, no ML. These values rank evidence;
6
+ * they are NOT probabilities and are NOT calibrated accuracy estimates.
6
7
  */
7
8
 
8
9
  'use strict';
@@ -19,7 +20,8 @@ const RESOLUTION = {
19
20
  UNCERTAIN: 'uncertain',
20
21
  };
21
22
 
22
- // Seed scores per resolution type (tunable)
23
+ // Ordinal evidence weights per resolution type. The legacy public field is
24
+ // named `confidence` for compatibility; do not interpret 0.98 as "98% likely".
23
25
  const SCORES = {
24
26
  [RESOLUTION.EXACT_BINDING]: 0.98,
25
27
  [RESOLUTION.SAME_CLASS]: 0.92,
@@ -59,6 +61,17 @@ function tierForResolution(resolution) {
59
61
  return RESOLUTION_TIER[resolution] || TIER.UNVERIFIED;
60
62
  }
61
63
 
64
+ function scored(resolution, reasons) {
65
+ const evidenceScore = SCORES[resolution];
66
+ return {
67
+ confidence: evidenceScore,
68
+ evidenceScore,
69
+ scoreKind: 'ordinal-evidence-not-probability',
70
+ resolution,
71
+ evidence: reasons,
72
+ };
73
+ }
74
+
62
75
  /**
63
76
  * Score a caller/callee edge based on resolution evidence.
64
77
  *
@@ -71,7 +84,7 @@ function tierForResolution(resolution) {
71
84
  * @param {boolean} [evidence.isUncertain] - Marked uncertain by resolution logic
72
85
  * @param {boolean} [evidence.isFunctionReference] - Passed as callback argument
73
86
  * @param {boolean} [evidence.hasReceiverType] - Go/Java/Rust parser-inferred receiverType
74
- * @returns {{ confidence: number, resolution: string, evidence: string[] }}
87
+ * @returns {{ confidence: number, evidenceScore: number, scoreKind: string, resolution: string, evidence: string[] }}
75
88
  */
76
89
  function scoreEdge(evidence) {
77
90
  const reasons = [];
@@ -81,38 +94,38 @@ function scoreEdge(evidence) {
81
94
  // (without this, a known mismatch would score receiver-hint 0.80).
82
95
  if (evidence.typeMismatch) {
83
96
  reasons.push('receiver type mismatch');
84
- return { confidence: SCORES[RESOLUTION.UNCERTAIN], resolution: RESOLUTION.UNCERTAIN, evidence: reasons };
97
+ return scored(RESOLUTION.UNCERTAIN, reasons);
85
98
  }
86
99
 
87
100
  // Nominal dispatch tiering (contract surface only — callers.js sets these
88
101
  // flags exclusively under collectAccount, so legacy paths never see them).
89
102
  if (evidence.possibleDispatch) {
90
103
  reasons.push('interface/supertype dispatch');
91
- return { confidence: SCORES[RESOLUTION.POSSIBLE_DISPATCH], resolution: RESOLUTION.POSSIBLE_DISPATCH, evidence: reasons };
104
+ return scored(RESOLUTION.POSSIBLE_DISPATCH, reasons);
92
105
  }
93
106
  if (evidence.methodAmbiguous) {
94
107
  reasons.push('untyped receiver, multiple same-name definitions');
95
- return { confidence: SCORES[RESOLUTION.METHOD_AMBIGUOUS], resolution: RESOLUTION.METHOD_AMBIGUOUS, evidence: reasons };
108
+ return scored(RESOLUTION.METHOD_AMBIGUOUS, reasons);
96
109
  }
97
110
 
98
111
  // Exact binding match (highest confidence)
99
112
  if (evidence.hasBindingId) {
100
113
  reasons.push('binding-id match');
101
114
  if (evidence.hasImportEvidence) reasons.push('import-verified');
102
- return { confidence: SCORES[RESOLUTION.EXACT_BINDING], resolution: RESOLUTION.EXACT_BINDING, evidence: reasons };
115
+ return scored(RESOLUTION.EXACT_BINDING, reasons);
103
116
  }
104
117
 
105
118
  // Same-class resolution (self/this/super/cls)
106
119
  if (evidence.resolvedBySameClass) {
107
120
  reasons.push('same-class method');
108
121
  if (evidence.hasInheritanceChain) reasons.push('via inheritance');
109
- return { confidence: SCORES[RESOLUTION.SAME_CLASS], resolution: RESOLUTION.SAME_CLASS, evidence: reasons };
122
+ return scored(RESOLUTION.SAME_CLASS, reasons);
110
123
  }
111
124
 
112
125
  // Receiver hint narrowed to specific type
113
126
  if (evidence.resolvedByReceiverHint || evidence.hasReceiverType) {
114
127
  reasons.push(evidence.hasReceiverType ? 'parser receiver-type' : 'local type inference');
115
- return { confidence: SCORES[RESOLUTION.RECEIVER_HINT], resolution: RESOLUTION.RECEIVER_HINT, evidence: reasons };
128
+ return scored(RESOLUTION.RECEIVER_HINT, reasons);
116
129
  }
117
130
 
118
131
  // Function reference (callback / passed-as-argument). Argument position is
@@ -123,10 +136,10 @@ function scoreEdge(evidence) {
123
136
  reasons.push('function reference');
124
137
  if (evidence.hasImportEvidence || evidence.hasSamePackageEvidence) {
125
138
  reasons.push(evidence.hasImportEvidence ? 'import-supported' : 'same package/module');
126
- return { confidence: SCORES[RESOLUTION.SCOPE_MATCH], resolution: RESOLUTION.SCOPE_MATCH, evidence: reasons };
139
+ return scored(RESOLUTION.SCOPE_MATCH, reasons);
127
140
  }
128
141
  reasons.push('no import evidence');
129
- return { confidence: SCORES[RESOLUTION.NAME_ONLY], resolution: RESOLUTION.NAME_ONLY, evidence: reasons };
142
+ return scored(RESOLUTION.NAME_ONLY, reasons);
130
143
  }
131
144
 
132
145
  // Scope/import-supported match
@@ -134,22 +147,22 @@ function scoreEdge(evidence) {
134
147
  if (evidence.hasImportEvidence) reasons.push('import-supported');
135
148
  if (evidence.hasReceiverEvidence) reasons.push('receiver binding in scope');
136
149
  if (evidence.hasSamePackageEvidence) reasons.push('same package/module');
137
- return { confidence: SCORES[RESOLUTION.SCOPE_MATCH], resolution: RESOLUTION.SCOPE_MATCH, evidence: reasons };
150
+ return scored(RESOLUTION.SCOPE_MATCH, reasons);
138
151
  }
139
152
 
140
153
  // Uncertain
141
154
  if (evidence.isUncertain) {
142
155
  reasons.push('ambiguous resolution');
143
- return { confidence: SCORES[RESOLUTION.UNCERTAIN], resolution: RESOLUTION.UNCERTAIN, evidence: reasons };
156
+ return scored(RESOLUTION.UNCERTAIN, reasons);
144
157
  }
145
158
 
146
159
  // Name-only match (no additional evidence)
147
160
  reasons.push('name match only');
148
- return { confidence: SCORES[RESOLUTION.NAME_ONLY], resolution: RESOLUTION.NAME_ONLY, evidence: reasons };
161
+ return scored(RESOLUTION.NAME_ONLY, reasons);
149
162
  }
150
163
 
151
164
  /**
152
- * Filter edges by minimum confidence threshold.
165
+ * Filter edges by minimum ordinal evidence threshold (legacy name retained).
153
166
  * @param {Array} edges - Array of objects with .confidence property
154
167
  * @param {number} minConfidence - Minimum confidence (0.0-1.0)
155
168
  * @returns {{ kept: Array, filtered: number }}
package/core/deadcode.js CHANGED
@@ -208,8 +208,7 @@ function nameOnlySelfRecursive(index, name) {
208
208
  }
209
209
  const { getCachedCalls } = require('./callers');
210
210
  for (const f of files) {
211
- let calls;
212
- try { calls = getCachedCalls(index, f); } catch { return false; }
211
+ const calls = getCachedCalls(index, f);
213
212
  if (!calls) return false;
214
213
  for (const call of calls) {
215
214
  if (call.name !== name && call.resolvedName !== name &&
@@ -943,29 +943,27 @@ function detectEntrypoints(index, options = {}) {
943
943
  // (3) Top-level invocation targets: any non-method call with no
944
944
  // enclosingFunction is module-load-time, so its callee is reachable.
945
945
  // We treat the callee as an entry point (the function being kicked off).
946
- try {
947
- const calls = getCachedCalls(index, filePath);
948
- if (calls && calls.length > 0) {
949
- for (const c of calls) {
950
- if (c.enclosingFunction != null) continue;
951
- if (c.isMethod) continue;
952
- // Resolve callee names (handles aliased imports)
953
- const names = c.resolvedNames || (c.resolvedName ? [c.resolvedName] : [c.name]);
954
- for (const n of names) {
955
- if (index.symbols.has(n)) allowed.add(n);
956
- }
946
+ const calls = getCachedCalls(index, filePath);
947
+ if (calls && calls.length > 0) {
948
+ for (const c of calls) {
949
+ if (c.enclosingFunction != null) continue;
950
+ if (c.isMethod) continue;
951
+ // Resolve callee names (handles aliased imports)
952
+ const names = c.resolvedNames || (c.resolvedName ? [c.resolvedName] : [c.name]);
953
+ for (const n of names) {
954
+ if (index.symbols.has(n)) allowed.add(n);
957
955
  }
958
-
959
- // (4) Function containing a top-level `if (require.main === module) { ... }`
960
- // wrapping calls — captured by treating any non-method call whose
961
- // enclosingFunction body is at top level. The simplest detection:
962
- // scan calls inside any function whose body contains the require.main
963
- // guard. We approximate via the same `enclosingFunction` data: if a
964
- // function contains top-level invocation-style entries, it's typically
965
- // identified by the (3) check above. Skip explicit (4) detection here —
966
- // (3) already covers `if (require.main === module) { main(); }`.
967
956
  }
968
- } catch (_e) { /* best-effort */ }
957
+
958
+ // (4) Function containing a top-level `if (require.main === module) { ... }`
959
+ // wrapping calls — captured by treating any non-method call whose
960
+ // enclosingFunction body is at top level. The simplest detection:
961
+ // scan calls inside any function whose body contains the require.main
962
+ // guard. We approximate via the same `enclosingFunction` data: if a
963
+ // function contains top-level invocation-style entries, it's typically
964
+ // identified by the (3) check above. Skip explicit (4) detection here —
965
+ // (3) already covers `if (require.main === module) { main(); }`.
966
+ }
969
967
 
970
968
  // If we identified at least one specific entry, use that set.
971
969
  // Otherwise fall back to permissive (null) so a CLI file with neither
@@ -1143,6 +1141,18 @@ function computeReachability(index) {
1143
1141
  const langModuleCache = new Map();
1144
1142
  for (const [, symbols] of index.symbols) {
1145
1143
  for (const symbol of symbols) {
1144
+ // JS/TS module-scope object registries are invoked through dynamic
1145
+ // property access (HANDLERS[command](...)). There may be no static
1146
+ // incoming edge to discover. Treat these members as conservative
1147
+ // roots so deadcode never presents a dynamically reachable handler
1148
+ // as safe to remove; callees then flow through the normal BFS.
1149
+ if (symbol.registryMember) {
1150
+ const registryKey = symbolKey(symbol.file, symbol.startLine);
1151
+ if (!reachable.has(registryKey)) {
1152
+ reachable.add(registryKey);
1153
+ queue.push(symbol);
1154
+ }
1155
+ }
1146
1156
  const fileEntry = index.files.get(symbol.file);
1147
1157
  if (!fileEntry) continue;
1148
1158
  const lang = fileEntry.language;
@@ -1183,12 +1193,7 @@ function computeReachability(index) {
1183
1193
  for (const [filePath, fileEntry] of index.files) {
1184
1194
  const lang = fileEntry.language;
1185
1195
  if (lang !== 'javascript' && lang !== 'typescript' && lang !== 'tsx') continue;
1186
- let calls;
1187
- try {
1188
- calls = getCachedCalls(index, filePath);
1189
- } catch (_e) {
1190
- continue;
1191
- }
1196
+ const calls = getCachedCalls(index, filePath);
1192
1197
  if (!calls || calls.length === 0) continue;
1193
1198
  for (const call of calls) {
1194
1199
  // Top-level call: AST recorded enclosingFunction === null/undefined.
package/core/execute.js CHANGED
@@ -241,7 +241,6 @@ function checkFilePatternMatch(index, filePattern) {
241
241
  if (fileEntry.relativePath.includes(filePattern)) return null;
242
242
  }
243
243
  // Suggest similar directories/files to help user refine
244
- const patternLower = filePattern.toLowerCase();
245
244
  const basename = filePattern.split('/').pop().toLowerCase();
246
245
  const suggestions = new Set();
247
246
  for (const [, fileEntry] of index.files) {
@@ -493,6 +492,7 @@ const HANDLERS = {
493
492
  example: (index, p) => {
494
493
  const err = requireName(p.name);
495
494
  if (err) return { ok: false, error: err };
495
+ const targetWasHandle = looksLikeHandle(p.name);
496
496
  applyClassMethodSyntax(p);
497
497
  const fileErr = checkFilePatternMatch(index, p.file);
498
498
  if (fileErr) return { ok: false, error: fileErr };
@@ -501,6 +501,12 @@ const HANDLERS = {
501
501
  const result = index.example(p.name, {
502
502
  file: p.file,
503
503
  className: p.className,
504
+ line: p.line,
505
+ // Preserve legacy plain-name `--file` as a call-site scope while
506
+ // letting a stable handle use its file component purely as exact
507
+ // target identity. Without this distinction, handles could never
508
+ // find examples outside the definition file.
509
+ callSiteFile: !targetWasHandle ? p.file : null,
504
510
  diverse: !!p.diverse,
505
511
  top: num(p.top, undefined),
506
512
  // MEDIUM-8: thread includeTests so test-file callers are included
@@ -583,7 +589,6 @@ const HANDLERS = {
583
589
  in: p.in,
584
590
  file: p.file,
585
591
  deep: !!p.deep,
586
- sampleSize: num(p.limit, undefined),
587
592
  });
588
593
  return { ok: true, result };
589
594
  },
@@ -832,10 +837,21 @@ const HANDLERS = {
832
837
  // splitting would shear the filename at the dot into
833
838
  // className='helper', name='go' and silently return nothing
834
839
  // (fix #239, G3-go-measured). Mirror the engine's file-path test.
835
- const testsTargetIsFile = typeof p.name === 'string' && (
840
+ const testsTargetIsHandle = looksLikeHandle(p.name);
841
+ const testsTargetIsFile = !testsTargetIsHandle && typeof p.name === 'string' && (
836
842
  p.name.includes('/') || p.name.includes('\\') ||
837
843
  /\.(js|ts|py|go|java|rs)$/.test(p.name));
838
844
  if (!testsTargetIsFile) applyClassMethodSyntax(p);
845
+ if (testsTargetIsHandle && p.line) {
846
+ const line = Number(p.line);
847
+ const pinned = (index.symbols.get(p.name) || []).filter(d =>
848
+ d.startLine === line && (!p.file || d.relativePath?.includes(p.file)));
849
+ if (pinned.length === 1 && !p.className && pinned[0].className) {
850
+ p.className = pinned[0].className;
851
+ }
852
+ const pinErr = checkDefinitionPin(index, p);
853
+ if (pinErr) return { ok: false, error: pinErr };
854
+ }
839
855
  if (p.file) {
840
856
  const fileErr = checkFilePatternMatch(index, p.file);
841
857
  if (fileErr) return { ok: false, error: fileErr };
@@ -260,6 +260,39 @@ function buildInheritanceGraph(index) {
260
260
  const alias = fileEntry.importAliases.find(a => a.local === parent);
261
261
  if (alias && classNames.has(alias.original)) return alias.original;
262
262
  }
263
+ // Qualified structural parent: `class CustomCommand(
264
+ // click.Command)`. The symbol table owns bare class names,
265
+ // so keeping `click.Command` makes the subclass invisible
266
+ // to dispatch/reachability. Normalize only when the
267
+ // qualifier is an actual project module import and its
268
+ // bounded import graph reaches exactly one matching class
269
+ // definition—never by suffix alone.
270
+ if (langTraits(fileEntry.language)?.typeSystem === 'structural' && parent.includes('.')) {
271
+ const pieces = parent.split('.');
272
+ const localModule = pieces[0];
273
+ const className = pieces[pieces.length - 1];
274
+ const binding = (fileEntry.importBindings || []).find(b => b.name === localModule);
275
+ const rel = binding && fileEntry.moduleResolved?.[binding.module];
276
+ if (rel && classNames.has(className)) {
277
+ const moduleFile = path.join(index.root, rel);
278
+ const candidateFiles = new Set((index.symbols.get(className) || [])
279
+ .filter(d => ['class', 'interface', 'struct', 'trait', 'record'].includes(d.type))
280
+ .map(d => d.file).filter(Boolean));
281
+ const reached = new Set();
282
+ const queue = [{ file: moduleFile, depth: 0 }];
283
+ const seen = new Set();
284
+ while (queue.length > 0) {
285
+ const cur = queue.shift();
286
+ if (!cur.file || seen.has(cur.file) || cur.depth > 4) continue;
287
+ seen.add(cur.file);
288
+ if (candidateFiles.has(cur.file)) reached.add(cur.file);
289
+ for (const next of index.importGraph.get(cur.file) || []) {
290
+ queue.push({ file: next, depth: cur.depth + 1 });
291
+ }
292
+ }
293
+ if (reached.size === 1) return className;
294
+ }
295
+ }
263
296
  return parent;
264
297
  });
265
298