ucn 5.4.2 → 5.5.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.
Files changed (85) hide show
  1. package/.claude/skills/ucn/SKILL.md +58 -236
  2. package/.claude/skills/ucn/references/commands.md +144 -68
  3. package/.claude/skills/ucn/references/resolution-rules.md +171 -0
  4. package/.claude/skills/ucn/references/trust-contract.md +27 -33
  5. package/README.md +18 -12
  6. package/cli/index.js +40 -14
  7. package/core/accessors.js +118 -3
  8. package/core/account.js +30 -0
  9. package/core/analysis.js +508 -264
  10. package/core/ast-analysis.js +76 -117
  11. package/core/async-producers.js +1729 -0
  12. package/core/bridge.js +1877 -268
  13. package/core/brief.js +6 -0
  14. package/core/build-worker.js +78 -14
  15. package/core/cache.js +522 -31
  16. package/core/callers.js +12053 -1047
  17. package/core/check.js +30 -38
  18. package/core/class-identity.js +1028 -0
  19. package/core/command-contracts.js +6 -6
  20. package/core/configuration-items.js +86 -0
  21. package/core/contract-membership.js +2262 -0
  22. package/core/cpp-scope.js +532 -0
  23. package/core/deadcode.js +171 -49
  24. package/core/decorator-binding.js +260 -0
  25. package/core/discovery.js +281 -164
  26. package/core/errors.js +52 -0
  27. package/core/execute.js +21 -9
  28. package/core/external-macros.js +326 -0
  29. package/core/graph-build.js +277 -8
  30. package/core/graph.js +35 -2
  31. package/core/ignore-rules.js +472 -0
  32. package/core/imports.js +504 -42
  33. package/core/index-ir.js +94 -11
  34. package/core/inprocess-clients.js +636 -0
  35. package/core/ir.js +87 -8
  36. package/core/macro-expansion.js +1416 -0
  37. package/core/output/analysis.js +18 -2
  38. package/core/output/brief.js +3 -0
  39. package/core/output/doctor.js +4 -0
  40. package/core/output/endpoints.js +15 -6
  41. package/core/output/find.js +7 -2
  42. package/core/output/refactoring.js +121 -14
  43. package/core/output/reporting.js +31 -3
  44. package/core/output/shared.js +14 -0
  45. package/core/output-budget.js +305 -122
  46. package/core/parallel-build.js +124 -47
  47. package/core/parser.js +3 -2
  48. package/core/project.js +457 -60
  49. package/core/provenance-facts.js +156 -12
  50. package/core/provenance-overload.js +30 -3
  51. package/core/provenance.js +104 -30
  52. package/core/pytest-fixtures.js +434 -0
  53. package/core/python-fixture-flow.js +17 -3
  54. package/core/python-modules.js +173 -0
  55. package/core/reflection.js +877 -0
  56. package/core/registry.js +1 -1
  57. package/core/reporting.js +234 -52
  58. package/core/request-helpers.js +561 -0
  59. package/core/route-graph.js +1922 -0
  60. package/core/route-lists.js +2414 -0
  61. package/core/rust-macro-expansion.js +2113 -0
  62. package/core/rust-modules.js +185 -0
  63. package/core/search.js +121 -29
  64. package/core/shared.js +19 -1
  65. package/core/test-name-bindings.js +256 -0
  66. package/core/type-denotation.js +1161 -0
  67. package/core/type-references.js +3229 -0
  68. package/core/verify.js +3360 -672
  69. package/languages/adapter.js +31 -5
  70. package/languages/c-family.js +4061 -212
  71. package/languages/c-macro-invocations.js +380 -0
  72. package/languages/c-preprocessor.js +254 -0
  73. package/languages/csharp.js +1406 -49
  74. package/languages/go.js +1243 -70
  75. package/languages/index.js +728 -6
  76. package/languages/java.js +473 -61
  77. package/languages/javascript.js +1270 -197
  78. package/languages/lexical-scope.js +1280 -0
  79. package/languages/python.js +1557 -72
  80. package/languages/rust-macro-rules.js +1020 -0
  81. package/languages/rust-value-flow.js +416 -0
  82. package/languages/rust.js +2237 -115
  83. package/languages/utils.js +298 -4
  84. package/mcp/server.js +22 -8
  85. package/package.json +1 -1
package/core/verify.js CHANGED
@@ -7,9 +7,29 @@
7
7
 
8
8
  const { detectLanguage, getParser, getLanguageAdapter, safeParse, langTraits } = require('../languages');
9
9
  const { sameNode } = require('../languages/utils');
10
- const { escapeRegExp, codeUnitCompare, NON_CALLABLE_TYPES } = require('./shared');
11
- const { findAccessorReferences } = require('./accessors');
10
+ const { escapeRegExp, codeUnitCompare, NON_CALLABLE_TYPES, isOverrideMarked, isMacroNamespaceDefinition } = require('./shared');
11
+ const { classDefsNamed, distinctOwnerDefinitions } = require('./class-identity');
12
+ const { isTypeRenamePin } = require('./type-references');
13
+ const { findAccessorReferences, isAccessorDefinition } = require('./accessors');
12
14
  const { validateCallMismatch, declarationIdentity } = require('./provenance');
15
+ const { UcnError } = require('./errors');
16
+ const { execFileSync, spawn } = require('child_process');
17
+ const path = require('path');
18
+ const fs = require('fs');
19
+ const os = require('os');
20
+
21
+ /**
22
+ * The string index of a token that a parser column points at. The
23
+ * tree-sitter binding reports columns of string input in UTF-16 code units;
24
+ * a column that does not spell the token there is read as a UTF-8 byte
25
+ * column (fix #386: tokens after a non-ASCII character on the same line
26
+ * were skipped).
27
+ */
28
+ function tokenColumn(line, column, token) {
29
+ if (!Number.isInteger(column) || column < 0) return null;
30
+ if (token && line.slice(column, column + token.length) === token) return column;
31
+ return codeUnitColumnForByteColumn(line, column);
32
+ }
13
33
 
