ctxman 3.1.0 → 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 +17 -10
- package/bin/cli.js +107 -8
- package/bin/cm-ask.js +74 -70
- package/bin/cm-audit.js +80 -7
- 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/examples/github-actions/ctxman-check.yml +1 -1
- package/index.d.ts +931 -1
- package/index.js +105 -1
- package/lib/analyzers/performance-dashboard.js +7 -3
- package/lib/analyzers/token-calculator.js +76 -4
- package/lib/api/mcp/server.js +713 -14
- 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/parsers/gitignore-parser.js +4 -1
- package/lib/policy/audit.js +22 -2
- package/lib/policy/index.js +6 -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 +291 -0
- package/lib/rag/sources/FileDataSource.js +6 -3
- 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/logger.js +5 -2
- 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/wizards/config-generator.js +20 -18
- package/lib/wizards/project-detector.js +2 -1
- package/lib/workspace/index.js +1274 -0
- package/package.json +2 -2
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;
|
|
@@ -43,7 +43,10 @@ class GitIgnoreParser {
|
|
|
43
43
|
} catch (error) {
|
|
44
44
|
// A directory, or a file that doesn't exist or can't be read
|
|
45
45
|
if (error.code === 'EISDIR') {
|
|
46
|
-
|
|
46
|
+
// Quoted and escaped: a line break in the path cannot start a line of its own
|
|
47
|
+
console.warn(
|
|
48
|
+
`Warning: ${JSON.stringify(String(filePath))} is a directory, expected a file`
|
|
49
|
+
);
|
|
47
50
|
}
|
|
48
51
|
return [];
|
|
49
52
|
}
|
package/lib/policy/audit.js
CHANGED
|
@@ -12,7 +12,7 @@ import os from 'os';
|
|
|
12
12
|
import path from 'path';
|
|
13
13
|
import { execFileSync } from 'child_process';
|
|
14
14
|
import { createRequire } from 'module';
|
|
15
|
-
import { relativePathOf } from './policy.js';
|
|
15
|
+
import { relativePathOf, loadPolicy, DEFAULT_AUDIT_PATH } from './policy.js';
|
|
16
16
|
|
|
17
17
|
const { version: CTXMAN_VERSION } = createRequire(import.meta.url)('../../package.json');
|
|
18
18
|
|
|
@@ -167,6 +167,24 @@ export function recordExport(policy, event = {}, { warn = defaultWarn } = {}) {
|
|
|
167
167
|
return record;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/**
|
|
171
|
+
* The audit logs of a project's policy, or the default location when the policy names none
|
|
172
|
+
* @param {string} root - Project root
|
|
173
|
+
* @param {Object} [options]
|
|
174
|
+
* @param {object} [options.env=process.env] - Environment for loading the policy
|
|
175
|
+
* @returns {{files: string[], error: Error|null}} `error` when the policy could not be
|
|
176
|
+
* loaded (the default location is returned then)
|
|
177
|
+
*/
|
|
178
|
+
export function auditLogFiles(root, { env = process.env } = {}) {
|
|
179
|
+
try {
|
|
180
|
+
const policy = loadPolicy(root, { env });
|
|
181
|
+
if (policy.audit.paths.length > 0) return { files: policy.audit.paths, error: null };
|
|
182
|
+
return { files: [path.join(root, DEFAULT_AUDIT_PATH)], error: null };
|
|
183
|
+
} catch (error) {
|
|
184
|
+
return { files: [path.join(root, DEFAULT_AUDIT_PATH)], error };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
170
188
|
/**
|
|
171
189
|
* Read audit logs. Lines that are not JSON objects are skipped and counted; events that
|
|
172
190
|
* appear in several logs (organisation and project) are listed once.
|
|
@@ -252,7 +270,7 @@ export function filterAuditEvents(events, { since = null, limit = null } = {}) {
|
|
|
252
270
|
const fit = (value, width, { keepEnd = false } = {}) => {
|
|
253
271
|
const text = value === null || value === undefined || value === '' ? '-' : String(value);
|
|
254
272
|
if (text.length <= width) return text.padEnd(width);
|
|
255
|
-
return keepEnd ?
|
|
273
|
+
return keepEnd ? `\u2026${text.slice(-(width - 1))}` : `${text.slice(0, width - 1)}\u2026`;
|
|
256
274
|
};
|
|
257
275
|
|
|
258
276
|
// A file destination inside the exported project is shown relative to it
|
|
@@ -271,9 +289,11 @@ const destinationOf = (event) => {
|
|
|
271
289
|
* @returns {string}
|
|
272
290
|
*/
|
|
273
291
|
export function formatAuditTable(events) {
|
|
292
|
+
const hasRepo = events.some((e) => typeof e?.repo === 'string' && e.repo.length > 0);
|
|
274
293
|
const columns = [
|
|
275
294
|
['TIME (UTC)', 20, (e) => String(e.timestamp ?? '').replace(/\.\d+Z$/, 'Z')],
|
|
276
295
|
['RESULT', 8, (e) => e.outcome],
|
|
296
|
+
...(hasRepo ? [['REPO', 14, (e) => e.repo]] : []),
|
|
277
297
|
['USER', 12, (e) => e.user],
|
|
278
298
|
['COMMAND', 26, (e) => e.command],
|
|
279
299
|
['DESTINATION', 28, destinationOf, { keepEnd: true }],
|
package/lib/policy/index.js
CHANGED
|
@@ -38,6 +38,7 @@ export {
|
|
|
38
38
|
recordExport,
|
|
39
39
|
buildAuditRecord,
|
|
40
40
|
readAuditLog,
|
|
41
|
+
auditLogFiles,
|
|
41
42
|
filterAuditEvents,
|
|
42
43
|
formatAuditTable,
|
|
43
44
|
parseSince,
|
|
@@ -50,3 +51,8 @@ export {
|
|
|
50
51
|
loadIgnoreFileRules,
|
|
51
52
|
IgnoreFileRules,
|
|
52
53
|
} from './ignore-files.js';
|
|
54
|
+
export {
|
|
55
|
+
resolvePolicyWorkspaceDirs,
|
|
56
|
+
checkWorkspacePolicies,
|
|
57
|
+
readWorkspaceAuditLogs,
|
|
58
|
+
} from './workspace.js';
|