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.
Files changed (53) hide show
  1. package/.ctxman/llm-profiles.json +6 -6
  2. package/README.md +16 -10
  3. package/bin/cli.js +107 -8
  4. package/bin/cm-ask.js +64 -6
  5. package/bin/cm-audit.js +76 -0
  6. package/bin/cm-diagnose.js +199 -0
  7. package/bin/cm-focus.js +322 -0
  8. package/bin/cm-map.js +71 -18
  9. package/bin/cm-pack.js +36 -5
  10. package/bin/cm-policy.js +107 -2
  11. package/bin/cm-pr.js +18 -3
  12. package/bin/cm-search.js +84 -19
  13. package/bin/cm-task.js +192 -0
  14. package/bin/cm-who.js +119 -0
  15. package/bin/cm-why.js +41 -5
  16. package/index.d.ts +931 -1
  17. package/index.js +105 -1
  18. package/lib/analyzers/token-calculator.js +76 -4
  19. package/lib/api/mcp/server.js +618 -16
  20. package/lib/cache/CacheManager.js +161 -47
  21. package/lib/core/Analyzer.js +61 -5
  22. package/lib/diagnose/index.js +712 -0
  23. package/lib/focus/index.js +790 -0
  24. package/lib/map/call-graph.js +570 -0
  25. package/lib/map/graph.js +30 -5
  26. package/lib/map/index.js +12 -4
  27. package/lib/map/rank.js +45 -7
  28. package/lib/map/render.js +89 -28
  29. package/lib/pack/index.js +20 -1
  30. package/lib/pack/select.js +8 -1
  31. package/lib/policy/audit.js +3 -1
  32. package/lib/policy/index.js +5 -0
  33. package/lib/policy/workspace.js +254 -0
  34. package/lib/pr/pr-context.js +292 -31
  35. package/lib/pr/pr-renderer.js +25 -10
  36. package/lib/rag/EmbeddingProvider.js +139 -2
  37. package/lib/rag/VectorStoreFactory.js +10 -4
  38. package/lib/rag/ask.js +186 -17
  39. package/lib/rag/stores/SqliteVectorStore.js +595 -0
  40. package/lib/search/hybrid.js +324 -0
  41. package/lib/search/symbols.js +75 -11
  42. package/lib/task/index.js +630 -0
  43. package/lib/utils/file-utils.js +1 -0
  44. package/lib/utils/multi-repo-manager.js +4 -1
  45. package/lib/watch/FileWatcher.js +121 -31
  46. package/lib/watch/IncrementalAnalyzer.js +964 -16
  47. package/lib/who/index.js +893 -0
  48. package/lib/why/analyzer-bridge.js +223 -0
  49. package/lib/why/discussions.js +314 -0
  50. package/lib/why/index.js +356 -30
  51. package/lib/why/render.js +154 -9
  52. package/lib/workspace/index.js +1274 -0
  53. 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
- * Files of the graph matched by focus paths (a file, or a folder for every file in it)
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 relative = toProjectPath(graph.root, entry);
132
- const matches = nodePaths.filter(
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 graph.edges) {
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, graph.edges, {
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) => ({ ...method, tokens: 0 })),
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
- line: method.line,
235
- name: method.name,
236
- signature: method.signature,
237
- tokens: sources[i] ? TokenUtils.calculate(sources[i], absolute) : 0,
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
- lines.push(`- L${method.line} ${inlineCode(method.signature)} (${method.tokens} tokens)`);
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
- lines.push(` ${method.line}: ${method.signature} [${method.tokens} tok]`);
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
- return `${JSON.stringify(
311
- {
312
- root: graph.root,
313
- nodes: ranked.map((row) => ({
314
- path: row.path,
315
- rank: row.rank,
316
- score: round(row.score, 6),
317
- })),
318
- edges: graph.edges.map((edge) => ({ ...edge, weight: round(edge.weight, 6) })),
319
- },
320
- null,
321
- 2
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 { ranked } = rankEntries(selection.root, entries);
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'
@@ -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 exclude are skipped.
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;
@@ -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 ? `…${text.slice(-(width - 1))}` : `${text.slice(0, width - 1)}…`;
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 }],
@@ -51,3 +51,8 @@ export {
51
51
  loadIgnoreFileRules,
52
52
  IgnoreFileRules,
53
53
  } from './ignore-files.js';
54
+ export {
55
+ resolvePolicyWorkspaceDirs,
56
+ checkWorkspacePolicies,
57
+ readWorkspaceAuditLogs,
58
+ } from './workspace.js';
@@ -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
+ }