ucn 5.3.2 → 5.3.4
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 +10 -2
- package/cli/index.js +23 -12
- package/core/cache.js +103 -1
- package/core/callers.js +192 -5
- package/core/project.js +16 -2
- package/core/search.js +1 -1
- package/core/shared.js +1 -1
- package/languages/java.js +24 -4
- package/languages/rust.js +8 -0
- package/mcp/server.js +7 -2
- package/package.json +1 -1
|
@@ -39,7 +39,7 @@ answer, a notice stating the full size and limit, and the accounting/contract
|
|
|
39
39
|
lines needed to interpret what remains; the requested limit includes all
|
|
40
40
|
three. The text block is the whole response on every surface.
|
|
41
41
|
|
|
42
|
-
Persistent indexes live in a per-user, project-keyed cache rather than the analyzed repository. Set `UCN_CACHE_DIR` to override the cache root; CLI `--no-cache` bypasses persistence and `--clear-cache` removes the current project's cache. Legacy `<project>/.ucn-cache` directories are migrated on first use.
|
|
42
|
+
Persistent indexes live in a per-user, project-keyed cache rather than the analyzed repository. Set `UCN_CACHE_DIR` to override the cache root; CLI `--no-cache` bypasses persistence and `--clear-cache` removes the current project's cache. Legacy `<project>/.ucn-cache` directories are migrated on first use. Concurrent invocations on a cold cache share one build: the first process takes a build lock, the others wait for its cache (`Waiting for another ucn process building the index...` on stderr) instead of rebuilding; a lock left by a dead process is broken automatically.
|
|
43
43
|
|
|
44
44
|
Supported source families are JavaScript/TypeScript/TSX, Python, Go, Rust, Java, C, C++, C#, and HTML inline JavaScript/event handlers. C/C++ uses `compile_commands.json` when available to classify headers and resolve include paths. Recoverable preprocessor branches contribute AST-proven source facts, so a single selected configuration does not silently erase definitions or calls; disagreeing conditional macro identities stay visible as unverified. C++ resolution uses namespace ownership, static overload shape (including arrays), and macro-parameter requalification. C# resolution uses declared property/field receiver types plus overload and hiding discipline. This is portable AST analysis, not a compiler build; macros, templates, generated code, reflection, and external dependency semantics can remain unverified.
|
|
45
45
|
|
|
@@ -161,7 +161,15 @@ Treat `deadcode` as a candidate generator. Before deletion, inspect `usages`, `i
|
|
|
161
161
|
(JSX children, HTML markup and attributes) in an `OTHER TEXT` section unless
|
|
162
162
|
`--code-only` is set, so it lists every line the `ACCOUNT` counts. This is a
|
|
163
163
|
literal-name inventory, not exact target binding. Identifier boundaries are
|
|
164
|
-
Unicode-aware
|
|
164
|
+
Unicode-aware and match `grep -w`: `hit` never matches inside `hitΔ`, while
|
|
165
|
+
`$` is a boundary (`buy${...}`, `$fail`, `ws$close()` all count).
|
|
166
|
+
|
|
167
|
+
Aliased and qualified calls resolve in every language: Rust `use m::f as g; g()`
|
|
168
|
+
is a caller of `f` (listed as a beyond-text caller, since the line holds no
|
|
169
|
+
target token); Java `pkg.Type.method()` and C# `Ns.Type.Method()` /
|
|
170
|
+
`using T = Ns.Type; T.Method()` pick the type the qualifier names when several
|
|
171
|
+
same-name types exist. A qualifier the resolver cannot place stays visible as
|
|
172
|
+
`method-ambiguous`, never confirmed by first-definition order.
|
|
165
173
|
|
|
166
174
|
`endpoints` recognizes client receivers by evidence (a receiver typed to an
|
|
167
175
|
HTTP client class, or bound to a pytest fixture that constructs one), not only
|
package/cli/index.js
CHANGED
|
@@ -766,8 +766,18 @@ function runProjectCommand(rootDir, command, arg) {
|
|
|
766
766
|
// If cache was loaded but stale, force rebuild to avoid duplicates
|
|
767
767
|
let needsCacheSave = false;
|
|
768
768
|
if (!usedCache) {
|
|
769
|
-
|
|
770
|
-
|
|
769
|
+
const buildOpts = { quiet: flags.quiet, forceRebuild: cacheWasLoaded, followSymlinks: flags.followSymlinks, maxFiles: flags.maxFiles, workers: flags.workers };
|
|
770
|
+
if (flags.cache && !flags.maxFiles) {
|
|
771
|
+
// Cross-process build lock (fix #354): concurrent cold-cache
|
|
772
|
+
// invocations share one build instead of each rebuilding.
|
|
773
|
+
index.buildCached(buildOpts, {
|
|
774
|
+
onWait: () => { if (!flags.quiet) console.error('Waiting for another ucn process building the index...'); },
|
|
775
|
+
});
|
|
776
|
+
needsCacheSave = false; // buildCached saved (or loaded the other process's cache)
|
|
777
|
+
} else {
|
|
778
|
+
index.build(null, buildOpts);
|
|
779
|
+
needsCacheSave = flags.cache;
|
|
780
|
+
}
|
|
771
781
|
}
|
|
772
782
|
|
|
773
783
|
try {
|
|
@@ -986,13 +996,10 @@ function runInteractive(rootDir) {
|
|
|
986
996
|
if (flags.cache) {
|
|
987
997
|
const loaded = !flags.clearCache && index.loadCache();
|
|
988
998
|
iCacheFresh = loaded && !index.isCacheStale();
|
|
989
|
-
if (!iCacheFresh && loaded) {
|
|
990
|
-
index.build(null, { quiet: true, forceRebuild: true, workers: flags.workers });
|
|
991
|
-
} else if (!iCacheFresh) {
|
|
992
|
-
index.build(null, { quiet: true, workers: flags.workers });
|
|
993
|
-
}
|
|
994
999
|
if (!iCacheFresh) {
|
|
995
|
-
|
|
1000
|
+
index.buildCached({ quiet: true, forceRebuild: !!loaded, workers: flags.workers }, {
|
|
1001
|
+
onWait: () => console.log('Waiting for another ucn process building the index...'),
|
|
1002
|
+
});
|
|
996
1003
|
}
|
|
997
1004
|
} else {
|
|
998
1005
|
index.build(null, { quiet: true, workers: flags.workers });
|
|
@@ -1113,15 +1120,19 @@ Flags can be added per-command: show myFunc --sections=source,callers
|
|
|
1113
1120
|
// appeared in neighbouring answers (UCN5-044).
|
|
1114
1121
|
if (index.isCacheStale()) {
|
|
1115
1122
|
console.log('Source changed; rebuilding index...');
|
|
1116
|
-
|
|
1123
|
+
const rebuildOpts = {
|
|
1117
1124
|
quiet: true,
|
|
1118
1125
|
forceRebuild: true,
|
|
1119
1126
|
followSymlinks: flags.followSymlinks,
|
|
1120
1127
|
maxFiles: flags.maxFiles,
|
|
1121
1128
|
workers: flags.workers,
|
|
1122
|
-
}
|
|
1123
|
-
if (flags.cache) {
|
|
1124
|
-
|
|
1129
|
+
};
|
|
1130
|
+
if (flags.cache && !flags.maxFiles) {
|
|
1131
|
+
index.buildCached(rebuildOpts, {
|
|
1132
|
+
onWait: () => console.log('Waiting for another ucn process building the index...'),
|
|
1133
|
+
});
|
|
1134
|
+
} else {
|
|
1135
|
+
index.build(null, rebuildOpts);
|
|
1125
1136
|
}
|
|
1126
1137
|
console.log(`Index ready: ${index.files.size} files, ${index.symbols.size} unique symbol names`);
|
|
1127
1138
|
}
|
package/core/cache.js
CHANGED
|
@@ -256,6 +256,14 @@ function pruneUserCache({ force = false, now = Date.now() } = {}) {
|
|
|
256
256
|
root = data.root || null;
|
|
257
257
|
touched = Math.max(touched, Number(data.timestamp) || 0);
|
|
258
258
|
} catch (_) { /* malformed/incomplete cache expires below */ }
|
|
259
|
+
// fix #354: a project whose FIRST build is in progress has no
|
|
260
|
+
// index.json yet, only a live build.lock — another process's prune
|
|
261
|
+
// must not delete the lock (or the dir) out from under the builder.
|
|
262
|
+
const lockPath = path.join(dir, BUILD_LOCK_FILE);
|
|
263
|
+
if (!root && fs.existsSync(lockPath) && !_lockIsStale(lockPath)) {
|
|
264
|
+
entries.push({ dir, touched: now, bytes: null });
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
259
267
|
if (!root || !fs.existsSync(root) || now - touched > CACHE_TTL_MS) {
|
|
260
268
|
try {
|
|
261
269
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
@@ -699,7 +707,7 @@ function clearAllCaches() {
|
|
|
699
707
|
// v212 (fix #342): extendsGraph/extendedByGraph no longer persisted (rebuilt on load).
|
|
700
708
|
// v213 (fix #348): Python from-import records carry per-name `renames` so
|
|
701
709
|
// import bindings pair each alias with its own module.
|
|
702
|
-
const CACHE_FORMAT_VERSION =
|
|
710
|
+
const CACHE_FORMAT_VERSION = 214;
|
|
703
711
|
const USAGE_CACHE_FILE = 'usage-results.json';
|
|
704
712
|
|
|
705
713
|
/**
|
|
@@ -1494,7 +1502,101 @@ function _computeReachabilityFingerprint(index) {
|
|
|
1494
1502
|
return `${fileCount}:${symbolCount}:${sample}`;
|
|
1495
1503
|
}
|
|
1496
1504
|
|
|
1505
|
+
const BUILD_LOCK_FILE = 'build.lock';
|
|
1506
|
+
const BUILD_LOCK_STALE_MS = 15 * 60 * 1000;
|
|
1507
|
+
const BUILD_LOCK_WAIT_MS = 15 * 60 * 1000;
|
|
1508
|
+
const BUILD_LOCK_POLL_MS = 150;
|
|
1509
|
+
|
|
1510
|
+
function _sleepSync(ms) {
|
|
1511
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
function _pidAlive(pid) {
|
|
1515
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
1516
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
function _readLock(lockPath) {
|
|
1520
|
+
try { return JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch (_) { return null; }
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
function _lockIsStale(lockPath) {
|
|
1524
|
+
const info = _readLock(lockPath);
|
|
1525
|
+
if (!info) {
|
|
1526
|
+
// Unreadable or half-written: judge by mtime alone
|
|
1527
|
+
try { return Date.now() - fs.statSync(lockPath).mtimeMs > BUILD_LOCK_STALE_MS; } catch (_) { return true; }
|
|
1528
|
+
}
|
|
1529
|
+
if (info.pid && !_pidAlive(info.pid)) return true;
|
|
1530
|
+
return Date.now() - (info.startedAt || 0) > BUILD_LOCK_STALE_MS;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/**
|
|
1534
|
+
* Cross-process build lock (fix #354): N concurrent cold-cache invocations
|
|
1535
|
+
* used to rebuild the whole index N times at once (6 processes on a 474k-line
|
|
1536
|
+
* repo: 9s each alone, 363s each together — the stampede fires after every
|
|
1537
|
+
* version bump, because the bump invalidates every cache). One process takes
|
|
1538
|
+
* the lock and builds; the others wait, then load the cache it saved. A lock
|
|
1539
|
+
* whose holder is dead, or older than 15 minutes, is broken. Waiters give up
|
|
1540
|
+
* after 15 minutes and build themselves (a wedged holder must never make the
|
|
1541
|
+
* tool hang forever).
|
|
1542
|
+
*
|
|
1543
|
+
* @returns {{ built: boolean, waited: boolean }}
|
|
1544
|
+
*/
|
|
1545
|
+
function buildWithLock(index, buildOpts = {}, options = {}) {
|
|
1546
|
+
const cacheDir = getProjectCacheDir(index.root);
|
|
1547
|
+
const lockPath = path.join(cacheDir, BUILD_LOCK_FILE);
|
|
1548
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
1549
|
+
const deadline = Date.now() + BUILD_LOCK_WAIT_MS;
|
|
1550
|
+
let announced = false;
|
|
1551
|
+
for (;;) {
|
|
1552
|
+
let fd = null;
|
|
1553
|
+
try {
|
|
1554
|
+
fd = fs.openSync(lockPath, 'wx');
|
|
1555
|
+
} catch (e) {
|
|
1556
|
+
if (e.code !== 'EEXIST') throw e;
|
|
1557
|
+
}
|
|
1558
|
+
if (fd !== null) {
|
|
1559
|
+
try {
|
|
1560
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid, startedAt: Date.now() }));
|
|
1561
|
+
fs.closeSync(fd);
|
|
1562
|
+
index.build(null, buildOpts);
|
|
1563
|
+
if (options.save !== false) {
|
|
1564
|
+
try { saveCache(index); } catch (_) { /* best-effort */ }
|
|
1565
|
+
}
|
|
1566
|
+
return { built: true, waited: announced };
|
|
1567
|
+
} finally {
|
|
1568
|
+
try { fs.unlinkSync(lockPath); } catch (_) { /* already gone */ }
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
if (_lockIsStale(lockPath)) {
|
|
1572
|
+
try { fs.unlinkSync(lockPath); } catch (_) { /* raced */ }
|
|
1573
|
+
continue;
|
|
1574
|
+
}
|
|
1575
|
+
if (Date.now() > deadline) {
|
|
1576
|
+
index.build(null, buildOpts);
|
|
1577
|
+
if (options.save !== false) {
|
|
1578
|
+
try { saveCache(index); } catch (_) { /* best-effort */ }
|
|
1579
|
+
}
|
|
1580
|
+
return { built: true, waited: true };
|
|
1581
|
+
}
|
|
1582
|
+
if (!announced) {
|
|
1583
|
+
announced = true;
|
|
1584
|
+
if (typeof options.onWait === 'function') options.onWait(_readLock(lockPath));
|
|
1585
|
+
}
|
|
1586
|
+
_sleepSync(BUILD_LOCK_POLL_MS);
|
|
1587
|
+
if (!fs.existsSync(lockPath)) {
|
|
1588
|
+
// The holder finished: its cache is the answer unless it is
|
|
1589
|
+
// already stale again (or it never saved one)
|
|
1590
|
+
if (options.reload !== false && loadCache(index) && !isCacheStale(index)) {
|
|
1591
|
+
return { built: false, waited: true };
|
|
1592
|
+
}
|
|
1593
|
+
// fall through: take the lock ourselves
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1497
1598
|
module.exports = {
|
|
1599
|
+
buildWithLock,
|
|
1498
1600
|
saveCache, saveUsageCache, loadCache, loadCallsCache, isCacheStale, ensureCallsCacheLoaded,
|
|
1499
1601
|
getUserCacheRoot, getProjectCacheDir, getProjectCachePath,
|
|
1500
1602
|
getLegacyProjectCacheDir, migrateLegacyProjectCache, clearProjectCache,
|
package/core/callers.js
CHANGED
|
@@ -10,7 +10,8 @@ const path = require('path');
|
|
|
10
10
|
const crypto = require('crypto');
|
|
11
11
|
const { detectLanguage, getParser, getLanguageAdapter, langTraits } = require('../languages');
|
|
12
12
|
const { isTestFile } = require('./discovery');
|
|
13
|
-
const { NON_CALLABLE_TYPES, isOverrideMarked, codeUnitCompare, isTestPath } = require('./shared');
|
|
13
|
+
const { NON_CALLABLE_TYPES, isOverrideMarked, codeUnitCompare, isTestPath, CALLABLE_SYMBOL_KINDS } = require('./shared');
|
|
14
|
+
const { _resolveJavaPackageImport } = require('./graph-build');
|
|
14
15
|
const { scoreEdge, tierForResolution, TIER } = require('./confidence');
|
|
15
16
|
const { findGoModule, resolveRustImport } = require('./imports');
|
|
16
17
|
|
|
@@ -634,6 +635,17 @@ function findCallers(index, name, options = {}) {
|
|
|
634
635
|
langTraits(fileEntry.language)?.typeSystem === 'structural';
|
|
635
636
|
|
|
636
637
|
for (let call of calls) {
|
|
638
|
+
// fix #353: C# `Beta.Helper.Widget()` — the parser records a
|
|
639
|
+
// field hop rooted at `this` (Beta is no local). When the
|
|
640
|
+
// prefix names a project NAMESPACE that declares the last
|
|
641
|
+
// segment as a type, the receiver is that type, namespace-
|
|
642
|
+
// qualified; the hop shape would only ever fail the field
|
|
643
|
+
// walk and route method-ambiguous.
|
|
644
|
+
if (fileEntry.language === 'csharp') {
|
|
645
|
+
const rewritten = _csharpNamespaceQualifiedReceiver(index, call,
|
|
646
|
+
index.findEnclosingFunction(filePath, call.line, true));
|
|
647
|
+
if (rewritten) call = rewritten;
|
|
648
|
+
}
|
|
637
649
|
// Skip if not matching our target name (also check alias resolution)
|
|
638
650
|
let calledAs = null; // surface name when matched via an import/export rename
|
|
639
651
|
const typeQualifierReference = targetIsTypeQuery &&
|
|
@@ -2808,6 +2820,16 @@ function findCallers(index, name, options = {}) {
|
|
|
2808
2820
|
const paired = nameBindings.filter(b => b.alias === call.name);
|
|
2809
2821
|
if (paired.length > 0) nameBindings = paired;
|
|
2810
2822
|
}
|
|
2823
|
+
// Scope-granular import bindings (fix #352): a
|
|
2824
|
+
// function-local `from x import name` binds the name for
|
|
2825
|
+
// ITS function only. Three such imports in one file used
|
|
2826
|
+
// to make three file-level bindings, so a bare call in any
|
|
2827
|
+
// of the functions scope-matched every pin (investment
|
|
2828
|
+
// run_validation: 6 defs, each claiming the others'
|
|
2829
|
+
// sites). The nearest enclosing binder owns the name; a
|
|
2830
|
+
// binding inside a function that does not enclose the
|
|
2831
|
+
// call is out of scope (#215 discipline, line-granular).
|
|
2832
|
+
nameBindings = _scopeImportBindings(fileEntry, nameBindings, call.line);
|
|
2811
2833
|
const tFiles = new Set(targetDefs.map(d => d.file).filter(Boolean));
|
|
2812
2834
|
// fix #215 (rich-measured: 225 builtin `print(...)` calls
|
|
2813
2835
|
// confirmed against rich's def via file-level import edges):
|
|
@@ -4075,12 +4097,17 @@ function findCallers(index, name, options = {}) {
|
|
|
4075
4097
|
if (call.isPathCall && receiverName) {
|
|
4076
4098
|
receiverName = String(receiverName).split('::').pop();
|
|
4077
4099
|
}
|
|
4100
|
+
let aliasResolvedFile = null;
|
|
4078
4101
|
if (receiverName && !tTypes.has(receiverName)) {
|
|
4079
4102
|
for (const im of (fileEntry.importBindings || [])) {
|
|
4080
4103
|
if (im.name !== receiverName) continue;
|
|
4081
|
-
|
|
4104
|
+
// fix #353: C# `using BH = Beta.Helper` (and Java
|
|
4105
|
+
// dotted paths) split on `.`; Rust paths on `::`.
|
|
4106
|
+
const orig = String(im.module || '').split(/::|\./).pop();
|
|
4082
4107
|
if (orig && orig !== receiverName && tTypes.has(orig)) {
|
|
4083
4108
|
receiverName = orig;
|
|
4109
|
+
const rel = fileEntry.moduleResolved && fileEntry.moduleResolved[im.module];
|
|
4110
|
+
if (rel) aliasResolvedFile = path.join(index.root, rel);
|
|
4084
4111
|
break;
|
|
4085
4112
|
}
|
|
4086
4113
|
}
|
|
@@ -4108,7 +4135,14 @@ function findCallers(index, name, options = {}) {
|
|
|
4108
4135
|
// name fallback above handles multi-definition names; this
|
|
4109
4136
|
// covers single-definition targets that skip it.
|
|
4110
4137
|
if (typeQualifiedReceiver) {
|
|
4111
|
-
|
|
4138
|
+
// fix #353: an alias binding that RESOLVED to a file is
|
|
4139
|
+
// the type's identity (`using BH = Beta.Helper`); a
|
|
4140
|
+
// parser-recorded qualifier (`beta.Helper`, `Beta.Helper`)
|
|
4141
|
+
// is a namespace/package hint for the resolver.
|
|
4142
|
+
const identity = aliasResolvedFile
|
|
4143
|
+
? (targetDefs2.some(d => d.file === aliasResolvedFile) ? 'target' : 'other')
|
|
4144
|
+
: _resolveReceiverTypeIdentity(index, filePath, receiverName, targetDefs2, call.line,
|
|
4145
|
+
call.receiverIsTypeQualified ? call.receiverTypeQualifier : undefined);
|
|
4112
4146
|
if (identity === 'other') {
|
|
4113
4147
|
recordExcluded(filePath, call.line, 'path-type-mismatch');
|
|
4114
4148
|
continue;
|
|
@@ -5344,6 +5378,12 @@ function findCallees(index, definition, options = {}) {
|
|
|
5344
5378
|
for (let call of calls) {
|
|
5345
5379
|
siteOrdinal++;
|
|
5346
5380
|
const siteId = siteOrdinal;
|
|
5381
|
+
if (language === 'csharp') {
|
|
5382
|
+
// fix #353: `Beta.Helper.Widget()` — namespace-qualified type
|
|
5383
|
+
// receiver (see the findCallers twin).
|
|
5384
|
+
const rewritten = _csharpNamespaceQualifiedReceiver(index, call, def);
|
|
5385
|
+
if (rewritten) call = rewritten;
|
|
5386
|
+
}
|
|
5347
5387
|
if (language === 'go' && call.isMethod &&
|
|
5348
5388
|
!call.receiverType && call.receiverIndexField) {
|
|
5349
5389
|
const indexedType = _goIndexedReceiverType(index, def.file, call);
|
|
@@ -9853,6 +9893,42 @@ function _projectTopLevelNames(index) {
|
|
|
9853
9893
|
return names;
|
|
9854
9894
|
}
|
|
9855
9895
|
|
|
9896
|
+
/**
|
|
9897
|
+
* Fix #352: restrict import bindings of a name to those in scope at a call
|
|
9898
|
+
* line. A binding whose import line sits inside a function body is local to
|
|
9899
|
+
* that function (Python function-body imports, JS function-scoped require);
|
|
9900
|
+
* the innermost enclosing binder wins, bindings in non-enclosing functions
|
|
9901
|
+
* are dropped, module-level bindings survive only when no enclosing function
|
|
9902
|
+
* binds the name. Bindings without a line (older records) are kept as-is.
|
|
9903
|
+
*/
|
|
9904
|
+
function _scopeImportBindings(fileEntry, bindings, callLine) {
|
|
9905
|
+
if (!bindings || bindings.length < 2 || callLine == null) return bindings;
|
|
9906
|
+
if (!bindings.some(b => b.line != null && b.deferred)) return bindings;
|
|
9907
|
+
const scopes = (fileEntry.symbols || []).filter(s =>
|
|
9908
|
+
s.startLine != null && s.endLine != null && s.endLine > s.startLine &&
|
|
9909
|
+
CALLABLE_SYMBOL_KINDS.has(s.type));
|
|
9910
|
+
const innermost = line => {
|
|
9911
|
+
let best = null;
|
|
9912
|
+
for (const s of scopes) {
|
|
9913
|
+
if (line < s.startLine || line > s.endLine) continue;
|
|
9914
|
+
if (!best || (s.endLine - s.startLine) < (best.endLine - best.startLine)) best = s;
|
|
9915
|
+
}
|
|
9916
|
+
return best;
|
|
9917
|
+
};
|
|
9918
|
+
const local = [];
|
|
9919
|
+
const moduleLevel = [];
|
|
9920
|
+
for (const b of bindings) {
|
|
9921
|
+
if (b.line == null) { moduleLevel.push(b); continue; }
|
|
9922
|
+
const scope = innermost(b.line);
|
|
9923
|
+
if (!scope) { moduleLevel.push(b); continue; }
|
|
9924
|
+
if (callLine < scope.startLine || callLine > scope.endLine) continue;
|
|
9925
|
+
local.push({ b, size: scope.endLine - scope.startLine });
|
|
9926
|
+
}
|
|
9927
|
+
if (local.length === 0) return moduleLevel;
|
|
9928
|
+
const nearest = Math.min(...local.map(l => l.size));
|
|
9929
|
+
return local.filter(l => l.size === nearest).map(l => l.b);
|
|
9930
|
+
}
|
|
9931
|
+
|
|
9856
9932
|
/**
|
|
9857
9933
|
* Is an UNRESOLVED module specifier a resolver gap rather than externality
|
|
9858
9934
|
* evidence? (fix #337b) Relative specifiers and first segments naming a
|
|
@@ -9980,6 +10056,32 @@ function _isGenericParamReceiverType(index, filePath, line, typeName) {
|
|
|
9980
10056
|
return _isEnclosingGenericParam(index, filePath, line, typeName);
|
|
9981
10057
|
}
|
|
9982
10058
|
|
|
10059
|
+
/**
|
|
10060
|
+
* fix #353: C# namespace-qualified type receivers. `Beta.Helper.Widget()` is
|
|
10061
|
+
* recorded by the parser as a this-rooted field hop (Beta is not a local);
|
|
10062
|
+
* when the dotted prefix names a project namespace (exactly, or relative to
|
|
10063
|
+
* the call's own namespace) that declares the last segment as a type, the
|
|
10064
|
+
* call is a type-qualified static call on that type. Returns a rewritten
|
|
10065
|
+
* record or null (unknown prefixes keep the parser's shape).
|
|
10066
|
+
*/
|
|
10067
|
+
function _csharpNamespaceQualifiedReceiver(index, call, enclosing) {
|
|
10068
|
+
if (!call.isMethod || call.receiverType || !Array.isArray(call.receiverFields) ||
|
|
10069
|
+
call.receiverFields.length < 2 || call.receiverRoot !== 'this') return null;
|
|
10070
|
+
const typeName = call.receiverFields[call.receiverFields.length - 1];
|
|
10071
|
+
if (!/^[A-Z]/.test(typeName)) return null;
|
|
10072
|
+
const prefix = call.receiverFields.slice(0, -1).join('.');
|
|
10073
|
+
const typeDefs = (index.symbols.get(typeName) || []).filter(d =>
|
|
10074
|
+
IDENTITY_TYPE_KINDS.has(d.type) && d.namespace);
|
|
10075
|
+
if (typeDefs.length === 0) return null;
|
|
10076
|
+
const enclosingNs = enclosing?.namespace || null;
|
|
10077
|
+
const candidates = enclosingNs ? [prefix, `${enclosingNs}.${prefix}`] : [prefix];
|
|
10078
|
+
const ns = candidates.find(c => typeDefs.some(d => d.namespace === c));
|
|
10079
|
+
if (!ns) return null;
|
|
10080
|
+
const { receiverRoot, receiverField, receiverFields, receiverRootType, receiverRootNamespace, ...rest } = call;
|
|
10081
|
+
void receiverRoot; void receiverField; void receiverFields; void receiverRootType; void receiverRootNamespace;
|
|
10082
|
+
return { ...rest, receiver: typeName, receiverIsTypeQualified: true, receiverTypeQualifier: ns };
|
|
10083
|
+
}
|
|
10084
|
+
|
|
9983
10085
|
/**
|
|
9984
10086
|
* Java same-package check across Maven/Gradle source roots (fix #246):
|
|
9985
10087
|
* src/main/java/<pkg> and src/test/java/<pkg> hold the SAME package —
|
|
@@ -10019,6 +10121,16 @@ function _resolveReceiverTypeIdentity(index, filePath, knownType, targetDefs, li
|
|
|
10019
10121
|
// cannot resolve it. That is not exclusion evidence.
|
|
10020
10122
|
return 'unknown';
|
|
10021
10123
|
}
|
|
10124
|
+
if (language === 'java' && namespaceHint && /^[a-z_]/.test(namespaceHint)) {
|
|
10125
|
+
// fix #353: a lowercase dotted qualifier is a PACKAGE
|
|
10126
|
+
// (`beta.Helper.widget()`); the package + type name resolve to one
|
|
10127
|
+
// file exactly like an import of `beta.Helper` would. Unresolvable
|
|
10128
|
+
// packages (external, resolver gap) are never exclusion evidence.
|
|
10129
|
+
const resolved = _resolveJavaPackageImport(index, `${namespaceHint}.${knownType}`, null);
|
|
10130
|
+
if (!resolved) return 'unknown';
|
|
10131
|
+
return targetDefs.some(d => d.className === knownType && d.file === resolved)
|
|
10132
|
+
? 'target' : 'other';
|
|
10133
|
+
}
|
|
10022
10134
|
if (language === 'java' && line == null) {
|
|
10023
10135
|
// Return-flow annotations are interpreted in the PRODUCER definition's
|
|
10024
10136
|
// file. That origin is stronger than same-package lookup and is
|
|
@@ -10965,6 +11077,23 @@ function _calleeStructuralBindingRoute(index, fileEntry, call, language, binding
|
|
|
10965
11077
|
let sawProjectish = false;
|
|
10966
11078
|
let sawUnknown = false;
|
|
10967
11079
|
for (const binding of bindings) {
|
|
11080
|
+
// fix #353 (Rust): `use alpha::widget as renamed; renamed()` — the
|
|
11081
|
+
// binding's module is the ITEM path; the item is its last segment
|
|
11082
|
+
// and the owning module file resolves through the Rust resolver.
|
|
11083
|
+
if (language === 'rust') {
|
|
11084
|
+
const segs = String(binding.module || '').split('::').filter(Boolean);
|
|
11085
|
+
const item = segs[segs.length - 1];
|
|
11086
|
+
const owners = _rustBindingResolvedFiles(index, fileEntry, fileEntry.path, binding);
|
|
11087
|
+
if (item && owners.size > 0) {
|
|
11088
|
+
sawProjectish = true;
|
|
11089
|
+
for (const moduleFile of owners) {
|
|
11090
|
+
const routed = _calleeExportDefinitions(index, moduleFile, item, language, call, {});
|
|
11091
|
+
for (const d of routed.matches) matches.set(`${d.file}:${d.startLine}`, d);
|
|
11092
|
+
if (routed.unknown) sawUnknown = true;
|
|
11093
|
+
}
|
|
11094
|
+
continue;
|
|
11095
|
+
}
|
|
11096
|
+
}
|
|
10968
11097
|
const rel = fileEntry.moduleResolved && fileEntry.moduleResolved[binding.module];
|
|
10969
11098
|
if (!rel) {
|
|
10970
11099
|
if (_unresolvedModuleIsGap(index, binding.module, binding)) {
|
|
@@ -11722,6 +11851,42 @@ function _nonCallableFieldMember(index, typeName, name, language) {
|
|
|
11722
11851
|
* (#215): the class defined in this file or a file binding of the name —
|
|
11723
11852
|
* an unbound capitalized receiver may be a parameter or local.
|
|
11724
11853
|
*/
|
|
11854
|
+
/**
|
|
11855
|
+
* fix #353: resolve the files that OWN a type-qualified static receiver from
|
|
11856
|
+
* the qualifier in the call (Java package / C# namespace), the file's alias
|
|
11857
|
+
* or name import binding of the receiver, or the caller's own namespace.
|
|
11858
|
+
* Returns a Set of absolute files, or null when nothing pins the owner.
|
|
11859
|
+
*/
|
|
11860
|
+
function _qualifiedStaticOwnerFiles(index, fileEntry, call, typeName, language, def) {
|
|
11861
|
+
const files = new Set();
|
|
11862
|
+
const typeDefs = (index.symbols.get(typeName) || []).filter(d =>
|
|
11863
|
+
IDENTITY_TYPE_KINDS.has(d.type) && d.file);
|
|
11864
|
+
const qual = call.receiverIsTypeQualified ? call.receiverTypeQualifier : null;
|
|
11865
|
+
if (qual) {
|
|
11866
|
+
if (language === 'java' && /^[a-z_]/.test(qual)) {
|
|
11867
|
+
const resolved = _resolveJavaPackageImport(index, `${qual}.${typeName}`, null);
|
|
11868
|
+
if (resolved) files.add(resolved);
|
|
11869
|
+
return files.size > 0 ? files : null;
|
|
11870
|
+
}
|
|
11871
|
+
if (language === 'csharp') {
|
|
11872
|
+
const enclosingNs = def?.namespace || null;
|
|
11873
|
+
const candidates = enclosingNs ? [qual, `${enclosingNs}.${qual}`] : [qual];
|
|
11874
|
+
for (const d of typeDefs) {
|
|
11875
|
+
if (candidates.includes(d.namespace)) files.add(d.file);
|
|
11876
|
+
}
|
|
11877
|
+
return files.size > 0 ? files : null;
|
|
11878
|
+
}
|
|
11879
|
+
}
|
|
11880
|
+
for (const b of (fileEntry?.importBindings || [])) {
|
|
11881
|
+
const bindsReceiver = b.name === call.receiver || b.alias === call.receiver ||
|
|
11882
|
+
(b.name === typeName && !b.alias);
|
|
11883
|
+
if (!bindsReceiver) continue;
|
|
11884
|
+
const rel = fileEntry.moduleResolved && fileEntry.moduleResolved[b.module];
|
|
11885
|
+
if (rel) files.add(path.join(index.root, rel));
|
|
11886
|
+
}
|
|
11887
|
+
return files.size > 0 ? files : null;
|
|
11888
|
+
}
|
|
11889
|
+
|
|
11725
11890
|
/**
|
|
11726
11891
|
* Namespace/module-container resolution (fix #254, W8 BUG-4 — verify's
|
|
11727
11892
|
* BUG-BX rule brought into the engine, range-based): `Utils.slug()` where a
|
|
@@ -11835,7 +12000,8 @@ function _calleeTypeQualifiedReceiver(index, def, fileEntry, call, language) {
|
|
|
11835
12000
|
if (typeDefs.length === 0) {
|
|
11836
12001
|
for (const im of (fileEntry?.importBindings || [])) {
|
|
11837
12002
|
if (im.name !== receiver) continue;
|
|
11838
|
-
|
|
12003
|
+
// fix #353: C# `using BH = Beta.Helper` splits on `.`
|
|
12004
|
+
const orig = String(im.module || '').split(/::|\./).pop();
|
|
11839
12005
|
if (orig && orig !== receiver && typeKindsOf(orig).length > 0) {
|
|
11840
12006
|
receiver = orig;
|
|
11841
12007
|
typeDefs = typeKindsOf(orig);
|
|
@@ -11877,9 +12043,30 @@ function _calleeTypeQualifiedReceiver(index, def, fileEntry, call, language) {
|
|
|
11877
12043
|
_normalizedAliasBase(index, d)));
|
|
11878
12044
|
if (bases.size === 1) candidateTypes.push(bases.values().next().value);
|
|
11879
12045
|
}
|
|
11880
|
-
const
|
|
12046
|
+
const allSymbols = index.symbols.get(call.name) || [];
|
|
11881
12047
|
const isCallable = (s) => !NON_CALLABLE_TYPES.has(s.type) ||
|
|
11882
12048
|
(s.type === 'field' && s.fieldType && /^func\b/.test(s.fieldType));
|
|
12049
|
+
// fix #353: the qualifier that is right there in the call owns the type —
|
|
12050
|
+
// `beta.Helper.widget()` (package), `Beta.Helper.Widget()` (namespace),
|
|
12051
|
+
// `using BH = Beta.Helper; BH.Widget()` (alias binding), `import
|
|
12052
|
+
// beta.Helper;` (name binding). Same-name types in other packages/
|
|
12053
|
+
// namespaces leave the candidate set BEFORE member-group construction
|
|
12054
|
+
// (the group dedupes identical signatures, so the first same-name type
|
|
12055
|
+
// used to swallow the second). An unresolvable qualifier keeps them all.
|
|
12056
|
+
const ownerFiles = (language === 'java' || language === 'csharp')
|
|
12057
|
+
? _qualifiedStaticOwnerFiles(index, fileEntry, call, receiver, language, def)
|
|
12058
|
+
: null;
|
|
12059
|
+
// A package qualifier the resolver cannot place (`org.external.Helper`)
|
|
12060
|
+
// over a project type of the same name: unpinnable — visible, never
|
|
12061
|
+
// confirmed by first-definition selection (#206 discipline).
|
|
12062
|
+
if (language === 'java' && call.receiverIsTypeQualified &&
|
|
12063
|
+
call.receiverTypeQualifier && /^[a-z_]/.test(call.receiverTypeQualifier) &&
|
|
12064
|
+
!ownerFiles) {
|
|
12065
|
+
return { unverified: 'method-ambiguous' };
|
|
12066
|
+
}
|
|
12067
|
+
const symbols = ownerFiles
|
|
12068
|
+
? allSymbols.filter(s => !candidateTypes.includes(s.className) || ownerFiles.has(s.file))
|
|
12069
|
+
: allSymbols;
|
|
11883
12070
|
if (language === 'java' || language === 'csharp') {
|
|
11884
12071
|
// Class-qualified Java/C# calls see the compiler member group on the
|
|
11885
12072
|
// qualifier. The helper handles inherited slots and C# name hiding.
|
package/core/project.js
CHANGED
|
@@ -1946,7 +1946,7 @@ class ProjectIndex {
|
|
|
1946
1946
|
* the usage scan saw (and may have deliberately dropped), as opposed to
|
|
1947
1947
|
* JSX children, HTML markup, or other non-code text (fix #350).
|
|
1948
1948
|
*/
|
|
1949
|
-
isIdentifierAtPosition(content, lineNum, column, filePath) {
|
|
1949
|
+
isIdentifierAtPosition(content, lineNum, column, filePath, name) {
|
|
1950
1950
|
const language = detectLanguage(filePath, this.root);
|
|
1951
1951
|
if (!language) return false;
|
|
1952
1952
|
try {
|
|
@@ -1954,7 +1954,12 @@ class ProjectIndex {
|
|
|
1954
1954
|
safeParse(getParser(language), content);
|
|
1955
1955
|
if (!tree) return false;
|
|
1956
1956
|
const node = tree.rootNode.descendantForPosition({ row: lineNum - 1, column });
|
|
1957
|
-
|
|
1957
|
+
if (!node || !/identifier|^name$|^word$/.test(node.type)) return false;
|
|
1958
|
+
// fix #352: the identifier node must BE the name — a hyphenated JSX
|
|
1959
|
+
// attribute (`data-cell-state`) is one property_identifier whose
|
|
1960
|
+
// text merely contains `cell`; the ACCOUNT counts that line as
|
|
1961
|
+
// other-text, so usages must list it (the #350 equality).
|
|
1962
|
+
return node.text === name;
|
|
1958
1963
|
} catch (e) {
|
|
1959
1964
|
return false;
|
|
1960
1965
|
}
|
|
@@ -2524,6 +2529,15 @@ class ProjectIndex {
|
|
|
2524
2529
|
saveCache(cachePath) { return indexCache.saveCache(this, cachePath); }
|
|
2525
2530
|
|
|
2526
2531
|
/** Load index from cache file */
|
|
2532
|
+
/**
|
|
2533
|
+
* Build under the cross-process build lock (fix #354) and save the cache.
|
|
2534
|
+
* Returns { built, waited }; when another process built meanwhile and its
|
|
2535
|
+
* cache is fresh, that cache is loaded instead of rebuilding.
|
|
2536
|
+
*/
|
|
2537
|
+
buildCached(buildOpts = {}, options = {}) {
|
|
2538
|
+
return indexCache.buildWithLock(this, buildOpts, options);
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2527
2541
|
loadCache(cachePath) { return indexCache.loadCache(this, cachePath); }
|
|
2528
2542
|
|
|
2529
2543
|
/** Persist the bounded, content-hash-keyed usage-query cache. */
|
package/core/search.js
CHANGED
|
@@ -84,7 +84,7 @@ function appendTextComplements(index, {
|
|
|
84
84
|
// the AST scan classified and then deliberately dropped (Rust enum
|
|
85
85
|
// variants against a struct pin, #234) is code, not text: skip it.
|
|
86
86
|
if (!commentOrString &&
|
|
87
|
-
index.isIdentifierAtPosition(content, lineNum, match.index, filePath)) continue;
|
|
87
|
+
index.isIdentifierAtPosition(content, lineNum, match.index, filePath, name)) continue;
|
|
88
88
|
const usage = {
|
|
89
89
|
file: filePath,
|
|
90
90
|
relativePath: fileEntry.relativePath,
|
package/core/shared.js
CHANGED
|
@@ -126,7 +126,7 @@ function addTestExclusions(exclude) {
|
|
|
126
126
|
*/
|
|
127
127
|
function literalNameRegex(name, flags = '') {
|
|
128
128
|
return new RegExp(
|
|
129
|
-
`(?<![\\p{L}\\p{N}_
|
|
129
|
+
`(?<![\\p{L}\\p{N}_])${escapeRegExp(name)}(?![\\p{L}\\p{N}_])`,
|
|
130
130
|
flags.includes('u') ? flags : flags + 'u');
|
|
131
131
|
}
|
|
132
132
|
|
package/languages/java.js
CHANGED
|
@@ -1502,14 +1502,34 @@ function findCallsInCode(code, parser) {
|
|
|
1502
1502
|
const valueNode = receiverNode.childForFieldName('value');
|
|
1503
1503
|
if (valueNode?.type === 'identifier') castReceiverName = valueNode.text;
|
|
1504
1504
|
}
|
|
1505
|
-
|
|
1505
|
+
let receiver = castReceiverName ||
|
|
1506
1506
|
((receiverNode?.type === 'identifier' || receiverNode?.type === 'this')
|
|
1507
1507
|
? receiverNode.text : undefined);
|
|
1508
|
+
// fix #353: `beta.Helper.widget()` — a lowercase dotted root
|
|
1509
|
+
// under a capitalized member is a PACKAGE-qualified type
|
|
1510
|
+
// (Java packages are lowercase by convention, types
|
|
1511
|
+
// capitalized); the receiver is the type and the package is
|
|
1512
|
+
// its qualifier, so same-name static methods in two packages
|
|
1513
|
+
// resolve by the qualifier that is right there in the call.
|
|
1514
|
+
let packageQualifier;
|
|
1515
|
+
if (!receiver && receiverNode?.type === 'field_access') {
|
|
1516
|
+
const rootNode = receiverNode.childForFieldName('object');
|
|
1517
|
+
const fldNode = receiverNode.childForFieldName('field');
|
|
1518
|
+
const rootText = rootNode?.text || '';
|
|
1519
|
+
const rootHead = rootText.split('.')[0];
|
|
1520
|
+
if (fldNode?.type === 'identifier' && /^[A-Z]/.test(fldNode.text) &&
|
|
1521
|
+
/^[a-z_][\w]*(\.[a-z_][\w]*)*$/.test(rootText) &&
|
|
1522
|
+
!getReceiverType(rootHead) && !isDeclaredLocal(rootHead) &&
|
|
1523
|
+
!hasEnclosingField(node, rootHead)) {
|
|
1524
|
+
receiver = fldNode.text;
|
|
1525
|
+
packageQualifier = rootText;
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1508
1528
|
const receiverType = castReceiverType ||
|
|
1509
1529
|
((receiver && receiver !== 'this') ? getReceiverType(receiver) : undefined);
|
|
1510
|
-
const receiverTypeQualifier =
|
|
1511
|
-
? getReceiverTypeQualifier(receiver) : undefined;
|
|
1512
|
-
const receiverIsTypeQualified = !!(receiverNode?.type === 'identifier' &&
|
|
1530
|
+
const receiverTypeQualifier = packageQualifier ||
|
|
1531
|
+
(!castReceiverType && receiver ? getReceiverTypeQualifier(receiver) : undefined);
|
|
1532
|
+
const receiverIsTypeQualified = !!((receiverNode?.type === 'identifier' || packageQualifier) &&
|
|
1513
1533
|
receiver && /^[A-Z]/.test(receiver) && !receiverType &&
|
|
1514
1534
|
!isDeclaredLocal(receiver) && !hasEnclosingField(node, receiver));
|
|
1515
1535
|
// fix #202: one-hop declared-field receivers —
|
package/languages/rust.js
CHANGED
|
@@ -3152,6 +3152,14 @@ function findImportsInCode(code, parser) {
|
|
|
3152
3152
|
if (pathNode && aliasNode) {
|
|
3153
3153
|
addLeaf(joinUsePath(prefix, pathNode.text), aliasNode.text,
|
|
3154
3154
|
'use', false, line);
|
|
3155
|
+
// fix #353: `use alpha::widget as renamed; renamed()` — the
|
|
3156
|
+
// alias pairing feeds findCallers' import-rename surface
|
|
3157
|
+
// (calledAs), exactly like Python/JS `import x as y`.
|
|
3158
|
+
const original = String(pathNode.text).split('::').pop();
|
|
3159
|
+
if (original && original !== aliasNode.text && original !== 'self') {
|
|
3160
|
+
if (!imports.aliases) imports.aliases = [];
|
|
3161
|
+
imports.aliases.push({ original, local: aliasNode.text });
|
|
3162
|
+
}
|
|
3155
3163
|
}
|
|
3156
3164
|
return;
|
|
3157
3165
|
}
|
package/mcp/server.js
CHANGED
|
@@ -71,8 +71,13 @@ function getIndex(projectDir, options) {
|
|
|
71
71
|
// Disk cache is fresh (skip when maxFiles is set — cached index may have different file count)
|
|
72
72
|
} else {
|
|
73
73
|
buildOpts.forceRebuild = !!loaded;
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
if (maxFiles) {
|
|
75
|
+
index.build(null, buildOpts); // Don't pollute disk cache with partial indexes
|
|
76
|
+
} else {
|
|
77
|
+
// Cross-process build lock (fix #354): a CLI or another MCP
|
|
78
|
+
// server building the same repo at the same moment shares one build
|
|
79
|
+
index.buildCached(buildOpts);
|
|
80
|
+
}
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
// LRU eviction
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ucn",
|
|
3
|
-
"version": "5.3.
|
|
3
|
+
"version": "5.3.4",
|
|
4
4
|
"mcpName": "io.github.mleoca/ucn",
|
|
5
5
|
"description": "Auditable AST code intelligence for AI agents: 18 task-oriented commands through one MCP tool, CLI, or agent skill. Supports JS/TS, Python, Go, Rust, Java, C, C++, C#, and HTML.",
|
|
6
6
|
"main": "index.js",
|