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
2 changes: 2 additions & 0 deletions .github/workflows/impact.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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`.
Expand Down
5 changes: 5 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
Expand Down Expand Up @@ -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 }}
Expand All @@ -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"
Expand Down
14 changes: 13 additions & 1 deletion impact_gate/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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":
Expand Down
25 changes: 24 additions & 1 deletion impact_gate/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down
160 changes: 160 additions & 0 deletions impact_gate/core/cognitive.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions impact_gate/core/lizard_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import lizard

from .cognitive import cognitive_complexity
from .units import LanguagePlugin, Unit, register


Expand All @@ -30,16 +31,20 @@ 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,
start_line=f.start_line,
end_line=f.end_line,
cc=f.cyclomatic_complexity,
nloc=f.nloc,
cognitive=cognitive_complexity(body, ext),
))
return units

Expand Down
1 change: 1 addition & 0 deletions impact_gate/core/units.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions impact_gate/data/defaults.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading