ucn 4.2.3 → 5.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/.claude/skills/ucn/SKILL.md +89 -77
  2. package/.claude/skills/ucn/references/commands.md +62 -68
  3. package/.claude/skills/ucn/references/trust-contract.md +31 -6
  4. package/README.md +438 -305
  5. package/assets/demo.svg +31 -0
  6. package/cli/index.js +430 -1385
  7. package/core/account.js +144 -34
  8. package/core/analysis.js +182 -72
  9. package/core/ast-analysis.js +279 -0
  10. package/core/bridge.js +205 -24
  11. package/core/brief.js +27 -58
  12. package/core/build-worker.js +21 -140
  13. package/core/cache.js +513 -11
  14. package/core/callers.js +4920 -456
  15. package/core/check.js +13 -4
  16. package/core/command-contracts.js +402 -0
  17. package/core/compilation-database.js +276 -0
  18. package/core/confidence.js +4 -1
  19. package/core/deadcode.js +397 -19
  20. package/core/discovery.js +359 -46
  21. package/core/entrypoints.js +195 -41
  22. package/core/execute.js +887 -81
  23. package/core/graph-build.js +162 -7
  24. package/core/graph.js +53 -77
  25. package/core/imports.js +65 -6
  26. package/core/index-ir.js +138 -0
  27. package/core/ir.js +195 -0
  28. package/core/output/analysis.js +212 -22
  29. package/core/output/brief.js +23 -0
  30. package/core/output/check.js +4 -0
  31. package/core/output/doctor.js +37 -6
  32. package/core/output/endpoints.js +5 -2
  33. package/core/output/extraction.js +24 -12
  34. package/core/output/find.js +141 -36
  35. package/core/output/graph.js +11 -5
  36. package/core/output/public.js +462 -0
  37. package/core/output/refactoring.js +42 -10
  38. package/core/output/reporting.js +97 -20
  39. package/core/output/search.js +24 -16
  40. package/core/output/shared.js +22 -1
  41. package/core/output/tracing.js +30 -15
  42. package/core/output-budget.js +295 -0
  43. package/core/output.js +1 -0
  44. package/core/parallel-build.js +44 -11
  45. package/core/parser.js +3 -3
  46. package/core/project.js +384 -187
  47. package/core/public-command.js +47 -0
  48. package/core/registry.js +247 -117
  49. package/core/reporting.js +312 -290
  50. package/core/search.js +317 -185
  51. package/core/semantic-provider.js +110 -0
  52. package/core/stacktrace.js +25 -0
  53. package/core/tracing.js +101 -51
  54. package/core/trust-matrix.js +19 -40
  55. package/core/verify.js +534 -37
  56. package/languages/adapter.js +218 -0
  57. package/languages/c-family.js +2791 -0
  58. package/languages/c.js +3 -0
  59. package/languages/cpp.js +3 -0
  60. package/languages/csharp.js +1402 -0
  61. package/languages/go.js +60 -21
  62. package/languages/html.js +2 -2
  63. package/languages/index.js +85 -7
  64. package/languages/java.js +396 -13
  65. package/languages/javascript.js +199 -19
  66. package/languages/python.js +964 -22
  67. package/languages/rust.js +1317 -152
  68. package/languages/utils.js +40 -3
  69. package/mcp/server.js +254 -636
  70. package/package.json +39 -22
  71. package/eslint.config.js +0 -43
  72. package/jsconfig.json +0 -10
package/core/verify.js CHANGED
@@ -5,9 +5,89 @@
5
5
  * as the first argument instead of using `this`.
6
6
  */
7
7
 
8
- const { detectLanguage, getParser, getLanguageModule, safeParse, langTraits } = require('../languages');
8
+ const { detectLanguage, getParser, getLanguageAdapter, safeParse, langTraits } = require('../languages');
9
9
  const { escapeRegExp, codeUnitCompare } = require('./shared');
10
10
 
