Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .claude/skills/agent-eval/corpus.json
Original file line number Diff line number Diff line change
Expand Up @@ -607,5 +607,28 @@
"files": "~3390",
"question": "How does programs.git.enable produce the final git config file in the user's home directory? Trace the flow from the git program module to the home-files machinery that links generated files into place."
}
],
"Haskell": [
{
"name": "xmonad",
"repo": "https://github.com/xmonad/xmonad",
"size": "Small",
"files": "~31",
"question": "How does a user's key binding reach the window management action it triggers? Trace from the X event loop to the key press handler to the window action."
},
{
"name": "postgrest",
"repo": "https://github.com/PostgREST/postgrest",
"size": "Medium",
"files": "~123",
"question": "How does an incoming HTTP request reach the database query executor? Trace from the request router to the SQL query builder and database call."
},
{
"name": "pandoc",
"repo": "https://github.com/jgm/pandoc",
"size": "Large",
"files": "~366",
"question": "How does a Markdown document get converted to HTML? Trace from the reader that parses Markdown to the writer that emits HTML."
}
]
}
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### New Features

- CodeGraph now indexes **Haskell** (`.hs`, `.lhs`) — functions (including multi-clause definitions with type signatures and Haddock docs), type classes and their instances, algebraic data types and their constructors, record fields, type synonyms, newtypes, and module imports. The call graph follows both local and qualified calls (`Mod.fn`), data-constructor use, and user-defined infix operators, and `where`-clause helpers are tracked as their own symbols.

- Anonymous usage telemetry is now stored entirely on CodeGraph's own first-party infrastructure — no third-party analytics vendor receives any of it, and the endpoint that receives it makes no outbound requests at all. Individual events are deleted after 90 days, leaving only anonymous daily totals. Nothing about what is collected changed, your IP address is still never read or stored, and every off-switch works exactly as before (`codegraph telemetry off`, `CODEGRAPH_TELEMETRY=0`, `DO_NOT_TRACK=1`). `TELEMETRY.md` remains the complete field-by-field list.

- `codegraph_explore` no longer re-sends source it already returned earlier in the same conversation. A file it has already shown you comes back as a short pointer — the path, the symbols and the exact line range, with confirmation that the file hasn't changed since — and the space that frees is spent on code you haven't seen yet, so a follow-up call covers new ground instead of repeating the last one. If a file was edited in between, its source is always shown again in full. Set `CODEGRAPH_EXPLORE_DEDUP=0` to turn this off.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ CodeGraph's parsing engine is a **native Rust kernel**: 20 languages — TypeScr
| **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 |
| **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes |
| **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config |
| **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
| **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, Haskell, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
| **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 17 frameworks |
| **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules |
| **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only |
Expand Down Expand Up @@ -803,6 +803,7 @@ is written):
| Solidity | `.sol` | Full support (contracts, libraries, interfaces, structs, enums, modifiers, events, errors, state variables, `import`/`using` directives, `emit`/`revert` calls) |
| Terraform / OpenTofu | `.tf`, `.tfvars`, `.tofu` | Full support (resources, data sources, modules, variables, outputs, providers incl. aliases, `locals`; `var.`/`local.`/`module.`/resource references with Terraform's per-directory scoping enforced; module calls bridged across the boundary — inputs to the child module's variables, `module.M.out` to the child's output, `source` to the module's files; cloudposse/atmos `remote-state` cross-component wiring when the component is statically named; `provider = aws.east` selections resolved up the module tree; `moved`/`import`/`removed`/`check` block references; `.tfvars` assignments linked to the variables they set) |
| Nix | `.nix` | Full support (functions with simple/destructured/curried params, `let`/attrset bindings, `inherit`, `import ./path` file edges — `./dir` resolving through `default.nix` — plus NixOS module `imports = [ ./x.nix ]` lists and `callPackage ./pkg.nix` file edges; call edges; module-system option wiring — a config write like `launchd.user.agents.x = { ... }` links to the module declaring `options.launchd.user.agents`, so option flows trace across modules) |
| Haskell | `.hs`, `.lhs` | Full support (functions with multi-clause grouping, type signatures with 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 `Mod.fn` call edges, data constructor instantiations, `where`-clause helper extraction) |

## Measured cross-file coverage

Expand Down
300 changes: 300 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,15 @@ describe('Language Detection', () => {
expect(isSourceFile('default.nix')).toBe(true);
});

it('should detect Haskell files', () => {
expect(detectLanguage('src/Main.hs')).toBe('haskell');
expect(detectLanguage('app/Lib.lhs')).toBe('haskell');
expect(detectLanguage('src/Data/Map.hs')).toBe('haskell');
expect(isSourceFile('app/Main.hs')).toBe(true);
expect(isSourceFile('app/Main.lhs')).toBe(true);
expect(isSourceFile('README.md')).toBe(false);
});

