Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 62 additions & 12 deletions src/claude/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/

import type { DiffAnalysis } from '../diff/analyzer.js'
import type { BulletproofConfig } from '../config.js'
import type { BulletproofConfig, RulesGuidance } from '../config.js'

/**
* Generate the system prompt for Claude
Expand All @@ -27,6 +27,66 @@ Do NOT read files from other users' directories.`
return base
}

/**
* Generate the rules guidance section based on config
*/
function generateRulesGuidanceSection(
guidance: RulesGuidance | undefined,
rulesFile: string
): string {
if (!guidance) {
return `Run \`git diff HEAD~1 --name-only\` to see changed files, then review them against ${rulesFile}.`
}

const sections: string[] = []

sections.push(
`Run \`git diff HEAD~1 --name-only\` to see changed files, then review them against ${rulesFile}:`
)

if (guidance.apiRoutes && guidance.apiRoutes.length > 0) {
sections.push('')
sections.push('**For API routes, verify:**')
guidance.apiRoutes.forEach((rule) => {
sections.push(`- ${rule}`)
})
}

if (guidance.reactComponents && guidance.reactComponents.length > 0) {
sections.push('')
sections.push('**For React components, verify:**')
guidance.reactComponents.forEach((rule) => {
sections.push(`- ${rule}`)
})
}

if (guidance.hooks && guidance.hooks.length > 0) {
sections.push('')
sections.push('**For hooks, verify:**')
guidance.hooks.forEach((rule) => {
sections.push(`- ${rule}`)
})
}

if (guidance.testFiles && guidance.testFiles.length > 0) {
sections.push('')
sections.push('**For test files, verify:**')
guidance.testFiles.forEach((rule) => {
sections.push(`- ${rule}`)
})
}

if (guidance.allFiles && guidance.allFiles.length > 0) {
sections.push('')
sections.push('**For ALL files, verify:**')
guidance.allFiles.forEach((rule) => {
sections.push(`- ${rule}`)
})
}

return sections.join('\n')
}

/**
* Generate the merge conflict resolution prompt
*/
Expand Down Expand Up @@ -151,17 +211,7 @@ ${checksToRun.join('\n')}
${coverageInstructions}

