From 9e5b50809f09569654850a570cca8864820fa9da Mon Sep 17 00:00:00 2001
From: David Spencer <1526975+DecisionNerd@users.noreply.github.com>
Date: Mon, 3 Aug 2026 00:30:45 -0600
Subject: [PATCH 1/3] test(ci): measure native binding Rust coverage
---
.github/workflows/test.yml | 3 +
Makefile | 25 +-
.../tests/composite_transaction.test.mjs | 133 +++++++++
.../tests/composite_transaction.py | 141 ++++++++++
docs/development/testing.md | 28 +-
docs/engineering/TESTING.md | 18 ++
scripts/check-coverage-rust.sh | 47 +---
scripts/ci/test-rust-coverage-ledger.py | 136 +++++++++
scripts/coverage-rust.sh | 217 ++++++++++-----
scripts/coverage_rust_ledger.py | 263 ++++++++++++++++++
10 files changed, 885 insertions(+), 126 deletions(-)
create mode 100755 scripts/ci/test-rust-coverage-ledger.py
create mode 100755 scripts/coverage_rust_ledger.py
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 8580bf3c6..f192f3f1e 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -80,6 +80,9 @@ jobs:
- name: Test Rust non-Cypher surface inventory
run: python3 scripts/ci/test-non-cypher-surface-gate.py
+ - name: Test Rust coverage ledger failure modes
+ run: python3 scripts/ci/test-rust-coverage-ledger.py
+
- name: Test Python and Node non-Cypher parity policy
run: python3 scripts/ci/check-binding-parity-policy.py
diff --git a/Makefile b/Makefile
index 60ed7fef5..861de685f 100644
--- a/Makefile
+++ b/Makefile
@@ -67,6 +67,8 @@ test-tck: ## Run TCK compliance tests via Rust BDD runner
# Multi-surface coverage thresholds (#742 §2). Override per surface as needed.
COVERAGE_FAIL_UNDER_RUST ?= 85
+COVERAGE_FAIL_UNDER_RUST_PYTHON_ADAPTER ?= 80
+COVERAGE_FAIL_UNDER_RUST_NODE_ADAPTER ?= 80
COVERAGE_FAIL_UNDER_PYTHON ?= 85
COVERAGE_FAIL_UNDER_NODE ?= 85
# Back-compat alias used by coverage-python.
@@ -88,7 +90,7 @@ coverage: ## Rust + Python + Node coverage with per-surface thresholds
@echo "━━━ Node JS surface coverage ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@$(MAKE) coverage-node
@echo "━━━ Coverage complete ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
- @echo "Rust: build/coverage-rust/ (lcov + html + summary)"
+ @echo "Rust: build/coverage-rust/ (ledger + merged lcov + core HTML + summary)"
@echo "Python: coverage.xml + htmlcov/"
@echo "Node: crates/graphforge-bindings-node/coverage/"
@echo "✅ All surfaces collected; thresholds enforced per surface"
@@ -96,7 +98,7 @@ coverage: ## Rust + Python + Node coverage with per-surface thresholds
coverage-python: ## Run unit tests with Python wrapper coverage (requires maturin develop)
@$(MAKE) _ensure-graphforge
uv run coverage erase
- uv run pytest tests/unit \
+ uv run pytest tests/unit crates/graphforge-bindings-py/tests/cli_entrypoint.py \
-n $${PYTEST_WORKERS:-4} \
--cov=$(PYTHON_COVERAGE_SRC) --cov-branch \
--cov-report=term-missing \
@@ -137,10 +139,13 @@ check-coverage-python: ## Validate Python wrapper coverage (≥85% default)
(echo "❌ Python wrapper coverage below $(COVERAGE_FAIL_UNDER_PYTHON)%" && exit 1)
@echo "✅ Python wrapper coverage meets threshold"
-check-coverage-rust: ## Validate Rust line coverage from llvm-cov summary (≥85% default)
- @test -f build/coverage-rust/summary.txt || \
- (echo "❌ Missing build/coverage-rust/summary.txt — run make coverage-rust first"; exit 1)
- @COVERAGE_FAIL_UNDER_RUST=$(COVERAGE_FAIL_UNDER_RUST) bash scripts/check-coverage-rust.sh
+check-coverage-rust: ## Validate core Rust (≥85%) and each native adapter (≥80%)
+ @test -f build/coverage-rust/ledger.json || \
+ (echo "❌ Missing build/coverage-rust/ledger.json — run make coverage-rust first"; exit 1)
+ @COVERAGE_FAIL_UNDER_RUST=$(COVERAGE_FAIL_UNDER_RUST) \
+ COVERAGE_FAIL_UNDER_RUST_PYTHON_ADAPTER=$(COVERAGE_FAIL_UNDER_RUST_PYTHON_ADAPTER) \
+ COVERAGE_FAIL_UNDER_RUST_NODE_ADAPTER=$(COVERAGE_FAIL_UNDER_RUST_NODE_ADAPTER) \
+ bash scripts/check-coverage-rust.sh
coverage-strict: ## Strict 90% coverage check for new features
@echo "Checking strict coverage (90%)..."
@@ -205,6 +210,7 @@ pre-push-fast: ## Run fast checks only — format, lint, type, security, docstr
pre-push: ## Run local policy checks plus multi-surface coverage thresholds
@$(MAKE) pre-push-fast
+ @uv run --no-sync python scripts/ci/test-rust-coverage-ledger.py
@echo "━━━ Coverage + thresholds (Rust + Python + Node) ━━━━━━━━━━━━━━━━━━━━━━━"
@$(MAKE) coverage
@echo "━━━ Public API BDD mutation sentinels ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@@ -242,8 +248,11 @@ cargo-test: ## Run all Rust workspace tests
# cargo install cargo-llvm-cov
# rustup component add llvm-tools-preview
# Prefer an isolated CARGO_TARGET_DIR when other builds are running (AGENTS.md).
-coverage-rust: ## Workspace Rust coverage via cargo-llvm-cov (term + lcov + html under build/coverage-rust)
- @COVERAGE_FAIL_UNDER_RUST=$(COVERAGE_FAIL_UNDER_RUST) bash scripts/coverage-rust.sh
+coverage-rust: ## Core + same-SHA Python/Node adapter Rust coverage ledger
+ @COVERAGE_FAIL_UNDER_RUST=$(COVERAGE_FAIL_UNDER_RUST) \
+ COVERAGE_FAIL_UNDER_RUST_PYTHON_ADAPTER=$(COVERAGE_FAIL_UNDER_RUST_PYTHON_ADAPTER) \
+ COVERAGE_FAIL_UNDER_RUST_NODE_ADAPTER=$(COVERAGE_FAIL_UNDER_RUST_NODE_ADAPTER) \
+ bash scripts/coverage-rust.sh
@$(MAKE) check-coverage-rust
bench-traversal: ## Run the #767 traversal scaling benchmark (release, manual; see benchmarks/traversal_scaling.md)
diff --git a/crates/graphforge-bindings-node/tests/composite_transaction.test.mjs b/crates/graphforge-bindings-node/tests/composite_transaction.test.mjs
index 7c7b4b9af..f312c7342 100644
--- a/crates/graphforge-bindings-node/tests/composite_transaction.test.mjs
+++ b/crates/graphforge-bindings-node/tests/composite_transaction.test.mjs
@@ -264,3 +264,136 @@ test("Node exposes one composite publish entrypoint with no inference helpers",
assert.equal(typeof forge.publishCompositeKnowledgeTransaction, "undefined");
assert.equal(typeof forge.inferCompositeParticipants, "undefined");
});
+
+test("every explicit composite participant family crosses the Node adapter", async () => {
+ const project = mkdtempSync(
+ join(tmpdir(), "gf-composite-participants-node-"),
+ );
+ try {
+ const forge = new GraphForge(project);
+ await enableCapabilities(forge);
+ const operationUuid = "018f0f4e-7b8c-7000-8000-00000000e001";
+ const provenanceUuid = "018f0f4e-7b8c-7000-8000-00000000e002";
+ const assertionUuid = "018f0f4e-7b8c-7000-8000-00000000e003";
+ const confidenceUuid = "018f0f4e-7b8c-7000-8000-00000000e004";
+ const reasoningUuid = "018f0f4e-7b8c-7000-8000-00000000e005";
+ const groupUuid = "018f0f4e-7b8c-7000-8000-00000000e006";
+ const before = projectDigest(project);
+ assert.throws(
+ () =>
+ forge.publishCompositeTransaction({
+ contractVersion: 1,
+ operationUuid,
+ graphMutations: [
+ {
+ kind: "create_node",
+ nodeUuid: "018f0f4e-7b8c-7000-8000-00000000e010",
+ label: "Person",
+ properties: { name: "Grace" },
+ },
+ ],
+ knowledge: {
+ confidenceAssessments: [
+ {
+ confidenceUuid,
+ assertionUuid,
+ policy: "conservative_min",
+ value: 0.5,
+ provenanceUuid,
+ recordedAtMicros: RECORDED_AT,
+ },
+ ],
+ confidenceInputs: [
+ {
+ confidenceUuid,
+ inputConfidenceUuid: "018f0f4e-7b8c-7000-8000-00000000e007",
+ inputValue: 0.5,
+ ordinal: 0,
+ },
+ ],
+ evidence: [
+ {
+ evidenceUuid: "018f0f4e-7b8c-7000-8000-00000000e008",
+ assertionUuid,
+ sourceUuid: "018f0f4e-7b8c-7000-8000-00000000e009",
+ sourceKind: "document",
+ role: "supports",
+ weight: 0.8,
+ provenanceUuid,
+ recordedAtMicros: RECORDED_AT,
+ },
+ ],
+ reasoning: [
+ {
+ reasoningUuid,
+ assertionUuid,
+ kind: "methodological_note",
+ contentFormat: "text/plain",
+ content: Buffer.from("explicit participant conversion"),
+ provenanceUuid,
+ recordedAtMicros: RECORDED_AT,
+ },
+ ],
+ assertionSupersessions: [
+ {
+ supersessionUuid: "018f0f4e-7b8c-7000-8000-00000000e00a",
+ priorAssertionUuid: assertionUuid,
+ replacementAssertionUuid:
+ "018f0f4e-7b8c-7000-8000-00000000e00b",
+ statusEventUuid: "018f0f4e-7b8c-7000-8000-00000000e00c",
+ reasoningUuid,
+ provenanceUuid,
+ recordedAtMicros: RECORDED_AT,
+ },
+ ],
+ hypothesisGroups: [
+ {
+ groupUuid,
+ questionKey: "which hypothesis",
+ provenanceUuid,
+ recordedAtMicros: RECORDED_AT,
+ },
+ ],
+ hypothesisMembership: [
+ {
+ membershipEventUuid: "018f0f4e-7b8c-7000-8000-00000000e00d",
+ operationUuid,
+ groupUuid,
+ assertionUuid,
+ action: "added",
+ reasoningUuid,
+ provenanceUuid,
+ recordedAtMicros: RECORDED_AT,
+ },
+ ],
+ hypothesisSelection: [
+ {
+ selectionEventUuid: "018f0f4e-7b8c-7000-8000-00000000e00e",
+ operationUuid,
+ groupUuid,
+ selectedAssertionUuid: assertionUuid,
+ reasoningUuid,
+ provenanceUuid,
+ recordedAtMicros: RECORDED_AT,
+ },
+ ],
+ assertionValidity: [
+ {
+ validityEventUuid: "018f0f4e-7b8c-7000-8000-00000000e00f",
+ assertionUuid,
+ validFromMicros: 1,
+ validToMicros: 2,
+ reasoningUuid,
+ provenanceUuid,
+ recordedAtMicros: RECORDED_AT,
+ },
+ ],
+ },
+ }),
+ (error) => error?.code === "GF_NOT_FOUND",
+ );
+ assert.equal(projectDigest(project), before);
+ } finally {
+ rmSync(project, { recursive: true, force: true });
+ }
+});
diff --git a/crates/graphforge-bindings-py/tests/composite_transaction.py b/crates/graphforge-bindings-py/tests/composite_transaction.py
index 54f40c755..390be22b1 100644
--- a/crates/graphforge-bindings-py/tests/composite_transaction.py
+++ b/crates/graphforge-bindings-py/tests/composite_transaction.py
@@ -247,6 +247,17 @@ def check_composite_transaction(project: Path) -> None:
)
assert project_digest(project) == before
+ try:
+ forge.publish_composite_transaction(
+ operation_uuid="018f0f4e-7b8c-7000-8000-00000000e020",
+ graph_mutations=[{"kind": "unknown_mutation"}],
+ )
+ except TypeError as exc:
+ assert "unknown composite graph mutation kind" in str(exc)
+ else:
+ raise SystemExit("expected an unknown composite mutation TypeError")
+ assert project_digest(project) == before
+
# Invalid identity (nil operation UUID) stays in Rust validation.
expect_code(
"GF_VALIDATION",
@@ -358,10 +369,140 @@ def check_strict_ontology_composite(project: Path) -> None:
assert project_digest(project) == before
+def check_all_explicit_participant_conversions(project: Path) -> None:
+ """Every documented participant family crosses the thin adapter."""
+ project.mkdir()
+ forge = g.GraphForge(str(project))
+ enable_capabilities(forge)
+ operation = "018f0f4e-7b8c-7000-8000-00000000e001"
+ provenance = "018f0f4e-7b8c-7000-8000-00000000e002"
+ assertion = "018f0f4e-7b8c-7000-8000-00000000e003"
+ confidence = "018f0f4e-7b8c-7000-8000-00000000e004"
+ reasoning = "018f0f4e-7b8c-7000-8000-00000000e005"
+ group = "018f0f4e-7b8c-7000-8000-00000000e006"
+ before = project_digest(project)
+
+ # These rows are structurally valid at the language boundary but deliberately
+ # reference an absent provenance event. Rust must reject the entire request
+ # without publishing the graph mutation.
+ expect_code(
+ "GF_NOT_FOUND",
+ lambda: forge.publish_composite_transaction(
+ operation_uuid=operation,
+ graph_mutations=[
+ {
+ "kind": "create_node",
+ "node_uuid": "018f0f4e-7b8c-7000-8000-00000000e010",
+ "label": "Person",
+ "properties": {"name": "Grace"},
+ }
+ ],
+ knowledge={
+ "confidence_assessments": [
+ {
+ "confidence_uuid": confidence,
+ "assertion_uuid": assertion,
+ "policy": "conservative_min",
+ "value": 0.5,
+ "provenance_uuid": provenance,
+ "recorded_at_micros": RECORDED_AT,
+ }
+ ],
+ "confidence_inputs": [
+ {
+ "confidence_uuid": confidence,
+ "input_confidence_uuid": "018f0f4e-7b8c-7000-8000-00000000e007",
+ "input_value": 0.5,
+ "ordinal": 0,
+ }
+ ],
+ "evidence": [
+ {
+ "evidence_uuid": "018f0f4e-7b8c-7000-8000-00000000e008",
+ "assertion_uuid": assertion,
+ "source_uuid": "018f0f4e-7b8c-7000-8000-00000000e009",
+ "source_kind": "document",
+ "role": "supports",
+ "weight": 0.8,
+ "provenance_uuid": provenance,
+ "recorded_at_micros": RECORDED_AT,
+ }
+ ],
+ "reasoning": [
+ {
+ "reasoning_uuid": reasoning,
+ "assertion_uuid": assertion,
+ "kind": "methodological_note",
+ "content_format": "text/plain",
+ "content": b"explicit participant conversion",
+ "provenance_uuid": provenance,
+ "recorded_at_micros": RECORDED_AT,
+ }
+ ],
+ "assertion_supersessions": [
+ {
+ "supersession_uuid": "018f0f4e-7b8c-7000-8000-00000000e00a",
+ "prior_assertion_uuid": assertion,
+ "replacement_assertion_uuid": "018f0f4e-7b8c-7000-8000-00000000e00b",
+ "status_event_uuid": "018f0f4e-7b8c-7000-8000-00000000e00c",
+ "reasoning_uuid": reasoning,
+ "provenance_uuid": provenance,
+ "recorded_at_micros": RECORDED_AT,
+ }
+ ],
+ "hypothesis_groups": [
+ {
+ "group_uuid": group,
+ "question_key": "which hypothesis",
+ "provenance_uuid": provenance,
+ "recorded_at_micros": RECORDED_AT,
+ }
+ ],
+ "hypothesis_membership": [
+ {
+ "membership_event_uuid": "018f0f4e-7b8c-7000-8000-00000000e00d",
+ "operation_uuid": operation,
+ "group_uuid": group,
+ "assertion_uuid": assertion,
+ "action": "added",
+ "reasoning_uuid": reasoning,
+ "provenance_uuid": provenance,
+ "recorded_at_micros": RECORDED_AT,
+ }
+ ],
+ "hypothesis_selection": [
+ {
+ "selection_event_uuid": "018f0f4e-7b8c-7000-8000-00000000e00e",
+ "operation_uuid": operation,
+ "group_uuid": group,
+ "selected_assertion_uuid": assertion,
+ "reasoning_uuid": reasoning,
+ "provenance_uuid": provenance,
+ "recorded_at_micros": RECORDED_AT,
+ }
+ ],
+ "assertion_validity": [
+ {
+ "validity_event_uuid": "018f0f4e-7b8c-7000-8000-00000000e00f",
+ "assertion_uuid": assertion,
+ "valid_from": 1,
+ "valid_to": 2,
+ "reasoning_uuid": reasoning,
+ "provenance_uuid": provenance,
+ "recorded_at_micros": RECORDED_AT,
+ }
+ ],
+ },
+ ),
+ )
+ assert project_digest(project) == before
+
+
if __name__ == "__main__":
check_no_inference_helpers()
with tempfile.TemporaryDirectory(prefix="gf-composite-py-") as directory:
check_composite_transaction(Path(directory))
+ check_all_explicit_participant_conversions(Path(directory) / "participants")
onto_root = Path(directory) / "strict"
onto_root.mkdir()
project = onto_root / "project"
diff --git a/docs/development/testing.md b/docs/development/testing.md
index 7b455e0f9..1511e8315 100644
--- a/docs/development/testing.md
+++ b/docs/development/testing.md
@@ -197,9 +197,9 @@ make test-unit
make test-integration
make test-tck
-# With coverage (Rust + Python + Node; ≥85% lines per surface)
+# With coverage (core Rust ≥85%; Rust adapters ≥80%; wrappers ≥85%)
make coverage # all surfaces + thresholds
-make coverage-rust # cargo llvm-cov → build/coverage-rust/
+make coverage-rust # core + same-SHA native adapter ledger
make coverage-python # pytest-cov on graphforge-bindings-py/python/graphforge
make coverage-node # c8 on @curatelabs/graphforge lib/ (needs *.node)
make coverage-report # open Python HTML report
@@ -229,17 +229,31 @@ def tmp_db(tmp_path):
### Coverage Requirements
-Local `make coverage` instruments three surfaces independently (pytest does **not**
-cover Rust; Codecov CI is separate/out of scope for this gate):
+Local `make coverage` instruments the shipped surfaces independently. The Rust
+run builds PyO3 and napi-rs once with LLVM instrumentation and executes the real
+Python and Node native acceptance suites against those exact artifacts. The
+command defaults to an isolated target under `build/coverage-rust/`; set
+`CARGO_TARGET_DIR` to choose a different isolated location.
| Surface | Tool | Scope | Threshold |
|---------|------|-------|-----------|
-| Rust | `cargo llvm-cov` | workspace crates via `cargo test --workspace` | ≥85% lines |
+| Core Rust | `cargo llvm-cov` | workspace Rust excluding binding adapters | ≥85% lines |
+| Python adapter Rust | `cargo llvm-cov` + native acceptance | `graphforge-bindings-py` Rust | ≥80% lines |
+| Node adapter Rust | `cargo llvm-cov` + native acceptance | `graphforge-bindings-node` Rust | ≥80% lines |
| Python | `pytest-cov` | `crates/graphforge-bindings-py/python/graphforge` | ≥85% lines; ≥90% patch on changed wrapper files |
| Node | `c8` | hand-written `@curatelabs/graphforge` `lib/**/*.mjs` | ≥85% lines |
-Override floors with `COVERAGE_FAIL_UNDER_RUST`, `COVERAGE_FAIL_UNDER_PYTHON`,
-and `COVERAGE_FAIL_UNDER_NODE`.
+`build/coverage-rust/ledger.json` is the machine-readable source of truth. It
+records the exact source SHA, toolchain, artifact hashes, profile counts, and
+separate core, adapter, and merged-workspace totals. `summary.txt` is the human
+view and `lcov.info` is the merged report. A high workspace average never
+substitutes for a missing profile or a failed adapter floor; stale, empty,
+wrong-artifact, and wrong-SHA evidence fails closed.
+
+Override floors with `COVERAGE_FAIL_UNDER_RUST`,
+`COVERAGE_FAIL_UNDER_RUST_PYTHON_ADAPTER`,
+`COVERAGE_FAIL_UNDER_RUST_NODE_ADAPTER`, `COVERAGE_FAIL_UNDER_PYTHON`, and
+`COVERAGE_FAIL_UNDER_NODE`.
### Required Checks (all PRs)
diff --git a/docs/engineering/TESTING.md b/docs/engineering/TESTING.md
index 5943cd5f7..fdf330c75 100644
--- a/docs/engineering/TESTING.md
+++ b/docs/engineering/TESTING.md
@@ -165,6 +165,24 @@ weakened assertions (`AGENTS.md`).
## Behavior coverage
+### Rust coverage evidence
+
+`make coverage-rust` measures four explicit totals: core Rust, Python adapter
+Rust, Node adapter Rust, and their merged workspace. The adapter totals come
+from the functional native acceptance suites—not placeholder binding tests—and
+therefore include persistence/reopen, structured lifecycle errors, parity, and
+no-fallback behavior executed through the instrumented PyO3 and napi-rs
+artifacts.
+
+The run uses an isolated `CARGO_TARGET_DIR` (defaulting under its output tree),
+builds each native artifact once, and verifies that the loaded artifact hash
+matches the measured object.
+`build/coverage-rust/ledger.json` also binds the evidence to `HEAD` and the LLVM
+toolchain. Missing, empty, stale, wrong-artifact, or wrong-SHA adapter profiles
+fail before totals are accepted. Core has an 85% floor and each Rust binding
+adapter has an independent 80% floor; the merged workspace percentage cannot
+average away a failed surface.
+
| Experience / Requirement | Scenario (Given/When/Then) | Test / evidence |
| ------------------------ | -------------------------- | ---- |
| FR-1 Cypher → Arrow | Given a graph, when `execute` runs, then Arrow rows match | Workspace/`graphforge-api` query tests; TCK corpus |
diff --git a/scripts/check-coverage-rust.sh b/scripts/check-coverage-rust.sh
index c7e891636..b4fc55682 100755
--- a/scripts/check-coverage-rust.sh
+++ b/scripts/check-coverage-rust.sh
@@ -1,42 +1,13 @@
#!/usr/bin/env bash
-# Parse cargo-llvm-cov term summary and enforce a line-coverage floor.
+# Revalidate the same-SHA per-surface Rust coverage ledger.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-SUMMARY="${COVERAGE_RUST_SUMMARY:-$ROOT/build/coverage-rust/summary.txt}"
-MIN="${COVERAGE_FAIL_UNDER_RUST:-85}"
-
-if [[ ! -f "${SUMMARY}" ]]; then
- echo "❌ Missing Rust coverage summary at ${SUMMARY}"
- exit 1
-fi
-
-# Match the TOTAL row from llvm-cov's text table, e.g.:
-# TOTAL 1234 100 200 50 800 100 85.00%
-line_pct="$(
- awk '
- /^TOTAL/ {
- for (i = NF; i >= 1; i--) {
- if ($i ~ /%$/) {
- gsub(/%/, "", $i)
- print $i
- exit
- }
- }
- }
- ' "${SUMMARY}"
-)"
-
-if [[ -z "${line_pct}" ]]; then
- echo "❌ Could not parse TOTAL line coverage from ${SUMMARY}"
- exit 1
-fi
-
-echo "Rust lines: ${line_pct}%"
-awk -v pct="${line_pct}" -v min="${MIN}" 'BEGIN {
- if (pct + 0 < min + 0) {
- printf("❌ Rust coverage below %s%% (got %s%%)\n", min, pct)
- exit 1
- }
- printf("✅ Rust coverage meets %s%% threshold\n", min)
-}'
+LEDGER="${COVERAGE_RUST_LEDGER:-$ROOT/build/coverage-rust/ledger.json}"
+
+python3 "$ROOT/scripts/coverage_rust_ledger.py" \
+ --root "$ROOT" \
+ --ledger "$LEDGER" \
+ --core-floor "${COVERAGE_FAIL_UNDER_RUST:-85}" \
+ --python-floor "${COVERAGE_FAIL_UNDER_RUST_PYTHON_ADAPTER:-80}" \
+ --node-floor "${COVERAGE_FAIL_UNDER_RUST_NODE_ADAPTER:-80}"
diff --git a/scripts/ci/test-rust-coverage-ledger.py b/scripts/ci/test-rust-coverage-ledger.py
new file mode 100755
index 000000000..6ca8595a0
--- /dev/null
+++ b/scripts/ci/test-rust-coverage-ledger.py
@@ -0,0 +1,136 @@
+#!/usr/bin/env python3
+"""Deterministic mutation sentinels for the Rust coverage ledger."""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+import os
+from pathlib import Path
+import tempfile
+import time
+from types import SimpleNamespace
+
+ROOT = Path(__file__).resolve().parents[2]
+SPEC = importlib.util.spec_from_file_location(
+ "coverage_rust_ledger", ROOT / "scripts" / "coverage_rust_ledger.py"
+)
+assert SPEC is not None and SPEC.loader is not None
+ledger_module = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(ledger_module)
+
+
+def expect_error(fragment: str, call) -> None:
+ try:
+ call()
+ except ledger_module.LedgerError as error:
+ assert fragment in str(error), (fragment, str(error))
+ else:
+ raise AssertionError(f"expected LedgerError containing {fragment!r}")
+
+
+def main() -> None:
+ head = ledger_module.git_head(ROOT)
+ with tempfile.TemporaryDirectory(prefix="gf-rust-coverage-ledger-") as directory:
+ temp = Path(directory)
+ artifact = temp / "adapter.so"
+ runtime = temp / "loaded.so"
+ profile = temp / "adapter.profraw"
+ artifact.write_bytes(b"same instrumented artifact")
+ runtime.write_bytes(artifact.read_bytes())
+ time.sleep(0.01)
+ profile.write_bytes(b"non-empty profile")
+ artifact_hash = ledger_module.sha256(artifact)
+ evidence = {
+ "source_sha": head,
+ "rustc": "rustc test",
+ "cargo_llvm_cov": "cargo-llvm-cov test",
+ "surfaces": {
+ name: {
+ "artifact": str(artifact),
+ "runtime_artifact": str(runtime),
+ "artifact_sha256": artifact_hash,
+ "profiles": [str(profile)],
+ }
+ for name in ("python_adapter", "node_adapter")
+ },
+ }
+
+ ledger_module.validate_evidence(evidence, ROOT, head)
+
+ mutated = json.loads(json.dumps(evidence))
+ mutated["source_sha"] = "0" * 40
+ expect_error("source SHA", lambda: ledger_module.validate_evidence(mutated, ROOT, head))
+
+ mutated = json.loads(json.dumps(evidence))
+ mutated["surfaces"].pop("node_adapter")
+ expect_error(
+ "missing node_adapter", lambda: ledger_module.validate_evidence(mutated, ROOT, head)
+ )
+
+ empty = temp / "empty.profraw"
+ empty.touch()
+ mutated = json.loads(json.dumps(evidence))
+ mutated["surfaces"]["python_adapter"]["profiles"] = [str(empty)]
+ expect_error(
+ "missing or empty", lambda: ledger_module.validate_evidence(mutated, ROOT, head)
+ )
+
+ stale = temp / "stale.profraw"
+ stale.write_bytes(b"old profile")
+ old = artifact.stat().st_mtime - 10
+ os.utime(stale, (old, old))
+ mutated = json.loads(json.dumps(evidence))
+ mutated["surfaces"]["node_adapter"]["profiles"] = [str(stale)]
+ expect_error("predates", lambda: ledger_module.validate_evidence(mutated, ROOT, head))
+
+ mutated = json.loads(json.dumps(evidence))
+ mutated["surfaces"]["python_adapter"]["artifact_sha256"] = "bad"
+ expect_error("artifact hash", lambda: ledger_module.validate_evidence(mutated, ROOT, head))
+
+ lcov = temp / "valid.lcov"
+ lcov.write_text(
+ f"SF:{ROOT / 'crates/graphforge-api/src/lib.rs'}\nDA:1,1\nend_of_record\n",
+ encoding="utf-8",
+ )
+ assert ledger_module.parse_lcov(lcov, ROOT)
+ malformed = temp / "malformed.lcov"
+ malformed.write_text("SF:no-lines.rs\nend_of_record\n", encoding="utf-8")
+ expect_error("no executable lines", lambda: ledger_module.parse_lcov(malformed, ROOT))
+
+ args = SimpleNamespace(
+ root=ROOT,
+ core_floor=85.0,
+ python_floor=80.0,
+ node_floor=80.0,
+ )
+ valid_ledger = {
+ "schema_version": 1,
+ "source_sha": head,
+ "surfaces": {
+ name: {"measured_lines": 100, "line_percent": 90.0}
+ for name in ("core", "python_adapter", "node_adapter")
+ },
+ }
+ ledger_module.validate_floors(valid_ledger, args)
+
+ mutated = json.loads(json.dumps(valid_ledger))
+ mutated["surfaces"]["python_adapter"]["line_percent"] = 79.99
+ expect_error(
+ "python_adapter Rust coverage below",
+ lambda: ledger_module.validate_floors(mutated, args),
+ )
+
+ mutated = json.loads(json.dumps(valid_ledger))
+ mutated["surfaces"]["node_adapter"]["measured_lines"] = 0
+ expect_error("malformed node_adapter", lambda: ledger_module.validate_floors(mutated, args))
+
+ mutated = json.loads(json.dumps(valid_ledger))
+ mutated["schema_version"] = 99
+ expect_error("unsupported or missing", lambda: ledger_module.validate_floors(mutated, args))
+
+ print("rust coverage ledger mutation sentinels: ok")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/coverage-rust.sh b/scripts/coverage-rust.sh
index 214a0b736..115c7d343 100755
--- a/scripts/coverage-rust.sh
+++ b/scripts/coverage-rust.sh
@@ -1,98 +1,169 @@
#!/usr/bin/env bash
-# Run workspace Rust tests under cargo-llvm-cov and emit term + lcov + HTML.
-# Prerequisites:
-# cargo install cargo-llvm-cov
-# rustup component add llvm-tools-preview
-#
-# Outputs (gitignored via build/):
-# build/coverage-rust/lcov.info
-# build/coverage-rust/html/index.html
-# build/coverage-rust/summary.txt
+# Measure core Rust plus the Rust code executed by real Python and Node acceptance.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
OUT_DIR="${COVERAGE_RUST_DIR:-build/coverage-rust}"
-LCOV_PATH="${OUT_DIR}/lcov.info"
-# cargo-llvm-cov appends /html under --output-dir
-HTML_DIR="${OUT_DIR}/html"
-SUMMARY_PATH="${OUT_DIR}/summary.txt"
-# Default: whole workspace. Override for focused runs, e.g. COVERAGE_RUST_ARGS='-p graphforge-api'
+mkdir -p "$OUT_DIR"
+OUT_DIR="$(cd "$OUT_DIR" && pwd)"
+PROFILE_DIR="$OUT_DIR/profiles"
+CORE_LCOV="$OUT_DIR/core.lcov.info"
+PYTHON_LCOV="$OUT_DIR/python-adapter.lcov.info"
+NODE_LCOV="$OUT_DIR/node-adapter.lcov.info"
+WORKSPACE_LCOV="$OUT_DIR/lcov.info"
+LEDGER="$OUT_DIR/ledger.json"
+EVIDENCE="$OUT_DIR/evidence.json"
+SUMMARY="$OUT_DIR/summary.txt"
COVERAGE_RUST_ARGS="${COVERAGE_RUST_ARGS:---workspace}"
+PYTHON_FLOOR="${COVERAGE_FAIL_UNDER_RUST_PYTHON_ADAPTER:-80}"
+NODE_FLOOR="${COVERAGE_FAIL_UNDER_RUST_NODE_ADAPTER:-80}"
+CORE_FLOOR="${COVERAGE_FAIL_UNDER_RUST:-85}"
-if ! command -v cargo-llvm-cov >/dev/null 2>&1 && ! cargo llvm-cov --version >/dev/null 2>&1; then
- echo "❌ cargo-llvm-cov not found."
- echo " Install with: cargo install cargo-llvm-cov"
- echo " Also require: rustup component add llvm-tools-preview"
+if ! cargo llvm-cov --version >/dev/null 2>&1; then
+ echo "Rust coverage evidence error: cargo-llvm-cov is not installed" >&2
exit 1
fi
-
if ! rustup component list --installed 2>/dev/null | grep -Eq '^llvm-tools'; then
- echo "❌ rustup llvm-tools component not installed."
- echo " Install with: rustup component add llvm-tools-preview"
+ echo "Rust coverage evidence error: rustup llvm-tools is not installed" >&2
exit 1
fi
+export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$OUT_DIR/native-target}"
+CORE_TARGET_DIR="$CARGO_TARGET_DIR/llvm-cov-target"
-mkdir -p "${OUT_DIR}"
-rm -rf "${HTML_DIR}" "${LCOV_PATH}" "${SUMMARY_PATH}"
-
-echo "━━━ Rust coverage (cargo llvm-cov ${COVERAGE_RUST_ARGS}) ━━━━━━━━━━━━━━━"
-echo " CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-