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/.claude/skills/ucn/SKILL.md +26 -5
- package/.claude/skills/ucn/references/commands.md +5 -5
- package/README.md +42 -15
- package/core/accessors.js +183 -0
- package/core/analysis.js +46 -0
- package/core/ast-analysis.js +104 -0
- package/core/cache.js +32 -1
- package/core/callers.js +1255 -64
- package/core/command-contracts.js +13 -13
- package/core/deadcode.js +41 -2
- package/core/execute.js +4 -1
- package/core/graph-build.js +6 -0
- package/core/graph.js +72 -5
- package/core/index-ir.js +13 -2
- package/core/ir.js +5 -3
- package/core/output/analysis.js +30 -1
- package/core/output/graph.js +28 -8
- package/core/output/public.js +4 -0
- package/core/output/refactoring.js +31 -2
- package/core/output/reporting.js +7 -0
- package/core/project.js +32 -0
- package/core/search.js +9 -0
- package/core/verify.js +1168 -38
- package/languages/c-family.js +239 -26
- package/languages/csharp.js +40 -4
- package/languages/go.js +473 -71
- package/languages/javascript.js +86 -4
- package/languages/python.js +392 -101
- package/languages/rust.js +87 -4
- package/languages/utils.js +11 -0
- package/mcp/server.js +99 -103
- package/mcp/stdio-server.js +296 -0
- package/package.json +10 -8
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 {
|
|
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)
|
|
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 {
|
|
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
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
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:
|
|
2268
|
+
calledAs: incoming.calledAs,
|
|
2269
|
+
change: null,
|
|
1879
2270
|
};
|
|
1880
|
-
if (
|
|
1881
|
-
|
|
1882
|
-
if (Number.isInteger(
|
|
1883
|
-
else
|
|
1884
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
2357
|
+
const {
|
|
2358
|
+
_nameBindingReaches,
|
|
2359
|
+
_moduleAttributeBindingReaches,
|
|
2360
|
+
} = require('./callers');
|
|
1937
2361
|
const renameTargetFiles = new Set([def.file]);
|
|
1938
|
-
|
|
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:
|
|
2048
|
-
const visited = new Set([identityKey(
|
|
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
|
|
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
|
|
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
|
-
|
|
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(); }
|