Skip to content

Commit 912d205

Browse files
authored
feat(manifest): give each output set its own sidecar classpath
A project now gets one runtime classpath per output set (main, test, testFixtures, KMP targets, Android variants), each one tool resolution in the order the JVM searches it, instead of one classpath merged across configurations. Test-only versions stay on the test classpath rather than being dropped. - Project sources and targets are split by output set. - A classpath entry is an external artifact or a build project's output set, so a dependency on a sibling's test-jar, testFixtures or test->test brings in that code and nothing else. - Each set's classpath starts with the project's own code and the output sets the tool wires into it (test holds main). - Compile-only entries (provided, compileOnly, system) follow the runtime ones, at their declared version. Producers state the mapping from their own model: Maven's scopes, Gradle source sets, Kotlin compilations and AGP variants, and sbt's classpath configurations.
1 parent 560519d commit 912d205

12 files changed

Lines changed: 807 additions & 425 deletions

File tree

‎src/commands/manifest/run-manifest-facts.test.mts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ function okResult(buildRoot: string): ManifestRunResult {
3434
},
3535
artifactPaths: {
3636
pathsById: new Map(),
37-
classpathByProject: new Map(),
37+
outputsByProject: new Map(),
38+
classpathsByProject: new Map(),
3839
directDependenciesByProject: new Map(),
3940
},
4041
stderr: '',

‎src/commands/manifest/scripts/assemble.mts‎

Lines changed: 131 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
11
import { existsSync } from 'node:fs'
22

3-
import {
4-
type ResolvedArtifactPaths,
5-
type SocketFactsSbom,
6-
type SocketFactsSbomComponent,
7-
type SocketFactsSbomDependency,
8-
type SocketFactsSbomMetadata,
9-
type SocketFactsSbomProject,
10-
} from './facts.mts'
113
import { moduleKey } from './resolution-report.mts'
124

5+
import type {
6+
ClasspathEntry,
7+
OutputPaths,
8+
ResolvedArtifactPaths,
9+
SocketFactsSbom,
10+
SocketFactsSbomComponent,
11+
SocketFactsSbomDependency,
12+
SocketFactsSbomMetadata,
13+
SocketFactsSbomProject,
14+
} from './facts.mts'
15+
1316
import type {
1417
ParsedRecords,
1518
RawCoord,
1619
RawProject,
1720
RawRoot,
21+
StatedClasspathEntry,
1822
} from './records.mts'
1923
import type { ResolutionReport } from './resolution-report.mts'
2024

@@ -49,8 +53,9 @@ type SubprojectGraph = {
4953

5054
type PerRoot = {
5155
projectKey: string
52-
config: string
5356
prod: boolean
57+
outputSet: string
58+
role: RawRoot['role']
5459
// In the root's classpath order.
5560
nodes: Map<string, RootNode>
5661
}
@@ -60,6 +65,7 @@ type RootNode = {
6065
children: string[]
6166
direct: boolean
6267
project: string
68+
outputSets: string[]
6369
targets: string[]
6470
unfetched: boolean
6571
}
@@ -127,11 +133,17 @@ function buildPerRoot(parsed: ParsedRecords): Map<string, PerRoot> {
127133
children: [],
128134
direct: false,
129135
project: n.project,
136+
outputSets: [],
130137
targets: [],
131138
unfetched: false,
132139
}
133140
nodes.set(id, node)
134141
}
142+
for (const set of n.outputSets) {
143+
if (!node.outputSets.includes(set)) {
144+
node.outputSets.push(set)
145+
}
146+
}
135147
node.direct ||= n.direct
136148
node.unfetched ||= n.unfetched
137149
node.targets.push(...n.targets)
@@ -149,16 +161,17 @@ function buildPerRoot(parsed: ParsedRecords): Map<string, PerRoot> {
149161
}
150162
out.set(rootId, {
151163
projectKey: r.projectKey,
152-
config: r.config,
153164
prod: r.prod,
165+
outputSet: r.outputSet,
166+
role: r.role,
154167
nodes,
155168
})
156169
}
157170
return out
158171
}
159172

