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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
*.log
.worktrees/
.claude/
__pycache__/
11 changes: 5 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Mycelium is a local-first, graph-aware code intelligence layer for AI agents. We

The daemon is registered as a systemd-user service (`mycel.service`) and watches this tree with a 2s debounce. Edits you make are reflected in the graph within seconds.

**As of 2026-05-04 benchmarking: only `definers` is reliable.** Use it for "where is X defined" — it returns a line range and signature in one shot, saving the subsequent `Read`. Fall back to grep for everything else.
**As of 2026-05-17 (Phase 3 Workstream A):** `definers`, `callers`, `uses`, and `implements` are all reliable for TypeScript and Rust within the indexed surface. `find` is reliable with description coverage. `IMPORTS` queries still need `grep` (file→file edge granularity).

Run from the repo root (`target/release/mycel`; not on PATH):

Expand All @@ -20,9 +20,9 @@ Run from the repo root (`target/release/mycel`; not on PATH):
| Install the graph-care skill into Claude Code | `target/release/mycel --repo . skill install` | **Works** |
| Backfill legacy description hashes | `target/release/mycel --repo . synthesize --refresh-hashes-only` | **Works** — one-shot for graphs predating 2026-05-05 |
| Re-index from scratch (drop legacy descriptions) | `target/release/mycel --repo . index . --force-cold-rebuild` | **Works** |
| Callers/callees | `target/release/mycel --repo . callers <name>` | **Broken** — returns empty; use grep |
| Type usage | `target/release/mycel --repo . uses <type>` | **Broken** — returns empty; use grep |
| Interface implementations | `target/release/mycel --repo . implements <iface>` | **Broken** — IMPLEMENTS edges not landing; use grep |
| Callers/callees | `target/release/mycel --repo . callers <name>` | **Works** — cross-file CALLS via LSP definition resolution (Phase 3 Workstream A) |
| Type usage | `target/release/mycel --repo . uses <type>` | **Works** — cross-file USES_TYPE via LSP definition resolution (Phase 3 Workstream A) |
| Interface implementations | `target/release/mycel --repo . implements <iface>` | **Works** — cross-file IMPLEMENTS via LSP definition resolution (Phase 3 Workstream A) |

Add `--json` for structured output. If you've rebuilt the daemon: `target/release/mycel daemon stop && target/release/mycel daemon start`.

Expand All @@ -35,9 +35,8 @@ These are tracked and being worked on; flag them when they bite, don't try to fi
- **`find` quality depends on description coverage.** Phase 2 ships description synthesis — a freshly indexed graph has signature+body-slice-embedded symbols until Claude (via the `mycel-graph-care` skill) writes behavioral descriptions for the symbols you actually work with — coverage grows with use, not with a one-shot bulk command. Symbols with descriptions cluster by behavior; signature-embedded symbols cluster by name shape. Mixed states (partial coverage) give mixed results.
- **Module-declaration hallucinations.** (Applies to the bulk `mycel synthesize` path; the workload-driven skill instructs Claude to skip module decls.) Single-line `mod foo;` symbols get rich behavioral descriptions hallucinated from the module's name only (e.g., `mod launchd;` → "core logic for managing background services… launching system daemons"), which cluster against unrelated queries. Known follow-up: skip `SymbolKind::Module` in `list_symbols_for_synthesis`. Until then, filter top results by `kind` if a module decl is dragging your search off course.
- **HNSW low-k flakiness.** FalkorDB's HNSW vector index walks adaptively; at `--limit < 10` it sometimes returns zero rows on valid queries that have answers at `--limit 20`. Default is `20` post-Phase 2; raise it further if you suspect a result is being clipped.
- **`callers`, `uses`, `implements` all return empty.** Benchmarked: `callers content_hash` → empty (grep found 3 callers). `uses Symbol` → empty (grep found 67). `implements Embedder` → empty (OllamaEmbedder clearly implements it). CALLS, TYPED_BY, and IMPLEMENTS edges are not landing. Phase 2/3 work.
- **Cross-file CALLS edges drop silently.** Tree-sitter only resolves same-file callees; the multilspy bridge currently emits degenerate `REFERENCES` so cross-file CALLS don't land. Phase 2/3 territory.
- **`IMPORTS` queries return empty** — tree-sitter emits import edges as file→file, not Symbol→Symbol. Use `grep -rn 'use <crate>'` directly.
- **LSP-resolved edges only land within the indexed surface.** Cross-file CALLS / USES_TYPE / IMPLEMENTS that resolve into stdlib, node_modules, or any path outside the repo are dropped (the bridge returns the file URI, `symbol_containing` returns None, the edge is skipped). Same-language source files inside the repo work. Phase 3 Workstream A.

