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
20 changes: 12 additions & 8 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,8 @@ by any of these types produces a `MAJOR`.

#### Scope

The scope MAY be provided. When present it MUST be lowercase and MUST be one of:
The scope MAY be provided, and is **required** on `feat` and `fix` (see below).
When present it MUST be lowercase and MUST be one of:

| Scope | Covers |
|---|---|
Expand All @@ -315,13 +316,16 @@ and `dum` (scope `dummies` → `Dummies`). A commit's scope decides which train'
release notes and changelog it lands in; see
[Adding a release train](doc/handwritten/for-maintainers/AddingAReleaseTrain.en.md).

Because of that, a user-facing change **must** carry a scope: an unscoped `feat:`
or `fix:` matches no train and is **silently dropped from every release note and
changelog**, so the change would vanish from the release record. Only what
genuinely belongs to no component stays unscoped — repository infrastructure (the
solution, `Directory.Build.props`, the workflows, `.gitignore`, `CLAUDE.md`),
repository-wide documentation, ADRs, and the `FirstClassErrors.Usage` samples —
and those use non-version-driving types anyway: `ci: …`, `docs: …`, `chore: …`.
Because of that, **`commit-lint` requires a scope on the two version-driving
types, `feat` and `fix`**, and rejects an unscoped one at the `commit-msg` hook
and in CI ([ADR-0034](doc/handwritten/for-maintainers/adr/0034-require-a-scope-on-the-version-driving-commit-types.md)):
it would match no train and be **silently dropped from every release note and
changelog**, vanishing from the release record. Every other type keeps the scope
optional — what genuinely belongs to no component stays unscoped (repository
infrastructure: the solution, `Directory.Build.props`, the workflows,
`.gitignore`, `CLAUDE.md`; repository-wide documentation; ADRs; the
`FirstClassErrors.Usage` samples), and those use non-version-driving types
anyway: `ci: …`, `docs: …`, `chore: …`.

When one atomic change crosses several components, the commit MUST carry all
their scopes, comma-separated with no space and ordered alphabetically. The
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# ADR-0034 | Exiger un scope sur les types de commit qui pilotent la version

🌍 🇬🇧 [English](0034-require-a-scope-on-the-version-driving-commit-types.md) · 🇫🇷 Français (ce fichier)

**Statut :** Accepté
**Date :** 2026-07-23
**Décideurs :** Reefact

## Contexte

Le dépôt publie plusieurs paquets versionnés indépendamment, regroupés en **trains de release** ; `tools/trains.sh` est la source de vérité unique qui associe chaque scope de Conventional Commit à un train (`lib`, `cli`, `dum`). Les release notes GitHub et le changelog d'un train sont générés (`tools/packaging/release-notes.sh`, `tools/changelog/collect-prs.sh`) en sélectionnant les commits dont le scope appartient à ce train. Un commit sans scope n'appartient à aucun train et est écarté. Cet écart est voulu : les commits d'infrastructure (`ci`, `chore`, `build`, `docs`) ne portent pas de scope et n'ont pas à figurer dans le dossier de release d'un paquet.

La convention de commit (CONTRIBUTING.md) rend le scope syntaxiquement optionnel sur tous les types. Les deux types **qui pilotent la version** sont `feat` (pilote `MINOR`) et `fix` (pilote `PATCH`) ; par définition, ils dénotent un changement visible par le consommateur d'un paquet. `commit-lint` — un unique script partagé par le hook local `commit-msg` et la vérification CI — valide la forme de l'en-tête et, jusqu'ici, acceptait un `feat`/`fix` sans scope.

