diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a95637..95a2833 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## 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. +- 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..3e4dede 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,14 @@ 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. Python mapping covers `pyproject.toml`, `setup.cfg`, `setup.py`, and `requirements.txt` metadata; `[project.scripts]`, `[tool.poetry.scripts]`, diff --git a/src/detect.ts b/src/detect.ts index 926302a..3ffecdc 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -249,6 +249,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 +694,119 @@ async function rubyDefaultCommands(root: string): Promise { }; } +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 +1401,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 +1458,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..0b68a5a 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -11786,6 +11786,272 @@ 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("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..747b832 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, 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)}