Refuse a second anonymous scalar in a positional constructor A new rule, `HsNoAnonymousScalar`: a non-record constructor holds at most one anonymous primitive. ## The gap `HsNewtypeNotSynonym` refuses `type RuleName = Text`, so a domain scalar cannot be a synonym. Nothing refuses the commoner shape, which is a domain scalar written as a bare `Text`, `String` or `Int` in a constructor with no name of its own at all. The guide asks for both halves: > Wrap domain scalars (ids, names, hashes) in `newtype`s, not `type` synonyms, > so they cannot be confused positionally. The clause that matters is "confused positionally", and that is what the existing rule does not reach. ## The principle "Is this a domain scalar" is judgement and cannot be checked. What can be checked is whether the scalar has a name, because Haskell gives a scalar occurrence exactly three places to get one: the type names it, a record field names it, or nothing names it. The third is a positional constructor field, where a reader has nothing to go on but position. That is decidable from one module's syntax. ## Why the budget is one and not zero A relayed `String` from another library, a count that is a count and a literal payload in a parsed AST are all genuinely primitive and travel anonymously on purpose, and when a constructor carries one payload the constructor name is its name: `AnswerUnusable ![Text]` and `UnknownRepoType !Text` need nothing. With one anonymous primitive the constructor name names it. With two the constructor name can name at most one of them, so the second has no name anywhere and swapping the pair typechecks. The same measurement says so from the other side. At a budget of zero this repository has 26 anonymous primitives, 17 of them textual, and of those 17 two are worth fixing and fifteen are error constructors carrying relayed prose. Twelve percent precision at zero, against nothing to fix at one. ## Measured population A throwaway extractor over `src/` and `app/` of nix-ci, smos, sydtest, hopinion, centjes and intray, 2330 modules. It approximates the parse with regexes, so these are the order of magnitude rather than the number the check prints. Non-record constructors over the budget: | | nix-ci | smos | sydtest | centjes | hopinion | intray | |---|---|---|---|---|---|---| | over budget | 14 | 5 | 12 | 8 | **0** | 0 | Thirty-nine sites in 2330 modules, and none in this repository, so the rule ships here with no cleanup at all. Hopinion scores zero for the reason the budget exists: every error constructor here carries exactly one prose payload, and the constructor name is its name. The true positives are all one shape, a name beside a reason or a key beside a value: `RenderErrorUTCTimeParseError !Text !Text !String`, `ReqKvPart !Text !Text`, `CodebergDecodeError !String !String`, `PostErrorTooLong !Text !Int !Int`. The false positives are one class, a pair that is genuinely a pair of the same thing, and they are all numeric: `RGB !Int !Int !Int`, `SegNormal !Int64 !Int64`. ## Scope - **Primitive**: `Text`, `String`, `ByteString`, `Char`, `Int`, `Integer`, `Word`, the sized variants, `Double`, `Float`, `Rational`, plus `Maybe`, `[]` and `NonEmpty` of any of them, which are as anonymous as what they wrap. `[Char]` falls out of that as the list of a primitive. Not `Path Abs File`, not a project type, not a tuple. - **`Bool` does not count.** Two `Bool` fields in one constructor is `HsNoDomainBool`'s finding, and reporting the same line twice in two vocabularies is worse than reporting it once. - **Record constructors are out of scope**, because their fields are named, which is half the point already. - **Function signatures are out of scope for now.** The shape is identical and the argument is the same, but the population is three to six times larger and the precision is visibly worse, so it wants its own rule and its own row. ## The fixes, and why this is an `error` Two, both always available: give at least all but one of the anonymous primitives a `newtype`, which is what the guide asks for, or switch the constructor to record syntax, which names them without adding a type. The second is why the class is `error` rather than `warn`: a site that wants neither carries an `[allow:HsNoAnonymousScalar]` saying why, and `RGB` saying "three channels, and the type is the order" is a comment worth having. ## What is here A check under `hopinion/src/Hopinion/Check/Hs/NoAnonymousScalar/`, split into `Fact`, `Extract` and `Rule` like its two neighbours, an entry in `Hopinion.Rule.Registry`, and a good and a bad resource with their goldens. The rule leaves `plan/rules.md` by joining the paragraph of shipped rules, as a rule that ships does. All four feedback loops pass, including `nix flake check`, which runs hopinion on hopinion: the new rule finds nothing in this repository.