From cc5d7fe6a1c066356358f8ad9973235e3a6eecae Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Tue, 30 Jun 2026 15:37:35 -0400 Subject: [PATCH 1/3] ci(lint): forbid misleading type+scope combinations in commit messages Add a custom gitlint rule (UC1) that rejects feat(ci), fix(ci), feat(e2e), and fix(e2e). These combinations pollute user-facing release notes with infrastructure changes that mean nothing to end users. The rule provides actionable error messages pointing to the correct prefix, e.g. "Use ci()" or "Use ci(e2e)". Signed-off-by: rbean Assisted-by: Claude claude-opus-4-6 Signed-off-by: Ralph Bean --- .gitlint | 1 + COMMITS.md | 11 ++++ Makefile | 1 + gitlint_rules/forbidden_type_scope.py | 51 ++++++++++++++++ gitlint_rules_test.py | 87 +++++++++++++++++++++++++++ 5 files changed, 151 insertions(+) create mode 100644 gitlint_rules/forbidden_type_scope.py create mode 100644 gitlint_rules_test.py diff --git a/.gitlint b/.gitlint index 50905a3a3..eaef75149 100644 --- a/.gitlint +++ b/.gitlint @@ -1,6 +1,7 @@ [general] # Enable conventional commits title check contrib=CT1 +extra-path=gitlint_rules/ [title-max-length] line-length=100 diff --git a/COMMITS.md b/COMMITS.md index 27edd4f5f..cdfce2751 100644 --- a/COMMITS.md +++ b/COMMITS.md @@ -50,6 +50,17 @@ Apply the same discipline to `fix` — bumping a dependency version is `chore`, The parenthesized scope is optional but encouraged. Use it to identify the subsystem: `feat(appsetup)`, `fix(mint)`, `docs(adr)`, `chore(ci)`. When fixing a specific issue, prefer the issue number as scope: `fix(#123): ...`. +### Forbidden type + scope combinations + +Some type/scope pairs are **enforced as errors** by gitlint (rule `UC1`). These combinations mislead users by putting infrastructure changes in user-facing release-note sections. + +| Forbidden | Why | Use instead | +|---|---|---| +| `fix(ci)` | CI changes are not user-visible bug fixes | `ci()` | +| `feat(ci)` | CI changes are not user-visible features | `ci()` | +| `fix(e2e)` | E2E test changes are not user-visible bug fixes | `ci(e2e)` | +| `feat(e2e)` | E2E test changes are not user-visible features | `ci(e2e)` | + ## Breaking changes Breaking changes **must** be marked in both commit messages and PR titles. GoReleaser builds release notes from merged PR titles (`use: github` in `.goreleaser.yml`), so an unmarked PR title means the breaking change is invisible to users reading the release notes. This has caused real incidents — users upgraded with no warning that their agents would stop working. diff --git a/Makefile b/Makefile index 0e4a9fc0b..3d6dd395a 100644 --- a/Makefile +++ b/Makefile @@ -130,6 +130,7 @@ script-test: $(call run-timed,bash internal/scaffold/fullsend-repo/scripts/pre-fetch-prior-review-test.sh) $(call run-timed,python3 internal/scaffold/fullsend-repo/scripts/process-fix-result-test.py) $(call run-timed,python3 skills/topissues/scripts/topissues_test.py) + $(call run-timed,python3 -m pytest gitlint_rules_test.py -v) test: lint-all go-test script-test lint-eval-cases diff --git a/gitlint_rules/forbidden_type_scope.py b/gitlint_rules/forbidden_type_scope.py new file mode 100644 index 000000000..8dde51269 --- /dev/null +++ b/gitlint_rules/forbidden_type_scope.py @@ -0,0 +1,51 @@ +"""Gitlint rule that forbids misleading type+scope combinations. + +Types like ``feat`` and ``fix`` appear in user-facing release notes. +Scopes like ``ci`` and ``e2e`` describe infrastructure, not user-visible +changes. Using them together pollutes the release notes with entries +that mean nothing to end users. +""" + +import re + +from gitlint.rules import CommitRule, RuleViolation + +# Pattern: type(scope): description +_CONVENTIONAL = re.compile(r"^(?P\w+)\((?P[^)]+)\)") + +# Map of (type, scope) -> suggested replacement. +# Scope matches are case-insensitive. +_FORBIDDEN = { + ("feat", "ci"): "ci()", + ("fix", "ci"): "ci()", + ("feat", "e2e"): "ci(e2e)", + ("fix", "e2e"): "ci(e2e)", +} + + +class ForbiddenTypeScope(CommitRule): + name = "forbidden-type-scope" + id = "UC1" + + def validate(self, commit): + title = commit.message.title + m = _CONVENTIONAL.match(title) + if not m: + return [] + + ctype = m.group("type").lower() + scope = m.group("scope").lower() + key = (ctype, scope) + + if key not in _FORBIDDEN: + return [] + + suggestion = _FORBIDDEN[key] + return [ + RuleViolation( + self.id, + f'"{ctype}({scope})" pollutes release notes with non-user-facing changes. ' + f"Use \"{suggestion}: ...\" instead.", + line_nr=1, + ) + ] diff --git a/gitlint_rules_test.py b/gitlint_rules_test.py new file mode 100644 index 000000000..5cc98e321 --- /dev/null +++ b/gitlint_rules_test.py @@ -0,0 +1,87 @@ +"""Tests for the ForbiddenTypeScope gitlint rule.""" + +import pytest + +from gitlint_rules.forbidden_type_scope import ForbiddenTypeScope +from gitlint.rules import RuleViolation + + +class FakeCommit: + """Minimal stand-in for a gitlint commit object.""" + + def __init__(self, title): + self.message = type("msg", (), {"title": title})() + + +def run_rule(title): + """Run ForbiddenTypeScope against a commit title and return violations.""" + rule = ForbiddenTypeScope() + commit = FakeCommit(title) + return rule.validate(commit) + + +# --- should be rejected --- + + +@pytest.mark.parametrize( + "title", + [ + "fix(ci): update workflow", + "feat(ci): add new job", + "fix(e2e): repair test", + "feat(e2e): add new test", + ], +) +def test_rejects_forbidden_combinations(title): + violations = run_rule(title) + assert violations, f"Expected violation for {title!r}" + assert len(violations) == 1 + assert isinstance(violations[0], RuleViolation) + + +def test_fix_ci_suggests_ci_subsystem(): + violations = run_rule("fix(ci): update workflow") + assert "ci()" in violations[0].message.lower() + + +def test_feat_e2e_suggests_ci_e2e(): + violations = run_rule("feat(e2e): add new test") + assert "ci(e2e)" in violations[0].message.lower() + + +# --- should be allowed --- + + +@pytest.mark.parametrize( + "title", + [ + "ci(lint): update linter config", + "ci(e2e): fix flaky test", + "fix(mint): correct token refresh", + "feat(review-agent): add outcome labels", + "chore(ci): bump action version", + "test(e2e): add new scenario", + "refactor(ci): simplify matrix", + "docs: update readme", + "fix(#123): handle nil pointer", + ], +) +def test_allows_valid_combinations(title): + violations = run_rule(title) + assert not violations, f"Unexpected violation for {title!r}: {violations}" + + +# --- should not crash on non-conventional titles --- + + +@pytest.mark.parametrize( + "title", + [ + "just a plain message", + "WIP", + "", + ], +) +def test_ignores_non_conventional(title): + violations = run_rule(title) + assert not violations From 4ac4b1231b8c0f83f92474da884b985cfb481847 Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Wed, 1 Jul 2026 14:37:45 -0400 Subject: [PATCH 2/3] chore: fix ruff lint and format issues Assisted-by: Claude Opus 4.6 Signed-off-by: Ralph Bean --- gitlint_rules/forbidden_type_scope.py | 2 +- gitlint_rules_test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gitlint_rules/forbidden_type_scope.py b/gitlint_rules/forbidden_type_scope.py index 8dde51269..833d3c309 100644 --- a/gitlint_rules/forbidden_type_scope.py +++ b/gitlint_rules/forbidden_type_scope.py @@ -45,7 +45,7 @@ def validate(self, commit): RuleViolation( self.id, f'"{ctype}({scope})" pollutes release notes with non-user-facing changes. ' - f"Use \"{suggestion}: ...\" instead.", + f'Use "{suggestion}: ..." instead.', line_nr=1, ) ] diff --git a/gitlint_rules_test.py b/gitlint_rules_test.py index 5cc98e321..99666f51c 100644 --- a/gitlint_rules_test.py +++ b/gitlint_rules_test.py @@ -1,9 +1,9 @@ """Tests for the ForbiddenTypeScope gitlint rule.""" import pytest +from gitlint.rules import RuleViolation from gitlint_rules.forbidden_type_scope import ForbiddenTypeScope -from gitlint.rules import RuleViolation class FakeCommit: From 72aa637156bff48aa7a3ca882428a7c4dfb07e11 Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Wed, 1 Jul 2026 14:50:45 -0400 Subject: [PATCH 3/3] ci(test): install pytest and gitlint-core in CI test job The gitlint_rules_test.py added in the previous commit imports pytest and gitlint.rules, but the CI workflow only installed pre-commit and jsonschema. Add both packages to the uv pip install step. Assisted-by: Claude claude-opus-4-6 Signed-off-by: Ralph Bean --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f2feeddd6..3bb1fa3ae 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -28,7 +28,7 @@ jobs: uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 - name: Install pre-commit and test dependencies - run: uv pip install --system pre-commit jsonschema + run: uv pip install --system pre-commit jsonschema pytest gitlint-core - name: Install lychee run: |