Skip to content

Commit 5c94a30

Browse files
authored
feat(mapper): add generic C/C++ feature mapping
Add deterministic C/C++ feature mapping for standalone main files, CMake targets, and autotools targets. Co-authored-by: Ilia Alshanetsky <ilia@ilia.ws>
1 parent 17b6322 commit 5c94a30

11 files changed

Lines changed: 2815 additions & 48 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased
44

5+
- Added generic C/C++ feature mapping for standalone `main()` files, CMake `add_executable` / `add_library` targets, and autotools `bin_PROGRAMS` / `lib_LTLIBRARIES` targets, thanks @iliaal.
56
- Added security ownership, CodeQL, Dependabot, dependency review, and a private disclosure policy for repository automation and package integrity, plus fixed the first CodeQL mapper sanitizer finding.
67
- Added JVM semantic role mapping from Java annotations, imports, inheritance, interfaces, and method signatures.
78
- Added Ruby and Rails feature mapping while excluding legacy Rails secrets from reviewable config, thanks @inertia186.

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ validation commands and records a patch attempt under `.clawpatch/`.
6363
- Ruby project metadata, executables, source groups, RSpec/Minitest suites
6464
- Rust `src/main.rs`, `src/bin/*.rs`, `src/lib.rs`, `crates/*`, and
6565
`tests/*.rs`
66+
- C/C++ standalone `main()` files, CMake `add_executable` / `add_library`
67+
targets, and autotools `bin_PROGRAMS` / `lib_LTLIBRARIES` targets
6668
- Python project metadata, console scripts, bounded source groups, pytest suites,
6769
and Flask/FastAPI routes
6870
- SwiftPM `Sources/*` targets and `Tests/*` suites

docs/feature-mapping.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ Supported deterministic mappers today:
4444
- Ruby project metadata, executables, source groups, RSpec/Minitest suites,
4545
Rails configs, routes, views, assets, and database files
4646
- Rust Cargo commands, libraries, workspace crates, and integration tests
47+
- C/C++ standalone `main()` files, CMake targets, and autotools targets
4748
- SwiftPM executable targets, library targets, and test suites
4849
- nested SwiftPM packages
4950
- Apple/Xcode projects from `project.yml`, `.xcodeproj`, or `.xcworkspace`
@@ -96,6 +97,11 @@ Android UI entrypoints, ViewModels, data boundaries, or dependency injection.
9697
Kotlin dependency-injection evidence includes Hilt, Dagger, Koin, and Metro
9798
annotations and imports.
9899

100+
C/C++ mapping covers generic project shapes only: standalone source files with
101+
`main()`, CMake `add_executable` / `add_library`, and autotools `bin_PROGRAMS` /
102+
`lib_LTLIBRARIES`. It deliberately avoids project-specific C dialects such as
103+
php-src extension metadata.
104+
99105
Python mapping covers `pyproject.toml`, `setup.cfg`, `setup.py`, and
100106
`requirements.txt` metadata; `[project.scripts]`, `[tool.poetry.scripts]`,
101107
`setup.cfg` `console_scripts`, and `setup.py` console script entry points; root

docs/index.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,12 @@ stderr so pipes stay parseable.
3030

3131
## What clawpatch does
3232

33-
- **Semantic feature mapping.** Detects npm bins, Next.js routes, React Router routes, Python packages and Flask/FastAPI routes, Ruby/Rails slices, Laravel/PHP slices, Java/Kotlin Gradle modules, Go packages, Rust crates, SwiftPM targets, and common config files as reviewable units.
33+
- **Semantic feature mapping.** Detects npm bins, Next.js routes, React Router routes, Python packages and Flask/FastAPI routes, Ruby/Rails slices, Laravel/PHP slices, Java/Kotlin Gradle modules, Go packages, Rust crates, C/C++ build targets, SwiftPM targets, and common config files as reviewable units.
3434
- **Automated code review.** Reviews features with AI providers (Codex CLI today), persists findings with severity, category, and line locations.
3535
- **Explicit fix workflow.** `clawpatch fix` runs validated patches for one finding at a time, never commits or pushes automatically.
3636
- **Stable state model.** All features, findings, patches live in `.clawpatch/` as JSON, resumable across runs.
3737
- **Safety first.** Review is read-only, fix refuses dirty worktrees, never auto-commits, validates before accepting patches.
38-
- **Multi-language.** JavaScript/TypeScript, Python, Ruby, PHP/Laravel, Java/Kotlin, Go, Rust, and Swift today; more mappers planned.
38+
- **Multi-language.** JavaScript/TypeScript, Python, Ruby, PHP/Laravel, Java/Kotlin, Go, Rust, C/C++, and Swift today; more mappers planned.
3939

4040
## Pick your path
4141

