@delegance/claude-autopilot 1.0.0-alpha.4

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/LICENSE +21 -0
  3. package/README.md +58 -0
  4. package/bin/autopilot.js +15 -0
  5. package/package.json +41 -0
  6. package/presets/go/autopilot.config.yaml +20 -0
  7. package/presets/go/rules/go-sql-injection.ts +65 -0
  8. package/presets/go/stack.md +20 -0
  9. package/presets/nextjs-supabase/autopilot.config.yaml +29 -0
  10. package/presets/nextjs-supabase/rules/supabase-rls-bypass.ts +39 -0
  11. package/presets/nextjs-supabase/stack.md +20 -0
  12. package/presets/python-fastapi/autopilot.config.yaml +20 -0
  13. package/presets/python-fastapi/rules/fastapi-missing-auth.ts +50 -0
  14. package/presets/python-fastapi/stack.md +20 -0
  15. package/presets/rails-postgres/autopilot.config.yaml +21 -0
  16. package/presets/rails-postgres/rules/rails-sql-injection.ts +42 -0
  17. package/presets/rails-postgres/stack.md +20 -0
  18. package/presets/t3/autopilot.config.yaml +22 -0
  19. package/presets/t3/rules/t3-server-only.ts +35 -0
  20. package/presets/t3/stack.md +20 -0
  21. package/scripts/test-runner.mjs +16 -0
  22. package/src/adapters/base.ts +19 -0
  23. package/src/adapters/loader.ts +101 -0
  24. package/src/adapters/migration-runner/supabase.ts +56 -0
  25. package/src/adapters/migration-runner/types.ts +36 -0
  26. package/src/adapters/review-bot-parser/cursor.ts +13 -0
  27. package/src/adapters/review-bot-parser/declarative-base.ts +64 -0
  28. package/src/adapters/review-bot-parser/types.ts +9 -0
  29. package/src/adapters/review-engine/codex.ts +108 -0
  30. package/src/adapters/review-engine/types.ts +19 -0
  31. package/src/adapters/vcs-host/github.ts +77 -0
  32. package/src/adapters/vcs-host/types.ts +44 -0
  33. package/src/cli/index.ts +110 -0
  34. package/src/cli/init.ts +88 -0
  35. package/src/cli/preflight.ts +154 -0
  36. package/src/cli/run.ts +152 -0
  37. package/src/cli/watch.ts +169 -0
  38. package/src/core/.gitkeep +0 -0
  39. package/src/core/cache/cached-engine.ts +32 -0
  40. package/src/core/cache/review-cache.ts +70 -0
  41. package/src/core/chunking/index.ts +82 -0
  42. package/src/core/config/loader.ts +41 -0
  43. package/src/core/config/preset-resolver.ts +46 -0
  44. package/src/core/config/schema.ts +63 -0
  45. package/src/core/config/types.ts +42 -0
  46. package/src/core/errors.ts +37 -0
  47. package/src/core/findings/dedup.ts +14 -0
  48. package/src/core/findings/types.ts +39 -0
  49. package/src/core/git/touched-files.ts +51 -0
  50. package/src/core/index.ts +1 -0
  51. package/src/core/logging/ndjson-writer.ts +37 -0
  52. package/src/core/logging/redaction.ts +19 -0
  53. package/src/core/phases/static-rules.ts +80 -0
  54. package/src/core/phases/tests.ts +51 -0
  55. package/src/core/pipeline/review-phase.ts +87 -0
  56. package/src/core/pipeline/run.ts +80 -0
  57. package/src/core/runtime/idempotency.ts +6 -0
  58. package/src/core/runtime/lock.ts +29 -0
  59. package/src/core/runtime/state.ts +97 -0
  60. package/src/core/shell.ts +48 -0
