From 4dfef10ae56ae95e0da04a366b86ecee0b40e40a Mon Sep 17 00:00:00 2001 From: magicyuan876 <317617749@qq.com> Date: Tue, 19 May 2026 21:20:33 +0800 Subject: [PATCH] feat: add static checks integration (openspec check) - Add src/core/code-checker/ module with runner, detector, and types - Add openspec check CLI command for lint/type-check/static analysis - Extend project config schema with checks array - Auto-detect checks for TS/JS, Rust, Python, Go, Ruby, Java - Support Java offline tools: Checkstyle, SpotBugs, PMD (Maven/Gradle) - Add file glob filtering, JSON output, concurrency control - Add comprehensive unit and E2E tests - Update dogfood config.yaml --- openspec/config.yaml | 12 ++ src/cli/index.ts | 23 +++ src/commands/check.ts | 250 ++++++++++++++++++++++++ src/core/code-checker/detector.ts | 183 +++++++++++++++++ src/core/code-checker/index.ts | 3 + src/core/code-checker/runner.ts | 233 ++++++++++++++++++++++ src/core/code-checker/types.ts | 42 ++++ src/core/project-config.ts | 47 ++++- test/commands/check.test.ts | 162 +++++++++++++++ test/core/code-checker/detector.test.ts | 168 ++++++++++++++++ test/core/code-checker/runner.test.ts | 174 +++++++++++++++++ 11 files changed, 1295 insertions(+), 2 deletions(-) create mode 100644 src/commands/check.ts create mode 100644 src/core/code-checker/detector.ts create mode 100644 src/core/code-checker/index.ts create mode 100644 src/core/code-checker/runner.ts create mode 100644 src/core/code-checker/types.ts create mode 100644 test/commands/check.test.ts create mode 100644 test/core/code-checker/detector.test.ts create mode 100644 test/core/code-checker/runner.test.ts diff --git a/openspec/config.yaml b/openspec/config.yaml index 0b7ad5176..e87a3ac8b 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -34,3 +34,15 @@ rules: - Use existing constants and lists - don't invent detection mechanisms - Prefer explicit lookups over pattern matching or regex - If we generate it, we track it by name in a constant + +checks: + - name: "TypeScript type check" + command: "pnpm exec tsc --noEmit" + - name: "ESLint" + command: "pnpm exec eslint src/ --ext .ts" + files: + - "src/**/*.ts" + - name: "Prettier format check" + command: "pnpm exec prettier --check src/" + files: + - "src/**/*.ts" diff --git a/src/cli/index.ts b/src/cli/index.ts index baa3e48fa..38eb77837 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -11,6 +11,7 @@ import { ViewCommand } from '../core/view.js'; import { registerSpecCommand } from '../commands/spec.js'; import { ChangeCommand } from '../commands/change.js'; import { ValidateCommand } from '../commands/validate.js'; +import { CheckCommand } from '../commands/check.js'; import { ShowCommand } from '../commands/show.js'; import { CompletionCommand } from '../commands/completion.js'; import { FeedbackCommand } from '../commands/feedback.js'; @@ -321,6 +322,28 @@ program } }); +// Top-level check command +program + .command('check [change-name]') + .description('Run static checks (lint, type-check) against the implementation') + .option('--json', 'Output results as JSON') + .option('--concurrency ', 'Max concurrent checks', '3') + .option('--no-interactive', 'Disable interactive prompts') + .action(async (changeName?: string, options?: { json?: boolean; concurrency?: string; noInteractive?: boolean }) => { + try { + const checkCommand = new CheckCommand(); + await checkCommand.execute(changeName, { + json: options?.json, + concurrency: options?.concurrency, + noInteractive: options?.noInteractive, + }); + } catch (error) { + console.log(); + ora().fail(`Error: ${(error as Error).message}`); + process.exit(1); + } + }); + // Top-level show command program .command('show [item-name]') diff --git a/src/commands/check.ts b/src/commands/check.ts new file mode 100644 index 000000000..6220e7db4 --- /dev/null +++ b/src/commands/check.ts @@ -0,0 +1,250 @@ +import ora from 'ora'; +import path from 'path'; +import { promises as fs } from 'fs'; +import { runCheck, detectChecks, CheckResult, CheckReport } from '../core/code-checker/index.js'; +import { readProjectConfig } from '../core/project-config.js'; +import { getActiveChangeIds } from '../utils/item-discovery.js'; + +export interface CheckOptions { + json?: boolean; + concurrency?: string; + noInteractive?: boolean; +} + +export class CheckCommand { + async execute(changeName: string | undefined, options: CheckOptions = {}): Promise { + const projectRoot = process.cwd(); + + // Resolve change name + const resolvedChangeName = await this.resolveChangeName(changeName, options); + if (!resolvedChangeName) { + process.exitCode = 1; + return; + } + + // Read config + const config = readProjectConfig(projectRoot); + const checks = config?.checks ?? []; + + if (checks.length === 0) { + const detected = detectChecks(projectRoot); + if (options.json) { + console.log( + JSON.stringify( + { + change: resolvedChangeName, + checks: [], + summary: { total: 0, passed: 0, failed: 0, skipped: 0 }, + hint: detected?.message ?? 'No checks configured. Add checks to openspec/config.yaml', + detected: detected?.detected ?? [], + }, + null, + 2 + ) + ); + } else { + console.log('No checks configured in openspec/config.yaml'); + if (detected) { + console.log(`\n${detected.message}`); + console.log('\nSuggested checks:'); + for (const check of detected.detected) { + console.log(` - name: "${check.name}"`); + console.log(` command: "${check.command}"`); + } + } else { + console.log( + '\nExample configuration:\n\nchecks:\n - name: "TypeScript types"\n command: "pnpm exec tsc --noEmit"' + ); + } + } + process.exitCode = 0; + return; + } + + // Detect affected files for filtering + const affectedFiles = await this.detectAffectedFiles(projectRoot, resolvedChangeName); + + // Run checks + const concurrency = this.normalizeConcurrency(options.concurrency) ?? 3; + const spinner = !options.json && !options.noInteractive ? ora('Running checks...').start() : undefined; + + const queue: Array<() => Promise> = []; + + for (const entry of checks) { + queue.push(async () => { + const result = await runCheck(projectRoot, entry, { + affectedFiles: affectedFiles.length > 0 ? affectedFiles : undefined, + }); + return result; + }); + } + + const results: CheckResult[] = new Array(queue.length); + let index = 0; + let running = 0; + + await new Promise((resolve) => { + const next = () => { + while (running < concurrency && index < queue.length) { + const currentIndex = index++; + const task = queue[currentIndex]; + running++; + if (spinner) spinner.text = `Running checks (${currentIndex + 1}/${queue.length})...`; + task() + .then((res) => { + results[currentIndex] = res; + }) + .catch((error: any) => { + const message = error?.message || 'Unknown error'; + results[currentIndex] = { + name: checks[currentIndex]?.name ?? 'unknown', + passed: false, + durationMs: 0, + stdout: '', + stderr: message, + issues: [{ level: 'ERROR', path: 'check', message }], + }; + }) + .finally(() => { + running--; + if (index >= queue.length && running === 0) resolve(); + else next(); + }); + } + }; + next(); + }); + + spinner?.stop(); + + // Build report + const orderedResults = results.filter((r): r is CheckResult => Boolean(r)); + const report: CheckReport = { + changeName: resolvedChangeName, + checks: orderedResults, + summary: { + total: orderedResults.length, + passed: orderedResults.filter((r) => r.passed && !r.skipped).length, + failed: orderedResults.filter((r) => !r.passed && !r.skipped).length, + skipped: orderedResults.filter((r) => r.skipped).length, + }, + }; + + // Output + if (options.json) { + console.log(JSON.stringify(report, null, 2)); + } else { + this.printReport(report); + } + + process.exitCode = report.summary.failed > 0 ? 1 : 0; + } + + private async resolveChangeName( + changeName: string | undefined, + options: CheckOptions + ): Promise { + if (changeName) return changeName; + + const changes = await getActiveChangeIds(); + if (changes.length === 0) { + console.error('No active changes found.'); + return undefined; + } + if (changes.length === 1) { + return changes[0]; + } + + if (options.noInteractive) { + console.error('Multiple active changes found. Specify one explicitly:'); + for (const c of changes) { + console.error(` ${c}`); + } + return undefined; + } + + // Interactive pick + const { select } = await import('@inquirer/prompts'); + const choice = await select({ + message: 'Select a change to check', + choices: changes.map((c) => ({ name: c, value: c })), + }); + return choice; + } + + private async detectAffectedFiles(projectRoot: string, changeName: string): Promise { + const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName); + const files = new Set(); + + // Scan tasks.md and design.md for likely file paths + for (const filename of ['tasks.md', 'design.md']) { + const filePath = path.join(changeDir, filename); + try { + const content = await fs.readFile(filePath, 'utf-8'); + // Look for file paths in common patterns: + // - `src/foo.ts` + // - "src/foo.ts" + // - (src/foo.ts) + // - backtick-wrapped paths + const pathRegex = /(?:`[^`]+`|"[^"]+"|'[^']+'|\([\w/.-]+\)|[\w/.-]+\.[a-zA-Z0-9]+)/g; + const matches = content.match(pathRegex) ?? []; + for (const match of matches) { + const cleaned = match.replace(/^[`"'()]|[`"'()]$/g, ''); + // Heuristic: looks like a relative or absolute file path + if (cleaned.includes('/') || cleaned.includes('\\')) { + files.add(cleaned); + } + } + } catch { + // File doesn't exist, ignore + } + } + + return Array.from(files); + } + + private printReport(report: CheckReport): void { + console.log(`\nStatic checks for change: ${report.changeName}\n`); + + for (const check of report.checks) { + if (check.skipped) { + console.log(`⊘ ${check.name} (skipped${check.skipReason ? `: ${check.skipReason}` : ''})`); + continue; + } + if (check.passed) { + console.log(`✓ ${check.name} (${check.durationMs}ms)`); + } else { + console.error(`✗ ${check.name} (${check.durationMs}ms)`); + for (const issue of check.issues) { + const loc = issue.line ? `:${issue.line}${issue.column ? `:${issue.column}` : ''}` : ''; + console.error(` [${issue.level}] ${issue.path}${loc}: ${issue.message}`); + } + // If no structured issues but there's stderr, show first few lines + if (check.issues.length === 0 && check.stderr) { + const lines = check.stderr.split('\n').filter((l) => l.trim()); + for (const line of lines.slice(0, 5)) { + console.error(` ${line}`); + } + if (lines.length > 5) { + console.error(` ... and ${lines.length - 5} more lines`); + } + } + } + } + + console.log( + `\nSummary: ${report.summary.passed} passed, ${report.summary.failed} failed, ${report.summary.skipped} skipped (${report.summary.total} total)` + ); + + if (report.summary.failed > 0) { + console.error('\nFix the issues above and run again.'); + } + } + + private normalizeConcurrency(value?: string): number | undefined { + if (!value) return undefined; + const n = parseInt(value, 10); + if (Number.isNaN(n) || n <= 0) return undefined; + return n; + } +} diff --git a/src/core/code-checker/detector.ts b/src/core/code-checker/detector.ts new file mode 100644 index 000000000..9e94622c3 --- /dev/null +++ b/src/core/code-checker/detector.ts @@ -0,0 +1,183 @@ +import { existsSync, readFileSync } from 'fs'; +import path from 'path'; +import { CheckEntry } from './types.js'; + +export interface DetectedChecks { + detected: CheckEntry[]; + message: string; +} + +/** + * Inspect project root for common config files and suggest appropriate static checks. + * This is best-effort and intended to help users bootstrap their config.yaml. + */ +export function detectChecks(projectRoot: string): DetectedChecks | null { + const detected: CheckEntry[] = []; + + const hasFile = (name: string) => existsSync(path.join(projectRoot, name)); + + // TypeScript / JavaScript + if (hasFile('package.json')) { + if (hasFile('tsconfig.json')) { + detected.push({ + name: 'TypeScript type check', + command: 'npx tsc --noEmit', + }); + } + + const hasEslintConfig = + hasFile('.eslintrc.js') || + hasFile('.eslintrc.cjs') || + hasFile('.eslintrc.json') || + hasFile('.eslintrc.yaml') || + hasFile('.eslintrc.yml') || + hasFile('eslint.config.js') || + hasFile('eslint.config.mjs') || + hasFile('eslint.config.cjs') || + hasFile('eslint.config.ts'); + + if (hasEslintConfig) { + detected.push({ + name: 'ESLint', + command: 'npx eslint . --ext .js,.ts,.jsx,.tsx', + }); + } + + if (hasFile('prettier.config.js') || hasFile('.prettierrc') || hasFile('.prettierrc.json')) { + detected.push({ + name: 'Prettier format check', + command: 'npx prettier --check .', + }); + } + } + + // Rust + if (hasFile('Cargo.toml')) { + detected.push({ + name: 'Rust compile check', + command: 'cargo check', + }); + detected.push({ + name: 'Rust clippy', + command: 'cargo clippy -- -D warnings', + }); + } + + // Python + if (hasFile('pyproject.toml') || hasFile('setup.py') || hasFile('requirements.txt')) { + detected.push({ + name: 'Python ruff check', + command: 'ruff check .', + }); + if (hasFile('pyproject.toml')) { + detected.push({ + name: 'Python mypy', + command: 'mypy .', + }); + } + } + + // Go + if (hasFile('go.mod')) { + detected.push({ + name: 'Go vet', + command: 'go vet ./...', + }); + } + + // Java — Maven + if (hasFile('pom.xml')) { + detected.push({ + name: 'Java Maven compile', + command: 'mvn compile -q', + }); + + try { + const pomContent = readFileSync(path.join(projectRoot, 'pom.xml'), 'utf-8').toLowerCase(); + if ( + pomContent.includes('maven-checkstyle-plugin') || + pomContent.includes('checkstyle') && pomContent.includes('') + ) { + detected.push({ + name: 'Java Checkstyle', + command: 'mvn checkstyle:check -q', + }); + } + if ( + pomContent.includes('spotbugs-maven-plugin') || + pomContent.includes('findbugs-maven-plugin') || + pomContent.includes('com.github.spotbugs') + ) { + detected.push({ + name: 'Java SpotBugs', + command: 'mvn spotbugs:check -q', + }); + } + if ( + pomContent.includes('maven-pmd-plugin') || + (pomContent.includes('pmd') && pomContent.includes('')) + ) { + detected.push({ + name: 'Java PMD', + command: 'mvn pmd:check -q', + }); + } + } catch { + // ignore read errors + } + } + + // Java — Gradle + if (hasFile('build.gradle') || hasFile('build.gradle.kts')) { + const gradleCmd = process.platform === 'win32' ? 'gradlew' : './gradlew'; + detected.push({ + name: 'Java Gradle check', + command: `${gradleCmd} check`, + }); + + for (const gradleFile of ['build.gradle', 'build.gradle.kts']) { + if (!hasFile(gradleFile)) continue; + try { + const gradleContent = readFileSync(path.join(projectRoot, gradleFile), 'utf-8').toLowerCase(); + if (gradleContent.includes('checkstyle')) { + detected.push({ + name: 'Java Checkstyle (Gradle)', + command: `${gradleCmd} checkstyleMain checkstyleTest`, + }); + } + if (gradleContent.includes('spotbugs')) { + detected.push({ + name: 'Java SpotBugs (Gradle)', + command: `${gradleCmd} spotbugsMain`, + }); + } + if (gradleContent.includes('pmd')) { + detected.push({ + name: 'Java PMD (Gradle)', + command: `${gradleCmd} pmdMain`, + }); + } + break; // only read the first existing file + } catch { + // ignore read errors + } + } + } + + // Ruby + if (hasFile('Gemfile')) { + detected.push({ + name: 'RuboCop', + command: 'bundle exec rubocop', + }); + } + + if (detected.length === 0) { + return null; + } + + return { + detected, + message: `Detected ${detected.length} check(s) based on project files. Add them to openspec/config.yaml under the 'checks' key.`, + }; +} diff --git a/src/core/code-checker/index.ts b/src/core/code-checker/index.ts new file mode 100644 index 000000000..1673b9362 --- /dev/null +++ b/src/core/code-checker/index.ts @@ -0,0 +1,3 @@ +export * from './types.js'; +export * from './runner.js'; +export * from './detector.js'; diff --git a/src/core/code-checker/runner.ts b/src/core/code-checker/runner.ts new file mode 100644 index 000000000..59cd093d4 --- /dev/null +++ b/src/core/code-checker/runner.ts @@ -0,0 +1,233 @@ +import { spawn } from 'child_process'; +import path from 'path'; +import { CheckEntry, CheckResult, CheckIssue } from './types.js'; + +const DEFAULT_TIMEOUT_MS = 60_000; + +function parseIssuesFromOutput(stdout: string, stderr: string): CheckIssue[] { + const combined = stdout + '\n' + stderr; + const issues: CheckIssue[] = []; + + // Try to parse common error formats: + // TypeScript: src/file.ts(5,23): error TS2345: ... + // ESLint: /path/to/file.ts\n 5:23 error ... + // Cargo: error[E0000]: ...\n --> src/file.rs:5:23 + // Generic: file.ext:5:23: error: ... + + const lines = combined.split('\n'); + + for (const line of lines) { + // TypeScript style: file.ts(5,23): error TS2345: message + const tsMatch = line.match(/^(.+)\((\d+),(\d+)\):\s*(error|warning)\s+(.+)$/i); + if (tsMatch) { + issues.push({ + level: tsMatch[4].toLowerCase() === 'error' ? 'ERROR' : 'WARNING', + path: tsMatch[1].trim(), + line: parseInt(tsMatch[2], 10), + column: parseInt(tsMatch[3], 10), + message: tsMatch[5].trim(), + }); + continue; + } + + // ESLint style: 5:23 error Message rule-name + const eslintMatch = line.match(/^\s*(\d+):(\d+)\s+(error|warning)\s+(.+?)\s+\S+$/); + if (eslintMatch) { + issues.push({ + level: eslintMatch[3].toLowerCase() === 'error' ? 'ERROR' : 'WARNING', + path: '', // ESLint output often prefixes with file path on previous line; keep empty + line: parseInt(eslintMatch[1], 10), + column: parseInt(eslintMatch[2], 10), + message: eslintMatch[4].trim(), + }); + continue; + } + + // Cargo style: --> src/file.rs:5:23 + const cargoLocMatch = line.match(/^\s*-->\s+(.+):(\d+):(\d+)$/); + if (cargoLocMatch) { + // The message is usually on previous lines; we capture location only + issues.push({ + level: 'ERROR', + path: cargoLocMatch[1].trim(), + line: parseInt(cargoLocMatch[2], 10), + column: parseInt(cargoLocMatch[3], 10), + message: 'See preceding lines for error details', + }); + continue; + } + + // Generic style: path/to/file:5:23: error: message + const genericMatch = line.match(/^(.+?):(\d+):(\d+):\s*(error|warning):\s*(.+)$/i); + if (genericMatch) { + issues.push({ + level: genericMatch[4].toLowerCase() === 'error' ? 'ERROR' : 'WARNING', + path: genericMatch[1].trim(), + line: parseInt(genericMatch[2], 10), + column: parseInt(genericMatch[3], 10), + message: genericMatch[5].trim(), + }); + continue; + } + } + + return issues; +} + +export async function runCheck( + projectRoot: string, + entry: CheckEntry, + options?: { + timeoutMs?: number; + affectedFiles?: string[]; + } +): Promise { + const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + // If files filter is specified, skip when no affected files match + if (entry.files && entry.files.length > 0 && options?.affectedFiles) { + const hasMatch = entry.files.some((pattern) => + options.affectedFiles!.some((file) => matchGlob(file, pattern)) + ); + if (!hasMatch) { + return { + name: entry.name, + passed: true, + durationMs: 0, + stdout: '', + stderr: '', + issues: [], + skipped: true, + skipReason: 'No affected files matched the check patterns', + }; + } + } + + const start = Date.now(); + + return new Promise((resolve) => { + const child = spawn(entry.command, [], { + shell: true, + cwd: projectRoot, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + env: process.env, + }); + + let stdout = ''; + let stderr = ''; + let timedOut = false; + + const timeout = setTimeout(() => { + timedOut = true; + child.kill('SIGTERM'); + // Force kill after grace period + setTimeout(() => { + // `killed` only means a signal was sent; check actual process state + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL'); + } + }, 5000); + }, timeoutMs); + + child.stdout?.setEncoding('utf-8'); + child.stdout?.on('data', (chunk: string) => { + stdout += chunk; + }); + + child.stderr?.setEncoding('utf-8'); + child.stderr?.on('data', (chunk: string) => { + stderr += chunk; + }); + + child.on('error', (error) => { + clearTimeout(timeout); + const durationMs = Date.now() - start; + resolve({ + name: entry.name, + passed: false, + durationMs, + stdout, + stderr: stderr || error.message, + issues: [ + { + level: 'ERROR', + path: 'check', + message: `Failed to run check: ${error.message}`, + }, + ], + }); + }); + + child.on('close', (code) => { + clearTimeout(timeout); + const durationMs = Date.now() - start; + + if (timedOut) { + resolve({ + name: entry.name, + passed: false, + durationMs, + stdout, + stderr, + issues: [ + { + level: 'ERROR', + path: 'check', + message: `Check timed out after ${timeoutMs}ms`, + }, + ], + }); + return; + } + + const passed = code === 0; + const issues = passed ? [] : parseIssuesFromOutput(stdout, stderr); + + // If we couldn't parse structured issues, surface stderr as a single issue + if (!passed && issues.length === 0 && stderr.trim()) { + issues.push({ + level: 'ERROR', + path: 'check', + message: stderr.trim(), + }); + } + + resolve({ + name: entry.name, + passed, + durationMs, + stdout, + stderr, + issues, + }); + }); + }); +} + +/** + * Naive glob matcher sufficient for static check file filtering. + * Supports * and ** patterns. + */ +function matchGlob(filePath: string, pattern: string): boolean { + const normalizedFile = path.posix.normalize(filePath.replace(/\\/g, '/')); + const normalizedPattern = path.posix.normalize(pattern.replace(/\\/g, '/')); + + // Convert glob pattern to regex + // First substitute ** and * with placeholders, then escape regex metacharacters, + // then restore placeholders to their intended regex fragments + const escaped = normalizedPattern.replace(/[|\\{}()\[\]^$+?.]/g, '\\$&'); + let regexStr = escaped + .replace(/\*\*/g, '{{GLOBSTAR}}') + .replace(/\*/g, '{{STAR}}') + .replace(/\{\{GLOBSTAR\}\}/g, '.*') + .replace(/\{\{STAR\}\}/g, '[^/]*'); + + // Anchor if pattern doesn't start with wildcard + if (!regexStr.startsWith('.*')) { + regexStr = '(?:^|/)' + regexStr; + } + + const regex = new RegExp(regexStr); + return regex.test(normalizedFile); +} diff --git a/src/core/code-checker/types.ts b/src/core/code-checker/types.ts new file mode 100644 index 000000000..89439b4c6 --- /dev/null +++ b/src/core/code-checker/types.ts @@ -0,0 +1,42 @@ +export interface CheckEntry { + /** Human-readable name for this check */ + name: string; + /** Shell command to execute */ + command: string; + /** Optional glob patterns; if provided, check only runs when affected files match */ + files?: string[]; +} + +export interface CheckConfig { + checks: CheckEntry[]; +} + +export interface CheckIssue { + level: 'ERROR' | 'WARNING' | 'INFO'; + path: string; + message: string; + line?: number; + column?: number; +} + +export interface CheckResult { + name: string; + passed: boolean; + durationMs: number; + stdout: string; + stderr: string; + issues: CheckIssue[]; + skipped?: boolean; + skipReason?: string; +} + +export interface CheckReport { + changeName?: string; + checks: CheckResult[]; + summary: { + total: number; + passed: number; + failed: number; + skipped: number; + }; +} diff --git a/src/core/project-config.ts b/src/core/project-config.ts index 6c1ea04a5..a34bdb1d0 100644 --- a/src/core/project-config.ts +++ b/src/core/project-config.ts @@ -1,7 +1,8 @@ -import { existsSync, readFileSync, statSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import path from 'path'; import { parse as parseYaml } from 'yaml'; import { z } from 'zod'; +import type { CheckEntry } from './code-checker/types.js'; /** * Zod schema for project configuration. @@ -38,9 +39,23 @@ export const ProjectConfigSchema = z.object({ ) .optional() .describe('Per-artifact rules, keyed by artifact ID'), + + // Optional: static analysis checks to run against implementation + checks: z + .array( + z.object({ + name: z.string().min(1), + command: z.string().min(1), + files: z.array(z.string()).optional(), + }) + ) + .optional() + .describe('Static analysis checks (lint, type-check, etc.)'), }); -export type ProjectConfig = z.infer; +export type ProjectConfig = z.infer & { + checks?: CheckEntry[]; +}; const MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit @@ -152,6 +167,34 @@ export function readProjectConfig(projectRoot: string): ProjectConfig | null { } } + // Parse checks field using Zod — validate entries individually so one + // malformed item doesn't discard the whole array + if (raw.checks !== undefined) { + if (!Array.isArray(raw.checks)) { + console.warn(`Invalid 'checks' field in config (must be an array of {name, command, files?})`); + } else { + const checkEntryField = z.object({ + name: z.string().trim().min(1), + command: z.string().trim().min(1), + files: z.array(z.string().trim().min(1)).optional(), + }); + + const validChecks: CheckEntry[] = []; + raw.checks.forEach((candidate: unknown, index: number) => { + const parsed = checkEntryField.safeParse(candidate); + if (parsed.success) { + validChecks.push(parsed.data); + } else { + console.warn(`Invalid check at index ${index}, ignoring this check`); + } + }); + + if (validChecks.length > 0) { + config.checks = validChecks; + } + } + } + // Return partial config even if some fields failed return Object.keys(config).length > 0 ? (config as ProjectConfig) : null; } catch (error) { diff --git a/test/commands/check.test.ts b/test/commands/check.test.ts new file mode 100644 index 000000000..bc1c5f8b2 --- /dev/null +++ b/test/commands/check.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { runCLI } from '../helpers/run-cli.js'; + +describe('check command', () => { + const projectRoot = process.cwd(); + const testDir = path.join(projectRoot, 'test-check-command-tmp'); + const changesDir = path.join(testDir, 'openspec', 'changes'); + + beforeEach(async () => { + await fs.mkdir(changesDir, { recursive: true }); + + // Create a simple change + const changeDir = path.join(changesDir, 'feat-auth'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile( + path.join(changeDir, 'proposal.md'), + '# Change: feat-auth\n\n## Why\nAdd auth.\n\n## What Changes\n- Add login', + 'utf-8' + ); + await fs.writeFile( + path.join(changeDir, 'tasks.md'), + '- [ ] Implement `src/auth.ts`\n- [ ] Add tests in `src/auth.test.ts`', + 'utf-8' + ); + + // Create config.yaml with a passing check + const configPath = path.join(testDir, 'openspec', 'config.yaml'); + await fs.writeFile(configPath, 'schema: spec-driven\n', 'utf-8'); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + it('suggests checks when none configured', async () => { + const result = await runCLI(['check', 'feat-auth'], { cwd: testDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('No checks configured'); + }); + + it('runs a passing check and exits 0', async () => { + // Write config with a simple passing command + const configPath = path.join(testDir, 'openspec', 'config.yaml'); + await fs.writeFile( + configPath, + [ + 'schema: spec-driven', + 'checks:', + ' - name: "Echo check"', + ' command: "echo hello"', + ].join('\n'), + 'utf-8' + ); + + const result = await runCLI(['check', 'feat-auth'], { cwd: testDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Echo check'); + expect(result.stdout).toContain('passed'); + }); + + it('runs a failing check and exits 1', async () => { + const configPath = path.join(testDir, 'openspec', 'config.yaml'); + await fs.writeFile( + configPath, + [ + 'schema: spec-driven', + 'checks:', + ' - name: "Fail check"', + ' command: "node -e process.exit(1)"', + ].join('\n'), + 'utf-8' + ); + + const result = await runCLI(['check', 'feat-auth'], { cwd: testDir }); + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).toContain('Fail check'); + expect(result.stdout + result.stderr).toContain('failed'); + }); + + it('outputs JSON when --json is passed', async () => { + const configPath = path.join(testDir, 'openspec', 'config.yaml'); + await fs.writeFile( + configPath, + [ + 'schema: spec-driven', + 'checks:', + ' - name: "Echo check"', + ' command: "echo hello"', + ].join('\n'), + 'utf-8' + ); + + const result = await runCLI(['check', 'feat-auth', '--json'], { cwd: testDir }); + expect(result.exitCode).toBe(0); + const json = JSON.parse(result.stdout.trim()); + expect(json.changeName).toBe('feat-auth'); + expect(Array.isArray(json.checks)).toBe(true); + expect(json.checks[0].name).toBe('Echo check'); + expect(json.checks[0].passed).toBe(true); + expect(json.summary.total).toBe(1); + expect(json.summary.passed).toBe(1); + }); + + it('auto-selects single active change when no name given', async () => { + const configPath = path.join(testDir, 'openspec', 'config.yaml'); + await fs.writeFile( + configPath, + [ + 'schema: spec-driven', + 'checks:', + ' - name: "Echo check"', + ' command: "echo hello"', + ].join('\n'), + 'utf-8' + ); + + const result = await runCLI(['check'], { cwd: testDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('feat-auth'); + }); + + it('skips check when files filter does not match', async () => { + const configPath = path.join(testDir, 'openspec', 'config.yaml'); + await fs.writeFile( + configPath, + [ + 'schema: spec-driven', + 'checks:', + ' - name: "Rust check"', + ' command: "echo rust"', + ' files:', + ' - "**/*.rs"', + ].join('\n'), + 'utf-8' + ); + + const result = await runCLI(['check', 'feat-auth'], { cwd: testDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('skipped'); + }); + + it('shows helpful message with detected checks', async () => { + // Create a package.json so detector finds something + await fs.writeFile( + path.join(testDir, 'package.json'), + JSON.stringify({ name: 'test' }), + 'utf-8' + ); + await fs.writeFile( + path.join(testDir, 'tsconfig.json'), + JSON.stringify({ compilerOptions: {} }), + 'utf-8' + ); + + const result = await runCLI(['check', 'feat-auth'], { cwd: testDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('No checks configured'); + expect(result.stdout).toContain('TypeScript type check'); + }); +}); diff --git a/test/core/code-checker/detector.test.ts b/test/core/code-checker/detector.test.ts new file mode 100644 index 000000000..fc991ea81 --- /dev/null +++ b/test/core/code-checker/detector.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { detectChecks } from '../../../src/core/code-checker/detector.js'; + +describe('detectChecks', () => { + const projectRoot = process.cwd(); + const testDir = path.join(projectRoot, 'test-detector-tmp'); + + beforeEach(async () => { + await fs.mkdir(testDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + it('returns null when no recognizable project files exist', () => { + const result = detectChecks(testDir); + expect(result).toBeNull(); + }); + + it('detects TypeScript checks when package.json + tsconfig.json exist', async () => { + await fs.writeFile(path.join(testDir, 'package.json'), '{}', 'utf-8'); + await fs.writeFile(path.join(testDir, 'tsconfig.json'), '{}', 'utf-8'); + + const result = detectChecks(testDir); + expect(result).not.toBeNull(); + expect(result!.detected.some((c) => c.name === 'TypeScript type check')).toBe(true); + }); + + it('detects ESLint when eslint config exists', async () => { + await fs.writeFile(path.join(testDir, 'package.json'), '{}', 'utf-8'); + await fs.writeFile(path.join(testDir, '.eslintrc.json'), '{}', 'utf-8'); + + const result = detectChecks(testDir); + expect(result).not.toBeNull(); + expect(result!.detected.some((c) => c.name === 'ESLint')).toBe(true); + }); + + it('detects Rust checks when Cargo.toml exists', async () => { + await fs.writeFile(path.join(testDir, 'Cargo.toml'), '[package]', 'utf-8'); + + const result = detectChecks(testDir); + expect(result).not.toBeNull(); + expect(result!.detected.some((c) => c.name === 'Rust compile check')).toBe(true); + expect(result!.detected.some((c) => c.name === 'Rust clippy')).toBe(true); + }); + + it('detects Python checks when pyproject.toml exists', async () => { + await fs.writeFile(path.join(testDir, 'pyproject.toml'), '[project]', 'utf-8'); + + const result = detectChecks(testDir); + expect(result).not.toBeNull(); + expect(result!.detected.some((c) => c.name === 'Python ruff check')).toBe(true); + expect(result!.detected.some((c) => c.name === 'Python mypy')).toBe(true); + }); + + it('detects Go checks when go.mod exists', async () => { + await fs.writeFile(path.join(testDir, 'go.mod'), 'module test', 'utf-8'); + + const result = detectChecks(testDir); + expect(result).not.toBeNull(); + expect(result!.detected.some((c) => c.name === 'Go vet')).toBe(true); + }); + + it('detects Java Maven checks when pom.xml exists', async () => { + await fs.writeFile(path.join(testDir, 'pom.xml'), '', 'utf-8'); + + const result = detectChecks(testDir); + expect(result).not.toBeNull(); + expect(result!.detected.some((c) => c.name === 'Java Maven compile')).toBe(true); + }); + + it('detects Java Checkstyle from Maven pom.xml', async () => { + const pom = ` + + + + org.apache.maven.plugins + maven-checkstyle-plugin + + + + `; + await fs.writeFile(path.join(testDir, 'pom.xml'), pom, 'utf-8'); + + const result = detectChecks(testDir); + expect(result).not.toBeNull(); + expect(result!.detected.some((c) => c.name === 'Java Checkstyle')).toBe(true); + }); + + it('detects Java SpotBugs from Maven pom.xml', async () => { + const pom = ` + + + + com.github.spotbugs + spotbugs-maven-plugin + + + + `; + await fs.writeFile(path.join(testDir, 'pom.xml'), pom, 'utf-8'); + + const result = detectChecks(testDir); + expect(result).not.toBeNull(); + expect(result!.detected.some((c) => c.name === 'Java SpotBugs')).toBe(true); + }); + + it('detects Java PMD from Maven pom.xml', async () => { + const pom = ` + + + + org.apache.maven.plugins + maven-pmd-plugin + + + + `; + await fs.writeFile(path.join(testDir, 'pom.xml'), pom, 'utf-8'); + + const result = detectChecks(testDir); + expect(result).not.toBeNull(); + expect(result!.detected.some((c) => c.name === 'Java PMD')).toBe(true); + }); + + it('detects Java Gradle checks when build.gradle exists', async () => { + await fs.writeFile(path.join(testDir, 'build.gradle'), "plugins { id 'java' }", 'utf-8'); + + const result = detectChecks(testDir); + expect(result).not.toBeNull(); + expect(result!.detected.some((c) => c.name === 'Java Gradle check')).toBe(true); + }); + + it('detects Java offline checks from build.gradle', async () => { + const gradle = `plugins { + id 'java' + id 'checkstyle' + id 'com.github.spotbugs' + id 'pmd' + }`; + await fs.writeFile(path.join(testDir, 'build.gradle'), gradle, 'utf-8'); + + const result = detectChecks(testDir); + expect(result).not.toBeNull(); + expect(result!.detected.some((c) => c.name === 'Java Checkstyle (Gradle)')).toBe(true); + expect(result!.detected.some((c) => c.name === 'Java SpotBugs (Gradle)')).toBe(true); + expect(result!.detected.some((c) => c.name === 'Java PMD (Gradle)')).toBe(true); + }); + + it('detects Java Gradle checks when build.gradle.kts exists', async () => { + await fs.writeFile(path.join(testDir, 'build.gradle.kts'), "plugins { java }", 'utf-8'); + + const result = detectChecks(testDir); + expect(result).not.toBeNull(); + expect(result!.detected.some((c) => c.name === 'Java Gradle check')).toBe(true); + }); + + it('detects Ruby checks when Gemfile exists', async () => { + await fs.writeFile(path.join(testDir, 'Gemfile'), "source 'https://rubygems.org'", 'utf-8'); + + const result = detectChecks(testDir); + expect(result).not.toBeNull(); + expect(result!.detected.some((c) => c.name === 'RuboCop')).toBe(true); + }); +}); diff --git a/test/core/code-checker/runner.test.ts b/test/core/code-checker/runner.test.ts new file mode 100644 index 000000000..e15b439ad --- /dev/null +++ b/test/core/code-checker/runner.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'events'; +import { runCheck } from '../../../src/core/code-checker/runner.js'; + +// Mock child_process spawn +vi.mock('child_process', () => ({ + spawn: vi.fn(), +})); + +import { spawn } from 'child_process'; + +function createMockChild(exitCode: number | null, stdout: string, stderr: string, error?: Error) { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdout.setEncoding = vi.fn(); + child.stderr.setEncoding = vi.fn(); + child.kill = vi.fn(() => true); + child.killed = false; + + const timers: ReturnType[] = []; + + // Simulate async data and close + timers.push(setTimeout(() => { + if (error) { + child.emit('error', error); + return; + } + if (stdout) { + const chunks = stdout.split('\n'); + chunks.forEach((chunk, i) => { + timers.push(setTimeout(() => child.stdout.emit('data', chunk + (i < chunks.length - 1 ? '\n' : '')), 0)); + }); + } + if (stderr) { + const chunks = stderr.split('\n'); + chunks.forEach((chunk, i) => { + timers.push(setTimeout(() => child.stderr.emit('data', chunk + (i < chunks.length - 1 ? '\n' : '')), 0)); + }); + } + timers.push(setTimeout(() => child.emit('close', exitCode), 10)); + }, 0)); + + // Attach cleanup helper + child._cleanup = () => timers.forEach(clearTimeout); + + return child; +} + +describe('runCheck', () => { + let lastMockChild: any; + + beforeEach(() => { + vi.clearAllMocks(); + lastMockChild = undefined; + }); + + afterEach(() => { + if (lastMockChild?._cleanup) { + lastMockChild._cleanup(); + } + }); + + it('returns passed=true when command exits 0', async () => { + lastMockChild = createMockChild(0, 'All good', ''); + vi.mocked(spawn).mockReturnValue(lastMockChild); + + const result = await runCheck('/project', { name: 'Test', command: 'echo ok' }); + expect(result.passed).toBe(true); + expect(result.issues).toHaveLength(0); + expect(result.stdout).toContain('All good'); + }); + + it('returns passed=false when command exits non-zero', async () => { + lastMockChild = createMockChild(1, '', 'Something went wrong'); + vi.mocked(spawn).mockReturnValue(lastMockChild); + + const result = await runCheck('/project', { name: 'Test', command: 'false' }); + expect(result.passed).toBe(false); + expect(result.issues.length).toBeGreaterThan(0); + expect(result.issues[0].message).toContain('Something went wrong'); + }); + + it('parses TypeScript error format', async () => { + lastMockChild = createMockChild(2, 'src/index.ts(5,23): error TS2345: Argument of type string is not assignable.', ''); + vi.mocked(spawn).mockReturnValue(lastMockChild); + + const result = await runCheck('/project', { name: 'TSC', command: 'tsc' }); + expect(result.passed).toBe(false); + expect(result.issues).toHaveLength(1); + expect(result.issues[0].path).toBe('src/index.ts'); + expect(result.issues[0].line).toBe(5); + expect(result.issues[0].column).toBe(23); + expect(result.issues[0].level).toBe('ERROR'); + }); + + it('parses ESLint error format', async () => { + lastMockChild = createMockChild(1, '', ' 5:23 error Unexpected any @typescript-eslint/no-explicit-any'); + vi.mocked(spawn).mockReturnValue(lastMockChild); + + const result = await runCheck('/project', { name: 'ESLint', command: 'eslint' }); + expect(result.passed).toBe(false); + expect(result.issues).toHaveLength(1); + expect(result.issues[0].line).toBe(5); + expect(result.issues[0].column).toBe(23); + expect(result.issues[0].level).toBe('ERROR'); + }); + + it('skips check when files filter does not match affected files', async () => { + lastMockChild = createMockChild(0, '', ''); + vi.mocked(spawn).mockReturnValue(lastMockChild); + + const result = await runCheck('/project', { + name: 'Rust', + command: 'cargo check', + files: ['**/*.rs'], + }, { + affectedFiles: ['src/main.ts'], + }); + + expect(result.skipped).toBe(true); + expect(result.passed).toBe(true); + expect(spawn).not.toHaveBeenCalled(); + }); + + it('runs check when files filter matches affected files', async () => { + lastMockChild = createMockChild(0, '', ''); + vi.mocked(spawn).mockReturnValue(lastMockChild); + + const result = await runCheck('/project', { + name: 'Rust', + command: 'cargo check', + files: ['**/*.rs'], + }, { + affectedFiles: ['src/main.rs'], + }); + + expect(result.skipped).toBeUndefined(); + expect(spawn).toHaveBeenCalled(); + }); + + it('handles spawn error gracefully', async () => { + lastMockChild = createMockChild(null, '', '', new Error('ENOENT: tsc not found')); + vi.mocked(spawn).mockReturnValue(lastMockChild); + + const result = await runCheck('/project', { name: 'TSC', command: 'tsc' }); + expect(result.passed).toBe(false); + expect(result.issues[0].message).toContain('ENOENT'); + }); + + it('times out after custom timeout', async () => { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdout.setEncoding = vi.fn(); + child.stderr.setEncoding = vi.fn(); + child.killed = false; + child.kill = vi.fn((signal: string) => { + child.killed = true; + // Simulate process termination after kill + setTimeout(() => child.emit('close', signal === 'SIGKILL' ? null : 1), 10); + return true; + }); + lastMockChild = child; + + vi.mocked(spawn).mockReturnValue(child); + + const promise = runCheck('/project', { name: 'Slow', command: 'sleep 100' }, { timeoutMs: 50 }); + + const result = await promise; + expect(result.passed).toBe(false); + expect(result.issues[0].message).toContain('timed out'); + }); +});