ucn 5.1.1 → 5.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/verify.js CHANGED
@@ -6,7 +6,9 @@
6
6
  */
7
7
 
8
8
  const { detectLanguage, getParser, getLanguageAdapter, safeParse, langTraits } = require('../languages');
9
- const { escapeRegExp, codeUnitCompare } = require('./shared');
9
+ const { sameNode } = require('../languages/utils');
10
+ const { escapeRegExp, codeUnitCompare, NON_CALLABLE_TYPES } = require('./shared');
11
+ const { findAccessorReferences } = require('./accessors');
10
12
 
11
13
  function codeUnitColumnForByteColumn(line, byteColumn) {
12
14
  if (!Number.isInteger(byteColumn) || byteColumn < 0) return null;
@@ -24,13 +26,14 @@ function codeUnitColumnForByteColumn(line, byteColumn) {
24
26
 
25
27
  /** Replace only AST identifier tokens on one source line. */
26
28
  function renameIdentifierTokens(index, filePath, lineNumber, oldName, newName,
27
- preferredByteColumns = null, expectedCallCount = null) {
29
+ preferredByteColumns = null, expectedCallCount = null, tokenOptions = {}) {
28
30
  const absolute = filePath && require('path').isAbsolute(filePath)
29
31
  ? filePath : require('path').join(index.root, filePath || '');
30
32
  const content = index._readFile(absolute);
31
33
  const sourceLine = content.split('\n')[lineNumber - 1] || '';
32
34
  let byteColumns = Array.isArray(preferredByteColumns)
33
35
  ? preferredByteColumns.filter(Number.isInteger) : [];
36
+ const defNameByteColumns = [];
34
37
 
35
38
  if (byteColumns.length === 0) {
36
39
  const language = index.files.get(absolute)?.language ||
@@ -65,7 +68,20 @@ function renameIdentifierTokens(index, filePath, lineNumber, oldName, newName,
65
68
  break;
66
69
  }
67
70
  }
68
- if (eligible) byteColumns.push(node.startPosition.column);
71
+ if (eligible) {
72
+ byteColumns.push(node.startPosition.column);
73
+ // Definition-name token: the identifier that IS its
74
+ // parent's `name` field (method_declaration name,
75
+ // function_definition name, variable_declarator name).
76
+ // Wrapper identity via sameNode (#233 — wrappers are
77
+ // not reference-stable across walks).
78
+ if (tokenOptions.definitionNameOnly && node.parent) {
79
+ const nameChild = node.parent.childForFieldName?.('name');
80
+ if (nameChild && sameNode(nameChild, node)) {
81
+ defNameByteColumns.push(node.startPosition.column);
82
+ }
83
+ }
84
+ }
69
85
  continue;
70
86
  }
71
87
  stack.push(...(node.namedChildren || []));
@@ -73,6 +89,17 @@ function renameIdentifierTokens(index, filePath, lineNumber, oldName, newName,
73
89
  }
74
90
  }
75
91
 
92
+ // Definition-line discipline (fix #300, mux-measured): a def line can
93
+ // repeat the symbol as a parameter TYPE (`func (r *Route) BuildVarsFunc(f
94
+ // BuildVarsFunc) *Route`) — renaming every token breaks the type
95
+ // reference. Rename only the definition's own NAME token (AST name field;
96
+ // first occurrence as fallback) — javac/gopls rename semantics.
97
+ if (tokenOptions.definitionNameOnly && byteColumns.length > 1) {
98
+ byteColumns = defNameByteColumns.length > 0
99
+ ? [defNameByteColumns[0]]
100
+ : [Math.min(...byteColumns)];
101
+ }
102
+
76
103
  const columns = [...new Set(byteColumns
77
104
  .map(column => codeUnitColumnForByteColumn(sourceLine, column))
78
105
  .filter(Number.isInteger))].sort((a, b) => b - a);
@@ -720,7 +747,7 @@ function contractedCallerSweep(index, name, def) {
720
747
  return (a.line || 0) - (b.line || 0);
721
748
  });
722
749
 
723
- return { confirmed, unverified, account };
750
+ return { confirmed, unverified, account, groundSet };
724
751
  }
725
752
 
726
753
  /** Map an unverified sweep entry to the public site shape (relative `file`). */
@@ -734,6 +761,14 @@ function unverifiedSiteShape(u) {
734
761
  ...(u.reason && { reason: u.reason }),
735
762
  ...(u.dispatchVia && { dispatchVia: u.dispatchVia }),
736
763
  ...(u.dispatchCandidates != null && { dispatchCandidates: u.dispatchCandidates }),
764
+ // External attribution (fixes #210/#220(6)/#265D): the engine already
765
+ // labels these routes; the JSON site must carry the flag so consumers
766
+ // can tell "satisfied by a contract outside the project" from
767
+ // project-attributed dispatch (the text band renders it already).
768
+ ...(u.externalContract && { externalContract: true }),
769
+ // Module-attribute attribution (fix #294): the name binds the module's
770
+ // export surface — a non-slot surface for rename purposes.
771
+ ...(u.moduleAttribute && { moduleAttribute: true }),
737
772
  };
738
773
  }
739
774
 
@@ -747,7 +782,7 @@ function unverifiedSiteShape(u) {
747
782
  * @returns {{ sites: Array, unverifiedSites: Array, account: object }}
748
783
  */
749
784
  function computePlanCallSites(index, name, def) {
750
- const { confirmed, unverified, account } = contractedCallerSweep(index, name, def);
785
+ const { confirmed, unverified, account, groundSet } = contractedCallerSweep(index, name, def);
751
786
 
752
787
  const sites = [];
753
788
  const planLineSeen = new Map(); // 'file:line' -> per-line ordinal (fix #231)
@@ -782,7 +817,15 @@ function computePlanCallSites(index, name, def) {
782
817
  if (fc !== 0) return fc;
783
818
  return (a.line || 0) - (b.line || 0);
784
819
  });
785
- return { sites, unverifiedSites: unverified.map(unverifiedSiteShape), account };
820
+ return {
821
+ sites,
822
+ unverifiedSites: unverified.map(unverifiedSiteShape),
823
+ // Plan-only evidence used to promote calls through a compiler-proven
824
+ // Go interface slot. Kept out of the public JSON surface.
825
+ rawUnverified: unverified,
826
+ account,
827
+ groundSet,
828
+ };
786
829
  }
787
830
 
