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.
Files changed (60) hide show
  1. package/.ctxman/llm-profiles.json +6 -6
  2. package/README.md +17 -10
  3. package/bin/cli.js +107 -8
  4. package/bin/cm-ask.js +74 -70
  5. package/bin/cm-audit.js +80 -7
  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/examples/github-actions/ctxman-check.yml +1 -1
  17. package/index.d.ts +931 -1
  18. package/index.js +105 -1
  19. package/lib/analyzers/performance-dashboard.js +7 -3
  20. package/lib/analyzers/token-calculator.js +76 -4
  21. package/lib/api/mcp/server.js +713 -14
  22. package/lib/cache/CacheManager.js +161 -47
  23. package/lib/core/Analyzer.js +61 -5
  24. package/lib/diagnose/index.js +712 -0
  25. package/lib/focus/index.js +790 -0
  26. package/lib/map/call-graph.js +570 -0
  27. package/lib/map/graph.js +30 -5
  28. package/lib/map/index.js +12 -4
  29. package/lib/map/rank.js +45 -7
  30. package/lib/map/render.js +89 -28
  31. package/lib/pack/index.js +20 -1
  32. package/lib/pack/select.js +8 -1
  33. package/lib/parsers/gitignore-parser.js +4 -1
  34. package/lib/policy/audit.js +22 -2
  35. package/lib/policy/index.js +6 -0
  36. package/lib/policy/workspace.js +254 -0
  37. package/lib/pr/pr-context.js +292 -31
  38. package/lib/pr/pr-renderer.js +25 -10
  39. package/lib/rag/EmbeddingProvider.js +139 -2
  40. package/lib/rag/VectorStoreFactory.js +10 -4
  41. package/lib/rag/ask.js +291 -0
  42. package/lib/rag/sources/FileDataSource.js +6 -3
  43. package/lib/rag/stores/SqliteVectorStore.js +595 -0
  44. package/lib/search/hybrid.js +324 -0
  45. package/lib/search/symbols.js +75 -11
  46. package/lib/task/index.js +630 -0
  47. package/lib/utils/file-utils.js +1 -0
  48. package/lib/utils/logger.js +5 -2
  49. package/lib/utils/multi-repo-manager.js +4 -1
  50. package/lib/watch/FileWatcher.js +121 -31
  51. package/lib/watch/IncrementalAnalyzer.js +964 -16
  52. package/lib/who/index.js +893 -0
  53. package/lib/why/analyzer-bridge.js +223 -0
  54. package/lib/why/discussions.js +314 -0
  55. package/lib/why/index.js +356 -30
  56. package/lib/why/render.js +154 -9
  57. package/lib/wizards/config-generator.js +20 -18
  58. package/lib/wizards/project-detector.js +2 -1
  59. package/lib/workspace/index.js +1274 -0
  60. package/package.json +2 -2
@@ -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
+ }
@@ -17,6 +17,7 @@ import FileUtils from '../utils/file-utils.js';
17
17
  import TokenUtils from '../utils/token-utils.js';
18
18
  import { redactSecrets } from '../utils/secret-redactor.js';
19
19
  import { readFileChecked } from '../utils/safe-read.js';
20
+ import { resolveWorkspaceRepos, enforceWorkspacePolicies } from '../workspace/index.js';
20
21
  import PrGit, { bufferToText } from './pr-git.js';
