diff --git a/.github/workflows/impact.yml b/.github/workflows/impact.yml index 174eaf1..8c78318 100644 --- a/.github/workflows/impact.yml +++ b/.github/workflows/impact.yml @@ -18,3 +18,5 @@ jobs: - uses: ./ # the impact-gate action in this repo with: enforcement: warn # report only for now; switch to block when ready + cognitive-max: 20 # dogfood the readability gate: report methods over this + # Cognitive Complexity (Campbell 2018). warn = report, never fail. diff --git a/README.md b/README.md index 4744983..98771d9 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,22 @@ history it takes to trust the project over the seed. A fresh repo with no baseli grades on the seed alone. A deep history leans on itself. The grade shows in every format next to the raw number. +## Gate on cognitive complexity (deeply-nested methods) + +Change-impact and the curve both measure *how a change moves*. It cannot see a method that +is simply hard to read (working code with deep decision nesting). That is the shape AI code +generators typically produce. + +`--cognitive-max N` turns on an **absolute per-method** gate on **Cognitive Complexity** +(Campbell 2018). A change blocks when any method in a changed file exceeds `N`. It is +independent of the impact number. A small diff can pass on impact yet block here for leaving +a tangled method behind. + +```sh +# Block any change that leaves a method with cognitive complexity over 15 (SonarSource's line). +impact-gate score --enforcement block --cognitive-max 15 +``` + ## Configure with `.impact-gate.yml` (repo root) ```yaml @@ -165,6 +181,7 @@ 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. # measure_config: .impact-measure.yml # optional: ignore globs and language overrides +cognitive_max: 15 # block a method over this Cognitive Complexity (null/omitted = off) # Grading curve (percentile gate). When enabled, warn_at/block_at are ignored and the # gate uses the percentiles below instead. @@ -175,6 +192,9 @@ curve_prior_weight: 200 # K in w = n/(n+K): history needed to trust the p baseline_file: .impact-gate-baseline.json # where `impact-gate baseline` caches the distribution ``` +The cognitive gate is independent of the curve: it can run alongside either the absolute or the +percentile impact gate, or on its own. + CLI flags override the file. A CI job can pass `--tolerance` or `--warn-at`. So a team can dial tolerance without editing the repo. The curve dials have flags too: `--curve`, `--warn-percentile`, `--block-percentile`, `--baseline-file`. diff --git a/action.yml b/action.yml index f43f43e..7decd27 100644 --- a/action.yml +++ b/action.yml @@ -23,6 +23,9 @@ inputs: tolerance: description: "Multiplier applied to both thresholds." default: "" + cognitive-max: + description: "Block when any method in a changed file exceeds this Cognitive Complexity (Campbell 2018). Off unless set; SonarSource's line is 15." + default: "" config: description: "Path to an .impact-gate.yml." default: "" @@ -52,6 +55,7 @@ runs: INPUT_WARN_AT: ${{ inputs.warn-at }} INPUT_BLOCK_AT: ${{ inputs.block-at }} INPUT_TOLERANCE: ${{ inputs.tolerance }} + INPUT_COGNITIVE_MAX: ${{ inputs.cognitive-max }} INPUT_CONFIG: ${{ inputs.config }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} GITHUB_TOKEN: ${{ inputs.github-token || github.token }} @@ -70,6 +74,7 @@ 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_COGNITIVE_MAX" ]; then args+=(--cognitive-max "$INPUT_COGNITIVE_MAX"); fi if [ -n "$INPUT_CONFIG" ]; then args+=(--config "$INPUT_CONFIG"); fi report="$RUNNER_TEMP/impact-gate.md" diff --git a/impact_gate/cli.py b/impact_gate/cli.py index 7162f32..7259537 100644 --- a/impact_gate/cli.py +++ b/impact_gate/cli.py @@ -51,12 +51,18 @@ def _add_score_args(p: argparse.ArgumentParser) -> None: "baseline and the shipped seed. 0 grades PURELY against the " "--baseline-file distribution (ignore the seed); large K leans on " "the seed. Default from config (200).") + # cognitive-complexity gate (absolute per-method; independent of the change-impact gate) + p.add_argument("--cognitive-max", dest="cognitive_max", type=int, + help="block when any method in a changed file has cognitive complexity " + "(Campbell 2018) above this. Catches deeply-nested, hard-to-read " + "methods the change-impact score cannot see. Off unless set; " + "SonarSource's default line is 15.") # Gate knobs an argparse flag may override on top of the config file, when given. _OVERRIDE_ATTRS = ("warn_at", "block_at", "enforcement", "tolerance", "measure_config", "curve_enabled", "baseline_file", "warn_percentile", "block_percentile", - "curve_prior_weight") + "curve_prior_weight", "cognitive_max") def _resolve_config(args) -> GateConfig: @@ -95,6 +101,12 @@ def _cmd_score(args) -> int: level = cfg.level(score.impact) blocked = cfg.blocks(score.impact) + # Cognitive gate: an ABSOLUTE per-method readability check, OR'd with the change-impact gate. + # A change can pass on impact yet block here for leaving a deeply-nested, hard-to-read method. + if cfg.cognitive_enabled() and cfg.cognitive_level(score.cognitive_max) == "block": + level = "block" + blocked = blocked or cfg.blocks_cognitive(score.cognitive_max) + if args.format == "json": print(report.render_json(score, cfg, level, args.mode, args.base, blocked, grade)) elif args.format == "markdown": diff --git a/impact_gate/config.py b/impact_gate/config.py index 0019da0..057ab9f 100644 --- a/impact_gate/config.py +++ b/impact_gate/config.py @@ -25,12 +25,13 @@ _D = load_defaults() _ABS = _D["absolute"] _CURVE = _D["curve"] +_COG = _D.get("cognitive", {"cognitive_max": None}) # 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") + "curve_prior_weight", "baseline_file", "cognitive_max") @dataclass @@ -48,6 +49,11 @@ class GateConfig: 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 + # Cognitive-complexity gate (Campbell 2018): an ABSOLUTE per-method readability threshold, + # independent of the change-impact composite/curve. None = off (the default). When set, ANY + # method in a changed file whose cognitive complexity exceeds it blocks — this is the gate + # for deeply-nested, hard-to-read methods that the change-impact score cannot see. + cognitive_max: int | None = _COG["cognitive_max"] def effective_warn(self) -> float | None: return None if self.warn_at is None else self.warn_at * self.tolerance @@ -83,6 +89,21 @@ def blocks_grade(self, percentile: float) -> bool: return (self.enforcement == "block" and self.level_for_grade(percentile) == "block") + def cognitive_enabled(self) -> bool: + return self.cognitive_max is not None + + def cognitive_level(self, max_cognitive: int) -> str: + """'block' when a method's cognitive complexity exceeds `cognitive_max`, else 'ok'. + Independent of the change-impact level; the two are OR'd by the caller.""" + if self.cognitive_max is not None and max_cognitive > self.cognitive_max: + return "block" + return "ok" + + def blocks_cognitive(self, max_cognitive: int) -> bool: + """Fails the gate on readability only under 'block' enforcement (like `blocks`).""" + return (self.enforcement == "block" + and self.cognitive_level(max_cognitive) == "block") + @classmethod def load(cls, path: str | None = None, repo_path: str = ".") -> "GateConfig": """Load from an explicit path, else the first `.impact-gate.y*ml` in repo_path.""" @@ -113,6 +134,8 @@ def validate(self) -> None: raise ValueError("warn_percentile must be <= block_percentile") if self.curve_prior_weight < 0: raise ValueError("curve_prior_weight (K) must be >= 0") + if self.cognitive_max is not None and self.cognitive_max <= 0: + raise ValueError("cognitive_max must be > 0 (or null to disable)") def _discover(repo_path: str) -> str | None: diff --git a/impact_gate/core/cognitive.py b/impact_gate/core/cognitive.py new file mode 100644 index 0000000..22a183b --- /dev/null +++ b/impact_gate/core/cognitive.py @@ -0,0 +1,160 @@ +"""Language-agnostic Cognitive Complexity (Campbell, 2018). + +ImpactGate's own estimator, computed over one function's source text. It is NOT the +certified SonarSource implementation; it is a deliberately simple, faithful approximation +whose numbers are calibrated against PMD (Java) and eslint-plugin-sonarjs (TypeScript) in +the test suite. It powers an ABSOLUTE per-method gate, and for a gate cross-checkpoint / +cross-PR CONSISTENCY matters more than matching a third party's exact value. + +Method (the parts of Campbell's rules that a parser-free scan can carry faithfully): + + * +1 (plus the current NESTING level) for each control-flow structure that nests: + ``if``, ``for``/``foreach``, ``while``, ``do``, ``switch``, ``catch``. + * +1 (with NO nesting increment) for a continuation: ``else`` and ``else if`` / ``elif``. + * +1 for each run of like boolean operators (a run of ``&&`` is one, a run of ``||`` is + one; switching operator starts a new run). + * NESTING increases inside every one of those blocks. + +Nesting is tracked by braces for the C family and by indentation for Python-like languages. +Two documented simplifications keep it parser-free and cross-language robust: the ternary +``?:`` is not counted (its token is ambiguous with TS optional/nullable syntax), and nested +lambdas/functions do not add a nesting level. Both under-count slightly and uniformly, which +a gate threshold absorbs; the calibration tests pin the residual. +""" +from __future__ import annotations + +import re + +# Languages whose nesting is indentation-based rather than brace-based. +_PY_EXTS = {"py", "pyw", "pyi"} + +_STRIP = re.compile( + r'"""(?:\\.|[^\\])*?"""|\'\'\'(?:\\.|[^\\])*?\'\'\'' # python triple strings + r'|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'' # normal strings/chars + r'|`(?:\\.|[^`\\])*`' # JS/TS template strings + r'|//[^\n]*|/\*.*?\*/' # C-family comments + r'|#[^\n]*', # python/shell comments + re.S) + +# Control-flow keywords shared across the C family (and most curly-brace languages). +_NEST_KW = {"if", "for", "foreach", "while", "switch", "catch"} # +1 + nesting, opens a block +_C_TOKEN = re.compile(r"&&|\|\||[{}();]|[A-Za-z_$][A-Za-z0-9_$]*") + + +class _CScan: + """Mutable state for the C-family cognitive walk. Each step is a one-liner so the walk + loop stays flat (and this scanner stays under its own gate).""" + + def __init__(self) -> None: + self.score = 0 + self.nesting = 0 + self._brace: list[int] = [] # per '{': the nesting it added (0 or 1) + self._pending = 0 # nesting to attach to the next '{' + self._last_bool: str | None = None + self.prev: str | None = None # previous token (for do-while tails) + + def boolean(self, op: str) -> None: + if op != self._last_bool: # a run of like operators counts once + self.score += 1 + self._last_bool = op + + def end_bool_run(self) -> None: + self._last_bool = None + + def open_brace(self) -> None: + self._brace.append(self._pending) + self.nesting += self._pending + self._pending = 0 + + def close_brace(self) -> None: + if self._brace: + self.nesting -= self._brace.pop() + + def nest(self) -> None: # if/for/while/switch/catch/do: +1 + depth, block nests + self.score += 1 + self.nesting + self._pending = 1 + + def cont(self) -> None: # else / else-if: +1, no depth, block nests + self.score += 1 + self._pending = 1 + + +def _cognitive_c(source: str) -> int: + """Brace-nested cognitive complexity for the C family (Java, JS/TS, C/C++, C#, Go, …).""" + toks = _C_TOKEN.findall(_STRIP.sub(" ", source)) + s = _CScan() + # Tokens that reset the boolean run and take a fixed action (order: reset, then act). + fixed = {"{": s.open_brace, "}": s.close_brace, "do": s.nest, ";": lambda: None} + i, n = 0, len(toks) + while i < n: + t = toks[i] + if t in ("&&", "||"): # boolean run — operands between operators don't break it + s.boolean(t) + elif t in fixed: + s.end_bool_run() + fixed[t]() + elif t == "else": + s.end_bool_run() + s.cont() + if i + 1 < n and toks[i + 1] == "if": + i += 1 # consume the `if` of `else if` + elif t in _NEST_KW and not (t == "while" and s.prev == "}"): # skip do{}while tail + s.end_bool_run() + s.nest() + s.prev = t + i += 1 + return max(s.score, 0) + + +_PY_NEST_KW = {"if", "for", "while", "with", "try"} # +1 + nesting, opens a block +_PY_CONT_KW = {"elif", "else", "except", "finally"} # +1, no nesting increment +_PY_LEAD = re.compile(r"^(\s*)(\w+)") +_PY_BOOL = re.compile(r"\b(and|or)\b") + + +def _cognitive_py(source: str) -> int: + """Indentation-nested cognitive complexity for Python-like languages.""" + text = _STRIP.sub(" ", source) + score = 0 + # stack of (indent_of_block_body, contributes_nesting) for open control blocks + stack: list[tuple[int, int]] = [] + body_indent: int | None = None # indent of the def body (nesting 0 baseline) + for raw in text.split("\n"): + if not raw.strip(): + continue + m = _PY_LEAD.match(raw) + if not m: + continue + indent = len(m.group(1).expandtabs()) + kw = m.group(2) + if body_indent is None and kw != "def": + body_indent = indent + # pop blocks we have dedented out of + while stack and indent < stack[-1][0]: + stack.pop() + nesting = sum(c for _, c in stack) + if kw in _PY_NEST_KW: + score += 1 + nesting + stack.append((indent + 1, 1)) + elif kw in _PY_CONT_KW: + score += 1 + stack.append((indent + 1, 1)) + # boolean-operator runs on the line (a run of `and` = 1, of `or` = 1) + last = None + for bm in _PY_BOOL.finditer(raw): + op = bm.group(1) + if op != last: + score += 1 + last = op + return max(score, 0) + + +def cognitive_complexity(source: str, ext: str = "") -> int: + """Estimate the Cognitive Complexity of one function's source text. `ext` is the file + extension (no dot) used only to pick the nesting model; unknown extensions use the + brace-based C-family scanner, which is the right default for curly-brace languages.""" + if not source or not source.strip(): + return 0 + if ext.lower() in _PY_EXTS: + return _cognitive_py(source) + return _cognitive_c(source) diff --git a/impact_gate/core/lizard_plugin.py b/impact_gate/core/lizard_plugin.py index 80bf677..a16ff19 100644 --- a/impact_gate/core/lizard_plugin.py +++ b/impact_gate/core/lizard_plugin.py @@ -9,6 +9,7 @@ import lizard +from .cognitive import cognitive_complexity from .units import LanguagePlugin, Unit, register @@ -30,9 +31,12 @@ def parse(self, blob: bytes, path: str) -> list[Unit]: info = lizard.analyze_file.analyze_source_code(path, src) except Exception: return [] + lines = src.split("\n") + ext = path.rsplit(".", 1)[-1].lower() if "." in path else "" units: list[Unit] = [] for f in info.function_list: container, _short = _container(f.name) + body = "\n".join(lines[f.start_line - 1:f.end_line]) # signature + body span units.append(Unit( name=f.name, container=container, @@ -40,6 +44,7 @@ def parse(self, blob: bytes, path: str) -> list[Unit]: end_line=f.end_line, cc=f.cyclomatic_complexity, nloc=f.nloc, + cognitive=cognitive_complexity(body, ext), )) return units diff --git a/impact_gate/core/units.py b/impact_gate/core/units.py index c5ee6d0..a755ed3 100644 --- a/impact_gate/core/units.py +++ b/impact_gate/core/units.py @@ -18,6 +18,7 @@ class Unit: end_line: int cc: int # cyclomatic complexity nloc: int + cognitive: int = 0 # cognitive complexity (Campbell 2018); 0 if the plugin doesn't compute it class LanguagePlugin: diff --git a/impact_gate/data/defaults.json b/impact_gate/data/defaults.json index c5cdce7..fb83abe 100644 --- a/impact_gate/data/defaults.json +++ b/impact_gate/data/defaults.json @@ -22,6 +22,11 @@ "baseline_file": ".impact-gate-baseline.json" }, + "cognitive": { + "note": "Absolute per-method Cognitive Complexity (Campbell 2018) gate, independent of the change-impact composite/curve. null = off. When set, any method in a changed file whose cognitive complexity exceeds this value blocks (under 'block' enforcement). SonarSource's default line is 15.", + "cognitive_max": null + }, + "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", diff --git a/impact_gate/engine.py b/impact_gate/engine.py index 05442b2..16ac9fe 100644 --- a/impact_gate/engine.py +++ b/impact_gate/engine.py @@ -45,6 +45,18 @@ class UnitScore: kind: str # mutation / godclass / rename +@dataclass +class CognitiveUnit: + """A function in a changed file with its ABSOLUTE cognitive complexity (Campbell 2018). + + Independent of the change-impact composite: a per-method readability signal over the AFTER + version of every file the change touched, consumed by the optional cognitive gate.""" + path: str + name: str + container: str + cognitive: int + + @dataclass class FileScore: path: str @@ -80,6 +92,8 @@ class ChangeScore: files: list[FileScore] = field(default_factory=list) units: list[UnitScore] = field(default_factory=list) # sorted by cost, desc skipped: list[SkippedFile] = field(default_factory=list) # over max_diff_lines + cognitive_max: int = 0 # worst method (changed files, after) + cognitive_units: list[CognitiveUnit] = field(default_factory=list) # sorted by cognitive, desc @property def empty(self) -> bool: @@ -88,6 +102,26 @@ def empty(self) -> bool: return self.files_changed == 0 and not self.skipped +def _touches(u, added: list[tuple[int, int]]) -> bool: + """True if function `u`'s line span overlaps any of the change's NEW-side added ranges + (start, count) — i.e. this change added or edited lines inside the function.""" + for start, count in added: + if count <= 0: + continue + if not (u.end_line < start or u.start_line > start + count - 1): + return True + return False + + +def _collect_cognitive(c: ChangedFile, after_units) -> list[CognitiveUnit]: + """CognitiveUnits for the methods THIS change touched: a new file's methods, or (for a + modified file) only functions overlapping the added line ranges. A pre-existing, untouched, + already-accepted complex method in the same file is never re-flagged.""" + new_file = c.before is None + return [CognitiveUnit(c.path, u.name, u.container, u.cognitive) + for u in after_units if new_file or _touches(u, c.added)] + + def _parse(mcfg: MeasureConfig, path: str, data: bytes | None): """(source_lines, units) for one file version, or ([], []) when absent/unparsable.""" if data is None: @@ -125,6 +159,7 @@ def score_change(changed: list[ChangedFile], total_mut = total_god = 0 files: list[FileScore] = [] units: list[UnitScore] = [] + cog_units: list[CognitiveUnit] = [] for c in scored: before_src, before_units = _parse(mcfg, c.path, c.before) after_src, after_units = _parse(mcfg, c.path, c.after) @@ -142,9 +177,12 @@ def score_change(changed: list[ChangedFile], for u in fi.units: units.append(UnitScore(c.path, u.name, u.container, u.cc, u.wmc_other, u.cost, u.kind)) + cog_units.extend(_collect_cognitive(c, after_units)) files.sort(key=lambda f: f.cost, reverse=True) units.sort(key=lambda u: u.cost, reverse=True) + cog_units.sort(key=lambda u: u.cognitive, reverse=True) + cognitive_max = cog_units[0].cognitive if cog_units else 0 composite = (total_mut + total_god) * files_changed return ChangeScore(files_changed, total_mut, total_god, composite, files, units, - skipped) + skipped, cognitive_max, cog_units) diff --git a/impact_gate/report.py b/impact_gate/report.py index 37cfd16..4aa2c21 100644 --- a/impact_gate/report.py +++ b/impact_gate/report.py @@ -43,6 +43,43 @@ def _grade_line(grade, cfg: GateConfig) -> str: f"{lang}, {_grade_source(grade)})") +def _cognitive_offenders(score: ChangeScore, cfg: GateConfig, limit: int = 10): + """Methods in changed files whose cognitive complexity exceeds the gate (or [] if off).""" + if not cfg.cognitive_enabled(): + return [] + return [u for u in score.cognitive_units if u.cognitive > cfg.cognitive_max][:limit] + + +def cognitive_report(score: ChangeScore, cfg: GateConfig) -> dict | None: + """The cognitive-gate section for JSON output, or None when the gate is off.""" + if not cfg.cognitive_enabled(): + return None + offs = _cognitive_offenders(score, cfg) + return { + "threshold": cfg.cognitive_max, + "max": score.cognitive_max, + "blocked": bool(offs), + "offenders": [{"path": u.path, "name": u.name, "container": u.container, + "cognitive": u.cognitive} for u in offs], + } + + +def _cognitive_lines(score: ChangeScore, cfg: GateConfig) -> list[str]: + """Text/markdown lines naming the over-limit methods to break up (or [] when none).""" + offs = _cognitive_offenders(score, cfg, limit=5) + if not offs: + return [] + lines = ["", f"Methods over the cognitive-complexity limit ({cfg.cognitive_max}) — " + "break these into smaller methods (reduce nesting/decision depth; " + "sequential code is fine):"] + for u in offs: + 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" cognitive {u.cognitive:>4} {loc} ({where})") + return lines + + def render_text(score: ChangeScore, cfg: GateConfig, level: str, mode: str, base: str, blocked: bool, grade=None) -> str: if score.empty: @@ -84,6 +121,7 @@ def render_text(score: ChangeScore, cfg: GateConfig, level: str, where = f"in {container}" if container else "file scope" lines.append(f" {u.cost:>12,} {loc} " f"({where}, CC {u.cc}, WMC_other {u.wmc_other}, {u.kind})") + lines.extend(_cognitive_lines(score, cfg)) return "\n".join(lines) @@ -126,6 +164,9 @@ def render_json(score: ChangeScore, cfg: GateConfig, level: str, "n": grade.n, "language": grade.language, } + cog = cognitive_report(score, cfg) + if cog is not None: + out["cognitive"] = cog return json.dumps(out, indent=2) @@ -185,4 +226,15 @@ def render_markdown(score: ChangeScore, cfg: GateConfig, level: str, cls = f"`{container}`" if container else "_file scope_" lines.append(f"| {u.cost:,} | {loc} | {cls} | {u.cc} | {u.wmc_other} | " f"{u.kind} |") + offs = _cognitive_offenders(score, cfg, limit=5) + if offs: + lines += ["", f"### Methods over the cognitive-complexity limit ({cfg.cognitive_max})", + "", "Break these into smaller methods — reduce nesting/decision depth " + "(sequential code is fine).", "", + "| cognitive | location | class |", "|---|---|---|"] + for u in offs: + 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.cognitive} | {loc} | {cls} |") return "\n".join(lines) diff --git a/tests/test_cognitive.py b/tests/test_cognitive.py new file mode 100644 index 0000000..64fe2fc --- /dev/null +++ b/tests/test_cognitive.py @@ -0,0 +1,153 @@ +"""Cognitive Complexity (Campbell 2018): the estimator, the plugin field, the gate, output. + +The estimator is ImpactGate's own; these assertions are the calibration. The Java values match +PMD's CognitiveComplexity rule on the same snippets (hand-verified), which is what lets the +metric be trusted as a gate without shipping PMD. +""" +import json + +from gitutil import commit, score, stage, write + +from impact_gate import report +from impact_gate.config import GateConfig +from impact_gate.core.cognitive import cognitive_complexity as cc +from impact_gate.core.units import get_plugin + + +# --- the estimator ------------------------------------------------------------ + +def test_flat_sequential_costs_nothing(): + # The whole point: long, flat, sequential code is easy to read -> 0. + assert cc("void f(){ a(); b(); c(); int x=1; x=x+1; log(x); d(); e(); }", "java") == 0 + + +def test_nesting_matches_pmd_java(): + # if>if>if>if>while>if : 1+2+3+4+5+6 = 21 (== PMD CognitiveComplexity on this snippet). + src = "void f(){ if(a){ if(b){ if(c){ if(d){ while(e){ if(g){ h(); } } } } } } }" + assert cc(src, "java") == 21 + + +def test_boolean_operator_runs(): + assert cc("boolean f(){ return a && b && c; }", "java") == 1 # one run of && + assert cc("boolean f(){ return a && b || c && d; }", "java") == 3 # && , || , && + + +def test_else_if_is_flat_increment(): + # if (+1) + else-if (+1) + else (+1) = 3; the else chain adds no nesting. + assert cc("void f(){ if(a){x();} else if(b){y();} else {z();} }", "java") == 3 + + +def test_switch_counts_once_not_per_case(): + assert cc('String w(int n){ switch(n){ case 1: return "a"; default: return "b"; } }', + "java") == 1 + + +def test_python_indentation_model(): + src = ("def f(x):\n" + " if x:\n" + " for i in y:\n" + " if i and j:\n" + " do(i)\n" + " elif z:\n" + " pass\n") + # if(1) + for(2) + if(3) + and(1) + elif(1) = 8 + assert cc(src, "py") == 8 + + +def test_strings_and_comments_do_not_count(): + src = 'void f(){ String s = "if(a){for(;;){}}"; /* if while for */ log(s); }' + assert cc(src, "java") == 0 + + +# --- the plugin field --------------------------------------------------------- + +def test_plugin_sets_unit_cognitive(): + src = b"class D { void f(boolean a,boolean b,boolean c){ if(a){ if(b){ if(c){ x(); } } } } }" + units = get_plugin("java").parse(src, "D.java") + f = next(u for u in units if u.name.endswith("f")) + assert f.cognitive == 6 # 1 + 2 + 3 + assert f.cc >= 1 # existing metric still populated + + +# --- score_change carries it -------------------------------------------------- + +_NESTED4 = ("class D { void f(boolean a,boolean b,boolean c,boolean d){ " + "if(a){ if(b){ if(c){ if(d){ x(); } } } } } }") # cognitive 1+2+3+4 = 10 + + +def test_score_change_reports_cognitive(repo): + write(repo, "base.txt", "x") + commit(repo, "init") + stage(repo, "D.java", _NESTED4) + s = score(repo, "worktree") + assert s.cognitive_max == 10 + assert any(u.cognitive == 10 for u in s.cognitive_units) + + +def test_cognitive_scoped_to_changed_methods_only(repo): + # A file with an already-complex, ACCEPTED method `old` (cognitive 15) plus a trivial one. + v1 = ("class D {\n" + " void old(boolean a,boolean b,boolean c,boolean d,boolean e){\n" + " if(a){ if(b){ if(c){ if(d){ while(e){ x(); } } } } }\n" + " }\n" + " void small(){ y(); }\n" + "}\n") + write(repo, "D.java", v1) + commit(repo, "v1") + # Edit ONLY `small`; leave the complex `old` untouched. + v2 = v1.replace("void small(){ y(); }", "void small(){ y(); z(); w(); }") + stage(repo, "D.java", v2) + s = score(repo, "worktree") + names = [u.name for u in s.cognitive_units] + assert not any("old" in n for n in names) # untouched accepted complexity is NOT re-flagged + assert s.cognitive_max < 15 # the god-method's 15 does not surface + + +# --- the gate ----------------------------------------------------------------- + +def test_gate_off_by_default(): + assert GateConfig().cognitive_enabled() is False + + +def test_gate_blocks_strictly_over_threshold(): + cfg = GateConfig(enforcement="block", cognitive_max=8) + assert cfg.cognitive_enabled() + assert cfg.cognitive_level(10) == "block" + assert cfg.cognitive_level(8) == "ok" # strictly greater blocks + assert cfg.blocks_cognitive(10) is True + assert cfg.blocks_cognitive(8) is False + + +def test_gate_warn_mode_does_not_fail(): + cfg = GateConfig(enforcement="warn", cognitive_max=5) + assert cfg.blocks_cognitive(99) is False # only 'block' enforcement fails the gate + + +def test_validate_rejects_nonpositive(): + import pytest + with pytest.raises(ValueError): + GateConfig(cognitive_max=0).validate() + + +# --- output ------------------------------------------------------------------- + +def test_json_output_has_cognitive_section(repo): + write(repo, "base.txt", "x") + commit(repo, "init") + stage(repo, "D.java", _NESTED4) + s = score(repo, "worktree") + cfg = GateConfig(enforcement="block", cognitive_max=5) + out = json.loads(report.render_json(s, cfg, "block", "worktree", "main", True)) + assert out["cognitive"]["max"] == 10 + assert out["cognitive"]["threshold"] == 5 + assert out["cognitive"]["blocked"] is True + assert out["cognitive"]["offenders"] + + +def test_json_omits_cognitive_when_off(repo): + write(repo, "base.txt", "x") + commit(repo, "init") + stage(repo, "D.java", _NESTED4) + s = score(repo, "worktree") + out = json.loads(report.render_json(s, GateConfig(), "ok", "worktree", "main", False)) + assert "cognitive" not in out