ucn 5.5.0 → 5.5.2

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.
Files changed (93) hide show
  1. package/.claude/skills/ucn/SKILL.md +35 -9
  2. package/.claude/skills/ucn/references/commands.md +38 -16
  3. package/.claude/skills/ucn/references/resolution-rules.md +126 -20
  4. package/.claude/skills/ucn/references/trust-contract.md +5 -3
  5. package/README.md +41 -27
  6. package/cli/index.js +6 -1
  7. package/core/accessors.js +341 -54
  8. package/core/account.js +106 -31
  9. package/core/analysis.js +266 -127
  10. package/core/ast-analysis.js +12 -3
  11. package/core/brief.js +8 -4
  12. package/core/build-worker.js +2 -1
  13. package/core/cache.js +144 -6
  14. package/core/call-name-cache.js +134 -0
  15. package/core/callable-scope.js +21 -0
  16. package/core/callers.js +5111 -1024
  17. package/core/check.js +22 -5
  18. package/core/class-identity.js +399 -12
  19. package/core/constructor-calls.js +831 -0
  20. package/core/constructor-hierarchy.js +147 -0
  21. package/core/constructor-protocol.js +196 -0
  22. package/core/contract-membership.js +346 -140
  23. package/core/cpp-conversion-constraints.js +123 -0
  24. package/core/cpp-conversion-syntax.js +216 -0
  25. package/core/cpp-conversions.js +414 -0
  26. package/core/cpp-scope.js +245 -44
  27. package/core/deadcode.js +35 -15
  28. package/core/decorator-binding.js +137 -3
  29. package/core/enum-reference-syntax.js +134 -0
  30. package/core/execute.js +17 -1
  31. package/core/external-macros.js +42 -10
  32. package/core/go-packages.js +171 -0
  33. package/core/graph-build.js +2 -0
  34. package/core/graph.js +75 -59
  35. package/core/ignore-rules.js +2 -1
  36. package/core/imports.js +145 -19
  37. package/core/index-ir.js +41 -4
  38. package/core/ir.js +27 -6
  39. package/core/macro-expansion.js +38 -2
  40. package/core/out-of-class-members.js +182 -0
  41. package/core/output/analysis-ext.js +4 -2
  42. package/core/output/analysis.js +89 -20
  43. package/core/output/graph.js +12 -3
  44. package/core/output/lines.js +4 -3
  45. package/core/output/public.js +1 -0
  46. package/core/output/reporting.js +6 -1
  47. package/core/output/shared.js +2 -0
  48. package/core/output/tracing.js +28 -22
  49. package/core/output-budget.js +2 -2
  50. package/core/parallel-build.js +52 -1
  51. package/core/project.js +228 -114
  52. package/core/provenance-constructor-base.js +161 -0
  53. package/core/provenance-facts.js +257 -27
  54. package/core/provenance-overload.js +458 -54
  55. package/core/provenance-type.js +487 -0
  56. package/core/provenance.js +234 -15
  57. package/core/public-command.js +17 -2
  58. package/core/query-bodies.js +159 -0
  59. package/core/reflection.js +255 -2
  60. package/core/rename-token-syntax.js +111 -0
  61. package/core/reporting.js +13 -7
  62. package/core/rust-macro-expansion.js +1202 -157
  63. package/core/rust-modules.js +21 -3
  64. package/core/rust-names.js +607 -0
  65. package/core/search.js +70 -17
  66. package/core/shared.js +98 -1
  67. package/core/source-memo.js +201 -0
  68. package/core/text-usage-syntax.js +38 -0
  69. package/core/tracing.js +7 -1
  70. package/core/type-denotation.js +157 -46
  71. package/core/type-references.js +615 -246
  72. package/core/value-references.js +2777 -0
  73. package/core/verify.js +1097 -281
  74. package/languages/adapter.js +28 -4
  75. package/languages/c-family.js +1118 -164
  76. package/languages/constructor-bases.js +105 -0
  77. package/languages/cpp-platform.js +26 -0
  78. package/languages/cpp-site-syntax.js +83 -0
  79. package/languages/csharp-scope.js +439 -0
  80. package/languages/csharp.js +428 -68
  81. package/languages/go.js +385 -207
  82. package/languages/html.js +4 -2
  83. package/languages/index.js +397 -2
  84. package/languages/java-platform.js +226 -0
  85. package/languages/java-unicode.js +269 -0
  86. package/languages/java.js +461 -117
  87. package/languages/javascript.js +418 -66
  88. package/languages/lexical-scope.js +190 -23
  89. package/languages/python.js +404 -54
  90. package/languages/rust.js +548 -89
  91. package/languages/utils.js +67 -8
  92. package/mcp/server.js +8 -2
  93. package/package.json +2 -2