160173
// Components are merged by id across every resolution root; which ids belong
161-
// to which subproject is kept separately (classpathByProject) for
174+
// to which subproject is kept separately (classpathsByProject) for
162175
// reachability.
163176
function mergeById(perRoot: Map<string, PerRoot>): Map<string, MergedNode> {
164177
const finalNodes = new Map<string, MergedNode>()
@@ -408,50 +421,51 @@ function treeRoots(
408421
return roots
409422
}
410423

411-
// A test configuration: Gradle's `test…`/`…Test…` (testRuntimeClasspath,
412-
// integrationTestCompileClasspath, debugUnitTestRuntimeClasspath), sbt's
413-
// test and it configurations.
414-
const TEST_CONFIG = /^test|Test|^it(?:-internal)?$/
415-
const SBT_TEST_CONFIG = /^(?:test|it)(?:-internal)?$/
416-
417-
// Lower ranks go first; an unrecognised configuration counts as tooling.
418-
function classpathRank(config: string): number {
419-
const n = config.toLowerCase()
420-
// Gradle's legacy `runtime`/`compile` (before Gradle 7) are a subset of the
421-
// matching `…Classpath` configuration, so they rank just behind it.
422-
const legacy = n.endsWith('runtime') || n.endsWith('compile') ? 0.5 : 0
423-
const runtime = n.endsWith('runtimeclasspath') || n.endsWith('runtime')
424-
const compile = n.endsWith('compileclasspath') || n.endsWith('compile')
425-
if (TEST_CONFIG.test(config)) {
426-
if (runtime || SBT_TEST_CONFIG.test(n)) {
427-
return 4 + legacy
428-
}
429-
return compile ? 5 + legacy : 6
430-
}
431-
if (n === 'runtimeclasspath' || n === 'runtime' || n === 'runtime-internal') {
432-
return legacy
433-
}
434-
if (n === 'compileclasspath' || n === 'compile' || n === 'compile-internal') {
435-
return 1 + legacy
436-
}
437-
if (runtime) {
438-
return 2 + legacy
439-
}
440-
if (compile || n === 'provided' || n === 'optional') {
441-
return 3 + legacy
424+
// A dependency on a build project names the output sets it brings in; without
425+
// one it is a plain dependency, which brings the project's main code.
426+
function entriesOf(
427+
id: string,
428+
project: string,
429+
outputSets: string[],
430+
): ClasspathEntry[] {
431+
if (!project) {
432+
return [{ id }]
442433
}
443-
return 6
434+
return (outputSets.length ? outputSets : ['main']).map(outputs => ({
435+
project: id,
436+
outputs,
437+
}))
444438
}
445439

446-
// The JVM loads a class from the first classpath entry that has it, so the
447-
// first root by rank to resolve a module fixes its version.
448-
function deriveClasspath(roots: PerRoot[]): string[] {
449-
const ranked = [...roots].sort(
450-
(a, b) => classpathRank(a.config) - classpathRank(b.config),
440+
// The project's own code goes first, the way the build tools put it.
441+
function ownEntries(project: RawProject, outputSet: string): ClasspathEntry[] {
442+
return [outputSet, ...(project.outputSets.get(outputSet) ?? [])].map(
443+
outputs => ({ project: project.projectKey, outputs }),
451444
)
445+
}
446+
447+
function uniqueEntries(entries: ClasspathEntry[]): ClasspathEntry[] {
448+
const seen = new Set<string>()
449+
return entries.filter(e => {
450+
const key = 'id' in e ? e.id : `${e.project}\u0000${e.outputs}`
451+
if (seen.has(key)) {
452+
return false
453+
}
454+
seen.add(key)
455+
return true
456+
})
457+
}
458+
459+
// The runtime classpath, then the entries only the compile classpath has
460+
// (provided, compileOnly), in the version the runtime one resolved, if any.
461+
function derivedClasspath(roots: PerRoot[]): ClasspathEntry[] {
462+
const ordered = [
463+
...roots.filter(r => r.role === 'runtime'),
464+
...roots.filter(r => r.role === 'compile'),
465+
]
452466
const versionOf = new Map<string, string>()
453-
const out = new Set<string>()
454-
for (const { nodes } of ranked) {
467+
const out: ClasspathEntry[] = []
468+
for (const { nodes } of ordered) {
455469
for (const { 0: id, 1: node } of nodes) {
456470
const module = node.project
457471
? `project ${node.project}`
@@ -462,42 +476,33 @@ function deriveClasspath(roots: PerRoot[]): string[] {
462476
versionOf.set(module, version)
463477
}
464478
if (version === node.coord.version) {
465-
out.add(id)
479+
out.push(...entriesOf(id, node.project, node.outputSets))
466480
}
467481
}
468482
}
469-
return [...out]
483+
return out
470484
}
471485

472-
// A project's classpath as its build tool states it, by component id.
473486
function statedClasspath(
474487
projectKey: string,
475-
stated: string[],
476-
roots: RawRoot[],
477-
): string[] {
478-
const ids = new Map<string, string>()
479-
for (const r of roots) {
480-
for (const { 0: coordId, 1: n } of r.nodes) {
481-
ids.set(coordId, componentId(coordId, n.project))
482-
}
483-
}
484-
const cp = new Set<string>()
485-
for (const coordId of stated) {
486-
const id = ids.get(coordId)
487-
if (id === undefined) {
488+
stated: StatedClasspathEntry[],
489+
projectOf: Map<string, string>,
490+
): ClasspathEntry[] {
491+
return stated.flatMap(({ coordId, outputSets }) => {
492+
const project = projectOf.get(coordId)
493+
if (project === undefined) {
488494
throw new Error(
489495
`The classpath of ${projectKey} names ${coordId}, which none of its resolution roots resolved`,
490496
)
491497
}
492-
cp.add(id)
493-
}
494-
return [...cp]
498+
return entriesOf(componentId(coordId, project), project, outputSets)
499+
})
495500
}
496501

497502
function buildClasspaths(
498503
parsed: ParsedRecords,
499504
perRoot: Map<string, PerRoot>,
500-
): Map<string, string[]> {
505+
): Map<string, Map<string, ClasspathEntry[]>> {
501506
const rootIdsByProject = new Map<string, string[]>()
502507
for (const { 0: rootId, 1: r } of parsed.roots) {
503508
let list = rootIdsByProject.get(r.projectKey)
@@ -507,20 +512,50 @@ function buildClasspaths(
507512
}
508513
list.push(rootId)
509514
}
510-
const out = new Map<string, string[]>()
511-
for (const projectKey of parsed.projects.keys()) {
515+
const out = new Map<string, Map<string, ClasspathEntry[]>>()
516+
for (const { 0: projectKey, 1: project } of parsed.projects) {
512517
const rootIds = rootIdsByProject.get(projectKey) ?? []
513-
const stated = parsed.classpaths.get(projectKey)
514-
out.set(
515-
projectKey,
516-
stated
517-
? statedClasspath(
518-
projectKey,
519-
stated,
520-
rootIds.flatMap(id => parsed.roots.get(id) ?? []),
521-
)
522-
: deriveClasspath(rootIds.flatMap(id => perRoot.get(id) ?? [])),
518+
// Every output set has a classpath, if only its own code.
519+
const classpaths = new Map<string, ClasspathEntry[]>(
520+
[...project.outputSets.keys()].map(name => [name, []]),
523521
)
522+
const stated = parsed.classpaths.get(projectKey)
523+
if (stated) {
524+
const projectOf = new Map<string, string>()
525+
for (const id of rootIds) {
526+
for (const { 0: coordId, 1: n } of parsed.roots.get(id)?.nodes ?? []) {
527+
projectOf.set(coordId, n.project)
528+
}
529+
}
530+
for (const { 0: name, 1: entries } of stated) {
531+
classpaths.set(name, statedClasspath(projectKey, entries, projectOf))
532+
}
533+
} else {
534+
const bySet = new Map<string, PerRoot[]>()
535+
for (const id of rootIds) {
536+
const r = perRoot.get(id)
537+
if (r?.outputSet && r.role) {
538+
let roots = bySet.get(r.outputSet)
539+
if (!roots) {
540+
roots = []
541+
bySet.set(r.outputSet, roots)
542+
}
543+
roots.push(r)
544+
}
545+
}
546+
for (const { 0: name, 1: roots } of bySet) {
547+
if (roots.some(r => r.role === 'runtime')) {
548+
classpaths.set(name, derivedClasspath(roots))
549+
}
550+
}
551+
}
552+
for (const { 0: name, 1: entries } of classpaths) {
553+
classpaths.set(
554+
name,
555+
uniqueEntries([...ownEntries(project, name), ...entries]),
556+
)
557+
}
558+
out.set(projectKey, classpaths)
524559
}
525560
return out
526561
}
@@ -530,7 +565,7 @@ function buildArtifactPaths(
530565
projects: RawProject[],
531566
excludedProjects: RawProject[],
532567
graphs: Map<string, SubprojectGraph>,
533-
classpaths: Map<string, string[]>,
568+
classpaths: Map<string, Map<string, ClasspathEntry[]>>,
534569
fileExists: (path: string) => boolean,
535570
): ResolvedArtifactPaths {
536571
const pathsById: ResolvedArtifactPaths['pathsById'] = new Map()
@@ -554,25 +589,28 @@ function buildArtifactPaths(
554589
pathsById.set(
555590
id,
556591
fn.resolutions.length && !complete.length
557-
? { sources: [] }
558-
: {
559-
sources: [],
560-
targets: [...new Set(complete.flatMap(r => r.targets))].sort(),
561-
},
592+
? {}
593+
: { targets: [...new Set(complete.flatMap(r => r.targets))].sort() },
562594
)
563595
}
564596
}
565597
// A project's own component shares its id, so this also covers dependency
566598
// edges onto a sibling project.
599+
const outputsByProject = new Map<string, Map<string, OutputPaths>>()
567600
for (const p of [...projects, ...excludedProjects]) {
568-
pathsById.set(p.projectKey, {
569-
sources: [...new Set(p.sources)].filter(fileExists).sort(),
570-
targets: [...new Set(p.targets)].filter(fileExists).sort(),
571-
})
601+
const sets = new Map<string, OutputPaths>()
602+
for (const { 0: name, 1: o } of p.outputs) {
603+
sets.set(name, {
604+
sources: [...new Set(o.sources)].filter(fileExists).sort(),
605+
targets: [...new Set(o.targets)].filter(fileExists).sort(),
606+
})
607+
}
608+
outputsByProject.set(p.projectKey, sets)
572609
}
573610
return {
574611
pathsById,
575-
classpathByProject: classpaths,
612+
outputsByProject,
613+
classpathsByProject: classpaths,
576614
directDependenciesByProject: new Map(
577615
projects.map(p => {
578616
const g = graphs.get(p.projectKey)

0 commit comments

Comments
 (0)