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
54 changes: 52 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,12 +401,12 @@ print(result["status"]) # "fail" — 'docs' not in allowed types

```python
{
"status": "pass" | "fail",
"status": "pass" | "fail" | "skip",
"checks": [
{
"rule_id": "<rule identifier, e.g. CC001>",
"check": "<rule name>",
"status": "pass" | "fail",
"status": "pass" | "fail" | "skip",
"value": "<actual value that was checked>",
"error": "<human-readable error description>",
"suggest": "<how to fix>",
Expand All @@ -417,6 +417,56 @@ print(result["status"]) # "fail" — 'docs' not in allowed types
}
```

`skip` means the rule never ran — the author matched `ignore_authors`, or
there was nothing to check. It is deliberately not `pass`: a skipped rule
validated nothing, so reporting it as a pass makes a bypassed policy
indistinguishable from an enforced one. A skipped check carries no `value`,
since nothing was examined.

The top-level `status` is `skip` only when **every** check skipped; one real
verdict makes it `pass` or `fail` as before. Only `fail` is an error, and the
CLI exit code follows that — a fully skipped run still exits `0`, so code
branching on `status == "fail"` is unaffected.

```bash
echo "chore(deps): bump commit-check" | CCHK_IGNORE_AUTHORS="dependabot[bot]" commit-check -m --format json
```

```json
{
"status": "skip",
"checks": [
{
"rule_id": "CC001",
"check": "message",
"status": "skip",
"value": "",
"error": "",
"suggest": "",
"docs_url": "https://commit-check.com/rules/#cc001"
},
{
"rule_id": "CC004",
"check": "subject_max_length",
"status": "skip",
"value": "",
"error": "",
"suggest": "",
"docs_url": "https://commit-check.com/rules/#cc004"
},
{
"rule_id": "CC005",
"check": "subject_min_length",
"status": "skip",
"value": "",
"error": "",
"suggest": "",
"docs_url": "https://commit-check.com/rules/#cc005"
}
]
}
```

Available API functions:

- `validate_message(message, *, config=None)` — validate a commit message string
Expand Down
22 changes: 16 additions & 6 deletions commit_check/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,26 @@
Return-value schema (all functions)::

{
"status": "pass" | "fail",
"status": "pass" | "fail" | "skip",
"checks": [
{
"check": "<rule name>",
"status": "pass" | "fail",
"status": "pass" | "fail" | "skip",
"value": "<actual value that was checked>",
"error": "<error description>",
"suggest": "<how to fix>",
},
...
]
}

``"skip"`` means the rule never ran — the author is on an ``ignore_authors``
list, or there was nothing to check. It is deliberately not ``"pass"``: a
skipped rule validated nothing, and collapsing the two makes a bypassed
policy indistinguishable from an enforced one. The top-level ``status`` is
``"skip"`` only when *every* check skipped; a run with any real verdict
reports ``"pass"`` or ``"fail"`` as before. Only ``"fail"`` is an error, so
code branching on ``status == "fail"`` keeps working unchanged.
"""

from __future__ import annotations
Expand All @@ -43,6 +51,7 @@
CheckOutcome,
ValidationContext,
ValidationEngine,
overall_status,
)
from commit_check.rule_builder import RuleBuilder

Expand All @@ -55,9 +64,8 @@
def _build_result(outcomes: list[CheckOutcome]) -> dict[str, Any]:
"""Convert a list of :class:`~commit_check.engine.CheckOutcome` into the
public return-value dict."""
overall = "fail" if any(o.status == "fail" for o in outcomes) else "pass"
return {
"status": overall,
"status": overall_status(o.status for o in outcomes),
"checks": [o.to_dict() for o in outcomes],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

Expand Down Expand Up @@ -245,7 +253,9 @@ def validate_author(
cfg,
)
all_checks = name_result["checks"] + email_result["checks"]
overall = "fail" if any(c["status"] == "fail" for c in all_checks) else "pass"
# Shared reducer, not a local "fail or else pass": a combined call
# in which every nested check skipped is still a skip.
overall = overall_status(c["status"] for c in all_checks)
return {"status": overall, "checks": all_checks}

stdin = None
Expand Down Expand Up @@ -303,5 +313,5 @@ def validate_all(
author_result = validate_author(author_name, author_email, config=config)
all_checks.extend(author_result["checks"])

overall = "fail" if any(c["status"] == "fail" for c in all_checks) else "pass"
overall = overall_status(c["status"] for c in all_checks)
return {"status": overall, "checks": all_checks}
76 changes: 61 additions & 15 deletions commit_check/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Iterable
from dataclasses import dataclass
from enum import IntEnum
from dataclasses import field
Expand All @@ -27,10 +28,22 @@


class ValidationResult(IntEnum):
"""Validation result codes."""
"""Validation result codes.

