Skip to content

Commit bbd2fd2

Browse files
authored
test(manifest): run the facts assembler over smoke-test records (#1713)
* test(manifest): check Maven reachability per project, as the assembler does A prod node whose scope dependencyManagement sets sits in Maven's compile root without a parent there; its edge lives in the test root. The assembler merges a project's roots, so the facts are fine, but the shared reachability assertion rejected the Maven records. Maven's smoke test now checks each project with --per-project, which merges a project's roots the way the assembler does. Gradle and sbt keep the stricter per-root check, as their roots are independent resolutions. Both modes now drop an edge whose ends are not both nodes of its root, as the assembler does, so a dangling edge no longer makes a node look reachable. The Maven fixture covers the managed-scope case. * test(manifest): run the facts assembler over smoke-test records assert-reachability.py was a hand-ported copy of the assembler's own reachability check, and the per-project mode ported more of it. The smoke tests now assemble the records with the real parseRecords and assembleFacts, which throw on a resolved dependency no direct dependency of its project reaches, so the check cannot drift from the assembler. The assembler checks only the roots of known projects, so the runner also rejects a root of any other project. The script, its unit tests and the --per-project flag are gone; their shapes are now assembler tests.
1 parent 86a6e1b commit bbd2fd2

14 files changed

Lines changed: 174 additions & 210 deletions

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,74 @@ describe('records → assemble → sidecar', () => {
373373
'Resolved dependencies of a are not reachable from its direct dependencies: g:dep:jar:1, g:loose:jar:1',
374374
)
375375
})
376+
it.for([
377+
{
378+
name: 'an edge through a node its root does not hold',
379+
records: [
380+
'project\ta\tg\ta\t1\ta',
381+
'root\tr1\ta\tcompile\t1',
382+
'node\tr1\tg:lib:jar:1\tg\tlib\t1\tjar\t\t1',
383+
'node\tr1\tg:dep:jar:1\tg\tdep\t1\tjar\t\t0',
384+
'edge\tr1\tg:lib:jar:1\tg:ghost:jar:1',
385+
'edge\tr1\tg:ghost:jar:1\tg:dep:jar:1',
386+
],
387+
unreached:
388+
'a are not reachable from its direct dependencies: g:dep:jar:1',
389+
},
390+
{
391+
name: "an edge in another project's root",
392+
records: [
393+
'project\ta\tg\ta\t1\ta',
394+
'project\tb\tg\tb\t1\tb',
395+
'root\tr1\ta\tcompile\t1',
396+
'node\tr1\tg:lib:jar:1\tg\tlib\t1\tjar\t\t1',
397+
'node\tr1\tg:dep:jar:1\tg\tdep\t1\tjar\t\t0',
398+
'root\tr2\tb\tcompile\t1',
399+
'node\tr2\tg:lib:jar:1\tg\tlib\t1\tjar\t\t1',
400+
'node\tr2\tg:dep:jar:1\tg\tdep\t1\tjar\t\t0',
401+
'edge\tr2\tg:lib:jar:1\tg:dep:jar:1',
402+
],
403+
unreached:
404+
'a are not reachable from its direct dependencies: g:dep:jar:1',
405+
},
406+
{
407+
// Maven: dependencyManagement makes `managed` compile under a test parent,
408+
// so the compile root holds it without a parent.
409+
name: "a node whose parent edge only the project's other root holds",
410+
records: [
411+
'project\ta\tg\ta\t1\ta',
412+
'root\tr1\ta\tcompile\t1',
413+
'node\tr1\tg:managed:jar:1\tg\tmanaged\t1\tjar\t\t0',
414+
'root\tr2\ta\ttest\t0',
415+
'node\tr2\tg:harness:jar:1\tg\tharness\t1\tjar\t\t1',
416+
'node\tr2\tg:managed:jar:1\tg\tmanaged\t1\tjar\t\t0',
417+
'edge\tr2\tg:harness:jar:1\tg:managed:jar:1',
418+
],
419+
unreached: undefined,
420+
},
421+
{
422+
name: "a build project's artifacts, direct in one root only",
423+
records: [
424+
'project\ta\tg\ta\t1\ta',
425+
'project\tg:lib:1\tg\tlib\t1\tlib',
426+
'root\tr1\ta\tcompile\t1',
427+
'node\tr1\tg:lib:jar:1\tg\tlib\t1\tjar\t\t1\tg:lib:1',
428+
'root\tr2\ta\ttest\t0',
429+
'node\tr2\tg:lib:test-jar:tests:1\tg\tlib\t1\ttest-jar\ttests\t0\tg:lib:1',
430+
],
431+
unreached: undefined,
432+
},
433+
])('checks reachability for $name', ({ records, unreached }) => {
434+
const assemble = () =>
435+
assembleFacts(
436+
parseRecords(['meta\tmaven\t3.9.9\t17', ...records].join('\n')),
437+
)
438+
if (unreached) {
439+
expect(assemble).toThrow(`Resolved dependencies of ${unreached}`)
440+
} else {
441+
expect(assemble).not.toThrow()
442+
}
443+
})
376444
it("gives a dependency on a build project that project's id, and marks only it firstParty", () => {
377445
const records = [
378446
'meta\tgradle\t8.0\t17',

‎src/commands/manifest/scripts/test/README.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ workspace-enumeration sibling (`socket-workspaces.init.gradle`,
88
against a matrix of build-tool versions, asserting they still emit the
99
expected line-protocol records.
1010

11-
Every ecosystem's records are also checked against the shared
12-
`assert-reachability.py` invariant: within each resolution root, every component
13-
must be reachable from one of that root's direct dependencies, or the manifest
14-
consumer reports it as an orphaned component. It's unit-tested over
15-
synthetic records in `test/manifest-reachability-assertions.test.mts`.
11+
The main Gradle, Maven and sbt smoke tests also assemble their records with the
12+
facts assembler (`assemble-records.mts`). It fails on a root outside every
13+
project, and on a resolved dependency no direct dependency of its project
14+
reaches, the manifest consumer's orphaned component. This runs the assembler's
15+
TypeScript directly, so the suites need Node 22+ and the repo's installed
16+
dependencies (`pnpm install`).
1617

1718
## Run locally, on demand
1819

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Assemble a records file the way `socket manifest` does. The assembler throws on a resolved
2+
// dependency no direct dependency of its project reaches, the manifest consumer's "orphaned
3+
// component", but checks only the roots of known projects, so a root of any other project fails here.
4+
import { readFileSync } from 'node:fs'
5+
6+
import { assembleFacts } from '../assemble.mts'
7+
import { parseRecords } from '../records.mts'
8+
9+
const recordsFile = process.argv[2]
10+
if (!recordsFile) {
11+
throw new Error('usage: assemble-records.mts <records.tsv>')
12+
}
13+
const parsed = parseRecords(readFileSync(recordsFile, 'utf8'))
14+
for (const [rootId, root] of parsed.roots) {
15+
if (!parsed.projects.has(root.projectKey)) {
16+
throw new Error(
17+
`root ${rootId} belongs to ${root.projectKey}, which has no project record`,
18+
)
19+
}
20+
}
21+
assembleFacts(parsed, { fileExists: () => true })
22+
console.log(`PASS (assembled): ${recordsFile}`)

‎src/commands/manifest/scripts/test/assert-reachability.py‎

Lines changed: 0 additions & 47 deletions
This file was deleted.

‎src/commands/manifest/scripts/test/compat-cache.sh‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,9 @@
66
# `mktemp -d` for a cold run.
77
SOCKET_COMPAT_CACHE="${SOCKET_COMPAT_CACHE:-${TMPDIR:-/tmp}/socket-manifest-compat}"
88
mkdir -p "$SOCKET_COMPAT_CACHE"
9+
10+
# Assemble a records file the way `socket manifest` does, failing on an orphaned component. Runs the
11+
# assembler's TypeScript directly, so it needs Node 22+ and the repo's installed dependencies.
12+
assemble_records() {
13+
node --experimental-strip-types --no-warnings "$(dirname "${BASH_SOURCE[0]}")/assemble-records.mts" "$1"
14+
}

‎src/commands/manifest/scripts/test/gradle-compat/smoke-test-failures.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,5 +123,5 @@ if errors:
123123
sys.exit(1)
124124
print(f"PASS (failures, withFiles={sys.argv[2]}): every failure reported, no partial or fabricated component")
125125
PY
126-
python3 "$HERE/../assert-reachability.py" "$RECORDS"
126+
assemble_records "$RECORDS"
127127
done

‎src/commands/manifest/scripts/test/gradle-compat/smoke-test.sh‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
# - foo (prod) appears in a prod root, bar (test) only in non-prod roots -> the assembler's dev flag;
99
# - every resolved dependency gets an on-disk jar `file` record under -Psocket.withFiles (incl. the
1010
# production dep reached via the legacy `compile` config on old Gradle);
11-
# - every component is reachable from a direct dependency of its own root (no orphaned components);
11+
# - its records assemble into facts with no orphaned components;
1212
# - -Psocket.populateFilesFor scopes materialization to a single GAV;
1313
# - a jar that cannot be fetched is reported as a failure, and the other jars still materialize.
1414
#
@@ -72,7 +72,7 @@ print(f"PASS ({'scoped' if scoped else 'full'}): nodes {sorted(nodes)}; foo prod
7272
+ ("; bar skipped" if scoped else "; bar jar"))
7373
PY
7474

75-
python3 "$HERE/../assert-reachability.py" "$RECORDS"
75+
assemble_records "$RECORDS"
7676

7777
# Second run: scope --with-files to a single GAV and assert ONLY that artifact is materialized.
7878
SCOPE="$HERE/.populate-for.txt"
@@ -98,7 +98,7 @@ if errors:
9898
print("PASS (populateFilesFor scoping): foo materialized, bar skipped")
9999
PY
100100

101-
python3 "$HERE/../assert-reachability.py" "$RECORDS"
101+
assemble_records "$RECORDS"
102102

103103
# Third run: bar's jar is gone from the repository, so it must be reported rather than emitted as
104104
# resolved with no file.

‎src/commands/manifest/scripts/test/maven-compat/assert-records.py‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@
2323

2424
errors = []
2525

26-
# NOTE: the generic "every component is reachable from a direct dependency of its root" invariant
27-
# lives in ../assert-reachability.py, which smoke-test.sh runs alongside this file. What remains here
28-
# is Maven-specific: which coordinate lands in which root, and jar materialization.
26+
# NOTE: smoke-test.sh also assembles the records (../assemble-records.mts), which fails on an
27+
# orphaned component. What remains here is Maven-specific: which coordinate lands in which root,
28+
# and jar materialization.
2929

3030
def coord(prefix, fields=None):
3131
"""The one emitted coordId starting with prefix, or None. Keeps assertions version-agnostic.
@@ -98,6 +98,14 @@ def edge_pair(parent, child):
9898
if pair and not any(root_projects.get(rid) == 'demo:mid:1.0' for rid in edge_roots(*pair)):
9999
errors.append("edge demo.ext:med-io -> demo.ext:med-util missing from mid")
100100

101+
# A managed scope applies to a transitive dependency as it is: demo.ext:managed is compile under
102+
# its test parent, so it is on the runtime classpath.
103+
pair = edge_pair('demo.ext:mgd-test:jar:', 'demo.ext:managed:jar:')
104+
if pair:
105+
if in_prod(pair[0]): errors.append("test dep demo.ext:mgd-test wrongly in a prod root")
106+
elif not in_prod(pair[1]): errors.append("managed-scope demo.ext:managed not in a prod root")
107+
elif not edge_roots(*pair): errors.append("edge demo.ext:mgd-test -> demo.ext:managed missing")
108+
101109
# An edge across the prod/dev split: container-lib (compile) onto container-api, which app
102110
# declares provided. The edge is kept and container-api stays dev.
103111
pair = edge_pair('demo.ext:container-lib:jar:', 'demo.ext:container-api:jar:')

‎src/commands/manifest/scripts/test/maven-compat/project/app/pom.xml‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@
77
<version>1.0</version>
88
</parent>
99
<artifactId>app</artifactId>
10+
<!-- A managed scope applies to a transitive dependency as it is, so demo.ext:managed is compile
11+
under its test parent, and in the compile root without one. -->
12+
<dependencyManagement>
13+
<dependencies>
14+
<dependency>
15+
<groupId>demo.ext</groupId>
16+
<artifactId>managed</artifactId>
17+
<version>1.0</version>
18+
<scope>compile</scope>
19+
</dependency>
20+
</dependencies>
21+
</dependencyManagement>
1022
<dependencies>
1123
<!-- internal reactor modules -> inter-module edges, bare-id components. lib's jar comes in
1224
through mid, which excludes demo.ext:libdep, so only lib's test-jar further down brings
@@ -74,5 +86,11 @@
7486
<type>test-jar</type>
7587
<scope>test</scope>
7688
</dependency>
89+
<dependency>
90+
<groupId>demo.ext</groupId>
91+
<artifactId>mgd-test</artifactId>
92+
<version>1.0</version>
93+
<scope>test</scope>
94+
</dependency>
7795
</dependencies>
7896
</project>

‎src/commands/manifest/scripts/test/maven-compat/smoke-test.sh‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,17 @@
66
# its transitive demo.ext:harness-core (only in a non-prod root -> the assembler's dev flag);
77
# - emits the internal reactor module demo:lib by its bare groupId:artifactId:version id (so the
88
# inter-module edge lines up with its `project` record);
9-
# - marks a node prod by the scope Maven kept it with, and keeps it reachable in the prod root;
9+
# - marks a node prod by the scope Maven kept it with, also one whose scope dependencyManagement
10+
# sets under a test parent;
1011
# - records every resolved edge, pointing at the node Maven kept: onto a direct dependency, onto a
1112
# mediated version, and from a prod node onto a provided one;
12-
# - every component is reachable from a direct dependency of its own root (no orphaned components);
13+
# - its records assemble into facts with no orphaned components;
1314
# - keeps a dependency only a sibling module's test-jar brings (mid excludes demo.ext:libdep from
1415
# lib, and app reaches lib's jar through mid before lib's test-jar);
1516
# - materializes resolved external jars under -Dsocket.withFiles;
1617
# - scopes that materialization to -Dsocket.populateFilesFor (a newline-delimited GAV file).
1718
#
18-
# The three external artifacts are stubs generated into a file-based repo at test time (see
19+
# The external artifacts are stubs generated into a file-based repo at test time (see
1920
# ../make-stub-repo.sh, shared with the Gradle fixture): the fixture only needs their graph shape, it
2021
# never compiles against or runs them, so nothing here can age into a CVE alert or a version bump.
2122
#
@@ -48,7 +49,9 @@ bash "$HERE/../make-stub-repo.sh" "$STUBS" \
4849
'demo.ext:med-util:2.0' \
4950
'demo.ext:med-io:1.0+demo.ext:med-util:2.0' \
5051
'demo.ext:container-api:1.0' \
51-
'demo.ext:container-lib:1.0+demo.ext:container-api:1.0'
52+
'demo.ext:container-lib:1.0+demo.ext:container-api:1.0' \
53+
'demo.ext:mgd-test:1.0+demo.ext:managed:1.0' \
54+
'demo.ext:managed:1.0'
5255
# Maven's own plugin closure stays cached between runs; the stubs never do, so every run has to
5356
# resolve them from the repo just generated.
5457
rm -rf "$M2/demo" "$RECORDS" "$PROJECT"/*/target "$PROJECT"/target
@@ -72,7 +75,7 @@ if not any(r[0] == 'node' and r[1] in app and r[2].startswith('demo.ext:libdep:'
7275
print("FAIL: demo.ext:libdep missing from app"); sys.exit(1)
7376
print("PASS: demo.ext:libdep in app through lib's test-jar")
7477
PY
75-
python3 "$HERE/../assert-reachability.py" "$RECORDS"
78+
assemble_records "$RECORDS"
7679

7780
# Second run: scope --with-files to a single GAV and assert ONLY that artifact is materialized.
7881
# The GAV comes from the records the first run just emitted, so it always matches the fixture pom.
@@ -121,4 +124,4 @@ if errors:
121124
print("PASS (populateFilesFor scoping): demo.ext:tool materialized, demo.ext:harness skipped")
122125
PY
123126

124-
python3 "$HERE/../assert-reachability.py" "$RECORDS"
127+
assemble_records "$RECORDS"

0 commit comments

Comments
 (0)