a1fe78a8

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.

Total

58m19s

Created Started Finished

Time to Start Worker time Duration Time to finish Idle
Config 2s 0s 0s 3s 2s
Eval 1m22s 8s 8s 1m30s 1m19s
Build 1m30s 0s 56m51s 58m22s 56m51s
Suite 2s 8s 58m19s 58m22s 58m13s

Timeline

Config Eval Build Compressed gap
0s1m30s