``SKIP`` means the validator declined to run — the author is on an
ignore list, or there was nothing to check — as opposed to ``PASS``,
which means the rule ran and found nothing to object to. Reporting a
skip as a pass makes a bypassed policy indistinguishable from an
enforced one, so the two are kept apart.

Only ``FAIL`` is an error. ``validate_all`` returns ``PASS``/``FAIL``
explicitly rather than propagating this value, so the new member never
reaches an exit code.
"""

PASS = 0
FAIL = 1
SKIP = 2


@dataclass(frozen=True)
Expand All @@ -55,7 +68,11 @@
"""

check: str
status: str # "pass" or "fail"
# "pass" (the rule ran and was satisfied), "fail" (the rule ran and was
# not), or "skip" (the rule never ran — ignored author, or nothing to
# check). A skip is not a pass: it means the policy was bypassed, and
# collapsing the two lets a run that validated nothing report success.
status: str
# The concrete value that was checked (subject, branch, author, ...),
# populated on both pass and fail so consumers can report what was
# validated even when the check succeeded.
Expand All @@ -78,6 +95,31 @@
}


def overall_status(statuses: Iterable[str]) -> str:
"""Reduce per-check statuses to one of ``"pass"``/``"fail"``/``"skip"``.

Takes plain status strings rather than a specific type so that every
caller can share it: the CLI's ``--format json`` and the API's
:class:`CheckOutcome` objects, and the API's combined paths
(``validate_author`` with both inputs, ``validate_all``) which merge
already-serialised check dicts.

That breadth is the point. This rule had been copied into four places,
and each copy defaulted to ``"pass"`` for anything that was not a
failure — which is how a fully skipped run kept reporting success even
after the skip status existed.

``"skip"`` requires that *every* check skipped: a single real verdict
means something was actually validated. Only ``"fail"`` is an error.
"""
seen = list(statuses)
if any(s == "fail" for s in seen):
return "fail"
if seen and all(s == "skip" for s in seen):
return "skip"
return "pass"


class BaseValidator(ABC):
"""Abstract base validator."""

Expand Down Expand Up @@ -272,7 +314,7 @@

def validate(self, context: ValidationContext) -> ValidationResult:
if self._should_skip_commit_validation(context):
return ValidationResult.PASS
return ValidationResult.SKIP

message = self._get_commit_message(context)
if not message:
Expand All @@ -294,7 +336,7 @@

def validate(self, context: ValidationContext) -> ValidationResult:
if self._should_skip_commit_validation(context):
return ValidationResult.PASS
return ValidationResult.SKIP

subject = self._get_subject(context)
if not subject:
Expand Down Expand Up @@ -401,7 +443,7 @@
def validate(self, context: ValidationContext) -> ValidationResult:
# Use commit skip logic for ignore_authors
if self._should_skip_commit_validation(context):
return ValidationResult.PASS
return ValidationResult.SKIP

author_value = self._get_author_value(context)
if not author_value:
Expand Down Expand Up @@ -455,7 +497,8 @@
return ValidationResult.FAIL

if self.rule.ignored and author_value in self.rule.ignored:
return ValidationResult.PASS # Ignored authors pass silently
# An ignored author is a deliberate bypass, not a verdict.
return ValidationResult.SKIP