If a `mycel` command returns empty when you expect results, **first verify the daemon is healthy** before assuming the query is wrong:

Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

36 changes: 36 additions & 0 deletions crates/mycel-core/src/edge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,40 @@ pub struct Edge {
pub to: String,
pub kind: EdgeKind,
pub source: EdgeSource,
/// 1-indexed line number in the `from` symbol's source file at which the
/// call/use/implements site appears. Populated by tree-sitter extractors
/// for CALLS / USES_TYPE / IMPLEMENTS edges so the LSP refinement layer
/// can hover that line to resolve the `to` endpoint to a qualified name
/// across file boundaries. `None` for derived or pre-resolution edges.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from_line: Option<u32>,
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn edge_default_has_no_from_line() {
let e = Edge {
from: "a".into(),
to: "b".into(),
kind: EdgeKind::Calls,
source: EdgeSource::TreeSitter,
from_line: None,
};
assert!(e.from_line.is_none());
}

#[test]
fn edge_with_from_line_carries_coord() {
let e = Edge {
from: "a".into(),
to: "b".into(),
kind: EdgeKind::Calls,
source: EdgeSource::TreeSitter,
from_line: Some(42),
};
assert_eq!(e.from_line, Some(42));
}
}
1 change: 1 addition & 0 deletions crates/mycel-core/tests/serde.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ fn edge_kind_serializes_lowercase() {
to: "b".into(),
kind: EdgeKind::Calls,
source: EdgeSource::Lsp,
from_line: None,
};
let json = serde_json::to_string(&e).unwrap();
assert!(json.contains("\"calls\""));
Expand Down
17 changes: 15 additions & 2 deletions crates/mycel-extract/src/languages/rust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,20 +151,29 @@ fn extract_rust(
let mut matches = cursor.matches(&q_impl, tree.root_node(), bytes);
while let Some(m) = matches.next() {
let mut trait_name: Option<&str> = None;
let mut trait_node: Option<Node> = None;
let mut type_name: Option<&str> = None;
for c in m.captures {
match q_impl.capture_names()[c.index as usize] {
"trait" => trait_name = node_text(c.node, bytes),
"trait" => {
trait_name = node_text(c.node, bytes);
trait_node = Some(c.node);
}
"ty" => type_name = node_text(c.node, bytes),
_ => {}
}
}
if let (Some(trait_name), Some(type_name)) = (trait_name, type_name) {
if let (Some(trait_name), Some(trait_node), Some(type_name)) =
(trait_name, trait_node, type_name)
{
edges.push(Edge {
from: format!("{}::{}", file.as_str(), type_name),
to: trait_name.into(),
kind: EdgeKind::Implements,
source: EdgeSource::TreeSitter,
// 1-indexed row of the trait reference in the `impl Trait for Ty`
// header so LSP can resolve `trait_name` to its definition file.
from_line: Some(trait_node.start_position().row as u32 + 1),
});
}
}
Expand All @@ -185,6 +194,7 @@ fn extract_rust(
to: path.into(),
kind: EdgeKind::Imports,
source: EdgeSource::TreeSitter,
from_line: None,
});
}
}
Expand Down Expand Up @@ -214,6 +224,9 @@ fn extract_rust(
to: callee.into(),
kind: EdgeKind::Calls,
source: EdgeSource::TreeSitter,
// 1-indexed call-site row so LSP refinement can hover the
// line to resolve `callee` across files.
from_line: Some(c.node.start_position().row as u32 + 1),
});
}
}
Expand Down
4 changes: 4 additions & 0 deletions crates/mycel-extract/src/languages/typescript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,9 @@ fn extract_symbols_and_edges(
to: callee_name.into(),
kind: EdgeKind::Calls,
source: EdgeSource::TreeSitter,
// 1-indexed call-site row so LSP refinement can hover the line
// to resolve `callee_name` across files.
from_line: Some(site.start_position().row as u32 + 1),
});
}