788
831
  /**
@@ -1623,6 +1666,346 @@ const RESERVED_WORDS_BY_LANGUAGE = {
1623
1666
  'while').split(' ')),
1624
1667
  };
1625
1668
 
1669
+ /** Split a Go signature list on commas outside nested type syntax. */
1670
+ function splitGoSignatureList(raw) {
1671
+ const text = String(raw || '').trim().replace(/^\((.*)\)$/s, '$1');
1672
+ if (!text) return [];
1673
+ const out = [];
1674
+ let start = 0;
1675
+ let round = 0;
1676
+ let square = 0;
1677
+ let curly = 0;
1678
+ for (let i = 0; i < text.length; i++) {
1679
+ const ch = text[i];
1680
+ if (ch === '(') round++;
1681
+ else if (ch === ')') round = Math.max(0, round - 1);
1682
+ else if (ch === '[') square++;
1683
+ else if (ch === ']') square = Math.max(0, square - 1);
1684
+ else if (ch === '{') curly++;
1685
+ else if (ch === '}') curly = Math.max(0, curly - 1);
1686
+ else if (ch === ',' && round === 0 && square === 0 && curly === 0) {
1687
+ out.push(text.slice(start, i).trim());
1688
+ start = i + 1;
1689
+ }
1690
+ }
1691
+ out.push(text.slice(start).trim());
1692
+ return out.filter(Boolean);
1693
+ }
1694
+
1695
+ const GO_UNNAMED_TYPE_PREFIX = /^(?:\*|\[|map\[|chan(?:<-)?\s|<-chan\s|func\s*\(|interface\s*\{|struct\s*\{|\.\.\.)/;
1696
+
1697
+ /** Turn one raw Go parameter/result declaration into its type spelling. */
1698
+ function goDeclarationType(raw) {
1699
+ let value = String(raw || '').trim();
1700
+ if (!value) return null;
1701
+ // Named slots/results: `value T`, `ctx context.Context`. Unnamed type
1702
+ // forms starting with Go type syntax (`chan T`, `func(...)`, `*T`) keep
1703
+ // their whole text. Structured params normally handle grouped names;
1704
+ // this fallback exists for unnamed interface slots and result tuples.
1705
+ if (!GO_UNNAMED_TYPE_PREFIX.test(value)) {
1706
+ const named = value.match(/^[A-Za-z_][A-Za-z0-9_]*\s+(.+)$/s);
1707
+ if (named) value = named[1].trim();
1708
+ }
1709
+ return value.replace(/\s+/g, '');
1710
+ }
1711
+
1712
+ /** Compiler-shaped Go method fingerprint: parameter types + result types. */
1713
+ function goMethodFingerprint(def) {
1714
+ let params;
1715
+ if (Array.isArray(def.paramsStructured) && def.paramsStructured.length > 0) {
1716
+ params = [];
1717
+ for (const param of def.paramsStructured) {
1718
+ const rawType = param.type || (param.unnamed ? param.name : null);
1719
+ const type = goDeclarationType(rawType);
1720
+ if (!type) return null;
1721
+ params.push(type);
1722
+ }
1723
+ } else if (def.params === '' || def.params == null) {
1724
+ params = [];
1725
+ } else {
1726
+ params = splitGoSignatureList(def.params).map(goDeclarationType);
1727
+ if (params.some(type => !type)) return null;
1728
+ }
1729
+ const results = def.returnType
1730
+ ? splitGoSignatureList(def.returnType).map(goDeclarationType)
1731
+ : [];
1732
+ if (results.some(type => !type)) return null;
1733
+ return `${params.join(',')}->${results.join(',')}`;
1734
+ }
1735
+
1736
+ function goTypeIdentity(name, file) {
1737
+ const pathMod = require('path');
1738
+ return `${pathMod.dirname(file || '')}\0${name}`;
1739
+ }
1740
+
1741
+ function goTypeDefinition(index, name, fromFile) {
1742
+ const pathMod = require('path');
1743
+ const dir = pathMod.dirname(fromFile || '');
1744
+ const kinds = new Set(['struct', 'type', 'class']);
1745
+ const defs = (index.symbols.get(name) || []).filter(candidate =>
1746
+ candidate.file && pathMod.dirname(candidate.file) === dir &&
1747
+ kinds.has(candidate.type));
1748
+ if (defs.length !== 1 || defs[0].generics) return null;
1749
+ return defs[0];
1750
+ }
1751
+
1752
+ function goInterfaceMembers(index, interfaceDef) {
1753
+ const out = [];
1754
+ for (const defs of index.symbols.values()) {
1755
+ for (const candidate of defs) {
1756
+ if (candidate.file === interfaceDef.file &&
1757
+ candidate.className === interfaceDef.name &&
1758
+ candidate.type === 'method' &&
1759
+ candidate.startLine >= interfaceDef.startLine &&
1760
+ candidate.startLine <= interfaceDef.endLine) out.push(candidate);
1761
+ }
1762
+ }
1763
+ return out;
1764
+ }
1765
+
1766
+ /** Authored embedded-type spellings for one Go type declaration. */
1767
+ function goEmbeddedTypes(index, typeDef) {
1768
+ const out = [];
1769
+ for (const definitions of index.symbols.values()) {
1770
+ for (const candidate of definitions) {
1771
+ if (candidate.file === typeDef.file &&
1772
+ candidate.className === typeDef.name &&
1773
+ candidate.type === 'field' && candidate.fieldType &&
1774
+ candidate.startLine >= typeDef.startLine &&
1775
+ candidate.startLine <= typeDef.endLine) {
1776
+ out.push(String(candidate.fieldType).trim());
1777
+ }
1778
+ }
1779
+ }
1780
+ return out;
1781
+ }
1782
+
1783
+ /**
1784
+ * Complete method requirements for one project Go interface. A qualified or
1785
+ * unresolved embedded interface makes the set open, so the caller abstains.
1786
+ */
1787
+ function goInterfaceRequirements(index, interfaceDef, memo, visiting = new Set()) {
1788
+ const key = `${interfaceDef.file}\0${interfaceDef.name}`;
1789
+ if (memo.has(key)) return memo.get(key);
1790
+ if (visiting.has(key) || interfaceDef.generics) return null;
1791
+ visiting.add(key);
1792
+ const requirements = new Map();
1793
+ for (const member of goInterfaceMembers(index, interfaceDef)) {
1794
+ const fingerprint = goMethodFingerprint(member);
1795
+ if (!fingerprint) { visiting.delete(key); memo.set(key, null); return null; }
1796
+ const previous = requirements.get(member.name);
1797
+ if (previous && previous.fingerprint !== fingerprint) {
1798
+ visiting.delete(key); memo.set(key, null); return null;
1799
+ }
1800
+ requirements.set(member.name, { fingerprint, defs: [member] });
1801
+ }
1802
+ for (const rawParent of goEmbeddedTypes(index, interfaceDef)) {
1803
+ if (rawParent.includes('.')) {
1804
+ visiting.delete(key); memo.set(key, null); return null;
1805
+ }
1806
+ const parent = (index.symbols.get(rawParent) || []).find(candidate =>
1807
+ candidate.type === 'interface' &&
1808
+ require('path').dirname(candidate.file || '') ===
1809
+ require('path').dirname(interfaceDef.file || ''));
1810
+ if (!parent) { visiting.delete(key); memo.set(key, null); return null; }
1811
+ const inherited = goInterfaceRequirements(index, parent, memo, visiting);
1812
+ if (!inherited) { visiting.delete(key); memo.set(key, null); return null; }
1813
+ for (const [name, requirement] of inherited) {
1814
+ const previous = requirements.get(name);
1815
+ if (previous && previous.fingerprint !== requirement.fingerprint) {
1816
+ visiting.delete(key); memo.set(key, null); return null;
1817
+ }
1818
+ if (!previous) requirements.set(name, requirement);
1819
+ }
1820
+ }
1821
+ visiting.delete(key);
1822
+ const result = requirements.size > 0 ? requirements : null;
1823
+ memo.set(key, result);
1824
+ return result;
1825
+ }
1826
+
1827
+ /**
1828
+ * Method set for a project Go named type, including promoted embedded methods.
1829
+ * Unknown/external embeddings make the set open and force abstention.
1830
+ */
1831
+ function goConcreteMethodSet(index, typeDef, memo, visiting = new Set()) {
1832
+ const key = goTypeIdentity(typeDef.name, typeDef.file);
1833
+ if (memo.has(key)) return memo.get(key);
1834
+ if (visiting.has(key) || typeDef.generics) return null;
1835
+ visiting.add(key);
1836
+ const methods = new Map();
1837
+ const directNames = new Set();
1838
+ const dir = require('path').dirname(typeDef.file || '');
1839
+ for (const [name, defs] of index.symbols) {
1840
+ const owned = defs.filter(candidate =>
1841
+ !NON_CALLABLE_TYPES.has(candidate.type) &&
1842
+ candidate.className === typeDef.name && candidate.file &&
1843
+ require('path').dirname(candidate.file) === dir &&
1844
+ candidate.type !== 'method');
1845
+ for (const method of owned) {
1846
+ const fingerprint = goMethodFingerprint(method);
1847
+ if (!fingerprint) { visiting.delete(key); memo.set(key, null); return null; }
1848
+ const previous = methods.get(name);
1849
+ if (previous && previous.fingerprint !== fingerprint) {
1850
+ visiting.delete(key); memo.set(key, null); return null;
1851
+ }
1852
+ if (!previous) methods.set(name, { fingerprint, defs: [method] });
1853
+ else previous.defs.push(method);
1854
+ directNames.add(name);
1855
+ }
1856
+ }
1857
+ for (const rawParent of goEmbeddedTypes(index, typeDef)) {
1858
+ if (rawParent.includes('.')) {
1859
+ visiting.delete(key); memo.set(key, null); return null;
1860
+ }
1861
+ const parent = goTypeDefinition(index, rawParent.replace(/^\*/, ''), typeDef.file);
1862
+ if (!parent) { visiting.delete(key); memo.set(key, null); return null; }
1863
+ const promoted = goConcreteMethodSet(index, parent, memo, visiting);
1864
+ if (!promoted) { visiting.delete(key); memo.set(key, null); return null; }
1865
+ for (const [name, method] of promoted) {
1866
+ // A direct method shadows a promoted one. Two promoted methods of
1867
+ // the same name are ambiguous in Go; abstain instead of coupling.
1868
+ if (methods.has(name)) {
1869
+ if (directNames.has(name)) continue;
1870
+ visiting.delete(key); memo.set(key, null); return null;
1871
+ }
1872
+ methods.set(name, method);
1873
+ }
1874
+ }
1875
+ visiting.delete(key);
1876
+ memo.set(key, methods);
1877
+ return methods;
1878
+ }
1879
+
1880
+ function goMethodSetSatisfies(methods, requirements) {
1881
+ if (!methods || !requirements) return false;
1882
+ for (const [name, requirement] of requirements) {
1883
+ if (methods.get(name)?.fingerprint !== requirement.fingerprint) return false;
1884
+ }
1885
+ return true;
1886
+ }
1887
+
1888
+ function goMethodArityCompatible(def, argCount) {
1889
+ if (!Number.isInteger(argCount)) return true;
1890
+ let params;
1891
+ if (Array.isArray(def.paramsStructured) && def.paramsStructured.length > 0) {
1892
+ params = def.paramsStructured.map(param =>
1893
+ param.type || (param.unnamed ? param.name : null));
1894
+ } else if (def.params === '' || def.params == null) {
1895
+ params = [];
1896
+ } else {
1897
+ params = splitGoSignatureList(def.params);
1898
+ }
1899
+ if (params.some(param => !param)) return true;
1900
+ const variadic = params.length > 0 &&
1901
+ goDeclarationType(params[params.length - 1])?.startsWith('...');
1902
+ return variadic ? argCount >= params.length - 1 : argCount === params.length;
1903
+ }
1904
+
1905
+ function goSlotCoversMethodAmbiguity(index, name, slot, raw) {
1906
+ if (!slot || raw.reason !== 'method-ambiguous') return false;
1907
+ const candidates = (index.symbols.get(name) || []).filter(definition =>
1908
+ definition.className && !NON_CALLABLE_TYPES.has(definition.type) &&
1909
+ goMethodArityCompatible(definition, raw.argCount));
1910
+ return candidates.length > 0 && candidates.every(definition =>
1911
+ slot.memberIdentity.has(`${require('path').resolve(definition.file)}\0${definition.startLine}`));
1912
+ }
1913
+
1914
+ /**
1915
+ * Compute the Go method/interface rename component containing the pinned
1916
+ * member. Go satisfaction is implicit, so this is a bipartite fixed point:
1917
+ * concrete types join interfaces whose complete method sets they satisfy,
1918
+ * and every satisfier of a joined interface joins the same compiler slot.
1919
+ */
1920
+ function goInterfaceRenameClosure(index, targetDef) {
1921
+ if (!targetDef.className || goMethodFingerprint(targetDef) == null) return null;
1922
+ const pathMod = require('path');
1923
+ const interfaceMemo = new Map();
1924
+ const methodMemo = new Map();
1925
+ const interfaces = [];
1926
+ for (const defs of index.symbols.values()) {
1927
+ for (const candidate of defs) {
1928
+ if (candidate.type !== 'interface') continue;
1929
+ const requirements = goInterfaceRequirements(index, candidate, interfaceMemo);
1930
+ const directTarget = goInterfaceMembers(index, candidate).find(member =>
1931
+ member.name === targetDef.name &&
1932
+ goMethodFingerprint(member) === goMethodFingerprint(targetDef));
1933
+ if (requirements && directTarget) {
1934
+ interfaces.push({
1935
+ key: `${candidate.file}\0${candidate.name}`,
1936
+ def: candidate,
1937
+ requirements,
1938
+ targetDefs: [directTarget],
1939
+ });
1940
+ }
1941
+ }
1942
+ }
1943
+ if (interfaces.length === 0) return null;
1944
+
1945
+ const concreteByKey = new Map();
1946
+ for (const definition of index.symbols.get(targetDef.name) || []) {
1947
+ if (!definition.className || definition.type === 'method' ||
1948
+ NON_CALLABLE_TYPES.has(definition.type)) continue;
1949
+ const typeDef = goTypeDefinition(index, definition.className, definition.file);
1950
+ if (!typeDef) continue;
1951
+ const key = goTypeIdentity(typeDef.name, typeDef.file);
1952
+ const node = concreteByKey.get(key) || {
1953
+ key, def: typeDef, methods: goConcreteMethodSet(index, typeDef, methodMemo),
1954
+ targetDefs: [],
1955
+ };
1956
+ node.targetDefs.push(definition);
1957
+ concreteByKey.set(key, node);
1958
+ }
1959
+
1960
+ const startInterface = interfaces.find(node =>
1961
+ node.def.file === targetDef.file && node.def.name === targetDef.className);
1962
+ const startConcreteKey = goTypeIdentity(targetDef.className, targetDef.file);
1963
+ if (!startInterface && !concreteByKey.has(startConcreteKey)) return null;
1964
+
1965
+ const joinedInterfaces = new Set(startInterface ? [startInterface.key] : []);
1966
+ const joinedConcrete = new Set(startInterface ? [] : [startConcreteKey]);
1967
+ let changed = true;
1968
+ while (changed) {
1969
+ changed = false;
1970
+ for (const interfaceNode of interfaces) {
1971
+ for (const concrete of concreteByKey.values()) {
1972
+ if (!goMethodSetSatisfies(concrete.methods, interfaceNode.requirements)) continue;
1973
+ if (joinedInterfaces.has(interfaceNode.key) && !joinedConcrete.has(concrete.key)) {
1974
+ joinedConcrete.add(concrete.key); changed = true;
1975
+ }
1976
+ if (joinedConcrete.has(concrete.key) && !joinedInterfaces.has(interfaceNode.key)) {
1977
+ joinedInterfaces.add(interfaceNode.key); changed = true;
1978
+ }
1979
+ }
1980
+ }
1981
+ }
1982
+ if (joinedInterfaces.size === 0) return null;
1983
+
1984
+ const memberDefs = [];
1985
+ const interfaceNames = new Set();
1986
+ for (const node of interfaces) {
1987
+ if (!joinedInterfaces.has(node.key)) continue;
1988
+ interfaceNames.add(node.def.name);
1989
+ memberDefs.push(...node.targetDefs);
1990
+ }
1991
+ for (const node of concreteByKey.values()) {
1992
+ if (joinedConcrete.has(node.key)) memberDefs.push(...node.targetDefs);
1993
+ }
1994
+ const seen = new Set();
1995
+ const uniqueMembers = memberDefs.filter(member => {
1996
+ const key = `${pathMod.resolve(member.file)}\0${member.startLine}`;
1997
+ if (seen.has(key)) return false;
1998
+ seen.add(key);
1999
+ return true;
2000
+ });
2001
+ return {
2002
+ interfaceNames,
2003
+ memberDefs: uniqueMembers,
2004
+ memberIdentity: new Set(uniqueMembers.map(member =>
2005
+ `${pathMod.resolve(member.file)}\0${member.startLine}`)),
2006
+ };
2007
+ }
2008
+
1626
2009
  function plan(index, name, options = {}) {
1627
2010
  index._beginOp();
1628
2011
  try {
@@ -1656,8 +2039,8 @@ function plan(index, name, options = {}) {
1656
2039
  // run contractedCallerSweep (v4 tiered contract), so plan and verify stay
1657
2040
  // in lock-step by construction. Unverified candidates are NOT planned
1658
2041
  // (they may target another symbol) but stay visible with reasons.
1659
- const { sites: planCallSites, unverifiedSites: planUnverified, account: planAccount } =
1660
- computePlanCallSites(index, name, def);
2042
+ const { sites: planCallSites, unverifiedSites: planUnverified, account: planAccount,
2043
+ groundSet: planGroundSet } = computePlanCallSites(index, name, def);
1661
2044
  const impactScopeWarning = computePlanScopeWarning(index, name, def, options);
1662
2045
 
1663
2046
  // Reject ambiguous multi-op invocations rather than silently coalescing.
@@ -1702,6 +2085,7 @@ function plan(index, name, options = {}) {
1702
2085
  let newSignature = currentSignature;
1703
2086
  let operation = null;
1704
2087
  let changes = [];
2088
+ const reviewItems = [];
1705
2089
  let unchangedSites = 0;
1706
2090
 
1707
2091
  if (options.addParam) {
@@ -1864,35 +2248,56 @@ function plan(index, name, options = {}) {
1864
2248
  // line appears ONCE however many call records it holds (fix #230 —
1865
2249
  // the non-global regex left the inner call behind and emitted a
1866
2250
  // duplicate entry per record).
1867
- const callLines = new Map();
1868
- for (const site of planCallSites) {
1869
- const lineKey = `${site.file}:${site.line}`;
1870
- const group = callLines.get(lineKey) || {
1871
- file: site.file,
1872
- absoluteFile: site.absoluteFile,
1873
- line: site.line,
1874
- expression: site.expression,
2251
+ // Slot closure is discovered after the pin's own caller sweep. Keep a
2252
+ // cumulative per-line record so later member sweeps can add a second
2253
+ // exact token on the SAME line (`a.Run() || b.Run() || other.Run()`)
2254
+ // and update one edit instead of losing it to line-level deduplication.
2255
+ const emittedRenameCalls = new Map();
2256
+ const emitRenameCallSites = (siteList) => {
2257
+ const touched = new Set();
2258
+ for (const incoming of siteList) {
2259
+ const lineKey = `${incoming.file}:${incoming.line}`;
2260
+ const site = emittedRenameCalls.get(lineKey) || {
2261
+ file: incoming.file,
2262
+ absoluteFile: incoming.absoluteFile,
2263
+ line: incoming.line,
2264
+ expression: incoming.expression,
1875
2265
  columns: [],
1876
2266
  missingColumn: false,
1877
2267
  callCount: 0,
1878
- calledAs: site.calledAs,
2268
+ calledAs: incoming.calledAs,
2269
+ change: null,
1879
2270
  };
1880
- if (group.calledAs !== site.calledAs) group.calledAs = null;
1881
- group.callCount++;
1882
- if (Number.isInteger(site.column)) group.columns.push(site.column);
1883
- else group.missingColumn = true;
1884
- callLines.set(lineKey, group);
2271
+ if (site.calledAs !== incoming.calledAs) site.calledAs = null;
2272
+ site.callCount++;
2273
+ if (Number.isInteger(incoming.column)) site.columns.push(incoming.column);
2274
+ else site.missingColumn = true;
2275
+ emittedRenameCalls.set(lineKey, site);
2276
+ touched.add(lineKey);
1885
2277
  }
1886
- for (const site of callLines.values()) {
2278
+ for (const lineKey of touched) {
2279
+ const site = emittedRenameCalls.get(lineKey);
1887
2280
  // A renamed import preserves its local alias (`old as local` /
1888
2281
  // `{ old: local }`). The caller engine carries the authored name
1889
2282
  // in calledAs; that token must remain unchanged while the import's
1890
2283
  // source-side identifier is edited below.
1891
2284
  if (site.calledAs && site.calledAs !== name) continue;
1892
- const edit = renameIdentifierTokens(index,
2285
+ let edit = renameIdentifierTokens(index,
1893
2286
  site.absoluteFile || site.file, site.line, name,
1894
2287
  options.renameTo, site.missingColumn ? [] : site.columns,
1895
2288
  site.callCount);
2289
+ // Column-less records restrict token eligibility to
2290
+ // call-expression targets — which finds nothing for confirmed
2291
+ // function-REFERENCE sites (`handler = serveWs`, macro-interior
2292
+ // calls whose records carry no column). Retry in all-identifier
2293
+ // mode: still AST tokens only, still refused unless the row's
2294
+ // token count equals the engine's record count, so a line mixing
2295
+ // the target with an unrelated same-name token stays manual.
2296
+ if (edit.renamed === edit.source && site.missingColumn) {
2297
+ edit = renameIdentifierTokens(index,
2298
+ site.absoluteFile || site.file, site.line, name,
2299
+ options.renameTo, null, site.callCount);
2300
+ }
1896
2301
  const newExpression = edit.renamed;
1897
2302
  // A confirmed call through an import alias (`xf()`) is a real
1898
2303
  // caller but the alias spelling does not change. The required
@@ -1903,26 +2308,42 @@ function plan(index, name, options = {}) {
1903
2308
  // required but cannot safely synthesize it. Never fall back
1904
2309
  // to whole-line regex replacement.
1905
2310
  if (site.missingColumn) {
1906
- changes.push({
2311
+ const manual = {
1907
2312
  file: site.file,
1908
2313
  line: site.line,
1909
2314
  expression: edit.source,
1910
2315
  suggestion: `Rename call identifier "${name}" to "${options.renameTo}" manually`,
1911
2316
  needsReview: true,
1912
2317
  editKind: 'call',
1913
- });
2318
+ };
2319
+ if (site.change) {
2320
+ for (const key of Object.keys(site.change)) delete site.change[key];
2321
+ Object.assign(site.change, manual);
2322
+ } else {
2323
+ site.change = manual;
2324
+ changes.push(manual);
2325
+ }
1914
2326
  }
1915
2327
  continue;
1916
2328
  }
1917
- changes.push({
2329
+ const concrete = {
1918
2330
  file: site.file,
1919
2331
  line: site.line,
1920
2332
  expression: edit.source,
1921
2333
  suggestion: `Rename to: ${newExpression}`,
1922
2334
  newExpression,
1923
2335
  editKind: 'call',
1924
- });
2336
+ };
2337
+ if (site.change) {
2338
+ for (const key of Object.keys(site.change)) delete site.change[key];
2339
+ Object.assign(site.change, concrete);
2340
+ } else {
2341
+ site.change = concrete;
2342
+ changes.push(concrete);
2343
+ }
1925
2344
  }
2345
+ };
2346
+ emitRenameCallSites(planCallSites);
1926
2347
 
1927
2348
  // Also include import statements that reference the renamed function.
1928
2349
  // Name ownership (fix #230, the #217 rule): an import of the same
@@ -1933,9 +2354,19 @@ function plan(index, name, options = {}) {
1933
2354
  // 'no' (the binding provably resolves elsewhere) skips; 'unknown'
1934
2355
  // (CJS surfaces, star imports, resolver gaps) keeps the import —
1935
2356
  // a missed import breaks the rename just as surely.
1936
- const { _nameBindingReaches } = require('./callers');
2357
+ const {
2358
+ _nameBindingReaches,
2359
+ _moduleAttributeBindingReaches,
2360
+ } = require('./callers');
1937
2361
  const renameTargetFiles = new Set([def.file]);
1938
- const usages = index.usages(name, { codeOnly: true });
2362
+ // A rename plan is repository-wide. The caller sweep already includes
2363
+ // tests; the import/reference sweep must not silently hide them via
2364
+ // usages()' navigation-oriented default test exclusion.
2365
+ const usages = index.usages(name, {
2366
+ codeOnly: false,
2367
+ includeTests: true,
2368
+ internalEvidence: true,
2369
+ });
1939
2370
  const importUsages = usages.filter(u => u.usageType === 'import' && !u.isDefinition);
1940
2371
  for (const imp of importUsages) {
1941
2372
  // Skip if already covered by a call site change in the same file:line
@@ -2010,6 +2441,23 @@ function plan(index, name, options = {}) {
2010
2441
  // re-export chain; unknown CJS/dynamic surfaces are unsafe to
2011
2442
  // rewrite mechanically.
2012
2443
  if (exportPath !== def.file && ownership !== 'yes') continue;
2444
+ // Symbol identity (fix #300, mux-measured): a name-keyed
2445
+ // export list records one entry per same-named symbol — under
2446
+ // the METHOD pin (r *Route) BuildVarsFunc, the entry anchored
2447
+ // at the same-named TYPE's definition line (`type
2448
+ // BuildVarsFunc func(...)`) is the type's export surface, not
2449
+ // the method's; renaming it silently renames the type and
2450
+ // breaks every type reference. Skip entries whose line hosts
2451
+ // a different same-named definition. CJS surfaces
2452
+ // (`module.exports = { helper }`) have no def at their line
2453
+ // and keep flowing.
2454
+ const defsAtExportLine = (index.symbols.get(name) || []).filter(d =>
2455
+ d.file === exportPath &&
2456
+ (d.startLine === exported.line ||
2457
+ (d.nameLine ?? d.startLine) === exported.line));
2458
+ const pinnedAtExportLine = defsAtExportLine.some(d =>
2459
+ d.file === def.file && d.startLine === def.startLine);
2460
+ if (defsAtExportLine.length > 0 && !pinnedAtExportLine) continue;
2013
2461
  const exportFile = targetEntry.relativePath || exportPath;
2014
2462
  if (changes.some(change =>
2015
2463
  change.file === exportFile && change.line === exported.line)) {
@@ -2034,6 +2482,109 @@ function plan(index, name, options = {}) {
2034
2482
  }
2035
2483
  }
2036
2484
 
2485
+ // Python __all__ string entries are rename edits (fix #300,
2486
+ // requests-measured): `__all__ = (..., "put", ...)` names the
2487
+ // module-level binding by STRING — after the def and the import that
2488
+ // binds it are renamed, the dangling entry is a compiler-checked
2489
+ // break (pyright reportUnsupportedDunderAll). grep sees inside
2490
+ // strings; the plan must too. Scan exactly the files whose
2491
+ // module-level binding of the name this plan renames: the pin's own
2492
+ // module (module-level pins only) and every file receiving an
2493
+ // [import] edit. AST-detected: string elements of an __all__
2494
+ // assignment matching the name exactly; dynamic __all__ manipulation
2495
+ // stays out of scope.
2496
+ if (!def.className) {
2497
+ const pathMod = require('path');
2498
+ const dunderFiles = new Set([def.file]);
2499
+ for (const change of changes) {
2500
+ if (change.editKind !== 'import') continue;
2501
+ const abs = pathMod.isAbsolute(change.file)
2502
+ ? change.file : pathMod.join(index.root, change.file);
2503
+ dunderFiles.add(abs);
2504
+ }
2505
+ for (const abs of dunderFiles) {
2506
+ const fe = index.files.get(abs);
2507
+ if (!fe || fe.language !== 'python') continue;
2508
+ let content;
2509
+ try { content = index._readFile(abs); } catch { continue; }
2510
+ if (!content.includes('__all__')) continue;
2511
+ const parser = getParser('python');
2512
+ const tree = parser && (index._getParsedTree?.(abs, content, 'python') ||
2513
+ safeParse(parser, content));
2514
+ if (!tree) continue;
2515
+ const lines = content.split('\n');
2516
+ const rel = fe.relativePath || abs;
2517
+ const stack = [tree.rootNode];
2518
+ while (stack.length > 0) {
2519
+ const node = stack.pop();
2520
+ if (node.type === 'assignment' || node.type === 'augmented_assignment') {
2521
+ const left = node.childForFieldName('left');
2522
+ if (left?.text !== '__all__') continue;
2523
+ const right = node.childForFieldName('right');
2524
+ if (!right) continue;
2525
+ const strStack = [right];
2526
+ while (strStack.length > 0) {
2527
+ const s = strStack.pop();
2528
+ if (s.type === 'string') {
2529
+ const inner = s.text.replace(/^[rbuf]*["']/i, '').replace(/["']$/, '');
2530
+ if (inner !== name) continue;
2531
+ const lineNo = s.startPosition.row + 1;
2532
+ if (changes.some(c => c.file === rel && c.line === lineNo)) continue;
2533
+ const sourceLine = lines[lineNo - 1] || '';
2534
+ const quoteRe = new RegExp(`(["'])${escapeRegExp(name)}\\1`, 'g');
2535
+ const newLine = sourceLine.replace(quoteRe, `$1${options.renameTo}$1`);
2536
+ if (newLine === sourceLine) continue;
2537
+ changes.push({
2538
+ file: rel,
2539
+ line: lineNo,
2540
+ expression: sourceLine.trim(),
2541
+ suggestion: `Update __all__ entry: ${newLine.trim()}`,
2542
+ newExpression: newLine.trim(),
2543
+ editKind: 'reference',
2544
+ });
2545
+ } else {
2546
+ for (let i = 0; i < s.namedChildCount; i++) strStack.push(s.namedChild(i));
2547
+ }
2548
+ }
2549
+ continue;
2550
+ }
2551
+ for (let i = 0; i < node.namedChildCount; i++) stack.push(node.namedChild(i));
2552
+ }
2553
+ }
2554
+ }
2555
+
2556
+ // Overload signatures and their implementation are ONE callable
2557
+ // (fix #265A, def side): renaming any member must rename the whole
2558
+ // group, or the survivors keep the old name and the compiler rejects
2559
+ // the group (TS 2394 / pyright reportInconsistentOverload). Same
2560
+ // closure the caller engine uses (isSignature-gated, so Java arity
2561
+ // overloads — separate bindable methods — never close).
2562
+ {
2563
+ const { _closeCallableIdentityGroup } = require('./callers');
2564
+ const identityGroup = _closeCallableIdentityGroup(
2565
+ index, [def], definitions);
2566
+ for (const member of identityGroup) {
2567
+ if (member === def) continue;
2568
+ const line = member.nameLine || member.startLine;
2569
+ const rel = member.relativePath || member.file;
2570
+ if (changes.some(change =>
2571
+ change.file === rel && change.line === line)) continue;
2572
+ const edit = renameIdentifierTokens(index, member.file,
2573
+ line, name, options.renameTo,
2574
+ null, null, { definitionNameOnly: true });
2575
+ if (edit.renamed === edit.source) continue;
2576
+ changes.push({
2577
+ file: rel,
2578
+ line,
2579
+ expression: edit.source,
2580
+ suggestion: `Update overload signature: ${edit.renamed}`,
2581
+ newExpression: edit.renamed,
2582
+ isDefinition: true,
2583
+ editKind: 'definition',
2584
+ });
2585
+ }
2586
+ }
2587
+
2037
2588
  // Renaming a virtual/overridden member is one hierarchy-wide change.
2038
2589
  // Leaving descendant declarations behind either fails compilation
2039
2590
  // (Java/C#/TS override) or silently changes dispatch (Python/JS).
@@ -2043,9 +2594,68 @@ function plan(index, name, options = {}) {
2043
2594
  // classes in unrelated packages. Follow only children whose base
2044
2595
  // resolves from the child's scope to this exact parent file.
2045
2596
  const identityKey = (className, file) => `${file || ''}\0${className}`;
2597
+
2598
+ // The pinned member may itself be an override: the dispatch slot
2599
+ // is rooted at the TOPMOST project ancestor defining the name
2600
+ // (flask PassList.check → JSONTag.check → every sibling
2601
+ // override; outcome-eval 2026-08-18). Climb the extends chain
2602
+ // with the same identity discipline, emit each ancestor definer,
2603
+ // then walk DOWN from the root so sibling overrides join too.
2604
+ // Arity-overload languages require the ancestor's signature to
2605
+ // be the same virtual slot (a same-name different-arity Java
2606
+ // method is a sibling, never the root).
2607
+ let slotRoot = { name: def.className, file: def.file };
2608
+ const slotAncestors = [];
2609
+ const climbed = new Set([identityKey(slotRoot.name, slotRoot.file)]);
2610
+ for (let hop = 0; hop < 8; hop++) {
2611
+ const parents = index._getInheritanceParents(
2612
+ slotRoot.name, slotRoot.file) || [];
2613
+ let moved = false;
2614
+ for (const parentName of parents) {
2615
+ const parentFile = index._resolveClassFile(parentName, slotRoot.file);
2616
+ if (!parentFile) continue;
2617
+ const parentDef = (index.symbols.get(name) || []).find(symbol =>
2618
+ symbol.className === parentName && symbol.file === parentFile &&
2619
+ !NON_CALLABLE_TYPES.has(symbol.type));
2620
+ if (!parentDef) continue;
2621
+ if (langTraits(planLang)?.hasArityOverloads &&
2622
+ (parentDef.paramsStructured || []).length !==
2623
+ (def.paramsStructured || []).length) continue;
2624
+ const key = identityKey(parentName, parentFile);
2625
+ if (climbed.has(key)) break;
2626
+ climbed.add(key);
2627
+ slotRoot = { name: parentName, file: parentFile };
2628
+ slotAncestors.push({ className: parentName, file: parentFile, def: parentDef });
2629
+ moved = true;
2630
+ break;
2631
+ }
2632
+ if (!moved) break;
2633
+ }
2634
+ const slotMemberDefs = [];
2635
+ for (const ancestor of slotAncestors) {
2636
+ slotMemberDefs.push(ancestor.def);
2637
+ const line = ancestor.def.nameLine || ancestor.def.startLine;
2638
+ const rel = ancestor.def.relativePath || ancestor.def.file;
2639
+ if (changes.some(change =>
2640
+ change.file === rel && change.line === line)) continue;
2641
+ const edit = renameIdentifierTokens(index, ancestor.def.file,
2642
+ line, name, options.renameTo,
2643
+ null, null, { definitionNameOnly: true });
2644
+ if (edit.renamed === edit.source) continue;
2645
+ changes.push({
2646
+ file: rel,
2647
+ line,
2648
+ expression: edit.source,
2649
+ suggestion: `Update base definition: ${edit.renamed}`,
2650
+ newExpression: edit.renamed,
2651
+ isDefinition: true,
2652
+ editKind: 'definition',
2653
+ });
2654
+ }
2655
+
2046
2656
  const descendants = new Map();
2047
- const queue = [{ name: def.className, file: def.file }];
2048
- const visited = new Set([identityKey(def.className, def.file)]);
2657
+ const queue = [{ name: slotRoot.name, file: slotRoot.file }];
2658
+ const visited = new Set([identityKey(slotRoot.name, slotRoot.file)]);
2049
2659
  while (queue.length > 0 && descendants.size < 5000) {
2050
2660
  const parent = queue.shift();
2051
2661
  for (const child of index.extendedByGraph.get(parent.name) || []) {
@@ -2066,11 +2676,19 @@ function plan(index, name, options = {}) {
2066
2676
  for (const override of index.symbols.get(name) || []) {
2067
2677
  if (!override.className ||
2068
2678
  !descendants.has(identityKey(override.className, override.file))) continue;
2679
+ // Slot-rooting can put the pin's OWN class in the descendant
2680
+ // set. The pin handles itself; same-class siblings (Java
2681
+ // arity overloads, TS/Python signature stubs) belong to the
2682
+ // identity-group pass, never the hierarchy walk.
2683
+ if (override.className === def.className &&
2684
+ override.file === def.file &&
2685
+ override.startLine !== def.startLine) continue;
2069
2686
  const line = override.nameLine || override.startLine;
2070
2687
  const rel = override.relativePath || override.file;
2071
2688
  if (changes.some(change => change.file === rel && change.line === line)) continue;
2072
2689
  const edit = renameIdentifierTokens(index, override.file,
2073
- line, name, options.renameTo);
2690
+ line, name, options.renameTo,
2691
+ null, null, { definitionNameOnly: true });
2074
2692
  const sourceLine = edit.source;
2075
2693
  const newExpression = edit.renamed;
2076
2694
  if (newExpression === sourceLine) continue;
@@ -2083,7 +2701,260 @@ function plan(index, name, options = {}) {
2083
2701
  isDefinition: true,
2084
2702
  editKind: 'definition',
2085
2703
  });
2704
+ slotMemberDefs.push(override);
2705
+ }
2706
+
2707
+ // Go interface slots are implicit (fix #302, mux Match): there is
2708
+ // no implements edge for the hierarchy walk above. Compute the
2709
+ // compiler-shaped satisfaction component from complete project
2710
+ // method sets, then rename every declaration in that component.
2711
+ // Open method sets (external/qualified embeds, generics, unknown
2712
+ // signatures) abstain in goInterfaceRenameClosure — absence is
2713
+ // never treated as proof.
2714
+ const goInterfaceSlot = planLang === 'go'
2715
+ ? goInterfaceRenameClosure(index, def)
2716
+ : null;
2717
+ if (goInterfaceSlot) {
2718
+ for (const member of goInterfaceSlot.memberDefs) {
2719
+ if (member.file === def.file && member.startLine === def.startLine) continue;
2720
+ const line = member.nameLine || member.startLine;
2721
+ const rel = member.relativePath || member.file;
2722
+ if (!changes.some(change =>
2723
+ change.file === rel && change.line === line)) {
2724
+ const edit = renameIdentifierTokens(index, member.file,
2725
+ line, name, options.renameTo,
2726
+ null, null, { definitionNameOnly: true });
2727
+ if (edit.renamed !== edit.source) {
2728
+ changes.push({
2729
+ file: rel,
2730
+ line,
2731
+ expression: edit.source,
2732
+ suggestion: `Update Go interface-slot definition: ${edit.renamed}`,
2733
+ newExpression: edit.renamed,
2734
+ isDefinition: true,
2735
+ editKind: 'definition',
2736
+ });
2737
+ }
2738
+ }
2739
+ slotMemberDefs.push(member);
2740
+ }
2741
+ }
2742
+
2743
+ // Rust trait slots (fix #296, serde-as_cast-measured): trait
2744
+ // impls carry `traitName` markers, not extends edges — the climb
2745
+ // and descendant walk above cannot see them, so renaming a trait
2746
+ // method left every impl (and the trait declaration, under an
2747
+ // impl pin) behind. Close the slot over the trait's own
2748
+ // declaration member and every indexed impl member implementing
2749
+ // it, with file-identity discipline (two crates may define
2750
+ // same-named traits — a member joins only when its trait
2751
+ // resolves to the pin's trait FILE).
2752
+ const slotTraitNames = new Set();
2753
+ let pinTraitFile = null;
2754
+ if (def.traitName) {
2755
+ slotTraitNames.add(def.traitName);
2756
+ pinTraitFile = index._resolveClassFile(def.traitName, def.file) || null;
2757
+ } else if ((index.symbols.get(def.className) || []).some(d =>
2758
+ d.type === 'trait' && d.file === def.file)) {
2759
+ slotTraitNames.add(def.className);
2760
+ pinTraitFile = def.file;
2086
2761
  }
2762
+ if (slotTraitNames.size > 0 && pinTraitFile) {
2763
+ const traitName = [...slotTraitNames][0];
2764
+ for (const member of index.symbols.get(name) || []) {
2765
+ if (member.file === def.file && member.startLine === def.startLine) continue;
2766
+ if (NON_CALLABLE_TYPES.has(member.type)) continue;
2767
+ let inSlot = false;
2768
+ if (member.traitName === traitName) {
2769
+ inSlot = index._resolveClassFile(traitName, member.file) === pinTraitFile;
2770
+ } else if (member.className === traitName && member.file === pinTraitFile &&
2771
+ (index.symbols.get(traitName) || []).some(d =>
2772
+ d.type === 'trait' && d.file === member.file)) {
2773
+ inSlot = true; // the trait's own declaration member
2774
+ }
2775
+ if (!inSlot) continue;
2776
+ const line = member.nameLine || member.startLine;
2777
+ const rel = member.relativePath || member.file;
2778
+ if (!changes.some(change => change.file === rel && change.line === line)) {
2779
+ const edit = renameIdentifierTokens(index, member.file,
2780
+ line, name, options.renameTo,
2781
+ null, null, { definitionNameOnly: true });
2782
+ if (edit.renamed !== edit.source) {
2783
+ changes.push({
2784
+ file: rel,
2785
+ line,
2786
+ expression: edit.source,
2787
+ suggestion: `Update trait-slot definition: ${edit.renamed}`,
2788
+ newExpression: edit.renamed,
2789
+ isDefinition: true,
2790
+ editKind: 'definition',
2791
+ });
2792
+ }
2793
+ }
2794
+ slotMemberDefs.push(member);
2795
+ }
2796
+ // Macro-generated impls are invisible to the index (the
2797
+ // as_cast_impl! family: the `fn` lives in a macro_rules body,
2798
+ // no impl_item node exists). A definition-shaped ground line
2799
+ // no indexed def claims, sitting inside a macro_rules body
2800
+ // whose token tree names `impl … <Trait>`, implements the
2801
+ // renamed slot — compiler-connected via the impl header, so
2802
+ // the edit is synthesized mechanically.
2803
+ // The sweep already computed the ground set for this exact
2804
+ // name (contractedCallerSweep) — reuse it instead of a second
2805
+ // full-repo text scan (read-only by contract).
2806
+ const macroGround = planGroundSet;
2807
+ const fnRe = new RegExp(`\\bfn\\s+${escapeRegExp(name)}\\b`);
2808
+ const implRe = new RegExp(
2809
+ `\\bimpl\\b[^;{}]{0,160}\\b${escapeRegExp(traitName)}\\b`);
2810
+ for (const [macroFile, lineNos] of macroGround.perFile) {
2811
+ const macroEntry = index.files.get(macroFile);
2812
+ if (!macroEntry || macroEntry.language !== 'rust') continue;
2813
+ const claimed = new Set((index.symbols.get(name) || [])
2814
+ .filter(d => d.file === macroFile)
2815
+ .map(d => d.nameLine || d.startLine));
2816
+ let macroTree = null;
2817
+ let macroNodes = null;
2818
+ for (const lineNo of lineNos) {
2819
+ if (claimed.has(lineNo)) continue;
2820
+ const rel = macroEntry.relativePath || macroFile;
2821
+ if (changes.some(change =>
2822
+ change.file === rel && change.line === lineNo)) continue;
2823
+ const content = index._readFile(macroFile);
2824
+ if (!fnRe.test(content.split('\n')[lineNo - 1] || '')) continue;
2825
+ if (macroTree === null) {
2826
+ const parser = getParser('rust');
2827
+ macroTree = (parser &&
2828
+ (index._getParsedTree?.(macroFile, content, 'rust') ||
2829
+ safeParse(parser, content))) || false;
2830
+ macroNodes = [];
2831
+ if (macroTree) {
2832
+ const stack = [macroTree.rootNode];
2833
+ while (stack.length > 0) {
2834
+ const node = stack.pop();
2835
+ if (node.type === 'macro_definition') {
2836
+ macroNodes.push(node);
2837
+ continue;
2838
+ }
2839
+ for (let i = 0; i < node.namedChildCount; i++) {
2840
+ stack.push(node.namedChild(i));
2841
+ }
2842
+ }
2843
+ }
2844
+ }
2845
+ if (!macroTree || !macroNodes) continue;
2846
+ const mac = macroNodes.find(m =>
2847
+ m.startPosition.row + 1 <= lineNo &&
2848
+ m.endPosition.row + 1 >= lineNo);
2849
+ if (!mac || !implRe.test(mac.text)) continue;
2850
+ const edit = renameIdentifierTokens(index, macroFile,
2851
+ lineNo, name, options.renameTo,
2852
+ null, null, { definitionNameOnly: true });
2853
+ if (edit.renamed === edit.source) continue;
2854
+ changes.push({
2855
+ file: rel,
2856
+ line: lineNo,
2857
+ expression: edit.source,
2858
+ suggestion: `Update macro-generated trait implementation: ${edit.renamed}`,
2859
+ newExpression: edit.renamed,
2860
+ isDefinition: true,
2861
+ editKind: 'definition',
2862
+ });
2863
+ }
2864
+ }
2865
+ }
2866
+
2867
+ // A slot rename must also carry every member's CALL sites — the
2868
+ // pin's sweep answers for the pin only (a TagDict-typed caller
2869
+ // of TagDict.check is a confirmed caller of the SLOT being
2870
+ // renamed, absent from PassList.check's answer). Union the
2871
+ // members' sweeps; their unverified candidates join the visible
2872
+ // band with slot attribution. Bounded — a pathological slot
2873
+ // discloses the cut instead of sweeping forever.
2874
+ const SLOT_SWEEP_CAP = 25;
2875
+ const siteTokenKey = site =>
2876
+ `${site.file}:${site.line}:${Number.isInteger(site.column) ? site.column : '*'}`;
2877
+ const seenSiteTokens = new Set(planCallSites.map(site => siteTokenKey(site)));
2878
+ const slotMemberSites = [];
2879
+ for (const memberDef of slotMemberDefs.slice(0, SLOT_SWEEP_CAP)) {
2880
+ const memberSweep = computePlanCallSites(index, name, memberDef);
2881
+ for (const site of memberSweep.sites) {
2882
+ const key = siteTokenKey(site);
2883
+ if (seenSiteTokens.has(key)) continue;
2884
+ seenSiteTokens.add(key);
2885
+ slotMemberSites.push(site);
2886
+ }
2887
+ // A call through a joined Go interface receiver is bound to
2888
+ // this exact METHOD SLOT even though runtime dispatch cannot
2889
+ // select one concrete body. Promote it for rename purposes
2890
+ // only; caller/context evidence stays honestly unverified.
2891
+ for (const raw of memberSweep.rawUnverified || []) {
2892
+ const exactInterfaceReceiver = goInterfaceSlot &&
2893
+ raw.reason === 'possible-dispatch' &&
2894
+ goInterfaceSlot.interfaceNames.has(raw.dispatchVia);
2895
+ const closedAmbiguity = goSlotCoversMethodAmbiguity(
2896
+ index, name, goInterfaceSlot, raw);
2897
+ if ((!exactInterfaceReceiver && !closedAmbiguity) ||
2898
+ raw.externalContract) continue;
2899
+ const relativePath = raw.relativePath ||
2900
+ index.files.get(raw.file)?.relativePath || raw.file;
2901
+ const key = siteTokenKey({
2902
+ file: relativePath, line: raw.line, column: raw.column,
2903
+ });
2904
+ if (seenSiteTokens.has(key)) continue;
2905
+ let content = raw.content || '';
2906
+ if (!content && raw.file) {
2907
+ try { content = index._getFileLines(raw.file)[raw.line - 1] || ''; }
2908
+ catch { /* unreadable is already disclosed by the account */ }
2909
+ }
2910
+ const analysis = analyzeCallSite(index, {
2911
+ file: raw.file,
2912
+ relativePath,
2913
+ line: raw.line,
2914
+ content,
2915
+ usageType: 'call',
2916
+ receiver: raw.receiver,
2917
+ }, name, 0);
2918
+ seenSiteTokens.add(key);
2919
+ slotMemberSites.push({
2920
+ file: relativePath,
2921
+ absoluteFile: raw.file,
2922
+ line: raw.line,
2923
+ ...(Number.isInteger(raw.column) && { column: raw.column }),
2924
+ expression: content.trim(),
2925
+ args: analysis.args,
2926
+ argCount: analysis.argCount,
2927
+ ...(raw.calledAs && { calledAs: raw.calledAs }),
2928
+ });
2929
+ }
2930
+ for (const site of memberSweep.unverifiedSites) {
2931
+ if (planUnverified.some(existing =>
2932
+ existing.file === site.file &&
2933
+ existing.line === site.line)) continue;
2934
+ planUnverified.push({
2935
+ ...site,
2936
+ slotMember: memberDef.className,
2937
+ });
2938
+ }
2939
+ }
2940
+ if (slotMemberDefs.length > SLOT_SWEEP_CAP) {
2941
+ resolved.warnings.push({
2942
+ message: `Dispatch slot has ${slotMemberDefs.length} member ` +
2943
+ `definitions; call sites were swept for the first ` +
2944
+ `${SLOT_SWEEP_CAP} — review the rest manually.`,
2945
+ });
2946
+ }
2947
+ emitRenameCallSites(slotMemberSites);
2948
+ // A site a slot member's sweep CONFIRMED is a planned edit now —
2949
+ // it no longer belongs in the pin's "may need this change" band.
2950
+ const changedLines = new Set(changes.map(change =>
2951
+ `${change.file}:${change.line}`));
2952
+ const keptUnverified = planUnverified.filter(site =>
2953
+ !changedLines.has(`${site.file}:${site.line}`));
2954
+ planUnverified.length = 0;
2955
+ planUnverified.push(...keptUnverified);
2956
+ planUnverified.sort((a, b) => codeUnitCompare(a.file, b.file) ||
2957
+ a.line - b.line);
2087
2958
  }
2088
2959
 
2089
2960
  // C/C++ declarations and definitions are one compiler symbol. A
@@ -2108,7 +2979,8 @@ function plan(index, name, options = {}) {
2108
2979
  const rel = sibling.relativePath || sibling.file;
2109
2980
  if (changes.some(change => change.file === rel && change.line === line)) continue;
2110
2981
  const edit = renameIdentifierTokens(index, sibling.file,
2111
- line, name, options.renameTo);
2982
+ line, name, options.renameTo,
2983
+ null, null, { definitionNameOnly: true });
2112
2984
  if (edit.renamed === edit.source) continue;
2113
2985
  changes.push({
2114
2986
  file: rel,
@@ -2121,6 +2993,250 @@ function plan(index, name, options = {}) {
2121
2993
  });
2122
2994
  }
2123
2995
  }
2996
+
2997
+ // Reference-position usages are rename edits too (outcome-eval
2998
+ // flask, 2026-08-18): `return decorator`, `callback=handler`,
2999
+ // `cls.method` values. Call syntax flows through the tiered sweep;
3000
+ // references never did — a plan-following rename left them on the
3001
+ // old name and the toolchain rejected the result. Evidence
3002
+ // discipline mirrors the caller engine:
3003
+ // - same-file, non-method pin: nearest-binder containment — the
3004
+ // innermost same-name def whose scope container holds the line
3005
+ // must be the pin (a sibling nested `decorator` keeps its own
3006
+ // references; ties bind nothing);
3007
+ // - same-file, method pin: self/cls/this-received inside the
3008
+ // pin's class range (receiver evidence read from the line);
3009
+ // - cross-file, non-method pin: the #217 import-ownership chase —
3010
+ // 'yes' edits, 'unknown' surfaces needsReview (no synthesized
3011
+ // edit), 'no' skips;
3012
+ // - cross-file method references are receiver-blind here:
3013
+ // call-shaped sites already flow through the sweep and the
3014
+ // unverified band.
3015
+ // Shadow discipline (the #215/#203 concern — text rows carry no
3016
+ // localShadow flag): a module-scope pin auto-edits only rows that
3017
+ // sit at MODULE scope themselves (`TABLE = {"h": helper}`,
3018
+ // `module.exports = { helper }`, decorator argument lists). A row
3019
+ // inside some function body may reference a shadowing local
3020
+ // (`const job = job2`), and argument-position references inside
3021
+ // functions are the parser's #221 records — the sweep's domain —
3022
+ // so those rows surface needsReview instead of a synthesized edit.
3023
+ // A NESTED pin's own container is exempt: inside it the pin IS the
3024
+ // binder (`return decorator`).
3025
+ {
3026
+ const fileSymbols = index.files.get(def.file)?.symbols || [];
3027
+ const scopeKinds = new Set(['function', 'method', 'constructor',
3028
+ 'private', 'get', 'set', 'property', 'classmethod', 'special']);
3029
+ const rangeOf = symbol => ({
3030
+ start: symbol.startLine,
3031
+ end: symbol.endLine || symbol.startLine,
3032
+ });
3033
+ const containerOf = (symbol) => {
3034
+ const target = rangeOf(symbol);
3035
+ let best = null;
3036
+ for (const candidate of fileSymbols) {
3037
+ if (candidate === symbol || !scopeKinds.has(candidate.type)) continue;
3038
+ const range = rangeOf(candidate);
3039
+ if (!(range.start <= target.start && range.end >= target.end)) continue;
3040
+ if (range.start === target.start && range.end === target.end) continue;
3041
+ if (!best || (range.end - range.start) < (best.end - best.start)) {
3042
+ best = range;
3043
+ }
3044
+ }
3045
+ return best; // null = module scope
3046
+ };
3047
+ const binders = definitions
3048
+ .filter(candidate => candidate.file === def.file &&
3049
+ !NON_CALLABLE_TYPES.has(candidate.type))
3050
+ .map(candidate => ({ def: candidate, container: containerOf(candidate) }));
3051
+ const classKinds = new Set(['class', 'struct', 'interface', 'trait',
3052
+ 'record', 'enum', 'namespace']);
3053
+ const pinClassRange = def.className
3054
+ ? fileSymbols.find(symbol => symbol.name === def.className &&
3055
+ classKinds.has(symbol.type) &&
3056
+ symbol.startLine <= def.startLine &&
3057
+ (symbol.endLine || symbol.startLine) >= (def.endLine || def.startLine))
3058
+ : null;
3059
+ const selfReceived = new RegExp(
3060
+ `(?:^|[^A-Za-z0-9_$.])(?:self|cls|this)\\s*\\.\\s*` +
3061
+ `${escapeRegExp(name)}(?![A-Za-z0-9_$])`);
3062
+ const unverifiedLines = new Set(planUnverified.map(site =>
3063
+ `${site.file}:${site.line}`));
3064
+ const insideFunctionLike = (filePath, line) => {
3065
+ const symbols = index.files.get(filePath)?.symbols || [];
3066
+ return symbols.some(symbol => scopeKinds.has(symbol.type) &&
3067
+ symbol.startLine <= line &&
3068
+ (symbol.endLine || symbol.startLine) >= line);
3069
+ };
3070
+ const pinContainer = containerOf(def);
3071
+ for (const ref of usages) {
3072
+ if (ref.usageType !== 'reference' || ref.isDefinition) continue;
3073
+ const rel = ref.relativePath || ref.file;
3074
+ if (changes.some(change =>
3075
+ change.file === rel && change.line === ref.line)) continue;
3076
+ if (unverifiedLines.has(`${rel}:${ref.line}`)) continue;
3077
+ let verdict = null;
3078
+ if (ref.file === def.file) {
3079
+ if (def.className) {
3080
+ const lineText = ref.content ||
3081
+ index.getLineContent(def.file, ref.line) || '';
3082
+ if (pinClassRange && ref.line >= pinClassRange.startLine &&
3083
+ ref.line <= (pinClassRange.endLine || Infinity) &&
3084
+ selfReceived.test(lineText)) {
3085
+ verdict = 'edit';
3086
+ }
3087
+ } else {
3088
+ let winner = null;
3089
+ let ambiguous = false;
3090
+ for (const binder of binders) {
3091
+ const contains = !binder.container ||
3092
+ (binder.container.start <= ref.line &&
3093
+ binder.container.end >= ref.line);
3094
+ if (!contains) continue;
3095
+ const size = binder.container
3096
+ ? binder.container.end - binder.container.start
3097
+ : Infinity;
3098
+ if (!winner || size < winner.size) {
3099
+ winner = { def: binder.def, size };
3100
+ ambiguous = false;
3101
+ } else if (size === winner.size &&
3102
+ binder.def !== winner.def) {
3103
+ ambiguous = true;
3104
+ }
3105
+ }
3106
+ if (winner && !ambiguous && winner.def === def) {
3107
+ if (pinContainer) {
3108
+ verdict = 'edit'; // nested pin binds its container
3109
+ } else if (!insideFunctionLike(def.file, ref.line)) {
3110
+ verdict = 'edit'; // module-scope row, module pin
3111
+ } else {
3112
+ verdict = 'review'; // possible local shadow
3113
+ }
3114
+ }
3115
+ }
3116
+ } else if (!def.className) {
3117
+ // Module-attribute references carry stronger evidence
3118
+ // than a bare name: `import requests; requests.put` binds
3119
+ // through requests' export chain even inside a function.
3120
+ // Parser-side local-shadow evidence is a hard guard — a
3121
+ // parameter/assignment named requests defeats the import.
3122
+ const moduleOwnership = ref.receiver &&
3123
+ !ref.receiverLocalBinding
3124
+ ? _moduleAttributeBindingReaches(
3125
+ index, ref.file, ref.receiver, name,
3126
+ renameTargetFiles)
3127
+ : null;
3128
+ if (moduleOwnership === 'yes') {
3129
+ verdict = 'edit';
3130
+ } else if (moduleOwnership === 'unknown') {
3131
+ verdict = 'review';
3132
+ } else if (!ref.receiver) {
3133
+ const ownership = _nameBindingReaches(
3134
+ index, ref.file, name, renameTargetFiles);
3135
+ if (ownership === 'yes' &&
3136
+ !insideFunctionLike(ref.file, ref.line)) {
3137
+ verdict = 'edit';
3138
+ } else if (ownership !== 'no') {
3139
+ verdict = 'review';
3140
+ }
3141
+ }
3142
+ }
3143
+ if (!verdict) continue;
3144
+ if (verdict === 'edit') {
3145
+ const edit = renameIdentifierTokens(index, ref.file,
3146
+ ref.line, name, options.renameTo,
3147
+ Number.isInteger(ref.column) ? [ref.column] : null);
3148
+ if (edit.renamed === edit.source) continue;
3149
+ changes.push({
3150
+ file: rel,
3151
+ line: ref.line,
3152
+ expression: edit.source,
3153
+ suggestion: `Update reference: ${edit.renamed}`,
3154
+ newExpression: edit.renamed,
3155
+ editKind: 'reference',
3156
+ });
3157
+ } else {
3158
+ changes.push({
3159
+ file: rel,
3160
+ line: ref.line,
3161
+ expression: (ref.content || '').trim(),
3162
+ suggestion: `Verify this reference resolves to ` +
3163
+ `${name} at ${def.relativePath || def.file}:` +
3164
+ `${def.startLine} before renaming`,
3165
+ needsReview: true,
3166
+ editKind: 'reference',
3167
+ });
3168
+ }
3169
+ }
3170
+ }
3171
+
3172
+ // Property/getter/setter renames must cover their normal consumption
3173
+ // form: attribute reads and writes. Reuse impact's receiver-evidence
3174
+ // query so a typed field is edited mechanically and an unresolved
3175
+ // receiver is surfaced for review instead of silently omitted.
3176
+ const accessorReferences = findAccessorReferences(index, name, def, {
3177
+ includeTests: true,
3178
+ });
3179
+ if (accessorReferences) {
3180
+ const confirmedByLine = new Map();
3181
+ for (const ref of accessorReferences.confirmed) {
3182
+ const key = `${ref.absoluteFile}\0${ref.line}`;
3183
+ if (!confirmedByLine.has(key)) confirmedByLine.set(key, []);
3184
+ confirmedByLine.get(key).push(ref);
3185
+ }
3186
+ for (const refs of confirmedByLine.values()) {
3187
+ const ref = refs[0];
3188
+ const columns = refs.map(item => item.column)
3189
+ .filter(Number.isInteger);
3190
+ const edit = renameIdentifierTokens(index, ref.absoluteFile,
3191
+ ref.line, name, options.renameTo,
3192
+ columns.length === refs.length ? columns : null);
3193
+ if (edit.renamed === edit.source) continue;
3194
+ const concrete = {
3195
+ file: ref.file,
3196
+ line: ref.line,
3197
+ expression: edit.source,
3198
+ suggestion: `Update property access: ${edit.renamed}`,
3199
+ newExpression: edit.renamed,
3200
+ editKind: 'reference',
3201
+ };
3202
+ const existing = changes.find(change =>
3203
+ change.file === ref.file && change.line === ref.line &&
3204
+ !change.needsReview);
3205
+ if (existing) Object.assign(existing, concrete);
3206
+ else changes.push(concrete);
3207
+ }
3208
+ for (const ref of accessorReferences.unverified) {
3209
+ changes.push({
3210
+ file: ref.file,
3211
+ line: ref.line,
3212
+ expression: ref.expression,
3213
+ suggestion: `Verify this property access resolves to ${name} on ` +
3214
+ `${accessorReferences.owner} before renaming`,
3215
+ needsReview: true,
3216
+ editKind: 'reference',
3217
+ });
3218
+ }
3219
+ }
3220
+
3221
+ // String/comment occurrences in indexed source can encode guards,
3222
+ // reflection keys, protocol names, snapshots, or documentation. AST
3223
+ // identifier replacement must never rewrite them automatically, but a
3224
+ // complete plan must list them as explicit review work.
3225
+ for (const textRef of usages.filter(usage => usage.usageType === 'text')) {
3226
+ const rel = textRef.relativePath || textRef.file;
3227
+ if (reviewItems.some(item =>
3228
+ item.file === rel && item.line === textRef.line)) continue;
3229
+ reviewItems.push({
3230
+ file: rel,
3231
+ line: textRef.line,
3232
+ expression: (textRef.content || '').trim(),
3233
+ suggestion: `Review comment/string dependency on "${name}"; ` +
3234
+ 'rename manually only if its contract changes',
3235
+ needsReview: true,
3236
+ textDependency: true,
3237
+ editKind: 'text-reference',
3238
+ });
3239
+ }
2124
3240
  }
2125
3241
 
2126
3242
  // Every operation changes the selected declaration. Historically `plan`
@@ -2138,7 +3254,8 @@ function plan(index, name, options = {}) {
2138
3254
  existingDefinitionLine.editKind = 'definition';
2139
3255
  if (options.renameTo) {
2140
3256
  const renamed = renameIdentifierTokens(index, def.file,
2141
- definitionLine, name, options.renameTo).renamed;
3257
+ definitionLine, name, options.renameTo,
3258
+ null, null, { definitionNameOnly: true }).renamed;
2142
3259
  existingDefinitionLine.newExpression = renamed;
2143
3260
  existingDefinitionLine.suggestion = `Update definition: ${renamed}`;
2144
3261
  }
@@ -2152,7 +3269,8 @@ function plan(index, name, options = {}) {
2152
3269
  };
2153
3270
  if (options.renameTo) {
2154
3271
  const renamed = renameIdentifierTokens(index, def.file,
2155
- definitionLine, name, options.renameTo).renamed;
3272
+ definitionLine, name, options.renameTo,
3273
+ null, null, { definitionNameOnly: true }).renamed;
2156
3274
  definitionChange.newExpression = renamed;
2157
3275
  definitionChange.suggestion = `Update definition: ${renamed}`;
2158
3276
  } else {
@@ -2174,7 +3292,10 @@ function plan(index, name, options = {}) {
2174
3292
  !change.editKind).length,
2175
3293
  imports: changes.filter(change => change.editKind === 'import').length,
2176
3294
  exports: changes.filter(change => change.editKind === 'export').length,
2177
- reviewRequired: changes.filter(change => change.needsReview).length,
3295
+ references: changes.filter(change => change.editKind === 'reference').length,
3296
+ textReferences: reviewItems.length,
3297
+ reviewRequired: changes.filter(change => change.needsReview).length +
3298
+ reviewItems.length,
2178
3299
  };
2179
3300
 
2180
3301
  return {
@@ -2195,9 +3316,11 @@ function plan(index, name, options = {}) {
2195
3316
  params: newParams.map(p => formatPlanParamName(p)).filter(Boolean)
2196
3317
  },
2197
3318
  totalChanges: changes.length,
2198
- filesAffected: new Set(changes.map(c => c.file)).size,
3319
+ filesAffected: new Set([...changes, ...reviewItems].map(c => c.file)).size,
2199
3320
  changeSummary,
2200
3321
  changes,
3322
+ reviewItems,
3323
+ totalReviewItems: reviewItems.length,
2201
3324
  ...(unchangedSites > 0 && { unchangedSites }),
2202
3325
  // v4 tiered contract: sites that MAY also need this change but lack
2203
3326
  // binding/receiver evidence — review manually before refactoring.
@@ -2205,6 +3328,13 @@ function plan(index, name, options = {}) {
2205
3328
  unverifiedSites: planUnverified,
2206
3329
  account: planAccount,
2207
3330
  scopeWarning: impactScopeWarning,
3331
+ ...(options.renameTo && {
3332
+ outsideIndexedSource: {
3333
+ scope: 'indexed-source-files',
3334
+ excluded: ['documentation', 'configuration', 'generated files', 'unsupported languages'],
3335
+ action: `Search non-source project files for the exact spelling "${name}" before applying the rename.`,
3336
+ },
3337
+ }),
2208
3338
  ...(resolved.warnings.length > 0 && { warnings: resolved.warnings }),
2209
3339
  };
2210
3340
  } finally { index._endOp(); }