21
22
  import {
22
23
  parseNameStatus,
@@ -53,6 +54,7 @@ const DEFAULTS = {
53
54
  redact: true,
54
55
  maxFileBytes: 1024 * 1024,
55
56
  maxRelatedTests: 50,
57
+ repos: null,
56
58
  };
57
59
 
58
60
  // Test files read to look for imports and references: per file, and for all of them
@@ -65,6 +67,7 @@ const FILTER_NAMES = {
65
67
  context: '.contextignore',
66
68
  'context-include': '.contextinclude',
67
69
  };
70
+ const IMPACT_ORDER = { low: 0, medium: 1, high: 2 };
68
71
 
69
72
  /**
70
73
  * Check and complete the options
@@ -273,35 +276,58 @@ function describeDropped(dropped) {
273
276
  return labels;
274
277
  }
275
278
 
276
- /**
277
- * Build the review context for a branch, the staged changes or the working tree
278
- * @param {string} [root=process.cwd()] - Project directory (inside a git work tree); a
279
- * subdirectory limits the change to that directory
280
- * @param {object} [options]
281
- * @param {string} [options.base] - Base revision (default: origin/HEAD, else main, else master;
282
- * HEAD with staged/working)
283
- * @param {string} [options.head='HEAD'] - Head revision
284
- * @param {boolean} [options.staged] - Compare the index with the base instead
285
- * @param {boolean} [options.working] - Compare the working tree (with untracked files)
286
- * @param {number} [options.unified=3] - Context lines in the diff
287
- * @param {boolean} [options.diff=true] - Include the unified diff
288
- * @param {boolean} [options.withTests] - Include related tests' contents
289
- * @param {boolean} [options.withFiles] - Include changed files' full contents
290
- * @param {string} [options.ask] - Question appended at the end
291
- * @param {string} [options.instructions] - Instructions appended at the end
292
- * @param {number} [options.budget] - Maximum tokens of the output
293
- * @param {'markdown'|'xml'|'json'} [options.style='markdown']
294
- * @param {boolean} [options.redact=true] - Mask secrets in contents, diff and commit messages
295
- * @returns {Promise<object>} `output` (the rendered text), `tokens`, per-section `sections`
296
- * token counts, the collected `commits`, `files`, `methods`, `relatedTests`, `stats`,
297
- * `budget` (limit, dropped, over), `redactions` and `warnings`
298
- */
299
- export async function buildPrContext(root = process.cwd(), options = {}) {
300
- const opts = normalizePrOptions(options);
301
- const rootPath = path.resolve(root);
302
- if (!fs.existsSync(rootPath) || !fs.statSync(rootPath).isDirectory()) {
303
- throw new Error(`Not a directory: ${rootPath}`);
279
+ function buildWorkspaceRepoInputs(primaryRoot, extraRepos) {
280
+ const realPrimary = fs.existsSync(primaryRoot)
281
+ ? fs.realpathSync(primaryRoot)
282
+ : path.resolve(primaryRoot);
283
+ const isContainerOfExtra = extraRepos.some((item) => {
284
+ const raw = typeof item === 'string' ? item : item?.path;
285
+ if (typeof raw !== 'string' || !raw.trim()) return false;
286
+ const resolved = path.resolve(raw);
287
+ const real = fs.existsSync(resolved) ? fs.realpathSync(resolved) : resolved;
288
+ return real !== realPrimary && real.startsWith(realPrimary + path.sep);
289
+ });
290
+ if (isContainerOfExtra || !PrGit.findRepoRoot(realPrimary)) {
291
+ return [...extraRepos];
304
292
  }
293
+ return [primaryRoot, ...extraRepos];
294
+ }
295
+
296
+ function prefixDiffWithAlias(diffText, alias) {
297
+ if (!diffText) return diffText;
298
+ return diffText
299
+ .split('\n')
300
+ .map((line) => {
301
+ if (line.startsWith('diff --git a/')) {
302
+ return line.replace(
303
+ /^diff --git a\/(.+?) b\/(.+)$/,
304
+ `diff --git a/${alias}/$1 b/${alias}/$2`
305
+ );
306
+ }
307
+ if (line.startsWith('--- a/')) {
308
+ return `--- a/${alias}/${line.slice(6)}`;
309
+ }
310
+ if (line.startsWith('+++ b/')) {
311
+ return `+++ b/${alias}/${line.slice(6)}`;
312
+ }
313
+ if (line.startsWith('rename from ')) {
314
+ return `rename from ${alias}/${line.slice(12)}`;
315
+ }
316
+ if (line.startsWith('rename to ')) {
317
+ return `rename to ${alias}/${line.slice(10)}`;
318
+ }
319
+ if (line.startsWith('copy from ')) {
320
+ return `copy from ${alias}/${line.slice(10)}`;
321
+ }
322
+ if (line.startsWith('copy to ')) {
323
+ return `copy to ${alias}/${line.slice(8)}`;
324
+ }
325
+ return line;
326
+ })
327
+ .join('\n');
328
+ }
329
+
330
+ function collectSingleRepoPr(rootPath, opts) {
305
331
  const repoTop = PrGit.findRepoRoot(rootPath);
306
332
  if (!repoTop) throw new Error(`Not inside a git repository: ${rootPath}`);
307
333
  const repoRoot = fs.realpathSync(repoTop);
@@ -635,7 +661,10 @@ export async function buildPrContext(root = process.cwd(), options = {}) {
635
661
  );
636
662
  }
637
663
 
638
- // --- render within the budget ------------------------------------------------------------
664
+ return { model, scope, redactions, warnings };
665
+ }
666
+
667
+ function renderModelWithinBudget(model, opts, redactions, warnings, scope) {
639
668
  const count = (text) => TokenUtils.calculate(text, 'pr-context.md');
640
669
  const dropped = {
641
670
  contents: [],
@@ -691,6 +720,7 @@ export async function buildPrContext(root = process.cwd(), options = {}) {
691
720
  }
692
721
 
693
722
  return {
723
+ ...(model.repos ? { repos: model.repos } : {}),
694
724
  output: result.output,
695
725
  tokens: result.tokens,
696
726
  style: opts.style,
@@ -704,8 +734,9 @@ export async function buildPrContext(root = process.cwd(), options = {}) {
704
734
  budget: model.budget,
705
735
  commits: model.commits,
706
736
  files: model.files,
707
- methods: { changed: methods, deleted: deletedMethods },
708
- relatedTests: tests.map(({ path: testPath, reasons, changed, excluded }) => ({
737
+ methods: { changed: model.methods, deleted: model.deletedMethods },
738
+ relatedTests: model.tests.map(({ path: testPath, reasons, changed, excluded, repo }) => ({
739
+ ...(repo ? { repo } : {}),
709
740
  path: testPath,
710
741
  reasons,
711
742
  changed,
@@ -716,6 +747,236 @@ export async function buildPrContext(root = process.cwd(), options = {}) {
716
747
  };
717
748
  }
718
749
 
750
+ /**
751
+ * Build the review context for a branch, the staged changes or the working tree
752
+ * @param {string} [root=process.cwd()] - Project directory (inside a git work tree); a
753
+ * subdirectory limits the change to that directory
754
+ * @param {object} [options]
755
+ * @param {string} [options.base] - Base revision (default: origin/HEAD, else main, else master;
756
+ * HEAD with staged/working)
757
+ * @param {string} [options.head='HEAD'] - Head revision
758
+ * @param {boolean} [options.staged] - Compare the index with the base instead
759
+ * @param {boolean} [options.working] - Compare the working tree (with untracked files)
760
+ * @param {number} [options.unified=3] - Context lines in the diff
761
+ * @param {boolean} [options.diff=true] - Include the unified diff
762
+ * @param {boolean} [options.withTests] - Include related tests' contents
763
+ * @param {boolean} [options.withFiles] - Include changed files' full contents
764
+ * @param {string} [options.ask] - Question appended at the end
765
+ * @param {string} [options.instructions] - Instructions appended at the end
766
+ * @param {number} [options.budget] - Maximum tokens of the output
767
+ * @param {'markdown'|'xml'|'json'} [options.style='markdown']
768
+ * @param {boolean} [options.redact=true] - Mask secrets in contents, diff and commit messages
769
+ * @param {Array<string|{path: string, alias?: string}>} [options.repos] - Additional workspace repositories
770
+ * @returns {Promise<object>} `output` (the rendered text), `tokens`, per-section `sections`
771
+ * token counts, the collected `commits`, `files`, `methods`, `relatedTests`, `stats`,
772
+ * `budget` (limit, dropped, over), `redactions` and `warnings`
773
+ */
774
+ export async function buildPrContext(root = process.cwd(), options = {}) {
775
+ const opts = normalizePrOptions(options);
776
+ const rootPath = path.resolve(root);
777
+ if (!fs.existsSync(rootPath) || !fs.statSync(rootPath).isDirectory()) {
778
+ throw new Error(`Not a directory: ${rootPath}`);
779
+ }
780
+
781
+ const isWorkspace = Array.isArray(options.repos) && options.repos.length > 0;
782
+ if (!isWorkspace) {
783
+ const { model, scope, redactions, warnings } = collectSingleRepoPr(rootPath, opts);
784
+ return renderModelWithinBudget(model, opts, redactions, warnings, scope);
785
+ }
786
+
787
+ const resolvedRepos = resolveWorkspaceRepos(buildWorkspaceRepoInputs(rootPath, options.repos));
788
+ enforceWorkspacePolicies(resolvedRepos, {
789
+ filesByRepo: new Map(),
790
+ tokens: 0,
791
+ redact: opts.redact,
792
+ });
793
+
794
+ for (const repo of resolvedRepos) {
795
+ if (!PrGit.findRepoRoot(repo.realRoot)) {
796
+ throw new Error(`Not inside a git repository: ${repo.root}`);
797
+ }
798
+ }
799
+
800
+ const perRepoResults = [];
801
+ const skippedErrors = [];
802
+ let firstError = null;
803
+
804
+ for (const repo of resolvedRepos) {
805
+ try {
806
+ const single = collectSingleRepoPr(repo.root, { ...opts, budget: null });
807
+ perRepoResults.push({ repo, ...single });
808
+ } catch (error) {
809
+ if (error.name === 'PolicyConfigError' || error.name === 'PolicyViolationError') {
810
+ throw error;
811
+ }
812
+ firstError ??= error;
813
+ skippedErrors.push({ repo, error });
814
+ }
815
+ }
816
+
817
+ if (perRepoResults.length === 0 && firstError) {
818
+ throw firstError;
819
+ }
820
+
821
+ const warnings = [];
822
+ for (const { repo, error } of skippedErrors) {
823
+ warnings.push(`${repo.alias}: skipped (${error.message})`);
824
+ }
825
+
826
+ const redactions = {};
827
+ const commits = [];
828
+ const files = [];
829
+ const methods = [];
830
+ const deletedMethods = [];
831
+ const diffs = [];
832
+ const allTests = [];
833
+ const contents = [];
834
+ const byStatus = {};
835
+ const filesByRepo = new Map(resolvedRepos.map((r) => [r.alias, []]));
836
+
837
+ let highestImpact = { level: 'low', score: 0 };
838
+
839
+ for (const { repo, model: m, redactions: r, warnings: w } of perRepoResults) {
840
+ for (const [type, count] of Object.entries(r)) {
841
+ redactions[type] = (redactions[type] || 0) + count;
842
+ }
843
+ for (const warning of w) {
844
+ warnings.push(`${repo.alias}: ${warning}`);
845
+ }
846
+ if (
847
+ (IMPACT_ORDER[m.impact.level] ?? 0) > (IMPACT_ORDER[highestImpact.level] ?? 0) ||
848
+ m.impact.score > highestImpact.score
849
+ ) {
850
+ highestImpact = {
851
+ level:
852
+ (IMPACT_ORDER[m.impact.level] ?? 0) >= (IMPACT_ORDER[highestImpact.level] ?? 0)
853
+ ? m.impact.level
854
+ : highestImpact.level,
855
+ score: Math.max(highestImpact.score, m.impact.score),
856
+ };
857
+ }
858
+ for (const [status, cnt] of Object.entries(m.stats.byStatus)) {
859
+ byStatus[status] = (byStatus[status] || 0) + cnt;
860
+ }
861
+ for (const c of m.commits) {
862
+ commits.push({ ...c, repo: repo.alias });
863
+ }
864
+ for (const f of m.files) {
865
+ filesByRepo.get(repo.alias)?.push({ path: f.path, tokens: 0 });
866
+ files.push({
867
+ ...f,
868
+ repo: repo.alias,
869
+ path: `${repo.alias}/${f.path}`,
870
+ oldPath: f.oldPath ? `${repo.alias}/${f.oldPath}` : null,
871
+ });
872
+ }
873
+ for (const method of m.methods) {
874
+ methods.push({
875
+ ...method,
876
+ repo: repo.alias,
877
+ file: `${repo.alias}/${method.file}`,
878
+ });
879
+ }
880
+ for (const dm of m.deletedMethods) {
881
+ deletedMethods.push({
882
+ ...dm,
883
+ repo: repo.alias,
884
+ file: `${repo.alias}/${dm.file}`,
885
+ });
886
+ }
887
+ for (const d of m.diffs) {
888
+ diffs.push({
889
+ ...d,
890
+ repo: repo.alias,
891
+ path: `${repo.alias}/${d.path}`,
892
+ text: prefixDiffWithAlias(d.text, repo.alias),
893
+ });
894
+ }
895
+ for (const t of m.tests) {
896
+ allTests.push({
897
+ ...t,
898
+ repo: repo.alias,
899
+ path: `${repo.alias}/${t.path}`,
900
+ });
901
+ }
902
+ for (const c of m.contents) {
903
+ contents.push({
904
+ ...c,
905
+ repo: repo.alias,
906
+ path: `${repo.alias}/${c.path}`,
907
+ });
908
+ }
909
+ }
910
+
911
+ const tests = allTests.slice(0, opts.maxRelatedTests);
912
+ if (allTests.length > tests.length) {
913
+ warnings.push(
914
+ `${allTests.length} related tests found across workspace; the first ${tests.length} are listed`
915
+ );
916
+ }
917
+
918
+ const uniqueJoin = (values) => [...new Set(values.filter(Boolean))].join(', ');
919
+ const firstModel = perRepoResults[0].model;
920
+ const baseRef = uniqueJoin(perRepoResults.map((x) => x.model.base.ref));
921
+ const baseShort = uniqueJoin(perRepoResults.map((x) => x.model.base.short));
922
+ const headRef = uniqueJoin(perRepoResults.map((x) => x.model.head.ref));
923
+ const headShort = uniqueJoin(perRepoResults.map((x) => x.model.head.short));
924
+ const mergeShort = uniqueJoin(perRepoResults.map((x) => x.model.mergeBase.short));
925
+
926
+ const workspaceModel = {
927
+ repos: resolvedRepos.map(({ alias, root: r }) => ({ alias, root: r })),
928
+ mode: firstModel.mode,
929
+ base: { ref: baseRef, sha: firstModel.base.sha, short: baseShort || null },
930
+ head:
931
+ firstModel.mode === 'range'
932
+ ? { ref: headRef, sha: firstModel.head.sha, short: headShort || null }
933
+ : { ref: headRef, sha: null, short: null },
934
+ mergeBase: { sha: firstModel.mergeBase.sha, short: mergeShort },
935
+ scope: '',
936
+ impact: highestImpact,
937
+ stats: {
938
+ commits: commits.length,
939
+ files: files.length,
940
+ byStatus,
941
+ additions: files.reduce((sum, f) => sum + f.additions, 0),
942
+ deletions: files.reduce((sum, f) => sum + f.deletions, 0),
943
+ excluded: files.filter((f) => f.excluded).length,
944
+ changedMethods: methods.length,
945
+ addedMethods: methods.filter((m) => m.change === 'added').length,
946
+ deletedMethods: deletedMethods.length,
947
+ relatedTests: tests.length,
948
+ },
949
+ commits,
950
+ files,
951
+ methods,
952
+ deletedMethods,
953
+ diffEnabled: opts.diff,
954
+ diffs,
955
+ tests,
956
+ withTests: opts.withTests,
957
+ withFiles: opts.withFiles,
958
+ contents,
959
+ instructions: opts.instructions,
960
+ ask: opts.ask,
961
+ budget: opts.budget ? { limit: opts.budget, dropped: [], over: false } : null,
962
+ tokens: null,
963
+ logBodiesDropped: false,
964
+ logDropped: false,
965
+ filesDropped: false,
966
+ methodsDropped: false,
967
+ };
968
+
969
+ const rendered = renderModelWithinBudget(workspaceModel, opts, redactions, warnings, '');
970
+
971
+ enforceWorkspacePolicies(resolvedRepos, {
972
+ filesByRepo,
973
+ tokens: rendered.tokens,
974
+ redact: opts.redact,
975
+ });
976
+
977
+ return rendered;
978
+ }
979
+
719
980
  /**
720
981
  * What the budget drops, least important first: full file contents, test contents, related
721
982
  * test paths, per-file diffs, method sources, the methods list, commit bodies, the commit