return ValidationResult.PASS

Expand All @@ -465,7 +508,7 @@

def validate(self, context: ValidationContext) -> ValidationResult:
if self._should_skip_branch_validation(context):
return ValidationResult.PASS
return ValidationResult.SKIP
branch_name = (
context.stdin_text.strip()
if context.stdin_text is not None
Expand All @@ -490,7 +533,7 @@

def validate(self, context: ValidationContext) -> ValidationResult:
if self._should_skip_branch_validation(context):
return ValidationResult.PASS
return ValidationResult.SKIP

current_branch = get_branch_name()
target_pattern = self.rule.regex
Expand Down Expand Up @@ -588,7 +631,7 @@

def validate(self, context: ValidationContext) -> ValidationResult:
if self._should_skip_commit_validation(context):
return ValidationResult.PASS
return ValidationResult.SKIP

message = self._get_commit_message(context)
if not message:
Expand All @@ -610,7 +653,7 @@

def validate(self, context: ValidationContext) -> ValidationResult:
if self._should_skip_commit_validation(context):
return ValidationResult.PASS
return ValidationResult.SKIP

message = self._get_commit_message(context)
if not message:
Expand Down Expand Up @@ -767,9 +810,9 @@
self._checked_value = self._resolve_current_author(context)
if self._should_skip_commit_validation(context):
self._checked_value = ""
return ValidationResult.PASS
return ValidationResult.SKIP
elif self._should_skip_commit_validation(context):
return ValidationResult.PASS
return ValidationResult.SKIP

message = self._get_commit_message(context)
# allow_empty_commits is the rule that exists to judge an empty
Expand Down Expand Up @@ -851,7 +894,7 @@

def validate(self, context: ValidationContext) -> ValidationResult:
if self._should_skip_commit_validation(context):
return ValidationResult.PASS
return ValidationResult.SKIP

message = self._get_commit_body(context)
if not message:
Expand Down Expand Up @@ -950,7 +993,7 @@
else ValidationResult.PASS
)

def validate_all_detailed(self, context: ValidationContext) -> list[CheckOutcome]:

Check failure on line 996 in commit_check/engine.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 18 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=commit-check_commit-check&issues=AZ_b0E68nSwiWDspQKZb&open=AZ_b0E68nSwiWDspQKZb&pullRequest=537
"""Run all validations and return structured :class:`CheckOutcome` objects.

Unlike :meth:`validate_all`, this method:
Expand Down Expand Up @@ -991,11 +1034,14 @@
)
)
else:
# A skipped rule never ran, so it has no value to report and
# must not be reported as a pass — see ValidationResult.SKIP.
skipped = result == ValidationResult.SKIP
outcomes.append(
CheckOutcome(
check=rule.check,
status="pass",
value=validator._checked_value or "",
status="skip" if skipped else "pass",
value="" if skipped else (validator._checked_value or ""),
rule_id=rule.rule_id or "",
docs_url=rule.docs_url or "",
)
Expand Down
7 changes: 5 additions & 2 deletions commit_check/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
ValidationContext,
ValidationResult,
CheckOutcome,
overall_status,
)
from . import __version__

Expand Down Expand Up @@ -446,7 +447,7 @@ def _get_requested_checks(args: argparse.Namespace) -> list[str]:
def _run_json_output(engine: ValidationEngine, context: ValidationContext) -> int:
"""Run validation and print JSON output."""
outcomes: list[CheckOutcome] = engine.validate_all_detailed(context)
overall = "fail" if any(o.status == "fail" for o in outcomes) else "pass"
overall = overall_status(o.status for o in outcomes)
print(
json.dumps(
{
Expand All @@ -456,7 +457,9 @@ def _run_json_output(engine: ValidationEngine, context: ValidationContext) -> in
indent=2,
)
)
return 0 if overall == "pass" else 1
# Only a failure is an error. A skipped run validated nothing, but that
# is not a policy violation, so it must not turn into a non-zero exit.
return 1 if overall == "fail" else 0


def main() -> int:
Expand Down
Loading