@delegance/claude-autopilot 2.1.0 → 2.3.0
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/CHANGELOG.md +19 -0
- package/package.json +1 -1
- package/src/cli/ci.ts +2 -0
- package/src/cli/index.ts +8 -0
- package/src/cli/run.ts +40 -0
- package/src/core/chunking/index.ts +21 -1
- package/src/core/config/types.ts +1 -1
- package/src/core/git/diff-hunks.ts +86 -0
- package/src/core/ignore/index.ts +42 -0
- package/src/core/persist/findings-cache.ts +43 -0
- package/src/core/pipeline/review-phase.ts +85 -31
- package/src/core/pipeline/run.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.3.0] — 2026-04-22
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Parallel chunk review** — file-level chunks are now reviewed concurrently (default parallelism: 3, configurable via `chunking.parallelism`); serial fallback preserved when `cost.budgetUSD` is set so budget enforcement remains accurate
|
|
7
|
+
- **`.autopilot-ignore`** — project-level suppression file; format: `<rule-id> <glob>` or bare `<glob>` (matches any finding on that path); comments and blank lines ignored; suppressed count printed dim after run
|
|
8
|
+
- **`--delta` mode** — only reports findings new since the previous run; pre-existing findings are hidden and the count is printed dim; findings always persisted to `.autopilot-cache/findings.json` after each run (gitignored)
|
|
9
|
+
- `src/core/ignore/index.ts` — `loadIgnoreRules()`, `applyIgnoreRules()`
|
|
10
|
+
- `src/core/persist/findings-cache.ts` — `loadCachedFindings()`, `saveCachedFindings()`, `filterNewFindings()`
|
|
11
|
+
- 15 new tests — **248 total**
|
|
12
|
+
|
|
13
|
+
## [2.2.0] — 2026-04-22
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **`reviewStrategy: diff`** — new chunking strategy that sends `git diff` unified hunks instead of full file contents; typically ~70% fewer tokens and more focused findings (LLM sees exactly what changed)
|
|
17
|
+
- **`--diff` flag** on `run` and `ci` subcommands — shorthand to activate diff strategy without editing config
|
|
18
|
+
- **`src/core/git/diff-hunks.ts`** — `getFileDiffs()`, `parseUnifiedDiff()`, `formatDiffContent()`; per-file diff sections in fenced code blocks; files that exceed `maxChars` are omitted with a count notice
|
|
19
|
+
- `BuildChunksInput.base` / `ReviewPhaseInput.base` / `RunInput.base` — threads git base ref through pipeline to diff engine
|
|
20
|
+
- 9 new tests for `parseUnifiedDiff` and `formatDiffContent` — **233 total**
|
|
21
|
+
|
|
3
22
|
## [2.1.0] — 2026-04-22
|
|
4
23
|
|
|
5
24
|
### Added
|
package/package.json
CHANGED
package/src/cli/ci.ts
CHANGED
|
@@ -6,6 +6,7 @@ export interface CiCommandOptions {
|
|
|
6
6
|
base?: string;
|
|
7
7
|
postComments?: boolean;
|
|
8
8
|
sarifOutput?: string;
|
|
9
|
+
diff?: boolean;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -34,5 +35,6 @@ export async function runCi(options: CiCommandOptions = {}): Promise<number> {
|
|
|
34
35
|
postComments: options.postComments ?? true,
|
|
35
36
|
format: 'sarif',
|
|
36
37
|
outputPath: sarifOutput,
|
|
38
|
+
diff: options.diff,
|
|
37
39
|
});
|
|
38
40
|
}
|
package/src/cli/index.ts
CHANGED
|
@@ -66,6 +66,8 @@ Options (run):
|
|
|
66
66
|
--config <path> Path to config file (default: ./autopilot.config.yaml)
|
|
67
67
|
--files <a,b,c> Explicit comma-separated file list (skips git detection)
|
|
68
68
|
--dry-run Show what would run without executing
|
|
69
|
+
--diff Send git diff hunks instead of full files (~70% fewer tokens)
|
|
70
|
+
--delta Only report findings new since last run (suppress pre-existing)
|
|
69
71
|
--post-comments Post/update a summary comment on the open PR
|
|
70
72
|
--format <text|sarif> Output format (default: text)
|
|
71
73
|
--output <path> Output file path (required with --format sarif)
|
|
@@ -120,6 +122,8 @@ switch (subcommand) {
|
|
|
120
122
|
const config = flag('config');
|
|
121
123
|
const filesArg = flag('files');
|
|
122
124
|
const dryRun = boolFlag('dry-run');
|
|
125
|
+
const diff = boolFlag('diff');
|
|
126
|
+
const delta = boolFlag('delta');
|
|
123
127
|
const postComments = boolFlag('post-comments');
|
|
124
128
|
const formatArg = flag('format');
|
|
125
129
|
const outputPath = flag('output');
|
|
@@ -138,6 +142,8 @@ switch (subcommand) {
|
|
|
138
142
|
configPath: config,
|
|
139
143
|
files: filesArg ? filesArg.split(',').map(f => f.trim()) : undefined,
|
|
140
144
|
dryRun,
|
|
145
|
+
diff,
|
|
146
|
+
delta,
|
|
141
147
|
postComments,
|
|
142
148
|
format: formatArg as 'text' | 'sarif' | undefined,
|
|
143
149
|
outputPath,
|
|
@@ -151,11 +157,13 @@ switch (subcommand) {
|
|
|
151
157
|
const config = flag('config');
|
|
152
158
|
const outputPath = flag('output');
|
|
153
159
|
const noPostComments = boolFlag('no-post-comments');
|
|
160
|
+
const diff = boolFlag('diff');
|
|
154
161
|
const code = await runCi({
|
|
155
162
|
configPath: config,
|
|
156
163
|
base,
|
|
157
164
|
sarifOutput: outputPath,
|
|
158
165
|
postComments: noPostComments ? false : undefined,
|
|
166
|
+
diff,
|
|
159
167
|
});
|
|
160
168
|
process.exit(code);
|
|
161
169
|
break;
|
package/src/cli/run.ts
CHANGED
|
@@ -38,6 +38,8 @@ import { detectProtectedPaths } from '../core/detect/protected-paths.ts';
|
|
|
38
38
|
import { detectGitContext } from '../core/detect/git-context.ts';
|
|
39
39
|
import { detectProject } from './detector.ts';
|
|
40
40
|
import { detectPrNumber, formatComment, postPrComment } from './pr-comment.ts';
|
|
41
|
+
import { loadIgnoreRules, applyIgnoreRules } from '../core/ignore/index.ts';
|
|
42
|
+
import { loadCachedFindings, saveCachedFindings, filterNewFindings } from '../core/persist/findings-cache.ts';
|
|
41
43
|
|
|
42
44
|
function readToolVersion(): string {
|
|
43
45
|
const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../package.json');
|
|
@@ -64,6 +66,8 @@ export interface RunCommandOptions {
|
|
|
64
66
|
base?: string; // git base ref (default HEAD~1)
|
|
65
67
|
files?: string[]; // explicit file list (skips git detection)
|
|
66
68
|
dryRun?: boolean; // skip review, print what would run
|
|
69
|
+
diff?: boolean; // use diff strategy (send git hunks instead of full files)
|
|
70
|
+
delta?: boolean; // only report findings not present in last run's baseline
|
|
67
71
|
format?: 'text' | 'sarif';
|
|
68
72
|
outputPath?: string;
|
|
69
73
|
postComments?: boolean; // post/update summary comment on the open PR
|
|
@@ -167,6 +171,11 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
|
|
|
167
171
|
? await loadRulesFromConfig(config.staticRules)
|
|
168
172
|
: [];
|
|
169
173
|
|
|
174
|
+
// Apply --diff flag: override reviewStrategy to 'diff'
|
|
175
|
+
if (options.diff && config.reviewStrategy !== 'diff') {
|
|
176
|
+
config = { ...config, reviewStrategy: 'diff' };
|
|
177
|
+
}
|
|
178
|
+
|
|
170
179
|
// Execute pipeline
|
|
171
180
|
const input: RunInput = {
|
|
172
181
|
touchedFiles,
|
|
@@ -175,11 +184,42 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
|
|
|
175
184
|
staticRules,
|
|
176
185
|
cwd,
|
|
177
186
|
gitSummary: gitCtx.summary ?? undefined,
|
|
187
|
+
base: options.base,
|
|
178
188
|
};
|
|
179
189
|
|
|
180
190
|
console.log('');
|
|
181
191
|
const result = await runAutopilot(input);
|
|
182
192
|
|
|
193
|
+
// Apply .autopilot-ignore suppression rules
|
|
194
|
+
const ignoreRules = loadIgnoreRules(cwd);
|
|
195
|
+
if (ignoreRules.length > 0) {
|
|
196
|
+
const before = result.allFindings.length;
|
|
197
|
+
result.allFindings = applyIgnoreRules(result.allFindings, ignoreRules);
|
|
198
|
+
for (const phase of result.phases) {
|
|
199
|
+
phase.findings = applyIgnoreRules(phase.findings, ignoreRules);
|
|
200
|
+
}
|
|
201
|
+
const suppressed = before - result.allFindings.length;
|
|
202
|
+
if (suppressed > 0) {
|
|
203
|
+
console.log(fmt('dim', ` [run] ${suppressed} finding${suppressed !== 1 ? 's' : ''} suppressed by .autopilot-ignore`));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Delta mode: filter to only new findings vs last run's baseline, then persist
|
|
208
|
+
if (options.delta) {
|
|
209
|
+
const cached = loadCachedFindings(cwd);
|
|
210
|
+
const before = result.allFindings.length;
|
|
211
|
+
result.allFindings = filterNewFindings(result.allFindings, cached);
|
|
212
|
+
for (const phase of result.phases) {
|
|
213
|
+
phase.findings = filterNewFindings(phase.findings, cached);
|
|
214
|
+
}
|
|
215
|
+
const existing = before - result.allFindings.length;
|
|
216
|
+
if (existing > 0) {
|
|
217
|
+
console.log(fmt('dim', ` [run] ${existing} pre-existing finding${existing !== 1 ? 's' : ''} hidden (--delta mode)`));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// Always persist the unfiltered findings as the new baseline
|
|
221
|
+
saveCachedFindings(cwd, result.allFindings);
|
|
222
|
+
|
|
183
223
|
// emitAnnotations is a no-op unless GITHUB_ACTIONS=true
|
|
184
224
|
emitAnnotations(result.allFindings);
|
|
185
225
|
|
|
@@ -3,6 +3,7 @@ import * as path from 'node:path';
|
|
|
3
3
|
import type { ReviewEngine, ReviewInput } from '../../adapters/review-engine/types.ts';
|
|
4
4
|
import type { AutopilotConfig } from '../config/types.ts';
|
|
5
5
|
import { rankByRisk } from './risk-ranker.ts';
|
|
6
|
+
import { getFileDiffs, formatDiffContent } from '../git/diff-hunks.ts';
|
|
6
7
|
|
|
7
8
|
export interface ReviewChunk {
|
|
8
9
|
content: string;
|
|
@@ -12,11 +13,12 @@ export interface ReviewChunk {
|
|
|
12
13
|
|
|
13
14
|
export interface BuildChunksInput {
|
|
14
15
|
touchedFiles: string[];
|
|
15
|
-
strategy: 'auto' | 'single-pass' | 'file-level';
|
|
16
|
+
strategy: 'auto' | 'single-pass' | 'file-level' | 'diff';
|
|
16
17
|
chunking?: AutopilotConfig['chunking'];
|
|
17
18
|
engine: ReviewEngine;
|
|
18
19
|
cwd?: string;
|
|
19
20
|
protectedPaths?: string[];
|
|
21
|
+
base?: string; // git base ref — required for 'diff' strategy
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
const DEFAULT_SMALL_TIER_TOKENS = 8000;
|
|
@@ -26,6 +28,11 @@ export async function buildReviewChunks(input: BuildChunksInput): Promise<Review
|
|
|
26
28
|
const smallMax = input.chunking?.smallTierMaxTokens ?? DEFAULT_SMALL_TIER_TOKENS;
|
|
27
29
|
const fileMax = input.chunking?.perFileMaxTokens ?? DEFAULT_FILE_TIER_TOKENS;
|
|
28
30
|
|
|
31
|
+
// Diff strategy: send unified diff hunks instead of full file contents
|
|
32
|
+
if (input.strategy === 'diff') {
|
|
33
|
+
return buildDiffChunks(input);
|
|
34
|
+
}
|
|
35
|
+
|
|
29
36
|
const ranked = rankByRisk(input.touchedFiles, { protectedPaths: input.protectedPaths });
|
|
30
37
|
const fileContents = await readFiles(ranked, input.cwd);
|
|
31
38
|
|
|
@@ -51,6 +58,19 @@ export async function buildReviewChunks(input: BuildChunksInput): Promise<Review
|
|
|
51
58
|
return chunks;
|
|
52
59
|
}
|
|
53
60
|
|
|
61
|
+
function buildDiffChunks(input: BuildChunksInput): ReviewChunk[] {
|
|
62
|
+
const cwd = input.cwd ?? process.cwd();
|
|
63
|
+
const base = input.base ?? 'HEAD~1';
|
|
64
|
+
const ranked = rankByRisk(input.touchedFiles, { protectedPaths: input.protectedPaths });
|
|
65
|
+
const diffs = getFileDiffs(cwd, base, ranked);
|
|
66
|
+
|
|
67
|
+
if (diffs.length === 0) return [];
|
|
68
|
+
|
|
69
|
+
// Single chunk — diff content is already compact; truncation handled in formatDiffContent
|
|
70
|
+
const content = formatDiffContent(diffs);
|
|
71
|
+
return [{ content, kind: 'file-batch', files: diffs.map(d => d.file) }];
|
|
72
|
+
}
|
|
73
|
+
|
|
54
74
|
async function readFiles(touchedFiles: string[], cwd?: string): Promise<Map<string, string>> {
|
|
55
75
|
const result = new Map<string, string>();
|
|
56
76
|
for (const f of touchedFiles) {
|
package/src/core/config/types.ts
CHANGED
|
@@ -27,7 +27,7 @@ export interface AutopilotConfig {
|
|
|
27
27
|
maxCodexRetries?: number;
|
|
28
28
|
maxBugbotRounds?: number;
|
|
29
29
|
};
|
|
30
|
-
reviewStrategy?: 'auto' | 'single-pass' | 'file-level';
|
|
30
|
+
reviewStrategy?: 'auto' | 'single-pass' | 'file-level' | 'diff';
|
|
31
31
|
chunking?: {
|
|
32
32
|
smallTierMaxTokens?: number;
|
|
33
33
|
partialReviewTokens?: number;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { runSafe } from '../shell.ts';
|
|
2
|
+
|
|
3
|
+
export interface FileDiff {
|
|
4
|
+
file: string;
|
|
5
|
+
hunks: string; // unified diff content for this file (header + hunks)
|
|
6
|
+
additions: number;
|
|
7
|
+
deletions: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Returns per-file unified diffs for the given files between base and HEAD.
|
|
12
|
+
* Falls back to working-tree diff (unstaged) when base diff is empty for a file.
|
|
13
|
+
*/
|
|
14
|
+
export function getFileDiffs(cwd: string, base: string, files: string[]): FileDiff[] {
|
|
15
|
+
if (files.length === 0) return [];
|
|
16
|
+
|
|
17
|
+
// Get full diff in one shot — more efficient than per-file calls
|
|
18
|
+
const raw = runSafe('git', ['diff', base, 'HEAD', '--unified=3', '--', ...files], { cwd })
|
|
19
|
+
?? runSafe('git', ['diff', 'HEAD', '--unified=3', '--', ...files], { cwd })
|
|
20
|
+
?? '';
|
|
21
|
+
|
|
22
|
+
return parseUnifiedDiff(raw, files);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parses unified diff output into per-file FileDiff entries.
|
|
27
|
+
* Only returns files that actually have diff content.
|
|
28
|
+
*/
|
|
29
|
+
export function parseUnifiedDiff(raw: string, requestedFiles: string[]): FileDiff[] {
|
|
30
|
+
if (!raw.trim()) return [];
|
|
31
|
+
|
|
32
|
+
const results: FileDiff[] = [];
|
|
33
|
+
const sections = raw.split(/^(?=diff --git )/m).filter(Boolean);
|
|
34
|
+
|
|
35
|
+
const requested = new Set(requestedFiles.map(f => f.replace(/\\/g, '/')));
|
|
36
|
+
|
|
37
|
+
for (const section of sections) {
|
|
38
|
+
// Extract b/ filename from diff header: diff --git a/src/foo.ts b/src/foo.ts
|
|
39
|
+
const headerMatch = section.match(/^diff --git a\/.+ b\/(.+)$/m);
|
|
40
|
+
if (!headerMatch) continue;
|
|
41
|
+
const file = headerMatch[1]!.trim();
|
|
42
|
+
if (!requested.has(file)) continue;
|
|
43
|
+
|
|
44
|
+
// Strip the git binary/index header lines, keep hunk content
|
|
45
|
+
const hunkStart = section.indexOf('@@');
|
|
46
|
+
const hunks = hunkStart >= 0 ? section.slice(hunkStart) : '';
|
|
47
|
+
if (!hunks.trim()) continue;
|
|
48
|
+
|
|
49
|
+
let additions = 0;
|
|
50
|
+
let deletions = 0;
|
|
51
|
+
for (const line of hunks.split('\n')) {
|
|
52
|
+
if (line.startsWith('+') && !line.startsWith('+++')) additions++;
|
|
53
|
+
if (line.startsWith('-') && !line.startsWith('---')) deletions++;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
results.push({ file, hunks: hunks.trimEnd(), additions, deletions });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return results;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Formats FileDiff entries into a review-ready string.
|
|
64
|
+
* Total size is bounded by maxChars (default 120K chars ≈ 30K tokens).
|
|
65
|
+
*/
|
|
66
|
+
export function formatDiffContent(diffs: FileDiff[], maxChars = 120_000): string {
|
|
67
|
+
const parts: string[] = [];
|
|
68
|
+
let total = 0;
|
|
69
|
+
let skipped = 0;
|
|
70
|
+
|
|
71
|
+
for (const d of diffs) {
|
|
72
|
+
const section = `## ${d.file} (+${d.additions}/-${d.deletions})\n\`\`\`diff\n${d.hunks}\n\`\`\``;
|
|
73
|
+
if (total + section.length > maxChars) {
|
|
74
|
+
skipped++;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
parts.push(section);
|
|
78
|
+
total += section.length;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (skipped > 0) {
|
|
82
|
+
parts.push(`[${skipped} file${skipped !== 1 ? 's' : ''} omitted — diff exceeded size limit]`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return parts.join('\n\n');
|
|
86
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { minimatch } from 'minimatch';
|
|
4
|
+
import type { Finding } from '../findings/types.ts';
|
|
5
|
+
|
|
6
|
+
export interface IgnoreRule {
|
|
7
|
+
ruleId: string | '*'; // finding id prefix or '*' for any
|
|
8
|
+
pathGlob: string | null; // null = match all paths
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function loadIgnoreRules(cwd: string): IgnoreRule[] {
|
|
12
|
+
const filePath = path.join(cwd, '.autopilot-ignore');
|
|
13
|
+
if (!fs.existsSync(filePath)) return [];
|
|
14
|
+
|
|
15
|
+
const rules: IgnoreRule[] = [];
|
|
16
|
+
for (const raw of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
|
17
|
+
const line = raw.trim();
|
|
18
|
+
if (!line || line.startsWith('#')) continue;
|
|
19
|
+
|
|
20
|
+
const parts = line.split(/\s+/);
|
|
21
|
+
if (parts.length === 1) {
|
|
22
|
+
// bare glob — suppress any finding whose file matches
|
|
23
|
+
rules.push({ ruleId: '*', pathGlob: parts[0]! });
|
|
24
|
+
} else {
|
|
25
|
+
// <rule-id-or-*> <path-glob>
|
|
26
|
+
rules.push({ ruleId: parts[0]!, pathGlob: parts[1]! });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return rules;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function matchesRule(finding: Finding, rule: IgnoreRule): boolean {
|
|
33
|
+
const ruleMatches = rule.ruleId === '*' || finding.id.startsWith(rule.ruleId);
|
|
34
|
+
if (!ruleMatches) return false;
|
|
35
|
+
if (rule.pathGlob === null) return true;
|
|
36
|
+
return minimatch(finding.file.replace(/\\/g, '/'), rule.pathGlob, { matchBase: true });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function applyIgnoreRules(findings: Finding[], rules: IgnoreRule[]): Finding[] {
|
|
40
|
+
if (rules.length === 0) return findings;
|
|
41
|
+
return findings.filter(f => !rules.some(r => matchesRule(f, r)));
|
|
42
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import type { Finding } from '../findings/types.ts';
|
|
4
|
+
|
|
5
|
+
const CACHE_DIR = '.autopilot-cache';
|
|
6
|
+
const CACHE_FILE = 'findings.json';
|
|
7
|
+
|
|
8
|
+
function cacheFilePath(cwd: string): string {
|
|
9
|
+
return path.join(cwd, CACHE_DIR, CACHE_FILE);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function findingKey(f: Finding): string {
|
|
13
|
+
return `${f.id}::${f.file}::${f.line ?? ''}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function loadCachedFindings(cwd: string): Finding[] {
|
|
17
|
+
const p = cacheFilePath(cwd);
|
|
18
|
+
if (!fs.existsSync(p)) return [];
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(fs.readFileSync(p, 'utf8')) as Finding[];
|
|
21
|
+
} catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function saveCachedFindings(cwd: string, findings: Finding[]): void {
|
|
27
|
+
const dir = path.join(cwd, CACHE_DIR);
|
|
28
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
29
|
+
// atomic write
|
|
30
|
+
const tmp = cacheFilePath(cwd) + '.tmp';
|
|
31
|
+
fs.writeFileSync(tmp, JSON.stringify(findings, null, 2), 'utf8');
|
|
32
|
+
fs.renameSync(tmp, cacheFilePath(cwd));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Returns only findings not present in the cached baseline.
|
|
37
|
+
* Two findings are considered the same when id + file + line all match.
|
|
38
|
+
*/
|
|
39
|
+
export function filterNewFindings(current: Finding[], cached: Finding[]): Finding[] {
|
|
40
|
+
if (cached.length === 0) return current;
|
|
41
|
+
const seen = new Set(cached.map(findingKey));
|
|
42
|
+
return current.filter(f => !seen.has(findingKey(f)));
|
|
43
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ReviewEngine } from '../../adapters/review-engine/types.ts';
|
|
2
2
|
import type { Finding } from '../findings/types.ts';
|
|
3
3
|
import type { AutopilotConfig } from '../config/types.ts';
|
|
4
|
-
import { buildReviewChunks } from '../chunking/index.ts';
|
|
4
|
+
import { buildReviewChunks, type ReviewChunk } from '../chunking/index.ts';
|
|
5
5
|
|
|
6
6
|
export interface ReviewPhaseResult {
|
|
7
7
|
phase: 'review';
|
|
@@ -19,6 +19,49 @@ export interface ReviewPhaseInput {
|
|
|
19
19
|
cwd?: string;
|
|
20
20
|
gitSummary?: string;
|
|
21
21
|
budgetRemainingUSD?: number;
|
|
22
|
+
base?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ChunkResult {
|
|
26
|
+
findings: Finding[];
|
|
27
|
+
inputTokens: number;
|
|
28
|
+
outputTokens: number;
|
|
29
|
+
costUSD: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function reviewChunk(chunk: ReviewChunk, input: ReviewPhaseInput): Promise<ChunkResult> {
|
|
33
|
+
const output = await input.engine.review({
|
|
34
|
+
content: chunk.content,
|
|
35
|
+
kind: chunk.kind,
|
|
36
|
+
context: { stack: input.config.stack, cwd: input.cwd, gitSummary: input.gitSummary },
|
|
37
|
+
});
|
|
38
|
+
return {
|
|
39
|
+
findings: output.findings,
|
|
40
|
+
inputTokens: output.usage?.input ?? 0,
|
|
41
|
+
outputTokens: output.usage?.output ?? 0,
|
|
42
|
+
costUSD: output.usage?.costUSD ?? 0,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Run up to `limit` promises concurrently, preserving result order. */
|
|
47
|
+
async function pMap<T, R>(
|
|
48
|
+
items: T[],
|
|
49
|
+
fn: (item: T, index: number) => Promise<R>,
|
|
50
|
+
limit: number,
|
|
51
|
+
): Promise<R[]> {
|
|
52
|
+
const results: R[] = new Array(items.length);
|
|
53
|
+
let next = 0;
|
|
54
|
+
|
|
55
|
+
async function worker(): Promise<void> {
|
|
56
|
+
while (next < items.length) {
|
|
57
|
+
const i = next++;
|
|
58
|
+
results[i] = await fn(items[i]!, i);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker());
|
|
63
|
+
await Promise.all(workers);
|
|
64
|
+
return results;
|
|
22
65
|
}
|
|
23
66
|
|
|
24
67
|
export async function runReviewPhase(input: ReviewPhaseInput): Promise<ReviewPhaseResult> {
|
|
@@ -35,43 +78,54 @@ export async function runReviewPhase(input: ReviewPhaseInput): Promise<ReviewPha
|
|
|
35
78
|
engine: input.engine,
|
|
36
79
|
cwd: input.cwd,
|
|
37
80
|
protectedPaths: input.config.protectedPaths,
|
|
81
|
+
base: input.base,
|
|
38
82
|
});
|
|
39
83
|
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
let totalOutputTokens = 0;
|
|
43
|
-
let totalCostUSD = 0;
|
|
44
|
-
let budgetExceeded = false;
|
|
84
|
+
const parallelism = input.config.chunking?.parallelism ?? 3;
|
|
85
|
+
const budgetUSD = input.budgetRemainingUSD;
|
|
45
86
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
87
|
+
// For budget tracking we still need to enforce it — run serially if budget set,
|
|
88
|
+
// parallel otherwise (budget check between serial chunks is the safe path).
|
|
89
|
+
let chunkResults: ChunkResult[];
|
|
90
|
+
if (budgetUSD !== undefined) {
|
|
91
|
+
chunkResults = [];
|
|
92
|
+
let spent = 0;
|
|
93
|
+
let budgetExceeded = false;
|
|
94
|
+
for (const chunk of chunks) {
|
|
95
|
+
if (spent >= budgetUSD) { budgetExceeded = true; break; }
|
|
96
|
+
const r = await reviewChunk(chunk, input);
|
|
97
|
+
spent += r.costUSD;
|
|
98
|
+
chunkResults.push(r);
|
|
50
99
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
100
|
+
if (budgetExceeded) {
|
|
101
|
+
chunkResults.push({
|
|
102
|
+
findings: [{
|
|
103
|
+
id: 'budget-exceeded',
|
|
104
|
+
source: 'pipeline',
|
|
105
|
+
severity: 'warning',
|
|
106
|
+
category: 'budget',
|
|
107
|
+
file: '<pipeline>',
|
|
108
|
+
message: `Review budget of $${budgetUSD} USD exceeded — remaining chunks skipped`,
|
|
109
|
+
protectedPath: false,
|
|
110
|
+
createdAt: new Date().toISOString(),
|
|
111
|
+
}],
|
|
112
|
+
inputTokens: 0, outputTokens: 0, costUSD: 0,
|
|
113
|
+
});
|
|
61
114
|
}
|
|
115
|
+
} else {
|
|
116
|
+
chunkResults = await pMap(chunks, chunk => reviewChunk(chunk, input), parallelism);
|
|
62
117
|
}
|
|
63
118
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
});
|
|
119
|
+
let totalInputTokens = 0;
|
|
120
|
+
let totalOutputTokens = 0;
|
|
121
|
+
let totalCostUSD = 0;
|
|
122
|
+
const allFindings: Finding[] = [];
|
|
123
|
+
|
|
124
|
+
for (const r of chunkResults) {
|
|
125
|
+
allFindings.push(...r.findings);
|
|
126
|
+
totalInputTokens += r.inputTokens;
|
|
127
|
+
totalOutputTokens += r.outputTokens;
|
|
128
|
+
totalCostUSD += r.costUSD;
|
|
75
129
|
}
|
|
76
130
|
|
|
77
131
|
const hasCritical = allFindings.some(f => f.severity === 'critical');
|
package/src/core/pipeline/run.ts
CHANGED
|
@@ -18,6 +18,7 @@ export interface RunInput {
|
|
|
18
18
|
staticRules?: StaticRule[];
|
|
19
19
|
cwd?: string;
|
|
20
20
|
gitSummary?: string;
|
|
21
|
+
base?: string;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export interface RunResult {
|
|
@@ -62,6 +63,7 @@ export async function runAutopilot(input: RunInput): Promise<RunResult> {
|
|
|
62
63
|
cwd: input.cwd,
|
|
63
64
|
gitSummary: input.gitSummary,
|
|
64
65
|
budgetRemainingUSD: budgetUSD,
|
|
66
|
+
base: input.base,
|
|
65
67
|
});
|
|
66
68
|
phases.push(reviewResult);
|
|
67
69
|
if (reviewResult.costUSD !== undefined) {
|