ucn 3.8.23 → 3.8.25

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 (44) hide show
  1. package/.claude/skills/ucn/SKILL.md +114 -11
  2. package/README.md +152 -156
  3. package/cli/index.js +363 -37
  4. package/core/analysis.js +936 -32
  5. package/core/bridge.js +1111 -0
  6. package/core/brief.js +408 -0
  7. package/core/cache.js +105 -5
  8. package/core/callers.js +72 -18
  9. package/core/check.js +200 -0
  10. package/core/discovery.js +57 -34
  11. package/core/entrypoints.js +638 -4
  12. package/core/execute.js +304 -5
  13. package/core/git-enrich.js +130 -0
  14. package/core/graph.js +24 -2
  15. package/core/output/analysis.js +157 -25
  16. package/core/output/brief.js +100 -0
  17. package/core/output/check.js +79 -0
  18. package/core/output/doctor.js +85 -0
  19. package/core/output/endpoints.js +239 -0
  20. package/core/output/extraction.js +2 -0
  21. package/core/output/find.js +126 -39
  22. package/core/output/graph.js +48 -15
  23. package/core/output/refactoring.js +103 -5
  24. package/core/output/reporting.js +63 -23
  25. package/core/output/search.js +110 -17
  26. package/core/output/shared.js +56 -2
  27. package/core/output.js +4 -0
  28. package/core/parser.js +8 -2
  29. package/core/project.js +39 -3
  30. package/core/registry.js +30 -14
  31. package/core/reporting.js +465 -2
  32. package/core/search.js +130 -10
  33. package/core/shared.js +101 -5
  34. package/core/tracing.js +16 -6
  35. package/core/verify.js +982 -95
  36. package/languages/go.js +91 -6
  37. package/languages/html.js +10 -0
  38. package/languages/java.js +151 -35
  39. package/languages/javascript.js +290 -33
  40. package/languages/python.js +78 -11
  41. package/languages/rust.js +267 -12
  42. package/languages/utils.js +315 -3
  43. package/mcp/server.js +91 -16
  44. package/package.json +9 -1
package/core/verify.js CHANGED
@@ -10,6 +10,134 @@ const { detectLanguage, getParser, getLanguageModule, safeParse, langTraits } =
10
10
  const { escapeRegExp } = require('./shared');
11
11
  const { extractImports } = require('./imports');
12
12
 
13
+ // ============================================================================
14
+ // CALL-SITE CLASSIFICATION (Feature A)
15
+ // ============================================================================
16
+ // AST node-type sets per language for walk-up classification of call sites.
17
+ // Detection is structural — we walk parents from the call node and stop at
18
+ // function boundaries to keep the classification scoped to the enclosing fn.
19
+
20
+ // Loop nodes — call sites inside these are "hot path" (likely repeated).
21
+ const LOOP_NODE_TYPES = {
22
+ javascript: new Set(['for_statement', 'while_statement', 'do_statement', 'for_in_statement', 'for_of_statement']),
23
+ typescript: new Set(['for_statement', 'while_statement', 'do_statement', 'for_in_statement', 'for_of_statement']),
24
+ tsx: new Set(['for_statement', 'while_statement', 'do_statement', 'for_in_statement', 'for_of_statement']),
25
+ html: new Set(['for_statement', 'while_statement', 'do_statement', 'for_in_statement', 'for_of_statement']),
26
+ python: new Set(['for_statement', 'while_statement']),
27
+ go: new Set(['for_statement']),
28
+ rust: new Set(['for_expression', 'while_expression', 'loop_expression']),
29
+ java: new Set(['for_statement', 'while_statement', 'do_statement', 'enhanced_for_statement']),
30
+ };
31
+
32
+ // Try nodes — call sites inside these are "guarded" (errors are caught).
33
+ // Go uses defer/recover (skipped). Rust uses Result-based error handling (skipped).
34
+ const TRY_NODE_TYPES = {
35
+ javascript: new Set(['try_statement']),
36
+ typescript: new Set(['try_statement']),
37
+ tsx: new Set(['try_statement']),
38
+ html: new Set(['try_statement']),
39
+ python: new Set(['try_statement']),
40
+ go: new Set(),
41
+ rust: new Set(),
42
+ java: new Set(['try_statement', 'try_with_resources_statement']),
43
+ };
44
+
45
+ // Function boundary nodes — walk-up stops at these (we don't classify across
46
+ // inner function definitions). These also identify "callback wrappers" when
47
+ // they're the value of an argument to another call_expression.
48
+ const FN_NODE_TYPES = {
49
+ javascript: new Set(['function_declaration', 'function_expression', 'arrow_function', 'method_definition', 'generator_function', 'generator_function_declaration']),
50
+ typescript: new Set(['function_declaration', 'function_expression', 'arrow_function', 'method_definition', 'generator_function', 'generator_function_declaration', 'function_signature']),
51
+ tsx: new Set(['function_declaration', 'function_expression', 'arrow_function', 'method_definition', 'generator_function', 'generator_function_declaration', 'function_signature']),
52
+ html: new Set(['function_declaration', 'function_expression', 'arrow_function', 'method_definition', 'generator_function', 'generator_function_declaration']),
53
+ python: new Set(['function_definition', 'async_function_definition', 'lambda']),
54
+ go: new Set(['function_declaration', 'method_declaration', 'func_literal']),
55
+ rust: new Set(['function_item', 'closure_expression']),
56
+ java: new Set(['method_declaration', 'constructor_declaration', 'lambda_expression']),
57
+ };
58
+
59
+ // Await-expression node types per language with async/await support.
60
+ // JS/TS: await is a unary expression `await call()`.
61
+ // Python: await is `await call()`.
62
+ // Go/Java/Rust currently have no await keyword tracked here.
63
+ const AWAIT_NODE_TYPES = {
64
+ javascript: new Set(['await_expression']),
65
+ typescript: new Set(['await_expression']),
66
+ tsx: new Set(['await_expression']),
67
+ html: new Set(['await_expression']),
68
+ python: new Set(['await']),
69
+ go: new Set(),
70
+ rust: new Set(),
71
+ java: new Set(),
72
+ };
73
+
74
+ // Argument-list node types — used to detect callback context. When walking up,
75
+ // if a function/lambda we cross has a parent of these types (which is itself
76
+ // inside a call_expression), the inner call is in a callback.
77
+ const ARGUMENTS_NODE_TYPES = new Set(['arguments', 'argument_list']);
78
+
79
+ /**
80
+ * Classify a call site by walking up its ancestors.
81
+ *
82
+ * Returns flags describing the structural context: `inLoop`, `inTry`,
83
+ * `inCallback`, `awaited`. Walks from the call node up to the enclosing
84
+ * function boundary (so an outer try wrapping an inner function does NOT
85
+ * leak `inTry: true` into a call inside the inner function).
86
+ *
87
+ * `inCallback` is set when, while walking up to the boundary, we cross an
88
+ * inner function/lambda that is itself an argument of another call.
89
+ *
90
+ * `awaited` is set when the call expression's immediate parent is an
91
+ * await-style node. Non-async languages always return `awaited: false`.
92
+ *
93
+ * @param {object} callNode - tree-sitter node for the call
94
+ * @param {string} language - canonical language name
95
+ * @returns {{inLoop:boolean, inTry:boolean, inCallback:boolean, awaited:boolean}}
96
+ */
97
+ function classifyCallContext(callNode, language) {
98
+ const result = { inLoop: false, inTry: false, inCallback: false, awaited: false };
99
+ if (!callNode) return result;
100
+
101
+ const loopTypes = LOOP_NODE_TYPES[language] || new Set();
102
+ const tryTypes = TRY_NODE_TYPES[language] || new Set();
103
+ const fnTypes = FN_NODE_TYPES[language] || new Set();
104
+ const awaitTypes = AWAIT_NODE_TYPES[language] || new Set();
105
+
106
+ // awaited: parent of the call must be an await-style node.
107
+ // Some grammars (Python) wrap the call in `await { call }`; others
108
+ // (JS/TS) use `await_expression > call_expression`. Both are detected by
109
+ // checking the immediate parent.
110
+ if (callNode.parent && awaitTypes.has(callNode.parent.type)) {
111
+ result.awaited = true;
112
+ }
113
+
114
+ // Walk up to classify loop/try/callback. Stop when we cross a function
115
+ // boundary — an inner closure isolates the inner call from outer context.
116
+ let current = callNode.parent;
117
+ while (current) {
118
+ const t = current.type;
119
+ if (loopTypes.has(t)) result.inLoop = true;
120
+ if (tryTypes.has(t)) result.inTry = true;
121
+ // Function boundary — stop, but first check if THIS function is an
122
+ // argument to another call (callback context). The ancestor chain is:
123
+ // outer_call > arguments > arrow_function/lambda > … > inner call
124
+ if (fnTypes.has(t)) {
125
+ const parent = current.parent;
126
+ if (parent && ARGUMENTS_NODE_TYPES.has(parent.type)) {
127
+ const grand = parent.parent;
128
+ if (grand && (grand.type === 'call_expression' || grand.type === 'call' ||
129
+ grand.type === 'method_invocation' || grand.type === 'object_creation_expression' ||
130
+ grand.type === 'macro_invocation')) {
131
+ result.inCallback = true;
132
+ }
133
+ }
134
+ break;
135
+ }
136
+ current = current.parent;
137
+ }
138
+ return result;
139
+ }
140
+
13
141
  /**
14
142
  * Find a call expression node at the target line matching funcName
15
143
  */
