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]), + ); + }); +});