@delegance/claude-autopilot 1.5.0 → 1.7.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 +43 -0
- package/README.md +33 -2
- package/package.json +1 -1
- package/src/adapters/review-engine/auto.ts +62 -30
- package/src/adapters/review-engine/claude.ts +3 -2
- package/src/adapters/review-engine/codex.ts +3 -2
- package/src/adapters/review-engine/gemini.ts +3 -2
- package/src/adapters/review-engine/openai-compatible.ts +3 -2
- package/src/adapters/review-engine/types.ts +1 -1
- package/src/cli/run.ts +19 -0
- package/src/core/detect/git-context.ts +27 -0
- package/src/core/detect/protected-paths.ts +63 -0
- package/src/core/detect/provider-usage.ts +74 -0
- package/src/core/detect/stack.ts +153 -0
- package/src/core/pipeline/review-phase.ts +2 -1
- package/src/core/pipeline/run.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.7.0] — 2026-04-22
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Stack auto-detection** (`src/core/detect/stack.ts`) — infers human-readable stack string from `package.json`, `go.mod`, `Cargo.toml`, `requirements.txt`, `Gemfile`; detects framework, ORM, auth, UI library, language; injected into review prompt automatically when `stack:` is absent from config
|
|
7
|
+
- **Protected-paths auto-detection** (`src/core/detect/protected-paths.ts`) — scans for migration dirs (`data/deltas/`, `migrations/`, `db/migrate/`, `prisma/migrations/`, `alembic/versions/`, `flyway/`), schema files (`schema.prisma`, `schema.sql`, `db/schema.rb`), infra dirs (`terraform/`, `k8s/`, `helm/`, `.github/workflows/`); populates `protectedPaths` when not set in config
|
|
8
|
+
- **Test-command runtime fallback** — re-runs project detector at `run` time when `testCommand` is absent from config; `null` still disables the test phase explicitly
|
|
9
|
+
- **Git context enrichment** (`src/core/detect/git-context.ts`) — injects branch name and last commit message into the review prompt as `Change context: branch: feat/x | last commit: add user auth` so the LLM understands intent
|
|
10
|
+
- `ReviewInput.context.gitSummary` — new context field; all five adapters (claude, gemini, codex, openai-compatible, auto) inject it when present
|
|
11
|
+
- 18 new tests (9 stack + 9 protected-paths) — **199 total**
|
|
12
|
+
|
|
13
|
+
## [1.6.0] — 2026-04-22
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **Provider usage scanner** (`src/core/detect/provider-usage.ts`) — walks project source files, counts per-provider API key and SDK references (capped at 1 per file to avoid skew), returns `ProviderCounts`
|
|
17
|
+
- **`dominantProvider()`** — returns the provider with the highest file-reference count
|
|
18
|
+
- **Smart `auto` tiebreaker** — when multiple API keys are present, `auto` scans the codebase and prefers the provider already used there; falls back to env-key priority order if counts are all zero
|
|
19
|
+
- `ReviewInput.context.cwd` — threads working directory through to the review engine so `auto` knows where to scan; `review-phase.ts` now passes `cwd` in context
|
|
20
|
+
- 12 new tests for `detectProviderUsage` and `dominantProvider` — **181 total**
|
|
21
|
+
|
|
22
|
+
## [1.5.0] — 2026-04-22
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- **Gemini adapter** (`gemini`) — Google Gemini 2.5 Pro via `@google/generative-ai`; accepts `GEMINI_API_KEY` or `GOOGLE_API_KEY`; 1M token context window
|
|
26
|
+
- **OpenAI-compatible adapter** (`openai-compatible`) — works with any OpenAI-API-compatible endpoint (Groq, Ollama, Together AI, etc.); requires `options.model`; auto-selects API key via `options.apiKeyEnv` → `OPENAI_API_KEY` → `'ollama'`
|
|
27
|
+
- **Updated auto adapter** — full priority chain: `ANTHROPIC_API_KEY` → `GEMINI_API_KEY`/`GOOGLE_API_KEY` → `OPENAI_API_KEY` → `GROQ_API_KEY` (wraps openai-compatible with Groq config)
|
|
28
|
+
- `run.ts` no-key warning now lists all four key options
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
- 169 tests total (up from 136)
|
|
32
|
+
|
|
33
|
+
## [1.4.0] — 2026-04-21
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
- **Static rules registry** (`src/core/static-rules/registry.ts`) — lazy-loads built-in rules by name; fixes critical bug where config `staticRules` was always silently ignored
|
|
37
|
+
- **7 built-in rules**: `hardcoded-secrets`, `npm-audit`, `package-lock-sync`, `console-log`, `todo-fixme`, `large-file`, `missing-tests`
|
|
38
|
+
- **Claude adapter** (`claude`) — Anthropic Claude Opus 4.7 via `@anthropic-ai/sdk`; configurable model via `context.model`
|
|
39
|
+
- **Auto adapter** (`auto`) — detects best available key at runtime; checked in priority order
|
|
40
|
+
- `doctor` now checks `ANTHROPIC_API_KEY` in addition to `OPENAI_API_KEY`
|
|
41
|
+
- 136 tests total
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
- **Critical**: `staticRules` in `RunInput` was never populated — config-listed rules were silently ignored. `loadRulesFromConfig()` now wired into `run.ts`
|
|
45
|
+
|
|
3
46
|
## [1.2.8] — 2026-04-21
|
|
4
47
|
|
|
5
48
|
### Added
|
package/README.md
CHANGED
|
@@ -118,7 +118,7 @@ Presets: `nextjs-supabase`, `t3`, `python-fastapi`, `rails-postgres`, `go`.
|
|
|
118
118
|
```yaml
|
|
119
119
|
configVersion: 1
|
|
120
120
|
reviewEngine:
|
|
121
|
-
adapter:
|
|
121
|
+
adapter: auto # auto-detects best available key at runtime
|
|
122
122
|
testCommand: npm test
|
|
123
123
|
protectedPaths:
|
|
124
124
|
- src/core/**
|
|
@@ -130,6 +130,37 @@ staticRules:
|
|
|
130
130
|
|
|
131
131
|
Full schema and preset defaults: `presets/<name>/autopilot.config.yaml`.
|
|
132
132
|
|
|
133
|
+
### Review Engine Adapters
|
|
134
|
+
|
|
135
|
+
| Adapter | Key required | Notes |
|
|
136
|
+
|---|---|---|
|
|
137
|
+
| `auto` | any below | Auto-selects best available (recommended) |
|
|
138
|
+
| `claude` | `ANTHROPIC_API_KEY` | Opus 4.7 default |
|
|
139
|
+
| `gemini` | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | Gemini 2.5 Pro, 1M context |
|
|
140
|
+
| `codex` | `OPENAI_API_KEY` | GPT-5 Codex |
|
|
141
|
+
| `openai-compatible` | configurable | Groq, Ollama, Together AI, etc. |
|
|
142
|
+
|
|
143
|
+
`auto` priority: Anthropic → Gemini → OpenAI → Groq.
|
|
144
|
+
|
|
145
|
+
**Groq example:**
|
|
146
|
+
```yaml
|
|
147
|
+
reviewEngine:
|
|
148
|
+
adapter: openai-compatible
|
|
149
|
+
options:
|
|
150
|
+
model: llama-3.3-70b-versatile
|
|
151
|
+
baseUrl: https://api.groq.com/openai/v1
|
|
152
|
+
apiKeyEnv: GROQ_API_KEY
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**Ollama (local, no key):**
|
|
156
|
+
```yaml
|
|
157
|
+
reviewEngine:
|
|
158
|
+
adapter: openai-compatible
|
|
159
|
+
options:
|
|
160
|
+
model: llama3.2
|
|
161
|
+
baseUrl: http://localhost:11434/v1
|
|
162
|
+
```
|
|
163
|
+
|
|
133
164
|
## GitHub Actions
|
|
134
165
|
|
|
135
166
|
```yaml
|
|
@@ -175,7 +206,7 @@ Four pluggable adapter points:
|
|
|
175
206
|
|
|
176
207
|
| Point | Built-in | Purpose |
|
|
177
208
|
|---|---|---|
|
|
178
|
-
| `review-engine` | `codex` | LLM code review
|
|
209
|
+
| `review-engine` | `auto`, `claude`, `gemini`, `codex`, `openai-compatible` | LLM code review |
|
|
179
210
|
| `vcs-host` | `github` | PR comments + SARIF upload |
|
|
180
211
|
| `migration-runner` | `supabase` | DB migration execution |
|
|
181
212
|
| `review-bot-parser` | `cursor` | Parse review bot comments |
|
package/package.json
CHANGED
|
@@ -1,44 +1,74 @@
|
|
|
1
1
|
import type { Capabilities } from '../base.ts';
|
|
2
2
|
import type { ReviewEngine, ReviewInput, ReviewOutput } from './types.ts';
|
|
3
3
|
import { AutopilotError } from '../../core/errors.ts';
|
|
4
|
+
import { detectProviderUsage, dominantProvider, type Provider } from '../../core/detect/provider-usage.ts';
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
interface AvailableProvider {
|
|
7
|
+
provider: Provider;
|
|
8
|
+
load: () => Promise<ReviewEngine>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function buildGroqAdapter(base: ReviewEngine): ReviewEngine {
|
|
12
|
+
return {
|
|
13
|
+
...base,
|
|
14
|
+
name: 'auto',
|
|
15
|
+
review(input: ReviewInput) {
|
|
16
|
+
return base.review({
|
|
17
|
+
...input,
|
|
18
|
+
context: {
|
|
19
|
+
...input.context,
|
|
20
|
+
model: 'llama-3.3-70b-versatile',
|
|
21
|
+
baseUrl: 'https://api.groq.com/openai/v1',
|
|
22
|
+
apiKeyEnv: 'GROQ_API_KEY',
|
|
23
|
+
} as typeof input.context,
|
|
24
|
+
});
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getAvailableProviders(): AvailableProvider[] {
|
|
30
|
+
const available: AvailableProvider[] = [];
|
|
7
31
|
if (process.env.ANTHROPIC_API_KEY) {
|
|
8
|
-
|
|
9
|
-
return claudeAdapter;
|
|
32
|
+
available.push({ provider: 'anthropic', load: async () => (await import('./claude.ts')).claudeAdapter });
|
|
10
33
|
}
|
|
11
34
|
if (process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY) {
|
|
12
|
-
|
|
13
|
-
return geminiAdapter;
|
|
35
|
+
available.push({ provider: 'gemini', load: async () => (await import('./gemini.ts')).geminiAdapter });
|
|
14
36
|
}
|
|
15
37
|
if (process.env.OPENAI_API_KEY) {
|
|
16
|
-
|
|
17
|
-
return codexAdapter;
|
|
38
|
+
available.push({ provider: 'openai', load: async () => (await import('./codex.ts')).codexAdapter });
|
|
18
39
|
}
|
|
19
40
|
if (process.env.GROQ_API_KEY) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
available.push({
|
|
42
|
+
provider: 'groq',
|
|
43
|
+
load: async () => buildGroqAdapter((await import('./openai-compatible.ts')).openaiCompatibleAdapter),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return available;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function resolveAdapter(cwd: string): Promise<ReviewEngine> {
|
|
50
|
+
const available = getAvailableProviders();
|
|
51
|
+
|
|
52
|
+
if (available.length === 0) {
|
|
53
|
+
throw new AutopilotError(
|
|
54
|
+
'No LLM API key found. Set one of: ANTHROPIC_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, GROQ_API_KEY',
|
|
55
|
+
{ code: 'auth', provider: 'auto' },
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Single provider — no need to scan
|
|
60
|
+
if (available.length === 1) return available[0]!.load();
|
|
61
|
+
|
|
62
|
+
// Multiple keys present — prefer the provider most referenced in source code
|
|
63
|
+
const counts = detectProviderUsage(cwd);
|
|
64
|
+
const dominant = dominantProvider(counts);
|
|
65
|
+
if (dominant) {
|
|
66
|
+
const match = available.find(p => p.provider === dominant);
|
|
67
|
+
if (match) return match.load();
|
|
37
68
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
);
|
|
69
|
+
|
|
70
|
+
// Fallback to first available (env-key priority order)
|
|
71
|
+
return available[0]!.load();
|
|
42
72
|
}
|
|
43
73
|
|
|
44
74
|
export const autoAdapter: ReviewEngine = {
|
|
@@ -54,7 +84,9 @@ export const autoAdapter: ReviewEngine = {
|
|
|
54
84
|
},
|
|
55
85
|
|
|
56
86
|
async review(input: ReviewInput): Promise<ReviewOutput> {
|
|
57
|
-
const
|
|
87
|
+
const cwd = (input.context as Record<string, unknown> | undefined)?.['cwd'] as string | undefined
|
|
88
|
+
?? process.cwd();
|
|
89
|
+
const adapter = await resolveAdapter(cwd);
|
|
58
90
|
return adapter.review(input);
|
|
59
91
|
},
|
|
60
92
|
};
|
|
@@ -14,7 +14,7 @@ const COST_PER_M_OUTPUT = 75.0;
|
|
|
14
14
|
const SYSTEM_PROMPT_TEMPLATE = `You are a senior software architect reviewing code changes for quality, security, and correctness.
|
|
15
15
|
|
|
16
16
|
The codebase context:
|
|
17
|
-
{STACK}
|
|
17
|
+
{STACK}{GIT_CONTEXT}
|
|
18
18
|
|
|
19
19
|
Provide structured feedback in exactly this format:
|
|
20
20
|
|
|
@@ -56,7 +56,8 @@ export const claudeAdapter: ReviewEngine = {
|
|
|
56
56
|
|
|
57
57
|
const model = (input.context as Record<string, unknown> | undefined)?.['model'] as string | undefined ?? DEFAULT_MODEL;
|
|
58
58
|
const stack = input.context?.stack ?? 'A web application — stack details unspecified.';
|
|
59
|
-
const
|
|
59
|
+
const gitCtx = input.context?.gitSummary ? `\n\nChange context: ${input.context.gitSummary}` : '';
|
|
60
|
+
const systemPrompt = SYSTEM_PROMPT_TEMPLATE.replace('{STACK}', stack).replace('{GIT_CONTEXT}', gitCtx);
|
|
60
61
|
|
|
61
62
|
const client = new Anthropic({ apiKey });
|
|
62
63
|
let response: Anthropic.Message;
|
|
@@ -10,7 +10,7 @@ const MAX_OUTPUT_TOKENS = 4096;
|
|
|
10
10
|
const SYSTEM_PROMPT_TEMPLATE = `You are a senior software architect providing feedback on designs, proposals, and ideas.
|
|
11
11
|
|
|
12
12
|
The codebase context:
|
|
13
|
-
{STACK}
|
|
13
|
+
{STACK}{GIT_CONTEXT}
|
|
14
14
|
|
|
15
15
|
Provide structured feedback in exactly this format:
|
|
16
16
|
|
|
@@ -49,7 +49,8 @@ export const codexAdapter: ReviewEngine = {
|
|
|
49
49
|
throw new AutopilotError('OPENAI_API_KEY not set', { code: 'auth', provider: 'codex' });
|
|
50
50
|
}
|
|
51
51
|
const stack = input.context?.stack ?? 'A web application — stack details unspecified.';
|
|
52
|
-
const
|
|
52
|
+
const gitCtx = input.context?.gitSummary ? `\n\nChange context: ${input.context.gitSummary}` : '';
|
|
53
|
+
const systemPrompt = SYSTEM_PROMPT_TEMPLATE.replace('{STACK}', stack).replace('{GIT_CONTEXT}', gitCtx);
|
|
53
54
|
|
|
54
55
|
const client = new OpenAI({ apiKey });
|
|
55
56
|
let response;
|
|
@@ -14,7 +14,7 @@ const COST_PER_M_OUTPUT = 10.0;
|
|
|
14
14
|
const PROMPT_TEMPLATE = `You are a senior software architect reviewing code changes for quality, security, and correctness.
|
|
15
15
|
|
|
16
16
|
The codebase context:
|
|
17
|
-
{STACK}
|
|
17
|
+
{STACK}{GIT_CONTEXT}
|
|
18
18
|
|
|
19
19
|
Please review the following:
|
|
20
20
|
|
|
@@ -64,7 +64,8 @@ export const geminiAdapter: ReviewEngine = {
|
|
|
64
64
|
|
|
65
65
|
const model = (input.context as Record<string, unknown> | undefined)?.['model'] as string | undefined ?? DEFAULT_MODEL;
|
|
66
66
|
const stack = input.context?.stack ?? 'A web application — stack details unspecified.';
|
|
67
|
-
const
|
|
67
|
+
const gitCtx = input.context?.gitSummary ? `\n\nChange context: ${input.context.gitSummary}` : '';
|
|
68
|
+
const prompt = PROMPT_TEMPLATE.replace('{STACK}', stack).replace('{GIT_CONTEXT}', gitCtx).replace('{CONTENT}', input.content);
|
|
68
69
|
|
|
69
70
|
const genAI = new GoogleGenerativeAI(apiKey);
|
|
70
71
|
const genModel = genAI.getGenerativeModel({
|
|
@@ -9,7 +9,7 @@ const MAX_OUTPUT_TOKENS = 4096;
|
|
|
9
9
|
const SYSTEM_PROMPT_TEMPLATE = `You are a senior software architect reviewing code changes for quality, security, and correctness.
|
|
10
10
|
|
|
11
11
|
The codebase context:
|
|
12
|
-
{STACK}
|
|
12
|
+
{STACK}{GIT_CONTEXT}
|
|
13
13
|
|
|
14
14
|
Provide structured feedback in exactly this format:
|
|
15
15
|
|
|
@@ -63,7 +63,8 @@ export const openaiCompatibleAdapter: ReviewEngine = {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
const stack = input.context?.stack ?? 'A web application — stack details unspecified.';
|
|
66
|
-
const
|
|
66
|
+
const gitCtx = input.context?.gitSummary ? `\n\nChange context: ${input.context.gitSummary}` : '';
|
|
67
|
+
const systemPrompt = SYSTEM_PROMPT_TEMPLATE.replace('{STACK}', stack).replace('{GIT_CONTEXT}', gitCtx);
|
|
67
68
|
const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) });
|
|
68
69
|
|
|
69
70
|
let response: OpenAI.Chat.ChatCompletion;
|
|
@@ -4,7 +4,7 @@ import type { Finding } from '../../core/findings/types.ts';
|
|
|
4
4
|
export interface ReviewInput {
|
|
5
5
|
content: string;
|
|
6
6
|
kind: 'spec' | 'pr-diff' | 'file-batch';
|
|
7
|
-
context?: { spec?: string; plan?: string; stack?: string };
|
|
7
|
+
context?: { spec?: string; plan?: string; stack?: string; cwd?: string; gitSummary?: string };
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export interface ReviewOutput {
|
package/src/cli/run.ts
CHANGED
|
@@ -33,6 +33,10 @@ import type { AutopilotConfig } from '../core/config/types.ts';
|
|
|
33
33
|
import { fileURLToPath } from 'node:url';
|
|
34
34
|
import { toSarif } from '../formatters/sarif.ts';
|
|
35
35
|
import { emitAnnotations } from '../formatters/github-annotations.ts';
|
|
36
|
+
import { detectStack } from '../core/detect/stack.ts';
|
|
37
|
+
import { detectProtectedPaths } from '../core/detect/protected-paths.ts';
|
|
38
|
+
import { detectGitContext } from '../core/detect/git-context.ts';
|
|
39
|
+
import { detectProject } from './detector.ts';
|
|
36
40
|
|
|
37
41
|
function readToolVersion(): string {
|
|
38
42
|
const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../package.json');
|
|
@@ -92,6 +96,20 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
|
|
|
92
96
|
return 1;
|
|
93
97
|
}
|
|
94
98
|
|
|
99
|
+
// Fill in missing config fields from auto-detection
|
|
100
|
+
if (!config.stack) {
|
|
101
|
+
const detected = detectStack(cwd);
|
|
102
|
+
if (detected) config = { ...config, stack: detected };
|
|
103
|
+
}
|
|
104
|
+
if (!config.protectedPaths || config.protectedPaths.length === 0) {
|
|
105
|
+
const detected = detectProtectedPaths(cwd);
|
|
106
|
+
if (detected.length > 0) config = { ...config, protectedPaths: detected };
|
|
107
|
+
}
|
|
108
|
+
if (config.testCommand === undefined) {
|
|
109
|
+
config = { ...config, testCommand: detectProject(cwd).testCommand };
|
|
110
|
+
}
|
|
111
|
+
const gitCtx = detectGitContext(cwd);
|
|
112
|
+
|
|
95
113
|
// Resolve touched files
|
|
96
114
|
const touchedFiles = options.files ?? resolveGitTouchedFiles({ cwd, base: options.base });
|
|
97
115
|
if (touchedFiles.length === 0) {
|
|
@@ -141,6 +159,7 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
|
|
|
141
159
|
reviewEngine,
|
|
142
160
|
staticRules,
|
|
143
161
|
cwd,
|
|
162
|
+
gitSummary: gitCtx.summary ?? undefined,
|
|
144
163
|
};
|
|
145
164
|
|
|
146
165
|
console.log('');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { runSafe } from '../shell.ts';
|
|
2
|
+
|
|
3
|
+
export interface GitContext {
|
|
4
|
+
branch: string | null;
|
|
5
|
+
commitMessage: string | null;
|
|
6
|
+
/** Short summary suitable for injecting into a review prompt */
|
|
7
|
+
summary: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Reads branch name and last commit message from git. Returns nulls gracefully
|
|
12
|
+
* if git is unavailable or the repo has no commits.
|
|
13
|
+
*/
|
|
14
|
+
export function detectGitContext(cwd: string): GitContext {
|
|
15
|
+
const branch = runSafe('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'])?.trim() ?? null;
|
|
16
|
+
const commitMessage = runSafe('git', ['-C', cwd, 'log', '-1', '--format=%s'])?.trim() ?? null;
|
|
17
|
+
|
|
18
|
+
let summary: string | null = null;
|
|
19
|
+
if (branch || commitMessage) {
|
|
20
|
+
const parts: string[] = [];
|
|
21
|
+
if (branch && branch !== 'HEAD') parts.push(`branch: ${branch}`);
|
|
22
|
+
if (commitMessage) parts.push(`last commit: ${commitMessage}`);
|
|
23
|
+
summary = parts.join(' | ');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return { branch, commitMessage, summary };
|
|
27
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
|
|
4
|
+
interface MigrationSignal {
|
|
5
|
+
glob: string;
|
|
6
|
+
check: (cwd: string) => boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const MIGRATION_SIGNALS: MigrationSignal[] = [
|
|
10
|
+
{ glob: 'data/deltas/**', check: c => fs.existsSync(path.join(c, 'data', 'deltas')) },
|
|
11
|
+
{ glob: 'migrations/**', check: c => fs.existsSync(path.join(c, 'migrations')) },
|
|
12
|
+
{ glob: 'db/migrate/**', check: c => fs.existsSync(path.join(c, 'db', 'migrate')) },
|
|
13
|
+
{ glob: 'database/migrations/**', check: c => fs.existsSync(path.join(c, 'database', 'migrations')) },
|
|
14
|
+
{ glob: 'prisma/migrations/**', check: c => fs.existsSync(path.join(c, 'prisma', 'migrations')) },
|
|
15
|
+
{ glob: 'alembic/versions/**', check: c => fs.existsSync(path.join(c, 'alembic', 'versions')) },
|
|
16
|
+
{ glob: 'flyway/**', check: c => fs.existsSync(path.join(c, 'flyway')) },
|
|
17
|
+
// *.sql is handled below via readdirSync
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const SCHEMA_FILES = [
|
|
21
|
+
'prisma/schema.prisma',
|
|
22
|
+
'schema.prisma',
|
|
23
|
+
'schema.sql',
|
|
24
|
+
'db/schema.rb',
|
|
25
|
+
'config/schema.xml',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const INFRA_SIGNALS: Array<{ glob: string; check: (cwd: string) => boolean }> = [
|
|
29
|
+
{ glob: 'terraform/**', check: c => fs.existsSync(path.join(c, 'terraform')) },
|
|
30
|
+
{ glob: 'infra/**', check: c => fs.existsSync(path.join(c, 'infra')) },
|
|
31
|
+
{ glob: '.github/workflows/**', check: c => fs.existsSync(path.join(c, '.github', 'workflows')) },
|
|
32
|
+
{ glob: 'k8s/**', check: c => fs.existsSync(path.join(c, 'k8s')) },
|
|
33
|
+
{ glob: 'helm/**', check: c => fs.existsSync(path.join(c, 'helm')) },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Scans the project for migration directories, schema files, and infra configs
|
|
38
|
+
* and returns glob patterns suitable for `protectedPaths`.
|
|
39
|
+
*/
|
|
40
|
+
export function detectProtectedPaths(cwd: string): string[] {
|
|
41
|
+
const found = new Set<string>();
|
|
42
|
+
|
|
43
|
+
for (const sig of MIGRATION_SIGNALS) {
|
|
44
|
+
if (sig.check(cwd)) found.add(sig.glob);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Root-level .sql files
|
|
48
|
+
try {
|
|
49
|
+
if (fs.readdirSync(cwd).some(f => f.endsWith('.sql'))) found.add('*.sql');
|
|
50
|
+
} catch { /* ignore */ }
|
|
51
|
+
|
|
52
|
+
for (const rel of SCHEMA_FILES) {
|
|
53
|
+
if (fs.existsSync(path.join(cwd, rel))) {
|
|
54
|
+
found.add(rel.includes('/') ? rel.split('/')[0] + '/**' : rel);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const sig of INFRA_SIGNALS) {
|
|
59
|
+
if (sig.check(cwd)) found.add(sig.glob);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return Array.from(found).sort();
|
|
63
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export type Provider = 'anthropic' | 'gemini' | 'openai' | 'groq';
|
|
5
|
+
|
|
6
|
+
const PROVIDER_PATTERNS: Record<Provider, RegExp> = {
|
|
7
|
+
anthropic: /ANTHROPIC_API_KEY|@anthropic-ai\/sdk|anthropic\.com|claude-[a-z0-9]/gi,
|
|
8
|
+
gemini: /GEMINI_API_KEY|GOOGLE_API_KEY|@google\/generative-ai|generativelanguage\.googleapis/gi,
|
|
9
|
+
openai: /OPENAI_API_KEY|openai\.com|gpt-[0-9]/gi,
|
|
10
|
+
groq: /GROQ_API_KEY|api\.groq\.com/gi,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rb']);
|
|
14
|
+
|
|
15
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.nuxt', 'out',
|
|
16
|
+
'coverage', '__pycache__', '.venv', 'venv', 'target', '.gradle', '.cache', '.turbo']);
|
|
17
|
+
|
|
18
|
+
function walkSync(dir: string, files: string[] = []): string[] {
|
|
19
|
+
let entries: fs.Dirent[];
|
|
20
|
+
try {
|
|
21
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
22
|
+
} catch {
|
|
23
|
+
return files;
|
|
24
|
+
}
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
27
|
+
const full = path.join(dir, entry.name);
|
|
28
|
+
if (entry.isDirectory()) {
|
|
29
|
+
walkSync(full, files);
|
|
30
|
+
} else if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
|
|
31
|
+
files.push(full);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return files;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ProviderCounts {
|
|
38
|
+
anthropic: number;
|
|
39
|
+
gemini: number;
|
|
40
|
+
openai: number;
|
|
41
|
+
groq: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Scans source files under `cwd` and returns per-provider match counts.
|
|
46
|
+
* Counts are capped at 1 per file to avoid skewing on generated lock files.
|
|
47
|
+
*/
|
|
48
|
+
export function detectProviderUsage(cwd: string): ProviderCounts {
|
|
49
|
+
const counts: ProviderCounts = { anthropic: 0, gemini: 0, openai: 0, groq: 0 };
|
|
50
|
+
const files = walkSync(cwd);
|
|
51
|
+
for (const file of files) {
|
|
52
|
+
let content: string;
|
|
53
|
+
try {
|
|
54
|
+
content = fs.readFileSync(file, 'utf8');
|
|
55
|
+
} catch {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
for (const [provider, pattern] of Object.entries(PROVIDER_PATTERNS) as [Provider, RegExp][]) {
|
|
59
|
+
pattern.lastIndex = 0;
|
|
60
|
+
if (pattern.test(content)) counts[provider]++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return counts;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Returns the provider with the highest usage count, or null if all zero.
|
|
68
|
+
*/
|
|
69
|
+
export function dominantProvider(counts: ProviderCounts): Provider | null {
|
|
70
|
+
const entries = Object.entries(counts) as [Provider, number][];
|
|
71
|
+
const max = Math.max(...entries.map(([, v]) => v));
|
|
72
|
+
if (max === 0) return null;
|
|
73
|
+
return entries.find(([, v]) => v === max)![0];
|
|
74
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
|
|
4
|
+
function readJson(p: string): Record<string, unknown> | null {
|
|
5
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function fileContains(p: string, needle: string): boolean {
|
|
9
|
+
try { return fs.readFileSync(p, 'utf8').includes(needle); } catch { return false; }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function readFile(p: string): string {
|
|
13
|
+
try { return fs.readFileSync(p, 'utf8'); } catch { return ''; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function version(deps: Record<string, string>, name: string): string | null {
|
|
17
|
+
const v = deps[name];
|
|
18
|
+
if (!v) return null;
|
|
19
|
+
return v.replace(/^[\^~>=<\s]+/, '').split('.')[0] ?? null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Infers a human-readable stack description from project files.
|
|
24
|
+
* Returns null if nothing definitive is found (caller should omit from prompt).
|
|
25
|
+
*/
|
|
26
|
+
export function detectStack(cwd: string): string | null {
|
|
27
|
+
// Go
|
|
28
|
+
const goMod = path.join(cwd, 'go.mod');
|
|
29
|
+
if (fs.existsSync(goMod)) {
|
|
30
|
+
const content = readFile(goMod);
|
|
31
|
+
const parts = ['Go'];
|
|
32
|
+
if (content.includes('gin-gonic/gin')) parts.push('Gin');
|
|
33
|
+
else if (content.includes('labstack/echo')) parts.push('Echo');
|
|
34
|
+
else if (content.includes('gofiber/fiber')) parts.push('Fiber');
|
|
35
|
+
else if (content.includes('go-chi/chi')) parts.push('Chi');
|
|
36
|
+
if (content.includes('database/sql') || content.includes('sqlx') || content.includes('pgx')) parts.push('PostgreSQL');
|
|
37
|
+
if (content.includes('gorm.io')) parts.push('GORM');
|
|
38
|
+
if (content.includes('redis')) parts.push('Redis');
|
|
39
|
+
return parts.join(' + ');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Rust
|
|
43
|
+
const cargoToml = path.join(cwd, 'Cargo.toml');
|
|
44
|
+
if (fs.existsSync(cargoToml)) {
|
|
45
|
+
const content = readFile(cargoToml);
|
|
46
|
+
const parts = ['Rust'];
|
|
47
|
+
if (content.includes('actix-web')) parts.push('Actix-Web');
|
|
48
|
+
else if (content.includes('axum')) parts.push('Axum');
|
|
49
|
+
else if (content.includes('warp')) parts.push('Warp');
|
|
50
|
+
if (content.includes('sqlx') || content.includes('diesel')) parts.push('PostgreSQL');
|
|
51
|
+
if (content.includes('serde')) parts.push('Serde');
|
|
52
|
+
if (content.includes('tokio')) parts.push('Tokio async');
|
|
53
|
+
return parts.join(' + ');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Ruby / Rails
|
|
57
|
+
const gemfile = path.join(cwd, 'Gemfile');
|
|
58
|
+
if (fs.existsSync(gemfile)) {
|
|
59
|
+
const content = readFile(gemfile);
|
|
60
|
+
const parts: string[] = [];
|
|
61
|
+
if (content.includes("'rails'") || content.includes('"rails"')) parts.push('Ruby on Rails');
|
|
62
|
+
else if (content.includes("'sinatra'") || content.includes('"sinatra"')) parts.push('Sinatra');
|
|
63
|
+
else parts.push('Ruby');
|
|
64
|
+
if (content.includes('pg') || content.includes('postgresql')) parts.push('PostgreSQL');
|
|
65
|
+
else if (content.includes('mysql')) parts.push('MySQL');
|
|
66
|
+
else if (content.includes('sqlite')) parts.push('SQLite');
|
|
67
|
+
if (content.includes('rspec')) parts.push('RSpec');
|
|
68
|
+
if (content.includes('sidekiq')) parts.push('Sidekiq');
|
|
69
|
+
return parts.join(' + ');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Python
|
|
73
|
+
const reqTxt = path.join(cwd, 'requirements.txt');
|
|
74
|
+
const pyproject = path.join(cwd, 'pyproject.toml');
|
|
75
|
+
const hasFastapi = fileContains(reqTxt, 'fastapi') || fileContains(pyproject, 'fastapi');
|
|
76
|
+
const hasDjango = fileContains(reqTxt, 'django') || fileContains(pyproject, 'django');
|
|
77
|
+
const hasFlask = fileContains(reqTxt, 'flask') || fileContains(pyproject, 'flask');
|
|
78
|
+
if (hasFastapi || hasDjango || hasFlask || fs.existsSync(reqTxt) || fs.existsSync(pyproject)) {
|
|
79
|
+
const parts: string[] = [];
|
|
80
|
+
if (hasFastapi) parts.push('FastAPI');
|
|
81
|
+
else if (hasDjango) parts.push('Django');
|
|
82
|
+
else if (hasFlask) parts.push('Flask');
|
|
83
|
+
else parts.push('Python');
|
|
84
|
+
const combined = readFile(reqTxt) + readFile(pyproject);
|
|
85
|
+
if (combined.includes('sqlalchemy') || combined.includes('SQLAlchemy')) parts.push('SQLAlchemy');
|
|
86
|
+
if (combined.includes('postgresql') || combined.includes('psycopg')) parts.push('PostgreSQL');
|
|
87
|
+
if (combined.includes('pydantic')) parts.push('Pydantic');
|
|
88
|
+
if (combined.includes('celery')) parts.push('Celery');
|
|
89
|
+
return parts.join(' + ');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Node / JS / TS
|
|
93
|
+
const pkgPath = path.join(cwd, 'package.json');
|
|
94
|
+
if (!fs.existsSync(pkgPath)) return null;
|
|
95
|
+
const pkg = readJson(pkgPath);
|
|
96
|
+
if (!pkg) return null;
|
|
97
|
+
|
|
98
|
+
const deps: Record<string, string> = {
|
|
99
|
+
...(pkg['dependencies'] as Record<string, string> ?? {}),
|
|
100
|
+
...(pkg['devDependencies'] as Record<string, string> ?? {}),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const parts: string[] = [];
|
|
104
|
+
const isTs = 'typescript' in deps || fs.existsSync(path.join(cwd, 'tsconfig.json'));
|
|
105
|
+
|
|
106
|
+
// Framework
|
|
107
|
+
if ('next' in deps) {
|
|
108
|
+
const v = version(deps, 'next');
|
|
109
|
+
parts.push(v ? `Next.js ${v}` : 'Next.js');
|
|
110
|
+
} else if ('nuxt' in deps || 'nuxt3' in deps) {
|
|
111
|
+
parts.push('Nuxt');
|
|
112
|
+
} else if ('remix' in deps || '@remix-run/react' in deps) {
|
|
113
|
+
parts.push('Remix');
|
|
114
|
+
} else if ('astro' in deps) {
|
|
115
|
+
parts.push('Astro');
|
|
116
|
+
} else if ('express' in deps) {
|
|
117
|
+
parts.push('Express');
|
|
118
|
+
} else if ('fastify' in deps) {
|
|
119
|
+
parts.push('Fastify');
|
|
120
|
+
} else if ('hono' in deps) {
|
|
121
|
+
parts.push('Hono');
|
|
122
|
+
} else if ('react' in deps) {
|
|
123
|
+
parts.push('React');
|
|
124
|
+
} else if ('vue' in deps) {
|
|
125
|
+
parts.push('Vue');
|
|
126
|
+
} else if ('svelte' in deps || '@sveltejs/kit' in deps) {
|
|
127
|
+
parts.push('SvelteKit');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Database / ORM
|
|
131
|
+
if ('@supabase/supabase-js' in deps) parts.push('Supabase');
|
|
132
|
+
if ('prisma' in deps || '@prisma/client' in deps) parts.push('Prisma');
|
|
133
|
+
if ('drizzle-orm' in deps) parts.push('Drizzle');
|
|
134
|
+
if ('typeorm' in deps) parts.push('TypeORM');
|
|
135
|
+
if ('mongoose' in deps) parts.push('MongoDB');
|
|
136
|
+
|
|
137
|
+
// Meta-frameworks / routers
|
|
138
|
+
if ('@trpc/server' in deps) parts.push('tRPC');
|
|
139
|
+
if ('graphql' in deps && ('apollo-server' in deps || '@apollo/server' in deps)) parts.push('GraphQL/Apollo');
|
|
140
|
+
|
|
141
|
+
// Auth
|
|
142
|
+
if ('next-auth' in deps || '@auth/core' in deps) parts.push('NextAuth');
|
|
143
|
+
if ('clerk' in deps || '@clerk/nextjs' in deps) parts.push('Clerk');
|
|
144
|
+
|
|
145
|
+
// UI
|
|
146
|
+
if ('tailwindcss' in deps) parts.push('Tailwind CSS');
|
|
147
|
+
|
|
148
|
+
// Language suffix
|
|
149
|
+
if (isTs) parts.push('TypeScript');
|
|
150
|
+
|
|
151
|
+
if (parts.length === 0) return null;
|
|
152
|
+
return parts.join(' + ');
|
|
153
|
+
}
|
|
@@ -17,6 +17,7 @@ export interface ReviewPhaseInput {
|
|
|
17
17
|
engine: ReviewEngine;
|
|
18
18
|
config: AutopilotConfig;
|
|
19
19
|
cwd?: string;
|
|
20
|
+
gitSummary?: string;
|
|
20
21
|
budgetRemainingUSD?: number;
|
|
21
22
|
}
|
|
22
23
|
|
|
@@ -49,7 +50,7 @@ export async function runReviewPhase(input: ReviewPhaseInput): Promise<ReviewPha
|
|
|
49
50
|
const output = await input.engine.review({
|
|
50
51
|
content: chunk.content,
|
|
51
52
|
kind: chunk.kind,
|
|
52
|
-
context: { stack: input.config.stack },
|
|
53
|
+
context: { stack: input.config.stack, cwd: input.cwd, gitSummary: input.gitSummary },
|
|
53
54
|
});
|
|
54
55
|
allFindings.push(...output.findings);
|
|
55
56
|
if (output.usage) {
|
package/src/core/pipeline/run.ts
CHANGED
|
@@ -17,6 +17,7 @@ export interface RunInput {
|
|
|
17
17
|
reviewEngine?: ReviewEngine;
|
|
18
18
|
staticRules?: StaticRule[];
|
|
19
19
|
cwd?: string;
|
|
20
|
+
gitSummary?: string;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export interface RunResult {
|
|
@@ -59,6 +60,7 @@ export async function runAutopilot(input: RunInput): Promise<RunResult> {
|
|
|
59
60
|
engine: input.reviewEngine,
|
|
60
61
|
config: input.config,
|
|
61
62
|
cwd: input.cwd,
|
|
63
|
+
gitSummary: input.gitSummary,
|
|
62
64
|
budgetRemainingUSD: budgetUSD,
|
|
63
65
|
});
|
|
64
66
|
phases.push(reviewResult);
|