Skip to content
Merged
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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,12 @@ openswarm review --max --concurrency 8 # widen the fan-out — areas auto-spli
# (legacy spray) · --issues <id> (set parent) · --fallback
# <adapter> · --out <file> · --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 <n> · --rounds <n> (default 3)
# any language: put {"checks": {"test": "pytest -x"}} in openswarm.json

# Code Registry & BS Detector
openswarm check --scan # Scan repo → register all entities
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>', 'Project path (default: cwd)')
.option('--checks <list>', '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 <list>', '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 <n>', 'Max fix workers in flight (default 4)', (v) => parseInt(v, 10))
.option('--rounds <n>', 'Max check → fix → re-check rounds (default 3)', (v) => parseInt(v, 10))
.option('--adapter <name>', 'Adapter override for the fix workers')
Expand Down
78 changes: 78 additions & 0 deletions src/cli/fixCommand.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { describe, it, expect, vi } from 'vitest';
import {
resolveChecks,
resolveProjectChecks,
parseFailingFiles,
deriveFixAreas,
buildFixCheckTask,
runFixCommand,
type Check,
type CheckOutcome,
type FixArea,
type ProjectProbe,
} from './fixCommand.js';

describe('resolveChecks (INT-2267)', () => {
Expand All @@ -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 = [
Expand All @@ -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)', () => {
Expand Down Expand Up @@ -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)', () => {
Expand Down
143 changes: 139 additions & 4 deletions src/cli/fixCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -85,6 +87,134 @@ export function readScripts(cwd: string): Record<string, string> {
}
}

// ── 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<string, string>;
/** `package.json` scripts. */
npmScripts?: Record<string, string>;
/** `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<string, Pick<Check, 'program' | 'args'>> = {
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<string, Pick<Check, 'program' | 'args'>> = {
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<string, Pick<Check, 'program' | 'args'>>,
defaults: string[],
requested?: string[],
): Check[] {
const keys = requested?.length ? requested.map((k) => KNOWN_CHECKS[k.trim()] ?? k.trim()) : defaults;
const seen = new Set<string>();
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<string, string> | 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<string>();
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');

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand All @@ -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' };
}

Expand Down
9 changes: 9 additions & 0 deletions src/support/repoMetadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions src/support/repoMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Expand Down
Loading