package/core/account.js CHANGED
@@ -36,7 +36,7 @@
36
36
  * Ground-set semantics are grep `-n -w`: unit is the (file, line) pair, each
37
37
  * line with >= 1 word-boundary match counts once, case-sensitive.
38
38
  *
39
- * Performance: the first ground scan is one `includes()`-gated read per project
39
+ * Performance: the first ground scan is one literal-name-gated read per project
40
40
  * file — the same I/O profile as the existing `search`/`usages` commands.
41
41
  * Exact results are retained in a small LRU for the lifetime of one built index
42
42
  * so context/about/impact projections of the same symbol do not rescan the
@@ -53,6 +53,7 @@
53
53
  const fs = require('fs');
54
54
  const path = require('path');
55
55
  const { codeUnitCompare, literalNameRegex } = require('./shared');
56
+ const { compareNames } = require('./discovery');
56
57
 
57
58
  // Unsupported-language site listings are capped so a Rails-sized repo cannot
58
59
  // flood the account object; the counts always cover the full set.
@@ -84,7 +85,7 @@ function computeGroundSet(index, name) {
84
85
  index._groundSetCache.set(name, cached);
85
86
  return cached.result;
86
87
  }
87
- const wordRe = literalNameRegex(name);
88
+ const wordRe = literalNameRegex(name, name ? 'y' : '');
88
89
  const perFile = new Map();
89
90
  let total = 0;
90
91
  let fileCount = 0;
@@ -96,14 +97,7 @@ function computeGroundSet(index, name) {
96
97
  } catch (e) {
97
98
  continue; // deleted since indexing; not part of the universe anymore
98
99
  }