it('should detect a .h whose only C++ signal is an export-macro class as cpp', () => {
// Lean Unreal-Engine style header: the class is annotated with an export
// macro and carries no explicit `public:`/`virtual`/`namespace`/`template`,
Expand Down Expand Up @@ -11589,3 +11598,294 @@ describe('C/C++ kernel-port preParse blanks (R7a)', () => {
expect(result.nodes.some((n) => n.kind === 'method' && n.name === 'size')).toBe(true);
});
});

describe('Haskell Extraction', () => {
describe('Language detection', () => {
it('should report Haskell as supported', () => {
expect(isLanguageSupported('haskell')).toBe(true);
expect(getSupportedLanguages()).toContain('haskell');
expect(isSourceFile('app/Main.hs')).toBe(true);
expect(isSourceFile('app/Main.lhs')).toBe(true);
});
});

describe('Function extraction', () => {
it('should extract simple functions', () => {
const code = `module M where

double :: Int -> Int
double x = x * 2

identity x = x
`;
const result = extractFromSource('src/M.hs', code);
const double = result.nodes.find((n) => n.kind === 'function' && n.name === 'double');
expect(double).toBeDefined();
expect(double?.signature).toBe('double :: Int -> Int');
expect(double?.language).toBe('haskell');
const identity = result.nodes.find((n) => n.kind === 'function' && n.name === 'identity');
expect(identity).toBeDefined();
});

it('should merge multi-clause functions into one node spanning all clauses', () => {
const code = `module M where

factorial :: Int -> Int
factorial 0 = 1
factorial n = n * factorial (n - 1)
`;
const result = extractFromSource('src/M.hs', code);
const fns = result.nodes.filter((n) => n.kind === 'function' && n.name === 'factorial');
expect(fns).toHaveLength(1);
expect(fns[0]!.startLine).toBe(4);
expect(fns[0]!.endLine).toBe(5);
});

it('should extract functions with guards', () => {
const code = `module M where

bar :: Int -> Int
bar x
| x > 0 = x
| otherwise = 0
`;
const result = extractFromSource('src/M.hs', code);
const bar = result.nodes.find((n) => n.kind === 'function' && n.name === 'bar');
expect(bar).toBeDefined();
expect(bar?.startLine).toBe(4);
expect(bar?.endLine).toBe(6);
});

it('should use the preceding signature as the function signature', () => {
const code = `module M where

-- | Computes the area.
area :: Double -> Double
area r = pi * r * r
`;
const result = extractFromSource('src/M.hs', code);
const area = result.nodes.find((n) => n.kind === 'function' && n.name === 'area');
expect(area?.signature).toBe('area :: Double -> Double');
expect(area?.docstring).toBe('Computes the area.');
});

it('should qualify functions with the module namespace', () => {
const code = `module MyModule where

start :: IO ()
start = pure ()
`;
const result = extractFromSource('src/MyModule.hs', code);
const ns = result.nodes.find((n) => n.kind === 'namespace');
expect(ns?.name).toBe('MyModule');
const start = result.nodes.find((n) => n.kind === 'function' && n.name === 'start');
expect(start?.qualifiedName).toBe('MyModule::start');
});
});

describe('Data type extraction', () => {
it('should extract simple ADTs as structs with constructors as enum_members', () => {
const code = `module M where

data Color = Red | Green | Blue
`;
const result = extractFromSource('src/M.hs', code);
const color = result.nodes.find((n) => n.kind === 'struct' && n.name === 'Color');
expect(color).toBeDefined();
const ctors = result.nodes.filter((n) => n.kind === 'enum_member').map((n) => n.name);
expect(ctors).toContain('Red');
expect(ctors).toContain('Green');
expect(ctors).toContain('Blue');
});

it('should extract record ADTs with fields', () => {
const code = `module M where

data Point = Point { x :: Double, y :: Double }
`;
const result = extractFromSource('src/M.hs', code);
const point = result.nodes.find((n) => n.kind === 'struct' && n.name === 'Point');
expect(point).toBeDefined();
const ctor = result.nodes.find((n) => n.kind === 'enum_member' && n.name === 'Point');
expect(ctor).toBeDefined();
const fields = result.nodes.filter((n) => n.kind === 'field').map((n) => n.name);
expect(fields).toContain('x');
expect(fields).toContain('y');
});

it('should extract parameterized ADTs', () => {
const code = `module M where

data Tree a = Leaf | Node a (Tree a) (Tree a)
`;
const result = extractFromSource('src/M.hs', code);
const tree = result.nodes.find((n) => n.kind === 'struct' && n.name === 'Tree');
expect(tree).toBeDefined();
const ctors = result.nodes.filter((n) => n.kind === 'enum_member').map((n) => n.name);
expect(ctors).toContain('Leaf');
expect(ctors).toContain('Node');
});
});

describe('Type class and instance extraction', () => {
it('should extract type classes as traits', () => {
const code = `module M where

class Shape s where
area :: s -> Double
perimeter :: s -> Double
`;
const result = extractFromSource('src/M.hs', code);
const shape = result.nodes.find((n) => n.kind === 'trait' && n.name === 'Shape');
expect(shape).toBeDefined();
});

it('should extract instances with implements reference', () => {
const code = `module M where

data Circle = Circle { radius :: Double }

class Shape s where
area :: s -> Double

instance Shape Circle where
area (Circle r) = pi * r * r
`;
const result = extractFromSource('src/M.hs', code);
const inst = result.nodes.find((n) => n.kind === 'class' && n.name === 'Shape.Circle');
expect(inst).toBeDefined();
const implRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'implements');
expect(implRefs.some((r) => r.referenceName === 'Shape')).toBe(true);
});

it('should not merge same-named methods across different instances', () => {
const code = `module M where

data Circle = Circle { radius :: Double }
data Square = Square { side :: Double }

class Shape s where
area :: s -> Double

instance Shape Circle where
area (Circle r) = pi * r * r

instance Shape Square where
area (Square s) = s * s
`;
const result = extractFromSource('src/M.hs', code);
const areas = result.nodes.filter((n) => n.kind === 'function' && n.name === 'area');
expect(areas).toHaveLength(2);
const circleInst = result.nodes.find((n) => n.kind === 'class' && n.name === 'Shape.Circle');
const squareInst = result.nodes.find((n) => n.kind === 'class' && n.name === 'Shape.Square');
expect(circleInst).toBeDefined();
expect(squareInst).toBeDefined();
// Each area method must be contained by its own instance, not both
// collapsed onto one.
const containsArea = (instId: string | undefined) =>
result.edges.some(
(e) =>
e.kind === 'contains' &&
e.source === instId &&
areas.some((a) => a.id === e.target),
);
expect(containsArea(circleInst!.id)).toBe(true);
expect(containsArea(squareInst!.id)).toBe(true);
// The two area nodes must be distinct (not the same id merged).
expect(areas[0]!.id).not.toBe(areas[1]!.id);
});
});

describe('Type synonym and newtype extraction', () => {
it('should extract type synonyms as type aliases', () => {
const code = `module M where

type Point2D = (Double, Double)
type Synonym = [Int]
`;
const result = extractFromSource('src/M.hs', code);
const p2d = result.nodes.find((n) => n.kind === 'type_alias' && n.name === 'Point2D');
expect(p2d).toBeDefined();
const syn = result.nodes.find((n) => n.kind === 'type_alias' && n.name === 'Synonym');
expect(syn).toBeDefined();
});

it('should extract newtypes as structs', () => {
const code = `module M where

newtype Score = Score Int
`;
const result = extractFromSource('src/M.hs', code);
const score = result.nodes.find((n) => n.kind === 'struct' && n.name === 'Score');
expect(score).toBeDefined();
});
});

describe('Import extraction', () => {
it('should extract simple imports', () => {
const code = `module M where

import Data.List (sort)
`;
const result = extractFromSource('src/M.hs', code);
const imp = result.nodes.find((n) => n.kind === 'import' && n.name === 'Data.List');
expect(imp).toBeDefined();
});

it('should extract qualified imports with alias', () => {
const code = `module M where

import qualified Data.Map as Map
`;
const result = extractFromSource('src/M.hs', code);
const imp = result.nodes.find((n) => n.kind === 'import' && n.name === 'Data.Map');
expect(imp).toBeDefined();
});
});

describe('Call edges', () => {
it('should extract local function calls', () => {
const code = `module M where

factorial :: Int -> Int
factorial 0 = 1
factorial n = n * factorial (n - 1)
`;
const result = extractFromSource('src/M.hs', code);
const refs = result.unresolvedReferences.filter(
(r) => r.referenceName === 'factorial' && r.referenceKind === 'calls',
);
expect(refs.length).toBeGreaterThan(0);
});

it('should extract qualified calls', () => {
const code = `module M where

import qualified Data.Text as T

main :: IO ()
main = T.putStrLn "hello"
`;
const result = extractFromSource('src/M.hs', code);
const refs = result.unresolvedReferences.filter(
(r) => r.referenceName === 'T.putStrLn' && r.referenceKind === 'calls',
);
expect(refs.length).toBeGreaterThan(0);
});

it('should extract data constructor applications as instantiates', () => {
const code = `module M where

data Maybe a = Nothing | Just a

main :: IO ()
main = print (Just 42)
`;
const result = extractFromSource('src/M.hs', code);
const refs = result.unresolvedReferences.filter(
(r) => r.referenceName === 'Just' && r.referenceKind === 'instantiates',
);
expect(refs.length).toBeGreaterThan(0);
});
});
});
Loading