diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a95637..b825eb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## 0.4.1 - Unreleased +- Added CUDA support to the C/C++ mapper, mapping `.cu` / `.cuh` sources as standalone `main()` files and as CMake and autotools targets, including the legacy `FindCUDA` `cuda_add_executable` / `cuda_add_library` commands. Repositories containing CUDA sources are detected as `cuda` projects; CUDA targets are tagged `cuda` and carry the `concurrency` trust boundary. +- Added C/C++/CUDA source-group mapping, so source files not owned by any CMake, autotools, or `main()` target are grouped per directory into bounded review slices. +- Added conservative C/C++/CUDA validation command defaults from a root `Makefile` `check` / `test` target or a declared `CMakePresets.json` build workflow, and mapped `CMakeLists.txt`, `CMakePresets.json`, and `configure.ac` as config features. +- Added per-feature selection of the C/C++/CUDA validation commands so features tagged `c`, `cpp`, or `cuda` use the declared Makefile or CMake preset commands even when another language wins the project's language-priority defaults, for example a Python repository with CUDA kernels. The detected native command set is persisted as a nullable `nativeCommands` field on the project record and config, and existing `.clawpatch/config.json` files load without migration. +- Made `clawpatch review` and `clawpatch fix` CUDA-aware, injecting CUDA-specific reviewer guidance (kernel races, unchecked CUDA runtime calls, host/device pointer confusion, memory-access hazards, and synchronization mistakes) for features that own `.cu` / `.cuh` sources. + ## 0.4.0 - 2026-05-22 - Added `clawpatch ci` to initialize, map, review, write a report, and append a GitHub Actions step summary in one CI-friendly command. diff --git a/README.md b/README.md index 83cef66..0ce38c8 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,10 @@ validation commands and records a patch attempt under `.clawpatch/`. Ecto migrations, project scripts, and ExUnit suites - Rust `src/main.rs`, `src/bin/*.rs`, `src/lib.rs`, `crates/*`, and `tests/*.rs` -- C/C++ standalone `main()` files, CMake `add_executable` / `add_library` - targets, and autotools `bin_PROGRAMS` / `lib_LTLIBRARIES` targets +- C/C++/CUDA standalone `main()` files, CMake `add_executable` / `add_library` + targets, autotools `bin_PROGRAMS` / `lib_LTLIBRARIES` targets, and source + groups for files outside any build target, including CUDA `.cu` / `.cuh` + sources - Python project metadata, console scripts, bounded source groups, pytest suites, and Flask/FastAPI/Django routes - SwiftPM `Sources/*` targets and `Tests/*` suites diff --git a/docs/code-review.md b/docs/code-review.md index 3ee65a4..1ca3f03 100644 --- a/docs/code-review.md +++ b/docs/code-review.md @@ -120,5 +120,15 @@ Categories requested from the provider: - `build-release` - `maintainability` +## CUDA-aware review + +When a feature owns CUDA `.cu` / `.cuh` sources, `clawpatch review` (in the +default mode) and `clawpatch fix` add CUDA-specific guidance to the provider +prompt: kernel data races and synchronization barriers, unchecked CUDA runtime +calls and missing post-launch error checks, host versus device pointer +confusion, unsafe global- and shared-memory access, stream and event +synchronization, and device-memory leaks. Findings still use the existing +categories; there is no CUDA-specific category. Deslopify mode is unaffected. + Review does not edit files. Use `clawpatch fix --finding ` for the explicit patch loop. diff --git a/docs/feature-mapping.md b/docs/feature-mapping.md index e8c7172..32f0f71 100644 --- a/docs/feature-mapping.md +++ b/docs/feature-mapping.md @@ -54,7 +54,7 @@ Supported deterministic mappers today: - Ruby project metadata, executables, source groups, RSpec/Minitest suites, Rails configs, routes, views, assets, and database files - Rust Cargo commands, libraries, workspace crates, and integration tests -- C/C++ standalone `main()` files, CMake targets, and autotools targets +- C/C++/CUDA standalone `main()` files, CMake targets, and autotools targets - C#/.NET projects from `.sln`, `.slnx`, `.csproj`, `.fsproj`, and `.vbproj`, ASP.NET Core controllers, minimal API endpoints, C#/F#/Visual Basic source groups, and .NET test projects @@ -155,7 +155,20 @@ files are skipped. C/C++ mapping covers generic project shapes only: standalone source files with `main()`, CMake `add_executable` / `add_library`, and autotools `bin_PROGRAMS` / `lib_LTLIBRARIES`. It deliberately avoids project-specific C dialects such as -php-src extension metadata. +php-src extension metadata. CUDA `.cu` / `.cuh` files are mapped through the same +C/C++ shapes, including the legacy `FindCUDA` `cuda_add_executable` / +`cuda_add_library` commands; CUDA targets are tagged `cuda`, and a repository with +`.cu` / `.cuh` sources is detected as a `cuda` project. Source files not owned by +any build target are grouped per directory into bounded, low-confidence source +groups. C/C++/CUDA validation commands are emitted only when the project declares +them: a root `Makefile` `check`/`test` target, or a `CMakePresets.json` build +workflow. Otherwise they stay null. When a project mixes C/C++/CUDA sources +with another language whose defaults sit higher in the language-priority +order, such as a Python repository with CUDA kernels, the C/C++/CUDA commands +are also persisted alongside the primary command set as `nativeCommands`, and +`clawpatch fix` selects them for features tagged `c`, `cpp`, or `cuda` so a +CUDA repair is validated with the declared Makefile or CMake preset commands +rather than the project's primary test runner. Python mapping covers `pyproject.toml`, `setup.cfg`, `setup.py`, and `requirements.txt` metadata; `[project.scripts]`, `[tool.poetry.scripts]`, diff --git a/src/app.ts b/src/app.ts index 2ac258c..02f1719 100644 --- a/src/app.ts +++ b/src/app.ts @@ -94,7 +94,11 @@ export async function initCommand( const paths = statePaths(stateDir); await ensureStateDirs(paths); const project = await detectProject(context.root); - const detectedConfig = { ...config, commands: project.detected.commands }; + const detectedConfig = { + ...config, + commands: project.detected.commands, + nativeCommands: project.detected.nativeCommands ?? null, + }; const previous = await readProject(paths); if (previous !== null && flags["force"] !== true) { throw new ClawpatchError("project already initialized; use --force", 2, "already-initialized"); @@ -550,7 +554,11 @@ export async function showCommand( const record = assertDefined(finding, `finding not found: ${findingId}`); const feature = features.find((candidate) => candidate.featureId === record.featureId) ?? null; const linkedPatches = patches.filter((patch) => patch.findingIds.includes(record.findingId)); - const validation = validationCommandsForFeature(feature, loaded.config.commands); + const validation = validationCommandsForFeature( + feature, + loaded.config.commands, + loaded.config.nativeCommands ?? null, + ); if (context.options.json) { return { finding: findingSummary(record, feature), @@ -1032,7 +1040,11 @@ export async function fixCommand( }; const prompt = await buildFixPrompt(loaded.root, finding, feature, config); if (flags["dryRun"] === true) { - const validationCommands = validationCommandsForFeature(feature, config.commands); + const validationCommands = validationCommandsForFeature( + feature, + config.commands, + config.nativeCommands ?? null, + ); return { finding: finding.findingId, dryRun: true, @@ -1073,7 +1085,11 @@ export async function fixCommand( }); throw error; } - const validationCommands = validationCommandsForFeature(feature, config.commands); + const validationCommands = validationCommandsForFeature( + feature, + config.commands, + config.nativeCommands ?? null, + ); const commandsRun: CommandResult[] = []; for (const command of validationCommands) { commandsRun.push(await runCommand(command, loaded.root)); diff --git a/src/config.ts b/src/config.ts index c6723f2..7606282 100644 --- a/src/config.ts +++ b/src/config.ts @@ -49,6 +49,7 @@ export function defaultConfig(): ClawpatchConfig { reasoningEffort: null, }, commands: defaultCommands, + nativeCommands: null, review: { maxContextFiles: 24, maxOwnedFiles: 12, diff --git a/src/detect.ts b/src/detect.ts index 926302a..522f709 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -46,6 +46,7 @@ export async function detectProject(root: string): Promise { const frameworks = await detectFrameworks(root, pkg, composer); const languages = await detectLanguages(root); const commands = await detectCommands(root, pkg, composer, languages, packageManagers); + const nativeCommands = await detectNativeCommands(root, languages, commands); const name = typeof pkg?.name === "string" ? pkg.name @@ -69,6 +70,7 @@ export async function detectProject(root: string): Promise { frameworks, packageManagers, commands, + nativeCommands, }, createdAt: now, updatedAt: now, @@ -249,6 +251,9 @@ async function languageDefaultCommands( if (languages.includes("ruby")) { return rubyDefaultCommands(root); } + if (languages.includes("c") || languages.includes("cpp") || languages.includes("cuda")) { + return cOrCppDefaultCommands(root); + } return { typecheck: null, @@ -691,6 +696,145 @@ async function rubyDefaultCommands(root: string): Promise { }; } +async function detectNativeCommands( + root: string, + languages: string[], + primary: ProjectCommands, +): Promise { + if ( + !languages.some((language) => language === "c" || language === "cpp" || language === "cuda") + ) { + return null; + } + const native = await cOrCppDefaultCommands(root); + if (!hasValidationCommand(native)) { + return null; + } + if (projectCommandsEqual(native, primary)) { + return null; + } + return native; +} + +function projectCommandsEqual(a: ProjectCommands, b: ProjectCommands): boolean { + return ( + a.typecheck === b.typecheck && a.lint === b.lint && a.format === b.format && a.test === b.test + ); +} + +async function cOrCppDefaultCommands(root: string): Promise { + const makefileCommands = await makefileDefaultCommands(root); + if (makefileCommands !== null) { + return makefileCommands; + } + const presetCommands = await cmakePresetDefaultCommands(root); + if (presetCommands !== null) { + return presetCommands; + } + return { typecheck: null, lint: null, format: null, test: null }; +} + +async function makefileDefaultCommands(root: string): Promise { + if (!(await pathExists(join(root, "Makefile")))) { + return null; + } + const source = await readFile(join(root, "Makefile"), "utf8").catch(() => ""); + const test = makefileHasTarget(source, "check") + ? "make check" + : makefileHasTarget(source, "test") + ? "make test" + : null; + if (test === null) { + return null; + } + return { typecheck: null, lint: null, format: null, test }; +} + +function makefileHasTarget(source: string, target: string): boolean { + return new RegExp(`^${target}\\s*:(?!=)`, "mu").test(source); +} + +type CMakePresetSets = { + workflowPresets: string[]; + configurePresets: string[]; + buildPresets: string[]; + testPresets: string[]; +}; + +async function cmakePresetDefaultCommands(root: string): Promise { + if (!(await pathExists(join(root, "CMakePresets.json")))) { + return null; + } + const presets = await readCMakePresets(root); + if (presets === null) { + return null; + } + const testPreset = singlePresetName(presets.testPresets); + return { + typecheck: cmakeBuildCommand(presets), + lint: null, + format: null, + test: testPreset === null ? null : `ctest --preset ${testPreset}`, + }; +} + +function cmakeBuildCommand(presets: CMakePresetSets): string | null { + const workflow = singlePresetName(presets.workflowPresets); + if (workflow !== null) { + return `cmake --workflow --preset ${workflow}`; + } + const configure = singlePresetName(presets.configurePresets); + const build = singlePresetName(presets.buildPresets); + if (configure !== null && build !== null) { + return `cmake --preset ${configure} && cmake --build --preset ${build}`; + } + return null; +} + +function singlePresetName(names: string[]): string | null { + return names.length === 1 ? (names[0] ?? null) : null; +} + +async function readCMakePresets(root: string): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(join(root, "CMakePresets.json"), "utf8")); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) { + return null; + } + const record = parsed as Record; + return { + workflowPresets: cmakePresetNames(record["workflowPresets"]), + configurePresets: cmakePresetNames(record["configurePresets"]), + buildPresets: cmakePresetNames(record["buildPresets"]), + testPresets: cmakePresetNames(record["testPresets"]), + }; +} + +function cmakePresetNames(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + const names: string[] = []; + for (const entry of value) { + if (typeof entry !== "object" || entry === null) { + continue; + } + const preset = entry as { name?: unknown; hidden?: unknown }; + if ( + typeof preset.name === "string" && + preset.hidden !== true && + /^[A-Za-z0-9._-]+$/u.test(preset.name) + ) { + names.push(preset.name); + } + } + return names; +} + async function mixProjectInfo(root: string): Promise { if (!(await pathExists(join(root, "mix.exs")))) { return { dependencies: new Set() }; @@ -1285,6 +1429,9 @@ async function detectLanguages(root: string): Promise { if (!languages.includes("cpp") && (await containsCppFile(root))) { languages.push("cpp"); } + if (!languages.includes("cuda") && (await containsCudaFile(root))) { + languages.push("cuda"); + } if (!languages.includes("php") && (await containsReviewablePhpFile(root))) { languages.push("php"); } @@ -1339,6 +1486,13 @@ async function containsCFile(root: string): Promise { return containsFileWithExtension(root, ".c", 5, shouldSkipCOrCppSearchEntry); } +async function containsCudaFile(root: string): Promise { + return ( + (await containsFileWithExtensionIgnoringCase(root, ".cu", 5, shouldSkipCOrCppSearchEntry)) || + (await containsFileWithExtensionIgnoringCase(root, ".cuh", 5, shouldSkipCOrCppSearchEntry)) + ); +} + async function containsCppFile(root: string): Promise { return ( (await containsFileWithExtension(root, ".C", 5, shouldSkipCOrCppSearchEntry)) || diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 0b0889f..c9024b1 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -11786,6 +11786,303 @@ add_executable(headerapp include/headers.hpp) ]); }); + it("maps a standalone CUDA source file with main as a CUDA binary", async () => { + const root = await fixtureRoot("clawpatch-cuda-standalone-"); + await writeFixture( + root, + "saxpy.cu", + "__global__ void saxpy(float *x) { x[threadIdx.x] *= 2.0f; }\nint main(void) { return 0; }\n", + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const saxpy = result.features.find((feature) => feature.title === "CUDA binary saxpy"); + + expect(saxpy?.source).toBe("c-main"); + expect(saxpy?.entrypoints[0]).toMatchObject({ path: "saxpy.cu", symbol: "main" }); + expect(saxpy?.tags).toContain("cuda"); + }); + + it("maps CMake CUDA targets including .cu and .cuh sources", async () => { + const root = await fixtureRoot("clawpatch-cmake-cuda-"); + await writeFixture( + root, + "CMakeLists.txt", + "project(gpuapp CUDA)\nadd_executable(gpuapp src/main.cu src/kernels.cu src/kernels.cuh)\n", + ); + await writeFixture(root, "src/main.cu", "int main(void) { return 0; }\n"); + await writeFixture( + root, + "src/kernels.cu", + "__global__ void scale(float *x) { x[0] = 1.0f; }\n", + ); + await writeFixture(root, "src/kernels.cuh", "__global__ void scale(float *x);\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const gpuapp = result.features.find((feature) => feature.title === "CMake binary gpuapp"); + + expect(gpuapp?.entrypoints[0]).toMatchObject({ path: "src/main.cu", symbol: "main" }); + expect(gpuapp?.tags).toContain("cuda"); + expect(gpuapp?.ownedFiles).toEqual([ + { path: "src/main.cu", reason: "target source" }, + { path: "src/kernels.cu", reason: "target source" }, + { path: "src/kernels.cuh", reason: "target source" }, + ]); + }); + + it("maps legacy FindCUDA cuda_add_executable and cuda_add_library targets", async () => { + const root = await fixtureRoot("clawpatch-cmake-find-cuda-"); + await writeFixture( + root, + "CMakeLists.txt", + "find_package(CUDA REQUIRED)\ncuda_add_executable(gpuapp src/main.cu)\ncuda_add_library(gpukernels src/kernels.cu)\n", + ); + await writeFixture(root, "src/main.cu", "int main(void) { return 0; }\n"); + await writeFixture( + root, + "src/kernels.cu", + "__global__ void scale(float *x) { x[0] = 1.0f; }\n", + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const titles = result.features.map((feature) => feature.title); + const gpuapp = result.features.find((feature) => feature.title === "CMake binary gpuapp"); + const gpukernels = result.features.find( + (feature) => feature.title === "CMake library gpukernels", + ); + + expect(titles).toContain("CMake binary gpuapp"); + expect(titles).toContain("CMake library gpukernels"); + expect(gpuapp?.entrypoints[0]).toMatchObject({ path: "src/main.cu", symbol: "main" }); + expect(gpuapp?.tags).toContain("cuda"); + expect(gpuapp?.summary).toContain("cuda_add_executable"); + expect(gpukernels?.ownedFiles).toEqual([{ path: "src/kernels.cu", reason: "target source" }]); + }); + + it("detects CUDA projects from .cu sources", async () => { + const root = await fixtureRoot("clawpatch-cuda-detect-"); + await writeFixture(root, "src/kernel.cu", "__global__ void noop(void) {}\n"); + + const project = await detectProject(root); + + expect(project.detected.languages).toContain("cuda"); + }); + + it("surfaces native commands on mixed Python + CUDA projects", async () => { + const root = await fixtureRoot("clawpatch-cuda-mixed-py-"); + await writeFixture(root, "pyproject.toml", '[project]\nname="demo"\ndependencies=["pytest"]\n'); + await writeFixture(root, "tests/test_demo.py", "def test_ok(): assert True\n"); + await writeFixture(root, "src/kernel.cu", "__global__ void noop(void) {}\n"); + await writeFixture(root, "Makefile", "all:\n\techo build\n\ncheck:\n\techo run native tests\n"); + + const project = await detectProject(root); + + expect(project.detected.languages).toContain("cuda"); + expect(project.detected.languages).toContain("python"); + expect(project.detected.commands.test).toBe("pytest"); + expect(project.detected.nativeCommands).toEqual({ + typecheck: null, + lint: null, + format: null, + test: "make check", + }); + }); + + it("leaves nativeCommands null when the primary command set already covers C/C++/CUDA", async () => { + const root = await fixtureRoot("clawpatch-cuda-native-only-"); + await writeFixture(root, "src/kernel.cu", "__global__ void noop(void) {}\n"); + await writeFixture(root, "Makefile", "all:\n\techo build\n\ncheck:\n\techo run native tests\n"); + + const project = await detectProject(root); + + expect(project.detected.commands.test).toBe("make check"); + expect(project.detected.nativeCommands).toBeNull(); + }); + + it("tags CUDA build targets with the concurrency trust boundary", async () => { + const root = await fixtureRoot("clawpatch-cuda-concurrency-"); + await writeFixture( + root, + "CMakeLists.txt", + "project(gpuapp CUDA)\nadd_executable(gpuapp src/main.cu)\n", + ); + await writeFixture(root, "src/main.cu", "int main(void) { return 0; }\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const gpuapp = result.features.find((feature) => feature.title === "CMake binary gpuapp"); + + expect(gpuapp?.trustBoundaries).toContain("concurrency"); + }); + + it("tags mixed CMake targets as CUDA when any owned source is .cu", async () => { + const root = await fixtureRoot("clawpatch-cmake-mixed-cuda-"); + await writeFixture( + root, + "CMakeLists.txt", + "project(gpuapp CUDA CXX)\nadd_executable(gpuapp src/main.cpp src/kernel.cu)\n", + ); + await writeFixture(root, "src/main.cpp", "int main(void) { return 0; }\n"); + await writeFixture(root, "src/kernel.cu", "__global__ void scale(float *x) { x[0] = 1.0f; }\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const gpuapp = result.features.find((feature) => feature.title === "CMake binary gpuapp"); + + expect(gpuapp?.entrypoints[0]).toMatchObject({ path: "src/main.cpp", symbol: "main" }); + expect(gpuapp?.tags).toContain("cuda"); + expect(gpuapp?.trustBoundaries).toContain("concurrency"); + }); + + it("maps CMake and autotools build files as config features", async () => { + const root = await fixtureRoot("clawpatch-build-config-"); + await writeFixture(root, "CMakeLists.txt", "project(app CXX)\nadd_executable(app main.cpp)\n"); + await writeFixture(root, "CMakePresets.json", '{"version":6}\n'); + await writeFixture(root, "configure.ac", "AC_INIT([app],[1.0])\n"); + await writeFixture(root, "main.cpp", "int main(void) { return 0; }\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const titles = result.features.map((feature) => feature.title); + + expect(titles).toContain("Project config CMakeLists.txt"); + expect(titles).toContain("Project config CMakePresets.json"); + expect(titles).toContain("Project config configure.ac"); + }); + + it("emits `make check` as the C/C++ test command when the Makefile declares a check target", async () => { + const root = await fixtureRoot("clawpatch-cpp-makefile-check-"); + await writeFixture(root, "Makefile", "all:\n\tcc -o app main.c\n\ncheck:\n\t./app\n"); + await writeFixture(root, "main.c", "int main(void) { return 0; }\n"); + + const project = await detectProject(root); + + expect(project.detected.commands.typecheck).toBeNull(); + expect(project.detected.commands.test).toBe("make check"); + }); + + it("emits no C/C++ validation commands when the Makefile has no check or test target", async () => { + const root = await fixtureRoot("clawpatch-cpp-makefile-notest-"); + await writeFixture(root, "Makefile", "all:\n\tcc -o app main.c\n"); + await writeFixture(root, "main.c", "int main(void) { return 0; }\n"); + + const project = await detectProject(root); + + expect(project.detected.commands.typecheck).toBeNull(); + expect(project.detected.commands.test).toBeNull(); + }); + + it("emits a CMake workflow preset validation command when one is declared", async () => { + const root = await fixtureRoot("clawpatch-cmake-preset-"); + await writeFixture(root, "CMakeLists.txt", "project(app CXX)\nadd_executable(app main.cpp)\n"); + await writeFixture(root, "main.cpp", "int main(void) { return 0; }\n"); + await writeFixture( + root, + "CMakePresets.json", + JSON.stringify({ version: 6, workflowPresets: [{ name: "default", steps: [] }] }), + ); + + const project = await detectProject(root); + + expect(project.detected.commands.typecheck).toBe("cmake --workflow --preset default"); + }); + + it("emits no C/C++ validation command for a CMake project without presets", async () => { + const root = await fixtureRoot("clawpatch-cmake-nopreset-"); + await writeFixture(root, "CMakeLists.txt", "project(app CXX)\nadd_executable(app main.cpp)\n"); + await writeFixture(root, "main.cpp", "int main(void) { return 0; }\n"); + + const project = await detectProject(root); + + expect(project.detected.commands.typecheck).toBeNull(); + expect(project.detected.commands.test).toBeNull(); + }); + + it("emits no C/C++ validation command for ambiguous CMake presets", async () => { + const root = await fixtureRoot("clawpatch-cmake-ambiguous-preset-"); + await writeFixture(root, "CMakeLists.txt", "project(app CXX)\nadd_executable(app main.cpp)\n"); + await writeFixture(root, "main.cpp", "int main(void) { return 0; }\n"); + await writeFixture( + root, + "CMakePresets.json", + JSON.stringify({ + version: 6, + workflowPresets: [ + { name: "debug", steps: [] }, + { name: "release", steps: [] }, + ], + }), + ); + + const project = await detectProject(root); + + expect(project.detected.commands.typecheck).toBeNull(); + }); + + it("maps loose C++ sources with no build target as a source-group feature", async () => { + const root = await fixtureRoot("clawpatch-cpp-group-"); + await writeFixture(root, "lib/parser.cpp", "int parse(void) { return 0; }\n"); + await writeFixture(root, "lib/lexer.cpp", "int lex(void) { return 0; }\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const group = result.features.find((feature) => feature.title === "C/C++ source group lib"); + + expect(group?.kind).toBe("library"); + expect(group?.source).toBe("c-cpp-group"); + expect(group?.ownedFiles).toEqual([ + { path: "lib/lexer.cpp", reason: "source group member" }, + { path: "lib/parser.cpp", reason: "source group member" }, + ]); + }); + + it("excludes files already owned by a CMake target from source groups", async () => { + const root = await fixtureRoot("clawpatch-cpp-group-exclude-"); + await writeFixture(root, "CMakeLists.txt", "add_executable(app src/main.cpp)\n"); + await writeFixture(root, "src/main.cpp", "int main(void) { return 0; }\n"); + await writeFixture(root, "src/helper.cpp", "int help(void) { return 0; }\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const group = result.features.find((feature) => feature.title === "C/C++ source group src"); + + expect(group?.ownedFiles).toEqual([{ path: "src/helper.cpp", reason: "source group member" }]); + }); + + it("maps a loose CUDA kernel directory as a CUDA source group with concurrency", async () => { + const root = await fixtureRoot("clawpatch-cuda-group-"); + await writeFixture( + root, + "kernels/reduce.cu", + "__global__ void reduce(float *x) { x[0] = 0; }\n", + ); + await writeFixture(root, "kernels/reduce.cuh", "__global__ void reduce(float *x);\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const group = result.features.find((feature) => feature.title === "CUDA source group kernels"); + + expect(group?.tags).toContain("cuda"); + expect(group?.trustBoundaries).toContain("concurrency"); + expect(group?.ownedFiles).toEqual([ + { path: "kernels/reduce.cu", reason: "source group member" }, + { path: "kernels/reduce.cuh", reason: "source group member" }, + ]); + }); + + it("emits no C/C++ validation command for an autotools-only project", async () => { + const root = await fixtureRoot("clawpatch-autotools-nullcmd-"); + await writeFixture(root, "Makefile.am", "bin_PROGRAMS = app\napp_SOURCES = main.c\n"); + await writeFixture(root, "main.c", "int main(void) { return 0; }\n"); + + const project = await detectProject(root); + + expect(project.detected.commands.typecheck).toBeNull(); + expect(project.detected.commands.test).toBeNull(); + }); + it("maps autotools targets from Makefile.in", async () => { const root = await fixtureRoot("clawpatch-autotools-makefile-in-"); await writeFixture( diff --git a/src/mappers/c-cpp-groups.ts b/src/mappers/c-cpp-groups.ts new file mode 100644 index 0000000..5587f00 --- /dev/null +++ b/src/mappers/c-cpp-groups.ts @@ -0,0 +1,53 @@ +import { chunkFiles, partitionFileGroups } from "./grouping.js"; +import { isCOrCppPath, isCOrCppTestPath, languageTag, withCudaConcurrency } from "./shared.js"; +import { FeatureSeed } from "./types.js"; + +const sourceGroupMaxFiles = 12; + +export function cCppGroupSeeds(sourceFiles: string[], ownedPaths: Set): FeatureSeed[] { + const residual = sourceFiles.filter( + (path) => isCOrCppPath(path) && !ownedPaths.has(path) && !isCOrCppTestPath(path), + ); + if (residual.length === 0) { + return []; + } + const byTopDir = new Map(); + for (const path of residual) { + const slash = path.indexOf("/"); + const topDir = slash === -1 ? "" : path.slice(0, slash); + byTopDir.set(topDir, [...(byTopDir.get(topDir) ?? []), path]); + } + const seeds: FeatureSeed[] = []; + for (const [topDir, files] of [...byTopDir.entries()].toSorted(([left], [right]) => + left.localeCompare(right), + )) { + const groups = + topDir === "" + ? chunkFiles(".", files.toSorted(), sourceGroupMaxFiles) + : partitionFileGroups(topDir, files, sourceGroupMaxFiles); + for (const group of groups) { + seeds.push(groupSeed(group.label, group.files)); + } + } + return seeds; +} + +function groupSeed(label: string, files: string[]): FeatureSeed { + const sorted = files.toSorted(); + const tags = [...new Set(sorted.map(languageTag))]; + const isCuda = tags.includes("cuda"); + return { + title: `${isCuda ? "CUDA" : "C/C++"} source group ${label}`, + summary: `C/C++/CUDA source files under ${label} not owned by a build target.`, + kind: "library", + source: "c-cpp-group", + confidence: "low", + entryPath: sorted[0] ?? label, + symbol: label, + route: null, + command: null, + tags: [...tags, "source-group"], + trustBoundaries: withCudaConcurrency(["filesystem"], isCuda ? "cuda" : "cpp"), + ownedFiles: sorted.map((path) => ({ path, reason: "source group member" })), + }; +} diff --git a/src/mappers/c-cpp.ts b/src/mappers/c-cpp.ts index e3506b7..7d9a93c 100644 --- a/src/mappers/c-cpp.ts +++ b/src/mappers/c-cpp.ts @@ -4,12 +4,17 @@ import { isSafeFile, isCOrCppTestPath, isSampleProjectPath, + languageLabel, + languageTag, normalize, packageTrustBoundaries, shouldSkip, stripLineComments, + targetLanguageTag, walk, + withCudaConcurrency, } from "./shared.js"; +import { cCppGroupSeeds } from "./c-cpp-groups.js"; import { FeatureSeed, SeedFileRef } from "./types.js"; export async function cCppSeeds(root: string): Promise { @@ -29,15 +34,19 @@ export async function cCppSeeds(root: string): Promise { .flatMap((seed) => [seed.entryPath, ...(seed.ownedFiles?.map((file) => file.path) ?? [])]), ); seeds.push(...(await mainFunctionTargets(root, files, alreadySeeded))); + const ownedPaths = new Set( + seeds.flatMap((seed) => [seed.entryPath, ...(seed.ownedFiles?.map((file) => file.path) ?? [])]), + ); + seeds.push(...cCppGroupSeeds(files.filter(isCOrCppSource), ownedPaths)); return dedupeByEntry(seeds); } function isCOrCppSource(path: string): boolean { - return /\.(?:c|cc|cpp|cxx|h|hh|hpp|hxx)$/iu.test(path); + return /\.(?:c|cc|cpp|cxx|cu|cuh|h|hh|hpp|hxx)$/iu.test(path); } function isCOrCppCompilable(path: string): boolean { - return /\.(?:c|cc|cpp|cxx)$/iu.test(path); + return /\.(?:c|cc|cpp|cxx|cu)$/iu.test(path); } function isMakefile(path: string): boolean { @@ -48,10 +57,6 @@ function isCMake(path: string): boolean { return path.endsWith("CMakeLists.txt") || path.endsWith(".cmake"); } -function languageTag(path: string): "c" | "cpp" { - return /\.(?:C|H)$/u.test(path) || /\.(?:cc|cpp|cxx|hh|hpp|hxx)$/iu.test(path) ? "cpp" : "c"; -} - async function autotoolsTargets(root: string, files: string[]): Promise { const seeds: FeatureSeed[] = []; const makefiles = files.filter(isMakefile); @@ -74,7 +79,7 @@ async function autotoolsTargets(root: string, files: string[]): Promise "")); const effectiveProjectSourceDir = cmakeDeclaresProject(body) ? dir : projectSourceDir; const effectiveProjectName = cmakeProjectName(body) ?? projectName; - for (const args of cmakeCommandArgs(body, "add_executable")) { + for (const { command, args } of cmakeTargetCalls(body, [ + "add_executable", + "cuda_add_executable", + ])) { const [rawTarget = "", ...sources] = splitWords(args); const target = resolveCMakeTargetName(rawTarget, effectiveProjectName); if (!isValidTargetName(target)) { @@ -171,6 +179,7 @@ async function cmakeTargets(root: string, files: string[]): Promise { + return commands.flatMap((command) => + cmakeCommandArgs(body, command).map((args) => ({ command, args })), + ); +} + function cmakeCommandArgs(body: string, command: string): string[] { const args: string[] = []; const needle = command.toLowerCase(); @@ -660,8 +678,8 @@ async function mainFunctionTargets( .at(-1) ?.replace(/\.[^.]+$/u, "") ?? "main"; seeds.push({ - title: `${tag === "cpp" ? "C++" : "C"} binary ${command}`, - summary: `C/C++ source file with a top-level main() at ${file}.`, + title: `${languageLabel(tag)} binary ${command}`, + summary: `${tag === "cuda" ? "CUDA" : "C/C++"} source file with a top-level main() at ${file}.`, kind: "cli-command", source: "c-main", confidence: "medium", @@ -670,7 +688,7 @@ async function mainFunctionTargets( route: null, command, tags: [tag, "cli"], - trustBoundaries: ["user-input", "filesystem", "process-exec"], + trustBoundaries: withCudaConcurrency(["user-input", "filesystem", "process-exec"], tag), }); } return seeds; diff --git a/src/mappers/config.ts b/src/mappers/config.ts index b966128..93f9add 100644 --- a/src/mappers/config.ts +++ b/src/mappers/config.ts @@ -23,6 +23,9 @@ export async function configSeeds(root: string): Promise { "composer.lock", "phpunit.xml", "Makefile", + "CMakeLists.txt", + "CMakePresets.json", + "configure.ac", ]; const seeds: FeatureSeed[] = []; for (const file of candidates) { diff --git a/src/mappers/grouping.ts b/src/mappers/grouping.ts index 330976d..f77d57e 100644 --- a/src/mappers/grouping.ts +++ b/src/mappers/grouping.ts @@ -75,7 +75,7 @@ function partitionAt( return groups; } -function chunkFiles(label: string, files: string[], maxFiles: number): FileGroup[] { +export function chunkFiles(label: string, files: string[], maxFiles: number): FileGroup[] { if (files.length === 0) { return []; } diff --git a/src/mappers/shared.ts b/src/mappers/shared.ts index 07a71e1..9be4431 100644 --- a/src/mappers/shared.ts +++ b/src/mappers/shared.ts @@ -392,7 +392,7 @@ function isJsTestPath(path: string): boolean { } export function isCOrCppPath(path: string): boolean { - return /\.(?:c|cc|cpp|cxx|h|hh|hpp|hxx)$/iu.test(path); + return /\.(?:c|cc|cpp|cxx|cu|cuh|h|hh|hpp|hxx)$/iu.test(path); } export function isCOrCppTestPath(path: string): boolean { @@ -405,6 +405,36 @@ export function isCOrCppTestPath(path: string): boolean { ); } +export type LanguageTag = "c" | "cpp" | "cuda"; + +export function languageTag(path: string): LanguageTag { + if (/\.cuh?$/iu.test(path)) { + return "cuda"; + } + return /\.(?:C|H)$/u.test(path) || /\.(?:cc|cpp|cxx|hh|hpp|hxx)$/iu.test(path) ? "cpp" : "c"; +} + +export function languageLabel(tag: LanguageTag): string { + return tag === "cuda" ? "CUDA" : tag === "cpp" ? "C++" : "C"; +} + +export function targetLanguageTag(entryPath: string, sourcePaths: readonly string[]): LanguageTag { + if (sourcePaths.some((path) => languageTag(path) === "cuda")) { + return "cuda"; + } + return languageTag(entryPath); +} + +export function withCudaConcurrency( + boundaries: TrustBoundary[], + tag: LanguageTag, +): TrustBoundary[] { + if (tag !== "cuda" || boundaries.includes("concurrency")) { + return boundaries; + } + return [...boundaries, "concurrency"]; +} + function shouldSkipCOrCppNearbyPath(path: string): boolean { return shouldSkip(path) || isCOrCppDependencyPath(path) || isSampleProjectPath(path); } diff --git a/src/prompt.test.ts b/src/prompt.test.ts index f6ea20a..dba73d0 100644 --- a/src/prompt.test.ts +++ b/src/prompt.test.ts @@ -195,6 +195,112 @@ describe("review prompt provenance", () => { }); }); +describe("CUDA prompt guidance", () => { + it("includes CUDA review guidance for a feature that owns a .cu file", async () => { + const root = await fixtureRoot("clawpatch-prompt-cuda-review-"); + await writeFixture(root, "src/kernel.cu", "__global__ void k(void) {}\n"); + const cudaFeature: FeatureRecord = { + ...feature(), + entrypoints: [], + ownedFiles: [{ path: "src/kernel.cu", reason: "kernel" }], + contextFiles: [], + }; + const bundle = await buildReviewPromptBundle(root, project(root), cudaFeature, defaultConfig()); + + expect(bundle.prompt).toContain("CUDA hazards"); + }); + + it("omits CUDA review guidance for a non-CUDA feature", async () => { + const root = await fixtureRoot("clawpatch-prompt-noncuda-review-"); + await writeFixture(root, "src/index.ts", "export const value = 1;\n"); + const tsFeature: FeatureRecord = { + ...feature(), + entrypoints: [], + ownedFiles: [{ path: "src/index.ts", reason: "primary" }], + contextFiles: [], + }; + const bundle = await buildReviewPromptBundle(root, project(root), tsFeature, defaultConfig()); + + expect(bundle.prompt).not.toContain("CUDA hazards"); + }); + + it("omits CUDA review guidance in deslopify mode even for a CUDA feature", async () => { + const root = await fixtureRoot("clawpatch-prompt-cuda-deslopify-"); + await writeFixture(root, "src/kernel.cu", "__global__ void k(void) {}\n"); + const cudaFeature: FeatureRecord = { + ...feature(), + entrypoints: [], + ownedFiles: [{ path: "src/kernel.cu", reason: "kernel" }], + contextFiles: [], + }; + const bundle = await buildReviewPromptBundle( + root, + project(root), + cudaFeature, + defaultConfig(), + "deslopify", + ); + + expect(bundle.prompt).not.toContain("CUDA hazards"); + }); + + it("includes CUDA review guidance for a mixed feature whose entrypoint is C++ but owns a .cu file", async () => { + const root = await fixtureRoot("clawpatch-prompt-cuda-mixed-"); + await writeFixture(root, "src/main.cpp", "int main(void) { return 0; }\n"); + await writeFixture(root, "src/kernel.cu", "__global__ void k(void) {}\n"); + const mixedFeature: FeatureRecord = { + ...feature(), + entrypoints: [{ path: "src/main.cpp", symbol: "main", route: null, command: null }], + ownedFiles: [ + { path: "src/main.cpp", reason: "host" }, + { path: "src/kernel.cu", reason: "kernel" }, + ], + contextFiles: [], + }; + const bundle = await buildReviewPromptBundle( + root, + project(root), + mixedFeature, + defaultConfig(), + ); + + expect(bundle.prompt).toContain("CUDA hazards"); + }); + + it("includes CUDA guidance in the fix prompt for a CUDA feature", async () => { + const root = await fixtureRoot("clawpatch-prompt-cuda-fix-"); + await writeFixture(root, "src/kernel.cu", "__global__ void k(void) {}\n"); + const cudaFeature: FeatureRecord = { + ...feature(), + entrypoints: [], + ownedFiles: [{ path: "src/kernel.cu", reason: "kernel" }], + contextFiles: [], + }; + const prompt = await buildFixPrompt( + root, + finding("src/kernel.cu"), + cudaFeature, + defaultConfig(), + ); + + expect(prompt).toContain("CUDA hazards"); + }); + + it("omits CUDA guidance in the fix prompt for a non-CUDA feature", async () => { + const root = await fixtureRoot("clawpatch-prompt-noncuda-fix-"); + await writeFixture(root, "src/index.ts", "export const value = 1;\n"); + const tsFeature: FeatureRecord = { + ...feature(), + entrypoints: [], + ownedFiles: [{ path: "src/index.ts", reason: "primary" }], + contextFiles: [], + }; + const prompt = await buildFixPrompt(root, finding("src/index.ts"), tsFeature, defaultConfig()); + + expect(prompt).not.toContain("CUDA hazards"); + }); +}); + function project(root: string): ProjectRecord { return { schemaVersion: 1, @@ -212,6 +318,7 @@ function project(root: string): ProjectRecord { frameworks: [], packageManagers: ["npm"], commands: defaultConfig().commands, + nativeCommands: null, }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), diff --git a/src/prompt.ts b/src/prompt.ts index e0e4283..9d0e354 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -181,6 +181,8 @@ ${customPrompt.trim()} const validEvidencePaths = [ ...new Set(includedFiles.filter((file) => file.readable).map((file) => file.path)), ]; + const cudaBlock = + mode === "default" && featureIncludesCuda(feature) ? `\n${cudaGuidance()}\n` : ""; const prompt = `You are reviewing one semantic feature for clawpatch. Return strict JSON only. No markdown fences. @@ -204,7 +206,7 @@ ${customBlock}Review categories: - release/build hazards - maintainability risks with concrete impact -${reviewModeInstructions(mode)} +${reviewModeInstructions(mode)}${cudaBlock} Inspect owned files, context files, and linked tests. Treat included tests as first-class evidence of intended behavior. If tests contradict a suspected bug, either skip it or @@ -345,6 +347,25 @@ function reviewModeInstructions(mode: ReviewMode): string { throw new Error(`Unsupported review mode: ${mode}`); } +function featureIncludesCuda(feature: FeatureRecord): boolean { + const paths = [ + ...feature.entrypoints.map((entrypoint) => entrypoint.path), + ...feature.ownedFiles.map((file) => file.path), + ]; + return paths.some((path) => /\.cuh?$/iu.test(path)); +} + +function cudaGuidance(): string { + return `This feature includes CUDA .cu/.cuh sources. Inspect for these CUDA hazards: +- Kernel data races; missing, divergent, or conditionally-reached __syncthreads()/__syncwarp() barriers. +- Unchecked CUDA runtime calls (cudaMalloc, cudaMemcpy, cudaFree, async copies) and missing cudaGetLastError()/cudaDeviceSynchronize() after a kernel launch. +- Host vs. device pointer confusion: dereferencing device memory on the host, or passing the wrong memory space or copy direction to cudaMemcpy. +- Out-of-bounds or uncoalesced global-memory access, shared-memory bank conflicts, and blockIdx/threadIdx-derived indices used without bounds checks. +- Stream and event synchronization errors, including use-after-free across asynchronous copies. +- Device-memory leaks: allocations not freed on every return path. +Map findings to the existing categories (concurrency, bug, data-loss, performance). Report only hazards visible in the included code; do not speculate about GPU runtime behavior you cannot see.`; +} + export async function buildRevalidatePrompt(root: string, findingJson: string): Promise { return `Revalidate this clawpatch finding against the current repository at ${root}. @@ -369,6 +390,7 @@ export async function buildFixPrompt( for (const path of fixPromptPaths(finding, feature, config)) { fileBlocks.push(await rawFileBlock(root, path)); } + const cudaBlock = featureIncludesCuda(feature) ? `\n${cudaGuidance()}\n` : ""; return `You are clawpatch applying one small repair in the current repository. Fix only the finding below. Keep the patch minimal. Add or update focused tests when feasible. @@ -382,7 +404,7 @@ After editing, return strict JSON only: "steps": ["string"], "validationCommands": ["string"] } - +${cudaBlock} Finding: ${JSON.stringify(finding, null, 2)} diff --git a/src/types.ts b/src/types.ts index d21df35..d0ea47c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -112,6 +112,7 @@ export const projectRecordSchema = z.object({ frameworks: z.array(z.string()), packageManagers: z.array(z.string()), commands: projectCommandsSchema, + nativeCommands: projectCommandsSchema.nullable().optional().default(null), }), createdAt: z.string(), updatedAt: z.string(), @@ -130,6 +131,7 @@ export const configSchema = z.object({ reasoningEffort: reasoningEffortSchema.nullable().optional().default(null), }), commands: projectCommandsSchema, + nativeCommands: projectCommandsSchema.nullable().optional().default(null), review: z.object({ maxContextFiles: z.number().int().positive(), maxOwnedFiles: z.number().int().positive(), diff --git a/src/validation.test.ts b/src/validation.test.ts new file mode 100644 index 0000000..f141217 --- /dev/null +++ b/src/validation.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { validationCommandsForFeature } from "./validation.js"; +import { FeatureRecord } from "./types.js"; + +const baseCommands = { + typecheck: "mypy .", + lint: "ruff check .", + format: "ruff format --check .", + test: "pytest", +}; + +const nativeCommands = { + typecheck: null, + lint: null, + format: null, + test: "ctest --preset default", +}; + +function feature(tags: string[]): FeatureRecord { + return { + schemaVersion: 1, + featureId: "feat_test", + title: "test", + summary: "test", + kind: "library", + source: "cmake-bin", + confidence: "high", + entrypoints: [], + ownedFiles: [], + contextFiles: [], + tests: [], + tags, + trustBoundaries: [], + status: "pending", + lock: null, + findingIds: [], + patchAttemptIds: [], + analysisHistory: [], + createdAt: "1970-01-01T00:00:00.000Z", + updatedAt: "1970-01-01T00:00:00.000Z", + }; +} + +describe("validationCommandsForFeature", () => { + it("uses primary commands when no nativeCommands are configured", () => { + expect(validationCommandsForFeature(feature(["python"]), baseCommands, null)).toEqual([ + "ruff format --check .", + "mypy .", + "ruff check .", + "pytest", + ]); + }); + + it("uses primary commands for features without a native language tag", () => { + expect(validationCommandsForFeature(feature(["python"]), baseCommands, nativeCommands)).toEqual( + ["ruff format --check .", "mypy .", "ruff check .", "pytest"], + ); + }); + + it("uses nativeCommands for cuda-tagged features when configured", () => { + expect(validationCommandsForFeature(feature(["cuda"]), baseCommands, nativeCommands)).toEqual([ + "ctest --preset default", + ]); + }); + + it("uses nativeCommands for cpp-tagged features when configured", () => { + expect( + validationCommandsForFeature(feature(["cpp", "library"]), baseCommands, nativeCommands), + ).toEqual(["ctest --preset default"]); + }); + + it("uses nativeCommands for c-tagged features when configured", () => { + expect(validationCommandsForFeature(feature(["c"]), baseCommands, nativeCommands)).toEqual([ + "ctest --preset default", + ]); + }); + + it("falls back to primary commands for native features when nativeCommands is null", () => { + expect(validationCommandsForFeature(feature(["cuda"]), baseCommands, null)).toEqual([ + "ruff format --check .", + "mypy .", + "ruff check .", + "pytest", + ]); + }); +}); diff --git a/src/validation.ts b/src/validation.ts index 9c25a61..1b731f7 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -8,21 +8,29 @@ export type ValidationCommandConfig = { test: string | null; }; +const nativeFeatureTags = new Set(["c", "cpp", "cuda"]); + export function validationCommandsForFeature( feature: FeatureRecord | null, commands: ValidationCommandConfig, + nativeCommands: ValidationCommandConfig | null = null, ): string[] { + const effective = nativeCommands !== null && featureIsNative(feature) ? nativeCommands : commands; const featureCommands = (feature?.tests ?? []).flatMap((test) => test.command === null || test.command.length === 0 ? [] : [test.command], ); const configuredTest = - feature?.tags.includes(suppressedTestCommandTag) === true ? null : commands.test; + feature?.tags.includes(suppressedTestCommandTag) === true ? null : effective.test; const ordered = [ - commands.format, + effective.format, ...featureCommands, - commands.typecheck, - commands.lint, + effective.typecheck, + effective.lint, configuredTest, ].filter((command): command is string => command !== null && command.length > 0); return Array.from(new Set(ordered)); } + +function featureIsNative(feature: FeatureRecord | null): boolean { + return feature?.tags.some((tag) => nativeFeatureTags.has(tag)) === true; +} diff --git a/src/workflow.test.ts b/src/workflow.test.ts index 6f8cc02..92e9360 100644 --- a/src/workflow.test.ts +++ b/src/workflow.test.ts @@ -2143,6 +2143,34 @@ describe("workflow", () => { expect(config.commands.test).toBe("npm run test"); }); + it("loads pre-existing config files that pre-date nativeCommands", async () => { + const root = await fixtureRoot("clawpatch-config-pre-native-"); + await writeFixture( + root, + ".clawpatch/config.json", + JSON.stringify({ + schemaVersion: 1, + stateDir: ".clawpatch", + include: ["**/*"], + exclude: [".clawpatch/**"], + provider: { name: "codex", model: null }, + commands: { typecheck: null, lint: null, format: null, test: "pytest" }, + review: { + maxContextFiles: 24, + maxOwnedFiles: 12, + maxFindingsPerFeature: 10, + minConfidenceToFix: "medium", + }, + git: { requireCleanWorktreeForFix: true, commit: false, openPr: false }, + }), + ); + + const config = await loadConfig(root, testOptions(root)); + + expect(config.commands.test).toBe("pytest"); + expect(config.nativeCommands).toBeNull(); + }); + it("clean-locks requeues claimed features", async () => { const root = await fixtureRoot("clawpatch-clean-locks-"); await writeFixture(