14
34
  function codeUnitColumnForByteColumn(line, byteColumn) {
15
35
  if (!Number.isInteger(byteColumn) || byteColumn < 0) return null;
@@ -25,6 +45,101 @@ function codeUnitColumnForByteColumn(line, byteColumn) {
25
45
  return null;
26
46
  }
27
47
 
48
+ /**
49
+ * Choose the declaration's own name token among name-field identifiers on a
50
+ * definition line (fix #359). Candidates carry `{column, decl}` where decl is
51
+ * the node whose name/declarator field the identifier is.
52
+ * 1. The declaration must end where the indexed definition ends (when the
53
+ * definition's endLine is known): a multi-line body's nested parameter
54
+ * or type declarations end earlier.
55
+ * 2. A member definition (className set) whose class lexically encloses it
56
+ * binds the candidate INSIDE that class (a one-line `class A { A() {} }`
57
+ * or `type T struct { T int }` pins the member, not the container).
58
+ * 3. Otherwise the outermost declaration wins: parameters, named results
59
+ * and qualified type names are always nested inside the declaration
60
+ * they belong to.
61
+ */
62
+ function pickDeclarationNameToken(candidates, definition) {
63
+ if (!candidates || candidates.length === 0) return null;
64
+ let pool = candidates;
65
+ if (definition && Number.isInteger(definition.endLine)) {
66
+ const spanMatch = pool.filter(c => c.decl.endPosition.row + 1 === definition.endLine);
67
+ if (spanMatch.length > 0) pool = spanMatch;
68
+ }
69
+ const className = definition && definition.className;
70
+ if (className) {
71
+ const inside = pool.filter(c => {
72
+ for (let anc = c.decl.parent, depth = 0; anc && depth < 12;
73
+ anc = anc.parent, depth++) {
74
+ const ancName = anc.childForFieldName?.('name');
75
+ if (ancName && ancName.text === className &&
76
+ !sameNode(anc, c.decl)) return true;
77
+ }
78
+ return false;
79
+ });
80
+ if (inside.length > 0) pool = inside;
81
+ }
82
+ let best = null;
83
+ for (const c of pool) {
84
+ if (!best) { best = c; continue; }
85
+ const a = c.decl, b = best.decl;
86
+ if (a.startIndex <= b.startIndex && a.endIndex >= b.endIndex &&
87
+ (a.startIndex < b.startIndex || a.endIndex > b.endIndex)) {
88
+ best = c;
89
+ } else if (!(b.startIndex <= a.startIndex && b.endIndex >= a.endIndex) &&
90
+ c.column < best.column) {
91
+ // Disjoint declarations: earliest on the line.
92
+ best = c;
93
+ }
94
+ }
95
+ return best ? best.column : null;
96
+ }
97
+
98
+ /**
99
+ * Byte columns of `NAME` tokens on one line that are member accesses on the
100
+ * enclosing instance or class receiver (`this.NAME`, `self.NAME`,
101
+ * `cls.NAME`, `Self::NAME`), by AST: the token is the member part of its
102
+ * parent node and the parent's receiver part is a receiver keyword of the
103
+ * language (traits.selfParam) or a `this`/`self` node (fix #376).
104
+ */
105
+ function selfMemberTokenColumns(index, filePath, lineNumber, name) {
106
+ const path = require('path');
107
+ const absolute = path.isAbsolute(filePath) ? filePath : path.join(index.root, filePath);
108
+ const language = index.files.get(absolute)?.language;
109
+ const parser = language && getParser(language);
110
+ if (!parser) return [];
111
+ let content;
112
+ try { content = index._readFile(absolute); } catch { return []; }
113
+ const tree = index._getParsedTree?.(absolute, content, language) || safeParse(parser, content);
114
+ if (!tree) return [];
115
+ const receivers = new Set([...(langTraits(language)?.selfParam || []), 'this', 'self', 'Self']);
116
+ const row = lineNumber - 1;
117
+ const out = [];
118
+ const stack = [tree.rootNode];
119
+ while (stack.length > 0) {
120
+ const node = stack.pop();
121
+ if (node.endPosition.row < row || node.startPosition.row > row) continue;
122
+ if (node.startPosition.row === row && node.text === name &&
123
+ /identifier$/.test(node.type) && node.parent) {
124
+ const parent = node.parent;
125
+ const receiver = parent.namedChild(0);
126
+ if (receiver && !sameNode(receiver, node) &&
127
+ parent.namedChildCount === 2 && sameNode(parent.namedChild(1), node) &&
128
+ receivers.has(receiver.text.replace(/\s+/g, ''))) {
129
+ out.push(node.startPosition.column);
130
+ } else if (parent.type === 'member_access_expression' &&
131
+ sameNode(parent.childForFieldName('name'), node) &&
132
+ receivers.has(String(parent.childForFieldName('expression')?.text || '').replace(/\s+/g, ''))) {
133
+ // C# `this.m`: the keyword receiver is an anonymous node.
134
+ out.push(node.startPosition.column);
135
+ }
136
+ continue;
137
+ }
138
+ for (let i = 0; i < node.namedChildCount; i++) stack.push(node.namedChild(i));
139
+ }
140
+ return out;
141
+ }
142
+
28
143
  /** Replace only AST identifier tokens on one source line. */
29
144
  function renameIdentifierTokens(index, filePath, lineNumber, oldName, newName,
30
145
  preferredByteColumns = null, expectedCallCount = null, tokenOptions = {}) {
@@ -77,9 +192,15 @@ function renameIdentifierTokens(index, filePath, lineNumber, oldName, newName,
77
192
  // Wrapper identity via sameNode (#233 — wrappers are
78
193
  // not reference-stable across walks).
79
194
  if (tokenOptions.definitionNameOnly && node.parent) {
80
- const nameChild = node.parent.childForFieldName?.('name');
81
- if (nameChild && sameNode(nameChild, node)) {
82
- defNameByteColumns.push(node.startPosition.column);
195
+ const decl = node.parent;
196
+ const nameChild = decl.childForFieldName?.('name');
197
+ const declaratorChild = decl.childForFieldName?.('declarator');
198
+ if ((nameChild && sameNode(nameChild, node)) ||
199
+ (declaratorChild && sameNode(declaratorChild, node))) {
200
+ defNameByteColumns.push({
201
+ column: node.startPosition.column,
202
+ decl,
203
+ });
83
204
  }
84
205
  }
85
206
  }
@@ -95,14 +216,20 @@ function renameIdentifierTokens(index, filePath, lineNumber, oldName, newName,
95
216
  // BuildVarsFunc) *Route`) — renaming every token breaks the type
96
217
  // reference. Rename only the definition's own NAME token (AST name field;
97
218
  // first occurrence as fallback) — javac/gopls rename semantics.
219
+ //
220
+ // Declaration identity (fix #359, gin-measured): several identifiers on
221
+ // a definition line can be their parent's name field (a Go named result
222
+ // `(head string)`, a qualified return type `http.Pusher`, a Java/C
223
+ // parameter declarator). The definition token is the name of the
224
+ // symbol's OWN declaration node, never a nested declaration's name.
98
225
  if (tokenOptions.definitionNameOnly && byteColumns.length > 1) {
99
- byteColumns = defNameByteColumns.length > 0
100
- ? [defNameByteColumns[0]]
101
- : [Math.min(...byteColumns)];
226
+ const chosen = pickDeclarationNameToken(defNameByteColumns,
227
+ tokenOptions.definition || null);
228
+ byteColumns = chosen != null ? [chosen] : [Math.min(...byteColumns)];
102
229
  }
103
230
 
104
231
  const columns = [...new Set(byteColumns
105
- .map(column => codeUnitColumnForByteColumn(sourceLine, column))
232
+ .map(column => tokenColumn(sourceLine, column, oldName))
106
233
  .filter(Number.isInteger))].sort((a, b) => b - a);
107
234
  if (expectedCallCount != null && columns.length !== expectedCallCount) {
108
235
  return { source: sourceLine.trim(), renamed: sourceLine.trim(), count: 0 };
@@ -113,7 +240,102 @@ function renameIdentifierTokens(index, filePath, lineNumber, oldName, newName,
113
240
  renamed = renamed.slice(0, column) + newName +
114
241
  renamed.slice(column + oldName.length);
115
242
  }
116
- return { source: sourceLine.trim(), renamed: renamed.trim(), count: columns.length };
243
+ return {
244
+ source: sourceLine.trim(), renamed: renamed.trim(), count: columns.length,
245
+ columns, rawSource: sourceLine, oldName, newName,
246
+ };
247
+ }
248
+
249
+ /**
250
+ * The column of the called name inside one call's source range (fix #392):
251
+ * two same-name calls on a line (`a.run(b.run())`) are two tokens, and the
252
+ * record of each call spans its own call node. Returns null when the range
253
+ * does not resolve to one name token on the line.
254
+ */
255
+ function callTokenColumn(index, filePath, range, name, line) {
256
+ if (!range || !filePath) return null;
257
+ const absolute = require('path').isAbsolute(filePath)
258
+ ? filePath : require('path').join(index.root, filePath);
259
+ const language = index.files.get(absolute)?.language;
260
+ const parser = language && getParser(language);
261
+ if (!parser) return null;
262
+ let content;
263
+ try { content = index._readFile(absolute); } catch { return null; }
264
+ const tree = index._getParsedTree?.(absolute, content, language) || safeParse(parser, content);
265
+ if (!tree) return null;
266
+ let node = tree.rootNode.descendantForIndex(range.start, Math.max(range.start, range.end - 1));
267
+ while (node && !(node.startIndex <= range.start && node.endIndex >= range.end)) node = node.parent;
268
+ if (!node) return null;
269
+ // A call spelled inside a Python string annotation (fix #398F): the
270
+ // parser read it from the annotation's expression; the range is the
271
+ // called name inside the string's content.
272
+ if (node.type === 'string_content' && content.slice(range.start, range.end) === name &&
273
+ node.startPosition.row === line - 1 && node.endPosition.row === line - 1) {
274
+ return node.startPosition.column + (range.start - node.startIndex);
275
+ }
276
+ const isName = n => n && n.text === name && /identifier$/.test(n.type) &&
277
+ n.startPosition.row === line - 1;
278
+ if (isName(node)) return node.startPosition.column;
279
+ // The callee part of the call node: its last same-name token outside
280
+ // the argument list names the call (`x.run(y.run())` -> the outer run).
281
+ const callee = node.childForFieldName?.('function') || node.childForFieldName?.('name') ||
282
+ node.childForFieldName?.('method') || node.childForFieldName?.('constructor') ||
283
+ node.childForFieldName?.('macro') || null;
284
+ if (!callee) return null;
285
+ if (isName(callee)) return callee.startPosition.column;
286
+ const named = ['property', 'attribute', 'field', 'name'];
287
+ for (const field of named) {
288
+ const part = callee.childForFieldName?.(field);
289
+ if (isName(part)) return part.startPosition.column;
290
+ }
291
+ return null;
292
+ }
293
+
294
+ /**
295
+ * Merge a second token edit into an existing change on the same line
296
+ * (fix #376): a one-line method whose body calls a same-named slot member
297
+ * (`@Override public String m() { return delegate.m(); }`) needs its
298
+ * declaration token and its call token renamed in ONE line edit. Changes
299
+ * carry their token edit as a non-enumerable `_edit`.
300
+ */
301
+ function mergeTokenEdit(change, edit) {
302
+ const prior = change?._edit;
303
+ if (!prior || !edit || prior.rawSource !== edit.rawSource ||
304
+ prior.oldName !== edit.oldName || prior.newName !== edit.newName) return false;
305
+ const columns = [...new Set([...prior.columns, ...edit.columns])].sort((a, b) => b - a);
306
+ if (columns.length === prior.columns.length) return true;
307
+ let renamed = prior.rawSource;
308
+ for (const column of columns) {
309
+ if (renamed.slice(column, column + prior.oldName.length) !== prior.oldName) continue;
310
+ renamed = renamed.slice(0, column) + prior.newName + renamed.slice(column + prior.oldName.length);
311
+ }
312
+ const previous = change.newExpression;
313
+ change.newExpression = renamed.trim();
314
+ if (typeof change.suggestion === 'string' && previous) {
315
+ change.suggestion = change.suggestion.replace(previous, change.newExpression);
316
+ }
317
+ tagTokenEdit(change, { ...prior, columns, renamed: change.newExpression });
318
+ return true;
319
+ }
320
+
321
+ /**
322
+ * Mark a change whose tokens a dedicated pass proved (a macro argument that
323
+ * declares the renamed class, an exactly resolved using-declaration): the
324
+ * type-reference pass keeps them even where its own lookup is unsure.
325
+ */
326
+ function markEvidence(change) {
327
+ Object.defineProperty(change, '_evidence', {
328
+ value: true, enumerable: false, writable: true, configurable: true,
329
+ });
330
+ return change;
331
+ }
332
+
333
+ function tagTokenEdit(change, edit) {
334
+ if (!change || !edit || !Array.isArray(edit.columns)) return change;
335
+ Object.defineProperty(change, '_edit', {
336
+ value: edit, enumerable: false, writable: true, configurable: true,
337
+ });
338
+ return change;
117
339
  }
118
340
 
119
341
  // ============================================================================
@@ -335,8 +557,20 @@ function _collectCallNodes(node, callTypes, targetRow, funcName, limit, out = []
335
557
 
336
558
  // Recurse into children — nested same-name calls (`greet(greet(x))`)
337
559
  // are separate records, so a match's children are still scanned.
338
- for (let i = 0; i < node.childCount; i++) {
339
- _collectCallNodes(node.child(i), callTypes, targetRow, funcName, limit, out);
560
+ // Children are in source order: binary-search the first one that can
561
+ // reach the target row instead of visiting every earlier sibling.
562
+ const count = node.childCount;
563
+ let lo = 0;
564
+ let hi = count;
565
+ while (lo < hi) {
566
+ const mid = (lo + hi) >> 1;
567
+ if (node.child(mid).endPosition.row < targetRow) lo = mid + 1;
568
+ else hi = mid;
569
+ }
570
+ for (let i = lo; i < count; i++) {
571
+ const child = node.child(i);
572
+ if (child.startPosition.row > targetRow) break;
573
+ _collectCallNodes(child, callTypes, targetRow, funcName, limit, out);
340
574
  if (out.length >= limit) return out;
341
575
  }
342
576
  return out;
@@ -712,7 +946,7 @@ function _constructorParamLists(index, def, lang) {
712
946
  * @param {object} def - Resolved definition (pinned target)
713
947
  * @returns {{ confirmed: Array, unverified: Array, account: object }}
714
948
  */
715
- function contractedCallerSweep(index, name, def) {
949
+ function contractedCallerSweep(index, name, def, options = {}) {
716
950
  const rawCallers = index.findCallers(name, {
717
951
  includeMethods: true,
718
952
  targetDefinitions: [def],
@@ -728,11 +962,38 @@ function contractedCallerSweep(index, name, def) {
728
962
  for (const u of rawCallers.unverifiedEntries || []) {
729
963
  unverified.push(u);
730
964
  }
965
+ // Call sites that bound the definition's base declaration and that the
966
+ // sweep of the changed declaration excludes (fix #394): they leave the
967
+ // excluded bucket and join the unverified band, to be checked against
968
+ // the new signature.
969
+ const rebound = options.baseChanges
970
+ ? sitesReboundByChange(index, name, def, rawCallers, confirmed, unverified, options.baseChanges)
971
+ : [];
972
+ if (rebound.length > 0 && rawCallers.accountRaw) {
973
+ const moved = new Set(rebound.filter(site => !site.stillUnverified)
974
+ .map(site => `${site.file}\0${site.line}`));
975
+ rawCallers.accountRaw = {
976
+ ...rawCallers.accountRaw,
977
+ excludedEntries: rawCallers.accountRaw.excludedEntries.filter(entry =>
978
+ !moved.has(`${entry.file}\0${entry.line}`)),
979
+ };
980
+ for (const site of rebound) {
981
+ // A site already in the band is replaced by its checked record.
982
+ const at = site.stillUnverified ? unverified.findIndex(u => u.file === site.file &&
983
+ u.line === site.line && !u.rebind && (site.column == null || u.column == null || u.column === site.column)) : -1;
984
+ if (at >= 0) unverified[at] = site;
985
+ else unverified.push(site);
986
+ }
987
+ }
731
988
 
732
989
  // Conservation account from the sweep's claims (impact's manual
733
990
  // composition).
734
991
  const { computeGroundSet, buildAccount } = require('./account');
735
- const groundSet = computeGroundSet(index, name);
992
+ let groundSet = index._opGroundSetCache?.get(name);
993
+ if (!groundSet) {
994
+ groundSet = computeGroundSet(index, name);
995
+ index._opGroundSetCache?.set(name, groundSet);
996
+ }
736
997
  const accountRaw = rawCallers.accountRaw || { unverifiedLines: [], excludedEntries: [] };
737
998
  const confirmedEntries = confirmed.map(c => ({ file: c.file, line: c.line }));
738
999
  const unverifiedEntries = [
@@ -760,7 +1021,295 @@ function contractedCallerSweep(index, name, def) {
760
1021
  return (a.line || 0) - (b.line || 0);
761
1022
  });
762
1023
 
763
- return { confirmed, unverified, account, groundSet };
1024
+ return {
1025
+ confirmed, unverified, account, groundSet, rebound, baseChanges: rebound.baseChanges || null,
1026
+ accountParts: { confirmedEntries, unverifiedEntries, excludedEntries: accountRaw.excludedEntries },
1027
+ };
1028
+ }
1029
+
1030
+ // ── Signature changes against a base revision (fix #394) ─────────────────
1031
+ //
1032
+ // The caller engine binds call sites the way the compiler does for code
1033
+ // that compiles: after `isEmpty(String)` became `isEmpty(String, boolean)`,
1034
+ // `Util.isEmpty(s)` fits no parameter list of the pin and the sibling
1035
+ // `isEmpty(Object[])` takes one argument, so the site is excluded as an
1036
+ // arity/overload mismatch - exactly the site `check` exists to report. The
1037
+ // base declaration decides which sites bound the definition before the
1038
+ // change; each is then judged against the new signature and the remaining
1039
+ // overloads.
1040
+
1041
+ // Exclusions whose verdict depends on the pin's declared parameters.
1042
+ const SIGNATURE_DEPENDENT_EXCLUSIONS = new Set(['arity-mismatch', 'overload-mismatch', 'generic-arity-mismatch']);
1043
+
1044
+ /** Declared parameter shape (types, optional/default/rest, method type parameters); names ignored. */
1045
+ function declaredSignatureKey(symbol) {
1046
+ const ps = symbol?.paramsStructured;
1047
+ const params = Array.isArray(ps)
1048
+ ? ps.map(p => [String(p?.type ?? '').replace(/\s+/g, ''), p?.optional ? 'o' : '',
1049
+ p?.default !== undefined ? 'd' : '', p?.rest ? 'r' : '', p?.type ? '' : String(p?.name ?? '')].join(':')).join(',')
1050
+ : `raw:${String(symbol?.params ?? '').replace(/\s+/g, '')}`;
1051
+ return `${params}|${String(symbol?.generics ?? '').replace(/\s+/g, '')}`;
1052
+ }
1053
+
1054
+ /**
1055
+ * The definition (and, for C/C++, its declarations elsewhere) whose declared
1056
+ * parameters differ at `base`: [{ def, before }] with `before` the base
1057
+ * declaration of the same name and owner, or [] (no git, untracked, file
1058
+ * unchanged, parameters unchanged, or a new overload).
1059
+ */
1060
+ const GIT_REF_FORMAT = /^[a-zA-Z0-9._\-~\/^@{}:]+$/; // eslint-disable-line no-useless-escape
1061
+
1062
+ /**
1063
+ * Start reading `file` at `base` in the background (a shell running `git
1064
+ * show` into a temporary file), so the read overlaps the caller sweep of the
1065
+ * current declaration; null where that is not available.
1066
+ */
1067
+ let baseReadSerial = 0;
1068
+
1069
+ function startBaseRead(file, base) {
1070
+ if (process.platform === 'win32' || !file || !GIT_REF_FORMAT.test(base)) return null;
1071
+ const out = path.join(os.tmpdir(),
1072
+ `ucn-base-${process.pid}-${++baseReadSerial}-${Math.random().toString(36).slice(2, 10)}`);
1073
+ let child;
1074
+ try {
1075
+ // The content appears under `out` only once complete; `out.none`
1076
+ // marks a revision that does not hold the file.
1077
+ child = spawn('/bin/sh', ['-c',
1078
+ 'git show "$1" > "$2.part" 2>/dev/null && mv "$2.part" "$2" || { rm -f "$2.part"; : > "$2.none"; }',
1079
+ 'ucn-base', `${base}:./${path.basename(file)}`, out],
1080
+ { cwd: path.dirname(file), stdio: 'ignore' });
1081
+ } catch {
1082
+ child = null;
1083
+ }
1084
+ if (!child?.pid) return null;
1085
+ child.on('error', () => {});
1086
+ child.unref();
1087
+ return { file, out };
1088
+ }
1089
+
1090
+ /** Throw a UcnError unless `base` names a commit of the repository holding `file`. */
1091
+ function assertGitCommit(file, base) {
1092
+ try {
1093
+ execFileSync('git', ['rev-parse', '--show-toplevel'], {
1094
+ cwd: path.dirname(file), stdio: ['ignore', 'pipe', 'ignore'],
1095
+ });
1096
+ } catch (e) {
1097
+ throw new UcnError('Not a git repository. check --base requires git.', { cause: e });
1098
+ }
1099
+ try {
1100
+ execFileSync('git', ['rev-parse', '--verify', '--quiet', '--end-of-options', `${base}^{commit}`], {
1101
+ cwd: path.dirname(file), stdio: ['ignore', 'pipe', 'ignore'],
1102
+ });
1103
+ } catch (e) {
1104
+ throw new UcnError(`Unknown git ref: ${base}`, { cause: e });
1105
+ }
1106
+ }
1107
+
1108
+ /** The content a background read produced: a string, null (not at base), or undefined (no answer in time). */
1109
+ function finishBaseRead(handle, timeoutMs = 15000) {
1110
+ const cell = new Int32Array(new SharedArrayBuffer(4));
1111
+ const started = Date.now();
1112
+ const none = `${handle.out}.none`;
1113
+ try {
1114
+ for (;;) {
1115
+ if (fs.existsSync(handle.out)) return fs.readFileSync(handle.out, 'utf-8');
1116
+ if (fs.existsSync(none)) return null;
1117
+ if (Date.now() - started > timeoutMs) return undefined;
1118
+ Atomics.wait(cell, 0, 0, 1);
1119
+ }
1120
+ } catch {
1121
+ return undefined;
1122
+ } finally {
1123
+ handle.done = true;
1124
+ for (const leftover of [handle.out, none]) {
1125
+ try { fs.unlinkSync(leftover); } catch { /* not created */ }
1126
+ }
1127
+ }
1128
+ }
1129
+
1130
+ function baseDeclarationChanges(index, name, def, base = 'HEAD', baseRead = null) {
1131
+ if (!GIT_REF_FORMAT.test(base)) {
1132
+ throw new UcnError(`Invalid git ref format: ${base}`);
1133
+ }
1134
+ const language = index.files.get(def.file)?.language;
1135
+ let members = [def];
1136
+ if (language === 'c' || language === 'cpp') {
1137
+ const { _closeCallableIdentityGroup } = require('./callers');
1138
+ members = _closeCallableIdentityGroup(index, [def], index.symbols.get(name) || [def]);
1139
+ }
1140
+ const byFile = new Map();
1141
+ for (const member of members) {
1142
+ if (!member.file || !index.files.has(member.file)) continue;
1143
+ if (!byFile.has(member.file)) byFile.set(member.file, []);
1144
+ byFile.get(member.file).push(member);
1145
+ }
1146
+ const { extractCallableSymbols } = require('./analysis');
1147
+ const changes = [];
1148
+ for (const [file, defs] of [...byFile].sort(([a], [b]) => codeUnitCompare(a, b))) {
1149
+ let baseContent = baseRead && !baseRead.done && baseRead.file === file ? finishBaseRead(baseRead) : undefined;
1150
+ if (baseContent === null) continue;
1151
+ if (baseContent === undefined) {
1152
+ try {
1153
+ baseContent = execFileSync('git', ['show', `${base}:./${path.basename(file)}`], {
1154
+ cwd: path.dirname(file), encoding: 'utf-8', maxBuffer: 256 * 1024 * 1024,
1155
+ stdio: ['ignore', 'pipe', 'ignore'],
1156
+ });
1157
+ } catch {
1158
+ continue;
1159
+ }
1160
+ }
1161
+ // Unchanged since base: the index's content hash of the file says so
1162
+ // without reading it again.
1163
+ const indexedHash = index.files.get(file)?.hash;
1164
+ if (indexedHash && require('crypto').createHash('md5').update(baseContent).digest('hex') === indexedHash) continue;
1165
+ if (!indexedHash) {
1166
+ let current;
1167
+ try { current = index._readFile(file); } catch { continue; }
1168
+ if (baseContent === current) continue;
1169
+ }
1170
+ const entry = index.files.get(file);
1171
+ let baseSymbols;
1172
+ try {
1173
+ baseSymbols = extractCallableSymbols(require('./parser').parse(baseContent, entry.language));
1174
+ } catch {
1175
+ continue;
1176
+ }
1177
+ for (const d of defs) {
1178
+ const before = baseCounterpartOf(entry.symbols, baseSymbols, d);
1179
+ if (before) changes.push({ def: d, before });
1180
+ }
1181
+ }
1182
+ return changes;
1183
+ }
1184
+
1185
+ /**
1186
+ * The base declaration of `d` (same name and owner) when its declared
1187
+ * parameters changed, else null: base overloads a current sibling still
1188
+ * declares are that sibling; among the rest the nearest line wins.
1189
+ */
1190
+ function baseCounterpartOf(currentSymbols, baseSymbols, d) {
1191
+ const identity = symbol => `${symbol.name}\0${symbol.className || ''}`;
1192
+ const callable = symbol => {
1193
+ const type = symbol.type || symbol.memberType || 'method';
1194
+ return !NON_CALLABLE_TYPES.has(type) || (type === 'field' && symbol.isMethod);
1195
+ };
1196
+ const same = baseSymbols.filter(symbol => identity(symbol) === identity(d));
1197
+ const key = declaredSignatureKey(d);
1198
+ if (same.length === 0 || same.some(symbol => declaredSignatureKey(symbol) === key)) return null;
1199
+ const siblings = new Set((currentSymbols || []).filter(symbol => symbol !== d && callable(symbol) &&
1200
+ identity(symbol) === identity(d)).map(declaredSignatureKey));
1201
+ const changed = same.filter(symbol => !siblings.has(declaredSignatureKey(symbol)));
1202
+ if (changed.length === 0) return null;
1203
+ changed.sort((a, b) => Math.abs(a.startLine - d.startLine) - Math.abs(b.startLine - d.startLine) ||
1204
+ a.startLine - b.startLine);
1205
+ return changed[0];
1206
+ }
1207
+
1208
+ const DECLARED_SIGNATURE_FIELDS = ['params', 'paramsStructured', 'generics'];
1209
+
1210
+ /** Callers of `def` as the index binds them with each changed declaration's base parameters. */
1211
+ function callersBeforeChange(index, name, def, changes) {
1212
+ return index._isolatedOperation(() => {
1213
+ const saved = changes.map(change => [change.def, DECLARED_SIGNATURE_FIELDS.map(field =>
1214
+ [field, Object.prototype.hasOwnProperty.call(change.def, field), change.def[field]])]);
1215
+ try {
1216
+ for (const change of changes) {
1217
+ for (const field of DECLARED_SIGNATURE_FIELDS) {
1218
+ if (change.before[field] === undefined) delete change.def[field];
1219
+ else change.def[field] = change.before[field];
1220
+ }
1221
+ }
1222
+ const raw = index.findCallers(name, { includeMethods: true, targetDefinitions: [def], collectAccount: true });
1223
+ return [
1224
+ ...raw.filter(c => c.tier !== 'unverified').map(c => ({ site: c, boundBefore: 'confirmed' })),
1225
+ ...raw.filter(c => c.tier === 'unverified').concat(raw.unverifiedEntries || [])
1226
+ .map(c => ({ site: c, boundBefore: 'unverified' })),
1227
+ ];
1228
+ } finally {
1229
+ for (const [definition, fields] of saved) {
1230
+ for (const [field, own, value] of fields) {
1231
+ if (own) definition[field] = value;
1232
+ else delete definition[field];
1233
+ }
1234
+ }
1235
+ }
1236
+ });
1237
+ }
1238
+
1239
+ /**
1240
+ * Sites the base declaration bound that the current sweep excludes, each
1241
+ * with its verdict against the new signature: `rebind.kind` is
1242
+ * 'no-overload' (fits no parameter list: a compile error), 'other-overload'
1243
+ * (another overload may take it now), 'count' (the argument count decides)
1244
+ * or 'unread' (the call record could not be re-read).
1245
+ */
1246
+ function sitesReboundByChange(index, name, def, rawCallers, confirmed, unverified, changes) {
1247
+ const excluded = rawCallers.accountRaw?.excludedEntries || [];
1248
+ // Only an exclusion by parameter fit, or a site left unverified, can hide
1249
+ // a call of the old declaration; otherwise the base is not read.
1250
+ const dependent = excluded.filter(entry => SIGNATURE_DEPENDENT_EXCLUSIONS.has(entry.reason));
1251
+ if (dependent.length === 0 && unverified.length === 0) return [];
1252
+ const baseChanges = typeof changes === 'function' ? changes() : changes;
1253
+ if (!baseChanges || baseChanges.length === 0) return [];
1254
+ const siteKey = site => `${site.file}\0${site.line}`;
1255
+ const confirmedKeys = new Set(confirmed.map(siteKey));
1256
+ const unverifiedKeys = new Set(unverified.map(siteKey));
1257
+ const reasonAt = new Map(excluded.map(entry => [siteKey(entry), entry.reason]));
1258
+ const before = callersBeforeChange(index, name, def, baseChanges);
1259
+ const { getCachedCalls, _overloadDiscipline, _overloadApplicable } = require('./callers');
1260
+ const pins = [...new Set([def, ...baseChanges.map(change => change.def)])];
1261
+ const definitions = index.symbols.get(name) || [];
1262
+ const out = [];
1263
+ const seen = new Set();
1264
+ for (const { site, boundBefore } of before) {
1265
+ const key = siteKey(site);
1266
+ // A site still confirmed is arg-checked as usual. A site the old
1267
+ // declaration confirmed and the new one leaves unverified or
1268
+ // excludes is checked against the new declaration here.
1269
+ if (confirmedKeys.has(key) || seen.has(key)) continue;
1270
+ if (unverifiedKeys.has(key) ? boundBefore !== 'confirmed' : !reasonAt.has(key)) continue;
1271
+ seen.add(key);
1272
+ const calls = (getCachedCalls(index, site.file) || []).filter(call =>
1273
+ call.line === site.line && (call.name === name || call.resolvedName === name));
1274
+ const call = calls.find(c => site.column == null || c.column === site.column) || calls[0];
1275
+ const language = index.files.get(site.file)?.language;
1276
+ let rebind = { kind: call ? 'count' : 'unread' };
1277
+ if (call && langTraits(language)?.hasArityOverloads && call.argCount != null && !call.argSpread) {
1278
+ const verdict = _overloadDiscipline(index, call, pins, definitions, site.file);
1279
+ if (verdict === 'other-overload') {
1280
+ const { ownerRefOf } = require('./class-identity');
1281
+ const ownerKey = ownerRefOf(index, def)?.key;
1282
+ const others = definitions.filter(d => !pins.includes(d) && !NON_CALLABLE_TYPES.has(d.type) &&
1283
+ d.className === def.className && ownerKey && ownerRefOf(index, d)?.key === ownerKey &&
1284
+ _overloadApplicable(index, call, d));
1285
+ rebind = { kind: 'other-overload', overloads: others.map(d => formatTypedSignature(d)) };
1286
+ } else if (verdict?.ambiguous) {
1287
+ rebind = verdict.candidates === 0 ? { kind: 'no-overload' }
1288
+ : { kind: 'other-overload', candidates: verdict.candidates };
1289
+ }
1290
+ }
1291
+ let content = site.content;
1292
+ if (content == null) {
1293
+ try { content = index._getFileLines(site.file)[site.line - 1] ?? ''; } catch { content = ''; }
1294
+ }
1295
+ const prior = unverifiedKeys.has(key)
1296
+ ? unverified.find(u => siteKey(u) === key && (site.column == null || u.column == null || u.column === site.column))
1297
+ : null;
1298
+ out.push({
1299
+ ...site,
1300
+ content,
1301
+ tier: 'unverified',
1302
+ reason: 'bound-before-change',
1303
+ ...(prior?.reason && { priorReason: prior.reason }),
1304
+ boundBefore,
1305
+ excludedAs: reasonAt.get(key) || null,
1306
+ ...(unverifiedKeys.has(key) && { stillUnverified: true }),
1307
+ rebind,
1308
+ });
1309
+ }
1310
+ out.sort((a, b) => codeUnitCompare(a.relativePath || '', b.relativePath || '') || a.line - b.line);
1311
+ out.baseChanges = baseChanges;
1312
+ return out;
764
1313
  }
765
1314
 
766
1315
  /** Map an unverified sweep entry to the public site shape (relative `file`). */
@@ -782,6 +1331,7 @@ function unverifiedSiteShape(u) {
782
1331
  // Module-attribute attribution (fix #294): the name binds the module's
783
1332
  // export surface — a non-slot surface for rename purposes.
784
1333
  ...(u.moduleAttribute && { moduleAttribute: true }),
1334
+ ...(u.annotationShorthand && { annotationShorthand: u.annotationShorthand }),
785
1335
  };
786
1336
  }
787
1337
 
@@ -794,12 +1344,22 @@ function unverifiedSiteShape(u) {
794
1344
  * @param {object} def - Resolved definition
795
1345
  * @returns {{ sites: Array, unverifiedSites: Array, account: object }}
796
1346
  */
797
- function computePlanCallSites(index, name, def) {
798
- const { confirmed, unverified, account, groundSet } = contractedCallerSweep(index, name, def);
1347
+ function computePlanCallSites(index, name, def, options = {}) {
1348
+ // A plan holds one call-site tree per file across its sweeps (fix #395).
1349
+ const ownsTreeCache = !index._treeCache;
1350
+ // A rename edits name tokens only; argument analysis (a parse of every
1351
+ // caller file) serves signature changes (fix #396).
1352
+ const analyzeArgs = options.analyzeArgs !== false;
1353
+ const { confirmed, unverified, account, groundSet, accountParts } =
1354
+ contractedCallerSweep(index, name, def);
799
1355
 
800
1356
  const sites = [];
801
1357
  const planLineSeen = new Map(); // 'file:line' -> per-line ordinal (fix #231)
802
1358
  for (const c of confirmed) {
1359
+ // A call a macro template generated is spelled in the macro
1360
+ // definition (fix #374): the template pass edits or lists it there;
1361
+ // the invocation line has no token to rename.
1362
+ if (c.macroExpansion?.origin === 'template') continue;
803
1363
  const call = {
804
1364
  file: c.file,
805
1365
  relativePath: c.relativePath,
@@ -813,35 +1373,50 @@ function computePlanCallSites(index, name, def) {
813
1373
  const siteKey = `${c.file}:${c.line}:${c.calledAs || name}`;
814
1374
  const occurrence = planLineSeen.get(siteKey) || 0;
815
1375
  planLineSeen.set(siteKey, occurrence + 1);
816
- const analysis = analyzeCallSite(index, call, name, occurrence);
1376
+ const analysis = analyzeArgs ? analyzeCallSite(index, call, name, occurrence) : { args: null, argCount: 0 };
1377
+ // The call's source range (provenance site) lets the rename find
1378
+ // its own name token when the record carries no column (fix #392).
1379
+ const siteFacts = c.provenance?.facts?.site;
1380
+ const siteRange = !Number.isInteger(c.column) && siteFacts &&
1381
+ siteFacts.line === c.line && Number.isInteger(siteFacts.start) &&
1382
+ Number.isInteger(siteFacts.end) ? { start: siteFacts.start, end: siteFacts.end } : null;
817
1383
  sites.push({
818
1384
  file: call.relativePath,
819
1385
  absoluteFile: call.file,
820
1386
  line: call.line,
821
1387
  ...(Number.isInteger(c.column) && { column: c.column }),
1388
+ ...(siteRange && { siteRange }),
822
1389
  expression: (call.content || '').trim(),
823
1390
  args: analysis.args,
824
1391
  argCount: analysis.argCount,
825
1392
  ...(analysis.keywordArgNames && { keywordArgNames: analysis.keywordArgNames }),
826
1393
  ...(analysis.positionalCount != null && { positionalCount: analysis.positionalCount }),
827
1394
  ...(c.calledAs && { calledAs: c.calledAs }),
1395
+ ...(c.macroExpansion && { macroExpansion: c.macroExpansion }),
1396
+ ...(c.targetTyped && { targetTyped: true }),
1397
+ ...(c.destructured && { destructured: c.destructured }),
1398
+ ...(c.importAliasSpelling && { importAliasSpelling: true }),
828
1399
  });
829
1400
  }
830
- clearTreeCache(index);
831
- // Stable ordering (matches CLAUDE.md rule #11): files alphabetical, sites by line ascending.
1401
+ if (ownsTreeCache) clearTreeCache(index);
1402
+ // Stable ordering contract: files alphabetical, sites by line ascending.
832
1403
  sites.sort((a, b) => {
833
1404
  const fc = codeUnitCompare(String(a.file), String(b.file));
834
1405
  if (fc !== 0) return fc;
835
1406
  return (a.line || 0) - (b.line || 0);
836
1407
  });
1408
+ // A target-typed `new(..)` spells no name (fix #400): a rename never
1409
+ // edits it, so an undecided one is no rename candidate either.
1410
+ const renameRelevant = u => u.macroExpansion?.origin !== 'template' && !u.targetTyped;
837
1411
  return {
838
1412
  sites,
839
- unverifiedSites: unverified.map(unverifiedSiteShape),
1413
+ unverifiedSites: unverified.filter(renameRelevant).map(unverifiedSiteShape),
840
1414
  // Plan-only evidence used to promote calls through a compiler-proven
841
1415
  // Go interface slot. Kept out of the public JSON surface.
842
- rawUnverified: unverified,
1416
+ rawUnverified: unverified.filter(renameRelevant),
843
1417
  account,
844
1418
  groundSet,
1419
+ accountParts,
845
1420
  };
846
1421
  }
847
1422
 
@@ -877,6 +1452,22 @@ function computePlanScopeWarning(index, name, def, options) {
877
1452
  * @param {string} funcName - Function name to find
878
1453
  * @returns {object} { args, argCount, hasSpread, hasVariable }
879
1454
  */
1455
+ /**
1456
+ * The operation's parsed tree of a file (the one the ground-set
1457
+ * classification already parsed) when the language's query tree for this
1458
+ * content is the plain parse; null otherwise (a C# file with conditional
1459
+ * directives reads a configuration view).
1460
+ */
1461
+ function sharedParsedTree(index, file, content, language) {
1462
+ if (typeof index._getParsedTree !== 'function') return null;
1463
+ // A language that queries its own view of a file (C# conditional
1464
+ // compilation) shares the tree only where that view is the plain parse
1465
+ // (fix #395).
1466
+ const adapter = getLanguageAdapter(language);
1467
+ if (adapter?.queryTree && !adapter.queryTreeIsPlain?.(content, index.files?.get(file))) return null;
1468
+ return index._getParsedTree(file, content, language);
1469
+ }
1470
+
880
1471
  function analyzeCallSite(index, call, funcName, occurrence = 0) {
881
1472
  try {
882
1473
  const language = detectLanguage(call.file);
@@ -897,9 +1488,12 @@ function analyzeCallSite(index, call, funcName, occurrence = 0) {
897
1488
  const virtualJS = htmlModule.buildVirtualJSContent(content, blocks);
898
1489
  tree = safeParse(jsParser, virtualJS);
899
1490
  } else {
900
- const parser = getParser(language);
901
- if (!parser) return { args: null, argCount: 0 };
902
- tree = safeParse(parser, content);
1491
+ tree = sharedParsedTree(index, call.file, content, language);
1492
+ if (!tree) {
1493
+ const parser = getParser(language);
1494
+ if (!parser) return { args: null, argCount: 0 };
1495
+ tree = safeParse(parser, content);
1496
+ }
903
1497
  }
904
1498
  if (!tree) return { args: null, argCount: 0 };
905
1499
  if (!index._treeCache) index._treeCache = new Map();
@@ -1069,9 +1663,12 @@ function analyzeCallShape(index, filePath, lineNum, funcName) {
1069
1663
  const virtualJS = htmlModule.buildVirtualJSContent(content, blocks);
1070
1664
  tree = safeParse(jsParser, virtualJS);
1071
1665
  } else {
1072
- const parser = getParser(language);
1073
- if (!parser) return null;
1074
- tree = safeParse(parser, content);
1666
+ tree = sharedParsedTree(index, filePath, content, language);
1667
+ if (!tree) {
1668
+ const parser = getParser(language);
1669
+ if (!parser) return null;
1670
+ tree = safeParse(parser, content);
1671
+ }
1075
1672
  }
1076
1673
  if (!tree) return null;
1077
1674
  if (!index._treeCache) index._treeCache = new Map();
@@ -1323,7 +1920,11 @@ function verify(index, name, options = {}) {
1323
1920
  const ctorParamLists = _constructorParamLists(index, def, lang);
1324
1921
  const inheritedCtorOnly = !ctorParamLists && def.type === 'class' && !!def.extends;
1325
1922
  const selfParams = langTraits(lang)?.selfParam;
1326
- const stripSelf = (list) => (selfParams && list.length > 0 && list[0] && selfParams.includes(list[0].name))
1923
+ // `&'a mut self` names the receiver too (fix #369): lifetimes are not
1924
+ // part of the receiver spelling.
1925
+ const selfSpelling = name => String(name || '').replace(/'[A-Za-z_]\w*\s*/g, '').trim();
1926
+ const stripSelf = (list) => (selfParams && list.length > 0 && list[0] &&
1927
+ selfParams.includes(selfSpelling(list[0].name)))
1327
1928
  ? list.slice(1) : list;
1328
1929
  let callableIdentityParams = null;
1329
1930
  if (!ctorParamLists && ['c', 'cpp'].includes(lang)) {
@@ -1354,21 +1955,47 @@ function verify(index, name, options = {}) {
1354
1955
  // dropped). Engine receiver physics replace the pre-v4 className filter
1355
1956
  // and the isMethodCall secondary filter — --include-methods and
1356
1957
  // --include-uncertain are implied no-ops for verify.
1357
- const { confirmed: callerResults, unverified: sweepUnverified, account } =
1358
- contractedCallerSweep(index, name, def);
1958
+ // A declaration changed since the base revision is checked on the call
1959
+ // sites its base declaration bound (fix #394); the base file is read in
1960
+ // the background while the current declaration is swept, and used only
1961
+ // when the sweep excludes a site by parameter fit or leaves one unverified.
1962
+ const baseRef = options.base || 'HEAD';
1963
+ if (!GIT_REF_FORMAT.test(baseRef)) throw new UcnError(`Invalid git ref format: ${baseRef}`);
1964
+ // A base the user names must be a commit: a misspelled ref would read as
1965
+ // a file absent at base and check nothing against the old declaration
1966
+ // (the target-less check already resolved its base through the diff).
1967
+ if (options.base && options.checkBaseRef) assertGitCommit(def.file, baseRef);
1968
+ const baseRead = startBaseRead(def.file, baseRef);
1969
+ let sweep;
1970
+ try {
1971
+ sweep = contractedCallerSweep(index, name, def, {
1972
+ baseChanges: () => baseDeclarationChanges(index, name, def, baseRef, baseRead),
1973
+ });
1974
+ } finally {
1975
+ if (baseRead && !baseRead.done) finishBaseRead(baseRead);
1976
+ }
1977
+ const { confirmed: callerResults, unverified: sweepUnverified, account, rebound, baseChanges } = sweep;
1359
1978
 
1360
1979
  // Convert caller results to usage-like objects for analyzeCallSite.
1361
1980
  // Carry callerFile/callerStartLine through so we can compute inTestCase.
1362
1981
  const invalidFamilyCalls = sweepUnverified.filter(c =>
1363
- validateCallMismatch(c.provenance, declarationIdentity(def)));
1364
- const calls = [...callerResults, ...invalidFamilyCalls].map(c => ({
1365
- invalidOverload: invalidFamilyCalls.includes(c),
1982
+ !c.rebind && validateCallMismatch(c.provenance, declarationIdentity(def)));
1983
+ const siteContent = c => {
1984
+ if (c.content != null) return c.content;
1985
+ try { return index._getFileLines(c.file)[c.line - 1] ?? ''; } catch { return ''; }
1986
+ };
1987
+ const pinBaseChange = (baseChanges || []).find(change => change.def === def) || null;
1988
+ const calls = [...callerResults, ...invalidFamilyCalls, ...rebound].map(c => ({
1989
+ invalidOverload: invalidFamilyCalls.includes(c) || c.rebind?.kind === 'no-overload',
1990
+ ...(c.rebind && { rebind: c.rebind, excludedAs: c.excludedAs, boundBefore: c.boundBefore,
1991
+ unverifiedReason: c.priorReason }),
1366
1992
  file: c.file,
1367
1993
  relativePath: c.relativePath,
1368
1994
  line: c.line,
1369
- content: c.content,
1995
+ content: siteContent(c),
1370
1996
  usageType: 'call',
1371
1997
  receiver: c.receiver,
1998
+ isMethod: c.isMethod,
1372
1999
  calledAs: c.calledAs,
1373
2000
  callSite: c.provenance?.facts?.site,
1374
2001
  // Preserve receiver identity through the usage-shaped adapter. Go
@@ -1378,6 +2005,7 @@ function verify(index, name, options = {}) {
1378
2005
  receiverType: c.receiverType,
1379
2006
  callerFile: c.callerFile,
1380
2007
  callerStartLine: c.callerStartLine,
2008
+ ...(c.macroExpansion && { macroExpansion: c.macroExpansion }),
1381
2009
  }));
1382
2010
 
1383
2011
  const valid = [];
@@ -1424,13 +2052,17 @@ function verify(index, name, options = {}) {
1424
2052
  callerStartLine: call.callerStartLine,
1425
2053
  };
1426
2054
 
1427
- if (analysis.args === null) {
1428
- // Couldn't parse arguments
2055
+ if (analysis.args === null || call.macroExpansion) {
2056
+ // Couldn't parse arguments, or the call exists only in the
2057
+ // expansion of a macro invocation on this line (fix #362): its
2058
+ // arguments are the macro's, not text on the line.
1429
2059
  uncertain.push({
1430
2060
  file: call.relativePath,
1431
2061
  line: call.line,
1432
2062
  expression: call.content.trim(),
1433
- reason: analysis.uncertainReason || 'Could not parse call arguments',
2063
+ reason: call.macroExpansion
2064
+ ? `Call produced by expanding macro ${call.macroExpansion.macro}; check the macro's argument list`
2065
+ : analysis.uncertainReason || 'Could not parse call arguments',
1434
2066
  patterns: patternFlagsFrom(analysis),
1435
2067
  ...carry,
1436
2068
  });
@@ -1453,6 +2085,9 @@ function verify(index, name, options = {}) {
1453
2085
  }
1454
2086
 
1455
2087
  let argCount = analysis.argCount;
2088
+ // A C# extension method called on its receiver (`b.Length(1, 2)`)
2089
+ // receives that receiver as its `this` parameter (fix #394).
2090
+ if (def.isExtensionMethod && call.isMethod && call.receiver !== def.className) argCount += 1;
1456
2091
  // Method-expression / UFCS receiver shift (fix #230): Go
1457
2092
  // `M.Add(*m, 2)` and Rust `Engine::run(&e, 1)` pass the receiver as
1458
2093
  // the FIRST argument — the same +1 shift the #205 arity discipline
@@ -1472,11 +2107,32 @@ function verify(index, name, options = {}) {
1472
2107
  const countOk = hasRest
1473
2108
  ? argCount >= minArgs
1474
2109
  : (argCount >= minArgs && argCount <= expectedParamCount);
2110
+ if (call.rebind && call.rebind.kind !== 'no-overload' && (countOk || call.rebind.kind !== 'count')) {
2111
+ // Bound the base declaration; the new one may still take it, or
2112
+ // another overload may: the compiler decides (fix #394).
2113
+ const target = call.rebind.overloads?.length > 0
2114
+ ? `it may now bind ${call.rebind.overloads.join(' or ')}`
2115
+ : call.rebind.candidates > 0 ? `it may now bind one of ${call.rebind.candidates} overloads`
2116
+ : call.rebind.kind === 'unread' ? 'its call could not be re-read'
2117
+ : call.excludedAs ? `the index excludes it now (${call.excludedAs})`
2118
+ : `its binding to the new declaration is unverified (${call.unverifiedReason || 'no evidence'})`;
2119
+ uncertain.push({
2120
+ file: call.relativePath,
2121
+ line: call.line,
2122
+ expression: call.content.trim(),
2123
+ reason: `Called the declaration before the change; ${target}`,
2124
+ boundBeforeChange: true,
2125
+ patterns: patternFlagsFrom(analysis),
2126
+ ...carry,
2127
+ });
2128
+ continue;
2129
+ }
1475
2130
  if (!countOk || call.invalidOverload) {
1476
2131
  mismatches.push({
1477
2132
  file: call.relativePath,
1478
2133
  line: call.line,
1479
2134
  expression: call.content.trim(),
2135
+ ...(call.rebind && { boundBeforeChange: true }),
1480
2136
  expected: call.invalidOverload && countOk ? 'a compatible overload signature' : hasRest
1481
2137
  ? `at least ${minArgs} arg(s)`
1482
2138
  : (minArgs === expectedParamCount
@@ -1641,6 +2297,15 @@ function verify(index, name, options = {}) {
1641
2297
  patterns: patternsAgg,
1642
2298
  scopeWarning,
1643
2299
  ...(warnings.length > 0 && { warnings }),
2300
+ // The base declaration whose call sites were checked (fix #394).
2301
+ ...(pinBaseChange && {
2302
+ changedSince: {
2303
+ base: options.base || 'HEAD',
2304
+ signature: formatTypedSignature({ ...def, ...pinBaseChange.before,
2305
+ ...(pinBaseChange.before.generics === undefined && { generics: undefined }) }),
2306
+ callSites: rebound.length,
2307
+ },
2308
+ }),
1644
2309
  };
1645
2310
  } finally { index._endOp(); }
1646
2311
  }
@@ -1690,223 +2355,80 @@ const RESERVED_WORDS_BY_LANGUAGE = {
1690
2355
  'while').split(' ')),
1691
2356
  };
1692
2357
 
1693
- /** Split a Go signature list on commas outside nested type syntax. */
1694
- function splitGoSignatureList(raw) {
1695
- const text = String(raw || '').trim().replace(/^\((.*)\)$/s, '$1');
1696
- if (!text) return [];
1697
- const out = [];
1698
- let start = 0;
1699
- let round = 0;
1700
- let square = 0;
1701
- let curly = 0;
1702
- for (let i = 0; i < text.length; i++) {
1703
- const ch = text[i];
1704
- if (ch === '(') round++;
1705
- else if (ch === ')') round = Math.max(0, round - 1);
1706
- else if (ch === '[') square++;
1707
- else if (ch === ']') square = Math.max(0, square - 1);
1708
- else if (ch === '{') curly++;
1709
- else if (ch === '}') curly = Math.max(0, curly - 1);
1710
- else if (ch === ',' && round === 0 && square === 0 && curly === 0) {
1711
- out.push(text.slice(start, i).trim());
1712
- start = i + 1;
1713
- }
1714
- }
1715
- out.push(text.slice(start).trim());
1716
- return out.filter(Boolean);
1717
- }
1718
-
1719
- const GO_UNNAMED_TYPE_PREFIX = /^(?:\*|\[|map\[|chan(?:<-)?\s|<-chan\s|func\s*\(|interface\s*\{|struct\s*\{|\.\.\.)/;
1720
-
1721
- /** Turn one raw Go parameter/result declaration into its type spelling. */
1722
- function goDeclarationType(raw) {
1723
- let value = String(raw || '').trim();
1724
- if (!value) return null;
1725
- // Named slots/results: `value T`, `ctx context.Context`. Unnamed type
1726
- // forms starting with Go type syntax (`chan T`, `func(...)`, `*T`) keep
1727
- // their whole text. Structured params normally handle grouped names;
1728
- // this fallback exists for unnamed interface slots and result tuples.
1729
- if (!GO_UNNAMED_TYPE_PREFIX.test(value)) {
1730
- const named = value.match(/^[A-Za-z_][A-Za-z0-9_]*\s+(.+)$/s);
1731
- if (named) value = named[1].trim();
1732
- }
1733
- return value.replace(/\s+/g, '');
1734
- }
2358
+ const {
2359
+ goContractClosure,
2360
+ externalContractOf,
2361
+ hierarchySlotClosure,
2362
+ anonymousSlotMembers,
2363
+ typedLiteralContract,
2364
+ typedLiteralMembers,
2365
+ rustProjectTraits,
2366
+ rustMacroTemplateSites,
2367
+ splitGoSignatureList,
2368
+ goDeclarationType,
2369
+ } = require('./contract-membership');
1735
2370
 
1736
- /** Compiler-shaped Go method fingerprint: parameter types + result types. */
1737
- function goMethodFingerprint(def) {
1738
- let params;
1739
- if (Array.isArray(def.paramsStructured) && def.paramsStructured.length > 0) {
1740
- params = [];
1741
- for (const param of def.paramsStructured) {
1742
- const rawType = param.type || (param.unnamed ? param.name : null);
1743
- const type = goDeclarationType(rawType);
1744
- if (!type) return null;
1745
- params.push(type);
1746
- }
1747
- } else if (def.params === '' || def.params == null) {
1748
- params = [];
2371
+ /**
2372
+ * Plan verdict of a C++ qualified reference `Q::name` to the renamed
2373
+ * callable (fix #393): Q resolved from the reference's scope (macro-opened
2374
+ * namespaces, usings, aliases) names the pin's namespace (a namespace-scope
2375
+ * pin) or its class (a member): 'edit'; another namespace or class: null;
2376
+ * unresolvable: 'review'.
2377
+ */
2378
+ function cppQualifiedReferenceVerdict(index, ref, def) {
2379
+ const { resolveQualifier, effectiveNamespace, macroPrefixAt, includeClosure } = require('./cpp-scope');
2380
+ // Inside an indexed declaration the reference's scope is that
2381
+ // declaration's (as for calls); at namespace scope (an explicit
2382
+ // instantiation is no indexed symbol) it is read from the AST under the
2383
+ // macro-opened prefix.
2384
+ const enclosing = index.findEnclosingFunction(ref.file, ref.line, true);
2385
+ let namespace;
2386
+ if (enclosing) {
2387
+ namespace = effectiveNamespace(index, enclosing) || '';
1749
2388
  } else {
1750
- params = splitGoSignatureList(def.params).map(goDeclarationType);
1751
- if (params.some(type => !type)) return null;
2389
+ let astNamespace = '';
2390
+ try {
2391
+ const content = index._readFile(ref.file);
2392
+ const tree = index._getParsedTree?.(ref.file, content, 'cpp') || safeParse(getParser('cpp'), content);
2393
+ const row = ref.line - 1;
2394
+ const text = (index._getFileLines?.(ref.file) || content.split('\n'))[row] || '';
2395
+ const column = Number.isInteger(ref.column) ? ref.column : Math.max(0, text.search(/\S/));
2396
+ const parts = [];
2397
+ for (let node = tree?.rootNode.descendantForPosition({ row, column }); node; node = node.parent) {
2398
+ if (node.type !== 'namespace_definition') continue;
2399
+ const nameNode = node.childForFieldName('name');
2400
+ if (nameNode?.text) parts.unshift(nameNode.text.replace(/\s+/g, ''));
2401
+ }
2402
+ astNamespace = parts.join('::');
2403
+ } catch { /* unparsed: the prefix alone */ }
2404
+ const prefix = macroPrefixAt(index, ref.file, ref.line) || '';
2405
+ namespace = prefix && astNamespace ? `${prefix}::${astNamespace}` : prefix || astNamespace;
1752
2406
  }
1753
- const results = def.returnType
1754
- ? splitGoSignatureList(def.returnType).map(goDeclarationType)
1755
- : [];
1756
- if (results.some(type => !type)) return null;
1757
- return `${params.join(',')}->${results.join(',')}`;
1758
- }
1759
-
1760
- function goTypeIdentity(name, file) {
1761
- const pathMod = require('path');
1762
- return `${pathMod.dirname(file || '')}\0${name}`;
1763
- }
1764
-
1765
- function goTypeDefinition(index, name, fromFile) {
1766
- const pathMod = require('path');
1767
- const dir = pathMod.dirname(fromFile || '');
1768
- const kinds = new Set(['struct', 'type', 'class']);
1769
- const defs = (index.symbols.get(name) || []).filter(candidate =>
1770
- candidate.file && pathMod.dirname(candidate.file) === dir &&
1771
- kinds.has(candidate.type));
1772
- if (defs.length !== 1 || defs[0].generics) return null;
1773
- return defs[0];
1774
- }
1775
-
1776
- function goInterfaceMembers(index, interfaceDef) {
1777
- const out = [];
1778
- for (const defs of index.symbols.values()) {
1779
- for (const candidate of defs) {
1780
- if (candidate.file === interfaceDef.file &&
1781
- candidate.className === interfaceDef.name &&
1782
- candidate.type === 'method' &&
1783
- candidate.startLine >= interfaceDef.startLine &&
1784
- candidate.startLine <= interfaceDef.endLine) out.push(candidate);
1785
- }
2407
+ const context = {
2408
+ file: ref.file,
2409
+ line: ref.line,
2410
+ namespace,
2411
+ className: enclosing?.className || null,
2412
+ };
2413
+ let resolved;
2414
+ try {
2415
+ resolved = resolveQualifier(index, context, String(ref.receiver), includeClosure(index, ref.file));
2416
+ } catch {
2417
+ return 'review';
1786
2418
  }
1787
- return out;
1788
- }
1789
-
1790
- /** Authored embedded-type spellings for one Go type declaration. */
1791
- function goEmbeddedTypes(index, typeDef) {
1792
- const out = [];
1793
- for (const definitions of index.symbols.values()) {
1794
- for (const candidate of definitions) {
1795
- if (candidate.file === typeDef.file &&
1796
- candidate.className === typeDef.name &&
1797
- candidate.type === 'field' && candidate.fieldType &&
1798
- candidate.startLine >= typeDef.startLine &&
1799
- candidate.startLine <= typeDef.endLine) {
1800
- out.push(String(candidate.fieldType).trim());
1801
- }
1802
- }
2419
+ if (!resolved || resolved.kind === 'unknown') return 'review';
2420
+ const owner = def.className || (def.receiver ? String(def.receiver).replace(/^\*/, '') : null);
2421
+ if (resolved.kind === 'namespace') {
2422
+ if (owner) return null;
2423
+ return (resolved.resolvedNamespace || resolved.namespace) === effectiveNamespace(index, def) ? 'edit' : null;
1803
2424
  }
1804
- return out;
1805
- }
1806
-
1807
- /**
1808
- * Complete method requirements for one project Go interface. A qualified or
1809
- * unresolved embedded interface makes the set open, so the caller abstains.
1810
- */
1811
- function goInterfaceRequirements(index, interfaceDef, memo, visiting = new Set()) {
1812
- const key = `${interfaceDef.file}\0${interfaceDef.name}`;
1813
- if (memo.has(key)) return memo.get(key);
1814
- if (visiting.has(key) || interfaceDef.generics) return null;
1815
- visiting.add(key);
1816
- const requirements = new Map();
1817
- for (const member of goInterfaceMembers(index, interfaceDef)) {
1818
- const fingerprint = goMethodFingerprint(member);
1819
- if (!fingerprint) { visiting.delete(key); memo.set(key, null); return null; }
1820
- const previous = requirements.get(member.name);
1821
- if (previous && previous.fingerprint !== fingerprint) {
1822
- visiting.delete(key); memo.set(key, null); return null;
1823
- }
1824
- requirements.set(member.name, { fingerprint, defs: [member] });
1825
- }
1826
- for (const rawParent of goEmbeddedTypes(index, interfaceDef)) {
1827
- if (rawParent.includes('.')) {
1828
- visiting.delete(key); memo.set(key, null); return null;
1829
- }
1830
- const parent = (index.symbols.get(rawParent) || []).find(candidate =>
1831
- candidate.type === 'interface' &&
1832
- require('path').dirname(candidate.file || '') ===
1833
- require('path').dirname(interfaceDef.file || ''));
1834
- if (!parent) { visiting.delete(key); memo.set(key, null); return null; }
1835
- const inherited = goInterfaceRequirements(index, parent, memo, visiting);
1836
- if (!inherited) { visiting.delete(key); memo.set(key, null); return null; }
1837
- for (const [name, requirement] of inherited) {
1838
- const previous = requirements.get(name);
1839
- if (previous && previous.fingerprint !== requirement.fingerprint) {
1840
- visiting.delete(key); memo.set(key, null); return null;
1841
- }
1842
- if (!previous) requirements.set(name, requirement);
1843
- }
1844
- }
1845
- visiting.delete(key);
1846
- const result = requirements.size > 0 ? requirements : null;
1847
- memo.set(key, result);
1848
- return result;
1849
- }
1850
-
1851
- /**
1852
- * Method set for a project Go named type, including promoted embedded methods.
1853
- * Unknown/external embeddings make the set open and force abstention.
1854
- */
1855
- function goConcreteMethodSet(index, typeDef, memo, visiting = new Set()) {
1856
- const key = goTypeIdentity(typeDef.name, typeDef.file);
1857
- if (memo.has(key)) return memo.get(key);
1858
- if (visiting.has(key) || typeDef.generics) return null;
1859
- visiting.add(key);
1860
- const methods = new Map();
1861
- const directNames = new Set();
1862
- const dir = require('path').dirname(typeDef.file || '');
1863
- for (const [name, defs] of index.symbols) {
1864
- const owned = defs.filter(candidate =>
1865
- !NON_CALLABLE_TYPES.has(candidate.type) &&
1866
- candidate.className === typeDef.name && candidate.file &&
1867
- require('path').dirname(candidate.file) === dir &&
1868
- candidate.type !== 'method');
1869
- for (const method of owned) {
1870
- const fingerprint = goMethodFingerprint(method);
1871
- if (!fingerprint) { visiting.delete(key); memo.set(key, null); return null; }
1872
- const previous = methods.get(name);
1873
- if (previous && previous.fingerprint !== fingerprint) {
1874
- visiting.delete(key); memo.set(key, null); return null;
1875
- }
1876
- if (!previous) methods.set(name, { fingerprint, defs: [method] });
1877
- else previous.defs.push(method);
1878
- directNames.add(name);
1879
- }
1880
- }
1881
- for (const rawParent of goEmbeddedTypes(index, typeDef)) {
1882
- if (rawParent.includes('.')) {
1883
- visiting.delete(key); memo.set(key, null); return null;
1884
- }
1885
- const parent = goTypeDefinition(index, rawParent.replace(/^\*/, ''), typeDef.file);
1886
- if (!parent) { visiting.delete(key); memo.set(key, null); return null; }
1887
- const promoted = goConcreteMethodSet(index, parent, memo, visiting);
1888
- if (!promoted) { visiting.delete(key); memo.set(key, null); return null; }
1889
- for (const [name, method] of promoted) {
1890
- // A direct method shadows a promoted one. Two promoted methods of
1891
- // the same name are ambiguous in Go; abstain instead of coupling.
1892
- if (methods.has(name)) {
1893
- if (directNames.has(name)) continue;
1894
- visiting.delete(key); memo.set(key, null); return null;
1895
- }
1896
- methods.set(name, method);
1897
- }
1898
- }
1899
- visiting.delete(key);
1900
- memo.set(key, methods);
1901
- return methods;
1902
- }
1903
-
1904
- function goMethodSetSatisfies(methods, requirements) {
1905
- if (!methods || !requirements) return false;
1906
- for (const [name, requirement] of requirements) {
1907
- if (methods.get(name)?.fingerprint !== requirement.fingerprint) return false;
2425
+ if (resolved.kind === 'type') {
2426
+ if (!owner) return null;
2427
+ const classes = resolved.classes || [];
2428
+ if (classes.length === 0) return 'review';
2429
+ return classes.some(cls => cls.name === String(owner).replace(/<.*$/, '')) ? 'edit' : null;
1908
2430
  }
1909
- return true;
2431
+ return 'review';
1910
2432
  }
1911
2433
 
1912
2434
  function goMethodArityCompatible(def, argCount) {
@@ -1935,125 +2457,500 @@ function goSlotCoversMethodAmbiguity(index, name, slot, raw) {
1935
2457
  slot.memberIdentity.has(`${require('path').resolve(definition.file)}\0${definition.startLine}`));
1936
2458
  }
1937
2459
 
1938
- /**
1939
- * Compute the Go method/interface rename component containing the pinned
1940
- * member. Go satisfaction is implicit, so this is a bipartite fixed point:
1941
- * concrete types join interfaces whose complete method sets they satisfy,
1942
- * and every satisfier of a joined interface joins the same compiler slot.
1943
- */
1944
- function goInterfaceRenameClosure(index, targetDef) {
1945
- if (!targetDef.className || goMethodFingerprint(targetDef) == null) return null;
1946
- const pathMod = require('path');
1947
- const interfaceMemo = new Map();
1948
- const methodMemo = new Map();
1949
- const interfaces = [];
1950
- for (const defs of index.symbols.values()) {
1951
- for (const candidate of defs) {
1952
- if (candidate.type !== 'interface') continue;
1953
- const requirements = goInterfaceRequirements(index, candidate, interfaceMemo);
1954
- const directTarget = goInterfaceMembers(index, candidate).find(member =>
1955
- member.name === targetDef.name &&
1956
- goMethodFingerprint(member) === goMethodFingerprint(targetDef));
1957
- if (requirements && directTarget) {
1958
- interfaces.push({
1959
- key: `${candidate.file}\0${candidate.name}`,
1960
- def: candidate,
1961
- requirements,
1962
- targetDefs: [directTarget],
1963
- });
1964
- }
1965
- }
1966
- }
1967
- if (interfaces.length === 0) return null;
2460
+ const IMPORT_STATEMENT_TYPES = new Set(['import_statement', 'import_from_statement',
2461
+ 'future_import_statement', 'use_declaration', 'import_declaration', 'using_directive', 'export_statement']);
1968
2462
 
1969
- const concreteByKey = new Map();
1970
- for (const definition of index.symbols.get(targetDef.name) || []) {
1971
- if (!definition.className || definition.type === 'method' ||
1972
- NON_CALLABLE_TYPES.has(definition.type)) continue;
1973
- const typeDef = goTypeDefinition(index, definition.className, definition.file);
1974
- if (!typeDef) continue;
1975
- const key = goTypeIdentity(typeDef.name, typeDef.file);
1976
- const node = concreteByKey.get(key) || {
1977
- key, def: typeDef, methods: goConcreteMethodSet(index, typeDef, methodMemo),
1978
- targetDefs: [],
1979
- };
1980
- node.targetDefs.push(definition);
1981
- concreteByKey.set(key, node);
2463
+ /** The first line of the import statement holding the token at
2464
+ * line/column (1-based line, 0-based column), or `line` when none does. */
2465
+ function importStatementLine(index, filePath, line, column) {
2466
+ const entry = index.files.get(filePath);
2467
+ if (!entry || !Number.isInteger(column)) return line;
2468
+ let tree;
2469
+ try {
2470
+ const content = index._readFile(filePath);
2471
+ tree = index._getParsedTree?.(filePath, content, entry.language) ||
2472
+ safeParse(getParser(entry.language), content);
2473
+ } catch {
2474
+ tree = null;
1982
2475
  }
2476
+ let node = tree?.rootNode?.descendantForPosition({ row: line - 1, column });
2477
+ while (node && !IMPORT_STATEMENT_TYPES.has(node.type)) node = node.parent;
2478
+ return node ? node.startPosition.row + 1 : line;
2479
+ }
1983
2480
 
1984
- const startInterface = interfaces.find(node =>
1985
- node.def.file === targetDef.file && node.def.name === targetDef.className);
1986
- const startConcreteKey = goTypeIdentity(targetDef.className, targetDef.file);
1987
- if (!startInterface && !concreteByKey.has(startConcreteKey)) return null;
2481
+ /** Is a Go site file compiled under another build constraint than the
2482
+ * pin's file (fix #400)? A file every build compiles is in every
2483
+ * configuration. */
2484
+ function goOtherConfiguration(index, pinFile, siteFile) {
2485
+ const { _goFileConstraint } = require('./callers');
2486
+ const site = siteFile ? _goFileConstraint(index, siteFile) : '';
2487
+ return !!site && site !== _goFileConstraint(index, pinFile);
2488
+ }
1988
2489
 
1989
- const joinedInterfaces = new Set(startInterface ? [startInterface.key] : []);
1990
- const joinedConcrete = new Set(startInterface ? [] : [startConcreteKey]);
1991
- let changed = true;
1992
- while (changed) {
1993
- changed = false;
1994
- for (const interfaceNode of interfaces) {
1995
- for (const concrete of concreteByKey.values()) {
1996
- if (!goMethodSetSatisfies(concrete.methods, interfaceNode.requirements)) continue;
1997
- if (joinedInterfaces.has(interfaceNode.key) && !joinedConcrete.has(concrete.key)) {
1998
- joinedConcrete.add(concrete.key); changed = true;
1999
- }
2000
- if (joinedConcrete.has(concrete.key) && !joinedInterfaces.has(interfaceNode.key)) {
2001
- joinedInterfaces.add(interfaceNode.key); changed = true;
2002
- }
2003
- }
2004
- }
2490
+ /**
2491
+ * fix #396: other external-linkage definitions of a C/C++ free function
2492
+ * `def` in other files (same C++ namespace and parameter types, same
2493
+ * language branch). `defines`: a declaration in the pin's identity group is
2494
+ * in the definition's include closure (both define the declared function);
2495
+ * `unrelated`: none is.
2496
+ */
2497
+ function cLinkAlternatives(index, def, definitions) {
2498
+ const none = { defines: [], unrelated: [] };
2499
+ const language = index.files.get(def.file)?.language;
2500
+ if (!langTraits(language)?.textualIncludes || def.className || def.receiver || def.isSignature ||
2501
+ def.type !== 'function' || def.modifiers?.includes('static') || def.friendOf) return none;
2502
+ const { _closeCallableIdentityGroup, _cFamilySignatureKey, _textualIncludeClosures } = require('./callers');
2503
+ const { effectiveNamespace } = require('./cpp-scope');
2504
+ const group = new Set(_closeCallableIdentityGroup(index, [def], definitions));
2505
+ const prototypes = [...group].filter(member => member.isSignature);
2506
+ const overloads = langTraits(language)?.hasArityOverloads;
2507
+ const key = d => `${overloads ? effectiveNamespace(index, d) : ''}\0${overloads ? _cFamilySignatureKey(d) : ''}\0${d.languageBranch || ''}`;
2508
+ const pinKey = key(def);
2509
+ const out = { defines: [], unrelated: [] };
2510
+ for (const other of definitions) {
2511
+ if (other === def || group.has(other) || other.file === def.file || other.type !== 'function' ||
2512
+ other.isSignature || other.className || other.receiver || other.friendOf ||
2513
+ other.modifiers?.includes('static') || other.generatedByMacro ||
2514
+ !langTraits(index.files.get(other.file)?.language)?.textualIncludes || key(other) !== pinKey) continue;
2515
+ const closure = _textualIncludeClosures(index, other.file).all;
2516
+ const declared = prototypes.some(prototype => prototype.file === other.file || closure.has(prototype.file));
2517
+ (declared ? out.defines : out.unrelated).push(other);
2005
2518
  }
2006
- if (joinedInterfaces.size === 0) return null;
2519
+ return out;
2520
+ }
2007
2521
 
2008
- const memberDefs = [];
2009
- const interfaceNames = new Set();
2010
- for (const node of interfaces) {
2011
- if (!joinedInterfaces.has(node.key)) continue;
2012
- interfaceNames.add(node.def.name);
2013
- memberDefs.push(...node.targetDefs);
2014
- }
2015
- for (const node of concreteByKey.values()) {
2016
- if (joinedConcrete.has(node.key)) memberDefs.push(...node.targetDefs);
2017
- }
2018
- const seen = new Set();
2019
- const uniqueMembers = memberDefs.filter(member => {
2020
- const key = `${pathMod.resolve(member.file)}\0${member.startLine}`;
2021
- if (seen.has(key)) return false;
2022
- seen.add(key);
2023
- return true;
2024
- });
2025
- return {
2026
- interfaceNames,
2027
- memberDefs: uniqueMembers,
2028
- memberIdentity: new Set(uniqueMembers.map(member =>
2029
- `${pathMod.resolve(member.file)}\0${member.startLine}`)),
2030
- };
2522
+ // C: plain identifiers of one spelling at file scope name one entity
2523
+ // (no overloading, no namespaces).
2524
+ function cExternalReferenceLanguage(index, file) {
2525
+ const traits = langTraits(index.files.get(file)?.language);
2526
+ return !!traits?.textualIncludes && !traits.hasArityOverloads;
2031
2527
  }
2032
2528
 
2033
- function plan(index, name, options = {}) {
2034
- index._beginOp();
2035
- try {
2036
- const definitions = index.symbols.get(name);
2037
- if (!definitions || definitions.length === 0) {
2038
- return { found: false, function: name };
2039
- }
2529
+ /**
2530
+ * fix #396: where a file-scope C reference to NAME in another file than the
2531
+ * pin resolves. The file's own definitions or declarations of the name that
2532
+ * are not the pin decide first (a static function or object of its own);
2533
+ * otherwise the external-linkage function the unit links is the pin when the
2534
+ * link verdict keeps it and a declaration of it is visible ('edit'), several
2535
+ * link candidates or no visible declaration review, another definition is
2536
+ * not the pin.
2537
+ */
2538
+ function cExternalReferenceVerdict(index, ref, def, pinIdentity, definitions) {
2539
+ if (def.className || def.receiver || def.modifiers?.includes('static')) return null;
2540
+ const own = (index.files.get(ref.file)?.symbols || []).filter(symbol => symbol.name === def.name);
2541
+ if (own.some(symbol => !pinIdentity.has(symbol))) return null;
2542
+ const { _textualLinkVerdict, _textualIncludeClosures } = require('./callers');
2543
+ const verdict = _textualLinkVerdict(index, ref.file, [...pinIdentity], definitions, ref.line);
2544
+ if (verdict === 'other') return null;
2545
+ if (verdict && verdict.ambiguous) return 'review';
2546
+ const { strong } = _textualIncludeClosures(index, ref.file);
2547
+ const declared = own.length > 0 || [...pinIdentity].some(member => strong.has(member.file));
2548
+ return declared ? 'edit' : 'review';
2549
+ }
2040
2550
 
2041
- const resolved = index.resolveSymbol(name, { file: options.file, className: options.className, line: options.line });
2042
- const def = resolved.def || definitions[0];
2043
- // BUG-BY: enrich types for typed-arrow-fn declarations.
2044
- const arrowTypes = extractArrowTypesFromVarDecl(index, def);
2045
- // Class target: the signature being planned is the CONSTRUCTOR's
2046
- // (fix #230, same rule as verify) — single-ctor classes only; with
2047
- // overloads the class def's own (empty) list stays, since plan cannot
2048
- // know which overload the user means.
2049
- const planLang = index.files.get(def.file)?.language;
2050
- const planCtorLists = _constructorParamLists(index, def, planLang);
2051
- const currentParams = (planCtorLists && planCtorLists.length === 1 && planCtorLists[0]) ||
2052
- (arrowTypes?.paramsStructured) || def.paramsStructured || [];
2053
- // BUG-BV: render with TS-correct param formatting (`opt?: number`).
2054
- const currentSignature = formatTypedSignature(def,
2055
- (planCtorLists && planCtorLists.length === 1)
2056
- ? { paramsStructured: currentParams }
2551
+ /**
2552
+ * Type-reference completion of a type rename (fix #386). Every name token on
2553
+ * the ground lines is decided by core/type-references.js; this merges the
2554
+ * decisions into the plan's changes:
2555
+ * - edits join an existing token edit on the line, replace a manual
2556
+ * review item another pass left on a line whose tokens are all decided,
2557
+ * or become new reference edits;
2558
+ * - tokens the lookup cannot settle make the line a review item with
2559
+ * their reason (a mixed line keeps its certain edits and is flagged);
2560
+ * - a line holding the name outside every parsed token and outside
2561
+ * comments/strings (macro bodies, unparsed regions) is a review item;
2562
+ * - reference-review items of other passes on lines whose tokens provably
2563
+ * name something else are dropped, and unverified call candidates on
2564
+ * decided lines leave the band (claimed by the account instead).
2565
+ */
2566
+ function applyTypeReferencePass(index, ctx) {
2567
+ const { def, name, renameTo, changes, reviewItems, planUnverified } = ctx;
2568
+ const { typeReferenceSites } = require('./type-references');
2569
+ const { files } = typeReferenceSites(index, def, name, ctx.groundSet);
2570
+ const decided = new Map(); // rel:line -> { abs, edited }
2571
+ const reviewed = new Map(); // rel:line -> { abs, reason }
2572
+ const stringEdited = new Set();
2573
+ const target = `${def.relativePath || def.file}:${def.nameLine || def.startLine}`;
2574
+ const reasonText = reason => String(reason || 'unresolved').replace(/-/g, ' ');
2575
+ const special = change => change.contractDependency || change.templateDependency ||
2576
+ change.editKind === 'example' || change.editKind === 'macro-expansion';
2577
+ // Rebuild a line edit from code-unit token columns.
2578
+ const lineEdit = (raw, columns) => {
2579
+ const sorted = [...columns].sort((a, b) => b - a);
2580
+ let renamed = raw;
2581
+ for (const column of sorted) {
2582
+ if (renamed.slice(column, column + name.length) !== name) continue;
2583
+ renamed = renamed.slice(0, column) + renameTo + renamed.slice(column + name.length);
2584
+ }
2585
+ return {
2586
+ source: raw.trim(), renamed: renamed.trim(), count: sorted.length,
2587
+ columns: sorted, rawSource: raw, oldName: name, newName: renameTo,
2588
+ };
2589
+ };
2590
+ for (const [abs, info] of files) {
2591
+ const rel = info.relativePath;
2592
+ const fileLines = index._getFileLines(abs);
2593
+ for (const [line, slot] of [...info.lines].sort((a, b) => a[0] - b[0])) {
2594
+ const key = `${rel}:${line}`;
2595
+ const at = changes.findIndex(change => change.file === rel && change.line === line);
2596
+ const existing = at >= 0 ? changes[at] : null;
2597
+ const raw = fileLines[line - 1] || '';
2598
+ const source = raw.trim();
2599
+ if (slot.tokens === 0 && slot.reviews.length === 0) {
2600
+ if (slot.text && !slot.macroBody) continue;
2601
+ if (existing) continue;
2602
+ const reason = slot.unparsedReason || (slot.macroBody ? 'macro-body' : 'unparsed-occurrence');
2603
+ reviewed.set(key, { abs, reason });
2604
+ changes.push({
2605
+ file: rel,
2606
+ line,
2607
+ expression: source,
2608
+ suggestion: `"${name}" appears here outside the parsed code (${reasonText(reason)}); ` +
2609
+ `rename it if it names ${name} at ${target}`,
2610
+ needsReview: true,
2611
+ reviewReason: reason,
2612
+ editKind: 'reference',
2613
+ });
2614
+ continue;
2615
+ }
2616
+ const units = columns => columns.map(column => tokenColumn(raw, column, name))
2617
+ .filter(Number.isInteger);
2618
+ const editUnits = units(slot.edits);
2619
+ const skipUnits = new Set(units(slot.skipColumns || []));
2620
+ const reviewList = slot.reviews.map(review => ({
2621
+ ...review,
2622
+ unit: Number.isInteger(review.column) ? tokenColumn(raw, review.column, name) : null,
2623
+ }));
2624
+ // An existing edit of this line: the type lookup decides every
2625
+ // token spelling the name. A token the other pass edits and the
2626
+ // lookup cannot settle stays edited and is flagged; one the
2627
+ // lookup proves to name another declaration (or a macro argument
2628
+ // its replacement list uses in another role) leaves the edit,
2629
+ // unless the edit is the pin's own declaration or one a
2630
+ // dedicated pass proved.
2631
+ if (existing && existing.newExpression !== undefined && !special(existing)) {
2632
+ const prior = existing._edit && existing._edit.rawSource === raw ? existing._edit.columns
2633
+ : renameIdentifierTokens(index, abs, line, name, renameTo).columns || [];
2634
+ const trusted = (existing.isDefinition || existing._evidence) &&
2635
+ !reviewList.some(review => review.reason === 'macro-argument-other-use');
2636
+ const hardUnits = new Set(reviewList.filter(review => review.reason === 'macro-argument-other-use')
2637
+ .map(review => review.unit).filter(Number.isInteger));
2638
+ const keep = trusted ? prior
2639
+ : prior.filter(column => !skipUnits.has(column) && !hardUnits.has(column));
2640
+ const final = new Set([...keep, ...editUnits]);
2641
+ const open = reviewList.filter(review => review.unit == null || !(trusted && final.has(review.unit)));
2642
+ if (final.size === 0) {
2643
+ if (open.length === 0) {
2644
+ changes.splice(at, 1);
2645
+ decided.set(key, { abs, edited: false });
2646
+ continue;
2647
+ }
2648
+ delete existing.newExpression;
2649
+ existing.needsReview = true;
2650
+ existing.reviewReason = open[0].reason;
2651
+ existing.suggestion = `Verify whether "${name}" here names ${name} at ${target} ` +
2652
+ `(${reasonText(open[0].reason)}) and rename it if it does`;
2653
+ reviewed.set(key, { abs, reason: open[0].reason });
2654
+ continue;
2655
+ }
2656
+ const edit = lineEdit(raw, final);
2657
+ const previous = existing.newExpression;
2658
+ existing.expression = edit.source;
2659
+ existing.newExpression = edit.renamed;
2660
+ if (typeof existing.suggestion === 'string' && previous && existing.suggestion.includes(previous)) {
2661
+ existing.suggestion = existing.suggestion.replace(previous, edit.renamed);
2662
+ }
2663
+ tagTokenEdit(existing, edit);
2664
+ if (open.length > 0) {
2665
+ existing.needsReview = true;
2666
+ existing.reviewReason = existing.reviewReason || open[0].reason;
2667
+ existing.suggestion += `; verify the "${name}" token(s) UCN could not resolve ` +
2668
+ `(${reasonText(open[0].reason)})`;
2669
+ reviewed.set(key, { abs, reason: open[0].reason });
2670
+ } else {
2671
+ // A manual flag another pass left because it could not
2672
+ // place a token is settled once every token is decided.
2673
+ if (existing.needsReview && !existing.reviewReason &&
2674
+ final.size + skipUnits.size >= slot.tokens) delete existing.needsReview;
2675
+ decided.set(key, { abs, edited: true });
2676
+ if (slot.stringAnnotation) stringEdited.add(key);
2677
+ }
2678
+ continue;
2679
+ }
2680
+ const certain = reviewList.length === 0;
2681
+ const edit = editUnits.length > 0 ? lineEdit(raw, editUnits) : null;
2682
+ const hasEdit = !!edit && edit.renamed !== edit.source;
2683
+ if (certain) {
2684
+ decided.set(key, { abs, edited: hasEdit });
2685
+ if (slot.stringAnnotation && hasEdit) stringEdited.add(key);
2686
+ } else {
2687
+ reviewed.set(key, { abs, reason: reviewList[0].reason });
2688
+ }
2689
+ const reviewNote = certain ? '' : `; verify the "${name}" token(s) UCN could not resolve ` +
2690
+ `(${reasonText(reviewList[0].reason)})`;
2691
+ if (existing) {
2692
+ // A manual review item another pass left on this line.
2693
+ if (existing.newExpression === undefined && existing.needsReview && !special(existing)) {
2694
+ if (certain && hasEdit) {
2695
+ const kind = existing.editKind === 'call' ? 'call' : 'reference';
2696
+ for (const field of Object.keys(existing)) delete existing[field];
2697
+ Object.assign(existing, {
2698
+ file: rel,
2699
+ line,
2700
+ expression: edit.source,
2701
+ suggestion: `${kind === 'call' ? 'Rename to' : 'Update type reference'}: ${edit.renamed}`,
2702
+ newExpression: edit.renamed,
2703
+ editKind: kind,
2704
+ });
2705
+ tagTokenEdit(existing, edit);
2706
+ } else if (certain) {
2707
+ // Every token here provably names another declaration.
2708
+ changes.splice(at, 1);
2709
+ } else if (typeof existing.suggestion === 'string' && !existing.suggestion.includes(reviewNote)) {
2710
+ existing.reviewReason = existing.reviewReason || reviewList[0].reason;
2711
+ existing.suggestion += reviewNote;
2712
+ }
2713
+ }
2714
+ continue;
2715
+ }
2716
+ if (hasEdit) {
2717
+ changes.push(tagTokenEdit({
2718
+ file: rel,
2719
+ line,
2720
+ expression: edit.source,
2721
+ suggestion: `Update type reference: ${edit.renamed}${reviewNote}`,
2722
+ newExpression: edit.renamed,
2723
+ editKind: 'reference',
2724
+ ...(!certain && { needsReview: true, reviewReason: reviewList[0].reason }),
2725
+ }, edit));
2726
+ } else if (!certain) {
2727
+ changes.push({
2728
+ file: rel,
2729
+ line,
2730
+ expression: source,
2731
+ suggestion: `Verify whether "${name}" here names ${name} at ${target} ` +
2732
+ `(${reasonText(reviewList[0].reason)}) and rename it if it does`,
2733
+ needsReview: true,
2734
+ reviewReason: reviewList[0].reason,
2735
+ editKind: 'reference',
2736
+ });
2737
+ }
2738
+ }
2739
+ }
2740
+ // C# applies `XAttribute` as `[X]` (fix #386).
2741
+ const { csharpAttributeShortSites } = require('./type-references');
2742
+ const suffix = 'Attribute';
2743
+ for (const site of csharpAttributeShortSites(index, def, name)) {
2744
+ if (site.verdict === 'no') continue;
2745
+ const short = name.slice(0, -suffix.length);
2746
+ const newShort = renameTo.endsWith(suffix) && renameTo.length > suffix.length
2747
+ ? renameTo.slice(0, -suffix.length) : renameTo;
2748
+ const raw = index._getFileLines(site.file)[site.line - 1] || '';
2749
+ const column = tokenColumn(raw, site.column, short);
2750
+ const existing = changes.find(change => change.file === site.relativePath && change.line === site.line);
2751
+ if (site.verdict === 'yes' && Number.isInteger(column) && !existing) {
2752
+ const renamed = raw.slice(0, column) + newShort + raw.slice(column + short.length);
2753
+ changes.push({
2754
+ file: site.relativePath,
2755
+ line: site.line,
2756
+ expression: raw.trim(),
2757
+ suggestion: `Update attribute usage: ${renamed.trim()}`,
2758
+ newExpression: renamed.trim(),
2759
+ editKind: 'reference',
2760
+ });
2761
+ continue;
2762
+ }
2763
+ const reason = site.verdict === 'yes' ? 'attribute-short-name' : site.reason || 'attribute-short-name';
2764
+ if (existing) {
2765
+ existing.needsReview = true;
2766
+ existing.reviewReason = existing.reviewReason || reason;
2767
+ continue;
2768
+ }
2769
+ changes.push({
2770
+ file: site.relativePath,
2771
+ line: site.line,
2772
+ expression: raw.trim(),
2773
+ suggestion: `Verify whether [${short}] applies ${name} at ${target} and rename it to [${newShort}] if it does`,
2774
+ needsReview: true,
2775
+ reviewReason: reason,
2776
+ editKind: 'reference',
2777
+ });
2778
+ }
2779
+ // String annotations the pass edited are no longer text-only review.
2780
+ if (stringEdited.size > 0) {
2781
+ for (let i = reviewItems.length - 1; i >= 0; i--) {
2782
+ const item = reviewItems[i];
2783
+ if (item.textDependency && stringEdited.has(`${item.file}:${item.line}`)) reviewItems.splice(i, 1);
2784
+ }
2785
+ }
2786
+ // Unverified call candidates on decided lines are settled.
2787
+ const removed = [];
2788
+ const kept = [];
2789
+ for (const site of planUnverified) {
2790
+ const key = `${site.file}:${site.line}`;
2791
+ if (decided.has(key)) removed.push(site);
2792
+ else kept.push(site);
2793
+ }
2794
+ if (removed.length > 0) {
2795
+ planUnverified.length = 0;
2796
+ planUnverified.push(...kept);
2797
+ }
2798
+ return { decided, reviewed, removed };
2799
+ }
2800
+
2801
+ /**
2802
+ * Object identity of a one-hop member-assignment definition (fix #359, ky-
2803
+ * measured): `response.json = async () => {...}` patches a property of the
2804
+ * object `response` names. It is a project declaration only when that object
2805
+ * is itself a project binding: a module-scope declaration or import of the
2806
+ * file (`const api = {}; api.get = ...`, `exports.h = ...`). A receiver bound
2807
+ * inside an enclosing function (parameter or local) or not declared at all
2808
+ * (a runtime global) names an object whose property contract belongs to its
2809
+ * own type, typically external (a Fetch `Response`); renaming it is not a
2810
+ * mechanical project rename. Returns 'module' | 'local' | 'external', or null
2811
+ * when the definition is not a receiver-named member assignment.
2812
+ */
2813
+ const FUNCTION_SCOPE_TYPES = new Set([
2814
+ 'function_declaration', 'function_expression', 'function', 'arrow_function',
2815
+ 'method_definition', 'generator_function', 'generator_function_declaration',
2816
+ ]);
2817
+ function memberAssignmentOwnerScope(index, def) {
2818
+ if (!def || !def.memberAssigned || def.className || !def.assignedReceiver) return null;
2819
+ const receiver = def.assignedReceiver;
2820
+ if (receiver === 'exports' || receiver === 'module') return 'module';
2821
+ const language = index.files.get(def.file)?.language;
2822
+ const parser = language && getParser(language);
2823
+ if (!parser) return null;
2824
+ const content = index._readFile(def.file);
2825
+ const tree = index._getParsedTree?.(def.file, content, language) || safeParse(parser, content);
2826
+ if (!tree) return null;
2827
+ const row = def.startLine - 1;
2828
+ // The assignment node at the definition line.
2829
+ let assignment = null;
2830
+ const stack = [tree.rootNode];
2831
+ while (stack.length > 0 && !assignment) {
2832
+ const node = stack.pop();
2833
+ if (node.endPosition.row < row || node.startPosition.row > row) continue;
2834
+ if (node.type === 'assignment_expression' && node.startPosition.row === row) {
2835
+ const left = node.childForFieldName('left');
2836
+ const obj = left && left.type === 'member_expression'
2837
+ ? left.childForFieldName('object') : null;
2838
+ const prop = left && left.childForFieldName('property');
2839
+ if (obj && obj.type === 'identifier' && obj.text === receiver &&
2840
+ prop && prop.text === def.name) {
2841
+ assignment = node;
2842
+ break;
2843
+ }
2844
+ }
2845
+ stack.push(...(node.namedChildren || []));
2846
+ }
2847
+ if (!assignment) return null;
2848
+ const declaresName = (node) => {
2849
+ // Binding identifiers introduced by a pattern / declarator subtree.
2850
+ const out = [];
2851
+ const walk = (n) => {
2852
+ if (!n) return;
2853
+ if ((n.type === 'identifier' || n.type === 'shorthand_property_identifier_pattern') &&
2854
+ n.text === receiver) { out.push(n); return; }
2855
+ if (n.type === 'default_value' || n.type === 'assignment_pattern') {
2856
+ walk(n.childForFieldName('left'));
2857
+ return;
2858
+ }
2859
+ for (const c of n.namedChildren || []) {
2860
+ if (c.type === 'type_annotation') continue;
2861
+ walk(c);
2862
+ }
2863
+ };
2864
+ walk(node);
2865
+ return out.length > 0;
2866
+ };
2867
+ const scopeDeclares = (scope) => {
2868
+ if (FUNCTION_SCOPE_TYPES.has(scope.type)) {
2869
+ const params = scope.childForFieldName('parameters') ||
2870
+ scope.childForFieldName('parameter');
2871
+ if (params && declaresName(params)) return true;
2872
+ }
2873
+ const body = FUNCTION_SCOPE_TYPES.has(scope.type)
2874
+ ? scope.childForFieldName('body') : scope;
2875
+ if (!body) return false;
2876
+ const stmts = [body];
2877
+ while (stmts.length > 0) {
2878
+ const n = stmts.pop();
2879
+ for (const c of n.namedChildren || []) {
2880
+ if (FUNCTION_SCOPE_TYPES.has(c.type) || c.type === 'class_declaration' ||
2881
+ c.type === 'class') {
2882
+ const nm = c.childForFieldName('name');
2883
+ if (nm && nm.text === receiver && c.type !== 'function_expression' &&
2884
+ c.type !== 'arrow_function' && c.type !== 'class') return true;
2885
+ continue;
2886
+ }
2887
+ if (c.type === 'variable_declarator') {
2888
+ if (declaresName(c.childForFieldName('name'))) return true;
2889
+ continue;
2890
+ }
2891
+ if (c.type === 'import_statement') {
2892
+ if (declaresName(c.namedChildren.find(x => x.type === 'import_clause'))) return true;
2893
+ continue;
2894
+ }
2895
+ if (c.type === 'catch_clause') {
2896
+ if (declaresName(c.childForFieldName('parameter'))) return true;
2897
+ }
2898
+ if (c.type === 'for_in_statement') {
2899
+ if (declaresName(c.childForFieldName('left'))) return true;
2900
+ }
2901
+ stmts.push(c);
2902
+ }
2903
+ }
2904
+ return false;
2905
+ };
2906
+ for (let scope = assignment.parent; scope; scope = scope.parent) {
2907
+ if (FUNCTION_SCOPE_TYPES.has(scope.type)) {
2908
+ if (scopeDeclares(scope)) return 'local';
2909
+ } else if (scope.type === 'program') {
2910
+ return scopeDeclares(scope) ? 'module' : 'external';
2911
+ }
2912
+ }
2913
+ return 'external';
2914
+ }
2915
+
2916
+ function plan(index, name, options = {}) {
2917
+ index._beginOp();
2918
+ // Every slot member of a rename is swept over the same files: their call
2919
+ // sites are analyzed on one tree per file for the whole plan (fix #395).
2920
+ const ownsTreeCache = !index._treeCache;
2921
+ if (ownsTreeCache) index._treeCache = new Map();
2922
+ try {
2923
+ const definitions = index.symbols.get(name);
2924
+ if (!definitions || definitions.length === 0) {
2925
+ return { found: false, function: name };
2926
+ }
2927
+
2928
+ const resolved = index.resolveSymbol(name, { file: options.file, className: options.className, line: options.line });
2929
+ let def = resolved.def || definitions[0];
2930
+ // A constructor or destructor carries its class's name (Java, C#, C++):
2931
+ // renaming it is renaming the class (fix #386).
2932
+ // (A C# member can never carry its type's name except the finalizer.)
2933
+ if (options.renameTo && def.className === def.name && !isTypeRenamePin(def) &&
2934
+ (def.type === 'constructor' || def.memberType === 'constructor' ||
2935
+ (def.type === 'method' && index.files.get(def.file)?.language === 'csharp'))) {
2936
+ const owner = definitions.find(d => isTypeRenamePin(d) && d.file === def.file &&
2937
+ d.startLine <= def.startLine && d.endLine >= def.endLine);
2938
+ if (owner) def = owner;
2939
+ }
2940
+ // BUG-BY: enrich types for typed-arrow-fn declarations.
2941
+ const arrowTypes = extractArrowTypesFromVarDecl(index, def);
2942
+ // Class target: the signature being planned is the CONSTRUCTOR's
2943
+ // (fix #230, same rule as verify) — single-ctor classes only; with
2944
+ // overloads the class def's own (empty) list stays, since plan cannot
2945
+ // know which overload the user means.
2946
+ const planLang = index.files.get(def.file)?.language;
2947
+ const planCtorLists = _constructorParamLists(index, def, planLang);
2948
+ const currentParams = (planCtorLists && planCtorLists.length === 1 && planCtorLists[0]) ||
2949
+ (arrowTypes?.paramsStructured) || def.paramsStructured || [];
2950
+ // BUG-BV: render with TS-correct param formatting (`opt?: number`).
2951
+ const currentSignature = formatTypedSignature(def,
2952
+ (planCtorLists && planCtorLists.length === 1)
2953
+ ? { paramsStructured: currentParams }
2057
2954
  : arrowTypes ? {
2058
2955
  paramsStructured: arrowTypes.paramsStructured,
2059
2956
  returnType: arrowTypes.returnType
@@ -2063,8 +2960,13 @@ function plan(index, name, options = {}) {
2063
2960
  // run contractedCallerSweep (v4 tiered contract), so plan and verify stay
2064
2961
  // in lock-step by construction. Unverified candidates are NOT planned
2065
2962
  // (they may target another symbol) but stay visible with reasons.
2066
- const { sites: planCallSites, unverifiedSites: planUnverified, account: planAccount,
2067
- groundSet: planGroundSet } = computePlanCallSites(index, name, def);
2963
+ const { sites: planCallSites, unverifiedSites: planUnverified, account: pinAccount,
2964
+ groundSet: planGroundSet, accountParts: planAccountParts } = computePlanCallSites(index, name, def,
2965
+ { analyzeArgs: !options.renameTo });
2966
+ let planAccount = pinAccount;
2967
+ // Call sites a slot member's sweep contributed to this plan (fix #376):
2968
+ // the plan's ACCOUNT is rebuilt over the same sites it lists.
2969
+ const slotAccountSites = [];
2068
2970
  const impactScopeWarning = computePlanScopeWarning(index, name, def, options);
2069
2971
 
2070
2972
  // Reject ambiguous multi-op invocations rather than silently coalescing.
@@ -2110,6 +3012,12 @@ function plan(index, name, options = {}) {
2110
3012
  let operation = null;
2111
3013
  let changes = [];
2112
3014
  const reviewItems = [];
3015
+ // Contract membership (fix #360): project slot roots the rename closed
3016
+ // over, and Go satisfaction sites the closure could not complete.
3017
+ const contractRootDefs = [];
3018
+ const goContractReviews = [];
3019
+ // Hierarchy slot closure of a rename (fix #376), for the contract check.
3020
+ let hierarchySlot = null;
2113
3021
  let unchangedSites = 0;
2114
3022
 
2115
3023
  if (options.addParam) {
@@ -2229,7 +3137,8 @@ function plan(index, name, options = {}) {
2229
3137
  const lang = fileEntry?.language;
2230
3138
  let selfOffset = 0;
2231
3139
  const planSelfParams = langTraits(lang)?.selfParam;
2232
- if (planSelfParams && currentParams.length > 0 && planSelfParams.includes(currentParams[0].name)) {
3140
+ if (planSelfParams && currentParams.length > 0 && planSelfParams.includes(
3141
+ String(currentParams[0].name || '').replace(/'[A-Za-z_]\w*\s*/g, '').trim())) {
2233
3142
  selfOffset = 1;
2234
3143
  }
2235
3144
  const callerArgIndex = paramIndex - selfOffset;
@@ -2266,6 +3175,8 @@ function plan(index, name, options = {}) {
2266
3175
  }
2267
3176
  }
2268
3177
 
3178
+ // The rename's usage records, shared with the method-group pass below.
3179
+ let planUsages = null;
2269
3180
  if (options.renameTo) {
2270
3181
  operation = 'rename';
2271
3182
  newSignature = currentSignature.replace(new RegExp('\\b' + escapeRegExp(name) + '\\b'), options.renameTo);
@@ -2280,9 +3191,95 @@ function plan(index, name, options = {}) {
2280
3191
  // exact token on the SAME line (`a.Run() || b.Run() || other.Run()`)
2281
3192
  // and update one edit instead of losing it to line-level deduplication.
2282
3193
  const emittedRenameCalls = new Map();
3194
+ // A definition edit on a line another change already edits joins
3195
+ // that change's token edit instead of being dropped (fix #376).
3196
+ const lineTaken = (member, rel, line) => {
3197
+ const existing = changes.find(change => change.file === rel && change.line === line);
3198
+ if (!existing) return false;
3199
+ if (existing.newExpression !== undefined && existing._edit) {
3200
+ const edit = renameIdentifierTokens(index, member.file, line, name,
3201
+ options.renameTo, null, null, { definitionNameOnly: true, definition: member });
3202
+ if (edit.renamed !== edit.source && mergeTokenEdit(existing, edit)) {
3203
+ existing.isDefinition = true;
3204
+ existing.editKind = 'definition';
3205
+ existing.suggestion = `Update definition: ${existing.newExpression}`;
3206
+ }
3207
+ }
3208
+ return true;
3209
+ };
3210
+ const emittedPatternKeys = new Set();
3211
+ const emitPatternKeyEdit = (absoluteFile, relativeFile, pattern) => {
3212
+ const key = `${relativeFile}:${pattern.line}:${pattern.column}`;
3213
+ if (emittedPatternKeys.has(key)) return;
3214
+ emittedPatternKeys.add(key);
3215
+ const replacement = pattern.shorthand
3216
+ ? `${options.renameTo}: ${pattern.local}` : options.renameTo;
3217
+ const edit = renameIdentifierTokens(index, absoluteFile, pattern.line, name,
3218
+ replacement, [pattern.column], 1);
3219
+ if (edit.renamed === edit.source) {
3220
+ if (!reviewItems.some(item => item.file === relativeFile && item.line === pattern.line)) {
3221
+ reviewItems.push({
3222
+ file: relativeFile,
3223
+ line: pattern.line,
3224
+ expression: edit.source,
3225
+ suggestion: `Rename the destructured property "${name}" to "${options.renameTo}" ` +
3226
+ 'manually, keeping the local binding',
3227
+ needsReview: true,
3228
+ editKind: 'reference',
3229
+ });
3230
+ }
3231
+ return;
3232
+ }
3233
+ const concrete = tagTokenEdit({
3234
+ file: relativeFile,
3235
+ line: pattern.line,
3236
+ expression: edit.source,
3237
+ suggestion: `Rename the destructured property: ${edit.renamed}`,
3238
+ newExpression: edit.renamed,
3239
+ editKind: 'reference',
3240
+ }, edit);
3241
+ const lineOwner = changes.find(change => change.file === relativeFile &&
3242
+ change.line === pattern.line && change.newExpression !== undefined && change._edit);
3243
+ if (!lineOwner || !mergeTokenEdit(lineOwner, edit)) changes.push(concrete);
3244
+ };
2283
3245
  const emitRenameCallSites = (siteList) => {
2284
3246
  const touched = new Set();
2285
3247
  for (const incoming of siteList) {
3248
+ // A target-typed `new(..)` spells no name: the type written at
3249
+ // its declaration site is what the rename edits (fix #393).
3250
+ if (incoming.targetTyped) continue;
3251
+ // A call of a name destructured from the member (`const { run }
3252
+ // = make(); run()`) spells the member at the pattern key (fix
3253
+ // #397): the key is renamed and the local binding kept
3254
+ // (`{ run }` -> `{ runZ: run }`), so the call line is unchanged.
3255
+ if (incoming.destructured) {
3256
+ emitPatternKeyEdit(incoming.absoluteFile || incoming.file, incoming.file, incoming.destructured);
3257
+ continue;
3258
+ }
3259
+ // A call spelled by an aliased import's local name keeps it; the
3260
+ // import's source side and the export chain carry the rename.
3261
+ if (incoming.importAliasSpelling) continue;
3262
+ // A pasted call target (`fs__##lc` with `XX(STAT, stat)`) is
3263
+ // not spelled on the invocation line: no token edit exists here,
3264
+ // and the rename is incomplete until the macro's arguments or
3265
+ // replacement list change with it (fix #362).
3266
+ if (incoming.macroExpansion?.origin === 'paste') {
3267
+ if (!reviewItems.some(item => item.file === incoming.file && item.line === incoming.line)) {
3268
+ reviewItems.push({
3269
+ file: incoming.file,
3270
+ line: incoming.line,
3271
+ expression: incoming.expression,
3272
+ suggestion: `Macro ${incoming.macroExpansion.macro} builds "${name}" by token pasting at this ` +
3273
+ 'invocation; change the macro arguments or definition so the expansion spells ' +
3274
+ `"${options.renameTo}"`,
3275
+ needsReview: true,
3276
+ templateDependency: true,
3277
+ reviewReason: 'macro-expansion',
3278
+ editKind: 'macro-expansion',
3279
+ });
3280
+ }
3281
+ continue;
3282
+ }
2286
3283
  const lineKey = `${incoming.file}:${incoming.line}`;
2287
3284
  const site = emittedRenameCalls.get(lineKey) || {
2288
3285
  file: incoming.file,
@@ -2297,7 +3294,10 @@ function plan(index, name, options = {}) {
2297
3294
  };
2298
3295
  if (site.calledAs !== incoming.calledAs) site.calledAs = null;
2299
3296
  site.callCount++;
2300
- if (Number.isInteger(incoming.column)) site.columns.push(incoming.column);
3297
+ const column = Number.isInteger(incoming.column) ? incoming.column
3298
+ : callTokenColumn(index, incoming.absoluteFile || incoming.file, incoming.siteRange,
3299
+ name, incoming.line);
3300
+ if (Number.isInteger(column)) site.columns.push(column);
2301
3301
  else site.missingColumn = true;
2302
3302
  emittedRenameCalls.set(lineKey, site);
2303
3303
  touched.add(lineKey);
@@ -2308,7 +3308,9 @@ function plan(index, name, options = {}) {
2308
3308
  // `{ old: local }`). The caller engine carries the authored name
2309
3309
  // in calledAs; that token must remain unchanged while the import's
2310
3310
  // source-side identifier is edited below.
2311
- if (site.calledAs && site.calledAs !== name) continue;
3311
+ // `calledAs: 'bound'` labels a bind/call/apply site
3312
+ // (`obj.m.bind(obj)`), which spells the name (fix #397).
3313
+ if (site.calledAs && site.calledAs !== name && site.calledAs !== 'bound') continue;
2312
3314
  let edit = renameIdentifierTokens(index,
2313
3315
  site.absoluteFile || site.file, site.line, name,
2314
3316
  options.renameTo, site.missingColumn ? [] : site.columns,
@@ -2334,7 +3336,7 @@ function plan(index, name, options = {}) {
2334
3336
  // Missing parser columns mean UCN can prove the edit is
2335
3337
  // required but cannot safely synthesize it. Never fall back
2336
3338
  // to whole-line regex replacement.
2337
- if (site.missingColumn) {
3339
+ if (site.missingColumn && site.calledAs !== 'bound') {
2338
3340
  const manual = {
2339
3341
  file: site.file,
2340
3342
  line: site.line,
@@ -2353,17 +3355,25 @@ function plan(index, name, options = {}) {
2353
3355
  }
2354
3356
  continue;
2355
3357
  }
2356
- const concrete = {
3358
+ const concrete = tagTokenEdit({
2357
3359
  file: site.file,
2358
3360
  line: site.line,
2359
3361
  expression: edit.source,
2360
3362
  suggestion: `Rename to: ${newExpression}`,
2361
3363
  newExpression,
2362
3364
  editKind: 'call',
2363
- };
2364
- if (site.change) {
3365
+ }, edit);
3366
+ // Another pass already edits this line (a slot member's
3367
+ // one-line declaration): add the call tokens to that edit.
3368
+ const lineOwner = site.mergedInto || (!site.change && changes.find(change =>
3369
+ change.file === site.file && change.line === site.line &&
3370
+ change.newExpression !== undefined && change._edit));
3371
+ if (lineOwner && mergeTokenEdit(lineOwner, edit)) {
3372
+ site.mergedInto = lineOwner;
3373
+ } else if (site.change) {
2365
3374
  for (const key of Object.keys(site.change)) delete site.change[key];
2366
3375
  Object.assign(site.change, concrete);
3376
+ tagTokenEdit(site.change, edit);
2367
3377
  } else {
2368
3378
  site.change = concrete;
2369
3379
  changes.push(concrete);
@@ -2394,22 +3404,95 @@ function plan(index, name, options = {}) {
2394
3404
  includeTests: true,
2395
3405
  internalEvidence: true,
2396
3406
  });
3407
+ planUsages = usages;
2397
3408
  const importUsages = usages.filter(u => u.usageType === 'import' && !u.isDefinition);
3409
+ // Import identity (fix #359, starlette-measured): an import binds a
3410
+ // module-level name. A MEMBER pin (method/field of a class, struct,
3411
+ // trait or impl) is never what `import json` binds, so renaming
3412
+ // Request.json must not rewrite the stdlib `import json` in its own
3413
+ // file. Languages whose bare names can denote members through a
3414
+ // member import (Java `import static a.D.head`) keep the edit only
3415
+ // when the binding provably reaches the pinned file.
3416
+ const pinIsMember = !!def.className && !!(def.isMethod || def.memberType);
3417
+ const memberImportable = pinIsMember &&
3418
+ !!langTraits(planLang)?.bareCallReachesMethods;
3419
+ const importMayBindPin = (file) => {
3420
+ // A source-side import token need not be exposed by its importing
3421
+ // file: `from core import transform as xf` binds only `xf` there.
3422
+ // The statement's source module decides ownership below.
3423
+ if (!pinIsMember) return true;
3424
+ return memberImportable &&
3425
+ _nameBindingReaches(index, file, name, renameTargetFiles) === 'yes';
3426
+ };
3427
+ // Import SOURCE identity (fix #376): an import statement's source-side
3428
+ // name is edited only when the module it imports from binds the
3429
+ // renamed definition. `import { flag as baseFlag } from './utils'`
3430
+ // inside the pin's own file imports ANOTHER module's flag; a module
3431
+ // never imports its own definition, so in the pin's file an import of
3432
+ // the name from any other (or unresolvable) module is never this
3433
+ // rename's import.
3434
+ const importSourceBindsPin = (filePath, line, column = null) => {
3435
+ const fileEntry = index.files.get(filePath);
3436
+ // Bindings carry their statement's first line: a token on a later
3437
+ // line of a multi-line import (`import {\n a,\n} from './m'`,
3438
+ // `from m import (\n a,\n)`) belongs to that statement (fix #400).
3439
+ const onLine = (fileEntry?.importBindings || []).some(binding => binding.line === line &&
3440
+ (binding.name === name || binding.alias === name));
3441
+ const statementLine = onLine ? line : importStatementLine(index, filePath, line, column);
3442
+ const bindings = (fileEntry?.importBindings || []).filter(binding =>
3443
+ (binding.line === line || binding.line === statementLine) &&
3444
+ binding.name === name && binding.module);
3445
+ // The name is only the LOCAL alias of another imported name on
3446
+ // this line (`import { g as f }`, `const f = require('m').g`,
3447
+ // fix #397): an import alias keeps its local name, and the
3448
+ // source-side name is not the renamed definition's.
3449
+ if (bindings.length === 0 && (fileEntry?.importBindings || []).some(binding =>
3450
+ (binding.line === line || binding.line === statementLine) &&
3451
+ binding.alias === name && binding.name !== name)) return false;
3452
+ if (bindings.length === 0) return true;
3453
+ return bindings.some(binding => {
3454
+ const rel = fileEntry.moduleResolved?.[binding.module];
3455
+ const abs = rel ? (require('path').isAbsolute(rel) ? rel
3456
+ : require('path').join(index.root, rel)) : null;
3457
+ // An import from a module outside the project (`const parse =
3458
+ // require('parseurl')`, fix #397) binds that module's value,
3459
+ // never the renamed definition; a resolver gap keeps the edit.
3460
+ if (!abs && !require('./callers')._unresolvedModuleIsGap(index, binding.module, binding)) return false;
3461
+ if (!abs) return filePath !== def.file;
3462
+ if (abs === filePath) return false;
3463
+ // A module binding the name through one alternative of a
3464
+ // configuration item (fix #398F) keeps its item name: the
3465
+ // importer's token names the item, not the renamed pin.
3466
+ const verdict = _nameBindingReaches(index, abs, name, renameTargetFiles, 4,
3467
+ { configurationVerdict: true });
3468
+ return verdict !== 'no' && verdict !== 'configuration';
3469
+ });
3470
+ };
3471
+ // An unaliased import that is one alternative of a configuration
3472
+ // item in its own scope (fix #398F: `try: from ._speedups import f` /
3473
+ // `except ImportError: def f`) keeps the item's local name.
3474
+ const importKeepsItemName = (filePath, line) => {
3475
+ const { itemsNamed } = require('./configuration-items');
3476
+ return itemsNamed(index, filePath, name).some(item =>
3477
+ item.sites.some(site => site.kind === 'import' && site.line === line));
3478
+ };
2398
3479
  for (const imp of importUsages) {
2399
3480
  // Skip if already covered by a call site change in the same file:line
2400
3481
  const alreadyCovered = changes.some(c =>
2401
3482
  c.file === (imp.relativePath || imp.file) && c.line === imp.line
2402
3483
  );
2403
3484
  if (alreadyCovered) continue;
2404
- if (imp.file && def.file &&
2405
- _nameBindingReaches(index, imp.file, name, renameTargetFiles) === 'no') {
2406
- continue;
2407
- }
3485
+ // The token is the importer's own local alias (fix #397): an
3486
+ // import alias keeps its local name.
3487
+ if (imp.importAlias) continue;
3488
+ if (imp.file && def.file && !importMayBindPin(imp.file)) continue;
3489
+ if (imp.file && !importSourceBindsPin(imp.file, imp.line, imp.column)) continue;
2408
3490
  const edit = renameIdentifierTokens(index, imp.file,
2409
- imp.line, name, options.renameTo);
3491
+ imp.line, name, imp.file && importKeepsItemName(imp.file, imp.line)
3492
+ ? `${options.renameTo} as ${name}` : options.renameTo);
2410
3493
  const newImport = edit.renamed;
2411
3494
  if (newImport === edit.source) continue;
2412
- changes.push({
3495
+ changes.push(tagTokenEdit({
2413
3496
  file: imp.relativePath || imp.file,
2414
3497
  line: imp.line,
2415
3498
  expression: edit.source,
@@ -2417,7 +3500,7 @@ function plan(index, name, options = {}) {
2417
3500
  newExpression: newImport,
2418
3501
  isImport: true,
2419
3502
  editKind: 'import',
2420
- });
3503
+ }, edit));
2421
3504
  }
2422
3505
 
2423
3506
  // Renamed CJS/Python imports are intentionally surfaced as reference
@@ -2429,8 +3512,8 @@ function plan(index, name, options = {}) {
2429
3512
  .find(alias => alias.original === binding.name)?.local;
2430
3513
  if (binding.name !== name || !localAlias ||
2431
3514
  localAlias === name || !binding.line) continue;
2432
- if (_nameBindingReaches(index, filePath, name,
2433
- renameTargetFiles) === 'no') continue;
3515
+ if (!importMayBindPin(filePath)) continue;
3516
+ if (!importSourceBindsPin(filePath, binding.line)) continue;
2434
3517
  const rel = fileEntry.relativePath || filePath;
2435
3518
  if (changes.some(change =>
2436
3519
  change.file === rel && change.line === binding.line)) continue;
@@ -2485,18 +3568,27 @@ function plan(index, name, options = {}) {
2485
3568
  const pinnedAtExportLine = defsAtExportLine.some(d =>
2486
3569
  d.file === def.file && d.startLine === def.startLine);
2487
3570
  if (defsAtExportLine.length > 0 && !pinnedAtExportLine) continue;
3571
+ // A member pin's export surface is its own declaration line
3572
+ // only; a same-named module-level export belongs to another
3573
+ // binding (fix #359).
3574
+ if (pinIsMember && !pinnedAtExportLine) continue;
2488
3575
  const exportFile = targetEntry.relativePath || exportPath;
2489
3576
  if (changes.some(change =>
2490
3577
  change.file === exportFile && change.line === exported.line)) {
2491
3578
  continue;
2492
3579
  }
3580
+ // The pinned definition's own line is an export surface of
3581
+ // THAT declaration (`export function head(head: string)`):
3582
+ // rename only the declaration's name token (fix #359).
2493
3583
  const edit = renameIdentifierTokens(index, exportPath,
2494
- exported.line, name, options.renameTo);
3584
+ exported.line, name, options.renameTo, null, null,
3585
+ pinnedAtExportLine
3586
+ ? { definitionNameOnly: true, definition: def } : {});
2495
3587
  const sourceLine = edit.source;
2496
3588
  const occurrences = edit.count;
2497
3589
  const newExpression = edit.renamed;
2498
3590
  if (newExpression === sourceLine) continue;
2499
- changes.push({
3591
+ changes.push(tagTokenEdit({
2500
3592
  file: exportFile,
2501
3593
  line: exported.line,
2502
3594
  expression: sourceLine,
@@ -2505,7 +3597,7 @@ function plan(index, name, options = {}) {
2505
3597
  isExport: true,
2506
3598
  editKind: 'export',
2507
3599
  ...(occurrences > 1 && { needsReview: true }),
2508
- });
3600
+ }, edit));
2509
3601
  }
2510
3602
  }
2511
3603
 
@@ -2594,82 +3686,290 @@ function plan(index, name, options = {}) {
2594
3686
  if (member === def) continue;
2595
3687
  const line = member.nameLine || member.startLine;
2596
3688
  const rel = member.relativePath || member.file;
2597
- if (changes.some(change =>
2598
- change.file === rel && change.line === line)) continue;
3689
+ if (lineTaken(member, rel, line)) continue;
2599
3690
  const edit = renameIdentifierTokens(index, member.file,
2600
3691
  line, name, options.renameTo,
2601
- null, null, { definitionNameOnly: true });
3692
+ null, null, { definitionNameOnly: true, definition: member });
2602
3693
  if (edit.renamed === edit.source) continue;
2603
- changes.push({
3694
+ changes.push(tagTokenEdit({
2604
3695
  file: rel,
2605
3696
  line,
2606
3697
  expression: edit.source,
2607
- suggestion: `Update overload signature: ${edit.renamed}`,
3698
+ suggestion: langTraits(index.files.get(member.file)?.language)?.textualIncludes
3699
+ ? `Update declaration: ${edit.renamed}`
3700
+ : isAccessorDefinition(member) && isAccessorDefinition(def)
3701
+ ? `Update accessor: ${edit.renamed}`
3702
+ : `Update overload signature: ${edit.renamed}`,
2608
3703
  newExpression: edit.renamed,
2609
3704
  isDefinition: true,
2610
3705
  editKind: 'definition',
2611
- });
3706
+ }, edit));
3707
+ }
3708
+ }
3709
+
3710
+ // Configuration alternatives (fix #376): in a language where a
3711
+ // definition cannot be repeated in one scope except under
3712
+ // conditional compilation (traits.conditionalDefinitions: Rust
3713
+ // `#[cfg]`, C/C++/C# `#if`), a same-file definition of the same name,
3714
+ // owner and signature is the SAME item in another build
3715
+ // configuration. Only one is compiled at a time, so the caller engine
3716
+ // binds calls to one of them; the rename must change every
3717
+ // alternative and every alternative's call sites.
3718
+ const conditionalStyle = langTraits(planLang)?.conditionalDefinitions;
3719
+ // Other external definitions of a C/C++ free function in other files
3720
+ // (fix #396: platform variants `net_open` in net.c and net_win.c):
3721
+ // each defines the function its shared declaration declares, so the
3722
+ // rename changes them all; one no shared declaration ties to the pin
3723
+ // (a separate program's own definition) is a review item.
3724
+ const linkAlternatives = cLinkAlternatives(index, def, definitions);
3725
+ for (const other of linkAlternatives.unrelated) {
3726
+ reviewItems.push({
3727
+ file: other.relativePath || path.relative(index.root, other.file),
3728
+ line: other.nameLine || other.startLine,
3729
+ expression: (index.getLineContent(other.file, other.nameLine || other.startLine) || '').trim(),
3730
+ suggestion: `Another external definition of "${name}" that no declaration of the renamed ` +
3731
+ 'function reaches (a separate program or platform build); rename it too if it is the same function',
3732
+ needsReview: true,
3733
+ editKind: 'definition',
3734
+ });
3735
+ }
3736
+ if (conditionalStyle && !def.isSignature) {
3737
+ const { _cFamilySignatureKey, _isCrossFileConfigurationMember, _sameOwnerPath,
3738
+ _isConfigurationAlternative } = require('./callers');
3739
+ const hasCfg = d => (d.attributesWithArgs || []).some(a => a.name === 'cfg') ||
3740
+ (d.modifiers || []).includes('cfg');
3741
+ const alternatives = definitions.filter(other => other !== def && ((
3742
+ // Runtime configuration items (fix #398F, Python): the
3743
+ // parser-recorded alternatives of the item, whatever their
3744
+ // parameters.
3745
+ conditionalStyle === 'branch' ? _isConfigurationAlternative(index, def, other) :
3746
+ other.file === def.file && !other.isSignature &&
3747
+ !NON_CALLABLE_TYPES.has(other.type) &&
3748
+ (other.className || null) === (def.className || null) &&
3749
+ (other.traitName || null) === (def.traitName || null) &&
3750
+ (other.namespace || null) === (def.namespace || null) &&
3751
+ (other.explicitInterface || null) === (def.explicitInterface || null) &&
3752
+ !(other.startLine === def.startLine && other.endLine === def.endLine) &&
3753
+ // Members of two same-name classes declared in different
3754
+ // scopes (a function-local class) are two items (fix #389).
3755
+ !distinctOwnerDefinitions(index, other, def) &&
3756
+ // One full owner path (fix #395): same-name nested types of
3757
+ // two outer classes are two items.
3758
+ _sameOwnerPath(index, other, def) &&
3759
+ _cFamilySignatureKey(other) === _cFamilySignatureKey(def) &&
3760
+ (conditionalStyle !== 'attribute' || hasCfg(def) || hasCfg(other))) ||
3761
+ // The same class defined again in a file no translation unit
3762
+ // includes beside this one (fix #385: `#ifdef _WIN32
3763
+ // #include "client-windows.h" #else #include "client.h"`).
3764
+ (!other.isSignature && _isCrossFileConfigurationMember(index, def, other)) ||
3765
+ linkAlternatives.defines.includes(other)));
3766
+ const alternativeSites = [];
3767
+ for (const other of alternatives) {
3768
+ const line = other.nameLine || other.startLine;
3769
+ const rel = other.relativePath || other.file;
3770
+ if (!lineTaken(other, rel, line)) {
3771
+ const edit = renameIdentifierTokens(index, other.file, line, name,
3772
+ options.renameTo, null, null, { definitionNameOnly: true, definition: other });
3773
+ if (edit.renamed !== edit.source) {
3774
+ changes.push(tagTokenEdit({
3775
+ file: rel,
3776
+ line,
3777
+ expression: edit.source,
3778
+ suggestion: linkAlternatives.defines.includes(other)
3779
+ ? `Update another definition of the declared function: ${edit.renamed}`
3780
+ : `Update configuration alternative: ${edit.renamed}`,
3781
+ newExpression: edit.renamed,
3782
+ isDefinition: true,
3783
+ editKind: 'definition',
3784
+ }, edit));
3785
+ }
3786
+ }
3787
+ const sweep = computePlanCallSites(index, name, other, { analyzeArgs: !options.renameTo });
3788
+ const seen = new Set([...planCallSites, ...alternativeSites].map(site =>
3789
+ `${site.file}:${site.line}:${site.column ?? '*'}`));
3790
+ const lines = new Set([...planCallSites, ...alternativeSites].map(site =>
3791
+ `${site.file}:${site.line}`));
3792
+ for (const site of sweep.sites) {
3793
+ const key = `${site.file}:${site.line}:${site.column ?? '*'}`;
3794
+ if (seen.has(key)) continue;
3795
+ // One call reached through two alternatives' sweeps, once
3796
+ // without its column (fix #385: a macro alternative's
3797
+ // invocation beside the function's own call record).
3798
+ if (site.column == null && lines.has(`${site.file}:${site.line}`)) continue;
3799
+ const lineless = alternativeSites.findIndex(existing => existing.column == null &&
3800
+ existing.file === site.file && existing.line === site.line);
3801
+ if (lineless >= 0) alternativeSites.splice(lineless, 1);
3802
+ seen.add(key);
3803
+ lines.add(`${site.file}:${site.line}`);
3804
+ alternativeSites.push(site);
3805
+ }
3806
+ for (const site of sweep.unverifiedSites) {
3807
+ if (planUnverified.some(existing => existing.file === site.file &&
3808
+ existing.line === site.line)) continue;
3809
+ // A call another alternative's sweep already edits is that
3810
+ // edit, not a second (column-less) site of review.
3811
+ if (lines.has(`${site.file}:${site.line}`)) continue;
3812
+ planUnverified.push(site);
3813
+ }
3814
+ }
3815
+ // A call the engine left ambiguous only between configuration
3816
+ // alternatives binds one of them whichever configuration builds:
3817
+ // when the rename covers every project body of the name, it is
3818
+ // an edit.
3819
+ if (alternatives.length > 0) {
3820
+ const renamed = new Set([def, ...alternatives]);
3821
+ const coversAll = definitions.every(d => renamed.has(d) || d.isSignature ||
3822
+ NON_CALLABLE_TYPES.has(d.type) ||
3823
+ (d.className || null) !== (def.className || null));
3824
+ if (coversAll) {
3825
+ const promoted = planUnverified.filter(site =>
3826
+ site.reason === 'link-ambiguous' || site.reason === 'macro-definition-ambiguous' ||
3827
+ site.reason === 'overload-ambiguous' || site.reason === 'configuration-alternative');
3828
+ const editedLines = new Set([...planCallSites, ...alternativeSites]
3829
+ .map(site => `${site.file}:${site.line}`));
3830
+ for (const site of promoted) {
3831
+ // Already an edit through an alternative's own sweep.
3832
+ if (editedLines.has(`${site.file}:${site.line}`)) continue;
3833
+ alternativeSites.push({
3834
+ file: site.file,
3835
+ absoluteFile: require('path').join(index.root, site.file),
3836
+ line: site.line,
3837
+ expression: site.expression,
3838
+ });
3839
+ }
3840
+ }
3841
+ }
3842
+ if (alternativeSites.length > 0) {
3843
+ emitRenameCallSites(alternativeSites);
3844
+ slotAccountSites.push(...alternativeSites);
3845
+ const changedLines = new Set(changes.map(change => `${change.file}:${change.line}`));
3846
+ const kept = planUnverified.filter(site => !changedLines.has(`${site.file}:${site.line}`));
3847
+ planUnverified.length = 0;
3848
+ planUnverified.push(...kept);
3849
+ planUnverified.sort((a, b) => codeUnitCompare(a.file, b.file) || a.line - b.line);
3850
+ }
3851
+ }
3852
+
3853
+ // Run-time alternative bindings of the same scope variable (fix
3854
+ // #376): a language whose adapter reports them (Python) binds a name
3855
+ // per executed statement, so `if ...: f = lib.f` / `else: def f()`,
3856
+ // try/except import fallbacks and a class body's `close = release`
3857
+ // are one variable with the pin. Definitions and assignment targets
3858
+ // are renamed with it, an unaliased import alternative gains an
3859
+ // alias, and class-body reads of a member are references.
3860
+ {
3861
+ let adapter = null;
3862
+ try { adapter = getLanguageAdapter(planLang); } catch { adapter = null; }
3863
+ if (typeof adapter?.findScopeAlternativeBindings === 'function') {
3864
+ let tree = null;
3865
+ try {
3866
+ const content = index._readFile(def.file);
3867
+ const parser = getParser(planLang);
3868
+ tree = parser && (index._getParsedTree?.(def.file, content, planLang) ||
3869
+ safeParse(parser, content));
3870
+ } catch { tree = null; }
3871
+ const bindings = tree
3872
+ ? adapter.findScopeAlternativeBindings(tree, def.nameLine || def.startLine, name)
3873
+ : [];
3874
+ const rel = def.relativePath || def.file;
3875
+ for (const binding of bindings) {
3876
+ if (changes.some(change => change.file === rel && change.line === binding.line)) continue;
3877
+ const replacement = binding.kind === 'import-name'
3878
+ ? `${name} as ${options.renameTo}` : options.renameTo;
3879
+ const edit = renameIdentifierTokens(index, def.file, binding.line, name,
3880
+ replacement, [binding.column]);
3881
+ if (edit.renamed === edit.source) continue;
3882
+ const isReference = binding.kind === 'reference';
3883
+ changes.push(tagTokenEdit({
3884
+ file: rel,
3885
+ line: binding.line,
3886
+ expression: edit.source,
3887
+ suggestion: isReference
3888
+ ? `Update class-scope reference: ${edit.renamed}`
3889
+ : `Update alternative binding: ${edit.renamed}`,
3890
+ newExpression: edit.renamed,
3891
+ ...(!isReference && { isDefinition: true }),
3892
+ editKind: isReference ? 'reference' : 'definition',
3893
+ }, edit));
3894
+ }
2612
3895
  }
2613
3896
  }
2614
3897
 
2615
3898
  // Renaming a virtual/overridden member is one hierarchy-wide change.
2616
3899
  // Leaving descendant declarations behind either fails compilation
2617
3900
  // (Java/C#/TS override) or silently changes dispatch (Python/JS).
2618
- if (def.className) {
2619
- // Inheritance identity is (class name, defining file), not just
2620
- // the spelling. Repositories routinely contain two Handler/Base
2621
- // classes in unrelated packages. Follow only children whose base
2622
- // resolves from the child's scope to this exact parent file.
2623
- const identityKey = (className, file) => `${file || ''}\0${className}`;
2624
-
2625
- // The pinned member may itself be an override: the dispatch slot
2626
- // is rooted at the TOPMOST project ancestor defining the name
2627
- // (flask PassList.check → JSONTag.check → every sibling
2628
- // override; outcome-eval 2026-08-18). Climb the extends chain
2629
- // with the same identity discipline, emit each ancestor definer,
2630
- // then walk DOWN from the root so sibling overrides join too.
2631
- // Arity-overload languages require the ancestor's signature to
2632
- // be the same virtual slot (a same-name different-arity Java
2633
- // method is a sibling, never the root).
2634
- let slotRoot = { name: def.className, file: def.file };
3901
+ // A typed object-literal member (`const h: Handler = { handle() {} }`)
3902
+ // fills the slot of its declared interface the same way (fix #360).
3903
+ const literalContract = typedLiteralContract(index, def);
3904
+ if (def.className || literalContract?.projectInterface) {
3905
+ // Slot closure by class DEFINITION identity (fix #376): the
3906
+ // renamed member's slot spans every transitive ancestor that
3907
+ // declares it - including through intermediate classes that do
3908
+ // not redeclare it (jackson WriterBasedJsonGenerator.writeName
3909
+ // -> JsonGeneratorBase -> GeneratorBase -> JsonGenerator) - and
3910
+ // every transitive descendant of those declaring classes that
3911
+ // overrides it. Parents resolve from the declaring file
3912
+ // (qualified and nested spellings included), children only when
3913
+ // their own base resolves back to the same definition; languages
3914
+ // with overloads by parameter list keep only the same-signature
3915
+ // slot. Declared `implements` clauses are slot edges for members
3916
+ // declared in the class body.
3917
+ let slotStartRef = null;
3918
+ if (!def.className && literalContract?.projectInterface) {
3919
+ const iface = literalContract.projectInterface;
3920
+ const entry = classDefsNamed(index, iface.name).entries
3921
+ .find(e => e.def.file === iface.file);
3922
+ if (entry) slotStartRef = { name: iface.name, key: entry.key, def: entry.def };
3923
+ }
3924
+ const hierarchy = (def.className || slotStartRef)
3925
+ ? hierarchySlotClosure(index, def, slotStartRef) : null;
2635
3926
  const slotAncestors = [];
2636
- const climbed = new Set([identityKey(slotRoot.name, slotRoot.file)]);
2637
- for (let hop = 0; hop < 8; hop++) {
2638
- const parents = index._getInheritanceParents(
2639
- slotRoot.name, slotRoot.file) || [];
2640
- let moved = false;
2641
- for (const parentName of parents) {
2642
- const parentFile = index._resolveClassFile(parentName, slotRoot.file);
2643
- if (!parentFile) continue;
2644
- const parentDef = (index.symbols.get(name) || []).find(symbol =>
2645
- symbol.className === parentName && symbol.file === parentFile &&
2646
- !NON_CALLABLE_TYPES.has(symbol.type));
2647
- if (!parentDef) continue;
2648
- if (langTraits(planLang)?.hasArityOverloads &&
2649
- (parentDef.paramsStructured || []).length !==
2650
- (def.paramsStructured || []).length) continue;
2651
- const key = identityKey(parentName, parentFile);
2652
- if (climbed.has(key)) break;
2653
- climbed.add(key);
2654
- slotRoot = { name: parentName, file: parentFile };
2655
- slotAncestors.push({ className: parentName, file: parentFile, def: parentDef });
2656
- moved = true;
2657
- break;
2658
- }
2659
- if (!moved) break;
3927
+ for (const ancestor of hierarchy?.ancestors || []) {
3928
+ for (const member of ancestor.members) {
3929
+ if (member.file === def.file && member.startLine === def.startLine) continue;
3930
+ slotAncestors.push({ className: ancestor.ref.name, file: member.file, def: member });
3931
+ }
2660
3932
  }
3933
+ hierarchySlot = hierarchy;
2661
3934
  const slotMemberDefs = [];
3935
+ // fix #379: a C/C++ slot member declared in its class and
3936
+ // defined out of line (`void Derived::f() {}` in another file)
3937
+ // is one callable; renaming the declaration alone leaves a
3938
+ // definition that matches nothing.
3939
+ const { _closeCallableIdentityGroup: closeIdentity } = require('./callers');
3940
+ const renameSlotPartners = (member, suggestion) => {
3941
+ for (const partner of closeIdentity(index, [member], definitions)) {
3942
+ if (partner === member ||
3943
+ (partner.file === def.file && partner.startLine === def.startLine)) continue;
3944
+ const line = partner.nameLine || partner.startLine;
3945
+ const rel = partner.relativePath || partner.file;
3946
+ if (lineTaken(partner, rel, line)) continue;
3947
+ const edit = renameIdentifierTokens(index, partner.file,
3948
+ line, name, options.renameTo,
3949
+ null, null, { definitionNameOnly: true, definition: partner });
3950
+ if (edit.renamed === edit.source) continue;
3951
+ changes.push(tagTokenEdit({
3952
+ file: rel,
3953
+ line,
3954
+ expression: edit.source,
3955
+ suggestion: `${suggestion}: ${edit.renamed}`,
3956
+ newExpression: edit.renamed,
3957
+ isDefinition: true,
3958
+ editKind: 'definition',
3959
+ }, edit));
3960
+ slotMemberDefs.push(partner);
3961
+ }
3962
+ };
2662
3963
  for (const ancestor of slotAncestors) {
2663
3964
  slotMemberDefs.push(ancestor.def);
2664
3965
  const line = ancestor.def.nameLine || ancestor.def.startLine;
2665
3966
  const rel = ancestor.def.relativePath || ancestor.def.file;
2666
- if (changes.some(change =>
2667
- change.file === rel && change.line === line)) continue;
3967
+ if (lineTaken(ancestor.def, rel, line)) continue;
2668
3968
  const edit = renameIdentifierTokens(index, ancestor.def.file,
2669
3969
  line, name, options.renameTo,
2670
- null, null, { definitionNameOnly: true });
3970
+ null, null, { definitionNameOnly: true, definition: ancestor.def });
2671
3971
  if (edit.renamed === edit.source) continue;
2672
- changes.push({
3972
+ changes.push(tagTokenEdit({
2673
3973
  file: rel,
2674
3974
  line,
2675
3975
  expression: edit.source,
@@ -2677,59 +3977,112 @@ function plan(index, name, options = {}) {
2677
3977
  newExpression: edit.renamed,
2678
3978
  isDefinition: true,
2679
3979
  editKind: 'definition',
2680
- });
3980
+ }, edit));
2681
3981
  }
2682
-
2683
- const descendants = new Map();
2684
- const queue = [{ name: slotRoot.name, file: slotRoot.file }];
2685
- const visited = new Set([identityKey(slotRoot.name, slotRoot.file)]);
2686
- while (queue.length > 0 && descendants.size < 5000) {
2687
- const parent = queue.shift();
2688
- for (const child of index.extendedByGraph.get(parent.name) || []) {
2689
- const childName = typeof child === 'string' ? child : child.name;
2690
- const childFile = typeof child === 'string'
2691
- ? index._resolveClassFile(childName, parent.file)
2692
- : child.file;
2693
- if (!childName || !childFile) continue;
2694
- const parentFile = index._resolveClassFile(parent.name, childFile);
2695
- if (parentFile !== parent.file) continue;
2696
- const key = identityKey(childName, childFile);
2697
- if (visited.has(key)) continue;
2698
- visited.add(key);
2699
- descendants.set(key, { name: childName, file: childFile });
2700
- queue.push({ name: childName, file: childFile });
2701
- }
2702
- }
2703
- for (const override of index.symbols.get(name) || []) {
2704
- if (!override.className ||
2705
- !descendants.has(identityKey(override.className, override.file))) continue;
2706
- // Slot-rooting can put the pin's OWN class in the descendant
2707
- // set. The pin handles itself; same-class siblings (Java
2708
- // arity overloads, TS/Python signature stubs) belong to the
2709
- // identity-group pass, never the hierarchy walk.
2710
- if (override.className === def.className &&
2711
- override.file === def.file &&
2712
- override.startLine !== def.startLine) continue;
2713
- const line = override.nameLine || override.startLine;
2714
- const rel = override.relativePath || override.file;
2715
- if (changes.some(change => change.file === rel && change.line === line)) continue;
2716
- const edit = renameIdentifierTokens(index, override.file,
2717
- line, name, options.renameTo,
2718
- null, null, { definitionNameOnly: true });
2719
- const sourceLine = edit.source;
2720
- const newExpression = edit.renamed;
2721
- if (newExpression === sourceLine) continue;
2722
- changes.push({
3982
+ for (const ancestor of slotAncestors) renameSlotPartners(ancestor.def, 'Update base definition');
3983
+
3984
+ const pinOwnerKey = hierarchy?.pinRef?.key || null;
3985
+ for (const descendant of hierarchy?.descendants || []) {
3986
+ for (const override of descendant.members) {
3987
+ // The pin handles itself; same-class siblings (Java
3988
+ // arity overloads, TS/Python signature stubs) belong to
3989
+ // the identity-group pass, never the hierarchy walk.
3990
+ if (pinOwnerKey && descendant.ref.key === pinOwnerKey) continue;
3991
+ if (override.file === def.file && override.startLine === def.startLine) continue;
3992
+ const line = override.nameLine || override.startLine;
3993
+ const rel = override.relativePath || override.file;
3994
+ if (lineTaken(override, rel, line)) continue;
3995
+ const edit = renameIdentifierTokens(index, override.file,
3996
+ line, name, options.renameTo,
3997
+ null, null, { definitionNameOnly: true, definition: override });
3998
+ const sourceLine = edit.source;
3999
+ const newExpression = edit.renamed;
4000
+ if (newExpression === sourceLine) continue;
4001
+ changes.push(tagTokenEdit({
4002
+ file: rel,
4003
+ line,
4004
+ expression: sourceLine,
4005
+ suggestion: `Update overriding definition: ${newExpression}`,
4006
+ newExpression,
4007
+ isDefinition: true,
4008
+ editKind: 'definition',
4009
+ }, edit));
4010
+ slotMemberDefs.push(override);
4011
+ }
4012
+ }
4013
+ for (const descendant of hierarchy?.descendants || []) {
4014
+ if (pinOwnerKey && descendant.ref.key === pinOwnerKey) continue;
4015
+ for (const override of descendant.members) {
4016
+ renameSlotPartners(override, 'Update overriding definition');
4017
+ }
4018
+ }
4019
+ const slotOwners = [
4020
+ ...(hierarchy ? [hierarchy.pinRef, ...hierarchy.ancestors.map(a => a.ref),
4021
+ ...hierarchy.descendants.map(d => d.ref)] : []),
4022
+ ].filter(ref => ref?.def?.file).map(ref => ({ name: ref.name, file: ref.def.file }));
4023
+ // Typed object literals declared against the root or any
4024
+ // descendant interface fill the same slot.
4025
+ for (const owner of slotOwners) {
4026
+ for (const member of typedLiteralMembers(index, name, owner.name, owner.file)) {
4027
+ if (member.file === def.file && member.startLine === def.startLine) continue;
4028
+ const line = member.nameLine || member.startLine;
4029
+ const rel = member.relativePath || member.file;
4030
+ if (lineTaken(member, rel, line)) continue;
4031
+ const edit = renameIdentifierTokens(index, member.file,
4032
+ line, name, options.renameTo,
4033
+ null, null, { definitionNameOnly: true, definition: member });
4034
+ if (edit.renamed === edit.source) continue;
4035
+ changes.push(tagTokenEdit({
4036
+ file: rel,
4037
+ line,
4038
+ expression: edit.source,
4039
+ suggestion: `Update typed object-literal member: ${edit.renamed}`,
4040
+ newExpression: edit.renamed,
4041
+ isDefinition: true,
4042
+ editKind: 'definition',
4043
+ }, edit));
4044
+ slotMemberDefs.push(member);
4045
+ }
4046
+ }
4047
+ // Anonymous subclasses (fix #390): a Java `new Listener() {
4048
+ // @Override void run() {} }` or a JS/TS class expression that
4049
+ // extends a slot owner overrides the member without being an
4050
+ // indexed class.
4051
+ for (const site of anonymousSlotMembers(index, name, planGroundSet?.perFile, hierarchy, def)) {
4052
+ const rel = require('path').relative(index.root, site.file);
4053
+ if (site.verdict === 'review') {
4054
+ if (!changes.some(change => change.file === rel && change.line === site.line)) {
4055
+ changes.push({
4056
+ file: rel,
4057
+ line: site.line,
4058
+ expression: (index._getFileLines(site.file)[site.line - 1] || '').trim(),
4059
+ suggestion: `Verify whether this anonymous class member overrides ${name} and rename it to ${options.renameTo} if it does`,
4060
+ needsReview: true,
4061
+ reviewReason: site.reason,
4062
+ editKind: 'definition',
4063
+ });
4064
+ }
4065
+ continue;
4066
+ }
4067
+ const existing = changes.find(change => change.file === rel && change.line === site.line);
4068
+ const edit = renameIdentifierTokens(index, site.file, site.line, name, options.renameTo,
4069
+ [site.column]);
4070
+ if (edit.renamed === edit.source) continue;
4071
+ if (existing) {
4072
+ if (existing.newExpression !== undefined && existing._edit) mergeTokenEdit(existing, edit);
4073
+ continue;
4074
+ }
4075
+ changes.push(tagTokenEdit({
2723
4076
  file: rel,
2724
- line,
2725
- expression: sourceLine,
2726
- suggestion: `Update overriding definition: ${newExpression}`,
2727
- newExpression,
4077
+ line: site.line,
4078
+ expression: edit.source,
4079
+ suggestion: `Update anonymous class override: ${edit.renamed}`,
4080
+ newExpression: edit.renamed,
2728
4081
  isDefinition: true,
2729
4082
  editKind: 'definition',
2730
- });
2731
- slotMemberDefs.push(override);
4083
+ }, edit));
2732
4084
  }
4085
+ contractRootDefs.push(...slotAncestors.map(ancestor => ancestor.def));
2733
4086
 
2734
4087
  // Go interface slots are implicit (fix #302, mux Match): there is
2735
4088
  // no implements edge for the hierarchy walk above. Compute the
@@ -2739,20 +4092,20 @@ function plan(index, name, options = {}) {
2739
4092
  // signatures) abstain in goInterfaceRenameClosure — absence is
2740
4093
  // never treated as proof.
2741
4094
  const goInterfaceSlot = planLang === 'go'
2742
- ? goInterfaceRenameClosure(index, def)
4095
+ ? goContractClosure(index, def)
2743
4096
  : null;
4097
+ if (goInterfaceSlot) goContractReviews.push(...goInterfaceSlot.reviews);
2744
4098
  if (goInterfaceSlot) {
2745
4099
  for (const member of goInterfaceSlot.memberDefs) {
2746
4100
  if (member.file === def.file && member.startLine === def.startLine) continue;
2747
4101
  const line = member.nameLine || member.startLine;
2748
4102
  const rel = member.relativePath || member.file;
2749
- if (!changes.some(change =>
2750
- change.file === rel && change.line === line)) {
4103
+ if (!lineTaken(member, rel, line)) {
2751
4104
  const edit = renameIdentifierTokens(index, member.file,
2752
4105
  line, name, options.renameTo,
2753
- null, null, { definitionNameOnly: true });
4106
+ null, null, { definitionNameOnly: true, definition: member });
2754
4107
  if (edit.renamed !== edit.source) {
2755
- changes.push({
4108
+ changes.push(tagTokenEdit({
2756
4109
  file: rel,
2757
4110
  line,
2758
4111
  expression: edit.source,
@@ -2760,7 +4113,7 @@ function plan(index, name, options = {}) {
2760
4113
  newExpression: edit.renamed,
2761
4114
  isDefinition: true,
2762
4115
  editKind: 'definition',
2763
- });
4116
+ }, edit));
2764
4117
  }
2765
4118
  }
2766
4119
  slotMemberDefs.push(member);
@@ -2776,38 +4129,44 @@ function plan(index, name, options = {}) {
2776
4129
  // it, with file-identity discipline (two crates may define
2777
4130
  // same-named traits — a member joins only when its trait
2778
4131
  // resolves to the pin's trait FILE).
2779
- const slotTraitNames = new Set();
2780
- let pinTraitFile = null;
4132
+ // Trait identity is the trait DEFINITION (fix #376): impl
4133
+ // headers spell generic traits with arguments (`impl<T> Ctx<T>
4134
+ // for Option<T>`) and paths (`crate::Ctx`), so a member joins
4135
+ // when its impl's trait resolves to the pin's trait definition.
4136
+ const traitKey = d => `${d.file}\0${d.startLine}`;
4137
+ let pinTraitDefs = [];
2781
4138
  if (def.traitName) {
2782
- slotTraitNames.add(def.traitName);
2783
- pinTraitFile = index._resolveClassFile(def.traitName, def.file) || null;
4139
+ pinTraitDefs = rustProjectTraits(index, def.traitName, def.file, def.startLine);
2784
4140
  } else if ((index.symbols.get(def.className) || []).some(d =>
2785
4141
  d.type === 'trait' && d.file === def.file)) {
2786
- slotTraitNames.add(def.className);
2787
- pinTraitFile = def.file;
4142
+ pinTraitDefs = (index.symbols.get(def.className) || []).filter(d =>
4143
+ d.type === 'trait' && d.file === def.file &&
4144
+ d.startLine <= def.startLine && (d.endLine || d.startLine) >= def.startLine);
2788
4145
  }
2789
- if (slotTraitNames.size > 0 && pinTraitFile) {
2790
- const traitName = [...slotTraitNames][0];
4146
+ if (pinTraitDefs.length === 1) {
4147
+ const pinTrait = pinTraitDefs[0];
2791
4148
  for (const member of index.symbols.get(name) || []) {
2792
4149
  if (member.file === def.file && member.startLine === def.startLine) continue;
2793
4150
  if (NON_CALLABLE_TYPES.has(member.type)) continue;
2794
4151
  let inSlot = false;
2795
- if (member.traitName === traitName) {
2796
- inSlot = index._resolveClassFile(traitName, member.file) === pinTraitFile;
2797
- } else if (member.className === traitName && member.file === pinTraitFile &&
2798
- (index.symbols.get(traitName) || []).some(d =>
2799
- d.type === 'trait' && d.file === member.file)) {
4152
+ if (member.traitName) {
4153
+ const traits = rustProjectTraits(index, member.traitName, member.file,
4154
+ member.startLine);
4155
+ inSlot = traits.length === 1 && traitKey(traits[0]) === traitKey(pinTrait);
4156
+ } else if (member.className === pinTrait.name && member.file === pinTrait.file &&
4157
+ member.startLine >= pinTrait.startLine &&
4158
+ member.startLine <= (pinTrait.endLine || pinTrait.startLine)) {
2800
4159
  inSlot = true; // the trait's own declaration member
2801
4160
  }
2802
4161
  if (!inSlot) continue;
2803
4162
  const line = member.nameLine || member.startLine;
2804
4163
  const rel = member.relativePath || member.file;
2805
- if (!changes.some(change => change.file === rel && change.line === line)) {
4164
+ if (!lineTaken(member, rel, line)) {
2806
4165
  const edit = renameIdentifierTokens(index, member.file,
2807
4166
  line, name, options.renameTo,
2808
- null, null, { definitionNameOnly: true });
4167
+ null, null, { definitionNameOnly: true, definition: member });
2809
4168
  if (edit.renamed !== edit.source) {
2810
- changes.push({
4169
+ changes.push(tagTokenEdit({
2811
4170
  file: rel,
2812
4171
  line,
2813
4172
  expression: edit.source,
@@ -2815,80 +4174,11 @@ function plan(index, name, options = {}) {
2815
4174
  newExpression: edit.renamed,
2816
4175
  isDefinition: true,
2817
4176
  editKind: 'definition',
2818
- });
4177
+ }, edit));
2819
4178
  }
2820
4179
  }
2821
4180
  slotMemberDefs.push(member);
2822
4181
  }
2823
- // Macro-generated impls are invisible to the index (the
2824
- // as_cast_impl! family: the `fn` lives in a macro_rules body,
2825
- // no impl_item node exists). A definition-shaped ground line
2826
- // no indexed def claims, sitting inside a macro_rules body
2827
- // whose token tree names `impl … <Trait>`, implements the
2828
- // renamed slot — compiler-connected via the impl header, so
2829
- // the edit is synthesized mechanically.
2830
- // The sweep already computed the ground set for this exact
2831
- // name (contractedCallerSweep) — reuse it instead of a second
2832
- // full-repo text scan (read-only by contract).
2833
- const macroGround = planGroundSet;
2834
- const fnRe = new RegExp(`\\bfn\\s+${escapeRegExp(name)}\\b`);
2835
- const implRe = new RegExp(
2836
- `\\bimpl\\b[^;{}]{0,160}\\b${escapeRegExp(traitName)}\\b`);
2837
- for (const [macroFile, lineNos] of macroGround.perFile) {
2838
- const macroEntry = index.files.get(macroFile);
2839
- if (!macroEntry || macroEntry.language !== 'rust') continue;
2840
- const claimed = new Set((index.symbols.get(name) || [])
2841
- .filter(d => d.file === macroFile)
2842
- .map(d => d.nameLine || d.startLine));
2843
- let macroTree = null;
2844
- let macroNodes = null;
2845
- for (const lineNo of lineNos) {
2846
- if (claimed.has(lineNo)) continue;
2847
- const rel = macroEntry.relativePath || macroFile;
2848
- if (changes.some(change =>
2849
- change.file === rel && change.line === lineNo)) continue;
2850
- const content = index._readFile(macroFile);
2851
- if (!fnRe.test(content.split('\n')[lineNo - 1] || '')) continue;
2852
- if (macroTree === null) {
2853
- const parser = getParser('rust');
2854
- macroTree = (parser &&
2855
- (index._getParsedTree?.(macroFile, content, 'rust') ||
2856
- safeParse(parser, content))) || false;
2857
- macroNodes = [];
2858
- if (macroTree) {
2859
- const stack = [macroTree.rootNode];
2860
- while (stack.length > 0) {
2861
- const node = stack.pop();
2862
- if (node.type === 'macro_definition') {
2863
- macroNodes.push(node);
2864
- continue;
2865
- }
2866
- for (let i = 0; i < node.namedChildCount; i++) {
2867
- stack.push(node.namedChild(i));
2868
- }
2869
- }
2870
- }
2871
- }
2872
- if (!macroTree || !macroNodes) continue;
2873
- const mac = macroNodes.find(m =>
2874
- m.startPosition.row + 1 <= lineNo &&
2875
- m.endPosition.row + 1 >= lineNo);
2876
- if (!mac || !implRe.test(mac.text)) continue;
2877
- const edit = renameIdentifierTokens(index, macroFile,
2878
- lineNo, name, options.renameTo,
2879
- null, null, { definitionNameOnly: true });
2880
- if (edit.renamed === edit.source) continue;
2881
- changes.push({
2882
- file: rel,
2883
- line: lineNo,
2884
- expression: edit.source,
2885
- suggestion: `Update macro-generated trait implementation: ${edit.renamed}`,
2886
- newExpression: edit.renamed,
2887
- isDefinition: true,
2888
- editKind: 'definition',
2889
- });
2890
- }
2891
- }
2892
4182
  }
2893
4183
 
2894
4184
  // A slot rename must also carry every member's CALL sites — the
@@ -2903,8 +4193,15 @@ function plan(index, name, options = {}) {
2903
4193
  `${site.file}:${site.line}:${Number.isInteger(site.column) ? site.column : '*'}`;
2904
4194
  const seenSiteTokens = new Set(planCallSites.map(site => siteTokenKey(site)));
2905
4195
  const slotMemberSites = [];
2906
- for (const memberDef of slotMemberDefs.slice(0, SLOT_SWEEP_CAP)) {
2907
- const memberSweep = computePlanCallSites(index, name, memberDef);
4196
+ // Members a macro generated (fix #374) are swept after, and
4197
+ // capped apart from, the written ones: a macro instantiated for
4198
+ // many types must not crowd written members out of the sweep.
4199
+ const writtenMembers = slotMemberDefs.filter(member => !member.macroExpansion);
4200
+ const generatedMembers = slotMemberDefs.filter(member => member.macroExpansion);
4201
+ const sweptMembers = [...writtenMembers.slice(0, SLOT_SWEEP_CAP),
4202
+ ...generatedMembers.slice(0, SLOT_SWEEP_CAP)];
4203
+ for (const memberDef of sweptMembers) {
4204
+ const memberSweep = computePlanCallSites(index, name, memberDef, { analyzeArgs: !options.renameTo });
2908
4205
  for (const site of memberSweep.sites) {
2909
4206
  const key = siteTokenKey(site);
2910
4207
  if (seenSiteTokens.has(key)) continue;
@@ -2918,9 +4215,16 @@ function plan(index, name, options = {}) {
2918
4215
  for (const raw of memberSweep.rawUnverified || []) {
2919
4216
  const exactInterfaceReceiver = goInterfaceSlot &&
2920
4217
  raw.reason === 'possible-dispatch' &&
2921
- goInterfaceSlot.interfaceNames.has(raw.dispatchVia);
4218
+ goInterfaceSlot.interfaceNames.has(
4219
+ String(raw.dispatchVia || '').split('.').pop());
4220
+ // A closed ambiguity holds where its receiver is a
4221
+ // project value: in a file of another build
4222
+ // configuration than the pin's, names may resolve to
4223
+ // other declarations (an import there, a variable here),
4224
+ // so the site stays a review item (fix #400).
2922
4225
  const closedAmbiguity = goSlotCoversMethodAmbiguity(
2923
- index, name, goInterfaceSlot, raw);
4226
+ index, name, goInterfaceSlot, raw) &&
4227
+ !goOtherConfiguration(index, def.file, raw.file);
2924
4228
  if ((!exactInterfaceReceiver && !closedAmbiguity) ||
2925
4229
  raw.externalContract) continue;
2926
4230
  const relativePath = raw.relativePath ||
@@ -2964,14 +4268,16 @@ function plan(index, name, options = {}) {
2964
4268
  });
2965
4269
  }
2966
4270
  }
2967
- if (slotMemberDefs.length > SLOT_SWEEP_CAP) {
4271
+ if (writtenMembers.length > SLOT_SWEEP_CAP || generatedMembers.length > SLOT_SWEEP_CAP) {
2968
4272
  resolved.warnings.push({
2969
4273
  message: `Dispatch slot has ${slotMemberDefs.length} member ` +
2970
4274
  `definitions; call sites were swept for the first ` +
2971
- `${SLOT_SWEEP_CAP} — review the rest manually.`,
4275
+ `${SLOT_SWEEP_CAP}${generatedMembers.length > 0
4276
+ ? ' written and first ' + SLOT_SWEEP_CAP + ' macro-generated' : ''} — review the rest manually.`,
2972
4277
  });
2973
4278
  }
2974
4279
  emitRenameCallSites(slotMemberSites);
4280
+ slotAccountSites.push(...slotMemberSites);
2975
4281
  // A site a slot member's sweep CONFIRMED is a planned edit now —
2976
4282
  // it no longer belongs in the pin's "may need this change" band.
2977
4283
  const changedLines = new Set(changes.map(change =>
@@ -3004,12 +4310,12 @@ function plan(index, name, options = {}) {
3004
4310
  !new Set(['c', 'cpp']).has(siblingLang)) continue;
3005
4311
  const line = sibling.nameLine || sibling.startLine;
3006
4312
  const rel = sibling.relativePath || sibling.file;
3007
- if (changes.some(change => change.file === rel && change.line === line)) continue;
4313
+ if (lineTaken(sibling, rel, line)) continue;
3008
4314
  const edit = renameIdentifierTokens(index, sibling.file,
3009
4315
  line, name, options.renameTo,
3010
- null, null, { definitionNameOnly: true });
4316
+ null, null, { definitionNameOnly: true, definition: sibling });
3011
4317
  if (edit.renamed === edit.source) continue;
3012
- changes.push({
4318
+ changes.push(tagTokenEdit({
3013
4319
  file: rel,
3014
4320
  line,
3015
4321
  expression: edit.source,
@@ -3017,23 +4323,151 @@ function plan(index, name, options = {}) {
3017
4323
  newExpression: edit.renamed,
3018
4324
  isDefinition: true,
3019
4325
  editKind: 'definition',
3020
- });
4326
+ }, edit));
3021
4327
  }
3022
4328
  }
3023
4329
 
3024
- // Reference-position usages are rename edits too (outcome-eval
3025
- // flask, 2026-08-18): `return decorator`, `callback=handler`,
3026
- // `cls.method` values. Call syntax flows through the tiered sweep;
3027
- // references never did — a plan-following rename left them on the
3028
- // old name and the toolchain rejected the result. Evidence
3029
- // discipline mirrors the caller engine:
3030
- // - same-file, non-method pin: nearest-binder containment — the
3031
- // innermost same-name def whose scope container holds the line
3032
- // must be the pin (a sibling nested `decorator` keeps its own
3033
- // references; ties bind nothing);
3034
- // - same-file, method pin: self/cls/this-received inside the
3035
- // pin's class range (receiver evidence read from the line);
3036
- // - cross-file, non-method pin: the #217 import-ownership chase —
4330
+ // Macro templates (fix #360, bytes-measured): identifiers inside a
4331
+ // macro_rules! transcriber bind at every expansion site, so the
4332
+ // AST never shows them as definitions or calls of the pin - and a
4333
+ // rename that leaves them behind breaks every expansion. Each token
4334
+ // spelling the name is classified by its token shape; a site is
4335
+ // edited only when its identity is proven from the pin:
4336
+ // `fn NAME` whose every owner (an `impl Trait for ..` header inside
4337
+ // the template, or the impl blocks that invoke the macro) is the
4338
+ // pin's own trait slot;
4339
+ // `.NAME(` / `Self::NAME(` forwarding inside such a template fn;
4340
+ // `NAME(` for a free-function pin whose binding reaches the pin in
4341
+ // the macro's file.
4342
+ // Every other token that can denote the pin is listed for review,
4343
+ // never omitted; tokens that provably denote another item (a `fn`
4344
+ // under a different trait, a dot-call against a free function) are
4345
+ // skipped.
4346
+ // A renamed TYPE's template tokens are decided by the type-reference
4347
+ // pass (fix #386).
4348
+ if (langTraits(planLang)?.macroTemplateBodies && planGroundSet?.perFile && !isTypeRenamePin(def)) {
4349
+ const pinTraitDefs = def.traitName
4350
+ ? rustProjectTraits(index, def.traitName, def.file, def.startLine)
4351
+ : (def.className ? (index.symbols.get(def.className) || []).filter(d =>
4352
+ d.type === 'trait' && d.file === def.file) : []);
4353
+ const pinTraitKeys = new Set(pinTraitDefs.map(d => `${d.file}\0${d.startLine}`));
4354
+ const ownerIsPinTrait = (owner, macroFile) => owner.kind === 'impl' && owner.trait &&
4355
+ rustProjectTraits(index, owner.trait, macroFile)
4356
+ .some(d => pinTraitKeys.has(`${d.file}\0${d.startLine}`));
4357
+ // Files that can hold template tokens of this name: a ground
4358
+ // line inside an indexed macro definition's range. The AST pass
4359
+ // decides every token; this only avoids parsing other files.
4360
+ const templateFiles = [...planGroundSet.perFile.entries()].filter(([file, lineNos]) => {
4361
+ const entry = index.files.get(file);
4362
+ if (entry?.language !== planLang) return false;
4363
+ const macros = (entry.symbols || []).filter(symbol => symbol.type === 'macro');
4364
+ return macros.length > 0 && [...lineNos].some(lineNo => macros.some(macro =>
4365
+ macro.startLine <= lineNo && (macro.endLine || macro.startLine) >= lineNo));
4366
+ }).map(([file]) => file);
4367
+ const sites = rustMacroTemplateSites(index, name, templateFiles);
4368
+ const decisions = new Map(); // site -> 'edit' | 'review' | 'skip'
4369
+ const editedTemplateFns = new Set(); // file\0macro
4370
+ for (const site of sites.filter(item => item.shape === 'definition')) {
4371
+ let decision;
4372
+ if (pinTraitKeys.size > 0) {
4373
+ const matches = site.owners.filter(owner => ownerIsPinTrait(owner, site.file));
4374
+ if (site.owners.length > 0 && matches.length === site.owners.length) decision = 'edit';
4375
+ else if (matches.length === 0 && site.owners.length > 0 &&
4376
+ site.owners.every(owner => owner.kind === 'impl' && owner.trait)) decision = 'skip';
4377
+ else decision = 'review';
4378
+ } else if (def.className) {
4379
+ decision = site.owners.length > 0 && site.owners.every(owner =>
4380
+ owner.kind === 'impl' && owner.trait) ? 'skip' : 'review';
4381
+ } else {
4382
+ decision = site.owners.length > 0 && site.owners.every(owner =>
4383
+ owner.kind === 'impl') ? 'skip' : 'review';
4384
+ }
4385
+ decisions.set(site, decision);
4386
+ if (decision === 'edit') editedTemplateFns.add(`${site.file}\0${site.macroName}`);
4387
+ }
4388
+ for (const site of sites.filter(item => item.shape !== 'definition')) {
4389
+ let decision = 'review';
4390
+ const forwarding = site.enclosingFn === name &&
4391
+ editedTemplateFns.has(`${site.file}\0${site.macroName}`);
4392
+ if (site.shape === 'method-call') {
4393
+ decision = !def.className ? 'skip' : forwarding ? 'edit' : 'review';
4394
+ } else if (site.shape === 'path-call') {
4395
+ decision = forwarding && site.pathQualifier === 'Self' ? 'edit' : 'review';
4396
+ } else if (site.shape === 'call') {
4397
+ if (def.className) decision = 'skip'; // bare calls never denote methods
4398
+ else {
4399
+ decision = _nameBindingReaches(index, site.file, name, renameTargetFiles) === 'yes'
4400
+ ? 'edit' : 'review';
4401
+ }
4402
+ }
4403
+ decisions.set(site, decision);
4404
+ }
4405
+ const editsByLine = new Map();
4406
+ const unverifiedTemplateLines = new Set(planUnverified.map(site =>
4407
+ `${site.file}:${site.line}`));
4408
+ for (const [site, decision] of decisions) {
4409
+ if (decision === 'skip') continue;
4410
+ const key = `${site.file}\0${site.line}`;
4411
+ if (decision === 'edit') {
4412
+ if (!editsByLine.has(key)) editsByLine.set(key, { site, columns: [], definition: false });
4413
+ const entry = editsByLine.get(key);
4414
+ entry.columns.push(site.column);
4415
+ if (site.shape === 'definition') entry.definition = true;
4416
+ continue;
4417
+ }
4418
+ if (unverifiedTemplateLines.has(`${site.relativePath}:${site.line}`)) continue;
4419
+ if (reviewItems.some(item => item.file === site.relativePath &&
4420
+ item.line === site.line && item.templateDependency)) continue;
4421
+ reviewItems.push({
4422
+ file: site.relativePath,
4423
+ line: site.line,
4424
+ expression: (index.getLineContent(site.file, site.line) || '').trim(),
4425
+ suggestion: `Macro ${site.macroName || 'template'} spells "${name}" as a ` +
4426
+ `${site.shape.replace('-', ' ')} token; it binds at each expansion - ` +
4427
+ 'rename it with the pin if the expansions reach it',
4428
+ needsReview: true,
4429
+ templateDependency: true,
4430
+ reviewReason: 'macro-template',
4431
+ editKind: 'macro-template',
4432
+ });
4433
+ }
4434
+ for (const { site, columns, definition } of editsByLine.values()) {
4435
+ if (changes.some(change => change.file === site.relativePath &&
4436
+ change.line === site.line)) continue;
4437
+ const edit = renameIdentifierTokens(index, site.file, site.line, name,
4438
+ options.renameTo, columns.sort((a, b) => a - b));
4439
+ if (edit.renamed === edit.source) continue;
4440
+ changes.push(tagTokenEdit({
4441
+ file: site.relativePath,
4442
+ line: site.line,
4443
+ expression: edit.source,
4444
+ suggestion: `Update macro template ${site.macroName || ''}: ${edit.renamed}`.replace(' ', ' '),
4445
+ newExpression: edit.renamed,
4446
+ ...(definition && { isDefinition: true }),
4447
+ editKind: definition ? 'definition' : 'call',
4448
+ }, edit));
4449
+ }
4450
+ if (editsByLine.size > 0) {
4451
+ const changedLines = new Set(changes.map(change => `${change.file}:${change.line}`));
4452
+ const kept = planUnverified.filter(site => !changedLines.has(`${site.file}:${site.line}`));
4453
+ planUnverified.length = 0;
4454
+ planUnverified.push(...kept);
4455
+ }
4456
+ }
4457
+
4458
+ // Reference-position usages are rename edits too (outcome-eval
4459
+ // flask, 2026-08-18): `return decorator`, `callback=handler`,
4460
+ // `cls.method` values. Call syntax flows through the tiered sweep;
4461
+ // references never did — a plan-following rename left them on the
4462
+ // old name and the toolchain rejected the result. Evidence
4463
+ // discipline mirrors the caller engine:
4464
+ // - same-file, non-method pin: nearest-binder containment — the
4465
+ // innermost same-name def whose scope container holds the line
4466
+ // must be the pin (a sibling nested `decorator` keeps its own
4467
+ // references; ties bind nothing);
4468
+ // - same-file, method pin: self/cls/this-received inside the
4469
+ // pin's class range (receiver evidence read from the line);
4470
+ // - cross-file, non-method pin: the #217 import-ownership chase —
3037
4471
  // 'yes' edits, 'unknown' surfaces needsReview (no synthesized
3038
4472
  // edit), 'no' skips;
3039
4473
  // - cross-file method references are receiver-blind here:
@@ -3071,10 +4505,18 @@ function plan(index, name, options = {}) {
3071
4505
  }
3072
4506
  return best; // null = module scope
3073
4507
  };
4508
+ // A prototype and its definition (C/C++ forward declarations,
4509
+ // overload signatures) are one callable (fix #396): each binds
4510
+ // the name as the pin.
4511
+ const pinIdentity = new Set(require('./callers')._closeCallableIdentityGroup(
4512
+ index, [def], definitions));
3074
4513
  const binders = definitions
3075
4514
  .filter(candidate => candidate.file === def.file &&
3076
4515
  !NON_CALLABLE_TYPES.has(candidate.type))
3077
- .map(candidate => ({ def: candidate, container: containerOf(candidate) }));
4516
+ .map(candidate => ({
4517
+ def: pinIdentity.has(candidate) ? def : candidate,
4518
+ container: containerOf(candidate),
4519
+ }));
3078
4520
  const classKinds = new Set(['class', 'struct', 'interface', 'trait',
3079
4521
  'record', 'enum', 'namespace']);
3080
4522
  const pinClassRange = def.className
@@ -3095,92 +4537,236 @@ function plan(index, name, options = {}) {
3095
4537
  (symbol.endLine || symbol.startLine) >= line);
3096
4538
  };
3097
4539
  const pinContainer = containerOf(def);
3098
- for (const ref of usages) {
3099
- if (ref.usageType !== 'reference' || ref.isDefinition) continue;
3100
- const rel = ref.relativePath || ref.file;
3101
- if (changes.some(change =>
3102
- change.file === rel && change.line === ref.line)) continue;
3103
- if (unverifiedLines.has(`${rel}:${ref.line}`)) continue;
3104
- let verdict = null;
4540
+ const methodGroupPin = !!langTraits(planLang)?.methodGroupReferences &&
4541
+ (def.type === 'method' || def.memberType === 'method' || !!def.isMethod);
4542
+ // Where each bare reference resolves (fix #392): the parser
4543
+ // records the language's own lexical scoping on the usage
4544
+ // (`scopeBinding`), so a reference inside a function body or on a
4545
+ // decorator line is decided instead of reviewed. The pin must be
4546
+ // declared at file scope for a file-scope resolution to reach it
4547
+ // (a Rust inline module is its own scope).
4548
+ const pinNameLine = def.nameLine || def.startLine;
4549
+ const pinAtFileScope = !pinContainer && !fileSymbols.some(symbol =>
4550
+ symbol.type === 'module' && symbol !== def &&
4551
+ symbol.startLine <= pinNameLine &&
4552
+ (symbol.endLine || symbol.startLine) >= pinNameLine &&
4553
+ !(symbol.startLine === def.startLine && symbol.name === def.name));
4554
+ const fileScopeBinders = binders.filter(binder => !binder.container);
4555
+ const pinIsFileBinding = pinAtFileScope && fileScopeBinders.length >= 1 &&
4556
+ fileScopeBinders.every(binder => binder.def === def);
4557
+ // One record's verdict: 'edit', 'review' or null (not the pin).
4558
+ const verdictFor = (ref) => {
4559
+ // A C++ qualified reference (`template auto ns::f(float)
4560
+ // -> R;`, `&Cls::m`, fix #393) names what its qualifier
4561
+ // resolves to from the reference's own scope.
4562
+ if (ref.receiver && index.files.get(ref.file)?.language === 'cpp') {
4563
+ return cppQualifiedReferenceVerdict(index, ref, def);
4564
+ }
4565
+ // A function declared in a function body (a C# local
4566
+ // function, a nested def) is in scope only in its block:
4567
+ // never from another file, and never as the member of an
4568
+ // object (`profile.Items`, fix #395).
4569
+ if (pinContainer && !def.className && (ref.file !== def.file || ref.receiver)) return null;
4570
+ // A property of an object (an object-literal member, `obj.f =
4571
+ // fn`) is reached through a receiver, never as a bare
4572
+ // binding: member accesses are decided by the call-site and
4573
+ // accessor passes (fix #397).
4574
+ if (ref.receiver && def.memberAssigned && !def.className) return null;
3105
4575
  if (ref.file === def.file) {
4576
+ // C# method groups are decided by the language's lookup
4577
+ // in their own pass (fix #395).
4578
+ if (def.className && methodGroupPin) return null;
3106
4579
  if (def.className) {
3107
4580
  const lineText = ref.content ||
3108
4581
  index.getLineContent(def.file, ref.line) || '';
3109
- if (pinClassRange && ref.line >= pinClassRange.startLine &&
3110
- ref.line <= (pinClassRange.endLine || Infinity) &&
4582
+ // `this` names the innermost enclosing class (fix
4583
+ // #390: `this.size = size` in a nested class's
4584
+ // constructor is its own field), and where fields
4585
+ // and methods are separate namespaces a paren-less
4586
+ // `this.m` is never the method (Java, Rust).
4587
+ const innermostClass = fileSymbols.filter(symbol =>
4588
+ classKinds.has(symbol.type) && symbol.startLine <= ref.line &&
4589
+ (symbol.endLine || symbol.startLine) >= ref.line)
4590
+ .sort((a, b) => ((a.endLine || a.startLine) - a.startLine) -
4591
+ ((b.endLine || b.startLine) - b.startLine))[0] || null;
4592
+ if (pinClassRange && innermostClass === pinClassRange &&
4593
+ !langTraits(planLang)?.fieldsAndMethodsSeparate &&
3111
4594
  selfReceived.test(lineText)) {
3112
- verdict = 'edit';
3113
- }
3114
- } else {
3115
- let winner = null;
3116
- let ambiguous = false;
3117
- for (const binder of binders) {
3118
- const contains = !binder.container ||
3119
- (binder.container.start <= ref.line &&
3120
- binder.container.end >= ref.line);
3121
- if (!contains) continue;
3122
- const size = binder.container
3123
- ? binder.container.end - binder.container.start
3124
- : Infinity;
3125
- if (!winner || size < winner.size) {
3126
- winner = { def: binder.def, size };
3127
- ambiguous = false;
3128
- } else if (size === winner.size &&
3129
- binder.def !== winner.def) {
3130
- ambiguous = true;
3131
- }
3132
- }
3133
- if (winner && !ambiguous && winner.def === def) {
3134
- if (pinContainer) {
3135
- verdict = 'edit'; // nested pin binds its container
3136
- } else if (!insideFunctionLike(def.file, ref.line)) {
3137
- verdict = 'edit'; // module-scope row, module pin
3138
- } else {
3139
- verdict = 'review'; // possible local shadow
3140
- }
4595
+ return 'edit';
3141
4596
  }
4597
+ return null;
4598
+ }
4599
+ if (!ref.receiver && ref.scopeBinding === 'module') {
4600
+ // Resolves at file scope: the pin when it is the
4601
+ // file's only binding of the name there (a nested pin
4602
+ // is not visible there).
4603
+ if (pinIsFileBinding) return 'edit';
4604
+ if (!pinAtFileScope) return null;
4605
+ } else if (!ref.receiver && ref.scopeBinding === 'local') {
4606
+ // An enclosing function or block binds the name: the
4607
+ // pin only when that binding is the pin's own def.
4608
+ return Array.isArray(ref.scopeDefLines) &&
4609
+ ref.scopeDefLines.includes(pinNameLine) ? 'edit' : null;
3142
4610
  }
3143
- } else if (!def.className) {
3144
- // Module-attribute references carry stronger evidence
3145
- // than a bare name: `import requests; requests.put` binds
3146
- // through requests' export chain even inside a function.
3147
- // Parser-side local-shadow evidence is a hard guard — a
3148
- // parameter/assignment named requests defeats the import.
3149
- const moduleOwnership = ref.receiver &&
3150
- !ref.receiverLocalBinding
3151
- ? _moduleAttributeBindingReaches(
3152
- index, ref.file, ref.receiver, name,
3153
- renameTargetFiles)
3154
- : null;
3155
- if (moduleOwnership === 'yes') {
3156
- verdict = 'edit';
3157
- } else if (moduleOwnership === 'unknown') {
3158
- verdict = 'review';
3159
- } else if (!ref.receiver) {
3160
- const ownership = _nameBindingReaches(
3161
- index, ref.file, name, renameTargetFiles);
3162
- if (ownership === 'yes' &&
3163
- !insideFunctionLike(ref.file, ref.line)) {
3164
- verdict = 'edit';
3165
- } else if (ownership !== 'no') {
3166
- verdict = 'review';
4611
+ let winner = null;
4612
+ let ambiguous = false;
4613
+ for (const binder of binders) {
4614
+ const contains = !binder.container ||
4615
+ (binder.container.start <= ref.line &&
4616
+ binder.container.end >= ref.line);
4617
+ if (!contains) continue;
4618
+ const size = binder.container
4619
+ ? binder.container.end - binder.container.start
4620
+ : Infinity;
4621
+ if (!winner || size < winner.size) {
4622
+ winner = { def: binder.def, size };
4623
+ ambiguous = false;
4624
+ } else if (size === winner.size &&
4625
+ binder.def !== winner.def) {
4626
+ ambiguous = true;
3167
4627
  }
3168
4628
  }
4629
+ if (winner && !ambiguous && winner.def === def) {
4630
+ if (pinContainer) return 'edit'; // nested pin binds its container
4631
+ if (!insideFunctionLike(def.file, ref.line)) return 'edit'; // module-scope row
4632
+ return 'review'; // possible local shadow
4633
+ }
4634
+ return null;
4635
+ }
4636
+ if (def.className) return null;
4637
+ // Module-attribute references carry stronger evidence
4638
+ // than a bare name: `import requests; requests.put` binds
4639
+ // through requests' export chain even inside a function.
4640
+ // Parser-side local-shadow evidence is a hard guard — a
4641
+ // parameter/assignment named requests defeats the import.
4642
+ const moduleOwnership = ref.receiver &&
4643
+ !ref.receiverLocalBinding
4644
+ ? _moduleAttributeBindingReaches(
4645
+ index, ref.file, ref.receiver, name,
4646
+ renameTargetFiles)
4647
+ : null;
4648
+ if (moduleOwnership === 'yes') return 'edit';
4649
+ if (moduleOwnership === 'unknown') return 'review';
4650
+ if (ref.receiver) return null;
4651
+ // A local binding of the name is never the pin (fix #392); a
4652
+ // file-scope resolution follows the file's binding of the name.
4653
+ if (ref.scopeBinding === 'local') return null;
4654
+ // C (fix #396): a file-scope reference names the file's own
4655
+ // function of that name, else the external-linkage function
4656
+ // its translation unit links, as a call does.
4657
+ if (ref.scopeBinding === 'module' && cExternalReferenceLanguage(index, ref.file)) {
4658
+ return cExternalReferenceVerdict(index, ref, def, pinIdentity, definitions);
4659
+ }
4660
+ const ownership = _nameBindingReaches(
4661
+ index, ref.file, name, renameTargetFiles);
4662
+ if (ownership === 'yes' &&
4663
+ (ref.scopeBinding === 'module' ||
4664
+ !insideFunctionLike(ref.file, ref.line))) {
4665
+ return 'edit';
3169
4666
  }
4667
+ return ownership !== 'no' ? 'review' : null;
4668
+ };
4669
+ const eligible = (ref) => {
4670
+ if (ref.usageType !== 'reference' || ref.isDefinition) return false;
4671
+ // Keyword-argument names, object keys and struct field keys
4672
+ // are not references to a non-member binding (a member pin
4673
+ // reads its receiver tokens from the line below).
4674
+ if (!def.className && !ref.receiver && ref.scopeBinding === 'none') return false;
4675
+ // An attribute path or derive entry names a macro (fix #377):
4676
+ // never a reference to a same-named fn or value.
4677
+ if (ref.namespace === 'macro' && !isMacroNamespaceDefinition(def)) return false;
4678
+ return true;
4679
+ };
4680
+ for (const first of usages) {
4681
+ if (first.usageType !== 'reference' || first.isDefinition) continue;
4682
+ // Every record of the line is decided (fix #392): two
4683
+ // references on one line are two tokens.
4684
+ const records = [first, ...(first.sameLine || [])].filter(eligible);
4685
+ if (records.length === 0) continue;
4686
+ const ref = records[0];
4687
+ const rel = ref.relativePath || ref.file;
4688
+ if (unverifiedLines.has(`${rel}:${ref.line}`)) continue;
4689
+ const existing = changes.find(change =>
4690
+ change.file === rel && change.line === ref.line);
4691
+ const verdicts = records.map(verdictFor);
4692
+ let verdict = verdicts.includes('review') ? 'review'
4693
+ : verdicts.includes('edit') ? 'edit' : null;
3170
4694
  if (!verdict) continue;
4695
+ // A same-file member pin is referenced through its receiver
4696
+ // (fix #376): edit exactly the `this.NAME` / `self.NAME`
4697
+ // member tokens on the line (both in `this.m =
4698
+ // this.m.bind(this)`), never an object-literal key or other
4699
+ // same-spelled property (`{ m: this.m }` keeps its key).
4700
+ let refColumns = records.filter((record, i) => verdicts[i] === 'edit')
4701
+ .map(record => record.column);
4702
+ if (!refColumns.every(Number.isInteger)) refColumns = null;
4703
+ if (verdict === 'edit' && ref.file === def.file && def.className) {
4704
+ const selfColumns = selfMemberTokenColumns(index, def.file, ref.line, name);
4705
+ if (selfColumns.length === 0) verdict = 'review';
4706
+ else refColumns = selfColumns;
4707
+ }
4708
+ // An object-literal shorthand `{ name }` keeps its key (fix
4709
+ // #397, immer-measured: `{ applyPatches_ }` passed where a
4710
+ // Plugin object is expected): the value is spelled out,
4711
+ // `{ name: renamed }`.
4712
+ const shorthandEdits = verdict === 'edit' ? records.filter((record, i) =>
4713
+ verdicts[i] === 'edit' && record.shorthandProperty) : [];
4714
+ if (shorthandEdits.length > 0) {
4715
+ const mixed = shorthandEdits.length !== records.filter((record, i) =>
4716
+ verdicts[i] === 'edit').length;
4717
+ if (existing || mixed || !refColumns) {
4718
+ if (!existing?.needsReview) {
4719
+ reviewItems.push({
4720
+ file: rel,
4721
+ line: ref.line,
4722
+ expression: (ref.content || '').trim(),
4723
+ suggestion: `Shorthand property "${name}" names the renamed binding: ` +
4724
+ `write it as "${name}: ${options.renameTo}" (the key stays)`,
4725
+ needsReview: true,
4726
+ editKind: 'reference',
4727
+ });
4728
+ }
4729
+ continue;
4730
+ }
4731
+ const edit = renameIdentifierTokens(index, ref.file, ref.line, name,
4732
+ `${name}: ${options.renameTo}`, refColumns);
4733
+ if (edit.renamed === edit.source) continue;
4734
+ changes.push(tagTokenEdit({
4735
+ file: rel,
4736
+ line: ref.line,
4737
+ expression: edit.source,
4738
+ suggestion: `Update reference: ${edit.renamed}`,
4739
+ newExpression: edit.renamed,
4740
+ editKind: 'reference',
4741
+ }, edit));
4742
+ continue;
4743
+ }
4744
+ if (existing) {
4745
+ // A line another pass already edits (a call beside the
4746
+ // reference) gains the reference tokens.
4747
+ // A member pin's own `this.m` tokens join a bound call's
4748
+ // edit on the line (`this.m = this.m.bind(this)`, fix #397).
4749
+ if (verdict === 'edit' && (!def.className || ref.file === def.file) &&
4750
+ existing.newExpression !== undefined &&
4751
+ !existing.needsReview && refColumns) {
4752
+ const edit = renameIdentifierTokens(index, ref.file,
4753
+ ref.line, name, options.renameTo, refColumns);
4754
+ mergeTokenEdit(existing, edit);
4755
+ }
4756
+ continue;
4757
+ }
3171
4758
  if (verdict === 'edit') {
3172
4759
  const edit = renameIdentifierTokens(index, ref.file,
3173
- ref.line, name, options.renameTo,
3174
- Number.isInteger(ref.column) ? [ref.column] : null);
4760
+ ref.line, name, options.renameTo, refColumns);
3175
4761
  if (edit.renamed === edit.source) continue;
3176
- changes.push({
4762
+ changes.push(tagTokenEdit({
3177
4763
  file: rel,
3178
4764
  line: ref.line,
3179
4765
  expression: edit.source,
3180
4766
  suggestion: `Update reference: ${edit.renamed}`,
3181
4767
  newExpression: edit.renamed,
3182
4768
  editKind: 'reference',
3183
- });
4769
+ }, edit));
3184
4770
  } else {
3185
4771
  changes.push({
3186
4772
  file: rel,
@@ -3214,8 +4800,26 @@ function plan(index, name, options = {}) {
3214
4800
  const ref = refs[0];
3215
4801
  const columns = refs.map(item => item.column)
3216
4802
  .filter(Number.isInteger);
4803
+ // A shorthand destructuring key (`const { body } = this`)
4804
+ // reads the property into a local of the same name: the key
4805
+ // is renamed and the local kept (`{ bodyZ: body }`, fix #397).
4806
+ const shorthandKeys = refs.filter(item => item.patternKey?.shorthand);
4807
+ if (shorthandKeys.length > 0 && (shorthandKeys.length !== refs.length ||
4808
+ columns.length !== refs.length)) {
4809
+ changes.push({
4810
+ file: ref.file,
4811
+ line: ref.line,
4812
+ expression: ref.expression,
4813
+ suggestion: `Rename the destructured property "${name}" to "${options.renameTo}" ` +
4814
+ 'manually, keeping the local binding',
4815
+ needsReview: true,
4816
+ editKind: 'reference',
4817
+ });
4818
+ continue;
4819
+ }
3217
4820
  const edit = renameIdentifierTokens(index, ref.absoluteFile,
3218
- ref.line, name, options.renameTo,
4821
+ ref.line, name, shorthandKeys.length > 0
4822
+ ? `${options.renameTo}: ${name}` : options.renameTo,
3219
4823
  columns.length === refs.length ? columns : null);
3220
4824
  if (edit.renamed === edit.source) continue;
3221
4825
  const concrete = {
@@ -3232,7 +4836,11 @@ function plan(index, name, options = {}) {
3232
4836
  if (existing) Object.assign(existing, concrete);
3233
4837
  else changes.push(concrete);
3234
4838
  }
4839
+ const listedAccess = new Set();
3235
4840
  for (const ref of accessorReferences.unverified) {
4841
+ const lineKey = `${ref.file}:${ref.line}`;
4842
+ if (listedAccess.has(lineKey)) continue;
4843
+ listedAccess.add(lineKey);
3236
4844
  changes.push({
3237
4845
  file: ref.file,
3238
4846
  line: ref.line,
@@ -3276,15 +4884,36 @@ function plan(index, name, options = {}) {
3276
4884
  const definitionSource = index.getLineContent(def.file, definitionLine).trim();
3277
4885
  const existingDefinitionLine = changes.find(change =>
3278
4886
  change.file === definitionFile && change.line === definitionLine);
3279
- if (existingDefinitionLine) {
4887
+ // A definition a macro_rules! transcriber generated (fix #374) is
4888
+ // spelled in the macro's template, never at the invocation: the
4889
+ // template pass below edits or lists it; the invocation line is only
4890
+ // pointed at for review.
4891
+ if (def.macroExpansion?.origin === 'template') {
4892
+ reviewItems.push({
4893
+ file: definitionFile,
4894
+ line: definitionLine,
4895
+ expression: definitionSource,
4896
+ suggestion: `Generated by macro ${def.macroExpansion.macro} (${def.macroExpansion.definition}); ` +
4897
+ 'the declaration is written in the macro template',
4898
+ needsReview: true,
4899
+ templateDependency: true,
4900
+ editKind: 'macro-generated-definition',
4901
+ });
4902
+ } else if (existingDefinitionLine) {
3280
4903
  existingDefinitionLine.isDefinition = true;
3281
4904
  existingDefinitionLine.editKind = 'definition';
3282
4905
  if (options.renameTo) {
3283
- const renamed = renameIdentifierTokens(index, def.file,
4906
+ const defEdit = renameIdentifierTokens(index, def.file,
3284
4907
  definitionLine, name, options.renameTo,
3285
- null, null, { definitionNameOnly: true }).renamed;
3286
- existingDefinitionLine.newExpression = renamed;
3287
- existingDefinitionLine.suggestion = `Update definition: ${renamed}`;
4908
+ null, null, { definitionNameOnly: true, definition: def });
4909
+ // Keep the tokens the line's other edit renames (a recursive or
4910
+ // delegating call on the declaration line) (fix #376).
4911
+ if (!(existingDefinitionLine.newExpression !== undefined &&
4912
+ mergeTokenEdit(existingDefinitionLine, defEdit))) {
4913
+ existingDefinitionLine.newExpression = defEdit.renamed;
4914
+ tagTokenEdit(existingDefinitionLine, defEdit);
4915
+ }
4916
+ existingDefinitionLine.suggestion = `Update definition: ${existingDefinitionLine.newExpression}`;
3288
4917
  }
3289
4918
  } else {
3290
4919
  const definitionChange = {
@@ -3295,11 +4924,13 @@ function plan(index, name, options = {}) {
3295
4924
  editKind: 'definition',
3296
4925
  };
3297
4926
  if (options.renameTo) {
3298
- const renamed = renameIdentifierTokens(index, def.file,
4927
+ const defEdit = renameIdentifierTokens(index, def.file,
3299
4928
  definitionLine, name, options.renameTo,
3300
- null, null, { definitionNameOnly: true }).renamed;
4929
+ null, null, { definitionNameOnly: true, definition: def });
4930
+ const renamed = defEdit.renamed;
3301
4931
  definitionChange.newExpression = renamed;
3302
4932
  definitionChange.suggestion = `Update definition: ${renamed}`;
4933
+ tagTokenEdit(definitionChange, defEdit);
3303
4934
  } else {
3304
4935
  definitionChange.suggestion =
3305
4936
  `Update declaration signature to: ${newSignature}`;
@@ -3311,6 +4942,674 @@ function plan(index, name, options = {}) {
3311
4942
  changes.unshift(definitionChange);
3312
4943
  }
3313
4944
 
4945
+ // Members a macro invocation declares (fix #385: `ERROR_DEF(Base, Name)`
4946
+ // in the class body expands to Name's constructors) are spelled by the
4947
+ // invocation's argument: renaming the class renames that token too, or
4948
+ // the expansion declares members of the old name.
4949
+ if (options.renameTo && ['class', 'struct', 'union'].includes(def.type) &&
4950
+ langTraits(planLang)?.textualIncludes) {
4951
+ const invocationLines = new Set();
4952
+ for (const member of index.files.get(def.file)?.symbols || []) {
4953
+ if (!member.generatedByMacro || member.className !== def.name || member.name !== name ||
4954
+ member.startLine < def.startLine || member.endLine > def.endLine) continue;
4955
+ invocationLines.add(member.startLine);
4956
+ }
4957
+ for (const line of [...invocationLines].sort((a, b) => a - b)) {
4958
+ if (changes.some(change => change.file === definitionFile && change.line === line)) continue;
4959
+ const edit = renameIdentifierTokens(index, def.file, line, name, options.renameTo);
4960
+ if (edit.renamed === edit.source) continue;
4961
+ changes.push(markEvidence(tagTokenEdit({
4962
+ file: definitionFile,
4963
+ line,
4964
+ expression: edit.source,
4965
+ suggestion: `Rename the macro argument that declares members of ${name}: ${edit.renamed}`,
4966
+ newExpression: edit.renamed,
4967
+ editKind: 'reference',
4968
+ }, edit)));
4969
+ }
4970
+ }
4971
+
4972
+ // C++ using-declarations that name the renamed entity (fix #385, the
4973
+ // spdlog rename harness's silent breaks: `using details::os::
4974
+ // filename_to_str;` before bare calls the plan already edits). The
4975
+ // written qualifier resolves from the declaration's enclosing
4976
+ // namespaces (macro-opened ones included); one that names the pin's
4977
+ // namespace (or its class, for `using Base::m;`) is edited, one that
4978
+ // only ends like it is listed for review.
4979
+ if (options.renameTo && langTraits(planLang)?.textualIncludes) {
4980
+ const { effectiveNamespace } = require('./cpp-scope');
4981
+ const pinNamespace = def.className ? null : effectiveNamespace(index, def);
4982
+ for (const [file, entry] of index.files) {
4983
+ for (const fact of entry.cppUsings || []) {
4984
+ if (fact.kind !== 'declaration' || fact.name !== name) continue;
4985
+ const rel = entry.relativePath;
4986
+ if (changes.some(change => change.file === rel && change.line === fact.line)) continue;
4987
+ const absolute = fact.target.startsWith('::');
4988
+ const qualifier = fact.target.replace(/^::/, '').split('::').slice(0, -1).join('::');
4989
+ let exact = false;
4990
+ let near = false;
4991
+ if (def.className) {
4992
+ const owner = qualifier.split('::').pop();
4993
+ exact = owner === def.className;
4994
+ } else {
4995
+ const site = effectiveNamespace(index, { file, startLine: fact.line, namespace: fact.namespace });
4996
+ const scopes = [];
4997
+ if (!absolute) {
4998
+ const parts = site ? site.split('::') : [];
4999
+ for (let i = parts.length; i > 0; i--) scopes.push(parts.slice(0, i).join('::'));
5000
+ }
5001
+ scopes.push('');
5002
+ exact = scopes.some(scope => (scope && qualifier ? `${scope}::${qualifier}` : scope || qualifier) === pinNamespace);
5003
+ near = !exact && (pinNamespace === qualifier || pinNamespace.endsWith(`::${qualifier}`));
5004
+ }
5005
+ if (!exact && !near) continue;
5006
+ const edit = renameIdentifierTokens(index, file, fact.line, name, options.renameTo);
5007
+ if (exact && edit.renamed !== edit.source) {
5008
+ changes.push(markEvidence(tagTokenEdit({
5009
+ file: rel,
5010
+ line: fact.line,
5011
+ expression: edit.source,
5012
+ suggestion: `Rename the using-declaration: ${edit.renamed}`,
5013
+ newExpression: edit.renamed,
5014
+ editKind: 'reference',
5015
+ }, edit)));
5016
+ } else {
5017
+ reviewItems.push({
5018
+ file: rel,
5019
+ line: fact.line,
5020
+ expression: index.getLineContent(file, fact.line).trim(),
5021
+ suggestion: `A using-declaration names "${name}" through a qualifier UCN cannot resolve to the renamed declaration; rename it with the declaration if it names it`,
5022
+ needsReview: true,
5023
+ editKind: 'reference',
5024
+ });
5025
+ }
5026
+ }
5027
+ }
5028
+ }
5029
+
5030
+ // Every token that names a renamed TYPE (fix #386): base clauses,
5031
+ // field/parameter/return/local and generic-argument types, casts and
5032
+ // type tests, static member qualifiers, constructor and destructor
5033
+ // names, member initializers, qualified spellings and imports. Tokens
5034
+ // are resolved by the language's own name lookup; a token UCN cannot
5035
+ // resolve to exactly the renamed declaration is a review item.
5036
+ let typePass = null;
5037
+ const fileRenames = [];
5038
+ if (options.renameTo && isTypeRenamePin(def) && planGroundSet &&
5039
+ !def.macroExpansion) {
5040
+ typePass = applyTypeReferencePass(index, {
5041
+ def, name, renameTo: options.renameTo, changes, reviewItems,
5042
+ planUnverified, groundSet: planGroundSet,
5043
+ });
5044
+ // javac requires a public top-level type in a file named after it:
5045
+ // the file is renamed with the type.
5046
+ const pathMod = require('path');
5047
+ const extension = pathMod.extname(def.file);
5048
+ if (langTraits(planLang)?.typeNamedSourceFile && !def.enclosingType && !def.isNested &&
5049
+ pathMod.basename(def.file, extension) === name) {
5050
+ const from = def.relativePath || pathMod.relative(index.root, def.file);
5051
+ fileRenames.push({
5052
+ from,
5053
+ to: pathMod.join(pathMod.dirname(from), `${options.renameTo}${extension}`),
5054
+ });
5055
+ }
5056
+ }
5057
+
5058
+ // Token pasting can spell the renamed name without writing it (fix
5059
+ // #396: `struct sdshdr##T` in SDS_HDR_VAR, invoked as SDS_HDR_VAR(8, s)):
5060
+ // the pasting macro and each invocation that produces the name are
5061
+ // review items; nothing there spells the name to edit.
5062
+ if (options.renameTo && langTraits(planLang)?.macroInvocationSyntax === 'call' && !def.macroExpansion) {
5063
+ const { pastedNameSites } = require('./macro-expansion');
5064
+ const pasted = pastedNameSites(index, name);
5065
+ const taken = new Set([...changes, ...reviewItems].map(change => `${change.file}:${change.line}`));
5066
+ const push = (file, line, suggestion) => {
5067
+ const rel = path.relative(index.root, file);
5068
+ if (taken.has(`${rel}:${line}`)) return;
5069
+ taken.add(`${rel}:${line}`);
5070
+ reviewItems.push({
5071
+ file: rel,
5072
+ line,
5073
+ expression: (index.getLineContent(file, line) || '').trim(),
5074
+ suggestion,
5075
+ needsReview: true,
5076
+ templateDependency: true,
5077
+ reviewReason: 'macro-paste',
5078
+ editKind: 'macro-expansion',
5079
+ });
5080
+ };
5081
+ // A paste of parameters only (`a ## b`) can spell any name: its
5082
+ // invocations decide, not its definition.
5083
+ for (const macro of pasted.macros.filter(item =>
5084
+ item.chains.some(chain => chain.some(op => op.literal != null)))) {
5085
+ push(macro.symbol.file, macro.symbol.startLine,
5086
+ `Macro ${macro.symbol.name} pastes \`${macro.spelling}\`, which can spell "${name}" at its ` +
5087
+ 'invocations; review the macro and its invocations');
5088
+ }
5089
+ for (const site of pasted.sites) {
5090
+ push(site.file, site.line,
5091
+ `Macro ${site.macro} pastes "${name}" at this invocation; change the macro arguments or ` +
5092
+ `definition so the expansion spells "${options.renameTo}"`);
5093
+ }
5094
+ }
5095
+
5096
+ // A call site excluded inside a region the parser could not read (fix
5097
+ // #396: a decoration macro defined in another header broke the member
5098
+ // around it) was decided from a partial tree: listed for review, never
5099
+ // dropped silently.
5100
+ if (options.renameTo && planAccountParts?.excludedEntries?.length > 0) {
5101
+ const taken = new Set([...changes, ...reviewItems].map(change => `${change.file}:${change.line}`));
5102
+ const seen = new Set();
5103
+ for (const entry of planAccountParts.excludedEntries) {
5104
+ const regions = index.files.get(entry.file)?.parseErrorRegions;
5105
+ if (!Array.isArray(regions) || !regions.some(([start, end]) => start <= entry.line && entry.line <= end)) continue;
5106
+ const rel = path.relative(index.root, entry.file);
5107
+ const key = `${rel}:${entry.line}`;
5108
+ if (taken.has(key) || seen.has(key)) continue;
5109
+ seen.add(key);
5110
+ reviewItems.push({
5111
+ file: rel,
5112
+ line: entry.line,
5113
+ expression: (index.getLineContent(entry.file, entry.line) || '').trim(),
5114
+ suggestion: `This line sits in a region the parser could not fully read, where the call was ` +
5115
+ `excluded (${entry.reason}) from a partial tree; rename it too if it names this "${name}"`,
5116
+ needsReview: true,
5117
+ reviewReason: 'parse-recovery',
5118
+ editKind: 'call',
5119
+ });
5120
+ }
5121
+ }
5122
+
5123
+ // Documentation references the compiler resolves (fix #390): C# `cref`
5124
+ // attributes naming the renamed member are edited (CS1574 fails the
5125
+ // build once the member is gone); ones the lookup cannot settle are
5126
+ // listed for review, not left as comment text.
5127
+ if (options.renameTo && langTraits(planLang)?.docCrefReferences) {
5128
+ const renamedDefs = [];
5129
+ const sameNameDefs = index.symbols.get(name) || [];
5130
+ for (const change of changes) {
5131
+ if (change.editKind !== 'definition' || change.newExpression === undefined) continue;
5132
+ for (const candidate of sameNameDefs) {
5133
+ if ((candidate.relativePath || candidate.file) === change.file &&
5134
+ (candidate.nameLine || candidate.startLine) === change.line &&
5135
+ !renamedDefs.includes(candidate)) renamedDefs.push(candidate);
5136
+ }
5137
+ }
5138
+ if (!renamedDefs.includes(def)) renamedDefs.push(def);
5139
+ _docCrefMemberPass(index, name, options.renameTo, renamedDefs, changes, reviewItems);
5140
+ }
5141
+
5142
+ // Method groups (fix #395): C# simple names and `this.`/`base.`/type
5143
+ // accesses in value position that the language's lookup resolves to the
5144
+ // renamed method (delegates, events, LINQ arguments, `nameof`).
5145
+ if (options.renameTo && def.className && langTraits(planLang)?.methodGroupReferences &&
5146
+ (def.type === 'method' || def.memberType === 'method' || def.isMethod)) {
5147
+ const renamedDefs = [];
5148
+ const sameNameDefs = index.symbols.get(name) || [];
5149
+ for (const change of changes) {
5150
+ if (change.editKind !== 'definition' || change.newExpression === undefined) continue;
5151
+ for (const candidate of sameNameDefs) {
5152
+ if ((candidate.relativePath || candidate.file) === change.file &&
5153
+ (candidate.nameLine || candidate.startLine) === change.line &&
5154
+ !renamedDefs.includes(candidate)) renamedDefs.push(candidate);
5155
+ }
5156
+ }
5157
+ if (!renamedDefs.includes(def)) renamedDefs.push(def);
5158
+ _methodGroupPass(index, name, options.renameTo, renamedDefs, changes,
5159
+ planUsages || index.usages(name, { codeOnly: false, includeTests: true, internalEvidence: true }));
5160
+ }
5161
+
5162
+ // Test functions the toolchain binds to a renamed declaration by NAME
5163
+ // (fix #383): Go examples `Example<Type>_<Method>` fail go vet once the
5164
+ // method is renamed alone. They are renamed with it (their definition
5165
+ // token), or listed for review when the binding is ambiguous.
5166
+ if (options.renameTo && langTraits(planLang)?.nameBoundTestFunctions) {
5167
+ const renamedDefs = [];
5168
+ const sameNameDefs = index.symbols.get(name) || [];
5169
+ for (const change of changes) {
5170
+ if (change.editKind !== 'definition' || change.newExpression === undefined) continue;
5171
+ for (const candidate of sameNameDefs) {
5172
+ if ((candidate.relativePath || candidate.file) === change.file &&
5173
+ (candidate.nameLine || candidate.startLine) === change.line &&
5174
+ !renamedDefs.includes(candidate)) renamedDefs.push(candidate);
5175
+ }
5176
+ }
5177
+ const { nameBoundTestFunctions } = require('./test-name-bindings');
5178
+ for (const bound of nameBoundTestFunctions(index, renamedDefs, name, options.renameTo)) {
5179
+ const example = bound.def;
5180
+ const rel = example.relativePath || example.file;
5181
+ const line = example.nameLine || example.startLine;
5182
+ if (changes.some(change => change.file === rel && change.line === line)) continue;
5183
+ const edit = renameIdentifierTokens(index, example.file, line, example.name, bound.newName,
5184
+ null, null, { definitionNameOnly: true, definition: example });
5185
+ const reason = bound.binding === 'member'
5186
+ ? `go vet binds ${example.name} to the renamed member`
5187
+ : `go vet binds ${example.name} to the renamed identifier`;
5188
+ if (bound.status === 'edit' && edit.renamed !== edit.source) {
5189
+ // Keep the file's changes in line order.
5190
+ const sameFile = changes.map((change, at) => ({ change, at }))
5191
+ .filter(item => item.change.file === rel);
5192
+ const after = sameFile.find(item => item.change.line > line);
5193
+ const at = after ? after.at
5194
+ : (sameFile.length > 0 ? sameFile[sameFile.length - 1].at + 1 : changes.length);
5195
+ changes.splice(at, 0, tagTokenEdit({
5196
+ file: rel,
5197
+ line,
5198
+ expression: edit.source,
5199
+ newExpression: edit.renamed,
5200
+ suggestion: `Rename example (${reason}): ${edit.renamed}`,
5201
+ editKind: 'example',
5202
+ }, edit));
5203
+ } else {
5204
+ reviewItems.push({
5205
+ file: rel,
5206
+ line,
5207
+ expression: edit.source,
5208
+ suggestion: bound.reason === 'example-referenced'
5209
+ ? `${reason}; it is also referenced as a function, so rename it to ${bound.newName} with its references`
5210
+ : `${example.name} may name the renamed declaration or a same-name declaration of another imported package; rename it to ${bound.newName} if it names this one`,
5211
+ needsReview: true,
5212
+ testBinding: true,
5213
+ reviewReason: bound.reason,
5214
+ editKind: 'example',
5215
+ });
5216
+ }
5217
+ }
5218
+ }
5219
+
5220
+ // A member assignment onto a function-local or undeclared object is not
5221
+ // a project declaration (fix #359): its property name belongs to that
5222
+ // object's own type, so the edit is withheld and routed to review.
5223
+ if (options.renameTo) {
5224
+ const ownerScope = memberAssignmentOwnerScope(index, def);
5225
+ if (ownerScope === 'local' || ownerScope === 'external') {
5226
+ const defChange = changes.find(change =>
5227
+ change.file === definitionFile && change.line === definitionLine &&
5228
+ change.editKind === 'definition');
5229
+ if (defChange) {
5230
+ delete defChange.newExpression;
5231
+ defChange.needsReview = true;
5232
+ defChange.reviewReason = ownerScope === 'local'
5233
+ ? 'member-assignment-local-object' : 'member-assignment-external-object';
5234
+ defChange.suggestion = `Property assignment on ${ownerScope === 'local'
5235
+ ? 'function-local' : 'undeclared (runtime/global)'} object ` +
5236
+ `"${def.assignedReceiver}": "${name}" is that object's property, ` +
5237
+ 'not a project declaration; rename only if you own its type';
5238
+ }
5239
+ }
5240
+ }
5241
+
5242
+ // Contract membership (fix #360). A member that fills the slot of a
5243
+ // contract outside the project cannot be renamed by editing project code:
5244
+ // a definite membership (external trait impl, marked override with no
5245
+ // project definer, language protocol or universal-supertype member)
5246
+ // withholds every edit; a possible one (the owner declares out-of-project
5247
+ // supertypes) keeps the edits and requires review at the definition and
5248
+ // the declaration a compiler would reject. Go satisfaction sites the
5249
+ // structural closure could not complete are listed where Go reports them.
5250
+ // Single-element annotations (fix #389): `@Marker(x)` sets the element
5251
+ // `value` without naming it. When the site's annotation type is the
5252
+ // renamed element's owner, the rename names the element there
5253
+ // (`@Marker(renamed = x)`); an undecided owner stays listed for review.
5254
+ if (options.renameTo && planUnverified.some(site => site.annotationShorthand?.certain)) {
5255
+ const pathMod = require('path');
5256
+ const kept = [];
5257
+ for (const site of planUnverified) {
5258
+ const shorthand = site.annotationShorthand;
5259
+ const abs = pathMod.isAbsolute(site.file) ? site.file : pathMod.join(index.root, site.file);
5260
+ const text = shorthand?.certain && Number.isInteger(shorthand.column)
5261
+ ? (index._getFileLines(abs)[site.line - 1] || '') : '';
5262
+ if (!text || shorthand.column > text.length ||
5263
+ changes.some(change => change.file === site.file && change.line === site.line)) {
5264
+ kept.push(site);
5265
+ continue;
5266
+ }
5267
+ const renamed = `${text.slice(0, shorthand.column)}${options.renameTo} = ${text.slice(shorthand.column)}`;
5268
+ changes.push({
5269
+ file: site.file,
5270
+ line: site.line,
5271
+ expression: text.trim(),
5272
+ newExpression: renamed.trim(),
5273
+ suggestion: `Name the element in the single-element annotation: ${renamed.trim()}`,
5274
+ editKind: 'reference',
5275
+ });
5276
+ }
5277
+ planUnverified.length = 0;
5278
+ planUnverified.push(...kept);
5279
+ }
5280
+
5281
+ // One source of truth for the plan's ACCOUNT (fix #376): a line the plan
5282
+ // edits as a call is confirmed and a line it lists as unverified is
5283
+ // unverified, whichever slot member's sweep contributed it; the pin's own
5284
+ // exclusions only keep the lines no listed site claims.
5285
+ if (options.renameTo && planAccountParts && planGroundSet &&
5286
+ (slotAccountSites.length > 0 || planUnverified.length > 0 || typePass)) {
5287
+ const pathMod = require('path');
5288
+ const absoluteOf = file => (pathMod.isAbsolute(file) ? file : pathMod.join(index.root, file));
5289
+ const editedCallLines = new Set(changes.filter(change =>
5290
+ change.editKind === 'call').map(change => `${change.file}:${change.line}`));
5291
+ const { buildAccount } = require('./account');
5292
+ // A type rename decides the call-shaped lines of its references too
5293
+ // (fix #386): edited ones are confirmed, the ones that provably name
5294
+ // another declaration are excluded, and the ones left for review are
5295
+ // listed with the unverified sites.
5296
+ const typeDecided = typePass?.decided || new Map();
5297
+ const relOf = file => pathMod.relative(index.root, file);
5298
+ const typeClaims = (keys) => {
5299
+ const confirmed = [];
5300
+ const excluded = [];
5301
+ for (const key of keys) {
5302
+ const hit = typeDecided.get(key);
5303
+ if (!hit) continue;
5304
+ const line = Number(key.slice(key.lastIndexOf(':') + 1));
5305
+ if (hit.edited) confirmed.push({ file: hit.abs, line });
5306
+ else excluded.push({ file: hit.abs, line, reason: 'other-declaration' });
5307
+ }
5308
+ return { confirmed, excluded };
5309
+ };
5310
+ const build = (extra) => buildAccount(index, name, {
5311
+ groundSet: planGroundSet,
5312
+ confirmedEntries: [
5313
+ ...planAccountParts.confirmedEntries,
5314
+ ...slotAccountSites.filter(site => editedCallLines.has(`${site.file}:${site.line}`))
5315
+ .map(site => ({ file: site.absoluteFile || absoluteOf(site.file), line: site.line })),
5316
+ ...extra.confirmed,
5317
+ ],
5318
+ unverifiedEntries: [
5319
+ ...planAccountParts.unverifiedEntries.filter(entry =>
5320
+ !typeDecided.has(`${relOf(entry.file)}:${entry.line}`)),
5321
+ ...planUnverified.map(site => ({ file: absoluteOf(site.file), line: site.line })),
5322
+ ],
5323
+ excludedEntries: [...extra.excluded, ...planAccountParts.excludedEntries],
5324
+ });
5325
+ let extra = typeClaims((typePass?.removed || []).map(site => `${site.file}:${site.line}`));
5326
+ planAccount = build(extra);
5327
+ if (typePass && planAccount.callNotResolved?.length > 0) {
5328
+ // Call-shaped lines no engine candidate claimed: settled by the
5329
+ // type pass, or listed with the unverified sites for review.
5330
+ const keys = planAccount.callNotResolved.map(site =>
5331
+ `${site.relativePath || relOf(site.file)}:${site.line}`);
5332
+ const more = typeClaims(keys);
5333
+ let listedMore = false;
5334
+ for (const key of keys) {
5335
+ const review = typePass.reviewed.get(key);
5336
+ if (!review || planUnverified.some(site => `${site.file}:${site.line}` === key)) continue;
5337
+ const line = Number(key.slice(key.lastIndexOf(':') + 1));
5338
+ planUnverified.push({
5339
+ file: key.slice(0, key.lastIndexOf(':')),
5340
+ line,
5341
+ expression: (index._getFileLines(review.abs)[line - 1] || '').trim(),
5342
+ reason: `type-reference-${review.reason}`,
5343
+ });
5344
+ listedMore = true;
5345
+ }
5346
+ if (listedMore) {
5347
+ planUnverified.sort((a, b) => codeUnitCompare(a.file, b.file) || a.line - b.line);
5348
+ }
5349
+ if (more.confirmed.length > 0 || more.excluded.length > 0 || listedMore) {
5350
+ extra = { confirmed: [...extra.confirmed, ...more.confirmed],
5351
+ excluded: [...extra.excluded, ...more.excluded] };
5352
+ planAccount = build(extra);
5353
+ }
5354
+ }
5355
+ }
5356
+ let contract = null;
5357
+ if (options.renameTo) {
5358
+ const findings = [];
5359
+ const seenMembers = new Set();
5360
+ for (const member of [def, ...contractRootDefs]) {
5361
+ const key = `${member.file}\0${member.startLine}`;
5362
+ if (seenMembers.has(key)) continue;
5363
+ seenMembers.add(key);
5364
+ const finding = externalContractOf(index, member) ||
5365
+ typedLiteralContract(index, member)?.external || null;
5366
+ if (finding) findings.push({ member, ...finding });
5367
+ }
5368
+ // Hierarchy slot the closure could not complete (fix #376): a member
5369
+ // marked as an override whose declaring ancestor was not located in
5370
+ // the project cannot be renamed alone (the compiler rejects the
5371
+ // marker, or dispatch silently changes); an ancestor spelling that
5372
+ // resolves to no single project class may declare the slot too.
5373
+ if (options.renameTo && def.className && !def.traitName &&
5374
+ !findings.some(finding => finding.member === def)) {
5375
+ const defSite = {
5376
+ file: def.file,
5377
+ relativePath: def.relativePath,
5378
+ line: def.nameLine || def.startLine,
5379
+ };
5380
+ const ownerDecl = hierarchySlot?.pinRef?.def;
5381
+ const classSites = ownerDecl && ownerDecl.file === def.file ? [{
5382
+ file: ownerDecl.file,
5383
+ relativePath: ownerDecl.relativePath || def.relativePath,
5384
+ line: ownerDecl.startLine,
5385
+ }] : [];
5386
+ const unresolvedDeclaring = (hierarchySlot?.unresolved || []).filter(owner =>
5387
+ (index.symbols.get(name) || []).some(member => member.className === owner));
5388
+ if (isOverrideMarked(def) && !(hierarchySlot?.ancestors.length > 0)) {
5389
+ findings.push({
5390
+ member: def,
5391
+ certainty: 'definite',
5392
+ via: unresolvedDeclaring.length > 0 ? unresolvedDeclaring
5393
+ : (hierarchySlot?.external.length > 0 ? hierarchySlot.external
5394
+ : [langTraits(planLang)?.universalSupertype || 'supertype']),
5395
+ reason: 'overrides-unlocated-member',
5396
+ sites: [defSite, ...classSites],
5397
+ });
5398
+ } else if (unresolvedDeclaring.length > 0) {
5399
+ findings.push({
5400
+ member: def,
5401
+ certainty: 'possible',
5402
+ via: unresolvedDeclaring,
5403
+ reason: 'ancestor-unresolved',
5404
+ sites: [defSite, ...classSites],
5405
+ });
5406
+ }
5407
+ }
5408
+ // A decorator that replaces the callable receives its name too
5409
+ // (fix #392): pytest fixtures, CLI commands, endpoints and registries
5410
+ // bind the function by it. Decorators proven to keep the name out
5411
+ // (standard ones, property accessors, project decorators that only
5412
+ // call/return/wrap it) need nothing more.
5413
+ {
5414
+ const { nameBindingDecorators } = require('./decorator-binding');
5415
+ const decoratorMemo = new Map();
5416
+ const decorated = new Set();
5417
+ // A pytest fixture is requested by name (fix #402): the
5418
+ // parameters pytest injects it into are renamed with it, each
5419
+ // with the uses that bind that parameter, and what pytest's
5420
+ // resolution leaves open is listed for review.
5421
+ const fixtureSites = options.renameTo && planLang === 'python'
5422
+ ? require('./pytest-fixtures').fixtureParameterSites(index, def) : null;
5423
+ if (fixtureSites) {
5424
+ for (const site of fixtureSites.edits) {
5425
+ const rel = path.relative(index.root, site.file);
5426
+ const edit = renameIdentifierTokens(index, site.file, site.line, name,
5427
+ options.renameTo, site.byteColumns);
5428
+ if (edit.renamed === edit.source) continue;
5429
+ const existing = changes.find(change => change.file === rel && change.line === site.line);
5430
+ if (existing) {
5431
+ if (existing.newExpression !== undefined && !existing.needsReview) mergeTokenEdit(existing, edit);
5432
+ continue;
5433
+ }
5434
+ changes.push(tagTokenEdit({
5435
+ file: rel,
5436
+ line: site.line,
5437
+ expression: edit.source,
5438
+ suggestion: `Update fixture parameter: ${edit.renamed}`,
5439
+ newExpression: edit.renamed,
5440
+ editKind: 'reference',
5441
+ }, edit));
5442
+ }
5443
+ const reasonText = {
5444
+ 'not-collected': 'pytest does not collect this module by default; if it is collected or imported into a test module, the parameter requests the fixture',
5445
+ 'parametrized': 'the parameter is also a parametrize argument name',
5446
+ 'plugin-fixture': 'the fixture is registered as a plugin and no project fixture of this name precedes it here',
5447
+ 'class-fixtures': 'its class declares several fixtures of this name',
5448
+ 'class-binding': 'its class binds the name',
5449
+ 'class-bases': 'its class has bases whose fixtures are not resolved',
5450
+ 'module-binding': 'the module binds the name otherwise than by one fixture definition',
5451
+ 'module-import': 'the module imports the name from elsewhere',
5452
+ 'module-namespace': 'the module may bind the name through a star import or a dynamically named fixture',
5453
+ 'unreadable': 'a module on its fixture lookup path could not be read',
5454
+ };
5455
+ for (const site of fixtureSites.reviews) {
5456
+ const rel = path.relative(index.root, site.file);
5457
+ if (reviewItems.some(item => item.file === rel && item.line === site.line)) continue;
5458
+ reviewItems.push({
5459
+ file: rel,
5460
+ line: site.line,
5461
+ expression: (index.getLineContent(site.file, site.line) || '').trim(),
5462
+ suggestion: `pytest may inject fixture "${name}" here by name: ` +
5463
+ `${reasonText[site.reason] || site.reason}; rename the parameter if it requests this fixture`,
5464
+ needsReview: true,
5465
+ editKind: 'reference',
5466
+ });
5467
+ }
5468
+ }
5469
+ for (const member of [def, ...contractRootDefs]) {
5470
+ const key = `${member.file}\0${member.startLine}`;
5471
+ if (decorated.has(key)) continue;
5472
+ decorated.add(key);
5473
+ let unproven = nameBindingDecorators(index, member, decoratorMemo);
5474
+ // The fixture pass above settled pytest's name binding.
5475
+ if (fixtureSites && !fixtureSites.dynamic && member === def) {
5476
+ const fileEntry = index.files.get(member.file);
5477
+ const vocab = langTraits(planLang)?.callableDecorators || {};
5478
+ const { resolveDecorator } = require('./async-producers');
5479
+ unproven = unproven.filter(text => resolveDecorator(text, fileEntry, vocab)?.name !== 'pytest.fixture');
5480
+ }
5481
+ if (unproven.length === 0) continue;
5482
+ // Point at each decorator line the declaration spans.
5483
+ const nameLine = member.nameLine || member.startLine;
5484
+ const lines = index._getFileLines(member.file) || [];
5485
+ const sites = [];
5486
+ for (let line = Math.max(1, member.startLine); line < nameLine; line++) {
5487
+ const text = (lines[line - 1] || '').trim();
5488
+ if (unproven.some(deco => text.startsWith(`@${deco.split('(')[0]}`))) {
5489
+ sites.push({ file: member.file, relativePath: member.relativePath, line });
5490
+ }
5491
+ }
5492
+ if (sites.length === 0) {
5493
+ sites.push({ file: member.file, relativePath: member.relativePath, line: nameLine });
5494
+ }
5495
+ findings.push({
5496
+ member,
5497
+ certainty: 'possible',
5498
+ via: unproven.map(text => `@${text}`),
5499
+ reason: 'decorator-name-binding',
5500
+ sites,
5501
+ });
5502
+ }
5503
+ }
5504
+ const definite = findings.find(finding => finding.certainty === 'definite');
5505
+ const defChange = changes.find(change =>
5506
+ change.file === definitionFile && change.line === definitionLine &&
5507
+ change.editKind === 'definition');
5508
+ const describe = finding => {
5509
+ const owner = finding.member.className || finding.member.registryContainer || finding.member.name;
5510
+ const via = finding.via.join(', ');
5511
+ switch (finding.reason) {
5512
+ case 'implements-external-trait':
5513
+ return `${owner}.${name} implements external trait ${via}; the trait fixes the name`;
5514
+ case 'language-protocol-member':
5515
+ return `${name} is a ${via} protocol member dispatched by name`;
5516
+ case 'possible-protocol-member':
5517
+ return `${owner} derives from ${via} (outside the project), which may carry a runtime protocol that uses ${name} by name`;
5518
+ case 'overrides-universal-supertype':
5519
+ return `${owner}.${name} overrides ${via}.${name}`;
5520
+ case 'overrides-external-member':
5521
+ return `${owner}.${name} overrides a member of ${via} (outside the project)`;
5522
+ case 'overrides-unlocated-member':
5523
+ return `${owner}.${name} is marked as an override, but the member it overrides (via ${via}) was not located in the project`;
5524
+ case 'ancestor-unresolved':
5525
+ return `${owner} extends ${via}, which does not resolve to one project class; it may declare ${name} too`;
5526
+ case 'implements-cfg-external-trait':
5527
+ return `${owner}.${name} implements ${via}, which is an external trait under some build configurations; the external trait fixes the name`;
5528
+ case 'compiler-protocol-type':
5529
+ return `${via} is a type the compiler references by its full name for a language feature the project uses`;
5530
+ case 'macro-generated-name':
5531
+ return `${name} is the callable the ${via} invocation defines; its name is generated by the macro, not written in the source`;
5532
+ case 'macro-invocation':
5533
+ return `${name} is a function-like macro (${via}) in effect where it is declared; this declaration is an invocation of the macro, not a function`;
5534
+ case 'standard-namespace-declaration':
5535
+ return `${name} is declared in the standard-library namespace ${via}; where the platform declares it, references bind the platform's`;
5536
+ case 'typed-literal-external-member':
5537
+ return `${owner}.${name} is a member of an object typed ${via} (outside the project)`;
5538
+ case 'decorator-name-binding':
5539
+ return `${via} receives ${name} and may bind it by its name (fixtures, commands, endpoints, registries); check what uses that name`;
5540
+ default:
5541
+ return `${owner} declares ${via} (outside the project); ${name} may implement or override a member of it`;
5542
+ }
5543
+ };
5544
+ const pushContractItem = (site, suggestion, reason) => {
5545
+ const rel = site.relativePath || site.file;
5546
+ if (reviewItems.some(item => item.file === rel && item.line === site.line &&
5547
+ item.contractDependency)) return;
5548
+ reviewItems.push({
5549
+ file: rel,
5550
+ line: site.line,
5551
+ expression: (index.getLineContent(site.file, site.line) || '').trim(),
5552
+ suggestion,
5553
+ needsReview: true,
5554
+ contractDependency: true,
5555
+ reviewReason: reason,
5556
+ editKind: 'contract',
5557
+ });
5558
+ };
5559
+ if (definite) {
5560
+ for (const change of changes) {
5561
+ if (change.newExpression === undefined) continue;
5562
+ delete change.newExpression;
5563
+ change.needsReview = true;
5564
+ change.reviewReason = change.reviewReason || 'contract-blocked';
5565
+ }
5566
+ if (defChange) {
5567
+ defChange.needsReview = true;
5568
+ defChange.reviewReason = definite.reason;
5569
+ defChange.suggestion = `Rename blocked: ${describe(definite)}. ` +
5570
+ (definite.reason === 'macro-generated-name'
5571
+ ? 'Change the invocation that generates it instead'
5572
+ : definite.reason === 'macro-invocation' ? 'Rename the macro instead'
5573
+ : 'Renaming it here breaks the implementation; change the contract, not this member');
5574
+ }
5575
+ } else if (findings.length > 0 && defChange) {
5576
+ defChange.needsReview = true;
5577
+ defChange.reviewReason = findings[0].reason;
5578
+ defChange.suggestion = `${defChange.suggestion}. Review: ${describe(findings[0])}`;
5579
+ }
5580
+ for (const finding of findings) {
5581
+ for (const site of finding.sites) {
5582
+ const siteRel = site.relativePath || site.file;
5583
+ if (siteRel === definitionFile && site.line === definitionLine) continue;
5584
+ pushContractItem(site, describe(finding), finding.reason);
5585
+ }
5586
+ }
5587
+ for (const review of goContractReviews) {
5588
+ pushContractItem(review, review.message, review.reason);
5589
+ }
5590
+ if (goContractReviews.length > 0 && defChange && !definite) {
5591
+ defChange.needsReview = true;
5592
+ defChange.reviewReason = defChange.reviewReason || 'contract-incomplete';
5593
+ }
5594
+ if (findings.length > 0 || goContractReviews.length > 0) {
5595
+ contract = {
5596
+ blocked: !!definite,
5597
+ external: findings.map(finding => ({
5598
+ member: `${finding.member.relativePath || finding.member.file}:` +
5599
+ `${finding.member.nameLine || finding.member.startLine}`,
5600
+ certainty: finding.certainty,
5601
+ reason: finding.reason,
5602
+ via: finding.via,
5603
+ })),
5604
+ incomplete: goContractReviews.map(review => ({
5605
+ file: review.relativePath,
5606
+ line: review.line,
5607
+ reason: review.reason,
5608
+ })),
5609
+ };
5610
+ }
5611
+ }
5612
+
3314
5613
  const changeSummary = {
3315
5614
  // editKind is a partition: a public declaration that is also an
3316
5615
  // export remains one definition edit, never two summary buckets.
@@ -3320,7 +5619,11 @@ function plan(index, name, options = {}) {
3320
5619
  imports: changes.filter(change => change.editKind === 'import').length,
3321
5620
  exports: changes.filter(change => change.editKind === 'export').length,
3322
5621
  references: changes.filter(change => change.editKind === 'reference').length,
3323
- textReferences: reviewItems.length,
5622
+ examples: changes.filter(change => change.editKind === 'example').length,
5623
+ textReferences: reviewItems.filter(item =>
5624
+ !item.contractDependency && !item.templateDependency && !item.testBinding).length,
5625
+ contracts: reviewItems.filter(item => item.contractDependency).length,
5626
+ templates: reviewItems.filter(item => item.templateDependency).length,
3324
5627
  reviewRequired: changes.filter(change => change.needsReview).length +
3325
5628
  reviewItems.length,
3326
5629
  };
@@ -3344,11 +5647,13 @@ function plan(index, name, options = {}) {
3344
5647
  },
3345
5648
  totalChanges: changes.length,
3346
5649
  filesAffected: new Set([...changes, ...reviewItems].map(c => c.file)).size,
5650
+ ...(fileRenames.length > 0 && { fileRenames }),
3347
5651
  changeSummary,
3348
5652
  changes,
3349
5653
  reviewItems,
3350
5654
  totalReviewItems: reviewItems.length,
3351
5655
  ...(unchangedSites > 0 && { unchangedSites }),
5656
+ ...(contract && { contract }),
3352
5657
  // v4 tiered contract: sites that MAY also need this change but lack
3353
5658
  // binding/receiver evidence — review manually before refactoring.
3354
5659
  unverifiedCount: planUnverified.length,
@@ -3364,7 +5669,10 @@ function plan(index, name, options = {}) {
3364
5669
  }),
3365
5670
  ...(resolved.warnings.length > 0 && { warnings: resolved.warnings }),
3366
5671
  };
3367
- } finally { index._endOp(); }
5672
+ } finally {
5673
+ if (ownsTreeCache) clearTreeCache(index);
5674
+ index._endOp();
5675
+ }
3368
5676
  }
3369
5677
 
3370
5678
  /**
@@ -3441,4 +5749,384 @@ function analyzeCallSiteAST(index, filePath, lineNum, funcName) {
3441
5749
  return result;
3442
5750
  }
3443
5751
 
3444
- module.exports = { verify, plan, analyzeCallSite, analyzeCallSiteAST, analyzeCallShape, classifyArgNode, findCallNode, clearTreeCache, identifyCallPatterns };
5752
+ /**
5753
+ * C# XML documentation `cref` references to a renamed MEMBER (fix #390).
5754
+ * Each `cref` spelling of the name is resolved the way the compiler resolves
5755
+ * it: unqualified in the scope of the documented declaration (its type, the
5756
+ * type's ancestors, then enclosing types), qualified through the named type.
5757
+ * A cref reaching a renamed member is edited; one reaching another member,
5758
+ * or naming a type or namespace, stays a comment-text item; one the lookup
5759
+ * cannot settle (an unresolved type, part of an overload group without a
5760
+ * parameter list) is a review item.
5761
+ */
5762
+ function _docCrefMemberPass(index, name, renameTo, renamedDefs, changes, reviewItems) {
5763
+ const pathMod = require('path');
5764
+ const { ownerRefOf, resolveClassRef, findDeclaringClass } = require('./class-identity');
5765
+ const renamed = new Set(renamedDefs);
5766
+ const members = (index.symbols.get(name) || []).filter(d => d.className &&
5767
+ !IDENTITY_TYPE_KINDS_FOR_CREF.has(d.type));
5768
+ if (members.length === 0) return;
5769
+ const classRefOf = (fileEntry, cls) => {
5770
+ const entry = classDefsNamed(index, cls.name).entries.find(e => e.def === cls);
5771
+ return entry ? { name: cls.name, key: entry.key, def: cls } : null;
5772
+ };
5773
+ const scopeOf = (abs, fileEntry, line) => {
5774
+ // The documented declaration: the first one after the comment.
5775
+ let next = null;
5776
+ for (const symbol of fileEntry.symbols || []) {
5777
+ if (symbol.startLine <= line) continue;
5778
+ if (!next || symbol.startLine < next.startLine) next = symbol;
5779
+ }
5780
+ if (!next) return [];
5781
+ const chain = [];
5782
+ let ref = IDENTITY_TYPE_KINDS_FOR_CREF.has(next.type)
5783
+ ? classRefOf(fileEntry, next) : ownerRefOf(index, next);
5784
+ const seen = new Set();
5785
+ while (ref && !seen.has(ref.key || ref.name)) {
5786
+ seen.add(ref.key || ref.name);
5787
+ chain.push(ref);
5788
+ const outer = ref.def?.enclosingType;
5789
+ ref = outer ? resolveClassRef(index, outer, abs, { namespace: ref.def.namespace || null }) : null;
5790
+ }
5791
+ return chain;
5792
+ };
5793
+ const paramKey = text => String(text || '').replace(/\b(?:ref|out|in|params|this)\b/g, '')
5794
+ .replace(/[{<].*$/s, '').replace(/\s+/g, '').split('.').pop();
5795
+ // 'edit' | 'skip' | 'review' for a member found through `declaring`.
5796
+ const verdictFor = (declaring, paramsText) => {
5797
+ if (!declaring) return 'skip';
5798
+ if (declaring.uncertain) return 'review';
5799
+ const found = declaring.members;
5800
+ const hit = found.filter(member => renamed.has(member));
5801
+ if (hit.length === 0) return 'skip';
5802
+ if (hit.length === found.length) return 'edit';
5803
+ if (paramsText == null) return 'review';
5804
+ const wanted = paramsText.split(',').map(paramKey).filter(Boolean);
5805
+ const selected = found.filter(member => {
5806
+ const params = (member.paramsStructured || []).map(p => paramKey(p?.type));
5807
+ return params.length === wanted.length && params.every((p, i) => p === wanted[i]);
5808
+ });
5809
+ if (selected.length !== 1) return 'review';
5810
+ return renamed.has(selected[0]) ? 'edit' : 'skip';
5811
+ };
5812
+ const crefRe = /\bcref\s*=\s*"([^"]*)"/g;
5813
+ const idRe = new RegExp(`(^|[^A-Za-z0-9_])(${escapeRegExp(name)})(?![A-Za-z0-9_])`, 'g');
5814
+ for (let i = reviewItems.length - 1; i >= 0; i--) {
5815
+ const item = reviewItems[i];
5816
+ if (!item.textDependency) continue;
5817
+ const abs = pathMod.join(index.root, item.file);
5818
+ const fileEntry = index.files.get(abs);
5819
+ if (!fileEntry || !langTraits(fileEntry.language)?.docCrefReferences) continue;
5820
+ const text = index._getFileLines(abs)[item.line - 1] || '';
5821
+ if (!/^\s*\/\/\//.test(text)) continue;
5822
+ const edits = [];
5823
+ let review = false;
5824
+ let scope = null;
5825
+ crefRe.lastIndex = 0;
5826
+ let match;
5827
+ while ((match = crefRe.exec(text)) !== null) {
5828
+ const value = match[1];
5829
+ const valueStart = match.index + match[0].indexOf('"') + 1;
5830
+ const body = value.replace(/^[A-Za-z]:/, ' ');
5831
+ idRe.lastIndex = 0;
5832
+ let id;
5833
+ while ((id = idRe.exec(body)) !== null) {
5834
+ const start = id.index + id[1].length;
5835
+ const after = body.slice(start + name.length);
5836
+ // A qualifier segment (`Name.Member`) or generic type name.
5837
+ if (/^\s*[.{<]/.test(after)) continue;
5838
+ const paramsMatch = /^\s*\(([^)]*)\)/.exec(after);
5839
+ const paramsText = paramsMatch ? paramsMatch[1] : null;
5840
+ const qualifierText = body.slice(0, start).replace(/\.\s*$/, '');
5841
+ const qualified = /\.\s*$/.test(body.slice(0, start));
5842
+ let verdict;
5843
+ if (qualified) {
5844
+ const segment = qualifierText.split('.').pop().trim();
5845
+ const generic = /\{([^}]*)\}\s*$/.exec(segment);
5846
+ const typeName = segment.replace(/\{.*$/, '').trim();
5847
+ const arity = generic ? generic[1].split(',').length : 0;
5848
+ const traits = langTraits(fileEntry.language);
5849
+ const ref = resolveClassRef(index, typeName, abs, {
5850
+ ...(traits?.genericArityIsIdentity && { arity }),
5851
+ });
5852
+ if (ref.external) verdict = 'skip';
5853
+ else if (!ref.key) verdict = 'review';
5854
+ else verdict = verdictFor(findDeclaringClass(index, ref, members), paramsText);
5855
+ } else {
5856
+ if (!scope) scope = scopeOf(abs, fileEntry, item.line);
5857
+ if (scope.length === 0) verdict = 'review';
5858
+ else {
5859
+ verdict = 'skip';
5860
+ for (const ref of scope) {
5861
+ const declaring = findDeclaringClass(index, ref, members);
5862
+ if (!declaring) continue;
5863
+ verdict = verdictFor(declaring, paramsText);
5864
+ break;
5865
+ }
5866
+ }
5867
+ }
5868
+ if (verdict === 'edit') edits.push(valueStart + start);
5869
+ else if (verdict === 'review') review = true;
5870
+ }
5871
+ }
5872
+ if (review) {
5873
+ item.textDependency = false;
5874
+ item.reviewReason = 'doc-cref';
5875
+ item.suggestion = `Verify whether this documentation reference names the renamed ${name} ` +
5876
+ `and rename it to ${renameTo} if it does (the compiler resolves cref attributes)`;
5877
+ continue;
5878
+ }
5879
+ if (edits.length === 0) continue;
5880
+ let renamedText = text;
5881
+ for (const column of edits.sort((a, b) => b - a)) {
5882
+ renamedText = renamedText.slice(0, column) + renameTo + renamedText.slice(column + name.length);
5883
+ }
5884
+ reviewItems.splice(i, 1);
5885
+ const change = tagTokenEdit({
5886
+ file: item.file,
5887
+ line: item.line,
5888
+ expression: text.trim(),
5889
+ newExpression: renamedText.trim(),
5890
+ suggestion: `Update documentation reference: ${renamedText.trim()}`,
5891
+ editKind: 'reference',
5892
+ }, { columns: edits, rawSource: text, oldName: name, newName: renameTo, renamed: renamedText.trim() });
5893
+ // Keep the file's changes in line order.
5894
+ const sameFile = changes.map((other, at) => ({ other, at }))
5895
+ .filter(entry => entry.other.file === item.file);
5896
+ const after = sameFile.find(entry => entry.other.line > item.line);
5897
+ const at = after ? after.at
5898
+ : (sameFile.length > 0 ? sameFile[sameFile.length - 1].at + 1 : changes.length);
5899
+ changes.splice(at, 0, change);
5900
+ }
5901
+ }
5902
+
5903
+ const CSHARP_TYPE_DECLARATION_NODES = new Set(['class_declaration', 'struct_declaration',
5904
+ 'record_declaration', 'record_struct_declaration', 'interface_declaration']);
5905
+ // Declarations whose `name` field binds a local of the member body.
5906
+ const CSHARP_LOCAL_DECLARATION_NODES = new Set(['parameter', 'catch_declaration', 'declaration_pattern',
5907
+ 'declaration_expression', 'variable_declarator', 'local_function_statement', 'from_clause',
5908
+ 'join_clause', 'query_continuation', 'recursive_pattern', 'var_pattern']);
5909
+
5910
+ const CSHARP_BLOCK_SCOPES = new Set(['block', 'switch_section', 'for_statement', 'using_statement',
5911
+ 'fixed_statement', 'lambda_expression', 'anonymous_method_expression', 'arrow_expression_clause']);
5912
+
5913
+ /**
5914
+ * Where C# scopes a local named like the reference (spec 7.7): 'local' when
5915
+ * a parameter, local variable, local function, pattern, lambda, foreach,
5916
+ * catch or range variable of that name is in scope at `node`, 'none' when
5917
+ * no declaration of the member reaches it, 'unknown' for an unmodelled
5918
+ * declaration shape.
5919
+ */
5920
+ function csharpLocalBinding(memberNode, node, name) {
5921
+ const contains = (scope, target) => scope && scope.startIndex <= target.startIndex &&
5922
+ target.endIndex <= scope.endIndex;
5923
+ const nearest = (from, kinds) => {
5924
+ for (let current = from.parent; current && !sameNode(current, memberNode); current = current.parent) {
5925
+ if (kinds.has(current.type)) return current;
5926
+ }
5927
+ return memberNode;
5928
+ };
5929
+ let verdict = 'none';
5930
+ const scopeOf = (declared) => {
5931
+ const parent = declared.parent;
5932
+ switch (parent.type) {
5933
+ case 'parameter': {
5934
+ const owner = parent.parent?.parent;
5935
+ return owner || memberNode;
5936
+ }
5937
+ case 'variable_declarator': case 'local_function_statement':
5938
+ case 'declaration_pattern': case 'declaration_expression': case 'single_variable_designation':
5939
+ case 'recursive_pattern': case 'var_pattern':
5940
+ return nearest(parent, CSHARP_BLOCK_SCOPES);
5941
+ case 'catch_declaration':
5942
+ return parent.parent;
5943
+ case 'foreach_statement':
5944
+ return parent;
5945
+ case 'from_clause': case 'join_clause': case 'let_clause': case 'query_continuation':
5946
+ return nearest(parent, new Set(['query_expression']));
5947
+ default:
5948
+ return null;
5949
+ }
5950
+ };
5951
+ const visit = current => {
5952
+ if (verdict === 'local') return;
5953
+ if ((current.type === 'implicit_parameter' || current.type === 'identifier') && current.text === name &&
5954
+ !sameNode(current, node)) {
5955
+ const parent = current.parent;
5956
+ let declares = current.type === 'implicit_parameter';
5957
+ if (!declares && parent) {
5958
+ declares = (CSHARP_LOCAL_DECLARATION_NODES.has(parent.type) &&
5959
+ sameNode(parent.childForFieldName('name'), current) && !sameNode(parent, memberNode)) ||
5960
+ (parent.type === 'foreach_statement' && sameNode(parent.childForFieldName('left'), current)) ||
5961
+ parent.type === 'single_variable_designation' ||
5962
+ (parent.type === 'let_clause' && sameNode(parent.namedChild(0), current));
5963
+ }
5964
+ if (declares) {
5965
+ const scope = current.type === 'implicit_parameter' ? parent : scopeOf(current);
5966
+ if (!scope) verdict = 'unknown';
5967
+ else if (contains(scope, node)) verdict = 'local';
5968
+ }
5969
+ return;
5970
+ }
5971
+ for (const child of current.namedChildren || []) visit(child);
5972
+ };
5973
+ for (const child of memberNode.namedChildren || []) visit(child);
5974
+ return verdict;
5975
+ }
5976
+
5977
+ /**
5978
+ * Method groups in a C# member rename (fix #395): a simple name in value
5979
+ * position (`new(Build, 4)`, `Changed += OnChanged`, `.Select(Build)`,
5980
+ * `Func<int> f = Build`, `nameof(Build)`), a `this.`/`base.` access or a
5981
+ * type-qualified `Host.Build` names a method group, found by C# simple-name
5982
+ * lookup (spec 12.8.4): a local of that name in the enclosing member hides
5983
+ * it (review); then the enclosing class and its ancestry, then each
5984
+ * enclosing class outward. The first class that declares the name decides:
5985
+ * its members of that name are the renamed ones - an edit; the renamed ones
5986
+ * beside others (an overload chosen by the delegate's signature) or an
5987
+ * unresolved ancestry - a review item; none - not the pin.
5988
+ */
5989
+ function _methodGroupPass(index, name, renameTo, renamedDefs, changes, usages) {
5990
+ const pathMod = require('path');
5991
+ const { resolveClassRef, findDeclaringClass, parentRefsOf } = require('./class-identity');
5992
+ const renamed = new Set(renamedDefs);
5993
+ const members = (index.symbols.get(name) || []).filter(d => d.className &&
5994
+ !IDENTITY_TYPE_KINDS_FOR_CREF.has(d.type));
5995
+ if (!members.some(member => renamed.has(member))) return;
5996
+ const verdictOf = declaring => {
5997
+ if (!declaring) return null;
5998
+ if (declaring.uncertain) return 'review';
5999
+ const hit = declaring.members.filter(member => renamed.has(member));
6000
+ if (hit.length === 0) return 'skip';
6001
+ return hit.length === declaring.members.length ? 'edit' : 'review';
6002
+ };
6003
+ const classRefAt = (file, declNode) => {
6004
+ const nameNode = declNode.childForFieldName('name');
6005
+ if (!nameNode) return null;
6006
+ const line = nameNode.startPosition.row + 1;
6007
+ const entry = classDefsNamed(index, nameNode.text).entries.find(e => e.def.file === file &&
6008
+ (e.def.nameLine || e.def.startLine) <= line && e.def.endLine >= line &&
6009
+ e.def.startLine <= line);
6010
+ return entry ? { name: nameNode.text, key: entry.key, def: entry.def } : null;
6011
+ };
6012
+ const openAncestry = ref => parentRefsOf(index, ref).some(parent => parent.external || !parent.key);
6013
+ const verdictFor = (ref, tree) => {
6014
+ const row = ref.line - 1;
6015
+ const column = Number.isInteger(ref.column) ? ref.column : null;
6016
+ if (column == null || !tree) return 'review';
6017
+ const node = tree.rootNode.descendantForPosition({ row, column });
6018
+ if (!node || node.type !== 'identifier' || node.text !== name) return 'review';
6019
+ const types = [];
6020
+ let member = null;
6021
+ for (let current = node.parent; current; current = current.parent) {
6022
+ if (CSHARP_TYPE_DECLARATION_NODES.has(current.type)) types.push(current);
6023
+ else if (types.length === 0 && current.parent?.type === 'declaration_list') member = current;
6024
+ }
6025
+ if (types.length === 0) return 'review';
6026
+ const receiver = ref.receiver || null;
6027
+ if (receiver && receiver !== 'this' && receiver !== 'base') {
6028
+ // `Host.Build`: a static method group of the named class.
6029
+ if (!/^[A-Z]/.test(receiver)) return null;
6030
+ const typeRef = resolveClassRef(index, receiver, ref.file, { line: ref.line });
6031
+ if (!typeRef?.key) return typeRef?.external ? null : 'review';
6032
+ return verdictOf(findDeclaringClass(index, typeRef, members)) === 'edit' ? 'edit'
6033
+ : verdictOf(findDeclaringClass(index, typeRef, members)) === 'review' ? 'review' : null;
6034
+ }
6035
+ if (!receiver && member) {
6036
+ const local = csharpLocalBinding(member, node, name);
6037
+ if (local === 'local') return null;
6038
+ if (local === 'unknown') return 'review';
6039
+ }
6040
+ for (let i = 0; i < types.length; i++) {
6041
+ const classRef = classRefAt(ref.file, types[i]);
6042
+ if (!classRef) return 'review';
6043
+ const declaring = findDeclaringClass(index, classRef, members,
6044
+ receiver === 'base' ? { parentsOnly: true } : {});
6045
+ const verdict = verdictOf(declaring);
6046
+ if (verdict === 'skip') return null;
6047
+ if (verdict) return verdict;
6048
+ // `this.`/`base.` name the innermost class only; a simple name
6049
+ // continues outward unless an unmodelled base could declare it.
6050
+ if (receiver || openAncestry(classRef)) return receiver ? null : 'review';
6051
+ }
6052
+ return null;
6053
+ };
6054
+ const trees = new Map();
6055
+ const treeOf = file => {
6056
+ if (trees.has(file)) return trees.get(file);
6057
+ let tree;
6058
+ try {
6059
+ const content = index._readFile(file);
6060
+ tree = index._getParsedTree?.(file, content, 'csharp') || safeParse(getParser('csharp'), content);
6061
+ } catch { tree = null; }
6062
+ trees.set(file, tree);
6063
+ return tree;
6064
+ };
6065
+ // Only simple names and `this.`/`base.`/type-qualified accesses can name
6066
+ // the method group; other member accesses are never parsed for.
6067
+ const candidateRecord = record => record.usageType === 'reference' && !record.isDefinition &&
6068
+ (!record.receiver || record.receiver === 'this' || record.receiver === 'base' ||
6069
+ /^[A-Z]/.test(String(record.receiver)));
6070
+ for (const first of usages) {
6071
+ if (first.usageType !== 'reference' || first.isDefinition) continue;
6072
+ const abs = first.file;
6073
+ if (!langTraits(index.files.get(abs)?.language)?.methodGroupReferences) continue;
6074
+ const records = [first, ...(first.sameLine || [])].filter(candidateRecord);
6075
+ if (records.length === 0) continue;
6076
+ const rel = first.relativePath || pathMod.relative(index.root, abs);
6077
+ const edits = [];
6078
+ let review = false;
6079
+ for (const record of records) {
6080
+ const verdict = verdictFor({ ...record, file: abs }, treeOf(abs));
6081
+ if (verdict === 'edit') edits.push(record.column);
6082
+ else if (verdict === 'review') review = true;
6083
+ }
6084
+ if (edits.length === 0 && !review) continue;
6085
+ const existing = changes.find(change => change.file === rel && change.line === first.line);
6086
+ if (existing) {
6087
+ if (edits.length > 0 && existing.newExpression !== undefined && !existing.needsReview) {
6088
+ mergeTokenEdit(existing, renameIdentifierTokens(index, abs, first.line, name, renameTo, edits));
6089
+ }
6090
+ if (review && !existing.needsReview) {
6091
+ existing.needsReview = true;
6092
+ existing.reviewReason = existing.reviewReason || 'method-group';
6093
+ }
6094
+ continue;
6095
+ }
6096
+ let change;
6097
+ if (edits.length > 0 && !review) {
6098
+ const edit = renameIdentifierTokens(index, abs, first.line, name, renameTo, edits);
6099
+ if (edit.renamed === edit.source) continue;
6100
+ change = tagTokenEdit({
6101
+ file: rel,
6102
+ line: first.line,
6103
+ expression: edit.source,
6104
+ suggestion: `Update method group reference: ${edit.renamed}`,
6105
+ newExpression: edit.renamed,
6106
+ editKind: 'reference',
6107
+ }, edit);
6108
+ } else {
6109
+ change = {
6110
+ file: rel,
6111
+ line: first.line,
6112
+ expression: (first.content || index.getLineContent(abs, first.line) || '').trim(),
6113
+ suggestion: `Verify whether this method group names the renamed ${name} ` +
6114
+ `(an overload chosen by the delegate type, or a local of that name) and rename it to ${renameTo} if it does`,
6115
+ needsReview: true,
6116
+ reviewReason: 'method-group',
6117
+ editKind: 'reference',
6118
+ };
6119
+ }
6120
+ // Keep the file's changes in line order.
6121
+ const sameFile = changes.map((other, at) => ({ other, at }))
6122
+ .filter(entry => entry.other.file === rel);
6123
+ const after = sameFile.find(entry => entry.other.line > first.line);
6124
+ const at = after ? after.at
6125
+ : (sameFile.length > 0 ? sameFile[sameFile.length - 1].at + 1 : changes.length);
6126
+ changes.splice(at, 0, change);
6127
+ }
6128
+ }
6129
+
6130
+ const IDENTITY_TYPE_KINDS_FOR_CREF = new Set(['class', 'struct', 'interface', 'record', 'enum', 'trait', 'type']);
6131
+
6132
+ module.exports = { verify, plan, baseCounterpartOf, sitesReboundByChange, computePlanCallSites, analyzeCallSite, analyzeCallSiteAST, analyzeCallShape, classifyArgNode, findCallNode, clearTreeCache, identifyCallPatterns };