Skip to content

feat(haskell): add module-aware extraction and resolution#1337

Draft
michelguillaume wants to merge 10 commits into
colbymchenry:mainfrom
TontineTrust:feat/haskell-support-clean
Draft

feat(haskell): add module-aware extraction and resolution#1337
michelguillaume wants to merge 10 commits into
colbymchenry:mainfrom
TontineTrust:feat/haskell-support-clean

Conversation

@michelguillaume

@michelguillaume michelguillaume commented Jul 17, 2026

Copy link
Copy Markdown

Credit and lineage

This PR continues and supersedes #395 by @ichxorya. The branch intentionally preserves all four original commits (f441a09, c0ff295, 1c52378, and 4abb846) with their original authorship, then merges current main and layers the additional extraction, resolution, performance, and regression work on top.

Thank you to @ichxorya for the grammar integration, initial extractor, HOF/where/do/operator/GADT work, and the original evaluation data.

What changed

  • Registers .hs, packages the pinned MIT-licensed ABI-14 tree-sitter-haskell WASM, and documents a reproducible pinned rebuild/health-check recipe.
  • Extracts modules, grouped equations/signatures, constants, local functions, ADTs/newtypes/GADTs, data families/instances, pattern synonyms, constructors, record fields, aliases/type families, typeclasses, instances, standalone deriving, superclasses, and derived classes.
  • Classifies declarations from the type AST, including constrained signatures and multi-method class signatures, instead of treating every textual -> as a top-level function.
  • Models direct, qualified, infix/backticked, point-free, composition, constructor-as-function, bare qualified monadic actions, and qualified function-first higher-order references.
  • Distinguishes constructor patterns from runtime calls and models do, lambda, case, recursive let/where, list-comprehension, and pattern-guard bindings as lexical scope. Local declarations shadow imports without fabricating project-wide edges.
  • Resolves Haskell modules from indexed module headers, including qualified/unqualified imports, ImportQualifiedPost, aliases, explicit lists, Type(..), selected children, hiding, export lists, and restricted/aliased re-exports.
  • Disambiguates duplicate module headers with the conventional Module/Path.hs suffix and importer proximity, while declining ambiguous ties.
  • Re-resolves stamped incoming references during incremental sync so visibility/module/export-list edits cannot retain stale cross-file edges.
  • Uses GC-safe per-extraction state plus cached lexical/export indexes; long do blocks no longer rescan all prior statements for every reference.
  • Adds 24 focused Haskell tests covering the original feat(haskell): add Haskell support #395 behavior and the production audit findings.

Why

The earlier implementation established useful syntax extraction, but current CodeGraph resolution assumed path-like imports and could not connect Haskell modules reliably. It also allowed false global edges for patterns, higher-order values, local functions, monadic binds, hidden imports, and restricted re-exports. Incremental re-indexing could preserve an edge after only an export header changed.

This version applies Haskell's lexical and module/import/export rules before accepting a unique target, while remaining a syntax-based graph rather than a compiler frontend.

Impact measured on a production Haskell monorepo

  • Full local index completed over 1,394 files: 44,621 nodes and 74,714 edges.
  • Useful edge count increased by 1,171 versus the prior branch audit (73,543 → 74,714).
  • 731 malformed backticked references dropped to 0.
  • 23 bare qualified actions in the real Spec.hs are now connected.
  • Constructor patterns are emitted as dependency references rather than calls; the post-fix audit found no remaining certain false pattern-call edges.
  • Both imports of a duplicated production module header now select the exact conventional module path.
  • A generated 2,000-statement do block dropped from about 5.4 s to about 207 ms in the local extraction benchmark (~26× faster).

Validation

  • npm run build
  • Focused Haskell/resolution/function-ref/sync suites: 248/248 tests passed.
  • Full suite on Node 22 LTS: 145 test files passed, 4 skipped; 2,493 tests passed, 49 skipped.
  • Haskell grammar health check: 100/100 clean repeated parses in the multi-grammar runtime (ABI 14).
  • npm pack --dry-run: Haskell extractor, WASM, and license are included.
  • Full local index against the production monorepo completed with a complete marker and no pending changes.
  • The TontineTrust fork runs a daily/manual upstream merge, full build/test/package validation, and only pushes the Haskell branch after a successful validation while this PR remains open.

Deliberate limits

This is static syntax/scope modeling, not GHC typechecking. It understands monadic syntax and lexical binders (do, <-, let, guards, comprehensions, >>=, >=>, <$>) but does not infer concrete monad types or perform semantic typeclass/instance dispatch. Duplicate modules are handled conservatively, but Cabal component graphs are not interpreted yet. CPP/Template Haskell expansion, .lhs, and .hsc are also not modeled.

