Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions impact_gate/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
163 changes: 163 additions & 0 deletions impact_gate/providers.py
Original file line number Diff line number Diff line change
@@ -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 <token>`. 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))
90 changes: 90 additions & 0 deletions tests/test_providers.py
Original file line number Diff line number Diff line change
@@ -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