ctxman 3.1.1 → 3.2.8

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 (55) hide show
  1. package/.ctxman/llm-profiles.json +6 -6
  2. package/README.md +16 -10
  3. package/bin/cli.js +109 -10
  4. package/bin/cm-ask.js +64 -6
  5. package/bin/cm-audit.js +76 -0
  6. package/bin/cm-count.js +45 -6
  7. package/bin/cm-diagnose.js +199 -0
  8. package/bin/cm-focus.js +322 -0
  9. package/bin/cm-map.js +71 -18
  10. package/bin/cm-pack.js +36 -5
  11. package/bin/cm-policy.js +107 -2
  12. package/bin/cm-pr.js +18 -3
  13. package/bin/cm-search.js +84 -19
  14. package/bin/cm-task.js +192 -0
  15. package/bin/cm-who.js +119 -0
  16. package/bin/cm-why.js +41 -5
  17. package/index.d.ts +940 -1
  18. package/index.js +105 -1
  19. package/lib/analyzers/token-calculator.js +76 -4
  20. package/lib/api/mcp/server.js +843 -29
  21. package/lib/cache/CacheManager.js +161 -47
  22. package/lib/core/Analyzer.js +61 -5
  23. package/lib/count/index.js +74 -14
  24. package/lib/diagnose/index.js +712 -0
  25. package/lib/focus/index.js +790 -0
  26. package/lib/map/call-graph.js +570 -0
  27. package/lib/map/graph.js +30 -5
  28. package/lib/map/index.js +12 -4
  29. package/lib/map/rank.js +45 -7
  30. package/lib/map/render.js +89 -28
  31. package/lib/pack/index.js +20 -1
  32. package/lib/pack/select.js +8 -1
  33. package/lib/policy/audit.js +3 -1
  34. package/lib/policy/index.js +5 -0
  35. package/lib/policy/workspace.js +254 -0
  36. package/lib/pr/pr-context.js +292 -31
  37. package/lib/pr/pr-renderer.js +25 -10
  38. package/lib/rag/EmbeddingProvider.js +139 -2
  39. package/lib/rag/VectorStoreFactory.js +10 -4
  40. package/lib/rag/ask.js +186 -17
  41. package/lib/rag/stores/SqliteVectorStore.js +595 -0
  42. package/lib/search/hybrid.js +324 -0
  43. package/lib/search/symbols.js +75 -11
  44. package/lib/task/index.js +630 -0
  45. package/lib/utils/file-utils.js +1 -0
  46. package/lib/utils/multi-repo-manager.js +4 -1
  47. package/lib/watch/FileWatcher.js +121 -31
  48. package/lib/watch/IncrementalAnalyzer.js +964 -16
  49. package/lib/who/index.js +893 -0
  50. package/lib/why/analyzer-bridge.js +223 -0
  51. package/lib/why/discussions.js +314 -0
  52. package/lib/why/index.js +356 -30
  53. package/lib/why/render.js +154 -9
  54. package/lib/workspace/index.js +1274 -0
  55. package/package.json +1 -1
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ctxman diagnose - stack-trace & failure root-cause bundle
4
+ *
5
+ * stdout carries the diagnosis document (unless -o/--output or --copy); summary goes to stderr.
6
+ * Exit codes: 0 done, 1 error, 2 invalid arguments / options.
7
+ */
8
+
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { getLogger } from '../lib/utils/logger.js';
12
+
13
+ function printHelp() {
14
+ process.stdout.write(`ctxman diagnose - root-cause context bundle for stack traces & test failures
15
+
16
+ Usage: ctxman diagnose [log-file-or-text] [options]
17
+
18
+ Parses stack trace frames across Node, TS, Python, Go, Rust, Java, C#, Vitest,
19
+ resolves implicated files safely inside the repository (or across multiple
20
+ repositories with --repo), extracts enclosing method code windows, 1-hop graph
21
+ neighbors (across module imports and the AST symbol call graph), and recent
22
+ git history packed within the budget.
23
+
24
+ Options:
25
+ --repo DIR Additional repository root to resolve frames across (repeatable)
26
+ --stdin Read failure log or stack trace from stdin
27
+ --budget TOKENS Token budget (e.g. 24000, 24k; default: 24000)
28
+ --format FORMAT Output format: markdown (default), json, xml
29
+ --json Output in JSON format (default: markdown)
30
+ --calls Include cross-file AST symbol call graph edges (default)
31
+ --no-calls Disable AST symbol call graph edges (module imports only)
32
+ --no-redact Do not mask secrets
33
+ -o, --output FILE Write output to FILE instead of stdout
34
+ --copy Copy document to clipboard
35
+ -q, --quiet Do not print summary to stderr
36
+ -h, --help Show this help
37
+
38
+ Examples:
39
+ ctxman diagnose error.log
40
+ npm test 2>&1 | ctxman diagnose --stdin
41
+ ctxman diagnose "Error: invalid token at src/auth.js:42:10" --budget 20k
42
+ ctxman diagnose "Error at billing-api/src/invoice.js:3:10" --repo ../checkout-web
43
+ ctxman diagnose failure.txt --json -o diagnosis.json
44
+ `);
45
+ }
46
+
47
+ async function readStdin() {
48
+ const chunks = [];
49
+ for await (const chunk of process.stdin) {
50
+ chunks.push(chunk);
51
+ }
52
+ return Buffer.concat(chunks).toString('utf-8');
53
+ }
54
+
55
+ async function main() {
56
+ const argv = process.argv.slice(2);
57
+ if (argv.includes('-h') || argv.includes('--help')) {
58
+ printHelp();
59
+ return;
60
+ }
61
+
62
+ getLogger({ level: process.env.LOG_LEVEL || 'error', logToFile: false });
63
+ console.log = console.info = console.debug = console.error;
64
+
65
+ const { diagnoseFailure } = await import('../lib/diagnose/index.js');
66
+ const { parseBudget } = await import('../lib/task/index.js');
67
+
68
+ let inputArg = null;
69
+ let root = process.cwd();
70
+ const repos = [];
71
+ let useStdin = false;
72
+ let budget = 24000;
73
+ let format = 'markdown';
74
+ let callGraph = true;
75
+ let redact = true;
76
+ let outputFile = null;
77
+ let copy = false;
78
+ let quiet = false;
79
+
80
+ try {
81
+ for (let i = 0; i < argv.length; i++) {
82
+ const arg = argv[i];
83
+
84
+ if (arg === '--stdin') {
85
+ useStdin = true;
86
+ } else if (arg === '--root' || arg === '-r') {
87
+ if (i + 1 >= argv.length) throw new Error('--root needs a directory path');
88
+ root = path.resolve(argv[++i]);
89
+ } else if (arg.startsWith('--root=')) {
90
+ root = path.resolve(arg.slice('--root='.length));
91
+ } else if (arg === '--repo') {
92
+ if (i + 1 >= argv.length) throw new Error('--repo needs a directory path');
93
+ repos.push(path.resolve(argv[++i]));
94
+ } else if (arg.startsWith('--repo=')) {
95
+ const val = arg.slice('--repo='.length);
96
+ if (!val) throw new Error('--repo needs a directory path');
97
+ repos.push(path.resolve(val));
98
+ } else if (arg === '--budget') {
99
+ budget = parseBudget(argv[++i]);
100
+ } else if (arg.startsWith('--budget=')) {
101
+ budget = parseBudget(arg.slice('--budget='.length));
102
+ } else if (arg === '--format') {
103
+ format = argv[++i];
104
+ } else if (arg.startsWith('--format=')) {
105
+ format = arg.slice('--format='.length);
106
+ } else if (arg === '--json') {
107
+ format = 'json';
108
+ } else if (arg === '--calls' || arg === '--call-graph') {
109
+ callGraph = true;
110
+ } else if (arg === '--no-calls' || arg === '--no-call-graph') {
111
+ callGraph = false;
112
+ } else if (arg === '--no-redact') {
113
+ redact = false;
114
+ } else if (arg === '-o' || arg === '--output') {
115
+ outputFile = argv[++i];
116
+ } else if (arg.startsWith('-o=')) {
117
+ outputFile = arg.slice('-o='.length);
118
+ } else if (arg.startsWith('--output=')) {
119
+ outputFile = arg.slice('--output='.length);
120
+ } else if (arg === '--copy') {
121
+ copy = true;
122
+ } else if (arg === '-q' || arg === '--quiet') {
123
+ quiet = true;
124
+ } else if (!arg.startsWith('-')) {
125
+ if (inputArg === null) {
126
+ inputArg = arg;
127
+ } else {
128
+ inputArg += ` ${arg}`;
129
+ }
130
+ } else {
131
+ process.stderr.write(`Unknown option: ${arg}\n`);
132
+ process.exitCode = 2;
133
+ return;
134
+ }
135
+ }
136
+ } catch (error) {
137
+ process.stderr.write(`Error: ${error.message}\n`);
138
+ process.exitCode = 2;
139
+ return;
140
+ }
141
+
142
+ let traceText = '';
143
+ if (useStdin || (!inputArg && !process.stdin.isTTY)) {
144
+ traceText = await readStdin();
145
+ } else if (inputArg) {
146
+ if (fs.existsSync(inputArg) && fs.statSync(inputArg).isFile()) {
147
+ traceText = fs.readFileSync(inputArg, 'utf-8');
148
+ } else {
149
+ traceText = inputArg;
150
+ }
151
+ }
152
+
153
+ if (!traceText || !traceText.trim()) {
154
+ process.stderr.write('Error: Failure log or stack trace is required.\n');
155
+ process.exitCode = 2;
156
+ return;
157
+ }
158
+
159
+ try {
160
+ const result = await diagnoseFailure(root, traceText, {
161
+ repos,
162
+ budget,
163
+ format,
164
+ callGraph,
165
+ redact,
166
+ output: outputFile,
167
+ });
168
+
169
+ if (copy) {
170
+ const { default: ClipboardUtils } = await import('../lib/utils/clipboard-utils.js');
171
+ await ClipboardUtils.copy(result.text);
172
+ if (!quiet) {
173
+ process.stderr.write(`Copied ${result.totalTokens} tokens to clipboard.\n`);
174
+ }
175
+ } else if (!outputFile) {
176
+ process.stdout.write(result.text + (result.text.endsWith('\n') ? '' : '\n'));
177
+ }
178
+
179
+ if (!quiet) {
180
+ const repoLabel = result.repos?.length > 1 ? ` across ${result.repos.length} repos` : '';
181
+ process.stderr.write(
182
+ `Diagnosed: ${result.implicatedFiles.length} implicated file(s)${repoLabel}, ${result.totalTokens}/${result.budget} tokens.\n`
183
+ );
184
+ if (outputFile) {
185
+ process.stderr.write(`Saved to: ${outputFile}\n`);
186
+ }
187
+ }
188
+ } catch (error) {
189
+ process.stderr.write(`Error: ${error.message}\n`);
190
+ process.exitCode = error.exitCode ?? 1;
191
+ }
192
+ }
193
+
194
+ process.stdout.on('error', (error) => {
195
+ if (error.code !== 'EPIPE') throw error;
196
+ process.exit(process.exitCode ?? 0);
197
+ });
198
+
199
+ main();
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ctxman focus - pack a target file (or file:symbol) and its import/call neighborhood
4
+ *
5
+ * stdout carries only the document (unless -o/--copy); summary and notices go to stderr.
6
+ * Exit codes: 0 done, 1 error, 2 invalid policy / exceeds --max-tokens, 3 refused by policy.
7
+ */
8
+
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { getLogger } from '../lib/utils/logger.js';
12
+
13
+ const STYLES = ['xml', 'markdown', 'plain', 'json'];
14
+ const EXIT_TOO_LARGE = 2;
15
+
16
+ function printHelp() {
17
+ process.stdout.write(`ctxman focus - pack a file (or file:symbol) and its import/call neighborhood
18
+
19
+ Usage: ctxman focus <file[:symbol]> [root] [options]
20
+
21
+ Resolves <file> inside the project (default root: current directory) or across a
22
+ multi-repository workspace (--repo), traverses the repository import graph and
23
+ cross-file AST symbol call graph up to --depth hops (both outgoing imports/calls
24
+ and incoming importers/callers by default), and packs the target file first
25
+ followed by its closest neighbors.
26
+
27
+ Graph Neighborhood:
28
+ --repo DIR Additional repository root for cross-repo neighborhood (repeatable)
29
+ -d, --depth N Hops in the import/call graph (default: 1; 0 = target file only)
30
+ -s, --symbol NAME Focus the cross-file call graph around a specific function/method
31
+ --imports-only Follow only outgoing imports/callees (dependencies of <file>)
32
+ --importers-only Follow only incoming importers/callers (files depending on <file>)
33
+ --calls-only Follow only outgoing cross-file function calls
34
+ --callers-only Follow only incoming cross-file function callers
35
+ --no-calls Disable cross-file AST call graph edges (module imports only)
36
+ --no-imports Same as --importers-only
37
+ --no-importers Same as --imports-only
38
+ --no-tests Exclude test/spec files from the neighborhood
39
+
40
+ Document & Size:
41
+ --budget TOKENS Token limit (e.g. 20000 or 20k); target first, closest hops next
42
+ --max-tokens TOKENS Exit with code 2, writing nothing, when the document is larger
43
+ --style STYLE xml (default), markdown, plain, json
44
+ --json Same as --style json
45
+ --no-tree Leave out the directory tree
46
+ --header TEXT Text at the top
47
+ --instructions FILE Append the text of FILE at the end, after the files
48
+ --line-numbers Number every line
49
+ --compress Keep signatures, replace function bodies with placeholders
50
+ --no-redact Do not mask secrets
51
+ --target-model ID Count with this model's tokenizer encoding and report fit
52
+
53
+ Output:
54
+ -o, --output FILE Write to FILE instead of stdout
55
+ --copy Copy to the clipboard instead of stdout
56
+ -q, --quiet No summary on stderr
57
+ -h, --help Show this help
58
+
59
+ Examples:
60
+ ctxman focus src/auth/login.ts
61
+ ctxman focus src/auth/login.ts:authenticate --depth 2
62
+ ctxman focus billing-api/src/invoice.js --repo ../checkout-web --depth 2
63
+ ctxman focus src/auth/login.ts --depth 2 --budget 25k --style markdown
64
+ ctxman focus lib/pack/index.js --imports-only --compress --copy
65
+ ctxman focus src/billing.js --no-tests -o focus-billing.xml
66
+ `);
67
+ }
68
+
69
+ function parseTokens(text, flag) {
70
+ const match = /^(\d+(?:\.\d+)?)([km])?$/i.exec(String(text).trim());
71
+ const scale = { k: 1e3, m: 1e6 }[match?.[2]?.toLowerCase()] ?? 1;
72
+ const value = match ? Math.round(Number(match[1]) * scale) : NaN;
73
+ if (!Number.isInteger(value) || value <= 0) {
74
+ throw new Error(`${flag} needs a positive number of tokens (e.g. 20000 or 20k), got ${text}`);
75
+ }
76
+ return value;
77
+ }
78
+
79
+ function parseDepth(text) {
80
+ const value = Number(String(text).trim());
81
+ if (!Number.isInteger(value) || value < 0) {
82
+ throw new Error(`--depth needs a non-negative integer, got ${text}`);
83
+ }
84
+ return value;
85
+ }
86
+
87
+ const VALUE_OPTIONS = {
88
+ '--root': 'root',
89
+ '-r': 'root',
90
+ '--repo': 'repos',
91
+ '--depth': 'depth',
92
+ '-d': 'depth',
93
+ '--symbol': 'symbol',
94
+ '-s': 'symbol',
95
+ '--budget': 'budget',
96
+ '--max-tokens': 'maxTokens',
97
+ '--style': 'style',
98
+ '--header': 'header',
99
+ '--instructions': 'instructions',
100
+ '--target-model': 'targetModel',
101
+ '--output': 'output',
102
+ '-o': 'output',
103
+ };
104
+
105
+ const FLAG_OPTIONS = {
106
+ '--imports-only': ['importers', false],
107
+ '--importers-only': ['imports', false],
108
+ '--calls-only': ['direction', 'calls'],
109
+ '--callers-only': ['direction', 'callers'],
110
+ '--calls': ['callGraph', true],
111
+ '--no-calls': ['callGraph', false],
112
+ '--no-imports': ['imports', false],
113
+ '--no-importers': ['importers', false],
114
+ '--no-tests': ['includeTests', false],
115
+ '--json': ['style', 'json'],
116
+ '--tree': ['tree', true],
117
+ '--no-tree': ['tree', false],
118
+ '--line-numbers': ['lineNumbers', true],
119
+ '--compress': ['compress', true],
120
+ '--redact': ['redact', true],
121
+ '--no-redact': ['redact', false],
122
+ '--copy': ['copy', true],
123
+ '--quiet': ['quiet', true],
124
+ '-q': ['quiet', true],
125
+ '--help': ['help', true],
126
+ '-h': ['help', true],
127
+ };
128
+
129
+ export function parseFocusArgs(argv) {
130
+ const options = {
131
+ repos: [],
132
+ depth: 1,
133
+ imports: true,
134
+ importers: true,
135
+ callGraph: true,
136
+ includeTests: true,
137
+ tree: true,
138
+ redact: true,
139
+ positional: [],
140
+ };
141
+
142
+ for (let i = 0; i < argv.length; i++) {
143
+ const arg = argv[i];
144
+ if (arg === '--') {
145
+ options.positional.push(...argv.slice(i + 1));
146
+ break;
147
+ }
148
+ const eq = arg.startsWith('--') ? arg.indexOf('=') : -1;
149
+ const name = eq > 0 ? arg.slice(0, eq) : arg;
150
+ if (VALUE_OPTIONS[name]) {
151
+ let value;
152
+ if (eq > 0) value = arg.slice(eq + 1);
153
+ else if (i + 1 < argv.length) value = argv[++i];
154
+ else throw new Error(`${name} needs a value`);
155
+ const key = VALUE_OPTIONS[name];
156
+ if (key === 'repos') {
157
+ options.repos.push(value);
158
+ } else {
159
+ options[key] = value;
160
+ }
161
+ } else if (FLAG_OPTIONS[name] && eq < 0) {
162
+ const [key, value] = FLAG_OPTIONS[name];
163
+ options[key] = value;
164
+ } else if (arg.startsWith('-') && arg !== '-') {
165
+ throw new Error(`Unknown option: ${arg}`);
166
+ } else {
167
+ options.positional.push(arg);
168
+ }
169
+ }
170
+
171
+ if (options.help) return options;
172
+
173
+ if (options.positional.length === 0) {
174
+ throw new Error('Missing target <file>');
175
+ }
176
+ if (options.positional.length > 2) {
177
+ throw new Error(
178
+ `Expected <file> [root], got ${options.positional.length} positional arguments`
179
+ );
180
+ }
181
+
182
+ options.depth = parseDepth(options.depth);
183
+ if (!options.imports && !options.importers && !options.direction && options.depth > 0) {
184
+ throw new Error(
185
+ 'Cannot disable both --imports and --importers (use --depth 0 for target only)'
186
+ );
187
+ }
188
+ if (options.style !== undefined && !STYLES.includes(options.style)) {
189
+ throw new Error(`--style must be one of ${STYLES.join(', ')}`);
190
+ }
191
+ if (options.budget !== undefined) options.budget = parseTokens(options.budget, '--budget');
192
+ if (options.maxTokens !== undefined) {
193
+ options.maxTokens = parseTokens(options.maxTokens, '--max-tokens');
194
+ }
195
+
196
+ return options;
197
+ }
198
+
199
+ const formatNumber = (n) => Number(n).toLocaleString('en-US');
200
+
201
+ function display(file) {
202
+ const relative = path.relative(process.cwd(), file);
203
+ return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file;
204
+ }
205
+
206
+ function printSummary(result, extras) {
207
+ const { focus } = result;
208
+ const packedNeighbors = Math.max(0, result.files.length - 1);
209
+ const targetLabel = focus.symbol ? `${focus.target}:${focus.symbol}` : focus.target;
210
+ const repoLabel =
211
+ result.repos?.length > 1 ? ` across ${formatNumber(result.repos.length)} repos` : '';
212
+ const lines = [
213
+ `ctxman focus: ${targetLabel} + ${formatNumber(packedNeighbors)}/${formatNumber(focus.neighbors.length)} neighbor(s)${repoLabel} ` +
214
+ `(depth ${focus.depth}, ${focus.direction}), ${formatNumber(result.totalTokens)} tokens (${result.style}, ${result.encoding})`,
215
+ ];
216
+ if (result.budget) {
217
+ const omitted = result.omitted.length;
218
+ lines.push(
219
+ ` Budget ${formatNumber(result.budget.limit)}: ${formatNumber(result.budget.used)} used` +
220
+ (omitted ? `; ${formatNumber(omitted)} neighbor(s) left out` : '; every file fits')
221
+ );
222
+ }
223
+ if (result.compression) {
224
+ lines.push(
225
+ ` Compressed ${formatNumber(result.compression.bodies)} function bodies: ` +
226
+ `${formatNumber(result.compression.savedTokens)} tokens saved`
227
+ );
228
+ }
229
+ if (result.redactions.total) {
230
+ lines.push(` Redacted ${formatNumber(result.redactions.total)} secret(s)`);
231
+ }
232
+ lines.push(...extras.map((line) => ` ${line}`));
233
+ process.stderr.write(`${lines.join('\n')}\n`);
234
+ }
235
+
236
+ async function main() {
237
+ let args;
238
+ try {
239
+ args = parseFocusArgs(process.argv.slice(2));
240
+ } catch (error) {
241
+ process.stderr.write(`Error: ${error.message}\nRun "ctxman focus --help" for usage.\n`);
242
+ process.exitCode = 1;
243
+ return;
244
+ }
245
+ if (args.help) {
246
+ printHelp();
247
+ return;
248
+ }
249
+
250
+ getLogger({ level: process.env.LOG_LEVEL || 'warn', logToFile: false });
251
+ console.log = console.info = console.debug = console.error;
252
+
253
+ try {
254
+ const { focusContext } = await import('../lib/focus/index.js');
255
+ const [target, explicitRoot] = args.positional;
256
+ const root = path.resolve(args.root ?? explicitRoot ?? '.');
257
+ const output = args.output ? path.resolve(args.output) : null;
258
+
259
+ const options = {
260
+ repos: args.repos.map((r) => path.resolve(r)),
261
+ depth: args.depth,
262
+ symbol: args.symbol,
263
+ direction: args.direction,
264
+ imports: args.imports,
265
+ importers: args.importers,
266
+ callGraph: args.callGraph,
267
+ includeTests: args.includeTests,
268
+ cwd: args.root || explicitRoot ? root : process.cwd(),
269
+ style: args.style,
270
+ tree: args.tree,
271
+ header: args.header,
272
+ lineNumbers: args.lineNumbers,
273
+ compress: args.compress,
274
+ redact: args.redact,
275
+ budget: args.budget,
276
+ targetModel: args.targetModel,
277
+ excludeFiles: output ? [output] : [],
278
+ };
279
+ if (args.instructions !== undefined) {
280
+ options.instructions = fs.readFileSync(path.resolve(args.instructions), 'utf8');
281
+ }
282
+
283
+ const result = await focusContext(root, target, options);
284
+ const extras = [];
285
+
286
+ if (args.maxTokens && result.totalTokens > args.maxTokens) {
287
+ if (!args.quiet) printSummary(result, extras);
288
+ process.stderr.write(
289
+ `Error: the document has ${formatNumber(result.totalTokens)} tokens, more than ` +
290
+ `--max-tokens ${formatNumber(args.maxTokens)}; nothing was written.\n`
291
+ );
292
+ process.exitCode = EXIT_TOO_LARGE;
293
+ return;
294
+ }
295
+
296
+ let toStdout = !output && !args.copy;
297
+ if (output) {
298
+ fs.mkdirSync(path.dirname(output), { recursive: true });
299
+ fs.writeFileSync(output, result.text);
300
+ extras.push(`Written to ${display(output)}`);
301
+ }
302
+ if (args.copy) {
303
+ const { default: ClipboardUtils } = await import('../lib/utils/clipboard-utils.js');
304
+ if (!ClipboardUtils.copy(result.text) && !output) {
305
+ extras.push('Clipboard unavailable; writing to stdout instead');
306
+ toStdout = true;
307
+ }
308
+ }
309
+ if (toStdout) process.stdout.write(result.text);
310
+ if (!args.quiet) printSummary(result, extras);
311
+ } catch (error) {
312
+ process.stderr.write(`Error: ${error.message}\n`);
313
+ process.exitCode = error.exitCode ?? 1;
314
+ }
315
+ }
316
+
317
+ process.stdout.on('error', (error) => {
318
+ if (error.code !== 'EPIPE') throw error;
319
+ process.exit(process.exitCode ?? 0);
320
+ });
321
+
322
+ main();
package/bin/cm-map.js CHANGED
@@ -25,8 +25,10 @@ are listed with one line per function (line, signature, tokens) until the budget
25
25
 
