diff --git a/lib/graph.ts b/lib/graph.ts index be1b3ac..40cdd60 100644 --- a/lib/graph.ts +++ b/lib/graph.ts @@ -1,4 +1,4 @@ -import { DepGraphBuilder, PkgManager } from '@snyk/dep-graph'; +import { DepGraphBuilder, PkgInfo, PkgManager } from '@snyk/dep-graph'; import type { CoordinateMap } from './types'; @@ -13,12 +13,14 @@ export interface GradleGraph { interface QueueItem { id: string; parentId: string; + ancestry: string[]; } export async function buildGraph( gradleGraph: GradleGraph, rootPkgName: string, projectVersion: string, + verbose?: boolean, coordinateMap?: CoordinateMap, ) { const pkgManager: PkgManager = { name: 'gradle' }; @@ -33,15 +35,16 @@ export async function buildGraph( return depGraphBuilder.build(); } - const visited: string[] = []; + const visitedMap: Record = {}; const queue: QueueItem[] = []; - queue.push(...findChildren('root-node', gradleGraph)); // queue direct dependencies + queue.push(...findChildren('root-node', [], gradleGraph)); // queue direct dependencies // breadth first search while (queue.length > 0) { const item = queue.shift(); if (!item) continue; let { id, parentId } = item; + const { ancestry } = item; // take a copy as id maybe mutated below and we need this id when finding childing in GradleGraph const gradleGraphId = `${id}`; const node = gradleGraph[id]; @@ -59,7 +62,9 @@ export async function buildGraph( parentId = coordinateMap[parentId]; } } - if (visited.includes(id)) { + + const visited = visitedMap[id]; + if (!verbose && visited) { const prunedId = id + ':pruned'; depGraphBuilder.addPkgNode({ name, version }, prunedId, { labels: { pruned: 'true' }, @@ -67,10 +72,27 @@ export async function buildGraph( depGraphBuilder.connectDep(parentId, prunedId); continue; // don't queue any more children } - depGraphBuilder.addPkgNode({ name, version }, id); - depGraphBuilder.connectDep(parentId, id); - queue.push(...findChildren(gradleGraphId, gradleGraph)); // queue children - visited.push(id); + + if (verbose && ancestry.includes(id)) { + const prunedId = id + ':pruned'; + depGraphBuilder.addPkgNode(visited, prunedId, { + labels: { pruned: 'cyclic' }, + }); + depGraphBuilder.connectDep(parentId, prunedId); + continue; // don't queue any more children + } + + if (verbose && visited) { + // use visited node when omitted dependencies found (verbose) + depGraphBuilder.addPkgNode(visited, id); + depGraphBuilder.connectDep(parentId, id); + } else { + depGraphBuilder.addPkgNode({ name, version }, id); + depGraphBuilder.connectDep(parentId, id); + visitedMap[id] = { name, version }; + } + // Remember to push updated ancestry here + queue.push(...findChildren(gradleGraphId, [...ancestry, id], gradleGraph)); // queue children } return depGraphBuilder.build(); @@ -78,13 +100,14 @@ export async function buildGraph( export function findChildren( parentId: string, + ancestry: string[], gradleGraph: GradleGraph, ): QueueItem[] { const result: QueueItem[] = []; for (const id of Object.keys(gradleGraph)) { const node = gradleGraph[id]; if (node?.parentIds?.includes(parentId)) { - result.push({ id, parentId }); + result.push({ id, ancestry, parentId }); } } return result; diff --git a/lib/index.ts b/lib/index.ts index 0095dfe..fb872bd 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -13,12 +13,14 @@ import { getGradleAttributesPretty } from './gradle-attributes-pretty'; import { buildGraph, GradleGraph } from './graph'; import type { CoordinateMap, + GradleInspectOptions, PomCoords, Sha1Map, SnykHttpClient, } from './types'; import { getMavenPackageInfo } from './search'; import debugModule = require('debug'); +import { CliOptions } from './types'; type ScannedProject = legacyCommon.ScannedProject; @@ -43,35 +45,7 @@ const cannotResolveVariantMarkers = [ 'Unable to find a matching variant of project', ]; -// TODO(kyegupov): the types below will be extracted to a common plugin interface library - -export interface GradleInspectOptions { - // A regular expression (Java syntax, case-insensitive) to select Gradle configurations. - // If only one configuration matches, its attributes will be used for dependency resolution; - // otherwise, an artificial merged configuration will be created (see configuration-attributes - // below). - // Attributes are important for dependency resolution in Android builds (see - // https://developer.android.com/studio/build/dependencies#variant_aware ) - // This replaces legacy `-- --configuration=foo` argument specification. - 'configuration-matching'?: string; - - // A comma-separated list of key:value pairs, e.g. "buildtype=release,usage=java-runtime". - // If specified, will scan all configurations for attributes with names that contain "keys" (case-insensitive) - // and have values that have a string representation that match the specified one, and will copy - // these attributes into the merged configuration. - // Attributes are important for dependency resolution in Android builds (see - // https://developer.android.com/studio/build/dependencies#variant_aware ) - 'configuration-attributes'?: string; - - // For some reason, `--no-daemon` is not required for Unix, but on Windows, without this flag, apparently, - // Gradle process just never exits, from the Node's standpoint. - // Leaving default usage `--no-daemon`, because of backwards compatibility - daemon?: boolean; - initScript?: string; - gradleNormalizeDeps?: boolean; -} - -type Options = api.InspectOptions & GradleInspectOptions; +type Options = api.InspectOptions & GradleInspectOptions & CliOptions; type VersionBuildInfo = api.VersionBuildInfo; // Overload type definitions, so that when you call inspect() with an `options` literal (e.g. in tests), @@ -79,13 +53,17 @@ type VersionBuildInfo = api.VersionBuildInfo; export async function inspect( root: string, targetFile: string, - options?: api.SingleSubprojectInspectOptions & GradleInspectOptions, + options?: api.SingleSubprojectInspectOptions & + GradleInspectOptions & + CliOptions, snykHttpClient?: SnykHttpClient, ): Promise; export async function inspect( root: string, targetFile: string, - options: api.MultiSubprojectInspectOptions & GradleInspectOptions, + options: api.MultiSubprojectInspectOptions & + GradleInspectOptions & + CliOptions, snykHttpClient?: SnykHttpClient, ): Promise; @@ -526,7 +504,11 @@ async function getAllDeps( concurrency: 100, }); } - return await processProjectsInExtractedJSON(extractedJSON, coordinateMap); + return await processProjectsInExtractedJSON( + extractedJSON, + options['print-graph'], + coordinateMap, + ); } catch (err) { const error: Error = err; const gradleErrorMarkers = /^\s*>\s.*$/; @@ -611,6 +593,7 @@ ${chalk.red.bold(mainErrorMessage)}`; export async function processProjectsInExtractedJSON( extractedJSON: JsonDepsScriptResult, + verbose?: boolean, coordinateMap?: CoordinateMap, ) { for (const projectId in extractedJSON.projects) { @@ -632,6 +615,7 @@ export async function processProjectsInExtractedJSON( gradleGraph, rootPkgName, projectVersion, + verbose, coordinateMap, ); // this property usage ends here diff --git a/lib/types.ts b/lib/types.ts index b43859c..b18abf3 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -53,3 +53,34 @@ export interface GetPackageData { data: GetPackageResponseData; links: GetPackageLinks; } + +// TODO(kyegupov): the types below will be extracted to a common plugin interface library +export interface GradleInspectOptions { + // A regular expression (Java syntax, case-insensitive) to select Gradle configurations. + // If only one configuration matches, its attributes will be used for dependency resolution; + // otherwise, an artificial merged configuration will be created (see configuration-attributes + // below). + // Attributes are important for dependency resolution in Android builds (see + // https://developer.android.com/studio/build/dependencies#variant_aware ) + // This replaces legacy `-- --configuration=foo` argument specification. + 'configuration-matching'?: string; + + // A comma-separated list of key:value pairs, e.g. "buildtype=release,usage=java-runtime". + // If specified, will scan all configurations for attributes with names that contain "keys" (case-insensitive) + // and have values that have a string representation that match the specified one, and will copy + // these attributes into the merged configuration. + // Attributes are important for dependency resolution in Android builds (see + // https://developer.android.com/studio/build/dependencies#variant_aware ) + 'configuration-attributes'?: string; + + // For some reason, `--no-daemon` is not required for Unix, but on Windows, without this flag, apparently, + // Gradle process just never exits, from the Node's standpoint. + // Leaving default usage `--no-daemon`, because of backwards compatibility + daemon?: boolean; + initScript?: string; + gradleNormalizeDeps?: boolean; +} + +export interface CliOptions { + 'print-graph'?: boolean; // this will need to change as it will affect all gradle sboms +} diff --git a/test/fixtures/verbose/build.gradle b/test/fixtures/verbose/build.gradle new file mode 100644 index 0000000..086e076 --- /dev/null +++ b/test/fixtures/verbose/build.gradle @@ -0,0 +1,13 @@ +plugins { + id 'java' +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.apache.ignite:ignite-spring:2.13.0' + implementation 'org.apache.ignite:ignite-indexing:2.13.0' + implementation 'org.apache.ignite:ignite-core:2.13.0' +} diff --git a/test/fixtures/verbose/dep-graph.json b/test/fixtures/verbose/dep-graph.json new file mode 100644 index 0000000..69d4fb4 --- /dev/null +++ b/test/fixtures/verbose/dep-graph.json @@ -0,0 +1,348 @@ +{ + "graph": { + "nodes": [ + { + "deps": [ + { + "nodeId": "org.apache.ignite:ignite-spring@2.13.0" + }, + { + "nodeId": "org.apache.ignite:ignite-indexing@2.13.0" + }, + { + "nodeId": "org.apache.ignite:ignite-core@2.13.0" + } + ], + "nodeId": "root-node", + "pkgId": "verbose@unspecified" + }, + { + "deps": [ + { + "nodeId": "org.apache.ignite:ignite-indexing@2.13.0" + }, + { + "nodeId": "org.apache.ignite:ignite-core@2.13.0" + }, + { + "nodeId": "org.springframework:spring-context@5.2.21.RELEASE" + }, + { + "nodeId": "commons-logging:commons-logging@1.1.1" + } + ], + "nodeId": "org.apache.ignite:ignite-spring@2.13.0", + "pkgId": "org.apache.ignite:ignite-spring@2.13.0" + }, + { + "deps": [ + { + "nodeId": "org.apache.ignite:ignite-core@2.13.0" + }, + { + "nodeId": "commons-codec:commons-codec@1.13" + }, + { + "nodeId": "org.apache.lucene:lucene-analyzers-common@7.4.0" + }, + { + "nodeId": "org.apache.lucene:lucene-core@7.4.0" + }, + { + "nodeId": "org.apache.lucene:lucene-queryparser@7.4.0" + }, + { + "nodeId": "com.h2database:h2@1.4.197" + } + ], + "nodeId": "org.apache.ignite:ignite-indexing@2.13.0", + "pkgId": "org.apache.ignite:ignite-indexing@2.13.0" + }, + { + "deps": [ + { + "nodeId": "javax.cache:cache-api@1.0.0" + }, + { + "nodeId": "org.jetbrains:annotations@16.0.3" + } + ], + "nodeId": "org.apache.ignite:ignite-core@2.13.0", + "pkgId": "org.apache.ignite:ignite-core@2.13.0" + }, + { + "deps": [ + { + "nodeId": "org.springframework:spring-aop@5.2.21.RELEASE" + }, + { + "nodeId": "org.springframework:spring-beans@5.2.21.RELEASE" + }, + { + "nodeId": "org.springframework:spring-core@5.2.21.RELEASE" + }, + { + "nodeId": "org.springframework:spring-expression@5.2.21.RELEASE" + } + ], + "nodeId": "org.springframework:spring-context@5.2.21.RELEASE", + "pkgId": "org.springframework:spring-context@5.2.21.RELEASE" + }, + { + "deps": [], + "nodeId": "commons-logging:commons-logging@1.1.1", + "pkgId": "commons-logging:commons-logging@1.1.1" + }, + { + "deps": [], + "nodeId": "commons-codec:commons-codec@1.13", + "pkgId": "commons-codec:commons-codec@1.13" + }, + { + "deps": [ + { + "nodeId": "org.apache.lucene:lucene-core@7.4.0" + } + ], + "nodeId": "org.apache.lucene:lucene-analyzers-common@7.4.0", + "pkgId": "org.apache.lucene:lucene-analyzers-common@7.4.0" + }, + { + "deps": [], + "nodeId": "org.apache.lucene:lucene-core@7.4.0", + "pkgId": "org.apache.lucene:lucene-core@7.4.0" + }, + { + "deps": [ + { + "nodeId": "org.apache.lucene:lucene-core@7.4.0" + }, + { + "nodeId": "org.apache.lucene:lucene-queries@7.4.0" + }, + { + "nodeId": "org.apache.lucene:lucene-sandbox@7.4.0" + } + ], + "nodeId": "org.apache.lucene:lucene-queryparser@7.4.0", + "pkgId": "org.apache.lucene:lucene-queryparser@7.4.0" + }, + { + "deps": [], + "nodeId": "com.h2database:h2@1.4.197", + "pkgId": "com.h2database:h2@1.4.197" + }, + { + "deps": [], + "nodeId": "javax.cache:cache-api@1.0.0", + "pkgId": "javax.cache:cache-api@1.0.0" + }, + { + "deps": [], + "nodeId": "org.jetbrains:annotations@16.0.3", + "pkgId": "org.jetbrains:annotations@16.0.3" + }, + { + "deps": [ + { + "nodeId": "org.springframework:spring-beans@5.2.21.RELEASE" + }, + { + "nodeId": "org.springframework:spring-core@5.2.21.RELEASE" + } + ], + "nodeId": "org.springframework:spring-aop@5.2.21.RELEASE", + "pkgId": "org.springframework:spring-aop@5.2.21.RELEASE" + }, + { + "deps": [ + { + "nodeId": "org.springframework:spring-core@5.2.21.RELEASE" + } + ], + "nodeId": "org.springframework:spring-beans@5.2.21.RELEASE", + "pkgId": "org.springframework:spring-beans@5.2.21.RELEASE" + }, + { + "deps": [ + { + "nodeId": "org.springframework:spring-jcl@5.2.21.RELEASE" + } + ], + "nodeId": "org.springframework:spring-core@5.2.21.RELEASE", + "pkgId": "org.springframework:spring-core@5.2.21.RELEASE" + }, + { + "deps": [ + { + "nodeId": "org.springframework:spring-core@5.2.21.RELEASE" + } + ], + "nodeId": "org.springframework:spring-expression@5.2.21.RELEASE", + "pkgId": "org.springframework:spring-expression@5.2.21.RELEASE" + }, + { + "deps": [], + "nodeId": "org.apache.lucene:lucene-queries@7.4.0", + "pkgId": "org.apache.lucene:lucene-queries@7.4.0" + }, + { + "deps": [], + "nodeId": "org.apache.lucene:lucene-sandbox@7.4.0", + "pkgId": "org.apache.lucene:lucene-sandbox@7.4.0" + }, + { + "deps": [], + "nodeId": "org.springframework:spring-jcl@5.2.21.RELEASE", + "pkgId": "org.springframework:spring-jcl@5.2.21.RELEASE" + } + ], + "rootNodeId": "root-node" + }, + "pkgManager": { + "name": "gradle" + }, + "pkgs": [ + { + "id": "verbose@unspecified", + "info": { + "name": "verbose", + "version": "unspecified" + } + }, + { + "id": "org.apache.ignite:ignite-spring@2.13.0", + "info": { + "name": "org.apache.ignite:ignite-spring", + "version": "2.13.0" + } + }, + { + "id": "org.apache.ignite:ignite-indexing@2.13.0", + "info": { + "name": "org.apache.ignite:ignite-indexing", + "version": "2.13.0" + } + }, + { + "id": "org.apache.ignite:ignite-core@2.13.0", + "info": { + "name": "org.apache.ignite:ignite-core", + "version": "2.13.0" + } + }, + { + "id": "org.springframework:spring-context@5.2.21.RELEASE", + "info": { + "name": "org.springframework:spring-context", + "version": "5.2.21.RELEASE" + } + }, + { + "id": "commons-logging:commons-logging@1.1.1", + "info": { + "name": "commons-logging:commons-logging", + "version": "1.1.1" + } + }, + { + "id": "commons-codec:commons-codec@1.13", + "info": { + "name": "commons-codec:commons-codec", + "version": "1.13" + } + }, + { + "id": "org.apache.lucene:lucene-analyzers-common@7.4.0", + "info": { + "name": "org.apache.lucene:lucene-analyzers-common", + "version": "7.4.0" + } + }, + { + "id": "org.apache.lucene:lucene-core@7.4.0", + "info": { + "name": "org.apache.lucene:lucene-core", + "version": "7.4.0" + } + }, + { + "id": "org.apache.lucene:lucene-queryparser@7.4.0", + "info": { + "name": "org.apache.lucene:lucene-queryparser", + "version": "7.4.0" + } + }, + { + "id": "com.h2database:h2@1.4.197", + "info": { + "name": "com.h2database:h2", + "version": "1.4.197" + } + }, + { + "id": "javax.cache:cache-api@1.0.0", + "info": { + "name": "javax.cache:cache-api", + "version": "1.0.0" + } + }, + { + "id": "org.jetbrains:annotations@16.0.3", + "info": { + "name": "org.jetbrains:annotations", + "version": "16.0.3" + } + }, + { + "id": "org.springframework:spring-aop@5.2.21.RELEASE", + "info": { + "name": "org.springframework:spring-aop", + "version": "5.2.21.RELEASE" + } + }, + { + "id": "org.springframework:spring-beans@5.2.21.RELEASE", + "info": { + "name": "org.springframework:spring-beans", + "version": "5.2.21.RELEASE" + } + }, + { + "id": "org.springframework:spring-core@5.2.21.RELEASE", + "info": { + "name": "org.springframework:spring-core", + "version": "5.2.21.RELEASE" + } + }, + { + "id": "org.springframework:spring-expression@5.2.21.RELEASE", + "info": { + "name": "org.springframework:spring-expression", + "version": "5.2.21.RELEASE" + } + }, + { + "id": "org.apache.lucene:lucene-queries@7.4.0", + "info": { + "name": "org.apache.lucene:lucene-queries", + "version": "7.4.0" + } + }, + { + "id": "org.apache.lucene:lucene-sandbox@7.4.0", + "info": { + "name": "org.apache.lucene:lucene-sandbox", + "version": "7.4.0" + } + }, + { + "id": "org.springframework:spring-jcl@5.2.21.RELEASE", + "info": { + "name": "org.springframework:spring-jcl", + "version": "5.2.21.RELEASE" + } + } + ], + "schemaVersion": "1.2.0" +} diff --git a/test/functional/graph.spec.ts b/test/functional/graph.spec.ts index 2abe00e..0bc1d1c 100644 --- a/test/functional/graph.spec.ts +++ b/test/functional/graph.spec.ts @@ -10,6 +10,7 @@ describe('buildGraph', () => { ); expect(received.equals(expected.build())).toBe(true); }); + it('returns expected graph with top level dependencies', async () => { const received = await buildGraph( { @@ -57,6 +58,7 @@ describe('buildGraph', () => { expected.connectDep('a@1', 'b@1'); expect(received.equals(expected.build())).toBe(true); }); + it('returns expected graph with cyclic dependencies', async () => { const received = await buildGraph( { @@ -95,6 +97,124 @@ describe('buildGraph', () => { expected.connectDep('c@1', 'b@1:pruned'); expect(received.equals(expected.build())).toBe(true); }); + + it('returns expected graph with cyclic dependencies and verbose', async () => { + const received = await buildGraph( + { + 'a@1': { + name: 'a', + version: '1', + parentIds: ['root-node'], + }, + 'b@1': { + name: 'b', + version: '1', + parentIds: ['a@1', 'c@1'], + }, + 'c@1': { + name: 'c', + version: '1', + parentIds: ['b@1'], // cycle between b and c + }, + }, + 'project', + '1.2.3', + true, + ); + const expected = new DepGraphBuilder( + { name: 'gradle' }, + { name: 'project', version: '1.2.3' }, + ); + expected.addPkgNode({ name: 'a', version: '1' }, 'a@1'); + expected.connectDep(expected.rootNodeId, 'a@1'); + expected.addPkgNode({ name: 'b', version: '1' }, 'b@1'); + expected.connectDep('a@1', 'b@1'); + expected.addPkgNode({ name: 'c', version: '1' }, 'c@1'); + expected.connectDep('b@1', 'c@1'); + expected.addPkgNode({ name: 'b', version: '1' }, 'b@1:pruned', { + labels: { pruned: 'cyclic' }, + }); + expected.connectDep('c@1', 'b@1:pruned'); + expect(received.equals(expected.build())).toBe(true); + }); + + it('returns expected graph with repeated dependencies', async () => { + const received = await buildGraph( + { + 'a@1': { + name: 'a', + version: '1', + parentIds: ['root-node'], + }, + 'b@1': { + name: 'b', + version: '1', + parentIds: ['a@1'], + }, + 'c@1': { + name: 'c', + version: '1', + parentIds: ['a@1', 'b@1'], + }, + }, + 'project', + '1.2.3', + false, + ); + const expected = new DepGraphBuilder( + { name: 'gradle' }, + { name: 'project', version: '1.2.3' }, + ); + expected.addPkgNode({ name: 'a', version: '1' }, 'a@1'); + expected.connectDep(expected.rootNodeId, 'a@1'); + expected.addPkgNode({ name: 'b', version: '1' }, 'b@1'); + expected.connectDep('a@1', 'b@1'); + expected.addPkgNode({ name: 'c', version: '1' }, 'c@1'); + expected.connectDep('a@1', 'c@1'); + expected.addPkgNode({ name: 'c', version: '1' }, 'c@1:pruned', { + labels: { pruned: 'true' }, + }); + expected.connectDep('b@1', 'c@1:pruned'); + expect(received.equals(expected.build())).toBe(true); + }); + + it('returns expected graph with repeated dependencies and verbose', async () => { + const received = await buildGraph( + { + 'a@1': { + name: 'a', + version: '1', + parentIds: ['root-node'], + }, + 'b@1': { + name: 'b', + version: '1', + parentIds: ['a@1'], + }, + 'c@1': { + name: 'c', + version: '1', + parentIds: ['a@1', 'b@1'], // cycle between b and c + }, + }, + 'project', + '1.2.3', + true, + ); + const expected = new DepGraphBuilder( + { name: 'gradle' }, + { name: 'project', version: '1.2.3' }, + ); + expected.addPkgNode({ name: 'a', version: '1' }, 'a@1'); + expected.connectDep(expected.rootNodeId, 'a@1'); + expected.addPkgNode({ name: 'b', version: '1' }, 'b@1'); + expected.connectDep('a@1', 'b@1'); + expected.addPkgNode({ name: 'c', version: '1' }, 'c@1'); + expected.connectDep('b@1', 'c@1'); + expected.connectDep('a@1', 'c@1'); + expect(received.equals(expected.build())).toBe(true); + }); + it('returns expected graph with coordinate map', async () => { const received = await buildGraph( { @@ -111,6 +231,7 @@ describe('buildGraph', () => { }, 'project', '1.2.3', + false, { 'com.private:a@1': 'unknown:a@unknown', }, @@ -135,11 +256,12 @@ describe('buildGraph', () => { describe('findChildren', () => { it('returns empty when graph empty', () => { - const received = findChildren('root-node', {}); + const received = findChildren('root-node', [], {}); expect(received).toEqual([]); }); + it('returns empty when graph has no parent id', () => { - const received = findChildren('not-found', { + const received = findChildren('not-found', [], { 'a@1': { name: 'a', version: '1', @@ -148,8 +270,9 @@ describe('findChildren', () => { }); expect(received).toEqual([]); }); + it('returns nodes with given parent id', () => { - const received = findChildren('root-node', { + const received = findChildren('root-node', [], { 'a@1': { name: 'a', version: '1', @@ -167,12 +290,13 @@ describe('findChildren', () => { }, }); expect(received).toEqual([ - { id: 'a@1', parentId: 'root-node' }, - { id: 'c@1', parentId: 'root-node' }, + { id: 'a@1', ancestry: [], parentId: 'root-node' }, + { id: 'c@1', ancestry: [], parentId: 'root-node' }, ]); }); + it('returns nodes with given parent id when multiple parents', () => { - const received = findChildren('root-node', { + const received = findChildren('root-node', [], { 'a@1': { name: 'a', version: '1', @@ -190,9 +314,9 @@ describe('findChildren', () => { }, }); expect(received).toEqual([ - { id: 'a@1', parentId: 'root-node' }, - { id: 'b@1', parentId: 'root-node' }, - { id: 'c@1', parentId: 'root-node' }, + { id: 'a@1', ancestry: [], parentId: 'root-node' }, + { id: 'b@1', ancestry: [], parentId: 'root-node' }, + { id: 'c@1', ancestry: [], parentId: 'root-node' }, ]); }); }); diff --git a/test/system/plugin.test.ts b/test/system/plugin.test.ts index 33fd43f..15044d0 100644 --- a/test/system/plugin.test.ts +++ b/test/system/plugin.test.ts @@ -200,3 +200,15 @@ test('repeated transitive lines terminated at duplicate node and labeled pruned' const result = await inspect('.', path.join(pathToFixture, 'build.gradle')); expect(result.dependencyGraph?.equals(expected)).toBe(true); }); + +test('repeated transitive lines not pruned if verbose graph', async () => { + const pathToFixture = fixtureDir('verbose'); + const expectedJson = JSON.parse( + fs.readFileSync(path.join(pathToFixture, 'dep-graph.json'), 'utf-8'), + ); + const expected = depGraphLib.createFromJSON(expectedJson); + const result = await inspect('.', path.join(pathToFixture, 'build.gradle'), { + 'print-graph': true, + }); + expect(result.dependencyGraph?.equals(expected)).toBe(true); +});