La conséquence a déjà été observée et documentée (issue #231, PR #292) : un contributeur qui suit la convention à la lettre peut écrire un `feat:`/`fix:` visible par l'utilisateur et sans scope, qui disparaît alors silencieusement à la fois des release notes et du changelog. Le changement est livré dans les binaires mais n'apparaît jamais dans le dossier de release, sans erreur à aucun moment.

## Décision

`commit-lint` exige un scope de Conventional Commit sur les deux types qui pilotent la version, `feat` et `fix`, et rejette un commit sans scope — de façon identique au hook `commit-msg` et à la vérification CI — tandis que tout autre type conserve le scope optionnel.

## Justification

Le dossier de release est partitionné uniquement par le scope ; sur les types qui pilotent la version, le scope n'est donc pas une aide à la lisibilité mais la clé de routage qui décide si un changement visible par le consommateur est enregistré, ou non. Un `feat`/`fix` sans scope n'est pas simplement moins lisible — il est retiré du compte rendu public de ce qui a été livré, en silence.

La documentation seule ne peut pas combler ce manque. La règle déjà énoncée dans CONTRIBUTING dépend du fait que l'auteur se souvienne d'une conséquence invisible au moment d'écrire le commit ; l'échec ne produit aucun signal et ne se révèle que plus tard, comme un trou qu'un utilisateur remarque. Porter la règle dans `commit-lint` transforme une convention à mémoriser en un invariant appliqué, attrapé à l'écriture et — pour un hook local contourné — de nouveau en CI, la même application en couches sur laquelle la convention s'appuie déjà pour ses autres règles.

Confiner l'exigence à `feat` et `fix`, c'est ce qui la garde correcte plutôt que seulement stricte : seuls ces deux types pilotent une version et dénotent un changement visible par le consommateur. Les types qui ne pilotent pas la version sont précisément le travail d'infrastructure et à l'échelle du dépôt qui n'appartient légitimement à aucun train ; y exiger un scope serait du bruit et présenterait à tort du travail de dépôt comme du travail de composant.

Le coût — rejeter certains messages valides aujourd'hui — est borné et auto-correcteur : l'auteur ajoute un scope dès que le hook ou la CI le signale, guidé par un message qui nomme la conséquence. La grammaire de l'en-tête et le câblage partagé hook/CI relèvent de l'implémentation, dans `tools/commit-lint/lint-commit-message.sh` et le workflow commit-lint — pas ici.

## Alternatives envisagées

### Continuer à documenter la règle sans l'appliquer (l'état après #231)

Envisagé parce que #231 / #292 ont déjà corrigé la documentation, de sorte que la convention énonce désormais l'exigence en prose. Rejeté parce qu'une règle en prose contre un échec silencieux et invisible repose sur le fait que l'auteur s'en souvienne précisément quand rien ne signale l'erreur ; le dossier de release peut toujours perdre un changement visible par l'utilisateur, ce que la règle existe pour empêcher.

### Exiger un scope sur tous les types

Envisagé parce qu'une règle unique et uniforme « toujours un scope » est la plus simple à énoncer et à implémenter. Rejeté parce que les types qui ne pilotent pas la version sont le cas légitime « sans scope » — infrastructure, documentation à l'échelle du dépôt, ADR, exemples de `FirstClassErrors.Usage` — et y forcer un scope serait du bruit et attribuerait à tort du travail de dépôt à un composant et à son train.

### Modifier l'outillage de release pour conserver les feat/fix sans scope

Envisagé parce que l'écart se produit dans `release-notes.sh` / `collect-prs.sh`, qui pourraient au contraire router un commit qui pilote la version et sans scope vers un train par défaut. Rejeté parce qu'il n'existe pas de train par défaut correct pour un changement visible par le consommateur et sans scope : le scope est précisément ce qui identifie quel paquet a changé, donc deviner classerait le changement dans le dossier de release du mauvais paquet — une erreur plus subtile et plus difficile à remarquer que refuser le commit ambigu à la source.

## Conséquences

### Positives

* Un `feat`/`fix` visible par l'utilisateur ne peut plus disparaître des release notes ni du changelog ; le dossier de release est complet par construction.
* La règle que la convention documente déjà devient appliquée — à l'écriture et en CI — comblant le manque que #231 ne pouvait que décrire.
* Le travail d'infrastructure et à l'échelle du dépôt reste sans scope, de sorte que l'application n'ajoute aucune friction là où un scope n'aurait pas de sens.

### Négatives

* Des messages de commit valides aujourd'hui (`feat: …` / `fix: …` sans scope) sont désormais rejetés ; les contributeurs doivent ajouter un scope.
* La convention gagne une règle dépendante du type (scope requis pour `feat`/`fix`, optionnel sinon) au lieu d'un « scope optionnel » uniforme, une légère augmentation de la surface de la règle.

### Risques

* Un changement véritablement transverse ou limité aux samples, visible par l'utilisateur, pourrait n'avoir aucun scope unique naturel. Mesure : un changement qui traverse plusieurs composants porte déjà tous leurs scopes (la convention exige la liste complète séparée par des virgules) ; un changement qui n'appartient réellement à aucun composant n'est, par définition, pas un `feat`/`fix` de composant et prend un type qui ne pilote pas la version.
* L'historique et les branches en cours créés avant l'adoption peuvent contenir des `feat`/`fix` sans scope. Mesure : la règle s'applique à partir de son adoption, aux nouveaux commits, cohérent avec CONTRIBUTING → « Adoption » ; l'historique antérieur n'est pas réécrit et les commits de merge restent exemptés.

## Actions de suivi

* Appliquer l'exigence dans `tools/commit-lint/lint-commit-message.sh` (fait dans la pull request d'implémentation).
* Énoncer la règle appliquée dans CONTRIBUTING.md et sa traduction française (fait dans la pull request d'implémentation).
* Aucune modification de l'outillage de release n'est nécessaire : la sémantique de partition est inchangée ; seul le linter gagne l'exigence.

## Références

* Issue [#293](https://github.com/Reefact/first-class-errors/issues/293) — la présente décision.
* Issue [#231](https://github.com/Reefact/first-class-errors/issues/231) et PR [#292](https://github.com/Reefact/first-class-errors/pull/292) — ont fait surgir et documenté la dérive que cette décision applique.
* `tools/trains.sh`, `tools/packaging/release-notes.sh`, `tools/changelog/collect-prs.sh` — la partition par scope et l'écart des commits sans scope.
* `tools/commit-lint/lint-commit-message.sh` et `.github/workflows/commit-lint.yml` — là où l'exigence est appliquée.
* CONTRIBUTING.md → « Scope » et « Adoption ».
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# ADR-0034 | Require a scope on the version-driving commit types

🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0034-require-a-scope-on-the-version-driving-commit-types.fr.md)

**Status:** Accepted
**Date:** 2026-07-23
**Decision Makers:** Reefact

## Context

The repository publishes several independently-versioned packages grouped into **release trains**; `tools/trains.sh` is the single source of truth mapping each Conventional Commit scope to a train (`lib`, `cli`, `dum`). A train's GitHub Release notes and changelog are generated (`tools/packaging/release-notes.sh`, `tools/changelog/collect-prs.sh`) by selecting the commits whose scope belongs to that train. A commit with no scope belongs to no train and is excluded. That exclusion is intended: infrastructure commits (`ci`, `chore`, `build`, `docs`) carry no scope and should not appear in a package's release record.

The commit convention (CONTRIBUTING.md) makes the scope syntactically optional on every type. The two **version-driving** types are `feat` (drives `MINOR`) and `fix` (drives `PATCH`); by definition they denote a change visible to the consumer of a package. `commit-lint` — one script shared by the local `commit-msg` hook and the CI check — validates the header shape and, until now, accepted a `feat`/`fix` with no scope.

The consequence has already been observed and documented (issue #231, PR #292): a contributor following the letter of the convention can write an unscoped, user-facing `feat:`/`fix:`, which then silently disappears from both the release notes and the changelog. The change ships in the binaries yet never appears in the release record, with no error at any point.

## Decision

`commit-lint` requires a Conventional Commit scope on the two version-driving types, `feat` and `fix`, and rejects an unscoped one — identically at the `commit-msg` hook and in the CI check — while every other type keeps the scope optional.

## Rationale

The release record is partitioned solely by scope, so on the version-driving types the scope is not a readability aid but the routing key that decides whether a consumer-visible change is recorded at all. A `feat`/`fix` without it is not merely less readable — it is dropped from the public account of what shipped, silently.

Documentation alone cannot close that gap. The rule already stated in CONTRIBUTING depends on the author recalling an invisible consequence at the moment of writing the commit; the failure produces no signal and only surfaces later as a hole a user notices. Moving the rule into `commit-lint` turns a remembered convention into an enforced invariant, caught at write time and — for a bypassed local hook — again in CI, the same layered enforcement the convention already relies on for its other rules.

Confining the requirement to `feat` and `fix` is what keeps it correct rather than merely strict: only those two types drive a version and denote consumer-visible change. The non-version-driving types are exactly the infrastructure and repository-wide work that legitimately belongs to no train, so requiring a scope there would be noise and would misrepresent repository work as component work.

The cost — rejecting some messages that are valid today — is bounded and self-correcting: the author adds a scope the moment the hook or CI flags it, guided by a message that names the consequence. The header grammar and the shared hook/CI wiring are implementation, in `tools/commit-lint/lint-commit-message.sh` and the commit-lint workflow — not here.

## Alternatives Considered

### Keep documenting the rule, do not enforce it (the state after #231)

Considered because #231 / #292 already corrected the documentation, so the convention now states the requirement in prose. Rejected because a prose rule against a silent, invisible failure relies on the author remembering it exactly when nothing signals the mistake; the release record can still lose a user-facing change, which is the harm the rule exists to prevent.

### Require a scope on every type

Considered because a single, uniform "always scope" rule is the simplest to state and to implement. Rejected because the non-version-driving types are the legitimate unscoped case — infrastructure, repository-wide documentation, ADRs, the `FirstClassErrors.Usage` samples — and forcing a scope there would be noise and would falsely attribute repository work to a component and its train.

### Change the release tooling to keep unscoped feat/fix instead

Considered because the drop happens in `release-notes.sh` / `collect-prs.sh`, which could instead route an unscoped version-driving commit into a default train. Rejected because there is no correct default train for a scopeless consumer-visible change: the scope is precisely what identifies which package changed, so guessing would file the change under the wrong package's release record — a subtler, harder-to-notice error than refusing the ambiguous commit at the source.

## Consequences

### Positive

* A user-facing `feat`/`fix` can no longer vanish from the release notes or the changelog; the release record is complete by construction.
* The rule the convention already documents becomes enforced — at write time and in CI — closing the gap #231 could only describe.
* Infrastructure and repository-wide work stays scope-free, so the enforcement adds no friction where a scope would be meaningless.

### Negative

* Commit messages valid today (`feat: …` / `fix: …` with no scope) are now rejected; contributors must add a scope.
* The convention gains one type-dependent rule (scope required for `feat`/`fix`, optional otherwise) in place of a single uniform "scope optional", a small increase in the rule's surface.

### Risks

* A genuinely cross-cutting or sample-only user-facing change might have no natural single scope. Mitigation: a change crossing several components already carries all their scopes (the convention requires the full comma-separated list); a change that truly belongs to no component is, by definition, not a component `feat`/`fix` and takes a non-version-driving type.
* History and in-flight branches created before adoption may contain unscoped `feat`/`fix`. Mitigation: the rule applies from adoption on, to new commits, consistent with CONTRIBUTING → "Adoption"; prior history is not rewritten and merge commits stay exempt.

## Follow-up Actions

* Enforce the requirement in `tools/commit-lint/lint-commit-message.sh` (done in the implementing pull request).
* State the enforced rule in CONTRIBUTING.md and its French translation (done in the implementing pull request).
* No release-tooling change is needed: the partition semantics are unchanged; only the linter gains the requirement.

## References

* Issue [#293](https://github.com/Reefact/first-class-errors/issues/293) — this decision.
* Issue [#231](https://github.com/Reefact/first-class-errors/issues/231) and PR [#292](https://github.com/Reefact/first-class-errors/pull/292) — surfaced and documented the drift this decision enforces.
* `tools/trains.sh`, `tools/packaging/release-notes.sh`, `tools/changelog/collect-prs.sh` — the scope-based partition and the unscoped-commit drop.
* `tools/commit-lint/lint-commit-message.sh` and `.github/workflows/commit-lint.yml` — where the requirement is enforced.
* CONTRIBUTING.md → "Scope" and "Adoption".
1 change: 1 addition & 0 deletions doc/handwritten/for-maintainers/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,4 @@ Optional supporting material:
| [ADR-0031](0031-name-any-factories-after-their-clr-type.md) | Name Any's scalar factories after their CLR type | Accepted |
| [ADR-0032](0032-draw-arbitrary-values-from-an-explicit-top-level-pool.md) | Draw arbitrary values from an explicit, top-level choice pool | Accepted |
| [ADR-0033](0033-meet-string-exclusions-with-a-bounded-redraw.md) | Meet string exclusions with a bounded redraw | Accepted |
| [ADR-0034](0034-require-a-scope-on-the-version-driving-commit-types.md) | Require a scope on the version-driving commit types | Accepted |
Loading