99
- if (!content.includes(name)) continue;
100
- // Shared read-only lines array — split once per file per operation,
101
- // reused across the symbols of a multi-symbol command (diff-impact).
102
- const lines = index._getFileLines(filePath);
103
- const matched = [];
104
- for (let i = 0; i < lines.length; i++) {
105
- if (wordRe.test(lines[i])) matched.push(i + 1);
106
- }
100
+ const matched = matchingGroundLines(content, name, wordRe);
107
101
  if (matched.length > 0) {
108
102
  perFile.set(filePath, matched);
109
103
  total += matched.length;
@@ -180,6 +174,34 @@ function computeGroundSet(index, name) {
180
174
  return result;
181
175
  }
182
176
 
177
+ // Find literal candidates natively, checking the same Unicode boundaries
178
+ // at their exact offsets. Count line breaks only up to matching positions;
179
+ // once a line matches, its remaining occurrences cannot change the account.
180
+ function matchingGroundLines(content, name, wordRe) {
181
+ if (name.includes('\n')) return [];
182
+ if (!name) return content.split('\n').flatMap((line, i) => wordRe.test(line) ? [i + 1] : []);
183
+ const matched = [];
184
+ let at = content.indexOf(name);
185
+ if (at === -1) return matched;
186
+ let line = 1, end = content.indexOf('\n');
187
+ while (at !== -1) {
188
+ wordRe.lastIndex = at;
189
+ if (wordRe.test(content)) {
190
+ while (end !== -1 && end < at) {
191
+ line++;
192
+ end = content.indexOf('\n', end + 1);
193
+ }
194
+ matched.push(line);
195
+ if (end === -1) break;
196
+ at = content.indexOf(name, end + 1);
197
+ } else {
198
+ // A rejected candidate can overlap a later valid occurrence.
199
+ at = content.indexOf(name, at + 1);
200
+ }
201
+ }
202
+ return matched;
203
+ }
204
+
183
205
  /** Scan only files the parser/index could not ingest. */
184
206
  function scanFailedFiles(index, name) {
185
207
  const unparsed = { fileCount: 0, lines: 0, files: [] };
@@ -200,7 +222,7 @@ function scanFailedFiles(index, name) {
200
222
  if (!content.includes(name)) continue;
201
223
  let matched = 0;
202
224
  for (const line of content.split('\n')) {
203
- if (wordRe.test(line)) matched++;
225
+ if (line.includes(name) && wordRe.test(line)) matched++;
204
226
  }
205
227
  if (matched > 0) {
206
228
  unparsed.fileCount++;
@@ -248,7 +270,7 @@ function scanUnsupportedFiles(index, name, opts = {}) {
248
270
  const lines = content.split('\n');
249
271
  let matched = 0;
250
272
  for (let i = 0; i < lines.length; i++) {
251
- if (!wordRe.test(lines[i])) continue;
273
+ if (!lines[i].includes(name) || !wordRe.test(lines[i])) continue;
252
274
  matched++;
253
275
  if (unsupported.sites.length < UNSUPPORTED_SITE_CAP) {
254
276
  unsupported.sites.push({
@@ -276,6 +298,23 @@ function scanUnsupportedFiles(index, name, opts = {}) {
276
298
  return unsupported;
277
299
  }
278
300
 
301
+ /**
302
+ * A set of (file, line) keys held per file: `key(file, line)` names one,
303
+ * and `add`/`has` take a key or a (file, line) pair. Lines compare by their
304
+ * text, as `${file}:${line}` strings do.
305
+ */
306
+ class LineKeys {
307
+ constructor() { this.byFile = new Map(); }
308
+ key(file, line) { return { file: String(file), line: String(line) }; }
309
+ add(file, line) {
310
+ const key = line === undefined ? file : this.key(file, line);
311
+ let lines = this.byFile.get(key.file);
312
+ if (!lines) this.byFile.set(key.file, lines = new Set());
313
+ lines.add(key.line);
314
+ }
315
+ has(key) { return this.byFile.get(key.file)?.has(key.line) === true; }
316
+ }
317
+
279
318
  /**
280
319
  * Classify ground lines not claimed by engine results.
281
320
  *
@@ -289,7 +328,7 @@ function scanUnsupportedFiles(index, name, opts = {}) {
289
328
  * @param {object} index - ProjectIndex
290
329
  * @param {string} name - Symbol name
291
330
  * @param {object} groundSet - from computeGroundSet
292
- * @param {Set<string>} claimedKeys - `${absPath}:${line}` keys already claimed
331
+ * @param {LineKeys} claimedKeys - (absPath, line) keys already claimed
293
332
  * (confirmed + unverified + excluded engine lines)
294
333
  * @returns {{
295
334
  * nonCall: { imports: number, definitions: number, references: number, unclassifiedText: number, total: number },
@@ -311,12 +350,12 @@ function classifyGroundLines(index, name, groundSet, claimedKeys) {
311
350
  if (!defLinesByFile.has(def.file)) defLinesByFile.set(def.file, new Set());
312
351
  defLinesByFile.get(def.file).add(def.startLine);
313
352
  }
314
- let getCachedCalls = null; // lazy require to avoid cycle at module load
353
+ let getCachedCallsBearing = null; // lazy require to avoid cycle at module load
315
354
 
316
355
  for (const [filePath, lineNumbers] of groundSet.perFile) {
317
356
  let unclaimed = null;
318
357
  for (const line of lineNumbers) {
319
- if (claimedKeys.has(`${filePath}:${line}`)) continue;
358
+ if (claimedKeys.has(claimedKeys.key(filePath, line))) continue;
320
359
  (unclaimed || (unclaimed = [])).push(line);
321
360
  }
322
361
  if (!unclaimed) continue;
@@ -324,20 +363,34 @@ function classifyGroundLines(index, name, groundSet, claimedKeys) {
324
363
  const fileEntry = index.files.get(filePath);
325
364
  const relativePath = fileEntry ? fileEntry.relativePath : path.relative(index.root, filePath);
326
365
 
327
- // Cheap pass: calls cache + symbol table
328
- if (!getCachedCalls) getCachedCalls = require('./callers').getCachedCalls;
329
- const cachedCalls = getCachedCalls(index, filePath);
366
+ // Cheap pass: calls cache + symbol table (only records bearing the
367
+ // name on unclaimed lines are read below).
368
+ if (!getCachedCallsBearing) getCachedCallsBearing = require('./callers').getCachedCallsBearing;
369
+ const unclaimedLines = new Set(unclaimed);
370
+ const cachedCalls = getCachedCallsBearing(index, filePath, name, unclaimedLines);
371
+ const recordIsNoCall = require('./callers')._recordIsNoCall;
330
372
  const callLines = new Set();
373
+ // `(T)(x)` casts: uses of the type, not calls (fix #427).
374
+ let castLines = null;
331
375
  if (Array.isArray(cachedCalls)) {
332
376
  for (const c of cachedCalls) {
377
+ // The ordinary cache can return the whole file. Only these
378
+ // name-bearing, unclaimed lines can affect this account.
379
+ if (!(c.name === name || c.resolvedName === name ||
380
+ (c.resolvedNames && c.resolvedNames.includes(name))) || !unclaimedLines.has(c.line)) continue;
333
381
  // Unclaimed callback/type references have no invocation
334
382
  // syntax. Let the usage AST classify them as references;
335
383
  // merely entering the candidate cache is not a call fact.
336
- if (c.isFunctionReference || c.isTypeReference) continue;
337
- if (c.name === name || c.resolvedName === name ||
338
- (c.resolvedNames && c.resolvedNames.includes(name))) {
339
- callLines.add(c.line);
384
+ // An implicit initializer has no written invocation either.
385
+ if (c.isFunctionReference || c.isTypeReference || c.constructorSyntax === 'implicit') continue;
386
+ // Nor a data member's mem-initializer or a default
387
+ // initialization of no class (fix #421).
388
+ if ((c.memberInitializer || c.constructorSyntax === 'default') && recordIsNoCall(index, filePath, c)) continue;
389
+ if (c.parenthesizedCallee && c.name === name && recordIsNoCall(index, filePath, c)) {
390
+ (castLines ||= new Set()).add(c.line);
391
+ continue;
340
392
  }
393
+ callLines.add(c.line);
341
394
  }
342
395
  }
343
396
  const defLines = defLinesByFile.get(filePath);
@@ -346,6 +399,8 @@ function classifyGroundLines(index, name, groundSet, claimedKeys) {
346
399
  for (const line of unclaimed) {
347
400
  if (callLines.has(line)) {
348
401
  callNotResolved.push({ file: filePath, relativePath, line });
402
+ } else if (castLines?.has(line)) {
403
+ nonCall.references++;
349
404
  } else if (defLines && defLines.has(line)) {
350
405
  nonCall.definitions++;
351
406
  } else {
@@ -359,8 +414,11 @@ function classifyGroundLines(index, name, groundSet, claimedKeys) {
359
414
  // Call lines were classified from the complete calls cache above.
360
415
  // Language adapters may skip usage-only call recovery here; notably,
361
416
  // C/C++ avoids reparsing every matching macro replacement list.
417
+ // Only occurrence kinds are consumed below, so receiver ownership
418
+ // is optional on this path too.
362
419
  const usages = index._getCachedUsages(filePath, name, {
363
420
  skipCallRecovery: true,
421
+ usageKindsOnly: true,
364
422
  });
365
423
  const byLine = new Map();
366
424
  if (Array.isArray(usages)) {
@@ -415,22 +473,29 @@ const RANK = { call: 0, import: 1, definition: 2, reference: 3 };
415
473
  */
416
474
  function buildAccount(index, name, parts) {
417
475
  const { groundSet } = parts;
418
- const confirmedEntries = parts.confirmedEntries || [];
419
- const unverifiedEntries = parts.unverifiedEntries || [];
420
- const excludedEntries = parts.excludedEntries || [];
421
-
422
- const groundKeys = new Set();
476
+ // Entries in (file, line) order, stable (fix #407): samples, the evidence
477
+ // list and the reason of a line excluded twice must not follow the order
478
+ // the engine visited files in, which a warm session's derivation history
479
+ // changes.
480
+ const confirmedEntries = sortedSites(parts.confirmedEntries || []);
481
+ const unverifiedEntries = sortedSites(parts.unverifiedEntries || []);
482
+ const excludedEntries = sortedSites(parts.excludedEntries || []);
483
+
484
+ // (file, line) keys, kept per file (fix #405b: one composed string per
485
+ // ground line and per entry); a line is keyed by its text, as the
486
+ // composed `${file}:${line}` keys were.
487
+ const groundKeys = new LineKeys();
423
488
  for (const [filePath, lineNumbers] of groundSet.perFile) {
424
- for (const line of lineNumbers) groundKeys.add(`${filePath}:${line}`);
489
+ for (const line of lineNumbers) groundKeys.add(filePath, line);
425
490
  }
426
491
 
427
- const claimedKeys = new Set();
492
+ const claimedKeys = new LineKeys();
428
493
  const beyondText = { count: 0, sample: [] };
429
494
  let confirmed = 0;
430
495
  let unverified = 0;
431
496
 
432
497
  const claim = (entry, bucket) => {
433
- const key = `${entry.file}:${entry.line}`;
498
+ const key = claimedKeys.key(entry.file, entry.line);
434
499
  if (claimedKeys.has(key)) return; // one bucket per line; first claim wins
435
500
  if (!groundKeys.has(key)) {
436
501
  // Engine found a call site grep would miss (alias / indirect name).
@@ -454,7 +519,7 @@ function buildAccount(index, name, parts) {
454
519
  const excludedByReason = {};
455
520
  let excludedTotal = 0;
456
521
  for (const e of excludedEntries) {
457
- const key = `${e.file}:${e.line}`;
522
+ const key = claimedKeys.key(e.file, e.line);
458
523
  if (claimedKeys.has(key)) continue;
459
524
  claimedKeys.add(key);
460
525
  if (!groundKeys.has(key)) continue; // excluded non-ground line: irrelevant to both grep and display
@@ -541,6 +606,16 @@ function relPath(index, filePath) {
541
606
  return fileEntry ? fileEntry.relativePath : path.relative(index.root, filePath);
542
607
  }
543
608
 
609
+ /** Sites by (file in the index's canonical order, line), stable; the input
610
+ * itself when already in that order, else a sorted copy (fix #407). */
611
+ function sortedSites(entries) {
612
+ const before = (a, b) => compareNames(String(a.file), String(b.file)) || ((a.line || 0) - (b.line || 0));
613
+ for (let i = 1; i < entries.length; i++) {
614
+ if (before(entries[i - 1], entries[i]) > 0) return [...entries].sort(before);
615
+ }
616
+ return entries;
617
+ }
618
+
544
619
  module.exports = {
545
620
  computeGroundSet,
546
621
  classifyGroundLines,