Expand All @@ -229,6 +232,7 @@ fn extract_symbols_and_edges(
to: src_text.into(),
kind: EdgeKind::Imports,
source: EdgeSource::TreeSitter,
from_line: None,
});
}
}
Expand Down
37 changes: 37 additions & 0 deletions crates/mycel-extract/tests/rust_fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,40 @@ fn simple_module_snapshot() {
fn trait_and_impl_snapshot() {
insta::assert_yaml_snapshot!(extract_fixture("trait_and_impl.rs"));
}

#[test]
fn rust_call_edge_carries_from_line() {
let out = extract_fixture("simple_module.rs");
let call_edges: Vec<_> = out
.edges
.iter()
.filter(|e| matches!(e.kind, mycel_core::EdgeKind::Calls))
.collect();
assert!(!call_edges.is_empty(), "fixture should produce >=1 CALL edge");
// Pinning the exact line locks the `start_position().row + 1` conversion.
// A `+ 0` regression would be caught here, where `> 0` alone would not.
// The only call site in the fixture is `add(x, x)` inside `double` on line 6.
for e in &call_edges {
assert_eq!(e.from_line, Some(6), "edge {e:?}");
}
}

#[test]
fn rust_implements_edge_carries_from_line() {
let out = extract_fixture("trait_and_impl.rs");
let impl_edges: Vec<_> = out
.edges
.iter()
.filter(|e| matches!(e.kind, mycel_core::EdgeKind::Implements))
.collect();
assert!(
!impl_edges.is_empty(),
"fixture should produce >=1 IMPLEMENTS edge"
);
// Pinning the exact line locks the `start_position().row + 1` conversion.
// A `+ 0` regression would be caught here, where `> 0` alone would not.
// The only impl in the fixture is `impl Greeter for FormalGreeter` on line 9.
for e in &impl_edges {
assert_eq!(e.from_line, Some(9), "edge {e:?}");
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
---
source: crates/mycel-extract/tests/rust_fixtures.rs
assertion_line: 21
expression: "extract_fixture(\"simple_module.rs\")"
---
symbols:
Expand Down Expand Up @@ -30,4 +29,5 @@ edges:
to: add
kind: calls
source: tree_sitter
from_line: 6
language: rust
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
---
source: crates/mycel-extract/tests/rust_fixtures.rs
assertion_line: 26
expression: "extract_fixture(\"trait_and_impl.rs\")"
---
symbols:
Expand Down Expand Up @@ -37,4 +36,5 @@ edges:
to: Greeter
kind: implements
source: tree_sitter
from_line: 9
language: rust
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
---
source: crates/mycel-extract/tests/typescript_fixtures.rs
assertion_line: 31
expression: "extract_fixture(\"imports_and_exports.ts\")"
---
symbols:
Expand All @@ -16,6 +15,7 @@ edges:
to: add
kind: calls
source: tree_sitter
from_line: 6
- from: tests/fixtures/typescript/imports_and_exports.ts
to: "./simple_function"
kind: imports
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
---
source: crates/mycel-extract/tests/typescript_fixtures.rs
assertion_line: 21
expression: "extract_fixture(\"simple_function.ts\")"
---
symbols:
Expand All @@ -23,4 +22,5 @@ edges:
to: add
kind: calls
source: tree_sitter
from_line: 6
language: typescript
17 changes: 17 additions & 0 deletions crates/mycel-extract/tests/typescript_fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,20 @@ fn class_with_methods_snapshot() {
fn imports_and_exports_snapshot() {
insta::assert_yaml_snapshot!(extract_fixture("imports_and_exports.ts"));
}

#[test]
fn typescript_call_edge_carries_from_line() {
let out = extract_fixture("imports_and_exports.ts");
let call_edges: Vec<_> = out
.edges
.iter()
.filter(|e| matches!(e.kind, mycel_core::EdgeKind::Calls))
.collect();
assert!(!call_edges.is_empty(), "fixture should produce >=1 CALL edge");
// Pinning the exact line locks the `start_position().row + 1` conversion.
// A `+ 0` regression would be caught here, where `> 0` alone would not.
// The only call site in the fixture is `add(1, 2)` on line 6.
for e in &call_edges {
assert_eq!(e.from_line, Some(6), "edge {e:?}");
}
}
34 changes: 34 additions & 0 deletions crates/mycel-graph/src/queries.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::GraphClient;
use crate::cypher::escape;
use crate::symbol::parse_symbol_row;
use falkordb::FalkorValue;
use mycel_core::*;

impl GraphClient {
Expand Down Expand Up @@ -56,4 +57,37 @@ impl GraphClient {
.filter_map(parse_symbol_row)
.collect())
}

/// Returns the qname of the Symbol whose `start_line..=end_line` range
/// contains `line` in `file_path`. Used by Workstream A's pipeline to map
/// LSP-returned definition locations back to Symbol qnames.
///
/// Returns `Ok(None)` when no Symbol spans that location — common case
/// for definitions outside the indexed surface (stdlib, node_modules) or
/// for references into module-decl symbols whose range we don't model.
/// For nested symbols (e.g., a method inside a class — both could match
/// line N), `LIMIT 1` arbitrarily picks one; a future iteration may
/// prefer the smallest enclosing range.
pub async fn symbol_containing(
&self,
file_path: &str,
line: u32,
) -> Result<Option<String>> {
let cypher = format!(
"MATCH (s:Symbol) WHERE s.file_path = '{p}' \
AND s.start_line <= {l} AND s.end_line >= {l} \
RETURN s.qualified_name LIMIT 1",
p = escape(file_path),
l = line,
);
let rows = self.query(&cypher).await?;
Ok(rows
.into_iter()
.next()
.and_then(|row| row.into_iter().next())
.and_then(|v| match v {
FalkorValue::String(s) => Some(s),
_ => None,
}))
}
}
41 changes: 41 additions & 0 deletions crates/mycel-graph/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ async fn edge_upsert_callers_query() {
to: "crate::bar".into(),
kind: EdgeKind::Calls,
source: EdgeSource::Lsp,
from_line: None,
}])
.await
.unwrap();
Expand Down Expand Up @@ -154,6 +155,7 @@ async fn imports_uses_implements_queries() {
to: "ts::IFoo".into(),
kind: EdgeKind::Implements,
source: EdgeSource::Lsp,
from_line: None,
}])
.await
.unwrap();
Expand Down Expand Up @@ -787,6 +789,45 @@ async fn set_description_round_trips_newlines_and_quotes() {
assert_eq!(info.description.as_deref(), Some(payload));
}