11
+ function codeUnitColumnForByteColumn(line, byteColumn) {
12
+ if (!Number.isInteger(byteColumn) || byteColumn < 0) return null;
13
+ let bytes = 0;
14
+ for (let i = 0; i <= line.length; i++) {
15
+ if (bytes === byteColumn) return i;
16
+ if (i === line.length) break;
17
+ const cp = line.codePointAt(i);
18
+ const ch = String.fromCodePoint(cp);
19
+ bytes += Buffer.byteLength(ch);
20
+ if (ch.length === 2) i++;
21
+ }
22
+ return null;
23
+ }
24
+
25
+ /** Replace only AST identifier tokens on one source line. */
26
+ function renameIdentifierTokens(index, filePath, lineNumber, oldName, newName,
27
+ preferredByteColumns = null, expectedCallCount = null) {
28
+ const absolute = filePath && require('path').isAbsolute(filePath)
29
+ ? filePath : require('path').join(index.root, filePath || '');
30
+ const content = index._readFile(absolute);
31
+ const sourceLine = content.split('\n')[lineNumber - 1] || '';
32
+ let byteColumns = Array.isArray(preferredByteColumns)
33
+ ? preferredByteColumns.filter(Number.isInteger) : [];
34
+
35
+ if (byteColumns.length === 0) {
36
+ const language = index.files.get(absolute)?.language ||
37
+ detectLanguage(absolute, index.root);
38
+ const parser = language && getParser(language);
39
+ const tree = parser && (index._getParsedTree?.(absolute, content, language) ||
40
+ safeParse(parser, content));
41
+ if (tree) {
42
+ const targetRow = lineNumber - 1;
43
+ const stack = [tree.rootNode];
44
+ while (stack.length > 0) {
45
+ const node = stack.pop();
46
+ if (node.endPosition.row < targetRow ||
47
+ node.startPosition.row > targetRow) continue;
48
+ if (node.startPosition.row === targetRow && node.text === oldName &&
49
+ /identifier(?:_pattern)?$/.test(node.type)) {
50
+ let eligible = preferredByteColumns == null;
51
+ if (!eligible) {
52
+ const callTypes = new Set([
53
+ 'call', 'call_expression', 'method_invocation',
54
+ 'invocation_expression', 'method_call_expression',
55
+ ]);
56
+ for (let parent = node.parent, depth = 0;
57
+ parent && depth < 5; parent = parent.parent, depth++) {
58
+ if (!callTypes.has(parent.type)) continue;
59
+ const target = parent.childForFieldName('function') ||
60
+ parent.childForFieldName('name') ||
61
+ parent.childForFieldName('method') ||
62
+ parent.namedChild(0);
63
+ if (target && node.startIndex >= target.startIndex &&
64
+ node.endIndex <= target.endIndex) eligible = true;
65
+ break;
66
+ }
67
+ }
68
+ if (eligible) byteColumns.push(node.startPosition.column);
69
+ continue;
70
+ }
71
+ stack.push(...(node.namedChildren || []));
72
+ }
73
+ }
74
+ }
75
+
76
+ const columns = [...new Set(byteColumns
77
+ .map(column => codeUnitColumnForByteColumn(sourceLine, column))
78
+ .filter(Number.isInteger))].sort((a, b) => b - a);
79
+ if (expectedCallCount != null && columns.length !== expectedCallCount) {
80
+ return { source: sourceLine.trim(), renamed: sourceLine.trim(), count: 0 };
81
+ }
82
+ let renamed = sourceLine;
83
+ for (const column of columns) {
84
+ if (renamed.slice(column, column + oldName.length) !== oldName) continue;
85
+ renamed = renamed.slice(0, column) + newName +
86
+ renamed.slice(column + oldName.length);
87
+ }
88
+ return { source: sourceLine.trim(), renamed: renamed.trim(), count: columns.length };
89
+ }
90
+
11
91
  // ============================================================================
12
92
  // CALL-SITE CLASSIFICATION (Feature A)
13
93
  // ============================================================================
