From e4182b310b9b2b62f7e887b988e5b3c07d229252 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Nov 2026 13:10:07 +0000 Subject: [PATCH] fix: keep Gradle map-style dependencies in vg scan Groovy group/name/version maps and the Kotlin key = "value" form were ignored, so those dependencies never showed up in vg scan or vg sbom export. Read both forms in any key order, with or without parentheses, and pass the coordinate through pushGradleCoord so configuration sections and de-duplication match string notation. Signed-off-by: Cursor Agent Co-authored-by: ABIZ - Agentic Business Record Standard --- CHANGELOG.md | 2 + DOCS.md | 9 +- src/core-open/scanners/java-scanner.ts | 120 ++++++++++++++++++++++ test/gradle-map-notation.test.ts | 135 +++++++++++++++++++++++++ 4 files changed, 262 insertions(+), 4 deletions(-) create mode 100644 test/gradle-map-notation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b77189..af12c1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ backward compatible. ### Fixed +- **`vg scan` and `vg sbom export` keep Gradle map-style dependencies.** Groovy `implementation group: 'g', name: 'a', version: 'v'` and Kotlin `implementation(group = "g", name = "a", version = "v")` are read in any key order, with or without parentheses. `testImplementation` stays a development dependency. A map entry with no `version` is omitted, the same as a quoted coordinate with no version (#661). + - **`vg scan` reports a .NET runtime inherited from `Directory.Build.props`.** A project that sets neither `TargetFramework` nor `TargetFrameworks` takes them from the nearest `Directory.Build.props` between that project and the scanned directory. A value in the project file wins. A value that contains `$(`, such as `$(DefaultTfm)`, is not reported as a runtime (#627). - **`vg scan` reads OpenTofu `.tofu` files for provider and module drift.** `required_providers`, `required_version`, and registry `module` blocks in a `.tofu` file are scored, including a tree that has no `.tf` file. When `x.tf` and `x.tofu` are in the same directory, only `x.tofu` is read. A `.tf` file with no `.tofu` sibling is unchanged. The file list used when there is no file index also includes `terraform.tf` and the `.tofu` names of the usual root files (#622). diff --git a/DOCS.md b/DOCS.md index 78f54dc..b3c3c34 100644 --- a/DOCS.md +++ b/DOCS.md @@ -3416,7 +3416,7 @@ A direct dependency and a `` entry for the same coordinate #### Gradle -`vg scan` reads `build.gradle` and `build.gradle.kts`. The match is a text search over the whole file for these configuration names: `implementation`, `api`, `compileOnly`, `runtimeOnly`, `testImplementation`, `testRuntimeOnly`, `testCompileOnly`, `annotationProcessor`, `kapt`. The coordinate is a quoted `group:artifact:version` (`implementation 'g:a:v'` or `implementation("g:a:v")`), or a `libs…` alias from `gradle/libs.versions.toml`. `implementation platform('g:a:v')`, `api(platform("g:a:v"))`, and `platform(libs…)` on `implementation` or `api` are included too. The configuration name is not stored. `testImplementation`, `testRuntimeOnly`, and `testCompileOnly` are `section` `devDependencies`. The other names in that list, and a `platform` row, are `section` `dependencies`. A coordinate declared on a production configuration and one of those test configurations is stored once, as `dependencies`, and the production declaration supplies the version. +`vg scan` reads `build.gradle` and `build.gradle.kts`. The match is a text search over the whole file for these configuration names: `implementation`, `api`, `compileOnly`, `runtimeOnly`, `testImplementation`, `testRuntimeOnly`, `testCompileOnly`, `annotationProcessor`, `kapt`. The coordinate is a quoted `group:artifact:version` (`implementation 'g:a:v'` or `implementation("g:a:v")`), a `group` / `name` / `version` map (`implementation group: 'g', name: 'a', version: 'v'` or `implementation(group = "g", name = "a", version = "v")`), or a `libs…` alias from `gradle/libs.versions.toml`. `implementation platform('g:a:v')`, `api(platform("g:a:v"))`, and `platform(libs…)` on `implementation` or `api` are included too. The configuration name is not stored. `testImplementation`, `testRuntimeOnly`, and `testCompileOnly` are `section` `devDependencies`. The other names in that list, and a `platform` row, are `section` `dependencies`. A coordinate declared on a production configuration and one of those test configurations is stored once, as `dependencies`, and the production declaration supplies the version. | Declaration | `vg build` import edge | `vg scan` row | | --- | --- | --- | @@ -3429,7 +3429,8 @@ A direct dependency and a `` entry for the same coordinate | `libs.bundles.` on a configuration in the list above | Omitted. | Included. Each `[bundles]` member that resolves to a versioned library becomes its own row on that configuration. | | `implementation(platform(libs.))` or `api(platform(libs.))` | Omitted. | Included, same as a quoted platform coordinate. | | Alias with no version, a `version.ref` that is not in `[versions]`, or no matching library | Omitted. | Omitted. A `gradle.lockfile` line for that coordinate does not add the row. | -| Map notation (`group:`, `name:`, `version:`), `project(':core')`, `[plugins]` aliases | Omitted. | Omitted. | +| Map notation with quoted `group` and `name`, and an optional `version`. Groovy uses `key: 'value'` (`implementation group: 'g', name: 'a', version: 'v'` and `testImplementation(group: "g", name: "a", version: "v")`). Kotlin uses `key = "value"` (`implementation(group = "g", name = "a", version = "v")`). Parentheses are optional. Keys may be in any order. | Omitted. | Included. `currentSpec` is the version string. A map with no `version` is omitted, the same as `implementation 'g:a'`. The configuration name selects the section, so `testImplementation` is `devDependencies`. | +| `project(':core')`, `[plugins]` aliases | Omitted. | Omitted. | | A matching configuration string in a comment, or inside `constraints { }` | Omitted. | Included. The search does not know comments or which block it is in. The same is true of a `libs…` alias in a comment. | `gradle/libs.versions.toml` is read from the directory that contains the build file. When that file is absent, parent directories are searched up to the directory you scanned. A catalog file that is present but not valid TOML is ignored, and a parent catalog is not used in its place. The first library alias that normalizes to the same accessor wins. `[plugins]` is not a dependency. @@ -3449,14 +3450,14 @@ A directory that contains both `pom.xml` and `build.gradle` (or `build.gradle.kt - A versioned `` entry becomes a scan row even when the POM does not depend on that artifact. A BOM import can show up as its own row. - Parent POMs, imported BOMs that this file does not restate, and `${project.version}` do not fill a missing child version. - A `gradle.lockfile` does not create a row for a coordinate the build file declared without a version, and it does not create a row for a catalog alias that has no version. -- The Gradle reader is a text search. Commented-out lines and `constraints` blocks that contain a recognized configuration become rows. Custom configurations, map notation, and `project()` dependencies do not. A `libs…` alias is included only when the version catalog supplies a version. +- The Gradle reader is a text search. Commented-out lines and `constraints` blocks that contain a recognized configuration become rows. Custom configurations and `project()` dependencies do not. A map entry with quoted `group` and `name` is included; without `version` it is omitted, the same as `implementation 'g:a'`. A `libs…` alias is included only when the version catalog supplies a version. - Two manifests in one directory do not have a defined winner. #### Recover a missing version **Maven, scan row missing.** Put a literal `` on the `` in that same `pom.xml`, or use `${name}` with `` defined in that file's ``. A versioned `` entry in that same file also creates the row, including one inside ``. A profile coordinate with a version is already a scan row. It is `optionalDependencies` unless its scope is `test`. Declare it in the top-level `` when it should be a required production row. Repeat a parent-managed or BOM-managed version in this file. Replace `${project.version}` with a literal or with a `` entry in this file. -**Gradle, scan row missing.** Declare `group:artifact:version` on `implementation`, `api`, `compileOnly`, `runtimeOnly`, `testImplementation`, `testRuntimeOnly`, `testCompileOnly`, `annotationProcessor`, or `kapt`, or use `implementation platform('g:a:v')` / `api(platform("g:a:v"))`. A `libs…` alias on one of those configurations is included when `gradle/libs.versions.toml` maps it to a group, an artifact, and a version, including `version.ref` and a `libs.bundles…` alias. Put that file in the project's `gradle/` directory, or in a parent directory up to the directory you scanned. A declaration with no version stays omitted; add the version on that line or in the catalog. `gradle.lockfile` then supplies `resolvedVersion` for a dynamic declared version (`1.+`) or a catalog pin, and leaves `currentSpec` as the text you wrote or the catalog version. Generate the lockfile with Gradle's dependency locking so the line is `group:artifact:version=configurations`, and keep it next to the build file. For a custom configuration, declare the same coordinate again on one of the configurations above. Delete a commented-out coordinate if it should not be a row. +**Gradle, scan row missing.** Declare `group:artifact:version` on `implementation`, `api`, `compileOnly`, `runtimeOnly`, `testImplementation`, `testRuntimeOnly`, `testCompileOnly`, `annotationProcessor`, or `kapt`, or use `implementation platform('g:a:v')` / `api(platform("g:a:v"))`. Map notation with a quoted `group`, `name`, and `version` on those configurations is included too. A `libs…` alias on one of those configurations is included when `gradle/libs.versions.toml` maps it to a group, an artifact, and a version, including `version.ref` and a `libs.bundles…` alias. Put that file in the project's `gradle/` directory, or in a parent directory up to the directory you scanned. A declaration with no version stays omitted; add the version on that line or in the catalog. `gradle.lockfile` then supplies `resolvedVersion` for a dynamic declared version (`1.+`) or a catalog pin, and leaves `currentSpec` as the text you wrote or the catalog version. Generate the lockfile with Gradle's dependency locking so the line is `group:artifact:version=configurations`, and keep it next to the build file. For a custom configuration, declare the same coordinate again on one of the configurations above. Delete a commented-out coordinate if it should not be a row. **`resolvedVersion` is null.** The row is present and `currentSpec` holds the declared text, and the text is not a semantic version (`1.2.3-SNAPSHOT`, `[1.0,2.0)`, `1.+` with no lockfile pin). Pin a semantic version in the declaration, or add a `gradle.lockfile` pin for a dynamic Gradle version. diff --git a/src/core-open/scanners/java-scanner.ts b/src/core-open/scanners/java-scanner.ts index 3c3442d..02da712 100644 --- a/src/core-open/scanners/java-scanner.ts +++ b/src/core-open/scanners/java-scanner.ts @@ -477,6 +477,118 @@ function pushGradleCoord(events: GradleDepEvent[], index: number, notation: stri }); } +interface GradleMapCall { + index: number; + end: number; + notation: string; + configuration: string; +} + +/** + * Map notation, in any key order, with or without parentheses. + * Groovy is `key: 'value'`; Kotlin is `key = "value"`. A quoted coordinate + * (`implementation 'g:a:v'`) stays with the string matchers. The span covers + * the call so a configuration name at the end of a quoted value (`slf4j-api`) + * is not recorded again. + */ +function findGradleMapCalls(content: string): GradleMapCall[] { + const calls: GradleMapCall[] = []; + const callRegex = new RegExp(`\\b(?:${GRADLE_CONFIG_NAMES})\\b`, 'g'); + let match: RegExpExecArray | null; + while ((match = callRegex.exec(content)) !== null) { + const region = readGradleCall(content, match.index + match[0].length); + const notation = gradleMapNotation(region.argument); + if (!notation) continue; + calls.push({ + index: match.index, + end: region.end, + notation, + configuration: bareGradleConfiguration(match[0]), + }); + } + return calls; +} + +function gradleMapCallCovers(calls: readonly GradleMapCall[], index: number): boolean { + return calls.some((call) => index >= call.index && index < call.end); +} + +/** Parenthesized argument, or the rest of the line up to the next configuration. */ +function readGradleCall(content: string, argStart: number): { argument: string; end: number } { + let i = argStart; + while (i < content.length && (content[i] === ' ' || content[i] === '\t')) i++; + if (content[i] === '(') { + const end = matchingParenEnd(content, i); + if (end !== null) return { argument: content.slice(i + 1, end - 1), end }; + } + const lineEnd = content.indexOf('\n', argStart); + const limit = lineEnd === -1 ? content.length : lineEnd; + const end = endBeforeNextCall(content, i, limit); + return { argument: content.slice(argStart, end), end }; +} + +function matchingParenEnd(content: string, open: number): number | null { + let depth = 0; + let quote: '"' | "'" | null = null; + for (let j = open; j < content.length; j++) { + const ch = content[j]!; + if (quote) { + if (ch === '\\') { + j++; + continue; + } + if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '(') depth++; + else if (ch === ')') { + depth--; + if (depth === 0) return j + 1; + } + } + return null; +} + +function endBeforeNextCall(content: string, from: number, limit: number): number { + const callAt = new RegExp(`^(?:${GRADLE_CONFIG_NAMES})\\b`); + let quote: '"' | "'" | null = null; + for (let i = from; i < limit; i++) { + const ch = content[i]!; + if (quote) { + if (ch === '\\') { + i++; + continue; + } + if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (callAt.test(content.slice(i, limit))) return i; + } + return limit; +} + +/** `group` and `name` are required. A missing `version` matches a coordinate with no version. */ +function gradleMapNotation(argument: string): string | null { + if (/^\s*['"]/.test(argument)) return null; + const fields: Partial> = {}; + const valueRegex = /\b(group|name|version)\s*[:=]\s*(['"])([^'"]*)\2/g; + let match: RegExpExecArray | null; + while ((match = valueRegex.exec(argument)) !== null) { + const key = match[1] as 'group' | 'name' | 'version'; + if (fields[key] === undefined && match[3]) fields[key] = match[3]; + } + if (!fields.group || !fields.name) return null; + return fields.version ? `${fields.group}:${fields.name}:${fields.version}` : `${fields.group}:${fields.name}`; +} + export function parseGradleBuild( content: string, filePath: string, @@ -496,21 +608,29 @@ export function parseGradleBuild( if (toolchainMatch) javaVersion = toolchainMatch[1]; const events: GradleDepEvent[] = []; + const mapCalls = findGradleMapCalls(content); // Parse dependencies: implementation 'group:artifact:version' // The optional parenthesis also matches Kotlin DSL `implementation("g:a:v")`. + // A hit inside a map call is the closing quote of a value such as `slf4j-api`. const depRegex = new RegExp(`\\b(?:${GRADLE_CONFIG_NAMES})\\s*(?:\\(?\\s*)?['"]([^'"]+)['"]`, 'g'); let match: RegExpExecArray | null; while ((match = depRegex.exec(content)) !== null) { + if (gradleMapCallCovers(mapCalls, match.index)) continue; pushGradleCoord(events, match.index, match[1]!, bareGradleConfiguration(match[0]!)); } // Parse Kotlin DSL: implementation("group:artifact:version") const kotlinDepRegex = new RegExp(`\\b(?:${GRADLE_CONFIG_NAMES})\\s*\\(\\s*"([^"]+)"\\s*\\)`, 'g'); while ((match = kotlinDepRegex.exec(content)) !== null) { + if (gradleMapCallCovers(mapCalls, match.index)) continue; pushGradleCoord(events, match.index, match[1]!, bareGradleConfiguration(match[0]!)); } + for (const call of mapCalls) { + pushGradleCoord(events, call.index, call.notation, call.configuration); + } + // Parse platform/BOM dependencies const platformRegex = /\b(?:implementation|api)\s*(?:\(?\s*)?platform\s*\(\s*['"]([^'"]+)['"]\s*\)/g; while ((match = platformRegex.exec(content)) !== null) { diff --git a/test/gradle-map-notation.test.ts b/test/gradle-map-notation.test.ts new file mode 100644 index 0000000..b30cde0 --- /dev/null +++ b/test/gradle-map-notation.test.ts @@ -0,0 +1,135 @@ +/** + * Gradle map notation is a real dependency row. Groovy `key: 'value'` and + * Kotlin `key = "value"` are read with or without parentheses, in any key + * order. `testImplementation` is a dev row. A map with no `version` is kept + * on the parsed build the same way as `implementation 'g:a'`, and `vg scan` + * omits it. A quoted coordinate on the same line stays its own row. A + * configuration name has to start at a word boundary, so `myapi` and `xkapt` + * are not dependencies. + */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { parseGradleBuild, scanJavaProjects } from '../src/core-open/scanners/java-scanner.js'; +import { MavenCache } from '../src/core-open/scanners/maven-cache.js'; +import { Semaphore } from '../src/core-open/utils/semaphore.js'; + +const GROOVY = ` +dependencies { + implementation 'com.example:string-lib:1.0.0' + implementation 'com.example:no-version-string' + implementation 'com.example:filled' + implementation group: 'org.apache.commons', name: 'commons-lang3', version: '3.14.0' + testImplementation(group: "junit", name: "junit", version: "4.13.2") + runtimeOnly(version: '2.0.0', name: 'runtime-only-lib', group: 'com.example') + implementation group: 'org.slf4j', name: 'slf4j-api', version: '2.0.9' + implementation group: 'com.example', name: 'no-version' + implementation group: 'com.example', name: 'filled', version: '5.0.0' + implementation 'com.example:kept:1.2.3' + implementation group: 'com.example', name: 'kept' + testImplementation(group: "com.example", name: "both", version: "9.9.9") + implementation group: 'com.example', name: 'both', version: '1.2.3' + implementation( + name: 'multiline-lib', + version: '8.0.0', + group: 'com.example' + ) + implementation group: 'com.example', name: 'left', version: '1.0.0' api 'com.example:right:2.0.0' + myapi(group: 'com.example', name: 'hidden-lib', version: '1.0.0') + xkapt group: 'com.example', name: 'also-hidden', version: '1.0.0' +} +`; + +const KOTLIN = ` +dependencies { + implementation(group = "org.apache.commons", name = "commons-lang3", version = "3.14.0") + testImplementation(name = "junit", version = "4.13.2", group = "junit") + compileOnly(version = "1.5.0", group = "com.example", name = "compile-only-lib") + implementation group = "org.slf4j", name = "slf4j-api", version = "2.0.9" + implementation(group = "com.example", name = "no-version") + implementation("com.example:no-version-string") + implementation("com.example:string-lib:1.0.0") + implementation(group = "com.example", name = "open") + implementation("com.example:open:6.0.0") +} +`; + +/** package, version, configuration, manifest section. Empty version means omitted from scan. */ +const GROOVY_ROWS: Array<[string, string, string, string]> = [ + ['com.example:both', '1.2.3', 'implementation', 'dependencies'], + ['com.example:filled', '5.0.0', 'implementation', 'dependencies'], + ['com.example:kept', '1.2.3', 'implementation', 'dependencies'], + ['com.example:left', '1.0.0', 'implementation', 'dependencies'], + ['com.example:multiline-lib', '8.0.0', 'implementation', 'dependencies'], + ['com.example:no-version', '', 'implementation', ''], + ['com.example:no-version-string', '', 'implementation', ''], + ['com.example:right', '2.0.0', 'api', 'dependencies'], + ['com.example:runtime-only-lib', '2.0.0', 'runtimeOnly', 'dependencies'], + ['com.example:string-lib', '1.0.0', 'implementation', 'dependencies'], + ['junit:junit', '4.13.2', 'testImplementation', 'devDependencies'], + ['org.apache.commons:commons-lang3', '3.14.0', 'implementation', 'dependencies'], + ['org.slf4j:slf4j-api', '2.0.9', 'implementation', 'dependencies'], +]; + +const KOTLIN_ROWS: Array<[string, string, string, string]> = [ + ['com.example:compile-only-lib', '1.5.0', 'compileOnly', 'dependencies'], + ['com.example:no-version', '', 'implementation', ''], + ['com.example:no-version-string', '', 'implementation', ''], + ['com.example:open', '6.0.0', 'implementation', 'dependencies'], + ['com.example:string-lib', '1.0.0', 'implementation', 'dependencies'], + ['junit:junit', '4.13.2', 'testImplementation', 'devDependencies'], + ['org.apache.commons:commons-lang3', '3.14.0', 'implementation', 'dependencies'], + ['org.slf4j:slf4j-api', '2.0.9', 'implementation', 'dependencies'], +]; + +function offlineMavenCache(): MavenCache { + const cache = new MavenCache(new Semaphore(1)); + vi.spyOn(cache, 'get').mockImplementation(async () => ({ + latest: null, + stableVersions: [], + latestStableOverall: null, + })); + return cache; +} + +function byName(source: string, fileName: string): Map { + const parsed = parseGradleBuild(source, path.join('proj', fileName)); + expect(parsed.dependencies).toHaveLength(new Set(parsed.dependencies.map((dep) => `${dep.groupId}:${dep.artifactId}`)).size); + return new Map(parsed.dependencies.map((dep) => [`${dep.groupId}:${dep.artifactId}`, dep])); +} + +describe('Gradle map notation', () => { + let root: string; + + afterEach(() => { + vi.restoreAllMocks(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it.each([ + ['build.gradle', GROOVY, GROOVY_ROWS], + ['build.gradle.kts', KOTLIN, KOTLIN_ROWS], + ] as const)('reads map notation from %s and omits a missing version', async (fileName, source, rows) => { + const stored = byName(source, fileName); + expect([...stored.keys()].sort()).toEqual(rows.map(([name]) => name).sort()); + for (const [name, version, configuration] of rows) { + expect(stored.get(name)?.version).toBe(version); + expect(stored.get(name)?.configuration).toBe(configuration); + expect(stored.get(name)?.configuration).toMatch(/^[A-Za-z]+$/); + } + expect(stored.has('com.example:hidden-lib')).toBe(false); + expect(stored.has('com.example:also-hidden')).toBe(false); + + root = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-gradle-map-')); + fs.writeFileSync(path.join(root, fileName), source); + + const projects = await scanJavaProjects(root, offlineMavenCache()); + expect(projects).toHaveLength(1); + const scanned = rows.filter(([, version]) => version !== ''); + const ordered = [...scanned].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); + expect(projects[0]!.dependencies.map((dep) => [dep.package, dep.currentSpec, dep.section])).toEqual( + ordered.map(([name, version, , section]) => [name, version, section]), + ); + }); +});