ucn 3.8.26 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/ucn/SKILL.md +31 -17
- package/README.md +95 -28
- package/cli/index.js +32 -7
- package/core/account.js +354 -0
- package/core/analysis.js +335 -15
- package/core/build-worker.js +21 -1
- package/core/cache.js +52 -3
- package/core/callers.js +3421 -159
- package/core/confidence.js +82 -19
- package/core/deadcode.js +211 -21
- package/core/execute.js +6 -1
- package/core/graph-build.js +45 -3
- package/core/imports.js +118 -1
- package/core/output/analysis.js +345 -83
- package/core/output/reporting.js +19 -3
- package/core/output/shared.js +33 -2
- package/core/output/tracing.js +208 -10
- package/core/project.js +19 -2
- package/core/registry.js +15 -3
- package/core/shared.js +21 -0
- package/core/tracing.js +534 -190
- package/languages/go.js +317 -6
- package/languages/index.js +79 -0
- package/languages/java.js +243 -16
- package/languages/javascript.js +357 -24
- package/languages/python.js +423 -28
- package/languages/rust.js +377 -8
- package/languages/utils.js +72 -18
- package/mcp/server.js +5 -4
- package/package.json +9 -3
- package/.github/workflows/ci.yml +0 -45
- package/.github/workflows/publish.yml +0 -79
package/core/callers.js
CHANGED
|
@@ -10,8 +10,9 @@ const path = require('path');
|
|
|
10
10
|
const crypto = require('crypto');
|
|
11
11
|
const { detectLanguage, getParser, getLanguageModule, langTraits } = require('../languages');
|
|
12
12
|
const { isTestFile } = require('./discovery');
|
|
13
|
-
const { NON_CALLABLE_TYPES } = require('./shared');
|
|
14
|
-
const { scoreEdge } = require('./confidence');
|
|
13
|
+
const { NON_CALLABLE_TYPES, isOverrideMarked } = require('./shared');
|
|
14
|
+
const { scoreEdge, tierForResolution, TIER } = require('./confidence');
|
|
15
|
+
const { findGoModule } = require('./imports');
|
|
15
16
|
|
|
16
17
|
/** Set.some() helper — like Array.some() but for Sets */
|
|
17
18
|
function setSome(set, predicate) {
|
|
@@ -148,6 +149,22 @@ function findCallers(index, name, options = {}) {
|
|
|
148
149
|
const callers = [];
|
|
149
150
|
const stats = options.stats;
|
|
150
151
|
|
|
152
|
+
// Conservation accounting (tiered caller contract): when collectAccount
|
|
153
|
+
// is set (context/about/impact only — trace/blast/verify paths must stay
|
|
154
|
+
// byte-identical), candidates that the legacy flags would silently drop are
|
|
155
|
+
// RETAINED as unverified-tier entries (rendered in their own output
|
|
156
|
+
// section), and candidates positively excluded (call targets a different
|
|
157
|
+
// symbol) are recorded with a reason for the account arithmetic.
|
|
158
|
+
const collectAccount = !!options.collectAccount;
|
|
159
|
+
const accountRaw = collectAccount ? { unverifiedLines: [], excludedEntries: [] } : null;
|
|
160
|
+
// Cap on how many unverified entries get full enrichment (content + caller
|
|
161
|
+
// lookup); the rest stay as shadow-style records. Display caps are handled
|
|
162
|
+
// by formatters — this only bounds file reads.
|
|
163
|
+
const unverifiedEnrichLimit = options.unverifiedEnrichLimit ?? 10;
|
|
164
|
+
const recordExcluded = (filePath, line, reason) => {
|
|
165
|
+
if (accountRaw) accountRaw.excludedEntries.push({ file: filePath, line, reason });
|
|
166
|
+
};
|
|
167
|
+
|
|
151
168
|
// Get definition lines to exclude them
|
|
152
169
|
const definitions = index.symbols.get(name) || [];
|
|
153
170
|
const definitionLines = new Set();
|
|
@@ -155,10 +172,183 @@ function findCallers(index, name, options = {}) {
|
|
|
155
172
|
definitionLines.add(`${def.file}:${def.startLine}`);
|
|
156
173
|
}
|
|
157
174
|
|
|
175
|
+
// Possible-dispatch tiering inputs (nominal contract surface) — all fixed
|
|
176
|
+
// per query, computed lazily once. targetTypes mirrors the receiver-class
|
|
177
|
+
// disambiguation set (target classes + non-overriding subtypes); owner
|
|
178
|
+
// keys are the distinct types defining a same-name method project-wide.
|
|
179
|
+
let _dispatchTargetTypes = null;
|
|
180
|
+
const dispatchTargetTypes = (targetDefs) => {
|
|
181
|
+
if (!_dispatchTargetTypes) _dispatchTargetTypes = _buildTargetTypeSet(index, targetDefs, definitions);
|
|
182
|
+
return _dispatchTargetTypes;
|
|
183
|
+
};
|
|
184
|
+
let _methodOwnerKeys = null;
|
|
185
|
+
const methodOwnerKeys = () => {
|
|
186
|
+
if (!_methodOwnerKeys) {
|
|
187
|
+
_methodOwnerKeys = new Set();
|
|
188
|
+
for (const d of definitions) {
|
|
189
|
+
if (NON_CALLABLE_TYPES.has(d.type)) {
|
|
190
|
+
// Function-typed FIELDS are callable owners (fix #219):
|
|
191
|
+
// `effect.transform(...)` may be ZodType.transform OR the
|
|
192
|
+
// $ZodTransformDef.transform property — single-method-owner
|
|
193
|
+
// confirmation is a lie when an interface declares the same
|
|
194
|
+
// name as a callable property. Structural only: Java/Rust
|
|
195
|
+
// cannot call a field by name (obj.f() is always a method
|
|
196
|
+
// there); Go CAN (func fields) but no measured Go board
|
|
197
|
+
// carries the family — deferred until measured.
|
|
198
|
+
if (d.type === 'field' && d.className && _callableFieldDef(index, d)) {
|
|
199
|
+
_methodOwnerKeys.add(d.className);
|
|
200
|
+
}
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const o = d.className || (d.receiver && d.receiver.replace(/^\*/, ''));
|
|
204
|
+
if (o) _methodOwnerKeys.add(o);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return _methodOwnerKeys;
|
|
208
|
+
};
|
|
209
|
+
const _dispatchCountCache = new Map(); // via type -> candidate count
|
|
210
|
+
const countDispatchCandidates = (via) => {
|
|
211
|
+
if (!_dispatchCountCache.has(via)) {
|
|
212
|
+
_dispatchCountCache.set(via, _countDispatchCandidates(index, via, definitions));
|
|
213
|
+
}
|
|
214
|
+
return _dispatchCountCache.get(via);
|
|
215
|
+
};
|
|
216
|
+
// External-contract target (fix #210, gson-measured): the pinned method
|
|
217
|
+
// carries an explicit override marker (@Override / TS `override` / typing
|
|
218
|
+
// @override / Rust `impl Trait for X`) and has a SINGLE project-wide
|
|
219
|
+
// owner — so the overridden definition is not in the project (a visible
|
|
220
|
+
// project supertype defining the method would be a second owner). The
|
|
221
|
+
// method name provably exists on an external contract (java.lang.Number,
|
|
222
|
+
// std Iterator, ...): any external-typed receiver satisfies the same
|
|
223
|
+
// call, and unique project ownership stops being identity evidence.
|
|
224
|
+
// Receiver-evidence-free calls route possible-dispatch instead of
|
|
225
|
+
// confirming. Lazily computed once per query; null = not external.
|
|
226
|
+
let _extContract; // undefined → not yet computed
|
|
227
|
+
const externalContractTarget = () => {
|
|
228
|
+
if (_extContract !== undefined) return _extContract;
|
|
229
|
+
_extContract = null;
|
|
230
|
+
if (methodOwnerKeys().size === 1) {
|
|
231
|
+
const tDefs = (options.targetDefinitions || definitions).filter(d =>
|
|
232
|
+
!NON_CALLABLE_TYPES.has(d.type) && (d.className || d.receiver));
|
|
233
|
+
// `some`, not `every`: one marked overload proves the NAME exists
|
|
234
|
+
// on an external contract — receiver identity is then unprovable
|
|
235
|
+
// for every call shape (external signatures are invisible).
|
|
236
|
+
const marked = tDefs.find(d => isOverrideMarked(d));
|
|
237
|
+
if (marked) _extContract = { via: _externalContractVia(index, marked) };
|
|
238
|
+
}
|
|
239
|
+
return _extContract;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// ---- Rename-alias surfaces (import/export renames) ----
|
|
243
|
+
// Other surface names that can denote this symbol:
|
|
244
|
+
// import-side: `import { _gt as gt }` — fileEntry.importAliases, valid
|
|
245
|
+
// only inside the renaming file;
|
|
246
|
+
// export-side: `export { _enum as enum }` / Rust `pub use a as b` —
|
|
247
|
+
// exportDetails entries carrying an alias, valid in files importing the
|
|
248
|
+
// renaming module (or in that module itself).
|
|
249
|
+
// A renaming file must sit on an import path to the target (or define it)
|
|
250
|
+
// — otherwise it renames an unrelated same-name symbol. Matched call
|
|
251
|
+
// sites are beyond-text claims: the line does not contain the target name
|
|
252
|
+
// (account.js classifies them in the beyondText bucket).
|
|
253
|
+
const aliasTargetFiles = new Set((options.targetDefinitions || definitions)
|
|
254
|
+
.map(d => d.file).filter(Boolean));
|
|
255
|
+
const importAliasLocals = new Map(); // filePath -> Set<localName>
|
|
256
|
+
const exportAliasRenamers = new Map(); // aliasName -> Set<renamingFilePath>
|
|
257
|
+
for (const [fp, fe] of index.files) {
|
|
258
|
+
const hasImportRenames = fe.importAliases &&
|
|
259
|
+
fe.importAliases.some(a => a && a.original === name && a.local && a.local !== name);
|
|
260
|
+
const exportRenames = fe.exportDetails
|
|
261
|
+
? fe.exportDetails.filter(e => e && e.alias && e.name === name && e.alias !== name)
|
|
262
|
+
: [];
|
|
263
|
+
if (!hasImportRenames && exportRenames.length === 0) continue;
|
|
264
|
+
const fpImports = index.importGraph.get(fp);
|
|
265
|
+
let linksTarget = aliasTargetFiles.has(fp) ||
|
|
266
|
+
(fpImports && setSome(fpImports, imp => aliasTargetFiles.has(imp)));
|
|
267
|
+
// One barrel hop: `export { _gt as gt } from './core/index.js'` where
|
|
268
|
+
// the barrel re-exports the defining file.
|
|
269
|
+
if (!linksTarget && fpImports) {
|
|
270
|
+
for (const imp of fpImports) {
|
|
271
|
+
const trans = index.importGraph.get(imp);
|
|
272
|
+
if (trans && setSome(trans, ti => aliasTargetFiles.has(ti))) {
|
|
273
|
+
linksTarget = true;
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (!linksTarget) continue;
|
|
279
|
+
if (hasImportRenames) {
|
|
280
|
+
for (const a of fe.importAliases) {
|
|
281
|
+
if (a && a.original === name && a.local && a.local !== name) {
|
|
282
|
+
if (!importAliasLocals.has(fp)) importAliasLocals.set(fp, new Set());
|
|
283
|
+
importAliasLocals.get(fp).add(a.local);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
for (const e of exportRenames) {
|
|
288
|
+
// If the renaming file defines the name itself, the rename refers
|
|
289
|
+
// to that local definition (classic/schemas.ts's `export { _enum
|
|
290
|
+
// as enum }` renames ITS _enum wrapper, not core's _enum) — only
|
|
291
|
+
// credit it when the pinned target IS that local definition.
|
|
292
|
+
if (!aliasTargetFiles.has(fp) && definitions.some(d => d.file === fp)) continue;
|
|
293
|
+
if (!exportAliasRenamers.has(e.alias)) exportAliasRenamers.set(e.alias, new Set());
|
|
294
|
+
exportAliasRenamers.get(e.alias).add(fp);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// Files that can reach each renaming module through imports — re-export
|
|
298
|
+
// chains run deep (test → mini/index → external → schemas), so a fixed
|
|
299
|
+
// hop count misses real surfaces. Bounded reverse-import BFS; matching
|
|
300
|
+
// stays name+path specific, and tiering still demands its own evidence.
|
|
301
|
+
const aliasReachers = new Map(); // aliasName -> Set<filePath> (renamers + transitive importers)
|
|
302
|
+
for (const [aliasName, renamers] of exportAliasRenamers) {
|
|
303
|
+
const reach = new Set(renamers);
|
|
304
|
+
let frontier = [...renamers];
|
|
305
|
+
for (let depth = 0; depth < 4 && frontier.length && reach.size <= 5000; depth++) {
|
|
306
|
+
const next = [];
|
|
307
|
+
for (const f of frontier) {
|
|
308
|
+
const importers = index.exportGraph.get(f);
|
|
309
|
+
if (!importers) continue;
|
|
310
|
+
for (const importer of importers) {
|
|
311
|
+
if (!reach.has(importer)) {
|
|
312
|
+
reach.add(importer);
|
|
313
|
+
next.push(importer);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
frontier = next;
|
|
318
|
+
}
|
|
319
|
+
aliasReachers.set(aliasName, reach);
|
|
320
|
+
}
|
|
321
|
+
const aliasNames = new Set(exportAliasRenamers.keys());
|
|
322
|
+
for (const locals of importAliasLocals.values()) {
|
|
323
|
+
for (const local of locals) aliasNames.add(local);
|
|
324
|
+
}
|
|
325
|
+
const hasAliasSurfaces = aliasNames.size > 0;
|
|
326
|
+
|
|
158
327
|
// Phase 1: Find matching calls without reading file content.
|
|
159
328
|
// Collect pending callers keyed by file — content is read only in Phase 2.
|
|
160
329
|
const pendingByFile = new Map(); // filePath -> [{ call, fileEntry, callerSymbol, isMethod, isFunctionReference, receiver }]
|
|
161
330
|
let pendingCount = 0;
|
|
331
|
+
// Route a would-be-dropped candidate into the pending pipeline as an
|
|
332
|
+
// unverified-tier entry (tiered caller contract: shown in its own
|
|
333
|
+
// section, never silently hidden). Does NOT count toward pendingCount —
|
|
334
|
+
// totals describe the confirmed answer.
|
|
335
|
+
const routeUnverified = (filePath, fileEntry, call, reason, calledAs, meta) => {
|
|
336
|
+
if (!collectAccount) return; // non-account paths (trace/blast/verify) keep the plain drop
|
|
337
|
+
if (!pendingByFile.has(filePath)) pendingByFile.set(filePath, []);
|
|
338
|
+
pendingByFile.get(filePath).push({
|
|
339
|
+
call, fileEntry, callerSymbol: null,
|
|
340
|
+
isMethod: call.isMethod || false,
|
|
341
|
+
isFunctionReference: !!call.isFunctionReference,
|
|
342
|
+
receiver: call.receiver, receiverType: call.receiverType,
|
|
343
|
+
calledAs,
|
|
344
|
+
_tier: TIER.UNVERIFIED, _reason: reason, _meta: meta,
|
|
345
|
+
// Dispatch-tiered routes carry their own resolution so JSON output
|
|
346
|
+
// distinguishes "possible virtual dispatch" from a bare uncertain.
|
|
347
|
+
_evidence: reason === 'possible-dispatch' ? { possibleDispatch: true }
|
|
348
|
+
: reason === 'method-ambiguous' ? { methodAmbiguous: true }
|
|
349
|
+
: { isUncertain: true },
|
|
350
|
+
});
|
|
351
|
+
};
|
|
162
352
|
const maxResults = options.maxResults;
|
|
163
353
|
// BUG-H1: when consumers (like `about`) need an accurate truncation header
|
|
164
354
|
// ("showing N of <total>"), they pass needsTotal:true so Phase 1 runs to
|
|
@@ -166,9 +356,19 @@ function findCallers(index, name, options = {}) {
|
|
|
166
356
|
// file reads stay bounded, but the candidate count reflects the true total.
|
|
167
357
|
const needsTotal = !!options.needsTotal;
|
|
168
358
|
const localTypeCache = new Map(); // `${filePath}:${startLine}` -> localTypes Map or null
|
|
359
|
+
const returnFlowCache = new Map(); // filePath -> return-type-flow map (see _buildReturnTypeFlowMap)
|
|
169
360
|
|
|
170
361
|
// Use inverted callee index to skip files that don't contain calls to this name
|
|
171
|
-
|
|
362
|
+
let calleeFiles = index.getCalleeFiles(name);
|
|
363
|
+
if (hasAliasSurfaces) {
|
|
364
|
+
// Alias surfaces are indexed under their own names — union their files in.
|
|
365
|
+
const union = new Set(calleeFiles || []);
|
|
366
|
+
for (const aliasName of aliasNames) {
|
|
367
|
+
const aliasFiles = index.getCalleeFiles(aliasName);
|
|
368
|
+
if (aliasFiles) for (const f of aliasFiles) union.add(f);
|
|
369
|
+
}
|
|
370
|
+
if (union.size > 0) calleeFiles = union;
|
|
371
|
+
}
|
|
172
372
|
const fileIterator = calleeFiles
|
|
173
373
|
? [...calleeFiles].map(fp => [fp, index.files.get(fp)]).filter(([, fe]) => fe)
|
|
174
374
|
: index.files;
|
|
@@ -180,50 +380,303 @@ function findCallers(index, name, options = {}) {
|
|
|
180
380
|
const calls = getCachedCalls(index, filePath);
|
|
181
381
|
if (!calls) continue;
|
|
182
382
|
|
|
183
|
-
for (
|
|
383
|
+
for (let call of calls) {
|
|
184
384
|
// Skip if not matching our target name (also check alias resolution)
|
|
385
|
+
let calledAs = null; // surface name when matched via an import/export rename
|
|
185
386
|
if (call.name !== name && call.resolvedName !== name &&
|
|
186
|
-
!(call.resolvedNames && call.resolvedNames.includes(name)))
|
|
387
|
+
!(call.resolvedNames && call.resolvedNames.includes(name))) {
|
|
388
|
+
if (!hasAliasSurfaces) continue;
|
|
389
|
+
const locals = importAliasLocals.get(filePath);
|
|
390
|
+
if (locals && locals.has(call.name)) {
|
|
391
|
+
calledAs = call.name;
|
|
392
|
+
} else {
|
|
393
|
+
const reach = aliasReachers.get(call.name);
|
|
394
|
+
if (reach && reach.has(filePath)) calledAs = call.name;
|
|
395
|
+
}
|
|
396
|
+
if (!calledAs) continue;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Return-type flow: an untyped method receiver may be a
|
|
400
|
+
// variable assigned from a call with a known return annotation
|
|
401
|
+
// (response = client.get(...) with Client.get() -> Response).
|
|
402
|
+
// Structural languages get this everywhere (fix #199). Nominal
|
|
403
|
+
// languages get it on the account surface only (fix #207):
|
|
404
|
+
// a flow-typed interface receiver must reroute to visible
|
|
405
|
+
// possible-dispatch on mismatch, and that routing is
|
|
406
|
+
// collectAccount-gated — legacy commands would silently drop
|
|
407
|
+
// the edge instead. Real method calls only — the callback/
|
|
408
|
+
// reference branch keeps its own #206 routing.
|
|
409
|
+
// Copy-on-enrich: cached call objects stay parser-pure — the flow
|
|
410
|
+
// type derives from OTHER files' annotations, so it must never be
|
|
411
|
+
// persisted with this file's calls.
|
|
412
|
+
if (call.isMethod && call.receiver && !call.receiverType &&
|
|
413
|
+
(langTraits(fileEntry.language)?.typeSystem === 'structural' ||
|
|
414
|
+
(collectAccount && !call.isPotentialCallback && !call.isPathCall &&
|
|
415
|
+
langTraits(fileEntry.language)?.typeSystem === 'nominal'))) {
|
|
416
|
+
let flowMap = returnFlowCache.get(filePath);
|
|
417
|
+
if (flowMap === undefined) {
|
|
418
|
+
flowMap = _buildReturnTypeFlowMap(index, filePath, calls);
|
|
419
|
+
returnFlowCache.set(filePath, flowMap);
|
|
420
|
+
}
|
|
421
|
+
const flowEntry = flowMap && _lookupReturnTypeFlow(flowMap, call);
|
|
422
|
+
if (flowEntry && flowEntry.externalVia) {
|
|
423
|
+
// External producer (fix #220) — typed outside the
|
|
424
|
+
// project; blocks single-owner confirmation, routes
|
|
425
|
+
// possible-dispatch in the gate. Nominal-only entries.
|
|
426
|
+
call = { ...call, receiverExternalFlow: flowEntry.externalVia };
|
|
427
|
+
} else if (flowEntry) {
|
|
428
|
+
call = { ...call, receiverType: flowEntry.type,
|
|
429
|
+
...(flowEntry.fromFile && { receiverTypeFlowFile: flowEntry.fromFile }) };
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Chained-receiver typing (fix #219): the receiver IS a call —
|
|
434
|
+
// `me._def.args.parseAsync(args, params).catch(...)` — so the
|
|
435
|
+
// producer's DECLARED return annotation types it (Promise →
|
|
436
|
+
// builtin → exclusion-grade under the trust gate; the target's
|
|
437
|
+
// own class validates; anything else attributes dispatch).
|
|
438
|
+
// Method producers must AGREE project-wide (the #207
|
|
439
|
+
// discipline); plain producers follow #199's unique-def rule.
|
|
440
|
+
// Structural only: nominal parsers don't capture receiverCall —
|
|
441
|
+
// their chained calls stay under the #204 dispatch tiering
|
|
442
|
+
// (visible, honest) until a measured family justifies the
|
|
443
|
+
// #207 origin-pinning rails there.
|
|
444
|
+
if (call.isMethod && !call.receiver && !call.receiverType && call.receiverCall &&
|
|
445
|
+
langTraits(fileEntry.language)?.typeSystem === 'structural') {
|
|
446
|
+
const chainedType = _chainedReceiverType(index, call, fileEntry.language);
|
|
447
|
+
if (chainedType) call = { ...call, receiverType: chainedType };
|
|
448
|
+
} else if (call.isMethod && !call.receiver && !call.receiverType && call.receiverCall &&
|
|
449
|
+
collectAccount && !call.isPotentialCallback &&
|
|
450
|
+
langTraits(fileEntry.language)?.typeSystem === 'nominal') {
|
|
451
|
+
// Nominal chained receivers (fix #220, cobra-measured):
|
|
452
|
+
// account-gated like the #207 nominal flow — mismatch
|
|
453
|
+
// reroutes are account-only; legacy would silently drop.
|
|
454
|
+
const flowEntry = _nominalChainedReceiverType(index, call, fileEntry, filePath);
|
|
455
|
+
if (flowEntry && flowEntry.externalVia) {
|
|
456
|
+
call = { ...call, receiverExternalFlow: flowEntry.externalVia };
|
|
457
|
+
} else if (flowEntry) {
|
|
458
|
+
call = { ...call, receiverType: flowEntry.type,
|
|
459
|
+
...(flowEntry.fromFile && { receiverTypeFlowFile: flowEntry.fromFile }) };
|
|
460
|
+
}
|
|
461
|
+
}
|
|
187
462
|
|
|
188
463
|
// For potential callbacks (function passed as arg), validate against symbol table
|
|
189
464
|
// and skip complex binding resolution — just check the name exists
|
|
190
465
|
if (call.isPotentialCallback) {
|
|
466
|
+
// Go closure-entry marker: a composite-field func literal
|
|
467
|
+
// records the ENCLOSING function's name at the closure line
|
|
468
|
+
// (deadcode reachability for RunE-style closures) — it is a
|
|
469
|
+
// self-line artifact, never a caller edge.
|
|
470
|
+
if (!call.isFunctionReference && !call.isMethod && call.fieldName &&
|
|
471
|
+
call.enclosingFunction && call.enclosingFunction.name === call.name) {
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
191
474
|
const syms = definitions;
|
|
192
475
|
if (!syms || syms.length === 0) continue;
|
|
476
|
+
const cbTargetDefs = options.targetDefinitions || definitions;
|
|
193
477
|
|
|
194
478
|
// Go unexported visibility: lowercase functions are package-private.
|
|
195
|
-
// Only allow callers from the same package directory.
|
|
479
|
+
// Only allow callers from the same package directory. Recorded
|
|
480
|
+
// with reason (not a silent drop) — same disposition as the
|
|
481
|
+
// plain-call visibility gate below.
|
|
196
482
|
if (langTraits(fileEntry.language)?.exportVisibility === 'capitalization' && /^[a-z]/.test(name)) {
|
|
197
|
-
const targetDefs = options.targetDefinitions || definitions;
|
|
198
483
|
const targetPkgDirs = new Set(
|
|
199
|
-
|
|
484
|
+
cbTargetDefs.filter(d => d.file).map(d => path.dirname(d.file))
|
|
200
485
|
);
|
|
201
486
|
if (targetPkgDirs.size > 0 && !targetPkgDirs.has(path.dirname(filePath))) {
|
|
487
|
+
recordExcluded(filePath, call.line, 'out-of-scope-package');
|
|
202
488
|
continue;
|
|
203
489
|
}
|
|
204
490
|
}
|
|
205
491
|
|
|
206
|
-
//
|
|
492
|
+
// Package-qualified reference (Go): `pkg.Name` passed as a
|
|
493
|
+
// value denotes a symbol IN package pkg — never the current
|
|
494
|
+
// package (Go cannot self-import), never an unrelated
|
|
495
|
+
// same-name target. grpc-go-measured: `balancer.Get(priority.Name)`
|
|
496
|
+
// references the CONST priority.Name, not a pinned method
|
|
497
|
+
// `Name` — the target's own same-file/same-package evidence
|
|
498
|
+
// says nothing about what a qualified name resolves to.
|
|
207
499
|
if (call.isMethod && call.receiver &&
|
|
500
|
+
langTraits(fileEntry.language)?.hasReceiverPackageCalls) {
|
|
501
|
+
const cbPkgRes = _receiverPackageResolution(index, fileEntry, call.receiver, cbTargetDefs);
|
|
502
|
+
if (cbPkgRes) {
|
|
503
|
+
if (cbPkgRes.singleSegment) {
|
|
504
|
+
// Single-segment import — Go stdlib, always external
|
|
505
|
+
recordExcluded(filePath, call.line, 'external-package');
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
if (!cbPkgRes.targetInPkg) {
|
|
509
|
+
recordExcluded(filePath, call.line, 'other-definition');
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// A bare identifier can never denote a METHOD where bare
|
|
516
|
+
// names don't reach methods (fix #220, grpc-go-measured:
|
|
517
|
+
// `balancer.Get(Name)` references each package's const
|
|
518
|
+
// Name, never the pinned method Name() — Go method values
|
|
519
|
+
// require receivers, Rust `use` cannot import associated
|
|
520
|
+
// functions). Java exempt (static imports). Compiler-grade
|
|
521
|
+
// kind evidence — excluded with reason, all surfaces.
|
|
522
|
+
if (!call.isMethod && !call.receiver &&
|
|
523
|
+
langTraits(fileEntry.language)?.typeSystem === 'nominal' &&
|
|
524
|
+
!langTraits(fileEntry.language)?.bareCallReachesMethods) {
|
|
525
|
+
const allMethodTargets = cbTargetDefs.length > 0 && cbTargetDefs.every(d =>
|
|
526
|
+
!NON_CALLABLE_TYPES.has(d.type) && (d.className || d.receiver));
|
|
527
|
+
if (allMethodTargets) {
|
|
528
|
+
recordExcluded(filePath, call.line, 'method-kind-mismatch');
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// A paren-less member access is ALWAYS a field in Rust —
|
|
534
|
+
// method values are path-only (Type::method), so
|
|
535
|
+
// `self.paths.has_implicit_path` provably denotes the bool
|
|
536
|
+
// FIELD, never the method (fix #220, ripgrep-measured).
|
|
537
|
+
if (call.isMethod && call.isFunctionReference &&
|
|
538
|
+
langTraits(fileEntry.language)?.memberAccessNeverMethod) {
|
|
539
|
+
const allMethodTargets = cbTargetDefs.length > 0 && cbTargetDefs.every(d =>
|
|
540
|
+
!NON_CALLABLE_TYPES.has(d.type) && (d.className || d.receiver));
|
|
541
|
+
if (allMethodTargets) {
|
|
542
|
+
recordExcluded(filePath, call.line, 'method-kind-mismatch');
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Nominal type receiver disambiguation for callbacks (e.g. dc.worker)
|
|
548
|
+
if (call.isMethod &&
|
|
208
549
|
langTraits(fileEntry.language)?.typeSystem === 'nominal') {
|
|
209
|
-
const targetDefs = options.targetDefinitions || definitions;
|
|
210
550
|
const targetTypes = new Set();
|
|
211
|
-
for (const td of
|
|
551
|
+
for (const td of cbTargetDefs) {
|
|
212
552
|
if (td.className) targetTypes.add(td.className);
|
|
213
553
|
if (td.receiver) targetTypes.add(td.receiver.replace(/^\*/, ''));
|
|
214
554
|
}
|
|
215
|
-
if (targetTypes.size > 0 && call.receiverType
|
|
216
|
-
|
|
555
|
+
if (targetTypes.size > 0 && call.receiver && call.receiverType &&
|
|
556
|
+
!targetTypes.has(call.receiverType)) {
|
|
557
|
+
// Raw-set mismatch — check the CLOSED set (aliases +
|
|
558
|
+
// non-overriding subtypes incl. Go embedding) before
|
|
559
|
+
// disposing: a reference through a promoting outer
|
|
560
|
+
// type or a type alias IS the target's method.
|
|
561
|
+
if (!dispatchTargetTypes(cbTargetDefs).has(call.receiverType)) {
|
|
562
|
+
// A method VALUE binds at the receiver's static
|
|
563
|
+
// type: a typed receiver that is neither the
|
|
564
|
+
// target type nor below it denotes ANOTHER
|
|
565
|
+
// type's method — excluded with reason, unless
|
|
566
|
+
// the type can virtually dispatch into the
|
|
567
|
+
// target (interface receiver), which routes
|
|
568
|
+
// visible possible-dispatch. Was a silent drop:
|
|
569
|
+
// the ground line surfaced as call-not-resolved
|
|
570
|
+
// (grpc-go/cursive-measured).
|
|
571
|
+
if (collectAccount) {
|
|
572
|
+
if (_dispatchCapableSupertype(index, fileEntry.language, call.receiverType, cbTargetDefs, definitions)) {
|
|
573
|
+
routeUnverified(filePath, fileEntry, call, 'possible-dispatch', calledAs, {
|
|
574
|
+
dispatchVia: call.receiverType,
|
|
575
|
+
dispatchCandidates: countDispatchCandidates(call.receiverType),
|
|
576
|
+
});
|
|
577
|
+
} else {
|
|
578
|
+
recordExcluded(filePath, call.line, 'receiver-type-mismatch');
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
// Under the account contract, a qualified reference
|
|
585
|
+
// whose receiver is neither an imported package
|
|
586
|
+
// (resolved above), the target type itself
|
|
587
|
+
// (type-qualified method reference), nor a validated
|
|
588
|
+
// type is a name match through an UNKNOWN owner — that
|
|
589
|
+
// includes receivers the parser could not capture at
|
|
590
|
+
// all (indexed/chained selectors: `xs[0].Name`).
|
|
591
|
+
// Mirrors the #204 method-call gate: a unique
|
|
592
|
+
// project-wide owner still confirms; multiple owners
|
|
593
|
+
// route to visible 'method-ambiguous' — never confirmed
|
|
594
|
+
// via the target's bare-identifier scope evidence below.
|
|
595
|
+
// A pinned TYPE target additionally routes regardless
|
|
596
|
+
// of owner count: `m.ResourceType` is a member access
|
|
597
|
+
// on a value — it cannot denote the type itself (only
|
|
598
|
+
// a package-qualified name can, and that resolved
|
|
599
|
+
// above; an alias-imported package receiver stays
|
|
600
|
+
// visible here rather than excluded).
|
|
601
|
+
if (collectAccount) {
|
|
602
|
+
const cbTypes = dispatchTargetTypes(cbTargetDefs);
|
|
603
|
+
const cbTypeQualified = call.receiver && cbTypes.has(call.receiver);
|
|
604
|
+
const cbTypedMatch = call.receiverType && cbTypes.has(call.receiverType);
|
|
605
|
+
const cbAllTypeTargets = cbTargetDefs.length > 0 &&
|
|
606
|
+
cbTargetDefs.every(d => IDENTITY_TYPE_KINDS.has(d.type));
|
|
607
|
+
if (!cbTypeQualified && !cbTypedMatch &&
|
|
608
|
+
(methodOwnerKeys().size > 1 || cbAllTypeTargets)) {
|
|
609
|
+
routeUnverified(filePath, fileEntry, call, 'method-ambiguous', calledAs,
|
|
610
|
+
{ dispatchCandidates: methodOwnerKeys().size });
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Resolution evidence for a bare-identifier function reference:
|
|
617
|
+
// the name reaches the target via module scope (same file),
|
|
618
|
+
// same-package scope (nominal languages), or an import edge
|
|
619
|
+
// (direct or one barrel hop). Argument position alone is a name
|
|
620
|
+
// match, not evidence — a local variable or an unrelated
|
|
621
|
+
// same-name symbol shadows it invisibly.
|
|
622
|
+
const cbTargetFiles = new Set(cbTargetDefs.map(d => d.file).filter(Boolean));
|
|
623
|
+
const cbSameFile = cbTargetFiles.has(filePath);
|
|
624
|
+
const cbSamePackage = !cbSameFile &&
|
|
625
|
+
langTraits(fileEntry.language)?.typeSystem === 'nominal' &&
|
|
626
|
+
cbTargetDefs.some(d => d.file && path.dirname(d.file) === path.dirname(filePath));
|
|
627
|
+
let cbImportLink = false;
|
|
628
|
+
if (!cbSameFile && !cbSamePackage) {
|
|
629
|
+
const cbImports = index.importGraph.get(filePath);
|
|
630
|
+
cbImportLink = !!(cbImports && setSome(cbImports, imp => cbTargetFiles.has(imp)));
|
|
631
|
+
if (!cbImportLink && cbImports) {
|
|
632
|
+
for (const imp of cbImports) {
|
|
633
|
+
const trans = index.importGraph.get(imp);
|
|
634
|
+
if (trans && setSome(trans, ti => cbTargetFiles.has(ti))) {
|
|
635
|
+
cbImportLink = true;
|
|
636
|
+
break;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (!cbImportLink) {
|
|
641
|
+
// Positive mis-link evidence: the name resolves to a
|
|
642
|
+
// same-name definition in this file (or one this file
|
|
643
|
+
// imports) that is NOT the target — same disposition
|
|
644
|
+
// as the import-graph disambiguation for plain calls.
|
|
645
|
+
const cbOtherDefFiles = new Set(definitions
|
|
646
|
+
.map(d => d.file).filter(f => f && !cbTargetFiles.has(f)));
|
|
647
|
+
if (cbOtherDefFiles.has(filePath) ||
|
|
648
|
+
(cbImports && setSome(cbImports, imp => cbOtherDefFiles.has(imp)))) {
|
|
649
|
+
recordExcluded(filePath, call.line, 'other-definition-import');
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
217
652
|
}
|
|
218
653
|
}
|
|
219
654
|
|
|
220
655
|
// Find the enclosing function
|
|
221
656
|
const callerSymbol = index.findEnclosingFunction(filePath, call.line, true);
|
|
657
|
+
// A parameter of the enclosing function with the same name
|
|
658
|
+
// shadows the target: `disposeEffect(effect)` inside
|
|
659
|
+
// `function disposeEffect(effect)` references the parameter,
|
|
660
|
+
// not a same-name module-scope symbol. Same for let/const
|
|
661
|
+
// locals and inner-arrow params (fix #203 — parser-side
|
|
662
|
+
// lexical scope walk sets call.localShadow; JS block-accurate,
|
|
663
|
+
// Python function-wide assignment semantics).
|
|
664
|
+
if (call.localShadow ||
|
|
665
|
+
(callerSymbol && Array.isArray(callerSymbol.paramsStructured) &&
|
|
666
|
+
callerSymbol.paramsStructured.some(p => p && p.name === call.name))) {
|
|
667
|
+
recordExcluded(filePath, call.line, 'local-shadow');
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
222
670
|
if (!pendingByFile.has(filePath)) pendingByFile.set(filePath, []);
|
|
223
671
|
pendingByFile.get(filePath).push({
|
|
224
672
|
call, fileEntry, callerSymbol,
|
|
225
673
|
isMethod: false, isFunctionReference: true, receiver: undefined,
|
|
226
|
-
|
|
674
|
+
calledAs,
|
|
675
|
+
_evidence: {
|
|
676
|
+
isFunctionReference: true,
|
|
677
|
+
hasImportEvidence: cbSameFile || cbImportLink,
|
|
678
|
+
hasSamePackageEvidence: cbSamePackage,
|
|
679
|
+
}
|
|
227
680
|
});
|
|
228
681
|
pendingCount++;
|
|
229
682
|
continue;
|
|
@@ -232,6 +685,18 @@ function findCallers(index, name, options = {}) {
|
|
|
232
685
|
// Resolve binding within this file (without mutating cached call objects)
|
|
233
686
|
let bindingId = call.bindingId;
|
|
234
687
|
let isUncertain = call.uncertain;
|
|
688
|
+
// Parser-detected lexical shadow (fix #203, hoisted by #222 —
|
|
689
|
+
// express-measured): a local let/var/param of the same name
|
|
690
|
+
// shadows the target at this reference, whatever record shape
|
|
691
|
+
// carried it. The callback fast path already excluded its own;
|
|
692
|
+
// an isFunctionReference-only argument ref (`router.use(path,
|
|
693
|
+
// f)` inside `use(fn)`'s forEach closure) used to slip past to
|
|
694
|
+
// binding resolution and exact-confirm on the shadowed name.
|
|
695
|
+
if (call.localShadow && !call.isPotentialCallback) {
|
|
696
|
+
recordExcluded(filePath, call.line, 'local-shadow');
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
|
|
235
700
|
// Skip binding resolution for calls with non-self/this/cls receivers:
|
|
236
701
|
// e.g., analyzer.analyze_instrument() should NOT resolve to a local
|
|
237
702
|
// standalone function def `analyze_instrument` — they're different symbols.
|
|
@@ -240,6 +705,27 @@ function findCallers(index, name, options = {}) {
|
|
|
240
705
|
const skipLocalBinding = call.receiver && !selfReceivers.has(call.receiver);
|
|
241
706
|
if (!bindingId && !skipLocalBinding) {
|
|
242
707
|
let bindings = (fileEntry.bindings || []).filter(b => b.name === call.name);
|
|
708
|
+
// A bare call cannot bind to a METHOD def where bare names
|
|
709
|
+
// never reach methods (fix #220, cobra-measured): Go's
|
|
710
|
+
// func (c *Command) MarkFlagDirname and func MarkFlagDirname
|
|
711
|
+
// coexist in one package — the bare call denotes the
|
|
712
|
+
// FUNCTION. Java keeps both (implicit this-calls). Fix
|
|
713
|
+
// #222 (rich-measured) extends this to structural: the
|
|
714
|
+
// bindings table lists class members, but Python bare-name
|
|
715
|
+
// lookup never enters class scope (`cell_len(self.plain)`
|
|
716
|
+
// inside Text binds the module-level import of
|
|
717
|
+
// cells.cell_len, not Text.cell_len) and a JS class
|
|
718
|
+
// member is not a file binding either — the structural
|
|
719
|
+
// dispatch gates can't own this case because a matched
|
|
720
|
+
// bindingId bypasses them.
|
|
721
|
+
if (!call.isMethod && bindings.length > 0 &&
|
|
722
|
+
!langTraits(fileEntry.language)?.bareCallReachesMethods) {
|
|
723
|
+
const defsOfName = index.symbols.get(call.name) || [];
|
|
724
|
+
bindings = bindings.filter(b => {
|
|
725
|
+
const sym = defsOfName.find(s => s.file === filePath && s.startLine === b.startLine);
|
|
726
|
+
return !(sym && (sym.className || sym.receiver));
|
|
727
|
+
});
|
|
728
|
+
}
|
|
243
729
|
// For Go, also check sibling files in same directory (same package scope)
|
|
244
730
|
if (bindings.length === 0 && langTraits(fileEntry.language)?.packageScope === 'directory') {
|
|
245
731
|
const dir = path.dirname(filePath);
|
|
@@ -256,6 +742,15 @@ function findCallers(index, name, options = {}) {
|
|
|
256
742
|
}
|
|
257
743
|
if (bindings.length === 1) {
|
|
258
744
|
bindingId = bindings[0].id;
|
|
745
|
+
} else if (bindings.length > 1 && !call.isMethod &&
|
|
746
|
+
call.isConstructor &&
|
|
747
|
+
bindings.filter(b => b.type === 'class' || b.type === 'function').length === 1) {
|
|
748
|
+
// Constructor calls bind to constructable symbols: `new ZodArray()`
|
|
749
|
+
// must resolve to the class binding, not a same-name field/const
|
|
750
|
+
// elsewhere in the file (TS declaration merging, bottom-of-file
|
|
751
|
+
// namespace aliases). All `new`-style languages mark these
|
|
752
|
+
// (JS/TS `new`, Java `new`, Go/Rust composite/struct literals).
|
|
753
|
+
bindingId = bindings.find(b => b.type === 'class' || b.type === 'function').id;
|
|
259
754
|
} else if (bindings.length > 1 && !call.isMethod) {
|
|
260
755
|
// For implicit same-class calls (Java: execute() means this.execute()),
|
|
261
756
|
// try to resolve via caller's className before marking uncertain
|
|
@@ -325,19 +820,64 @@ function findCallers(index, name, options = {}) {
|
|
|
325
820
|
// self/this.method() calls can be resolved by same-class matching
|
|
326
821
|
// even when binding is ambiguous (e.g. method exists in multiple classes)
|
|
327
822
|
let resolvedBySameClass = false;
|
|
823
|
+
// Receiver/path type known to mismatch the target: such an edge can
|
|
824
|
+
// never tier as confirmed even when legacy includeUncertain keeps it
|
|
825
|
+
// visible (scoreEdge checks hasReceiverType before isUncertain, so
|
|
826
|
+
// without this flag a known mismatch would score receiver-hint 0.80).
|
|
827
|
+
let typeMismatch = false;
|
|
828
|
+
// Structural languages: receiver-hint requires a VALIDATED match
|
|
829
|
+
// (receiverType ∈ target class + subtypes). Ancestor-kept and
|
|
830
|
+
// trust-gate-passed types fall back to import/scope evidence —
|
|
831
|
+
// an unvalidated annotation must not upgrade the tier.
|
|
832
|
+
let receiverTypeValidated = false;
|
|
833
|
+
// Nominal local-inference match (receiver typed via
|
|
834
|
+
// _buildTypedLocalTypeMap ∈ target types) — receiver evidence
|
|
835
|
+
// for the dispatch tiering below.
|
|
836
|
+
let nominalInferredMatch = false;
|
|
837
|
+
// Identity discipline (fix #206): the receiver's type NAME
|
|
838
|
+
// matches the target's type, but several distinct types share
|
|
839
|
+
// that name and none is resolvable from this file's scope —
|
|
840
|
+
// not confirmation evidence, not exclusion evidence. Routed
|
|
841
|
+
// method-ambiguous under the account contract.
|
|
842
|
+
let receiverTypeUnresolved = false;
|
|
328
843
|
if (call.isMethod) {
|
|
329
844
|
if (call.selfAttribute && fileEntry.language === 'python') {
|
|
330
845
|
// self.attr.method() — resolve via attribute type inference
|
|
331
846
|
const callerSymbol = index.findEnclosingFunction(filePath, call.line, true);
|
|
332
847
|
if (!callerSymbol?.className) {
|
|
333
848
|
// Can't resolve — include only if includeMethods requested
|
|
334
|
-
if (!options.includeMethods)
|
|
849
|
+
if (!options.includeMethods) {
|
|
850
|
+
routeUnverified(filePath, fileEntry, call, 'method-no-evidence', calledAs);
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
335
853
|
} else {
|
|
336
854
|
const attrTypes = getInstanceAttributeTypes(index, filePath, callerSymbol.className);
|
|
337
855
|
const targetClass = attrTypes?.get(call.selfAttribute);
|
|
338
856
|
if (targetClass && definitions.some(d => d.className === targetClass)) {
|
|
857
|
+
// fix #202b: the resolved class must be the
|
|
858
|
+
// TARGET's class (or an ancestor — dynamic
|
|
859
|
+
// dispatch). self.attr.m() resolving to class X
|
|
860
|
+
// is not a caller of a pinned target Y.m.
|
|
861
|
+
const tDefs = options.targetDefinitions || definitions;
|
|
862
|
+
const targetClasses = new Set(tDefs.map(d => d.className).filter(Boolean));
|
|
863
|
+
if (!targetClasses.has(targetClass) &&
|
|
864
|
+
!_isAncestorOfTargetClass(index, targetClass, tDefs)) {
|
|
865
|
+
recordExcluded(filePath, call.line, 'other-definition');
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
// fix #218: attribute typed as a STRICT ancestor
|
|
869
|
+
// of the pinned target's class — reaching the
|
|
870
|
+
// subclass override is dynamic dispatch (#204
|
|
871
|
+
// physics). Demote-only, account-gated.
|
|
872
|
+
if (collectAccount && !targetClasses.has(targetClass)) {
|
|
873
|
+
routeUnverified(filePath, fileEntry, call, 'possible-dispatch', calledAs, {
|
|
874
|
+
dispatchVia: targetClass,
|
|
875
|
+
});
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
339
878
|
resolvedBySameClass = true;
|
|
340
879
|
} else if (!options.includeMethods) {
|
|
880
|
+
routeUnverified(filePath, fileEntry, call, 'method-no-evidence', calledAs);
|
|
341
881
|
continue;
|
|
342
882
|
}
|
|
343
883
|
}
|
|
@@ -345,24 +885,27 @@ function findCallers(index, name, options = {}) {
|
|
|
345
885
|
// self/this/super.method() — resolve to same-class or parent method
|
|
346
886
|
const callerSymbol = index.findEnclosingFunction(filePath, call.line, true);
|
|
347
887
|
if (!callerSymbol?.className) {
|
|
348
|
-
if (!options.includeMethods)
|
|
888
|
+
if (!options.includeMethods) {
|
|
889
|
+
routeUnverified(filePath, fileEntry, call, 'method-no-evidence', calledAs);
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
349
892
|
} else {
|
|
350
893
|
// For super(), skip same-class — only check parent chain
|
|
351
|
-
let
|
|
352
|
-
|
|
353
|
-
|
|
894
|
+
let matchedClass = call.receiver !== 'super' &&
|
|
895
|
+
definitions.some(d => d.className === callerSymbol.className)
|
|
896
|
+
? callerSymbol.className : null;
|
|
354
897
|
// Walk inheritance chain using BFS if not found in same class
|
|
355
|
-
if (!
|
|
898
|
+
if (!matchedClass) {
|
|
356
899
|
const visited = new Set([callerSymbol.className]);
|
|
357
900
|
const callerFile = callerSymbol.file || filePath;
|
|
358
901
|
const startParents = index._getInheritanceParents(callerSymbol.className, callerFile) || [];
|
|
359
902
|
const queue = startParents.map(p => ({ name: p, contextFile: callerFile }));
|
|
360
|
-
while (queue.length > 0 && !
|
|
903
|
+
while (queue.length > 0 && !matchedClass) {
|
|
361
904
|
const { name: current, contextFile } = queue.shift();
|
|
362
905
|
if (visited.has(current)) continue;
|
|
363
906
|
visited.add(current);
|
|
364
|
-
|
|
365
|
-
if (!
|
|
907
|
+
if (definitions.some(d => d.className === current)) matchedClass = current;
|
|
908
|
+
if (!matchedClass) {
|
|
366
909
|
const resolvedFile = index._resolveClassFile(current, contextFile);
|
|
367
910
|
const grandparents = index._getInheritanceParents(current, resolvedFile) || [];
|
|
368
911
|
for (const gp of grandparents) {
|
|
@@ -371,9 +914,75 @@ function findCallers(index, name, options = {}) {
|
|
|
371
914
|
}
|
|
372
915
|
}
|
|
373
916
|
}
|
|
374
|
-
if (
|
|
917
|
+
if (matchedClass) {
|
|
918
|
+
// fix #202b: same-class resolution must land on the
|
|
919
|
+
// TARGET's class (or an ancestor — dynamic dispatch
|
|
920
|
+
// may run the target override). self.path() inside
|
|
921
|
+
// StandardImpl resolves to StandardImpl::path — not
|
|
922
|
+
// a caller of a pinned target Haystack::path.
|
|
923
|
+
// NOMINAL languages + Python: the exclusion is sound
|
|
924
|
+
// only when the inheritance graph is complete; TS
|
|
925
|
+
// hierarchies hide edges UCN can't see (zod's
|
|
926
|
+
// `declare class` merging — measured: the structural
|
|
927
|
+
// guard excluded true callers). Python's recorded
|
|
928
|
+
// bases are reliable, but MRO adds a trap nominal
|
|
929
|
+
// languages lack: `self.method()` inside Mixin can
|
|
930
|
+
// dispatch to a CO-PARENT's method through a common
|
|
931
|
+
// subclass (class C(Target, Mixin) — C's MRO finds
|
|
932
|
+
// Target.method before Mixin's). Exclusion therefore
|
|
933
|
+
// also requires that the matched class and the
|
|
934
|
+
// target's class share no project descendant.
|
|
935
|
+
const sameClassTraits = langTraits(fileEntry.language);
|
|
936
|
+
if (sameClassTraits?.typeSystem === 'nominal' ||
|
|
937
|
+
fileEntry.language === 'python') {
|
|
938
|
+
const tDefs = options.targetDefinitions || definitions;
|
|
939
|
+
const targetClasses = new Set(tDefs.map(d => d.className).filter(Boolean));
|
|
940
|
+
if (!targetClasses.has(matchedClass) &&
|
|
941
|
+
!_isAncestorOfTargetClass(index, matchedClass, tDefs) &&
|
|
942
|
+
!(fileEntry.language === 'python' &&
|
|
943
|
+
_shareProjectDescendant(index, matchedClass, targetClasses))) {
|
|
944
|
+
recordExcluded(filePath, call.line, 'other-definition');
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
// fix #218 (rich-measured): the match landed on
|
|
948
|
+
// a STRICT ancestor of the pinned target's class
|
|
949
|
+
// (or a Python co-parent via shared descendant) —
|
|
950
|
+
// `self.render()` inside abstract ProgressColumn
|
|
951
|
+
// lexically binds the ancestor's def; reaching the
|
|
952
|
+
// pinned SUBCLASS override is dynamic dispatch,
|
|
953
|
+
// possible but not confirmable (#204 physics).
|
|
954
|
+
// Demote-only, account-gated; when the pinned
|
|
955
|
+
// target IS the declaring class, matchedClass ∈
|
|
956
|
+
// targetClasses and confirmation stands.
|
|
957
|
+
if (collectAccount && !targetClasses.has(matchedClass)) {
|
|
958
|
+
routeUnverified(filePath, fileEntry, call, 'possible-dispatch', calledAs, {
|
|
959
|
+
dispatchVia: matchedClass,
|
|
960
|
+
});
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
} else if (collectAccount) {
|
|
964
|
+
// fix #213 (JS/TS, zod seed-B-measured): the same
|
|
965
|
+
// pinning check, but ROUTED visible instead of
|
|
966
|
+
// excluded — `this.min()` inside ZodString lexically
|
|
967
|
+
// binds ZodString.min or a subclass override, never
|
|
968
|
+
// a pinned sibling ZodNumber.min (cross-sibling
|
|
969
|
+
// spray was ~23 of 38 FP edges). Exclusion stays
|
|
970
|
+
// off: TS `declare class` merging hides extends
|
|
971
|
+
// edges, so an unrelated-looking class may still be
|
|
972
|
+
// an ancestor (the original #202b structural revert).
|
|
973
|
+
// Legacy keeps confirming (drop-vs-route asymmetry).
|
|
974
|
+
const tDefs = options.targetDefinitions || definitions;
|
|
975
|
+
const targetClasses = new Set(tDefs.map(d => d.className).filter(Boolean));
|
|
976
|
+
if (!targetClasses.has(matchedClass) &&
|
|
977
|
+
!_isAncestorOfTargetClass(index, matchedClass, tDefs) &&
|
|
978
|
+
!_shareProjectDescendant(index, matchedClass, targetClasses)) {
|
|
979
|
+
routeUnverified(filePath, fileEntry, call, 'method-ambiguous', calledAs);
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
375
983
|
resolvedBySameClass = true;
|
|
376
984
|
} else if (!options.includeMethods) {
|
|
985
|
+
routeUnverified(filePath, fileEntry, call, 'method-no-evidence', calledAs);
|
|
377
986
|
continue;
|
|
378
987
|
}
|
|
379
988
|
}
|
|
@@ -381,14 +990,58 @@ function findCallers(index, name, options = {}) {
|
|
|
381
990
|
// Go doesn't use this/self/cls - always include Go method calls
|
|
382
991
|
// Java method calls are always obj.method() - include by default
|
|
383
992
|
// Rust Type::method() calls - include by default (associated functions)
|
|
384
|
-
// For other languages, skip method calls unless explicitly requested
|
|
385
|
-
|
|
993
|
+
// For other languages, skip method calls unless explicitly requested.
|
|
994
|
+
// Under collectAccount the gate falls through instead: receiver
|
|
995
|
+
// evidence computed at the push site decides the tier (a require'd
|
|
996
|
+
// module receiver earns scope-match/confirmed; an unknown receiver
|
|
997
|
+
// is marked uncertain in the binding block and routes below).
|
|
998
|
+
if (langTraits(fileEntry.language)?.methodCallInclusion === 'explicit' && !options.includeMethods) {
|
|
999
|
+
if (!collectAccount) continue;
|
|
1000
|
+
}
|
|
386
1001
|
}
|
|
387
1002
|
}
|
|
388
1003
|
|
|
1004
|
+
// Declared-field receiver typing (fix #202, extended to
|
|
1005
|
+
// structural by fix #219): one-hop field receivers
|
|
1006
|
+
// (self.dent.path() / h.inner.Run() / this._map.has()) resolve
|
|
1007
|
+
// through the field's DECLARED type. Computed before binding
|
|
1008
|
+
// checks — name-bindings don't model receivers, so a same-file
|
|
1009
|
+
// `path` binding must not claim a call whose receiver field is
|
|
1010
|
+
// typed elsewhere. JS/TS `this`-rooted hops resolve their root
|
|
1011
|
+
// type here (the enclosing class — the parser's walk does not
|
|
1012
|
+
// track class context; arrows keep lexical `this`, and nested
|
|
1013
|
+
// function declarations are their own symbols WITHOUT
|
|
1014
|
+
// className, so dynamic-this shapes resolve to nothing).
|
|
1015
|
+
let fieldHopType = null;
|
|
1016
|
+
let fieldHopRootType = call.receiverRootType;
|
|
1017
|
+
if (!fieldHopRootType && call.receiverField && call.receiverRoot === 'this' &&
|
|
1018
|
+
!resolvedBySameClass &&
|
|
1019
|
+
langTraits(fileEntry.language)?.typeSystem === 'structural') {
|
|
1020
|
+
const hopEnclosing = index.findEnclosingFunction(filePath, call.line, true);
|
|
1021
|
+
if (hopEnclosing?.className) fieldHopRootType = hopEnclosing.className;
|
|
1022
|
+
}
|
|
1023
|
+
if (call.isMethod && !call.receiverType && call.receiverField && fieldHopRootType &&
|
|
1024
|
+
!resolvedBySameClass) {
|
|
1025
|
+
fieldHopType = _declaredFieldType(index, fieldHopRootType, call.receiverField, fileEntry.language);
|
|
1026
|
+
}
|
|
1027
|
+
// Dispatch attribution (contract surface only): a field DECLARED
|
|
1028
|
+
// as a project interface/trait carries no exclusion evidence
|
|
1029
|
+
// (_declaredFieldType returns null — any implementor may receive
|
|
1030
|
+
// the call), but it IS positive evidence of possible dispatch.
|
|
1031
|
+
// Resolved separately so the unverified tier can attribute the
|
|
1032
|
+
// edge: "possible-dispatch via <Interface> — 1 of N impls".
|
|
1033
|
+
let fieldDispatchType = null;
|
|
1034
|
+
if (collectAccount && fieldHopType === null &&
|
|
1035
|
+
call.isMethod && !call.receiverType && call.receiverField && fieldHopRootType &&
|
|
1036
|
+
!resolvedBySameClass) {
|
|
1037
|
+
fieldDispatchType = _declaredFieldInterfaceType(index, fieldHopRootType, call.receiverField, fileEntry.language);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
389
1040
|
// Skip uncertain calls unless resolved by same-class matching or explicitly requested
|
|
390
1041
|
if (isUncertain && !resolvedBySameClass && !options.includeUncertain) {
|
|
391
1042
|
if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
|
|
1043
|
+
routeUnverified(filePath, fileEntry, call,
|
|
1044
|
+
call.isMethod ? 'method-no-evidence' : 'ambiguous-binding', calledAs);
|
|
392
1045
|
continue;
|
|
393
1046
|
}
|
|
394
1047
|
|
|
@@ -400,7 +1053,110 @@ function findCallers(index, name, options = {}) {
|
|
|
400
1053
|
const targetDefs = options.targetDefinitions || definitions;
|
|
401
1054
|
const targetBindingIds = new Set(targetDefs.map(d => d.bindingId).filter(Boolean));
|
|
402
1055
|
if (targetBindingIds.size > 0 && bindingId && !targetBindingIds.has(bindingId)) {
|
|
403
|
-
|
|
1056
|
+
// fix #202: a declared-field receiver type that VALIDATES
|
|
1057
|
+
// against the target overrides name-binding evidence —
|
|
1058
|
+
// self.dent.path() name-binds to a same-file `path` def,
|
|
1059
|
+
// but the field's declared type says the call is the
|
|
1060
|
+
// target's (receiver-typed edges fall through to the
|
|
1061
|
+
// receiver-class disambiguation below).
|
|
1062
|
+
const fieldHopMatchesTarget = fieldHopType && targetDefs.some(d =>
|
|
1063
|
+
(d.className || (d.receiver || '').replace(/^\*/, '')) === fieldHopType);
|
|
1064
|
+
if (!fieldHopMatchesTarget) {
|
|
1065
|
+
recordExcluded(filePath, call.line, 'other-definition');
|
|
1066
|
+
continue;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// Name-level import shadowing (fix #209, httpx-measured): an
|
|
1071
|
+
// explicit import binding of the NAME rebinds it for the whole
|
|
1072
|
+
// file — `from urllib.parse import unquote` makes every bare
|
|
1073
|
+
// `unquote(...)` urllib's, regardless of which project files
|
|
1074
|
+
// this file also imports (file-level import edges are not
|
|
1075
|
+
// name-level evidence; httpx/_urls.py imports ._utils for
|
|
1076
|
+
// OTHER names while unquote comes from urllib). A bare call
|
|
1077
|
+
// only reaches the project def when SOME import binding of the
|
|
1078
|
+
// name resolves to a target file (directly or one barrel hop),
|
|
1079
|
+
// or the target is defined in this file. Mis-resolved project
|
|
1080
|
+
// modules must not exclude: a binding to an unresolved module
|
|
1081
|
+
// whose first segment matches a project directory routes
|
|
1082
|
+
// visible instead.
|
|
1083
|
+
if (!bindingId && !call.isMethod && !calledAs &&
|
|
1084
|
+
langTraits(fileEntry.language)?.typeSystem === 'structural' &&
|
|
1085
|
+
(fileEntry.importBindings || []).length > 0) {
|
|
1086
|
+
const nameBindings = fileEntry.importBindings.filter(b => b.name === call.name);
|
|
1087
|
+
const tFiles = new Set(targetDefs.map(d => d.file).filter(Boolean));
|
|
1088
|
+
// fix #215 (rich-measured: 225 builtin `print(...)` calls
|
|
1089
|
+
// confirmed against rich's def via file-level import edges):
|
|
1090
|
+
// a bare name in a module file resolves to a local binding,
|
|
1091
|
+
// an import binding of THAT name, or a builtin/global — it
|
|
1092
|
+
// can never reach an unimported project def. No local
|
|
1093
|
+
// binding (bindingId), no import binding of the name, no
|
|
1094
|
+
// star import that could inject it → the call provably
|
|
1095
|
+
// does not denote the target. Same correctness family as
|
|
1096
|
+
// the all-external shadow exclusion below; the
|
|
1097
|
+
// importBindings.length precondition keeps script files
|
|
1098
|
+
// (no module discipline) out.
|
|
1099
|
+
// `resolvedName` means the parser already resolved a local
|
|
1100
|
+
// alias to the original through a real import binding
|
|
1101
|
+
// (`const { parse: csvParse } = require(...)`) — name-level
|
|
1102
|
+
// evidence by construction; importBindings store the
|
|
1103
|
+
// ORIGINAL name, so the local alias must not look unbound.
|
|
1104
|
+
if (nameBindings.length === 0 && !call.resolvedName &&
|
|
1105
|
+
tFiles.size > 0 && !tFiles.has(filePath) &&
|
|
1106
|
+
!(fileEntry.importNames || []).includes('*')) {
|
|
1107
|
+
recordExcluded(filePath, call.line, 'name-not-in-scope');
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
// A same-file pin does not put a bare name in scope when
|
|
1111
|
+
// every pinned def is class-scoped (fix #222, rich-measured):
|
|
1112
|
+
// the class member is outside the bare-name lookup chain,
|
|
1113
|
+
// so the file's import bindings own the name.
|
|
1114
|
+
const samefilePinsOutOfScope = targetDefs.length > 0 &&
|
|
1115
|
+
targetDefs.every(d => d.className);
|
|
1116
|
+
if (nameBindings.length > 0 && (!tFiles.has(filePath) || samefilePinsOutOfScope)) {
|
|
1117
|
+
// Name-level export-chain ownership (fix #217): each
|
|
1118
|
+
// binding is chased by NAME, not by file — `from
|
|
1119
|
+
// .render import render` pins to tests/render.py's own
|
|
1120
|
+
// def and cannot denote markup.render no matter what
|
|
1121
|
+
// tests/render.py imports (file-level reach said yes
|
|
1122
|
+
// through console.py — 24 rich FP edges). Exclusion
|
|
1123
|
+
// requires EVERY binding to be a definitive dead end:
|
|
1124
|
+
// external pins (#209c) or chains that provably
|
|
1125
|
+
// terminate away from the targets; any un-modelable
|
|
1126
|
+
// surface (CJS, star imports, module assignments,
|
|
1127
|
+
// resolver gaps) routes 'unknown' and blocks exclusion.
|
|
1128
|
+
let reaches = false;
|
|
1129
|
+
let undetermined = false;
|
|
1130
|
+
for (const b of nameBindings) {
|
|
1131
|
+
const rel = fileEntry.moduleResolved && fileEntry.moduleResolved[b.module];
|
|
1132
|
+
if (!rel) {
|
|
1133
|
+
// Unresolved module: external — unless it is
|
|
1134
|
+
// relative (project-internal by construction)
|
|
1135
|
+
// or its first segment names a project path
|
|
1136
|
+
// (resolution gap, not externality evidence)
|
|
1137
|
+
const mod = String(b.module);
|
|
1138
|
+
const firstSeg = mod.split(/[./]/).filter(Boolean)[0];
|
|
1139
|
+
if (mod.startsWith('.') ||
|
|
1140
|
+
(firstSeg && _projectTopLevelNames(index).has(firstSeg))) {
|
|
1141
|
+
undetermined = true;
|
|
1142
|
+
}
|
|
1143
|
+
continue;
|
|
1144
|
+
}
|
|
1145
|
+
const resolvedAbs = path.join(index.root, rel);
|
|
1146
|
+
const verdict = _nameBindingReaches(index, resolvedAbs, b.name, tFiles);
|
|
1147
|
+
if (verdict === 'yes') { reaches = true; break; }
|
|
1148
|
+
if (verdict === 'unknown') undetermined = true;
|
|
1149
|
+
}
|
|
1150
|
+
if (!reaches && !undetermined) {
|
|
1151
|
+
// Every import binding of this name pins away from
|
|
1152
|
+
// the pinned targets (external module, or a project
|
|
1153
|
+
// def the name-chase resolved with certainty) — the
|
|
1154
|
+
// bare name is rebound away from the target
|
|
1155
|
+
// (compiler-checked module semantics).
|
|
1156
|
+
recordExcluded(filePath, call.line, 'other-definition-import');
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
404
1160
|
}
|
|
405
1161
|
|
|
406
1162
|
// Import-graph disambiguation for JS/TS/Python: when multiple definitions of
|
|
@@ -424,7 +1180,25 @@ function findCallers(index, name, options = {}) {
|
|
|
424
1180
|
break;
|
|
425
1181
|
}
|
|
426
1182
|
}
|
|
427
|
-
if (!foundViaReexport)
|
|
1183
|
+
if (!foundViaReexport) {
|
|
1184
|
+
// Disposition depends on what the caller DOES import:
|
|
1185
|
+
// - imports a DIFFERENT same-name def's file → positive
|
|
1186
|
+
// mis-link evidence → excluded other-definition-import
|
|
1187
|
+
// - imports neither def → pure ambiguity, no positive
|
|
1188
|
+
// evidence → unverified tier (visible), per the contract
|
|
1189
|
+
const otherDefFiles = new Set((index.symbols.get(name) || [])
|
|
1190
|
+
.map(d => d.file).filter(f => f && !targetFiles.has(f)));
|
|
1191
|
+
const importsOtherDef = imports && setSome(imports, imp => otherDefFiles.has(imp));
|
|
1192
|
+
if (importsOtherDef || otherDefFiles.has(filePath)) {
|
|
1193
|
+
recordExcluded(filePath, call.line, 'other-definition-import');
|
|
1194
|
+
continue;
|
|
1195
|
+
}
|
|
1196
|
+
if (collectAccount) {
|
|
1197
|
+
routeUnverified(filePath, fileEntry, call, 'no-import-link', calledAs);
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
continue;
|
|
1201
|
+
}
|
|
428
1202
|
}
|
|
429
1203
|
}
|
|
430
1204
|
}
|
|
@@ -436,6 +1210,7 @@ function findCallers(index, name, options = {}) {
|
|
|
436
1210
|
targetDefs.filter(d => d.file).map(d => path.dirname(d.file))
|
|
437
1211
|
);
|
|
438
1212
|
if (targetPkgDirs.size > 0 && !targetPkgDirs.has(path.dirname(filePath))) {
|
|
1213
|
+
recordExcluded(filePath, call.line, 'out-of-scope-package');
|
|
439
1214
|
continue;
|
|
440
1215
|
}
|
|
441
1216
|
}
|
|
@@ -445,15 +1220,131 @@ function findCallers(index, name, options = {}) {
|
|
|
445
1220
|
// and cli.Run() (package call, isMethod:false) from matching DeploymentController.Run.
|
|
446
1221
|
// Rust path calls (module::func(), Type::new()) bypass this filter — they're
|
|
447
1222
|
// scoped_identifier calls that can target both standalone functions and impl methods.
|
|
448
|
-
|
|
1223
|
+
// The binding guard is per-direction (fix #220): a name binding
|
|
1224
|
+
// is receiver-blind, so it cannot make x.f() reach a standalone
|
|
1225
|
+
// function in languages whose dot-calls provably never do (Rust
|
|
1226
|
+
// needs (s.f)() parens — ripgrep's `.preprocessor_globs(...)`
|
|
1227
|
+
// bound the same-file FUNCTION def). Go keeps !bindingId there:
|
|
1228
|
+
// func-typed fields ARE name-callable. The bare-call direction
|
|
1229
|
+
// keeps !bindingId — the upstream binding filter already
|
|
1230
|
+
// re-resolves those to function defs where methods are
|
|
1231
|
+
// unreachable.
|
|
1232
|
+
if ((!bindingId || (call.isMethod &&
|
|
1233
|
+
!langTraits(fileEntry.language)?.methodCallReachesFunctions)) &&
|
|
1234
|
+
!resolvedBySameClass && !call.isPathCall &&
|
|
449
1235
|
langTraits(fileEntry.language)?.typeSystem === 'nominal') {
|
|
450
1236
|
const targetHasClass = targetDefs.some(d => d.className);
|
|
451
1237
|
if (call.isMethod && !targetHasClass) {
|
|
452
1238
|
// Method call but target is a standalone function — skip
|
|
1239
|
+
recordExcluded(filePath, call.line, 'method-kind-mismatch');
|
|
453
1240
|
continue;
|
|
454
1241
|
}
|
|
455
1242
|
if (!call.isMethod && targetHasClass) {
|
|
456
1243
|
// Non-method call but target is a class method — skip
|
|
1244
|
+
recordExcluded(filePath, call.line, 'method-kind-mismatch');
|
|
1245
|
+
continue;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// Module receiver: httpx.get() / ns.helper() dispatches to a
|
|
1250
|
+
// module export — it can never be a CLASS METHOD call. Applies
|
|
1251
|
+
// only when every target is a class method; standalone-function
|
|
1252
|
+
// and class (constructor) targets keep flowing on import evidence.
|
|
1253
|
+
if (!bindingId && !resolvedBySameClass && call.isMethod && call.receiverIsModule &&
|
|
1254
|
+
langTraits(fileEntry.language)?.typeSystem === 'structural' &&
|
|
1255
|
+
targetDefs.length > 0 && targetDefs.every(d => d.className)) {
|
|
1256
|
+
isUncertain = true;
|
|
1257
|
+
typeMismatch = true;
|
|
1258
|
+
if (collectAccount) {
|
|
1259
|
+
recordExcluded(filePath, call.line, 'module-receiver');
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1262
|
+
if (!options.includeUncertain) {
|
|
1263
|
+
if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// Module-qualified ownership, structural (fix #209 — the #206
|
|
1269
|
+
// Go rule transferred): `httpcore.URL(...)` denotes URL IN the
|
|
1270
|
+
// httpcore module — an EXTERNAL module's attribute can never be
|
|
1271
|
+
// the project's URL class. Resolve the receiver's own import
|
|
1272
|
+
// binding (name-level — the file importing the target for
|
|
1273
|
+
// other names proves nothing): binding module external →
|
|
1274
|
+
// excluded; resolves to a project file that doesn't reach a
|
|
1275
|
+
// target (directly or one re-export hop) → visible, not
|
|
1276
|
+
// excluded (deep barrel chains exceed the hop budget);
|
|
1277
|
+
// unresolved-but-project-looking → visible (resolver gap).
|
|
1278
|
+
if (!bindingId && !resolvedBySameClass && call.isMethod && call.receiverIsModule &&
|
|
1279
|
+
call.receiver && langTraits(fileEntry.language)?.typeSystem === 'structural' &&
|
|
1280
|
+
(fileEntry.importBindings || []).length > 0) {
|
|
1281
|
+
const recvBindings = fileEntry.importBindings.filter(b => b.name === call.receiver);
|
|
1282
|
+
const tFiles = new Set(targetDefs.map(d => d.file).filter(Boolean));
|
|
1283
|
+
if (recvBindings.length > 0 && !tFiles.has(filePath)) {
|
|
1284
|
+
let reaches = false;
|
|
1285
|
+
let projectish = false;
|
|
1286
|
+
for (const b of recvBindings) {
|
|
1287
|
+
const rel = fileEntry.moduleResolved && fileEntry.moduleResolved[b.module];
|
|
1288
|
+
if (!rel) {
|
|
1289
|
+
const mod = String(b.module);
|
|
1290
|
+
const firstSeg = mod.split(/[./]/).filter(Boolean)[0];
|
|
1291
|
+
if (mod.startsWith('.') ||
|
|
1292
|
+
(firstSeg && _projectTopLevelNames(index).has(firstSeg))) {
|
|
1293
|
+
projectish = true;
|
|
1294
|
+
}
|
|
1295
|
+
continue;
|
|
1296
|
+
}
|
|
1297
|
+
projectish = true;
|
|
1298
|
+
const resolvedAbs = path.join(index.root, rel);
|
|
1299
|
+
// Name-level ownership (fix #217 applied to module
|
|
1300
|
+
// receivers — zod family D): `z._default(...)` asks
|
|
1301
|
+
// for the MODULE's `_default` attribute; with three
|
|
1302
|
+
// project defs of the name, only the one the export
|
|
1303
|
+
// chain actually exposes can be the callee. The
|
|
1304
|
+
// chase is definitive only on fully-modeled ESM/
|
|
1305
|
+
// Python surfaces — 'unknown' (CJS, stars, module
|
|
1306
|
+
// assignments) falls back to file-level reach.
|
|
1307
|
+
const verdict = _nameBindingReaches(index, resolvedAbs, call.name, tFiles);
|
|
1308
|
+
if (verdict === 'yes' ||
|
|
1309
|
+
(verdict === 'unknown' && _importReaches(index, resolvedAbs, tFiles))) {
|
|
1310
|
+
reaches = true; break;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
if (!reaches) {
|
|
1314
|
+
if (!projectish) {
|
|
1315
|
+
recordExcluded(filePath, call.line, 'external-package');
|
|
1316
|
+
continue;
|
|
1317
|
+
}
|
|
1318
|
+
if (collectAccount) {
|
|
1319
|
+
routeUnverified(filePath, fileEntry, call, 'no-import-link', calledAs);
|
|
1320
|
+
continue;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// Structural typed-receiver kind filter: a method call on a
|
|
1327
|
+
// receiver with a known class type can only target that class's
|
|
1328
|
+
// methods — never a standalone function. Module receivers are
|
|
1329
|
+
// never typed (localVarTypes only types constructor results,
|
|
1330
|
+
// annotations, and literals), so module-qualified calls to
|
|
1331
|
+
// standalone functions keep flowing on import evidence. Class
|
|
1332
|
+
// targets are exempt: their own type matching runs below.
|
|
1333
|
+
// Trust gate: only builtin/project-class types are positive
|
|
1334
|
+
// evidence — an alias/interface annotation can wrap the target
|
|
1335
|
+
// (`const x: Fetcher = { fetch }`), so it must not exclude.
|
|
1336
|
+
if (!bindingId && !resolvedBySameClass && call.isMethod && call.receiverType &&
|
|
1337
|
+
langTraits(fileEntry.language)?.typeSystem === 'structural' &&
|
|
1338
|
+
_receiverTypeTrustedForExclusion(index, call.receiverType) &&
|
|
1339
|
+
!targetDefs.some(d => d.className || d.receiver || NON_CALLABLE_TYPES.has(d.type))) {
|
|
1340
|
+
isUncertain = true;
|
|
1341
|
+
typeMismatch = true;
|
|
1342
|
+
if (collectAccount) {
|
|
1343
|
+
recordExcluded(filePath, call.line, 'receiver-type-mismatch');
|
|
1344
|
+
continue;
|
|
1345
|
+
}
|
|
1346
|
+
if (!options.includeUncertain) {
|
|
1347
|
+
if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
|
|
457
1348
|
continue;
|
|
458
1349
|
}
|
|
459
1350
|
}
|
|
@@ -464,16 +1355,36 @@ function findCallers(index, name, options = {}) {
|
|
|
464
1355
|
// like "fmt", "os") and third-party calls (import graph has no edge to target).
|
|
465
1356
|
if (!call.isMethod && call.receiver && !bindingId &&
|
|
466
1357
|
langTraits(fileEntry.language)?.hasReceiverPackageCalls) {
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
const last = parts[parts.length - 1];
|
|
471
|
-
const pkgName = (/^v\d+$/.test(last) && parts.length > 1) ? parts[parts.length - 2] : last;
|
|
472
|
-
return pkgName === call.receiver;
|
|
473
|
-
});
|
|
474
|
-
if (importModule) {
|
|
475
|
-
if (!importModule.includes('/')) {
|
|
1358
|
+
const pkgRes = _receiverPackageResolution(index, fileEntry, call.receiver, targetDefs);
|
|
1359
|
+
if (pkgRes) {
|
|
1360
|
+
if (pkgRes.singleSegment) {
|
|
476
1361
|
// Single-segment import — Go stdlib, always external
|
|
1362
|
+
recordExcluded(filePath, call.line, 'external-package');
|
|
1363
|
+
continue;
|
|
1364
|
+
}
|
|
1365
|
+
// A package-qualified name can never denote the
|
|
1366
|
+
// caller's own FILE's package (Go cannot self-import):
|
|
1367
|
+
// a pinned target defined only in this very file is
|
|
1368
|
+
// positively a different symbol — measured:
|
|
1369
|
+
// &certprovider.KeyMaterial{...} inside KeyMaterial()
|
|
1370
|
+
// claiming a self-edge through a local binding.
|
|
1371
|
+
if (targetDefs.length > 0 && targetDefs.every(d => d.file === filePath)) {
|
|
1372
|
+
recordExcluded(filePath, call.line, 'other-definition');
|
|
1373
|
+
continue;
|
|
1374
|
+
}
|
|
1375
|
+
// Receiver-package identity (fix #206b): an import edge
|
|
1376
|
+
// to the target's file proves the caller USES the
|
|
1377
|
+
// target's package, not that THIS qualified name
|
|
1378
|
+
// resolves there. The qualified name denotes a symbol
|
|
1379
|
+
// in the RECEIVER's module — the target must live in
|
|
1380
|
+
// that module's package (project-relative module-path
|
|
1381
|
+
// suffix, or conventional package-segment match).
|
|
1382
|
+
// grpc-go measured: `&v3corepb.Locality{...}` (aliased
|
|
1383
|
+
// EXTERNAL envoy proto) and `xdsresource.Locality{...}`
|
|
1384
|
+
// confirmed for clients.Locality because the caller
|
|
1385
|
+
// also imported clients/config.go.
|
|
1386
|
+
if (!pkgRes.targetInPkg) {
|
|
1387
|
+
recordExcluded(filePath, call.line, 'other-definition');
|
|
477
1388
|
continue;
|
|
478
1389
|
}
|
|
479
1390
|
// Multi-segment import — verify via import graph
|
|
@@ -485,46 +1396,162 @@ function findCallers(index, name, options = {}) {
|
|
|
485
1396
|
// No import edge — allow same-package (same directory) calls
|
|
486
1397
|
const callerDir = path.dirname(filePath);
|
|
487
1398
|
const samePackage = targetDefs.some(d => d.file && path.dirname(d.file) === callerDir);
|
|
488
|
-
if (!samePackage)
|
|
1399
|
+
if (!samePackage) {
|
|
1400
|
+
recordExcluded(filePath, call.line, 'external-package');
|
|
1401
|
+
continue;
|
|
1402
|
+
}
|
|
489
1403
|
}
|
|
490
1404
|
}
|
|
491
1405
|
}
|
|
492
1406
|
}
|
|
493
1407
|
|
|
1408
|
+
// Alias-matched method call on a TYPED receiver: the receiver's
|
|
1409
|
+
// class owns the method dispatch — it cannot be a renamed
|
|
1410
|
+
// standalone function (`numberSchema.gt()` is ZodNumber.gt, not
|
|
1411
|
+
// `export { _gt as gt }`). Namespace receivers (`import * as
|
|
1412
|
+
// checks; checks.gt()`) carry no receiverType and keep flowing
|
|
1413
|
+
// on import evidence.
|
|
1414
|
+
if (calledAs && call.isMethod && call.receiverType &&
|
|
1415
|
+
!targetDefs.some(td => td.className || td.receiver)) {
|
|
1416
|
+
isUncertain = true;
|
|
1417
|
+
typeMismatch = true;
|
|
1418
|
+
if (collectAccount) {
|
|
1419
|
+
recordExcluded(filePath, call.line, 'receiver-type-mismatch');
|
|
1420
|
+
continue;
|
|
1421
|
+
}
|
|
1422
|
+
if (!options.includeUncertain) {
|
|
1423
|
+
if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
|
|
1424
|
+
continue;
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
494
1428
|
// Receiver-class disambiguation:
|
|
495
1429
|
// When the target definition has a class/receiver type, filter callers
|
|
496
1430
|
// whose receiverType is known to be a different type.
|
|
497
1431
|
// All languages use receiverType when available (constructor/annotation inference).
|
|
498
1432
|
// Go/Java/Rust additionally fall back to variable name matching.
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
if (cName) targetTypes.add(cName);
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
}
|
|
1433
|
+
// A declared-field receiver type (fix #202) enters even when a
|
|
1434
|
+
// name-binding matched — bindings don't model receivers. Same
|
|
1435
|
+
// for a BUILTIN-typed receiver (fix #209): `"".join(...)` in
|
|
1436
|
+
// the file that defines URL.join name-binds to the method def,
|
|
1437
|
+
// but the receiver IS a str — the literal type outranks the
|
|
1438
|
+
// name binding (str/dict/Array are never project classes).
|
|
1439
|
+
const builtinReceiverOverride = !!(call.receiverType &&
|
|
1440
|
+
BUILTIN_RECEIVER_TYPES.has(call.receiverType) &&
|
|
1441
|
+
langTraits(fileEntry.language)?.typeSystem === 'structural');
|
|
1442
|
+
if (call.isMethod && (call.receiver || call.receiverType || fieldHopType) && !resolvedBySameClass &&
|
|
1443
|
+
(!bindingId || fieldHopType || builtinReceiverOverride) &&
|
|
1444
|
+
(call.receiverType || fieldHopType || langTraits(fileEntry.language)?.typeSystem === 'nominal')) {
|
|
1445
|
+
// Target type set: target classes + non-overriding subtypes
|
|
1446
|
+
// (a Child receiver calling an inherited Base method IS a
|
|
1447
|
+
// caller of Base.method). Memoized — fixed per query.
|
|
1448
|
+
const targetTypes = dispatchTargetTypes(targetDefs);
|
|
520
1449
|
if (targetTypes.size > 0) {
|
|
521
1450
|
// Use inferred receiverType when available (Go/Java/Rust parameter type tracking)
|
|
522
|
-
|
|
1451
|
+
// Generic type parameters by convention are not type
|
|
1452
|
+
// identity in EITHER direction (fix #220): a receiver
|
|
1453
|
+
// typed 'T' neither validates against a blanket-impl
|
|
1454
|
+
// target named 'T' nor excludes a concrete target —
|
|
1455
|
+
// T may be instantiated with anything. A short-caps
|
|
1456
|
+
// name with a real project type def is a class.
|
|
1457
|
+
let knownType = call.receiverType || fieldHopType;
|
|
1458
|
+
if (knownType && /^[A-Z][A-Z0-9]?$/.test(knownType) &&
|
|
1459
|
+
!(index.symbols.get(knownType) || []).some(d => IDENTITY_TYPE_KINDS.has(d.type))) knownType = null;
|
|
523
1460
|
if (knownType) {
|
|
524
|
-
const
|
|
525
|
-
|
|
526
|
-
|
|
1461
|
+
const viaFieldHop = !call.receiverType; // declared-field hop (fix #202)
|
|
1462
|
+
// Exclusion requires an UNRELATED type. A receiver typed
|
|
1463
|
+
// as an ANCESTOR of the target's class may dynamically
|
|
1464
|
+
// dispatch to the target override (x: Base; x.parse()
|
|
1465
|
+
// can run Child.parse) — structural languages only;
|
|
1466
|
+
// Go embedding has no virtual dispatch. Field-hop types
|
|
1467
|
+
// get the ancestor guard too (Java virtual dispatch).
|
|
1468
|
+
const structural = langTraits(fileEntry.language)?.typeSystem === 'structural';
|
|
1469
|
+
if (targetTypes.has(knownType)) {
|
|
1470
|
+
receiverTypeValidated = true;
|
|
1471
|
+
// Identity discipline (nominal, fix #206): a
|
|
1472
|
+
// NAME match is only identity when the
|
|
1473
|
+
// unqualified type name resolves (same file →
|
|
1474
|
+
// same package directory → import edge) to the
|
|
1475
|
+
// target's package. grpc-go defines ~20 structs
|
|
1476
|
+
// all named `bb` — leastrequest's `parser :=
|
|
1477
|
+
// bb{}` validating against cdsbalancer's
|
|
1478
|
+
// bb.ParseConfig is name conflation, not
|
|
1479
|
+
// receiver evidence. Only DIRECT target type
|
|
1480
|
+
// names are disciplined — subtype names entered
|
|
1481
|
+
// targetTypes via the inheritance walk, whose
|
|
1482
|
+
// edges already carry package context.
|
|
1483
|
+
if (!structural &&
|
|
1484
|
+
targetDefs.some(d => (d.className || (d.receiver || '').replace(/^\*/, '')) === knownType)) {
|
|
1485
|
+
// Flow-typed receivers (fix #207) resolve identity
|
|
1486
|
+
// from the producing annotation's scope — the name
|
|
1487
|
+
// was written THERE, not in the consuming file.
|
|
1488
|
+
const identity = _resolveReceiverTypeIdentity(
|
|
1489
|
+
index, call.receiverTypeFlowFile || filePath, knownType, targetDefs);
|
|
1490
|
+
if (identity === 'other') {
|
|
1491
|
+
receiverTypeValidated = false;
|
|
1492
|
+
} else if (identity === 'unknown') {
|
|
1493
|
+
receiverTypeValidated = false;
|
|
1494
|
+
receiverTypeUnresolved = true;
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
const matchesTarget = receiverTypeValidated ||
|
|
1499
|
+
((structural || viaFieldHop) && _isAncestorOfTargetClass(index, knownType, targetDefs));
|
|
1500
|
+
// Structural trust gate: a name that is neither a
|
|
1501
|
+
// builtin nor a project class (type alias, interface,
|
|
1502
|
+
// external type) tracks no hierarchy UCN can check —
|
|
1503
|
+
// not positive evidence against the target.
|
|
1504
|
+
// Field-hop exclusion additionally demands the field's
|
|
1505
|
+
// type DEFINE the method itself — otherwise Go
|
|
1506
|
+
// promotion, Rust Deref, or Java inheritance could
|
|
1507
|
+
// still route the call to the target. Exception: an
|
|
1508
|
+
// EXTERNAL field type (no project class/struct def,
|
|
1509
|
+
// e.g. Map/StringBuilder) excludes without that —
|
|
1510
|
+
// external code cannot Deref/promote/inherit INTO
|
|
1511
|
+
// project types, so the only dispatch path back is a
|
|
1512
|
+
// project subtype of the external type, which the
|
|
1513
|
+
// ancestor guard above already keeps.
|
|
1514
|
+
// (1-2 char ALL-CAPS names are generic type params by
|
|
1515
|
+
// convention — T, K, V, T1 — never external evidence:
|
|
1516
|
+
// T may be instantiated WITH the target class. And the
|
|
1517
|
+
// external rule needs the target's ancestor chain to be
|
|
1518
|
+
// FULLY project-resolvable: a chain that dead-ends at an
|
|
1519
|
+
// external ancestor (LinkedTreeMap extends AbstractMap)
|
|
1520
|
+
// may reach knownType through ancestry UCN can't see —
|
|
1521
|
+
// measured on gson: 6 true edges lost without this.)
|
|
1522
|
+
const fieldHopDefinesMethod = !viaFieldHop || definitions.some(d =>
|
|
1523
|
+
(d.className || (d.receiver || '').replace(/^\*/, '')) === knownType) ||
|
|
1524
|
+
(!/^[A-Z][A-Z0-9]?$/.test(knownType) &&
|
|
1525
|
+
!(index.symbols.get(knownType) || []).some(d =>
|
|
1526
|
+
d.type === 'class' || d.type === 'struct' || d.type === 'interface' || d.type === 'trait') &&
|
|
1527
|
+
_targetAncestryFullyResolved(index, targetDefs));
|
|
1528
|
+
const exclusionTrusted = (!structural ||
|
|
1529
|
+
_receiverTypeTrustedForExclusion(index, knownType)) && fieldHopDefinesMethod &&
|
|
1530
|
+
!receiverTypeUnresolved; // unresolvable identity is not positive evidence either way
|
|
1531
|
+
if (!matchesTarget && exclusionTrusted) {
|
|
1532
|
+
// Known type doesn't match target — positive evidence the
|
|
1533
|
+
// call targets a DIFFERENT symbol. Under the account contract
|
|
1534
|
+
// this is excluded-with-reason, not a revealable uncertain.
|
|
527
1535
|
isUncertain = true;
|
|
1536
|
+
typeMismatch = true;
|
|
1537
|
+
if (collectAccount) {
|
|
1538
|
+
// ...unless the type can VIRTUALLY dispatch into
|
|
1539
|
+
// the target: an interface/trait receiver that
|
|
1540
|
+
// declares the method, or (Java — all instance
|
|
1541
|
+
// methods virtual) a superclass of the target.
|
|
1542
|
+
// Not evidence against — visible possible-dispatch.
|
|
1543
|
+
// Go struct embedding binds statically and stays
|
|
1544
|
+
// excluded.
|
|
1545
|
+
if (_dispatchCapableSupertype(index, fileEntry.language, knownType, targetDefs, definitions)) {
|
|
1546
|
+
routeUnverified(filePath, fileEntry, call, 'possible-dispatch', calledAs, {
|
|
1547
|
+
dispatchVia: knownType,
|
|
1548
|
+
dispatchCandidates: countDispatchCandidates(knownType),
|
|
1549
|
+
});
|
|
1550
|
+
continue;
|
|
1551
|
+
}
|
|
1552
|
+
recordExcluded(filePath, call.line, 'receiver-type-mismatch');
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
528
1555
|
if (!options.includeUncertain) {
|
|
529
1556
|
if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
|
|
530
1557
|
continue;
|
|
@@ -551,7 +1578,22 @@ function findCallers(index, name, options = {}) {
|
|
|
551
1578
|
const inferredType = localTypes.get(call.receiver);
|
|
552
1579
|
if (inferredType) {
|
|
553
1580
|
if (targetTypes.has(inferredType)) {
|
|
554
|
-
|
|
1581
|
+
// Identity discipline (fix #206) — same as the
|
|
1582
|
+
// parser-typed branch above: a name match on a
|
|
1583
|
+
// DIRECT target type must resolve to the
|
|
1584
|
+
// target's package, not a same-named foreign type.
|
|
1585
|
+
let identity = 'target';
|
|
1586
|
+
if (targetDefs.some(d => (d.className || (d.receiver || '').replace(/^\*/, '')) === inferredType)) {
|
|
1587
|
+
identity = _resolveReceiverTypeIdentity(index, filePath, inferredType, targetDefs);
|
|
1588
|
+
}
|
|
1589
|
+
if (identity === 'target') {
|
|
1590
|
+
inferredMatch = true;
|
|
1591
|
+
nominalInferredMatch = true;
|
|
1592
|
+
} else if (identity === 'other') {
|
|
1593
|
+
inferredMismatch = true;
|
|
1594
|
+
} else {
|
|
1595
|
+
receiverTypeUnresolved = true;
|
|
1596
|
+
}
|
|
555
1597
|
} else {
|
|
556
1598
|
inferredMismatch = true;
|
|
557
1599
|
}
|
|
@@ -561,20 +1603,72 @@ function findCallers(index, name, options = {}) {
|
|
|
561
1603
|
}
|
|
562
1604
|
if (inferredMismatch) {
|
|
563
1605
|
isUncertain = true;
|
|
1606
|
+
typeMismatch = true;
|
|
1607
|
+
if (collectAccount) {
|
|
1608
|
+
recordExcluded(filePath, call.line, 'receiver-type-mismatch');
|
|
1609
|
+
continue;
|
|
1610
|
+
}
|
|
564
1611
|
if (!options.includeUncertain) {
|
|
565
1612
|
if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
|
|
566
1613
|
continue;
|
|
567
1614
|
}
|
|
568
1615
|
}
|
|
569
|
-
// Still no type — fall back to receiver name matching when
|
|
570
|
-
|
|
1616
|
+
// Still no type — fall back to receiver name matching when
|
|
1617
|
+
// multiple defs exist. A field-declared interface/trait type
|
|
1618
|
+
// (fieldDispatchType, contract surface only) outranks the name
|
|
1619
|
+
// heuristic: `storage.save()` on a field declared `Storage`
|
|
1620
|
+
// is a dispatch edge, not a case-insensitive name accident —
|
|
1621
|
+
// skip the fallback and let the dispatch tiering route it.
|
|
1622
|
+
// call.receiver guard: a generic-param knownType
|
|
1623
|
+
// (fix #220) reaches here receiver-less — there is
|
|
1624
|
+
// no receiver NAME to match against.
|
|
1625
|
+
if (call.receiver && !inferredMatch && !inferredMismatch && definitions.length > 1 && !fieldDispatchType) {
|
|
571
1626
|
const receiverLower = call.receiver.toLowerCase();
|
|
572
1627
|
const matchesTarget = [...targetTypes].some(cn => cn.toLowerCase() === receiverLower);
|
|
1628
|
+
// Type-qualified identity discipline (fix #220,
|
|
1629
|
+
// ripgrep-measured): a path-call receiver that
|
|
1630
|
+
// matches the target type's NAME must also
|
|
1631
|
+
// resolve (same file → same dir → import edge)
|
|
1632
|
+
// to the target's package — every ripgrep crate
|
|
1633
|
+
// defines its own `Config`, and printer's
|
|
1634
|
+
// Config::default() name-matches core's Config
|
|
1635
|
+
// while provably denoting the same-file struct.
|
|
1636
|
+
// Path style only: a Go/Java receiver named
|
|
1637
|
+
// like the type may be a VARIABLE (#206b) —
|
|
1638
|
+
// its type is unknown, identity proves nothing.
|
|
1639
|
+
if (matchesTarget && call.isPathCall &&
|
|
1640
|
+
langTraits(fileEntry.language)?.typeQualifiedCallStyle === 'path') {
|
|
1641
|
+
const identity = _resolveReceiverTypeIdentity(index, filePath, call.receiver, targetDefs);
|
|
1642
|
+
if (identity === 'other') {
|
|
1643
|
+
isUncertain = true;
|
|
1644
|
+
typeMismatch = true;
|
|
1645
|
+
if (collectAccount) {
|
|
1646
|
+
recordExcluded(filePath, call.line, 'path-type-mismatch');
|
|
1647
|
+
continue;
|
|
1648
|
+
}
|
|
1649
|
+
if (!options.includeUncertain) {
|
|
1650
|
+
if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
|
|
1651
|
+
continue;
|
|
1652
|
+
}
|
|
1653
|
+
} else if (identity === 'unknown' && collectAccount) {
|
|
1654
|
+
// Unresolvable identity never confirms,
|
|
1655
|
+
// never excludes (#206).
|
|
1656
|
+
routeUnverified(filePath, fileEntry, call, 'method-ambiguous', calledAs, {
|
|
1657
|
+
dispatchCandidates: methodOwnerKeys().size,
|
|
1658
|
+
});
|
|
1659
|
+
continue;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
573
1662
|
if (!matchesTarget) {
|
|
574
1663
|
// Rust/Go path calls (Type::method() / pkg.Method()): receiver IS the type name
|
|
575
1664
|
// If it doesn't match target, it's definitely a different type — filter it
|
|
576
1665
|
if (call.isPathCall && /^[A-Z]/.test(call.receiver)) {
|
|
577
1666
|
isUncertain = true;
|
|
1667
|
+
typeMismatch = true;
|
|
1668
|
+
if (collectAccount) {
|
|
1669
|
+
recordExcluded(filePath, call.line, 'path-type-mismatch');
|
|
1670
|
+
continue;
|
|
1671
|
+
}
|
|
578
1672
|
if (!options.includeUncertain) {
|
|
579
1673
|
if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
|
|
580
1674
|
continue;
|
|
@@ -588,6 +1682,26 @@ function findCallers(index, name, options = {}) {
|
|
|
588
1682
|
const matchesOther = [...nonTargetClasses].some(cn => cn.toLowerCase() === receiverLower);
|
|
589
1683
|
if (matchesOther) {
|
|
590
1684
|
isUncertain = true;
|
|
1685
|
+
typeMismatch = true;
|
|
1686
|
+
if (collectAccount) {
|
|
1687
|
+
// The matched class may be a dispatch-capable
|
|
1688
|
+
// supertype of the target (a receiver named
|
|
1689
|
+
// after the interface it is typed as) — that
|
|
1690
|
+
// is a possible dispatch edge, not evidence
|
|
1691
|
+
// against the target.
|
|
1692
|
+
const dispatchSuper = [...nonTargetClasses]
|
|
1693
|
+
.filter(cn => cn.toLowerCase() === receiverLower)
|
|
1694
|
+
.find(cn => _dispatchCapableSupertype(index, fileEntry.language, cn, targetDefs, definitions));
|
|
1695
|
+
if (dispatchSuper) {
|
|
1696
|
+
routeUnverified(filePath, fileEntry, call, 'possible-dispatch', calledAs, {
|
|
1697
|
+
dispatchVia: dispatchSuper,
|
|
1698
|
+
dispatchCandidates: countDispatchCandidates(dispatchSuper),
|
|
1699
|
+
});
|
|
1700
|
+
continue;
|
|
1701
|
+
}
|
|
1702
|
+
recordExcluded(filePath, call.line, 'receiver-other-class');
|
|
1703
|
+
continue;
|
|
1704
|
+
}
|
|
591
1705
|
if (!options.includeUncertain) {
|
|
592
1706
|
if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
|
|
593
1707
|
continue;
|
|
@@ -599,90 +1713,543 @@ function findCallers(index, name, options = {}) {
|
|
|
599
1713
|
}
|
|
600
1714
|
}
|
|
601
1715
|
|
|
1716
|
+
// Arity pruning (nominal contract surface, fix #205): a call
|
|
1717
|
+
// whose argument count cannot fit ANY pinned definition's
|
|
1718
|
+
// parameter range is positive evidence the call binds a
|
|
1719
|
+
// different symbol — excluded-with-reason. Static-arity
|
|
1720
|
+
// languages only: their compilers enforce arity, so a mismatch
|
|
1721
|
+
// IS evidence. JS pads/ignores extra args legally and Python
|
|
1722
|
+
// decorators reshape signatures invisibly — never prune there.
|
|
1723
|
+
// Go tuple expansion (f(g()) filling two params) means too-FEW
|
|
1724
|
+
// syntactic args is not evidence in Go — only too-many prunes.
|
|
1725
|
+
// Binding/same-class evidence outranks the count (then a
|
|
1726
|
+
// mismatch more likely means our param parse is wrong).
|
|
1727
|
+
if (collectAccount && !bindingId && !resolvedBySameClass &&
|
|
1728
|
+
call.argCount != null && !call.argSpread &&
|
|
1729
|
+
langTraits(fileEntry.language)?.typeSystem === 'nominal' &&
|
|
1730
|
+
!_callArityCompatible(call, targetDefs, fileEntry.language)) {
|
|
1731
|
+
recordExcluded(filePath, call.line, 'arity-mismatch');
|
|
1732
|
+
continue;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
// Overload discipline (fix #205, languages with arity/type
|
|
1736
|
+
// overloading — Java): when the pinned target shares its name
|
|
1737
|
+
// with sibling overloads in the same class, a call site only
|
|
1738
|
+
// CONFIRMS the pinned overload if its static argument shape
|
|
1739
|
+
// (count + literal kinds) binds it:
|
|
1740
|
+
// - kinds prove a DIFFERENT overload → excluded 'overload-mismatch'
|
|
1741
|
+
// - kinds prove the pinned one uniquely → flows on (confirmable)
|
|
1742
|
+
// - undecidable (variable args) → visible 'overload-ambiguous'
|
|
1743
|
+
// jdtls-measured: class-level receiver evidence said "some add()
|
|
1744
|
+
// overload", which is not evidence for THIS add(Number).
|
|
1745
|
+
if (collectAccount && options.targetDefinitions &&
|
|
1746
|
+
langTraits(fileEntry.language)?.hasArityOverloads &&
|
|
1747
|
+
call.argCount != null && !call.argSpread && !call.isConstructor) {
|
|
1748
|
+
const overloadVerdict = _overloadDiscipline(index, call, targetDefs, definitions);
|
|
1749
|
+
if (overloadVerdict === 'other-overload') {
|
|
1750
|
+
recordExcluded(filePath, call.line, 'overload-mismatch');
|
|
1751
|
+
continue;
|
|
1752
|
+
}
|
|
1753
|
+
if (overloadVerdict && overloadVerdict.ambiguous) {
|
|
1754
|
+
routeUnverified(filePath, fileEntry, call, 'overload-ambiguous', calledAs, {
|
|
1755
|
+
dispatchCandidates: overloadVerdict.candidates,
|
|
1756
|
+
});
|
|
1757
|
+
continue;
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
|
|
602
1761
|
// Find the enclosing function (get full symbol info)
|
|
603
1762
|
const callerSymbol = index.findEnclosingFunction(filePath, call.line, true);
|
|
604
1763
|
|
|
1764
|
+
// Method call whose receiver has no binding evidence in this file's
|
|
1765
|
+
// scope (structural languages only) — receiver-evidence-free.
|
|
1766
|
+
// Hoisted because it also limits what counts as import evidence.
|
|
1767
|
+
const uncertainMethodReceiver = skipLocalBinding && call.isMethod && !resolvedBySameClass &&
|
|
1768
|
+
langTraits(fileEntry.language)?.typeSystem === 'structural' &&
|
|
1769
|
+
!(call.receiver && (fileEntry.bindings || []).some(b => b.name === call.receiver));
|
|
1770
|
+
|
|
605
1771
|
// Check import graph evidence: does this file import from the target definition's file?
|
|
606
1772
|
const targetDefs2 = options.targetDefinitions || definitions;
|
|
607
1773
|
const targetFiles2 = new Set(targetDefs2.map(d => d.file).filter(Boolean));
|
|
608
1774
|
const callerImports = index.importGraph.get(filePath);
|
|
609
|
-
let
|
|
1775
|
+
let importEdgeLink = !!(callerImports && setSome(callerImports, imp => targetFiles2.has(imp)));
|
|
610
1776
|
// Check one level of re-exports (barrel files) for import evidence
|
|
611
|
-
if (!
|
|
1777
|
+
if (!importEdgeLink && callerImports) {
|
|
612
1778
|
for (const imp of callerImports) {
|
|
613
1779
|
const transImports = index.importGraph.get(imp);
|
|
614
1780
|
if (transImports && setSome(transImports, ti => targetFiles2.has(ti))) {
|
|
615
|
-
|
|
1781
|
+
importEdgeLink = true;
|
|
616
1782
|
break;
|
|
617
1783
|
}
|
|
618
1784
|
}
|
|
619
1785
|
}
|
|
1786
|
+
// Same-file membership is module-scope evidence for plain calls,
|
|
1787
|
+
// but says nothing about a method receiver: `foo.map()` sharing a
|
|
1788
|
+
// file with `function map()` must not confirm while foo's type is
|
|
1789
|
+
// unknown. Real import edges keep counting — importing the
|
|
1790
|
+
// defining module is evidence the file uses its API.
|
|
1791
|
+
const hasImportLink = importEdgeLink ||
|
|
1792
|
+
(targetFiles2.has(filePath) && !uncertainMethodReceiver);
|
|
620
1793
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
hasBindingId: !!bindingId,
|
|
629
|
-
resolvedBySameClass: !!resolvedBySameClass,
|
|
630
|
-
isUncertain: !!isUncertain || (
|
|
631
|
-
// Method calls where binding resolution was skipped (non-self receiver)
|
|
632
|
-
// and the receiver has no binding evidence → uncertain (JS/TS/Python only)
|
|
633
|
-
skipLocalBinding && call.isMethod && !resolvedBySameClass &&
|
|
634
|
-
langTraits(fileEntry.language)?.typeSystem === 'structural' &&
|
|
635
|
-
!(call.receiver && (fileEntry.bindings || []).some(b => b.name === call.receiver))
|
|
636
|
-
),
|
|
637
|
-
hasReceiverType: !!call.receiverType,
|
|
638
|
-
hasReceiverEvidence: !!(call.receiver &&
|
|
639
|
-
(fileEntry.bindings || []).some(b => b.name === call.receiver)),
|
|
640
|
-
hasImportEvidence: !!bindingId || hasImportLink,
|
|
641
|
-
}
|
|
642
|
-
});
|
|
643
|
-
pendingCount++;
|
|
644
|
-
}
|
|
645
|
-
} catch (e) {
|
|
646
|
-
// Expected: minified files exceed tree-sitter buffer, binary files fail to parse.
|
|
647
|
-
// These are not actionable errors — silently skip.
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
// True total candidate count from Phase 1 (before any Phase 2 truncation).
|
|
652
|
-
// Used by callers that need accurate "showing N of <total>" headers.
|
|
653
|
-
const totalCount = pendingCount;
|
|
654
|
-
// When needsTotal is set with a maxResults cap, only enrich the first
|
|
655
|
-
// `maxResults` candidates in Phase 2 — file reads stay bounded.
|
|
656
|
-
const enrichLimit = (needsTotal && maxResults) ? maxResults : Infinity;
|
|
657
|
-
let enrichedCount = 0;
|
|
1794
|
+
// Same-package evidence (nominal type systems): Java/Rust/Go
|
|
1795
|
+
// resolve same-package/module names without import statements,
|
|
1796
|
+
// so a target defined in the caller's directory is real scope
|
|
1797
|
+
// evidence, not a bare name match.
|
|
1798
|
+
const hasSamePackageEvidence = !hasImportLink &&
|
|
1799
|
+
langTraits(fileEntry.language)?.typeSystem === 'nominal' &&
|
|
1800
|
+
targetDefs2.some(d => d.file && path.dirname(d.file) === path.dirname(filePath));
|
|
658
1801
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
1802
|
+
// Possible-dispatch tiering (nominal languages, contract surface
|
|
1803
|
+
// only): methodCallInclusion='auto' confirms method calls with
|
|
1804
|
+
// ZERO receiver evidence — right when the name is unique
|
|
1805
|
+
// project-wide (cobra), a lie when dozens of types implement it
|
|
1806
|
+
// (gson TypeAdapter.read). The confirmed tier keeps only
|
|
1807
|
+
// evidence-backed edges: validated/inferred receiver type,
|
|
1808
|
+
// same-class resolution, binding, a type-qualified receiver
|
|
1809
|
+
// (Type::method / Type.method), or a name with a single
|
|
1810
|
+
// project-wide owner. The rest stay VISIBLE as unverified — a
|
|
1811
|
+
// known-but-unvalidated receiver type is 'possible-dispatch'
|
|
1812
|
+
// (attributed via the declared supertype), an untyped receiver
|
|
1813
|
+
// against multiple owners is 'method-ambiguous'. Nothing is
|
|
1814
|
+
// dropped: conservation holds, the entries move tiers.
|
|
1815
|
+
// A binding matched from a bare-name lookup is receiver-blind:
|
|
1816
|
+
// method calls resolve through their RECEIVER in every supported
|
|
1817
|
+
// language, never through file scope — a same-file def or import
|
|
1818
|
+
// of the name says nothing about what `parse_hex(v).map(...)` or
|
|
1819
|
+
// `self.inner.next()` dispatches to (cursive-measured: 9 of 11
|
|
1820
|
+
// method FPs were chained/field-rooted calls confirmed
|
|
1821
|
+
// exact-binding against Rgb::map / Iterator-impl next / V::draw).
|
|
1822
|
+
// Such calls must earn the confirmed tier through receiver
|
|
1823
|
+
// evidence — route them through the dispatch tiering below.
|
|
1824
|
+
// Self-receiver calls are not affected (same-class resolution
|
|
1825
|
+
// owns them); captured-receiver calls never bound (skipLocalBinding).
|
|
1826
|
+
// Local-alias calls (fix #218): `get_style = console.get_style;
|
|
1827
|
+
// get_style(x)` is a TRUE edge with compiler-grade evidence,
|
|
1828
|
+
// but it reaches the target through a local variable — the
|
|
1829
|
+
// line's name resolves to the alias, not the def, so reference
|
|
1830
|
+
// oracles place nothing here and grep-parity verification is
|
|
1831
|
+
// impossible. Visible unverified, never confirmed (not even by
|
|
1832
|
+
// same-class/type-qualified/single-owner evidence); the
|
|
1833
|
+
// exclusion-grade checks (typed-receiver mismatch, same-class
|
|
1834
|
+
// pinning, arity) already fired above and win.
|
|
1835
|
+
if (collectAccount && call.aliasCall) {
|
|
1836
|
+
routeUnverified(filePath, fileEntry, call, 'alias-call', calledAs, {
|
|
1837
|
+
...(call.receiver && { dispatchVia: call.receiver }),
|
|
1838
|
+
});
|
|
1839
|
+
continue;
|
|
1840
|
+
}
|
|
664
1841
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
1842
|
+
const receiverBlindBinding = !!bindingId && call.isMethod && !call.receiver;
|
|
1843
|
+
if (collectAccount && call.isMethod && (!bindingId || receiverBlindBinding) && !resolvedBySameClass &&
|
|
1844
|
+
!receiverTypeValidated && !nominalInferredMatch &&
|
|
1845
|
+
langTraits(fileEntry.language)?.typeSystem === 'nominal') {
|
|
1846
|
+
const tTypes = dispatchTargetTypes(targetDefs2);
|
|
1847
|
+
// `use X as Y` import rename (fix #222b, ripgrep-measured:
|
|
1848
|
+
// `use ContextSeparator as Separator; Separator::disabled()`
|
|
1849
|
+
// — the alias names the TARGET type locally): judge path
|
|
1850
|
+
// receivers by the ORIGINAL name. Only fires when the
|
|
1851
|
+
// import's last path segment IS a target type — package
|
|
1852
|
+
// aliases and unrelated imports stay untouched.
|
|
1853
|
+
let receiverName = call.receiver;
|
|
1854
|
+
if (receiverName && !tTypes.has(receiverName)) {
|
|
1855
|
+
for (const im of (fileEntry.importBindings || [])) {
|
|
1856
|
+
if (im.name !== receiverName) continue;
|
|
1857
|
+
const orig = String(im.module || '').split('::').pop();
|
|
1858
|
+
if (orig && orig !== receiverName && tTypes.has(orig)) {
|
|
1859
|
+
receiverName = orig;
|
|
1860
|
+
break;
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
// A receiver that shares the target type's NAME is only
|
|
1865
|
+
// type-qualified when the call matches the language's
|
|
1866
|
+
// qualified-call syntax (typeQualifiedCallStyle trait):
|
|
1867
|
+
// Rust requires Type::method (a dot-call receiver matching
|
|
1868
|
+
// a type name is a variable); Go method expressions
|
|
1869
|
+
// T.M(recv, ...) pass the receiver as the first argument,
|
|
1870
|
+
// so a zero-arg call on a type-named receiver is a
|
|
1871
|
+
// variable, not the type (grpc-go's `bb` collision).
|
|
1872
|
+
let typeQualifiedReceiver = !!(receiverName && tTypes.has(receiverName));
|
|
1873
|
+
if (typeQualifiedReceiver) {
|
|
1874
|
+
const qualStyle = langTraits(fileEntry.language)?.typeQualifiedCallStyle;
|
|
1875
|
+
if (qualStyle === 'path') typeQualifiedReceiver = !!call.isPathCall;
|
|
1876
|
+
else if (qualStyle === 'method-expr') typeQualifiedReceiver = call.argCount == null || call.argCount >= 1;
|
|
1877
|
+
}
|
|
1878
|
+
// Identity discipline on the qualified shape itself (fix
|
|
1879
|
+
// #220): a genuinely type-qualified call still only NAMES
|
|
1880
|
+
// the type — the name must resolve to the target's package
|
|
1881
|
+
// (every ripgrep crate defines a `Config`). 'other' is
|
|
1882
|
+
// compiler-grade evidence for a different type; 'unknown'
|
|
1883
|
+
// never confirms and never excludes (#206). The receiver-
|
|
1884
|
+
// name fallback above handles multi-definition names; this
|
|
1885
|
+
// covers single-definition targets that skip it.
|
|
1886
|
+
if (typeQualifiedReceiver) {
|
|
1887
|
+
const identity = _resolveReceiverTypeIdentity(index, filePath, receiverName, targetDefs2);
|
|
1888
|
+
if (identity === 'other') {
|
|
1889
|
+
recordExcluded(filePath, call.line, 'path-type-mismatch');
|
|
1890
|
+
continue;
|
|
1891
|
+
}
|
|
1892
|
+
if (identity === 'unknown') {
|
|
1893
|
+
routeUnverified(filePath, fileEntry, call, 'method-ambiguous', calledAs, {
|
|
1894
|
+
dispatchCandidates: methodOwnerKeys().size,
|
|
1895
|
+
});
|
|
1896
|
+
continue;
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
if (!typeQualifiedReceiver) {
|
|
1900
|
+
// External-producer receiver (fix #220): the variable
|
|
1901
|
+
// was assigned from a call into an external package
|
|
1902
|
+
// (av := reflect.ValueOf(a)) — its type was decided
|
|
1903
|
+
// outside the project, so unique project ownership is
|
|
1904
|
+
// not identity evidence. Visible, never excluded
|
|
1905
|
+
// (external generic identity functions can return
|
|
1906
|
+
// project values).
|
|
1907
|
+
if (call.receiverExternalFlow) {
|
|
1908
|
+
routeUnverified(filePath, fileEntry, call, 'possible-dispatch', calledAs, {
|
|
1909
|
+
dispatchVia: call.receiverExternalFlow,
|
|
1910
|
+
externalContract: true,
|
|
1911
|
+
});
|
|
1912
|
+
continue;
|
|
1913
|
+
}
|
|
1914
|
+
// Unresolvable type-name identity (fix #206): the
|
|
1915
|
+
// receiver is typed with a name several distinct types
|
|
1916
|
+
// share, and none resolves from this file's scope —
|
|
1917
|
+
// visible ambiguous, not confirmable receiver evidence.
|
|
1918
|
+
if (receiverTypeUnresolved) {
|
|
1919
|
+
routeUnverified(filePath, fileEntry, call, 'method-ambiguous', calledAs, {
|
|
1920
|
+
dispatchCandidates: methodOwnerKeys().size,
|
|
1921
|
+
});
|
|
1922
|
+
continue;
|
|
1923
|
+
}
|
|
1924
|
+
// Type-qualified path calls naming a NON-target type
|
|
1925
|
+
// (fix #222, seed-C-measured — the #220(2) fallback
|
|
1926
|
+
// only ran with multiple same-name definitions, so
|
|
1927
|
+
// single-owner names bypassed the whole discipline):
|
|
1928
|
+
// `Vec::<String>::new()` inside assert_eq! names std's
|
|
1929
|
+
// Vec — same-package scope cannot make it the project
|
|
1930
|
+
// `new`. Generic-param receivers (`T::zero()` — T is
|
|
1931
|
+
// instantiable with ANY type satisfying its bound)
|
|
1932
|
+
// route VISIBLE, never excluded; concrete non-target
|
|
1933
|
+
// type names are compiler-grade evidence for a
|
|
1934
|
+
// different type. `Self` keeps its current scope
|
|
1935
|
+
// resolution (a true same-impl call). Alias-qualified
|
|
1936
|
+
// receivers are in the #208-closed tTypes and never
|
|
1937
|
+
// reach here.
|
|
1938
|
+
if (call.isPathCall && call.receiver &&
|
|
1939
|
+
/^[A-Z]/.test(call.receiver) && call.receiver !== 'Self') {
|
|
1940
|
+
if (/^[A-Z][A-Z0-9]?$/.test(call.receiver) &&
|
|
1941
|
+
!(index.symbols.get(call.receiver) || []).some(d => IDENTITY_TYPE_KINDS.has(d.type))) {
|
|
1942
|
+
routeUnverified(filePath, fileEntry, call, 'method-ambiguous', calledAs, {
|
|
1943
|
+
dispatchCandidates: methodOwnerKeys().size,
|
|
1944
|
+
});
|
|
1945
|
+
continue;
|
|
1946
|
+
}
|
|
1947
|
+
recordExcluded(filePath, call.line, 'path-type-mismatch');
|
|
1948
|
+
continue;
|
|
1949
|
+
}
|
|
1950
|
+
const knownDispatchType = call.receiverType || fieldHopType || fieldDispatchType;
|
|
1951
|
+
if (knownDispatchType && !tTypes.has(knownDispatchType)) {
|
|
1952
|
+
routeUnverified(filePath, fileEntry, call, 'possible-dispatch', calledAs, {
|
|
1953
|
+
dispatchVia: knownDispatchType,
|
|
1954
|
+
dispatchCandidates: countDispatchCandidates(knownDispatchType),
|
|
1955
|
+
});
|
|
1956
|
+
continue;
|
|
1957
|
+
}
|
|
1958
|
+
if (!knownDispatchType && methodOwnerKeys().size > 1) {
|
|
1959
|
+
routeUnverified(filePath, fileEntry, call, 'method-ambiguous', calledAs, {
|
|
1960
|
+
dispatchCandidates: methodOwnerKeys().size,
|
|
1961
|
+
});
|
|
1962
|
+
continue;
|
|
1963
|
+
}
|
|
1964
|
+
// Single project-wide owner, but the method provably
|
|
1965
|
+
// implements an EXTERNAL contract (fix #210): the
|
|
1966
|
+
// receiver could be any external subtype
|
|
1967
|
+
// (((Long) obj).intValue() vs LazilyParsedNumber's
|
|
1968
|
+
// @Override intValue) — unique ownership is not
|
|
1969
|
+
// identity evidence here. Visible, never excluded.
|
|
1970
|
+
const extContract = !knownDispatchType && externalContractTarget();
|
|
1971
|
+
if (extContract) {
|
|
1972
|
+
routeUnverified(filePath, fileEntry, call, 'possible-dispatch', calledAs, {
|
|
1973
|
+
...(extContract.via && { dispatchVia: extContract.via }),
|
|
1974
|
+
externalContract: true,
|
|
1975
|
+
});
|
|
1976
|
+
continue;
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
// Sibling-impl overload ambiguity (fix #220, cursive-measured —
|
|
1982
|
+
// the #205 jdtls insight for languages WITHOUT arity-overload
|
|
1983
|
+
// discipline): Rust defines same-name methods on the SAME type
|
|
1984
|
+
// across impl blocks (`impl From<Color> for ColorStyle` ×4;
|
|
1985
|
+
// `impl Rgb<f32>` vs `impl Rgb<u8>` both with as_color).
|
|
1986
|
+
// Class-level receiver evidence — type-qualified path calls,
|
|
1987
|
+
// name-validated receiver types — proves "some ColorStyle::from",
|
|
1988
|
+
// never the pinned one; with an arity-indistinguishable
|
|
1989
|
+
// same-class sibling the call routes visible. Alias-qualified
|
|
1990
|
+
// receivers are exempt: `StyledString::plain` names ONE
|
|
1991
|
+
// instantiation by construction (#208 — the alias carries the
|
|
1992
|
+
// type argument even though UCN's closure is name-level).
|
|
1993
|
+
// Go cannot compile same-class same-name siblings; Java runs
|
|
1994
|
+
// its own #205 argKinds discipline (hasArityOverloads).
|
|
1995
|
+
if (collectAccount && call.isMethod && !resolvedBySameClass &&
|
|
1996
|
+
(!bindingId || receiverBlindBinding) &&
|
|
1997
|
+
langTraits(fileEntry.language)?.typeSystem === 'nominal' &&
|
|
1998
|
+
!langTraits(fileEntry.language)?.hasArityOverloads &&
|
|
1999
|
+
options.targetDefinitions && options.targetDefinitions.length > 0 &&
|
|
2000
|
+
!(call.receiver && (index.symbols.get(call.receiver) || []).some(d => d.aliasOf))) {
|
|
2001
|
+
const pinnedCallable = options.targetDefinitions.filter(d => !NON_CALLABLE_TYPES.has(d.type));
|
|
2002
|
+
const pinnedClasses = new Set(pinnedCallable
|
|
2003
|
+
.map(d => d.className || (d.receiver || '').replace(/^\*/, ''))
|
|
2004
|
+
.filter(Boolean));
|
|
2005
|
+
if (pinnedClasses.size > 0) {
|
|
2006
|
+
const pinnedKeys = new Set(pinnedCallable.map(d => `${d.file}:${d.startLine}`));
|
|
2007
|
+
// Same-FILE constraint: a same-name class in another
|
|
2008
|
+
// package is a DIFFERENT type, not a sibling impl
|
|
2009
|
+
// (Go's per-package `bb` structs). The measured Rust
|
|
2010
|
+
// families (From impls, generic instantiations) live
|
|
2011
|
+
// in the type's own file.
|
|
2012
|
+
const pinnedFiles = new Set(pinnedCallable.map(d => d.file).filter(Boolean));
|
|
2013
|
+
const sibling = definitions.find(d =>
|
|
2014
|
+
!NON_CALLABLE_TYPES.has(d.type) &&
|
|
2015
|
+
pinnedClasses.has(d.className || (d.receiver || '').replace(/^\*/, '')) &&
|
|
2016
|
+
pinnedFiles.has(d.file) &&
|
|
2017
|
+
!pinnedKeys.has(`${d.file}:${d.startLine}`) &&
|
|
2018
|
+
_callArityCompatible(call, [d], fileEntry.language));
|
|
2019
|
+
if (sibling) {
|
|
2020
|
+
routeUnverified(filePath, fileEntry, call, 'overload-ambiguous', calledAs, {
|
|
2021
|
+
dispatchCandidates: definitions.filter(d =>
|
|
2022
|
+
!NON_CALLABLE_TYPES.has(d.type) &&
|
|
2023
|
+
pinnedClasses.has(d.className || (d.receiver || '').replace(/^\*/, ''))).length,
|
|
2024
|
+
});
|
|
2025
|
+
continue;
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
// Structural dispatch tiering (fix #209, httpx-measured — the
|
|
2031
|
+
// #204 discipline applied to structural languages): file-level
|
|
2032
|
+
// import/scope evidence speaks for a bare NAME reaching this
|
|
2033
|
+
// file, not for a method call's receiver — `key.decode(enc)`
|
|
2034
|
+
// in a file that imports _decoders.py is bytes.decode, not
|
|
2035
|
+
// ContentDecoder.decode. An untyped-receiver method call
|
|
2036
|
+
// confirms only via binding, same-class, a validated receiver
|
|
2037
|
+
// type, a type-qualified receiver (Class.method static style),
|
|
2038
|
+
// or a single project-wide owner. Multi-owner name matches
|
|
2039
|
+
// route VISIBLE method-ambiguous — never dropped. Same for a
|
|
2040
|
+
// bare call against pure method targets (a bare name cannot
|
|
2041
|
+
// denote a method in JS/TS/Python — only a rebound alias can,
|
|
2042
|
+
// which has no evidence here either).
|
|
2043
|
+
if (collectAccount && (!bindingId || receiverBlindBinding) && !resolvedBySameClass &&
|
|
2044
|
+
!receiverTypeValidated &&
|
|
2045
|
+
langTraits(fileEntry.language)?.typeSystem === 'structural') {
|
|
2046
|
+
// Module-qualified calls (z.string(), ns.helper()) are
|
|
2047
|
+
// exempt: the module IS name-level evidence, and the
|
|
2048
|
+
// module-ownership block above already routed the ones
|
|
2049
|
+
// whose module doesn't reach the target.
|
|
2050
|
+
if (call.isMethod && !call.receiverIsModule) {
|
|
2051
|
+
const tTypes = dispatchTargetTypes(targetDefs2);
|
|
2052
|
+
const typeQualifiedReceiver = !!(call.receiver && tTypes.has(call.receiver));
|
|
2053
|
+
// External-producer receiver (fix #222, httpx-measured
|
|
2054
|
+
// — the #220 Go rule for structural languages): the
|
|
2055
|
+
// variable was assigned from a call into an external
|
|
2056
|
+
// module (logger = logging.getLogger(...)), so its
|
|
2057
|
+
// type was decided outside the project and unique
|
|
2058
|
+
// project ownership is not identity evidence.
|
|
2059
|
+
// Visible, never excluded.
|
|
2060
|
+
if (!typeQualifiedReceiver && call.receiverExternalFlow) {
|
|
2061
|
+
routeUnverified(filePath, fileEntry, call, 'possible-dispatch', calledAs, {
|
|
2062
|
+
dispatchVia: call.receiverExternalFlow,
|
|
2063
|
+
externalContract: true,
|
|
2064
|
+
});
|
|
2065
|
+
continue;
|
|
2066
|
+
}
|
|
2067
|
+
// A method call cannot denote a standalone function
|
|
2068
|
+
// (fix #218, rich-measured: `console.print(...)`
|
|
2069
|
+
// confirmed scope-match against module-level print):
|
|
2070
|
+
// only an attribute assignment could rebind one onto a
|
|
2071
|
+
// receiver, which is beyond name-level evidence. Typed
|
|
2072
|
+
// receivers are excluded above (#198); untyped ones
|
|
2073
|
+
// route visible. Module receivers stay exempt
|
|
2074
|
+
// (rich.print(...) IS the module function).
|
|
2075
|
+
if (!typeQualifiedReceiver && targetDefs2.length > 0 &&
|
|
2076
|
+
targetDefs2.every(d => !d.className && !d.receiver)) {
|
|
2077
|
+
routeUnverified(filePath, fileEntry, call, 'method-ambiguous', calledAs, {
|
|
2078
|
+
dispatchCandidates: methodOwnerKeys().size,
|
|
2079
|
+
});
|
|
2080
|
+
continue;
|
|
2081
|
+
}
|
|
2082
|
+
// External-contract single owner (fix #210): same
|
|
2083
|
+
// physics as the nominal gate above — an override
|
|
2084
|
+
// marker proves the name exists on a contract UCN
|
|
2085
|
+
// cannot see, so the receiver could be any external
|
|
2086
|
+
// subtype. Checked before the multi-owner branch
|
|
2087
|
+
// only via owner count (===1) being its precondition.
|
|
2088
|
+
const extContract = !typeQualifiedReceiver &&
|
|
2089
|
+
externalContractTarget();
|
|
2090
|
+
if (extContract) {
|
|
2091
|
+
routeUnverified(filePath, fileEntry, call, 'possible-dispatch', calledAs, {
|
|
2092
|
+
...(extContract.via && { dispatchVia: extContract.via }),
|
|
2093
|
+
externalContract: true,
|
|
2094
|
+
});
|
|
2095
|
+
continue;
|
|
2096
|
+
}
|
|
2097
|
+
if (!typeQualifiedReceiver && methodOwnerKeys().size > 1) {
|
|
2098
|
+
const knownDispatchType = call.receiverType || fieldHopType || fieldDispatchType;
|
|
2099
|
+
if (knownDispatchType) {
|
|
2100
|
+
// Known-but-unvalidated type (supertype of the
|
|
2101
|
+
// target — dynamic dispatch — or an alias/
|
|
2102
|
+
// interface name UCN can't validate, or a
|
|
2103
|
+
// declared-field hop type, fix #219): a
|
|
2104
|
+
// possible dispatch edge, attributed via the
|
|
2105
|
+
// receiver's declared type (#204 physics).
|
|
2106
|
+
routeUnverified(filePath, fileEntry, call, 'possible-dispatch', calledAs, {
|
|
2107
|
+
dispatchVia: knownDispatchType,
|
|
2108
|
+
dispatchCandidates: countDispatchCandidates(knownDispatchType),
|
|
2109
|
+
});
|
|
2110
|
+
} else {
|
|
2111
|
+
routeUnverified(filePath, fileEntry, call, 'method-ambiguous', calledAs, {
|
|
2112
|
+
dispatchCandidates: methodOwnerKeys().size,
|
|
2113
|
+
});
|
|
2114
|
+
}
|
|
2115
|
+
continue;
|
|
2116
|
+
}
|
|
2117
|
+
} else if (!calledAs && !call.isConstructor &&
|
|
2118
|
+
targetDefs2.length > 0 && targetDefs2.every(d => d.className)) {
|
|
2119
|
+
routeUnverified(filePath, fileEntry, call, 'method-ambiguous', calledAs, {
|
|
2120
|
+
dispatchCandidates: methodOwnerKeys().size,
|
|
2121
|
+
});
|
|
2122
|
+
continue;
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
if (!pendingByFile.has(filePath)) pendingByFile.set(filePath, []);
|
|
2127
|
+
pendingByFile.get(filePath).push({
|
|
2128
|
+
call, fileEntry, callerSymbol,
|
|
2129
|
+
isMethod: call.isMethod || false,
|
|
2130
|
+
// Function references can resolve through the plain binding
|
|
2131
|
+
// path too (e.g. JS `arr.map(helper)` with a local binding) —
|
|
2132
|
+
// surface the parser's marker on the edge (fix #221).
|
|
2133
|
+
isFunctionReference: !!call.isFunctionReference,
|
|
2134
|
+
receiver: call.receiver,
|
|
2135
|
+
receiverType: call.receiverType,
|
|
2136
|
+
calledAs,
|
|
2137
|
+
_evidence: {
|
|
2138
|
+
hasBindingId: !!bindingId,
|
|
2139
|
+
resolvedBySameClass: !!resolvedBySameClass,
|
|
2140
|
+
hasSamePackageEvidence,
|
|
2141
|
+
// Method calls where binding resolution was skipped (non-self receiver)
|
|
2142
|
+
// and the receiver has no binding evidence → uncertain (JS/TS/Python only)
|
|
2143
|
+
isUncertain: !!isUncertain || uncertainMethodReceiver,
|
|
2144
|
+
hasReceiverType: langTraits(fileEntry.language)?.typeSystem === 'structural'
|
|
2145
|
+
? receiverTypeValidated
|
|
2146
|
+
: !!call.receiverType,
|
|
2147
|
+
hasReceiverEvidence: !!(call.receiver &&
|
|
2148
|
+
(fileEntry.bindings || []).some(b => b.name === call.receiver)),
|
|
2149
|
+
hasImportEvidence: !!bindingId || hasImportLink,
|
|
2150
|
+
...(typeMismatch && { typeMismatch: true }),
|
|
2151
|
+
}
|
|
2152
|
+
});
|
|
2153
|
+
pendingCount++;
|
|
2154
|
+
}
|
|
2155
|
+
} catch (e) {
|
|
2156
|
+
// Expected: minified files exceed tree-sitter buffer, binary files fail to parse.
|
|
2157
|
+
// These are not actionable errors — silently skip.
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
// True total candidate count from Phase 1 (before any Phase 2 truncation).
|
|
2162
|
+
// Used by callers that need accurate "showing N of <total>" headers.
|
|
2163
|
+
const totalCount = pendingCount;
|
|
2164
|
+
// When needsTotal is set with a maxResults cap, only enrich the first
|
|
2165
|
+
// `maxResults` candidates in Phase 2 — file reads stay bounded.
|
|
2166
|
+
const enrichLimit = (needsTotal && maxResults) ? maxResults : Infinity;
|
|
2167
|
+
let enrichedCount = 0;
|
|
2168
|
+
|
|
2169
|
+
// BUG-H1: shadow records for un-enriched candidates so post-call filters
|
|
2170
|
+
// (exclude / minConfidence) can produce an accurate total without forcing
|
|
2171
|
+
// a Phase-2 file read for every candidate. Each shadow has just enough
|
|
2172
|
+
// info to drive the filter predicates: relativePath + confidence.
|
|
2173
|
+
const shadowEntries = [];
|
|
2174
|
+
// Unverified-tier entries (collectAccount only): retained drops, rendered
|
|
2175
|
+
// in their own section. First `unverifiedEnrichLimit` get content + caller
|
|
2176
|
+
// lookup; the rest stay shadow-style (file/line/reason only).
|
|
2177
|
+
const unverifiedEntries = [];
|
|
2178
|
+
let unverifiedEnriched = 0;
|
|
2179
|
+
|
|
2180
|
+
// Phase 2: Read content only for files with matching calls (eliminates ~98% of file reads)
|
|
2181
|
+
outer: for (const [filePath, pending] of pendingByFile) {
|
|
2182
|
+
let content = null;
|
|
2183
|
+
for (const { call, fileEntry, callerSymbol, isMethod, isFunctionReference, receiver, receiverType, calledAs, _evidence, _tier, _reason, _meta } of pending) {
|
|
2184
|
+
const scored = scoreEdge(_evidence || {});
|
|
2185
|
+
// Family B contract field (fix #221): a bind/call/apply site reaches
|
|
2186
|
+
// the target through Function.prototype indirection, not direct call
|
|
2187
|
+
// syntax — label the edge calledAs:'bound'. Rename aliases keep their
|
|
2188
|
+
// surface name (they describe the same slot and are rarer). Label
|
|
2189
|
+
// only, computed at edge construction: routing logic never sees it.
|
|
2190
|
+
const edgeCalledAs = calledAs || (call.boundCall ? 'bound' : undefined);
|
|
2191
|
+
if (_tier) {
|
|
2192
|
+
// Routed unverified entry — never competes with the main
|
|
2193
|
+
// answer for maxResults/enrichLimit slots.
|
|
2194
|
+
const base = {
|
|
2195
|
+
file: filePath,
|
|
2196
|
+
relativePath: fileEntry.relativePath,
|
|
2197
|
+
line: call.line,
|
|
2198
|
+
confidence: scored.confidence,
|
|
2199
|
+
resolution: scored.resolution,
|
|
2200
|
+
tier: _tier,
|
|
2201
|
+
reason: _reason,
|
|
2202
|
+
...(_meta || {}),
|
|
2203
|
+
isMethod: call.isMethod || false,
|
|
2204
|
+
...(isFunctionReference && { isFunctionReference: true }),
|
|
2205
|
+
...(receiver !== undefined && { receiver }),
|
|
2206
|
+
...(receiverType && { receiverType }),
|
|
2207
|
+
...(edgeCalledAs && { calledAs: edgeCalledAs }),
|
|
2208
|
+
};
|
|
2209
|
+
if (unverifiedEnriched < unverifiedEnrichLimit) {
|
|
2210
|
+
if (content === null) {
|
|
2211
|
+
try { content = fs.readFileSync(filePath, 'utf-8'); }
|
|
2212
|
+
catch (e) { content = ''; }
|
|
2213
|
+
}
|
|
2214
|
+
const enclosing = index.findEnclosingFunction(filePath, call.line, true);
|
|
2215
|
+
unverifiedEntries.push({
|
|
2216
|
+
...base,
|
|
2217
|
+
content: getLine(content, call.line),
|
|
2218
|
+
callerName: enclosing ? enclosing.name : null,
|
|
2219
|
+
callerFile: enclosing ? filePath : null,
|
|
2220
|
+
callerStartLine: enclosing ? enclosing.startLine : null,
|
|
2221
|
+
callerEndLine: enclosing ? enclosing.endLine : null,
|
|
2222
|
+
});
|
|
2223
|
+
unverifiedEnriched++;
|
|
2224
|
+
} else {
|
|
2225
|
+
unverifiedEntries.push(base);
|
|
2226
|
+
}
|
|
2227
|
+
continue;
|
|
2228
|
+
}
|
|
2229
|
+
// Tier stamped ONLY under collectAccount so trace/blast/verify
|
|
2230
|
+
// results stay byte-identical. A known type mismatch can never
|
|
2231
|
+
// tier as confirmed, whatever its resolution score says.
|
|
2232
|
+
const tier = collectAccount
|
|
2233
|
+
? (_evidence && _evidence.typeMismatch ? TIER.UNVERIFIED : tierForResolution(scored.resolution))
|
|
2234
|
+
: undefined;
|
|
2235
|
+
if (enrichedCount >= enrichLimit) {
|
|
2236
|
+
// Push shadow only — no file read needed.
|
|
2237
|
+
shadowEntries.push({
|
|
2238
|
+
file: filePath,
|
|
2239
|
+
relativePath: fileEntry.relativePath,
|
|
2240
|
+
line: call.line,
|
|
2241
|
+
confidence: scored.confidence,
|
|
2242
|
+
resolution: scored.resolution,
|
|
2243
|
+
...(tier && { tier }),
|
|
2244
|
+
isMethod: call.isMethod || false,
|
|
2245
|
+
...(isFunctionReference && { isFunctionReference: true }),
|
|
2246
|
+
...(receiver !== undefined && { receiver }),
|
|
2247
|
+
...(receiverType && { receiverType }),
|
|
2248
|
+
...(edgeCalledAs && { calledAs: edgeCalledAs }),
|
|
2249
|
+
});
|
|
2250
|
+
continue;
|
|
2251
|
+
}
|
|
2252
|
+
// First time we hit this file's enrichment loop — read the file once.
|
|
686
2253
|
if (content === null) {
|
|
687
2254
|
try { content = fs.readFileSync(filePath, 'utf-8'); }
|
|
688
2255
|
catch (e) { content = ''; /* deleted/unreadable; skip enrichment for rest */ break; }
|
|
@@ -700,8 +2267,10 @@ function findCallers(index, name, options = {}) {
|
|
|
700
2267
|
...(isFunctionReference && { isFunctionReference: true }),
|
|
701
2268
|
...(receiver !== undefined && { receiver }),
|
|
702
2269
|
...(receiverType && { receiverType }),
|
|
2270
|
+
...(edgeCalledAs && { calledAs: edgeCalledAs }),
|
|
703
2271
|
confidence: scored.confidence,
|
|
704
2272
|
resolution: scored.resolution,
|
|
2273
|
+
...(tier && { tier }),
|
|
705
2274
|
});
|
|
706
2275
|
enrichedCount++;
|
|
707
2276
|
}
|
|
@@ -724,6 +2293,29 @@ function findCallers(index, name, options = {}) {
|
|
|
724
2293
|
writable: true,
|
|
725
2294
|
configurable: true,
|
|
726
2295
|
});
|
|
2296
|
+
// Conservation raw data (collectAccount only): dropped-candidate lines with
|
|
2297
|
+
// reasons, consumed by composeAccount in analysis.js. Non-enumerable so
|
|
2298
|
+
// JSON.stringify of results is unaffected.
|
|
2299
|
+
if (accountRaw) {
|
|
2300
|
+
Object.defineProperty(callers, 'accountRaw', {
|
|
2301
|
+
value: accountRaw,
|
|
2302
|
+
enumerable: false,
|
|
2303
|
+
writable: true,
|
|
2304
|
+
configurable: true,
|
|
2305
|
+
});
|
|
2306
|
+
// Retained unverified-tier entries, sorted (relativePath, line) per the
|
|
2307
|
+
// output ordering contract.
|
|
2308
|
+
unverifiedEntries.sort((a, b) => {
|
|
2309
|
+
if (a.relativePath !== b.relativePath) return a.relativePath.localeCompare(b.relativePath);
|
|
2310
|
+
return (a.line || 0) - (b.line || 0);
|
|
2311
|
+
});
|
|
2312
|
+
Object.defineProperty(callers, 'unverifiedEntries', {
|
|
2313
|
+
value: unverifiedEntries,
|
|
2314
|
+
enumerable: false,
|
|
2315
|
+
writable: true,
|
|
2316
|
+
configurable: true,
|
|
2317
|
+
});
|
|
2318
|
+
}
|
|
727
2319
|
|
|
728
2320
|
return callers;
|
|
729
2321
|
} finally { index._endOp(); }
|
|
@@ -774,6 +2366,66 @@ function findCallees(index, def, options = {}) {
|
|
|
774
2366
|
let selfAttrCalls = null; // collected for Python self.attr.method() resolution
|
|
775
2367
|
let selfMethodCalls = null; // collected for Python self.method() resolution
|
|
776
2368
|
|
|
2369
|
+
// Callee conservation account (trace-down contract): every call record
|
|
2370
|
+
// in the def's scope lands in exactly one bucket — confirmed callee
|
|
2371
|
+
// edge, retained unverified entry (visible, with reason), external/
|
|
2372
|
+
// builtin, excluded-with-reason, or display-filtered. The unit is the
|
|
2373
|
+
// call RECORD (a line may hold several); siteIds (record ordinals)
|
|
2374
|
+
// keep the arithmetic exact when one record yields multiple edges
|
|
2375
|
+
// (same-name overload fan-out). collectAccount-gated: legacy callers
|
|
2376
|
+
// of findCallees (context/about/smart) see byte-identical results.
|
|
2377
|
+
const collectAccount = !!options.collectAccount;
|
|
2378
|
+
const calleeAccount = collectAccount ? {
|
|
2379
|
+
totalSites: 0,
|
|
2380
|
+
confirmed: 0,
|
|
2381
|
+
unverified: 0,
|
|
2382
|
+
external: { count: 0, sample: [] },
|
|
2383
|
+
excluded: { total: 0, byReason: {} },
|
|
2384
|
+
filtered: { count: 0, byReason: {} },
|
|
2385
|
+
} : null;
|
|
2386
|
+
const claimedSiteIds = collectAccount ? new Set() : null;
|
|
2387
|
+
const unverifiedCallees = collectAccount ? new Map() : null; // name|reason -> entry
|
|
2388
|
+
const noteSite = (siteId, bucket, reason, call) => {
|
|
2389
|
+
if (!calleeAccount || claimedSiteIds.has(siteId)) return;
|
|
2390
|
+
claimedSiteIds.add(siteId);
|
|
2391
|
+
if (bucket === 'confirmed') {
|
|
2392
|
+
calleeAccount.confirmed++;
|
|
2393
|
+
} else if (bucket === 'unverified') {
|
|
2394
|
+
calleeAccount.unverified++;
|
|
2395
|
+
} else if (bucket === 'external') {
|
|
2396
|
+
calleeAccount.external.count++;
|
|
2397
|
+
if (call && calleeAccount.external.sample.length < 3) {
|
|
2398
|
+
calleeAccount.external.sample.push({ name: call.name, line: call.line });
|
|
2399
|
+
}
|
|
2400
|
+
} else if (bucket === 'excluded') {
|
|
2401
|
+
const r = reason || 'excluded';
|
|
2402
|
+
calleeAccount.excluded.total++;
|
|
2403
|
+
if (!calleeAccount.excluded.byReason[r]) calleeAccount.excluded.byReason[r] = 0;
|
|
2404
|
+
calleeAccount.excluded.byReason[r]++;
|
|
2405
|
+
} else if (bucket === 'filtered') {
|
|
2406
|
+
const r = reason || 'filtered';
|
|
2407
|
+
calleeAccount.filtered.count++;
|
|
2408
|
+
if (!calleeAccount.filtered.byReason[r]) calleeAccount.filtered.byReason[r] = 0;
|
|
2409
|
+
calleeAccount.filtered.byReason[r]++;
|
|
2410
|
+
}
|
|
2411
|
+
};
|
|
2412
|
+
// Retain an uncertain/unresolved call as a visible unverified callee
|
|
2413
|
+
// entry (aggregated by name+reason) and claim its site.
|
|
2414
|
+
const noteUnverified = (siteId, call, reason) => {
|
|
2415
|
+
if (!collectAccount || claimedSiteIds.has(siteId)) return;
|
|
2416
|
+
noteSite(siteId, 'unverified', reason, call);
|
|
2417
|
+
const key = `${call.name}|${reason}`;
|
|
2418
|
+
let entry = unverifiedCallees.get(key);
|
|
2419
|
+
if (!entry) {
|
|
2420
|
+
const defs = index.symbols.get(call.name) || [];
|
|
2421
|
+
const owners = defs.filter(s => !NON_CALLABLE_TYPES.has(s.type)).length;
|
|
2422
|
+
entry = { name: call.name, reason, callCount: 0, sites: [], ownerCount: owners };
|
|
2423
|
+
unverifiedCallees.set(key, entry);
|
|
2424
|
+
}
|
|
2425
|
+
entry.callCount++;
|
|
2426
|
+
entry.sites.push(call.line);
|
|
2427
|
+
};
|
|
2428
|
+
|
|
777
2429
|
// Build local variable type map for receiver resolution
|
|
778
2430
|
// Scans for patterns like: bt = Backtester(...) → bt maps to Backtester
|
|
779
2431
|
let localTypes = null;
|
|
@@ -783,7 +2435,10 @@ function findCallees(index, def, options = {}) {
|
|
|
783
2435
|
localTypes = _buildTypedLocalTypeMap(index, def, calls);
|
|
784
2436
|
}
|
|
785
2437
|
|
|
2438
|
+
let siteOrdinal = -1;
|
|
786
2439
|
for (const call of calls) {
|
|
2440
|
+
siteOrdinal++;
|
|
2441
|
+
const siteId = siteOrdinal;
|
|
787
2442
|
// Filter to calls within this function's scope
|
|
788
2443
|
// Method 1: Direct match via enclosingFunction (fast path for direct calls)
|
|
789
2444
|
const isDirectMatch = call.enclosingFunction &&
|
|
@@ -796,6 +2451,7 @@ function findCallees(index, def, options = {}) {
|
|
|
796
2451
|
const isNestedCallback = isInRange && !isInInnerSymbol && !isDirectMatch;
|
|
797
2452
|
|
|
798
2453
|
if (!isDirectMatch && !isNestedCallback) continue;
|
|
2454
|
+
if (calleeAccount) calleeAccount.totalSites++;
|
|
799
2455
|
|
|
800
2456
|
// Smart method call handling:
|
|
801
2457
|
// - Go: include all method calls (Go doesn't use this/self/cls)
|
|
@@ -839,9 +2495,15 @@ function findCallees(index, def, options = {}) {
|
|
|
839
2495
|
const existing = callees.get(key);
|
|
840
2496
|
if (existing) {
|
|
841
2497
|
existing.count += 1;
|
|
2498
|
+
if (collectAccount) { existing.sites.push(call.line); existing.siteIds.push(siteId); }
|
|
842
2499
|
} else {
|
|
843
|
-
callees.set(key, { name: call.name, bindingId: match.bindingId, count: 1
|
|
2500
|
+
callees.set(key, { name: call.name, bindingId: match.bindingId, count: 1,
|
|
2501
|
+
...(collectAccount && { sites: [call.line], siteIds: [siteId] }) });
|
|
844
2502
|
}
|
|
2503
|
+
} else if (collectAccount) {
|
|
2504
|
+
// Locally-typed receiver, but the type defines no such
|
|
2505
|
+
// method in the index — visible, never silently dropped.
|
|
2506
|
+
noteUnverified(siteId, call, 'uncertain-receiver');
|
|
845
2507
|
}
|
|
846
2508
|
continue;
|
|
847
2509
|
} else if (call.receiverType) {
|
|
@@ -875,8 +2537,10 @@ function findCallees(index, def, options = {}) {
|
|
|
875
2537
|
const existing = callees.get(key);
|
|
876
2538
|
if (existing) {
|
|
877
2539
|
existing.count += 1;
|
|
2540
|
+
if (collectAccount) { existing.sites.push(call.line); existing.siteIds.push(siteId); }
|
|
878
2541
|
} else {
|
|
879
|
-
callees.set(key, { name: call.name, bindingId: match.bindingId, count: 1
|
|
2542
|
+
callees.set(key, { name: call.name, bindingId: match.bindingId, count: 1,
|
|
2543
|
+
...(collectAccount && { sites: [call.line], siteIds: [siteId] }) });
|
|
880
2544
|
}
|
|
881
2545
|
continue;
|
|
882
2546
|
}
|
|
@@ -924,22 +2588,29 @@ function findCallees(index, def, options = {}) {
|
|
|
924
2588
|
const existing = callees.get(key);
|
|
925
2589
|
if (existing) {
|
|
926
2590
|
existing.count += 1;
|
|
2591
|
+
if (collectAccount) { existing.sites.push(call.line); existing.siteIds.push(siteId); }
|
|
927
2592
|
} else {
|
|
928
|
-
callees.set(key, { name: call.name, bindingId: match.bindingId, count: 1
|
|
2593
|
+
callees.set(key, { name: call.name, bindingId: match.bindingId, count: 1,
|
|
2594
|
+
...(collectAccount && { sites: [call.line], siteIds: [siteId] }) });
|
|
929
2595
|
}
|
|
930
2596
|
continue;
|
|
931
2597
|
}
|
|
932
2598
|
}
|
|
933
2599
|
// Import resolved but no project definition matches — external call, skip
|
|
2600
|
+
noteSite(siteId, 'external', null, call);
|
|
934
2601
|
continue;
|
|
935
2602
|
}
|
|
936
2603
|
} else if (langTraits(language)?.methodCallInclusion === 'explicit' && !options.includeMethods) {
|
|
2604
|
+
noteSite(siteId, 'filtered', 'method-calls-excluded', call);
|
|
937
2605
|
continue;
|
|
938
2606
|
}
|
|
939
2607
|
}
|
|
940
2608
|
|
|
941
2609
|
// Skip keywords and built-ins
|
|
942
|
-
if (index.isKeyword(call.name, language))
|
|
2610
|
+
if (index.isKeyword(call.name, language)) {
|
|
2611
|
+
noteSite(siteId, 'external', null, call);
|
|
2612
|
+
continue;
|
|
2613
|
+
}
|
|
943
2614
|
|
|
944
2615
|
// Use resolved name (from alias tracking) if available
|
|
945
2616
|
// For multi-target aliases (ternary), pick the first that exists in symbol table
|
|
@@ -959,11 +2630,15 @@ function findCallees(index, def, options = {}) {
|
|
|
959
2630
|
const syms = index.symbols.get(effectiveName);
|
|
960
2631
|
if (!syms || !syms.some(s =>
|
|
961
2632
|
['function', 'method', 'constructor', 'static', 'public', 'abstract'].includes(s.type))) {
|
|
2633
|
+
// Argument-position name with no function definition — a
|
|
2634
|
+
// local variable or data, positively not a callee edge.
|
|
2635
|
+
noteSite(siteId, 'excluded', 'callback-no-evidence', call);
|
|
962
2636
|
continue;
|
|
963
2637
|
}
|
|
964
2638
|
const hasBinding = fileEntry?.bindings?.some(b => b.name === call.name);
|
|
965
2639
|
const inSameFile = syms.some(s => s.file === def.file);
|
|
966
2640
|
if (!hasBinding && !inSameFile) {
|
|
2641
|
+
noteSite(siteId, 'excluded', 'callback-no-evidence', call);
|
|
967
2642
|
continue;
|
|
968
2643
|
}
|
|
969
2644
|
}
|
|
@@ -971,21 +2646,21 @@ function findCallees(index, def, options = {}) {
|
|
|
971
2646
|
// Collect selfAttribute calls for second-pass resolution
|
|
972
2647
|
if (call.selfAttribute && language === 'python') {
|
|
973
2648
|
if (!selfAttrCalls) selfAttrCalls = [];
|
|
974
|
-
selfAttrCalls.push(call);
|
|
2649
|
+
selfAttrCalls.push({ call, siteId });
|
|
975
2650
|
continue;
|
|
976
2651
|
}
|
|
977
2652
|
|
|
978
2653
|
// Collect self/this.method() calls for same-class resolution
|
|
979
2654
|
if (call.isMethod && ['self', 'cls', 'this'].includes(call.receiver)) {
|
|
980
2655
|
if (!selfMethodCalls) selfMethodCalls = [];
|
|
981
|
-
selfMethodCalls.push(call);
|
|
2656
|
+
selfMethodCalls.push({ call, siteId });
|
|
982
2657
|
continue;
|
|
983
2658
|
}
|
|
984
2659
|
|
|
985
2660
|
// Collect super().method() calls for parent-class resolution
|
|
986
2661
|
if (call.isMethod && call.receiver === 'super') {
|
|
987
2662
|
if (!selfMethodCalls) selfMethodCalls = [];
|
|
988
|
-
selfMethodCalls.push(call);
|
|
2663
|
+
selfMethodCalls.push({ call, siteId });
|
|
989
2664
|
continue;
|
|
990
2665
|
}
|
|
991
2666
|
|
|
@@ -993,6 +2668,7 @@ function findCallees(index, def, options = {}) {
|
|
|
993
2668
|
let calleeKey = call.bindingId || effectiveName;
|
|
994
2669
|
let bindingResolved = call.bindingId;
|
|
995
2670
|
let isUncertain = call.uncertain;
|
|
2671
|
+
let uncertainReason = null; // account-mode reason for the unverified bucket
|
|
996
2672
|
if (!call.bindingId && fileEntry?.bindings) {
|
|
997
2673
|
let bindings = fileEntry.bindings.filter(b => b.name === call.name);
|
|
998
2674
|
// For Go, also check sibling files in same directory (same package scope)
|
|
@@ -1043,9 +2719,11 @@ function findCallees(index, def, options = {}) {
|
|
|
1043
2719
|
bindingResolved = matchingDef.bindingId;
|
|
1044
2720
|
} else {
|
|
1045
2721
|
isUncertain = true;
|
|
2722
|
+
uncertainReason = 'method-ambiguous';
|
|
1046
2723
|
}
|
|
1047
2724
|
} else {
|
|
1048
2725
|
isUncertain = true;
|
|
2726
|
+
uncertainReason = 'method-ambiguous';
|
|
1049
2727
|
}
|
|
1050
2728
|
}
|
|
1051
2729
|
}
|
|
@@ -1072,22 +2750,49 @@ function findCallees(index, def, options = {}) {
|
|
|
1072
2750
|
} else if (bindings.length > 1) {
|
|
1073
2751
|
if (call.name === def.name) {
|
|
1074
2752
|
// Calling same-name function (e.g., Java overloads)
|
|
1075
|
-
// Add ALL other overloads as potential callees
|
|
1076
|
-
|
|
2753
|
+
// Add ALL other overloads as potential callees.
|
|
2754
|
+
// A RECEIVER-QUALIFIED same-name call names its type
|
|
2755
|
+
// (Rust `Patterns::from_low_args(...)`, Go `T.M(...)`)
|
|
2756
|
+
// — resolve to the matching class's binding instead of
|
|
2757
|
+
// spraying every same-name def (#223, ripgrep-measured
|
|
2758
|
+
// on the callee eval arm: HiArgs::from_low_args calls
|
|
2759
|
+
// three sibling types' from_low_args — every def was
|
|
2760
|
+
// claimed at all three sites). Variable receivers match
|
|
2761
|
+
// no class → unchanged full fan-out; bare calls (Java
|
|
2762
|
+
// implicit-this overloads) keep fanning out.
|
|
2763
|
+
let otherBindings = bindings.filter(b =>
|
|
1077
2764
|
b.startLine !== def.startLine
|
|
1078
2765
|
);
|
|
2766
|
+
const fanReceiver = call.receiver || call.receiverType;
|
|
2767
|
+
if (fanReceiver && otherBindings.length > 1) {
|
|
2768
|
+
const symsForName = index.symbols.get(call.name) || [];
|
|
2769
|
+
const classMatched = otherBindings.filter(b => {
|
|
2770
|
+
const bSym = symsForName.find(s => s.bindingId === b.id);
|
|
2771
|
+
const cls = bSym && (bSym.className ||
|
|
2772
|
+
(bSym.receiver && bSym.receiver.replace(/^\*/, '')));
|
|
2773
|
+
return cls === fanReceiver;
|
|
2774
|
+
});
|
|
2775
|
+
if (classMatched.length > 0) otherBindings = classMatched;
|
|
2776
|
+
}
|
|
1079
2777
|
for (const ob of otherBindings) {
|
|
1080
2778
|
const existing = callees.get(ob.id);
|
|
1081
2779
|
if (existing) {
|
|
1082
2780
|
existing.count += 1;
|
|
2781
|
+
if (collectAccount) { existing.sites.push(call.line); existing.siteIds.push(siteId); }
|
|
1083
2782
|
} else {
|
|
1084
2783
|
callees.set(ob.id, {
|
|
1085
2784
|
name: effectiveName,
|
|
1086
2785
|
bindingId: ob.id,
|
|
1087
|
-
count: 1
|
|
2786
|
+
count: 1,
|
|
2787
|
+
...(collectAccount && { sites: [call.line], siteIds: [siteId] })
|
|
1088
2788
|
});
|
|
1089
2789
|
}
|
|
1090
2790
|
}
|
|
2791
|
+
if (otherBindings.length === 0) {
|
|
2792
|
+
// All same-name bindings are the def itself — a
|
|
2793
|
+
// recursive self-call, never a callee edge.
|
|
2794
|
+
noteSite(siteId, 'excluded', 'self-recursion', call);
|
|
2795
|
+
}
|
|
1091
2796
|
continue; // Already added all overloads, skip normal add
|
|
1092
2797
|
} else if (def.className && !call.isMethod) {
|
|
1093
2798
|
// Implicit same-class call (Java: execute() means this.execute())
|
|
@@ -1107,9 +2812,11 @@ function findCallees(index, def, options = {}) {
|
|
|
1107
2812
|
}
|
|
1108
2813
|
} else {
|
|
1109
2814
|
isUncertain = true;
|
|
2815
|
+
uncertainReason = 'binding-ambiguous';
|
|
1110
2816
|
}
|
|
1111
2817
|
} else {
|
|
1112
2818
|
isUncertain = true;
|
|
2819
|
+
uncertainReason = 'binding-ambiguous';
|
|
1113
2820
|
}
|
|
1114
2821
|
} else {
|
|
1115
2822
|
// Try to resolve to a binding defined within the parent function's
|
|
@@ -1122,25 +2829,47 @@ function findCallees(index, def, options = {}) {
|
|
|
1122
2829
|
calleeKey = bindingResolved;
|
|
1123
2830
|
} else {
|
|
1124
2831
|
isUncertain = true;
|
|
2832
|
+
uncertainReason = 'binding-ambiguous';
|
|
1125
2833
|
}
|
|
1126
2834
|
}
|
|
1127
2835
|
}
|
|
1128
2836
|
}
|
|
1129
2837
|
|
|
1130
|
-
if (isUncertain
|
|
1131
|
-
if (
|
|
1132
|
-
|
|
2838
|
+
if (isUncertain) {
|
|
2839
|
+
if (collectAccount) {
|
|
2840
|
+
// Contract mode: uncertain callee edges are never silently
|
|
2841
|
+
// dropped NOR silently confirmed — visible unverified
|
|
2842
|
+
// entries with a reason. --include-uncertain is an implied
|
|
2843
|
+
// no-op here (the caller-contract precedent).
|
|
2844
|
+
if (options.stats) options.stats.uncertain = (options.stats.uncertain || 0) + 1;
|
|
2845
|
+
noteUnverified(siteId, call, uncertainReason || 'uncertain-receiver');
|
|
2846
|
+
continue;
|
|
2847
|
+
}
|
|
2848
|
+
if (!options.includeUncertain) {
|
|
2849
|
+
if (options.stats) options.stats.uncertain = (options.stats.uncertain || 0) + 1;
|
|
2850
|
+
continue;
|
|
2851
|
+
}
|
|
1133
2852
|
}
|
|
1134
2853
|
|
|
1135
2854
|
const existing = callees.get(calleeKey);
|
|
1136
2855
|
if (existing) {
|
|
1137
2856
|
existing.count += 1;
|
|
2857
|
+
if (collectAccount) {
|
|
2858
|
+
existing.sites.push(call.line);
|
|
2859
|
+
existing.siteIds.push(siteId);
|
|
2860
|
+
if (call.isPotentialCallback || call.isFunctionReference) existing.isFunctionReference = true;
|
|
2861
|
+
}
|
|
1138
2862
|
} else {
|
|
1139
2863
|
callees.set(calleeKey, {
|
|
1140
2864
|
name: effectiveName,
|
|
1141
2865
|
bindingId: bindingResolved,
|
|
1142
2866
|
count: 1,
|
|
1143
|
-
...(call.isConstructor && { isConstructor: true })
|
|
2867
|
+
...(call.isConstructor && { isConstructor: true }),
|
|
2868
|
+
...(collectAccount && {
|
|
2869
|
+
sites: [call.line],
|
|
2870
|
+
siteIds: [siteId],
|
|
2871
|
+
...((call.isPotentialCallback || call.isFunctionReference) && { isFunctionReference: true }),
|
|
2872
|
+
})
|
|
1144
2873
|
});
|
|
1145
2874
|
}
|
|
1146
2875
|
}
|
|
@@ -1149,7 +2878,7 @@ function findCallees(index, def, options = {}) {
|
|
|
1149
2878
|
// Respect includeMethods=false — skip self/this method resolution entirely
|
|
1150
2879
|
if (selfAttrCalls && def.className && options.includeMethods !== false) {
|
|
1151
2880
|
const attrTypes = getInstanceAttributeTypes(index, def.file, def.className);
|
|
1152
|
-
for (const call of selfAttrCalls) {
|
|
2881
|
+
for (const { call, siteId } of selfAttrCalls) {
|
|
1153
2882
|
let targetClass = attrTypes ? attrTypes.get(call.selfAttribute) : null;
|
|
1154
2883
|
// Unique method heuristic: if attr type unknown but method exists on exactly one class
|
|
1155
2884
|
if (!targetClass) {
|
|
@@ -1164,36 +2893,45 @@ function findCallees(index, def, options = {}) {
|
|
|
1164
2893
|
}
|
|
1165
2894
|
}
|
|
1166
2895
|
}
|
|
1167
|
-
if (!targetClass) continue;
|
|
2896
|
+
if (!targetClass) { noteUnverified(siteId, call, 'self-attr-unresolved'); continue; }
|
|
1168
2897
|
|
|
1169
2898
|
// Find method in symbol table where className matches
|
|
1170
2899
|
const symbols = index.symbols.get(call.name);
|
|
1171
|
-
if (!symbols) continue;
|
|
2900
|
+
if (!symbols) { noteUnverified(siteId, call, 'self-attr-unresolved'); continue; }
|
|
1172
2901
|
|
|
1173
2902
|
const match = symbols.find(s => s.className === targetClass);
|
|
1174
|
-
if (!match) continue;
|
|
2903
|
+
if (!match) { noteUnverified(siteId, call, 'self-attr-unresolved'); continue; }
|
|
1175
2904
|
|
|
1176
2905
|
const key = match.bindingId || `${targetClass}.${call.name}`;
|
|
1177
2906
|
const existing = callees.get(key);
|
|
1178
2907
|
if (existing) {
|
|
1179
2908
|
existing.count += 1;
|
|
2909
|
+
if (collectAccount) { existing.sites.push(call.line); existing.siteIds.push(siteId); }
|
|
1180
2910
|
} else {
|
|
1181
2911
|
callees.set(key, {
|
|
1182
2912
|
name: call.name,
|
|
1183
2913
|
bindingId: match.bindingId,
|
|
1184
|
-
count: 1
|
|
2914
|
+
count: 1,
|
|
2915
|
+
...(collectAccount && { sites: [call.line], siteIds: [siteId] })
|
|
1185
2916
|
});
|
|
1186
2917
|
}
|
|
1187
2918
|
}
|
|
2919
|
+
} else if (selfAttrCalls && collectAccount) {
|
|
2920
|
+
// Pass skipped (no class context, or methods display-filtered):
|
|
2921
|
+
// claim the sites so the account stays conserved.
|
|
2922
|
+
for (const { call, siteId } of selfAttrCalls) {
|
|
2923
|
+
if (options.includeMethods === false) noteSite(siteId, 'filtered', 'method-calls-excluded', call);
|
|
2924
|
+
else noteUnverified(siteId, call, 'self-attr-unresolved');
|
|
2925
|
+
}
|
|
1188
2926
|
}
|
|
1189
2927
|
|
|
1190
2928
|
// Third pass: resolve self/this/super.method() calls to same-class or parent methods
|
|
1191
2929
|
// Falls back to walking the inheritance chain if not found in same class
|
|
1192
2930
|
// Respect includeMethods=false — skip self/this method resolution entirely
|
|
1193
2931
|
if (selfMethodCalls && def.className && options.includeMethods !== false) {
|
|
1194
|
-
for (const call of selfMethodCalls) {
|
|
2932
|
+
for (const { call, siteId } of selfMethodCalls) {
|
|
1195
2933
|
const symbols = index.symbols.get(call.name);
|
|
1196
|
-
if (!symbols) continue;
|
|
2934
|
+
if (!symbols) { noteUnverified(siteId, call, 'inherited-unresolved'); continue; }
|
|
1197
2935
|
|
|
1198
2936
|
// For super().method(), skip same-class — start from parent
|
|
1199
2937
|
let match = call.receiver === 'super'
|
|
@@ -1221,20 +2959,27 @@ function findCallees(index, def, options = {}) {
|
|
|
1221
2959
|
}
|
|
1222
2960
|
}
|
|
1223
2961
|
|
|
1224
|
-
if (!match) continue;
|
|
2962
|
+
if (!match) { noteUnverified(siteId, call, 'inherited-unresolved'); continue; }
|
|
1225
2963
|
|
|
1226
2964
|
const key = match.bindingId || `${match.className}.${call.name}`;
|
|
1227
2965
|
const existing = callees.get(key);
|
|
1228
2966
|
if (existing) {
|
|
1229
2967
|
existing.count += 1;
|
|
2968
|
+
if (collectAccount) { existing.sites.push(call.line); existing.siteIds.push(siteId); }
|
|
1230
2969
|
} else {
|
|
1231
2970
|
callees.set(key, {
|
|
1232
2971
|
name: call.name,
|
|
1233
2972
|
bindingId: match.bindingId,
|
|
1234
|
-
count: 1
|
|
2973
|
+
count: 1,
|
|
2974
|
+
...(collectAccount && { sites: [call.line], siteIds: [siteId] })
|
|
1235
2975
|
});
|
|
1236
2976
|
}
|
|
1237
2977
|
}
|
|
2978
|
+
} else if (selfMethodCalls && collectAccount) {
|
|
2979
|
+
for (const { call, siteId } of selfMethodCalls) {
|
|
2980
|
+
if (options.includeMethods === false) noteSite(siteId, 'filtered', 'method-calls-excluded', call);
|
|
2981
|
+
else noteUnverified(siteId, call, 'inherited-unresolved');
|
|
2982
|
+
}
|
|
1238
2983
|
}
|
|
1239
2984
|
|
|
1240
2985
|
// Look up each callee in the symbol table
|
|
@@ -1248,9 +2993,21 @@ function findCallees(index, def, options = {}) {
|
|
|
1248
2993
|
// Pre-compute import graph for callee confidence scoring
|
|
1249
2994
|
const callerImportSet = index.importGraph.get(def.file) || new Set();
|
|
1250
2995
|
|
|
1251
|
-
for (const { name: calleeName, bindingId, count, isConstructor } of callees.values()) {
|
|
2996
|
+
for (const { name: calleeName, bindingId, count, isConstructor, sites, siteIds, isFunctionReference } of callees.values()) {
|
|
2997
|
+
const claimSites = (bucket, reason) => {
|
|
2998
|
+
if (!collectAccount || !siteIds) return;
|
|
2999
|
+
for (let i = 0; i < siteIds.length; i++) {
|
|
3000
|
+
noteSite(siteIds[i], bucket, reason, { name: calleeName, line: sites[i] });
|
|
3001
|
+
}
|
|
3002
|
+
};
|
|
1252
3003
|
const symbols = index.symbols.get(calleeName);
|
|
1253
|
-
if (symbols
|
|
3004
|
+
if (!symbols || symbols.length === 0) {
|
|
3005
|
+
// Name not in the symbol table — external library, builtin, or
|
|
3006
|
+
// unindexed code. Visible in the callee account, not an edge.
|
|
3007
|
+
claimSites('external', null);
|
|
3008
|
+
continue;
|
|
3009
|
+
}
|
|
3010
|
+
if (symbols.length > 0) {
|
|
1254
3011
|
let callee = symbols[0];
|
|
1255
3012
|
|
|
1256
3013
|
// If we have a binding ID, find the exact matching symbol
|
|
@@ -1348,7 +3105,10 @@ function findCallees(index, def, options = {}) {
|
|
|
1348
3105
|
const isFuncField = callee.type === 'field' && callee.fieldType &&
|
|
1349
3106
|
/^func\b/.test(callee.fieldType);
|
|
1350
3107
|
// Constructor calls (new Foo()) are always callable regardless of type
|
|
1351
|
-
if (!isFuncField && !isConstructor)
|
|
3108
|
+
if (!isFuncField && !isConstructor) {
|
|
3109
|
+
claimSites('excluded', 'non-callable-shadow');
|
|
3110
|
+
continue;
|
|
3111
|
+
}
|
|
1352
3112
|
}
|
|
1353
3113
|
|
|
1354
3114
|
// Skip test-file callees when caller is production code and
|
|
@@ -1356,6 +3116,7 @@ function findCallees(index, def, options = {}) {
|
|
|
1356
3116
|
if (!callerIsTest && !bindingId) {
|
|
1357
3117
|
const calleeFileEntry = index.files.get(callee.file);
|
|
1358
3118
|
if (calleeFileEntry && isTestFile(calleeFileEntry.relativePath, calleeFileEntry.language)) {
|
|
3119
|
+
claimSites('excluded', 'test-file-no-import-link');
|
|
1359
3120
|
continue;
|
|
1360
3121
|
}
|
|
1361
3122
|
}
|
|
@@ -1366,12 +3127,18 @@ function findCallees(index, def, options = {}) {
|
|
|
1366
3127
|
(callee.file === def.file) || callerImportSet.has(callee.file),
|
|
1367
3128
|
isUncertain: false, // uncertain callees already filtered above
|
|
1368
3129
|
});
|
|
3130
|
+
claimSites('confirmed', null);
|
|
1369
3131
|
result.push({
|
|
1370
3132
|
...callee,
|
|
1371
3133
|
callCount: count,
|
|
1372
3134
|
weight: index.calculateWeight(count),
|
|
1373
3135
|
confidence: calleeScored.confidence,
|
|
1374
3136
|
resolution: calleeScored.resolution,
|
|
3137
|
+
...(collectAccount && {
|
|
3138
|
+
tier: TIER.CONFIRMED,
|
|
3139
|
+
sites: [...sites].sort((a, b) => a - b),
|
|
3140
|
+
...(isFunctionReference && { functionReference: true }),
|
|
3141
|
+
}),
|
|
1375
3142
|
});
|
|
1376
3143
|
}
|
|
1377
3144
|
}
|
|
@@ -1379,6 +3146,24 @@ function findCallees(index, def, options = {}) {
|
|
|
1379
3146
|
// Sort by call count (core dependencies first)
|
|
1380
3147
|
result.sort((a, b) => b.callCount - a.callCount);
|
|
1381
3148
|
|
|
3149
|
+
if (calleeAccount) {
|
|
3150
|
+
const claimed = calleeAccount.confirmed + calleeAccount.unverified +
|
|
3151
|
+
calleeAccount.external.count + calleeAccount.excluded.total +
|
|
3152
|
+
calleeAccount.filtered.count;
|
|
3153
|
+
calleeAccount.unaccounted = calleeAccount.totalSites - claimed;
|
|
3154
|
+
calleeAccount.conserved = calleeAccount.unaccounted === 0;
|
|
3155
|
+
// Stable ordering (output contract): by name, then reason.
|
|
3156
|
+
const unverifiedList = [...unverifiedCallees.values()]
|
|
3157
|
+
.map(e => ({ ...e, sites: [...e.sites].sort((a, b) => a - b) }))
|
|
3158
|
+
.sort((a, b) => a.name.localeCompare(b.name) || a.reason.localeCompare(b.reason));
|
|
3159
|
+
Object.defineProperty(result, 'calleeAccount', {
|
|
3160
|
+
value: calleeAccount, enumerable: false, writable: true, configurable: true,
|
|
3161
|
+
});
|
|
3162
|
+
Object.defineProperty(result, 'unverifiedCallees', {
|
|
3163
|
+
value: unverifiedList, enumerable: false, writable: true, configurable: true,
|
|
3164
|
+
});
|
|
3165
|
+
}
|
|
3166
|
+
|
|
1382
3167
|
return result;
|
|
1383
3168
|
} catch (e) {
|
|
1384
3169
|
// Expected: file read/parse failures (minified, binary, buffer exceeded).
|
|
@@ -1491,6 +3276,1483 @@ function _buildLocalTypeMap(index, def, calls) {
|
|
|
1491
3276
|
* variable types for method resolution. Not used by JS/TS/Python -- structural
|
|
1492
3277
|
* languages use import evidence via _buildLocalTypeMap instead.
|
|
1493
3278
|
*/
|
|
3279
|
+
/**
|
|
3280
|
+
* Single concrete type name from a return-annotation STRING (symbols store
|
|
3281
|
+
* returnType as text). Conservative: ambiguous shapes return undefined.
|
|
3282
|
+
* Handles: Foo · pkg.Foo · "Foo" · Foo | None · Optional[Foo] · Promise<Foo> ·
|
|
3283
|
+
* list[Item] (→ list — the value IS a list) · Foo<T> / Foo[T] (→ Foo).
|
|
3284
|
+
*/
|
|
3285
|
+
function _typeNameFromReturnAnnotation(text) {
|
|
3286
|
+
if (!text || typeof text !== 'string') return undefined;
|
|
3287
|
+
let t = text.trim().replace(/^["']|["']$/g, '').trim();
|
|
3288
|
+
// X | None / X | null / X | undefined → X (single real member only)
|
|
3289
|
+
if (t.includes('|')) {
|
|
3290
|
+
const parts = t.split('|').map(s => s.trim())
|
|
3291
|
+
.filter(s => !['None', 'null', 'undefined'].includes(s));
|
|
3292
|
+
if (parts.length !== 1) return undefined;
|
|
3293
|
+
t = parts[0];
|
|
3294
|
+
}
|
|
3295
|
+
// unwrap value-transparent wrappers: Optional[X], Promise<X>, Awaitable[X]
|
|
3296
|
+
let m;
|
|
3297
|
+
while ((m = t.match(/^(?:typing\.)?(Optional|Annotated|Final|Promise|Awaitable)\s*[[<]\s*([^,]+?)\s*[\]>]$/))) {
|
|
3298
|
+
t = m[2].trim();
|
|
3299
|
+
}
|
|
3300
|
+
// generic base: Foo[...] / Foo<...> → Foo (the value is a Foo)
|
|
3301
|
+
m = t.match(/^([\w.]+)\s*[[<]/);
|
|
3302
|
+
if (m) t = m[1];
|
|
3303
|
+
// dotted → last segment; validate a bare identifier remains
|
|
3304
|
+
const last = t.split('.').pop();
|
|
3305
|
+
return /^[A-Za-z_]\w*$/.test(last) ? last : undefined;
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
/**
|
|
3309
|
+
* Per-file return-type-flow map: variables typed by what the assigned call
|
|
3310
|
+
* returns. Key `${enclosingFnStartLine||''}:${varName}` → [{ line, type,
|
|
3311
|
+
* fromFile? }] (all assignments, so lookups can pick the nearest preceding
|
|
3312
|
+
* one). All producer resolutions are conservative.
|
|
3313
|
+
*
|
|
3314
|
+
* Structural shapes (fix #199 — unchanged):
|
|
3315
|
+
* - typed-receiver method call: receiverType class (or an ancestor walk is
|
|
3316
|
+
* NOT attempted — exact className match only) defines the method with a
|
|
3317
|
+
* return annotation
|
|
3318
|
+
* - self/this/cls method call: the enclosing class (walking up its
|
|
3319
|
+
* inheritance chain for inherited methods) defines the method with a
|
|
3320
|
+
* return annotation
|
|
3321
|
+
* - plain call with exactly ONE project definition carrying a return annotation
|
|
3322
|
+
*
|
|
3323
|
+
* Nominal shapes (fix #207 — compiler-checked annotations, so resolution
|
|
3324
|
+
* confidence carries; same-named owners must AGREE on the return type):
|
|
3325
|
+
* - Go package-qualified producer: bb := balancer.Get(n) — defs resolved
|
|
3326
|
+
* strictly into the imported package (no root-package trust)
|
|
3327
|
+
* - Rust path producer: let c = Config::load()? — last path segment as the
|
|
3328
|
+
* impl type; Result/Option unwrap via assignedUnwrap; Self → the impl type
|
|
3329
|
+
* - Java static producer (typeQualifiedCallStyle 'static'): var c =
|
|
3330
|
+
* Config.parse(...) — receiver as className
|
|
3331
|
+
* - plain producer: Go resolves same-package ONLY (an unqualified Go call
|
|
3332
|
+
* cannot reach another package); Rust/Java add a same-file narrowing on
|
|
3333
|
+
* top of the global-unique rule
|
|
3334
|
+
* Nominal entries carry fromFile — the TYPE's defining file resolved from
|
|
3335
|
+
* the PRODUCER's scope (_resolveFlowTypeOrigin) — so the #206 identity
|
|
3336
|
+
* discipline resolves the name where the annotation lives, not where the
|
|
3337
|
+
* consuming call happens to be.
|
|
3338
|
+
*/
|
|
3339
|
+
function _buildReturnTypeFlowMap(index, filePath, calls) {
|
|
3340
|
+
const fileEntry = index.files.get(filePath);
|
|
3341
|
+
const language = fileEntry?.language;
|
|
3342
|
+
const nominal = langTraits(language)?.typeSystem === 'nominal';
|
|
3343
|
+
let map = null;
|
|
3344
|
+
for (const call of calls) {
|
|
3345
|
+
if (!call.assignedTo) continue;
|
|
3346
|
+
let returnType, fromFile, selfClass;
|
|
3347
|
+
if (call.isMethod && call.receiverType) {
|
|
3348
|
+
const defs = index.symbols.get(call.name) || [];
|
|
3349
|
+
if (nominal) {
|
|
3350
|
+
const matches = defs.filter(d => d.className === call.receiverType && d.returnType);
|
|
3351
|
+
if (matches.length > 0 && new Set(matches.map(d => d.returnType)).size === 1) {
|
|
3352
|
+
returnType = matches[0].returnType;
|
|
3353
|
+
fromFile = matches[0].file;
|
|
3354
|
+
selfClass = matches[0].className;
|
|
3355
|
+
}
|
|
3356
|
+
} else {
|
|
3357
|
+
const def = defs.find(d => d.className === call.receiverType && d.returnType);
|
|
3358
|
+
returnType = def && def.returnType;
|
|
3359
|
+
}
|
|
3360
|
+
} else if (call.isMethod && ['self', 'this', 'cls'].includes(call.receiver)) {
|
|
3361
|
+
const enclosing = index.findEnclosingFunction(filePath, call.line, true);
|
|
3362
|
+
let cls = enclosing && enclosing.className;
|
|
3363
|
+
let ctxFile = filePath;
|
|
3364
|
+
const visited = new Set();
|
|
3365
|
+
while (cls && !visited.has(cls)) {
|
|
3366
|
+
visited.add(cls);
|
|
3367
|
+
const def = (index.symbols.get(call.name) || [])
|
|
3368
|
+
.find(d => d.className === cls && d.returnType);
|
|
3369
|
+
if (def) { returnType = def.returnType; fromFile = def.file; selfClass = cls; break; }
|
|
3370
|
+
const parents = index._getInheritanceParents(cls, ctxFile) || [];
|
|
3371
|
+
const next = parents[0]; // single chain; diamond bases stay untyped
|
|
3372
|
+
if (next && index._resolveClassFile) {
|
|
3373
|
+
ctxFile = index._resolveClassFile(next, ctxFile) || ctxFile;
|
|
3374
|
+
}
|
|
3375
|
+
cls = next;
|
|
3376
|
+
}
|
|
3377
|
+
} else if (nominal && call.isMethod && call.isPathCall && call.receiver) {
|
|
3378
|
+
// Rust: let c = config::Config::load()? — the last path segment
|
|
3379
|
+
// names the impl type (module-path producers stay untyped)
|
|
3380
|
+
const seg = call.receiver.split('::').pop();
|
|
3381
|
+
const matches = (index.symbols.get(call.name) || [])
|
|
3382
|
+
.filter(d => d.className === seg && d.returnType);
|
|
3383
|
+
if (matches.length > 0 && new Set(matches.map(d => d.returnType)).size === 1) {
|
|
3384
|
+
returnType = matches[0].returnType;
|
|
3385
|
+
fromFile = matches[0].file;
|
|
3386
|
+
selfClass = seg;
|
|
3387
|
+
}
|
|
3388
|
+
} else if (nominal && call.isMethod && call.receiver &&
|
|
3389
|
+
langTraits(language)?.typeQualifiedCallStyle === 'static') {
|
|
3390
|
+
// Java static factory: var c = Config.parse(...) — only sound for
|
|
3391
|
+
// the static call style; a Go receiver named like a type is a
|
|
3392
|
+
// VARIABLE (fix #206 typeQualifiedCallStyle discipline)
|
|
3393
|
+
const matches = (index.symbols.get(call.name) || [])
|
|
3394
|
+
.filter(d => d.className === call.receiver && d.returnType);
|
|
3395
|
+
if (matches.length > 0 && new Set(matches.map(d => d.returnType)).size === 1) {
|
|
3396
|
+
returnType = matches[0].returnType;
|
|
3397
|
+
fromFile = matches[0].file;
|
|
3398
|
+
selfClass = call.receiver;
|
|
3399
|
+
}
|
|
3400
|
+
} else if (nominal && !call.isMethod && call.receiver &&
|
|
3401
|
+
langTraits(language)?.hasReceiverPackageCalls) {
|
|
3402
|
+
// Go package-qualified producer: bb := balancer.Get(n) — Get
|
|
3403
|
+
// resolves IN the imported package (fix #206 name ownership)
|
|
3404
|
+
const cands = (index.symbols.get(call.name) || [])
|
|
3405
|
+
.filter(d => !NON_CALLABLE_TYPES.has(d.type) && d.returnType);
|
|
3406
|
+
const inPkg = fileEntry && _qualifiedProducerDefs(index, fileEntry, call.receiver, cands);
|
|
3407
|
+
if (inPkg && inPkg.length > 0 && new Set(inPkg.map(d => d.returnType)).size === 1) {
|
|
3408
|
+
returnType = inPkg[0].returnType;
|
|
3409
|
+
fromFile = inPkg[0].file;
|
|
3410
|
+
} else {
|
|
3411
|
+
// External producer (fix #220, cobra-measured): the parser
|
|
3412
|
+
// marked this call package-qualified (receiver ∈ imports),
|
|
3413
|
+
// and the package resolves to no project def — the variable's
|
|
3414
|
+
// type was decided OUTSIDE the project (av := reflect.ValueOf).
|
|
3415
|
+
// Not positive evidence for any type, but compiler-grade
|
|
3416
|
+
// evidence AGAINST single-owner confirmation: route visible.
|
|
3417
|
+
// EVERY tuple element is external-decided (tmpFile, err := …),
|
|
3418
|
+
// unlike typed flow which pairs only element 0 (#207).
|
|
3419
|
+
const scope = call.enclosingFunction ? `${call.enclosingFunction.startLine}` : '';
|
|
3420
|
+
if (!map) map = new Map();
|
|
3421
|
+
for (const lhs of [call.assignedTo, ...(call.assignedTupleRest || [])]) {
|
|
3422
|
+
const key = `${scope}:${lhs}`;
|
|
3423
|
+
if (!map.has(key)) map.set(key, []);
|
|
3424
|
+
map.get(key).push({ line: call.line, externalVia: `${call.receiver}.${call.name}` });
|
|
3425
|
+
}
|
|
3426
|
+
continue;
|
|
3427
|
+
}
|
|
3428
|
+
} else if (!nominal && call.isMethod && call.receiver && call.receiverIsModule) {
|
|
3429
|
+
// Structural module-qualified producer (fix #209): schema =
|
|
3430
|
+
// z.string() — the module alias resolves through the file's
|
|
3431
|
+
// import bindings to its file (one re-export hop for barrels),
|
|
3432
|
+
// and the producer's return annotation types the variable.
|
|
3433
|
+
// Standalone exports only (className-less): a module attr is
|
|
3434
|
+
// never a class method.
|
|
3435
|
+
const binding = (fileEntry?.importBindings || []).find(b => b.name === call.receiver);
|
|
3436
|
+
const rel = binding && fileEntry.moduleResolved && fileEntry.moduleResolved[binding.module];
|
|
3437
|
+
if (binding && !rel) {
|
|
3438
|
+
// External module producer (fix #222, httpx-measured — the
|
|
3439
|
+
// #220 Go external-producer rule for structural languages):
|
|
3440
|
+
// logger = logging.getLogger(...) / thread = threading.Thread()
|
|
3441
|
+
// types the variable OUTSIDE the project, so unique project
|
|
3442
|
+
// ownership of a later method name (logger.info vs the only
|
|
3443
|
+
// project `info`) is not identity evidence. Same externality
|
|
3444
|
+
// test as #209 module ownership: relative or project-ish
|
|
3445
|
+
// modules are resolver gaps, never externality evidence.
|
|
3446
|
+
const mod = String(binding.module);
|
|
3447
|
+
const firstSeg = mod.split(/[./]/).filter(Boolean)[0];
|
|
3448
|
+
if (!mod.startsWith('.') &&
|
|
3449
|
+
!(firstSeg && _projectTopLevelNames(index).has(firstSeg))) {
|
|
3450
|
+
const scope = call.enclosingFunction ? `${call.enclosingFunction.startLine}` : '';
|
|
3451
|
+
if (!map) map = new Map();
|
|
3452
|
+
const key = `${scope}:${call.assignedTo}`;
|
|
3453
|
+
if (!map.has(key)) map.set(key, []);
|
|
3454
|
+
map.get(key).push({ line: call.line, externalVia: `${call.receiver}.${call.name}` });
|
|
3455
|
+
}
|
|
3456
|
+
continue;
|
|
3457
|
+
}
|
|
3458
|
+
if (rel) {
|
|
3459
|
+
const modFile = path.join(index.root, rel);
|
|
3460
|
+
const cands = (index.symbols.get(call.name) || [])
|
|
3461
|
+
.filter(d => !NON_CALLABLE_TYPES.has(d.type) && d.returnType && !d.className);
|
|
3462
|
+
let matches = cands.filter(d => d.file === modFile);
|
|
3463
|
+
if (matches.length === 0) {
|
|
3464
|
+
const hop = index.importGraph.get(modFile);
|
|
3465
|
+
if (hop) matches = cands.filter(d => hop.has(d.file));
|
|
3466
|
+
}
|
|
3467
|
+
if (matches.length > 0 && new Set(matches.map(d => d.returnType)).size === 1) {
|
|
3468
|
+
returnType = matches[0].returnType;
|
|
3469
|
+
}
|
|
3470
|
+
}
|
|
3471
|
+
} else if (!call.isMethod && !call.receiver) {
|
|
3472
|
+
const defs = (index.symbols.get(call.name) || [])
|
|
3473
|
+
.filter(d => !NON_CALLABLE_TYPES.has(d.type));
|
|
3474
|
+
let chosen = null;
|
|
3475
|
+
if (nominal && langTraits(language)?.packageScope === 'directory') {
|
|
3476
|
+
// Go: an unqualified call resolves within the package — a
|
|
3477
|
+
// globally-unique def in ANOTHER package is unreachable
|
|
3478
|
+
const dir = path.dirname(filePath);
|
|
3479
|
+
const samePkg = defs.filter(d => d.file && path.dirname(d.file) === dir);
|
|
3480
|
+
if (samePkg.length === 1) chosen = samePkg[0];
|
|
3481
|
+
} else if (defs.length === 1) {
|
|
3482
|
+
chosen = defs[0];
|
|
3483
|
+
} else if (nominal && defs.length > 1) {
|
|
3484
|
+
const sameFile = defs.filter(d => d.file === filePath);
|
|
3485
|
+
if (sameFile.length === 1) chosen = sameFile[0];
|
|
3486
|
+
}
|
|
3487
|
+
if (chosen) { returnType = chosen.returnType; fromFile = chosen.file; }
|
|
3488
|
+
}
|
|
3489
|
+
if (!returnType) continue;
|
|
3490
|
+
let typeName, entryFromFile;
|
|
3491
|
+
if (nominal) {
|
|
3492
|
+
const parsed = _returnTypeNameNominal(returnType, language, {
|
|
3493
|
+
unwrapped: call.assignedUnwrap, tuple: call.assignedTuple, selfClass,
|
|
3494
|
+
});
|
|
3495
|
+
if (!parsed) continue;
|
|
3496
|
+
const origin = _resolveFlowTypeOrigin(index, fromFile || filePath, parsed.name, parsed.qualifier);
|
|
3497
|
+
if (!origin) continue; // identity unpinnable — don't type at all
|
|
3498
|
+
typeName = parsed.name;
|
|
3499
|
+
entryFromFile = origin.fromFile;
|
|
3500
|
+
} else {
|
|
3501
|
+
typeName = _typeNameFromReturnAnnotation(returnType);
|
|
3502
|
+
}
|
|
3503
|
+
if (!typeName) continue;
|
|
3504
|
+
const scope = call.enclosingFunction ? `${call.enclosingFunction.startLine}` : '';
|
|
3505
|
+
const key = `${scope}:${call.assignedTo}`;
|
|
3506
|
+
if (!map) map = new Map();
|
|
3507
|
+
if (!map.has(key)) map.set(key, []);
|
|
3508
|
+
map.get(key).push({ line: call.line, type: typeName,
|
|
3509
|
+
...(entryFromFile && { fromFile: entryFromFile }) });
|
|
3510
|
+
}
|
|
3511
|
+
return map;
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3514
|
+
/** Nearest preceding flow assignment for this call's receiver (fn scope, then module). */
|
|
3515
|
+
function _lookupReturnTypeFlow(map, call) {
|
|
3516
|
+
const fnScope = call.enclosingFunction ? `${call.enclosingFunction.startLine}` : '';
|
|
3517
|
+
for (const scope of fnScope === '' ? [''] : [fnScope, '']) {
|
|
3518
|
+
const entries = map.get(`${scope}:${call.receiver}`);
|
|
3519
|
+
if (!entries) continue;
|
|
3520
|
+
let best = null;
|
|
3521
|
+
for (const e of entries) {
|
|
3522
|
+
if (e.line < call.line && (!best || e.line > best.line)) best = e;
|
|
3523
|
+
}
|
|
3524
|
+
if (best) return best;
|
|
3525
|
+
}
|
|
3526
|
+
return undefined;
|
|
3527
|
+
}
|
|
3528
|
+
|
|
3529
|
+
// Return-annotation names that must never type a receiver in nominal flow:
|
|
3530
|
+
// builtin interfaces/primitives whose project implementors UCN cannot see —
|
|
3531
|
+
// a receiver typed `error` CAN dispatch into a project type's Error() method,
|
|
3532
|
+
// so excluding on it would lose true edges. (Rust primitives are safe: project
|
|
3533
|
+
// extension impls put the primitive name in dispatchTargetTypes.)
|
|
3534
|
+
const _GO_FLOW_REJECT = new Set([
|
|
3535
|
+
'error', 'any', 'string', 'bool', 'byte', 'rune', 'uintptr',
|
|
3536
|
+
'int', 'int8', 'int16', 'int32', 'int64',
|
|
3537
|
+
'uint', 'uint8', 'uint16', 'uint32', 'uint64',
|
|
3538
|
+
'float32', 'float64', 'complex64', 'complex128',
|
|
3539
|
+
]);
|
|
3540
|
+
const _JAVA_FLOW_REJECT = new Set([
|
|
3541
|
+
'Object', 'void', 'int', 'long', 'short', 'byte', 'char',
|
|
3542
|
+
'boolean', 'float', 'double', 'var',
|
|
3543
|
+
]);
|
|
3544
|
+
|
|
3545
|
+
/** Split generic-argument text on commas at angle/paren/bracket depth 0. */
|
|
3546
|
+
function _splitTopLevelGenericArgs(s) {
|
|
3547
|
+
const out = [];
|
|
3548
|
+
let depth = 0, cur = '';
|
|
3549
|
+
for (const ch of s) {
|
|
3550
|
+
if (ch === '<' || ch === '(' || ch === '[') depth++;
|
|
3551
|
+
else if (ch === '>' || ch === ')' || ch === ']') depth--;
|
|
3552
|
+
if (ch === ',' && depth === 0) { out.push(cur); cur = ''; }
|
|
3553
|
+
else cur += ch;
|
|
3554
|
+
}
|
|
3555
|
+
out.push(cur);
|
|
3556
|
+
return out;
|
|
3557
|
+
}
|
|
3558
|
+
|
|
3559
|
+
/**
|
|
3560
|
+
* Single concrete type name from a NOMINAL return annotation (fix #207).
|
|
3561
|
+
* Returns { name, qualifier } or undefined. Conservative: ambiguous or
|
|
3562
|
+
* non-nominal shapes (slices, maps, chans, fn types, dyn/impl traits,
|
|
3563
|
+
* generic type params, builtin interfaces) return undefined.
|
|
3564
|
+
* - Go: `*Builder` → Builder; tuple `(T, error)` pairs its FIRST element
|
|
3565
|
+
* with a tuple-unpacking assignment (`v, err := f()`) — tuple/assignment
|
|
3566
|
+
* shapes must agree or the parse is wrong; `pkg.Type` keeps the qualifier
|
|
3567
|
+
* - Rust: Self → the impl type; Result<T,_>/Option<T> unwrap ONLY under
|
|
3568
|
+
* assignedUnwrap (`?` / .unwrap() / .expect()); Box/Rc/Arc auto-deref via
|
|
3569
|
+
* _normalizeFieldTypeName
|
|
3570
|
+
* - Java: plain names and generic bases via _normalizeFieldTypeName
|
|
3571
|
+
*/
|
|
3572
|
+
function _returnTypeNameNominal(text, language, opts = {}) {
|
|
3573
|
+
if (!text || typeof text !== 'string') return undefined;
|
|
3574
|
+
let t = text.trim();
|
|
3575
|
+
if (language === 'go') {
|
|
3576
|
+
if (t.startsWith('(')) {
|
|
3577
|
+
if (!opts.tuple) return undefined;
|
|
3578
|
+
const inner = t.slice(1, -1);
|
|
3579
|
+
if (inner.includes('func(') || inner.includes('func (')) return undefined;
|
|
3580
|
+
const first = inner.split(',')[0].trim();
|
|
3581
|
+
const parts = first.split(/\s+/);
|
|
3582
|
+
t = parts[parts.length - 1]; // named return `n int` → int
|
|
3583
|
+
} else if (opts.tuple) {
|
|
3584
|
+
return undefined; // v, err := f() needs a multi-return producer
|
|
3585
|
+
}
|
|
3586
|
+
} else if (opts.tuple) {
|
|
3587
|
+
return undefined;
|
|
3588
|
+
}
|
|
3589
|
+
if (language === 'rust') {
|
|
3590
|
+
if (/^&?\s*(mut\s+)?Self$/.test(t)) {
|
|
3591
|
+
return opts.selfClass ? { name: opts.selfClass } : undefined;
|
|
3592
|
+
}
|
|
3593
|
+
if (opts.unwrapped) {
|
|
3594
|
+
const m = t.match(/^(?:[A-Za-z_][A-Za-z0-9_]*\s*::\s*)*(Result|Option)\s*<(.*)>$/s);
|
|
3595
|
+
if (!m) return undefined; // unwrap on a non-Result/Option annotation — alias or parse gap
|
|
3596
|
+
t = (_splitTopLevelGenericArgs(m[2])[0] || '').trim();
|
|
3597
|
+
if (/^&?\s*(mut\s+)?Self$/.test(t)) {
|
|
3598
|
+
return opts.selfClass ? { name: opts.selfClass } : undefined;
|
|
3599
|
+
}
|
|
3600
|
+
}
|
|
3601
|
+
} else if (opts.unwrapped) {
|
|
3602
|
+
return undefined;
|
|
3603
|
+
}
|
|
3604
|
+
// Qualifier survives only the Go shape (`pkg.Type`); Rust paths and Java
|
|
3605
|
+
// dotted names lose theirs in normalization — capture it first.
|
|
3606
|
+
let qualifier;
|
|
3607
|
+
if (language === 'go') {
|
|
3608
|
+
const qm = t.replace(/^\*+/, '').match(/^([A-Za-z_]\w*)\.([A-Za-z_]\w*)$/);
|
|
3609
|
+
if (qm) qualifier = qm[1];
|
|
3610
|
+
} else {
|
|
3611
|
+
const stripped = t.replace(/^&+\s*/, '').replace(/^mut\s+/, '');
|
|
3612
|
+
if (/^[A-Za-z_$][\w$]*\s*(::|\.)/.test(stripped)) qualifier = '<unresolvable>';
|
|
3613
|
+
}
|
|
3614
|
+
const norm = _normalizeFieldTypeName(t, language);
|
|
3615
|
+
if (!norm) return undefined;
|
|
3616
|
+
if (/^[A-Z][A-Z0-9]?$/.test(norm)) return undefined; // generic type param (T, K, V1)
|
|
3617
|
+
if (language === 'go' && _GO_FLOW_REJECT.has(norm)) return undefined;
|
|
3618
|
+
if (language === 'java' && _JAVA_FLOW_REJECT.has(norm)) return undefined;
|
|
3619
|
+
return { name: norm, qualifier };
|
|
3620
|
+
}
|
|
3621
|
+
|
|
3622
|
+
/**
|
|
3623
|
+
* Pin a flow type name to its defining file from the PRODUCER's scope
|
|
3624
|
+
* (fix #207 — the #206 identity lesson applied to annotations: `Builder` in
|
|
3625
|
+
* balancer/base.go means balancer.Builder; resolving it from the consuming
|
|
3626
|
+
* file's scope could hit an unrelated same-name type). Returns { fromFile }
|
|
3627
|
+
* or null when identity cannot be pinned:
|
|
3628
|
+
* - Go-qualified (`pkg.Type`): the qualifier must resolve through the
|
|
3629
|
+
* producer's imports to exactly one project package — else null
|
|
3630
|
+
* - Rust/Java-qualified annotations: external paths — only acceptable when
|
|
3631
|
+
* NO project type shares the name (the name then can't conflate)
|
|
3632
|
+
* - unqualified: same file → same dir → import edge; unique-anywhere is NOT
|
|
3633
|
+
* trusted (a use/import of an external type can shadow it invisibly)
|
|
3634
|
+
* - no project type def at all: external name — safe, can't conflate
|
|
3635
|
+
*/
|
|
3636
|
+
function _resolveFlowTypeOrigin(index, producerFile, typeName, qualifier) {
|
|
3637
|
+
const typeDefs = (index.symbols.get(typeName) || [])
|
|
3638
|
+
.filter(d => IDENTITY_TYPE_KINDS.has(d.type) && d.file);
|
|
3639
|
+
if (typeDefs.length === 0) return { fromFile: producerFile };
|
|
3640
|
+
if (qualifier === '<unresolvable>') return null;
|
|
3641
|
+
if (qualifier) {
|
|
3642
|
+
const fe = index.files.get(producerFile);
|
|
3643
|
+
const inPkg = fe && _qualifiedProducerDefs(index, fe, qualifier, typeDefs);
|
|
3644
|
+
if (inPkg && inPkg.length > 0 && new Set(inPkg.map(d => d.file)).size === 1) {
|
|
3645
|
+
return { fromFile: inPkg[0].file };
|
|
3646
|
+
}
|
|
3647
|
+
return null;
|
|
3648
|
+
}
|
|
3649
|
+
const sameFile = typeDefs.find(d => d.file === producerFile);
|
|
3650
|
+
if (sameFile) return { fromFile: sameFile.file };
|
|
3651
|
+
const dir = path.dirname(producerFile);
|
|
3652
|
+
const sameDir = typeDefs.filter(d => path.dirname(d.file) === dir);
|
|
3653
|
+
if (sameDir.length === 1) return { fromFile: sameDir[0].file };
|
|
3654
|
+
if (sameDir.length > 1) return null;
|
|
3655
|
+
const imports = index.importGraph.get(producerFile);
|
|
3656
|
+
if (imports) {
|
|
3657
|
+
const imported = typeDefs.filter(d => imports.has(d.file));
|
|
3658
|
+
if (imported.length === 1) return { fromFile: imported[0].file };
|
|
3659
|
+
}
|
|
3660
|
+
return null;
|
|
3661
|
+
}
|
|
3662
|
+
|
|
3663
|
+
/**
|
|
3664
|
+
* Defs that live in the package an import-qualified receiver names, resolved
|
|
3665
|
+
* through the producer file's imports (alias-aware — importNames pairs 1:1
|
|
3666
|
+
* with imports for Go). STRICT counterpart of _receiverPackageResolution:
|
|
3667
|
+
* used for POSITIVE typing (fix #207), so root-package defs ("." — package
|
|
3668
|
+
* identity unverifiable from paths) never match here, while over there they
|
|
3669
|
+
* must never be excluded. Returns null when the receiver names no import.
|
|
3670
|
+
*/
|
|
3671
|
+
function _qualifiedProducerDefs(index, fileEntry, receiver, defs) {
|
|
3672
|
+
const modules = fileEntry.imports || [];
|
|
3673
|
+
const names = fileEntry.importNames || [];
|
|
3674
|
+
let importModule = null;
|
|
3675
|
+
if (names.length === modules.length) {
|
|
3676
|
+
const i = names.indexOf(receiver);
|
|
3677
|
+
if (i >= 0) importModule = modules[i];
|
|
3678
|
+
}
|
|
3679
|
+
if (!importModule) {
|
|
3680
|
+
importModule = modules.find(mod => {
|
|
3681
|
+
const parts = mod.split('/');
|
|
3682
|
+
const last = parts[parts.length - 1];
|
|
3683
|
+
const pkgName = (/^v\d+$/.test(last) && parts.length > 1) ? parts[parts.length - 2] : last;
|
|
3684
|
+
return pkgName === receiver;
|
|
3685
|
+
}) || null;
|
|
3686
|
+
}
|
|
3687
|
+
if (!importModule || !importModule.includes('/')) return null;
|
|
3688
|
+
const parts = importModule.split('/');
|
|
3689
|
+
const last = parts[parts.length - 1];
|
|
3690
|
+
const pkgSeg = (/^v\d+$/.test(last) && parts.length > 1) ? parts[parts.length - 2] : last;
|
|
3691
|
+
return defs.filter(d => {
|
|
3692
|
+
if (!d.file) return false;
|
|
3693
|
+
const dir = path.dirname(d.file);
|
|
3694
|
+
const relDir = index.root ? path.relative(index.root, dir) : '';
|
|
3695
|
+
if (!relDir || relDir === '.' || relDir.startsWith('..')) return false;
|
|
3696
|
+
if (importModule === relDir || importModule.endsWith('/' + relDir)) return true;
|
|
3697
|
+
const base = path.basename(dir);
|
|
3698
|
+
return base === pkgSeg || base === receiver;
|
|
3699
|
+
});
|
|
3700
|
+
}
|
|
3701
|
+
|
|
3702
|
+
// Builtin receiver types from literal/annotation inference (Python builtins,
|
|
3703
|
+
// JS globals, TS predefined types). Definitionally not project classes, so a
|
|
3704
|
+
// mismatch against a project class target is always positive evidence.
|
|
3705
|
+
const BUILTIN_RECEIVER_TYPES = new Set([
|
|
3706
|
+
'dict', 'list', 'set', 'tuple', 'str', 'int', 'float', 'bool', 'bytes', 'frozenset',
|
|
3707
|
+
'Array', 'String', 'Object', 'RegExp', 'Number', 'Boolean', 'Map', 'Set', 'Promise',
|
|
3708
|
+
'WeakMap', 'WeakSet',
|
|
3709
|
+
'string', 'number', 'boolean', 'bigint', 'symbol',
|
|
3710
|
+
]);
|
|
3711
|
+
|
|
3712
|
+
/**
|
|
3713
|
+
* Can this receiverType justify EXCLUDING a caller (structural languages)?
|
|
3714
|
+
* True for builtins and names that resolve to a project class/struct — types
|
|
3715
|
+
* whose identity and hierarchy UCN tracks. False for type aliases, interfaces,
|
|
3716
|
+
* and external types: those can wrap or alias the target, so a name mismatch
|
|
3717
|
+
* is not evidence against it.
|
|
3718
|
+
*/
|
|
3719
|
+
function _receiverTypeTrustedForExclusion(index, typeName) {
|
|
3720
|
+
if (BUILTIN_RECEIVER_TYPES.has(typeName)) return true;
|
|
3721
|
+
const defs = index.symbols.get(typeName);
|
|
3722
|
+
return !!defs && defs.some(d => d.type === 'class' || d.type === 'struct');
|
|
3723
|
+
}
|
|
3724
|
+
|
|
3725
|
+
/**
|
|
3726
|
+
* Resolve which same-name TYPE an unqualified receiver-type name denotes from
|
|
3727
|
+
* a caller file's scope, and compare it against the pinned target's package
|
|
3728
|
+
* (fix #206 — cross-package type-name conflation: grpc-go defines ~20 structs
|
|
3729
|
+
* all named `bb`; a receiver typed `bb` in package leastrequest is not
|
|
3730
|
+
* evidence for cdsbalancer's bb.ParseConfig).
|
|
3731
|
+
*
|
|
3732
|
+
* Nearest-scope resolution: same file → same directory (Go packages, Java
|
|
3733
|
+
* packages, Rust sibling modules) → an import edge to the defining file.
|
|
3734
|
+
*
|
|
3735
|
+
* Returns:
|
|
3736
|
+
* 'target' — the name resolves to the target's package, or only one type
|
|
3737
|
+
* definition exists project-wide (name IS identity)
|
|
3738
|
+
* 'other' — positive evidence it denotes a DIFFERENT same-name type
|
|
3739
|
+
* 'unknown' — several same-name types exist and none is resolvable from
|
|
3740
|
+
* this file's scope (not evidence either way)
|
|
3741
|
+
*/
|
|
3742
|
+
/**
|
|
3743
|
+
* Resolve a Go package-qualified receiver to its import module and decide
|
|
3744
|
+
* whether the pinned targets can live in that module's package (fix #206b).
|
|
3745
|
+
* Alias-aware: importNames[i] pairs 1:1 with imports[i] for Go (one package
|
|
3746
|
+
* name per import), so `v3corepb "github.com/.../core/v3"` resolves from the
|
|
3747
|
+
* alias, not the path segment.
|
|
3748
|
+
*
|
|
3749
|
+
* targetInPkg accepts a target whose project-relative directory is a SUFFIX
|
|
3750
|
+
* of the module path (robust when dir names diverge), or whose directory
|
|
3751
|
+
* basename matches the module's package segment / the receiver (conventional
|
|
3752
|
+
* fallback — also covers root-package projects, where relative dir is '').
|
|
3753
|
+
*
|
|
3754
|
+
* Returns null when the receiver names no import (likely a variable).
|
|
3755
|
+
*/
|
|
3756
|
+
function _receiverPackageResolution(index, fileEntry, receiver, targetDefs) {
|
|
3757
|
+
const modules = fileEntry.imports || [];
|
|
3758
|
+
const names = fileEntry.importNames || [];
|
|
3759
|
+
let importModule = null;
|
|
3760
|
+
if (names.length === modules.length) {
|
|
3761
|
+
const i = names.indexOf(receiver);
|
|
3762
|
+
if (i >= 0) importModule = modules[i];
|
|
3763
|
+
}
|
|
3764
|
+
if (!importModule) {
|
|
3765
|
+
importModule = modules.find(mod => {
|
|
3766
|
+
const parts = mod.split('/');
|
|
3767
|
+
const last = parts[parts.length - 1];
|
|
3768
|
+
const pkgName = (/^v\d+$/.test(last) && parts.length > 1) ? parts[parts.length - 2] : last;
|
|
3769
|
+
return pkgName === receiver;
|
|
3770
|
+
}) || null;
|
|
3771
|
+
}
|
|
3772
|
+
if (!importModule) return null;
|
|
3773
|
+
if (!importModule.includes('/')) return { importModule, singleSegment: true, targetInPkg: false };
|
|
3774
|
+
const parts = importModule.split('/');
|
|
3775
|
+
const last = parts[parts.length - 1];
|
|
3776
|
+
const pkgSeg = (/^v\d+$/.test(last) && parts.length > 1) ? parts[parts.length - 2] : last;
|
|
3777
|
+
const targetInPkg = targetDefs.some(d => {
|
|
3778
|
+
if (!d.file) return false;
|
|
3779
|
+
const dir = path.dirname(d.file);
|
|
3780
|
+
const relDir = index.root ? path.relative(index.root, dir) : '';
|
|
3781
|
+
// Root-package target: its directory is the clone dir — package
|
|
3782
|
+
// identity is unverifiable from PATHS, and exclusion requires
|
|
3783
|
+
// POSITIVE evidence. go.mod's module line IS that identity (fix
|
|
3784
|
+
// #220, cobra-measured): `exec.Command(...)` on import "os/exec"
|
|
3785
|
+
// can never denote the root package's Command, while the root
|
|
3786
|
+
// self-import `cobra "github.com/spf13/cobra"` matches exactly.
|
|
3787
|
+
// Without a go.mod, never exclude (checkout-dir-name luck).
|
|
3788
|
+
if (!relDir || relDir === '.') {
|
|
3789
|
+
const goMod = findGoModule(index.root);
|
|
3790
|
+
if (goMod && goMod.modulePath) {
|
|
3791
|
+
// index.root may be a subtree of the go.mod root (grpc-go's
|
|
3792
|
+
// internal/xds target): the root package's import path is
|
|
3793
|
+
// modulePath + the subtree's relative path.
|
|
3794
|
+
let effective = goMod.modulePath;
|
|
3795
|
+
const sub = goMod.root && path.relative(goMod.root, index.root);
|
|
3796
|
+
if (sub && sub !== '.' && !sub.startsWith('..')) {
|
|
3797
|
+
effective = effective + '/' + sub.split(path.sep).join('/');
|
|
3798
|
+
}
|
|
3799
|
+
return importModule === effective;
|
|
3800
|
+
}
|
|
3801
|
+
return true;
|
|
3802
|
+
}
|
|
3803
|
+
if (!relDir.startsWith('..') &&
|
|
3804
|
+
(importModule === relDir || importModule.endsWith('/' + relDir))) return true;
|
|
3805
|
+
const base = path.basename(dir);
|
|
3806
|
+
return base === pkgSeg || base === receiver;
|
|
3807
|
+
});
|
|
3808
|
+
return { importModule, singleSegment: false, targetInPkg };
|
|
3809
|
+
}
|
|
3810
|
+
|
|
3811
|
+
/**
|
|
3812
|
+
* Name-level export-chain reachability (fix #217, rich-measured: 24 test-file
|
|
3813
|
+
* `render(bar)` calls confirmed against markup.render although the binding
|
|
3814
|
+
* `from .render import render` pins to tests/render.py's OWN def — file-level
|
|
3815
|
+
* _importReaches chased on through console.py's imports).
|
|
3816
|
+
*
|
|
3817
|
+
* A binding of NAME resolved to a module file can only denote a def in a
|
|
3818
|
+
* target file if the NAME itself flows there: through the file being a target
|
|
3819
|
+
* file, a re-export of the name (`export {x} from` / `export * from` /
|
|
3820
|
+
* Python `from .x import name`), or surfaces the chase cannot model. Verdicts:
|
|
3821
|
+
* 'yes' — some chain reaches a target file (confirmable, as before)
|
|
3822
|
+
* 'no' — every chain terminates away from the targets (exclusion-grade:
|
|
3823
|
+
* the bare name provably denotes a different def)
|
|
3824
|
+
* 'unknown' — un-modelable surface on a live path: CJS exports (assignment-
|
|
3825
|
+
* based, attribute re-exports indistinguishable from local
|
|
3826
|
+
* values), star imports, module-scope assignments of the name,
|
|
3827
|
+
* module-level __getattr__ (PEP 562), unresolved project-ish
|
|
3828
|
+
* modules, depth exhaustion. Never exclusion evidence.
|
|
3829
|
+
* Pinned targets are defs NAMED `name`, so single renames along the chain
|
|
3830
|
+
* cannot fool a 'no' (a rename changes the exposed attribute name; re-renames
|
|
3831
|
+
* back to the original route through records this chase follows or flags).
|
|
3832
|
+
*/
|
|
3833
|
+
function _nameBindingReaches(index, startAbs, name, targetFiles, maxDepth = 4) {
|
|
3834
|
+
let unknown = false;
|
|
3835
|
+
const visited = new Set();
|
|
3836
|
+
let frontier = [[startAbs, name]];
|
|
3837
|
+
for (let d = 0; d <= maxDepth && frontier.length > 0; d++) {
|
|
3838
|
+
const next = [];
|
|
3839
|
+
for (const [abs, attr] of frontier) {
|
|
3840
|
+
if (targetFiles.has(abs)) return 'yes';
|
|
3841
|
+
const stateKey = `${abs}\x00${attr}`;
|
|
3842
|
+
if (visited.has(stateKey)) continue;
|
|
3843
|
+
visited.add(stateKey);
|
|
3844
|
+
const fe = index.files.get(abs);
|
|
3845
|
+
if (!fe) { unknown = true; continue; }
|
|
3846
|
+
|
|
3847
|
+
const enqueue = (module, nextAttr) => {
|
|
3848
|
+
const rel = fe.moduleResolved && fe.moduleResolved[module];
|
|
3849
|
+
if (!rel) {
|
|
3850
|
+
// Unresolved: relative or project-ish → resolver gap, not
|
|
3851
|
+
// a terminal; clearly external → that path pins outside
|
|
3852
|
+
// the project (dead end, consistent with #209c).
|
|
3853
|
+
const mod = String(module);
|
|
3854
|
+
const firstSeg = mod.split(/[./]/).filter(Boolean)[0];
|
|
3855
|
+
if (mod.startsWith('.') ||
|
|
3856
|
+
(firstSeg && _projectTopLevelNames(index).has(firstSeg))) unknown = true;
|
|
3857
|
+
return;
|
|
3858
|
+
}
|
|
3859
|
+
next.push([path.join(index.root, rel), nextAttr]);
|
|
3860
|
+
};
|
|
3861
|
+
|
|
3862
|
+
// CJS export surface is assignment-based (`exports.x = require(..).x`,
|
|
3863
|
+
// `module.exports = require(..)`) and recorded indistinguishably from
|
|
3864
|
+
// local values — a CJS file can never produce a definitive dead end.
|
|
3865
|
+
if ((fe.exportDetails || []).some(e => e.type === 'exports' || e.type === 'module.exports')) {
|
|
3866
|
+
unknown = true;
|
|
3867
|
+
}
|
|
3868
|
+
// JS/TS re-export records: `export {x as y} from './src'` exposes y,
|
|
3869
|
+
// chase continues under the SOURCE-side name; `export * from`
|
|
3870
|
+
// exposes everything the source does. `export * as ns from`
|
|
3871
|
+
// (alias on the re-export-all) exposes ONLY `ns` — a module
|
|
3872
|
+
// namespace object, unmodelable when asked for — never the
|
|
3873
|
+
// source's flattened names.
|
|
3874
|
+
for (const e of (fe.exportDetails || [])) {
|
|
3875
|
+
if (!e.source) continue;
|
|
3876
|
+
if (e.type === 're-export' && (e.alias || e.name) === attr) enqueue(e.source, e.name);
|
|
3877
|
+
else if (e.type === 're-export-all') {
|
|
3878
|
+
if (e.alias) { if (e.alias === attr) unknown = true; }
|
|
3879
|
+
else enqueue(e.source, attr);
|
|
3880
|
+
}
|
|
3881
|
+
}
|
|
3882
|
+
// Import bindings of the attr (Python re-export idiom `from .x import
|
|
3883
|
+
// name`, JS import-then-export). importBindings store ORIGINAL names;
|
|
3884
|
+
// importAliases is a flat list (pairing to its import lost), so a
|
|
3885
|
+
// renamed import is followed under BOTH its original and local names —
|
|
3886
|
+
// over-following errs toward 'yes'/'unknown', never toward exclusion.
|
|
3887
|
+
const aliases = fe.importAliases || [];
|
|
3888
|
+
for (const b of (fe.importBindings || [])) {
|
|
3889
|
+
const exposed = [b.name, ...aliases.filter(a => a.original === b.name).map(a => a.local)];
|
|
3890
|
+
if (exposed.includes(attr)) enqueue(b.module, b.name);
|
|
3891
|
+
}
|
|
3892
|
+
// Un-modelable name sources on this file:
|
|
3893
|
+
if ((fe.importNames || []).includes('*')) unknown = true; // star import
|
|
3894
|
+
if ((fe.moduleAssignedNames || []).includes(attr)) unknown = true; // module-scope `attr = ...`
|
|
3895
|
+
if ((index.symbols.get('__getattr__') || []).some(s => s.file === abs && !s.className)) {
|
|
3896
|
+
unknown = true; // PEP 562 dynamic attrs
|
|
3897
|
+
}
|
|
3898
|
+
}
|
|
3899
|
+
frontier = next;
|
|
3900
|
+
}
|
|
3901
|
+
if (frontier.length > 0) unknown = true; // depth exhausted with live paths
|
|
3902
|
+
return unknown ? 'unknown' : 'no';
|
|
3903
|
+
}
|
|
3904
|
+
|
|
3905
|
+
/**
|
|
3906
|
+
* Bounded-depth reachability over the import graph: can `fromAbs` reach any
|
|
3907
|
+
* target file through re-export/import chains? Barrel hierarchies routinely
|
|
3908
|
+
* run 2-3 hops (zod: v4/index → classic/index → schemas), so name-level
|
|
3909
|
+
* module checks must not use a 1-hop budget (fix #209).
|
|
3910
|
+
*/
|
|
3911
|
+
function _importReaches(index, fromAbs, targetFiles, maxDepth = 4) {
|
|
3912
|
+
if (targetFiles.has(fromAbs)) return true;
|
|
3913
|
+
const visited = new Set([fromAbs]);
|
|
3914
|
+
let frontier = [fromAbs];
|
|
3915
|
+
for (let d = 0; d < maxDepth; d++) {
|
|
3916
|
+
const next = [];
|
|
3917
|
+
for (const f of frontier) {
|
|
3918
|
+
const edges = index.importGraph.get(f);
|
|
3919
|
+
if (!edges) continue;
|
|
3920
|
+
for (const e of edges) {
|
|
3921
|
+
if (visited.has(e)) continue;
|
|
3922
|
+
if (targetFiles.has(e)) return true;
|
|
3923
|
+
visited.add(e);
|
|
3924
|
+
next.push(e);
|
|
3925
|
+
}
|
|
3926
|
+
}
|
|
3927
|
+
if (next.length === 0) break;
|
|
3928
|
+
frontier = next;
|
|
3929
|
+
}
|
|
3930
|
+
return false;
|
|
3931
|
+
}
|
|
3932
|
+
|
|
3933
|
+
/**
|
|
3934
|
+
* Top-level path segments of the project (dir names + module names of root
|
|
3935
|
+
* files). Used to tell "module failed to resolve because it is EXTERNAL"
|
|
3936
|
+
* from "module failed to resolve because our resolver has a gap" — only the
|
|
3937
|
+
* former is exclusion evidence (fix #209). Memoized on the index.
|
|
3938
|
+
*/
|
|
3939
|
+
function _projectTopLevelNames(index) {
|
|
3940
|
+
if (index._projectTopLevelNames) return index._projectTopLevelNames;
|
|
3941
|
+
const names = new Set();
|
|
3942
|
+
for (const [, fe] of index.files) {
|
|
3943
|
+
const seg = (fe.relativePath || '').split(/[\\/]/)[0];
|
|
3944
|
+
if (!seg) continue;
|
|
3945
|
+
names.add(seg);
|
|
3946
|
+
const dot = seg.lastIndexOf('.');
|
|
3947
|
+
if (dot > 0) names.add(seg.slice(0, dot)); // utils.py → utils
|
|
3948
|
+
}
|
|
3949
|
+
index._projectTopLevelNames = names;
|
|
3950
|
+
return names;
|
|
3951
|
+
}
|
|
3952
|
+
|
|
3953
|
+
const IDENTITY_TYPE_KINDS = new Set(['class', 'struct', 'interface', 'trait', 'enum']);
|
|
3954
|
+
function _resolveReceiverTypeIdentity(index, filePath, knownType, targetDefs) {
|
|
3955
|
+
const typeDefs = (index.symbols.get(knownType) || []).filter(d => IDENTITY_TYPE_KINDS.has(d.type));
|
|
3956
|
+
if (typeDefs.length <= 1) return 'target';
|
|
3957
|
+
const targetDirs = new Set(targetDefs.map(d => d.file && path.dirname(d.file)).filter(Boolean));
|
|
3958
|
+
const inTargetPkg = (d) => d.file && targetDirs.has(path.dirname(d.file));
|
|
3959
|
+
const sameFile = typeDefs.filter(d => d.file === filePath);
|
|
3960
|
+
if (sameFile.length > 0) return sameFile.some(inTargetPkg) ? 'target' : 'other';
|
|
3961
|
+
const callerDir = path.dirname(filePath);
|
|
3962
|
+
const sameDir = typeDefs.filter(d => d.file && path.dirname(d.file) === callerDir);
|
|
3963
|
+
if (sameDir.length > 0) return sameDir.some(inTargetPkg) ? 'target' : 'other';
|
|
3964
|
+
const imports = index.importGraph.get(filePath);
|
|
3965
|
+
if (imports) {
|
|
3966
|
+
const imported = typeDefs.filter(d => d.file && imports.has(d.file));
|
|
3967
|
+
if (imported.length > 0) return imported.some(inTargetPkg) ? 'target' : 'other';
|
|
3968
|
+
}
|
|
3969
|
+
return 'unknown';
|
|
3970
|
+
}
|
|
3971
|
+
|
|
3972
|
+
/**
|
|
3973
|
+
* Is typeName an ancestor (transitively) of any target definition's class?
|
|
3974
|
+
* Used by receiver-class disambiguation: a receiver typed as a SUPERTYPE of
|
|
3975
|
+
* the target's class is not evidence against the target — dynamic dispatch
|
|
3976
|
+
* may run the target override at that site.
|
|
3977
|
+
*/
|
|
3978
|
+
function _isAncestorOfTargetClass(index, typeName, targetDefs) {
|
|
3979
|
+
const visited = new Set();
|
|
3980
|
+
const queue = [];
|
|
3981
|
+
for (const td of targetDefs) {
|
|
3982
|
+
const cls = td.className || (td.receiver && td.receiver.replace(/^\*/, ''));
|
|
3983
|
+
if (cls) queue.push({ name: cls, file: td.file });
|
|
3984
|
+
}
|
|
3985
|
+
while (queue.length > 0) {
|
|
3986
|
+
const { name, file } = queue.shift();
|
|
3987
|
+
if (visited.has(name)) continue;
|
|
3988
|
+
visited.add(name);
|
|
3989
|
+
const parents = index._getInheritanceParents(name, file) || [];
|
|
3990
|
+
for (const parent of parents) {
|
|
3991
|
+
if (parent === typeName) return true;
|
|
3992
|
+
if (!visited.has(parent)) {
|
|
3993
|
+
const parentFile = index._resolveClassFile ? index._resolveClassFile(parent, file) : file;
|
|
3994
|
+
queue.push({ name: parent, file: parentFile });
|
|
3995
|
+
}
|
|
3996
|
+
}
|
|
3997
|
+
}
|
|
3998
|
+
return false;
|
|
3999
|
+
}
|
|
4000
|
+
|
|
4001
|
+
/**
|
|
4002
|
+
* Do two classes share a project descendant (Python #202b guard)? With
|
|
4003
|
+
* multiple inheritance, `self.method()` inside Mixin dispatches through
|
|
4004
|
+
* type(self).__mro__ — a class C(Target, Mixin) looks the method up on
|
|
4005
|
+
* Target BEFORE Mixin, so a sibling-class exclusion is only sound when no
|
|
4006
|
+
* project class inherits from both sides. Conservative: any common
|
|
4007
|
+
* descendant keeps the edge regardless of MRO order.
|
|
4008
|
+
*/
|
|
4009
|
+
function _collectDescendants(index, className, cap = 256) {
|
|
4010
|
+
const out = new Set([className]);
|
|
4011
|
+
const queue = [className];
|
|
4012
|
+
while (queue.length > 0 && out.size < cap) {
|
|
4013
|
+
const children = index.extendedByGraph?.get(queue.pop());
|
|
4014
|
+
if (!children) continue;
|
|
4015
|
+
for (const child of children) {
|
|
4016
|
+
const cName = typeof child === 'string' ? child : child.name;
|
|
4017
|
+
if (!cName || out.has(cName)) continue;
|
|
4018
|
+
out.add(cName);
|
|
4019
|
+
queue.push(cName);
|
|
4020
|
+
}
|
|
4021
|
+
}
|
|
4022
|
+
return out;
|
|
4023
|
+
}
|
|
4024
|
+
|
|
4025
|
+
function _shareProjectDescendant(index, className, targetClasses) {
|
|
4026
|
+
if (!targetClasses || targetClasses.size === 0) return false;
|
|
4027
|
+
const mine = _collectDescendants(index, className);
|
|
4028
|
+
for (const t of targetClasses) {
|
|
4029
|
+
const theirs = _collectDescendants(index, t);
|
|
4030
|
+
// matchedClass BELOW the target: every descendant's MRO finds the
|
|
4031
|
+
// matched override before the target (subclass precedes superclass
|
|
4032
|
+
// in C3) — the target def is unreachable from this site, exclusion
|
|
4033
|
+
// stands. Not an MRO trap.
|
|
4034
|
+
if (theirs.has(className)) continue;
|
|
4035
|
+
for (const d of theirs) {
|
|
4036
|
+
if (mine.has(d)) return true;
|
|
4037
|
+
}
|
|
4038
|
+
}
|
|
4039
|
+
return false;
|
|
4040
|
+
}
|
|
4041
|
+
|
|
4042
|
+
/**
|
|
4043
|
+
* Resolve a one-hop field receiver to the field's DECLARED type (fix #202):
|
|
4044
|
+
* rootType.fieldName → the field's declared type from the struct/class body
|
|
4045
|
+
* (Rust/Go/Java parsers emit field members with fieldType). Returns null —
|
|
4046
|
+
* never a wrong type — when: no such field, the declared type doesn't
|
|
4047
|
+
* normalize to a plain nominal name (slices, fn types, wrappers), same-named
|
|
4048
|
+
* classes disagree, or the type is a trait/interface (dynamic dispatch —
|
|
4049
|
+
* a trait-typed field is not evidence against any implementor).
|
|
4050
|
+
*/
|
|
4051
|
+
function _declaredFieldType(index, rootType, fieldName, language) {
|
|
4052
|
+
const defs = index.symbols.get(fieldName);
|
|
4053
|
+
if (!defs) return null;
|
|
4054
|
+
// 'private field' (JS #-fields, fix #219): equally compiler-true, and
|
|
4055
|
+
// safer — nothing outside the class can rebind them.
|
|
4056
|
+
const fields = defs.filter(d =>
|
|
4057
|
+
(d.type === 'field' || d.memberType === 'field' || d.memberType === 'private field') &&
|
|
4058
|
+
d.className === rootType && d.fieldType);
|
|
4059
|
+
if (fields.length === 0) return null;
|
|
4060
|
+
const normalized = new Set();
|
|
4061
|
+
for (const f of fields) {
|
|
4062
|
+
const t = _normalizeFieldTypeName(f.fieldType, language);
|
|
4063
|
+
if (t) normalized.add(t);
|
|
4064
|
+
else return null; // any un-normalizable declaration → no evidence
|
|
4065
|
+
}
|
|
4066
|
+
if (normalized.size !== 1) return null; // same-named classes disagree
|
|
4067
|
+
const typeName = [...normalized][0];
|
|
4068
|
+
// Generic type parameters by convention (T, K, V1 — fix #220,
|
|
4069
|
+
// cursive-measured): `view: T` declares the field as WHATEVER the
|
|
4070
|
+
// instantiation chose — not a type identity. Without this, the hop
|
|
4071
|
+
// "validated" against Rust blanket impls (`impl<T: ViewWrapper> View
|
|
4072
|
+
// for T` records className 'T'), confirming self.view.layout() for
|
|
4073
|
+
// every wrapper view. A short-caps name with a real project type def
|
|
4074
|
+
// (class A in a fixture) is a class, not a generic param.
|
|
4075
|
+
if (/^[A-Z][A-Z0-9]?$/.test(typeName) &&
|
|
4076
|
+
!(index.symbols.get(typeName) || []).some(d => IDENTITY_TYPE_KINDS.has(d.type))) return null;
|
|
4077
|
+
const typeDefs = index.symbols.get(typeName);
|
|
4078
|
+
if (typeDefs && typeDefs.some(d => d.type === 'trait' || d.type === 'interface')) return null;
|
|
4079
|
+
return typeName;
|
|
4080
|
+
}
|
|
4081
|
+
|
|
4082
|
+
/**
|
|
4083
|
+
* Can this call's argument count fit any target definition's parameter
|
|
4084
|
+
* range? (Nominal languages only — their compilers enforce arity, so a
|
|
4085
|
+
* mismatch is positive evidence the call binds a different symbol.)
|
|
4086
|
+
* Accepts both the bound form (obj.m(a)) and the unbound/UFCS form
|
|
4087
|
+
* (Type::m(&obj, a) / Class.m(obj, a)) for method targets. Returns true
|
|
4088
|
+
* whenever the signature is unknown, variadic, or the target is not a
|
|
4089
|
+
* plain callable — unknown never excludes.
|
|
4090
|
+
*/
|
|
4091
|
+
function _callArityCompatible(call, targetDefs, language) {
|
|
4092
|
+
const traits = langTraits(language);
|
|
4093
|
+
const selfNames = new Set((traits?.selfParam || [])
|
|
4094
|
+
.map(s => String(s).replace(/&|mut\s*/g, '').trim()));
|
|
4095
|
+
let sawComparable = false;
|
|
4096
|
+
for (const def of targetDefs) {
|
|
4097
|
+
if (NON_CALLABLE_TYPES.has(def.type)) return true;
|
|
4098
|
+
const ps = def.paramsStructured;
|
|
4099
|
+
if (!Array.isArray(ps)) return true;
|
|
4100
|
+
if (ps.some(p => p && p.rest)) return true;
|
|
4101
|
+
const params = ps.filter((p, i) => !(i === 0 && p &&
|
|
4102
|
+
selfNames.has(String(p.name || '').replace(/&|mut\s*/g, '').trim())));
|
|
4103
|
+
const isMethodDef = !!(def.className || def.receiver);
|
|
4104
|
+
// The receiver-as-first-arg shift applies only to call shapes that
|
|
4105
|
+
// can actually be unbound: Rust UFCS (Type::method(&x)) and Go
|
|
4106
|
+
// method expressions (Type.Method(recv)) — the receiver text IS the
|
|
4107
|
+
// type. Java has no unbound instance-call form.
|
|
4108
|
+
const defType = def.className || (def.receiver || '').replace(/^\*/, '');
|
|
4109
|
+
const unboundForm = call.isPathCall || (!!call.receiver && call.receiver === defType);
|
|
4110
|
+
const max = params.length + (isMethodDef && unboundForm ? 1 : 0);
|
|
4111
|
+
const min = params.filter(p => p && !p.optional && p.default === undefined).length;
|
|
4112
|
+
sawComparable = true;
|
|
4113
|
+
if (langTraits(language)?.packageScope === 'directory') {
|
|
4114
|
+
// Go: f(g()) tuple expansion can fill several params with one
|
|
4115
|
+
// syntactic arg — too-few never excludes, only too-many.
|
|
4116
|
+
if (call.argCount <= max) return true;
|
|
4117
|
+
} else if (call.argCount >= min && call.argCount <= max) {
|
|
4118
|
+
return true;
|
|
4119
|
+
}
|
|
4120
|
+
}
|
|
4121
|
+
return sawComparable ? false : true;
|
|
4122
|
+
}
|
|
4123
|
+
|
|
4124
|
+
const JAVA_PRIMITIVES = new Set(['int', 'long', 'short', 'byte', 'char', 'float', 'double', 'boolean']);
|
|
4125
|
+
|
|
4126
|
+
// Which parameter types a call-site literal kind can bind (Java overload
|
|
4127
|
+
// resolution: identity, widening, boxing — plus the boxed types' interfaces).
|
|
4128
|
+
// Anything not provably incompatible MATCHES: only certainty excludes.
|
|
4129
|
+
const JAVA_KIND_TYPES = {
|
|
4130
|
+
string: ['String', 'CharSequence', 'Comparable', 'Serializable'],
|
|
4131
|
+
char: ['char', 'Character', 'int', 'long', 'float', 'double', 'Comparable', 'Serializable'],
|
|
4132
|
+
int: ['int', 'long', 'float', 'double', 'Integer', 'Number', 'Comparable', 'Serializable'],
|
|
4133
|
+
long: ['long', 'float', 'double', 'Long', 'Number', 'Comparable', 'Serializable'],
|
|
4134
|
+
float: ['float', 'double', 'Float', 'Number', 'Comparable', 'Serializable'],
|
|
4135
|
+
double: ['double', 'Double', 'Number', 'Comparable', 'Serializable'],
|
|
4136
|
+
boolean: ['boolean', 'Boolean', 'Comparable', 'Serializable'],
|
|
4137
|
+
};
|
|
4138
|
+
|
|
4139
|
+
/**
|
|
4140
|
+
* Can an argument of static kind `kind` (from the Java parser's argKinds)
|
|
4141
|
+
* bind a parameter declared as `paramType`? Unknown kinds ('expr',
|
|
4142
|
+
* 'lambda'), unknown/generic param types, and unresolvable hierarchies all
|
|
4143
|
+
* match — a mismatch must be provable to count.
|
|
4144
|
+
*/
|
|
4145
|
+
function _javaArgKindMatches(index, kind, paramType) {
|
|
4146
|
+
if (!kind || kind === 'expr' || kind === 'lambda') return true;
|
|
4147
|
+
if (!paramType) return true;
|
|
4148
|
+
const bare = String(paramType).replace(/<.*$/s, '').trim()
|
|
4149
|
+
.replace(/\.\.\.$/, '').replace(/\[\]$/, '').split('.').pop();
|
|
4150
|
+
if (!bare || bare === 'Object') return true;
|
|
4151
|
+
if (/^[A-Z][0-9]?$/.test(bare)) return true; // generic type variable (T, E, K1...)
|
|
4152
|
+
if (kind === 'null') return !JAVA_PRIMITIVES.has(bare);
|
|
4153
|
+
if (kind.startsWith('new:') || kind.startsWith('cast:')) {
|
|
4154
|
+
const t = kind.slice(kind.indexOf(':') + 1);
|
|
4155
|
+
if (t === bare) return true;
|
|
4156
|
+
const tDefs = (index.symbols.get(t) || [])
|
|
4157
|
+
.filter(d => d.type === 'class' || d.type === 'interface');
|
|
4158
|
+
if (tDefs.length === 0) return true; // external arg type — unknowable
|
|
4159
|
+
const asTarget = [{ className: t, file: tDefs[0].file }];
|
|
4160
|
+
if (_isDispatchAncestor(index, bare, asTarget)) return true;
|
|
4161
|
+
// Deny only when t's ancestry is fully project-visible — a chain
|
|
4162
|
+
// that dead-ends external may still reach paramType.
|
|
4163
|
+
return !_targetAncestryFullyResolved(index, asTarget);
|
|
4164
|
+
}
|
|
4165
|
+
const allowed = JAVA_KIND_TYPES[kind];
|
|
4166
|
+
if (!allowed) return true;
|
|
4167
|
+
return allowed.includes(bare);
|
|
4168
|
+
}
|
|
4169
|
+
|
|
4170
|
+
/** Is overload `def` applicable to this call's static argument shape? */
|
|
4171
|
+
function _overloadApplicable(index, call, def) {
|
|
4172
|
+
const ps = def.paramsStructured;
|
|
4173
|
+
if (!Array.isArray(ps)) return true;
|
|
4174
|
+
const hasRest = ps.some(p => p && p.rest);
|
|
4175
|
+
const min = ps.filter(p => p && !p.optional && p.default === undefined && !p.rest).length;
|
|
4176
|
+
if (call.argCount < min) return false;
|
|
4177
|
+
if (!hasRest && call.argCount > ps.length) return false;
|
|
4178
|
+
const kinds = call.argKinds;
|
|
4179
|
+
if (!Array.isArray(kinds)) return true;
|
|
4180
|
+
for (let i = 0; i < kinds.length && i < ps.length; i++) {
|
|
4181
|
+
const p = ps[i];
|
|
4182
|
+
if (!p || p.rest) break;
|
|
4183
|
+
if (!_javaArgKindMatches(index, kinds[i], p.type)) return false;
|
|
4184
|
+
}
|
|
4185
|
+
return true;
|
|
4186
|
+
}
|
|
4187
|
+
|
|
4188
|
+
/**
|
|
4189
|
+
* Overload discipline (Java): when the pinned target has same-class sibling
|
|
4190
|
+
* overloads, decide what the call site's static argument shape proves.
|
|
4191
|
+
* Returns 'other-overload' (binds a sibling — exclusion evidence),
|
|
4192
|
+
* {ambiguous, candidates} (cannot tell — visible unverified), or null
|
|
4193
|
+
* (no siblings / uniquely the pinned one / model has no opinion).
|
|
4194
|
+
*/
|
|
4195
|
+
function _overloadDiscipline(index, call, targetDefs, definitions) {
|
|
4196
|
+
const targetOwners = new Set(targetDefs.map(d => d.className).filter(Boolean));
|
|
4197
|
+
if (targetOwners.size === 0) return null;
|
|
4198
|
+
const family = definitions.filter(d => !NON_CALLABLE_TYPES.has(d.type) &&
|
|
4199
|
+
d.className && targetOwners.has(d.className));
|
|
4200
|
+
if (family.length <= 1) return null;
|
|
4201
|
+
const pinnedKeys = new Set(targetDefs.map(d => `${d.file}:${d.startLine}`));
|
|
4202
|
+
if (family.every(d => pinnedKeys.has(`${d.file}:${d.startLine}`))) return null;
|
|
4203
|
+
const applicable = family.filter(d => _overloadApplicable(index, call, d));
|
|
4204
|
+
if (applicable.length === 0) return null; // shape fits nothing we model — no claim
|
|
4205
|
+
const pinnedApplicable = applicable.some(d => pinnedKeys.has(`${d.file}:${d.startLine}`));
|
|
4206
|
+
if (!pinnedApplicable) return 'other-overload';
|
|
4207
|
+
if (applicable.length === 1) return null; // uniquely the pinned overload
|
|
4208
|
+
return { ambiguous: true, candidates: applicable.length };
|
|
4209
|
+
}
|
|
4210
|
+
|
|
4211
|
+
/**
|
|
4212
|
+
* Build the target type set for receiver-class disambiguation: target
|
|
4213
|
+
* classes/receivers + their non-overriding subtypes (transitively). A Child
|
|
4214
|
+
* receiver calling an inherited Base method IS a caller of Base.method;
|
|
4215
|
+
* children that define the method themselves dispatch to the override.
|
|
4216
|
+
*/
|
|
4217
|
+
function _buildTargetTypeSet(index, targetDefs, definitions) {
|
|
4218
|
+
const targetTypes = new Set();
|
|
4219
|
+
for (const td of targetDefs) {
|
|
4220
|
+
if (td.className) targetTypes.add(td.className);
|
|
4221
|
+
if (td.receiver) targetTypes.add(td.receiver.replace(/^\*/, ''));
|
|
4222
|
+
}
|
|
4223
|
+
if (targetTypes.size > 0) {
|
|
4224
|
+
const queue = [...targetTypes];
|
|
4225
|
+
while (queue.length > 0) {
|
|
4226
|
+
const children = index.extendedByGraph?.get(queue.pop());
|
|
4227
|
+
if (!children) continue;
|
|
4228
|
+
for (const child of children) {
|
|
4229
|
+
const cName = typeof child === 'string' ? child : child.name;
|
|
4230
|
+
if (!cName || targetTypes.has(cName)) continue;
|
|
4231
|
+
const overrides = definitions.some(d => d.className === cName);
|
|
4232
|
+
if (overrides) continue;
|
|
4233
|
+
targetTypes.add(cName);
|
|
4234
|
+
queue.push(cName);
|
|
4235
|
+
}
|
|
4236
|
+
}
|
|
4237
|
+
}
|
|
4238
|
+
// Type aliases are the SAME type (Rust `pub type StyledString =
|
|
4239
|
+
// SpannedString<Style>`, Go `type A = B`) — compiler-checked identity,
|
|
4240
|
+
// not a subtype edge. Close over them in BOTH directions: a method on
|
|
4241
|
+
// the base must accept alias-qualified receivers (cursive-measured: 24
|
|
4242
|
+
// StyledString::plain edges wrongly excluded as path-type-mismatch),
|
|
4243
|
+
// and a method on an inherent alias impl must accept base receivers.
|
|
4244
|
+
// Sound only when EVERY type-kind def of the name is an alias agreeing
|
|
4245
|
+
// on one base — a same-name alias to a different type in another
|
|
4246
|
+
// package must not confirm foreign receivers (#206 discipline). The
|
|
4247
|
+
// parser records aliasOf for Rust/Go; names without it never close.
|
|
4248
|
+
if (targetTypes.size > 0) {
|
|
4249
|
+
const aliasPairs = [];
|
|
4250
|
+
for (const [aliasName, defs] of index.symbols) {
|
|
4251
|
+
let base = null;
|
|
4252
|
+
let pure = true;
|
|
4253
|
+
for (const d of defs) {
|
|
4254
|
+
if (d.type !== 'type' && !IDENTITY_TYPE_KINDS.has(d.type)) continue;
|
|
4255
|
+
if (d.type === 'type' && d.aliasOf) {
|
|
4256
|
+
if (base === null) base = d.aliasOf;
|
|
4257
|
+
else if (base !== d.aliasOf) { pure = false; break; }
|
|
4258
|
+
} else { pure = false; break; }
|
|
4259
|
+
}
|
|
4260
|
+
if (pure && base) aliasPairs.push([aliasName, base]);
|
|
4261
|
+
}
|
|
4262
|
+
let changed = aliasPairs.length > 0;
|
|
4263
|
+
while (changed) {
|
|
4264
|
+
changed = false;
|
|
4265
|
+
for (const [a, b] of aliasPairs) {
|
|
4266
|
+
if (targetTypes.has(b) && !targetTypes.has(a)) { targetTypes.add(a); changed = true; }
|
|
4267
|
+
if (targetTypes.has(a) && !targetTypes.has(b)) { targetTypes.add(b); changed = true; }
|
|
4268
|
+
}
|
|
4269
|
+
}
|
|
4270
|
+
}
|
|
4271
|
+
return targetTypes;
|
|
4272
|
+
}
|
|
4273
|
+
|
|
4274
|
+
/**
|
|
4275
|
+
* Can a receiver typed as `typeName` VIRTUALLY dispatch into the target
|
|
4276
|
+
* definition? True when typeName is a project interface/trait that declares
|
|
4277
|
+
* the method or sits above the target's class, or — in languages where every
|
|
4278
|
+
* instance method is virtual (Java) — any supertype of the target. Go struct
|
|
4279
|
+
* embedding binds statically and never qualifies; Go interfaces qualify via
|
|
4280
|
+
* the declares-the-method check (satisfaction is implicit — there is no
|
|
4281
|
+
* recorded edge to walk). Used only to decide possible-dispatch ROUTING
|
|
4282
|
+
* (visible unverified), never to exclude.
|
|
4283
|
+
*/
|
|
4284
|
+
function _dispatchCapableSupertype(index, language, typeName, targetDefs, definitions) {
|
|
4285
|
+
const traits = langTraits(language);
|
|
4286
|
+
if (traits?.typeSystem !== 'nominal') return false;
|
|
4287
|
+
// The implicit root supertype (Java `Object`) sits above EVERY class
|
|
4288
|
+
// without a declared extends edge — `void show(Object o) { o.size() }`
|
|
4289
|
+
// can dispatch into any project override, but the ancestry walk below
|
|
4290
|
+
// cannot see the implicit edge (fix #212). Bare-name compare on the last
|
|
4291
|
+
// segment covers `java.lang.Object` annotations; a project class that
|
|
4292
|
+
// shadows the root name only ever gains routing (demote-only), never
|
|
4293
|
+
// loses an exclusion it was entitled to.
|
|
4294
|
+
if (traits.universalSupertype &&
|
|
4295
|
+
String(typeName).split('.').pop() === traits.universalSupertype) {
|
|
4296
|
+
return true;
|
|
4297
|
+
}
|
|
4298
|
+
const typeDefs = index.symbols.get(typeName) || [];
|
|
4299
|
+
const isIface = typeDefs.some(d => d.type === 'interface' || d.type === 'trait');
|
|
4300
|
+
if (isIface) {
|
|
4301
|
+
// The interface/trait declares this method → any implementor
|
|
4302
|
+
// (recorded or implicit) may receive the call.
|
|
4303
|
+
if (definitions.some(d => d.className === typeName)) return true;
|
|
4304
|
+
return _isDispatchAncestor(index, typeName, targetDefs);
|
|
4305
|
+
}
|
|
4306
|
+
if (traits.allMethodsVirtual) {
|
|
4307
|
+
return _isDispatchAncestor(index, typeName, targetDefs);
|
|
4308
|
+
}
|
|
4309
|
+
return false;
|
|
4310
|
+
}
|
|
4311
|
+
|
|
4312
|
+
/**
|
|
4313
|
+
* Like _isAncestorOfTargetClass, but walks `implements` records (Java
|
|
4314
|
+
* implements clauses, Rust `impl Trait for Type` surfaced as implements)
|
|
4315
|
+
* in addition to extends edges — the inheritance graph only stores extends,
|
|
4316
|
+
* yet virtual dispatch flows through interface/trait edges too. Routing
|
|
4317
|
+
* decision only (possible-dispatch vs excluded), never exclusion evidence.
|
|
4318
|
+
*/
|
|
4319
|
+
function _isDispatchAncestor(index, typeName, targetDefs) {
|
|
4320
|
+
const visited = new Set();
|
|
4321
|
+
const queue = [];
|
|
4322
|
+
for (const td of targetDefs) {
|
|
4323
|
+
const cls = td.className || (td.receiver && td.receiver.replace(/^\*/, ''));
|
|
4324
|
+
if (cls) queue.push({ name: cls, file: td.file });
|
|
4325
|
+
}
|
|
4326
|
+
while (queue.length > 0) {
|
|
4327
|
+
const { name, file } = queue.shift();
|
|
4328
|
+
if (visited.has(name)) continue;
|
|
4329
|
+
visited.add(name);
|
|
4330
|
+
const parents = [
|
|
4331
|
+
...(index._getInheritanceParents(name, file) || []),
|
|
4332
|
+
..._implementsParents(index, name),
|
|
4333
|
+
];
|
|
4334
|
+
for (const parent of parents) {
|
|
4335
|
+
if (parent === typeName) return true;
|
|
4336
|
+
if (!visited.has(parent)) {
|
|
4337
|
+
const parentFile = index._resolveClassFile ? index._resolveClassFile(parent, file) : file;
|
|
4338
|
+
queue.push({ name: parent, file: parentFile });
|
|
4339
|
+
}
|
|
4340
|
+
}
|
|
4341
|
+
}
|
|
4342
|
+
return false;
|
|
4343
|
+
}
|
|
4344
|
+
|
|
4345
|
+
/** Interface/trait names a class declares it implements (generics stripped). */
|
|
4346
|
+
function _implementsParents(index, className) {
|
|
4347
|
+
const defs = index.symbols.get(className);
|
|
4348
|
+
if (!defs) return [];
|
|
4349
|
+
const out = [];
|
|
4350
|
+
for (const d of defs) {
|
|
4351
|
+
if (!Array.isArray(d.implements)) continue;
|
|
4352
|
+
for (const p of d.implements) {
|
|
4353
|
+
const bare = String(p).replace(/<.*$/s, '').trim().split(/[.:]+/).pop();
|
|
4354
|
+
if (bare) out.push(bare);
|
|
4355
|
+
}
|
|
4356
|
+
}
|
|
4357
|
+
return out;
|
|
4358
|
+
}
|
|
4359
|
+
|
|
4360
|
+
/**
|
|
4361
|
+
* How many same-name method definitions could a call through `via` dispatch
|
|
4362
|
+
* to? Counts distinct owner types among the definitions that sit at or below
|
|
4363
|
+
* `via` (extends edges + implements records). Languages with implicit
|
|
4364
|
+
* interface satisfaction (Go) record no edges at all — fall back to the full
|
|
4365
|
+
* owner count. Display/routing enrichment only ("1 of N implementations").
|
|
4366
|
+
*/
|
|
4367
|
+
function _countDispatchCandidates(index, via, definitions) {
|
|
4368
|
+
const ownerFiles = new Map(); // owner type -> defining file
|
|
4369
|
+
for (const d of definitions) {
|
|
4370
|
+
if (NON_CALLABLE_TYPES.has(d.type)) continue;
|
|
4371
|
+
const o = d.className || (d.receiver && d.receiver.replace(/^\*/, ''));
|
|
4372
|
+
if (o && !ownerFiles.has(o)) ownerFiles.set(o, d.file);
|
|
4373
|
+
}
|
|
4374
|
+
if (ownerFiles.size === 0) return 0;
|
|
4375
|
+
// Interface/trait owners hold the abstract declaration, not a landing
|
|
4376
|
+
// site — "implementations" counts the concrete methods dispatch can run.
|
|
4377
|
+
const isIface = (o) => (index.symbols.get(o) || [])
|
|
4378
|
+
.some(d => d.type === 'interface' || d.type === 'trait');
|
|
4379
|
+
let count = 0;
|
|
4380
|
+
let concrete = 0;
|
|
4381
|
+
for (const [owner, file] of ownerFiles) {
|
|
4382
|
+
if (isIface(owner)) continue;
|
|
4383
|
+
concrete++;
|
|
4384
|
+
if (owner === via || _isDispatchAncestor(index, via, [{ className: owner, file }])) count++;
|
|
4385
|
+
}
|
|
4386
|
+
// No recorded edges below `via` (Go interfaces are satisfied implicitly)
|
|
4387
|
+
// → any concrete owner is a candidate.
|
|
4388
|
+
return count > 0 ? count : (concrete > 0 ? concrete : ownerFiles.size);
|
|
4389
|
+
}
|
|
4390
|
+
|
|
4391
|
+
/**
|
|
4392
|
+
* Resolve a one-hop field receiver to a declared project INTERFACE/TRAIT
|
|
4393
|
+
* type — exactly the case _declaredFieldType refuses (a trait-typed field is
|
|
4394
|
+
* not exclusion evidence against any implementor). Dispatch attribution
|
|
4395
|
+
* only: lets the unverified tier say "possible-dispatch via <Interface>".
|
|
4396
|
+
* Rust `dyn Trait` / `Box<dyn Trait>` / `&dyn Trait` resolve to Trait here.
|
|
4397
|
+
*/
|
|
4398
|
+
function _declaredFieldInterfaceType(index, rootType, fieldName, language) {
|
|
4399
|
+
const defs = index.symbols.get(fieldName);
|
|
4400
|
+
if (!defs) return null;
|
|
4401
|
+
const fields = defs.filter(d =>
|
|
4402
|
+
(d.type === 'field' || d.memberType === 'field') &&
|
|
4403
|
+
d.className === rootType && d.fieldType);
|
|
4404
|
+
if (fields.length === 0) return null;
|
|
4405
|
+
const normalized = new Set();
|
|
4406
|
+
for (const f of fields) {
|
|
4407
|
+
const t = _normalizeFieldTypeName(f.fieldType, language) ||
|
|
4408
|
+
(language === 'rust' ? _normalizeRustDynTypeName(f.fieldType) : null);
|
|
4409
|
+
if (t) normalized.add(t);
|
|
4410
|
+
else return null; // un-normalizable declaration → no attribution
|
|
4411
|
+
}
|
|
4412
|
+
if (normalized.size !== 1) return null; // same-named classes disagree
|
|
4413
|
+
const typeName = [...normalized][0];
|
|
4414
|
+
const typeDefs = index.symbols.get(typeName);
|
|
4415
|
+
if (!typeDefs || !typeDefs.some(d => d.type === 'trait' || d.type === 'interface')) return null;
|
|
4416
|
+
return typeName;
|
|
4417
|
+
}
|
|
4418
|
+
|
|
4419
|
+
/** Rust dyn-trait declarations: `dyn Flag`, `&dyn Flag`, `Box<dyn Flag>` → Flag. */
|
|
4420
|
+
function _normalizeRustDynTypeName(raw) {
|
|
4421
|
+
let t = String(raw).trim();
|
|
4422
|
+
let prev;
|
|
4423
|
+
do {
|
|
4424
|
+
prev = t;
|
|
4425
|
+
t = t.replace(/^&+\s*/, '').replace(/^'[A-Za-z_][A-Za-z0-9_]*\s*/, '').replace(/^mut\s+/, '');
|
|
4426
|
+
const wrap = t.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*<(.*)>$/s);
|
|
4427
|
+
if (wrap && _RUST_DEREF_WRAPPERS.has(wrap[1])) t = wrap[2].trim();
|
|
4428
|
+
} while (t !== prev);
|
|
4429
|
+
const m = t.match(/^dyn\s+([A-Za-z_][A-Za-z0-9_]*(?:\s*::\s*[A-Za-z_][A-Za-z0-9_]*)*)$/s);
|
|
4430
|
+
if (!m) return null;
|
|
4431
|
+
return m[1].split('::').pop().trim();
|
|
4432
|
+
}
|
|
4433
|
+
|
|
4434
|
+
/**
|
|
4435
|
+
* Is every ancestor in the targets' inheritance closure a project-resolvable
|
|
4436
|
+
* class? A chain that dead-ends at an EXTERNAL ancestor may continue into
|
|
4437
|
+
* supertypes UCN can't see, so absence-of-knownType in the visible chain is
|
|
4438
|
+
* not evidence (fix #202: external-type exclusion gate).
|
|
4439
|
+
*/
|
|
4440
|
+
function _targetAncestryFullyResolved(index, targetDefs) {
|
|
4441
|
+
const visited = new Set();
|
|
4442
|
+
const queue = [];
|
|
4443
|
+
for (const td of targetDefs) {
|
|
4444
|
+
const cls = td.className || (td.receiver && td.receiver.replace(/^\*/, ''));
|
|
4445
|
+
if (cls) queue.push({ name: cls, file: td.file });
|
|
4446
|
+
}
|
|
4447
|
+
while (queue.length > 0) {
|
|
4448
|
+
const { name, file } = queue.shift();
|
|
4449
|
+
if (visited.has(name)) continue;
|
|
4450
|
+
visited.add(name);
|
|
4451
|
+
const parents = index._getInheritanceParents(name, file) || [];
|
|
4452
|
+
for (const parent of parents) {
|
|
4453
|
+
const defs = index.symbols.get(parent);
|
|
4454
|
+
const isProject = !!defs && defs.some(d =>
|
|
4455
|
+
d.type === 'class' || d.type === 'struct' || d.type === 'interface' || d.type === 'trait');
|
|
4456
|
+
if (!isProject) return false; // external ancestor — chain invisible beyond here
|
|
4457
|
+
if (!visited.has(parent)) {
|
|
4458
|
+
const parentFile = index._resolveClassFile ? index._resolveClassFile(parent, file) : file;
|
|
4459
|
+
queue.push({ name: parent, file: parentFile });
|
|
4460
|
+
}
|
|
4461
|
+
}
|
|
4462
|
+
}
|
|
4463
|
+
return true;
|
|
4464
|
+
}
|
|
4465
|
+
|
|
4466
|
+
// _externalContractMarker moved to core/shared.js as isOverrideMarked (shared
|
|
4467
|
+
// with deadcode's out-of-tree override suppression — one source of truth).
|
|
4468
|
+
|
|
4469
|
+
/**
|
|
4470
|
+
* Name of the external contract a marked method implements, for dispatch
|
|
4471
|
+
* attribution ("possible-dispatch via Number — external contract"). Rust
|
|
4472
|
+
* impls name the trait directly; Java/TS/Python derive it from the class's
|
|
4473
|
+
* own extends/implements entries that do NOT resolve to project types.
|
|
4474
|
+
* Returns null when the contract type is not uniquely attributable —
|
|
4475
|
+
* the demotion still applies, only the label loses its `via`.
|
|
4476
|
+
*/
|
|
4477
|
+
function _externalContractVia(index, def) {
|
|
4478
|
+
if (def.traitName) {
|
|
4479
|
+
// rust `impl fmt::Display for X` → Display (strip path + generics)
|
|
4480
|
+
const bare = String(def.traitName).replace(/<.*$/, '').split('::').pop().trim();
|
|
4481
|
+
return bare || null;
|
|
4482
|
+
}
|
|
4483
|
+
const cls = def.className;
|
|
4484
|
+
if (!cls) return null;
|
|
4485
|
+
const classDefs = (index.symbols.get(cls) || []).filter(d =>
|
|
4486
|
+
d.file === def.file &&
|
|
4487
|
+
(d.type === 'class' || d.type === 'struct' || d.type === 'interface' || d.type === 'trait'));
|
|
4488
|
+
const supers = [];
|
|
4489
|
+
for (const cd of classDefs) {
|
|
4490
|
+
if (cd.extends) supers.push(...(Array.isArray(cd.extends) ? cd.extends : [cd.extends]));
|
|
4491
|
+
if (cd.implements) supers.push(...cd.implements);
|
|
4492
|
+
}
|
|
4493
|
+
const externals = [];
|
|
4494
|
+
for (const raw of supers) {
|
|
4495
|
+
const bare = String(raw).replace(/<.*$/, '').split('.').pop().trim();
|
|
4496
|
+
if (!bare) continue;
|
|
4497
|
+
const defs = index.symbols.get(bare);
|
|
4498
|
+
const isProject = !!defs && defs.some(d =>
|
|
4499
|
+
d.type === 'class' || d.type === 'struct' || d.type === 'interface' || d.type === 'trait');
|
|
4500
|
+
if (!isProject && !externals.includes(bare)) externals.push(bare);
|
|
4501
|
+
}
|
|
4502
|
+
if (externals.length === 1) return externals[0];
|
|
4503
|
+
if (externals.length === 0 && supers.length === 0 &&
|
|
4504
|
+
def.modifiers && def.modifiers.includes('override')) {
|
|
4505
|
+
// java: @Override with no explicit supertypes can only override
|
|
4506
|
+
// java.lang.Object (toString/equals/hashCode) — in compiling code.
|
|
4507
|
+
return 'Object';
|
|
4508
|
+
}
|
|
4509
|
+
return null; // several external candidates — attribution unknowable
|
|
4510
|
+
}
|
|
4511
|
+
|
|
4512
|
+
/** Rust deref-transparent wrappers: Box<X>/Rc<X>/Arc<X> auto-deref to X for method calls. */
|
|
4513
|
+
const _RUST_DEREF_WRAPPERS = new Set(['Box', 'Rc', 'Arc']);
|
|
4514
|
+
|
|
4515
|
+
/**
|
|
4516
|
+
* Normalize a declared field type to a bare nominal type name, or null when
|
|
4517
|
+
* the declaration carries no usable single-type evidence.
|
|
4518
|
+
* rust: `&'a mut ignore::DirEntry` → DirEntry; `Box<DirEntry>` → DirEntry;
|
|
4519
|
+
* `Box<dyn Flag>`/`dyn Flag`/`impl Trait` → null; tuples/fns → null
|
|
4520
|
+
* go: `*ignore.Ig` → Ig; slices/maps/chans/funcs → null
|
|
4521
|
+
* java: `java.util.List<Foo>` → List; arrays → null
|
|
4522
|
+
*/
|
|
4523
|
+
function _normalizeFieldTypeName(raw, language) {
|
|
4524
|
+
let t = String(raw).trim();
|
|
4525
|
+
if (language === 'rust') {
|
|
4526
|
+
let prev;
|
|
4527
|
+
do {
|
|
4528
|
+
prev = t;
|
|
4529
|
+
t = t.replace(/^&+\s*/, '').replace(/^'[A-Za-z_][A-Za-z0-9_]*\s*/, '').replace(/^mut\s+/, '');
|
|
4530
|
+
} while (t !== prev);
|
|
4531
|
+
if (/^(dyn|impl)\b/.test(t)) return null;
|
|
4532
|
+
const m = t.match(/^([A-Za-z_][A-Za-z0-9_]*(?:\s*::\s*[A-Za-z_][A-Za-z0-9_]*)*)\s*(?:<(.*)>)?$/s);
|
|
4533
|
+
if (!m) return null;
|
|
4534
|
+
const base = m[1].split('::').pop().trim();
|
|
4535
|
+
if (m[2] !== undefined && _RUST_DEREF_WRAPPERS.has(base)) {
|
|
4536
|
+
return _normalizeFieldTypeName(m[2], 'rust');
|
|
4537
|
+
}
|
|
4538
|
+
return base;
|
|
4539
|
+
}
|
|
4540
|
+
if (language === 'go') {
|
|
4541
|
+
t = t.replace(/^\*+/, '');
|
|
4542
|
+
const m = t.match(/^([A-Za-z_][A-Za-z0-9_]*)(?:\.([A-Za-z_][A-Za-z0-9_]*))?$/);
|
|
4543
|
+
if (!m) return null;
|
|
4544
|
+
return m[2] || m[1];
|
|
4545
|
+
}
|
|
4546
|
+
if (language === 'java') {
|
|
4547
|
+
const m = t.match(/^([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*(?:<.*>)?$/s);
|
|
4548
|
+
if (!m) return null;
|
|
4549
|
+
return m[1].split('.').pop();
|
|
4550
|
+
}
|
|
4551
|
+
if (langTraits(language)?.typeSystem === 'structural') {
|
|
4552
|
+
// JS/TS/Python (fix #219): compiler-true annotation heads, value-
|
|
4553
|
+
// position semantics — a field declared Promise<X> HOLDS a Promise.
|
|
4554
|
+
return _structuralTypeHead(t);
|
|
4555
|
+
}
|
|
4556
|
+
return null;
|
|
4557
|
+
}
|
|
4558
|
+
|
|
4559
|
+
// typing-module aliases for builtin containers — normalized to the runtime
|
|
4560
|
+
// type so BUILTIN_RECEIVER_TYPES and the trust gate see one name.
|
|
4561
|
+
const _PY_TYPING_BUILTINS = {
|
|
4562
|
+
Dict: 'dict', List: 'list', Set: 'set', Tuple: 'tuple',
|
|
4563
|
+
FrozenSet: 'frozenset', Text: 'str',
|
|
4564
|
+
};
|
|
4565
|
+
|
|
4566
|
+
/**
|
|
4567
|
+
* Single concrete type name from a STRUCTURAL annotation in value position
|
|
4568
|
+
* (fix #219): field declarations and chained-receiver producer returns.
|
|
4569
|
+
* Unlike _typeNameFromReturnAnnotation, Promise/Awaitable are NOT unwrapped
|
|
4570
|
+
* by default — the value IS the promise (`parseAsync(...).catch` dispatches
|
|
4571
|
+
* on Promise). opts.unwrapAsync handles `(await f()).m()`: TS annotations
|
|
4572
|
+
* unwrap their Promise/Awaitable head; a Python async producer's annotation
|
|
4573
|
+
* already names the awaited value, so it passes through unchanged.
|
|
4574
|
+
* Conservative: unions of two real types, function types, object literals,
|
|
4575
|
+
* and tuples return null — a wrong head would exclude true callers.
|
|
4576
|
+
*/
|
|
4577
|
+
function _structuralTypeHead(text, opts = {}) {
|
|
4578
|
+
if (!text || typeof text !== 'string') return null;
|
|
4579
|
+
let t = text.trim().replace(/^readonly\s+/, '').replace(/^["']|["']$/g, '').trim();
|
|
4580
|
+
if (t.includes('|')) {
|
|
4581
|
+
const parts = t.split('|').map(s => s.trim())
|
|
4582
|
+
.filter(s => s && !['None', 'null', 'undefined'].includes(s));
|
|
4583
|
+
if (parts.length !== 1) return null;
|
|
4584
|
+
t = parts[0];
|
|
4585
|
+
}
|
|
4586
|
+
let m;
|
|
4587
|
+
// type-transparent wrappers (the value's runtime type is the argument)
|
|
4588
|
+
while ((m = t.match(/^(?:typing\.)?(Optional|Annotated|Final)\s*[[<]\s*(.+)\s*[\]>]$/s))) {
|
|
4589
|
+
t = (_splitTopLevelGenericArgs(m[2])[0] || '').trim(); // Annotated[X, meta] → X
|
|
4590
|
+
}
|
|
4591
|
+
if (opts.unwrapAsync) {
|
|
4592
|
+
m = t.match(/^(?:typing\.)?(Promise|Awaitable|Coroutine)\s*[[<]\s*(.*)\s*[\]>]$/s);
|
|
4593
|
+
if (m) {
|
|
4594
|
+
const args = _splitTopLevelGenericArgs(m[2]).map(s => s.trim());
|
|
4595
|
+
// Coroutine[Y, S, R] resolves to its RETURN (last) argument
|
|
4596
|
+
t = (m[1] === 'Coroutine' ? args[args.length - 1] : args[0]) || '';
|
|
4597
|
+
}
|
|
4598
|
+
}
|
|
4599
|
+
if (/\[\]$/.test(t)) return 'Array'; // TS Foo[] — the value is an array
|
|
4600
|
+
m = t.match(/^([\w$.]+)\s*[[<]/s); // generic head: Foo<...> / dict[...]
|
|
4601
|
+
if (m) t = m[1];
|
|
4602
|
+
const last = t.split('.').pop();
|
|
4603
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(last)) return null; // fn types, object literals, tuples
|
|
4604
|
+
return _PY_TYPING_BUILTINS[last] || last;
|
|
4605
|
+
}
|
|
4606
|
+
|
|
4607
|
+
// Structural annotation heads that carry no receiver identity: TS escape
|
|
4608
|
+
// hatches, the receiver-polymorphic `this`/`Self`, and Python's object root.
|
|
4609
|
+
const _STRUCTURAL_FLOW_REJECT = new Set([
|
|
4610
|
+
'any', 'unknown', 'object', 'void', 'never', 'undefined', 'null',
|
|
4611
|
+
'this', 'Self', 'Object', 'None',
|
|
4612
|
+
]);
|
|
4613
|
+
|
|
4614
|
+
/**
|
|
4615
|
+
* Type a chained receiver from its producer's declared return annotation
|
|
4616
|
+
* (fix #219): `parseAsync(args).catch(...)` — the receiver of .catch IS the
|
|
4617
|
+
* parseAsync(...) call, so its return annotation is compiler-true receiver
|
|
4618
|
+
* evidence. Method producers follow the #207 agreement discipline: EVERY
|
|
4619
|
+
* same-name method def project-wide must carry a return annotation and all
|
|
4620
|
+
* heads must agree (whichever class the producer dispatches to, the type is
|
|
4621
|
+
* the same). Plain producers follow #199's unique-project-def rule. Python
|
|
4622
|
+
* async producers type only AWAITED chains — the bare value is a coroutine,
|
|
4623
|
+
* not the annotation's type (TS annotations already SAY Promise, so they
|
|
4624
|
+
* type either way).
|
|
4625
|
+
*/
|
|
4626
|
+
/**
|
|
4627
|
+
* Nominal chained-receiver typing (fix #220, cobra-measured — #219's part 2
|
|
4628
|
+
* extended past the structural gate now that a family is measured):
|
|
4629
|
+
* `rootCmd.Flags().String(...)` — the producer's compiler-checked return
|
|
4630
|
+
* annotation types the receiver. Reuses the #207 rails verbatim: method
|
|
4631
|
+
* producers must AGREE project-wide, plain producers are same-package-only
|
|
4632
|
+
* for Go (an unqualified call cannot cross packages), package-qualified
|
|
4633
|
+
* producers resolve strictly through the file's imports
|
|
4634
|
+
* (_qualifiedProducerDefs), and the type NAME pins to its defining file from
|
|
4635
|
+
* the PRODUCER's scope (_resolveFlowTypeOrigin). External producer packages
|
|
4636
|
+
* and reject-set returns stay untyped — no evidence either way.
|
|
4637
|
+
*/
|
|
4638
|
+
function _nominalChainedReceiverType(index, call, fileEntry, filePath) {
|
|
4639
|
+
const language = fileEntry.language;
|
|
4640
|
+
const defs = (index.symbols.get(call.receiverCall) || [])
|
|
4641
|
+
.filter(d => !NON_CALLABLE_TYPES.has(d.type));
|
|
4642
|
+
let producer = null;
|
|
4643
|
+
let selfClass;
|
|
4644
|
+
if (call.receiverCallReceiver) {
|
|
4645
|
+
// Package-qualified producer: os.CreateTemp().Name(). A package that
|
|
4646
|
+
// resolves to no project def decided the type OUTSIDE the project —
|
|
4647
|
+
// external-flow marker (blocks single-owner confirmation, routes
|
|
4648
|
+
// possible-dispatch; never excludes).
|
|
4649
|
+
const cands = defs.filter(d => d.returnType);
|
|
4650
|
+
const inPkg = _qualifiedProducerDefs(index, fileEntry, call.receiverCallReceiver, cands);
|
|
4651
|
+
if (!inPkg || inPkg.length === 0 ||
|
|
4652
|
+
new Set(inPkg.map(d => d.returnType)).size !== 1) {
|
|
4653
|
+
return { externalVia: `${call.receiverCallReceiver}.${call.receiverCall}` };
|
|
4654
|
+
}
|
|
4655
|
+
producer = inPkg[0];
|
|
4656
|
+
} else if (call.receiverCallIsMethod) {
|
|
4657
|
+
// Method producer: every same-name method def project-wide must carry
|
|
4658
|
+
// an annotation and agree (#219 discipline — whichever class
|
|
4659
|
+
// dispatches, the type is the same).
|
|
4660
|
+
const methodDefs = defs.filter(d => d.className || d.receiver);
|
|
4661
|
+
if (methodDefs.length === 0) return null;
|
|
4662
|
+
if (!methodDefs.every(d => d.returnType)) return null;
|
|
4663
|
+
if (new Set(methodDefs.map(d => d.returnType)).size !== 1) return null;
|
|
4664
|
+
producer = methodDefs[0];
|
|
4665
|
+
const classes = new Set(methodDefs.map(d =>
|
|
4666
|
+
d.className || (d.receiver || '').replace(/^\*/, '')));
|
|
4667
|
+
selfClass = classes.size === 1 ? [...classes][0] : undefined;
|
|
4668
|
+
} else {
|
|
4669
|
+
// Plain producer: Go resolves within the package; others same-file
|
|
4670
|
+
// narrowing, then global-unique (#199/#207 rules). Where bare calls
|
|
4671
|
+
// reach methods (Java), a bare producer is this.getConfig() — the
|
|
4672
|
+
// enclosing class's own method wins.
|
|
4673
|
+
if (langTraits(language)?.bareCallReachesMethods) {
|
|
4674
|
+
const enclosing = index.findEnclosingFunction(filePath, call.line, true);
|
|
4675
|
+
if (enclosing?.className) {
|
|
4676
|
+
const sameClass = defs.find(d => d.className === enclosing.className && d.returnType);
|
|
4677
|
+
if (sameClass) {
|
|
4678
|
+
const parsedSC = _returnTypeNameNominal(sameClass.returnType, language, {
|
|
4679
|
+
selfClass: enclosing.className,
|
|
4680
|
+
});
|
|
4681
|
+
if (!parsedSC) return null;
|
|
4682
|
+
const originSC = _resolveFlowTypeOrigin(index, sameClass.file || filePath, parsedSC.name, parsedSC.qualifier);
|
|
4683
|
+
if (!originSC) return null;
|
|
4684
|
+
return { type: parsedSC.name, ...(originSC.fromFile && { fromFile: originSC.fromFile }) };
|
|
4685
|
+
}
|
|
4686
|
+
}
|
|
4687
|
+
}
|
|
4688
|
+
const cands = defs.filter(d => !(d.className || d.receiver));
|
|
4689
|
+
let chosen = null;
|
|
4690
|
+
if (langTraits(language)?.packageScope === 'directory') {
|
|
4691
|
+
const dir = path.dirname(filePath);
|
|
4692
|
+
const samePkg = cands.filter(d => d.file && path.dirname(d.file) === dir);
|
|
4693
|
+
if (samePkg.length === 1) chosen = samePkg[0];
|
|
4694
|
+
} else if (cands.length === 1) {
|
|
4695
|
+
chosen = cands[0];
|
|
4696
|
+
} else {
|
|
4697
|
+
const sameFile = cands.filter(d => d.file === filePath);
|
|
4698
|
+
if (sameFile.length === 1) chosen = sameFile[0];
|
|
4699
|
+
}
|
|
4700
|
+
if (!chosen || !chosen.returnType) return null;
|
|
4701
|
+
producer = chosen;
|
|
4702
|
+
}
|
|
4703
|
+
const parsed = _returnTypeNameNominal(producer.returnType, language, { selfClass });
|
|
4704
|
+
if (!parsed) return null;
|
|
4705
|
+
const origin = _resolveFlowTypeOrigin(index, producer.file || filePath, parsed.name, parsed.qualifier);
|
|
4706
|
+
if (!origin) return null;
|
|
4707
|
+
return { type: parsed.name, ...(origin.fromFile && { fromFile: origin.fromFile }) };
|
|
4708
|
+
}
|
|
4709
|
+
|
|
4710
|
+
function _chainedReceiverType(index, call, language) {
|
|
4711
|
+
const defs = (index.symbols.get(call.receiverCall) || [])
|
|
4712
|
+
.filter(d => !NON_CALLABLE_TYPES.has(d.type));
|
|
4713
|
+
let producers;
|
|
4714
|
+
if (call.receiverCallIsMethod) {
|
|
4715
|
+
producers = defs.filter(d => d.className);
|
|
4716
|
+
if (producers.length === 0) return null;
|
|
4717
|
+
if (!producers.every(d => d.returnType)) return null;
|
|
4718
|
+
} else {
|
|
4719
|
+
if (defs.length !== 1 || !defs[0].returnType) return null;
|
|
4720
|
+
producers = defs;
|
|
4721
|
+
}
|
|
4722
|
+
if (language === 'python' && !call.receiverCallAwaited &&
|
|
4723
|
+
producers.some(d => d.isAsync)) return null;
|
|
4724
|
+
const heads = new Set();
|
|
4725
|
+
for (const d of producers) {
|
|
4726
|
+
const h = _structuralTypeHead(d.returnType, { unwrapAsync: call.receiverCallAwaited });
|
|
4727
|
+
if (!h) return null;
|
|
4728
|
+
heads.add(h);
|
|
4729
|
+
if (heads.size > 1) return null;
|
|
4730
|
+
}
|
|
4731
|
+
const head = [...heads][0];
|
|
4732
|
+
if (/^[A-Z][A-Z0-9]?$/.test(head)) return null; // generic type param (T, K, V1)
|
|
4733
|
+
if (_STRUCTURAL_FLOW_REJECT.has(head)) return null;
|
|
4734
|
+
return head;
|
|
4735
|
+
}
|
|
4736
|
+
|
|
4737
|
+
/**
|
|
4738
|
+
* Is this field symbol callable by its own name (obj.f(...) reaches the
|
|
4739
|
+
* field's function value)? Arrow-function class fields are callable by
|
|
4740
|
+
* construction; annotation-typed fields qualify via a function-type shape.
|
|
4741
|
+
* Structural languages only — Java needs .apply()/.run() on a functional
|
|
4742
|
+
* field and Rust needs (s.f)(…) parens, so their fields never own a
|
|
4743
|
+
* method-call name; Go func fields DO but stay under the existing owner
|
|
4744
|
+
* rules until a measured family justifies the churn.
|
|
4745
|
+
*/
|
|
4746
|
+
function _callableFieldDef(index, d) {
|
|
4747
|
+
const lang = index.files.get(d.file)?.language;
|
|
4748
|
+
if (langTraits(lang)?.typeSystem !== 'structural') return false;
|
|
4749
|
+
if (d.isMethod) return true; // arrow-function class fields
|
|
4750
|
+
if (!d.fieldType) return false;
|
|
4751
|
+
return /=>/.test(d.fieldType) ||
|
|
4752
|
+
/^(?:typing\.)?Callable\b/.test(d.fieldType.trim()) ||
|
|
4753
|
+
/^Function\b/.test(d.fieldType.trim());
|
|
4754
|
+
}
|
|
4755
|
+
|
|
1494
4756
|
function _buildTypedLocalTypeMap(index, def, calls) {
|
|
1495
4757
|
const localTypes = new Map();
|
|
1496
4758
|
let _cachedLines = null;
|