ucn 3.8.23 → 3.8.26

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.
Files changed (44) hide show
  1. package/.claude/skills/ucn/SKILL.md +127 -12
  2. package/README.md +152 -156
  3. package/cli/index.js +363 -37
  4. package/core/analysis.js +936 -32
  5. package/core/bridge.js +1095 -0
  6. package/core/brief.js +408 -0
  7. package/core/cache.js +105 -5
  8. package/core/callers.js +72 -18
  9. package/core/check.js +200 -0
  10. package/core/discovery.js +57 -34
  11. package/core/entrypoints.js +638 -4
  12. package/core/execute.js +304 -5
  13. package/core/git-enrich.js +130 -0
  14. package/core/graph.js +24 -2
  15. package/core/output/analysis.js +157 -25
  16. package/core/output/brief.js +100 -0
  17. package/core/output/check.js +79 -0
  18. package/core/output/doctor.js +85 -0
  19. package/core/output/endpoints.js +239 -0
  20. package/core/output/extraction.js +2 -0
  21. package/core/output/find.js +126 -39
  22. package/core/output/graph.js +48 -15
  23. package/core/output/refactoring.js +103 -5
  24. package/core/output/reporting.js +63 -23
  25. package/core/output/search.js +110 -17
  26. package/core/output/shared.js +56 -2
  27. package/core/output.js +4 -0
  28. package/core/parser.js +8 -2
  29. package/core/project.js +39 -3
  30. package/core/registry.js +30 -14
  31. package/core/reporting.js +465 -2
  32. package/core/search.js +130 -52
  33. package/core/shared.js +101 -5
  34. package/core/tracing.js +16 -6
  35. package/core/verify.js +982 -95
  36. package/languages/go.js +91 -6
  37. package/languages/html.js +10 -0
  38. package/languages/java.js +151 -35
  39. package/languages/javascript.js +290 -33
  40. package/languages/python.js +78 -11
  41. package/languages/rust.js +267 -12
  42. package/languages/utils.js +315 -3
  43. package/mcp/server.js +91 -16
  44. package/package.json +9 -1
