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/analysis.js CHANGED
@@ -14,7 +14,7 @@ const { UcnError } = require('./errors');
14
14
  const { parse } = require('./parser');
15
15
  const { detectLanguage, langTraits } = require('../languages');
16
16
  const { NON_CALLABLE_TYPES, addTestExclusions, countTextBlindspots,
17
- codeUnitCompare, formatSymbolHandle } = require('./shared');
17
+ codeUnitCompare, formatSymbolHandle, isValueDefinition } = require('./shared');
18
18
  const { isTestFile } = require('./discovery');
19
19
  // Entry-point detection loads on first use (fix #375).
20
20
  let entrypointsModule = null;
@@ -346,6 +346,23 @@ function callNotResolvedEntries(index, account, options = {}) {
346
346
  * @param {object} options - { file, className, includeMethods, includeUncertain, exclude, minConfidence }
347
347
  * @returns {object|null}
348
348
  */
349
+ /**
350
+ * The hint for a constructor handle with no callers whose declaring class
351
+ * is unresolved (fix #421): constructions name the class, and without its
352
+ * definition they cannot be bound to this constructor. Null otherwise: a
353
+ * constructor of a resolved class receives every construction selecting it.
354
+ */
355
+ function unownedConstructorHint(index, def, callerCount) {
356
+ if (callerCount > 0 || !def?.className) return null;
357
+ const { isConstructor } = require('./constructor-calls');
358
+ if (!isConstructor(def, index) || require('./class-identity').ownerDefinitionOf(index, def) ||
359
+ !index.getCalleeFiles(def.className)) return null;
360
+ return {
361
+ type: 'hint',
362
+ message: `The class declaring this constructor was not resolved, so constructions of \`${def.className}\` are not bound to it; query \`${def.className}\` for the construction sites.`,
363
+ };
364
+ }
365
+
349
366
  function context(index, name, options = {}) {
350
367
  index._beginOp();
351
368
  try {
@@ -591,6 +608,7 @@ function context(index, name, options = {}) {
591
608
  ...(ambiguityCandidates && { ambiguityCandidates }),
592
609
  callees,
593
610
  unverifiedCallees: rawCallees.unverifiedCallees || [],
611
+ ...(rawCallees.initializerReviews?.length && { initializerReviews: rawCallees.initializerReviews }),
594
612
  callerHistogram,
595
613
  calleeHistogram,
596
614
  meta: {
@@ -619,18 +637,11 @@ function context(index, name, options = {}) {
619
637
  }
620
638
  };
621
639
 
622
- // Constructor pins surface their invocation sites under the CLASS pin
623
- // (`new Widget()` indexes under 'Widget' — the fix #239 liveness rule;
624
- // fix #254 makes it discoverable): a bare empty answer sent readers
625
- // hunting for callers the class pin already has.
626
- if (def.className && callers.length === 0 &&
627
- (def.type === 'constructor' || name === 'constructor' || name === '__init__') &&
628
- index.getCalleeFiles(def.className)) {
629
- warnings.push({
630
- type: 'hint',
631
- message: `Constructor invocations (\`new ${def.className}(...)\` / \`${def.className}(...)\`) are indexed under the class name — query \`${def.className}\` for the call sites.`,
632
- });
633
- }
640
+ // A constructor handle lists the constructions that select it (fix
641
+ // #417). Only a constructor whose declaring class is unresolved cannot
642
+ // receive them; its class handle still has the sites (fix #421).
643
+ const unownedHint = unownedConstructorHint(index, def, callers.length);
644
+ if (unownedHint) warnings.push(unownedHint);
634
645
 
635
646
  if (warnings.length > 0) {
636
647
  result.warnings = warnings;
@@ -711,6 +722,7 @@ function smart(index, name, options = {}) {
711
722
  dependencies,
712
723
  types,
713
724
  unverifiedCallees: smartUnverified,
725
+ ...(callees.initializerReviews?.length && { initializerReviews: callees.initializerReviews }),
714
726
  meta: {
715
727
  complete: stats.uncertain === 0 && dynamicImports === 0 && smartUnverified.length === 0,
716
728
  skipped: 0,
@@ -991,99 +1003,132 @@ function related(index, name, options = {}) {
991
1003
  * @param {object} options - { file, className, exclude, top }
992
1004
  * @returns {object|null}
993
1005
  */
994
- // Kinds whose dependents are annotation/reference sites rather than calls.
995
- // Classes and structs stay out: `new X()` / `X{}` are call-shaped and already
996
- // flow through the caller sweep.
997
- const TYPE_REFERENCE_KINDS = new Set(['type', 'interface', 'enum', 'trait', 'record']);
1006
+ // Kinds whose dependents include annotation/reference sites (fix #345):
1007
+ // every type declaration. A class or struct is also constructed: its
1008
+ // constructions are call sites of the caller band, every other token naming
1009
+ // it (annotations, bases, casts, qualifiers, imports) a type reference
1010
+ // (fix #428).
1011
+ const TYPE_REFERENCE_KINDS = new Set(['class', 'struct', 'interface', 'type', 'enum', 'trait', 'record', 'union']);
1012
+ // Tokens that declare the type (its own name, its constructors' names).
1013
+ const TYPE_DECLARATION_ROLES = new Set(['decl', 'ctor', 'ctor-qualified', 'pin-binding']);
998
1014
 
999
1015
  /**
1000
- * fix #345: tiered annotation-site band for a type-kind definition.
1001
- * Confirmed needs identity evidence: same file as the definition, or an
1002
- * import binding of the name in the referencing file that reaches the
1003
- * definition's file (the #215/#217 scope discipline). Anything else is
1004
- * VISIBLE unverified with a reason. A same-name definition elsewhere is
1005
- * excluded as other-target. Spelling alone never confirms.
1016
+ * The type reference band of a type definition (fixes #345, #428): every
1017
+ * token naming the type on the name's ground lines, decided by the
1018
+ * type-rename lookup (core/type-references.js, the language's own name
1019
+ * lookup: lexical shadows, type parameters, imports, packages, namespaces,
1020
+ * include closures). A token the lookup proves names the type is confirmed,
1021
+ * one it cannot settle is unverified with its reason, one naming something
1022
+ * else is excluded with its reason. Tokens at call sites (constructions)
1023
+ * belong to the caller band and declaration tokens to the definitions; the
1024
+ * account partitions all of them.
1006
1025
  */
1007
1026
  function findTypeReferences(index, name, def, options = {}) {
1008
- if (!def || !TYPE_REFERENCE_KINDS.has(def.type)) return null;
1009
- const { usages } = require('./search');
1010
- const { _importReaches, _sameNominalPackageDir } = require('./callers');
1011
- const records = usages(index, name, {
1012
- includeTests: true, codeOnly: true, exclude: options.exclude,
1013
- });
1014
- const targetFiles = new Set([def.file]);
1015
- const sameNameDefs = (index.symbols.get(name) || []).filter(d => d !== def);
1016
- const confirmed = [];
1017
- const unverified = [];
1018
- const excluded = [];
1019
- for (const u of (Array.isArray(records) ? records : records?.usages || [])) {
1020
- if (u.isDefinition || u.usageType !== 'reference') continue;
1021
- const site = {
1022
- file: u.relativePath, line: u.line,
1023
- expression: (u.content || '').trim(),
1024
- };
1025
- // A same-name type defined in the referencing file owns that file's
1026
- // bare references (the #215 scope rule): excluded, never confirmed.
1027
- if (u.file !== def.file && sameNameDefs.some(d => d.file === u.file && TYPE_REFERENCE_KINDS.has(d.type))) {
1028
- excluded.push({ ...site, reason: 'other-definition' });
1029
- continue;
1030
- }
1031
- if (sameNameDefs.some(d => d.file === u.file && (d.nameLine || d.startLine) === u.line)) {
1032
- excluded.push({ ...site, reason: 'other-definition' });
1033
- continue;
1034
- }
1035
- if (u.file === def.file) {
1036
- confirmed.push({ ...site, evidence: 'same-file' });
1037
- continue;
1038
- }
1039
- const fileEntry = index.files.get(u.file);
1040
- // Directory-scoped packages (Go) and Java packages see sibling files'
1041
- // types without an import; a same-name def in another package would
1042
- // have been excluded above only if it shared the line, so require the
1043
- // pinned def to be the package's own.
1044
- const packageScoped = fileEntry && (
1045
- (langTraits(fileEntry.language).packageScope === 'directory' &&
1046
- path.dirname(u.file) === path.dirname(def.file)) ||
1047
- (fileEntry.language === 'java' &&
1048
- _sameNominalPackageDir(path.dirname(def.file), path.dirname(u.file), 'java')));
1049
- if (packageScoped) {
1050
- const foreign = sameNameDefs.some(d => TYPE_REFERENCE_KINDS.has(d.type) &&
1051
- path.dirname(d.file) === path.dirname(u.file));
1052
- if (foreign) unverified.push({ ...site, reason: 'same-package-ambiguous' });
1053
- else confirmed.push({ ...site, evidence: 'package-scope' });
1054
- continue;
1055
- }
1056
- const bindings = (fileEntry?.importBindings || []).filter(b =>
1057
- b.name === name || b.alias === name);
1058
- if (bindings.length > 0) {
1059
- const reaches = bindings.some(b => {
1060
- const rel = fileEntry.moduleResolved && fileEntry.moduleResolved[b.module];
1061
- return rel && _importReaches(index, path.join(index.root, rel), targetFiles);
1062
- });
1063
- if (reaches) { confirmed.push({ ...site, evidence: 'import' }); continue; }
1064
- const otherProject = bindings.some(b =>
1065
- fileEntry.moduleResolved && fileEntry.moduleResolved[b.module]);
1066
- if (otherProject) { excluded.push({ ...site, reason: 'other-definition-import' }); continue; }
1067
- unverified.push({ ...site, reason: 'import-unresolved' });
1068
- continue;
1069
- }
1070
- if (fileEntry?.importNames?.includes('*')) {
1071
- unverified.push({ ...site, reason: 'star-import' });
1072
- continue;
1027
+ if (!def || !TYPE_REFERENCE_KINDS.has(def.type) || def.isMethod) return null;
1028
+ const { TypeReferenceResolver, typeReferenceSites, csharpAttributeShortSites } = require('./type-references');
1029
+ const { computeGroundSet } = require('./account');
1030
+ const { getCachedCalls, _recordIsNoCall } = require('./callers');
1031
+ const resolver = new TypeReferenceResolver(index, def, name);
1032
+ resolver.collectRoles = true;
1033
+ const ground = computeGroundSet(index, name);
1034
+ const scopedGround = options.files ? { ...ground,
1035
+ perFile: new Map([...ground.perFile].filter(([file]) => options.files.has(file))) } : ground;
1036
+ const { files } = typeReferenceSites(index, def, name, scopedGround, resolver);
1037
+ const confirmed = [], unverified = [], excluded = [];
1038
+ let tokens = 0, lineCount = 0, textLines = 0, constructions = 0, declarations = 0;
1039
+ const scannedFiles = new Set();
1040
+ const roleKind = role => role === 'value' || role === 'shorthand' ? 'value' : role === 'import' ? 'import'
1041
+ : role === 'string-annotation' ? 'annotation-string' : role === 'doc-cref' ? 'doc-reference'
1042
+ : role === 'member' ? 'member' : 'type';
1043
+ for (const [absoluteFile, info] of [...files].sort((a, b) => codeUnitCompare(a[1].relativePath, b[1].relativePath))) {
1044
+ const file = info.relativePath;
1045
+ if (!index.matchesFilters(file, options) || options.files && !options.files.has(absoluteFile)) continue;
1046
+ // One split of the file and one grouping of its call records by line.
1047
+ let fileLines;
1048
+ try { fileLines = index._getFileLines(absoluteFile) || []; } catch { fileLines = []; }
1049
+ let callsByLine = null;
1050
+ for (const [line, slot] of [...info.lines].sort((a, b) => a[0] - b[0])) {
1051
+ const expression = String(fileLines[line - 1] || '').trim();
1052
+ if (slot.tokens === 0 && slot.reviews.length === 0) {
1053
+ if (slot.text && !slot.macroBody) { textLines++; continue; }
1054
+ // The name outside every parsed token and comment: code the
1055
+ // lookup cannot read (a macro body, an unparsed region).
1056
+ lineCount++;
1057
+ scannedFiles.add(absoluteFile);
1058
+ unverified.push({ file, line, expression, kind: 'unparsed',
1059
+ reason: slot.unparsedReason || (slot.macroBody ? 'macro-body' : 'unparsed-occurrence'), tier: 'unverified' });
1060
+ continue;
1061
+ }
1062
+ if (slot.tokens > 0) { lineCount++; scannedFiles.add(absoluteFile); }
1063
+ tokens += slot.tokens;
1064
+ // Call-site tokens of the line: the construction records' name
1065
+ // columns; records without a column, by the token's AST position.
1066
+ if (!callsByLine) {
1067
+ callsByLine = new Map();
1068
+ for (const call of getCachedCalls(index, absoluteFile) || []) {
1069
+ if (call.name !== name && !(call.receiverIsTypeQualified && call.receiver === name)) continue;
1070
+ if (!callsByLine.has(call.line)) callsByLine.set(call.line, []);
1071
+ callsByLine.get(call.line).push(call);
1072
+ }
1073
+ }
1074
+ // A cast or a member initializer spelled like a call is no call.
1075
+ // A static member call through the type (`Key.get(..)`, Java and
1076
+ // C#) is a type-qualifier site of the caller band too.
1077
+ const lineCalls = (callsByLine.get(line) || []).filter(call => !_recordIsNoCall(index, absoluteFile, call));
1078
+ // A default or list initialization (`Foo y;`, `Foo z{}`, fix
1079
+ // #421) is recorded at its declarator and a qualified call at its
1080
+ // member: the type token is the nearest name token before it.
1081
+ const lineTokens = [...slot.edits, ...slot.reviews.map(review => review.column),
1082
+ ...(slot.skipColumns || [])].filter(Number.isInteger).sort((a, b) => a - b);
1083
+ const callColumns = new Set(lineCalls.map(call => {
1084
+ const column = call.column ?? call.callSite?.column;
1085
+ const before = call.name !== name || ['default', 'list'].includes(call.constructorSyntax);
1086
+ if (!Number.isInteger(column) || !before || lineTokens.includes(column)) return column;
1087
+ return lineTokens.filter(token => token < column).pop();
1088
+ }).filter(Number.isInteger));
1089
+ const columnless = lineCalls.some(call => !Number.isInteger(call.column ?? call.callSite?.column));
1090
+ const atCall = column => callColumns.has(column) ||
1091
+ (columnless && (slot.constructionColumns || []).includes(column));
1092
+ const roleAt = column => slot.roles?.get(column);
1093
+ const site = (column, extra) => ({ file, line, ...(Number.isInteger(column) && { column }), expression, ...extra });
1094
+ for (const column of slot.edits) {
1095
+ const role = roleAt(column);
1096
+ if (TYPE_DECLARATION_ROLES.has(role)) { declarations++; continue; }
1097
+ if (atCall(column)) { constructions++; continue; }
1098
+ confirmed.push(site(column, { kind: roleKind(role), tier: 'confirmed' }));
1099
+ }
1100
+ for (const review of slot.reviews) {
1101
+ if (Number.isInteger(review.column) && atCall(review.column)) { constructions++; continue; }
1102
+ unverified.push(site(review.column, { kind: roleKind(roleAt(review.column)),
1103
+ reason: review.reason || 'unresolved', tier: 'unverified' }));
1104
+ }
1105
+ for (const column of slot.skipColumns || []) {
1106
+ if (atCall(column)) { constructions++; continue; }
1107
+ excluded.push(site(column, { reason: slot.skipReasons?.get(column) || 'other-declaration' }));
1108
+ }
1073
1109
  }
1074
- unverified.push({ ...site, reason: 'no-import-link' });
1075
1110
  }
1076
- if (confirmed.length === 0 && unverified.length === 0 && excluded.length === 0) {
1077
- return { owner: def.type, confirmedCount: 0, unverifiedCount: 0, totalCandidates: 0,
1078
- byFile: [], unverifiedSites: [], excluded: { total: 0, byReason: {} } };
1111
+ // C# applies `XAttribute` as `[X]` (fix #390): the short spellings.
1112
+ let attributeTokens = 0;
1113
+ for (const short of csharpAttributeShortSites(index, def, name)) {
1114
+ if (!index.matchesFilters(short.relativePath, options) || options.files && !options.files.has(short.file)) continue;
1115
+ attributeTokens++;
1116
+ const entry = { file: short.relativePath, line: short.line, column: short.column,
1117
+ expression: (index.getLineContent(short.file, short.line) || '').trim(), kind: 'attribute' };
1118
+ if (short.verdict === 'yes') confirmed.push({ ...entry, tier: 'confirmed' });
1119
+ else if (short.verdict === 'unknown') unverified.push({ ...entry, reason: short.reason || 'unresolved', tier: 'unverified' });
1120
+ else excluded.push({ ...entry, reason: short.reason || 'other-declaration' });
1079
1121
  }
1080
- const bySite = (a, b) => a.file !== b.file ? codeUnitCompare(a.file, b.file) : a.line - b.line;
1122
+ const bySite = (a, b) => codeUnitCompare(a.file, b.file) || a.line - b.line || (a.column ?? -1) - (b.column ?? -1);
1081
1123
  confirmed.sort(bySite); unverified.sort(bySite);
1082
1124
  const byFile = new Map();
1083
- for (const site of confirmed) {
1084
- if (!byFile.has(site.file)) byFile.set(site.file, []);
1085
- byFile.get(site.file).push(site);
1125
+ for (const entry of confirmed) {
1126
+ if (!byFile.has(entry.file)) byFile.set(entry.file, []);
1127
+ byFile.get(entry.file).push(entry);
1086
1128
  }
1129
+ const tokenSites = unverified.filter(entry => entry.kind !== 'unparsed').length;
1130
+ const allTokens = tokens + attributeTokens;
1131
+ const decided = confirmed.length + tokenSites + excluded.length + constructions + declarations;
1087
1132
  return {
1088
1133
  owner: def.type,
1089
1134
  confirmedCount: confirmed.length,
@@ -1093,7 +1138,14 @@ function findTypeReferences(index, name, def, options = {}) {
1093
1138
  unverifiedSites: unverified,
1094
1139
  excluded: {
1095
1140
  total: excluded.length,
1096
- byReason: excluded.reduce((out, s) => { out[s.reason] = (out[s.reason] || 0) + 1; return out; }, {}),
1141
+ byReason: excluded.reduce((out, entry) => { out[entry.reason] = (out[entry.reason] || 0) + 1; return out; }, {}),
1142
+ },
1143
+ account: {
1144
+ tokens: allTokens, lines: lineCount, files: scannedFiles.size, textLines,
1145
+ confirmed: confirmed.length, unverified: tokenSites, unparsedLines: unverified.length - tokenSites,
1146
+ excluded: excluded.length, constructions, declarations,
1147
+ ...(attributeTokens > 0 && { attributeTokens }),
1148
+ conserved: decided === allTokens,
1097
1149
  },
1098
1150
  };
1099
1151
  }
@@ -1416,6 +1468,29 @@ function impact(index, name, options = {}) {
1416
1468
  // dependents. Same design as the accessor band: a separate band, never
1417
1469
  // fake caller edges (the caller oracle and the account stay call-shaped).
1418
1470
  let typeReferences = findTypeReferences(index, name, def, options);
1471
+ const valueAccesses = isValueDefinition(def)
1472
+ ? require('./value-references').findValueReferences(index, name, def, {
1473
+ includeTests: true, exclude: options.exclude,
1474
+ }) : null;
1475
+
1476
+ // A value token that is also a call is one dependency site (fix #420):
1477
+ // the value band lists every token, the call band only the calls.
1478
+ const valueOnlyCount = valueAccesses ? (() => {
1479
+ const calls = new Map();
1480
+ for (const site of filteredSites) {
1481
+ const key = `${site.file}\0${site.line}`;
1482
+ calls.set(key, (calls.get(key) || 0) + 1);
1483
+ }
1484
+ let count = 0;
1485
+ for (const group of valueAccesses.byFile) {
1486
+ for (const site of group.sites) {
1487
+ const key = `${site.file}\0${site.line}`;
1488
+ if (calls.get(key) > 0) calls.set(key, calls.get(key) - 1);
1489
+ else count++;
1490
+ }
1491
+ }
1492
+ return count;
1493
+ })() : 0;
1419
1494
 
1420
1495
  // Apply top limit if specified (limits total call sites shown)
1421
1496
  const totalBeforeLimit = filteredSites.length;
@@ -1464,6 +1539,8 @@ function impact(index, name, options = {}) {
1464
1539
  ...(propertyAccesses?.unverifiedSites || []).map(site => site.file),
1465
1540
  ...(typeReferences?.byFile || []).map(group => group.file),
1466
1541
  ...(typeReferences?.unverifiedSites || []).map(site => site.file),
1542
+ ...(valueAccesses?.byFile || []).map(group => group.file),
1543
+ ...(valueAccesses?.unverifiedSites || []).map(site => site.file),
1467
1544
  ]);
1468
1545
 
1469
1546
  return {
@@ -1486,6 +1563,11 @@ function impact(index, name, options = {}) {
1486
1563
  totalDependencySites: totalBeforeLimit + typeReferences.confirmedCount,
1487
1564
  affectedFiles: affectedFiles.size,
1488
1565
  }),
1566
+ ...(valueAccesses && {
1567
+ valueAccesses,
1568
+ totalDependencySites: totalBeforeLimit + valueOnlyCount,
1569
+ affectedFiles: affectedFiles.size,
1570
+ }),
1489
1571
  account: impactAccount,
1490
1572
  hasEntrypoints: !!impactReachable && impactReachable.size > 0,
1491
1573
  callerHistogram,
@@ -1572,7 +1654,7 @@ function about(index, name, options = {}) {
1572
1654
  // ("Using <def>...") stays consistent with the count.
1573
1655
  const visible = definitions.filter(d => {
1574
1656
  if (d === primary || (d.relativePath === primary.relativePath && d.startLine === primary.startLine)) return true;
1575
- const lang = detectLanguage(d.file);
1657
+ const lang = index.languageOf(d.file);
1576
1658
  if (isTestFile(d.relativePath, lang) || isTestPath(d.relativePath)) return false;
1577
1659
  return true;
1578
1660
  });
@@ -1831,6 +1913,7 @@ function about(index, name, options = {}) {
1831
1913
  ...(c.paramsStructured && { paramsStructured: c.paramsStructured }),
1832
1914
  ...(c.docstring && { docstring: c.docstring }),
1833
1915
  ...(c.sideEffects && c.sideEffects.length && { sideEffects: c.sideEffects }),
1916
+ ...(c.initializerOf && { initializerOf: c.initializerOf }),
1834
1917
  }));
1835
1918
  }
1836
1919
 
@@ -1964,16 +2047,8 @@ function about(index, name, options = {}) {
1964
2047
  completeness: detectCompleteness(index)
1965
2048
  };
1966
2049
 
1967
- // Constructor pins surface their invocation sites under the CLASS pin
1968
- // (`new Widget()` indexes under 'Widget' — fix #254, same hint as context).
1969
- if (primary.className && (callers?.length ?? 0) === 0 &&
1970
- (primary.type === 'constructor' || symbolName === 'constructor' || symbolName === '__init__') &&
1971
- index.getCalleeFiles(primary.className)) {
1972
- result.warnings = [...(result.warnings || []), {
1973
- type: 'hint',
1974
- message: `Constructor invocations (\`new ${primary.className}(...)\` / \`${primary.className}(...)\`) are indexed under the class name — query \`${primary.className}\` for the call sites.`,
1975
- }];
1976
- }
2050
+ const unownedHint = unownedConstructorHint(index, primary, callers?.length ?? 0);
2051
+ if (unownedHint) result.warnings = [...(result.warnings || []), unownedHint];
1977
2052
 
1978
2053
  return result;
1979
2054
  } finally { index._endOp(); }
@@ -2129,7 +2204,7 @@ function diffImpact(index, options = {}) {
2129
2204
  let nonSourcePaths = 0;
2130
2205
 
2131
2206
  for (const change of changes) {
2132
- const lang = detectLanguage(change.filePath);
2207
+ const lang = index.languageOf(change.filePath);
2133
2208
  if (!lang || (change.untracked && !index.files.has(change.filePath))) {
2134
2209
  nonSourcePaths++;
2135
2210
  continue;
@@ -2174,6 +2249,7 @@ function diffImpact(index, options = {}) {
2174
2249
  let oldCallables = null; // old symbols WITH ranges — deleted lines are old-file coordinates
2175
2250
  let oldParsed = null;
2176
2251
  let oldLines = null;
2252
+ let oldText = null;
2177
2253
  if (change.deletedLines.length > 0 || change.addedLines.length > 0) {
2178
2254
  const ref = staged ? 'HEAD' : base;
2179
2255
  try {
@@ -2181,9 +2257,10 @@ function diffImpact(index, options = {}) {
2181
2257
  'git', ['show', `${ref}:${change.gitRelativePath}`],
2182
2258
  { cwd: index.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }
2183
2259
  );
2184
- const fileLang = detectLanguage(change.filePath);
2260
+ const fileLang = index.languageOf(change.filePath);
2185
2261
  if (fileLang) {
2186
2262
  oldParsed = parse(oldContent, fileLang);
2263
+ oldText = oldContent;
2187
2264
  oldLines = oldContent.split('\n');
2188
2265
  oldSymbolIdentities = new Set();
2189
2266
  oldCallables = extractCallableSymbols(oldParsed);
@@ -2198,17 +2275,61 @@ function diffImpact(index, options = {}) {
2198
2275
 
2199
2276
  collectNonCallableChanges(index, change, oldParsed, fileEntry.symbols, symbolChanges, oldLines);
2200
2277
 
2278
+ // Several callables can share a changed line (`class Box {
2279
+ // constructor(v) {..} get w() {..} }`, fix #434): the line belongs
2280
+ // to each whose own source changed, a callable whose text equals its
2281
+ // base twin's is unchanged by it.
2282
+ let currentText = null;
2283
+ const spanText = (text, s) => text != null && s?.sourceSpan &&
2284
+ Number.isInteger(s.sourceSpan.start) && Number.isInteger(s.sourceSpan.end)
2285
+ ? text.slice(s.sourceSpan.start, s.sourceSpan.end) : null;
2286
+ const unchangedMemo = new Map();
2287
+ const unchangedCallable = symbol => {
2288
+ if (unchangedMemo.has(symbol)) return unchangedMemo.get(symbol);
2289
+ let unchanged = false;
2290
+ if (oldCallables !== null && symbol.sourceSpan) {
2291
+ if (currentText === null) {
2292
+ try { currentText = index._readFile(change.filePath); } catch { currentText = undefined; }
2293
+ }
2294
+ const text = spanText(currentText, symbol);
2295
+ unchanged = text != null && oldCallables.some(old => old.name === symbol.name &&
2296
+ (old.className || '') === (symbol.className || '') && spanText(oldText, old) === text);
2297
+ }
2298
+ unchangedMemo.set(symbol, unchanged);
2299
+ return unchanged;
2300
+ };
2201
2301
  for (const line of change.addedLines) {
2202
2302
  // Arrow fields are indexed as fields but their bodies still need
2203
2303
  // callable impact once declaration-only changes are separated out.
2204
- const symbol = index.findEnclosingFunction(change.filePath, line, true) ||
2304
+ const primary = index.findEnclosingFunction(change.filePath, line, true) ||
2205
2305
  fileEntry.symbols.find(s => isDiffCallable(s) && s.startLine <= line && s.endLine >= line);
2206
- if (symbol) {
2207
- const key = `${symbol.name}:${symbol.startLine}`;
2208
- if (!affectedSymbols.has(key)) {
2209
- affectedSymbols.set(key, { symbol, addedLines: [], deletedLines: [] });
2306
+ let lineSymbols = primary ? [primary] : [];
2307
+ if (primary) {
2308
+ const disjoint = (a, b) => a.sourceSpan && b.sourceSpan &&
2309
+ (a.sourceSpan.end <= b.sourceSpan.start || b.sourceSpan.end <= a.sourceSpan.start);
2310
+ const siblings = fileEntry.symbols.filter(s => s !== primary && isDiffCallable(s) &&
2311
+ s.startLine <= line && s.endLine >= line && disjoint(s, primary) &&
2312
+ !fileEntry.symbols.some(inner => inner !== s && isDiffCallable(inner) && inner.sourceSpan && s.sourceSpan &&
2313
+ inner.sourceSpan.start >= s.sourceSpan.start && inner.sourceSpan.end <= s.sourceSpan.end &&
2314
+ inner.startLine <= line && inner.endLine >= line));
2315
+ // Only a line inside the callable's own source span: an
2316
+ // attribute or decorator line outside it changes the callable.
2317
+ const spans = s => s.sourceSpan?.startLine != null && s.sourceSpan.startLine <= line &&
2318
+ line <= s.sourceSpan.endLine;
2319
+ lineSymbols = [primary, ...siblings].filter(s => !(spans(s) && unchangedCallable(s)));
2320
+ }
2321
+ if (lineSymbols.length > 0) {
2322
+ for (const symbol of lineSymbols) {
2323
+ const key = `${symbol.name}:${symbol.startLine}`;
2324
+ if (!affectedSymbols.has(key)) {
2325
+ affectedSymbols.set(key, { symbol, addedLines: [], deletedLines: [] });
2326
+ }
2327
+ affectedSymbols.get(key).addedLines.push(line);
2210
2328
  }
2211
- affectedSymbols.get(key).addedLines.push(line);
2329
+ } else if (primary) {
2330
+ // Every callable on the line is unchanged: the line's other
2331
+ // tokens changed.
2332
+ continue;
2212
2333
  } else {
2213
2334
  // Module-level change
2214
2335
  const existing = moduleLevelChanges.find(m => m.filePath === change.filePath);
@@ -2266,11 +2387,16 @@ function diffImpact(index, options = {}) {
2266
2387
  for (const c of candidates) {
2267
2388
  if (Math.abs(c.startLine - bestOld.startLine) < Math.abs(cur.startLine - bestOld.startLine)) cur = c;
2268
2389
  }
2269
- const key = `${cur.name}:${cur.startLine}`;
2270
- if (!affectedSymbols.has(key)) {
2271
- affectedSymbols.set(key, { symbol: cur, addedLines: [], deletedLines: [] });
2390
+ // A callable whose own text is unchanged is not
2391
+ // what the deleted line changed (fix #434).
2392
+ if (!(bestOld.sourceSpan?.startLine != null && bestOld.sourceSpan.startLine <= line &&
2393
+ line <= bestOld.sourceSpan.endLine && unchangedCallable(cur))) {
2394
+ const key = `${cur.name}:${cur.startLine}`;
2395
+ if (!affectedSymbols.has(key)) {
2396
+ affectedSymbols.set(key, { symbol: cur, addedLines: [], deletedLines: [] });
2397
+ }
2398
+ affectedSymbols.get(key).deletedLines.push(line);
2272
2399
  }
2273
- affectedSymbols.get(key).deletedLines.push(line);
2274
2400
  }
2275
2401
  // Identity gone → the whole function was deleted; the
2276
2402
  // deletion detection below reports it. Its body lines are
@@ -2509,8 +2635,15 @@ function diffImpact(index, options = {}) {
2509
2635
  const { getCachedCalls } = require('./callers');
2510
2636
  for (const del of deletedFunctions) {
2511
2637
  const remaining = [];
2512
- const calleeFiles = index.getCalleeFiles(del.name);
2513
- if (calleeFiles) {
2638
+ // A snapshot: deriving a file's macro-expansion calls re-lists it
2639
+ // in the callee index. Files whose expansions' calls are not
2640
+ // derived yet and whose expanded text holds the name join it
2641
+ // (fix #405).
2642
+ const calleeFiles = new Set(index.getCalleeFiles(del.name) || []);
2643
+ for (const f of require('./rust-macro-expansion').rustPendingFilesBearing(index, [del.name])) {
2644
+ calleeFiles.add(f);
2645
+ }
2646
+ if (calleeFiles.size > 0) {
2514
2647
  for (const f of calleeFiles) {
2515
2648
  const calls = getCachedCalls(index, f);
2516
2649
  if (!Array.isArray(calls)) continue;
@@ -2546,8 +2679,10 @@ function diffImpact(index, options = {}) {
2546
2679
  totalCallSites += evidence.totalCallSites || 0;
2547
2680
  totalUnverifiedSites += evidence.unverifiedSites?.length || 0;
2548
2681
  for (const group of evidence.byFile || []) callerFileSet.add(path.resolve(index.root, group.file));
2549
- const refs = evidence.typeReferences || evidence.propertyAccesses;
2550
- totalDependencySites += refs?.confirmedCount || 0;
2682
+ const refs = evidence.typeReferences || evidence.propertyAccesses || evidence.valueAccesses;
2683
+ // Non-call sites only: a value band also lists the call tokens.
2684
+ totalDependencySites += refs && Number.isInteger(evidence.totalDependencySites)
2685
+ ? Math.max(0, evidence.totalDependencySites - (evidence.totalCallSites || 0)) : refs?.confirmedCount || 0;
2551
2686
  unverifiedDependencySites += refs?.unverifiedCount || 0;
2552
2687
  for (const group of refs?.byFile || []) dependencyFiles.add(group.file);
2553
2688
  for (const site of refs?.unverifiedSites || []) dependencyFiles.add(site.file);
@@ -2602,7 +2737,7 @@ function collectNonCallableChanges(index, change, oldParsed, currentSymbols, out
2602
2737
  before.get(key).push(s);
2603
2738
  }
2604
2739
  const touches = (s, lines) => lines.some(line => line >= s.startLine && line <= (s.endLine || s.startLine));
2605
- const language = detectLanguage(change.filePath);
2740
+ const language = index.languageOf(change.filePath);
2606
2741
  let currentSnapshots = new Map();
2607
2742
  let oldSnapshots = new Map();
2608
2743
  // Unsupported/recovered declaration shapes retain conservative range
@@ -2690,13 +2825,15 @@ function extractCallableSymbols(parsed) {
2690
2825
  const signature = s => ({ params: s.params, paramsStructured: s.paramsStructured, generics: s.generics });
2691
2826
  for (const fn of parsed.functions) {
2692
2827
  const className = fn.className || (fn.receiver ? fn.receiver.replace(/^\*/, '') : '');
2693
- symbols.push({ name: fn.name, className, startLine: fn.startLine, endLine: fn.endLine, ...signature(fn) });
2828
+ symbols.push({ name: fn.name, className, startLine: fn.startLine, endLine: fn.endLine, ...signature(fn),
2829
+ ...(fn.sourceSpan && { sourceSpan: fn.sourceSpan }) });
2694
2830
  }
2695
2831
  for (const cls of parsed.classes) {
2696
2832
  if (cls.members) {
2697
2833
  for (const m of cls.members) {
2698
2834
  if (!isDiffCallable(m)) continue;
2699
- symbols.push({ name: m.name, className: cls.name, startLine: m.startLine, endLine: m.endLine, ...signature(m) });
2835
+ symbols.push({ name: m.name, className: cls.name, startLine: m.startLine, endLine: m.endLine, ...signature(m),
2836
+ ...(m.sourceSpan && { sourceSpan: m.sourceSpan }) });
2700
2837
  }
2701
2838
  }
2702
2839
  }
@@ -3561,4 +3698,6 @@ module.exports = {
3561
3698
  // Shared with tracing.js (trace/blast/reverseTrace root accounts) — the
3562
3699
  // tree commands compose the same text-ground account at their root hop.
3563
3700
  composeAccount,
3701
+ // Test discovery shares impact's per-token type-reference evidence.
3702
+ findTypeReferences,
3564
3703
  };
@@ -37,7 +37,10 @@ const DECLARATION_NODES = {
37
37
  trait: new Set(['trait_item']),
38
38
  impl: new Set(['impl_item']),
39
39
  record: new Set(['record_declaration']),
40
- field: new Set(['field_definition', 'public_field_definition', 'property_signature', 'field_declaration', 'variable_declarator']),
40
+ // An instance attribute a method assigns on its receiver (`this.v = v`,
41
+ // `self.v = v`) is declared by that assignment (fix #434).
42
+ field: new Set(['field_definition', 'public_field_definition', 'property_signature', 'field_declaration', 'variable_declarator',
43
+ 'assignment_expression', 'assignment']),
41
44
  state: new Set(['variable_declarator', 'assignment', 'init_declarator', 'const_item', 'static_item', 'const_spec', 'var_spec']),
42
45
  };
43
46
  const FUNCTION_EXPRESSIONS = new Set([
@@ -72,7 +75,9 @@ function declarationSnapshots(content, language, symbols) {
72
75
  if (!node) return false;
73
76
  if (node.text === name) return true;
74
77
  return hasName(node.childForFieldName('name') || node.childForFieldName('declarator') ||
75
- (node.type === 'generic_type' && node.childForFieldName('type')), name);
78
+ (node.type === 'generic_type' && node.childForFieldName('type')) ||
79
+ (['member_expression', 'attribute'].includes(node.type) &&
80
+ (node.childForFieldName('property') || node.childForFieldName('attribute'))), name);
76
81
  };
77
82
  for (const symbol of symbols) {
78
83
  const kinds = DECLARATION_NODES[symbol.type];
@@ -400,7 +405,11 @@ function projectComputedDispatch(index) {
400
405
  const byFile = new Map();
401
406
  for (const [filePath, fileEntry] of index.files) {
402
407
  try {
403
- const sites = computedDispatchSites(index._readFile(filePath), fileEntry.language);
408
+ // Extracted at index time from the file's literal parse (fix
409
+ // #436); an entry without them is read here.
410
+ const sites = Array.isArray(fileEntry.computedDispatchSites)
411
+ ? fileEntry.computedDispatchSites.map(site => ({ ...site }))
412
+ : computedDispatchSites(index._readFile(filePath), fileEntry.language);
404
413
  if (sites.length > 0) byFile.set(filePath, sites);
405
414
  } catch (_) {
406
415
  // Unreadable files are reported through the existing parse/read