diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9c8898f3..3adf4115 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -84,6 +84,7 @@ jobs: python -m pip list --format=freeze \ | LC_ALL=C sort -f > build-environment-evidence/environment.lock cyclonedx-py environment --output-reproducible --of JSON \ + --pyproject pyproject.toml \ -o build-environment-evidence/engraphis-${GITHUB_REF_NAME#v}.cdx.json - name: Build source and universal wheel distributions diff --git a/scripts/release_evidence.py b/scripts/release_evidence.py index 3a635481..3554a7bb 100644 --- a/scripts/release_evidence.py +++ b/scripts/release_evidence.py @@ -20,6 +20,15 @@ except ImportError: # pragma: no cover - supported Python 3.9/3.10 tomllib = None +try: # Prefer the installed packaging module when available. + from packaging.markers import InvalidMarker, Marker + from packaging.specifiers import InvalidSpecifier, SpecifierSet + from packaging.version import InvalidVersion, Version +except ImportError: # pragma: no cover - fallback for environments without top-level packaging + from pip._vendor.packaging.markers import InvalidMarker, Marker + from pip._vendor.packaging.specifiers import InvalidSpecifier, SpecifierSet + from pip._vendor.packaging.version import InvalidVersion, Version + FORMAT = "engraphis-release-evidence/3" PACKAGE = "engraphis" @@ -30,12 +39,27 @@ _PACKAGE_LOCK_LINE = re.compile(r"([A-Za-z0-9][A-Za-z0-9_.-]*)==([^\s]+)\Z") _IMAGE_DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z") _BUILDER_IMAGE = "github-hosted:ubuntu-latest/python-3.11" +# Extras installed by the release workflow (`.github/workflows/release.yml` runs +# `pip install ... ".[all,test]"` before capturing the SBOM), so the captured +# closure must include every marker-applicable requirement they declare. +_RELEASE_EXTRAS = ("all", "test") _BUILDER_TOOLCHAIN = { "build": "1.5.0", "pip": "26.2", "setuptools": "83.0.0", "wheel": "0.47.0", } + + +def _purl_matches(purl: str, name: str, version: str) -> bool: + """Return True when a pkg:pypi PURL names *name* at *version*.""" + if not purl.startswith("pkg:pypi/"): + return False + remainder = purl[len("pkg:pypi/"):].split("?", 1)[0] + if "@" not in remainder: + return False + purl_name, purl_version = remainder.split("@", 1) + return _canonical_package_name(purl_name) == name and purl_version == version _GRYPE_VERSION = "0.110.0" _SECRET_NAME = re.compile( r"(?:secret|token|password|credential|api[-_]?key|private[-_]?key)", re.IGNORECASE @@ -121,6 +145,8 @@ def project_version(root: Path) -> str: return version + + def git_commit(root: Path) -> str: try: commit = subprocess.check_output( @@ -218,6 +244,121 @@ def _canonical_package_name(value: str) -> str: return re.sub(r"[-_.]+", "-", value).lower() +def _parse_requirement(requirement: str) -> tuple[str, str | None]: + """Extract (name, specifier) from a PEP 508 requirement string.""" + requirement = requirement.strip() + if not requirement: + return ("", None) + match = re.match( + r'^([A-Za-z0-9][A-Za-z0-9._-]*)' + r'(?:\[.*?\])?' + r'\s*' + r'((?:[<>=!~]=?[^;,\s]+(?:\s*,\s*[<>=!~]=?[^;,\s]+)*)?)', + requirement, + ) + if not match: + name = re.split(r"[\s;<(>=!~\[]", requirement, maxsplit=1)[0] + return (name, None) + name = match.group(1) + specifier = match.group(2) if match.group(2) else None + return (name, specifier) + + +def _version_satisfies(version: str, specifier: str) -> bool: + """Check if *version* satisfies a PEP 440 specifier.""" + if not specifier: + return True + try: + candidate = Version(version) + spec = SpecifierSet(specifier) + except (InvalidVersion, InvalidSpecifier): + return False + return candidate in spec + + +def _declared_dependencies( + root: Path, *, include_extras: bool = True, +) -> dict[str, str | None]: + """Return {canonical_name: combined specifier} required in the SBOM closure. + + Covers [project].dependencies plus, when ``include_extras`` is true, every + requirement declared by the extras the release workflow installs + (``_RELEASE_EXTRAS``). PEP 508 environment markers are evaluated against the + running interpreter, which in the release workflow is the same environment + that captures the SBOM; requirements whose markers do not apply are not + required. + """ + pyproject = root / "pyproject.toml" + try: + raw = pyproject.read_text(encoding="utf-8") + except OSError: + return {} + requirements: list[str] = [] + if tomllib is not None: + try: + parsed = tomllib.loads(raw) + except (KeyError, ValueError): + parsed = {} + project = parsed.get("project", {}) if isinstance(parsed, dict) else {} + if isinstance(project, dict): + core = project.get("dependencies", []) + if isinstance(core, list): + requirements.extend(item for item in core if isinstance(item, str)) + extras = project.get("optional-dependencies", {}) + if include_extras and isinstance(extras, dict): + for extra in _RELEASE_EXTRAS: + group = extras.get(extra) + if isinstance(group, list): + requirements.extend( + item for item in group if isinstance(item, str) + ) + else: + project = re.search(r"(?ms)^\[project\]\s*(.*?)(?=^\[|\Z)", raw) + if project is not None: + deps_block = re.search( + r'(?m)^dependencies\s*=\s*\[(.*?)\]', project.group(1), re.DOTALL, + ) + if deps_block is not None: + requirements.extend(re.findall(r'"([^"]+)"', deps_block.group(1))) + extras_table = re.search( + r"(?ms)^\[project\.optional-dependencies\]\s*(.*?)(?=^\[|\Z)", raw, + ) + if include_extras and extras_table is not None: + for extra in _RELEASE_EXTRAS: + group = re.search( + r"(?m)^" + re.escape(extra) + r"\s*=\s*\[(.*?)\]", + extras_table.group(1), re.DOTALL, + ) + if group is not None: + requirements.extend(re.findall(r'"([^"]+)"', group.group(1))) + deps: dict[str, str | None] = {} + for requirement in requirements: + if not isinstance(requirement, str) or not requirement.strip(): + continue + name, specifier = _parse_requirement(requirement) + canonical = _canonical_package_name(name) + if not canonical or canonical == PACKAGE: + continue + marker_text = requirement.split(";", 1)[1].strip() if ";" in requirement else "" + if marker_text: + try: + applies = Marker(marker_text).evaluate() + except InvalidMarker as exc: + raise EvidenceError( + "pyproject.toml declares an unparsable environment marker: " + + marker_text + ) from exc + if not applies: + continue + if canonical not in deps or deps[canonical] is None: + deps[canonical] = specifier + elif specifier and specifier != deps[canonical]: + # Multiple selected extras can constrain the same distribution. + # Preserve every applicable constraint so validation enforces their + # intersection instead of silently accepting the first one seen. + deps[canonical] = f"{deps[canonical]},{specifier}" + return deps + def _python_sbom_packages(document: dict[str, Any]) -> set[tuple[str, str]]: packages = set() metadata_component = document.get("metadata", {}).get("component") @@ -225,29 +366,151 @@ def _python_sbom_packages(document: dict[str, Any]) -> set[tuple[str, str]]: purl = metadata_component.get("purl") name = metadata_component.get("name") version = metadata_component.get("version") + # cyclonedx-py --pyproject emits the application as root-component + # without a PURL; environment_lock_artifact validates the name/version. if ( - isinstance(purl, str) - and purl.startswith("pkg:pypi/") - and isinstance(name, str) + isinstance(name, str) and isinstance(version, str) + and ( + purl is None + or (isinstance(purl, str) and purl.startswith("pkg:pypi/")) + ) ): packages.add((_canonical_package_name(name), version)) - for component in document.get("components", []): + components = document.get("components", []) + if not isinstance(components, list): + raise EvidenceError("SBOM components must be a JSON array") + for component in components: if not isinstance(component, dict): - continue + raise EvidenceError("SBOM components must be JSON objects") purl = component.get("purl") name = component.get("name") version = component.get("version") if ( - isinstance(purl, str) - and purl.startswith("pkg:pypi/") - and isinstance(name, str) - and isinstance(version, str) + not isinstance(name, str) + or not name + or not isinstance(version, str) + or not version ): - packages.add((_canonical_package_name(name), version)) + raise EvidenceError("SBOM component must identify name and version") + if not isinstance(purl, str) or not purl.startswith("pkg:pypi/"): + raise EvidenceError( + "SBOM component lacks a valid PyPI PURL: " + + name + "@" + version + ) + if not _purl_matches(purl, _canonical_package_name(name), version): + raise EvidenceError( + "SBOM component PURL does not match its name/version: " + + name + "@" + version + " vs " + purl + ) + packages.add((_canonical_package_name(name), version)) return packages +def _python_component_refs(component: Any) -> set[str]: + """Return the component's preferred CycloneDX dependency-graph ref.""" + if not isinstance(component, dict): + return set() + bom_ref = component.get("bom-ref") + if isinstance(bom_ref, str) and bom_ref: + return {bom_ref} + purl = component.get("purl") + return {purl} if isinstance(purl, str) and purl else set() + + +def _validate_python_sbom_dependency_closure( + document: dict[str, Any], declared_names: set[str], +) -> None: + """Validate the CycloneDX dependency graph of the captured Python SBOM. + + The pinned capture generator (cyclonedx-bom 7.3.0) emits one + ``dependencies`` entry per component plus the project root, with + ``dependsOn`` resolved against installed distribution metadata. The graph + is optional here because lock-to-SBOM closure coverage in + ``environment_lock_artifact`` already rejects truncated captures + deterministically; when present it must be coherent: every ``dependsOn`` + ref must resolve to the root or a listed component, and every declared + requirement must be transitively reachable from the project root. + Workflow-installed build tooling (pip, build, twine, ...) is legitimately + captured yet unreachable from the root, so full-graph reachability from + the root alone is intentionally not required. + """ + entries = document.get("dependencies") + if entries is None: + return + if not isinstance(entries, list): + raise EvidenceError("SBOM dependency graph must be a JSON array") + edges: dict[str, list[str]] = {} + for entry in entries: + if ( + not isinstance(entry, dict) + or not isinstance(entry.get("ref"), str) + or not entry["ref"] + ): + raise EvidenceError("SBOM dependency graph entries must carry string refs") + children = entry.get("dependsOn", []) + if not isinstance(children, list) or any( + not isinstance(child, str) or not child for child in children + ): + raise EvidenceError("SBOM dependency graph dependsOn must list string refs") + ref = entry["ref"] + if ref in edges: + raise EvidenceError( + "SBOM dependency graph contains duplicate ref: " + ref + ) + edges[ref] = children + metadata_component = document.get("metadata", {}).get("component") + root_refs = _python_component_refs(metadata_component) + known_refs = set(root_refs) + ref_names: dict[str, str] = {} + for component in document.get("components", []): + name = component.get("name") if isinstance(component, dict) else None + refs = _python_component_refs(component) + if not isinstance(name, str) or not refs: + continue + canonical = _canonical_package_name(name) + for ref in refs: + if ref in known_refs: + raise EvidenceError( + "SBOM dependency graph ref collides with root or another " + "component: " + ref + ) + known_refs.add(ref) + ref_names[ref] = canonical + if not any(ref in edges for ref in refs): + raise EvidenceError( + "SBOM dependency graph is missing an entry for component " + name + ) + for ref in edges: + if ref not in known_refs: + raise EvidenceError( + "SBOM dependency graph references unknown component ref: " + ref + ) + for children in edges.values(): + for child in children: + if child not in known_refs: + raise EvidenceError( + "SBOM dependency graph references unknown component ref: " + child + ) + frontier = list(root_refs) + reachable_refs = set(root_refs) + while frontier: + ref = frontier.pop() + for child in edges.get(ref, ()): + if child not in reachable_refs: + reachable_refs.add(child) + frontier.append(child) + reachable_names = { + ref_names[ref] for ref in reachable_refs if ref in ref_names + } + unreachable = declared_names - reachable_names + if unreachable: + raise EvidenceError( + "declared dependencies are unreachable from the SBOM root " + "in the dependency graph: " + ", ".join(sorted(unreachable)) + ) + + def sbom_artifact(root: Path, path: Path) -> dict[str, Any]: """Validate and fingerprint the build-captured Python CycloneDX SBOM.""" if not path.is_file(): @@ -272,12 +535,25 @@ def sbom_artifact(root: Path, path: Path) -> dict[str, Any]: } -def environment_lock_artifact(root: Path, path: Path, sbom: Path) -> dict[str, Any]: - """Require the exact build freeze to equal the Python SBOM package closure.""" +def environment_lock_artifact( + root: Path, path: Path, sbom: Path, version: str) -> dict[str, Any]: + """Require the captured freeze and the Python SBOM to describe one closure. + + The comparison is two-sided after name canonicalization: every SBOM + package must appear in the lock at the same version (version skew fails), + and every locked package must be inventoried by the SBOM. The pinned + generator (cyclonedx-bom 7.3.0, ``cyclonedx-py environment``) inventories + the whole build environment including workflow-installed tooling, so a + lock entry missing from the SBOM means a truncated capture, not expected + tooling overhead. A truncated SBOM that keeps the root and every direct + requirement but drops transitive packages would otherwise pass a + one-directional subset check and let incomplete release evidence publish. + """ if not path.is_file() or path.is_symlink(): raise EvidenceError("build environment lock is missing") relative = _relative_path(root, path) packages: set[tuple[str, str]] = set() + seen_names: set[str] = set() try: lines = path.read_text(encoding="utf-8").splitlines() except (OSError, UnicodeDecodeError) as exc: @@ -288,13 +564,92 @@ def environment_lock_artifact(root: Path, path: Path, sbom: Path) -> dict[str, A match = _PACKAGE_LOCK_LINE.fullmatch(line) if match is None: raise EvidenceError("build environment lock must contain exact name==version lines") - package = (_canonical_package_name(match.group(1)), match.group(2)) + canonical = _canonical_package_name(match.group(1)) + package = (canonical, match.group(2)) if package in packages: raise EvidenceError("build environment lock contains a duplicate package") + if canonical in seen_names: + raise EvidenceError( + "build environment lock contains conflicting versions of " + canonical + ) + seen_names.add(canonical) packages.add(package) - sbom_packages = _python_sbom_packages(_json_object(sbom, "SBOM")) - if packages != sbom_packages: + document = _json_object(sbom, "SBOM") + sbom_packages = _python_sbom_packages(document) + if not sbom_packages: + raise EvidenceError("SBOM contains no Python package components") + metadata_component = document.get("metadata", {}).get("component") + if not isinstance(metadata_component, dict): + raise EvidenceError( + "SBOM metadata.component does not identify the " + PACKAGE + " root" + ) + root_name = metadata_component.get("name") + root_version = metadata_component.get("version") + root_purl = metadata_component.get("purl") + if ( + not isinstance(root_name, str) + or _canonical_package_name(root_name) != PACKAGE + or not isinstance(root_version, str) + or root_version != version + or ( + root_purl is not None + and ( + not isinstance(root_purl, str) + or not _purl_matches(root_purl, PACKAGE, version) + ) + ) + ): + raise EvidenceError( + "SBOM metadata.component does not identify the " + PACKAGE + + " root at version " + version + ) + declared = _declared_dependencies(root) + core_declared = _declared_dependencies(root, include_extras=False) + dependency_packages = { + pkg for pkg in sbom_packages + if pkg != (_canonical_package_name(PACKAGE), version) + } + declared_names = {name for name in declared if name != PACKAGE} + core_declared_names = {name for name in core_declared if name != PACKAGE} + sbom_dependency_names = { + name for name, _ in dependency_packages + } + missing_declared = declared_names - sbom_dependency_names + if missing_declared: + raise EvidenceError( + "SBOM is missing declared dependencies: " + + ", ".join(sorted(missing_declared)) + ) + # Validate version constraints for declared dependencies + sbom_versions = {name: ver for name, ver in dependency_packages} + for name, specifier in declared.items(): + if name == PACKAGE or not specifier or name not in sbom_versions: + continue + sbom_ver = sbom_versions[name] + if not _version_satisfies(sbom_ver, specifier): + raise EvidenceError( + f"SBOM version {name}=={sbom_ver} does not satisfy " + f"declared constraint {specifier}" + ) + if not dependency_packages: + raise EvidenceError( + "SBOM contains no dependency components beyond the " + PACKAGE + " root" + ) + # Extras are required to be present in the captured SBOM/lock above, but + # pip's installed-distribution metadata does not preserve which extras were + # selected. Their CycloneDX nodes therefore need not be reachable from the + # project root, unlike core project dependencies. + _validate_python_sbom_dependency_closure(document, core_declared_names) + if not sbom_packages.issubset(packages): raise EvidenceError("build environment lock and Python SBOM package closure differ") + missing_from_sbom = sorted( + {name for name, _ in packages} - {name for name, _ in sbom_packages} + ) + if missing_from_sbom: + raise EvidenceError( + "Python SBOM omits captured environment packages " + "(truncated closure): " + ", ".join(missing_from_sbom) + ) return { "filename": path.name, "path": relative, @@ -675,7 +1030,7 @@ def build_evidence( artifacts = distribution_artifacts(distribution_directory, version) artifact_digests = {item["filename"]: item["sha256"] for item in artifacts} python_sbom = sbom_artifact(root, sbom) - environment = environment_lock_artifact(root, environment_lock, sbom) + environment = environment_lock_artifact(root, environment_lock, sbom, version) container_sbom = container_sbom_artifact(root, image_sbom, image_digest) container_scan = container_scan_artifact(root, image_scan) reproducibility_record = reproducibility_artifact( diff --git a/tests/e2e/graph-engine.spec.js b/tests/e2e/graph-engine.spec.js index a30e1311..12aa945e 100644 --- a/tests/e2e/graph-engine.spec.js +++ b/tests/e2e/graph-engine.spec.js @@ -379,7 +379,20 @@ async function openDashboard(page, { query = '', graphScene = graphScenePayload page.on('request', request => requested.push(request.url())); page.on('console', message => { - if (message.type() === 'error') consoleErrors.push(message.text()); + if (message.type() === 'error') { + const text = message.text(); + // force-graph applies inline styles at runtime, producing known CSP blocks + // against style-src-elem. Chromium's console message text reports the + // directive but not the originating script URL; that lives in + // message.location().url. Filter only when the location names the + // force-graph vendor bundle; application-level CSP regressions surface + // through a different location and must still fail the assertion. + if (/style-src(-elem)?/.test(text)) { + const loc = message.location(); + if (loc && typeof loc.url === 'string' && loc.url.includes('force-graph')) return; + } + consoleErrors.push(text); + } }); page.on('pageerror', error => pageErrors.push(String(error))); @@ -415,10 +428,10 @@ const fetched = (requested, name) => requested.filter(url => url.includes(name)) /** Open the Graph view and wait for force-graph to put a sized canvas on the page. */ async function openGraphView(page) { await page.locator('.nav-item[data-view="graph"]').click(); - const canvas = page.locator('#graph-net canvas').first(); + const canvas = page.locator('#graph-net canvas, #graph-canvas canvas').first(); await expect(canvas).toBeAttached({ timeout: 20_000 }); await page.waitForFunction(() => { - const c = document.querySelector('#graph-net canvas'); + const c = document.querySelector('#graph-net canvas, #graph-canvas canvas'); return c && c.width > 0 && c.height > 0; }, null, { timeout: 20_000 }); return canvas; @@ -428,14 +441,17 @@ async function openGraphView(page) { centres are evidence-mass weighted, matching the runtime force and server scene contract. */ async function galaxySystemSnapshot(page) { return page.evaluate(() => { - const nodes = window.__fg.graphData().nodes.filter(node => !node.ghost); + const graph = window.__fg; + const nodes = graph && typeof graph.graphData === 'function' + ? graph.graphData().nodes.filter(node => !node.ghost) + : []; const anchor = nodes.slice().sort((left, right) => { const leftGlobal = left.anchor_role === 'global' ? 1 : 0; const rightGlobal = right.anchor_role === 'global' ? 1 : 0; return rightGlobal - leftGlobal || Number(right.gravity_mass || 0) - Number(left.gravity_mass || 0) || String(left.id).localeCompare(String(right.id)); - })[0]; + })[0] || { id: null, x: 0, y: 0, gravity_mass: 1, radius: 1 }; const groups = new Map(); nodes.forEach(node => { const id = String(node.community_id ?? node.community ?? 'ungrouped'); @@ -490,10 +506,13 @@ async function galaxySystemSnapshot(page) { systemAnchorId: node.system_anchor_id || null, radius: Number(node.radius), }])), - diagnostics: window.__engraphisGraph.physicsDiagnostics(), + diagnostics: window.__engraphisGraph + && typeof window.__engraphisGraph.physicsDiagnostics === 'function' + ? window.__engraphisGraph.physicsDiagnostics() + : null, d3Budget: { - time: typeof window.__fg.cooldownTime === 'function' ? window.__fg.cooldownTime() : null, - ticks: typeof window.__fg.cooldownTicks === 'function' ? window.__fg.cooldownTicks() : null, + time: graph && typeof graph.cooldownTime === 'function' ? graph.cooldownTime() : null, + ticks: graph && typeof graph.cooldownTicks === 'function' ? graph.cooldownTicks() : null, }, finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] .every(value => Number.isFinite(value))), @@ -549,102 +568,152 @@ async function renderedStellarSnapshot(page, systemId = 'aurora') { return page.evaluate(id => { const graph = window.__fg; const engine = window.__engraphisGraph; - const nodes = graph.graphData().nodes.filter(node => !node.ghost); - const star = nodes.find(node => node.id === `${id}-star`); - const planet = nodes.find(node => node.id === `${id}-planet`); - const anchor = nodes.find(node => node.anchor_role === 'global'); - const members = nodes.filter(node => node.community_id === id); - const mass = members.reduce((sum, node) => sum + Number(node.gravity_mass || 1), 0); + const nodes = graph && typeof graph.graphData === 'function' + ? graph.graphData().nodes.filter(node => !node.ghost) + : []; + const zeroPoint = { x: 0, y: 0 }; + const zeroVector = { x: 0, y: 0, vx: 0, vy: 0 }; + const byId = new Map(nodes.map(node => [String(node.id), node])); + const star = byId.get(`${id}-star`) || null; + const planet = byId.get(`${id}-planet`) || null; + const globalAnchors = nodes.filter(node => node.anchor_role === 'global'); + const anchor = globalAnchors.length === 1 ? globalAnchors[0] : null; + const anchorValid = Boolean(anchor && anchor.anchor_role === 'global'); + const members = nodes.filter(node => String(node.community_id ?? node.community ?? 'ungrouped') === id); + const memberWeight = node => Math.max(0.01, Number(node.gravity_mass) || 1); + const mass = members.reduce((sum, node) => sum + memberWeight(node), 0); + const weighted = (selector, fallback = 0) => (mass > 0 + ? members.reduce((sum, node) => sum + selector(node) * memberWeight(node), 0) / mass + : fallback); const center = { - x: members.reduce((sum, node) => sum - + node.x * Number(node.gravity_mass || 1), 0) / mass, - y: members.reduce((sum, node) => sum - + node.y * Number(node.gravity_mass || 1), 0) / mass, - vx: members.reduce((sum, node) => sum - + Number(node.vx || 0) * Number(node.gravity_mass || 1), 0) / mass, - vy: members.reduce((sum, node) => sum - + Number(node.vy || 0) * Number(node.gravity_mass || 1), 0) / mass, + x: weighted(node => Number(node.x) || 0), + y: weighted(node => Number(node.y) || 0), + vx: weighted(node => Number(node.vx) || 0), + vy: weighted(node => Number(node.vy) || 0), }; - const starPoint = graph.graph2ScreenCoords(star.x, star.y); - const planetPoint = graph.graph2ScreenCoords(planet.x, planet.y); - const starEdge = graph.graph2ScreenCoords(star.x + Number(star.radius || 0), star.y); - const planetEdge = graph.graph2ScreenCoords( - planet.x + Number(planet.radius || 0), planet.y, - ); - const canvas = document.querySelector('#graph-canvas canvas'); - const bounds = canvas.getBoundingClientRect(); - const local = { x: planet.x - star.x, y: planet.y - star.y }; + const toScreen = point => { + if (!graph || typeof graph.graph2ScreenCoords !== 'function' || !point) return { ...zeroPoint }; + const screen = graph.graph2ScreenCoords(point.x, point.y); + return { + x: Number(screen && screen.x), + y: Number(screen && screen.y), + }; + }; + const starPoint = toScreen(star || zeroVector); + const planetPoint = toScreen(planet || zeroVector); + const starEdge = star ? toScreen({ x: Number(star.x) + Number(star.radius || 0), y: Number(star.y) }) : { ...zeroPoint }; + const planetEdge = planet ? toScreen({ x: Number(planet.x) + Number(planet.radius || 0), y: Number(planet.y) }) : { ...zeroPoint }; + const canvas = document.querySelector('#graph-canvas canvas, #graph-net canvas'); + const bounds = canvas && typeof canvas.getBoundingClientRect === 'function' + ? canvas.getBoundingClientRect() + : null; + const local = star && planet + ? { x: Number(planet.x) - Number(star.x), y: Number(planet.y) - Number(star.y) } + : { ...zeroPoint }; const screenLocal = { x: planetPoint.x - starPoint.x, y: planetPoint.y - starPoint.y, }; - const inside = point => point.x >= 0 && point.y >= 0 - && point.x <= bounds.width && point.y <= bounds.height; - const diagnostics = engine.physicsDiagnostics(); - const nodeRadius = node => Number(node.radius || node.visual_radius || 0); + const inside = point => { + if (!bounds) return false; + return point.x >= 0 && point.y >= 0 + && point.x <= bounds.width && point.y <= bounds.height; + }; + const diagnostics = engine && typeof engine.physicsDiagnostics === 'function' + ? engine.physicsDiagnostics() || {} + : {}; + const nodeRadius = node => Number(node && (node.radius || node.visual_radius) || 0); const blackHolePadding = Number(diagnostics.blackHoleExclusionPadding || 0); - const blackHoleClearances = nodes.filter(node => node !== anchor).map(node => - Math.hypot(node.x - anchor.x, node.y - anchor.y) - - nodeRadius(anchor) - nodeRadius(node) - blackHolePadding); - const byId = new Map(nodes.map(node => [node.id, node])); + const anchorPoint = anchor || zeroVector; + const blackHoleClearances = anchor + ? nodes.filter(node => node !== anchor).map(node => + Math.hypot(Number(node.x) - Number(anchorPoint.x), Number(node.y) - Number(anchorPoint.y)) + - nodeRadius(anchorPoint) - nodeRadius(node) - blackHolePadding) + : []; const stellarClearances = nodes.flatMap(node => { - const stellarAnchor = byId.get(node.system_anchor_id); + const stellarAnchor = byId.get(String(node.system_anchor_id)); if (!stellarAnchor || stellarAnchor === node || stellarAnchor.anchor_role !== 'community') return []; - return [Math.hypot(node.x - stellarAnchor.x, node.y - stellarAnchor.y) + return [Math.hypot(Number(node.x) - Number(stellarAnchor.x), Number(node.y) - Number(stellarAnchor.y)) - nodeRadius(stellarAnchor) - nodeRadius(node) - Number(diagnostics.systemAnchorExclusionPadding || 0)]; }); const envelope = Number(diagnostics.farFieldConfinement && diagnostics.farFieldConfinement.envelopeRadius); - const outerClearances = nodes.filter(node => node !== anchor).map(node => - envelope - Math.hypot(node.x - anchor.x, node.y - anchor.y) - nodeRadius(node)); + const outerClearances = anchor + ? nodes.filter(node => node !== anchor).map(node => + envelope - Math.hypot(Number(node.x) - Number(anchorPoint.x), Number(node.y) - Number(anchorPoint.y)) + - nodeRadius(node)) + : []; + const safeMin = values => (values.length ? Math.min(...values) : 0); + const settings = engine && typeof engine.state === 'function' && engine.state() + ? engine.state().settings + : null; + const collapsed = engine && typeof engine.state === 'function' && engine.state() + ? engine.state().collapsed + : null; + const finite = [ + starPoint.x, starPoint.y, planetPoint.x, planetPoint.y, + center.x, center.y, center.vx, center.vy, + local.x, local.y, screenLocal.x, screenLocal.y, + ].every(Number.isFinite) && Boolean(star && planet) && anchorValid; return { - star: { id: star.id, x: star.x, y: star.y, + star: star ? { id: star.id, x: Number(star.x) || 0, y: Number(star.y) || 0, vx: Number(star.vx) || 0, vy: Number(star.vy) || 0, warp: Number(star.__galaxySpacetimeWarp) || 0, mass: Number(star.gravity_mass) || 1, screenX: starPoint.x, screenY: starPoint.y, - screenRadius: Math.abs(starEdge.x - starPoint.x) }, - planet: { id: planet.id, anchor: planet.system_anchor_id, - x: planet.x, y: planet.y, vx: Number(planet.vx) || 0, + screenRadius: Math.abs(starEdge.x - starPoint.x) } : null, + planet: planet ? { id: planet.id, anchor: planet.system_anchor_id || null, + x: Number(planet.x) || 0, y: Number(planet.y) || 0, vx: Number(planet.vx) || 0, vy: Number(planet.vy) || 0, mass: Number(planet.gravity_mass) || 1, screenX: planetPoint.x, screenY: planetPoint.y, - screenRadius: Math.abs(planetEdge.x - planetPoint.x) }, + screenRadius: Math.abs(planetEdge.x - planetPoint.x) } : null, local: { ...local, radius: Math.hypot(local.x, local.y), angle: Math.atan2(local.y, local.x), - relativeSpeed: Math.hypot((Number(planet.vx) || 0) - (Number(star.vx) || 0), - (Number(planet.vy) || 0) - (Number(star.vy) || 0)) }, + relativeSpeed: star && planet + ? Math.hypot((Number(planet.vx) || 0) - (Number(star.vx) || 0), + (Number(planet.vy) || 0) - (Number(star.vy) || 0)) + : 0 }, screenLocal: { ...screenLocal, radius: Math.hypot(screenLocal.x, screenLocal.y), angle: Math.atan2(screenLocal.y, screenLocal.x) }, - anchor: { id: anchor.id, x: anchor.x, y: anchor.y, + // Keep the compact phase names used by the focused browser contract alongside the + // richer local/screenLocal payload consumed by the existing regression tests. + phase: Math.atan2(local.y, local.x), + screenPhase: Math.atan2(screenLocal.y, screenLocal.x), + center, + anchor: anchor ? { id: anchor.id, anchorRole: anchor.anchor_role, + x: Number(anchor.x) || 0, y: Number(anchor.y) || 0, vx: Number(anchor.vx) || 0, vy: Number(anchor.vy) || 0, - radius: nodeRadius(anchor), warp: Number(anchor.__galaxySpacetimeWarp) || 0 }, + radius: nodeRadius(anchor), warp: Number(anchor.__galaxySpacetimeWarp) || 0 } + : null, + anchorValid, + globalAnchorCount: globalAnchors.length, coreFollower: (() => { - const node = nodes.find(candidate => candidate.id === 'core-star'); - return node ? { x: node.x, y: node.y, + const node = byId.get('core-star'); + return node ? { x: Number(node.x) || 0, y: Number(node.y) || 0, vx: Number(node.vx) || 0, vy: Number(node.vy) || 0 } : null; })(), systemCenter: center, - globalAngle: Math.atan2(center.y - anchor.y, center.x - anchor.x), + globalAngle: Math.atan2(center.y - (anchor ? Number(anchor.y) || 0 : 0), + center.x - (anchor ? Number(anchor.x) || 0 : 0)), visible: inside(starPoint) && inside(planetPoint), - canvas: { width: bounds.width, height: bounds.height }, - zoom: canvas.__zoom && canvas.__zoom.k, + canvas: { width: bounds ? bounds.width : 0, height: bounds ? bounds.height : 0 }, + zoom: canvas && canvas.__zoom ? canvas.__zoom.k : null, diagnostics, safety: { - minimumBlackHoleClearance: Math.min(...blackHoleClearances), - minimumStellarClearance: Math.min(...stellarClearances), - minimumOuterClearance: Math.min(...outerClearances), + minimumBlackHoleClearance: safeMin(blackHoleClearances), + minimumStellarClearance: safeMin(stellarClearances), + minimumOuterClearance: safeMin(outerClearances), envelope, - maximumSpeed: Math.max(...nodes.map(node => Math.hypot( + maximumSpeed: nodes.length ? Math.max(...nodes.map(node => Math.hypot( Number(node.vx) || 0, Number(node.vy) || 0, - ))), - speedCapActivations: diagnostics.speedCapActivations, + ))) : 0, + speedCapActivations: diagnostics.speedCapActivations || 0, }, - settings: engine.state().settings, - collapsed: engine.state().collapsed, - finite: [star.x, star.y, planet.x, planet.y, starPoint.x, starPoint.y, - planetPoint.x, planetPoint.y].every(Number.isFinite), + settings, + collapsed, + finite, }; }, systemId); } @@ -1685,6 +1754,9 @@ for (const reducedMotion of [false, true]) { expect(samples.every(sample => sample.finite && sample.visible), JSON.stringify(evidence)) .toBe(true); + expect(samples.every(sample => sample.anchorValid + && sample.anchor.anchorRole === 'global' + && sample.globalAnchorCount === 1), JSON.stringify(evidence)).toBe(true); expect(samples.every(sample => Number.isFinite(sample.safety.envelope) && sample.safety.envelope > 0), JSON.stringify(evidence)).toBe(true); expect(Math.min(...samples.map(sample => sample.safety.minimumBlackHoleClearance)), @@ -2171,7 +2243,9 @@ test('served Complete Galaxy uses the lightweight all-body orbit path instead of if (Math.abs(step) <= 1e-8) { state.frozen++; frozen++; if (!first) first = { id, reason: 'frozen' }; } } let minTravel = Infinity, totalFrozen = 0; - for (const state of totals.values()) { minTravel = Math.min(minTravel, Math.abs(state.travel)); totalFrozen += state.frozen; } + for (const state of totals.values()) { + minTravel = Math.min(minTravel, Math.abs(state.travel)); totalFrozen += state.frozen; + } return { count: now.size, missing, nonFinite, frozen, totalFrozen, minTravel, first }; }; const global = check('global', observer.global), local = check('local', observer.local); @@ -3207,6 +3281,8 @@ test('Galaxy sliders retain full ranges with orbital-speed and radius response', expect(immediate.after.velocities).toEqual(immediate.before.velocities); expect(baseline.steps).toBeGreaterThanOrEqual(8); expect(strong.steps).toBeGreaterThanOrEqual(8); + // Keep both comparisons on the established two-step budget. A four-step mismatch lets one + // trial run 50% longer and can make its radius/travel assertions pass by extra integration. expect(Math.abs(strong.steps - baseline.steps)).toBeLessThanOrEqual(2); for (const [id, ratio] of Object.entries(physicalField.ratios)) { expect(physicalField.baseline[id], id).toBeGreaterThan(0); @@ -3621,3 +3697,105 @@ test('Classic does not expose a complete graph control', async ({ page }) => { expect(await page.evaluate(() => GRAPH_FULL)).toBe(false); expect(session.pageErrors).toEqual([]); }); + +test.describe('Opt-in canvas graph engine helper contracts', () => { + test('renders a canvas without uncaught errors or application CSP violations', async ({ page }) => { + const session = await openDashboard(page, { query: '?graph-engine=next' }); + const canvas = await openGraphView(page); + + await expect(canvas).toBeVisible(); + expect(session.consoleErrors).toEqual([]); + expect(session.pageErrors).toEqual([]); + // force-graph may emit its known vendor stylesheet CSP reports when it attaches. The + // application renderer must not add any inline-script, inline-style, or other violations. + const unexpectedViolations = (await session.violations()) + .filter(violation => violation.directive !== 'style-src-elem'); + expect(unexpectedViolations).toEqual([]); + }); + + test('keeps galaxy systems finite and separated inside the rendered envelope', async ({ page }) => { + await openDashboard(page, { + query: '?graph-engine=next', + graphScene: blackHoleGalaxyScene, + }); + await openGraphView(page); + await page.evaluate(scene => { + window.__engraphisGraph.setPreset('galaxy'); + window.__engraphisGraph.setSettings({ gravity: 48 }); + window.__engraphisGraph.setData(scene); + window.__engraphisGraph.setScope({ showUnlinked: true, minDegree: 0 }); + }, blackHoleGalaxyScene); + await page.waitForFunction(() => window.__fg.graphData().nodes.length === 8 + && window.__engraphisGraph.physicsDiagnostics().steps >= 5); + + const systems = await galaxySystemSnapshot(page); + expect(systems.finite).toBe(true); + + const envelope = await renderedSystemEnvelopeSnapshot(page); + expect(envelope.systems.length).toBe(3); + expect(envelope.systems.every(system => system.members === 2)).toBe(true); + expect(envelope.systems.every(system => system.visible)).toBe(true); + expect(envelope.finite).toBe(true); + expect(envelope.overlaps).toBe(0); + }); + + test('observes orbital phase motion in the rendered stellar snapshot', async ({ page }) => { + await openDashboard(page, { + query: '?graph-engine=next', + graphScene: blackHoleGalaxyScene, + }); + await openGraphView(page); + await page.evaluate(scene => { + window.__engraphisGraph.setPreset('galaxy'); + window.__engraphisGraph.setSettings({ gravity: 48 }); + window.__engraphisGraph.setData(scene); + window.__engraphisGraph.setScope({ showUnlinked: true, minDegree: 0 }); + }, blackHoleGalaxyScene); + await page.waitForFunction(() => window.__fg.graphData().nodes.length === 8 + && window.__engraphisGraph.physicsDiagnostics().steps >= 5); + + const initial = await renderedStellarSnapshot(page, 'aurora'); + expect(initial.finite).toBe(true); + const targetStep = Number(initial.diagnostics.steps || 0) + 12; + await page.waitForFunction(step => window.__engraphisGraph + && window.__engraphisGraph.physicsDiagnostics().steps >= step, + targetStep, { timeout: 10_000 }); + const updated = await renderedStellarSnapshot(page, 'aurora'); + + expect(updated.finite).toBe(true); + expect(updated.visible).toBe(true); + expect(updated.phase).not.toBe(initial.phase); + expect(Math.abs(signedAngleDelta(initial.phase, updated.phase))).toBeGreaterThan(0.01); + }); + + test('rejects a rendered stellar snapshot without its global anchor', async ({ page }) => { + await openDashboard(page, { + query: '?graph-engine=next', + graphScene: blackHoleGalaxyScene, + }); + await openGraphView(page); + await page.evaluate(scene => { + const api = window.__engraphisGraph; + api.freeze(true); + api.setPreset('galaxy'); + api.setSettings({ gravity: 48 }); + api.setData(scene); + api.setScope({ showUnlinked: true, minDegree: 0 }); + }, blackHoleGalaxyScene); + await page.waitForFunction(() => window.__fg.graphData().nodes.length === 8 + && window.__engraphisGraph.physicsDiagnostics().frozen); + + await page.evaluate(() => { + const globalAnchor = window.__fg.graphData().nodes.find( + node => node.anchor_role === 'global', + ); + globalAnchor.anchor_role = 'none'; + }); + const snapshot = await renderedStellarSnapshot(page, 'aurora'); + + expect(snapshot.globalAnchorCount).toBe(0); + expect(snapshot.anchorValid).toBe(false); + expect(snapshot.anchor).toBeNull(); + expect(snapshot.finite).toBe(false); + }); +}); diff --git a/tests/test_release_evidence.py b/tests/test_release_evidence.py index c283b98f..c8d5b5e2 100644 --- a/tests/test_release_evidence.py +++ b/tests/test_release_evidence.py @@ -30,7 +30,8 @@ def _root(tmp_path): (tmp_path / "eval" / "datasets").mkdir(parents=True) (tmp_path / "pyproject.toml").write_text( - '[project]\nname = "engraphis"\nversion = "1.2.3"\n', encoding="utf-8" + '[project]\nname = "engraphis"\nversion = "1.2.3"\ndependencies = ["alpha-package>=1.0"]\n', + encoding="utf-8", ) (tmp_path / "LICENSE").write_text("Apache-2.0\n", encoding="utf-8") (tmp_path / "NOTICE").write_text("Engraphis\n", encoding="utf-8") @@ -59,6 +60,21 @@ def _release_inputs(root, dist): { "bomFormat": "CycloneDX", "specVersion": "1.6", + "metadata": { + "component": { + "type": "application", + "name": "engraphis", + "version": "1.2.3", + "purl": "pkg:pypi/engraphis@1.2.3", + }, + }, + "dependencies": [ + { + "ref": "pkg:pypi/engraphis@1.2.3", + "dependsOn": ["pkg:pypi/alpha-package@1.0"], + }, + {"ref": "pkg:pypi/alpha-package@1.0", "dependsOn": []}, + ], "components": [ { "type": "library", @@ -66,12 +82,6 @@ def _release_inputs(root, dist): "version": "1.0", "purl": "pkg:pypi/alpha-package@1.0", }, - { - "type": "application", - "name": "engraphis", - "version": "1.2.3", - "purl": "pkg:pypi/engraphis@1.2.3", - }, ], } ), @@ -358,6 +368,684 @@ def test_release_evidence_rejects_build_freeze_that_differs_from_python_sbom(tmp _build(root, dist, inputs=inputs) +def test_release_evidence_rejects_lock_with_conflicting_versions(tmp_path): + """A lock with the same package at two versions must fail.""" + root = _root(tmp_path) + dist = _dist(root) + inputs = _release_inputs(root, dist) + inputs["environment_lock"].write_text( + "alpha-package==1.0\nalpha-package==9.9\nengraphis==1.2.3\n", + encoding="utf-8", + ) + with pytest.raises(EvidenceError, match="conflicting versions"): + _build(root, dist, inputs=inputs) + + +def test_release_evidence_rejects_lock_packages_missing_from_sbom(tmp_path): + """A locked package absent from the SBOM means a truncated capture. + + cyclonedx-bom 7.3.0 (``cyclonedx-py environment``) inventories the whole + build environment, including workflow-installed tooling such as pip and + setuptools, so a captured lock and SBOM name-set must match after + canonicalization; a lock-only entry is how a transitive-only package + could vanish while every direct name check still succeeds. + """ + root = _root(tmp_path) + dist = _dist(root) + inputs = _release_inputs(root, dist) + inputs["environment_lock"].write_text( + "alpha-package==1.0\nengraphis==1.2.3\npip==26.2\nsetuptools==83.0.0\n", + encoding="utf-8", + ) + with pytest.raises(EvidenceError, match="truncated closure"): + _build(root, dist, inputs=inputs) + + +def test_release_evidence_accepts_cyclonedx_root_component_without_purl(tmp_path): + """cyclonedx-py uses root-component without a root PyPI PURL.""" + root = _root(tmp_path) + dist = _dist(root) + inputs = _release_inputs(root, dist) + sbom_doc = json.loads(inputs["sbom"].read_text(encoding="utf-8")) + sbom_doc["metadata"]["component"]["bom-ref"] = "root-component" + sbom_doc["metadata"]["component"].pop("purl") + sbom_doc["components"][0]["bom-ref"] = "alpha-component" + sbom_doc["dependencies"][0]["ref"] = "root-component" + sbom_doc["dependencies"][0]["dependsOn"] = ["alpha-component"] + sbom_doc["dependencies"][1]["ref"] = "alpha-component" + inputs["sbom"].write_text(json.dumps(sbom_doc), encoding="utf-8") + + _build(root, dist, inputs=inputs) + + +def test_release_evidence_rejects_mismatched_root_purl(tmp_path): + """An optional root PURL, when present, must still identify Engraphis.""" + root = _root(tmp_path) + dist = _dist(root) + inputs = _release_inputs(root, dist) + sbom_doc = json.loads(inputs["sbom"].read_text(encoding="utf-8")) + sbom_doc["metadata"]["component"]["purl"] = "pkg:pypi/other-project@1.2.3" + inputs["sbom"].write_text(json.dumps(sbom_doc), encoding="utf-8") + + with pytest.raises(EvidenceError, match="metadata.component does not identify"): + _build(root, dist, inputs=inputs) + + +def test_release_evidence_rejects_sbom_omitting_transitive_packages(tmp_path): + """A truncated SBOM keeping root + direct deps but dropping a transitive + package must fail even without any dependency graph present.""" + root = _root(tmp_path) + dist = _dist(root) + inputs = _release_inputs(root, dist) + # beta-package reaches the environment only transitively through + # alpha-package; the lock still lists it while the SBOM omits it. + inputs["environment_lock"].write_text( + "alpha-package==1.0\nbeta-package==0.9\nengraphis==1.2.3\n", + encoding="utf-8", + ) + with pytest.raises(EvidenceError, match="truncated closure"): + _build(root, dist, inputs=inputs) + + +def test_release_evidence_rejects_dangling_dependency_graph_ref(tmp_path): + """A dependsOn ref resolving to nothing must fail the dependency-graph check.""" + root = _root(tmp_path) + dist = _dist(root) + inputs = _release_inputs(root, dist) + sbom_doc = json.loads(inputs["sbom"].read_text(encoding="utf-8")) + sbom_doc["components"].append( + { + "type": "library", + "name": "beta-package", + "version": "0.9", + "purl": "pkg:pypi/beta-package@0.9", + } + ) + sbom_doc["dependencies"] = [ + { + "ref": "pkg:pypi/engraphis@1.2.3", + "dependsOn": ["pkg:pypi/alpha-package@1.0"], + }, + { + "ref": "pkg:pypi/alpha-package@1.0", + "dependsOn": ["pkg:pypi/ghost-package@9.9"], + }, + {"ref": "pkg:pypi/beta-package@0.9", "dependsOn": []}, + ] + inputs["sbom"].write_text(json.dumps(sbom_doc), encoding="utf-8") + inputs["environment_lock"].write_text( + "alpha-package==1.0\nbeta-package==0.9\nengraphis==1.2.3\n", + encoding="utf-8", + ) + with pytest.raises(EvidenceError, match="unknown component ref"): + _build(root, dist, inputs=inputs) + + +def test_release_evidence_rejects_dependency_graph_entry_without_component(tmp_path): + """A graph entry must identify the root or a listed SBOM component.""" + root = _root(tmp_path) + dist = _dist(root) + inputs = _release_inputs(root, dist) + sbom_doc = json.loads(inputs["sbom"].read_text(encoding="utf-8")) + sbom_doc["dependencies"] = [ + { + "ref": "pkg:pypi/engraphis@1.2.3", + "dependsOn": ["ghost-ref"], + }, + {"ref": "ghost-ref", "dependsOn": ["pkg:pypi/alpha-package@1.0"]}, + {"ref": "pkg:pypi/alpha-package@1.0", "dependsOn": []}, + ] + inputs["sbom"].write_text(json.dumps(sbom_doc), encoding="utf-8") + + with pytest.raises(EvidenceError, match="unknown component ref"): + _build(root, dist, inputs=inputs) + + +def test_release_evidence_rejects_duplicate_dependency_graph_ref(tmp_path): + """Repeated graph refs must not overwrite an earlier contradictory edge.""" + root = _root(tmp_path) + dist = _dist(root) + inputs = _release_inputs(root, dist) + sbom_doc = json.loads(inputs["sbom"].read_text(encoding="utf-8")) + sbom_doc["dependencies"] = [ + { + "ref": "pkg:pypi/engraphis@1.2.3", + "dependsOn": ["pkg:pypi/ghost-package@9.9"], + }, + { + "ref": "pkg:pypi/engraphis@1.2.3", + "dependsOn": ["pkg:pypi/alpha-package@1.0"], + }, + {"ref": "pkg:pypi/alpha-package@1.0", "dependsOn": []}, + ] + inputs["sbom"].write_text(json.dumps(sbom_doc), encoding="utf-8") + + with pytest.raises(EvidenceError, match="duplicate ref"): + _build(root, dist, inputs=inputs) + + +def test_release_evidence_rejects_component_ref_colliding_with_root(tmp_path): + """A component ref must not alias a root ref and fabricate reachability.""" + root = _root(tmp_path) + dist = _dist(root) + inputs = _release_inputs(root, dist) + sbom_doc = json.loads(inputs["sbom"].read_text(encoding="utf-8")) + sbom_doc["metadata"]["component"]["bom-ref"] = "shared-root-ref" + sbom_doc["components"][0]["bom-ref"] = "shared-root-ref" + sbom_doc["dependencies"] = [ + {"ref": "pkg:pypi/engraphis@1.2.3", "dependsOn": []}, + {"ref": "pkg:pypi/alpha-package@1.0", "dependsOn": []}, + ] + inputs["sbom"].write_text(json.dumps(sbom_doc), encoding="utf-8") + + with pytest.raises(EvidenceError, match="collides with root"): + _build(root, dist, inputs=inputs) + + +def test_release_evidence_rejects_purl_graph_ref_when_bom_ref_is_present(tmp_path): + """Dependency refs must use bom-ref when a component declares one.""" + root = _root(tmp_path) + dist = _dist(root) + inputs = _release_inputs(root, dist) + sbom_doc = json.loads(inputs["sbom"].read_text(encoding="utf-8")) + sbom_doc["metadata"]["component"]["bom-ref"] = "root-component" + sbom_doc["components"][0]["bom-ref"] = "alpha-component" + sbom_doc["dependencies"] = [ + { + "ref": "root-component", + "dependsOn": ["pkg:pypi/alpha-package@1.0"], + }, + {"ref": "alpha-component", "dependsOn": []}, + ] + inputs["sbom"].write_text(json.dumps(sbom_doc), encoding="utf-8") + + with pytest.raises(EvidenceError, match="unknown component ref"): + _build(root, dist, inputs=inputs) + + +def test_release_evidence_rejects_unreachable_declared_dependency(tmp_path): + """A declared dependency present in the SBOM but not reachable from the + project root through the dependency graph must fail.""" + root = _root(tmp_path) + dist = _dist(root) + inputs = _release_inputs(root, dist) + sbom_doc = json.loads(inputs["sbom"].read_text(encoding="utf-8")) + sbom_doc["dependencies"] = [ + {"ref": "pkg:pypi/engraphis@1.2.3", "dependsOn": []}, + {"ref": "pkg:pypi/alpha-package@1.0", "dependsOn": []}, + ] + inputs["sbom"].write_text(json.dumps(sbom_doc), encoding="utf-8") + with pytest.raises(EvidenceError, match="unreachable from the SBOM root"): + _build(root, dist, inputs=inputs) + + +def test_release_evidence_rejects_empty_sbom_package_set(tmp_path): + """An SBOM with no Python components must not pass the subset check.""" + root = _root(tmp_path) + dist = _dist(root) + inputs = _release_inputs(root, dist) + # Replace SBOM with one that has no pypi components + inputs["sbom"].write_text( + json.dumps( + { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [ + {"type": "library", "name": "libssl", "version": "3.0", + "purl": "pkg:deb/debian/libssl@3.0"}, + ], + } + ), + encoding="utf-8", + ) + with pytest.raises(EvidenceError, match="(no Python package components|lacks a valid PyPI PURL)"): + _build(root, dist, inputs=inputs) + + +def test_release_evidence_rejects_sbom_missing_root_component(tmp_path): + """A truncated SBOM that retains one matching dep but omits the root + engraphis component must fail the lock comparison.""" + root = _root(tmp_path) + dist = _dist(root) + inputs = _release_inputs(root, dist) + # SBOM has only alpha-package (which IS in the lock) but no engraphis root. + inputs["sbom"].write_text( + json.dumps( + { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [ + { + "type": "library", + "name": "alpha-package", + "version": "1.0", + "purl": "pkg:pypi/alpha-package@1.0", + }, + ], + } + ), + encoding="utf-8", + ) + with pytest.raises(EvidenceError, match="metadata.component does not identify"): + _build(root, dist, inputs=inputs) + + +def test_release_evidence_rejects_sbom_with_wrong_version_root_component(tmp_path): + """An SBOM whose metadata.component names a different project version must fail.""" + root = _root(tmp_path) + dist = _dist(root) + inputs = _release_inputs(root, dist) + inputs["sbom"].write_text( + json.dumps( + { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "metadata": { + "component": { + "type": "application", + "name": "other-project", + "version": "9.9.9", + "purl": "pkg:pypi/other-project@9.9.9", + }, + }, + "components": [ + { + "type": "library", + "name": "engraphis", + "version": "1.2.3", + "purl": "pkg:pypi/engraphis@1.2.3", + }, + { + "type": "library", + "name": "alpha-package", + "version": "1.0", + "purl": "pkg:pypi/alpha-package@1.0", + }, + ], + } + ), + encoding="utf-8", + ) + with pytest.raises(EvidenceError, match="metadata.component does not identify"): + _build(root, dist, inputs=inputs) + + +def test_release_evidence_rejects_sbom_with_only_root_component(tmp_path): + """An SBOM with valid metadata.component but no dependency components must fail.""" + root = _root(tmp_path) + dist = _dist(root) + inputs = _release_inputs(root, dist) + inputs["sbom"].write_text( + json.dumps( + { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "metadata": { + "component": { + "type": "application", + "name": "engraphis", + "version": "1.2.3", + "purl": "pkg:pypi/engraphis@1.2.3", + }, + }, + "components": [], + } + ), + encoding="utf-8", + ) + with pytest.raises(EvidenceError, match="(no dependency components|missing declared dependencies)"): + _build(root, dist, inputs=inputs) + + +def test_release_evidence_rejects_sbom_missing_declared_dependencies(tmp_path): + """An SBOM missing a declared dependency must fail the closure check.""" + root = _root(tmp_path) + dist = _dist(root) + inputs = _release_inputs(root, dist) + # SBOM has root + pip (not declared) but omits alpha-package (declared) + inputs["sbom"].write_text( + json.dumps( + { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "metadata": { + "component": { + "type": "application", + "name": "engraphis", + "version": "1.2.3", + "purl": "pkg:pypi/engraphis@1.2.3", + }, + }, + "components": [ + {"type": "library", "name": "pip", "version": "26.2", + "purl": "pkg:pypi/pip@26.2"}, + ], + } + ), + encoding="utf-8", + ) + with pytest.raises(EvidenceError, match="missing declared dependencies"): + _build(root, dist, inputs=inputs) + + +def test_release_evidence_rejects_sbom_version_violating_declared_constraint(tmp_path): + """An SBOM whose version violates a declared specifier must fail.""" + root = _root(tmp_path) + dist = _dist(root) + inputs = _release_inputs(root, dist) + # pyproject.toml declares alpha-package>=1.0, but SBOM has 0.5 + inputs["sbom"].write_text( + json.dumps( + { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "metadata": { + "component": { + "type": "application", + "name": "engraphis", + "version": "1.2.3", + "purl": "pkg:pypi/engraphis@1.2.3", + }, + }, + "components": [ + {"type": "library", "name": "alpha-package", "version": "0.5", + "purl": "pkg:pypi/alpha-package@0.5"}, + ], + } + ), + encoding="utf-8", + ) + with pytest.raises(EvidenceError, match="does not satisfy declared constraint"): + _build(root, dist, inputs=inputs) + + +def test_release_evidence_combines_duplicate_declared_constraints(tmp_path): + """Repeated requirements must enforce the intersection of their specifiers.""" + root = _root(tmp_path) + (root / "pyproject.toml").write_text( + '[project]\nname = "engraphis"\nversion = "1.2.3"\n' + 'dependencies = ["alpha-package>=1.0"]\n' + "[project.optional-dependencies]\n" + 'all = ["alpha-package>=2.0"]\n' + 'test = ["alpha-package<3.0"]\n', + encoding="utf-8", + ) + dist = _dist(root) + inputs = _release_inputs(root, dist) + inputs["environment_lock"].write_text( + "alpha-package==1.5\nengraphis==1.2.3\n", encoding="utf-8" + ) + inputs["sbom"].write_text( + json.dumps( + { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "metadata": { + "component": { + "type": "application", + "name": "engraphis", + "version": "1.2.3", + "purl": "pkg:pypi/engraphis@1.2.3", + }, + }, + "components": [ + { + "type": "library", + "name": "alpha-package", + "version": "1.5", + "purl": "pkg:pypi/alpha-package@1.5", + }, + ], + } + ), + encoding="utf-8", + ) + + with pytest.raises(EvidenceError, match="does not satisfy declared constraint"): + _build(root, dist, inputs=inputs) + + +def test_release_evidence_rejects_prerelease_versions_against_pep440_floors(tmp_path): + """A prerelease below the declared floor must not satisfy the constraint.""" + root = _root(tmp_path) + (root / "pyproject.toml").write_text( + '[project]\nname = "engraphis"\nversion = "1.2.3"\n' + 'dependencies = ["alpha-package>=1.0", "numpy>=1.24"]\n', + encoding="utf-8", + ) + dist = _dist(root) + inputs = _release_inputs(root, dist) + inputs["environment_lock"].write_text( + "alpha-package==1.0\nengraphis==1.2.3\nnumpy==1.24rc1\n", + encoding="utf-8", + ) + inputs["sbom"].write_text( + json.dumps( + { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "metadata": { + "component": { + "type": "application", + "name": "engraphis", + "version": "1.2.3", + "purl": "pkg:pypi/engraphis@1.2.3", + }, + }, + "components": [ + { + "type": "library", + "name": "alpha-package", + "version": "1.0", + "purl": "pkg:pypi/alpha-package@1.0", + }, + { + "type": "library", + "name": "numpy", + "version": "1.24rc1", + "purl": "pkg:pypi/numpy@1.24rc1", + }, + ], + } + ), + encoding="utf-8", + ) + + with pytest.raises(EvidenceError, match="does not satisfy declared constraint"): + _build(root, dist, inputs=inputs) + + +def test_release_evidence_requires_selected_extra_dependencies(tmp_path): + """The closure must include requirements from the extras the workflow installs.""" + root = _root(tmp_path) + (root / "pyproject.toml").write_text( + '[project]\nname = "engraphis"\nversion = "1.2.3"\n' + 'dependencies = ["alpha-package>=1.0"]\n' + "[project.optional-dependencies]\n" + "all = [\"extra-dep>=1.0; python_version >= '3.9'\"]\n" + "test = [\"test-dep>=0.1\"]\n", + encoding="utf-8", + ) + dist = _dist(root) + inputs = _release_inputs(root, dist) + # SBOM and lock carry only the core dependency; extra-dep/test-dep are missing. + with pytest.raises(EvidenceError, match="missing declared dependencies"): + _build(root, dist, inputs=inputs) + + +def test_release_evidence_allows_unreachable_selected_extra_dependencies(tmp_path): + """Selected extras must be captured, but need not be root-reachable in CycloneDX.""" + root = _root(tmp_path) + (root / "pyproject.toml").write_text( + '[project]\nname = "engraphis"\nversion = "1.2.3"\n' + 'dependencies = ["alpha-package>=1.0"]\n' + "[project.optional-dependencies]\n" + 'all = ["extra-dep>=1.0"]\n' + 'test = ["test-dep>=0.1"]\n', + encoding="utf-8", + ) + dist = _dist(root) + inputs = _release_inputs(root, dist) + sbom_doc = json.loads(inputs["sbom"].read_text(encoding="utf-8")) + for name, version in (("extra-dep", "1.0"), ("test-dep", "0.1")): + ref = f"pkg:pypi/{name}@{version}" + sbom_doc["components"].append( + {"type": "library", "name": name, "version": version, "purl": ref} + ) + # The capture contains the selected extras, but the installed metadata + # does not promise that those nodes are linked from the project root. + sbom_doc["dependencies"].append({"ref": ref, "dependsOn": []}) + inputs["sbom"].write_text(json.dumps(sbom_doc), encoding="utf-8") + inputs["environment_lock"].write_text( + "alpha-package==1.0\nextra-dep==1.0\ntest-dep==0.1\nengraphis==1.2.3\n", + encoding="utf-8", + ) + + evidence = _build(root, dist, inputs=inputs) + + assert evidence["environment_lock"]["package_count"] == 4 + + +def test_release_evidence_ignores_extra_dependencies_with_inapplicable_markers(tmp_path): + """Extras requirements whose environment marker excludes this interpreter are + not required, mirroring what pip installs in the capture environment.""" + root = _root(tmp_path) + (root / "pyproject.toml").write_text( + '[project]\nname = "engraphis"\nversion = "1.2.3"\n' + 'dependencies = ["alpha-package>=1.0"]\n' + "[project.optional-dependencies]\n" + "all = [\"future-dep>=1.0; python_version < '3.9'\"]\n" + "test = [\"legacy-dep>=0.1; python_version < '3.9'\"]\n", + encoding="utf-8", + ) + dist = _dist(root) + evidence = _build(root, dist) + assert evidence["environment_lock"]["package_count"] == 2 + + +def test_release_evidence_rejects_sbom_with_mismatched_root_purl(tmp_path): + """An SBOM whose metadata.component PURL names a different package must fail.""" + root = _root(tmp_path) + dist = _dist(root) + inputs = _release_inputs(root, dist) + inputs["sbom"].write_text( + json.dumps( + { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "metadata": { + "component": { + "type": "application", + "name": "engraphis", + "version": "1.2.3", + "purl": "pkg:pypi/other-project@1.2.3", + }, + }, + "components": [ + { + "type": "library", + "name": "alpha-package", + "version": "1.0", + "purl": "pkg:pypi/alpha-package@1.0", + }, + ], + } + ), + encoding="utf-8", + ) + with pytest.raises(EvidenceError, match="metadata.component does not identify"): + _build(root, dist, inputs=inputs) + + +def test_release_evidence_rejects_sbom_with_malformed_dependency_purl(tmp_path): + """An SBOM component whose PURL names a different package than its name/version must fail.""" + root = _root(tmp_path) + dist = _dist(root) + inputs = _release_inputs(root, dist) + inputs["sbom"].write_text( + json.dumps( + { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "metadata": { + "component": { + "type": "application", + "name": "engraphis", + "version": "1.2.3", + "purl": "pkg:pypi/engraphis@1.2.3", + }, + }, + "components": [ + { + "type": "library", + "name": "alpha-package", + "version": "1.0", + "purl": "pkg:pypi/other-project@1.0", + }, + ], + } + ), + encoding="utf-8", + ) + with pytest.raises(EvidenceError, match="PURL does not match"): + _build(root, dist, inputs=inputs) + + +def test_release_evidence_rejects_sbom_with_non_pypi_component_purl(tmp_path): + """An SBOM component with a non-PyPI PURL (e.g. pkg:deb) must fail.""" + root = _root(tmp_path) + dist = _dist(root) + inputs = _release_inputs(root, dist) + inputs["sbom"].write_text( + json.dumps( + { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "metadata": { + "component": { + "type": "application", + "name": "engraphis", + "version": "1.2.3", + "purl": "pkg:pypi/engraphis@1.2.3", + }, + }, + "components": [ + { + "type": "library", + "name": "alpha-package", + "version": "1.0", + "purl": "pkg:pypi/alpha-package@1.0", + }, + { + "type": "library", + "name": "libssl", + "version": "3.0", + "purl": "pkg:deb/debian/libssl@3.0", + }, + ], + } + ), + encoding="utf-8", + ) + with pytest.raises(EvidenceError, match="lacks a valid PyPI PURL"): + _build(root, dist, inputs=inputs) + + +def test_release_evidence_rejects_sbom_component_missing_identity(tmp_path): + """A component missing name/version must not disappear from the closure.""" + root = _root(tmp_path) + dist = _dist(root) + inputs = _release_inputs(root, dist) + sbom_doc = json.loads(inputs["sbom"].read_text(encoding="utf-8")) + sbom_doc["components"].append( + {"type": "library", "purl": "pkg:pypi/ghost-package@9.9"} + ) + inputs["sbom"].write_text(json.dumps(sbom_doc), encoding="utf-8") + + with pytest.raises(EvidenceError, match="must identify name and version"): + _build(root, dist, inputs=inputs) + + def test_release_evidence_rejects_partial_or_unbound_container_evidence(tmp_path): root = _root(tmp_path) dist = _dist(root)