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
6 changes: 6 additions & 0 deletions .claude/skills/agent-eval/corpus.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,5 +94,11 @@
{ "name": "react-native-segmented-control", "repo": "https://github.com/react-native-segmented-control/segmented-control", "size": "Small", "files": "~25", "question": "How does JSX `<SegmentedControl onChange={cb}/>` reach the native onChange handler on iOS/Android?" },
{ "name": "react-native-screens", "repo": "https://github.com/software-mansion/react-native-screens", "size": "Medium", "files": "~1200", "question": "How does JSX `<ScreenStack>` reach the native RNSScreenStackView component?" },
{ "name": "react-native-skia", "repo": "https://github.com/Shopify/react-native-skia", "size": "Large", "files": "~1000", "question": "How does a `<SkiaPictureView/>` JSX usage reach the iOS / Android native renderer?" }
],
"Clojure": [
{ "name": "ring", "repo": "https://github.com/ring-clojure/ring", "size": "Small", "files": "~80", "question": "How does a Ring request flow from the Jetty adapter through the handler to an HTTP response?" },
{ "name": "logseq", "repo": "https://github.com/logseq/logseq", "size": "Medium", "files": "~960", "question": "How does editing a block in the editor get persisted to the database and reflected back in the UI?" },
{ "name": "metabase", "repo": "https://github.com/metabase/metabase", "size": "Large", "files": "~3400", "question": "How does a query submitted to the API flow through the query processor middleware to the database driver?" },
{ "name": "status-mobile", "repo": "https://github.com/status-im/status-mobile", "size": "Large", "files": "~2050", "question": "How does tapping the logout option in profile settings end the session and what happens to the app state?" }
]
}
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### New Features

