From aab9b0cb33a75abefee52f57b1d282ca3e8e9490 Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Sun, 22 Mar 2026 08:56:20 -0400 Subject: [PATCH] fix(skills): address QA round 21 QA21-1: Recognize pip-tools style requirement manifests, including requirements.in, requirements-dev.in, and files under requirements/*.in or requirements/*.txt, for Python/FastAPI detection and nested marker normalization. QA21-2: Generalize Spring Boot version-catalog detection beyond the default libs accessor by supporting any *.versions.toml catalog name and matching its corresponding accessor in build.gradle(.kts). Also fix the root-level requirements/base.in path matcher and add regression tests for custom catalog accessors and pip-tools layouts. --- src/resources/extensions/gsd/detection.ts | 32 +++++++++----- .../extensions/gsd/tests/detection.test.ts | 42 +++++++++++++++++++ 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/resources/extensions/gsd/detection.ts b/src/resources/extensions/gsd/detection.ts index ca61611e4..a5b76da24 100644 --- a/src/resources/extensions/gsd/detection.ts +++ b/src/resources/extensions/gsd/detection.ts @@ -440,7 +440,7 @@ export function detectProjectSignals(basePath: string): ProjectSignals { const springBootBuildFiles = scannedFiles.filter((file) => file.endsWith("pom.xml") || file.endsWith("build.gradle") || file.endsWith("build.gradle.kts"), ); - const springBootVersionCatalogs = scannedFiles.filter((file) => file.endsWith("libs.versions.toml")); + const springBootVersionCatalogs = scannedFiles.filter((file) => file.endsWith(".versions.toml")); if (containsSpringBootMarker(basePath, springBootBuildFiles, springBootVersionCatalogs)) { pushUnique(detectedFiles, "dep:spring-boot"); if (!primaryLanguage) { @@ -777,8 +777,9 @@ function isPythonRequirementsFile(relativePath: string): boolean { const basename = normalized.slice(normalized.lastIndexOf("/") + 1); return ( basename === "requirements.txt" || - /^requirements([-.].+)?\.txt$/i.test(basename) || - /\/requirements\/[^/]+\.txt$/i.test(normalized) + basename === "requirements.in" || + /^requirements([-.].+)?\.(txt|in)$/i.test(basename) || + /(^|\/)requirements\/[^/]+\.(txt|in)$/i.test(normalized) ); } @@ -812,6 +813,7 @@ function containsSpringBootMarker( ): boolean { const usedPluginAliases = new Set(); const usedLibraryAliases = new Set(); + const catalogAccessors = new Set(versionCatalogFiles.map(versionCatalogAccessorName).filter(Boolean)); for (const relativePath of buildFiles) { try { @@ -822,15 +824,17 @@ function containsSpringBootMarker( } const normalized = content.toLowerCase(); - const aliasRe = /alias\(\s*libs\.plugins\.([a-z0-9_.-]+)\s*\)/gi; let match: RegExpExecArray | null; - while ((match = aliasRe.exec(normalized)) !== null) { - usedPluginAliases.add(normalizePluginAlias(match[1])); - } + for (const accessor of catalogAccessors) { + const aliasRe = new RegExp(`alias\\(\\s*${accessor}\\.plugins\\.([a-z0-9_.-]+)\\s*\\)`, "gi"); + while ((match = aliasRe.exec(normalized)) !== null) { + usedPluginAliases.add(normalizePluginAlias(match[1])); + } - const libraryAliasRe = /\blibs\.((?!plugins\b)[a-z0-9_.-]+)/gi; - while ((match = libraryAliasRe.exec(normalized)) !== null) { - usedLibraryAliases.add(normalizePluginAlias(match[1])); + const libraryAliasRe = new RegExp(`\\b${accessor}\\.((?!plugins\\b)[a-z0-9_.-]+)`, "gi"); + while ((match = libraryAliasRe.exec(normalized)) !== null) { + usedLibraryAliases.add(normalizePluginAlias(match[1])); + } } } catch { // unreadable build file — continue scanning others @@ -901,7 +905,7 @@ function stripDependencyComments(relativePath: string, content: string): string if (relativePath.endsWith("pyproject.toml")) { return content.replace(/(^|\s)#.*$/gm, ""); } - if (relativePath.endsWith("libs.versions.toml")) { + if (relativePath.endsWith(".versions.toml")) { return content.replace(/(^|\s)#.*$/gm, ""); } if (relativePath.endsWith("pom.xml")) { @@ -1045,6 +1049,12 @@ function normalizePluginAlias(alias: string): string { return alias.toLowerCase().replace(/[-_]/g, "."); } +function versionCatalogAccessorName(relativePath: string): string { + const normalized = relativePath.replaceAll("\\", "/"); + const basename = normalized.slice(normalized.lastIndexOf("/") + 1); + return basename.replace(/\.versions\.toml$/i, "").toLowerCase(); +} + function scanProjectFiles(basePath: string): string[] { const files: string[] = []; const queue: Array<{ path: string; depth: number }> = [{ path: basePath, depth: 0 }]; diff --git a/src/resources/extensions/gsd/tests/detection.test.ts b/src/resources/extensions/gsd/tests/detection.test.ts index d73fac377..373057d59 100644 --- a/src/resources/extensions/gsd/tests/detection.test.ts +++ b/src/resources/extensions/gsd/tests/detection.test.ts @@ -864,6 +864,31 @@ test("detectProjectSignals: FastAPI direct reference with @ emits dep:fastapi", } }); +test("detectProjectSignals: FastAPI detected via requirements.in", () => { + const dir = makeTempDir("signals-fastapi-requirements-in"); + try { + writeFileSync(join(dir, "requirements.in"), "fastapi>=0.115\n", "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("dep:fastapi"), "requirements.in should trigger FastAPI detection"); + assert.ok(signals.detectedFiles.includes("requirements.txt"), "requirements.in should normalize to requirements.txt marker"); + } finally { + cleanup(dir); + } +}); + +test("detectProjectSignals: FastAPI detected via nested requirements/base.in", () => { + const dir = makeTempDir("signals-fastapi-requirements-dir-in"); + try { + mkdirSync(join(dir, "requirements"), { recursive: true }); + writeFileSync(join(dir, "requirements", "base.in"), "fastapi>=0.115\n", "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("dep:fastapi"), "requirements/base.in should trigger FastAPI detection"); + assert.ok(signals.detectedFiles.includes("requirements.txt"), "requirements/base.in should normalize to requirements.txt marker"); + } finally { + cleanup(dir); + } +}); + test("detectProjectSignals: FastAPI comments do not trigger dep:fastapi", () => { const dir = makeTempDir("signals-fastapi-comment"); try { @@ -1155,3 +1180,20 @@ test("detectProjectSignals: Spring Boot version-catalog bundle alias emits dep:s cleanup(dir); } }); + +test("detectProjectSignals: Spring Boot custom version-catalog accessor emits dep:spring-boot", () => { + const dir = makeTempDir("signals-spring-version-catalog-custom-accessor"); + try { + mkdirSync(join(dir, "gradle"), { recursive: true }); + writeFileSync(join(dir, "build.gradle.kts"), "plugins { alias(backend.plugins.web) }", "utf-8"); + writeFileSync( + join(dir, "gradle", "backend.versions.toml"), + "[plugins]\nweb = { id = 'org.springframework.boot', version = '3.2.0' }\n", + "utf-8", + ); + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("dep:spring-boot"), "custom version-catalog accessors should trigger Spring Boot detection"); + } finally { + cleanup(dir); + } +});