ucn 5.2.2 → 5.3.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.
@@ -215,6 +215,10 @@ function formatDiffImpact(result, options = {}) {
215
215
  if (result.nonSourcePaths > 0) {
216
216
  lines.push(`Note: ${result.nonSourcePaths} changed path(s) outside supported source files not analyzed.`);
217
217
  }
218
+ // fix #346: untracked source files join the working-tree diff.
219
+ if (result.untrackedPaths > 0) {
220
+ lines.push(`Note: ${result.untrackedPaths} untracked source file(s) included as whole-file additions.`);
221
+ }
218
222
  lines.push('');
219
223
 
220
224
  // Modified functions
@@ -786,8 +786,8 @@ function formatImpact(impact, options = {}) {
786
786
  // Summary (confirmed + unverified tiers reported separately)
787
787
  const impactUnverified = impact.unverifiedSites || [];
788
788
  const unverifiedSuffix = impactUnverified.length > 0 ? ` confirmed + ${impactUnverified.length} unverified` : '';
789
- if (impact.propertyAccesses) {
790
- const pa = impact.propertyAccesses;
789
+ if (impact.propertyAccesses || impact.typeReferences) {
790
+ const pa = impact.propertyAccesses || impact.typeReferences;
791
791
  const uv = pa.unverifiedCount ? ` + ${pa.unverifiedCount} unverified` : '';
792
792
  lines.push(`DEPENDENCY SITES: ${impact.totalDependencySites} confirmed${uv}`);
793
793
  }
@@ -885,6 +885,34 @@ function formatImpact(impact, options = {}) {
885
885
  }
886
886
  }
887
887
 
888
+ // fix #345: annotation sites of a type-kind definition, tiered like the
889
+ // accessor band. The headline no longer says 0 for a type with dependents.
890
+ if (impact.typeReferences) {
891
+ const refs = impact.typeReferences;
892
+ lines.push(`${compact ? '' : '\n'}TYPE REFERENCE SITES: ${refs.confirmedCount} confirmed` +
893
+ (refs.unverifiedCount ? ` + ${refs.unverifiedCount} unverified` : '') +
894
+ (refs.excluded?.total ? ` (${refs.excluded.total} other-target)` : ''));
895
+ for (const group of refs.byFile) {
896
+ for (const site of group.sites) {
897
+ const expr = site.expression ? `: ${site.expression.replace(/\s+/g, ' ').slice(0, 100)}` : '';
898
+ lines.push(` ${group.file}:${site.line}${expr}`);
899
+ }
900
+ }
901
+ if (refs.unverifiedSites.length > 0) {
902
+ lines.push(`${compact ? '' : '\n'}UNVERIFIED TYPE REFERENCE CANDIDATES (${refs.unverifiedSites.length}) — name matches, no import link to this definition:`);
903
+ for (const site of refs.unverifiedSites.slice(0, 10)) {
904
+ const expr = site.expression ? `: ${site.expression.replace(/\s+/g, ' ').slice(0, 100)}` : '';
905
+ lines.push(` ${site.file}:${site.line}${expr} (${site.reason})`);
906
+ }
907
+ if (refs.unverifiedSites.length > 10) {
908
+ lines.push(` (+${refs.unverifiedSites.length - 10} more unverified)`);
909
+ }
910
+ }
911
+ if (refs.confirmedCount === 0 && refs.unverifiedCount === 0) {
912
+ lines.push(' (no annotation sites outside the definition)');
913
+ }
914
+ }
915
+
888
916
  // Unverified tier: visible, capped at 10 one-liners
889
917
  if (impactUnverified.length > 0) {
890
918
  lines.push(`${compact ? '' : '\n'}UNVERIFIED CALL SITES (${impactUnverified.length}) — call syntax, no binding/receiver evidence:`);
@@ -84,7 +84,7 @@ function formatRoutesAndRequests(routes, requests, meta, options, advisory = nul
84
84
 
85
85
  if (showClient) {
86
86
  if (requests.length === 0) {
87
- if (showServer) lines.push('No client requests detected.');
87
+ if (showServer) lines.push('Client Requests: 0 — no static route literal found in any indexed file (wrapped or dynamically built request paths are invisible to this scan).');
88
88
  } else {
89
89
  if (showServer) lines.push('');
90
90
  lines.push(`Client Requests: ${requests.length}`);
@@ -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,272 @@
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
+ if (result.untrackedPaths) notes.push(`# ${result.untrackedPaths} untracked source file(s) included as whole-file additions.`);
188
+ return { records: [...new Set(out)], notes };
189
+ }
190
+ for (const group of result.byFile || []) {
191
+ for (const site of group.sites || []) {
192
+ const tag = site.tier && site.tier !== 'confirmed' ? unverifiedTag(site) : '';
193
+ out.push(record(group.file, site.line, site.expression, tag));
194
+ }
195
+ }
196
+ for (const site of result.unverifiedSites || []) {
197
+ out.push(record(pathOf(site), site.line, site.content || site.expression, unverifiedTag(site)));
198
+ }
199
+ if (result.propertyAccesses) {
200
+ const accesses = result.propertyAccesses;
201
+ for (const group of accesses.byFile || []) {
202
+ for (const access of group.sites || []) {
203
+ out.push(record(group.file, access.line, access.expression, 'property-access'));
204
+ }
205
+ }
206
+ for (const access of accesses.unverifiedSites || []) {
207
+ out.push(record(pathOf(access), access.line, access.expression, `${unverifiedTag(access)}; property-access`));
208
+ }
209
+ notes.push(`# PROPERTY ACCESS SITES: ${accesses.confirmedCount} confirmed, ${accesses.unverifiedCount} unverified, ${accesses.excluded?.total || 0} other-target (separate from caller ACCOUNT).`);
210
+ }
211
+ if (result.typeReferences) {
212
+ const refs = result.typeReferences;
213
+ for (const group of refs.byFile || []) {
214
+ for (const site of group.sites || []) {
215
+ out.push(record(group.file, site.line, site.expression, 'type-reference'));
216
+ }
217
+ }
218
+ for (const site of refs.unverifiedSites || []) {
219
+ out.push(record(pathOf(site), site.line, site.expression, `unverified: ${site.reason}; type-reference`));
220
+ }
221
+ notes.push(`# TYPE REFERENCE SITES: ${refs.confirmedCount} confirmed, ${refs.unverifiedCount} unverified, ${refs.excluded?.total || 0} other-target (separate from caller ACCOUNT).`);
222
+ }
223
+ notes.push(...accountComments(result.account));
224
+ for (const warning of result.warnings || []) notes.push(...commentLines(warning.message));
225
+ if (result.scopeWarning?.hint) notes.push(...commentLines(result.scopeWarning.hint));
226
+ if (result.shownCallSites < result.totalCallSites) {
227
+ notes.push(`# ${result.totalCallSites - result.shownCallSites} more call site(s) omitted by --top/--limit`);
228
+ }
229
+ return { records: out, notes };
230
+ }
231
+
232
+ /**
233
+ * Render a public command result as grep-shaped records followed by `# `
234
+ * comment lines. Returns '' when the command has nothing to list.
235
+ */
236
+ function formatPublicLines(command, result, params = {}, execution = {}) {
237
+ let shaped;
238
+ switch (command) {
239
+ case 'find': shaped = findRecords(result); break;
240
+ case 'usages': shaped = usagesRecords(result); break;
241
+ case 'search': shaped = searchRecords(result); break;
242
+ case 'show': shaped = showRecords(result, params); break;
243
+ case 'impact': shaped = impactRecords(result); break;
244
+ default: return null;
245
+ }
246
+ const lines = [...shaped.records, ...shaped.notes];
247
+ if (execution.note) lines.push(...commentLines(execution.note));
248
+ return lines.map(line => line.startsWith('# ')
249
+ ? commentLines(formatSurfaceMessage(line.slice(2), execution.surface)).join('\n') : line).join('\n');
250
+ }
251
+
252
+ /**
253
+ * Render a `source` result as the code text alone.
254
+ */
255
+ function formatPublicRaw(result, execution = {}) {
256
+ let code = '';
257
+ if (result && Array.isArray(result.lines)) code = result.lines.join('\n');
258
+ else if (result && Array.isArray(result.entries)) {
259
+ code = result.entries.map(entry => entry.code == null ? '' : String(entry.code)).join('\n\n');
260
+ } else if (result && typeof result.code === 'string') code = result.code;
261
+ // A note (the same-name disambiguation, a hidden-section warning) must
262
+ // not vanish in raw mode. Code lines are never reinterpreted, so it
263
+ // cannot ride inside the text: the CLI prints `execution.note` to stderr
264
+ // itself (see emitCliText); the single-block surfaces get it appended as
265
+ // one trailing `# ` line after the code.
266
+ if (execution.note && execution.surface !== 'cli') {
267
+ return `${code.replace(/\n$/, '')}\n${commentLines(execution.note).join('\n')}`;
268
+ }
269
+ return code;
270
+ }
271
+
272
+ 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}`);
@@ -532,6 +532,14 @@ function formatReverseTraceJson(result) {
532
532
  /**
533
533
  * Format affected-tests command output - text
534
534
  */
535
+ // fix #347: a hub symbol at depth 2 links hundreds of names per test file;
536
+ // the list is the answer only for leaves. Cap at 8 unless --all.
537
+ function linkList(names, options) {
538
+ const MAX_LINKS = options?.all ? Infinity : 8;
539
+ if (!Array.isArray(names) || names.length <= MAX_LINKS) return (names || []).join(', ');
540
+ return `${names.slice(0, MAX_LINKS).join(', ')}, +${names.length - MAX_LINKS} more`;
541
+ }
542
+
535
543
  function formatAffectedTests(result, options = {}) {
536
544
  if (!result) return 'Function not found.';
537
545
 
@@ -553,7 +561,7 @@ function formatAffectedTests(result, options = {}) {
553
561
  lines.push(`Test files to run (${summary.totalTestFiles}):`);
554
562
  lines.push('');
555
563
  for (const tf of displayFiles) {
556
- lines.push(` ${tf.file} (links: ${tf.linkedFunctions.join(', ')})`);
564
+ lines.push(` ${tf.file} (links: ${linkList(tf.linkedFunctions, options)})`);
557
565
  // Show up to 5 key matches per file
558
566
  const keyMatches = tf.matches
559
567
  .filter(m => m.matchType === 'call' || m.matchType === 'test-case')
@@ -581,7 +589,7 @@ function formatAffectedTests(result, options = {}) {
581
589
  lines.push(` Additional test files (${pat.length}):`);
582
590
  const MAX_POSSIBLE = options.all ? Infinity : 10;
583
591
  for (const tf of pat.slice(0, MAX_POSSIBLE)) {
584
- lines.push(` ${tf.file} (links: ${tf.linkedFunctions.join(', ')})`);
592
+ lines.push(` ${tf.file} (links: ${linkList(tf.linkedFunctions, options)})`);
585
593
  }
586
594
  if (pat.length > MAX_POSSIBLE) {
587
595
  lines.push(` ... ${pat.length - MAX_POSSIBLE} more (${options.allHint || 'use --all'})`);
@@ -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
@@ -149,6 +149,7 @@ class ProjectIndex {
149
149
  this._opCppPathReceiverTypeCache = new Map();
150
150
  this._opDerefPairs = undefined;
151
151
  this._opAliasPairs = undefined;
152
+ this._opFindCallersCaches = null; // fix #340: findCallers per-file derivations
152
153
  this._opDepth = 0;
153
154
  }
154
155
  this._opDepth++;
@@ -178,6 +179,7 @@ class ProjectIndex {
178
179
  this._opCppPathReceiverTypeCache = null;
179
180
  this._opDerefPairs = null;
180
181
  this._opAliasPairs = null;
182
+ this._opFindCallersCaches = null;
181
183
  // Free cached file content from callsCache entries (retained during
182
184
  // operation for _readFile caching, not needed between operations)
183
185
  for (const entry of this.callsCache.values()) {
@@ -1249,7 +1251,7 @@ class ProjectIndex {
1249
1251
  const typeOrder = new Set([
1250
1252
  'class', 'struct', 'interface', 'type', 'impl', 'enum', 'record',
1251
1253
  ]);
1252
- const { isTestPath } = require('./shared');
1254
+ const { isTestPath, CALLABLE_SYMBOL_KINDS } = require('./shared');
1253
1255
  const scored = definitions.map(d => {
1254
1256
  let score = 0;
1255
1257
  const rp = d.relativePath || '';
@@ -1280,10 +1282,21 @@ class ProjectIndex {
1280
1282
  // Deprioritize type-only overload signatures (TypeScript function_signature)
1281
1283
  if (d.isSignature) score -= 200;
1282
1284
  // Prefer larger function bodies (implementation over overload signature)
1283
- // 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.
1284
1289
  if (d.startLine && d.endLine && d.type === 'function') {
1285
1290
  score += Math.min(d.endLine - d.startLine, 100);
1286
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;
1287
1300
  // Prefer shallower paths (fewer directory levels = more central to project)
1288
1301
  // Max bonus 50 for root-level files, decreasing with depth
1289
1302
  const depth = (rp.match(/\//g) || []).length;