ucn 4.0.1 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.claude/skills/ucn/SKILL.md +31 -7
  2. package/README.md +89 -50
  3. package/cli/index.js +199 -94
  4. package/core/account.js +3 -1
  5. package/core/analysis.js +334 -316
  6. package/core/bridge.js +13 -8
  7. package/core/cache.js +109 -19
  8. package/core/callers.js +969 -77
  9. package/core/check.js +19 -8
  10. package/core/deadcode.js +368 -40
  11. package/core/discovery.js +31 -11
  12. package/core/entrypoints.js +149 -17
  13. package/core/execute.js +330 -43
  14. package/core/graph-build.js +61 -10
  15. package/core/graph.js +282 -61
  16. package/core/imports.js +72 -3
  17. package/core/output/analysis-ext.js +70 -10
  18. package/core/output/analysis.js +67 -33
  19. package/core/output/check.js +4 -1
  20. package/core/output/doctor.js +13 -3
  21. package/core/output/endpoints.js +8 -3
  22. package/core/output/extraction.js +12 -1
  23. package/core/output/find.js +23 -9
  24. package/core/output/graph.js +32 -9
  25. package/core/output/refactoring.js +147 -49
  26. package/core/output/reporting.js +104 -5
  27. package/core/output/search.js +30 -3
  28. package/core/output/shared.js +31 -4
  29. package/core/output/tracing.js +22 -11
  30. package/core/parser.js +8 -6
  31. package/core/project.js +167 -7
  32. package/core/registry.js +20 -16
  33. package/core/reporting.js +220 -84
  34. package/core/search.js +270 -55
  35. package/core/shared.js +285 -1
  36. package/core/stacktrace.js +23 -1
  37. package/core/tracing.js +278 -36
  38. package/core/verify.js +352 -349
  39. package/languages/go.js +29 -17
  40. package/languages/index.js +56 -0
  41. package/languages/java.js +133 -16
  42. package/languages/javascript.js +215 -27
  43. package/languages/python.js +113 -47
  44. package/languages/rust.js +89 -26
  45. package/languages/utils.js +35 -7
  46. package/mcp/server.js +72 -31
  47. package/package.json +4 -1
package/core/verify.js CHANGED
@@ -7,8 +7,7 @@
7
7
 
8
8
  const path = require('path');
9
9
  const { detectLanguage, getParser, getLanguageModule, safeParse, langTraits } = require('../languages');
10
- const { escapeRegExp } = require('./shared');
11
- const { extractImports } = require('./imports');
10
+ const { escapeRegExp, codeUnitCompare } = require('./shared');
12
11
 
13
12
  // ============================================================================
14
13
  // CALL-SITE CLASSIFICATION (Feature A)