- CodeGraph now indexes **Clojure and ClojureScript** (`.clj`, `.cljs`, `.cljc`, and Babashka `.bb` files) — namespaces, functions (`defn`, multi-arity, function-valued `def`s), protocols, records and their methods, multimethods, and `:require`/`:import` clauses. Calls through namespace aliases (`(str/upper-case ...)`) and `:refer`ed symbols resolve across files, so callers, callees, impact, and `codegraph_explore` flow tracing all work on Clojure codebases.
- **EDN config files** (`deps.edn`, `bb.edn`, `shadow-cljs.edn`, integrant/system configs) are indexed as data: top-level keys become searchable config entries, and qualified symbols in their values (a shadow-cljs `:init-fn`, an integrant component's handler) link to the code they name. Large EDN datasets (translation dictionaries, fixtures) are deliberately kept out of the graph.
- **re-frame apps get connected event flows**: every `reg-event-db`/`reg-event-fx`/`reg-sub` registration becomes a searchable symbol named by its keyword (`:profile/logout`), and `dispatch`/`subscribe` call sites link to it — so callers, impact, and flow tracing follow keyword-keyed dispatch across files. Project facades that wrap re-frame (custom `reg-*` helpers, `sub` aliases) are detected too.
- **UIx and helix components are first-class**: `defui`/`defnc` definitions become component symbols, and `($ button ...)` element composition produces real call edges — so "what renders this component" works in ClojureScript React apps.
- `codegraph status --json` now also reports the running CLI `version`, the index directory (`indexPath`), and a `lastIndexed` timestamp (ISO-8601, or null when nothing's indexed yet), so CI and scripts can pin the CLI version and check index freshness from a single command. A matching `CodeGraph.getLastIndexedAt()` library method exposes the same freshness check without shelling out. Thanks @12122J and @eddieran. (#329)

### Fixes
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ CodeGraph cuts **tokens, tool calls, and wall-clock time on every repo** — acr
| **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, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Dart, Lua, Luau, Svelte, Liquid, Pascal/Delphi |
| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Dart, Lua, Luau, Clojure/ClojureScript, Svelte, Liquid, Pascal/Delphi |
| **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 14 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 @@ -635,6 +635,7 @@ is written):
| Pascal / Delphi | `.pas`, `.dpr`, `.dpk`, `.lpr` | Full support (classes, records, interfaces, enums, DFM/FMX form files) |
| Lua | `.lua` | Full support (functions, methods with receivers, local variables, `require` imports, call edges) |
| Luau | `.luau` | Full support (everything in Lua, plus `type`/`export type` aliases, typed signatures, and Roblox instance-path `require`) |
| Clojure / ClojureScript | `.clj`, `.cljs`, `.cljc`, `.bb`, `.edn` | Full support (namespaces, `defn`/`def`, protocols, records, multimethods, `:require` alias-resolved call edges, reader conditionals, re-frame keyword dispatch, UIx/helix component composition; `.edn` config keys + code references) |

## Troubleshooting

Expand Down
178 changes: 178 additions & 0 deletions __tests__/explore-clojure-tokens.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/**
* Explore query handling for Clojure / monorepo idioms.
*
* Covers the four layers of the explore token fix:
* 1. Lisp-alphabet symbol tokens (kebab-case, `?`/`!`/`+`, `alias/name`)
* reach the named-seed injection instead of being filtered out.
* 2. A bare token naming a NAMESPACE by its last segment resolves to the
* module and pulls its file into the render.
* 3. An ambiguous bare token prefers the candidate co-located with the
* anchors (other tokens' locations) over a bigger-bodied def in an
* unrelated subsystem.
* 4. A colon-less namespaced keyword (`app/set-page-state`) resolves to the
* re-frame registration node `:app/set-page-state` — without letting a
* bare name be hijacked by a same-named unqualified keyword.
*/

import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';

beforeAll(async () => {
await initGrammars();
await loadAllGrammars();
});

function hasSqliteBindings(): boolean {
try {
const { DatabaseSync } = require('node:sqlite');
const db = new DatabaseSync(':memory:');
db.close();
return true;
} catch {
return false;
}
}
const HAS_SQLITE = hasSqliteBindings();

function tmpRoot(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-explore-clj-'));
}

function rmTree(dir: string): void {
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
}

/**
* A miniature monorepo with the shapes from the real-world failing session:
* - app/page/lifecycle/{activate,set_state}.cljs — per-stage `dashboard` fns
* (the ambiguous name) plus namespace-named stages.
* - app/page/hooks.cljs — a unique kebab fn (`on-route-change+`, the anchor)
* that dispatches the re-frame event.
* - backend/scim.clj — an unrelated subsystem with a LONGER same-named
* `dashboard` fn (the co-location trap) .
* - app/core/handlers.cljs — the re-frame registration `:app/set-page-state`.
*/
async function buildCljMonorepo(): Promise<string> {
const root = tmpRoot();
const w = (rel: string, content: string) => {
const p = path.join(root, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, content);
};

w('frontend/src/app/page/hooks.cljs', `(ns app.page.hooks
(:require [re-frame.core :as rf]))

(defn on-route-change+ [route]
(rf/dispatch [:app/set-page-state {:route route}]))
`);
w('frontend/src/app/page/lifecycle/activate.cljs', `(ns app.page.lifecycle.activate)

(defn dashboard [ctx]
(assoc ctx :activated true))
`);
w('frontend/src/app/page/lifecycle/set_state.cljs', `(ns app.page.lifecycle.set-state)

(defn dashboard [ctx]
(assoc ctx :page-state :dashboard))
`);
w('frontend/src/app/core/handlers.cljs', `(ns app.core.handlers
(:require [re-frame.core :as rf]))

(rf/reg-event-fx :app/set-page-state
(fn [{:keys [db]} [_ state]]
{:db (assoc db :page-state state)}))

(rf/reg-sub :dashboard
(fn [db _] (:dashboard db)))
`);
w('backend/src/backend/scim.clj', `(ns backend.scim)

(defn dashboard [user opts audit log extra]
(let [a (str user) b (str opts) c (str audit) d (str log) e (str extra)
f (str a b) g (str c d) h (str e f) i (str g h)]
(str a b c d e f g h i)))

(defn unrelated-one [] 1)
(defn unrelated-two [] 2)
(defn unrelated-three [] 3)
(defn unrelated-four [] 4)
`);
// A 4th `dashboard` def so the name is ambiguous (>3 defs) and the
// co-location pick actually runs — at <=3 defs ALL of them inject by design.
w('backend/src/backend/admin.clj', `(ns backend.admin)

(defn dashboard [stats]
(str "admin" stats))
`);
return root;
}

describe.skipIf(!HAS_SQLITE)('explore — Clojure/monorepo query tokens', () => {
let projectRoot: string;
let cg: any;
let handler: any;
let findAllSymbols: (cg: any, s: string) => { nodes: any[]; note: string };

beforeEach(async () => {
projectRoot = await buildCljMonorepo();
const CodeGraph = (await import('../src/index')).default;
const { ToolHandler } = await import('../src/mcp/tools');
cg = CodeGraph.initSync(projectRoot, {
config: { include: ['**/*.clj', '**/*.cljs'], exclude: [] },
});
await cg.indexAll();
handler = new ToolHandler(cg);
findAllSymbols = (handler as any).findAllSymbols.bind(handler);
});

afterEach(() => {
handler?.closeAll();
cg?.destroy();
rmTree(projectRoot);
});

async function explore(query: string): Promise<string> {
const res = await handler.execute('codegraph_explore', { query });
return res.content.map((c: any) => c.text ?? '').join('\n');
}

it('kebab-case tokens reach seed injection (named file renders)', async () => {
const out = await explore('on-route-change+ set-page-state route');
expect(out).toContain('hooks.cljs');
expect(out).toContain('on-route-change+');
});

it('a namespace-segment token pulls the module file into the render', async () => {
// `set-state` is no function — only the ns app.page.lifecycle.set-state.
const out = await explore('on-route-change+ set-state dashboard');
expect(out).toContain('set_state.cljs');
});

it('an ambiguous bare token prefers the candidate co-located with anchors', async () => {
// `dashboard` defs: two lifecycle stage fns (small) + backend.scim's
// (largest body, wrong subsystem). The anchor `on-route-change+` lives in
// frontend/src/app/page, so the lifecycle defs must win the render and
// the SCIM file must not appear.
const out = await explore('on-route-change+ activate set-state dashboard page lifecycle');
expect(out).toContain('lifecycle/activate.cljs');
expect(out).not.toContain('scim.clj');
});

it('a colon-less namespaced keyword resolves to the registration node', () => {
const { nodes } = findAllSymbols(cg, 'app/set-page-state');
expect(nodes.length).toBeGreaterThanOrEqual(1);
expect(nodes[0].name).toBe(':app/set-page-state');
});

it('a bare name is NOT hijacked by a same-named unqualified keyword', () => {
// `:dashboard` (reg-sub) exists AND fns named `dashboard` exist — the
// colon fallback must not preempt plain-name resolution for bare tokens.
const { nodes } = findAllSymbols(cg, 'dashboard');
expect(nodes.length).toBeGreaterThanOrEqual(1);
expect(nodes.every((n: any) => n.name === 'dashboard')).toBe(true);
});
});
Loading