## RULES COMPLIANCE CHECK (Step 1):
Run \`git diff HEAD~1 --name-only\` to see changed files, then review them against ${config.rulesFile}:

**For API routes, verify:**
- Uses proper authentication
- Returns proper HTTP status codes (200, 201, 400, 401, 403, 404, 500)
- Has corresponding tests

**For ALL files, verify:**
- No hardcoded values (use constants)
- TypeScript types are explicit (no \`any\`)
- Follows existing code patterns
${generateRulesGuidanceSection(config.rulesGuidance, config.rulesFile)}

Report after compliance check:
✓ RULES COMPLIANCE PASSED or ✗ RULES COMPLIANCE: [list violations]
Expand Down
170 changes: 150 additions & 20 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,74 @@ export interface CommandsConfig {
testCoverageRelated: string
}

/**
* Pattern that can be either a glob string or a regex pattern string
* Regex patterns should be prefixed with "regex:" (e.g., "regex:^src/app/.*\\.(ts|tsx)$")
*/
export type CoveragePattern = string

/**
* Coverage scope configuration - patterns for files that require coverage
*/
export interface CoverageScopeConfig {
/** Glob patterns for files that should be included in coverage */
include: string[]
/** Glob patterns for files that should be excluded from coverage */
exclude: string[]
/**
* Patterns for files that should be included in coverage.
* Can be glob patterns (e.g., "src/**\/*.ts") or regex patterns prefixed with "regex:"
* (e.g., "regex:^src/app/.*\\.(ts|tsx)$")
*/
include: CoveragePattern[]
/**
* Patterns for files that should be excluded from coverage.
* Can be glob patterns or regex patterns prefixed with "regex:"
*/
exclude: CoveragePattern[]
}

/**
* Preset configurations for common project types
*/
export type CoveragePreset = 'default' | 'vapi-nextjs'

/**
* Vapi Next.js preset coverage scope patterns (matching atlas implementation)
*/
export const VAPI_NEXTJS_COVERAGE_SCOPE: CoverageScopeConfig = {
include: [
'regex:^src/app/.*\\.(ts|tsx)$',
'regex:^src/components/.*\\.(ts|tsx)$',
'regex:^src/hooks/.*\\.(ts|tsx)$',
'regex:^src/lib/.*\\.ts$',
],
exclude: [
'regex:^src/test/',
'regex:\\.test\\.(ts|tsx)$',
'regex:\\.spec\\.(ts|tsx)$',
'regex:/types/',
'regex:\\.d\\.ts$',
'regex:/layout\\.tsx$',
'regex:/page\\.tsx$',
'regex:^src/components/ui/[^/]+\\.tsx$',
'regex:^src/lib/api/generate-mcp-tools\\.ts$',
'regex:/index\\.ts$',
'regex:^src/app/api/auth/\\[\\.\\.\\.nextauth\\]/route\\.ts$',
],
}

/**
* Detailed rules guidance for the Claude agent
* These provide specific conventions for different file types
*/
export interface RulesGuidance {
/** Rules for API routes */
apiRoutes?: string[]
/** Rules for React components */
reactComponents?: string[]
/** Rules for hooks */
hooks?: string[]
/** Rules for test files */
testFiles?: string[]
/** Rules for all files */
allFiles?: string[]
}

/**
Expand All @@ -73,6 +133,9 @@ export interface BulletproofConfig {
/** Coverage scope patterns */
coverageScope: CoverageScopeConfig

/** Use a preset for coverage scope (overrides coverageScope if set) */
coveragePreset?: CoveragePreset

/** Default checks to run */
checks: ChecksConfig

Expand All @@ -87,6 +150,46 @@ export interface BulletproofConfig {

/** Additional prompt instructions */
additionalInstructions?: string

/** Detailed rules guidance for different file types */
rulesGuidance?: RulesGuidance
}

/**
* Default Vapi-specific rules guidance (matching atlas implementation)
*/
export const DEFAULT_VAPI_RULES_GUIDANCE: RulesGuidance = {
apiRoutes: [
'Uses `authenticateRequestWithPermissions` for auth',
'Emits SSE events for all create/update/delete operations (`emitEvent`)',
'Returns proper HTTP status codes (200, 201, 400, 401, 403, 404, 500)',
'Has corresponding tests in src/test/api/',
],
reactComponents: [
'Uses existing UI components from src/components/ui/ (Button, Card, Badge, etc.)',
'Uses `cn()` for className merging',
'Uses `@/` path aliases (never relative imports like ../../)',
'Loading states use skeleton components',
'Detail/nested pages use `DetailPageHeader` with back button',
'Mutations use `useOptimisticMutation` hook',
'Links prefetch on hover',
],
hooks: [
'Custom hooks follow use* naming convention',
'Uses proper TypeScript types (no `any`)',
],
testFiles: [
'Located in src/test/ mirroring source path',
'Tests auth (401), validation (400), happy path, and error cases',
'Uses vi.mock() before imports',
'Has beforeEach with vi.resetAllMocks()',
],
allFiles: [
'No hardcoded values (use constants)',
'Console logs use context prefixes like [API], [SSE], [Auth]',
'TypeScript types are explicit (no `any`)',
'Import order follows: React > Next > External > Internal hooks > Components > Utils > Types',
],
}

/**
Expand Down Expand Up @@ -130,6 +233,7 @@ export const DEFAULT_CONFIG: BulletproofConfig = {
testCoverageRelated: 'npm run test:coverage:related',
},
rulesFile: '.cursorrules',
rulesGuidance: DEFAULT_VAPI_RULES_GUIDANCE,
}

/**
Expand Down Expand Up @@ -174,6 +278,25 @@ export function loadConfigFile(cwd: string): Partial<BulletproofConfig> | null {
return null
}

/**
* Get coverage scope based on preset or explicit config
*/
function getCoverageScope(
partial: Partial<BulletproofConfig> | null,
defaults: BulletproofConfig
): CoverageScopeConfig {
// If preset is specified, use it
if (partial?.coveragePreset === 'vapi-nextjs') {
return VAPI_NEXTJS_COVERAGE_SCOPE
}

// Otherwise use explicit config or defaults
return {
include: partial?.coverageScope?.include ?? defaults.coverageScope.include,
exclude: partial?.coverageScope?.exclude ?? defaults.coverageScope.exclude,
}
}

/**
* Merge partial config with defaults
*/
Expand All @@ -192,10 +315,8 @@ export function mergeConfig(
...defaults.coverageThresholds,
...partial.coverageThresholds,
},
coverageScope: {
include: partial.coverageScope?.include ?? defaults.coverageScope.include,
exclude: partial.coverageScope?.exclude ?? defaults.coverageScope.exclude,
},
coverageScope: getCoverageScope(partial, defaults),
coveragePreset: partial.coveragePreset,
checks: {
...defaults.checks,
...partial.checks,
Expand All @@ -207,6 +328,7 @@ export function mergeConfig(
rulesFile: partial.rulesFile ?? defaults.rulesFile,
systemPrompt: partial.systemPrompt,
additionalInstructions: partial.additionalInstructions,
rulesGuidance: partial.rulesGuidance ?? defaults.rulesGuidance,
}
}

Expand All @@ -218,26 +340,34 @@ export function loadConfig(cwd: string = process.cwd()): BulletproofConfig {
return mergeConfig(fileConfig)
}

/**
* Convert a coverage pattern (glob or regex) to a RegExp
*/
function patternToRegex(pattern: CoveragePattern): RegExp {
// Check if it's a regex pattern (prefixed with "regex:")
if (pattern.startsWith('regex:')) {
return new RegExp(pattern.slice(6))
}

// Otherwise, convert glob to regex
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*\*/g, '<<<GLOBSTAR>>>')
.replace(/\*/g, '[^/]*')
.replace(/<<<GLOBSTAR>>>/g, '.*')
return new RegExp(`^${escaped}$`)
}

/**
* Check if a file matches coverage scope patterns
*/
export function isInCoverageScope(
file: string,
scope: CoverageScopeConfig
): boolean {
// Convert glob patterns to regex for matching
const toRegex = (pattern: string): RegExp => {
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*\*/g, '<<<GLOBSTAR>>>')
.replace(/\*/g, '[^/]*')
.replace(/<<<GLOBSTAR>>>/g, '.*')
return new RegExp(`^${escaped}$`)
}

// Check if file matches any include pattern
const matchesInclude = scope.include.some((pattern) =>
toRegex(pattern).test(file)
patternToRegex(pattern).test(file)
)

if (!matchesInclude) {
Expand All @@ -246,7 +376,7 @@ export function isInCoverageScope(

// Check if file matches any exclude pattern
const matchesExclude = scope.exclude.some((pattern) =>
toRegex(pattern).test(file)
patternToRegex(pattern).test(file)
)

return !matchesExclude
Expand Down
4 changes: 4 additions & 0 deletions src/guardian.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
* Main orchestrator class that coordinates all checks and Claude agent.
*/

import * as dotenv from 'dotenv'
import { execSync } from 'child_process'

// Load environment variables from .env.local (matching atlas behavior)
dotenv.config({ path: '.env.local' })
import type { BulletproofConfig } from './config.js'
import { loadConfig } from './config.js'
import { analyzeDiff, type DiffAnalysis } from './diff/analyzer.js'
Expand Down