@@ -141,9 +140,21 @@ function classifyCallContext(callNode, language) {
141
140
  /**
142
141
  * Find a call expression node at the target line matching funcName
143
142
  */
144
- function findCallNode(node, callTypes, targetRow, funcName) {
143
+ function findCallNode(node, callTypes, targetRow, funcName, occurrence = 0) {
144
+ // Several same-name calls can share one line (`greet("a") + greet("b")`,
145
+ // f-strings) — fix #231: callers pass the site's per-line ordinal so each
146
+ // record is arg-checked against ITS OWN node, not the line's first.
147
+ // Records and this walk are both pre-order, so ordinals align; an
148
+ // out-of-range ordinal falls back to the first match (never worse than
149
+ // the pre-fix behavior when a parse shape hides a node).
150
+ const matches = _collectCallNodes(node, callTypes, targetRow, funcName, occurrence + 1);
151
+ return matches[occurrence] || matches[0] || null;
152
+ }
153
+
154
+ function _collectCallNodes(node, callTypes, targetRow, funcName, limit, out = []) {
155
+ if (out.length >= limit) return out;
145
156
  if (node.startPosition.row > targetRow || node.endPosition.row < targetRow) {
146
- return null; // Skip nodes that don't contain the target line
157
+ return out; // Skip nodes that don't contain the target line
147
158
  }
148
159
 
149
160
  if (callTypes.has(node.type) && node.startPosition.row <= targetRow && node.endPosition.row >= targetRow) {
@@ -153,7 +164,16 @@ function findCallNode(node, callTypes, targetRow, funcName) {
153
164
  if (typeNode) {
154
165
  // Strip generics and package qualifiers: com.foo.Bar<T> -> Bar
155
166
  const typeName = typeNode.text.replace(/<.*>$/, '').split('.').pop();
156
- if (typeName === funcName) return node;
167
+ if (typeName === funcName) out.push(node);
168
+ }
169
+ } else if (node.type === 'new_expression') {
170
+ // JS/TS constructor: new ClassName(args) — class is in 'constructor'
171
+ // field (fix #230: these sites used to fall out as "Could not
172
+ // parse call arguments" and every class verify went uncertain).
173
+ const ctorNode = node.childForFieldName('constructor');
174
+ if (ctorNode) {
175
+ const typeName = ctorNode.text.replace(/<.*>$/, '').split('.').pop();
176
+ if (typeName === funcName) out.push(node);
157
177
  }
158
178
  } else {
159
179
  // Check if this call is for our target function
@@ -169,17 +189,19 @@ function findCallNode(node, callTypes, targetRow, funcName) {
169
189
  : funcNode.type === 'scoped_identifier'
170
190
  ? (funcNode.childForFieldName('name') || funcNode.namedChild(funcNode.namedChildCount - 1))?.text
171
191
  : funcNode.text;
172
- if (funcText === funcName) return node;
192
+ if (funcText === funcName) out.push(node);
173
193
  }
174
194
  }
195
+ if (out.length >= limit) return out;
175
196
  }
176
197
 
177
- // Recurse into children
198
+ // Recurse into children — nested same-name calls (`greet(greet(x))`)
199
+ // are separate records, so a match's children are still scanned.
178
200
  for (let i = 0; i < node.childCount; i++) {
179
- const result = findCallNode(node.child(i), callTypes, targetRow, funcName);
180
- if (result) return result;
201
+ _collectCallNodes(node.child(i), callTypes, targetRow, funcName, limit, out);
202
+ if (out.length >= limit) return out;
181
203
  }
182
- return null;
204
+ return out;
183
205
  }
184
206
 
185
207
  /**
@@ -439,149 +461,184 @@ function extractArrowTypesFromVarDecl(index, def) {
439
461
  }
440
462
 
441
463
  /**
442
- * BUG-BX: A receiver like `Utils.helper()` may be a TS namespace member call
443
- * for a regular (non-method) exported function. Returns true when the
444
- * receiver matches a known namespace/class symbol that contains a function
445
- * with the verified name.
464
+ * Constructor parameter lists for a CLASS verify/plan target (fix #230): a
465
+ * class def carries no paramsStructured, so `verify Task` used to arg-check
466
+ * `new Task(id, name)` against 0..0 a false red on every parameterized
467
+ * constructor, in every language. Sources: indexed constructor members
468
+ * (JS/TS `constructor`, Python `__init__` — emitted with type
469
+ * 'constructor'), or a Java AST walk (constructors are deliberately not
470
+ * indexed as members there). Returns an array of paramsStructured lists —
471
+ * one per constructor overload — or null when the class declares none.
446
472
  * @param {object} index - ProjectIndex instance
447
- * @param {string} receiver - Receiver text from the call site
448
- * @param {string} funcName - Name being verified
449
- * @param {string} defFile - The definition's file (to scope the match)
450
- * @returns {boolean}
473
+ * @param {object} def - Resolved definition (any type; non-class returns null)
474
+ * @param {string} lang - The definition file's language
475
+ * @returns {Array<Array<object>>|null}
451
476
  */
452
- function isNamespaceContainerFor(index, receiver, funcName, defFile) {
453
- if (!receiver || !funcName) return false;
454
- const candidates = index.symbols.get(receiver);
455
- if (!candidates || candidates.length === 0) return false;
456
- // Accept namespace, module, class, or interface containers
457
- return candidates.some(c => {
458
- const t = c.type;
459
- if (t === 'namespace' || t === 'module' || t === 'class' || t === 'interface') {
460
- // Same file as the def is the strongest signal; fall back to project-wide match.
461
- if (!defFile || c.file === defFile) return true;
462
- // Cross-file: only accept when receiver is a dedicated namespace/module
463
- return t === 'namespace' || t === 'module';
477
+ function _constructorParamLists(index, def, lang) {
478
+ if (!def || !def.file || !['class', 'enum', 'record'].includes(def.type)) return null;
479
+ const lists = [];
480
+ const endLine = def.endLine != null ? def.endLine : Infinity;
481
+ const inRange = (d) => d.file === def.file &&
482
+ d.startLine >= def.startLine && d.startLine <= endLine &&
483
+ Array.isArray(d.paramsStructured);
484
+ for (const ctorName of ['constructor', '__init__']) {
485
+ for (const d of (index.symbols.get(ctorName) || [])) {
486
+ if (d.className === def.name && inRange(d)) lists.push(d.paramsStructured);
464
487
  }
465
- return false;
466
- });
488
+ }
489
+ // Java constructor members are named after the CLASS (enum-body
490
+ // constructors carry paramsStructured since fix #230).
491
+ for (const d of (index.symbols.get(def.name) || [])) {
492
+ if (d.type === 'constructor' && d.className === def.name && inRange(d)) {
493
+ lists.push(d.paramsStructured);
494
+ }
495
+ }
496
+ if (lists.length > 0) return lists;
497
+ if (lang !== 'java') return null;
498
+ let parser, content;
499
+ try {
500
+ parser = getParser('java');
501
+ content = index._readFile(def.file);
502
+ } catch (e) {
503
+ return null;
504
+ }
505
+ if (!parser || content == null) return null;
506
+ const tree = safeParse(parser, content);
507
+ if (!tree) return null;
508
+ const { parseStructuredParams } = require('../languages/utils');
509
+ const targetRow = def.startLine - 1;
510
+ let classNode = null;
511
+ (function findClass(node) {
512
+ if (classNode || !node) return;
513
+ if ((node.type === 'class_declaration' || node.type === 'enum_declaration' ||
514
+ node.type === 'record_declaration') &&
515
+ node.startPosition.row <= targetRow && node.endPosition.row >= targetRow) {
516
+ const nameNode = node.childForFieldName('name');
517
+ if (nameNode && nameNode.text === def.name) {
518
+ classNode = node;
519
+ return;
520
+ }
521
+ }
522
+ for (let i = 0; i < node.namedChildCount; i++) findClass(node.namedChild(i));
523
+ })(tree.rootNode);
524
+ if (!classNode) return null;
525
+ // Records declare their canonical constructor's params on the header.
526
+ if (classNode.type === 'record_declaration') {
527
+ const recParams = classNode.childForFieldName('parameters');
528
+ if (recParams) lists.push(parseStructuredParams(recParams, 'java') || []);
529
+ }
530
+ const collectCtors = (body) => {
531
+ if (!body) return;
532
+ for (let i = 0; i < body.namedChildCount; i++) {
533
+ const child = body.namedChild(i);
534
+ if (child.type === 'constructor_declaration') {
535
+ const paramsNode = child.childForFieldName('parameters');
536
+ lists.push(parseStructuredParams(paramsNode, 'java') || []);
537
+ } else if (child.type === 'enum_body_declarations') {
538
+ collectCtors(child);
539
+ }
540
+ }
541
+ };
542
+ collectCtors(classNode.childForFieldName('body'));
543
+ return lists.length > 0 ? lists : null;
467
544
  }
468
545
 
469
546
  /**
470
- * BUG-BW: Build the list of call sites for `plan` using the SAME findCallers
471
- * + className filter logic that verify uses. This guarantees plan and verify
472
- * agree on which sites need updating the previous implementation routed
473
- * through `index.impact()` whose filter is stricter for unresolved receivers
474
- * (e.g. `this.repo.save()`), causing plan to miss class-method call sites
475
- * that verify finds.
476
- *
477
- * Returns an array of plan-shaped sites: { file, line, expression, args, argCount }.
547
+ * v4 tiered caller sweep shared by verify and plan (BUG-BW lockstep): run
548
+ * findCallers in collectAccount mode and partition candidates into the
549
+ * confirmed band (arg-checked / planned) and the VISIBLE unverified band
550
+ * (rendered with reasons, never silently dropped). The pre-v4 className and
551
+ * receiver heuristics are gone engine receiver physics decide tier and
552
+ * exclusion, and their fallback branches could silently drop true callers.
553
+ * Namespace-container receivers (BUG-BX `Utils.helper()`) confirm in the
554
+ * ENGINE since fix #254 (range-based containment + scope evidence), so no
555
+ * verify-local promotion remains — the bands are the sweep's verbatim.
478
556
  *
479
557
  * @param {object} index - ProjectIndex instance
480
- * @param {string} name - Function name being refactored
481
- * @param {object} def - Resolved definition
482
- * @param {object} options - { file, className, line }
483
- * @returns {Array}
558
+ * @param {string} name - Symbol name
559
+ * @param {object} def - Resolved definition (pinned target)
560
+ * @returns {{ confirmed: Array, unverified: Array, account: object }}
484
561
  */
485
- function computePlanCallSites(index, name, def, options) {
486
- let callerResults = index.findCallers(name, {
562
+ function contractedCallerSweep(index, name, def) {
563
+ const rawCallers = index.findCallers(name, {
487
564
  includeMethods: true,
488
- includeUncertain: false,
489
565
  targetDefinitions: [def],
566
+ collectAccount: true,
490
567
  });
491
568
 
492
- // Mirror verify's className filter (kept inline rather than re-extracted to
493
- // avoid changing verify's behavior).
494
- if (options.className && def.className) {
495
- const targetClassName = def.className;
496
- callerResults = callerResults.filter(c => {
497
- if (!c.isMethod) return true;
498
- const r = c.receiver;
499
- if (!r || ['self', 'cls', 'this', 'super'].includes(r)) return true;
500
- if (r.toLowerCase().includes(targetClassName.toLowerCase())) return true;
501
- // Local var type inference from constructor assignments
502
- if (c.callerFile) {
503
- const callerDef = c.callerStartLine ? { file: c.callerFile, startLine: c.callerStartLine, endLine: c.callerEndLine } : null;
504
- if (callerDef) {
505
- const callerCalls = index.getCachedCalls(c.callerFile);
506
- if (callerCalls && Array.isArray(callerCalls)) {
507
- const localTypes = new Map();
508
- for (const call of callerCalls) {
509
- if (call.line >= callerDef.startLine && call.line <= callerDef.endLine) {
510
- if (!call.isMethod && !call.receiver) {
511
- const syms = index.symbols.get(call.name);
512
- if (syms && syms.some(s => s.type === 'class')) {
513
- const content = index._readFile(c.callerFile);
514
- const clines = content.split('\n');
515
- const cline = clines[call.line - 1] || '';
516
- const m = cline.match(/^\s*(\w+)\s*=\s*(?:await\s+)?(\w+)\s*\(/);
517
- if (m && m[2] === call.name) {
518
- localTypes.set(m[1], call.name);
519
- }
520
- }
521
- }
522
- }
523
- }
524
- const receiverType = localTypes.get(r);
525
- if (receiverType) return receiverType === targetClassName;
526
- }
527
- }
528
- }
529
- // Param type annotations
530
- if (c.callerFile && c.callerStartLine) {
531
- const callerSymbol = index.findEnclosingFunction(c.callerFile, c.line, true);
532
- if (callerSymbol && callerSymbol.paramsStructured) {
533
- for (const param of callerSymbol.paramsStructured) {
534
- if (param.name === r && param.type) {
535
- const typeMatches = param.type.match(/\b([A-Za-z_]\w*)\b/g);
536
- if (typeMatches && typeMatches.some(t => t === targetClassName)) {
537
- return true;
538
- }
539
- return false;
540
- }
541
- }
542
- }
543
- }
544
- // Unique method heuristic
545
- const methodDefs = index.symbols.get(name);
546
- if (methodDefs) {
547
- const classNames = new Set();
548
- for (const d of methodDefs) {
549
- if (d.className) classNames.add(d.className);
550
- }
551
- if (classNames.size === 1 && classNames.has(targetClassName)) {
552
- return true;
553
- }
554
- }
555
- return false;
556
- });
569
+ const confirmed = [];
570
+ const unverified = [];
571
+ for (const c of rawCallers) {
572
+ if (c.tier !== 'unverified') confirmed.push(c);
573
+ else unverified.push(c);
574
+ }
575
+ for (const u of rawCallers.unverifiedEntries || []) {
576
+ unverified.push(u);
557
577
  }
558
578
 
559
- // Apply the same isMethodCall / non-method filter verify uses.
560
- const defIsMethod = !!(def.isMethod || def.type === 'method' || def.className);
561
- const targetBasename = path.basename(def.file, path.extname(def.file));
562
- const defFileEntry = index.files.get(def.file);
563
- const defLang = defFileEntry?.language;
564
-
565
- const importNameCache = new Map();
566
- function getImportedNames(filePath) {
567
- if (importNameCache.has(filePath)) return importNameCache.get(filePath);
568
- const names = new Set();
569
- const fe = index.files.get(filePath);
570
- if (!fe) { importNameCache.set(filePath, names); return names; }
571
- try {
572
- const content = index._readFile(filePath);
573
- const { imports: rawImports, importAliases } = extractImports(content, fe.language);
574
- for (const imp of rawImports) {
575
- if (imp.names) for (const n of imp.names) names.add(n);
576
- }
577
- if (importAliases) for (const alias of importAliases) names.add(alias.local);
578
- } catch (e) { /* skip */ }
579
- importNameCache.set(filePath, names);
580
- return names;
579
+ // Conservation account from the sweep's claims (impact's manual
580
+ // composition).
581
+ const { computeGroundSet, buildAccount } = require('./account');
582
+ const groundSet = computeGroundSet(index, name);
583
+ const accountRaw = rawCallers.accountRaw || { unverifiedLines: [], excludedEntries: [] };
584
+ const confirmedEntries = confirmed.map(c => ({ file: c.file, line: c.line }));
585
+ const unverifiedEntries = [
586
+ ...accountRaw.unverifiedLines,
587
+ ...unverified.map(u => ({ file: u.file, line: u.line })),
588
+ ];
589
+ for (const s of rawCallers.shadowEntries || []) {
590
+ (s.tier === 'unverified' ? unverifiedEntries : confirmedEntries).push({ file: s.file, line: s.line });
581
591
  }
592
+ const account = buildAccount(index, name, {
593
+ groundSet,
594
+ confirmedEntries,
595
+ unverifiedEntries,
596
+ excludedEntries: accountRaw.excludedEntries,
597
+ });
598
+
599
+ // Ground call-lines no engine candidate claimed: visible one-liners
600
+ // (already counted unverified in the account arithmetic).
601
+ const { callNotResolvedEntries } = require('./analysis');
602
+ for (const e of callNotResolvedEntries(index, account)) unverified.push(e);
603
+ unverified.sort((a, b) => {
604
+ const ap = a.relativePath || '';
605
+ const bp = b.relativePath || '';
606
+ if (ap !== bp) return codeUnitCompare(ap, bp);
607
+ return (a.line || 0) - (b.line || 0);
608
+ });
609
+
610
+ return { confirmed, unverified, account };
611
+ }
612
+
613
+ /** Map an unverified sweep entry to the public site shape (relative `file`). */
614
+ function unverifiedSiteShape(u) {
615
+ return {
616
+ file: u.relativePath,
617
+ line: u.line,
618
+ expression: (u.content || '').trim(),
619
+ callerName: u.callerName ?? null,
620
+ tier: 'unverified',
621
+ ...(u.reason && { reason: u.reason }),
622
+ ...(u.dispatchVia && { dispatchVia: u.dispatchVia }),
623
+ ...(u.dispatchCandidates != null && { dispatchCandidates: u.dispatchCandidates }),
624
+ };
625
+ }
626
+
627
+ /**
628
+ * BUG-BW: Build the list of call sites for `plan` using the SAME sweep verify
629
+ * uses. This guarantees plan and verify agree on which sites need updating.
630
+ *
631
+ * @param {object} index - ProjectIndex instance
632
+ * @param {string} name - Function name being refactored
633
+ * @param {object} def - Resolved definition
634
+ * @returns {{ sites: Array, unverifiedSites: Array, account: object }}
635
+ */
636
+ function computePlanCallSites(index, name, def) {
637
+ const { confirmed, unverified, account } = contractedCallerSweep(index, name, def);
582
638
 
583
639
  const sites = [];
584
- for (const c of callerResults) {
640
+ const planLineSeen = new Map(); // 'file:line' -> per-line ordinal (fix #231)
641
+ for (const c of confirmed) {
585
642
  const call = {
586
643
  file: c.file,
587
644
  relativePath: c.relativePath,
@@ -590,23 +647,10 @@ function computePlanCallSites(index, name, def, options) {
590
647
  usageType: 'call',
591
648
  receiver: c.receiver,
592
649
  };
593
- const analysis = analyzeCallSite(index, call, name);
594
-
595
- if (analysis.isMethodCall && !defIsMethod) {
596
- const callReceiver = call.receiver;
597
- if (callReceiver && callReceiver === targetBasename) {
598
- const importedNames = getImportedNames(call.file);
599
- if (!importedNames.has(callReceiver)) continue;
600
- } else if (callReceiver && langTraits(defLang)?.hasReceiverPackageCalls) {
601
- const targetDir = path.basename(path.dirname(def.file));
602
- if (callReceiver !== targetDir) continue;
603
- } else if (callReceiver && isNamespaceContainerFor(index, callReceiver, name, def.file)) {
604
- // BUG-BX: TS namespace-qualified call — accept.
605
- } else {
606
- continue;
607
- }
608
- }
609
-
650
+ const siteKey = `${c.file}:${c.line}`;
651
+ const occurrence = planLineSeen.get(siteKey) || 0;
652
+ planLineSeen.set(siteKey, occurrence + 1);
653
+ const analysis = analyzeCallSite(index, call, name, occurrence);
610
654
  sites.push({
611
655
  file: call.relativePath,
612
656
  line: call.line,
@@ -618,11 +662,11 @@ function computePlanCallSites(index, name, def, options) {
618
662
  clearTreeCache(index);
619
663
  // Stable ordering (matches CLAUDE.md rule #11): files alphabetical, sites by line ascending.
620
664
  sites.sort((a, b) => {
621
- const fc = String(a.file).localeCompare(String(b.file));
665
+ const fc = codeUnitCompare(String(a.file), String(b.file));
622
666
  if (fc !== 0) return fc;
623
667
  return (a.line || 0) - (b.line || 0);
624
668
  });
625
- return sites;
669
+ return { sites, unverifiedSites: unverified.map(unverifiedSiteShape), account };
626
670
  }
627
671
 
628
672
  /**
@@ -657,7 +701,7 @@ function computePlanScopeWarning(index, name, def, options) {
657
701
  * @param {string} funcName - Function name to find
658
702
  * @returns {object} { args, argCount, hasSpread, hasVariable }
659
703
  */
660
- function analyzeCallSite(index, call, funcName) {
704
+ function analyzeCallSite(index, call, funcName, occurrence = 0) {
661
705
  try {
662
706
  const language = detectLanguage(call.file);
663
707
  if (!language) return { args: null, argCount: 0 };
@@ -687,11 +731,12 @@ function analyzeCallSite(index, call, funcName) {
687
731
  }
688
732
 
689
733
  // Call node types vary by language
690
- const callTypes = new Set(['call_expression', 'call', 'method_invocation', 'object_creation_expression']);
734
+ const callTypes = new Set(['call_expression', 'call', 'method_invocation',
735
+ 'object_creation_expression', 'new_expression']);
691
736
  const targetRow = call.line - 1; // tree-sitter is 0-indexed
692
737
 
693
738
  // Find the call expression at the target line matching funcName
694
- const callNode = findCallNode(tree.rootNode, callTypes, targetRow, funcName);
739
+ const callNode = findCallNode(tree.rootNode, callTypes, targetRow, funcName, occurrence);
695
740
  if (!callNode) return { args: null, argCount: 0 };
696
741
 
697
742
  // Check if this is a method call (obj.func()) vs a direct call (func())
@@ -954,105 +999,37 @@ function verify(index, name, options = {}) {
954
999
  // BUG-BY: enrich types for arrow functions whose types live on the
955
1000
  // enclosing variable_declarator's type_annotation rather than inline.
956
1001
  const arrowTypes = extractArrowTypesFromVarDecl(index, def);
957
- let params = (arrowTypes?.paramsStructured) || def.paramsStructured || [];
1002
+ // Class target: arg-check against CONSTRUCTOR parameters (fix #230).
1003
+ // Multiple lists = constructor overloads (Java): a call is valid when it
1004
+ // fits the combined range; a class with only an inherited constructor
1005
+ // (extends, no own ctor) has an arity UCN can't see — accept any count
1006
+ // rather than false-flag every call against the implicit 0-arg default.
1007
+ const ctorParamLists = _constructorParamLists(index, def, lang);
1008
+ const inheritedCtorOnly = !ctorParamLists && def.type === 'class' && !!def.extends;
958
1009
  const selfParams = langTraits(lang)?.selfParam;
959
- if (selfParams && params.length > 0 && selfParams.includes(params[0].name)) {
960
- params = params.slice(1);
961
- }
962
- const hasRest = params.some(p => p.rest);
963
- // Rest params don't count toward expected/min — they accept 0+ extra args
964
- const nonRestParams = params.filter(p => !p.rest);
965
- const expectedParamCount = nonRestParams.length;
966
- const optionalCount = nonRestParams.filter(p => p.optional || p.default !== undefined).length;
967
- const minArgs = expectedParamCount - optionalCount;
968
-
969
- // Get all call sites using findCallers for accurate resolution
970
- // (usages-based approach misses calls when className is set or local names collide)
971
- // BUG-H3: accept user-supplied flag. The default keeps the historical behavior:
972
- // findCallers always returns method calls (so callers like obj.method() are seen),
973
- // and the secondary filter below drops them when verifying a non-method def
974
- // (e.g. dict.get() vs standalone get()). Passing --include-methods opts out of
975
- // the secondary filter so all method-style calls are treated as candidates.
976
- const verifyIncludeMethods = options.includeMethods === true;
977
- const verifyIncludeUncertain = options.includeUncertain ?? false;
978
- let callerResults = index.findCallers(name, {
979
- // Always pass true to findCallers so method calls are visible — the secondary
980
- // filter inside verify is the one that does the policy-driven filtering.
981
- includeMethods: true,
982
- includeUncertain: verifyIncludeUncertain,
983
- targetDefinitions: [def],
1010
+ const stripSelf = (list) => (selfParams && list.length > 0 && list[0] && selfParams.includes(list[0].name))
1011
+ ? list.slice(1) : list;
1012
+ const rawParamLists = ctorParamLists ||
1013
+ [(arrowTypes?.paramsStructured) || def.paramsStructured || []];
1014
+ const params = stripSelf(rawParamLists[0]);
1015
+ const arities = rawParamLists.map(l => {
1016
+ const list = stripSelf(l);
1017
+ const nonRest = list.filter(p => !p.rest);
1018
+ const optional = nonRest.filter(p => p.optional || p.default !== undefined).length;
1019
+ return { hasRest: list.some(p => p.rest), max: nonRest.length, min: nonRest.length - optional };
984
1020
  });
1021
+ const hasRest = inheritedCtorOnly || arities.some(a => a.hasRest);
1022
+ // Rest params don't count toward expected/min — they accept 0+ extra args
1023
+ const expectedParamCount = Math.max(...arities.map(a => a.max));
1024
+ const minArgs = inheritedCtorOnly ? 0 : Math.min(...arities.map(a => a.min));
985
1025
 
986
- // When className is explicitly provided, filter out method calls whose
987
- // receiver clearly belongs to a different type (same logic as impact()).
988
- if (options.className && def.className) {
989
- const targetClassName = def.className;
990
- callerResults = callerResults.filter(c => {
991
- if (!c.isMethod) return true;
992
- const r = c.receiver;
993
- if (!r || ['self', 'cls', 'this', 'super'].includes(r)) return true;
994
- if (r.toLowerCase().includes(targetClassName.toLowerCase())) return true;
995
- // Check local variable type inference from constructor assignments
996
- if (c.callerFile) {
997
- const callerDef = c.callerStartLine ? { file: c.callerFile, startLine: c.callerStartLine, endLine: c.callerEndLine } : null;
998
- if (callerDef) {
999
- const callerCalls = index.getCachedCalls(c.callerFile);
1000
- if (callerCalls && Array.isArray(callerCalls)) {
1001
- const localTypes = new Map();
1002
- for (const call of callerCalls) {
1003
- if (call.line >= callerDef.startLine && call.line <= callerDef.endLine) {
1004
- if (!call.isMethod && !call.receiver) {
1005
- const syms = index.symbols.get(call.name);
1006
- if (syms && syms.some(s => s.type === 'class')) {
1007
- const content = index._readFile(c.callerFile);
1008
- const clines = content.split('\n');
1009
- const cline = clines[call.line - 1] || '';
1010
- const m = cline.match(/^\s*(\w+)\s*=\s*(?:await\s+)?(\w+)\s*\(/);
1011
- if (m && m[2] === call.name) {
1012
- localTypes.set(m[1], call.name);
1013
- }
1014
- }
1015
- }
1016
- }
1017
- }
1018
- const receiverType = localTypes.get(r);
1019
- if (receiverType) {
1020
- return receiverType === targetClassName;
1021
- }
1022
- }
1023
- }
1024
- }
1025
- // Check parameter type annotations: def foo(tracker: SourceTracker) → tracker.record()
1026
- if (c.callerFile && c.callerStartLine) {
1027
- const callerSymbol = index.findEnclosingFunction(c.callerFile, c.line, true);
1028
- if (callerSymbol && callerSymbol.paramsStructured) {
1029
- for (const param of callerSymbol.paramsStructured) {
1030
- if (param.name === r && param.type) {
1031
- const typeMatches = param.type.match(/\b([A-Za-z_]\w*)\b/g);
1032
- if (typeMatches && typeMatches.some(t => t === targetClassName)) {
1033
- return true;
1034
- }
1035
- return false;
1036
- }
1037
- }
1038
- }
1039
- }
1040
- // Unique method heuristic: if the called method exists on exactly one class
1041
- // and it matches the target, include the call (no other class could match)
1042
- const methodDefs = index.symbols.get(name);
1043
- if (methodDefs) {
1044
- const classNames = new Set();
1045
- for (const d of methodDefs) {
1046
- if (d.className) classNames.add(d.className);
1047
- }
1048
- if (classNames.size === 1 && classNames.has(targetClassName)) {
1049
- return true;
1050
- }
1051
- }
1052
- // className explicitly set but receiver type unknown — filter it out
1053
- return false;
1054
- });
1055
- }
1026
+ // v4 tiered contract: the confirmed band is arg-checked below; unverified
1027
+ // candidates stay VISIBLE in their own band with reasons (never silently
1028
+ // dropped). Engine receiver physics replace the pre-v4 className filter
1029
+ // and the isMethodCall secondary filter — --include-methods and
1030
+ // --include-uncertain are implied no-ops for verify.
1031
+ const { confirmed: callerResults, unverified: sweepUnverified, account } =
1032
+ contractedCallerSweep(index, name, def);
1056
1033
 
1057
1034
  // Convert caller results to usage-like objects for analyzeCallSite.
1058
1035
  // Carry callerFile/callerStartLine through so we can compute inTestCase.
@@ -1071,36 +1048,7 @@ function verify(index, name, options = {}) {
1071
1048
  const mismatches = [];
1072
1049
  const uncertain = [];
1073
1050
 
1074
- // If the definition is NOT a method, filter out method calls (e.g., dict.get() vs get())
1075
- // This prevents false positives where a standalone function name matches method calls.
1076
- // Exception: module-level calls (module.func()) are kept when the receiver matches the
1077
- // target module's name and is an imported name (e.g., jobs.submit() where jobs is imported
1078
- // and the function lives in jobs.py).
1079
1051
  const defIsMethod = !!(def.isMethod || def.type === 'method' || def.className);
1080
- const targetBasename = path.basename(def.file, path.extname(def.file));
1081
- const defFileEntry = index.files.get(def.file);
1082
- const defLang = defFileEntry?.language;
1083
-
1084
- // Build import-name lookup for receiver checking (module.func() vs dict.get())
1085
- const importNameCache = new Map();
1086
- function getImportedNames(filePath) {
1087
- if (importNameCache.has(filePath)) return importNameCache.get(filePath);
1088
- const names = new Set();
1089
- const fe = index.files.get(filePath);
1090
- if (!fe) { importNameCache.set(filePath, names); return names; }
1091
- try {
1092
- const content = index._readFile(filePath);
1093
- const { imports: rawImports, importAliases } = extractImports(content, fe.language);
1094
- for (const imp of rawImports) {
1095
- if (imp.names) for (const n of imp.names) names.add(n);
1096
- }
1097
- if (importAliases) {
1098
- for (const alias of importAliases) names.add(alias.local);
1099
- }
1100
- } catch (e) { /* skip */ }
1101
- importNameCache.set(filePath, names);
1102
- return names;
1103
- }
1104
1052
 
1105
1053
  // Helper: extract pattern flags (Feature A/B) from analyzeCallSite result.
1106
1054
  // Reused so each valid/mismatch/uncertain entry carries the same shape.
@@ -1114,42 +1062,12 @@ function verify(index, name, options = {}) {
1114
1062
  };
1115
1063
  }
1116
1064
 
1065
+ const verifyLineSeen = new Map(); // 'file:line' -> per-line ordinal (fix #231)
1117
1066
  for (const call of calls) {
1118
- const analysis = analyzeCallSite(index, call, name);
1119
-
1120
- // Skip method calls when verifying a non-method definition.
1121
- // This prevents false positives (e.g., dict.get() vs standalone get()).
1122
- // Allow module-level calls only when:
1123
- // 1. Receiver matches target file's basename (e.g., jobs == jobs for jobs.py)
1124
- // 2. Receiver is an imported name (not a local variable)
1125
- // BUG-H3: when verifyIncludeMethods is true, keep method-style calls that
1126
- // findCallers already accepted (binding/import resolution did the heavy lifting).
1127
- // The receiver-based filtering below is a secondary safety net — skip it when
1128
- // user explicitly opted into method calls.
1129
- if (analysis.isMethodCall && !defIsMethod && !verifyIncludeMethods) {
1130
- const callReceiver = call.receiver;
1131
- if (callReceiver && callReceiver === targetBasename) {
1132
- const importedNames = getImportedNames(call.file);
1133
- if (!importedNames.has(callReceiver)) continue;
1134
- // Receiver matches target module and is imported — keep it
1135
- } else if (callReceiver && langTraits(defLang)?.hasReceiverPackageCalls) {
1136
- // Go: receiver is package alias (last segment of import path, e.g., "controller"
1137
- // from "k8s.io/.../pkg/controller"), not the filename ("controller_utils").
1138
- // Check if receiver matches the directory name of the target file.
1139
- const targetDir = path.basename(path.dirname(def.file));
1140
- if (callReceiver !== targetDir) {
1141
- continue;
1142
- }
1143
- // Receiver matches package directory — keep it
1144
- } else if (callReceiver && isNamespaceContainerFor(index, callReceiver, name, def.file)) {
1145
- // BUG-BX: TS namespace-qualified call (e.g. `Utils.helper()` where
1146
- // `Utils` is a `namespace` symbol containing `helper`). Treat the
1147
- // call as a direct invocation of the namespace member function.
1148
- // Same handling for class static methods and module containers.
1149
- } else {
1150
- continue;
1151
- }
1152
- }
1067
+ const siteKey = `${call.file}:${call.line}`;
1068
+ const occurrence = verifyLineSeen.get(siteKey) || 0;
1069
+ verifyLineSeen.set(siteKey, occurrence + 1);
1070
+ const analysis = analyzeCallSite(index, call, name, occurrence);
1153
1071
 
1154
1072
  // Carry callerFile/callerStartLine so tagInTestCase can resolve the
1155
1073
  // enclosing function in a later pass.
@@ -1184,7 +1102,20 @@ function verify(index, name, options = {}) {
1184
1102
  continue;
1185
1103
  }
1186
1104
 
1187
- const argCount = analysis.argCount;
1105
+ let argCount = analysis.argCount;
1106
+ // Method-expression / UFCS receiver shift (fix #230): Go
1107
+ // `M.Add(*m, 2)` and Rust `Engine::run(&e, 1)` pass the receiver as
1108
+ // the FIRST argument — the same +1 shift the #205 arity discipline
1109
+ // already applies when confirming these sites. Without it the
1110
+ // arg-check false-flagged every confirmed method-expression call.
1111
+ const targetTypeName = def.className || (def.receiver || '').replace(/^\*/, '');
1112
+ if (targetTypeName && call.receiver === targetTypeName && argCount > 0) {
1113
+ const qualStyle = langTraits(lang)?.typeQualifiedCallStyle;
1114
+ if ((qualStyle === 'method-expr' && def.receiver) ||
1115
+ (qualStyle === 'path' && def.isMethod)) {
1116
+ argCount -= 1;
1117
+ }
1118
+ }
1188
1119
 
1189
1120
  // Check if arg count is valid
1190
1121
  if (hasRest) {
@@ -1319,7 +1250,10 @@ function verify(index, name, options = {}) {
1319
1250
  optional: p.optional || p.default !== undefined,
1320
1251
  hasDefault: p.default !== undefined
1321
1252
  })),
1322
- expectedArgs: { min: minArgs, max: hasRest ? '∞' : expectedParamCount },
1253
+ // max: null = unbounded (rest param) typed for JSON consumers;
1254
+ // the text formatter renders it as `${min}+` (fix #230, was the
1255
+ // string '∞' leaking into JSON output).
1256
+ expectedArgs: { min: minArgs, max: hasRest ? null : expectedParamCount },
1323
1257
  totalCalls: valid.length + mismatches.length + uncertain.length,
1324
1258
  valid: valid.length,
1325
1259
  mismatches: mismatches.length,
@@ -1327,6 +1261,11 @@ function verify(index, name, options = {}) {
1327
1261
  validDetails: valid,
1328
1262
  mismatchDetails: mismatches,
1329
1263
  uncertainDetails: uncertain,
1264
+ // v4 tiered contract: candidates without binding/receiver evidence are
1265
+ // NOT arg-checked (they may target another symbol) but stay visible.
1266
+ unverifiedCount: sweepUnverified.length,
1267
+ unverifiedSites: sweepUnverified.map(unverifiedSiteShape),
1268
+ account,
1330
1269
  patterns: patternsAgg,
1331
1270
  scopeWarning
1332
1271
  };
@@ -1352,19 +1291,29 @@ function plan(index, name, options = {}) {
1352
1291
  const def = resolved.def || definitions[0];
1353
1292
  // BUG-BY: enrich types for typed-arrow-fn declarations.
1354
1293
  const arrowTypes = extractArrowTypesFromVarDecl(index, def);
1355
- const currentParams = (arrowTypes?.paramsStructured) || def.paramsStructured || [];
1294
+ // Class target: the signature being planned is the CONSTRUCTOR's
1295
+ // (fix #230, same rule as verify) — single-ctor classes only; with
1296
+ // overloads the class def's own (empty) list stays, since plan cannot
1297
+ // know which overload the user means.
1298
+ const planLang = index.files.get(def.file)?.language;
1299
+ const planCtorLists = _constructorParamLists(index, def, planLang);
1300
+ const currentParams = (planCtorLists && planCtorLists.length === 1 && planCtorLists[0]) ||
1301
+ (arrowTypes?.paramsStructured) || def.paramsStructured || [];
1356
1302
  // BUG-BV: render with TS-correct param formatting (`opt?: number`).
1357
- const currentSignature = formatTypedSignature(def, arrowTypes ? {
1358
- paramsStructured: arrowTypes.paramsStructured,
1359
- returnType: arrowTypes.returnType
1360
- } : {});
1361
-
1362
- // BUG-BW: plan must discover call sites the same way verify does for class
1363
- // methods. Previously plan relied on `index.impact()` whose filter rejected
1364
- // calls with unresolved receivers (e.g. `this.field.method()`), even when
1365
- // verify's filter accepts them. Compute call sites locally to keep plan
1366
- // and verify in lock-step.
1367
- const planCallSites = computePlanCallSites(index, name, def, options);
1303
+ const currentSignature = formatTypedSignature(def,
1304
+ (planCtorLists && planCtorLists.length === 1)
1305
+ ? { paramsStructured: currentParams }
1306
+ : arrowTypes ? {
1307
+ paramsStructured: arrowTypes.paramsStructured,
1308
+ returnType: arrowTypes.returnType
1309
+ } : {});
1310
+
1311
+ // BUG-BW: plan must discover call sites the same way verify does — both
1312
+ // run contractedCallerSweep (v4 tiered contract), so plan and verify stay
1313
+ // in lock-step by construction. Unverified candidates are NOT planned
1314
+ // (they may target another symbol) but stay visible with reasons.
1315
+ const { sites: planCallSites, unverifiedSites: planUnverified, account: planAccount } =
1316
+ computePlanCallSites(index, name, def);
1368
1317
  const impactScopeWarning = computePlanScopeWarning(index, name, def, options);
1369
1318
 
1370
1319
  // Reject ambiguous multi-op invocations rather than silently coalescing.
@@ -1398,9 +1347,14 @@ function plan(index, name, options = {}) {
1398
1347
  };
1399
1348
  }
1400
1349
  operation = 'add-param';
1350
+ // Default parameter values only exist in some languages (trait).
1351
+ // For Go/Java/Rust a --default value is a suggested ARGUMENT for the
1352
+ // call sites, never signature syntax — `opt = null` is not valid Go.
1353
+ const planFileEntry = index.files.get(def.file);
1354
+ const langHasDefaults = langTraits(planFileEntry?.language)?.hasDefaultParams !== false;
1401
1355
  const newParam = {
1402
1356
  name: options.addParam,
1403
- ...(options.defaultValue && { default: options.defaultValue })
1357
+ ...(options.defaultValue && langHasDefaults && { default: options.defaultValue })
1404
1358
  };
1405
1359
 
1406
1360
  // When adding a param, insert before rest params (*args/**kwargs) and
@@ -1436,11 +1390,17 @@ function plan(index, name, options = {}) {
1436
1390
  const newRet = arrowTypes?.returnType || def.returnType;
1437
1391
  if (newRet) newSignature += `: ${newRet}`;
1438
1392
 
1439
- // Describe changes needed at each call site
1393
+ // Describe changes needed at each call site. Without language support
1394
+ // for default values, every call site must pass the new argument.
1440
1395
  for (const site of planCallSites) {
1441
- const suggestion = options.defaultValue
1442
- ? `No change needed (has default value)`
1443
- : `Add argument: ${options.addParam}`;
1396
+ let suggestion;
1397
+ if (options.defaultValue && langHasDefaults) {
1398
+ suggestion = `No change needed (has default value)`;
1399
+ } else if (options.defaultValue) {
1400
+ suggestion = `Add argument: ${options.defaultValue} (no default parameter values in ${planFileEntry?.language || 'this language'})`;
1401
+ } else {
1402
+ suggestion = `Add argument: ${options.addParam}`;
1403
+ }
1444
1404
  changes.push({
1445
1405
  file: site.file,
1446
1406
  line: site.line,
@@ -1491,16 +1451,32 @@ function plan(index, name, options = {}) {
1491
1451
  }
1492
1452
  const callerArgIndex = paramIndex - selfOffset;
1493
1453
 
1494
- // Describe changes at each call site
1495
- for (const site of planCallSites) {
1496
- if (site.args && site.argCount > callerArgIndex) {
1497
- changes.push({
1498
- file: site.file,
1499
- line: site.line,
1500
- expression: site.expression,
1501
- suggestion: `Remove argument ${callerArgIndex + 1}: ${site.args[callerArgIndex] || '?'}`,
1502
- args: site.args
1503
- });
1454
+ // Removing the receiver param itself (self/cls/&self): bound calls
1455
+ // pass it implicitly no caller-side change exists (fix #230; used
1456
+ // to emit "Remove argument 0: ?" at every site).
1457
+ if (callerArgIndex >= 0) {
1458
+ // Describe changes at each call site
1459
+ for (const site of planCallSites) {
1460
+ if (site.args && site.argCount > callerArgIndex) {
1461
+ changes.push({
1462
+ file: site.file,
1463
+ line: site.line,
1464
+ expression: site.expression,
1465
+ suggestion: `Remove argument ${callerArgIndex + 1}: ${site.args[callerArgIndex] || '?'}`,
1466
+ args: site.args
1467
+ });
1468
+ } else if (!site.args) {
1469
+ // Arguments unparseable (macro bodies, generated code) —
1470
+ // surface for manual review instead of dropping silently
1471
+ // (fix #230).
1472
+ changes.push({
1473
+ file: site.file,
1474
+ line: site.line,
1475
+ expression: site.expression,
1476
+ suggestion: 'Could not parse arguments — review this call site manually',
1477
+ needsReview: true
1478
+ });
1479
+ }
1504
1480
  }
1505
1481
  }
1506
1482
  }
@@ -1509,10 +1485,18 @@ function plan(index, name, options = {}) {
1509
1485
  operation = 'rename';
1510
1486
  newSignature = currentSignature.replace(new RegExp('\\b' + escapeRegExp(name) + '\\b'), options.renameTo);
1511
1487
 
1512
- // All call sites need renaming
1488
+ // All call sites need renaming. Global replace: a line with several
1489
+ // calls (`compute(compute(1))`) renames every occurrence, and the
1490
+ // line appears ONCE however many call records it holds (fix #230 —
1491
+ // the non-global regex left the inner call behind and emitted a
1492
+ // duplicate entry per record).
1493
+ const renamedLines = new Set();
1513
1494
  for (const site of planCallSites) {
1495
+ const lineKey = `${site.file}:${site.line}`;
1496
+ if (renamedLines.has(lineKey)) continue;
1497
+ renamedLines.add(lineKey);
1514
1498
  const newExpression = site.expression.replace(
1515
- new RegExp('\\b' + escapeRegExp(name) + '\\b'),
1499
+ new RegExp('\\b' + escapeRegExp(name) + '\\b', 'g'),
1516
1500
  options.renameTo
1517
1501
  );
1518
1502
  changes.push({
@@ -1524,7 +1508,17 @@ function plan(index, name, options = {}) {
1524
1508
  });
1525
1509
  }
1526
1510
 
1527
- // Also include import statements that reference the renamed function
1511
+ // Also include import statements that reference the renamed function.
1512
+ // Name ownership (fix #230, the #217 rule): an import of the same
1513
+ // NAME from an unrelated module is not this rename's import —
1514
+ // renaming alpha.compute must not rewrite `from beta import compute`
1515
+ // (the plan's own call-site sweep already excludes caller_b's calls
1516
+ // as other-definition-import; the import pass has to agree).
1517
+ // 'no' (the binding provably resolves elsewhere) skips; 'unknown'
1518
+ // (CJS surfaces, star imports, resolver gaps) keeps the import —
1519
+ // a missed import breaks the rename just as surely.
1520
+ const { _nameBindingReaches } = require('./callers');
1521
+ const renameTargetFiles = new Set([def.file]);
1528
1522
  const usages = index.usages(name, { codeOnly: true });
1529
1523
  const importUsages = usages.filter(u => u.usageType === 'import' && !u.isDefinition);
1530
1524
  for (const imp of importUsages) {
@@ -1533,6 +1527,10 @@ function plan(index, name, options = {}) {
1533
1527
  c.file === (imp.relativePath || imp.file) && c.line === imp.line
1534
1528
  );
1535
1529
  if (alreadyCovered) continue;
1530
+ if (imp.file && def.file &&
1531
+ _nameBindingReaches(index, imp.file, name, renameTargetFiles) === 'no') {
1532
+ continue;
1533
+ }
1536
1534
  const newImport = imp.content.trim().replace(
1537
1535
  new RegExp('\\b' + escapeRegExp(name) + '\\b'),
1538
1536
  options.renameTo
@@ -1568,6 +1566,11 @@ function plan(index, name, options = {}) {
1568
1566
  totalChanges: changes.length,
1569
1567
  filesAffected: new Set(changes.map(c => c.file)).size,
1570
1568
  changes,
1569
+ // v4 tiered contract: sites that MAY also need this change but lack
1570
+ // binding/receiver evidence — review manually before refactoring.
1571
+ unverifiedCount: planUnverified.length,
1572
+ unverifiedSites: planUnverified,
1573
+ account: planAccount,
1571
1574
  scopeWarning: impactScopeWarning
1572
1575
  };
1573
1576
  } finally { index._endOp(); }