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
package/lib/map/rank.js
CHANGED
|
@@ -117,9 +117,28 @@ export function pageRank(ids, edges, options = {}) {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
/**
|
|
120
|
-
*
|
|
120
|
+
* Split a `file[:symbol]` target into `{ pathPart, symbol }` (ignoring Windows drive colons).
|
|
121
|
+
* @param {string} entry
|
|
122
|
+
* @returns {{ pathPart: string, symbol: string | null }}
|
|
123
|
+
*/
|
|
124
|
+
export function splitTargetSymbol(entry) {
|
|
125
|
+
if (typeof entry !== 'string') return { pathPart: '', symbol: null };
|
|
126
|
+
const lastColon = entry.lastIndexOf(':');
|
|
127
|
+
// Ignore Windows drive letters like `C:\foo` (colon at index 1)
|
|
128
|
+
if (lastColon > 1) {
|
|
129
|
+
const candidatePath = entry.slice(0, lastColon);
|
|
130
|
+
const candidateSymbol = entry.slice(lastColon + 1).trim();
|
|
131
|
+
if (candidatePath && candidateSymbol && !candidateSymbol.includes('/')) {
|
|
132
|
+
return { pathPart: candidatePath, symbol: candidateSymbol };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { pathPart: entry, symbol: null };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Files of the graph matched by focus paths (a file, `file:symbol`, or a folder for every file in it)
|
|
121
140
|
* @param {import('./graph.js').RepoGraph} graph
|
|
122
|
-
* @param {string[]} focus - Absolute or project-relative paths
|
|
141
|
+
* @param {string[]} focus - Absolute or project-relative paths (optionally with `:symbol`)
|
|
123
142
|
* @returns {{files: string[], unmatched: string[]}}
|
|
124
143
|
* @throws {Error} When a path lies outside the project
|
|
125
144
|
*/
|
|
@@ -127,11 +146,22 @@ export function resolveFocus(graph, focus = []) {
|
|
|
127
146
|
const files = new Set();
|
|
128
147
|
const unmatched = [];
|
|
129
148
|
const nodePaths = graph.nodes.map((node) => node.path);
|
|
149
|
+
const nodeMap = new Map(graph.nodes.map((node) => [node.path, node]));
|
|
130
150
|
for (const entry of focus) {
|
|
131
|
-
const
|
|
132
|
-
const
|
|
151
|
+
const { pathPart, symbol } = splitTargetSymbol(entry);
|
|
152
|
+
const relative = toProjectPath(graph.root, pathPart);
|
|
153
|
+
let matches = nodePaths.filter(
|
|
133
154
|
(file) => relative === '' || file === relative || file.startsWith(`${relative}/`)
|
|
134
155
|
);
|
|
156
|
+
if (symbol && matches.length === 1) {
|
|
157
|
+
const node = nodeMap.get(matches[0]);
|
|
158
|
+
if (node && Array.isArray(node.methods) && node.methods.length > 0) {
|
|
159
|
+
const hasSymbol = node.methods.some(
|
|
160
|
+
(m) => m.name === symbol || m.name.replace(/^(?:get|set)\s+/, '') === symbol
|
|
161
|
+
);
|
|
162
|
+
if (!hasSymbol) matches = [];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
135
165
|
if (matches.length === 0) unmatched.push(entry);
|
|
136
166
|
for (const file of matches) files.add(file);
|
|
137
167
|
}
|
|
@@ -160,7 +190,8 @@ export function toProjectPath(root, entry) {
|
|
|
160
190
|
* Rank the files of a graph, best first
|
|
161
191
|
* @param {import('./graph.js').RepoGraph} graph
|
|
162
192
|
* @param {Object} [options]
|
|
163
|
-
* @param {string[]} [options.focus] - Files or folders to personalise PageRank toward
|
|
193
|
+
* @param {string[]} [options.focus] - Files, `file:symbol` targets, or folders to personalise PageRank toward
|
|
194
|
+
* @param {boolean} [options.callGraph=false] - Blend cross-file AST symbol call edges into PageRank
|
|
164
195
|
* @param {{pagerank?: number, history?: number, symbols?: number}} [options.weights]
|
|
165
196
|
* @param {number} [options.damping=0.85]
|
|
166
197
|
* @returns {Array<{path: string, rank: number, score: number, pagerank: number,
|
|
@@ -171,12 +202,19 @@ export function rankFiles(graph, options = {}) {
|
|
|
171
202
|
const weights = { ...DEFAULT_WEIGHTS, ...options.weights };
|
|
172
203
|
const ids = graph.nodes.map((node) => node.path);
|
|
173
204
|
const focus = new Set(resolveFocus(graph, options.focus || []).files);
|
|
205
|
+
const activeEdges =
|
|
206
|
+
options.callGraph && Array.isArray(graph.callEdges) && graph.callEdges.length > 0
|
|
207
|
+
? [
|
|
208
|
+
...graph.edges,
|
|
209
|
+
...graph.callEdges.map(({ from, to, weight }) => ({ from, to, weight: weight ?? 1 })),
|
|
210
|
+
]
|
|
211
|
+
: graph.edges;
|
|
174
212
|
|
|
175
213
|
let personalization = null;
|
|
176
214
|
if (focus.size > 0) {
|
|
177
215
|
const preference = new Map();
|
|
178
216
|
for (const file of focus) preference.set(file, FOCUS_WEIGHT);
|
|
179
|
-
for (const { from, to } of
|
|
217
|
+
for (const { from, to } of activeEdges) {
|
|
180
218
|
for (const [a, b] of [
|
|
181
219
|
[from, to],
|
|
182
220
|
[to, from],
|
|
@@ -196,7 +234,7 @@ export function rankFiles(graph, options = {}) {
|
|
|
196
234
|
);
|
|
197
235
|
}
|
|
198
236
|
|
|
199
|
-
const { scores } = pageRank(ids,
|
|
237
|
+
const { scores } = pageRank(ids, activeEdges, {
|
|
200
238
|
damping: options.damping ?? DEFAULT_DAMPING,
|
|
201
239
|
personalization,
|
|
202
240
|
});
|
package/lib/map/render.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Rendering for `ctxman map`: the ranked files with their function signatures, trimmed
|
|
3
|
-
* to a token budget, or the dependency graph itself
|
|
3
|
+
* to a token budget, or the dependency & cross-file call graph itself
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import fs from 'fs';
|
|
@@ -37,6 +37,7 @@ export function normalizeMapOptions(options = {}) {
|
|
|
37
37
|
importantLast: Boolean(options.importantLast),
|
|
38
38
|
style: options.style ?? 'text',
|
|
39
39
|
graph: Boolean(options.graph),
|
|
40
|
+
callGraph: Boolean(options.callGraph),
|
|
40
41
|
redact: options.redact !== false,
|
|
41
42
|
weights: { ...DEFAULT_WEIGHTS, ...options.weights },
|
|
42
43
|
damping: options.damping ?? DEFAULT_DAMPING,
|
|
@@ -77,6 +78,7 @@ export function normalizeMapOptions(options = {}) {
|
|
|
77
78
|
* @param {boolean} [options.importantLast=false] - Highest-ranked file at the end
|
|
78
79
|
* @param {'text'|'markdown'|'json'|'dot'} [options.style='text']
|
|
79
80
|
* @param {boolean} [options.graph=false] - Render the dependency edges instead of the map
|
|
81
|
+
* @param {boolean} [options.callGraph=false] - Include cross-file AST symbol call graph edges
|
|
80
82
|
* @param {boolean} [options.redact=true] - Mask secrets in signatures
|
|
81
83
|
* @param {{pagerank?: number, history?: number, symbols?: number}} [options.weights]
|
|
82
84
|
* @returns {{output: string, style: string, budget: number, tokens: number,
|
|
@@ -102,6 +104,7 @@ export function renderRepoMap(root, options = {}) {
|
|
|
102
104
|
let timer = performance.now();
|
|
103
105
|
const ranked = rankFiles(graph, {
|
|
104
106
|
focus: focus.files,
|
|
107
|
+
callGraph: opts.callGraph,
|
|
105
108
|
weights: opts.weights,
|
|
106
109
|
damping: opts.damping,
|
|
107
110
|
});
|
|
@@ -116,7 +119,7 @@ export function renderRepoMap(root, options = {}) {
|
|
|
116
119
|
warnings,
|
|
117
120
|
};
|
|
118
121
|
if (opts.graph || opts.style === 'dot') {
|
|
119
|
-
result.output = renderGraph(graph, ranked, opts.style);
|
|
122
|
+
result.output = renderGraph(graph, ranked, opts.style, { callGraph: opts.callGraph });
|
|
120
123
|
result.tokens = countTokens(result.output);
|
|
121
124
|
result.files = [];
|
|
122
125
|
result.skipped = 0;
|
|
@@ -174,13 +177,18 @@ function selectFiles(graph, ranked, opts) {
|
|
|
174
177
|
path: row.path,
|
|
175
178
|
score: row.score,
|
|
176
179
|
tokens: 0,
|
|
177
|
-
methods: node.methods.map((method) => ({
|
|
180
|
+
methods: node.methods.map((method) => ({
|
|
181
|
+
line: method.line,
|
|
182
|
+
name: method.name,
|
|
183
|
+
signature: method.signature,
|
|
184
|
+
tokens: 0,
|
|
185
|
+
})),
|
|
178
186
|
});
|
|
179
187
|
if (countTokens(skeleton + separator) > remaining) {
|
|
180
188
|
skipped++;
|
|
181
189
|
continue;
|
|
182
190
|
}
|
|
183
|
-
const entry = describeFile(graph, node, row, analyzer);
|
|
191
|
+
const entry = describeFile(graph, node, row, analyzer, opts);
|
|
184
192
|
const block = renderBlock(style, entry);
|
|
185
193
|
const cost = countTokens(block + separator);
|
|
186
194
|
if (cost > remaining) {
|
|
@@ -216,7 +224,7 @@ function displayOrder(chosen, opts) {
|
|
|
216
224
|
}
|
|
217
225
|
|
|
218
226
|
/** Map entry of a file: its tokens and the tokens of each of its methods */
|
|
219
|
-
function describeFile(graph, node, row, analyzer) {
|
|
227
|
+
function describeFile(graph, node, row, analyzer, opts = {}) {
|
|
220
228
|
const absolute = path.join(graph.root, node.path);
|
|
221
229
|
let content = '';
|
|
222
230
|
try {
|
|
@@ -225,17 +233,37 @@ function describeFile(graph, node, row, analyzer) {
|
|
|
225
233
|
// Deleted since the scan: keep the signatures, count nothing
|
|
226
234
|
}
|
|
227
235
|
const sources = content ? analyzer.extractMethodSources(content, node.methods, absolute) : [];
|
|
236
|
+
const outgoingCallsByMethod = new Map();
|
|
237
|
+
if (opts.callGraph && Array.isArray(graph.callEdges)) {
|
|
238
|
+
for (const edge of graph.callEdges) {
|
|
239
|
+
if (edge.from !== node.path) continue;
|
|
240
|
+
const key = `${edge.caller}:${edge.callerLine}`;
|
|
241
|
+
let list = outgoingCallsByMethod.get(key);
|
|
242
|
+
if (!list) {
|
|
243
|
+
list = [];
|
|
244
|
+
outgoingCallsByMethod.set(key, list);
|
|
245
|
+
}
|
|
246
|
+
list.push(`${edge.to}:${edge.callee}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
228
249
|
return {
|
|
229
250
|
path: node.path,
|
|
230
251
|
rank: row.rank,
|
|
231
252
|
score: row.score,
|
|
232
253
|
tokens: content ? TokenUtils.calculate(content, absolute) : 0,
|
|
233
|
-
methods: node.methods.map((method, i) =>
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
254
|
+
methods: node.methods.map((method, i) => {
|
|
255
|
+
const item = {
|
|
256
|
+
line: method.line,
|
|
257
|
+
name: method.name,
|
|
258
|
+
signature: method.signature,
|
|
259
|
+
tokens: sources[i] ? TokenUtils.calculate(sources[i], absolute) : 0,
|
|
260
|
+
};
|
|
261
|
+
if (opts.callGraph) {
|
|
262
|
+
const targets = outgoingCallsByMethod.get(`${method.name}:${method.line}`) || [];
|
|
263
|
+
if (targets.length > 0) item.calls = targets;
|
|
264
|
+
}
|
|
265
|
+
return item;
|
|
266
|
+
}),
|
|
239
267
|
};
|
|
240
268
|
}
|
|
241
269
|
|
|
@@ -246,13 +274,23 @@ export function renderBlock(style, entry) {
|
|
|
246
274
|
const lines = [`## ${entry.path}`, `score ${score} · ${entry.tokens} tokens`];
|
|
247
275
|
if (entry.methods.length > 0) lines.push('');
|
|
248
276
|
for (const method of entry.methods) {
|
|
249
|
-
|
|
277
|
+
const callSuffix =
|
|
278
|
+
Array.isArray(method.calls) && method.calls.length > 0
|
|
279
|
+
? ` -> ${method.calls.join(', ')}`
|
|
280
|
+
: '';
|
|
281
|
+
lines.push(
|
|
282
|
+
`- L${method.line} ${inlineCode(method.signature)} (${method.tokens} tokens)${callSuffix}`
|
|
283
|
+
);
|
|
250
284
|
}
|
|
251
285
|
return lines.join('\n');
|
|
252
286
|
}
|
|
253
287
|
const lines = [`${entry.path} (score ${score}, ${entry.tokens} tokens)`];
|
|
254
288
|
for (const method of entry.methods) {
|
|
255
|
-
|
|
289
|
+
const callSuffix =
|
|
290
|
+
Array.isArray(method.calls) && method.calls.length > 0
|
|
291
|
+
? ` -> ${method.calls.join(', ')}`
|
|
292
|
+
: '';
|
|
293
|
+
lines.push(` ${method.line}: ${method.signature} [${method.tokens} tok]${callSuffix}`);
|
|
256
294
|
}
|
|
257
295
|
return lines.join('\n');
|
|
258
296
|
}
|
|
@@ -265,6 +303,10 @@ function inlineCode(text) {
|
|
|
265
303
|
|
|
266
304
|
function renderJson(graph, ranked, selection, opts) {
|
|
267
305
|
const included = new Set(selection.files.map((file) => file.path));
|
|
306
|
+
const callEdges = (graph.callEdges || []).map((edge) => ({
|
|
307
|
+
...edge,
|
|
308
|
+
weight: round(edge.weight, 6),
|
|
309
|
+
}));
|
|
268
310
|
const document = {
|
|
269
311
|
root: graph.root,
|
|
270
312
|
budget: opts.tokens,
|
|
@@ -290,9 +332,11 @@ function renderJson(graph, ranked, selection, opts) {
|
|
|
290
332
|
included: included.has(row.path),
|
|
291
333
|
})),
|
|
292
334
|
edges: graph.edges.map((edge) => ({ ...edge, weight: round(edge.weight, 6) })),
|
|
335
|
+
callEdges,
|
|
293
336
|
stats: {
|
|
294
337
|
files: graph.stats.files,
|
|
295
338
|
edges: graph.stats.edges,
|
|
339
|
+
callEdges: graph.stats.callEdges ?? callEdges.length,
|
|
296
340
|
unresolvedImports: graph.stats.unresolvedImports,
|
|
297
341
|
skippedFiles: graph.stats.skipped,
|
|
298
342
|
},
|
|
@@ -302,24 +346,28 @@ function renderJson(graph, ranked, selection, opts) {
|
|
|
302
346
|
|
|
303
347
|
/**
|
|
304
348
|
* The dependency edges: `a -> b` lines (text), a fenced block (markdown), nodes and edges
|
|
305
|
-
* (json) or a Graphviz digraph (dot)
|
|
349
|
+
* (json) or a Graphviz digraph (dot). When `options.callGraph` is true, cross-file AST
|
|
350
|
+
* symbol call edges (`from:caller -> to:callee`) are also included.
|
|
306
351
|
*/
|
|
307
|
-
export function renderGraph(graph, ranked, style) {
|
|
352
|
+
export function renderGraph(graph, ranked, style, options = {}) {
|
|
353
|
+
const includeCalls = Boolean(options.callGraph);
|
|
308
354
|
const weightNote = (edge) => (edge.weight === 1 ? '' : ` (${round(edge.weight, 3)})`);
|
|
355
|
+
const callEdges = graph.callEdges || [];
|
|
356
|
+
|
|
309
357
|
if (style === 'json') {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
)}\n`;
|
|
358
|
+
const payload = {
|
|
359
|
+
root: graph.root,
|
|
360
|
+
nodes: ranked.map((row) => ({
|
|
361
|
+
path: row.path,
|
|
362
|
+
rank: row.rank,
|
|
363
|
+
score: round(row.score, 6),
|
|
364
|
+
})),
|
|
365
|
+
edges: graph.edges.map((edge) => ({ ...edge, weight: round(edge.weight, 6) })),
|
|
366
|
+
};
|
|
367
|
+
if (includeCalls) {
|
|
368
|
+
payload.callEdges = callEdges.map((edge) => ({ ...edge, weight: round(edge.weight, 6) }));
|
|
369
|
+
}
|
|
370
|
+
return `${JSON.stringify(payload, null, 2)}\n`;
|
|
323
371
|
}
|
|
324
372
|
if (style === 'dot') {
|
|
325
373
|
const escape = (value) => value.replace(/["\\]/g, '\\$&');
|
|
@@ -335,10 +383,23 @@ export function renderGraph(graph, ranked, style) {
|
|
|
335
383
|
const attributes = edge.weight === 1 ? '' : ` [weight=${round(edge.weight, 3)}]`;
|
|
336
384
|
lines.push(` ${quote(edge.from)} -> ${quote(edge.to)}${attributes};`);
|
|
337
385
|
}
|
|
386
|
+
if (includeCalls) {
|
|
387
|
+
for (const edge of callEdges) {
|
|
388
|
+
const callLabel = `"${escape(`${edge.caller}->${edge.callee}`)}"`;
|
|
389
|
+
lines.push(
|
|
390
|
+
` ${quote(edge.from)} -> ${quote(edge.to)} [style=dashed, label=${callLabel}];`
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
338
394
|
lines.push('}');
|
|
339
395
|
return `${lines.join('\n')}\n`;
|
|
340
396
|
}
|
|
341
397
|
const lines = graph.edges.map((edge) => `${edge.from} -> ${edge.to}${weightNote(edge)}`);
|
|
398
|
+
if (includeCalls) {
|
|
399
|
+
for (const edge of callEdges) {
|
|
400
|
+
lines.push(`${edge.from}:${edge.caller} -> ${edge.to}:${edge.callee}${weightNote(edge)}`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
342
403
|
if (style === 'markdown') {
|
|
343
404
|
return `# Dependency graph\n\n\`\`\`text\n${lines.join('\n')}${lines.length ? '\n' : ''}\`\`\`\n`;
|
|
344
405
|
}
|
package/lib/pack/index.js
CHANGED
|
@@ -95,6 +95,7 @@ function normalizeOptions(options) {
|
|
|
95
95
|
include: stringList(options.include, 'include'),
|
|
96
96
|
exclude: stringList(options.exclude, 'exclude'),
|
|
97
97
|
files: options.files ? stringList(options.files, 'files') : null,
|
|
98
|
+
rankOrder: options.rankOrder ? stringList(options.rankOrder, 'rankOrder') : null,
|
|
98
99
|
cwd: options.cwd,
|
|
99
100
|
excludeFiles: stringList(options.excludeFiles, 'excludeFiles'),
|
|
100
101
|
maxFileSize: positiveInteger(options.maxFileSize, 'maxFileSize') ?? DEFAULT_MAX_FILE_SIZE,
|
|
@@ -209,6 +210,23 @@ function modelFit(profile, tokens, encoding) {
|
|
|
209
210
|
};
|
|
210
211
|
}
|
|
211
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Order entries according to an explicit priority list, falling back to `rankEntries()`
|
|
215
|
+
* for any entry not named in `rankOrder`.
|
|
216
|
+
*/
|
|
217
|
+
function orderEntriesByPriority(root, entries, rankOrder) {
|
|
218
|
+
if (!rankOrder || rankOrder.length === 0) {
|
|
219
|
+
return rankEntries(root, entries).ranked;
|
|
220
|
+
}
|
|
221
|
+
const priority = new Map(rankOrder.map((p, idx) => [p, idx]));
|
|
222
|
+
const named = entries
|
|
223
|
+
.filter((entry) => priority.has(entry.path))
|
|
224
|
+
.sort((a, b) => priority.get(a.path) - priority.get(b.path));
|
|
225
|
+
const unnamed = entries.filter((entry) => !priority.has(entry.path));
|
|
226
|
+
if (unnamed.length === 0) return named;
|
|
227
|
+
return [...named, ...rankEntries(root, unnamed).ranked];
|
|
228
|
+
}
|
|
229
|
+
|
|
212
230
|
/**
|
|
213
231
|
* Pack the contents of a project's files into one document
|
|
214
232
|
* @param {string} root - Project root
|
|
@@ -217,6 +235,7 @@ function modelFit(profile, tokens, encoding) {
|
|
|
217
235
|
* @param {string[]} [options.include] - Gitignore-style globs: keep only matching files
|
|
218
236
|
* @param {string[]} [options.exclude] - Gitignore-style globs: leave matching files out
|
|
219
237
|
* @param {string[]} [options.files] - Explicit file list instead of scanning the root
|
|
238
|
+
* @param {string[]} [options.rankOrder] - Explicit priority order for budget trimming
|
|
220
239
|
* @param {string} [options.cwd] - Base for relative `files` entries (default: root)
|
|
221
240
|
* @param {string[]} [options.excludeFiles] - Absolute paths to leave out
|
|
222
241
|
* @param {number} [options.maxFileSize=1048576] - Skip larger files (bytes)
|
|
@@ -269,7 +288,7 @@ export async function packContext(root, options = {}) {
|
|
|
269
288
|
let omitted = [];
|
|
270
289
|
let rendered;
|
|
271
290
|
if (opts.budget) {
|
|
272
|
-
const
|
|
291
|
+
const ranked = orderEntriesByPriority(selection.root, entries, opts.rankOrder);
|
|
273
292
|
const separator = count('\n\n');
|
|
274
293
|
const wrapperCost = (entry) =>
|
|
275
294
|
opts.style === 'json'
|
package/lib/pack/select.js
CHANGED
|
@@ -128,11 +128,14 @@ function isIgnored(scanner, rootPath, relativePath) {
|
|
|
128
128
|
|
|
129
129
|
/**
|
|
130
130
|
* An explicit list, in the given order. A path that leaves the root (`..`, an absolute path
|
|
131
|
-
* elsewhere, a symlink pointing out) is an error; files the ignore rules
|
|
131
|
+
* elsewhere, a symlink pointing out) is an error; files the ignore rules or export policy
|
|
132
|
+
* exclude are skipped.
|
|
132
133
|
*/
|
|
133
134
|
function listedFiles(rootPath, entries, cwd, skipped) {
|
|
134
135
|
const realRoot = fs.realpathSync(rootPath);
|
|
135
136
|
const scanner = new Scanner(rootPath);
|
|
137
|
+
const policy = scanner.getPolicy();
|
|
138
|
+
scanner.getIgnoreFileRules();
|
|
136
139
|
const files = [];
|
|
137
140
|
const seen = new Set();
|
|
138
141
|
for (const entry of entries) {
|
|
@@ -165,6 +168,10 @@ function listedFiles(rootPath, entries, cwd, skipped) {
|
|
|
165
168
|
skipped.push({ path: relativePath, reason: 'not-a-file' });
|
|
166
169
|
continue;
|
|
167
170
|
}
|
|
171
|
+
if (policy?.isAuditLog(relative) || scanner.isDenied(relative)) {
|
|
172
|
+
skipped.push({ path: relativePath, reason: 'denied' });
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
168
175
|
if (isIgnored(scanner, rootPath, relative)) {
|
|
169
176
|
skipped.push({ path: relativePath, reason: 'ignored' });
|
|
170
177
|
continue;
|
package/lib/policy/audit.js
CHANGED
|
@@ -270,7 +270,7 @@ export function filterAuditEvents(events, { since = null, limit = null } = {}) {
|
|
|
270
270
|
const fit = (value, width, { keepEnd = false } = {}) => {
|
|
271
271
|
const text = value === null || value === undefined || value === '' ? '-' : String(value);
|
|
272
272
|
if (text.length <= width) return text.padEnd(width);
|
|
273
|
-
return keepEnd ?
|
|
273
|
+
return keepEnd ? `\u2026${text.slice(-(width - 1))}` : `${text.slice(0, width - 1)}\u2026`;
|
|
274
274
|
};
|
|
275
275
|
|
|
276
276
|
// A file destination inside the exported project is shown relative to it
|
|
@@ -289,9 +289,11 @@ const destinationOf = (event) => {
|
|
|
289
289
|
* @returns {string}
|
|
290
290
|
*/
|
|
291
291
|
export function formatAuditTable(events) {
|
|
292
|
+
const hasRepo = events.some((e) => typeof e?.repo === 'string' && e.repo.length > 0);
|
|
292
293
|
const columns = [
|
|
293
294
|
['TIME (UTC)', 20, (e) => String(e.timestamp ?? '').replace(/\.\d+Z$/, 'Z')],
|
|
294
295
|
['RESULT', 8, (e) => e.outcome],
|
|
296
|
+
...(hasRepo ? [['REPO', 14, (e) => e.repo]] : []),
|
|
295
297
|
['USER', 12, (e) => e.user],
|
|
296
298
|
['COMMAND', 26, (e) => e.command],
|
|
297
299
|
['DESTINATION', 28, destinationOf, { keepEnd: true }],
|
package/lib/policy/index.js
CHANGED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-repository workspace policy inspection & audit log aggregation
|
|
3
|
+
*
|
|
4
|
+
* Unlike `resolveWorkspaceRepos()`, `resolvePolicyWorkspaceDirs()` does NOT abort on the
|
|
5
|
+
* first invalid `.ctxman/policy.json` file so `ctxman policy check --repo ...` and MCP
|
|
6
|
+
* `check_policy` / `audit_log` can inspect and report all repositories in one pass.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { loadPolicy, PolicyConfigError } from './policy.js';
|
|
12
|
+
import { auditLogFiles, readAuditLog, filterAuditEvents } from './audit.js';
|
|
13
|
+
|
|
14
|
+
function sanitizeAlias(raw) {
|
|
15
|
+
const cleaned = String(raw || 'repo')
|
|
16
|
+
.trim()
|
|
17
|
+
.replace(/[\\/]+/g, '-')
|
|
18
|
+
.replace(/^\.+/, '');
|
|
19
|
+
return cleaned || 'repo';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function buildWorkspaceInputs(primaryRoot, extraRepos = []) {
|
|
23
|
+
const resolvedPrimary = path.resolve(primaryRoot || process.cwd());
|
|
24
|
+
if (!Array.isArray(extraRepos) || extraRepos.length === 0) {
|
|
25
|
+
return [resolvedPrimary];
|
|
26
|
+
}
|
|
27
|
+
const realPrimary = fs.existsSync(resolvedPrimary)
|
|
28
|
+
? fs.realpathSync(resolvedPrimary)
|
|
29
|
+
: resolvedPrimary;
|
|
30
|
+
const isContainerOfExtra = extraRepos.some((item) => {
|
|
31
|
+
const raw = typeof item === 'string' ? item : item?.path;
|
|
32
|
+
if (typeof raw !== 'string' || !raw.trim()) return false;
|
|
33
|
+
const resolved = path.resolve(raw);
|
|
34
|
+
const real = fs.existsSync(resolved) ? fs.realpathSync(resolved) : resolved;
|
|
35
|
+
return real !== realPrimary && real.startsWith(realPrimary + path.sep);
|
|
36
|
+
});
|
|
37
|
+
return isContainerOfExtra ? [...extraRepos] : [resolvedPrimary, ...extraRepos];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve, validate directory existence, deduplicate, and assign unique aliases without
|
|
42
|
+
* failing on invalid policy files.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} primaryRoot
|
|
45
|
+
* @param {Array<string|{path: string, alias?: string}>} [extraRepos]
|
|
46
|
+
* @returns {Array<{alias: string, root: string, realRoot: string}>}
|
|
47
|
+
*/
|
|
48
|
+
export function resolvePolicyWorkspaceDirs(primaryRoot, extraRepos = []) {
|
|
49
|
+
const inputs = buildWorkspaceInputs(primaryRoot, extraRepos);
|
|
50
|
+
const rawEntries = [];
|
|
51
|
+
const seenRealRoots = new Set();
|
|
52
|
+
|
|
53
|
+
for (const item of inputs) {
|
|
54
|
+
const rawPath = typeof item === 'string' ? item : item?.path;
|
|
55
|
+
const explicitAlias = typeof item === 'object' && item !== null ? item.alias : undefined;
|
|
56
|
+
if (typeof rawPath !== 'string' || !rawPath.trim()) {
|
|
57
|
+
throw new Error('Repository path must be a non-empty string');
|
|
58
|
+
}
|
|
59
|
+
const resolved = path.resolve(rawPath);
|
|
60
|
+
if (!fs.statSync(resolved, { throwIfNoEntry: false })?.isDirectory()) {
|
|
61
|
+
throw new Error(`Not a directory: ${rawPath}`);
|
|
62
|
+
}
|
|
63
|
+
const realRoot = fs.realpathSync(resolved);
|
|
64
|
+
if (seenRealRoots.has(realRoot)) continue;
|
|
65
|
+
seenRealRoots.add(realRoot);
|
|
66
|
+
|
|
67
|
+
rawEntries.push({
|
|
68
|
+
root: resolved,
|
|
69
|
+
realRoot,
|
|
70
|
+
explicitAlias: explicitAlias ? sanitizeAlias(explicitAlias) : null,
|
|
71
|
+
baseName: sanitizeAlias(path.basename(resolved) || path.basename(realRoot) || 'repo'),
|
|
72
|
+
parentName: sanitizeAlias(path.basename(path.dirname(resolved))),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const baseCounts = new Map();
|
|
77
|
+
for (const entry of rawEntries) {
|
|
78
|
+
const candidate = entry.explicitAlias ?? entry.baseName;
|
|
79
|
+
baseCounts.set(candidate, (baseCounts.get(candidate) ?? 0) + 1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const usedAliases = new Set();
|
|
83
|
+
return rawEntries.map((entry) => {
|
|
84
|
+
let candidate = entry.explicitAlias ?? entry.baseName;
|
|
85
|
+
if (!entry.explicitAlias && (baseCounts.get(candidate) ?? 0) > 1 && entry.parentName) {
|
|
86
|
+
candidate = `${entry.parentName}-${entry.baseName}`;
|
|
87
|
+
}
|
|
88
|
+
let unique = candidate;
|
|
89
|
+
let suffix = 2;
|
|
90
|
+
while (usedAliases.has(unique)) {
|
|
91
|
+
unique = `${candidate}-${suffix++}`;
|
|
92
|
+
}
|
|
93
|
+
usedAliases.add(unique);
|
|
94
|
+
return {
|
|
95
|
+
alias: unique,
|
|
96
|
+
root: entry.root,
|
|
97
|
+
realRoot: entry.realRoot,
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Validate and summarize export policies across a multi-repository workspace.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} primaryRoot
|
|
106
|
+
* @param {Array<string|{path: string, alias?: string}>} [extraRepos]
|
|
107
|
+
* @param {object} [options]
|
|
108
|
+
* @param {object} [options.env=process.env]
|
|
109
|
+
* @returns {object}
|
|
110
|
+
*/
|
|
111
|
+
export function checkWorkspacePolicies(primaryRoot, extraRepos = [], { env = process.env } = {}) {
|
|
112
|
+
const dirs = resolvePolicyWorkspaceDirs(primaryRoot, extraRepos);
|
|
113
|
+
const repoResults = [];
|
|
114
|
+
const validPolicies = [];
|
|
115
|
+
|
|
116
|
+
for (const { alias, root } of dirs) {
|
|
117
|
+
try {
|
|
118
|
+
const policy = loadPolicy(root, { env });
|
|
119
|
+
validPolicies.push({ alias, policy });
|
|
120
|
+
repoResults.push({
|
|
121
|
+
alias,
|
|
122
|
+
valid: true,
|
|
123
|
+
...policy.toJSON(),
|
|
124
|
+
});
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (!(error instanceof PolicyConfigError)) throw error;
|
|
127
|
+
repoResults.push({
|
|
128
|
+
alias,
|
|
129
|
+
root,
|
|
130
|
+
valid: false,
|
|
131
|
+
file: error.file,
|
|
132
|
+
errors: error.errors,
|
|
133
|
+
message: error.message,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const valid = repoResults.every((r) => r.valid);
|
|
139
|
+
const active = validPolicies.some(({ policy }) => policy.active);
|
|
140
|
+
const requireRedaction = validPolicies.some(({ policy }) => policy.requireRedaction);
|
|
141
|
+
const tokenLimits = validPolicies
|
|
142
|
+
.map(({ policy }) => policy.maxExportTokens)
|
|
143
|
+
.filter((n) => typeof n === 'number');
|
|
144
|
+
const maxExportTokens = tokenLimits.length > 0 ? Math.min(...tokenLimits) : null;
|
|
145
|
+
|
|
146
|
+
const modelRules = validPolicies.flatMap(({ alias, policy }) =>
|
|
147
|
+
policy.modelRules.map(({ scope, file, patterns }) => ({
|
|
148
|
+
repo: alias,
|
|
149
|
+
scope,
|
|
150
|
+
file,
|
|
151
|
+
patterns,
|
|
152
|
+
}))
|
|
153
|
+
);
|
|
154
|
+
const ignoreFiles = [...new Set(validPolicies.flatMap(({ policy }) => policy.ignoreFiles))];
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
valid,
|
|
158
|
+
active,
|
|
159
|
+
requireRedaction,
|
|
160
|
+
maxExportTokens,
|
|
161
|
+
allowedTargetModels: modelRules.length > 0 ? modelRules : null,
|
|
162
|
+
ignoreFiles,
|
|
163
|
+
repos: repoResults,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Read, tag with `repo`, deduplicate, and chronologically merge export audit logs across
|
|
169
|
+
* a multi-repository workspace.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} primaryRoot
|
|
172
|
+
* @param {Array<string|{path: string, alias?: string}>} [extraRepos]
|
|
173
|
+
* @param {object} [options]
|
|
174
|
+
* @param {Date|null} [options.since=null]
|
|
175
|
+
* @param {number|null} [options.limit=null]
|
|
176
|
+
* @param {object} [options.env=process.env]
|
|
177
|
+
* @returns {object}
|
|
178
|
+
*/
|
|
179
|
+
export function readWorkspaceAuditLogs(
|
|
180
|
+
primaryRoot,
|
|
181
|
+
extraRepos = [],
|
|
182
|
+
{ since = null, limit = null, env = process.env } = {}
|
|
183
|
+
) {
|
|
184
|
+
const dirs = resolvePolicyWorkspaceDirs(primaryRoot, extraRepos);
|
|
185
|
+
const aliasByRoot = new Map();
|
|
186
|
+
for (const dir of dirs) {
|
|
187
|
+
aliasByRoot.set(dir.root, dir.alias);
|
|
188
|
+
aliasByRoot.set(dir.realRoot, dir.alias);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const seenEventIds = new Set();
|
|
192
|
+
const seenFiles = new Set();
|
|
193
|
+
const allFiles = [];
|
|
194
|
+
const existingFiles = [];
|
|
195
|
+
const missingFiles = [];
|
|
196
|
+
const policyErrors = [];
|
|
197
|
+
const mergedEvents = [];
|
|
198
|
+
let skippedLines = 0;
|
|
199
|
+
|
|
200
|
+
for (const { alias, root } of dirs) {
|
|
201
|
+
const { files, error } = auditLogFiles(root, { env });
|
|
202
|
+
if (error) {
|
|
203
|
+
policyErrors.push({ repo: alias, root, message: error.message });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const unreadFiles = files.filter((file) => {
|
|
207
|
+
const resolvedFile = path.resolve(file);
|
|
208
|
+
if (seenFiles.has(resolvedFile)) return false;
|
|
209
|
+
seenFiles.add(resolvedFile);
|
|
210
|
+
allFiles.push(file);
|
|
211
|
+
return true;
|
|
212
|
+
});
|
|
213
|
+
if (unreadFiles.length === 0) continue;
|
|
214
|
+
|
|
215
|
+
const { events, skipped, missing } = readAuditLog(unreadFiles);
|
|
216
|
+
skippedLines += skipped;
|
|
217
|
+
for (const file of unreadFiles) {
|
|
218
|
+
if (missing.includes(file)) missingFiles.push(file);
|
|
219
|
+
else existingFiles.push(file);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for (const event of events) {
|
|
223
|
+
if (event.id && seenEventIds.has(event.id)) continue;
|
|
224
|
+
if (event.id) seenEventIds.add(event.id);
|
|
225
|
+
let eventRepo = event.repo ?? null;
|
|
226
|
+
if (!eventRepo && typeof event.root === 'string') {
|
|
227
|
+
const resolvedEventRoot = path.resolve(event.root);
|
|
228
|
+
const realEventRoot = fs.existsSync(resolvedEventRoot)
|
|
229
|
+
? fs.realpathSync(resolvedEventRoot)
|
|
230
|
+
: resolvedEventRoot;
|
|
231
|
+
eventRepo = aliasByRoot.get(resolvedEventRoot) ?? aliasByRoot.get(realEventRoot) ?? null;
|
|
232
|
+
}
|
|
233
|
+
mergedEvents.push({
|
|
234
|
+
repo: eventRepo ?? alias,
|
|
235
|
+
...event,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
mergedEvents.sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)));
|
|
241
|
+
const matching = filterAuditEvents(mergedEvents, { since });
|
|
242
|
+
const selected = filterAuditEvents(matching, { limit });
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
repos: dirs.map(({ alias, root }) => ({ alias, root })),
|
|
246
|
+
files: existingFiles,
|
|
247
|
+
allFiles,
|
|
248
|
+
missing: missingFiles,
|
|
249
|
+
total: matching.length,
|
|
250
|
+
events: selected,
|
|
251
|
+
skippedLines,
|
|
252
|
+
policyErrors,
|
|
253
|
+
};
|
|
254
|
+
}
|