ctxman 3.1.1 → 3.2.7
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/.ctxman/llm-profiles.json +6 -6
- package/README.md +16 -10
- package/bin/cli.js +107 -8
- package/bin/cm-ask.js +64 -6
- package/bin/cm-audit.js +76 -0
- package/bin/cm-diagnose.js +199 -0
- package/bin/cm-focus.js +322 -0
- package/bin/cm-map.js +71 -18
- package/bin/cm-pack.js +36 -5
- package/bin/cm-policy.js +107 -2
- package/bin/cm-pr.js +18 -3
- package/bin/cm-search.js +84 -19
- package/bin/cm-task.js +192 -0
- package/bin/cm-who.js +119 -0
- package/bin/cm-why.js +41 -5
- package/index.d.ts +931 -1
- package/index.js +105 -1
- package/lib/analyzers/token-calculator.js +76 -4
- package/lib/api/mcp/server.js +618 -16
- package/lib/cache/CacheManager.js +161 -47
- package/lib/core/Analyzer.js +61 -5
- package/lib/diagnose/index.js +712 -0
- package/lib/focus/index.js +790 -0
- package/lib/map/call-graph.js +570 -0
- package/lib/map/graph.js +30 -5
- package/lib/map/index.js +12 -4
- package/lib/map/rank.js +45 -7
- package/lib/map/render.js +89 -28
- package/lib/pack/index.js +20 -1
- package/lib/pack/select.js +8 -1
- package/lib/policy/audit.js +3 -1
- package/lib/policy/index.js +5 -0
- package/lib/policy/workspace.js +254 -0
- package/lib/pr/pr-context.js +292 -31
- package/lib/pr/pr-renderer.js +25 -10
- package/lib/rag/EmbeddingProvider.js +139 -2
- package/lib/rag/VectorStoreFactory.js +10 -4
- package/lib/rag/ask.js +186 -17
- package/lib/rag/stores/SqliteVectorStore.js +595 -0
- package/lib/search/hybrid.js +324 -0
- package/lib/search/symbols.js +75 -11
- package/lib/task/index.js +630 -0
- package/lib/utils/file-utils.js +1 -0
- package/lib/utils/multi-repo-manager.js +4 -1
- package/lib/watch/FileWatcher.js +121 -31
- package/lib/watch/IncrementalAnalyzer.js +964 -16
- package/lib/who/index.js +893 -0
- package/lib/why/analyzer-bridge.js +223 -0
- package/lib/why/discussions.js +314 -0
- package/lib/why/index.js +356 -30
- package/lib/why/render.js +154 -9
- package/lib/workspace/index.js +1274 -0
- 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();
|
package/bin/cm-focus.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|