ichxorya and others added 6 commits May 25, 2026 13:27
Closes three of the gaps flagged after the basic Haskell extractor landed:

- instance C T where ... emits an implements reference from T to C
  (local-receiver lookup; orphan instances where T lives in another file
  remain a known gap).
- data T ... deriving (C1, C2) and the newtype analogue emit one
  implements reference per derived class.
- class (Eq a, Show a) => Ord a where ... emits extends references from
  Ord to Eq and Show. Wired through core extractInheritance with a new
  Haskell context: case alongside the existing extends_clause /
  superclass / base_class_clause arms — no impact on other languages.
- Record-syntax fields (data Foo = Foo { x :: Int }) become field nodes
  scoped to the parent enum. Haskell record selectors live at the type
  level (x :: Foo -> Int), so scoping at the enum, not the constructor,
  is the right model.

Also adds a top-of-file doc block in haskell.ts that enumerates what the
module extracts, what it does NOT extract yet, and how the module is
tested (vitest cases + verify-extraction.mjs on the 4 pinned repos +
agent A/B benchmark).

Verified on 4 pinned repos:
  xmonad     (v0.18.1)    — 799 nodes, 1630 edges, 57 fields, 12 implements
  shellcheck (v0.11.0)    — 2034 / 4137 / 143 fields
  pandoc     (3.9.0.2)    — 16220 / 35985 / 1119 fields / 67 implements / 3 extends
  purescript (c4a35b34)   — 8185 / 15757 / 578 fields / 277 implements

5 new vitest cases (now 13 Haskell tests; 828 tests overall pass).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends Haskell support with extraction fixes uncovered during an audit
cycle. All changes are additive — 843 tests pass, 0 fails. Real-repo call
coverage roughly doubles to triples across the four pinned repos.

Higher-order call synthesis
  `map area xs` now emits a call edge from the caller to `area`, even
  though `area` is passed as data. The HOF_NAMES allowlist covers ~30
  function-first Foldable / Traversable / list-utility combinators.
  Data-first variants (forM_, for_, forM, for) are deliberately excluded
  — their signature is `t a -> (a -> f b) -> …`, so bridging them would
  emit the data list as a bogus callee.

Where-clause calls
  New `extraBodyFields` hook in LanguageExtractor; used in the core
  extractFunction / extractMethod. Haskell sets it to ['binds'] so the
  framework also walks the `where`-clause sibling field, not just
  `match`. Without this, ~1,900 where-clauses across the four pinned
  repos were dropping their call edges. Shellcheck alone went from
  1,822 → 7,552 calls (+314%) from this fix.

Top-level `bind` extraction
  Tree-sitter parses parameterless top-level bindings (`main = do …`,
  `pi = 3.14`, `constVal = 42`) as `bind` nodes, NOT `function`. Without
  handling, `main` had no node at all — a huge miss for any Haskell
  program. Now emitted as `function` kind.

Operator function definitions
  `x === y = …` (infix style) and `(===) x y = …` (prefix style) had no
  `name:` field — only an `infix` child. Now extracted with the operator
  wrapped in parens (e.g. `function:(===)`).

GADT constructors
  `data Term a where IntT :: Int -> Term Int` uses a `gadt_constructors`
  wrapper instead of `data_constructors`, and each `gadt_constructor`
  carries its `name:` directly (no nested `record`). Both shapes are
  now walked.

Bare-variable do-statements
  `main = do { hi; bye }` — the bare variable in do-statement position
  is the action being run. Tree-sitter wraps it as
  `statement → exp → variable` (no `apply` node), so no call edge was
  emitted. Now `variable`/`constructor` inside a single-child `exp`
  emits a call to the caller.

Scope-stack regression guards
  Custom function / method / operator body walks in visitNode now
  pushScope before visiting body, so calls inside attribute to the
  correct enclosing node. Previously instance-method and operator-
  function calls were leaking to the file scope.

Real-repo call edges (before → after these fixes):
  xmonad       613 →  1,015   (+66%)
  shellcheck 1,822 →  7,552   (+314%)
  pandoc    15,693 → 31,242   (+99%)
  purescript 4,852 → 14,492   (+199%)

Tests
  28 Haskell vitest cases (was 13). 843 total tests pass. Also a 29-case
  audit probe suite with positive and negative coverage (lambdas /
  composed-fns / sections / data-args don't bridge; constructors,
  multi-arg HOFs, case/if/do-block calls, where-bound nested fns,
  do-block bare statements, prefix and infix operator defs, GADT
  constructors, newtype non-stdlib deriving, instance method
  receivers all do).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds on the Haskell support introduced by @ichxorya in colbymchenry#395, whose four commits remain intact in this branch history.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants