ucn 5.0.6 → 5.2.0

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