diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dc114e..9285063 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Fixed `clawpatch ci --since` empty-review output so it reports `reviewed: 0`. - Fixed formatter configuration so `oxfmt` uses two-space indentation consistently across platforms. - Added generic package-less monorepo app-root mapping for Node/Next projects under roots such as `apps/*` and `packages/*` when positive source or framework signals are present. +- Added Maven project mapping for root, nested, and multi-module Java/Kotlin projects with Spring role slices, Maven validation defaults, and `pom.xml` detection, thanks @julianshess. - Added a release-prep checklist for auditing changelog, package metadata, and dry-run package contents without publishing. - Improved OpenCode malformed JSON diagnostics with output length, event kinds, and a bounded preview, thanks @rohitjavvadi. - Fixed Express route mapping for aliased Router imports that follow block comment banners, thanks @rohitjavvadi. diff --git a/README.md b/README.md index 642c661..6b6c792 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,8 @@ validation commands and records a patch attempt under `.clawpatch/`. - React Router routes and React components - 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 +- Java/Kotlin Gradle source groups, Maven source groups, and root Gradle/Maven + build/test commands - JVM semantic roles from Java and Kotlin code evidence such as annotations, imports, interfaces, inheritance, supertypes, and method signatures - Kotlin Android semantic roles for UI entrypoints, ViewModels, data diff --git a/docs/feature-mapping.md b/docs/feature-mapping.md index ad84a6c..14174ce 100644 --- a/docs/feature-mapping.md +++ b/docs/feature-mapping.md @@ -61,6 +61,8 @@ Supported deterministic mappers today: - nested SwiftPM packages - Apple/Xcode projects from `project.yml`, `.xcodeproj`, or `.xcworkspace` - Java/Kotlin Gradle modules from `settings.gradle(.kts)` and `build.gradle(.kts)` +- Java/Kotlin Maven modules from root and nested `pom.xml` files, including + multi-module projects - Laravel/PHP projects from `composer.json` and `artisan`, including controllers referenced by routes, form requests, Artisan commands, jobs, services, models, migrations, seeders, Composer scripts, and grouped PHP test suites @@ -129,12 +131,15 @@ lazy import, and also maps page/component files under `src/pages` and `src/components` as UI-flow slices. 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. -Java and Kotlin files in Gradle modules also get role-oriented review slices -when code evidence identifies web entrypoints, services, persistence boundaries, -external clients, configuration, framework components, extension boundaries, -Android UI entrypoints, ViewModels, data boundaries, or dependency injection. +Gradle modules are grouped from `src/main`, `src/test`, and `src/androidTest`, +and Maven modules are grouped from `src/main` and `src/test`. Root Gradle +projects get default `gradle`/`./gradlew` build and test commands; root Maven +projects get default `mvn`/`./mvnw` compile and test commands. +Java and Kotlin files in Gradle modules, plus Java files in Maven modules, also +get role-oriented review slices when code evidence identifies web entrypoints, +services, persistence boundaries, external clients, configuration, framework +components, extension boundaries, Android UI entrypoints, ViewModels, data +boundaries, or dependency injection. Kotlin dependency-injection evidence includes Hilt, Dagger, Koin, and Metro annotations and imports. diff --git a/docs/index.md b/docs/index.md index b3688b6..6e25dec 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,7 +30,7 @@ stderr so pipes stay parseable. ## What clawpatch does -- **Semantic feature mapping.** Detects npm bins, Next.js routes, React Router routes, Python packages and Flask/FastAPI/Django routes, Ruby/Rails slices, Laravel/PHP slices, Java/Kotlin Gradle modules, C#/.NET projects and ASP.NET endpoints, Go packages, Rust crates, C/C++ build targets, SwiftPM targets, and common config files as reviewable units. +- **Semantic feature mapping.** Detects npm bins, Next.js routes, React Router routes, Python packages and Flask/FastAPI/Django routes, Ruby/Rails slices, Laravel/PHP slices, Java/Kotlin Gradle and Maven modules, C#/.NET projects and ASP.NET endpoints, Go packages, Rust crates, C/C++ build targets, SwiftPM targets, and common config files as reviewable units. - **Automated code review.** Reviews features with AI providers (Codex CLI today), persists findings with severity, category, and line locations. - **Explicit fix workflow.** `clawpatch fix` runs validated patches for one finding at a time, never commits or pushes automatically. - **Stable state model.** All features, findings, patches live in `.clawpatch/` as JSON, resumable across runs. diff --git a/docs/quickstart.md b/docs/quickstart.md index 11142f1..9504acd 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -47,7 +47,7 @@ This discovers reviewable features: - npm package bins and root/workspace scripts - Next.js routes - Go packages and commands -- Java/Kotlin Gradle modules +- Java/Kotlin Gradle and Maven modules - Python packages, console scripts, Flask/FastAPI/Django routes, and pytest suites - C#/.NET projects, ASP.NET endpoints, source groups, and test projects - JVM semantic role groups diff --git a/src/detect.ts b/src/detect.ts index 3826e49..926302a 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -234,6 +234,9 @@ async function languageDefaultCommands( ) { return gradleDefaultCommands(root); } + if (await isRootMavenProject(root)) { + return mavenDefaultCommands(root); + } if (languages.includes("elixir")) { return elixirDefaultCommands(root); } @@ -331,6 +334,9 @@ async function detectPackageManagers(root: string): Promise { ) { found.push("gradle"); } + if (!found.includes("maven") && (await containsFileNamed(root, "pom.xml", 5))) { + found.push("maven"); + } if ( !found.includes("cmake") && (await containsFileNamed(root, "CMakeLists.txt", 5, shouldSkipCOrCppSearchEntry)) @@ -408,6 +414,20 @@ async function gradleDefaultCommands(root: string): Promise { }; } +async function isRootMavenProject(root: string): Promise { + return await pathExists(join(root, "pom.xml")); +} + +async function mavenDefaultCommands(root: string): Promise { + const runner = (await pathExists(join(root, "mvnw"))) ? "./mvnw" : "mvn"; + return { + typecheck: `${runner} -DskipTests compile`, + lint: null, + format: null, + test: `${runner} test`, + }; +} + async function dotnetDefaultCommands(root: string): Promise { const target = await dotnetValidationTarget(root); const testTarget = await dotnetTestTarget(root, target); @@ -1114,6 +1134,11 @@ async function detectFrameworks( frameworks.push(name); } } + for (const name of await detectMavenFrameworks(root)) { + if (!frameworks.includes(name)) { + frameworks.push(name); + } + } for (const name of await detectDotnetFrameworks(root)) { if (!frameworks.includes(name)) { frameworks.push(name); @@ -1122,6 +1147,32 @@ async function detectFrameworks( return uniqueStrings(frameworks); } +async function detectMavenFrameworks(root: string): Promise { + const frameworks: string[] = []; + for (const pom of await collectMavenPomFiles(root, 5)) { + const source = await readFile(join(root, pom), "utf8").catch(() => ""); + const activeSource = stripXmlComments(source); + if (mavenPomHasSpring(activeSource)) { + frameworks.push("spring"); + } + if (mavenPomHasSpringBoot(activeSource)) { + frameworks.push("spring-boot"); + } + } + return uniqueStrings(frameworks); +} + +function mavenPomHasSpring(source: string): boolean { + return /\s*org\.springframework(?:\.[^<]*)?\s*<\/groupId>/iu.test(source); +} + +function mavenPomHasSpringBoot(source: string): boolean { + return ( + /\s*org\.springframework\.boot\s*<\/groupId>/iu.test(source) || + /\s*spring-boot-[^<]*\s*<\/artifactId>/iu.test(source) + ); +} + async function detectDotnetFrameworks(root: string): Promise { const frameworks: string[] = []; for (const project of await collectDotnetFiles(root, isDotnetProjectFileName, 5)) { @@ -1720,6 +1771,43 @@ async function collectDotnetFiles( return [...new Set(files)].toSorted(); } +async function collectMavenPomFiles(root: string, maxDepth: number): Promise { + const files: string[] = []; + await collectMavenPomFilesAt(root, maxDepth, files); + return [...new Set(files)].toSorted(); +} + +async function collectMavenPomFilesAt( + dir: string, + remainingDepth: number, + files: string[], + relativeDir = "", +): Promise { + if (remainingDepth < 0 || !(await pathExists(dir))) { + return; + } + const dirInfo = await lstat(dir); + if (!dirInfo.isDirectory() || dirInfo.isSymbolicLink()) { + return; + } + for (const entry of await readdir(dir)) { + const relativePath = relativeDir.length === 0 ? entry : `${relativeDir}/${entry}`; + if (shouldSkipSearchEntry(entry, relativePath)) { + continue; + } + const full = join(dir, entry); + const info = await lstat(full); + if (info.isSymbolicLink()) { + continue; + } + if (info.isFile() && entry === "pom.xml") { + files.push(relativePath); + } else if (info.isDirectory()) { + await collectMavenPomFilesAt(full, remainingDepth - 1, files, relativePath); + } + } +} + async function collectDotnetFilesAt( dir: string, remainingDepth: number, @@ -1781,7 +1869,22 @@ function hasDotnetTestFrameworkEvidence(source: string): boolean { } function stripXmlComments(source: string): string { - return source.replace(//gu, ""); + let output = ""; + let index = 0; + while (index < source.length) { + const start = source.indexOf("", start + 4); + if (end === -1) { + break; + } + index = end + 3; + } + return output; } function isDotnetWebProject(source: string): boolean { diff --git a/src/mapper.test.ts b/src/mapper.test.ts index a992fe2..9b324b4 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1,5 +1,5 @@ import { mkdir, symlink } from "node:fs/promises"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { describe, expect, it } from "vitest"; import { detectProject } from "./detect.js"; import { mapFeatures } from "./mapper.js"; @@ -7,6 +7,8 @@ import { discoverNodeProjects } from "./mappers/projects.js"; import { turboTaskGraph } from "./mappers/turbo.js"; import { fixtureRoot, writeFixture } from "./test-helpers.js"; +const symlinkIt = process.platform === "win32" ? it.skip : it; + describe("mapFeatures", () => { it("maps package bins, scripts, configs, and Next routes", async () => { const root = await fixtureRoot("clawpatch-map-"); @@ -1466,15 +1468,16 @@ describe("mapFeatures", () => { const project = await detectProject(root); const result = await mapFeatures(root, project, []); const titles = result.features.map((feature) => feature.title); + const rootName = basename(root); const rubyProject = result.features.find( - (feature) => feature.title === `Ruby project ${root.split("/").at(-1)}`, + (feature) => feature.title === `Ruby project ${rootName}`, ); const siteConfig = result.features.find( (feature) => feature.title === "Jekyll site configuration", ); expect(project.detected.frameworks).toContain("jekyll"); - expect(titles).toContain(`Ruby project ${root.split("/").at(-1)}`); + expect(titles).toContain(`Ruby project ${rootName}`); expect(titles).not.toContain("Ruby project jekyll"); expect(titles).toContain("Jekyll site configuration"); expect(titles).toContain("Jekyll theme _layouts"); @@ -1583,8 +1586,9 @@ describe("mapFeatures", () => { ...feature.ownedFiles.map((ref) => ref.path), ...feature.contextFiles.map((ref) => ref.path), ]); + const rootName = basename(root); const rubyProject = result.features.find( - (feature) => feature.title === `Ruby project ${root.split("/").at(-1)}`, + (feature) => feature.title === `Ruby project ${rootName}`, ); const nodePackage = result.features.find( (feature) => feature.title === "Node package rails-webpacker-shell", @@ -1773,8 +1777,10 @@ describe("mapFeatures", () => { "../outside-workspace/evil/src/index.ts", "export function evil() {}\n", ); - await symlink(join(root, "../outside-workspace"), join(root, "linked-pkg"), "dir"); - await symlink(join(root, "../outside-workspace"), join(root, "linked"), "dir"); + if (process.platform !== "win32") { + await symlink(join(root, "../outside-workspace"), join(root, "linked-pkg"), "dir"); + await symlink(join(root, "../outside-workspace"), join(root, "linked"), "dir"); + } const project = await detectProject(root); const result = await mapFeatures(root, project, []); @@ -3342,10 +3348,12 @@ describe("mapFeatures", () => { "../outside-linked.tsx", "export default function LinkedPage() { return null; }\n", ); - await symlink( - join(root, "../outside-linked.tsx"), - join(root, "frontend/src/pages/LinkedPage.tsx"), - ); + if (process.platform !== "win32") { + await symlink( + join(root, "../outside-linked.tsx"), + join(root, "frontend/src/pages/LinkedPage.tsx"), + ); + } await writeFixture( root, "frontend/src/components/Dialog.tsx", @@ -3710,7 +3718,7 @@ describe("mapFeatures", () => { expect(titles).not.toContain("React route /ambiguous"); }); - it("does not discover React packages through symlinked package roots", async () => { + symlinkIt("does not discover React packages through symlinked package roots", async () => { const root = await fixtureRoot("clawpatch-react-symlink-package-"); const outside = join(root, "../outside-react-package"); const outsidePackages = join(root, "../outside-react-packages"); @@ -9486,6 +9494,454 @@ describe("mapFeatures", () => { ).toBe(true); }); + it("detects Maven root projects and wrapper validation commands", async () => { + const root = await fixtureRoot("clawpatch-root-maven-map-"); + await writeFixture(root, "mvnw", "#!/bin/sh\n"); + await writeFixture( + root, + "pom.xml", + [ + "", + " 4.0.0", + " com.acme", + " demo-app", + " 1.0.0", + "", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/java/com/acme/App.java", + "package com.acme;\nclass App {}\n", + ); + await writeFixture( + root, + "src/test/java/com/acme/AppTest.java", + "package com.acme;\nclass AppTest {}\n", + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const titles = result.features.map((feature) => feature.title); + + expect(project.detected.languages).toContain("java"); + expect(project.detected.packageManagers).toContain("maven"); + expect(project.detected.commands).toMatchObject({ + typecheck: "./mvnw -DskipTests compile", + test: "./mvnw test", + }); + expect(titles).toContain("Maven module demo-app"); + expect(titles).toContain("Maven source src"); + expect(titles).toContain("Maven test suite src"); + expect(result.features.find((feature) => feature.title === "Maven source src")?.tests).toEqual([ + { path: "src/test/java/com/acme/AppTest.java", command: null }, + ]); + }); + + it("preserves Gradle validation commands when root Maven and Gradle files coexist", async () => { + const root = await fixtureRoot("clawpatch-root-maven-gradle-commands-"); + await writeFixture(root, "pom.xml", "dual-build\n"); + await writeFixture(root, "build.gradle", "plugins { id 'java' }\n"); + await writeFixture( + root, + "src/main/java/com/acme/App.java", + "package com.acme;\nclass App {}\n", + ); + + const project = await detectProject(root); + + expect(project.detected.packageManagers).toEqual(expect.arrayContaining(["gradle", "maven"])); + expect(project.detected.commands).toMatchObject({ + typecheck: "gradle build", + test: "gradle test", + }); + }); + + it("maps Spring JVM roles from Maven Java projects", async () => { + const root = await fixtureRoot("clawpatch-maven-spring-role-map-"); + await writeFixture( + root, + "pom.xml", + [ + "", + " 4.0.0", + " com.acme", + " orders-api", + " 1.0.0", + " ", + " ", + " org.springframework.boot", + " spring-boot-starter-web", + " ", + " ", + " org.springframework.boot", + " spring-boot-starter-data-jpa", + " ", + " ", + "", + "", + ].join("\n"), + ); + await writeFixture(root, ".mvn/maven.config", "-Dspring.profiles.active=local\n"); + await writeFixture(root, "src/main/resources/application.yml", "server:\n port: 8080\n"); + await writeFixture( + root, + "src/main/java/com/acme/api/OrderController.java", + [ + "package com.acme.api;", + "", + "import org.springframework.web.bind.annotation.GetMapping;", + "import org.springframework.web.bind.annotation.RestController;", + "", + "@RestController", + "public class OrderController {", + ' @GetMapping("/orders")', + ' public String list() { return "ok"; }', + "}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/java/com/acme/app/BillingService.java", + [ + "package com.acme.app;", + "", + "import org.springframework.stereotype.Service;", + "", + "@Service", + "public class BillingService {}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/java/com/acme/db/OrderRepository.java", + [ + "package com.acme.db;", + "", + "import org.springframework.stereotype.Repository;", + "", + "@Repository", + "public interface OrderRepository {}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/java/com/acme/client/RemoteClient.java", + [ + "package com.acme.client;", + "", + "import java.net.http.HttpClient;", + "", + "public class RemoteClient {", + " private final HttpClient client = HttpClient.newHttpClient();", + "}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/java/com/acme/config/AppConfig.java", + [ + "package com.acme.config;", + "", + "import org.springframework.context.annotation.Configuration;", + "", + "@Configuration", + "public class AppConfig {}", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const bySource = new Map(result.features.map((feature) => [feature.source, feature])); + const module = result.features.find((feature) => feature.title === "Maven module orders-api"); + + expect(project.detected.frameworks).toEqual(expect.arrayContaining(["spring", "spring-boot"])); + expect(module?.tags).toEqual( + expect.arrayContaining([ + "maven", + "project:orders-api", + "project-root:.", + "spring", + "spring-boot", + ]), + ); + expect(module?.contextFiles.map((file) => file.path).toSorted()).toEqual( + [".mvn/maven.config", "src/main/resources/application.yml"].toSorted(), + ); + expect(bySource.get("jvm-role-web-entrypoint")?.ownedFiles[0]?.path).toBe( + "src/main/java/com/acme/api/OrderController.java", + ); + expect(bySource.get("jvm-role-application-service")?.ownedFiles[0]?.path).toBe( + "src/main/java/com/acme/app/BillingService.java", + ); + expect(bySource.get("jvm-role-persistence-boundary")?.ownedFiles[0]?.path).toBe( + "src/main/java/com/acme/db/OrderRepository.java", + ); + expect(bySource.get("jvm-role-external-client")?.ownedFiles[0]?.path).toBe( + "src/main/java/com/acme/client/RemoteClient.java", + ); + expect(bySource.get("jvm-role-configuration")?.ownedFiles[0]?.path).toBe( + "src/main/java/com/acme/config/AppConfig.java", + ); + }); + + it("ignores Maven metadata inside XML comments", async () => { + const root = await fixtureRoot("clawpatch-maven-xml-comments-"); + await writeFixture( + root, + "pom.xml", + [ + "", + " 4.0.0", + " com.acme", + " commented-app", + " 1.0.0", + " ", + " ", + "", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/main/java/com/acme/App.java", + "package com.acme;\nclass App {}\n", + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const titles = result.features.map((feature) => feature.title); + + expect(project.detected.frameworks).not.toContain("spring"); + expect(project.detected.frameworks).not.toContain("spring-boot"); + expect(titles).toContain("Maven module commented-app"); + expect(titles).not.toContain("Maven module ghost"); + }); + + it("maps Maven multi-module projects without empty parent source groups", async () => { + const root = await fixtureRoot("clawpatch-maven-multimodule-map-"); + await writeFixture( + root, + "pom.xml", + [ + "", + " 4.0.0", + " com.acme", + " root-parent", + " 1.0.0", + " pom", + " ", + " core", + " services", + " ", + "", + "", + ].join("\n"), + ); + await writeFixture( + root, + "core/pom.xml", + [ + "", + " 4.0.0", + " ", + " com.acme", + " root-parent", + " 1.0.0", + " ", + " core-service", + "", + "", + ].join("\n"), + ); + await writeFixture( + root, + "core/src/main/java/com/acme/Core.java", + "package com.acme;\nclass Core {}\n", + ); + await writeFixture( + root, + "services/pom.xml", + [ + "", + " 4.0.0", + " ", + " com.acme", + " root-parent", + " 1.0.0", + " ", + " services-parent", + " pom", + " ", + " api", + " ../shared", + " ", + "", + "", + ].join("\n"), + ); + await writeFixture( + root, + "services/api/pom.xml", + [ + "", + " 4.0.0", + " ", + " com.acme", + " root-parent", + " 1.0.0", + " ", + " api-service", + "", + "", + ].join("\n"), + ); + await writeFixture( + root, + "services/api/src/main/java/com/acme/api/Api.java", + "package com.acme.api;\nclass Api {}\n", + ); + await writeFixture( + root, + "shared/pom.xml", + [ + "", + " 4.0.0", + " ", + " com.acme", + " root-parent", + " 1.0.0", + " ", + " shared-library", + "", + "", + ].join("\n"), + ); + await writeFixture( + root, + "shared/src/main/java/com/acme/shared/Shared.java", + "package com.acme.shared;\nclass Shared {}\n", + ); + await writeFixture( + root, + "tools/standalone/pom.xml", + [ + "", + " 4.0.0", + " com.acme.tools", + " standalone-tool", + " 1.0.0", + "", + "", + ].join("\n"), + ); + await writeFixture( + root, + "tools/standalone/src/main/java/com/acme/tools/Tool.java", + "package com.acme.tools;\nclass Tool {}\n", + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const titles = result.features.map((feature) => feature.title); + const core = result.features.find((feature) => feature.title === "Maven module core-service"); + + expect(project.detected.packageManagers).toContain("maven"); + expect(project.detected.commands).toMatchObject({ + typecheck: "mvn -DskipTests compile", + test: "mvn test", + }); + expect(titles).toContain("Maven module root-parent"); + expect(titles).toContain("Maven module services-parent"); + expect(titles).toContain("Maven module core-service"); + expect(titles).toContain("Maven module api-service"); + expect(titles).toContain("Maven module shared-library"); + expect(titles).toContain("Maven module standalone-tool"); + expect(titles).toContain("Maven source core/src"); + expect(titles).toContain("Maven source services/api/src"); + expect(titles).toContain("Maven source shared/src"); + expect(titles).toContain("Maven source tools/standalone/src"); + expect(titles).not.toContain("Maven source src"); + expect(titles).not.toContain("Maven source services/src"); + expect(titles.filter((title) => title === "Maven module api-service")).toHaveLength(1); + expect(titles.filter((title) => title === "Maven module shared-library")).toHaveLength(1); + expect(core?.tags).toEqual( + expect.arrayContaining(["project:core-service", "project-root:core"]), + ); + }); + + it("maps nested Maven projects without assigning root validation commands", async () => { + const root = await fixtureRoot("clawpatch-nested-maven-map-"); + await writeFixture(root, "package.json", JSON.stringify({ name: "host" }, null, 2)); + await writeFixture( + root, + "apps/service/pom.xml", + [ + "", + " 4.0.0", + " com.acme", + " service-app", + " 1.0.0", + "", + "", + ].join("\n"), + ); + await writeFixture( + root, + "apps/service/src/main/java/com/acme/App.java", + "package com.acme;\nclass App {}\n", + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const titles = result.features.map((feature) => feature.title); + + expect(project.detected.packageManagers).toContain("maven"); + expect(project.detected.commands.typecheck).toBeNull(); + expect(project.detected.commands.test).toBeNull(); + expect(titles).toContain("Maven module service-app"); + expect(titles).toContain("Maven source apps/service/src"); + }); + + it("ignores Maven manifests under fixtures and testdata during detection", async () => { + const root = await fixtureRoot("clawpatch-maven-fixture-detect-"); + await writeFixture(root, "package.json", JSON.stringify({ name: "host" }, null, 2)); + await writeFixture( + root, + "testdata/pom.xml", + "fixture\n", + ); + await writeFixture(root, "testdata/src/main/java/com/example/App.java", "class App {}\n"); + await writeFixture( + root, + "fixtures/service/pom.xml", + "sample\n", + ); + await writeFixture( + root, + "fixtures/service/src/main/java/com/example/App.java", + "class App {}\n", + ); + await writeFixture( + root, + "vendor/lib/pom.xml", + "vendored\n", + ); + await writeFixture(root, "vendor/lib/src/main/java/com/example/App.java", "class App {}\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect(project.detected.packageManagers).not.toContain("maven"); + expect(result.features.some((feature) => feature.source.startsWith("maven-"))).toBe(false); + }); + it("ignores vendored SwiftPM manifests during detection", async () => { const root = await fixtureRoot("clawpatch-vendored-swiftpm-detect-"); await writeFixture(root, "package.json", JSON.stringify({ name: "host" }, null, 2)); @@ -9687,7 +10143,7 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) expect(command?.tests).toEqual([{ path: "root_test.go", command: "go test ./..." }]); }); - it("maps Go packages from symlinked explicit roots", async () => { + symlinkIt("maps Go packages from symlinked explicit roots", async () => { const root = await fixtureRoot("clawpatch-go-symlink-real-"); const link = `${root}-link`; await writeFixture(root, "go.mod", "module example.com/symlink\n\ngo 1.26\n"); @@ -9893,13 +10349,14 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) it("maps CMake C and C++ targets without duplicating main files", async () => { const root = await fixtureRoot("clawpatch-cmake-cpp-map-"); + const cmakeRoot = root.replaceAll("\\", "/"); await writeFixture( root, "CMakeLists.txt", `add_executable(myapp src/main.cpp src/util.cpp) add_executable(quoted "src/quoted.cpp") ADD_EXECUTABLE(upper src/upper.c) -add_executable(absin ${root}/src/absin.cpp) +add_executable(absin ${cmakeRoot}/src/absin.cpp) add_executable(absout /src/main.cpp) add_executable(7zip src/seven.c) add_executable(latebin) @@ -12197,7 +12654,7 @@ add_executable(headerapp include/headers.hpp) expect(cli?.tests).toEqual([{ path: "test_cli.py", command: "pytest" }]); }); - it("does not resolve Python console scripts through symlinked package dirs", async () => { + symlinkIt("does not resolve Python console scripts through symlinked package dirs", async () => { const root = await fixtureRoot("clawpatch-python-script-symlink-root-"); const external = await fixtureRoot("clawpatch-python-script-symlink-external-"); await writeFixture( @@ -13480,7 +13937,7 @@ members = ["tools/old"] expect(titles).not.toContain("Rust library old"); }); - it("skips duplicate and symlinked Cargo workspace members", async () => { + symlinkIt("skips duplicate and symlinked Cargo workspace members", async () => { const root = await fixtureRoot("clawpatch-rust-workspace-safe-"); const external = await fixtureRoot("clawpatch-rust-workspace-external-"); await writeFixture( @@ -13506,7 +13963,7 @@ members = ["tools/old"] expect(paths.some((path) => path.startsWith("../"))).toBe(false); }); - it("does not scan symlinked conventional crates directories", async () => { + symlinkIt("does not scan symlinked conventional crates directories", async () => { const root = await fixtureRoot("clawpatch-rust-crates-symlink-root-"); const external = await fixtureRoot("clawpatch-rust-crates-symlink-external-"); await writeFixture(root, "Cargo.toml", '[package]\nname = "rootpkg"\n'); @@ -13523,7 +13980,7 @@ members = ["tools/old"] expect(titles).not.toContain("Rust library outside-member"); }); - it("does not map Rust entrypoints through symlinked source directories", async () => { + symlinkIt("does not map Rust entrypoints through symlinked source directories", async () => { const root = await fixtureRoot("clawpatch-rust-src-symlink-root-"); const externalRoot = await fixtureRoot("clawpatch-rust-src-symlink-external-root-"); const externalMember = await fixtureRoot("clawpatch-rust-src-symlink-external-member-"); @@ -13967,7 +14424,7 @@ let package = Package( expect(paths.some((path) => path.startsWith("../"))).toBe(false); }); - it("ignores SwiftPM custom paths through symlinks outside the repo", async () => { + symlinkIt("ignores SwiftPM custom paths through symlinks outside the repo", async () => { const root = await fixtureRoot("clawpatch-swift-symlink-path-"); const external = await fixtureRoot("clawpatch-swift-external-path-"); await writeFixture( @@ -14031,7 +14488,7 @@ let package = Package(name: "NoTests", targets: [.executableTarget(name: "NoTest expect(feature?.tests).toEqual([]); }); - it("ignores symlinked SwiftPM test directories", async () => { + symlinkIt("ignores symlinked SwiftPM test directories", async () => { const root = await fixtureRoot("clawpatch-swift-symlink-tests-"); const external = await fixtureRoot("clawpatch-swift-external-tests-"); await writeFixture( diff --git a/src/mapper.ts b/src/mapper.ts index efa6d0e..e3ce738 100644 --- a/src/mapper.ts +++ b/src/mapper.ts @@ -8,6 +8,7 @@ import { goSeeds } from "./mappers/go.js"; import { appleSeeds } from "./mappers/apple.js"; import { gradleSeeds } from "./mappers/gradle.js"; import { laravelSeeds } from "./mappers/laravel.js"; +import { mavenSeeds } from "./mappers/maven.js"; import { nextSeeds } from "./mappers/next.js"; import { nodeRouteSeeds } from "./mappers/node-routes.js"; import { nodeSeeds } from "./mappers/node.js"; @@ -55,6 +56,7 @@ const featureMappers: FeatureMapper[] = [ { name: "swift", map: swiftSeeds }, { name: "apple", map: appleSeeds }, { name: "gradle", map: gradleSeeds }, + { name: "maven", map: mavenSeeds }, { name: "laravel", map: laravelSeeds }, { name: "config", map: configSeeds }, ]; diff --git a/src/mappers/dotnet.ts b/src/mappers/dotnet.ts index 1d4815c..5f75d9c 100644 --- a/src/mappers/dotnet.ts +++ b/src/mappers/dotnet.ts @@ -949,7 +949,22 @@ function normalizeMsbuildPath(path: string): string { } function stripXmlComments(source: string): string { - return source.replace(//gu, ""); + let output = ""; + let index = 0; + while (index < source.length) { + const start = source.indexOf("", start + 4); + if (end === -1) { + break; + } + index = end + 3; + } + return output; } function isStrongTestProject(source: string): boolean { diff --git a/src/mappers/gradle.ts b/src/mappers/gradle.ts index c8c62da..e678950 100644 --- a/src/mappers/gradle.ts +++ b/src/mappers/gradle.ts @@ -1,12 +1,18 @@ import { lstat, readFile, readdir } from "node:fs/promises"; -import { basename, dirname, join } from "node:path"; +import { join } from "node:path"; import { pathExists } from "../fs.js"; import { partitionFileGroups } from "./grouping.js"; +import { + associatedJvmTests, + isExternalProjectImport, + isNetworkClientImport, + jvmRoleSeeds, + parseJavaFile, +} from "./jvm.js"; import { isSampleProjectPath, normalize, pathMatchesPrefix, shouldSkip, walk } from "./shared.js"; -import { FeatureSeed, SeedTestRef } from "./types.js"; +import { FeatureSeed } from "./types.js"; const maxOwnedFiles = 12; -const maxTests = 8; const emptyProjectPackages = new Set(); const kotlinBuiltinTypes = new Set([ "AbstractMethodError", @@ -180,76 +186,6 @@ const kotlinBuiltinTypes = new Set([ "UShortIterator", "Void", ]); -const jvmRoleDefinitions = { - "web-entrypoint": { - title: "web entrypoint", - kind: "route", - tags: ["jvm", "web"], - trustBoundaries: ["network", "user-input", "serialization"], - }, - "application-service": { - title: "application service", - kind: "service", - tags: ["jvm", "service"], - trustBoundaries: [], - }, - "persistence-boundary": { - title: "persistence boundary", - kind: "service", - tags: ["jvm", "persistence"], - trustBoundaries: ["database", "serialization"], - }, - "external-client": { - title: "external client", - kind: "service", - tags: ["jvm", "external-api"], - trustBoundaries: ["network", "external-api", "serialization"], - }, - configuration: { - title: "configuration", - kind: "config", - tags: ["jvm", "config"], - trustBoundaries: ["filesystem"], - }, - "framework-component": { - title: "framework component", - kind: "library", - tags: ["jvm", "framework"], - trustBoundaries: [], - }, - "extension-boundary": { - title: "extension boundary", - kind: "library", - tags: ["jvm", "interface"], - trustBoundaries: [], - }, -} as const satisfies Record< - string, - { - title: string; - kind: FeatureSeed["kind"]; - tags: string[]; - trustBoundaries: FeatureSeed["trustBoundaries"]; - } ->; -type JvmRoleKey = keyof typeof jvmRoleDefinitions; -type JvmRoleEvidence = { - role: JvmRoleKey; - reason: string; -}; -type JavaDeclaration = { - kind: "class" | "interface" | "record" | "enum"; - name: string; - extendsTypes: string[]; - implementsTypes: string[]; -}; -type JavaFileInfo = { - packageName: string | null; - annotations: Set; - imports: Map; - declarations: JavaDeclaration[]; - methodReturnTypes: Set; -}; const kotlinRoleDefinitions = { "android-ui-entrypoint": { title: "UI entrypoint", @@ -405,7 +341,7 @@ async function gradleProjectSeeds(root: string, gradleRoot: string): Promise { - const matches = new Map>(); - const javaFiles: Array<{ filePath: string; info: JavaFileInfo }> = []; - for (const filePath of sourceFiles.filter((file) => file.endsWith(".java"))) { - const source = await readFile(join(root, filePath), "utf8"); - javaFiles.push({ filePath, info: parseJavaFile(source) }); - } - const projectPackages = new Set( - javaFiles.flatMap(({ info }) => (info.packageName === null ? [] : [info.packageName])), - ); - - for (const { filePath, info } of javaFiles) { - for (const evidence of jvmRoleEvidence(info, projectPackages)) { - const byFile = matches.get(evidence.role) ?? new Map(); - const reasons = byFile.get(filePath) ?? []; - reasons.push(evidence.reason); - byFile.set(filePath, reasons); - matches.set(evidence.role, byFile); - } - } - - const seeds: FeatureSeed[] = []; - for (const [role, byFile] of [...matches.entries()].toSorted(([left], [right]) => - left.localeCompare(right), - )) { - const definition = jvmRoleDefinitions[role]; - for (const group of partitionFileGroups(sourceRoot, [...byFile.keys()], maxOwnedFiles)) { - const tests = associatedGradleTests(group.files, testFiles); - seeds.push({ - title: `JVM role ${definition.title} ${group.label}`, - summary: `JVM ${definition.title} group ${group.label} with ${group.files.length} files, classified from Java code evidence.`, - kind: definition.kind, - source: `jvm-role-${role}`, - confidence: role === "extension-boundary" ? "medium" : "high", - entryPath: buildFile, - symbol: group.label, - route: null, - command: null, - ownedFiles: group.files.map((path) => ({ - path, - reason: `jvm ${definition.title} evidence: ${unique(byFile.get(path) ?? []).join("; ")}`, - })), - contextFiles: tests.map((test) => ({ path: test.path, reason: "associated gradle test" })), - tests, - tags: [...tags, ...definition.tags], - trustBoundaries: definition.trustBoundaries, - skipNearbyTests: true, - }); - } - } - return seeds; -} - -function jvmRoleEvidence(info: JavaFileInfo, projectPackages: Set): JvmRoleEvidence[] { - const evidence: JvmRoleEvidence[] = []; - evidence.push(...annotationEvidence(info)); - evidence.push(...importEvidence(info)); - evidence.push(...declarationEvidence(info, projectPackages)); - evidence.push(...methodReturnEvidence(info, projectPackages)); - return dedupeEvidence(evidence); -} - function kotlinFrameworkRoleEvidence( info: KotlinFileInfo, tags: string[], @@ -1283,45 +1150,6 @@ function isKotlinServerWebAnnotationImport(full: string): boolean { ); } -function parseJavaFile(source: string): JavaFileInfo { - const stripped = stripJavaComments(source); - const packageName = /^\s*package\s+([A-Za-z0-9_.]+)\s*;/mu.exec(stripped)?.[1] ?? null; - const imports = new Map(); - for (const match of stripped.matchAll(/^\s*import\s+(?:static\s+)?([A-Za-z0-9_.]+)\s*;/gmu)) { - const full = match[1]; - const simple = full?.split(".").at(-1); - if (full !== undefined && simple !== undefined) { - imports.set(simple, full); - } - } - - const annotations = new Set(); - for (const match of stripped.matchAll(/@([A-Za-z_][A-Za-z0-9_.]*)/gu)) { - const raw = match[1]; - if (raw !== undefined) { - annotations.add(raw.split(".").at(-1) ?? raw); - } - } - - const methodReturnTypes = new Set(); - for (const match of stripped.matchAll( - /\b(?:public|protected|private|static|final|abstract|synchronized|native|default|\s)+([A-Z][A-Za-z0-9_$.<>?]*)\s+[A-Za-z_][A-Za-z0-9_]*\s*\(/gu, - )) { - const type = match[1]; - if (type !== undefined) { - methodReturnTypes.add(baseJavaTypeName(stripGenericParameters(type))); - } - } - - return { - packageName, - annotations, - imports, - declarations: parseJavaDeclarations(stripped), - methodReturnTypes, - }; -} - function parseKotlinFile(source: string): KotlinFileInfo { const stripped = stripKotlinComments(source); const packageName = /^\s*package\s+([A-Za-z0-9_.]+)\s*;?/mu.exec(stripped)?.[1] ?? null; @@ -1377,26 +1205,6 @@ function parseKotlinFile(source: string): KotlinFileInfo { }; } -function parseJavaDeclarations(source: string): JavaDeclaration[] { - const declarations: JavaDeclaration[] = []; - const declarationPattern = - /\b(class|interface|record|enum)\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s*<[^{};]*>)?(?:\s*\([^{};]*\))?(?:\s+extends\s+([^{]+?))?(?:\s+implements\s+([^{]+?))?\s*\{/gsu; - for (const match of source.matchAll(declarationPattern)) { - const kind = match[1]; - const name = match[2]; - if (kind === undefined || name === undefined) { - continue; - } - declarations.push({ - kind: kind as JavaDeclaration["kind"], - name, - extendsTypes: match[3] === undefined ? [] : javaTypeNames(match[3]), - implementsTypes: match[4] === undefined ? [] : javaTypeNames(match[4]), - }); - } - return declarations; -} - function parseKotlinDeclarations(source: string): KotlinDeclaration[] { const declarations: KotlinDeclaration[] = []; const declarationPattern = @@ -1416,134 +1224,6 @@ function parseKotlinDeclarations(source: string): KotlinDeclaration[] { return declarations; } -function annotationEvidence(info: JavaFileInfo): JvmRoleEvidence[] { - const evidence: JvmRoleEvidence[] = []; - for (const annotation of info.annotations) { - if ( - [ - "Controller", - "RestController", - "RequestMapping", - "Path", - "GET", - "POST", - "PUT", - "DELETE", - "PATCH", - ].includes(annotation) - ) { - evidence.push({ role: "web-entrypoint", reason: `annotation @${annotation}` }); - } - if (["Service", "Component", "ApplicationScoped", "Singleton", "Named"].includes(annotation)) { - evidence.push({ role: "application-service", reason: `annotation @${annotation}` }); - } - if (["Entity", "Repository", "Table", "MappedSuperclass"].includes(annotation)) { - evidence.push({ role: "persistence-boundary", reason: `annotation @${annotation}` }); - } - if (["Configuration", "Bean", "ConfigurationProperties"].includes(annotation)) { - evidence.push({ role: "configuration", reason: `annotation @${annotation}` }); - } - } - return evidence; -} - -function importEvidence(info: JavaFileInfo): JvmRoleEvidence[] { - const evidence: JvmRoleEvidence[] = []; - for (const full of info.imports.values()) { - if ( - full.startsWith("org.springframework.web.bind.annotation.") || - /^(?:jakarta|javax)\.ws\.rs\./u.test(full) - ) { - evidence.push({ role: "web-entrypoint", reason: `web framework import ${full}` }); - } - if ( - /^(?:jakarta|javax)\.persistence\./u.test(full) || - full.startsWith("org.hibernate.") || - full.startsWith("java.sql.") - ) { - evidence.push({ role: "persistence-boundary", reason: `persistence import ${full}` }); - } - if ( - isNetworkClientImport(full) || - full.startsWith("okhttp3.") || - full.startsWith("retrofit2.") || - full.startsWith("org.apache.http.") || - full.startsWith("io.grpc.") || - full.startsWith("software.amazon.awssdk.") || - full.startsWith("com.google.cloud.") || - full.startsWith("com.azure.") - ) { - evidence.push({ role: "external-client", reason: `external client import ${full}` }); - } - } - return evidence; -} - -function declarationEvidence(info: JavaFileInfo, projectPackages: Set): JvmRoleEvidence[] { - const evidence: JvmRoleEvidence[] = []; - for (const declaration of info.declarations) { - if (declaration.kind === "interface") { - evidence.push({ - role: "extension-boundary", - reason: `interface declaration ${declaration.name}`, - }); - } - for (const type of [...declaration.extendsTypes, ...declaration.implementsTypes]) { - const full = info.imports.get(type); - if (full !== undefined && isExternalProjectImport(full, projectPackages)) { - evidence.push({ role: "framework-component", reason: `inherits external type ${full}` }); - } - if (declaration.implementsTypes.includes(type)) { - evidence.push({ role: "extension-boundary", reason: `implements ${type}` }); - } - } - } - return evidence; -} - -function methodReturnEvidence(info: JavaFileInfo, projectPackages: Set): JvmRoleEvidence[] { - const evidence: JvmRoleEvidence[] = []; - for (const type of info.methodReturnTypes) { - const full = info.imports.get(type); - if (full !== undefined && isExternalProjectImport(full, projectPackages)) { - evidence.push({ role: "framework-component", reason: `returns external type ${full}` }); - } - } - return evidence; -} - -function isExternalProjectImport(full: string, projectPackages: Set): boolean { - if (/^(?:java|kotlin)\./u.test(full)) { - return false; - } - if ( - /^(?:javax|jakarta)\./u.test(full) && - !/^(?:javax|jakarta)\.(?:servlet|ws\.rs)\./u.test(full) - ) { - return false; - } - for (const packageName of projectPackages) { - if (full.startsWith(`${packageName}.`)) { - return false; - } - } - return true; -} - -function isNetworkClientImport(full: string): boolean { - return ( - full.startsWith("java.net.http.") || - [ - "java.net.DatagramSocket", - "java.net.HttpURLConnection", - "java.net.ServerSocket", - "java.net.Socket", - "java.net.URL", - "java.net.URLConnection", - ].includes(full) - ); -} - function isKotlinExternalClientImport(full: string): boolean { return ( isNetworkClientImport(full) || @@ -1622,12 +1302,6 @@ function isSpringDataPersistenceImport(full: string): boolean { ); } -function javaTypeNames(raw: string): string[] { - return splitJavaTypeList(raw) - .map((type) => baseJavaTypeName(stripGenericParameters(type))) - .filter((type) => type.length > 0); -} - function kotlinTypeNames(raw: string): string[] { const parts: string[] = []; let angleDepth = 0; @@ -1658,17 +1332,6 @@ function kotlinSupertypeNames(raw: string): string[] { return kotlinTypeNames(raw.replace(/\s+\bwhere\s+[A-Za-z_][A-Za-z0-9_]*\s*:[\s\S]*$/u, "")); } -function baseJavaTypeName(raw: string): string { - return ( - raw - .replace(/\?.*$/su, "") - .split(".") - .at(-1) - ?.replace(/[^A-Za-z0-9_$]/gu, "") - .trim() ?? "" - ); -} - function baseKotlinTypeName(raw: string): string { return ( raw @@ -1692,27 +1355,6 @@ function kotlinTypeReferenceName(raw: string): string { return baseKotlinTypeName(type); } -function splitJavaTypeList(raw: string): string[] { - const parts: string[] = []; - let depth = 0; - let current = ""; - for (const char of raw) { - if (char === "<") { - depth += 1; - } else if (char === ">") { - depth = Math.max(0, depth - 1); - } - if (char === "," && depth === 0) { - parts.push(current); - current = ""; - continue; - } - current += char; - } - parts.push(current); - return parts; -} - function stripGenericParameters(raw: string): string { let depth = 0; let result = ""; @@ -1732,12 +1374,6 @@ function stripGenericParameters(raw: string): string { return result; } -function stripJavaComments(source: string): string { - return source - .replace(/\/\*[\s\S]*?\*\//gu, (value) => " ".repeat(value.length)) - .replace(/\/\/.*$/gmu, ""); -} - function stripKotlinComments(source: string): string { let stripped = ""; let index = 0; @@ -1821,18 +1457,6 @@ function stripKotlinComments(source: string): string { return stripped; } -function dedupeEvidence(evidence: JvmRoleEvidence[]): JvmRoleEvidence[] { - const seen = new Set(); - return evidence.filter((item) => { - const key = `${item.role}:${item.reason}`; - if (seen.has(key)) { - return false; - } - seen.add(key); - return true; - }); -} - function dedupeKotlinEvidence(evidence: KotlinRoleEvidence[]): KotlinRoleEvidence[] { const seen = new Set(); return evidence.filter((item) => { @@ -1990,20 +1614,6 @@ async function gradleContextFiles( return refs; } -function associatedGradleTests(files: string[], testFiles: string[]): SeedTestRef[] { - const stems = new Set(files.map((file) => basename(file).replace(/\.[^.]+$/u, ""))); - const dirs = new Set(files.map((file) => dirname(file))); - return testFiles - .filter((test) => { - const stem = basename(test) - .replace(/\.[^.]+$/u, "") - .replace(/(?:Test|Spec)$/u, ""); - return stems.has(stem) || [...dirs].some((dir) => pathMatchesPrefix(test, dir)); - }) - .slice(0, maxTests) - .map((path) => ({ path, command: null })); -} - async function gradleTags( root: string, gradleRoot: string, diff --git a/src/mappers/jvm.ts b/src/mappers/jvm.ts new file mode 100644 index 0000000..bcd7722 --- /dev/null +++ b/src/mappers/jvm.ts @@ -0,0 +1,431 @@ +import { readFile } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import { partitionFileGroups } from "./grouping.js"; +import { pathMatchesPrefix } from "./shared.js"; +import { FeatureSeed, SeedTestRef } from "./types.js"; + +const maxOwnedFiles = 12; +const maxTests = 8; + +const jvmRoleDefinitions = { + "web-entrypoint": { + title: "web entrypoint", + kind: "route", + tags: ["jvm", "web"], + trustBoundaries: ["network", "user-input", "serialization"], + }, + "application-service": { + title: "application service", + kind: "service", + tags: ["jvm", "service"], + trustBoundaries: [], + }, + "persistence-boundary": { + title: "persistence boundary", + kind: "service", + tags: ["jvm", "persistence"], + trustBoundaries: ["database", "serialization"], + }, + "external-client": { + title: "external client", + kind: "service", + tags: ["jvm", "external-api"], + trustBoundaries: ["network", "external-api", "serialization"], + }, + configuration: { + title: "configuration", + kind: "config", + tags: ["jvm", "config"], + trustBoundaries: ["filesystem"], + }, + "framework-component": { + title: "framework component", + kind: "library", + tags: ["jvm", "framework"], + trustBoundaries: [], + }, + "extension-boundary": { + title: "extension boundary", + kind: "library", + tags: ["jvm", "interface"], + trustBoundaries: [], + }, +} as const satisfies Record< + string, + { + title: string; + kind: FeatureSeed["kind"]; + tags: string[]; + trustBoundaries: FeatureSeed["trustBoundaries"]; + } +>; + +type JvmRoleKey = keyof typeof jvmRoleDefinitions; +type JvmRoleEvidence = { + role: JvmRoleKey; + reason: string; +}; + +type JavaDeclaration = { + kind: "class" | "interface" | "record" | "enum"; + name: string; + extendsTypes: string[]; + implementsTypes: string[]; +}; + +export type JavaFileInfo = { + packageName: string | null; + annotations: Set; + imports: Map; + declarations: JavaDeclaration[]; + methodReturnTypes: Set; +}; + +export async function jvmRoleSeeds( + root: string, + entryPath: string, + sourceRoot: string, + sourceFiles: string[], + testFiles: string[], + tags: string[], +): Promise { + const matches = new Map>(); + const javaFiles: Array<{ filePath: string; info: JavaFileInfo }> = []; + for (const filePath of sourceFiles.filter((file) => file.endsWith(".java"))) { + const source = await readFile(join(root, filePath), "utf8"); + javaFiles.push({ filePath, info: parseJavaFile(source) }); + } + const projectPackages = new Set( + javaFiles.flatMap(({ info }) => (info.packageName === null ? [] : [info.packageName])), + ); + + for (const { filePath, info } of javaFiles) { + for (const evidence of jvmRoleEvidence(info, projectPackages)) { + const byFile = matches.get(evidence.role) ?? new Map(); + const reasons = byFile.get(filePath) ?? []; + reasons.push(evidence.reason); + byFile.set(filePath, reasons); + matches.set(evidence.role, byFile); + } + } + + const seeds: FeatureSeed[] = []; + for (const [role, byFile] of [...matches.entries()].toSorted(([left], [right]) => + left.localeCompare(right), + )) { + const definition = jvmRoleDefinitions[role]; + for (const group of partitionFileGroups(sourceRoot, [...byFile.keys()], maxOwnedFiles)) { + const tests = associatedJvmTests(group.files, testFiles); + seeds.push({ + title: `JVM role ${definition.title} ${group.label}`, + summary: `JVM ${definition.title} group ${group.label} with ${group.files.length} files, classified from Java code evidence.`, + kind: definition.kind, + source: `jvm-role-${role}`, + confidence: role === "extension-boundary" ? "medium" : "high", + entryPath, + symbol: group.label, + route: null, + command: null, + ownedFiles: group.files.map((path) => ({ + path, + reason: `jvm ${definition.title} evidence: ${unique(byFile.get(path) ?? []).join("; ")}`, + })), + contextFiles: tests.map((test) => ({ path: test.path, reason: "associated jvm test" })), + tests, + tags: [...tags, ...definition.tags], + trustBoundaries: definition.trustBoundaries, + skipNearbyTests: true, + }); + } + } + return seeds; +} + +export function parseJavaFile(source: string): JavaFileInfo { + const stripped = stripJavaComments(source); + const packageName = /^\s*package\s+([A-Za-z0-9_.]+)\s*;/mu.exec(stripped)?.[1] ?? null; + const imports = new Map(); + for (const match of stripped.matchAll(/^\s*import\s+(?:static\s+)?([A-Za-z0-9_.]+)\s*;/gmu)) { + const full = match[1]; + const simple = full?.split(".").at(-1); + if (full !== undefined && simple !== undefined) { + imports.set(simple, full); + } + } + + const annotations = new Set(); + for (const match of stripped.matchAll(/@([A-Za-z_][A-Za-z0-9_.]*)/gu)) { + const raw = match[1]; + if (raw !== undefined) { + annotations.add(raw.split(".").at(-1) ?? raw); + } + } + + const methodReturnTypes = new Set(); + for (const match of stripped.matchAll( + /\b(?:public|protected|private|static|final|abstract|synchronized|native|default|\s)+([A-Z][A-Za-z0-9_$.<>?]*)\s+[A-Za-z_][A-Za-z0-9_]*\s*\(/gu, + )) { + const type = match[1]; + if (type !== undefined) { + methodReturnTypes.add(baseJavaTypeName(stripGenericParameters(type))); + } + } + + return { + packageName, + annotations, + imports, + declarations: parseJavaDeclarations(stripped), + methodReturnTypes, + }; +} + +export function isExternalProjectImport(full: string, projectPackages: Set): boolean { + if (/^(?:java|kotlin)\./u.test(full)) { + return false; + } + if ( + /^(?:javax|jakarta)\./u.test(full) && + !/^(?:javax|jakarta)\.(?:servlet|ws\.rs)\./u.test(full) + ) { + return false; + } + for (const packageName of projectPackages) { + if (full.startsWith(`${packageName}.`)) { + return false; + } + } + return true; +} + +export function isNetworkClientImport(full: string): boolean { + return ( + full.startsWith("java.net.http.") || + [ + "java.net.DatagramSocket", + "java.net.HttpURLConnection", + "java.net.ServerSocket", + "java.net.Socket", + "java.net.URL", + "java.net.URLConnection", + ].includes(full) + ); +} + +export function associatedJvmTests(files: string[], testFiles: string[]): SeedTestRef[] { + const stems = new Set(files.map((file) => basename(file).replace(/\.[^.]+$/u, ""))); + const dirs = new Set(files.map((file) => dirname(file))); + return testFiles + .filter((test) => { + const stem = basename(test) + .replace(/\.[^.]+$/u, "") + .replace(/(?:Test|Spec)$/u, ""); + return stems.has(stem) || [...dirs].some((dir) => pathMatchesPrefix(test, dir)); + }) + .slice(0, maxTests) + .map((path) => ({ path, command: null })); +} + +function jvmRoleEvidence(info: JavaFileInfo, projectPackages: Set): JvmRoleEvidence[] { + const evidence: JvmRoleEvidence[] = []; + evidence.push(...annotationEvidence(info)); + evidence.push(...importEvidence(info)); + evidence.push(...declarationEvidence(info, projectPackages)); + evidence.push(...methodReturnEvidence(info, projectPackages)); + return dedupeEvidence(evidence); +} + +function parseJavaDeclarations(source: string): JavaDeclaration[] { + const declarations: JavaDeclaration[] = []; + const declarationPattern = + /\b(class|interface|record|enum)\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s*<[^{};]*>)?(?:\s*\([^{};]*\))?(?:\s+extends\s+([^{]+?))?(?:\s+implements\s+([^{]+?))?\s*\{/gsu; + for (const match of source.matchAll(declarationPattern)) { + const kind = match[1]; + const name = match[2]; + if (kind === undefined || name === undefined) { + continue; + } + declarations.push({ + kind: kind as JavaDeclaration["kind"], + name, + extendsTypes: match[3] === undefined ? [] : javaTypeNames(match[3]), + implementsTypes: match[4] === undefined ? [] : javaTypeNames(match[4]), + }); + } + return declarations; +} + +function annotationEvidence(info: JavaFileInfo): JvmRoleEvidence[] { + const evidence: JvmRoleEvidence[] = []; + for (const annotation of info.annotations) { + if ( + [ + "Controller", + "RestController", + "RequestMapping", + "Path", + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", + ].includes(annotation) + ) { + evidence.push({ role: "web-entrypoint", reason: `annotation @${annotation}` }); + } + if (["Service", "Component", "ApplicationScoped", "Singleton", "Named"].includes(annotation)) { + evidence.push({ role: "application-service", reason: `annotation @${annotation}` }); + } + if (["Entity", "Repository", "Table", "MappedSuperclass"].includes(annotation)) { + evidence.push({ role: "persistence-boundary", reason: `annotation @${annotation}` }); + } + if (["Configuration", "Bean", "ConfigurationProperties"].includes(annotation)) { + evidence.push({ role: "configuration", reason: `annotation @${annotation}` }); + } + } + return evidence; +} + +function importEvidence(info: JavaFileInfo): JvmRoleEvidence[] { + const evidence: JvmRoleEvidence[] = []; + for (const full of info.imports.values()) { + if ( + full.startsWith("org.springframework.web.bind.annotation.") || + /^(?:jakarta|javax)\.ws\.rs\./u.test(full) + ) { + evidence.push({ role: "web-entrypoint", reason: `web framework import ${full}` }); + } + if ( + /^(?:jakarta|javax)\.persistence\./u.test(full) || + full.startsWith("org.hibernate.") || + full.startsWith("java.sql.") + ) { + evidence.push({ role: "persistence-boundary", reason: `persistence import ${full}` }); + } + if ( + isNetworkClientImport(full) || + full.startsWith("okhttp3.") || + full.startsWith("retrofit2.") || + full.startsWith("org.apache.http.") || + full.startsWith("io.grpc.") || + full.startsWith("software.amazon.awssdk.") || + full.startsWith("com.google.cloud.") || + full.startsWith("com.azure.") + ) { + evidence.push({ role: "external-client", reason: `external client import ${full}` }); + } + } + return evidence; +} + +function declarationEvidence(info: JavaFileInfo, projectPackages: Set): JvmRoleEvidence[] { + const evidence: JvmRoleEvidence[] = []; + for (const declaration of info.declarations) { + if (declaration.kind === "interface") { + evidence.push({ + role: "extension-boundary", + reason: `interface declaration ${declaration.name}`, + }); + } + for (const type of [...declaration.extendsTypes, ...declaration.implementsTypes]) { + const full = info.imports.get(type); + if (full !== undefined && isExternalProjectImport(full, projectPackages)) { + evidence.push({ role: "framework-component", reason: `inherits external type ${full}` }); + } + if (declaration.implementsTypes.includes(type)) { + evidence.push({ role: "extension-boundary", reason: `implements ${type}` }); + } + } + } + return evidence; +} + +function methodReturnEvidence(info: JavaFileInfo, projectPackages: Set): JvmRoleEvidence[] { + const evidence: JvmRoleEvidence[] = []; + for (const type of info.methodReturnTypes) { + const full = info.imports.get(type); + if (full !== undefined && isExternalProjectImport(full, projectPackages)) { + evidence.push({ role: "framework-component", reason: `returns external type ${full}` }); + } + } + return evidence; +} + +function javaTypeNames(raw: string): string[] { + return splitJavaTypeList(raw) + .map((type) => baseJavaTypeName(stripGenericParameters(type))) + .filter((type) => type.length > 0); +} + +function baseJavaTypeName(raw: string): string { + return ( + raw + .replace(/\?.*$/su, "") + .split(".") + .at(-1) + ?.replace(/[^A-Za-z0-9_$]/gu, "") + .trim() ?? "" + ); +} + +function splitJavaTypeList(raw: string): string[] { + const parts: string[] = []; + let depth = 0; + let current = ""; + for (const char of raw) { + if (char === "<") { + depth += 1; + } else if (char === ">") { + depth = Math.max(0, depth - 1); + } + if (char === "," && depth === 0) { + parts.push(current); + current = ""; + continue; + } + current += char; + } + parts.push(current); + return parts; +} + +function stripGenericParameters(raw: string): string { + let depth = 0; + let result = ""; + for (const char of raw) { + if (char === "<") { + depth += 1; + continue; + } + if (char === ">") { + depth = Math.max(0, depth - 1); + continue; + } + if (depth === 0) { + result += char; + } + } + return result; +} + +function stripJavaComments(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//gu, (value) => " ".repeat(value.length)) + .replace(/\/\/.*$/gmu, ""); +} + +function dedupeEvidence(evidence: JvmRoleEvidence[]): JvmRoleEvidence[] { + const seen = new Set(); + return evidence.filter((item) => { + const key = `${item.role}:${item.reason}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + +function unique(values: string[]): string[] { + return [...new Set(values)]; +} diff --git a/src/mappers/maven.ts b/src/mappers/maven.ts new file mode 100644 index 0000000..cf8cc39 --- /dev/null +++ b/src/mappers/maven.ts @@ -0,0 +1,395 @@ +import { lstat, readFile, readdir } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { pathExists } from "../fs.js"; +import { partitionFileGroups } from "./grouping.js"; +import { associatedJvmTests, jvmRoleSeeds } from "./jvm.js"; +import { isSampleProjectPath, normalize, shouldSkip, walk } from "./shared.js"; +import { FeatureSeed } from "./types.js"; + +const maxOwnedFiles = 12; + +type MavenProjectInfo = { + root: string; + pomPath: string; + artifactId: string; + packaging: string | null; + modules: string[]; + hasSpring: boolean; + hasSpringBoot: boolean; +}; + +export async function mavenSeeds(root: string): Promise { + const roots = await discoverMavenRoots(root); + const seeds: FeatureSeed[] = []; + const mappedModuleRoots = new Set(); + for (const mavenRoot of roots) { + seeds.push(...(await mavenProjectSeeds(root, mavenRoot, mappedModuleRoots))); + } + return seeds; +} + +async function mavenProjectSeeds( + root: string, + mavenRoot: string, + mappedModuleRoots: Set, +): Promise { + const moduleRoots = await mavenModuleRoots(root, mavenRoot); + const seeds: FeatureSeed[] = []; + for (const moduleRoot of moduleRoots) { + if (mappedModuleRoots.has(moduleRoot)) { + continue; + } + const info = await readMavenProject(root, moduleRoot); + if (info === null) { + continue; + } + mappedModuleRoots.add(moduleRoot); + const sourceRoot = moduleRoot === "." ? "src" : `${moduleRoot}/src`; + const sourceFiles = (await walk(root, [sourceRoot])) + .filter(isMavenSourceFile) + .filter((file) => !isMavenTestFile(moduleRoot, file)); + const testFiles = (await walk(root, [sourceRoot])) + .filter(isMavenSourceFile) + .filter((file) => isMavenTestFile(moduleRoot, file)); + const tags = mavenTags(info, sourceFiles); + const contextFiles = await mavenContextFiles(root, moduleRoot); + + seeds.push({ + title: `Maven module ${info.artifactId}`, + summary: `Maven module ${info.artifactId} rooted at ${moduleRoot}.`, + kind: "library", + source: "maven-module", + confidence: "medium", + entryPath: info.pomPath, + symbol: info.artifactId, + route: null, + command: null, + ownedFiles: [{ path: info.pomPath, reason: "maven project file" }], + contextFiles, + tags, + trustBoundaries: ["filesystem", "process-exec"], + skipNearbyTests: true, + }); + + for (const group of partitionFileGroups(sourceRoot, sourceFiles, maxOwnedFiles)) { + const tests = associatedJvmTests(group.files, testFiles); + seeds.push({ + title: `Maven source ${group.label}`, + summary: `Maven source group ${group.label} with ${group.files.length} files.`, + kind: "library", + source: "maven-source-group", + confidence: "medium", + entryPath: info.pomPath, + symbol: group.label, + route: null, + command: null, + ownedFiles: group.files.map((path) => ({ + path, + reason: `maven source group ${group.label}`, + })), + contextFiles: tests.map((test) => ({ path: test.path, reason: "associated maven test" })), + tests, + tags, + trustBoundaries: ["filesystem", "process-exec"], + skipNearbyTests: true, + }); + } + + seeds.push( + ...(await jvmRoleSeeds(root, info.pomPath, sourceRoot, sourceFiles, testFiles, tags)), + ); + + if (testFiles.length > 0) { + for (const group of partitionFileGroups(sourceRoot, testFiles, maxOwnedFiles)) { + seeds.push({ + title: `Maven test suite ${group.label}`, + summary: `Maven test group ${group.label} with ${group.files.length} files.`, + kind: "test-suite", + source: "maven-test-group", + confidence: "medium", + entryPath: group.files[0] ?? info.pomPath, + symbol: group.label, + route: null, + command: null, + ownedFiles: group.files.map((path) => ({ + path, + reason: `maven test group ${group.label}`, + })), + tags: [...tags, "test"], + trustBoundaries: [], + skipNearbyTests: true, + }); + } + } + } + return seeds; +} + +async function discoverMavenRoots(root: string): Promise { + const roots: string[] = []; + await discoverMavenRootsInto(root, ".", 5, roots); + return roots.toSorted(); +} + +async function discoverMavenRootsInto( + root: string, + dir: string, + remainingDepth: number, + roots: string[], +): Promise { + if (remainingDepth < 0 || (dir !== "." && shouldSkipMavenPath(dir))) { + return; + } + const full = dir === "." ? root : join(root, dir); + if (!(await pathExists(full))) { + return; + } + const info = await lstat(full); + if (!info.isDirectory() || info.isSymbolicLink()) { + return; + } + if ((await mavenPomFile(root, dir)) !== null) { + roots.push(dir); + } + for (const entry of await readdir(full)) { + const child = dir === "." ? entry : `${dir}/${entry}`; + if (shouldSkipMavenPath(child)) { + continue; + } + const childInfo = await lstat(join(full, entry)); + if (childInfo.isDirectory() && !childInfo.isSymbolicLink()) { + await discoverMavenRootsInto(root, child, remainingDepth - 1, roots); + } + } +} + +async function mavenModuleRoots(root: string, mavenRoot: string): Promise { + const modules = new Set([mavenRoot]); + await collectMavenModules(root, mavenRoot, 5, modules); + return [...modules].toSorted(); +} + +async function collectMavenModules( + root: string, + moduleRoot: string, + remainingDepth: number, + modules: Set, +): Promise { + if (remainingDepth < 0 || shouldSkipMavenPath(moduleRoot)) { + return; + } + const info = await readMavenProject(root, moduleRoot); + if (info === null) { + return; + } + for (const modulePath of info.modules) { + const childRoot = mavenModulePath(moduleRoot, modulePath); + if (childRoot === null || shouldSkipMavenPath(childRoot)) { + continue; + } + if ((await mavenPomFile(root, childRoot)) === null) { + continue; + } + modules.add(childRoot); + await collectMavenModules(root, childRoot, remainingDepth - 1, modules); + } +} + +async function readMavenProject( + root: string, + moduleRoot: string, +): Promise { + const pomPath = await mavenPomFile(root, moduleRoot); + if (pomPath === null) { + return null; + } + const source = await readFile(join(root, pomPath), "utf8").catch(() => ""); + const activeSource = stripXmlComments(source); + const topLevel = removeXmlBlocks(activeSource, [ + "parent", + "modules", + "dependencies", + "dependencyManagement", + "build", + "profiles", + "repositories", + "pluginRepositories", + "reporting", + ]); + return { + root: moduleRoot, + pomPath, + artifactId: xmlElementValue(topLevel, "artifactId") ?? mavenFallbackArtifactId(moduleRoot), + packaging: xmlElementValue(topLevel, "packaging"), + modules: xmlElementValuesInBlocks(activeSource, "modules", "module"), + hasSpring: mavenPomHasSpring(activeSource), + hasSpringBoot: mavenPomHasSpringBoot(activeSource), + }; +} + +async function mavenPomFile(root: string, moduleRoot: string): Promise { + const path = moduleRoot === "." ? "pom.xml" : `${moduleRoot}/pom.xml`; + return (await pathExists(join(root, path))) ? path : null; +} + +async function mavenContextFiles( + root: string, + moduleRoot: string, +): Promise> { + const candidates = ["AGENTS.md", "README.md", ".mvn/maven.config"].map((file) => + moduleRoot === "." ? file : `${moduleRoot}/${file}`, + ); + const refs: Array<{ path: string; reason: string }> = []; + for (const candidate of candidates) { + if (await pathExists(join(root, candidate))) { + refs.push({ path: candidate, reason: "maven module context" }); + } + } + for (const path of await mavenSpringResourceFiles(root, moduleRoot)) { + refs.push({ path, reason: "spring application configuration" }); + } + return refs; +} + +async function mavenSpringResourceFiles(root: string, moduleRoot: string): Promise { + const resourcesRoot = + moduleRoot === "." ? "src/main/resources" : `${moduleRoot}/src/main/resources`; + return (await walk(root, [resourcesRoot])).filter((path) => + /(^|\/)application(?:[-.][^/]*)?\.(?:properties|ya?ml)$/iu.test(path), + ); +} + +function mavenTags(info: MavenProjectInfo, sourceFiles: string[]): string[] { + const tags = ["maven", `project:${info.artifactId}`, `project-root:${info.root}`]; + if (sourceFiles.some((file) => file.endsWith(".java"))) { + tags.push("java"); + } + if (sourceFiles.some((file) => file.endsWith(".kt") || file.endsWith(".kts"))) { + tags.push("kotlin"); + } + if (info.packaging !== null) { + tags.push(`packaging:${info.packaging}`); + } + if (info.hasSpring) { + tags.push("spring"); + } + if (info.hasSpringBoot) { + tags.push("spring-boot"); + } + return tags; +} + +function mavenModulePath(moduleRoot: string, modulePath: string): string | null { + const normalizedModulePath = normalize(modulePath.trim().replace(/\\/gu, "/")); + if ( + normalizedModulePath.length === 0 || + normalizedModulePath === "." || + /^(?:[A-Za-z]:)?\//u.test(normalizedModulePath) + ) { + return null; + } + const base = moduleRoot === "." ? "" : moduleRoot; + const resolved = normalize(join(base, normalizedModulePath)); + if (resolved === "." || resolved === ".." || resolved.startsWith("../")) { + return null; + } + return resolved; +} + +function mavenFallbackArtifactId(moduleRoot: string): string { + return moduleRoot === "." ? "root" : basename(moduleRoot); +} + +function shouldSkipMavenPath(path: string): boolean { + return ( + shouldSkip(path) || isSampleProjectPath(path) || path === "vendor" || path.startsWith("vendor/") + ); +} + +function mavenPomHasSpring(source: string): boolean { + return ( + mavenPomHasSpringBoot(source) || + /\s*org\.springframework(?:\.[^<]*)?\s*<\/groupId>/iu.test(source) + ); +} + +function mavenPomHasSpringBoot(source: string): boolean { + return ( + /\s*org\.springframework\.boot\s*<\/groupId>/iu.test(source) || + /\s*spring-boot-[^<]*\s*<\/artifactId>/iu.test(source) + ); +} + +function isMavenSourceFile(path: string): boolean { + const normalized = normalize(path); + return /\.(?:java|kt|kts)$/u.test(normalized); +} + +function isMavenTestFile(moduleRoot: string, path: string): boolean { + const relativePath = normalize(path).slice(moduleRoot === "." ? 0 : moduleRoot.length + 1); + return /^src\/(?:test|it|integrationTest)\//u.test(relativePath); +} + +function xmlElementValue(source: string, name: string): string | null { + const escapedName = name.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + return ( + new RegExp(`<${escapedName}\\b[^>]*>\\s*([^<]+?)\\s*`, "iu") + .exec(source)?.[1] + ?.trim() ?? null + ); +} + +function xmlElementValuesInBlocks( + source: string, + blockName: string, + elementName: string, +): string[] { + const values: string[] = []; + const escapedBlock = blockName.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const escapedElement = elementName.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const blockPattern = new RegExp(`<${escapedBlock}\\b[^>]*>([\\s\\S]*?)`, "giu"); + const elementPattern = new RegExp( + `<${escapedElement}\\b[^>]*>\\s*([^<]+?)\\s*`, + "giu", + ); + for (const block of source.matchAll(blockPattern)) { + for (const match of (block[1] ?? "").matchAll(elementPattern)) { + const value = match[1]?.trim(); + if (value !== undefined && value.length > 0) { + values.push(value); + } + } + } + return [...new Set(values)]; +} + +function removeXmlBlocks(source: string, names: string[]): string { + let output = source; + for (const name of names) { + const escapedName = name.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + output = output.replace( + new RegExp(`<${escapedName}\\b[^>]*>[\\s\\S]*?`, "giu"), + " ", + ); + } + return output; +} + +function stripXmlComments(source: string): string { + let output = ""; + let index = 0; + while (index < source.length) { + const start = source.indexOf("", start + 4); + if (end === -1) { + break; + } + index = end + 3; + } + return output; +} diff --git a/src/workflow.test.ts b/src/workflow.test.ts index a539766..fc5c219 100644 --- a/src/workflow.test.ts +++ b/src/workflow.test.ts @@ -55,6 +55,10 @@ import { fixtureRoot, testOptions, writeFixture } from "./test-helpers.js"; import { findingRecordSchema } from "./types.js"; import type { FeatureRecord, PatchAttempt } from "./types.js"; +const symlinkIt = process.platform === "win32" ? it.skip : it; +const posixFileModeIt = process.platform === "win32" ? it.skip : it; +const posixPathspecIt = process.platform === "win32" ? it.skip : it; + async function sinceFixture(prefix: string): Promise { const root = await fixtureRoot(prefix); await writeFixture( @@ -137,6 +141,32 @@ async function checkCommand(root: string, command: string): Promise { } } +async function writeGhSuccessScript(root: string, url: string): Promise { + if (process.platform === "win32") { + const path = "success-gh.cmd"; + await writeFixture(root, path, `@echo off\r\necho ${url}\r\n`); + return join(root, path); + } + const path = "success-gh.sh"; + const fullPath = join(root, path); + await writeFixture(root, path, `#!/bin/sh\necho ${url}\n`); + await chmod(fullPath, 0o755); + return fullPath; +} + +async function writeGhFailureScript(root: string): Promise { + if (process.platform === "win32") { + const path = "fail-gh.cmd"; + await writeFixture(root, path, "@echo off\r\nexit /b 42\r\n"); + return join(root, path); + } + const path = "fail-gh.sh"; + const fullPath = join(root, path); + await writeFixture(root, path, "#!/bin/sh\nexit 42\n"); + await chmod(fullPath, 0o755); + return fullPath; +} + async function runCli(argv: string[]): Promise<{ stdout: string; stderr: string }> { let stdout = ""; let stderr = ""; @@ -1869,7 +1899,7 @@ describe("workflow", () => { ); }); - it("does not recurse through symlinked mapper directories", async () => { + symlinkIt("does not recurse through symlinked mapper directories", async () => { const root = await fixtureRoot("clawpatch-map-symlink-root-"); const external = await fixtureRoot("clawpatch-map-symlink-external-"); await writeFixture(root, "package.json", JSON.stringify({ name: "map-symlink" })); @@ -2489,7 +2519,7 @@ describe("workflow", () => { expect(patches[0]?.filesChanged).toEqual(["scratch/note.txt"]); }); - it("records mode-only changes to already-dirty files", async () => { + posixFileModeIt("records mode-only changes to already-dirty files", async () => { const root = await fixtureRoot("clawpatch-fix-dirty-mode-"); await initGit(root); await checkCommand(root, "git config core.filemode true"); @@ -2540,7 +2570,7 @@ describe("workflow", () => { expect(patches[0]?.filesChanged).toEqual(["script.sh"]); }); - it("fingerprints dirty symlinks without reading external targets", async () => { + symlinkIt("fingerprints dirty symlinks without reading external targets", async () => { const root = await fixtureRoot("clawpatch-fix-dirty-symlink-"); const external = await fixtureRoot("clawpatch-fix-dirty-symlink-external-"); const externalPath = join(external, "target.txt"); @@ -3276,7 +3306,7 @@ describe("workflow", () => { } }); - it("does not include escaped feature paths in prompts", async () => { + symlinkIt("does not include escaped feature paths in prompts", async () => { const root = await fixtureRoot("clawpatch-path-escape-"); const siblingSecret = join(root, "..", "secret.txt"); await writeFixture(root, "package.json", JSON.stringify({ name: "path-escape" })); @@ -3582,7 +3612,7 @@ describe("workflow", () => { }); }); - it("opens PRs from symlinked project roots with repo-relative patch paths", async () => { + symlinkIt("opens PRs from symlinked project roots with repo-relative patch paths", async () => { const root = await fixtureRoot("clawpatch-open-pr-symlink-root-"); const projectRoot = join(root, "packages/app"); await writeFixture( @@ -3635,13 +3665,10 @@ describe("workflow", () => { updatedAt: now, }); const ghScripts = await fixtureRoot("clawpatch-open-pr-symlink-root-gh-"); - const successGh = join(ghScripts, "success-gh.sh"); - await writeFixture( + const successGh = await writeGhSuccessScript( ghScripts, - "success-gh.sh", - "#!/bin/sh\necho https://github.com/openclaw/clawpatch/pull/1004\n", + "https://github.com/openclaw/clawpatch/pull/1004", ); - await chmod(successGh, 0o755); const previousGh = process.env["CLAWPATCH_GH"]; try { process.env["CLAWPATCH_GH"] = successGh; @@ -3670,7 +3697,7 @@ describe("workflow", () => { } }); - it("opens PRs for newly created dangling symlinks", async () => { + symlinkIt("opens PRs for newly created dangling symlinks", async () => { const root = await fixtureRoot("clawpatch-open-pr-symlink-"); await writeFixture(root, "package.json", JSON.stringify({ name: "open-pr-symlink" })); await initGit(root); @@ -3714,13 +3741,10 @@ describe("workflow", () => { updatedAt: now, }); const ghScripts = await fixtureRoot("clawpatch-open-pr-symlink-gh-"); - const successGh = join(ghScripts, "success-gh.sh"); - await writeFixture( + const successGh = await writeGhSuccessScript( ghScripts, - "success-gh.sh", - "#!/bin/sh\necho https://github.com/openclaw/clawpatch/pull/1003\n", + "https://github.com/openclaw/clawpatch/pull/1003", ); - await chmod(successGh, 0o755); const previousGh = process.env["CLAWPATCH_GH"]; try { process.env["CLAWPATCH_GH"] = successGh; @@ -3775,9 +3799,7 @@ describe("workflow", () => { updatedAt: now, }); const ghScripts = await fixtureRoot("clawpatch-open-pr-existing-url-gh-"); - const failingGh = join(ghScripts, "fail-gh.sh"); - await writeFixture(ghScripts, "fail-gh.sh", "#!/bin/sh\nexit 42\n"); - await chmod(failingGh, 0o755); + const failingGh = await writeGhFailureScript(ghScripts); const previousGh = process.env["CLAWPATCH_GH"]; try { process.env["CLAWPATCH_GH"] = failingGh; @@ -3850,16 +3872,11 @@ describe("workflow", () => { }; await writePatchAttempt(paths, patch); const ghScripts = await fixtureRoot("clawpatch-open-pr-gh-"); - const failingGh = join(ghScripts, "fail-gh.sh"); - const successGh = join(ghScripts, "success-gh.sh"); - await writeFixture(ghScripts, "fail-gh.sh", "#!/bin/sh\nexit 42\n"); - await writeFixture( + const failingGh = await writeGhFailureScript(ghScripts); + const successGh = await writeGhSuccessScript( ghScripts, - "success-gh.sh", - "#!/bin/sh\necho https://github.com/openclaw/clawpatch/pull/999\n", + "https://github.com/openclaw/clawpatch/pull/999", ); - await chmod(failingGh, 0o755); - await chmod(successGh, 0o755); const previousGh = process.env["CLAWPATCH_GH"]; try { process.env["CLAWPATCH_GH"] = failingGh; @@ -3965,13 +3982,10 @@ describe("workflow", () => { updatedAt: now, }); const ghScripts = await fixtureRoot("clawpatch-open-pr-recorded-base-gh-"); - const successGh = join(ghScripts, "success-gh.sh"); - await writeFixture( + const successGh = await writeGhSuccessScript( ghScripts, - "success-gh.sh", - "#!/bin/sh\necho https://github.com/openclaw/clawpatch/pull/1005\n", + "https://github.com/openclaw/clawpatch/pull/1005", ); - await chmod(successGh, 0o755); const previousGh = process.env["CLAWPATCH_GH"]; try { process.env["CLAWPATCH_GH"] = successGh; @@ -4047,13 +4061,10 @@ describe("workflow", () => { updatedAt: now, }); const ghScripts = await fixtureRoot("clawpatch-open-pr-existing-branch-gh-"); - const successGh = join(ghScripts, "success-gh.sh"); - await writeFixture( + const successGh = await writeGhSuccessScript( ghScripts, - "success-gh.sh", - "#!/bin/sh\necho https://github.com/openclaw/clawpatch/pull/1002\n", + "https://github.com/openclaw/clawpatch/pull/1002", ); - await chmod(successGh, 0o755); const previousGh = process.env["CLAWPATCH_GH"]; try { process.env["CLAWPATCH_GH"] = successGh; @@ -4136,13 +4147,10 @@ describe("workflow", () => { updatedAt: now, }); const ghScripts = await fixtureRoot("clawpatch-open-pr-pathspec-gh-"); - const successGh = join(ghScripts, "success-gh.sh"); - await writeFixture( + const successGh = await writeGhSuccessScript( ghScripts, - "success-gh.sh", - "#!/bin/sh\necho https://github.com/openclaw/clawpatch/pull/1000\n", + "https://github.com/openclaw/clawpatch/pull/1000", ); - await chmod(successGh, 0o755); const previousGh = process.env["CLAWPATCH_GH"]; try { process.env["CLAWPATCH_GH"] = successGh; @@ -4166,7 +4174,7 @@ describe("workflow", () => { } }); - it("opens PRs for literal names that look like git pathspec magic", async () => { + posixPathspecIt("opens PRs for literal names that look like git pathspec magic", async () => { const root = await fixtureRoot("clawpatch-open-pr-literal-pathspec-"); await writeFixture(root, "package.json", JSON.stringify({ name: "open-pr-literal-pathspec" })); await writeFixture(root, "README.md", "base\n"); @@ -4211,13 +4219,10 @@ describe("workflow", () => { updatedAt: now, }); const ghScripts = await fixtureRoot("clawpatch-open-pr-literal-pathspec-gh-"); - const successGh = join(ghScripts, "success-gh.sh"); - await writeFixture( + const successGh = await writeGhSuccessScript( ghScripts, - "success-gh.sh", - "#!/bin/sh\necho https://github.com/openclaw/clawpatch/pull/1002\n", + "https://github.com/openclaw/clawpatch/pull/1002", ); - await chmod(successGh, 0o755); const previousGh = process.env["CLAWPATCH_GH"]; try { process.env["CLAWPATCH_GH"] = successGh; @@ -4293,13 +4298,10 @@ describe("workflow", () => { updatedAt: now, }); const ghScripts = await fixtureRoot("clawpatch-open-pr-rename-gh-"); - const successGh = join(ghScripts, "success-gh.sh"); - await writeFixture( + const successGh = await writeGhSuccessScript( ghScripts, - "success-gh.sh", - "#!/bin/sh\necho https://github.com/openclaw/clawpatch/pull/1001\n", + "https://github.com/openclaw/clawpatch/pull/1001", ); - await chmod(successGh, 0o755); const previousGh = process.env["CLAWPATCH_GH"]; try { process.env["CLAWPATCH_GH"] = successGh;