ucn 5.2.1 → 5.3.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/core/registry.js CHANGED
@@ -133,13 +133,13 @@ const FLAG_APPLICABILITY = {
133
133
  // Understand one symbol. `sections` is a comma-separated projection:
134
134
  // summary, callers, callees, source, dependencies, tests, types, example,
135
135
  // related. Caller-bearing projections always preserve ACCOUNT/CONTRACT.
136
- show: ['name', 'file', 'exclude', 'className', 'line', 'sections', 'includeMethods', 'includeTests', 'top', 'all', 'withTypes', 'minConfidence', 'showConfidence', 'unreachableOnly', 'compact', 'git', 'diverse'],
137
- find: ['name', 'file', 'exclude', 'className', 'includeTests', 'limit', 'exact', 'in', 'compact', 'type', 'withSource'],
138
- usages: ['name', 'file', 'exclude', 'className', 'includeTests', 'limit', 'codeOnly', 'context', 'in', 'compact', 'all'],
139
- search: ['term', 'file', 'exclude', 'includeTests', 'top', 'limit', 'codeOnly', 'caseSensitive', 'context', 'regex', 'in', 'type', 'param', 'receiver', 'returns', 'decorator', 'exported', 'unused'],
140
- source: ['name', 'file', 'className', 'line', 'range', 'all', 'maxLines'],
136
+ show: ['name', 'file', 'exclude', 'className', 'line', 'sections', 'includeMethods', 'includeTests', 'top', 'all', 'withTypes', 'minConfidence', 'showConfidence', 'unreachableOnly', 'compact', 'git', 'diverse', 'lines'],
137
+ find: ['name', 'file', 'exclude', 'className', 'includeTests', 'limit', 'exact', 'in', 'compact', 'type', 'withSource', 'lines'],
138
+ usages: ['name', 'file', 'exclude', 'className', 'includeTests', 'limit', 'codeOnly', 'context', 'in', 'compact', 'all', 'lines'],
139
+ search: ['term', 'file', 'exclude', 'includeTests', 'top', 'limit', 'codeOnly', 'caseSensitive', 'context', 'regex', 'in', 'type', 'param', 'receiver', 'returns', 'decorator', 'exported', 'unused', 'lines'],
140
+ source: ['name', 'file', 'className', 'line', 'range', 'all', 'maxLines', 'raw'],
141
141
  trace: ['name', 'file', 'exclude', 'className', 'line', 'direction', 'to', 'includeMethods', 'depth', 'all', 'expandUnverified'],
142
- impact: ['name', 'file', 'exclude', 'className', 'line', 'includeMethods', 'top', 'unreachableOnly', 'compact', 'base', 'staged', 'limit', 'all'],
142
+ impact: ['name', 'file', 'exclude', 'className', 'line', 'includeMethods', 'top', 'unreachableOnly', 'compact', 'base', 'staged', 'limit', 'all', 'lines'],
143
143
  tests: ['name', 'file', 'exclude', 'className', 'line', 'callsOnly', 'depth', 'includeMethods', 'all'],
144
144
  deps: ['file', 'exclude', 'depth', 'direction', 'all', 'detailed', 'cycles'],
145
145
  api: ['file', 'in', 'limit'],
@@ -308,6 +308,7 @@ function formatSurfaceMessage(message, surface = 'cli') {
308
308
  'expandUnverified', 'withSource', 'all', 'compact', 'exact',
309
309
  'regex', 'exported', 'unused', 'staged', 'detailed', 'functions',
310
310
  'hot', 'deep', 'cycles', 'bridge', 'unmatched', 'diverse', 'git',
311
+ 'raw', 'lines',
311
312
  ]);