#[tokio::test]
async fn symbol_containing_finds_enclosing_symbol() {
let client = fresh_client("mycel:test:symbol_containing").await;
// Upsert a Symbol that spans lines 10..=20.
let sym = Symbol {
qualified_name: QualifiedName::new("src/foo.rs::bar"),
kind: SymbolKind::Function,
file_path: "src/foo.rs".into(),
start_line: 10,
end_line: 20,
signature: Signature::new("fn bar()"),
jsdoc: None,
synthesized_description: None,
exported: true,
embedding: None,
body_hash: None,
description_source_hash: None,
};
client.upsert_symbol(&sym).await.unwrap();

// A line inside the span -> Some(qname)
let hit = client.symbol_containing("src/foo.rs", 15).await.unwrap();
assert_eq!(hit.as_deref(), Some("src/foo.rs::bar"));

// Boundary lines (inclusive both ends)
let lo = client.symbol_containing("src/foo.rs", 10).await.unwrap();
let hi = client.symbol_containing("src/foo.rs", 20).await.unwrap();
assert_eq!(lo.as_deref(), Some("src/foo.rs::bar"));
assert_eq!(hi.as_deref(), Some("src/foo.rs::bar"));

// A line outside the span -> None
let miss = client.symbol_containing("src/foo.rs", 5).await.unwrap();
assert!(miss.is_none(), "line 5 is outside 10..=20");

// A line in a different file -> None
let other = client.symbol_containing("src/other.rs", 15).await.unwrap();
assert!(other.is_none());
}

#[tokio::test]
async fn upsert_symbol_preserves_description_source_hash_on_none() {
// Sibling invariant to upsert_symbol_writes_body_hash_when_set: an
Expand Down
Loading