From b68ddd25b1ea7d2e450d7f9b02230586e4df6f1d Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 11:42:05 +0200 Subject: [PATCH 01/34] Update graphify-py submodule to 4b17f19 --- graphify-py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphify-py b/graphify-py index a7a7322..4b17f19 160000 --- a/graphify-py +++ b/graphify-py @@ -1 +1 @@ -Subproject commit a7a732290ce58652263da7980a5b1a435ea553aa +Subproject commit 4b17f199afd39c24231e06a448caad45317b29e3 From 02e010aa86ea32bdd4096347829b5625b44a45fe Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 13:01:26 +0200 Subject: [PATCH 02/34] Resync with graphify-py @ 4b17f19 Port the v0.8.25 -> v0.8.27 changes from `graphify-py` and bump the workspace version to 0.8.27. Ports: - detect: anchored `.graphifyignore`/`.gitignore` patterns now match the anchor-relative path directly (no subtree/basename fallback), so `/inbox/` no longer leaks into `src/inbox/` (#1087). Sort the walked file list and each file-type bucket for deterministic output. - export: cap obsidian/canvas note filenames to 200 UTF-8 bytes with an 8-char SHA-1 suffix on truncation, preventing ENAMETOOLONG on long or multibyte labels (#1094). - analyze: add `find_import_cycles`, collapsing the graph to file-level `imports_from`/`re_exports` edges and reporting simple cycles (#961). - report: render an `## Import Cycles` section from `find_import_cycles`. - extract: file-level node IDs follow the `{parent_dir}_{stem}` spec so AST and semantic nodes coincide (#1033); root-level symbol prefixes are relativised the same way (#1096); `pnpm-workspace.yaml` `'.'` resolves to the root without crashing (#1083); TypeScript `interface A extends B` now emits an `inherits` edge via the `extends_type_clause` branch (#1095). `extract()` honours `cache_root` as the id-relativisation root. - llm: load custom OpenAI-compatible providers from `providers.json`, route `extract`/`call_llm` through them, and consult them in `detect_backend` after the built-ins (#1084). Add batched community labelling (`label_communities` / `generate_community_labels`, #1097). - cli: `provider add|list|show|remove`, a `label` command, and `--no-label`/`--backend` on `cluster-only`; auto-name communities when no labels file exists. Replace console em-dash/arrow glyphs with ASCII for non-UTF-8 terminals (#992). Divergences from graphify-py: - The Rust `extract` is a one-shot pipeline that already writes `GRAPH_REPORT.md` and a placeholder labels file, so the Python "next: run cluster-only" hint is omitted; run `graphify label` to LLM-name communities. - The `GRAPHIFY_DEBUG` traceback hook has no Rust equivalent (extractors return `FileResult.error` strings, not a stack trace). Glory to the Omnissiah --- Cargo.lock | 95 +++---- Cargo.toml | 2 +- README.md | 5 +- USAGE.md | 40 +++ crates/graphify-analyze/src/cycles.rs | 209 ++++++++++++++ crates/graphify-analyze/src/lib.rs | 4 + crates/graphify-analyze/tests/parity.rs | 155 ++++++++++- crates/graphify-detect/src/ignore.rs | 10 +- crates/graphify-detect/src/walk.rs | 11 + crates/graphify-detect/tests/parity_ignore.rs | 104 +++++++ crates/graphify-export/Cargo.toml | 1 + crates/graphify-export/src/canvas.rs | 2 +- crates/graphify-export/src/json.rs | 4 +- crates/graphify-export/src/obsidian.rs | 2 +- crates/graphify-export/src/util.rs | 37 +++ crates/graphify-export/tests/filename_cap.rs | 143 ++++++++++ .../graphify-extract/src/extractors/multi.rs | 112 +++++++- .../graphify-extract/src/generic/inherit.rs | 12 + crates/graphify-extract/src/ids.rs | 13 + crates/graphify-extract/src/lib.rs | 2 +- crates/graphify-extract/src/postprocess.rs | 54 ++-- crates/graphify-extract/src/workspace.rs | 6 + .../tests/file_node_id_spec.rs | 201 ++++++++++++++ crates/graphify-extract/tests/parity.rs | 23 ++ .../graphify-extract/tests/ts_inheritance.rs | 206 ++++++++++++++ crates/graphify-llm/Cargo.toml | 1 + crates/graphify-llm/src/backends.rs | 21 +- crates/graphify-llm/src/call.rs | 33 +++ crates/graphify-llm/src/extract.rs | 48 ++++ crates/graphify-llm/src/labeling.rs | 258 ++++++++++++++++++ crates/graphify-llm/src/lib.rs | 14 +- crates/graphify-llm/src/providers.rs | 127 +++++++++ crates/graphify-llm/tests/labeling.rs | 216 +++++++++++++++ .../graphify-llm/tests/provider_registry.rs | 188 +++++++++++++ crates/graphify-prs/src/dashboard.rs | 13 +- crates/graphify-report/Cargo.toml | 1 + crates/graphify-report/src/render.rs | 3 + crates/graphify-report/src/sections/cycles.rs | 32 +++ crates/graphify-report/src/sections/mod.rs | 1 + crates/graphify-report/tests/parity.rs | 58 ++++ src/cli/args.rs | 59 ++++ src/cli/clone.rs | 4 +- src/cli/cluster_only.rs | 88 +++++- src/cli/dispatch.rs | 58 +++- src/cli/extract.rs | 4 +- src/cli/global.rs | 2 +- src/cli/mod.rs | 1 + src/cli/provider.rs | 133 +++++++++ tests/cli_commands.rs | 139 ++++++++++ 49 files changed, 2824 insertions(+), 131 deletions(-) create mode 100644 crates/graphify-analyze/src/cycles.rs create mode 100644 crates/graphify-export/tests/filename_cap.rs create mode 100644 crates/graphify-extract/tests/file_node_id_spec.rs create mode 100644 crates/graphify-extract/tests/ts_inheritance.rs create mode 100644 crates/graphify-llm/src/labeling.rs create mode 100644 crates/graphify-llm/src/providers.rs create mode 100644 crates/graphify-llm/tests/labeling.rs create mode 100644 crates/graphify-llm/tests/provider_registry.rs create mode 100644 crates/graphify-report/src/sections/cycles.rs create mode 100644 src/cli/provider.rs diff --git a/Cargo.lock b/Cargo.lock index ecaea47..40c57b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1473,7 +1473,7 @@ dependencies = [ [[package]] name = "graphify" -version = "0.8.25" +version = "0.8.27" dependencies = [ "anyhow", "assert_cmd", @@ -1511,7 +1511,7 @@ dependencies = [ [[package]] name = "graphify-affected" -version = "0.8.25" +version = "0.8.27" dependencies = [ "graphify-build", "graphify-security", @@ -1523,7 +1523,7 @@ dependencies = [ [[package]] name = "graphify-analyze" -version = "0.8.25" +version = "0.8.27" dependencies = [ "graphify-build", "graphify-cluster", @@ -1535,7 +1535,7 @@ dependencies = [ [[package]] name = "graphify-benchmark" -version = "0.8.25" +version = "0.8.27" dependencies = [ "graphify-build", "graphify-security", @@ -1548,7 +1548,7 @@ dependencies = [ [[package]] name = "graphify-build" -version = "0.8.25" +version = "0.8.27" dependencies = [ "caseless", "graphify-security", @@ -1565,7 +1565,7 @@ dependencies = [ [[package]] name = "graphify-cache" -version = "0.8.25" +version = "0.8.27" dependencies = [ "hex", "indexmap", @@ -1580,7 +1580,7 @@ dependencies = [ [[package]] name = "graphify-cluster" -version = "0.8.25" +version = "0.8.27" dependencies = [ "graphify-build", "indexmap", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "graphify-dedup" -version = "0.8.25" +version = "0.8.27" dependencies = [ "caseless", "indexmap", @@ -1608,7 +1608,7 @@ dependencies = [ [[package]] name = "graphify-detect" -version = "0.8.25" +version = "0.8.27" dependencies = [ "calamine", "graphify-google", @@ -1631,7 +1631,7 @@ dependencies = [ [[package]] name = "graphify-diagnostics" -version = "0.8.25" +version = "0.8.27" dependencies = [ "graphify-build", "graphify-security", @@ -1645,7 +1645,7 @@ dependencies = [ [[package]] name = "graphify-export" -version = "0.8.25" +version = "0.8.27" dependencies = [ "chrono", "graphify-build", @@ -1658,6 +1658,7 @@ dependencies = [ "regex", "serde_json", "serial_test", + "sha1 0.11.0", "sha2 0.11.0", "tempfile", "thiserror 2.0.18", @@ -1666,7 +1667,7 @@ dependencies = [ [[package]] name = "graphify-extract" -version = "0.8.25" +version = "0.8.27" dependencies = [ "flate2", "graphify-build", @@ -1714,7 +1715,7 @@ dependencies = [ [[package]] name = "graphify-global" -version = "0.8.25" +version = "0.8.27" dependencies = [ "chrono", "graphify-build", @@ -1730,7 +1731,7 @@ dependencies = [ [[package]] name = "graphify-google" -version = "0.8.25" +version = "0.8.27" dependencies = [ "hex", "regex", @@ -1743,7 +1744,7 @@ dependencies = [ [[package]] name = "graphify-hooks" -version = "0.8.25" +version = "0.8.27" dependencies = [ "regex", "serde_json", @@ -1755,7 +1756,7 @@ dependencies = [ [[package]] name = "graphify-html" -version = "0.8.25" +version = "0.8.27" dependencies = [ "chrono", "graphify-build", @@ -1772,7 +1773,7 @@ dependencies = [ [[package]] name = "graphify-ingest" -version = "0.8.25" +version = "0.8.27" dependencies = [ "chrono", "graphify-security", @@ -1789,7 +1790,7 @@ dependencies = [ [[package]] name = "graphify-llm" -version = "0.8.25" +version = "0.8.27" dependencies = [ "aws-config", "aws-sdk-bedrockruntime", @@ -1797,6 +1798,7 @@ dependencies = [ "indexmap", "mockito", "rayon", + "regex", "serde", "serde_json", "serial_test", @@ -1810,14 +1812,14 @@ dependencies = [ [[package]] name = "graphify-manifest" -version = "0.8.25" +version = "0.8.27" dependencies = [ "graphify-detect", ] [[package]] name = "graphify-multigraph-compat" -version = "0.8.25" +version = "0.8.27" dependencies = [ "graphify-build", "indexmap", @@ -1828,7 +1830,7 @@ dependencies = [ [[package]] name = "graphify-prs" -version = "0.8.25" +version = "0.8.27" dependencies = [ "chrono", "graphify-security", @@ -1841,9 +1843,10 @@ dependencies = [ [[package]] name = "graphify-report" -version = "0.8.25" +version = "0.8.27" dependencies = [ "chrono", + "graphify-analyze", "graphify-build", "indexmap", "regex", @@ -1854,7 +1857,7 @@ dependencies = [ [[package]] name = "graphify-scip" -version = "0.8.25" +version = "0.8.27" dependencies = [ "graphify-security", "hex", @@ -1869,7 +1872,7 @@ dependencies = [ [[package]] name = "graphify-security" -version = "0.8.25" +version = "0.8.27" dependencies = [ "ipnet", "mockito", @@ -1883,7 +1886,7 @@ dependencies = [ [[package]] name = "graphify-semantic" -version = "0.8.25" +version = "0.8.27" dependencies = [ "indexmap", "regex", @@ -1895,7 +1898,7 @@ dependencies = [ [[package]] name = "graphify-serve" -version = "0.8.25" +version = "0.8.27" dependencies = [ "graphify-analyze", "graphify-build", @@ -1911,7 +1914,7 @@ dependencies = [ [[package]] name = "graphify-transcribe" -version = "0.8.25" +version = "0.8.27" dependencies = [ "graphify-security", "hex", @@ -1924,7 +1927,7 @@ dependencies = [ [[package]] name = "graphify-validate" -version = "0.8.25" +version = "0.8.27" dependencies = [ "serde_json", "thiserror 2.0.18", @@ -1932,7 +1935,7 @@ dependencies = [ [[package]] name = "graphify-watch" -version = "0.8.25" +version = "0.8.27" dependencies = [ "graphify-analyze", "graphify-build", @@ -1954,7 +1957,7 @@ dependencies = [ [[package]] name = "graphify-wiki" -version = "0.8.25" +version = "0.8.27" dependencies = [ "graphify-build", "indexmap", @@ -2520,9 +2523,9 @@ dependencies = [ [[package]] name = "kqueue" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" dependencies = [ "kqueue-sys", "libc", @@ -3472,15 +3475,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "scc" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" -dependencies = [ - "sdd", -] - [[package]] name = "schannel" version = "0.1.29" @@ -3496,12 +3490,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sdd" -version = "3.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" - [[package]] name = "security-framework" version = "2.11.1" @@ -3602,24 +3590,23 @@ dependencies = [ [[package]] name = "serial_test" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" dependencies = [ "futures-executor", "futures-util", "log", "once_cell", "parking_lot", - "scc", "serial_test_derive", ] [[package]] name = "serial_test_derive" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", @@ -4373,9 +4360,9 @@ dependencies = [ [[package]] name = "tree-sitter-swift" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3b98fb6bc8e6a6a10023f401aa6a1858115e849dfaf7de57dd8b8ea0f257bd9" +checksum = "fe36052155b9dd69ca82b3b8f1b4ccfb2d867125ac1a4db1dd7331829242668c" dependencies = [ "cc", "tree-sitter-language", diff --git a/Cargo.toml b/Cargo.toml index 6c8ba22..1845390 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,7 +43,7 @@ license = "Apache-2.0" publish = false repository = "https://github.com/bunkerlab-net/graphify" rust-version = "1.95" -version = "0.8.25" +version = "0.8.27" [workspace.dependencies] anyhow = "1" diff --git a/README.md b/README.md index 664470c..f2713bd 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,10 @@ a Rust equivalent, and outputs are byte-identical where the test suite asserts i and env-var _names_ (values are never read). - **Documents, papers, images, video** — PDF, DOCX, audio transcription, OCR, Google Workspace exports. - **Local-first** — `graph.json` lives next to your code; no daemon, no cloud, no account. -- **Optional LLM-driven semantic extraction** through OpenAI, Anthropic, Gemini, DeepSeek, Moonshot, Ollama, or Bedrock. +- **Optional LLM-driven semantic extraction** through OpenAI, Anthropic, Gemini, DeepSeek, Moonshot, Ollama, Bedrock, + or any OpenAI-compatible **custom provider** registered with `graphify provider add`. +- **LLM community naming** — `graphify label` (or `cluster-only`) auto-names graph communities with the configured + backend; degrades to `Community N` placeholders when no backend is available. - **AI-assistant integration** — drop-in installers for Claude Code, Codex, Amp, Cursor, Gemini CLI, GitHub Copilot, VS Code, OpenCode, Aider, Factory Droid, Trae, Hermes, Kiro, Pi, Devin CLI, Google Antigravity, and more. - **MCP server** for any MCP-capable assistant (`graphify serve`). diff --git a/USAGE.md b/USAGE.md index 08b9b5d..ee69ec3 100644 --- a/USAGE.md +++ b/USAGE.md @@ -141,12 +141,31 @@ commit's changes are lost under contention. Rerun clustering on an existing `graph.json` and regenerate the report and HTML viz. Useful after tweaking cluster parameters or when you only want to refresh `GRAPH_REPORT.md`. +When no `.graphify_labels.json` exists yet, `cluster-only` auto-names communities with the configured LLM backend +in a single batched call, falling back to `Community N` placeholders if no backend is configured or the call fails. +An existing labels file is preserved (re-run `graphify label` to force a refresh). + ```bash graphify cluster-only . graphify cluster-only . --no-viz # skip graph.html (saves time on >5k-node graphs) graphify cluster-only . --graph other/graph.json # use a non-default graph location +graphify cluster-only . --no-label # keep "Community N" placeholders (skip LLM naming) +graphify cluster-only . --backend openai # backend to use for naming (default: auto-detect) ``` +### `label ` + +`label` is `cluster-only` that **always** (re)names communities with the configured LLM backend, even when a +`.graphify_labels.json` already exists. Use it to refresh names after the graph changed, or to switch backends. + +```bash +graphify label . # re-name with the auto-detected backend +graphify label . --backend gemini # force a specific backend +graphify label . --no-viz # skip graph.html regeneration +``` + +If no backend is configured (no API key), `label` degrades to `Community N` placeholders and prints a hint. + --- ## Querying the graph @@ -557,6 +576,27 @@ present, otherwise auto-detection falls through to the next backend. Force a backend with `--backend`; override its default model with `--model`. +#### Custom providers + +Any OpenAI-compatible endpoint can be registered as a custom backend and used like a built-in one (e.g. +`graphify extract . --backend nvidia`, `graphify label . --backend nvidia`). Custom providers are stored in +`~/.graphify/providers.json` and managed with the `provider` command: + +```bash +graphify provider add nvidia \ + --base-url https://integrate.api.nvidia.com/v1 \ + --default-model minimaxai/minimax-m2.7 \ + --env-key NVIDIA_API_KEY \ + [--pricing-input 0.0 --pricing-output 0.0] +graphify provider list # name + base URL of each registered provider +graphify provider show nvidia # full JSON config for one provider +graphify provider remove nvidia +``` + +Built-in backend names cannot be shadowed. Auto-detection consults custom providers **after** all built-ins, in +registry order, selecting the first whose `--env-key` variable is set. Missing `pricing` defaults to zero so cost +estimation never fails. + ### Determinism note `graph.json` is written with `serde_json` configured for `preserve_order`, so node and edge insertion order is preserved diff --git a/crates/graphify-analyze/src/cycles.rs b/crates/graphify-analyze/src/cycles.rs new file mode 100644 index 0000000..adb7b64 --- /dev/null +++ b/crates/graphify-analyze/src/cycles.rs @@ -0,0 +1,209 @@ +//! Circular import detection at the file level. +//! +//! Ports `find_import_cycles` from `graphify-py/graphify/analyze.py` (#961). +//! Symbol-level nodes are collapsed to their parent file via the `source_file` +//! attribute, a directed file-level graph is built from `imports_from` / +//! `re_exports` edges, and simple cycles bounded by length are reported. + +use graphify_build::Graph; +use indexmap::{IndexMap, IndexSet}; +use serde_json::Value; + +/// Default cap on the number of files a reported cycle may span. +const DEFAULT_MAX_CYCLE_LENGTH: usize = 5; +/// Default cap on the number of cycles returned (shortest first). +const DEFAULT_TOP_N: usize = 20; + +/// A circular import dependency at the file level. +/// +/// Mirrors the Python record shape `{"cycle": [...], "length": n, "why": ...}`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportCycle { + /// Files forming the cycle, in dependency order and starting from the + /// lexicographically smallest file so rotations dedupe to one record. + pub cycle: Vec, + /// Number of files in the cycle (`cycle.len()`). + pub length: usize, + /// Constant rationale string, kept for parity with the Python record. + pub why: &'static str, +} + +/// Detect circular import dependencies at the file level using the default +/// bounds (`max_cycle_length = 5`, `top_n = 20`). +/// +/// Mirrors Python `find_import_cycles(G)`. +#[must_use] +pub fn find_import_cycles(graph: &Graph) -> Vec { + find_import_cycles_bounded(graph, DEFAULT_MAX_CYCLE_LENGTH, DEFAULT_TOP_N) +} + +/// Detect circular import dependencies, reporting at most `top_n` cycles each +/// spanning at most `max_cycle_length` files (shortest first). +/// +/// Collapses symbol-level nodes to their parent file via `source_file`, orients +/// each `imports_from` / `re_exports` edge using the edge's own `source_file`, +/// finds simple cycles, and deduplicates rotations by normalising every cycle to +/// start from its lexicographically smallest file. +#[must_use] +pub fn find_import_cycles_bounded( + graph: &Graph, + max_cycle_length: usize, + top_n: usize, +) -> Vec { + let adj = build_file_graph(graph); + if adj.values().all(IndexSet::is_empty) { + return Vec::new(); + } + + // Enumerate elementary cycles directly in normalised (smallest-file-first) + // form: a cycle is emitted only from its lexicographically smallest file, + // visiting strictly larger files in between, which yields each cycle exactly + // once. The `top_n * 10` cap guards against combinatorial explosion. + let cap = top_n.saturating_mul(10); + let mut cycles = enumerate_cycles(&adj, max_cycle_length, cap); + + // Shortest first (tightest coupling); lexicographic tie-break keeps the + // ordering deterministic regardless of graph iteration order. + cycles.sort_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b))); + cycles.dedup(); + cycles.truncate(top_n); + + cycles + .into_iter() + .map(|cycle| ImportCycle { + length: cycle.len(), + cycle, + why: "circular dependency", + }) + .collect() +} + +/// Resolve a node's owning file via its `source_file` attribute, or `""`. +fn endpoint_source_file<'a>(graph: &'a Graph, node_id: &str) -> &'a str { + graph + .node_data(node_id) + .and_then(|attrs| attrs.get("source_file")) + .and_then(Value::as_str) + .unwrap_or("") +} + +/// Build the directed file-level adjacency from import/re-export edges. +/// +/// Endpoints are resolved using `source_file` only — never inferred from a +/// label or id — so external nodes (no `source_file`) drop out cleanly. +fn build_file_graph(graph: &Graph) -> IndexMap> { + let mut adj: IndexMap> = IndexMap::new(); + + for edge in graph.edges() { + let relation = edge + .attrs + .get("relation") + .and_then(Value::as_str) + .unwrap_or(""); + if relation != "imports_from" && relation != "re_exports" { + continue; + } + let src_file = edge + .attrs + .get("source_file") + .and_then(Value::as_str) + .unwrap_or(""); + if src_file.is_empty() { + continue; + } + + let u_file = endpoint_source_file(graph, &edge.source); + let v_file = endpoint_source_file(graph, &edge.target); + + // Orient the edge from its `source_file` endpoint to the opposite one. + // Works for both directed and undirected inputs. + let tgt_file = if u_file == src_file { + v_file + } else if v_file == src_file { + u_file + } else if !v_file.is_empty() && v_file != src_file { + v_file + } else { + u_file + }; + if tgt_file.is_empty() { + continue; + } + + adj.entry(src_file.to_string()) + .or_default() + .insert(tgt_file.to_string()); + // Ensure the target file is a known node even with no out-edges. + adj.entry(tgt_file.to_string()).or_default(); + } + + adj +} + +/// Enumerate elementary cycles, each already normalised to start from its +/// lexicographically smallest file. Stops once `cap` cycles are collected. +fn enumerate_cycles( + adj: &IndexMap>, + max_len: usize, + cap: usize, +) -> Vec> { + let mut out: Vec> = Vec::new(); + for start in adj.keys() { + if out.len() >= cap { + break; + } + let mut path: Vec<&str> = vec![start.as_str()]; + let mut on_path: IndexSet<&str> = IndexSet::new(); + on_path.insert(start.as_str()); + dfs_cycles( + adj, + start, + start, + max_len, + &mut path, + &mut on_path, + &mut out, + cap, + ); + } + out +} + +/// Depth-first walk emitting every simple cycle that closes back at `start` +/// while visiting only files strictly greater than `start` (so `start` is the +/// unique minimum and the emitted path is the normalised cycle). +#[allow(clippy::too_many_arguments)] // a focused recursive walker; an options +// struct would just scatter the loop state across allocations. +fn dfs_cycles<'a>( + adj: &'a IndexMap>, + start: &'a str, + current: &'a str, + max_len: usize, + path: &mut Vec<&'a str>, + on_path: &mut IndexSet<&'a str>, + out: &mut Vec>, + cap: usize, +) { + if out.len() >= cap { + return; + } + let Some(neighbors) = adj.get(current) else { + return; + }; + for next in neighbors { + let next = next.as_str(); + if next == start { + // Closing edge → `path` is a complete normalised cycle. + out.push(path.iter().map(|s| (*s).to_string()).collect()); + if out.len() >= cap { + return; + } + } else if next > start && path.len() < max_len && !on_path.contains(next) { + path.push(next); + on_path.insert(next); + dfs_cycles(adj, start, next, max_len, path, on_path, out, cap); + on_path.shift_remove(next); + path.pop(); + } + } +} diff --git a/crates/graphify-analyze/src/lib.rs b/crates/graphify-analyze/src/lib.rs index 8ddc983..5b96bc3 100644 --- a/crates/graphify-analyze/src/lib.rs +++ b/crates/graphify-analyze/src/lib.rs @@ -12,6 +12,8 @@ //! `is_json_key_node`) and the `file_category` helper, shared across modules. //! - **Cross-language detection** (`cross_lang`): identifies edges that span //! different programming-language families or community boundaries. +//! - **Import cycles** (`cycles`): collapses the graph to file granularity and +//! reports circular import dependencies via [`find_import_cycles`]. //! - **Diffing** (`diff`): compares two graph snapshots and surfaces added / //! removed nodes and edges via [`graph_diff`]. //! - **God-node detection** (`god_nodes`): returns the top-N highest-degree @@ -25,12 +27,14 @@ pub(crate) mod centrality; pub(crate) mod classify; pub(crate) mod cross_lang; +pub(crate) mod cycles; pub(crate) mod diff; pub(crate) mod god_nodes; pub(crate) mod suggest; pub(crate) mod surprises; pub use classify::{file_category, is_concept_node, is_json_key_node}; +pub use cycles::{ImportCycle, find_import_cycles, find_import_cycles_bounded}; pub use diff::graph_diff; pub use god_nodes::god_nodes; pub use suggest::suggest_questions; diff --git a/crates/graphify-analyze/tests/parity.rs b/crates/graphify-analyze/tests/parity.rs index 5a97be4..8a37b10 100644 --- a/crates/graphify-analyze/tests/parity.rs +++ b/crates/graphify-analyze/tests/parity.rs @@ -17,8 +17,8 @@ #![allow(clippy::expect_used)] use graphify_analyze::{ - SurpriseScoreInput, file_category, god_nodes, graph_diff, is_concept_node, is_json_key_node, - surprise_score, surprising_connections, + SurpriseScoreInput, file_category, find_import_cycles, find_import_cycles_bounded, god_nodes, + graph_diff, is_concept_node, is_json_key_node, surprise_score, surprising_connections, }; use graphify_build::{Graph, GraphKind, build_from_json}; use graphify_cluster::cluster; @@ -1740,3 +1740,154 @@ fn ambiguous_edges_score_at_most_04() { } assert!(found, "no AMBIGUOUS edges found in test fixture"); } + +// ── test_analyze.py: find_import_cycles ────────────────────────────────────── + +/// Add a file (or external) node carrying a `source_file` attribute (or none). +fn cycle_node(g: &mut Graph, id: &str, label: &str, source_file: Option<&str>) { + let mut m = IndexMap::new(); + m.insert("label".to_string(), json!(label)); + m.insert("file_type".to_string(), json!("code")); + if let Some(sf) = source_file { + m.insert("source_file".to_string(), json!(sf)); + } + g.add_node(id, m); +} + +/// Add an import-style edge carrying `relation` + `source_file` + `confidence`. +fn cycle_edge(g: &mut Graph, src: &str, tgt: &str, relation: &str, source_file: &str, conf: &str) { + let mut m = IndexMap::new(); + m.insert("relation".to_string(), json!(relation)); + m.insert("source_file".to_string(), json!(source_file)); + m.insert("confidence".to_string(), json!(conf)); + g.add_edge(src, tgt, m); +} + +/// Mirrors `_make_cycle_graph_directed`. Node ids are arbitrary — cycles are +/// resolved purely from the `source_file` attribute, never the id/label. +fn make_cycle_graph(kind: GraphKind) -> Graph { + let mut g = Graph::new(kind); + cycle_node(&mut g, "a", "a.ts", Some("src/a.ts")); + cycle_node(&mut g, "b", "b.ts", Some("src/b.ts")); + cycle_node(&mut g, "c", "c.ts", Some("src/c.ts")); + cycle_node(&mut g, "d", "d.ts", Some("src/d.ts")); + // External-like node (no source_file): must be skipped safely. + cycle_node(&mut g, "ext", "react", None); + + // 2-cycle: a <-> b + cycle_edge(&mut g, "a", "b", "imports_from", "src/a.ts", "EXTRACTED"); + cycle_edge(&mut g, "b", "a", "imports_from", "src/b.ts", "EXTRACTED"); + // 3-cycle: b -> c -> d -> b + cycle_edge(&mut g, "b", "c", "imports_from", "src/b.ts", "EXTRACTED"); + cycle_edge(&mut g, "c", "d", "imports_from", "src/c.ts", "EXTRACTED"); + cycle_edge(&mut g, "d", "b", "imports_from", "src/d.ts", "EXTRACTED"); + // Self-loop: c imports itself. + cycle_edge(&mut g, "c", "c", "imports_from", "src/c.ts", "EXTRACTED"); + // Mixed edge types + an import edge whose target has no source_file: skipped. + cycle_edge(&mut g, "a", "ext", "calls", "src/a.ts", "INFERRED"); + cycle_edge(&mut g, "a", "ext", "contains", "src/a.ts", "EXTRACTED"); + cycle_edge(&mut g, "a", "ext", "imports_from", "src/a.ts", "EXTRACTED"); + g +} + +/// `test_find_import_cycles_returns_structured_records` +#[test] +fn find_import_cycles_returns_structured_records() { + let g = make_cycle_graph(GraphKind::DiGraph); + let cycles = find_import_cycles(&g); + assert!(!cycles.is_empty()); + let first = &cycles[0]; + assert!(!first.cycle.is_empty()); + assert_eq!(first.length, first.cycle.len()); + assert_eq!(first.why, "circular dependency"); +} + +/// `test_find_import_cycles_detects_2_and_3_cycles` +#[test] +fn find_import_cycles_detects_2_and_3_cycles() { + let g = make_cycle_graph(GraphKind::DiGraph); + let cycles = find_import_cycles(&g); + let sets: Vec> = cycles + .iter() + .map(|c| c.cycle.iter().map(String::as_str).collect()) + .collect(); + assert!( + sets.iter() + .any(|s| s.contains("src/a.ts") && s.contains("src/b.ts")) + ); + assert!( + sets.iter() + .any(|s| s.contains("src/b.ts") && s.contains("src/c.ts") && s.contains("src/d.ts")) + ); +} + +/// `test_find_import_cycles_includes_self_loop_cycle` +#[test] +fn find_import_cycles_includes_self_loop_cycle() { + let g = make_cycle_graph(GraphKind::DiGraph); + let cycles = find_import_cycles(&g); + assert!( + cycles + .iter() + .any(|c| c.cycle == vec!["src/c.ts".to_string()] && c.length == 1) + ); +} + +/// `test_find_import_cycles_respects_max_cycle_length` +#[test] +fn find_import_cycles_respects_max_cycle_length() { + let g = make_cycle_graph(GraphKind::DiGraph); + let cycles = find_import_cycles_bounded(&g, 2, 20); + assert!(cycles.iter().all(|c| c.length <= 2)); +} + +/// `test_find_import_cycles_skips_nodes_without_source_file` +#[test] +fn find_import_cycles_skips_nodes_without_source_file() { + let g = make_cycle_graph(GraphKind::DiGraph); + let cycles = find_import_cycles(&g); + let flat = cycles + .iter() + .flat_map(|c| c.cycle.iter()) + .cloned() + .collect::>() + .join(" "); + assert!(!flat.contains("react")); +} + +/// `test_find_import_cycles_handles_undirected_graph_input` +#[test] +fn find_import_cycles_handles_undirected_graph_input() { + let g = make_cycle_graph(GraphKind::Graph); + let cycles = find_import_cycles(&g); + // Orientation is still resolved via each edge's `source_file`. + assert!(!cycles.is_empty()); +} + +/// `test_find_import_cycles_ignores_non_import_relations` +#[test] +fn find_import_cycles_ignores_non_import_relations() { + let mut g = Graph::new(GraphKind::DiGraph); + cycle_node(&mut g, "a", "a.ts", Some("src/a.ts")); + cycle_node(&mut g, "b", "b.ts", Some("src/b.ts")); + cycle_edge(&mut g, "a", "b", "calls", "src/a.ts", "INFERRED"); + cycle_edge(&mut g, "b", "a", "contains", "src/b.ts", "EXTRACTED"); + assert!(find_import_cycles(&g).is_empty()); +} + +/// `test_find_import_cycles_empty_graph` +#[test] +fn find_import_cycles_empty_graph() { + let g = Graph::new(GraphKind::DiGraph); + assert!(find_import_cycles(&g).is_empty()); +} + +/// `test_find_import_cycles_no_cycles` +#[test] +fn find_import_cycles_no_cycles() { + let mut g = Graph::new(GraphKind::DiGraph); + cycle_node(&mut g, "x", "x.ts", Some("x.ts")); + cycle_node(&mut g, "y", "y.ts", Some("y.ts")); + cycle_edge(&mut g, "x", "y", "imports_from", "x.ts", "EXTRACTED"); + assert!(find_import_cycles(&g).is_empty()); +} diff --git a/crates/graphify-detect/src/ignore.rs b/crates/graphify-detect/src/ignore.rs index ce0d786..0954189 100644 --- a/crates/graphify-detect/src/ignore.rs +++ b/crates/graphify-detect/src/ignore.rs @@ -310,9 +310,12 @@ fn eval_path(target: &Path, root: &Path, patterns: &IgnorePatterns) -> bool { } let matched = if anchored { + // Anchored patterns match the anchor-relative path directly — no + // subtree/basename fallback. Without this, `/inbox/` would leak into + // `src/inbox/` deep in the tree via segment matching (#1087). last_anchor_rel .as_deref() - .is_some_and(|rel| rel_matches(rel, target_name, p)) + .is_some_and(|rel| fnmatch(rel, p)) } else { let root_matched = root_rel .as_deref() @@ -378,9 +381,12 @@ pub fn is_included(path: &Path, root: &Path, patterns: &IgnorePatterns) -> bool continue; } if anchored { + // Anchored include patterns match the anchor-relative path directly, + // mirroring the anchored ignore fix (#1087): no subtree/basename + // fallback, so `/src/foo` only matches at the anchor root. if path.strip_prefix(anchor).ok().is_some_and(|rel| { let rel_str = path_to_forward_slash(rel); - rel_matches(&rel_str, target_name, p) + fnmatch(&rel_str, p) }) { return true; } diff --git a/crates/graphify-detect/src/walk.rs b/crates/graphify-detect/src/walk.rs index 4fb633f..0f87be3 100644 --- a/crates/graphify-detect/src/walk.rs +++ b/crates/graphify-detect/src/walk.rs @@ -476,6 +476,11 @@ fn run_walk_phase(ctx: &WalkCtx<'_>, root: &Path, memory_dir: &Path) -> Vec String { if cleaned.is_empty() { "unnamed".to_string() } else { - cleaned + crate::util::cap_filename(&cleaned) } } diff --git a/crates/graphify-export/src/json.rs b/crates/graphify-export/src/json.rs index a12f6ea..9df970e 100644 --- a/crates/graphify-export/src/json.rs +++ b/crates/graphify-export/src/json.rs @@ -397,7 +397,7 @@ pub fn backup_if_protected(out_dir: &Path) -> Option { Ok(Some(path)) => Some(path), Ok(None) => None, Err(e) => { - eprintln!("[graphify] warning: backup failed ({e}) — continuing with overwrite"); + eprintln!("[graphify] warning: backup failed ({e}) - continuing with overwrite"); None } } @@ -426,7 +426,7 @@ fn try_backup( .file_name() .and_then(|n| n.to_str()) .unwrap_or("backup"); - println!("[graphify] backed up {reason} graph ({copied} files) → {dir_name}/"); + println!("[graphify] backed up {reason} graph ({copied} files) -> {dir_name}/"); Ok(Some(backup_dir.to_path_buf())) } else { Ok(None) diff --git a/crates/graphify-export/src/obsidian.rs b/crates/graphify-export/src/obsidian.rs index c37e531..f08f1a4 100644 --- a/crates/graphify-export/src/obsidian.rs +++ b/crates/graphify-export/src/obsidian.rs @@ -39,7 +39,7 @@ fn safe_name(label: &str) -> String { if cleaned.is_empty() { "unnamed".to_string() } else { - cleaned + crate::util::cap_filename(&cleaned) } } diff --git a/crates/graphify-export/src/util.rs b/crates/graphify-export/src/util.rs index f3399a9..5c13c95 100644 --- a/crates/graphify-export/src/util.rs +++ b/crates/graphify-export/src/util.rs @@ -1,6 +1,7 @@ //! Shared utility constants and helpers used by every exporter format. use indexmap::IndexMap; +use sha1::{Digest, Sha1}; /// Community colors — shared across HTML, SVG, Obsidian, Canvas. pub const COMMUNITY_COLORS: [&str; 10] = [ @@ -119,6 +120,42 @@ pub fn yaml_str(s: &str) -> String { out } +/// Cap a filename stem to 200 UTF-8 bytes so it stays under the 255-byte +/// filesystem limit even after the `.md` extension and any dedup suffix are +/// appended (#1094). +/// +/// The cap is on BYTES, not chars: a label of multibyte characters (CJK, +/// accented) can blow past 255 bytes well under 255 chars. When truncation +/// happens an 8-char SHA-1 prefix of the full label is appended so two distinct +/// labels sharing a long prefix yield distinct, deterministic filenames instead +/// of colliding. +/// +/// Mirrors Python `_cap_filename`. +#[must_use] +pub(crate) fn cap_filename(s: &str) -> String { + use std::fmt::Write as _; + const LIMIT: usize = 200; + let bytes = s.as_bytes(); + if bytes.len() <= LIMIT { + return s.to_string(); + } + // hexdigest()[:8] == first 4 bytes rendered as 8 hex chars. + let digest = Sha1::digest(bytes); + let mut hex = String::with_capacity(8); + for b in digest.iter().take(4) { + let _ = write!(hex, "{b:02x}"); + } + // Reserve "_" + 8 hex chars. + let keep = LIMIT - 9; + // `bytes[..keep]` is a prefix of valid UTF-8, so the only invalid sequence + // is a split trailing char; drop it (matches Python's decode(_, "ignore")). + let end = match std::str::from_utf8(&bytes[..keep]) { + Ok(_) => keep, + Err(e) => e.valid_up_to(), + }; + format!("{}_{hex}", &s[..end]) +} + /// Default confidence → numeric score mapping. /// /// Mirrors `_CONFIDENCE_SCORE_DEFAULTS`: `INFERRED → 0.5`, diff --git a/crates/graphify-export/tests/filename_cap.rs b/crates/graphify-export/tests/filename_cap.rs new file mode 100644 index 0000000..bef3a6b --- /dev/null +++ b/crates/graphify-export/tests/filename_cap.rs @@ -0,0 +1,143 @@ +//! Parity tests for issue #1094: `to_obsidian` / `to_canvas` must cap filenames +//! to stay under the 255-byte filesystem limit, instead of crashing with +//! `OSError` ENAMETOOLONG on long node labels. +//! +//! Mirrors `graphify-py/tests/test_obsidian_filename_cap.py`. +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::ffi::OsStr; +use std::fs; +use std::path::Path; + +use graphify_build::{Graph, build_from_json}; +use graphify_export::{to_canvas, to_obsidian}; +use indexmap::IndexMap; +use regex::Regex; +use serde_json::{Value, json}; +use tempfile::tempdir; + +/// Build a chain graph (each node wikilinks to the next) with the given labels, +/// all in community 0. Mirrors the Python `_graph` helper. +fn graph(labels: &[&str]) -> (Graph, IndexMap>) { + let mut nodes = Vec::new(); + let mut ids = Vec::new(); + for (i, lab) in labels.iter().enumerate() { + let nid = format!("n{i}"); + nodes.push(json!({ + "id": nid, + "label": lab, + "file_type": "code", + "source_file": "x.py", + "community": 0, + })); + ids.push(nid); + } + let mut edges = Vec::new(); + for w in ids.windows(2) { + edges.push(json!({ + "source": w[0], + "target": w[1], + "relation": "calls", + "confidence": "EXTRACTED", + })); + } + let g = + build_from_json(json!({"nodes": nodes, "edges": edges}), false, None).expect("build graph"); + let mut comms: IndexMap> = IndexMap::new(); + comms.insert(0, ids); + (g, comms) +} + +/// Largest `*.md` filename in `dir`, measured in UTF-8 bytes. +fn max_name_bytes(dir: &Path) -> usize { + fs::read_dir(dir) + .expect("read_dir") + .filter_map(Result::ok) + .filter(|e| e.path().extension() == Some(OsStr::new("md"))) + .map(|e| e.file_name().as_encoded_bytes().len()) + .max() + .unwrap_or(0) +} + +#[test] +fn obsidian_long_ascii_label_does_not_crash() { + let (g, comms) = graph(&[&"a".repeat(300), "short"]); + let tmp = tempdir().expect("tempdir"); + to_obsidian(&g, &comms, tmp.path(), None, None).expect("to_obsidian"); + assert!(max_name_bytes(tmp.path()) <= 255); +} + +#[test] +fn obsidian_long_cjk_label_byte_cap() { + // 300 CJK chars = 900 bytes in UTF-8: a char cap would still overflow. + let (g, comms) = graph(&[&"中".repeat(300), "ok"]); + let tmp = tempdir().expect("tempdir"); + to_obsidian(&g, &comms, tmp.path(), None, None).expect("to_obsidian"); + assert!(max_name_bytes(tmp.path()) <= 255); +} + +#[test] +fn obsidian_distinct_long_labels_sharing_prefix_do_not_collide() { + let prefix = "z".repeat(250); + let (g, comms) = graph(&[&format!("{prefix}_ALPHA"), &format!("{prefix}_BETA")]); + let tmp = tempdir().expect("tempdir"); + to_obsidian(&g, &comms, tmp.path(), None, None).expect("to_obsidian"); + + let md_files: Vec<_> = fs::read_dir(tmp.path()) + .expect("read_dir") + .filter_map(Result::ok) + .filter(|e| e.path().extension() == Some(OsStr::new("md"))) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|name| !name.starts_with("_COMMUNITY_")) + .collect(); + // Two distinct nodes must produce two distinct files (no overwrite). + assert_eq!(md_files.len(), 2, "{md_files:?}"); + assert!(max_name_bytes(tmp.path()) <= 255); +} + +#[test] +fn obsidian_wikilink_resolves_after_truncation() { + let (g, comms) = graph(&[&"w".repeat(300), "neighbor"]); + let tmp = tempdir().expect("tempdir"); + to_obsidian(&g, &comms, tmp.path(), None, None).expect("to_obsidian"); + + // The note for "neighbor" should link to the truncated filename of the long + // label. Every [[target]] must correspond to a real .md file on disk. + let neighbor_note = fs::read_to_string(tmp.path().join("neighbor.md")).expect("neighbor.md"); + let re = Regex::new(r"\[\[([^\]]+)\]\]").expect("valid regex"); + let targets: Vec<&str> = re + .captures_iter(&neighbor_note) + .map(|c| c.get(1).expect("group 1").as_str()) + .collect(); + assert!(!targets.is_empty(), "no wikilink found in neighbor note"); + for t in targets { + assert!( + tmp.path().join(format!("{t}.md")).exists(), + "dangling wikilink: {t}" + ); + } +} + +#[test] +fn canvas_long_label_file_ref_capped() { + let (g, comms) = graph(&[&"c".repeat(300), "ok"]); + let tmp = tempdir().expect("tempdir"); + let out = tmp.path().join("graph.canvas"); + to_canvas(&g, &comms, &out, None, None).expect("to_canvas"); + let data: Value = serde_json::from_str(&fs::read_to_string(&out).expect("read canvas")) + .expect("parse canvas"); + for node in data + .get("nodes") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + if node.get("type").and_then(Value::as_str) == Some("file") { + let file = node + .get("file") + .and_then(Value::as_str) + .expect("file field"); + assert!(file.len() <= 255, "{file}"); + } + } +} diff --git a/crates/graphify-extract/src/extractors/multi.rs b/crates/graphify-extract/src/extractors/multi.rs index 20cfff5..186aed6 100644 --- a/crates/graphify-extract/src/extractors/multi.rs +++ b/crates/graphify-extract/src/extractors/multi.rs @@ -685,6 +685,22 @@ fn resolve_cross_file_java_imports(per_file: &[FileResult], paths: &[PathBuf]) - new_edges } +/// Relativise `path` against `root`, falling back to canonicalising the path +/// when a lexical strip fails (e.g. the path is relative, or differs from +/// `root` only by a symlink such as macOS's `/var` → `/private/var`). +/// +/// Mirrors Python's `path.relative_to(root)` with its +/// `path.resolve().relative_to(root)` fallback. Returns `None` only when the +/// path is genuinely outside `root`. +fn relativise_under_root(path: &Path, root: &Path) -> Option { + if let Ok(rel) = path.strip_prefix(root) { + return Some(rel.to_path_buf()); + } + path.canonicalize() + .ok() + .and_then(|c| c.strip_prefix(root).map(Path::to_path_buf).ok()) +} + // ── Main extract() ──────────────────────────────────────────────────────────── /// Extract AST nodes and edges from a list of code files. @@ -706,7 +722,7 @@ pub fn extract(paths: &[PathBuf], cache_root: Option<&Path>) -> ExtractOutput { // Infer common root for ID relativisation let root: PathBuf = { - let r = if paths.len() == 1 { + let inferred = if paths.len() == 1 { paths[0] .parent() .map_or_else(|| PathBuf::from("."), PathBuf::from) @@ -732,7 +748,12 @@ pub fn extract(paths: &[PathBuf], cache_root: Option<&Path>) -> ExtractOutput { paths[0].components().take(common_len).collect() } }; - r.canonicalize().unwrap_or(r) + // An explicit `cache_root` overrides the inferred prefix, matching + // Python's `if cache_root is not None: root = cache_root`. The root + // drives both cache keys and the #1033 file-node-id relativisation, so + // a divergence here splits AST/semantic file nodes apart. + let base = cache_root.map_or(inferred, Path::to_path_buf); + base.canonicalize().unwrap_or(base) }; let effective_root: &Path = cache_root.unwrap_or(&root); @@ -803,15 +824,34 @@ pub fn extract(paths: &[PathBuf], cache_root: Option<&Path>) -> ExtractOutput { } all_edges.extend(cross_edges); - // Remap absolute file-node IDs to project-relative IDs (#502) + // Remap absolute file-node IDs to the canonical `{parent_dir}_{stem}` spec + // form so (a) edge endpoints are stable across machines (#502) and (b) AST + // file nodes match the IDs semantic subagents generate (#1033). let mut id_remap: HashMap = HashMap::new(); + // Symbol node IDs embed the file stem the extractor saw as a prefix. For a + // root-level file that stem picks up the absolute parent directory name, so + // a symbol becomes `_main_run` while the file node correctly + // relativises to `main` and the spec wants `main_run` — splitting the symbol + // into AST/semantic ghosts (#1096). Relativise the symbol prefix the same + // way, gated by `source_file` so two files sharing a prefix can't + // cross-contaminate. Keyed by the path string the extractor recorded in + // `source_file` → (old_prefix, new_prefix). + let mut prefix_remap: HashMap = HashMap::new(); for path in paths { let old_id = make_id1(&path.to_string_lossy()); - if let Ok(rel) = path.strip_prefix(&root) { - let new_id = make_id1(&rel.to_string_lossy()); - if old_id != new_id { - id_remap.insert(old_id, new_id); - } + // Resolve relative-to-root; a lexical strip can fail (path is relative, or + // differs from `root` only by a symlink), so fall back to canonicalising — + // mirrors Python's `resolve().relative_to(root)` fallback. + let Some(rel) = relativise_under_root(path, &root) else { + continue; + }; + let new_id = crate::ids::file_node_id(&rel); + if old_id != new_id { + id_remap.insert(old_id, new_id.clone()); + } + let old_pref = crate::ids::file_node_id(path); + if old_pref != new_id { + prefix_remap.insert(path.to_string_lossy().into_owned(), (old_pref, new_id)); } } if !id_remap.is_empty() { @@ -829,6 +869,51 @@ pub fn extract(paths: &[PathBuf], cache_root: Option<&Path>) -> ExtractOutput { } } } + if !prefix_remap.is_empty() { + let mut sym_remap: HashMap = HashMap::new(); + for n in &all_nodes { + if n.source_file.is_empty() { + continue; + } + let Some((old_pref, new_pref)) = prefix_remap.get(&n.source_file) else { + continue; + }; + // IDs are make_id output (lowercase word chars + `_`), so slicing at + // a byte offset is always on a char boundary. + if n.id.len() > old_pref.len() + && n.id.starts_with(old_pref.as_str()) + && n.id.as_bytes()[old_pref.len()] == b'_' + { + let new_nid = format!("{new_pref}{}", &n.id[old_pref.len()..]); + if new_nid != n.id { + sym_remap.insert(n.id.clone(), new_nid); + } + } + } + if !sym_remap.is_empty() { + for n in &mut all_nodes { + if let Some(new_id) = sym_remap.get(&n.id) { + n.id = new_id.clone(); + } + } + for e in &mut all_edges { + if let Some(new_id) = sym_remap.get(&e.source) { + e.source = new_id.clone(); + } + if let Some(new_id) = sym_remap.get(&e.target) { + e.target = new_id.clone(); + } + } + // raw_calls carry caller_nid (a symbol id) consumed by the cross-file + // call pass below — rewrite it too or those edges dangle on a stale + // source (#1096). + for rc in &mut all_raw_calls { + if let Some(new_id) = sym_remap.get(&rc.caller_nid) { + rc.caller_nid = new_id.clone(); + } + } + } + } // Disambiguate node IDs that collide across two or more distinct // source files (e.g. two `Program.cs` files in different directories). @@ -892,15 +977,16 @@ pub fn extract(paths: &[PathBuf], cache_root: Option<&Path>) -> ExtractOutput { continue; } let sf_path = PathBuf::from(&n.source_file); + // Relativise the same way `id_remap` does so a symbol's file-nid matches + // its (relativised) file node id — including the canonicalise fallback + // for absolute paths that differ from `root` only by a symlink. Relative + // source paths are used verbatim (mirrors Python). let sf_rel = if sf_path.is_absolute() { - sf_path - .strip_prefix(&root) - .map(PathBuf::from) - .unwrap_or(sf_path) + relativise_under_root(&sf_path, &root).unwrap_or(sf_path) } else { sf_path }; - nid_to_file_nid.insert(n.id.clone(), make_id1(&sf_rel.to_string_lossy())); + nid_to_file_nid.insert(n.id.clone(), crate::ids::file_node_id(&sf_rel)); } let mut existing_pairs: std::collections::HashSet<(String, String)> = all_edges diff --git a/crates/graphify-extract/src/generic/inherit.rs b/crates/graphify-extract/src/generic/inherit.rs index 07910b6..cc50750 100644 --- a/crates/graphify-extract/src/generic/inherit.rs +++ b/crates/graphify-extract/src/generic/inherit.rs @@ -724,6 +724,18 @@ pub(super) fn emit_ts_inheritance( } } } + } else if child.kind() == "extends_type_clause" { + // Interface heritage (`interface A extends B, C`) is an + // extends_type_clause node directly under the declaration, NOT + // wrapped in class_heritage. Its base entries are the same node types + // extends_clause holds, so the entry helper is reusable. Without this + // branch interface inheritance is dropped entirely (#1095). + for name in super::references::ts_heritage_clause_entries(child, source) { + let base_nid = emit_base_node(&name, line, stem, str_path, nodes, seen_ids); + add_edge( + class_nid, &base_nid, "inherits", line, str_path, None, edges, + ); + } } if !cur.goto_next_sibling() { break; diff --git a/crates/graphify-extract/src/ids.rs b/crates/graphify-extract/src/ids.rs index 616748f..8118b54 100644 --- a/crates/graphify-extract/src/ids.rs +++ b/crates/graphify-extract/src/ids.rs @@ -65,6 +65,19 @@ pub fn file_stem(path: &Path) -> String { } } +/// File-level node ID matching the skill.md spec: `{parent_dir}_{stem}` — one +/// parent directory level, no extension. +/// +/// `rel_path` MUST be relative to the project root so top-level files collapse +/// to a bare stem (`setup.py` → `setup`) instead of picking up the root +/// directory name. This must equal the ID semantic subagents generate, or AST +/// and semantic extraction split a file into two disconnected ghost nodes +/// (#1033). Mirrors Python `_file_node_id`. +#[must_use] +pub fn file_node_id(rel_path: &Path) -> String { + make_id1(&file_stem(rel_path)) +} + #[cfg(test)] #[path = "ids_tests.rs"] mod ids_tests; diff --git a/crates/graphify-extract/src/lib.rs b/crates/graphify-extract/src/lib.rs index 30c1620..4c520d9 100644 --- a/crates/graphify-extract/src/lib.rs +++ b/crates/graphify-extract/src/lib.rs @@ -44,5 +44,5 @@ pub use extractors::{ extract_rust, extract_scala, extract_sln, extract_sql, extract_svelte, extract_swift, extract_verilog, extract_zig, is_mcp_config_path, }; -pub use ids::{file_stem, make_id, make_id1}; +pub use ids::{file_node_id, file_stem, make_id, make_id1}; pub use types::{Edge, ExtractOutput, FileResult, Node, RawCall}; diff --git a/crates/graphify-extract/src/postprocess.rs b/crates/graphify-extract/src/postprocess.rs index 997a3b9..8a6fd5c 100644 --- a/crates/graphify-extract/src/postprocess.rs +++ b/crates/graphify-extract/src/postprocess.rs @@ -239,11 +239,11 @@ fn is_type_like_definition(node: &Node) -> bool { /// /// Mirrors `_merge_swift_extensions` in graphify-py `extract.py`. pub fn merge_swift_extensions(paths: &[PathBuf], nodes: &mut Vec, edges: &mut Vec) { - // Collect (nid, label) for every Swift class_declaration whose body - // contains the `extension` keyword. Re-parsing each Swift file once - // here is cheaper than threading a sidecar through the generic walker. - let mut extension_nids: HashSet = HashSet::new(); - let mut extension_labels: HashMap = HashMap::new(); + // Re-parse each Swift file to collect the type names declared as + // `extension Foo`, keyed by the file path string the extractor recorded in + // `source_file`. Re-parsing once here is cheaper than threading a sidecar + // through the generic walker. + let mut ext_names_by_file: HashMap> = HashMap::new(); let mut parser = tree_sitter::Parser::new(); if parser @@ -263,14 +263,30 @@ pub fn merge_swift_extensions(paths: &[PathBuf], nodes: &mut Vec, edges: & let Some(tree) = parser.parse(&source, None) else { continue; }; - let stem = crate::ids::file_stem(path); - collect_swift_extensions( - tree.root_node(), - &source, - &stem, - &mut extension_nids, - &mut extension_labels, - ); + let mut names: HashSet = HashSet::new(); + collect_swift_extension_names(tree.root_node(), &source, &mut names); + if !names.is_empty() { + ext_names_by_file.insert(path.to_string_lossy().into_owned(), names); + } + } + + if ext_names_by_file.is_empty() { + return; + } + + // Identify the actual extension nodes by (source_file, label) rather than a + // re-derived id — the file-node-id remap (#1033/#1096) rewrites symbol ids, + // so matching on the id we'd compute from the path no longer holds. + let mut extension_nids: HashSet = HashSet::new(); + let mut extension_labels: HashMap = HashMap::new(); + for node in nodes.iter() { + if ext_names_by_file + .get(&node.source_file) + .is_some_and(|names| names.contains(&node.label)) + { + extension_nids.insert(node.id.clone()); + extension_labels.insert(node.id.clone(), node.label.clone()); + } } if extension_nids.is_empty() { @@ -344,12 +360,10 @@ pub fn merge_swift_extensions(paths: &[PathBuf], nodes: &mut Vec, edges: & *edges = rewritten; } -fn collect_swift_extensions( +fn collect_swift_extension_names( node: tree_sitter::Node<'_>, source: &[u8], - stem: &str, - extension_nids: &mut HashSet, - extension_labels: &mut std::collections::HashMap, + names: &mut HashSet, ) { // tree-sitter `child()` takes a `u32` index while `child_count()` returns // `usize`. AST nodes never exceed 2^32 children in practice; truncate @@ -376,15 +390,13 @@ fn collect_swift_extensions( if let Some(name) = name && !name.is_empty() { - let nid = make_id(&[stem, &name]); - extension_labels.insert(nid.clone(), name); - extension_nids.insert(nid); + names.insert(name); } } } for i in 0..child_count { if let Some(child) = node.child(i) { - collect_swift_extensions(child, source, stem, extension_nids, extension_labels); + collect_swift_extension_names(child, source, names); } } } diff --git a/crates/graphify-extract/src/workspace.rs b/crates/graphify-extract/src/workspace.rs index c4470d6..f280ff7 100644 --- a/crates/graphify-extract/src/workspace.rs +++ b/crates/graphify-extract/src/workspace.rs @@ -159,6 +159,12 @@ pub fn load_workspace_packages(start_dir: &Path) -> IndexMap { /// understand resolve to the empty list. fn glob_workspace_pattern(root: &Path, pattern: &str) -> Vec { let pattern = pattern.trim_end_matches('/'); + // A `.` / `./` entry means "the workspace root is itself a package" — resolve + // it to `root` directly rather than globbing (#1083; Python's `root.glob('.')` + // crashed on 3.10). + if pattern == "." { + return vec![root.to_path_buf()]; + } if let Some(prefix) = pattern.strip_suffix("/**") { // recursive glob — walk every subdirectory let base = root.join(prefix); diff --git a/crates/graphify-extract/tests/file_node_id_spec.rs b/crates/graphify-extract/tests/file_node_id_spec.rs new file mode 100644 index 0000000..d136cbf --- /dev/null +++ b/crates/graphify-extract/tests/file_node_id_spec.rs @@ -0,0 +1,201 @@ +//! Regression tests for issue #1033 / #1096: AST file-level node IDs must match +//! the skill.md `{parent_dir}_{stem}` spec (one parent level, no extension) so +//! AST and semantic extraction produce the SAME node for a file instead of two +//! disconnected ghosts. +//! +//! Mirrors `graphify-py/tests/test_file_node_id_spec.py`. +//! +//! skill.md spec: +//! +//! ```text +//! stem = {parent_dir}_{filename_without_ext}, lowercased, non-alphanumeric -> _ +//! examples: +//! src/auth/session.py + ValidateToken -> auth_session_validatetoken +//! match/script/pipeline_step.py (file node) -> script_pipeline_step +//! setup.py (top-level) -> setup +//! ``` +#![allow(clippy::expect_used)] + +use std::collections::HashSet; +use std::path::Path; + +use graphify_extract::extract; +use indexmap::IndexMap; +use serde_json::Value; +use tempfile::tempdir; + +/// All node ids in the extraction result. +fn ids(nodes: &[IndexMap]) -> HashSet { + nodes + .iter() + .filter_map(|n| n.get("id").and_then(Value::as_str).map(str::to_string)) + .collect() +} + +fn write(path: &Path, text: &str) { + std::fs::create_dir_all(path.parent().expect("has parent")).expect("create dirs"); + std::fs::write(path, text).expect("write file"); +} + +#[test] +fn file_node_id_uses_parent_dir_and_stem_no_extension() { + // match/script/pipeline_step.py -> file node id 'script_pipeline_step'. + let tmp = tempdir().expect("tempdir"); + let root = tmp.path().canonicalize().expect("canonicalize"); + let f = root.join("match").join("script").join("pipeline_step.py"); + write(&f, "def run():\n pass\n"); + + let result = extract(&[f], Some(&root)); + let ids = ids(&result.nodes); + + assert!( + ids.contains("script_pipeline_step"), + "expected spec-format file id 'script_pipeline_step', got {ids:?}" + ); + // The old buggy full-path-with-extension id must be gone. + assert!(!ids.contains("match_script_pipeline_step_py")); + assert!( + !ids.iter() + .any(|i| i.ends_with("_py") && i.contains("pipeline_step")) + ); +} + +#[test] +fn top_level_file_node_id_is_bare_stem() { + // A file directly at the project root collapses to just its stem. + let tmp = tempdir().expect("tempdir"); + let root = tmp.path().canonicalize().expect("canonicalize"); + let f = root.join("setup.py"); + write(&f, "def configure():\n pass\n"); + + let result = extract(&[f], Some(&root)); + let ids = ids(&result.nodes); + + assert!( + ids.contains("setup"), + "expected bare stem 'setup', got {ids:?}" + ); + assert!(!ids.contains("setup_py")); +} + +#[test] +fn top_level_file_symbol_ids_use_bare_stem() { + // A SYMBOL in a root-level file must use the bare-stem prefix (`setup_configure`), + // not pick up the project-root directory name (`_setup_configure`) (#1096). + let tmp = tempdir().expect("tempdir"); + let root = tmp.path().canonicalize().expect("canonicalize"); + let f = root.join("main.py"); + write(&f, "def run():\n return 1\n"); + + let result = extract(&[f], Some(&root)); + let ids = ids(&result.nodes); + + assert!( + ids.contains("main_run"), + "expected bare-stem symbol 'main_run', got {ids:?}" + ); + // The root directory name must NOT appear in any symbol id. + let rootname = root + .file_name() + .map(|n| n.to_string_lossy().to_lowercase().replace('-', "_")) + .unwrap_or_default(); + assert!( + rootname.is_empty() || !ids.iter().any(|i| i.contains(&rootname)), + "root dir name leaked into ids: {ids:?}" + ); + + // contains edge file -> symbol must connect with the canonical ids. + let contains: Vec<_> = result + .edges + .iter() + .filter(|e| { + e.get("relation").and_then(Value::as_str) == Some("contains") + && e.get("target").and_then(Value::as_str) == Some("main_run") + }) + .collect(); + assert!(!contains.is_empty()); + assert_eq!( + contains[0].get("source").and_then(Value::as_str), + Some("main") + ); +} + +#[test] +fn nested_file_symbol_ids_unchanged() { + // Regression guard: nested files (immediate parent identical in abs/rel form) + // must be completely unaffected by the symbol remap. + let tmp = tempdir().expect("tempdir"); + let root = tmp.path().canonicalize().expect("canonicalize"); + let f = root.join("sub").join("mod.py"); + write(&f, "def work():\n return 2\n"); + + let result = extract(&[f], Some(&root)); + let ids = ids(&result.nodes); + assert!(ids.contains("sub_mod")); + assert!(ids.contains("sub_mod_work")); +} + +#[test] +fn symbol_and_file_ids_share_the_same_stem() { + // Symbol ids already use {parent}_{stem}_{name}; the file node must share + // that stem prefix so 'contains' edges connect file -> symbol. + let tmp = tempdir().expect("tempdir"); + let root = tmp.path().canonicalize().expect("canonicalize"); + let f = root.join("match").join("script").join("pipeline_step.py"); + write(&f, "def run():\n pass\n\nclass Stage:\n pass\n"); + + let result = extract(&[f], Some(&root)); + let ids = ids(&result.nodes); + + assert!(ids.contains("script_pipeline_step")); // file node + assert!(ids.contains("script_pipeline_step_stage")); // class symbol shares stem + + // The file -> class 'contains' edge must reference the real file node id. + let contains: Vec<_> = result + .edges + .iter() + .filter(|e| { + e.get("relation").and_then(Value::as_str) == Some("contains") + && e.get("target").and_then(Value::as_str) == Some("script_pipeline_step_stage") + }) + .collect(); + assert!( + !contains.is_empty(), + "no 'contains' edge to the class symbol" + ); + assert_eq!( + contains[0].get("source").and_then(Value::as_str), + Some("script_pipeline_step"), + ); +} + +#[test] +fn cross_file_import_edges_stay_connected() { + // Changing the file-id format must not orphan import edges. + let tmp = tempdir().expect("tempdir"); + let root = tmp.path().canonicalize().expect("canonicalize"); + let pkg = root.join("pkg"); + write(&pkg.join("models.py"), "class User:\n pass\n"); + write( + &pkg.join("auth.py"), + "from models import User\n\nclass Session:\n def check(self):\n return User()\n", + ); + + let files = vec![pkg.join("models.py"), pkg.join("auth.py")]; + let result = extract(&files, Some(&root)); + let ids = ids(&result.nodes); + + assert!(ids.contains("pkg_models")); + assert!(ids.contains("pkg_auth")); + + // No edge endpoint may keep the old extension-suffixed `*_py` format. + for e in &result.edges { + for key in ["source", "target"] { + let endpoint = e.get(key).and_then(Value::as_str).unwrap_or(""); + assert!( + !endpoint.ends_with("_py"), + "edge endpoint {endpoint:?} kept the old extension-suffixed format" + ); + } + } +} diff --git a/crates/graphify-extract/tests/parity.rs b/crates/graphify-extract/tests/parity.rs index 47a3517..1bb3e99 100644 --- a/crates/graphify-extract/tests/parity.rs +++ b/crates/graphify-extract/tests/parity.rs @@ -764,6 +764,29 @@ fn extract_js_resolves_pnpm_workspace_package() { ); } +#[test] +fn pnpm_workspace_dot_package_does_not_crash() { + // `packages: - '.'` in pnpm-workspace.yaml must resolve the root as a package + // without crashing workspace resolution (#1083; Python's `root.glob('.')` + // raised IndexError on 3.10). + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path(); + write_file( + &root.join("pnpm-workspace.yaml"), + "packages:\n - '.'\n - 'examples/*'\n", + ); + write_file(&root.join("package.json"), "{\"name\": \"my-app\"}"); + let src = root.join("index.ts"); + write_file(&src, "import { foo } from 'my-app';\n"); + + // Must complete without panicking and still produce the file's nodes. + let result = extract(&[src], Some(root)); + assert!( + !result.nodes.is_empty(), + "extraction produced no nodes for index.ts" + ); +} + #[test] fn extract_ts_tsconfig_array_extends_alias_resolves_existing_ts_file() { // graphify-py #1017: TypeScript 5.0 allows `extends` as an array; later diff --git a/crates/graphify-extract/tests/ts_inheritance.rs b/crates/graphify-extract/tests/ts_inheritance.rs new file mode 100644 index 0000000..bc12f22 --- /dev/null +++ b/crates/graphify-extract/tests/ts_inheritance.rs @@ -0,0 +1,206 @@ +//! Parity tests for issue #1095: TypeScript inheritance capture. +//! +//! Mirrors `graphify-py/tests/test_ts_inheritance.py`. Two gaps on v0.8.26: +//! 1. `interface A extends B` produced no `inherits` edge (the walker only +//! looked at `class_heritage`, but interface heritage is an +//! `extends_type_clause` node). +//! 2. `class X extends Y` where `Y` is same-file produced no edge (the +//! use-fact resolver only consulted the import table, never same-file +//! symbol nodes). +//! +//! Files live under a `src/` subdir so the one-parent-level node-ID stem is +//! stable (a root-level file would derive its stem from the tmp dir name). +#![allow(clippy::expect_used)] + +use std::path::{Path, PathBuf}; + +use graphify_extract::{extract, file_stem, make_id}; +use serde_json::Value; +use tempfile::tempdir; + +fn write(path: &Path, text: &str) -> PathBuf { + std::fs::create_dir_all(path.parent().expect("has parent")).expect("create dirs"); + std::fs::write(path, text).expect("write file"); + path.to_path_buf() +} + +/// `_make_id(_file_stem(src_file), src_sym)` → symbol node id. +fn sym_id(file: &str, sym: &str) -> String { + make_id(&[&file_stem(Path::new(file)), sym]) +} + +fn has_inherits( + edges: &[indexmap::IndexMap], + src_file: &str, + src_sym: &str, + tgt_file: &str, + tgt_sym: &str, + relation: &str, +) -> bool { + let src = sym_id(src_file, src_sym); + let tgt = sym_id(tgt_file, tgt_sym); + edges.iter().any(|e| { + e.get("source").and_then(Value::as_str) == Some(src.as_str()) + && e.get("target").and_then(Value::as_str) == Some(tgt.as_str()) + && e.get("relation").and_then(Value::as_str) == Some(relation) + }) +} + +#[test] +fn interface_extends_same_file() { + let tmp = tempdir().expect("tempdir"); + let root = tmp.path().canonicalize().expect("canonicalize"); + let f = write( + &root.join("src").join("a.ts"), + "export interface Base { x: number; }\n\ + export interface Derived extends Base { y: number; }\n", + ); + let result = extract(&[f], Some(&root)); + assert!(has_inherits( + &result.edges, + "src/a.ts", + "Derived", + "src/a.ts", + "Base", + "inherits" + )); +} + +#[test] +fn interface_extends_multiple_same_file() { + let tmp = tempdir().expect("tempdir"); + let root = tmp.path().canonicalize().expect("canonicalize"); + let f = write( + &root.join("src").join("a.ts"), + "interface A { a: number; }\n\ + interface B { b: number; }\n\ + interface M extends A, B { m: number; }\n", + ); + let result = extract(&[f], Some(&root)); + assert!(has_inherits( + &result.edges, + "src/a.ts", + "M", + "src/a.ts", + "A", + "inherits" + )); + assert!(has_inherits( + &result.edges, + "src/a.ts", + "M", + "src/a.ts", + "B", + "inherits" + )); +} + +#[test] +fn class_extends_same_file() { + let tmp = tempdir().expect("tempdir"); + let root = tmp.path().canonicalize().expect("canonicalize"); + let f = write( + &root.join("src").join("a.ts"), + "class Animal {}\nclass Dog extends Animal {}\n", + ); + let result = extract(&[f], Some(&root)); + assert!(has_inherits( + &result.edges, + "src/a.ts", + "Dog", + "src/a.ts", + "Animal", + "inherits" + )); +} + +#[test] +fn interface_extends_generic_base_same_file() { + let tmp = tempdir().expect("tempdir"); + let root = tmp.path().canonicalize().expect("canonicalize"); + let f = write( + &root.join("src").join("a.ts"), + "interface Base { x: T; }\n\ + interface G extends Base { y: number; }\n", + ); + let result = extract(&[f], Some(&root)); + assert!(has_inherits( + &result.edges, + "src/a.ts", + "G", + "src/a.ts", + "Base", + "inherits" + )); +} + +#[test] +fn interface_extends_imported() { + let tmp = tempdir().expect("tempdir"); + let root = tmp.path().canonicalize().expect("canonicalize"); + write( + &root.join("src").join("b.ts"), + "export interface Imported { z: number; }\n", + ); + write( + &root.join("src").join("a.ts"), + "import { Imported } from './b';\n\ + export interface D extends Imported { d: number; }\n", + ); + let result = extract( + &[root.join("src").join("a.ts"), root.join("src").join("b.ts")], + Some(&root), + ); + assert!(has_inherits( + &result.edges, + "src/a.ts", + "D", + "src/b.ts", + "Imported", + "inherits" + )); +} + +#[test] +fn imported_class_extends_still_works() { + // Regression guard: the originally-working imported-class case must stay. + let tmp = tempdir().expect("tempdir"); + let root = tmp.path().canonicalize().expect("canonicalize"); + write(&root.join("src").join("b.ts"), "export class Imported {}\n"); + write( + &root.join("src").join("a.ts"), + "import { Imported } from './b';\nclass Cat extends Imported {}\n", + ); + let result = extract( + &[root.join("src").join("a.ts"), root.join("src").join("b.ts")], + Some(&root), + ); + assert!(has_inherits( + &result.edges, + "src/a.ts", + "Cat", + "src/b.ts", + "Imported", + "inherits" + )); +} + +#[test] +fn class_implements_same_file_interface() { + let tmp = tempdir().expect("tempdir"); + let root = tmp.path().canonicalize().expect("canonicalize"); + let f = write( + &root.join("src").join("a.ts"), + "interface Walker { walk(): void; }\n\ + class Person implements Walker { walk() {} }\n", + ); + let result = extract(&[f], Some(&root)); + assert!(has_inherits( + &result.edges, + "src/a.ts", + "Person", + "src/a.ts", + "Walker", + "implements" + )); +} diff --git a/crates/graphify-llm/Cargo.toml b/crates/graphify-llm/Cargo.toml index 275a0de..52a457c 100644 --- a/crates/graphify-llm/Cargo.toml +++ b/crates/graphify-llm/Cargo.toml @@ -24,6 +24,7 @@ aws-sdk-bedrockruntime = { version = "1", default-features = false, features = [ graphify-security = { workspace = true } indexmap = { workspace = true } rayon = { workspace = true } +regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/graphify-llm/src/backends.rs b/crates/graphify-llm/src/backends.rs index 35daca6..43314b3 100644 --- a/crates/graphify-llm/src/backends.rs +++ b/crates/graphify-llm/src/backends.rs @@ -179,6 +179,19 @@ pub fn format_backend_env_keys(backend: &str) -> String { /// resolved at call time by the AWS SDK. #[must_use] pub fn detect_backend() -> Option { + detect_backend_with(&crate::providers::load_custom_providers()) +} + +/// [`detect_backend`] against an explicit custom-provider set. +/// +/// Built-in backends take priority (gemini → kimi → claude → openai → deepseek → +/// bedrock → ollama); only then are custom providers consulted, in registry +/// order, returning the first whose `env_key` is set (#1084). Splitting the +/// custom set out makes the priority ordering testable without a `providers.json`. +#[must_use] +pub fn detect_backend_with( + custom: &indexmap::IndexMap, +) -> Option { for backend in ["gemini", "kimi", "claude", "openai", "deepseek"] { if !get_backend_api_key(backend).is_empty() { return Some(backend.to_string()); @@ -187,12 +200,18 @@ pub fn detect_backend() -> Option { if bedrock::credentials_appear_configured() { return Some("bedrock".to_string()); } - // Ollama: checked last to avoid shadowing paid backends. + // Ollama: checked before custom providers to avoid shadowing a local model. if let Ok(url) = std::env::var("OLLAMA_BASE_URL") && !url.is_empty() { ollama::validate_ollama_base_url(&url); return Some("ollama".to_string()); } + // Custom providers last: a configured `env_key` selects the provider. + for (name, provider) in custom { + if std::env::var(&provider.env_key).is_ok_and(|v| !v.is_empty()) { + return Some(name.clone()); + } + } None } diff --git a/crates/graphify-llm/src/call.rs b/crates/graphify-llm/src/call.rs index 56ceeae..9979116 100644 --- a/crates/graphify-llm/src/call.rs +++ b/crates/graphify-llm/src/call.rs @@ -21,6 +21,14 @@ use crate::{bedrock, claude, claude_cli, deepseek, gemini, kimi, ollama, openai, pub fn call_llm(prompt: &str, backend: &str, max_tokens: usize) -> Result { let max_tokens_u32 = u32::try_from(max_tokens).unwrap_or(u32::MAX); + // Custom (non-built-in) provider: route through the OpenAI-compatible client + // using the provider's base_url / model / env_key (#1084). + if !crate::providers::is_builtin_backend(backend) + && let Some(provider) = crate::providers::load_custom_providers().get(backend) + { + return call_custom_plain(provider, prompt, max_tokens_u32); + } + let cfg = backend_config(backend).ok_or_else(|| { let available = BACKENDS .iter() @@ -113,3 +121,28 @@ pub fn call_llm(prompt: &str, backend: &str, max_tokens: usize) -> Result unreachable!("backend_config already validated backend name"), } } + +/// Plain-text call against a custom OpenAI-compatible provider (#1084). +fn call_custom_plain( + provider: &crate::providers::CustomProvider, + prompt: &str, + max_tokens: u32, +) -> Result { + let key = std::env::var(&provider.env_key).unwrap_or_default(); + if key.is_empty() { + return Err(LlmError::NoApiKey(format!( + "No API key for backend '{}'. Set {}.", + provider.name, provider.env_key + ))); + } + kimi::call_plain_openai_compat(&kimi::PlainOpenAiRequest { + base_url: &provider.base_url, + api_key: &key, + model: &provider.default_model, + prompt, + temperature: Some(provider.temperature), + reasoning_effort: None, + disable_thinking: false, + max_tokens, + }) +} diff --git a/crates/graphify-llm/src/extract.rs b/crates/graphify-llm/src/extract.rs index b8e2902..62b95c9 100644 --- a/crates/graphify-llm/src/extract.rs +++ b/crates/graphify-llm/src/extract.rs @@ -40,6 +40,14 @@ pub fn extract_files_direct_mode( root: &Path, deep_mode: bool, ) -> Result { + // Custom (non-built-in) provider: extract via the OpenAI-compatible client + // using the provider's base_url / model / env_key (#1084). + if !crate::providers::is_builtin_backend(backend) + && let Some(provider) = crate::providers::load_custom_providers().get(backend) + { + return extract_custom(provider, files, api_key, model, root, deep_mode); + } + let cfg = backend_config(backend).ok_or_else(|| { let available = BACKENDS .iter() @@ -132,3 +140,43 @@ pub fn extract_files_direct_mode( _ => unreachable!("backend_config already validated backend name"), } } + +/// Extract via a custom OpenAI-compatible provider (#1084). Honors an explicit +/// `api_key`/`model`, else falls back to the provider's `env_key`/`default_model`. +fn extract_custom( + provider: &crate::providers::CustomProvider, + files: &[PathBuf], + api_key: Option<&str>, + model: Option<&str>, + root: &Path, + deep_mode: bool, +) -> Result { + let key = api_key + .map(str::to_string) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| std::env::var(&provider.env_key).unwrap_or_default()); + if key.is_empty() { + return Err(LlmError::NoApiKey(format!( + "No API key for backend '{}'. Set {} or pass api_key=.", + provider.name, provider.env_key + ))); + } + let mdl = model + .filter(|s| !s.is_empty()) + .unwrap_or(&provider.default_model); + let user_msg = read_files(files, root); + let max_out = openai_compat::resolve_max_tokens(8192); + openai_compat::call_openai_compat(&openai_compat::OpenAiRequest { + base_url: &provider.base_url, + api_key: &key, + model: mdl, + messages: openai_compat::extraction_messages_for(&user_msg, deep_mode), + temperature: Some(provider.temperature), + reasoning_effort: None, + max_completion_tokens: max_out, + disable_thinking: false, + ollama_options: None, + backend_name: &provider.name, + timeout: openai_compat::api_timeout(), + }) +} diff --git a/crates/graphify-llm/src/labeling.rs b/crates/graphify-llm/src/labeling.rs new file mode 100644 index 0000000..0cf75a2 --- /dev/null +++ b/crates/graphify-llm/src/labeling.rs @@ -0,0 +1,258 @@ +//! LLM-backed community labeling (#1097). +//! +//! Mirrors the `label_communities` / `generate_community_labels` helpers folded +//! into `graphify-py/graphify/llm.py`. When graphify runs inside an +//! orchestrating agent, the agent names communities itself (skill.md Step 5). +//! When run as a bare CLI there is no agent, so these helpers ask the configured +//! backend to name communities in ONE batched call. +//! +//! Unlike the Python original, the graph is not passed in directly — a +//! `graphify_build::Graph` would couple this low-level crate to the graph model. +//! Callers pass the data the prompt needs: a `node_id → label` map and the set +//! of god-node ids (used only to bias which member labels are sampled first). + +use indexmap::{IndexMap, IndexSet}; +use regex::Regex; + +use crate::LlmError; +use crate::backends::detect_backend; +use crate::call::call_llm; + +/// Cap on LLM-named communities; the tail keeps `Community N` placeholders. +const LABEL_MAX_COMMUNITIES: usize = 200; +/// Node labels sampled per community for the prompt. +const LABEL_TOP_K: usize = 12; +/// Individual labels are truncated to this many chars to keep the prompt small. +const LABEL_MAXLEN: usize = 60; + +/// Leading/trailing markdown code-fence stripper. Known-good literal pattern. +#[allow(clippy::expect_used)] +static LABEL_FENCE_RE: std::sync::LazyLock = std::sync::LazyLock::new(|| { + Regex::new(r"(?i)^\s*```(?:json)?\s*|\s*```\s*$").expect("static fence regex") +}); + +/// `{cid: "Community {cid}"}` for every community. +#[must_use] +pub fn placeholder_community_labels( + communities: &IndexMap>, +) -> IndexMap { + communities + .keys() + .map(|cid| (*cid, format!("Community {cid}"))) + .collect() +} + +/// One prompt line per community (largest first), sampling up to `top_k` +/// representative node labels (god nodes first). Returns `(lines, labeled_cids)`; +/// communities with no resolvable labels are skipped. +fn community_label_lines( + communities: &IndexMap>, + node_labels: &IndexMap, + gods: &IndexSet, + max_communities: usize, + top_k: usize, +) -> (Vec, Vec) { + // Largest community first; stable so ties keep insertion order. + let mut ordered: Vec<(&i64, &Vec)> = communities.iter().collect(); + ordered.sort_by_key(|(_, members)| std::cmp::Reverse(members.len())); + + let mut lines = Vec::new(); + let mut labeled_cids = Vec::new(); + for (cid, members) in ordered.into_iter().take(max_communities) { + // God members first, then the rest (stable within each group). + let ranked = members + .iter() + .filter(|m| gods.contains(m.as_str())) + .chain(members.iter().filter(|m| !gods.contains(m.as_str()))); + + let mut names: Vec = Vec::new(); + let mut seen: IndexSet = IndexSet::new(); + for nid in ranked { + let raw = node_labels.get(nid).map_or(nid.as_str(), String::as_str); + let label: String = raw + .trim() + .trim_matches(|c| c == '(' || c == ')') + .chars() + .take(LABEL_MAXLEN) + .collect(); + if !label.is_empty() && seen.insert(label.to_lowercase()) { + names.push(label); + } + if names.len() >= top_k { + break; + } + } + if !names.is_empty() { + lines.push(format!("Community {cid}: {}", names.join(", "))); + labeled_cids.push(*cid); + } + } + (lines, labeled_cids) +} + +/// Parse the backend's JSON `{cid: name}` reply. Errors on non-JSON or a +/// non-object payload; silently ignores cids it didn't name. +fn parse_label_response( + text: &str, + labeled_cids: &[i64], +) -> Result, LlmError> { + let cleaned = LABEL_FENCE_RE.replace_all(text.trim(), "").to_string(); + let cleaned = if cleaned.starts_with('{') { + cleaned + } else if let (Some(start), Some(end)) = (cleaned.find('{'), cleaned.rfind('}')) { + if end > start { + cleaned[start..=end].to_string() + } else { + cleaned + } + } else { + cleaned + }; + + let data: serde_json::Value = + serde_json::from_str(&cleaned).map_err(|e| LlmError::Parse(e.to_string()))?; + let Some(obj) = data.as_object() else { + return Err(LlmError::Parse( + "label response is not a JSON object".to_string(), + )); + }; + + let mut out = IndexMap::new(); + for &cid in labeled_cids { + if let Some(name) = obj + .get(&cid.to_string()) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + { + out.insert(cid, name.to_string()); + } + } + Ok(out) +} + +/// Return a complete `{cid: name}` map using `backend` for naming. +/// +/// Placeholders (`Community N`) are used for any community the backend did not +/// name. Errors on backend/parse failure — callers wanting graceful degradation +/// should use [`generate_community_labels`]. +/// +/// # Errors +/// Propagates [`LlmError`] from the backend call or a malformed JSON reply. +pub fn label_communities( + communities: &IndexMap>, + node_labels: &IndexMap, + gods: &IndexSet, + backend: &str, +) -> Result, LlmError> { + label_communities_with(communities, node_labels, gods, backend, |prompt, b, max| { + call_llm(prompt, b, max as usize) + }) +} + +/// [`label_communities`] with an injectable LLM call — `call(prompt, backend, +/// max_tokens)`. Useful for testing without the network, or to route the call +/// through a custom client. +/// +/// # Errors +/// Propagates whatever `call` returns, plus parse errors on a malformed reply. +pub fn label_communities_with( + communities: &IndexMap>, + node_labels: &IndexMap, + gods: &IndexSet, + backend: &str, + call: F, +) -> Result, LlmError> +where + F: Fn(&str, &str, u32) -> Result, +{ + let mut labels = placeholder_community_labels(communities); + let (lines, labeled_cids) = community_label_lines( + communities, + node_labels, + gods, + LABEL_MAX_COMMUNITIES, + LABEL_TOP_K, + ); + if lines.is_empty() { + return Ok(labels); + } + + let prompt = format!( + "You are naming clusters in a knowledge graph. For each community below, \ + return a concise 2-5 word plain-language name describing what it is about \ + (e.g. \"Order Management\", \"Payment Flow\", \"Auth Middleware\"). \ + Respond ONLY with a JSON object mapping the community id (as a string) to \ + its name - no prose, no markdown fences.\n\n{}", + lines.join("\n") + ); + + let labeled_len = u32::try_from(labeled_cids.len()).unwrap_or(u32::MAX); + let max_tokens = (40 + 16u32.saturating_mul(labeled_len)).min(4096); + let text = call(&prompt, backend, max_tokens)?; + labels.extend(parse_label_response(&text, &labeled_cids)?); + Ok(labels) +} + +/// CLI entry point: resolve a backend, name communities, and degrade to +/// `Community N` placeholders on any failure (no backend, API error, malformed +/// reply). Returns `(labels, source)` where `source` is `"llm"` or +/// `"placeholder"`. Never errors. +#[must_use] +pub fn generate_community_labels( + communities: &IndexMap>, + node_labels: &IndexMap, + gods: &IndexSet, + backend: Option<&str>, + quiet: bool, +) -> (IndexMap, &'static str) { + generate_community_labels_with( + communities, + node_labels, + gods, + backend, + quiet, + |prompt, b, max| call_llm(prompt, b, max as usize), + ) +} + +/// [`generate_community_labels`] with an injectable LLM call — `call(prompt, +/// backend, max_tokens)`. Used by the public wrapper and by tests. +#[must_use] +pub fn generate_community_labels_with( + communities: &IndexMap>, + node_labels: &IndexMap, + gods: &IndexSet, + backend: Option<&str>, + quiet: bool, + call: F, +) -> (IndexMap, &'static str) +where + F: Fn(&str, &str, u32) -> Result, +{ + let resolved = match backend { + Some(b) if !b.is_empty() => Some(b.to_string()), + _ => detect_backend(), + }; + let Some(backend) = resolved else { + if !quiet { + eprintln!( + "[graphify label] no LLM backend configured; keeping Community N \ + placeholders. Set an API key (e.g. GOOGLE_API_KEY) or pass --backend." + ); + } + return (placeholder_community_labels(communities), "placeholder"); + }; + match label_communities_with(communities, node_labels, gods, &backend, call) { + Ok(labels) => (labels, "llm"), + Err(exc) => { + if !quiet { + eprintln!( + "[graphify label] warning: community labeling failed ({exc}); \ + using Community N placeholders." + ); + } + (placeholder_community_labels(communities), "placeholder") + } + } +} diff --git a/crates/graphify-llm/src/lib.rs b/crates/graphify-llm/src/lib.rs index c369287..2803736 100644 --- a/crates/graphify-llm/src/lib.rs +++ b/crates/graphify-llm/src/lib.rs @@ -41,11 +41,13 @@ mod error; pub mod extract; pub mod gemini; pub mod kimi; +pub mod labeling; pub mod ollama; pub mod openai; pub mod openai_compat; pub mod parallel; pub mod parse; +pub mod providers; pub mod read; mod response; pub mod retry; @@ -53,8 +55,8 @@ pub mod tokenizer; pub mod tokens; pub use backends::{ - BACKENDS, BackendConfig, Pricing, backend_config, detect_backend, format_backend_env_keys, - get_backend_api_key, router, + BACKENDS, BackendConfig, Pricing, backend_config, detect_backend, detect_backend_with, + format_backend_env_keys, get_backend_api_key, router, }; pub use call::call_llm; pub use constants::{ @@ -63,11 +65,19 @@ pub use constants::{ }; pub use error::LlmError; pub use extract::{extract_files_direct, extract_files_direct_mode}; +pub use labeling::{ + generate_community_labels, generate_community_labels_with, label_communities, + label_communities_with, placeholder_community_labels, +}; pub use parallel::{ ChunkDoneCb, CorpusConfig, extract_corpus_parallel, extract_corpus_parallel_with_total, merge_into, }; pub use parse::{empty_fragment, parse_llm_json, response_is_hollow}; +pub use providers::{ + CustomProvider, custom_providers_path, is_builtin_backend, load_custom_providers, + load_custom_providers_from, +}; pub use read::read_files; pub use response::{LlmBackend, LlmResponse}; pub use retry::{ diff --git a/crates/graphify-llm/src/providers.rs b/crates/graphify-llm/src/providers.rs new file mode 100644 index 0000000..083963f --- /dev/null +++ b/crates/graphify-llm/src/providers.rs @@ -0,0 +1,127 @@ +//! Custom LLM provider registry loaded from `providers.json` (#1084). +//! +//! Mirrors `_custom_providers_path` / `_load_custom_providers` from +//! `graphify-py/graphify/llm.py`. A custom provider is an OpenAI-compatible +//! endpoint declared by the user (`graphify provider add …`) so backends beyond +//! the built-in set can drive extraction and community labelling. + +use std::path::{Path, PathBuf}; + +use indexmap::IndexMap; +use serde_json::Value; + +use crate::backends::{BACKENDS, Pricing}; + +/// A user-declared OpenAI-compatible provider. +#[derive(Debug, Clone)] +pub struct CustomProvider { + /// Provider name (the `--backend` selector). + pub name: String, + /// OpenAI-compatible base URL (e.g. `https://integrate.api.nvidia.com/v1`). + pub base_url: String, + /// Default model string when the caller does not override it. + pub default_model: String, + /// Environment variable holding the API key. + pub env_key: String, + /// Pricing for cost estimation (defaults to zero when omitted). + pub pricing: Pricing, + /// Sampling temperature (defaults to 0). + pub temperature: f64, +} + +/// Path to the `providers.json` registry. +/// +/// `global == true` → `~/.graphify/providers.json`; otherwise the +/// project-local `.graphify/providers.json`. Mirrors Python +/// `_custom_providers_path`. +#[must_use] +pub fn custom_providers_path(global: bool) -> PathBuf { + if global { + home_dir() + .unwrap_or_default() + .join(".graphify") + .join("providers.json") + } else { + PathBuf::from(".graphify").join("providers.json") + } +} + +/// User home directory from `$HOME` (matches the detect crate's resolution). +fn home_dir() -> Option { + std::env::var_os("HOME").map(PathBuf::from) +} + +/// Load custom providers from the standard local + global `providers.json` +/// paths. Built-in names are never shadowed; pricing defaults to zero. +#[must_use] +pub fn load_custom_providers() -> IndexMap { + load_custom_providers_from(&custom_providers_path(false), &custom_providers_path(true)) +} + +/// Load custom providers from explicit `local`/`global` registry files (in that +/// order — a later file overrides an earlier one for the same name). Malformed +/// files are skipped silently, mirroring Python's broad `except`. +#[must_use] +pub fn load_custom_providers_from(local: &Path, global: &Path) -> IndexMap { + let mut providers: IndexMap = IndexMap::new(); + for path in [local, global] { + let Ok(text) = std::fs::read_to_string(path) else { + continue; + }; + let Ok(Value::Object(map)) = serde_json::from_str::(&text) else { + continue; + }; + for (name, cfg) in map { + if is_builtin_backend(&name) { + continue; + } + let Some(obj) = cfg.as_object() else { + continue; + }; + let pricing = obj.get("pricing").and_then(Value::as_object).map_or( + Pricing { + input: 0.0, + output: 0.0, + }, + |p| Pricing { + input: p.get("input").and_then(Value::as_f64).unwrap_or(0.0), + output: p.get("output").and_then(Value::as_f64).unwrap_or(0.0), + }, + ); + providers.insert( + name.clone(), + CustomProvider { + name, + base_url: obj + .get("base_url") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + default_model: obj + .get("default_model") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + env_key: obj + .get("env_key") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + pricing, + temperature: obj + .get("temperature") + .and_then(Value::as_f64) + .unwrap_or(0.0), + }, + ); + } + } + providers +} + +/// Returns `true` if `name` is a built-in backend (which a custom provider may +/// never shadow). +#[must_use] +pub fn is_builtin_backend(name: &str) -> bool { + BACKENDS.iter().any(|b| b.name == name) +} diff --git a/crates/graphify-llm/tests/labeling.rs b/crates/graphify-llm/tests/labeling.rs new file mode 100644 index 0000000..7ccadd1 --- /dev/null +++ b/crates/graphify-llm/tests/labeling.rs @@ -0,0 +1,216 @@ +//! Parity tests for LLM-backed community labeling (#1097). +//! +//! Mirrors `graphify-py/tests/test_labeling.py`. The Python tests monkeypatch +//! `_call_llm`; the Rust port injects the call via the `*_with` variants. +#![allow(clippy::expect_used, clippy::float_cmp, unsafe_code)] + +use std::cell::Cell; + +use graphify_llm::{ + generate_community_labels, generate_community_labels_with, label_communities_with, +}; +use indexmap::{IndexMap, IndexSet}; + +/// community 0 = ordering, community 1 = payments. +fn graph() -> (IndexMap, IndexMap>) { + let mut node_labels = IndexMap::new(); + node_labels.insert("order_place".to_string(), "place_order".to_string()); + node_labels.insert("order_repo".to_string(), "OrderRepository".to_string()); + node_labels.insert("pay_charge".to_string(), "charge_card".to_string()); + node_labels.insert("pay_stripe".to_string(), "StripeClient".to_string()); + let mut communities = IndexMap::new(); + communities.insert(0, vec!["order_place".to_string(), "order_repo".to_string()]); + communities.insert(1, vec!["pay_charge".to_string(), "pay_stripe".to_string()]); + (node_labels, communities) +} + +#[test] +fn label_communities_happy_path() { + let (node_labels, communities) = graph(); + let gods = IndexSet::new(); + let captured: Cell> = Cell::new(None); + + let labels = label_communities_with( + &communities, + &node_labels, + &gods, + "gemini", + |prompt, backend, _max| { + captured.set(Some((prompt.to_string(), backend.to_string()))); + Ok(r#"{"0": "Order Management", "1": "Payment Flow"}"#.to_string()) + }, + ) + .expect("labeling succeeds"); + + assert_eq!(labels[&0], "Order Management"); + assert_eq!(labels[&1], "Payment Flow"); + let (prompt, backend) = captured.take().expect("call invoked"); + assert!(prompt.contains("place_order")); + assert!(prompt.contains("StripeClient")); + assert_eq!(backend, "gemini"); +} + +#[test] +fn label_communities_partial_reply_fills_placeholder() { + let (node_labels, communities) = graph(); + let gods = IndexSet::new(); + let labels = label_communities_with(&communities, &node_labels, &gods, "gemini", |_, _, _| { + Ok(r#"{"0": "Order Management"}"#.to_string()) + }) + .expect("labeling succeeds"); + assert_eq!(labels[&0], "Order Management"); + assert_eq!(labels[&1], "Community 1"); // missing cid falls back +} + +#[test] +fn label_communities_strips_code_fences() { + let (node_labels, communities) = graph(); + let gods = IndexSet::new(); + let labels = label_communities_with(&communities, &node_labels, &gods, "gemini", |_, _, _| { + Ok("```json\n{\"0\":\"Orders\",\"1\":\"Pay\"}\n```".to_string()) + }) + .expect("labeling succeeds"); + assert_eq!(labels[&0], "Orders"); + assert_eq!(labels[&1], "Pay"); +} + +#[test] +fn label_communities_malformed_errors() { + let (node_labels, communities) = graph(); + let gods = IndexSet::new(); + let result = label_communities_with(&communities, &node_labels, &gods, "gemini", |_, _, _| { + Ok("sorry, I cannot help".to_string()) + }); + assert!(result.is_err()); +} + +#[test] +fn generate_community_labels_degrades_on_error() { + let (node_labels, communities) = graph(); + let gods = IndexSet::new(); + let (labels, source) = generate_community_labels_with( + &communities, + &node_labels, + &gods, + Some("gemini"), + true, + |_, _, _| Ok("not json".to_string()), + ); + assert_eq!(source, "placeholder"); + assert_eq!(labels[&0], "Community 0"); + assert_eq!(labels[&1], "Community 1"); +} + +#[test] +fn generate_community_labels_no_backend() { + // With backend=None and no env keys / no providers.json, detect_backend() + // returns None and the real wrapper degrades to placeholders without any LLM + // call. nextest runs each test in its own process, so env edits are isolated. + let home = tempfile::tempdir().expect("tempdir"); + let mut g = EnvGuard::new(); + g.set("HOME", &home.path().to_string_lossy()); + for key in [ + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "MOONSHOT_API_KEY", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "DEEPSEEK_API_KEY", + "OLLAMA_BASE_URL", + "AWS_PROFILE", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_WEB_IDENTITY_TOKEN_FILE", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + ] { + g.unset(key); + } + + let (node_labels, communities) = graph(); + let gods = IndexSet::new(); + let (labels, source) = generate_community_labels(&communities, &node_labels, &gods, None, true); + assert_eq!(source, "placeholder"); + assert_eq!(labels[&0], "Community 0"); + assert_eq!(labels[&1], "Community 1"); +} + +/// RAII guard that sets/restores env vars. +struct EnvGuard { + saved: Vec<(String, Option)>, +} +impl EnvGuard { + fn new() -> Self { + Self { saved: vec![] } + } + fn set(&mut self, k: &str, v: &str) -> &mut Self { + self.saved.push((k.to_string(), std::env::var(k).ok())); + unsafe { std::env::set_var(k, v) }; + self + } + fn unset(&mut self, k: &str) -> &mut Self { + self.saved.push((k.to_string(), std::env::var(k).ok())); + unsafe { std::env::remove_var(k) }; + self + } +} +impl Drop for EnvGuard { + fn drop(&mut self) { + for (k, prev) in self.saved.drain(..).rev() { + match prev { + Some(v) => unsafe { std::env::set_var(&k, &v) }, + None => unsafe { std::env::remove_var(&k) }, + } + } + } +} + +#[test] +fn generate_community_labels_success() { + let (node_labels, communities) = graph(); + let gods = IndexSet::new(); + let (labels, source) = generate_community_labels_with( + &communities, + &node_labels, + &gods, + Some("gemini"), + true, + |_, _, _| Ok(r#"{"0":"Orders","1":"Payments"}"#.to_string()), + ); + assert_eq!(source, "llm"); + assert_eq!(labels[&0], "Orders"); + assert_eq!(labels[&1], "Payments"); +} + +#[test] +fn gods_as_ids_do_not_crash() { + // god_nodes() ids are pre-resolved to a set at the CLI boundary; passing + // them must not change the labels for these small communities. + let (node_labels, communities) = graph(); + let mut gods = IndexSet::new(); + gods.insert("order_repo".to_string()); + let labels = label_communities_with(&communities, &node_labels, &gods, "gemini", |_, _, _| { + Ok(r#"{"0":"Orders","1":"Pay"}"#.to_string()) + }) + .expect("labeling succeeds"); + assert_eq!(labels[&0], "Orders"); + assert_eq!(labels[&1], "Pay"); +} + +#[test] +fn empty_communities_returns_placeholders() { + let node_labels = IndexMap::new(); + let mut communities: IndexMap> = IndexMap::new(); + communities.insert(0, vec![]); + let gods = IndexSet::new(); + let called = Cell::new(false); + // community with no resolvable nodes -> no prompt line -> no backend call. + let labels = label_communities_with(&communities, &node_labels, &gods, "gemini", |_, _, _| { + called.set(true); + Ok("{}".to_string()) + }) + .expect("labeling succeeds"); + assert_eq!(labels[&0], "Community 0"); + assert!(!called.get()); +} diff --git a/crates/graphify-llm/tests/provider_registry.rs b/crates/graphify-llm/tests/provider_registry.rs new file mode 100644 index 0000000..a00e91b --- /dev/null +++ b/crates/graphify-llm/tests/provider_registry.rs @@ -0,0 +1,188 @@ +//! Parity tests for the custom LLM provider registry (#1084). +//! +//! Mirrors `graphify-py/tests/test_provider_registry.py`. +#![allow(clippy::expect_used, clippy::float_cmp, unsafe_code)] + +use graphify_llm::{ + CustomProvider, Pricing, call_llm, detect_backend_with, load_custom_providers_from, +}; +use indexmap::IndexMap; +use tempfile::tempdir; + +/// RAII guard that sets/restores env vars (mirrors the HTTP tests' guard). +struct EnvGuard { + saved: Vec<(String, Option)>, +} +impl EnvGuard { + fn new() -> Self { + Self { saved: vec![] } + } + fn set(&mut self, k: &str, v: &str) -> &mut Self { + self.saved.push((k.to_string(), std::env::var(k).ok())); + unsafe { std::env::set_var(k, v) }; + self + } + fn unset(&mut self, k: &str) -> &mut Self { + self.saved.push((k.to_string(), std::env::var(k).ok())); + unsafe { std::env::remove_var(k) }; + self + } +} +impl Drop for EnvGuard { + fn drop(&mut self) { + for (k, prev) in self.saved.drain(..).rev() { + match prev { + Some(v) => unsafe { std::env::set_var(&k, &v) }, + None => unsafe { std::env::remove_var(&k) }, + } + } + } +} + +#[test] +fn custom_provider_load_returns_config() { + let tmp = tempdir().expect("tempdir"); + let global = tmp.path().join("providers.json"); + std::fs::write( + &global, + r#"{ + "nvidia": { + "base_url": "https://integrate.api.nvidia.com/v1", + "default_model": "minimaxai/minimax-m2.7", + "env_key": "NVIDIA_API_KEY", + "pricing": {"input": 0.0, "output": 0.0}, + "temperature": 0 + } + }"#, + ) + .expect("write providers.json"); + + let loaded = load_custom_providers_from(&tmp.path().join("local.json"), &global); + assert!(loaded.contains_key("nvidia")); + assert_eq!( + loaded["nvidia"].base_url, + "https://integrate.api.nvidia.com/v1" + ); +} + +#[test] +fn custom_provider_pricing_defaults_to_zero() { + let tmp = tempdir().expect("tempdir"); + let global = tmp.path().join("providers.json"); + std::fs::write( + &global, + r#"{ + "mymodel": { + "base_url": "http://localhost:8080/v1", + "default_model": "llama3", + "env_key": "MY_API_KEY" + } + }"#, + ) + .expect("write providers.json"); + + let loaded = load_custom_providers_from(&tmp.path().join("local.json"), &global); + assert!(loaded.contains_key("mymodel")); + assert_eq!(loaded["mymodel"].pricing.input, 0.0); + assert_eq!(loaded["mymodel"].pricing.output, 0.0); +} + +#[test] +fn custom_provider_cannot_shadow_builtin() { + let tmp = tempdir().expect("tempdir"); + let global = tmp.path().join("providers.json"); + std::fs::write( + &global, + r#"{ + "claude": { + "base_url": "http://evil.example.com/v1", + "default_model": "evil-model", + "env_key": "EVIL_KEY" + } + }"#, + ) + .expect("write providers.json"); + + let loaded = load_custom_providers_from(&tmp.path().join("local.json"), &global); + assert!(!loaded.contains_key("claude")); +} + +#[test] +fn detect_backend_custom_provider_after_builtins() { + let mut g = EnvGuard::new(); + for key in [ + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "MOONSHOT_API_KEY", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "DEEPSEEK_API_KEY", + "OLLAMA_BASE_URL", + "AWS_PROFILE", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_WEB_IDENTITY_TOKEN_FILE", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + ] { + g.unset(key); + } + g.set("MY_CUSTOM_KEY", "test-key"); + + let mut custom: IndexMap = IndexMap::new(); + custom.insert( + "myprovider".to_string(), + CustomProvider { + name: "myprovider".to_string(), + base_url: "http://example.com/v1".to_string(), + default_model: "mymodel".to_string(), + env_key: "MY_CUSTOM_KEY".to_string(), + pricing: Pricing { + input: 0.0, + output: 0.0, + }, + temperature: 0.0, + }, + ); + + assert_eq!(detect_backend_with(&custom).as_deref(), Some("myprovider")); +} + +#[test] +fn custom_provider_call_llm_routes_via_openai_compat() { + // A custom provider registered in $HOME/.graphify/providers.json must drive a + // plain call through the OpenAI-compatible client (#1084). nextest isolates + // each test in its own process, so the HOME/env edits are safe. + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/chat/completions") + .with_status(200) + .with_body( + serde_json::json!({ + "choices": [{"message": {"content": "from custom"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 3, "completion_tokens": 4} + }) + .to_string(), + ) + .create(); + + let home = tempdir().expect("tempdir"); + std::fs::create_dir_all(home.path().join(".graphify")).expect("mkdir .graphify"); + std::fs::write( + home.path().join(".graphify").join("providers.json"), + format!( + r#"{{"custom1": {{"base_url": "{}", "default_model": "m", "env_key": "CUSTOM1_KEY"}}}}"#, + server.url() + ), + ) + .expect("write providers.json"); + + let mut g = EnvGuard::new(); + g.set("HOME", &home.path().to_string_lossy()); + g.set("GRAPHIFY_TEST_ALLOW_PRIVATE_IPS", "1"); + g.set("CUSTOM1_KEY", "secret"); + + let out = call_llm("ping", "custom1", 32).expect("custom provider call succeeds"); + assert_eq!(out, "from custom"); +} diff --git a/crates/graphify-prs/src/dashboard.rs b/crates/graphify-prs/src/dashboard.rs index 2b939ba..85b95a0 100644 --- a/crates/graphify-prs/src/dashboard.rs +++ b/crates/graphify-prs/src/dashboard.rs @@ -216,7 +216,7 @@ pub fn render_worktrees(prs: &[PrInfo], worktrees: &HashMap PR {} [{}] {}", dim("branch:"), branch, bold(&format!("#{}", pr.number)), @@ -244,7 +244,7 @@ pub fn render_conflicts( if actionable.is_empty() { println!( "{}", - dim("\n No graph impact data — run with a valid graph.json to detect conflicts.\n") + dim("\n No graph impact data - run with a valid graph.json to detect conflicts.\n") ); return; } @@ -264,7 +264,7 @@ pub fn render_conflicts( if conflicts.is_empty() { println!( "{}", - green("\n No community overlap between open PRs — safe to merge in any order.\n") + green("\n No community overlap between open PRs - safe to merge in any order.\n") ); return; } @@ -319,7 +319,12 @@ pub fn render_pr_detail(pr: &PrInfo) { ); println!(" {}", pr.title); println!(); - println!(" {} {} → {}", dim("branch:"), pr.branch, pr.base_branch); + println!( + " {} {} -> {}", + dim("branch:"), + pr.branch, + pr.base_branch + ); println!(" {} {}", dim("author:"), pr.author); println!(" {} {}d ago", dim("updated:"), pr.days_old()); println!( diff --git a/crates/graphify-report/Cargo.toml b/crates/graphify-report/Cargo.toml index 7514ee0..019ec17 100644 --- a/crates/graphify-report/Cargo.toml +++ b/crates/graphify-report/Cargo.toml @@ -10,6 +10,7 @@ version.workspace = true [dependencies] chrono = { workspace = true } +graphify-analyze = { workspace = true } graphify-build = { workspace = true } indexmap = { workspace = true } regex = { workspace = true } diff --git a/crates/graphify-report/src/render.rs b/crates/graphify-report/src/render.rs index c771f05..f299390 100644 --- a/crates/graphify-report/src/render.rs +++ b/crates/graphify-report/src/render.rs @@ -12,6 +12,7 @@ use crate::error::ReportError; use crate::sections; use crate::sections::{ communities::{render_communities, render_nav_hubs}, + cycles::render_import_cycles, detection::{ ConfidenceStats, render_ambiguous, render_corpus_check, render_gaps, render_summary, }, @@ -92,6 +93,8 @@ pub fn render_report(graph: &Graph, analysis: &Value) -> String { } render_god_nodes(&mut lines, god_node_list); render_surprising(&mut lines, surprise_list); + // Circular imports surfaced from the file-level dependency graph (#961). + render_import_cycles(&mut lines, graph); if let Some(hyperedges) = graph .graph_attrs .get("hyperedges") diff --git a/crates/graphify-report/src/sections/cycles.rs b/crates/graphify-report/src/sections/cycles.rs new file mode 100644 index 0000000..98d9aa5 --- /dev/null +++ b/crates/graphify-report/src/sections/cycles.rs @@ -0,0 +1,32 @@ +//! Import-cycles section renderer. +//! +//! Surfaces circular import dependencies detected at the file level, mirroring +//! the `## Import Cycles` block `report.generate` adds (#961). + +use graphify_analyze::find_import_cycles; +use graphify_build::Graph; + +/// Render the "Import Cycles" section. +/// +/// Always emits the heading; lists one bullet per detected cycle (rendered as a +/// closed path `a -> b -> a`), or `- None detected.` when there are none. +pub(crate) fn render_import_cycles(lines: &mut Vec, graph: &Graph) { + lines.push(String::new()); + lines.push("## Import Cycles".to_string()); + + let cycles = find_import_cycles(graph); + if cycles.is_empty() { + lines.push("- None detected.".to_string()); + return; + } + + for c in &cycles { + if c.cycle.is_empty() { + continue; + } + let mut path = c.cycle.clone(); + path.push(c.cycle[0].clone()); + let cycle_path = path.join(" -> "); + lines.push(format!("- {}-file cycle: `{cycle_path}`", c.length)); + } +} diff --git a/crates/graphify-report/src/sections/mod.rs b/crates/graphify-report/src/sections/mod.rs index 9dede7f..320b02d 100644 --- a/crates/graphify-report/src/sections/mod.rs +++ b/crates/graphify-report/src/sections/mod.rs @@ -4,6 +4,7 @@ //! classification helpers live here because multiple sections need them. pub mod communities; +pub mod cycles; pub mod detection; pub mod god_nodes; pub mod header; diff --git a/crates/graphify-report/tests/parity.rs b/crates/graphify-report/tests/parity.rs index 7183504..0a9a9f7 100644 --- a/crates/graphify-report/tests/parity.rs +++ b/crates/graphify-report/tests/parity.rs @@ -372,3 +372,61 @@ fn test_empty_graph_renders() { "no surprises message present" ); } + +// --------------------------------------------------------------------------- +// Import Cycles section (#961) +// --------------------------------------------------------------------------- + +#[test] +fn test_report_import_cycles_none_detected() { + // The default fixture has no import edges, so the section degrades gracefully. + let graph = make_graph(); + let analysis = make_analysis(); + let report = render_report(&graph, &analysis); + assert!( + report.contains("## Import Cycles"), + "import cycles section missing" + ); + let section = report + .split("## Import Cycles") + .nth(1) + .expect("section body"); + assert!( + section.contains("- None detected."), + "expected 'None detected.' when no cycles exist" + ); +} + +#[test] +fn test_report_import_cycles_lists_cycle() { + // Two files importing each other form a 2-file cycle, rendered as a closed + // path `a -> b -> a`. + let extraction = json!({ + "nodes": [ + {"id": "a", "label": "a.ts", "file_type": "code", "source_file": "src/a.ts"}, + {"id": "b", "label": "b.ts", "file_type": "code", "source_file": "src/b.ts"} + ], + "edges": [ + {"source": "a", "target": "b", "relation": "imports_from", + "source_file": "src/a.ts", "confidence": "EXTRACTED"}, + {"source": "b", "target": "a", "relation": "imports_from", + "source_file": "src/b.ts", "confidence": "EXTRACTED"} + ] + }); + let graph = build_from_json(extraction, true, None).expect("build graph"); + let analysis = json!({ + "communities": {}, + "cohesion_scores": {}, + "community_labels": {}, + "god_nodes": [], + "surprising_connections": [], + "detection_result": { "total_files": 2, "total_words": 0, "warning": null }, + "token_cost": { "input": 0, "output": 0 }, + "root": "./project" + }); + let report = render_report(&graph, &analysis); + assert!( + report.contains("- 2-file cycle: `src/a.ts -> src/b.ts -> src/a.ts`"), + "expected the 2-file cycle line, got:\n{report}" + ); +} diff --git a/src/cli/args.rs b/src/cli/args.rs index c3cc53e..68ed25c 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -108,6 +108,39 @@ pub(crate) enum Command { exclude_hubs: Option, #[arg(long = "min-community-size", default_value_t = 3)] min_community_size: usize, + /// Keep `Community N` placeholders (skip LLM community naming). + #[arg(long = "no-label")] + no_label: bool, + /// Backend to use for community naming (default: auto-detect). + #[arg(long)] + backend: Option, + }, + + /// (Re)name communities with the configured LLM backend, regenerate report. + /// + /// Equivalent to `cluster-only` but always refreshes community names even + /// when a `.graphify_labels.json` already exists. + Label { + path: PathBuf, + #[arg(long = "no-viz")] + no_viz: bool, + #[arg(long)] + graph: Option, + #[arg(long, default_value_t = 1.0)] + resolution: f64, + #[arg(long = "exclude-hubs")] + exclude_hubs: Option, + #[arg(long = "min-community-size", default_value_t = 3)] + min_community_size: usize, + /// Backend to use (default: auto-detect from API keys). + #[arg(long)] + backend: Option, + }, + + /// Manage custom LLM providers (`graphify provider `). + Provider { + #[command(subcommand)] + cmd: ProviderCommand, }, /// BFS traversal of graph.json for a question. @@ -448,6 +481,32 @@ pub(crate) enum HookCmd { Status, } +/// Subcommands for the `provider` command group (custom LLM providers, #1084). +#[derive(Debug, Subcommand)] +pub(crate) enum ProviderCommand { + /// Register a custom OpenAI-compatible provider in `~/.graphify/providers.json`. + Add { + /// Provider name (the `--backend` selector). + name: String, + #[arg(long = "base-url")] + base_url: Option, + #[arg(long = "default-model")] + default_model: Option, + #[arg(long = "env-key")] + env_key: Option, + #[arg(long = "pricing-input")] + pricing_input: Option, + #[arg(long = "pricing-output")] + pricing_output: Option, + }, + /// List registered custom providers. + List, + /// Print one provider's full configuration as JSON. + Show { name: String }, + /// Remove a custom provider. + Remove { name: String }, +} + /// Subcommands for the `global` command group. #[derive(Debug, Subcommand)] pub(crate) enum GlobalCmd { diff --git a/src/cli/clone.rs b/src/cli/clone.rs index bd652e6..f7968cf 100644 --- a/src/cli/clone.rs +++ b/src/cli/clone.rs @@ -33,7 +33,7 @@ pub(crate) fn cmd_clone( // Repo already present — pull to update. Mirrors Python's // `git -C pull [origin -- ]` at __main__.py:1174. eprintln!( - "repo already cloned at {} — pulling latest ...", + "repo already cloned at {} - pulling latest ...", target.display() ); let mut cmd = Proc::new("git"); @@ -48,7 +48,7 @@ pub(crate) fn cmd_clone( eprintln!("warning: git pull failed (exit {status}); local copy may be stale"); } } else { - eprintln!("cloning {url} → {} ...", target.display()); + eprintln!("cloning {url} -> {} ...", target.display()); if let Some(parent) = target.parent() { std::fs::create_dir_all(parent)?; } diff --git a/src/cli/cluster_only.rs b/src/cli/cluster_only.rs index f5bade8..1f4dc9a 100644 --- a/src/cli/cluster_only.rs +++ b/src/cli/cluster_only.rs @@ -5,6 +5,17 @@ use anyhow::{Result, anyhow}; use crate::cli::{build_analysis, load_graph}; +/// Community-labelling knobs for [`cmd_cluster_only`]. +#[derive(Clone, Copy, Default)] +pub(crate) struct LabelOptions<'a> { + /// Keep `Community N` placeholders instead of LLM-naming (the `--no-label` flag). + pub no_label: bool, + /// Backend override for naming; `None` auto-detects from API keys. + pub backend: Option<&'a str>, + /// `graphify label` always (re)names even when a labels file exists. + pub force_relabel: bool, +} + /// Rerun community detection on an existing graph.json and regenerate the report. /// /// `exclude_hubs` is forwarded directly to `graphify_cluster::cluster` as the @@ -21,6 +32,7 @@ pub(crate) fn cmd_cluster_only( resolution: f64, exclude_hubs: Option, min_community_size: usize, + opts: LabelOptions<'_>, ) -> Result<()> { let start = std::time::Instant::now(); let graph_path = graph.map_or_else( @@ -119,25 +131,47 @@ pub(crate) fn cmd_cluster_only( graphify_export::to_json(&g, &communities, &graph_path, true, None)?; eprintln!(" wrote {}", graph_path.display()); - // Persist (or refresh) `.graphify_labels.json` so the HTML viz and - // subsequent exports can find community labels. Loads existing labels - // first to preserve user-edited names; falls back to `"Community "`. + // Resolve `.graphify_labels.json` so the HTML viz and downstream exports can + // find community labels. Three paths, mirroring Python's cluster-only/label: + // 1. labels file exists & not forced → load it (preserve user edits), fill + // any gaps with placeholders. + // 2. `--no-label` (and not forced) → all placeholders, no LLM call. + // 3. otherwise → auto-name with the configured backend (#1097); degrades + // to placeholders on no-backend/error. let labels_path = graph_path.with_file_name(".graphify_labels.json"); - let mut labels: indexmap::IndexMap = indexmap::IndexMap::new(); - if let Ok(text) = std::fs::read_to_string(&labels_path) - && let Ok(serde_json::Value::Object(map)) = serde_json::from_str::(&text) - { - for (k, v) in &map { - if let (Ok(cid), Some(s)) = (k.parse::(), v.as_str()) { - labels.insert(cid, s.to_string()); + let labels: indexmap::IndexMap = if labels_path.exists() && !opts.force_relabel { + let mut existing: indexmap::IndexMap = indexmap::IndexMap::new(); + if let Ok(text) = std::fs::read_to_string(&labels_path) + && let Ok(serde_json::Value::Object(map)) = + serde_json::from_str::(&text) + { + for (k, v) in &map { + if let (Ok(cid), Some(s)) = (k.parse::(), v.as_str()) { + existing.insert(cid, s.to_string()); + } } } - } - for cid in communities.keys() { + for cid in communities.keys() { + existing + .entry(*cid) + .or_insert_with(|| format!("Community {cid}")); + } + existing + } else if opts.no_label && !opts.force_relabel { + graphify_llm::placeholder_community_labels(&communities) + } else { + eprintln!("Labeling communities..."); + let node_labels = node_label_map(&g); + let gods = god_node_ids(&g); + let (labels, _source) = graphify_llm::generate_community_labels( + &communities, + &node_labels, + &gods, + opts.backend, + false, + ); labels - .entry(*cid) - .or_insert_with(|| format!("Community {cid}")); - } + }; let labels_json: serde_json::Map = labels .iter() .map(|(cid, name)| (cid.to_string(), serde_json::Value::String(name.clone()))) @@ -174,3 +208,27 @@ pub(crate) fn cmd_cluster_only( eprintln!("done in {:.1}s", start.elapsed().as_secs_f64()); Ok(()) } + +/// Build `node_id → label` for community labelling prompts. +fn node_label_map(g: &graphify_build::Graph) -> indexmap::IndexMap { + g.nodes() + .filter_map(|(id, attrs)| { + attrs + .get("label") + .and_then(serde_json::Value::as_str) + .map(|label| (id.clone(), label.to_string())) + }) + .collect() +} + +/// The set of god-node ids (used to bias which member labels are sampled first). +fn god_node_ids(g: &graphify_build::Graph) -> indexmap::IndexSet { + graphify_analyze::god_nodes(g, 20) + .into_iter() + .filter_map(|n| { + n.get("id") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + .collect() +} diff --git a/src/cli/dispatch.rs b/src/cli/dispatch.rs index 453eaea..cd3156e 100644 --- a/src/cli/dispatch.rs +++ b/src/cli/dispatch.rs @@ -26,7 +26,8 @@ pub(crate) fn dispatch(cmd: Command) -> Result<()> { force, no_cluster, } => cli::extract::cmd_update(&path, force, no_cluster), - cmd @ Command::ClusterOnly { .. } => dispatch_cluster_only(cmd), + cmd @ (Command::ClusterOnly { .. } | Command::Label { .. }) => dispatch_cluster_only(cmd), + Command::Provider { cmd } => cli::provider::cmd_provider(cmd), cmd @ Command::Query { .. } => dispatch_query(cmd), Command::Path { from, to, graph } => cli::query::cmd_path(&from, &to, graph.as_deref()), Command::Explain { node, graph } => cli::query::cmd_explain(&node, graph.as_deref()), @@ -112,16 +113,58 @@ fn dispatch_install(platform: Option<&str>, positional: Option<&str>, project: b } fn dispatch_cluster_only(cmd: Command) -> Result<()> { - let Command::ClusterOnly { + // `cluster-only` and `label` share the same handler; `label` forces a relabel. + let ( path, no_viz, graph, resolution, exclude_hubs, min_community_size, - } = cmd - else { - unreachable!("dispatch_cluster_only invoked with wrong variant") + no_label, + backend, + force, + ) = match cmd { + Command::ClusterOnly { + path, + no_viz, + graph, + resolution, + exclude_hubs, + min_community_size, + no_label, + backend, + } => ( + path, + no_viz, + graph, + resolution, + exclude_hubs, + min_community_size, + no_label, + backend, + false, + ), + Command::Label { + path, + no_viz, + graph, + resolution, + exclude_hubs, + min_community_size, + backend, + } => ( + path, + no_viz, + graph, + resolution, + exclude_hubs, + min_community_size, + false, + backend, + true, + ), + _ => unreachable!("dispatch_cluster_only invoked with wrong variant"), }; cli::cluster_only::cmd_cluster_only( &path, @@ -130,6 +173,11 @@ fn dispatch_cluster_only(cmd: Command) -> Result<()> { resolution, exclude_hubs, min_community_size, + cli::cluster_only::LabelOptions { + no_label, + backend: backend.as_deref(), + force_relabel: force, + }, ) } diff --git a/src/cli/extract.rs b/src/cli/extract.rs index 2770532..5994225 100644 --- a/src/cli/extract.rs +++ b/src/cli/extract.rs @@ -752,7 +752,7 @@ pub(crate) fn cmd_update(path: &std::path::Path, force: bool, no_cluster: bool) } Ok(()) } else { - anyhow::bail!("Nothing to update or rebuild failed — check output above.") + anyhow::bail!("Nothing to update or rebuild failed - check output above.") } } @@ -869,7 +869,7 @@ pub(crate) fn cmd_extract_global_add( match graphify_global::global_add(graph_path, &tag, &global_path, &manifest_path) { Ok(summary) => { if summary.nodes_added == 0 && summary.nodes_removed == 0 { - eprintln!("[graphify global] '{tag}' unchanged since last add — skipped."); + eprintln!("[graphify global] '{tag}' unchanged since last add - skipped."); } else { eprintln!( "[graphify global] '{tag}' merged into global graph \ diff --git a/src/cli/global.rs b/src/cli/global.rs index aa10452..dfcca00 100644 --- a/src/cli/global.rs +++ b/src/cli/global.rs @@ -26,7 +26,7 @@ pub(crate) fn cmd_global(cmd: GlobalCmd) -> Result<()> { let global_path = graphify_global::global_graph_path(); let summary = graphify_global::global_add(&graph, &tag, &global_path, &manifest_path)?; if summary.nodes_added == 0 && summary.nodes_removed == 0 { - println!("'{tag}' unchanged since last add — global graph not modified."); + println!("'{tag}' unchanged since last add - global graph not modified."); } else { println!( "Added '{tag}' to global graph: +{} nodes, -{} pruned. Global: {}", diff --git a/src/cli/mod.rs b/src/cli/mod.rs index b059994..3ccdb19 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -19,6 +19,7 @@ pub(crate) mod hooks; pub(crate) mod install; pub(crate) mod merge; pub(crate) mod merge_chunks; +pub(crate) mod provider; pub(crate) mod prs; pub(crate) mod query; pub(crate) mod save_result; diff --git a/src/cli/provider.rs b/src/cli/provider.rs new file mode 100644 index 0000000..18f6039 --- /dev/null +++ b/src/cli/provider.rs @@ -0,0 +1,133 @@ +//! `provider` command — manage custom LLM providers in +//! `~/.graphify/providers.json` (#1084). +//! +//! Ports the `provider [add|list|show|remove]` block from +//! `graphify-py/graphify/__main__.py`. + +use anyhow::{Result, anyhow}; +use serde_json::{Map, Value, json}; + +use crate::cli::args::ProviderCommand; + +/// Dispatch a `provider` subcommand. +pub(crate) fn cmd_provider(cmd: ProviderCommand) -> Result<()> { + match cmd { + ProviderCommand::List => { + list(); + Ok(()) + } + ProviderCommand::Show { name } => show(&name), + ProviderCommand::Add { + name, + base_url, + default_model, + env_key, + pricing_input, + pricing_output, + } => add( + &name, + base_url.as_deref(), + default_model.as_deref(), + env_key.as_deref(), + pricing_input.unwrap_or(0.0), + pricing_output.unwrap_or(0.0), + ), + ProviderCommand::Remove { name } => remove(&name), + } +} + +/// Load the global registry as an ordered JSON object (empty on missing/invalid). +fn load_registry() -> Map { + let path = graphify_llm::custom_providers_path(true); + std::fs::read_to_string(&path) + .ok() + .and_then(|t| serde_json::from_str::(&t).ok()) + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default() +} + +/// Write the registry back, pretty-printed with a trailing newline (matches Python). +fn save_registry(registry: &Map) -> Result<()> { + let path = graphify_llm::custom_providers_path(true); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let body = serde_json::to_string_pretty(&Value::Object(registry.clone()))?; + std::fs::write(&path, format!("{body}\n"))?; + Ok(()) +} + +fn list() { + let registry = load_registry(); + if registry.is_empty() { + println!("No custom providers registered."); + } else { + for (name, cfg) in ®istry { + let base = cfg + .get("base_url") + .and_then(Value::as_str) + .unwrap_or_default(); + println!(" {name} ({base})"); + } + } +} + +fn show(name: &str) -> Result<()> { + let registry = load_registry(); + let Some(cfg) = registry.get(name) else { + return Err(anyhow!("Provider '{name}' not found.")); + }; + let mut single = Map::new(); + single.insert(name.to_string(), cfg.clone()); + println!("{}", serde_json::to_string_pretty(&Value::Object(single))?); + Ok(()) +} + +fn add( + name: &str, + base_url: Option<&str>, + default_model: Option<&str>, + env_key: Option<&str>, + pricing_input: f64, + pricing_output: f64, +) -> Result<()> { + if graphify_llm::is_builtin_backend(name) { + return Err(anyhow!( + "Error: '{name}' is a built-in provider and cannot be overridden." + )); + } + let (Some(base_url), Some(default_model), Some(env_key)) = ( + base_url.filter(|s| !s.is_empty()), + default_model.filter(|s| !s.is_empty()), + env_key.filter(|s| !s.is_empty()), + ) else { + return Err(anyhow!( + "Error: --base-url, --default-model, and --env-key are required." + )); + }; + + let mut registry = load_registry(); + registry.insert( + name.to_string(), + json!({ + "base_url": base_url, + "default_model": default_model, + "env_key": env_key, + "pricing": {"input": pricing_input, "output": pricing_output}, + "temperature": 0, + }), + ); + save_registry(®istry)?; + println!("Provider '{name}' added. Use with: graphify extract . --backend {name}"); + Ok(()) +} + +fn remove(name: &str) -> Result<()> { + let mut registry = load_registry(); + if registry.shift_remove(name).is_none() { + return Err(anyhow!("Provider '{name}' not found.")); + } + save_registry(®istry)?; + println!("Provider '{name}' removed."); + Ok(()) +} diff --git a/tests/cli_commands.rs b/tests/cli_commands.rs index f095e9c..f140598 100644 --- a/tests/cli_commands.rs +++ b/tests/cli_commands.rs @@ -212,6 +212,29 @@ fn extract_incremental_mode_with_existing_manifest() { .stderr(contains("incremental scan")); } +/// Mirrors `test_no_incremental_without_manifest`: a first extract with no +/// manifest must run a full scan, never the incremental path. Asserts the +/// specific incremental-mode phrases are absent (a bare "incremental" would also +/// match the temp path or unrelated wording). +#[test] +fn extract_without_manifest_is_full_scan() { + let dir = tempfile::tempdir().unwrap(); + write_python_project(dir.path()); + let assert = cli_no_backend() + .args(["extract", dir.path().to_str().unwrap(), "--no-cluster"]) + .assert() + .success(); + let out = assert.get_output(); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) + .to_lowercase(); + assert!(!combined.contains("incremental update"), "{combined}"); + assert!(!combined.contains("incremental scan"), "{combined}"); +} + #[test] fn extract_default_mode_runs_full_pipeline() { let dir = tempfile::tempdir().unwrap(); @@ -513,3 +536,119 @@ fn check_update_prints_status() { fs::write(out.join("needs_update"), "1").unwrap(); cli().arg("check-update").arg(dir.path()).assert().success(); } + +// ── provider subcommand (#1084) ───────────────────────────────────────────── + +#[test] +fn provider_add_list_show_remove_round_trip() { + // `provider` writes to $HOME/.graphify/providers.json; point HOME at a temp + // dir so the round-trip is isolated. + let home = tempfile::tempdir().unwrap(); + + cli() + .env("HOME", home.path()) + .args([ + "provider", + "add", + "nvidia", + "--base-url", + "https://integrate.api.nvidia.com/v1", + "--default-model", + "minimaxai/minimax-m2.7", + "--env-key", + "NVIDIA_API_KEY", + ]) + .assert() + .success() + .stdout(contains("Provider 'nvidia' added")); + + cli() + .env("HOME", home.path()) + .args(["provider", "list"]) + .assert() + .success() + .stdout(contains("nvidia").and(contains("integrate.api.nvidia.com"))); + + cli() + .env("HOME", home.path()) + .args(["provider", "show", "nvidia"]) + .assert() + .success() + .stdout(contains("\"base_url\"")); + + cli() + .env("HOME", home.path()) + .args(["provider", "remove", "nvidia"]) + .assert() + .success() + .stdout(contains("Provider 'nvidia' removed")); + + cli() + .env("HOME", home.path()) + .args(["provider", "list"]) + .assert() + .success() + .stdout(contains("No custom providers registered.")); +} + +#[test] +fn provider_add_rejects_builtin_name() { + let home = tempfile::tempdir().unwrap(); + cli() + .env("HOME", home.path()) + .args([ + "provider", + "add", + "claude", + "--base-url", + "http://x/v1", + "--default-model", + "m", + "--env-key", + "K", + ]) + .assert() + .failure() + .stderr(contains("built-in provider")); +} + +#[test] +fn provider_show_missing_fails() { + let home = tempfile::tempdir().unwrap(); + cli() + .env("HOME", home.path()) + .args(["provider", "show", "ghost"]) + .assert() + .failure() + .stderr(contains("not found")); +} + +// ── label command shares the cluster-only handler (#1097) ─────────────────── + +#[test] +fn label_no_backend_keeps_placeholders() { + // With no backend configured, `label` degrades to `Community N` placeholders + // and still regenerates the report/labels file. + let dir = tempfile::tempdir().unwrap(); + write_python_project(dir.path()); + cli_no_backend() + .args(["extract", dir.path().to_str().unwrap(), "--no-cluster"]) + .assert() + .success(); + + cli_no_backend() + .args(["label", dir.path().to_str().unwrap(), "--no-viz"]) + .assert() + .success(); + + let labels = fs::read_to_string( + dir.path() + .join("graphify-out") + .join(".graphify_labels.json"), + ) + .unwrap(); + assert!( + labels.contains("Community"), + "expected placeholder labels: {labels}" + ); +} From b82bc9437d09e054f77df58774f3f1ee15f7d6c6 Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 13:12:15 +0200 Subject: [PATCH 03/34] Add coverage for custom-provider call paths and labeling Close the gaps the `cargo llvm-cov` pass surfaced in the v0.8.27 resync's new code: - `extract_files_direct` with a custom provider now has a mockito test exercising `extract_custom` (llm/extract.rs 45% -> 72% line). - Community labelling gains an end-to-end test through the public `label_communities` / `generate_community_labels` wrappers (via a mock custom provider), a `quiet=false` degradation path, and a prose-wrapped-JSON parse case (llm/labeling.rs 81% -> 90% line). Test-only; no production code changes. By the will of the Machine God --- crates/graphify-llm/tests/labeling.rs | 85 ++++++++++++++++++- .../graphify-llm/tests/provider_registry.rs | 48 ++++++++++- 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/crates/graphify-llm/tests/labeling.rs b/crates/graphify-llm/tests/labeling.rs index 7ccadd1..a36707f 100644 --- a/crates/graphify-llm/tests/labeling.rs +++ b/crates/graphify-llm/tests/labeling.rs @@ -7,7 +7,8 @@ use std::cell::Cell; use graphify_llm::{ - generate_community_labels, generate_community_labels_with, label_communities_with, + generate_community_labels, generate_community_labels_with, label_communities, + label_communities_with, }; use indexmap::{IndexMap, IndexSet}; @@ -74,6 +75,20 @@ fn label_communities_strips_code_fences() { assert_eq!(labels[&1], "Pay"); } +#[test] +fn label_communities_extracts_json_from_surrounding_prose() { + // A reply that wraps the JSON in prose is salvaged by slicing the first `{` + // to the last `}`. + let (node_labels, communities) = graph(); + let gods = IndexSet::new(); + let labels = label_communities_with(&communities, &node_labels, &gods, "gemini", |_, _, _| { + Ok("Here are the names: {\"0\":\"Orders\",\"1\":\"Pay\"} hope that helps".to_string()) + }) + .expect("labeling succeeds"); + assert_eq!(labels[&0], "Orders"); + assert_eq!(labels[&1], "Pay"); +} + #[test] fn label_communities_malformed_errors() { let (node_labels, communities) = graph(); @@ -136,6 +151,74 @@ fn generate_community_labels_no_backend() { assert_eq!(labels[&1], "Community 1"); } +#[test] +fn generate_community_labels_degrades_loud() { + // quiet=false exercises the warning branch; result is still placeholders. + let (node_labels, communities) = graph(); + let gods = IndexSet::new(); + let (labels, source) = generate_community_labels_with( + &communities, + &node_labels, + &gods, + Some("gemini"), + false, + |_, _, _| Ok("not json".to_string()), + ); + assert_eq!(source, "placeholder"); + assert_eq!(labels[&0], "Community 0"); +} + +#[test] +fn label_communities_real_path_via_custom_provider() { + // Exercise the public (non-`_with`) wrappers end-to-end: a custom provider + // pointed at a mock server drives `call_llm`, and both `label_communities` + // and `generate_community_labels` parse the reply. + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/chat/completions") + .with_status(200) + .with_body( + serde_json::json!({ + "choices": [{ + "message": {"content": "{\"0\":\"Orders\",\"1\":\"Payments\"}"}, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 3, "completion_tokens": 4} + }) + .to_string(), + ) + .expect_at_least(1) + .create(); + + let home = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(home.path().join(".graphify")).expect("mkdir"); + std::fs::write( + home.path().join(".graphify").join("providers.json"), + format!( + r#"{{"labelprov": {{"base_url": "{}", "default_model": "m", "env_key": "LABELPROV_KEY"}}}}"#, + server.url() + ), + ) + .expect("write providers.json"); + + let mut g = EnvGuard::new(); + g.set("HOME", &home.path().to_string_lossy()); + g.set("GRAPHIFY_TEST_ALLOW_PRIVATE_IPS", "1"); + g.set("LABELPROV_KEY", "secret"); + + let (node_labels, communities) = graph(); + let gods = IndexSet::new(); + + let labels = label_communities(&communities, &node_labels, &gods, "labelprov").expect("labels"); + assert_eq!(labels[&0], "Orders"); + assert_eq!(labels[&1], "Payments"); + + let (labels, source) = + generate_community_labels(&communities, &node_labels, &gods, Some("labelprov"), true); + assert_eq!(source, "llm"); + assert_eq!(labels[&0], "Orders"); +} + /// RAII guard that sets/restores env vars. struct EnvGuard { saved: Vec<(String, Option)>, diff --git a/crates/graphify-llm/tests/provider_registry.rs b/crates/graphify-llm/tests/provider_registry.rs index a00e91b..2fbae23 100644 --- a/crates/graphify-llm/tests/provider_registry.rs +++ b/crates/graphify-llm/tests/provider_registry.rs @@ -4,7 +4,8 @@ #![allow(clippy::expect_used, clippy::float_cmp, unsafe_code)] use graphify_llm::{ - CustomProvider, Pricing, call_llm, detect_backend_with, load_custom_providers_from, + CustomProvider, Pricing, call_llm, detect_backend_with, extract_files_direct, + load_custom_providers_from, }; use indexmap::IndexMap; use tempfile::tempdir; @@ -186,3 +187,48 @@ fn custom_provider_call_llm_routes_via_openai_compat() { let out = call_llm("ping", "custom1", 32).expect("custom provider call succeeds"); assert_eq!(out, "from custom"); } + +#[test] +fn custom_provider_extract_files_routes_via_openai_compat() { + // `extract_files_direct` with a custom provider must extract through the + // OpenAI-compatible client (#1084). + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/chat/completions") + .with_status(200) + .with_body( + serde_json::json!({ + "choices": [{ + "message": {"content": "{\"nodes\":[{\"id\":\"n1\"}],\"edges\":[]}"}, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 3, "completion_tokens": 4} + }) + .to_string(), + ) + .create(); + + let home = tempdir().expect("tempdir"); + std::fs::create_dir_all(home.path().join(".graphify")).expect("mkdir .graphify"); + std::fs::write( + home.path().join(".graphify").join("providers.json"), + format!( + r#"{{"custom2": {{"base_url": "{}", "default_model": "m", "env_key": "CUSTOM2_KEY"}}}}"#, + server.url() + ), + ) + .expect("write providers.json"); + + let work = tempdir().expect("workdir"); + let file = work.path().join("a.py"); + std::fs::write(&file, "def f():\n return 1\n").expect("write source"); + + let mut g = EnvGuard::new(); + g.set("HOME", &home.path().to_string_lossy()); + g.set("GRAPHIFY_TEST_ALLOW_PRIVATE_IPS", "1"); + g.set("CUSTOM2_KEY", "secret"); + + let resp = extract_files_direct(&[file], "custom2", None, None, work.path()) + .expect("custom provider extraction succeeds"); + assert_eq!(resp.nodes.len(), 1); +} From 807ab9f7adad94865a76f56794522637a3be943e Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 13:23:52 +0200 Subject: [PATCH 04/34] Address CodeRabbit findings on the provider registry Apply the two valid findings from the CodeRabbit review and document the two disputed ones. Fixed: - `custom_providers_path` falls back to the local path when `$HOME` is unset, and `load_custom_providers_from` reads identical local/global paths only once, so an unset `$HOME` no longer reads the same file twice (CodeRabbit, providers.rs). - The `provider` command now aborts when `providers.json` exists but is unreadable or not a JSON object, instead of silently overwriting it on `add`/`remove`. graphify-py clobbers a malformed file via a bare `except`; refusing to is a deliberate, safer divergence that prevents data loss (CodeRabbit, src/cli/provider.rs). Disputed (left as-is): - Provider load order `[local, global]` (global wins): this matches graphify-py `_load_custom_providers`, which iterates `(local, global)` and assigns `providers[name] = cfg`. The suggested "local wins" would diverge from the reference. - `load_custom_providers` per call in `extract_files_direct` / `call_llm`: only reached for custom (non-built-in) backends, since `is_builtin_backend` short-circuits built-ins without touching the filesystem. The file is tiny and correctness is unaffected; threading a process-lifetime cache through the parallel corpus path is not worth the added shared state. Tests cover the new branches (identical-path dedup, malformed-registry abort). Ave Deus Mechanicus --- crates/graphify-llm/src/providers.rs | 25 +++++++----- .../graphify-llm/tests/provider_registry.rs | 17 ++++++++ src/cli/provider.rs | 39 +++++++++++-------- tests/cli_commands.rs | 26 +++++++++++++ 4 files changed, 81 insertions(+), 26 deletions(-) diff --git a/crates/graphify-llm/src/providers.rs b/crates/graphify-llm/src/providers.rs index 083963f..23b2d28 100644 --- a/crates/graphify-llm/src/providers.rs +++ b/crates/graphify-llm/src/providers.rs @@ -36,14 +36,13 @@ pub struct CustomProvider { /// `_custom_providers_path`. #[must_use] pub fn custom_providers_path(global: bool) -> PathBuf { - if global { - home_dir() - .unwrap_or_default() - .join(".graphify") - .join("providers.json") - } else { - PathBuf::from(".graphify").join("providers.json") + // When `$HOME` is unset the global path falls back to the local one rather + // than resolving to a stray relative `.graphify/...` that would collide with + // — and be read twice as — the local path. + if global && let Some(home) = home_dir() { + return home.join(".graphify").join("providers.json"); } + PathBuf::from(".graphify").join("providers.json") } /// User home directory from `$HOME` (matches the detect crate's resolution). @@ -59,12 +58,18 @@ pub fn load_custom_providers() -> IndexMap { } /// Load custom providers from explicit `local`/`global` registry files (in that -/// order — a later file overrides an earlier one for the same name). Malformed -/// files are skipped silently, mirroring Python's broad `except`. +/// order — a later file overrides an earlier one for the same name, so `global` +/// wins, matching Python `_load_custom_providers`). Malformed files are skipped +/// silently, mirroring Python's broad `except`. Identical `local`/`global` paths +/// (e.g. when `$HOME` is unset) are read only once. #[must_use] pub fn load_custom_providers_from(local: &Path, global: &Path) -> IndexMap { let mut providers: IndexMap = IndexMap::new(); - for path in [local, global] { + let mut paths: Vec<&Path> = vec![local]; + if global != local { + paths.push(global); + } + for path in paths { let Ok(text) = std::fs::read_to_string(path) else { continue; }; diff --git a/crates/graphify-llm/tests/provider_registry.rs b/crates/graphify-llm/tests/provider_registry.rs index 2fbae23..2f3c891 100644 --- a/crates/graphify-llm/tests/provider_registry.rs +++ b/crates/graphify-llm/tests/provider_registry.rs @@ -66,6 +66,23 @@ fn custom_provider_load_returns_config() { ); } +#[test] +fn custom_provider_identical_local_global_path_read_once() { + // When `$HOME` is unset the local and global paths coincide; the registry + // must still be read only once (no double-insert / wasted read). + let tmp = tempdir().expect("tempdir"); + let path = tmp.path().join("providers.json"); + std::fs::write( + &path, + r#"{"only": {"base_url": "http://x/v1", "default_model": "m", "env_key": "K"}}"#, + ) + .expect("write providers.json"); + + let loaded = load_custom_providers_from(&path, &path); + assert_eq!(loaded.len(), 1); + assert!(loaded.contains_key("only")); +} + #[test] fn custom_provider_pricing_defaults_to_zero() { let tmp = tempdir().expect("tempdir"); diff --git a/src/cli/provider.rs b/src/cli/provider.rs index 18f6039..4e6f9a5 100644 --- a/src/cli/provider.rs +++ b/src/cli/provider.rs @@ -12,10 +12,7 @@ use crate::cli::args::ProviderCommand; /// Dispatch a `provider` subcommand. pub(crate) fn cmd_provider(cmd: ProviderCommand) -> Result<()> { match cmd { - ProviderCommand::List => { - list(); - Ok(()) - } + ProviderCommand::List => list(), ProviderCommand::Show { name } => show(&name), ProviderCommand::Add { name, @@ -36,14 +33,23 @@ pub(crate) fn cmd_provider(cmd: ProviderCommand) -> Result<()> { } } -/// Load the global registry as an ordered JSON object (empty on missing/invalid). -fn load_registry() -> Map { +/// Load the global registry as an ordered JSON object. A missing file yields an +/// empty registry; a file that exists but is unreadable or not a JSON object is +/// an error, so `add`/`remove` never clobber a malformed file (a divergence from +/// graphify-py, which silently overwrites it). +fn load_registry() -> Result> { let path = graphify_llm::custom_providers_path(true); - std::fs::read_to_string(&path) - .ok() - .and_then(|t| serde_json::from_str::(&t).ok()) - .and_then(|v| v.as_object().cloned()) - .unwrap_or_default() + if !path.is_file() { + return Ok(Map::new()); + } + let text = std::fs::read_to_string(&path) + .map_err(|e| anyhow!("cannot read {}: {e}", path.display()))?; + let value: Value = serde_json::from_str(&text) + .map_err(|e| anyhow!("malformed providers.json at {}: {e}", path.display()))?; + value + .as_object() + .cloned() + .ok_or_else(|| anyhow!("providers.json at {} is not a JSON object", path.display())) } /// Write the registry back, pretty-printed with a trailing newline (matches Python). @@ -57,8 +63,8 @@ fn save_registry(registry: &Map) -> Result<()> { Ok(()) } -fn list() { - let registry = load_registry(); +fn list() -> Result<()> { + let registry = load_registry()?; if registry.is_empty() { println!("No custom providers registered."); } else { @@ -70,10 +76,11 @@ fn list() { println!(" {name} ({base})"); } } + Ok(()) } fn show(name: &str) -> Result<()> { - let registry = load_registry(); + let registry = load_registry()?; let Some(cfg) = registry.get(name) else { return Err(anyhow!("Provider '{name}' not found.")); }; @@ -106,7 +113,7 @@ fn add( )); }; - let mut registry = load_registry(); + let mut registry = load_registry()?; registry.insert( name.to_string(), json!({ @@ -123,7 +130,7 @@ fn add( } fn remove(name: &str) -> Result<()> { - let mut registry = load_registry(); + let mut registry = load_registry()?; if registry.shift_remove(name).is_none() { return Err(anyhow!("Provider '{name}' not found.")); } diff --git a/tests/cli_commands.rs b/tests/cli_commands.rs index f140598..6c8a348 100644 --- a/tests/cli_commands.rs +++ b/tests/cli_commands.rs @@ -623,6 +623,32 @@ fn provider_show_missing_fails() { .stderr(contains("not found")); } +#[test] +fn provider_malformed_registry_is_not_clobbered() { + // A present-but-malformed providers.json must abort (rather than be silently + // overwritten), so the user's other providers aren't lost to a typo. + let home = tempfile::tempdir().unwrap(); + let cfg = home.path().join(".graphify"); + fs::create_dir_all(&cfg).unwrap(); + fs::write(cfg.join("providers.json"), "{ this is not json").unwrap(); + cli() + .env("HOME", home.path()) + .args([ + "provider", + "add", + "nvidia", + "--base-url", + "http://x/v1", + "--default-model", + "m", + "--env-key", + "K", + ]) + .assert() + .failure() + .stderr(contains("malformed")); +} + // ── label command shares the cluster-only handler (#1097) ─────────────────── #[test] From 623cc54054056840ec10d0438ac865df0ed63874 Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 13:37:22 +0200 Subject: [PATCH 05/34] Apply second CodeRabbit round; document disputes Fixed: - Rename the `ts_inheritance` test helper `has_inherits` to `has_edge` since it takes an arbitrary relation argument. - Use `sort_by_cached_key` for the deterministic file sort in `detect::walk` so each path is stringified once, not per comparison. - Annotate the env-mutating provider/labeling tests with `#[serial]` so they are safe under `cargo test` (thread-per-test), not only under nextest (process-per-test). - Make the community-labelling `max_tokens` arithmetic fully saturating. Disputed (left as-is, parity with graphify-py): - `is_included` anchored matching uses a direct fnmatch with no ancestor walk. This is exactly the #1087 change in graphify-py `_is_included`; adding an ancestor walk would revert that fix for the include path. - `cluster-only` regenerates `Community N` placeholders when an existing `.graphify_labels.json` fails to parse. graphify-py does the same, and the labels file is a regenerable cache, not user-authored config (unlike `providers.json`, whose clobber we did guard against). - The `ts_inheritance` per-test boilerplate is left inline for readability (trivial). By the will of the Machine God --- crates/graphify-detect/src/walk.rs | 2 +- .../graphify-extract/tests/ts_inheritance.rs | 18 +++++++++--------- crates/graphify-llm/src/labeling.rs | 5 ++++- crates/graphify-llm/tests/labeling.rs | 3 +++ crates/graphify-llm/tests/provider_registry.rs | 4 ++++ 5 files changed, 21 insertions(+), 11 deletions(-) diff --git a/crates/graphify-detect/src/walk.rs b/crates/graphify-detect/src/walk.rs index 0f87be3..2831917 100644 --- a/crates/graphify-detect/src/walk.rs +++ b/crates/graphify-detect/src/walk.rs @@ -480,7 +480,7 @@ fn run_walk_phase(ctx: &WalkCtx<'_>, root: &Path, memory_dir: &Path) -> Vec String { make_id(&[&file_stem(Path::new(file)), sym]) } -fn has_inherits( +fn has_edge( edges: &[indexmap::IndexMap], src_file: &str, src_sym: &str, @@ -56,7 +56,7 @@ fn interface_extends_same_file() { export interface Derived extends Base { y: number; }\n", ); let result = extract(&[f], Some(&root)); - assert!(has_inherits( + assert!(has_edge( &result.edges, "src/a.ts", "Derived", @@ -77,7 +77,7 @@ fn interface_extends_multiple_same_file() { interface M extends A, B { m: number; }\n", ); let result = extract(&[f], Some(&root)); - assert!(has_inherits( + assert!(has_edge( &result.edges, "src/a.ts", "M", @@ -85,7 +85,7 @@ fn interface_extends_multiple_same_file() { "A", "inherits" )); - assert!(has_inherits( + assert!(has_edge( &result.edges, "src/a.ts", "M", @@ -104,7 +104,7 @@ fn class_extends_same_file() { "class Animal {}\nclass Dog extends Animal {}\n", ); let result = extract(&[f], Some(&root)); - assert!(has_inherits( + assert!(has_edge( &result.edges, "src/a.ts", "Dog", @@ -124,7 +124,7 @@ fn interface_extends_generic_base_same_file() { interface G extends Base { y: number; }\n", ); let result = extract(&[f], Some(&root)); - assert!(has_inherits( + assert!(has_edge( &result.edges, "src/a.ts", "G", @@ -151,7 +151,7 @@ fn interface_extends_imported() { &[root.join("src").join("a.ts"), root.join("src").join("b.ts")], Some(&root), ); - assert!(has_inherits( + assert!(has_edge( &result.edges, "src/a.ts", "D", @@ -175,7 +175,7 @@ fn imported_class_extends_still_works() { &[root.join("src").join("a.ts"), root.join("src").join("b.ts")], Some(&root), ); - assert!(has_inherits( + assert!(has_edge( &result.edges, "src/a.ts", "Cat", @@ -195,7 +195,7 @@ fn class_implements_same_file_interface() { class Person implements Walker { walk() {} }\n", ); let result = extract(&[f], Some(&root)); - assert!(has_inherits( + assert!(has_edge( &result.edges, "src/a.ts", "Person", diff --git a/crates/graphify-llm/src/labeling.rs b/crates/graphify-llm/src/labeling.rs index 0cf75a2..65c178b 100644 --- a/crates/graphify-llm/src/labeling.rs +++ b/crates/graphify-llm/src/labeling.rs @@ -188,7 +188,10 @@ where ); let labeled_len = u32::try_from(labeled_cids.len()).unwrap_or(u32::MAX); - let max_tokens = (40 + 16u32.saturating_mul(labeled_len)).min(4096); + let max_tokens = 16u32 + .saturating_mul(labeled_len) + .saturating_add(40) + .min(4096); let text = call(&prompt, backend, max_tokens)?; labels.extend(parse_label_response(&text, &labeled_cids)?); Ok(labels) diff --git a/crates/graphify-llm/tests/labeling.rs b/crates/graphify-llm/tests/labeling.rs index a36707f..bbdf3da 100644 --- a/crates/graphify-llm/tests/labeling.rs +++ b/crates/graphify-llm/tests/labeling.rs @@ -11,6 +11,7 @@ use graphify_llm::{ label_communities_with, }; use indexmap::{IndexMap, IndexSet}; +use serial_test::serial; /// community 0 = ordering, community 1 = payments. fn graph() -> (IndexMap, IndexMap>) { @@ -117,6 +118,7 @@ fn generate_community_labels_degrades_on_error() { } #[test] +#[serial] fn generate_community_labels_no_backend() { // With backend=None and no env keys / no providers.json, detect_backend() // returns None and the real wrapper degrades to placeholders without any LLM @@ -169,6 +171,7 @@ fn generate_community_labels_degrades_loud() { } #[test] +#[serial] fn label_communities_real_path_via_custom_provider() { // Exercise the public (non-`_with`) wrappers end-to-end: a custom provider // pointed at a mock server drives `call_llm`, and both `label_communities` diff --git a/crates/graphify-llm/tests/provider_registry.rs b/crates/graphify-llm/tests/provider_registry.rs index 2f3c891..99b8bd6 100644 --- a/crates/graphify-llm/tests/provider_registry.rs +++ b/crates/graphify-llm/tests/provider_registry.rs @@ -8,6 +8,7 @@ use graphify_llm::{ load_custom_providers_from, }; use indexmap::IndexMap; +use serial_test::serial; use tempfile::tempdir; /// RAII guard that sets/restores env vars (mirrors the HTTP tests' guard). @@ -126,6 +127,7 @@ fn custom_provider_cannot_shadow_builtin() { } #[test] +#[serial] fn detect_backend_custom_provider_after_builtins() { let mut g = EnvGuard::new(); for key in [ @@ -168,6 +170,7 @@ fn detect_backend_custom_provider_after_builtins() { } #[test] +#[serial] fn custom_provider_call_llm_routes_via_openai_compat() { // A custom provider registered in $HOME/.graphify/providers.json must drive a // plain call through the OpenAI-compatible client (#1084). nextest isolates @@ -206,6 +209,7 @@ fn custom_provider_call_llm_routes_via_openai_compat() { } #[test] +#[serial] fn custom_provider_extract_files_routes_via_openai_compat() { // `extract_files_direct` with a custom provider must extract through the // OpenAI-compatible client (#1084). From b1baafb2eca5dea59a4830f8aa9201f81a4df340 Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 13:47:07 +0200 Subject: [PATCH 06/34] Apply third CodeRabbit round; dispute sha1 false positive Fixed (all trivial): - Atomic registry write in `provider`: write a sibling temp file then rename, so a crash mid-write cannot truncate `providers.json`. - Share one `EnvGuard` across the `graphify-llm` integration tests via `tests/common/mod.rs` instead of duplicating it. - Add a clarifying comment on the `find_import_cycles` edge-orientation fallback. Disputed (left as-is): - `sha1 = "0.11"` does NOT duplicate `digest`: `cargo tree` shows both `sha1 0.11` and `sha2 0.11` resolve to `digest 0.11.3`. Downgrading to `sha1 0.10` would pull in `digest 0.10` and create the duplication CodeRabbit warned about, so the suggestion is inverted. - The per-call `load_custom_providers` in `extract_files_direct` / `call_llm` only runs for custom backends (built-ins short-circuit via `is_builtin_backend` with no filesystem access); threading a cache through the parallel corpus path is not worth the shared state. Glory to the Omnissiah --- crates/graphify-analyze/src/cycles.rs | 4 ++ crates/graphify-llm/tests/common/mod.rs | 37 +++++++++++++++++++ crates/graphify-llm/tests/labeling.rs | 33 ++--------------- .../graphify-llm/tests/provider_registry.rs | 31 +--------------- src/cli/provider.rs | 6 ++- 5 files changed, 51 insertions(+), 60 deletions(-) create mode 100644 crates/graphify-llm/tests/common/mod.rs diff --git a/crates/graphify-analyze/src/cycles.rs b/crates/graphify-analyze/src/cycles.rs index adb7b64..c9eaa98 100644 --- a/crates/graphify-analyze/src/cycles.rs +++ b/crates/graphify-analyze/src/cycles.rs @@ -121,6 +121,10 @@ fn build_file_graph(graph: &Graph) -> IndexMap> { v_file } else if v_file == src_file { u_file + // Fallback: neither endpoint's source_file equals the edge's (e.g. path + // normalisation mismatch or inconsistent data). Prefer the non-empty, + // non-source endpoint, else fall back to `u_file`, so orientation still + // resolves to a valid target file. } else if !v_file.is_empty() && v_file != src_file { v_file } else { diff --git a/crates/graphify-llm/tests/common/mod.rs b/crates/graphify-llm/tests/common/mod.rs new file mode 100644 index 0000000..52815e1 --- /dev/null +++ b/crates/graphify-llm/tests/common/mod.rs @@ -0,0 +1,37 @@ +//! Shared test helpers for the `graphify-llm` integration tests. +#![allow(dead_code, unsafe_code)] + +/// RAII guard that sets/restores process environment variables. Restored in +/// reverse order on drop. Tests that use it must be `#[serial]`. +pub struct EnvGuard { + saved: Vec<(String, Option)>, +} + +impl EnvGuard { + pub fn new() -> Self { + Self { saved: vec![] } + } + + pub fn set(&mut self, k: &str, v: &str) -> &mut Self { + self.saved.push((k.to_string(), std::env::var(k).ok())); + unsafe { std::env::set_var(k, v) }; + self + } + + pub fn unset(&mut self, k: &str) -> &mut Self { + self.saved.push((k.to_string(), std::env::var(k).ok())); + unsafe { std::env::remove_var(k) }; + self + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + for (k, prev) in self.saved.drain(..).rev() { + match prev { + Some(v) => unsafe { std::env::set_var(&k, &v) }, + None => unsafe { std::env::remove_var(&k) }, + } + } + } +} diff --git a/crates/graphify-llm/tests/labeling.rs b/crates/graphify-llm/tests/labeling.rs index bbdf3da..54df0f5 100644 --- a/crates/graphify-llm/tests/labeling.rs +++ b/crates/graphify-llm/tests/labeling.rs @@ -13,6 +13,9 @@ use graphify_llm::{ use indexmap::{IndexMap, IndexSet}; use serial_test::serial; +mod common; +use common::EnvGuard; + /// community 0 = ordering, community 1 = payments. fn graph() -> (IndexMap, IndexMap>) { let mut node_labels = IndexMap::new(); @@ -222,36 +225,6 @@ fn label_communities_real_path_via_custom_provider() { assert_eq!(labels[&0], "Orders"); } -/// RAII guard that sets/restores env vars. -struct EnvGuard { - saved: Vec<(String, Option)>, -} -impl EnvGuard { - fn new() -> Self { - Self { saved: vec![] } - } - fn set(&mut self, k: &str, v: &str) -> &mut Self { - self.saved.push((k.to_string(), std::env::var(k).ok())); - unsafe { std::env::set_var(k, v) }; - self - } - fn unset(&mut self, k: &str) -> &mut Self { - self.saved.push((k.to_string(), std::env::var(k).ok())); - unsafe { std::env::remove_var(k) }; - self - } -} -impl Drop for EnvGuard { - fn drop(&mut self) { - for (k, prev) in self.saved.drain(..).rev() { - match prev { - Some(v) => unsafe { std::env::set_var(&k, &v) }, - None => unsafe { std::env::remove_var(&k) }, - } - } - } -} - #[test] fn generate_community_labels_success() { let (node_labels, communities) = graph(); diff --git a/crates/graphify-llm/tests/provider_registry.rs b/crates/graphify-llm/tests/provider_registry.rs index 99b8bd6..af80461 100644 --- a/crates/graphify-llm/tests/provider_registry.rs +++ b/crates/graphify-llm/tests/provider_registry.rs @@ -11,35 +11,8 @@ use indexmap::IndexMap; use serial_test::serial; use tempfile::tempdir; -/// RAII guard that sets/restores env vars (mirrors the HTTP tests' guard). -struct EnvGuard { - saved: Vec<(String, Option)>, -} -impl EnvGuard { - fn new() -> Self { - Self { saved: vec![] } - } - fn set(&mut self, k: &str, v: &str) -> &mut Self { - self.saved.push((k.to_string(), std::env::var(k).ok())); - unsafe { std::env::set_var(k, v) }; - self - } - fn unset(&mut self, k: &str) -> &mut Self { - self.saved.push((k.to_string(), std::env::var(k).ok())); - unsafe { std::env::remove_var(k) }; - self - } -} -impl Drop for EnvGuard { - fn drop(&mut self) { - for (k, prev) in self.saved.drain(..).rev() { - match prev { - Some(v) => unsafe { std::env::set_var(&k, &v) }, - None => unsafe { std::env::remove_var(&k) }, - } - } - } -} +mod common; +use common::EnvGuard; #[test] fn custom_provider_load_returns_config() { diff --git a/src/cli/provider.rs b/src/cli/provider.rs index 4e6f9a5..657a0f6 100644 --- a/src/cli/provider.rs +++ b/src/cli/provider.rs @@ -59,7 +59,11 @@ fn save_registry(registry: &Map) -> Result<()> { std::fs::create_dir_all(parent)?; } let body = serde_json::to_string_pretty(&Value::Object(registry.clone()))?; - std::fs::write(&path, format!("{body}\n"))?; + // Write to a sibling temp file then rename, so a crash mid-write can't leave + // a truncated/corrupt registry (rename is atomic on the same filesystem). + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, format!("{body}\n"))?; + std::fs::rename(&tmp, &path)?; Ok(()) } From fa496eac00efc55c4ade612da2849e0b39ee3f4a Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 13:57:28 +0200 Subject: [PATCH 07/34] Use slice::first in import-cycles renderer Replace the post-empty-check index `c.cycle[0]` with a `let else` on `c.cycle.first()` so the non-empty invariant is explicit and there is no direct indexing (CodeRabbit, trivial). Ave Deus Mechanicus --- crates/graphify-report/src/sections/cycles.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/graphify-report/src/sections/cycles.rs b/crates/graphify-report/src/sections/cycles.rs index 98d9aa5..3cdbb7a 100644 --- a/crates/graphify-report/src/sections/cycles.rs +++ b/crates/graphify-report/src/sections/cycles.rs @@ -21,11 +21,11 @@ pub(crate) fn render_import_cycles(lines: &mut Vec, graph: &Graph) { } for c in &cycles { - if c.cycle.is_empty() { + let Some(first) = c.cycle.first() else { continue; - } + }; let mut path = c.cycle.clone(); - path.push(c.cycle[0].clone()); + path.push(first.clone()); let cycle_path = path.join(" -> "); lines.push(format!("- {}-file cycle: `{cycle_path}`", c.length)); } From d2f80eb2f187a2befcf5a684c91e7b0f379c068c Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 14:38:34 +0200 Subject: [PATCH 08/34] Address CodeRabbit review on resync to v0.8.27 Applies the still-valid findings from CodeRabbit's review and documents the verified false positives. Fixed: - `find_import_cycles`: visit start files and neighbours in lexicographic order so the `cap` truncation collects a deterministic set by graph content, not edge-insertion order. - `find_import_cycles_bounded`: return early for `max_cycle_length == 0` so self-loops (length 1) cannot leak past a zero bound, matching Python's `len(cycle) <= max_cycle_length` filter. - Provider registry loader: skip records missing or blanking any of `base_url` / `default_model` / `env_key`, mirroring the `provider add` contract that already requires all three; a half-formed provider can never authenticate or address an endpoint. - `relativise_under_root` and `EnvGuard::new` gain `#[must_use]`. - `file_node_id_spec.rs` tests now return `Result` and propagate with `?` instead of a blanket `expect_used` allow. - `cli_commands.rs`: pass temp paths via `.arg(path)` rather than `to_str().unwrap()`. Added parity coverage: - `re_exports`-only cycle detection (treated identically to `imports_from`). - Zero `max_cycle_length` returns no cycles. - Provider records missing a required field are skipped. Disputed (verified false positives, left unchanged): - cluster-only `--no-label` precedence and malformed-labels overwrite: both match `graphify-py/__main__.py:2417-2448` exactly (existing file wins over `--no-label`; a parse failure falls back to placeholders and the file is rewritten unconditionally). The labels file is a regenerable cache, unlike the user-authored `providers.json`. - `prefix_remap` key normalisation: keys and the `n.source_file` lookup are the identical absolute-path string at that point (relativisation runs later), so switching to `file_node_id` keys would only add cross -file collision risk. - pnpm `.`-package edge assertion: the upstream test asserts only crash safety (no `IndexError`); there is no `main` entry to resolve `my-app` to deterministically. Glory to the Omnissiah --- crates/graphify-analyze/src/cycles.rs | 23 ++++++- crates/graphify-analyze/tests/parity.rs | 27 ++++++++ .../graphify-extract/src/extractors/multi.rs | 1 + .../tests/file_node_id_spec.rs | 67 +++++++++++-------- crates/graphify-llm/src/providers.rs | 31 ++++----- crates/graphify-llm/tests/common/mod.rs | 1 + .../graphify-llm/tests/provider_registry.rs | 27 ++++++++ tests/cli_commands.rs | 12 +++- 8 files changed, 139 insertions(+), 50 deletions(-) diff --git a/crates/graphify-analyze/src/cycles.rs b/crates/graphify-analyze/src/cycles.rs index c9eaa98..283ddbc 100644 --- a/crates/graphify-analyze/src/cycles.rs +++ b/crates/graphify-analyze/src/cycles.rs @@ -50,6 +50,14 @@ pub fn find_import_cycles_bounded( max_cycle_length: usize, top_n: usize, ) -> Vec { + // Every cycle spans at least one file, so a zero bound admits none. Return + // early — mirrors Python's `len(cycle) <= max_cycle_length` rejecting every + // cycle at max=0, and stops a self-loop (length 1) from leaking past the + // documented bound. + if max_cycle_length == 0 { + return Vec::new(); + } + let adj = build_file_graph(graph); if adj.values().all(IndexSet::is_empty) { return Vec::new(); @@ -152,7 +160,12 @@ fn enumerate_cycles( cap: usize, ) -> Vec> { let mut out: Vec> = Vec::new(); - for start in adj.keys() { + // Visit start files in lexicographic order so that, when `cap` truncates a + // combinatorially large set, the cycles collected are a deterministic + // function of graph *content* rather than edge-insertion order. + let mut starts: Vec<&String> = adj.keys().collect(); + starts.sort(); + for start in starts { if out.len() >= cap { break; } @@ -194,8 +207,12 @@ fn dfs_cycles<'a>( let Some(neighbors) = adj.get(current) else { return; }; - for next in neighbors { - let next = next.as_str(); + // Sorted neighbour traversal keeps cap-truncation deterministic by content + // (see `enumerate_cycles`). Neighbour sets are small (file-level fan-out), + // so sorting per visit is cheap. + let mut nbrs: Vec<&str> = neighbors.iter().map(String::as_str).collect(); + nbrs.sort_unstable(); + for next in nbrs { if next == start { // Closing edge → `path` is a complete normalised cycle. out.push(path.iter().map(|s| (*s).to_string()).collect()); diff --git a/crates/graphify-analyze/tests/parity.rs b/crates/graphify-analyze/tests/parity.rs index 8a37b10..e14d379 100644 --- a/crates/graphify-analyze/tests/parity.rs +++ b/crates/graphify-analyze/tests/parity.rs @@ -1841,6 +1841,13 @@ fn find_import_cycles_respects_max_cycle_length() { assert!(cycles.iter().all(|c| c.length <= 2)); } +/// A zero max length admits no cycles — not even self-loops (length 1). +#[test] +fn find_import_cycles_zero_max_length_returns_none() { + let g = make_cycle_graph(GraphKind::DiGraph); + assert!(find_import_cycles_bounded(&g, 0, 20).is_empty()); +} + /// `test_find_import_cycles_skips_nodes_without_source_file` #[test] fn find_import_cycles_skips_nodes_without_source_file() { @@ -1875,6 +1882,26 @@ fn find_import_cycles_ignores_non_import_relations() { assert!(find_import_cycles(&g).is_empty()); } +/// `re_exports` edges are import-like and must close cycles too — Python treats +/// them identically to `imports_from` in `find_import_cycles` (#961). +#[test] +fn find_import_cycles_detects_re_exports_cycle() { + let mut g = Graph::new(GraphKind::DiGraph); + cycle_node(&mut g, "a", "a.ts", Some("src/a.ts")); + cycle_node(&mut g, "b", "b.ts", Some("src/b.ts")); + // 2-cycle formed entirely via re_exports rather than imports_from. + cycle_edge(&mut g, "a", "b", "re_exports", "src/a.ts", "EXTRACTED"); + cycle_edge(&mut g, "b", "a", "re_exports", "src/b.ts", "EXTRACTED"); + let cycles = find_import_cycles(&g); + assert!( + cycles.iter().any(|c| { + let s: std::collections::HashSet<&str> = c.cycle.iter().map(String::as_str).collect(); + s.contains("src/a.ts") && s.contains("src/b.ts") + }), + "re_exports cycle a<->b not detected: {cycles:?}" + ); +} + /// `test_find_import_cycles_empty_graph` #[test] fn find_import_cycles_empty_graph() { diff --git a/crates/graphify-extract/src/extractors/multi.rs b/crates/graphify-extract/src/extractors/multi.rs index 186aed6..5147dfa 100644 --- a/crates/graphify-extract/src/extractors/multi.rs +++ b/crates/graphify-extract/src/extractors/multi.rs @@ -692,6 +692,7 @@ fn resolve_cross_file_java_imports(per_file: &[FileResult], paths: &[PathBuf]) - /// Mirrors Python's `path.relative_to(root)` with its /// `path.resolve().relative_to(root)` fallback. Returns `None` only when the /// path is genuinely outside `root`. +#[must_use] fn relativise_under_root(path: &Path, root: &Path) -> Option { if let Ok(rel) = path.strip_prefix(root) { return Some(rel.to_path_buf()); diff --git a/crates/graphify-extract/tests/file_node_id_spec.rs b/crates/graphify-extract/tests/file_node_id_spec.rs index d136cbf..b4e1291 100644 --- a/crates/graphify-extract/tests/file_node_id_spec.rs +++ b/crates/graphify-extract/tests/file_node_id_spec.rs @@ -14,9 +14,9 @@ //! match/script/pipeline_step.py (file node) -> script_pipeline_step //! setup.py (top-level) -> setup //! ``` -#![allow(clippy::expect_used)] use std::collections::HashSet; +use std::error::Error; use std::path::Path; use graphify_extract::extract; @@ -24,6 +24,8 @@ use indexmap::IndexMap; use serde_json::Value; use tempfile::tempdir; +type TestResult = Result<(), Box>; + /// All node ids in the extraction result. fn ids(nodes: &[IndexMap]) -> HashSet { nodes @@ -32,18 +34,19 @@ fn ids(nodes: &[IndexMap]) -> HashSet { .collect() } -fn write(path: &Path, text: &str) { - std::fs::create_dir_all(path.parent().expect("has parent")).expect("create dirs"); - std::fs::write(path, text).expect("write file"); +fn write(path: &Path, text: &str) -> TestResult { + std::fs::create_dir_all(path.parent().ok_or("path has no parent")?)?; + std::fs::write(path, text)?; + Ok(()) } #[test] -fn file_node_id_uses_parent_dir_and_stem_no_extension() { +fn file_node_id_uses_parent_dir_and_stem_no_extension() -> TestResult { // match/script/pipeline_step.py -> file node id 'script_pipeline_step'. - let tmp = tempdir().expect("tempdir"); - let root = tmp.path().canonicalize().expect("canonicalize"); + let tmp = tempdir()?; + let root = tmp.path().canonicalize()?; let f = root.join("match").join("script").join("pipeline_step.py"); - write(&f, "def run():\n pass\n"); + write(&f, "def run():\n pass\n")?; let result = extract(&[f], Some(&root)); let ids = ids(&result.nodes); @@ -58,15 +61,16 @@ fn file_node_id_uses_parent_dir_and_stem_no_extension() { !ids.iter() .any(|i| i.ends_with("_py") && i.contains("pipeline_step")) ); + Ok(()) } #[test] -fn top_level_file_node_id_is_bare_stem() { +fn top_level_file_node_id_is_bare_stem() -> TestResult { // A file directly at the project root collapses to just its stem. - let tmp = tempdir().expect("tempdir"); - let root = tmp.path().canonicalize().expect("canonicalize"); + let tmp = tempdir()?; + let root = tmp.path().canonicalize()?; let f = root.join("setup.py"); - write(&f, "def configure():\n pass\n"); + write(&f, "def configure():\n pass\n")?; let result = extract(&[f], Some(&root)); let ids = ids(&result.nodes); @@ -76,16 +80,17 @@ fn top_level_file_node_id_is_bare_stem() { "expected bare stem 'setup', got {ids:?}" ); assert!(!ids.contains("setup_py")); + Ok(()) } #[test] -fn top_level_file_symbol_ids_use_bare_stem() { +fn top_level_file_symbol_ids_use_bare_stem() -> TestResult { // A SYMBOL in a root-level file must use the bare-stem prefix (`setup_configure`), // not pick up the project-root directory name (`_setup_configure`) (#1096). - let tmp = tempdir().expect("tempdir"); - let root = tmp.path().canonicalize().expect("canonicalize"); + let tmp = tempdir()?; + let root = tmp.path().canonicalize()?; let f = root.join("main.py"); - write(&f, "def run():\n return 1\n"); + write(&f, "def run():\n return 1\n")?; let result = extract(&[f], Some(&root)); let ids = ids(&result.nodes); @@ -118,31 +123,33 @@ fn top_level_file_symbol_ids_use_bare_stem() { contains[0].get("source").and_then(Value::as_str), Some("main") ); + Ok(()) } #[test] -fn nested_file_symbol_ids_unchanged() { +fn nested_file_symbol_ids_unchanged() -> TestResult { // Regression guard: nested files (immediate parent identical in abs/rel form) // must be completely unaffected by the symbol remap. - let tmp = tempdir().expect("tempdir"); - let root = tmp.path().canonicalize().expect("canonicalize"); + let tmp = tempdir()?; + let root = tmp.path().canonicalize()?; let f = root.join("sub").join("mod.py"); - write(&f, "def work():\n return 2\n"); + write(&f, "def work():\n return 2\n")?; let result = extract(&[f], Some(&root)); let ids = ids(&result.nodes); assert!(ids.contains("sub_mod")); assert!(ids.contains("sub_mod_work")); + Ok(()) } #[test] -fn symbol_and_file_ids_share_the_same_stem() { +fn symbol_and_file_ids_share_the_same_stem() -> TestResult { // Symbol ids already use {parent}_{stem}_{name}; the file node must share // that stem prefix so 'contains' edges connect file -> symbol. - let tmp = tempdir().expect("tempdir"); - let root = tmp.path().canonicalize().expect("canonicalize"); + let tmp = tempdir()?; + let root = tmp.path().canonicalize()?; let f = root.join("match").join("script").join("pipeline_step.py"); - write(&f, "def run():\n pass\n\nclass Stage:\n pass\n"); + write(&f, "def run():\n pass\n\nclass Stage:\n pass\n")?; let result = extract(&[f], Some(&root)); let ids = ids(&result.nodes); @@ -167,19 +174,20 @@ fn symbol_and_file_ids_share_the_same_stem() { contains[0].get("source").and_then(Value::as_str), Some("script_pipeline_step"), ); + Ok(()) } #[test] -fn cross_file_import_edges_stay_connected() { +fn cross_file_import_edges_stay_connected() -> TestResult { // Changing the file-id format must not orphan import edges. - let tmp = tempdir().expect("tempdir"); - let root = tmp.path().canonicalize().expect("canonicalize"); + let tmp = tempdir()?; + let root = tmp.path().canonicalize()?; let pkg = root.join("pkg"); - write(&pkg.join("models.py"), "class User:\n pass\n"); + write(&pkg.join("models.py"), "class User:\n pass\n")?; write( &pkg.join("auth.py"), "from models import User\n\nclass Session:\n def check(self):\n return User()\n", - ); + )?; let files = vec![pkg.join("models.py"), pkg.join("auth.py")]; let result = extract(&files, Some(&root)); @@ -198,4 +206,5 @@ fn cross_file_import_edges_stay_connected() { ); } } + Ok(()) } diff --git a/crates/graphify-llm/src/providers.rs b/crates/graphify-llm/src/providers.rs index 23b2d28..0fffbd3 100644 --- a/crates/graphify-llm/src/providers.rs +++ b/crates/graphify-llm/src/providers.rs @@ -83,6 +83,19 @@ pub fn load_custom_providers_from(local: &Path, global: &Path) -> IndexMap IndexMap Self { Self { saved: vec![] } } diff --git a/crates/graphify-llm/tests/provider_registry.rs b/crates/graphify-llm/tests/provider_registry.rs index af80461..4ac0a8c 100644 --- a/crates/graphify-llm/tests/provider_registry.rs +++ b/crates/graphify-llm/tests/provider_registry.rs @@ -79,6 +79,33 @@ fn custom_provider_pricing_defaults_to_zero() { assert_eq!(loaded["mymodel"].pricing.output, 0.0); } +#[test] +fn custom_provider_missing_required_field_is_skipped() { + // `provider add` rejects a record missing base_url/default_model/env_key, so + // a hand-edited registry entry that omits one is non-functional. The loader + // must skip it rather than insert a half-formed provider. + let tmp = tempdir().expect("tempdir"); + let global = tmp.path().join("providers.json"); + std::fs::write( + &global, + r#"{ + "no_url": {"default_model": "m", "env_key": "K"}, + "no_model": {"base_url": "http://x/v1", "env_key": "K"}, + "no_key": {"base_url": "http://x/v1", "default_model": "m"}, + "blank_url":{"base_url": "", "default_model": "m", "env_key": "K"}, + "good": {"base_url": "http://x/v1", "default_model": "m", "env_key": "K"} + }"#, + ) + .expect("write providers.json"); + + let loaded = load_custom_providers_from(&tmp.path().join("local.json"), &global); + assert_eq!(loaded.len(), 1); + assert!(loaded.contains_key("good")); + for skipped in ["no_url", "no_model", "no_key", "blank_url"] { + assert!(!loaded.contains_key(skipped), "{skipped} should be skipped"); + } +} + #[test] fn custom_provider_cannot_shadow_builtin() { let tmp = tempdir().expect("tempdir"); diff --git a/tests/cli_commands.rs b/tests/cli_commands.rs index 6c8a348..601b080 100644 --- a/tests/cli_commands.rs +++ b/tests/cli_commands.rs @@ -221,7 +221,9 @@ fn extract_without_manifest_is_full_scan() { let dir = tempfile::tempdir().unwrap(); write_python_project(dir.path()); let assert = cli_no_backend() - .args(["extract", dir.path().to_str().unwrap(), "--no-cluster"]) + .arg("extract") + .arg(dir.path()) + .arg("--no-cluster") .assert() .success(); let out = assert.get_output(); @@ -658,12 +660,16 @@ fn label_no_backend_keeps_placeholders() { let dir = tempfile::tempdir().unwrap(); write_python_project(dir.path()); cli_no_backend() - .args(["extract", dir.path().to_str().unwrap(), "--no-cluster"]) + .arg("extract") + .arg(dir.path()) + .arg("--no-cluster") .assert() .success(); cli_no_backend() - .args(["label", dir.path().to_str().unwrap(), "--no-viz"]) + .arg("label") + .arg(dir.path()) + .arg("--no-viz") .assert() .success(); From 9f096053c0b479d847e98d9ec05e8152e65b287d Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 14:49:11 +0200 Subject: [PATCH 09/34] Address second CodeRabbit round on v0.8.27 resync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed: - Custom providers now honour a `max_completion_tokens` field from `providers.json` on the extraction path (default 8192), mirroring Python's `_resolve_max_tokens(cfg.get("max_completion_tokens", 8192))` at `llm.py:720`. Previously the Rust port hardcoded 8192, so a hand-tuned custom provider budget was silently ignored. - `build_file_graph`: drop the dead `&& v_file != src_file` guard in the orientation fallback — the preceding arm already rules out `v_file == src_file`. - `EnvGuard` gains a `Default` impl alongside `new`. Added parity coverage for `max_completion_tokens` parse + default. Disputed (verified false positives, left unchanged): - `sha1` / `sha2` digest-major split: `cargo tree` shows both crates at `0.11.0` resolving to a single `digest v0.11.3`; there is no split and `util.rs`/`json.rs` each use their own crate's identical `Digest`. - Caching `load_custom_providers()` in a process-global `LazyLock`: would capture `$HOME` at first access and make the `#[serial]` env-mutating tests order-dependent under a shared-process runner. The redundant read is bounded to custom (non-built-in) backends and keeps a freshly written `providers.json` visible within a long-running process. By the will of the Machine God --- crates/graphify-analyze/src/cycles.rs | 9 ++++---- crates/graphify-llm/src/extract.rs | 5 ++++- crates/graphify-llm/src/providers.rs | 13 +++++++++++ crates/graphify-llm/tests/common/mod.rs | 6 +++++ .../graphify-llm/tests/provider_registry.rs | 22 +++++++++++++++++++ 5 files changed, 50 insertions(+), 5 deletions(-) diff --git a/crates/graphify-analyze/src/cycles.rs b/crates/graphify-analyze/src/cycles.rs index 283ddbc..0c6d42e 100644 --- a/crates/graphify-analyze/src/cycles.rs +++ b/crates/graphify-analyze/src/cycles.rs @@ -130,10 +130,11 @@ fn build_file_graph(graph: &Graph) -> IndexMap> { } else if v_file == src_file { u_file // Fallback: neither endpoint's source_file equals the edge's (e.g. path - // normalisation mismatch or inconsistent data). Prefer the non-empty, - // non-source endpoint, else fall back to `u_file`, so orientation still - // resolves to a valid target file. - } else if !v_file.is_empty() && v_file != src_file { + // normalisation mismatch or inconsistent data). Prefer the non-empty + // endpoint (already known to differ from `src_file`, since the prior arm + // ruled out `v_file == src_file`), else fall back to `u_file`, so + // orientation still resolves to a valid target file. + } else if !v_file.is_empty() { v_file } else { u_file diff --git a/crates/graphify-llm/src/extract.rs b/crates/graphify-llm/src/extract.rs index 62b95c9..ceb1d84 100644 --- a/crates/graphify-llm/src/extract.rs +++ b/crates/graphify-llm/src/extract.rs @@ -165,7 +165,10 @@ fn extract_custom( .filter(|s| !s.is_empty()) .unwrap_or(&provider.default_model); let user_msg = read_files(files, root); - let max_out = openai_compat::resolve_max_tokens(8192); + // Honour the provider's configured `max_completion_tokens` (default 8192), + // then apply the `GRAPHIFY_MAX_OUTPUT_TOKENS` override — mirroring Python's + // `_resolve_max_tokens(cfg.get("max_completion_tokens", 8192))` (llm.py:720). + let max_out = openai_compat::resolve_max_tokens(provider.max_completion_tokens); openai_compat::call_openai_compat(&openai_compat::OpenAiRequest { base_url: &provider.base_url, api_key: &key, diff --git a/crates/graphify-llm/src/providers.rs b/crates/graphify-llm/src/providers.rs index 0fffbd3..3846e1c 100644 --- a/crates/graphify-llm/src/providers.rs +++ b/crates/graphify-llm/src/providers.rs @@ -27,8 +27,16 @@ pub struct CustomProvider { pub pricing: Pricing, /// Sampling temperature (defaults to 0). pub temperature: f64, + /// Default output-token budget for extraction, before the + /// `GRAPHIFY_MAX_OUTPUT_TOKENS` override. Mirrors Python's + /// `cfg.get("max_completion_tokens", 8192)` on the OpenAI-compatible path. + pub max_completion_tokens: u32, } +/// Fallback output-token budget when a provider omits `max_completion_tokens`, +/// matching Python's `cfg.get("max_completion_tokens", 8192)`. +const DEFAULT_MAX_COMPLETION_TOKENS: u32 = 8192; + /// Path to the `providers.json` registry. /// /// `global == true` → `~/.graphify/providers.json`; otherwise the @@ -118,6 +126,11 @@ pub fn load_custom_providers_from(local: &Path, global: &Path) -> IndexMap)>, } +impl Default for EnvGuard { + fn default() -> Self { + Self::new() + } +} + impl EnvGuard { #[must_use] pub fn new() -> Self { diff --git a/crates/graphify-llm/tests/provider_registry.rs b/crates/graphify-llm/tests/provider_registry.rs index 4ac0a8c..a7358fb 100644 --- a/crates/graphify-llm/tests/provider_registry.rs +++ b/crates/graphify-llm/tests/provider_registry.rs @@ -57,6 +57,27 @@ fn custom_provider_identical_local_global_path_read_once() { assert!(loaded.contains_key("only")); } +#[test] +fn custom_provider_max_completion_tokens_parsed_and_defaulted() { + // A provider may set `max_completion_tokens` (honoured on the extraction + // path, mirroring Python's `cfg.get("max_completion_tokens", 8192)`); when + // omitted it falls back to 8192. + let tmp = tempdir().expect("tempdir"); + let global = tmp.path().join("providers.json"); + std::fs::write( + &global, + r#"{ + "big": {"base_url": "http://x/v1", "default_model": "m", "env_key": "K", "max_completion_tokens": 16000}, + "dflt": {"base_url": "http://y/v1", "default_model": "m", "env_key": "K"} + }"#, + ) + .expect("write providers.json"); + + let loaded = load_custom_providers_from(&tmp.path().join("local.json"), &global); + assert_eq!(loaded["big"].max_completion_tokens, 16000); + assert_eq!(loaded["dflt"].max_completion_tokens, 8192); +} + #[test] fn custom_provider_pricing_defaults_to_zero() { let tmp = tempdir().expect("tempdir"); @@ -163,6 +184,7 @@ fn detect_backend_custom_provider_after_builtins() { output: 0.0, }, temperature: 0.0, + max_completion_tokens: 8192, }, ); From ed45693f1e2d3fd1297f91b39d44b4ae027dce9b Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 15:00:44 +0200 Subject: [PATCH 10/34] Address third CodeRabbit round on v0.8.27 resync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed: - `DetectResult.files` is now an `IndexMap` instead of a `HashMap`. The buckets are built in the fixed order `code, document, paper, image, video` (matching Python's insertion-ordered dict), but `HashMap` discarded that order. The order flows through `collect_extract_files` into `extract()` and thus into `graph.json` node order (which `to_json` does not re-sort) and the manifest, so the previous type made those outputs nondeterministic run-to-run and divergent from Python. The reconstruction path in `detect_result_from_incremental` and `persist_manifest` are updated to match; the latter now passes the map straight through (no clone-collect). - `anchored_file_not_matched_at_depth` gains a positive assertion that `/build` still matches `build` at the repo root, alongside the existing negative `src/build` case. Disputed (verified false positives, left unchanged): - Submodule pointer bump to `4b17f19`: intentional — it is the subject of this resync, documented in the first commit and the PR body. - Caching `load_custom_providers()` in a process-global static: re-raised from the prior round; same rationale (captures `$HOME` at first access, makes `#[serial]` env-mutating tests order-dependent under a shared -process runner, redundant read bounded to custom backends only). - Extracting a shared setup helper across the `ts_inheritance` tests: a stylistic test-DRY nitpick, not a defect; the explicit per-test setup keeps each case readable and isolated. Ave Deus Mechanicus --- crates/graphify-detect/src/walk.rs | 17 +++++++++++------ crates/graphify-detect/tests/parity_ignore.rs | 8 +++++++- src/cli/extract.rs | 13 +++++-------- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/crates/graphify-detect/src/walk.rs b/crates/graphify-detect/src/walk.rs index 2831917..acae9f3 100644 --- a/crates/graphify-detect/src/walk.rs +++ b/crates/graphify-detect/src/walk.rs @@ -3,11 +3,12 @@ //! Ports `detect`, `collect_files`, and `_auto_follow_symlinks` from //! `graphify-py/graphify/detect.py`. -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use ignore_walk::{WalkBuilder, WalkState}; +use indexmap::IndexMap; use rayon::prelude::*; use crate::extensions::{FileType, GOOGLE_WORKSPACE_EXTENSIONS, classify_file}; @@ -89,8 +90,12 @@ pub const FILE_COUNT_UPPER: usize = 500; /// Full output of a [`detect`] run, analogous to the Python dict return. #[derive(Debug, Clone)] pub struct DetectResult { - /// Files grouped by type string (`"code"`, `"document"`, `"paper"`, `"image"`, `"video"`). - pub files: HashMap>, + /// Files grouped by type string, in the fixed insertion order `"code"`, + /// `"document"`, `"paper"`, `"image"`, `"video"`. `IndexMap` (not `HashMap`) + /// keeps that order observable so the flattened extraction file list — and + /// hence `graph.json` node order and the manifest — is deterministic and + /// matches Python's insertion-ordered dict. + pub files: IndexMap>, /// Total number of discovered files across all types. pub total_files: usize, /// Estimated total word count across all non-video files. @@ -493,7 +498,7 @@ fn run_walk_phase(ctx: &WalkCtx<'_>, root: &Path, memory_dir: &Path) -> Vec>, + files: IndexMap>, to_count: Vec<(PathBuf, FileType)>, skipped_sensitive: Vec, } @@ -508,7 +513,7 @@ fn run_classify_phase( ignore_patterns: &crate::ignore::IgnorePatterns, google_workspace: bool, ) -> ClassifyOutput { - let mut files: HashMap> = ["code", "document", "paper", "image", "video"] + let mut files: IndexMap> = ["code", "document", "paper", "image", "video"] .iter() .map(|k| ((*k).to_string(), Vec::new())) .collect(); @@ -630,7 +635,7 @@ struct ConvertCtx<'a> { root: &'a Path, converted_dir: &'a Path, ignore_patterns: &'a IgnorePatterns, - files: &'a mut HashMap>, + files: &'a mut IndexMap>, to_count: &'a mut Vec<(PathBuf, FileType)>, skipped_sensitive: &'a mut Vec, } diff --git a/crates/graphify-detect/tests/parity_ignore.rs b/crates/graphify-detect/tests/parity_ignore.rs index 45c66d1..302292c 100644 --- a/crates/graphify-detect/tests/parity_ignore.rs +++ b/crates/graphify-detect/tests/parity_ignore.rs @@ -186,13 +186,19 @@ fn anchored_dir_matches_at_root() { #[test] fn anchored_file_not_matched_at_depth() { - // /build must not match src/build. + // /build must match build at the repo root, but NOT src/build at depth. let tmp = tempdir().expect("tempdir"); let root = tmp.path().canonicalize().expect("canonicalize"); + let root_build = root.join("build"); let src_build = root.join("src").join("build"); std::fs::create_dir_all(&src_build).expect("create_dir_all"); + std::fs::create_dir_all(&root_build).expect("create_dir_all"); std::fs::write(root.join(".graphifyignore"), "/build\n").expect("write ignore"); let patterns = load_graphifyignore(&root); + assert!( + is_ignored(&root_build, &root, &patterns), + "root build/ must be ignored by /build" + ); assert!( !is_ignored(&src_build, &root, &patterns), "src/build must NOT be ignored by /build" diff --git a/src/cli/extract.rs b/src/cli/extract.rs index 5994225..402040a 100644 --- a/src/cli/extract.rs +++ b/src/cli/extract.rs @@ -215,8 +215,9 @@ fn detect_result_from_incremental( path: &std::path::Path, inc: &graphify_detect::IncrementalDetectResult, ) -> graphify_detect::DetectResult { - let mut files: std::collections::HashMap> = - std::collections::HashMap::new(); + // Seed in the canonical type order so the reconstructed `DetectResult` + // matches a fresh `detect` walk (and Python's insertion-ordered dict). + let mut files: indexmap::IndexMap> = indexmap::IndexMap::new(); for (kind, paths) in &inc.changed_files { files.entry(kind.clone()).or_default().extend(paths.clone()); } @@ -647,15 +648,11 @@ fn render_html_viz( /// incremental code path. Mirrors `_save_manifest(... kind="both")` at /// `__main__.py:2891`. fn persist_manifest( - detect_files: &std::collections::HashMap>, + detect_files: &indexmap::IndexMap>, out_dir: &std::path::Path, ) { let manifest_path = out_dir.join("manifest.json"); - let files_indexed: indexmap::IndexMap> = detect_files - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - if let Err(e) = graphify_detect::save_manifest(&files_indexed, &manifest_path, "both") { + if let Err(e) = graphify_detect::save_manifest(detect_files, &manifest_path, "both") { eprintln!(" warning: could not write manifest: {e}"); } } From 262d7739ba10c4dbc042449d13ee6f2302873200 Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 15:09:26 +0200 Subject: [PATCH 11/34] Address fourth CodeRabbit round on v0.8.27 resync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed: - Import-cycles report renderer no longer clones the cycle vector to close the path; it joins the files and appends the start file inline. Disputed (verified false positives, left unchanged): - `--temperature` flag on `provider add`: Python's `provider add` hardcodes `"temperature": 0` (`__main__.py:1877`) and exposes no such flag, so the Rust port already matches; adding the flag would diverge from the reference CLI surface. A custom `temperature` is still honoured when present in a hand-edited `providers.json`. - `sha1` 0.10 downgrade: the workspace pins `sha2 = "0.11"`, so within `graphify-export` both `sha1` and `sha2` resolve to a single `digest v0.11.3`. Downgrading `sha1` to 0.10 would pair it with the 0.10 digest while `sha2` stays on 0.11 — creating the split the finding aims to remove. The 0.10 `sha*`/`digest` entries in `Cargo.lock` come from unrelated transitive deps. - Caching `load_custom_providers()` and `prefix_remap` keying: re-raised from prior rounds; same verified rationale (process-global cache breaks `#[serial]` test isolation; `source_file` is the verbatim input-path string at remap time, with relativisation running strictly afterward). Glory to the Omnissiah --- crates/graphify-report/src/sections/cycles.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/graphify-report/src/sections/cycles.rs b/crates/graphify-report/src/sections/cycles.rs index 3cdbb7a..949dbba 100644 --- a/crates/graphify-report/src/sections/cycles.rs +++ b/crates/graphify-report/src/sections/cycles.rs @@ -24,9 +24,9 @@ pub(crate) fn render_import_cycles(lines: &mut Vec, graph: &Graph) { let Some(first) = c.cycle.first() else { continue; }; - let mut path = c.cycle.clone(); - path.push(first.clone()); - let cycle_path = path.join(" -> "); + // Render the closed path `a -> b -> a` without cloning the cycle: join + // the files, then append the start file to close the loop. + let cycle_path = format!("{} -> {first}", c.cycle.join(" -> ")); lines.push(format!("- {}-file cycle: `{cycle_path}`", c.length)); } } From 339c9127cb48070ac261e59f310dfe95e3f8eca2 Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 15:17:48 +0200 Subject: [PATCH 12/34] Address fifth CodeRabbit round on v0.8.27 resync Fixed: - `#[must_use]` on the pure `node_label_map` / `god_node_ids` helpers. Disputed (verified false positives, left unchanged): - Making `provider add`'s `--base-url` / `--default-model` / `--env-key` clap-required: the handler already validates them (provider.rs:110) and returns the byte-identical error message Python uses (`__main__.py:1863`), matching the reference's optional-parse + validate pattern. The loader's skip-incomplete guard is defense-in-depth for hand-edited registries. - Merging curated labels when `generate_community_labels` degrades: the `else` branch only runs when no labels file exists (nothing to lose) or `--force-relabel` was passed. Python behaves identically (`__main__.py:2424-2448`): `label` / force-relabel is a deliberate regenerate that degrades to placeholders by design, and the labels file is a regenerable cache. Merging would make `--force-relabel` not fully relabel. By the will of the Machine God --- src/cli/cluster_only.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/cli/cluster_only.rs b/src/cli/cluster_only.rs index 1f4dc9a..a8c9de5 100644 --- a/src/cli/cluster_only.rs +++ b/src/cli/cluster_only.rs @@ -210,6 +210,7 @@ pub(crate) fn cmd_cluster_only( } /// Build `node_id → label` for community labelling prompts. +#[must_use] fn node_label_map(g: &graphify_build::Graph) -> indexmap::IndexMap { g.nodes() .filter_map(|(id, attrs)| { @@ -222,6 +223,7 @@ fn node_label_map(g: &graphify_build::Graph) -> indexmap::IndexMap indexmap::IndexSet { graphify_analyze::god_nodes(g, 20) .into_iter() From 38e2d5464f79360f0a30a892b2ab1d73068a7798 Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 15:44:05 +0200 Subject: [PATCH 13/34] Fix three CodeRabbit findings previously mis-disputed These were dismissed in earlier rounds either on an unverified assumption (finding 1) or on "matches graphify-py" grounds (findings 2-3), which the project rules reject: feature parity is the bar, not bug parity. 1. pnpm `.`-package import resolution (#1083 follow-up). The resolver canonicalises the entry path (on macOS `/tmp` -> `/private/tmp`), so the resolved `imports_from` target id (`make_id1` of the canonical path) did not match the input-path key in `id_remap` and the edge dangled off the relativised file node. `id_remap` now also maps each input file's canonicalised spelling to its node id, so the resolved edge connects. The parity test now asserts the import resolves to a REAL node id, not just that extraction produced nodes. 2. `cluster-only --no-label` was silently ignored when a labels file already existed, because the load-existing branch was checked first. The `--no-label` branch now runs first, so the flag always yields `Community N` placeholders as documented. 3. `cluster-only` silently overwrote a malformed/unreadable `.graphify_labels.json` with placeholders, destroying hand-curated edits. The read is now fallible (`read_existing_labels`): on failure it warns, falls back to placeholders for the run, and leaves the file on disk untouched. This is a deliberate divergence from graphify-py (`__main__.py:2418-2448`), which clobbers on parse failure. Added cluster-only parity tests: `--no-label` overrides curated names; malformed labels file is preserved. Ave Deus Mechanicus --- .../graphify-extract/src/extractors/multi.rs | 11 +++ crates/graphify-extract/tests/parity.rs | 29 ++++++ src/cli/cluster_only.rs | 97 +++++++++++++------ tests/cli_export.rs | 76 +++++++++++++++ 4 files changed, 183 insertions(+), 30 deletions(-) diff --git a/crates/graphify-extract/src/extractors/multi.rs b/crates/graphify-extract/src/extractors/multi.rs index 5147dfa..733c857 100644 --- a/crates/graphify-extract/src/extractors/multi.rs +++ b/crates/graphify-extract/src/extractors/multi.rs @@ -850,6 +850,17 @@ pub fn extract(paths: &[PathBuf], cache_root: Option<&Path>) -> ExtractOutput { if old_id != new_id { id_remap.insert(old_id, new_id.clone()); } + // Import resolution (e.g. the pnpm `.`-package entry, #1083) canonicalises + // the resolved path, which on macOS rewrites `/tmp` → `/private/tmp`. That + // id differs from the input-path id keyed above, so an edge targeting the + // canonical spelling would dangle off the relativised file node. Map the + // canonical spelling to the same node so the resolved edge connects. + if let Ok(canon) = path.canonicalize() { + let canon_id = make_id1(&canon.to_string_lossy()); + if canon_id != new_id { + id_remap.entry(canon_id).or_insert_with(|| new_id.clone()); + } + } let old_pref = crate::ids::file_node_id(path); if old_pref != new_id { prefix_remap.insert(path.to_string_lossy().into_owned(), (old_pref, new_id)); diff --git a/crates/graphify-extract/tests/parity.rs b/crates/graphify-extract/tests/parity.rs index 1bb3e99..7e5c20a 100644 --- a/crates/graphify-extract/tests/parity.rs +++ b/crates/graphify-extract/tests/parity.rs @@ -785,6 +785,35 @@ fn pnpm_workspace_dot_package_does_not_crash() { !result.nodes.is_empty(), "extraction produced no nodes for index.ts" ); + + // The `.`-package must actually resolve: `import from 'my-app'` resolves to + // the root package's entry (index.ts), so there must be an `imports_from` + // edge whose target is a REAL node id (`index`), not a dangling + // absolute-path id. This guards the #1083 resolution + id-normalisation, + // not just crash-safety. + let node_ids: std::collections::HashSet<&str> = result + .nodes + .iter() + .filter_map(|n| n.get("id").and_then(serde_json::Value::as_str)) + .collect(); + let resolved = result.edges.iter().any(|e| { + e.get("relation").and_then(serde_json::Value::as_str) == Some("imports_from") + && e.get("target") + .and_then(serde_json::Value::as_str) + .is_some_and(|t| node_ids.contains(t)) + }); + assert!( + resolved, + "the pnpm `.`-package import must resolve to a real node; edges: {:?}", + result + .edges + .iter() + .map(|e| ( + e.get("relation").and_then(serde_json::Value::as_str), + e.get("target").and_then(serde_json::Value::as_str) + )) + .collect::>() + ); } #[test] diff --git a/src/cli/cluster_only.rs b/src/cli/cluster_only.rs index a8c9de5..cd299fe 100644 --- a/src/cli/cluster_only.rs +++ b/src/cli/cluster_only.rs @@ -132,33 +132,42 @@ pub(crate) fn cmd_cluster_only( eprintln!(" wrote {}", graph_path.display()); // Resolve `.graphify_labels.json` so the HTML viz and downstream exports can - // find community labels. Three paths, mirroring Python's cluster-only/label: - // 1. labels file exists & not forced → load it (preserve user edits), fill - // any gaps with placeholders. - // 2. `--no-label` (and not forced) → all placeholders, no LLM call. + // find community labels. Three paths, checked in this order: + // 1. `--no-label` (and not forced) → all placeholders, no LLM call, no file + // read. Checked FIRST so the flag is honoured even when a labels file + // already exists (it was previously shadowed and silently ignored). + // 2. labels file exists & not forced → load it (preserve user edits), fill + // any gaps with placeholders. A malformed/unreadable file is NOT + // overwritten — we warn and fall back to placeholders for this run so a + // hand-curated file isn't silently clobbered (divergence from Python's + // `__main__.py:2418-2448`, which degrades to placeholders and rewrites + // the file; the Rust port treats that data loss as a bug). // 3. otherwise → auto-name with the configured backend (#1097); degrades // to placeholders on no-backend/error. let labels_path = graph_path.with_file_name(".graphify_labels.json"); - let labels: indexmap::IndexMap = if labels_path.exists() && !opts.force_relabel { - let mut existing: indexmap::IndexMap = indexmap::IndexMap::new(); - if let Ok(text) = std::fs::read_to_string(&labels_path) - && let Ok(serde_json::Value::Object(map)) = - serde_json::from_str::(&text) - { - for (k, v) in &map { - if let (Ok(cid), Some(s)) = (k.parse::(), v.as_str()) { - existing.insert(cid, s.to_string()); + let mut skip_label_write = false; + let labels: indexmap::IndexMap = if opts.no_label && !opts.force_relabel { + graphify_llm::placeholder_community_labels(&communities) + } else if labels_path.exists() && !opts.force_relabel { + match read_existing_labels(&labels_path) { + Ok(mut existing) => { + for cid in communities.keys() { + existing + .entry(*cid) + .or_insert_with(|| format!("Community {cid}")); } + existing + } + Err(e) => { + eprintln!( + " warning: could not read {} ({e}); using placeholders and \ + leaving the existing file untouched", + labels_path.display() + ); + skip_label_write = true; + graphify_llm::placeholder_community_labels(&communities) } } - for cid in communities.keys() { - existing - .entry(*cid) - .or_insert_with(|| format!("Community {cid}")); - } - existing - } else if opts.no_label && !opts.force_relabel { - graphify_llm::placeholder_community_labels(&communities) } else { eprintln!("Labeling communities..."); let node_labels = node_label_map(&g); @@ -172,15 +181,22 @@ pub(crate) fn cmd_cluster_only( ); labels }; - let labels_json: serde_json::Map = labels - .iter() - .map(|(cid, name)| (cid.to_string(), serde_json::Value::String(name.clone()))) - .collect(); - std::fs::write( - &labels_path, - serde_json::to_string(&serde_json::Value::Object(labels_json))?, - )?; - eprintln!(" wrote {}", labels_path.display()); + if skip_label_write { + eprintln!( + " kept existing {} (not overwritten)", + labels_path.display() + ); + } else { + let labels_json: serde_json::Map = labels + .iter() + .map(|(cid, name)| (cid.to_string(), serde_json::Value::String(name.clone()))) + .collect(); + std::fs::write( + &labels_path, + serde_json::to_string(&serde_json::Value::Object(labels_json))?, + )?; + eprintln!(" wrote {}", labels_path.display()); + } let html_path = graph_path.with_file_name("graph.html"); if no_viz { @@ -209,6 +225,27 @@ pub(crate) fn cmd_cluster_only( Ok(()) } +/// Read an existing `.graphify_labels.json` into a `cid → name` map. +/// +/// Returns `Err` when the file is unreadable or is not a JSON object, so the +/// caller can avoid overwriting a malformed or hand-curated file with +/// placeholders. +fn read_existing_labels(path: &std::path::Path) -> Result> { + let text = std::fs::read_to_string(path).map_err(|e| anyhow!("read failed: {e}"))?; + let serde_json::Value::Object(map) = serde_json::from_str::(&text) + .map_err(|e| anyhow!("parse failed: {e}"))? + else { + return Err(anyhow!("not a JSON object")); + }; + let mut existing: indexmap::IndexMap = indexmap::IndexMap::new(); + for (k, v) in &map { + if let (Ok(cid), Some(s)) = (k.parse::(), v.as_str()) { + existing.insert(cid, s.to_string()); + } + } + Ok(existing) +} + /// Build `node_id → label` for community labelling prompts. #[must_use] fn node_label_map(g: &graphify_build::Graph) -> indexmap::IndexMap { diff --git a/tests/cli_export.rs b/tests/cli_export.rs index 5f4857f..727ce9a 100644 --- a/tests/cli_export.rs +++ b/tests/cli_export.rs @@ -448,3 +448,79 @@ fn cluster_only_remaps_labels_to_previous_cids() { graph.json community attrs. actual={actual_cids:?} labels={label_cids:?}" ); } + +/// Helper: write a minimal two-node connected graph so `cluster-only` resolves +/// exactly one community. +fn write_min_graph(graph_path: &Path) { + fs::write( + graph_path, + r#"{"nodes":[ + {"id":"a","label":"A","file_type":"code","source_file":"a.py"}, + {"id":"b","label":"B","file_type":"code","source_file":"b.py"} + ],"edges":[ + {"source":"a","target":"b","context":"CALLS","confidence":"EXTRACTED","source_file":"a.py"} + ]}"#, + ) + .unwrap(); +} + +#[test] +fn cluster_only_no_label_overrides_existing_labels() { + // `--no-label` must produce `Community N` placeholders even when a labels + // file with curated names already exists. Previously the load-existing + // branch shadowed the flag, so `--no-label` was silently ignored. + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("graphify-out"); + fs::create_dir_all(&out).unwrap(); + let graph_path = out.join("graph.json"); + let labels_path = out.join(".graphify_labels.json"); + write_min_graph(&graph_path); + fs::write(&labels_path, r#"{"0":"Curated Name"}"#).unwrap(); + + cli() + .arg("cluster-only") + .arg(dir.path()) + .arg("--no-label") + .arg("--no-viz") + .assert() + .success(); + + let labels = fs::read_to_string(&labels_path).unwrap(); + assert!( + !labels.contains("Curated Name"), + "--no-label must overwrite curated names with placeholders: {labels}" + ); + assert!( + labels.contains("Community "), + "--no-label must write `Community N` placeholders: {labels}" + ); +} + +#[test] +fn cluster_only_does_not_clobber_malformed_labels() { + // A malformed `.graphify_labels.json` must be preserved (not silently + // overwritten with placeholders) so hand-curated edits aren't lost. + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("graphify-out"); + fs::create_dir_all(&out).unwrap(); + let graph_path = out.join("graph.json"); + let labels_path = out.join(".graphify_labels.json"); + write_min_graph(&graph_path); + let malformed = "{ this is : not valid json "; + fs::write(&labels_path, malformed).unwrap(); + + cli() + .arg("cluster-only") + .arg(dir.path()) + .arg("--no-viz") + .assert() + .success() + .stderr(contains("leaving the existing file untouched")); + + // The malformed file must be left exactly as written. + let after = fs::read_to_string(&labels_path).unwrap(); + assert_eq!( + after, malformed, + "malformed labels file must not be clobbered" + ); +} From 685cba4c3dcd8cc24e42085275b35b936d10cee4 Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 15:57:43 +0200 Subject: [PATCH 14/34] Address sixth CodeRabbit round on v0.8.27 resync Fixed: - `file_node_id_spec` root-leak guard now normalises the temp-dir name with `make_id1` (the same routine ids use) instead of only lowercasing and replacing `-`, so it detects a leak regardless of which non-alphanumeric characters the temp dir name contains. - Custom-provider `max_completion_tokens` accepts a whole-number JSON float (e.g. `8192.0`) in addition to an integer; negative / non-finite / out-of-range values fall back to the 8192 default instead of being silently dropped or wrapping. - USAGE.md custom-providers section now documents that the registry is read from both `~/.graphify/providers.json` (global) and `.graphify/providers.json` (project-local), that the global entry wins on a name clash, and that an entry needs a non-empty base_url/default_model/env_key plus the optional `max_completion_tokens` cap. Added coverage: float + negative `max_completion_tokens` parsing. Disputed (engineering cost/benefit, not parity): - Threading a pre-resolved provider from the corpus boundary to avoid the per-chunk `load_custom_providers()` read: the read is already gated to custom (non-built-in) backends via `&&` short-circuit, so the common path has zero overhead, and for custom backends a sub-kilobyte JSON read is negligible next to each chunk's LLM HTTP round-trip. Threading an owned provider through `CorpusConfig`/`AdaptiveRetryCtx`/retry into a new non-public extract variant (the public `extract_files_direct*` API and its tests can't change) is disproportionate plumbing for that bounded gain. Glory to the Omnissiah --- USAGE.md | 11 ++++++---- .../tests/file_node_id_spec.rs | 9 +++++--- crates/graphify-llm/src/providers.rs | 21 +++++++++++++++++-- .../graphify-llm/tests/provider_registry.rs | 6 ++++++ 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/USAGE.md b/USAGE.md index ee69ec3..7b2a4c6 100644 --- a/USAGE.md +++ b/USAGE.md @@ -579,8 +579,10 @@ Force a backend with `--backend`; override its default model with `--model`. #### Custom providers Any OpenAI-compatible endpoint can be registered as a custom backend and used like a built-in one (e.g. -`graphify extract . --backend nvidia`, `graphify label . --backend nvidia`). Custom providers are stored in -`~/.graphify/providers.json` and managed with the `provider` command: +`graphify extract . --backend nvidia`, `graphify label . --backend nvidia`). At extraction/labeling time custom +providers are loaded from **both** `~/.graphify/providers.json` (user-global) and `.graphify/providers.json` +(project-local, when present); if the same name appears in both, the global entry wins. The `provider` command +manages the user-global registry: ```bash graphify provider add nvidia \ @@ -593,9 +595,10 @@ graphify provider show nvidia # full JSON config for one provider graphify provider remove nvidia ``` -Built-in backend names cannot be shadowed. Auto-detection consults custom providers **after** all built-ins, in +Built-in backend names cannot be shadowed, and a registry entry is ignored unless it supplies a non-empty +`base_url`, `default_model`, and `env_key`. Auto-detection consults custom providers **after** all built-ins, in registry order, selecting the first whose `--env-key` variable is set. Missing `pricing` defaults to zero so cost -estimation never fails. +estimation never fails; an optional `max_completion_tokens` caps extraction output (default 8192). ### Determinism note diff --git a/crates/graphify-extract/tests/file_node_id_spec.rs b/crates/graphify-extract/tests/file_node_id_spec.rs index b4e1291..ad006cc 100644 --- a/crates/graphify-extract/tests/file_node_id_spec.rs +++ b/crates/graphify-extract/tests/file_node_id_spec.rs @@ -19,7 +19,7 @@ use std::collections::HashSet; use std::error::Error; use std::path::Path; -use graphify_extract::extract; +use graphify_extract::{extract, make_id1}; use indexmap::IndexMap; use serde_json::Value; use tempfile::tempdir; @@ -99,10 +99,13 @@ fn top_level_file_symbol_ids_use_bare_stem() -> TestResult { ids.contains("main_run"), "expected bare-stem symbol 'main_run', got {ids:?}" ); - // The root directory name must NOT appear in any symbol id. + // The root directory name must NOT appear in any symbol id. Normalise the + // dir name exactly as node ids are (`make_id1`) so the guard catches a leak + // regardless of which non-alphanumeric chars the temp dir name contains — + // the old `-`→`_`-only lowercasing missed `.`, `/`, etc. let rootname = root .file_name() - .map(|n| n.to_string_lossy().to_lowercase().replace('-', "_")) + .map(|n| make_id1(&n.to_string_lossy())) .unwrap_or_default(); assert!( rootname.is_empty() || !ids.iter().any(|i| i.contains(&rootname)), diff --git a/crates/graphify-llm/src/providers.rs b/crates/graphify-llm/src/providers.rs index 3846e1c..8d401aa 100644 --- a/crates/graphify-llm/src/providers.rs +++ b/crates/graphify-llm/src/providers.rs @@ -37,6 +37,24 @@ pub struct CustomProvider { /// matching Python's `cfg.get("max_completion_tokens", 8192)`. const DEFAULT_MAX_COMPLETION_TOKENS: u32 = 8192; +/// Parse a `max_completion_tokens` JSON value, accepting either an integer or a +/// whole-number float (e.g. `8192.0`). Returns `None` for missing, negative, +/// non-finite, or out-of-`u32`-range values so the caller falls back to the +/// default rather than silently dropping a hand-written float budget. +fn max_completion_tokens_from(v: &Value) -> Option { + if let Some(n) = v.as_u64() { + return u32::try_from(n).ok(); + } + let f = v.as_f64()?; + if !f.is_finite() || f < 0.0 { + return None; + } + // Truncation is intentional: a token budget is an integer count. + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let n = f as u64; + u32::try_from(n).ok() +} + /// Path to the `providers.json` registry. /// /// `global == true` → `~/.graphify/providers.json`; otherwise the @@ -128,8 +146,7 @@ pub fn load_custom_providers_from(local: &Path, global: &Path) -> IndexMap Date: Mon, 1 Jun 2026 16:07:43 +0200 Subject: [PATCH 15/34] Address seventh CodeRabbit round on v0.8.27 resync Doc/comment polish only: - Add `// SAFETY:` comments to the `EnvGuard` env-mutating `unsafe` blocks (`set`/`unset`/`Drop`) stating the `#[serial]`-execution invariant that rules out concurrent process-environment access. - Make the `detect_backend_with` doc comment a complete sentence. By the will of the Machine God --- crates/graphify-llm/src/backends.rs | 3 ++- crates/graphify-llm/tests/common/mod.rs | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/graphify-llm/src/backends.rs b/crates/graphify-llm/src/backends.rs index 43314b3..2404c76 100644 --- a/crates/graphify-llm/src/backends.rs +++ b/crates/graphify-llm/src/backends.rs @@ -182,7 +182,8 @@ pub fn detect_backend() -> Option { detect_backend_with(&crate::providers::load_custom_providers()) } -/// [`detect_backend`] against an explicit custom-provider set. +/// Detects the backend like [`detect_backend`], but against an explicit +/// custom-provider set. /// /// Built-in backends take priority (gemini → kimi → claude → openai → deepseek → /// bedrock → ollama); only then are custom providers consulted, in registry diff --git a/crates/graphify-llm/tests/common/mod.rs b/crates/graphify-llm/tests/common/mod.rs index 4461628..7029481 100644 --- a/crates/graphify-llm/tests/common/mod.rs +++ b/crates/graphify-llm/tests/common/mod.rs @@ -21,12 +21,15 @@ impl EnvGuard { pub fn set(&mut self, k: &str, v: &str) -> &mut Self { self.saved.push((k.to_string(), std::env::var(k).ok())); + // SAFETY: every test using EnvGuard is `#[serial]`, so no other thread + // reads or writes the process environment concurrently with this mutation. unsafe { std::env::set_var(k, v) }; self } pub fn unset(&mut self, k: &str) -> &mut Self { self.saved.push((k.to_string(), std::env::var(k).ok())); + // SAFETY: see `set` — `#[serial]` execution rules out concurrent env access. unsafe { std::env::remove_var(k) }; self } @@ -35,6 +38,7 @@ impl EnvGuard { impl Drop for EnvGuard { fn drop(&mut self) { for (k, prev) in self.saved.drain(..).rev() { + // SAFETY: see `set` — `#[serial]` execution rules out concurrent env access. match prev { Some(v) => unsafe { std::env::set_var(&k, &v) }, None => unsafe { std::env::remove_var(&k) }, From 41a15596d05e06fe82bf8970e6cc368360c49118 Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 16:25:31 +0200 Subject: [PATCH 16/34] Address eighth CodeRabbit round on v0.8.27 resync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed: - `is_included` anchored patterns now also match subtree descendants (`/src` includes `src/main.py`), mirroring `could_contain_included_path` and normal allowlist semantics. This is the inverse of the anchored *ignore* precision fix (#1087): an ignore pattern must not leak into a subtree, but an include directory is meant to pull its whole subtree in. `is_included` is a public helper not yet wired into the walk (the walk rescues via `could_contain_included_path`, and `.graphifyinclude` is a loaded-but-unused stub in graphify-py), so this corrects the helper with no change to walk behaviour. - Community-label JSON sanitizer always slices the first `{` … last `}` span, even when the reply already starts with `{`, so a leading-JSON + trailing-prose reply parses instead of degrading to placeholders. Diverges from graphify-py (`llm.py:1308`), which keeps that data loss. Added coverage: anchored include root/subtree/depth + anchored-file + unanchored-at-depth; label reply with trailing prose. Disputed (engineering judgment, not parity): - Extracting a shared `tempdir/canonicalize/write/extract` helper across the `ts_inheritance` tests: a stylistic test-DRY change with no correctness impact. The explicit per-test setup keeps each case self-contained and readable; collapsing it trades that for indirection. Per the repo's surgical-changes guidance, leaving working tests untouched. Ave Deus Mechanicus --- crates/graphify-detect/src/ignore.rs | 12 +++-- .../graphify-detect/tests/parity_include.rs | 50 +++++++++++++++++++ crates/graphify-llm/src/labeling.rs | 18 +++---- crates/graphify-llm/tests/labeling.rs | 15 ++++++ 4 files changed, 81 insertions(+), 14 deletions(-) diff --git a/crates/graphify-detect/src/ignore.rs b/crates/graphify-detect/src/ignore.rs index 0954189..c82d548 100644 --- a/crates/graphify-detect/src/ignore.rs +++ b/crates/graphify-detect/src/ignore.rs @@ -381,12 +381,16 @@ pub fn is_included(path: &Path, root: &Path, patterns: &IgnorePatterns) -> bool continue; } if anchored { - // Anchored include patterns match the anchor-relative path directly, - // mirroring the anchored ignore fix (#1087): no subtree/basename - // fallback, so `/src/foo` only matches at the anchor root. + // Anchored include patterns match the anchor-relative path directly. + // For an *allowlist*, an anchored directory (`/src`) also covers its + // descendants (`src/main.py`), so a `"{p}/"`-prefix match is included + // too — mirroring `could_contain_included_path`. This is the inverse + // of the anchored *ignore* fix (#1087): an ignore pattern must not + // leak into a subtree at the wrong depth, but an include directory is + // meant to pull its whole subtree in. if path.strip_prefix(anchor).ok().is_some_and(|rel| { let rel_str = path_to_forward_slash(rel); - fnmatch(&rel_str, p) + fnmatch(&rel_str, p) || rel_str.starts_with(&format!("{p}/")) }) { return true; } diff --git a/crates/graphify-detect/tests/parity_include.rs b/crates/graphify-detect/tests/parity_include.rs index 18bea54..66455ac 100644 --- a/crates/graphify-detect/tests/parity_include.rs +++ b/crates/graphify-detect/tests/parity_include.rs @@ -54,6 +54,56 @@ fn is_included_matches_glob() { )); } +#[test] +fn is_included_anchored_dir_matches_root_and_subtree() { + // An anchored allowlist directory (`/src`) includes the directory itself and + // everything beneath it, but not a same-named directory deeper in the tree. + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path().canonicalize().expect("canonicalize"); + fs::create_dir_all(root.join("src/deep")).expect("test invariant"); + fs::create_dir_all(root.join("x/src")).expect("test invariant"); + fs::write(root.join(".graphifyinclude"), "/src\n").expect("test invariant"); + let patterns = load_graphifyinclude(&root); + + assert!( + is_included(&root.join("src"), &root, &patterns), + "/src must include the anchored directory itself" + ); + assert!( + is_included(&root.join("src/deep/main.py"), &root, &patterns), + "/src must include files in its subtree" + ); + assert!( + !is_included(&root.join("x/src"), &root, &patterns), + "/src is anchored to root and must NOT match a nested src/" + ); +} + +#[test] +fn is_included_anchored_file_matches_only_at_root() { + // An anchored file pattern (`/setup.py`) matches at the anchor root but not + // a same-named file deeper in the tree. + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path().canonicalize().expect("canonicalize"); + fs::create_dir_all(root.join("pkg")).expect("test invariant"); + fs::write(root.join(".graphifyinclude"), "/setup.py\n").expect("test invariant"); + let patterns = load_graphifyinclude(&root); + + assert!(is_included(&root.join("setup.py"), &root, &patterns)); + assert!(!is_included(&root.join("pkg/setup.py"), &root, &patterns)); +} + +#[test] +fn is_included_unanchored_matches_at_depth() { + // An unanchored pattern matches anywhere in the tree (not just the root). + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path().canonicalize().expect("canonicalize"); + fs::create_dir_all(root.join("a/b")).expect("test invariant"); + fs::write(root.join(".graphifyinclude"), "*.py\n").expect("test invariant"); + let patterns = load_graphifyinclude(&root); + assert!(is_included(&root.join("a/b/deep.py"), &root, &patterns)); +} + #[test] fn is_included_with_no_patterns_is_false() { let tmp = tempfile::tempdir().expect("tempdir"); diff --git a/crates/graphify-llm/src/labeling.rs b/crates/graphify-llm/src/labeling.rs index 65c178b..f0a4a81 100644 --- a/crates/graphify-llm/src/labeling.rs +++ b/crates/graphify-llm/src/labeling.rs @@ -97,16 +97,14 @@ fn parse_label_response( labeled_cids: &[i64], ) -> Result, LlmError> { let cleaned = LABEL_FENCE_RE.replace_all(text.trim(), "").to_string(); - let cleaned = if cleaned.starts_with('{') { - cleaned - } else if let (Some(start), Some(end)) = (cleaned.find('{'), cleaned.rfind('}')) { - if end > start { - cleaned[start..=end].to_string() - } else { - cleaned - } - } else { - cleaned + // Always slice the first `{` … last `}` span, even when the reply already + // starts with `{`, so trailing prose (`{"0":"x"} hope that helps`) is + // dropped rather than failing the strict parse. Diverges from graphify-py + // (`llm.py:1308`), which only slices when the text does NOT start with `{` + // and therefore degrades such replies to placeholders. + let cleaned = match (cleaned.find('{'), cleaned.rfind('}')) { + (Some(start), Some(end)) if end > start => cleaned[start..=end].to_string(), + _ => cleaned, }; let data: serde_json::Value = diff --git a/crates/graphify-llm/tests/labeling.rs b/crates/graphify-llm/tests/labeling.rs index 54df0f5..81efaf4 100644 --- a/crates/graphify-llm/tests/labeling.rs +++ b/crates/graphify-llm/tests/labeling.rs @@ -93,6 +93,21 @@ fn label_communities_extracts_json_from_surrounding_prose() { assert_eq!(labels[&1], "Pay"); } +#[test] +fn label_communities_strips_trailing_prose_after_json() { + // A reply that leads with valid JSON but appends prose + // (`{"0":"x"} hope that helps`) must still parse: the sanitizer slices the + // first `{` … last `}` span even when the text already starts with `{`. + let (node_labels, communities) = graph(); + let gods = IndexSet::new(); + let labels = label_communities_with(&communities, &node_labels, &gods, "gemini", |_, _, _| { + Ok(r#"{"0":"Orders","1":"Pay"} hope that helps"#.to_string()) + }) + .expect("labeling succeeds"); + assert_eq!(labels[&0], "Orders"); + assert_eq!(labels[&1], "Pay"); +} + #[test] fn label_communities_malformed_errors() { let (node_labels, communities) = graph(); From 03776b1ebdb9e154b2f79b311887e25054f4ff54 Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 16:41:28 +0200 Subject: [PATCH 17/34] Fix --no-label data loss; ninth CodeRabbit round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headline fix corrects a regression I introduced two commits earlier. - `cluster-only --no-label` no longer clobbers a hand-curated `.graphify_labels.json`. My earlier "finding 2" change reordered the `--no-label` branch ahead of the load-existing branch so the flag always produced `Community N` placeholders and rewrote the file — silently destroying curated labels. That was wrong: an existing labels file already means no LLM call, so `--no-label` should be a harmless no-op there. The load-existing branch is restored to first position, so `--no-label` preserves curated labels and only yields placeholders when no file exists. The malformed-file guard (warn + don't overwrite) is kept. Also fixed: - `god_node_ids` magic `20` → named `GOD_NODE_CAP` constant. - `max_completion_tokens_from` doc now states it accepts any finite non-negative float, truncated toward zero (`8192.9` → `8192`), matching the implementation. Test changes: replaced the test that asserted `--no-label` wipes curated labels with one asserting it PRESERVES them, plus a test that `--no-label` with no file yields placeholders. Disputed (verified false positives): - "Print updated vs added in `provider add`": Python `__main__.py:1880` always prints `'added'` (upsert, no distinction). Adding an "updated" message would diverge from the reference, not match it. - "Use `NamedTempFile` in `save_registry`": Python writes the registry non-atomically (`write_text`); the current same-dir temp + rename already exceeds that. `provider` is an interactive, non-concurrent command, so a unique temp + new runtime dep is unwarranted. - Caching the per-chunk `load_custom_providers()` read: re-raised; same engineering rationale (gated to custom backends, dwarfed by the LLM call, threading through the parallel pipeline is disproportionate). By the will of the Machine God --- crates/graphify-llm/src/providers.rs | 7 +++--- src/cli/cluster_only.rs | 31 ++++++++++++++---------- tests/cli_export.rs | 36 ++++++++++++++++++++++------ 3 files changed, 51 insertions(+), 23 deletions(-) diff --git a/crates/graphify-llm/src/providers.rs b/crates/graphify-llm/src/providers.rs index 8d401aa..a2f256e 100644 --- a/crates/graphify-llm/src/providers.rs +++ b/crates/graphify-llm/src/providers.rs @@ -38,9 +38,10 @@ pub struct CustomProvider { const DEFAULT_MAX_COMPLETION_TOKENS: u32 = 8192; /// Parse a `max_completion_tokens` JSON value, accepting either an integer or a -/// whole-number float (e.g. `8192.0`). Returns `None` for missing, negative, -/// non-finite, or out-of-`u32`-range values so the caller falls back to the -/// default rather than silently dropping a hand-written float budget. +/// finite non-negative float, truncated toward zero (e.g. `8192.0` and `8192.9` +/// both yield `8192`). Returns `None` for missing, negative, non-finite, or +/// out-of-`u32`-range values so the caller falls back to the default rather than +/// silently dropping a hand-written float budget. fn max_completion_tokens_from(v: &Value) -> Option { if let Some(n) = v.as_u64() { return u32::try_from(n).ok(); diff --git a/src/cli/cluster_only.rs b/src/cli/cluster_only.rs index cd299fe..fd6ee95 100644 --- a/src/cli/cluster_only.rs +++ b/src/cli/cluster_only.rs @@ -133,22 +133,21 @@ pub(crate) fn cmd_cluster_only( // Resolve `.graphify_labels.json` so the HTML viz and downstream exports can // find community labels. Three paths, checked in this order: - // 1. `--no-label` (and not forced) → all placeholders, no LLM call, no file - // read. Checked FIRST so the flag is honoured even when a labels file - // already exists (it was previously shadowed and silently ignored). - // 2. labels file exists & not forced → load it (preserve user edits), fill - // any gaps with placeholders. A malformed/unreadable file is NOT - // overwritten — we warn and fall back to placeholders for this run so a - // hand-curated file isn't silently clobbered (divergence from Python's - // `__main__.py:2418-2448`, which degrades to placeholders and rewrites - // the file; the Rust port treats that data loss as a bug). + // 1. labels file exists & not forced → load it (preserve user edits, fill + // any gaps with placeholders). This runs whether or not `--no-label` is + // set: an existing file already means no LLM call, so `--no-label` is a + // harmless no-op here — crucially, it must NOT wipe hand-curated labels + // to placeholders. A malformed/unreadable file is NOT overwritten — we + // warn and fall back to placeholders for this run so the file isn't + // silently clobbered (divergence from Python `__main__.py:2418-2448`, + // which degrades to placeholders and rewrites the file). + // 2. `--no-label` (and not forced) with no labels file → placeholders, no + // LLM call. // 3. otherwise → auto-name with the configured backend (#1097); degrades // to placeholders on no-backend/error. let labels_path = graph_path.with_file_name(".graphify_labels.json"); let mut skip_label_write = false; - let labels: indexmap::IndexMap = if opts.no_label && !opts.force_relabel { - graphify_llm::placeholder_community_labels(&communities) - } else if labels_path.exists() && !opts.force_relabel { + let labels: indexmap::IndexMap = if labels_path.exists() && !opts.force_relabel { match read_existing_labels(&labels_path) { Ok(mut existing) => { for cid in communities.keys() { @@ -168,6 +167,8 @@ pub(crate) fn cmd_cluster_only( graphify_llm::placeholder_community_labels(&communities) } } + } else if opts.no_label && !opts.force_relabel { + graphify_llm::placeholder_community_labels(&communities) } else { eprintln!("Labeling communities..."); let node_labels = node_label_map(&g); @@ -259,10 +260,14 @@ fn node_label_map(g: &graphify_build::Graph) -> indexmap::IndexMap indexmap::IndexSet { - graphify_analyze::god_nodes(g, 20) + graphify_analyze::god_nodes(g, GOD_NODE_CAP) .into_iter() .filter_map(|n| { n.get("id") diff --git a/tests/cli_export.rs b/tests/cli_export.rs index 727ce9a..37940c2 100644 --- a/tests/cli_export.rs +++ b/tests/cli_export.rs @@ -465,10 +465,10 @@ fn write_min_graph(graph_path: &Path) { } #[test] -fn cluster_only_no_label_overrides_existing_labels() { - // `--no-label` must produce `Community N` placeholders even when a labels - // file with curated names already exists. Previously the load-existing - // branch shadowed the flag, so `--no-label` was silently ignored. +fn cluster_only_no_label_preserves_existing_labels() { + // `--no-label` must NOT wipe a curated labels file to placeholders. An + // existing file already means no LLM call, so `--no-label` is a harmless + // no-op here: the curated names are preserved, not clobbered. let dir = tempfile::tempdir().unwrap(); let out = dir.path().join("graphify-out"); fs::create_dir_all(&out).unwrap(); @@ -487,12 +487,34 @@ fn cluster_only_no_label_overrides_existing_labels() { let labels = fs::read_to_string(&labels_path).unwrap(); assert!( - !labels.contains("Curated Name"), - "--no-label must overwrite curated names with placeholders: {labels}" + labels.contains("Curated Name"), + "--no-label must preserve curated names, not wipe them: {labels}" ); +} + +#[test] +fn cluster_only_no_label_placeholders_when_no_file() { + // With no existing labels file, `--no-label` produces `Community N` + // placeholders (and no LLM call). + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("graphify-out"); + fs::create_dir_all(&out).unwrap(); + let graph_path = out.join("graph.json"); + let labels_path = out.join(".graphify_labels.json"); + write_min_graph(&graph_path); + + cli() + .arg("cluster-only") + .arg(dir.path()) + .arg("--no-label") + .arg("--no-viz") + .assert() + .success(); + + let labels = fs::read_to_string(&labels_path).unwrap(); assert!( labels.contains("Community "), - "--no-label must write `Community N` placeholders: {labels}" + "--no-label with no file must write `Community N` placeholders: {labels}" ); } From 646d51c017eabfae5551266f7957c27ab2073dae Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 16:54:42 +0200 Subject: [PATCH 18/34] Address tenth CodeRabbit round on v0.8.27 resync Fixed: - `is_included` anchored subtree check now uses a byte-level prefix test instead of allocating a `"{p}/"` String per (pattern, path). - Document the security rationale for global-over-local provider precedence in `load_custom_providers_from` (see disputes). Disputed (verified, non-parity reasons): - Flip provider precedence to local-wins: declined for security. A project-local `.graphify/providers.json` can come from a cloned/untrusted repo; letting it shadow a globally-defined provider name would let the repo redirect that backend's `base_url` to an attacker endpoint and exfiltrate the API key (resolved from the user's env via `env_key`). global-wins blocks that hijack; built-in names are already un-shadowable. Comment added so the precedence reads as deliberate. - Extract a `remap_if_different` helper in `multi.rs`: the two id_remap insertions have intentionally different overwrite semantics (the input-path id uses `insert` so it wins; the canonical spelling uses `or_insert` so it never displaces a real input-path mapping). One helper would change behavior on a make_id collision or need a flag that adds noise. - Validate `base_url` shape in `provider add`: Python only checks non-empty (`__main__.py:1862`); the URL is validated by the SSRF guard at request time, so early `Url::parse` would add a runtime dep for marginal gain. - Cache the per-chunk `load_custom_providers()` read: re-raised; same engineering rationale as prior rounds. Glory to the Omnissiah --- crates/graphify-detect/src/ignore.rs | 7 ++++++- crates/graphify-llm/src/providers.rs | 17 ++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/graphify-detect/src/ignore.rs b/crates/graphify-detect/src/ignore.rs index c82d548..e721760 100644 --- a/crates/graphify-detect/src/ignore.rs +++ b/crates/graphify-detect/src/ignore.rs @@ -390,7 +390,12 @@ pub fn is_included(path: &Path, root: &Path, patterns: &IgnorePatterns) -> bool // meant to pull its whole subtree in. if path.strip_prefix(anchor).ok().is_some_and(|rel| { let rel_str = path_to_forward_slash(rel); - fnmatch(&rel_str, p) || rel_str.starts_with(&format!("{p}/")) + // Subtree match (`{p}/...`) via a byte check — no per-call `{p}/` + // String allocation. + fnmatch(&rel_str, p) + || (rel_str.len() > p.len() + && rel_str.as_bytes()[p.len()] == b'/' + && rel_str.starts_with(p)) }) { return true; } diff --git a/crates/graphify-llm/src/providers.rs b/crates/graphify-llm/src/providers.rs index a2f256e..69c69e5 100644 --- a/crates/graphify-llm/src/providers.rs +++ b/crates/graphify-llm/src/providers.rs @@ -84,11 +84,18 @@ pub fn load_custom_providers() -> IndexMap { load_custom_providers_from(&custom_providers_path(false), &custom_providers_path(true)) } -/// Load custom providers from explicit `local`/`global` registry files (in that -/// order — a later file overrides an earlier one for the same name, so `global` -/// wins, matching Python `_load_custom_providers`). Malformed files are skipped -/// silently, mirroring Python's broad `except`. Identical `local`/`global` paths -/// (e.g. when `$HOME` is unset) are read only once. +/// Load custom providers from explicit `local`/`global` registry files. +/// +/// `local` (project `.graphify/providers.json`) is read first, then `global` +/// (`~/.graphify/providers.json`); a later file overrides an earlier one for the +/// same name, so **`global` wins**. This is deliberate and security-relevant, not +/// just Python parity: a project-local registry can come from a cloned/untrusted +/// repo, and letting it shadow a provider name the user defined globally would +/// let the repo redirect that backend's `base_url` to an attacker-controlled +/// endpoint and exfiltrate the API key resolved from the user's environment via +/// `env_key`. Built-in names are separately un-shadowable. Malformed files are +/// skipped silently (mirroring Python's broad `except`); identical `local`/ +/// `global` paths (e.g. when `$HOME` is unset) are read only once. #[must_use] pub fn load_custom_providers_from(local: &Path, global: &Path) -> IndexMap { let mut providers: IndexMap = IndexMap::new(); From bb7b4e6871d70dc6bd2855d451b9a674fbc780da Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 17:08:23 +0200 Subject: [PATCH 19/34] Address eleventh CodeRabbit round on v0.8.27 resync Both findings were valid and are fixed: - `detect_result_from_incremental` now seeds all canonical file-type buckets in fixed order (even when empty) before merging the incremental results, so a reconstructed `DetectResult` is structurally identical to a fresh `detect` walk. Extracted the canonical kind list into a shared `graphify_detect::FILE_TYPE_KINDS` constant so the walk and the CLI reconstruction use one source of truth. - `is_included` anchored subtree matching now handles globbed directory stems. This matcher's `*` does not cross `/` (gitignore semantics), so `/src*` previously matched `src1` but not `src1/deep.py`; the subtree check now falls back to `{p}/**` for globbed stems while keeping the zero-alloc byte-prefix fast path for literal stems. Converges with graphify-py, whose `fnmatch`-based `_is_included` (`*` crosses `/`) already pulls such descendants in. Added coverage: anchored globbed include dir matches its subtree but not an unrelated directory. Ave Deus Mechanicus --- crates/graphify-detect/src/ignore.rs | 8 ++++-- crates/graphify-detect/src/lib.rs | 2 +- crates/graphify-detect/src/walk.rs | 8 +++++- .../graphify-detect/tests/parity_include.rs | 26 +++++++++++++++++++ src/cli/extract.rs | 11 +++++--- 5 files changed, 48 insertions(+), 7 deletions(-) diff --git a/crates/graphify-detect/src/ignore.rs b/crates/graphify-detect/src/ignore.rs index e721760..05c8545 100644 --- a/crates/graphify-detect/src/ignore.rs +++ b/crates/graphify-detect/src/ignore.rs @@ -390,12 +390,16 @@ pub fn is_included(path: &Path, root: &Path, patterns: &IgnorePatterns) -> bool // meant to pull its whole subtree in. if path.strip_prefix(anchor).ok().is_some_and(|rel| { let rel_str = path_to_forward_slash(rel); - // Subtree match (`{p}/...`) via a byte check — no per-call `{p}/` - // String allocation. + // Match the path itself, then its subtree. A literal directory + // stem uses a zero-alloc byte check (`{p}/...`); a globbed stem + // (`src*`) needs `{p}/**` since this matcher's `*` does not cross + // `/`. The format! only runs for the rare globbed-include case. fnmatch(&rel_str, p) || (rel_str.len() > p.len() && rel_str.as_bytes()[p.len()] == b'/' && rel_str.starts_with(p)) + || (p.bytes().any(|b| b == b'*' || b == b'?') + && fnmatch(&rel_str, &format!("{p}/**"))) }) { return true; } diff --git a/crates/graphify-detect/src/lib.rs b/crates/graphify-detect/src/lib.rs index 2b954dd..bedecf0 100644 --- a/crates/graphify-detect/src/lib.rs +++ b/crates/graphify-detect/src/lib.rs @@ -37,4 +37,4 @@ pub use manifest::{ }; pub use sensitive::{SKIP_DIRS, SKIP_FILES, is_noise_dir, is_sensitive}; pub use shebang::{env_command_args, shebang_interpreter}; -pub use walk::{DetectResult, auto_follow_symlinks, collect_files, detect}; +pub use walk::{DetectResult, FILE_TYPE_KINDS, auto_follow_symlinks, collect_files, detect}; diff --git a/crates/graphify-detect/src/walk.rs b/crates/graphify-detect/src/walk.rs index acae9f3..e0b9116 100644 --- a/crates/graphify-detect/src/walk.rs +++ b/crates/graphify-detect/src/walk.rs @@ -87,6 +87,12 @@ pub const CORPUS_UPPER_THRESHOLD: u64 = 500_000; /// File count above which the corpus is considered large regardless of word count. pub const FILE_COUNT_UPPER: usize = 500; +/// Canonical file-type bucket keys, in the fixed order every [`DetectResult`] +/// must present them (see [`DetectResult::files`]). Used both by the fresh +/// `detect` walk and by callers that reconstruct a `DetectResult` (e.g. the +/// incremental path) so the two produce structurally identical results. +pub const FILE_TYPE_KINDS: [&str; 5] = ["code", "document", "paper", "image", "video"]; + /// Full output of a [`detect`] run, analogous to the Python dict return. #[derive(Debug, Clone)] pub struct DetectResult { @@ -513,7 +519,7 @@ fn run_classify_phase( ignore_patterns: &crate::ignore::IgnorePatterns, google_workspace: bool, ) -> ClassifyOutput { - let mut files: IndexMap> = ["code", "document", "paper", "image", "video"] + let mut files: IndexMap> = FILE_TYPE_KINDS .iter() .map(|k| ((*k).to_string(), Vec::new())) .collect(); diff --git a/crates/graphify-detect/tests/parity_include.rs b/crates/graphify-detect/tests/parity_include.rs index 66455ac..284353a 100644 --- a/crates/graphify-detect/tests/parity_include.rs +++ b/crates/graphify-detect/tests/parity_include.rs @@ -79,6 +79,32 @@ fn is_included_anchored_dir_matches_root_and_subtree() { ); } +#[test] +fn is_included_anchored_globbed_dir_matches_subtree() { + // An anchored globbed directory stem (`/src*`) includes glob-matched + // directories and everything beneath them — the matcher's `*` does not + // cross `/`, so the subtree match needs the `{p}/**` form. + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path().canonicalize().expect("canonicalize"); + fs::create_dir_all(root.join("src1/deep")).expect("test invariant"); + fs::create_dir_all(root.join("lib")).expect("test invariant"); + fs::write(root.join(".graphifyinclude"), "/src*\n").expect("test invariant"); + let patterns = load_graphifyinclude(&root); + + assert!( + is_included(&root.join("src1"), &root, &patterns), + "/src* must match a glob-matched directory" + ); + assert!( + is_included(&root.join("src1/deep/main.py"), &root, &patterns), + "/src* must include files beneath a glob-matched directory" + ); + assert!( + !is_included(&root.join("lib/main.py"), &root, &patterns), + "/src* must not match an unrelated directory" + ); +} + #[test] fn is_included_anchored_file_matches_only_at_root() { // An anchored file pattern (`/setup.py`) matches at the anchor root but not diff --git a/src/cli/extract.rs b/src/cli/extract.rs index 402040a..bebdf61 100644 --- a/src/cli/extract.rs +++ b/src/cli/extract.rs @@ -215,9 +215,14 @@ fn detect_result_from_incremental( path: &std::path::Path, inc: &graphify_detect::IncrementalDetectResult, ) -> graphify_detect::DetectResult { - // Seed in the canonical type order so the reconstructed `DetectResult` - // matches a fresh `detect` walk (and Python's insertion-ordered dict). - let mut files: indexmap::IndexMap> = indexmap::IndexMap::new(); + // Seed all canonical buckets in fixed order (even when empty) so the + // reconstructed `DetectResult` is structurally identical to a fresh `detect` + // walk — same kinds, same order — rather than only the kinds that happen to + // have changed/unchanged files. + let mut files: indexmap::IndexMap> = graphify_detect::FILE_TYPE_KINDS + .iter() + .map(|k| ((*k).to_string(), Vec::new())) + .collect(); for (kind, paths) in &inc.changed_files { files.entry(kind.clone()).or_default().extend(paths.clone()); } From 7ea62a33261583c5275393cd1b5bcda850ad94e4 Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 17:19:32 +0200 Subject: [PATCH 20/34] Address twelfth CodeRabbit round on v0.8.27 resync All trivial cleanups: - Drop the unused `clippy::float_cmp` / `unsafe_code` allows from `labeling.rs` (the file has no float comparisons or `unsafe`); keep only `expect_used`. - Extract the dense anchored-include predicate into a named `anchored_include_matches` helper for readability. - `save_registry` serializes the `Map` directly instead of cloning it into a `Value::Object`. Disputed (already satisfied): - Add `#[must_use]` to `cap_filename`: it already carries the attribute (`export/util.rs:134`); no change needed. By the will of the Machine God --- crates/graphify-detect/src/ignore.rs | 44 +++++++++++++++------------ crates/graphify-llm/tests/labeling.rs | 2 +- src/cli/provider.rs | 2 +- 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/crates/graphify-detect/src/ignore.rs b/crates/graphify-detect/src/ignore.rs index 05c8545..87101c0 100644 --- a/crates/graphify-detect/src/ignore.rs +++ b/crates/graphify-detect/src/ignore.rs @@ -365,6 +365,25 @@ pub fn is_ignored(path: &Path, root: &Path, patterns: &IgnorePatterns) -> bool { eval_path(path, root, patterns) } +/// Whether an anchored `.graphifyinclude` stem `p` (anchor-relative, no +/// surrounding slashes) matches the anchor-relative path `rel_str` — the path +/// itself or anything in its subtree. +/// +/// For an allowlist an anchored directory (`/src`) covers its descendants +/// (`src/main.py`), mirroring [`could_contain_included_path`]. This is the +/// inverse of the anchored *ignore* fix (#1087): an ignore pattern must not leak +/// into a subtree, but an include directory pulls its whole subtree in. A literal +/// stem uses a zero-alloc byte check (`{p}/...`); a globbed stem (`src*`) needs +/// `{p}/**` since this matcher's `*` does not cross `/` (the `format!` runs only +/// for the rare globbed-include case). +fn anchored_include_matches(rel_str: &str, p: &str) -> bool { + fnmatch(rel_str, p) + || (rel_str.len() > p.len() + && rel_str.as_bytes()[p.len()] == b'/' + && rel_str.starts_with(p)) + || (p.bytes().any(|b| b == b'*' || b == b'?') && fnmatch(rel_str, &format!("{p}/**"))) +} + /// Return `true` if `path` matches any `.graphifyinclude` allowlist pattern. #[must_use] pub fn is_included(path: &Path, root: &Path, patterns: &IgnorePatterns) -> bool { @@ -381,26 +400,11 @@ pub fn is_included(path: &Path, root: &Path, patterns: &IgnorePatterns) -> bool continue; } if anchored { - // Anchored include patterns match the anchor-relative path directly. - // For an *allowlist*, an anchored directory (`/src`) also covers its - // descendants (`src/main.py`), so a `"{p}/"`-prefix match is included - // too — mirroring `could_contain_included_path`. This is the inverse - // of the anchored *ignore* fix (#1087): an ignore pattern must not - // leak into a subtree at the wrong depth, but an include directory is - // meant to pull its whole subtree in. - if path.strip_prefix(anchor).ok().is_some_and(|rel| { - let rel_str = path_to_forward_slash(rel); - // Match the path itself, then its subtree. A literal directory - // stem uses a zero-alloc byte check (`{p}/...`); a globbed stem - // (`src*`) needs `{p}/**` since this matcher's `*` does not cross - // `/`. The format! only runs for the rare globbed-include case. - fnmatch(&rel_str, p) - || (rel_str.len() > p.len() - && rel_str.as_bytes()[p.len()] == b'/' - && rel_str.starts_with(p)) - || (p.bytes().any(|b| b == b'*' || b == b'?') - && fnmatch(&rel_str, &format!("{p}/**"))) - }) { + if path + .strip_prefix(anchor) + .ok() + .is_some_and(|rel| anchored_include_matches(&path_to_forward_slash(rel), p)) + { return true; } } else { diff --git a/crates/graphify-llm/tests/labeling.rs b/crates/graphify-llm/tests/labeling.rs index 81efaf4..5c1ceda 100644 --- a/crates/graphify-llm/tests/labeling.rs +++ b/crates/graphify-llm/tests/labeling.rs @@ -2,7 +2,7 @@ //! //! Mirrors `graphify-py/tests/test_labeling.py`. The Python tests monkeypatch //! `_call_llm`; the Rust port injects the call via the `*_with` variants. -#![allow(clippy::expect_used, clippy::float_cmp, unsafe_code)] +#![allow(clippy::expect_used)] use std::cell::Cell; diff --git a/src/cli/provider.rs b/src/cli/provider.rs index 657a0f6..67ff15d 100644 --- a/src/cli/provider.rs +++ b/src/cli/provider.rs @@ -58,7 +58,7 @@ fn save_registry(registry: &Map) -> Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - let body = serde_json::to_string_pretty(&Value::Object(registry.clone()))?; + let body = serde_json::to_string_pretty(registry)?; // Write to a sibling temp file then rename, so a crash mid-write can't leave // a truncated/corrupt registry (rename is atomic on the same filesystem). let tmp = path.with_extension("json.tmp"); From bcc48a31b8858c27d661effbaf7529d2f588622d Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 17:47:31 +0200 Subject: [PATCH 21/34] Address thirteenth CodeRabbit round on v0.8.27 resync Fixes five findings and documents one dispute. - Add `EnvGuard::scrub_backends` to `graphify-llm`'s test `common` module and route the duplicated backend-env-unset loops in `labeling.rs` and `provider_registry.rs` through it. The canonical list now includes `AWS_CONTAINER_CREDENTIALS_FULL_URI` (read by `credentials_appear_configured` but previously omitted from the scrub), so a host with that variable set can no longer make `detect_backend` pick `bedrock` and pollute the no-backend path. - Enforce the mock endpoint in `label_communities_real_path_via_custom_provider` with an explicit `mock.assert()` rather than relying on the label assertions alone. - Add an `extract_files` helper to `ts_inheritance.rs` and collapse the repeated tempdir/canonicalize/write/extract setup across all seven inheritance tests onto it. - Reconcile the README LLM-backend list with the `--backend` identifiers (`Claude (Anthropic)`, `Kimi (Moonshot)`) and spell out the identifier names so readers know what to pass. Disputed (not changed): CodeRabbit suggested guarding `provider add` against overwriting an existing provider with a prompt or `--force` flag. graphify-py's `provider add` upserts unconditionally by design (add-or-update); a prompt would diverge from that behaviour, break non-interactive scripted usage, and fail the existing parity tests. No underlying bug, so left as-is. Glory to the Omnissiah --- README.md | 5 +- .../graphify-extract/tests/ts_inheritance.rs | 103 ++++++++---------- crates/graphify-llm/tests/common/mod.rs | 30 +++++ crates/graphify-llm/tests/labeling.rs | 25 +---- .../graphify-llm/tests/provider_registry.rs | 19 +--- 5 files changed, 85 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index f2713bd..68173d5 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,9 @@ a Rust equivalent, and outputs are byte-identical where the test suite asserts i and env-var _names_ (values are never read). - **Documents, papers, images, video** — PDF, DOCX, audio transcription, OCR, Google Workspace exports. - **Local-first** — `graph.json` lives next to your code; no daemon, no cloud, no account. -- **Optional LLM-driven semantic extraction** through OpenAI, Anthropic, Gemini, DeepSeek, Moonshot, Ollama, Bedrock, - or any OpenAI-compatible **custom provider** registered with `graphify provider add`. +- **Optional LLM-driven semantic extraction** through OpenAI, Claude (Anthropic), Gemini, DeepSeek, Kimi (Moonshot), + Ollama, Bedrock, or any OpenAI-compatible **custom provider** registered with `graphify provider add`. The + `--backend` identifiers are `openai`, `claude`, `gemini`, `deepseek`, `kimi`, `ollama`, and `bedrock`. - **LLM community naming** — `graphify label` (or `cluster-only`) auto-names graph communities with the configured backend; degrades to `Community N` placeholders when no backend is available. - **AI-assistant integration** — drop-in installers for Claude Code, Codex, Amp, Cursor, Gemini CLI, GitHub Copilot, diff --git a/crates/graphify-extract/tests/ts_inheritance.rs b/crates/graphify-extract/tests/ts_inheritance.rs index 987391f..9d1f929 100644 --- a/crates/graphify-extract/tests/ts_inheritance.rs +++ b/crates/graphify-extract/tests/ts_inheritance.rs @@ -14,7 +14,7 @@ use std::path::{Path, PathBuf}; -use graphify_extract::{extract, file_stem, make_id}; +use graphify_extract::{ExtractOutput, extract, file_stem, make_id}; use serde_json::Value; use tempfile::tempdir; @@ -24,6 +24,21 @@ fn write(path: &Path, text: &str) -> PathBuf { path.to_path_buf() } +/// Write each `(relative path, contents)` pair under a fresh tempdir, then run +/// `extract` over all of them in the given order. The returned `TempDir` must +/// be kept in scope by the caller so the written files survive for the duration +/// of the test. +fn extract_files(files: &[(&str, &str)]) -> (tempfile::TempDir, ExtractOutput) { + let tmp = tempdir().expect("tempdir"); + let root = tmp.path().canonicalize().expect("canonicalize"); + let paths: Vec = files + .iter() + .map(|(rel, contents)| write(&root.join(rel), contents)) + .collect(); + let result = extract(&paths, Some(&root)); + (tmp, result) +} + /// `_make_id(_file_stem(src_file), src_sym)` → symbol node id. fn sym_id(file: &str, sym: &str) -> String { make_id(&[&file_stem(Path::new(file)), sym]) @@ -48,14 +63,11 @@ fn has_edge( #[test] fn interface_extends_same_file() { - let tmp = tempdir().expect("tempdir"); - let root = tmp.path().canonicalize().expect("canonicalize"); - let f = write( - &root.join("src").join("a.ts"), + let (_tmp, result) = extract_files(&[( + "src/a.ts", "export interface Base { x: number; }\n\ export interface Derived extends Base { y: number; }\n", - ); - let result = extract(&[f], Some(&root)); + )]); assert!(has_edge( &result.edges, "src/a.ts", @@ -68,15 +80,12 @@ fn interface_extends_same_file() { #[test] fn interface_extends_multiple_same_file() { - let tmp = tempdir().expect("tempdir"); - let root = tmp.path().canonicalize().expect("canonicalize"); - let f = write( - &root.join("src").join("a.ts"), + let (_tmp, result) = extract_files(&[( + "src/a.ts", "interface A { a: number; }\n\ interface B { b: number; }\n\ interface M extends A, B { m: number; }\n", - ); - let result = extract(&[f], Some(&root)); + )]); assert!(has_edge( &result.edges, "src/a.ts", @@ -97,13 +106,8 @@ fn interface_extends_multiple_same_file() { #[test] fn class_extends_same_file() { - let tmp = tempdir().expect("tempdir"); - let root = tmp.path().canonicalize().expect("canonicalize"); - let f = write( - &root.join("src").join("a.ts"), - "class Animal {}\nclass Dog extends Animal {}\n", - ); - let result = extract(&[f], Some(&root)); + let (_tmp, result) = + extract_files(&[("src/a.ts", "class Animal {}\nclass Dog extends Animal {}\n")]); assert!(has_edge( &result.edges, "src/a.ts", @@ -116,14 +120,11 @@ fn class_extends_same_file() { #[test] fn interface_extends_generic_base_same_file() { - let tmp = tempdir().expect("tempdir"); - let root = tmp.path().canonicalize().expect("canonicalize"); - let f = write( - &root.join("src").join("a.ts"), + let (_tmp, result) = extract_files(&[( + "src/a.ts", "interface Base { x: T; }\n\ interface G extends Base { y: number; }\n", - ); - let result = extract(&[f], Some(&root)); + )]); assert!(has_edge( &result.edges, "src/a.ts", @@ -136,21 +137,14 @@ fn interface_extends_generic_base_same_file() { #[test] fn interface_extends_imported() { - let tmp = tempdir().expect("tempdir"); - let root = tmp.path().canonicalize().expect("canonicalize"); - write( - &root.join("src").join("b.ts"), - "export interface Imported { z: number; }\n", - ); - write( - &root.join("src").join("a.ts"), - "import { Imported } from './b';\n\ - export interface D extends Imported { d: number; }\n", - ); - let result = extract( - &[root.join("src").join("a.ts"), root.join("src").join("b.ts")], - Some(&root), - ); + let (_tmp, result) = extract_files(&[ + ( + "src/a.ts", + "import { Imported } from './b';\n\ + export interface D extends Imported { d: number; }\n", + ), + ("src/b.ts", "export interface Imported { z: number; }\n"), + ]); assert!(has_edge( &result.edges, "src/a.ts", @@ -164,17 +158,13 @@ fn interface_extends_imported() { #[test] fn imported_class_extends_still_works() { // Regression guard: the originally-working imported-class case must stay. - let tmp = tempdir().expect("tempdir"); - let root = tmp.path().canonicalize().expect("canonicalize"); - write(&root.join("src").join("b.ts"), "export class Imported {}\n"); - write( - &root.join("src").join("a.ts"), - "import { Imported } from './b';\nclass Cat extends Imported {}\n", - ); - let result = extract( - &[root.join("src").join("a.ts"), root.join("src").join("b.ts")], - Some(&root), - ); + let (_tmp, result) = extract_files(&[ + ( + "src/a.ts", + "import { Imported } from './b';\nclass Cat extends Imported {}\n", + ), + ("src/b.ts", "export class Imported {}\n"), + ]); assert!(has_edge( &result.edges, "src/a.ts", @@ -187,14 +177,11 @@ fn imported_class_extends_still_works() { #[test] fn class_implements_same_file_interface() { - let tmp = tempdir().expect("tempdir"); - let root = tmp.path().canonicalize().expect("canonicalize"); - let f = write( - &root.join("src").join("a.ts"), + let (_tmp, result) = extract_files(&[( + "src/a.ts", "interface Walker { walk(): void; }\n\ class Person implements Walker { walk() {} }\n", - ); - let result = extract(&[f], Some(&root)); + )]); assert!(has_edge( &result.edges, "src/a.ts", diff --git a/crates/graphify-llm/tests/common/mod.rs b/crates/graphify-llm/tests/common/mod.rs index 7029481..a642550 100644 --- a/crates/graphify-llm/tests/common/mod.rs +++ b/crates/graphify-llm/tests/common/mod.rs @@ -33,6 +33,36 @@ impl EnvGuard { unsafe { std::env::remove_var(k) }; self } + + /// Unset every environment variable that `detect_backend` consults, so a + /// test can drive the no-backend / custom-provider path without the host's + /// real credentials leaking in. The list mirrors the keys checked by + /// `backends::detect_backend_with` and `bedrock::credentials_appear_configured` + /// (notably `AWS_CONTAINER_CREDENTIALS_FULL_URI`) — keep it in lockstep with + /// them, and with `cli_no_backend()` in the binary's `tests/cli_commands.rs`. + pub fn scrub_backends(&mut self) -> &mut Self { + for key in [ + // Built-in API keys (gemini -> kimi -> claude -> openai -> deepseek). + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "MOONSHOT_API_KEY", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "DEEPSEEK_API_KEY", + // Bedrock credential-provider entry points. + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_PROFILE", + "AWS_WEB_IDENTITY_TOKEN_FILE", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + // Ollama (checked last before custom providers). + "OLLAMA_BASE_URL", + ] { + self.unset(key); + } + self + } } impl Drop for EnvGuard { diff --git a/crates/graphify-llm/tests/labeling.rs b/crates/graphify-llm/tests/labeling.rs index 5c1ceda..6218fbb 100644 --- a/crates/graphify-llm/tests/labeling.rs +++ b/crates/graphify-llm/tests/labeling.rs @@ -144,24 +144,7 @@ fn generate_community_labels_no_backend() { let home = tempfile::tempdir().expect("tempdir"); let mut g = EnvGuard::new(); g.set("HOME", &home.path().to_string_lossy()); - for key in [ - "GEMINI_API_KEY", - "GOOGLE_API_KEY", - "MOONSHOT_API_KEY", - "ANTHROPIC_API_KEY", - "OPENAI_API_KEY", - "DEEPSEEK_API_KEY", - "OLLAMA_BASE_URL", - "AWS_PROFILE", - "AWS_REGION", - "AWS_DEFAULT_REGION", - "AWS_ACCESS_KEY_ID", - "AWS_SECRET_ACCESS_KEY", - "AWS_WEB_IDENTITY_TOKEN_FILE", - "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", - ] { - g.unset(key); - } + g.scrub_backends(); let (node_labels, communities) = graph(); let gods = IndexSet::new(); @@ -195,7 +178,7 @@ fn label_communities_real_path_via_custom_provider() { // pointed at a mock server drives `call_llm`, and both `label_communities` // and `generate_community_labels` parse the reply. let mut server = mockito::Server::new(); - let _m = server + let mock = server .mock("POST", "/chat/completions") .with_status(200) .with_body( @@ -238,6 +221,10 @@ fn label_communities_real_path_via_custom_provider() { generate_community_labels(&communities, &node_labels, &gods, Some("labelprov"), true); assert_eq!(source, "llm"); assert_eq!(labels[&0], "Orders"); + + // Enforce that the mock endpoint was actually hit, rather than relying on + // the label assertions alone. + mock.assert(); } #[test] diff --git a/crates/graphify-llm/tests/provider_registry.rs b/crates/graphify-llm/tests/provider_registry.rs index b5fd666..a726c0b 100644 --- a/crates/graphify-llm/tests/provider_registry.rs +++ b/crates/graphify-llm/tests/provider_registry.rs @@ -157,24 +157,7 @@ fn custom_provider_cannot_shadow_builtin() { #[serial] fn detect_backend_custom_provider_after_builtins() { let mut g = EnvGuard::new(); - for key in [ - "GEMINI_API_KEY", - "GOOGLE_API_KEY", - "MOONSHOT_API_KEY", - "ANTHROPIC_API_KEY", - "OPENAI_API_KEY", - "DEEPSEEK_API_KEY", - "OLLAMA_BASE_URL", - "AWS_PROFILE", - "AWS_REGION", - "AWS_DEFAULT_REGION", - "AWS_ACCESS_KEY_ID", - "AWS_SECRET_ACCESS_KEY", - "AWS_WEB_IDENTITY_TOKEN_FILE", - "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", - ] { - g.unset(key); - } + g.scrub_backends(); g.set("MY_CUSTOM_KEY", "test-key"); let mut custom: IndexMap = IndexMap::new(); From 7e3b81c48c5d93cd39a923b0520828d76a76234c Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 18:03:25 +0200 Subject: [PATCH 22/34] Address fourteenth CodeRabbit round on v0.8.27 resync Fixes four findings and documents two disputes. - Centralise the backend-selection env var list behind a new `graphify_llm::backend_selection_env_vars()`. It is built from the per-backend `ENV_KEY` constants, the new `bedrock::CREDENTIAL_ENV_VARS`, and `ollama::BASE_URL_ENV` (the same names `detect_backend` reads), and `credentials_appear_configured` now derives from that const too. The test helper `EnvGuard::scrub_backends` calls the shared function, so the scrub set can no longer drift from the detection logic. - Simplify the glob-char scan in `anchored_include_matches` to `p.contains('*') || p.contains('?')`. - Rename the `ids` test helper in `file_node_id_spec.rs` to `node_ids` so the per-test `let ids = ...` bindings no longer shadow it. - Remove the orphaned temp file in `provider::save_registry` when the atomic rename fails, preserving the original error. Disputed (not changed): CodeRabbit flagged `load_custom_providers()` in `call_llm` and `extract_files_direct_mode` as repeated disk I/O and suggested a process-lifetime cache. Both calls are gated behind `!is_builtin_backend(backend)`, so built-in backends never touch disk; only the custom-provider path reads the (small) registry. A global OnceLock cache would freeze the first-loaded registry for the whole process, breaking the `#[serial]` custom-provider tests that each point `HOME` at a different `providers.json`, and would introduce global mutable state the workspace guidelines steer away from. The marginal I/O does not justify that risk. By the will of the Machine God --- crates/graphify-detect/src/ignore.rs | 2 +- .../tests/file_node_id_spec.rs | 14 +++---- crates/graphify-llm/src/backends.rs | 25 ++++++++++++- crates/graphify-llm/src/bedrock.rs | 37 ++++++++++++++----- crates/graphify-llm/src/lib.rs | 4 +- crates/graphify-llm/tests/common/mod.rs | 26 ++----------- src/cli/provider.rs | 6 ++- 7 files changed, 71 insertions(+), 43 deletions(-) diff --git a/crates/graphify-detect/src/ignore.rs b/crates/graphify-detect/src/ignore.rs index 87101c0..594a91d 100644 --- a/crates/graphify-detect/src/ignore.rs +++ b/crates/graphify-detect/src/ignore.rs @@ -381,7 +381,7 @@ fn anchored_include_matches(rel_str: &str, p: &str) -> bool { || (rel_str.len() > p.len() && rel_str.as_bytes()[p.len()] == b'/' && rel_str.starts_with(p)) - || (p.bytes().any(|b| b == b'*' || b == b'?') && fnmatch(rel_str, &format!("{p}/**"))) + || ((p.contains('*') || p.contains('?')) && fnmatch(rel_str, &format!("{p}/**"))) } /// Return `true` if `path` matches any `.graphifyinclude` allowlist pattern. diff --git a/crates/graphify-extract/tests/file_node_id_spec.rs b/crates/graphify-extract/tests/file_node_id_spec.rs index ad006cc..d6ecb7c 100644 --- a/crates/graphify-extract/tests/file_node_id_spec.rs +++ b/crates/graphify-extract/tests/file_node_id_spec.rs @@ -27,7 +27,7 @@ use tempfile::tempdir; type TestResult = Result<(), Box>; /// All node ids in the extraction result. -fn ids(nodes: &[IndexMap]) -> HashSet { +fn node_ids(nodes: &[IndexMap]) -> HashSet { nodes .iter() .filter_map(|n| n.get("id").and_then(Value::as_str).map(str::to_string)) @@ -49,7 +49,7 @@ fn file_node_id_uses_parent_dir_and_stem_no_extension() -> TestResult { write(&f, "def run():\n pass\n")?; let result = extract(&[f], Some(&root)); - let ids = ids(&result.nodes); + let ids = node_ids(&result.nodes); assert!( ids.contains("script_pipeline_step"), @@ -73,7 +73,7 @@ fn top_level_file_node_id_is_bare_stem() -> TestResult { write(&f, "def configure():\n pass\n")?; let result = extract(&[f], Some(&root)); - let ids = ids(&result.nodes); + let ids = node_ids(&result.nodes); assert!( ids.contains("setup"), @@ -93,7 +93,7 @@ fn top_level_file_symbol_ids_use_bare_stem() -> TestResult { write(&f, "def run():\n return 1\n")?; let result = extract(&[f], Some(&root)); - let ids = ids(&result.nodes); + let ids = node_ids(&result.nodes); assert!( ids.contains("main_run"), @@ -139,7 +139,7 @@ fn nested_file_symbol_ids_unchanged() -> TestResult { write(&f, "def work():\n return 2\n")?; let result = extract(&[f], Some(&root)); - let ids = ids(&result.nodes); + let ids = node_ids(&result.nodes); assert!(ids.contains("sub_mod")); assert!(ids.contains("sub_mod_work")); Ok(()) @@ -155,7 +155,7 @@ fn symbol_and_file_ids_share_the_same_stem() -> TestResult { write(&f, "def run():\n pass\n\nclass Stage:\n pass\n")?; let result = extract(&[f], Some(&root)); - let ids = ids(&result.nodes); + let ids = node_ids(&result.nodes); assert!(ids.contains("script_pipeline_step")); // file node assert!(ids.contains("script_pipeline_step_stage")); // class symbol shares stem @@ -194,7 +194,7 @@ fn cross_file_import_edges_stay_connected() -> TestResult { let files = vec![pkg.join("models.py"), pkg.join("auth.py")]; let result = extract(&files, Some(&root)); - let ids = ids(&result.nodes); + let ids = node_ids(&result.nodes); assert!(ids.contains("pkg_models")); assert!(ids.contains("pkg_auth")); diff --git a/crates/graphify-llm/src/backends.rs b/crates/graphify-llm/src/backends.rs index 2404c76..02f5147 100644 --- a/crates/graphify-llm/src/backends.rs +++ b/crates/graphify-llm/src/backends.rs @@ -202,7 +202,7 @@ pub fn detect_backend_with( return Some("bedrock".to_string()); } // Ollama: checked before custom providers to avoid shadowing a local model. - if let Ok(url) = std::env::var("OLLAMA_BASE_URL") + if let Ok(url) = std::env::var(ollama::BASE_URL_ENV) && !url.is_empty() { ollama::validate_ollama_base_url(&url); @@ -216,3 +216,26 @@ pub fn detect_backend_with( } None } + +/// Every environment variable [`detect_backend`] consults to auto-select a +/// built-in backend, in detection-priority order. +/// +/// Built from the per-backend `ENV_KEY` constants, [`bedrock::CREDENTIAL_ENV_VARS`], +/// and [`ollama::BASE_URL_ENV`] — the same names the detection logic above reads — +/// so the list can never drift from it. Custom-provider `env_key`s are excluded +/// because they are registry-defined, not statically known. Tests scrub exactly +/// this set to drive the no-backend / custom-provider path deterministically. +#[must_use] +pub fn backend_selection_env_vars() -> Vec<&'static str> { + let mut vars = vec![ + gemini::ENV_KEY, + gemini::ENV_KEY_FALLBACK, + kimi::ENV_KEY, + claude::ENV_KEY, + openai::ENV_KEY, + deepseek::ENV_KEY, + ]; + vars.extend_from_slice(bedrock::CREDENTIAL_ENV_VARS); + vars.push(ollama::BASE_URL_ENV); + vars +} diff --git a/crates/graphify-llm/src/bedrock.rs b/crates/graphify-llm/src/bedrock.rs index f68d21b..4f8de1f 100644 --- a/crates/graphify-llm/src/bedrock.rs +++ b/crates/graphify-llm/src/bedrock.rs @@ -115,17 +115,36 @@ pub fn resolve_region() -> String { #[must_use] pub fn credentials_appear_configured() -> bool { let env_set = |k: &str| std::env::var(k).is_ok_and(|v| !v.is_empty()); - // Explicit static credentials. - (env_set("AWS_ACCESS_KEY_ID") && env_set("AWS_SECRET_ACCESS_KEY")) - // Profile in ~/.aws/credentials or ~/.aws/config. - || env_set("AWS_PROFILE") - // Web identity (IRSA on EKS, GitHub OIDC, etc.). - || env_set("AWS_WEB_IDENTITY_TOKEN_FILE") - // ECS task role. - || env_set("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") - || env_set("AWS_CONTAINER_CREDENTIALS_FULL_URI") + // Explicit static credentials require BOTH the access key id and secret; + // every other entry point in `CREDENTIAL_ENV_VARS` is sufficient on its own. + if env_set("AWS_ACCESS_KEY_ID") && env_set("AWS_SECRET_ACCESS_KEY") { + return true; + } + CREDENTIAL_ENV_VARS + .iter() + .filter(|k| !matches!(**k, "AWS_ACCESS_KEY_ID" | "AWS_SECRET_ACCESS_KEY")) + .any(|k| env_set(k)) } +/// AWS credential-provider environment variables that +/// [`credentials_appear_configured`] treats as evidence that credentials are +/// configured. Centralised so the no-backend detection list +/// ([`crate::backend_selection_env_vars`]) and its test scrub stay in lockstep +/// with the detection logic above. `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` +/// are listed individually but only count as credentials when both are present. +pub const CREDENTIAL_ENV_VARS: &[&str] = &[ + // Explicit static credentials (need both of these together). + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + // Profile in ~/.aws/credentials or ~/.aws/config. + "AWS_PROFILE", + // Web identity (IRSA on EKS, GitHub OIDC, etc.). + "AWS_WEB_IDENTITY_TOKEN_FILE", + // ECS / container task roles. + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", +]; + /// Process-wide tokio runtime used to drive the (async-only) AWS SDK. /// /// Building the runtime fails only when the OS denies thread/file-descriptor diff --git a/crates/graphify-llm/src/lib.rs b/crates/graphify-llm/src/lib.rs index 2803736..7def7b2 100644 --- a/crates/graphify-llm/src/lib.rs +++ b/crates/graphify-llm/src/lib.rs @@ -55,8 +55,8 @@ pub mod tokenizer; pub mod tokens; pub use backends::{ - BACKENDS, BackendConfig, Pricing, backend_config, detect_backend, detect_backend_with, - format_backend_env_keys, get_backend_api_key, router, + BACKENDS, BackendConfig, Pricing, backend_config, backend_selection_env_vars, detect_backend, + detect_backend_with, format_backend_env_keys, get_backend_api_key, router, }; pub use call::call_llm; pub use constants::{ diff --git a/crates/graphify-llm/tests/common/mod.rs b/crates/graphify-llm/tests/common/mod.rs index a642550..4a082fd 100644 --- a/crates/graphify-llm/tests/common/mod.rs +++ b/crates/graphify-llm/tests/common/mod.rs @@ -36,29 +36,11 @@ impl EnvGuard { /// Unset every environment variable that `detect_backend` consults, so a /// test can drive the no-backend / custom-provider path without the host's - /// real credentials leaking in. The list mirrors the keys checked by - /// `backends::detect_backend_with` and `bedrock::credentials_appear_configured` - /// (notably `AWS_CONTAINER_CREDENTIALS_FULL_URI`) — keep it in lockstep with - /// them, and with `cli_no_backend()` in the binary's `tests/cli_commands.rs`. + /// real credentials leaking in. The exact set comes from + /// [`graphify_llm::backend_selection_env_vars`], so it can never drift from + /// the detection logic it mirrors. pub fn scrub_backends(&mut self) -> &mut Self { - for key in [ - // Built-in API keys (gemini -> kimi -> claude -> openai -> deepseek). - "GEMINI_API_KEY", - "GOOGLE_API_KEY", - "MOONSHOT_API_KEY", - "ANTHROPIC_API_KEY", - "OPENAI_API_KEY", - "DEEPSEEK_API_KEY", - // Bedrock credential-provider entry points. - "AWS_ACCESS_KEY_ID", - "AWS_SECRET_ACCESS_KEY", - "AWS_PROFILE", - "AWS_WEB_IDENTITY_TOKEN_FILE", - "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", - "AWS_CONTAINER_CREDENTIALS_FULL_URI", - // Ollama (checked last before custom providers). - "OLLAMA_BASE_URL", - ] { + for key in graphify_llm::backend_selection_env_vars() { self.unset(key); } self diff --git a/src/cli/provider.rs b/src/cli/provider.rs index 67ff15d..ed1bf36 100644 --- a/src/cli/provider.rs +++ b/src/cli/provider.rs @@ -63,7 +63,11 @@ fn save_registry(registry: &Map) -> Result<()> { // a truncated/corrupt registry (rename is atomic on the same filesystem). let tmp = path.with_extension("json.tmp"); std::fs::write(&tmp, format!("{body}\n"))?; - std::fs::rename(&tmp, &path)?; + if let Err(e) = std::fs::rename(&tmp, &path) { + // Don't leave the orphaned temp file behind on a failed rename. + let _ = std::fs::remove_file(&tmp); + return Err(e.into()); + } Ok(()) } From 37570aa5f63aa8b0a27f5aa7e21a9c8a107cdc76 Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 18:13:59 +0200 Subject: [PATCH 23/34] Address fifteenth CodeRabbit round on v0.8.27 resync Fixes two findings and re-documents two standing disputes. - Rewrite the literal-stem subtree test in `anchored_include_matches` as `rel_str.strip_prefix(p).is_some_and(|rest| rest.starts_with('/'))`, replacing the manual length/index/`starts_with` checks. Same zero-alloc behaviour, clearer intent. - Swap the `seen` dedup set in `label_communities`'s name builder from `IndexSet` to `std::collections::HashSet`. The set is membership-only; `names` (a `Vec`) carries the observable order, so insertion-order tracking was never needed. Disputed again (not changed): CodeRabbit re-flagged `load_custom_providers()` in `call_llm` and `extract_files_direct_mode` as repeated disk I/O. Both calls are gated behind `!is_builtin_backend(backend)`, so only the custom-provider path ever reads the (small) registry. A process-lifetime `OnceLock` cache would freeze the first-loaded registry and introduce the global mutable state the workspace guidelines steer away from, while the alternative (threading a preloaded map through the public `call_llm` / `extract_files_direct` API and the per-chunk retry context) is an invasive signature change across crates for a trivial perf nit. The cost optimised away is a few KB of JSON per chunk, custom-provider-only. Left as-is pending owner direction. Ave Deus Mechanicus --- crates/graphify-detect/src/ignore.rs | 12 ++++++------ crates/graphify-llm/src/labeling.rs | 4 +++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/graphify-detect/src/ignore.rs b/crates/graphify-detect/src/ignore.rs index 594a91d..c7b8090 100644 --- a/crates/graphify-detect/src/ignore.rs +++ b/crates/graphify-detect/src/ignore.rs @@ -373,14 +373,14 @@ pub fn is_ignored(path: &Path, root: &Path, patterns: &IgnorePatterns) -> bool { /// (`src/main.py`), mirroring [`could_contain_included_path`]. This is the /// inverse of the anchored *ignore* fix (#1087): an ignore pattern must not leak /// into a subtree, but an include directory pulls its whole subtree in. A literal -/// stem uses a zero-alloc byte check (`{p}/...`); a globbed stem (`src*`) needs -/// `{p}/**` since this matcher's `*` does not cross `/` (the `format!` runs only -/// for the rare globbed-include case). +/// stem uses a zero-alloc `strip_prefix` check (`{p}/...`); a globbed stem +/// (`src*`) needs `{p}/**` since this matcher's `*` does not cross `/` (the +/// `format!` runs only for the rare globbed-include case). fn anchored_include_matches(rel_str: &str, p: &str) -> bool { fnmatch(rel_str, p) - || (rel_str.len() > p.len() - && rel_str.as_bytes()[p.len()] == b'/' - && rel_str.starts_with(p)) + || rel_str + .strip_prefix(p) + .is_some_and(|rest| rest.starts_with('/')) || ((p.contains('*') || p.contains('?')) && fnmatch(rel_str, &format!("{p}/**"))) } diff --git a/crates/graphify-llm/src/labeling.rs b/crates/graphify-llm/src/labeling.rs index f0a4a81..deddd05 100644 --- a/crates/graphify-llm/src/labeling.rs +++ b/crates/graphify-llm/src/labeling.rs @@ -66,7 +66,9 @@ fn community_label_lines( .chain(members.iter().filter(|m| !gods.contains(m.as_str()))); let mut names: Vec = Vec::new(); - let mut seen: IndexSet = IndexSet::new(); + // Membership-only dedup: `names` carries the observable order, so the + // set never needs insertion-order tracking. + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); for nid in ranked { let raw = node_labels.get(nid).map_or(nid.as_str(), String::as_str); let label: String = raw From 1d173850127782ab08c309ba51a96493a2f79196 Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 19:10:25 +0200 Subject: [PATCH 24/34] Document sixteenth CodeRabbit round (no code changes) All four findings this round are no-change determinations. Disputed (approved won't-fix): CodeRabbit flagged `load_custom_providers()` as repeated disk I/O in three call sites this pass: - `call_llm` (call.rs) and `extract_files_direct_mode` (extract.rs) are gated behind `!is_builtin_backend`, so only the custom-provider path reads the small registry. - `detect_backend` (backends.rs) loads it ~once per run for auto-selection. A process-lifetime `OnceLock` cache would add global mutable state (against the workspace guideline) and stale `serve`/`watch`; threading a preloaded map is an invasive multi-crate API change for a trivial perf nit. Project owner approved keeping these as documented disputes. False positive: CodeRabbit suggested adding `#![allow(clippy::expect_used, clippy::unwrap_used)]` to `file_node_id_spec.rs`. That file uses `?` with a `TestResult` return type and contains zero `.expect()`/`.unwrap()` calls, so the suppression would silence lints that never fire. Clippy already passes on the file. Not added. By the will of the Machine God From 60b8de245652beabe4bb2eab674e5c9f3dea2f44 Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 19:21:57 +0200 Subject: [PATCH 25/34] Address seventeenth CodeRabbit round on v0.8.27 resync Adds one regression test; both findings this round are disputes. - Add `extract_swift_same_file_class_and_extension_keeps_canonical` to lock in that a single Swift file declaring both `class Foo` and `extension Foo` collapses to exactly one canonical node. Disputed (false positive): CodeRabbit claimed `merge_swift_extensions` over-marks same-file class+extension pairs because it matches extension nodes by `(source_file, label)` instead of the node's own kind. It cannot: tree-sitter-swift parses both as `class_declaration` and both derive the same `{stem}_{label}` id, so the extractor's `seen_ids` collapses them to a single node before postprocessing ever runs. There is only ever one node per `(source_file, label)`, so the label match is equivalent to graphify-py's nid-based `swift_extensions` tracking. The new test proves the canonical node is preserved. Disputed (parity): CodeRabbit wanted `provider add` to write `"temperature": 0.0` instead of `0`. graphify-py writes integer `0` (`__main__.py`), and byte-identical registry output is a resync goal; `serde_json::to_string_pretty` matches it, and the loader reads the integer back via `as_f64()`, so there is no functional difference. Changing it would diverge the Rust registry from the Python one. Left as integer `0`. Glory to the Omnissiah --- crates/graphify-extract/tests/parity.rs | 29 +++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/graphify-extract/tests/parity.rs b/crates/graphify-extract/tests/parity.rs index 7e5c20a..32a4b9a 100644 --- a/crates/graphify-extract/tests/parity.rs +++ b/crates/graphify-extract/tests/parity.rs @@ -896,6 +896,35 @@ fn extract_swift_merges_extension_across_files() { ); } +#[test] +fn extract_swift_same_file_class_and_extension_keeps_canonical() { + // A single file declaring both `class Foo` and `extension Foo` collapses to + // one `Foo` node via `seen_ids` (matching graphify-py), so the merge pass + // matching extension nodes by (source_file, label) cannot drop the canonical + // class: there is only ever one node per (file, label). Guards against a + // regression where same-file pairs would both be marked as extensions. + use graphify_extract::extract; + let tmp = tempfile::tempdir().expect("tempdir"); + let f = tmp.path().join("Foo.swift"); + std::fs::write( + &f, + "class Foo {\n func bar() {}\n}\nextension Foo {\n func baz() {}\n}\n", + ) + .expect("test invariant"); + let result = extract(std::slice::from_ref(&f), None); + let foo_nodes: Vec<_> = result + .nodes + .iter() + .filter(|n| n.get("label").and_then(serde_json::Value::as_str) == Some("Foo")) + .collect(); + assert_eq!( + foo_nodes.len(), + 1, + "same-file class+extension must keep exactly one canonical Foo node, got {}: {foo_nodes:?}", + foo_nodes.len() + ); +} + #[test] fn extract_bash_source_user_defined_emits_calls_not_imports_from() { // When `source` is user-defined as a function (shadowing the builtin), From b0608719b36b88997e4bd83fbd64954cc44cb0aa Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 19:34:13 +0200 Subject: [PATCH 26/34] Address eighteenth CodeRabbit round on v0.8.27 resync Fixes three findings. - Correct the `scrub_backends` doc comment: it unsets only the built-in backend-selection vars from `backend_selection_env_vars()` and does NOT clear custom-provider `env_key`s (those are registry-defined, so a test relying on one must handle it explicitly). - Short-circuit `find_import_cycles_bounded` when `top_n == 0`, mirroring the existing `max_cycle_length == 0` guard, so a zero-results request skips `build_file_graph`. Add a `top_n == 0` parity test alongside the zero-max-length one. - Add descriptive failure messages to the `has_edge` assertions in the TypeScript inheritance tests so a failure names the missing edge. By the will of the Machine God --- crates/graphify-analyze/src/cycles.rs | 7 + crates/graphify-analyze/tests/parity.rs | 7 + .../graphify-extract/tests/ts_inheritance.rs | 138 ++++++++++-------- crates/graphify-llm/tests/common/mod.rs | 11 +- 4 files changed, 95 insertions(+), 68 deletions(-) diff --git a/crates/graphify-analyze/src/cycles.rs b/crates/graphify-analyze/src/cycles.rs index 0c6d42e..7085928 100644 --- a/crates/graphify-analyze/src/cycles.rs +++ b/crates/graphify-analyze/src/cycles.rs @@ -58,6 +58,13 @@ pub fn find_import_cycles_bounded( return Vec::new(); } + // Zero results requested: short-circuit before building the file graph. The + // `top_n * 10` cap would already yield nothing, but skipping the graph walk + // avoids wasted work. + if top_n == 0 { + return Vec::new(); + } + let adj = build_file_graph(graph); if adj.values().all(IndexSet::is_empty) { return Vec::new(); diff --git a/crates/graphify-analyze/tests/parity.rs b/crates/graphify-analyze/tests/parity.rs index e14d379..1c9e9a9 100644 --- a/crates/graphify-analyze/tests/parity.rs +++ b/crates/graphify-analyze/tests/parity.rs @@ -1848,6 +1848,13 @@ fn find_import_cycles_zero_max_length_returns_none() { assert!(find_import_cycles_bounded(&g, 0, 20).is_empty()); } +/// A zero `top_n` requests no results, so the enumeration short-circuits. +#[test] +fn find_import_cycles_zero_top_n_returns_none() { + let g = make_cycle_graph(GraphKind::DiGraph); + assert!(find_import_cycles_bounded(&g, 5, 0).is_empty()); +} + /// `test_find_import_cycles_skips_nodes_without_source_file` #[test] fn find_import_cycles_skips_nodes_without_source_file() { diff --git a/crates/graphify-extract/tests/ts_inheritance.rs b/crates/graphify-extract/tests/ts_inheritance.rs index 9d1f929..cd87d13 100644 --- a/crates/graphify-extract/tests/ts_inheritance.rs +++ b/crates/graphify-extract/tests/ts_inheritance.rs @@ -68,14 +68,17 @@ fn interface_extends_same_file() { "export interface Base { x: number; }\n\ export interface Derived extends Base { y: number; }\n", )]); - assert!(has_edge( - &result.edges, - "src/a.ts", - "Derived", - "src/a.ts", - "Base", - "inherits" - )); + assert!( + has_edge( + &result.edges, + "src/a.ts", + "Derived", + "src/a.ts", + "Base", + "inherits" + ), + "expected same-file `inherits` edge Derived -> Base" + ); } #[test] @@ -86,36 +89,31 @@ fn interface_extends_multiple_same_file() { interface B { b: number; }\n\ interface M extends A, B { m: number; }\n", )]); - assert!(has_edge( - &result.edges, - "src/a.ts", - "M", - "src/a.ts", - "A", - "inherits" - )); - assert!(has_edge( - &result.edges, - "src/a.ts", - "M", - "src/a.ts", - "B", - "inherits" - )); + assert!( + has_edge(&result.edges, "src/a.ts", "M", "src/a.ts", "A", "inherits"), + "expected same-file `inherits` edge M -> A" + ); + assert!( + has_edge(&result.edges, "src/a.ts", "M", "src/a.ts", "B", "inherits"), + "expected same-file `inherits` edge M -> B" + ); } #[test] fn class_extends_same_file() { let (_tmp, result) = extract_files(&[("src/a.ts", "class Animal {}\nclass Dog extends Animal {}\n")]); - assert!(has_edge( - &result.edges, - "src/a.ts", - "Dog", - "src/a.ts", - "Animal", - "inherits" - )); + assert!( + has_edge( + &result.edges, + "src/a.ts", + "Dog", + "src/a.ts", + "Animal", + "inherits" + ), + "expected same-file `inherits` edge Dog -> Animal" + ); } #[test] @@ -125,14 +123,17 @@ fn interface_extends_generic_base_same_file() { "interface Base { x: T; }\n\ interface G extends Base { y: number; }\n", )]); - assert!(has_edge( - &result.edges, - "src/a.ts", - "G", - "src/a.ts", - "Base", - "inherits" - )); + assert!( + has_edge( + &result.edges, + "src/a.ts", + "G", + "src/a.ts", + "Base", + "inherits" + ), + "expected same-file `inherits` edge G -> Base (generic base)" + ); } #[test] @@ -145,14 +146,17 @@ fn interface_extends_imported() { ), ("src/b.ts", "export interface Imported { z: number; }\n"), ]); - assert!(has_edge( - &result.edges, - "src/a.ts", - "D", - "src/b.ts", - "Imported", - "inherits" - )); + assert!( + has_edge( + &result.edges, + "src/a.ts", + "D", + "src/b.ts", + "Imported", + "inherits" + ), + "expected cross-file `inherits` edge D -> Imported" + ); } #[test] @@ -165,14 +169,17 @@ fn imported_class_extends_still_works() { ), ("src/b.ts", "export class Imported {}\n"), ]); - assert!(has_edge( - &result.edges, - "src/a.ts", - "Cat", - "src/b.ts", - "Imported", - "inherits" - )); + assert!( + has_edge( + &result.edges, + "src/a.ts", + "Cat", + "src/b.ts", + "Imported", + "inherits" + ), + "expected cross-file `inherits` edge Cat -> Imported" + ); } #[test] @@ -182,12 +189,15 @@ fn class_implements_same_file_interface() { "interface Walker { walk(): void; }\n\ class Person implements Walker { walk() {} }\n", )]); - assert!(has_edge( - &result.edges, - "src/a.ts", - "Person", - "src/a.ts", - "Walker", - "implements" - )); + assert!( + has_edge( + &result.edges, + "src/a.ts", + "Person", + "src/a.ts", + "Walker", + "implements" + ), + "expected same-file `implements` edge Person -> Walker" + ); } diff --git a/crates/graphify-llm/tests/common/mod.rs b/crates/graphify-llm/tests/common/mod.rs index 4a082fd..b45130c 100644 --- a/crates/graphify-llm/tests/common/mod.rs +++ b/crates/graphify-llm/tests/common/mod.rs @@ -34,11 +34,14 @@ impl EnvGuard { self } - /// Unset every environment variable that `detect_backend` consults, so a - /// test can drive the no-backend / custom-provider path without the host's - /// real credentials leaking in. The exact set comes from + /// Unset the built-in backend-selection environment variables (the API + /// keys, Bedrock credential vars, and `OLLAMA_BASE_URL`) so a test can drive + /// the no-backend / custom-provider path without the host's real credentials + /// leaking in. The exact set comes from /// [`graphify_llm::backend_selection_env_vars`], so it can never drift from - /// the detection logic it mirrors. + /// the detection logic. This does NOT clear custom-provider `env_key`s — + /// those are registry-defined, not statically known, so a test relying on + /// one must set or unset it explicitly. pub fn scrub_backends(&mut self) -> &mut Self { for key in graphify_llm::backend_selection_env_vars() { self.unset(key); From f80eabb897b8650db95cf39433761a8c79fe2b9a Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 19:43:23 +0200 Subject: [PATCH 27/34] Document nineteenth CodeRabbit round (no code changes) Both findings this round are disputes. Disputed (over-abstraction): CodeRabbit suggested making the `has_edge` test helper in `ts_inheritance.rs` generic over a map-like type. It is a private, single-file helper that only ever receives `ExtractOutput.edges` (`Vec>`); a generic signature adds an abstraction no caller needs (against the workspace's "no abstractions for single-use code" guideline), and the suggested `serde_json::Map` signature would not even match the concrete `IndexMap` field type. Left concrete. Disputed (parity, owner-approved): CodeRabbit wanted `provider add` to upsert and preserve hand-edited `temperature` / `max_completion_tokens` fields rather than replace the whole entry. graphify-py's `add` replaces the entry outright (`existing[name] = {...}`), and byte-identical registry output is a resync goal. The project owner chose to keep the replace behaviour for parity. No test in either codebase pins re-add semantics. Ave Deus Mechanicus From e693488d082a9afc28890eacaa99c77bce6c20a4 Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 20:16:26 +0200 Subject: [PATCH 28/34] Address twentieth CodeRabbit round on v0.8.27 resync The headline change completes the `.graphifyinclude` allowlist, which `graphify-py` defines but never wires into `detect()` (dead code in the reference). The Rust port already used `could_contain_included_path` for directory pruning yet never rescued files at classification time, so the allowlist was inert end-to-end. Completing a feature the reference leaves inert is a deliberate divergence approved by the project owner (feature parity, not bug parity), and is documented in `USAGE.md`. - `classify_one` now keeps an ignored file when it (or an ancestor directory) matches `.graphifyinclude`. The sensitive-file guard still runs after the rescue, so an allowlist can never pull secrets into the corpus. - `could_contain_included_path` now mirrors `anchored_include_matches`, so anchored stems (`/src`, `/src*`) keep the walker descending through the whole subtree instead of stopping one level deep. Adds an end-to-end `detect` regression for the rescue. - `detect_result_from_incremental` sorts each bucket so incremental reconstruction is byte-identical to a fresh `detect` walk. - The Swift extension-merge parity tests now assert the survivor is the canonical class (by `source_file`) carrying both the class and merged extension methods, not just a node count. - The `provider.rs` temp-rename comment no longer claims unconditional atomicity; overwrite and atomicity semantics are platform- and filesystem-dependent. By the will of the Machine God --- USAGE.md | 18 +++++ crates/graphify-detect/src/ignore.rs | 15 ++++- crates/graphify-detect/src/walk.rs | 38 +++++++++-- .../graphify-detect/tests/parity_include.rs | 32 +++++++++ crates/graphify-extract/tests/parity.rs | 67 +++++++++++++++++++ src/cli/extract.rs | 8 +++ src/cli/provider.rs | 6 +- 7 files changed, 178 insertions(+), 6 deletions(-) diff --git a/USAGE.md b/USAGE.md index 7b2a4c6..f142e01 100644 --- a/USAGE.md +++ b/USAGE.md @@ -539,6 +539,24 @@ the LLM spend. ## Configuration +### File selection (`.graphifyignore` / `.graphifyinclude`) + +`detect` — and therefore `extract` / `update` / `watch` — honours two optional control files at the scan root, on +top of any `.gitignore`: + +- **`.graphifyignore`** — gitignore-syntax exclude list. Same last-match-wins and parent-exclusion rules as + `.gitignore` (a `!` re-include cannot rescue a file whose ancestor directory is excluded). Loaded from the scan + root up to the VCS root. +- **`.graphifyinclude`** — gitignore-syntax **allowlist** that re-includes files an ignore rule would otherwise + drop. A file is kept when it (or an ancestor directory) matches an include pattern, even if `.graphifyignore` / + `.gitignore` excludes it. Anchored directory stems cover their whole subtree — both `/src` and the globbed + `/src*` pull in `src/deep/main.py`. The sensitive-file guard still runs after the allowlist, so an include can + never pull secrets (`.env`, private keys, etc.) into the corpus. + +Files under `graphify-out/memory/` are always detected regardless of either file. (`graphify-py` ships the +`.graphifyinclude` matcher but never wires it into `detect`, so the allowlist is inert there; the Rust port +completes the feature.) + ### Environment variables | Variable | Effect | diff --git a/crates/graphify-detect/src/ignore.rs b/crates/graphify-detect/src/ignore.rs index c7b8090..cd9f6a9 100644 --- a/crates/graphify-detect/src/ignore.rs +++ b/crates/graphify-detect/src/ignore.rs @@ -429,6 +429,12 @@ pub fn is_included(path: &Path, root: &Path, patterns: &IgnorePatterns) -> bool } /// Return `true` if a directory may contain files matched by `.graphifyinclude`. +/// +/// Used to keep the walker from pruning a subtree that the allowlist would +/// later accept. It mirrors [`anchored_include_matches`] so a directory is kept +/// when it is the matched stem, lives inside the stem's subtree (a literal +/// `/src` or globbed `/src*` covering `src/deep/main.py`), or is an ancestor of +/// a more-specific pattern target. #[must_use] pub fn could_contain_included_path(path: &Path, root: &Path, patterns: &IgnorePatterns) -> bool { if patterns.is_empty() { @@ -457,10 +463,17 @@ pub fn could_contain_included_path(path: &Path, root: &Path, patterns: &IgnorePa if p.is_empty() { continue; } + // `dir` is an ancestor of a more-specific pattern target (e.g. dir + // `src`, pattern `src/a/b.py`): descend to reach the target. if p == rel || p.starts_with(&format!("{rel}/")) { return true; } - if fnmatch(rel, p) { + // `dir` is the matched stem itself or lives inside its subtree. Use + // the same anchored-subtree test as `is_included` so the + // descendant-covering semantics (a literal `/src` or globbed `/src*` + // pulling in `src/deep/main.py`) are honoured during traversal — + // otherwise the walker prunes subtrees the allowlist would accept. + if anchored_include_matches(rel, p) { return true; } } diff --git a/crates/graphify-detect/src/walk.rs b/crates/graphify-detect/src/walk.rs index e0b9116..537ab6a 100644 --- a/crates/graphify-detect/src/walk.rs +++ b/crates/graphify-detect/src/walk.rs @@ -13,7 +13,7 @@ use rayon::prelude::*; use crate::extensions::{FileType, GOOGLE_WORKSPACE_EXTENSIONS, classify_file}; use crate::ignore::{ - IgnorePatterns, could_contain_included_path, is_ignored, load_graphifyignore, + IgnorePatterns, could_contain_included_path, is_ignored, is_included, load_graphifyignore, load_graphifyinclude, }; use crate::office::{convert_office_file, xlsx_to_markdown}; @@ -45,6 +45,7 @@ fn classify_one( memory_dir: &Path, converted_dir: &Path, ignore_patterns: &IgnorePatterns, + include_patterns: &IgnorePatterns, ) -> FileDecision { let in_memory = memory_dir.exists() && p.starts_with(memory_dir); if !in_memory && p.starts_with(converted_dir) { @@ -53,7 +54,16 @@ fn classify_one( // Memory-dir sidecars bypass ignore filtering: a user's `.gitignore` // pattern (e.g. `*.md`) must not erase the `graphify-out/memory` notes we // generate ourselves. Mirrors graphify-py `detect()` (#1047). - if !in_memory && is_ignored(p, root, ignore_patterns) { + // + // A `.graphifyinclude` allowlist re-includes an otherwise-ignored file + // (gitignore-negation style, but in a dedicated file): the walker already + // descends into ignored directories that `could_contain_included_path` + // flags, and this is the matching file-level rescue. The sensitive-file + // guard below still runs, so an allowlist cannot pull secrets into the + // corpus. (graphify-py defines these helpers but never wired them in — the + // feature was left inert there; the Rust port completes it.) + if !in_memory && is_ignored(p, root, ignore_patterns) && !is_included(p, root, include_patterns) + { return FileDecision::Skip; } if is_sensitive(p) { @@ -440,6 +450,7 @@ pub fn detect( &memory_dir, &converted_dir, &ignore_patterns, + &include_patterns, google_workspace, ); @@ -517,6 +528,7 @@ fn run_classify_phase( memory_dir: &Path, converted_dir: &Path, ignore_patterns: &crate::ignore::IgnorePatterns, + include_patterns: &crate::ignore::IgnorePatterns, google_workspace: bool, ) -> ClassifyOutput { let mut files: IndexMap> = FILE_TYPE_KINDS @@ -531,12 +543,30 @@ fn run_classify_phase( let decisions: Vec = if all_files.len() >= PARALLEL_COUNT_THRESHOLD { all_files .par_iter() - .map(|p| classify_one(p, root, memory_dir, converted_dir, ignore_patterns)) + .map(|p| { + classify_one( + p, + root, + memory_dir, + converted_dir, + ignore_patterns, + include_patterns, + ) + }) .collect() } else { all_files .iter() - .map(|p| classify_one(p, root, memory_dir, converted_dir, ignore_patterns)) + .map(|p| { + classify_one( + p, + root, + memory_dir, + converted_dir, + ignore_patterns, + include_patterns, + ) + }) .collect() }; diff --git a/crates/graphify-detect/tests/parity_include.rs b/crates/graphify-detect/tests/parity_include.rs index 284353a..c578795 100644 --- a/crates/graphify-detect/tests/parity_include.rs +++ b/crates/graphify-detect/tests/parity_include.rs @@ -249,3 +249,35 @@ fn detect_with_nested_subdirs() { let code: &Vec = result.files.get("code").expect("key present"); assert!(code.len() >= 3); } + +#[test] +fn detect_graphifyinclude_rescues_ignored_subtree() { + // End-to-end allowlist: `.graphifyignore = *` ignores everything, while + // `.graphifyinclude = /src*` re-includes a glob-matched anchored directory + // and its whole subtree. The walker must descend past the ignored dirs + // (`could_contain_included_path`) AND rescue the files at classification time + // (`is_included`), so a deeply nested allowlisted file lands in the result + // while an unrelated file stays ignored. + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path().canonicalize().expect("canonicalize"); + fs::create_dir_all(root.join("src1/deep")).expect("test invariant"); + fs::create_dir_all(root.join("other")).expect("test invariant"); + fs::write(root.join("src1/deep/main.py"), "x = 1").expect("test invariant"); + fs::write(root.join("other/skip.py"), "y = 2").expect("test invariant"); + fs::write(root.join(".graphifyignore"), "*\n").expect("test invariant"); + fs::write(root.join(".graphifyinclude"), "/src*\n").expect("test invariant"); + + let result = detect(&root, None, None); + let code: &Vec = result.files.get("code").expect("key present"); + assert!( + code.iter() + .any(|f| std::path::Path::new(f).ends_with("src1/deep/main.py")), + "allowlisted `src1/deep/main.py` must be rescued from the `*` ignore, got {code:?}" + ); + assert!( + !code + .iter() + .any(|f| std::path::Path::new(f).ends_with("skip.py")), + "non-allowlisted `other/skip.py` must stay ignored, got {code:?}" + ); +} diff --git a/crates/graphify-extract/tests/parity.rs b/crates/graphify-extract/tests/parity.rs index 32a4b9a..bf0984b 100644 --- a/crates/graphify-extract/tests/parity.rs +++ b/crates/graphify-extract/tests/parity.rs @@ -894,6 +894,58 @@ fn extract_swift_merges_extension_across_files() { "expected a single canonical Foo node, got {}: {foo_nodes:?}", foo_nodes.len() ); + // The survivor must be the canonical *class* (declared in `Foo.swift`), not + // the extension node (declared in `Foo+Ext.swift`). A count-only assertion + // would still pass if the merge kept the wrong node, so pin down the origin. + let foo = foo_nodes[0]; + let foo_src = foo + .get("source_file") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + assert_eq!( + std::path::Path::new(foo_src) + .file_name() + .and_then(|n| n.to_str()), + Some("Foo.swift"), + "retained Foo must be the class from Foo.swift, not the extension; got {foo_src:?}" + ); + // Both the class's own method (`bar`) and the merged extension's method + // (`baz`) must hang off the surviving canonical node. + let foo_id = foo + .get("id") + .and_then(serde_json::Value::as_str) + .expect("Foo node has id"); + assert!( + has_method_edge(&result, foo_id, ".bar()"), + "canonical Foo must keep its own class method `.bar()`" + ); + assert!( + has_method_edge(&result, foo_id, ".baz()"), + "canonical Foo must absorb the merged extension method `.baz()`" + ); +} + +/// Whether a `method` edge runs from node `src_id` to a node labelled +/// `target_label`. Used to assert which members hang off a Swift node after the +/// `extension` merge pass remaps edges onto the canonical class. +fn has_method_edge( + result: &graphify_extract::ExtractOutput, + src_id: &str, + target_label: &str, +) -> bool { + result.edges.iter().any(|e| { + e.get("source").and_then(serde_json::Value::as_str) == Some(src_id) + && e.get("relation").and_then(serde_json::Value::as_str) == Some("method") + && e.get("target") + .and_then(serde_json::Value::as_str) + .is_some_and(|tgt| { + result.nodes.iter().any(|n| { + n.get("id").and_then(serde_json::Value::as_str) == Some(tgt) + && n.get("label").and_then(serde_json::Value::as_str) + == Some(target_label) + }) + }) + }) } #[test] @@ -923,6 +975,21 @@ fn extract_swift_same_file_class_and_extension_keeps_canonical() { "same-file class+extension must keep exactly one canonical Foo node, got {}: {foo_nodes:?}", foo_nodes.len() ); + // The single surviving node must carry both the class method (`bar`) and the + // extension method (`baz`), proving it is the real class node and not an + // extension-shaped remnant. + let foo_id = foo_nodes[0] + .get("id") + .and_then(serde_json::Value::as_str) + .expect("Foo node has id"); + assert!( + has_method_edge(&result, foo_id, ".bar()"), + "canonical Foo must keep its own class method `.bar()`" + ); + assert!( + has_method_edge(&result, foo_id, ".baz()"), + "canonical Foo must keep the same-file extension method `.baz()`" + ); } #[test] diff --git a/src/cli/extract.rs b/src/cli/extract.rs index bebdf61..5631561 100644 --- a/src/cli/extract.rs +++ b/src/cli/extract.rs @@ -229,6 +229,14 @@ fn detect_result_from_incremental( for (kind, paths) in &inc.unchanged_files { files.entry(kind.clone()).or_default().extend(paths.clone()); } + // Sort each bucket so the reconstructed lists match a fresh `detect` walk + // byte-for-byte (which sorts every bucket — see `walk::detect`). Without + // this, concatenating changed-then-unchanged would interleave paths out of + // order and make incremental extraction non-deterministic relative to a + // full scan. + for bucket in files.values_mut() { + bucket.sort(); + } let total_files = files.values().map(Vec::len).sum(); graphify_detect::DetectResult { files, diff --git a/src/cli/provider.rs b/src/cli/provider.rs index ed1bf36..6dc6948 100644 --- a/src/cli/provider.rs +++ b/src/cli/provider.rs @@ -60,7 +60,11 @@ fn save_registry(registry: &Map) -> Result<()> { } let body = serde_json::to_string_pretty(registry)?; // Write to a sibling temp file then rename, so a crash mid-write can't leave - // a truncated/corrupt registry (rename is atomic on the same filesystem). + // a truncated/corrupt registry. `std::fs::rename` replaces an existing + // destination on both Unix and Windows, but the exact atomicity / overwrite + // semantics are platform- and filesystem-dependent (e.g. Windows needs + // specific kernel support for POSIX-style atomic replace), so this is a + // best-effort guard rather than a hard atomicity guarantee. let tmp = path.with_extension("json.tmp"); std::fs::write(&tmp, format!("{body}\n"))?; if let Err(e) = std::fs::rename(&tmp, &path) { From 26d8e73aadb8871b036e23f94e59ac0ba64335a1 Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 20:28:39 +0200 Subject: [PATCH 29/34] Document twenty-first CodeRabbit round (no code changes) All three findings this round are previously-approved disputes verified against the current code; no changes warranted. - `crates/graphify-llm/src/call.rs` and `crates/graphify-llm/src/extract.rs`: CodeRabbit again flags the per-call `load_custom_providers()` disk read and asks for a process-wide cache. Approved won't-fix: a `OnceLock` / `LazyLock` global breaks the `#[serial]` HOME-mutating test isolation (each test points `HOME` at a different `providers.json`), and threading a resolved registry through every call site is disproportionate for a path taken only by custom (non-built-in) backends. - `src/cli/provider.rs`: CodeRabbit asks for a `--temperature` flag on `provider add`. Disputed on parity grounds: graphify-py `__main__.py` (lines 1872-1878) hardcodes `"temperature": 0` and parses no `--temperature` argument, so the Rust `add` is byte-identical to the reference. Adding a flag would exceed the feature-parity bar. Glory to the Omnissiah From 25dcd57a806328c7885d86df533c1db1504f793f Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 20:37:57 +0200 Subject: [PATCH 30/34] Document twenty-second CodeRabbit round (no code changes) All three findings this round are disputes verified against the current code and the graphify-py reference; no changes warranted. - `crates/graphify-export/Cargo.toml`: the recurring sha1/sha2 digest "version split" is a false positive. `cargo tree` shows `sha1 v0.11.0` and `sha2 v0.11.0` resolving through a single `digest v0.11.3`; the workspace pins `sha2 = "0.11"`, so `sha1 = "0.11"` already unifies the trait family. Downgrading sha1 would *create* the split. - `crates/graphify-export/src/canvas.rs`: `cap_filename` is parity-matched. graphify-py `export.py` (`safe_name` -> `_cap_filename(..., limit=200)` then `f"{base}_{count}"`) caps before dedup exactly as Rust does, and the 200-byte cap reserves 55 bytes under the 255-byte limit for `.md` plus the dedup suffix. Overflow would need >1e51 collisions; re-ordering the cap would diverge from the reference for no real-world gain. - `crates/graphify-analyze/src/cycles.rs`: the `top_n * 10` enumeration cap matches graphify-py `analyze.py` (#961), which caps `nx.simple_cycles` output at `top_n * 10` before `cycles.sort(key=len)`. Both suggested changes (sort-then-limit, or length-prioritised enumeration) would defeat the documented combinatorial-explosion guard and break byte-identical parity with the reference. Ave Deus Mechanicus From 046b85f1ad6b44aaaf20cf56c2f10879af351eee Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 20:49:37 +0200 Subject: [PATCH 31/34] Harden custom-provider routing tests; dispute labels format Twenty-third CodeRabbit round: one fix, one dispute. - `crates/graphify-llm/tests/provider_registry.rs`: add `scrub_backends()` to the `call_llm` and `extract_files_direct` custom-provider routing tests. Both were already hermetic (an explicit non-builtin backend routes straight through the custom path and never consults built-in env vars), but scrubbing makes the isolation explicit, matches the sibling detection test's setup, and guards against a future refactor that grew a built-in fallback. `CUSTOM*_KEY` is a registry env_key and is untouched by the scrub. Disputed (no change): - `src/cli/cluster_only.rs`: CodeRabbit asked to pretty-print `.graphify_labels.json`. Declined: `extract.rs` writes the same file with compact `serde_json::to_string`, and graphify-py `__main__.py` (line 2448) writes it single-line via `json.dumps` without `indent`. Pretty-printing would break both internal consistency and byte-level parity with the reference. By the will of the Machine God --- crates/graphify-llm/tests/provider_registry.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/graphify-llm/tests/provider_registry.rs b/crates/graphify-llm/tests/provider_registry.rs index a726c0b..b78ff0f 100644 --- a/crates/graphify-llm/tests/provider_registry.rs +++ b/crates/graphify-llm/tests/provider_registry.rs @@ -211,6 +211,10 @@ fn custom_provider_call_llm_routes_via_openai_compat() { .expect("write providers.json"); let mut g = EnvGuard::new(); + // Hermetic setup: clear built-in backend keys so this stays a pure + // custom-provider routing test even if `call_llm` ever grows a built-in + // fallback. (`CUSTOM1_KEY` is a registry env_key, untouched by scrub.) + g.scrub_backends(); g.set("HOME", &home.path().to_string_lossy()); g.set("GRAPHIFY_TEST_ALLOW_PRIVATE_IPS", "1"); g.set("CUSTOM1_KEY", "secret"); @@ -256,6 +260,8 @@ fn custom_provider_extract_files_routes_via_openai_compat() { std::fs::write(&file, "def f():\n return 1\n").expect("write source"); let mut g = EnvGuard::new(); + // Hermetic setup: see `custom_provider_call_llm_routes_via_openai_compat`. + g.scrub_backends(); g.set("HOME", &home.path().to_string_lossy()); g.set("GRAPHIFY_TEST_ALLOW_PRIVATE_IPS", "1"); g.set("CUSTOM2_KEY", "secret"); From dba8e766739614bdefa6d91ec9be91f1a76f26ff Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 21:03:10 +0200 Subject: [PATCH 32/34] Harden labeling test; drop redundant unsafe allow Twenty-fourth CodeRabbit round: three fixes, four disputes. Fixes: - `crates/graphify-llm/tests/labeling.rs`: add `scrub_backends()` to the custom-provider labeling test, matching the routing tests hardened in the previous round (hermetic, explicit-backend setup). - `crates/graphify-llm/tests/provider_registry.rs`: drop the now-redundant `unsafe_code` from the crate-level allow. CodeRabbit flagged the asymmetry with `labeling.rs`; the correct direction is removal. The earlier round replaced this file's inline `std::env::remove_var` scrub loop with the safe `EnvGuard::scrub_backends()` call, so no direct `unsafe` remains here (the `unsafe` blocks live in `tests/common/mod.rs`, which carries its own inner allow). Clippy confirms it compiles clean. - `src/cli/cluster_only.rs`: annotate the bare `false` argument to `generate_community_labels` as `// quiet`. Disputed (no change): - `crates/graphify-extract/tests/ts_inheritance.rs`: a single-use `EdgeMap` type alias for `IndexMap` is over-abstraction; the concrete type matches the file's style. - `crates/graphify-export/Cargo.toml`: the workspace MSRV is already 1.95 (`rust-version = "1.95"`), well above `sha1 = "0.11"`'s 1.85 floor. - `crates/graphify-analyze/src/cycles.rs`: the requested explanatory comment on the edge-orientation fallback already exists (lines 139-143). - `src/cli/cluster_only.rs`: pretty-printing or conditionally skipping the `.graphify_labels.json` write both diverge from graphify-py `__main__.py:2448`, which writes single-line via `json.dumps` and rewrites unconditionally. Glory to the Omnissiah --- crates/graphify-llm/tests/labeling.rs | 4 ++++ crates/graphify-llm/tests/provider_registry.rs | 2 +- src/cli/cluster_only.rs | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/graphify-llm/tests/labeling.rs b/crates/graphify-llm/tests/labeling.rs index 6218fbb..9ebac0c 100644 --- a/crates/graphify-llm/tests/labeling.rs +++ b/crates/graphify-llm/tests/labeling.rs @@ -206,6 +206,10 @@ fn label_communities_real_path_via_custom_provider() { .expect("write providers.json"); let mut g = EnvGuard::new(); + // Hermetic setup: clear built-in backend keys so this stays a pure + // custom-provider labeling test. (`LABELPROV_KEY` is a registry env_key, + // untouched by scrub.) + g.scrub_backends(); g.set("HOME", &home.path().to_string_lossy()); g.set("GRAPHIFY_TEST_ALLOW_PRIVATE_IPS", "1"); g.set("LABELPROV_KEY", "secret"); diff --git a/crates/graphify-llm/tests/provider_registry.rs b/crates/graphify-llm/tests/provider_registry.rs index b78ff0f..4c40a29 100644 --- a/crates/graphify-llm/tests/provider_registry.rs +++ b/crates/graphify-llm/tests/provider_registry.rs @@ -1,7 +1,7 @@ //! Parity tests for the custom LLM provider registry (#1084). //! //! Mirrors `graphify-py/tests/test_provider_registry.py`. -#![allow(clippy::expect_used, clippy::float_cmp, unsafe_code)] +#![allow(clippy::expect_used, clippy::float_cmp)] use graphify_llm::{ CustomProvider, Pricing, call_llm, detect_backend_with, extract_files_direct, diff --git a/src/cli/cluster_only.rs b/src/cli/cluster_only.rs index fd6ee95..0c3d0d7 100644 --- a/src/cli/cluster_only.rs +++ b/src/cli/cluster_only.rs @@ -178,7 +178,7 @@ pub(crate) fn cmd_cluster_only( &node_labels, &gods, opts.backend, - false, + false, // quiet ); labels }; From 26322af46751cc3cc002657915440e86ebf6c266 Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 21:13:55 +0200 Subject: [PATCH 33/34] Cover ?-glob include branch; dispute five nitpicks Twenty-fifth CodeRabbit round: one fix, five disputes. Fix: - `crates/graphify-detect/tests/parity_include.rs`: add a regression test for an anchored `?`-glob include stem (`/src?`), exercising the `p.contains('?')` branch of `anchored_include_matches` that the existing `/src*` test left uncovered. Disputed (no change): - `crates/graphify-detect/src/walk.rs`: `FileType` has exactly five variants and `as_str()` is an exhaustive match onto exactly `FILE_TYPE_KINDS`, so bucket insertion can only ever hit a pre-seeded key. A runtime assertion would guard a compile-time-impossible state. - `crates/graphify-llm/src/call.rs` and `.../extract.rs`: the per-call `load_custom_providers()` read is the standing approved won't-fix (a global cache breaks `#[serial]` HOME-mutating test isolation). - `crates/graphify-llm/src/bedrock.rs`: the closure is correct and clippy-clean. `CREDENTIAL_ENV_VARS: &[&str]` means `.iter()` yields `&&str`, so the `filter` predicate receives `&&&str` and `**k` is the `&str`. CodeRabbit's `|&k|` (binding `&&str`) would not match the `&str` literal patterns. - `crates/graphify-export/Cargo.toml`: the recurring sha1/sha2 "digest split" is a false positive. `cargo tree` shows `sha1 v0.11.0` and `sha2 v0.11.0` resolving through one `digest v0.11.3`; the workspace pins `sha2 = "0.11"`. Ave Deus Mechanicus --- .../graphify-detect/tests/parity_include.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/graphify-detect/tests/parity_include.rs b/crates/graphify-detect/tests/parity_include.rs index c578795..89ac1a8 100644 --- a/crates/graphify-detect/tests/parity_include.rs +++ b/crates/graphify-detect/tests/parity_include.rs @@ -105,6 +105,32 @@ fn is_included_anchored_globbed_dir_matches_subtree() { ); } +#[test] +fn is_included_anchored_question_glob_dir_matches_subtree() { + // An anchored `?`-glob stem (`/src?`) matches a single extra character and + // covers the subtree beneath the matched directory, exercising the `?` + // branch of `anchored_include_matches` (the `*` branch is covered above). + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path().canonicalize().expect("canonicalize"); + fs::create_dir_all(root.join("src1/deep")).expect("test invariant"); + fs::create_dir_all(root.join("lib")).expect("test invariant"); + fs::write(root.join(".graphifyinclude"), "/src?\n").expect("test invariant"); + let patterns = load_graphifyinclude(&root); + + assert!( + is_included(&root.join("src1"), &root, &patterns), + "/src? must match a single-character-glob directory" + ); + assert!( + is_included(&root.join("src1/deep/main.py"), &root, &patterns), + "/src? must include files beneath the matched directory" + ); + assert!( + !is_included(&root.join("lib/main.py"), &root, &patterns), + "/src? must not match an unrelated directory" + ); +} + #[test] fn is_included_anchored_file_matches_only_at_root() { // An anchored file pattern (`/setup.py`) matches at the anchor root but not From 8089979c445f67dabd564efe4f5297a29f93b099 Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Mon, 1 Jun 2026 21:35:55 +0200 Subject: [PATCH 34/34] Propagate allowlist rescue to sidecars; reject NaN pricing Address two CodeRabbit findings on the `.graphifyinclude` rescue and the `provider add` pricing inputs. `ConvertCtx::record` re-checked `is_ignored` on the converted sidecar path without honouring the source's allowlist verdict. A source rescued by `.graphifyinclude` but ignored by a broad pattern such as `*` had its office/Google-Workspace sidecar silently dropped, so the very content the rescue was meant to keep never reached the corpus. `record` now takes the source path and bypasses the sidecar ignore check when `is_included(src)` holds, mirroring the `Direct` path in `classify_one`. The allowlist is keyed on source paths, so the verdict is taken from the source rather than the derived `.md` path. An ordinary (non-rescued) source still has its sidecar filtered through `.graphifyignore`, matching graphify-py `detect()`. `provider add` accepted non-finite `--pricing-input` / `--pricing-output` values. `serde_json` serializes `NaN`/`+-Inf` to `null`, which the loader reads back as the `0.0` default, so an invalid price was silently lost rather than rejected. `add` now validates both values with `f64::is_finite` and returns an error before writing the registry. Both branches are covered: a docx rescued past a `*` ignore whose sidecar must survive, and a `--pricing-input nan` add that must fail without creating a registry file. Glory to the Omnissiah --- crates/graphify-detect/src/walk.rs | 24 +++++++--- .../graphify-detect/tests/parity_include.rs | 48 +++++++++++++++++++ src/cli/provider.rs | 9 ++++ tests/cli_commands.rs | 33 +++++++++++++ 4 files changed, 108 insertions(+), 6 deletions(-) diff --git a/crates/graphify-detect/src/walk.rs b/crates/graphify-detect/src/walk.rs index 537ab6a..14dbee6 100644 --- a/crates/graphify-detect/src/walk.rs +++ b/crates/graphify-detect/src/walk.rs @@ -575,6 +575,7 @@ fn run_classify_phase( root, converted_dir, ignore_patterns, + include_patterns, files: &mut files, to_count: &mut to_count, skipped_sensitive: &mut skipped_sensitive, @@ -671,16 +672,27 @@ struct ConvertCtx<'a> { root: &'a Path, converted_dir: &'a Path, ignore_patterns: &'a IgnorePatterns, + include_patterns: &'a IgnorePatterns, files: &'a mut IndexMap>, to_count: &'a mut Vec<(PathBuf, FileType)>, skipped_sensitive: &'a mut Vec, } impl ConvertCtx<'_> { - /// Register a converted sidecar file. Word counting is deferred to a - /// parallel pass after all conversions have completed. - fn record(&mut self, md_path: &Path, ftype: FileType) { - if is_ignored(md_path, self.root, self.ignore_patterns) { + /// Register a converted sidecar file produced from `src`. + /// + /// The sidecar inherits its source's allowlist verdict: an ordinary source + /// still has its sidecar filtered through `.graphifyignore` (matching + /// graphify-py `detect()`), but a source rescued by `.graphifyinclude` + /// keeps its sidecar even when the converted path would otherwise be + /// ignored. The allowlist is keyed on source paths, so the verdict is taken + /// from `src` rather than the derived `md_path` — otherwise a global ignore + /// such as `*` would silently drop the very content the rescue was meant to + /// keep (the `Direct` path already honours this in `classify_one`). Word + /// counting is deferred to a parallel pass after all conversions complete. + fn record(&mut self, src: &Path, md_path: &Path, ftype: FileType) { + let source_rescued = is_included(src, self.root, self.include_patterns); + if !source_rescued && is_ignored(md_path, self.root, self.ignore_patterns) { return; } self.files @@ -729,7 +741,7 @@ fn convert_google_workspace( None, ); match convert_res { - Ok(Some(md_path)) => ctx.record(&md_path, ftype), + Ok(Some(md_path)) => ctx.record(p, &md_path, ftype), Ok(None) => ctx.skipped_sensitive.push(format!( "{} [Google Workspace export produced no readable text]", p.to_string_lossy() @@ -744,7 +756,7 @@ fn convert_google_workspace( /// Convert a `.docx`/`.xlsx` to a markdown sidecar via `office::convert_office_file`. fn convert_office(ctx: &mut ConvertCtx<'_>, p: &Path, ftype: FileType) { match convert_office_file(p, ctx.converted_dir) { - Ok(Some(md_path)) => ctx.record(&md_path, ftype), + Ok(Some(md_path)) => ctx.record(p, &md_path, ftype), Ok(None) => ctx.skipped_sensitive.push(format!( "{} [office document contained no extractable text]", p.to_string_lossy() diff --git a/crates/graphify-detect/tests/parity_include.rs b/crates/graphify-detect/tests/parity_include.rs index 89ac1a8..1028461 100644 --- a/crates/graphify-detect/tests/parity_include.rs +++ b/crates/graphify-detect/tests/parity_include.rs @@ -307,3 +307,51 @@ fn detect_graphifyinclude_rescues_ignored_subtree() { "non-allowlisted `other/skip.py` must stay ignored, got {code:?}" ); } + +/// Build a minimal valid DOCX (a ZIP holding `word/document.xml`) so `detect`'s +/// office-conversion path produces a non-empty markdown sidecar. +fn build_minimal_docx(path: &std::path::Path, body: &str) { + use std::io::Write; + let f = fs::File::create(path).expect("test invariant"); + let mut zip = zip::ZipWriter::new(f); + let opts: zip::write::SimpleFileOptions = + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + zip.start_file("word/document.xml", opts) + .expect("test invariant"); + let xml = format!( + r#" + + {body} +"# + ); + zip.write_all(xml.as_bytes()).expect("test invariant"); + zip.finish().expect("test invariant"); +} + +#[test] +fn detect_graphifyinclude_rescues_converted_office_sidecar() { + // The allowlist rescue must extend to converted sidecars: an office doc that + // is ignored (`*`) but explicitly included is converted to a markdown + // sidecar under `graphify-out/converted/`. That sidecar path also matches the + // `*` ignore, so unless it inherits the source's allowlist verdict it would + // be dropped by `ConvertCtx::record` and the rescued content would silently + // vanish. Office files are never recorded directly - only their sidecar - so + // a non-empty `document` bucket here proves the rescue reached conversion. + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path().canonicalize().expect("canonicalize"); + build_minimal_docx(&root.join("report.docx"), "quarterly numbers"); + fs::write(root.join(".graphifyignore"), "*\n").expect("test invariant"); + fs::write(root.join(".graphifyinclude"), "report.docx\n").expect("test invariant"); + + let result = detect(&root, None, None); + let docs: &Vec = result.files.get("document").expect("key present"); + assert!( + docs.iter().any(|f| { + std::path::Path::new(f) + .extension() + .is_some_and(|e| e == "md") + && f.contains("converted") + }), + "rescued `report.docx` sidecar must survive the `*` ignore, got {docs:?}" + ); +} diff --git a/src/cli/provider.rs b/src/cli/provider.rs index 6dc6948..cac1dfd 100644 --- a/src/cli/provider.rs +++ b/src/cli/provider.rs @@ -125,6 +125,15 @@ fn add( )); }; + // Reject NaN/+-Inf pricing up front: `serde_json` serializes non-finite + // floats to `null`, which the loader then reads back as the 0.0 default, so + // an invalid price would be silently lost rather than stored. + if !pricing_input.is_finite() || !pricing_output.is_finite() { + return Err(anyhow!( + "Error: --pricing-input and --pricing-output must be finite numbers." + )); + } + let mut registry = load_registry()?; registry.insert( name.to_string(), diff --git a/tests/cli_commands.rs b/tests/cli_commands.rs index 601b080..e849fa9 100644 --- a/tests/cli_commands.rs +++ b/tests/cli_commands.rs @@ -651,6 +651,39 @@ fn provider_malformed_registry_is_not_clobbered() { .stderr(contains("malformed")); } +#[test] +fn provider_add_rejects_non_finite_pricing() { + // A non-finite price (`nan`/`inf`) would serialize to JSON `null` and read + // back as the 0.0 default, silently losing the value, so it is rejected. + let home = tempfile::tempdir().unwrap(); + cli() + .env("HOME", home.path()) + .args([ + "provider", + "add", + "nvidia", + "--base-url", + "http://x/v1", + "--default-model", + "m", + "--env-key", + "K", + "--pricing-input", + "nan", + ]) + .assert() + .failure() + .stderr(contains("finite")); + // The rejected add must not have created a registry file. + assert!( + !home + .path() + .join(".graphify") + .join("providers.json") + .exists() + ); +} + // ── label command shares the cluster-only handler (#1097) ─────────────────── #[test]