diff --git a/release-files.json b/release-files.json index ef7495c..4c5b8bf 100644 --- a/release-files.json +++ b/release-files.json @@ -41,6 +41,7 @@ "src/repo/analyzer.ts", "src/repo/adapters/go-test.ts", "src/repo/adapters/go.ts", + "src/repo/adapters/maven.ts", "src/repo/adapters/index.ts", "src/repo/adapters/types.ts", "src/repo/adapters/vue.ts", @@ -92,4 +93,3 @@ "tsconfig.json" ] } - diff --git a/src/planner/test-command.ts b/src/planner/test-command.ts index 0e6a559..7f93a4d 100644 --- a/src/planner/test-command.ts +++ b/src/planner/test-command.ts @@ -151,6 +151,21 @@ export function planSelectiveTestCommands( const group: SelectiveTestCommandGroup = { runnerId: "go:test", label: "Go package tests", paths: [...goPaths].sort(), commandSpec }; return { commands: [...jsPlan.commands, commandSpec], groups: [...jsPlan.groups, group], unroutedPaths: [] }; } + const javaPaths = selectedPaths.filter((path) => path.endsWith(".java") || path.endsWith(".kt")); + if (javaPaths.length) { + const modules = profile.mavenTestModules ?? {}; + const unclaimed = javaPaths.filter((path) => !Object.hasOwn(modules, path)); + if (unclaimed.length) return { commands: [], groups: [], unroutedPaths: unclaimed, refusalReason: "Maven test files require verified reactor module metadata" }; + if (selectedPaths.some((path) => !path.endsWith(".java") && !path.endsWith(".kt"))) return { commands: [], groups: [], unroutedPaths: [...selectedPaths], refusalReason: "Mixed Maven/JavaScript selective execution requires separate CI jobs" }; + const targets = [...new Set(javaPaths.map((path) => modules[path]!))].sort(); + if (targets.some((target) => target !== "." && (target.startsWith("/") || target.split("/").includes("..") || /[\\\r\n]/.test(target)))) { + return { commands: [], groups: [], unroutedPaths: javaPaths, refusalReason: "Invalid Maven reactor module target" }; + } + const args = targets.length === 1 && targets[0] === "." ? ["test"] : ["-pl", targets.join(","), "-am", "test"]; + const commandSpec: CommandSpec = { executable: "mvn", args }; + const group: SelectiveTestCommandGroup = { runnerId: "maven:surefire", label: "Maven reactor tests", paths: [...javaPaths].sort(), commandSpec }; + return { commands: [commandSpec], groups: [group], unroutedPaths: [] }; + } const paths = [...selectedPaths].sort(); if (paths.length === 0) return { commands: [], groups: [], unroutedPaths: [] }; diff --git a/src/repo/adapters/index.ts b/src/repo/adapters/index.ts index 06c623c..db7856a 100644 --- a/src/repo/adapters/index.ts +++ b/src/repo/adapters/index.ts @@ -1,10 +1,11 @@ import { readdirSync } from "node:fs"; import { join } from "node:path"; import { goAdapter } from "./go.js"; +import { mavenAdapter } from "./maven.js"; import { vueAdapter } from "./vue.js"; import type { RepositoryAdapter } from "./types.js"; -export const REPOSITORY_ADAPTERS: readonly RepositoryAdapter[] = [vueAdapter, goAdapter]; +export const REPOSITORY_ADAPTERS: readonly RepositoryAdapter[] = [vueAdapter, goAdapter, mavenAdapter]; /** Never follows symlinks or scans dependency/build output directories. */ export function adapterFiles(root: string, exclusions: readonly string[] = []): string[] { diff --git a/src/repo/adapters/maven.ts b/src/repo/adapters/maven.ts new file mode 100644 index 0000000..cb8e285 --- /dev/null +++ b/src/repo/adapters/maven.ts @@ -0,0 +1,38 @@ +import { readFileSync } from "node:fs"; +import { dirname, posix } from "node:path"; +import { contribution, type AdapterContext, type AdapterContribution, type RepositoryAdapter } from "./types.js"; + +interface Pom { path:string; dir:string; groupId?:string; artifactId?:string; modules:string[]; dependencies:Array<{groupId:string;artifactId:string}>; } + +function tags(xml:string, name:string): string[] { + const re=new RegExp(`<${name}\\b[^>]*>([\\s\\S]*?)<\\/${name}>`,"g"); const out:string[]=[]; let m:RegExpExecArray|null; + while((m=re.exec(xml))) out.push((m[1]??"").trim()); return out; +} +function first(xml:string,name:string):string|undefined { return tags(xml,name)[0]; } +function parsePom(path:string, xml:string):Pom { + const dir=posix.dirname(path)==="."?"":posix.dirname(path); + const parent=first(xml,"parent")??""; + const own=xml.replace(/]*>[\s\S]*?<\/parent>/,""); + const deps=tags(xml,"dependency").map(x=>({groupId:first(x,"groupId")??"",artifactId:first(x,"artifactId")??""})).filter(x=>x.artifactId); + return {path,dir,groupId:first(own,"groupId")??first(parent,"groupId"),artifactId:first(own,"artifactId"),modules:tags(first(xml,"modules")??"","module"),dependencies:deps}; +} +function under(dir:string,path:string){return !dir||path===dir||path.startsWith(dir+"/");} +function owningPom(poms:Pom[], file:string):Pom|undefined { return poms.filter(p=>under(p.dir,file)).sort((a,b)=>b.dir.length-a.dir.length)[0]; } +function javaFiles(context:AdapterContext, poms:Pom[], pom:Pom){return context.files.filter(f=>owningPom(poms,f)===pom&&/\/src\/(?:main|test)\/(?:java|kotlin)\/.*\.(?:java|kt)$/.test("/"+f));} +function isTest(path:string){return /\/src\/test\/(?:java|kotlin)\//.test("/"+path)&&/(?:Test|Tests|TestCase|IT)\.(?:java|kt)$/.test(path);} + +export function analyzeMaven(context:AdapterContext):AdapterContribution { + const r=contribution(mavenAdapter); + const poms=context.files.filter(f=>f==="pom.xml"||f.endsWith("/pom.xml")).map(path=>parsePom(path,readFileSync(context.repoPath+"/"+path,"utf8"))); + if(!poms.length){r.blockers.push("Maven analysis found no pom.xml");return r;} + const byGA=new Map(poms.filter(p=>p.artifactId).map(p=>[`${p.groupId??""}:${p.artifactId}`,p])); + const byArtifact=new Map(poms.filter(p=>p.artifactId).map(p=>[p.artifactId!,p])); + const members=new Map(), anchors=new Map(); + for(const p of poms){const files=javaFiles(context,poms,p); if(!files.length) continue; members.set(p,files); anchors.set(p,files[0]!); r.sourcePaths.push(...files); for(const f of files) if(isTest(f)){r.testFiles.push(f);r.testPackages[f]=p.dir||".";}} + for(const [p,files] of members){const a=anchors.get(p)!; for(const f of files) if(f!==a) r.edges.push({from:a,to:f,kind:"import"},{from:f,to:a,kind:"import"}); + for(const d of p.dependencies){const normalizedGroup=d.groupId.replace(/\$\{project\.groupId\}/g,p.groupId??"").replace(/\$\{pom\.groupId\}/g,p.groupId??""); const target=byGA.get(`${normalizedGroup}:${d.artifactId}`)??byArtifact.get(d.artifactId); const ta=target&&anchors.get(target); if(ta&&ta!==a) r.edges.push({from:a,to:ta,kind:"import"});} + } + if(!r.sourcePaths.length) r.blockers.push("Maven reactor contains no Java sources"); + return r; +} +export const mavenAdapter:RepositoryAdapter={id:"maven",version:"1",kind:"language",detect:({files})=>files.includes("pom.xml")&&files.some(f=>f.endsWith(".java")||f.endsWith(".kt")),analyze:analyzeMaven}; diff --git a/src/repo/graph.ts b/src/repo/graph.ts index 5e9d618..21e2990 100644 --- a/src/repo/graph.ts +++ b/src/repo/graph.ts @@ -616,17 +616,18 @@ export async function buildDependencyGraph( if (contributions.some((item) => item.id === "go") && files.some((file) => /\.(?:[cm]?[jt]sx?|vue)$/.test(file))) { adapterBlockers.push("Mixed Go/JavaScript repositories require explicit cross-language dependencies; full validation required"); } - if (contributions.length && files.some((file) => /\.(?:py|rs|java|kt|cs|svelte|astro)$/.test(file))) { + if (contributions.length && files.some((file) => /\.(?:py|rs|cs|svelte|astro)$/.test(file))) { adapterBlockers.push("Unmodeled languages alongside an adapter require full validation"); } profile.adapters = contributions.map(({ id, version, blockers }) => ({ id, version, blockers })); profile.goTestPackages = Object.assign({}, ...contributions.map((item) => item.testPackages)); profile.goTestEnvironment = contributions.find((item) => item.id === "go")?.executionEnv; + profile.mavenTestModules = Object.assign({}, ...contributions.filter((item) => item.id === "maven").map((item) => item.testPackages)); const adapterTests = contributions.flatMap((item) => item.testFiles); profile.testFilePaths = [...new Set([...profile.testFilePaths, ...adapterTests])].sort(); if (profile.testUniverse) { profile.testUniverse.discoveredTestFiles = profile.testFilePaths.length; - profile.testUniverse.blindSpot = (profile.testUniverse.declaredFrameworks.length > 0 || contributions.some((item) => item.id === "go")) && profile.testFilePaths.length === 0; + profile.testUniverse.blindSpot = (profile.testUniverse.declaredFrameworks.length > 0 || contributions.some((item) => item.id === "go" || item.id === "maven")) && profile.testFilePaths.length === 0; } const entryPointPaths = new Set(profile.entryPoints.map((e) => e.path)); @@ -901,8 +902,9 @@ export function classifyRepositoryProject(repoPath: string): { capable: boolean; if (typescript.capable) return typescript; const files = adapterFiles(repoPath); if (files.includes("go.mod")) return { capable: true, reason: "Go module (package-level analysis)" }; + if (files.includes("pom.xml") && files.some((file) => file.endsWith(".java") || file.endsWith(".kt"))) return { capable: true, reason: "Maven reactor (module-level Java analysis)" }; if (files.some((file) => file.endsWith(".vue"))) return { capable: true, reason: "Vue single-file components" }; - return { capable: false, reason: "No TypeScript project, Vue components, or root Go module found" }; + return { capable: false, reason: "No TypeScript project, Vue components, root Go module, or Maven reactor found" }; } export interface TypeScriptProjectCapability { diff --git a/src/repo/impact.ts b/src/repo/impact.ts index 38c5966..5628fc4 100644 --- a/src/repo/impact.ts +++ b/src/repo/impact.ts @@ -7,7 +7,7 @@ import { repositoryLayout, UNKNOWN_REPOSITORY_LAYOUT, type RepositoryLayout } fr import { DEFAULT_TEST_FILE_MATCHER, matchesGlob as matchesTestGlob, testFileMatcherForProfile } from "./test-discovery.js"; import { resolveTestFixtureOwners } from "./test-fixture-ownership.js"; -const SOURCE_EXTENSIONS = new Set([".ts",".tsx",".js",".jsx",".mjs",".cjs",".mts",".cts",".vue",".go"]); +const SOURCE_EXTENSIONS = new Set([".ts",".tsx",".js",".jsx",".mjs",".cjs",".mts",".cts",".vue",".go",".java",".kt"]); const ASSET_EXTENSIONS = new Set([".css",".scss",".sass",".less",".json",".jsonc",".svg",".png",".jpg",".jpeg",".gif",".webp",".ico",".bmp",".woff",".woff2",".ttf",".otf",".eot",".wasm",".md",".txt"]); const NEXT_ENTRY_NAMES = new Set(["page","layout","route","api","loading","error","template","not-found","middleware","generatemetadata","generatestaticparams"]); @@ -26,7 +26,7 @@ function isDocumentationFile(filePath: string, layout: RepositoryLayout): boolea function isConfigFile(filePath: string): boolean { const CONFIG_FILE_NAMES = new Set(["package.json","package-lock.json","yarn.lock","pnpm-lock.yaml","bun.lockb","bun.lock","tsconfig.json","tsconfig.base.json","tsconfig.build.json","jsconfig.json"]); const base = posix.basename(filePath); - if (["go.mod", "go.sum", "go.work", "go.work.sum"].includes(base)) return true; + if (["go.mod", "go.sum", "go.work", "go.work.sum", "pom.xml"].includes(base)) return true; if (/^(?:vite|vue|nuxt)\.config\./.test(base)) return true; if (CONFIG_FILE_NAMES.has(base)) return true; if (base.startsWith("next.config")) return true; @@ -243,7 +243,7 @@ export class ImpactAnalyzer { const riskSignals: ImpactRiskSignal[] = []; const fallbackReasons: string[] = [...(graphResult.adapterBlockers ?? [])]; for (const file of delta.files) { - if (allChangePaths(file).some((path) => /(?:^|\/)(?:go\.(?:mod|sum|work)|go\.work\.sum|(?:vite|vue|nuxt)\.config\.[^/]+)$/.test(path))) { + if (allChangePaths(file).some((path) => /(?:^|\/)(?:go\.(?:mod|sum|work)|go\.work\.sum|pom\.xml|(?:vite|vue|nuxt)\.config\.[^/]+)$/.test(path))) { fallbackReasons.push(`Language/framework configuration changed: ${file.path}`); } } diff --git a/src/repo/test-discovery.ts b/src/repo/test-discovery.ts index 3beb139..2e6e2db 100644 --- a/src/repo/test-discovery.ts +++ b/src/repo/test-discovery.ts @@ -80,6 +80,8 @@ export interface TestDiscovery { export const DEFAULT_TEST_PATTERNS: readonly string[] = [ "**/*.test.{ts,tsx,js,jsx,mjs,cjs,mts,cts}", "**/*.spec.{ts,tsx,js,jsx,mjs,cjs,mts,cts}", + "**/src/test/java/**/*{Test,Tests,TestCase,IT}.java", + "**/src/test/kotlin/**/*{Test,Tests,TestCase,IT}.kt", ]; const FAMILY_TOKENS: Record = { diff --git a/src/repo/types.ts b/src/repo/types.ts index f876344..33b9cac 100644 --- a/src/repo/types.ts +++ b/src/repo/types.ts @@ -44,6 +44,8 @@ export interface RepositoryProfile { adapterBlockers?: string[]; goTestPackages?: Record; goTestEnvironment?: Record; + /** Maven/JUnit test file to owning reactor module (repo-relative, "." for root). */ + mavenTestModules?: Record; packageManager: PackageManager; packageJson: { name?: string; diff --git a/tests/repo/language-adapters.test.ts b/tests/repo/language-adapters.test.ts index 375026e..4baaf15 100644 --- a/tests/repo/language-adapters.test.ts +++ b/tests/repo/language-adapters.test.ts @@ -44,3 +44,126 @@ test("Vue source change reaches the importing test through a component", async ( rmSync(root, { recursive: true, force: true }); } }); + + +test("Maven multi-module source change reaches downstream module tests", async () => { + const root = fixture({ + "pom.xml": `4.0.0exampleparent1pomlibraryapplication`, + "library/pom.xml": `exampleparent1library`, + "library/src/main/java/example/Library.java": "package example; public class Library {}", + "application/pom.xml": `exampleparent1applicationexamplelibrary1`, + "application/src/main/java/example/App.java": "package example; public class App {}", + "application/src/test/java/example/AppTest.java": "package example; public class AppTest {}", + }); + try { + const graph = await buildDependencyGraph({ repoPath: root }); + assert.equal(classifyRepositoryProject(root).capable, true); + assert.deepEqual(graph.adapterBlockers, []); + const impact = new ImpactAnalyzer().analyze(delta("library/src/main/java/example/Library.java"), graph, graph.profile); + assert.equal(impact.fallbackRequired, false); + assert.deepEqual(impact.affectedTests.map((item) => item.path), ["application/src/test/java/example/AppTest.java"]); + const plan = planSelectiveTestCommands(graph.profile, impact.affectedTests.map((item) => item.path)); + assert.equal(plan.groups[0]?.runnerId, "maven:surefire"); + assert.deepEqual(plan.commands[0]?.args, ["-pl", "application", "-am", "test"]); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + + +test("Spring gs-multi-module shape selects application when library changes", async () => { + const root = fixture({ + "pom.xml": `org.springframeworkgs-multi-module0.0.1-SNAPSHOTpomlibraryapplication`, + "library/pom.xml": `org.springframework.bootspring-boot-starter-parent3.5.11com.examplelibrary0.0.1-SNAPSHOT`, + "library/src/main/java/com/example/service/MyService.java": "package com.example.service; public class MyService {}", + "library/src/test/java/com/example/service/MyServiceTest.java": "package com.example.service; public class MyServiceTest {}", + "application/pom.xml": `org.springframework.bootspring-boot-starter-parent3.5.11com.exampleapplication0.0.1-SNAPSHOTcom.examplelibrary\${project.version}`, + "application/src/main/java/com/example/application/DemoApplication.java": "package com.example.application; public class DemoApplication {}", + "application/src/test/java/com/example/application/DemoApplicationTest.java": "package com.example.application; public class DemoApplicationTest {}", + }); + try { + const graph = await buildDependencyGraph({ repoPath: root }); + const impact = new ImpactAnalyzer().analyze(delta("library/src/main/java/com/example/service/MyService.java"), graph, graph.profile); + assert.equal(impact.fallbackRequired, false); + assert.deepEqual(impact.affectedTests.map((item) => item.path), [ + "application/src/test/java/com/example/application/DemoApplicationTest.java", + "library/src/test/java/com/example/service/MyServiceTest.java", + ]); + const plan = planSelectiveTestCommands(graph.profile, impact.affectedTests.map((item) => item.path)); + assert.deepEqual(plan.commands[0]?.args, ["-pl", "application,library", "-am", "test"]); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + + +test("Maven Kotlin multi-module source change reaches downstream tests", async () => { + const root = fixture({ + "pom.xml": `org.exampleparent1pomcommonselector`, + "common/pom.xml": `org.exampleparent1common`, + "common/src/main/kotlin/org/example/Config.kt": "package org.example; class Config", + "common/src/test/kotlin/org/example/ConfigTest.kt": "package org.example; class ConfigTest", + "selector/pom.xml": `org.exampleparent1selectororg.examplecommon1`, + "selector/src/main/kotlin/org/example/Selector.kt": "package org.example; class Selector", + "selector/src/test/kotlin/org/example/SelectorTest.kt": "package org.example; class SelectorTest", + }); + try { + const graph = await buildDependencyGraph({ repoPath: root }); + assert.equal(classifyRepositoryProject(root).capable, true); + assert.deepEqual(graph.adapterBlockers, []); + const impact = new ImpactAnalyzer().analyze(delta("common/src/main/kotlin/org/example/Config.kt"), graph, graph.profile); + assert.equal(impact.fallbackRequired, false); + assert.deepEqual(impact.affectedTests.map((item) => item.path), [ + "common/src/test/kotlin/org/example/ConfigTest.kt", + "selector/src/test/kotlin/org/example/SelectorTest.kt", + ]); + const plan = planSelectiveTestCommands(graph.profile, impact.affectedTests.map((item) => item.path)); + assert.deepEqual(plan.commands[0]?.args, ["-pl", "common,selector", "-am", "test"]); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + + +test("Jicofo-style Maven property dependency connects Kotlin modules", async () => { + const root = fixture({ + "pom.xml": `org.jitsijicofo-parent1.1-SNAPSHOTpomjicofo-commonjicofo-selector`, + "jicofo-common/pom.xml": `org.jitsijicofo-parent1.1-SNAPSHOTjicofo-common`, + "jicofo-common/src/main/kotlin/org/jitsi/jicofo/JicofoConfig.kt": "package org.jitsi.jicofo; class JicofoConfig", + "jicofo-common/src/test/kotlin/org/jitsi/jicofo/JicofoConfigTest.kt": "package org.jitsi.jicofo; class JicofoConfigTest", + "jicofo-selector/pom.xml": `org.jitsijicofo-parent1.1-SNAPSHOTjicofo-selector\${project.groupId}jicofo-common\${project.version}`, + "jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/BridgeSelector.kt": "package org.jitsi.jicofo.bridge; class BridgeSelector", + "jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/BridgeSelectorTest.kt": "package org.jitsi.jicofo.bridge; class BridgeSelectorTest", + }); + try { + const graph = await buildDependencyGraph({ repoPath: root }); + const impact = new ImpactAnalyzer().analyze(delta("jicofo-common/src/main/kotlin/org/jitsi/jicofo/JicofoConfig.kt"), graph, graph.profile); + assert.equal(impact.fallbackRequired, false); + assert.deepEqual(impact.affectedTests.map((item) => item.path), [ + "jicofo-common/src/test/kotlin/org/jitsi/jicofo/JicofoConfigTest.kt", + "jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/BridgeSelectorTest.kt", + ]); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + + +test("Jicofo-style three-module reactor propagates common changes through selector to jicofo", async () => { + const root = fixture({ + "pom.xml": `org.jitsijicofo-parent1.1-SNAPSHOTpomjicofo-commonjicofo-selectorjicofo`, + "jicofo-common/pom.xml": `org.jitsijicofo-parent1.1-SNAPSHOTjicofo-common`, + "jicofo-common/src/main/kotlin/org/jitsi/jicofo/JicofoConfig.kt": "package org.jitsi.jicofo; class JicofoConfig", + "jicofo-common/src/test/kotlin/org/jitsi/jicofo/JicofoConfigTest.kt": "package org.jitsi.jicofo; class JicofoConfigTest", + "jicofo-selector/pom.xml": `org.jitsijicofo-parent1.1-SNAPSHOTjicofo-selector\${project.groupId}jicofo-common`, + "jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/BridgeSelector.kt": "package org.jitsi.jicofo.bridge; class BridgeSelector", + "jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/BridgeSelectorTest.kt": "package org.jitsi.jicofo.bridge; class BridgeSelectorTest", + "jicofo/pom.xml": `org.jitsijicofo-parent1.1-SNAPSHOTjicofo\${project.groupId}jicofo-common\${project.groupId}jicofo-selector`, + "jicofo/src/main/kotlin/org/jitsi/jicofo/JicofoServices.kt": "package org.jitsi.jicofo; class JicofoServices", + "jicofo/src/test/kotlin/org/jitsi/jicofo/JicofoServicesTest.kt": "package org.jitsi.jicofo; class JicofoServicesTest", + }); + try { + const graph = await buildDependencyGraph({ repoPath: root }); + const impact = new ImpactAnalyzer().analyze(delta("jicofo-common/src/main/kotlin/org/jitsi/jicofo/JicofoConfig.kt"), graph, graph.profile); + assert.equal(impact.fallbackRequired, false); + assert.deepEqual(impact.affectedTests.map((item) => item.path), [ + "jicofo-common/src/test/kotlin/org/jitsi/jicofo/JicofoConfigTest.kt", + "jicofo-selector/src/test/kotlin/org/jitsi/jicofo/bridge/BridgeSelectorTest.kt", + "jicofo/src/test/kotlin/org/jitsi/jicofo/JicofoServicesTest.kt", + ]); + const plan = planSelectiveTestCommands(graph.profile, impact.affectedTests.map((item) => item.path)); + assert.deepEqual(plan.commands[0]?.args, ["-pl", "jicofo,jicofo-common,jicofo-selector", "-am", "test"]); + } finally { rmSync(root, { recursive: true, force: true }); } +});