docs/quickstart.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ This discovers reviewable features:
5050
- JVM semantic role groups
5151
- Ruby packages, Rails apps, executables, and tests
5252
- Rust crates and binaries
53+
- C/C++ standalone binaries and CMake/autotools targets
5354
- SwiftPM targets and tests
5455
- Laravel controllers, requests, jobs, commands, services, models, migrations, and tests
5556
- Config files

docs/spec.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,7 @@ Mappers:
704704
- Express/Fastify/Hono route registrations.
705705
- Go `cmd/*` commands and `internal/*` packages.
706706
- Rust Cargo commands, libraries, workspace crates, and integration tests.
707+
- C/C++ standalone `main()` files, CMake targets, and autotools targets.
707708
- SwiftPM executable targets, library targets, and test suites.
708709
- Test suites from common test file globs.
709710
- Config/release features from package/build/release files.

src/detect.ts

Lines changed: 121 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,19 @@ async function detectPackageManagers(root: string): Promise<string[]> {
304304
) {
305305
found.push("gradle");
306306
}
307+
if (
308+
!found.includes("cmake") &&
309+
(await containsFileNamed(root, "CMakeLists.txt", 5, shouldSkipCOrCppSearchEntry))
310+
) {
311+
found.push("cmake");
312+
}
313+
if (
314+
!found.includes("autotools") &&
315+
((await containsFileNamed(root, "Makefile.am", 5, shouldSkipCOrCppSearchEntry)) ||
316+
(await containsFileNamed(root, "Makefile.in", 5, shouldSkipCOrCppSearchEntry)))
317+
) {
318+
found.push("autotools");
319+
}
307320
if (await pathExists(join(root, "composer.json"))) {
308321
found.push("composer");
309322
}
@@ -978,12 +991,35 @@ async function detectLanguages(root: string): Promise<string[]> {
978991
) {
979992
languages.push("kotlin");
980993
}
994+
if (!languages.includes("c") && (await containsCFile(root))) {
995+
languages.push("c");
996+
}
997+
if (!languages.includes("cpp") && (await containsCppFile(root))) {
998+
languages.push("cpp");
999+
}
9811000
if (!languages.includes("php") && (await containsReviewablePhpFile(root))) {
9821001
languages.push("php");
9831002
}
9841003
return languages;
9851004
}
9861005

1006+
async function containsCFile(root: string): Promise<boolean> {
1007+
return containsFileWithExtension(root, ".c", 5, shouldSkipCOrCppSearchEntry);
1008+
}
1009+
1010+
async function containsCppFile(root: string): Promise<boolean> {
1011+
return (
1012+
(await containsFileWithExtension(root, ".C", 5, shouldSkipCOrCppSearchEntry)) ||
1013+
(await containsFileWithExtension(root, ".H", 5, shouldSkipCOrCppSearchEntry)) ||
1014+
(await containsFileWithExtensionIgnoringCase(root, ".cpp", 5, shouldSkipCOrCppSearchEntry)) ||
1015+
(await containsFileWithExtensionIgnoringCase(root, ".cc", 5, shouldSkipCOrCppSearchEntry)) ||
1016+
(await containsFileWithExtensionIgnoringCase(root, ".cxx", 5, shouldSkipCOrCppSearchEntry)) ||
1017+
(await containsFileWithExtensionIgnoringCase(root, ".hpp", 5, shouldSkipCOrCppSearchEntry)) ||
1018+
(await containsFileWithExtensionIgnoringCase(root, ".hh", 5, shouldSkipCOrCppSearchEntry)) ||
1019+
(await containsFileWithExtensionIgnoringCase(root, ".hxx", 5, shouldSkipCOrCppSearchEntry))
1020+
);
1021+
}
1022+
9871023
const jvmSourceSearchRoots = ["src", "app", "apps", "lib"] as const;
9881024