26
26
  Options:
27
27
  -t, --tokens N Token budget for the map (default: 2048)
28
- -f, --focus PATH Bias the ranking toward a file or folder and its neighbours
29
- (repeatable)
28
+ --repo DIR Additional repository root for a multi-repo workspace map
29
+ under one shared token budget (repeatable)
30
+ -f, --focus PATH Bias the ranking toward a file, file:symbol, or folder and its
31
+ neighbours (repeatable)
30
32
  --history N Commits of git history to mine for change frequency
31
33
  (default: 300, 0 disables)
32
34
  --order rank|path Order of the files in the output (default: rank)
@@ -34,6 +36,7 @@ Options:
34
36
  --style STYLE text (default), markdown, json, dot (Graphviz)
35
37
  --json Same as --style json
36
38
  --graph Print the dependency edges instead of the map
39
+ -c, --call-graph Include cross-file AST symbol call graph edges
37
40
  -o, --output FILE Write to FILE instead of stdout
38
41
  --copy Copy to the clipboard instead of stdout
39
42
  --no-redact Do not mask secrets in signatures
@@ -47,6 +50,9 @@ Score:
47
50
  Examples:
48
51
  ctxman map # map of the current directory, 2048 tokens
49
52
  ctxman map --tokens 4096 --style markdown
