Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a146687
feat(resolve): name the .tres marker node from script_class
sunerpy Jul 30, 2026
9ab0830
feat(graph): rank an exact whole-name match above every non-exact row
sunerpy Jul 30, 2026
4f32ad7
docs(mcp): recommend streamable-HTTP for Zed remote development
sunerpy Jul 30, 2026
e3787e9
docs(godot): document exact-name search recall
sunerpy Jul 30, 2026
2ab415d
fix(graph): group exact name matches by sort key instead of score
sunerpy Jul 30, 2026
74eb775
fix(watch): ignore .godot and addons without relying on gitignore
sunerpy Jul 30, 2026
c604b67
fix(watch): take ignore_dirs from project config as a structural skip
sunerpy Jul 30, 2026
eb9c2d1
fix(watch): apply exclude unconditionally and prune ignore_dirs struc…
sunerpy Jul 31, 2026
870fcc7
fix(watch): honor the configured ignore_paths pattern set
sunerpy Jul 31, 2026
db421e4
fix(watch): honor pattern negation in every config ignore set
sunerpy Jul 31, 2026
9e3005f
fix(cli): count Godot resource referrers in impact edgeCount
sunerpy Jul 31, 2026
a89364e
feat(daemon): register foreground stdio MCP processes in a PID-keyed …
sunerpy Jul 31, 2026
77d8c02
feat(cli): add codegraph mcp list to surface stdio MCP processes
sunerpy Jul 31, 2026
7a57126
feat(cli): warn before a rebuild when a stdio MCP server may hold the…
sunerpy Jul 31, 2026
bc187b4
docs: document mcp list, impact edge counts, and the MCP registry
sunerpy Jul 31, 2026
674b655
fix(cli): correct five honesty defects in the MCP registry and impact…
sunerpy Jul 31, 2026
37d366a
fix(cli): stop treating a registered project as a holder boundary
sunerpy Jul 31, 2026
33364c3
test(cli): normalize the index duration in either unit
sunerpy Jul 31, 2026
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
33 changes: 33 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,39 @@ conflict (listing the running instance), and notes any other live servers when t
(`stop` uses `process::terminate_pid` — SIGTERM on unix / `TerminateProcess` on Windows). None of this
touches extraction/golden equivalence.

