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.
- package/.claude/skills/ucn/SKILL.md +49 -3
- package/.claude/skills/ucn/references/commands.md +3 -1
- package/README.md +158 -533
- package/cli/index.js +74 -10
- package/core/account.js +36 -8
- package/core/cache.js +167 -21
- package/core/callers.js +1229 -162
- package/core/execute.js +24 -6
- package/core/graph.js +167 -35
- package/core/index-ir.js +17 -12
- package/core/ir.js +56 -8
- package/core/output/graph.js +60 -11
- package/core/output/lines.js +259 -0
- package/core/output/public.js +15 -0
- package/core/output/reporting.js +9 -2
- package/core/output-budget.js +7 -4
- package/core/project.js +103 -5
- package/core/registry.js +7 -6
- package/core/reporting.js +159 -14
- package/languages/c-family.js +19 -17
- package/languages/go.js +170 -42
- package/languages/javascript.js +470 -15
- package/languages/python.js +678 -38
- package/languages/rust.js +1 -0
- package/mcp/server.js +3 -1
- package/package.json +2 -2
- package/assets/demo.svg +0 -31
package/core/output/graph.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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'}
|
|
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 };
|
package/core/output/public.js
CHANGED
|
@@ -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 = {
|
package/core/output/reporting.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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}`);
|
package/core/output-budget.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|