Skip to content

Commit 9f1330f

Browse files
authored
Merge pull request #57 from oscaruiz/fix/26-gradle-multimodule-detection
fix: detect technologies in Gradle multi-module projects via settings gradle (#26)
2 parents 323edbf + 286494c commit 9f1330f

3 files changed

Lines changed: 133 additions & 3 deletions

File tree

packages/autoskills/lib.mjs

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,33 @@ const GRADLE_SCAN_ROOT_FILES = [
5252

5353
// ── Gradle Scanning ──────────────────────────────────────────
5454

55+
/**
56+
* Extracts module paths declared in a `settings.gradle(.kts)` file.
57+
* Handles both Kotlin DSL (`include("mod:sub")`) and Groovy (`include 'mod:sub'`)
58+
* syntaxes, including multiple modules on a single line.
59+
* Colon-separated module names are converted to filesystem paths (`adapters:web` → `adapters/web`).
60+
* @param {string} content - Raw file content of settings.gradle(.kts).
61+
* @returns {string[]} Module directory paths relative to the project root.
62+
*/
63+
function parseSettingsGradleModules(content) {
64+
const modules = [];
65+
const includeRe = /include\s*\(?\s*([^)]+)/g;
66+
const quotedRe = /['"]([^'"]+)['"]/g;
67+
let includeMatch;
68+
while ((includeMatch = includeRe.exec(content)) !== null) {
69+
const args = includeMatch[1];
70+
let quotedMatch;
71+
while ((quotedMatch = quotedRe.exec(args)) !== null) {
72+
modules.push(quotedMatch[1].replace(/:/g, "/"));
73+
}
74+
}
75+
return modules;
76+
}
77+
5578
/**
5679
* Builds a list of Gradle build file paths to scan for technology markers.
57-
* Includes root-level Gradle files and `build.gradle(.kts)` inside immediate subdirectories.
80+
* Includes root-level Gradle files, `build.gradle(.kts)` inside immediate subdirectories,
81+
* and modules declared in `settings.gradle(.kts)`.
5882
* @param {string} projectDir - Absolute path to the project root.
5983
* @returns {string[]} Candidate file paths.
6084
*/
@@ -65,8 +89,17 @@ function gradleLayoutCandidatePaths(projectDir) {
6589
if (cached) return cached;
6690

6791
const candidates = [];
92+
const seen = new Set();
93+
94+
function add(filePath) {
95+
if (!seen.has(filePath)) {
96+
candidates.push(filePath);
97+
seen.add(filePath);
98+
}
99+
}
100+
68101
for (const f of GRADLE_SCAN_ROOT_FILES) {
69-
candidates.push(join(projectDir, f));
102+
add(join(projectDir, f));
70103
}
71104
let entries;
72105
try {
@@ -77,9 +110,27 @@ function gradleLayoutCandidatePaths(projectDir) {
77110
for (const e of entries) {
78111
if (!e.isDirectory() || e.name.startsWith(".") || SCAN_SKIP_DIRS.has(e.name)) continue;
79112
for (const g of ["build.gradle.kts", "build.gradle"]) {
80-
candidates.push(join(projectDir, e.name, g));
113+
add(join(projectDir, e.name, g));
81114
}
82115
}
116+
117+
// Parse settings.gradle(.kts) for declared modules (handles deep nesting)
118+
for (const settingsFile of ["settings.gradle.kts", "settings.gradle"]) {
119+
const settingsPath = join(projectDir, settingsFile);
120+
let content;
121+
try {
122+
content = readFileSync(settingsPath, "utf-8");
123+
} catch {
124+
continue;
125+
}
126+
for (const modulePath of parseSettingsGradleModules(content)) {
127+
for (const g of ["build.gradle.kts", "build.gradle"]) {
128+
add(join(projectDir, modulePath, g));
129+
}
130+
}
131+
break; // only use the first settings file found
132+
}
133+
83134
_gradleCache.set(projectDir, candidates);
84135
return candidates;
85136
}

packages/autoskills/tests/cli.test.mjs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,27 @@ describe("CLI", () => {
460460
ok(output.includes("Node.js"));
461461
});
462462

463+
it("detects technologies from Gradle multi-module project with --dry-run", () => {
464+
writePackageJson(tmp.path);
465+
writeFile(
466+
tmp.path,
467+
"settings.gradle.kts",
468+
'rootProject.name = "my-app"\ninclude("adapters:web")',
469+
);
470+
writeFile(tmp.path, "build.gradle.kts", "sourceCompatibility = JavaVersion.VERSION_17");
471+
writeFile(
472+
tmp.path,
473+
"adapters/web/build.gradle.kts",
474+
'plugins { id("org.springframework.boot") }',
475+
);
476+
writeFile(tmp.path, "src/main/resources/application.properties", "server.port=8080");
477+
478+
const output = run(["--dry-run"], tmp.path);
479+
480+
ok(output.includes("Java"));
481+
ok(output.includes("Spring Boot"));
482+
});
483+
463484
it("adds web fundamentals when npm frontend is detected too", () => {
464485
writePackageJson(tmp.path, { dependencies: { react: "^19", next: "^15" } });
465486

packages/autoskills/tests/detect.test.mjs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,64 @@ plugins {
325325
ok(ids.includes("android"));
326326
});
327327

328+
it("detects Java from nested Gradle module declared in settings.gradle.kts", () => {
329+
writePackageJson(tmp.path);
330+
writeFile(
331+
tmp.path,
332+
"settings.gradle.kts",
333+
'rootProject.name = "my-app"\ninclude("adapters:web")',
334+
);
335+
writeFile(
336+
tmp.path,
337+
"adapters/web/build.gradle.kts",
338+
"sourceCompatibility = JavaVersion.VERSION_17",
339+
);
340+
const { detected } = detectTechnologies(tmp.path);
341+
ok(detected.some((t) => t.id === "java"));
342+
});
343+
344+
it("detects Kotlin Multiplatform from nested module declared in settings.gradle", () => {
345+
writePackageJson(tmp.path);
346+
writeFile(tmp.path, "settings.gradle", "include 'shared'");
347+
writeFile(tmp.path, "shared/build.gradle.kts", 'plugins { kotlin("multiplatform") }');
348+
const { detected } = detectTechnologies(tmp.path);
349+
ok(detected.some((t) => t.id === "kotlin-multiplatform"));
350+
});
351+
352+
it("detects Android from deeply nested module in Gradle multi-module project", () => {
353+
writePackageJson(tmp.path);
354+
writeFile(tmp.path, "settings.gradle.kts", 'include("feature:login")');
355+
writeFile(
356+
tmp.path,
357+
"feature/login/build.gradle.kts",
358+
'plugins { id("com.android.library") }',
359+
);
360+
const { detected } = detectTechnologies(tmp.path);
361+
ok(detected.some((t) => t.id === "android"));
362+
});
363+
364+
it("handles settings.gradle with multiple includes on one line", () => {
365+
writePackageJson(tmp.path);
366+
writeFile(tmp.path, "settings.gradle", "include 'app', 'core', 'data'");
367+
writeFile(
368+
tmp.path,
369+
"app/build.gradle.kts",
370+
'plugins { id("java-library") }',
371+
);
372+
const { detected } = detectTechnologies(tmp.path);
373+
ok(detected.some((t) => t.id === "java"));
374+
});
375+
376+
it("handles settings.gradle.kts with multi-line includes", () => {
377+
writePackageJson(tmp.path);
378+
writeFile(tmp.path, "settings.gradle.kts", 'include(\n ":app",\n ":core"\n)');
379+
writeFile(tmp.path, "app/build.gradle.kts", 'plugins { id("java-library") }');
380+
writeFile(tmp.path, "core/build.gradle.kts", 'plugins { kotlin("multiplatform") }');
381+
const { detected } = detectTechnologies(tmp.path);
382+
ok(detected.some((t) => t.id === "java"));
383+
ok(detected.some((t) => t.id === "kotlin-multiplatform"));
384+
});
385+
328386
it("detects Java from pom.xml (Maven project)", () => {
329387
writeFile(tmp.path, "pom.xml", "<project><groupId>com.example</groupId></project>");
330388
const { detected } = detectTechnologies(tmp.path);

0 commit comments

Comments
 (0)