@delegance/claude-autopilot 1.3.1 → 1.5.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/package.json +2 -1
- package/src/adapters/loader.ts +2 -0
- package/src/adapters/review-engine/auto.ts +26 -3
- package/src/adapters/review-engine/gemini.ts +131 -0
- package/src/adapters/review-engine/openai-compatible.ts +126 -0
- package/src/cli/preflight.ts +8 -5
- package/src/cli/run.ts +11 -3
- package/src/core/static-rules/registry.ts +42 -0
- package/src/core/static-rules/rules/console-log.ts +42 -0
- package/src/core/static-rules/rules/hardcoded-secrets.ts +57 -0
- package/src/core/static-rules/rules/large-file.ts +37 -0
- package/src/core/static-rules/rules/missing-tests.ts +57 -0
- package/src/core/static-rules/rules/npm-audit.ts +38 -0
- package/src/core/static-rules/rules/package-lock-sync.ts +54 -0
- package/src/core/static-rules/rules/todo-fixme.ts +40 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delegance/claude-autopilot",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Claude Code automation pipeline: spec → plan → implement → validate → PR",
|
|
6
6
|
"keywords": [
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@anthropic-ai/sdk": "^0.90.0",
|
|
49
|
+
"@google/generative-ai": "^0.24.1",
|
|
49
50
|
"ajv": "^8",
|
|
50
51
|
"dotenv": ">=16",
|
|
51
52
|
"js-yaml": "^4",
|
package/src/adapters/loader.ts
CHANGED
|
@@ -16,6 +16,8 @@ const BUILTIN_PATHS: Record<IntegrationPoint, Record<string, string>> = {
|
|
|
16
16
|
'review-engine': {
|
|
17
17
|
codex: './review-engine/codex.ts',
|
|
18
18
|
claude: './review-engine/claude.ts',
|
|
19
|
+
gemini: './review-engine/gemini.ts',
|
|
20
|
+
'openai-compatible': './review-engine/openai-compatible.ts',
|
|
19
21
|
auto: './review-engine/auto.ts',
|
|
20
22
|
},
|
|
21
23
|
'vcs-host': { github: './vcs-host/github.ts' },
|
|
@@ -2,19 +2,42 @@ 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
4
|
|
|
5
|
-
// Priority order
|
|
5
|
+
// Priority order for key detection
|
|
6
6
|
async function resolveAdapter(): Promise<ReviewEngine> {
|
|
7
7
|
if (process.env.ANTHROPIC_API_KEY) {
|
|
8
8
|
const { claudeAdapter } = await import('./claude.ts');
|
|
9
9
|
return claudeAdapter;
|
|
10
10
|
}
|
|
11
|
+
if (process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY) {
|
|
12
|
+
const { geminiAdapter } = await import('./gemini.ts');
|
|
13
|
+
return geminiAdapter;
|
|
14
|
+
}
|
|
11
15
|
if (process.env.OPENAI_API_KEY) {
|
|
12
16
|
const { codexAdapter } = await import('./codex.ts');
|
|
13
17
|
return codexAdapter;
|
|
14
18
|
}
|
|
19
|
+
if (process.env.GROQ_API_KEY) {
|
|
20
|
+
const { openaiCompatibleAdapter } = await import('./openai-compatible.ts');
|
|
21
|
+
// Wrap with Groq config injected into review() context
|
|
22
|
+
return {
|
|
23
|
+
...openaiCompatibleAdapter,
|
|
24
|
+
name: 'auto',
|
|
25
|
+
review(input: ReviewInput) {
|
|
26
|
+
return openaiCompatibleAdapter.review({
|
|
27
|
+
...input,
|
|
28
|
+
context: {
|
|
29
|
+
...input.context,
|
|
30
|
+
model: 'llama-3.3-70b-versatile',
|
|
31
|
+
baseUrl: 'https://api.groq.com/openai/v1',
|
|
32
|
+
apiKeyEnv: 'GROQ_API_KEY',
|
|
33
|
+
} as typeof input.context,
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
15
38
|
throw new AutopilotError(
|
|
16
|
-
'No LLM API key found
|
|
17
|
-
{ code: 'auth', provider: 'auto' }
|
|
39
|
+
'No LLM API key found. Set one of: ANTHROPIC_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, GROQ_API_KEY',
|
|
40
|
+
{ code: 'auth', provider: 'auto' },
|
|
18
41
|
);
|
|
19
42
|
}
|
|
20
43
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { GoogleGenerativeAI } from '@google/generative-ai';
|
|
2
|
+
import type { Finding } from '../../core/findings/types.ts';
|
|
3
|
+
import { AutopilotError } from '../../core/errors.ts';
|
|
4
|
+
import type { Capabilities } from '../base.ts';
|
|
5
|
+
import type { ReviewEngine, ReviewInput, ReviewOutput } from './types.ts';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_MODEL = 'gemini-2.5-pro-preview-05-06';
|
|
8
|
+
const MAX_OUTPUT_TOKENS = 4096;
|
|
9
|
+
|
|
10
|
+
// Cost per million tokens (USD) — gemini-2.5-pro pricing (<200k context)
|
|
11
|
+
const COST_PER_M_INPUT = 1.25;
|
|
12
|
+
const COST_PER_M_OUTPUT = 10.0;
|
|
13
|
+
|
|
14
|
+
const PROMPT_TEMPLATE = `You are a senior software architect reviewing code changes for quality, security, and correctness.
|
|
15
|
+
|
|
16
|
+
The codebase context:
|
|
17
|
+
{STACK}
|
|
18
|
+
|
|
19
|
+
Please review the following:
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
{CONTENT}
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
Provide structured feedback in exactly this format:
|
|
28
|
+
|
|
29
|
+
## Review Summary
|
|
30
|
+
One paragraph overall assessment.
|
|
31
|
+
|
|
32
|
+
## Findings
|
|
33
|
+
|
|
34
|
+
For each finding, use this format:
|
|
35
|
+
### [CRITICAL|WARNING|NOTE] <short title>
|
|
36
|
+
<explanation>
|
|
37
|
+
**Suggestion:** <actionable fix>
|
|
38
|
+
|
|
39
|
+
Rules:
|
|
40
|
+
- CRITICAL: Blocks merge (security issues, data loss risks, broken contracts)
|
|
41
|
+
- WARNING: Should address before merging (logic errors, missing error handling, test gaps)
|
|
42
|
+
- NOTE: Improvement suggestion (style, performance, clarity)
|
|
43
|
+
- Maximum 10 findings, ranked by severity
|
|
44
|
+
- Be specific and constructive
|
|
45
|
+
- Reference the file and line when possible`;
|
|
46
|
+
|
|
47
|
+
export const geminiAdapter: ReviewEngine = {
|
|
48
|
+
name: 'gemini',
|
|
49
|
+
apiVersion: '1.0.0',
|
|
50
|
+
|
|
51
|
+
getCapabilities(): Capabilities {
|
|
52
|
+
return { structuredOutput: false, streaming: false, maxContextTokens: 1000000, inlineComments: false };
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
estimateTokens(content: string): number {
|
|
56
|
+
return Math.ceil(content.length / 4);
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
async review(input: ReviewInput): Promise<ReviewOutput> {
|
|
60
|
+
const apiKey = process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY;
|
|
61
|
+
if (!apiKey) {
|
|
62
|
+
throw new AutopilotError('GEMINI_API_KEY (or GOOGLE_API_KEY) not set', { code: 'auth', provider: 'gemini' });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const model = (input.context as Record<string, unknown> | undefined)?.['model'] as string | undefined ?? DEFAULT_MODEL;
|
|
66
|
+
const stack = input.context?.stack ?? 'A web application — stack details unspecified.';
|
|
67
|
+
const prompt = PROMPT_TEMPLATE.replace('{STACK}', stack).replace('{CONTENT}', input.content);
|
|
68
|
+
|
|
69
|
+
const genAI = new GoogleGenerativeAI(apiKey);
|
|
70
|
+
const genModel = genAI.getGenerativeModel({
|
|
71
|
+
model,
|
|
72
|
+
generationConfig: { maxOutputTokens: MAX_OUTPUT_TOKENS },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
let result: Awaited<ReturnType<typeof genModel.generateContent>>;
|
|
76
|
+
try {
|
|
77
|
+
result = await genModel.generateContent(prompt);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
80
|
+
const isRateLimit = /rate.limit|429|quota/i.test(message);
|
|
81
|
+
const isAuth = /api.key|unauthorized|403/i.test(message);
|
|
82
|
+
throw new AutopilotError(`Gemini review call failed: ${message}`, {
|
|
83
|
+
code: isAuth ? 'auth' : isRateLimit ? 'rate_limit' : 'transient_network',
|
|
84
|
+
provider: 'gemini',
|
|
85
|
+
retryable: isRateLimit,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const rawOutput = result.response.text();
|
|
90
|
+
const usage = result.response.usageMetadata;
|
|
91
|
+
const costUSD = usage
|
|
92
|
+
? (usage.promptTokenCount / 1_000_000) * COST_PER_M_INPUT +
|
|
93
|
+
(usage.candidatesTokenCount / 1_000_000) * COST_PER_M_OUTPUT
|
|
94
|
+
: undefined;
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
findings: parseGeminiOutput(rawOutput),
|
|
98
|
+
rawOutput,
|
|
99
|
+
usage: usage
|
|
100
|
+
? { input: usage.promptTokenCount, output: usage.candidatesTokenCount, costUSD }
|
|
101
|
+
: undefined,
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export default geminiAdapter;
|
|
107
|
+
|
|
108
|
+
function parseGeminiOutput(output: string): Finding[] {
|
|
109
|
+
const findings: Finding[] = [];
|
|
110
|
+
const regex = /### \[(CRITICAL|WARNING|NOTE)\]\s*(.+?)(?=\n### \[|## Review Summary|$)/gs;
|
|
111
|
+
let match: RegExpExecArray | null;
|
|
112
|
+
while ((match = regex.exec(output)) !== null) {
|
|
113
|
+
const severity = match[1]!.toLowerCase() as Finding['severity'];
|
|
114
|
+
const body = match[2]!.trim();
|
|
115
|
+
const titleEnd = body.indexOf('\n');
|
|
116
|
+
const title = (titleEnd > 0 ? body.slice(0, titleEnd) : body).trim();
|
|
117
|
+
const suggestion = body.match(/\*\*Suggestion:\*\*\s*(.+)/s)?.[1]?.trim();
|
|
118
|
+
findings.push({
|
|
119
|
+
id: `gemini-${findings.length}`,
|
|
120
|
+
source: 'review-engine',
|
|
121
|
+
severity,
|
|
122
|
+
category: 'gemini-review',
|
|
123
|
+
file: '<unspecified>',
|
|
124
|
+
message: title,
|
|
125
|
+
suggestion,
|
|
126
|
+
protectedPath: false,
|
|
127
|
+
createdAt: new Date().toISOString(),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return findings;
|
|
131
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import OpenAI from 'openai';
|
|
2
|
+
import type { Finding } from '../../core/findings/types.ts';
|
|
3
|
+
import { AutopilotError } from '../../core/errors.ts';
|
|
4
|
+
import type { Capabilities } from '../base.ts';
|
|
5
|
+
import type { ReviewEngine, ReviewInput, ReviewOutput } from './types.ts';
|
|
6
|
+
|
|
7
|
+
const MAX_OUTPUT_TOKENS = 4096;
|
|
8
|
+
|
|
9
|
+
const SYSTEM_PROMPT_TEMPLATE = `You are a senior software architect reviewing code changes for quality, security, and correctness.
|
|
10
|
+
|
|
11
|
+
The codebase context:
|
|
12
|
+
{STACK}
|
|
13
|
+
|
|
14
|
+
Provide structured feedback in exactly this format:
|
|
15
|
+
|
|
16
|
+
## Review Summary
|
|
17
|
+
One paragraph overall assessment.
|
|
18
|
+
|
|
19
|
+
## Findings
|
|
20
|
+
|
|
21
|
+
For each finding, use this format:
|
|
22
|
+
### [CRITICAL|WARNING|NOTE] <short title>
|
|
23
|
+
<explanation>
|
|
24
|
+
**Suggestion:** <actionable fix>
|
|
25
|
+
|
|
26
|
+
Rules:
|
|
27
|
+
- CRITICAL: Blocks merge (security issues, data loss risks, broken contracts)
|
|
28
|
+
- WARNING: Should address before merging (logic errors, missing error handling, test gaps)
|
|
29
|
+
- NOTE: Improvement suggestion (style, performance, clarity)
|
|
30
|
+
- Maximum 10 findings, ranked by severity
|
|
31
|
+
- Be specific and constructive
|
|
32
|
+
- Reference the file and line when possible`;
|
|
33
|
+
|
|
34
|
+
export const openaiCompatibleAdapter: ReviewEngine = {
|
|
35
|
+
name: 'openai-compatible',
|
|
36
|
+
apiVersion: '1.0.0',
|
|
37
|
+
|
|
38
|
+
getCapabilities(): Capabilities {
|
|
39
|
+
return { structuredOutput: false, streaming: false, maxContextTokens: 128000, inlineComments: false };
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
estimateTokens(content: string): number {
|
|
43
|
+
return Math.ceil(content.length / 4);
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
async review(input: ReviewInput): Promise<ReviewOutput> {
|
|
47
|
+
const opts = (input.context as Record<string, unknown> | undefined) ?? {};
|
|
48
|
+
|
|
49
|
+
// API key: options.apiKey → named env var → OPENAI_API_KEY
|
|
50
|
+
const apiKeyEnv = (opts['apiKeyEnv'] as string | undefined) ?? 'OPENAI_API_KEY';
|
|
51
|
+
const apiKey = (opts['apiKey'] as string | undefined) ?? process.env[apiKeyEnv] ?? 'ollama';
|
|
52
|
+
|
|
53
|
+
const baseURL = (opts['baseUrl'] as string | undefined) ??
|
|
54
|
+
process.env.OPENAI_BASE_URL ??
|
|
55
|
+
undefined;
|
|
56
|
+
|
|
57
|
+
const model = opts['model'] as string | undefined;
|
|
58
|
+
if (!model) {
|
|
59
|
+
throw new AutopilotError(
|
|
60
|
+
'openai-compatible adapter requires options.model to be set in autopilot.config.yaml',
|
|
61
|
+
{ code: 'invalid_config', provider: 'openai-compatible' },
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const stack = input.context?.stack ?? 'A web application — stack details unspecified.';
|
|
66
|
+
const systemPrompt = SYSTEM_PROMPT_TEMPLATE.replace('{STACK}', stack);
|
|
67
|
+
const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) });
|
|
68
|
+
|
|
69
|
+
let response: OpenAI.Chat.ChatCompletion;
|
|
70
|
+
try {
|
|
71
|
+
response = await client.chat.completions.create({
|
|
72
|
+
model,
|
|
73
|
+
max_tokens: MAX_OUTPUT_TOKENS,
|
|
74
|
+
messages: [
|
|
75
|
+
{ role: 'system', content: systemPrompt },
|
|
76
|
+
{ role: 'user', content: `Please review the following:\n\n---\n\n${input.content}` },
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
} catch (err) {
|
|
80
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
81
|
+
const isRateLimit = /rate.limit|429/i.test(message);
|
|
82
|
+
const isAuth = /unauthorized|401|invalid.api.key/i.test(message);
|
|
83
|
+
throw new AutopilotError(`openai-compatible review call failed: ${message}`, {
|
|
84
|
+
code: isAuth ? 'auth' : isRateLimit ? 'rate_limit' : 'transient_network',
|
|
85
|
+
provider: 'openai-compatible',
|
|
86
|
+
retryable: isRateLimit,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const rawOutput = response.choices[0]?.message.content ?? '';
|
|
91
|
+
return {
|
|
92
|
+
findings: parseOutput(rawOutput),
|
|
93
|
+
rawOutput,
|
|
94
|
+
usage: response.usage
|
|
95
|
+
? { input: response.usage.prompt_tokens, output: response.usage.completion_tokens }
|
|
96
|
+
: undefined,
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export default openaiCompatibleAdapter;
|
|
102
|
+
|
|
103
|
+
function parseOutput(output: string): Finding[] {
|
|
104
|
+
const findings: Finding[] = [];
|
|
105
|
+
const regex = /### \[(CRITICAL|WARNING|NOTE)\]\s*(.+?)(?=\n### \[|## Review Summary|$)/gs;
|
|
106
|
+
let match: RegExpExecArray | null;
|
|
107
|
+
while ((match = regex.exec(output)) !== null) {
|
|
108
|
+
const severity = match[1]!.toLowerCase() as Finding['severity'];
|
|
109
|
+
const body = match[2]!.trim();
|
|
110
|
+
const titleEnd = body.indexOf('\n');
|
|
111
|
+
const title = (titleEnd > 0 ? body.slice(0, titleEnd) : body).trim();
|
|
112
|
+
const suggestion = body.match(/\*\*Suggestion:\*\*\s*(.+)/s)?.[1]?.trim();
|
|
113
|
+
findings.push({
|
|
114
|
+
id: `openai-compatible-${findings.length}`,
|
|
115
|
+
source: 'review-engine',
|
|
116
|
+
severity,
|
|
117
|
+
category: 'openai-compatible-review',
|
|
118
|
+
file: '<unspecified>',
|
|
119
|
+
message: title,
|
|
120
|
+
suggestion,
|
|
121
|
+
protectedPath: false,
|
|
122
|
+
createdAt: new Date().toISOString(),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return findings;
|
|
126
|
+
}
|
package/src/cli/preflight.ts
CHANGED
|
@@ -87,14 +87,17 @@ export async function runDoctor(): Promise<DoctorResult> {
|
|
|
87
87
|
: undefined,
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
// 6. OPENAI_API_KEY
|
|
90
|
+
// 6. LLM API key (ANTHROPIC_API_KEY preferred, OPENAI_API_KEY as fallback)
|
|
91
91
|
const envVars = envFile ? loadEnvFile(envFile) : {};
|
|
92
|
+
const hasAnthropic = !!process.env.ANTHROPIC_API_KEY || !!envVars['ANTHROPIC_API_KEY'];
|
|
92
93
|
const hasOpenAI = !!process.env.OPENAI_API_KEY || !!envVars['OPENAI_API_KEY'];
|
|
94
|
+
const hasLLMKey = hasAnthropic || hasOpenAI;
|
|
95
|
+
const llmKeyName = hasAnthropic ? 'ANTHROPIC_API_KEY' : hasOpenAI ? 'OPENAI_API_KEY' : 'none';
|
|
93
96
|
checks.push({
|
|
94
|
-
name:
|
|
95
|
-
result:
|
|
96
|
-
message: !
|
|
97
|
-
? `
|
|
97
|
+
name: `LLM API key (${llmKeyName})`,
|
|
98
|
+
result: hasLLMKey ? 'pass' : 'warn',
|
|
99
|
+
message: !hasLLMKey
|
|
100
|
+
? `No LLM API key found — set ANTHROPIC_API_KEY (recommended) or OPENAI_API_KEY to enable review`
|
|
98
101
|
: undefined,
|
|
99
102
|
});
|
|
100
103
|
|
package/src/cli/run.ts
CHANGED
|
@@ -21,6 +21,7 @@ for (const f of ENV_FILES) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
import { loadConfig } from '../core/config/loader.ts';
|
|
24
|
+
import { loadRulesFromConfig } from '../core/static-rules/registry.ts';
|
|
24
25
|
import { resolvePreset } from '../core/config/preset-resolver.ts';
|
|
25
26
|
import { mergeConfigs } from '../core/config/preset-resolver.ts';
|
|
26
27
|
import { loadAdapter } from '../adapters/loader.ts';
|
|
@@ -111,9 +112,10 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
|
|
|
111
112
|
let reviewEngine: ReviewEngine | undefined;
|
|
112
113
|
if (config.reviewEngine) {
|
|
113
114
|
const ref = typeof config.reviewEngine === 'string' ? config.reviewEngine : config.reviewEngine.adapter;
|
|
114
|
-
const hasAnyKey = !!(process.env.ANTHROPIC_API_KEY || process.env.
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
const hasAnyKey = !!(process.env.ANTHROPIC_API_KEY || process.env.GEMINI_API_KEY ||
|
|
116
|
+
process.env.GOOGLE_API_KEY || process.env.OPENAI_API_KEY || process.env.GROQ_API_KEY);
|
|
117
|
+
if (!hasAnyKey && ['auto', 'claude', 'gemini', 'codex', 'openai-compatible'].includes(ref)) {
|
|
118
|
+
console.log(fmt('yellow', '\n [run] No LLM API key found — set ANTHROPIC_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, or GROQ_API_KEY to enable review'));
|
|
117
119
|
} else {
|
|
118
120
|
try {
|
|
119
121
|
reviewEngine = await loadAdapter<ReviewEngine>({
|
|
@@ -127,11 +129,17 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
|
|
|
127
129
|
}
|
|
128
130
|
}
|
|
129
131
|
|
|
132
|
+
// Load static rules from config
|
|
133
|
+
const staticRules = config.staticRules && config.staticRules.length > 0
|
|
134
|
+
? await loadRulesFromConfig(config.staticRules)
|
|
135
|
+
: [];
|
|
136
|
+
|
|
130
137
|
// Execute pipeline
|
|
131
138
|
const input: RunInput = {
|
|
132
139
|
touchedFiles,
|
|
133
140
|
config,
|
|
134
141
|
reviewEngine,
|
|
142
|
+
staticRules,
|
|
135
143
|
cwd,
|
|
136
144
|
};
|
|
137
145
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { StaticRule } from '../phases/static-rules.ts';
|
|
2
|
+
import type { StaticRuleReference } from '../config/types.ts';
|
|
3
|
+
|
|
4
|
+
// Built-in cross-stack rules
|
|
5
|
+
const BUILTIN: Record<string, () => Promise<StaticRule>> = {
|
|
6
|
+
'hardcoded-secrets': () => import('./rules/hardcoded-secrets.ts').then(m => m.hardcodedSecretsRule),
|
|
7
|
+
'npm-audit': () => import('./rules/npm-audit.ts').then(m => m.npmAuditRule),
|
|
8
|
+
'package-lock-sync': () => import('./rules/package-lock-sync.ts').then(m => m.packageLockSyncRule),
|
|
9
|
+
'console-log': () => import('./rules/console-log.ts').then(m => m.consoleLogRule),
|
|
10
|
+
'todo-fixme': () => import('./rules/todo-fixme.ts').then(m => m.todoFixmeRule),
|
|
11
|
+
'large-file': () => import('./rules/large-file.ts').then(m => m.largeFileRule),
|
|
12
|
+
'missing-tests': () => import('./rules/missing-tests.ts').then(m => m.missingTestsRule),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Preset-specific rules registered by name
|
|
16
|
+
const PRESET: Record<string, () => Promise<StaticRule>> = {
|
|
17
|
+
'supabase-rls-bypass': () => import('../../../presets/nextjs-supabase/rules/supabase-rls-bypass.ts').then(m => m.supabaseRlsBypassRule),
|
|
18
|
+
'go-sql-injection': () => import('../../../presets/go/rules/go-sql-injection.ts').then(m => m.goSqlInjectionRule),
|
|
19
|
+
'fastapi-missing-auth': () => import('../../../presets/python-fastapi/rules/fastapi-missing-auth.ts').then(m => m.fastapiMissingAuthRule),
|
|
20
|
+
't3-server-only': () => import('../../../presets/t3/rules/t3-server-only.ts').then(m => m.t3ServerOnlyRule),
|
|
21
|
+
'rails-sql-injection': () => import('../../../presets/rails-postgres/rules/rails-sql-injection.ts').then(m => m.railsSqlInjectionRule),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const ALL = { ...BUILTIN, ...PRESET };
|
|
25
|
+
|
|
26
|
+
export async function loadRulesFromConfig(refs: StaticRuleReference[]): Promise<StaticRule[]> {
|
|
27
|
+
const rules: StaticRule[] = [];
|
|
28
|
+
for (const ref of refs) {
|
|
29
|
+
const name = typeof ref === 'string' ? ref : ref.adapter;
|
|
30
|
+
const loader = ALL[name];
|
|
31
|
+
if (loader) {
|
|
32
|
+
rules.push(await loader());
|
|
33
|
+
} else {
|
|
34
|
+
process.stderr.write(`[autopilot] Unknown static rule: "${name}" — skipping\n`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return rules;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function listAvailableRules(): string[] {
|
|
41
|
+
return Object.keys(ALL);
|
|
42
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import type { StaticRule } from '../../phases/static-rules.ts';
|
|
3
|
+
import type { Finding } from '../../findings/types.ts';
|
|
4
|
+
|
|
5
|
+
const CONSOLE_CALLS = /\bconsole\.(log|debug|info)\s*\(/;
|
|
6
|
+
const CODE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs']);
|
|
7
|
+
const TEST_PATH = /(?:__tests__|\.test\.|\.spec\.|\/test\/|\/tests\/)/;
|
|
8
|
+
|
|
9
|
+
export const consoleLogRule: StaticRule = {
|
|
10
|
+
name: 'console-log',
|
|
11
|
+
severity: 'warning',
|
|
12
|
+
|
|
13
|
+
async check(touchedFiles: string[]): Promise<Finding[]> {
|
|
14
|
+
const findings: Finding[] = [];
|
|
15
|
+
for (const file of touchedFiles) {
|
|
16
|
+
const ext = file.slice(file.lastIndexOf('.'));
|
|
17
|
+
if (!CODE_EXTS.has(ext) || TEST_PATH.test(file)) continue;
|
|
18
|
+
let content: string;
|
|
19
|
+
try { content = fs.readFileSync(file, 'utf8'); } catch { continue; }
|
|
20
|
+
const lines = content.split('\n');
|
|
21
|
+
for (let i = 0; i < lines.length; i++) {
|
|
22
|
+
const line = lines[i]!;
|
|
23
|
+
if (line.trim().startsWith('//')) continue;
|
|
24
|
+
if (CONSOLE_CALLS.test(line)) {
|
|
25
|
+
findings.push({
|
|
26
|
+
id: `console-log:${file}:${i + 1}`,
|
|
27
|
+
source: 'static-rules',
|
|
28
|
+
severity: 'warning',
|
|
29
|
+
category: 'console-log',
|
|
30
|
+
file,
|
|
31
|
+
line: i + 1,
|
|
32
|
+
message: 'console.log/debug/info left in production code',
|
|
33
|
+
suggestion: 'Remove or replace with a structured logger',
|
|
34
|
+
protectedPath: false,
|
|
35
|
+
createdAt: new Date().toISOString(),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return findings;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import type { StaticRule } from '../../phases/static-rules.ts';
|
|
3
|
+
import type { Finding } from '../../findings/types.ts';
|
|
4
|
+
|
|
5
|
+
const SECRET_PATTERNS: { regex: RegExp; label: string }[] = [
|
|
6
|
+
{ regex: /\bAKIA[0-9A-Z]{16}\b/, label: 'AWS Access Key ID' },
|
|
7
|
+
{ regex: /(?:password|passwd|pwd)\s*[:=]\s*['"][^'"]{6,}['"]/, label: 'Hardcoded password' },
|
|
8
|
+
{ regex: /(?:api_key|apikey|api-key)\s*[:=]\s*['"][^'"]{8,}['"]/, label: 'Hardcoded API key' },
|
|
9
|
+
{ regex: /(?:secret|secret_key|secretkey)\s*[:=]\s*['"][^'"]{8,}['"]/, label: 'Hardcoded secret' },
|
|
10
|
+
{ regex: /(?:access_token|accesstoken)\s*[:=]\s*['"][^'"]{8,}['"]/, label: 'Hardcoded access token' },
|
|
11
|
+
{ regex: /(?:private_key|privatekey)\s*[:=]\s*['"][^'"]{8,}['"]/, label: 'Hardcoded private key' },
|
|
12
|
+
{ regex: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/, label: 'Private key block' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
// Patterns that indicate a placeholder, not a real secret
|
|
16
|
+
const PLACEHOLDER = /(?:your[-_]?|xxx|placeholder|example|test|fake|dummy|changeme|<[^>]+>)/i;
|
|
17
|
+
const SKIP_EXTS = new Set(['.md', '.txt', '.yaml', '.yml', '.json', '.lock', '.snap']);
|
|
18
|
+
const TEST_PATH = /(?:__tests__|\.test\.|\.spec\.|\/test\/|\/tests\/)/;
|
|
19
|
+
|
|
20
|
+
export const hardcodedSecretsRule: StaticRule = {
|
|
21
|
+
name: 'hardcoded-secrets',
|
|
22
|
+
severity: 'critical',
|
|
23
|
+
|
|
24
|
+
async check(touchedFiles: string[]): Promise<Finding[]> {
|
|
25
|
+
const findings: Finding[] = [];
|
|
26
|
+
for (const file of touchedFiles) {
|
|
27
|
+
const ext = file.slice(file.lastIndexOf('.'));
|
|
28
|
+
if (SKIP_EXTS.has(ext) || TEST_PATH.test(file)) continue;
|
|
29
|
+
let content: string;
|
|
30
|
+
try { content = fs.readFileSync(file, 'utf8'); } catch { continue; }
|
|
31
|
+
const lines = content.split('\n');
|
|
32
|
+
for (let i = 0; i < lines.length; i++) {
|
|
33
|
+
const line = lines[i]!;
|
|
34
|
+
if (line.trim().startsWith('//') || line.trim().startsWith('#')) continue;
|
|
35
|
+
for (const { regex, label } of SECRET_PATTERNS) {
|
|
36
|
+
const match = line.match(regex);
|
|
37
|
+
if (match && !PLACEHOLDER.test(match[0])) {
|
|
38
|
+
findings.push({
|
|
39
|
+
id: `hardcoded-secrets:${file}:${i + 1}`,
|
|
40
|
+
source: 'static-rules',
|
|
41
|
+
severity: 'critical',
|
|
42
|
+
category: 'hardcoded-secrets',
|
|
43
|
+
file,
|
|
44
|
+
line: i + 1,
|
|
45
|
+
message: `${label} appears hardcoded`,
|
|
46
|
+
suggestion: 'Move to environment variable and load via process.env',
|
|
47
|
+
protectedPath: false,
|
|
48
|
+
createdAt: new Date().toISOString(),
|
|
49
|
+
});
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return findings;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import type { StaticRule } from '../../phases/static-rules.ts';
|
|
3
|
+
import type { Finding } from '../../findings/types.ts';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_THRESHOLD = 500;
|
|
6
|
+
const SKIP_EXTS = new Set(['.lock', '.snap', '.map', '.min.js', '.min.css']);
|
|
7
|
+
|
|
8
|
+
export const largeFileRule: StaticRule = {
|
|
9
|
+
name: 'large-file',
|
|
10
|
+
severity: 'note',
|
|
11
|
+
|
|
12
|
+
async check(touchedFiles: string[]): Promise<Finding[]> {
|
|
13
|
+
const threshold = parseInt(process.env.AUTOPILOT_LARGE_FILE_LINES ?? '', 10) || DEFAULT_THRESHOLD;
|
|
14
|
+
const findings: Finding[] = [];
|
|
15
|
+
for (const file of touchedFiles) {
|
|
16
|
+
const ext = file.slice(file.lastIndexOf('.'));
|
|
17
|
+
if (SKIP_EXTS.has(ext)) continue;
|
|
18
|
+
let content: string;
|
|
19
|
+
try { content = fs.readFileSync(file, 'utf8'); } catch { continue; }
|
|
20
|
+
const lines = content.split('\n').length;
|
|
21
|
+
if (lines > threshold) {
|
|
22
|
+
findings.push({
|
|
23
|
+
id: `large-file:${file}`,
|
|
24
|
+
source: 'static-rules',
|
|
25
|
+
severity: 'note',
|
|
26
|
+
category: 'large-file',
|
|
27
|
+
file,
|
|
28
|
+
message: `File is ${lines} lines (threshold: ${threshold})`,
|
|
29
|
+
suggestion: 'Consider splitting into smaller, focused modules',
|
|
30
|
+
protectedPath: false,
|
|
31
|
+
createdAt: new Date().toISOString(),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return findings;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import type { StaticRule } from '../../phases/static-rules.ts';
|
|
4
|
+
import type { Finding } from '../../findings/types.ts';
|
|
5
|
+
|
|
6
|
+
const SOURCE_DIRS = ['src/', 'app/', 'lib/', 'utils/'];
|
|
7
|
+
const SOURCE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx']);
|
|
8
|
+
const TEST_PATH = /(?:__tests__|\.test\.|\.spec\.|\/test\/|\/tests\/)/;
|
|
9
|
+
const INDEX_FILE = /(?:^|[/\\])index\.[tj]sx?$/;
|
|
10
|
+
|
|
11
|
+
function isSourceFile(f: string): boolean {
|
|
12
|
+
const ext = f.slice(f.lastIndexOf('.'));
|
|
13
|
+
return SOURCE_EXTS.has(ext) && !TEST_PATH.test(f) && SOURCE_DIRS.some(d => f.startsWith(d));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function hasTestCounterpart(file: string, touchedFiles: Set<string>): boolean {
|
|
17
|
+
const base = file.replace(/\.[tj]sx?$/, '');
|
|
18
|
+
const candidates = [
|
|
19
|
+
`${base}.test.ts`, `${base}.test.tsx`, `${base}.test.js`,
|
|
20
|
+
`${base}.spec.ts`, `${base}.spec.tsx`, `${base}.spec.js`,
|
|
21
|
+
];
|
|
22
|
+
const dir = path.dirname(file);
|
|
23
|
+
const name = path.basename(base);
|
|
24
|
+
candidates.push(
|
|
25
|
+
`${dir}/__tests__/${name}.test.ts`,
|
|
26
|
+
`${dir}/__tests__/${name}.test.tsx`,
|
|
27
|
+
`${dir}/__tests__/${name}.test.js`,
|
|
28
|
+
);
|
|
29
|
+
return candidates.some(c => touchedFiles.has(c) || fs.existsSync(c));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const missingTestsRule: StaticRule = {
|
|
33
|
+
name: 'missing-tests',
|
|
34
|
+
severity: 'note',
|
|
35
|
+
|
|
36
|
+
async check(touchedFiles: string[]): Promise<Finding[]> {
|
|
37
|
+
const touched = new Set(touchedFiles);
|
|
38
|
+
const findings: Finding[] = [];
|
|
39
|
+
for (const file of touchedFiles) {
|
|
40
|
+
if (!isSourceFile(file) || INDEX_FILE.test(file)) continue;
|
|
41
|
+
if (!hasTestCounterpart(file, touched)) {
|
|
42
|
+
findings.push({
|
|
43
|
+
id: `missing-tests:${file}`,
|
|
44
|
+
source: 'static-rules',
|
|
45
|
+
severity: 'note',
|
|
46
|
+
category: 'missing-tests',
|
|
47
|
+
file,
|
|
48
|
+
message: 'No test file found for this changed source file',
|
|
49
|
+
suggestion: `Add tests at ${file.replace(/\.[tj]sx?$/, '.test.ts')}`,
|
|
50
|
+
protectedPath: false,
|
|
51
|
+
createdAt: new Date().toISOString(),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return findings;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { runSafe } from '../../shell.ts';
|
|
4
|
+
import type { StaticRule } from '../../phases/static-rules.ts';
|
|
5
|
+
import type { Finding } from '../../findings/types.ts';
|
|
6
|
+
|
|
7
|
+
export const npmAuditRule: StaticRule = {
|
|
8
|
+
name: 'npm-audit',
|
|
9
|
+
severity: 'critical',
|
|
10
|
+
|
|
11
|
+
async check(touchedFiles: string[]): Promise<Finding[]> {
|
|
12
|
+
const cwd = process.cwd();
|
|
13
|
+
if (!fs.existsSync(path.join(cwd, 'package.json'))) return [];
|
|
14
|
+
|
|
15
|
+
const out = runSafe('npm', ['audit', '--json'], { cwd });
|
|
16
|
+
if (!out) return [];
|
|
17
|
+
|
|
18
|
+
let report: { vulnerabilities?: Record<string, { severity: string; name: string; via: unknown[] }> };
|
|
19
|
+
try { report = JSON.parse(out); } catch { return []; }
|
|
20
|
+
|
|
21
|
+
const findings: Finding[] = [];
|
|
22
|
+
for (const [, vuln] of Object.entries(report.vulnerabilities ?? {})) {
|
|
23
|
+
if (vuln.severity !== 'critical' && vuln.severity !== 'high') continue;
|
|
24
|
+
findings.push({
|
|
25
|
+
id: `npm-audit:${vuln.name}`,
|
|
26
|
+
source: 'static-rules',
|
|
27
|
+
severity: vuln.severity === 'critical' ? 'critical' : 'warning',
|
|
28
|
+
category: 'npm-audit',
|
|
29
|
+
file: 'package.json',
|
|
30
|
+
message: `${vuln.severity.toUpperCase()} vulnerability in ${vuln.name}`,
|
|
31
|
+
suggestion: `Run: npm audit fix`,
|
|
32
|
+
protectedPath: false,
|
|
33
|
+
createdAt: new Date().toISOString(),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return findings;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import type { StaticRule } from '../../phases/static-rules.ts';
|
|
4
|
+
import type { Finding } from '../../findings/types.ts';
|
|
5
|
+
|
|
6
|
+
export const packageLockSyncRule: StaticRule = {
|
|
7
|
+
name: 'package-lock-sync',
|
|
8
|
+
severity: 'warning',
|
|
9
|
+
|
|
10
|
+
async check(touchedFiles: string[]): Promise<Finding[]> {
|
|
11
|
+
const cwd = process.cwd();
|
|
12
|
+
const hasPkg = touchedFiles.some(f => f === 'package.json');
|
|
13
|
+
const hasLock = touchedFiles.some(f => f === 'package-lock.json');
|
|
14
|
+
|
|
15
|
+
if (!hasPkg && !hasLock) return [];
|
|
16
|
+
|
|
17
|
+
const pkgExists = fs.existsSync(path.join(cwd, 'package.json'));
|
|
18
|
+
const lockExists = fs.existsSync(path.join(cwd, 'package-lock.json'));
|
|
19
|
+
|
|
20
|
+
if (!pkgExists) return [];
|
|
21
|
+
|
|
22
|
+
// package.json changed but lock didn't
|
|
23
|
+
if (hasPkg && !hasLock && lockExists) {
|
|
24
|
+
return [{
|
|
25
|
+
id: 'package-lock-sync:package.json',
|
|
26
|
+
source: 'static-rules',
|
|
27
|
+
severity: 'warning',
|
|
28
|
+
category: 'package-lock-sync',
|
|
29
|
+
file: 'package.json',
|
|
30
|
+
message: 'package.json changed but package-lock.json was not updated',
|
|
31
|
+
suggestion: 'Run npm install to sync the lockfile',
|
|
32
|
+
protectedPath: false,
|
|
33
|
+
createdAt: new Date().toISOString(),
|
|
34
|
+
}];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// lock changed but package.json didn't (unusual, flag it)
|
|
38
|
+
if (hasLock && !hasPkg) {
|
|
39
|
+
return [{
|
|
40
|
+
id: 'package-lock-sync:package-lock.json',
|
|
41
|
+
source: 'static-rules',
|
|
42
|
+
severity: 'warning',
|
|
43
|
+
category: 'package-lock-sync',
|
|
44
|
+
file: 'package-lock.json',
|
|
45
|
+
message: 'package-lock.json changed without a corresponding package.json change',
|
|
46
|
+
suggestion: 'Verify this is intentional — lockfile-only changes can indicate manual edits',
|
|
47
|
+
protectedPath: false,
|
|
48
|
+
createdAt: new Date().toISOString(),
|
|
49
|
+
}];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return [];
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import type { StaticRule } from '../../phases/static-rules.ts';
|
|
3
|
+
import type { Finding } from '../../findings/types.ts';
|
|
4
|
+
|
|
5
|
+
const TODO_PATTERN = /\b(TODO|FIXME|HACK|XXX)\b/;
|
|
6
|
+
const SKIP_EXTS = new Set(['.lock', '.snap', '.png', '.jpg', '.svg', '.ico']);
|
|
7
|
+
|
|
8
|
+
export const todoFixmeRule: StaticRule = {
|
|
9
|
+
name: 'todo-fixme',
|
|
10
|
+
severity: 'note',
|
|
11
|
+
|
|
12
|
+
async check(touchedFiles: string[]): Promise<Finding[]> {
|
|
13
|
+
const findings: Finding[] = [];
|
|
14
|
+
for (const file of touchedFiles) {
|
|
15
|
+
const ext = file.slice(file.lastIndexOf('.'));
|
|
16
|
+
if (SKIP_EXTS.has(ext)) continue;
|
|
17
|
+
let content: string;
|
|
18
|
+
try { content = fs.readFileSync(file, 'utf8'); } catch { continue; }
|
|
19
|
+
const lines = content.split('\n');
|
|
20
|
+
for (let i = 0; i < lines.length; i++) {
|
|
21
|
+
const match = lines[i]!.match(TODO_PATTERN);
|
|
22
|
+
if (match) {
|
|
23
|
+
findings.push({
|
|
24
|
+
id: `todo-fixme:${file}:${i + 1}`,
|
|
25
|
+
source: 'static-rules',
|
|
26
|
+
severity: 'note',
|
|
27
|
+
category: 'todo-fixme',
|
|
28
|
+
file,
|
|
29
|
+
line: i + 1,
|
|
30
|
+
message: `${match[1]} comment in changed file`,
|
|
31
|
+
suggestion: 'Resolve before merging or track in an issue',
|
|
32
|
+
protectedPath: false,
|
|
33
|
+
createdAt: new Date().toISOString(),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return findings;
|
|
39
|
+
},
|
|
40
|
+
};
|