@@ -169,7 +249,8 @@ function _collectCallNodes(node, callTypes, targetRow, funcName, limit, out = []
169
249
  // JS/TS constructor: new ClassName(args) — class is in 'constructor'
170
250
  // field (fix #230: these sites used to fall out as "Could not
171
251
  // parse call arguments" and every class verify went uncertain).
172
- const ctorNode = node.childForFieldName('constructor');
252
+ const ctorNode = node.childForFieldName('constructor') ||
253
+ node.childForFieldName('type');
173
254
  if (ctorNode) {
174
255
  const typeName = ctorNode.text.replace(/<.*>$/, '').split('.').pop();
175
256
  if (typeName === funcName) out.push(node);
@@ -183,12 +264,30 @@ function _collectCallNodes(node, callTypes, targetRow, funcName, limit, out = []
183
264
  funcNode = funcNode.childForFieldName('function') || funcNode.namedChild(0);
184
265
  }
185
266
  if (funcNode) {
186
- const funcText = funcNode.type === 'member_expression' || funcNode.type === 'selector_expression' || funcNode.type === 'field_expression' || funcNode.type === 'attribute'
187
- ? (funcNode.childForFieldName('property') || funcNode.childForFieldName('field') || funcNode.childForFieldName('attribute') || funcNode.namedChild(funcNode.namedChildCount - 1))?.text
188
- : funcNode.type === 'scoped_identifier'
267
+ const memberProperty = funcNode.type === 'member_expression'
268
+ ? funcNode.childForFieldName('property')
269
+ : null;
270
+ const indirectKind = memberProperty &&
271
+ ['call', 'apply', 'bind'].includes(memberProperty.text)
272
+ ? memberProperty.text
273
+ : null;
274
+ const indirectObject = indirectKind
275
+ ? funcNode.childForFieldName('object')
276
+ : null;
277
+ const indirectTarget = indirectObject?.type === 'member_expression'
278
+ ? indirectObject.childForFieldName('property')?.text
279
+ : indirectObject?.text;
280
+ const funcText = funcNode.type === 'member_expression' ||
281
+ funcNode.type === 'member_access_expression' ||
282
+ funcNode.type === 'selector_expression' ||
283
+ funcNode.type === 'field_expression' || funcNode.type === 'attribute'
284
+ ? (funcNode.childForFieldName('property') || funcNode.childForFieldName('name') ||
285
+ funcNode.childForFieldName('field') || funcNode.childForFieldName('attribute') ||
286
+ funcNode.namedChild(funcNode.namedChildCount - 1))?.text
287
+ : funcNode.type === 'scoped_identifier' || funcNode.type === 'qualified_identifier'
189
288
  ? (funcNode.childForFieldName('name') || funcNode.namedChild(funcNode.namedChildCount - 1))?.text
190
289
  : funcNode.text;
191
- if (funcText === funcName) out.push(node);
290
+ if (funcText === funcName || indirectTarget === funcName) out.push(node);
192
291
  }
193
292
  }
194
293
  if (out.length >= limit) return out;
@@ -474,7 +573,7 @@ function extractArrowTypesFromVarDecl(index, def) {
474
573
  * @returns {Array<Array<object>>|null}
475
574
  */
476
575
  function _constructorParamLists(index, def, lang) {
477
- if (!def || !def.file || !['class', 'enum', 'record'].includes(def.type)) return null;
576
+ if (!def || !def.file || !['class', 'struct', 'enum', 'record'].includes(def.type)) return null;
478
577
  const lists = [];
479
578
  const endLine = def.endLine != null ? def.endLine : Infinity;
480
579
  const inRange = (d) => d.file === def.file &&
@@ -652,10 +751,13 @@ function computePlanCallSites(index, name, def) {
652
751
  const analysis = analyzeCallSite(index, call, name, occurrence);
653
752
  sites.push({
654
753
  file: call.relativePath,
754
+ absoluteFile: call.file,
655
755
  line: call.line,
756
+ ...(Number.isInteger(c.column) && { column: c.column }),
656
757
  expression: (call.content || '').trim(),
657
758
  args: analysis.args,
658
759
  argCount: analysis.argCount,
760
+ ...(c.calledAs && { calledAs: c.calledAs }),
659
761
  });
660
762
  }
661
763
  clearTreeCache(index);
@@ -711,7 +813,7 @@ function analyzeCallSite(index, call, funcName, occurrence = 0) {
711
813
  const content = index._readFile(call.file);
712
814
  // HTML files need special handling: parse script blocks as JS
713
815
  if (language === 'html') {
714
- const htmlModule = getLanguageModule('html');
816
+ const htmlModule = getLanguageAdapter('html');
715
817
  const htmlParser = getParser('html');
716
818
  const jsParser = getParser('javascript');
717
819
  if (!htmlParser || !jsParser) return { args: null, argCount: 0 };
@@ -731,7 +833,7 @@ function analyzeCallSite(index, call, funcName, occurrence = 0) {
731
833
 
732
834
  // Call node types vary by language
733
835
  const callTypes = new Set(['call_expression', 'call', 'method_invocation',
734
- 'object_creation_expression', 'new_expression']);
836
+ 'invocation_expression', 'object_creation_expression', 'new_expression']);
735
837
  const targetRow = call.line - 1; // tree-sitter is 0-indexed
736
838
 
737
839
  // Find the call expression at the target line matching funcName
@@ -765,9 +867,56 @@ function analyzeCallSite(index, call, funcName, occurrence = 0) {
765
867
  const argsNode = callNode.childForFieldName('arguments');
766
868
  if (!argsNode) return { args: [], argCount: 0, isMethodCall, ...ctx };
767
869
 
768
- const args = [];
870
+ let args = [];
769
871
  for (let i = 0; i < argsNode.namedChildCount; i++) {
770
- args.push(argsNode.namedChild(i).text.trim());
872
+ const argNode = argsNode.namedChild(i);
873
+ if (argNode.type.includes('comment')) continue;
874
+ args.push(argNode.text.trim());
875
+ }
876
+
877
+ // Function.prototype indirection has a precise, AST-visible argument
878
+ // mapping. `fn.call(thisArg, a, b)` invokes fn(a, b). `fn.apply`
879
+ // is countable only when its argument array is a literal. `bind`
880
+ // creates a partially applied function rather than invoking it, so it
881
+ // remains explicitly uncertain instead of looking like a parser bug.
882
+ let indirectKind = null;
883
+ if (language === 'javascript' || language === 'typescript' ||
884
+ language === 'tsx' || language === 'html') {
885
+ const property = funcNode?.type === 'member_expression'
886
+ ? funcNode.childForFieldName('property')?.text
887
+ : null;
888
+ if (['call', 'apply', 'bind'].includes(property)) indirectKind = property;
889
+ }
890
+ if (indirectKind === 'bind') {
891
+ return {
892
+ args: null,
893
+ argCount: 0,
894
+ indirectKind,
895
+ uncertainReason: 'Function.bind creates a partial application; final invocation arguments are not known here',
896
+ isMethodCall,
897
+ ...ctx,
898
+ };
899
+ }
900
+ if (indirectKind === 'call') {
901
+ args = args.slice(1);
902
+ } else if (indirectKind === 'apply') {
903
+ const arrayArg = argsNode.namedChild(1);
904
+ if (!arrayArg || arrayArg.type !== 'array') {
905
+ return {
906
+ args: null,
907
+ argCount: 0,
908
+ indirectKind,
909
+ uncertainReason: 'Function.apply argument list is not a static array literal',
910
+ isMethodCall,
911
+ ...ctx,
912
+ };
913
+ }
914
+ args = [];
915
+ for (let i = 0; i < arrayArg.namedChildCount; i++) {
916
+ const argNode = arrayArg.namedChild(i);
917
+ if (argNode.type.includes('comment')) continue;
918
+ args.push(argNode.text.trim());
919
+ }
771
920
  }
772
921
 
773
922
  return {
@@ -776,6 +925,7 @@ function analyzeCallSite(index, call, funcName, occurrence = 0) {
776
925
  hasSpread: args.some(a => a.startsWith('...')),
777
926
  hasVariable: args.some(a => /^[a-zA-Z_]\w*$/.test(a)),
778
927
  isMethodCall,
928
+ ...(indirectKind && { indirectKind }),
779
929
  ...ctx,
780
930
  };
781
931
  } catch (e) {
@@ -809,7 +959,7 @@ function analyzeCallShape(index, filePath, lineNum, funcName) {
809
959
  if (!tree) {
810
960
  const content = index._readFile(filePath);
811
961
  if (language === 'html') {
812
- const htmlModule = getLanguageModule('html');
962
+ const htmlModule = getLanguageAdapter('html');
813
963
  const htmlParser = getParser('html');
814
964
  const jsParser = getParser('javascript');
815
965
  if (!htmlParser || !jsParser) return null;
@@ -827,7 +977,8 @@ function analyzeCallShape(index, filePath, lineNum, funcName) {
827
977
  index._treeCache.set(filePath, tree);
828
978
  }
829
979
 
830
- const callTypes = new Set(['call_expression', 'call', 'method_invocation', 'object_creation_expression']);
980
+ const callTypes = new Set(['call_expression', 'call', 'method_invocation',
981
+ 'invocation_expression', 'object_creation_expression', 'new_expression']);
831
982
  const callNode = findCallNode(tree.rootNode, callTypes, lineNum - 1, funcName);
832
983
  if (!callNode) return null;
833
984
 
@@ -840,6 +991,7 @@ function analyzeCallShape(index, filePath, lineNum, funcName) {
840
991
  const argTexts = [];
841
992
  for (let i = 0; i < argsNode.namedChildCount; i++) {
842
993
  const argNode = argsNode.namedChild(i);
994
+ if (argNode.type.includes('comment')) continue;
843
995
  argKinds.push(classifyArgNode(argNode));
844
996
  argTexts.push(argNode.text.trim());
845
997
  }
@@ -987,7 +1139,7 @@ function identifyCallPatterns(callSites, funcName) {
987
1139
  function verify(index, name, options = {}) {
988
1140
  index._beginOp();
989
1141
  try {
990
- const { def } = index.resolveSymbol(name, { file: options.file, className: options.className, line: options.line });
1142
+ const { def, warnings } = index.resolveSymbol(name, { file: options.file, className: options.className, line: options.line });
991
1143
  if (!def) {
992
1144
  return { found: false, function: name };
993
1145
  }
@@ -1008,7 +1160,17 @@ function verify(index, name, options = {}) {
1008
1160
  const selfParams = langTraits(lang)?.selfParam;
1009
1161
  const stripSelf = (list) => (selfParams && list.length > 0 && list[0] && selfParams.includes(list[0].name))
1010
1162
  ? list.slice(1) : list;
1163
+ let callableIdentityParams = null;
1164
+ if (!ctorParamLists && ['c', 'cpp'].includes(lang)) {
1165
+ const { _closeCallableIdentityGroup } = require('./callers');
1166
+ const family = _closeCallableIdentityGroup(
1167
+ index, [def], index.symbols.get(name) || [def]);
1168
+ callableIdentityParams = family
1169
+ .filter(member => Array.isArray(member.paramsStructured))
1170
+ .map(member => member.paramsStructured);
1171
+ }
1011
1172
  const rawParamLists = ctorParamLists ||
1173
+ (callableIdentityParams?.length ? callableIdentityParams : null) ||
1012
1174
  [(arrowTypes?.paramsStructured) || def.paramsStructured || []];
1013
1175
  const params = stripSelf(rawParamLists[0]);
1014
1176
  const arities = rawParamLists.map(l => {
@@ -1039,6 +1201,11 @@ function verify(index, name, options = {}) {
1039
1201
  content: c.content,
1040
1202
  usageType: 'call',
1041
1203
  receiver: c.receiver,
1204
+ // Preserve receiver identity through the usage-shaped adapter. Go
1205
+ // permits a local value to have the same spelling as its type; only
1206
+ // a type-qualified call is a method expression with an explicit
1207
+ // receiver argument.
1208
+ receiverType: c.receiverType,
1042
1209
  callerFile: c.callerFile,
1043
1210
  callerStartLine: c.callerStartLine,
1044
1211
  }));
@@ -1081,7 +1248,7 @@ function verify(index, name, options = {}) {
1081
1248
  file: call.relativePath,
1082
1249
  line: call.line,
1083
1250
  expression: call.content.trim(),
1084
- reason: 'Could not parse call arguments',
1251
+ reason: analysis.uncertainReason || 'Could not parse call arguments',
1085
1252
  patterns: patternFlagsFrom(analysis),
1086
1253
  ...carry,
1087
1254
  });
@@ -1110,7 +1277,8 @@ function verify(index, name, options = {}) {
1110
1277
  const targetTypeName = def.className || (def.receiver || '').replace(/^\*/, '');
1111
1278
  if (targetTypeName && call.receiver === targetTypeName && argCount > 0) {
1112
1279
  const qualStyle = langTraits(lang)?.typeQualifiedCallStyle;
1113
- if ((qualStyle === 'method-expr' && def.receiver) ||
1280
+ if ((qualStyle === 'method-expr' && def.receiver &&
1281
+ !call.receiverType) ||
1114
1282
  (qualStyle === 'path' && def.isMethod)) {
1115
1283
  argCount -= 1;
1116
1284
  }
@@ -1266,7 +1434,8 @@ function verify(index, name, options = {}) {
1266
1434
  unverifiedSites: sweepUnverified.map(unverifiedSiteShape),
1267
1435
  account,
1268
1436
  patterns: patternsAgg,
1269
- scopeWarning
1437
+ scopeWarning,
1438
+ ...(warnings.length > 0 && { warnings }),
1270
1439
  };
1271
1440
  } finally { index._endOp(); }
1272
1441
  }
@@ -1278,6 +1447,44 @@ function verify(index, name, options = {}) {
1278
1447
  * @param {object} options - { addParam, removeParam, renameTo, defaultValue }
1279
1448
  * @returns {object} Plan with before/after signatures and affected call sites
1280
1449
  */
1450
+ // Strict reserved words per language — names that can never be identifiers.
1451
+ // Contextual/soft keywords (TS `interface`, Python `match`, C# `var`) are
1452
+ // deliberately absent: they are legal identifiers, and over-blocking a rename
1453
+ // is worse than trusting the compiler for the soft cases. Fail-open for
1454
+ // languages without an entry.
1455
+ const JS_RESERVED = new Set(('break case catch class const continue debugger default delete do else enum export ' +
1456
+ 'extends false finally for function if import in instanceof new null return super switch this throw true try ' +
1457
+ 'typeof var void while with yield let static await').split(' '));
1458
+ const C_RESERVED = new Set(('auto break case char const continue default do double else enum extern float for goto ' +
1459
+ 'if inline int long register restrict return short signed sizeof static struct switch typedef union unsigned ' +
1460
+ 'void volatile while _Bool').split(' '));
1461
+ const RESERVED_WORDS_BY_LANGUAGE = {
1462
+ javascript: JS_RESERVED,
1463
+ typescript: JS_RESERVED,
1464
+ tsx: JS_RESERVED,
1465
+ python: new Set(('False None True and as assert async await break class continue def del elif else except ' +
1466
+ 'finally for from global if import in is lambda nonlocal not or pass raise return try while with yield').split(' ')),
1467
+ go: new Set(('break case chan const continue default defer else fallthrough for func go goto if import ' +
1468
+ 'interface map package range return select struct switch type var').split(' ')),
1469
+ rust: new Set(('as async await break const continue crate dyn else enum extern false fn for if impl in let ' +
1470
+ 'loop match mod move mut pub ref return self Self static struct super trait true type unsafe use where ' +
1471
+ 'while').split(' ')),
1472
+ java: new Set(('abstract assert boolean break byte case catch char class const continue default do double ' +
1473
+ 'else enum extends final finally float for goto if implements import instanceof int interface long native ' +
1474
+ 'new package private protected public return short static strictfp super switch synchronized this throw ' +
1475
+ 'throws transient try void volatile while true false null').split(' ')),
1476
+ c: C_RESERVED,
1477
+ cpp: new Set([...C_RESERVED, ...('bool catch class constexpr delete explicit false friend mutable namespace ' +
1478
+ 'new noexcept nullptr operator private protected public template this throw true try typename using ' +
1479
+ 'virtual wchar_t').split(' ')]),
1480
+ csharp: new Set(('abstract as base bool break byte case catch char checked class const continue decimal ' +
1481
+ 'default delegate do double else enum event explicit extern false finally fixed float for foreach goto if ' +
1482
+ 'implicit in int interface internal is lock long namespace new null object operator out override params ' +
1483
+ 'private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct ' +
1484
+ 'switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile ' +
1485
+ 'while').split(' ')),
1486
+ };
1487
+
1281
1488
  function plan(index, name, options = {}) {
1282
1489
  index._beginOp();
1283
1490
  try {
@@ -1331,10 +1538,33 @@ function plan(index, name, options = {}) {
1331
1538
  };
1332
1539
  }
1333
1540
 
1541
+ // Rename-target sanity: a same-name rename is a 0-change request, and a
1542
+ // reserved word in the target's language would write syntax errors into
1543
+ // the declaration and every call site. (Identifier SHAPE is validated at
1544
+ // the execute layer; keywords need the resolved symbol's language.)
1545
+ if (options.renameTo) {
1546
+ if (options.renameTo === def.name || options.renameTo === name) {
1547
+ return {
1548
+ found: true,
1549
+ function: name,
1550
+ error: `renameTo "${options.renameTo}" matches the current name — nothing to rename.`,
1551
+ };
1552
+ }
1553
+ const reserved = RESERVED_WORDS_BY_LANGUAGE[planLang === 'html' ? 'javascript' : planLang];
1554
+ if (reserved && reserved.has(options.renameTo)) {
1555
+ return {
1556
+ found: true,
1557
+ function: name,
1558
+ error: `renameTo "${options.renameTo}" is a reserved word in ${planLang === 'html' ? 'javascript' : planLang} and cannot be used as an identifier.`,
1559
+ };
1560
+ }
1561
+ }
1562
+
1334
1563
  let newParams = [...currentParams];
1335
1564
  let newSignature = currentSignature;
1336
1565
  let operation = null;
1337
1566
  let changes = [];
1567
+ let unchangedSites = 0;
1338
1568
 
1339
1569
  if (options.addParam) {
1340
1570
  // Check if parameter already exists
@@ -1394,7 +1624,11 @@ function plan(index, name, options = {}) {
1394
1624
  for (const site of planCallSites) {
1395
1625
  let suggestion;
1396
1626
  if (options.defaultValue && langHasDefaults) {
1397
- suggestion = `No change needed (has default value)`;
1627
+ // The default makes the existing call valid. Keep the fact in
1628
+ // metadata, but do not inflate the concrete edit plan with a
1629
+ // no-op entry (UCN5-166).
1630
+ unchangedSites++;
1631
+ continue;
1398
1632
  } else if (options.defaultValue) {
1399
1633
  suggestion = `Add argument: ${options.defaultValue} (no default parameter values in ${planFileEntry?.language || 'this language'})`;
1400
1634
  } else {
@@ -1405,7 +1639,8 @@ function plan(index, name, options = {}) {
1405
1639
  line: site.line,
1406
1640
  expression: site.expression,
1407
1641
  suggestion,
1408
- args: site.args
1642
+ args: site.args,
1643
+ editKind: 'call',
1409
1644
  });
1410
1645
  }
1411
1646
  }
@@ -1462,7 +1697,8 @@ function plan(index, name, options = {}) {
1462
1697
  line: site.line,
1463
1698
  expression: site.expression,
1464
1699
  suggestion: `Remove argument ${callerArgIndex + 1}: ${site.args[callerArgIndex] || '?'}`,
1465
- args: site.args
1700
+ args: site.args,
1701
+ editKind: 'call',
1466
1702
  });
1467
1703
  } else if (!site.args) {
1468
1704
  // Arguments unparseable (macro bodies, generated code) —
@@ -1473,7 +1709,8 @@ function plan(index, name, options = {}) {
1473
1709
  line: site.line,
1474
1710
  expression: site.expression,
1475
1711
  suggestion: 'Could not parse arguments — review this call site manually',
1476
- needsReview: true
1712
+ needsReview: true,
1713
+ editKind: 'call',
1477
1714
  });
1478
1715
  }
1479
1716
  }
@@ -1489,21 +1726,63 @@ function plan(index, name, options = {}) {
1489
1726
  // line appears ONCE however many call records it holds (fix #230 —
1490
1727
  // the non-global regex left the inner call behind and emitted a
1491
1728
  // duplicate entry per record).
1492
- const renamedLines = new Set();
1729
+ const callLines = new Map();
1493
1730
  for (const site of planCallSites) {
1494
1731
  const lineKey = `${site.file}:${site.line}`;
1495
- if (renamedLines.has(lineKey)) continue;
1496
- renamedLines.add(lineKey);
1497
- const newExpression = site.expression.replace(
1498
- new RegExp('\\b' + escapeRegExp(name) + '\\b', 'g'),
1499
- options.renameTo
1500
- );
1501
- changes.push({
1732
+ const group = callLines.get(lineKey) || {
1502
1733
  file: site.file,
1734
+ absoluteFile: site.absoluteFile,
1503
1735
  line: site.line,
1504
1736
  expression: site.expression,
1737
+ columns: [],
1738
+ missingColumn: false,
1739
+ callCount: 0,
1740
+ calledAs: site.calledAs,
1741
+ };
1742
+ if (group.calledAs !== site.calledAs) group.calledAs = null;
1743
+ group.callCount++;
1744
+ if (Number.isInteger(site.column)) group.columns.push(site.column);
1745
+ else group.missingColumn = true;
1746
+ callLines.set(lineKey, group);
1747
+ }
1748
+ for (const site of callLines.values()) {
1749
+ // A renamed import preserves its local alias (`old as local` /
1750
+ // `{ old: local }`). The caller engine carries the authored name
1751
+ // in calledAs; that token must remain unchanged while the import's
1752
+ // source-side identifier is edited below.
1753
+ if (site.calledAs && site.calledAs !== name) continue;
1754
+ const edit = renameIdentifierTokens(index,
1755
+ site.absoluteFile || site.file, site.line, name,
1756
+ options.renameTo, site.missingColumn ? [] : site.columns,
1757
+ site.callCount);
1758
+ const newExpression = edit.renamed;
1759
+ // A confirmed call through an import alias (`xf()`) is a real
1760
+ // caller but the alias spelling does not change. The required
1761
+ // edit is the import's source-side name; never emit a byte-for-
1762
+ // byte no-op that makes the plan look complete.
1763
+ if (newExpression === edit.source) {
1764
+ // Missing parser columns mean UCN can prove the edit is
1765
+ // required but cannot safely synthesize it. Never fall back
1766
+ // to whole-line regex replacement.
1767
+ if (site.missingColumn) {
1768
+ changes.push({
1769
+ file: site.file,
1770
+ line: site.line,
1771
+ expression: edit.source,
1772
+ suggestion: `Rename call identifier "${name}" to "${options.renameTo}" manually`,
1773
+ needsReview: true,
1774
+ editKind: 'call',
1775
+ });
1776
+ }
1777
+ continue;
1778
+ }
1779
+ changes.push({
1780
+ file: site.file,
1781
+ line: site.line,
1782
+ expression: edit.source,
1505
1783
  suggestion: `Rename to: ${newExpression}`,
1506
- newExpression
1784
+ newExpression,
1785
+ editKind: 'call',
1507
1786
  });
1508
1787
  }
1509
1788
 
@@ -1530,21 +1809,236 @@ function plan(index, name, options = {}) {
1530
1809
  _nameBindingReaches(index, imp.file, name, renameTargetFiles) === 'no') {
1531
1810
  continue;
1532
1811
  }
1533
- const newImport = imp.content.trim().replace(
1534
- new RegExp('\\b' + escapeRegExp(name) + '\\b'),
1535
- options.renameTo
1536
- );
1812
+ const edit = renameIdentifierTokens(index, imp.file,
1813
+ imp.line, name, options.renameTo);
1814
+ const newImport = edit.renamed;
1815
+ if (newImport === edit.source) continue;
1537
1816
  changes.push({
1538
1817
  file: imp.relativePath || imp.file,
1539
1818
  line: imp.line,
1540
- expression: imp.content.trim(),
1819
+ expression: edit.source,
1541
1820
  suggestion: `Update import: ${newImport}`,
1542
1821
  newExpression: newImport,
1543
- isImport: true
1822
+ isImport: true,
1823
+ editKind: 'import',
1544
1824
  });
1545
1825
  }
1826
+
1827
+ // Renamed CJS/Python imports are intentionally surfaced as reference
1828
+ // usages by their parsers, so usageType alone cannot find the import
1829
+ // line. importBindings retains the original/local pair and line.
1830
+ for (const [filePath, fileEntry] of index.files) {
1831
+ for (const binding of fileEntry.importBindings || []) {
1832
+ const localAlias = binding.alias || (fileEntry.importAliases || [])
1833
+ .find(alias => alias.original === binding.name)?.local;
1834
+ if (binding.name !== name || !localAlias ||
1835
+ localAlias === name || !binding.line) continue;
1836
+ if (_nameBindingReaches(index, filePath, name,
1837
+ renameTargetFiles) === 'no') continue;
1838
+ const rel = fileEntry.relativePath || filePath;
1839
+ if (changes.some(change =>
1840
+ change.file === rel && change.line === binding.line)) continue;
1841
+ const edit = renameIdentifierTokens(index, filePath,
1842
+ binding.line, name, options.renameTo);
1843
+ const sourceLine = edit.source;
1844
+ const newImport = edit.renamed;
1845
+ if (newImport === sourceLine) continue;
1846
+ changes.push({
1847
+ file: rel,
1848
+ line: binding.line,
1849
+ expression: sourceLine,
1850
+ suggestion: `Update import: ${newImport}`,
1851
+ newExpression: newImport,
1852
+ isImport: true,
1853
+ editKind: 'import',
1854
+ });
1855
+ }
1856
+ }
1857
+
1858
+ // Export surfaces owned by the selected definition are declaration
1859
+ // references too. In particular, CommonJS shorthand
1860
+ // `module.exports = { helper }` must be renamed or the otherwise
1861
+ // complete caller/import edit set breaks the module API.
1862
+ for (const [exportPath, targetEntry] of index.files) {
1863
+ for (const exported of targetEntry.exportDetails || []) {
1864
+ if ((exported.name !== name && exported.alias !== name) ||
1865
+ !exported.line) continue;
1866
+ const ownership = _nameBindingReaches(
1867
+ index, exportPath, name, renameTargetFiles, 8);
1868
+ // Transitive file reachability is not name ownership. A file
1869
+ // can import the target somewhere in its dependency closure
1870
+ // while exporting its own same-spelled local declaration.
1871
+ // Edit the target's own export, or a positively-resolved
1872
+ // re-export chain; unknown CJS/dynamic surfaces are unsafe to
1873
+ // rewrite mechanically.
1874
+ if (exportPath !== def.file && ownership !== 'yes') continue;
1875
+ const exportFile = targetEntry.relativePath || exportPath;
1876
+ if (changes.some(change =>
1877
+ change.file === exportFile && change.line === exported.line)) {
1878
+ continue;
1879
+ }
1880
+ const edit = renameIdentifierTokens(index, exportPath,
1881
+ exported.line, name, options.renameTo);
1882
+ const sourceLine = edit.source;
1883
+ const occurrences = edit.count;
1884
+ const newExpression = edit.renamed;
1885
+ if (newExpression === sourceLine) continue;
1886
+ changes.push({
1887
+ file: exportFile,
1888
+ line: exported.line,
1889
+ expression: sourceLine,
1890
+ suggestion: `Update export: ${newExpression}`,
1891
+ newExpression,
1892
+ isExport: true,
1893
+ editKind: 'export',
1894
+ ...(occurrences > 1 && { needsReview: true }),
1895
+ });
1896
+ }
1897
+ }
1898
+
1899
+ // Renaming a virtual/overridden member is one hierarchy-wide change.
1900
+ // Leaving descendant declarations behind either fails compilation
1901
+ // (Java/C#/TS override) or silently changes dispatch (Python/JS).
1902
+ if (def.className) {
1903
+ // Inheritance identity is (class name, defining file), not just
1904
+ // the spelling. Repositories routinely contain two Handler/Base
1905
+ // classes in unrelated packages. Follow only children whose base
1906
+ // resolves from the child's scope to this exact parent file.
1907
+ const identityKey = (className, file) => `${file || ''}\0${className}`;
1908
+ const descendants = new Map();
1909
+ const queue = [{ name: def.className, file: def.file }];
1910
+ const visited = new Set([identityKey(def.className, def.file)]);
1911
+ while (queue.length > 0 && descendants.size < 5000) {
1912
+ const parent = queue.shift();
1913
+ for (const child of index.extendedByGraph.get(parent.name) || []) {
1914
+ const childName = typeof child === 'string' ? child : child.name;
1915
+ const childFile = typeof child === 'string'
1916
+ ? index._resolveClassFile(childName, parent.file)
1917
+ : child.file;
1918
+ if (!childName || !childFile) continue;
1919
+ const parentFile = index._resolveClassFile(parent.name, childFile);
1920
+ if (parentFile !== parent.file) continue;
1921
+ const key = identityKey(childName, childFile);
1922
+ if (visited.has(key)) continue;
1923
+ visited.add(key);
1924
+ descendants.set(key, { name: childName, file: childFile });
1925
+ queue.push({ name: childName, file: childFile });
1926
+ }
1927
+ }
1928
+ for (const override of index.symbols.get(name) || []) {
1929
+ if (!override.className ||
1930
+ !descendants.has(identityKey(override.className, override.file))) continue;
1931
+ const line = override.nameLine || override.startLine;
1932
+ const rel = override.relativePath || override.file;
1933
+ if (changes.some(change => change.file === rel && change.line === line)) continue;
1934
+ const edit = renameIdentifierTokens(index, override.file,
1935
+ line, name, options.renameTo);
1936
+ const sourceLine = edit.source;
1937
+ const newExpression = edit.renamed;
1938
+ if (newExpression === sourceLine) continue;
1939
+ changes.push({
1940
+ file: rel,
1941
+ line,
1942
+ expression: sourceLine,
1943
+ suggestion: `Update overriding definition: ${newExpression}`,
1944
+ newExpression,
1945
+ isDefinition: true,
1946
+ editKind: 'definition',
1947
+ });
1948
+ }
1949
+ }
1950
+
1951
+ // C/C++ declarations and definitions are one compiler symbol. A
1952
+ // selected implementation must carry its matching header prototype,
1953
+ // and selecting the prototype must carry the implementation.
1954
+ if (planLang === 'c' || planLang === 'cpp') {
1955
+ const ownerOf = symbol => symbol.className ||
1956
+ (symbol.receiver || '').replace(/^\*/, '') || null;
1957
+ const signatureOf = symbol => (symbol.paramsStructured || []).map(param =>
1958
+ String(param.type || param.name || '').replace(/\s+/g, '')).join(',');
1959
+ const linked = candidate => candidate.file === def.file ||
1960
+ index.importGraph.get(def.file)?.has(candidate.file) ||
1961
+ index.importGraph.get(candidate.file)?.has(def.file);
1962
+ for (const sibling of index.symbols.get(name) || []) {
1963
+ if (sibling === def || ownerOf(sibling) !== ownerOf(def) ||
1964
+ signatureOf(sibling) !== signatureOf(def) ||
1965
+ !(sibling.isSignature || def.isSignature) || !linked(sibling)) continue;
1966
+ const siblingLang = index.files.get(sibling.file)?.language;
1967
+ if (siblingLang !== planLang &&
1968
+ !new Set(['c', 'cpp']).has(siblingLang)) continue;
1969
+ const line = sibling.nameLine || sibling.startLine;
1970
+ const rel = sibling.relativePath || sibling.file;
1971
+ if (changes.some(change => change.file === rel && change.line === line)) continue;
1972
+ const edit = renameIdentifierTokens(index, sibling.file,
1973
+ line, name, options.renameTo);
1974
+ if (edit.renamed === edit.source) continue;
1975
+ changes.push({
1976
+ file: rel,
1977
+ line,
1978
+ expression: edit.source,
1979
+ suggestion: `Update paired declaration: ${edit.renamed}`,
1980
+ newExpression: edit.renamed,
1981
+ isDefinition: true,
1982
+ editKind: 'definition',
1983
+ });
1984
+ }
1985
+ }
1986
+ }
1987
+
1988
+ // Every operation changes the selected declaration. Historically `plan`
1989
+ // only listed callers/imports in changes[], while rendering the new
1990
+ // signature separately. An agent applying the advertised edit array
1991
+ // therefore produced uncompilable code. Keep the declaration in the same
1992
+ // concrete list and count as every other required edit.
1993
+ const definitionLine = def.nameLine || def.startLine;
1994
+ const definitionFile = def.relativePath || def.file;
1995
+ const definitionSource = index.getLineContent(def.file, definitionLine).trim();
1996
+ const existingDefinitionLine = changes.find(change =>
1997
+ change.file === definitionFile && change.line === definitionLine);
1998
+ if (existingDefinitionLine) {
1999
+ existingDefinitionLine.isDefinition = true;
2000
+ existingDefinitionLine.editKind = 'definition';
2001
+ if (options.renameTo) {
2002
+ const renamed = renameIdentifierTokens(index, def.file,
2003
+ definitionLine, name, options.renameTo).renamed;
2004
+ existingDefinitionLine.newExpression = renamed;
2005
+ existingDefinitionLine.suggestion = `Update definition: ${renamed}`;
2006
+ }
2007
+ } else {
2008
+ const definitionChange = {
2009
+ file: definitionFile,
2010
+ line: definitionLine,
2011
+ expression: definitionSource,
2012
+ isDefinition: true,
2013
+ editKind: 'definition',
2014
+ };
2015
+ if (options.renameTo) {
2016
+ const renamed = renameIdentifierTokens(index, def.file,
2017
+ definitionLine, name, options.renameTo).renamed;
2018
+ definitionChange.newExpression = renamed;
2019
+ definitionChange.suggestion = `Update definition: ${renamed}`;
2020
+ } else {
2021
+ definitionChange.suggestion =
2022
+ `Update declaration signature to: ${newSignature}`;
2023
+ // Signature layouts can span multiple lines and differ by
2024
+ // language. The AST proves this edit is required, while the
2025
+ // preview explicitly withholds a fake one-line replacement.
2026
+ definitionChange.needsReview = true;
2027
+ }
2028
+ changes.unshift(definitionChange);
1546
2029
  }
1547
2030
 
2031
+ const changeSummary = {
2032
+ // editKind is a partition: a public declaration that is also an
2033
+ // export remains one definition edit, never two summary buckets.
2034
+ definitions: changes.filter(change => change.editKind === 'definition').length,
2035
+ calls: changes.filter(change => change.editKind === 'call' ||
2036
+ !change.editKind).length,
2037
+ imports: changes.filter(change => change.editKind === 'import').length,
2038
+ exports: changes.filter(change => change.editKind === 'export').length,
2039
+ reviewRequired: changes.filter(change => change.needsReview).length,
2040
+ };
2041
+
1548
2042
  return {
1549
2043
  found: true,
1550
2044
  function: name,
@@ -1564,13 +2058,16 @@ function plan(index, name, options = {}) {
1564
2058
  },
1565
2059
  totalChanges: changes.length,
1566
2060
  filesAffected: new Set(changes.map(c => c.file)).size,
2061
+ changeSummary,
1567
2062
  changes,
2063
+ ...(unchangedSites > 0 && { unchangedSites }),
1568
2064
  // v4 tiered contract: sites that MAY also need this change but lack
1569
2065
  // binding/receiver evidence — review manually before refactoring.
1570
2066
  unverifiedCount: planUnverified.length,
1571
2067
  unverifiedSites: planUnverified,
1572
2068
  account: planAccount,
1573
- scopeWarning: impactScopeWarning
2069
+ scopeWarning: impactScopeWarning,
2070
+ ...(resolved.warnings.length > 0 && { warnings: resolved.warnings }),
1574
2071
  };
1575
2072
  } finally { index._endOp(); }
1576
2073
  }