@@ -0,0 +1,239 @@
1
+ /**
2
+ * core/output/endpoints.js — Formatters for the `endpoints` command.
3
+ *
4
+ * Two surfaces: text (human) and JSON (machine).
5
+ *
6
+ * Layout principles:
7
+ * - Routes grouped by file when no --bridge flag.
8
+ * - With --bridge: routes listed individually, each followed by matched clients
9
+ * and a confidence tag, then unmatched routes/requests.
10
+ * - Counts at the top so the user knows the size up front.
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const SEP_TIER = { exact: 'EXACT', partial: 'PARTIAL', uncertain: 'UNCERTAIN' };
16
+
17
+ function pad(s, n) {
18
+ if (typeof s !== 'string') s = String(s);
19
+ return s.length >= n ? s : s + ' '.repeat(n - s.length);
20
+ }
21
+
22
+ function formatEndpoints(result, options = {}) {
23
+ if (!result) return 'No endpoints data.';
24
+ const { routes, requests, bridges, unmatchedRoutes, unmatchedRequests, meta } = result;
25
+ const showBridge = options.bridge;
26
+
27
+ if (!showBridge) {
28
+ return formatRoutesAndRequests(routes, requests, meta, options);
29
+ }
30
+ return formatBridges(bridges, unmatchedRoutes, unmatchedRequests, meta, options);
31
+ }
32
+
33
+ /**
34
+ * Compute the unique-request match percentage. A single client request that
35
+ * matches multiple server routes (e.g., trailing-slash dups) must count once,
36
+ * not once per bridge — otherwise "Matched: N (200%)" shows up. Returns an
37
+ * integer percentage clamped to [0, 100].
38
+ */
39
+ function uniqueMatchPercent(bridges, totalRequests) {
40
+ if (!totalRequests || totalRequests <= 0) return 0;
41
+ const matchedRequestKeys = new Set();
42
+ for (const b of bridges) {
43
+ const r = b.request;
44
+ matchedRequestKeys.add(`${r.absoluteFile || r.file}:${r.line}:${r.method}:${r.path}`);
45
+ }
46
+ const pct = Math.round((matchedRequestKeys.size / totalRequests) * 100);
47
+ return Math.min(100, Math.max(0, pct));
48
+ }
49
+
50
+ function formatRoutesAndRequests(routes, requests, meta, options) {
51
+ const lines = [];
52
+ const showServer = !options.clientOnly;
53
+ const showClient = !options.serverOnly;
54
+
55
+ if (showServer) {
56
+ if (routes.length === 0) {
57
+ lines.push('No server routes detected.');
58
+ } else {
59
+ lines.push(`Server Routes: ${routes.length}`);
60
+ const fwSummary = Object.entries(meta.byFramework || {})
61
+ .map(([k, v]) => `${k}=${v}`)
62
+ .join(', ');
63
+ if (fwSummary) lines.push(`Frameworks: ${fwSummary}`);
64
+ lines.push('');
65
+
66
+ const byFile = new Map();
67
+ for (const r of routes) {
68
+ const list = byFile.get(r.file) || [];
69
+ list.push(r);
70
+ byFile.set(r.file, list);
71
+ }
72
+ for (const [file, list] of byFile) {
73
+ lines.push(`${file}`);
74
+ for (const r of list) {
75
+ const handler = r.handler || '<anonymous>';
76
+ const fw = r.framework ? `[${r.framework}]` : '';
77
+ lines.push(` ${pad(r.method, 7)} ${pad(r.path, 40)} → ${handler} ${fw} :${r.line}`);
78
+ }
79
+ lines.push('');
80
+ }
81
+ }
82
+ }
83
+
84
+ if (showClient) {
85
+ if (requests.length === 0) {
86
+ if (showServer) lines.push('No client requests detected.');
87
+ } else {
88
+ if (showServer) lines.push('');
89
+ lines.push(`Client Requests: ${requests.length}`);
90
+ lines.push('');
91
+
92
+ const byFile = new Map();
93
+ for (const r of requests) {
94
+ const list = byFile.get(r.file) || [];
95
+ list.push(r);
96
+ byFile.set(r.file, list);
97
+ }
98
+ for (const [file, list] of byFile) {
99
+ lines.push(`${file}`);
100
+ for (const r of list) {
101
+ const inferred = r.methodInferred ? '?' : '';
102
+ const interp = r.interp ? ' (interp)' : '';
103
+ const fw = r.framework ? `[${r.framework}]` : '';
104
+ lines.push(` ${pad(r.method + inferred, 7)} ${pad(r.path + interp, 40)} from ${r.callerName} ${fw} :${r.line}`);
105
+ }
106
+ lines.push('');
107
+ }
108
+ }
109
+ }
110
+
111
+ return lines.join('\n').trimEnd();
112
+ }
113
+
114
+ function formatBridges(bridges, unmatchedRoutes, unmatchedRequests, meta, options = {}) {
115
+ const lines = [];
116
+ const matched = bridges.length;
117
+ const unmatchedOnly = !!options.unmatched;
118
+
119
+ lines.push(`Endpoint Bridges`);
120
+ lines.push(`================`);
121
+ lines.push(`Server routes: ${meta.totalRoutes} Client requests: ${meta.totalRequests}`);
122
+ // HIGH-3: percentage = unique matched client requests / total client
123
+ // requests. Counting bridges directly inflates >100% on many-to-many
124
+ // matches (trailing-slash dups, wildcard overlap, etc.).
125
+ const pct = uniqueMatchPercent(bridges, meta.totalRequests);
126
+ lines.push(`Matched: ${matched} (${pct}%) Unmatched routes: ${unmatchedRoutes.length} Unmatched requests: ${unmatchedRequests.length}`);
127
+ lines.push('');
128
+
129
+ // HIGH-2: in --unmatched mode, suppress the Matched section entirely.
130
+ if (matched > 0 && !unmatchedOnly) {
131
+ // Group bridges by route for display
132
+ const byRoute = new Map();
133
+ for (const b of bridges) {
134
+ const key = `${b.route.absoluteFile}:${b.route.line}:${b.route.method}:${b.route.path}`;
135
+ const list = byRoute.get(key) || { route: b.route, clients: [] };
136
+ list.clients.push(b);
137
+ byRoute.set(key, list);
138
+ }
139
+ // Sort routes alphabetically
140
+ const sorted = [...byRoute.values()].sort((a, b) => {
141
+ if (a.route.file !== b.route.file) return a.route.file.localeCompare(b.route.file);
142
+ return a.route.line - b.route.line;
143
+ });
144
+
145
+ lines.push(`Matched (${sorted.length} routes):`);
146
+ for (const { route, clients } of sorted) {
147
+ lines.push(` ${pad(route.method, 7)} ${route.path} [${route.framework}] ${route.file}:${route.line}`);
148
+ for (const b of clients) {
149
+ const conf = b.confidence.toFixed(2);
150
+ const tier = b.matchType.toUpperCase();
151
+ const inf = b.methodInferred ? ' method?' : '';
152
+ lines.push(` ↔ ${pad(b.request.method + inf, 9)} ${pad(b.request.path, 30)} ${tier} (${conf}) from ${b.request.callerName} ${b.request.file}:${b.request.line}`);
153
+ }
154
+ lines.push('');
155
+ }
156
+ }
157
+
158
+ if (unmatchedRoutes.length > 0) {
159
+ lines.push(`Unmatched server routes (${unmatchedRoutes.length}):`);
160
+ for (const r of unmatchedRoutes) {
161
+ lines.push(` ${pad(r.method, 7)} ${pad(r.path, 40)} → ${r.handler} [${r.framework}] ${r.file}:${r.line}`);
162
+ }
163
+ lines.push('');
164
+ }
165
+
166
+ if (unmatchedRequests.length > 0) {
167
+ lines.push(`Unmatched client requests (${unmatchedRequests.length}):`);
168
+ for (const r of unmatchedRequests) {
169
+ const inferred = r.methodInferred ? '?' : '';
170
+ const interp = r.interp ? ' (interp)' : '';
171
+ lines.push(` ${pad(r.method + inferred, 7)} ${pad(r.path + interp, 40)} from ${r.callerName} [${r.framework}] ${r.file}:${r.line}`);
172
+ }
173
+ }
174
+
175
+ return lines.join('\n').trimEnd();
176
+ }
177
+
178
+ function formatEndpointsJson(result, options = {}) {
179
+ if (!result) return JSON.stringify({ meta: {}, data: {} }, null, 2);
180
+ const { routes, requests, bridges, unmatchedRoutes, unmatchedRequests, meta } = result;
181
+ // Read `unmatched` from explicit options OR sticky result property (set by
182
+ // execute.js when the user passed --unmatched). The CLI/MCP wrappers may
183
+ // not pass options through to JSON output, so we use both as fallbacks.
184
+ const unmatchedOnly = !!(options.unmatched || result._unmatched);
185
+
186
+ const trimRoute = (r) => ({
187
+ method: r.method,
188
+ path: r.path,
189
+ normalizedPath: r.normalizedPath,
190
+ handler: r.handler,
191
+ file: r.file,
192
+ line: r.line,
193
+ framework: r.framework,
194
+ ...(r.classPrefix && { classPrefix: r.classPrefix }),
195
+ });
196
+ const trimReq = (r) => ({
197
+ method: r.method,
198
+ path: r.path,
199
+ normalizedPath: r.normalizedPath,
200
+ ...(r.interp && { interp: true }),
201
+ file: r.file,
202
+ line: r.line,
203
+ callerName: r.callerName,
204
+ ...(r.callerStartLine && { callerStartLine: r.callerStartLine }),
205
+ framework: r.framework,
206
+ ...(r.methodInferred && { methodInferred: true }),
207
+ });
208
+ const trimBridge = (b) => ({
209
+ route: trimRoute(b.route),
210
+ request: trimReq(b.request),
211
+ matchType: b.matchType,
212
+ confidence: b.confidence,
213
+ ...(b.methodInferred && { methodInferred: true }),
214
+ });
215
+
216
+ return JSON.stringify({
217
+ meta: {
218
+ ok: true,
219
+ ...meta,
220
+ // HIGH-2: signal to consumers that bridges array was suppressed
221
+ // because the user filtered to unmatched-only.
222
+ ...(unmatchedOnly && { filterMode: 'unmatched' }),
223
+ },
224
+ data: {
225
+ routes: routes.map(trimRoute),
226
+ requests: requests.map(trimReq),
227
+ // In unmatched-only mode, the matched bridges array is suppressed
228
+ // — consumers that want both should not pass --unmatched.
229
+ bridges: unmatchedOnly ? [] : bridges.map(trimBridge),
230
+ unmatchedRoutes: unmatchedRoutes.map(trimRoute),
231
+ unmatchedRequests: unmatchedRequests.map(trimReq),
232
+ },
233
+ }, null, 2);
234
+ }
235
+
236
+ module.exports = {
237
+ formatEndpoints,
238
+ formatEndpointsJson,
239
+ };
@@ -45,6 +45,7 @@ function formatFunctionJson(fn, code) {
45
45
  endLine: fn.endLine,
46
46
  modifiers: fn.modifiers || [],
47
47
  ...(fn.returnType && { returnType: fn.returnType }),
48
+ ...(fn.paramTypes && { paramTypes: fn.paramTypes }),
48
49
  ...(fn.generics && { generics: fn.generics }),
49
50
  ...(fn.docstring && { docstring: fn.docstring }),
50
51
  ...(fn.isArrow && { isArrow: true }),
@@ -82,6 +83,7 @@ function formatFnResultJson(result) {
82
83
  endLine: match.endLine,
83
84
  modifiers: match.modifiers || [],
84
85
  ...(match.returnType && { returnType: match.returnType }),
86
+ ...(match.paramTypes && { paramTypes: match.paramTypes }),
85
87
  ...(match.generics && { generics: match.generics }),
86
88
  ...(match.docstring && { docstring: match.docstring }),
87
89
  ...(match.isArrow && { isArrow: true }),
@@ -9,6 +9,20 @@ const {
9
9
  computeConfidence,
10
10
  } = require('./shared');
11
11
 
12
+ const { formatSymbolHandle } = require('../shared');
13
+
14
+ /**
15
+ * Trim a docstring to a single short sentence (max 80 chars) for inline display.
16
+ */
17
+ function firstSentenceShort(text) {
18
+ if (!text) return null;
19
+ const trimmed = text.trim();
20
+ const m = trimmed.match(/^(.+?[.!?])(?:\s|$)/);
21
+ let s = m ? m[1] : trimmed;
22
+ if (s.length > 80) s = s.slice(0, 77) + '...';
23
+ return s;
24
+ }
25
+
12
26
  /**
13
27
  * Format find command output
14
28
  */
@@ -34,6 +48,10 @@ function formatFind(symbols, query, top) {
34
48
  ? formatFunctionSignature(s)
35
49
  : formatClassSignature(s);
36
50
  lines.push(`${s.relativePath}:${s.startLine} ${sig}`);
51
+ if (s.docstring) {
52
+ const snip = firstSentenceShort(s.docstring);
53
+ if (snip) lines.push(` "${snip}"`);
54
+ }
37
55
  if (s.usageCounts !== undefined) {
38
56
  const c = s.usageCounts;
39
57
  const parts = [];
@@ -57,6 +75,7 @@ function formatFind(symbols, query, top) {
57
75
  }
58
76
 
59
77
  function formatFindJson(items) {
78
+ const { formatSymbolHandle } = require('../shared');
60
79
  return JSON.stringify({
61
80
  meta: { command: 'find', count: items.length },
62
81
  data: items.map(m => ({
@@ -65,6 +84,7 @@ function formatFindJson(items) {
65
84
  file: m.relativePath || m.file,
66
85
  line: m.startLine,
67
86
  endLine: m.endLine,
87
+ handle: formatSymbolHandle(m),
68
88
  ...(m.className && { className: m.className }),
69
89
  ...(m.receiver && { receiver: m.receiver }),
70
90
  })),
@@ -80,7 +100,7 @@ function formatFindJson(items) {
80
100
  * @param {object} options - { depth, top, all }
81
101
  */
82
102
  function formatFindDetailed(symbols, query, options = {}) {
83
- const { depth, top, all } = options;
103
+ const { depth, top, all, compact } = options;
84
104
  const DEFAULT_LIMIT = 5;
85
105
 
86
106
  if (symbols.length === 0) {
@@ -97,7 +117,7 @@ function formatFindDetailed(symbols, query, options = {}) {
97
117
  } else {
98
118
  lines.push(`Found ${symbols.length} match(es) for "${query}":`);
99
119
  }
100
- lines.push('─'.repeat(60));
120
+ if (!compact) lines.push('─'.repeat(60));
101
121
 
102
122
  for (let i = 0; i < showing; i++) {
103
123
  const s = symbols[i];
@@ -114,8 +134,30 @@ function formatFindDetailed(symbols, query, options = {}) {
114
134
 
115
135
  const confidence = computeConfidence(s);
116
136
  const confStr = confidence.level !== 'high' ? ` [${confidence.level}]` : '';
137
+ const handle = formatSymbolHandle(s);
138
+ const loc = handle || (s.relativePath + ':' + s.startLine);
139
+
140
+ if (compact) {
141
+ // One line per result: "<handle> <sig> <usages?> <doc snippet?>"
142
+ const parts = [`${loc} ${sig}${confStr}`];
143
+ if (s.usageCounts !== undefined && s.usageCounts.total > 0) {
144
+ parts.push(`(${s.usageCounts.total} usages)`);
145
+ } else if (s.usageCount !== undefined) {
146
+ parts.push(`(${s.usageCount} usages)`);
147
+ }
148
+ if (s.docstring) {
149
+ const snip = firstSentenceShort(s.docstring);
150
+ if (snip) parts.push(`— ${snip}`);
151
+ }
152
+ lines.push(parts.join(' '));
153
+ continue;
154
+ }
117
155
 
118
- lines.push(`${s.relativePath}:${s.startLine} ${sig}${confStr}`);
156
+ lines.push(`${loc} ${sig}${confStr}`);
157
+ if (s.docstring) {
158
+ const snip = firstSentenceShort(s.docstring);
159
+ if (snip) lines.push(` "${snip}"`);
160
+ }
119
161
  if (s.usageCounts !== undefined) {
120
162
  const c = s.usageCounts;
121
163
  const parts = [];
@@ -150,7 +192,7 @@ function formatFindDetailed(symbols, query, options = {}) {
150
192
  // Skip code extraction on error
151
193
  }
152
194
  }
153
- lines.push('');
195
+ if (!compact) lines.push('');
154
196
  }
155
197
 
156
198
  if (hidden > 0) {
@@ -164,6 +206,7 @@ function formatFindDetailed(symbols, query, options = {}) {
164
206
  * Format symbol search results as JSON
165
207
  */
166
208
  function formatSymbolJson(symbols, query) {
209
+ const { formatSymbolHandle } = require('../shared');
167
210
  return JSON.stringify({
168
211
  meta: { complete: true, skipped: 0, dynamicImports: 0, uncertain: 0 },
169
212
  data: {
@@ -175,9 +218,12 @@ function formatSymbolJson(symbols, query) {
175
218
  file: s.relativePath || s.file,
176
219
  startLine: s.startLine,
177
220
  endLine: s.endLine,
221
+ handle: formatSymbolHandle(s),
178
222
  ...(s.params && { params: s.params }), // FULL params
179
223
  ...(s.paramsStructured && { paramsStructured: s.paramsStructured }),
180
224
  ...(s.returnType && { returnType: s.returnType }),
225
+ ...(s.paramTypes && { paramTypes: s.paramTypes }),
226
+ ...(s.docstring && { docstring: s.docstring }),
181
227
  ...(s.modifiers && { modifiers: s.modifiers }),
182
228
  ...(s.usageCount !== undefined && { usageCount: s.usageCount }),
183
229
  ...(s.usageCounts !== undefined && { usageCounts: s.usageCounts })
@@ -190,6 +236,7 @@ function formatSymbolJson(symbols, query) {
190
236
  * Format usages as JSON - FULL expressions, never truncated
191
237
  */
192
238
  function formatUsagesJson(usages, name) {
239
+ const { formatSymbolHandle } = require('../shared');
193
240
  const definitions = usages.filter(u => u.isDefinition);
194
241
  const refs = usages.filter(u => !u.isDefinition);
195
242
 
@@ -197,14 +244,29 @@ function formatUsagesJson(usages, name) {
197
244
  const imports = refs.filter(u => u.usageType === 'import');
198
245
  const references = refs.filter(u => u.usageType === 'reference');
199
246
 
200
- const formatUsage = (u) => ({
201
- file: u.relativePath || u.file,
202
- line: u.line,
203
- expression: u.content, // FULL expression - key improvement
204
- ...(u.args && { args: u.args }), // Parsed arguments
205
- ...(u.before && u.before.length > 0 && { before: u.before }),
206
- ...(u.after && u.after.length > 0 && { after: u.after })
207
- });
247
+ // Each usage record points at a call site. We emit a per-occurrence handle
248
+ // pointing at the SITE itself in the form "relativePath:line:callerName"
249
+ // (or "relativePath:line:_topLevel" when the call is at module scope and has
250
+ // no enclosing function). When the enclosing function position is known, we
251
+ // also emit `enclosingHandle` as a jump-back target to the function head.
252
+ const formatUsage = (u) => {
253
+ const file = u.relativePath || u.file;
254
+ const callerToken = u.callerName || '_topLevel';
255
+ const handle = `${file}:${u.line}:${callerToken}`;
256
+ const enclosingHandle = (u.callerStartLine && u.callerName)
257
+ ? `${file}:${u.callerStartLine}:${u.callerName}`
258
+ : undefined;
259
+ return {
260
+ file,
261
+ line: u.line,
262
+ handle,
263
+ ...(enclosingHandle && { enclosingHandle }),
264
+ expression: u.content, // FULL expression - key improvement
265
+ ...(u.args && { args: u.args }), // Parsed arguments
266
+ ...(u.before && u.before.length > 0 && { before: u.before }),
267
+ ...(u.after && u.after.length > 0 && { after: u.after })
268
+ };
269
+ };
208
270
 
209
271
  return JSON.stringify({
210
272
  meta: { complete: true, skipped: 0, dynamicImports: 0, uncertain: 0 },
@@ -215,15 +277,20 @@ function formatUsagesJson(usages, name) {
215
277
  importCount: imports.length,
216
278
  referenceCount: references.length,
217
279
  totalUsages: refs.length,
218
- definitions: definitions.map(d => ({
219
- file: d.relativePath || d.file,
220
- line: d.line,
221
- signature: d.signature || null, // FULL signature
222
- type: d.type || null,
223
- ...(d.returnType && { returnType: d.returnType }),
224
- ...(d.before && d.before.length > 0 && { before: d.before }),
225
- ...(d.after && d.after.length > 0 && { after: d.after })
226
- })),
280
+ definitions: definitions.map(d => {
281
+ const handle = formatSymbolHandle({ ...d, name: d.name || name });
282
+ return {
283
+ file: d.relativePath || d.file,
284
+ line: d.line,
285
+ ...(handle && { handle }),
286
+ signature: d.signature || null, // FULL signature
287
+ type: d.type || null,
288
+ ...(d.returnType && { returnType: d.returnType }),
289
+ ...(d.docstring && { docstring: d.docstring }),
290
+ ...(d.before && d.before.length > 0 && { before: d.before }),
291
+ ...(d.after && d.after.length > 0 && { after: d.after })
292
+ };
293
+ }),
227
294
  calls: calls.map(formatUsage),
228
295
  imports: imports.map(formatUsage),
229
296
  references: references.map(formatUsage)
@@ -234,7 +301,8 @@ function formatUsagesJson(usages, name) {
234
301
  /**
235
302
  * Format usages command output
236
303
  */
237
- function formatUsages(usages, name) {
304
+ function formatUsages(usages, name, options = {}) {
305
+ const compact = !!options.compact;
238
306
  const defs = usages.filter(u => u.isDefinition);
239
307
  const calls = usages.filter(u => u.usageType === 'call');
240
308
  const imports = usages.filter(u => u.usageType === 'import');
@@ -242,7 +310,7 @@ function formatUsages(usages, name) {
242
310
 
243
311
  const lines = [];
244
312
  lines.push(`Usages of "${name}": ${defs.length} definitions, ${calls.length} calls, ${imports.length} imports, ${refs.length} references`);
245
- lines.push('═'.repeat(60));
313
+ if (!compact) lines.push('═'.repeat(60));
246
314
 
247
315
  function renderContextLines(usage) {
248
316
  if (usage.before && usage.before.length > 0) {
@@ -261,38 +329,57 @@ function formatUsages(usages, name) {
261
329
  }
262
330
 
263
331
  if (defs.length > 0) {
264
- lines.push('\nDEFINITIONS:');
332
+ lines.push(`${compact ? '' : '\n'}DEFINITIONS:`);
265
333
  for (const d of defs) {
266
- lines.push(` ${d.relativePath}:${d.line || d.startLine}`);
267
- if (d.signature) lines.push(` ${d.signature}`);
334
+ if (compact) {
335
+ lines.push(` ${d.relativePath}:${d.line || d.startLine}${d.signature ? ' ' + d.signature : ''}`);
336
+ } else {
337
+ lines.push(` ${d.relativePath}:${d.line || d.startLine}`);
338
+ if (d.signature) lines.push(` ${d.signature}`);
339
+ }
268
340
  }
269
341
  }
270
342
 
271
343
  if (calls.length > 0) {
272
- lines.push('\nCALLS:');
344
+ lines.push(`${compact ? '' : '\n'}CALLS:`);
273
345
  for (const c of calls) {
274
- lines.push(` ${c.relativePath}:${c.line}`);
275
- renderContextLines(c);
276
- lines.push(` ${c.content.trim()}`);
277
- renderAfterLines(c);
346
+ if (compact) {
347
+ const expr = c.content ? c.content.trim().replace(/\s+/g, ' ').slice(0, 100) : '';
348
+ lines.push(` ${c.relativePath}:${c.line}: ${expr}`);
349
+ } else {
350
+ lines.push(` ${c.relativePath}:${c.line}`);
351
+ renderContextLines(c);
352
+ lines.push(` ${c.content.trim()}`);
353
+ renderAfterLines(c);
354
+ }
278
355
  }
279
356
  }
280
357
 
281
358
  if (imports.length > 0) {
282
- lines.push('\nIMPORTS:');
359
+ lines.push(`${compact ? '' : '\n'}IMPORTS:`);
283
360
  for (const i of imports) {
284
- lines.push(` ${i.relativePath}:${i.line}`);
285
- lines.push(` ${i.content.trim()}`);
361
+ if (compact) {
362
+ const expr = i.content ? i.content.trim().replace(/\s+/g, ' ').slice(0, 100) : '';
363
+ lines.push(` ${i.relativePath}:${i.line}: ${expr}`);
364
+ } else {
365
+ lines.push(` ${i.relativePath}:${i.line}`);
366
+ lines.push(` ${i.content.trim()}`);
367
+ }
286
368
  }
287
369
  }
288
370
 
289
371
  if (refs.length > 0) {
290
- lines.push('\nREFERENCES:');
372
+ lines.push(`${compact ? '' : '\n'}REFERENCES:`);
291
373
  for (const r of refs) {
292
- lines.push(` ${r.relativePath}:${r.line}`);
293
- renderContextLines(r);
294
- lines.push(` ${r.content.trim()}`);
295
- renderAfterLines(r);
374
+ if (compact) {
375
+ const expr = r.content ? r.content.trim().replace(/\s+/g, ' ').slice(0, 100) : '';
376
+ lines.push(` ${r.relativePath}:${r.line}: ${expr}`);
377
+ } else {
378
+ lines.push(` ${r.relativePath}:${r.line}`);
379
+ renderContextLines(r);
380
+ lines.push(` ${r.content.trim()}`);
381
+ renderAfterLines(r);
382
+ }
296
383
  }
297
384
  }
298
385
 
@@ -105,6 +105,22 @@ function formatExportersJson(exporters, filePath) {
105
105
  }, null, 2);
106
106
  }
107
107
 
108
+ /**
109
+ * Dedup exported/api symbols. TypeScript overloads emit one symbol per overload
110
+ * declaration with identical name/signature; collapse them to a single entry.
111
+ * Key is "name|signature" (falls back to name|startLine when no signature).
112
+ */
113
+ function dedupExportSymbols(symbols) {
114
+ if (!Array.isArray(symbols)) return symbols;
115
+ const seen = new Map();
116
+ for (const s of symbols) {
117
+ const sig = s.signature || (s.params !== undefined ? `${s.name}(${s.params})` : '');
118
+ const key = sig ? `${s.name}|${sig}` : `${s.name}|${s.startLine}`;
119
+ if (!seen.has(key)) seen.set(key, s);
120
+ }
121
+ return Array.from(seen.values());
122
+ }
123
+
108
124
  /**
109
125
  * Format file-exports command output
110
126
  */
@@ -112,9 +128,10 @@ function formatFileExports(exports, filePath) {
112
128
  if (exports?.error) return formatFileError(exports, filePath);
113
129
  if (exports.length === 0) return `No exports found in ${filePath}`;
114
130
 
131
+ const deduped = dedupExportSymbols(exports);
115
132
  const lines = [];
116
133
  lines.push(`Exports from ${filePath}:\n`);
117
- for (const exp of exports) {
134
+ for (const exp of deduped) {
118
135
  lines.push(` ${lineRange(exp.startLine, exp.endLine)} ${exp.signature || exp.name}`);
119
136
  }
120
137
  return lines.join('\n');
@@ -123,11 +140,13 @@ function formatFileExports(exports, filePath) {
123
140
  function formatFileExportsJson(result, filePath) {
124
141
  if (!result) return JSON.stringify({ found: false });
125
142
  // result is an array of exported symbols from index.fileExports()
143
+ const rawExports = Array.isArray(result) ? result : (result.exports || []);
144
+ const exports = dedupExportSymbols(rawExports);
126
145
  return JSON.stringify({
127
146
  meta: { command: 'fileExports', file: filePath || (Array.isArray(result) ? result[0]?.file : result.file) },
128
147
  data: {
129
148
  file: filePath || (Array.isArray(result) ? result[0]?.file : result.file),
130
- exports: Array.isArray(result) ? result : (result.exports || []),
149
+ exports,
131
150
  },
132
151
  }, null, 2);
133
152
  }
@@ -148,7 +167,7 @@ function formatApi(symbols, filePath) {
148
167
  lines.push('Note: Python requires __all__ for export detection. Use \'toc\' command to see all functions/classes.');
149
168
  }
150
169
  } else {
151
- // Group by file
170
+ // Group by file, then dedup overloads within each file group.
152
171
  const byFile = new Map();
153
172
  for (const sym of symbols) {
154
173
  if (!byFile.has(sym.file)) {
@@ -159,7 +178,8 @@ function formatApi(symbols, filePath) {
159
178
 
160
179
  for (const [file, syms] of byFile) {
161
180
  lines.push(file);
162
- for (const s of syms) {
181
+ const dedupedSyms = dedupExportSymbols(syms);
182
+ for (const s of dedupedSyms) {
163
183
  const sig = s.signature || `${s.type} ${s.name}`;
164
184
  lines.push(` ${lineRange(s.startLine, s.endLine)} ${sig}`);
165
185
  }
@@ -174,19 +194,32 @@ function formatApi(symbols, filePath) {
174
194
  * Format api as JSON
175
195
  */
176
196
  function formatApiJson(symbols, filePath) {
197
+ const { formatSymbolHandle } = require('../shared');
198
+ const deduped = dedupExportSymbols(symbols);
177
199
  return JSON.stringify({
200
+ meta: { command: 'api', count: deduped.length },
178
201
  ...(filePath && { file: filePath }),
179
- exportCount: symbols.length,
180
- exports: symbols.map(s => ({
181
- name: s.name,
182
- type: s.type,
183
- file: s.file,
184
- startLine: s.startLine,
185
- endLine: s.endLine,
186
- ...(s.params && { params: s.params }),
187
- ...(s.returnType && { returnType: s.returnType }),
188
- ...(s.signature && { signature: s.signature })
189
- }))
202
+ data: {
203
+ exportCount: deduped.length,
204
+ exports: deduped.map(s => {
205
+ const handleSym = { ...s, relativePath: s.relativePath || s.file };
206
+ const handle = formatSymbolHandle(handleSym);
207
+ return {
208
+ name: s.name,
209
+ type: s.type,
210
+ file: s.file,
211
+ startLine: s.startLine,
212
+ endLine: s.endLine,
213
+ ...(handle && { handle }),
214
+ ...(s.params && { params: s.params }),
215
+ ...(s.paramsStructured && { paramsStructured: s.paramsStructured }),
216
+ ...(s.paramTypes && { paramTypes: s.paramTypes }),
217
+ ...(s.returnType && { returnType: s.returnType }),
218
+ ...(s.docstring && { docstring: s.docstring }),
219
+ ...(s.signature && { signature: s.signature }),
220
+ };
221
+ }),
222
+ },
190
223
  }, null, 2);
191
224
  }
192
225