Foreground stdio `serve --mcp` gets a THIRD, PID-keyed registry
(`codegraph-daemon/src/mcp_registry.rs`): one `<pid>.json`
(`McpServerInfo { pid, project: Option<String>, transport: "stdio", started_at, version }`, camelCase on
the wire) under the same GLOBAL state chain as the HTTP one but with an `mcp` leaf
(`$XDG_STATE_HOME/codegraph/mcp`, else `~/.local/state/codegraph/mcp`, `%LOCALAPPDATA%\codegraph\mcp` on
Windows; `CODEGRAPH_MCP_REGISTRY_DIR` overrides). PID keying is forced by the transport: a stdio process
has no addr and no per-project rendezvous, several may serve one project, and one may serve none.
Registration fires from all THREE foreground exits in `cmd_serve` (`Direct`, `SpawnOrProxy`, and the
too-broad-root home guard) and NEVER from `BeDaemon`, which already owns `.codegraph-v2/daemon.pid`.
Reads go through `RegistryRead::{Available, Unavailable}` so a MISSING directory ("nobody registered
yet", normal) is distinguishable from an unreadable one (an outage); `read_dir`'s `NotFound` only means
MISSING when `fs::symlink_metadata` also fails, so a dangling symlink at the registry path reads as an
outage instead of an empty registry. `list_entries` is the RAW on-disk view (stale entries included);
`live_entries` filters its RETURN VALUE by `is_process_alive` rather than trusting `prune_dead`'s
deletions to have landed, so a dead entry on an undeletable (read-only) registry is still never reported
as running — dead-PID and unparseable files are pruned on read as best-effort disk self-heal only.
`project` records the launch `--path` and ONLY that: it is `Some` only when the user actually passed
`--path` (a bare `serve --mcp` stores `None`, not its cwd), and it is purely INFORMATIONAL — never a
capability boundary, because `roots::resolve_project_arg` probes an absolute per-call `projectPath` on its
own merits and consults the launch default only when no path was passed, so any live server can be asked
to open any indexed project's database. `codegraph mcp list [--json]` renders it as `LAUNCH PROJECT`, and
BOTH holder diagnostics — `index`'s pre-warning and the `RemoveDatabase` FAILURE path — therefore report
ALL live entries with no narrowing: filtering by `project` would drop the holder in the very case they
exist for (a server launched elsewhere that a client has since pointed at this project), and the failure
path additionally has only a DB path, which with `CODEGRAPH_DIR` set is a `<name>-v2-<projectIdentity>`
sibling of the legacy root that can sit outside the project tree entirely. This
registry is PURE OBSERVABILITY — there is no `mcp stop` and no `terminate_pid` import, because a stale
entry's PID may have been reused and this workspace has no portable instance-identity primitive
(`try_acquire_daemon_lock` is a `create_new` placeholder plus a recorded PID, not an OS advisory lock);
`list` asks a human to confirm the PID with `ps -p <pid> -o command=` / `tasklist /FI "PID eq <pid>"`
before offering `kill <pid>` / `taskkill /PID <pid> /F`. CLI/daemon only; extraction and golden
equivalence untouched.

## Agent installer (`codegraph install` / `uninstall`)

`codegraph install` writes the codegraph MCP-server entry into each supported agent's config
Expand Down
572 changes: 570 additions & 2 deletions crates/codegraph-cli/src/main.rs

Large diffs are not rendered by default.

188 changes: 188 additions & 0 deletions crates/codegraph-cli/tests/impact_edge_count.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

struct TestDir {
path: PathBuf,
}

impl TestDir {
fn new(label: &str) -> Self {
let path = std::env::temp_dir().join(format!(
"codegraph-cli-impact-edge-count-{label}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&path).unwrap();
Self { path }
}

fn path(&self) -> &Path {
&self.path
}
}

impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}

fn cli(args: &[&str]) -> (String, String, bool) {
let output = Command::new(env!("CARGO_BIN_EXE_codegraph"))
.args(args)
.env("CODEGRAPH_NO_DAEMON", "1")
.env("CODEGRAPH_NO_WATCH", "1")
.output()
.expect("run codegraph binary");
(
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
output.status.success(),
)
}

fn index_project(project: &Path) {
let p = project.to_str().unwrap();
let (out, err, ok) = cli(&["init", p]);
assert!(ok, "init failed: stdout={out} stderr={err}");
let (out, err, ok) = cli(&["index", "--force", p]);
assert!(ok, "index --force failed: stdout={out} stderr={err}");
}

fn impact_json(project: &Path, symbol: &str) -> serde_json::Value {
let p = project.to_str().unwrap();
let (stdout, err, ok) = cli(&["impact", symbol, "-p", p, "--depth", "4", "--json"]);
assert!(ok, "impact failed: stdout={stdout} stderr={err}");
serde_json::from_str(&stdout).expect("impact emits valid JSON on stdout")
}

#[test]
fn godot_resource_referrers_count_as_impact_edges() {
let dir = TestDir::new("godot");
let project = dir.path().join("godot");
fs::create_dir_all(&project).unwrap();
fs::write(
project.join("project.godot"),
"[application]\nconfig/name=\"Impact edge count\"\n",
)
.unwrap();
fs::write(
project.join("weapon_data.gd"),
"class_name WeaponData\nextends Resource\n",
)
.unwrap();
for index in 0..12 {
fs::write(
project.join(format!("weapon_{index}.tres")),
"[gd_resource type=\"Resource\" load_steps=2 format=3]\n\
\n\
[ext_resource type=\"Script\" path=\"res://weapon_data.gd\" id=\"1_script\"]\n\
\n\
[resource]\n\
script = ExtResource(\"1_script\")\n",
)
.unwrap();
}
index_project(&project);

let value = impact_json(&project, "weapon_data.gd");
let resource_referrers = value["affected"]
.as_array()
.expect("affected is an array")
.iter()
.filter(|node| {
node["filePath"]
.as_str()
.is_some_and(|path| path.ends_with(".tres"))
})
.count() as u64;
let edge_count = value["edgeCount"].as_u64().expect("edgeCount is a number");

assert_eq!(edge_count, resource_referrers);
assert!(edge_count > 0, "resource referrers must contribute edges");
assert_eq!(
value["resourceEdgeCount"]
.as_u64()
.expect("resourceEdgeCount is a number"),
resource_referrers
);
}

#[test]
fn godot_referrer_already_reached_by_graph_is_not_counted_twice() {
let dir = TestDir::new("godot-overlap");
let project = dir.path().join("godot");
fs::create_dir_all(project.join("scripts")).unwrap();
fs::create_dir_all(project.join("data")).unwrap();
fs::write(
project.join("project.godot"),
"[application]\nconfig/name=\"Impact edge overlap\"\n",
)
.unwrap();
fs::write(
project.join("scripts/target.gd"),
"class_name Target\nextends Resource\n",
)
.unwrap();
fs::write(
project.join("scripts/user.gd"),
"const TargetResource = preload(\"res://scripts/target.gd\")\n",
)
.unwrap();
fs::write(
project.join("data/one.tres"),
"[gd_resource type=\"Resource\" load_steps=2 format=3]\n\
\n\
[ext_resource type=\"Script\" path=\"res://scripts/target.gd\" id=\"1_script\"]\n\
\n\
[resource]\n\
script = ExtResource(\"1_script\")\n",
)
.unwrap();
index_project(&project);

let value = impact_json(&project, "target.gd");
let affected = value["affected"].as_array().expect("affected is an array");
let file_paths = affected
.iter()
.map(|node| {
node["filePath"]
.as_str()
.expect("every affected row has a filePath")
})
.collect::<Vec<_>>();
let distinct_file_paths = file_paths.iter().copied().collect::<HashSet<_>>();

assert_eq!(
distinct_file_paths.len(),
file_paths.len(),
"a graph-reached preload referrer must not be appended again: {value}"
);
assert_eq!(value["edgeCount"].as_u64(), Some(2), "{value}");
assert_eq!(value["resourceEdgeCount"].as_u64(), Some(1), "{value}");
}

#[test]
fn pure_code_edge_count_is_unchanged() {
let dir = TestDir::new("pure-code");
let project = dir.path().join("typescript");
fs::create_dir_all(&project).unwrap();
fs::write(
project.join("chain.ts"),
"export function helper(): number { return 1; }\n\
export function caller(): number { return helper(); }\n\
export function outer(): number { return caller(); }\n",
)
.unwrap();
index_project(&project);

let value = impact_json(&project, "helper");

assert_eq!(value["edgeCount"].as_u64(), Some(2));
assert_eq!(value["resourceEdgeCount"].as_u64(), Some(0));
}
Loading
Loading