diff --git a/package.json b/package.json index bf59b68..0630189 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mesadev/saguaro", - "version": "0.4.16", + "version": "0.4.17", "description": "AI code review that enforces your team's rules during development", "license": "Apache-2.0", "type": "module", diff --git a/src/adapter/rules.ts b/src/adapter/rules.ts index e21a83f..20605ed 100644 --- a/src/adapter/rules.ts +++ b/src/adapter/rules.ts @@ -1,11 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import { - loadValidatedConfig, - resolveApiKey, - resolveModelForReview, - resolveModelFromResolvedConfig, -} from '../config/model-config.js'; +import { resolveGeneratorBackend } from '../generator/llm-backend.js'; import { findRepoRoot } from '../git/git.js'; import { generateRule } from '../rules/generator.js'; import type { PreviewRuleResult } from '../rules/preview.js'; @@ -176,20 +171,12 @@ export function writeGeneratedRules(rules: RulePolicy[]): WriteGeneratedRulesRes export async function generateRuleAdapter(request: GenerateRuleAdapterRequest): Promise { const repoRoot = findRepoRoot(); const target = analyzeTarget({ targetPath: request.target, repoRoot }); - - const config = loadValidatedConfig(); - const apiKey = resolveApiKey(config); - const modelName = resolveModelForReview(config, 'rules'); - const model = resolveModelFromResolvedConfig({ - provider: config.model.provider, - model: modelName, - apiKey, - }); + const backend = resolveGeneratorBackend(); const result = await generateRule({ intent: request.intent, target, - model, + backend, title: request.title, severity: request.severity, repoRoot, diff --git a/src/ai/agent-runner.ts b/src/ai/agent-runner.ts index d5f9ab2..9267f13 100644 --- a/src/ai/agent-runner.ts +++ b/src/ai/agent-runner.ts @@ -3,7 +3,7 @@ import type { AgentRunner, AgentRunnerOptions, AgentRunnerResult } from '../core const FIVE_MINUTES_MS = 5 * 60 * 1000; const TEN_MB = 10 * 1024 * 1024; -const DEFAULT_MAX_TURNS = 10; +const DEFAULT_MAX_TURNS = 30; interface SpawnCliOptions { command: string; diff --git a/src/cli/lib/rules.ts b/src/cli/lib/rules.ts index 6ca99e7..b6ce561 100644 --- a/src/cli/lib/rules.ts +++ b/src/cli/lib/rules.ts @@ -10,12 +10,7 @@ import { locateRulesDirectoryAdapter, validateRulesAdapter, } from '../../adapter/rules.js'; -import { - loadValidatedConfig, - resolveApiKey, - resolveModelForReview, - resolveModelFromResolvedConfig, -} from '../../config/model-config.js'; +import { resolveGeneratorBackend } from '../../generator/llm-backend.js'; import { findRepoRoot } from '../../git/git.js'; import { generateRule } from '../../rules/generator.js'; import { previewRule } from '../../rules/preview.js'; @@ -231,19 +226,12 @@ const createRule = async (argv: CreateRuleArgv): Promise => { spinner.start('Generating rule...'); try { - const config = loadValidatedConfig(); - const apiKey = resolveApiKey(config); - const modelName = resolveModelForReview(config, 'rules'); - const model = resolveModelFromResolvedConfig({ - provider: config.model.provider, - model: modelName, - apiKey, - }); + const backend = resolveGeneratorBackend(); const result = await generateRule({ intent, target, - model, + backend, title: argv.title, severity: argv.severity as Severity | undefined, repoRoot, diff --git a/src/generator/index.ts b/src/generator/index.ts index 0b38e7d..3de4c3d 100644 --- a/src/generator/index.ts +++ b/src/generator/index.ts @@ -1,3 +1,4 @@ +export { CliLlmBackend, type GeneratorLlmBackend, resolveGeneratorBackend, SdkLlmBackend } from './llm-backend.js'; export { orchestrate as generateRules } from './orchestrator.js'; export type { GenerateRulesOptions, diff --git a/src/generator/llm-backend.ts b/src/generator/llm-backend.ts new file mode 100644 index 0000000..e6d60f2 --- /dev/null +++ b/src/generator/llm-backend.ts @@ -0,0 +1,217 @@ +import type { LanguageModel } from 'ai'; +import { generateObject, generateText } from 'ai'; +import type { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; +import { + createClaudeCliRunner, + createCodexCliRunner, + createGeminiCliRunner, + isCliAvailable, +} from '../ai/agent-runner.js'; +import type { ModelProvider } from '../config/model-config.js'; +import { loadValidatedConfig, resolveApiKey, resolveModelFromResolvedConfig } from '../config/model-config.js'; +import type { AgentRunner } from '../core/types.js'; +import { logger } from '../util/logger.js'; + +// --------------------------------------------------------------------------- +// Interface +// --------------------------------------------------------------------------- + +export interface StructuredResult { + object: T; + inputTokens: number; + outputTokens: number; +} + +export interface TextResult { + text: string; +} + +export interface GeneratorLlmBackend { + generateStructured(options: { + system: string; + prompt: string; + schema: T; + abortSignal?: AbortSignal; + }): Promise>>; + + generatePlainText(options: { system: string; prompt: string }): Promise; +} + +// --------------------------------------------------------------------------- +// CLI Implementation (default — uses claude -p / codex / gemini) +// --------------------------------------------------------------------------- + +export class CliLlmBackend implements GeneratorLlmBackend { + constructor( + private runner: AgentRunner, + private cwd: string + ) {} + + async generateStructured(options: { + system: string; + prompt: string; + schema: T; + abortSignal?: AbortSignal; + }): Promise>> { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- zod-to-json-schema expects Zod v3 types + const jsonSchema = JSON.stringify(zodToJsonSchema(options.schema as any), null, 2); + const jsonInstruction = `\n\nCRITICAL: Your response must be ONLY a raw JSON object. No preamble, no explanation, no markdown fences, no "here is" or "I will" text. The very first character of your response MUST be \`{\`. Respond with nothing but valid JSON matching this schema:\n\n${jsonSchema}`; + + const result = await this.runner.execute({ + systemPrompt: options.system + jsonInstruction, + prompt: options.prompt, + cwd: this.cwd, + abortSignal: options.abortSignal, + }); + + const jsonText = extractJson(result.output); + let parsed: unknown; + try { + parsed = JSON.parse(jsonText); + } catch { + throw new Error(`CLI backend returned invalid JSON: ${jsonText.slice(0, 200)}...`); + } + + const validated = options.schema.safeParse(parsed); + if (!validated.success) { + const issues = validated.error.issues + .map((i: z.ZodIssue) => `${i.path.join('.') || '(root)'}: ${i.message}`) + .join('; '); + throw new Error(`CLI backend output failed schema validation: ${issues}`); + } + + return { object: validated.data, inputTokens: 0, outputTokens: 0 }; + } + + async generatePlainText(options: { system: string; prompt: string }): Promise { + const result = await this.runner.execute({ + systemPrompt: options.system, + prompt: options.prompt, + cwd: this.cwd, + }); + return { text: result.output }; + } +} + +// --------------------------------------------------------------------------- +// SDK Implementation (fallback — requires API key) +// --------------------------------------------------------------------------- + +export class SdkLlmBackend implements GeneratorLlmBackend { + constructor(private model: LanguageModel) {} + + async generateStructured(options: { + system: string; + prompt: string; + schema: T; + abortSignal?: AbortSignal; + }): Promise>> { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generateObject has complex generics that don't align with our generic constraint + const result = await generateObject({ + model: this.model, + schema: options.schema as any, + system: options.system, + prompt: options.prompt, + abortSignal: options.abortSignal, + }); + return { + object: result.object as z.infer, + inputTokens: result.usage.inputTokens ?? 0, + outputTokens: result.usage.outputTokens ?? 0, + }; + } + + async generatePlainText(options: { system: string; prompt: string }): Promise { + const result = await generateText({ + model: this.model, + system: options.system, + prompt: options.prompt, + }); + return { text: result.text }; + } +} + +// --------------------------------------------------------------------------- +// Factory — CLI default, SDK fallback +// --------------------------------------------------------------------------- + +const PROVIDER_CLI: Record = { + anthropic: 'claude', + openai: 'codex', + google: 'gemini', +}; + +function createCliRunnerForProvider(provider: ModelProvider): AgentRunner { + switch (provider) { + case 'anthropic': + return createClaudeCliRunner(); + case 'openai': + return createCodexCliRunner(); + case 'google': + return createGeminiCliRunner(); + } +} + +/** + * Resolve the LLM backend for rule generation. + * Default: CLI agent (claude -p, codex, gemini). + * Fallback: AI SDK with API key (only when no CLI agent is installed). + */ +export function resolveGeneratorBackend(configPath?: string): GeneratorLlmBackend { + const config = loadValidatedConfig(configPath); + const provider = config.model.provider; + const cliCommand = PROVIDER_CLI[provider]; + + if (isCliAvailable(cliCommand)) { + logger.debug(`[generator] Using CLI backend (${cliCommand})`); + const runner = createCliRunnerForProvider(provider); + return new CliLlmBackend(runner, process.cwd()); + } + + // Fallback: SDK (requires API key) + logger.debug(`[generator] CLI ${cliCommand} not available, falling back to SDK`); + const apiKey = resolveApiKey(config); + const model = resolveModelFromResolvedConfig({ + provider, + model: config.model.name, + apiKey, + }); + return new SdkLlmBackend(model); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function extractJson(text: string): string { + let cleaned = text.trim(); + + // Strip markdown code fences + const fenceMatch = cleaned.match(/```(?:json)?\s*\n([\s\S]*?)\n\s*```/); + if (fenceMatch) { + cleaned = fenceMatch[1]!.trim(); + } + + // Find the first { or [ — skip any preamble text the LLM added + const objStart = cleaned.indexOf('{'); + const arrStart = cleaned.indexOf('['); + let start = -1; + if (objStart === -1) start = arrStart; + else if (arrStart === -1) start = objStart; + else start = Math.min(objStart, arrStart); + + if (start > 0) { + cleaned = cleaned.slice(start); + } + + // Trim trailing non-JSON (e.g. trailing explanation after the closing brace) + const lastBrace = cleaned.lastIndexOf('}'); + const lastBracket = cleaned.lastIndexOf(']'); + const end = Math.max(lastBrace, lastBracket); + if (end > 0) { + cleaned = cleaned.slice(0, end + 1); + } + + return cleaned.trim(); +} diff --git a/src/generator/orchestrator.ts b/src/generator/orchestrator.ts index 581d213..05775f6 100644 --- a/src/generator/orchestrator.ts +++ b/src/generator/orchestrator.ts @@ -1,17 +1,17 @@ import path from 'node:path'; -import type { LanguageModel } from 'ai'; -import { generateObject } from 'ai'; import yaml from 'js-yaml'; import { Minimatch } from 'minimatch'; import { z } from 'zod'; -import { loadReviewAdapterConfig, resolveModelFromResolvedConfig } from '../config/model-config.js'; import { findRepoRoot } from '../git/git.js'; import { buildIndex } from '../indexer/build.js'; import { JsonIndexStore } from '../indexer/store.js'; import type { CodebaseIndex } from '../indexer/types.js'; import { STARTER_RULES } from '../templates/starter-rules.js'; import type { RulePolicy } from '../types/types.js'; +import { logger } from '../util/logger.js'; import { computeArchitecturalContext } from './architecture.js'; +import type { GeneratorLlmBackend } from './llm-backend.js'; +import { resolveGeneratorBackend } from './llm-backend.js'; import { scanAndSelectFiles } from './scanner.js'; import { RuleProposalSchema } from './schemas.js'; import { synthesizeRules } from './synthesis.js'; @@ -78,8 +78,7 @@ const UNSCOPED_GLOB_PATTERNS = ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx', '* export async function orchestrate(options: GenerateRulesOptions): Promise { const cwd = options.cwd ?? process.cwd(); const startMs = Date.now(); - const { modelConfig } = loadReviewAdapterConfig(options.configPath); - const model = resolveModelFromResolvedConfig(modelConfig); + const backend = resolveGeneratorBackend(options.configPath); options.onProgress?.({ type: 'indexing' }); const repoRoot = findRepoRoot(cwd); const saguaroCacheDir = path.join(repoRoot, '.saguaro', 'cache'); @@ -102,7 +101,7 @@ export async function orchestrate(options: GenerateRulesOptions): Promise { const promises = scanResult.zones.map((zone) => - analyzeZone(zone, scanResult, model, index, cwdOffset, onProgress, abortSignal) + analyzeZone(zone, scanResult, backend, index, cwdOffset, onProgress, abortSignal) ); return Promise.all(promises); } @@ -184,7 +183,7 @@ async function analyzeZonesInParallel( async function analyzeZone( zone: ZoneConfig, scanResult: ScanResult, - model: LanguageModel, + backend: GeneratorLlmBackend, index: CodebaseIndex | null, cwdOffset: string, onProgress: GenerateRulesOptions['onProgress'], @@ -202,32 +201,43 @@ async function analyzeZone( const prompt = buildZonePrompt(zone, scanResult, target, index, cwdOffset); - const result = await generateObject({ - model, - schema: z.object({ - rules: z.array(RuleProposalSchema), - }), - system: ZONE_ANALYSIS_SYSTEM, - prompt, - abortSignal, - }); + try { + const result = await backend.generateStructured({ + system: ZONE_ANALYSIS_SYSTEM, + prompt, + schema: z.object({ + rules: z.array(RuleProposalSchema), + }), + abortSignal, + }); - const rules = result.object.rules.slice(0, target + 5); - const durationMs = Date.now() - startMs; + const rules = result.object.rules.slice(0, target + 5); + const durationMs = Date.now() - startMs; - onProgress?.({ - type: 'zone_completed', - zoneName: zone.name, - rulesProposed: rules.length, - durationMs, - }); + onProgress?.({ + type: 'zone_completed', + zoneName: zone.name, + rulesProposed: rules.length, + durationMs, + }); - return { - zoneName: zone.name, - rules, - inputTokens: result.usage.inputTokens ?? 0, - outputTokens: result.usage.outputTokens ?? 0, - }; + return { + zoneName: zone.name, + rules, + inputTokens: result.inputTokens, + outputTokens: result.outputTokens, + }; + } catch (err) { + if (abortSignal?.aborted) throw err; + logger.debug(`Zone ${zone.name} analysis failed, skipping: ${err instanceof Error ? err.message : String(err)}`); + onProgress?.({ + type: 'zone_completed', + zoneName: zone.name, + rulesProposed: 0, + durationMs: Date.now() - startMs, + }); + return { zoneName: zone.name, rules: [], inputTokens: 0, outputTokens: 0 }; + } } function buildZonePrompt( diff --git a/src/generator/schemas.ts b/src/generator/schemas.ts index 443190b..21e0825 100644 --- a/src/generator/schemas.ts +++ b/src/generator/schemas.ts @@ -11,8 +11,12 @@ export const RuleProposalSchema = z.object({ violations: z.array(z.string()).describe('Code snippets (10-120 chars) showing violations'), compliant: z.array(z.string()).describe('Code snippets (10-120 chars) showing correct code'), }) + .optional() .describe('Concrete code examples of violations and compliant patterns'), - tags: z.array(z.string()).describe('Lowercase hyphenated tags for categorization (e.g., "architecture", "security")'), + tags: z + .array(z.string()) + .optional() + .describe('Lowercase hyphenated tags for categorization (e.g., "architecture", "security")'), }); export const TriageDecisionSchema = z.object({ diff --git a/src/generator/synthesis.ts b/src/generator/synthesis.ts index a258c59..b77b8ec 100644 --- a/src/generator/synthesis.ts +++ b/src/generator/synthesis.ts @@ -1,9 +1,8 @@ -import type { LanguageModel } from 'ai'; -import { generateObject } from 'ai'; import { Minimatch } from 'minimatch'; import { z } from 'zod'; import type { RulePolicy } from '../types/types.js'; import { logger } from '../util/logger.js'; +import type { GeneratorLlmBackend } from './llm-backend.js'; import { RuleProposalSchema, TriageDecisionSchema } from './schemas.js'; const TRIAGE_PROMPT = `You are a senior engineering lead reviewing candidate code review rules generated from different zones of a codebase. @@ -59,11 +58,11 @@ interface SynthesisResult { export async function synthesizeRules(options: { candidates: RulePolicy[]; - model: LanguageModel; + backend: GeneratorLlmBackend; allSourceFilePaths: string[]; abortSignal?: AbortSignal; }): Promise { - const { candidates, model, allSourceFilePaths, abortSignal } = options; + const { candidates, backend, allSourceFilePaths, abortSignal } = options; if (candidates.length === 0) { return { rules: [], inputTokens: 0, outputTokens: 0 }; @@ -73,7 +72,7 @@ export async function synthesizeRules(options: { // Step 1: Triage — decisions only, tiny output const similarityClusters = computeGlobSimilarityClusters(candidates, allSourceFilePaths); - const triage = await triageRules({ candidates, model, similarityClusters, abortSignal }); + const triage = await triageRules({ candidates, backend, similarityClusters, abortSignal }); if (!triage) { // Fallback: triage failed, keep all candidates return { rules: candidates, inputTokens: 0, outputTokens: 0 }; @@ -100,7 +99,7 @@ export async function synthesizeRules(options: { const mergeResults = await mergeRulesInParallel({ mergeGroups: triage.decisions.merge, candidatesByID, - model, + backend, abortSignal, }); @@ -222,11 +221,11 @@ interface TriageResult { async function triageRules(options: { candidates: RulePolicy[]; - model: LanguageModel; + backend: GeneratorLlmBackend; similarityClusters: SimilarityCluster[]; abortSignal?: AbortSignal; }): Promise { - const { candidates, model, similarityClusters, abortSignal } = options; + const { candidates, backend, similarityClusters, abortSignal } = options; const candidatesSummary = formatCandidatesSummary(candidates); const allIDs = candidates.map((r) => r.id); @@ -243,18 +242,17 @@ ${allIDs.join(', ')} Classify every candidate ID into exactly one bucket: keep, drop, or merge.`; try { - const result = await generateObject({ - model, - schema: TriageDecisionSchema, + const result = await backend.generateStructured({ system: TRIAGE_PROMPT, prompt: userPrompt, + schema: TriageDecisionSchema, abortSignal, }); return { decisions: result.object, - inputTokens: result.usage.inputTokens ?? 0, - outputTokens: result.usage.outputTokens ?? 0, + inputTokens: result.inputTokens, + outputTokens: result.outputTokens, }; } catch (err) { if (abortSignal?.aborted) throw err; @@ -274,17 +272,17 @@ interface MergeResults { async function mergeRulesInParallel(options: { mergeGroups: MergeGroup[]; candidatesByID: Map; - model: LanguageModel; + backend: GeneratorLlmBackend; abortSignal?: AbortSignal; }): Promise { - const { mergeGroups, candidatesByID, model, abortSignal } = options; + const { mergeGroups, candidatesByID, backend, abortSignal } = options; if (mergeGroups.length === 0) { return { rules: [], inputTokens: 0, outputTokens: 0 }; } const results = await Promise.all( - mergeGroups.map((group) => mergeSingleGroup({ group, candidatesByID, model, abortSignal })) + mergeGroups.map((group) => mergeSingleGroup({ group, candidatesByID, backend, abortSignal })) ); return { @@ -297,10 +295,10 @@ async function mergeRulesInParallel(options: { async function mergeSingleGroup(options: { group: MergeGroup; candidatesByID: Map; - model: LanguageModel; + backend: GeneratorLlmBackend; abortSignal?: AbortSignal; }): Promise<{ rule: RulePolicy; inputTokens: number; outputTokens: number }> { - const { group, candidatesByID, model, abortSignal } = options; + const { group, candidatesByID, backend, abortSignal } = options; const target = candidatesByID.get(group.target); const sources = group.sources.map((id) => candidatesByID.get(id)).filter(Boolean) as RulePolicy[]; @@ -335,18 +333,17 @@ Merge reason: ${group.reason} Write one unified rule using the target's ID (${target.id}).`; try { - const result = await generateObject({ - model, - schema: z.object({ rule: RuleProposalSchema }), + const result = await backend.generateStructured({ system: MERGE_PROMPT, prompt: userPrompt, + schema: z.object({ rule: RuleProposalSchema }), abortSignal, }); return { rule: result.object.rule, - inputTokens: result.usage.inputTokens ?? 0, - outputTokens: result.usage.outputTokens ?? 0, + inputTokens: result.inputTokens, + outputTokens: result.outputTokens, }; } catch (err) { if (abortSignal?.aborted) throw err; diff --git a/src/rules/generator.ts b/src/rules/generator.ts index 803e1ed..147af98 100644 --- a/src/rules/generator.ts +++ b/src/rules/generator.ts @@ -1,6 +1,5 @@ -import type { LanguageModel } from 'ai'; -import { generateText } from 'ai'; import yaml from 'js-yaml'; +import type { GeneratorLlmBackend } from '../generator/llm-backend.js'; import { STARTER_RULES } from '../templates/starter-rules.js'; import type { RulePolicy, Severity } from '../types/types.js'; import { type CodebaseSnippet, toKebabCase } from '../util/constants.js'; @@ -22,7 +21,7 @@ interface BuildPromptInput { export interface GenerateRuleRequest { intent: string; target: TargetAnalysis; - model: LanguageModel; + backend: GeneratorLlmBackend; title?: string; severity?: Severity; repoRoot: string; @@ -284,8 +283,7 @@ export async function generateRule(request: GenerateRuleRequest): Promise 0) { + cleaned = cleaned.slice(yamlStart); + } + return cleaned; }