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
|
@@ -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
|
+
}
|
package/lib/pr/pr-context.js
CHANGED
|
@@ -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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|