b04ecb5c

Created Started Finished

Ask a field named after a project scalar for that scalar's type

`HsNewtypeNotSynonym` refuses `type RuleName = Text`, so a domain scalar cannot
be a synonym. Nothing refuses the commoner shape: a record field holding the
bare primitive while the type that names it sits unused in the same repository.

## The lexicon is the project's own newtypes

The obvious way to reach that population is a word list: a field named `name`,
`id`, `key`, `hash` or `url` holding a bare `Text` is a domain scalar. That is a
rule that fires on a name rather than on a type. `postBodyText`, `rowText` and
`errorMessage` are all text and all correctly typed, and the list that excludes
them is one somebody maintains against a corpus they cannot see.

`HsScalarNewtypeUnused` reads the project's own newtypes instead:

> A record field whose name ends in the name of a scalar newtype the project
> defines, and whose type is that newtype's underlying primitive.

```haskell
newtype Hostname = Hostname Text     -- the project already named this

data Settings = Settings
  { settingsHostname :: !Text        -- and this field went without it
  }
```

That is what makes every finding actionable without the rule having a view on
which scalars are domain scalars: the author has already decided this one
deserves a type and written the type down, so the finding is not an opinion, and
the message can name the type to use. A project that has named nothing gets
nothing, which is the right incentive and also the rule's honest limit.

Details, all exercised by the resources:

- The primitive has to match. `installationId :: !Int` against
  `newtype InstallationId = InstallationId Int` matches; the same field name
  against a `Text`-wrapping newtype does not, because that newtype is not the
  fix.
- `Maybe`, `[]` and `NonEmpty` are read through, so
  `pushEventUserUsername :: !(Maybe Text)` wants `Maybe Username`. Measured both
  ways round: reading through them adds three sites in nix-ci and none anywhere
  else, and all three are real.
- The longest name wins, so a project with `Name` and `OwnerName` is told about
  `OwnerName` for `repoOwnerName`.
- A newtype's own accessor is left out. `unHostname :: Text` is how `Hostname`
  is taken apart, not a site that declined it.

## The measured population

The same throwaway extractor as the earlier proposal, over `src/` and `app/` of
six repositories, 2330 modules.

| | nix-ci | centjes | smos | hopinion | sydtest | intray |
|---|---|---|---|---|---|---|
| sites | 19 | 19 | 4 | 1 | 0 | 0 |
| scalar newtypes defined | 99 | 16 | 26 | 34 | 5 | 4 |

Forty-three sites. The shape the rule is for is the four nix-ci background-job
payloads that each carry an installation id as an `Int` while `InstallationId`
sits in the same repository: not a scalar nobody thought about, but one somebody
named and then four sites did not use.

## The residue, and why this is bucket C

Sixteen of centjes's nineteen are `...Description :: Text` on Typst report
inputs, where `Centjes.Description.Description` has already been rendered and
the field holds the rendered form. That is a rendering boundary, and at the
boundary the scalar genuinely stops being the domain type. It is the honest
residue rather than a fault in the matching, and it is exactly the kind of thing
an author can say in one line and a checker cannot.

So the obligation is that the field has the project's type, and a site that
declines carries its reason:

```haskell
data TypstInput = TypstInput
  { -- [allow:HsScalarNewtypeUnused] Typst is handed the rendered form, so this
    -- is a Hostname that has already stopped being one.
    typstInputHostname :: !Text
  }
```

The clean resource holds that site, so the annotation path is exercised rather
than described.

## Tier and resources

Project tier, because the newtype and the site that declined it rarely share a
module: every finding measured in nix-ci crosses a package boundary. The
resources are therefore whole repositories of two packages rather than single
modules, and the rule reports the same findings through fact files as it does in
one process, which `RuleSpec` asserts per project rule.

The store format version is bumped, because the rule brings two tables. Without
it, a project run over package outputs written by an older build would find
nothing for this rule instead of saying why it cannot.

## The one site it fixes here

A rule that ships has to hold in this repository, and this one finds exactly one
site: `ErrorDecl.errorDeclName`, which held the name of a declaration as a
`Text` while `Hopinion.Facts.Name` already defines `DeclName` for exactly that.

`DeclName` rather than `TypeHead`: a `TypeHead` is the head of an applied type
with its arguments dropped, deliberately lossy so that the obligation engine can
ask whether a type is tested at all, where this field is a declaration's own
name, read off the same `tcdLName` that every `DeclName` in the declaration list
comes from.

## Feedback loops

All four pass: `cabal build all`,
`cabal test hopinion-gen --test-options='--ai-executor'` (593 tests),
`pre-commit run -a`, and `nix flake check`.