312
313
  if (surface === 'mcp') {
313
314
  rendered = rendered.replace(/--([a-z][a-z0-9-]*)(?:=([^\s,.)]+))?/g,
package/core/reporting.js CHANGED
@@ -133,41 +133,155 @@ function getStats(index, options = {}) {
133
133
  // decide which definitions are capable of entering the requested top
134
134
  // N. Exact pinned caller resolution is then run only until no unseen
135
135
  // candidate can beat the current Nth result.
136
- const rawUpperByName = new Map();
136
+ // A record whose receiver is provably EXTERNAL (fix #340) cannot
137
+ // confirm a project definition — the engine routes such calls
138
+ // external-package / possible-dispatch, never confirmed — so it leaves
139
+ // the bound. `t.Fatalf(...)` (receiverType T, qualifier `testing`)
140
+ // used to hand every project `Fatalf`/`Fatal`/`Errorf`/`String` a
141
+ // four-digit upper bound, so the early stop never fired and grpc-go's
142
+ // `repo` refined 1375 candidates. Resolution is the engine's own
143
+ // module physics: an import naming the qualifier that resolves to a
144
+ // project file keeps the record; a resolver gap keeps it; only an
145
+ // import that is non-relative, non-project, and unresolved drops it.
146
+ const { _unresolvedModuleIsGap } = require('./callers');
147
+ const externalQualifierMemo = new Map(); // filePath + qualifier -> boolean
148
+ const receiverProvablyExternal = (filePath, fileEntry, c) => {
149
+ if (!c.isMethod || !fileEntry) return false;
150
+ const qualifier = c.receiverTypeQualifier ||
151
+ (c.receiverIsModule && c.receiver) || null;
152
+ if (!qualifier || typeof qualifier !== 'string') return false;
153
+ const key = filePath + '\u0000' + qualifier;
154
+ const memo = externalQualifierMemo.get(key);
155
+ if (memo !== undefined) return memo;
156
+ const head = qualifier.split('.')[0];
157
+ const modules = new Set();
158
+ for (const binding of fileEntry.importBindings || []) {
159
+ if (binding.name === head || binding.alias === head) modules.add(binding.module);
160
+ }
161
+ for (const mod of fileEntry.imports || []) {
162
+ const text = String(mod || '');
163
+ if (text === head || text.split('/').pop() === head ||
164
+ text.split('.').pop() === head) modules.add(text);
165
+ }
166
+ let verdict = false;
167
+ if (modules.size > 0) {
168
+ verdict = true;
169
+ for (const mod of modules) {
170
+ if (fileEntry.moduleResolved?.[mod] || _unresolvedModuleIsGap(index, mod)) {
171
+ verdict = false;
172
+ break;
173
+ }
174
+ }
175
+ }
176
+ externalQualifierMemo.set(key, verdict);
177
+ return verdict;
178
+ };
179
+ // Per-DEFINITION bound (fix #340). A record with a trusted parser
180
+ // receiver type can confirm only that type's own same-name method,
181
+ // or an inherited/promoted one when that type defines none itself —
182
+ // so it is charged to that class alone (or to every definition when
183
+ // the class is not a definer). Untyped and convention-guessed
184
+ // receivers (#266: never exclusion evidence) stay charged to every
185
+ // definition. grpc-go: `String` has 278 same-name methods and `Close`
186
+ // 146; under the per-NAME bound every one of them inherited the whole
187
+ // name's ceiling and had to be refined.
188
+ const normalizeTypeName = (text) => String(text || '')
189
+ .replace(/^[*&\s]+/, '').replace(/[<[(].*$/, '').split('.').pop() || null;
190
+ const untypedByName = new Map(); // name -> records with no trusted receiver type
191
+ const typedByName = new Map(); // name -> Map<className, records typed to it>
192
+ const chargeRecord = (name, c) => {
193
+ const typed = c.isMethod && c.receiverType && !c.receiverTypeGuessed
194
+ ? normalizeTypeName(c.receiverType) : null;
195
+ if (typed) {
196
+ let byClass = typedByName.get(name);
197
+ if (!byClass) { byClass = new Map(); typedByName.set(name, byClass); }
198
+ byClass.set(typed, (byClass.get(typed) || 0) + 1);
199
+ } else {
200
+ untypedByName.set(name, (untypedByName.get(name) || 0) + 1);
201
+ }
202
+ };
203
+ // Fix #343: symbols and call sites inside inline test modules
204
+ // (`#[cfg(test)] mod tests` / `#[test]` fns, fix #244's ranges) are
205
+ // test code even though their FILE is production — ripgrep's
206
+ // `TempDir.path` (197 calls, all from tests) ranked second among
207
+ // "production functions". Under productionCallsOnly they leave the
208
+ // candidate set, the exact count, AND the upper bound (a bound that
209
+ // still counted them stopped the early exit from firing, so the
210
+ // orientation fell back to its approximate refinement budget).
211
+ const { inlineTestRanges, lineInRanges } = require('./shared');
212
+ const inlineTestRangesByFile = new Map();
213
+ const inInlineTest = (file, line) => {
214
+ if (!file || !line) return false;
215
+ let ranges = inlineTestRangesByFile.get(file);
216
+ if (!ranges) {
217
+ ranges = inlineTestRanges(index.files.get(file) || {});
218
+ inlineTestRangesByFile.set(file, ranges);
219
+ }
220
+ return ranges.length > 0 && lineInRanges(line, ranges);
221
+ };
137
222
  for (const [filePath, entry] of index.callsCache) {
138
223
  if (!scopedPaths.has(filePath)) continue;
139
- if (index.files.get(filePath)?.isBundled) continue;
224
+ const fileEntry = index.files.get(filePath);
225
+ if (fileEntry?.isBundled) continue;
140
226
  if (!entry || !Array.isArray(entry.calls)) continue;
141
227
  const seenInFile = new Set();
142
228
  for (const c of entry.calls) {
143
229
  if (!c || !c.name) continue;
144
- const key = `${c.name}::${c.line || 0}`;
230
+ if (receiverProvablyExternal(filePath, fileEntry, c)) continue;
231
+ if (options.productionCallsOnly && inInlineTest(filePath, c.line)) continue;
232
+ // Distinct receiver types on one line can confirm DIFFERENT
233
+ // definitions. Dedup within a type bucket, never across them.
234
+ // Mixed typed/untyped buckets may overcount, which is safe for
235
+ // an upper bound; dropping a bucket can erase a true HOT item.
236
+ const bucket = c.isMethod && c.receiverType && !c.receiverTypeGuessed
237
+ ? normalizeTypeName(c.receiverType) : '';
238
+ const key = `${c.name}::${c.line || 0}::${bucket}`;
145
239
  if (!seenInFile.has(key)) {
146
240
  seenInFile.add(key);
147
- rawUpperByName.set(c.name, (rawUpperByName.get(c.name) || 0) + 1);
241
+ chargeRecord(c.name, c);
148
242
  }
149
243
  if (c.resolvedName && c.resolvedName !== c.name) {
150
- const rkey = `${c.resolvedName}::${c.line || 0}`;
244
+ const rkey = `${c.resolvedName}::${c.line || 0}::${bucket}`;
151
245
  if (!seenInFile.has(rkey)) {
152
246
  seenInFile.add(rkey);
153
- rawUpperByName.set(c.resolvedName,
154
- (rawUpperByName.get(c.resolvedName) || 0) + 1);
247
+ chargeRecord(c.resolvedName, c);
155
248
  }
156
249
  }
157
250
  }
158
251
  }
252
+ const ownerOf = (symbol) => normalizeTypeName(symbol.className || symbol.receiver || '');
159
253
 
160
254
  const candidates = [];
161
255
  const seenDefinitions = new Set();
162
256
  for (const [name, symbols] of index.symbols) {
163
- const upper = rawUpperByName.get(name) || 0;
164
- if (upper === 0) continue;
257
+ const untyped = untypedByName.get(name) || 0;
258
+ const typedByClass = typedByName.get(name);
259
+ if (untyped === 0 && !typedByClass) continue;
165
260
  const callable = symbols.filter(symbol =>
166
261
  FUNCTION_TYPES.has(symbol.type) &&
167
262
  matchesReportingScope(index, symbol.relativePath, options));
263
+ const definers = new Set(callable.map(ownerOf).filter(Boolean));
168
264
  for (const symbol of callable) {
265
+ let upper = untyped;
266
+ if (typedByClass) {
267
+ const owner = ownerOf(symbol);
268
+ for (const [cls, count] of typedByClass) {
269
+ if (cls === owner || !definers.has(cls)) upper += count;
270
+ }
271
+ }
272
+ if (upper === 0) continue;
273
+ // Fair share of the name's ceiling: a name shared by 278
274
+ // methods cannot make all 278 hot, so a definition's likely
275
+ // count is nearer upper/definers than upper. Ordering by the
276
+ // share puts the genuinely hot definitions first; the early
277
+ // stop below still uses the exact remaining ceiling, so the
278
+ // exact answer is unchanged and only a bounded refinement
279
+ // (`maxRefine`) benefits from the order.
280
+ const share = upper / Math.max(1, callable.length);
169
281
  if (index.files.get(symbol.file)?.isBundled) continue;
170
- if (options.productionCallsOnly && require('./shared').isTestPath(symbol.relativePath)) continue;
282
+ if (options.productionCallsOnly &&
283
+ (require('./shared').isTestPath(symbol.relativePath) ||
284
+ inInlineTest(symbol.file, symbol.startLine))) continue;
171
285
  const identity = `${symbol.file}:${symbol.startLine}:${name}:` +
172
286
  `${symbol.className || symbol.receiver || ''}:${symbol.params || ''}`;
173
287
  if (seenDefinitions.has(identity)) continue;
@@ -183,21 +297,39 @@ function getStats(index, options = {}) {
183
297
  index.importGraph.get(symbol.file)?.has(candidate.file)))) {
184
298
  continue;
185
299
  }
186
- candidates.push({ name, symbol, upper });
300
+ candidates.push({ name, symbol, upper, share });
187
301
  }
188
302
  }
303
+ const maxRefine = Number.isInteger(options.maxRefine) && options.maxRefine > 0
304
+ ? options.maxRefine : Infinity;
305
+ // Exact mode walks the ceilings in descending order so the early stop
306
+ // fires as soon as possible; a bounded refinement walks fair shares so
307
+ // the budget lands on the definitions most likely to be hot.
189
308
  candidates.sort((a, b) =>
309
+ (maxRefine !== Infinity ? (b.share - a.share) : 0) ||
190
310
  (b.upper - a.upper) ||
191
311
  codeUnitCompare(a.symbol.relativePath, b.symbol.relativePath) ||
192
312
  (a.symbol.startLine || 0) - (b.symbol.startLine || 0));
313
+ // Suffix maximum of the exact ceilings: once no unseen candidate can
314
+ // beat the current Nth result, the answer is exact regardless of order.
315
+ const remainingUpper = new Array(candidates.length + 1).fill(-1);
316
+ for (let i = candidates.length - 1; i >= 0; i--) {
317
+ remainingUpper[i] = Math.max(candidates[i].upper, remainingUpper[i + 1]);
318
+ }
193
319
 
194
320
  const hotList = [];
195
321
  const { findCallers } = require('./callers');
196
322
  const scopedCallerQuery = !!(options.file || options.in ||
197
323
  (options.exclude && options.exclude.length > 0));
198
324
  let refined = 0;
325
+ let budgetExhausted = false;
326
+ // One operation scope for the whole refinement loop (fix #340): the
327
+ // per-file derivations findCallers builds are shared across candidates.
328
+ if (top > 0) index._beginOp();
329
+ try {
199
330
  if (top > 0) {
200
331
  for (let candidateIndex = 0; candidateIndex < candidates.length; candidateIndex++) {
332
+ if (refined >= maxRefine) { budgetExhausted = true; break; }
201
333
  const { name, symbol } = candidates[candidateIndex];
202
334
  const exact = findCallers(index, name, {
203
335
  targetDefinitions: [symbol],
@@ -210,7 +342,8 @@ function getStats(index, options = {}) {
210
342
  (!scopedCallerQuery || scopedPaths.has(caller.file)) &&
211
343
  !index.files.get(caller.file)?.isBundled &&
212
344
  (!options.productionCallsOnly ||
213
- !require('./shared').isTestPath(caller.relativePath || caller.file))).length;
345
+ (!require('./shared').isTestPath(caller.relativePath || caller.file) &&
346
+ !inInlineTest(caller.file, caller.line)))).length;
214
347
  if (count > 0) {
215
348
  const owner = symbol.className ||
216
349
  (symbol.receiver || '').replace(/^\*/, '');
@@ -229,11 +362,11 @@ function getStats(index, options = {}) {
229
362
  (a.startLine || 0) - (b.startLine || 0));
230
363
  if (hotList.length >= top) {
231
364
  const threshold = hotList[top - 1].callCount;
232
- const nextUpper = candidates[candidateIndex + 1]?.upper ?? -1;
233
- if (nextUpper < threshold) break;
365
+ if (remainingUpper[candidateIndex + 1] < threshold) break;
234
366
  }
235
367
  }
236
368
  }
369
+ } finally { if (top > 0) index._endOp(); }
237
370
 
238
371
  // Stable order: callCount desc, then (relativePath, startLine) asc.
239
372
  hotList.sort((a, b) =>
@@ -248,6 +381,7 @@ function getStats(index, options = {}) {
248
381
  totalKind: refined === candidates.length ? 'confirmed' : 'raw-call-candidates',
249
382
  refined,
250
383
  items: hotList.slice(0, top),
384
+ ...(budgetExhausted && { budgetExhausted: true, maxRefine }),
251
385
  note: refined === candidates.length
252
386
  ? 'Counts are confirmed caller-engine edges pinned to each displayed definition; unverified dispatch is excluded.'
253
387
  : `Displayed counts are exact confirmed caller-engine edges; ${candidates.length} raw candidates were bounded and ${refined} required exact refinement.`,
@@ -805,6 +939,12 @@ function computeEvidenceProfile(index, { sampleSize, matchInFilter }) {
805
939
  * trust verdict. Composes existing engine reads; counts and pointers only
806
940
  * (no caller claims, so no account — the toc/stats category).
807
941
  */
942
+ // Orientation refines at most this many HOT candidates exactly (fix #340).
943
+ // grpc-go (1037 files): exact refinement walks 1089 candidates in ~20s; 400
944
+ // in fair-share order reproduces the exact top 8 in under 5s. When the budget
945
+ // binds, the header says so and points at the exact command.
946
+ const ORIENT_HOT_REFINE_BUDGET = 400;
947
+
808
948
  function orient(index, options = {}) {
809
949
  const top = options.top || 8;
810
950
  const scope = {
@@ -827,6 +967,8 @@ function orient(index, options = {}) {
827
967
  // actually contains production files. In an all-test repository it
828
968
  // would erase the raw ranking that orient promises as its fallback.
829
969
  productionCallsOnly: options.includeTests !== true && hasProductionFiles,
970
+ maxRefine: Number.isInteger(options.hotRefineBudget) && options.hotRefineBudget > 0
971
+ ? options.hotRefineBudget : ORIENT_HOT_REFINE_BUDGET,
830
972
  });
831
973
  const health = doctor(index, scope);
832
974
 
@@ -908,6 +1050,9 @@ function orient(index, options = {}) {
908
1050
  total: stats.hot?.total ?? 0,
909
1051
  totalKind: stats.hot?.totalKind || 'confirmed',
910
1052
  refined: stats.hot?.refined ?? 0,
1053
+ ...(stats.hot?.budgetExhausted && {
1054
+ budgetExhausted: true, maxRefine: stats.hot.maxRefine,
1055
+ }),
911
1056
  top,
912
1057
  production,
913
1058
  items: hotItems,
@@ -2776,7 +2776,7 @@ function findImportsInCode(code, parser) {
2776
2776
  } finally { /* cached with the selected tree */ }
2777
2777
  }
2778
2778
 
2779
- function findUsagesInCode(code, name, parser, existingTree) {
2779
+ function findUsagesInCode(code, name, parser, existingTree, options = {}) {
2780
2780
  // Usage is the raw literal-name inventory. The literal C/C++ tree retains
2781
2781
  // identifiers from every preprocessor branch and is sufficient for
2782
2782
  // occurrence kind/line classification; symbol ownership still comes from
@@ -2860,22 +2860,24 @@ function findUsagesInCode(code, name, parser, existingTree) {
2860
2860
  // call extractor reparses those AST-proven regions; surface the resulting
2861
2861
  // call usages here as well so callers/callees, usages, and tests share one
2862
2862
  // semantic fact set.
2863
- const seenCalls = new Set(usages
2864
- .filter(usage => usage.usageType === 'call')
2865
- .map(usage => `${usage.line}:${usage.column ?? ''}`));
2866
- const macroCalls = findMacroBodyCalls(tree, code, parser, name);
2867
- for (const call of macroCalls) {
2868
- if (call.name !== name) continue;
2869
- const key = `${call.line}:${call.column ?? ''}`;
2870
- if (seenCalls.has(key)) continue;
2871
- seenCalls.add(key);
2872
- addUsage({
2873
- line: call.line,
2874
- column: call.column,
2875
- usageType: 'call',
2876
- ...(call.receiver && { receiver: call.receiver }),
2877
- ...(call.macroParameter && { macroParameter: true }),
2878
- });
2863
+ if (!options.skipCallRecovery) {
2864
+ const seenCalls = new Set(usages
2865
+ .filter(usage => usage.usageType === 'call')
2866
+ .map(usage => `${usage.line}:${usage.column ?? ''}`));
2867
+ const macroCalls = findMacroBodyCalls(tree, code, parser, name);
2868
+ for (const call of macroCalls) {
2869
+ if (call.name !== name) continue;
2870
+ const key = `${call.line}:${call.column ?? ''}`;
2871
+ if (seenCalls.has(key)) continue;
2872
+ seenCalls.add(key);
2873
+ addUsage({
2874
+ line: call.line,
2875
+ column: call.column,
2876
+ usageType: 'call',
2877
+ ...(call.receiver && { receiver: call.receiver }),
2878
+ ...(call.macroParameter && { macroParameter: true }),
2879
+ });
2880
+ }
2879
2881
  }
2880
2882
  return usages;
2881
2883
  }