Skip to content
Merged
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
27 changes: 22 additions & 5 deletions src/commands/manifest/scripts/assemble.mts
Original file line number Diff line number Diff line change
Expand Up @@ -193,15 +193,24 @@ function buildManifestFilesById(
): Map<string, SocketFactsManifestReference[]> {
const buildFilesByCoord = new Map<string, Set<string>>()
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
// 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 ?? [
...(project?.buildFiles ?? []),
...(project?.missingBuildFiles ?? []),
]
for (const f of files) {
set.add(f)
}
}
Expand Down Expand Up @@ -288,8 +297,16 @@ 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 declared = [...new Set([...p.declaredIn.values()].flat())]
const files = p.buildFiles.length
? p.buildFiles
: declared.length
? declared
: p.missingBuildFiles
if (files.length) {
entry.manifestFiles = [...files].sort().map(file => ({ file }))
}
return entry
})
Expand Down
62 changes: 62 additions & 0 deletions src/commands/manifest/scripts/assemble.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,68 @@ 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'],
})
})
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', () => {
Expand Down
9 changes: 6 additions & 3 deletions src/commands/manifest/scripts/facts.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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, nor exist on disk).
manifestFiles?: SocketFactsManifestReference[] | undefined
}

Expand All @@ -46,7 +47,9 @@ export type SocketFactsSbomProject = AnyPURL & {
id: string
subprojectDir: string
dependencies: string[]
// The module's own build files, e.g. a POM other than `<subprojectDir>/pom.xml`.
// The module's own build files, e.g. a POM other than `<subprojectDir>/pom.xml`;
// 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
}

Expand Down
19 changes: 17 additions & 2 deletions src/commands/manifest/scripts/records.mts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ 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
// edge rootId parentCoordId childCoordId
Expand Down Expand Up @@ -68,6 +69,10 @@ 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<string, string[]>
}

export type ParsedRecords = {
Expand Down Expand Up @@ -145,6 +150,8 @@ export function parseRecords(text: string): ParsedRecords {
sources: [],
targets: [],
buildFiles: [],
missingBuildFiles: [],
declaredIn: new Map(),
}
result.projects.set(key, p)
}
Expand Down Expand Up @@ -185,7 +192,15 @@ 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':
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': {
Expand Down
71 changes: 67 additions & 4 deletions src/commands/manifest/scripts/socket-facts.init.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,65 @@ 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([:]),
]

// 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:<path>:`), which needs StackWalker (Java 9+).
gradle.ext.socketInitScript = initscript.sourceFile?.canonicalFile
Comment thread
jfblaa marked this conversation as resolved.
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 ->
Expand Down Expand Up @@ -117,12 +174,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,
Expand Down Expand Up @@ -518,7 +575,13 @@ 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 ->
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]) }
Expand Down