@openez-graph/cli 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,12 +9,12 @@ OpenEZ Graph indexes your codebase into a local SQLite database, builds a code g
9
9
 
10
10
  **Zero config. No Docker. No Postgres. No Redis. Just install and go.**
11
11
 
12
- > **v1.0: Bun-powered, Rust-native parsing.** The CLI now runs exclusively on [Bun](https://bun.sh) 1.1+ with native `bun:sqlite` (no `better-sqlite3` compilation step). TS/JS parsing uses [oxc-parser](https://oxc.rs) (Rust-based, ~13x faster than Babel) instead of `ts-morph`. Python/Go/Rust use tree-sitter in rayon-parallel batches. Requires Bun 1.1+.
12
+ > **v1.0: Bun-powered, Rust-native parsing.** The CLI now runs exclusively on [Bun](https://bun.sh) 1.1+ with native `bun:sqlite` (no `better-sqlite3` compilation step). TS/JS parsing uses [oxc-parser](https://oxc.rs) (Rust-based, ~13x faster than Babel) instead of `ts-morph`. Python/Go/Rust/Ruby use tree-sitter (WASM) with regex fallback. Requires Bun 1.1+.
13
13
 
14
14
  ## Features
15
15
 
16
16
  - **Bun-powered** — native `bun:sqlite` driver, no native compilation, near-instant startup
17
- - **Rust-native parsing** — [oxc-parser](https://oxc.rs) for TS/JS (~13x faster than Babel), tree-sitter for Python/Go/Rust in rayon-parallel batches
17
+ - **Rust-native parsing** — [oxc-parser](https://oxc.rs) for TS/JS (~13x faster than Babel), tree-sitter (WASM) for Python/Go/Rust/Ruby with regex fallback
18
18
  - **Zero-config** — auto-registers workspace, auto-indexes; opt-in auto-sync via `OPENEZ_MCP_WATCH=1`
19
19
  - **SQLite-first** — all data stored locally in `.openez/` per workspace, no Postgres/Redis
20
20
  - **FTS5 full-text search** — SQLite FTS5 with BM25 ranking and porter tokenizer
@@ -106,14 +106,16 @@ Valid config keys: `embedding.provider`, `embedding.openai_api_key`, `embedding.
106
106
 
107
107
  ## Supported languages
108
108
 
109
- | Language | Parser | Indexing depth |
110
- | ----------------------- | ---------------------------- | -------------------------------------------------------------------- |
111
- | TypeScript / JavaScript | [oxc-parser](https://oxc.rs) | Richest — symbol extraction, imports, calls (~13x faster than Babel) |
112
- | Python | tree-sitter (Rust, rayon) | Symbol extraction, decorators, imports, calls |
113
- | Go | tree-sitter (Rust, rayon) | Symbol extraction, receiver-qualified methods, calls |
114
- | Rust | tree-sitter (Rust, rayon) | Symbol extraction, impl blocks, calls |
115
- | YAML / JSON / TOML | Structure-aware | Structure-aware chunking |
116
- | Markdown | Section-oriented | Section-oriented chunking |
109
+ | Language | Parser | Indexing depth |
110
+ | ----------------------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------- |
111
+ | TypeScript / JavaScript | [oxc-parser](https://oxc.rs) | Richest — symbol extraction, imports, calls (~13x faster than Babel) |
112
+ | Python | tree-sitter (WASM) | Symbol extraction, decorators, imports, calls |
113
+ | Go | tree-sitter (WASM) | Symbol extraction, receiver-qualified methods, calls |
114
+ | Rust | tree-sitter (WASM) | Symbol extraction, impl blocks, calls |
115
+ | Ruby | tree-sitter (WASM) | Symbol extraction, `class << self` context, `self.foo` calls, `require_relative` imports |
116
+ | CoffeeScript / Slim / CSS / SCSS / SASS / LESS / Haml | Fallback parser | Scanned and chunked (no symbol extraction) |
117
+ | YAML / JSON / TOML | Structure-aware | Structure-aware chunking |
118
+ | Markdown | Section-oriented | Section-oriented chunking |
117
119
 
118
120
  ## Retrieval quality
119
121
 
package/dist/CHANGELOG.md CHANGED
@@ -5,12 +5,34 @@ All notable changes to OpenEZ Graph are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.1] - 2026-08-16
9
+
10
+ ### Fixed
11
+
12
+ - **Reindex and DB open performance on large workspaces** — `openez status` and `openez reindex` on a 92K-chunk workspace (e.g. Zed) went from >8 minutes (100% CPU, never completing) to ~0.15s (status) and ~25s (reindex). Two root causes fixed:
13
+ - **FTS repair scan on every DB open** — `initializeWorkspaceSchema` ran an O(n²) `LEFT JOIN chunks_fts` (chunk_id is UNINDEXED in FTS5) plus an orphan-cleanup `DELETE ... NOT IN` on every open, even when FTS was fully in sync. Now skips the expensive repair when a count + shape check confirms FTS is in sync, and only runs the full repair when counts diverge or orphaned rows are detected.
14
+ - **FTS trigger storm during full reindex** — `resetIndexArtifacts` deleted 92K chunks with FTS triggers active, firing 92K triggered `DELETE FROM chunks_fts WHERE chunk_id = old.id` (each a full FTS shadow-table scan). Now drops FTS triggers and the FTS table before deleting chunks, then recreates an empty FTS table for the reindex to populate via `bulkInsertFts`.
15
+
16
+ ## [1.3.0] - 2026-08-15
17
+
18
+ ### Added
19
+
20
+ - **Ruby indexing** — tree-sitter WASM symbol extraction for classes, modules, methods, singleton methods, named lambdas, `class << self` nesting, and `require_relative` import edges.
21
+ - **Ruby and frontend language scanning** — `.rb`, `.rake`, `.gemspec`, CoffeeScript, Slim, CSS, SCSS, Sass, Less, and Haml files are now indexed; unsupported languages use fallback chunking.
22
+
23
+ ### Fixed
24
+
25
+ - **Ruby graph extraction** — preserves calls inside ordinary assignments, qualifies `self` calls in class bodies, keeps regex fallback nesting correct, and aligns method export metadata with the AST parser.
26
+
8
27
  ## [1.2.0] - 2026-08-11
9
28
 
10
29
  ### Added
11
30
 
12
31
  - **Lease-based indexing ownership** — indexing claims a 60-second lease with 15-second heartbeat. If the lease expires (e.g. process crash), another process can take over. Completion and failure writes are fenced by owner token — a stale owner cannot overwrite the status set by a newer owner.
13
32
  - **Graph build warnings in MCP** — `code_query` response includes a `warnings` array when graph expansion fails, so callers see FTS-only results instead of silently missing graph expansion.
33
+ - **Web UI: Reindex button** — workspace detail page now has a "Reindex workspace" button with loading state and error display, triggering a full rebuild via `startIndexRun(workspaceId, "full")`.
34
+ - **Web UI: Build & Open Graph** — workspace detail page shows "Build & Open Graph" button when graph data is empty, or "Open Graph" when graph exists. Graph is built lazily on first access via `ensureGraphReady`.
35
+ - **Web UI: Local embedding model picker** — settings page now supports selecting local embedding model preset (`jina-code-static-256`) with server-side validation against `LOCAL_EMBEDDING_MODELS` catalog. Provider dropdown includes "OpenEZ local" option alongside OpenAI and Ollama.
14
36
 
15
37
  ### Fixed
16
38
 
@@ -285,7 +307,9 @@ Remediation release — index/graph correctness, data protection, and web flow f
285
307
  - Error handling and validation for import path extraction
286
308
  - CLI npm packaging
287
309
 
310
+ [1.3.1]: https://github.com/asta-nguyen/openez-graph/compare/v1.3.0...v1.3.1
288
311
  [1.2.0]: https://github.com/asta-nguyen/openez-graph/compare/v1.1.0...v1.2.0
312
+ [1.3.0]: https://github.com/asta-nguyen/openez-graph/compare/v1.2.0...v1.3.0
289
313
  [1.1.0]: https://github.com/asta-nguyen/openez-graph/compare/v1.0.3...v1.1.0
290
314
  [1.0.3]: https://github.com/asta-nguyen/openez-graph/compare/v1.0.2...v1.0.3
291
315
  [1.0.2]: https://github.com/asta-nguyen/openez-graph/compare/v1.0.1...v1.0.2
package/dist/cli.cjs CHANGED
@@ -11910,6 +11910,13 @@ function createFtsOps(native, stmts, deps) {
11910
11910
  },
11911
11911
  // ── Reset ──
11912
11912
  resetIndexArtifacts() {
11913
+ native.exec("DROP TRIGGER IF EXISTS chunks_fts_insert");
11914
+ native.exec("DROP TRIGGER IF EXISTS chunks_fts_delete");
11915
+ native.exec("DROP TRIGGER IF EXISTS chunks_fts_update");
11916
+ native.exec("DROP TABLE IF EXISTS chunks_fts");
11917
+ native.exec(
11918
+ "CREATE VIRTUAL TABLE chunks_fts USING fts5(chunk_id UNINDEXED, path, heading, language, search_text, tokenize = 'unicode61')"
11919
+ );
11913
11920
  native.exec("DELETE FROM graph_edges");
11914
11921
  native.exec("DELETE FROM graph_nodes");
11915
11922
  native.exec("DELETE FROM chunks");
@@ -12114,20 +12121,36 @@ function initializeWorkspaceSchema(sqlite) {
12114
12121
  INNER JOIN documents d ON d.id = c.document_id;
12115
12122
  `);
12116
12123
  } else {
12117
- sqlite.exec(`
12118
- INSERT INTO chunks_fts (chunk_id, path, heading, language, search_text)
12119
- SELECT c.id, d.path, coalesce(c.heading, ''),
12120
- coalesce(d.language, ''),
12121
- ${composeFtsSearchTextSql("c.metadata", "c.content")}
12122
- FROM chunks c
12123
- INNER JOIN documents d ON d.id = c.document_id
12124
- LEFT JOIN chunks_fts f ON f.chunk_id = c.id
12125
- WHERE f.chunk_id IS NULL;
12126
- `);
12127
- sqlite.exec(`
12128
- DELETE FROM chunks_fts
12129
- WHERE chunk_id NOT IN (SELECT id FROM chunks);
12130
- `);
12124
+ const chunkCount = sqlite.prepare("SELECT count(*) as c FROM chunks").get().c;
12125
+ const ftsCount = sqlite.prepare("SELECT count(*) as c FROM chunks_fts").get().c;
12126
+ let ftsInSync = chunkCount === ftsCount;
12127
+ if (ftsInSync) {
12128
+ const ftsShape = sqlite.prepare(
12129
+ `
12130
+ SELECT count(DISTINCT f.chunk_id) AS distinct_count,
12131
+ count(*) - count(c.id) AS orphan_count
12132
+ FROM chunks_fts f
12133
+ LEFT JOIN chunks c ON c.id = f.chunk_id
12134
+ `
12135
+ ).get();
12136
+ ftsInSync = ftsShape.distinct_count === chunkCount && ftsShape.orphan_count === 0;
12137
+ }
12138
+ if (!ftsInSync) {
12139
+ sqlite.exec(`
12140
+ INSERT INTO chunks_fts (chunk_id, path, heading, language, search_text)
12141
+ SELECT c.id, d.path, coalesce(c.heading, ''),
12142
+ coalesce(d.language, ''),
12143
+ ${composeFtsSearchTextSql("c.metadata", "c.content")}
12144
+ FROM chunks c
12145
+ INNER JOIN documents d ON d.id = c.document_id
12146
+ LEFT JOIN chunks_fts f ON f.chunk_id = c.id
12147
+ WHERE f.chunk_id IS NULL;
12148
+ `);
12149
+ sqlite.exec(`
12150
+ DELETE FROM chunks_fts
12151
+ WHERE chunk_id NOT IN (SELECT id FROM chunks);
12152
+ `);
12153
+ }
12131
12154
  }
12132
12155
  const hasTypeLabelIdx = sqlite.prepare(
12133
12156
  "SELECT count(*) as c FROM sqlite_master WHERE type='index' AND name='idx_graph_nodes_type_label'"
@@ -33034,6 +33057,15 @@ function inferDocumentKind(filePath) {
33034
33057
  if (codeExtensions.has(extension)) {
33035
33058
  return { kind: "code", language: codeExtensions.get(extension) ?? null, extension };
33036
33059
  }
33060
+ if (styleExtensions.has(extension)) {
33061
+ return { kind: "code", language: styleExtensions.get(extension) ?? null, extension };
33062
+ }
33063
+ if (templateExtensions.has(extension)) {
33064
+ return { kind: "code", language: templateExtensions.get(extension) ?? null, extension };
33065
+ }
33066
+ if (scriptExtensions.has(extension)) {
33067
+ return { kind: "code", language: scriptExtensions.get(extension) ?? null, extension };
33068
+ }
33037
33069
  if (configExtensions.has(extension)) {
33038
33070
  return { kind: "config", language: configExtensions.get(extension) ?? null, extension };
33039
33071
  }
@@ -33537,6 +33569,83 @@ function parseRust(content, counter = exactTokenCounter) {
33537
33569
  callExpressions
33538
33570
  };
33539
33571
  }
33572
+ function findRubyBlockEnd(codeLines, startIndex) {
33573
+ const openingLine = codeLines[startIndex] ?? "";
33574
+ if (openingLine.includes(";") && /\bend\s*(?:#.*)?$/.test(openingLine)) {
33575
+ return startIndex + 1;
33576
+ }
33577
+ const baseIndent = codeLines[startIndex]?.search(/\S/) ?? 0;
33578
+ for (let i = startIndex + 1; i < codeLines.length; i++) {
33579
+ const trimmed = codeLines[i].trim();
33580
+ if (trimmed === "") continue;
33581
+ const indent = codeLines[i].search(/\S/);
33582
+ if (indent < 0) continue;
33583
+ if (indent < baseIndent) {
33584
+ return i;
33585
+ }
33586
+ if (indent === baseIndent && (trimmed === "end" || trimmed.startsWith("end ") || trimmed.startsWith("end "))) {
33587
+ return i + 1;
33588
+ }
33589
+ }
33590
+ return codeLines.length;
33591
+ }
33592
+ function parseRuby(content, counter = exactTokenCounter) {
33593
+ const lines = content.split("\n");
33594
+ const codeLines = stripNonCode(content, { hashComments: true }).split("\n");
33595
+ const definedSymbols = [];
33596
+ const importPaths = [];
33597
+ const calledIdentifiers = /* @__PURE__ */ new Set();
33598
+ const callExpressions = [];
33599
+ const symbolRegex = /^\s*(?:def\s+(?:self\.)?(\w+[?!]?)|class\s+(\w+(?:::\w+)*)|module\s+(\w+(?:::\w+)*))/;
33600
+ const requireRelativeRegex = /\brequire_relative\s+['"]([^'"]+)['"]/g;
33601
+ const contextStack = [];
33602
+ for (let i = 0; i < codeLines.length; i++) {
33603
+ const line = codeLines[i];
33604
+ const trimmed = line.trim();
33605
+ while (contextStack.length > 0 && i >= contextStack[contextStack.length - 1].endLine) {
33606
+ contextStack.pop();
33607
+ }
33608
+ const match2 = symbolRegex.exec(trimmed);
33609
+ if (match2) {
33610
+ const rawName = match2[1] ?? match2[2] ?? match2[3];
33611
+ if (!rawName) continue;
33612
+ const isMethod = Boolean(match2[1]);
33613
+ const symbolType2 = isMethod ? "function" : match2[2] ? "class" : "module";
33614
+ const parentName = contextStack.length > 0 ? contextStack[contextStack.length - 1].name : null;
33615
+ const name = parentName ? `${parentName}::${rawName}` : rawName;
33616
+ const endLine = findRubyBlockEnd(codeLines, i);
33617
+ definedSymbols.push({
33618
+ name,
33619
+ symbolType: symbolType2,
33620
+ type: symbolType2,
33621
+ exported: isMethod ? false : !rawName.startsWith("_"),
33622
+ startLine: i + 1,
33623
+ endLine
33624
+ });
33625
+ contextStack.push({ name, endLine });
33626
+ continue;
33627
+ }
33628
+ requireRelativeRegex.lastIndex = 0;
33629
+ let reqMatch;
33630
+ while ((reqMatch = requireRelativeRegex.exec(lines[i] ?? "")) !== null) {
33631
+ importPaths.push(reqMatch[1]);
33632
+ }
33633
+ }
33634
+ const chunks2 = createSymbolChunks(definedSymbols, lines, "ruby", counter);
33635
+ if (chunks2.length === 0) {
33636
+ return {
33637
+ ...makeFallbackChunks(content, lines, counter),
33638
+ importPaths: [...new Set(importPaths)]
33639
+ };
33640
+ }
33641
+ return {
33642
+ chunks: chunks2,
33643
+ importPaths: [...new Set(importPaths)],
33644
+ definedSymbols,
33645
+ calledIdentifiers: [...calledIdentifiers],
33646
+ callExpressions
33647
+ };
33648
+ }
33540
33649
  function indexConfig(content, language, counter = exactTokenCounter) {
33541
33650
  switch (language) {
33542
33651
  case "yaml":
@@ -33808,7 +33917,7 @@ function makeFallbackChunks(content, lines, counter = exactTokenCounter) {
33808
33917
  callExpressions: []
33809
33918
  };
33810
33919
  }
33811
- var import_node_path11, codeExtensions, configExtensions, markdownExtensions, PYTHON_CALL_IGNORES, GO_CALL_IGNORES, RUST_CALL_IGNORES;
33920
+ var import_node_path11, codeExtensions, configExtensions, styleExtensions, templateExtensions, scriptExtensions, markdownExtensions, PYTHON_CALL_IGNORES, GO_CALL_IGNORES, RUST_CALL_IGNORES;
33812
33921
  var init_languages = __esm({
33813
33922
  "../../packages/indexer/src/languages.ts"() {
33814
33923
  "use strict";
@@ -33826,7 +33935,10 @@ var init_languages = __esm({
33826
33935
  [".cts", "typescript"],
33827
33936
  [".py", "python"],
33828
33937
  [".go", "go"],
33829
- [".rs", "rust"]
33938
+ [".rs", "rust"],
33939
+ [".rb", "ruby"],
33940
+ [".rake", "ruby"],
33941
+ [".gemspec", "ruby"]
33830
33942
  ]);
33831
33943
  configExtensions = /* @__PURE__ */ new Map([
33832
33944
  [".yaml", "yaml"],
@@ -33834,6 +33946,20 @@ var init_languages = __esm({
33834
33946
  [".json", "json"],
33835
33947
  [".toml", "toml"]
33836
33948
  ]);
33949
+ styleExtensions = /* @__PURE__ */ new Map([
33950
+ [".css", "css"],
33951
+ [".scss", "scss"],
33952
+ [".sass", "scss"],
33953
+ [".less", "less"]
33954
+ ]);
33955
+ templateExtensions = /* @__PURE__ */ new Map([
33956
+ [".slim", "slim"],
33957
+ [".haml", "haml"]
33958
+ ]);
33959
+ scriptExtensions = /* @__PURE__ */ new Map([
33960
+ [".coffee", "coffeescript"],
33961
+ [".litcoffee", "coffeescript"]
33962
+ ]);
33837
33963
  markdownExtensions = /* @__PURE__ */ new Set([".md", ".mdx"]);
33838
33964
  PYTHON_CALL_IGNORES = /* @__PURE__ */ new Set([
33839
33965
  "if",
@@ -34863,44 +34989,62 @@ function walkTree(root, config2, lines) {
34863
34989
  }
34864
34990
  const symbolRule = symbolRuleMap.get(node.type);
34865
34991
  if (symbolRule) {
34866
- const name = symbolRule.extractName ? symbolRule.extractName(node) : getNodeName(node, symbolRule.nameField);
34867
- if (name) {
34868
- const parentName = contextStack.length > 0 ? contextStack[contextStack.length - 1].name : null;
34869
- const exported = symbolRule.isExported ? symbolRule.isExported(name, node) : false;
34870
- let receiver;
34871
- let receiverVar = null;
34872
- let receiverType = null;
34873
- if (symbolRule.receiverField) {
34874
- const rawReceiver = node.childForFieldName(symbolRule.receiverField)?.text;
34875
- if (rawReceiver) {
34876
- receiver = symbolRule.normalizeReceiver ? symbolRule.normalizeReceiver(rawReceiver) : rawReceiver;
34877
- receiverVar = goReceiverName(rawReceiver);
34878
- receiverType = goReceiverType(rawReceiver);
34879
- }
34880
- }
34881
- const fullName = receiverType ? `${receiverType}::${name}` : parentName ? `${parentName}::${name}` : name;
34882
- symbols.push({
34883
- name: fullName,
34884
- symbolType: symbolRule.symbolType,
34885
- type: symbolRule.symbolType,
34886
- exported,
34887
- startLine: startRow,
34888
- endLine: endRow,
34889
- ...receiver ? { receiver } : {}
34890
- });
34891
- const receiverInfo = receiverVar && receiverType ? { varName: receiverVar, typeName: receiverType } : void 0;
34892
- extractCallsInNode(
34893
- node,
34894
- config2,
34895
- fullName,
34896
- receiverInfo,
34897
- calledIdentifiers,
34898
- callExpressions
34899
- );
34900
- const isContextNode = symbolRule.establishesContext || config2.contextNodeTypes.has(node.type);
34901
- if (isContextNode) {
34902
- const contextName = symbolRule.extractContextName ? symbolRule.extractContextName(node) ?? fullName : fullName;
34903
- contextStack.push({ name: contextName, endRow, receiver: receiverInfo });
34992
+ if (symbolRule.contextOnly) {
34993
+ const contextName = symbolRule.extractContextName ? symbolRule.extractContextName(node, contextStack) ?? null : null;
34994
+ if (contextName) {
34995
+ contextStack.push({
34996
+ name: contextName,
34997
+ endRow,
34998
+ kind: symbolRule.contextKind
34999
+ });
35000
+ }
35001
+ } else {
35002
+ const name = symbolRule.extractName ? symbolRule.extractName(node) : getNodeName(node, symbolRule.nameField ?? "name");
35003
+ if (name) {
35004
+ const parentName = contextStack.length > 0 ? contextStack[contextStack.length - 1].name : null;
35005
+ const exported = symbolRule.isExported ? symbolRule.isExported(name, node) : false;
35006
+ let receiver;
35007
+ let receiverVar = null;
35008
+ let receiverType = null;
35009
+ if (symbolRule.receiverField) {
35010
+ const rawReceiver = node.childForFieldName(symbolRule.receiverField)?.text;
35011
+ if (rawReceiver) {
35012
+ receiver = symbolRule.normalizeReceiver ? symbolRule.normalizeReceiver(rawReceiver) : rawReceiver;
35013
+ receiverVar = goReceiverName(rawReceiver);
35014
+ receiverType = goReceiverType(rawReceiver);
35015
+ }
35016
+ }
35017
+ const fullName = receiverType ? `${receiverType}::${name}` : parentName ? `${parentName}::${name}` : name;
35018
+ symbols.push({
35019
+ name: fullName,
35020
+ symbolType: symbolRule.symbolType,
35021
+ type: symbolRule.symbolType,
35022
+ exported,
35023
+ startLine: startRow,
35024
+ endLine: endRow,
35025
+ ...receiver ? { receiver } : {}
35026
+ });
35027
+ const receiverInfo = receiverVar && receiverType ? { varName: receiverVar, typeName: receiverType } : void 0;
35028
+ const isContextNode = symbolRule.establishesContext || config2.contextNodeTypes.has(node.type);
35029
+ const contextName = isContextNode ? symbolRule.extractContextName?.(node, contextStack) ?? fullName : null;
35030
+ const callContext = contextName && symbolRule.contextKind ? [...contextStack, { name: contextName, endRow, kind: symbolRule.contextKind }] : contextStack;
35031
+ extractCallsInNode(
35032
+ node,
35033
+ config2,
35034
+ fullName,
35035
+ receiverInfo,
35036
+ calledIdentifiers,
35037
+ callExpressions,
35038
+ callContext
35039
+ );
35040
+ if (isContextNode) {
35041
+ contextStack.push({
35042
+ name: contextName ?? fullName,
35043
+ endRow,
35044
+ kind: symbolRule.contextKind,
35045
+ receiver: receiverInfo
35046
+ });
35047
+ }
34904
35048
  }
34905
35049
  }
34906
35050
  } else if (isCallNode(node, config2.callRule)) {
@@ -34919,9 +35063,19 @@ function walkTree(root, config2, lines) {
34919
35063
  }
34920
35064
  return { symbols, importPaths, calledIdentifiers, callExpressions };
34921
35065
  }
34922
- function extractCallsInNode(symbolNode, config2, callerName, receiverInfo, calledIdentifiers, callExpressions) {
34923
- const nestedSymbolTypes = config2.symbolRules.map((r) => r.nodeType);
34924
- const nestedSymbols = symbolNode.descendantsOfType(nestedSymbolTypes).filter((n) => !(n.startIndex === symbolNode.startIndex && n.endIndex === symbolNode.endIndex));
35066
+ function extractCallsInNode(symbolNode, config2, callerName, receiverInfo, calledIdentifiers, callExpressions, contextStack) {
35067
+ const symbolRuleMap = new Map(config2.symbolRules.map((rule) => [rule.nodeType, rule]));
35068
+ const nestedSymbols = symbolNode.descendantsOfType(config2.symbolRules.map((rule) => rule.nodeType)).filter((node) => {
35069
+ if (node.startIndex === symbolNode.startIndex && node.endIndex === symbolNode.endIndex) {
35070
+ return false;
35071
+ }
35072
+ const rule = symbolRuleMap.get(node.type);
35073
+ if (!rule) return false;
35074
+ if (rule.contextOnly) return true;
35075
+ return Boolean(
35076
+ rule.extractName ? rule.extractName(node) : getNodeName(node, rule.nameField ?? "name")
35077
+ );
35078
+ });
34925
35079
  const callNodes = symbolNode.descendantsOfType(config2.callRule.nodeType);
34926
35080
  for (const callNode of callNodes) {
34927
35081
  const insideNested = nestedSymbols.some(
@@ -34932,11 +35086,13 @@ function extractCallsInNode(symbolNode, config2, callerName, receiverInfo, calle
34932
35086
  if (!calleeName2) continue;
34933
35087
  const normalized = config2.normalizeCallName(calleeName2);
34934
35088
  const qualifiedCallee = receiverInfo && calleeName2.startsWith(`${receiverInfo.varName}.`) ? `${receiverInfo.typeName}::${normalized}` : normalized;
34935
- if (!normalized || config2.callIgnores.has(calleeName2) || config2.callIgnores.has(normalized) || qualifiedCallee === callerName) {
35089
+ const hookQualified = config2.qualifyCall?.(callNode, calleeName2, normalized, contextStack);
35090
+ const finalCallee = hookQualified ?? qualifiedCallee;
35091
+ if (!normalized || config2.callIgnores.has(calleeName2) || config2.callIgnores.has(normalized) || finalCallee === callerName) {
34936
35092
  continue;
34937
35093
  }
34938
- calledIdentifiers.add(qualifiedCallee);
34939
- callExpressions.push({ callerName, calleeName: qualifiedCallee });
35094
+ calledIdentifiers.add(finalCallee);
35095
+ callExpressions.push({ callerName, calleeName: finalCallee });
34940
35096
  }
34941
35097
  }
34942
35098
  async function parseWithTreeSitter(config2, content, counter) {
@@ -35053,7 +35209,42 @@ function extractRustImports(node) {
35053
35209
  }
35054
35210
  return paths;
35055
35211
  }
35056
- var PYTHON_CALL_IGNORES2, pythonConfig, GO_CALL_IGNORES2, goConfig, RUST_CALL_IGNORES2, rustConfig;
35212
+ function normalizeRubyCallName(value) {
35213
+ const parts = value.split(/[.::]/).filter(Boolean);
35214
+ return parts[parts.length - 1] ?? value;
35215
+ }
35216
+ function extractRubyImports(node) {
35217
+ const paths = [];
35218
+ if (node.type === "call") {
35219
+ const methodNode = node.childForFieldName("method");
35220
+ if (methodNode && methodNode.text === "require_relative") {
35221
+ const argsNode = node.childForFieldName("arguments");
35222
+ if (argsNode) {
35223
+ const stringNode = argsNode.namedChildren.find(
35224
+ (c) => c.type === "string" || c.type === "argument_list"
35225
+ );
35226
+ const target = stringNode?.type === "string" ? stringNode : stringNode?.namedChildren.find((c) => c.type === "string");
35227
+ if (target) {
35228
+ const text2 = target.text.replace(/^['"]|['"]$/g, "");
35229
+ if (text2) paths.push(text2);
35230
+ }
35231
+ }
35232
+ }
35233
+ }
35234
+ return paths;
35235
+ }
35236
+ function rubyQualifyCall(callNode, _calleeName, normalized, contextStack) {
35237
+ const receiverNode = callNode.childForFieldName("receiver");
35238
+ if (!receiverNode || receiverNode.text !== "self") return null;
35239
+ for (let i = contextStack.length - 1; i >= 0; i--) {
35240
+ const frame = contextStack[i];
35241
+ if (frame.kind === "class" || frame.kind === "module") {
35242
+ return `${frame.name}::${normalized}`;
35243
+ }
35244
+ }
35245
+ return null;
35246
+ }
35247
+ var PYTHON_CALL_IGNORES2, pythonConfig, GO_CALL_IGNORES2, goConfig, RUST_CALL_IGNORES2, rustConfig, RUBY_CALL_IGNORES, rubyConfig;
35057
35248
  var init_configs = __esm({
35058
35249
  "../../packages/indexer/src/tree-sitter/configs.ts"() {
35059
35250
  "use strict";
@@ -35280,7 +35471,7 @@ var init_configs = __esm({
35280
35471
  }
35281
35472
  return typeName ? `impl ${typeName}` : null;
35282
35473
  },
35283
- extractContextName: (node) => {
35474
+ extractContextName: (node, _contextStack) => {
35284
35475
  return node.childForFieldName("type")?.text ?? null;
35285
35476
  },
35286
35477
  establishesContext: true,
@@ -35318,6 +35509,132 @@ var init_configs = __esm({
35318
35509
  contextNodeTypes: /* @__PURE__ */ new Set(["function_item", "trait_item", "impl_item"]),
35319
35510
  contextNameField: "name"
35320
35511
  };
35512
+ RUBY_CALL_IGNORES = /* @__PURE__ */ new Set([
35513
+ "if",
35514
+ "unless",
35515
+ "while",
35516
+ "until",
35517
+ "for",
35518
+ "return",
35519
+ "yield",
35520
+ "break",
35521
+ "next",
35522
+ "redo",
35523
+ "retry",
35524
+ "puts",
35525
+ "pp",
35526
+ "p",
35527
+ "print",
35528
+ "raise",
35529
+ "require",
35530
+ "require_relative",
35531
+ "load",
35532
+ "autoload",
35533
+ "attr_accessor",
35534
+ "attr_reader",
35535
+ "attr_writer",
35536
+ "include",
35537
+ "extend",
35538
+ "define_method",
35539
+ "lambda",
35540
+ "proc",
35541
+ "super",
35542
+ "self",
35543
+ "nil",
35544
+ "true",
35545
+ "false",
35546
+ "new",
35547
+ "Array",
35548
+ "Hash",
35549
+ "String",
35550
+ "Integer",
35551
+ "Float",
35552
+ "Symbol"
35553
+ ]);
35554
+ rubyConfig = {
35555
+ language: "ruby",
35556
+ symbolRules: [
35557
+ {
35558
+ nodeType: "class",
35559
+ symbolType: "class",
35560
+ nameField: "name",
35561
+ establishesContext: true,
35562
+ contextKind: "class",
35563
+ isExported: (name) => name[0] >= "A" && name[0] <= "Z"
35564
+ },
35565
+ {
35566
+ nodeType: "module",
35567
+ symbolType: "module",
35568
+ nameField: "name",
35569
+ establishesContext: true,
35570
+ contextKind: "module",
35571
+ isExported: (name) => name[0] >= "A" && name[0] <= "Z"
35572
+ },
35573
+ {
35574
+ nodeType: "method",
35575
+ symbolType: "function",
35576
+ nameField: "name",
35577
+ establishesContext: true,
35578
+ isExported: () => false
35579
+ },
35580
+ {
35581
+ nodeType: "singleton_method",
35582
+ symbolType: "function",
35583
+ nameField: "name",
35584
+ establishesContext: true,
35585
+ isExported: () => false
35586
+ },
35587
+ {
35588
+ nodeType: "singleton_class",
35589
+ symbolType: "class",
35590
+ contextOnly: true,
35591
+ extractContextName: (_node, contextStack) => {
35592
+ for (let i = contextStack.length - 1; i >= 0; i--) {
35593
+ const frame = contextStack[i];
35594
+ if (frame.kind === "class" || frame.kind === "module") {
35595
+ return frame.name;
35596
+ }
35597
+ }
35598
+ return null;
35599
+ },
35600
+ isExported: () => false
35601
+ },
35602
+ {
35603
+ nodeType: "assignment",
35604
+ symbolType: "lambda",
35605
+ extractName: (node) => {
35606
+ const rightNode = node.childForFieldName("right");
35607
+ if (!rightNode) return null;
35608
+ if (rightNode.type === "lambda") {
35609
+ const leftNode = node.childForFieldName("left");
35610
+ return leftNode?.text ?? null;
35611
+ }
35612
+ if (rightNode.type === "call") {
35613
+ const methodNode = rightNode.childForFieldName("method");
35614
+ const methodName = methodNode?.text;
35615
+ if (methodName === "proc" || methodName === "lambda") {
35616
+ const leftNode = node.childForFieldName("left");
35617
+ return leftNode?.text ?? null;
35618
+ }
35619
+ const receiverNode = rightNode.childForFieldName("receiver");
35620
+ if (receiverNode?.text === "Proc" && methodName === "new") {
35621
+ const leftNode = node.childForFieldName("left");
35622
+ return leftNode?.text ?? null;
35623
+ }
35624
+ }
35625
+ return null;
35626
+ },
35627
+ isExported: () => false
35628
+ }
35629
+ ],
35630
+ importRules: [{ nodeType: "call", extract: extractRubyImports }],
35631
+ callRule: { nodeType: "call", functionField: "method" },
35632
+ callIgnores: RUBY_CALL_IGNORES,
35633
+ normalizeCallName: normalizeRubyCallName,
35634
+ contextNodeTypes: /* @__PURE__ */ new Set(["class", "module", "method", "singleton_method"]),
35635
+ contextNameField: "name",
35636
+ qualifyCall: rubyQualifyCall
35637
+ };
35321
35638
  }
35322
35639
  });
35323
35640
 
@@ -35345,12 +35662,14 @@ var init_tree_sitter_parser = __esm({
35345
35662
  LANGUAGE_CONFIGS = {
35346
35663
  python: pythonConfig,
35347
35664
  go: goConfig,
35348
- rust: rustConfig
35665
+ rust: rustConfig,
35666
+ ruby: rubyConfig
35349
35667
  };
35350
35668
  REGEX_FALLBACKS = {
35351
35669
  python: parsePython,
35352
35670
  go: parseGo,
35353
- rust: parseRust
35671
+ rust: parseRust,
35672
+ ruby: parseRuby
35354
35673
  };
35355
35674
  TreeSitterParser = class {
35356
35675
  name = "tree-sitter";
@@ -43052,12 +43371,18 @@ var init_scanner = __esm({
43052
43371
  DEFAULT_INCLUDE_PATTERNS = [
43053
43372
  ...Array.from(codeExtensions.keys()).map((ext) => `**/*${ext}`),
43054
43373
  ...Array.from(configExtensions.keys()).map((ext) => `**/*${ext}`),
43055
- ...Array.from(markdownExtensions).map((ext) => `**/*${ext}`)
43374
+ ...Array.from(markdownExtensions).map((ext) => `**/*${ext}`),
43375
+ ...Array.from(styleExtensions.keys()).map((ext) => `**/*${ext}`),
43376
+ ...Array.from(templateExtensions.keys()).map((ext) => `**/*${ext}`),
43377
+ ...Array.from(scriptExtensions.keys()).map((ext) => `**/*${ext}`)
43056
43378
  ];
43057
43379
  ALLOWED_EXTENSIONS = /* @__PURE__ */ new Set([
43058
43380
  ...codeExtensions.keys(),
43059
43381
  ...configExtensions.keys(),
43060
- ...markdownExtensions
43382
+ ...markdownExtensions,
43383
+ ...styleExtensions.keys(),
43384
+ ...templateExtensions.keys(),
43385
+ ...scriptExtensions.keys()
43061
43386
  ]);
43062
43387
  DEFAULT_EXCLUDE_PATTERNS = [
43063
43388
  "**/node_modules",
@@ -43178,6 +43503,9 @@ function createWorkspaceFileResolver(workspaceRoot, files) {
43178
43503
  const resolved = resolvePythonImport(importerRelativePath, importPath);
43179
43504
  if (resolved) return resolved;
43180
43505
  }
43506
+ if (language === "ruby") {
43507
+ return resolveRelativeImport(importerRelativePath, importPath);
43508
+ }
43181
43509
  if (importPath.startsWith(".")) {
43182
43510
  return resolveRelativeImport(importerRelativePath, importPath);
43183
43511
  }
@@ -44116,7 +44444,8 @@ var init_index_workspace = __esm({
44116
44444
  ".cts",
44117
44445
  ".md",
44118
44446
  ".mdx",
44119
- ".py"
44447
+ ".py",
44448
+ ".rb"
44120
44449
  ];
44121
44450
  PARSER_VERSION_OXC = "oxc-v2";
44122
44451
  PARSER_VERSION_NATIVE = "native-v1";
@@ -59961,7 +60290,7 @@ __export(mcp_bridge_exports, {
59961
60290
  startMcpServer: () => startMcpServer
59962
60291
  });
59963
60292
  async function startMcpServer(defaultPath, version4) {
59964
- await createAndStartMcpServer({ defaultPath, version: version4, build: "32a9b86-dirty" });
60293
+ await createAndStartMcpServer({ defaultPath, version: version4, build: "5060798-dirty" });
59965
60294
  }
59966
60295
  var init_mcp_bridge = __esm({
59967
60296
  "src/mcp-bridge.ts"() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openez-graph/cli",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "Local-first code intelligence engine — index, query, and graph your codebase with zero config. SQLite-only, MCP-first.",
5
5
  "license": "MIT",
6
6
  "author": "Asta Nguyen",
@@ -51,6 +51,7 @@
51
51
  "oxc-parser": "^0.143.0",
52
52
  "tree-sitter-go": "^0.25.0",
53
53
  "tree-sitter-python": "^0.25.0",
54
+ "tree-sitter-ruby": "^0.23.1",
54
55
  "tree-sitter-rust": "^0.24.0",
55
56
  "web-tree-sitter": "^0.26.11"
56
57
  },