@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delegance/claude-autopilot",
3
- "version": "1.3.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",
@@ -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: ANTHROPIC_API_KEY claude, OPENAI_API_KEY → codex
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 set ANTHROPIC_API_KEY (recommended) or OPENAI_API_KEY to enable review',
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
+ }
@@ -87,14 +87,17 @@ export async function runDoctor(): Promise<DoctorResult> {
87
87
  : undefined,
88
88
  });
89
89
 
90
- // 6. OPENAI_API_KEY set
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: 'OPENAI_API_KEY',
95
- result: hasOpenAI ? 'pass' : 'warn',
96
- message: !hasOpenAI
97
- ? `OPENAI_API_KEY not setCodex review steps will be skipped`
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.OPENAI_API_KEY);
115
- if (!hasAnyKey && (ref === 'auto' || ref === 'claude' || ref === 'codex')) {
116
- console.log(fmt('yellow', '\n [run] No LLM API key found — set ANTHROPIC_API_KEY or OPENAI_API_KEY to enable review'));
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
+ };