diff --git a/README.md b/README.md index 041123f..049d8f3 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,6 @@ warn_at: 50000 # impact above which to warn block_at: 200000 # impact above which to block enforcement: warn # off, warn, or block. Start on warn. Flip to block when ready. tolerance: 1.0 # CI-adjustable multiplier on both thresholds. Above 1 is more lenient. -wmc_context: before # measure definition. Uses the pre-change container. This is canonical. # measure_config: .impact-measure.yml # optional: ignore globs and language overrides ``` diff --git a/action.yml b/action.yml index 93f84b8..f43f43e 100644 --- a/action.yml +++ b/action.yml @@ -23,9 +23,6 @@ inputs: tolerance: description: "Multiplier applied to both thresholds." default: "" - wmc-context: - description: "before (canonical) or after." - default: "" config: description: "Path to an .impact-gate.yml." default: "" @@ -55,7 +52,6 @@ runs: INPUT_WARN_AT: ${{ inputs.warn-at }} INPUT_BLOCK_AT: ${{ inputs.block-at }} INPUT_TOLERANCE: ${{ inputs.tolerance }} - INPUT_WMC_CONTEXT: ${{ inputs.wmc-context }} INPUT_CONFIG: ${{ inputs.config }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} GITHUB_TOKEN: ${{ inputs.github-token || github.token }} @@ -74,7 +70,6 @@ runs: if [ -n "$INPUT_WARN_AT" ]; then args+=(--warn-at "$INPUT_WARN_AT"); fi if [ -n "$INPUT_BLOCK_AT" ]; then args+=(--block-at "$INPUT_BLOCK_AT"); fi if [ -n "$INPUT_TOLERANCE" ]; then args+=(--tolerance "$INPUT_TOLERANCE"); fi - if [ -n "$INPUT_WMC_CONTEXT" ]; then args+=(--wmc-context "$INPUT_WMC_CONTEXT"); fi if [ -n "$INPUT_CONFIG" ]; then args+=(--config "$INPUT_CONFIG"); fi report="$RUNNER_TEMP/impact-gate.md" diff --git a/impact_gate/baseline.py b/impact_gate/baseline.py new file mode 100644 index 0000000..41fff0a --- /dev/null +++ b/impact_gate/baseline.py @@ -0,0 +1,208 @@ +"""The project baseline: the repo's own per-commit impact distribution, and the +empirical-Bayes blend of it with the shipped seed prior. + +Scope. The walk starts from the merged base branch (default `main`) and follows its +first-parent mainline, so only landed work is measured; an unmerged in-flight branch is +never reached, because the walk only ever follows real merge commits. + +One observation = one atomic landed change: + * a main non-merge commit -> its own diff vs its parent; + * a leaf MR (a merged branch with no MRs inside it) -> the net change from the branch + start (merge-base of the merge's parents) to the branch tip; + * a parent MR (a merged branch that contains MRs) -> its direct commits netted per run + between the merges on its spine, each run based at the preceding synced base; every + child MR recurses, and a merge that only syncs the parent/main branch in is skipped. +A merge is never scored as its own diff, so a long-running roll-up cannot inflate things. + +Each observation is one composite impact = (Σ mutation + Σ godclass) * files. The +distribution of that over all observations is the baseline. The gate always grades the +composite; mutation cost stays a per-file ranking signal (report side), never a rival +distribution to gate against. + +Grading. A change's grade blends its percentile rank in this project distribution (n +observations) with its rank against the per-language seed table, weighting the project by + w = n / (n + K) +so a shallow history leans on the seed and a deep one trusts itself. K is curve_prior_weight. +""" +from __future__ import annotations + +import bisect +import json +import re +from dataclasses import dataclass, field + +from . import gitio +from .core.config import MeasureConfig +from .core.gitplumb import GitRepo +from .data import load_defaults, rank_in_table, seed_table +from .engine import score_change + +_BD = load_defaults().get("baseline", {}) + + +# --------------------------------------------------------------------------- model + +@dataclass +class Baseline: + """The project's composite per-observation distribution (ascending).""" + n: int + dist: list[int] = field(default_factory=list) + head: str | None = None + base_ref: str | None = None + + def rank(self, value: float) -> float: + return _percentile_rank(self.dist, value) + + def to_dict(self) -> dict: + return {"_meta": {"tool": "impact-gate", "n": self.n, "head": self.head, + "base_ref": self.base_ref}, + "distribution": self.dist} + + @classmethod + def from_dict(cls, d: dict) -> "Baseline": + meta = d.get("_meta", {}) + dist = [int(v) for v in (d.get("distribution") or [])] + n = int(meta.get("n", len(dist))) + return cls(n=n, dist=dist, head=meta.get("head"), + base_ref=meta.get("base_ref")) + + +def _percentile_rank(sorted_vals: list[int], value: float) -> float: + """Percentile rank of `value` in an ascending list: fraction below plus half the + ties, in [0, 100]. Above every observation -> 100; at or below the smallest -> ~0.""" + n = len(sorted_vals) + if n == 0: + return 0.0 + lo = bisect.bisect_left(sorted_vals, value) + hi = bisect.bisect_right(sorted_vals, value) + return round((lo + 0.5 * (hi - lo)) / n * 100, 2) + + +# ----------------------------------------------------------------------- the walk + +def build_baseline(repo_path: str, mcfg: MeasureConfig | None = None, *, + base_ref: str | None = None, max_commits: int | None = None, + exclude_subject_pattern: str | None = None) -> Baseline: + """Walk the merged history of `base_ref` into the project's impact distribution.""" + mcfg = mcfg or MeasureConfig() + base_ref = base_ref or _BD.get("base_ref") or "HEAD" + if max_commits is None: + max_commits = _BD.get("max_commits") + if exclude_subject_pattern is None: + exclude_subject_pattern = _BD.get("exclude_subject_pattern") + pat = re.compile(exclude_subject_pattern) if exclude_subject_pattern else None + + parents = gitio.rev_parents(repo_path, base_ref) + mainline = gitio.mainline_commits(repo_path, base_ref, max_commits) + repo = GitRepo(repo_path) + comp: list[int] = [] + seen: set[str] = set() + + def emit(old_rev: str, new_rev: str) -> None: + score = score_change(gitio.diff_between(repo, old_rev, new_rev), mcfg) + if score.empty: + return + comp.append(score.impact) + + def walk_mr(merge_sha: str, p1: str, tip: str) -> None: + if merge_sha in seen: + return + seen.add(merge_sha) + if pat and pat.search(gitio.commit_subject(repo_path, merge_sha)): + return # naming-convention exclusion: skip this MR entirely + start = gitio.merge_base(repo_path, p1, tip) or p1 + spine = gitio.first_parent_spine(repo_path, start, tip) + base = start + for c in reversed(spine): # oldest first, along the branch + cps = parents.get(c) or gitio.rev_parents(repo_path, c).get(c, []) + if len(cps) < 2: + continue # direct commit: extends the run + prev = cps[0] # branch state just before this merge + if prev != base: + emit(base, prev) # net of the run of direct commits + for sec in cps[1:]: + if gitio.is_ancestor(repo_path, sec, p1): + continue # a sync of parent/main: already counted + walk_mr(c, cps[0], sec) # a child MR: recurse + base = c # advance past the merge + if base != tip: + emit(base, tip) # final run up to the branch tip + + try: + for c in mainline: + ps = parents.get(c, []) + if len(ps) >= 2: # an MR landed on the mainline + for sec in ps[1:]: + walk_mr(c, ps[0], sec) + else: # a direct commit on the mainline + emit(ps[0] if ps else gitio.EMPTY_TREE, c) + finally: + repo.close() + + comp.sort() + return Baseline(n=len(comp), dist=comp, + head=gitio.head_sha(repo_path, base_ref), base_ref=base_ref) + + +# ------------------------------------------------------------------- persistence + +def save_baseline(baseline: Baseline, path: str) -> None: + with open(path, "w", encoding="utf-8") as fh: + json.dump(baseline.to_dict(), fh, indent=1) + + +def load_baseline(path: str) -> Baseline | None: + """The cached baseline at `path`, or None if it is missing or unreadable.""" + try: + with open(path, encoding="utf-8") as fh: + return Baseline.from_dict(json.load(fh)) + except (OSError, ValueError): + return None + + +# ------------------------------------------------------------------------ grading + +@dataclass +class Grade: + percentile: float # the blended grade, 0..100 + value: int # the composite impact that was graded + seed_percentile: float # rank against the shipped per-language seed + project_percentile: float | None # rank against project history (None at cold start) + weight: float # w = n / (n + K): the project's share of the blend + n: int # project observations behind the grade + language: str | None + + +def dominant_language(score) -> str | None: + """The language driving the change (highest-cost file), else None -> pooled seed.""" + best, best_cost = None, -1 + for f in score.files: + if f.lang and f.cost > best_cost: + best, best_cost = f.lang, f.cost + return best + + +def grade_value(value: int, *, language: str | None, + baseline: Baseline | None, prior_weight_K: float) -> Grade: + seed_pct, seed_vals = seed_table(language) + seed_rank = rank_in_table(value, seed_pct, seed_vals) + n = baseline.n if baseline else 0 + if n <= 0: + return Grade(seed_rank, value, seed_rank, None, 0.0, 0, language) + project_rank = baseline.rank(value) + denom = n + prior_weight_K + w = n / denom if denom > 0 else 1.0 + blended = round(w * project_rank + (1 - w) * seed_rank, 2) + return Grade(blended, value, round(seed_rank, 2), round(project_rank, 2), + round(w, 4), n, language) + + +def grade_change(score, *, baseline: Baseline | None = None, + prior_weight_K: float = 200) -> Grade: + """Grade a scored change by blending its project and seed percentile ranks. + + The graded value is always the composite impact; the seed table is picked by the + change's dominant language. + """ + return grade_value(score.impact, language=dominant_language(score), + baseline=baseline, prior_weight_K=prior_weight_K) diff --git a/impact_gate/cli.py b/impact_gate/cli.py index 12f7de0..ca5332c 100644 --- a/impact_gate/cli.py +++ b/impact_gate/cli.py @@ -35,14 +35,13 @@ def _add_score_args(p: argparse.ArgumentParser) -> None: p.add_argument("--block-at", type=int) p.add_argument("--enforcement", choices=ENFORCEMENTS) p.add_argument("--tolerance", type=float) - p.add_argument("--wmc-context", choices=("before", "after")) p.add_argument("--measure-config", help="Surveyor-style YAML for ignore globs etc.") def _resolve_config(args) -> GateConfig: cfg = GateConfig.load(args.config, args.repo) for attr in ("warn_at", "block_at", "enforcement", "tolerance", - "wmc_context", "measure_config"): + "measure_config"): val = getattr(args, attr, None) if val is not None: setattr(cfg, attr, val) @@ -59,7 +58,7 @@ def _cmd_score(args) -> int: print(f"impact-gate: {e}", file=sys.stderr) return 1 - score = score_change(changed, mcfg, cfg.wmc_context) + score = score_change(changed, mcfg) level = cfg.level(score.impact) blocked = cfg.blocks(score.impact) diff --git a/impact_gate/config.py b/impact_gate/config.py index c76172a..1252fea 100644 --- a/impact_gate/config.py +++ b/impact_gate/config.py @@ -1,27 +1,53 @@ """Gate configuration: thresholds, enforcement mode, CI-adjustable tolerance. Resolved in layers (later wins): built-in defaults -> `.impact-gate.yml` in the repo --> CLI flags / CI inputs. For now thresholds are absolute impact numbers (seeded from -the Surveyor corpus); the per-project grading curve is a later phase and will populate -`warn_at` / `block_at` automatically from history. +-> CLI flags / CI inputs. The built-in defaults are not literals here; they are read +from `impact_gate/data/defaults.json`, so tuning them is a data edit, not a code change. + +Two gating modes coexist. Absolute: `warn_at` / `block_at` are raw composite-impact +numbers. Curve (`curve_enabled`): a change is graded by its percentile against the +blended seed + project distribution, and `warn_percentile` / `block_percentile` gate on +that. The curve fields are wired here; `baseline.py` and the percentile gate consume +them (a later phase). Absolute stays the fallback when the curve is off or no baseline +exists yet. """ from __future__ import annotations import os from dataclasses import dataclass +from .data import load_defaults + CONFIG_NAMES = (".impact-gate.yml", ".impact-gate.yaml") ENFORCEMENTS = ("off", "warn", "block") +# Built-in defaults come from the shipped JSON, never from literals in this file. +_D = load_defaults() +_ABS = _D["absolute"] +_CURVE = _D["curve"] + +# The knobs an .impact-gate.yml / CLI flag may override, and their loaders. Absolute and +# curve knobs share one path so a repo can set either mode's numbers in the same file. +_SCALAR_KEYS = ("warn_at", "block_at", "enforcement", "tolerance", "measure_config", + "curve_enabled", "warn_percentile", "block_percentile", + "curve_prior_weight", "baseline_file") + @dataclass class GateConfig: - warn_at: int | None = None # impact above which to warn (None = never warn) - block_at: int | None = None # impact above which to block (None = never block) - enforcement: str = "warn" # off | warn | block (block = a too-high change fails) - tolerance: float = 1.0 # CI-adjustable multiplier on both thresholds (>1 = looser) - wmc_context: str = "before" # measure definition (canonical: before-context) - measure_config: str | None = None # optional Surveyor-style YAML (ignore globs, etc.) + warn_at: int | None = _ABS["warn_at"] # impact above which to warn (None = never) + block_at: int | None = _ABS["block_at"] # impact above which to block (None = never) + enforcement: str = _D["enforcement"] # off | warn | block (block = too-high fails) + tolerance: float = _D["tolerance"] # CI multiplier on both thresholds (>1 = looser) + measure_config: str | None = None # optional Surveyor-style YAML (ignore globs) + # Grading curve (percentile-based). Consumed once baseline.py + the percentile gate land. + # The gate always scores the composite (change-level) impact; the mutation cost is a + # per-file signal used to rank which files to consider, not a rival gating metric. + curve_enabled: bool = _CURVE["enabled"] # gate on percentile vs absolute + warn_percentile: float = _CURVE["warn_percentile"] + block_percentile: float = _CURVE["block_percentile"] + curve_prior_weight: float = _CURVE["prior_weight_K"] # K in w = n / (n + K) + baseline_file: str = _CURVE["baseline_file"] # project distribution cache def effective_warn(self) -> float | None: return None if self.warn_at is None else self.warn_at * self.tolerance @@ -53,8 +79,7 @@ def load(cls, path: str | None = None, repo_path: str = ".") -> "GateConfig": import yaml # optional; only needed when a config file exists with open(found) as fh: data = yaml.safe_load(fh) or {} - for key in ("warn_at", "block_at", "enforcement", "tolerance", - "wmc_context", "measure_config"): + for key in _SCALAR_KEYS: if key in data and data[key] is not None: setattr(cfg, key, data[key]) cfg.validate() @@ -64,10 +89,16 @@ def validate(self) -> None: if self.enforcement not in ENFORCEMENTS: raise ValueError(f"enforcement must be one of {ENFORCEMENTS}, got " f"{self.enforcement!r}") - if self.wmc_context not in ("before", "after"): - raise ValueError("wmc_context must be 'before' or 'after'") if self.tolerance <= 0: raise ValueError("tolerance must be > 0") + for name in ("warn_percentile", "block_percentile"): + p = getattr(self, name) + if not 0 < p < 100: + raise ValueError(f"{name} must be within (0, 100), got {p}") + if self.warn_percentile > self.block_percentile: + raise ValueError("warn_percentile must be <= block_percentile") + if self.curve_prior_weight < 0: + raise ValueError("curve_prior_weight (K) must be >= 0") def _discover(repo_path: str) -> str | None: diff --git a/impact_gate/core/config.py b/impact_gate/core/config.py index 6a729a5..c5a1efe 100644 --- a/impact_gate/core/config.py +++ b/impact_gate/core/config.py @@ -66,7 +66,16 @@ def language(self, path: str) -> str | None: return self.lang_by_ext.get(self.ext(path)) def is_ignored(self, path: str) -> bool: - return any(fnmatch.fnmatch(path, pat) for pat in self.ignore) + # Match each glob against the path, and also with a leading "**/" stripped, so a + # pattern like "**/vendor/**" ignores a TOP-LEVEL vendor/ too. fnmatch's "*" spans + # "/", but "**/vendor/**" still requires a parent segment before "vendor", which + # let a repo-root vendor/ or node_modules/ slip through. + for pat in self.ignore: + if fnmatch.fnmatch(path, pat): + return True + if pat.startswith("**/") and fnmatch.fnmatch(path, pat[3:]): + return True + return False def is_test(self, path: str) -> bool: return any(fnmatch.fnmatch(path, pat) for pat in self.test_patterns) diff --git a/impact_gate/core/impact.py b/impact_gate/core/impact.py index 2a39aca..8685198 100644 --- a/impact_gate/core/impact.py +++ b/impact_gate/core/impact.py @@ -2,9 +2,9 @@ cost(unit) = max(WMC_other, 1) * CC * max(1, dlines) WMC_other = sum CC of the OTHER units sharing the unit's container, measured on - the PRE-change (before) container by default (`wmc_context`), so a unit - added to a brand-new container costs ~CC*dlines while a method accreted - onto an existing class is still charged for the siblings already there. + the PRE-change (before) container, so a unit added to a brand-new + container costs ~CC*dlines while a method accreted onto an existing + class is still charged for the siblings already there. mutation_cost = sum cost over modified/renamed existing units godclass_cost = sum cost over new units (new files + new methods) @@ -80,33 +80,26 @@ def compute_file_impact( before: list[Unit], after: list[Unit], before_src: list[str], after_src: list[str], added: list[tuple[int, int]], removed: list[tuple[int, int]], - rename_jaccard: float, wmc_context: str = "before", + rename_jaccard: float, ) -> FileImpact: added_lines = _line_set(added) removed_lines = _line_set(removed) - # WMC_other = the surrounding complexity you must comprehend to change a unit. - # Two definitions, selected by `wmc_context`: - # "after" — the container as it stands in the RESULTING file (original behaviour). - # "before" — the complexity that PRE-EXISTED the change (the context you faced). - # Under "before", a unit added to a brand-new container has no prior siblings, so its - # WMC_other falls to 0 (floored to 1): importing/greenfield code costs only ~CC*dlines, - # while a method accreted onto an existing (god) class is still charged for the siblings - # that were already there — so growth-by-accretion stays expensive. - after_by_container: dict[str, int] = {} - for u in after: - after_by_container[u.container] = after_by_container.get(u.container, 0) + u.cc + # WMC_other = the surrounding complexity you must comprehend to change a unit, + # measured on the PRE-change (before) container: the complexity that pre-existed the + # change (the context you faced). A unit added to a brand-new container has no prior + # siblings, so its WMC_other falls to 0 (floored to 1): importing/greenfield code costs + # only ~CC*dlines, while a method accreted onto an existing (god) class is still charged + # for the siblings that were already there — so growth-by-accretion stays expensive. before_by_container: dict[str, int] = {} for u in before: before_by_container[u.container] = before_by_container.get(u.container, 0) + u.cc def _wmc(u: Unit, src: Unit | None) -> int: - if wmc_context == "before": - # other pre-existing complexity in the container; subtract the unit's own prior - # contribution (`src`) only if it already existed — a new unit subtracts nothing. - base = before_by_container.get(u.container, 0) - return max(base - (src.cc if src is not None else 0), 0) - return max(after_by_container.get(u.container, 0) - u.cc, 0) + # other pre-existing complexity in the container; subtract the unit's own prior + # contribution (`src`) only if it already existed — a new unit subtracts nothing. + base = before_by_container.get(u.container, 0) + return max(base - (src.cc if src is not None else 0), 0) before_by_name = {u.name: u for u in before} after_names = {u.name for u in after} diff --git a/impact_gate/data/__init__.py b/impact_gate/data/__init__.py new file mode 100644 index 0000000..a9c8fe8 --- /dev/null +++ b/impact_gate/data/__init__.py @@ -0,0 +1,108 @@ +"""Shipped data and the loader for it. + +Every tunable number the gate leans on lives in a JSON file in this package, not in +code, so refining them as research lands is a data edit and a version bump — never a +code change: + + defaults.json — default gate / grading-curve constants (GateConfig reads + these as its built-in defaults). + seed_percentiles.json — per-language percentile tables of per-commit composite + impact, with a pooled fallback. The cold-start prior for the + grading curve, derived from the corpus scan. + +Files are read through importlib.resources so they resolve the same whether the package +is run from a checkout or an installed wheel. Results are cached; call `reload()` in a +test that rewrites a file. +""" +from __future__ import annotations + +import json +from functools import lru_cache +from importlib import resources + + +def _read(name: str) -> dict: + with resources.files(__package__).joinpath(name).open(encoding="utf-8") as fh: + return json.load(fh) + + +@lru_cache(maxsize=None) +def load_defaults() -> dict: + """The default gate / curve constants from defaults.json (cached).""" + return _read("defaults.json") + + +@lru_cache(maxsize=None) +def load_seed_percentiles() -> dict: + """The seed percentile tables from seed_percentiles.json (cached, validated).""" + data = _read("seed_percentiles.json") + _validate_seed(data) + return data + + +def reload() -> None: + """Drop the caches so the next load re-reads from disk (tests that rewrite files).""" + load_defaults.cache_clear() + load_seed_percentiles.cache_clear() + + +def seed_table(lang: str | None) -> tuple[list[float], list[float]]: + """(percentiles, values) for a language, falling back to the pooled table. + + `percentiles` is the shared axis (ascending, e.g. [50, 75, 90, 95, 98, 99]); + `values` is the per-commit impact at each of those percentiles for `lang`. + """ + data = load_seed_percentiles() + pct = [float(p) for p in data["percentiles"]] + table = data["languages"].get(lang) if lang else None + if table is None: + table = data["pooled"] + return pct, [float(v) for v in table["values"]] + + +def rank_in_table(value: float, percentiles: list[float], + values: list[float]) -> float: + """Percentile rank of `value` against a percentile->value table. + + Linear interpolation between the tabulated points, clamped to [0, 100]. Below the + first point the rank scales from 0 up to the first percentile; above the last point + it saturates just under 100 (the tail is unbounded, so never report a flat 100). + This is the seed half of the grade; `baseline.py` blends it with project history. + """ + if value <= 0: + return 0.0 + # Below the lowest tabulated value: scale 0 -> percentiles[0] by the value ratio. + if value <= values[0]: + first = percentiles[0] + return round(first * (value / values[0]), 2) if values[0] > 0 else 0.0 + for i in range(1, len(values)): + if value <= values[i]: + lo_v, hi_v = values[i - 1], values[i] + lo_p, hi_p = percentiles[i - 1], percentiles[i] + frac = (value - lo_v) / (hi_v - lo_v) if hi_v > lo_v else 0.0 + return round(lo_p + frac * (hi_p - lo_p), 2) + # Above the top tabulated point: approach 100 without reaching it. + last_p = percentiles[-1] + return round(last_p + (100.0 - last_p) * 0.5, 2) + + +def _validate_seed(data: dict) -> None: + """Fail loud on a malformed table, so a bad scan swap is caught at load, not use.""" + pct = data.get("percentiles") + if not isinstance(pct, list) or len(pct) < 2: + raise ValueError("seed_percentiles.json: 'percentiles' must list >= 2 points") + if any(pct[i] >= pct[i + 1] for i in range(len(pct) - 1)): + raise ValueError("seed_percentiles.json: 'percentiles' must be ascending") + if not (0 < pct[0] and pct[-1] < 100): + raise ValueError("seed_percentiles.json: percentiles must be within (0, 100)") + tables = {"pooled": data.get("pooled"), **(data.get("languages") or {})} + if data.get("pooled") is None: + raise ValueError("seed_percentiles.json: a 'pooled' fallback table is required") + for label, table in tables.items(): + vals = (table or {}).get("values") + if not isinstance(vals, list) or len(vals) != len(pct): + raise ValueError(f"seed_percentiles.json: table {label!r} needs " + f"{len(pct)} values to match 'percentiles'") + if any(vals[i] > vals[i + 1] for i in range(len(vals) - 1)): + raise ValueError(f"seed_percentiles.json: table {label!r} values must be " + "non-decreasing") diff --git a/impact_gate/data/defaults.json b/impact_gate/data/defaults.json new file mode 100644 index 0000000..c5cdce7 --- /dev/null +++ b/impact_gate/data/defaults.json @@ -0,0 +1,31 @@ +{ + "_meta": { + "note": "Default gate and grading-curve constants, single-sourced here so tuning is a config edit, not a code change. GateConfig reads these as its built-in defaults; an .impact-gate.yml and CLI flags still override per-repo.", + "provisional": true + }, + + "enforcement": "warn", + "tolerance": 1.0, + + "absolute": { + "note": "Absolute composite-impact thresholds. Used when the curve is off, and as the fallback before a project baseline exists. null = that threshold is not set.", + "warn_at": null, + "block_at": null + }, + + "curve": { + "note": "Percentile-based grading. When enabled, a change is graded by its percentile against the blended (seed + project) distribution instead of a raw number. The graded value is always the composite (change-level) impact; per-file mutation cost is a ranking signal, not a rival gating metric.", + "enabled": false, + "warn_percentile": 90, + "block_percentile": 98, + "prior_weight_K": 200, + "baseline_file": ".impact-gate-baseline.json" + }, + + "baseline": { + "note": "How the merged history becomes the per-commit distribution. Walk the first-parent mainline of the merged base branch (only landed work; in-flight branches are never reached). One observation per atomic landed change: a main non-merge commit (its own diff); a leaf MR (net branch-start..tip); a parent MR's direct commits netted per run between merges, each based at the preceding synced base, with child MRs recursing and parent/main syncs skipped. A merge is never scored as its own diff, so a long-running roll-up cannot inflate the numbers. exclude_subject_pattern skips an MR by its merge-commit subject; max_commits caps how many recent mainline commits are walked.", + "base_ref": "main", + "exclude_subject_pattern": null, + "max_commits": null + } +} diff --git a/impact_gate/data/seed_percentiles.json b/impact_gate/data/seed_percentiles.json new file mode 100644 index 0000000..3a41975 --- /dev/null +++ b/impact_gate/data/seed_percentiles.json @@ -0,0 +1,120 @@ +{ + "_meta": { + "provisional": false, + "metric": "composite", + "measure": "before-context WMC", + "unit": "per-commit composite impact = (Sigma mutation + Sigma godclass) * files_changed", + "source": "Clean before-context corpus scan of 20 OSS repos (~/scan, 2026-08-29), top-level vendored/generated dirs excluded. Per-commit non-merge observations touching >=1 source file; a commit is filed under the language of its highest-cost changed file. Languages with n<2000 observations fall back to the pooled table.", + "corpus_repos": 20, + "total_observations": 349165 + }, + "percentiles": [ + 50, + 75, + 90, + 95, + 98, + 99 + ], + "pooled": { + "n": 349165, + "values": [ + 2808, + 32376, + 274365, + 1023748, + 4930693, + 14479833 + ] + }, + "languages": { + "python": { + "n": 121128, + "values": [ + 1956, + 18034, + 113191, + 340181, + 1221525, + 3116935 + ] + }, + "java": { + "n": 116663, + "values": [ + 1536, + 21576, + 200367, + 782002, + 3888534, + 11755119 + ] + }, + "typescript": { + "n": 61700, + "values": [ + 4594, + 42944, + 290914, + 872002, + 2998780, + 6653046 + ] + }, + "csharp": { + "n": 18816, + "values": [ + 14558, + 222106, + 2188540, + 8302052, + 37141443, + 107186433 + ] + }, + "javascript": { + "n": 11933, + "values": [ + 14380, + 324216, + 4971922, + 20409314, + 86210976, + 238154394 + ] + }, + "c": { + "n": 9915, + "values": [ + 27468, + 191882, + 1148946, + 3526840, + 14816807, + 45066286 + ] + }, + "scala": { + "n": 4720, + "values": [ + 18470, + 283960, + 2461280, + 7622635, + 23232808, + 51698271 + ] + }, + "go": { + "n": 3478, + "values": [ + 16779, + 147153, + 1008750, + 3631168, + 17143725, + 59416058 + ] + } + } +} diff --git a/impact_gate/engine.py b/impact_gate/engine.py index 8a2aa83..17244e5 100644 --- a/impact_gate/engine.py +++ b/impact_gate/engine.py @@ -83,8 +83,8 @@ def _parse(mcfg: MeasureConfig, path: str, data: bytes | None): return src, units -def score_change(changed: list[ChangedFile], mcfg: MeasureConfig | None = None, - wmc_context: str = "before") -> ChangeScore: +def score_change(changed: list[ChangedFile], + mcfg: MeasureConfig | None = None) -> ChangeScore: """Compute the change-impact of a set of changed files. Only source files with an impact-bearing status count, `files_changed` is the @@ -105,7 +105,7 @@ def score_change(changed: list[ChangedFile], mcfg: MeasureConfig | None = None, continue fi = compute_file_impact( before_units, after_units, before_src, after_src, - c.added, c.removed, mcfg.rename_jaccard, wmc_context, + c.added, c.removed, mcfg.rename_jaccard, ) total_mut += fi.mutation_cost total_god += fi.godclass_cost diff --git a/impact_gate/gitio.py b/impact_gate/gitio.py index 8342349..ad8c5c0 100644 --- a/impact_gate/gitio.py +++ b/impact_gate/gitio.py @@ -21,6 +21,9 @@ _DIFF = ["diff", "-U0", "-M", "--no-color", "--no-ext-diff", "--find-renames"] +# git's canonical empty-tree object; the "parent" a root commit is diffed against. +EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" + class DiffError(RuntimeError): """A change could not be resolved (e.g. no merge-base — shallow clone).""" @@ -112,3 +115,75 @@ def changed_files(repo_path: str, mode: str = "staged", return out finally: repo.close() + + +# ---- history walk (for the project baseline) ----------------------------------- + +def head_sha(repo_path: str, rev: str = "HEAD") -> str: + return _git(repo_path, "rev-parse", rev).strip() + + +def merge_base(repo_path: str, a: str, b: str) -> str | None: + """Public alias: the best common ancestor (branch-start point) of two revs.""" + return _merge_base(repo_path, a, b) + + +def is_ancestor(repo_path: str, ancestor: str, descendant: str) -> bool: + """True if `ancestor` is reachable from `descendant` — used to tell a child MR + (feature merged into the branch) from a sync (parent/main merged into the branch).""" + r = subprocess.run(["git", "-C", repo_path, "merge-base", "--is-ancestor", + ancestor, descendant], capture_output=True) + return r.returncode == 0 + + +def rev_parents(repo_path: str, rev: str = "HEAD") -> dict[str, list[str]]: + """Map every commit reachable from `rev` -> its parent SHAs (first parent first).""" + out = _git(repo_path, "rev-list", "--parents", rev) + parents: dict[str, list[str]] = {} + for line in out.split("\n"): + if not line: + continue + shas = line.split() + parents[shas[0]] = shas[1:] + return parents + + +def mainline_commits(repo_path: str, rev: str = "HEAD", + max_commits: int | None = None) -> list[str]: + """The first-parent spine of `rev`, newest first (the landed-changes mainline).""" + args = ["rev-list", "--first-parent"] + if max_commits is not None: + args += ["-n", str(max_commits)] + args.append(rev) + return [s for s in _git(repo_path, *args).split("\n") if s] + + +def first_parent_spine(repo_path: str, start: str, tip: str) -> list[str]: + """Commits on `tip`'s first-parent chain back to but excluding `start` + (newest first). This is one branch's own line of commits.""" + out = _git(repo_path, "rev-list", "--first-parent", f"{start}..{tip}") + return [s for s in out.split("\n") if s] + + +def commit_subject(repo_path: str, rev: str) -> str: + return _git(repo_path, "log", "-1", "--format=%s", rev).strip() + + +def diff_between(repo: GitRepo, old_rev: str, new_rev: str) -> list[ChangedFile]: + """The `ChangedFile`s of the net diff old_rev..new_rev, with before/after bytes. + + Shares the -U0 rename-aware diff and blob streaming used by the live modes, so a + historical range is scored exactly as the same change would be today. `old_rev` may + be EMPTY_TREE to score a root commit's whole content as added. + """ + out: list[ChangedFile] = [] + for d in repo.diff(old_rev, new_rev): + if d.is_binary: + continue + new_path = d.new_path or d.old_path or "" + old_path = d.old_path or new_path + before = None if d.status == "A" else _blob_bytes(repo, old_rev, old_path) + after = None if d.status == "D" else _blob_bytes(repo, new_rev, new_path) + out.append(ChangedFile(path=new_path, status=d.status, before=before, + after=after, added=d.added, removed=d.removed)) + return out diff --git a/impact_gate/report.py b/impact_gate/report.py index 26f7fad..1374a17 100644 --- a/impact_gate/report.py +++ b/impact_gate/report.py @@ -10,6 +10,14 @@ "staged": "staged vs HEAD", "worktree": "working tree vs HEAD"} +def _unit_parts(u) -> tuple[str, str]: + """(container, short_name) for a unit. "Owner::addOwner" -> ("Owner", "addOwner"); + a free function keeps its name and reports container "" (file scope). The class is + what you look at when a driver is heavy, so the reports show it beside the method.""" + short = u.name.rpartition("::")[2] if "::" in u.name else u.name + return u.container, short + + def _thresholds_note(cfg: GateConfig) -> str: w, b = cfg.effective_warn(), cfg.effective_block() parts = [] @@ -31,20 +39,28 @@ def render_text(score: ChangeScore, cfg: GateConfig, level: str, desc = _MODE_DESC[mode].format(base=base) lines = [ f"Change impact: {score.impact:,} [{tag}]{_thresholds_note(cfg)}", - f" files changed: {score.files_changed} " - f"mutation: {score.mutation:,} new code: {score.godclass:,} " - f"({desc}, wmc-context: {cfg.wmc_context})", + f" files changed: {score.files_changed} ({desc})", ] if level == "block" and not blocked: lines.append(" note: over the block threshold. This will fail once " "enforcement is set to 'block'.") + ranked = [f for f in score.files if f.cost > 0] + if ranked: + lines.append("") + lines.append("Files to consider for refactoring (by change-impact cost):") + for f in ranked[:5]: + lines.append(f" {f.cost:>12,} {f.path} " + f"(existing {f.mutation:,}, new {f.godclass:,}; " + f"{f.mut_fns} fns changed, {f.new_fns} new)") if level != "ok" and score.units: lines.append("") lines.append("Top cost drivers. Simplify or refactor these:") for u in score.units[:5]: - loc = f"{u.path}:{u.name}" if u.name else u.path + container, short = _unit_parts(u) + loc = f"{u.path}:{short}" if short else u.path + where = f"in {container}" if container else "file scope" lines.append(f" {u.cost:>12,} {loc} " - f"(CC {u.cc}, WMC_other {u.wmc_other}, {u.kind})") + f"({where}, CC {u.cc}, WMC_other {u.wmc_other}, {u.kind})") return "\n".join(lines) @@ -55,11 +71,8 @@ def render_json(score: ChangeScore, cfg: GateConfig, level: str, "level": level, "blocked": blocked, "files_changed": score.files_changed, - "mutation": score.mutation, - "godclass": score.godclass, "mode": mode, "base": base, - "wmc_context": cfg.wmc_context, "thresholds": { "warn": cfg.effective_warn(), "block": cfg.effective_block(), @@ -86,26 +99,35 @@ def render_markdown(score: ChangeScore, cfg: GateConfig, level: str, lines = [ f"## Change impact: {score.impact:,} {verdict}", "", - "| metric | value |", + "| field | value |", "|---|---|", f"| files changed | {score.files_changed} |", - f"| mutation (disturbing existing code) | {score.mutation:,} |", - f"| new code | {score.godclass:,} |", ] w, b = cfg.effective_warn(), cfg.effective_block() if w is not None: lines.append(f"| warn threshold | {int(w):,} |") if b is not None: lines.append(f"| block threshold | {int(b):,} |") - lines.append(f"| scope | {desc}, wmc-context {cfg.wmc_context} |") + lines.append(f"| scope | {desc} |") if level == "block" and not blocked: lines += ["", "> Over the block threshold. This will fail once enforcement " "is set to `block`."] + ranked = [f for f in score.files if f.cost > 0] + if ranked: + lines += ["", "### Files to consider for refactoring", "", + "| cost | file | existing | new | fns changed | fns new |", + "|---|---|---|---|---|---|"] + for f in ranked[:5]: + lines.append(f"| {f.cost:,} | `{f.path}` | {f.mutation:,} | " + f"{f.godclass:,} | {f.mut_fns} | {f.new_fns} |") if level != "ok" and score.units: lines += ["", "### Top cost drivers. Simplify or refactor these.", "", - "| cost | location | CC | WMC_other | kind |", - "|---|---|---|---|---|"] + "| cost | location | class | CC | WMC_other | kind |", + "|---|---|---|---|---|---|"] for u in score.units[:5]: - loc = f"`{u.path}:{u.name}`" if u.name else f"`{u.path}`" - lines.append(f"| {u.cost:,} | {loc} | {u.cc} | {u.wmc_other} | {u.kind} |") + container, short = _unit_parts(u) + loc = f"`{u.path}:{short}`" if short else f"`{u.path}`" + cls = f"`{container}`" if container else "_file scope_" + lines.append(f"| {u.cost:,} | {loc} | {cls} | {u.cc} | {u.wmc_other} | " + f"{u.kind} |") return "\n".join(lines) diff --git a/pyproject.toml b/pyproject.toml index 2b5f7c9..54f6ffd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,10 @@ dev = ["pytest>=7"] impact-gate = "impact_gate.cli:main" [tool.setuptools] -packages = ["impact_gate", "impact_gate.core"] +packages = ["impact_gate", "impact_gate.core", "impact_gate.data"] + +[tool.setuptools.package-data] +"impact_gate.data" = ["*.json"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/gitutil.py b/tests/gitutil.py index 1e5d206..ea74154 100644 --- a/tests/gitutil.py +++ b/tests/gitutil.py @@ -49,10 +49,9 @@ def stage(repo, rel: str, content: str) -> None: git(repo, "add", rel) -def score(repo, mode: str = "worktree", base: str = "main", - wmc_context: str = "before"): +def score(repo, mode: str = "worktree", base: str = "main"): changed = gitio.changed_files(str(repo), mode, base) - return engine.score_change(changed, wmc_context=wmc_context) + return engine.score_change(changed) def cc_func(name: str, branches: int) -> str: diff --git a/tests/test_baseline.py b/tests/test_baseline.py new file mode 100644 index 0000000..01ffbdb --- /dev/null +++ b/tests/test_baseline.py @@ -0,0 +1,110 @@ +"""The baseline history walk, the empirical-Bayes blend, and persistence. + +The walk's unit is one atomic landed change. The fixture below lands, in order: + m1, m2 two direct commits on main + feat1 (f1, f2) a leaf MR (a branch with no MRs inside it) + feat2 a parent MR: a direct commit (p1), a child MR (child2 -> c1), + then another direct commit (p2) +so the expected observations are exactly: + m1, m2, feat1-net, feat2 run-before-child (p1), child2 (c1), feat2 run-after-child (p2) += six. The three merge commits are never scored as their own diff. +""" +from gitutil import cc_func, commit, git, init_repo, write + +from impact_gate import baseline +from impact_gate.baseline import Baseline, grade_value + + +def _co(repo, *args): + git(repo, "checkout", "-q", *args) + + +def _merge(repo, branch, msg): + git(repo, "merge", "-q", "--no-ff", "-m", msg, branch) + + +def _history(tmp_path): + repo = init_repo(tmp_path / "proj") + write(repo, "app.py", cc_func("a", 3)) + commit(repo, "m1") + write(repo, "app.py", cc_func("a", 3) + cc_func("b", 4)) + commit(repo, "m2") + + _co(repo, "-b", "feat1") # leaf MR + write(repo, "feature.py", cc_func("x", 2)) + commit(repo, "f1") + write(repo, "feature.py", cc_func("x", 2) + cc_func("y", 3)) + commit(repo, "f2") + _co(repo, "main") + _merge(repo, "feat1", "Merge feat1") + + _co(repo, "-b", "feat2") # parent MR + write(repo, "mod.py", cc_func("p", 3)) + commit(repo, "p1") # direct run before the child MR + _co(repo, "-b", "child2") # child MR + write(repo, "child.py", cc_func("c", 2)) + commit(repo, "c1") + _co(repo, "feat2") + _merge(repo, "child2", "Merge child2") + write(repo, "mod.py", cc_func("p", 3) + cc_func("q", 2)) + commit(repo, "p2") # direct run after the child MR + _co(repo, "main") + _merge(repo, "feat2", "Merge feat2") + return repo + + +def test_walk_counts_atomic_changes_not_merges(tmp_path): + repo = _history(tmp_path) + bl = baseline.build_baseline(str(repo), base_ref="main") + # Six atomic changes; the three merge commits contribute no observation of their own. + assert bl.n == 6 + assert len(bl.dist) == 6 + assert all(v > 0 for v in bl.dist) + assert bl.dist == sorted(bl.dist) + + +def test_subject_pattern_excludes_an_mr(tmp_path): + repo = _history(tmp_path) + full = baseline.build_baseline(str(repo), base_ref="main") + # Excluding the parent MR by its merge subject drops it and its child MR: the run + # before the child (p1), the child (c1) and the run after (p2) all go -> 3 fewer. + trimmed = baseline.build_baseline(str(repo), base_ref="main", + exclude_subject_pattern=r"Merge feat2") + assert trimmed.n == full.n - 3 + + +def test_percentile_rank_endpoints_and_middle(): + bl = Baseline(n=5, dist=[10, 20, 30, 40, 50]) + assert bl.rank(5) == 0.0 # below all + assert bl.rank(100) == 100.0 # above all + assert bl.rank(30) == 50.0 # the median value + + +def test_cold_start_grade_is_pure_seed(): + g = grade_value(500, language="python", baseline=None, prior_weight_K=200) + assert g.project_percentile is None + assert g.weight == 0.0 + assert g.percentile == g.seed_percentile + + +def test_blend_weights_project_by_n_over_n_plus_k(): + bl = Baseline(n=100, dist=[1] * 99 + [10_000]) + # value 5000 sits above the 99 low observations, below the single high one, with no + # ties -> project rank 99.0. + g = grade_value(5000, language="python", baseline=bl, prior_weight_K=100) + assert g.weight == 0.5 # n/(n+K) = 100/200 + assert g.project_percentile == 99.0 + expected = round(0.5 * 99.0 + 0.5 * g.seed_percentile, 2) + assert g.percentile == expected + + +def test_persistence_round_trip(tmp_path): + repo = _history(tmp_path) + bl = baseline.build_baseline(str(repo), base_ref="main") + path = str(tmp_path / "baseline.json") + baseline.save_baseline(bl, path) + back = baseline.load_baseline(path) + assert back is not None + assert back.n == bl.n + assert back.dist == bl.dist + assert baseline.load_baseline(str(tmp_path / "missing.json")) is None diff --git a/tests/test_cli.py b/tests/test_cli.py index ecb5ad8..4d53dea 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -63,7 +63,7 @@ def test_markdown_output(repo, capsys): out = capsys.readouterr().out assert code == 0 assert "## Change impact: 2" in out - assert "| metric | value |" in out + assert "| field | value |" in out assert "Top cost drivers" in out # warn level shows drivers diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..0a8094e --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,27 @@ +"""Ignore rules, including the top-level vendored-dir fix.""" +from impact_gate.core.config import MeasureConfig + + +def test_ignores_vendored_and_generated_at_any_depth(): + cfg = MeasureConfig() + # Top-level (the bug): a repo-root vendor/ or node_modules/ must be ignored. + assert cfg.is_ignored("vendor/foo.go") + assert cfg.is_ignored("node_modules/x.js") + assert cfg.is_ignored("third_party/y.cc") + assert cfg.is_ignored("dist/app.min.js") + # Nested (already worked): still ignored. + assert cfg.is_ignored("pkg/vendor/foo.go") + assert cfg.is_ignored("web/node_modules/x.js") + # Top-level file patterns too. + assert cfg.is_ignored("bundle.min.js") + # Real source is not ignored. + assert not cfg.is_ignored("src/main/App.java") + assert not cfg.is_ignored("app.py") + + +def test_vendored_change_scores_empty(): + # A change confined to vendored code should contribute no impact. + from impact_gate.engine import ChangedFile, score_change + cf = ChangedFile("vendor/lib.go", "M", b"func f(){}\n", b"func f(){return}\n", + added=[(1, 1)], removed=[(1, 1)]) + assert score_change([cf]).empty diff --git a/tests/test_data.py b/tests/test_data.py new file mode 100644 index 0000000..3732365 --- /dev/null +++ b/tests/test_data.py @@ -0,0 +1,67 @@ +"""The shipped-data loader, its schema validation, and the config knobs it feeds.""" +import pytest + +from impact_gate import data +from impact_gate.config import GateConfig + + +def test_defaults_shape(): + d = data.load_defaults() + assert d["enforcement"] in ("off", "warn", "block") + assert d["curve"]["warn_percentile"] < d["curve"]["block_percentile"] + assert d["curve"]["prior_weight_K"] >= 0 + + +def test_seed_percentiles_validate_and_load(): + seed = data.load_seed_percentiles() # raises if the shipped table is malformed + pct = seed["percentiles"] + assert pct == sorted(pct) and 0 < pct[0] and pct[-1] < 100 + assert seed["pooled"] is not None + + +def test_seed_table_falls_back_to_pooled(): + pct, pooled = data.seed_table("no-such-language") + _, py = data.seed_table("python") + _, pooled_direct = data.seed_table(None) + assert pooled == pooled_direct # unknown lang -> pooled + assert py != pooled # a known lang has its own table + + +def test_rank_in_table_is_monotonic_and_bounded(): + pct = [50.0, 90.0, 99.0] + vals = [100.0, 1000.0, 10000.0] + assert data.rank_in_table(0, pct, vals) == 0.0 + assert data.rank_in_table(100, pct, vals) == 50.0 # exactly the 50th point + assert data.rank_in_table(1000, pct, vals) == 90.0 # exactly the 90th point + mid = data.rank_in_table(550, pct, vals) # between 50th and 90th + assert 50.0 < mid < 90.0 + tail = data.rank_in_table(1_000_000, pct, vals) # far above the top point + assert 99.0 < tail < 100.0 # approaches but never hits 100 + + +def test_gateconfig_defaults_come_from_json(): + cfg = GateConfig() + d = data.load_defaults() + assert cfg.warn_percentile == d["curve"]["warn_percentile"] + assert cfg.block_percentile == d["curve"]["block_percentile"] + assert cfg.curve_prior_weight == d["curve"]["prior_weight_K"] + assert cfg.curve_enabled == d["curve"]["enabled"] + + +def test_validate_rejects_bad_curve_knobs(): + with pytest.raises(ValueError): + GateConfig(warn_percentile=0).validate() + with pytest.raises(ValueError): + GateConfig(warn_percentile=95, block_percentile=90).validate() + with pytest.raises(ValueError): + GateConfig(curve_prior_weight=-1).validate() + + +def test_config_file_overrides_curve_knobs(tmp_path): + (tmp_path / ".impact-gate.yml").write_text( + "curve_enabled: true\nwarn_percentile: 80\nblock_percentile: 95\n" + "curve_prior_weight: 100\n") + cfg = GateConfig.load(repo_path=str(tmp_path)) + assert cfg.curve_enabled is True + assert cfg.warn_percentile == 80 and cfg.block_percentile == 95 + assert cfg.curve_prior_weight == 100 diff --git a/tests/test_measure.py b/tests/test_measure.py index 9611bf7..782c0bb 100644 --- a/tests/test_measure.py +++ b/tests/test_measure.py @@ -25,13 +25,12 @@ def test_isolated_new_file_floors_wmc_to_one(): assert s.impact == 2 -def test_before_vs_after_context_on_new_file(): - # Three new functions in a new file. This is the import case. +def test_greenfield_new_file_uses_before_context(): + # Three new functions in a new file. This is the import case. Under the canonical + # before-context, each new func has no prior siblings -> WMC 1 -> cost 1*1*2 = 2; + # total 6. (The removed after-context would have charged each for the other two.) cf = ChangedFile("m.py", "A", None, THREE, added=[(1, 8)], removed=[]) - # before: each new func has no prior siblings -> WMC 1 -> cost 1*1*2 = 2; total 6. - assert score_change([cf], wmc_context="before").impact == 6 - # after: each func sees the other two as siblings -> WMC 3-1 = 2 -> cost 4; total 12. - assert score_change([cf], wmc_context="after").impact == 12 + assert score_change([cf]).impact == 6 def test_accretion_charged_for_prior_siblings(): diff --git a/tests/test_report.py b/tests/test_report.py new file mode 100644 index 0000000..fe7d2cf --- /dev/null +++ b/tests/test_report.py @@ -0,0 +1,77 @@ +"""The driver tables name the containing class (text and markdown), and the report +ranks the files to consider for refactoring by their change-impact cost.""" +import json + +from impact_gate.config import GateConfig +from impact_gate.engine import ChangeScore, FileScore, UnitScore +from impact_gate import report + + +def _score(units, files=None): + return ChangeScore(files_changed=len(files) if files else 1, mutation=0, + godclass=999, impact=999, files=files or [], units=units) + + +CLASSED = UnitScore("Owner.java", "Owner::c", "Owner", 4, 5, 500, "godclass") +FREE = UnitScore("util.py", "helper", "", 2, 0, 6, "godclass") + + +def _render(fn, units, files=None): + cfg = GateConfig(warn_at=1) + s = _score(units, files) + return fn(s, cfg, cfg.level(s.impact), "worktree", "main", False) + + +# A god-class file (heavy new code) and a lighter modification, in ranked order. +GODCLASS = FileScore("big.py", "python", mutation=100, godclass=900, mut_fns=1, new_fns=8) +SMALL = FileScore("small.py", "python", mutation=40, godclass=0, mut_fns=2, new_fns=0) +CLEAN = FileScore("clean.py", "python", mutation=0, godclass=0, mut_fns=0, new_fns=0) + + +def test_text_names_the_class_and_collapses_the_qualifier(): + out = _render(report.render_text, [CLASSED]) + assert "Owner.java:c" in out # method shown short, not "Owner::c" + assert "in Owner" in out # the containing class is named + assert "Owner::c" not in out # qualifier not duplicated + + +def test_text_labels_file_scope_for_free_functions(): + out = _render(report.render_text, [FREE]) + assert "util.py:helper" in out + assert "file scope" in out + + +def test_markdown_has_a_class_column(): + out = _render(report.render_markdown, [CLASSED, FREE]) + assert "| cost | location | class | CC | WMC_other | kind |" in out + assert "`Owner.java:c` | `Owner` |" in out # class in its own column + assert "`util.py:helper` | _file scope_ |" in out + + +def test_text_ranks_files_to_consider_by_cost(): + out = _render(report.render_text, [], [GODCLASS, SMALL]) + assert "Files to consider for refactoring" in out + # The god-class file (cost 1000) ranks above the small modification (cost 40), and its + # new functions are reported so an added-code god class is visible as such. + assert out.index("big.py") < out.index("small.py") + assert "8 new" in out + + +def test_zero_cost_files_are_not_listed(): + out = _render(report.render_text, [], [CLEAN]) + assert "Files to consider for refactoring" not in out + + +def test_markdown_lists_the_files_table(): + out = _render(report.render_markdown, [], [GODCLASS, SMALL]) + assert "### Files to consider for refactoring" in out + assert "| cost | file | existing | new | fns changed | fns new |" in out + assert "`big.py`" in out + + +def test_json_drops_change_level_mutation_but_keeps_per_file_breakdown(): + out = _render(report.render_json, [], [GODCLASS]) + data = json.loads(out) + assert "mutation" not in data and "godclass" not in data # no rival change metric + assert data["files"][0]["cost"] == 1000 # ranking is per file + assert data["files"][0]["new_fns"] == 8