Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Detected Java/Kotlin language and default Gradle build/test commands for root Gradle projects.
- Added FastAPI route feature mapping and kept root/web Python project detection in sync.
- Added Flask route feature mapping for Python projects, including `web/` source roots, common root entry files, non-list method literals, and Python framework detection.
- Added Next.js route mapping for `src/app` and `src/pages` layouts, thanks @obatried.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ validation commands and records a patch attempt under `.clawpatch/`.
- Next.js `app/` and `pages/` routes
- Go package slices from `go list ./...`, including command packages
- Go package tests and same-repo imports as review context
- Java/Kotlin Gradle source groups and root Gradle build/test commands
- Rust `src/main.rs`, `src/bin/*.rs`, `src/lib.rs`, `crates/*`, and
`tests/*.rs`
- Python project metadata, console scripts, bounded source groups, pytest suites,
Expand Down
3 changes: 2 additions & 1 deletion docs/feature-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Supported deterministic mappers today:
- SwiftPM executable targets, library targets, and test suites
- nested SwiftPM packages
- Apple/Xcode projects from `project.yml`, `.xcodeproj`, or `.xcworkspace`
- Gradle/Android modules from `settings.gradle(.kts)` and `build.gradle(.kts)`
- Java/Kotlin Gradle modules from `settings.gradle(.kts)` and `build.gradle(.kts)`
- common config files

The mapper does not call a model. It uses repo conventions and cheap filesystem
Expand All @@ -53,6 +53,7 @@ be found cheaply.
Native app mappers use the same bounded grouping model. SwiftPM packages can be
discovered below the repo root, Apple projects are grouped by Swift source area,
and Gradle modules are grouped from `src/main`, `src/test`, and `src/androidTest`.
Root Gradle projects get default `gradle`/`./gradlew` build and test commands.

