dc00c041

Created Started Cancelled

Ask for an error type rather than a String

A new module-level rule, `HsNoStringError`: an error is a type of its own, not a `String`.

## What it reports

The first argument of `Either`, `Except`, `ExceptT`, `MonadError` and `Validation`, when that argument is `String`, `Text`, `[Char]`, or a list or `NonEmpty` of those. The finding underlines the argument alone, not the whole signature, and names the carrier:

```
[error HsNoStringError]: The error type of Either is String.
   8 | parseAge :: String -> Either String Int
     *                              ------
     *                              `- An error is a type of its own, not a String.
```

A caller can branch on a constructor and cannot branch on prose, so a `String` error asks every handler to treat every failure the same way, and the words are chosen at the point of failure, which is the place that knows least about who will read them.

## Two things worth reviewing

**The traversal deduplicates.** A type node arrives more than once: a strict field is a bang around parentheses around the application, and all three answer for the same error argument. Applications are peeled and deduplicated by location, and the function half of an application is dropped, since the `Either String` inside `Either String Int` is the same `String`. Without both, `attemptOutcome :: !(Either String Int)` reported three times.

**`peelType` and `peelApp` moved** from `Hopinion.Extract` to `Hopinion.Extract.Ghc`, which is where the reading no rule owns lives. Neither function changed.

## Tried out on real repositories

nix-ci8 (39), smos (60), salsa (54): **153 findings, no false positives, no parse failures.** 145 `String`, 8 `Text`, all of them `Either`. Not one `ExceptT`, `Except`, `MonadError` or `Validation`, because those repositories do not use a string with them, so those four carriers are untried outside the resources here.

The dominant shape is the rule's exact target:

```haskell
parseTag       :: Text -> Either String Tag
parseGlobText  :: Text -> Either String Glob
mkTitleOrError :: Text -> Either String Title
readSmosFile   :: Path Abs File -> IO (Maybe (Either String SmosFile))
```

Two findings make the argument by themselves: `StorePathSpec.hs` and `GlobSpec.hs` both assert the exact wording of a failure, because there is no constructor to compare.

Twenty of the 153 are in generated OpenAPI clients (`smos-stripe-client`, `salsa-party-stripe-client`), inside `eitherDecodeStrict body :: Either String ...`. Nothing to fix there; a consuming repository leaves the package out of the `packages` list.

## What it does not see

95 `Left "literal"` sites across the three repositories, 14 files of which get no finding at all. The cause is the same every time: the `Either String` is never written down, because a library combinator supplies it and the lambda handed to `bimapCodec`, `eitherReader`, `checkMapEither` or `fromPersistValue` only writes `Left "..."`. A rule of its own if it is worth one, and it would fire mostly where the shape is the library's rather than ours.

## This repository

One finding: `fromPersistValueViaCodec`, which is `persistent`'s `fromPersistValue` under another name. It carries an `[allow:HsNoStringError]` with that reason, and the paragraph in the module header that said the same thing is gone, so it is said once.

`nix flake check` passes.
PR #14