diff --git a/src/resources/extensions/gsd/detection.ts b/src/resources/extensions/gsd/detection.ts index d1e8fdb9f..674590426 100644 --- a/src/resources/extensions/gsd/detection.ts +++ b/src/resources/extensions/gsd/detection.ts @@ -440,6 +440,9 @@ export function detectProjectSignals(basePath: string): ProjectSignals { const springBootVersionCatalogs = scannedFiles.filter((file) => file.endsWith("libs.versions.toml")); if (containsSpringBootMarker(basePath, springBootBuildFiles, springBootVersionCatalogs)) { pushUnique(detectedFiles, "dep:spring-boot"); + if (!primaryLanguage) { + primaryLanguage = springBootBuildFiles.some((file) => file.endsWith("build.gradle.kts")) ? "kotlin" : "java/kotlin"; + } } // Git repo detection @@ -770,8 +773,8 @@ function containsDependencyMarker(basePath: string, relativePaths: string[], mar for (const relativePath of relativePaths) { try { const raw = readBounded(join(basePath, relativePath), 64 * 1024); - const content = stripDependencyComments(relativePath, raw).toLowerCase(); - if (marker === "fastapi" && /\bfastapi(?=$|[\s<=>\[\],;"'])/.test(content)) { + const content = extractDependencyContent(relativePath, raw).toLowerCase(); + if (marker === "fastapi" && /\bfastapi(?=$|[\s<=>!~\[\],;"'])/.test(content)) { return true; } } catch { @@ -793,7 +796,7 @@ function containsSpringBootMarker( try { const raw = readBounded(join(basePath, relativePath), 64 * 1024); const content = stripDependencyComments(relativePath, raw).toLowerCase(); - if (/(org\.springframework\.boot|spring[-.]boot(?:[-.]starter)?)/.test(content)) { + if (/(org\.springframework\.boot|spring-boot(?:-starter)?)/.test(content)) { return true; } @@ -854,6 +857,62 @@ function stripDependencyComments(relativePath: string, content: string): string return content; } +function extractDependencyContent(relativePath: string, content: string): string { + const stripped = stripDependencyComments(relativePath, content); + if (relativePath.endsWith("pyproject.toml")) { + return extractPyprojectDependencySections(stripped); + } + return stripped; +} + +function extractPyprojectDependencySections(content: string): string { + const lines = content.split("\n"); + const collected: string[] = []; + let section = ""; + let collectingProjectDeps = false; + let bracketDepth = 0; + + for (const line of lines) { + const trimmed = line.trim(); + + if (collectingProjectDeps) { + collected.push(line); + bracketDepth += countChar(line, "[") - countChar(line, "]"); + if (bracketDepth <= 0) { + collectingProjectDeps = false; + } + continue; + } + + const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/); + if (sectionMatch) { + section = sectionMatch[1].trim(); + continue; + } + + if (section === "project" && /^dependencies\s*=\s*\[/.test(trimmed)) { + collected.push(line); + bracketDepth = countChar(line, "[") - countChar(line, "]"); + collectingProjectDeps = bracketDepth > 0; + continue; + } + + if ( + section === "project.optional-dependencies" || + section === "tool.poetry.dependencies" || + /^tool\.poetry\.group\.[^.]+\.dependencies$/.test(section) + ) { + collected.push(line); + } + } + + return collected.join("\n"); +} + +function countChar(text: string, char: string): number { + return [...text].filter((c) => c === char).length; +} + function normalizePluginAlias(alias: string): string { return alias.toLowerCase().replace(/[-_]/g, "."); } diff --git a/src/resources/extensions/gsd/tests/detection.test.ts b/src/resources/extensions/gsd/tests/detection.test.ts index 7bf8ec359..589fdc5bd 100644 --- a/src/resources/extensions/gsd/tests/detection.test.ts +++ b/src/resources/extensions/gsd/tests/detection.test.ts @@ -782,6 +782,32 @@ test("detectProjectSignals: FastAPI detected via pyproject.toml dependency", () } }); +test("detectProjectSignals: FastAPI detected with PEP 508 ~= operator", () => { + const dir = makeTempDir("signals-fastapi-compatible-release"); + try { + writeFileSync(join(dir, "requirements.txt"), "fastapi~=0.115\n", "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("dep:fastapi"), "~= should count as a FastAPI dependency"); + } finally { + cleanup(dir); + } +}); + +test("detectProjectSignals: pyproject metadata mention does not trigger dep:fastapi", () => { + const dir = makeTempDir("signals-fastapi-pyproject-metadata"); + try { + writeFileSync( + join(dir, "pyproject.toml"), + '[project]\nname = "example"\nkeywords = ["fastapi"]\ndependencies = ["flask>=3.0"]\n', + "utf-8", + ); + const signals = detectProjectSignals(dir); + assert.ok(!signals.detectedFiles.includes("dep:fastapi"), "metadata-only mentions should not trigger FastAPI detection"); + } finally { + cleanup(dir); + } +}); + test("detectProjectSignals: FastAPI comments do not trigger dep:fastapi", () => { const dir = makeTempDir("signals-fastapi-comment"); try { @@ -875,6 +901,7 @@ test("detectProjectSignals: nested Spring Boot Gradle service emits dep:spring-b ); const signals = detectProjectSignals(dir); assert.ok(signals.detectedFiles.includes("dep:spring-boot"), "should detect nested Spring Boot Gradle service"); + assert.equal(signals.primaryLanguage, "java/kotlin"); } finally { cleanup(dir); } @@ -956,3 +983,20 @@ test("detectProjectSignals: unused Spring Boot alias in libs.versions.toml does cleanup(dir); } }); + +test("detectProjectSignals: spring-like alias name without Spring Boot id does not emit dep:spring-boot", () => { + const dir = makeTempDir("signals-spring-version-catalog-false-alias"); + try { + mkdirSync(join(dir, "gradle"), { recursive: true }); + writeFileSync(join(dir, "build.gradle.kts"), "plugins { alias(libs.plugins.spring.boot.conventions) }", "utf-8"); + writeFileSync( + join(dir, "gradle", "libs.versions.toml"), + "[plugins]\nspring-boot-conventions = { id = 'com.example.conventions', version = '1.0.0' }\n", + "utf-8", + ); + const signals = detectProjectSignals(dir); + assert.ok(!signals.detectedFiles.includes("dep:spring-boot"), "spring-looking alias names should not imply Spring Boot without matching id"); + } finally { + cleanup(dir); + } +});