Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
9 changes: 5 additions & 4 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3416,7 +3416,7 @@ A direct dependency and a `<dependencyManagement>` 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 |
| --- | --- | --- |
Expand All @@ -3429,7 +3429,8 @@ A direct dependency and a `<dependencyManagement>` entry for the same coordinate
| `libs.bundles.<alias>` 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.<alias>))` or `api(platform(libs.<alias>))` | 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.
Expand All @@ -3449,14 +3450,14 @@ A directory that contains both `pom.xml` and `build.gradle` (or `build.gradle.kt
- A versioned `<dependencyManagement>` 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 `<version>` on the `<dependency>` in that same `pom.xml`, or use `${name}` with `<name>` defined in that file's `<properties>`. A versioned `<dependencyManagement>` entry in that same file also creates the row, including one inside `<profiles>`. 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 `<dependencies>` 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 `<properties>` 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.

Expand Down
120 changes: 120 additions & 0 deletions src/core-open/scanners/java-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<'group' | 'name' | 'version', string>> = {};
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,
Expand All @@ -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) {
Expand Down
Loading
Loading