53
+ ctxman map --call-graph # annotate methods with cross-file call targets
54
+ ctxman map --graph --call-graph # print both import and symbol call edges
55
+ ctxman map --repo ../api --repo ../web --tokens 4096
50
56
  ctxman map src --focus src/server.js # around the file you are working on
51
57
  ctxman map --important-last -o map.txt # best files last, next to your question
52
58
  ctxman map --graph --style dot | dot -Tsvg > deps.svg
@@ -61,6 +67,7 @@ Examples:
61
67
  function parseArgs(argv) {
62
68
  const options = {
63
69
  root: null,
70
+ repos: [],
64
71
  tokens: undefined,
65
72
  focus: [],
66
73
  history: undefined,
@@ -68,6 +75,7 @@ function parseArgs(argv) {
68
75
  importantLast: false,
69
76
  style: undefined,
70
77
  graph: false,
78
+ callGraph: false,
71
79
  output: null,
72
80
  copy: false,
73
81
  redact: true,
@@ -103,6 +111,9 @@ function parseArgs(argv) {
103
111
  case '--tokens':
104
112
  options.tokens = integer(arg, value(), 1);
105
113
  break;
114
+ case '--repo':
115
+ options.repos.push(value());
116
+ break;
106
117
  case '-f':
107
118
  case '--focus':
108
119
  options.focus.push(value());
@@ -131,6 +142,10 @@ function parseArgs(argv) {
131
142
  case '--graph':
132
143
  options.graph = true;
133
144
  break;
145
+ case '-c':
146
+ case '--call-graph':
147
+ options.callGraph = true;
148
+ break;
134
149
  case '-o':
135
150
  case '--output':
136
151
  options.output = value();
@@ -159,12 +174,22 @@ function parseArgs(argv) {
159
174
 
160
175
  /**
161
176
  * A focus path is project-relative when it exists under the project, otherwise taken
162
- * from the current directory (`ctxman map ../app --focus ../app/src/a.js`)
177
+ * from the current directory (`ctxman map ../app --focus ../app/src/a.js`). Preserves
178
+ * an optional `:symbol` suffix.
163
179
  */
164
180
  function focusPath(root, entry) {
165
- if (path.isAbsolute(entry) || fs.existsSync(path.resolve(root, entry))) return entry;
166
- const fromCwd = path.resolve(entry);
167
- return fs.existsSync(fromCwd) ? fromCwd : entry;
181
+ const lastColon = entry.lastIndexOf(':');
182
+ let pathPart = entry;
183
+ let symbolSuffix = '';
184
+ if (lastColon > 1 && !fs.existsSync(path.resolve(root, entry))) {
185
+ pathPart = entry.slice(0, lastColon);
186
+ symbolSuffix = entry.slice(lastColon);
187
+ }
188
+ if (path.isAbsolute(pathPart) || fs.existsSync(path.resolve(root, pathPart))) {
189
+ return `${pathPart}${symbolSuffix}`;
190
+ }
191
+ const fromCwd = path.resolve(pathPart);
192
+ return fs.existsSync(fromCwd) ? `${fromCwd}${symbolSuffix}` : entry;
168
193
  }
169
194
 
170
195
  function formatMs(ms) {
@@ -191,19 +216,40 @@ async function main() {
191
216
  getLogger({ level: process.env.LOG_LEVEL || 'warn', logToFile: false });
192
217
  console.log = console.info = console.debug = console.error;
193
218
  const { renderRepoMap } = await import('../lib/map/index.js');
219
+ const { mapWorkspace } = await import('../lib/workspace/index.js');
194
220
  const { default: ClipboardUtils } = await import('../lib/utils/clipboard-utils.js');
195
221
 
196
- const root = path.resolve(options.root ?? '.');
197
- const result = renderRepoMap(root, {
198
- tokens: options.tokens,
199
- focus: options.focus.map((entry) => focusPath(root, entry)),
200
- history: options.history,
201
- order: options.order,
202
- importantLast: options.importantLast,
203
- style: options.style,
204
- graph: options.graph,
205
- redact: options.redact,
206
- });
222
+ let result;
223
+ if (options.repos.length > 0) {
224
+ const repoList = [
225
+ ...(options.root !== null ? [path.resolve(options.root)] : []),
226
+ ...options.repos.map((r) => path.resolve(r)),
227
+ ];
228
+ result = mapWorkspace(repoList, {
229
+ tokens: options.tokens,
230
+ focus: options.focus,
231
+ history: options.history,
232
+ order: options.order,
233
+ importantLast: options.importantLast,
234
+ style: options.style,
235
+ graph: options.graph,
236
+ callGraph: options.callGraph,
237
+ redact: options.redact,
238
+ });
239
+ } else {
240
+ const root = path.resolve(options.root ?? '.');
241
+ result = renderRepoMap(root, {
242
+ tokens: options.tokens,
243
+ focus: options.focus.map((entry) => focusPath(root, entry)),
244
+ history: options.history,
245
+ order: options.order,
246
+ importantLast: options.importantLast,
247
+ style: options.style,
248
+ graph: options.graph,
249
+ callGraph: options.callGraph,
250
+ redact: options.redact,
251
+ });
252
+ }
207
253
 
208
254
  if (options.output) {
209
255
  fs.writeFileSync(options.output, result.output);
@@ -219,12 +265,19 @@ async function main() {
219
265
  for (const warning of result.warnings) console.error(`warning: ${warning}`);
220
266
  const { stats } = result;
221
267
  const parts = [];
268
+ if (result.repos?.length > 1) parts.push(`${result.repos.length} repos`);
222
269
  if (options.graph || result.style === 'dot') {
223
270
  parts.push(`${stats.edges} edges between ${stats.files} files`);
271
+ if (options.callGraph && stats.callEdges !== undefined) {
272
+ parts.push(`${stats.callEdges} call edges`);
273
+ }
224
274
  } else {
225
275
  parts.push(`${result.files.length} of ${stats.files} files`);
226
276
  parts.push(`${result.tokens}/${result.budget} tokens`);
227
277
  parts.push(`${stats.edges} import edges`);
278
+ if (options.callGraph && stats.callEdges !== undefined) {
279
+ parts.push(`${stats.callEdges} call edges`);
280
+ }
228
281
  }
229
282
  const history = result.graph.history;
230
283
  if (history.available) parts.push(`history ${history.commits} commits`);
@@ -232,7 +285,7 @@ async function main() {
232
285
  const redacted = Object.values(stats.redactions).reduce((sum, count) => sum + count, 0);
233
286
  if (redacted > 0) parts.push(`${redacted} secrets redacted`);
234
287
  parts.push(formatMs(stats.timings.total));
235
- console.error(`ctxman map: ${parts.join(' · ')}`);
288
+ console.error(`ctxman map: ${parts.join(' \u00b7 ')}`);
236
289
  if (options.output) console.error(`Map written to ${options.output}`);
237
290
  }
238
291