Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CHANGELOG entry updated


- 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
263 changes: 263 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,257 @@ 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);
});
});

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);
});
});
});
9 changes: 9 additions & 0 deletions src/extraction/grammars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
terraform: 'tree-sitter-terraform.wasm',
arkts: 'tree-sitter-arkts.wasm',
nix: 'tree-sitter-nix.wasm',
haskell: 'tree-sitter-haskell.wasm',
};

/**
Expand Down Expand Up @@ -170,6 +171,9 @@ export const EXTENSION_MAP: Record<string, Language> = {
'.tf': 'terraform',
'.tfvars': 'terraform',
'.tofu': 'terraform',
// Haskell — vendored grammar from tree-sitter-haskell 0.23.1 (ABI 14).
'.hs': 'haskell',
'.lhs': 'haskell',
};

/**
Expand Down Expand Up @@ -291,6 +295,10 @@ export async function initGrammars(): Promise<void> {
const VENDORED_WASM_LANGS: ReadonlySet<GrammarLanguage> = new Set([
'pascal', 'scala', 'lua', 'luau', 'csharp', 'r', 'cfml', 'cfscript', 'cfquery',
'cobol', 'vbnet', 'erlang', 'terraform', 'arkts', 'nix',
// Haskell: tree-sitter-wasms doesn't ship it; we vendor the prebuilt
// tree-sitter-haskell.wasm from the tree-sitter-haskell 0.23.1 npm package
// (MIT, ABI 14) — byte-identical to the npm tarball's artifact.
'haskell',
'typescript', 'tsx', 'javascript', 'jsx', 'java', 'python', 'go',
// R7a (C/C++ kernel port prep): tree-sitter-c v0.24.2 (b780e47) +
// tree-sitter-cpp v0.23.4 (f41e1a0), parser.c/scanner.c sha-matched against
Expand Down Expand Up @@ -655,6 +663,7 @@ export function getLanguageDisplayName(language: Language): string {
erlang: 'Erlang',
terraform: 'Terraform',
arkts: 'ArkTS',
haskell: 'Haskell',
unknown: 'Unknown',
};
return names[language] || language;
Expand Down
Loading