diff --git a/componentDetection.test.ts b/componentDetection.test.ts index 886aead..1a55c80 100644 --- a/componentDetection.test.ts +++ b/componentDetection.test.ts @@ -1,4 +1,4 @@ -import ComponentDetection from "./componentDetection"; +import ComponentDetection, { DependencyGraphs } from "./componentDetection"; import fs from "fs"; test("Downloads CLI", async () => { @@ -70,7 +70,7 @@ describe("ComponentDetection.makePackageUrl", () => { }); describe("ComponentDetection.processComponentsToManifests", () => { - test("adds package as direct dependency when no top level referrers", () => { + test("adds package as direct dependency when it is listed as an explicitlyReferencedComponentIds", () => { const componentsFound = [ { component: { @@ -86,20 +86,29 @@ describe("ComponentDetection.processComponentsToManifests", () => { }, isDevelopmentDependency: false, topLevelReferrers: [], // Empty = direct dependency - locationsFoundAt: ["package.json"] + locationsFoundAt: ["/package.json"] } ]; - const manifests = ComponentDetection.processComponentsToManifests(componentsFound); + const dependencyGraphs: DependencyGraphs = { + "/package.json": { + graph: { "test-package": null }, + explicitlyReferencedComponentIds: ["test-package 1.0.0 - npm"], + developmentDependencies: [], + dependencies: [] + } + }; + + const manifests = ComponentDetection.processComponentsToManifests(componentsFound, dependencyGraphs); expect(manifests).toHaveLength(1); - expect(manifests[0].name).toBe("package.json"); + expect(manifests[0].name).toBe("/package.json"); expect(manifests[0].directDependencies()).toHaveLength(1); expect(manifests[0].indirectDependencies()).toHaveLength(0); expect(manifests[0].countDependencies()).toBe(1); }); - test("adds package as indirect dependency when has top level referrers", () => { + test("adds package as indirect dependency when it is not in explicitlyReferencedComponentIds", () => { const componentsFound = [ { component: { @@ -126,56 +135,75 @@ describe("ComponentDetection.processComponentsToManifests", () => { } } ], - locationsFoundAt: ["package.json"] + locationsFoundAt: ["/package.json"] } ]; - const manifests = ComponentDetection.processComponentsToManifests(componentsFound); + const dependencyGraphs: DependencyGraphs = { + "/package.json": { + graph: { "parent-package": null }, + explicitlyReferencedComponentIds: [], + developmentDependencies: [], + dependencies: [] + } + }; + + const manifests = ComponentDetection.processComponentsToManifests(componentsFound, dependencyGraphs); expect(manifests).toHaveLength(1); - expect(manifests[0].name).toBe("package.json"); + expect(manifests[0].name).toBe("/package.json"); expect(manifests[0].directDependencies()).toHaveLength(0); expect(manifests[0].indirectDependencies()).toHaveLength(1); expect(manifests[0].countDependencies()).toBe(1); }); +}); - test("adds package as direct dependency when top level referrer is itself", () => { - const componentsFound = [ - { - component: { - name: "test-package", - version: "1.0.0", - packageUrl: { - Scheme: "pkg", - Type: "npm", - Name: "test-package", - Version: "1.0.0" - }, - id: "test-package 1.0.0 - npm" - }, - isDevelopmentDependency: false, - topLevelReferrers: [ - { - name: "test-package", - version: "1.0.0", - packageUrl: { - Scheme: "pkg", - Type: "npm", - Name: "test-package", - Version: "1.0.0" - } - } - ], - locationsFoundAt: ["package.json"] +describe('normalizeDependencyGraphPaths', () => { + test('converts absolute paths to relative paths based on filePath input', () => { + // Simulate a repo at /repo and a scan root at /repo/packages + const fakeCwd = '/workspaces'; + const filePathInput = 'my-super-cool-repo'; + const absBase = '/workspaces/my-super-cool-repo'; + const dependencyGraphs: DependencyGraphs = { + '/workspaces/my-super-cool-repo/a/package.json': { + graph: { 'foo': null }, + explicitlyReferencedComponentIds: [], + developmentDependencies: [], + dependencies: [] + }, + '/workspaces/my-super-cool-repo/b/package.json': { + graph: { 'bar': null }, + explicitlyReferencedComponentIds: [], + developmentDependencies: [], + dependencies: [] } - ]; - - const manifests = ComponentDetection.processComponentsToManifests(componentsFound); - - expect(manifests).toHaveLength(1); - expect(manifests[0].name).toBe("package.json"); - expect(manifests[0].directDependencies()).toHaveLength(1); - expect(manifests[0].indirectDependencies()).toHaveLength(0); - expect(manifests[0].countDependencies()).toBe(1); + }; + // Patch process.cwd for this test + const originalCwd = process.cwd; + (process as any).cwd = () => fakeCwd; + const normalized = ComponentDetection.normalizeDependencyGraphPaths(dependencyGraphs, filePathInput); + // Restore process.cwd + (process as any).cwd = originalCwd; + expect(Object.keys(normalized)).toContain('/a/package.json'); + expect(Object.keys(normalized)).toContain('/b/package.json'); + expect(normalized['/a/package.json'].graph).toEqual({ 'foo': null }); + expect(normalized['/b/package.json'].graph).toEqual({ 'bar': null }); + }); +}); + +describe('normalizeDependencyGraphPaths with real output.json', () => { + test('converts absolute paths in output.json to relative paths using current cwd and filePath', () => { + const output = JSON.parse(fs.readFileSync('./output.json', 'utf8')); + const dependencyGraphs = output.dependencyGraphs; + // Use the same filePath as the action default (".") + const normalized = ComponentDetection.normalizeDependencyGraphPaths(dependencyGraphs, 'test'); + // Should contain /package.json and /package-lock.json as keys + expect(Object.keys(normalized)).toContain('/package.json'); + expect(Object.keys(normalized)).toContain('/package-lock.json'); + // All keys should now be relative to the repo root (cwd) and start with '/' + for (const key of Object.keys(normalized)) { + expect(key.startsWith('/')).toBe(true); + expect(key).not.toMatch(/^\w:\\|^\/\/|^\.{1,2}\//); // Not windows absolute, not network, not relative + } }); }); diff --git a/componentDetection.ts b/componentDetection.ts index 9b1d35b..2b4f496 100644 --- a/componentDetection.ts +++ b/componentDetection.ts @@ -7,7 +7,7 @@ import { Package, Snapshot, Manifest, - submitSnapshot + submitSnapshot, } from '@github/dependency-submission-toolkit' import fetch from 'cross-fetch' import tar from 'tar' @@ -16,6 +16,7 @@ import * as exec from '@actions/exec'; import dotenv from 'dotenv' import { Context } from '@actions/github/lib/context' import { unmockedModulePathPatterns } from './jest.config' +import path from 'path'; dotenv.config(); export default class ComponentDetection { @@ -70,10 +71,11 @@ export default class ComponentDetection { core.info("Getting manifests from results"); const results = await fs.readFileSync(this.outputPath, 'utf8'); var json: any = JSON.parse(results); - return this.processComponentsToManifests(json.componentsFound); + let dependencyGraphs: DependencyGraphs = this.normalizeDependencyGraphPaths(json.dependencyGraphs, core.getInput('filePath')); + return this.processComponentsToManifests(json.componentsFound, dependencyGraphs); } - public static processComponentsToManifests(componentsFound: any[]): Manifest[] { + public static processComponentsToManifests(componentsFound: any[], dependencyGraphs: DependencyGraphs): Manifest[] { // Parse the result file and add the packages to the package cache const packageCache = new PackageCache(); const packages: Array = []; @@ -139,12 +141,12 @@ export default class ComponentDetection { const manifests: Array = []; // Check the locationsFoundAt for every package and add each as a manifest - this.addPackagesToManifests(packages, manifests); + this.addPackagesToManifests(packages, manifests, dependencyGraphs); return manifests; } - private static addPackagesToManifests(packages: Array, manifests: Array): void { + private static addPackagesToManifests(packages: Array, manifests: Array, dependencyGraphs: DependencyGraphs): void { packages.forEach((pkg: ComponentDetectionPackage) => { pkg.locationsFoundAt.forEach((location: any) => { if (!manifests.find((manifest: Manifest) => manifest.name == location)) { @@ -152,16 +154,27 @@ export default class ComponentDetection { manifests.push(manifest); } - // Filter out self-references from topLevelReferrers - const nonSelfReferrers = pkg.topLevelReferrers.filter((referrer: any) => { - if (!referrer.packageUrlString) return false; - return referrer.packageUrlString !== pkg.packageUrlString; - }); + const depGraphEntry = dependencyGraphs[location]; + if (!depGraphEntry) { + core.warning(`No dependency graph entry found for manifest location: ${location}`); + return; // Skip this location if not found in dependencyGraphs + } - if (nonSelfReferrers.length == 0) { - manifests.find((manifest: Manifest) => manifest.name == location)?.addDirectDependency(pkg, ComponentDetection.getDependencyScope(pkg)); + const directDependencies = depGraphEntry.explicitlyReferencedComponentIds; + if (directDependencies.includes(pkg.id)) { + manifests + .find((manifest: Manifest) => manifest.name == location) + ?.addDirectDependency( + pkg, + ComponentDetection.getDependencyScope(pkg) + ); } else { - manifests.find((manifest: Manifest) => manifest.name == location)?.addIndirectDependency(pkg, ComponentDetection.getDependencyScope(pkg)); + manifests + .find((manifest: Manifest) => manifest.name == location) + ?.addIndirectDependency( + pkg, + ComponentDetection.getDependencyScope(pkg) + ); } }); }); @@ -249,6 +262,29 @@ export default class ComponentDetection { throw new Error("Failed to download latest release"); } } + + /** + * Normalizes the keys of a DependencyGraphs object to be relative paths from the resolved filePath input. + * @param dependencyGraphs The DependencyGraphs object to normalize. + * @param filePathInput The filePath input (relative or absolute) from the action configuration. + * @returns A new DependencyGraphs object with relative path keys. + */ + public static normalizeDependencyGraphPaths( + dependencyGraphs: DependencyGraphs, + filePathInput: string + ): DependencyGraphs { + // Resolve the base directory from filePathInput (relative to cwd if not absolute) + const baseDir = path.resolve(process.cwd(), filePathInput); + const normalized: DependencyGraphs = {}; + for (const absPath in dependencyGraphs) { + // Make the path relative to the baseDir + let relPath = path.relative(baseDir, absPath).replace(/\\/g, '/'); + // Ensure leading slash to represent repo root + if (!relPath.startsWith('/')) relPath = '/' + relPath; + normalized[relPath] = dependencyGraphs[absPath]; + } + return normalized; + } } class ComponentDetectionPackage extends Package { @@ -261,6 +297,33 @@ class ComponentDetectionPackage extends Package { } } +/** + * Types for the dependencyGraphs section of output.json + */ +export type DependencyGraph = { + /** + * The dependency graph: keys are component IDs, values are either null (no dependencies) or an array of component IDs (dependencies) + */ + graph: Record; + /** + * Explicitly referenced component IDs + */ + explicitlyReferencedComponentIds: string[]; + /** + * Development dependencies + */ + developmentDependencies: string[]; + /** + * Regular dependencies + */ + dependencies: string[]; +}; + +/** + * The top-level dependencyGraphs object: keys are manifest file paths, values are DependencyGraph objects + */ +export type DependencyGraphs = Record; +