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.
- package/.claude/skills/ucn/SKILL.md +59 -3
- package/.claude/skills/ucn/references/commands.md +3 -1
- package/README.md +158 -533
- package/cli/index.js +71 -10
- package/core/analysis.js +178 -14
- package/core/cache.js +22 -13
- package/core/callers.js +51 -52
- package/core/check.js +2 -2
- package/core/execute.js +24 -6
- package/core/graph.js +167 -35
- package/core/index-ir.js +12 -9
- package/core/output/analysis-ext.js +4 -0
- package/core/output/analysis.js +30 -2
- package/core/output/endpoints.js +1 -1
- package/core/output/graph.js +60 -11
- package/core/output/lines.js +272 -0
- package/core/output/public.js +15 -0
- package/core/output/reporting.js +9 -2
- package/core/output/tracing.js +10 -2
- package/core/output-budget.js +7 -4
- package/core/project.js +15 -2
- package/core/registry.js +7 -6
- package/core/reporting.js +159 -14
- package/languages/javascript.js +213 -6
- package/languages/python.js +167 -39
- package/mcp/server.js +3 -1
- package/package.json +2 -2
- package/assets/demo.svg +0 -31
|
@@ -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
|
package/core/output/analysis.js
CHANGED
|
@@ -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:`);
|
package/core/output/endpoints.js
CHANGED
|
@@ -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('
|
|
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}`);
|
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,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 };
|
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/tracing.js
CHANGED
|
@@ -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
|
|
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
|
|
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'})`);
|
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
|
@@ -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
|
|
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;
|