Python mapping covers `pyproject.toml` metadata, `[project.scripts]` and
`[tool.poetry.scripts]` console scripts, root app files, source groups under
Expand Down
1 change: 1 addition & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ This discovers reviewable features:
- npm package bins and scripts
- Next.js routes
- Go packages and commands
- Java/Kotlin Gradle modules
- Python packages, console scripts, Flask routes, and pytest suites
- Rust crates and binaries
- SwiftPM targets and tests
Expand Down
50 changes: 49 additions & 1 deletion src/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ async function languageDefaultCommands(
if (languages.includes("python")) {
return pythonDefaultCommands(root);
}
if (
(languages.includes("java") || languages.includes("kotlin")) &&
(await isRootGradleProject(root))
) {
return gradleDefaultCommands(root);
}

return {
typecheck: null,
Expand Down Expand Up @@ -240,6 +246,25 @@ async function detectPackageManagers(root: string): Promise<string[]> {

const pythonPackageManagers = new Set(["uv", "poetry", "pdm", "hatch", "pip", "python"]);

async function isRootGradleProject(root: string): Promise<boolean> {
return (
(await pathExists(join(root, "settings.gradle"))) ||
(await pathExists(join(root, "settings.gradle.kts"))) ||
(await pathExists(join(root, "build.gradle"))) ||
(await pathExists(join(root, "build.gradle.kts")))
);
}

async function gradleDefaultCommands(root: string): Promise<ProjectCommands> {
const runner = (await pathExists(join(root, "gradlew"))) ? "./gradlew" : "gradle";
return {
typecheck: `${runner} build`,
lint: null,
format: null,
test: `${runner} test`,
};
}

async function pythonDefaultCommands(root: string): Promise<ProjectCommands> {
const info = await pythonProjectInfo(root);
const runner = await pythonRunner(root);
Expand Down Expand Up @@ -725,6 +750,9 @@ async function detectLanguages(root: string): Promise<string[]> {
if (!languages.includes("python") && (await containsReviewablePythonFile(root))) {
languages.push("python");
}
if (!languages.includes("java") && (await containsReviewableJavaFile(root))) {
languages.push("java");
}
if (
!languages.includes("swift") &&
((await containsFileNamed(root, "Package.swift", 5)) ||
Expand All @@ -734,14 +762,34 @@ async function detectLanguages(root: string): Promise<string[]> {
}
if (
!languages.includes("kotlin") &&
((await containsFileWithExtension(root, ".kt", 5)) ||
((await containsReviewableKotlinFile(root)) ||
(await containsFileWithExtension(root, ".kt", 5)) ||
(await containsFileWithExtension(root, ".kts", 5)))
) {
languages.push("kotlin");
}
return languages;
}

const jvmSourceSearchRoots = ["src", "app", "apps", "lib"] as const;

async function containsReviewableJavaFile(root: string): Promise<boolean> {
return containsReviewableJvmFile(root, ".java");
}

async function containsReviewableKotlinFile(root: string): Promise<boolean> {
return containsReviewableJvmFile(root, ".kt");
}

async function containsReviewableJvmFile(root: string, extension: string): Promise<boolean> {
for (const prefix of jvmSourceSearchRoots) {
if (await containsFileWithExtension(join(root, prefix), extension, 8)) {
return true;
}
}
return false;
}

async function isPythonProject(root: string): Promise<boolean> {
return (
(await pathExists(join(root, "pyproject.toml"))) ||
Expand Down
51 changes: 51 additions & 0 deletions src/mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,55 @@ describe("mapFeatures", () => {
expect(titles).toContain("Gradle source src");
expect(titles).toContain("Gradle test suite src");
expect(titles.some((title) => title.includes("./src"))).toBe(false);
expect(project.detected.languages).toContain("kotlin");
expect(project.detected.commands).toMatchObject({
typecheck: "gradle build",
test: "gradle test",
});
});

it("detects Kotlin and Gradle commands for Groovy Gradle root projects", async () => {
const root = await fixtureRoot("clawpatch-root-kotlin-gradle-detect-");
await writeFixture(root, "settings.gradle", "pluginManagement {}\n");
await writeFixture(root, "build.gradle", "plugins { id 'org.jetbrains.kotlin.jvm' }\n");
await writeFixture(root, "src/main/kotlin/com/example/app/App.kt", "class App\n");
await writeFixture(root, "src/test/kotlin/com/example/app/AppTest.kt", "class AppTest\n");

const project = await detectProject(root);

expect(project.detected.languages).toContain("kotlin");
expect(project.detected.packageManagers).toContain("gradle");
expect(project.detected.commands).toMatchObject({
typecheck: "gradle build",
test: "gradle test",
});
});

it("detects Java and wrapper Gradle commands for root Gradle projects", async () => {
const root = await fixtureRoot("clawpatch-root-java-gradle-detect-");
await writeFixture(root, "gradlew", "#!/bin/sh\n");
await writeFixture(root, "settings.gradle", "pluginManagement {}\n");
await writeFixture(root, "build.gradle", "plugins { id 'java' }\n");
await writeFixture(root, "src/main/java/com/example/App.java", "class App {}\n");
await writeFixture(root, "src/test/java/com/example/AppTest.java", "class AppTest {}\n");

const project = await detectProject(root);

expect(project.detected.languages).toContain("java");
expect(project.detected.packageManagers).toContain("gradle");
expect(project.detected.commands).toMatchObject({
typecheck: "./gradlew build",
test: "./gradlew test",
});
});

it("does not detect Java from documentation-only Java files", async () => {
const root = await fixtureRoot("clawpatch-docs-java-detect-");
await writeFixture(root, "docs/Example.java", "class Example {}\n");

const project = await detectProject(root);

expect(project.detected.languages).not.toContain("java");
});

it("maps build.gradle-only roots without empty Gradle groups", async () => {
Expand Down Expand Up @@ -526,6 +575,8 @@ describe("mapFeatures", () => {
const titles = result.features.map((feature) => feature.title);

expect(project.detected.packageManagers).toContain("gradle");
expect(project.detected.commands.typecheck).toBeNull();
expect(project.detected.commands.test).toBeNull();
expect(titles).toContain("Gradle module apps/android");
expect(titles).toContain("Gradle source apps/android/src");
});
Expand Down