ucn 3.8.25 → 4.0.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 +44 -18
- package/README.md +95 -28
- package/cli/index.js +28 -5
- package/core/account.js +354 -0
- package/core/analysis.js +335 -15
- package/core/bridge.js +0 -16
- package/core/build-worker.js +21 -1
- package/core/cache.js +52 -3
- package/core/callers.js +3434 -158
- package/core/confidence.js +82 -19
- package/core/deadcode.js +114 -21
- package/core/execute.js +4 -0
- package/core/graph-build.js +44 -2
- package/core/imports.js +118 -1
- package/core/output/analysis.js +345 -83
- package/core/output/reporting.js +8 -2
- 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/search.js +0 -42
- 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 +3 -3
- package/package.json +9 -3
- package/.github/workflows/ci.yml +0 -45
- package/.github/workflows/publish.yml +0 -79
package/core/confidence.js
CHANGED
|
@@ -9,24 +9,56 @@
|
|
|
9
9
|
|
|
10
10
|
// Resolution types ordered from most to least confident
|
|
11
11
|
const RESOLUTION = {
|
|
12
|
-
EXACT_BINDING:
|
|
13
|
-
SAME_CLASS:
|
|
14
|
-
RECEIVER_HINT:
|
|
15
|
-
SCOPE_MATCH:
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
EXACT_BINDING: 'exact-binding',
|
|
13
|
+
SAME_CLASS: 'same-class',
|
|
14
|
+
RECEIVER_HINT: 'receiver-hint',
|
|
15
|
+
SCOPE_MATCH: 'scope-match',
|
|
16
|
+
POSSIBLE_DISPATCH: 'possible-dispatch',
|
|
17
|
+
NAME_ONLY: 'name-only',
|
|
18
|
+
METHOD_AMBIGUOUS: 'method-ambiguous',
|
|
19
|
+
UNCERTAIN: 'uncertain',
|
|
18
20
|
};
|
|
19
21
|
|
|
20
22
|
// Seed scores per resolution type (tunable)
|
|
21
23
|
const SCORES = {
|
|
22
|
-
[RESOLUTION.EXACT_BINDING]:
|
|
23
|
-
[RESOLUTION.SAME_CLASS]:
|
|
24
|
-
[RESOLUTION.RECEIVER_HINT]:
|
|
25
|
-
[RESOLUTION.SCOPE_MATCH]:
|
|
26
|
-
[RESOLUTION.
|
|
27
|
-
[RESOLUTION.
|
|
24
|
+
[RESOLUTION.EXACT_BINDING]: 0.98,
|
|
25
|
+
[RESOLUTION.SAME_CLASS]: 0.92,
|
|
26
|
+
[RESOLUTION.RECEIVER_HINT]: 0.80,
|
|
27
|
+
[RESOLUTION.SCOPE_MATCH]: 0.65,
|
|
28
|
+
[RESOLUTION.POSSIBLE_DISPATCH]: 0.50,
|
|
29
|
+
[RESOLUTION.NAME_ONLY]: 0.40,
|
|
30
|
+
[RESOLUTION.METHOD_AMBIGUOUS]: 0.35,
|
|
31
|
+
[RESOLUTION.UNCERTAIN]: 0.25,
|
|
28
32
|
};
|
|
29
33
|
|
|
34
|
+
// Trust tiers for the tiered caller contract. CONFIRMED = the resolution
|
|
35
|
+
// rests on binding/receiver/import evidence; UNVERIFIED = name match without
|
|
36
|
+
// evidence. The mapping is resolution-based, never language-based — evidence
|
|
37
|
+
// flags already come from langTraits dispatch in callers.js, so every
|
|
38
|
+
// language gets correct tiers automatically.
|
|
39
|
+
const TIER = { CONFIRMED: 'confirmed', UNVERIFIED: 'unverified' };
|
|
40
|
+
const RESOLUTION_TIER = {
|
|
41
|
+
[RESOLUTION.EXACT_BINDING]: TIER.CONFIRMED,
|
|
42
|
+
[RESOLUTION.SAME_CLASS]: TIER.CONFIRMED,
|
|
43
|
+
[RESOLUTION.RECEIVER_HINT]: TIER.CONFIRMED,
|
|
44
|
+
// scope-match is only assigned with import/receiver/callback evidence
|
|
45
|
+
// (see scoreEdge below) — that satisfies the contract's evidence clause.
|
|
46
|
+
[RESOLUTION.SCOPE_MATCH]: TIER.CONFIRMED,
|
|
47
|
+
// Nominal dispatch tiering: a call that CAN reach the target through
|
|
48
|
+
// virtual dispatch (interface/supertype-typed receiver) or whose untyped
|
|
49
|
+
// receiver faces multiple same-name owners is evidence a call happens —
|
|
50
|
+
// not evidence it reaches THIS definition. Unverified by construction.
|
|
51
|
+
[RESOLUTION.POSSIBLE_DISPATCH]: TIER.UNVERIFIED,
|
|
52
|
+
[RESOLUTION.NAME_ONLY]: TIER.UNVERIFIED,
|
|
53
|
+
[RESOLUTION.METHOD_AMBIGUOUS]: TIER.UNVERIFIED,
|
|
54
|
+
[RESOLUTION.UNCERTAIN]: TIER.UNVERIFIED,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Map a RESOLUTION value to its trust tier (unknown values are unverified). */
|
|
58
|
+
function tierForResolution(resolution) {
|
|
59
|
+
return RESOLUTION_TIER[resolution] || TIER.UNVERIFIED;
|
|
60
|
+
}
|
|
61
|
+
|
|
30
62
|
/**
|
|
31
63
|
* Score a caller/callee edge based on resolution evidence.
|
|
32
64
|
*
|
|
@@ -44,6 +76,25 @@ const SCORES = {
|
|
|
44
76
|
function scoreEdge(evidence) {
|
|
45
77
|
const reasons = [];
|
|
46
78
|
|
|
79
|
+
// Known receiver/path type mismatch — checked FIRST: positive evidence the
|
|
80
|
+
// call targets a different symbol overrides any receiver-type signal
|
|
81
|
+
// (without this, a known mismatch would score receiver-hint 0.80).
|
|
82
|
+
if (evidence.typeMismatch) {
|
|
83
|
+
reasons.push('receiver type mismatch');
|
|
84
|
+
return { confidence: SCORES[RESOLUTION.UNCERTAIN], resolution: RESOLUTION.UNCERTAIN, evidence: reasons };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Nominal dispatch tiering (contract surface only — callers.js sets these
|
|
88
|
+
// flags exclusively under collectAccount, so legacy paths never see them).
|
|
89
|
+
if (evidence.possibleDispatch) {
|
|
90
|
+
reasons.push('interface/supertype dispatch');
|
|
91
|
+
return { confidence: SCORES[RESOLUTION.POSSIBLE_DISPATCH], resolution: RESOLUTION.POSSIBLE_DISPATCH, evidence: reasons };
|
|
92
|
+
}
|
|
93
|
+
if (evidence.methodAmbiguous) {
|
|
94
|
+
reasons.push('untyped receiver, multiple same-name definitions');
|
|
95
|
+
return { confidence: SCORES[RESOLUTION.METHOD_AMBIGUOUS], resolution: RESOLUTION.METHOD_AMBIGUOUS, evidence: reasons };
|
|
96
|
+
}
|
|
97
|
+
|
|
47
98
|
// Exact binding match (highest confidence)
|
|
48
99
|
if (evidence.hasBindingId) {
|
|
49
100
|
reasons.push('binding-id match');
|
|
@@ -64,16 +115,25 @@ function scoreEdge(evidence) {
|
|
|
64
115
|
return { confidence: SCORES[RESOLUTION.RECEIVER_HINT], resolution: RESOLUTION.RECEIVER_HINT, evidence: reasons };
|
|
65
116
|
}
|
|
66
117
|
|
|
118
|
+
// Function reference (callback / passed-as-argument). Argument position is
|
|
119
|
+
// only confirming when the name demonstrably reaches the target (same file,
|
|
120
|
+
// same package, or an import edge) — otherwise it's a bare name match: a
|
|
121
|
+
// local variable or an unrelated same-name symbol shadows it invisibly.
|
|
122
|
+
if (evidence.isFunctionReference) {
|
|
123
|
+
reasons.push('function reference');
|
|
124
|
+
if (evidence.hasImportEvidence || evidence.hasSamePackageEvidence) {
|
|
125
|
+
reasons.push(evidence.hasImportEvidence ? 'import-supported' : 'same package/module');
|
|
126
|
+
return { confidence: SCORES[RESOLUTION.SCOPE_MATCH], resolution: RESOLUTION.SCOPE_MATCH, evidence: reasons };
|
|
127
|
+
}
|
|
128
|
+
reasons.push('no import evidence');
|
|
129
|
+
return { confidence: SCORES[RESOLUTION.NAME_ONLY], resolution: RESOLUTION.NAME_ONLY, evidence: reasons };
|
|
130
|
+
}
|
|
131
|
+
|
|
67
132
|
// Scope/import-supported match
|
|
68
|
-
if (evidence.hasImportEvidence || evidence.hasReceiverEvidence) {
|
|
133
|
+
if (evidence.hasImportEvidence || evidence.hasReceiverEvidence || evidence.hasSamePackageEvidence) {
|
|
69
134
|
if (evidence.hasImportEvidence) reasons.push('import-supported');
|
|
70
135
|
if (evidence.hasReceiverEvidence) reasons.push('receiver binding in scope');
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Function reference (callback)
|
|
75
|
-
if (evidence.isFunctionReference) {
|
|
76
|
-
reasons.push('function reference');
|
|
136
|
+
if (evidence.hasSamePackageEvidence) reasons.push('same package/module');
|
|
77
137
|
return { confidence: SCORES[RESOLUTION.SCOPE_MATCH], resolution: RESOLUTION.SCOPE_MATCH, evidence: reasons };
|
|
78
138
|
}
|
|
79
139
|
|
|
@@ -111,6 +171,9 @@ function filterByConfidence(edges, minConfidence) {
|
|
|
111
171
|
module.exports = {
|
|
112
172
|
RESOLUTION,
|
|
113
173
|
SCORES,
|
|
174
|
+
TIER,
|
|
175
|
+
RESOLUTION_TIER,
|
|
176
|
+
tierForResolution,
|
|
114
177
|
scoreEdge,
|
|
115
178
|
filterByConfidence,
|
|
116
179
|
};
|
package/core/deadcode.js
CHANGED
|
@@ -177,6 +177,45 @@ function buildUsageIndex(index, filterNames) {
|
|
|
177
177
|
return usageIndex;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Is a symbol part of the public/exported API surface?
|
|
182
|
+
*
|
|
183
|
+
* Beyond direct evidence (export list, export/public modifiers, Go
|
|
184
|
+
* capitalization), methods of an exported class count as exported in
|
|
185
|
+
* languages where class members are public by default (implicitlyPublicMembers
|
|
186
|
+
* trait — JS/TS/Python): they are reachable through the class from outside
|
|
187
|
+
* the project, so claiming them dead invites deleting public API (fix #211 —
|
|
188
|
+
* zod's `strictImplement` is called by zero project files but is documented
|
|
189
|
+
* public API). Private-by-shape members (#name, _name, `private` modifier)
|
|
190
|
+
* stay claimable.
|
|
191
|
+
*/
|
|
192
|
+
function symbolIsExported(index, symbol, fileEntry) {
|
|
193
|
+
if (!fileEntry) return false;
|
|
194
|
+
const name = symbol.name;
|
|
195
|
+
const mods = symbol.modifiers || [];
|
|
196
|
+
if (fileEntry.exports.includes(name) || mods.includes('export') || mods.includes('public')) {
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
const traits = langTraits(fileEntry.language);
|
|
200
|
+
if (traits?.exportVisibility === 'capitalization') {
|
|
201
|
+
return /^[A-Z]/.test(name);
|
|
202
|
+
}
|
|
203
|
+
if (traits?.implicitlyPublicMembers && symbol.className &&
|
|
204
|
+
!mods.includes('private') && !name.startsWith('#') && !name.startsWith('_')) {
|
|
205
|
+
const classSyms = index.symbols.get(symbol.className) || [];
|
|
206
|
+
const cls = classSyms.find(c => c.file === symbol.file &&
|
|
207
|
+
(c.type === 'class' || c.type === 'interface'));
|
|
208
|
+
if (cls) {
|
|
209
|
+
const cmods = cls.modifiers || [];
|
|
210
|
+
if (fileEntry.exports.includes(symbol.className) ||
|
|
211
|
+
cmods.includes('export') || cmods.includes('public')) {
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
180
219
|
/**
|
|
181
220
|
* Find dead code (unused functions/classes)
|
|
182
221
|
* @param {object} index - ProjectIndex instance
|
|
@@ -220,15 +259,7 @@ function deadcode(index, options = {}) {
|
|
|
220
259
|
for (const name of potentiallyDeadNames) {
|
|
221
260
|
const syms = index.symbols.get(name) || [];
|
|
222
261
|
// Keep the name only if at least one definition is NOT exported
|
|
223
|
-
const allExported = syms.every(s =>
|
|
224
|
-
const fe = index.files.get(s.file);
|
|
225
|
-
const lang = fe?.language;
|
|
226
|
-
if (!fe) return false;
|
|
227
|
-
return fe.exports.includes(name) ||
|
|
228
|
-
(s.modifiers || []).includes('export') ||
|
|
229
|
-
(s.modifiers || []).includes('public') ||
|
|
230
|
-
(langTraits(lang)?.exportVisibility === 'capitalization' && /^[A-Z]/.test(name));
|
|
231
|
-
});
|
|
262
|
+
const allExported = syms.every(s => symbolIsExported(index, s, index.files.get(s.file)));
|
|
232
263
|
if (!allExported) narrowed.add(name);
|
|
233
264
|
}
|
|
234
265
|
potentiallyDeadNames = narrowed;
|
|
@@ -260,10 +291,18 @@ function deadcode(index, options = {}) {
|
|
|
260
291
|
const content = index._readFile(filePath);
|
|
261
292
|
// Fast pre-filter: extract identifiers from file, intersect with target names.
|
|
262
293
|
// One regex pass over content (O(content)) vs O(names × content) substring searches.
|
|
294
|
+
// Names the identifier regex can never produce — quoted member names
|
|
295
|
+
// (zod's `"~validate"`), $-containing JS names — fall back to a substring
|
|
296
|
+
// check, or they would scan as zero-usage and be falsely claimed dead
|
|
297
|
+
// (fix #211: `this["~validate"](data)` is a real usage; the quotes in
|
|
298
|
+
// the symbol name make the substring search self-delimiting).
|
|
263
299
|
const fileIdentifiers = new Set(content.match(/\b[a-zA-Z_]\w*\b/g));
|
|
264
300
|
const namesInFile = [];
|
|
265
301
|
for (const name of potentiallyDeadNames) {
|
|
266
|
-
|
|
302
|
+
const present = /^[a-zA-Z_]\w*$/.test(name)
|
|
303
|
+
? fileIdentifiers.has(name)
|
|
304
|
+
: content.includes(name);
|
|
305
|
+
if (present) namesInFile.push(name);
|
|
267
306
|
}
|
|
268
307
|
if (namesInFile.length === 0) continue;
|
|
269
308
|
const lines = content.split('\n');
|
|
@@ -291,9 +330,37 @@ function deadcode(index, options = {}) {
|
|
|
291
330
|
(hashIdx === 0 || /\s/.test(line[hashIdx - 1]))) continue;
|
|
292
331
|
// Skip if inside a string literal
|
|
293
332
|
if (isInsideString(line, pos)) continue;
|
|
294
|
-
//
|
|
333
|
+
// Property/field access (preceded by '.'), not a
|
|
334
|
+
// call: resolve the RECEIVER (fix #216, express-
|
|
335
|
+
// measured false-dead — `app.all(route, user.load)`
|
|
336
|
+
// is a callback reference to user.js's load, and
|
|
337
|
+
// deleting it breaks the route).
|
|
338
|
+
// - import-bound module receiver → usage scoped
|
|
339
|
+
// to the module's resolved file
|
|
340
|
+
// - this/self/cls receiver → usage scoped to the
|
|
341
|
+
// same file (same-class member reference)
|
|
342
|
+
// - any other receiver (local object literal,
|
|
343
|
+
// instance) → NOT a usage of a standalone
|
|
344
|
+
// symbol (fix #123: `Primitives.Separator` has
|
|
345
|
+
// its own key; must not keep the export alive)
|
|
346
|
+
let dottedScope;
|
|
295
347
|
if (pos > 0 && line[pos - 1] === '.' &&
|
|
296
|
-
(pos + nameLen >= line.length || line[pos + nameLen] !== '('))
|
|
348
|
+
(pos + nameLen >= line.length || line[pos + nameLen] !== '(')) {
|
|
349
|
+
let r = pos - 2;
|
|
350
|
+
while (r >= 0 && /[\w$]/.test(line[r])) r--;
|
|
351
|
+
const receiver = line.slice(r + 1, pos - 1);
|
|
352
|
+
if (!receiver) continue;
|
|
353
|
+
if (['this', 'self', 'cls'].includes(receiver)) {
|
|
354
|
+
dottedScope = 'same-file';
|
|
355
|
+
} else {
|
|
356
|
+
const binding = (fileEntry.importBindings || [])
|
|
357
|
+
.find(b => b.name === receiver);
|
|
358
|
+
const resolved = binding && fileEntry.moduleResolved &&
|
|
359
|
+
fileEntry.moduleResolved[binding.module];
|
|
360
|
+
if (!resolved) continue;
|
|
361
|
+
dottedScope = resolved;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
297
364
|
// Skip object literal key: name followed by ':' (not '::' for Rust paths)
|
|
298
365
|
const afterChar = pos + nameLen < line.length ? line[pos + nameLen] : '';
|
|
299
366
|
const afterChar2 = pos + nameLen + 1 < line.length ? line[pos + nameLen + 1] : '';
|
|
@@ -303,7 +370,8 @@ function deadcode(index, options = {}) {
|
|
|
303
370
|
usageIndex.get(name).push({
|
|
304
371
|
file: filePath,
|
|
305
372
|
line: i + 1,
|
|
306
|
-
relativePath: fileEntry.relativePath
|
|
373
|
+
relativePath: fileEntry.relativePath,
|
|
374
|
+
...(dottedScope && { dottedScope })
|
|
307
375
|
});
|
|
308
376
|
break; // one match per line is enough for deadcode
|
|
309
377
|
}
|
|
@@ -365,12 +433,7 @@ function deadcode(index, options = {}) {
|
|
|
365
433
|
continue;
|
|
366
434
|
}
|
|
367
435
|
|
|
368
|
-
const isExported =
|
|
369
|
-
fileEntry.exports.includes(name) ||
|
|
370
|
-
mods.includes('export') ||
|
|
371
|
-
mods.includes('public') ||
|
|
372
|
-
(langTraits(lang)?.exportVisibility === 'capitalization' && /^[A-Z]/.test(name))
|
|
373
|
-
);
|
|
436
|
+
const isExported = symbolIsExported(index, symbol, fileEntry);
|
|
374
437
|
|
|
375
438
|
// Skip exported unless requested
|
|
376
439
|
if (isExported && !options.includeExported) {
|
|
@@ -389,8 +452,15 @@ function deadcode(index, options = {}) {
|
|
|
389
452
|
// Filter out usages that are at the definition location
|
|
390
453
|
// nameLine: when decorators/annotations are present, startLine is the decorator line
|
|
391
454
|
// but the name identifier is on a different line (nameLine). Check both.
|
|
455
|
+
// Dotted usages (fix #216) are scoped to the file their receiver
|
|
456
|
+
// resolves to — `user.load` keeps user.js's load alive, never an
|
|
457
|
+
// unrelated module's same-name symbol.
|
|
392
458
|
let nonDefUsages = allUsages.filter(u =>
|
|
393
|
-
!(u.file === symbol.file && (u.line === symbol.startLine || u.line === symbol.nameLine))
|
|
459
|
+
!(u.file === symbol.file && (u.line === symbol.startLine || u.line === symbol.nameLine)) &&
|
|
460
|
+
(!u.dottedScope ||
|
|
461
|
+
(u.dottedScope === 'same-file'
|
|
462
|
+
? u.file === symbol.file
|
|
463
|
+
: u.dottedScope === symbol.relativePath))
|
|
394
464
|
);
|
|
395
465
|
|
|
396
466
|
// For exported symbols in --include-exported mode, also filter out export-site
|
|
@@ -428,6 +498,28 @@ function deadcode(index, options = {}) {
|
|
|
428
498
|
? mods.filter(m => !javaKw.has(m))
|
|
429
499
|
: [];
|
|
430
500
|
|
|
501
|
+
// Interface/trait member declarations are contract surface, not
|
|
502
|
+
// executable code: "unreferenced" is true, but deleting one
|
|
503
|
+
// changes the API contract rather than removing dead logic (Go
|
|
504
|
+
// marker interfaces exist SOLELY as uncallable declarations —
|
|
505
|
+
// grpc-go-measured: its entire default deadcode output was this
|
|
506
|
+
// family). Label so the claim self-explains (fix #211). Only
|
|
507
|
+
// body-less declarations qualify — Java `default` and Rust
|
|
508
|
+
// default-bodied trait methods are executable code, detected
|
|
509
|
+
// generically by a brace in the member's source range.
|
|
510
|
+
const declaredOn = (() => {
|
|
511
|
+
if (!symbol.className) return null;
|
|
512
|
+
const enclosing = (index.symbols.get(symbol.className) || []).find(c =>
|
|
513
|
+
c.file === symbol.file && (c.type === 'interface' || c.type === 'trait'));
|
|
514
|
+
if (!enclosing) return null;
|
|
515
|
+
try {
|
|
516
|
+
const content = index._readFile(symbol.file);
|
|
517
|
+
const range = content.split('\n').slice(symbol.startLine - 1, symbol.endLine).join('\n');
|
|
518
|
+
if (range.includes('{')) return null;
|
|
519
|
+
} catch { return null; }
|
|
520
|
+
return { kind: enclosing.type, name: symbol.className };
|
|
521
|
+
})();
|
|
522
|
+
|
|
431
523
|
results.push({
|
|
432
524
|
name: symbol.name,
|
|
433
525
|
type: symbol.type,
|
|
@@ -437,7 +529,8 @@ function deadcode(index, options = {}) {
|
|
|
437
529
|
isExported,
|
|
438
530
|
usageCount: 0,
|
|
439
531
|
...(decorators.length > 0 && { decorators }),
|
|
440
|
-
...(annotations.length > 0 && { annotations })
|
|
532
|
+
...(annotations.length > 0 && { annotations }),
|
|
533
|
+
...(declaredOn && { declaredOn })
|
|
441
534
|
});
|
|
442
535
|
}
|
|
443
536
|
}
|
package/core/execute.js
CHANGED
|
@@ -292,6 +292,7 @@ const HANDLERS = {
|
|
|
292
292
|
const result = index.context(p.name, {
|
|
293
293
|
...buildCallerOptions(p),
|
|
294
294
|
unreachableOnly: !!p.unreachableOnly,
|
|
295
|
+
all: !!p.all,
|
|
295
296
|
});
|
|
296
297
|
if (!result) return { ok: false, error: `Symbol "${p.name}" not found.` };
|
|
297
298
|
const tNote = truncationNote(index);
|
|
@@ -337,6 +338,7 @@ const HANDLERS = {
|
|
|
337
338
|
...buildCallerOptions(p),
|
|
338
339
|
depth: depthVal ?? 3,
|
|
339
340
|
all: p.all || depthVal !== undefined,
|
|
341
|
+
expandUnverified: !!p.expandUnverified,
|
|
340
342
|
});
|
|
341
343
|
if (!result) return { ok: false, error: `Function "${p.name}" not found.` };
|
|
342
344
|
const note = treeNote(result);
|
|
@@ -358,6 +360,7 @@ const HANDLERS = {
|
|
|
358
360
|
...buildCallerOptions(p),
|
|
359
361
|
depth: depthVal ?? 5,
|
|
360
362
|
all: p.all || depthVal !== undefined,
|
|
363
|
+
expandUnverified: !!p.expandUnverified,
|
|
361
364
|
});
|
|
362
365
|
if (!result) return { ok: false, error: `Function "${p.name}" not found.` };
|
|
363
366
|
const note = treeNote(result);
|
|
@@ -396,6 +399,7 @@ const HANDLERS = {
|
|
|
396
399
|
...buildCallerOptions(p),
|
|
397
400
|
depth: depthVal ?? 3,
|
|
398
401
|
all: p.all || depthVal !== undefined,
|
|
402
|
+
expandUnverified: !!p.expandUnverified,
|
|
399
403
|
});
|
|
400
404
|
if (!result) return { ok: false, error: `Function "${p.name}" not found.` };
|
|
401
405
|
const note = treeNote(result);
|
package/core/graph-build.js
CHANGED
|
@@ -105,6 +105,13 @@ function buildImportGraph(index) {
|
|
|
105
105
|
for (const [filePath, fileEntry] of index.files) {
|
|
106
106
|
const importedFiles = new Set();
|
|
107
107
|
const seenModules = new Set();
|
|
108
|
+
// Per-module resolution map (fix #209): module string → resolved
|
|
109
|
+
// project file (ROOT-RELATIVE — fileEntry persists in the cache, so
|
|
110
|
+
// paths must stay portable). Lets query-time code answer "which FILE
|
|
111
|
+
// does the module behind this import binding live in" — file-level
|
|
112
|
+
// importGraph edges can't (a file importing the target for OTHER
|
|
113
|
+
// names is not evidence about THIS name's module).
|
|
114
|
+
const moduleResolved = {};
|
|
108
115
|
|
|
109
116
|
for (const importModule of fileEntry.imports) {
|
|
110
117
|
// Skip null modules (e.g., dynamic include! macros in Rust)
|
|
@@ -128,6 +135,7 @@ function buildImportGraph(index) {
|
|
|
128
135
|
}
|
|
129
136
|
|
|
130
137
|
if (resolved && index.files.has(resolved)) {
|
|
138
|
+
moduleResolved[importModule] = path.relative(index.root, resolved);
|
|
131
139
|
// For Go, a package import means all files in that directory are dependencies
|
|
132
140
|
// (Go packages span multiple files in the same directory)
|
|
133
141
|
const filesToLink = [resolved];
|
|
@@ -154,6 +162,7 @@ function buildImportGraph(index) {
|
|
|
154
162
|
}
|
|
155
163
|
|
|
156
164
|
index.importGraph.set(filePath, importedFiles);
|
|
165
|
+
fileEntry.moduleResolved = moduleResolved;
|
|
157
166
|
}
|
|
158
167
|
}
|
|
159
168
|
|
|
@@ -181,8 +190,15 @@ function buildInheritanceGraph(index) {
|
|
|
181
190
|
}
|
|
182
191
|
|
|
183
192
|
if (symbol.extends) {
|
|
184
|
-
// Parse comma-separated parents (Python MRO: "Flyable, Swimmable")
|
|
185
|
-
|
|
193
|
+
// Parse comma-separated parents (Python MRO: "Flyable, Swimmable").
|
|
194
|
+
// Commas inside type arguments do NOT separate parents:
|
|
195
|
+
// `extends Base<string, object>` is ONE parent `Base`, and
|
|
196
|
+
// `class C(Mapping[str, int], Base)` is `Mapping` + `Base`.
|
|
197
|
+
// The naive split made every generically-extended class
|
|
198
|
+
// parentless (fix #214 — zod's whole ZodType hierarchy had no
|
|
199
|
+
// ancestor edges, measured: 12 true base-class dispatch edges
|
|
200
|
+
// demoted because `Base<string` never equals `Base`).
|
|
201
|
+
const parents = splitParentList(symbol.extends);
|
|
186
202
|
|
|
187
203
|
// Resolve aliased parent names via import aliases
|
|
188
204
|
// e.g., const { BaseHandler: Handler } = require('./base')
|
|
@@ -221,4 +237,30 @@ function buildInheritanceGraph(index) {
|
|
|
221
237
|
}
|
|
222
238
|
}
|
|
223
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Split an extends/bases clause on TOP-LEVEL commas only and strip each
|
|
242
|
+
* parent's trailing type-argument suffix: `Base<string, object>` → `Base`,
|
|
243
|
+
* `Mapping[str, int], Flyable` → `Mapping`, `Flyable`. Depth-tracks <>, [],
|
|
244
|
+
* and () so argument commas never split (fix #214).
|
|
245
|
+
*/
|
|
246
|
+
function splitParentList(clause) {
|
|
247
|
+
const parts = [];
|
|
248
|
+
let depth = 0;
|
|
249
|
+
let current = '';
|
|
250
|
+
for (const ch of String(clause)) {
|
|
251
|
+
if (ch === '<' || ch === '[' || ch === '(') depth++;
|
|
252
|
+
else if (ch === '>' || ch === ']' || ch === ')') depth = Math.max(0, depth - 1);
|
|
253
|
+
if (ch === ',' && depth === 0) {
|
|
254
|
+
parts.push(current);
|
|
255
|
+
current = '';
|
|
256
|
+
} else {
|
|
257
|
+
current += ch;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
parts.push(current);
|
|
261
|
+
return parts
|
|
262
|
+
.map(s => s.trim().replace(/[<[(].*$/s, '').trim())
|
|
263
|
+
.filter(Boolean);
|
|
264
|
+
}
|
|
265
|
+
|
|
224
266
|
module.exports = { buildDirIndex, buildImportGraph, buildInheritanceGraph, _resolveJavaPackageImport };
|
package/core/imports.js
CHANGED
|
@@ -120,6 +120,10 @@ function resolveImport(importPath, fromFile, config = {}) {
|
|
|
120
120
|
if (result) return result;
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
|
+
|
|
124
|
+
// Package self-reference (import own package by name)
|
|
125
|
+
const selfResolved = resolveSelfReference(importPath, fromDir, config);
|
|
126
|
+
if (selfResolved) return selfResolved;
|
|
123
127
|
}
|
|
124
128
|
|
|
125
129
|
// Check Go module imports
|
|
@@ -412,12 +416,124 @@ function resolveRustImport(importPath, fromFile, projectRoot) {
|
|
|
412
416
|
/**
|
|
413
417
|
* Try to resolve a path with various extensions
|
|
414
418
|
*/
|
|
419
|
+
// package.json lookup cache for self-reference resolution (dir -> info|null).
|
|
420
|
+
// Process-lifetime cache: package.json name/exports churn is rare enough that
|
|
421
|
+
// long-lived servers (MCP) tolerate it.
|
|
422
|
+
const _pkgCache = new Map();
|
|
423
|
+
|
|
424
|
+
function _findPackageJson(fromDir, stopDir) {
|
|
425
|
+
let current = fromDir;
|
|
426
|
+
for (let i = 0; i < 8; i++) {
|
|
427
|
+
let info;
|
|
428
|
+
if (_pkgCache.has(current)) {
|
|
429
|
+
info = _pkgCache.get(current);
|
|
430
|
+
} else {
|
|
431
|
+
info = null;
|
|
432
|
+
const candidate = path.join(current, 'package.json');
|
|
433
|
+
try {
|
|
434
|
+
if (fs.existsSync(candidate)) {
|
|
435
|
+
const pkg = JSON.parse(fs.readFileSync(candidate, 'utf-8'));
|
|
436
|
+
info = { dir: current, name: pkg.name, exports: pkg.exports, main: pkg.main };
|
|
437
|
+
}
|
|
438
|
+
} catch { /* unreadable or invalid JSON */ }
|
|
439
|
+
_pkgCache.set(current, info);
|
|
440
|
+
}
|
|
441
|
+
if (info) return info;
|
|
442
|
+
if (stopDir && current === stopDir) break;
|
|
443
|
+
const parent = path.dirname(current);
|
|
444
|
+
if (parent === current) break;
|
|
445
|
+
current = parent;
|
|
446
|
+
}
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Flatten an exports-map entry to candidate targets (condition objects in
|
|
451
|
+
* insertion order, arrays in order). 'types' conditions are skipped — they
|
|
452
|
+
* name declaration files, not runtime sources. */
|
|
453
|
+
function _collectExportTargets(entry, out = []) {
|
|
454
|
+
if (typeof entry === 'string') {
|
|
455
|
+
out.push(entry);
|
|
456
|
+
} else if (Array.isArray(entry)) {
|
|
457
|
+
for (const e of entry) _collectExportTargets(e, out);
|
|
458
|
+
} else if (entry && typeof entry === 'object') {
|
|
459
|
+
for (const [cond, v] of Object.entries(entry)) {
|
|
460
|
+
if (cond === 'types') continue;
|
|
461
|
+
_collectExportTargets(v, out);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return out;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Package self-reference: a file importing its own package by name
|
|
469
|
+
* (`import * as z from "zod/v3"` inside the zod repo) — standard in monorepo
|
|
470
|
+
* tests and benchmarks. Resolves through package.json "exports" (conditional
|
|
471
|
+
* objects, arrays, '*' wildcards), accepting the first condition target that
|
|
472
|
+
* lands on a real file.
|
|
473
|
+
*/
|
|
474
|
+
function resolveSelfReference(importPath, fromDir, config) {
|
|
475
|
+
const pkg = _findPackageJson(fromDir, config.root ? path.dirname(config.root) : null);
|
|
476
|
+
if (!pkg || !pkg.name) return null;
|
|
477
|
+
if (importPath !== pkg.name && !importPath.startsWith(pkg.name + '/')) return null;
|
|
478
|
+
const subpath = importPath === pkg.name ? '.' : './' + importPath.slice(pkg.name.length + 1);
|
|
479
|
+
const extensions = config.extensions || getExtensions(config.language);
|
|
480
|
+
const tryTargets = (entry, wildcard) => {
|
|
481
|
+
for (const target of _collectExportTargets(entry)) {
|
|
482
|
+
const concrete = wildcard != null ? target.replace(/\*/g, wildcard) : target;
|
|
483
|
+
const resolved = resolveFilePath(path.resolve(pkg.dir, concrete), extensions);
|
|
484
|
+
if (resolved) return resolved;
|
|
485
|
+
}
|
|
486
|
+
return null;
|
|
487
|
+
};
|
|
488
|
+
const exp = pkg.exports;
|
|
489
|
+
if (typeof exp === 'string') {
|
|
490
|
+
return subpath === '.' ? tryTargets(exp, null) : null;
|
|
491
|
+
}
|
|
492
|
+
if (exp && typeof exp === 'object') {
|
|
493
|
+
if (exp[subpath] !== undefined) {
|
|
494
|
+
const hit = tryTargets(exp[subpath], null);
|
|
495
|
+
if (hit) return hit;
|
|
496
|
+
}
|
|
497
|
+
for (const [key, val] of Object.entries(exp)) {
|
|
498
|
+
const star = key.indexOf('*');
|
|
499
|
+
if (star === -1) continue;
|
|
500
|
+
const pre = key.slice(0, star);
|
|
501
|
+
const post = key.slice(star + 1);
|
|
502
|
+
if (subpath.length > pre.length + post.length &&
|
|
503
|
+
subpath.startsWith(pre) && subpath.endsWith(post)) {
|
|
504
|
+
const wild = subpath.slice(pre.length, subpath.length - post.length);
|
|
505
|
+
const hit = tryTargets(val, wild);
|
|
506
|
+
if (hit) return hit;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
// No exports map: bare name -> main/index; subpath -> direct file
|
|
512
|
+
if (subpath === '.') {
|
|
513
|
+
return (pkg.main && resolveFilePath(path.resolve(pkg.dir, pkg.main), extensions)) ||
|
|
514
|
+
resolveFilePath(path.resolve(pkg.dir, 'index'), extensions);
|
|
515
|
+
}
|
|
516
|
+
return resolveFilePath(path.resolve(pkg.dir, subpath), extensions);
|
|
517
|
+
}
|
|
518
|
+
|
|
415
519
|
function resolveFilePath(basePath, extensions) {
|
|
416
520
|
// Check exact path
|
|
417
521
|
if (fs.existsSync(basePath) && fs.statSync(basePath).isFile()) {
|
|
418
522
|
return basePath;
|
|
419
523
|
}
|
|
420
524
|
|
|
525
|
+
// TS-ESM: explicit '.js'/'.mjs' specifiers refer to '.ts'/'.mts' sources
|
|
526
|
+
// (import specifiers name the compiled output). Remap before probing.
|
|
527
|
+
const esmRemap = { '.js': ['.ts', '.tsx'], '.jsx': ['.tsx'], '.mjs': ['.mts'], '.cjs': ['.cts'] };
|
|
528
|
+
const explicitExt = path.extname(basePath);
|
|
529
|
+
if (esmRemap[explicitExt]) {
|
|
530
|
+
const stem = basePath.slice(0, -explicitExt.length);
|
|
531
|
+
for (const tsExt of esmRemap[explicitExt]) {
|
|
532
|
+
const candidate = stem + tsExt;
|
|
533
|
+
try { if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate; } catch { /* skip */ }
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
421
537
|
// Try adding extensions
|
|
422
538
|
for (const ext of extensions) {
|
|
423
539
|
const withExt = basePath + ext;
|
|
@@ -633,5 +749,6 @@ module.exports = {
|
|
|
633
749
|
extractImports,
|
|
634
750
|
extractExports,
|
|
635
751
|
resolveImport,
|
|
636
|
-
resolveFilePath
|
|
752
|
+
resolveFilePath,
|
|
753
|
+
findGoModule
|
|
637
754
|
};
|