From 611e108c6f88482089de749e57bef8e614c03a03 Mon Sep 17 00:00:00 2001 From: Daniel Sagenschneider Date: Sat, 29 Aug 2026 17:10:15 +0800 Subject: [PATCH] External storage --- impact_gate/cli.py | 17 ++-- impact_gate/providers.py | 163 +++++++++++++++++++++++++++++++++++++++ tests/test_providers.py | 90 +++++++++++++++++++++ 3 files changed, 264 insertions(+), 6 deletions(-) create mode 100644 impact_gate/providers.py create mode 100644 tests/test_providers.py diff --git a/impact_gate/cli.py b/impact_gate/cli.py index 38bf8f8..9c5e7bd 100644 --- a/impact_gate/cli.py +++ b/impact_gate/cli.py @@ -15,7 +15,7 @@ from .core.config import MeasureConfig -from . import __version__, baseline, gitio, report +from . import __version__, baseline, gitio, providers, report from .config import ENFORCEMENTS, GateConfig from .engine import score_change @@ -54,7 +54,9 @@ def _add_score_args(p: argparse.ArgumentParser) -> None: def _resolve_config(args) -> GateConfig: - cfg = GateConfig.load(args.config, args.repo) + # Base policy from the policy port (local .impact-gate.yml today); CLI flags are the + # outermost layer and win over whatever the provider supplied. + cfg = providers.select_policy_provider(args.config, args.repo).policy(args.repo) for attr in _OVERRIDE_ATTRS: val = getattr(args, attr, None) if val is not None: @@ -76,9 +78,11 @@ def _cmd_score(args) -> int: grade = None if cfg.curve_enabled: - bl = baseline.load_baseline(os.path.join(args.repo, cfg.baseline_file)) - grade = baseline.grade_change(score, baseline=bl, - prior_weight_K=cfg.curve_prior_weight) + grades = providers.select_grade_provider(args.repo, cfg.baseline_file, + cfg.curve_prior_weight) + grade = grades.grade(providers.ChangeSummary.of(score), args.repo) + if grade is None: # backend unreachable -> shipped-seed only + grade = providers.seed_grade(score, cfg.curve_prior_weight) level = cfg.level_for_grade(grade.percentile) blocked = cfg.blocks_grade(grade.percentile) else: @@ -120,7 +124,8 @@ def _cmd_baseline(args) -> int: "baseline. Check --base-ref points at a branch with history.", file=sys.stderr) return 1 - baseline.save_baseline(bl, out_path) + providers.select_grade_provider(args.repo, cfg.baseline_file, + cfg.curve_prior_weight).publish(args.repo, bl) print(f"impact-gate: baseline written to {out_path} " f"({bl.n} observations from '{bl.base_ref}').") return 0 diff --git a/impact_gate/providers.py b/impact_gate/providers.py new file mode 100644 index 0000000..fc7d4ab --- /dev/null +++ b/impact_gate/providers.py @@ -0,0 +1,163 @@ +"""Ports and adapters for where the grade and the policy come from. + +The gate logic depends only on two ports, never on files or HTTP directly: + + GradeProvider grade a change against a distribution, and publish a rebuilt baseline. + PolicyProvider supply the gate policy (thresholds, enforcement, curve knobs). + +Today the only adapters are local: a JSON baseline file on disk and an .impact-gate.yml. +A remote adapter (a hosted store + config service) implements the same two ports, so the +CLI and the CI plugins do not change when a team moves from the file to the service. The +choice of adapter lives in `select_*_provider`; everything above the ports is +storage-agnostic. + +Contract rules every adapter must honour, so the merge path stays safe: + * grade() returns None when the backend is unreachable. The caller then falls back to + the shipped seed (a grade with no project baseline), never a hard failure of the gate. + * the verdict (warn / block, exit code) is always computed by the caller from the + policy, never by the provider. A provider informs the gate; it never *is* the gate. + * a remote grade should be reproducible after the fact: record which baseline head / + version produced it (a field to add to Grade when the remote adapter lands). + +REMOTE API CONTRACT (for the future hosted adapter; not implemented yet): + POST {base}/v1/grade {repo, value, language} -> Grade JSON + PUT {base}/v1/baseline {repo, distribution:[...], head} -> replace the baseline + POST {base}/v1/observations {repo, from_head, observations} -> incremental append + Auth: `Authorization: Bearer `. Identity: the `repo` id, configured per project. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Protocol + +from .baseline import (Baseline, Grade, dominant_language, grade_value, + load_baseline, save_baseline) +from .config import GateConfig +from .engine import ChangeScore + + +@dataclass +class ChangeSummary: + """The storage-agnostic description of a change that a grade needs. Deliberately tiny: + this is all a remote grade endpoint ever receives — a number and a language, never the + diff or the source.""" + value: int # composite impact to grade + language: str | None # dominant language; selects the seed table (None -> pooled) + + @classmethod + def of(cls, score: ChangeScore) -> "ChangeSummary": + return cls(value=score.impact, language=dominant_language(score)) + + +class GradeProvider(Protocol): + """Where a change's grade comes from, and where a rebuilt baseline is stored.""" + + def grade(self, change: ChangeSummary, repo_id: str) -> Grade | None: + """Grade `change` against the distribution for `repo_id`. None means the backend + is unavailable, and the caller falls back to the shipped seed.""" + ... + + def publish(self, repo_id: str, baseline: Baseline) -> None: + """Replace the stored baseline for `repo_id` with a freshly built one.""" + ... + + +class PolicyProvider(Protocol): + """Where the gate policy (thresholds, enforcement, curve knobs) comes from.""" + + def policy(self, repo_id: str) -> GateConfig: + ... + + +# ------------------------------------------------------------------- local adapters + +_UNSET = object() + + +class LocalGradeProvider: + """Grades against the shipped seed blended with a JSON baseline file on disk.""" + + def __init__(self, path: str | None, prior_weight_K: float): + self.path = path + self.prior_weight_K = prior_weight_K + self._baseline = _UNSET # loaded lazily; publish() sets it without a read + + @property + def baseline(self) -> Baseline | None: + if self._baseline is _UNSET: + self._baseline = load_baseline(self.path) if self.path else None + return self._baseline + + def grade(self, change: ChangeSummary, repo_id: str | None = None) -> Grade: + # Never None: with no baseline file this is a pure-seed grade, which is exactly the + # fallback a remote adapter degrades to. + return grade_value(change.value, language=change.language, + baseline=self.baseline, prior_weight_K=self.prior_weight_K) + + def publish(self, repo_id: str | None, baseline: Baseline) -> None: + if not self.path: + raise ValueError("no baseline_file configured to publish to") + save_baseline(baseline, self.path) + self._baseline = baseline + + +class LocalPolicyProvider: + """Reads the gate policy from an .impact-gate.yml (or the built-in defaults).""" + + def __init__(self, config_path: str | None, repo_path: str): + self.config_path = config_path + self.repo_path = repo_path + + def policy(self, repo_id: str | None = None) -> GateConfig: + return GateConfig.load(self.config_path, self.repo_path) + + +# -------------------------------------------------------------- remote adapters (stubs) + +class RemoteGradeProvider: + """Contract stub for the hosted grade store. Not implemented yet: grade() reports the + backend as unavailable (so the gate falls back to the shipped seed) and publish() + refuses rather than silently dropping data. See the REMOTE API CONTRACT above.""" + + def __init__(self, base_url: str, token: str | None = None): + self.base_url = base_url + self.token = token + + def grade(self, change: ChangeSummary, repo_id: str) -> Grade | None: + return None # unavailable -> the caller uses the shipped-seed fallback + + def publish(self, repo_id: str, baseline: Baseline) -> None: + raise NotImplementedError("remote grade store not implemented yet") + + +class RemotePolicyProvider: + """Contract stub for hosted, centrally governed policy. Not implemented yet.""" + + def __init__(self, base_url: str, token: str | None = None): + self.base_url = base_url + self.token = token + + def policy(self, repo_id: str) -> GateConfig: + raise NotImplementedError("remote policy service not implemented yet") + + +# --------------------------------------------------------------- selection + fallback + +def select_grade_provider(repo_path: str, baseline_file: str, + prior_weight_K: float) -> GradeProvider: + """The grade adapter for this run. Local file today; the extension point for a hosted + store is here (e.g. return a RemoteGradeProvider when a baseline API is configured).""" + return LocalGradeProvider(os.path.join(repo_path, baseline_file), prior_weight_K) + + +def select_policy_provider(config_path: str | None, repo_path: str) -> PolicyProvider: + """The policy adapter for this run. Local .impact-gate.yml today; the extension point + for hosted, centrally governed policy is here.""" + return LocalPolicyProvider(config_path, repo_path) + + +def seed_grade(score: ChangeScore, prior_weight_K: float) -> Grade: + """The shipped-seed-only grade — the safe fallback when a remote provider is + unreachable. No project baseline, so `w = 0` and the grade is the pure seed rank.""" + return LocalGradeProvider(None, prior_weight_K).grade(ChangeSummary.of(score)) diff --git a/tests/test_providers.py b/tests/test_providers.py new file mode 100644 index 0000000..68df257 --- /dev/null +++ b/tests/test_providers.py @@ -0,0 +1,90 @@ +"""The provider seam: the local file/policy adapters, the remote stubs' contract, and +the selection + seed fallback that keep the gate storage-agnostic.""" +import pytest + +from impact_gate.baseline import Baseline +from impact_gate.config import GateConfig +from impact_gate.engine import ChangeScore, FileScore +from impact_gate import providers +from impact_gate.providers import (ChangeSummary, LocalGradeProvider, + LocalPolicyProvider, RemoteGradeProvider, + RemotePolicyProvider) + + +def _score(impact, files): + return ChangeScore(files_changed=len(files), mutation=0, godclass=0, + impact=impact, files=files, units=[]) + + +def test_change_summary_takes_value_and_dominant_language(): + score = _score(1234, [FileScore("a.py", "python", 10, 0, 1, 0), + FileScore("b.js", "javascript", 100, 0, 1, 0)]) + cs = ChangeSummary.of(score) + assert cs.value == 1234 + assert cs.language == "javascript" # the higher-cost file wins + + +def test_local_grade_provider_without_a_file_is_pure_seed(): + g = LocalGradeProvider(None, prior_weight_K=200).grade(ChangeSummary(500, "python")) + assert g.project_percentile is None # no project baseline + assert g.weight == 0.0 + assert g.percentile == g.seed_percentile + + +def test_local_grade_provider_blends_a_published_baseline(tmp_path): + path = str(tmp_path / "bl.json") + prov = LocalGradeProvider(path, prior_weight_K=100) + prov.publish("repo", Baseline(n=100, dist=[1] * 99 + [10_000])) + g = prov.grade(ChangeSummary(5000, "python")) + assert g.project_percentile == 99.0 # above the 99 low obs, below the high one + assert g.n == 100 + assert 0.0 < g.weight < 1.0 # blended, not pure seed + + +def test_publish_writes_the_file_and_updates_the_cache(tmp_path): + from impact_gate.baseline import load_baseline + path = str(tmp_path / "bl.json") + prov = LocalGradeProvider(path, prior_weight_K=200) + bl = Baseline(n=3, dist=[10, 20, 30]) + prov.publish("repo", bl) + assert load_baseline(path).dist == [10, 20, 30] # persisted + assert prov.baseline.dist == [10, 20, 30] # cached without a re-read + + +def test_publish_without_a_path_refuses(): + with pytest.raises(ValueError): + LocalGradeProvider(None, 200).publish("repo", Baseline(n=1, dist=[1])) + + +def test_local_policy_provider_returns_a_gateconfig(tmp_path): + (tmp_path / ".impact-gate.yml").write_text("enforcement: block\nwarn_at: 5\n") + cfg = LocalPolicyProvider(None, str(tmp_path)).policy("repo") + assert isinstance(cfg, GateConfig) + assert cfg.enforcement == "block" and cfg.warn_at == 5 + + +def test_remote_grade_stub_reports_unavailable_and_refuses_publish(): + rp = RemoteGradeProvider("https://example.test", token="t") + assert rp.grade(ChangeSummary(1, "python"), "repo") is None # -> seed fallback + with pytest.raises(NotImplementedError): + rp.publish("repo", Baseline(n=1, dist=[1])) + + +def test_remote_policy_stub_not_implemented(): + with pytest.raises(NotImplementedError): + RemotePolicyProvider("https://example.test").policy("repo") + + +def test_selection_returns_local_adapters(tmp_path): + gp = providers.select_grade_provider(str(tmp_path), ".impact-gate-baseline.json", 200) + pp = providers.select_policy_provider(None, str(tmp_path)) + assert isinstance(gp, LocalGradeProvider) + assert isinstance(pp, LocalPolicyProvider) + assert gp.path.endswith(".impact-gate-baseline.json") # repo path joined in + + +def test_seed_grade_helper_is_the_pure_seed_fallback(): + score = _score(500, [FileScore("a.py", "python", 500, 0, 1, 0)]) + g = providers.seed_grade(score, prior_weight_K=200) + assert g.project_percentile is None + assert g.percentile == g.seed_percentile