@@ -62,6 +190,466 @@ function clearTreeCache(index) {
62
190
  index._treeCache = null;
63
191
  }
64
192
 
193
+ /**
194
+ * Render a single parameter with TS-correct optional marker placement.
195
+ * BUG-BV fix: `?` follows the NAME, not the TYPE (e.g. `opt?: number`,
196
+ * not the invalid `opt: number?`). Used by verify/plan signature output.
197
+ * @param {object} p - Param object {name, type?, optional?, default?, rest?}
198
+ * @returns {string}
199
+ */
200
+ function formatTypedParam(p) {
201
+ if (!p || !p.name) return '';
202
+ // Rest-param prefix:
203
+ // Python `**kwargs` / `*args` keep their `*` prefix (name already starts with `*`).
204
+ // JS/TS rest like `...rest` keeps `...` (avoid double-prefix if name already has `...`).
205
+ // Bare names with rest=true get `...` prefix (JS rest with stripped pattern name).
206
+ let s;
207
+ if (p.rest) {
208
+ const n = String(p.name);
209
+ if (n.startsWith('*') || n.startsWith('...')) s = n;
210
+ else s = `...${n}`;
211
+ } else {
212
+ s = p.name;
213
+ }
214
+ // Optional marker — placed AFTER name, BEFORE type (TS syntax: `opt?: number`)
215
+ if (p.optional && !p.rest && p.default == null) s += '?';
216
+ if (p.type) s += `: ${p.type}`;
217
+ if (p.default != null) s += ` = ${p.default}`;
218
+ return s;
219
+ }
220
+
221
+ /**
222
+ * Render a param name for the plan `before.params` / `after.params` arrays.
223
+ * These arrays are name-keyed (callers do `.includes('retries')` exact match),
224
+ * so we keep TS optional `?` and type annotation for BUG-BV/#181 contracts,
225
+ * but omit the ` = default` suffix and rest `*`/`...` prefix that callers don't
226
+ * test against. Mirrors the pre-rewrite shape of plan output.
227
+ * @param {object} p
228
+ * @returns {string}
229
+ */
230
+ function formatPlanParamName(p) {
231
+ if (!p || !p.name) return '';
232
+ let s = p.name;
233
+ if (p.optional && !p.default) s += '?';
234
+ if (p.type) s += `: ${p.type}`;
235
+ return s;
236
+ }
237
+
238
+ /**
239
+ * Compute the modifier-prefix tokens for a function/method definition.
240
+ * Returns an array of tokens (e.g. ['static', 'async']) drawn from:
241
+ * - def.modifiers (Java, Python async, Rust pub/async, ...)
242
+ * - def.isAsync / def.async (JS/TS class methods)
243
+ * - def.memberType (JS/TS: 'static', 'static get', 'static override', ...)
244
+ *
245
+ * BUG-5: rename and add-param signature reconstruction must preserve modifier
246
+ * prefixes (async/static/public/...) — JS class methods don't populate
247
+ * def.modifiers, so we synthesise tokens from isAsync + memberType.
248
+ * @param {object} def
249
+ * @returns {string[]} ordered modifier tokens (no trailing space)
250
+ */
251
+ function computeModifierTokens(def) {
252
+ if (!def) return [];
253
+ const tokens = [];
254
+ // Pull declared modifiers first (Java public/static/final, Python ['async'], Rust pub/async).
255
+ if (Array.isArray(def.modifiers) && def.modifiers.length) {
256
+ for (const m of def.modifiers) {
257
+ if (typeof m === 'string' && m.length && !tokens.includes(m)) tokens.push(m);
258
+ }
259
+ }
260
+ // JS/TS class methods: memberType encodes static/get/set/override/private.
261
+ // Examples: 'static', 'static get', 'static override', 'static override get',
262
+ // 'override', 'override get', 'get', 'set', 'private', 'method',
263
+ // 'abstract', 'constructor'. Only structural prefixes are added.
264
+ const memberType = def.memberType;
265
+ if (typeof memberType === 'string' && memberType.length) {
266
+ const STRUCTURAL_PREFIXES = new Set(['static', 'override', 'abstract', 'public', 'private', 'protected', 'readonly', 'get', 'set']);
267
+ for (const tok of memberType.split(/\s+/)) {
268
+ if (STRUCTURAL_PREFIXES.has(tok) && !tokens.includes(tok)) tokens.push(tok);
269
+ }
270
+ }
271
+ // Async (JS/TS isAsync, fallback for languages that set def.async).
272
+ const asyncFlag = def.isAsync || def.async || (Array.isArray(def.modifiers) && def.modifiers.includes('async'));
273
+ if (asyncFlag && !tokens.includes('async')) tokens.push('async');
274
+ return tokens;
275
+ }
276
+
277
+ /**
278
+ * Build a function signature string from a definition, using
279
+ * TS-correct param formatting (BUG-BV). Local to verify.js to avoid
280
+ * the shared formatter's incorrect `?` placement.
281
+ * @param {object} def - Symbol definition
282
+ * @param {object} [overrides] - Optional { paramsStructured, returnType, name } overrides
283
+ * @returns {string}
284
+ */
285
+ function formatTypedSignature(def, overrides = {}) {
286
+ const parts = [];
287
+ const modTokens = computeModifierTokens(def);
288
+ if (modTokens.length) {
289
+ parts.push(modTokens.join(' '));
290
+ }
291
+ const name = overrides.name || def.name;
292
+ parts.push(name);
293
+ const ps = overrides.paramsStructured != null ? overrides.paramsStructured : def.paramsStructured;
294
+ if (Array.isArray(ps)) {
295
+ const paramTypes = def.paramTypes || {};
296
+ const parts2 = ps.map(p => {
297
+ // Apply paramTypes mapping when paramsStructured doesn't carry types
298
+ const merged = { ...p };
299
+ if (!merged.type && paramTypes[p.name]) merged.type = paramTypes[p.name];
300
+ return formatTypedParam(merged);
301
+ });
302
+ parts.push(`(${parts2.filter(Boolean).join(', ')})`);
303
+ } else if (def.params !== undefined) {
304
+ parts.push(`(${def.params})`);
305
+ }
306
+ const rt = overrides.returnType != null ? overrides.returnType : def.returnType;
307
+ if (rt) parts.push(`: ${rt}`);
308
+ return parts.join(' ');
309
+ }
310
+
311
+ /**
312
+ * BUG-BY: For an arrow function declared as `const x: (a: number) => number = (a) => ...`
313
+ * the inline arrow params/return type are missing types — they live on the
314
+ * variable_declarator's type_annotation. Walk up to the declarator and
315
+ * extract `function_type` parts (params + return type) when present.
316
+ *
317
+ * Returns null if no enrichment is available; otherwise an object with
318
+ * { paramsStructured, returnType } suitable for use as overrides.
319
+ *
320
+ * Only applies to TS-family files (typescript/tsx). JS doesn't have function_type
321
+ * annotations at the variable declarator level.
322
+ *
323
+ * @param {object} index - ProjectIndex instance
324
+ * @param {object} def - Symbol definition (must have file + startLine)
325
+ * @returns {{ paramsStructured: Array, returnType: string|null }|null}
326
+ */
327
+ function extractArrowTypesFromVarDecl(index, def) {
328
+ if (!def || !def.file || !def.startLine) return null;
329
+ const lang = detectLanguage(def.file);
330
+ if (lang !== 'typescript' && lang !== 'tsx') return null;
331
+ // Already have types — nothing to enrich.
332
+ const ps = def.paramsStructured;
333
+ const allHaveTypes = Array.isArray(ps) && ps.length > 0 && ps.every(p => p && p.type);
334
+ if (allHaveTypes && def.returnType) return null;
335
+ let parser;
336
+ try {
337
+ parser = getParser(lang);
338
+ } catch (e) {
339
+ return null;
340
+ }
341
+ if (!parser) return null;
342
+ let content;
343
+ try {
344
+ content = index._readFile(def.file);
345
+ } catch (e) {
346
+ return null;
347
+ }
348
+ const tree = safeParse(parser, content);
349
+ if (!tree) return null;
350
+
351
+ // Find the variable_declarator that wraps the arrow function at def.startLine
352
+ const targetRow = def.startLine - 1;
353
+ function findVarDecl(node) {
354
+ if (!node) return null;
355
+ if (node.startPosition.row > targetRow || node.endPosition.row < targetRow) return null;
356
+ if (node.type === 'variable_declarator') {
357
+ // Check if this declarator's value is an arrow_function (or function_expression)
358
+ const valueNode = node.childForFieldName('value');
359
+ if (valueNode && (valueNode.type === 'arrow_function' || valueNode.type === 'function_expression' || valueNode.type === 'function')) {
360
+ // Confirm name matches and starts at our target row
361
+ const nameNode = node.childForFieldName('name');
362
+ if (nameNode && nameNode.text === def.name) {
363
+ return node;
364
+ }
365
+ }
366
+ }
367
+ for (let i = 0; i < node.namedChildCount; i++) {
368
+ const result = findVarDecl(node.namedChild(i));
369
+ if (result) return result;
370
+ }
371
+ return null;
372
+ }
373
+ const declarator = findVarDecl(tree.rootNode);
374
+ if (!declarator) return null;
375
+
376
+ // Look for type_annotation child holding a function_type
377
+ let typeAnno = null;
378
+ for (let i = 0; i < declarator.namedChildCount; i++) {
379
+ const child = declarator.namedChild(i);
380
+ if (child.type === 'type_annotation') { typeAnno = child; break; }
381
+ }
382
+ if (!typeAnno) return null;
383
+ // type_annotation > function_type
384
+ let fnType = null;
385
+ for (let i = 0; i < typeAnno.namedChildCount; i++) {
386
+ const child = typeAnno.namedChild(i);
387
+ if (child.type === 'function_type') { fnType = child; break; }
388
+ }
389
+ if (!fnType) return null;
390
+ // function_type has formal_parameters + a return type sibling
391
+ const fp = fnType.childForFieldName('parameters') || (() => {
392
+ for (let i = 0; i < fnType.namedChildCount; i++) {
393
+ const c = fnType.namedChild(i);
394
+ if (c.type === 'formal_parameters') return c;
395
+ }
396
+ return null;
397
+ })();
398
+ let returnType = null;
399
+ // Return type is the last named child (predefined_type, type_identifier, etc.) that isn't formal_parameters
400
+ for (let i = fnType.namedChildCount - 1; i >= 0; i--) {
401
+ const c = fnType.namedChild(i);
402
+ if (c.type !== 'formal_parameters' && c.type !== 'type_parameters') {
403
+ returnType = c.text;
404
+ break;
405
+ }
406
+ }
407
+ // Build typed paramsStructured by reading param names + types out of fp.
408
+ // Pair against the existing inline params (from def.paramsStructured) so
409
+ // we preserve names declared at the arrow site if they differ.
410
+ let typedParams = [];
411
+ if (fp) {
412
+ for (let i = 0; i < fp.namedChildCount; i++) {
413
+ const param = fp.namedChild(i);
414
+ const info = {};
415
+ if (param.type === 'required_parameter' || param.type === 'optional_parameter') {
416
+ const patternNode = param.childForFieldName('pattern');
417
+ const tnode = param.childForFieldName('type');
418
+ if (patternNode) info.name = patternNode.text;
419
+ if (tnode) info.type = tnode.text.replace(/^:\s*/, '');
420
+ if (param.type === 'optional_parameter') info.optional = true;
421
+ } else if (param.type === 'identifier') {
422
+ info.name = param.text;
423
+ }
424
+ if (info.name) typedParams.push(info);
425
+ }
426
+ }
427
+ // If inline params have names (from arrow), prefer those names but keep types from fnType
428
+ if (Array.isArray(ps) && ps.length === typedParams.length) {
429
+ typedParams = typedParams.map((tp, i) => ({
430
+ ...ps[i], // start from existing (preserves rest, default, etc.)
431
+ ...(tp.type ? { type: tp.type } : {}),
432
+ ...(tp.optional ? { optional: true } : {}),
433
+ }));
434
+ }
435
+ return {
436
+ paramsStructured: typedParams.length ? typedParams : ps,
437
+ returnType: returnType || def.returnType || null,
438
+ };
439
+ }
440
+
441
+ /**
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.
446
+ * @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}
451
+ */
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';
464
+ }
465
+ return false;
466
+ });
467
+ }
468
+
469
+ /**
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 }.
478
+ *
479
+ * @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}
484
+ */
485
+ function computePlanCallSites(index, name, def, options) {
486
+ let callerResults = index.findCallers(name, {
487
+ includeMethods: true,
488
+ includeUncertain: false,
489
+ targetDefinitions: [def],
490
+ });
491
+
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
+ });
557
+ }
558
+
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;
581
+ }
582
+
583
+ const sites = [];
584
+ for (const c of callerResults) {
585
+ const call = {
586
+ file: c.file,
587
+ relativePath: c.relativePath,
588
+ line: c.line,
589
+ content: c.content,
590
+ usageType: 'call',
591
+ receiver: c.receiver,
592
+ };
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
+
610
+ sites.push({
611
+ file: call.relativePath,
612
+ line: call.line,
613
+ expression: (call.content || '').trim(),
614
+ args: analysis.args,
615
+ argCount: analysis.argCount,
616
+ });
617
+ }
618
+ clearTreeCache(index);
619
+ // Stable ordering (matches CLAUDE.md rule #11): files alphabetical, sites by line ascending.
620
+ sites.sort((a, b) => {
621
+ const fc = String(a.file).localeCompare(String(b.file));
622
+ if (fc !== 0) return fc;
623
+ return (a.line || 0) - (b.line || 0);
624
+ });
625
+ return sites;
626
+ }
627
+
628
+ /**
629
+ * Compute the same scopeWarning that impact() returns for plan output.
630
+ * @param {object} index - ProjectIndex instance
631
+ * @param {string} name - Function name
632
+ * @param {object} def - Resolved definition
633
+ * @param {object} options
634
+ * @returns {object|null}
635
+ */
636
+ function computePlanScopeWarning(index, name, def, options) {
637
+ const defIsMethod = !!(def.isMethod || def.type === 'method' || def.className);
638
+ if (!defIsMethod) return null;
639
+ const allDefs = index.symbols.get(name);
640
+ if (!allDefs || allDefs.length <= 1) return null;
641
+ const classNames = [...new Set(allDefs
642
+ .filter(d => d.className && d.className !== def.className)
643
+ .map(d => d.className))];
644
+ if (classNames.length === 0) return null;
645
+ if (options.className || options.file) return null;
646
+ return {
647
+ targetClass: def.className || '(unknown)',
648
+ otherClasses: classNames,
649
+ hint: `Results may include calls to ${classNames.join(', ')}.${name}(). Use file= or className= to narrow scope.`
650
+ };
651
+ }
652
+
65
653
  /**
66
654
  * Analyze a call site to understand how it's being called (AST-based)
67
655
  * @param {object} index - ProjectIndex instance
@@ -121,8 +709,17 @@ function analyzeCallSite(index, call, funcName) {
121
709
  }
122
710
  }
123
711
 
712
+ // Feature A/B: classify the call site by structural context.
713
+ // inLoop/inTry/inCallback come from walking up to the fn boundary.
714
+ // awaited comes from the immediate parent (await_expression).
715
+ // inTestCase is computed by the caller via the enclosing function's
716
+ // entry-point kind — analyzeCallSite doesn't have that info here, so
717
+ // it's left to be filled in by impact()/about() etc. that have
718
+ // access to the enclosing-function symbol.
719
+ const ctx = classifyCallContext(callNode, language);
720
+
124
721
  const argsNode = callNode.childForFieldName('arguments');
125
- if (!argsNode) return { args: [], argCount: 0, isMethodCall };
722
+ if (!argsNode) return { args: [], argCount: 0, isMethodCall, ...ctx };
126
723
 
127
724
  const args = [];
128
725
  for (let i = 0; i < argsNode.namedChildCount; i++) {
@@ -134,13 +731,155 @@ function analyzeCallSite(index, call, funcName) {
134
731
  argCount: args.length,
135
732
  hasSpread: args.some(a => a.startsWith('...')),
136
733
  hasVariable: args.some(a => /^[a-zA-Z_]\w*$/.test(a)),
137
- isMethodCall
734
+ isMethodCall,
735
+ ...ctx,
138
736
  };
139
737
  } catch (e) {
140
738
  return { args: null, argCount: 0 };
141
739
  }
142
740
  }
143
741
 
742
+ /**
743
+ * Argument shape analysis for a call site (used by `example --diverse`).
744
+ *
745
+ * Returns a per-arg list of AST node types ("string_literal", "number_literal",
746
+ * "identifier", "member_expression", "call_expression", "arrow_function",
747
+ * "object", "array", "spread", "other") derived directly from tree-sitter,
748
+ * plus a stable "shape key" that callers can use for clustering.
749
+ *
750
+ * Returns null when the call node can't be located (parse failure, file unreadable).
751
+ *
752
+ * @param {object} index - ProjectIndex instance
753
+ * @param {string} filePath - Absolute file path
754
+ * @param {number} lineNum - 1-indexed line of the call
755
+ * @param {string} funcName - Function name being called
756
+ * @returns {{argKinds: string[], argTexts: string[], argCount: number, shapeKey: string}|null}
757
+ */
758
+ function analyzeCallShape(index, filePath, lineNum, funcName) {
759
+ try {
760
+ const language = detectLanguage(filePath);
761
+ if (!language) return null;
762
+
763
+ // Reuse tree cache to avoid re-parsing during a batch (clustering scans many sites)
764
+ let tree = index._treeCache?.get(filePath);
765
+ if (!tree) {
766
+ const content = index._readFile(filePath);
767
+ if (language === 'html') {
768
+ const htmlModule = getLanguageModule('html');
769
+ const htmlParser = getParser('html');
770
+ const jsParser = getParser('javascript');
771
+ if (!htmlParser || !jsParser) return null;
772
+ const blocks = htmlModule.extractScriptBlocks(content, htmlParser);
773
+ if (blocks.length === 0) return null;
774
+ const virtualJS = htmlModule.buildVirtualJSContent(content, blocks);
775
+ tree = safeParse(jsParser, virtualJS);
776
+ } else {
777
+ const parser = getParser(language);
778
+ if (!parser) return null;
779
+ tree = safeParse(parser, content);
780
+ }
781
+ if (!tree) return null;
782
+ if (!index._treeCache) index._treeCache = new Map();
783
+ index._treeCache.set(filePath, tree);
784
+ }
785
+
786
+ const callTypes = new Set(['call_expression', 'call', 'method_invocation', 'object_creation_expression']);
787
+ const callNode = findCallNode(tree.rootNode, callTypes, lineNum - 1, funcName);
788
+ if (!callNode) return null;
789
+
790
+ const argsNode = callNode.childForFieldName('arguments');
791
+ if (!argsNode) {
792
+ return { argKinds: [], argTexts: [], argCount: 0, shapeKey: '0:' };
793
+ }
794
+
795
+ const argKinds = [];
796
+ const argTexts = [];
797
+ for (let i = 0; i < argsNode.namedChildCount; i++) {
798
+ const argNode = argsNode.namedChild(i);
799
+ argKinds.push(classifyArgNode(argNode));
800
+ argTexts.push(argNode.text.trim());
801
+ }
802
+
803
+ const shapeKey = `${argKinds.length}:${argKinds.join(',')}`;
804
+ return {
805
+ argKinds,
806
+ argTexts,
807
+ argCount: argKinds.length,
808
+ shapeKey,
809
+ };
810
+ } catch (e) {
811
+ return null;
812
+ }
813
+ }
814
+
815
+ /**
816
+ * Map a tree-sitter argument node to a coarse "kind" tag for shape clustering.
817
+ * The mapping is intentionally tight — a call passing `getUser()` should cluster
818
+ * with another call passing `loadConfig()` (both `call_expression`), but NOT
819
+ * with one passing `42` (a `number_literal`).
820
+ *
821
+ * Cross-language note: tree-sitter grammars use slightly different node names
822
+ * (`string_literal` vs `string`, `integer` vs `number_literal`). We canonicalize
823
+ * to a small set so a JS sample and a Python sample produce the same shape key.
824
+ */
825
+ function classifyArgNode(node) {
826
+ if (!node) return 'other';
827
+ const t = node.type;
828
+ // Strings
829
+ if (t === 'string' || t === 'string_literal' || t === 'template_string' ||
830
+ t === 'raw_string_literal' || t === 'interpreted_string_literal') {
831
+ return 'string_literal';
832
+ }
833
+ // Numbers
834
+ if (t === 'number' || t === 'integer' || t === 'float' || t === 'number_literal' ||
835
+ t === 'integer_literal' || t === 'float_literal' || t === 'decimal_integer_literal' ||
836
+ t === 'hex_integer_literal' || t === 'real_literal') {
837
+ return 'number_literal';
838
+ }
839
+ // Booleans + null
840
+ if (t === 'true' || t === 'false' || t === 'null' || t === 'null_literal' ||
841
+ t === 'boolean_literal' || t === 'none' || t === 'nil') {
842
+ return 'literal';
843
+ }
844
+ // Identifiers (bare variable name)
845
+ if (t === 'identifier' || t === 'shorthand_property_identifier' ||
846
+ t === 'name' || t === 'simple_identifier' || t === 'type_identifier') {
847
+ return 'identifier';
848
+ }
849
+ // Member access: obj.attr / obj.method (no call)
850
+ if (t === 'member_expression' || t === 'attribute' || t === 'selector_expression' ||
851
+ t === 'field_expression' || t === 'field_access' || t === 'scoped_identifier') {
852
+ return 'member_expression';
853
+ }
854
+ // Nested calls: foo(getThing())
855
+ if (t === 'call_expression' || t === 'call' || t === 'method_invocation' ||
856
+ t === 'object_creation_expression' || t === 'macro_invocation') {
857
+ return 'call_expression';
858
+ }
859
+ // Anonymous functions
860
+ if (t === 'arrow_function' || t === 'function_expression' || t === 'function' ||
861
+ t === 'lambda' || t === 'closure_expression' || t === 'function_literal' ||
862
+ t === 'lambda_expression') {
863
+ return 'arrow_function';
864
+ }
865
+ // Object/struct literals
866
+ if (t === 'object' || t === 'object_expression' || t === 'dictionary' ||
867
+ t === 'struct_expression' || t === 'composite_literal') {
868
+ return 'object';
869
+ }
870
+ // Array/list literals
871
+ if (t === 'array' || t === 'array_expression' || t === 'list' || t === 'tuple' ||
872
+ t === 'array_literal') {
873
+ return 'array';
874
+ }
875
+ // Spread / unpacking
876
+ if (t === 'spread_element' || t === 'spread' || t === 'list_splat' ||
877
+ t === 'dictionary_splat') {
878
+ return 'spread';
879
+ }
880
+ return 'other';
881
+ }
882
+
144
883
  /**
145
884
  * Identify common calling patterns
146
885
  * @param {Array} callSites - Array of call site objects
@@ -152,15 +891,25 @@ function identifyCallPatterns(callSites, funcName) {
152
891
  constantArgs: 0, // Call sites with literal/constant arguments
153
892
  variableArgs: 0, // Call sites passing variables
154
893
  chainedCalls: 0, // Calls that are part of method chains
155
- awaitedCalls: 0, // Async calls with await
156
- spreadCalls: 0 // Calls using spread operator
894
+ awaitedCalls: 0, // Async calls with await (AST-derived from site.awaited)
895
+ spreadCalls: 0, // Calls using spread operator
896
+ // Feature A: structural classification counts.
897
+ inLoop: 0, // Call sites inside a loop construct
898
+ inTry: 0, // Call sites inside a try block
899
+ inCallback: 0, // Call sites inside a callback fn passed as an argument
900
+ inTestCase: 0 // Call sites whose enclosing function is a test entry
157
901
  };
158
902
 
159
903
  for (const site of callSites) {
160
904
  const expr = site.expression;
161
905
 
162
906
  if (site.hasSpread) patterns.spreadCalls++;
163
- if (/await\s/.test(expr)) patterns.awaitedCalls++;
907
+ // Feature B: prefer the AST-derived `awaited` signal (set by
908
+ // analyzeCallSite's classifyCallContext walk). Fall back to a text
909
+ // check on the expression for callers that still pass legacy sites.
910
+ if (site.awaited === true || (site.awaited !== false && /\bawait\s/.test(expr))) {
911
+ patterns.awaitedCalls++;
912
+ }
164
913
  if (new RegExp('\\.' + escapeRegExp(funcName) + '\\s*\\(').test(expr)) patterns.chainedCalls++;
165
914
 
166
915
  if (site.args && site.args.length > 0) {
@@ -171,6 +920,14 @@ function identifyCallPatterns(callSites, funcName) {
171
920
  if (hasLiteral) patterns.constantArgs++;
172
921
  if (site.hasVariable) patterns.variableArgs++;
173
922
  }
923
+
924
+ // Feature A counters — these flags are set on each site by
925
+ // analyzeCallSite (inLoop/inTry/inCallback) or by the caller after
926
+ // looking up the enclosing function (inTestCase).
927
+ if (site.inLoop) patterns.inLoop++;
928
+ if (site.inTry) patterns.inTry++;
929
+ if (site.inCallback) patterns.inCallback++;
930
+ if (site.inTestCase) patterns.inTestCase++;
174
931
  }
175
932
 
176
933
  return patterns;
@@ -186,7 +943,7 @@ function identifyCallPatterns(callSites, funcName) {
186
943
  function verify(index, name, options = {}) {
187
944
  index._beginOp();
188
945
  try {
189
- const { def } = index.resolveSymbol(name, { file: options.file, className: options.className });
946
+ const { def } = index.resolveSymbol(name, { file: options.file, className: options.className, line: options.line });
190
947
  if (!def) {
191
948
  return { found: false, function: name };
192
949
  }
@@ -194,7 +951,10 @@ function verify(index, name, options = {}) {
194
951
  // (callers don't pass self/cls explicitly: obj.method(a, b) not obj.method(obj, a, b))
195
952
  const fileEntry = index.files.get(def.file);
196
953
  const lang = fileEntry?.language;
197
- let params = def.paramsStructured || [];
954
+ // BUG-BY: enrich types for arrow functions whose types live on the
955
+ // enclosing variable_declarator's type_annotation rather than inline.
956
+ const arrowTypes = extractArrowTypesFromVarDecl(index, def);
957
+ let params = (arrowTypes?.paramsStructured) || def.paramsStructured || [];
198
958
  const selfParams = langTraits(lang)?.selfParam;
199
959
  if (selfParams && params.length > 0 && selfParams.includes(params[0].name)) {
200
960
  params = params.slice(1);
@@ -208,9 +968,18 @@ function verify(index, name, options = {}) {
208
968
 
209
969
  // Get all call sites using findCallers for accurate resolution
210
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;
211
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.
212
981
  includeMethods: true,
213
- includeUncertain: false,
982
+ includeUncertain: verifyIncludeUncertain,
214
983
  targetDefinitions: [def],
215
984
  });
216
985
 
@@ -285,7 +1054,8 @@ function verify(index, name, options = {}) {
285
1054
  });
286
1055
  }
287
1056
 
288
- // Convert caller results to usage-like objects for analyzeCallSite
1057
+ // Convert caller results to usage-like objects for analyzeCallSite.
1058
+ // Carry callerFile/callerStartLine through so we can compute inTestCase.
289
1059
  const calls = callerResults.map(c => ({
290
1060
  file: c.file,
291
1061
  relativePath: c.relativePath,
@@ -293,6 +1063,8 @@ function verify(index, name, options = {}) {
293
1063
  content: c.content,
294
1064
  usageType: 'call',
295
1065
  receiver: c.receiver,
1066
+ callerFile: c.callerFile,
1067
+ callerStartLine: c.callerStartLine,
296
1068
  }));
297
1069
 
298
1070
  const valid = [];
@@ -330,6 +1102,18 @@ function verify(index, name, options = {}) {
330
1102
  return names;
331
1103
  }
332
1104
 
1105
+ // Helper: extract pattern flags (Feature A/B) from analyzeCallSite result.
1106
+ // Reused so each valid/mismatch/uncertain entry carries the same shape.
1107
+ function patternFlagsFrom(a) {
1108
+ return {
1109
+ inLoop: !!a.inLoop,
1110
+ inTry: !!a.inTry,
1111
+ inCallback: !!a.inCallback,
1112
+ awaited: !!a.awaited,
1113
+ // inTestCase filled in below via tagInTestCase
1114
+ };
1115
+ }
1116
+
333
1117
  for (const call of calls) {
334
1118
  const analysis = analyzeCallSite(index, call, name);
335
1119
 
@@ -338,7 +1122,11 @@ function verify(index, name, options = {}) {
338
1122
  // Allow module-level calls only when:
339
1123
  // 1. Receiver matches target file's basename (e.g., jobs == jobs for jobs.py)
340
1124
  // 2. Receiver is an imported name (not a local variable)
341
- if (analysis.isMethodCall && !defIsMethod) {
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) {
342
1130
  const callReceiver = call.receiver;
343
1131
  if (callReceiver && callReceiver === targetBasename) {
344
1132
  const importedNames = getImportedNames(call.file);
@@ -353,18 +1141,32 @@ function verify(index, name, options = {}) {
353
1141
  continue;
354
1142
  }
355
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.
356
1149
  } else {
357
1150
  continue;
358
1151
  }
359
1152
  }
360
1153
 
1154
+ // Carry callerFile/callerStartLine so tagInTestCase can resolve the
1155
+ // enclosing function in a later pass.
1156
+ const carry = {
1157
+ callerFile: call.callerFile,
1158
+ callerStartLine: call.callerStartLine,
1159
+ };
1160
+
361
1161
  if (analysis.args === null) {
362
1162
  // Couldn't parse arguments
363
1163
  uncertain.push({
364
1164
  file: call.relativePath,
365
1165
  line: call.line,
366
1166
  expression: call.content.trim(),
367
- reason: 'Could not parse call arguments'
1167
+ reason: 'Could not parse call arguments',
1168
+ patterns: patternFlagsFrom(analysis),
1169
+ ...carry,
368
1170
  });
369
1171
  continue;
370
1172
  }
@@ -375,7 +1177,9 @@ function verify(index, name, options = {}) {
375
1177
  file: call.relativePath,
376
1178
  line: call.line,
377
1179
  expression: call.content.trim(),
378
- reason: 'Uses spread operator'
1180
+ reason: 'Uses spread operator',
1181
+ patterns: patternFlagsFrom(analysis),
1182
+ ...carry,
379
1183
  });
380
1184
  continue;
381
1185
  }
@@ -386,7 +1190,12 @@ function verify(index, name, options = {}) {
386
1190
  if (hasRest) {
387
1191
  // With rest param, need at least minArgs
388
1192
  if (argCount >= minArgs) {
389
- valid.push({ file: call.relativePath, line: call.line });
1193
+ valid.push({
1194
+ file: call.relativePath,
1195
+ line: call.line,
1196
+ patterns: patternFlagsFrom(analysis),
1197
+ ...carry,
1198
+ });
390
1199
  } else {
391
1200
  mismatches.push({
392
1201
  file: call.relativePath,
@@ -394,13 +1203,20 @@ function verify(index, name, options = {}) {
394
1203
  expression: call.content.trim(),
395
1204
  expected: `at least ${minArgs} arg(s)`,
396
1205
  actual: argCount,
397
- args: analysis.args
1206
+ args: analysis.args,
1207
+ patterns: patternFlagsFrom(analysis),
1208
+ ...carry,
398
1209
  });
399
1210
  }
400
1211
  } else {
401
1212
  // Without rest, need between minArgs and expectedParamCount
402
1213
  if (argCount >= minArgs && argCount <= expectedParamCount) {
403
- valid.push({ file: call.relativePath, line: call.line });
1214
+ valid.push({
1215
+ file: call.relativePath,
1216
+ line: call.line,
1217
+ patterns: patternFlagsFrom(analysis),
1218
+ ...carry,
1219
+ });
404
1220
  } else {
405
1221
  mismatches.push({
406
1222
  file: call.relativePath,
@@ -410,13 +1226,47 @@ function verify(index, name, options = {}) {
410
1226
  ? `${expectedParamCount} arg(s)`
411
1227
  : `${minArgs}-${expectedParamCount} arg(s)`,
412
1228
  actual: argCount,
413
- args: analysis.args
1229
+ args: analysis.args,
1230
+ patterns: patternFlagsFrom(analysis),
1231
+ ...carry,
414
1232
  });
415
1233
  }
416
1234
  }
417
1235
  }
418
1236
  clearTreeCache(index);
419
1237
 
1238
+ // Feature A: tag each entry with `inTestCase` based on its enclosing function.
1239
+ // Done after the per-call loop because tagInTestCase prefers a single pass
1240
+ // through file metadata to avoid repeated lookups.
1241
+ {
1242
+ const { tagInTestCase } = require('./analysis');
1243
+ // Build a flat list of entries that need tagging — each carries
1244
+ // callerFile + callerStartLine + line. tagInTestCase mutates in place.
1245
+ const allSites = [...valid, ...mismatches, ...uncertain].map(s => ({
1246
+ ...s,
1247
+ // Mirror inputs tagInTestCase expects
1248
+ line: s.line,
1249
+ callerFile: s.callerFile,
1250
+ callerStartLine: s.callerStartLine,
1251
+ }));
1252
+ // Use a parallel array so we can write back patterns.inTestCase.
1253
+ tagInTestCase(index, allSites);
1254
+ let i = 0;
1255
+ for (const s of valid) { s.patterns.inTestCase = !!allSites[i++].inTestCase; }
1256
+ for (const s of mismatches) { s.patterns.inTestCase = !!allSites[i++].inTestCase; }
1257
+ for (const s of uncertain) { s.patterns.inTestCase = !!allSites[i++].inTestCase; }
1258
+ }
1259
+
1260
+ // Strip carry fields — they were internal scaffolding for tagInTestCase
1261
+ // and shouldn't appear in the public result.
1262
+ function strip(arr) {
1263
+ for (const s of arr) {
1264
+ delete s.callerFile;
1265
+ delete s.callerStartLine;
1266
+ }
1267
+ }
1268
+ strip(valid); strip(mismatches); strip(uncertain);
1269
+
420
1270
  // Detect scope pollution for methods
421
1271
  let scopeWarning = null;
422
1272
  if (defIsMethod) {
@@ -435,12 +1285,35 @@ function verify(index, name, options = {}) {
435
1285
  }
436
1286
  }
437
1287
 
1288
+ // Feature A/B: build a top-level patterns aggregate across all call
1289
+ // sites verify saw (valid + mismatches + uncertain). Mirrors the shape
1290
+ // identifyCallPatterns returns in impact() so consumers can compare.
1291
+ const allSitesForAgg = [...valid, ...mismatches, ...uncertain].map(s => ({
1292
+ // identifyCallPatterns reads site.expression / site.args / site.hasSpread /
1293
+ // site.hasVariable and the boolean pattern flags.
1294
+ expression: s.expression || '',
1295
+ args: s.args || null,
1296
+ hasSpread: false, // already filtered out into uncertain
1297
+ hasVariable: false, // not propagated from analyzeCallSite here; harmless
1298
+ awaited: !!(s.patterns && s.patterns.awaited),
1299
+ inLoop: !!(s.patterns && s.patterns.inLoop),
1300
+ inTry: !!(s.patterns && s.patterns.inTry),
1301
+ inCallback: !!(s.patterns && s.patterns.inCallback),
1302
+ inTestCase: !!(s.patterns && s.patterns.inTestCase),
1303
+ }));
1304
+ const patternsAgg = identifyCallPatterns(allSitesForAgg, name);
1305
+
438
1306
  return {
439
1307
  found: true,
440
1308
  function: name,
441
1309
  file: def.relativePath,
442
1310
  startLine: def.startLine,
443
- signature: index.formatSignature(def),
1311
+ // BUG-BV: use local TS-correct param formatter (`opt?: number`, not `opt: number?`).
1312
+ // BUG-BY: when the def is a typed arrow declaration, render with enriched types.
1313
+ signature: formatTypedSignature(def, arrowTypes ? {
1314
+ paramsStructured: arrowTypes.paramsStructured,
1315
+ returnType: arrowTypes.returnType
1316
+ } : {}),
444
1317
  params: params.map(p => ({
445
1318
  name: p.name,
446
1319
  optional: p.optional || p.default !== undefined,
@@ -451,8 +1324,10 @@ function verify(index, name, options = {}) {
451
1324
  valid: valid.length,
452
1325
  mismatches: mismatches.length,
453
1326
  uncertain: uncertain.length,
1327
+ validDetails: valid,
454
1328
  mismatchDetails: mismatches,
455
1329
  uncertainDetails: uncertain,
1330
+ patterns: patternsAgg,
456
1331
  scopeWarning
457
1332
  };
458
1333
  } finally { index._endOp(); }
@@ -473,11 +1348,40 @@ function plan(index, name, options = {}) {
473
1348
  return { found: false, function: name };
474
1349
  }
475
1350
 
476
- const resolved = index.resolveSymbol(name, { file: options.file, className: options.className });
1351
+ const resolved = index.resolveSymbol(name, { file: options.file, className: options.className, line: options.line });
477
1352
  const def = resolved.def || definitions[0];
478
- const impact = index.impact(name, { file: options.file, className: options.className });
479
- const currentParams = def.paramsStructured || [];
480
- const currentSignature = index.formatSignature(def);
1353
+ // BUG-BY: enrich types for typed-arrow-fn declarations.
1354
+ const arrowTypes = extractArrowTypesFromVarDecl(index, def);
1355
+ const currentParams = (arrowTypes?.paramsStructured) || def.paramsStructured || [];
1356
+ // 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);
1368
+ const impactScopeWarning = computePlanScopeWarning(index, name, def, options);
1369
+
1370
+ // Reject ambiguous multi-op invocations rather than silently coalescing.
1371
+ // The previous behavior reported only the *last* operation in the
1372
+ // headline, which made plan output untrustworthy for multi-op refactors.
1373
+ const requestedOps = [
1374
+ options.addParam ? 'addParam' : null,
1375
+ options.removeParam ? 'removeParam' : null,
1376
+ options.renameTo ? 'renameTo' : null,
1377
+ ].filter(Boolean);
1378
+ if (requestedOps.length > 1) {
1379
+ return {
1380
+ found: true,
1381
+ function: name,
1382
+ error: `plan accepts one operation at a time; got ${requestedOps.length}: ${requestedOps.join(', ')}. Run separately and compose results.`,
1383
+ };
1384
+ }
481
1385
 
482
1386
  let newParams = [...currentParams];
483
1387
  let newSignature = currentSignature;
@@ -522,32 +1426,28 @@ function plan(index, name, options = {}) {
522
1426
  }
523
1427
  }
524
1428
 
525
- // Generate new signature
526
- const paramsList = newParams.map(p => {
527
- let str = (p.rest && !p.name.startsWith('*')) ? `...${p.name}` : p.name;
528
- if (p.optional && !p.default) str += '?';
529
- if (p.type) str += `: ${p.type}`;
530
- if (p.default) str += ` = ${p.default}`;
531
- return str;
532
- }).join(', ');
533
- const asyncPrefix = (def.async || def.isAsync || def.modifiers?.includes('async')) ? 'async ' : '';
534
- newSignature = `${asyncPrefix}${name}(${paramsList})`;
535
- if (def.returnType) newSignature += `: ${def.returnType}`;
1429
+ // Generate new signature with TS-correct optional marker (BUG-BV)
1430
+ // and arrow-fn enriched return type (BUG-BY).
1431
+ // BUG-5: preserve all modifier tokens (async/static/public/...).
1432
+ const paramsList = newParams.map(formatTypedParam).filter(Boolean).join(', ');
1433
+ const modTokens = computeModifierTokens(def);
1434
+ const modPrefix = modTokens.length ? modTokens.join(' ') + ' ' : '';
1435
+ newSignature = `${modPrefix}${name}(${paramsList})`;
1436
+ const newRet = arrowTypes?.returnType || def.returnType;
1437
+ if (newRet) newSignature += `: ${newRet}`;
536
1438
 
537
1439
  // Describe changes needed at each call site
538
- for (const fileGroup of impact.byFile) {
539
- for (const site of fileGroup.sites) {
540
- const suggestion = options.defaultValue
541
- ? `No change needed (has default value)`
542
- : `Add argument: ${options.addParam}`;
543
- changes.push({
544
- file: site.file,
545
- line: site.line,
546
- expression: site.expression,
547
- suggestion,
548
- args: site.args
549
- });
550
- }
1440
+ for (const site of planCallSites) {
1441
+ const suggestion = options.defaultValue
1442
+ ? `No change needed (has default value)`
1443
+ : `Add argument: ${options.addParam}`;
1444
+ changes.push({
1445
+ file: site.file,
1446
+ line: site.line,
1447
+ expression: site.expression,
1448
+ suggestion,
1449
+ args: site.args
1450
+ });
551
1451
  }
552
1452
  }
553
1453
 
@@ -570,17 +1470,15 @@ function plan(index, name, options = {}) {
570
1470
 
571
1471
  newParams = currentParams.filter(p => p.name !== removeTarget);
572
1472
 
573
- // Generate new signature
574
- const paramsList = newParams.map(p => {
575
- let str = (p.rest && !p.name.startsWith('*')) ? `...${p.name}` : p.name;
576
- if (p.optional && !p.default) str += '?';
577
- if (p.type) str += `: ${p.type}`;
578
- if (p.default) str += ` = ${p.default}`;
579
- return str;
580
- }).join(', ');
581
- const asyncPrefix = (def.async || def.isAsync || def.modifiers?.includes('async')) ? 'async ' : '';
582
- newSignature = `${asyncPrefix}${name}(${paramsList})`;
583
- if (def.returnType) newSignature += `: ${def.returnType}`;
1473
+ // Generate new signature with TS-correct optional marker (BUG-BV)
1474
+ // and arrow-fn enriched return type (BUG-BY).
1475
+ // BUG-5: preserve all modifier tokens (async/static/public/...).
1476
+ const paramsList = newParams.map(formatTypedParam).filter(Boolean).join(', ');
1477
+ const modTokens = computeModifierTokens(def);
1478
+ const modPrefix = modTokens.length ? modTokens.join(' ') + ' ' : '';
1479
+ newSignature = `${modPrefix}${name}(${paramsList})`;
1480
+ const newRet = arrowTypes?.returnType || def.returnType;
1481
+ if (newRet) newSignature += `: ${newRet}`;
584
1482
 
585
1483
  // For Python/Rust methods, self/cls/&self/&mut self is in paramsStructured
586
1484
  // but callers don't pass it. Adjust paramIndex to caller-side position.
@@ -594,17 +1492,15 @@ function plan(index, name, options = {}) {
594
1492
  const callerArgIndex = paramIndex - selfOffset;
595
1493
 
596
1494
  // Describe changes at each call site
597
- for (const fileGroup of impact.byFile) {
598
- for (const site of fileGroup.sites) {
599
- if (site.args && site.argCount > callerArgIndex) {
600
- changes.push({
601
- file: site.file,
602
- line: site.line,
603
- expression: site.expression,
604
- suggestion: `Remove argument ${callerArgIndex + 1}: ${site.args[callerArgIndex] || '?'}`,
605
- args: site.args
606
- });
607
- }
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
+ });
608
1504
  }
609
1505
  }
610
1506
  }
@@ -614,20 +1510,18 @@ function plan(index, name, options = {}) {
614
1510
  newSignature = currentSignature.replace(new RegExp('\\b' + escapeRegExp(name) + '\\b'), options.renameTo);
615
1511
 
616
1512
  // All call sites need renaming
617
- for (const fileGroup of impact.byFile) {
618
- for (const site of fileGroup.sites) {
619
- const newExpression = site.expression.replace(
620
- new RegExp('\\b' + escapeRegExp(name) + '\\b'),
621
- options.renameTo
622
- );
623
- changes.push({
624
- file: site.file,
625
- line: site.line,
626
- expression: site.expression,
627
- suggestion: `Rename to: ${newExpression}`,
628
- newExpression
629
- });
630
- }
1513
+ for (const site of planCallSites) {
1514
+ const newExpression = site.expression.replace(
1515
+ new RegExp('\\b' + escapeRegExp(name) + '\\b'),
1516
+ options.renameTo
1517
+ );
1518
+ changes.push({
1519
+ file: site.file,
1520
+ line: site.line,
1521
+ expression: site.expression,
1522
+ suggestion: `Rename to: ${newExpression}`,
1523
+ newExpression
1524
+ });
631
1525
  }
632
1526
 
633
1527
  // Also include import statements that reference the renamed function
@@ -662,26 +1556,19 @@ function plan(index, name, options = {}) {
662
1556
  operation,
663
1557
  before: {
664
1558
  signature: currentSignature,
665
- params: currentParams.map(p => {
666
- let n = p.name;
667
- if (p.optional && !p.default) n += '?';
668
- if (p.type) n += `: ${p.type}`;
669
- return n;
670
- })
1559
+ // BUG-BV: TS-correct optional marker (`opt?: number`); test contract
1560
+ // expects name-keyed array entries (no ` = default`, no rest prefix)
1561
+ // so callers can `.includes('paramName')` for exact match.
1562
+ params: currentParams.map(p => formatPlanParamName(p)).filter(Boolean)
671
1563
  },
672
1564
  after: {
673
1565
  signature: newSignature,
674
- params: newParams.map(p => {
675
- let n = p.name;
676
- if (p.optional && !p.default) n += '?';
677
- if (p.type) n += `: ${p.type}`;
678
- return n;
679
- })
1566
+ params: newParams.map(p => formatPlanParamName(p)).filter(Boolean)
680
1567
  },
681
1568
  totalChanges: changes.length,
682
1569
  filesAffected: new Set(changes.map(c => c.file)).size,
683
1570
  changes,
684
- scopeWarning: impact?.scopeWarning || null
1571
+ scopeWarning: impactScopeWarning
685
1572
  };
686
1573
  } finally { index._endOp(); }
687
1574
  }
@@ -760,4 +1647,4 @@ function analyzeCallSiteAST(index, filePath, lineNum, funcName) {
760
1647
  return result;
761
1648
  }
762
1649
 
763
- module.exports = { verify, plan, analyzeCallSite, analyzeCallSiteAST, findCallNode, clearTreeCache, identifyCallPatterns };
1650
+ module.exports = { verify, plan, analyzeCallSite, analyzeCallSiteAST, analyzeCallShape, classifyArgNode, findCallNode, clearTreeCache, identifyCallPatterns };