diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c31869a..8b1d83c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,11 @@ jobs: - name: Run the full unit suite if: env.HAS_SPLIT_TOKEN == 'true' run: python -m pytest tests/unit -q + env: + # Components were just staged, so an incomplete set is a provisioning failure, not + # a reason to quietly cover less. Without this the suite ran with ZERO renderers + # loaded and still reported green (#22). + INTENTUMDIFF_REQUIRE_ALL_COMPONENTS: "1" - name: Run the reduced suite (no token - components unavailable) # Dependabot and fork PRs get no secrets, so parser components cannot be diff --git a/scripts/provision_build_inputs.py b/scripts/provision_build_inputs.py index 3541ac0..713e9c5 100644 --- a/scripts/provision_build_inputs.py +++ b/scripts/provision_build_inputs.py @@ -222,6 +222,67 @@ def get(url: str, accept: str = "application/vnd.github+json") -> bytes: staged += 1 except urllib.error.HTTPError as exc: missing.append((name, f"HTTP {exc.code} at {stage} ({exc.reason})")) + # ---- renderers ------------------------------------------------------------------- + # Renderers are NOT parser repos - they are crates in intentumdiff-core, published as + # the `renderer-components` artifact since core#26. Nothing fetched them before, so the + # suite ran with ZERO renderers loaded and still reported green: every test touching + # --format html|patch|llm|terminal was skipping, hitting a Python fallback, or asserting + # on degraded output, with nothing distinguishing those from real coverage (#22). + stage = "renderers" + try: + runs = _json.loads( + get(f"{api}/repos/{org}/intentumdiff-core/actions/runs" + f"?status=success&per_page=20") + ).get("workflow_runs", []) + art = None + for run in runs: + arts = _json.loads(get(run["artifacts_url"])) + art = next((a for a in arts.get("artifacts", []) + if a["name"] == "renderer-components" and not a.get("expired")), None) + if art: + break + if art is None: + missing.append(("intentumdiff-core", "no renderer-components artifact")) + else: + blob = get(art["archive_download_url"]) + found = 0 + with zipfile.ZipFile(io.BytesIO(blob)) as z: + for member in z.namelist(): + if not member.endswith(".wasm"): + continue + out = Path(member).name + for _prefix in ("intentumdiff_", "intentdiff_"): + if out.startswith(_prefix): + out = out[len(_prefix):] + break + payload = z.read(member) + digest = _hashlib.sha256(payload).hexdigest() + pinned = pins.get(out) + # Renderers are not all pinned in the registry today. Where a pin EXISTS + # it is enforced exactly as for parsers; where it does not, the component + # is staged and the fact is printed rather than silently accepted, so an + # unpinned component is visible instead of indistinguishable from a + # verified one. + if pinned is not None and pinned != digest: + missing.append(("intentumdiff-core", + f"{out} CHECKSUM MISMATCH (registry pins " + f"{pinned[:16]}..., artifact is {digest[:16]}...)")) + continue + if pinned is None: + print(f" note: {out} is not pinned in the registry (staged unverified)") + (WASM_DEST / out).write_bytes(payload) + staged += 1 + found += 1 + # Fail closed on a partial set. A consumer that stages 3 of 4 renderers loses one + # silently and reports green - the exact failure this exists to end. + _EXPECTED_RENDERERS = 4 + if found < _EXPECTED_RENDERERS: + missing.append(("intentumdiff-core", + f"only {found}/{_EXPECTED_RENDERERS} renderer components in " + f"the artifact")) + except urllib.error.HTTPError as exc: + missing.append(("intentumdiff-core", f"HTTP {exc.code} at {stage} ({exc.reason})")) + print(f"staged {staged} components into {WASM_DEST}") if missing: print(f"MISSING ({len(missing)}):") diff --git a/src/intentumdiff/plugins/registry.py b/src/intentumdiff/plugins/registry.py index 1a8c1b3..e59fdb8 100644 --- a/src/intentumdiff/plugins/registry.py +++ b/src/intentumdiff/plugins/registry.py @@ -29,6 +29,7 @@ import importlib.metadata import logging +import os import re import threading import time @@ -44,7 +45,7 @@ ParserAdapter, RendererAdapter, ) -from intentumdiff.plugins.exceptions import PluginFuelExhausted, PluginNotFoundError +from intentumdiff.plugins.exceptions import PluginFuelExhausted, PluginNotFoundError, PluginLoadError from intentumdiff.plugins.language_metadata import fallback_language_info from intentumdiff.plugins.loader import LoadedPlugin, load_plugin @@ -1046,6 +1047,7 @@ def _load_parsers( def _load_renderers(fuel: int = 10_000_000) -> list[RendererAdapter]: """Discover and instantiate all registered renderer plugins.""" adapters: list[RendererAdapter] = [] + failed: list[str] = [] for ep in importlib.metadata.entry_points(group=_RENDERER_GROUP): try: wasm_path = _wasm_path_from_ep(ep) @@ -1056,6 +1058,22 @@ def _load_renderers(fuel: int = 10_000_000) -> list[RendererAdapter]: logger.debug("Loaded renderer plugin: %s (%s)", ep.name, wasm_path) except Exception as exc: logger.warning("Failed to load renderer plugin %r: %s", ep.name, exc) + failed.append(ep.name) + # A renderer that fails to load used to be a warning and nothing else: the adapter was + # simply omitted and every caller carried on with a smaller set. CI ran the whole suite + # with ZERO renderers and reported green - each test touching --format html|patch|llm| + # terminal was skipping, hitting a Python fallback, or asserting on degraded output, and + # nothing distinguished those from real coverage (#22). + # + # Opt-in rather than always-on, because a user with a partial install should still get a + # working diff - degrading is right for THEM and wrong for a gate. CI sets this so an + # incomplete component set stops the run where the cause is legible. + if failed and os.environ.get("INTENTUMDIFF_REQUIRE_ALL_COMPONENTS") == "1": + raise PluginLoadError( + f"{len(failed)} renderer plugin(s) failed to load: {', '.join(sorted(failed))}. " + "INTENTUMDIFF_REQUIRE_ALL_COMPONENTS=1 is set, so an incomplete component set is " + "an error rather than a silent downgrade - stage the components, or unset it." + ) return adapters