ucn 5.2.1 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,7 +21,7 @@ function formatImports(imports, filePath) {
21
21
  if (internal.length > 0) {
22
22
  lines.push('INTERNAL:');
23
23
  for (const imp of internal) {
24
- lines.push(` ${imp.module}${imp.deferred ? ' [function-local/deferred]' : ''}`);
24
+ lines.push(` ${imp.module}${deferredImportLabel(imp)}`);
25
25
  if (imp.resolved) {
26
26
  lines.push(` -> ${imp.resolved}${imp.indexed === false
27
27
  ? ' (not indexed; absent from dependency graph)'
@@ -37,7 +37,7 @@ function formatImports(imports, filePath) {
37
37
  if (internal.length > 0) lines.push('');
38
38
  lines.push('EXTERNAL:');
39
39
  for (const imp of external) {
40
- lines.push(` ${imp.module}${imp.deferred ? ' [function-local/deferred]' : ''}`);
40
+ lines.push(` ${imp.module}${deferredImportLabel(imp)}`);
41
41
  if (imp.names && imp.names.length > 0) {
42
42
  lines.push(` ${imp.names.join(', ')}`);
43
43
  }
@@ -48,7 +48,7 @@ function formatImports(imports, filePath) {
48
48
  if (internal.length > 0 || external.length > 0) lines.push('');
49
49
  lines.push('DYNAMIC (unresolved):');
50
50
  for (const imp of dynamic) {
51
- lines.push(` ${imp.module || '(variable)'}${imp.deferred ? ' [function-local/deferred]' : ''}`);
51
+ lines.push(` ${imp.module || '(variable)'}${deferredImportLabel(imp)}`);
52
52
  if (imp.names && imp.names.length > 0) {
53
53
  lines.push(` ${imp.names.join(', ')}`);
54
54
  }
@@ -407,6 +407,22 @@ function formatGraphJson(graph) {
407
407
  return JSON.stringify(result, null, 2);
408
408
  }
409
409
 
410
+ // fix #338: deferred-edge vocabulary shared by deps and cycle output.
411
+ const DEFERRED_REASON_LABELS = {
412
+ 'function-local': 'function-local import',
413
+ 'type-checking': 'TYPE_CHECKING-only import, never executed at runtime',
414
+ 'type-only': 'type-only import, erased at compile time',
415
+ };
416
+ function deferredReasonLabel(reason) {
417
+ return DEFERRED_REASON_LABELS[reason] || 'deferred import';
418
+ }
419
+ const EAGER_CYCLE_DISPLAY_LIMIT = 25;
420
+ const DEFERRED_CYCLE_DISPLAY_LIMIT = 10;
421
+ function deferredImportLabel(imp) {
422
+ if (!imp.deferred) return '';
423
+ return imp.deferredReason ? ` [deferred: ${imp.deferredReason}]` : ' [deferred]';
424
+ }
425
+
410
426
  function formatCircularDeps(result) {
411
427
  if (!result) return 'No results.';
412
428
  const lines = [];
@@ -420,7 +436,7 @@ function formatCircularDeps(result) {
420
436
 
421
437
  const scannedCount = result.filesWithImports != null ? result.filesWithImports : result.totalFiles;
422
438
 
423
- if (result.cycles.length === 0) {
439
+ if (result.cycles.length === 0 && !(result.components || []).length && !result.summary?.truncated) {
424
440
  lines.push('');
425
441
  lines.push('No circular dependencies found.');
426
442
  lines.push(`Scanned ${scannedCount} files with import relationships.`);
@@ -429,12 +445,32 @@ function formatCircularDeps(result) {
429
445
 
430
446
  const eager = result.cycles.filter(cycle => cycle.classification !== 'deferred');
431
447
  const deferred = result.cycles.filter(cycle => cycle.classification === 'deferred');
448
+
449
+ // Groups first: a strongly connected file set is the unit a refactor has
450
+ // to break, and a 14-file tangle can hold hundreds of elementary cycles.
451
+ const groups = result.components || [];
452
+ if (groups.length > 0) {
453
+ lines.push('');
454
+ lines.push(`CYCLE GROUPS (${groups.length}) — every member reaches every other member:`);
455
+ for (const group of groups) {
456
+ const counts = [];
457
+ if (group.eagerCycles != null) counts.push(`${group.eagerCycles} import-time`);
458
+ if (group.deferredCycles != null) counts.push(`${group.deferredCycles} deferred`);
459
+ const suffix = counts.length > 0 ? ` [${counts.join(', ')}${result.summary.truncated ? '; enumerated counts only' : ''}]` : '';
460
+ lines.push(` ${group.size} files: ${group.files.join(', ')}${suffix}`);
461
+ }
462
+ }
463
+
432
464
  let cycleNumber = 0;
433
- const renderGroup = (title, group, deferredGroup = false) => {
465
+ const renderGroup = (title, group, deferredGroup, displayLimit) => {
434
466
  if (group.length === 0) return;
467
+ const shown = group.slice(0, displayLimit);
435
468
  lines.push('');
436
- lines.push(`${title} (${group.length}):`);
437
- for (const cycle of group) {
469
+ const heading = shown.length < group.length
470
+ ? `${title} (${group.length}, showing ${shown.length} shortest):`
471
+ : `${title} (${group.length}):`;
472
+ lines.push(heading);
473
+ for (const cycle of shown) {
438
474
  cycleNumber++;
439
475
  lines.push('');
440
476
  lines.push(`Cycle ${cycleNumber} (${cycle.length} files):`);
@@ -442,19 +478,32 @@ function formatCircularDeps(result) {
442
478
  if (deferredGroup) {
443
479
  for (const edge of cycle.deferredEdges || []) {
444
480
  const at = edge.line != null ? `:${edge.line}` : '';
445
- lines.push(` deferred edge: ${edge.from}${at} → ${edge.to} (function-local import)`);
481
+ const why = (edge.reasons || []).map(deferredReasonLabel).join('; ') || 'function-local import';
482
+ lines.push(` deferred edge: ${edge.from}${at} → ${edge.to} (${why})`);
446
483
  }
447
484
  }
448
485
  }
486
+ if (shown.length < group.length) {
487
+ lines.push(` ... and ${group.length - shown.length} more (use --json for the full list)`);
488
+ }
449
489
  };
450
- renderGroup('IMPORT-TIME CYCLES', eager);
451
- renderGroup('DEFERRED CYCLES', deferred, true);
490
+ renderGroup('IMPORT-TIME CYCLES', eager, false, EAGER_CYCLE_DISPLAY_LIMIT);
491
+ renderGroup('DEFERRED CYCLES', deferred, true, DEFERRED_CYCLE_DISPLAY_LIMIT);
452
492
 
453
493
  lines.push('');
454
494
  const { totalCycles, filesInCycles } = result.summary;
455
495
  lines.push(`Summary: ${totalCycles} circular dependency chain${totalCycles !== 1 ? 's' : ''} involving ${filesInCycles} file${filesInCycles !== 1 ? 's' : ''} (${scannedCount} files with imports scanned).`);
496
+ if (result.summary.truncated) {
497
+ if (result.summary.truncationReasons?.includes('component-size')) {
498
+ lines.push(`Cycle enumeration skipped groups larger than ${result.summary.maxComponentSize} files.`);
499
+ }
500
+ if (!result.summary.truncationReasons || result.summary.truncationReasons.includes('cycle-limit')) {
501
+ lines.push(`Enumeration stopped at ${result.summary.cycleLimit} elementary cycles.`);
502
+ }
503
+ lines.push('The CYCLE GROUPS list and files-in-cycles count are complete; enumerated cycle counts are lower bounds.');
504
+ }
456
505
  if (deferred.length > 0) {
457
- lines.push(`${deferred.length} chain${deferred.length === 1 ? '' : 's'} contain a function-local import; they are not unconditional import-time cycles, but may still matter if invoked during initialization.`);
506
+ lines.push(`${deferred.length} chain${deferred.length === 1 ? '' : 's'} close only through deferred edges (function-local, TYPE_CHECKING-only, or type-only imports); they are not unconditional import-time cycles, but a function-local edge may still matter if invoked during initialization.`);
458
507
  }
459
508
 
460
509
  return lines.join('\n');
@@ -0,0 +1,259 @@
1
+ /**
2
+ * core/output/lines.js - grep-shaped and raw output modes (fix #341).
3
+ *
4
+ * `--lines`: one `path:line:text` record per line — the `grep -n` shape — so
5
+ * `ucn` composes with head/cut/xargs and reads like the tool agents already
6
+ * reach for in a shell. Everything that is not a record (ACCOUNT/CONTRACT
7
+ * lines, disambiguation, notes) follows the records as `# ` comment lines:
8
+ * the CLI routes those to stderr, MCP keeps them in its single text block.
9
+ * Records outside the confirmed tier carry a trailing `\t# tag`, so the
10
+ * `path:line:` prefix stays parseable while the tier stays visible.
11
+ *
12
+ * `--raw` (source): the code text and nothing else — no header, no gutter —
13
+ * so an agent can extract pristine text for an exact-string edit.
14
+ */
15
+ 'use strict';
16
+
17
+ const { CALLABLE_SYMBOL_KINDS } = require('../shared');
18
+ const { formatSurfaceMessage } = require('../registry');
19
+ const { formatAccountLines, formatCalleeAccountLine } = require('./analysis');
20
+
21
+ const LINES_COMMANDS = new Set(['find', 'usages', 'search', 'show', 'impact']);
22
+
23
+ function record(pathLike, line, text, tag = '') {
24
+ // One record per line is the contract: a multi-line signature or a
25
+ // wrapped call expression folds onto one line, and the source line's
26
+ // indentation is dropped (a locate result needs the text, `--raw` has
27
+ // the layout).
28
+ const body = String(text == null ? '' : text).replace(/\s*\n\s*/g, ' ').trim();
29
+ // Keep unusual filenames from becoming notes or extra physical records.
30
+ let file = String(pathLike).replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
31
+ if (file.startsWith('# ')) file = './' + file;
32
+ return `${file}:${line == null ? 0 : line}:${body}${tag ? `\t# ${tag}` : ''}`;
33
+ }
34
+
35
+ function commentLines(text) {
36
+ return String(text || '').split(/\r?\n/).filter(Boolean).map(line => `# ${line}`);
37
+ }
38
+
39
+ function signatureOf(symbol) {
40
+ const owner = symbol.className ? `${symbol.className}.` : '';
41
+ const callable = CALLABLE_SYMBOL_KINDS.has(symbol.type) || symbol.params != null;
42
+ // A multi-line parameter list folds onto one line and drops the trailing
43
+ // comma the source may carry before its closing paren.
44
+ const params = String(symbol.params || '').replace(/\s*\n\s*/g, ' ').replace(/,\s*$/, '');
45
+ return callable ? `${owner}${symbol.name}(${params})` : `${owner}${symbol.name}`;
46
+ }
47
+
48
+ function pathOf(entry) {
49
+ return entry.relativePath || entry.file || '';
50
+ }
51
+
52
+ function unverifiedTag(entry) {
53
+ const reason = entry.reason || 'unverified';
54
+ const via = entry.dispatchVia ? ` via ${entry.dispatchVia}` : '';
55
+ return `unverified: ${reason}${via}`;
56
+ }
57
+
58
+ function accountComments(account) {
59
+ if (!account) return [];
60
+ return [].concat(formatAccountLines(account) || [])
61
+ .join('\n').split('\n').filter(Boolean).map(line => `# ${line}`);
62
+ }
63
+
64
+ function findRecords(result) {
65
+ const out = [];
66
+ if (Array.isArray(result)) {
67
+ for (const symbol of result) out.push(record(pathOf(symbol), symbol.startLine, signatureOf(symbol), symbol.type));
68
+ } else if (result && Array.isArray(result.types)) {
69
+ for (const type of result.types) {
70
+ out.push(record(pathOf(type), type.startLine ?? type.line, type.name, type.type || type.kind));
71
+ }
72
+ }
73
+ return { records: out, notes: [] };
74
+ }
75
+
76
+ function usagesRecords(result) {
77
+ const out = [];
78
+ const notes = [];
79
+ for (const usage of Array.isArray(result) ? result : []) {
80
+ const kind = usage.isDefinition ? 'definition' : (usage.usageType || 'reference');
81
+ out.push(record(pathOf(usage), usage.line, usage.content, kind === 'call' ? '' : kind));
82
+ }
83
+ const counts = result && result.summaryCounts;
84
+ if (counts && counts.hiddenTestUsages > 0) {
85
+ notes.push(`# ${counts.hiddenTestUsages} test-file usage(s) hidden by default (--include-tests)`);
86
+ }
87
+ return { records: out, notes };
88
+ }
89
+
90
+ function searchRecords(result) {
91
+ const out = [];
92
+ const notes = [];
93
+ // Structural search (--type=...) returns { meta, results: [{file, line,
94
+ // name, kind, receiver, params?}] }; text search returns file groups.
95
+ if (result && !Array.isArray(result) && Array.isArray(result.results)) {
96
+ for (const item of result.results) {
97
+ const text = item.params != null ? `${item.name}(${item.params})` : item.name;
98
+ out.push(record(item.file, item.line, text, item.kind || item.type));
99
+ }
100
+ const meta = result.meta;
101
+ if (meta && meta.totalMatched > meta.shown) {
102
+ notes.push(`# ${meta.totalMatched - meta.shown} more match(es) (--limit=N / --all)`);
103
+ }
104
+ return { records: out, notes };
105
+ }
106
+ for (const item of Array.isArray(result) ? result : []) {
107
+ if (Array.isArray(item.matches)) {
108
+ for (const match of item.matches) out.push(record(item.file, match.line, match.content));
109
+ } else if (item.file != null && item.line != null) {
110
+ const text = item.content != null ? item.content : signatureOf(item);
111
+ out.push(record(pathOf(item), item.line, text, item.type || item.kind));
112
+ }
113
+ }
114
+ const meta = result && result.meta;
115
+ if (meta && meta.filesSkipped > 0) {
116
+ notes.push(`# ${meta.filesSkipped} test file(s) hidden by default (--include-tests)`);
117
+ }
118
+ if (meta && meta.truncatedMatches > 0) {
119
+ notes.push(`# ${meta.truncatedMatches} more match(es) omitted by --top/--limit`);
120
+ }
121
+ return { records: out, notes };
122
+ }
123
+
124
+ function callerRecords(context) {
125
+ const out = [];
126
+ for (const caller of context.callers || []) {
127
+ const tag = caller.tier && caller.tier !== 'confirmed' ? unverifiedTag(caller) : '';
128
+ out.push(record(pathOf(caller), caller.line, caller.content, tag));
129
+ }
130
+ for (const caller of context.unverifiedCallers || []) {
131
+ out.push(record(pathOf(caller), caller.line, caller.content, unverifiedTag(caller)));
132
+ }
133
+ return out;
134
+ }
135
+
136
+ function showRecords(result, params = {}) {
137
+ const out = [];
138
+ const notes = [];
139
+ const context = result && result.context;
140
+ // Only an EXPLICIT --sections selects the band: the resolved defaults
141
+ // (summary, callers, callees) would mix callee records into a caller
142
+ // listing and skew `cut -d: -f1 | sort | uniq -c`.
143
+ const explicit = (Array.isArray(params.sections) ? params.sections : String(params.sections || '').split(','))
144
+ .map(s => String(s).trim().toLowerCase()).filter(Boolean);
145
+ const selected = new Set(explicit.length > 0 ? explicit : ['callers']);
146
+ if (context) {
147
+ if (selected.has('callers')) out.push(...callerRecords(context));
148
+ if (selected.has('callees')) {
149
+ for (const callee of context.callees || []) {
150
+ const count = callee.callCount > 1 ? ` x${callee.callCount}` : '';
151
+ out.push(record(pathOf(callee), callee.startLine, signatureOf(callee), `callee${count}`));
152
+ }
153
+ for (const callee of context.unverifiedCallees || []) {
154
+ for (const site of callee.sites || []) {
155
+ out.push(record(pathOf(context), site, callee.name, `${unverifiedTag(callee)}; callee`));
156
+ }
157
+ }
158
+ }
159
+ notes.push(...accountComments(context.meta && context.meta.account));
160
+ if (context.meta && context.meta.calleeAccount && selected.has('callees')) {
161
+ notes.push(`# ${formatCalleeAccountLine(context.meta.calleeAccount)}`);
162
+ }
163
+ }
164
+ if (result && result.target && result.target.alternatives && result.target.alternatives.length > 0) {
165
+ notes.push(`# ${result.target.alternatives.length + 1} definitions; using ${result.target.handle || result.target.file}. Pass a file:line:name handle to pin another.`);
166
+ }
167
+ return { records: out, notes };
168
+ }
169
+
170
+ function impactRecords(result) {
171
+ const out = [];
172
+ const notes = [];
173
+ if (!result) return { records: out, notes };
174
+ if (Array.isArray(result.functions)) {
175
+ for (const fn of result.functions) {
176
+ out.push(...callerRecords(fn));
177
+ notes.push(...accountComments(fn.account));
178
+ }
179
+ for (const fn of result.deletedFunctions || []) {
180
+ for (const site of fn.remainingCallSites || []) {
181
+ out.push(record(pathOf(site), site.line, site.content, 'unverified: deleted-target-name-match'));
182
+ }
183
+ }
184
+ const summary = result.summary || {};
185
+ notes.push(`# Diff: ${summary.modifiedFunctions || 0} modified, ${summary.newFunctions || 0} new, ${summary.deletedFunctions || 0} deleted functions; ${(result.moduleLevelChanges || []).length} file(s) with module-level changes.`);
186
+ if (result.nonSourcePaths) notes.push(`# ${result.nonSourcePaths} changed path(s) outside supported source files not analyzed.`);
187
+ return { records: [...new Set(out)], notes };
188
+ }
189
+ for (const group of result.byFile || []) {
190
+ for (const site of group.sites || []) {
191
+ const tag = site.tier && site.tier !== 'confirmed' ? unverifiedTag(site) : '';
192
+ out.push(record(group.file, site.line, site.expression, tag));
193
+ }
194
+ }
195
+ for (const site of result.unverifiedSites || []) {
196
+ out.push(record(pathOf(site), site.line, site.content || site.expression, unverifiedTag(site)));
197
+ }
198
+ if (result.propertyAccesses) {
199
+ const accesses = result.propertyAccesses;
200
+ for (const group of accesses.byFile || []) {
201
+ for (const access of group.sites || []) {
202
+ out.push(record(group.file, access.line, access.expression, 'property-access'));
203
+ }
204
+ }
205
+ for (const access of accesses.unverifiedSites || []) {
206
+ out.push(record(pathOf(access), access.line, access.expression, `${unverifiedTag(access)}; property-access`));
207
+ }
208
+ notes.push(`# PROPERTY ACCESS SITES: ${accesses.confirmedCount} confirmed, ${accesses.unverifiedCount} unverified, ${accesses.excluded?.total || 0} other-target (separate from caller ACCOUNT).`);
209
+ }
210
+ notes.push(...accountComments(result.account));
211
+ for (const warning of result.warnings || []) notes.push(...commentLines(warning.message));
212
+ if (result.scopeWarning?.hint) notes.push(...commentLines(result.scopeWarning.hint));
213
+ if (result.shownCallSites < result.totalCallSites) {
214
+ notes.push(`# ${result.totalCallSites - result.shownCallSites} more call site(s) omitted by --top/--limit`);
215
+ }
216
+ return { records: out, notes };
217
+ }
218
+
219
+ /**
220
+ * Render a public command result as grep-shaped records followed by `# `
221
+ * comment lines. Returns '' when the command has nothing to list.
222
+ */
223
+ function formatPublicLines(command, result, params = {}, execution = {}) {
224
+ let shaped;
225
+ switch (command) {
226
+ case 'find': shaped = findRecords(result); break;
227
+ case 'usages': shaped = usagesRecords(result); break;
228
+ case 'search': shaped = searchRecords(result); break;
229
+ case 'show': shaped = showRecords(result, params); break;
230
+ case 'impact': shaped = impactRecords(result); break;
231
+ default: return null;
232
+ }
233
+ const lines = [...shaped.records, ...shaped.notes];
234
+ if (execution.note) lines.push(...commentLines(execution.note));
235
+ return lines.map(line => line.startsWith('# ')
236
+ ? commentLines(formatSurfaceMessage(line.slice(2), execution.surface)).join('\n') : line).join('\n');
237
+ }
238
+
239
+ /**
240
+ * Render a `source` result as the code text alone.
241
+ */
242
+ function formatPublicRaw(result, execution = {}) {
243
+ let code = '';
244
+ if (result && Array.isArray(result.lines)) code = result.lines.join('\n');
245
+ else if (result && Array.isArray(result.entries)) {
246
+ code = result.entries.map(entry => entry.code == null ? '' : String(entry.code)).join('\n\n');
247
+ } else if (result && typeof result.code === 'string') code = result.code;
248
+ // A note (the same-name disambiguation, a hidden-section warning) must
249
+ // not vanish in raw mode. Code lines are never reinterpreted, so it
250
+ // cannot ride inside the text: the CLI prints `execution.note` to stderr
251
+ // itself (see emitCliText); the single-block surfaces get it appended as
252
+ // one trailing `# ` line after the code.
253
+ if (execution.note && execution.surface !== 'cli') {
254
+ return `${code.replace(/\n$/, '')}\n${commentLines(execution.note).join('\n')}`;
255
+ }
256
+ return code;
257
+ }
258
+
259
+ module.exports = { LINES_COMMANDS, formatPublicLines, formatPublicRaw, commentLines };
@@ -11,6 +11,7 @@
11
11
  const { COMMAND_CONTRACTS } = require('../command-contracts');
12
12
  const { COMMAND_TRUST_MATRIX } = require('../trust-matrix');
13
13
  const { toCliName, toMcpName, formatSurfaceMessage } = require('../registry');
14
+ const { LINES_COMMANDS, formatPublicLines, formatPublicRaw } = require('./lines');
14
15
 
15
16
  const legacy = {
16
17
  ...require('./analysis'),
@@ -274,6 +275,20 @@ function formatRepo(result, params = {}, hints = presentationHints()) {
274
275
  }
275
276
 
276
277
  function formatPublicText(command, result, params = {}, execution = {}) {
278
+ // Grep-shaped and raw modes (fix #341): the agent-in-a-shell surface.
279
+ if (params.raw && command === 'source') {
280
+ return formatPublicRaw(result, {
281
+ ...execution,
282
+ note: execution.note ? formatSurfaceMessage(execution.note, execution.surface) : undefined,
283
+ });
284
+ }
285
+ if (params.lines && LINES_COMMANDS.has(command)) {
286
+ const shaped = formatPublicLines(command, result, params, {
287
+ ...execution,
288
+ note: execution.note ? formatSurfaceMessage(execution.note, execution.surface) : undefined,
289
+ });
290
+ if (shaped != null) return shaped;
291
+ }
277
292
  const hints = presentationHints(execution.surface);
278
293
  if (result?.scopeWarning?.hint) {
279
294
  result.scopeWarning = {
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * core/output/reporting.js - Stats/TOC/deadcode/entrypoints formatters
3
3
  */
4
+ const path = require('path');
4
5
 
5
6
  const {
6
7
  lineRange,
@@ -457,7 +458,10 @@ function formatEntrypointsJson(results) {
457
458
  */
458
459
  function formatOrient(result, options = {}) {
459
460
  const lines = [];
460
- lines.push(`PROJECT ORIENTATION — ${result.root}${result.scope ? ` (scoped to ${result.scope})` : ''}`);
461
+ // The title names the project (fix #343); the absolute root is the
462
+ // caller's own argument and `--sections=stats` prints it in full.
463
+ const projectName = path.basename(String(result.root || '')) || String(result.root || '');
464
+ lines.push(`PROJECT ORIENTATION — ${projectName}${result.scope ? ` (scoped to ${result.scope})` : ''}`);
461
465
  lines.push('═'.repeat(60));
462
466
 
463
467
  // Size + language mix (percent by symbols, largest first)
@@ -486,7 +490,10 @@ function formatOrient(result, options = {}) {
486
490
  const population = result.hot.totalKind === 'raw-call-candidates'
487
491
  ? `${result.hot.total} raw candidates`
488
492
  : result.hot.total;
489
- lines.push(`HOT (most-called ${scope}, top ${result.hot.items.length} of ${population}):`);
493
+ const budgetNote = result.hot.budgetExhausted
494
+ ? `; refinement budget ${result.hot.maxRefine} reached — ranking approximate, exact list: ucn repo --sections=stats --hot`
495
+ : '';
496
+ lines.push(`HOT (most-called ${scope}, top ${result.hot.items.length} of ${population}${budgetNote}):`);
490
497
  for (const h of result.hot.items) {
491
498
  const label = h.className ? `${h.className}.${h.name}` : h.name;
492
499
  lines.push(` ${label} — ${h.callCount} call(s) · ${h.file}:${h.line}`);
@@ -33,18 +33,21 @@ function preservedContractMetadata(fullText, visibleText, options = {}) {
33
33
  // one physical line. Preserve the actionable test-scope contract as
34
34
  // its own sentence so a later parse-failure note cannot make the
35
35
  // whole metadata item too large for a small transport budget.
36
- const firstSentenceEnd = /^\s*\d+ test-file usage\(s\) hidden\b/.test(rawLine)
36
+ const firstSentenceEnd = /^\s*(?:# )?\d+ test-file usage\(s\) hidden\b/.test(rawLine)
37
37
  ? rawLine.indexOf('. ')
38
38
  : -1;
39
39
  const contractLine = firstSentenceEnd >= 0
40
40
  ? rawLine.slice(0, firstSentenceEnd + 1)
41
41
  : rawLine;
42
- if (!CONTRACT_LINE_RE.test(contractLine)) continue;
42
+ // Shell-shaped MCP/interactive results prefix disclosures with '# '.
43
+ // Match their contents but retain the prefix in the preserved text.
44
+ const evidenceLine = contractLine.replace(/^\s*# /, '').trim();
45
+ if (!CONTRACT_LINE_RE.test(evidenceLine)) continue;
43
46
  const line = contractLine.trim();
44
47
  if (!line || visible.has(line)) continue;
45
- const priority = /^(?:ACCOUNT|CONTRACT|WARNING|FILTERED|CALLEE ACCOUNT|TREE ACCOUNT):/.test(line)
48
+ const priority = /^(?:ACCOUNT|CONTRACT|WARNING|FILTERED|CALLEE ACCOUNT|TREE ACCOUNT):/.test(evidenceLine)
46
49
  ? 0
47
- : /^\d+ test-file usage\(s\) hidden\b/.test(line) ? 1 : 2;
50
+ : /^\d+ test-file usage\(s\) hidden\b/.test(evidenceLine) ? 1 : 2;
48
51
  candidates.push({ line, priority, sourceIndex });
49
52
  }
50
53
 
package/core/project.js CHANGED
@@ -79,8 +79,33 @@ class ProjectIndex {
79
79
  this._opLinesCache = null; // per-operation split-lines cache (Map<filePath, string[]>, bounded FIFO)
80
80
  this._opInnerSymbolRangesCache = null; // per-operation sorted class-method ranges by file
81
81
  this._opFlowTypeOriginCache = null; // per-operation annotation type identity results
82
+ this._opCppTypeCategoryCache = null; // per-operation normalized C++ parameter categories
83
+ this._opCppPathReceiverTypeCache = null; // per-operation C++ qualified receiver identity
84
+ this._opDerefPairs = null; // per-operation Rust Deref identity pairs
85
+ this._opAliasPairs = null; // per-operation language type-alias identity pairs
82
86
  this._parsedTreeCache = new Map(); // cross-operation LRU: filePath -> immutable tree entry
83
87
  this._parsedTreeCacheSourceBytes = 0;
88
+ // Cross-operation, content-hash-keyed usage classifications. Account
89
+ // queries often ask several projections for the same hot symbol; the
90
+ // AST answer is immutable until the file hash changes. Bounded by
91
+ // both entries and approximate payload size to avoid turning a warm
92
+ // MCP process into an unbounded repository mirror.
93
+ this._usageResultCache = new Map();
94
+ this._usageResultCacheWeight = 0;
95
+ this.usageCacheDirty = false;
96
+ // Exact text-ground sets are likewise immutable for one built index.
97
+ // The cache is cleared at every build and bounded in account.js.
98
+ this._groundSetCache = new Map();
99
+ this._groundSetCacheLines = 0;
100
+ // Bounded cross-operation memo for immutable name-level export
101
+ // ownership. Agent workflows ask show/impact/tests about related
102
+ // symbols in sequence; retaining these tri-state barrel verdicts
103
+ // avoids repeating the same bounded graph walks after every command.
104
+ this._nameBindingReachCache = new Map();
105
+ // Query-derived return flow depends on cross-file annotations and is
106
+ // deliberately never persisted. It is safe across commands only
107
+ // until the next build, which resets it below.
108
+ this._returnTypeFlowCache = new Map();
84
109
  this.calleeIndex = null; // name -> Set<filePath> — inverted call index (built lazily)
85
110
  }
86
111
 
@@ -120,6 +145,11 @@ class ProjectIndex {
120
145
  this._opInnerSymbolRangesCache = new Map();
121
146
  this._opFlowTypeOriginCache = new Map();
122
147
  this._opImportReachCache = new Map();
148
+ this._opCppTypeCategoryCache = new Map();
149
+ this._opCppPathReceiverTypeCache = new Map();
150
+ this._opDerefPairs = undefined;
151
+ this._opAliasPairs = undefined;
152
+ this._opFindCallersCaches = null; // fix #340: findCallers per-file derivations
123
153
  this._opDepth = 0;
124
154
  }
125
155
  this._opDepth++;
@@ -145,6 +175,11 @@ class ProjectIndex {
145
175
  this._opInnerSymbolRangesCache = null;
146
176
  this._opFlowTypeOriginCache = null;
147
177
  this._opImportReachCache = null;
178
+ this._opCppTypeCategoryCache = null;
179
+ this._opCppPathReceiverTypeCache = null;
180
+ this._opDerefPairs = null;
181
+ this._opAliasPairs = null;
182
+ this._opFindCallersCaches = null;
148
183
  // Free cached file content from callsCache entries (retained during
149
184
  // operation for _readFile caching, not needed between operations)
150
185
  for (const entry of this.callsCache.values()) {
@@ -255,15 +290,38 @@ class ProjectIndex {
255
290
  * multiple times within one operation (e.g., about() calls both countSymbolUsages and usages).
256
291
  * @param {string} filePath - File to scan
257
292
  * @param {string} name - Symbol name to find
293
+ * @param {object} [options]
294
+ * @param {boolean} [options.skipCallRecovery] - omit usage-only call
295
+ * recovery when the caller has already classified lines from the call index
258
296
  * @returns {Array|null} Array of usage objects or null if parsing failed
259
297
  */
260
- _getCachedUsages(filePath, name) {
261
- const cacheKey = `${filePath}\0${name}`;
298
+ _getCachedUsages(filePath, name, options = {}) {
299
+ // Account construction checks the complete calls cache before it asks
300
+ // the language adapter to classify the remaining name occurrences.
301
+ // C/C++ can therefore skip its expensive macro replacement-list call
302
+ // recovery in that mode. Partition both cache layers so a partial
303
+ // account classification can never poison the full `usages` result.
304
+ const mode = [
305
+ options.skipCallRecovery ? 'skip-call-recovery' : '',
306
+ ].filter(Boolean).join('+');
307
+ const modeSuffix = mode ? `\0${mode}` : '';
308
+ const cacheKey = `${filePath}\0${name}${modeSuffix}`;
262
309
  if (this._opUsagesCache) {
263
310
  const cached = this._opUsagesCache.get(cacheKey);
264
311
  if (cached !== undefined) return cached;
265
312
  }
266
313
 
314
+ const fileHash = this.files.get(filePath)?.hash || '';
315
+ const persistentKey = `${filePath}\0${fileHash}\0${name}${modeSuffix}`;
316
+ if (this._usageResultCache?.has(persistentKey)) {
317
+ const cached = this._usageResultCache.get(persistentKey);
318
+ // Map insertion order is the LRU order.
319
+ this._usageResultCache.delete(persistentKey);
320
+ this._usageResultCache.set(persistentKey, cached);
321
+ if (this._opUsagesCache) this._opUsagesCache.set(cacheKey, cached.value);
322
+ return cached.value;
323
+ }
324
+
267
325
  // Header language is resolved during indexing from compilation
268
326
  // databases/include context. Re-detecting `.h` here can choose C for
269
327
  // a C++ header and silently drop member/template usages from the raw
@@ -292,10 +350,26 @@ class ProjectIndex {
292
350
  !langModule.managesOwnParseTree
293
351
  ? this._getParsedTree(filePath, content, lang)
294
352
  : null;
295
- const usages = langModule.findUsagesInCode(content, name, parser, tree);
353
+ const usages = langModule.findUsagesInCode(
354
+ content, name, parser, tree, options);
296
355
  if (this._opUsagesCache) {
297
356
  this._opUsagesCache.set(cacheKey, usages);
298
357
  }
358
+ if (Array.isArray(usages) && this._usageResultCache) {
359
+ const weight = 64 + usages.length * 40;
360
+ this._usageResultCache.set(persistentKey, { value: usages, weight });
361
+ this._usageResultCacheWeight += weight;
362
+ this.usageCacheDirty = true;
363
+ const maxEntries = 4096;
364
+ const maxWeight = 16 * 1024 * 1024;
365
+ while (this._usageResultCache.size > maxEntries ||
366
+ this._usageResultCacheWeight > maxWeight) {
367
+ const oldest = this._usageResultCache.entries().next().value;
368
+ if (!oldest) break;
369
+ this._usageResultCache.delete(oldest[0]);
370
+ this._usageResultCacheWeight -= oldest[1].weight;
371
+ }
372
+ }
299
373
  return usages;
300
374
  } catch (e) {
301
375
  return null;
@@ -327,6 +401,14 @@ class ProjectIndex {
327
401
  const startTime = Date.now();
328
402
  const quiet = options.quiet !== false;
329
403
 
404
+ // Build/discovery can add, remove, or reclassify files. Do not retain
405
+ // a text-universe answer across that boundary. Hash-keyed usage
406
+ // results remain safe and useful for unchanged files.
407
+ this._groundSetCache = new Map();
408
+ this._groundSetCacheLines = 0;
409
+ this._nameBindingReachCache = new Map();
410
+ this._returnTypeFlowCache = new Map();
411
+
330
412
  // A (re)build invalidates any cache-loaded reachability set — the
331
413
  // fingerprint guard in computeReachability is content-shaped and
332
414
  // cannot see every rebuild (fix #249: a stale loaded set survived
@@ -1169,7 +1251,7 @@ class ProjectIndex {
1169
1251
  const typeOrder = new Set([
1170
1252
  'class', 'struct', 'interface', 'type', 'impl', 'enum', 'record',
1171
1253
  ]);
1172
- const { isTestPath } = require('./shared');
1254
+ const { isTestPath, CALLABLE_SYMBOL_KINDS } = require('./shared');
1173
1255
  const scored = definitions.map(d => {
1174
1256
  let score = 0;
1175
1257
  const rp = d.relativePath || '';
@@ -1200,10 +1282,21 @@ class ProjectIndex {
1200
1282
  // Deprioritize type-only overload signatures (TypeScript function_signature)
1201
1283
  if (d.isSignature) score -= 200;
1202
1284
  // Prefer larger function bodies (implementation over overload signature)
1203
- // Only for functions/methods — not for class-level types (struct vs impl)
1285
+ // Only for functions — not for class-level types (struct vs impl).
1286
+ // Same-named METHODS keep tying here on purpose: widening this to
1287
+ // every callable kind reshuffled method-vs-method picks project-wide
1288
+ // (httpx `send`), which is not the fix #343 defect.
1204
1289
  if (d.startLine && d.endLine && d.type === 'function') {
1205
1290
  score += Math.min(d.endLine - d.startLine, 100);
1206
1291
  }
1292
+ // Fix #343: a bare name denotes the CALLABLE when a field shares it.
1293
+ // A Rust/Go/Java method used to tie with a same-named field (the
1294
+ // builder idiom: `heap_limit` field + `heap_limit(&mut self)` setter)
1295
+ // and lose on file order, so `impact heap_limit` answered 0 call
1296
+ // sites against the field while the setter had 10 confirmed callers.
1297
+ // Callable kinds tie among themselves as before; only the
1298
+ // callable-vs-field tie is decided (a zero-line Java getter too).
1299
+ if (CALLABLE_SYMBOL_KINDS.has(d.type)) score += 25;
1207
1300
  // Prefer shallower paths (fewer directory levels = more central to project)
1208
1301
  // Max bonus 50 for root-level files, decreasing with depth
1209
1302
  const depth = (rp.match(/\//g) || []).length;
@@ -2414,6 +2507,11 @@ class ProjectIndex {
2414
2507
  /** Load index from cache file */
2415
2508
  loadCache(cachePath) { return indexCache.loadCache(this, cachePath); }
2416
2509
 
2510
+ /** Persist the bounded, content-hash-keyed usage-query cache. */
2511
+ saveUsageCache(cachePath = undefined) {
2512
+ return indexCache.saveUsageCache(this, cachePath);
2513
+ }
2514
+
2417
2515
  /** Return this project's default per-user cache file path. */
2418
2516
  getCachePath() { return indexCache.getProjectCachePath(this.root); }
2419
2517