From 2410fdf426aecee9b5fca012ba9239901f423351 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 8 Oct 2026 13:41:14 +0200 Subject: [PATCH 1/3] feat(manifest): attribute Gradle dependencies to the script declaring them A direct dependency's build-file mark is now the in-build script that declared it: the root build script for one added from subprojects {} or project(':x') {}, a script plugin for apply from. A subproject configured entirely from the root therefore gets the root script, both on its direct dependencies and in projects[].manifestFiles, instead of no build file. A dependency no build script declared, such as one added by a plugin, keeps the project's own build file. Co-Authored-By: Claude Opus 5.5 --- src/commands/manifest/scripts/assemble.mts | 22 +++++-- .../manifest/scripts/assemble.test.mts | 43 ++++++++++++ src/commands/manifest/scripts/facts.mts | 8 ++- src/commands/manifest/scripts/records.mts | 11 ++++ .../manifest/scripts/socket-facts.init.gradle | 65 +++++++++++++++++++ 5 files changed, 141 insertions(+), 8 deletions(-) diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index c9e1dc47a..1a2bb023f 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -193,15 +193,22 @@ function buildManifestFilesById( ): Map { const buildFilesByCoord = new Map>() for (const [rootId, ids] of directByRoot) { - const projectKey = perRoot.get(rootId)?.projectKey ?? '' - const buildFiles = parsed.projects.get(projectKey)?.buildFiles ?? [] + const root = perRoot.get(rootId) + const project = parsed.projects.get(root?.projectKey ?? '') for (const id of ids) { let set = buildFilesByCoord.get(id) if (!set) { set = new Set() buildFilesByCoord.set(id, set) } - for (const f of buildFiles) { + const coord = root?.nodes.get(id)?.coord + // A dependency no build script declared (e.g. one a plugin adds) is + // attributed to the project's own build file. + const files = + (coord && project?.declaredIn.get(`${coord.group}:${coord.name}`)) || + project?.buildFiles || + [] + for (const f of files) { set.add(f) } } @@ -288,8 +295,13 @@ function buildProjects( subprojectDir: p.dir, dependencies: [...(directByProject.get(p.projectKey) ?? [])].sort(), } - if (p.buildFiles.length) { - entry.manifestFiles = [...p.buildFiles].sort().map(file => ({ file })) + // A project without a build file of its own is defined by the scripts + // declaring its dependencies, e.g. the root build script. + const files = p.buildFiles.length + ? p.buildFiles + : [...new Set([...p.declaredIn.values()].flat())] + if (files.length) { + entry.manifestFiles = [...files].sort().map(file => ({ file })) } return entry }) diff --git a/src/commands/manifest/scripts/assemble.test.mts b/src/commands/manifest/scripts/assemble.test.mts index f00630685..0752a70c1 100644 --- a/src/commands/manifest/scripts/assemble.test.mts +++ b/src/commands/manifest/scripts/assemble.test.mts @@ -263,6 +263,49 @@ describe('records → assemble → sidecar', () => { 'g:mod-b:1': [{ file: 'mod/b.xml' }], }) }) + it('attributes Gradle direct dependencies to the script declaring them', () => { + const records = [ + 'meta\tgradle\t9.2.1\t21', + 'buildRoot\t/repo', + 'project\t:\tg\troot\t1\t.', + 'projectBuild\t:\tbuild.gradle.kts', + 'project\t:a\tg\ta\t1\ta', + 'declared\t:a\tx\tfrom-root\tbuild.gradle.kts', + 'project\t:b\tg\tb\t1\tb', + 'projectBuild\t:b\tb/build.gradle.kts', + 'declared\t:b\tx\tfrom-root\tbuild.gradle.kts', + 'declared\t:b\tx\town\tb/build.gradle.kts', + 'root\tr1\t:a\truntimeClasspath\t1', + 'node\tr1\tx:from-root:jar:1\tx\tfrom-root\t1\tjar\t\t1', + 'root\tr2\t:b\truntimeClasspath\t1', + 'node\tr2\tx:from-root:jar:1\tx\tfrom-root\t1\tjar\t\t1', + 'node\tr2\tx:own:jar:1\tx\town\t1\tjar\t\t1', + 'node\tr2\tx:by-plugin:jar:1\tx\tby-plugin\t1\tjar\t\t1', + ].join('\n') + const { facts } = assembleFacts(parseRecords(records)) + + expect( + Object.fromEntries( + facts.components.map(c => [ + c.id, + c.manifestFiles?.map(m => m.file).slice(1), + ]), + ), + ).toEqual({ + 'x:by-plugin:jar:1': ['b/build.gradle.kts'], + 'x:from-root:jar:1': ['build.gradle.kts'], + 'x:own:jar:1': ['b/build.gradle.kts'], + }) + expect( + Object.fromEntries( + facts.projects!.map(p => [p.id, p.manifestFiles?.map(m => m.file)]), + ), + ).toEqual({ + ':': ['build.gradle.kts'], + ':a': ['build.gradle.kts'], + ':b': ['b/build.gradle.kts'], + }) + }) }) describe('parseRecords', () => { diff --git a/src/commands/manifest/scripts/facts.mts b/src/commands/manifest/scripts/facts.mts index 011ee4c43..0cdba3c39 100644 --- a/src/commands/manifest/scripts/facts.mts +++ b/src/commands/manifest/scripts/facts.mts @@ -30,8 +30,9 @@ export type SocketFactsSbomComponent = AnyPURL & { // A module of the scanned build itself (same GAV as a projects[] entry). firstParty?: true | undefined dependencies?: string[] | undefined - // Direct dependencies only: the facts file plus the build files of the subprojects - // pulling it in directly, which need not declare it (e.g. a parent POM does). + // Direct dependencies only: the facts file plus, per subproject pulling it in + // directly, the Gradle script declaring it, else the subproject's build file + // (which need not declare it, e.g. a parent POM does). manifestFiles?: SocketFactsManifestReference[] | undefined } @@ -46,7 +47,8 @@ export type SocketFactsSbomProject = AnyPURL & { id: string subprojectDir: string dependencies: string[] - // The module's own build files, e.g. a POM other than `/pom.xml`. + // The module's own build files, e.g. a POM other than `/pom.xml`; + // for a Gradle project without one, the scripts declaring its dependencies. manifestFiles?: SocketFactsManifestReference[] | undefined } diff --git a/src/commands/manifest/scripts/records.mts b/src/commands/manifest/scripts/records.mts index f757d4bc6..7cf15ff21 100644 --- a/src/commands/manifest/scripts/records.mts +++ b/src/commands/manifest/scripts/records.mts @@ -15,6 +15,7 @@ import type { // projectSrc projectKey path (--with-files only) // projectTgt projectKey path (--with-files only) // projectBuild projectKey path (build-root-relative) +// declared projectKey group name path (script declaring the dependency; build-root-relative) // root rootId projectKey config prod(0|1) // node rootId coordId group name version ext classifier direct(0|1) project // edge rootId parentCoordId childCoordId @@ -68,6 +69,8 @@ export type RawProject = { targets: string[] // The project's own build files, build-root-relative. buildFiles: string[] + // "group:name" -> the scripts that declared that dependency on this project. + declaredIn: Map } export type ParsedRecords = { @@ -145,6 +148,7 @@ export function parseRecords(text: string): ParsedRecords { sources: [], targets: [], buildFiles: [], + declaredIn: new Map(), } result.projects.set(key, p) } @@ -188,6 +192,13 @@ export function parseRecords(text: string): ParsedRecords { project(f[1] ?? '').buildFiles.push(f[2]) } break + case 'declared': + if (f[4]) { + const { declaredIn } = project(f[1] ?? '') + const ga = `${f[2] ?? ''}:${f[3] ?? ''}` + declaredIn.set(ga, [...(declaredIn.get(ga) ?? []), f[4]]) + } + break case 'root': { const r = root(f[1] ?? '') r.projectKey = f[2] ?? '' diff --git a/src/commands/manifest/scripts/socket-facts.init.gradle b/src/commands/manifest/scripts/socket-facts.init.gradle index 1d2e86ed0..4f55023f1 100644 --- a/src/commands/manifest/scripts/socket-facts.init.gradle +++ b/src/commands/manifest/scripts/socket-facts.init.gradle @@ -63,8 +63,67 @@ gradle.ext.socketFactsState = [ projectArtifactExt : Collections.synchronizedMap([:]), // Project path -> "group:name", to tell this build's projects from an included build's. projectGaByPath : Collections.synchronizedMap([:]), + // Project path -> "group:name" -> build-root-relative scripts that declared that dependency. + declaredIn : Collections.synchronizedMap([:]), ] +// The in-build script declaring a dependency is the innermost script frame on the stack when it is +// added. Groovy script frames carry the absolute script path. Kotlin DSL frames carry only the base +// name; the path is read from the script class loader's scope id (`kotlin-dsl::`, Gradle 6.9 +// to 9.x), and is absent before Java 9 (no StackWalker). Unresolvable frames are skipped. +gradle.ext.socketInitScript = initscript.sourceFile?.canonicalFile +gradle.ext.socketDeclaringScript = { File rootDir -> + def rootPath = rootDir.canonicalFile.toPath() + def frames = [] + try { + StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE).forEach { f -> + frames << [f.fileName, f.fileName?.endsWith('.kts') ? f.declaringClass.classLoader?.toString() : null] + } + } catch (Throwable ignore) { + frames = new Throwable().stackTrace.collect { f -> [f.fileName, null] } + } + for (fr in frames) { + def path = fr[0] + if (path == null) continue + if (fr[1] != null) { + def m = (fr[1] =~ ('kotlin-dsl:(.+?[\\\\/]' + java.util.regex.Pattern.quote(path) + '):')) + path = m.find() ? m.group(1) : null + if (path == null) continue + } + def f = new File(path) + if (!f.isAbsolute() || !f.isFile()) continue + def c = f.canonicalFile + if (c == gradle.ext.socketInitScript || !c.toPath().startsWith(rootPath)) continue + return rootPath.relativize(c.toPath()).toString().replace(File.separator, '/') + } + null +} + +allprojects { p -> + p.configurations.all { c -> + c.dependencies.whenObjectAdded { d -> + if (d.group == null || d.name == null) return + def script = gradle.ext.socketDeclaringScript.call(p.rootDir) + if (script == null) return + def byGa + synchronized (gradle.socketFactsState.declaredIn) { + byGa = gradle.socketFactsState.declaredIn.get(p.path) + if (byGa == null) { + byGa = [:] + gradle.socketFactsState.declaredIn.put(p.path, byGa) + } + def ga = "${d.group}:${d.name}".toString() + def set = byGa.get(ga) + if (set == null) { + set = [] as Set + byGa.put(ga, set) + } + set << script + } + } + } +} + // Capture every project's (group:name) before collectors run so they can filter intra-project // deps without an ordering dependency on other subprojects. gradle.projectsEvaluated { g -> @@ -519,6 +578,12 @@ rootProject { rp -> projectsInfo.each { pi -> rec(['project', pi.path, pi.group ?: '', pi.name, pi.version ?: '', pi.dir]) pi.buildFiles.each { f -> rec(['projectBuild', pi.path, f]) } + def byGa + synchronized (state.declaredIn) { byGa = state.declaredIn.get(pi.path)?.collectEntries { k, v -> [k, new ArrayList(v)] } } + byGa?.keySet()?.sort()?.each { ga -> + def parts = ga.split(':', 2) + byGa[ga].sort().each { f -> rec(['declared', pi.path, parts[0], parts[1], f]) } + } if (withFilesProjects) { pi.sources.each { s -> rec(['projectSrc', pi.path, s]) } pi.targets.each { t -> rec(['projectTgt', pi.path, t]) } From 2be91d1ca398e8faaa36e0a5754ed6d90aa5f1c6 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 8 Oct 2026 14:01:03 +0200 Subject: [PATCH 2/3] feat(manifest): mark Gradle direct dependencies with the configured build file A direct dependency no build script is known to declare now falls back to the subproject's configured build file even when it is absent on disk, so every Gradle direct dependency names the subproject pulling it in. The same configured file is the last fallback for projects[].manifestFiles. Co-Authored-By: Claude Opus 5.5 --- src/commands/manifest/scripts/assemble.mts | 18 ++++++++++++------ .../manifest/scripts/assemble.test.mts | 19 +++++++++++++++++++ src/commands/manifest/scripts/facts.mts | 5 +++-- src/commands/manifest/scripts/records.mts | 8 ++++++-- .../manifest/scripts/socket-facts.init.gradle | 8 ++++---- 5 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index 1a2bb023f..a4fc59bbc 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -203,11 +203,14 @@ function buildManifestFilesById( } const coord = root?.nodes.get(id)?.coord // A dependency no build script declared (e.g. one a plugin adds) is - // attributed to the project's own build file. - const files = - (coord && project?.declaredIn.get(`${coord.group}:${coord.name}`)) || - project?.buildFiles || - [] + // attributed to the project's build file, even one absent on disk: its + // presence marks the dependency direct for this subproject. + const declared = + coord && project?.declaredIn.get(`${coord.group}:${coord.name}`) + const files = declared ?? [ + ...(project?.buildFiles ?? []), + ...(project?.missingBuildFiles ?? []), + ] for (const f of files) { set.add(f) } @@ -297,9 +300,12 @@ function buildProjects( } // A project without a build file of its own is defined by the scripts // declaring its dependencies, e.g. the root build script. + const declared = [...new Set([...p.declaredIn.values()].flat())] const files = p.buildFiles.length ? p.buildFiles - : [...new Set([...p.declaredIn.values()].flat())] + : declared.length + ? declared + : p.missingBuildFiles if (files.length) { entry.manifestFiles = [...files].sort().map(file => ({ file })) } diff --git a/src/commands/manifest/scripts/assemble.test.mts b/src/commands/manifest/scripts/assemble.test.mts index 0752a70c1..2d802d959 100644 --- a/src/commands/manifest/scripts/assemble.test.mts +++ b/src/commands/manifest/scripts/assemble.test.mts @@ -306,6 +306,25 @@ describe('records → assemble → sidecar', () => { ':b': ['b/build.gradle.kts'], }) }) + it('falls back to the configured Gradle build file even when absent', () => { + const records = [ + 'meta\tgradle\t9.2.1\t21', + 'buildRoot\t/repo', + 'project\t:a\tg\ta\t1\ta', + 'projectBuild\t:a\ta/build.gradle.kts\tmissing', + 'root\tr1\t:a\truntimeClasspath\t1', + 'node\tr1\tx:undeclared:jar:1\tx\tundeclared\t1\tjar\t\t1', + ].join('\n') + const { facts } = assembleFacts(parseRecords(records)) + + expect(facts.components[0]?.manifestFiles).toEqual([ + { file: '.socket.facts.json' }, + { file: 'a/build.gradle.kts' }, + ]) + expect(facts.projects![0]?.manifestFiles).toEqual([ + { file: 'a/build.gradle.kts' }, + ]) + }) }) describe('parseRecords', () => { diff --git a/src/commands/manifest/scripts/facts.mts b/src/commands/manifest/scripts/facts.mts index 0cdba3c39..54825bf65 100644 --- a/src/commands/manifest/scripts/facts.mts +++ b/src/commands/manifest/scripts/facts.mts @@ -32,7 +32,7 @@ export type SocketFactsSbomComponent = AnyPURL & { dependencies?: string[] | undefined // Direct dependencies only: the facts file plus, per subproject pulling it in // directly, the Gradle script declaring it, else the subproject's build file - // (which need not declare it, e.g. a parent POM does). + // (which need not declare it, e.g. a parent POM does, nor exist on disk). manifestFiles?: SocketFactsManifestReference[] | undefined } @@ -48,7 +48,8 @@ export type SocketFactsSbomProject = AnyPURL & { subprojectDir: string dependencies: string[] // The module's own build files, e.g. a POM other than `/pom.xml`; - // for a Gradle project without one, the scripts declaring its dependencies. + // for a Gradle project without one, the scripts declaring its dependencies, + // else its configured build file, which may not exist on disk. manifestFiles?: SocketFactsManifestReference[] | undefined } diff --git a/src/commands/manifest/scripts/records.mts b/src/commands/manifest/scripts/records.mts index 7cf15ff21..59f3fa84e 100644 --- a/src/commands/manifest/scripts/records.mts +++ b/src/commands/manifest/scripts/records.mts @@ -14,7 +14,7 @@ import type { // project projectKey group name version dir // projectSrc projectKey path (--with-files only) // projectTgt projectKey path (--with-files only) -// projectBuild projectKey path (build-root-relative) +// projectBuild projectKey path [missing] (build-root-relative; `missing`: configured, absent on disk) // declared projectKey group name path (script declaring the dependency; build-root-relative) // root rootId projectKey config prod(0|1) // node rootId coordId group name version ext classifier direct(0|1) project @@ -69,6 +69,8 @@ export type RawProject = { targets: string[] // The project's own build files, build-root-relative. buildFiles: string[] + // Build files the tool configures for the project but absent on disk. + missingBuildFiles: string[] // "group:name" -> the scripts that declared that dependency on this project. declaredIn: Map } @@ -148,6 +150,7 @@ export function parseRecords(text: string): ParsedRecords { sources: [], targets: [], buildFiles: [], + missingBuildFiles: [], declaredIn: new Map(), } result.projects.set(key, p) @@ -189,7 +192,8 @@ export function parseRecords(text: string): ParsedRecords { break case 'projectBuild': if (f[2]) { - project(f[1] ?? '').buildFiles.push(f[2]) + const p = project(f[1] ?? '') + ;(f[3] === 'missing' ? p.missingBuildFiles : p.buildFiles).push(f[2]) } break case 'declared': diff --git a/src/commands/manifest/scripts/socket-facts.init.gradle b/src/commands/manifest/scripts/socket-facts.init.gradle index 4f55023f1..f16495b9e 100644 --- a/src/commands/manifest/scripts/socket-facts.init.gradle +++ b/src/commands/manifest/scripts/socket-facts.init.gradle @@ -176,12 +176,12 @@ gradle.projectsEvaluated { g -> } } } catch (Exception ignore) {} - // `buildFile` is the configured script even when absent on disk (a project configured from the - // root or a convention plugin); only a script that exists is the project's own build file. + // `buildFile` is the configured script, absent on disk for a project configured from the root or + // a convention plugin. Still emitted: a direct dependency's mark must name its subproject. def buildFiles = [] try { def bf = p.buildFile - if (bf != null && bf.isFile()) buildFiles << rel(bf) + if (bf != null) buildFiles << [rel(bf), bf.isFile()] } catch (Exception ignore) {} g.socketFactsState.projectsInfo.add([ path : p.path, @@ -577,7 +577,7 @@ rootProject { rp -> synchronized (state.projectsInfo) { projectsInfo = new ArrayList(state.projectsInfo) } projectsInfo.each { pi -> rec(['project', pi.path, pi.group ?: '', pi.name, pi.version ?: '', pi.dir]) - pi.buildFiles.each { f -> rec(['projectBuild', pi.path, f]) } + pi.buildFiles.each { f -> rec(['projectBuild', pi.path, f[0]] + (f[1] ? [] : ['missing'])) } def byGa synchronized (state.declaredIn) { byGa = state.declaredIn.get(pi.path)?.collectEntries { k, v -> [k, new ArrayList(v)] } } byGa?.keySet()?.sort()?.each { ga -> From 2d0110c95065d24f25754ccd251d6ca5dc8e5066 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 8 Oct 2026 14:17:06 +0200 Subject: [PATCH 3/3] docs(manifest): tighten Gradle attribution comments Co-Authored-By: Claude Opus 5.5 --- src/commands/manifest/scripts/assemble.mts | 5 ++--- src/commands/manifest/scripts/socket-facts.init.gradle | 6 ++---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index a4fc59bbc..a478931ec 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -202,9 +202,8 @@ function buildManifestFilesById( buildFilesByCoord.set(id, set) } const coord = root?.nodes.get(id)?.coord - // A dependency no build script declared (e.g. one a plugin adds) is - // attributed to the project's build file, even one absent on disk: its - // presence marks the dependency direct for this subproject. + // Without a known declaring script, the project's build file, even one + // absent on disk: its presence marks the dependency direct here. const declared = coord && project?.declaredIn.get(`${coord.group}:${coord.name}`) const files = declared ?? [ diff --git a/src/commands/manifest/scripts/socket-facts.init.gradle b/src/commands/manifest/scripts/socket-facts.init.gradle index f16495b9e..90b3437d0 100644 --- a/src/commands/manifest/scripts/socket-facts.init.gradle +++ b/src/commands/manifest/scripts/socket-facts.init.gradle @@ -67,10 +67,8 @@ gradle.ext.socketFactsState = [ declaredIn : Collections.synchronizedMap([:]), ] -// The in-build script declaring a dependency is the innermost script frame on the stack when it is -// added. Groovy script frames carry the absolute script path. Kotlin DSL frames carry only the base -// name; the path is read from the script class loader's scope id (`kotlin-dsl::`, Gradle 6.9 -// to 9.x), and is absent before Java 9 (no StackWalker). Unresolvable frames are skipped. +// Kotlin DSL frames carry only the script's base name; its path is read from the script class +// loader's internal scope id (`kotlin-dsl::`), which needs StackWalker (Java 9+). gradle.ext.socketInitScript = initscript.sourceFile?.canonicalFile gradle.ext.socketDeclaringScript = { File rootDir -> def rootPath = rootDir.canonicalFile.toPath()