@@ -0,0 +1,82 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import type { ReviewEngine, ReviewInput } from '../../adapters/review-engine/types.ts';
4
+ import type { AutopilotConfig } from '../config/types.ts';
5
+
6
+ export interface ReviewChunk {
7
+ content: string;
8
+ kind: ReviewInput['kind'];
9
+ files: string[];
10
+ }
11
+
12
+ export interface BuildChunksInput {
13
+ touchedFiles: string[];
14
+ strategy: 'auto' | 'single-pass' | 'file-level';
15
+ chunking?: AutopilotConfig['chunking'];
16
+ engine: ReviewEngine;
17
+ cwd?: string;
18
+ }
19
+
20
+ const DEFAULT_SMALL_TIER_TOKENS = 8000;
21
+ const DEFAULT_FILE_TIER_TOKENS = 60000;
22
+
23
+ export async function buildReviewChunks(input: BuildChunksInput): Promise<ReviewChunk[]> {
24
+ const smallMax = input.chunking?.smallTierMaxTokens ?? DEFAULT_SMALL_TIER_TOKENS;
25
+ const fileMax = input.chunking?.perFileMaxTokens ?? DEFAULT_FILE_TIER_TOKENS;
26
+
27
+ const fileContents = await readFiles(input.touchedFiles, input.cwd);
28
+
29
+ if (input.strategy === 'single-pass') {
30
+ const combined = formatBatch(fileContents);
31
+ return [{ content: combined, kind: 'file-batch', files: [...fileContents.keys()] }];
32
+ }
33
+
34
+ if (input.strategy === 'auto') {
35
+ const combined = formatBatch(fileContents);
36
+ if (input.engine.estimateTokens(combined) <= smallMax) {
37
+ return [{ content: combined, kind: 'file-batch', files: [...fileContents.keys()] }];
38
+ }
39
+ // fall through to file-level
40
+ }
41
+
42
+ // file-level: one chunk per readable file, truncated to fileMax tokens
43
+ const chunks: ReviewChunk[] = [];
44
+ for (const [filePath, content] of fileContents) {
45
+ const truncated = truncateToTokens(content, fileMax, input.engine);
46
+ chunks.push({ content: `// File: ${filePath}\n${truncated}`, kind: 'file-batch', files: [filePath] });
47
+ }
48
+ return chunks;
49
+ }
50
+
51
+ async function readFiles(touchedFiles: string[], cwd?: string): Promise<Map<string, string>> {
52
+ const result = new Map<string, string>();
53
+ for (const f of touchedFiles) {
54
+ const resolved = cwd ? path.resolve(cwd, f) : path.resolve(f);
55
+ try {
56
+ result.set(f, await fs.readFile(resolved, 'utf8'));
57
+ } catch {
58
+ // deleted or unreadable — skip silently
59
+ }
60
+ }
61
+ return result;
62
+ }
63
+
64
+ function formatBatch(fileContents: Map<string, string>): string {
65
+ const parts: string[] = [];
66
+ for (const [filePath, content] of fileContents) {
67
+ parts.push(`// File: ${filePath}\n${content}`);
68
+ }
69
+ return parts.join('\n\n---\n\n');
70
+ }
71
+
72
+ function truncateToTokens(content: string, maxTokens: number, engine: ReviewEngine): string {
73
+ if (engine.estimateTokens(content) <= maxTokens) return content;
74
+ let lo = 0;
75
+ let hi = content.length;
76
+ while (hi - lo > 128) {
77
+ const mid = (lo + hi) >> 1;
78
+ if (engine.estimateTokens(content.slice(0, mid)) <= maxTokens) lo = mid;
79
+ else hi = mid;
80
+ }
81
+ return content.slice(0, lo) + '\n// [truncated]';
82
+ }
@@ -0,0 +1,41 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as yaml from 'js-yaml';
3
+ import Ajv from 'ajv';
4
+ import { AutopilotError } from '../errors.ts';
5
+ import type { AutopilotConfig } from './types.ts';
6
+ import { AUTOPILOT_CONFIG_SCHEMA } from './schema.ts';
7
+
8
+ const ajv = new Ajv({ allErrors: true, strict: false });
9
+ const validate = ajv.compile(AUTOPILOT_CONFIG_SCHEMA);
10
+
11
+ export async function loadConfig(path: string): Promise<AutopilotConfig> {
12
+ let content: string;
13
+ try {
14
+ content = await fs.readFile(path, 'utf8');
15
+ } catch (err) {
16
+ throw new AutopilotError(`Config file not found: ${path}`, {
17
+ code: 'user_input',
18
+ details: { path, cause: err instanceof Error ? err.message : String(err) },
19
+ });
20
+ }
21
+
22
+ let parsed: unknown;
23
+ try {
24
+ parsed = yaml.load(content);
25
+ } catch (err) {
26
+ throw new AutopilotError(`Invalid YAML in ${path}`, {
27
+ code: 'invalid_config',
28
+ details: { path, cause: err instanceof Error ? err.message : String(err) },
29
+ });
30
+ }
31
+
32
+ if (!validate(parsed)) {
33
+ const errors = (validate.errors ?? []).map(e => `${e.instancePath || '<root>'}: ${e.message}`);
34
+ throw new AutopilotError('Config schema validation failed', {
35
+ code: 'invalid_config',
36
+ details: { path, errors },
37
+ });
38
+ }
39
+
40
+ return parsed as AutopilotConfig;
41
+ }
@@ -0,0 +1,46 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import { loadConfig } from './loader.ts';
4
+ import { AutopilotError } from '../errors.ts';
5
+ import type { AutopilotConfig } from './types.ts';
6
+
7
+ const PRESET_ROOT = path.resolve(process.cwd(), 'presets');
8
+
9
+ export interface ResolvedPreset {
10
+ name: string;
11
+ config: AutopilotConfig;
12
+ stack: string;
13
+ }
14
+
15
+ export async function resolvePreset(name: string): Promise<ResolvedPreset> {
16
+ const presetDir = path.join(PRESET_ROOT, name);
17
+ try {
18
+ await fs.stat(presetDir);
19
+ } catch {
20
+ throw new AutopilotError(`Preset not found: ${name}`, {
21
+ code: 'invalid_config',
22
+ details: { name, presetDir },
23
+ });
24
+ }
25
+
26
+ const config = await loadConfig(path.join(presetDir, 'autopilot.config.yaml'));
27
+ let stack = '';
28
+ try {
29
+ stack = await fs.readFile(path.join(presetDir, 'stack.md'), 'utf8');
30
+ } catch {
31
+ stack = config.stack ?? '';
32
+ }
33
+ return { name, config, stack };
34
+ }
35
+
36
+ export function mergeConfigs(preset: AutopilotConfig, user: AutopilotConfig): AutopilotConfig {
37
+ return {
38
+ ...preset,
39
+ ...user,
40
+ // Arrays are concatenated (preset values first) so user additions don't discard preset invariants
41
+ protectedPaths: [...(preset.protectedPaths ?? []), ...(user.protectedPaths ?? [])],
42
+ staticRules: [...(preset.staticRules ?? []), ...(user.staticRules ?? [])],
43
+ thresholds: { ...preset.thresholds, ...user.thresholds },
44
+ chunking: { ...preset.chunking, ...user.chunking },
45
+ };
46
+ }
@@ -0,0 +1,63 @@
1
+ export const AUTOPILOT_CONFIG_SCHEMA = {
2
+ $schema: 'http://json-schema.org/draft-07/schema#',
3
+ type: 'object',
4
+ required: ['configVersion'],
5
+ additionalProperties: false,
6
+ properties: {
7
+ configVersion: { const: 1 },
8
+ preset: { type: 'string' },
9
+ reviewEngine: { $ref: '#/definitions/adapterRef' },
10
+ vcsHost: { $ref: '#/definitions/adapterRef' },
11
+ migrationRunner: { $ref: '#/definitions/adapterRef' },
12
+ reviewBot: { $ref: '#/definitions/adapterRef' },
13
+ adapterAllowlist: { type: 'array', items: { type: 'string' } },
14
+ protectedPaths: { type: 'array', items: { type: 'string' } },
15
+ staticRules: {
16
+ type: 'array',
17
+ items: {
18
+ oneOf: [
19
+ { type: 'string' },
20
+ { type: 'object', required: ['adapter'], properties: { adapter: { type: 'string' }, options: { type: 'object' } } },
21
+ ],
22
+ },
23
+ },
24
+ staticRulesParallel: { type: 'boolean' },
25
+ stack: { type: 'string' },
26
+ testCommand: { type: ['string', 'null'] },
27
+ thresholds: {
28
+ type: 'object',
29
+ properties: {
30
+ bugbotAutoFix: { type: 'number' },
31
+ bugbotProposePatch: { type: 'number' },
32
+ maxValidateRetries: { type: 'number' },
33
+ maxCodexRetries: { type: 'number' },
34
+ maxBugbotRounds: { type: 'number' },
35
+ },
36
+ additionalProperties: false,
37
+ },
38
+ reviewStrategy: { enum: ['auto', 'single-pass', 'file-level'] },
39
+ chunking: {
40
+ type: 'object',
41
+ properties: {
42
+ smallTierMaxTokens: { type: 'number' },
43
+ partialReviewTokens: { type: 'number' },
44
+ perFileMaxTokens: { type: 'number' },
45
+ parallelism: { type: 'number' },
46
+ rateLimitBackoff: { enum: ['exp', 'linear', 'none'] },
47
+ },
48
+ additionalProperties: false,
49
+ },
50
+ cost: { type: 'object' },
51
+ cache: { type: 'object' },
52
+ persistence: { type: 'object' },
53
+ concurrency: { type: 'object' },
54
+ },
55
+ definitions: {
56
+ adapterRef: {
57
+ oneOf: [
58
+ { type: 'string' },
59
+ { type: 'object', required: ['adapter'], properties: { adapter: { type: 'string' }, options: { type: 'object' } } },
60
+ ],
61
+ },
62
+ },
63
+ } as const;
@@ -0,0 +1,42 @@
1
+ export interface AdapterReference {
2
+ adapter: string;
3
+ options?: Record<string, unknown>;
4
+ }
5
+
6
+ export type AdapterRef = string | AdapterReference;
7
+
8
+ export type StaticRuleReference = string | { adapter: string; options?: Record<string, unknown> };
9
+
10
+ export interface AutopilotConfig {
11
+ configVersion: 1;
12
+ preset?: string;
13
+ reviewEngine?: AdapterRef;
14
+ vcsHost?: AdapterRef;
15
+ migrationRunner?: AdapterRef;
16
+ reviewBot?: AdapterRef;
17
+ adapterAllowlist?: string[];
18
+ protectedPaths?: string[];
19
+ staticRules?: StaticRuleReference[];
20
+ staticRulesParallel?: boolean;
21
+ stack?: string;
22
+ testCommand?: string | null;
23
+ thresholds?: {
24
+ bugbotAutoFix?: number;
25
+ bugbotProposePatch?: number;
26
+ maxValidateRetries?: number;
27
+ maxCodexRetries?: number;
28
+ maxBugbotRounds?: number;
29
+ };
30
+ reviewStrategy?: 'auto' | 'single-pass' | 'file-level';
31
+ chunking?: {
32
+ smallTierMaxTokens?: number;
33
+ partialReviewTokens?: number;
34
+ perFileMaxTokens?: number;
35
+ parallelism?: number;
36
+ rateLimitBackoff?: 'exp' | 'linear' | 'none';
37
+ };
38
+ cost?: Record<string, unknown>;
39
+ cache?: Record<string, unknown>;
40
+ persistence?: Record<string, unknown>;
41
+ concurrency?: Record<string, unknown>;
42
+ }
@@ -0,0 +1,37 @@
1
+ // src/core/errors.ts
2
+
3
+ export type ErrorCode =
4
+ | 'auth' | 'rate_limit' | 'transient_network' | 'invalid_config'
5
+ | 'adapter_bug' | 'user_input' | 'budget_exceeded' | 'concurrency_lock' | 'superseded';
6
+
7
+ export interface AutopilotErrorOptions {
8
+ code: ErrorCode;
9
+ retryable?: boolean;
10
+ provider?: string;
11
+ step?: string;
12
+ details?: Record<string, unknown>;
13
+ }
14
+
15
+ const DEFAULT_RETRYABLE: Record<ErrorCode, boolean> = {
16
+ auth: false, rate_limit: true, transient_network: true, invalid_config: false,
17
+ adapter_bug: false, user_input: false, budget_exceeded: false,
18
+ concurrency_lock: false, superseded: false,
19
+ };
20
+
21
+ export class AutopilotError extends Error {
22
+ code: ErrorCode;
23
+ retryable: boolean;
24
+ provider?: string;
25
+ step?: string;
26
+ details: Record<string, unknown>;
27
+
28
+ constructor(message: string, options: AutopilotErrorOptions) {
29
+ super(message);
30
+ this.name = 'AutopilotError';
31
+ this.code = options.code;
32
+ this.retryable = options.retryable ?? DEFAULT_RETRYABLE[options.code];
33
+ this.provider = options.provider;
34
+ this.step = options.step;
35
+ this.details = options.details ?? {};
36
+ }
37
+ }
@@ -0,0 +1,14 @@
1
+ import type { Finding } from './types.ts';
2
+
3
+ export function findingContentKey(f: Finding): string {
4
+ return `${f.file}|${f.line ?? ''}|${f.severity}|${f.message.slice(0, 40)}`;
5
+ }
6
+
7
+ export function dedupFindings(findings: Finding[]): Finding[] {
8
+ const seen = new Map<string, Finding>();
9
+ for (const f of findings) {
10
+ const key = findingContentKey(f);
11
+ if (!seen.has(key)) seen.set(key, f);
12
+ }
13
+ return Array.from(seen.values());
14
+ }
@@ -0,0 +1,39 @@
1
+ // src/core/findings/types.ts
2
+
3
+ export type FindingSource = 'static-rules' | 'review-engine' | 'pipeline' | `review-bot:${string}`;
4
+ export type Severity = 'critical' | 'warning' | 'note';
5
+
6
+ export interface Finding {
7
+ id: string;
8
+ source: FindingSource;
9
+ severity: Severity;
10
+ category: string;
11
+ file: string;
12
+ line?: number;
13
+ message: string;
14
+ suggestion?: string;
15
+ protectedPath: boolean;
16
+ createdAt: string;
17
+ }
18
+
19
+ export type TriageVerdict = 'real_bug' | 'false_positive' | 'low_value';
20
+ export type TriageAction = 'auto_fix' | 'propose_patch' | 'ask_question' | 'dismiss' | 'needs_human';
21
+
22
+ export interface TriageRecord {
23
+ findingId: string;
24
+ verdict: TriageVerdict;
25
+ confidence: number;
26
+ reason: string;
27
+ action: TriageAction;
28
+ recordedAt: string;
29
+ }
30
+
31
+ export type FixStatus = 'fixed' | 'reverted' | 'human_required' | 'skipped';
32
+
33
+ export interface FixAttempt {
34
+ findingId: string;
35
+ attemptedAt: string;
36
+ status: FixStatus;
37
+ commitSha?: string;
38
+ notes?: string;
39
+ }
@@ -0,0 +1,51 @@
1
+ import { runSafe } from '../shell.ts';
2
+
3
+ export interface TouchedFilesOptions {
4
+ cwd?: string;
5
+ base?: string; // e.g. 'HEAD~1', 'main', a SHA — defaults to HEAD~1
6
+ }
7
+
8
+ /**
9
+ * Returns the list of files changed relative to `base` (default HEAD~1).
10
+ * Falls back to `git status --short` unstaged/staged files if the diff fails
11
+ * (e.g. first commit, no parent).
12
+ */
13
+ export function resolveGitTouchedFiles(options: TouchedFilesOptions = {}): string[] {
14
+ const cwd = options.cwd ?? process.cwd();
15
+ const base = options.base ?? 'HEAD~1';
16
+
17
+ // Primary: diff against base
18
+ const diffOut = runSafe('git', ['diff', '--name-only', base, 'HEAD'], { cwd });
19
+ if (diffOut !== null && diffOut.trim().length > 0) {
20
+ return parseFileList(diffOut);
21
+ }
22
+
23
+ // Fallback: staged + unstaged working tree changes
24
+ const statusOut = runSafe('git', ['status', '--short'], { cwd });
25
+ if (statusOut !== null && statusOut.trim().length > 0) {
26
+ return parseStatusOutput(statusOut);
27
+ }
28
+
29
+ return [];
30
+ }
31
+
32
+ function parseFileList(output: string): string[] {
33
+ return [...new Set(output.split('\n').map(l => l.trim()).filter(Boolean))];
34
+ }
35
+
36
+ function parseStatusOutput(output: string): string[] {
37
+ const files = new Set<string>();
38
+ for (const line of output.split('\n')) {
39
+ const trimmed = line.trim();
40
+ if (!trimmed) continue;
41
+ // format: XY filename (or XY old -> new for renames)
42
+ const parts = trimmed.replace(/^\S+\s+/, '');
43
+ const renamed = parts.match(/^(.+)\s+->\s+(.+)$/);
44
+ if (renamed) {
45
+ files.add(renamed[2]!.trim());
46
+ } else {
47
+ files.add(parts.trim());
48
+ }
49
+ }
50
+ return [...files];
51
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,37 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { applyRedaction, DEFAULT_REDACTION_PATTERNS } from './redaction.ts';
4
+
5
+ export interface NdjsonLoggerOptions {
6
+ runId: string;
7
+ logsDir?: string;
8
+ redactionPatterns?: readonly string[];
9
+ }
10
+
11
+ export class NdjsonLogger {
12
+ private readonly runId: string;
13
+ private readonly filePath: string;
14
+ private readonly stream: fs.WriteStream;
15
+ private readonly redactionPatterns: readonly string[];
16
+
17
+ constructor(options: NdjsonLoggerOptions) {
18
+ this.runId = options.runId;
19
+ this.redactionPatterns = options.redactionPatterns ?? DEFAULT_REDACTION_PATTERNS;
20
+ const logsDir = options.logsDir ?? path.join('.claude', 'logs');
21
+ fs.mkdirSync(logsDir, { recursive: true });
22
+ this.filePath = path.join(logsDir, `${this.runId}.ndjson`);
23
+ this.stream = fs.createWriteStream(this.filePath, { flags: 'a' });
24
+ }
25
+
26
+ log(event: string, fields: Record<string, unknown> = {}): void {
27
+ const entry = { ts: new Date().toISOString(), runId: this.runId, event, ...fields };
28
+ const serialized = applyRedaction(JSON.stringify(entry), this.redactionPatterns);
29
+ this.stream.write(serialized + '\n');
30
+ }
31
+
32
+ close(): Promise<void> {
33
+ return new Promise(resolve => this.stream.end(() => resolve()));
34
+ }
35
+
36
+ getFilePath(): string { return this.filePath; }
37
+ }
@@ -0,0 +1,19 @@
1
+ export const DEFAULT_REDACTION_PATTERNS: readonly string[] = Object.freeze([
2
+ '\\bsk-[a-zA-Z0-9_-]{20,}',
3
+ '\\beyJ[a-zA-Z0-9_-]{30,}',
4
+ '\\bghp_[a-zA-Z0-9]{30,}',
5
+ '\\bxoxb-[a-zA-Z0-9-]{20,}',
6
+ '\\bAKIA[A-Z0-9]{16}\\b',
7
+ ]);
8
+
9
+ export function applyRedaction(text: string, patterns: readonly string[]): string {
10
+ let result = text;
11
+ for (const pattern of patterns) {
12
+ result = result.replace(new RegExp(pattern, 'g'), '[REDACTED]');
13
+ }
14
+ return result;
15
+ }
16
+
17
+ export function containsSecret(text: string, patterns: readonly string[]): boolean {
18
+ return patterns.some(p => new RegExp(p).test(text));
19
+ }
@@ -0,0 +1,80 @@
1
+ import type { Finding, FixAttempt, FixStatus } from '../findings/types.ts';
2
+ import { dedupFindings, findingContentKey } from '../findings/dedup.ts';
3
+
4
+ export interface StaticRule {
5
+ name: string;
6
+ severity: 'critical' | 'warning' | 'note';
7
+ check(touchedFiles: string[]): Promise<Finding[]>;
8
+ autofix?(finding: Finding): Promise<FixStatus>;
9
+ }
10
+
11
+ export interface StaticRulesPhaseInput {
12
+ touchedFiles: string[];
13
+ rules: StaticRule[];
14
+ }
15
+
16
+ export interface StaticRulesPhaseResult {
17
+ phase: 'static-rules';
18
+ status: 'pass' | 'warn' | 'fail';
19
+ findings: Finding[];
20
+ fixAttempts: FixAttempt[];
21
+ durationMs: number;
22
+ }
23
+
24
+ export async function runStaticRulesPhase(input: StaticRulesPhaseInput): Promise<StaticRulesPhaseResult> {
25
+ const start = Date.now();
26
+
27
+ const preFixFindings = dedupFindings(await runAllChecks(input.rules, input.touchedFiles));
28
+
29
+ const fixAttempts: FixAttempt[] = [];
30
+ let anyFixApplied = false;
31
+
32
+ for (const finding of preFixFindings) {
33
+ const rule = findRuleForFinding(input.rules, finding);
34
+ if (!rule?.autofix) continue;
35
+
36
+ if (finding.protectedPath) {
37
+ fixAttempts.push({
38
+ findingId: finding.id,
39
+ attemptedAt: new Date().toISOString(),
40
+ status: 'skipped',
41
+ notes: 'protected path',
42
+ });
43
+ continue;
44
+ }
45
+
46
+ const status = await rule.autofix(finding);
47
+ if (status === 'fixed') anyFixApplied = true;
48
+ fixAttempts.push({ findingId: finding.id, attemptedAt: new Date().toISOString(), status });
49
+ }
50
+
51
+ // Re-check is the source of truth for what persists after autofix.
52
+ // findings always returns preFixFindings so callers have a complete record;
53
+ // fixAttempts + re-check set-difference tells them what was resolved.
54
+ const postFixFindings = anyFixApplied
55
+ ? dedupFindings(await runAllChecks(input.rules, input.touchedFiles))
56
+ : preFixFindings;
57
+
58
+ const postFixKeys = new Set(postFixFindings.map(findingContentKey));
59
+ const isFixed = (f: Finding): boolean => !postFixKeys.has(findingContentKey(f));
60
+
61
+ const unfixedCritical = preFixFindings.some(f => f.severity === 'critical' && !isFixed(f));
62
+ const unfixedWarning = preFixFindings.some(f => f.severity === 'warning' && !isFixed(f));
63
+
64
+ let status: StaticRulesPhaseResult['status'];
65
+ if (unfixedCritical) status = 'fail';
66
+ else if (unfixedWarning) status = 'warn';
67
+ else status = 'pass';
68
+
69
+ return { phase: 'static-rules', status, findings: preFixFindings, fixAttempts, durationMs: Date.now() - start };
70
+ }
71
+
72
+ async function runAllChecks(rules: StaticRule[], files: string[]): Promise<Finding[]> {
73
+ const all: Finding[] = [];
74
+ for (const rule of rules) all.push(...(await rule.check(files)));
75
+ return all;
76
+ }
77
+
78
+ function findRuleForFinding(rules: StaticRule[], finding: Finding): StaticRule | undefined {
79
+ return rules.find(r => r.name === finding.category) ?? rules.find(r => finding.category.includes(r.name));
80
+ }
@@ -0,0 +1,51 @@
1
+ import { execSync } from 'node:child_process';
2
+ import type { Finding } from '../findings/types.ts';
3
+
4
+ export interface TestsPhaseInput {
5
+ touchedFiles: string[];
6
+ testCommand?: string | null;
7
+ cwd?: string;
8
+ }
9
+
10
+ export interface TestsPhaseResult {
11
+ phase: 'tests';
12
+ status: 'pass' | 'fail' | 'skip';
13
+ findings: Finding[];
14
+ output?: string;
15
+ durationMs: number;
16
+ }
17
+
18
+ export async function runTestsPhase(input: TestsPhaseInput): Promise<TestsPhaseResult> {
19
+ const start = Date.now();
20
+
21
+ if (!input.testCommand) {
22
+ return { phase: 'tests', status: 'skip', findings: [], durationMs: Date.now() - start };
23
+ }
24
+
25
+ let output: string | undefined;
26
+ try {
27
+ // shell:true is intentional — testCommand is developer-supplied config, supports quoted args + pipes.
28
+ output = execSync(input.testCommand, {
29
+ encoding: 'utf8',
30
+ cwd: input.cwd,
31
+ timeout: 120000,
32
+ shell: process.env.SHELL ?? "/bin/sh",
33
+ stdio: ['ignore', 'pipe', 'pipe'],
34
+ });
35
+ } catch {
36
+ const finding: Finding = {
37
+ id: 'tests-phase-fail',
38
+ source: 'static-rules',
39
+ severity: 'critical',
40
+ category: 'test-failure',
41
+ file: '<tests>',
42
+ message: `Test command failed: ${input.testCommand}`,
43
+ suggestion: 'Fix failing tests before merging',
44
+ protectedPath: false,
45
+ createdAt: new Date().toISOString(),
46
+ };
47
+ return { phase: 'tests', status: 'fail', findings: [finding], output: undefined, durationMs: Date.now() - start };
48
+ }
49
+
50
+ return { phase: 'tests', status: 'pass', findings: [], output, durationMs: Date.now() - start };
51
+ }