diff --git a/src/commands/manifest/scripts/assemble.test.mts b/src/commands/manifest/scripts/assemble.test.mts index 8ae6e7fabd..62c6ad3c8d 100644 --- a/src/commands/manifest/scripts/assemble.test.mts +++ b/src/commands/manifest/scripts/assemble.test.mts @@ -373,6 +373,74 @@ describe('records → assemble → sidecar', () => { 'Resolved dependencies of a are not reachable from its direct dependencies: g:dep:jar:1, g:loose:jar:1', ) }) + it.for([ + { + name: 'an edge through a node its root does not hold', + records: [ + 'project\ta\tg\ta\t1\ta', + 'root\tr1\ta\tcompile\t1', + 'node\tr1\tg:lib:jar:1\tg\tlib\t1\tjar\t\t1', + 'node\tr1\tg:dep:jar:1\tg\tdep\t1\tjar\t\t0', + 'edge\tr1\tg:lib:jar:1\tg:ghost:jar:1', + 'edge\tr1\tg:ghost:jar:1\tg:dep:jar:1', + ], + unreached: + 'a are not reachable from its direct dependencies: g:dep:jar:1', + }, + { + name: "an edge in another project's root", + records: [ + 'project\ta\tg\ta\t1\ta', + 'project\tb\tg\tb\t1\tb', + 'root\tr1\ta\tcompile\t1', + 'node\tr1\tg:lib:jar:1\tg\tlib\t1\tjar\t\t1', + 'node\tr1\tg:dep:jar:1\tg\tdep\t1\tjar\t\t0', + 'root\tr2\tb\tcompile\t1', + 'node\tr2\tg:lib:jar:1\tg\tlib\t1\tjar\t\t1', + 'node\tr2\tg:dep:jar:1\tg\tdep\t1\tjar\t\t0', + 'edge\tr2\tg:lib:jar:1\tg:dep:jar:1', + ], + unreached: + 'a are not reachable from its direct dependencies: g:dep:jar:1', + }, + { + // Maven: dependencyManagement makes `managed` compile under a test parent, + // so the compile root holds it without a parent. + name: "a node whose parent edge only the project's other root holds", + records: [ + 'project\ta\tg\ta\t1\ta', + 'root\tr1\ta\tcompile\t1', + 'node\tr1\tg:managed:jar:1\tg\tmanaged\t1\tjar\t\t0', + 'root\tr2\ta\ttest\t0', + 'node\tr2\tg:harness:jar:1\tg\tharness\t1\tjar\t\t1', + 'node\tr2\tg:managed:jar:1\tg\tmanaged\t1\tjar\t\t0', + 'edge\tr2\tg:harness:jar:1\tg:managed:jar:1', + ], + unreached: undefined, + }, + { + name: "a build project's artifacts, direct in one root only", + records: [ + 'project\ta\tg\ta\t1\ta', + 'project\tg:lib:1\tg\tlib\t1\tlib', + 'root\tr1\ta\tcompile\t1', + 'node\tr1\tg:lib:jar:1\tg\tlib\t1\tjar\t\t1\tg:lib:1', + 'root\tr2\ta\ttest\t0', + 'node\tr2\tg:lib:test-jar:tests:1\tg\tlib\t1\ttest-jar\ttests\t0\tg:lib:1', + ], + unreached: undefined, + }, + ])('checks reachability for $name', ({ records, unreached }) => { + const assemble = () => + assembleFacts( + parseRecords(['meta\tmaven\t3.9.9\t17', ...records].join('\n')), + ) + if (unreached) { + expect(assemble).toThrow(`Resolved dependencies of ${unreached}`) + } else { + expect(assemble).not.toThrow() + } + }) it("gives a dependency on a build project that project's id, and marks only it firstParty", () => { const records = [ 'meta\tgradle\t8.0\t17', diff --git a/src/commands/manifest/scripts/test/README.md b/src/commands/manifest/scripts/test/README.md index 4a07aafe2a..e11a36663a 100644 --- a/src/commands/manifest/scripts/test/README.md +++ b/src/commands/manifest/scripts/test/README.md @@ -8,11 +8,12 @@ workspace-enumeration sibling (`socket-workspaces.init.gradle`, against a matrix of build-tool versions, asserting they still emit the expected line-protocol records. -Every ecosystem's records are also checked against the shared -`assert-reachability.py` invariant: within each resolution root, every component -must be reachable from one of that root's direct dependencies, or the manifest -consumer reports it as an orphaned component. It's unit-tested over -synthetic records in `test/manifest-reachability-assertions.test.mts`. +The main Gradle, Maven and sbt smoke tests also assemble their records with the +facts assembler (`assemble-records.mts`). It fails on a root outside every +project, and on a resolved dependency no direct dependency of its project +reaches, the manifest consumer's orphaned component. This runs the assembler's +TypeScript directly, so the suites need Node 22+ and the repo's installed +dependencies (`pnpm install`). ## Run locally, on demand diff --git a/src/commands/manifest/scripts/test/assemble-records.mts b/src/commands/manifest/scripts/test/assemble-records.mts new file mode 100644 index 0000000000..531391fa1b --- /dev/null +++ b/src/commands/manifest/scripts/test/assemble-records.mts @@ -0,0 +1,22 @@ +// Assemble a records file the way `socket manifest` does. The assembler throws on a resolved +// dependency no direct dependency of its project reaches, the manifest consumer's "orphaned +// component", but checks only the roots of known projects, so a root of any other project fails here. +import { readFileSync } from 'node:fs' + +import { assembleFacts } from '../assemble.mts' +import { parseRecords } from '../records.mts' + +const recordsFile = process.argv[2] +if (!recordsFile) { + throw new Error('usage: assemble-records.mts ') +} +const parsed = parseRecords(readFileSync(recordsFile, 'utf8')) +for (const [rootId, root] of parsed.roots) { + if (!parsed.projects.has(root.projectKey)) { + throw new Error( + `root ${rootId} belongs to ${root.projectKey}, which has no project record`, + ) + } +} +assembleFacts(parsed, { fileExists: () => true }) +console.log(`PASS (assembled): ${recordsFile}`) diff --git a/src/commands/manifest/scripts/test/assert-reachability.py b/src/commands/manifest/scripts/test/assert-reachability.py deleted file mode 100755 index a5972e4a91..0000000000 --- a/src/commands/manifest/scripts/test/assert-reachability.py +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env python3 -# Assert the manifest REACHABILITY invariant over a records file emitted by any of the JVM -# build-tool scripts: within each resolution root, every component must be reachable from one of -# that root's direct dependencies by following `edge` rows. -# -# The manifest consumer reports a component that is neither direct nor referenced as an "orphaned -# component not reachable from any direct dependency". Porting a node to a root whose only parent -# edge lives in another root is exactly how that happens, so every ecosystem's smoke -# test runs this. Kept in its own file, rather than a heredoc inside each smoke-test.sh, so the unit -# suite can run it over synthetic records without a build tool or a JDK. -# -# Usage: assert-reachability.py -import sys - -rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] - -nodes_by_root, direct_by_root, edges_by_root = {}, {}, {} -for r in rows: - if r[0] == 'node': - nodes_by_root.setdefault(r[1], set()).add(r[2]) - if r[8] == '1': - direct_by_root.setdefault(r[1], set()).add(r[2]) - elif r[0] == 'edge': - edges_by_root.setdefault(r[1], set()).add((r[2], r[3])) - -errors = [] -for root_id, coords in nodes_by_root.items(): - adjacency = {} - for parent, child in edges_by_root.get(root_id, ()): - adjacency.setdefault(parent, set()).add(child) - seen = set(direct_by_root.get(root_id, ())) - stack = list(seen) - while stack: - for child in adjacency.get(stack.pop(), ()): - if child not in seen: - seen.add(child) - stack.append(child) - unreachable = sorted(coords - seen) - if unreachable: - errors.append(f"root {root_id}: unreachable from any direct dependency: {unreachable}") - -if errors: - print("FAIL:") - for e in errors: - print(" -", e) - sys.exit(1) -print(f"PASS (reachability): {len(nodes_by_root)} root(s), all components reachable from a direct dep") diff --git a/src/commands/manifest/scripts/test/compat-cache.sh b/src/commands/manifest/scripts/test/compat-cache.sh index a296ef44ec..65c68127ef 100755 --- a/src/commands/manifest/scripts/test/compat-cache.sh +++ b/src/commands/manifest/scripts/test/compat-cache.sh @@ -6,3 +6,9 @@ # `mktemp -d` for a cold run. SOCKET_COMPAT_CACHE="${SOCKET_COMPAT_CACHE:-${TMPDIR:-/tmp}/socket-manifest-compat}" mkdir -p "$SOCKET_COMPAT_CACHE" + +# Assemble a records file the way `socket manifest` does, failing on an orphaned component. Runs the +# assembler's TypeScript directly, so it needs Node 22+ and the repo's installed dependencies. +assemble_records() { + node --experimental-strip-types --no-warnings "$(dirname "${BASH_SOURCE[0]}")/assemble-records.mts" "$1" +} diff --git a/src/commands/manifest/scripts/test/gradle-compat/smoke-test-failures.sh b/src/commands/manifest/scripts/test/gradle-compat/smoke-test-failures.sh index fc8aa86d0b..ff1ef43a3f 100644 --- a/src/commands/manifest/scripts/test/gradle-compat/smoke-test-failures.sh +++ b/src/commands/manifest/scripts/test/gradle-compat/smoke-test-failures.sh @@ -123,5 +123,5 @@ if errors: sys.exit(1) print(f"PASS (failures, withFiles={sys.argv[2]}): every failure reported, no partial or fabricated component") PY - python3 "$HERE/../assert-reachability.py" "$RECORDS" + assemble_records "$RECORDS" done diff --git a/src/commands/manifest/scripts/test/gradle-compat/smoke-test.sh b/src/commands/manifest/scripts/test/gradle-compat/smoke-test.sh index 9fb864b513..08ff69c888 100755 --- a/src/commands/manifest/scripts/test/gradle-compat/smoke-test.sh +++ b/src/commands/manifest/scripts/test/gradle-compat/smoke-test.sh @@ -8,7 +8,7 @@ # - foo (prod) appears in a prod root, bar (test) only in non-prod roots -> the assembler's dev flag; # - every resolved dependency gets an on-disk jar `file` record under -Psocket.withFiles (incl. the # production dep reached via the legacy `compile` config on old Gradle); -# - every component is reachable from a direct dependency of its own root (no orphaned components); +# - its records assemble into facts with no orphaned components; # - -Psocket.populateFilesFor scopes materialization to a single GAV; # - a jar that cannot be fetched is reported as a failure, and the other jars still materialize. # @@ -72,7 +72,7 @@ print(f"PASS ({'scoped' if scoped else 'full'}): nodes {sorted(nodes)}; foo prod + ("; bar skipped" if scoped else "; bar jar")) PY -python3 "$HERE/../assert-reachability.py" "$RECORDS" +assemble_records "$RECORDS" # Second run: scope --with-files to a single GAV and assert ONLY that artifact is materialized. SCOPE="$HERE/.populate-for.txt" @@ -98,7 +98,7 @@ if errors: print("PASS (populateFilesFor scoping): foo materialized, bar skipped") PY -python3 "$HERE/../assert-reachability.py" "$RECORDS" +assemble_records "$RECORDS" # Third run: bar's jar is gone from the repository, so it must be reported rather than emitted as # resolved with no file. diff --git a/src/commands/manifest/scripts/test/maven-compat/assert-records.py b/src/commands/manifest/scripts/test/maven-compat/assert-records.py index 66202e5e72..66e8e374f2 100755 --- a/src/commands/manifest/scripts/test/maven-compat/assert-records.py +++ b/src/commands/manifest/scripts/test/maven-compat/assert-records.py @@ -23,9 +23,9 @@ errors = [] -# NOTE: the generic "every component is reachable from a direct dependency of its root" invariant -# lives in ../assert-reachability.py, which smoke-test.sh runs alongside this file. What remains here -# is Maven-specific: which coordinate lands in which root, and jar materialization. +# NOTE: smoke-test.sh also assembles the records (../assemble-records.mts), which fails on an +# orphaned component. What remains here is Maven-specific: which coordinate lands in which root, +# and jar materialization. def coord(prefix, fields=None): """The one emitted coordId starting with prefix, or None. Keeps assertions version-agnostic. @@ -98,6 +98,14 @@ def edge_pair(parent, child): if pair and not any(root_projects.get(rid) == 'demo:mid:1.0' for rid in edge_roots(*pair)): errors.append("edge demo.ext:med-io -> demo.ext:med-util missing from mid") +# A managed scope applies to a transitive dependency as it is: demo.ext:managed is compile under +# its test parent, so it is on the runtime classpath. +pair = edge_pair('demo.ext:mgd-test:jar:', 'demo.ext:managed:jar:') +if pair: + if in_prod(pair[0]): errors.append("test dep demo.ext:mgd-test wrongly in a prod root") + elif not in_prod(pair[1]): errors.append("managed-scope demo.ext:managed not in a prod root") + elif not edge_roots(*pair): errors.append("edge demo.ext:mgd-test -> demo.ext:managed missing") + # An edge across the prod/dev split: container-lib (compile) onto container-api, which app # declares provided. The edge is kept and container-api stays dev. pair = edge_pair('demo.ext:container-lib:jar:', 'demo.ext:container-api:jar:') diff --git a/src/commands/manifest/scripts/test/maven-compat/project/app/pom.xml b/src/commands/manifest/scripts/test/maven-compat/project/app/pom.xml index cd2250b4a8..557f557163 100644 --- a/src/commands/manifest/scripts/test/maven-compat/project/app/pom.xml +++ b/src/commands/manifest/scripts/test/maven-compat/project/app/pom.xml @@ -7,6 +7,18 @@ 1.0 app + + + + + demo.ext + managed + 1.0 + compile + + +