ucn 4.0.2 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/ucn/SKILL.md +31 -7
- package/README.md +89 -50
- package/cli/index.js +199 -94
- package/core/account.js +3 -1
- package/core/analysis.js +322 -304
- package/core/bridge.js +13 -8
- package/core/cache.js +109 -19
- package/core/callers.js +969 -77
- package/core/check.js +19 -8
- package/core/deadcode.js +368 -40
- package/core/discovery.js +31 -11
- package/core/entrypoints.js +149 -17
- package/core/execute.js +330 -43
- package/core/graph-build.js +61 -10
- package/core/graph.js +282 -61
- package/core/imports.js +72 -3
- package/core/output/analysis-ext.js +70 -10
- package/core/output/analysis.js +52 -33
- package/core/output/check.js +4 -1
- package/core/output/endpoints.js +8 -3
- package/core/output/extraction.js +12 -1
- package/core/output/find.js +23 -9
- package/core/output/graph.js +32 -9
- package/core/output/refactoring.js +147 -49
- package/core/output/reporting.js +104 -5
- package/core/output/search.js +30 -3
- package/core/output/shared.js +31 -4
- package/core/output/tracing.js +22 -11
- package/core/parser.js +8 -6
- package/core/project.js +167 -7
- package/core/registry.js +20 -16
- package/core/reporting.js +131 -13
- package/core/search.js +270 -55
- package/core/shared.js +240 -1
- package/core/stacktrace.js +23 -1
- package/core/tracing.js +278 -36
- package/core/verify.js +352 -349
- package/languages/go.js +29 -17
- package/languages/index.js +56 -0
- package/languages/java.js +133 -16
- package/languages/javascript.js +215 -27
- package/languages/python.js +113 -47
- package/languages/rust.js +89 -26
- package/languages/utils.js +35 -7
- package/mcp/server.js +69 -30
- package/package.json +4 -1
package/core/analysis.js
CHANGED
|
@@ -12,7 +12,7 @@ const path = require('path');
|
|
|
12
12
|
const { execFileSync } = require('child_process');
|
|
13
13
|
const { parse } = require('./parser');
|
|
14
14
|
const { detectLanguage, langTraits } = require('../languages');
|
|
15
|
-
const { NON_CALLABLE_TYPES, addTestExclusions, countTextBlindspots } = require('./shared');
|
|
15
|
+
const { NON_CALLABLE_TYPES, addTestExclusions, countTextBlindspots, codeUnitCompare } = require('./shared');
|
|
16
16
|
const { computeReachability, symbolKey } = require('./entrypoints');
|
|
17
17
|
const { getLanguageModule } = require('../languages');
|
|
18
18
|
|
|
@@ -154,10 +154,12 @@ function tagCallersReachable(callers, reachableSet) {
|
|
|
154
154
|
for (const c of callers) {
|
|
155
155
|
if (c.callerFile && c.callerStartLine != null) {
|
|
156
156
|
c.reachable = reachableSet.has(symbolKey(c.callerFile, c.callerStartLine));
|
|
157
|
-
} else {
|
|
158
|
-
// Module-level / unknown caller — treat as unreachable (no enclosing function)
|
|
159
|
-
c.reachable = false;
|
|
160
157
|
}
|
|
158
|
+
// Module-level callers get NO verdict (fix #256, rich-measured:
|
|
159
|
+
// every examples/*.py module-level `console.print(table)` rendered
|
|
160
|
+
// [unreachable]): the site executes at IMPORT time — function-graph
|
|
161
|
+
// reachability cannot speak for it, and "unreachable" invites
|
|
162
|
+
// deleting live code. Formatters mark only `reachable === false`.
|
|
161
163
|
}
|
|
162
164
|
return callers;
|
|
163
165
|
}
|
|
@@ -255,7 +257,7 @@ function callNotResolvedEntries(index, account, options = {}) {
|
|
|
255
257
|
return entries.map(e => {
|
|
256
258
|
let content = '';
|
|
257
259
|
try {
|
|
258
|
-
content = (index.
|
|
260
|
+
content = (index._getFileLines(e.file)[e.line - 1] || '').trim();
|
|
259
261
|
} catch { /* unreadable since scan — keep bare */ }
|
|
260
262
|
return {
|
|
261
263
|
file: e.file,
|
|
@@ -286,6 +288,13 @@ function context(index, name, options = {}) {
|
|
|
286
288
|
return null;
|
|
287
289
|
}
|
|
288
290
|
|
|
291
|
+
// Default includeMethods like about/impact (fix #234, campaign-measured:
|
|
292
|
+
// the same pinned method was confirmed in about but unverified in
|
|
293
|
+
// context — the tier depended on which command asked): true for method
|
|
294
|
+
// targets, where method calls are the primary invocation form.
|
|
295
|
+
const includeMethods = options.includeMethods ??
|
|
296
|
+
!!(def.isMethod || def.type === 'method' || def.className);
|
|
297
|
+
|
|
289
298
|
// Special handling for class/struct/interface types
|
|
290
299
|
if (['class', 'struct', 'interface', 'type'].includes(def.type)) {
|
|
291
300
|
const methods = index.findMethodsForType(name);
|
|
@@ -293,7 +302,7 @@ function context(index, name, options = {}) {
|
|
|
293
302
|
// Pin caller resolution to the resolved class definition — same as the
|
|
294
303
|
// function path below. Without this, same-name classes in other files
|
|
295
304
|
// conflate (their usages attribute to whichever def displays).
|
|
296
|
-
let typeCallers = index.findCallers(name, { includeMethods
|
|
305
|
+
let typeCallers = index.findCallers(name, { includeMethods, includeUncertain: options.includeUncertain, collectAccount: true, targetDefinitions: [def] });
|
|
297
306
|
const rawTypeCallers = typeCallers;
|
|
298
307
|
const typeFilteredByFlag = { exclude: 0, minConfidence: 0, unreachableOnly: 0 };
|
|
299
308
|
// Tier partition — same contract as the function path: constructor/usage
|
|
@@ -313,7 +322,7 @@ function context(index, name, options = {}) {
|
|
|
313
322
|
const byFileLine = (a, b) => {
|
|
314
323
|
const fa = a.relativePath || a.file || '';
|
|
315
324
|
const fb = b.relativePath || b.file || '';
|
|
316
|
-
if (fa !== fb) return fa
|
|
325
|
+
if (fa !== fb) return codeUnitCompare(fa, fb);
|
|
317
326
|
return (a.line || 0) - (b.line || 0);
|
|
318
327
|
};
|
|
319
328
|
const typeAccount = composeAccount(index, name, rawTypeCallers,
|
|
@@ -352,7 +361,7 @@ function context(index, name, options = {}) {
|
|
|
352
361
|
|
|
353
362
|
const stats = { uncertain: 0 };
|
|
354
363
|
let callers = index.findCallers(name, {
|
|
355
|
-
includeMethods
|
|
364
|
+
includeMethods,
|
|
356
365
|
includeUncertain: options.includeUncertain,
|
|
357
366
|
stats,
|
|
358
367
|
targetDefinitions: [def],
|
|
@@ -360,7 +369,11 @@ function context(index, name, options = {}) {
|
|
|
360
369
|
// --all lifts the unverified enrichment cap (content + caller lookup)
|
|
361
370
|
unverifiedEnrichLimit: options.all ? Infinity : undefined,
|
|
362
371
|
});
|
|
363
|
-
|
|
372
|
+
// Callee side runs the contract too (v4): uncertain/unresolved calls in
|
|
373
|
+
// the def's scope become VISIBLE unverifiedCallees entries + a conserved
|
|
374
|
+
// per-node calleeAccount instead of silently dropping (#223 machinery).
|
|
375
|
+
let callees = index.findCallees(def, { includeMethods, includeUncertain: options.includeUncertain, stats, collectAccount: true });
|
|
376
|
+
const rawCallees = callees;
|
|
364
377
|
// Pre-display-filter result for the conservation account (filters below
|
|
365
378
|
// build new arrays and would lose the non-enumerable accountRaw).
|
|
366
379
|
const rawCallers = callers;
|
|
@@ -405,7 +418,7 @@ function context(index, name, options = {}) {
|
|
|
405
418
|
const byFileLine = (a, b) => {
|
|
406
419
|
const fa = a.relativePath || a.file || '';
|
|
407
420
|
const fb = b.relativePath || b.file || '';
|
|
408
|
-
if (fa !== fb) return fa
|
|
421
|
+
if (fa !== fb) return codeUnitCompare(fa, fb);
|
|
409
422
|
return (a.line || 0) - (b.line || 0);
|
|
410
423
|
};
|
|
411
424
|
callers = [...callers].sort(byFileLine);
|
|
@@ -417,7 +430,7 @@ function context(index, name, options = {}) {
|
|
|
417
430
|
// Tiebreaker: file then line, for determinism
|
|
418
431
|
const fa = a.relativePath || a.file || '';
|
|
419
432
|
const fb = b.relativePath || b.file || '';
|
|
420
|
-
if (fa !== fb) return fa
|
|
433
|
+
if (fa !== fb) return codeUnitCompare(fa, fb);
|
|
421
434
|
return (a.startLine || 0) - (b.startLine || 0);
|
|
422
435
|
});
|
|
423
436
|
|
|
@@ -433,9 +446,9 @@ function context(index, name, options = {}) {
|
|
|
433
446
|
// Optional: filter to unreachable-only (helps surface dead-path callers/callees)
|
|
434
447
|
if (options.unreachableOnly) {
|
|
435
448
|
const before = callers.length;
|
|
436
|
-
callers = callers.filter(c =>
|
|
449
|
+
callers = callers.filter(c => c.reachable === false);
|
|
437
450
|
filteredByFlag.unreachableOnly = before - callers.length;
|
|
438
|
-
callees = callees.filter(c =>
|
|
451
|
+
callees = callees.filter(c => c.reachable === false);
|
|
439
452
|
}
|
|
440
453
|
|
|
441
454
|
// Conservation account: reconciles the caller answer against the text
|
|
@@ -470,6 +483,7 @@ function context(index, name, options = {}) {
|
|
|
470
483
|
callers,
|
|
471
484
|
unverifiedCallers,
|
|
472
485
|
callees,
|
|
486
|
+
unverifiedCallees: rawCallees.unverifiedCallees || [],
|
|
473
487
|
callerHistogram,
|
|
474
488
|
calleeHistogram,
|
|
475
489
|
meta: {
|
|
@@ -478,9 +492,10 @@ function context(index, name, options = {}) {
|
|
|
478
492
|
dynamicImports,
|
|
479
493
|
uncertain: stats.uncertain,
|
|
480
494
|
confidenceFiltered,
|
|
481
|
-
includeMethods: !!
|
|
495
|
+
includeMethods: !!includeMethods,
|
|
482
496
|
projectLanguage: index._getPredominantLanguage(),
|
|
483
497
|
account,
|
|
498
|
+
calleeAccount: rawCallees.calleeAccount,
|
|
484
499
|
// No detected entry points (e.g. library code) — reachability
|
|
485
500
|
// markers are meaningless and suppressed by formatters.
|
|
486
501
|
hasEntrypoints: reachableSet.size > 0,
|
|
@@ -492,6 +507,19 @@ function context(index, name, options = {}) {
|
|
|
492
507
|
}
|
|
493
508
|
};
|
|
494
509
|
|
|
510
|
+
// Constructor pins surface their invocation sites under the CLASS pin
|
|
511
|
+
// (`new Widget()` indexes under 'Widget' — the fix #239 liveness rule;
|
|
512
|
+
// fix #254 makes it discoverable): a bare empty answer sent readers
|
|
513
|
+
// hunting for callers the class pin already has.
|
|
514
|
+
if (def.className && callers.length === 0 &&
|
|
515
|
+
(def.type === 'constructor' || name === 'constructor' || name === '__init__') &&
|
|
516
|
+
index.getCalleeFiles(def.className)) {
|
|
517
|
+
warnings.push({
|
|
518
|
+
type: 'hint',
|
|
519
|
+
message: `Constructor invocations (\`new ${def.className}(...)\` / \`${def.className}(...)\`) are indexed under the class name — query \`${def.className}\` for the call sites.`,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
495
523
|
if (warnings.length > 0) {
|
|
496
524
|
result.warnings = warnings;
|
|
497
525
|
}
|
|
@@ -517,7 +545,12 @@ function smart(index, name, options = {}) {
|
|
|
517
545
|
}
|
|
518
546
|
const code = index.extractCode(def);
|
|
519
547
|
const stats = { uncertain: 0 };
|
|
520
|
-
|
|
548
|
+
// Method-target default like about/impact/context (fix #234).
|
|
549
|
+
const includeMethods = options.includeMethods ??
|
|
550
|
+
!!(def.isMethod || def.type === 'method' || def.className);
|
|
551
|
+
// Callee contract (v4): uncertain/unresolved calls surface as visible
|
|
552
|
+
// unverifiedCallees + a conserved calleeAccount, never a silent drop.
|
|
553
|
+
const callees = index.findCallees(def, { includeMethods, includeUncertain: options.includeUncertain, stats, collectAccount: true });
|
|
521
554
|
|
|
522
555
|
const filesInScope = new Set([def.file]);
|
|
523
556
|
callees.forEach(c => filesInScope.add(c.file));
|
|
@@ -557,6 +590,7 @@ function smart(index, name, options = {}) {
|
|
|
557
590
|
}
|
|
558
591
|
}
|
|
559
592
|
|
|
593
|
+
const smartUnverified = callees.unverifiedCallees || [];
|
|
560
594
|
return {
|
|
561
595
|
target: {
|
|
562
596
|
...def,
|
|
@@ -564,12 +598,14 @@ function smart(index, name, options = {}) {
|
|
|
564
598
|
},
|
|
565
599
|
dependencies,
|
|
566
600
|
types,
|
|
601
|
+
unverifiedCallees: smartUnverified,
|
|
567
602
|
meta: {
|
|
568
|
-
complete: stats.uncertain === 0 && dynamicImports === 0,
|
|
603
|
+
complete: stats.uncertain === 0 && dynamicImports === 0 && smartUnverified.length === 0,
|
|
569
604
|
skipped: 0,
|
|
570
605
|
dynamicImports,
|
|
571
606
|
uncertain: stats.uncertain,
|
|
572
|
-
projectLanguage: index._getPredominantLanguage()
|
|
607
|
+
projectLanguage: index._getPredominantLanguage(),
|
|
608
|
+
calleeAccount: callees.calleeAccount
|
|
573
609
|
}
|
|
574
610
|
};
|
|
575
611
|
} finally { index._endOp(); }
|
|
@@ -668,6 +704,10 @@ function related(index, name, options = {}) {
|
|
|
668
704
|
return null;
|
|
669
705
|
}
|
|
670
706
|
const related = {
|
|
707
|
+
// Advisory command (v4 two-tier surface): ranked similarity
|
|
708
|
+
// heuristics, not a verified claim — formatters print the label,
|
|
709
|
+
// JSON consumers read the field.
|
|
710
|
+
advisory: 'similarity-heuristics',
|
|
671
711
|
target: {
|
|
672
712
|
name: def.name,
|
|
673
713
|
file: def.relativePath,
|
|
@@ -696,6 +736,12 @@ function related(index, name, options = {}) {
|
|
|
696
736
|
related.sameFile.sort((a, b) =>
|
|
697
737
|
Math.abs(a.line - def.startLine) - Math.abs(b.line - def.startLine)
|
|
698
738
|
);
|
|
739
|
+
// --top caps every section the same way (fix #230 — sameFile ignored
|
|
740
|
+
// it, so JSON consumers got the full list while similarNames et al.
|
|
741
|
+
// were capped).
|
|
742
|
+
const sameFileLimit = options.top || (options.all ? Infinity : 10);
|
|
743
|
+
related.sameFileTotal = related.sameFile.length;
|
|
744
|
+
if (related.sameFile.length > sameFileLimit) related.sameFile = related.sameFile.slice(0, sameFileLimit);
|
|
699
745
|
}
|
|
700
746
|
|
|
701
747
|
// 2. Similar names (shared prefix/suffix, camelCase similarity)
|
|
@@ -868,144 +914,15 @@ function impact(index, name, options = {}) {
|
|
|
868
914
|
impactAccountRaw = callerResults.accountRaw;
|
|
869
915
|
impactRoutedUnverified = callerResults.unverifiedEntries || [];
|
|
870
916
|
|
|
871
|
-
//
|
|
872
|
-
//
|
|
873
|
-
//
|
|
874
|
-
//
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
const _impMethodDefs = index.symbols.get(name);
|
|
879
|
-
const _impClassNames = new Set();
|
|
880
|
-
if (_impMethodDefs) {
|
|
881
|
-
for (const d of _impMethodDefs) {
|
|
882
|
-
if (d.className) _impClassNames.add(d.className);
|
|
883
|
-
else if (d.receiver) _impClassNames.add(d.receiver.replace(/^\*/, ''));
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
|
-
const keepForTargetClass = (c) => {
|
|
887
|
-
// Keep non-method calls and self/this/cls calls (already resolved by findCallers)
|
|
888
|
-
if (!c.isMethod) return true;
|
|
889
|
-
const r = c.receiver;
|
|
890
|
-
if (r && ['self', 'cls', 'this', 'super'].includes(r)) return true;
|
|
891
|
-
// Use receiverType from findCallers when available (Go/Java/Rust type inference)
|
|
892
|
-
if (c.receiverType) {
|
|
893
|
-
return c.receiverType === targetClassName ? 'strong' : false;
|
|
894
|
-
}
|
|
895
|
-
// No receiver (chained/complex expression): only include if method is
|
|
896
|
-
// unique or rare across types — otherwise too many false positives
|
|
897
|
-
if (!r) {
|
|
898
|
-
return _impClassNames.size <= 1;
|
|
899
|
-
}
|
|
900
|
-
// Check if receiver matches the target class name (case-insensitive camelCase convention)
|
|
901
|
-
if (r.toLowerCase().includes(targetClassName.toLowerCase())) return true;
|
|
902
|
-
// Check if receiver is an instance of the target class using local variable type inference
|
|
903
|
-
if (c.callerFile) {
|
|
904
|
-
const callerDef = c.callerStartLine ? { file: c.callerFile, startLine: c.callerStartLine, endLine: c.callerEndLine } : null;
|
|
905
|
-
if (callerDef) {
|
|
906
|
-
const callerCalls = index.getCachedCalls(c.callerFile);
|
|
907
|
-
if (callerCalls && Array.isArray(callerCalls)) {
|
|
908
|
-
const localTypes = new Map();
|
|
909
|
-
for (const call of callerCalls) {
|
|
910
|
-
if (call.line >= callerDef.startLine && call.line <= callerDef.endLine) {
|
|
911
|
-
if (!call.isMethod && !call.receiver) {
|
|
912
|
-
const syms = index.symbols.get(call.name);
|
|
913
|
-
if (syms && syms.some(s => s.type === 'class')) {
|
|
914
|
-
// Found a constructor call — check for assignment pattern
|
|
915
|
-
const fileEntry = index.files.get(c.callerFile);
|
|
916
|
-
if (fileEntry) {
|
|
917
|
-
const content = index._readFile(c.callerFile);
|
|
918
|
-
const lines = content.split('\n');
|
|
919
|
-
const line = lines[call.line - 1] || '';
|
|
920
|
-
// Match "var = ClassName(...)" or "var = new ClassName(...)" or "Type var = new ClassName<>(...)"
|
|
921
|
-
const m = line.match(/(\w+)\s*=\s*(?:await\s+)?(?:new\s+)?(\w+)\s*(?:<[^>]*>)?\s*\(/);
|
|
922
|
-
if (m && m[2] === call.name) {
|
|
923
|
-
localTypes.set(m[1], call.name);
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
const receiverType = localTypes.get(r);
|
|
931
|
-
if (receiverType) {
|
|
932
|
-
return receiverType === targetClassName ? 'strong' : false;
|
|
933
|
-
}
|
|
934
|
-
}
|
|
935
|
-
}
|
|
936
|
-
}
|
|
937
|
-
// Check class field declarations for receiver type: private DataService service
|
|
938
|
-
if (c.callerFile) {
|
|
939
|
-
const callerEnclosing = index.findEnclosingFunction(c.callerFile, c.line, true);
|
|
940
|
-
if (callerEnclosing?.className) {
|
|
941
|
-
const classSyms = index.symbols.get(callerEnclosing.className);
|
|
942
|
-
if (classSyms) {
|
|
943
|
-
const classDef = classSyms.find(s => s.type === 'class' || s.type === 'struct' || s.type === 'interface');
|
|
944
|
-
if (classDef) {
|
|
945
|
-
const content = index._readFile(c.callerFile);
|
|
946
|
-
const lines = content.split('\n');
|
|
947
|
-
// Scan class body for field declarations matching the receiver
|
|
948
|
-
for (let li = classDef.startLine - 1; li < (classDef.endLine || classDef.startLine + 50) && li < lines.length; li++) {
|
|
949
|
-
const line = lines[li];
|
|
950
|
-
// Match Java/TS field: [modifiers] TypeName<...> receiverName [= ...]
|
|
951
|
-
const fieldMatch = line.match(new RegExp(`\\b(\\w+)(?:<[^>]*>)?\\s+${r.replace(/[.*+?^${}()|[\]\\]/g, '\\\\$&')}\\s*[;=]`));
|
|
952
|
-
if (fieldMatch) {
|
|
953
|
-
const fieldType = fieldMatch[1];
|
|
954
|
-
if (fieldType === targetClassName) return 'strong';
|
|
955
|
-
break;
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
// Check parameter type annotations: def foo(tracker: SourceTracker) → tracker.record()
|
|
963
|
-
if (c.callerFile && c.callerStartLine) {
|
|
964
|
-
const callerSymbol = index.findEnclosingFunction(c.callerFile, c.line, true);
|
|
965
|
-
if (callerSymbol && callerSymbol.paramsStructured) {
|
|
966
|
-
for (const param of callerSymbol.paramsStructured) {
|
|
967
|
-
if (param.name === r && param.type) {
|
|
968
|
-
// Check if the type annotation contains the target class name
|
|
969
|
-
const typeMatches = param.type.match(/\b([A-Za-z_]\w*)\b/g);
|
|
970
|
-
if (typeMatches && typeMatches.some(t => t === targetClassName)) {
|
|
971
|
-
return 'strong';
|
|
972
|
-
}
|
|
973
|
-
// Type annotation exists but doesn't match target class — filter out
|
|
974
|
-
return false;
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
}
|
|
979
|
-
// Unique method heuristic: if the called method exists on exactly one class/type
|
|
980
|
-
// and it matches the target, include the call (no other class could match)
|
|
981
|
-
if (_impClassNames.size === 1 && _impClassNames.has(targetClassName)) {
|
|
982
|
-
return true;
|
|
983
|
-
}
|
|
984
|
-
// Type-scoped query but receiver type unknown — filter it out.
|
|
985
|
-
// Unknown receivers are likely unrelated.
|
|
986
|
-
return false;
|
|
987
|
-
};
|
|
988
|
-
// Conservation: post-hoc rejects are positive type-mismatch evidence —
|
|
989
|
-
// they MOVE into the excluded bucket instead of vanishing. Survivors
|
|
990
|
-
// verified by STRONG evidence (receiverType / local constructor /
|
|
991
|
-
// field declaration / param annotation match) are upgraded to the
|
|
992
|
-
// confirmed tier — the filter just proved the receiver's type.
|
|
993
|
-
callerResults = callerResults.filter(c => {
|
|
994
|
-
const keep = keepForTargetClass(c);
|
|
995
|
-
if (!keep) {
|
|
996
|
-
impactPostHocExcluded.push({ file: c.file, line: c.line, reason: 'receiver-type-mismatch' });
|
|
997
|
-
return false;
|
|
998
|
-
}
|
|
999
|
-
if (keep === 'strong' && c.tier === 'unverified') {
|
|
1000
|
-
c.tier = 'confirmed';
|
|
1001
|
-
c.resolution = 'receiver-hint';
|
|
1002
|
-
c.confidence = 0.80;
|
|
1003
|
-
}
|
|
1004
|
-
return true;
|
|
1005
|
-
});
|
|
1006
|
-
}
|
|
917
|
+
// No post-hoc receiver filter here (fix #228, the #225 precedent):
|
|
918
|
+
// the engine's receiver physics (#198/#202/#204/#206/#219/#220) decide
|
|
919
|
+
// tier and exclusion inside findCallers with the pinned definition.
|
|
920
|
+
// The old hand-rolled filter re-derived receiver types with regexes
|
|
921
|
+
// and DROPPED confirmed field-hop/embedding callers that context and
|
|
922
|
+
// about kept — impact answered differently from its siblings on the
|
|
923
|
+
// same pin.
|
|
1007
924
|
|
|
1008
|
-
callSites = [];
|
|
925
|
+
callSites = []; callSites = [];
|
|
1009
926
|
for (const c of callerResults) {
|
|
1010
927
|
const analysis = index.analyzeCallSite(
|
|
1011
928
|
{ file: c.file, relativePath: c.relativePath, line: c.line, content: c.content },
|
|
@@ -1143,7 +1060,7 @@ function impact(index, name, options = {}) {
|
|
|
1143
1060
|
});
|
|
1144
1061
|
}
|
|
1145
1062
|
unverifiedSites.sort((a, b) => {
|
|
1146
|
-
if (a.file !== b.file) return a.file
|
|
1063
|
+
if (a.file !== b.file) return codeUnitCompare(a.file, b.file);
|
|
1147
1064
|
return (a.line || 0) - (b.line || 0);
|
|
1148
1065
|
});
|
|
1149
1066
|
|
|
@@ -1164,13 +1081,12 @@ function impact(index, name, options = {}) {
|
|
|
1164
1081
|
for (const site of filteredSites) {
|
|
1165
1082
|
if (site.callerFile && site.callerStartLine != null) {
|
|
1166
1083
|
site.reachable = impactReachable.has(symbolKey(site.callerFile, site.callerStartLine));
|
|
1167
|
-
} else {
|
|
1168
|
-
site.reachable = false;
|
|
1169
1084
|
}
|
|
1085
|
+
// Module-level sites: no verdict — import-time execution (fix #256).
|
|
1170
1086
|
}
|
|
1171
1087
|
if (options.unreachableOnly) {
|
|
1172
1088
|
const before = filteredSites.length;
|
|
1173
|
-
filteredSites = filteredSites.filter(s =>
|
|
1089
|
+
filteredSites = filteredSites.filter(s => s.reachable === false);
|
|
1174
1090
|
impactFilteredByFlag.unreachableOnly = before - filteredSites.length;
|
|
1175
1091
|
}
|
|
1176
1092
|
const callerHistogram = buildHistogram(filteredSites);
|
|
@@ -1205,7 +1121,7 @@ function impact(index, name, options = {}) {
|
|
|
1205
1121
|
}));
|
|
1206
1122
|
if (impactNotResolved.length > 0) {
|
|
1207
1123
|
unverifiedSites = [...unverifiedSites, ...impactNotResolved].sort((a, b) => {
|
|
1208
|
-
if (a.file !== b.file) return a.file
|
|
1124
|
+
if (a.file !== b.file) return codeUnitCompare(a.file, b.file);
|
|
1209
1125
|
return (a.line || 0) - (b.line || 0);
|
|
1210
1126
|
});
|
|
1211
1127
|
}
|
|
@@ -1266,7 +1182,7 @@ function impact(index, name, options = {}) {
|
|
|
1266
1182
|
callerHistogram,
|
|
1267
1183
|
// Stable ordering: files alphabetical, sites by line ascending. Documented contract.
|
|
1268
1184
|
byFile: Array.from(byFile.entries())
|
|
1269
|
-
.sort((a, b) => a[0]
|
|
1185
|
+
.sort((a, b) => codeUnitCompare(a[0], b[0]))
|
|
1270
1186
|
.map(([file, sites]) => ({
|
|
1271
1187
|
file,
|
|
1272
1188
|
count: sites.length,
|
|
@@ -1359,9 +1275,14 @@ function about(index, name, options = {}) {
|
|
|
1359
1275
|
const extra = visibleOthers.length - shown.length;
|
|
1360
1276
|
const alsoIn = shown.map(d => `${d.relativePath}:${d.startLine}`).join(', ');
|
|
1361
1277
|
const suffix = extra > 0 ? `, and ${extra} more` : '';
|
|
1278
|
+
// Same-file collisions can't be resolved with file= (fix #246).
|
|
1279
|
+
const allSameFile = visibleOthers.every(d => d.relativePath === primary.relativePath);
|
|
1280
|
+
const hint = allSameFile
|
|
1281
|
+
? 'Use line= or class_name= to disambiguate.'
|
|
1282
|
+
: 'Use file= to disambiguate.';
|
|
1362
1283
|
aboutWarnings.push({
|
|
1363
1284
|
type: 'ambiguous',
|
|
1364
|
-
message: `Found ${visible.length} definitions for "${name}". Using ${primary.relativePath}:${primary.startLine}. Also in: ${alsoIn}${suffix}.
|
|
1285
|
+
message: `Found ${visible.length} definitions for "${name}". Using ${primary.relativePath}:${primary.startLine}. Also in: ${alsoIn}${suffix}. ${hint}`,
|
|
1365
1286
|
alternatives: visibleOthers.map(d => ({ file: d.relativePath, line: d.startLine })),
|
|
1366
1287
|
});
|
|
1367
1288
|
}
|
|
@@ -1453,7 +1374,7 @@ function about(index, name, options = {}) {
|
|
|
1453
1374
|
// Optional: filter to unreachable-only callers
|
|
1454
1375
|
if (options.unreachableOnly) {
|
|
1455
1376
|
const before = allCallers.length;
|
|
1456
|
-
allCallers = allCallers.filter(c =>
|
|
1377
|
+
allCallers = allCallers.filter(c => c.reachable === false);
|
|
1457
1378
|
aboutFilteredByFlag.unreachableOnly = before - allCallers.length;
|
|
1458
1379
|
// Apply same filter to shadows using their callerStartLine/file when available.
|
|
1459
1380
|
// Shadows lack callerStartLine, so they're treated as reachable=false (conservative,
|
|
@@ -1468,6 +1389,17 @@ function about(index, name, options = {}) {
|
|
|
1468
1389
|
aboutAccount = composeAccount(index, symbolName, rawCallers,
|
|
1469
1390
|
aboutFilteredTotal > 0 ? { total: aboutFilteredTotal, byFlag: aboutFilteredByFlag } : undefined);
|
|
1470
1391
|
unverifiedPool.push(...callNotResolvedEntries(index, aboutAccount, options));
|
|
1392
|
+
// The USAGES fast path hardcodes references:0 (counting them needs a
|
|
1393
|
+
// text scan) — but the account just DID that scan and classified the
|
|
1394
|
+
// reference lines. Reconcile so the USAGES header never contradicts
|
|
1395
|
+
// the ACCOUNT line rendered below it (fix #237: `about with_logging`
|
|
1396
|
+
// said "0 references" while the account counted the @with_logging
|
|
1397
|
+
// decorator application).
|
|
1398
|
+
if (usagesByType && usagesByType.references === 0 &&
|
|
1399
|
+
aboutAccount?.nonCall?.references > 0) {
|
|
1400
|
+
usagesByType.references = aboutAccount.nonCall.references;
|
|
1401
|
+
usagesByType.total += aboutAccount.nonCall.references;
|
|
1402
|
+
}
|
|
1471
1403
|
// Stash the post-filter total on allCallers so the result builder can use it.
|
|
1472
1404
|
Object.defineProperty(allCallers, '__postFilterTotal', {
|
|
1473
1405
|
value: allCallers.length + shadowSurvivors.length,
|
|
@@ -1502,7 +1434,7 @@ function about(index, name, options = {}) {
|
|
|
1502
1434
|
unverifiedPool.sort((a, b) => {
|
|
1503
1435
|
const fa = a.relativePath || '';
|
|
1504
1436
|
const fb = b.relativePath || '';
|
|
1505
|
-
if (fa !== fb) return fa
|
|
1437
|
+
if (fa !== fb) return codeUnitCompare(fa, fb);
|
|
1506
1438
|
return (a.line || 0) - (b.line || 0);
|
|
1507
1439
|
});
|
|
1508
1440
|
aboutUnverified = {
|
|
@@ -1701,6 +1633,17 @@ function about(index, name, options = {}) {
|
|
|
1701
1633
|
completeness: detectCompleteness(index)
|
|
1702
1634
|
};
|
|
1703
1635
|
|
|
1636
|
+
// Constructor pins surface their invocation sites under the CLASS pin
|
|
1637
|
+
// (`new Widget()` indexes under 'Widget' — fix #254, same hint as context).
|
|
1638
|
+
if (primary.className && (callers?.length ?? 0) === 0 &&
|
|
1639
|
+
(primary.type === 'constructor' || symbolName === 'constructor' || symbolName === '__init__') &&
|
|
1640
|
+
index.getCalleeFiles(primary.className)) {
|
|
1641
|
+
result.warnings = [...(result.warnings || []), {
|
|
1642
|
+
type: 'hint',
|
|
1643
|
+
message: `Constructor invocations (\`new ${primary.className}(...)\` / \`${primary.className}(...)\`) are indexed under the class name — query \`${primary.className}\` for the call sites.`,
|
|
1644
|
+
}];
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1704
1647
|
return result;
|
|
1705
1648
|
} finally { index._endOp(); }
|
|
1706
1649
|
}
|
|
@@ -1743,13 +1686,22 @@ function diffImpact(index, options = {}) {
|
|
|
1743
1686
|
|
|
1744
1687
|
let diffText;
|
|
1745
1688
|
try {
|
|
1746
|
-
|
|
1689
|
+
// Capture stderr (fix #230): an invalid ref used to leak git's raw
|
|
1690
|
+
// "fatal: ambiguous argument" straight to the terminal AND repeat it
|
|
1691
|
+
// inside the error message.
|
|
1692
|
+
diffText = execFileSync('git', diffArgs, {
|
|
1693
|
+
cwd: index.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024,
|
|
1694
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1695
|
+
});
|
|
1747
1696
|
} catch (e) {
|
|
1748
1697
|
// git diff exits non-zero when there are diff errors, but also for invalid refs
|
|
1749
1698
|
if (e.stdout) {
|
|
1750
1699
|
diffText = e.stdout;
|
|
1751
1700
|
} else {
|
|
1752
|
-
|
|
1701
|
+
const fatal = String(e.stderr || '').split('\n').find(l => l.startsWith('fatal:'));
|
|
1702
|
+
throw new Error(fatal
|
|
1703
|
+
? `git diff failed — ${fatal.replace(/^fatal:\s*/, '')}`
|
|
1704
|
+
: `git diff failed: ${e.message}`, { cause: e });
|
|
1753
1705
|
}
|
|
1754
1706
|
}
|
|
1755
1707
|
|
|
@@ -1760,7 +1712,7 @@ function diffImpact(index, options = {}) {
|
|
|
1760
1712
|
moduleLevelChanges: [],
|
|
1761
1713
|
newFunctions: [],
|
|
1762
1714
|
deletedFunctions: [],
|
|
1763
|
-
summary: { modifiedFunctions: 0, deletedFunctions: 0, newFunctions: 0, totalCallSites: 0, affectedFiles: 0 }
|
|
1715
|
+
summary: { modifiedFunctions: 0, deletedFunctions: 0, newFunctions: 0, totalCallSites: 0, unverifiedCallSites: 0, affectedFiles: 0 }
|
|
1764
1716
|
};
|
|
1765
1717
|
}
|
|
1766
1718
|
|
|
@@ -1789,6 +1741,7 @@ function diffImpact(index, options = {}) {
|
|
|
1789
1741
|
const deletedFunctions = [];
|
|
1790
1742
|
const callerFileSet = new Set();
|
|
1791
1743
|
let totalCallSites = 0;
|
|
1744
|
+
let totalUnverifiedSites = 0;
|
|
1792
1745
|
|
|
1793
1746
|
for (const change of changes) {
|
|
1794
1747
|
const lang = detectLanguage(change.filePath);
|
|
@@ -1829,6 +1782,7 @@ function diffImpact(index, options = {}) {
|
|
|
1829
1782
|
// line-arithmetic guess that was wrong for tightly-packed 1-line functions.
|
|
1830
1783
|
// The identity key is `name\0className` (matches deletion-detection below).
|
|
1831
1784
|
let oldSymbolIdentities = null; // null = unknown (file untracked or git failed)
|
|
1785
|
+
let oldCallables = null; // old symbols WITH ranges — deleted lines are old-file coordinates
|
|
1832
1786
|
if (change.deletedLines.length > 0 || change.addedLines.length > 0) {
|
|
1833
1787
|
const ref = staged ? 'HEAD' : base;
|
|
1834
1788
|
try {
|
|
@@ -1840,7 +1794,8 @@ function diffImpact(index, options = {}) {
|
|
|
1840
1794
|
if (fileLang) {
|
|
1841
1795
|
const oldParsed = parse(oldContent, fileLang);
|
|
1842
1796
|
oldSymbolIdentities = new Set();
|
|
1843
|
-
|
|
1797
|
+
oldCallables = extractCallableSymbols(oldParsed);
|
|
1798
|
+
for (const oldFn of oldCallables) {
|
|
1844
1799
|
oldSymbolIdentities.add(`${oldFn.name}\0${oldFn.className || ''}`);
|
|
1845
1800
|
}
|
|
1846
1801
|
}
|
|
@@ -1873,46 +1828,81 @@ function diffImpact(index, options = {}) {
|
|
|
1873
1828
|
}
|
|
1874
1829
|
}
|
|
1875
1830
|
|
|
1831
|
+
// Current callable symbols by identity — used to map an OLD symbol that
|
|
1832
|
+
// still exists to its CURRENT definition for modification attribution.
|
|
1833
|
+
const currentByIdentity = new Map();
|
|
1834
|
+
for (const s of fileEntry.symbols) {
|
|
1835
|
+
if (NON_CALLABLE_TYPES.has(s.type)) continue;
|
|
1836
|
+
const k = `${s.name}\0${s.className || ''}`;
|
|
1837
|
+
if (!currentByIdentity.has(k)) currentByIdentity.set(k, []);
|
|
1838
|
+
currentByIdentity.get(k).push(s);
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1876
1841
|
for (const line of change.deletedLines) {
|
|
1877
|
-
//
|
|
1878
|
-
//
|
|
1879
|
-
//
|
|
1880
|
-
//
|
|
1881
|
-
//
|
|
1882
|
-
//
|
|
1883
|
-
//
|
|
1884
|
-
// 1-line function in tightly-packed files (BUG-F).
|
|
1885
|
-
let bestSymbol = null;
|
|
1886
|
-
let bestExact = false;
|
|
1887
|
-
let bestRange = Infinity;
|
|
1888
|
-
for (const symbol of fileEntry.symbols) {
|
|
1889
|
-
if (NON_CALLABLE_TYPES.has(symbol.type)) continue;
|
|
1890
|
-
const exact = line >= symbol.startLine && line <= symbol.endLine;
|
|
1891
|
-
const tolerant = line >= symbol.startLine - 2 && line <= symbol.endLine + 2;
|
|
1892
|
-
if (!exact && !tolerant) continue;
|
|
1893
|
-
const range = symbol.endLine - symbol.startLine;
|
|
1894
|
-
// Prefer exact-contained over tolerance-contained; among same kind, smaller range wins.
|
|
1895
|
-
const better = bestSymbol === null
|
|
1896
|
-
|| (exact && !bestExact)
|
|
1897
|
-
|| (exact === bestExact && range < bestRange);
|
|
1898
|
-
if (better) {
|
|
1899
|
-
bestSymbol = symbol;
|
|
1900
|
-
bestExact = exact;
|
|
1901
|
-
bestRange = range;
|
|
1902
|
-
}
|
|
1903
|
-
}
|
|
1842
|
+
// Deleted line numbers are OLD-file coordinates. Matching them
|
|
1843
|
+
// against CURRENT symbol ranges mis-attributes whenever the file
|
|
1844
|
+
// shifted (an untouched function reported modified because it now
|
|
1845
|
+
// occupies a deleted function's old lines). When the old parse is
|
|
1846
|
+
// available, attribute by the OLD symbol's range and map its
|
|
1847
|
+
// identity to the current definition; fall back to the old
|
|
1848
|
+
// current-range heuristic only when git couldn't produce the base.
|
|
1904
1849
|
let matched = false;
|
|
1905
|
-
if (
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1850
|
+
if (oldCallables !== null) {
|
|
1851
|
+
let bestOld = null;
|
|
1852
|
+
let bestRange = Infinity;
|
|
1853
|
+
for (const oldFn of oldCallables) {
|
|
1854
|
+
const end = oldFn.endLine || oldFn.startLine;
|
|
1855
|
+
if (line < oldFn.startLine || line > end) continue;
|
|
1856
|
+
const range = end - oldFn.startLine;
|
|
1857
|
+
if (bestOld === null || range < bestRange) {
|
|
1858
|
+
bestOld = oldFn;
|
|
1859
|
+
bestRange = range;
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
if (bestOld) {
|
|
1863
|
+
const identityKey = `${bestOld.name}\0${bestOld.className || ''}`;
|
|
1864
|
+
const candidates = currentByIdentity.get(identityKey);
|
|
1865
|
+
if (candidates && candidates.length > 0) {
|
|
1866
|
+
// Function still exists — a modification. Nearest
|
|
1867
|
+
// startLine disambiguates same-identity overloads.
|
|
1868
|
+
let cur = candidates[0];
|
|
1869
|
+
for (const c of candidates) {
|
|
1870
|
+
if (Math.abs(c.startLine - bestOld.startLine) < Math.abs(cur.startLine - bestOld.startLine)) cur = c;
|
|
1871
|
+
}
|
|
1872
|
+
const key = `${cur.name}:${cur.startLine}`;
|
|
1873
|
+
if (!affectedSymbols.has(key)) {
|
|
1874
|
+
affectedSymbols.set(key, { symbol: cur, addedLines: [], deletedLines: [] });
|
|
1875
|
+
}
|
|
1876
|
+
affectedSymbols.get(key).deletedLines.push(line);
|
|
1877
|
+
}
|
|
1878
|
+
// Identity gone → the whole function was deleted; the
|
|
1879
|
+
// deletion detection below reports it. Its body lines are
|
|
1880
|
+
// neither a modification nor a module-level change.
|
|
1881
|
+
matched = true;
|
|
1882
|
+
}
|
|
1883
|
+
} else {
|
|
1884
|
+
// Fallback (old file unreachable): current-range heuristic.
|
|
1885
|
+
// Pick the MOST-SPECIFIC match: prefer exact-contained over
|
|
1886
|
+
// tolerance-contained, then smallest range (BUG-F).
|
|
1887
|
+
let bestSymbol = null;
|
|
1888
|
+
let bestExact = false;
|
|
1889
|
+
let bestRange = Infinity;
|
|
1890
|
+
for (const symbol of fileEntry.symbols) {
|
|
1891
|
+
if (NON_CALLABLE_TYPES.has(symbol.type)) continue;
|
|
1892
|
+
const exact = line >= symbol.startLine && line <= symbol.endLine;
|
|
1893
|
+
const tolerant = line >= symbol.startLine - 2 && line <= symbol.endLine + 2;
|
|
1894
|
+
if (!exact && !tolerant) continue;
|
|
1895
|
+
const range = symbol.endLine - symbol.startLine;
|
|
1896
|
+
const better = bestSymbol === null
|
|
1897
|
+
|| (exact && !bestExact)
|
|
1898
|
+
|| (exact === bestExact && range < bestRange);
|
|
1899
|
+
if (better) {
|
|
1900
|
+
bestSymbol = symbol;
|
|
1901
|
+
bestExact = exact;
|
|
1902
|
+
bestRange = range;
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
if (bestSymbol) {
|
|
1916
1906
|
const key = `${bestSymbol.name}:${bestSymbol.startLine}`;
|
|
1917
1907
|
if (!affectedSymbols.has(key)) {
|
|
1918
1908
|
affectedSymbols.set(key, { symbol: bestSymbol, addedLines: [], deletedLines: [] });
|
|
@@ -1966,55 +1956,47 @@ function diffImpact(index, options = {}) {
|
|
|
1966
1956
|
|
|
1967
1957
|
// Detect deleted functions: compare old file symbols with current by identity.
|
|
1968
1958
|
// Uses name+className counts to handle overloads (e.g. Java method overloading).
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
);
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
const
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
const matching = oldSymbols.filter(s => `${s.name}\0${s.className || ''}` === key);
|
|
1999
|
-
// Report the extra ones (by startLine descending — later ones more likely deleted)
|
|
2000
|
-
const toReport = matching.slice(curCount);
|
|
2001
|
-
for (const oldFn of toReport) {
|
|
2002
|
-
deletedFunctions.push({
|
|
2003
|
-
name: oldFn.name,
|
|
2004
|
-
filePath: change.filePath,
|
|
2005
|
-
relativePath: change.relativePath,
|
|
2006
|
-
startLine: oldFn.startLine
|
|
2007
|
-
});
|
|
2008
|
-
}
|
|
2009
|
-
}
|
|
1959
|
+
// oldCallables was parsed from the same base revision above.
|
|
1960
|
+
if (change.deletedLines.length > 0 && oldCallables !== null) {
|
|
1961
|
+
const currentCounts = new Map();
|
|
1962
|
+
for (const s of fileEntry.symbols) {
|
|
1963
|
+
if (NON_CALLABLE_TYPES.has(s.type)) continue;
|
|
1964
|
+
const key = `${s.name}\0${s.className || ''}`;
|
|
1965
|
+
currentCounts.set(key, (currentCounts.get(key) || 0) + 1);
|
|
1966
|
+
}
|
|
1967
|
+
// Count old symbols by identity and detect deletions
|
|
1968
|
+
const oldCounts = new Map();
|
|
1969
|
+
for (const oldFn of oldCallables) {
|
|
1970
|
+
const key = `${oldFn.name}\0${oldFn.className || ''}`;
|
|
1971
|
+
oldCounts.set(key, (oldCounts.get(key) || 0) + 1);
|
|
1972
|
+
}
|
|
1973
|
+
// For each identity, if old count > current count, the difference are deletions
|
|
1974
|
+
for (const [key, oldCount] of oldCounts) {
|
|
1975
|
+
const curCount = currentCounts.get(key) || 0;
|
|
1976
|
+
if (oldCount > curCount) {
|
|
1977
|
+
// Find the specific old symbols with this identity that were deleted
|
|
1978
|
+
const matching = oldCallables.filter(s => `${s.name}\0${s.className || ''}` === key);
|
|
1979
|
+
// Report the extra ones (by startLine descending — later ones more likely deleted)
|
|
1980
|
+
const toReport = matching.slice(curCount);
|
|
1981
|
+
for (const oldFn of toReport) {
|
|
1982
|
+
deletedFunctions.push({
|
|
1983
|
+
name: oldFn.name,
|
|
1984
|
+
filePath: change.filePath,
|
|
1985
|
+
relativePath: change.relativePath,
|
|
1986
|
+
startLine: oldFn.startLine
|
|
1987
|
+
});
|
|
2010
1988
|
}
|
|
2011
1989
|
}
|
|
2012
|
-
} catch (e) {
|
|
2013
|
-
// File didn't exist in base, or git error — skip
|
|
2014
1990
|
}
|
|
2015
1991
|
}
|
|
2016
1992
|
|
|
2017
|
-
// For each affected function, find callers
|
|
1993
|
+
// For each affected function, find callers under the tiered caller
|
|
1994
|
+
// contract (v4): confirmed edges populate `callers`; evidence-less
|
|
1995
|
+
// candidates render in `unverifiedCallers` with reasons; positively
|
|
1996
|
+
// excluded sites reconcile in the per-symbol conservation account.
|
|
1997
|
+
// The pre-v4 hand-rolled nominal receiver filter is gone — the engine's
|
|
1998
|
+
// receiver physics (#198/#202/#204/#206/#220) decide tier and exclusion,
|
|
1999
|
+
// and its same-dir heuristic could silently drop true callers.
|
|
2018
2000
|
for (const [, data] of affectedSymbols) {
|
|
2019
2001
|
const { symbol, addedLines: aLines, deletedLines: dLines } = data;
|
|
2020
2002
|
|
|
@@ -2022,59 +2004,34 @@ function diffImpact(index, options = {}) {
|
|
|
2022
2004
|
const allDefs = index.symbols.get(symbol.name) || [];
|
|
2023
2005
|
const targetDefs = allDefs.filter(d => d.file === change.filePath && d.startLine === symbol.startLine);
|
|
2024
2006
|
|
|
2025
|
-
|
|
2007
|
+
const rawCallers = index.findCallers(symbol.name, {
|
|
2026
2008
|
targetDefinitions: targetDefs.length > 0 ? targetDefs : undefined,
|
|
2027
2009
|
includeMethods: true,
|
|
2028
|
-
|
|
2010
|
+
collectAccount: true,
|
|
2029
2011
|
});
|
|
2030
2012
|
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
if (!c.isMethod) return true;
|
|
2048
|
-
const r = c.receiver;
|
|
2049
|
-
if (r && ['self', 'cls', 'this', 'super'].includes(r)) return true;
|
|
2050
|
-
// No receiver (chained/complex expression): only include if method is
|
|
2051
|
-
// unique or rare across types — otherwise too many false positives
|
|
2052
|
-
if (!r) {
|
|
2053
|
-
return classNames.size <= 1;
|
|
2054
|
-
}
|
|
2055
|
-
// Use receiverType from findCallers when available
|
|
2056
|
-
if (c.receiverType) {
|
|
2057
|
-
return c.receiverType === targetClassName ||
|
|
2058
|
-
c.receiverType === targetDef.receiver?.replace(/^\*/, '');
|
|
2059
|
-
}
|
|
2060
|
-
// Unique method heuristic: if the method exists on exactly one class/type, include
|
|
2061
|
-
if (classNames.size === 1 && classNames.has(targetClassName)) return true;
|
|
2062
|
-
// For widely shared method names (Get, Set, Run, etc.), require same-package
|
|
2063
|
-
// evidence when receiver type is unknown
|
|
2064
|
-
if (isWidelyShared) {
|
|
2065
|
-
const callerFile = c.file || '';
|
|
2066
|
-
const targetDir = path.dirname(change.filePath);
|
|
2067
|
-
return path.dirname(callerFile) === targetDir;
|
|
2068
|
-
}
|
|
2069
|
-
// Unknown receiver + multiple classes with this method → filter out
|
|
2070
|
-
return false;
|
|
2071
|
-
});
|
|
2072
|
-
}
|
|
2013
|
+
const confirmed = rawCallers.filter(c => c.tier !== 'unverified');
|
|
2014
|
+
let unverified = rawCallers.filter(c => c.tier === 'unverified')
|
|
2015
|
+
.concat(rawCallers.unverifiedEntries || []);
|
|
2016
|
+
|
|
2017
|
+
// Per-symbol conservation account over the text ground set (same
|
|
2018
|
+
// ring as impact — every changed symbol is a root the user touched
|
|
2019
|
+
// directly). Ground call-lines no candidate claimed become visible
|
|
2020
|
+
// one-liners (call-not-resolved), never silent gaps.
|
|
2021
|
+
const account = composeAccount(index, symbol.name, rawCallers);
|
|
2022
|
+
for (const e of callNotResolvedEntries(index, account)) unverified.push(e);
|
|
2023
|
+
unverified.sort((a, b) => {
|
|
2024
|
+
const ap = a.relativePath || '';
|
|
2025
|
+
const bp = b.relativePath || '';
|
|
2026
|
+
if (ap !== bp) return codeUnitCompare(ap, bp);
|
|
2027
|
+
return (a.line || 0) - (b.line || 0);
|
|
2028
|
+
});
|
|
2073
2029
|
|
|
2074
|
-
for (const c of
|
|
2030
|
+
for (const c of confirmed) {
|
|
2075
2031
|
callerFileSet.add(c.file);
|
|
2076
2032
|
}
|
|
2077
|
-
totalCallSites +=
|
|
2033
|
+
totalCallSites += confirmed.length;
|
|
2034
|
+
totalUnverifiedSites += unverified.length;
|
|
2078
2035
|
|
|
2079
2036
|
functions.push({
|
|
2080
2037
|
name: symbol.name,
|
|
@@ -2085,17 +2042,68 @@ function diffImpact(index, options = {}) {
|
|
|
2085
2042
|
signature: index.formatSignature(symbol),
|
|
2086
2043
|
addedLines: aLines,
|
|
2087
2044
|
deletedLines: dLines,
|
|
2088
|
-
callers:
|
|
2045
|
+
callers: confirmed.map(c => ({
|
|
2089
2046
|
file: c.file,
|
|
2090
2047
|
relativePath: c.relativePath,
|
|
2091
2048
|
line: c.line,
|
|
2092
2049
|
callerName: c.callerName,
|
|
2093
|
-
content: c.content.trim()
|
|
2094
|
-
|
|
2050
|
+
content: (c.content || '').trim(),
|
|
2051
|
+
confidence: c.confidence,
|
|
2052
|
+
resolution: c.resolution,
|
|
2053
|
+
...(c.tier && { tier: c.tier }),
|
|
2054
|
+
})),
|
|
2055
|
+
unverifiedCallers: unverified.map(u => ({
|
|
2056
|
+
file: u.file,
|
|
2057
|
+
relativePath: u.relativePath,
|
|
2058
|
+
line: u.line,
|
|
2059
|
+
callerName: u.callerName ?? null,
|
|
2060
|
+
content: (u.content || '').trim(),
|
|
2061
|
+
tier: 'unverified',
|
|
2062
|
+
...(u.reason && { reason: u.reason }),
|
|
2063
|
+
...(u.dispatchVia && { dispatchVia: u.dispatchVia }),
|
|
2064
|
+
...(u.dispatchCandidates != null && { dispatchCandidates: u.dispatchCandidates }),
|
|
2065
|
+
})),
|
|
2066
|
+
account,
|
|
2095
2067
|
});
|
|
2096
2068
|
}
|
|
2097
2069
|
}
|
|
2098
2070
|
|
|
2071
|
+
// Deleted-but-still-called: a deleted function whose NAME still has call
|
|
2072
|
+
// sites in the current tree is a likely runtime/compile break. The def is
|
|
2073
|
+
// gone, so no receiver physics can pin these — they are name-level
|
|
2074
|
+
// candidates and are labeled as such (same epistemic tier as `usages`).
|
|
2075
|
+
if (deletedFunctions.length > 0) {
|
|
2076
|
+
const { getCachedCalls } = require('./callers');
|
|
2077
|
+
for (const del of deletedFunctions) {
|
|
2078
|
+
const remaining = [];
|
|
2079
|
+
const calleeFiles = index.getCalleeFiles(del.name);
|
|
2080
|
+
if (calleeFiles) {
|
|
2081
|
+
for (const f of calleeFiles) {
|
|
2082
|
+
const calls = getCachedCalls(index, f);
|
|
2083
|
+
if (!Array.isArray(calls)) continue;
|
|
2084
|
+
const fe = index.files.get(f);
|
|
2085
|
+
let lines = null;
|
|
2086
|
+
for (const c of calls) {
|
|
2087
|
+
if (c.name !== del.name && c.resolvedName !== del.name) continue;
|
|
2088
|
+
if (lines === null) {
|
|
2089
|
+
try { lines = index._getFileLines(f); } catch { lines = []; }
|
|
2090
|
+
}
|
|
2091
|
+
remaining.push({
|
|
2092
|
+
file: f,
|
|
2093
|
+
relativePath: fe ? fe.relativePath : path.relative(index.root, f),
|
|
2094
|
+
line: c.line,
|
|
2095
|
+
content: (lines[c.line - 1] || '').trim(),
|
|
2096
|
+
});
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
remaining.sort((a, b) => a.relativePath === b.relativePath
|
|
2101
|
+
? a.line - b.line
|
|
2102
|
+
: codeUnitCompare(a.relativePath, b.relativePath));
|
|
2103
|
+
del.remainingCallSites = remaining;
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2099
2107
|
return {
|
|
2100
2108
|
base: staged ? '(staged)' : base,
|
|
2101
2109
|
functions,
|
|
@@ -2107,6 +2115,7 @@ function diffImpact(index, options = {}) {
|
|
|
2107
2115
|
deletedFunctions: deletedFunctions.length,
|
|
2108
2116
|
newFunctions: newFunctions.length,
|
|
2109
2117
|
totalCallSites,
|
|
2118
|
+
unverifiedCallSites: totalUnverifiedSites,
|
|
2110
2119
|
affectedFiles: callerFileSet.size
|
|
2111
2120
|
}
|
|
2112
2121
|
};
|
|
@@ -2119,19 +2128,28 @@ function diffImpact(index, options = {}) {
|
|
|
2119
2128
|
|
|
2120
2129
|
/**
|
|
2121
2130
|
* Extract all callable symbols (functions + class methods) from a parse result,
|
|
2122
|
-
* matching how indexFile builds the symbol list
|
|
2131
|
+
* matching how indexFile builds the symbol list — the identity keys
|
|
2132
|
+
* (`name\0className`) derived here are compared against INDEXED symbols, so
|
|
2133
|
+
* both normalizations indexFile applies must happen here too:
|
|
2134
|
+
* - Go/Rust methods are standalone functions with a `receiver`; indexFile
|
|
2135
|
+
* derives className from it. Without this, every modified method's identity
|
|
2136
|
+
* misses the old set and it reads as new + phantom-deleted.
|
|
2137
|
+
* - Class members include FIELDS; the indexed side filters NON_CALLABLE_TYPES,
|
|
2138
|
+
* so the old side must too or every field in a changed file reads deleted.
|
|
2123
2139
|
* @param {object} parsed - Result from parse()
|
|
2124
2140
|
* @returns {Array<{name, className, startLine}>}
|
|
2125
2141
|
*/
|
|
2126
2142
|
function extractCallableSymbols(parsed) {
|
|
2127
2143
|
const symbols = [];
|
|
2128
2144
|
for (const fn of parsed.functions) {
|
|
2129
|
-
|
|
2145
|
+
const className = fn.className || (fn.receiver ? fn.receiver.replace(/^\*/, '') : '');
|
|
2146
|
+
symbols.push({ name: fn.name, className, startLine: fn.startLine, endLine: fn.endLine });
|
|
2130
2147
|
}
|
|
2131
2148
|
for (const cls of parsed.classes) {
|
|
2132
2149
|
if (cls.members) {
|
|
2133
2150
|
for (const m of cls.members) {
|
|
2134
|
-
|
|
2151
|
+
if (NON_CALLABLE_TYPES.has(m.memberType || 'method')) continue;
|
|
2152
|
+
symbols.push({ name: m.name, className: cls.name, startLine: m.startLine, endLine: m.endLine });
|
|
2135
2153
|
}
|
|
2136
2154
|
}
|
|
2137
2155
|
}
|
|
@@ -2608,12 +2626,12 @@ function auditAsync(index, options = {}) {
|
|
|
2608
2626
|
|
|
2609
2627
|
// Stable ordering (rule #11): sort by (file, line, callerName, calleeName).
|
|
2610
2628
|
issues.sort((a, b) => {
|
|
2611
|
-
const fc = String(a.file)
|
|
2629
|
+
const fc = codeUnitCompare(String(a.file), String(b.file));
|
|
2612
2630
|
if (fc !== 0) return fc;
|
|
2613
2631
|
if (a.line !== b.line) return a.line - b.line;
|
|
2614
|
-
const cc = String(a.callerName || '')
|
|
2632
|
+
const cc = codeUnitCompare(String(a.callerName || ''), String(b.callerName || ''));
|
|
2615
2633
|
if (cc !== 0) return cc;
|
|
2616
|
-
return String(a.calleeName || '')
|
|
2634
|
+
return codeUnitCompare(String(a.calleeName || ''), String(b.calleeName || ''));
|
|
2617
2635
|
});
|
|
2618
2636
|
|
|
2619
2637
|
return {
|