c7b8d283

Say what a path is, and add the rule that holds us to it

Below #11 in the stack: #11 and #12 rebase onto this.

A `FilePath` is a `String`, so nothing about it says whether it holds a file or a directory, whether it is absolute or relative, or whether it is a path at all. Every function taking one documents what it accepts and then trusts its callers.

`HsNoFilePath` reports the type where a module's own signatures use it.

## Read off the tokens

Not the parse tree. The token stream is where a name the code wrote is already told apart from the same word elsewhere: a comment saying `-- a FilePath is a String`, and the literal `"a FilePath is a String"`, are each one token of their own, and a qualified name is one token holding both halves, so `import System.FilePath` does not look like the type either. `good.hs` contains all three and its golden is empty.

That is also why this is not a grep.

## Every finding is suppressible where it is

Each one is scoped to the declaration it is inside, so the report offers a suppression for that declaration and nothing wider:

```
[error HsNoFilePath]: The type FilePath. Say what the path is with Path from path.
     ╭──▶ hopinion-gen/test/Hopinion/AnnotationSpec.hs@28:16-28:24
  28 │ resourceDir :: FilePath
     •                ┬───────
     │ Hint: To suppress, write this directly above line 28 of ... : -- [allow:HsNoFilePath] <reason>
```

Converting at the edge is not reported at all. A library that wants a `String` gets `readFile (toFilePath file)`, and that is correct.

## The repository is clean under it

Twenty findings, every one of them in the test specs. The library needed no changes to pass, which is worth saying: it had no `FilePath` in its own signatures already, only conversions at boundaries. Its one word-comparison of a directory name is now a comparison of directories.

Fixing the specs dropped `directory` from `hopinion-gen`. `resourceDir` is a `Path Rel Dir`, the three copies of `rootAt` take one, and `listDirRel` answers with directories and files already told apart, so several listing assertions are now exact about which is which instead of comparing sorted strings.

## It needed a sydtest change

NorfairKing/sydtest#137, merged and released as sydtest 0.30.0.0, pinned here at `27be1a6d`. `scenarioDir` handed its callback a `FilePath`, which forced `FilePath` into the signature of anything called from inside one. It now hands over a `Path Rel File`, and `scenarioDirOfDirs` a `Path Rel Dir`, relative to the directory given, so the one thing a caller has to know about a scenario is the one thing the type now tells them and the name does not have to be split back out of a joined path.

## What is deliberately not here

The same rule over `import System.FilePath` and `import System.Directory`. An import is not a declaration, so the comment attachment pass has nowhere to attach a suppression above one, and the only thing left is a file-scoped suppression, which would exempt every other finding in that file. A rule whose findings cannot be suppressed where they are breaks the rule #10 is built on: a suppression the report offers must be one the parser accepts. That wants an `AttachedToImport` first, and it is its own change.

`filepath` therefore survives in two places, both genuine gaps in `path`: cabal hands module names back as `[String]`, so `joinPath` is needed at that boundary, and turning a directory's name into a file's name has no typed equivalent. Those two are exactly where a dependency-based rule would want a per-site suppression.

One thing to settle: this cites `haskell-style.md`, "Preferred libraries", which does not currently say it. The guide wants the line, or the rule is citing something that is not there.

## What it costs elsewhere

57 findings in nix-ci, 47 in sydtest, 10 in centjes. None of them is the exception the rule stays quiet about, so all three are real conversions rather than suppressions to write.

nix-ci turns the rule off in a new `hopinion.yaml` until those 57 are converted, which is NorfairKing/nix-ci#527. That is a decision, not a workaround: 57 suppressions would reach a passing check dishonestly, because a suppression says this one is deliberate and none of these is. Converting them is a change to review on its own.

## Review loop

```
nix flake check
nix develop --command cabal test hopinion-test --test-options="--ai-executor"
```

Suite timing

Time to Start Worker time Duration Time to finish Idle
Config 0s 3s 3s 3s 0s
Eval 3s 7s 7s 11s 0s
Build 10s 0s 0s 10s 0s
Suite 0s 11s 11s 11s 0s

Timeline

0s10s