9891025
async function containsReviewableJavaFile(root: string): Promise<boolean> {
@@ -996,7 +1032,7 @@ async function containsReviewableKotlinFile(root: string): Promise<boolean> {
9961032

9971033
async function containsReviewableJvmFile(root: string, extension: string): Promise<boolean> {
9981034
for (const prefix of jvmSourceSearchRoots) {
999-
if (await containsFileWithExtension(join(root, prefix), extension, 8)) {
1035+
if (await containsFileWithExtension(join(root, prefix), extension, 8, undefined, prefix)) {
10001036
return true;
10011037
}
10021038
}
@@ -1031,7 +1067,15 @@ async function containsReviewablePythonFile(root: string): Promise<boolean> {
10311067
return true;
10321068
}
10331069
for (const prefix of pythonSourceSearchRoots) {
1034-
if (await containsFileMatching(join(root, prefix), 4, isReviewablePythonFileName)) {
1070+
if (
1071+
await containsFileMatching(
1072+
join(root, prefix),
1073+
4,
1074+
isReviewablePythonFileName,
1075+
undefined,
1076+
prefix,
1077+
)
1078+
) {
10351079
return true;
10361080
}
10371081
}
@@ -1040,7 +1084,7 @@ async function containsReviewablePythonFile(root: string): Promise<boolean> {
10401084

10411085
async function containsReviewablePhpFile(root: string): Promise<boolean> {
10421086
for (const prefix of ["app", "routes", "config", "database", "tests"]) {
1043-
if (await containsFileWithExtension(join(root, prefix), ".php", 4)) {
1087+
if (await containsFileWithExtension(join(root, prefix), ".php", 4, undefined, prefix)) {
10441088
return true;
10451089
}
10461090
}
@@ -1089,7 +1133,7 @@ async function pythonFrameworkScanFiles(root: string): Promise<string[]> {
10891133
}
10901134
}
10911135
for (const prefix of pythonSourceSearchRoots) {
1092-
await collectPythonFrameworkScanFiles(join(root, prefix), 4, files);
1136+
await collectPythonFrameworkScanFiles(join(root, prefix), 4, files, prefix);
10931137
}
10941138
return [...new Set(files)].slice(0, 200);
10951139
}
@@ -1098,6 +1142,7 @@ async function collectPythonFrameworkScanFiles(
10981142
dir: string,
10991143
remainingDepth: number,
11001144
files: string[],
1145+
relativeDir = "",
11011146
): Promise<void> {
11021147
if (remainingDepth < 0 || !(await pathExists(dir))) {
11031148
return;
@@ -1107,7 +1152,8 @@ async function collectPythonFrameworkScanFiles(
11071152
return;
11081153
}
11091154
for (const entry of await readdir(dir, { withFileTypes: true })) {
1110-
if (shouldSkipSearchEntry(entry.name)) {
1155+
const path = relativeDir === "" ? entry.name : `${relativeDir}/${entry.name}`;
1156+
if (shouldSkipSearchEntry(entry.name, path)) {
11111157
continue;
11121158
}
11131159
const full = join(dir, entry.name);
@@ -1117,7 +1163,7 @@ async function collectPythonFrameworkScanFiles(
11171163
if (entry.isFile() && isReviewablePythonFileName(entry.name)) {
11181164
files.push(full);
11191165
} else if (entry.isDirectory()) {
1120-
await collectPythonFrameworkScanFiles(full, remainingDepth - 1, files);
1166+
await collectPythonFrameworkScanFiles(full, remainingDepth - 1, files, path);
11211167
}
11221168
}
11231169
}
@@ -1127,12 +1173,14 @@ async function containsReviewableRubyFile(root: string): Promise<boolean> {
11271173
return true;
11281174
}
11291175
for (const prefix of ["app", "lib"]) {
1130-
if (await containsFileMatching(join(root, prefix), 4, isReviewableRubyFileName)) {
1176+
if (
1177+
await containsFileMatching(join(root, prefix), 4, isReviewableRubyFileName, undefined, prefix)
1178+
) {
11311179
return true;
11321180
}
11331181
}
11341182
for (const prefix of ["scripts", "script", "exe", "bin"]) {
1135-
if (await containsRubyExecutableSource(join(root, prefix), 4)) {
1183+
if (await containsRubyExecutableSource(join(root, prefix), 4, prefix)) {
11361184
return true;
11371185
}
11381186
}
@@ -1152,7 +1200,11 @@ function isRootReviewableRubyFileName(entry: string): boolean {
11521200
return isReviewableRubyFileName(entry) && !entry.startsWith("test_");
11531201
}
11541202

1155-
async function containsRubyExecutableSource(dir: string, remainingDepth: number): Promise<boolean> {
1203+
async function containsRubyExecutableSource(
1204+
dir: string,
1205+
remainingDepth: number,
1206+
relativeDir = "",
1207+
): Promise<boolean> {
11561208
if (remainingDepth < 0 || !(await pathExists(dir))) {
11571209
return false;
11581210
}
@@ -1161,7 +1213,8 @@ async function containsRubyExecutableSource(dir: string, remainingDepth: number)
11611213
return false;
11621214
}
11631215
for (const entry of await readdir(dir)) {
1164-
if (shouldSkipSearchEntry(entry)) {
1216+
const path = relativeDir === "" ? entry : `${relativeDir}/${entry}`;
1217+
if (shouldSkipSearchEntry(entry, path)) {
11651218
continue;
11661219
}
11671220
const full = join(dir, entry);
@@ -1176,7 +1229,10 @@ async function containsRubyExecutableSource(dir: string, remainingDepth: number)
11761229
) {
11771230
return true;
11781231
}
1179-
if (info.isDirectory() && (await containsRubyExecutableSource(full, remainingDepth - 1))) {
1232+
if (
1233+
info.isDirectory() &&
1234+
(await containsRubyExecutableSource(full, remainingDepth - 1, path))
1235+
) {
11801236
return true;
11811237
}
11821238
}
@@ -1241,22 +1297,54 @@ async function containsRubyPrefixedMinitestFile(
12411297
return false;
12421298
}
12431299

1244-
async function containsFileNamed(root: string, name: string, maxDepth: number): Promise<boolean> {
1245-
return containsFileMatching(root, maxDepth, (entry) => entry === name);
1300+
async function containsFileNamed(
1301+
root: string,
1302+
name: string,
1303+
maxDepth: number,
1304+
skipEntry: (entry: string, relativePath: string) => boolean = shouldSkipSearchEntry,
1305+
): Promise<boolean> {
1306+
return containsFileMatching(root, maxDepth, (entry) => entry === name, skipEntry);
12461307
}
12471308

12481309
async function containsFileWithExtension(
12491310
root: string,
12501311
extension: string,
12511312
maxDepth: number,
1313+
skipEntry: (entry: string, relativePath: string) => boolean = shouldSkipSearchEntry,
1314+
relativeDir = "",
12521315
): Promise<boolean> {
1253-
return containsFileMatching(root, maxDepth, (entry) => entry.endsWith(extension));
1316+
return containsFileMatching(
1317+
root,
1318+
maxDepth,
1319+
(entry) => entry.endsWith(extension),
1320+
skipEntry,
1321+
relativeDir,
1322+
);
1323+
}
1324+
1325+
async function containsFileWithExtensionIgnoringCase(
1326+
root: string,
1327+
extension: string,
1328+
maxDepth: number,
1329+
skipEntry: (entry: string, relativePath: string) => boolean = shouldSkipSearchEntry,
1330+
relativeDir = "",
1331+
): Promise<boolean> {
1332+
const lowercaseExtension = extension.toLowerCase();
1333+
return containsFileMatching(
1334+
root,
1335+
maxDepth,
1336+
(entry) => entry.toLowerCase().endsWith(lowercaseExtension),
1337+
skipEntry,
1338+
relativeDir,
1339+
);
12541340
}
12551341

12561342
async function containsFileMatching(
12571343
dir: string,
12581344
remainingDepth: number,
12591345
predicate: (entry: string) => boolean,
1346+
skipEntry: (entry: string, relativePath: string) => boolean = shouldSkipSearchEntry,
1347+
relativeDir = "",
12601348
): Promise<boolean> {
12611349
if (remainingDepth < 0 || !(await pathExists(dir))) {
12621350
return false;
@@ -1266,7 +1354,8 @@ async function containsFileMatching(
12661354
return false;
12671355
}
12681356
for (const entry of await readdir(dir)) {
1269-
if (shouldSkipSearchEntry(entry)) {
1357+
const relativePath = relativeDir.length === 0 ? entry : `${relativeDir}/${entry}`;
1358+
if (skipEntry(entry, relativePath)) {
12701359
continue;
12711360
}
12721361
const full = join(dir, entry);
@@ -1277,14 +1366,20 @@ async function containsFileMatching(
12771366
if (info.isFile() && predicate(entry)) {
12781367
return true;
12791368
}
1280-
if (info.isDirectory() && (await containsFileMatching(full, remainingDepth - 1, predicate))) {
1369+
if (
1370+
info.isDirectory() &&
1371+
(await containsFileMatching(full, remainingDepth - 1, predicate, skipEntry, relativePath))
1372+
) {
12811373
return true;
12821374
}
12831375
}
12841376
return false;
12851377
}
12861378

1287-
function shouldSkipSearchEntry(entry: string): boolean {
1379+
function shouldSkipSearchEntry(entry: string, relativePath = entry): boolean {
1380+
if (entry === "vendor" && relativePath === "vendor") {
1381+
return true;
1382+
}
12881383
return [
12891384
"node_modules",
12901385
"dist",
@@ -1302,7 +1397,6 @@ function shouldSkipSearchEntry(entry: string): boolean {
13021397
".ruff_cache",
13031398
".pytest_cache",
13041399
".bundle",
1305-
"vendor",
13061400
"fixtures",
13071401
"__fixtures__",
13081402
"testdata",
@@ -1313,6 +1407,15 @@ function shouldSkipSearchEntry(entry: string): boolean {
13131407
].includes(entry);
13141408
}
13151409

1410+
function shouldSkipCOrCppSearchEntry(entry: string): boolean {
1411+
return (
1412+
shouldSkipSearchEntry(entry) ||
1413+
entry === "vendor" ||
1414+
entry === "CMakeFiles" ||
1415+
/^cmake-build-[^/]+$/u.test(entry)
1416+
);
1417+
}
1418+
13161419
function stripLineComments(source: string, marker: "//"): string {
13171420
return source
13181421
.split("\n")

0 commit comments

Comments
 (0)