ucn 5.3.8 → 5.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,6 +28,101 @@ const CALLABLE_NODES = new Set([
28
28
  'operator_declaration', 'conversion_operator_declaration',
29
29
  ]);
30
30
 
31
+ const DECLARATION_NODES = {
32
+ class: new Set(['class_declaration', 'abstract_class_declaration', 'class_definition', 'class_specifier', 'class']),
33
+ struct: new Set(['struct_item', 'struct_specifier', 'struct_declaration', 'type_spec']),
34
+ interface: new Set(['interface_declaration', 'type_spec']),
35
+ type: new Set(['type_alias_declaration', 'type_definition', 'type_spec', 'type_alias', 'type_item', 'associated_type']),
36
+ enum: new Set(['enum_declaration', 'enum_item', 'enum_specifier']),
37
+ trait: new Set(['trait_item']),
38
+ impl: new Set(['impl_item']),
39
+ record: new Set(['record_declaration']),
40
+ field: new Set(['field_definition', 'public_field_definition', 'property_signature', 'field_declaration', 'variable_declarator']),
41
+ state: new Set(['variable_declarator', 'assignment', 'init_declarator', 'const_item', 'static_item', 'const_spec', 'var_spec']),
42
+ };
43
+ const FUNCTION_EXPRESSIONS = new Set([
44
+ 'function_expression', 'generator_function', 'arrow_function', 'lambda',
45
+ 'func_literal', 'closure_expression', 'lambda_expression', 'anonymous_method_expression',
46
+ ]);
47
+ const COMMENT_NODES = new Set(['comment', 'line_comment', 'block_comment']);
48
+
49
+ /**
50
+ * Compare declarations by AST tokens, not lines: a class header and its first
51
+ * method can share a line. Concrete methods belong to the callable diff;
52
+ * bodyless signatures, fields (including unindexed Python assignments), and
53
+ * nested types belong to the declaration. Function-valued fields retain their
54
+ * signature but not their executable body. Nothing here is persisted in the
55
+ * index, so old and current source use the same projection without a cache bump.
56
+ * Missing AST mappings return no snapshot; callers keep conservative reporting.
57
+ */
58
+ function declarationSnapshots(content, language, symbols) {
59
+ const snapshots = new Map();
60
+ if (content == null || symbols.length === 0) return snapshots;
61
+ const parser = getParser(language);
62
+ if (!parser) return snapshots;
63
+ const root = safeParse(parser, content).rootNode;
64
+ const wantedKinds = new Set(symbols.flatMap(s => [...(DECLARATION_NODES[s.type] || [])]));
65
+ const candidates = new Map();
66
+ walkNamed(root, node => {
67
+ if (!wantedKinds.has(node.type)) return;
68
+ if (!candidates.has(node.type)) candidates.set(node.type, []);
69
+ candidates.get(node.type).push(node);
70
+ });
71
+ const hasName = (node, name) => {
72
+ if (!node) return false;
73
+ if (node.text === name) return true;
74
+ return hasName(node.childForFieldName('name') || node.childForFieldName('declarator') ||
75
+ (node.type === 'generic_type' && node.childForFieldName('type')), name);
76
+ };
77
+ for (const symbol of symbols) {
78
+ const kinds = DECLARATION_NODES[symbol.type];
79
+ if (!kinds) continue;
80
+ let declaration = null;
81
+ for (const kind of kinds) {
82
+ for (const node of candidates.get(kind) || []) {
83
+ if (node.startPosition.row + 1 < symbol.startLine ||
84
+ node.endPosition.row + 1 > (symbol.endLine || symbol.startLine)) continue;
85
+ const name = node.childForFieldName('name') || node.childForFieldName('declarator') ||
86
+ node.childForFieldName('left') || (symbol.type === 'impl' && node.childForFieldName('type'));
87
+ if (!hasName(name, symbol.typeName || symbol.name)) continue;
88
+ if (!declaration || node.endIndex - node.startIndex > declaration.endIndex - declaration.startIndex) {
89
+ declaration = node;
90
+ }
91
+ }
92
+ }
93
+ if (!declaration || declaration.hasError) continue;
94
+ // Export modifiers and Python decorators live outside the declaration.
95
+ while (['export_statement', 'decorated_definition'].includes(declaration.parent?.type) &&
96
+ declaration.parent.startPosition.row + 1 >= symbol.startLine) declaration = declaration.parent;
97
+ const tokens = [];
98
+ const lines = new Set();
99
+ const visit = node => {
100
+ if (COMMENT_NODES.has(node.type) || node.type === 'pass_statement') return;
101
+ if (node.type === 'decorated_definition' &&
102
+ CALLABLE_NODES.has(node.childForFieldName('definition')?.type)) return;
103
+ // Class docstrings are documentation, not fields or inheritance.
104
+ if (language === 'python' && node.type === 'expression_statement' &&
105
+ node.namedChildCount === 1 && ['string', 'concatenated_string'].includes(node.namedChild(0).type)) return;
106
+ const body = CALLABLE_NODES.has(node.type) ? node.childForFieldName('body') : null;
107
+ if (body && !FUNCTION_EXPRESSIONS.has(node.type)) return;
108
+ if (node.childCount === 0) {
109
+ // Empty anonymous semicolons between members are separators.
110
+ if (node.type === ';' && ['class_body', 'interface_body', 'declaration_list'].includes(node.parent?.type)) return;
111
+ tokens.push([node.type, node.text]);
112
+ for (let line = node.startPosition.row + 1; line <= node.endPosition.row + 1; line++) lines.add(line);
113
+ return;
114
+ }
115
+ for (const child of node.children) {
116
+ if (body && child.id === body.id) continue;
117
+ visit(child);
118
+ }
119
+ };
120
+ visit(declaration);
121
+ snapshots.set(symbol, { signature: JSON.stringify(tokens), lines });
122
+ }
123
+ return snapshots;
124
+ }
125
+
31
126
  const BRANCH_NODES = new Set([
32
127
  'if_statement', 'if_expression', 'elif_clause',
33
128
  'for_statement', 'for_in_statement', 'for_expression',
@@ -375,6 +470,7 @@ function projectComputedDispatch(index) {
375
470
  }
376
471
 
377
472
  module.exports = {
473
+ declarationSnapshots,
378
474
  computeAstComplexity,
379
475
  computedDispatchSites,
380
476
  projectComputedDispatch,
package/core/bridge.js CHANGED
@@ -26,7 +26,7 @@
26
26
  'use strict';
27
27
 
28
28
  const fs = require('fs');
29
- const { codeUnitCompare } = require('./shared');
29
+ const { codeUnitCompare, isTestPath } = require('./shared');
30
30
  const path = require('path');
31
31
  const { getCachedCalls } = require('./callers');
32
32
  const { getParser, safeParse } = require('../languages');
@@ -1490,9 +1490,12 @@ function endpoints(index, options = {}) {
1490
1490
  showUncertain: options.showUncertain !== false,
1491
1491
  };
1492
1492
 
1493
- let routes = opts.clientOnly ? [] : extractServerRoutes(index);
1494
- let requests = (opts.serverOnly ? [] : extractClientRequests(index));
1495
- let uncertainRequests = opts.serverOnly ? [] : (index._endpointsCache?.uncertainRequests || []);
1493
+ const label = r => ({ ...r, isTest: isTestPath(r.file) });
1494
+ const inScope = r => index.matchesFilters(r.file, { in: options.in }) &&
1495
+ !(options.excludeTests && r.isTest);
1496
+ let routes = (opts.clientOnly ? [] : extractServerRoutes(index)).map(label).filter(inScope);
1497
+ let requests = (opts.serverOnly ? [] : extractClientRequests(index)).map(label).filter(inScope);
1498
+ let uncertainRequests = (opts.serverOnly ? [] : (index._endpointsCache?.uncertainRequests || [])).map(label).filter(inScope);
1496
1499
  if (uncertainRequests.length > 0) {
1497
1500
  // A server route registration (`@app.get("/x")`, `router.get("/x", h)`)
1498
1501
  // is request-shaped too; the route inventory already owns those lines.
@@ -1514,12 +1517,14 @@ function endpoints(index, options = {}) {
1514
1517
  uncertainRequests = uncertainRequests.filter(r => r.method.toUpperCase() === opts.method || r.method === 'request');
1515
1518
  }
1516
1519
 
1517
- let bridges = opts.bridge ? bridgeEndpoints(index) : [];
1520
+ let bridges = opts.bridge ? bridgeEndpoints(index).map(b => ({
1521
+ ...b, route: label(b.route), request: label(b.request),
1522
+ })) : [];
1518
1523
  if (!opts.showUncertain) {
1519
1524
  bridges = bridges.filter(b => b.matchType !== 'uncertain');
1520
1525
  }
1521
1526
  // If user filtered routes/requests, also constrain bridges
1522
- if (opts.method || opts.prefix) {
1527
+ if (opts.method || opts.prefix || options.in || options.excludeTests) {
1523
1528
  const routeKeys = new Set(routes.map(r => `${r.absoluteFile}:${r.line}:${r.method}:${r.path}`));
1524
1529
  const reqKeys = new Set(requests.map(r => `${r.absoluteFile}:${r.line}:${r.method}:${r.path}`));
1525
1530
  bridges = bridges.filter(b =>
package/core/cache.js CHANGED
@@ -713,7 +713,8 @@ function clearAllCaches() {
713
713
  // v225 (fix #357): Rust `use path::name as local` bindings record the original name with a paired `renames` alias.
714
714
  // v226: bundled/minified filename exclusions are disclosed in discoveryIssues.
715
715
  // v227: signature parameter/return text excludes AST comments in every language.
716
- const CACHE_FORMAT_VERSION = 227;
716
+ // v228: Python keyword arguments retain the same callable-reference facts as positional arguments.
717
+ const CACHE_FORMAT_VERSION = 228;
717
718
  const USAGE_CACHE_FILE = 'usage-results.json';
718
719
 
719
720
  /**
package/core/callers.js CHANGED
@@ -291,6 +291,22 @@ function _javaConstructorDisposition(index, filePath, fileEntry, call, targetDef
291
291
  return 'unknown';
292
292
  }
293
293
 
294
+ // A structural member passed as a value is not invocation syntax. Keep it
295
+ // in the callback model only when a project member can actually be callable;
296
+ // an unrelated standalone function's spelling is not such evidence. Module
297
+ // members remain eligible because modules can export standalone functions.
298
+ function isDataMemberReference(fileEntry, call, definitions) {
299
+ if (!call.isFunctionReference || !call.isMethod || call.receiverIsModule ||
300
+ call.receiverModuleSpecifier || langTraits(fileEntry?.language)?.typeSystem !== 'structural') return false;
301
+ if (_structuralModuleBindings(fileEntry, call).length > 0) return false;
302
+ return !definitions.some(def => !NON_CALLABLE_TYPES.has(def.type) &&
303
+ // A typed Python descriptor read really invokes its getter; retain
304
+ // that existing proof path. An untyped property spelling belongs to
305
+ // the separate property-access inventory, never the call band.
306
+ (!require('./accessors').isAccessorDefinition(def) || call.receiverType) &&
307
+ (def.className || def.receiver));
308
+ }
309
+
294
310
  /**
295
311
  * Find all call sites that invoke the named symbol.
296
312
  *
@@ -697,6 +713,7 @@ function findCallers(index, name, options = {}) {
697
713
  langTraits(fileEntry.language)?.typeSystem === 'structural';
698
714
 
699
715
  for (let call of calls) {
716
+ if (isDataMemberReference(fileEntry, call, options.targetDefinitions || definitions)) continue;
700
717
  // fix #353: C# `Beta.Helper.Widget()` — the parser records a
701
718
  // field hop rooted at `this` (Beta is no local). When the
702
719
  // prefix names a project NAMESPACE that declares the last
@@ -5444,7 +5461,8 @@ function findCallees(index, definition, options = {}) {
5444
5461
  // to this definition's source range. Nested closures deliberately
5445
5462
  // remain in the slice and retain the existing inner-symbol rules.
5446
5463
  const calls = _callsInDefinitionRange(index, def.file, allCalls,
5447
- def.startLine, def.endLine);
5464
+ def.startLine, def.endLine).filter(call => !isDataMemberReference(
5465
+ index.files.get(def.file), call, index.symbols.get(call.name) || []));
5448
5466
  // The reachability walk uses the legacy (non-accounting) path and most
5449
5467
  // entry/test symbols contain no calls. Avoid constructing receiver,
5450
5468
  // overload, and flow machinery for an empty source range. Contract
@@ -5545,7 +5563,8 @@ function findCallees(index, definition, options = {}) {
5545
5563
  if (!entry) {
5546
5564
  const defs = index.symbols.get(call.name) || [];
5547
5565
  const owners = defs.filter(s => !NON_CALLABLE_TYPES.has(s.type)).length;
5548
- entry = { name: call.name, reason, callCount: 0, sites: [], ownerCount: owners, ...meta };
5566
+ entry = { name: call.name, reason, callCount: 0, sites: [], ownerCount: owners,
5567
+ ...(call.isFunctionReference && { functionReference: true }), ...meta };
5549
5568
  unverifiedCallees.set(key, entry);
5550
5569
  }
5551
5570
  entry.callCount++;
package/core/check.js CHANGED
@@ -73,6 +73,11 @@ function check(index, options = {}) {
73
73
  const modified = (dr && Array.isArray(dr.functions)) ? dr.functions : [];
74
74
  const added = (dr && Array.isArray(dr.newFunctions)) ? dr.newFunctions : [];
75
75
  const deleted = (dr && Array.isArray(dr.deletedFunctions)) ? dr.deletedFunctions : [];
76
+ const declarations = [
77
+ ...(dr?.symbols || []).map(s => ({ ...s, _kind: 'modified', _nonCallable: true })),
78
+ ...(dr?.newSymbols || []).map(s => ({ ...s, _kind: 'added', _nonCallable: true })),
79
+ ...(dr?.deletedSymbols || []).map(s => ({ ...s, _kind: 'deleted', _nonCallable: true })),
80
+ ];
76
81
  const pathCounts = {
77
82
  changedPaths: dr?.changedPaths || 0,
78
83
  nonSourcePaths: dr?.nonSourcePaths || 0,
@@ -82,9 +87,10 @@ function check(index, options = {}) {
82
87
  const allChanged = [
83
88
  ...modified.map(f => ({ ...f, _kind: 'modified' })),
84
89
  ...added.map(f => ({ ...f, _kind: 'added' })),
90
+ ...declarations,
85
91
  ];
86
92
 
87
- if (!dr || (modified.length === 0 && added.length === 0 && deleted.length === 0)) {
93
+ if (!dr || (allChanged.length === 0 && deleted.length === 0)) {
88
94
  // fix #283: say what the diff actually contained. "no changes
89
95
  // detected" with three changed files reads as a false negative in a
90
96
  // pre-commit hook — the truth is the changes are outside what the
@@ -95,7 +101,7 @@ function check(index, options = {}) {
95
101
  if (changedPaths > 0 && nonSourcePaths === changedPaths) {
96
102
  reason = `${changedPaths} changed path(s), all outside supported source files; untracked source files are included`;
97
103
  } else if (changedPaths > 0) {
98
- reason = 'no callable-symbol changes in the diff or untracked source files';
104
+ reason = 'no indexed-symbol changes in the diff or untracked source files';
99
105
  }
100
106
  return {
101
107
  base: options.base || 'HEAD',
@@ -116,9 +122,23 @@ function check(index, options = {}) {
116
122
  // For each changed function, run verify and gather caller summary
117
123
  for (const fn of changed) {
118
124
  const filePath = fn.relativePath || fn.file || '';
125
+ if (fn._nonCallable) {
126
+ const dependency = fn.impact?.typeReferences || fn.impact?.propertyAccesses;
127
+ items.push({
128
+ name: fn.name, file: filePath, line: fn.startLine, kind: fn._kind,
129
+ symbolType: fn.type, requiresToolchainValidation: true,
130
+ callerCount: fn.impact?.totalCallSites || 0,
131
+ unverifiedCallerCount: fn.impact?.unverifiedSites?.length || 0,
132
+ dependencyCount: dependency?.confirmedCount || 0,
133
+ unverifiedDependencyCount: (dependency?.unverifiedCount || 0) + (fn.remainingReferences?.length || 0),
134
+ signatureMismatches: 0,
135
+ account: summarizeAccount(fn.impact?.account),
136
+ });
137
+ continue;
138
+ }
119
139
  let verifyResult;
120
140
  try {
121
- verifyResult = index.verify(fn.name, { file: filePath });
141
+ verifyResult = index.verify(fn.name, { file: filePath, line: fn.startLine });
122
142
  } catch (e) {
123
143
  verifyResult = null;
124
144
  }
@@ -225,6 +245,13 @@ function check(index, options = {}) {
225
245
  // Action items
226
246
  const actions = [];
227
247
  for (const it of items) {
248
+ if (it.requiresToolchainValidation) {
249
+ actions.push({
250
+ severity: it.kind === 'added' ? 'warn' : 'error',
251
+ kind: 'declaration_change',
252
+ message: `${it.name}: ${it.kind} ${it.symbolType}; shape/inheritance compatibility is not checked by arity analysis — review dependency sites and run the compiler/type checker and tests`,
253
+ });
254
+ }
228
255
  if (!it.account || !it.account.textComplete) {
229
256
  actions.push({
230
257
  severity: 'error',
@@ -272,7 +299,7 @@ function check(index, options = {}) {
272
299
  actions.push({
273
300
  severity: 'error',
274
301
  kind: 'truncated_change_set',
275
- message: `${allChanged.length - limit} changed function(s) were not checked; rerun without --limit`,
302
+ message: `${allChanged.length - limit} changed symbol(s) were not checked; rerun without --limit`,
276
303
  });
277
304
  }
278
305
  if (testFiles.length > 0) {
@@ -289,9 +316,10 @@ function check(index, options = {}) {
289
316
  const signatureMismatches = items.reduce((sum, it) => sum + (it.signatureMismatches || 0), 0);
290
317
  const filteredEdges = items.reduce((sum, it) => sum + (it.account ? it.account.filtered || 0 : 0), 0);
291
318
  const usageReviewSymbols = items.filter(it => it.account && it.account.requiresUsageReview).length;
319
+ const unvalidatedDeclarations = items.filter(it => it.requiresToolchainValidation && it.kind !== 'added').length;
292
320
  const reviewRequired = incompleteAccounts > 0 || unverifiedCallSites > 0 ||
293
321
  signatureMismatches > 0 || filteredEdges > 0 || usageReviewSymbols > 0 ||
294
- actions.some(a => a.kind === 'orphan_new') ||
322
+ actions.some(a => a.kind === 'orphan_new' || a.kind === 'declaration_change') ||
295
323
  !!(limit && allChanged.length > limit);
296
324
 
297
325
  return {
@@ -308,7 +336,7 @@ function check(index, options = {}) {
308
336
  totalTests: testCount,
309
337
  actions,
310
338
  trust: {
311
- status: incompleteAccounts > 0 || signatureMismatches > 0
339
+ status: incompleteAccounts > 0 || signatureMismatches > 0 || unvalidatedDeclarations > 0
312
340
  ? 'BLOCKED'
313
341
  : reviewRequired ? 'REVIEW_REQUIRED' : 'READY_FOR_TOOLCHAIN',
314
342
  accountsChecked: items.filter(it => it.account && it.account.available).length,
@@ -317,6 +345,7 @@ function check(index, options = {}) {
317
345
  signatureMismatches,
318
346
  filteredEdges,
319
347
  usageReviewSymbols,
348
+ unvalidatedDeclarations,
320
349
  semanticComplete: false,
321
350
  safeToDelete: false,
322
351
  requiresCompilerAndTests: true,
@@ -301,7 +301,7 @@ const COMMAND_CONTRACTS = Object.freeze({
301
301
  { name: 'bridge', when: '`bridge=true`.', answer: 'Exact/parameterized route-request matches plus unmatched sides.' },
302
302
  { name: 'unmatched', when: '`unmatched=true`.', answer: 'Only unmatched supported boundaries.' },
303
303
  ],
304
- defaults: ['Framework-specific static extraction.', 'Interpolated-path uncertainty remains visible unless hidden explicitly.'],
304
+ defaults: ['Framework-specific static extraction.', 'Test routes/requests are labeled; excludeTests=true hides them and in scopes a directory.', 'Interpolated-path uncertainty remains visible unless hidden explicitly.'],
305
305
  truth: 'Findings are static matches for supported framework call/decorator shapes; bridge confidence is match quality, not runtime probability.',
306
306
  nonGoals: ['Network discovery.', 'Frameworks not represented by an endpoint adapter.'],
307
307
  invalidCombinations: ['`serverOnly` and `clientOnly` cannot both describe a useful result.'],
package/core/execute.js CHANGED
@@ -1462,6 +1462,7 @@ const HANDLERS = {
1462
1462
  unused: p.unused || false,
1463
1463
  caseSensitive: p.caseSensitive || false,
1464
1464
  exclude,
1465
+ testExclude: p.includeTests ? undefined : ['test files'],
1465
1466
  in: p.in,
1466
1467
  file: p.file,
1467
1468
  top: topVal || (p.lines ? undefined : 50),
@@ -1470,13 +1471,15 @@ const HANDLERS = {
1470
1471
  const unsupported = (!p.regex && (p.term || p.name))
1471
1472
  ? require('./account').scanUnsupportedFiles(index, p.term || p.name)
1472
1473
  : null;
1473
- let note;
1474
+ let note = result.meta.filesSkipped > 0
1475
+ ? `${result.meta.filesSkipped} test file(s) hidden by default (--include-tests).`
1476
+ : undefined;
1474
1477
  if (unsupported?.lines > 0) {
1475
1478
  Object.defineProperty(result, 'unsupportedMatches', {
1476
1479
  value: unsupported,
1477
1480
  enumerable: false, writable: true, configurable: true,
1478
1481
  });
1479
- note = `${unsupported.lines} matching line(s) in ${unsupported.fileCount} unsupported-language file(s) were not structurally analyzed; verify with grep/ripgrep.`;
1482
+ note = combineNotes([note, `${unsupported.lines} matching line(s) in ${unsupported.fileCount} unsupported-language file(s) were not structurally analyzed; verify with grep/ripgrep.`]);
1480
1483
  }
1481
1484
  return { ok: true, result, structural: true, note };
1482
1485
  }
@@ -1793,6 +1796,8 @@ const HANDLERS = {
1793
1796
  method: normMethod,
1794
1797
  prefix: p.prefix || null,
1795
1798
  showUncertain: !p.hideUncertain,
1799
+ in: p.in,
1800
+ excludeTests: !!p.excludeTests,
1796
1801
  });
1797
1802
  if (p.framework != null && String(p.framework).trim() !== '') {
1798
1803
  const framework = String(p.framework).trim().toLowerCase();
@@ -1856,6 +1861,7 @@ const HANDLERS = {
1856
1861
  // Recompute meta after filtering
1857
1862
  result.meta = {
1858
1863
  totalRoutes: result.routes.length,
1864
+ testRoutes: result.routes.filter(r => r.isTest).length,
1859
1865
  totalRequests: result.requests.length,
1860
1866
  uncertainRequests: (result.uncertainRequests || []).length,
1861
1867
  totalBridges: result.bridges.length,
@@ -2283,7 +2289,7 @@ const HANDLERS = {
2283
2289
  const limit = num(p.limit, undefined);
2284
2290
  let note;
2285
2291
  if (limit && limit > 0 && result) {
2286
- const groups = ['functions', 'moduleLevelChanges', 'newFunctions', 'deletedFunctions'];
2292
+ const groups = ['functions', 'symbols', 'newSymbols', 'deletedSymbols', 'moduleLevelChanges', 'newFunctions', 'deletedFunctions'];
2287
2293
  const total = groups.reduce((sum, key) => sum + (result[key]?.length || 0), 0);
2288
2294
  if (total > limit) {
2289
2295
  let remaining = limit;
@@ -2506,6 +2512,18 @@ function execute(index, command, params = {}) {
2506
2512
  const validationError = validatePublicParams(command, params);
2507
2513
  if (validationError) return { ok: false, error: validationError };
2508
2514
  }
2515
+ // Public JSON paths and pasted stack frames may be absolute. Resolve
2516
+ // only indexed files, then use the same relative scope as our handles.
2517
+ const relativeIndexedFile = file => {
2518
+ if (!file || !path.isAbsolute(file)) return file;
2519
+ const resolved = index.resolveFilePathForQuery(file);
2520
+ return typeof resolved === 'string' ? index.files.get(resolved).relativePath : file;
2521
+ };
2522
+ if (params.file) params.file = relativeIndexedFile(params.file);
2523
+ const absoluteHandle = params.name && parseSymbolHandle(params.name);
2524
+ if (absoluteHandle && path.isAbsolute(absoluteHandle.file)) {
2525
+ params.name = relativeIndexedFile(absoluteHandle.file) + params.name.slice(absoluteHandle.file.length);
2526
+ }
2509
2527
  // Resolve name-less handles (e.g. `lib.js:42`) via index lookup before dispatch.
2510
2528
  // Handles WITH a name suffix are handled later by applyClassMethodSyntax.
2511
2529
  if (params && params.name && looksLikeHandle(params.name)) {
@@ -2520,6 +2538,7 @@ function execute(index, command, params = {}) {
2520
2538
  }
2521
2539
  }
2522
2540
  const response = handler(index, params);
2541
+ response.projectRoot = index.root;
2523
2542
  const bundled = (index.discoveryIssues || []).filter(issue => issue.reason === 'bundled');
2524
2543
  if (bundled.length > 0) {
2525
2544
  const files = bundled.slice(0, 5).map(issue => issue.relativePath).join(', ');
@@ -207,8 +207,14 @@ function formatDiffImpact(result, options = {}) {
207
207
  if (s.modifiedFunctions > 0) parts.push(`${s.modifiedFunctions} modified`);
208
208
  if (s.deletedFunctions > 0) parts.push(`${s.deletedFunctions} deleted`);
209
209
  if (s.newFunctions > 0) parts.push(`${s.newFunctions} new`);
210
+ if (s.modifiedSymbols || s.newSymbols || s.deletedSymbols) {
211
+ parts.push(`${s.modifiedSymbols || 0} modified, ${s.newSymbols || 0} new, ${s.deletedSymbols || 0} deleted non-callable declarations`);
212
+ }
210
213
  parts.push(`${s.totalCallSites || 0} call sites across ${s.affectedFiles || 0} files`);
211
214
  if (s.unverifiedCallSites > 0) parts.push(`${s.unverifiedCallSites} unverified`);
215
+ if (s.totalDependencySites || s.unverifiedDependencySites) {
216
+ parts.push(`${s.totalDependencySites || 0} confirmed + ${s.unverifiedDependencySites || 0} unverified non-call dependency sites across ${s.dependencyFiles || 0} files`);
217
+ }
212
218
  lines.push(parts.join(', '));
213
219
  // fix #283: changed paths outside supported source are invisible to the
214
220
  // symbol analysis — disclose instead of silently narrowing the diff.
@@ -229,10 +235,10 @@ function formatDiffImpact(result, options = {}) {
229
235
  lines.push(` ${fn.relativePath}:${fn.startLine}`);
230
236
  lines.push(` ${fn.signature}`);
231
237
  if (fn.addedLines.length > 0) {
232
- lines.push(` Lines added: ${formatLineRanges(fn.addedLines)}`);
238
+ lines.push(` Added at lines: ${formatLineRanges(fn.addedLines)}`);
233
239
  }
234
240
  if (fn.deletedLines.length > 0) {
235
- lines.push(` Lines deleted: ${formatLineRanges(fn.deletedLines)}`);
241
+ lines.push(` Deleted at old lines: ${formatLineRanges(fn.deletedLines)}`);
236
242
  }
237
243
 
238
244
  if (fn.callers.length > 0) {
@@ -302,6 +308,24 @@ function formatDiffImpact(result, options = {}) {
302
308
  }
303
309
  }
304
310
 
311
+ for (const [key, title] of [['symbols', 'MODIFIED'], ['newSymbols', 'NEW'], ['deletedSymbols', 'DELETED']]) {
312
+ if (!result[key]?.length) continue;
313
+ lines.push(`\n${title} DECLARATIONS:`);
314
+ for (const symbol of result[key]) {
315
+ lines.push(` ${symbol.type} ${symbol.name} — ${symbol.relativePath}:${symbol.startLine}`);
316
+ if (symbol.impact) {
317
+ lines.push(require('./analysis').formatImpact(symbol.impact, { compact: true }));
318
+ } else {
319
+ const refs = symbol.remainingReferences || [];
320
+ lines.push(` Remaining name references: ${refs.length} unverified (deleted target)`);
321
+ for (const site of refs.slice(0, MAX_CALLERS_PER_FN)) {
322
+ lines.push(` ${site.file}:${site.line}: ${site.expression}`);
323
+ }
324
+ if (refs.length > MAX_CALLERS_PER_FN) lines.push(` (+${refs.length - MAX_CALLERS_PER_FN} more)`);
325
+ }
326
+ }
327
+ }
328
+
305
329
  // Module-level changes
306
330
  if (result.moduleLevelChanges.length > 0) {
307
331
  lines.push('\nMODULE-LEVEL CHANGES:');
@@ -205,7 +205,7 @@ function formatCalleeAccountLine(acct) {
205
205
  function unverifiedCalleeLines(entries, compact) {
206
206
  if (!entries || entries.length === 0) return [];
207
207
  const lines = [];
208
- lines.push(`${compact ? '' : '\n'}CALLEES — UNVERIFIED (${entries.length}) — call syntax, receiver/binding unresolved:`);
208
+ lines.push(`${compact ? '' : '\n'}CALLEES — UNVERIFIED (${entries.length}) — call or callable-reference syntax, receiver/binding unresolved:`);
209
209
  for (const u of entries) {
210
210
  const owners = u.ownerCount > 1 ? ` (${u.ownerCount} owners)` : '';
211
211
  const sites = u.sites && u.sites.length > 0 ? ` L${u.sites.join(',L')}` : '';
@@ -461,7 +461,7 @@ function formatContext(ctx, options = {}) {
461
461
 
462
462
  const typeUnverified = ctx.unverifiedCallers || [];
463
463
  if (typeUnverified.length > 0) {
464
- lines.push(`\nCALLERS — UNVERIFIED (${typeUnverified.length}) — call syntax, no binding/receiver evidence:`);
464
+ lines.push(`\nCALLERS — UNVERIFIED (${typeUnverified.length}) — call or callable-reference syntax, no binding/receiver evidence:`);
465
465
  formatAmbiguityCandidates(lines, ctx.ambiguityCandidates);
466
466
  const cap = 10;
467
467
  let shown = 0;
@@ -688,7 +688,7 @@ function formatContext(ctx, options = {}) {
688
688
  // Actionable ambiguity: call syntax without enough identity evidence.
689
689
  // Always visible and capped at 10 one-liners unless --all.
690
690
  if (actionableUnverified.length > 0) {
691
- lines.push(`${compact ? '' : '\n'}CALLERS — UNVERIFIED (${actionableUnverified.length}) — call syntax, no binding/receiver evidence:`);
691
+ lines.push(`${compact ? '' : '\n'}CALLERS — UNVERIFIED (${actionableUnverified.length}) — call or callable-reference syntax, no binding/receiver evidence:`);
692
692
  formatAmbiguityCandidates(lines, ctx.ambiguityCandidates);
693
693
  const cap = (ctx.meta && ctx.meta.all) ? Infinity : 10;
694
694
  let shown = 0;
@@ -837,8 +837,10 @@ function formatImpact(impact, options = {}) {
837
837
  }
838
838
 
839
839
  // By file (confirmed tier)
840
- if (!compact) lines.push('');
841
- lines.push('BY FILE:');
840
+ if (impact.byFile.length > 0) {
841
+ if (!compact) lines.push('');
842
+ lines.push('BY FILE:');
843
+ }
842
844
 
843
845
  // Evidence aggregate over ALL sites (replaces per-edge confidence lines)
844
846
  const allSites = impact.byFile.flatMap(g => g.sites);
@@ -881,7 +883,7 @@ function formatImpact(impact, options = {}) {
881
883
  for (const site of group.sites) {
882
884
  const caller = site.callerName ? ` [${site.callerName}]` : '';
883
885
  const expr = site.expression ? `: ${site.expression.replace(/\s+/g, ' ').slice(0, 100)}` : '';
884
- lines.push(` ${group.file}:${site.line}${caller}${expr}`);
886
+ lines.push(` ${group.file}:${site.line}${caller} [${site.accessKind || 'access'}${Number.isInteger(site.column) ? `, column ${site.column + 1}` : ''}]${expr}`);
885
887
  }
886
888
  }
887
889
  if (access.unverifiedSites.length > 0) {
@@ -889,7 +891,7 @@ function formatImpact(impact, options = {}) {
889
891
  for (const site of access.unverifiedSites.slice(0, 10)) {
890
892
  const caller = site.callerName ? ` [${site.callerName}]` : '';
891
893
  const expr = site.expression ? `: ${site.expression.replace(/\s+/g, ' ').slice(0, 100)}` : '';
892
- lines.push(` ${site.file}:${site.line}${caller}${expr}`);
894
+ lines.push(` ${site.file}:${site.line}${caller} [${site.accessKind || 'access'}${Number.isInteger(site.column) ? `, column ${site.column + 1}` : ''}]${expr}`);
893
895
  }
894
896
  if (access.unverifiedSites.length > 10) {
895
897
  lines.push(` (+${access.unverifiedSites.length - 10} more unverified)`);
@@ -927,7 +929,7 @@ function formatImpact(impact, options = {}) {
927
929
 
928
930
  // Unverified tier: visible, capped at 10 one-liners
929
931
  if (impactUnverified.length > 0) {
930
- lines.push(`${compact ? '' : '\n'}UNVERIFIED CALL SITES (${impactUnverified.length}) — call syntax, no binding/receiver evidence:`);
932
+ lines.push(`${compact ? '' : '\n'}UNVERIFIED CALL SITES (${impactUnverified.length}) — call or callable-reference syntax, no binding/receiver evidence:`);
931
933
  const cap = 10;
932
934
  for (const site of impactUnverified.slice(0, cap)) {
933
935
  const caller = site.callerName ? ` [${site.callerName}]` : '';
@@ -1085,7 +1087,7 @@ function formatAbout(about, options = {}) {
1085
1087
  const aboutUnverified = about.callers.unverified;
1086
1088
  if (aboutUnverified && aboutUnverified.total > 0) {
1087
1089
  lines.push('');
1088
- lines.push(`CALLERS — UNVERIFIED (${aboutUnverified.total}) — call syntax, no binding/receiver evidence:`);
1090
+ lines.push(`CALLERS — UNVERIFIED (${aboutUnverified.total}) — call or callable-reference syntax, no binding/receiver evidence:`);
1089
1091
  for (const u of aboutUnverified.top) {
1090
1092
  const caller = u.callerName ? ` [${u.callerName}]` : '';
1091
1093
  const reason = u.reason ? ` (${unverifiedReasonLabel(u)})` : '';
@@ -29,15 +29,17 @@ function formatCheck(result) {
29
29
  if (result.trust.signatureMismatches) trustDetails.push(`${result.trust.signatureMismatches} signature mismatch(es)`);
30
30
  if (result.trust.filteredEdges) trustDetails.push(`${result.trust.filteredEdges} filtered edge(s)`);
31
31
  if (result.trust.usageReviewSymbols) trustDetails.push(`${result.trust.usageReviewSymbols} symbol(s) need usages review`);
32
+ if (result.trust.unvalidatedDeclarations) trustDetails.push(`${result.trust.unvalidatedDeclarations} declaration change(s) need toolchain validation`);
32
33
  if (trustDetails.length > 0) lines.push(` ${trustDetails.join(' · ')}`);
33
34
  }
34
35
 
35
36
  // Changed functions section
36
37
  const items = result.changed || [];
38
+ const noun = items.some(it => it.symbolType) ? 'symbol' : 'function';
37
39
  if (result.truncated) {
38
- lines.push(`Changed: ${items.length} of ${result.totalChanged} functions`);
40
+ lines.push(`Changed: ${items.length} of ${result.totalChanged} ${noun}s`);
39
41
  } else {
40
- lines.push(`Changed: ${items.length} function${items.length === 1 ? '' : 's'}`);
42
+ lines.push(`Changed: ${items.length} ${noun}${items.length === 1 ? '' : 's'}`);
41
43
  }
42
44
  if (items.length === 0) {
43
45
  lines.push(' (none — only non-function changes)');
@@ -47,6 +49,7 @@ function formatCheck(result) {
47
49
  if (it.kind && it.kind !== 'changed') tags.push(it.kind.toUpperCase());
48
50
  if (it.signatureMismatches > 0) tags.push(`SIG-DRIFT(${it.signatureMismatches})`);
49
51
  if (it.orphan) tags.push('ORPHAN');
52
+ if (it.symbolType) tags.push(it.symbolType);
50
53
  if (it.account && !it.account.textComplete) tags.push('ACCOUNT-INCOMPLETE');
51
54
  const tagStr = tags.length ? ' [' + tags.join(', ') + ']' : '';
52
55
  let callers = it.callerCount != null ? `${it.callerCount} caller${it.callerCount === 1 ? '' : 's'}` : '';
@@ -54,6 +57,7 @@ function formatCheck(result) {
54
57
  callers += ` (+${it.unverifiedCallerCount} unverified)`;
55
58
  }
56
59
  lines.push(` ${it.name} (${it.file}:${it.line})${tagStr} ${callers}`);
60
+ if (it.symbolType) lines.push(` Dependencies: ${it.dependencyCount || 0} confirmed, ${it.unverifiedDependencyCount || 0} unverified`);
57
61
  if (it.mismatches && it.mismatches.length > 0) {
58
62
  for (const m of it.mismatches.slice(0, 3)) {
59
63
  const where = m.file ? ` at ${m.file}:${m.line}` : '';