diff --git a/README.md b/README.md index 5526e6c..6802b1f 100644 --- a/README.md +++ b/README.md @@ -84,10 +84,12 @@ openswarm review --max --concurrency 8 # widen the fan-out — areas auto-spli # (legacy spray) · --issues (set parent) · --fallback # · --out · --dry-run (print the plan) -# CI / test gate auto-fix -openswarm fix # Run the checks (lint/typecheck/build/test from package.json), - # fan a fix-worker out over the failures, re-run until green +# CI / test gate auto-fix (npm / Cargo / Python auto-detected) +openswarm fix # Run the checks (package.json scripts, or cargo check+test, + # or ruff/mypy/pytest), fan a fix-worker out over the + # failures, re-run until green openswarm fix --checks lint,test # only these checks · --concurrency · --rounds (default 3) + # any language: put {"checks": {"test": "pytest -x"}} in openswarm.json # Code Registry & BS Detector openswarm check --scan # Scan repo → register all entities @@ -282,7 +284,7 @@ openswarm dash # open the web dashboard (:3847) - **Autonomous Pipeline** — Cron-driven heartbeat fetches Linear issues, runs Worker/Reviewer pair loops, and updates issue state automatically - **Worker/Reviewer Pairs** — Multi-iteration code generation with automated review, testing, and documentation stages - **Codebase Audit (`review --max`)** — fans reviewer subagents out over directory-shaped areas (auto-split to fill `--concurrency`), aggregates a deduped verdict into a markdown report, and synthesizes ≤10 cohesive Linear issues via a PM agent. `--fix` sends a worker per flagged area to apply the fixes in the working tree. Language-agnostic; codex usage-limit aware with automatic `claude` fallback -- **CI / test gate auto-fix (`openswarm fix`)** — runs the project's objective checks (lint / typecheck / build / test), groups the failures by file into areas, fans a fix-worker out over each, then **re-runs the checks and repeats until green** (or the round budget). Deterministic convergence — unlike the review fix pass, it verifies its own work +- **CI / test gate auto-fix (`openswarm fix`)** — runs the project's objective checks (lint / typecheck / build / test), groups the failures by file into areas, fans a fix-worker out over each, then **re-runs the checks and repeats until green** (or the round budget). Deterministic convergence — unlike the review fix pass, it verifies its own work. Multi-language: auto-detects npm scripts, `Cargo.toml` (`cargo check`/`test`, clippy on request), and Python tooling (`ruff`/`mypy`/`pytest`, gated on the repo's config); any other toolchain via a `"checks"` map in `openswarm.json` - **Decision Engine** — Scope validation, rate limiting, priority-based task selection, and workflow mapping - **Cognitive Memory** — LanceDB vector store with Xenova/multilingual-e5-base embeddings for long-term recall across sessions - **Repo Knowledge Loop** — workers learn each repository over time: task outcomes (success patterns, review-rejection pitfalls) are stored per-repo and recalled into the next worker prompt diff --git a/src/cli.ts b/src/cli.ts index 04f5796..34fcf6a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -308,7 +308,7 @@ program .command('fix') .description('Run the CI/test checks and fan a fix-worker out over the failures, re-running until green') .option('--path ', 'Project path (default: cwd)') - .option('--checks ', 'Comma list of checks (lint,type,build,test); default: all detected in package.json', (v) => v.split(',').map((s) => s.trim()).filter(Boolean)) + .option('--checks ', 'Comma list of checks (lint,type,build,test or script/config names); default: auto-detected from openswarm.json "checks", package.json scripts, Cargo.toml, or Python config', (v) => v.split(',').map((s) => s.trim()).filter(Boolean)) .option('--concurrency ', 'Max fix workers in flight (default 4)', (v) => parseInt(v, 10)) .option('--rounds ', 'Max check → fix → re-check rounds (default 3)', (v) => parseInt(v, 10)) .option('--adapter ', 'Adapter override for the fix workers') diff --git a/src/cli/fixCommand.test.ts b/src/cli/fixCommand.test.ts index 134030a..107fde4 100644 --- a/src/cli/fixCommand.test.ts +++ b/src/cli/fixCommand.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { resolveChecks, + resolveProjectChecks, parseFailingFiles, deriveFixAreas, buildFixCheckTask, @@ -8,6 +9,7 @@ import { type Check, type CheckOutcome, type FixArea, + type ProjectProbe, } from './fixCommand.js'; describe('resolveChecks (INT-2267)', () => { @@ -30,6 +32,65 @@ describe('resolveChecks (INT-2267)', () => { }); }); +describe('resolveProjectChecks (INT-2303)', () => { + const py = { marker: true, ruff: true, mypy: true, pytest: true }; + + it('prefers openswarm.json checks over everything, preserving key order, via sh -c', () => { + const probe: ProjectProbe = { + configChecks: { test: 'pytest -x', lint: 'ruff check .' }, + npmScripts: { test: 'vitest' }, + cargo: true, + python: py, + }; + const checks = resolveProjectChecks(probe); + expect(checks.map((c) => c.key)).toEqual(['test', 'lint']); + expect(checks[0]).toEqual({ key: 'test', program: 'sh', args: ['-c', 'pytest -x'], display: 'pytest -x' }); + }); + + it('filters config checks by --checks, accepting raw keys and aliases', () => { + const probe: ProjectProbe = { configChecks: { typecheck: 'mypy .', test: 'pytest' } }; + expect(resolveProjectChecks(probe, ['type']).map((c) => c.key)).toEqual(['typecheck']); + expect(resolveProjectChecks(probe, ['test', 'nope']).map((c) => c.key)).toEqual(['test']); + }); + + it('falls back to npm scripts when no config checks', () => { + const probe: ProjectProbe = { npmScripts: { test: 'vitest run' }, cargo: true }; + expect(resolveProjectChecks(probe)).toEqual([{ key: 'test', program: 'npm', args: ['run', 'test'] }]); + }); + + it('resolves Cargo defaults (check + test, skipping clippy/build)', () => { + const checks = resolveProjectChecks({ cargo: true }); + expect(checks.map((c) => c.key)).toEqual(['typecheck', 'test']); + expect(checks[0]).toEqual({ key: 'typecheck', program: 'cargo', args: ['check', '--all-targets'] }); + expect(checks[1]).toEqual({ key: 'test', program: 'cargo', args: ['test'] }); + }); + + it('resolves clippy/build for Cargo when explicitly requested', () => { + const checks = resolveProjectChecks({ cargo: true }, ['lint', 'build']); + expect(checks[0].args[0]).toBe('clippy'); + expect(checks[1].args).toEqual(['build']); + }); + + it('gates Python defaults on per-tool config', () => { + const all = resolveProjectChecks({ python: py }); + expect(all.map((c) => c.key)).toEqual(['lint', 'typecheck', 'test']); + expect(all.map((c) => c.program)).toEqual(['ruff', 'mypy', 'pytest']); + + const pytestOnly = resolveProjectChecks({ python: { marker: true, ruff: false, mypy: false, pytest: true } }); + expect(pytestOnly.map((c) => c.key)).toEqual(['test']); + }); + + it('lets --checks bypass the Python gating', () => { + const checks = resolveProjectChecks({ python: { marker: true, ruff: false, mypy: false, pytest: false } }, ['lint']); + expect(checks).toEqual([{ key: 'lint', program: 'ruff', args: ['check', '.'] }]); + }); + + it('returns [] for an empty probe', () => { + expect(resolveProjectChecks({})).toEqual([]); + expect(resolveProjectChecks({ python: { marker: false, ruff: true, mypy: true, pytest: true } })).toEqual([]); + }); +}); + describe('parseFailingFiles (INT-2267)', () => { it('extracts paths from tsc / vitest / eslint output and dedupes', () => { const out = [ @@ -46,6 +107,16 @@ describe('parseFailingFiles (INT-2267)', () => { expect(parseFailingFiles('all good, no files here')).toEqual([]); expect(parseFailingFiles('/usr/lib/node/x.js failed')).toEqual([]); }); + + it('extracts paths from cargo and pytest output (INT-2303)', () => { + const out = [ + 'error[E0308]: mismatched types', + ' --> src/main.rs:12:9', + 'FAILED tests/test_auth.py::test_login - AssertionError', + 'tests/test_auth.py:42: in test_login', + ].join('\n'); + expect(parseFailingFiles(out).sort()).toEqual(['src/main.rs', 'tests/test_auth.py']); + }); }); describe('deriveFixAreas (INT-2267)', () => { @@ -78,6 +149,13 @@ describe('buildFixCheckTask (INT-2267)', () => { expect(t).toContain('npm run typecheck'); expect(t.toLowerCase()).toContain('do not'); }); + + it('uses the display command for sh -c config checks (INT-2303)', () => { + const cfg: Check[] = [{ key: 'test', program: 'sh', args: ['-c', 'pytest -x'], display: 'pytest -x' }]; + const t = buildFixCheckTask(area, cfg); + expect(t).toContain('Re-run `pytest -x`'); + expect(t).not.toContain('sh -c'); + }); }); describe('runFixCommand loop (INT-2267)', () => { diff --git a/src/cli/fixCommand.ts b/src/cli/fixCommand.ts index c598659..3692b30 100644 --- a/src/cli/fixCommand.ts +++ b/src/cli/fixCommand.ts @@ -22,11 +22,13 @@ import { startProgressHeartbeat } from './reviewProgress.js'; import { status, c } from '../support/colors.js'; import type { AdapterName } from '../adapters/types.js'; -/** A resolvable objective check (`npm run lint`, `npm test`, …). */ +/** A resolvable objective check (`npm run lint`, `cargo test`, `pytest`, …). */ export interface Check { key: string; program: string; args: string[]; + /** Human-readable command (for prompts) when program+args is a shell wrapper. */ + display?: string; } /** The result of running one check, with the source files its output blamed. */ @@ -85,6 +87,134 @@ export function readScripts(cwd: string): Record { } } +// ── Multi-language check detection (INT-2303) ─────────────────────────────── + +/** What `probeProject` found on disk; `resolveProjectChecks` turns it into checks. */ +export interface ProjectProbe { + /** `openswarm.json` `"checks"` map (key → shell command) — any language, highest priority. */ + configChecks?: Record; + /** `package.json` scripts. */ + npmScripts?: Record; + /** `Cargo.toml` present. */ + cargo?: boolean; + /** Python project markers + which tools are configured. */ + python?: { marker: boolean; ruff: boolean; mypy: boolean; pytest: boolean }; +} + +/** Cargo check table. Defaults skip clippy (optional component) and build (subsumed by test). */ +const CARGO_CHECKS: Record> = { + lint: { program: 'cargo', args: ['clippy', '--no-deps', '--', '-D', 'warnings'] }, + typecheck: { program: 'cargo', args: ['check', '--all-targets'] }, + build: { program: 'cargo', args: ['build'] }, + test: { program: 'cargo', args: ['test'] }, +}; +const CARGO_DEFAULT_ORDER = ['typecheck', 'test']; + +/** Python check table. Defaults include only the tools the repo is configured for. */ +const PYTHON_CHECKS: Record> = { + lint: { program: 'ruff', args: ['check', '.'] }, + typecheck: { program: 'mypy', args: ['.'] }, + test: { program: 'pytest', args: [] }, +}; + +/** Build checks from a canonical-key table. `requested` bypasses the default gating. */ +function resolveTableChecks( + table: Record>, + defaults: string[], + requested?: string[], +): Check[] { + const keys = requested?.length ? requested.map((k) => KNOWN_CHECKS[k.trim()] ?? k.trim()) : defaults; + const seen = new Set(); + const checks: Check[] = []; + for (const key of keys) { + if (seen.has(key) || !(key in table)) continue; + seen.add(key); + checks.push({ key, ...table[key] }); + } + return checks; +} + +/** + * Read the project markers `resolveProjectChecks` needs: `openswarm.json` `checks`, + * package.json scripts, `Cargo.toml`, and Python config (pyproject/setup/requirements + * plus per-tool markers for ruff / mypy / pytest). + */ +export function probeProject(cwd: string): ProjectProbe { + const read = (f: string): string => { + try { + return readFileSync(join(cwd, f), 'utf8'); + } catch { + return ''; + } + }; + const has = (f: string): boolean => existsSync(join(cwd, f)); + + let configChecks: Record | undefined; + try { + const meta = JSON.parse(read('openswarm.json') || '{}'); + if (meta.checks && typeof meta.checks === 'object' && !Array.isArray(meta.checks)) { + const entries = Object.entries(meta.checks).filter((e): e is [string, string] => typeof e[1] === 'string'); + if (entries.length) configChecks = Object.fromEntries(entries); + } + } catch { + // tolerate a malformed openswarm.json — fall through to auto-detection + } + + const pyproject = read('pyproject.toml'); + const setupCfg = read('setup.cfg'); + return { + configChecks, + npmScripts: readScripts(cwd), + cargo: has('Cargo.toml'), + python: { + marker: !!pyproject || has('setup.py') || !!setupCfg || has('requirements.txt'), + ruff: pyproject.includes('[tool.ruff') || has('ruff.toml') || has('.ruff.toml'), + mypy: pyproject.includes('[tool.mypy') || setupCfg.includes('[mypy]') || has('mypy.ini') || has('.mypy.ini'), + pytest: + pyproject.includes('[tool.pytest') || has('pytest.ini') || has('conftest.py') || has('tests') || has('test'), + }, + }; +} + +/** + * Resolve checks across ecosystems, first non-empty source wins: + * openswarm.json `checks` (any language, explicit) → package.json scripts → + * Cargo.toml → Python markers. Mixed repos disambiguate via openswarm.json. Pure. + */ +export function resolveProjectChecks(probe: ProjectProbe, requested?: string[]): Check[] { + const cfg = probe.configChecks ?? {}; + if (Object.keys(cfg).length) { + const keys = requested?.length + ? requested.map((k) => (k.trim() in cfg ? k.trim() : KNOWN_CHECKS[k.trim()] ?? k.trim())) + : Object.keys(cfg); + const seen = new Set(); + const checks: Check[] = []; + for (const key of keys) { + if (seen.has(key) || !(key in cfg)) continue; + seen.add(key); + checks.push({ key, program: 'sh', args: ['-c', cfg[key]], display: cfg[key] }); + } + if (checks.length) return checks; + } + + const fromNpm = probe.npmScripts ? resolveChecks(probe.npmScripts, requested) : []; + if (fromNpm.length) return fromNpm; + + if (probe.cargo) { + const fromCargo = resolveTableChecks(CARGO_CHECKS, CARGO_DEFAULT_ORDER, requested); + if (fromCargo.length) return fromCargo; + } + + if (probe.python?.marker) { + const py = probe.python; + const defaults = [py.ruff ? 'lint' : '', py.mypy ? 'typecheck' : '', py.pytest ? 'test' : ''].filter(Boolean); + const fromPython = resolveTableChecks(PYTHON_CHECKS, defaults, requested); + if (fromPython.length) return fromPython; + } + + return []; +} + const SOURCE_EXT = 'tsx?|jsx?|mjs|cjs|py|rs|go|java|kt|kts|scala|rb|php|swift|c|cc|cpp|cxx|h|hpp|cs|ex|exs|lua|jl|zig|nim'; const FILE_RE = new RegExp(String.raw`(?:^|[\s('"\`])((?:[\w.\-]+\/)*[\w.\-]+\.(?:${SOURCE_EXT}))`, 'g'); @@ -139,7 +269,7 @@ export function deriveFixAreas(failing: CheckOutcome[], concurrency: number, max /** Build the fix worker's task: the failing output + a hard "verify, don't cheat" rule. */ export function buildFixCheckTask(area: FixArea, checks: Check[]): string { - const verify = checks.map((c) => `${c.program} ${c.args.join(' ')}`).join(' && '); + const verify = checks.map((c) => c.display ?? [c.program, ...c.args].join(' ')).join(' && '); return [ `The project's checks are failing. Apply the MINIMAL edits needed to make them pass.`, area.files.length @@ -256,7 +386,7 @@ export async function runFixCommand(opts: FixOptions = {}, deps: FixDeps = {}): const exists = deps.exists ?? ((p, base) => existsSync(join(base, p))); const runCheck = deps.runCheck ?? defaultRunCheck; const runFixWorker = deps.runFixWorker ?? ((area, checks, onLog) => defaultRunFixWorker(area, checks, cwd, opts, onLog)); - const checks = deps.checks ?? resolveChecks(readScripts(cwd), opts.checks); + const checks = deps.checks ?? resolveProjectChecks(probeProject(cwd), opts.checks); const recordOutcome = deps.recordOutcome ?? (async (info) => { @@ -269,7 +399,12 @@ export async function runFixCommand(opts: FixOptions = {}, deps: FixDeps = {}): }); if (!checks.length) { - log(status.warn('No checks resolved — add lint/typecheck/build/test scripts to package.json, or pass --checks.')); + log( + status.warn( + 'No checks resolved — no package.json scripts, Cargo.toml, or Python tool config detected. ' + + 'Add lint/typecheck/build/test scripts, define "checks" in openswarm.json (key → shell command), or pass --checks.', + ), + ); return { green: false, rounds: [], reason: 'no-checks' }; } diff --git a/src/support/repoMetadata.test.ts b/src/support/repoMetadata.test.ts index ba42d9a..7bc8432 100644 --- a/src/support/repoMetadata.test.ts +++ b/src/support/repoMetadata.test.ts @@ -45,6 +45,15 @@ describe('loadRepoMetadata', () => { expect(meta?.linear?.teamKey).toBe('RES'); }); + it('parses the checks map (INT-2303)', async () => { + writeFileSync( + join(dir, REPO_METADATA_FILENAME), + JSON.stringify({ schemaVersion: 1, checks: { lint: 'ruff check .', test: 'pytest -x' } }), + ); + const meta = await loadRepoMetadata(dir); + expect(meta?.checks).toEqual({ lint: 'ruff check .', test: 'pytest -x' }); + }); + it('throws RepoMetadataError on invalid JSON', async () => { writeFileSync(join(dir, REPO_METADATA_FILENAME), '{ not json'); await expect(loadRepoMetadata(dir)).rejects.toBeInstanceOf(RepoMetadataError); diff --git a/src/support/repoMetadata.ts b/src/support/repoMetadata.ts index 30295e8..ca7b6b9 100644 --- a/src/support/repoMetadata.ts +++ b/src/support/repoMetadata.ts @@ -29,6 +29,12 @@ const RepoMetadataSchema = z.object({ description: z.string().optional(), linear: LinearMappingSchema.optional(), github: GithubMappingSchema.optional(), + /** + * Objective check commands for `openswarm fix` (key → shell command), e.g. + * `{"lint": "ruff check .", "test": "pytest -x"}`. Overrides auto-detection — + * the escape hatch for any language/toolchain. (INT-2303) + */ + checks: z.record(z.string(), z.string()).optional(), /** Free-form notes the swarm should keep in mind. */ notes: z.string().optional(), });