Skip to content

Commit d20bc2c

Browse files
authored
fix: report a fully skipped run as skip, add warnings, require commit-check 2.17 (#34)
Every validation tool reduced its per-check statuses with its own copy of `"fail" if any failed else "pass"`, so a run in which every check skipped (the author is on `ignore_authors`, for example) came back as `pass`. An agent reading that took a bypassed policy for an enforced one. The four copies are replaced by one `_summarize` helper built on commit-check's own `overall_status` and `count_warnings`, so the MCP result now matches the CLI's `--format json`: `status` is `skip` when nothing was validated, a `warn` check leaves `status` at `pass`, and a top-level `warnings` field counts them. Both helpers exist since commit-check 2.17.0, which becomes the floor; `uv.lock` is regenerated accordingly. The `fix` key has been part of every release the floor allows, so the `setdefault("fix", "")` shim for older engines and its test are removed, along with the README caveat about them. The README documents the `skip` and `warn` statuses and the `warnings` field.
1 parent 1b7621a commit d20bc2c

5 files changed

Lines changed: 143 additions & 54 deletions

File tree

‎README.md‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,12 @@ All validation tools return the same structured commit-check result shape:
2929

3030
```json
3131
{
32-
"status": "pass|fail",
32+
"status": "pass|fail|skip",
33+
"warnings": 0,
3334
"checks": [
3435
{
3536
"check": "message",
36-
"status": "pass|fail",
37+
"status": "pass|fail|warn|skip",
3738
"value": "...",
3839
"error": "...",
3940
"suggest": "...",
@@ -43,12 +44,17 @@ All validation tools return the same structured commit-check result shape:
4344
}
4445
```
4546

47+
Only `fail` is a rejection. A check reports `skip` when it did not run — the
48+
author matched `ignore_authors`, or there was nothing to check — and the
49+
top-level `status` is `skip` only when **every** check skipped, so a run that
50+
validated nothing is never reported as a pass. A check reports `warn` when the
51+
config lists it under `warn`: the finding is complete, but it does not fail
52+
the run, the top-level `status` stays `pass`, and `warnings` counts them.
53+
4654
`suggest` is the advice a person reads. `fix` is the corrected value itself,
4755
present only when the correction is unambiguous — `Fix: add x` comes back with
4856
`"fix": "fix: add x"` — and an empty string otherwise, so an agent can apply
4957
a non-empty `fix` as it stands and fall back to `suggest` when it is empty.
50-
Populating `fix` needs a commit-check release that carries the field; with an
51-
older commit-check the key is present and always empty.
5258

5359
A call that cannot run at all — an empty `message`, a `repo_path` that does not
5460
exist, a `repo_path` that is not a git repository when the tool has to read git

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ classifiers = [
2525
"Topic :: Software Development",
2626
]
2727
dependencies = [
28-
"commit-check>=2.11.0,<3",
28+
"commit-check>=2.17.0,<3",
2929
"mcp>=2,<3"
3030
]
3131
dynamic = ["version"]

‎src/commit_check_mcp/server.py‎

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@
1111

1212
from commit_check import __version__ as commit_check_version
1313
from commit_check.config_merger import deep_merge, get_default_config, load_toml_config
14-
from commit_check.engine import ValidationContext, ValidationEngine, CheckOutcome
14+
from commit_check.engine import (
15+
CheckOutcome,
16+
ValidationContext,
17+
ValidationEngine,
18+
count_warnings,
19+
overall_status,
20+
)
1521
from commit_check.rule_builder import RuleBuilder, ValidationRule
1622
from commit_check.rules_catalog import BRANCH_RULES, COMMIT_RULES, PUSH_RULES
1723
from mcp.server.mcpserver import MCPServer
@@ -159,15 +165,24 @@ def _run_checks(
159165

160166
engine = ValidationEngine(filtered)
161167
outcomes: list[CheckOutcome] = engine.validate_all_detailed(context)
162-
checks = [o.to_dict() for o in outcomes]
163-
# commit-check names the corrected value in "fix" when a failure has an
164-
# unambiguous one. Older engines have no such field; give the key a
165-
# stable presence so an agent can always test it instead of probing for it.
166-
for check in checks:
167-
check.setdefault("fix", "")
168+
return _summarize([o.to_dict() for o in outcomes])
169+
168170

169-
overall = "fail" if any(c["status"] == "fail" for c in checks) else "pass"
170-
return {"status": overall, "checks": checks}
171+
def _summarize(checks: list[dict[str, Any]]) -> dict[str, Any]:
172+
"""Wrap per-check results in the shape every validation tool returns.
173+
174+
The overall ``status`` comes from commit-check's own reducer, so a run in
175+
which every check skipped is reported as ``"skip"`` rather than ``"pass"``:
176+
nothing was validated, and an agent must not read that as approval. A
177+
``"warn"`` is a finding the config asked to report without enforcing; it
178+
leaves ``status`` at ``"pass"`` and is counted in ``warnings``.
179+
"""
180+
statuses = [c["status"] for c in checks]
181+
return {
182+
"status": overall_status(statuses),
183+
"warnings": count_warnings(statuses),
184+
"checks": checks,
185+
}
171186

172187

173188
def _validate_message(
@@ -267,11 +282,7 @@ def _validate_author(
267282
ValidationContext(stdin_text=email, config=cfg),
268283
cfg,
269284
)
270-
checks = name_result["checks"] + email_result["checks"]
271-
return {
272-
"status": "fail" if any(c["status"] == "fail" for c in checks) else "pass",
273-
"checks": checks,
274-
}
285+
return _summarize(name_result["checks"] + email_result["checks"])
275286

276287
check_names: list[str] = []
277288
stdin = None
@@ -350,10 +361,7 @@ def _validate_all(
350361
)["checks"]
351362
)
352363

353-
return {
354-
"status": "fail" if any(c["status"] == "fail" for c in checks) else "pass",
355-
"checks": checks,
356-
}
364+
return _summarize(checks)
357365

358366

359367
@mcp.tool()
@@ -620,10 +628,7 @@ def validate_repository_state(
620628
)["checks"]
621629
)
622630

623-
return {
624-
"status": "fail" if any(c["status"] == "fail" for c in checks) else "pass",
625-
"checks": checks,
626-
}
631+
return _summarize(checks)
627632

628633

629634
@mcp.tool()

‎tests/test_server.py‎

Lines changed: 101 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -890,6 +890,107 @@ def fake_run(*, transport: str) -> None:
890890
assert called
891891

892892

893+
# ---------------------------------------------------------------------------
894+
# _summarize: the overall status and the warnings count
895+
# ---------------------------------------------------------------------------
896+
897+
898+
def _outcome(status: str, check: str = "message") -> object:
899+
class Outcome:
900+
def to_dict(self) -> dict[str, str]:
901+
return {
902+
"check": check,
903+
"status": status,
904+
"value": "x",
905+
"error": "",
906+
"suggest": "",
907+
"fix": "",
908+
}
909+
910+
return Outcome()
911+
912+
913+
class TestSummarize:
914+
def test_a_fully_skipped_run_is_reported_as_skip_not_pass(
915+
self, monkeypatch: pytest.MonkeyPatch
916+
) -> None:
917+
"""Every check skipped means nothing was validated. The old hand-rolled
918+
reducer defaulted anything that was not a failure to "pass", which
919+
told an agent a bypassed policy had been enforced."""
920+
from commit_check.engine import ValidationContext
921+
922+
monkeypatch.setattr(
923+
server.ValidationEngine,
924+
"validate_all_detailed",
925+
lambda self, context: [_outcome("skip"), _outcome("skip", "author_name")],
926+
)
927+
result = server._run_checks(
928+
["message", "author_name"], ValidationContext(stdin_text="x"), server._merge_config(None)
929+
)
930+
assert result["status"] == "skip"
931+
assert result["warnings"] == 0
932+
933+
def test_one_real_verdict_outweighs_the_skips(self, monkeypatch: pytest.MonkeyPatch) -> None:
934+
from commit_check.engine import ValidationContext
935+
936+
monkeypatch.setattr(
937+
server.ValidationEngine,
938+
"validate_all_detailed",
939+
lambda self, context: [_outcome("skip"), _outcome("pass", "branch")],
940+
)
941+
result = server._run_checks(
942+
["message", "branch"], ValidationContext(stdin_text="x"), server._merge_config(None)
943+
)
944+
assert result["status"] == "pass"
945+
946+
def test_a_warned_rule_is_counted_and_does_not_fail_the_run(self) -> None:
947+
"""Real engine: the config asks for the message rule as a warning."""
948+
from commit_check.engine import ValidationContext
949+
950+
cfg = server._merge_config({"warn": ["message"]})
951+
result = server._run_checks(
952+
["message"], ValidationContext(stdin_text="not conventional", config=cfg), cfg
953+
)
954+
assert result["status"] == "pass"
955+
assert result["warnings"] == 1
956+
assert result["checks"][0]["status"] == "warn"
957+
assert result["checks"][0]["error"]
958+
959+
def test_a_failure_still_fails(self) -> None:
960+
from commit_check.engine import ValidationContext
961+
962+
cfg = server._merge_config(None)
963+
result = server._run_checks(
964+
["message"], ValidationContext(stdin_text="not conventional", config=cfg), cfg
965+
)
966+
assert result["status"] == "fail"
967+
assert result["warnings"] == 0
968+
969+
def test_the_combined_tools_reduce_with_the_same_rule(
970+
self, monkeypatch: pytest.MonkeyPatch
971+
) -> None:
972+
"""validate_commit_context, validate_author_info with both inputs and
973+
validate_repository_state used to carry their own copy of the reducer."""
974+
975+
def skipped(check_names, context, config):
976+
return server._summarize(
977+
[
978+
{"check": cn, "status": "skip", "value": "", "error": "", "suggest": "", "fix": ""}
979+
for cn in check_names
980+
]
981+
)
982+
983+
monkeypatch.setattr(server, "_run_checks", skipped)
984+
985+
context = server._validate_all(message="x", branch="main")
986+
assert context["status"] == "skip"
987+
assert context["warnings"] == 0
988+
989+
author = server._validate_author(name="A", email="a@b.c")
990+
assert author["status"] == "skip"
991+
assert "warnings" in author
992+
993+
893994
# ---------------------------------------------------------------------------
894995
# _run_checks: the "fix" key
895996
# ---------------------------------------------------------------------------
@@ -931,29 +1032,6 @@ def to_dict(self) -> dict[str, str]:
9311032
assert result["checks"][0]["fix"] == "fix: add x"
9321033
assert result["checks"][0]["suggest"] == 'Use "fix: add x"'
9331034

934-
def test_an_engine_without_the_field_yields_an_empty_fix(
935-
self, monkeypatch: pytest.MonkeyPatch
936-
) -> None:
937-
from commit_check.engine import ValidationContext
938-
939-
class OldOutcome:
940-
def to_dict(self) -> dict[str, str]:
941-
return {
942-
"check": "message",
943-
"status": "fail",
944-
"value": "Fix: add x",
945-
"error": "Not conventional",
946-
"suggest": "Use a conventional type",
947-
}
948-
949-
monkeypatch.setattr(
950-
server.ValidationEngine, "validate_all_detailed", lambda self, context: [OldOutcome()]
951-
)
952-
result = server._run_checks(
953-
["message"], ValidationContext(stdin_text="Fix: add x"), server._merge_config(None)
954-
)
955-
assert result["checks"][0]["fix"] == ""
956-
9571035

9581036
# ---------------------------------------------------------------------------
9591037
# Errors reach the agent as tool errors (through the MCP tool manager)

‎uv.lock‎

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)