feat: add Haskell language support - #1561
Conversation
Add full Haskell (.hs, .lhs) support to CodeGraph's extraction pipeline: - Functions with multi-clause grouping, type signatures, and Haddock docs - Type classes as traits, instances with implements edges - Algebraic data types as structs, constructors as enum_members, record fields - Type synonyms, newtypes - Module imports with dotted module names (Data.List, etc.) - Call edges: local calls, qualified calls (Mod.fn), data constructor instantiations, and infix operator calls (user-defined operators only) - where-clause helper extraction Grammar: vendored tree-sitter-haskell 0.23.1 wasm (ABI 14) from the npm package — tree-sitter-wasms doesn't ship Haskell. Validated on three repos by size tier: - xmonad/xmonad (small, 41 files, 1222 nodes, 2208 edges) - PostgREST/postgrest (medium, 192 files, 5531 nodes, 9386 edges) - jgm/pandoc (large, 599 files, 23126 nodes, 52180 edges) All 630 extraction tests pass (19 new Haskell tests + 611 existing).
There was a problem hiding this comment.
CodeGraph review
Overall risk: 🟡 Low — New Haskell extractor is fully gated and covered by dedicated tests; the only defect is jargon-heavy Changelog wording that breaks the user-facing rule.
- ⛔ Violates: Changelog and release contract — The new Changelog entry uses internal graph-model terms (traits, implements edges, enum members, call edges, instantiations) instead of the friendly user-facing prose the rule requires.
@mightybyte — worth a look before this merges.
Verify in the running product (3 checks)
- cli — Run
codegraph init/syncon a repo containing.hs/.lhsfiles and checkcodegraph fileslists them with languagehaskell. - cli — Use
codegraph explore/queryon a multi-clause Haskell function (e.g.factorial 0 = 1/factorial n = ...) and confirm it returns ONE symbol spanning both clauses with the:: Int -> Intsignature and Haddock doc, not one symbol per clause. - cli — Query
codegraph callers/impactfor a Haskell function reached only via a built-in operator (e.g.x + y) versus one reached via a user-defined operator, and confirm only the user-defined operator produces acallsedge.
Worth double-checking
- extractCall Haskell branch and HASKELL_BUILTIN_OPS coverage
- File-scoped mutable memo for clause merging
What to look for in each
- extractCall Haskell branch and HASKELL_BUILTIN_OPS coverage — The graph shows no structurally-traced test reaches extractCall's new Haskell branch or HASKELL_BUILTIN_OPS (both flagged NO TEST, 34-36 dependents), because dispatch runs through the generic dynamic visitNode hook. The new extraction.test.ts Haskell suite exercises
n * factorial (n - 1)(an infix*) but never asserts that a built-in operator produces NO call edge, nor that a user-defined operator (viaprefix_id) produces one. Confirm the new tests actually run in CI and add an assertion for the builtin-exclusion behavior. - File-scoped mutable memo for clause merging —
lastFnFile/lastFnName/lastFnIdin haskell.ts are module-level (not per-extractor-instance) variables reset only whenresetFnMemosees a differentctx.filePath. This is documented as safe because 'extraction is file-sequential within a worker' — verify that assumption actually holds in the sync/worker pipeline, since any future concurrent extraction of two files in the same worker would corrupt clause merging across files.
Findings — 1 low
| Severity | Finding | Where |
|---|---|---|
| 🟡 Low | Changelog entry describes internal graph model instead of user-facing behavior (rule: Changelog and release contract) | CHANGELOG.md:14 — see inline comment |
Business rules — 1 violated · 6 not applicable
| Status | Rule | Note |
|---|---|---|
| ⛔ Violated | Changelog and release contract | The new Changelog entry uses internal graph-model terms (traits, implements edges, enum members, call edges, instantiations) instead of the friendly user-facing prose the rule requires. |
| — Not applicable | The graph model | The PR doesn't modify the nodes/edges schema; it only creates instances of existing node kinds (function, struct, enum_member, field, trait, class, import, type_alias) via the new Haskell extractor. |
| — Not applicable | Surfaces | The change doesn't touch sync/CLI/MCP surface code; watcher.ts's handleChange still calls the pre-existing isSourceFile, unmodified in behavior. |
| — Not applicable | CodeGraph business rules | This rule is a meta-description of the business-rules folder itself; the PR doesn't edit that folder's content. |
| — Not applicable | Source strings must exclude interpolated template literals | This PR only adds an import and an EXTRACTORS map entry in index.ts; it doesn't touch extractSourceStrings or template-literal handling. |
| — Not applicable | Coverage | The quoted language list belongs to a separate product-overview doc that already omits many supported languages; this PR doesn't edit that document. |
| — Not applicable | What is collected | The PR doesn't modify telemetry collection code; a new language value flowing through the existing 'languages indexed' counter isn't a change to what/how telemetry collects. |
Full assessment
This PR adds Haskell (.hs/.lhs) as a new tree-sitter-backed language: a standalone extractor for functions (with multi-clause merging), ADTs, type classes/instances, imports and calls, plus a Haskell-specific branch inside the shared extractCall method that is fully gated behind this.language === 'haskell' so it cannot regress any other language. It ships with a 263-line dedicated Haskell test suite covering functions, data types, instances, imports and call edges. The one concrete issue is the new CHANGELOG entry, which is written in internal graph-model vocabulary (traits, implements edges, enum members, instantiations) instead of the friendly user-facing prose the changelog rule requires.
Blast radius: 50 files affected beyond the diff · 274 symbols · 25 test files selected
Tests to run:
__tests__/exclude-config.test.ts__tests__/extension-mapping.test.ts__tests__/extraction.test.ts__tests__/frameworks.test.ts__tests__/grammar-wasm-bytes.test.ts__tests__/include-config.test.ts__tests__/kernel-ccpp-parity.test.ts__tests__/kernel-csharp-parity.test.ts__tests__/kernel-dart-parity.test.ts__tests__/kernel-grammar-parity.test.ts__tests__/kernel-kotlin-parity.test.ts__tests__/kernel-lua-parity.test.ts__tests__/kernel-php-parity.test.ts__tests__/kernel-r-parity.test.ts__tests__/kernel-ruby-parity.test.ts__tests__/kernel-rustlang-parity.test.ts__tests__/kernel-scaffold.test.ts__tests__/kernel-scala-parity.test.ts__tests__/kernel-swift-parity.test.ts__tests__/kernel-tsjs-parity.test.ts
|
|
||
| ### New Features | ||
|
|
||
| - CodeGraph now indexes **Haskell** (`.hs`, `.lhs`) — functions (including multi-clause definitions with type signatures and Haddock docs), type classes as traits, instances with `implements` edges, algebraic data types as structs with constructors as enum members, record fields, type synonyms, newtypes, module imports, local and qualified call edges, data constructor instantiations, and `where`-clause helper extraction. |
There was a problem hiding this comment.
🟡 Low — Changelog entry describes internal graph model instead of user-facing behavior
The new CHANGELOG.md line reads: "type classes as traits, instances with implements edges, algebraic data types as structs with constructors as enum members, record fields, ... local and qualified call edges, data constructor instantiations, and where-clause helper extraction." This is internal graph-schema vocabulary (node/edge kinds), not the "friendly prose" a user-facing changelog entry is required to use per the release contract rule — it reads like the README's per-language extractor spec rather than something an end user would parse.
⛔ Violates the business rule Changelog and release contract.
There was a problem hiding this comment.
CodeGraph review
Overall risk: 🔴 High — Haskell's clause-merge keys purely on function name, so same-named methods in different type-class instances (e.g. two show implementations) get silently merged into one node.
- 🔴 Type-class instance methods sharing a name silently merge into one node —
src/extraction/languages/haskell.ts:110— see inline comment
@mightybyte — worth a look before this merges.
Verify in the running product (3 checks)
- cli — Run
codegraph init/syncon a small Haskell project containing twoinstancedeclarations that both define a method with the same name (e.g.instance Show Foo where show ...andinstance Show Bar where show ...), then usecodegraph node/callersto inspect the resulting symbols for that method name. - cli — Index a Haskell file with a qualified import and a qualified call (e.g.
import qualified Data.Text as T/T.putStrLn "hi") and usecodegraph callers/impacton the imported function to confirm the call graph traces it end-to-end. - cli — Run
codegraph status/codegraph filesagainst a repo containing.hs/.lhsfiles.
Worth double-checking
- Clause-merge scoping in handleFunctionLike
- Test-reachability gap vs. visible test file
- Vendored grammar binary
What to look for in each
- Clause-merge scoping in handleFunctionLike — The continuation-clause branch
if (name === lastFnName && lastFnId)in src/extraction/languages/haskell.ts only compares the function's name against the globally-remembered last function name/id — it never checks that the enclosing scope (class/instance/top-level) is the same. Confirm this against a file with two instances defining the same method name and check whether the graph produces one node or two. - Test-reachability gap vs. visible test file — The graph marks extractCall, TreeSitterExtractor, HASKELL_BUILTIN_OPS, handleFunctionLike, handleDataType, handleClass, handleInstance, and most other new Haskell symbols as 'NO TEST REACHES THIS', despite tests/extraction.test.ts adding a 'Haskell Extraction' describe block that calls extractFromSource against these code paths. Confirm CI actually executes and passes this new test block, and that it's not being skipped/excluded — the discrepancy between the diff's visible tests and the graph's coverage signal should be resolved before trusting the coverage.
- Vendored grammar binary — src/extraction/wasm/tree-sitter-haskell.wasm is added as a binary with no diffable content. Confirm it is genuinely the claimed tree-sitter-haskell 0.23.1 (ABI 14) build and that grammar-wasm-bytes.test.ts's byte-parity check actually covers this new entry.
Business rules — 2 honored · 5 not applicable
| Status | Rule | Note |
|---|---|---|
| ✔ Honored | Changelog and release contract | The new CHANGELOG entry sits under '### New Features' in user-facing prose (language/extension names, no internal file or function names), matching the changelog contract. |
| ✔ Honored | Coverage | The change extends documented language coverage by adding a per-language extractor under src/extraction/languages/, consistent with the stated architecture, and updates README's language table and count accordingly. |
| — Not applicable | The graph model | The diff adds a new extractor that produces already-defined node kinds (function, struct, enum_member, field, type_alias, trait, class, import) but does not modify the nodes/edges schema itself (src/db/schema.sql untouched). |
| — Not applicable | Surfaces | sync/watcher behavior is unchanged; only the extension map that feeds isSourceFile grows to include .hs/.lhs, which is a downstream effect, not a modification of the sync surface itself. |
| — Not applicable | CodeGraph business rules | This is introductory framing text for the business-rules folder, not a specific behavior the diff modifies. |
| — Not applicable | Source strings must exclude interpolated template literals | The diff's change to src/extraction/languages/index.ts only imports and registers haskellExtractor; it does not touch extractSourceStrings or template-literal handling. |
| — Not applicable | What is collected | No telemetry collection code is touched; Haskell would simply become another value in the existing 'languages indexed' counter with no change to what's collected. |
Full assessment
This PR adds a full new Haskell extractor (functions, ADTs, type classes/instances, newtypes, imports, call edges) behind a vendored tree-sitter grammar, entirely additive and gated by language === 'haskell' checks so no other language's extraction path is touched. The extraction logic uses module-level mutable state to merge multi-clause function definitions into one node, but that merge condition checks only the function's NAME against the last-created function, not its enclosing scope — so two different type-class instances defining a same-named method (a routine Haskell idiom) will be incorrectly collapsed into a single graph node. The code graph also reports zero test reachability into nearly every new Haskell-specific function, even though the diff adds a large test file, which is worth reconciling before merge.
Blast radius: 50 files affected beyond the diff · 274 symbols · 25 test files selected
Tests to run:
__tests__/exclude-config.test.ts__tests__/extension-mapping.test.ts__tests__/extraction.test.ts__tests__/frameworks.test.ts__tests__/grammar-wasm-bytes.test.ts__tests__/include-config.test.ts__tests__/kernel-ccpp-parity.test.ts__tests__/kernel-csharp-parity.test.ts__tests__/kernel-dart-parity.test.ts__tests__/kernel-grammar-parity.test.ts__tests__/kernel-kotlin-parity.test.ts__tests__/kernel-lua-parity.test.ts__tests__/kernel-php-parity.test.ts__tests__/kernel-r-parity.test.ts__tests__/kernel-ruby-parity.test.ts__tests__/kernel-rustlang-parity.test.ts__tests__/kernel-scaffold.test.ts__tests__/kernel-scala-parity.test.ts__tests__/kernel-swift-parity.test.ts__tests__/kernel-tsjs-parity.test.ts
|
|
||
| // Continuation clause: same-name consecutive function — extend the existing | ||
| // node and attribute this clause's calls to it. | ||
| if (name === lastFnName && lastFnId) { |
There was a problem hiding this comment.
🔴 High — Type-class instance methods sharing a name silently merge into one node
In handleFunctionLike, if (name === lastFnName && lastFnId) { ... } (src/extraction/languages/haskell.ts:110) treats any function/bind with the same name as the previously-created one as a 'continuation clause' and extends that earlier node instead of creating a new one — but lastFnName/lastFnId are plain module-level variables with no scope awareness (class/instance/top-level). Two different instance blocks implementing the same method name (e.g. instance Show Circle where show ... followed later by instance Show Square where show ...) will have the second instance's method body merged into the first instance's node via visitMatch(match, lastFnId, ctx), mis-attributing its calls and extending the wrong node's endLine. This is a very common Haskell pattern and the only instance-related test in the diff exercises a single instance, so it doesn't catch this.
There was a problem hiding this comment.
Just committed an update that should address this.
Type-class instance methods sharing a name (e.g. two 'show' impls in different 'instance' blocks) were silently merged into one node because the clause-merge memo keyed only on function name. Track the top of the node stack (the instance/class/top-level container ID) alongside the name so continuation-clause merging only fires within the same scope. Adds a regression test with two 'instance Shape <T> where area ...' blocks asserting two distinct 'area' nodes, each contained by its own instance.
Add full Haskell (.hs, .lhs) support to CodeGraph's extraction pipeline:
Grammar: vendored tree-sitter-haskell 0.23.1 wasm (ABI 14) from the npm package — tree-sitter-wasms doesn't ship Haskell.
Validated on three repos by size tier:
All 630 extraction tests pass (19 new Haskell tests + 611 existing).