This is a spec-driven engineering plan. Each phase has a goal, concrete tasks,
and acceptance criteria. Tasks and criteria are tracked with checkboxes: mark
[x] only when the work is implemented and its acceptance criteria are
satisfied. A phase is "done" when every box under it is checked.
This document is scoped to engineering work.
[ ]not started / in progress[x]implemented and acceptance criteria satisfied- Effort sizing: S (hours), M (a few days), L (a week or more)
- CLI
ocelli check <path>scans.hs/.lhsfiles and emits heuristic diagnostics. - Three rules implemented: lazy
foldl, lazy record fields, lazyStateimports. - Rules are line-based (
lines+isInfixOf), with known false positives (strings, comments, qualified names) and false negatives (multi-line expressions). - Output is human-readable text only. Exit code is non-zero when diagnostics are found.
- No automated tests beyond
examples/and manual inspection.
ocelli targets GHC 9.6.7 (current GHCup recommended set with HLS) and more
recent GHC releases supported by HLS.
Decision for the parser work: build against a single recent ghc-lib-parse
series (track the latest stable line, e.g. 9.8.x or 9.10.x) rather than
attempting per-version parsing. Parsing is forgiving across minor versions, so
one recent ghc-lib-parse can parse the dialects ocelli cares about. Strict
per-version locking only becomes necessary at Phase 5 (Core/demand), which is
genuinely version-fragile. The exact pin is a task in Phase 1.
The order is impact-to-effort, with hard dependencies respected:
- Parser first. Every rule sits on line-scanning today. A real AST kills the false positives, and — critically — forces the rules into the shape HLS will hand them later. Skipping this means writing the rules twice.
- JSON output turns
ocellifrom "a CLI that prints text" into an engine something else can consume. It also defines a stable, testable contract. - Golden tests must land before further rule work: the parser refactor will touch every rule and there is currently no safety net.
- HLS plugin is the practical-tool payoff. Diagnostics first (the big leap), then hover (cheap), then code actions for select rules (expensive, lower ROI).
- Core/demand analysis is what makes
ocellimore than a linter, but it has the worst short-term impact-to-effort ratio. It comes after a solid parsed-AST foundation and a working plugin, and needs its own design pass.
Goal. Replace line-based scanning with a real parsed AST. Rules become pure functions over the AST, not over raw source text. Effort. L
- Confirm
cabal build,cabal check, andcabal run ocelli -- check examples/all succeed on a clean checkout. -
cabal buildis clean under-Wallandcabal checkreports no warnings.
- Pick and pin the
ghc-lib-parseversion (latest stable 9.8.x or 9.10.x); record the choice and reasoning in this file. - Decide the shared rule signature. Target shape: rules take the parsed module
produced by
ghc-lib-parseand return[Diagnostic]— pure, noIO, noFilePath/String. The CLI doesread → parse → run rules; HLS will reuse the same rule functions against theParsedModuleit already has. - Decide parse-failure behavior. Recommended: emit a structured diagnostic
with a new
DiagnosticKind(e.g.ParseFailure) so JSON consumers and CI see it, rather than silently skipping.
- Add
ghc-lib-parseas a dependency with the pinned bound. - Create
Ocelli.Parse: takes aFilePath+ source, returns either a parsed module or parse errors. Centralizes parser flags and language-extension setup. - Add
ParseFailuretoDiagnosticKind(per the decision above). - Rewrite
Ocelli.Rules.Foldlto consume the AST: detectfoldlin expression position, correctly distinguishfoldl', and handle qualified names (L.foldl,Data.List.foldl). No more matching inside strings or comments. - Rewrite
Ocelli.Rules.LazyRecordto consume the AST: detectdata/newtyperecord fields lacking a strictness annotation; respect aStrictData/Strictlanguage pragma in the module; no more multi-line guessing. - Rewrite
Ocelli.Rules.LazyStateto consume the AST: inspect import declarations for lazyStatemodules, excluding.Strictvariants. - Replace the hand-rolled
findColumnlogic with realSrcSpan-derived line/column positions. - Update
Ocelli.Checkorchestration to parse once per file, then run all rules over the parsed result. - Add example files that previously caused false positives (e.g.
foldlin a string literal, in a comment, a qualifiedfoldl') and a multi-line expression that was previously missed.
- All pre-existing
examples/files produce the same or strictly better diagnostics than before. - The new false-positive example files produce zero spurious diagnostics.
- Qualified names are handled correctly in all three rules.
-
LazyRecorddoes not warn whenStrictDatais enabled or all fields are strict. - Rule functions are pure and AST-typed (no
IO, noFilePath/Stringargs). -
cabal buildclean under-Wall;cabal run ocelli -- check examples/works.
Goal. Make diagnostics machine-readable so downstream consumers (tests, CI, the HLS plugin) have a stable contract. Effort. S–M
- Flag design:
--format=text|json, defaulting totextfor backwards compatibility. - JSON schema for
Diagnostic, including a top-level schema/format version field so the contract can evolve safely.
- Add a JSON serialization path for
Diagnostic(and the diagnostic list). - Add the
--formatflag to the CLI argument handling inapp/Main.hs. - Document the JSON schema in
docs/(field names, types, the version field, howconfidenceandkindare encoded). - Ensure exit-code behavior is unchanged regardless of output format.
-
ocelli check --format=json examples/emits valid, well-formed JSON. - Default (
text) output is byte-identical to the previous behavior. - The JSON schema is documented and includes a version field.
- Exit code is non-zero when diagnostics are found, in both formats.
Goal. A regression safety net before any further rule work. The Phase 1 refactor touched every rule; nothing currently guards against silent breakage. Effort. S–M
- Pick a test framework (e.g.
tasty+tasty-golden, orhspec).
- Add a test-suite stanza to
ocelli.cabal. - Create golden cases: input
.hsfile → expected JSON output, for each rule. - Include negative cases (clean files that should produce nothing) and the false-positive cases introduced in Phase 1.
- Include at least one parse-failure case.
- Wire the suite into
cabal test.
-
cabal testruns and passes on a clean checkout. - Every rule has at least one positive and one negative golden case.
- A parse-failure case is covered.
- The suite is runnable in CI with no extra setup (CI wiring itself is a separate concern).
Goal. Surface ocelli diagnostics inside the editor. This is the practical-tool
payoff. The plugin depends on the ocelli library and reuses the Phase 1 rule
functions directly — no rule logic is duplicated.
Effort. 4a: L · 4b: M · 4c: M
- Decide the plugin distribution story. Third-party HLS plugins generally require building HLS with the plugin linked in; confirm the intended distribution/install path and document it before investing in 4b/4c.
- Add a plugin component to the project (its own package or cabal component)
depending on the
ocellilibrary. - Implement a
PluginDescriptorthat obtains the parsed module from HLS and runs ocelli's rule functions over it. - Map
Diagnosticvalues to LSP diagnostics with correct source ranges.
- With the plugin enabled,
ocellidiagnostics appear inline (squiggles) in a supported editor on a known-bad file. - Diagnostic ranges point at the right span.
- No rule logic is duplicated between the CLI and the plugin.
- Add a hover handler that, on an
ocellidiagnostic, shows themessageandsuggestion. - Optionally include a short "why" explanation per rule (e.g. why lazy
foldlaccumulates thunks). This is the one place explanatory depth is in scope, if it is useful to the developer.
- Hovering over a diagnostic shows message and suggestion text.
- Hover content is sourced from the diagnostic, not re-derived.
- Implement an auto-fix code action for the unambiguous case:
foldl→foldl', including theData.Listimport if not already present. - Optionally implement the lazy
Stateimport swap to the.Strictmodule. - Do not attempt code actions for rules where the correct fix is ambiguous.
- The
foldl→foldl'action applies a correct edit, including import handling, and does nothing wrong when an import already exists or is qualified. - Code actions only appear for rules with an unambiguous fix.
Goal (direction only). Move from syntactic heuristics toward
compiler-informed diagnostics: read GHC's strictness/demand analysis so ocelli can
confirm or retract a heuristic ("the compiler proved this is strict — false
positive") and surface properties the developer cannot otherwise see.
This phase is not specced. It is genuinely GHC-version-fragile and the integration path into HLS is not yet clear. It needs its own design pass before tasks and acceptance criteria can be written. Placeholder here only so the ordering is explicit.
- Dedicated design pass: feasibility, version strategy, where Core access comes
from (GHC API directly vs. HLS-provided), and how results map onto the
existing
Confidencelevels.