diff --git a/.githooks/pre-push b/.githooks/pre-push index 516e648..1685657 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -10,7 +10,8 @@ # 1. cargo fmt --all --check (rustfmt) # 2. cargo clippy ... -D warnings (lint, warnings are errors) # 3. cargo test --workspace (incl. golden oracle + sync equivalence) -# 4. scripts/guardrail.sh (no AI / vector / LLM crates) +# 4. scripts/guardrail.sh (no AI/vector/LLM crates + no release +# asset-name drift) # # Enable once per clone: # make hooks # == git config core.hooksPath .githooks @@ -45,11 +46,12 @@ if ! cargo test --workspace; then exit 1 fi -echo "==> [4/4] scripts/guardrail.sh (no AI/vector/LLM crates)" +echo "==> [4/4] scripts/guardrail.sh (no AI/vector/LLM crates + asset-name drift)" if ! bash scripts/guardrail.sh; then echo "" >&2 - echo "❌ Scope guardrail failed: a forbidden AI/vector/LLM crate was detected." >&2 - echo " This project is deterministic and ML-free by design. Push aborted." >&2 + echo "❌ Scope guardrail failed: a forbidden AI/vector/LLM crate was detected," >&2 + echo " or the release workflow and the installers disagree on asset names." >&2 + echo " Read the diagnostics above; push aborted." >&2 exit 1 fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e376ea9..a33ee88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,14 @@ jobs: - name: Clippy lint run: cargo clippy --workspace --all-targets -- -D warnings + # Materializes the frozen v0.40.4 legacy CLI for the Batch M legacy + # compatibility tests, using THIS host's NATIVE asset (linux x86_64). The + # script verifies the archive digest, the extracted executable digest, and + # the real `--version` before exporting the path. A network or digest + # failure fails the job here — legacy tests are never skipped. + - name: Prepare legacy v0.40.4 fixture (native linux asset) + run: echo "CODEGRAPH_LEGACY_BIN=$(bash scripts/setup-legacy-fixture.sh)" >> "$GITHUB_ENV" + - name: Run tests (incl. golden oracle + sync equivalence) run: cargo test --workspace @@ -46,7 +54,25 @@ jobs: # (tree-sitter/libsqlite3-sys/ring) require MSVC tooling. Runtime # correctness is gated by the native windows-latest job below. - - name: Scope guardrail (no AI / vector / LLM crates) + # The guardrail's asset-name drift gate parses release-please.yml with + # yaml.safe_load, so PyYAML must be importable. Idempotent: installs only + # when the runner image does not already ship it. A missing PyYAML must + # never degrade the gate into a silent skip — the gate itself exits nonzero + # in that case, and this step fails loudly first. + - name: Ensure PyYAML (asset-name drift gate) + run: | + python3 -c 'import yaml' \ + || python3 -m pip install --break-system-packages PyYAML \ + || python3 -m pip install PyYAML + + # The CI-gate integrity block of the guardrail runs the shipped `ci-success` + # step body over a synthetic needs context, which needs jq. Present on + # ubuntu-latest; asserted here so a missing jq fails loudly rather than + # letting the gate degrade. + - name: Ensure jq (CI gate-integrity check) + run: jq --version + + - name: Scope guardrail (no AI/vector/LLM crates + release asset-name drift + CI gate integrity) run: bash scripts/guardrail.sh windows: @@ -69,9 +95,27 @@ jobs: # codegraph-bench is a Unix-only dev harness (publish=false); excluded from the Windows job. run: cargo clippy --workspace --exclude codegraph-bench --all-targets -- -D warnings + # Same fixture step, but the script selects THIS host's NATIVE asset + # (windows x86_64 .zip). There is no cross-execution and no emulation: + # the Windows legacy proof runs only here, on a real Windows runner. + - name: Prepare legacy v0.40.4 fixture (native windows asset) + shell: bash + run: echo "CODEGRAPH_LEGACY_BIN=$(bash scripts/setup-legacy-fixture.sh)" >> "$GITHUB_ENV" + - name: Run tests run: cargo test --workspace --exclude codegraph-bench + # Batch M item 16 acceptance: ONE long-lived shipped `serve --mcp` process + # releases every SQLite handle when a request completes, so the v2 main + # database can be REPLACED (MoveFileEx + REPLACE_EXISTING) without the + # compatibility close seam, and the next request serves only the + # replacement graph. The replacement rename is the Windows-specific handle + # proof (a retained handle makes it fail with a sharing violation), so this + # target is selected EXPLICITLY here — never compile-only, never skipped — + # even though the workspace run above already covers it. + - name: Run Batch M item 16 native Windows replacement acceptance + run: cargo test -p codegraph-rs --test batch_m_long_lived_mcp + audit: name: Security Audit runs-on: ubuntu-latest @@ -133,18 +177,87 @@ jobs: # to repo Secrets to use it; harmless/ignored if unset on a public repo. token: ${{ secrets.CODECOV_TOKEN }} + # ci-success is the SINGLE required status check for branch protection AND the + # job the release workflow's `verify-ci` waits on before a release may leave + # draft. It must therefore be a STRICT gate: it passes only when every required + # job's result is exactly `success`. + # + # WHY strict (the bug this shape fixes): `needs..result` can be `success`, + # `failure`, `cancelled`, or `skipped`. The previous check rejected only + # `failure`, so `cancelled` and `skipped` both PASSED. Combined with + # `cancel-in-progress: true` above, a run cancelled by a newer push could still + # conclude `CI Success` — and release-please.yml's `verify-ci` gates the release + # on exactly that job. A release could then be cut on a run whose tests never + # finished. Anything other than `success` now fails, including any result value + # GitHub may add in the future (unknown values are not silently tolerated). + # + # WHY the results are read from `toJSON(needs)` rather than named one by one: + # the step body hardcodes no job name, so adding a job to `needs:` below + # automatically makes it required with no change to the script. GitHub Actions + # cannot make this fully automatic — the `needs` context contains ONLY the jobs + # listed in `needs:`, so a new job that is never added there is invisible to + # this gate no matter how the expression is written. That gap is closed OUTSIDE + # the workflow, loudly: `scripts/check-ci-gate.sh` (run by scripts/guardrail.sh + # in `make ci`, the pre-push hook, and the CI `test` job) asserts that + # `ci-success.needs` equals every job in this file minus an explicit + # informational allow-list, so forgetting to list a new job turns CI red + # instead of silently narrowing the gate. ci-success: name: CI Success runs-on: ubuntu-latest if: always() # NOTE: `coverage` is intentionally NOT listed here — coverage is - # informational and must never gate a merge. + # informational (codecov.yml sets `informational: true`, baseline ~72% vs an + # aspirational 95% target) and must never gate a merge or a release. It is + # the ONLY allowed exclusion; scripts/check-ci-gate.sh enforces that, and + # also enforces that codecov.yml still marks coverage informational, so the + # exclusion can never outlive its justification. needs: [test, audit, windows] steps: - name: Check job status + env: + # The whole needs context as JSON: {"test":{"result":"success",...},...} + NEEDS_JSON: ${{ toJSON(needs) }} run: | - if [[ "${{ needs.test.result }}" == "failure" || "${{ needs.audit.result }}" == "failure" || "${{ needs.windows.result }}" == "failure" ]]; then - echo "❌ CI failed" + set -euo pipefail + + if [ -z "${NEEDS_JSON:-}" ]; then + echo "::error title=CI gate::the needs context was empty; cannot prove any required job succeeded — failing closed." + exit 1 + fi + if ! printf '%s' "$NEEDS_JSON" | jq -e 'type == "object" and length > 0' > /dev/null 2>&1; then + echo "::error title=CI gate::the needs context did not parse as a non-empty JSON object — failing closed." + printf 'needs context was: %s\n' "$NEEDS_JSON" + exit 1 + fi + + # One row per required job: "\t". A job whose `result` key + # is absent renders as and fails, never passes. + mapfile -t rows < <( + printf '%s' "$NEEDS_JSON" \ + | jq -r 'to_entries | sort_by(.key)[] | "\(.key)\t\(.value.result // "")"' + ) + if [ "${#rows[@]}" -eq 0 ]; then + echo "::error title=CI gate::no required jobs were found in the needs context — failing closed." + exit 1 + fi + + echo "Required job results (only 'success' passes):" + offenders=0 + for row in "${rows[@]}"; do + job="${row%%$'\t'*}" + result="${row#*$'\t'}" + if [ "$result" = "success" ]; then + printf ' ✅ %-12s %s\n' "$job" "$result" + else + printf ' ❌ %-12s %s\n' "$job" "$result" + echo "::error title=CI gate::required job '${job}' concluded '${result}', not 'success'." + offenders=$((offenders + 1)) + fi + done + + if [ "$offenders" -ne 0 ]; then + echo "❌ CI failed: ${offenders} of ${#rows[@]} required job(s) did not conclude 'success' (see the ❌ rows above)." exit 1 fi - echo "✅ CI passed" + echo "✅ CI passed: all ${#rows[@]} required job(s) concluded 'success'." diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 421f383..0f358a9 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -302,6 +302,30 @@ jobs: path: dist merge-multiple: true + # Checksums are computed HERE, from the very same merged `dist/` that the + # attach step below uploads, so the sums provably describe the uploaded + # bytes. Determinism: `LC_ALL=C sort` on basenames gives a byte-order, + # locale-independent ordering; sha256sum is run with cwd=dist so each line + # carries the bare asset name (no `dist/` prefix); output is LF-only. + - name: Generate SHA256SUMS + shell: bash + working-directory: dist + run: | + set -euo pipefail + shopt -s nullglob + archives=(*.tar.gz *.zip) + if [ "${#archives[@]}" -eq 0 ]; then + echo "::error::no release archives found in dist/" >&2 + exit 1 + fi + mapfile -t sorted < <(printf '%s\n' "${archives[@]}" | LC_ALL=C sort) + : > SHA256SUMS + for archive in "${sorted[@]}"; do + sha256sum "$archive" >> SHA256SUMS + done + echo "--- SHA256SUMS ---" + cat SHA256SUMS + - name: Generate release notes uses: orhun/git-cliff-action@v4 with: @@ -321,6 +345,7 @@ jobs: files: | dist/*.tar.gz dist/*.zip + dist/SHA256SUMS env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index b1dee1f..a565ed6 100644 --- a/.gitignore +++ b/.gitignore @@ -20,9 +20,7 @@ target/llvm-cov/ # Per-project index DB written by `codegraph init` / `index`. .codegraph/ - -# Local upstream-sync ledger (colby-upstream-sync skill); not part of the repo. -docs/upstream-sync/ +.codegraph-v2/ # AWS CodeBuild local config (sensitive — never commit) .aws/codebuild.env diff --git a/.oxfmtignore b/.oxfmtignore index 512eaf0..1aed11b 100644 --- a/.oxfmtignore +++ b/.oxfmtignore @@ -9,6 +9,11 @@ changelog/ .release-please-manifest.json release-please-config.json +# Frozen/hash-addressed upstream-sync provenance. These bytes are reviewed and +# verified by SHA rather than rewritten by the general Markdown formatter. +docs/upstream-sync/UPSTREAM.md +docs/upstream-sync/V1_5_COMMIT_MANIFEST.md + # Golden artifacts are byte-compared by the equivalence/golden oracle tests # (include_str! / fs::read). Reformatting them breaks the test suite. reference/ diff --git a/AGENTS.md b/AGENTS.md index e7f0510..2ba5541 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,9 +81,10 @@ Full Godot static-analysis scope, static-vs-runtime boundary, and honesty signal ## HTTP MCP server: background mode + addr-keyed registry -`serve --mcp` (stdio) uses the PER-PROJECT daemon (`.codegraph/daemon.pid` + socket). `serve --http` +`serve --mcp` (stdio) uses the PER-PROJECT daemon (`.codegraph-v2/daemon.pid` + socket; the whole +rendezvous is derived from `IndexPaths::current_root`). `serve --http` (streamable-HTTP) is different: HTTP servers are keyed by BIND ADDR — a global server (no `--path`) -spans many projects — so they use a GLOBAL, addr-keyed registry, NOT `.codegraph/`. The registry +spans many projects — so they use a GLOBAL, addr-keyed registry, NOT the per-project root. The registry lives in `codegraph-daemon/src/http_registry.rs`: one `.json` file per running server (`HttpServerInfo { pid, addr, mode, project, started_at, version, log_file }`) under `$XDG_STATE_HOME/codegraph/http` (else `~/.local/state/codegraph/http`; `%LOCALAPPDATA%\codegraph\http` @@ -158,9 +159,10 @@ make coverage # workspace coverage summary (informational; `make coverage-htm `Security Audit` (cargo-audit) + `CI Success` gate, on push/PR to `main`. - **Release** (`.github/workflows/release-please.yml`): release-please opens a release PR; merging it cuts a `v` tag and triggers the pipeline — - 4-platform binaries (linux musl x86_64/aarch64 via cargo-zigbuild, macOS - x86_64/aarch64), git-cliff release notes, and a GitHub Release with the - binaries attached. The project is distributed via GitHub Releases + + 6-platform binaries (linux musl x86_64/aarch64 via cargo-zigbuild, macOS + x86_64/aarch64, and Windows MSVC `x86_64-pc-windows-msvc` on `windows-latest` + plus `aarch64-pc-windows-msvc` on `windows-11-arm`), git-cliff release notes, + and a GitHub Release with the binaries attached. The project is distributed via GitHub Releases + `cargo install --git`; it is NOT published to crates.io. Version bumps are owned by release-please via `.release-please-manifest.json` — never bump by hand. diff --git a/Makefile b/Makefile index 6ee40c6..16103d9 100644 --- a/Makefile +++ b/Makefile @@ -126,7 +126,8 @@ test: @echo "🧪 Running tests..." $(CARGO) test --workspace -# Scope guardrail: no AI / vector / LLM crates allowed in the workspace +# Scope guardrail: no AI / vector / LLM crates in the workspace, and no drift +# between the release workflow's asset names and what the installers download. guardrail: @echo "🛡️ Running scope guardrail..." bash scripts/guardrail.sh diff --git a/README.md b/README.md index 1bb9b14..1dfee78 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ No AI/LLM anywhere inside the binary — output is byte-stable and deterministic ## Table of Contents - [Quickstart](#quickstart) +- [Upgrading from v0.40.4](#upgrading-from-v0404) - [Installation](#installation) - [One-liner install (scripts)](#one-liner-install-scripts) - [Prebuilt binaries](#prebuilt-binaries) @@ -56,13 +57,37 @@ irm https://raw.githubusercontent.com/sunerpy/codegraph-rust/main/scripts/instal **Index a project and query it:** ```bash -codegraph init /path/to/project # create .codegraph/ and run the first index +codegraph init /path/to/project # create .codegraph-v2/ and run the first index codegraph query "" -p /path/to/project # full-text search codegraph serve --mcp --path /path/to/project # MCP server (--path optional, defaults to cwd) ``` --- +## Upgrading from v0.40.4 + +The per-project index moved out of `/.codegraph/` into an isolated +`/.codegraph-v2/`. A `v0.40.4` index is **not migrated and not read** — +this binary never opens, migrates, or writes the old root, so an existing +`.codegraph/` is simply ignored. Two consequences: + +- **Run `codegraph init` again in each project.** Until you do, the project counts + as not indexed: `serve --mcp` falls back to direct mode with nothing to serve, + and the query commands report `CodeGraph not initialized`. +- **Old configuration does not carry over.** `.codegraph/config.toml` and + `.codegraph/codegraph.json` are ignored. Copy them to + `.codegraph-v2/config.toml` and `.codegraph-v2/codegraph.json` if you still + want them. + +The daemon rendezvous (`daemon.pid`, `daemon.sock`, `daemon.log`) moved with the +index, so it now also lives under `.codegraph-v2/`. + +`codegraph status` reports both roots: `indexPath` is the current one, and +`legacyIndexPaths` lists any old root still on disk. The old directory is left +untouched — delete it by hand once you no longer want it. + +--- + ## Installation The CLI package is **`codegraph-rs`** — a single binary named `codegraph`. SQLite @@ -91,7 +116,7 @@ irm https://raw.githubusercontent.com/sunerpy/codegraph-rust/main/scripts/instal # Fallback — build from source (only if you have a Rust toolchain) cargo install --git https://github.com/sunerpy/codegraph-rust codegraph-rs # binary: `codegraph` -codegraph init /path/to/project # create the index DB (.codegraph/) +codegraph init /path/to/project # create the index DB (.codegraph-v2/) codegraph index /path/to/project # parse + build the graph ``` @@ -218,7 +243,7 @@ Beyond wiring the MCP server, CodeGraph can install a `SKILL.md` directly into each agent's skill directory. The skill teaches your agent to use CodeGraph for code research and project onboarding — reach for `codegraph_explore` before grep/read, use `codegraph_node` instead of a plain file read on indexed source, -and run `codegraph init` when no `.codegraph/` index is present yet. +and run `codegraph init` when no `.codegraph-v2/` index is present yet. ```bash codegraph skill install --yes # install into all detected agents (global) @@ -384,8 +409,8 @@ LLM library _inside_ the codegraph binary itself. CodeGraph spawns a shared background daemon for each indexed project when you run `codegraph serve --mcp`. Multiple MCP clients (terminal tabs, agents) share that -one daemon via a Unix socket (`.codegraph/daemon.sock`). It exits once all clients -disconnect and the idle timeout elapses. +one daemon via a Unix socket (`.codegraph-v2/daemon.sock`). It exits once all +clients disconnect and the idle timeout elapses. Key operations: @@ -398,8 +423,8 @@ codegraph http stop # terminate one HTTP server by address Set `CODEGRAPH_NO_DAEMON=1` to force foreground mode (useful in CI). The daemon watches files with a 2 s debounce; pass `--no-watch` or set `CODEGRAPH_NO_WATCH=1` -to disable. Custom extension mapping goes in `.codegraph/codegraph.json`; exclude -patterns in `.codegraph/config.toml` under `[indexing] exclude`. +to disable. Custom extension mapping goes in `.codegraph-v2/codegraph.json`; +exclude patterns in `.codegraph-v2/config.toml` under `[indexing] exclude`. Full env-var table, HTTP server details, filesystem fallback behavior, and the Claude prompt-hook: [`docs/mcp.md`](docs/mcp.md) and @@ -476,6 +501,9 @@ Full list with extensions and per-language notes: [`docs/languages.md`](docs/lan - [`docs/godot.md`](docs/godot.md) — Godot static analysis: what CodeGraph extracts from `.tscn`/`.tres`/`project.godot`/`.gd`, the static-vs-runtime boundary, and honesty signals for dynamic reachability. +- [`docs/godot-gdext-decision.md`](docs/godot-gdext-decision.md) — why `gdext` + (godot-rust GDExtension bindings) is rejected for Godot analysis, and the + engine-free alternatives. - [`docs/grammar-manifest.md`](docs/grammar-manifest.md) / [`docs/embedded-extraction.md`](docs/embedded-extraction.md) — language support and extraction tiers (engineering ABI detail). diff --git a/crates/codegraph-bench/fixtures/cpp/attr_macro.c b/crates/codegraph-bench/fixtures/cpp/attr_macro.c new file mode 100644 index 0000000..7b361a8 --- /dev/null +++ b/crates/codegraph-bench/fixtures/cpp/attr_macro.c @@ -0,0 +1,19 @@ +#define SEC_ATTR __attribute__((section(".init"))) +#define VOID void + +typedef unsigned int UINT32; + +SEC_ATTR VOID GoodName (VOID) { +} + +SEC_ATTR UINT32 LostName (VOID) { + return 0; +} + +UINT32 NoAttr (void) { + return 0; +} + +SEC_ATTR UINT32 *PtrRet (VOID) { + return 0; +} diff --git a/crates/codegraph-bench/fixtures/cpp/namespaced_member.cpp b/crates/codegraph-bench/fixtures/cpp/namespaced_member.cpp new file mode 100644 index 0000000..4c02581 --- /dev/null +++ b/crates/codegraph-bench/fixtures/cpp/namespaced_member.cpp @@ -0,0 +1,11 @@ +#include "namespaced_member.hpp" + +namespace simulator { +int ManifestStartup::Apply(int input) { + return input; +} +} + +int run_manifest() { + return simulator::ManifestStartup::Apply(1); +} diff --git a/crates/codegraph-bench/fixtures/cpp/namespaced_member.hpp b/crates/codegraph-bench/fixtures/cpp/namespaced_member.hpp new file mode 100644 index 0000000..cf05bb3 --- /dev/null +++ b/crates/codegraph-bench/fixtures/cpp/namespaced_member.hpp @@ -0,0 +1,8 @@ +#pragma once + +namespace simulator { +class ManifestStartup { +public: + static int Apply(int input); +}; +} diff --git a/crates/codegraph-bench/fixtures/cpp/operators.cpp b/crates/codegraph-bench/fixtures/cpp/operators.cpp new file mode 100644 index 0000000..0dde49f --- /dev/null +++ b/crates/codegraph-bench/fixtures/cpp/operators.cpp @@ -0,0 +1,22 @@ +struct Vec2 { + int x; + Vec2 operator+(const Vec2& o) const { return Vec2{x + o.x}; } + Vec2 operator[](int i) const { return Vec2{x + i}; } + int get() const { return x; } +}; + +Vec2 explicit_operator_call(const Vec2& a, const Vec2& b) { + return a.operator+(b); +} + +Vec2 explicit_subscript_call(const Vec2& a) { + return a.operator[](3); +} + +Vec2 explicit_pointer_operator_call(const Vec2* p, const Vec2& b) { + return p->operator+(b); +} + +int plain_member_call(const Vec2& a) { + return a.get(); +} diff --git a/crates/codegraph-bench/fixtures/cpp/template_method.cpp b/crates/codegraph-bench/fixtures/cpp/template_method.cpp new file mode 100644 index 0000000..ef7392c --- /dev/null +++ b/crates/codegraph-bench/fixtures/cpp/template_method.cpp @@ -0,0 +1,19 @@ +template +class Box { +public: + T get() const; + void set(T v); + +private: + T value; +}; + +template +T Box::get() const { + return value; +} + +template +void Box::set(T v) { + value = v; +} diff --git a/crates/codegraph-bench/src/oracle/diff.rs b/crates/codegraph-bench/src/oracle/diff.rs index 3750999..44afecd 100644 --- a/crates/codegraph-bench/src/oracle/diff.rs +++ b/crates/codegraph-bench/src/oracle/diff.rs @@ -1,13 +1,31 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use super::canonicalize::{CanonicalDb, CanonicalRow}; +/// Every surface name `diff_canonical` can put on a `DiffEntry`, enumerated from +/// its `compare_*` call sites (nodes/files via `compare_tier1_rows`, schema via +/// `compare_schema`, edges/unresolved_refs via `compare_tier2_rows`). A RULE that +/// names anything else can never match a real diff, so it is rejected at parse +/// time: a typo'd `surface=` would otherwise sit in the document forever looking +/// like an active decision while silently allowing nothing — a LOUD error is +/// strictly better than an inert rule nobody notices is inert. +const DIFF_SURFACES: [&str; 5] = ["nodes", "files", "schema", "edges", "unresolved_refs"]; + +/// The field names a RULE line may carry. Anything else is a typo. +const RULE_FIELDS: [&str; 4] = ["tier", "surface", "key", "justification"]; + +/// A `DiffEntry` legitimately carries ANY tier — `diff_canonical` mints Tier-1 +/// (nodes/files/schema) and Tier-2 (edges/unresolved_refs) entries today, and +/// Tier-3 exists for behavioral surfaces. The asymmetry lives on the allowlist +/// side, not here: only Tier-3 may appear on a `RULE` line (`parse_rule`) and +/// only a Tier-3 entry can ever be allowed (`KnownDiffs::allows`). So all three +/// variants stay. #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] pub enum Tier { Tier1, @@ -120,17 +138,52 @@ impl KnownDiffs { Self::parse(&text) } + pub fn repo_doc_path() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("codegraph-bench lives under crates/") + .join("docs/upstream-sync/KNOWN_DIFFS.md") + } + + /// The committed allowlist, parsed. A syntactically invalid `KNOWN_DIFFS.md` + /// therefore FAILS every equivalence assertion instead of being silently + /// ignored, which is what made the document decorative before this wiring. + pub fn load_repo_doc() -> Result { + Self::load(&Self::repo_doc_path()) + } + + pub fn rule_count(&self) -> usize { + self.rules.len() + } + + /// Lines inside a fenced code block are DOCUMENTATION, not rules: the "Rule + /// format" section of `KNOWN_DIFFS.md` shows the template + /// `RULE tier=3 surface= …` inside a ```text fence, and the + /// pre-fail-closed parser accepted it as an ACTIVE rule (tier=3, surface + /// ``) — an allowlist entry nobody wrote on purpose, inert only + /// because that placeholder surface never matches a real diff. pub fn parse(text: &str) -> Result { let mut rules = Vec::new(); + let mut in_fence = false; for (line_number, line) in text.lines().enumerate() { let line = line.trim(); - if line.is_empty() || line.starts_with('#') || !line.starts_with("RULE ") { + if line.starts_with("```") { + in_fence = !in_fence; + continue; + } + if in_fence || line.is_empty() || line.starts_with('#') || !line.starts_with("RULE ") { continue; } rules.push(parse_rule(line).with_context(|| { format!("parsing KNOWN_DIFFS.md line {}: {line}", line_number + 1) })?); } + if in_fence { + anyhow::bail!( + "unterminated code fence: every RULE line after it would be silently skipped" + ); + } Ok(Self { rules }) } @@ -146,25 +199,54 @@ impl KnownDiffs { } } +/// Fail-closed RULE parser. Every rejection below exists because the permissive +/// version accepted a line that LOOKED like an active allowlist entry and then +/// silently did nothing (or, for `tier=1|2`, looked like it waved through a +/// Tier-1 golden difference): +/// +/// - a token without `=` was dropped, so `RULE garbage tier=3 …` parsed clean; +/// - an unknown field name (`surfce=nodes`) was dropped, so the rule silently +/// fell back to "missing surface" or to a stale value; +/// - a duplicate field silently kept the last occurrence, so two contradictory +/// `key=` values resolved by position alone; +/// - `tier=1` / `tier=2` parsed fine and were discarded only later by `allows`, +/// so the document's promise that Tier-1/Tier-2 are never allowlisted held by +/// downstream accident rather than at parse time; +/// - a `surface=` outside `DIFF_SURFACES` can never match a real diff. fn parse_rule(line: &str) -> Result { - let mut fields = BTreeMap::new(); + let mut fields: BTreeMap<&str, &str> = BTreeMap::new(); for token in line.trim_start_matches("RULE ").split_whitespace() { - if let Some((key, value)) = token.split_once('=') { - fields.insert(key, value); + let (key, value) = token + .split_once('=') + .with_context(|| format!("token {token} is not key=value"))?; + if key.is_empty() || value.is_empty() { + anyhow::bail!("token {token} has an empty key or value"); + } + if !RULE_FIELDS.contains(&key) { + anyhow::bail!("unknown field {key}; allowed fields are {RULE_FIELDS:?}"); + } + if fields.insert(key, value).is_some() { + anyhow::bail!("duplicate field {key}"); } } - let tier = match *fields.get("tier").context("missing tier")? { + + let tier_token = *fields.get("tier").context("missing tier")?; + let tier = match tier_token { "3" | "Tier3" | "tier3" => Tier::Tier3, - "2" | "Tier2" | "tier2" => Tier::Tier2, - "1" | "Tier1" | "tier1" => Tier::Tier1, + "1" | "Tier1" | "tier1" | "2" | "Tier2" | "tier2" => anyhow::bail!( + "tier={tier_token} may not be allowlisted; only Tier-3 differences can be allowed" + ), value => anyhow::bail!("unknown tier {value}"), }; + + let surface = *fields.get("surface").context("missing surface")?; + if !DIFF_SURFACES.contains(&surface) { + anyhow::bail!("unknown surface {surface}; the differ reports {DIFF_SURFACES:?}"); + } + Ok(KnownDiffRule { tier, - surface: fields - .get("surface") - .context("missing surface")? - .to_string(), + surface: surface.to_string(), key_pattern: fields .get("key") .context("missing key")? @@ -336,3 +418,252 @@ fn truncate(value: &str) -> String { format!("{}…", &value[..MAX], value.len() - MAX) } } + +#[cfg(test)] +mod tests { + use super::*; + + const TIER3_RULE: &str = + "RULE tier=3 surface=nodes key=alpha justification=documented-behavioral-drift"; + + fn tier3_entry(surface: &str, key: &str) -> DiffEntry { + entry( + Tier::Tier3, + surface, + key, + "expected".to_string(), + "actual".to_string(), + ) + } + + fn parsed(text: &str) -> KnownDiffs { + KnownDiffs::parse(text).expect("rule parses") + } + + fn parse_error(text: &str) -> String { + let error = KnownDiffs::parse(text).expect_err("rule must be rejected"); + format!("{error:#}") + } + + #[test] + fn tier3_rule_allows_its_matching_tier3_diff() { + let known = parsed(TIER3_RULE); + assert_eq!(known.rule_count(), 1); + assert!(known.allows(&tier3_entry("nodes", "function:alpha"))); + } + + #[test] + fn tier3_rule_does_not_allow_a_different_surface() { + let known = parsed(TIER3_RULE); + assert!(!known.allows(&tier3_entry("edges", "function:alpha"))); + } + + #[test] + fn tier3_rule_does_not_allow_a_non_matching_key() { + let known = parsed(TIER3_RULE); + assert!(!known.allows(&tier3_entry("nodes", "function:beta"))); + } + + #[test] + fn tier3_rule_does_not_allow_the_same_key_at_tier1() { + let known = parsed(TIER3_RULE); + let tier1 = entry( + Tier::Tier1, + "nodes", + "function:alpha", + "expected".to_string(), + "actual".to_string(), + ); + assert!(!known.allows(&tier1)); + } + + #[test] + fn tier3_rule_does_not_allow_the_same_key_at_tier2() { + let known = parsed(TIER3_RULE); + let tier2 = entry( + Tier::Tier2, + "nodes", + "function:alpha", + "expected".to_string(), + "actual".to_string(), + ); + assert!(!known.allows(&tier2)); + } + + #[test] + fn wildcard_rule_never_allows_a_tier1_golden_difference() { + let known = parsed("RULE tier=3 surface=nodes key=* justification=wildcard"); + let tier1 = entry( + Tier::Tier1, + "nodes", + "function:anything", + "expected".to_string(), + "actual".to_string(), + ); + assert!(!known.allows(&tier1)); + assert!(known.allows(&tier3_entry("nodes", "function:anything"))); + } + + #[test] + fn tier1_and_tier2_rules_are_rejected_at_parse_time() { + for token in ["1", "Tier1", "tier1", "2", "Tier2", "tier2"] { + let line = + format!("RULE tier={token} surface=nodes key=* justification=must-not-be-allowed"); + let message = parse_error(&line); + assert!( + message.contains("may not be allowlisted"), + "tier={token} must be rejected at parse time, got: {message}" + ); + assert!( + message.contains(&line), + "the error must name the offending line, got: {message}" + ); + } + } + + #[test] + fn token_without_equals_is_rejected() { + let message = parse_error("RULE garbage tier=3 surface=nodes key=* justification=x"); + assert!( + message.contains("token garbage is not key=value"), + "got: {message}" + ); + } + + #[test] + fn unknown_field_name_is_rejected() { + let message = parse_error("RULE tier=3 surfce=nodes key=* justification=x"); + assert!(message.contains("unknown field surfce"), "got: {message}"); + } + + #[test] + fn duplicate_field_is_rejected() { + let message = parse_error("RULE tier=3 surface=nodes key=a key=b justification=x"); + assert!(message.contains("duplicate field key"), "got: {message}"); + } + + #[test] + fn unknown_surface_is_rejected() { + let message = parse_error("RULE tier=3 surface=noodes key=* justification=x"); + assert!(message.contains("unknown surface noodes"), "got: {message}"); + for surface in DIFF_SURFACES { + let line = format!("RULE tier=3 surface={surface} key=* justification=x"); + assert_eq!(parsed(&line).rule_count(), 1, "{surface} must be accepted"); + } + } + + #[test] + fn missing_field_is_rejected() { + for line in [ + "RULE surface=nodes key=* justification=x", + "RULE tier=3 key=* justification=x", + "RULE tier=3 surface=nodes justification=x", + "RULE tier=3 surface=nodes key=*", + ] { + let message = parse_error(line); + assert!(message.contains("missing"), "{line} -> {message}"); + } + } + + #[test] + fn fenced_template_and_prose_lines_are_not_rules() { + let known = parsed( + "# Known CodeGraph Equivalence Differences\n\nNo Tier-3 rules are active yet.\n\n\ + ```text\nRULE tier=3 surface= key= \ + justification=\n```\n", + ); + assert_eq!(known.rule_count(), 0); + } + + #[test] + fn a_rule_after_a_closed_fence_is_still_active() { + let known = parsed(&format!( + "```text\nRULE tier=3 surface= key=* j=1\n```\n\n{TIER3_RULE}\n" + )); + assert_eq!(known.rule_count(), 1); + assert!(known.allows(&tier3_entry("nodes", "function:alpha"))); + } + + #[test] + fn unterminated_fence_is_rejected() { + let message = parse_error(&format!("```text\n{TIER3_RULE}\n")); + assert!( + message.contains("unterminated code fence"), + "got: {message}" + ); + } + + #[test] + fn committed_known_diffs_doc_parses_and_has_zero_active_rules() { + let path = KnownDiffs::repo_doc_path(); + let known = KnownDiffs::load(&path).unwrap_or_else(|error| { + panic!("{} must parse: {error:#}", path.display()); + }); + assert_eq!( + known.rule_count(), + 0, + "{} must have zero active Tier-3 rules; adding one silently widens \ + golden adjudication", + path.display() + ); + } + + #[test] + fn invalid_known_diffs_file_fails_to_load() { + let dir = std::env::temp_dir().join(format!( + "codegraph-bench-known-diffs-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + fs::create_dir(&dir).expect("temp dir"); + let path = dir.join("KNOWN_DIFFS.md"); + fs::write( + &path, + "RULE tier=1 surface=nodes key=* justification=sneaky\n", + ) + .expect("write fixture"); + + let message = format!("{:#}", KnownDiffs::load(&path).expect_err("must fail")); + let _ = fs::remove_dir_all(&dir); + + assert!(message.contains("may not be allowlisted"), "got: {message}"); + } + + #[test] + fn tier1_entries_survive_the_allowlist_in_diff_canonical() { + let expected = CanonicalDb { + nodes: vec![row(&[("id", "function:alpha"), ("name", "alpha")])], + edges: Vec::new(), + unresolved_refs: Vec::new(), + files: Vec::new(), + schema: String::new(), + }; + let mut actual = expected.clone(); + actual.nodes = vec![row(&[("id", "function:alpha"), ("name", "DRIFTED")])]; + + let known = parsed("RULE tier=3 surface=nodes key=* justification=wildcard"); + let error = diff_canonical(&expected, &actual, Some(&known)) + .expect_err("a Tier-1 node drift is never allowlisted"); + assert!( + error + .entries() + .iter() + .any(|entry| entry.tier == Tier::Tier1 && entry.surface == "nodes") + ); + } + + fn row(pairs: &[(&str, &str)]) -> CanonicalRow { + pairs + .iter() + .map(|(key, value)| { + ( + (*key).to_string(), + serde_json::Value::String((*value).to_string()), + ) + }) + .collect() + } +} diff --git a/crates/codegraph-bench/src/oracle/mod.rs b/crates/codegraph-bench/src/oracle/mod.rs index a6da34a..16f0e23 100644 --- a/crates/codegraph-bench/src/oracle/mod.rs +++ b/crates/codegraph-bench/src/oracle/mod.rs @@ -7,12 +7,24 @@ use std::path::Path; use anyhow::Result; pub use canonicalize::{CanonicalDb, CanonicalRow, canonicalize_db}; + pub use diff::{DiffEntry, DiffError, KnownDiffs, Tier, diff_canonical}; pub use golden::{load_golden, write_golden}; pub fn assert_equivalent(rust_db: &Path, golden_dir: &Path) -> Result<()> { + assert_equivalent_with_known_diffs(rust_db, golden_dir, &KnownDiffs::repo_doc_path()) +} + +/// The allowlist is loaded from `known_diffs_path` BEFORE any comparison, so an +/// unparseable `KNOWN_DIFFS.md` fails the assertion instead of being ignored. +pub fn assert_equivalent_with_known_diffs( + rust_db: &Path, + golden_dir: &Path, + known_diffs_path: &Path, +) -> Result<()> { + let known_diffs = KnownDiffs::load(known_diffs_path)?; let expected = load_golden(golden_dir)?; let actual = canonicalize_db(rust_db)?; - diff_canonical(&expected, &actual, None)?; + diff_canonical(&expected, &actual, Some(&known_diffs))?; Ok(()) } diff --git a/crates/codegraph-bench/tests/equivalence.rs b/crates/codegraph-bench/tests/equivalence.rs index e499c82..74cf2a9 100644 --- a/crates/codegraph-bench/tests/equivalence.rs +++ b/crates/codegraph-bench/tests/equivalence.rs @@ -1,10 +1,41 @@ use std::path::{Path, PathBuf}; use codegraph_bench::oracle::{ - Tier, assert_equivalent, canonicalize_db, diff_canonical, load_golden, write_golden, + KnownDiffs, Tier, assert_equivalent, assert_equivalent_with_known_diffs, canonicalize_db, + diff_canonical, load_golden, write_golden, }; use serde_json::json; +#[test] +fn committed_known_diffs_doc_is_parsed_and_allowlists_nothing() { + // Every `assert_equivalent` in this file now adjudicates through the + // committed KNOWN_DIFFS.md, so it must parse AND stay empty: one active + // Tier-3 rule would silently widen every golden comparison below. + let path = KnownDiffs::repo_doc_path(); + assert!(path.is_file(), "{} must exist", path.display()); + let known = KnownDiffs::load(&path).unwrap_or_else(|error| { + panic!("{} must parse: {error:#}", path.display()); + }); + assert_eq!(known.rule_count(), 0, "{} must stay empty", path.display()); +} + +#[test] +fn an_unparseable_known_diffs_file_fails_the_equivalence_assertion() { + let tempdir = TestDir::new("known-diffs-invalid"); + let path = tempdir.path().join("KNOWN_DIFFS.md"); + std::fs::write( + &path, + "RULE tier=1 surface=nodes key=* justification=sneaky\n", + ) + .unwrap(); + + let error = assert_equivalent_with_known_diffs(&mini_db(), &mini_golden_dir(), &path) + .expect_err("an invalid allowlist must fail, not be ignored"); + let message = format!("{error:#}"); + println!("invalid KNOWN_DIFFS.md failure:\n{message}"); + assert!(message.contains("may not be allowlisted"), "got: {message}"); +} + #[test] fn generated_golden_matches_committed_mini_fixture() { let tempdir = TestDir::new("generated-golden"); diff --git a/crates/codegraph-cli/Cargo.toml b/crates/codegraph-cli/Cargo.toml index 1a3292c..b6a6add 100644 --- a/crates/codegraph-cli/Cargo.toml +++ b/crates/codegraph-cli/Cargo.toml @@ -44,6 +44,10 @@ time = { workspace = true } [dev-dependencies] codegraph-bench = { path = "../codegraph-bench" } +# Feature-unifies the Store's lease barrier into `CARGO_BIN_EXE_codegraph` only +# while this package's test targets are built. A normal binary build uses the +# feature-free dependency declared above. +codegraph-store = { path = "../codegraph-store", features = ["test-hooks"] } rstest = { workspace = true } assert_fs = { workspace = true } assert_cmd = { workspace = true } diff --git a/crates/codegraph-cli/src/installer/mod.rs b/crates/codegraph-cli/src/installer/mod.rs index e345097..5a2570a 100644 --- a/crates/codegraph-cli/src/installer/mod.rs +++ b/crates/codegraph-cli/src/installer/mod.rs @@ -497,10 +497,8 @@ fn tildify(ctx: &InstallContext, path: &std::path::Path) -> String { mod tests { use super::*; use crate::installer::types::FileWrite; + use crate::test_env::env_guard; use std::fs; - use std::sync::Mutex; - - static ENV_LOCK: Mutex<()> = Mutex::new(()); fn temp_ctx(label: &str) -> (InstallContext, PathBuf) { let base = std::env::temp_dir().join(format!( @@ -589,11 +587,10 @@ mod tests { #[test] fn init_target_kiro_writes_project_local_mcp_with_concrete_path() { // Given a temp HOME and an indexed project root - let _lock = ENV_LOCK.lock().unwrap(); + let mut env = env_guard(); let (ctx, base) = temp_ctx("init-kiro"); let home_key = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; - let prev_home = std::env::var_os(home_key); - unsafe { std::env::set_var(home_key, &ctx.home) }; + env.set(home_key, &ctx.home); // When init --target=kiro installs project-local config (run twice = idempotent) run_install_local_targets(ctx.cwd.clone(), "kiro").unwrap(); @@ -617,21 +614,17 @@ mod tests { "must pin concrete project path, got: {args:?}" ); - match prev_home { - Some(v) => unsafe { std::env::set_var(home_key, v) }, - None => unsafe { std::env::remove_var(home_key) }, - } + env.assert_intact(); let _ = fs::remove_dir_all(base); } #[test] fn init_target_none_writes_nothing() { // Given a temp HOME and a project root - let _lock = ENV_LOCK.lock().unwrap(); + let mut env = env_guard(); let (ctx, base) = temp_ctx("init-none"); let home_key = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; - let prev_home = std::env::var_os(home_key); - unsafe { std::env::set_var(home_key, &ctx.home) }; + env.set(home_key, &ctx.home); // When init runs with the default target (none) run_install_local_targets(ctx.cwd.clone(), "none").unwrap(); @@ -642,10 +635,7 @@ mod tests { "none must be a pure no-op" ); - match prev_home { - Some(v) => unsafe { std::env::set_var(home_key, v) }, - None => unsafe { std::env::remove_var(home_key) }, - } + env.assert_intact(); let _ = fs::remove_dir_all(base); } @@ -777,13 +767,11 @@ mod tests { #[test] fn run_uninstall_with_ctx_via_public_paths() { - let _lock = ENV_LOCK.lock().unwrap(); + let mut env = env_guard(); let (ctx, base) = temp_ctx("run-uninstall"); let home_key = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; - let prev_home = std::env::var_os(home_key); - unsafe { std::env::set_var(home_key, &ctx.home) }; - let prev_xdg = std::env::var_os("XDG_CONFIG_HOME"); - unsafe { std::env::set_var("XDG_CONFIG_HOME", ctx.xdg_config_home.as_ref().unwrap()) }; + env.set(home_key, &ctx.home); + env.set("XDG_CONFIG_HOME", ctx.xdg_config_home.as_ref().unwrap()); run_uninstall(UninstallArgs { target: Some("gemini".to_string()), @@ -792,24 +780,16 @@ mod tests { }) .unwrap(); - match prev_home { - Some(v) => unsafe { std::env::set_var(home_key, v) }, - None => unsafe { std::env::remove_var(home_key) }, - } - match prev_xdg { - Some(v) => unsafe { std::env::set_var("XDG_CONFIG_HOME", v) }, - None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") }, - } + env.assert_intact(); let _ = fs::remove_dir_all(base); } #[test] fn run_skill_install_and_uninstall_and_status_via_ctx() { - let _lock = ENV_LOCK.lock().unwrap(); + let mut env = env_guard(); let (ctx, base) = temp_ctx("run-skill"); let home_key = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; - let prev_home = std::env::var_os(home_key); - unsafe { std::env::set_var(home_key, &ctx.home) }; + env.set(home_key, &ctx.home); run_skill_install(SkillArgs { target: Some("claude".to_string()), @@ -833,10 +813,7 @@ mod tests { }) .unwrap(); - match prev_home { - Some(v) => unsafe { std::env::set_var(home_key, v) }, - None => unsafe { std::env::remove_var(home_key) }, - } + env.assert_intact(); let _ = fs::remove_dir_all(base); } } diff --git a/crates/codegraph-cli/src/installer/registry.rs b/crates/codegraph-cli/src/installer/registry.rs index 0802ebf..f5ba9d8 100644 --- a/crates/codegraph-cli/src/installer/registry.rs +++ b/crates/codegraph-cli/src/installer/registry.rs @@ -103,8 +103,20 @@ pub fn resolve_target_flag( #[cfg(test)] mod tests { + use std::sync::atomic::{AtomicU64, Ordering}; + use super::*; + /// Monotonic per-process serial that makes every [`temp_ctx`] base unique. + /// + /// A wall-clock timestamp alone is NOT sufficient. `SystemTime::now()` has + /// nanosecond resolution on Linux but is only updated at the system timer tick + /// on Windows (~15.6 ms), so two of this module's tests — run concurrently in + /// threads of ONE process, hence one pid — can observe the SAME `as_nanos()` + /// value and hand the installer the SAME `home`/`cwd`, letting one test read + /// the config the other wrote. The serial cannot collide by construction. + static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + /// The full registry id set, in load-bearing order (registry.rs:5-7). const ALL_IDS: &[&str] = &[ "claude", @@ -143,8 +155,9 @@ mod tests { fn temp_ctx() -> InstallContext { let base = std::env::temp_dir().join(format!( - "cg-registry-{}-{}", + "cg-registry-{}-{}-{}", std::process::id(), + NEXT_TEMP.fetch_add(1, Ordering::Relaxed), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() diff --git a/crates/codegraph-cli/src/installer/shared.rs b/crates/codegraph-cli/src/installer/shared.rs index c7d9884..6a28453 100644 --- a/crates/codegraph-cli/src/installer/shared.rs +++ b/crates/codegraph-cli/src/installer/shared.rs @@ -738,8 +738,21 @@ fn find_next_table_header(content: &str, from: usize) -> usize { #[cfg(test)] mod tests { + use std::sync::atomic::{AtomicU64, Ordering}; + use super::*; + /// Monotonic per-process serial that makes every [`tmp_path`] directory unique. + /// + /// A wall-clock timestamp alone is NOT sufficient. `SystemTime::now()` has + /// nanosecond resolution on Linux but is only updated at the system timer tick + /// on Windows (~15.6 ms), so two of this module's tests — run concurrently in + /// threads of ONE process, hence one pid — can observe the SAME `as_nanos()` + /// value and land in the SAME directory. `create_dir_all` does not error on + /// that, so the two tests silently share a directory and clobber each other's + /// fixture files. The serial cannot collide by construction. + static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + #[test] fn parses_jsonc_with_line_and_block_comments() { let text = r#"{ @@ -1018,8 +1031,9 @@ mod tests { fn tmp_path(name: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!( - "cg-jsonc-{}-{}", + "cg-jsonc-{}-{}-{}", std::process::id(), + NEXT_TEMP.fetch_add(1, Ordering::Relaxed), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() diff --git a/crates/codegraph-cli/src/installer/targets/claude.rs b/crates/codegraph-cli/src/installer/targets/claude.rs index e74e23b..66e0ecc 100644 --- a/crates/codegraph-cli/src/installer/targets/claude.rs +++ b/crates/codegraph-cli/src/installer/targets/claude.rs @@ -400,12 +400,22 @@ pub static CLAUDE_TARGET: ClaudeCodeTarget = ClaudeCodeTarget; #[cfg(test)] mod tests { + use std::sync::atomic::{AtomicU64, Ordering}; + use super::*; + /// Monotonic per-process serial that makes every [`temp_ctx`] base unique. + /// + /// See `installer::registry::tests::NEXT_TEMP` for why a wall-clock timestamp + /// is not enough on Windows. Only one test calls this helper today, so it + /// cannot collide yet; the serial keeps that true when a second one is added. + static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + fn temp_ctx() -> InstallContext { let base = std::env::temp_dir().join(format!( - "codegraph-claude-skill-{}-{}", + "codegraph-claude-skill-{}-{}-{}", std::process::id(), + NEXT_TEMP.fetch_add(1, Ordering::Relaxed), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() diff --git a/crates/codegraph-cli/src/main.rs b/crates/codegraph-cli/src/main.rs index 814598e..8eb97a2 100644 --- a/crates/codegraph-cli/src/main.rs +++ b/crates/codegraph-cli/src/main.rs @@ -16,11 +16,11 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result, anyhow, bail}; use clap::{CommandFactory, Parser, Subcommand, ValueEnum}; use clap_complete::{Shell, generate}; -use codegraph_core::config::init_config; +use codegraph_core::config::Config; use codegraph_core::logger::{LoggerConfig, init_logger}; use codegraph_core::node_id::hash_content; use codegraph_core::types::{ExtractionResult, FileRecord, Language, Node, NodeKind}; -use codegraph_extract::{ExtractOptions, detect_language, extract_source}; +use codegraph_extract::{ExtractOptions, detect_language_with, extract_source_with}; use codegraph_graph::graph::{GodotReach, GraphTraverser}; use codegraph_graph::query::{SearchOptions, search_nodes}; use codegraph_mcp::{McpServer, RunUntilAdoption}; @@ -39,13 +39,119 @@ mod segment_match; mod segments; mod structural_gate; +/// Test-only: the ONE process-wide environment lock for this binary. +/// +/// `cargo test` runs every unit test of this binary on threads of a SINGLE +/// process, so `HOME`, `XDG_DATA_HOME`, `USERPROFILE`, … are shared mutable +/// state. Two independent `ENV_LOCK` statics used to live in this file (one per +/// test module) plus a third in `installer::tests`, and because distinct statics +/// do not exclude each other, a test holding "the" lock could still have `HOME` +/// swapped underneath it — which made `install_completions` write into the +/// developer's REAL home directory. Every test in this binary that mutates a +/// process-global env var must therefore go through [`test_env::EnvGuard`]. +#[cfg(test)] +pub(crate) mod test_env { + use std::ffi::{OsStr, OsString}; + use std::sync::{Mutex, MutexGuard}; + + /// The single env lock for the whole `codegraph` binary's test suite. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + /// Holds [`ENV_LOCK`] for its whole lifetime, records every variable it + /// touched, and restores all of them on drop (panic-safe, so a failing + /// assertion cannot leak a temp `HOME` into the rest of the suite). + pub(crate) struct EnvGuard { + _lock: MutexGuard<'static, ()>, + saved: Vec<(String, Option)>, + expected: Vec<(String, Option)>, + } + + /// Acquire the process-wide env lock. Poisoning is recovered so one failing + /// test does not cascade into every other env test. + pub(crate) fn env_guard() -> EnvGuard { + EnvGuard { + _lock: ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()), + saved: Vec::new(), + expected: Vec::new(), + } + } + + impl EnvGuard { + fn remember(&mut self, key: &str) { + if !self.saved.iter().any(|(k, _)| k == key) { + self.saved.push((key.to_string(), std::env::var_os(key))); + } + } + + fn expect(&mut self, key: &str, value: Option) { + match self.expected.iter_mut().find(|(k, _)| k == key) { + Some(slot) => slot.1 = value, + None => self.expected.push((key.to_string(), value)), + } + } + + /// Set `key`, then assert the write is observable — so a stolen variable + /// is a LOUD failure instead of a silent write to the real `$HOME`. + pub(crate) fn set(&mut self, key: &str, value: impl AsRef) -> &mut Self { + let value = value.as_ref().to_os_string(); + self.remember(key); + // SAFETY: ENV_LOCK is held for this guard's whole lifetime, so no + // other test thread of this binary reads or writes env concurrently. + unsafe { std::env::set_var(key, &value) }; + self.expect(key, Some(value)); + self.assert_intact(); + self + } + + /// Unset `key` and assert it stays unset. + pub(crate) fn remove(&mut self, key: &str) -> &mut Self { + self.remember(key); + // SAFETY: as in `set` — serialized by ENV_LOCK. + unsafe { std::env::remove_var(key) }; + self.expect(key, None); + self.assert_intact(); + self + } + + /// Panic if any variable this guard wrote has changed value since. + /// + /// This is the escape detector: if some future test mutates `HOME` + /// without taking [`ENV_LOCK`], the test that owns the guard fails with + /// a precise message instead of writing into the real home directory. + pub(crate) fn assert_intact(&self) { + for (key, want) in &self.expected { + assert_eq!( + &std::env::var_os(key), + want, + "env var {key} changed underneath this guard: another test \ + mutated process-global env without holding the shared lock" + ); + } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + for (key, value) in self.saved.drain(..).rev() { + match value { + // SAFETY: still holding ENV_LOCK; single-threaded here. + Some(v) => unsafe { std::env::set_var(&key, v) }, + None => unsafe { std::env::remove_var(&key) }, + } + } + } + } +} + const VERSION: &str = env!("CARGO_PKG_VERSION"); -const EXTRACTION_VERSION: i64 = 1; fn main() { let cli = Cli::parse(); - let bootstrap_root = cli.bootstrap_project_root(); - let config = match init_config(None, &bootstrap_root) { + // Process bootstrap has no addressed project yet, so this config is + // `APP_CONFIG`-or-defaults ONLY and may configure NOTHING but the logger + // below. Every project operation (index, sync, watch, an MCP request) loads + // the addressed project's own immutable config from its resolved index root. + let config = match Config::load_env_or_default(None) { Ok(config) => config, Err(err) => { eprintln!("CodeGraph config error: {err:#}"); @@ -89,42 +195,6 @@ struct Cli { command: Command, } -impl Cli { - fn bootstrap_project_root(&self) -> PathBuf { - let raw = match &self.command { - Command::Init { path, .. } - | Command::Uninit { path, .. } - | Command::Index { path, .. } - | Command::Sync { path, .. } - | Command::Status { path, .. } - | Command::Unlock { path } => path.clone(), - Command::Query { path, .. } - | Command::Files { path, .. } - | Command::Serve { path, .. } - | Command::Callers { path, .. } - | Command::Callees { path, .. } - | Command::Impact { path, .. } - | Command::Affected { path, .. } - | Command::Check { path, .. } - | Command::Audit { path, .. } - | Command::Explore { path, .. } - | Command::Node { path, .. } => path.clone(), - Command::Export { path, .. } => path.clone(), - Command::PromptHook { path, .. } => path.clone(), - // install/uninstall/skill are not project-scoped — bootstrap from cwd. - Command::Install { .. } - | Command::Uninstall { .. } - | Command::Skill { .. } - | Command::Http { .. } - | Command::Version - | Command::Completions { .. } - | Command::SelfUpdate { .. } => None, - }; - let start = absolute_path(raw.unwrap_or_else(|| PathBuf::from("."))); - resolve_project_path_optional(&start) - } -} - #[derive(Debug, Subcommand)] enum Command { // Upstream flags/output: upstream bin/codegraph.ts:420-424, 431-470. @@ -353,6 +423,10 @@ enum Command { target: String, #[arg(short, long)] path: Option, + /// Pin an overloaded SYMBOL to the definition in this file (path or + /// basename). Its source body is returned, like the unpinned form. + #[arg(short = 'f', long = "file")] + file: Option, /// Symbol mode: return just the file's symbol map instead of source. #[arg(long = "symbols-only")] symbols_only: bool, @@ -609,10 +683,11 @@ fn run(cli: Cli) -> Result<()> { Command::Node { target, path, + file, symbols_only, json, strict, - } => cmd_node(target, path, symbols_only, json, strict), + } => cmd_node(target, path, file, symbols_only, json, strict), Command::Install { target, location, @@ -1155,38 +1230,198 @@ fn cmd_prompt_hook(path: Option, query: Option) -> Result<()> { fn cmd_init(path: Option, target: &str) -> Result<()> { let project = absolute_path(path.unwrap_or_else(|| PathBuf::from("."))); - if is_initialized(&project) { + if explicit_init_observes_readable_current(&project)? { println!("Already initialized in {}", project.display()); println!("Use \"codegraph index\" to re-index or \"codegraph sync\" to update"); return installer::run_install_local_targets(project, target); } guard_indexable_root(&project)?; - fs::create_dir_all(codegraph_dir(&project)) - .with_context(|| format!("creating {}", codegraph_dir(&project).display()))?; - let result = index_project(&project, true, false)?; + // The rebuild layer creates the current root and its permanent lock under the + // one outer exclusive lease; pre-creating it here would produce a lockless + // namespace that acquisition then refuses. + let result = index_project(&project, codegraph_store::RebuildKind::ExplicitInit)?; println!("Initialized in {}", project.display()); print_index_result(&result); installer::run_install_local_targets(project, target) } fn cmd_uninit(path: Option, force: bool) -> Result<()> { - let project = resolve_required_project(path)?; + let project = resolve_required_rebuild_project(path)?; if !force { bail!("refusing to delete .codegraph without --force"); } - fs::remove_dir_all(codegraph_dir(&project)) - .with_context(|| format!("removing {}", codegraph_dir(&project).display()))?; + let paths = index_paths(&project)?; + // The drain runs INSIDE uninit's retained exclusive lease, after both durable + // markers publish and before any runtime child is removed. It sends the + // versioned, project-identity-bound shutdown control frame — which bypasses + // data-request lease acquisition, the only way it can be answered while this + // command holds the namespace exclusively — and waits for the daemon's + // post-drain ACK. No pid is ever signalled: an unresponsive daemon makes uninit + // fail closed with the namespace left recoverable `Uninitialized`. + let identity = paths.project_identity().to_string(); + let outcome = codegraph_store::uninit_index_with_drain( + &paths, + std::time::Instant::now() + REBUILD_LEASE_TIMEOUT, + || false, + || drain_project_daemon(&project, &identity), + )?; println!("Removed CodeGraph from {}", project.display()); + if outcome.legacy_index_present { + println!("Legacy CodeGraph index remains untouched"); + } Ok(()) } +/// Bounded budget for the ONE exclusive acquisition of the stale-sidecar +/// recovery attempted before the strict startup read gate. +/// +/// Deliberately much shorter than [`REBUILD_LEASE_TIMEOUT`]: this acquisition is +/// a best-effort repair on the latency-critical daemon-startup path, and the ONLY +/// legitimate reason it cannot be taken is that another cooperating holder (a +/// live reader or writer) owns the namespace — in which case there is nothing to +/// recover and startup must proceed to its unchanged verdict immediately instead +/// of stalling behind a long-lived MCP reader's shared lease. +const STALE_SIDECAR_RECOVERY_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500); + +/// Whether this project's PREVIOUS rendezvous owner may still be running. +/// +/// Read-only by construction: unlike `clear_stale_daemon_lock`, which removes a +/// stale record as a side effect, the startup gate must only OBSERVE liveness — +/// the record is the single-instance exclusion `try_acquire_daemon_lock` claims +/// moments later, so removing it here would open a double-start window. +/// +/// Fail-closed: an unreadable or empty pid record is reported as LIVE. An empty +/// record is an in-flight `create_new` placeholder whose rename has not landed, +/// exactly as the daemon lock layer already treats it. +fn previous_daemon_owner_may_be_live(project_root: &Path) -> bool { + let Ok(pid_path) = codegraph_daemon::daemon_pid_path(project_root) else { + return true; + }; + match std::fs::read_to_string(&pid_path) { + Ok(raw) => match codegraph_daemon::decode_lock_info(&raw) { + Some(info) => info.pid > 0 && codegraph_daemon::is_process_alive(info.pid), + None => true, + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(_) => true, + } +} + +/// Recover a provably dead previous owner's leftover `-wal`/`-shm` before the +/// strict gate runs. +/// +/// A daemon killed with an open SQLite connection leaves an un-checkpointed +/// write-ahead log behind, and the sidecar-freedom clause of the `Current` read +/// contract then refuses EVERY later daemon start until `codegraph init` is +/// re-run. The remedy is recovery, not permission: fold that log back into the +/// main database under an exclusive lease and delete the checkpointed sidecars, +/// then let the UNCHANGED gate decide. Nothing is relaxed — the gate below still +/// demands sidecar-freedom, and a namespace this repair cannot fix is still +/// refused. +/// +/// Both guards must hold: the rendezvous owner is provably not alive AND the +/// exclusive lease is obtainable within a short bound, so a live daemon's or a +/// live MCP reader's sidecars are never folded underneath them. A failure here is +/// swallowed to a log line: recovery is opportunistic, and the authoritative +/// verdict is always the gate's. +fn recover_dead_owner_sidecars(paths: &codegraph_core::IndexPaths, project_root: &Path) { + if previous_daemon_owner_may_be_live(project_root) { + return; + } + match Store::recover_stale_current_sidecars( + paths, + std::time::Instant::now() + STALE_SIDECAR_RECOVERY_TIMEOUT, + || false, + ) { + Ok(true) => tracing::info!( + project = %project_root.display(), + "folded a dead daemon's leftover write-ahead log back into the index" + ), + Ok(false) => {} + Err(error) => tracing::warn!( + %error, + project = %project_root.display(), + "could not recover leftover SQLite sidecars; the startup gate decides" + ), + } +} + +/// Daemon-startup gate (frozen plan lines 590-592, 603-604). +/// +/// ONE bounded shared acquisition validates everything: `Store::open_for_read` +/// acquires the shared `IndexLease` and, under it, corroborates the FULL `Current` +/// contract — both owner-bound state slots (so `Future`, `Corrupt`, `Building`, +/// `Uninitialized`, `Outdated`, and an owner mismatch are all refused), tombstone +/// absence, database presence, sidecar-freedom, and the exact extraction stamp +/// read from the checkpointed main-file bytes. Slot phase alone would let a +/// `Current` slot with a deleted database or a stale stamp publish a rendezvous. +/// +/// The returned `Store` OWNS that same lease, so retaining it across pid/socket +/// publication requires no second acquisition: there is no nested lock and no +/// window between validation and publication for another writer to reclassify. +fn authorize_daemon_startup(project_root: &Path) -> Result { + let paths = index_paths(project_root)?; + recover_dead_owner_sidecars(&paths, project_root); + match Store::open_for_read( + &paths, + std::time::Instant::now() + REBUILD_LEASE_TIMEOUT, + || false, + ) { + Ok(store) => Ok(Box::new(store)), + Err(error) => { + // Re-read the observed markers for the operator-facing message only. + // The refusal itself is already decided by the gate above. + let status = Store::extraction_status(&paths); + let tombstone = if paths.tombstone().exists() { + "present" + } else { + "absent" + }; + bail!( + "refusing to start a daemon for {}: {error}; index state is {status} and its \ + uninitialized tombstone is {tombstone}. No daemon pid, socket, or log was \ + published. Run `codegraph init` to rebuild.", + project_root.display() + ) + } + } +} + +/// Ask this project's daemon (if any) to stop accepting, cancel its watcher lease +/// loops, drain, remove its own pid/socket, and ACK. `Err(detail)` is the +/// fail-closed signal; the pid is only ever reported, never signalled. +fn drain_project_daemon(project: &Path, project_identity: &str) -> Result<(), String> { + match codegraph_daemon::request_daemon_shutdown(project, project_identity) { + Ok(codegraph_daemon::ShutdownOutcome::NoDaemon) => Ok(()), + Ok(codegraph_daemon::ShutdownOutcome::Drained { pid }) => { + tracing::info!(pid, "daemon drained and removed its own rendezvous"); + Ok(()) + } + Ok(codegraph_daemon::ShutdownOutcome::Unresponsive { pid, detail }) => Err(format!( + "daemon {pid} did not acknowledge the shutdown control frame ({detail})" + )), + Err(error) => Err(format!("could not reach this project's daemon: {error:#}")), + } +} + fn cmd_index(path: Option, force: bool, quiet: bool, verbose: bool) -> Result<()> { - let project = resolve_required_project(path)?; + // Index is the one ordinary CLI surface allowed to retry an authenticated + // interrupted Building rebuild. Resolve state slots as well as a DB artifact + // so the crash window after deletion but before final writer creation remains + // reachable; authorization is still decided later under the exclusive lease. + let project = resolve_required_rebuild_project(path)?; guard_indexable_root(&project)?; - if force { - remove_db_files(&project)?; - } - let result = index_project_inner(&project, true, verbose, quiet)?; + // `--force` no longer removes the DB up front: the rebuild layer performs the + // destructive removal itself, AFTER publishing `phase=building` under the one + // outer exclusive lease, so an interruption can never leave a bare DB with no + // state marker. Plain `index` takes the same full-rebuild path it always did. + let _ = force; + let result = index_project_inner( + &project, + codegraph_store::RebuildKind::Reindex, + verbose, + quiet, + )?; if !quiet { print_index_result(&result); } @@ -1197,7 +1432,11 @@ fn cmd_index(path: Option, force: bool, quiet: bool, verbose: bool) -> } fn cmd_sync(path: Option, quiet: bool) -> Result<()> { - let project = resolve_required_project(path)?; + // Sync must discover authenticated Outdated/Building state so its Store gate + // can migrate it under one retained exclusive lease. Uninitialized remains + // discoverable only to reach the typed under-lease rejection; it is never + // authorized to sync or recreate residue. + let project = resolve_required_rebuild_project(path)?; // True single-file incremental sync (P0, docs/optimization-analysis.md §1). // sync_project_once self-discovers candidate files via scan_project, so it works // for a cold CLI invocation with no daemon. Hash-gated skip + per-file delete/reinsert @@ -1231,24 +1470,58 @@ fn cmd_sync(path: Option, quiet: bool) -> Result<()> { } fn cmd_status(path: Option, json_output: bool) -> Result<()> { + let explicit = path.is_some(); let start = absolute_path(path.unwrap_or_else(|| PathBuf::from("."))); - let project = resolve_project_path_optional(&start); - let db = db_path(&project); + let project = if configured_root_is_absolute() { + if !explicit && !is_initialized(&start) { + bail!("CODEGRAPH_DIR is absolute; pass the project root explicitly"); + } + start + } else { + resolve_project_path_optional(&start) + }; + // Fail closed on an unsafe/aliased/overlapping configured root: status must + // surface the stable diagnostic, NOT mask an invalid `CODEGRAPH_DIR` as a + // default `.codegraph-v2` layout (which would report a bogus "not + // initialized"). A genuinely absent index still resolves fine and reports + // uninitialized below. + let resolved = index_paths(&project)?; + let index_root = resolved.current_root().to_path_buf(); + let db = resolved.current_db(); let db_exists = db.is_file(); + let legacy_index_paths = resolved + .legacy_roots() + .iter() + .filter(|path| path.exists()) + .cloned() + .collect::>(); + let legacy_index_present = !legacy_index_paths.is_empty(); let daemon_running = daemon_already_running(&project); - let daemon_pid_path = codegraph_daemon::daemon_pid_path(&project); - let daemon_socket_path = codegraph_daemon::recorded_socket_path(&project); - let daemon_log_path = codegraph_daemon::daemon_log_path(&project); - if !is_initialized(&project) { + let daemon_pid_path = codegraph_daemon::daemon_pid_path(&project)?; + let daemon_socket_path = codegraph_daemon::recorded_socket_path(&project)?; + let daemon_log_path = codegraph_daemon::daemon_log_path(&project)?; + let status_open = Store::open_for_status( + &resolved, + std::time::Instant::now() + STATUS_LEASE_TIMEOUT, + || false, + )?; + if status_open.rebuilding { if json_output { print_json(&json!({ + // The exclusive owner may be between lifecycle publications. + // DB presence alone cannot corroborate a readable Current index. "initialized": false, "version": VERSION, "projectPath": project, - "indexPath": codegraph_dir(&project), + "indexPath": index_root, "lastIndexed": null, + "rebuilding": true, "dbPath": db, "dbExists": db_exists, + "extractionStatus": null, + "extractionStatusDetail": "rebuilding", + "legacyIndexPresent": legacy_index_present, + "legacyIndexPaths": legacy_index_paths, "daemonRunning": daemon_running, "daemonPidPath": daemon_pid_path, "daemonSocketPath": daemon_socket_path, @@ -1258,6 +1531,45 @@ fn cmd_status(path: Option, json_output: bool) -> Result<()> { println!("\nCodeGraph Status\n"); println!("Project: {}", project.display()); println!("DB Path: {}", db.display()); + println!("State: rebuilding"); + } + return Ok(()); + } + let extraction_status = status_open + .status + .clone() + .expect("a non-busy status probe always classifies the namespace"); + let store = status_open.into_store(); + if store.is_none() { + if json_output { + print_json(&json!({ + "initialized": false, + "version": VERSION, + "projectPath": project, + "indexPath": index_root, + "lastIndexed": null, + "dbPath": db, + "dbExists": db_exists, + "extractionStatus": extraction_status_name(&extraction_status), + "extractionStatusDetail": extraction_status.to_string(), + "legacyIndexPresent": legacy_index_present, + "legacyIndexPaths": legacy_index_paths, + "daemonRunning": daemon_running, + "daemonPidPath": daemon_pid_path, + "daemonSocketPath": daemon_socket_path, + "daemonLogPath": daemon_log_path, + }))?; + } else { + println!("\nCodeGraph Status\n"); + println!("Project: {}", project.display()); + println!("DB Path: {}", db.display()); + println!("State: {extraction_status}"); + if legacy_index_present { + println!("Legacy index: present and untouched"); + for path in &legacy_index_paths { + println!(" {}", path.display()); + } + } println!( "Daemon: {}", if daemon_running { "running" } else { "stopped" } @@ -1268,27 +1580,26 @@ fn cmd_status(path: Option, json_output: bool) -> Result<()> { return Ok(()); } - let store = open_store(&project)?; + let store = store.expect("Current status retains its corroborated read store"); let counts = store.counts()?; let nodes_by_kind = store.node_counts_by_kind()?; let files_by_language = store.file_counts_by_language()?; - let db_size = fs::metadata(db_path(&project)) - .map(|m| m.len()) - .unwrap_or(0); + let db_size = fs::metadata(&db).map(|m| m.len()).unwrap_or(0); let last_indexed = latest_indexed_at(&store)?; let built_with_version = store.get_project_metadata("indexed_with_version")?; let built_with_extraction_version = store - .get_project_metadata("indexed_with_extraction_version")? - .and_then(|v| v.parse::().ok()); + .get_project_metadata(codegraph_store::EXTRACTION_VERSION_KEY)? + .and_then(|v| v.parse::().ok()); let reindex_recommended = last_indexed.is_some() - && built_with_extraction_version.is_none_or(|v| v < EXTRACTION_VERSION); + && built_with_extraction_version + .is_none_or(|v| v < codegraph_store::CURRENT_EXTRACTION_VERSION); let resolution_incomplete = store.is_resolution_incomplete()?; if json_output { let mut index_obj = json!({ "builtWithVersion": built_with_version, "builtWithExtractionVersion": built_with_extraction_version, - "currentExtractionVersion": EXTRACTION_VERSION, + "currentExtractionVersion": codegraph_store::CURRENT_EXTRACTION_VERSION, "reindexRecommended": reindex_recommended, }); // #1187: surface the interrupted-index state ONLY when the marker is set, @@ -1300,7 +1611,7 @@ fn cmd_status(path: Option, json_output: bool) -> Result<()> { "initialized": true, "version": VERSION, "projectPath": project, - "indexPath": codegraph_dir(&project), + "indexPath": index_root, "lastIndexed": last_indexed.map(iso_like_millis), "fileCount": counts.file_count, "nodeCount": counts.node_count, @@ -1313,9 +1624,13 @@ fn cmd_status(path: Option, json_output: bool) -> Result<()> { "pendingChanges": { "added": 0, "modified": 0, "removed": 0 }, "worktreeMismatch": null, "index": index_obj, - "dbPath": db, - "dbExists": db_exists, - "daemonRunning": daemon_running, + "dbPath": db, + "dbExists": db_exists, + "extractionStatus": extraction_status_name(&extraction_status), + "extractionStatusDetail": extraction_status.to_string(), + "legacyIndexPresent": legacy_index_present, + "legacyIndexPaths": legacy_index_paths, + "daemonRunning": daemon_running, "daemonPidPath": daemon_pid_path, "daemonSocketPath": daemon_socket_path, "daemonLogPath": daemon_log_path, @@ -1355,6 +1670,18 @@ fn cmd_status(path: Option, json_output: bool) -> Result<()> { Ok(()) } +fn extraction_status_name(status: &codegraph_store::ExtractionStatus) -> &'static str { + match status { + codegraph_store::ExtractionStatus::Current => "current", + codegraph_store::ExtractionStatus::Building { .. } => "building", + codegraph_store::ExtractionStatus::Uninitialized => "uninitialized", + codegraph_store::ExtractionStatus::Missing => "missing", + codegraph_store::ExtractionStatus::Outdated { .. } => "outdated", + codegraph_store::ExtractionStatus::Future { .. } => "future", + codegraph_store::ExtractionStatus::Corrupt { .. } => "corrupt", + } +} + fn cmd_query( search: String, path: Option, @@ -1492,6 +1819,25 @@ fn effective_log_level(config_level: &str) -> String { config_level.to_string() } +/// The project's rendezvous identities for one debug line, or the fail-closed +/// diagnostic when the configured index root is unsafe. Debug output must never +/// reconstruct a path the resolver refused. +fn describe_rendezvous(project_root: &Path) -> String { + match ( + codegraph_daemon::daemon_pid_path(project_root), + codegraph_daemon::recorded_socket_path(project_root), + ) { + (Ok(pid_path), Ok(socket_path)) => { + format!( + "pid={} socket={}", + pid_path.display(), + socket_path.display() + ) + } + (Err(error), _) | (_, Err(error)) => format!("(unresolved: {error})"), + } +} + fn emit_serve_startup_debug( project_root: &Path, explicit_path: bool, @@ -1504,14 +1850,19 @@ fn emit_serve_startup_debug( let cwd = std::env::current_dir() .map(|p| p.display().to_string()) .unwrap_or_else(|_| "(unknown)".to_string()); - let db = db_path(project_root); + let db = db_path(project_root).ok(); + let db_display = db + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "(unresolved)".to_string()); + let db_exists = db.as_ref().map(|p| p.is_file()).unwrap_or(false); tracing::debug!( %exe, %cwd, explicit_path, default_project = %project_root.display(), - db = %db.display(), - db_exists = db.is_file(), + db = %db_display, + db_exists, has_codegraph_dir = has_codegraph, mode = ?mode, "serve startup" @@ -1560,7 +1911,9 @@ fn cmd_serve( ); return serve_direct_no_services(project, &project_root, no_watch); } - let has_codegraph = codegraph_dir(&project_root).is_dir(); + let has_codegraph = codegraph_dir(&project_root) + .map(|d| d.is_dir()) + .unwrap_or(false); let mode = select_serve_mode(daemon_opt_out(), is_daemon_internal(), has_codegraph); emit_serve_startup_debug(&project_root, explicit_path, has_codegraph, &mode); match mode { @@ -1568,16 +1921,20 @@ fn cmd_serve( return serve_direct(project, &project_root, no_watch, explicit_path); } ServeMode::BeDaemon => { - let cfg = codegraph_core::config::get_config(); - return codegraph_daemon::run_foreground( + // The daemon loads THIS project's own watch config itself, from + // the resolved index root, so nothing is passed down here. The + // startup gate below validates state/owner/tombstone under a + // bounded SHARED index lease and RETAINS it across pid/socket + // publication, so a concurrent `uninit --force` cannot interleave. + let gate = |root: &Path| authorize_daemon_startup(root); + return codegraph_daemon::run_foreground_gated( &project_root, codegraph_daemon::DaemonOptions { run_mcp: true, host_pid: codegraph_daemon::host_pid_from_env(), - include: cfg.indexing.include.clone(), - exclude: cfg.indexing.exclude.clone(), ..Default::default() }, + Some(&gate), ) .context("running as detached MCP daemon"); } @@ -1614,7 +1971,7 @@ fn cmd_serve_http(path: Option, http_addr: &str, detach: bool) -> Resul let (project, mode) = match path { Some(raw) => { let project = resolve_project_path_optional(&absolute_path(raw)); - let db = db_path(&project); + let db = db_path(&project)?; if !db.is_file() { anyhow::bail!( "`serve --http --path` requires an indexed project, but no index was found at {}. Run `codegraph init {}` (or `codegraph index`) first.", @@ -2033,7 +2390,10 @@ mod normalize_lexical_tests { /// self-indexes the cwd — keeping it unindexed and therefore adoptable when the /// client reports its real workspace root via `roots/list`. fn should_run_serve_services(explicit_path: bool, project_root: &Path) -> bool { - explicit_path || codegraph_dir(project_root).is_dir() + explicit_path + || codegraph_dir(project_root) + .map(|d| d.is_dir()) + .unwrap_or(false) } fn serve_direct( @@ -2153,16 +2513,19 @@ fn start_daemon_for_adopted_root(project_root: &Path, no_watch: bool) -> Option< if daemon_opt_out() || is_daemon_internal() || !should_run_daemon_services(project_root) { return None; } - if !codegraph_dir(project_root).is_dir() { + if !codegraph_dir(project_root) + .map(|d| d.is_dir()) + .unwrap_or(false) + { return None; } if daemon_already_running(project_root) { + let socket_path = codegraph_daemon::recorded_socket_path(project_root).ok()?; tracing::debug!( - pid_path = %codegraph_daemon::daemon_pid_path(project_root).display(), - socket_path = %codegraph_daemon::recorded_socket_path(project_root).display(), + socket_path = %socket_path.display(), "adopted-root: attaching to existing daemon" ); - return Some(codegraph_daemon::recorded_socket_path(project_root)); + return Some(socket_path); } let Ok(exe) = std::env::current_exe() else { return None; @@ -2174,12 +2537,11 @@ fn start_daemon_for_adopted_root(project_root: &Path, no_watch: bool) -> Option< project = %project_root.display(), "started shared daemon for adopted project root" ); + let socket_path = codegraph_daemon::recorded_socket_path(project_root).ok()?; tracing::debug!( - pid_path = %codegraph_daemon::daemon_pid_path(project_root).display(), - socket_path = %codegraph_daemon::recorded_socket_path(project_root).display(), + socket_path = %socket_path.display(), "adopted-root: spawned new daemon" ); - let socket_path = codegraph_daemon::recorded_socket_path(project_root); socket_path.exists().then_some(socket_path) } Err(err) => { @@ -2241,11 +2603,18 @@ fn start_direct_watcher( project_root: &Path, no_watch: bool, ) -> Option { - let mut opts = codegraph_watch::WatchOptions::default(); - opts.no_watch = no_watch; - let cfg = codegraph_core::config::get_config(); - opts.include = cfg.indexing.include.clone(); - opts.exclude = cfg.indexing.exclude.clone(); + // Include/exclude, debounce, the enable flag, and extension overrides all come + // from THIS project's own config (its resolved index root), so a direct serve + // watches exactly the scope its own `index`/`sync` would. + let mut opts = match codegraph_watch::watch_options_for_project(project_root) { + Ok(opts) => opts, + Err(err) => { + tracing::warn!(error = %err, "could not load project watch config; watcher disabled"); + return None; + } + }; + // An explicit `--no-watch` still wins over the project's `watch.enabled`. + opts.no_watch = opts.no_watch || no_watch; opts.on_sync_complete = Some(std::sync::Arc::new( |outcome: codegraph_watch::SyncOutcome| { tracing::info!( @@ -2323,8 +2692,7 @@ fn serve_spawn_or_proxy( explicit_path: bool, ) -> Result<()> { tracing::debug!( - pid_path = %codegraph_daemon::daemon_pid_path(project_root).display(), - socket_path = %codegraph_daemon::recorded_socket_path(project_root).display(), + rendezvous = %describe_rendezvous(project_root), "serve_spawn_or_proxy: begin" ); match cold_start_action(daemon_already_running(project_root)) { @@ -2356,7 +2724,7 @@ fn serve_spawn_or_proxy( /// proxy semantics: `run_proxy` answers `initialize`/`tools/list` locally and /// forwards tool calls; its fd half-close / ppid-watchdog teardown is untouched. fn proxy_to_running_daemon(project_root: &Path) -> Option> { - let socket_path = codegraph_daemon::recorded_socket_path(project_root); + let socket_path = codegraph_daemon::recorded_socket_path(project_root).ok()?; if !socket_path.exists() { tracing::debug!("proxy_to_running_daemon: daemon socket missing; falling back to direct"); heal_stale_daemon_if_dead(project_root); @@ -2420,7 +2788,9 @@ fn heal_stale_daemon_if_dead(project_root: &Path) { } fn daemon_already_running(project_root: &Path) -> bool { - let pid_path = codegraph_daemon::daemon_pid_path(project_root); + let Ok(pid_path) = codegraph_daemon::daemon_pid_path(project_root) else { + return false; + }; let Ok(raw) = fs::read_to_string(&pid_path) else { return false; }; @@ -2435,7 +2805,8 @@ fn poll_for_daemon_socket(project_root: &Path) { // Re-read the lock each tick: the daemon rewrites the recorded socket to // its bind-fallback choice during startup, so the path can change while // we poll (D-Daemon-b). - if codegraph_daemon::recorded_socket_path(project_root).exists() { + if codegraph_daemon::recorded_socket_path(project_root).is_ok_and(|socket| socket.exists()) + { return; } std::thread::sleep(DAEMON_SOCKET_POLL_INTERVAL); @@ -2480,42 +2851,32 @@ mod serve_mode_tests { emit_serve_startup_debug, guard_indexable_root, select_serve_mode, should_run_daemon_services, should_run_serve_services, }; + use crate::test_env::env_guard; use std::path::Path; - use std::sync::Mutex; - - static ENV_LOCK: Mutex<()> = Mutex::new(()); #[test] fn debug_enabled_honors_truthy_values_only() { - let _lock = ENV_LOCK.lock().unwrap(); - let prev = std::env::var("CODEGRAPH_DEBUG").ok(); + let mut env = env_guard(); - unsafe { std::env::remove_var("CODEGRAPH_DEBUG") }; + env.remove("CODEGRAPH_DEBUG"); assert!(!debug_enabled(), "unset ⇒ off"); - unsafe { std::env::set_var("CODEGRAPH_DEBUG", "1") }; + env.set("CODEGRAPH_DEBUG", "1"); assert!(debug_enabled(), "\"1\" ⇒ on"); - unsafe { std::env::set_var("CODEGRAPH_DEBUG", "true") }; + env.set("CODEGRAPH_DEBUG", "true"); assert!(debug_enabled(), "\"true\" ⇒ on"); - unsafe { std::env::set_var("CODEGRAPH_DEBUG", "0") }; + env.set("CODEGRAPH_DEBUG", "0"); assert!(!debug_enabled(), "\"0\" ⇒ off"); - unsafe { std::env::set_var("CODEGRAPH_DEBUG", "yes") }; + env.set("CODEGRAPH_DEBUG", "yes"); assert!(!debug_enabled(), "any other value ⇒ off"); - - match prev { - Some(v) => unsafe { std::env::set_var("CODEGRAPH_DEBUG", v) }, - None => unsafe { std::env::remove_var("CODEGRAPH_DEBUG") }, - } } #[test] fn effective_log_level_translates_codegraph_debug_and_defers_to_rust_log() { - let _lock = ENV_LOCK.lock().unwrap(); - let prev_debug = std::env::var("CODEGRAPH_DEBUG").ok(); - let prev_rust_log = std::env::var("RUST_LOG").ok(); + let mut env = env_guard(); // Given RUST_LOG unset and CODEGRAPH_DEBUG unset: config level is used verbatim. - unsafe { std::env::remove_var("RUST_LOG") }; - unsafe { std::env::remove_var("CODEGRAPH_DEBUG") }; + env.remove("RUST_LOG"); + env.remove("CODEGRAPH_DEBUG"); assert_eq!( effective_log_level("info"), "info", @@ -2523,7 +2884,7 @@ mod serve_mode_tests { ); // When CODEGRAPH_DEBUG=1 and RUST_LOG unset: level bumps to debug (back-compat). - unsafe { std::env::set_var("CODEGRAPH_DEBUG", "1") }; + env.set("CODEGRAPH_DEBUG", "1"); assert_eq!( effective_log_level("info"), "debug", @@ -2532,21 +2893,12 @@ mod serve_mode_tests { // When RUST_LOG is set: the base opens to trace so the EnvFilter is the // sole gate (the reload floor must not cap RUST_LOG upward). - unsafe { std::env::set_var("RUST_LOG", "warn") }; + env.set("RUST_LOG", "warn"); assert_eq!( effective_log_level("info"), "trace", "RUST_LOG set ⇒ base opens to trace; EnvFilter owns the gate" ); - - match prev_debug { - Some(v) => unsafe { std::env::set_var("CODEGRAPH_DEBUG", v) }, - None => unsafe { std::env::remove_var("CODEGRAPH_DEBUG") }, - } - match prev_rust_log { - Some(v) => unsafe { std::env::set_var("RUST_LOG", v) }, - None => unsafe { std::env::remove_var("RUST_LOG") }, - } } #[test] @@ -2585,7 +2937,8 @@ mod serve_mode_tests { let indexed = std::env::temp_dir().join(format!("cg-serve-gate-idx-{}-{seq}", std::process::id())); std::fs::create_dir_all(&unindexed).unwrap(); - std::fs::create_dir_all(indexed.join(".codegraph")).unwrap(); + // Batch M: the serve-services gate keys on the v2 current root. + std::fs::create_dir_all(indexed.join(".codegraph-v2")).unwrap(); assert!( should_run_serve_services(true, &unindexed), @@ -2606,14 +2959,13 @@ mod serve_mode_tests { #[test] fn daemon_services_disabled_at_home_and_root_enabled_for_nested_project() { - let _lock = ENV_LOCK.lock().unwrap(); + let mut env = env_guard(); let home_key = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; - let prev_home = std::env::var_os(home_key); let tmp = std::env::temp_dir().join(format!("cg-serve-home-{}", std::process::id())); let nested = tmp.join("workspace/ProdDir/AI/codegraph-rust"); std::fs::create_dir_all(&nested).unwrap(); - unsafe { std::env::set_var(home_key, &tmp) }; + env.set(home_key, &tmp); assert!( !should_run_daemon_services(&tmp), @@ -2628,23 +2980,19 @@ mod serve_mode_tests { "a project nested under $HOME must keep daemon services" ); - match prev_home { - Some(v) => unsafe { std::env::set_var(home_key, v) }, - None => unsafe { std::env::remove_var(home_key) }, - } + env.assert_intact(); let _ = std::fs::remove_dir_all(&tmp); } #[test] fn guard_indexable_root_rejects_home_and_root_allows_nested_project() { - let _lock = ENV_LOCK.lock().unwrap(); + let mut env = env_guard(); let home_key = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; - let prev_home = std::env::var_os(home_key); let tmp = std::env::temp_dir().join(format!("cg-guard-home-{}", std::process::id())); let nested = tmp.join("workspace/proj"); std::fs::create_dir_all(&nested).unwrap(); - unsafe { std::env::set_var(home_key, &tmp) }; + env.set(home_key, &tmp); assert!( guard_indexable_root(&tmp).is_err(), @@ -2659,10 +3007,7 @@ mod serve_mode_tests { "a project nested under $HOME must be indexable" ); - match prev_home { - Some(v) => unsafe { std::env::set_var(home_key, v) }, - None => unsafe { std::env::remove_var(home_key) }, - } + env.assert_intact(); let _ = std::fs::remove_dir_all(&tmp); } @@ -2684,9 +3029,9 @@ mod serve_mode_tests { fn cmd_unlock(path: Option) -> Result<()> { let project = resolve_required_project(path)?; - let daemon_lock = codegraph_daemon::daemon_pid_path(&project); + let daemon_lock = codegraph_daemon::daemon_pid_path(&project)?; let daemon_removed = daemon_lock.exists() && codegraph_daemon::unlock_project(&project); - let lock = codegraph_dir(&project).join("codegraph.lock"); + let lock = codegraph_dir(&project)?.join("codegraph.lock"); if !lock.exists() && !daemon_removed { println!("No lock file found - nothing to do"); return Ok(()); @@ -2851,13 +3196,19 @@ fn cmd_explore( fn cmd_node( target: String, path: Option, + file: Option, symbols_only: bool, json_output: bool, strict: bool, ) -> Result<()> { let project = resolve_required_project(path)?; let engine = codegraph_mcp::CodeGraphEngine::open(&project)?; - let args = if node_target_is_file(&engine, &target) { + // #1314: `-f/--file` PINS an overloaded symbol to one file and must still + // carry `includeCode`, exactly like the bare-symbol branch below — the + // pinned definition's source body is the whole point of the pin. + let args = if let Some(file) = &file { + json!({ "symbol": target, "file": file, "includeCode": true }) + } else if node_target_is_file(&engine, &target) { json!({ "file": target, "symbolsOnly": symbols_only }) } else { json!({ "symbol": target, "includeCode": true }) @@ -3397,28 +3748,84 @@ fn finish_phase(bar: &ProgressBar, label: &str) { bar.abandon_with_message(format!("✓ {label} ({elapsed})")); } -fn index_project(project: &Path, clear_first: bool, verbose: bool) -> Result { - index_project_inner(project, clear_first, verbose, false) +fn index_project(project: &Path, kind: codegraph_store::RebuildKind) -> Result { + index_project_inner(project, kind, false, false) } -/// Restores the shared `synchronous=NORMAL` durability (and truncates the WAL) when -/// the full index finishes OR bails out early via `?`. Drop never panics: a failed -/// restore is logged, not propagated. +/// Owns one destructive v2 rebuild for the whole full-index body. +/// +/// `begin` acquires the single outer exclusive `IndexLease`, classifies under it, +/// publishes `phase=building`, removes the previous v2 database files, and opens +/// the fresh write-capable target. [`Self::finish`] is the EXPLICIT FALLIBLE +/// completion path required by the frozen plan (lines 548-556): under the same +/// retained lease it restores the shared `synchronous=NORMAL` durability, runs the +/// final checkpoint + compaction, stamps extraction version 2, checkpoints that +/// stamp into the main database file, closes the final SQLite connection, and only +/// then publishes `phase=current` (removing a tombstone solely for a successful +/// explicit `init`). Every failure propagates. +/// +/// `Drop` is emergency best-effort cleanup only: it can never publish `Current`, +/// so an index that bails out early via `?` leaves the namespace `phase=building` +/// — unreadable and fail-closed — and a rerun rebuilds it from scratch. +/// `Self::finish` consumes the guard, which is what disarms that fallback. struct BulkIndexPragmaGuard { - db_path: PathBuf, + rebuild: Option, +} + +/// Bounded wall-clock budget for acquiring the one outer exclusive lease. Never a +/// blocking wait: `IndexLease` polls `try_lock` against this monotonic deadline. +const REBUILD_LEASE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +const READ_LEASE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +const STATUS_LEASE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(100); + +impl BulkIndexPragmaGuard { + fn begin( + paths: &codegraph_core::IndexPaths, + kind: codegraph_store::RebuildKind, + ) -> Result { + let deadline = std::time::Instant::now() + REBUILD_LEASE_TIMEOUT; + let rebuild = codegraph_store::begin_full_rebuild(paths, kind, deadline, || false)?; + let rebuild = rebuild.open_store()?; + Ok(Self { + rebuild: Some(rebuild), + }) + } + + fn finish(mut self) -> Result<()> { + let rebuild = self + .rebuild + .take() + .expect("a rebuild guard is finished at most once"); + rebuild.finish().map_err(Into::into) + } +} + +impl std::ops::Deref for BulkIndexPragmaGuard { + type Target = codegraph_store::ActiveFullRebuild; + + fn deref(&self) -> &Self::Target { + self.rebuild + .as_ref() + .expect("the CLI guard owns its active rebuild until finish") + } +} + +impl std::ops::DerefMut for BulkIndexPragmaGuard { + fn deref_mut(&mut self) -> &mut Self::Target { + self.rebuild + .as_mut() + .expect("the CLI guard owns its active rebuild until finish") + } } impl Drop for BulkIndexPragmaGuard { fn drop(&mut self) { - let result = match Store::open(&self.db_path) { - Ok(store) => store.restore_default_pragmas().map_err(anyhow::Error::from), - Err(err) => Err(anyhow::Error::from(err)), - }; - if let Err(err) = result { + if let Some(rebuild) = self.rebuild.take() { + // No publication happens here by construction: the namespace stays + // `phase=building`, so nothing can read a half-built graph. tracing::warn!( - error = %err, - db = %self.db_path.display(), - "failed to restore default pragmas after full index", + root = %rebuild.paths().current_root().display(), + "full index did not finalize; index remains phase=building and unreadable", ); } } @@ -3426,39 +3833,40 @@ impl Drop for BulkIndexPragmaGuard { fn index_project_inner( project: &Path, - clear_first: bool, + kind: codegraph_store::RebuildKind, verbose: bool, quiet: bool, ) -> Result { let started = std::time::Instant::now(); - if clear_first { - remove_db_files(project)?; - } - fs::create_dir_all(codegraph_dir(project))?; - let config = codegraph_core::config::get_config(); - let options = ExtractOptions { - max_file_size: config.indexing.max_file_size, - ignore_dirs: config.indexing.ignore_dirs.clone(), - ignore_paths: config.indexing.ignore_paths.clone(), - exclude: config.indexing.exclude.clone(), - include: config.indexing.include.clone(), - parallel: true, - }; + let paths = index_paths(project)?; + let index_root = paths.current_root().to_path_buf(); + // THIS project's own immutable config + extension overrides, read from its + // resolved current index root. Nothing consults a process-global value, a + // legacy `.codegraph` root, or the process working directory. + let config = Config::load_for_paths(None, &paths)?; + let extensions = codegraph_extract::ExtensionOverrides::load_for_paths(&paths); + let options = ExtractOptions::for_project(&config, extensions); + let framework_context = codegraph_resolve::framework::FrameworkExtractionContext::new( + project.to_string_lossy().into_owned(), + codegraph_resolve::frameworks::godot_dsl_config::GodotDslConfig::load_for_paths(&paths), + ); if !quiet { eprintln!("Scanning files…"); } let files = codegraph_extract::engine::scan_project(project, &options)?; + // One destructive rebuild under ONE outer exclusive lease: classify, publish + // `phase=building`, remove the previous v2 DB files, then open the fresh + // write-capable target. The index root and DB are created by the rebuild + // layer, so nothing below reconstructs a path or reopens the namespace. + // // `synchronous=OFF` + a larger cache/mmap window speed up the from-scratch bulk - // index. The restore lives in a Drop guard, NOT a trailing statement, because - // every `?` below would skip a trailing restore and leave `synchronous=OFF` - // durable on the error path. Declared BEFORE `store` so it drops AFTER it: the - // guard's own connection then runs wal_checkpoint(TRUNCATE)+NORMAL with no WAL - // contention, leaving the file in the same shape a NORMAL run produces. - let _pragma_guard = BulkIndexPragmaGuard { - db_path: db_path(project), - }; - let store = open_store(project)?; + // index. Their restore is part of the guard's EXPLICIT FALLIBLE `finish`, not a + // trailing statement, because every `?` below would skip a trailing restore. + // If the body bails out early, the guard's Drop only attempts state-gated + // pragma repair/compaction/close and publishes nothing: the namespace stays + // `phase=building` and unreadable. + let store = BulkIndexPragmaGuard::begin(&paths, kind)?; store.set_bulk_index_pragmas()?; let before = store.counts()?; @@ -3483,7 +3891,7 @@ fn index_project_inner( const REF_FLUSH_ROWS: usize = 20_000; const RESOLVE_BATCH_ROWS: usize = 5_000; - let spill = SpillWriter::new(codegraph_dir(project))?; + let spill = SpillWriter::new(index_root.clone())?; let pending_nodes: Vec = Vec::with_capacity(NODE_FLUSH_ROWS); let bar = progress_bar( @@ -3594,12 +4002,12 @@ fn index_project_inner( duration_ms: 0, } } else { - extract_source(relative, &source, None) + extract_source_with(relative, &source, None, &options_ref.extensions) }; let file = FileRecord { path: relative.clone(), content_hash: hash_content(&source), - language: detect_language(relative), + language: detect_language_with(relative, &options_ref.extensions), size: metadata.len() as i64, modified_at: modified_millis(&metadata), indexed_at: now_millis(), @@ -3747,7 +4155,12 @@ fn index_project_inner( .into_iter() .map(|f| f.path) .collect::>(); - resolver.extract_and_persist_frameworks(&mut store, &relative_files)?; + resolver.extract_and_persist_frameworks_with( + &mut store, + &relative_files, + &framework_context, + &options.extensions, + )?; } finish_phase(&pb, "Detected frameworks"); // Finished from INSIDE the callback on the final chunk so the retained line @@ -3785,14 +4198,15 @@ fn index_project_inner( resolver.run_post_extract(&mut store)?; finish_phase(&pb, "Finalized frameworks"); store.set_project_metadata("indexed_with_version", VERSION)?; - store.set_project_metadata( - "indexed_with_extraction_version", - &EXTRACTION_VERSION.to_string(), - )?; - let pb = phase_spinner("Compacting database", quiet); - store.compact()?; - finish_phase(&pb, "Compacted database"); let after = store.counts()?; + // Explicit fallible finalization: pragma restore -> checkpoint + compaction -> + // extraction stamp -> stamp checkpoint -> close the final connection -> + // publish `phase=current` (and remove a tombstone only for a successful + // explicit init). The namespace becomes readable at the LAST step, or not at + // all. Counts are read BEFORE the connection closes. + let pb = phase_spinner("Publishing index", quiet); + store.finish()?; + finish_phase(&pb, "Published index"); Ok(IndexSummary { files_indexed, files_skipped, @@ -4198,24 +4612,120 @@ fn exact_or_top_matches<'a>(matches: &'a [Node], symbol: &str) -> Vec<&'a Node> } fn open_store(project: &Path) -> Result { - Store::open(&db_path(project)).map_err(Into::into) + let paths = index_paths(project)?; + Store::open_for_read( + &paths, + std::time::Instant::now() + READ_LEASE_TIMEOUT, + || false, + ) + .map_err(Into::into) } +/// Whether `project` has a current-namespace index DB. An unsafe/aliased +/// `CODEGRAPH_DIR` (a `resolve` failure) counts as NOT initialized here so +/// project discovery keeps walking; the mutating command paths independently +/// re-resolve fail-closed via [`db_path`]/[`codegraph_dir`] before touching disk. fn is_initialized(project: &Path) -> bool { - db_path(project).exists() + let Ok(paths) = index_paths(project) else { + return false; + }; + Store::extraction_status(&paths) == codegraph_store::ExtractionStatus::Current + && paths.current_db().is_file() +} + +/// Explicit init's early-return condition is a fully corroborated readable +/// Current namespace, never raw DB existence. Current+tombstone is an expected +/// retryable finalizer residue for explicit init, while every other Current +/// inconsistency remains a typed error. +fn explicit_init_observes_readable_current(project: &Path) -> Result { + let paths = index_paths(project)?; + if Store::extraction_status(&paths) != codegraph_store::ExtractionStatus::Current { + return Ok(false); + } + let deadline = std::time::Instant::now() + REBUILD_LEASE_TIMEOUT; + match Store::open_for_read(&paths, deadline, || false) { + Ok(store) => { + drop(store); + Ok(true) + } + Err(codegraph_store::StoreError::CurrentTombstoned { .. }) => Ok(false), + Err(error) => Err(error.into()), + } +} + +/// Discovery predicate for the destructive `index` command. A durable state +/// slot keeps an interrupted Building namespace discoverable even if the DB was +/// already deleted. A raw DB remains discoverable only so the under-lease Store +/// gate can reject Missing+DB as corruption; it is never interpreted as Current. +fn has_rebuild_namespace(project: &Path) -> bool { + let Ok(paths) = index_paths(project) else { + return false; + }; + Store::extraction_status(&paths) != codegraph_store::ExtractionStatus::Missing + || paths.current_db().exists() +} + +/// Authenticated lifecycle state is a discovery marker for default and relative +/// roots even when the namespace is not readable. A permanent lock or raw DB by +/// itself is deliberately not a marker: neither authenticates an owner-bound +/// project state. +fn has_lifecycle_namespace(project: &Path) -> bool { + let Ok(paths) = index_paths(project) else { + return false; + }; + Store::extraction_status(&paths) != codegraph_store::ExtractionStatus::Missing +} + +fn configured_root_is_absolute() -> bool { + std::env::var_os("CODEGRAPH_DIR") + .filter(|value| !value.is_empty()) + .is_some_and(|value| Path::new(&value).is_absolute()) } fn resolve_required_project(path: Option) -> Result { + let explicit = path.is_some(); let start = absolute_path(path.unwrap_or_else(|| PathBuf::from("."))); - let project = resolve_project_path_optional(&start); + let project = if configured_root_is_absolute() { + if !explicit && !is_initialized(&start) { + bail!("CODEGRAPH_DIR is absolute; pass the project root explicitly"); + } + start + } else { + resolve_project_path_optional(&start) + }; if !is_initialized(&project) { bail!("CodeGraph not initialized in {}", project.display()); } Ok(project) } +fn resolve_required_rebuild_project(path: Option) -> Result { + let explicit = path.is_some(); + let start = absolute_path(path.unwrap_or_else(|| PathBuf::from("."))); + if has_rebuild_namespace(&start) { + return Ok(start); + } + if configured_root_is_absolute() { + if !explicit { + bail!("CODEGRAPH_DIR is absolute; pass the project root explicitly"); + } + bail!("CodeGraph not initialized in {}", start.display()); + } + let mut current = start.as_path(); + while let Some(parent) = current.parent() { + if parent == current { + break; + } + if has_rebuild_namespace(parent) { + return Ok(parent.to_path_buf()); + } + current = parent; + } + bail!("CodeGraph not initialized in {}", start.display()) +} + fn resolve_project_path_optional(start: &Path) -> PathBuf { - if is_initialized(start) { + if configured_root_is_absolute() || has_lifecycle_namespace(start) { return start.to_path_buf(); } let mut current = start; @@ -4223,7 +4733,7 @@ fn resolve_project_path_optional(start: &Path) -> PathBuf { if parent == current { break; } - if is_initialized(parent) { + if has_lifecycle_namespace(parent) { return parent.to_path_buf(); } current = parent; @@ -4270,22 +4780,25 @@ fn normalize_lexical(path: &Path) -> PathBuf { out } -fn codegraph_dir(project: &Path) -> PathBuf { - project.join(std::env::var("CODEGRAPH_DIR").unwrap_or_else(|_| ".codegraph".to_string())) +/// Fail-closed resolution of the project's index paths through the single +/// `codegraph-core::IndexPaths` authority, honoring `CODEGRAPH_DIR`. Errors on +/// an unsafe/aliased/overlapping configured root or an inaccessible project; +/// callers never fall back to a reconstructed path. +fn index_paths(project: &Path) -> Result { + codegraph_core::IndexPaths::resolve(project, std::env::var("CODEGRAPH_DIR").ok().as_deref()) + .map_err(Into::into) } -fn db_path(project: &Path) -> PathBuf { - codegraph_dir(project).join("codegraph.db") +/// The current (v2) index root for `project` (`.codegraph-v2` by default; a +/// `-v2-` sibling for a configured `CODEGRAPH_DIR`). +/// Fail-closed via [`index_paths`]. +fn codegraph_dir(project: &Path) -> Result { + Ok(index_paths(project)?.current_root().to_path_buf()) } -fn remove_db_files(project: &Path) -> Result<()> { - for suffix in ["", "-wal", "-shm"] { - let path = PathBuf::from(format!("{}{}", db_path(project).display(), suffix)); - if path.exists() { - fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?; - } - } - Ok(()) +/// The current (v2) index DB path for `project`. Fail-closed via [`index_paths`]. +fn db_path(project: &Path) -> Result { + Ok(index_paths(project)?.current_db()) } fn parse_node_kind(raw: &str) -> Result { @@ -4311,6 +4824,18 @@ fn latest_indexed_at(store: &Store) -> Result> { } fn journal_mode(store: &Store) -> Result { + // A state-gated reader executes queries against a private deserialized + // in-memory image so it cannot create `-wal`/`-shm` sidecars. Its PRAGMA + // therefore reports `memory`, not the authoritative main database's mode. + // While the Store retains its shared lease, inspect SQLite's two format + // bytes instead: 2/2 is the durable WAL marker. Fall back to PRAGMA for + // legacy/non-WAL stores where the header cannot distinguish every rollback + // journal variant. + let mut header = [0_u8; 20]; + fs::File::open(store.path())?.read_exact(&mut header)?; + if header.starts_with(b"SQLite format 3\0") && header[18] == 2 && header[19] == 2 { + return Ok("wal".to_string()); + } store .connection() .query_row("PRAGMA journal_mode", [], |row| row.get(0)) @@ -5104,10 +5629,21 @@ mod pure_helper_tests { #[test] fn db_path_is_under_codegraph_dir() { + // Reads CODEGRAPH_DIR, which a sibling test unsets and restores, so it + // takes the same lock even though it never writes. + let _env = crate::test_env::env_guard(); if std::env::var("CODEGRAPH_DIR").is_err() { - let p = Path::new("/proj"); - assert_eq!(db_path(p), PathBuf::from("/proj/.codegraph/codegraph.db")); - assert_eq!(codegraph_dir(p), PathBuf::from("/proj/.codegraph")); + let dir = tmp("dbpath"); + let canonical = dir.canonicalize().unwrap(); + assert_eq!( + db_path(&dir).unwrap(), + canonical.join(".codegraph-v2/codegraph.db") + ); + assert_eq!( + codegraph_dir(&dir).unwrap(), + canonical.join(".codegraph-v2") + ); + let _ = fs::remove_dir_all(&dir); } } @@ -5219,34 +5755,23 @@ mod pure_helper_tests { #[test] fn env_path_none_for_empty_or_unset_some_for_value() { + let mut env = crate::test_env::env_guard(); let key = "CODEGRAPH_TEST_ENV_PATH_UNSET_XYZ"; - // SAFETY: the key is test-private and this test runs single-threaded within its own scope. - unsafe { - std::env::remove_var(key); - } + env.remove(key); assert_eq!(env_path(key), None); - unsafe { - std::env::set_var(key, ""); - } + env.set(key, ""); assert_eq!(env_path(key), None); - unsafe { - std::env::set_var(key, "/some/where"); - } + env.set(key, "/some/where"); assert_eq!(env_path(key), Some(PathBuf::from("/some/where"))); - unsafe { - std::env::remove_var(key); - } } } #[cfg(test)] mod formatter_and_env_tests { use super::*; + use crate::test_env::env_guard; use codegraph_core::types::{FileRecord, Language, NodeKind}; - // Serializes tests that mutate process-global env (cargo test runs them concurrently). - static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - fn tmp(tag: &str) -> PathBuf { let p = std::env::temp_dir().join(format!( "cg-cli-fmt-{tag}-{}-{}", @@ -5281,14 +5806,20 @@ mod formatter_and_env_tests { #[test] fn codegraph_dir_and_db_path_default_layout() { - let prev = std::env::var_os("CODEGRAPH_DIR"); - unsafe { std::env::remove_var("CODEGRAPH_DIR") }; - let proj = Path::new("/tmp/proj"); - assert_eq!(codegraph_dir(proj), proj.join(".codegraph")); - assert_eq!(db_path(proj), proj.join(".codegraph/codegraph.db")); - if let Some(v) = prev { - unsafe { std::env::set_var("CODEGRAPH_DIR", v) }; - } + let mut env = env_guard(); + env.remove("CODEGRAPH_DIR"); + let proj = tmp("default-layout"); + let canonical = proj.canonicalize().unwrap(); + assert_eq!( + codegraph_dir(&proj).unwrap(), + canonical.join(".codegraph-v2") + ); + assert_eq!( + db_path(&proj).unwrap(), + canonical.join(".codegraph-v2/codegraph.db") + ); + env.assert_intact(); + let _ = fs::remove_dir_all(&proj); } #[test] @@ -5524,31 +6055,20 @@ mod formatter_and_env_tests { #[test] fn home_dir_resolves_from_home_then_userprofile_then_errors() { - let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let prev_home = std::env::var_os("HOME"); - let prev_up = std::env::var_os("USERPROFILE"); - unsafe { std::env::set_var("HOME", "/home/tester") }; + let mut env = env_guard(); + env.set("HOME", "/home/tester"); assert_eq!(home_dir().unwrap(), PathBuf::from("/home/tester")); - unsafe { std::env::remove_var("HOME") }; - unsafe { std::env::remove_var("USERPROFILE") }; + env.remove("HOME"); + env.remove("USERPROFILE"); assert!(home_dir().is_err()); - if let Some(v) = prev_home { - unsafe { std::env::set_var("HOME", v) }; - } - if let Some(v) = prev_up { - unsafe { std::env::set_var("USERPROFILE", v) }; - } } #[test] fn completion_target_paths_per_shell() { - let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let prev_home = std::env::var_os("HOME"); - let prev_xdg = std::env::var_os("XDG_DATA_HOME"); - let prev_local = std::env::var_os("LOCALAPPDATA"); - unsafe { std::env::set_var("HOME", "/h") }; - unsafe { std::env::remove_var("XDG_DATA_HOME") }; - unsafe { std::env::remove_var("LOCALAPPDATA") }; + let mut env = env_guard(); + env.set("HOME", "/h"); + env.remove("XDG_DATA_HOME"); + env.remove("LOCALAPPDATA"); assert_eq!( completion_target(Shell::Bash).unwrap(), @@ -5570,61 +6090,31 @@ mod formatter_and_env_tests { completion_target(Shell::Elvish).unwrap(), PathBuf::from("/h/.config/codegraph/completion.elv") ); - unsafe { std::env::set_var("XDG_DATA_HOME", "/xdg") }; + env.set("XDG_DATA_HOME", "/xdg"); assert_eq!( completion_target(Shell::Bash).unwrap(), PathBuf::from("/xdg/bash-completion/completions/codegraph") ); - - if let Some(v) = prev_home { - unsafe { std::env::set_var("HOME", v) }; - } else { - unsafe { std::env::remove_var("HOME") }; - } - match prev_xdg { - Some(v) => unsafe { std::env::set_var("XDG_DATA_HOME", v) }, - None => unsafe { std::env::remove_var("XDG_DATA_HOME") }, - } - match prev_local { - Some(v) => unsafe { std::env::set_var("LOCALAPPDATA", v) }, - None => unsafe { std::env::remove_var("LOCALAPPDATA") }, - } } #[test] fn powershell_profile_path_override_then_userprofile_then_error() { - let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let prev_ps = std::env::var_os("CODEGRAPH_PS_PROFILE"); - let prev_up = std::env::var_os("USERPROFILE"); - let prev_home = std::env::var_os("HOME"); + let mut env = env_guard(); - unsafe { std::env::set_var("CODEGRAPH_PS_PROFILE", "/custom/profile.ps1") }; + env.set("CODEGRAPH_PS_PROFILE", "/custom/profile.ps1"); assert_eq!( powershell_profile_path().unwrap(), PathBuf::from("/custom/profile.ps1") ); - unsafe { std::env::remove_var("CODEGRAPH_PS_PROFILE") }; - unsafe { std::env::set_var("USERPROFILE", "/up") }; + env.remove("CODEGRAPH_PS_PROFILE"); + env.set("USERPROFILE", "/up"); assert_eq!( powershell_profile_path().unwrap(), PathBuf::from("/up/Documents/WindowsPowerShell/Microsoft.PowerShell_profile.ps1") ); - unsafe { std::env::remove_var("USERPROFILE") }; - unsafe { std::env::remove_var("HOME") }; + env.remove("USERPROFILE"); + env.remove("HOME"); assert!(powershell_profile_path().is_err()); - - match prev_ps { - Some(v) => unsafe { std::env::set_var("CODEGRAPH_PS_PROFILE", v) }, - None => unsafe { std::env::remove_var("CODEGRAPH_PS_PROFILE") }, - } - match prev_up { - Some(v) => unsafe { std::env::set_var("USERPROFILE", v) }, - None => unsafe { std::env::remove_var("USERPROFILE") }, - } - match prev_home { - Some(v) => unsafe { std::env::set_var("HOME", v) }, - None => unsafe { std::env::remove_var("HOME") }, - } } #[test] @@ -5651,16 +6141,13 @@ mod formatter_and_env_tests { #[test] fn http_log_path_lands_under_registry_dir() { + let mut env = env_guard(); let dir = tmp("httplog"); - let prev = std::env::var_os("CODEGRAPH_HTTP_REGISTRY_DIR"); - unsafe { std::env::set_var("CODEGRAPH_HTTP_REGISTRY_DIR", &dir) }; + env.set("CODEGRAPH_HTTP_REGISTRY_DIR", &dir); let p = http_log_path("127.0.0.1:8111"); assert!(p.starts_with(&dir)); assert!(p.extension().is_some_and(|e| e == "log")); - match prev { - Some(v) => unsafe { std::env::set_var("CODEGRAPH_HTTP_REGISTRY_DIR", v) }, - None => unsafe { std::env::remove_var("CODEGRAPH_HTTP_REGISTRY_DIR") }, - } + env.assert_intact(); let _ = fs::remove_dir_all(&dir); } @@ -5692,29 +6179,22 @@ mod formatter_and_env_tests { log_file: Some("/tmp/y.log".to_string()), }; print_http_conflict(&info); + let mut env = env_guard(); let dir = tmp("noteothers"); - let prev = std::env::var_os("CODEGRAPH_HTTP_REGISTRY_DIR"); - unsafe { std::env::set_var("CODEGRAPH_HTTP_REGISTRY_DIR", &dir) }; + env.set("CODEGRAPH_HTTP_REGISTRY_DIR", &dir); note_other_running_servers("127.0.0.1:1234"); - match prev { - Some(v) => unsafe { std::env::set_var("CODEGRAPH_HTTP_REGISTRY_DIR", v) }, - None => unsafe { std::env::remove_var("CODEGRAPH_HTTP_REGISTRY_DIR") }, - } + env.assert_intact(); let _ = fs::remove_dir_all(&dir); } #[test] fn is_http_detach_internal_reads_env_marker() { + let mut env = env_guard(); let key = codegraph_daemon::CODEGRAPH_HTTP_DETACH_INTERNAL; - let prev = std::env::var_os(key); - unsafe { std::env::remove_var(key) }; + env.remove(key); assert!(!is_http_detach_internal()); - unsafe { std::env::set_var(key, "1") }; + env.set(key, "1"); assert!(is_http_detach_internal()); - match prev { - Some(v) => unsafe { std::env::set_var(key, v) }, - None => unsafe { std::env::remove_var(key) }, - } } #[test] @@ -5734,70 +6214,58 @@ mod formatter_and_env_tests { #[test] fn install_completions_writes_zsh_fish_elvish_into_home() { - let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut env = env_guard(); let dir = tmp("install-comp"); - let prev_home = std::env::var_os("HOME"); - let prev_xdg = std::env::var_os("XDG_DATA_HOME"); - unsafe { std::env::set_var("HOME", &dir) }; - unsafe { std::env::remove_var("XDG_DATA_HOME") }; + env.set("HOME", &dir); + env.remove("XDG_DATA_HOME"); + // Every step re-asserts the guarded env BEFORE writing, so a stolen + // HOME fails here instead of installing into the real home directory. + env.assert_intact(); install_completions(Shell::Zsh).unwrap(); assert!(dir.join(".zfunc/_codegraph").is_file()); + env.assert_intact(); install_completions(Shell::Fish).unwrap(); assert!( dir.join(".config/fish/completions/codegraph.fish") .is_file() ); + env.assert_intact(); install_completions(Shell::Elvish).unwrap(); let elv = dir.join(".config/codegraph/completion.elv"); assert!(elv.is_file()); assert!(fs::read_to_string(&elv).unwrap().contains("codegraph")); + env.assert_intact(); install_completions(Shell::Bash).unwrap(); assert!( dir.join(".local/share/bash-completion/completions/codegraph") .is_file() ); - match prev_home { - Some(v) => unsafe { std::env::set_var("HOME", v) }, - None => unsafe { std::env::remove_var("HOME") }, - } - match prev_xdg { - Some(v) => unsafe { std::env::set_var("XDG_DATA_HOME", v) }, - None => unsafe { std::env::remove_var("XDG_DATA_HOME") }, - } let _ = fs::remove_dir_all(&dir); } #[test] fn install_completions_powershell_writes_script_and_dot_sources_profile() { - let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut env = env_guard(); let dir = tmp("install-ps"); - let prev_local = std::env::var_os("LOCALAPPDATA"); - let prev_ps = std::env::var_os("CODEGRAPH_PS_PROFILE"); let profile = dir.join("profile.ps1"); - unsafe { std::env::set_var("LOCALAPPDATA", &dir) }; - unsafe { std::env::set_var("CODEGRAPH_PS_PROFILE", &profile) }; + env.set("LOCALAPPDATA", &dir); + env.set("CODEGRAPH_PS_PROFILE", &profile); + env.assert_intact(); install_completions(Shell::PowerShell).unwrap(); let script = dir.join("codegraph/completion.ps1"); assert!(script.is_file()); + env.assert_intact(); install_completions(Shell::PowerShell).unwrap(); let line = format!(". \"{}\"", script.display()); let body = fs::read_to_string(&profile).unwrap(); assert_eq!(body.lines().filter(|l| l.trim() == line).count(), 1); - match prev_local { - Some(v) => unsafe { std::env::set_var("LOCALAPPDATA", v) }, - None => unsafe { std::env::remove_var("LOCALAPPDATA") }, - } - match prev_ps { - Some(v) => unsafe { std::env::set_var("CODEGRAPH_PS_PROFILE", v) }, - None => unsafe { std::env::remove_var("CODEGRAPH_PS_PROFILE") }, - } let _ = fs::remove_dir_all(&dir); } } diff --git a/crates/codegraph-cli/tests/batch_m_daemon_uninit_lifecycle.rs b/crates/codegraph-cli/tests/batch_m_daemon_uninit_lifecycle.rs new file mode 100644 index 0000000..972343d --- /dev/null +++ b/crates/codegraph-cli/tests/batch_m_daemon_uninit_lifecycle.rs @@ -0,0 +1,568 @@ +//! Batch M item 20 — daemon rendezvous lifecycle under `uninit --force`. +//! +//! Frozen plan `upstream-v1.5-portable-fixes.md` lines 590-612 and 787-793: +//! +//! * daemon startup takes a bounded SHARED index lease across state/owner/ +//! tombstone validation AND pid/socket publication, so a concurrent start that +//! blocks on `uninit`'s exclusive lease observes the authoritative +//! `uninitialized` state slot and the tombstone (published in that order) +//! BEFORE it could publish any rendezvous artifact — and therefore publishes +//! none; +//! * `uninit --force` acquires exclusive first, reclassifies, publishes the +//! `uninitialized` slot, publishes/ensures the tombstone, and only then sends a +//! versioned, project-identity-bound shutdown control frame that BYPASSES +//! data-request lease acquisition. The daemon stops accepting, cancels queued/ +//! running watcher lease loops, drains, removes its owned pid/socket, and ACKs; +//! only then does uninit remove the remaining v2 runtime children while still +//! holding the same exclusive lease; +//! * an unresponsive daemon is fail-closed: NO pid is killed, the already +//! published durable markers stay, and the namespace classifies recoverable +//! `Uninitialized` (never `Corrupt`), continuable by a repeated +//! `uninit --force`. +//! +//! Determinism. Ordering evidence never comes from a sleep: the concurrent-start +//! test publishes both durable markers while it still holds the exclusive lease, +//! and the competing daemon start is stopped at the store's test-only +//! post-acquisition SHARED-lease barrier, so the "did it publish anything?" +//! snapshot is taken at an exact checkpoint. The drain tests assert process exit +//! status and on-disk artifacts, with wall-clock only as a finite upper bound. + +use std::fs; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Output, Stdio}; +use std::sync::mpsc::{self, Receiver}; +use std::thread; +use std::time::{Duration, Instant}; + +use codegraph_core::IndexPaths; +use codegraph_daemon::{DaemonLockInfo, encode_lock_info, is_process_alive}; +use codegraph_store::{ + ExtractionStatus, IndexLease, StatePhase, Store, classify, publish_index_state, +}; + +/// Store test-hook barrier envs (compiled into `CARGO_BIN_EXE_codegraph` only +/// for this package's test builds, never the shipped binary). +const BARRIER_ADDR: &str = "CODEGRAPH_TEST_LEASE_BARRIER_ADDR"; +const BARRIER_MODE: &str = "CODEGRAPH_TEST_LEASE_BARRIER_MODE"; + +const WAIT: Duration = Duration::from_secs(20); +const LEASE_WAIT: Duration = Duration::from_secs(30); +const TOMBSTONE_BYTES: &[u8] = b"uninitialized\n"; + +fn bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_codegraph")) +} + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("codegraph-cli lives under crates/") + .to_path_buf() +} + +fn copy_tree(source: &Path, target: &Path) { + fs::create_dir_all(target).expect("create fixture target"); + for entry in fs::read_dir(source).expect("read fixture source") { + let entry = entry.expect("fixture entry"); + let destination = target.join(entry.file_name()); + if entry.path().is_dir() { + copy_tree(&entry.path(), &destination); + } else { + fs::copy(entry.path(), destination).expect("copy fixture file"); + } + } +} + +struct TestProject(PathBuf); + +impl TestProject { + /// A real `codegraph init` of the mini fixture: a genuinely `Current` v2 + /// namespace with its permanent lock, both state slots, and a database. + fn indexed(label: &str) -> Self { + let root = std::env::temp_dir().join(format!( + "codegraph-m20-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock after epoch") + .as_nanos() + )); + let project = root.join("mini"); + copy_tree( + &workspace_root().join("crates/codegraph-bench/fixtures/mini"), + &project, + ); + let output = Command::new(bin()) + .args(["init", project.to_str().expect("utf-8 project path")]) + .output() + .expect("run codegraph init"); + assert!( + output.status.success(), + "init failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + Self(project) + } + + fn path(&self) -> &Path { + &self.0 + } + + fn paths(&self) -> IndexPaths { + IndexPaths::resolve(&self.0, None).expect("resolve v2 index paths") + } +} + +impl Drop for TestProject { + fn drop(&mut self) { + if let Some(parent) = self.0.parent() { + let _ = fs::remove_dir_all(parent); + } + } +} + +/// Every rendezvous artifact path a daemon could publish: the authoritative v2 +/// identities plus the LEGACY `.codegraph` spellings, so "published nothing" +/// cannot pass merely because the daemon wrote somewhere else. +fn rendezvous_candidates(project: &Path, paths: &IndexPaths) -> Vec { + let legacy = project.join(".codegraph"); + vec![ + paths.daemon_pid(), + paths.daemon_socket(), + paths.daemon_log(), + legacy.join("daemon.pid"), + legacy.join("daemon.sock"), + legacy.join("daemon.log"), + ] +} + +fn assert_no_rendezvous(project: &Path, paths: &IndexPaths, when: &str) { + for candidate in rendezvous_candidates(project, paths) { + assert!( + fs::symlink_metadata(&candidate).is_err(), + "{when}: no daemon rendezvous artifact may exist at {}", + candidate.display() + ); + } +} + +/// Deterministic post-acquisition lease barrier: the child stops immediately +/// after its lease is granted and corroborated, acknowledges arrival on this +/// listener, and resumes only when released. +struct LeaseBarrier { + address: SocketAddr, + arrived: Receiver<(u8, TcpStream)>, +} + +impl LeaseBarrier { + fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind lease barrier listener"); + let address = listener.local_addr().expect("lease barrier address"); + let (tx, arrived) = mpsc::channel(); + thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { return }; + let mut marker = [0_u8; 1]; + if stream.read_exact(&mut marker).is_err() { + return; + } + if marker[0] == b'C' { + return; + } + if tx.send((marker[0], stream)).is_err() { + return; + } + } + }); + Self { address, arrived } + } + + fn configure(&self, command: &mut Command, mode: &str) { + command + .env(BARRIER_ADDR, self.address.to_string()) + .env(BARRIER_MODE, mode); + } + + fn wait(&self, expected: u8) -> TcpStream { + match self.arrived.recv_timeout(WAIT) { + Ok((actual, stream)) => { + assert_eq!(actual, expected, "wrong lease mode reached the barrier"); + stream + } + Err(error) => { + if let Ok(mut cancel) = TcpStream::connect(self.address) { + let _ = cancel.write_all(b"C"); + } + panic!("lease barrier was not reached before its finite deadline: {error}"); + } + } + } + + fn release(mut stream: TcpStream) { + stream.write_all(b"R").expect("release the barriered child"); + } +} + +struct ChildGuard(Option); + +impl ChildGuard { + fn new(child: Child) -> Self { + Self(Some(child)) + } + + fn child_mut(&mut self) -> &mut Child { + self.0.as_mut().expect("child still owned") + } + + /// Wait for exit within a finite bound. The bound is an upper limit only — + /// no assertion depends on how long the child took. + fn wait_bounded(&mut self, label: &str) -> std::process::ExitStatus { + let deadline = Instant::now() + WAIT; + loop { + match self.child_mut().try_wait().expect("poll child status") { + Some(status) => return status, + None if Instant::now() < deadline => thread::sleep(Duration::from_millis(20)), + None => { + let _ = self.child_mut().kill(); + panic!("{label} did not exit within {WAIT:?}"); + } + } + } + } + + fn finish(mut self, label: &str) -> Output { + let status = self.wait_bounded(label); + let mut child = self.0.take().expect("child still owned"); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + if let Some(pipe) = child.stdout.as_mut() { + let _ = pipe.read_to_end(&mut stdout); + } + if let Some(pipe) = child.stderr.as_mut() { + let _ = pipe.read_to_end(&mut stderr); + } + // `wait_bounded` already reaped the child through `try_wait`; std caches + // that status, so this second wait cannot block and simply proves to the + // reader (and to clippy) that no child is left unreaped. + let _ = child.wait(); + Output { + status, + stdout, + stderr, + } + } +} + +impl Drop for ChildGuard { + fn drop(&mut self) { + if let Some(child) = self.0.as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +/// Spawn the daemon process the way production does when it IS the daemon: +/// `CODEGRAPH_DAEMON_INTERNAL=1 codegraph serve --mcp --path `. +fn daemon_command(project: &Path) -> Command { + let mut command = Command::new(bin()); + command + .args(["serve", "--mcp", "--path"]) + .arg(project) + .env(codegraph_daemon::CODEGRAPH_DAEMON_INTERNAL, "1") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command +} + +fn uninit_force(project: &Path) -> Output { + Command::new(bin()) + .args(["uninit", "--force"]) + .arg(project) + .output() + .expect("run codegraph uninit --force") +} + +/// A live process this test owns, used ONLY as the pid recorded in an +/// unresponsive daemon's rendezvous record: the "no PID kill" assertion needs a +/// real live pid that production must leave running. +fn spawn_live_placeholder() -> Child { + #[cfg(unix)] + let mut command = { + let mut command = Command::new("/bin/sleep"); + command.arg("120"); + command + }; + #[cfg(windows)] + let mut command = { + let mut command = Command::new("cmd"); + command.args(["/C", "ping -n 120 127.0.0.1 > NUL"]); + command + }; + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn the live placeholder owner process") +} + +fn write_owner_record(paths: &IndexPaths, pid: u32) { + let info = DaemonLockInfo { + pid, + version: env!("CARGO_PKG_VERSION").to_string(), + socket_path: paths.daemon_socket(), + started_at: 1, + }; + fs::write( + paths.daemon_pid(), + encode_lock_info(&info).expect("encode owner record"), + ) + .expect("write daemon owner record"); +} + +fn assert_recoverable_uninitialized(paths: &IndexPaths, when: &str) { + let classification = classify(paths); + assert_eq!( + classification.status(), + &ExtractionStatus::Uninitialized, + "{when}: the namespace must classify recoverable Uninitialized, not Corrupt" + ); + assert_eq!( + Store::extraction_status(paths), + ExtractionStatus::Uninitialized, + "{when}: the visible status must agree" + ); + assert!( + paths.tombstone().is_file(), + "{when}: the tombstone must remain published" + ); + assert!( + paths.permanent_lock().is_file(), + "{when}: the permanent index.lock is never removed" + ); + assert!( + paths.state_slots().iter().all(|slot| slot.is_file()), + "{when}: both state slots survive" + ); +} + +/// Whether a recorded rendezvous name is bound. On Unix the socket IS a +/// filesystem path; on Windows it is a bare namespaced pipe name (the daemon's +/// `GenericNamespaced` identity), which has no filesystem existence, so the +/// published owner record is the only observable there. +fn recorded_rendezvous_is_bound(socket: &Path) -> bool { + #[cfg(unix)] + { + socket.exists() + } + #[cfg(not(unix))] + { + !socket.as_os_str().is_empty() + } +} + +fn wait_for_owner_record(paths: &IndexPaths) -> u32 { + let deadline = Instant::now() + WAIT; + while Instant::now() < deadline { + if let Ok(raw) = fs::read_to_string(paths.daemon_pid()) + && let Some(info) = codegraph_daemon::decode_lock_info(&raw) + && info.pid > 0 + && recorded_rendezvous_is_bound(&info.socket_path) + { + return info.pid; + } + thread::sleep(Duration::from_millis(25)); + } + panic!( + "the daemon never published its v2 rendezvous at {}", + paths.daemon_pid().display() + ); +} + +#[test] +fn daemon_start_during_uninit_observes_uninitialized_and_tombstone_before_publish() { + let project = TestProject::indexed("concurrent-start"); + let paths = project.paths(); + assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Current); + assert_no_rendezvous(project.path(), &paths, "before any daemon start"); + + let barrier = LeaseBarrier::start(); + + // The competing daemon start can only observe the namespace AFTER this + // exclusive lease is released, so everything published below is ordered + // before its first observation without any timing assumption. + let uninit_lease = + IndexLease::acquire_exclusive_existing(&paths, Instant::now() + LEASE_WAIT, || false) + .expect("uninit acquires the exclusive lease first"); + + let mut command = daemon_command(project.path()); + barrier.configure(&mut command, "shared"); + let daemon = ChildGuard::new(command.spawn().expect("spawn the competing daemon start")); + + // The authoritative durable markers, in the protocol order: the + // `uninitialized` state slot first, then the tombstone. + publish_index_state(&paths, &uninit_lease, StatePhase::Uninitialized) + .expect("publish the authoritative uninitialized slot"); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Uninitialized + ); + fs::write(paths.tombstone(), TOMBSTONE_BYTES).expect("publish the tombstone"); + assert_no_rendezvous( + project.path(), + &paths, + "while uninit still holds the exclusive lease", + ); + + drop(uninit_lease); + + // Deterministic checkpoint: the child has just been granted its startup + // SHARED lease. Nothing may have been published at or before this point. + let released = barrier.wait(b'S'); + assert_no_rendezvous( + project.path(), + &paths, + "at the daemon's own startup shared-lease checkpoint", + ); + LeaseBarrier::release(released); + + let output = daemon.finish("the competing daemon start"); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + assert!( + !output.status.success(), + "a daemon start must refuse an uninitialized namespace; stderr: {stderr}" + ); + assert!( + stderr.contains("uninitialized"), + "the refusal must name the observed uninitialized state: {stderr}" + ); + assert!( + stderr.contains("tombstone"), + "the refusal must name the observed tombstone: {stderr}" + ); + assert_no_rendezvous( + project.path(), + &paths, + "after the refused daemon start exited", + ); + assert_recoverable_uninitialized(&paths, "after the refused daemon start"); +} + +#[test] +fn uninit_shutdown_control_drains_without_pid_kill() { + let project = TestProject::indexed("shutdown-drain"); + let paths = project.paths(); + + let mut daemon = ChildGuard::new( + daemon_command(project.path()) + .spawn() + .expect("spawn daemon"), + ); + let daemon_pid = wait_for_owner_record(&paths); + assert!( + is_process_alive(daemon_pid), + "the published owner pid must be live before uninit runs" + ); + + // `uninit --force` holds the ONE exclusive lease for its whole lifecycle, so + // a shutdown control frame that took a data-request shared lease could never + // complete: this command succeeding IS the bypass evidence. + let output = uninit_force(project.path()); + assert!( + output.status.success(), + "uninit --force must drain the live daemon and complete; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let status = daemon.wait_bounded("the drained daemon"); + assert_eq!( + status.code(), + Some(0), + "the daemon must exit gracefully after ACKing its drain" + ); + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt as _; + assert_eq!( + status.signal(), + None, + "no PID may be signalled: the daemon exits on its own control frame" + ); + } + + assert!( + fs::symlink_metadata(paths.daemon_pid()).is_err(), + "the daemon removes its own pid record before ACKing" + ); + assert!( + fs::symlink_metadata(paths.daemon_socket()).is_err(), + "the daemon removes its own socket before ACKing" + ); + assert!( + !paths.current_db().is_file(), + "uninit removes the v2 database after the ACK, under the same lease" + ); + assert_recoverable_uninitialized(&paths, "after a drained uninit"); +} + +#[test] +fn unresponsive_daemon_leaves_recoverable_uninitialized_without_kill() { + let project = TestProject::indexed("unresponsive"); + let paths = project.paths(); + + // A live owner pid with an unreachable rendezvous socket: the daemon can + // never ACK, so uninit must fail closed. + let mut placeholder = spawn_live_placeholder(); + let owner_pid = placeholder.id(); + write_owner_record(&paths, owner_pid); + assert!(fs::symlink_metadata(paths.daemon_socket()).is_err()); + + let output = uninit_force(project.path()); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + assert!( + !output.status.success(), + "an unresponsive daemon must make uninit fail closed; stderr: {stderr}" + ); + assert!( + stderr.contains("daemon"), + "the incomplete-uninit error must name the undrained daemon: {stderr}" + ); + + assert!( + is_process_alive(owner_pid), + "no PID is killed: the recorded owner process must still be running" + ); + assert_recoverable_uninitialized(&paths, "after a fail-closed uninit"); + assert!( + paths.current_db().is_file(), + "the fail-closed pass must NOT remove runtime children" + ); + assert!( + paths.daemon_pid().is_file(), + "the undrained owner record is preserved for the continuation" + ); + + // Continuation: once the owner is gone, a repeated `uninit --force` resumes + // the same cleanup idempotently under a fresh lease and reclassification. + let _ = placeholder.kill(); + let _ = placeholder.wait(); + let deadline = Instant::now() + WAIT; + while is_process_alive(owner_pid) && Instant::now() < deadline { + thread::sleep(Duration::from_millis(20)); + } + + let retry = uninit_force(project.path()); + assert!( + retry.status.success(), + "a repeated uninit --force must resume cleanup; stderr: {}", + String::from_utf8_lossy(&retry.stderr) + ); + assert!(!paths.current_db().is_file()); + assert!(fs::symlink_metadata(paths.daemon_pid()).is_err()); + assert_recoverable_uninitialized(&paths, "after the uninit continuation"); +} diff --git a/crates/codegraph-cli/tests/batch_m_failed_open_recovery.rs b/crates/codegraph-cli/tests/batch_m_failed_open_recovery.rs new file mode 100644 index 0000000..b5e42ce --- /dev/null +++ b/crates/codegraph-cli/tests/batch_m_failed_open_recovery.rs @@ -0,0 +1,808 @@ +//! Batch M acceptance item 17 (frozen plan line 779): +//! `failed_engine_open_is_not_cached_and_next_request_recovers`. +//! +//! ONE long-lived SHIPPED MCP process (`codegraph serve --mcp --path `) +//! receives a request whose engine/`Store` open FAILS for a real v2 +//! state/artifact reason. The process must stay healthy, must retain NOTHING +//! from that failed open — no cached error, no partial engine, no `Store`, no +//! SQLite handle, no lease, no stale project result — and the NEXT request in the +//! SAME process, after the namespace is repaired without a restart, must succeed +//! and serve ONLY the repaired graph. +//! +//! # The staged failure is a real v2 inconsistency, not a stub +//! +//! The served project is really indexed by the shipped `codegraph init`, then +//! BOTH fixed state slots are removed and nothing else. The namespace therefore +//! classifies `Missing` while its main database (and its permanent lock) are +//! still present, which the read gate refuses with +//! `state is missing but a database artifact already exists at ` +//! (`reject_missing_database_artifacts`). Nothing here weakens or repairs a gate +//! to produce the failure, and nothing teaches a production read to accept an +//! invalid state. +//! +//! Crucially the failure happens AFTER project resolution: the current-namespace +//! DB file still exists, so `roots::probe_root` classifies the project `Indexed` +//! and `resolve_project_arg` resolves it. The test asserts request 1 carries the +//! ENGINE-OPEN diagnostic (`Failed to open project at …`), never the +//! resolution-miss diagnostic, so a resolution failure can never be mistaken for +//! the engine-open failure this item is about. +//! +//! # Determinism: protocol frames and fail-closed gates, never a sleep +//! +//! 1. **READY** — the parent blocks until the child frames the JSON-RPC response +//! for request 1. rmcp writes that frame only after the request's OWNED result +//! was fully materialized, so the frame proves request 1 finished end-to-end. +//! 2. **NO RETAINED LEASE** — the parent then acquires the namespace's EXCLUSIVE +//! lease. A shared reader lease retained by the FAILED open makes this +//! acquisition fail, so the exclusive lease is the fail-closed observation +//! that the failed open let go of everything. +//! 3. **NO RETAINED SQLITE HANDLE** — still holding that exclusive lease, the +//! parent renames the separately built repaired database over the live main +//! database file. On native Windows that rename fails with a sharing violation +//! while any process holds an open handle on the destination, so it is the +//! Windows handle proof for the failed-open path. +//! 4. **REPAIR COMPLETION** — the repair is finished by the protocol-aware +//! `Missing -> Building -> Current` fixture finalizer, and the parent asserts +//! the namespace classifies `Current` (plus lock/state-slot/sidecar +//! invariants) BEFORE sending request 2. Completion is observed from published +//! state, never from elapsed time. +//! 5. **CONTINUE** — request 2's frame is written only after that. The child +//! cannot have started it earlier because it had not been written. +//! +//! Channel/lease deadlines (`WAIT`) are DEADLOCK GUARDS only. No assertion in +//! this file is satisfied by waiting, by mtime, or by process exit. +//! +//! # Startup catch-up can neither repair the staged state nor fake the recovery +//! +//! `serve --mcp --path` runs a one-shot startup catch-up sync on a detached +//! thread and `--no-watch` does not disable it, so it must be excluded as an +//! explanation for BOTH halves of the acceptance: +//! +//! - It cannot repair the staged `Missing`-with-database namespace: +//! [`catch_up_sync_refuses_missing_state_with_database_without_mutating_bytes`] +//! drives the SAME `codegraph_watch::sync_project_once` entry point the +//! catch-up thread calls against an identically staged namespace and proves it +//! fails with the same state/artifact diagnostic while leaving the database +//! bytes byte-identical and the state slots absent. That is a gate, not a race. +//! - It cannot invent the repaired rows: the two distinguishing sources live +//! under a directory the served project's own root `.gitignore` excludes, which +//! the test PROVES with the shipped scanner. No sync can create those rows +//! (`scan_project` never yields the paths) and none can delete them (the cold +//! removal pass only considers a tracked path that is ALSO absent from disk, +//! and both files stay present). The only scannable file is byte-identical in +//! both graphs, so a catch-up that somehow ran after the repair is a proven +//! no-op rather than a source of the assertions. +//! +//! # A legacy namespace is not a recovery source +//! +//! The project also carries a legacy `.codegraph/codegraph.db` holding a TRAP +//! symbol that exists in no other graph. Request 2 must not surface it, so a +//! silent legacy fallback cannot masquerade as recovery. +//! +//! # Scope +//! +//! Query-side acceptance over the shipped binary plus one in-process test of the +//! same engine-open seam. M15 already made every MCP engine request-scoped, so +//! this item is the behavioral proof of that ownership; no production byte +//! changes here. + +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use codegraph_core::IndexPaths; +use codegraph_extract::ExtractOptions; +use codegraph_store::{ExtractionStatus, IndexLease, Store}; +use serde_json::{Value, json}; + +/// Finite deadlock guard for every blocking wait. Never ordering evidence. +const WAIT: Duration = Duration::from_secs(60); + +/// The scannable file every project here shares, byte-identical, so the startup +/// catch-up sync is a proven no-op instead of a degenerate empty scan. +const NEUTRAL_FILE: &str = "src/mkqneutral.ts"; +/// The gitignored directory holding the supplied-graph-only sources. Nothing +/// under it is ever scanned in the SERVED project. +const SUPPLIED_DIR: &str = "supplied"; +/// The symbol that exists ONLY in the pre-repair (original) graph. +const ORIGINAL_SYMBOL: &str = "zrqmfoss"; +/// The repo-relative file that exists ONLY in the pre-repair graph. +const ORIGINAL_FILE: &str = "supplied/zrqmfoss.ts"; +/// The symbol that exists ONLY in the repaired graph. +const REPAIRED_SYMBOL: &str = "vtwkhelm"; +/// The repo-relative file that exists ONLY in the repaired graph. +const REPAIRED_FILE: &str = "supplied/vtwkhelm.ts"; +/// The symbol that exists ONLY in the LEGACY `.codegraph` database. +const LEGACY_TRAP_SYMBOL: &str = "jbxdlurn"; +/// The repo-relative file that exists ONLY in the legacy database. +const LEGACY_TRAP_FILE: &str = "supplied/jbxdlurn.ts"; + +/// The exact fail-closed diagnostic the v2 read gate produces for a `Missing` +/// state whose database artifact still exists. +const MISSING_WITH_DB: &str = "state is missing but a database artifact already exists"; + +/// The compatibility close seam this recovery flow must never touch, spelled in +/// two halves so this file's own bytes are not a match for it. +const CLOSE_SEAM: &str = concat!("close_cached", "_handles"); + +fn bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_codegraph")) +} + +/// A temp directory removed on drop. +struct TestDir(PathBuf); + +impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-m17-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos() + )); + fs::create_dir_all(&path).expect("create temp dir"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +/// Write one source file, creating parents. +fn write_source(root: &Path, rel: &str, contents: &str) { + let path = root.join(rel); + fs::create_dir_all(path.parent().expect("source parent")).expect("create source dir"); + fs::write(&path, contents).expect("write source file"); +} + +fn source_for(symbol: &str) -> String { + format!("export function {symbol}(): number {{\n return 1;\n}}\n") +} + +/// The neutral scannable source, byte-identical in every project here. +fn neutral_source() -> String { + source_for("mkqneutral") +} + +/// A command with the ambient index-location override cleared, the daemon opted +/// out (ONE direct long-lived process, never a proxy) and the live watcher off. +fn base_command() -> Command { + let mut command = Command::new(bin()); + command + .env_remove("CODEGRAPH_DIR") + .env("CODEGRAPH_NO_DAEMON", "1") + .env("CODEGRAPH_NO_WATCH", "1"); + command +} + +/// Run the SHIPPED `codegraph init` over `project`, producing a real v2 +/// namespace (permanent lock + published `Current` state slots + stamped, +/// checkpointed database). Never a hand-authored fixture. +fn shipped_init(project: &Path) { + let output = base_command() + .args(["init"]) + .arg(project) + .output() + .expect("run codegraph init"); + assert!( + output.status.success(), + "codegraph init failed for {}: stdout={} stderr={}", + project.display(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn index_paths(project: &Path) -> IndexPaths { + IndexPaths::resolve(project, None).expect("resolve v2 IndexPaths") +} + +/// Build a project containing the neutral source plus `supplied_rel`, index it +/// with the shipped `init`, and return its current database path. The staging +/// project carries NO `.gitignore`, so its graph really does contain +/// `supplied_rel` and cannot contain any other supplied source. +fn build_graph_database( + staging: &TestDir, + label: &str, + supplied_rel: &str, + symbol: &str, +) -> PathBuf { + let project = staging.path().join(label); + fs::create_dir_all(&project).expect("create staging project"); + write_source(&project, NEUTRAL_FILE, &neutral_source()); + write_source(&project, supplied_rel, &source_for(symbol)); + shipped_init(&project); + let db = index_paths(&project).current_db(); + assert!( + db.is_file(), + "staging build must produce a database at {}", + db.display() + ); + db +} + +/// Materialize a really indexed served project, freeze the supplied sources out +/// of every future scan, plant the legacy trap graph, and then break the v2 +/// namespace into the REAL `Missing`-state-with-existing-database inconsistency +/// by removing ONLY the two fixed state slots. +fn stage_missing_state_with_database(project: &Path, staging: &TestDir, label: &str) -> IndexPaths { + fs::create_dir_all(project).expect("create served project"); + write_source(project, NEUTRAL_FILE, &neutral_source()); + write_source(project, ORIGINAL_FILE, &source_for(ORIGINAL_SYMBOL)); + shipped_init(project); + + // Both distinguishing sources exist on disk from here on, so no sync can + // classify either as removed. + write_source(project, REPAIRED_FILE, &source_for(REPAIRED_SYMBOL)); + write_source(project, LEGACY_TRAP_FILE, &source_for(LEGACY_TRAP_SYMBOL)); + freeze_supplied_sources_out_of_scans(project); + plant_legacy_trap_graph(project, staging, label); + + let paths = index_paths(project); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Current, + "the shipped init must leave a readable Current namespace before it is broken" + ); + + for slot in paths.state_slots() { + if slot.exists() { + fs::remove_file(&slot).expect("remove one fixed state slot"); + } + } + + // The namespace is now genuinely inconsistent: no state, but the database + // and the permanent lock are still there. + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Missing, + "removing both fixed state slots must classify the namespace Missing" + ); + assert!( + paths.current_db().is_file(), + "the staged failure requires the main database to still exist at {}", + paths.current_db().display() + ); + assert!( + paths.permanent_lock().is_file(), + "the staged failure keeps the permanent lock at {}", + paths.permanent_lock().display() + ); + assert!( + !paths.tombstone().exists(), + "the staged failure is a missing-state inconsistency, not an uninit tombstone" + ); + + // The read gate must reject it for exactly the staged reason, checked before + // any server is involved so the acceptance cannot mistake another failure + // for this one. + let rejection = Store::open_for_read(&paths, Instant::now() + WAIT, || false) + .expect_err("a Missing state with an existing database must fail closed") + .to_string(); + assert!( + rejection.contains(MISSING_WITH_DB), + "the staged condition must be the missing-state-with-database refusal: {rejection}" + ); + + paths +} + +/// Freeze the supplied sources out of every future scan of `project`, then PROVE +/// it with the shipped scanner — the same `scan_project` a sync feeds from. +fn freeze_supplied_sources_out_of_scans(project: &Path) { + fs::write(project.join(".gitignore"), format!("{SUPPLIED_DIR}/\n")) + .expect("write root .gitignore"); + + let scanned = codegraph_extract::engine::scan_project(project, &ExtractOptions::default()) + .expect("scan the served project with the shipped scanner"); + assert!( + scanned.contains(&NEUTRAL_FILE.to_string()), + "the neutral source must stay scannable so startup catch-up is a real no-op: {scanned:?}" + ); + for rel in [ORIGINAL_FILE, REPAIRED_FILE, LEGACY_TRAP_FILE] { + assert!( + !scanned.contains(&rel.to_string()), + "no sync may be able to index {rel}, or the recovery request could pass by reindexing \ + instead of by reading the repaired database: {scanned:?}" + ); + assert!( + project.join(rel).is_file(), + "{rel} must remain on disk so no sync can classify it as removed" + ); + } +} + +/// Plant a LEGACY `.codegraph/codegraph.db` holding a graph that exists nowhere +/// else, so a silent legacy fallback cannot masquerade as recovery. +fn plant_legacy_trap_graph(project: &Path, staging: &TestDir, label: &str) { + let trap_db = build_graph_database( + staging, + &format!("{label}-legacy-trap"), + LEGACY_TRAP_FILE, + LEGACY_TRAP_SYMBOL, + ); + let legacy_root = project.join(".codegraph"); + fs::create_dir_all(&legacy_root).expect("create the legacy root"); + fs::copy(&trap_db, legacy_root.join("codegraph.db")).expect("plant the legacy trap database"); +} + +/// Repair the staged namespace WITHOUT restarting the server. +/// +/// The EXCLUSIVE lease is acquired first: a shared reader lease retained by the +/// FAILED open makes this fail, which is the fail-closed proof that the failed +/// open released everything. `fs::rename` under that lease then installs the +/// repaired database — on native Windows it fails outright if any process still +/// holds an open handle on the destination. The protocol-aware fixture finalizer +/// republishes `Missing -> Building -> Current` with the exact extraction stamp, +/// so no production read gate is weakened to make the recovery work. +fn repair_without_restart(paths: &IndexPaths, repaired_db: &Path) { + let lease = IndexLease::acquire_exclusive_existing(paths, Instant::now() + WAIT, || false) + .expect( + "the failed engine open must have retained no shared reader lease, so the exclusive \ + lease is available for the repair", + ); + + let target = paths.current_db(); + fs::rename(repaired_db, &target).unwrap_or_else(|error| { + panic!( + "installing the repaired database at {} must succeed while the long-lived MCP process \ + is running (a retained SQLite handle from the failed open makes this fail on native \ + Windows): {error}", + target.display() + ) + }); + drop(lease); + + codegraph_store::test_support::finalize_current_test_fixture(paths) + .expect("republish Missing -> Building -> Current over the repaired database"); + + // Repair COMPLETION is observed from published state and artifact shape, not + // from elapsed time. + assert_eq!( + Store::extraction_status(paths), + ExtractionStatus::Current, + "the repair must publish a readable Current namespace" + ); + let [slot_zero, slot_one] = paths.state_slots(); + assert!( + slot_zero.is_file() || slot_one.is_file(), + "the repair must leave a published state slot behind" + ); + assert!( + paths.permanent_lock().is_file(), + "the repair must preserve the permanent lock at {}", + paths.permanent_lock().display() + ); + for suffix in ["-wal", "-shm"] { + let mut sidecar = target.as_os_str().to_os_string(); + sidecar.push(suffix); + let sidecar = PathBuf::from(sidecar); + assert!( + !sidecar.exists(), + "the repaired namespace must stay sidecar-free: {} exists", + sidecar.display() + ); + } +} + +/// One long-lived shipped MCP stdio server plus the reader thread that turns its +/// stdout into framed JSON-RPC responses. +struct LongLivedMcp { + child: Child, + stdin: Option, + lines: mpsc::Receiver, +} + +impl LongLivedMcp { + /// Spawn `serve --mcp --path --no-watch` with the daemon disabled, + /// so ONE direct, long-lived process owns the whole session. + fn spawn(project: &Path) -> Self { + let mut child = base_command() + .args(["serve", "--mcp", "--no-watch", "--path"]) + .arg(project) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn long-lived serve --mcp"); + let stdout = child.stdout.take().expect("child stdout"); + let (tx, lines) = mpsc::channel(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + let Ok(line) = line else { return }; + if tx.send(line).is_err() { + return; + } + } + }); + let stdin = child.stdin.take().expect("child stdin"); + Self { + child, + stdin: Some(stdin), + lines, + } + } + + fn send(&mut self, frame: &Value) { + let stdin = self.stdin.as_mut().expect("child stdin still owned"); + writeln!(stdin, "{frame}").expect("write MCP frame"); + stdin.flush().expect("flush MCP frame"); + } + + /// Block until the child frames the response for `id`. The ARRIVAL of this + /// frame is the child's READY acknowledgement: rmcp writes it only after the + /// request's owned result was fully produced. + fn await_response(&self, id: i64) -> Value { + let deadline = Instant::now() + WAIT; + loop { + let remaining = deadline + .checked_duration_since(Instant::now()) + .unwrap_or_default(); + let line = self + .lines + .recv_timeout(remaining) + .unwrap_or_else(|error| panic!("no response for id {id} before deadline: {error}")); + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if let Ok(value) = serde_json::from_str::(trimmed) + && value.get("id").and_then(Value::as_i64) == Some(id) + { + return value; + } + } + } + + /// Close stdin (EOF) and wait for the process to exit within the guard. + fn shutdown(mut self) { + drop(self.stdin.take()); + let deadline = Instant::now() + WAIT; + loop { + match self.child.try_wait().expect("poll child status") { + Some(status) => { + assert!( + status.success(), + "the long-lived MCP process must exit cleanly on stdin EOF: {status:?}" + ); + return; + } + None => { + assert!( + Instant::now() < deadline, + "the long-lived MCP process did not exit before its finite deadline" + ); + std::thread::park_timeout(Duration::from_millis(5)); + } + } + } + } +} + +impl Drop for LongLivedMcp { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn initialize_frames() -> [Value; 2] { + [ + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "m17-acceptance", "version": "0" } + } + }), + json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), + ] +} + +fn search_frame(id: i64, project: &Path, query: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { + "name": "codegraph_search", + "arguments": { + "query": query, + "projectPath": project.to_str().expect("utf8 project path") + } + } + }) +} + +/// The rendered text of a SUCCESSFUL tool result, rejecting a protocol error or +/// an `isError` result so a failed request can never be mistaken for a graph +/// observation. +fn tool_text(response: &Value, context: &str) -> String { + assert!( + response.get("error").is_none(), + "{context} returned a JSON-RPC error: {response}" + ); + assert_ne!( + response["result"]["isError"], + json!(true), + "{context} returned an error result: {response}" + ); + response["result"]["content"][0]["text"] + .as_str() + .unwrap_or_default() + .to_string() +} + +/// The rendered text of an `isError` tool result. A JSON-RPC protocol error or a +/// SUCCESS result is rejected: the failed open must surface as a tool error the +/// live session survives, not as a transport failure and not as a pass. +fn tool_error_text(response: &Value, context: &str) -> String { + assert!( + response.get("error").is_none(), + "{context} must fail as a tool error, not a JSON-RPC error: {response}" + ); + assert_eq!( + response["result"]["isError"], + json!(true), + "{context} must return an isError result: {response}" + ); + response["result"]["content"][0]["text"] + .as_str() + .unwrap_or_default() + .to_string() +} + +#[test] +fn failed_engine_open_is_not_cached_and_next_request_recovers() { + // GIVEN a really indexed v2 project broken into the REAL + // `Missing`-state-with-existing-database inconsistency, its distinguishing + // sources frozen out of every scan, a legacy trap graph planted beside it, + // and a separately built repaired database. + let home = TestDir::new("served"); + let project = home.path().join("project"); + let staging = TestDir::new("staging"); + let paths = stage_missing_state_with_database(&project, &staging, "served"); + let repaired_db = build_graph_database(&staging, "repaired", REPAIRED_FILE, REPAIRED_SYMBOL); + + // ONE long-lived shipped MCP process serves the whole session. + let mut server = LongLivedMcp::spawn(&project); + let [initialize, initialized] = initialize_frames(); + server.send(&initialize); + let init_response = server.await_response(1); + assert_eq!( + init_response["result"]["serverInfo"]["name"], + json!("codegraph"), + "the long-lived process must be the shipped codegraph MCP server: {init_response}" + ); + server.send(&initialized); + + // WHEN request 1 resolves the project but FAILS opening the engine/Store on + // the staged v2 inconsistency (its framed response IS the READY barrier) ... + server.send(&search_frame(2, &project, REPAIRED_SYMBOL)); + let failure = tool_error_text(&server.await_response(2), "request 1"); + assert!( + failure.contains("Failed to open project at"), + "request 1 must fail in the ENGINE OPEN, after project resolution: {failure}" + ); + assert!( + failure.contains(MISSING_WITH_DB), + "request 1 must fail for the staged v2 state/artifact reason: {failure}" + ); + assert!( + !failure.contains("No indexed project"), + "request 1 must not be a project-resolution miss: {failure}" + ); + + // ... the namespace is repaired IN PLACE, with no restart: the exclusive + // lease proves no reader lease survived the failed open, the rename proves no + // SQLite handle did, and the fixture finalizer republishes a real Current + // state. + repair_without_restart(&paths, &repaired_db); + + // THEN the very next request in the SAME process succeeds and serves ONLY + // the repaired graph, so the failed open was cached neither as an error nor + // as a stale project result. + server.send(&search_frame(3, &project, REPAIRED_SYMBOL)); + let recovered = tool_text(&server.await_response(3), "request 2"); + assert!( + recovered.contains("## Search Results ("), + "request 2 must render real search results, not an echo: {recovered}" + ); + assert!( + recovered.contains(REPAIRED_FILE), + "request 2 must serve the repaired graph: {recovered}" + ); + assert!( + !recovered.contains(ORIGINAL_FILE), + "request 2 must not leak the pre-repair graph: {recovered}" + ); + assert!( + !recovered.contains(LEGACY_TRAP_FILE), + "request 2 must not read the legacy namespace: {recovered}" + ); + + // The pre-repair symbol is GONE from the same long-lived session, observed + // through a lookup that resolves nothing rather than a response that merely + // omits it. + server.send(&search_frame(4, &project, ORIGINAL_SYMBOL)); + let original = tool_text(&server.await_response(4), "request 3"); + assert!( + original.contains("No results found"), + "the pre-repair symbol must be absent from the repaired graph: {original}" + ); + + // And the legacy trap graph is unreachable through the recovered session. + server.send(&search_frame(5, &project, LEGACY_TRAP_SYMBOL)); + let trap = tool_text(&server.await_response(5), "request 4"); + assert!( + trap.contains("No results found"), + "the legacy trap symbol must never be reachable: {trap}" + ); + + server.shutdown(); +} + +/// The in-process half of the same seam: `CodeGraphEngine::open` is the exact +/// call `execute_owned` makes per request, so a failed open must leave NOTHING +/// behind in THIS process either — no memoized error, no lease, no handle — and a +/// later open in the same process must observe the repaired graph. +#[test] +fn in_process_engine_open_failure_leaves_no_cached_state_and_reopens_repaired() { + let home = TestDir::new("inproc"); + let project = home.path().join("project"); + let staging = TestDir::new("inproc-staging"); + let paths = stage_missing_state_with_database(&project, &staging, "inproc"); + let repaired_db = + build_graph_database(&staging, "inproc-repaired", REPAIRED_FILE, REPAIRED_SYMBOL); + + // `CodeGraphEngine` is deliberately not `Debug` (it owns a SQLite handle), so + // the failure is taken by matching instead of `expect_err`. + let first = match codegraph_mcp::CodeGraphEngine::open(&project) { + Ok(_) => panic!("the staged namespace must fail the engine open"), + Err(error) => error.to_string(), + }; + assert!( + first.contains(MISSING_WITH_DB), + "the in-process failure must be the staged state/artifact refusal: {first}" + ); + + // Same fail-closed release proof, then the same legitimate repair. + repair_without_restart(&paths, &repaired_db); + + let engine = codegraph_mcp::CodeGraphEngine::open(&project) + .expect("the SAME process must reopen the repaired namespace"); + let result = engine.execute("codegraph_search", &json!({ "query": REPAIRED_SYMBOL })); + assert_ne!( + result.is_error, + Some(true), + "the reopened engine must serve the repaired graph" + ); + let text = result + .content + .iter() + .map(|c| c.text.as_str()) + .collect::>() + .join("\n"); + assert!( + text.contains(REPAIRED_FILE), + "the reopened engine must observe the repaired file: {text}" + ); + assert!( + !text.contains(ORIGINAL_FILE) && !text.contains(LEGACY_TRAP_FILE), + "the reopened engine must not surface the pre-repair or legacy graph: {text}" + ); +} + +/// The startup catch-up sync cannot be the thing that repairs the staged +/// namespace, and this is a GATE rather than a race: the same +/// `sync_project_once` entry point the catch-up thread calls refuses the staged +/// `Missing`-with-database namespace with the same diagnostic and leaves the +/// database bytes and the (absent) state slots exactly as they were. +#[test] +fn catch_up_sync_refuses_missing_state_with_database_without_mutating_bytes() { + let home = TestDir::new("catchup"); + let project = home.path().join("project"); + let staging = TestDir::new("catchup-staging"); + let paths = stage_missing_state_with_database(&project, &staging, "catchup"); + + let before = fs::read(paths.current_db()).expect("read the staged database bytes"); + + let error = codegraph_watch::sync_project_once(&project) + .expect_err("a catch-up sync must refuse the staged Missing-with-database namespace") + .to_string(); + assert!( + error.contains(MISSING_WITH_DB), + "the catch-up refusal must be the staged state/artifact gate: {error}" + ); + + let after = fs::read(paths.current_db()).expect("re-read the staged database bytes"); + assert_eq!( + before, after, + "the refused catch-up must leave the database bytes byte-identical" + ); + for slot in paths.state_slots() { + assert!( + !slot.exists(), + "the refused catch-up must not publish a state slot at {}", + slot.display() + ); + } + assert!( + !paths.tombstone().exists(), + "the refused catch-up must not create a tombstone" + ); + assert!( + paths.permanent_lock().is_file(), + "the refused catch-up must preserve the permanent lock" + ); +} + +/// The recovery flow must be independent of the compatibility close seam, so no +/// CODE in this file may name it — not as a call, an import, an alias, or a +/// wrapper. Checked structurally against this file's own bytes: comment lines +/// (which document the seam by name) are excluded, and the needle itself is +/// assembled from two halves so neither this oracle nor its failure message is a +/// match for what it forbids. +#[test] +fn recovery_flow_never_calls_the_close_helper() { + let offenders = code_lines_naming(include_str!("batch_m_failed_open_recovery.rs"), CLOSE_SEAM); + assert!( + offenders.is_empty(), + "the M17 recovery flow must not depend on the {CLOSE_SEAM} compatibility seam, but these \ + code lines name it: {offenders:?}" + ); +} + +/// Non-comment lines of `source` that contain `needle`, as `": "`. +/// A line whose first non-whitespace characters are `//` is documentation (this +/// file's own module docs and comments discuss the seam by name), so only real +/// code is inspected. +fn code_lines_naming(source: &str, needle: &str) -> Vec { + source + .lines() + .enumerate() + .filter(|(_, line)| !line.trim_start().starts_with("//")) + .filter(|(_, line)| line.contains(needle)) + .map(|(index, line)| format!("{}: {}", index + 1, line.trim())) + .collect() +} + +#[cfg(test)] +mod oracle_tests { + use super::code_lines_naming; + + /// The forbidden-name oracle must SEE a real invocation and IGNORE a comment + /// that merely mentions the name, so it can neither pass vacuously nor fail + /// on this file's own documentation. + #[test] + fn code_lines_naming_sees_code_and_ignores_comments() { + let needle = concat!("forbidden", "_seam"); + let source = concat!( + "//! docs mention forbidden_seam by name\n", + " // so does an indented comment: forbidden_seam\n", + "let clean = other_call();\n", + " store.forbidden_seam();\n" + ); + let found = code_lines_naming(source, needle); + assert_eq!( + found, + vec!["4: store.forbidden_seam();".to_string()], + "only the real invocation line is an offender" + ); + } +} diff --git a/crates/codegraph-cli/tests/batch_m_finalizer.rs b/crates/codegraph-cli/tests/batch_m_finalizer.rs new file mode 100644 index 0000000..c2c8154 --- /dev/null +++ b/crates/codegraph-cli/tests/batch_m_finalizer.rs @@ -0,0 +1,311 @@ +//! Batch M — `full_sync_finalizer_publishes_current_last` public-surface Red. +//! +//! Frozen plan `upstream-v1.5-portable-fixes.md` lines 548-556 and test 8 +//! (line 746) require that a destructive v2 rebuild becomes a READABLE `Current` +//! namespace only after every SQLite finalization step succeeded under the one +//! retained exclusive lease: pragma restore, final checkpoint + compaction, +//! extraction-version stamp, stamp checkpoint, and the final connection close. +//! Only then may the rebuild atomically publish `phase=current`. +//! +//! This file is the black-box half of that slice: it drives the shipped +//! `codegraph init` / `codegraph index --force` commands and then asks the +//! state-gated store layer (the accepted `Store::extraction_status` / +//! `Store::open_for_read` APIs from `f5c57f5`) whether the produced namespace is +//! a readable `Current`. Before the finalizer lands, `init` builds the DB but +//! publishes NO state slot at all, so classification is `Missing` and +//! `open_for_read` refuses — a behavioral failure, not a compile or setup one +//! (both assertions below are reached only after `init` succeeded and produced a +//! non-empty DB). +//! +//! The deterministic fault matrix over every finalization boundary lives in the +//! owning crate (`codegraph-store`, `src/rebuild.rs`), because the injection +//! seams are private by design. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, Instant}; + +use codegraph_core::IndexPaths; +use codegraph_store::{ExtractionStatus, IndexLease, StatePhase, Store, publish_index_state}; + +fn bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_codegraph")) +} + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("codegraph-cli is under crates/") + .to_path_buf() +} + +fn mini_fixture() -> PathBuf { + workspace_root().join("crates/codegraph-bench/fixtures/mini") +} + +struct TestDir { + path: PathBuf, +} + +impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-batchm-finalizer-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&path).unwrap(); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +fn copy_tree(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let from = entry.path(); + let to = dst.join(entry.file_name()); + if from.is_dir() { + copy_tree(&from, &to); + } else { + std::fs::copy(&from, &to).unwrap(); + } + } +} + +struct Run { + stdout: String, + stderr: String, + ok: bool, +} + +fn run_in(registry_dir: &Path, args: &[&str]) -> Run { + let output = Command::new(bin()) + .args(args) + .env("CODEGRAPH_HTTP_REGISTRY_DIR", registry_dir) + .env("CODEGRAPH_NO_DAEMON", "1") + .output() + .expect("run codegraph binary"); + Run { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + ok: output.status.success(), + } +} + +fn deadline() -> Instant { + Instant::now() + .checked_add(Duration::from_secs(10)) + .expect("finalizer test deadline") +} + +/// Assert the namespace produced by a finished rebuild is a readable `Current`: +/// the state slots classify `Current`, the tombstone is absent, the explicit +/// checkpoint/compaction/close pipeline satisfies the sidecar-free Current +/// artifact contract, and a state-gated read open serves rows. +fn assert_readable_current(paths: &IndexPaths, context: &str) { + let status = Store::extraction_status(paths); + assert_eq!( + status, + ExtractionStatus::Current, + "{context}: the finished rebuild must publish phase=current as its LAST step \ + (plan lines 548-556); observed {status:?}" + ); + assert!( + !paths.tombstone().exists(), + "{context}: a successful explicit init must leave no tombstone at {}", + paths.tombstone().display() + ); + let db = paths.current_db(); + for suffix in ["-wal", "-shm"] { + let sidecar = PathBuf::from(format!("{}{suffix}", db.display())); + assert!( + !sidecar.exists(), + "{context}: final checkpoint/compaction and connection close must satisfy \ + the sidecar-free Current artifact contract before publication; found {}", + sidecar.display() + ); + } + let store = Store::open_for_read(paths, deadline(), || false).unwrap_or_else(|error| { + panic!("{context}: a finalized namespace must be readable through the state gate: {error}") + }); + let counts = store + .counts() + .unwrap_or_else(|error| panic!("{context}: counts on a finalized namespace: {error}")); + assert!( + counts.node_count > 0, + "{context}: the finalized readable namespace must serve the built graph, got {} nodes", + counts.node_count + ); +} + +/// Stage the durable state left by an interrupted destructive rebuild. The +/// permanent lock and `phase=building` slot are authentic; the final SQLite DB +/// is independently present or absent to cover both user-reachable crash +/// windows (before or after the fresh writer opens). +fn stage_interrupted_building(paths: &IndexPaths, db_present: bool) { + let lease = IndexLease::create_exclusive(paths, deadline(), || false) + .expect("create the interrupted-rebuild namespace"); + publish_index_state(paths, &lease, StatePhase::Building).expect("publish phase=building"); + if db_present { + let store = Store::open(&paths.current_db()).expect("stage the partial rebuild DB"); + store + .set_project_metadata("partial-rebuild", "true") + .expect("write partial rebuild evidence"); + drop(store); + } + drop(lease); + assert_eq!( + Store::extraction_status(paths), + ExtractionStatus::Building { + built: codegraph_store::CURRENT_EXTRACTION_VERSION, + } + ); +} + +/// Plan test 8 (line 746) public boundary: only a completely successful rebuild +/// becomes readable `Current`, and that holds for the initial explicit `init` as +/// well as for an ordinary destructive `index --force` over the finished index. +#[test] +fn full_rebuild_publishes_readable_current_last_via_cli() { + let dir = TestDir::new("current-last"); + let project = dir.path().join("mini"); + copy_tree(&mini_fixture(), &project); + let p = project.to_str().unwrap(); + + let run = run_in(dir.path(), &["init", p]); + assert!( + run.ok, + "setup: `codegraph init` must succeed before the behavioral assertion \ + (stdout={}, stderr={})", + run.stdout, run.stderr + ); + let paths = IndexPaths::resolve(&project, None).expect("resolve default v2 paths"); + assert!( + std::fs::metadata(paths.current_db()) + .map(|m| m.len() > 0) + .unwrap_or(false), + "setup: `init` must produce a non-empty v2 DB at {}", + paths.current_db().display() + ); + + assert_readable_current(&paths, "explicit init"); + + let reindex = run_in(dir.path(), &["index", "--force", p]); + assert!( + reindex.ok, + "setup: `codegraph index --force` must succeed: {} {}", + reindex.stdout, reindex.stderr + ); + assert_readable_current(&paths, "index --force"); +} + +/// A raw partial DB is not proof of a healthy initialized index. Explicit init +/// is the recovery surface for either interrupted-Building crash window. +#[test] +fn explicit_init_recovers_interrupted_building_with_db_present_or_absent() { + for db_present in [false, true] { + let dir = TestDir::new(if db_present { + "init-building-db-present" + } else { + "init-building-db-absent" + }); + let project = dir.path().join("mini"); + copy_tree(&mini_fixture(), &project); + let paths = IndexPaths::resolve(&project, None).expect("resolve default v2 paths"); + stage_interrupted_building(&paths, db_present); + + let run = run_in(dir.path(), &["init", project.to_str().unwrap()]); + assert!( + run.ok, + "explicit init must recover Building (db_present={db_present}): stdout={}, stderr={}", + run.stdout, run.stderr + ); + assert!( + !run.stdout.contains("Already initialized"), + "a partial DB must not be mistaken for healthy Current: {}", + run.stdout + ); + assert_readable_current(&paths, "explicit init retry after Building"); + } +} + +/// Ordinary index may retry an authenticated Building rebuild, whether the +/// previous process crashed before or after creating the final DB artifact. +#[test] +fn index_recovers_interrupted_building_with_db_present_or_absent() { + for db_present in [false, true] { + let dir = TestDir::new(if db_present { + "index-building-db-present" + } else { + "index-building-db-absent" + }); + let project = dir.path().join("mini"); + copy_tree(&mini_fixture(), &project); + let paths = IndexPaths::resolve(&project, None).expect("resolve default v2 paths"); + stage_interrupted_building(&paths, db_present); + + let run = run_in(dir.path(), &["index", "--force", project.to_str().unwrap()]); + assert!( + run.ok, + "index must retry Building (db_present={db_present}): stdout={}, stderr={}", + run.stdout, run.stderr + ); + assert_readable_current(&paths, "index retry after Building"); + } +} + +/// If the prior explicit init published Current but failed at tombstone removal, +/// the namespace remains deliberately unreadable. A repeated explicit init is +/// the recovery surface and must not misreport it as already initialized. +#[test] +fn explicit_init_recovers_current_with_tombstone_residue() { + let dir = TestDir::new("init-current-tombstone"); + let project = dir.path().join("mini"); + copy_tree(&mini_fixture(), &project); + let p = project.to_str().unwrap(); + + let initial = run_in(dir.path(), &["init", p]); + assert!( + initial.ok, + "initial init must succeed: {} {}", + initial.stdout, initial.stderr + ); + let paths = IndexPaths::resolve(&project, None).expect("resolve default v2 paths"); + std::fs::write(paths.tombstone(), b"remove failed") + .expect("stage Current+tombstone finalizer residue"); + assert!( + Store::open_for_read(&paths, deadline(), || false).is_err(), + "Current+tombstone must not be readable" + ); + + let retry = run_in(dir.path(), &["init", p]); + assert!( + retry.ok, + "explicit init must recover Current+tombstone: stdout={}, stderr={}", + retry.stdout, retry.stderr + ); + assert!( + !retry.stdout.contains("Already initialized"), + "Current+tombstone is not a healthy Current namespace: {}", + retry.stdout + ); + assert_readable_current( + &paths, + "explicit init retry after tombstone removal failure", + ); +} diff --git a/crates/codegraph-cli/tests/batch_m_legacy_extension_override.rs b/crates/codegraph-cli/tests/batch_m_legacy_extension_override.rs new file mode 100644 index 0000000..122e975 --- /dev/null +++ b/crates/codegraph-cli/tests/batch_m_legacy_extension_override.rs @@ -0,0 +1,1825 @@ +//! Batch M acceptance item 22 — legacy scan under a runtime extension override. +//! +//! This target executes the REAL published v0.40.4 CLI (tag `v0.40.4` / +//! commit `aba40799ecacb94515f7e1690914d2accc4c8973`), materialized and +//! digest-verified by `scripts/setup-legacy-fixture.sh` from the checked-in +//! manifest at `tests/fixtures/legacy-v0.40.4/manifest.toml`. Nothing here is +//! built from this worktree, and there is no skip path: an unavailable or +//! unverifiable fixture is a fixture-setup FAILURE. +//! +//! # The two distinct claims item 22 separates +//! +//! 1. **Source visibility is configuration-dependent.** The acceptance claim +//! "the legacy scan produces only the expected graph" holds for the DEFAULT +//! supported-extension configuration. An unmodified old scanner that is +//! EXPLICITLY configured (legacy `.codegraph/codegraph.json`) to index +//! `.json`/`.toml` will legitimately report v2 files such as +//! `.codegraph-v2/config.toml` and `.codegraph-v2/index-state.0.json` as +//! SOURCE. That is documented here as accepted behavior, not a defect: the +//! user asked for those extensions. +//! +//! 2. **Storage authority is configuration-independent.** For EVERY +//! configuration, including the override above, the old scanner writes only +//! into its own legacy namespace and leaves every byte of the v2 namespace +//! unchanged, and the v2 graph stays readable by the v2 reader afterwards. +//! +//! Reading a v2 artifact's TEXT as source is therefore never the same thing as +//! holding authority over v2 storage. Assertion names below keep the two apart. +//! +//! # Explicit non-claims +//! +//! * This target says nothing about deliberately pointing the OLD binary's own +//! `CODEGRAPH_DIR` at the v2 root. The v0.40.4 binary predates the v2 state +//! protocol, treats any configured root as a plain directory, and derives no +//! identity-suffixed sibling; storage separation in that scenario rests on +//! root naming/identity, which is a different acceptance item. Item 22 is +//! scoped to the EXTENSION override, so the legacy runs below always use the +//! legacy binary's own default `.codegraph` root. +//! * The nonmutation oracle is a sequential full-byte snapshot taken while no +//! other codegraph process is running. It never follows aliases and fails +//! closed on every I/O or unexpected-type error. Root, nested directories, +//! and regular files are opened and identity-corroborated before their data +//! becomes authoritative. Unix uses `(dev, ino)` and Windows uses raw +//! `GetFileInformationByHandleEx(FileIdInfo)` handle identity — an EXACT +//! identity on both platforms, never a size/timestamp approximation. +//! Only a typed `NotFound` root yields an empty snapshot; an existing root +//! that is an alias, a reparse point, or a non-directory fails closed. +//! The oracle's own gates are proven by deterministic checkpoint self-tests +//! below (static root alias, root replacement, nested-directory replacement, +//! regular-file replacement), and the frozen-binary expectation is read from +//! the checked-in manifest rather than duplicated here. + +use std::collections::BTreeMap; +use std::fs::{self, File, Metadata, OpenOptions}; +use std::io::Read as _; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// The release-asset target triple this host would execute natively, or `None` +/// when no v0.40.4 asset is pinned for it. Derived from RUNTIME host facts and +/// cross-checked against the manifest by +/// [`pinned_host_set_matches_the_fixture_manifest`]. +fn native_asset_target() -> Option<&'static str> { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => Some("x86_64-unknown-linux-musl"), + ("windows", "x86_64") => Some("x86_64-pc-windows-msvc"), + _ => None, + } +} + +fn new_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_codegraph")) +} + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("codegraph-cli is under crates/") + .to_path_buf() +} + +fn mini_fixture() -> PathBuf { + workspace_root().join("crates/codegraph-bench/fixtures/mini") +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LegacyExpectation { + executable_sha256: String, + version_stdout: String, +} + +/// `key = "value"` from a manifest line, or `None` when the line is not that key. +fn manifest_string_value(line: &str, key: &str) -> Option { + let rest = line.trim_start().strip_prefix(key)?; + let rest = rest.trim_start().strip_prefix('=')?.trim(); + let inner = rest.strip_prefix('"')?.strip_suffix('"')?; + Some(inner.to_string()) +} + +/// The single `[fixture]` value for `key`. Absent or duplicated is fatal: the +/// manifest is the only authority for what the legacy binary must be. +fn fixture_field(manifest: &str, key: &str) -> String { + let mut in_fixture = false; + let mut found: Vec = Vec::new(); + for line in manifest.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') { + in_fixture = trimmed == "[fixture]"; + continue; + } + if !in_fixture { + continue; + } + if let Some(value) = manifest_string_value(trimmed, key) { + found.push(value); + } + } + match found.len() { + 1 => found.remove(0), + 0 => panic!("fixture manifest [fixture] is missing key '{key}'"), + n => panic!("fixture manifest [fixture] declares key '{key}' {n} times"), + } +} + +/// One `[[asset]]` block's parsed fields. Blocks are materialized as RECORDS so +/// block cardinality survives the parse: a streaming "am I inside a matching +/// block" boolean cannot tell one matching block from two. +#[derive(Debug, Default)] +struct AssetBlock { + targets: Vec, + executable_sha256s: Vec, +} + +/// Every `[[asset]]` block in `manifest`, in file order. +fn asset_blocks(manifest: &str) -> Vec { + let mut blocks: Vec = Vec::new(); + let mut current: Option = None; + for line in manifest.lines() { + let trimmed = line.trim(); + if trimmed == "[[asset]]" { + if let Some(block) = current.take() { + blocks.push(block); + } + current = Some(AssetBlock::default()); + continue; + } + if trimmed.starts_with('[') { + if let Some(block) = current.take() { + blocks.push(block); + } + continue; + } + let Some(block) = current.as_mut() else { + continue; + }; + if let Some(value) = manifest_string_value(trimmed, "target") { + block.targets.push(value); + } else if let Some(value) = manifest_string_value(trimmed, "executable_sha256") { + block.executable_sha256s.push(value); + } + } + if let Some(block) = current.take() { + blocks.push(block); + } + blocks +} + +/// The `executable_sha256` of the single `[[asset]]` block whose `target` equals +/// `target`. +/// +/// Block uniqueness is structural and independent of digest presence: two blocks +/// naming the same target are fatal even when only one of them carries a digest. +fn asset_executable_sha256(manifest: &str, target: &str) -> String { + let matching = asset_blocks(manifest) + .into_iter() + .filter(|block| block.targets.iter().any(|value| value == target)) + .collect::>(); + let block = match matching.len() { + 1 => matching.into_iter().next().expect("one matching block"), + 0 => panic!("fixture manifest has no [[asset]] block with target='{target}'"), + n => panic!("fixture manifest declares {n} [[asset]] blocks with target='{target}'"), + }; + + let digest = match block.executable_sha256s.len() { + 1 => block + .executable_sha256s + .into_iter() + .next() + .expect("one digest"), + 0 => panic!("fixture manifest [[asset]] target='{target}' has no executable_sha256"), + n => { + panic!( + "fixture manifest [[asset]] target='{target}' declares {n} executable_sha256 values" + ) + } + }; + assert!( + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()), + "manifest executable_sha256 for {target} is not 64 lowercase hex chars: {digest:?}" + ); + digest +} + +/// The frozen expectation for THIS host, read from the checked-in manifest so +/// the manifest stays the single authority (no duplicated digests in Rust). +fn native_legacy_expectation() -> LegacyExpectation { + let target = native_asset_target() + .unwrap_or_else(|| panic!("no pinned legacy asset for this native host")); + let manifest = manifest_text(); + LegacyExpectation { + executable_sha256: asset_executable_sha256(&manifest, target), + version_stdout: fixture_field(&manifest, "expected_version_stdout"), + } +} + +fn verify_legacy_binary(path: &Path, expectation: &LegacyExpectation) -> Result<(), String> { + let metadata = fs::symlink_metadata(path).map_err(|error| { + format!( + "inspect configured legacy binary {}: {error}", + path.display() + ) + })?; + if !is_regular(&metadata) { + return Err(format!( + "configured legacy binary is not a non-alias regular file: {}", + path.display() + )); + } + let initial_identity = identity_for_validated_path(path, &metadata).map_err(|error| { + format!( + "identify configured legacy binary {}: {error}", + path.display() + ) + })?; + let mut file = open_no_follow(path, false) + .map_err(|error| format!("open configured legacy binary {}: {error}", path.display()))?; + let opened = file + .metadata() + .map_err(|error| format!("inspect opened legacy binary {}: {error}", path.display()))?; + if !is_regular(&opened) { + return Err(format!( + "opened configured legacy binary is not regular: {}", + path.display() + )); + } + let opened_identity = identity_for_file(&file) + .map_err(|error| format!("identify opened legacy binary {}: {error}", path.display()))?; + if opened_identity != initial_identity { + return Err(format!( + "configured legacy binary changed between validation and open: {}", + path.display() + )); + } + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .map_err(|error| format!("read configured legacy binary {}: {error}", path.display()))?; + if identity_for_file(&file).map_err(|error| { + format!( + "reidentify legacy binary handle {}: {error}", + path.display() + ) + })? != initial_identity + { + return Err(format!( + "configured legacy binary handle changed while reading: {}", + path.display() + )); + } + corroborate_fixed_path(path, initial_identity, EntryKind::RegularFile) + .map_err(|error| format!("configured legacy binary changed after reading: {error}"))?; + + let observed_digest = sha256_hex(&bytes); + if observed_digest != expectation.executable_sha256 { + return Err(format!( + "configured legacy binary SHA-256 mismatch for {}: expected {}, observed {}", + path.display(), + expectation.executable_sha256, + observed_digest + )); + } + + let output = Command::new(path) + .arg("--version") + .output() + .map_err(|error| { + format!( + "run configured legacy binary {} --version: {error}", + path.display() + ) + })?; + if !output.status.success() { + return Err(format!( + "configured legacy binary {} --version failed with {}: {}", + path.display(), + output.status, + String::from_utf8_lossy(&output.stderr) + )); + } + let observed_version = String::from_utf8_lossy(&output.stdout) + .replace('\r', "") + .trim_end_matches('\n') + .to_string(); + if observed_version != expectation.version_stdout { + return Err(format!( + "configured legacy binary version mismatch for {}: expected {:?}, observed {:?}", + path.display(), + expectation.version_stdout, + observed_version + )); + } + corroborate_fixed_path(path, initial_identity, EntryKind::RegularFile) + .map_err(|error| format!("configured legacy binary changed after --version: {error}"))?; + Ok(()) +} + +/// Absolute path of the verified v0.40.4 executable. +/// +/// CI exports `CODEGRAPH_LEGACY_BIN` from an explicit setup step; a local run +/// falls back to invoking the setup script directly. Either way the binary is +/// digest- and `--version`-verified before any test uses it, and any failure +/// panics as a fixture-setup failure rather than skipping the test. +fn legacy_bin() -> PathBuf { + let expectation = native_legacy_expectation(); + if let Some(configured) = std::env::var_os("CODEGRAPH_LEGACY_BIN") { + let path = PathBuf::from(configured); + verify_legacy_binary(&path, &expectation).unwrap_or_else(|error| { + panic!("CODEGRAPH_LEGACY_BIN failed frozen fixture verification: {error}") + }); + return path; + } + + let script = workspace_root().join("scripts/setup-legacy-fixture.sh"); + let output = Command::new("bash") + .arg(&script) + .current_dir(workspace_root()) + .output() + .unwrap_or_else(|error| { + panic!( + "fixture setup could not run {}: {error}\n\ + The legacy-compatibility fixture is mandatory; this is a setup failure.", + script.display() + ) + }); + assert!( + output.status.success(), + "fixture setup failed ({}). This is a FIXTURE-SETUP FAILURE, never a skipped test.\n\ + stdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let path = stdout + .lines() + .rfind(|line| !line.trim().is_empty()) + .map(str::trim) + .unwrap_or_else(|| panic!("fixture setup printed no executable path")); + let path = PathBuf::from(path); + verify_legacy_binary(&path, &expectation) + .unwrap_or_else(|error| panic!("fixture setup returned an unverified binary: {error}")); + path +} + +struct TestDir(PathBuf); + +impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-batchm-item22-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock after epoch") + .as_nanos() + )); + fs::create_dir_all(&path).expect("create item-22 test directory"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn copy_tree(src: &Path, dst: &Path) { + fs::create_dir_all(dst).expect("create fixture destination"); + for entry in fs::read_dir(src).expect("read fixture directory") { + let entry = entry.expect("read fixture entry"); + let from = entry.path(); + let to = dst.join(entry.file_name()); + if from.is_dir() { + copy_tree(&from, &to); + } else { + fs::copy(&from, &to).expect("copy fixture file"); + } + } +} + +struct Run { + stdout: String, + stderr: String, + ok: bool, +} + +impl Run { + fn expect_ok(self, what: &str) -> Self { + assert!( + self.ok, + "{what} must succeed.\nstdout:\n{}\nstderr:\n{}", + self.stdout, self.stderr + ); + self + } +} + +/// Runs a codegraph binary with a deliberately clean environment: no inherited +/// `CODEGRAPH_DIR`, no daemon, and an isolated HTTP registry. +fn run(binary: &Path, project: &Path, args: &[&str]) -> Run { + let output = Command::new(binary) + .current_dir(project) + .args(args) + .env_remove("CODEGRAPH_DIR") + .env("CODEGRAPH_NO_DAEMON", "1") + .env("CODEGRAPH_NO_WATCH", "1") + .env("CODEGRAPH_HTTP_REGISTRY_DIR", project) + .output() + .unwrap_or_else(|error| panic!("run {}: {error}", binary.display())); + Run { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + ok: output.status.success(), + } +} + +/// The root-relative paths reported by `codegraph files`, normalized to `/`. +/// +/// `files` prints ` (, symbols)`; only those lines carry a +/// path, so the parse is exact rather than a substring search. +fn reported_files(listing: &str) -> Vec { + let mut out = listing + .lines() + .filter_map(|line| { + let body = line.strip_prefix(" ")?; + if body.starts_with(' ') { + return None; + } + let (path, rest) = body.rsplit_once(" (")?; + if !rest.ends_with(')') { + return None; + } + Some(path.trim().replace('\\', "/")) + }) + .collect::>(); + out.sort(); + out.dedup(); + out +} + +// --------------------------------------------------------------------------- +// Fail-closed nonmutation oracle +// --------------------------------------------------------------------------- + +#[derive(Debug, PartialEq, Eq)] +enum Entry { + Directory, + RegularFile(Vec), + Symlink(PathBuf), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SnapshotCheckpoint { + DirectoryPathValidated, + DirectoryHandleAndPathCorroborated, + DirectoryEnumerationCorroborated, + RegularPathValidated, + RegularHandleCorroborated, +} + +#[derive(Debug, PartialEq, Eq)] +enum SnapshotError { + /// A fixed path's identity stopped matching the identity captured for it, so + /// nothing observed through that path may be trusted. + PathChangedDuringRead(PathBuf), + /// The snapshot root exists but is not a non-alias directory (a symlink, a + /// Windows reparse point, or a non-directory). Never a successful snapshot: + /// only a typed `NotFound` may yield an empty one. + RootNotANonAliasDirectory(PathBuf), +} + +impl std::fmt::Display for SnapshotError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PathChangedDuringRead(path) => { + write!(f, "path identity changed during read: {}", path.display()) + } + Self::RootNotANonAliasDirectory(path) => write!( + f, + "snapshot root is not a non-alias directory: {}", + path.display() + ), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EntryKind { + Directory, + RegularFile, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FileIdentity { + #[cfg(unix)] + Unix { device: u64, inode: u64 }, + #[cfg(windows)] + Windows { + volume_serial_number: u64, + file_id: [u8; 16], + }, + #[cfg(not(any(unix, windows)))] + Portable { + len: u64, + modified: Option, + created: Option, + }, +} + +fn is_alias(metadata: &Metadata) -> bool { + if metadata.file_type().is_symlink() { + return true; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt as _; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + } + #[cfg(not(windows))] + false +} + +fn is_kind(metadata: &Metadata, kind: EntryKind) -> bool { + !is_alias(metadata) + && match kind { + EntryKind::Directory => metadata.file_type().is_dir(), + EntryKind::RegularFile => metadata.file_type().is_file(), + } +} + +fn is_regular(metadata: &Metadata) -> bool { + is_kind(metadata, EntryKind::RegularFile) +} + +fn identity_for_file(file: &File) -> std::io::Result { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + let metadata = file.metadata()?; + Ok(FileIdentity::Unix { + device: metadata.dev(), + inode: metadata.ino(), + }) + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle as _; + + const FILE_ID_INFO_CLASS: i32 = 18; + #[repr(C)] + struct FileIdInfo { + volume_serial_number: u64, + file_id: [u8; 16], + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetFileInformationByHandleEx( + h_file: isize, + file_information_class: i32, + lp_file_information: *mut core::ffi::c_void, + dw_buffer_size: u32, + ) -> i32; + } + + let mut info = FileIdInfo { + volume_serial_number: 0, + file_id: [0; 16], + }; + let ok = unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle() as isize, + FILE_ID_INFO_CLASS, + (&mut info as *mut FileIdInfo).cast(), + core::mem::size_of::() as u32, + ) + }; + if ok == 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(FileIdentity::Windows { + volume_serial_number: info.volume_serial_number, + file_id: info.file_id, + }) + } + #[cfg(not(any(unix, windows)))] + { + let metadata = file.metadata()?; + Ok(FileIdentity::Portable { + len: metadata.len(), + modified: metadata.modified().ok(), + created: metadata.created().ok(), + }) + } +} + +fn open_no_follow(path: &Path, directory: bool) -> std::io::Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + #[cfg(any(target_os = "linux", target_os = "android"))] + const O_NOFOLLOW: i32 = 0x0002_0000; + #[cfg(not(any(target_os = "linux", target_os = "android")))] + const O_NOFOLLOW: i32 = 0x0000_0100; + let _ = directory; + options.custom_flags(O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt as _; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + let flags = FILE_FLAG_OPEN_REPARSE_POINT + | if directory { + FILE_FLAG_BACKUP_SEMANTICS + } else { + 0 + }; + options.custom_flags(flags); + } + #[cfg(not(any(unix, windows)))] + let _ = directory; + options.open(path) +} + +fn identity_for_validated_path(path: &Path, metadata: &Metadata) -> std::io::Result { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + let _ = path; + Ok(FileIdentity::Unix { + device: metadata.dev(), + inode: metadata.ino(), + }) + } + #[cfg(windows)] + { + let directory = metadata.file_type().is_dir(); + let file = open_no_follow(path, directory)?; + let opened = file.metadata()?; + if !is_kind( + &opened, + if directory { + EntryKind::Directory + } else { + EntryKind::RegularFile + }, + ) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "validated path changed type before identity capture", + )); + } + identity_for_file(&file) + } + #[cfg(not(any(unix, windows)))] + { + let _ = path; + Ok(FileIdentity::Portable { + len: metadata.len(), + modified: metadata.modified().ok(), + created: metadata.created().ok(), + }) + } +} + +fn corroborate_fixed_path( + path: &Path, + expected: FileIdentity, + kind: EntryKind, +) -> Result<(), SnapshotError> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(SnapshotError::PathChangedDuringRead(path.to_path_buf())); + } + Err(error) => panic!("snapshot reinspect {}: {error}", path.display()), + }; + if !is_kind(&metadata, kind) { + return Err(SnapshotError::PathChangedDuringRead(path.to_path_buf())); + } + let reopened = match open_no_follow(path, kind == EntryKind::Directory) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(SnapshotError::PathChangedDuringRead(path.to_path_buf())); + } + Err(error) => panic!("snapshot reopen {}: {error}", path.display()), + }; + let reopened_metadata = reopened + .metadata() + .unwrap_or_else(|error| panic!("snapshot reopened metadata {}: {error}", path.display())); + let reopened_identity = identity_for_file(&reopened) + .unwrap_or_else(|error| panic!("snapshot reopened identity {}: {error}", path.display())); + if !is_kind(&reopened_metadata, kind) || reopened_identity != expected { + return Err(SnapshotError::PathChangedDuringRead(path.to_path_buf())); + } + Ok(()) +} + +/// Full-byte, alias-free snapshot of `root`. +/// +/// Directories are recorded as entries (so an added empty directory is visible), +/// regular files carry their COMPLETE bytes read through an opened handle, +/// and symlinks record their target without ever being followed. Every I/O +/// error and every unsupported entry kind panics, so the helper can never +/// report a false "unchanged". +fn snapshot(root: &Path) -> BTreeMap { + snapshot_with_checkpoint(root, |_, _| {}).expect("snapshot path identities remain stable") +} + +fn snapshot_with_checkpoint( + root: &Path, + mut checkpoint: F, +) -> Result, SnapshotError> +where + F: FnMut(&Path, SnapshotCheckpoint), +{ + fn read_regular_bytes( + path: &Path, + initial_identity: FileIdentity, + checkpoint: &mut F, + ) -> Result, SnapshotError> + where + F: FnMut(&Path, SnapshotCheckpoint), + { + let mut file = match open_no_follow(path, false) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(SnapshotError::PathChangedDuringRead(path.to_path_buf())); + } + Err(error) => panic!("snapshot open {}: {error}", path.display()), + }; + let opened = file + .metadata() + .unwrap_or_else(|error| panic!("snapshot opened metadata {}: {error}", path.display())); + let opened_identity = identity_for_file(&file) + .unwrap_or_else(|error| panic!("snapshot opened identity {}: {error}", path.display())); + if !is_regular(&opened) || opened_identity != initial_identity { + return Err(SnapshotError::PathChangedDuringRead(path.to_path_buf())); + } + corroborate_fixed_path(path, initial_identity, EntryKind::RegularFile)?; + checkpoint(path, SnapshotCheckpoint::RegularHandleCorroborated); + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .unwrap_or_else(|error| panic!("snapshot read {}: {error}", path.display())); + let after_read_identity = identity_for_file(&file).unwrap_or_else(|error| { + panic!("snapshot post-read identity {}: {error}", path.display()) + }); + if after_read_identity != initial_identity { + return Err(SnapshotError::PathChangedDuringRead(path.to_path_buf())); + } + corroborate_fixed_path(path, initial_identity, EntryKind::RegularFile)?; + Ok(bytes) + } + + fn walk( + root: &Path, + directory: &Path, + initial_identity: FileIdentity, + out: &mut BTreeMap, + checkpoint: &mut F, + ) -> Result<(), SnapshotError> + where + F: FnMut(&Path, SnapshotCheckpoint), + { + let opened_directory = match open_no_follow(directory, true) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(SnapshotError::PathChangedDuringRead( + directory.to_path_buf(), + )); + } + Err(error) => panic!("snapshot open directory {}: {error}", directory.display()), + }; + let opened_metadata = opened_directory.metadata().unwrap_or_else(|error| { + panic!( + "snapshot opened directory metadata {}: {error}", + directory.display() + ) + }); + let opened_identity = identity_for_file(&opened_directory).unwrap_or_else(|error| { + panic!( + "snapshot opened directory identity {}: {error}", + directory.display() + ) + }); + if !is_kind(&opened_metadata, EntryKind::Directory) || opened_identity != initial_identity { + return Err(SnapshotError::PathChangedDuringRead( + directory.to_path_buf(), + )); + } + corroborate_fixed_path(directory, initial_identity, EntryKind::Directory)?; + checkpoint( + directory, + SnapshotCheckpoint::DirectoryHandleAndPathCorroborated, + ); + + let entries = fs::read_dir(directory) + .unwrap_or_else(|error| panic!("snapshot read_dir {}: {error}", directory.display())); + let mut children = Vec::new(); + for entry in entries { + children.push( + entry + .unwrap_or_else(|error| { + panic!("snapshot entry in {}: {error}", directory.display()) + }) + .path(), + ); + } + corroborate_fixed_path(directory, initial_identity, EntryKind::Directory)?; + checkpoint( + directory, + SnapshotCheckpoint::DirectoryEnumerationCorroborated, + ); + children.sort(); + + for path in children { + let relative = path + .strip_prefix(root) + .unwrap_or_else(|error| panic!("snapshot strip {}: {error}", path.display())) + .to_path_buf(); + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(SnapshotError::PathChangedDuringRead(path)); + } + Err(error) => panic!("snapshot metadata {}: {error}", path.display()), + }; + let file_type = metadata.file_type(); + let entry = if is_kind(&metadata, EntryKind::Directory) { + Entry::Directory + } else if is_regular(&metadata) { + let identity = + identity_for_validated_path(&path, &metadata).unwrap_or_else(|error| { + panic!("snapshot initial file identity {}: {error}", path.display()) + }); + checkpoint(&path, SnapshotCheckpoint::RegularPathValidated); + Entry::RegularFile(read_regular_bytes(&path, identity, checkpoint)?) + } else if file_type.is_symlink() || is_alias(&metadata) { + Entry::Symlink(fs::read_link(&path).unwrap_or_else(|error| { + panic!("snapshot read_link {}: {error}", path.display()) + })) + } else { + panic!("snapshot unsupported entry kind: {}", path.display()); + }; + assert!( + out.insert(relative, entry).is_none(), + "duplicate snapshot path under {}", + root.display() + ); + if is_kind(&metadata, EntryKind::Directory) { + let identity = + identity_for_validated_path(&path, &metadata).unwrap_or_else(|error| { + panic!( + "snapshot initial directory identity {}: {error}", + path.display() + ) + }); + checkpoint(&path, SnapshotCheckpoint::DirectoryPathValidated); + walk(root, &path, identity, out, checkpoint)?; + } + } + corroborate_fixed_path(directory, initial_identity, EntryKind::Directory)?; + Ok(()) + } + + let metadata = match fs::symlink_metadata(root) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(BTreeMap::new()); + } + Err(error) => panic!("snapshot root metadata {}: {error}", root.display()), + }; + if !is_kind(&metadata, EntryKind::Directory) { + return Err(SnapshotError::RootNotANonAliasDirectory(root.to_path_buf())); + } + let identity = identity_for_validated_path(root, &metadata) + .unwrap_or_else(|error| panic!("snapshot root identity {}: {error}", root.display())); + checkpoint(root, SnapshotCheckpoint::DirectoryPathValidated); + let mut out = BTreeMap::new(); + walk(root, root, identity, &mut out, &mut checkpoint)?; + Ok(out) +} + +fn assert_unchanged( + before: &BTreeMap, + after: &BTreeMap, + what: &str, +) { + let mut paths = before + .keys() + .chain(after.keys()) + .cloned() + .collect::>(); + paths.sort(); + paths.dedup(); + let changed = paths + .into_iter() + .filter(|path| before.get(path) != after.get(path)) + .map(|path| { + let label = |entry: Option<&Entry>| match entry { + None => "absent".to_string(), + Some(Entry::Directory) => "directory".to_string(), + Some(Entry::RegularFile(bytes)) => format!("file[{} bytes]", bytes.len()), + Some(Entry::Symlink(target)) => format!("symlink -> {}", target.display()), + }; + format!( + "{}: {} => {}", + path.display(), + label(before.get(&path)), + label(after.get(&path)) + ) + }) + .collect::>(); + assert!(changed.is_empty(), "{what} changed entries: {changed:?}"); +} + +/// The checked-in fixture manifest's text. +fn manifest_text() -> String { + fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/legacy-v0.40.4/manifest.toml"), + ) + .expect("read the legacy fixture manifest") +} + +/// A v2 artifact path, as the legacy scanner would report it. +const V2_CONFIG_TOML: &str = ".codegraph-v2/config.toml"; +const V2_EXTENSION_JSON: &str = ".codegraph-v2/codegraph.json"; +/// A symbol that exists ONLY inside the v2 database — its source file is deleted +/// before any legacy run, so any legacy graph that surfaced it could only have +/// read v2 storage. +const V2_ONLY_SYMBOL: &str = "v2OnlyTrapSymbol"; + +/// Item 22. Both halves in one target so the two claims cannot drift apart: +/// the DEFAULT-configuration graph claim, and the every-configuration storage +/// claim under an explicit `.json`/`.toml` extension override. +/// +/// The proof runs natively on every host that has a pinned v0.40.4 asset +/// (linux/x86_64 and windows/x86_64 — the two CI jobs). A host with no pinned +/// asset cannot execute the real legacy binary at all; it says so LOUDLY and +/// asserts the manifest genuinely has no entry for it, so an unpinned pass can +/// never be mistaken for legacy coverage. +#[test] +fn legacy_scan_with_runtime_extension_override_does_not_weaken_storage_safety() { + let Some(asset_target) = native_asset_target() else { + let manifest = manifest_text(); + assert!( + !manifest.contains(&format!("target_os = \"{}\"", std::env::consts::OS)) + || !manifest.contains(&format!("target_arch = \"{}\"", std::env::consts::ARCH)), + "the manifest appears to pin {}/{} — the real item-22 proof must run here \ + instead of reporting no coverage", + std::env::consts::OS, + std::env::consts::ARCH + ); + eprintln!( + "item 22 NOT COVERED on this host: no pinned v0.40.4 asset for {}/{}. \ + The legacy proof executes the real published binary natively on \ + linux/x86_64 and windows/x86_64 only; do NOT read this pass as \ + legacy-compatibility coverage.", + std::env::consts::OS, + std::env::consts::ARCH + ); + return; + }; + assert!( + manifest_text().contains(&format!("target = \"{asset_target}\"")), + "the fixture manifest must pin an asset for this native host ({asset_target})" + ); + + let legacy = legacy_bin(); + let dir = TestDir::new("override"); + let project = dir.path().join("mini"); + copy_tree(&mini_fixture(), &project); + + // A v2-only symbol: indexed by the CURRENT binary, then its source removed. + // Afterwards the string lives in v2 storage and nowhere on disk as source. + let trap_source = project.join("src").join("v2_trap.ts"); + fs::write( + &trap_source, + format!("export function {V2_ONLY_SYMBOL}(): number {{\n return 42;\n}}\n"), + ) + .expect("write v2-only trap source"); + run(&new_bin(), &project, &["init", "."]).expect_ok("v2 init"); + fs::remove_file(&trap_source).expect("remove v2-only trap source"); + + let v2_root = project.join(".codegraph-v2"); + // Project-scoped v2 config/extension artifacts: exactly the TOML/JSON files + // an extension override would claim as source. + fs::write(v2_root.join("config.toml"), "[app]\nname = \"codegraph\"\n") + .expect("write v2 project config"); + fs::write( + v2_root.join("codegraph.json"), + "{\"extensions\":{\".zz\":\"lua\"}}\n", + ) + .expect("write v2 extension config"); + + let v2_before = snapshot(&v2_root); + assert!( + v2_before.contains_key(Path::new("codegraph.db")), + "setup: the v2 namespace must hold a database before the legacy runs" + ); + let v2_db_bytes = fs::read(v2_root.join("codegraph.db")).expect("read v2 database bytes"); + assert!( + contains_bytes(&v2_db_bytes, V2_ONLY_SYMBOL.as_bytes()), + "setup: the v2-only symbol must be present in v2 storage" + ); + let sources_before = snapshot(&project.join("src")); + let tools_before = snapshot(&project.join("tools")); + + // --------------------------------------------------------------------- + // Claim 1 — DEFAULT supported-extension configuration: only the expected + // source graph. No legacy extension override exists yet. + // --------------------------------------------------------------------- + run(&legacy, &project, &["init", "."]).expect_ok("legacy init with default extensions"); + let default_listing = run(&legacy, &project, &["files"]) + .expect_ok("legacy files with default extensions") + .stdout; + assert_eq!( + reported_files(&default_listing), + vec![ + "src/app.ts".to_string(), + "src/math.ts".to_string(), + "tools/greeter.py".to_string(), + ], + "with the DEFAULT supported-extension configuration the legacy scan must \ + report only the expected source graph" + ); + + let legacy_root = project.join(".codegraph"); + assert!( + legacy_root.join("codegraph.db").is_file(), + "the legacy binary must write its own legacy-namespace database" + ); + assert_unchanged( + &v2_before, + &snapshot(&v2_root), + "default-configuration legacy scan", + ); + + // --------------------------------------------------------------------- + // Claim 2 — EXPLICIT runtime extension override. The old scanner is told to + // treat `.json`/`.toml` as source, so it MAY now report v2 artifacts as + // source. Storage authority must not move an inch. + // --------------------------------------------------------------------- + fs::write( + legacy_root.join("codegraph.json"), + "{\"extensions\":{\".json\":\"javascript\",\".toml\":\"javascript\"}}\n", + ) + .expect("write legacy runtime extension override"); + + run(&legacy, &project, &["index", "--force", "."]) + .expect_ok("legacy index --force under the extension override"); + let override_listing = run(&legacy, &project, &["files"]) + .expect_ok("legacy files under the extension override") + .stdout; + let override_reported = reported_files(&override_listing); + + // DOCUMENTED, ACCEPTED source visibility: this is the whole point of item 22. + // The old scanner was explicitly configured for these extensions, so + // reporting v2 JSON/TOML as SOURCE is correct behavior, not a leak. + assert!( + override_reported.iter().any(|path| path == V2_CONFIG_TOML), + "an explicitly configured `.toml` extension is expected to make the old \ + scanner report {V2_CONFIG_TOML} as source; reported: {override_reported:?}" + ); + assert!( + override_reported + .iter() + .any(|path| path == V2_EXTENSION_JSON), + "an explicitly configured `.json` extension is expected to make the old \ + scanner report {V2_EXTENSION_JSON} as source; reported: {override_reported:?}" + ); + // The expected source graph is still fully present; the override only ADDS. + for expected in ["src/app.ts", "src/math.ts", "tools/greeter.py"] { + assert!( + override_reported.iter().any(|path| path == expected), + "the override must not drop expected source {expected}; reported: \ + {override_reported:?}" + ); + } + + // STORAGE AUTHORITY — unchanged for this configuration too. + assert_unchanged( + &v2_before, + &snapshot(&v2_root), + "extension-override legacy scan", + ); + assert_unchanged( + &sources_before, + &snapshot(&project.join("src")), + "extension-override legacy scan (source tree)", + ); + assert_unchanged( + &tools_before, + &snapshot(&project.join("tools")), + "extension-override legacy scan (tools tree)", + ); + + // Reading a v2 file's TEXT never grants v2 graph authority: the v2-only + // symbol is absent from the legacy database and from the legacy graph, even + // though the override let the scanner read v2 config text. + let legacy_db_bytes = + fs::read(legacy_root.join("codegraph.db")).expect("read legacy database bytes"); + assert!( + !contains_bytes(&legacy_db_bytes, V2_ONLY_SYMBOL.as_bytes()), + "the legacy database must not contain the v2-only symbol" + ); + let legacy_query = run(&legacy, &project, &["query", V2_ONLY_SYMBOL]) + .expect_ok("legacy query for the v2-only symbol"); + // The query echoes the search term, so match on the RESULT shape instead of + // the term: a hit prints `Search Results for "…":` followed by rows, a miss + // prints `No results found for "…"`. + assert!( + legacy_query.stdout.contains("No results found"), + "the legacy graph must not serve the v2-only symbol.\nstdout:\n{}", + legacy_query.stdout + ); + assert!( + !legacy_query.stdout.contains("Search Results for"), + "the legacy graph must return no result rows for the v2-only symbol.\nstdout:\n{}", + legacy_query.stdout + ); + + // And the v2 graph is still fully usable by the v2 reader afterwards. + let v2_query = run(&new_bin(), &project, &["query", V2_ONLY_SYMBOL]) + .expect_ok("v2 query after the legacy override runs"); + assert!( + v2_query.stdout.contains("Search Results for") && v2_query.stdout.contains("v2_trap.ts"), + "the v2 graph must still serve its own symbol after the legacy runs.\nstdout:\n{}", + v2_query.stdout + ); + assert_unchanged( + &v2_before, + &snapshot(&v2_root), + "v2 read after the extension-override legacy scan", + ); +} + +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +/// FIPS 180-4 SHA-256 over `bytes`, lowercase hex. +/// +/// Test-local on purpose: the crate under test must gain no dependency for a +/// fixture check. Correctness is pinned by [`sha256_hex_matches_known_vectors`] +/// against the published NIST vectors, so the legacy-binary digest gate never +/// rests on an unverified hash. +fn sha256_hex(bytes: &[u8]) -> String { + const K: [u32; 64] = [ + 0x428a_2f98, + 0x7137_4491, + 0xb5c0_fbcf, + 0xe9b5_dba5, + 0x3956_c25b, + 0x59f1_11f1, + 0x923f_82a4, + 0xab1c_5ed5, + 0xd807_aa98, + 0x1283_5b01, + 0x2431_85be, + 0x550c_7dc3, + 0x72be_5d74, + 0x80de_b1fe, + 0x9bdc_06a7, + 0xc19b_f174, + 0xe49b_69c1, + 0xefbe_4786, + 0x0fc1_9dc6, + 0x240c_a1cc, + 0x2de9_2c6f, + 0x4a74_84aa, + 0x5cb0_a9dc, + 0x76f9_88da, + 0x983e_5152, + 0xa831_c66d, + 0xb003_27c8, + 0xbf59_7fc7, + 0xc6e0_0bf3, + 0xd5a7_9147, + 0x06ca_6351, + 0x1429_2967, + 0x27b7_0a85, + 0x2e1b_2138, + 0x4d2c_6dfc, + 0x5338_0d13, + 0x650a_7354, + 0x766a_0abb, + 0x81c2_c92e, + 0x9272_2c85, + 0xa2bf_e8a1, + 0xa81a_664b, + 0xc24b_8b70, + 0xc76c_51a3, + 0xd192_e819, + 0xd699_0624, + 0xf40e_3585, + 0x106a_a070, + 0x19a4_c116, + 0x1e37_6c08, + 0x2748_774c, + 0x34b0_bcb5, + 0x391c_0cb3, + 0x4ed8_aa4a, + 0x5b9c_ca4f, + 0x682e_6ff3, + 0x748f_82ee, + 0x78a5_636f, + 0x84c8_7814, + 0x8cc7_0208, + 0x90be_fffa, + 0xa450_6ceb, + 0xbef9_a3f7, + 0xc671_78f2, + ]; + let mut state: [u32; 8] = [ + 0x6a09_e667, + 0xbb67_ae85, + 0x3c6e_f372, + 0xa54f_f53a, + 0x510e_527f, + 0x9b05_688c, + 0x1f83_d9ab, + 0x5be0_cd19, + ]; + + let mut message = bytes.to_vec(); + let bit_length = (bytes.len() as u64) * 8; + message.push(0x80); + while message.len() % 64 != 56 { + message.push(0); + } + message.extend_from_slice(&bit_length.to_be_bytes()); + + for chunk in message.chunks_exact(64) { + let mut w = [0u32; 64]; + for (index, word) in chunk.chunks_exact(4).enumerate() { + w[index] = u32::from_be_bytes([word[0], word[1], word[2], word[3]]); + } + for index in 16..64 { + let s0 = w[index - 15].rotate_right(7) + ^ w[index - 15].rotate_right(18) + ^ (w[index - 15] >> 3); + let s1 = w[index - 2].rotate_right(17) + ^ w[index - 2].rotate_right(19) + ^ (w[index - 2] >> 10); + w[index] = w[index - 16] + .wrapping_add(s0) + .wrapping_add(w[index - 7]) + .wrapping_add(s1); + } + + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = state; + for index in 0..64 { + let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let ch = (e & f) ^ ((!e) & g); + let temp1 = h + .wrapping_add(s1) + .wrapping_add(ch) + .wrapping_add(K[index]) + .wrapping_add(w[index]); + let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let maj = (a & b) ^ (a & c) ^ (b & c); + let temp2 = s0.wrapping_add(maj); + + h = g; + g = f; + f = e; + e = d.wrapping_add(temp1); + d = c; + c = b; + b = a; + a = temp1.wrapping_add(temp2); + } + for (slot, value) in state.iter_mut().zip([a, b, c, d, e, f, g, h]) { + *slot = slot.wrapping_add(value); + } + } + + let mut out = String::with_capacity(64); + for word in state { + for byte in word.to_be_bytes() { + out.push(char::from_digit((byte >> 4) as u32, 16).expect("hex nibble")); + out.push(char::from_digit((byte & 0x0f) as u32, 16).expect("hex nibble")); + } + } + out +} + +/// Harness self-test: the nonmutation oracle must FAIL on an equal-length +/// in-place byte change, so an "unchanged" verdict above is real byte proof and +/// not a size comparison. +#[test] +fn snapshot_detects_equal_length_byte_mutation() { + let dir = TestDir::new("selftest"); + let root = dir.path().join("ns"); + fs::create_dir_all(root.join("nested")).expect("create nested snapshot directory"); + let file = root.join("nested").join("a.bin"); + fs::write(&file, b"AAAA").expect("write self-test bytes"); + + let before = snapshot(&root); + fs::write(&file, b"AAAB").expect("equal-length in-place mutation"); + let after = snapshot(&root); + + let failed = + std::panic::catch_unwind(|| assert_unchanged(&before, &after, "self-test")).is_err(); + assert!( + failed, + "the nonmutation oracle must reject an equal-length byte mutation" + ); +} + +/// Guards the native-host mapping against silent drift from the fixture +/// manifest and the two CI jobs that execute the proof. +#[test] +fn pinned_host_set_matches_the_fixture_manifest() { + let manifest = manifest_text(); + let targets = manifest + .lines() + .filter_map(|line| line.trim().strip_prefix("target = ")) + .map(|value| value.trim().trim_matches('"').to_string()) + .collect::>(); + assert_eq!( + targets, + vec![ + "x86_64-unknown-linux-musl".to_string(), + "x86_64-pc-windows-msvc".to_string(), + ], + "the manifest must pin exactly the two natively-executed CI hosts" + ); + // The runtime host mapping must agree with the manifest: a mapped host has + // a manifest entry, and an unmapped host has none. + match native_asset_target() { + Some(target) => assert!( + targets.iter().any(|pinned| pinned == target), + "native_asset_target() returned {target}, absent from the manifest: {targets:?}" + ), + None => assert!( + !manifest.contains(&format!("target_os = \"{}\"", std::env::consts::OS)) + || !manifest.contains(&format!("target_arch = \"{}\"", std::env::consts::ARCH)), + "native_asset_target() reported no pinned asset for {}/{} while the \ + manifest pins it", + std::env::consts::OS, + std::env::consts::ARCH + ), + } +} + +/// The published NIST SHA-256 vectors. Without this the legacy-binary digest +/// gate would rest on an unverified hand-written hash. +#[test] +fn sha256_hex_matches_known_vectors() { + assert_eq!( + sha256_hex(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!( + sha256_hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + assert_eq!( + sha256_hex(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"), + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1" + ); + // Spans the 56-byte padding boundary, where a length-encoding slip hides. + assert_eq!( + sha256_hex(&[b'a'; 64]), + "ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb" + ); +} + +/// The expectation must come from the checked-in manifest, not from constants +/// duplicated in this file. +#[test] +fn native_legacy_expectation_is_read_from_the_manifest() { + if native_asset_target().is_none() { + return; + } + let expectation = native_legacy_expectation(); + let manifest = manifest_text(); + assert!( + manifest.contains(&expectation.executable_sha256), + "the native executable digest must be the manifest's own value" + ); + assert!( + manifest.contains(&format!( + "expected_version_stdout = \"{}\"", + expectation.version_stdout + )), + "the expected version must be the manifest's own value" + ); +} + +#[test] +fn manifest_asset_lookup_rejects_an_unpinned_target() { + let manifest = manifest_text(); + let missing = std::panic::catch_unwind(|| { + asset_executable_sha256(&manifest, "aarch64-unknown-linux-gnu") + }); + assert!( + missing.is_err(), + "an unpinned target must fail loudly instead of yielding a digest" + ); +} + +/// A synthetic `[[asset]]` block for the parser regressions below. +fn synthetic_asset(target: &str, digests: &[&str]) -> String { + let mut block = format!("[[asset]]\ntarget = \"{target}\"\n"); + for digest in digests { + block.push_str(&format!("executable_sha256 = \"{digest}\"\n")); + } + block +} + +fn lookup_panics(manifest: &str, target: &str) -> bool { + let manifest = manifest.to_string(); + let target = target.to_string(); + std::panic::catch_unwind(move || asset_executable_sha256(&manifest, &target)).is_err() +} + +/// Both carry hex LETTERS, so `to_uppercase()` in the malformed-digest test is a +/// real change rather than a no-op on an all-digit string. +const GOOD_DIGEST: &str = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"; +const OTHER_DIGEST: &str = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"; + +/// A single well-formed block is the ONLY accepting shape, so the rejections +/// below are attributable to the defect under test and not to a broken parser. +#[test] +fn manifest_asset_lookup_accepts_exactly_one_well_formed_block() { + let manifest = format!( + "[fixture]\ntag = \"v0.40.4\"\n\n{}{}", + synthetic_asset("target-a", &[GOOD_DIGEST]), + synthetic_asset("target-b", &[OTHER_DIGEST]) + ); + assert_eq!(asset_executable_sha256(&manifest, "target-a"), GOOD_DIGEST); + assert_eq!(asset_executable_sha256(&manifest, "target-b"), OTHER_DIGEST); +} + +/// The regression this test exists for: TWO blocks name the same target and only +/// ONE carries a digest. Counting digests alone yields exactly one value and +/// silently accepts; block cardinality must reject it. +#[test] +fn manifest_asset_lookup_rejects_duplicate_target_blocks_when_only_one_has_a_digest() { + let manifest = format!( + "{}{}", + synthetic_asset("target-a", &[GOOD_DIGEST]), + synthetic_asset("target-a", &[]) + ); + assert!( + lookup_panics(&manifest, "target-a"), + "two blocks naming the same target must be fatal even when only one has a digest" + ); + + // Also in the other order, so the rejection is not an artifact of which + // block happens to come first. + let reversed = format!( + "{}{}", + synthetic_asset("target-a", &[]), + synthetic_asset("target-a", &[GOOD_DIGEST]) + ); + assert!( + lookup_panics(&reversed, "target-a"), + "block-order must not change the duplicate-target verdict" + ); +} + +#[test] +fn manifest_asset_lookup_rejects_duplicate_target_blocks_that_both_have_digests() { + let manifest = format!( + "{}{}", + synthetic_asset("target-a", &[GOOD_DIGEST]), + synthetic_asset("target-a", &[OTHER_DIGEST]) + ); + assert!( + lookup_panics(&manifest, "target-a"), + "two blocks naming the same target must be fatal" + ); +} + +#[test] +fn manifest_asset_lookup_rejects_duplicate_digest_fields_in_one_block() { + let manifest = synthetic_asset("target-a", &[GOOD_DIGEST, OTHER_DIGEST]); + assert!( + lookup_panics(&manifest, "target-a"), + "one block declaring two digests must be fatal" + ); +} + +#[test] +fn manifest_asset_lookup_rejects_a_block_without_a_digest() { + let manifest = synthetic_asset("target-a", &[]); + assert!( + lookup_panics(&manifest, "target-a"), + "a matching block with no digest must be fatal" + ); +} + +#[test] +fn manifest_asset_lookup_rejects_a_malformed_digest() { + let uppercase = GOOD_DIGEST.to_uppercase(); + assert_ne!( + uppercase, GOOD_DIGEST, + "the uppercase case must actually differ from the accepted digest" + ); + for bad in [ + uppercase.as_str(), + &GOOD_DIGEST[..63], + &format!("{GOOD_DIGEST}a"), + &format!("{}g", &GOOD_DIGEST[..63]), + ] { + let manifest = synthetic_asset("target-a", &[bad]); + assert!( + lookup_panics(&manifest, "target-a"), + "a malformed digest must be fatal: {bad:?}" + ); + } +} + +/// A following `[fixture]` table must CLOSE the preceding asset block, so its +/// keys can never be attributed to that block. +#[test] +fn manifest_asset_blocks_are_closed_by_the_next_table() { + let manifest = format!( + "{}\n[fixture]\nexecutable_sha256 = \"{OTHER_DIGEST}\"\n", + synthetic_asset("target-a", &[GOOD_DIGEST]) + ); + assert_eq!( + asset_executable_sha256(&manifest, "target-a"), + GOOD_DIGEST, + "a key after the next table header must not join the asset block" + ); +} + +/// A root that is a symlink to a real directory must be REFUSED, and the alias +/// target must be neither enumerated nor modified. +#[cfg(unix)] +#[test] +fn snapshot_refuses_a_symlinked_root_without_touching_the_target() { + let dir = TestDir::new("rootalias"); + let target = dir.path().join("target"); + fs::create_dir_all(&target).expect("create alias target directory"); + let secret = target.join("secret.bin"); + fs::write(&secret, b"TARGET-BYTES").expect("write alias target bytes"); + let link = dir.path().join("root-link"); + std::os::unix::fs::symlink(&target, &link).expect("create root symlink"); + + let outcome = snapshot_with_checkpoint(&link, |_, _| {}); + assert_eq!( + outcome, + Err(SnapshotError::RootNotANonAliasDirectory(link.clone())), + "an aliased root must fail closed with the typed root error" + ); + + // The alias target is untouched: the same bytes, and the direct snapshot of + // the real directory still sees exactly one entry. + assert_eq!( + fs::read(&secret).expect("reread alias target bytes"), + b"TARGET-BYTES".to_vec(), + "refusing an aliased root must not modify the target" + ); + let direct = snapshot(&target); + assert_eq!( + direct.keys().cloned().collect::>(), + vec![PathBuf::from("secret.bin")], + "the alias target must be unchanged in shape" + ); +} + +/// A root that exists but is a regular file is refused with the same typed +/// error: only a typed `NotFound` may produce an empty snapshot. +#[test] +fn snapshot_refuses_a_non_directory_root_and_only_notfound_is_empty() { + let dir = TestDir::new("rootkind"); + let file_root = dir.path().join("not-a-dir"); + fs::write(&file_root, b"x").expect("write non-directory root"); + assert_eq!( + snapshot_with_checkpoint(&file_root, |_, _| {}), + Err(SnapshotError::RootNotANonAliasDirectory(file_root)), + "a non-directory root must fail closed" + ); + + let absent = dir.path().join("absent"); + assert_eq!( + snapshot_with_checkpoint(&absent, |_, _| {}), + Ok(BTreeMap::new()), + "a typed NotFound root is the ONLY empty-snapshot case" + ); +} + +/// Replaces `path` (a directory) with a fresh directory of different content, +/// deterministically and without sleeping. +fn replace_directory(path: &Path, marker: &str) { + let stash = path.with_extension("replaced-away"); + fs::rename(path, &stash).expect("move the original directory aside"); + fs::create_dir_all(path).expect("create the replacement directory"); + fs::write(path.join(marker), b"REPLACEMENT").expect("write replacement marker"); +} + +/// The post-corroboration gate is real: swapping the fixed ROOT directory after +/// its handle and path were corroborated must reject the run, so children +/// collected from the replacement are never accepted. +#[test] +fn snapshot_rejects_root_replaced_after_handle_corroboration() { + let dir = TestDir::new("rootswap"); + let root = dir.path().join("ns"); + fs::create_dir_all(&root).expect("create snapshot root"); + fs::write(root.join("original.bin"), b"ORIGINAL").expect("write original bytes"); + + let mut trace: Vec<(PathBuf, SnapshotCheckpoint)> = Vec::new(); + let mut swapped = false; + let outcome = snapshot_with_checkpoint(&root, |path, checkpoint| { + trace.push((path.to_path_buf(), checkpoint)); + if !swapped + && path == root + && checkpoint == SnapshotCheckpoint::DirectoryHandleAndPathCorroborated + { + swapped = true; + replace_directory(&root, "injected.bin"); + } + }); + + assert!(swapped, "the root checkpoint must have been reached"); + assert_eq!( + outcome, + Err(SnapshotError::PathChangedDuringRead(root.clone())), + "a root replaced after corroboration must be rejected" + ); + // Pins the POST-ENUMERATION gate specifically: enumeration ran against the + // replacement, so the run must die BEFORE that checkpoint. Without the + // post-enumeration recheck this checkpoint fires and the test fails. + assert!( + !trace.contains(&( + root.clone(), + SnapshotCheckpoint::DirectoryEnumerationCorroborated + )), + "the post-enumeration recheck must reject the swapped root before its \ + collected children are usable; trace: {trace:?}" + ); +} + +/// Same gate one level down: a NESTED directory swapped after its own +/// corroboration must reject the run. +#[test] +fn snapshot_rejects_nested_directory_replaced_after_handle_corroboration() { + let dir = TestDir::new("nestedswap"); + let root = dir.path().join("ns"); + let nested = root.join("nested"); + fs::create_dir_all(&nested).expect("create nested snapshot directory"); + fs::write(nested.join("original.bin"), b"ORIGINAL").expect("write nested original bytes"); + + let mut trace: Vec<(PathBuf, SnapshotCheckpoint)> = Vec::new(); + let mut swapped = false; + let outcome = snapshot_with_checkpoint(&root, |path, checkpoint| { + trace.push((path.to_path_buf(), checkpoint)); + if !swapped + && path == nested + && checkpoint == SnapshotCheckpoint::DirectoryHandleAndPathCorroborated + { + swapped = true; + replace_directory(&nested, "injected.bin"); + } + }); + + assert!(swapped, "the nested checkpoint must have been reached"); + assert_eq!( + outcome, + Err(SnapshotError::PathChangedDuringRead(nested.clone())), + "a nested directory replaced after corroboration must be rejected" + ); + assert!( + !trace.contains(&( + nested.clone(), + SnapshotCheckpoint::DirectoryEnumerationCorroborated + )), + "the post-enumeration recheck must reject the swapped nested directory \ + before its collected children are usable; trace: {trace:?}" + ); +} + +/// A regular file replaced by a DIFFERENT object after its handle was +/// corroborated must be rejected by the post-read fixed-path check, so bytes +/// read through the old handle are never attributed to the new path. +#[test] +fn snapshot_rejects_regular_file_replaced_after_handle_corroboration() { + let dir = TestDir::new("fileswap"); + let root = dir.path().join("ns"); + fs::create_dir_all(&root).expect("create snapshot root"); + let file = root.join("a.bin"); + fs::write(&file, b"ORIGINAL").expect("write original file bytes"); + + let mut swapped = false; + let outcome = snapshot_with_checkpoint(&root, |path, checkpoint| { + if !swapped && path == file && checkpoint == SnapshotCheckpoint::RegularHandleCorroborated { + swapped = true; + let replacement = root.join("replacement.tmp"); + fs::write(&replacement, b"REPLACED").expect("write replacement bytes"); + fs::rename(&replacement, &file).expect("atomically replace the snapshot file"); + } + }); + + assert!( + swapped, + "the regular-file checkpoint must have been reached" + ); + assert_eq!( + outcome, + Err(SnapshotError::PathChangedDuringRead(file.clone())), + "a file replaced after handle corroboration must be rejected" + ); +} + +/// A temporary executable that prints `line` on stdout, or `None` when this +/// platform has no dependency-free way to make one. +#[cfg(unix)] +fn write_version_stub(path: &Path, line: &str) -> Option<()> { + use std::os::unix::fs::PermissionsExt as _; + fs::write(path, format!("#!/bin/sh\necho '{line}'\n")).expect("write version stub"); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("mark stub executable"); + Some(()) +} + +#[cfg(not(unix))] +fn write_version_stub(_path: &Path, _line: &str) -> Option<()> { + None +} + +/// A wrong digest must be rejected BEFORE the candidate is ever executed. +#[test] +fn verify_legacy_binary_rejects_a_wrong_executable_digest() { + let dir = TestDir::new("badsha"); + let candidate = dir.path().join("fake-legacy"); + fs::write(&candidate, b"not the frozen legacy binary").expect("write fake legacy binary"); + + let expectation = LegacyExpectation { + executable_sha256: "0".repeat(64), + version_stdout: "codegraph 0.40.4".to_string(), + }; + let error = verify_legacy_binary(&candidate, &expectation) + .expect_err("a wrong digest must be rejected"); + assert!( + error.contains("SHA-256 mismatch"), + "the digest branch must be the rejection reason, got: {error}" + ); + assert!( + error.contains(&sha256_hex(b"not the frozen legacy binary")), + "the observed digest must be reported, got: {error}" + ); +} + +/// With the candidate's REAL digest as the expectation, verification reaches the +/// `--version` branch and rejects the wrong version. No collision is needed. +#[test] +fn verify_legacy_binary_rejects_a_wrong_executable_version() { + let dir = TestDir::new("badversion"); + let candidate = dir.path().join("stub-legacy"); + if write_version_stub(&candidate, "codegraph 9.9.9").is_none() { + eprintln!( + "verify_legacy_binary version branch NOT self-tested on {}: no \ + dependency-free executable stub. Production verification still runs \ + the real `--version` natively.", + std::env::consts::OS + ); + return; + } + + let bytes = fs::read(&candidate).expect("read the stub bytes"); + let expectation = LegacyExpectation { + executable_sha256: sha256_hex(&bytes), + version_stdout: "codegraph 0.40.4".to_string(), + }; + let error = verify_legacy_binary(&candidate, &expectation) + .expect_err("a wrong --version must be rejected"); + assert!( + error.contains("version mismatch") && error.contains("codegraph 9.9.9"), + "the version branch must be the rejection reason, got: {error}" + ); +} + +/// A matching digest AND matching version is the only accepting path. +#[test] +fn verify_legacy_binary_accepts_a_matching_digest_and_version() { + let dir = TestDir::new("goodstub"); + let candidate = dir.path().join("stub-legacy"); + if write_version_stub(&candidate, "codegraph 0.40.4").is_none() { + return; + } + let bytes = fs::read(&candidate).expect("read the stub bytes"); + let expectation = LegacyExpectation { + executable_sha256: sha256_hex(&bytes), + version_stdout: "codegraph 0.40.4".to_string(), + }; + verify_legacy_binary(&candidate, &expectation) + .expect("a matching digest and version must verify"); +} diff --git a/crates/codegraph-cli/tests/batch_m_long_lived_mcp.rs b/crates/codegraph-cli/tests/batch_m_long_lived_mcp.rs new file mode 100644 index 0000000..84cddb2 --- /dev/null +++ b/crates/codegraph-cli/tests/batch_m_long_lived_mcp.rs @@ -0,0 +1,576 @@ +//! Batch M acceptance item 16 (frozen plan lines 775-778): +//! `long_lived_v2_mcp_releases_handles_per_request`. +//! +//! ONE long-lived SHIPPED MCP process (`codegraph serve --mcp --path `) +//! must complete request 1, release EVERY SQLite handle and index lease it used, +//! permit an atomic replacement of the v2 main database file WITHOUT any +//! participation from the compatibility close seam, and then serve ONLY the +//! replacement graph on the next request. +//! +//! # Determinism: a child-process READY/CONTINUE barrier, never a sleep +//! +//! The barrier is the MCP wire protocol itself, so nothing here infers ordering +//! from elapsed time: +//! +//! 1. **READY** — the parent blocks until the child frames the JSON-RPC response +//! for request 1 on stdout. rmcp writes that frame only after the request's +//! OWNED, fully materialized result was produced, so the arrival of the frame +//! is proof that request 1 completed end-to-end (not merely that its SQL +//! finished). +//! 2. **HANDLES RELEASED** — the parent then acquires the namespace's EXCLUSIVE +//! index lease. A retained reader lease (or any request-scoped `Store` kept +//! alive between requests) makes this acquisition fail, so the exclusive +//! lease is the fail-closed observation that the child let go. +//! 3. **REPLACEMENT** — still holding the exclusive lease, the parent renames a +//! separately built database over the live v2 main database file. On native +//! Windows this rename (`MoveFileEx` + `REPLACE_EXISTING`) FAILS with a +//! sharing violation while any process holds an open handle on the +//! destination, because SQLite opens without `FILE_SHARE_DELETE`. That is the +//! Windows-specific handle proof this item exists for. +//! 4. **CONTINUE** — the parent releases the lease and writes the next request +//! frame. That frame is the continue signal; the child cannot have started it +//! earlier because it had not been written. +//! +//! Channel timeouts (`WAIT`) and the lease deadline are DEADLOCK GUARDS only. No +//! assertion in this file is satisfied by waiting. +//! +//! # The replacement graph cannot come from a reindex +//! +//! `serve --mcp --path` runs a one-shot startup catch-up sync on a detached +//! thread (`spawn_catch_up`), and `--no-watch` does not disable it. If the served +//! project's SOURCES could produce the replacement graph, that background sync +//! could satisfy the post-replacement assertions without the replaced bytes ever +//! being read — and could equally delete the replacement rows again, making the +//! test flaky in both directions. +//! +//! So the two distinguishing source files live under a directory the served +//! project's own root `.gitignore` excludes: +//! +//! - No sync can ever CREATE those rows: `scan_project` never yields the paths. +//! The test asserts this with the shipped scanner before the acceptance runs. +//! - No sync can ever DELETE those rows: the cold sync's removal pass only +//! considers a tracked path that is absent from disk, both files stay present, +//! and the same ignore policy filters them out of `should_handle_file` anyway. +//! +//! What remains scannable is one neutral file that is byte-identical in both +//! graphs, so the startup catch-up is a proven no-op instead of a race. The ONLY +//! way request 2 can observe the replacement file is by reading the database +//! bytes the parent supplied. +//! +//! # Scope +//! +//! Query-side acceptance over the shipped binary. Nothing here is a production +//! change and nothing weakens a state-slot, sidecar, stamp, or lease gate: both +//! databases are produced by the real `codegraph init`, so the target namespace +//! keeps its own valid permanent lock, its own `Current` state slots, and an +//! exact extraction stamp in the replaced bytes. + +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use codegraph_core::IndexPaths; +use codegraph_extract::ExtractOptions; +use codegraph_store::IndexLease; +use serde_json::{Value, json}; + +/// Finite deadlock guard for every blocking wait. Never ordering evidence. +const WAIT: Duration = Duration::from_secs(60); + +/// The scannable file both graphs share, byte-identical. Its only job is to keep +/// the startup catch-up sync a proven no-op instead of a degenerate empty scan. +const NEUTRAL_FILE: &str = "src/pwqneutral.ts"; +/// The gitignored directory holding the two supplied-graph-only sources. Nothing +/// under it is ever scanned in the SERVED project. +const SUPPLIED_DIR: &str = "supplied"; +/// The symbol that exists ONLY in the original graph. +const ORIGINAL_SYMBOL: &str = "qxwoolrig"; +/// The repo-relative file that exists ONLY in the original graph. +const ORIGINAL_FILE: &str = "supplied/mdzzplant.ts"; +/// The symbol that exists ONLY in the replacement graph. +const REPLACEMENT_SYMBOL: &str = "hbtvancur"; +/// The repo-relative file that exists ONLY in the replacement graph. +const REPLACEMENT_FILE: &str = "supplied/kfrnbadge.ts"; + +/// The compatibility close seam this acceptance flow must never touch, spelled +/// in two halves so this file's own bytes are not a match for it. +const CLOSE_SEAM: &str = concat!("close_cached", "_handles"); + +fn bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_codegraph")) +} + +/// A temp directory removed on drop. +struct TestDir(PathBuf); + +impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-m16-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos() + )); + fs::create_dir_all(&path).expect("create temp dir"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +/// Write one source file, creating parents. +fn write_source(root: &Path, rel: &str, contents: &str) { + let path = root.join(rel); + fs::create_dir_all(path.parent().expect("source parent")).expect("create source dir"); + fs::write(&path, contents).expect("write source file"); +} + +fn source_for(symbol: &str) -> String { + format!("export function {symbol}(): number {{\n return 1;\n}}\n") +} + +/// The neutral scannable source, byte-identical in every project here. +fn neutral_source() -> String { + source_for("pwqneutral") +} + +/// Run the SHIPPED `codegraph init` over `project`, producing a real v2 +/// namespace (permanent lock + `Current` state slots + stamped, checkpointed +/// database). Never a hand-authored fixture. +fn shipped_init(project: &Path) { + let output = base_command() + .args(["init"]) + .arg(project) + .output() + .expect("run codegraph init"); + assert!( + output.status.success(), + "codegraph init failed for {}: stdout={} stderr={}", + project.display(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +/// A command with the ambient index-location override cleared, so both the +/// staging build and the served project resolve their own default v2 namespace. +/// The daemon opt-out keeps ONE direct long-lived process (never a proxy to a +/// shared daemon), and the watch opt-out keeps the live watcher off. +fn base_command() -> Command { + let mut command = Command::new(bin()); + command + .env_remove("CODEGRAPH_DIR") + .env("CODEGRAPH_NO_DAEMON", "1") + .env("CODEGRAPH_NO_WATCH", "1"); + command +} + +fn index_paths(project: &Path) -> IndexPaths { + IndexPaths::resolve(project, None).expect("resolve v2 IndexPaths") +} + +/// One long-lived shipped MCP stdio server plus the reader thread that turns its +/// stdout into framed JSON-RPC responses. +struct LongLivedMcp { + child: Child, + stdin: Option, + lines: mpsc::Receiver, +} + +impl LongLivedMcp { + /// Spawn `serve --mcp --path --no-watch` with the daemon disabled, + /// so ONE direct, long-lived process owns the whole session. + fn spawn(project: &Path) -> Self { + let mut child = base_command() + .args(["serve", "--mcp", "--no-watch", "--path"]) + .arg(project) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn long-lived serve --mcp"); + let stdout = child.stdout.take().expect("child stdout"); + let (tx, lines) = mpsc::channel(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + let Ok(line) = line else { return }; + if tx.send(line).is_err() { + return; + } + } + }); + let stdin = child.stdin.take().expect("child stdin"); + Self { + child, + stdin: Some(stdin), + lines, + } + } + + fn send(&mut self, frame: &Value) { + let stdin = self.stdin.as_mut().expect("child stdin still owned"); + writeln!(stdin, "{frame}").expect("write MCP frame"); + stdin.flush().expect("flush MCP frame"); + } + + /// Block until the child frames the response for `id`. The ARRIVAL of this + /// frame is the child's READY acknowledgement: rmcp writes it only after the + /// request's owned result was fully produced. + fn await_response(&self, id: i64) -> Value { + let deadline = Instant::now() + WAIT; + loop { + let remaining = deadline + .checked_duration_since(Instant::now()) + .unwrap_or_default(); + let line = self + .lines + .recv_timeout(remaining) + .unwrap_or_else(|error| panic!("no response for id {id} before deadline: {error}")); + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if let Ok(value) = serde_json::from_str::(trimmed) + && value.get("id").and_then(Value::as_i64) == Some(id) + { + return value; + } + } + } + + /// Close stdin (EOF) and wait for the process to exit within the guard. + fn shutdown(mut self) { + drop(self.stdin.take()); + let deadline = Instant::now() + WAIT; + loop { + match self.child.try_wait().expect("poll child status") { + Some(status) => { + assert!( + status.success(), + "the long-lived MCP process must exit cleanly on stdin EOF: {status:?}" + ); + return; + } + None => { + assert!( + Instant::now() < deadline, + "the long-lived MCP process did not exit before its finite deadline" + ); + std::thread::park_timeout(Duration::from_millis(5)); + } + } + } + } +} + +impl Drop for LongLivedMcp { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn initialize_frames() -> [Value; 2] { + [ + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "m16-acceptance", "version": "0" } + } + }), + json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), + ] +} + +fn search_frame(id: i64, project: &Path, query: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { + "name": "codegraph_search", + "arguments": { + "query": query, + "projectPath": project.to_str().expect("utf8 project path") + } + } + }) +} + +/// The rendered tool text, rejecting a protocol error or an `isError` result so a +/// failed request can never be mistaken for a graph observation. +fn tool_text(response: &Value, context: &str) -> String { + assert!( + response.get("error").is_none(), + "{context} returned a JSON-RPC error: {response}" + ); + assert_ne!( + response["result"]["isError"], + json!(true), + "{context} returned an error result: {response}" + ); + response["result"]["content"][0]["text"] + .as_str() + .unwrap_or_default() + .to_string() +} + +/// Freeze the supplied-graph sources out of every future scan of the SERVED +/// project, then PROVE it with the shipped scanner. +/// +/// This is what makes the acceptance deterministic against the startup catch-up +/// sync: the scanner is the same `scan_project` a sync feeds from, so an empty +/// result for both supplied paths means no sync in this project can invent the +/// replacement rows (or the original ones) from disk. +fn freeze_supplied_sources_out_of_scans(project: &Path) { + fs::write(project.join(".gitignore"), format!("{SUPPLIED_DIR}/\n")) + .expect("write root .gitignore"); + + let scanned = codegraph_extract::engine::scan_project(project, &ExtractOptions::default()) + .expect("scan the served project with the shipped scanner"); + assert!( + scanned.contains(&NEUTRAL_FILE.to_string()), + "the neutral source must stay scannable so startup catch-up is a real no-op: {scanned:?}" + ); + assert!( + !scanned.contains(&REPLACEMENT_FILE.to_string()), + "no sync may be able to index {REPLACEMENT_FILE}, or request 2 could pass by reindexing \ + instead of by reading the replaced database: {scanned:?}" + ); + assert!( + !scanned.contains(&ORIGINAL_FILE.to_string()), + "no sync may be able to re-index {ORIGINAL_FILE} back into the replacement graph: \ + {scanned:?}" + ); + // Both supplied sources stay PRESENT on disk, so the cold sync's removal + // pass (tracked-but-absent) can never delete their rows either. + for rel in [ORIGINAL_FILE, REPLACEMENT_FILE] { + assert!( + project.join(rel).is_file(), + "{rel} must remain on disk so no sync can classify it as removed" + ); + } +} + +/// Build the replacement database in a staging project and return its path. The +/// staging project holds the neutral file plus ONLY the replacement supplied +/// source and carries NO `.gitignore`, so its graph contains the replacement +/// file and cannot contain the original one. +fn build_replacement_database(staging: &TestDir) -> PathBuf { + let project = staging.path().join("replacement-build"); + fs::create_dir_all(&project).expect("create staging project"); + write_source(&project, NEUTRAL_FILE, &neutral_source()); + write_source(&project, REPLACEMENT_FILE, &source_for(REPLACEMENT_SYMBOL)); + shipped_init(&project); + let db = index_paths(&project).current_db(); + assert!( + db.is_file(), + "staging build must produce a database at {}", + db.display() + ); + db +} + +/// Replace the live v2 main database file while the long-lived MCP process is +/// still running. The project's SOURCES are deliberately left untouched. +/// +/// The EXCLUSIVE index lease is acquired first: a retained reader lease from +/// request 1 makes this fail, which is the fail-closed proof that the long-lived +/// process released its lease when the request ended. `fs::rename` is then the +/// atomic replacement — on native Windows it fails outright if any process still +/// holds an open handle on the destination. +fn replace_database(project: &Path, replacement_db: &Path) { + let paths = index_paths(project); + let lease = IndexLease::acquire_exclusive_existing(&paths, Instant::now() + WAIT, || false) + .expect( + "the long-lived MCP process must have released every reader lease once request 1 \ + completed, so the exclusive lease is available for the replacement", + ); + + let target = paths.current_db(); + fs::rename(replacement_db, &target).unwrap_or_else(|error| { + panic!( + "atomically replacing {} must succeed while the long-lived MCP process is running \ + (a retained SQLite handle makes this fail on native Windows): {error}", + target.display() + ) + }); + + // The read path refuses a `Current` namespace whose SQLite sidecars + // reappeared without a state change. Assert that fail-closed contract holds + // for the replaced artifact instead of repairing it. + for suffix in ["-wal", "-shm"] { + let mut sidecar = target.as_os_str().to_os_string(); + sidecar.push(suffix); + let sidecar = PathBuf::from(sidecar); + assert!( + !sidecar.exists(), + "the replaced namespace must stay sidecar-free: {} exists", + sidecar.display() + ); + } + assert!( + paths.permanent_lock().is_file(), + "the replacement must preserve the permanent lock at {}", + paths.permanent_lock().display() + ); + let [slot_zero, slot_one] = paths.state_slots(); + assert!( + slot_zero.is_file() || slot_one.is_file(), + "the replacement must preserve the published Current state slots" + ); + + drop(lease); +} + +#[test] +fn long_lived_v2_mcp_releases_handles_per_request() { + // GIVEN a really indexed v2 project whose graph contains the original + // supplied symbol, with both supplied sources frozen out of every future + // scan, and a separately built replacement database that contains ONLY the + // replacement supplied symbol. + let home = TestDir::new("served"); + let project = home.path().join("project"); + fs::create_dir_all(&project).expect("create served project"); + write_source(&project, NEUTRAL_FILE, &neutral_source()); + write_source(&project, ORIGINAL_FILE, &source_for(ORIGINAL_SYMBOL)); + shipped_init(&project); + + write_source(&project, REPLACEMENT_FILE, &source_for(REPLACEMENT_SYMBOL)); + freeze_supplied_sources_out_of_scans(&project); + + let staging = TestDir::new("staging"); + let replacement_db = build_replacement_database(&staging); + + // ONE long-lived shipped MCP process serves the whole session. + let mut server = LongLivedMcp::spawn(&project); + let [initialize, initialized] = initialize_frames(); + server.send(&initialize); + let init_response = server.await_response(1); + assert_eq!( + init_response["result"]["serverInfo"]["name"], + json!("codegraph"), + "the long-lived process must be the shipped codegraph MCP server: {init_response}" + ); + server.send(&initialized); + + // WHEN request 1 completes (its framed response IS the READY barrier) ... + server.send(&search_frame(2, &project, ORIGINAL_SYMBOL)); + let first = tool_text(&server.await_response(2), "request 1"); + assert!( + first.contains(ORIGINAL_FILE), + "request 1 must observe the original graph: {first}" + ); + assert!( + !first.contains(REPLACEMENT_FILE), + "request 1 must not observe the replacement graph: {first}" + ); + + // ... the parent takes the exclusive lease (handles released) and atomically + // replaces the v2 database. No compatibility seam participates. + replace_database(&project, &replacement_db); + + // THEN the CONTINUE frame (request 2) serves ONLY the replacement graph. + server.send(&search_frame(3, &project, REPLACEMENT_SYMBOL)); + let second = tool_text(&server.await_response(3), "request 2"); + assert!( + second.contains("## Search Results ("), + "request 2 must render real search results, not an echo: {second}" + ); + assert!( + second.contains(REPLACEMENT_FILE), + "request 2 must serve the replacement graph: {second}" + ); + assert!( + !second.contains(ORIGINAL_FILE), + "request 2 must not leak the original graph: {second}" + ); + + // And the original symbol is GONE from the same long-lived session: the + // absence is observed through a lookup that resolves nothing, not through a + // response that merely omits it. + server.send(&search_frame(4, &project, ORIGINAL_SYMBOL)); + let third = tool_text(&server.await_response(4), "request 3"); + assert!( + third.contains("No results found"), + "the original symbol must be absent from the replacement graph: {third}" + ); + assert!( + !third.contains(ORIGINAL_FILE), + "the original file must be absent from the replacement graph: {third}" + ); + + server.shutdown(); +} + +/// The acceptance flow must be independent of the compatibility close seam, so +/// no CODE in this file may name it — not as a call, an import, an alias, or a +/// wrapper. Checked structurally against this file's own bytes: comment lines +/// (which document the seam by name) are excluded, and the needle itself is +/// assembled from two halves so neither this oracle nor its failure message is a +/// match for what it forbids. +#[test] +fn acceptance_flow_never_calls_the_close_helper() { + let offenders = code_lines_naming(include_str!("batch_m_long_lived_mcp.rs"), CLOSE_SEAM); + assert!( + offenders.is_empty(), + "the M16 acceptance flow must not depend on the {CLOSE_SEAM} compatibility seam, but \ + these code lines name it: {offenders:?}" + ); +} + +/// Non-comment lines of `source` that contain `needle`, as `": "`. +/// A line whose first non-whitespace characters are `//` is documentation (this +/// file's own module docs and comments discuss the seam by name), so only real +/// code is inspected. +fn code_lines_naming(source: &str, needle: &str) -> Vec { + source + .lines() + .enumerate() + .filter(|(_, line)| !line.trim_start().starts_with("//")) + .filter(|(_, line)| line.contains(needle)) + .map(|(index, line)| format!("{}: {}", index + 1, line.trim())) + .collect() +} + +#[cfg(test)] +mod oracle_tests { + use super::code_lines_naming; + + /// The forbidden-name oracle must SEE a real invocation and IGNORE a comment + /// that merely mentions the name, so it can neither pass vacuously nor fail + /// on this file's own documentation. + #[test] + fn code_lines_naming_sees_code_and_ignores_comments() { + let needle = concat!("forbidden", "_seam"); + let source = concat!( + "//! docs mention forbidden_seam by name\n", + " // so does an indented comment: forbidden_seam\n", + "let clean = other_call();\n", + " store.forbidden_seam();\n" + ); + let found = code_lines_naming(source, needle); + assert_eq!( + found, + vec!["4: store.forbidden_seam();".to_string()], + "only the real invocation line is an offender" + ); + } +} diff --git a/crates/codegraph-cli/tests/batch_m_outdated_migration.rs b/crates/codegraph-cli/tests/batch_m_outdated_migration.rs new file mode 100644 index 0000000..02266c9 --- /dev/null +++ b/crates/codegraph-cli/tests/batch_m_outdated_migration.rs @@ -0,0 +1,517 @@ +//! Batch M — `incremental_sync_on_outdated_v2_forces_all_files` (plan test 9, +//! frozen plan `upstream-v1.5-portable-fixes.md` lines 557-565 and 750-751). +//! +//! `codegraph sync` must classify the namespace BEFORE mutating a single row. +//! An `Outdated` v2 namespace (built by an older extraction version) therefore +//! cannot be updated file-by-file: the sync escalates, under the SAME retained +//! exclusive lease, to a deterministic full from-source migration that bypasses +//! every mtime/content-hash skip, processes every current candidate in sorted +//! order, drops tracked files that no longer exist, reruns framework extraction / +//! resolution / maintenance, and publishes `phase=current` last. Its five +//! canonical surfaces equal a fresh v2 `index --force`. +//! +//! `Future` and `Corrupt` states are refused with ZERO bytes changed anywhere in +//! the index namespace, and an `uninitialized` namespace stays reserved for an +//! explicit `init`. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, Instant}; + +use codegraph_bench::oracle::{canonicalize_db, diff_canonical}; +use codegraph_core::IndexPaths; +use codegraph_store::{ + CURRENT_EXTRACTION_VERSION, CURRENT_STORAGE_PROTOCOL, ExtractionStatus, Store, checksum_hex, +}; + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("codegraph-cli is under crates/") + .to_path_buf() +} + +fn mini_fixture() -> PathBuf { + workspace_root().join("crates/codegraph-bench/fixtures/mini") +} + +struct TestDir { + path: PathBuf, +} + +impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-batchm-migration-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&path).unwrap(); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +fn copy_tree(src: &Path, dst: &Path) { + fs::create_dir_all(dst).unwrap(); + for entry in fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let from = entry.path(); + let to = dst.join(entry.file_name()); + if from.is_dir() { + copy_tree(&from, &to); + } else { + fs::copy(&from, &to).unwrap(); + } + } +} + +struct Run { + stdout: String, + stderr: String, + ok: bool, +} + +fn cli(args: &[&str]) -> Run { + let output = Command::new(env!("CARGO_BIN_EXE_codegraph")) + .args(args) + .env("CODEGRAPH_NO_DAEMON", "1") + .output() + .expect("run codegraph binary"); + Run { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + ok: output.status.success(), + } +} + +fn deadline() -> Instant { + Instant::now() + .checked_add(Duration::from_secs(10)) + .expect("migration test deadline") +} + +/// Author ONE authoritative fixed state slot with an explicit protocol/version/ +/// phase triple and remove the companion, so classification is unambiguous. The +/// checksum comes from the shipped canonical payload helper, so the staged slot +/// is a genuine protocol record rather than a hand-rolled approximation. +fn stage_state_slot( + paths: &IndexPaths, + sequence: u64, + storage_protocol: u64, + extraction_version: u64, + phase: &str, +) { + let identity = paths.project_identity(); + let checksum = checksum_hex( + sequence, + storage_protocol, + extraction_version, + phase, + identity, + ); + let body = format!( + "{{\"sequence\":{sequence},\"storageProtocol\":{storage_protocol},\ + \"extractionVersion\":{extraction_version},\"phase\":\"{phase}\",\ + \"projectIdentity\":\"{identity}\",\"checksum\":\"{checksum}\"}}\n" + ); + let [slot0, slot1] = paths.state_slots(); + fs::write(&slot0, body).expect("stage authoritative state slot"); + let _ = fs::remove_file(&slot1); +} + +/// Complete no-follow snapshot entry for the refused-sync nonmutation oracle. +#[derive(Debug, PartialEq, Eq)] +enum NamespaceEntry { + Directory, + File(Vec), + Symlink(PathBuf), +} + +/// Fail-closed snapshot of the complete index namespace. Directories are data +/// (an empty directory has no other payload), regular files retain every byte, +/// and aliases retain only their target without following it. Any I/O failure or +/// unsupported entry kind aborts the proof instead of silently disappearing from +/// both sides of the comparison. +fn namespace_snapshot(root: &Path) -> BTreeMap { + fn walk(root: &Path, dir: &Path, out: &mut BTreeMap) { + let mut paths = fs::read_dir(dir) + .unwrap_or_else(|error| panic!("snapshot read_dir {}: {error}", dir.display())) + .map(|entry| { + entry + .unwrap_or_else(|error| panic!("snapshot entry in {}: {error}", dir.display())) + .path() + }) + .collect::>(); + paths.sort(); + for path in paths { + let relative = path + .strip_prefix(root) + .unwrap_or_else(|error| panic!("snapshot strip {}: {error}", path.display())) + .to_path_buf(); + let metadata = fs::symlink_metadata(&path) + .unwrap_or_else(|error| panic!("snapshot metadata {}: {error}", path.display())); + let file_type = metadata.file_type(); + let entry = + if file_type.is_dir() { + NamespaceEntry::Directory + } else if file_type.is_file() { + NamespaceEntry::File(fs::read(&path).unwrap_or_else(|error| { + panic!("snapshot read {}: {error}", path.display()) + })) + } else if file_type.is_symlink() { + NamespaceEntry::Symlink(fs::read_link(&path).unwrap_or_else(|error| { + panic!("snapshot read_link {}: {error}", path.display()) + })) + } else { + panic!("snapshot unsupported entry kind: {}", path.display()); + }; + assert!( + out.insert(relative, entry).is_none(), + "duplicate namespace snapshot path" + ); + if file_type.is_dir() { + walk(root, &path, out); + } + } + } + + let mut out = BTreeMap::new(); + walk(root, root, &mut out); + out +} + +/// Compare snapshots without dumping database bytes into a failure message. +fn assert_namespace_unchanged( + before: &BTreeMap, + after: &BTreeMap, + label: &str, +) { + let mut paths = before + .keys() + .chain(after.keys()) + .cloned() + .collect::>(); + paths.sort(); + paths.dedup(); + let changed = paths + .into_iter() + .filter(|path| before.get(path) != after.get(path)) + .collect::>(); + assert!( + changed.is_empty(), + "a refused {label} sync changed namespace entries: {changed:?}" + ); +} + +/// `Synced: N reindexed, M skipped (unchanged), K removed in …` +fn parse_sync_counters(stdout: &str) -> (usize, usize, usize) { + let line = stdout + .lines() + .find(|line| line.starts_with("Synced: ")) + .unwrap_or_else(|| panic!("sync must print its counters, got: {stdout}")); + let number_before = |needle: &str| -> usize { + let head = line + .split(needle) + .next() + .unwrap_or_else(|| panic!("missing {needle} in {line}")); + head.rsplit(|c: char| !c.is_ascii_digit()) + .find(|token| !token.is_empty()) + .unwrap_or_else(|| panic!("no count before {needle} in {line}")) + .parse() + .unwrap_or_else(|_| panic!("unparsable count before {needle} in {line}")) + }; + ( + number_before(" reindexed"), + number_before(" skipped"), + number_before(" removed"), + ) +} + +fn init_project(label: &str, dir: &TestDir) -> PathBuf { + let project = dir.path().join("mini"); + copy_tree(&mini_fixture(), &project); + let run = cli(&["init", project.to_str().unwrap()]); + assert!( + run.ok, + "setup {label}: `codegraph init` must succeed (stdout={}, stderr={})", + run.stdout, run.stderr + ); + project +} + +/// A fresh, fully-indexed peer of `project` built by `init` + `index --force`, +/// used as the canonical migration oracle. +fn fresh_force_peer(label: &str, project: &Path) -> (TestDir, PathBuf) { + let scratch = TestDir::new(&format!("{label}-scratch")); + let peer = scratch.path().join("mini"); + copy_tree(project, &peer); + let index_root = IndexPaths::resolve(&peer, None) + .expect("resolve peer v2 paths") + .current_root() + .to_path_buf(); + let _ = fs::remove_dir_all(&index_root); + let run = cli(&["init", peer.to_str().unwrap()]); + assert!( + run.ok, + "setup {label}: peer init must succeed: {} {}", + run.stdout, run.stderr + ); + let run = cli(&["index", "--force", peer.to_str().unwrap()]); + assert!( + run.ok, + "setup {label}: peer `index --force` must succeed: {} {}", + run.stdout, run.stderr + ); + (scratch, peer) +} + +/// Guard the nonmutation oracle itself: an equal-length in-place write must make +/// its assertion fail, so the refusal tests cannot regress to a size-only proof. +#[test] +fn namespace_snapshot_detects_equal_length_byte_mutation() { + let dir = TestDir::new("snapshot-self-test"); + let root = dir.path().join("index"); + fs::create_dir(&root).expect("create snapshot root"); + let file = root.join("state.json"); + fs::write(&file, b"AAAA").expect("write original bytes"); + let before = namespace_snapshot(&root); + fs::write(&file, b"BBBB").expect("replace with equal-length bytes"); + let after = namespace_snapshot(&root); + + let detected = std::panic::catch_unwind(|| { + assert_namespace_unchanged(&before, &after, "oracle-self-test") + }); + assert!( + detected.is_err(), + "the namespace nonmutation oracle must reject equal-length byte changes" + ); +} + +/// Plan test 9: an `Outdated` namespace forces EVERY file through migration — +/// zero unchanged skips even though every source byte is identical to the +/// outdated database — and the result equals a fresh v2 `index --force`. +#[test] +fn incremental_sync_on_outdated_v2_forces_all_files() { + let dir = TestDir::new("outdated-forces-all"); + let project = init_project("outdated-forces-all", &dir); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + + // Every source file is byte-identical to what the index already holds, so an + // unguarded incremental sync would skip all of them on the hash gate. + let tracked = { + let store = Store::open_for_read(&paths, deadline(), || false) + .expect("the initialized namespace is readable"); + store + .all_files() + .expect("tracked files") + .into_iter() + .map(|file| file.path) + .collect::>() + }; + assert!( + tracked.len() >= 2, + "the mini fixture must track several files, got {tracked:?}" + ); + + // The namespace was built by an OLDER extraction version. + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Outdated { + built: CURRENT_EXTRACTION_VERSION - 1, + }, + "setup: the staged slot must classify Outdated" + ); + + let run = cli(&["sync", project.to_str().unwrap()]); + assert!( + run.ok, + "sync must migrate an Outdated namespace: stdout={}, stderr={}", + run.stdout, run.stderr + ); + + let (reindexed, skipped, _removed) = parse_sync_counters(&run.stdout); + assert_eq!( + skipped, 0, + "an Outdated namespace must bypass every mtime/content-hash skip \ + (plan lines 557-565); got {skipped} unchanged skips in: {}", + run.stdout + ); + assert_eq!( + reindexed, + tracked.len(), + "migration must process EVERY current candidate, got {reindexed} of {} in: {}", + tracked.len(), + run.stdout + ); + + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Current, + "migration must publish phase=current as its last step" + ); + Store::open_for_read(&paths, deadline(), || false) + .expect("a migrated namespace must be readable through the state gate"); + + let (_scratch, peer) = fresh_force_peer("outdated-forces-all", &project); + let migrated = canonicalize_db(&paths.current_db()).expect("canonicalize migrated db"); + let rebuilt = canonicalize_db( + &IndexPaths::resolve(&peer, None) + .expect("resolve peer v2 paths") + .current_db(), + ) + .expect("canonicalize peer db"); + diff_canonical(&rebuilt, &migrated, None) + .expect("a forced migration must equal a fresh v2 `index --force`"); +} + +/// Migration is a from-source rebuild, so a tracked file that no longer exists +/// on disk disappears from the migrated index and the result still equals a +/// fresh `index --force` over the surviving tree. +#[test] +fn outdated_migration_drops_absent_tracked_files() { + let dir = TestDir::new("outdated-drops-absent"); + let project = init_project("outdated-drops-absent", &dir); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + + let removed_source = project.join("src/math.ts"); + assert!( + removed_source.is_file(), + "setup: the mini fixture must contain src/math.ts" + ); + fs::remove_file(&removed_source).expect("delete a tracked source file"); + + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ); + + let run = cli(&["sync", project.to_str().unwrap()]); + assert!( + run.ok, + "sync must migrate an Outdated namespace: stdout={}, stderr={}", + run.stdout, run.stderr + ); + + let store = Store::open_for_read(&paths, deadline(), || false) + .expect("a migrated namespace must be readable"); + assert!( + store + .file_by_path("src/math.ts") + .expect("query tracked file") + .is_none(), + "migration must drop tracked files that no longer exist on disk" + ); + drop(store); + + let (_scratch, peer) = fresh_force_peer("outdated-drops-absent", &project); + let migrated = canonicalize_db(&paths.current_db()).expect("canonicalize migrated db"); + let rebuilt = canonicalize_db( + &IndexPaths::resolve(&peer, None) + .expect("resolve peer v2 paths") + .current_db(), + ) + .expect("canonicalize peer db"); + diff_canonical(&rebuilt, &migrated, None) + .expect("a migration that dropped an absent file must equal a fresh `index --force`"); +} + +/// A `Future` or `Corrupt` namespace is refused by the sync writer gate before +/// any row mutation, leaving every byte in the namespace unchanged. +#[test] +fn sync_refuses_future_and_corrupt_state_without_mutation() { + for label in ["future", "corrupt"] { + let dir = TestDir::new(&format!("refuse-{label}")); + let project = init_project(label, &dir); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + + if label == "future" { + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION + 1, + "current", + ); + } else { + let [slot0, slot1] = paths.state_slots(); + fs::write(&slot0, b"{ not a state record").expect("stage a corrupt slot"); + let _ = fs::remove_file(&slot1); + } + let status = Store::extraction_status(&paths); + assert!( + matches!( + status, + ExtractionStatus::Future { .. } | ExtractionStatus::Corrupt { .. } + ), + "setup {label}: staged slot must classify Future/Corrupt, got {status:?}" + ); + + let before = namespace_snapshot(paths.current_root()); + let run = cli(&["sync", project.to_str().unwrap()]); + assert!( + !run.ok, + "sync must refuse a {label} namespace: stdout={}, stderr={}", + run.stdout, run.stderr + ); + let after = namespace_snapshot(paths.current_root()); + assert_namespace_unchanged(&before, &after, label); + } +} + +/// An interrupted-`uninit` namespace stays reserved for an explicit `init`: a +/// sync neither continues it nor mutates it. +#[test] +fn sync_refuses_uninitialized_namespace_without_mutation() { + let dir = TestDir::new("refuse-uninit"); + let project = init_project("refuse-uninit", &dir); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION, + "uninitialized", + ); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Uninitialized, + "setup: the staged slot must classify Uninitialized" + ); + + let before = namespace_snapshot(paths.current_root()); + let run = cli(&["sync", project.to_str().unwrap()]); + assert!( + !run.ok, + "sync must refuse an uninitialized namespace: stdout={}, stderr={}", + run.stdout, run.stderr + ); + let after = namespace_snapshot(paths.current_root()); + assert_namespace_unchanged(&before, &after, "uninitialized"); +} diff --git a/crates/codegraph-cli/tests/batch_m_uninit.rs b/crates/codegraph-cli/tests/batch_m_uninit.rs new file mode 100644 index 0000000..122d102 --- /dev/null +++ b/crates/codegraph-cli/tests/batch_m_uninit.rs @@ -0,0 +1,565 @@ +//! Batch M — interrupted `uninit --force` lifecycle acceptance. +//! +//! Revision 14 requires public uninit to publish an authoritative +//! `phase=uninitialized` slot before deleting any v2 residue, preserve the +//! permanent lock and both fixed state slots, and leave only explicit `init` or +//! another `uninit --force` continuation authorized. The store-owned fault +//! matrix for every private mutation boundary lives in `codegraph-store`. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, Instant}; + +use codegraph_core::IndexPaths; +use codegraph_store::{ + CURRENT_EXTRACTION_VERSION, CURRENT_STORAGE_PROTOCOL, ExtractionStatus, IndexLease, StatePhase, + Store, checksum_hex, classify, publish_index_state, +}; + +fn bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_codegraph")) +} + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("codegraph-cli is under crates/") + .to_path_buf() +} + +fn mini_fixture() -> PathBuf { + workspace_root().join("crates/codegraph-bench/fixtures/mini") +} + +struct TestDir(PathBuf); + +impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-batchm-uninit-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock after epoch") + .as_nanos() + )); + std::fs::create_dir_all(&path).expect("create uninit test directory"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn copy_tree(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).expect("create fixture destination"); + for entry in std::fs::read_dir(src).expect("read fixture directory") { + let entry = entry.expect("read fixture entry"); + let from = entry.path(); + let to = dst.join(entry.file_name()); + if from.is_dir() { + copy_tree(&from, &to); + } else { + std::fs::copy(&from, &to).expect("copy fixture file"); + } + } +} + +struct Run { + stdout: String, + stderr: String, + ok: bool, +} + +fn run_in(registry_dir: &Path, args: &[&str]) -> Run { + run_in_with_config(registry_dir, args, None) +} + +fn run_in_with_config(registry_dir: &Path, args: &[&str], codegraph_dir: Option<&Path>) -> Run { + let mut command = Command::new(bin()); + command + .current_dir(registry_dir) + .args(args) + .env("CODEGRAPH_HTTP_REGISTRY_DIR", registry_dir) + .env("CODEGRAPH_NO_DAEMON", "1"); + if let Some(configured) = codegraph_dir { + command.env("CODEGRAPH_DIR", configured); + } else { + command.env_remove("CODEGRAPH_DIR"); + } + let output = command.output().expect("run codegraph binary"); + Run { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + ok: output.status.success(), + } +} + +fn db_sidecar(db: &Path, suffix: &str) -> PathBuf { + let mut native = db.as_os_str().to_os_string(); + native.push(suffix); + PathBuf::from(native) +} + +#[derive(Debug, PartialEq, Eq)] +enum NamespaceEntry { + Directory, + File(Vec), + Symlink(PathBuf), +} + +fn namespace_snapshot(root: &Path) -> BTreeMap { + fn walk(root: &Path, directory: &Path, out: &mut BTreeMap) { + let mut children = std::fs::read_dir(directory) + .unwrap_or_else(|error| panic!("snapshot read_dir {}: {error}", directory.display())) + .map(|entry| { + entry + .unwrap_or_else(|error| { + panic!("snapshot entry in {}: {error}", directory.display()) + }) + .path() + }) + .collect::>(); + children.sort(); + for path in children { + let relative = path + .strip_prefix(root) + .unwrap_or_else(|error| panic!("snapshot strip {}: {error}", path.display())) + .to_path_buf(); + let metadata = std::fs::symlink_metadata(&path) + .unwrap_or_else(|error| panic!("snapshot metadata {}: {error}", path.display())); + let ty = metadata.file_type(); + let entry = + if ty.is_dir() { + NamespaceEntry::Directory + } else if ty.is_file() { + NamespaceEntry::File(std::fs::read(&path).unwrap_or_else(|error| { + panic!("snapshot read {}: {error}", path.display()) + })) + } else if ty.is_symlink() { + NamespaceEntry::Symlink(std::fs::read_link(&path).unwrap_or_else(|error| { + panic!("snapshot read_link {}: {error}", path.display()) + })) + } else { + panic!("snapshot unsupported entry kind: {}", path.display()); + }; + assert!( + out.insert(relative, entry).is_none(), + "duplicate snapshot path" + ); + if ty.is_dir() { + walk(root, &path, out); + } + } + } + + let mut out = BTreeMap::new(); + walk(root, root, &mut out); + out +} + +fn assert_namespace_unchanged( + before: &BTreeMap, + after: &BTreeMap, + label: &str, +) { + let mut all_paths = before + .keys() + .chain(after.keys()) + .cloned() + .collect::>(); + all_paths.sort(); + all_paths.dedup(); + let changed = all_paths + .into_iter() + .filter(|path| before.get(path) != after.get(path)) + .collect::>(); + assert!( + changed.is_empty(), + "{label} changed namespace entries: {changed:?}" + ); +} + +fn deadline() -> Instant { + Instant::now() + Duration::from_secs(10) +} + +fn assert_legacy_bytes(legacy_file: &Path, expected: &[u8]) { + assert_eq!( + std::fs::read(legacy_file).expect("read untouched legacy proof"), + expected, + "v2 lifecycle operations must not mutate the legacy namespace" + ); +} + +/// Public behavioral Red for acceptance item 11: successful uninit must leave a +/// recoverable authenticated lifecycle namespace, not erase its state root. +#[test] +fn interrupted_uninit_state_slot_is_recoverable_not_corrupt() { + let dir = TestDir::new("public-red"); + let project = dir.path().join("mini"); + copy_tree(&mini_fixture(), &project); + let project_arg = project.to_str().expect("UTF-8 test project path"); + + let init = run_in(dir.path(), &["init", project_arg]); + assert!( + init.ok, + "setup: init must succeed before the lifecycle assertion: stdout={}, stderr={}", + init.stdout, init.stderr + ); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + let legacy = project.join(".codegraph"); + std::fs::create_dir(&legacy).expect("create independent legacy namespace"); + let legacy_file = legacy.join("legacy.bin"); + let legacy_bytes = b"legacy bytes must remain unchanged"; + std::fs::write(&legacy_file, legacy_bytes).expect("write legacy proof bytes"); + + for (path, bytes) in [ + ( + db_sidecar(&paths.current_db(), "-wal"), + b"wal residue".as_slice(), + ), + ( + db_sidecar(&paths.current_db(), "-shm"), + b"shm residue".as_slice(), + ), + (paths.config_toml(), b"config residue".as_slice()), + (paths.extension_config(), b"extension residue".as_slice()), + (paths.daemon_pid(), b"pid residue".as_slice()), + (paths.daemon_log(), b"log residue".as_slice()), + (paths.daemon_socket(), b"socket residue".as_slice()), + ] { + std::fs::write(path, bytes).expect("stage removable v2 residue"); + } + + let uninit = run_in(dir.path(), &["uninit", "--force", project_arg]); + assert!( + uninit.ok, + "uninit --force must succeed: stdout={}, stderr={}", + uninit.stdout, uninit.stderr + ); + + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Uninitialized, + "uninit --force must durably publish phase=uninitialized before cleanup; deleting the whole v2 root loses the authenticated recovery state" + ); + assert!( + paths.permanent_lock().is_file(), + "uninit must preserve the permanent lock" + ); + assert!( + paths.tombstone().is_file(), + "uninit must ensure the tombstone" + ); + assert!( + paths.state_slots().iter().all(|slot| slot.is_file()), + "uninit must preserve both fixed state slots" + ); + assert!( + !paths.current_db().exists(), + "successful uninit cleanup must remove the v2 database" + ); + for path in [ + db_sidecar(&paths.current_db(), "-wal"), + db_sidecar(&paths.current_db(), "-shm"), + paths.config_toml(), + paths.extension_config(), + paths.daemon_pid(), + paths.daemon_log(), + paths.daemon_socket(), + ] { + assert!( + !path.exists(), + "successful uninit must remove v2 lifecycle residue: {}", + path.display() + ); + } + assert_legacy_bytes(&legacy_file, legacy_bytes); + + let status = run_in(dir.path(), &["status", "--json", project_arg]); + assert!( + status.ok, + "status must report interrupted uninit: stdout={}, stderr={}", + status.stdout, status.stderr + ); + let status_json: serde_json::Value = + serde_json::from_str(status.stdout.trim()).expect("status emits JSON"); + assert_eq!(status_json["initialized"], false); + assert_eq!(status_json["extractionStatus"], "uninitialized"); + assert_eq!(status_json["legacyIndexPresent"], true); + + for args in [ + vec!["sync", project_arg], + vec!["index", "--force", project_arg], + vec!["index", project_arg], + vec!["query", "Counter", "--path", project_arg], + ] { + let before_rejection = namespace_snapshot(paths.current_root()); + let rejected = run_in(dir.path(), &args); + assert!( + !rejected.ok, + "only init or repeated uninit may proceed after interrupted uninit; args={args:?}, stdout={}, stderr={}", + rejected.stdout, rejected.stderr + ); + assert_namespace_unchanged( + &before_rejection, + &namespace_snapshot(paths.current_root()), + &format!("rejected command {args:?}"), + ); + assert_legacy_bytes(&legacy_file, legacy_bytes); + } + + let first_sequence = classify(&paths) + .authoritative() + .expect("uninit has authoritative slot") + .record + .sequence; + let continuation = run_in(dir.path(), &["uninit", "--force", project_arg]); + assert!( + continuation.ok, + "repeated uninit must continue cleanup: stdout={}, stderr={}", + continuation.stdout, continuation.stderr + ); + let continued = classify(&paths); + assert_eq!(continued.status(), &ExtractionStatus::Uninitialized); + assert_eq!( + continued + .authoritative() + .expect("continued uninit has authoritative slot") + .record + .sequence, + first_sequence + 1, + "continued uninit must publish a monotonic Uninitialized sequence" + ); + assert_legacy_bytes(&legacy_file, legacy_bytes); + + let recovery = run_in(dir.path(), &["init", project_arg]); + assert!( + recovery.ok, + "explicit init must recover interrupted uninit: stdout={}, stderr={}", + recovery.stdout, recovery.stderr + ); + assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Current); + assert!( + paths.current_db().is_file(), + "init recovery rebuilds the DB" + ); + assert!( + !paths.tombstone().exists(), + "only successful explicit init recovery removes the tombstone" + ); + assert_legacy_bytes(&legacy_file, legacy_bytes); +} + +#[test] +fn namespace_snapshot_detects_equal_length_byte_mutation() { + let dir = TestDir::new("snapshot-self-test"); + let root = dir.path().join("namespace"); + std::fs::create_dir(&root).expect("create snapshot root"); + let file = root.join("state.json"); + std::fs::write(&file, b"AAAA").expect("write original bytes"); + let before = namespace_snapshot(&root); + std::fs::write(&file, b"BBBB").expect("write equal-length replacement"); + let after = namespace_snapshot(&root); + assert!( + std::panic::catch_unwind(|| { + assert_namespace_unchanged(&before, &after, "snapshot self-test") + }) + .is_err(), + "namespace oracle must detect equal-length byte mutation" + ); +} + +#[test] +fn absolute_configured_root_requires_explicit_project_for_nested_uninit() { + let dir = TestDir::new("absolute-configured-root"); + let project = dir.path().join("mini"); + copy_tree(&mini_fixture(), &project); + let nested = project.join("src/nested"); + std::fs::create_dir_all(&nested).expect("create nested invocation directory"); + let configured = dir.path().join("shared-index"); + let project_arg = project.to_str().expect("UTF-8 project path"); + + let init = run_in_with_config(dir.path(), &["init", project_arg], Some(&configured)); + assert!( + init.ok, + "configured init failed: {} {}", + init.stdout, init.stderr + ); + let paths = + IndexPaths::resolve(&project, configured.to_str()).expect("resolve configured root"); + let before = namespace_snapshot(paths.current_root()); + + let implicit = run_in_with_config(&nested, &["uninit", "--force"], Some(&configured)); + assert!(!implicit.ok, "nested implicit uninit must fail"); + assert!( + implicit.stderr.contains("pass the project root explicitly"), + "stable remedy missing: {}", + implicit.stderr + ); + assert_namespace_unchanged( + &before, + &namespace_snapshot(paths.current_root()), + "nested implicit absolute-root uninit refusal", + ); + + let explicit = run_in_with_config( + &nested, + &["uninit", "--force", project_arg], + Some(&configured), + ); + assert!( + explicit.ok, + "explicit project-root uninit must succeed: {} {}", + explicit.stdout, explicit.stderr + ); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Uninitialized + ); +} + +fn stage_lifecycle_state(paths: &IndexPaths, status: &str) { + let lease = IndexLease::create_exclusive(paths, deadline(), || false) + .expect("create lifecycle namespace"); + match status { + "building" => { + publish_index_state(paths, &lease, StatePhase::Building).expect("publish Building"); + } + "outdated" | "future" | "corrupt" => { + let (storage_protocol, extraction_version, phase) = match status { + "outdated" => ( + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ), + "future" => ( + CURRENT_STORAGE_PROTOCOL + 1, + CURRENT_EXTRACTION_VERSION + 1, + "current", + ), + "corrupt" => { + std::fs::write(&paths.state_slots()[0], b"not-json") + .expect("write corrupt slot"); + drop(lease); + return; + } + _ => unreachable!(), + }; + let checksum = checksum_hex( + 0, + storage_protocol, + extraction_version, + phase, + paths.project_identity(), + ); + let body = serde_json::json!({ + "sequence": 0, + "storageProtocol": storage_protocol, + "extractionVersion": extraction_version, + "phase": phase, + "projectIdentity": paths.project_identity(), + "checksum": checksum, + }); + std::fs::write( + &paths.state_slots()[0], + serde_json::to_vec(&body).expect("serialize lifecycle slot"), + ) + .expect("write lifecycle slot"); + } + _ => unreachable!(), + } + drop(lease); +} + +#[test] +fn nested_status_discovers_authenticated_non_current_parent_states() { + for expected in ["building", "outdated", "future", "corrupt"] { + let dir = TestDir::new(expected); + let project = dir.path().join("project"); + let nested = project.join("a/b"); + std::fs::create_dir_all(&nested).expect("create nested project directory"); + let paths = IndexPaths::resolve(&project, None).expect("resolve lifecycle paths"); + stage_lifecycle_state(&paths, expected); + + let status = run_in(&nested, &["status", "--json"]); + assert!( + status.ok, + "nested {expected} status failed: {} {}", + status.stdout, status.stderr + ); + let value: serde_json::Value = + serde_json::from_str(status.stdout.trim()).expect("parse status JSON"); + assert_eq!(value["projectPath"], project.to_string_lossy().as_ref()); + assert_eq!(value["extractionStatus"], expected); + assert_eq!(value["initialized"], false); + } +} + +#[test] +fn nested_status_discovers_interrupted_uninit_with_relative_configured_root() { + let dir = TestDir::new("nested-relative-uninit"); + let project = dir.path().join("mini"); + copy_tree(&mini_fixture(), &project); + let nested = project.join("src/nested"); + std::fs::create_dir_all(&nested).expect("create nested invocation directory"); + let configured = Path::new("cache/index"); + let project_arg = project.to_str().expect("UTF-8 project path"); + let init = run_in_with_config(dir.path(), &["init", project_arg], Some(configured)); + assert!(init.ok, "relative configured init failed: {}", init.stderr); + let uninit = run_in_with_config( + dir.path(), + &["uninit", "--force", project_arg], + Some(configured), + ); + assert!( + uninit.ok, + "relative configured uninit failed: {}", + uninit.stderr + ); + + let status = run_in_with_config(&nested, &["status", "--json"], Some(configured)); + assert!( + status.ok, + "nested Uninitialized status failed: {}", + status.stderr + ); + let value: serde_json::Value = + serde_json::from_str(status.stdout.trim()).expect("parse status JSON"); + assert_eq!(value["projectPath"], project.to_string_lossy().as_ref()); + assert_eq!(value["extractionStatus"], "uninitialized"); + assert_eq!(value["initialized"], false); +} + +#[test] +fn lock_only_parent_is_not_a_status_discovery_marker() { + let dir = TestDir::new("lock-only"); + let project = dir.path().join("project"); + // NOT `join("a/b")`: `Path::join` keeps an embedded `/` verbatim, so on + // Windows that literal yields a MIXED `…\project\a/b`, while the CLI re-emits + // this path through `normalize_lexical` as `…\project\a\b`. The assertion + // below compares the reported `projectPath` to this value, so it must be + // built in the same native-separator domain the CLI prints. + let nested = project.join("a").join("b"); + std::fs::create_dir_all(&nested).expect("create nested project directory"); + let paths = IndexPaths::resolve(&project, None).expect("resolve lifecycle paths"); + std::fs::create_dir(paths.current_root()).expect("create current root"); + std::fs::write(paths.permanent_lock(), b"").expect("write lock-only fixture"); + + let status = run_in(&nested, &["status", "--json"]); + assert!(status.ok, "lock-only status failed: {}", status.stderr); + let value: serde_json::Value = + serde_json::from_str(status.stdout.trim()).expect("parse status JSON"); + assert_eq!(value["projectPath"], nested.to_string_lossy().as_ref()); + assert_eq!(value["extractionStatus"], "missing"); +} diff --git a/crates/codegraph-cli/tests/batch_m_v2_namespace.rs b/crates/codegraph-cli/tests/batch_m_v2_namespace.rs new file mode 100644 index 0000000..4e901a8 --- /dev/null +++ b/crates/codegraph-cli/tests/batch_m_v2_namespace.rs @@ -0,0 +1,756 @@ +//! Batch M — initial black-box Red for the isolated v2 index namespace. +//! +//! Frozen plan `upstream-v1.5-portable-fixes.md` (Batch M, "Product boundary and +//! selected storage layout", plan line ~262) makes an explicit product-level +//! compatibility break: the default *current* index root is +//! `project/.codegraph-v2`, a **sibling** of the fixed legacy `project/.codegraph` +//! root, and new binaries never open, migrate, or write the legacy namespace +//! (plan lines 288-291). Acceptance for the Red boundary (plan lines 805-817) +//! requires black-box evidence that current v0.40.4 behavior "opens/writes the +//! fixed legacy `.codegraph` namespace instead of an isolated `.codegraph-v2` +//! namespace". +//! +//! This is the INITIAL black-box Red described at plan lines 805-809: it uses +//! ONLY the shipped public CLI surface (`codegraph init`) and filesystem +//! artifacts. It imports no proposed Green type (`IndexPaths`, `IndexLease`, +//! `open_for_*`) — those are Green design, not compile-time Red prerequisites. +//! It is NOT the later API-level refinement of the store/lease/path modes. +//! +//! Isolation mirrors `cli_commands.rs`: a private temp project plus an isolated +//! `CODEGRAPH_HTTP_REGISTRY_DIR`, and `CODEGRAPH_NO_DAEMON=1` so no daemon +//! rendezvous state leaks. The default `init` target is `none`, so no agent +//! config is written. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_codegraph")) +} + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("codegraph-cli is under crates/") + .to_path_buf() +} + +fn mini_fixture() -> PathBuf { + workspace_root().join("crates/codegraph-bench/fixtures/mini") +} + +struct TestDir { + path: PathBuf, +} + +impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-batchm-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&path).unwrap(); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +fn copy_tree(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let from = entry.path(); + let to = dst.join(entry.file_name()); + if from.is_dir() { + copy_tree(&from, &to); + } else { + std::fs::copy(&from, &to).unwrap(); + } + } +} + +struct Run { + stdout: String, + stderr: String, + ok: bool, +} + +fn run_in(registry_dir: &Path, args: &[&str]) -> Run { + run_in_env(registry_dir, args, &[]) +} + +fn run_in_env(registry_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Run { + let mut cmd = Command::new(bin()); + cmd.args(args) + .env("CODEGRAPH_HTTP_REGISTRY_DIR", registry_dir) + .env("CODEGRAPH_NO_DAEMON", "1"); + for (k, v) in envs { + cmd.env(k, v); + } + let output = cmd.output().expect("run codegraph binary"); + Run { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + ok: output.status.success(), + } +} + +fn indexed_names(project: &Path) -> Vec { + std::fs::read_dir(project) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect() +} + +/// Initial Batch M black-box Red: `init` must build the index in the isolated +/// sibling `.codegraph-v2` namespace, never the fixed legacy `.codegraph` one. +/// +/// Expected Red on the v0.40.4 base: `init` writes `.codegraph/codegraph.db` +/// (the fixed legacy root) and never creates `.codegraph-v2`, so the named +/// v2-namespace assertion fails behaviorally. The setup (`init` succeeds) and +/// the byte snapshot below both reach the assertion, so this is a behavioral — +/// not a compile/setup/panic — failure. The snapshot preserves the built DB +/// bytes that later Green will assert remain a byte-usable legacy graph. +#[test] +fn init_writes_isolated_v2_namespace_not_legacy_codegraph() { + let dir = TestDir::new("v2-namespace"); + let project = dir.path().join("mini"); + copy_tree(&mini_fixture(), &project); + let p = project.to_str().unwrap(); + + // Public shipped surface only. + let run = run_in(dir.path(), &["init", p]); + assert!( + run.ok, + "setup: `codegraph init` must succeed before the behavioral assertion \ + (stdout={}, stderr={})", + run.stdout, run.stderr + ); + + let legacy_root = project.join(".codegraph"); + let legacy_db = legacy_root.join("codegraph.db"); + let v2_root = project.join(".codegraph-v2"); + let v2_db = v2_root.join("codegraph.db"); + + // Byte snapshot of the DB the build actually produced (whichever namespace + // it landed in). Later Green preserves this as the byte-usable legacy graph; + // recording it here proves the build produced a non-empty DB and that the + // failure below is a namespace-placement failure, not an empty/failed build. + let built_db = if v2_db.is_file() { + v2_db.clone() + } else { + legacy_db.clone() + }; + let built_bytes = std::fs::read(&built_db).unwrap_or_default(); + assert!( + !built_bytes.is_empty(), + "setup: `init` must produce a non-empty index DB (checked {}); \ + got legacy_db.is_file()={}, v2_db.is_file()={}", + built_db.display(), + legacy_db.is_file(), + v2_db.is_file() + ); + + // PRIMARY behavioral Red assertion (plan line 262 + lines 805-817): the DB + // must live in the isolated sibling v2 namespace, not the legacy root. + assert!( + v2_db.is_file(), + "Batch M: `init` must create the isolated v2 namespace at {} \ + (a sibling of the legacy root, per plan line 262), but no v2 DB exists; \ + current v0.40.4 behavior wrote the legacy namespace instead \ + (.codegraph/codegraph.db present={})", + v2_db.display(), + legacy_db.is_file() + ); + + // Secondary behavioral Red assertion (plan lines 288-291): new binaries must + // never write the fixed legacy namespace. Reached only once the v2 assertion + // passes at Green; both are Green-stable (legacy root absent post-Green). + assert!( + !legacy_db.is_file(), + "Batch M: a new binary must not write the legacy namespace at {}; \ + the fixed legacy `.codegraph` root is read-only to new binaries", + legacy_db.display() + ); +} + +/// The v2 current root for a relative `CODEGRAPH_DIR` must be the fail-closed +/// `IndexPaths::resolve` identity-suffixed SIBLING `-v2-` +/// through the REAL CLI, never the old `/` simple-join. Proven +/// by `status --json`'s `indexPath` and the on-disk DB placement after `init`. +#[test] +fn configured_relative_root_uses_identity_suffixed_sibling_via_cli() { + let dir = TestDir::new("cfg-rel"); + let project = dir.path().join("mini"); + copy_tree(&mini_fixture(), &project); + let p = project.to_str().unwrap(); + + let run = run_in_env(dir.path(), &["init", p], &[("CODEGRAPH_DIR", "cache")]); + assert!(run.ok, "init must succeed: {} {}", run.stdout, run.stderr); + + let names = indexed_names(&project); + // The simple-join `/cache/codegraph.db` MUST NOT exist. + assert!( + !project.join("cache").join("codegraph.db").is_file(), + "configured root must NOT use the old simple-join `cache/`: dir={names:?}" + ); + // Exactly one identity-suffixed sibling `cache-v2-<64hex>` holds the DB. + let sibling = names + .iter() + .find(|n| n.starts_with("cache-v2-")) + .unwrap_or_else(|| panic!("expected a `cache-v2-` sibling, got {names:?}")); + assert_eq!( + sibling.len(), + "cache-v2-".len() + 64, + "sibling must carry a full 64-hex projectIdentity: {sibling}" + ); + assert!( + project.join(sibling).join("codegraph.db").is_file(), + "the identity-suffixed sibling must hold the DB: {names:?}" + ); + + let status = run_in_env( + dir.path(), + &["status", "--json", p], + &[("CODEGRAPH_DIR", "cache")], + ); + assert!(status.ok, "status must succeed: {}", status.stderr); + assert!( + status.stdout.contains(sibling.as_str()), + "status indexPath must name the identity sibling {sibling}: {}", + status.stdout + ); +} + +/// Two DISTINCT physical projects given the SAME absolute `CODEGRAPH_DIR` must +/// receive DISTINCT identity-suffixed current roots through the REAL CLI, so one +/// project can never open the other's index. +#[test] +fn two_projects_sharing_absolute_configured_root_get_distinct_roots_via_cli() { + let dir = TestDir::new("cfg-abs"); + let project_a = dir.path().join("a/mini"); + let project_b = dir.path().join("b/mini"); + copy_tree(&mini_fixture(), &project_a); + copy_tree(&mini_fixture(), &project_b); + let shared = dir.path().join("shared/cg"); + std::fs::create_dir_all(dir.path().join("shared")).unwrap(); + let shared_str = shared.to_str().unwrap(); + + let ra = run_in_env( + dir.path(), + &["init", project_a.to_str().unwrap()], + &[("CODEGRAPH_DIR", shared_str)], + ); + assert!(ra.ok, "init a must succeed: {} {}", ra.stdout, ra.stderr); + let rb = run_in_env( + dir.path(), + &["init", project_b.to_str().unwrap()], + &[("CODEGRAPH_DIR", shared_str)], + ); + assert!(rb.ok, "init b must succeed: {} {}", rb.stdout, rb.stderr); + + let parent = dir.path().join("shared"); + let siblings: Vec = std::fs::read_dir(&parent) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with("cg-v2-")) + .collect(); + assert_eq!( + siblings.len(), + 2, + "two projects must produce two distinct identity siblings, got {siblings:?}" + ); + assert_ne!( + siblings[0], siblings[1], + "the two current roots must differ" + ); + // The shared simple-join `shared/cg/codegraph.db` must never exist. + assert!( + !shared.join("codegraph.db").is_file(), + "configured absolute root must NOT collapse two projects onto one simple-join DB" + ); +} + +/// A `CODEGRAPH_DIR` that aliases the project root (`.`) must fail closed +/// through the REAL CLI, creating NO `/codegraph.db` and NO legacy +/// mutation — the fail-closed `resolve` contract, not the old simple-join. +#[test] +fn configured_dot_alias_fails_closed_without_mutation_via_cli() { + let dir = TestDir::new("cfg-dot"); + let project = dir.path().join("mini"); + copy_tree(&mini_fixture(), &project); + let p = project.to_str().unwrap(); + + let run = run_in_env(dir.path(), &["init", p], &[("CODEGRAPH_DIR", ".")]); + assert!( + !run.ok, + "init with CODEGRAPH_DIR=. must fail closed: stdout={} stderr={}", + run.stdout, run.stderr + ); + assert!( + !project.join("codegraph.db").is_file(), + "a `.` alias must not write `/codegraph.db`" + ); + assert!( + !project.join(".codegraph").join("codegraph.db").is_file(), + "a `.` alias must not mutate the legacy namespace" + ); +} + +/// The exact, deterministic representation of ONE filesystem entry in the +/// nonmutation oracle. Every supported entry kind carries its complete payload, +/// so equality of two snapshots is real evidence — not a proxy: +/// +/// - [`EntryKind::Directory`] — presence itself is the payload, so creating or +/// removing an EMPTY directory is detectable (a file-only snapshot misses it). +/// - [`EntryKind::RegularFile`] — the COMPLETE bytes, never the length: an +/// equal-length in-place write keeps the size identical, so size equality is +/// NOT evidence of byte identity. +/// - [`EntryKind::Symlink`] — the link TARGET, read with `read_link`; the link +/// is never followed, so the oracle neither reads through it nor mistakes a +/// change of the pointed-to file for a mutation of this tree. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum EntryKind { + Directory, + RegularFile(Vec), + Symlink(PathBuf), +} + +/// One snapshot entry: the OS-native relative path (a [`PathBuf`], so the +/// equality key is never a lossy `to_string_lossy` rendering) plus its exact +/// payload. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct TreeEntry { + rel: PathBuf, + kind: EntryKind, +} + +/// A bounded, byte-free label for a failure message: names the KIND (and a +/// length or link target), never the file contents. +fn kind_label(kind: &EntryKind) -> String { + match kind { + EntryKind::Directory => "directory".to_string(), + EntryKind::RegularFile(bytes) => format!("file[{} bytes]", bytes.len()), + EntryKind::Symlink(target) => format!("symlink -> {}", target.display()), + } +} + +/// Recursively snapshot EVERY filesystem entry under `root` — directories, +/// regular files (complete bytes), and symlinks (their targets) — sorted, so a +/// command can be proven nonmutating by comparing the before/after sets. +/// +/// FAIL-CLOSED: every I/O step is unwrapped with an explicit panic instead of +/// being skipped or defaulted. A swallowed `read_dir`/entry error would silently +/// drop a whole subtree from BOTH snapshots (making a mutation inside it +/// invisible), and `fs::read(..).unwrap_or_default()` would map an unreadable +/// file to empty bytes on both sides — each turns a real mutation into a false +/// "unchanged". An entry kind with no deterministic exact representation (fifo, +/// socket, device, …) panics rather than being silently omitted. +fn tree_snapshot(root: &Path) -> Vec { + fn walk(dir: &Path, base: &Path, out: &mut Vec) { + let entries = std::fs::read_dir(dir).unwrap_or_else(|e| { + panic!( + "nonmutation oracle: read_dir({}) failed: {e} — the oracle must fail \ + loudly, never silently skip a subtree", + dir.display() + ) + }); + for entry in entries { + let entry = entry.unwrap_or_else(|e| { + panic!( + "nonmutation oracle: a directory entry of {} could not be read: {e}", + dir.display() + ) + }); + let path = entry.path(); + let rel = path + .strip_prefix(base) + .unwrap_or_else(|_| { + panic!( + "nonmutation oracle: {} is not under the snapshot base {}", + path.display(), + base.display() + ) + }) + .to_path_buf(); + // `symlink_metadata` describes the LINK itself, so a symlink is never + // resolved to its destination here. + let meta = std::fs::symlink_metadata(&path).unwrap_or_else(|e| { + panic!( + "nonmutation oracle: symlink_metadata({}) failed: {e}", + path.display() + ) + }); + let file_type = meta.file_type(); + if file_type.is_symlink() { + let target = std::fs::read_link(&path).unwrap_or_else(|e| { + panic!( + "nonmutation oracle: read_link({}) failed: {e}", + path.display() + ) + }); + out.push(TreeEntry { + rel, + kind: EntryKind::Symlink(target), + }); + } else if file_type.is_dir() { + // Record the directory ITSELF before descending, so an empty + // directory's creation/removal is visible. + out.push(TreeEntry { + rel, + kind: EntryKind::Directory, + }); + walk(&path, base, out); + } else if file_type.is_file() { + let bytes = std::fs::read(&path).unwrap_or_else(|e| { + panic!( + "nonmutation oracle: read({}) failed: {e} — an unreadable file \ + must not be recorded as empty bytes", + path.display() + ) + }); + out.push(TreeEntry { + rel, + kind: EntryKind::RegularFile(bytes), + }); + } else { + panic!( + "nonmutation oracle: unsupported entry kind at {} ({file_type:?}); no \ + deterministic exact representation exists, so the oracle refuses to \ + omit it", + path.display() + ); + } + } + } + let mut out = Vec::new(); + walk(root, root, &mut out); + out.sort(); + out +} + +/// Assert two [`tree_snapshot`]s are EXACTLY equal, reporting only the CHANGED +/// entries (created / removed / same-path-different-payload). A bare `assert_eq!` +/// on the snapshots would dump every file's full contents into the failure +/// message; this compares the same exact payloads but names just the offending +/// paths and kinds. +fn assert_tree_bytes_unchanged(before: &[TreeEntry], after: &[TreeEntry], context: &str) { + let mut diffs: Vec = Vec::new(); + let before_map: std::collections::BTreeMap<&Path, &EntryKind> = + before.iter().map(|e| (e.rel.as_path(), &e.kind)).collect(); + let after_map: std::collections::BTreeMap<&Path, &EntryKind> = + after.iter().map(|e| (e.rel.as_path(), &e.kind)).collect(); + for (path, kind) in &before_map { + match after_map.get(path) { + None => diffs.push(format!( + "removed: {} ({})", + path.display(), + kind_label(kind) + )), + Some(other) if other != kind => diffs.push(format!( + "changed: {} ({} -> {})", + path.display(), + kind_label(kind), + kind_label(other) + )), + Some(_) => {} + } + } + for (path, kind) in &after_map { + if !before_map.contains_key(path) { + diffs.push(format!( + "created: {} ({})", + path.display(), + kind_label(kind) + )); + } + } + assert!( + diffs.is_empty(), + "{context}: project tree must be EXACTLY unchanged (complete file bytes, \ + directory presence, and symlink targets compared — never sizes), but \ + changed: {diffs:?}" + ); +} + +/// `status` under an invalid/aliasing `CODEGRAPH_DIR` must FAIL CLOSED through +/// the REAL CLI — surfacing the stable diagnostic instead of masking the bad +/// configuration behind a default `.codegraph-v2` "not initialized" report — and +/// must leave the project tree byte-for-byte unchanged (a read command never +/// mutates). This is the CLI-side proof of the status fail-closed correction. +#[test] +fn status_fails_closed_on_invalid_configured_root_without_mutation_via_cli() { + let dir = TestDir::new("status-invalid"); + let project = dir.path().join("mini"); + copy_tree(&mini_fixture(), &project); + let p = project.to_str().unwrap(); + + let before = tree_snapshot(&project); + + for json in [false, true] { + let mut args = vec!["status"]; + if json { + args.push("--json"); + } + args.push(p); + let run = run_in_env(dir.path(), &args, &[("CODEGRAPH_DIR", ".")]); + assert!( + !run.ok, + "status (json={json}) with CODEGRAPH_DIR=. must fail closed, not report a \ + default layout: stdout={} stderr={}", + run.stdout, run.stderr + ); + assert!( + !run.stdout.contains(".codegraph-v2"), + "status must NOT mask an invalid configured root as a `.codegraph-v2` \ + default: stdout={}", + run.stdout + ); + // The actionable `IndexPaths` diagnostic must reach the user (on stderr, + // where the CLI prints `Error: …`). Assert the STABLE reason phrasing — + // `.` aliases the project root — not merely "some error". + let combined = format!("{}{}", run.stdout, run.stderr); + assert!( + combined.contains("project root itself"), + "status (json={json}) must surface the stable unsafe-root diagnostic \ + (the `.` alias resolves to the project root itself): stdout={} stderr={}", + run.stdout, + run.stderr + ); + } + + let after = tree_snapshot(&project); + assert_tree_bytes_unchanged(&before, &after, "a fail-closed `status`"); +} + +/// The byte snapshot must catch an EQUAL-LENGTH in-place mutation — the exact +/// hole a size-only snapshot left open. Self-test of the harness: mutating one +/// byte without changing any file length must be reported as changed. +#[test] +fn tree_snapshot_detects_equal_length_byte_mutation() { + let dir = TestDir::new("snap-selftest"); + let project = dir.path().join("mini"); + std::fs::create_dir_all(&project).unwrap(); + let victim = project.join("a.txt"); + std::fs::write(&victim, b"AAAA").unwrap(); + + let before = tree_snapshot(&project); + std::fs::write(&victim, b"AAAB").unwrap(); + let after = tree_snapshot(&project); + + assert_eq!( + before.len(), + after.len(), + "sanity: the mutation must keep the file set identical" + ); + let byte_len = |entry: &TreeEntry| match &entry.kind { + EntryKind::RegularFile(bytes) => bytes.len(), + other => panic!("sanity: the victim must be a regular file, got {other:?}"), + }; + assert_eq!( + byte_len(&before[0]), + byte_len(&after[0]), + "sanity: the mutation must keep the byte LENGTH identical, so only a \ + full-byte comparison can detect it" + ); + assert_ne!( + before, after, + "a same-length byte mutation must change the snapshot" + ); + assert_oracle_rejects(&before, &after, "a same-length byte mutation"); +} + +/// The oracle must FAIL on the mutation described by `what`. Wrapped in +/// `catch_unwind` because [`assert_tree_bytes_unchanged`] proves itself by +/// panicking; a silent pass here would mean the oracle degraded again. +fn assert_oracle_rejects(before: &[TreeEntry], after: &[TreeEntry], what: &str) { + let outcome = std::panic::catch_unwind(|| { + assert_tree_bytes_unchanged(before, after, "self-test"); + }); + assert!( + outcome.is_err(), + "the nonmutation assertion must FAIL on {what}" + ); +} + +/// Creating or removing an EMPTY directory must be detected. A file-only +/// snapshot records nothing for an empty directory, so such a mutation would be +/// invisible — the oracle therefore snapshots directories themselves. +#[test] +fn tree_snapshot_detects_empty_directory_mutation() { + let dir = TestDir::new("snap-emptydir"); + let project = dir.path().join("mini"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(project.join("a.txt"), b"AAAA").unwrap(); + + let before = tree_snapshot(&project); + let empty = project.join("scratch"); + std::fs::create_dir(&empty).unwrap(); + let after_create = tree_snapshot(&project); + + assert_ne!( + before, after_create, + "creating an EMPTY directory must change the snapshot (no file changed)" + ); + assert_oracle_rejects(&before, &after_create, "an empty-directory creation"); + + std::fs::remove_dir(&empty).unwrap(); + let after_remove = tree_snapshot(&project); + assert_eq!( + before, after_remove, + "removing the empty directory must restore the exact snapshot" + ); + assert_oracle_rejects(&after_create, &after_remove, "an empty-directory removal"); +} + +/// A symlink is snapshotted as its TARGET, never followed. Retargeting the link +/// mutates this tree and must be detected; and because the link is not followed, +/// a change to the pointed-to file OUTSIDE the tree must NOT be reported as a +/// mutation of the tree. +#[cfg(unix)] +#[test] +fn tree_snapshot_detects_symlink_target_mutation_without_following() { + let dir = TestDir::new("snap-symlink"); + let outside = dir.path().join("outside"); + std::fs::create_dir_all(&outside).unwrap(); + let target_a = outside.join("a.bin"); + let target_b = outside.join("b.bin"); + std::fs::write(&target_a, b"AAAA").unwrap(); + std::fs::write(&target_b, b"BBBB").unwrap(); + + let project = dir.path().join("mini"); + std::fs::create_dir_all(&project).unwrap(); + let link = project.join("link"); + std::os::unix::fs::symlink(&target_a, &link).unwrap(); + + let before = tree_snapshot(&project); + assert_eq!( + before, + vec![TreeEntry { + rel: PathBuf::from("link"), + kind: EntryKind::Symlink(target_a.clone()), + }], + "a symlink must be recorded as its target, not as the pointed-to bytes" + ); + + // Retarget the link: the tree itself changed. + std::fs::remove_file(&link).unwrap(); + std::os::unix::fs::symlink(&target_b, &link).unwrap(); + let retargeted = tree_snapshot(&project); + assert_ne!( + before, retargeted, + "retargeting a symlink must change the snapshot" + ); + assert_oracle_rejects(&before, &retargeted, "a symlink retarget"); + + // Mutating the pointed-to file outside the tree must NOT read through the + // link, so the tree snapshot stays identical. + std::fs::remove_file(&link).unwrap(); + std::os::unix::fs::symlink(&target_a, &link).unwrap(); + let restored = tree_snapshot(&project); + assert_eq!(before, restored, "sanity: the link points at A again"); + std::fs::write(&target_a, b"ZZZZ").unwrap(); + let after_outside_write = tree_snapshot(&project); + assert_eq!( + restored, after_outside_write, + "the oracle must NOT follow the link: an outside-the-tree write is not a \ + mutation of this tree" + ); + assert_tree_bytes_unchanged(&restored, &after_outside_write, "self-test"); +} + +/// An entry with no deterministic exact representation must make the oracle +/// PANIC, never be silently omitted: a skipped entry disappears from BOTH +/// snapshots, so a mutation of it would read as "unchanged". A unix domain +/// socket file is the portable-in-std way to create such an entry. +#[cfg(unix)] +#[test] +fn tree_snapshot_fails_loudly_on_unsupported_entry_kind() { + let dir = TestDir::new("snap-special"); + let project = dir.path().join("mini"); + std::fs::create_dir_all(&project).unwrap(); + let sock = project.join("s.sock"); + let _listener = std::os::unix::net::UnixListener::bind(&sock).unwrap(); + + let outcome = std::panic::catch_unwind(|| tree_snapshot(&project)); + let message = outcome + .err() + .map(|payload| match payload.downcast::() { + Ok(s) => *s, + Err(_) => "".to_string(), + }) + .expect("the oracle must PANIC on an unsupported entry kind, not skip it"); + assert!( + message.contains("unsupported entry kind"), + "the panic must name the unsupported entry kind: {message}" + ); +} + +/// An escaping relative `CODEGRAPH_DIR` (`../shared/cg`) is a VALID configured +/// root that escapes the project; two sibling projects given the same escaping +/// value must each `init` into their own identity-suffixed sibling under the +/// shared external parent, never a single shared simple-join DB — the REAL-CLI +/// counterpart of the absolute-root isolation case. +#[test] +fn two_projects_sharing_escaping_relative_root_get_distinct_roots_via_cli() { + let dir = TestDir::new("cfg-escape"); + let base = dir.path(); + let project_a = base.join("a"); + let project_b = base.join("b"); + copy_tree(&mini_fixture(), &project_a); + copy_tree(&mini_fixture(), &project_b); + std::fs::create_dir_all(base.join("shared")).unwrap(); + + for project in [&project_a, &project_b] { + let run = run_in_env( + base, + &["init", project.to_str().unwrap()], + &[("CODEGRAPH_DIR", "../shared/cg")], + ); + assert!( + run.ok, + "init with escaping CODEGRAPH_DIR must succeed for {}: {} {}", + project.display(), + run.stdout, + run.stderr + ); + } + + let shared_parent = base.join("shared"); + let siblings: Vec = std::fs::read_dir(&shared_parent) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with("cg-v2-")) + .collect(); + assert_eq!( + siblings.len(), + 2, + "two projects sharing an escaping relative root must produce two distinct \ + identity siblings, got {siblings:?}" + ); + assert_ne!( + siblings[0], siblings[1], + "the two current roots must differ" + ); + assert!( + !shared_parent.join("cg").join("codegraph.db").is_file(), + "the escaping simple-join `shared/cg/codegraph.db` must never exist" + ); +} diff --git a/crates/codegraph-cli/tests/cli_commands.rs b/crates/codegraph-cli/tests/cli_commands.rs index df35f43..8500f98 100644 --- a/crates/codegraph-cli/tests/cli_commands.rs +++ b/crates/codegraph-cli/tests/cli_commands.rs @@ -9,6 +9,9 @@ use std::path::{Path, PathBuf}; use std::process::Command; +use codegraph_core::IndexPaths; +use codegraph_store::{ExtractionStatus, Store}; + fn bin() -> PathBuf { PathBuf::from(env!("CARGO_BIN_EXE_codegraph")) } @@ -298,7 +301,7 @@ fn unlock_removes_existing_lock_file() { let dir = TestDir::new("unlock-lock"); let project = indexed_project(&dir); let p = project.to_str().unwrap(); - let lock = project.join(".codegraph/codegraph.lock"); + let lock = project.join(".codegraph-v2/codegraph.lock"); std::fs::write(&lock, b"stale").unwrap(); let run = run_in(dir.path(), &["unlock", p]); assert!(run.ok, "unlock must succeed: {}", run.stderr); @@ -311,7 +314,7 @@ fn unlock_removes_existing_lock_file() { } #[test] -fn uninit_requires_force_then_removes() { +fn uninit_requires_force_then_preserves_recovery_state() { let dir = TestDir::new("uninit"); let project = indexed_project(&dir); let p = project.to_str().unwrap(); @@ -325,17 +328,27 @@ fn uninit_requires_force_then_removes() { refused.stderr ); assert!( - project.join(".codegraph").exists(), + project.join(".codegraph-v2").exists(), "uninit without --force must not delete the index" ); - // With --force: removes .codegraph. + // With --force: removes mutable v2 data but preserves the authenticated + // interrupted-uninit namespace needed by explicit recovery. let done = run_in(dir.path(), &["uninit", "--force", p]); assert!(done.ok, "uninit --force must succeed: {}", done.stderr); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 lifecycle paths"); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Uninitialized + ); assert!( - !project.join(".codegraph").exists(), - "uninit --force must remove .codegraph" + paths.current_root().is_dir(), + "uninit --force must preserve the v2 lifecycle root" ); + assert!(paths.permanent_lock().is_file()); + assert!(paths.tombstone().is_file()); + assert!(paths.state_slots().iter().all(|slot| slot.is_file())); + assert!(!paths.current_db().exists()); } #[test] diff --git a/crates/codegraph-cli/tests/cold_start_handshake.rs b/crates/codegraph-cli/tests/cold_start_handshake.rs index 7cca3b0..fc510bb 100644 --- a/crates/codegraph-cli/tests/cold_start_handshake.rs +++ b/crates/codegraph-cli/tests/cold_start_handshake.rs @@ -190,11 +190,11 @@ impl Drop for ServeProcess { } } -/// Read the live daemon pid recorded under `/.codegraph/daemon.pid`, if -/// any. The cold path fire-and-forget spawns this daemon; we reap it in teardown -/// so no orphan `codegraph` daemon process survives the test. +/// Read the live daemon pid recorded in the project's v2 rendezvous, if any. The +/// cold path fire-and-forget spawns this daemon; we reap it in teardown so no +/// orphan `codegraph` daemon process survives the test. fn recorded_daemon_pid(project: &Path) -> Option { - let pid_path = codegraph_daemon::daemon_pid_path(project); + let pid_path = codegraph_daemon::daemon_pid_path(project).ok()?; let raw = fs::read_to_string(&pid_path).ok()?; codegraph_daemon::decode_lock_info(&raw) .filter(|info| info.pid > 0) diff --git a/crates/codegraph-cli/tests/daemon_idle.rs b/crates/codegraph-cli/tests/daemon_idle.rs index a5d42db..47bc6e4 100644 --- a/crates/codegraph-cli/tests/daemon_idle.rs +++ b/crates/codegraph-cli/tests/daemon_idle.rs @@ -2,7 +2,7 @@ //! //! The daemon lives in a SEPARATE detached process, so we assert lifecycle via //! pid liveness (`is_process_alive`) at timed checkpoints, not via stdout (it is -//! detached + logs to `.codegraph/daemon.log`). With a multi-second +//! detached + logs to its v2 `daemon.log`). With a multi-second //! `CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS` the daemon should LINGER while a client is //! connected and shortly after the LAST client disconnects, then EXIT once the //! idle window elapses with zero clients. De-flake by polling pid liveness @@ -15,6 +15,7 @@ use std::fs; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::process::Command; +use std::sync::{Mutex, MutexGuard, OnceLock}; use std::time::{Duration, Instant}; use codegraph_daemon::{ @@ -94,10 +95,28 @@ fn indexed_project(label: &str) -> (TestDir, PathBuf) { (dir, project) } +/// `std::env` is process-global and BOTH tests in this binary run in parallel, +/// each setting `CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS` to its own window and then +/// REMOVING it. `Command` snapshots env inside `spawn_detached_daemon`, so a +/// sibling's `remove_var` landing between this test's `set_var` and that +/// snapshot hands the daemon NO idle var — it falls back to the 300 000 ms +/// default and outlives any per-test deadline. Serialize the whole +/// set → spawn → restore region so the spawned daemon always inherits the +/// window its own test chose. +fn env_guard() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + /// Spawn the detached daemon with a SHORT idle timeout so the test runs fast. /// `Command` (inside `spawn_detached_daemon`) snapshots env at spawn time, so /// setting it here is inherited by the daemon process. fn spawn_idle_daemon(project: &Path, idle_ms: &str) { + let _env = env_guard(); + // SAFETY: the env_guard held for this whole body serializes every + // set/remove in this binary against the spawn that snapshots them. unsafe { std::env::set_var("CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS", idle_ms) }; unsafe { std::env::set_var("CODEGRAPH_WATCH_DEBOUNCE_MS", "100") }; spawn_detached_daemon(&bin(), project, false).expect("spawn_detached_daemon"); @@ -179,7 +198,7 @@ fn wait_until_gone(pid: u32, timeout: Duration) -> bool { #[test] fn daemon_idle_exits_after_last_client() { let (_dir, project) = indexed_project("exits"); - let socket = daemon_socket_path(&project); + let socket = daemon_socket_path(&project).expect("resolve the v2 rendezvous socket identity"); spawn_idle_daemon(&project, "2000"); let pid = poll_for_daemon_pid(&socket, Duration::from_millis(3000)) @@ -226,7 +245,7 @@ fn daemon_idle_exits_after_last_client() { #[test] fn daemon_stays_alive_with_active_client() { let (_dir, project) = indexed_project("active"); - let socket = daemon_socket_path(&project); + let socket = daemon_socket_path(&project).expect("resolve the v2 rendezvous socket identity"); spawn_idle_daemon(&project, "500"); let pid = poll_for_daemon_pid(&socket, Duration::from_millis(3000)) diff --git a/crates/codegraph-cli/tests/daemon_single_watcher.rs b/crates/codegraph-cli/tests/daemon_single_watcher.rs index d4b5e66..51b0588 100644 --- a/crates/codegraph-cli/tests/daemon_single_watcher.rs +++ b/crates/codegraph-cli/tests/daemon_single_watcher.rs @@ -3,7 +3,7 @@ //! //! The watcher lives in a SEPARATE detached process, so an in-process //! `watcher_count()` hook is unreachable. We assert BEHAVIORALLY via the -//! observable single-sync signal the daemon writes to `.codegraph/daemon.log` +//! observable single-sync signal the daemon writes to its v2 `daemon.log` //! (its stdout/stderr is redirected there by the T2 detached spawn): //! `watcher sync #{n}: {files} file(s)`. Exactly ONE such line for a single //! change proves one watcher fired once, not N (one per connected client). @@ -178,7 +178,7 @@ fn wait_until_gone(pid: u32, timeout: Duration) -> bool { } fn daemon_log(project: &Path) -> PathBuf { - project.join(".codegraph").join("daemon.log") + codegraph_daemon::daemon_log_path(project).expect("resolve the v2 rendezvous log path") } /// Read the daemon.log and count the watcher sync lines. @@ -224,7 +224,7 @@ fn poll_for_sync_lines(log: &Path, timeout: Duration) -> usize { #[test] fn daemon_single_watcher_fires_once() { let (_dir, project) = indexed_project("fires-once"); - let socket = daemon_socket_path(&project); + let socket = daemon_socket_path(&project).expect("resolve the v2 rendezvous socket identity"); let log = daemon_log(&project); let pid = { @@ -296,7 +296,7 @@ fn daemon_single_watcher_fires_once() { #[test] fn daemon_no_watch_does_not_autosync() { let (_dir, project) = indexed_project("no-watch"); - let socket = daemon_socket_path(&project); + let socket = daemon_socket_path(&project).expect("resolve the v2 rendezvous socket identity"); let log = daemon_log(&project); let pid = { diff --git a/crates/codegraph-cli/tests/daemon_spawn.rs b/crates/codegraph-cli/tests/daemon_spawn.rs index 4923d45..a71a39f 100644 --- a/crates/codegraph-cli/tests/daemon_spawn.rs +++ b/crates/codegraph-cli/tests/daemon_spawn.rs @@ -156,8 +156,9 @@ fn wait_until_gone(pid: u32, timeout: Duration) -> bool { #[test] fn spawn_detached_daemon_listens_and_survives() { let (_dir, project) = indexed_project("survive"); - let socket = daemon_socket_path(&project); - let log = project.join(".codegraph").join("daemon.log"); + let socket = daemon_socket_path(&project).expect("resolve the v2 rendezvous socket identity"); + let log = + codegraph_daemon::daemon_log_path(&project).expect("resolve the v2 rendezvous log path"); // Spawn the detached daemon. The helper must RETURN without waiting. spawn_detached_daemon(&bin(), &project, false).expect("spawn_detached_daemon"); @@ -199,7 +200,7 @@ fn spawn_detached_daemon_listens_and_survives() { #[test] fn spawn_detached_daemon_twice_no_stale_deadlock() { let (_dir, project) = indexed_project("twice"); - let socket = daemon_socket_path(&project); + let socket = daemon_socket_path(&project).expect("resolve the v2 rendezvous socket identity"); spawn_detached_daemon(&bin(), &project, false).expect("first spawn"); let pid1 = poll_for_daemon_pid(&socket, Duration::from_millis(2000)).expect("first daemon pid"); @@ -215,7 +216,9 @@ fn spawn_detached_daemon_twice_no_stale_deadlock() { // A zombie's lock cannot be cleared by signal-0 liveness, so remove the // stale pid/socket directly (the test owns this temp project) to prove the // respawn path comes up cleanly rather than deadlocking on a stale lock. - let _ = fs::remove_file(codegraph_daemon::daemon_pid_path(&project)); + let _ = fs::remove_file( + codegraph_daemon::daemon_pid_path(&project).expect("resolve the v2 rendezvous pid path"), + ); let _ = fs::remove_file(&socket); // Second spawn must come up cleanly (reuse-or-respawn), not deadlock. diff --git a/crates/codegraph-cli/tests/daemon_stale_wal_recovery.rs b/crates/codegraph-cli/tests/daemon_stale_wal_recovery.rs new file mode 100644 index 0000000..1a073a4 --- /dev/null +++ b/crates/codegraph-cli/tests/daemon_stale_wal_recovery.rs @@ -0,0 +1,325 @@ +//! A daemon killed with an open SQLite connection leaves an un-checkpointed +//! `-wal` behind. The strict `Current` startup gate demands sidecar-freedom, so +//! before this recovery existed that residue refused EVERY later daemon start +//! until `codegraph init` was re-run. +//! +//! These tests plant a REAL un-checkpointed write-ahead log — a child process +//! writes a row with `wal_autocheckpoint=0` and then dies without closing SQLite +//! — and drive the PRODUCTION daemon-start path over it. Zero-byte sidecar files +//! would prove nothing, so the planted log is asserted non-empty and the planted +//! row is asserted absent from the main database file before startup. + +#![cfg(unix)] + +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use codegraph_daemon::{ + daemon_pid_path, daemon_socket_path, is_process_alive, spawn_detached_daemon, unlock_project, +}; +use codegraph_store::Store; +use interprocess::local_socket::traits::Stream as _; +use interprocess::local_socket::{GenericFilePath, Stream, ToFsName}; + +/// Env var carrying the database a child process must plant a live write-ahead +/// log into before dying. +const PLANT_WAL_DB: &str = "CODEGRAPH_TEST_PLANT_WAL_DB"; + +/// The metadata key the planted row uses. It exists ONLY in the write-ahead log +/// until something folds that log back into the main database file. +const WAL_ONLY_KEY: &str = "stale_wal_recovery_probe"; + +fn bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_codegraph")) +} + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("codegraph-cli is under crates/") + .to_path_buf() +} + +fn copy_tree(src: &Path, dst: &Path) { + fs::create_dir_all(dst).expect("create fixture copy dir"); + for entry in fs::read_dir(src).expect("read fixture dir") { + let entry = entry.expect("fixture dir entry"); + let from = entry.path(); + let to = dst.join(entry.file_name()); + if from.is_dir() { + copy_tree(&from, &to); + } else { + fs::copy(&from, &to).expect("copy fixture file"); + } + } +} + +struct TestDir(PathBuf); + +impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-stale-wal-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + fs::create_dir_all(&path).expect("create test dir"); + Self(path) + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn indexed_project(label: &str) -> (TestDir, PathBuf) { + let dir = TestDir::new(label); + let project = dir.0.join("mini"); + copy_tree( + &workspace_root().join("crates/codegraph-bench/fixtures/mini"), + &project, + ); + let status = Command::new(bin()) + .args(["init", project.to_str().expect("utf8 project path")]) + .status() + .expect("run codegraph init"); + assert!(status.success(), "init failed for {}", project.display()); + (dir, project) +} + +fn index_paths(project: &Path) -> codegraph_core::IndexPaths { + codegraph_core::IndexPaths::resolve(project, None).expect("resolve v2 index paths") +} + +fn sidecar(db: &Path, suffix: &str) -> PathBuf { + let mut native = db.as_os_str().to_os_string(); + native.push(suffix); + PathBuf::from(native) +} + +/// Plant a genuinely un-checkpointed write-ahead log by re-invoking THIS test +/// binary as a child that writes one row with WAL auto-checkpointing disabled and +/// then dies without closing SQLite. A real dead process is the point: an +/// in-process leak would keep the connection open and look like a LIVE owner. +fn plant_unckeckpointed_wal(db: &Path) { + let output = Command::new(std::env::current_exe().expect("current test binary")) + .arg("--exact") + .arg("plant_uncheckpointed_wal_child_process") + .arg("--nocapture") + .env(PLANT_WAL_DB, db) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .expect("run WAL-planting child"); + assert!( + !output.status.success(), + "the planting child must die without closing SQLite, so it never exits successfully" + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("PLANTED"), + "the planting child must report a committed row before dying: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// The child half of [`plant_unckeckpointed_wal`]. A no-op in an ordinary run. +#[test] +fn plant_uncheckpointed_wal_child_process() { + let Ok(db) = std::env::var(PLANT_WAL_DB) else { + return; + }; + let store = Store::open(Path::new(&db)).expect("open the planted database"); + store + .connection() + .pragma_update(None, "wal_autocheckpoint", 0) + .expect("disable WAL auto-checkpointing"); + store + .set_project_metadata(WAL_ONLY_KEY, "1") + .expect("commit the WAL-only row"); + println!("PLANTED"); + // Die exactly like a SIGKILLed daemon: no SQLite close, no checkpoint, so the + // committed row stays in the `-wal` sidecar only. + std::process::abort(); +} + +/// Read one metadata key from a COPY of the main database file alone, with no +/// sidecar beside it. A value visible here is durably folded into the main file; +/// a value only in the write-ahead log is not. +fn metadata_in_main_file_only(db: &Path, label: &str) -> Option { + let dir = TestDir::new(label); + let copy = dir.0.join("main-only.db"); + fs::copy(db, ©).expect("copy the main database file"); + let store = Store::open(©).expect("open the main-file-only copy"); + let value = store + .get_project_metadata(WAL_ONLY_KEY) + .expect("read the probe key"); + drop(store); + value +} + +fn read_pid_from_hello(socket: &Path) -> Option { + let name = socket.to_fs_name::().ok()?; + let stream = Stream::connect(name).ok()?; + let mut line = String::new(); + BufReader::new(&stream).read_line(&mut line).ok()?; + serde_json::from_str::(line.trim()) + .ok()? + .get("pid") + .and_then(serde_json::Value::as_u64) + .map(|pid| pid as u32) +} + +fn poll_for_daemon_pid(socket: &Path, timeout: Duration) -> Option { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if socket.exists() + && let Some(pid) = read_pid_from_hello(socket) + { + return Some(pid); + } + std::thread::sleep(Duration::from_millis(25)); + } + None +} + +fn process_is_gone_or_zombie(pid: u32) -> bool { + if !is_process_alive(pid) { + return true; + } + match fs::read_to_string(format!("/proc/{pid}/stat")) { + Ok(stat) => stat + .rsplit_once(')') + .and_then(|(_, rest)| rest.split_whitespace().next()) + .map(|state| state == "Z") + .unwrap_or(false), + Err(_) => true, + } +} + +fn kill_and_reap(pid: u32) { + let _ = Command::new("kill").arg("-9").arg(pid.to_string()).status(); + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if process_is_gone_or_zombie(pid) { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + panic!("daemon pid {pid} must be dead after teardown"); +} + +/// A dead owner's leftover, genuinely non-empty write-ahead log no longer bricks +/// the namespace: the daemon starts, and the row that existed ONLY in that log is +/// folded into the main database file instead of being discarded. +#[test] +fn a_dead_owners_uncheckpointed_wal_is_recovered_on_daemon_startup() { + let (_dir, project) = indexed_project("recover"); + let paths = index_paths(&project); + let db = paths.current_db(); + let wal = sidecar(&db, "-wal"); + + plant_unckeckpointed_wal(&db); + + // The residue is REAL: a non-empty log holding a row the main file lacks. + let planted = fs::metadata(&wal).expect("planted -wal exists").len(); + assert!( + planted > 0, + "the planted -wal must carry bytes; a zero-byte sidecar proves nothing" + ); + assert_eq!( + metadata_in_main_file_only(&db, "before"), + None, + "the probe row must live ONLY in the write-ahead log before recovery" + ); + + let socket = daemon_socket_path(&project).expect("resolve the rendezvous socket identity"); + spawn_detached_daemon(&bin(), &project, true).expect("spawn a daemon over the stale residue"); + let pid = poll_for_daemon_pid(&socket, Duration::from_secs(5)).unwrap_or_else(|| { + panic!( + "a daemon must start over a dead owner's leftover WAL; daemon log:\n{}", + fs::read_to_string(paths.daemon_log()).unwrap_or_default() + ) + }); + + assert_eq!( + metadata_in_main_file_only(&db, "after").as_deref(), + Some("1"), + "recovery must FOLD the log into the main database file, not discard it" + ); + assert!( + paths.permanent_lock().exists(), + "the permanent index lock must survive recovery" + ); + assert_eq!( + Store::extraction_status(&paths), + codegraph_store::ExtractionStatus::Current, + "the namespace must still classify as Current" + ); + + kill_and_reap(pid); + unlock_project(&project); +} + +/// The paired fail-closed control: the SAME stale residue in a namespace carrying +/// the uninitialized tombstone is still refused. Recovery never becomes a way +/// around the state protocol. +#[test] +fn the_same_stale_residue_under_a_tombstone_is_still_refused() { + let (_dir, project) = indexed_project("tombstoned"); + let paths = index_paths(&project); + let db = paths.current_db(); + let wal = sidecar(&db, "-wal"); + + plant_unckeckpointed_wal(&db); + assert!( + fs::metadata(&wal).expect("planted -wal exists").len() > 0, + "the planted -wal must carry bytes" + ); + fs::write(paths.tombstone(), b"").expect("plant the uninitialized tombstone"); + + let output = Command::new(bin()) + .args(["serve", "--mcp", "--path"]) + .arg(&project) + .env("CODEGRAPH_DAEMON_INTERNAL", "1") + .env("CODEGRAPH_NO_WATCH", "1") + .output() + .expect("run the gated daemon start"); + + assert!( + !output.status.success(), + "a tombstoned namespace must refuse to start a daemon" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("refusing to start a daemon"), + "the refusal must be reported: {stderr}" + ); + assert!( + fs::metadata(&wal) + .expect("the -wal survives a refusal") + .len() + > 0, + "a refused start must fold nothing" + ); + assert!( + !daemon_pid_path(&project) + .expect("resolve the rendezvous pid path") + .exists(), + "a refused start must publish no pid record" + ); + assert!( + !daemon_socket_path(&project) + .expect("resolve the rendezvous socket identity") + .exists(), + "a refused start must publish no socket" + ); +} diff --git a/crates/codegraph-cli/tests/daemon_sweep.rs b/crates/codegraph-cli/tests/daemon_sweep.rs index f72e783..ffe28b8 100644 --- a/crates/codegraph-cli/tests/daemon_sweep.rs +++ b/crates/codegraph-cli/tests/daemon_sweep.rs @@ -7,7 +7,7 @@ //! keeps being served. With `CODEGRAPH_DAEMON_CLIENT_SWEEP_MS=200` the sweep //! fires fast; we poll pid liveness / daemon exit against a clear deadline. //! -//! The daemon is detached + logs to `.codegraph/daemon.log`, so lifecycle is +//! The daemon is detached + logs to its v2 `daemon.log`, so lifecycle is //! asserted via the daemon pid (idle-exit once no live clients remain) — exactly //! the observable surface `daemon_idle.rs` uses. @@ -17,6 +17,7 @@ use std::fs; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::process::{Child, Command}; +use std::sync::{Mutex, MutexGuard, OnceLock}; use std::time::{Duration, Instant}; use codegraph_daemon::{ @@ -96,9 +97,24 @@ fn indexed_project(label: &str) -> (TestDir, PathBuf) { (dir, project) } +/// Same process-global-env hazard as `daemon_idle.rs`: both tests here spawn a +/// daemon through the set → spawn → remove sequence below, and `Command` +/// snapshots env INSIDE `spawn_detached_daemon`. A sibling's `remove_var` +/// landing in that window hands the daemon no sweep/idle vars, so it falls back +/// to the 30 000 / 300 000 ms defaults and outlives the test's deadline. +fn env_guard() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + /// Spawn the detached daemon with a SHORT sweep + idle window so the test is /// fast. `Command` snapshots env at spawn time, so the daemon inherits these. fn spawn_sweep_daemon(project: &Path) { + let _env = env_guard(); + // SAFETY: the env_guard held for this whole body serializes every + // set/remove in this binary against the spawn that snapshots them. unsafe { std::env::set_var("CODEGRAPH_DAEMON_CLIENT_SWEEP_MS", "200") }; unsafe { std::env::set_var("CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS", "500") }; unsafe { std::env::set_var("CODEGRAPH_WATCH_DEBOUNCE_MS", "100") }; @@ -203,7 +219,7 @@ fn spawn_throwaway_child() -> Child { #[test] fn dead_client_is_swept_and_daemon_idle_exits() { let (_dir, project) = indexed_project("dead"); - let socket = daemon_socket_path(&project); + let socket = daemon_socket_path(&project).expect("resolve the v2 rendezvous socket identity"); spawn_sweep_daemon(&project); let daemon_pid = poll_for_daemon_pid(&socket, Duration::from_millis(3000)) @@ -249,7 +265,7 @@ fn dead_client_is_swept_and_daemon_idle_exits() { #[test] fn client_without_hello_is_never_swept() { let (_dir, project) = indexed_project("nohello"); - let socket = daemon_socket_path(&project); + let socket = daemon_socket_path(&project).expect("resolve the v2 rendezvous socket identity"); spawn_sweep_daemon(&project); let daemon_pid = poll_for_daemon_pid(&socket, Duration::from_millis(3000)) diff --git a/crates/codegraph-cli/tests/fixtures/legacy-v0.40.4/manifest.toml b/crates/codegraph-cli/tests/fixtures/legacy-v0.40.4/manifest.toml new file mode 100644 index 0000000..d2e5eef --- /dev/null +++ b/crates/codegraph-cli/tests/fixtures/legacy-v0.40.4/manifest.toml @@ -0,0 +1,52 @@ +# Frozen legacy-compatibility fixture manifest (Batch M). +# +# Legacy compatibility tests execute the PUBLISHED v0.40.4 release binary — the +# last release whose index namespace was the fixed legacy `.codegraph` root — to +# prove that an UNMODIFIED old scanner cannot reach v2 storage. Nothing here is +# built from this worktree: the bytes below are the exact published artifacts. +# +# Provenance (GitHub release `v0.40.4` of sunerpy/codegraph-rust): +# tag = v0.40.4 +# commit = aba40799ecacb94515f7e1690914d2accc4c8973 +# +# Every digest was taken from the real downloaded bytes, and +# `expected_version_stdout` from the real `--version` run of the extracted +# executable. `scripts/setup-legacy-fixture.sh` re-verifies all three +# (archive digest, executable digest, `--version`) on EVERY use; a mismatch or a +# missing network is a hard fixture-setup failure, never a skipped test. +# +# Only the two natively-executed CI hosts are pinned: Linux x86_64 and Windows +# x86_64. Each CI job uses its OWN native asset — there is no cross-execution +# and no emulation. A host outside this set has no pinned asset, and the test +# suite says so explicitly instead of pretending coverage. + +[fixture] +tag = "v0.40.4" +commit = "aba40799ecacb94515f7e1690914d2accc4c8973" +version = "0.40.4" +expected_version_stdout = "codegraph 0.40.4" +release_download_prefix = "https://github.com/sunerpy/codegraph-rust/releases/download/v0.40.4/" + +[[asset]] +target = "x86_64-unknown-linux-musl" +target_os = "linux" +target_arch = "x86_64" +url = "https://github.com/sunerpy/codegraph-rust/releases/download/v0.40.4/codegraph-0.40.4-x86_64-unknown-linux-musl.tar.gz" +archive_name = "codegraph-0.40.4-x86_64-unknown-linux-musl.tar.gz" +archive_format = "tar.gz" +archive_size = 10026272 +archive_sha256 = "b549c0980b0f52f6b753f529322cdbc8892e03ef3736ec227a9e8f49985a3bd2" +member = "codegraph" +executable_sha256 = "1a14d195be755b27d0e1625d7d7e4662412a07d77cc0d0e518793cd50f2182d1" + +[[asset]] +target = "x86_64-pc-windows-msvc" +target_os = "windows" +target_arch = "x86_64" +url = "https://github.com/sunerpy/codegraph-rust/releases/download/v0.40.4/codegraph-0.40.4-x86_64-pc-windows-msvc.zip" +archive_name = "codegraph-0.40.4-x86_64-pc-windows-msvc.zip" +archive_format = "zip" +archive_size = 9988644 +archive_sha256 = "eda7cfd6d2d0cc85fd8bd6ba66be1d7130a9b00609255730ad155ce6fa1351db" +member = "codegraph.exe" +executable_sha256 = "e52703f3a3d5bef90997ce23d9a3b49c980e6bcc1a078fdb1245ad1305a5bc09" diff --git a/crates/codegraph-cli/tests/global_http_project_scoped_config.rs b/crates/codegraph-cli/tests/global_http_project_scoped_config.rs new file mode 100644 index 0000000..f35c481 --- /dev/null +++ b/crates/codegraph-cli/tests/global_http_project_scoped_config.rs @@ -0,0 +1,607 @@ +//! Batch M item 19 — PROJECT-SCOPED v2 configuration, end to end. +//! +//! One process must never let one project's configuration reach another, and must +//! never adopt a legacy `.codegraph/config.toml` / `.codegraph/codegraph.json`. +//! These targets drive the REAL `codegraph` binary: +//! +//! 1. [`global_http_uses_project_scoped_v2_configs`] — the named acceptance +//! target. ONE global `serve --http` process (no `--path`, `APP_CONFIG` +//! unset) answers requests for two projects whose current-root `config.toml` +//! files carry OPPOSING `include`/`exclude`/`max_file_size` settings, plus +//! hostile LEGACY configs that must be ignored. It proves per-request scoping +//! in both orders, then proves the same for `sync` and for the live watcher. +//! 2. [`app_config_overrides_both_projects_including_codegraph_dir_collision`] — +//! the control: `APP_CONFIG` is INTENTIONALLY process-wide, so it supersedes +//! both projects' own configs; and with both projects pointed at the SAME +//! absolute `CODEGRAPH_DIR`, their identity-suffixed current roots stay +//! distinct, so neither project's index or config collides with the other's. +#![cfg(unix)] + +use std::fs; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::time::{Duration, Instant}; + +use codegraph_core::IndexPaths; +use codegraph_store::Store; + +fn bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_codegraph")) +} + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("codegraph-cli is under crates/") + .to_path_buf() +} + +fn mini_fixture() -> PathBuf { + workspace_root().join("crates/codegraph-bench/fixtures/mini") +} + +struct TestDir { + path: PathBuf, +} + +impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-m19-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&path).unwrap(); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +fn copy_tree(src: &Path, dst: &Path) { + fs::create_dir_all(dst).unwrap(); + for entry in fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let from = entry.path(); + let to = dst.join(entry.file_name()); + if from.is_dir() { + copy_tree(&from, &to); + } else { + fs::copy(&from, &to).unwrap(); + } + } +} + +/// A `mini`-fixture project plus a `.gitignore`d `Tools/` dir, and HOSTILE legacy +/// configs (`.codegraph/config.toml` + `.codegraph/codegraph.json`) that no +/// production path may read. The legacy TOML asks for the OPPOSITE of every +/// project-scoped expectation, so adopting it fails the assertions below. +fn project_with_hostile_legacy_config(root: &Path, legacy_include: &str) -> PathBuf { + copy_tree(&mini_fixture(), root); + fs::write(root.join(".gitignore"), "Tools/\n").unwrap(); + fs::create_dir_all(root.join("Tools")).unwrap(); + fs::write( + root.join("Tools/helper.ts"), + "export function toolsHelper() { return 1; }\n", + ) + .unwrap(); + fs::create_dir_all(root.join(".codegraph")).unwrap(); + fs::write( + root.join(".codegraph/config.toml"), + format!( + "[app]\nname = \"legacy\"\n\n[indexing]\nmax_file_size = 7\ninclude = [{legacy_include}]\n" + ), + ) + .unwrap(); + fs::write( + root.join(".codegraph/codegraph.json"), + "{\"extensions\":{\".zz\":\"lua\"}}\n", + ) + .unwrap(); + root.to_path_buf() +} + +/// Write `contents` to the project's CURRENT-ROOT `config.toml` — the only +/// project config production reads. Call AFTER `init`, so the current root is the +/// initialized namespace rather than a hand-made directory. +fn write_current_config(project: &Path, contents: &str, codegraph_dir: Option<&str>) { + let paths = IndexPaths::resolve(project, codegraph_dir).expect("resolve index paths"); + assert!( + paths.current_root().is_dir(), + "write the project config after `init` created {}", + paths.current_root().display() + ); + fs::write(paths.config_toml(), contents).unwrap(); +} + +/// Run the binary with foreground-only env, returning (stdout, stderr, ok). +fn cli(args: &[&str], envs: &[(&str, &str)]) -> (String, String, bool) { + let mut cmd = Command::new(bin()); + cmd.args(args); + cmd.env("CODEGRAPH_NO_DAEMON", "1"); + cmd.env("CODEGRAPH_NO_WATCH", "1"); + cmd.env_remove("APP_CONFIG"); + for (key, value) in envs { + cmd.env(key, value); + } + let output = cmd.output().expect("run codegraph binary"); + ( + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + output.status.success(), + ) +} + +/// The indexed root-relative file paths recorded in the project's v2 database. +fn indexed_files(project: &Path, codegraph_dir: Option<&str>) -> Vec { + let paths = IndexPaths::resolve(project, codegraph_dir).expect("resolve index paths"); + let store = Store::open(&paths.current_db()).expect("open v2 store"); + let mut files = store + .all_files() + .expect("read files") + .into_iter() + .map(|file| file.path) + .collect::>(); + files.sort(); + files +} + +fn free_port() -> u16 { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap().port() +} + +fn http_post_mcp(addr: &str, body: &str) -> std::io::Result { + let sockaddr = addr.to_socket_addrs()?.next().expect("resolve addr"); + let mut stream = TcpStream::connect_timeout(&sockaddr, Duration::from_secs(5))?; + stream.set_read_timeout(Some(Duration::from_secs(10)))?; + let req = format!( + "POST /mcp HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\nAccept: application/json, text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(req.as_bytes())?; + stream.flush()?; + let mut buf = String::new(); + let _ = stream.read_to_string(&mut buf); + Ok(buf) +} + +fn wait_reachable(addr: &str) -> bool { + let deadline = Instant::now() + Duration::from_secs(30); + let init = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"m19","version":"0"}}}"#; + while Instant::now() < deadline { + if let Ok(resp) = http_post_mcp(addr, init) + && resp.contains("\"result\"") + { + return true; + } + std::thread::sleep(Duration::from_millis(200)); + } + false +} + +/// One `tools/call` against the GLOBAL HTTP server, addressed by `projectPath`. +fn http_tool_call(addr: &str, id: u64, tool: &str, arguments: &str) -> String { + let body = format!( + r#"{{"jsonrpc":"2.0","id":{id},"method":"tools/call","params":{{"name":"{tool}","arguments":{arguments}}}}}"# + ); + http_post_mcp(addr, &body).expect("HTTP tool call") +} + +/// A child process killed + reaped on drop, so no server leaks out of a test. +struct ChildGuard(Child); + +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +/// A live `serve --mcp` child driven over line-delimited JSON-RPC. +struct ServeProcess { + child: Child, + stdin: ChildStdin, + reader: BufReader, +} + +impl ServeProcess { + fn spawn(project: &Path) -> Self { + let mut child = Command::new(bin()) + .arg("serve") + .arg("--mcp") + .arg("--path") + .arg(project) + .env("CODEGRAPH_NO_DAEMON", "1") + // The documented env escape hatch still wins over config, keeping the + // watcher reaction fast and deterministic. + .env("CODEGRAPH_WATCH_DEBOUNCE_MS", "100") + .env_remove("CODEGRAPH_NO_WATCH") + .env_remove("APP_CONFIG") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn serve --mcp"); + let stdin = child.stdin.take().expect("serve stdin"); + let stdout = child.stdout.take().expect("serve stdout"); + Self { + child, + stdin, + reader: BufReader::new(stdout), + } + } + + fn send(&mut self, line: &str) { + self.stdin + .write_all(line.as_bytes()) + .expect("write request"); + self.stdin.write_all(b"\n").expect("write newline"); + self.stdin.flush().expect("flush request"); + } + + fn read_line(&mut self) -> Option { + let mut buf = String::new(); + match self.reader.read_line(&mut buf) { + Ok(0) => None, + Ok(_) => Some(buf), + Err(_) => None, + } + } + + fn handshake(&mut self) { + self.send( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"m19","version":"0"}}}"#, + ); + self.read_line().expect("initialize response"); + } + + /// Poll `codegraph_search` for `symbol`, returning whether it was FOUND + /// within the budget. The not-found response echoes the query, so the + /// explicit sentinel decides presence. + fn search_finds_within(&mut self, symbol: &str, budget: Duration) -> bool { + let deadline = Instant::now() + budget; + let mut id = 100; + loop { + id += 1; + self.send(&format!( + r#"{{"jsonrpc":"2.0","id":{id},"method":"tools/call","params":{{"name":"codegraph_search","arguments":{{"query":"{symbol}"}}}}}}"# + )); + let found = match self.read_line() { + Some(line) => line.contains(symbol) && !line.contains("No results found"), + None => false, + }; + if found { + return true; + } + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(200)); + } + } +} + +impl Drop for ServeProcess { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// ALPHA force-indexes its gitignored `Tools/`, drops the `tools/` python dir, +/// and caps `max_file_size` far below the fixture's TypeScript sources. +const ALPHA_CONFIG: &str = "[app]\nname = \"alpha\"\n\n[indexing]\nmax_file_size = 120\ninclude = [\"Tools/\"]\nexclude = [\"tools/\"]\n"; +/// BETA is the mirror image: no `include` (so `Tools/` stays gitignored), keeps +/// `tools/`, drops `src/math.ts`, and leaves `max_file_size` at the default. +const BETA_CONFIG: &str = "[app]\nname = \"beta\"\n\n[indexing]\nexclude = [\"src/math.ts\"]\n"; + +/// Item 19. ONE global HTTP process, `APP_CONFIG` unset, two projects with +/// opposing current-root configs — plus `sync` and the live watcher. +#[test] +fn global_http_uses_project_scoped_v2_configs() { + let home = TestDir::new("global-http"); + let alpha = project_with_hostile_legacy_config(&home.path().join("alpha"), "\"tools/\""); + let beta = project_with_hostile_legacy_config(&home.path().join("beta"), "\"Tools/\""); + + // -------------------------------------------------- index and sync --- + // `init` publishes each project's v2 namespace; the per-project config then + // lands in that namespace. `index --force` must scope each project's SCAN by + // its own config, and a subsequent `sync` must scope the same way. The hostile + // legacy `.codegraph/config.toml` in each project asks for the other's + // `include` and a 7-byte size cap, so adopting it would flip the assertions. + let (out, err, ok) = cli(&["init", alpha.to_str().unwrap()], &[]); + assert!(ok, "alpha init failed: stdout={out} stderr={err}"); + let (out, err, ok) = cli(&["init", beta.to_str().unwrap()], &[]); + assert!(ok, "beta init failed: stdout={out} stderr={err}"); + write_current_config(&alpha, ALPHA_CONFIG, None); + write_current_config(&beta, BETA_CONFIG, None); + let (out, err, ok) = cli(&["index", "--force", alpha.to_str().unwrap()], &[]); + assert!(ok, "alpha index failed: stdout={out} stderr={err}"); + let (out, err, ok) = cli(&["index", "--force", beta.to_str().unwrap()], &[]); + assert!(ok, "beta index failed: stdout={out} stderr={err}"); + + let alpha_files = indexed_files(&alpha, None); + let beta_files = indexed_files(&beta, None); + assert!( + alpha_files.contains(&"Tools/helper.ts".to_string()), + "alpha's own include must force its gitignored Tools/ in: {alpha_files:?}" + ); + assert!( + !alpha_files.iter().any(|path| path.starts_with("tools/")), + "alpha's own exclude must drop tools/: {alpha_files:?}" + ); + assert!( + !beta_files.contains(&"Tools/helper.ts".to_string()), + "beta must not inherit alpha's include: {beta_files:?}" + ); + assert!( + beta_files.contains(&"tools/greeter.py".to_string()), + "beta must not inherit alpha's exclude: {beta_files:?}" + ); + assert!( + !beta_files.contains(&"src/math.ts".to_string()), + "beta's own exclude must drop src/math.ts: {beta_files:?}" + ); + assert!( + alpha_files.contains(&"src/math.ts".to_string()), + "alpha must not inherit beta's exclude: {alpha_files:?}" + ); + + // The SYNC path is scoped the same way: an identical new file under the + // gitignored `Tools/` is picked up by alpha (whose config includes it) and + // ignored by beta (whose config does not, its legacy config notwithstanding). + for project in [&alpha, &beta] { + fs::write( + project.join("Tools/synced.ts"), + "export function syncedMarker() { return 1; }\n", + ) + .unwrap(); + } + let (out, err, ok) = cli(&["sync", alpha.to_str().unwrap()], &[]); + assert!(ok, "alpha sync failed: stdout={out} stderr={err}"); + let (out, err, ok) = cli(&["sync", beta.to_str().unwrap()], &[]); + assert!(ok, "beta sync failed: stdout={out} stderr={err}"); + assert!( + indexed_files(&alpha, None).contains(&"Tools/synced.ts".to_string()), + "alpha's sync must honor alpha's include" + ); + assert!( + !indexed_files(&beta, None).contains(&"Tools/synced.ts".to_string()), + "beta's sync must not adopt alpha's (or its legacy config's) include" + ); + + // --------------------------------------------------- one HTTP process --- + // GLOBAL mode: no `--path`, so each tool call carries its own projectPath and + // ONE process answers for both projects. + let port = free_port(); + let addr = format!("127.0.0.1:{port}"); + let registry = home.path().join("http-registry"); + let child = Command::new(bin()) + .args(["serve", "--http", "--http-addr", &addr]) + .env("CODEGRAPH_NO_DAEMON", "1") + .env("CODEGRAPH_NO_WATCH", "1") + .env("CODEGRAPH_HTTP_REGISTRY_DIR", ®istry) + .env_remove("APP_CONFIG") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn global serve --http"); + let _guard = ChildGuard(child); + assert!( + wait_reachable(&addr), + "the global HTTP MCP server never became reachable on {addr}" + ); + + // Per-project `max_file_size`: alpha's 120-byte cap refuses to serve + // `src/app.ts` (185 bytes) as source, while beta's default limit renders it. + // Requested in BOTH orders so no ordering or reuse can hide a bleed. + let alpha_node = http_tool_call( + &addr, + 11, + "codegraph_node", + &format!( + r#"{{"projectPath":"{}","file":"src/app.ts"}}"#, + alpha.display() + ), + ); + let beta_node = http_tool_call( + &addr, + 12, + "codegraph_node", + &format!( + r#"{{"projectPath":"{}","file":"src/app.ts"}}"#, + beta.display() + ), + ); + let alpha_again = http_tool_call( + &addr, + 13, + "codegraph_node", + &format!( + r#"{{"projectPath":"{}","file":"src/app.ts"}}"#, + alpha.display() + ), + ); + assert!( + alpha_node.contains("could not read from disk"), + "alpha's own 120-byte max_file_size must refuse src/app.ts: {alpha_node}" + ); + assert!( + !beta_node.contains("could not read from disk"), + "beta must not inherit alpha's max_file_size: {beta_node}" + ); + assert!( + beta_node.contains("export"), + "beta must serve src/app.ts source: {beta_node}" + ); + assert!( + alpha_again.contains("could not read from disk"), + "alpha must keep its own limit after beta was served: {alpha_again}" + ); + + // The two projects' file sets stay their own inside the SAME process. + let alpha_listing = http_tool_call( + &addr, + 21, + "codegraph_files", + &format!(r#"{{"projectPath":"{}"}}"#, alpha.display()), + ); + let beta_listing = http_tool_call( + &addr, + 22, + "codegraph_files", + &format!(r#"{{"projectPath":"{}"}}"#, beta.display()), + ); + assert!( + alpha_listing.contains("helper.ts") && !alpha_listing.contains("greeter.py"), + "alpha's listing must reflect alpha's config: {alpha_listing}" + ); + assert!( + beta_listing.contains("greeter.py") && !beta_listing.contains("helper.ts"), + "beta's listing must reflect beta's config: {beta_listing}" + ); + + // ------------------------------------------------------------- watcher --- + // The live watcher scopes by the ADDRESSED project's config: a new file under + // the gitignored `Tools/` is auto-synced for alpha (which includes it) and + // never for beta (which does not) — even though beta's hostile LEGACY config + // names `Tools/`. + let mut alpha_serve = ServeProcess::spawn(&alpha); + alpha_serve.handshake(); + fs::write( + alpha.join("Tools/watched.ts"), + "export function alphaWatchedMarker() { return 1; }\n", + ) + .unwrap(); + assert!( + alpha_serve.search_finds_within("alphaWatchedMarker", Duration::from_secs(20)), + "alpha's watcher must auto-sync a file its own include covers" + ); + drop(alpha_serve); + + let mut beta_serve = ServeProcess::spawn(&beta); + beta_serve.handshake(); + fs::write( + beta.join("Tools/watched.ts"), + "export function betaWatchedMarker() { return 1; }\n", + ) + .unwrap(); + assert!( + !beta_serve.search_finds_within("betaWatchedMarker", Duration::from_secs(6)), + "beta's watcher must not adopt alpha's (or its legacy config's) include" + ); + drop(beta_serve); +} + +/// The control: `APP_CONFIG` is INTENTIONALLY a process-wide override, so it +/// supersedes both projects' own current-root configs. The same run also points +/// both projects at ONE absolute `CODEGRAPH_DIR`: their identity-suffixed current +/// roots must stay distinct, so the collision attempt cannot merge two projects' +/// storage or configuration. +#[test] +fn app_config_overrides_both_projects_including_codegraph_dir_collision() { + let home = TestDir::new("app-config"); + let shared_dir = home.path().join("shared-index"); + let shared = shared_dir.to_string_lossy().into_owned(); + let alpha = project_with_hostile_legacy_config(&home.path().join("alpha"), "\"tools/\""); + let beta = project_with_hostile_legacy_config(&home.path().join("beta"), "\"Tools/\""); + + let override_path = home.path().join("process-wide.toml"); + fs::write( + &override_path, + "[app]\nname = \"process-wide\"\n\n[indexing]\ninclude = [\"Tools/\"]\n", + ) + .unwrap(); + let override_arg = override_path.to_string_lossy().into_owned(); + + // The identity-suffixed sibling roots must differ even though CODEGRAPH_DIR is + // literally the same absolute path for both projects. + let alpha_paths = IndexPaths::resolve(&alpha, Some(&shared)).expect("alpha paths"); + let beta_paths = IndexPaths::resolve(&beta, Some(&shared)).expect("beta paths"); + assert_ne!( + alpha_paths.current_root(), + beta_paths.current_root(), + "a shared absolute CODEGRAPH_DIR must still yield per-project current roots" + ); + assert_ne!(alpha_paths.current_db(), beta_paths.current_db()); + assert_ne!(alpha_paths.config_toml(), beta_paths.config_toml()); + + // Initialize both namespaces first (so each has a published current root), + // write each project's OWN config — which forbids the other's scope and, for + // alpha, `Tools/` too — then reindex under the process-wide APP_CONFIG. + for project in [&alpha, &beta] { + let (out, err, ok) = cli( + &["init", project.to_str().unwrap()], + &[("CODEGRAPH_DIR", shared.as_str())], + ); + assert!( + ok, + "init failed for {}: stdout={out} stderr={err}", + project.display() + ); + } + write_current_config(&alpha, ALPHA_CONFIG, Some(&shared)); + write_current_config(&beta, BETA_CONFIG, Some(&shared)); + for project in [&alpha, &beta] { + let (out, err, ok) = cli( + &["index", "--force", project.to_str().unwrap()], + &[ + ("CODEGRAPH_DIR", shared.as_str()), + ("APP_CONFIG", override_arg.as_str()), + ], + ); + assert!( + ok, + "index under APP_CONFIG failed for {}: stdout={out} stderr={err}", + project.display() + ); + } + + // APP_CONFIG won for BOTH: each index carries `Tools/helper.ts`, which + // alpha's own config allows but beta's forbids. + for (label, project) in [("alpha", &alpha), ("beta", &beta)] { + let files = indexed_files(project, Some(&shared)); + assert!( + files.contains(&"Tools/helper.ts".to_string()), + "APP_CONFIG must override {label}'s own config: {files:?}" + ); + // Storage stayed separate: each project's DB holds only its own tree. + assert!( + files.contains(&"src/app.ts".to_string()), + "{label}'s index must hold its own sources: {files:?}" + ); + } + assert!( + alpha_paths.current_root().is_dir() && beta_paths.current_root().is_dir(), + "both identity-suffixed roots must exist side by side under the shared CODEGRAPH_DIR" + ); + + // And with APP_CONFIG UNSET the same two projects fall back to their own + // configs again — the override is process-wide, not sticky state on disk. + let (out, err, ok) = cli( + &["index", "--force", beta.to_str().unwrap()], + &[("CODEGRAPH_DIR", shared.as_str())], + ); + assert!(ok, "beta reindex failed: stdout={out} stderr={err}"); + let beta_files = indexed_files(&beta, Some(&shared)); + assert!( + !beta_files.contains(&"Tools/helper.ts".to_string()), + "without APP_CONFIG beta must use its own config again: {beta_files:?}" + ); +} diff --git a/crates/codegraph-cli/tests/godot_idfields_cwd.rs b/crates/codegraph-cli/tests/godot_idfields_cwd.rs index 6c72668..6fda322 100644 --- a/crates/codegraph-cli/tests/godot_idfields_cwd.rs +++ b/crates/codegraph-cli/tests/godot_idfields_cwd.rs @@ -2,19 +2,19 @@ //! must fire under the REAL `codegraph index` CLI even when the process CWD is //! NOT the project root. //! -//! The pipeline (`extract_and_persist_frameworks`) hands the framework resolver -//! a repo-RELATIVE `.tres` path; the DSL config reader used to resolve that path -//! against the process CWD, so `$PROJECT/.codegraph/codegraph.json` was only -//! found when the CLI happened to run with its CWD == the project root. The A3 -//! unit tests masked this by passing ABSOLUTE `.tres` paths. This test drives -//! the binary from a DIFFERENT temp dir and asserts the `godot:id:*` (idFields) -//! and the `resourceFields` literal sentinels land in `unresolved_refs` — -//! proving config lookup now resolves against the project root. +//! The pipeline (`extract_and_persist_frameworks`) hands the framework resolver a +//! repo-RELATIVE `.tres` path plus the project's EXPLICITLY loaded DSL config, +//! read from the addressed project's own index root — so the process CWD cannot +//! influence which config is used. This test drives the binary from a DIFFERENT +//! temp dir and asserts the `godot:id:*` (idFields) and `resourceFields` literal +//! sentinels land in `unresolved_refs`; a companion target asserts a LEGACY +//! `.codegraph/codegraph.json` is never adopted. use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; +use codegraph_core::IndexPaths; use codegraph_store::Store; struct TestDir { @@ -70,13 +70,19 @@ duration = 5.0 "; fn write_godot_project(root: &Path) { - fs::create_dir_all(root.join(".codegraph")).unwrap(); - fs::write(root.join(".codegraph").join("codegraph.json"), DSL_CONFIG).unwrap(); + fs::create_dir_all(root).unwrap(); fs::write(root.join("project.godot"), PROJECT_GODOT).unwrap(); fs::create_dir_all(root.join("data")).unwrap(); fs::write(root.join("data").join("spell.tres"), SPELL_TRES).unwrap(); } +/// Write the project's CURRENT-ROOT `codegraph.json` — the only DSL config a +/// project-scoped run consults. Call after `init` published the namespace. +fn write_dsl_config(root: &Path) { + let paths = IndexPaths::resolve(root, None).expect("resolve index paths"); + fs::write(paths.extension_config(), DSL_CONFIG).unwrap(); +} + /// Run the binary from `cwd` (a FOREIGN directory) against an absolute project /// path. `CODEGRAPH_NO_DAEMON`/`NO_WATCH` keep the run foreground so the test /// never blocks on a background daemon. @@ -95,7 +101,9 @@ fn cli_from(cwd: &Path, args: &[&str]) -> (String, String, bool) { } fn unresolved_ref_names(project: &Path) -> Vec { - let db = project.join(".codegraph").join("codegraph.db"); + // Batch M: both the index DB and the opt-in DSL config live in the isolated + // v2 namespace. + let db = project.join(".codegraph-v2").join("codegraph.db"); let store = Store::open(&db).expect("open store"); store .all_unresolved_refs() @@ -122,6 +130,7 @@ fn idfields_dsl_fires_when_cwd_is_not_project_root() { let project_str = project.to_string_lossy().into_owned(); let (out, err, ok) = cli_from(foreign.path(), &["init", &project_str]); assert!(ok, "init failed: stdout={out} stderr={err}"); + write_dsl_config(&project); let (out, err, ok) = cli_from(foreign.path(), &["index", "--force", &project_str]); assert!(ok, "index --force failed: stdout={out} stderr={err}"); @@ -141,13 +150,10 @@ fn idfields_dsl_fires_when_cwd_is_not_project_root() { /// DSL config emits ZERO `godot:id:*` sentinels. #[test] fn no_config_emits_zero_id_sentinels_from_foreign_cwd() { - // Given a Godot project with NO `.codegraph/codegraph.json` DSL block, + // Given a Godot project with NO DSL config at all, let project_dir = TestDir::new("noconfig"); let project = project_dir.path().join("game"); - fs::create_dir_all(project.join(".codegraph")).unwrap(); - fs::write(project.join("project.godot"), PROJECT_GODOT).unwrap(); - fs::create_dir_all(project.join("data")).unwrap(); - fs::write(project.join("data").join("spell.tres"), SPELL_TRES).unwrap(); + write_godot_project(&project); let foreign = TestDir::new("noconfig-cwd"); // When indexed from a foreign cwd, @@ -164,3 +170,38 @@ fn no_config_emits_zero_id_sentinels_from_foreign_cwd() { "off-by-default violated: godot:id:* sentinel present without config: {names:?}" ); } + +/// A LEGACY `.codegraph/codegraph.json` must NEVER supply the DSL config: with the +/// same block written only there, the run emits zero sentinels. +#[test] +fn legacy_dsl_config_is_never_adopted() { + // Given a Godot project whose ONLY DSL config is the legacy one, + let project_dir = TestDir::new("legacy"); + let project = project_dir.path().join("game"); + write_godot_project(&project); + fs::create_dir_all(project.join(".codegraph")).unwrap(); + fs::write( + project.join(".codegraph").join("codegraph.json"), + DSL_CONFIG, + ) + .unwrap(); + let foreign = TestDir::new("legacy-cwd"); + + // When indexed, + let project_str = project.to_string_lossy().into_owned(); + let (out, err, ok) = cli_from(foreign.path(), &["init", &project_str]); + assert!(ok, "init failed: stdout={out} stderr={err}"); + let (out, err, ok) = cli_from(foreign.path(), &["index", "--force", &project_str]); + assert!(ok, "index --force failed: stdout={out} stderr={err}"); + + // Then nothing fired: neither the id sentinel nor the resourceFields literal. + let names = unresolved_ref_names(&project); + assert!( + !names.iter().any(|n| n.starts_with("godot:id:")), + "a legacy DSL config must not emit id sentinels: {names:?}" + ); + assert!( + !names.iter().any(|n| n == "Fireball"), + "a legacy DSL config must not emit resourceFields literals: {names:?}" + ); +} diff --git a/crates/codegraph-cli/tests/godot_idfields_determinism.rs b/crates/codegraph-cli/tests/godot_idfields_determinism.rs index c1bb8d6..1f8039a 100644 --- a/crates/codegraph-cli/tests/godot_idfields_determinism.rs +++ b/crates/codegraph-cli/tests/godot_idfields_determinism.rs @@ -5,11 +5,9 @@ //! //! This is the A5 counterpart to `sync_incremental.rs` / `parallel_index.rs`, //! but exercises the `.tres` framework-extraction path that emits -//! `godot:id::` sentinels — i.e. it stresses the mtime-cached -//! `dsl_id_fields` reader + `find_config_path` tree-walk under both the -//! incremental (`sync`) and full (`index --force`) code paths, and under both -//! the rayon parallel parse and a single-threaded (`RAYON_NUM_THREADS=1`) -//! parse. +//! `godot:id::` sentinels — i.e. it stresses the project-scoped DSL +//! config load under both the incremental (`sync`) and full (`index --force`) +//! code paths, and under the rayon parallel parse. //! //! Comparisons use the codegraph-bench canonical oracle (order-independent //! edge/ref multisets + `.schema`), so "identical" means content + `.schema`, @@ -22,6 +20,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use codegraph_bench::oracle::{canonicalize_db, diff_canonical}; +use codegraph_core::IndexPaths; struct TestDir { path: PathBuf, @@ -75,25 +74,25 @@ skill_effect = \"a:b:9015:c:7005:1000\" duration = 5.0 "; -/// Lay down a Godot project (project.godot + a `.tres` + an idFields config) -/// under `root`. +/// Lay down a Godot project (project.godot + a `.tres`) under `root`. fn write_godot_project(root: &Path) { - fs::create_dir_all(root.join(".codegraph")).unwrap(); - fs::write( - root.join(".codegraph").join("codegraph.json"), - IDFIELDS_CONFIG, - ) - .unwrap(); + fs::create_dir_all(root).unwrap(); fs::write(root.join("project.godot"), PROJECT_GODOT).unwrap(); fs::create_dir_all(root.join("data")).unwrap(); fs::write(root.join("data").join("strength.tres"), BUFF_TRES).unwrap(); } -/// Run the binary with `cwd` as the working directory. The config reader walks -/// up from each `.tres`'s relative path joined onto cwd, so the project must be -/// the cwd for the opt-in `idFields` config to be discovered — exactly how a -/// user runs `codegraph` from inside their project. `CODEGRAPH_NO_DAEMON` keeps -/// the run foreground so the test never blocks on a background daemon. +/// Write the project's CURRENT-ROOT `codegraph.json` — the only DSL config a +/// project-scoped run consults. Call after `init` published the namespace. +fn write_idfields_config(root: &Path) { + let paths = IndexPaths::resolve(root, None).expect("resolve index paths"); + fs::write(paths.extension_config(), IDFIELDS_CONFIG).unwrap(); +} + +/// Run the binary with `cwd` as the working directory — how a user runs +/// `codegraph` from inside their project. The DSL config is resolved from the +/// project's own index root, not the cwd. `CODEGRAPH_NO_DAEMON` keeps the run +/// foreground so the test never blocks on a background daemon. fn cli_cwd(cwd: &Path, args: &[&str]) -> (String, String, bool) { let mut cmd = Command::new(env!("CARGO_BIN_EXE_codegraph")); cmd.current_dir(cwd); @@ -109,7 +108,9 @@ fn cli_cwd(cwd: &Path, args: &[&str]) -> (String, String, bool) { } fn db_path(project: &Path) -> PathBuf { - project.join(".codegraph").join("codegraph.db") + // Batch M: both the index DB and the opt-in DSL config live in the isolated + // v2 namespace. + project.join(".codegraph-v2").join("codegraph.db") } /// (i) `sync` after an edit to the `.tres` must equal a full `index --force` @@ -123,6 +124,9 @@ fn idfields_sync_after_edit_equals_index_force_from_scratch() { write_godot_project(&project); let (out, err, ok) = cli_cwd(&project, &["init", "."]); assert!(ok, "init failed: stdout={out} stderr={err}"); + write_idfields_config(&project); + let (out, err, ok) = cli_cwd(&project, &["index", "--force", "."]); + assert!(ok, "configured index failed: stdout={out} stderr={err}"); // When the `.tres` is edited (a new idField line added) and synced, let edited = "\ @@ -144,6 +148,7 @@ duration = 9.0 fs::write(scratch_project.join("data").join("strength.tres"), edited).unwrap(); let (out, err, ok) = cli_cwd(&scratch_project, &["init", "."]); assert!(ok, "scratch init failed: stdout={out} stderr={err}"); + write_idfields_config(&scratch_project); let (out, err, ok) = cli_cwd(&scratch_project, &["index", "--force", "."]); assert!(ok, "index --force failed: stdout={out} stderr={err}"); @@ -156,8 +161,8 @@ duration = 9.0 /// (ii) Two independent parallel `index --force` runs of the same idFields /// Godot project must be canonically identical — proving the rayon-parallel -/// framework-extraction path (with the mtime-cached config reader shared across -/// threads) converges on ONE fixed canonical form regardless of thread +/// framework-extraction path (with the project's config loaded once and shared +/// immutably across threads) converges on ONE fixed canonical form regardless of thread /// scheduling. This mirrors `parallel_index.rs`'s determinism property, applied /// to the `godot:id:*` sentinel-emitting path. (A single-threaded run via /// `RAYON_NUM_THREADS=1` is deliberately NOT used: the CLI bulk-index pipeline @@ -172,12 +177,14 @@ fn idfields_parallel_index_is_deterministic_across_runs() { write_godot_project(&first_project); let (out, err, ok) = cli_cwd(&first_project, &["init", "."]); assert!(ok, "first init failed: stdout={out} stderr={err}"); + write_idfields_config(&first_project); let second = TestDir::new("parallel-b"); let second_project = second.path().join("game"); write_godot_project(&second_project); let (out, err, ok) = cli_cwd(&second_project, &["init", "."]); assert!(ok, "second init failed: stdout={out} stderr={err}"); + write_idfields_config(&second_project); // When each is independently indexed with the default rayon parallelism, let (out, err, ok) = cli_cwd(&first_project, &["index", "--force", "."]); diff --git a/crates/codegraph-cli/tests/lease_lifetime.rs b/crates/codegraph-cli/tests/lease_lifetime.rs new file mode 100644 index 0000000..382d0b9 --- /dev/null +++ b/crates/codegraph-cli/tests/lease_lifetime.rs @@ -0,0 +1,998 @@ +//! Deterministic acceptance tests for v2 reader/writer lease topology. + +use std::fs; +use std::io::{Cursor, Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Output, Stdio}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock, mpsc}; +use std::time::{Duration, Instant}; + +use codegraph_core::IndexPaths; +use codegraph_store::{IndexLease, IndexLeaseError, Store, StoreError}; + +const CHILD_ACTION: &str = "CODEGRAPH_LEASE_LIFETIME_CHILD_ACTION"; +const CHILD_PROJECT: &str = "CODEGRAPH_LEASE_LIFETIME_CHILD_PROJECT"; +const BARRIER_ADDR: &str = "CODEGRAPH_TEST_LEASE_BARRIER_ADDR"; +const BARRIER_MODE: &str = "CODEGRAPH_TEST_LEASE_BARRIER_MODE"; +const WAIT: Duration = Duration::from_secs(10); +const PROBE_WAIT: Duration = Duration::from_millis(100); + +fn bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_codegraph")) +} + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("codegraph-cli is under crates/") + .to_path_buf() +} + +fn copy_tree(source: &Path, target: &Path) { + fs::create_dir_all(target).expect("create fixture target"); + for entry in fs::read_dir(source).expect("read fixture source") { + let entry = entry.expect("fixture entry"); + let destination = target.join(entry.file_name()); + if entry.path().is_dir() { + copy_tree(&entry.path(), &destination); + } else { + fs::copy(entry.path(), destination).expect("copy fixture file"); + } + } +} + +struct TestProject(PathBuf); + +impl TestProject { + fn indexed(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-lease-lifetime-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos() + )); + copy_tree( + &workspace_root().join("crates/codegraph-bench/fixtures/mini"), + &path, + ); + let output = Command::new(bin()) + .args(["init", path.to_str().expect("utf8 fixture path")]) + .env("CODEGRAPH_NO_DAEMON", "1") + .output() + .expect("run fixture init"); + assert!( + output.status.success(), + "fixture init failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + + fn paths(&self) -> IndexPaths { + IndexPaths::resolve(self.path(), None).expect("fixture IndexPaths") + } + + fn http_registry_dir(&self) -> PathBuf { + self.path().join("http-registry") + } +} + +impl Drop for TestProject { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +struct LeaseBarrier { + address: SocketAddr, + arrived: mpsc::Receiver<(u8, TcpStream)>, +} + +impl LeaseBarrier { + fn new() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind lease barrier"); + let address = listener.local_addr().expect("lease barrier address"); + let (arrived_tx, arrived_rx) = mpsc::channel(); + // Accept EVERY arrival, not just the first: one process can reach the + // barrier more than once (a daemon takes a startup shared lease, then a + // separate per-request shared lease), and each arrival must be releasable + // in order rather than silently dropped. + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { + return; + }; + let _ = stream.set_read_timeout(Some(WAIT)); + let mut marker = [0_u8; 1]; + if stream.read_exact(&mut marker).is_err() { + return; + } + if marker[0] == b'C' { + return; + } + // The receiver can be gone during failure cleanup. That is not a + // listener-thread failure and must not add a secondary panic. + if arrived_tx.send((marker[0], stream)).is_err() { + return; + } + } + }); + Self { + address, + arrived: arrived_rx, + } + } + + fn configure(&self, command: &mut Command, mode: &str) { + command + .env(BARRIER_ADDR, self.address.to_string()) + .env(BARRIER_MODE, mode); + } + + fn child_command(&self, mode: &str) -> Command { + let mut command = Command::new(bin()); + self.configure(&mut command, mode); + command + } + + fn wait(&self, expected: u8) -> TcpStream { + match self.arrived.recv_timeout(WAIT) { + Ok((actual, stream)) => { + assert_eq!(actual, expected, "wrong lease mode reached barrier"); + stream + } + Err(error) => { + // Unblock the bounded listener before failing. This cancellation + // connection is harness cleanup, never lease-order evidence. + if let Ok(mut cancel) = TcpStream::connect(self.address) { + let _ = cancel.write_all(b"C"); + } + panic!("lease barrier was not reached before its finite deadline: {error}"); + } + } + } +} + +struct ChildGuard(Option); + +impl ChildGuard { + fn new(child: Child) -> Self { + Self(Some(child)) + } + + fn child_mut(&mut self) -> &mut Child { + self.0.as_mut().expect("child still owned") + } + + fn finish(mut self) -> Output { + wait_child(self.child_mut()); + self.0 + .take() + .expect("child still owned") + .wait_with_output() + .expect("collect child output") + } +} + +impl Drop for ChildGuard { + fn drop(&mut self) { + if let Some(child) = self.0.as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +fn wait_child(child: &mut Child) -> std::process::ExitStatus { + let deadline = Instant::now() + WAIT; + loop { + if let Some(status) = child.try_wait().expect("poll child status") { + return status; + } + assert!(Instant::now() < deadline, "child exceeded finite deadline"); + std::thread::park_timeout(Duration::from_millis(5)); + } +} + +fn run_probe(project: &Path, action: &str) -> String { + let output = Command::new(std::env::current_exe().expect("current test binary")) + .arg("--exact") + .arg("lease_lifetime_child_process") + .arg("--nocapture") + .env(CHILD_ACTION, action) + .env(CHILD_PROJECT, project) + .output() + .expect("run lease probe child"); + assert!( + output.status.success(), + "lease probe child failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout) + .expect("probe utf8") + .lines() + .find(|line| matches!(*line, "ACQUIRED" | "TIMED_OUT")) + .expect("probe result sentinel") + .to_string() +} + +fn assert_writer_blocked(project: &Path, context: &str) { + assert_eq!( + run_probe(project, "probe-exclusive"), + "TIMED_OUT", + "{context} must retain its shared lease through final result production" + ); +} + +fn mcp_frames(project: &Path) -> Vec { + let project = project.to_str().expect("utf8 project path"); + format!( + "{}\n{}\n{}\n", + serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"lease-test","version":"0"}} + }), + serde_json::json!({"jsonrpc":"2.0","method":"notifications/initialized"}), + serde_json::json!({ + "jsonrpc":"2.0", "id":2, "method":"tools/call", + "params":{"name":"codegraph_search","arguments":{"query":"add","projectPath":project}} + }), + ) + .into_bytes() +} + +fn spawn_stdio_reader(project: &Path, barrier: &LeaseBarrier) -> ChildGuard { + let mut command = barrier.child_command("shared"); + command + .args(["serve", "--mcp", "--path"]) + .arg(project) + .arg("--no-watch") + .env("CODEGRAPH_NO_DAEMON", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn().expect("spawn stdio MCP reader"); + child + .stdin + .take() + .expect("stdio MCP stdin") + .write_all(&mcp_frames(project)) + .expect("write stdio MCP frames"); + ChildGuard::new(child) +} + +fn assert_successful_mcp_output(output: &Output, context: &str) { + assert!( + output.status.success(), + "{context} failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("\"id\":2") && stdout.contains("add"), + "{context} did not return the tool result: {stdout}" + ); +} + +fn unused_loopback_addr() -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").expect("reserve HTTP port"); + let address = listener.local_addr().expect("reserved HTTP address"); + drop(listener); + address +} + +fn connect_http(address: SocketAddr) -> TcpStream { + let deadline = Instant::now() + WAIT; + loop { + match TcpStream::connect_timeout(&address, Duration::from_millis(100)) { + Ok(stream) => return stream, + Err(error) if Instant::now() < deadline => { + let _ = error; + std::thread::park_timeout(Duration::from_millis(5)); + } + Err(error) => panic!("HTTP server did not bind before deadline: {error}"), + } + } +} + +fn send_http_tool_call(stream: &mut TcpStream, address: SocketAddr, project: &Path) { + stream + .set_read_timeout(Some(WAIT)) + .expect("set HTTP read timeout"); + stream + .set_write_timeout(Some(WAIT)) + .expect("set HTTP write timeout"); + let body = serde_json::json!({ + "jsonrpc":"2.0", "id":7, "method":"tools/call", + "params":{"name":"codegraph_search","arguments":{"query":"add","projectPath":project}} + }) + .to_string(); + write!( + stream, + "POST /mcp HTTP/1.1\r\nHost: {address}\r\nContent-Type: application/json\r\nAccept: application/json, text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .expect("send HTTP MCP request"); + stream.flush().expect("flush HTTP MCP request"); +} + +fn wait_for_daemon(project: &Path) { + let deadline = Instant::now() + WAIT; + loop { + let socket = codegraph_daemon::recorded_socket_path(project) + .expect("resolve the recorded v2 rendezvous socket"); + if codegraph_daemon::attach_to_daemon(&socket).is_ok() { + return; + } + assert!(Instant::now() < deadline, "daemon did not become ready"); + std::thread::park_timeout(Duration::from_millis(5)); + } +} + +#[derive(Clone)] +struct SharedSink(Arc>>); + +impl Write for SharedSink { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +fn test_serial_guard() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +struct LeaseHookEnv { + old_addr: Option, + old_mode: Option, +} + +impl LeaseHookEnv { + fn exclusive(barrier: &LeaseBarrier) -> Self { + let guard = Self { + old_addr: std::env::var_os(BARRIER_ADDR), + old_mode: std::env::var_os(BARRIER_MODE), + }; + // SAFETY: both named tests are serialized by `test_serial_guard`; child + // processes receive explicit env values and no other test mutates them. + unsafe { + std::env::set_var(BARRIER_ADDR, barrier.address.to_string()); + std::env::set_var(BARRIER_MODE, "exclusive"); + } + guard + } +} + +impl Drop for LeaseHookEnv { + fn drop(&mut self) { + // SAFETY: serialized by `test_serial_guard`, as above. + unsafe { + match self.old_addr.take() { + Some(value) => std::env::set_var(BARRIER_ADDR, value), + None => std::env::remove_var(BARRIER_ADDR), + } + match self.old_mode.take() { + Some(value) => std::env::set_var(BARRIER_MODE, value), + None => std::env::remove_var(BARRIER_MODE), + } + } + } +} + +/// Only the metadata-comparing arms of `assert_path_still_names_opened_file` +/// (unix, portable) call this. The windows arm reopens the path with +/// `FILE_FLAG_OPEN_REPARSE_POINT` and compares true `FILE_ID_INFO` handles, +/// which subsumes both the alias check and the identity check — so widening +/// this gate to include windows makes the item never-used there. +#[cfg(not(windows))] +fn existing_metadata(path: &Path, operation: &str) -> fs::Metadata { + fs::symlink_metadata(path) + .unwrap_or_else(|error| panic!("{operation} {} failed: {error}", path.display())) +} + +fn is_regular_non_alias(metadata: &fs::Metadata) -> bool { + if !metadata.file_type().is_file() || metadata.file_type().is_symlink() { + return false; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 + } + #[cfg(not(windows))] + true +} + +#[cfg(windows)] +fn open_sqlite_artifact(path: &Path) -> std::io::Result { + use std::os::windows::fs::OpenOptionsExt; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + fs::OpenOptions::new() + .read(true) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) +} + +#[cfg(not(windows))] +fn open_sqlite_artifact(path: &Path) -> std::io::Result { + fs::File::open(path) +} + +#[cfg(unix)] +fn assert_same_file_identity( + path: &Path, + expected: &fs::Metadata, + actual: &fs::Metadata, + operation: &str, +) { + use std::os::unix::fs::MetadataExt; + + assert_eq!( + (actual.dev(), actual.ino()), + (expected.dev(), expected.ino()), + "{operation} changed the fixed SQLite artifact {}", + path.display() + ); +} + +#[cfg(windows)] +fn assert_same_file_identity( + path: &Path, + expected: &fs::Metadata, + actual: &fs::Metadata, + operation: &str, +) { + use std::os::windows::fs::MetadataExt; + + assert!( + expected.file_attributes() == actual.file_attributes() + && expected.creation_time() == actual.creation_time() + && expected.last_write_time() == actual.last_write_time() + && expected.file_size() == actual.file_size(), + "{operation} changed the fixed SQLite artifact {}", + path.display() + ); +} + +#[cfg(not(any(unix, windows)))] +fn assert_same_file_identity( + path: &Path, + expected: &fs::Metadata, + actual: &fs::Metadata, + operation: &str, +) { + assert_eq!( + expected.len(), + actual.len(), + "{operation} changed the fixed SQLite artifact {}", + path.display() + ); +} + +#[cfg(windows)] +fn windows_file_identity(file: &fs::File) -> std::io::Result<(u64, [u8; 16])> { + use std::os::windows::io::AsRawHandle; + + const FILE_ID_INFO_CLASS: i32 = 18; + #[repr(C)] + struct FileIdInfo { + volume_serial_number: u64, + file_id: [u8; 16], + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetFileInformationByHandleEx( + h_file: isize, + file_information_class: i32, + lp_file_information: *mut core::ffi::c_void, + dw_buffer_size: u32, + ) -> i32; + } + + let mut info = FileIdInfo { + volume_serial_number: 0, + file_id: [0; 16], + }; + // SAFETY: `file` owns a live handle and `info` is the exact writable + // FILE_ID_INFO layout required by FileIdInfo. + let ok = unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle() as isize, + FILE_ID_INFO_CLASS, + (&mut info as *mut FileIdInfo).cast(), + core::mem::size_of::() as u32, + ) + }; + if ok == 0 { + return Err(std::io::Error::last_os_error()); + } + Ok((info.volume_serial_number, info.file_id)) +} + +#[cfg(unix)] +fn assert_path_still_names_opened_file(path: &Path, opened: &fs::File) { + let opened_metadata = opened.metadata().unwrap_or_else(|error| { + panic!( + "inspect opened SQLite artifact {} failed: {error}", + path.display() + ) + }); + let path_metadata = existing_metadata(path, "reinspect SQLite artifact"); + assert!( + is_regular_non_alias(&path_metadata), + "SQLite artifact {} changed to an alias or other entry type during snapshot", + path.display() + ); + assert_same_file_identity(path, &opened_metadata, &path_metadata, "reading artifact"); +} + +#[cfg(windows)] +fn assert_path_still_names_opened_file(path: &Path, opened: &fs::File) { + let path_file = open_sqlite_artifact(path).unwrap_or_else(|error| { + panic!( + "reopen fixed SQLite artifact {} failed: {error}", + path.display() + ) + }); + let path_metadata = path_file.metadata().unwrap_or_else(|error| { + panic!( + "reinspect fixed SQLite artifact {} failed: {error}", + path.display() + ) + }); + assert!( + is_regular_non_alias(&path_metadata), + "SQLite artifact {} changed to an alias or other entry type during snapshot", + path.display() + ); + let opened_identity = windows_file_identity(opened).unwrap_or_else(|error| { + panic!( + "identify opened SQLite artifact {} failed: {error}", + path.display() + ) + }); + let path_identity = windows_file_identity(&path_file).unwrap_or_else(|error| { + panic!( + "identify fixed SQLite artifact {} failed: {error}", + path.display() + ) + }); + assert_eq!( + opened_identity, + path_identity, + "fixed SQLite artifact {} changed identity during snapshot", + path.display() + ); +} + +#[cfg(not(any(unix, windows)))] +fn assert_path_still_names_opened_file(path: &Path, opened: &fs::File) { + let opened_metadata = opened.metadata().unwrap_or_else(|error| { + panic!( + "inspect opened SQLite artifact {} failed: {error}", + path.display() + ) + }); + let path_metadata = existing_metadata(path, "reinspect SQLite artifact"); + assert!( + is_regular_non_alias(&path_metadata), + "SQLite artifact {} changed to an alias or other entry type during snapshot", + path.display() + ); + assert_same_file_identity(path, &opened_metadata, &path_metadata, "reading artifact"); +} + +fn sqlite_artifact_bytes(path: &Path) -> Option> { + let initial = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None, + Err(error) => panic!("inspect SQLite artifact {} failed: {error}", path.display()), + }; + assert!( + is_regular_non_alias(&initial), + "SQLite artifact {} must be a regular file, not an alias or other entry type", + path.display() + ); + + let mut file = open_sqlite_artifact(path) + .unwrap_or_else(|error| panic!("open SQLite artifact {} failed: {error}", path.display())); + let opened = file.metadata().unwrap_or_else(|error| { + panic!( + "inspect opened SQLite artifact {} failed: {error}", + path.display() + ) + }); + assert!( + is_regular_non_alias(&opened), + "opened SQLite artifact {} is not a regular file", + path.display() + ); + assert_same_file_identity(path, &initial, &opened, "opening artifact"); + + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .unwrap_or_else(|error| panic!("read SQLite artifact {} failed: {error}", path.display())); + let opened_after = file.metadata().unwrap_or_else(|error| { + panic!( + "reinspect opened SQLite artifact {} failed: {error}", + path.display() + ) + }); + assert_same_file_identity(path, &opened, &opened_after, "reading artifact"); + + assert_path_still_names_opened_file(path, &file); + let bytes_len = u64::try_from(bytes.len()).expect("SQLite artifact length fits u64"); + assert_eq!( + opened_after.len(), + bytes_len, + "SQLite artifact {} changed length during snapshot", + path.display() + ); + assert_eq!( + opened.len(), + bytes_len, + "opened SQLite artifact {} changed length during snapshot", + path.display() + ); + Some(bytes) +} + +fn sqlite_snapshot(paths: &IndexPaths) -> Vec<(PathBuf, Option>)> { + let db = paths.current_db(); + let sidecar = |suffix: &str| { + let mut path = db.as_os_str().to_os_string(); + path.push(suffix); + PathBuf::from(path) + }; + [db.clone(), sidecar("-wal"), sidecar("-shm")] + .into_iter() + .map(|path| (path.clone(), sqlite_artifact_bytes(&path))) + .collect() +} + +fn assert_sqlite_snapshot_unchanged( + before: &[(PathBuf, Option>)], + after: &[(PathBuf, Option>)], + context: &str, +) { + assert_eq!(before.len(), after.len(), "{context}: artifact set changed"); + for ((before_path, before_bytes), (after_path, after_bytes)) in before.iter().zip(after) { + assert_eq!(before_path, after_path, "{context}: artifact path changed"); + assert_eq!( + before_bytes.is_some(), + after_bytes.is_some(), + "{context}: artifact presence changed at {}", + before_path.display() + ); + assert!( + before_bytes == after_bytes, + "{context}: artifact bytes changed at {}", + before_path.display() + ); + } +} + +#[test] +fn sqlite_snapshot_rejects_non_regular_artifact() { + let project = TestProject::indexed("snapshot-non-regular"); + let paths = project.paths(); + let mut wal = paths.current_db().as_os_str().to_os_string(); + wal.push("-wal"); + let wal = PathBuf::from(wal); + fs::create_dir(&wal).expect("stage non-regular SQLite artifact"); + + let result = std::panic::catch_unwind(|| sqlite_snapshot(&paths)); + assert!( + result.is_err(), + "the nonmutation oracle must fail closed on a non-regular artifact" + ); +} + +#[cfg(unix)] +#[test] +fn sqlite_snapshot_rejects_alias_artifact_without_following_it() { + use std::os::unix::fs::symlink; + + let project = TestProject::indexed("snapshot-alias"); + let paths = project.paths(); + let mut wal = paths.current_db().as_os_str().to_os_string(); + wal.push("-wal"); + let wal = PathBuf::from(wal); + let outside = project.path().join("outside-sentinel"); + fs::write(&outside, b"outside").expect("stage alias target"); + symlink(&outside, &wal).expect("stage SQLite artifact alias"); + + let result = std::panic::catch_unwind(|| sqlite_snapshot(&paths)); + assert!( + result.is_err(), + "the nonmutation oracle must reject aliases without reading their targets" + ); + assert_eq!( + fs::read(&outside).expect("read untouched alias target"), + b"outside" + ); +} + +#[test] +fn reader_lease_spans_stamp_check_through_last_row() { + let _serial = test_serial_guard(); + let project = TestProject::indexed("reader-topologies"); + + // CLI query. + { + let barrier = LeaseBarrier::new(); + let mut command = barrier.child_command("shared"); + command + .args(["query", "add", "--path"]) + .arg(project.path()) + .arg("--json") + .env("CODEGRAPH_NO_DAEMON", "1") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let reader = ChildGuard::new(command.spawn().expect("spawn CLI reader")); + let mut release = barrier.wait(b'S'); + assert_writer_blocked(project.path(), "CLI query"); + release.write_all(b"R").expect("release CLI reader"); + let output = reader.finish(); + assert!(output.status.success(), "CLI reader failed after release"); + } + + // Direct stdio MCP. + { + let barrier = LeaseBarrier::new(); + let reader = spawn_stdio_reader(project.path(), &barrier); + let mut release = barrier.wait(b'S'); + assert_writer_blocked(project.path(), "stdio MCP"); + release.write_all(b"R").expect("release stdio MCP reader"); + assert_successful_mcp_output(&reader.finish(), "stdio MCP reader"); + } + + // Daemon session reached through the real local-handshake proxy. + { + let barrier = LeaseBarrier::new(); + let mut daemon_command = Command::new(bin()); + daemon_command + .args(["serve", "--mcp", "--path"]) + .arg(project.path()) + .arg("--no-watch") + .env(codegraph_daemon::CODEGRAPH_DAEMON_INTERNAL, "1") + .env("CODEGRAPH_NO_WATCH", "1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + barrier.configure(&mut daemon_command, "shared"); + let mut daemon = ChildGuard::new(daemon_command.spawn().expect("spawn daemon reader")); + // The daemon's STARTUP shared lease (state/owner/tombstone validation held + // across pid/socket publication) reaches the same barrier first. Release it + // so the rendezvous can be published; the next arrival is the per-request + // lease this test is about, which proves the two acquisitions are distinct. + let mut startup_release = barrier.wait(b'S'); + startup_release + .write_all(b"R") + .expect("release the daemon startup lease"); + wait_for_daemon(project.path()); + + let output = Arc::new(Mutex::new(Vec::new())); + let sink = SharedSink(Arc::clone(&output)); + let socket = codegraph_daemon::recorded_socket_path(project.path()) + .expect("resolve the recorded v2 rendezvous socket"); + let frames = mcp_frames(project.path()); + let (done_tx, done_rx) = mpsc::channel(); + std::thread::spawn(move || { + let result = codegraph_daemon::run_proxy( + &socket, + Some(codegraph_daemon::current_ppid()), + Cursor::new(frames), + sink, + ); + let _ = done_tx.send(result); + }); + + let mut release = barrier.wait(b'S'); + assert_writer_blocked(project.path(), "daemon proxy MCP"); + release + .write_all(b"R") + .expect("release daemon proxy reader"); + let proxy = done_rx + .recv_timeout(WAIT) + .expect("proxy completed before deadline") + .expect("proxy completed successfully"); + assert_eq!(proxy, codegraph_daemon::ProxyOutcome::Proxied); + let proxy_output = String::from_utf8( + output + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(), + ) + .expect("proxy output utf8"); + assert!( + proxy_output.contains("\"id\":2"), + "proxy output: {proxy_output}" + ); + let _ = daemon.child_mut().kill(); + let _ = daemon.child_mut().wait(); + } + + // Streamable HTTP MCP. + { + let barrier = LeaseBarrier::new(); + let address = unused_loopback_addr(); + let registry_dir = project.http_registry_dir(); + assert!( + registry_dir.starts_with(project.path()), + "HTTP registry must be owned by the fixture" + ); + let mut command = barrier.child_command("shared"); + command + .args(["serve", "--http", "--path"]) + .arg(project.path()) + .arg("--http-addr") + .arg(address.to_string()) + .env("CODEGRAPH_HTTP_REGISTRY_DIR", ®istry_dir) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut reader = ChildGuard::new(command.spawn().expect("spawn HTTP MCP reader")); + let mut stream = connect_http(address); + send_http_tool_call(&mut stream, address, project.path()); + let mut release = barrier.wait(b'S'); + assert_writer_blocked(project.path(), "HTTP MCP"); + release.write_all(b"R").expect("release HTTP MCP reader"); + let mut response = String::new(); + stream + .read_to_string(&mut response) + .expect("read HTTP MCP response"); + assert!(response.contains("200 OK"), "HTTP response: {response}"); + assert!(response.contains("add"), "HTTP tool response: {response}"); + let _ = reader.child_mut().kill(); + let _ = reader.child_mut().wait(); + fs::remove_dir_all(®istry_dir).expect("remove fixture-owned HTTP registry"); + match fs::symlink_metadata(®istry_dir) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Ok(_) => panic!("fixture-owned HTTP registry survived explicit cleanup"), + Err(error) => panic!("inspect cleaned HTTP registry failed: {error}"), + } + } + + // Status uses the same retained shared lease as ordinary reads. + { + let barrier = LeaseBarrier::new(); + let mut command = barrier.child_command("shared"); + command + .args(["status", "--json"]) + .arg(project.path()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let reader = ChildGuard::new(command.spawn().expect("spawn status reader")); + let mut release = barrier.wait(b'S'); + assert_writer_blocked(project.path(), "status"); + release.write_all(b"R").expect("release status reader"); + let output = reader.finish(); + assert!(output.status.success(), "status failed after release"); + let value: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("status JSON response"); + assert_eq!(value["initialized"], serde_json::Value::Bool(true)); + } +} + +#[test] +fn writer_blocks_new_reads_without_sqlite_open() { + let _serial = test_serial_guard(); + let project = TestProject::indexed("writer-watcher"); + let paths = project.paths(); + let source = project.path().join("src/math.ts"); + fs::write( + &source, + "export function add(a: number, b: number) { return a + b + 1; }\n", + ) + .expect("change watched source"); + + let barrier = LeaseBarrier::new(); + let _env = LeaseHookEnv::exclusive(&barrier); + let root = project.path().to_path_buf(); + let db = paths.current_db(); + let changed = source.clone(); + let (done_tx, done_rx) = mpsc::channel(); + std::thread::spawn(move || { + let result = codegraph_watch::sync_changed_paths(&root, &db, [changed]); + let _ = done_tx.send(result); + }); + + // The watcher acknowledged exclusive ownership immediately after the kernel + // lock and before Store::open_for_write can open SQLite. + let mut release = barrier.wait(b'X'); + let before = sqlite_snapshot(&paths); + assert_eq!( + run_probe(project.path(), "probe-read"), + "TIMED_OUT", + "a new reader must not pass a held watcher writer" + ); + let after_blocked_read = sqlite_snapshot(&paths); + assert_sqlite_snapshot_unchanged( + &before, + &after_blocked_read, + "the blocked reader must not open or mutate SQLite artifacts", + ); + + // Status contention is data, not an SQLite open or an error. + let output = Command::new(bin()) + .args(["status", "--json"]) + .arg(project.path()) + .output() + .expect("run busy status"); + assert!(output.status.success(), "busy status must succeed"); + let status: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("busy status JSON"); + assert_eq!(status["rebuilding"], serde_json::Value::Bool(true)); + assert_eq!( + status["initialized"], + serde_json::Value::Bool(false), + "busy status cannot corroborate a readable Current index" + ); + let after_busy_status = sqlite_snapshot(&paths); + assert_sqlite_snapshot_unchanged( + &before, + &after_busy_status, + "busy status must not open or mutate SQLite artifacts", + ); + + release.write_all(b"R").expect("release watcher writer"); + done_rx + .recv_timeout(WAIT) + .expect("watcher completed before deadline") + .expect("watcher sync succeeded after release"); +} + +#[test] +fn lease_lifetime_child_process() { + let Ok(action) = std::env::var(CHILD_ACTION) else { + return; + }; + let project = PathBuf::from(std::env::var_os(CHILD_PROJECT).expect("child project env")); + let paths = IndexPaths::resolve(&project, None).expect("child IndexPaths"); + match action.as_str() { + "probe-exclusive" => match IndexLease::acquire_exclusive_existing( + &paths, + Instant::now() + PROBE_WAIT, + || false, + ) { + Ok(lease) => { + println!("ACQUIRED"); + drop(lease); + } + Err(IndexLeaseError::TimedOut { .. }) => println!("TIMED_OUT"), + Err(error) => panic!("unexpected exclusive probe error: {error}"), + }, + "probe-read" => match Store::open_for_read(&paths, Instant::now() + PROBE_WAIT, || false) { + Ok(store) => { + println!("ACQUIRED"); + drop(store); + } + Err(StoreError::Lease(IndexLeaseError::TimedOut { .. })) => println!("TIMED_OUT"), + Err(error) => panic!("unexpected read probe error: {error}"), + }, + other => panic!("unknown lease-lifetime child action {other}"), + } +} diff --git a/crates/codegraph-cli/tests/multi_hump_query_cli.rs b/crates/codegraph-cli/tests/multi_hump_query_cli.rs new file mode 100644 index 0000000..589b45c --- /dev/null +++ b/crates/codegraph-cli/tests/multi_hump_query_cli.rs @@ -0,0 +1,247 @@ +//! Upstream `1de7e8f` (#1319) — a multi-hump field-name query must reach the +//! symbol that DEFINES it, even when the stored name segments differently from +//! the query's humps. +//! +//! Drives the real `codegraph` binary against a temp project holding, for each +//! query shape, three files: +//! * the DEFINER — a callable whose name embeds the query at a hump boundary +//! (`profileInfo` → `getProfileInfoV2`); +//! * a PROSE decoy — a constant whose signature/docstring merely MENTIONS the +//! query words, which is what today's FTS actually returns; +//! * a NAME decoy — a callable whose lowercase name CONTAINS the query's run +//! but at no hump boundary (`xxprofileinfoxx`), which a naive `LIKE +//! %needle%` would bind to. +//! +//! The definer must be returned and must outrank both decoys; the name decoy +//! must not be returned at all. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_codegraph")) +} + +struct TestDir { + path: PathBuf, +} + +impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-multi-hump-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&path).unwrap(); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +struct Run { + stdout: String, + stderr: String, + ok: bool, +} + +fn run_in(cwd: &Path, args: &[&str]) -> Run { + let output = Command::new(bin()) + .args(args) + .current_dir(cwd) + .env("CODEGRAPH_NO_DAEMON", "1") + .output() + .expect("run codegraph binary"); + Run { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + ok: output.status.success(), + } +} + +fn write_project(root: &Path) { + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + + std::fs::write( + src.join("profileController.js"), + "function getProfileInfoV2(userId) {\n return { userId };\n}\n\n\ + function updateUserProfileIdMapping(a, b) {\n return [a, b];\n}\n\n\ + function loadOrderStateSnapshot() {\n return 0;\n}\n\n\ + module.exports = {\n getProfileInfoV2,\n updateUserProfileIdMapping,\n loadOrderStateSnapshot,\n};\n", + ) + .unwrap(); + + std::fs::write( + src.join("prose_decoy.js"), + "const NOTES = \"profileInfo userProfileId orderState profile info user order state\";\n\ + function auditLog(msg) {\n return NOTES + msg;\n}\n\ + module.exports = { auditLog };\n", + ) + .unwrap(); + + std::fs::write( + src.join("name_decoy.js"), + "function xxprofileinfoxx() {\n return 0;\n}\n\n\ + function xxuserprofileidxx() {\n return 1;\n}\n\n\ + function xxorderstatexx() {\n return 2;\n}\n\n\ + module.exports = { xxprofileinfoxx, xxuserprofileidxx, xxorderstatexx };\n", + ) + .unwrap(); +} + +fn indexed_project(dir: &TestDir) -> PathBuf { + let project = dir.path().join("proj"); + std::fs::create_dir_all(&project).unwrap(); + write_project(&project); + let run = run_in(dir.path(), &["init", project.to_str().unwrap()]); + assert!(run.ok, "init failed: {} {}", run.stdout, run.stderr); + project +} + +/// `(name, filePath)` for every hit, in the order the CLI ranked them. +fn ranked(project: &Path, query: &str) -> Vec<(String, String)> { + let p = project.to_str().unwrap(); + let run = run_in(project, &["query", query, "-p", p, "--json"]); + assert!( + run.ok, + "query {query:?} must succeed: {} {}", + run.stdout, run.stderr + ); + let v: serde_json::Value = serde_json::from_str(&run.stdout) + .unwrap_or_else(|e| panic!("query {query:?} must emit JSON ({e}): {}", run.stdout)); + v.as_array() + .expect("query --json is an array") + .iter() + .map(|r| { + ( + r["node"]["name"].as_str().unwrap_or_default().to_string(), + r["node"]["filePath"] + .as_str() + .unwrap_or_default() + .to_string(), + ) + }) + .collect() +} + +/// `(query, definer, name-decoy)` — camelCase, snake_case and PascalCase forms of +/// the same field names, so the fix cannot pass by handling only the one shape +/// it was written for. +const CASES: &[(&str, &str, &str)] = &[ + ("profileInfo", "getProfileInfoV2", "xxprofileinfoxx"), + ("profile_info", "getProfileInfoV2", "xxprofileinfoxx"), + ("ProfileInfo", "getProfileInfoV2", "xxprofileinfoxx"), + ( + "userProfileId", + "updateUserProfileIdMapping", + "xxuserprofileidxx", + ), + ( + "user_profile_id", + "updateUserProfileIdMapping", + "xxuserprofileidxx", + ), + ("orderState", "loadOrderStateSnapshot", "xxorderstatexx"), +]; + +#[test] +fn multi_hump_field_name_query_reaches_its_definer_not_a_prose_or_name_decoy() { + let dir = TestDir::new("definer"); + let project = indexed_project(&dir); + + let mut failures: Vec = Vec::new(); + for (query, definer, name_decoy) in CASES { + let hits = ranked(&project, query); + let pos = |name: &str| hits.iter().position(|(n, _)| n == name); + match pos(definer) { + None => failures.push(format!( + "query {query:?}: definer {definer} ABSENT; ranking was {hits:?}" + )), + Some(d) => { + if let Some(other) = hits + .iter() + .position(|(n, _)| n != definer) + .filter(|other| *other < d) + { + failures.push(format!( + "query {query:?}: {} outranked definer {definer}; ranking was {hits:?}", + hits[other].0 + )); + } + } + } + if let Some(bad) = pos(name_decoy) { + failures.push(format!( + "query {query:?}: non-hump-boundary namesake {name_decoy} must not be returned (at {bad}); ranking was {hits:?}" + )); + } + } + assert!( + failures.is_empty(), + "multi-hump field-name queries must reach their definers:\n{}", + failures.join("\n") + ); +} + +/// A query that IS an exact symbol name keeps binding to that symbol — the +/// infix fallback must never displace an exact definition. +#[test] +fn exact_symbol_query_is_unaffected_by_the_infix_fallback() { + let dir = TestDir::new("exact"); + let project = indexed_project(&dir); + + for name in ["getProfileInfoV2", "updateUserProfileIdMapping", "auditLog"] { + let hits = ranked(&project, name); + assert_eq!( + hits.first().map(|(n, _)| n.as_str()), + Some(name), + "exact symbol {name:?} must still rank first, got {hits:?}" + ); + } +} + +/// A bare single-word query must NOT trigger the multi-hump fallback: it has no +/// humps, so widening it would resurrect the natural-language false positives +/// the stop-word guard exists to prevent. +#[test] +fn single_word_query_does_not_pull_in_infix_namesakes() { + let dir = TestDir::new("single"); + let project = indexed_project(&dir); + + for word in ["profile", "state"] { + let hits = ranked(&project, word); + assert!( + !hits.iter().any(|(n, _)| n.starts_with("xx")), + "single-word query {word:?} must not reach infix namesakes, got {hits:?}" + ); + } +} + +/// Ranking must be a pure function of the index: repeated identical queries +/// answer identically. +#[test] +fn multi_hump_ranking_is_deterministic_across_repeated_queries() { + let dir = TestDir::new("determinism"); + let project = indexed_project(&dir); + let first = ranked(&project, "userProfileId"); + for _ in 0..4 { + assert_eq!( + ranked(&project, "userProfileId"), + first, + "repeated identical queries must return an identical ranking" + ); + } +} diff --git a/crates/codegraph-cli/tests/node_file_pin_cli.rs b/crates/codegraph-cli/tests/node_file_pin_cli.rs new file mode 100644 index 0000000..9db426f --- /dev/null +++ b/crates/codegraph-cli/tests/node_file_pin_cli.rs @@ -0,0 +1,279 @@ +//! Upstream `ce983a0` (#1314) — `codegraph node -f ` must return +//! the symbol's SOURCE BODY, not just its location. +//! +//! `codegraph node setState` prints every overload's body. `-f ` is the +//! tool's own suggestion for picking ONE of them, so it must print that one's +//! body — the whole point of the disambiguation. These tests drive the REAL +//! binary against a temp project with two same-named definitions and assert on +//! the process's stdout. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_codegraph")) +} + +struct TestDir { + path: PathBuf, +} + +impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-node-file-pin-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&path).unwrap(); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +struct Run { + stdout: String, + stderr: String, + ok: bool, +} + +fn run_in(cwd: &Path, args: &[&str]) -> Run { + let output = Command::new(bin()) + .args(args) + .current_dir(cwd) + .env("CODEGRAPH_NO_DAEMON", "1") + .output() + .expect("run codegraph binary"); + Run { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + ok: output.status.success(), + } +} + +/// Two `setState` definitions, each carrying a marker only its own body holds. +fn indexed_project(dir: &TestDir) -> PathBuf { + let project = dir.path().join("proj"); + let src = project.join("src"); + std::fs::create_dir_all(&src).unwrap(); + std::fs::write( + src.join("alpha.ts"), + "export function setState(next: number): number {\n \ + const ALPHA_MARKER = next + 1;\n return ALPHA_MARKER;\n}\n", + ) + .unwrap(); + std::fs::write( + src.join("beta.ts"), + "export function setState(next: string): string {\n \ + const BETA_MARKER = next + \"!\";\n return BETA_MARKER;\n}\n", + ) + .unwrap(); + let run = run_in(dir.path(), &["init", project.to_str().unwrap()]); + assert!(run.ok, "init failed: {} {}", run.stdout, run.stderr); + project +} + +#[test] +fn node_symbol_pinned_to_a_file_prints_that_definitions_body_only() { + let dir = TestDir::new("pin"); + let project = indexed_project(&dir); + let p = project.to_str().unwrap(); + + let run = run_in( + project.as_path(), + &["node", "setState", "-f", "src/beta.ts", "-p", p], + ); + assert!( + run.ok, + "node -f must succeed: {} {}", + run.stdout, run.stderr + ); + assert!( + run.stdout.contains("BETA_MARKER"), + "the pinned definition's SOURCE BODY must be printed: {}", + run.stdout + ); + assert!( + !run.stdout.contains("ALPHA_MARKER"), + "the other overload's body must NOT be printed: {}", + run.stdout + ); + assert!( + run.stdout.contains("src/beta.ts"), + "the pinned location must be named: {}", + run.stdout + ); +} + +/// A basename pins as well as a repo-relative path. +#[test] +fn node_symbol_pinned_by_basename_prints_that_definitions_body() { + let dir = TestDir::new("basename"); + let project = indexed_project(&dir); + let p = project.to_str().unwrap(); + + let run = run_in( + project.as_path(), + &["node", "setState", "-f", "alpha.ts", "-p", p], + ); + assert!(run.ok, "basename pin must succeed: {}", run.stderr); + assert!( + run.stdout.contains("ALPHA_MARKER"), + "the basename-pinned body must be printed: {}", + run.stdout + ); + assert!( + !run.stdout.contains("BETA_MARKER"), + "the other overload's body must NOT be printed: {}", + run.stdout + ); +} + +/// A `--file` that matches no definition of the symbol must say so rather than +/// silently falling back to an arbitrary overload. +#[test] +fn node_symbol_pinned_to_a_non_matching_file_reports_not_found() { + let dir = TestDir::new("nomatch"); + let project = indexed_project(&dir); + let p = project.to_str().unwrap(); + + let run = run_in( + project.as_path(), + &["node", "setState", "-f", "src/nowhere.ts", "-p", p], + ); + assert!( + !run.stdout.contains("ALPHA_MARKER") && !run.stdout.contains("BETA_MARKER"), + "a non-matching pin must not fall back to an arbitrary overload: {}", + run.stdout + ); + assert!( + run.stdout.contains("not found") || run.stdout.contains("No "), + "a non-matching pin must report not-found: {}", + run.stdout + ); +} + +/// The bare (unpinned) form is unchanged: every overload's body, as before. +#[test] +fn bare_node_symbol_still_returns_every_overload_body() { + let dir = TestDir::new("bare"); + let project = indexed_project(&dir); + let p = project.to_str().unwrap(); + + let run = run_in(project.as_path(), &["node", "setState", "-p", p]); + assert!(run.ok, "bare node must succeed: {}", run.stderr); + assert!( + run.stdout.contains("ALPHA_MARKER") && run.stdout.contains("BETA_MARKER"), + "the unpinned form must still return every overload in full: {}", + run.stdout + ); +} + +/// The MCP `codegraph_node` contract: `symbol` + `file` must pin to that file's +/// definition and return its body, over real stdio. +#[cfg(unix)] +#[test] +fn mcp_node_symbol_plus_file_pins_and_returns_the_body() { + use std::io::{BufRead, BufReader, Write}; + use std::process::Stdio; + use std::time::{Duration, Instant}; + + let dir = TestDir::new("mcp"); + let project = indexed_project(&dir); + + let mut child = Command::new(bin()) + .args(["serve", "--mcp", "--path", project.to_str().unwrap()]) + .env("CODEGRAPH_NO_DAEMON", "1") + .env("CODEGRAPH_NO_WATCH", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn serve --mcp"); + let mut stdin = child.stdin.take().expect("child stdin"); + let mut stdout = BufReader::new(child.stdout.take().expect("child stdout")); + let deadline = Instant::now() + Duration::from_secs(30); + + let send = |stdin: &mut std::process::ChildStdin, v: serde_json::Value| { + writeln!(stdin, "{v}").unwrap(); + stdin.flush().unwrap(); + }; + let mut recv = |want_id: i64| -> serde_json::Value { + loop { + assert!(Instant::now() < deadline, "timed out awaiting id {want_id}"); + let mut line = String::new(); + match stdout.read_line(&mut line) { + Ok(0) => panic!("serve --mcp closed stdout before id {want_id}"), + Ok(_) => { + if let Ok(v) = serde_json::from_str::(line.trim()) + && v.get("id").and_then(serde_json::Value::as_i64) == Some(want_id) + { + return v; + } + } + Err(e) => panic!("reading serve --mcp stdout: {e}"), + } + } + }; + + send( + &mut stdin, + serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "node-file-pin-test", "version": "0" } + } + }), + ); + let _ = recv(1); + send( + &mut stdin, + serde_json::json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), + ); + send( + &mut stdin, + serde_json::json!({ + "jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": { + "name": "codegraph_node", + "arguments": { "symbol": "setState", "file": "src/beta.ts", "includeCode": true } + } + }), + ); + let resp = recv(2); + let text = resp["result"]["content"][0]["text"] + .as_str() + .unwrap_or_default() + .to_string(); + drop(stdin); + let _ = child.kill(); + let _ = child.wait(); + + assert_ne!( + resp["result"]["isError"], + serde_json::json!(true), + "MCP node with symbol+file must not error: {text}" + ); + assert!( + text.contains("BETA_MARKER"), + "MCP node symbol+file must return the pinned body: {text}" + ); + assert!( + !text.contains("ALPHA_MARKER"), + "MCP node symbol+file must not return the other overload: {text}" + ); +} diff --git a/crates/codegraph-cli/tests/parallel_index.rs b/crates/codegraph-cli/tests/parallel_index.rs index 0aa2e21..b513789 100644 --- a/crates/codegraph-cli/tests/parallel_index.rs +++ b/crates/codegraph-cli/tests/parallel_index.rs @@ -108,7 +108,8 @@ fn cli(args: &[&str]) -> (String, String, bool) { } fn db_path(project: &Path) -> PathBuf { - project.join(".codegraph").join("codegraph.db") + // Batch M: `init`/`index` now write the isolated v2 namespace. + project.join(".codegraph-v2").join("codegraph.db") } fn init_and_force_index(project: &Path) { diff --git a/crates/codegraph-cli/tests/proxy_e2e.rs b/crates/codegraph-cli/tests/proxy_e2e.rs index 63ccab4..a4b4120 100644 --- a/crates/codegraph-cli/tests/proxy_e2e.rs +++ b/crates/codegraph-cli/tests/proxy_e2e.rs @@ -206,7 +206,7 @@ fn run_proxy_oneshot( #[test] fn proxy_local_handshake_and_forwarded_tool_call() { let (_dir, project) = indexed_project("handshake"); - let socket = daemon_socket_path(&project); + let socket = daemon_socket_path(&project).expect("resolve the v2 rendezvous socket identity"); spawn_detached_daemon(&bin(), &project, false).expect("spawn detached daemon"); let pid = diff --git a/crates/codegraph-cli/tests/status_debug_fields.rs b/crates/codegraph-cli/tests/status_debug_fields.rs index 3f71a9c..44fe66a 100644 --- a/crates/codegraph-cli/tests/status_debug_fields.rs +++ b/crates/codegraph-cli/tests/status_debug_fields.rs @@ -162,7 +162,7 @@ fn status_json_flags_partial_only_when_marker_set() { let (out, err, ok) = cli(&["init", p]); assert!(ok, "init failed: stdout={out} stderr={err}"); - let db = project.join(".codegraph").join("codegraph.db"); + let db = project.join(".codegraph-v2").join("codegraph.db"); { let store = Store::open(&db).unwrap(); store.set_resolution_incomplete().unwrap(); diff --git a/crates/codegraph-cli/tests/sync_incremental.rs b/crates/codegraph-cli/tests/sync_incremental.rs index 0867b14..f7d1d99 100644 --- a/crates/codegraph-cli/tests/sync_incremental.rs +++ b/crates/codegraph-cli/tests/sync_incremental.rs @@ -72,7 +72,8 @@ fn cli(args: &[&str]) -> (String, String, bool) { } fn db_path(project: &Path) -> PathBuf { - project.join(".codegraph").join("codegraph.db") + // Batch M: `init`/`index`/`sync` now write the isolated v2 namespace. + project.join(".codegraph-v2").join("codegraph.db") } #[test] diff --git a/crates/codegraph-cli/tests/unicode_search_cli.rs b/crates/codegraph-cli/tests/unicode_search_cli.rs new file mode 100644 index 0000000..a160787 --- /dev/null +++ b/crates/codegraph-cli/tests/unicode_search_cli.rs @@ -0,0 +1,295 @@ +//! Upstream #1372 — non-ASCII identifiers must be FINDABLE and RANKED +//! correctly through the real user-facing surfaces. +//! +//! Drives the actual `codegraph` binary (`init` then `query --json`) against a +//! temp project whose files are named in SEVEN different scripts, each paired +//! with an ASCII-named DECOY file that defines an identically-named function. +//! The definer inside the script-named file must outrank its decoy, because the +//! query names that file. The scripts are deliberately not just the one the fix +//! was written for: CJK, Cyrillic, Greek, Kana, Hangul, accented Latin, and an +//! ASCII control that must stay byte-identical in behavior. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_codegraph")) +} + +struct TestDir { + path: PathBuf, +} + +impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-unicode-search-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&path).unwrap(); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +struct Run { + stdout: String, + stderr: String, + ok: bool, +} + +fn run_in(cwd: &Path, args: &[&str]) -> Run { + let output = Command::new(bin()) + .args(args) + .current_dir(cwd) + .env("CODEGRAPH_NO_DAEMON", "1") + .output() + .expect("run codegraph binary"); + Run { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + ok: output.status.success(), + } +} + +/// `(module-file stem, function name)` pairs. The module stem is what the query +/// names; the function is defined TWICE — once in `.py`, once in the +/// ASCII decoy `zdecoy_.py`. The decoy is named so it sorts and matches +/// nothing in the query: only path relevance can separate the two. +const CASES: &[(&str, &str, &str)] = &[ + ("示例模块", "handlercjk", "cjk"), + ("модуль", "handlercyr", "cyr"), + ("μονάδα", "handlergrk", "grk"), + ("サンプル", "handlerkana", "kana"), + ("모듈", "handlerkor", "kor"), + ("café_módulo", "handleracc", "acc"), + ("samplemodule", "handlerascii", "ascii"), +]; + +/// A two-character CJK module stem — below the 3-char floor ASCII tokens use. +/// Unsegmented scripts pack a whole word into two characters, so this shape +/// must rank as well as the four-character one. +const SHORT_CASE: (&str, &str, &str) = ("模块", "handlershort", "short"); + +fn write_project(root: &Path) { + let body = |func: &str| format!("def {func}():\n return 1\n"); + for (stem, func, tag) in CASES.iter().chain(std::iter::once(&SHORT_CASE)) { + std::fs::write(root.join(format!("{stem}.py")), body(func)).unwrap(); + std::fs::write(root.join(format!("zdecoy_{tag}.py")), body(func)).unwrap(); + } +} + +/// `(filePath, name)` for every hit, in the order the CLI ranked them. +fn ranked(project: &Path, query: &str) -> Vec<(String, String)> { + let p = project.to_str().unwrap(); + let run = run_in(project, &["query", query, "-p", p, "--json"]); + assert!( + run.ok, + "query {query:?} must succeed: {} {}", + run.stdout, run.stderr + ); + let v: serde_json::Value = serde_json::from_str(&run.stdout) + .unwrap_or_else(|e| panic!("query {query:?} must emit JSON ({e}): {}", run.stdout)); + v.as_array() + .expect("query --json is an array") + .iter() + .map(|r| { + ( + r["node"]["filePath"] + .as_str() + .unwrap_or_default() + .to_string(), + r["node"]["name"].as_str().unwrap_or_default().to_string(), + ) + }) + .collect() +} + +fn indexed_project(dir: &TestDir) -> PathBuf { + let project = dir.path().join("proj"); + std::fs::create_dir_all(&project).unwrap(); + write_project(&project); + let run = run_in(dir.path(), &["init", project.to_str().unwrap()]); + assert!(run.ok, "init failed: {} {}", run.stdout, run.stderr); + project +} + +/// The definer in the file the query NAMES must outrank the same-named decoy in +/// every script, not just the one the fix was written against. +#[test] +fn non_ascii_module_name_outranks_ascii_decoy_in_every_script() { + let dir = TestDir::new("rank"); + let project = indexed_project(&dir); + + let mut failures: Vec = Vec::new(); + for (stem, func, tag) in CASES.iter().chain(std::iter::once(&SHORT_CASE)) { + let hits = ranked(&project, &format!("{stem} {func}")); + let want = format!("{stem}.py"); + let decoy = format!("zdecoy_{tag}.py"); + let pos = |file: &str| hits.iter().position(|(f, n)| f == file && n == func); + match (pos(&want), pos(&decoy)) { + (Some(w), Some(d)) if w < d => {} + (w, d) => failures.push(format!( + "[{tag}] query {stem:?} {func:?}: definer {want} at {w:?}, decoy {decoy} at {d:?}; ranking was {hits:?}" + )), + } + } + assert!( + failures.is_empty(), + "the query names the module file, so its definer must rank first:\n{}", + failures.join("\n") + ); +} + +/// A non-ASCII SYMBOL name (not just a file name) stays findable by exact name. +#[test] +fn non_ascii_symbol_name_is_findable_by_exact_name() { + let dir = TestDir::new("symbol"); + let project = dir.path().join("proj"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write( + project.join("mod.py"), + "def 示例函数():\n return 1\n\n\ndef 데이터조회():\n return 2\n", + ) + .unwrap(); + let run = run_in(dir.path(), &["init", project.to_str().unwrap()]); + assert!(run.ok, "init failed: {} {}", run.stdout, run.stderr); + + for name in ["示例函数", "데이터조회"] { + let hits = ranked(&project, name); + assert_eq!( + hits.first().map(|(_, n)| n.as_str()), + Some(name), + "exact non-ASCII symbol {name:?} must rank first, got {hits:?}" + ); + } +} + +/// The same ranking must hold over the MCP `codegraph_search` contract, not +/// only the CLI: `serve --mcp` against the same index, driven over real stdio. +#[cfg(unix)] +#[test] +fn mcp_search_ranks_the_non_ascii_definer_over_its_decoy() { + use std::io::{BufRead, BufReader, Write}; + use std::process::Stdio; + use std::time::{Duration, Instant}; + + let dir = TestDir::new("mcp"); + let project = indexed_project(&dir); + + let mut child = Command::new(bin()) + .args(["serve", "--mcp", "--path", project.to_str().unwrap()]) + .env("CODEGRAPH_NO_DAEMON", "1") + .env("CODEGRAPH_NO_WATCH", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn serve --mcp"); + let mut stdin = child.stdin.take().expect("child stdin"); + let mut stdout = BufReader::new(child.stdout.take().expect("child stdout")); + let deadline = Instant::now() + Duration::from_secs(30); + + let send = |stdin: &mut std::process::ChildStdin, v: serde_json::Value| { + writeln!(stdin, "{v}").unwrap(); + stdin.flush().unwrap(); + }; + let mut recv = |want_id: i64| -> serde_json::Value { + loop { + assert!(Instant::now() < deadline, "timed out awaiting id {want_id}"); + let mut line = String::new(); + match stdout.read_line(&mut line) { + Ok(0) => panic!("serve --mcp closed stdout before id {want_id}"), + Ok(_) => { + if let Ok(v) = serde_json::from_str::(line.trim()) + && v.get("id").and_then(serde_json::Value::as_i64) == Some(want_id) + { + return v; + } + } + Err(e) => panic!("reading serve --mcp stdout: {e}"), + } + } + }; + + send( + &mut stdin, + serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "unicode-search-test", "version": "0" } + } + }), + ); + let _ = recv(1); + send( + &mut stdin, + serde_json::json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), + ); + + send( + &mut stdin, + serde_json::json!({ + "jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": { + "name": "codegraph_search", + "arguments": { "query": "示例模块 handlercjk" } + } + }), + ); + let resp = recv(2); + let text = resp["result"]["content"][0]["text"] + .as_str() + .unwrap_or_default() + .to_string(); + drop(stdin); + let _ = child.kill(); + let _ = child.wait(); + + assert_ne!( + resp["result"]["isError"], + serde_json::json!(true), + "MCP search must not error on a non-ASCII query: {text}" + ); + let definer = text + .find("示例模块.py") + .unwrap_or_else(|| panic!("MCP search must surface the non-ASCII definer file: {text}")); + let decoy = text + .find("zdecoy_cjk.py") + .unwrap_or_else(|| panic!("MCP search must surface the decoy too: {text}")); + assert!( + definer < decoy, + "MCP search must rank the named module's definer above its decoy: {text}" + ); +} + +/// Ranking must be a pure function of the index, not of incidental row order: +/// the same query over the same index answers identically every time. +#[test] +fn non_ascii_ranking_is_deterministic_across_repeated_queries() { + let dir = TestDir::new("determinism"); + let project = indexed_project(&dir); + let first = ranked(&project, "示例模块 handlercjk"); + for _ in 0..4 { + assert_eq!( + ranked(&project, "示例模块 handlercjk"), + first, + "repeated identical queries must return an identical ranking" + ); + } +} diff --git a/crates/codegraph-core/src/config.rs b/crates/codegraph-core/src/config.rs index 6d368fe..b61654e 100644 --- a/crates/codegraph-core/src/config.rs +++ b/crates/codegraph-core/src/config.rs @@ -1,7 +1,8 @@ //! Configuration module for CodeGraph. //! -//! Reads `/.codegraph/config.toml` via Pattern B (runtime discovery). -//! Config is optional — missing file uses all defaults, matching the upstream zero-config UX. +//! Project-scoped callers load immutable [`Config`] values from the current +//! [`IndexPaths`] root. Config is optional — a missing current-root file uses all +//! defaults, matching the upstream zero-config UX. //! //! ### Config Sources //! - `max_file_size`: upstream extraction/index.ts:101 (skip files >1MB) @@ -15,13 +16,16 @@ //! - watch.enabled: true //! - watch.debounce_ms: 2000 //! -//! Loaded once into a global OnceLock; consumers borrow &'static Config. -//! For libraries: this module returns Result; the binary owns the failure policy. +//! There is NO process-global config. Every project-scoped operation loads its own +//! immutable [`Config`] with [`Config::load_for_paths`] and passes the returned +//! [`Arc`] down; process bootstrap (which has no addressed project yet) uses +//! [`Config::load_env_or_default`], whose result may only configure the logger. +use crate::IndexPaths; use anyhow::{Context, Result}; use serde::Deserialize; use std::path::{Path, PathBuf}; -use std::sync::OnceLock; +use std::sync::Arc; /// Top-level configuration. #[derive(Debug, Clone, Deserialize)] @@ -236,6 +240,19 @@ impl Default for WatchConfig { } } +impl Default for Config { + fn default() -> Self { + Self { + app: AppConfig { + name: "codegraph".to_string(), + log_level: default_log_level(), + }, + indexing: IndexingConfig::default(), + watch: WatchConfig::default(), + } + } +} + impl Config { /// Read and parse a TOML file at `path`. pub fn from_path(path: impl AsRef) -> Result { @@ -247,69 +264,109 @@ impl Config { Ok(cfg) } - /// Discover the config file with a clear precedence: - /// 1. explicit `cli_path` (passed in directly) - /// 2. `APP_CONFIG` env var - /// 3. `./.codegraph/config.toml` (current working directory) - /// 4. `/.codegraph/config.toml` (if provided) + /// Load an immutable config for one resolved project's current index root. /// - /// If no file is found, returns all defaults. - pub fn discover(cli_path: Option<&Path>, project_root: &Path) -> Result { - if let Some(p) = cli_path { - return Self::from_path(p); + /// Precedence is deliberately narrow and project-authoritative: + /// + /// 1. explicit `cli_path`; + /// 2. the process-wide `APP_CONFIG` override; + /// 3. [`IndexPaths::config_toml`] for this project. + /// + /// A missing current-root config returns defaults. An explicitly selected + /// CLI or environment path must exist and parse successfully. This API never + /// consults a legacy `.codegraph/config.toml`, the process working directory, + /// or another project's paths, and it does not cache across calls. + pub fn load_for_paths(cli_path: Option<&Path>, paths: &IndexPaths) -> Result> { + if let Some(path) = cli_path { + return Self::from_path(path).map(Arc::new); } - if let Ok(p) = std::env::var("APP_CONFIG") { - return Self::from_path(p); + if let Some(path) = std::env::var_os("APP_CONFIG") { + return Self::from_path(PathBuf::from(path)).map(Arc::new); } - // Try .codegraph/config.toml relative to project root - let project_config = project_root.join(".codegraph").join("config.toml"); - if project_config.exists() { - return Self::from_path(&project_config); - } + let project_config = paths.config_toml(); + let config = if project_config + .try_exists() + .with_context(|| format!("checking config file: {}", project_config.display()))? + { + Self::from_path(project_config)? + } else { + Self::default() + }; + Ok(Arc::new(config)) + } - // Try ./.codegraph/config.toml (CWD) - let cwd_config = PathBuf::from(".codegraph/config.toml"); - if cwd_config.exists() { - return Self::from_path(&cwd_config); + /// Load the PROCESS-BOOTSTRAP config, before any project is addressed. + /// + /// Precedence is exactly the project-independent prefix of + /// [`Config::load_for_paths`]: an explicit `cli_path`, then the process-wide + /// `APP_CONFIG` override, then all defaults. It never reads a project root, + /// a legacy `.codegraph/config.toml`, or the process working directory. + /// + /// The result may ONLY configure process-wide bootstrap concerns (the logger + /// level). It is never the configuration source for a project operation — a + /// global HTTP server, sync, watcher, or extraction pass loads the addressed + /// project's own config through [`Config::load_for_paths`]. + pub fn load_env_or_default(cli_path: Option<&Path>) -> Result> { + if let Some(path) = cli_path { + return Self::from_path(path).map(Arc::new); } - - // No file found — return all defaults - Ok(Self { - app: AppConfig { - name: "codegraph".to_string(), - log_level: default_log_level(), - }, - indexing: IndexingConfig::default(), - watch: WatchConfig::default(), - }) + if let Some(path) = std::env::var_os("APP_CONFIG") { + return Self::from_path(PathBuf::from(path)).map(Arc::new); + } + Ok(Arc::new(Self::default())) } } -static CONFIG: OnceLock = OnceLock::new(); - -/// Initialize the global config once, early in `main`. Returns the parsed config -/// so `main` can react to errors before continuing. -pub fn init_config(cli_path: Option<&Path>, project_root: &Path) -> Result<&'static Config> { - let cfg = Config::discover(cli_path, project_root)?; - CONFIG - .set(cfg) - .map_err(|_| anyhow::anyhow!("config already initialized"))?; - Ok(CONFIG.get().expect("just set")) -} - -/// Borrow the global config after `init_config` has run. -/// Panics if not initialized; for library use, prefer init_config(). -pub fn get_config() -> &'static Config { - CONFIG - .get() - .expect("config not initialized; call init_config() first") -} - #[cfg(test)] mod tests { use super::*; + static APP_CONFIG_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// Panic-safe process-environment mutation for APP_CONFIG precedence tests. + /// The serialization lock is retained until after the previous value is + /// restored, including when an assertion unwinds through this guard. + struct AppConfigEnvGuard { + previous: Option, + _lock: std::sync::MutexGuard<'static, ()>, + } + + impl AppConfigEnvGuard { + fn set(value: Option<&Path>) -> Self { + let lock = APP_CONFIG_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let previous = std::env::var_os("APP_CONFIG"); + // SAFETY: every APP_CONFIG read/mutation in this test module that can + // overlap these tests is serialized by APP_CONFIG_ENV_LOCK. Drop + // restores the prior value while the same lock is still held. + match value { + Some(path) => unsafe { std::env::set_var("APP_CONFIG", path) }, + None => unsafe { std::env::remove_var("APP_CONFIG") }, + } + Self { + previous, + _lock: lock, + } + } + + fn unset() -> Self { + Self::set(None) + } + } + + impl Drop for AppConfigEnvGuard { + fn drop(&mut self) { + // SAFETY: the serialization guard is still held while restoring the + // process environment on both normal return and panic unwind. + match self.previous.take() { + Some(value) => unsafe { std::env::set_var("APP_CONFIG", value) }, + None => unsafe { std::env::remove_var("APP_CONFIG") }, + } + } + } + #[test] fn test_default_config_parses() { let toml_str = r#" @@ -397,8 +454,8 @@ max_file_size = 2097152 #[test] fn test_missing_file_returns_defaults() { - let cfg = Config::discover(None, Path::new("/tmp/nonexistent")) - .expect("should not error on missing file"); + let _env = AppConfigEnvGuard::unset(); + let cfg = Config::load_env_or_default(None).expect("should not error on missing file"); assert_eq!(cfg.app.log_level, "info"); assert_eq!(cfg.indexing.max_file_size, 1048576); assert!(cfg.watch.enabled); @@ -450,12 +507,12 @@ max_file_size = 2097152 } #[test] - fn discover_prefers_explicit_cli_path() { + fn load_env_or_default_prefers_explicit_cli_path() { let dir = temp_dir("cli-path"); let path = dir.join("explicit.toml"); std::fs::write(&path, "[app]\nname = \"explicit\"\nlog_level = \"error\"\n").unwrap(); - let cfg = Config::discover(Some(&path), Path::new("/tmp/ignored")).expect("discover"); + let cfg = Config::load_env_or_default(Some(&path)).expect("bootstrap config"); assert_eq!(cfg.app.name, "explicit"); assert_eq!(cfg.app.log_level, "error"); @@ -463,21 +520,244 @@ max_file_size = 2097152 } #[test] - fn discover_reads_project_root_codegraph_config() { - let project = temp_dir("project-root"); - let cfg_dir = project.join(".codegraph"); - std::fs::create_dir_all(&cfg_dir).unwrap(); + fn load_env_or_default_honors_app_config_then_falls_back_to_defaults() { + let dir = temp_dir("bootstrap-env"); + let path = dir.join("env.toml"); + std::fs::write(&path, "[app]\nname = \"env\"\nlog_level = \"trace\"\n").unwrap(); + { + let _env = AppConfigEnvGuard::set(Some(&path)); + let cfg = Config::load_env_or_default(None).expect("bootstrap config"); + assert_eq!(cfg.app.name, "env"); + assert_eq!(cfg.app.log_level, "trace"); + } + { + let _env = AppConfigEnvGuard::unset(); + let cfg = Config::load_env_or_default(None).expect("bootstrap config"); + assert_eq!(cfg.app.log_level, default_log_level()); + } + + let _ = std::fs::remove_dir_all(&dir); + } + + /// Bootstrap must never adopt a project or CWD legacy `.codegraph/config.toml`: + /// with `APP_CONFIG` unset it is defaults-only, so it can never become the + /// configuration source for a later per-project operation. + #[test] + fn load_env_or_default_ignores_legacy_project_and_cwd_configs() { + const CHILD_MARKER: &str = "CODEGRAPH_CONFIG_BOOTSTRAP_CHILD"; + + if std::env::var_os(CHILD_MARKER).is_some() { + let cfg = Config::load_env_or_default(None).unwrap(); + assert_eq!(cfg.app.name, "codegraph"); + assert_eq!(cfg.app.log_level, default_log_level()); + return; + } + + let outer = temp_dir("bootstrap-no-legacy"); + std::fs::create_dir_all(outer.join(".codegraph")).unwrap(); std::fs::write( - cfg_dir.join("config.toml"), - "[app]\nname = \"rooted\"\nlog_level = \"debug\"\n", + outer.join(".codegraph/config.toml"), + "[app]\nname = \"legacy-cwd\"\nlog_level = \"error\"\n", ) .unwrap(); - let cfg = Config::discover(None, &project).expect("discover project config"); - assert_eq!(cfg.app.name, "rooted"); - assert_eq!(cfg.app.log_level, "debug"); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .arg("--exact") + .arg("config::tests::load_env_or_default_ignores_legacy_project_and_cwd_configs") + .arg("--nocapture") + .current_dir(&outer) + .env(CHILD_MARKER, "1") + .env_remove("APP_CONFIG") + .output() + .unwrap(); + assert!( + output.status.success(), + "child failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let _ = std::fs::remove_dir_all(outer); + } + + fn project_paths(label: &str) -> (PathBuf, IndexPaths) { + let project = temp_dir(label); + let paths = IndexPaths::resolve(&project, None).expect("resolve project paths"); + (project, paths) + } + + fn write_current_config(paths: &IndexPaths, contents: &str) { + let path = paths.config_toml(); + std::fs::create_dir_all(path.parent().expect("config has parent")).unwrap(); + std::fs::write(path, contents).unwrap(); + } + + #[test] + fn load_for_paths_prefers_explicit_cli_path_over_app_config_and_project() { + let (project, paths) = project_paths("scoped-explicit"); + write_current_config(&paths, "[app]\nname = \"project\"\n"); - let _ = std::fs::remove_dir_all(&project); + let override_path = project.join("override.toml"); + std::fs::write(&override_path, "[app]\nname = \"override\"\n").unwrap(); + let explicit_path = project.join("explicit.toml"); + std::fs::write(&explicit_path, "[app]\nname = \"explicit\"\n").unwrap(); + let _env = AppConfigEnvGuard::set(Some(&override_path)); + + let config = Config::load_for_paths(Some(&explicit_path), &paths).unwrap(); + assert_eq!(config.app.name, "explicit"); + + let _ = std::fs::remove_dir_all(project); + } + + #[test] + fn load_for_paths_reads_only_the_resolved_current_root() { + let _env = AppConfigEnvGuard::unset(); + let (project, paths) = project_paths("scoped-current-root"); + write_current_config( + &paths, + "[app]\nname = \"current-root\"\n[indexing]\nmax_file_size = 321\n", + ); + + let config = Config::load_for_paths(None, &paths).unwrap(); + assert_eq!(config.app.name, "current-root"); + assert_eq!(config.indexing.max_file_size, 321); + + let _ = std::fs::remove_dir_all(project); + } + + #[test] + fn load_for_paths_missing_current_config_returns_defaults() { + let _env = AppConfigEnvGuard::unset(); + let (project, paths) = project_paths("scoped-missing"); + + let config = Config::load_for_paths(None, &paths).unwrap(); + assert_eq!(config.app.name, "codegraph"); + assert_eq!(config.app.log_level, "info"); + assert_eq!(config.indexing.max_file_size, default_max_file_size()); + assert!(config.indexing.include.is_empty()); + assert!(config.indexing.exclude.is_empty()); + + let _ = std::fs::remove_dir_all(project); + } + + #[test] + fn load_for_paths_reports_malformed_current_config() { + let _env = AppConfigEnvGuard::unset(); + let (project, paths) = project_paths("scoped-malformed"); + write_current_config(&paths, "this is not = valid toml [[["); + + let error = Config::load_for_paths(None, &paths).unwrap_err(); + let message = format!("{error:#}"); + assert!( + message.contains("parsing TOML"), + "unexpected error: {message}" + ); + assert!( + message.contains(&paths.config_toml().display().to_string()), + "error must name the project config: {message}" + ); + + let _ = std::fs::remove_dir_all(project); + } + + #[test] + fn load_for_paths_keeps_two_projects_isolated_without_app_config() { + let _env = AppConfigEnvGuard::unset(); + let (alpha_project, alpha_paths) = project_paths("scoped-alpha"); + let (beta_project, beta_paths) = project_paths("scoped-beta"); + write_current_config( + &alpha_paths, + "[app]\nname = \"alpha\"\n[indexing]\nmax_file_size = 111\ninclude = [\"alpha/**\"]\nexclude = [\"beta/**\"]\n", + ); + write_current_config( + &beta_paths, + "[app]\nname = \"beta\"\n[indexing]\nmax_file_size = 222\ninclude = [\"beta/**\"]\nexclude = [\"alpha/**\"]\n", + ); + + let alpha = Config::load_for_paths(None, &alpha_paths).unwrap(); + let beta = Config::load_for_paths(None, &beta_paths).unwrap(); + assert!(!Arc::ptr_eq(&alpha, &beta)); + assert_eq!(alpha.indexing.max_file_size, 111); + assert_eq!(alpha.indexing.include, ["alpha/**"]); + assert_eq!(alpha.indexing.exclude, ["beta/**"]); + assert_eq!(beta.indexing.max_file_size, 222); + assert_eq!(beta.indexing.include, ["beta/**"]); + assert_eq!(beta.indexing.exclude, ["alpha/**"]); + + let _ = std::fs::remove_dir_all(alpha_project); + let _ = std::fs::remove_dir_all(beta_project); + } + + #[test] + fn load_for_paths_app_config_intentionally_overrides_two_projects() { + let (alpha_project, alpha_paths) = project_paths("override-alpha"); + let (beta_project, beta_paths) = project_paths("override-beta"); + write_current_config(&alpha_paths, "[app]\nname = \"alpha\"\n"); + write_current_config(&beta_paths, "[app]\nname = \"beta\"\n"); + let override_path = alpha_project.join("global-override.toml"); + std::fs::write( + &override_path, + "[app]\nname = \"global\"\n[indexing]\nmax_file_size = 777\ninclude = [\"shared/**\"]\nexclude = [\"private/**\"]\n", + ) + .unwrap(); + let _env = AppConfigEnvGuard::set(Some(&override_path)); + + let alpha = Config::load_for_paths(None, &alpha_paths).unwrap(); + let beta = Config::load_for_paths(None, &beta_paths).unwrap(); + for config in [&alpha, &beta] { + assert_eq!(config.app.name, "global"); + assert_eq!(config.indexing.max_file_size, 777); + assert_eq!(config.indexing.include, ["shared/**"]); + assert_eq!(config.indexing.exclude, ["private/**"]); + } + + let _ = std::fs::remove_dir_all(alpha_project); + let _ = std::fs::remove_dir_all(beta_project); + } + + #[test] + fn load_for_paths_ignores_legacy_project_and_cwd_configs() { + const CHILD_PROJECT: &str = "CODEGRAPH_CONFIG_CWD_CHILD_PROJECT"; + + if let Some(project) = std::env::var_os(CHILD_PROJECT) { + let paths = IndexPaths::resolve(Path::new(&project), None).unwrap(); + let config = Config::load_for_paths(None, &paths).unwrap(); + assert_eq!(config.app.name, "codegraph"); + return; + } + + let outer = temp_dir("scoped-no-legacy"); + let project = outer.join("project"); + std::fs::create_dir_all(project.join(".codegraph")).unwrap(); + std::fs::write( + project.join(".codegraph/config.toml"), + "[app]\nname = \"legacy-project\"\n", + ) + .unwrap(); + std::fs::create_dir_all(outer.join(".codegraph")).unwrap(); + std::fs::write( + outer.join(".codegraph/config.toml"), + "[app]\nname = \"legacy-cwd\"\n", + ) + .unwrap(); + + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .arg("--exact") + .arg("config::tests::load_for_paths_ignores_legacy_project_and_cwd_configs") + .arg("--nocapture") + .current_dir(&outer) + .env(CHILD_PROJECT, &project) + .env_remove("APP_CONFIG") + .output() + .unwrap(); + assert!( + output.status.success(), + "child failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let _ = std::fs::remove_dir_all(outer); } #[test] @@ -554,30 +834,23 @@ ignore_paths = ["custom/gen*"] let cfg: Config = toml::from_str(with_override).expect("should parse"); assert_eq!(cfg.indexing.ignore_paths, vec!["custom/gen*"]); } + /// Two loads of the SAME project return independent immutable values, so no + /// caller can observe (or mutate) a shared process-wide config instance. #[test] - fn init_and_get_config_share_the_global_singleton() { - let dir = std::env::temp_dir().join(format!( - "codegraph-config-init-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let cg = dir.join(".codegraph"); - std::fs::create_dir_all(&cg).unwrap(); - std::fs::write( - cg.join("config.toml"), - "[app]\nname = \"global-singleton\"\n", - ) - .unwrap(); - let first = init_config(None, &dir).expect("first init succeeds"); - assert_eq!(first.app.name, "global-singleton"); - assert_eq!(get_config().app.name, "global-singleton"); + fn repeated_loads_are_independent_immutable_values() { + let _env = AppConfigEnvGuard::unset(); + let (project, paths) = project_paths("no-singleton"); + write_current_config(&paths, "[app]\nname = \"scoped\"\n"); + + let first = Config::load_for_paths(None, &paths).unwrap(); + let second = Config::load_for_paths(None, &paths).unwrap(); assert!( - init_config(None, &dir).is_err(), - "a second init must fail (already initialized)" + !Arc::ptr_eq(&first, &second), + "load_for_paths must not hand back a cached/shared instance" ); - let _ = std::fs::remove_dir_all(&dir); + assert_eq!(first.app.name, "scoped"); + assert_eq!(second.app.name, "scoped"); + + let _ = std::fs::remove_dir_all(project); } } diff --git a/crates/codegraph-core/src/index_paths.rs b/crates/codegraph-core/src/index_paths.rs new file mode 100644 index 0000000..ac06bad --- /dev/null +++ b/crates/codegraph-core/src/index_paths.rs @@ -0,0 +1,1086 @@ +//! Single source of truth for CodeGraph's on-disk index paths. +//! +//! Frozen plan `upstream-v1.5-portable-fixes.md`, Batch M ("Product boundary and +//! selected storage layout", plan lines 236-338): `IndexPaths` is the ONE path +//! authority shared by CLI, store, daemon, watch, and MCP. Production code must +//! NOT reconstruct `.codegraph*` paths independently. +//! +//! This module implements the PATH LAYER ONLY (the first Batch M slice). It +//! computes, for a given project and optional `CODEGRAPH_DIR` override: +//! +//! - the physical [`project_identity`](IndexPaths::project_identity) (full +//! lowercase SHA-256 of a versioned binary payload of the OS filesystem +//! identifiers — `st_dev`/`st_ino` on Unix, volume serial + 128-bit file id on +//! Windows). Unsupported filesystems fail closed; there is NO lexical-spelling +//! fallback; +//! - the normalized [`legacy_roots`](IndexPaths::legacy_roots) set: the fixed +//! `/.codegraph` root plus, when `CODEGRAPH_DIR` is set, the old CLI +//! root (relative-or-absolute), modeling v0.40.4's inconsistent env handling; +//! - the isolated [`current_root`](IndexPaths::current_root) — `/.codegraph-v2` +//! by default, or a `-v2-` SIBLING of the normalized +//! legacy root when `CODEGRAPH_DIR` is set; +//! - every current-root-owned artifact path (DB, permanent lock, the two fixed +//! state slots, the `uninitialized` tombstone, `config.toml`, `codegraph.json`, +//! and the daemon pid/log/socket identities). +//! +//! Path identity is PHYSICAL, not lexical: the project and each existing legacy +//! root are canonicalized; a not-yet-created root canonicalizes its nearest +//! existing ancestor and appends the normalized remainder. Empty/root/dot/parent +//! aliases, symlink (or Windows reparse-point) components below the canonical +//! ancestor, equality with any legacy identity, and any ancestor/descendant +//! overlap with a legacy identity all fail closed with typed, stable diagnostics. +//! +//! This slice provides the path VALUES only; the state-slot / lease / Store open +//! protocol that CONSUMES the state-slot, tombstone, and lock paths lands in a +//! later Batch M task. + +use std::path::{Component, Path, PathBuf}; + +use sha2::{Digest, Sha256}; +use thiserror::Error; + +/// Default current-root directory name (no `CODEGRAPH_DIR`): a sibling of the +/// fixed legacy `.codegraph` root. This is the ONE place the `.codegraph-v2` +/// literal is defined; every production consumer derives its default root from +/// [`IndexPaths::resolve`]. +pub const DEFAULT_CURRENT_DIR: &str = ".codegraph-v2"; +/// Fixed legacy root directory name used by old daemon/watch/MCP paths. +pub const LEGACY_DIR: &str = ".codegraph"; +/// Infix inserted into a configured legacy root's final component to form the +/// sibling current root (`-v2-`). +const CONFIGURED_SUFFIX_INFIX: &str = "-v2-"; + +/// Version byte of the binary payload hashed into the project identity. Bump +/// only with a deliberate, reviewed identity-format change. +const IDENTITY_PAYLOAD_VERSION: u8 = 1; +/// Magic prefix so the identity payload can never collide with an unrelated +/// SHA-256 preimage. +const IDENTITY_MAGIC: &[u8; 5] = b"cgpid"; +#[cfg(unix)] +const IDENTITY_PLATFORM_UNIX: u8 = 1; +#[cfg(windows)] +const IDENTITY_PLATFORM_WINDOWS: u8 = 2; + +/// Errors from [`IndexPaths::resolve`]. All variants carry stable, deterministic +/// diagnostics; there is never a fallback from physical identity to path +/// spelling on unsupported filesystems. +#[derive(Debug, Error)] +pub enum IndexPathsError { + /// The project path could not be canonicalized (missing or inaccessible). + #[error("project path is not accessible: {path} ({source})")] + ProjectInaccessible { + path: PathBuf, + source: std::io::Error, + }, + + /// The filesystem does not expose the stable physical identifiers required to + /// derive a project identity. Fail closed — never fall back to path spelling. + #[error( + "cannot derive a physical project identity for {path}: \ + the filesystem does not expose stable identifiers ({detail})" + )] + UnsupportedFilesystem { path: PathBuf, detail: String }, + + /// A path component could not be canonicalized (an existing ancestor became + /// inaccessible mid-resolution). + #[error("cannot canonicalize index path component {path}: {source}")] + Canonicalize { + path: PathBuf, + source: std::io::Error, + }, + + /// An empty/filesystem-root/`.`/`..` alias was supplied or derived. + #[error("refusing an unsafe index root {path}: {reason}")] + RootAlias { path: PathBuf, reason: String }, + + /// A symlink (or Windows reparse-point) component was found below the + /// canonical ancestor of an index root. Fail closed rather than follow it. + #[error( + "refusing index root: {path} is (or descends through) a symlink / \ + reparse point below its canonical ancestor" + )] + SymlinkComponent { path: PathBuf }, + + /// The derived current root equals, contains, or is contained by a legacy + /// root. The current namespace must be fully disjoint from every legacy one. + #[error( + "refusing current index root {current}: it overlaps the legacy root \ + {legacy} ({reason})" + )] + LegacyOverlap { + current: PathBuf, + legacy: PathBuf, + reason: String, + }, +} + +/// The resolved, physical, fail-closed set of index paths for one project. +/// +/// Construct via [`IndexPaths::resolve`]. Every artifact path is derived from a +/// single validated [`current_root`](IndexPaths::current_root); callers consume +/// these accessors instead of rebuilding `.codegraph-v2` strings. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IndexPaths { + project: PathBuf, + project_identity: String, + legacy_roots: Vec, + current_root: PathBuf, +} + +impl IndexPaths { + /// Resolve the physical index paths for `project`, honoring an optional + /// `CODEGRAPH_DIR` override (relative or absolute). Production callers pass + /// `std::env::var("CODEGRAPH_DIR").ok().as_deref()`. + /// + /// `project` MUST exist (it is canonicalized to derive the physical + /// identity). Fails closed on unsafe roots, symlink aliases, overlap with a + /// legacy root, and unsupported filesystems. + pub fn resolve(project: &Path, codegraph_dir: Option<&str>) -> Result { + let project = + project + .canonicalize() + .map_err(|source| IndexPathsError::ProjectInaccessible { + path: project.to_path_buf(), + source, + })?; + + let project_identity = physical_identity(&project)?; + + // Legacy root #1: the fixed `/.codegraph` (old daemon/watch/MCP). + let legacy_fixed = physical_normalize(&project, &project.join(LEGACY_DIR))?; + let mut legacy_roots = vec![legacy_fixed]; + + // Legacy root #2 + configured current root: only when CODEGRAPH_DIR is set. + let configured = codegraph_dir.filter(|value| !value.is_empty()); + let current_root = if let Some(value) = configured { + let raw = Path::new(value); + let cli_root = if raw.is_absolute() { + raw.to_path_buf() + } else { + project.join(raw) + }; + let cli_root = lexical_normalize(&cli_root); + reject_project_alias(&project, &cli_root, value)?; + let cli_root = physical_normalize(&project, &cli_root)?; + + // Sibling `-v2-`, never a child. + let parent = cli_root + .parent() + .ok_or_else(|| IndexPathsError::RootAlias { + path: cli_root.clone(), + reason: "configured legacy root has no parent directory".to_string(), + })?; + let name = cli_root + .file_name() + .ok_or_else(|| IndexPathsError::RootAlias { + path: cli_root.clone(), + reason: "configured legacy root has no final component".to_string(), + })? + .to_string_lossy() + .into_owned(); + let sibling_name = format!("{name}{CONFIGURED_SUFFIX_INFIX}{project_identity}"); + let current = parent.join(sibling_name); + + if !legacy_roots.contains(&cli_root) { + legacy_roots.push(cli_root); + } + physical_normalize(&project, ¤t)? + } else { + physical_normalize(&project, &project.join(DEFAULT_CURRENT_DIR))? + }; + + // Fail closed on any equality or ancestor/descendant overlap with a + // legacy identity: the current namespace must be fully disjoint. + for legacy in &legacy_roots { + if let Some(reason) = overlap_reason(¤t_root, legacy) { + return Err(IndexPathsError::LegacyOverlap { + current: current_root.clone(), + legacy: legacy.clone(), + reason: reason.to_string(), + }); + } + } + + Ok(Self { + project, + project_identity, + legacy_roots, + current_root, + }) + } + + /// The EXACT resolved physical index-root PATHS the scanner/watcher must + /// exclude before descending: every fixed/configured legacy root plus the + /// current root, as full normalized paths (not basenames). The scanner + /// compares each candidate directory path against this set, so a nested + /// configured root such as `/cache/index` is excluded at its true + /// depth while an unrelated directory that merely shares a basename is not. + /// + /// In-project roots are re-anchored onto the caller's `project` spelling + /// (`resolve` canonicalizes) so scanner candidates compare directly; roots + /// outside the project keep their absolute form. Always includes the fixed + /// `/.codegraph`. On a `resolve` failure it degrades to just the two + /// safe default paths (fixed `.codegraph` and the default `.codegraph-v2`) + /// rather than reconstructing a configured-root path; the fail-closed DB + /// contract is enforced separately at the CLI/MCP/watch boundary by + /// [`IndexPaths::resolve`]. + pub fn reserved_index_roots( + project: &Path, + codegraph_dir: Option<&str>, + ) -> std::collections::BTreeSet { + let mut roots = std::collections::BTreeSet::new(); + match Self::resolve(project, codegraph_dir) { + Ok(paths) => { + let canonical = paths.project(); + let reanchor = |root: &Path| -> PathBuf { + match root.strip_prefix(canonical) { + Ok(rel) => project.join(rel), + Err(_) => root.to_path_buf(), + } + }; + roots.insert(project.join(LEGACY_DIR)); + for legacy in paths.legacy_roots() { + roots.insert(reanchor(legacy)); + } + roots.insert(reanchor(paths.current_root())); + } + Err(_) => { + roots.insert(project.join(LEGACY_DIR)); + roots.insert(project.join(DEFAULT_CURRENT_DIR)); + } + } + roots + } + + /// Canonical project root. + pub fn project(&self) -> &Path { + &self.project + } + + /// Full lowercase SHA-256 (64 hex chars) of the versioned physical-identity + /// payload. Same value seeds state ownership and configured-root suffixes. + pub fn project_identity(&self) -> &str { + &self.project_identity + } + + /// The normalized legacy-root set: the fixed `/.codegraph` plus, + /// when `CODEGRAPH_DIR` is set, the old CLI root. New binaries never open, + /// migrate, or write these. + pub fn legacy_roots(&self) -> &[PathBuf] { + &self.legacy_roots + } + + /// The isolated current index root (`.codegraph-v2` by default; a + /// `-v2-` sibling of the configured legacy root when + /// `CODEGRAPH_DIR` is set). + pub fn current_root(&self) -> &Path { + &self.current_root + } + + /// `/codegraph.db`. + pub fn current_db(&self) -> PathBuf { + self.current_root.join("codegraph.db") + } + + /// `/index.lock` — the permanent lock file (never truncated or + /// deleted by normal operation). + pub fn permanent_lock(&self) -> PathBuf { + self.current_root.join("index.lock") + } + + /// The two fixed state-slot files `index-state.{0,1}.json`, in slot order. + pub fn state_slots(&self) -> [PathBuf; 2] { + [ + self.current_root.join("index-state.0.json"), + self.current_root.join("index-state.1.json"), + ] + } + + /// `/uninitialized` — the interrupted-uninit tombstone marker. + pub fn tombstone(&self) -> PathBuf { + self.current_root.join("uninitialized") + } + + /// `/config.toml` — the project-scoped default config. + pub fn config_toml(&self) -> PathBuf { + self.current_root.join("config.toml") + } + + /// `/codegraph.json` — the project-scoped custom-extension / + /// Godot DSL config. + pub fn extension_config(&self) -> PathBuf { + self.current_root.join("codegraph.json") + } + + /// `/daemon.pid`. + pub fn daemon_pid(&self) -> PathBuf { + self.current_root.join("daemon.pid") + } + + /// `/daemon.log`. + pub fn daemon_log(&self) -> PathBuf { + self.current_root.join("daemon.log") + } + + /// `/daemon.sock` — the in-root daemon socket identity. (The + /// POSIX tmp-socket fallback and Windows namespaced-pipe forms, which carry + /// the v2 protocol discriminator, are owned by the daemon lifecycle layer in + /// a later slice.) + pub fn daemon_socket(&self) -> PathBuf { + self.current_root.join("daemon.sock") + } +} + +/// Lexically normalize an absolute path: drop `.` components and fold each `..` +/// into the preceding normal component. Never touches the filesystem. +fn lexical_normalize(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + if matches!(out.components().next_back(), Some(Component::Normal(_))) { + out.pop(); + } else { + out.push(component); + } + } + other => out.push(other), + } + } + out +} + +/// Reject a configured legacy root that aliases the project itself, an ancestor +/// of the project, or the filesystem root — the `.`/`..`/root alias cases. +fn reject_project_alias( + project: &Path, + cli_root: &Path, + raw_value: &str, +) -> Result<(), IndexPathsError> { + if cli_root.parent().is_none() { + return Err(IndexPathsError::RootAlias { + path: cli_root.to_path_buf(), + reason: format!("CODEGRAPH_DIR={raw_value:?} resolves to the filesystem root"), + }); + } + if cli_root == project { + return Err(IndexPathsError::RootAlias { + path: cli_root.to_path_buf(), + reason: format!("CODEGRAPH_DIR={raw_value:?} resolves to the project root itself"), + }); + } + if project.starts_with(cli_root) { + return Err(IndexPathsError::RootAlias { + path: cli_root.to_path_buf(), + reason: format!("CODEGRAPH_DIR={raw_value:?} resolves to an ancestor of the project"), + }); + } + Ok(()) +} + +/// The deepest ancestor of `path` (inclusive) that currently exists, if any. +fn nearest_existing_ancestor(path: &Path) -> Option { + let mut current: Option<&Path> = Some(path); + while let Some(candidate) = current { + if candidate.symlink_metadata().is_ok() { + return Some(candidate.to_path_buf()); + } + current = candidate.parent(); + } + None +} + +/// Whether `path` currently exists AND is a symlink or reparse point. +/// +/// On Windows `FileType::is_symlink` misses directory junctions and other +/// reparse points, so the raw `FILE_ATTRIBUTE_REPARSE_POINT` attribute bit is +/// checked as well; those are the alias forms an index root must refuse. +fn is_symlink(path: &Path) -> bool { + let Ok(meta) = path.symlink_metadata() else { + return false; + }; + if meta.file_type().is_symlink() { + return true; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt as _; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; + if meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return true; + } + } + false +} + +/// Physically normalize an index root: reject empty/root aliases, reject any +/// symlink component below the canonical ancestor, and return the path anchored +/// on the canonicalized nearest-existing ancestor. +fn physical_normalize(project: &Path, target: &Path) -> Result { + let target = lexical_normalize(target); + if target.parent().is_none() { + return Err(IndexPathsError::RootAlias { + path: target.clone(), + reason: "path is the filesystem root".to_string(), + }); + } + + let existing = + nearest_existing_ancestor(&target).ok_or_else(|| IndexPathsError::RootAlias { + path: target.clone(), + reason: "no existing ancestor directory".to_string(), + })?; + + // `project` is already canonical (no symlink components). For a target under + // the project, probe only the tail below it and reject symlink components — + // the components above are the trusted physical base. + if let Ok(tail) = existing.strip_prefix(project) { + let mut probe = project.to_path_buf(); + for component in tail.components() { + probe.push(component); + if is_symlink(&probe) { + return Err(IndexPathsError::SymlinkComponent { path: probe }); + } + } + let remainder = target + .strip_prefix(&existing) + .expect("existing is an ancestor of target"); + return Ok(existing.join(remainder)); + } + + // Target outside the project (an absolute CODEGRAPH_DIR or its sibling): + // reject an alias in ANY existing component, not just the last one. + // `/link/child` where `link` is a symlink/junction reaches an ordinary + // `child` directory through the alias, so checking only `existing` would + // silently follow it. Walk every prefix from the filesystem root down to + // `existing` and reject the first symlink/reparse component. + let mut probe = PathBuf::new(); + for component in existing.components() { + probe.push(component); + if is_symlink(&probe) { + return Err(IndexPathsError::SymlinkComponent { path: probe }); + } + } + let base = existing + .canonicalize() + .map_err(|source| IndexPathsError::Canonicalize { + path: existing.clone(), + source, + })?; + let remainder = target + .strip_prefix(&existing) + .expect("existing is an ancestor of target"); + Ok(base.join(remainder)) +} + +/// If `current` and `legacy` overlap (equal, or one contains the other), return +/// a stable reason string; otherwise `None`. +fn overlap_reason(current: &Path, legacy: &Path) -> Option<&'static str> { + if current == legacy { + Some("equal paths") + } else if current.starts_with(legacy) { + Some("current root is inside the legacy root") + } else if legacy.starts_with(current) { + Some("legacy root is inside the current root") + } else { + None + } +} + +/// Compute the full lowercase SHA-256 of the versioned physical-identity payload +/// for a canonical project path. Fails closed on unsupported filesystems. +fn physical_identity(project: &Path) -> Result { + let payload = identity_payload(project)?; + let mut hasher = Sha256::new(); + hasher.update(&payload); + Ok(hex_lower(&hasher.finalize())) +} + +#[cfg(unix)] +fn identity_payload(project: &Path) -> Result, IndexPathsError> { + use std::os::unix::fs::MetadataExt as _; + + // `st_dev` + `st_ino` uniquely identify a filesystem object on POSIX; std + // exposes them without an external crate. A path that cannot be stat'd fails + // closed rather than falling back to spelling. + let meta = project + .metadata() + .map_err(|source| IndexPathsError::UnsupportedFilesystem { + path: project.to_path_buf(), + detail: format!("stat failed: {source}"), + })?; + let dev = meta.dev(); + let ino = meta.ino(); + + let mut payload = Vec::with_capacity(IDENTITY_MAGIC.len() + 2 + 16); + payload.extend_from_slice(IDENTITY_MAGIC); + payload.push(IDENTITY_PAYLOAD_VERSION); + payload.push(IDENTITY_PLATFORM_UNIX); + payload.extend_from_slice(&dev.to_le_bytes()); + payload.extend_from_slice(&ino.to_le_bytes()); + Ok(payload) +} + +#[cfg(windows)] +fn identity_payload(project: &Path) -> Result, IndexPathsError> { + // The volume serial number + 128-bit file id from + // GetFileInformationByHandleEx(FileIdInfo) are the stable physical + // identifiers on Windows (the 64-bit BY_HANDLE index is insufficient on + // ReFS). Raw kernel32 FFI avoids adding a dependency; the whole block is + // `cfg(windows)` and never compiled on Unix CI. + let (volume_serial, file_id) = windows_identity::file_id_info(project)?; + + let mut payload = Vec::with_capacity(IDENTITY_MAGIC.len() + 2 + 8 + 16); + payload.extend_from_slice(IDENTITY_MAGIC); + payload.push(IDENTITY_PAYLOAD_VERSION); + payload.push(IDENTITY_PLATFORM_WINDOWS); + payload.extend_from_slice(&volume_serial.to_le_bytes()); + payload.extend_from_slice(&file_id); + Ok(payload) +} + +#[cfg(not(any(unix, windows)))] +fn identity_payload(project: &Path) -> Result, IndexPathsError> { + // Fail closed: no stable physical identifier available. NEVER fall back to + // path spelling. + Err(IndexPathsError::UnsupportedFilesystem { + path: project.to_path_buf(), + detail: "platform exposes no stable filesystem object identifier".to_string(), + }) +} + +#[cfg(windows)] +mod windows_identity { + //! Minimal raw kernel32 FFI for the physical file identity. Compiled only on + //! Windows; adds no crate dependency. + + use std::os::windows::ffi::OsStrExt as _; + use std::path::Path; + + use super::IndexPathsError; + + // FILE_INFO_BY_HANDLE_CLASS::FileIdInfo + const FILE_ID_INFO_CLASS: i32 = 18; + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + const OPEN_EXISTING: u32 = 3; + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; + const FILE_SHARE_DELETE: u32 = 0x0000_0004; + const INVALID_HANDLE_VALUE: isize = -1; + + #[repr(C)] + struct FileIdInfo { + volume_serial_number: u64, + file_id: [u8; 16], + } + + #[link(name = "kernel32")] + unsafe extern "system" { + fn CreateFileW( + lp_file_name: *const u16, + dw_desired_access: u32, + dw_share_mode: u32, + lp_security_attributes: *mut core::ffi::c_void, + dw_creation_disposition: u32, + dw_flags_and_attributes: u32, + h_template_file: isize, + ) -> isize; + fn GetFileInformationByHandleEx( + h_file: isize, + file_information_class: i32, + lp_file_information: *mut core::ffi::c_void, + dw_buffer_size: u32, + ) -> i32; + fn CloseHandle(h_object: isize) -> i32; + } + + /// Return `(volume_serial_number, 128-bit file id)` for `path`, failing + /// closed on any filesystem that does not support the query. + pub(super) fn file_id_info(path: &Path) -> Result<(u64, [u8; 16]), IndexPathsError> { + let mut wide: Vec = path.as_os_str().encode_wide().collect(); + wide.push(0); + + // SAFETY: `wide` is a NUL-terminated UTF-16 path; all other pointers are + // null/valid and the handle is closed on every path below. + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + 0, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + core::ptr::null_mut(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, + 0, + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(IndexPathsError::UnsupportedFilesystem { + path: path.to_path_buf(), + detail: format!("CreateFileW failed: {}", std::io::Error::last_os_error()), + }); + } + + let mut info = FileIdInfo { + volume_serial_number: 0, + file_id: [0u8; 16], + }; + // SAFETY: `handle` is valid; `info` is a correctly sized FILE_ID_INFO. + let ok = unsafe { + GetFileInformationByHandleEx( + handle, + FILE_ID_INFO_CLASS, + (&mut info as *mut FileIdInfo).cast(), + core::mem::size_of::() as u32, + ) + }; + // Capture the query's OS error BEFORE CloseHandle, which itself sets the + // thread-local last-error and would otherwise mask the real failure. + let query_err = (ok == 0).then(std::io::Error::last_os_error); + // SAFETY: close the handle exactly once regardless of the query result. + unsafe { + CloseHandle(handle); + } + + if let Some(err) = query_err { + return Err(IndexPathsError::UnsupportedFilesystem { + path: path.to_path_buf(), + detail: format!("GetFileInformationByHandleEx(FileIdInfo) failed: {err}"), + }); + } + Ok((info.volume_serial_number, info.file_id)) + } +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0x0f) as usize] as char); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_dir(label: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "cg-indexpaths-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + // Canonicalize so expectations compare against the same physical base + // `resolve` uses (macOS `/tmp` is a symlink to `/private/tmp`). + dir.canonicalize().unwrap() + } + + #[test] + fn default_current_root_is_sibling_codegraph_v2() { + let project = temp_dir("default"); + let paths = IndexPaths::resolve(&project, None).expect("resolve default"); + + assert_eq!(paths.current_root(), project.join(".codegraph-v2")); + assert_eq!(paths.legacy_roots(), [project.join(".codegraph")]); + assert_eq!( + paths.current_db(), + project.join(".codegraph-v2").join("codegraph.db") + ); + // The default current root is a SIBLING of the legacy root, never a child. + assert!(!paths.current_root().starts_with(project.join(".codegraph"))); + + let _ = std::fs::remove_dir_all(&project); + } + + #[test] + fn every_derived_artifact_lives_under_current_root() { + let project = temp_dir("artifacts"); + let paths = IndexPaths::resolve(&project, None).expect("resolve"); + let root = project.join(".codegraph-v2"); + + assert_eq!(paths.current_root(), root); + assert_eq!(paths.current_db(), root.join("codegraph.db")); + assert_eq!(paths.permanent_lock(), root.join("index.lock")); + assert_eq!( + paths.state_slots(), + [ + root.join("index-state.0.json"), + root.join("index-state.1.json"), + ] + ); + assert_eq!(paths.tombstone(), root.join("uninitialized")); + assert_eq!(paths.config_toml(), root.join("config.toml")); + assert_eq!(paths.extension_config(), root.join("codegraph.json")); + assert_eq!(paths.daemon_pid(), root.join("daemon.pid")); + assert_eq!(paths.daemon_log(), root.join("daemon.log")); + assert_eq!(paths.daemon_socket(), root.join("daemon.sock")); + + let _ = std::fs::remove_dir_all(&project); + } + + #[test] + fn project_identity_is_full_lowercase_sha256() { + let project = temp_dir("identity"); + let paths = IndexPaths::resolve(&project, None).expect("resolve"); + let id = paths.project_identity(); + + assert_eq!(id.len(), 64, "identity is a full SHA-256 hex string"); + assert!( + id.bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()), + "identity is lowercase hex: {id}" + ); + + // Deterministic: a second resolve of the same physical project agrees. + let again = IndexPaths::resolve(&project, None).expect("resolve again"); + assert_eq!(again.project_identity(), id); + + let _ = std::fs::remove_dir_all(&project); + } + + #[test] + fn relative_codegraph_dir_current_root_is_sibling_with_identity_suffix() { + let project = temp_dir("rel-dir"); + let paths = IndexPaths::resolve(&project, Some("cache")).expect("resolve relative"); + + // Legacy roots: the fixed `.codegraph` plus the configured relative root. + assert_eq!( + paths.legacy_roots(), + [project.join(".codegraph"), project.join("cache")] + ); + // Current root is the SIBLING `cache-v2-`, not a child. + let expected = project.join(format!("cache-v2-{}", paths.project_identity())); + assert_eq!(paths.current_root(), expected); + assert!(!paths.current_root().starts_with(project.join("cache"))); + + let _ = std::fs::remove_dir_all(&project); + } + + #[test] + fn absolute_codegraph_dir_current_root_is_sibling_of_configured_root() { + let project = temp_dir("abs-proj"); + let cache = temp_dir("abs-cache"); + let configured = cache.join("cg"); + let paths = IndexPaths::resolve(&project, Some(configured.to_str().unwrap())) + .expect("resolve absolute"); + + assert!( + paths.legacy_roots().contains(&configured), + "configured absolute root is a legacy root: {:?}", + paths.legacy_roots() + ); + // Sibling `-v2-` next to the configured root. + let expected = cache.join(format!("cg-v2-{}", paths.project_identity())); + assert_eq!(paths.current_root(), expected); + assert!(!paths.current_root().starts_with(&configured)); + + let _ = std::fs::remove_dir_all(&project); + let _ = std::fs::remove_dir_all(&cache); + } + + #[test] + fn two_projects_do_not_collide_on_a_shared_configured_root() { + // Two DISTINCT physical projects given the SAME configured root must + // receive DISTINCT current roots (identity-suffixed), so one cannot open + // the other's state. + let project_a = temp_dir("collide-a"); + let project_b = temp_dir("collide-b"); + let shared = temp_dir("collide-shared"); + let configured = shared.join("cg"); + + let a = IndexPaths::resolve(&project_a, Some(configured.to_str().unwrap())).expect("a"); + let b = IndexPaths::resolve(&project_b, Some(configured.to_str().unwrap())).expect("b"); + + assert_ne!( + a.project_identity(), + b.project_identity(), + "distinct physical projects have distinct identities" + ); + assert_ne!( + a.current_root(), + b.current_root(), + "shared configured root must not collapse two projects onto one current root" + ); + + let _ = std::fs::remove_dir_all(&project_a); + let _ = std::fs::remove_dir_all(&project_b); + let _ = std::fs::remove_dir_all(&shared); + } + + #[test] + fn two_projects_do_not_collide_on_an_escaping_relative_root() { + // A relative CODEGRAPH_DIR that escapes to a shared external directory + // must still yield distinct, identity-suffixed current roots. + let base = temp_dir("escape-base"); + let project_a = base.join("a"); + let project_b = base.join("b"); + std::fs::create_dir_all(&project_a).unwrap(); + std::fs::create_dir_all(&project_b).unwrap(); + std::fs::create_dir_all(base.join("shared")).unwrap(); + + let a = IndexPaths::resolve(&project_a, Some("../shared/cg")).expect("a"); + let b = IndexPaths::resolve(&project_b, Some("../shared/cg")).expect("b"); + + // Both configured roots normalize to the SAME external path… + assert_eq!(a.legacy_roots()[1], b.legacy_roots()[1]); + // …yet the current roots differ by physical identity. + assert_ne!(a.current_root(), b.current_root()); + + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn rejects_nonexistent_project() { + let missing = std::env::temp_dir().join("cg-indexpaths-does-not-exist-xyz"); + let err = IndexPaths::resolve(&missing, None).expect_err("missing project fails closed"); + assert!(matches!(err, IndexPathsError::ProjectInaccessible { .. })); + } + + #[test] + fn rejects_root_dot_and_parent_codegraph_dir_aliases() { + let project = temp_dir("aliases"); + + // A configured root resolving to the project root itself. + let dot = IndexPaths::resolve(&project, Some(".")).expect_err("`.` must fail closed"); + assert!(matches!(dot, IndexPathsError::RootAlias { .. }), "{dot:?}"); + + // A configured root resolving to an ancestor of the project. + let parent = IndexPaths::resolve(&project, Some("..")).expect_err("`..` must fail closed"); + assert!( + matches!(parent, IndexPathsError::RootAlias { .. }), + "{parent:?}" + ); + + // The filesystem root. + let root = IndexPaths::resolve(&project, Some("/")).expect_err("`/` must fail closed"); + assert!( + matches!(root, IndexPathsError::RootAlias { .. }), + "{root:?}" + ); + + let _ = std::fs::remove_dir_all(&project); + } + + #[test] + fn empty_codegraph_dir_is_treated_as_unset_default() { + let project = temp_dir("empty-dir"); + // An empty override string is ignored (treated as unset), matching the + // shell semantics where an empty env value is a no-op override. + let paths = IndexPaths::resolve(&project, Some("")).expect("empty resolves to default"); + assert_eq!(paths.current_root(), project.join(".codegraph-v2")); + let _ = std::fs::remove_dir_all(&project); + } + + #[cfg(unix)] + #[test] + fn rejects_symlink_ancestor_below_project() { + use std::os::unix::fs::symlink; + + let project = temp_dir("symlink"); + let real = project.join("real"); + std::fs::create_dir_all(&real).unwrap(); + let link = project.join("link"); + symlink(&real, &link).unwrap(); + + // A configured root that descends THROUGH a symlink component below the + // project must fail closed rather than follow the link. + let err = IndexPaths::resolve(&project, Some("link/cg")) + .expect_err("symlink component must fail closed"); + assert!( + matches!(err, IndexPathsError::SymlinkComponent { .. }), + "{err:?}" + ); + + let _ = std::fs::remove_dir_all(&project); + } + + #[cfg(unix)] + #[test] + fn rejects_in_project_root_reached_through_intermediate_symlink() { + use std::os::unix::fs::symlink; + + // In-project spelling of the intermediate-alias case: `/link` + // is a symlink to `/real`, so `/link/child` EXISTS as + // an ordinary directory reached THROUGH the alias. A relative + // `CODEGRAPH_DIR=link/child/cg` must fail closed on the `link` component + // even though the nearest existing ancestor (`.../child`) is ordinary. + let project = temp_dir("symlink-inproj-mid"); + let real = project.join("real"); + std::fs::create_dir_all(real.join("child")).unwrap(); + let link = project.join("link"); + symlink(&real, &link).unwrap(); + + let err = IndexPaths::resolve(&project, Some("link/child/cg")) + .expect_err("in-project intermediate symlink must fail closed"); + assert!( + matches!(err, IndexPathsError::SymlinkComponent { .. }), + "{err:?}" + ); + + let _ = std::fs::remove_dir_all(&project); + } + + #[cfg(unix)] + #[test] + fn rejects_symlinked_absolute_configured_root() { + use std::os::unix::fs::symlink; + + let project = temp_dir("symlink-abs-proj"); + let cache = temp_dir("symlink-abs-cache"); + let real = cache.join("real"); + std::fs::create_dir_all(&real).unwrap(); + let link = cache.join("link"); + symlink(&real, &link).unwrap(); + + // An absolute configured root that IS a symlink must fail closed. + let err = IndexPaths::resolve(&project, Some(link.to_str().unwrap())) + .expect_err("symlinked absolute root must fail closed"); + assert!( + matches!(err, IndexPathsError::SymlinkComponent { .. }), + "{err:?}" + ); + + let _ = std::fs::remove_dir_all(&project); + let _ = std::fs::remove_dir_all(&cache); + } + + #[cfg(unix)] + #[test] + fn rejects_external_configured_root_reached_through_intermediate_symlink() { + use std::os::unix::fs::symlink; + + // `/link` is a symlink to `/real`; `/link/child` + // therefore EXISTS as an ordinary directory reached through the alias. + // An absolute CODEGRAPH_DIR of `/link/child/cg` must fail closed + // even though its nearest existing ancestor (`.../child`) is itself an + // ordinary directory — the intermediate `link` component is the alias. + let project = temp_dir("symlink-mid-proj"); + let cache = temp_dir("symlink-mid-cache"); + let real = cache.join("real"); + std::fs::create_dir_all(real.join("child")).unwrap(); + let link = cache.join("link"); + symlink(&real, &link).unwrap(); + + let configured = link.join("child").join("cg"); + let err = IndexPaths::resolve(&project, Some(configured.to_str().unwrap())) + .expect_err("intermediate symlink component must fail closed"); + assert!( + matches!(err, IndexPathsError::SymlinkComponent { .. }), + "{err:?}" + ); + + let _ = std::fs::remove_dir_all(&project); + let _ = std::fs::remove_dir_all(&cache); + } + + #[test] + fn rejects_current_root_overlapping_legacy_root() { + // A configured root whose derived sibling would nest under the fixed + // legacy `.codegraph` root must fail closed. Point CODEGRAPH_DIR at a + // child of `.codegraph`; the sibling `-v2-` then still lives + // inside `.codegraph`, overlapping the fixed legacy root. + let project = temp_dir("overlap"); + std::fs::create_dir_all(project.join(".codegraph")).unwrap(); + let err = IndexPaths::resolve(&project, Some(".codegraph/inner")) + .expect_err("overlap with legacy root must fail closed"); + assert!( + matches!(err, IndexPathsError::LegacyOverlap { .. }), + "{err:?}" + ); + let _ = std::fs::remove_dir_all(&project); + } + + #[test] + fn current_root_never_equals_a_legacy_root() { + let project = temp_dir("disjoint"); + let paths = IndexPaths::resolve(&project, Some("cache")).expect("resolve"); + for legacy in paths.legacy_roots() { + assert_ne!(paths.current_root(), legacy); + assert!(!paths.current_root().starts_with(legacy)); + assert!(!legacy.starts_with(paths.current_root())); + } + let _ = std::fs::remove_dir_all(&project); + } + + #[test] + fn lexical_normalize_folds_dot_and_parent() { + assert_eq!( + lexical_normalize(Path::new("/a/./b/../c")), + PathBuf::from("/a/c") + ); + assert_eq!(lexical_normalize(Path::new("/a/b/..")), PathBuf::from("/a")); + } + + #[test] + fn reserved_roots_default_are_exactly_legacy_and_v2_paths() { + let project = temp_dir("reserved-default"); + let roots = IndexPaths::reserved_index_roots(&project, None); + assert!(roots.contains(&project.join(".codegraph"))); + assert!(roots.contains(&project.join(".codegraph-v2"))); + // A user source directory sharing the `.codegraph-` prefix is NOT a + // reserved root and must remain scannable. + assert!(!roots.contains(&project.join(".codegraph-sources"))); + let _ = std::fs::remove_dir_all(&project); + } + + #[test] + fn reserved_roots_include_relative_configured_sibling_as_full_path() { + let project = temp_dir("reserved-configured"); + let roots = IndexPaths::reserved_index_roots(&project, Some("cache")); + let identity = IndexPaths::resolve(&project, Some("cache")) + .unwrap() + .project_identity() + .to_string(); + assert!(roots.contains(&project.join(".codegraph")), "{roots:?}"); + assert!( + roots.contains(&project.join("cache")), + "configured legacy root: {roots:?}" + ); + assert!( + roots.contains(&project.join(format!("cache-v2-{identity}"))), + "configured current sibling: {roots:?}" + ); + let _ = std::fs::remove_dir_all(&project); + } + + #[test] + fn reserved_roots_include_nested_configured_roots_at_true_depth() { + // A nested `CODEGRAPH_DIR=cache/index` puts the legacy root at + // `/cache/index` and the current root at + // `/cache/index-v2-`; both must be reserved as their + // full nested paths, so the scanner prunes them at depth rather than + // descending into and indexing the index's own storage. + let project = temp_dir("reserved-nested"); + let roots = IndexPaths::reserved_index_roots(&project, Some("cache/index")); + let identity = IndexPaths::resolve(&project, Some("cache/index")) + .unwrap() + .project_identity() + .to_string(); + assert!( + roots.contains(&project.join("cache").join("index")), + "nested configured legacy root: {roots:?}" + ); + assert!( + roots.contains(&project.join("cache").join(format!("index-v2-{identity}"))), + "nested configured current sibling: {roots:?}" + ); + let _ = std::fs::remove_dir_all(&project); + } + + #[test] + fn reserved_roots_degrade_to_default_paths_on_invalid_configured_root() { + let project = temp_dir("reserved-invalid"); + // `.` resolves to the project root itself — an invalid alias. Root + // derivation degrades to the safe default paths, never errors. + let roots = IndexPaths::reserved_index_roots(&project, Some(".")); + assert!(roots.contains(&project.join(".codegraph"))); + assert!(roots.contains(&project.join(".codegraph-v2"))); + let _ = std::fs::remove_dir_all(&project); + } +} diff --git a/crates/codegraph-core/src/lib.rs b/crates/codegraph-core/src/lib.rs index 8ce6dc5..4773e78 100644 --- a/crates/codegraph-core/src/lib.rs +++ b/crates/codegraph-core/src/lib.rs @@ -2,9 +2,11 @@ pub mod config; pub mod errors; +pub mod index_paths; pub mod logger; pub mod node_id; pub mod traits; pub mod types; pub use errors::{CodeGraphError, Result}; +pub use index_paths::{IndexPaths, IndexPathsError}; diff --git a/crates/codegraph-daemon/Cargo.toml b/crates/codegraph-daemon/Cargo.toml index 3d8aee3..92132ad 100644 --- a/crates/codegraph-daemon/Cargo.toml +++ b/crates/codegraph-daemon/Cargo.toml @@ -22,6 +22,7 @@ tracing = { workspace = true } time = { workspace = true } # Cross-platform IPC (local socket on unix, named pipe on windows) interprocess = { workspace = true } +codegraph-core = { path = "../codegraph-core" } codegraph-mcp = { path = "../codegraph-mcp" } codegraph-watch = { path = "../codegraph-watch" } tokio = { version = "1", features = [ @@ -30,6 +31,7 @@ tokio = { version = "1", features = [ "net", "time", "macros", + "sync", ] } # Unix process introspection (getppid, signal-0 liveness) + socket half-close diff --git a/crates/codegraph-daemon/src/control.rs b/crates/codegraph-daemon/src/control.rs new file mode 100644 index 0000000..fc3b87b --- /dev/null +++ b/crates/codegraph-daemon/src/control.rs @@ -0,0 +1,390 @@ +//! Versioned, project-identity-bound daemon CONTROL frames. +//! +//! Frozen plan lines 593-612. `uninit --force` holds the ONE outer exclusive +//! index lease for its whole lifecycle, so the frame that asks a live daemon to +//! shut down must NOT travel the ordinary data-request path — an ordinary request +//! takes its own SHARED lease and would deadlock against that exclusive holder. +//! +//! This module owns both ends of that narrow channel: +//! +//! * the wire form — a versioned JSON line carrying the FULL physical project +//! identity, so a frame can never drain a daemon serving another project; +//! * [`request_daemon_shutdown`], the caller side used by `uninit`, which +//! connects to the daemon's recorded rendezvous, sends the frame, and waits a +//! bounded time for the ACK the daemon writes only AFTER it has stopped +//! accepting, cancelled its watcher lease loops, drained, and removed its own +//! pid/socket. No PID is ever signalled: an unresponsive daemon returns +//! [`ShutdownOutcome::Unresponsive`] and the caller fails closed. + +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc; +use std::time::Duration; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::transport::{Rendezvous, connect}; + +/// Wire version of the control protocol. A daemon refuses any other value rather +/// than guessing a peer's semantics. +pub const CONTROL_PROTOCOL: u8 = 1; + +/// The only control action this version defines. +const SHUTDOWN_ACTION: &str = "shutdown"; + +/// Bounded wall-clock budget for the whole send-and-ACK exchange. +const SHUTDOWN_ACK_TIMEOUT: Duration = Duration::from_secs(10); +/// Slack added to the caller-side wait so a daemon that answers right at its own +/// drain budget is still read, while the wait stays finite everywhere. +const SHUTDOWN_WAIT_MARGIN: Duration = Duration::from_secs(5); + +/// A control frame sent on a fresh rendezvous connection, BEFORE any JSON-RPC. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ControlFrame { + /// Discriminator that separates a control frame from a client hello and from + /// a JSON-RPC request. + pub codegraph_control: u8, + /// The requested action. + pub action: String, + /// The FULL lowercase SHA-256 physical project identity the sender addresses. + pub project_identity: String, +} + +impl ControlFrame { + #[must_use] + pub fn shutdown(project_identity: &str) -> Self { + Self { + codegraph_control: CONTROL_PROTOCOL, + action: SHUTDOWN_ACTION.to_string(), + project_identity: project_identity.to_string(), + } + } + + /// Whether this frame is a shutdown request this build understands, bound to + /// `project_identity`. A version, action, or identity mismatch is refused. + #[must_use] + pub fn authorizes_shutdown_of(&self, project_identity: &str) -> bool { + self.codegraph_control == CONTROL_PROTOCOL + && self.action == SHUTDOWN_ACTION + && self.project_identity == project_identity + } +} + +/// The daemon's reply, written only after the drain completed. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ControlAck { + pub codegraph_control: u8, + pub action: String, + /// `true` only when the daemon fully drained and removed its own rendezvous. + pub drained: bool, +} + +impl ControlAck { + #[must_use] + pub fn drained() -> Self { + Self::for_drain(true) + } + + /// The ack for a completed (`true`) or INCOMPLETE (`false`) drain. An + /// incomplete drain must never be reported as success: the caller treats it as + /// unresponsive and fails closed. + #[must_use] + pub fn for_drain(drained: bool) -> Self { + Self { + codegraph_control: CONTROL_PROTOCOL, + action: SHUTDOWN_ACTION.to_string(), + drained, + } + } + + #[must_use] + pub fn accepted(&self) -> bool { + self.codegraph_control == CONTROL_PROTOCOL && self.action == SHUTDOWN_ACTION && self.drained + } +} + +/// Parse one already-read line as a control frame. Anything else is `None`, and +/// the caller must treat the bytes as ordinary session input. +/// +/// EVERY line that deserializes as a `ControlFrame` — including +/// `codegraph_control: 0` and any future version — is recognized here, so it is +/// routed to explicit authorization and refused. Rejecting a foreign version at +/// parse time instead would push those bytes into the JSON-RPC executor, which is +/// exactly the data path a control frame must never reach. +#[must_use] +pub fn parse_control_frame(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + serde_json::from_str::(trimmed).ok() +} + +/// Serialize a frame as one newline-terminated wire line. +#[must_use] +pub fn encode_control_line(value: &T) -> String { + format!( + "{}\n", + serde_json::to_string(value).unwrap_or_else(|_| String::from("{}")) + ) +} + +/// What the shutdown exchange established. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ShutdownOutcome { + /// No live owner: nothing to drain. + NoDaemon, + /// The daemon ACKed after draining and removing its own pid/socket. + Drained { pid: u32 }, + /// A live owner was recorded but never ACKed within the bounded budget. The + /// caller MUST fail closed; the pid is reported, never signalled. + Unresponsive { pid: u32, detail: String }, +} + +/// Ask the daemon recorded for `project_root` to shut down, and wait a bounded +/// time for its post-drain ACK. +/// +/// `project_identity` is the full physical identity from `IndexPaths`, so the +/// frame authorizes draining ONLY the daemon serving this exact namespace. +pub fn request_daemon_shutdown( + project_root: &Path, + project_identity: &str, +) -> Result { + let pid_path = crate::paths::daemon_pid_path(project_root)?; + let Some(info) = std::fs::read_to_string(&pid_path) + .ok() + .and_then(|raw| crate::lock::decode_lock_info(&raw)) + .filter(|info| info.pid > 0) + else { + return Ok(ShutdownOutcome::NoDaemon); + }; + if !crate::process::is_process_alive(info.pid) { + return Ok(ShutdownOutcome::NoDaemon); + } + let socket_path = if info.socket_path.as_os_str().is_empty() { + crate::paths::daemon_socket_path(project_root)? + } else { + info.socket_path.clone() + }; + + match bounded_exchange_shutdown(socket_path, project_identity.to_string()) { + Ok(()) => Ok(ShutdownOutcome::Drained { pid: info.pid }), + Err(detail) => Ok(ShutdownOutcome::Unresponsive { + pid: info.pid, + detail, + }), + } +} + +/// Run the exchange on a worker thread and wait for it with a monotonic channel +/// deadline. +/// +/// The per-socket send/recv timeouts `interprocess` exposes are Unix-only, so a +/// wedged Windows named pipe would otherwise block forever. Bounding the WAIT +/// itself (not the socket) is finite on every supported platform; the abandoned +/// worker owns only its own connection and never touches index bytes. +fn bounded_exchange_shutdown(socket_path: PathBuf, project_identity: String) -> Result<(), String> { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let result = + exchange_shutdown(&socket_path, &project_identity).map_err(|e| format!("{e:#}")); + let _ = tx.send(result); + }); + match rx.recv_timeout(SHUTDOWN_ACK_TIMEOUT + SHUTDOWN_WAIT_MARGIN) { + Ok(result) => result, + Err(mpsc::RecvTimeoutError::Timeout) => Err(format!( + "no drain acknowledgement within {:?}", + SHUTDOWN_ACK_TIMEOUT + SHUTDOWN_WAIT_MARGIN + )), + Err(mpsc::RecvTimeoutError::Disconnected) => { + Err("the shutdown exchange ended without a result".to_string()) + } + } +} + +fn exchange_shutdown(socket_path: &Path, project_identity: &str) -> Result<()> { + let rendezvous = Rendezvous::from_socket_path(socket_path); + let stream = connect(&rendezvous) + .with_context(|| format!("connecting to daemon socket {}", socket_path.display()))?; + #[cfg(unix)] + { + use interprocess::local_socket::traits::Stream as _; + stream + .set_recv_timeout(Some(SHUTDOWN_ACK_TIMEOUT)) + .context("bounding the daemon shutdown ACK wait")?; + stream + .set_send_timeout(Some(SHUTDOWN_ACK_TIMEOUT)) + .context("bounding the daemon shutdown send")?; + } + + // The daemon writes its versioned hello on accept; consume that line first so + // the ACK read below cannot mistake it for the reply. + let mut reader = BufReader::new(&stream); + let mut hello = String::new(); + reader + .read_line(&mut hello) + .context("reading the daemon hello before sending a control frame")?; + + let frame = ControlFrame::shutdown(project_identity); + (&stream) + .write_all(encode_control_line(&frame).as_bytes()) + .context("sending the shutdown control frame")?; + (&stream).flush().context("flushing the control frame")?; + + let mut ack_line = String::new(); + reader + .read_line(&mut ack_line) + .context("waiting for the daemon drain ACK")?; + let ack: ControlAck = serde_json::from_str(ack_line.trim()) + .with_context(|| format!("decoding the daemon drain ACK {:?}", ack_line.trim()))?; + anyhow::ensure!( + ack.accepted(), + "daemon reported an incomplete drain (drained={})", + ack.drained + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const IDENTITY: &str = "a1b2c3"; + + #[test] + fn shutdown_frame_round_trips_and_binds_the_project_identity() { + let frame = ControlFrame::shutdown(IDENTITY); + let line = encode_control_line(&frame); + assert!(line.ends_with('\n')); + let parsed = parse_control_frame(&line).expect("a control frame parses"); + assert_eq!(parsed, frame); + assert!(parsed.authorizes_shutdown_of(IDENTITY)); + assert!( + !parsed.authorizes_shutdown_of("another-project"), + "a frame must never authorize draining another project's daemon" + ); + } + + #[test] + fn a_client_hello_or_jsonrpc_line_is_not_a_control_frame() { + assert!(parse_control_frame("").is_none()); + assert!(parse_control_frame(" \n").is_none()); + assert!(parse_control_frame("{\"hostPid\":4242}").is_none()); + assert!(parse_control_frame("{\"jsonrpc\":\"2.0\",\"id\":1}").is_none()); + assert!(parse_control_frame("not json").is_none()); + } + + #[test] + fn a_foreign_protocol_version_is_never_authorized() { + for version in [0, CONTROL_PROTOCOL + 1, u8::MAX] { + let frame = ControlFrame { + codegraph_control: version, + action: SHUTDOWN_ACTION.to_string(), + project_identity: IDENTITY.to_string(), + }; + // Recognized as a control frame (so it is refused explicitly) and + // never authorized. + let parsed = parse_control_frame(&encode_control_line(&frame)) + .expect("every control-shaped line is recognized, not routed to JSON-RPC"); + assert_eq!(parsed.codegraph_control, version); + assert!(!parsed.authorizes_shutdown_of(IDENTITY)); + } + let frame = ControlFrame { + codegraph_control: CONTROL_PROTOCOL + 1, + action: SHUTDOWN_ACTION.to_string(), + project_identity: IDENTITY.to_string(), + }; + assert!(!frame.authorizes_shutdown_of(IDENTITY)); + let other_action = ControlFrame { + codegraph_control: CONTROL_PROTOCOL, + action: "reindex".to_string(), + project_identity: IDENTITY.to_string(), + }; + assert!(!other_action.authorizes_shutdown_of(IDENTITY)); + } + + #[test] + fn only_a_drained_matching_ack_is_accepted() { + assert!(ControlAck::drained().accepted()); + assert!(ControlAck::for_drain(true).accepted()); + let undrained = ControlAck::for_drain(false); + assert!( + !undrained.accepted(), + "an incomplete drain must never be accepted as success" + ); + assert_eq!( + undrained, + ControlAck { + codegraph_control: CONTROL_PROTOCOL, + action: SHUTDOWN_ACTION.to_string(), + drained: false, + } + ); + let wrong_version = ControlAck { + codegraph_control: CONTROL_PROTOCOL + 1, + action: SHUTDOWN_ACTION.to_string(), + drained: true, + }; + assert!(!wrong_version.accepted()); + } + + #[test] + fn no_recorded_owner_means_there_is_nothing_to_drain() { + let project = std::env::temp_dir().join(format!( + "cg-control-none-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&project).unwrap(); + let outcome = request_daemon_shutdown(&project, IDENTITY).expect("probe an idle project"); + assert_eq!(outcome, ShutdownOutcome::NoDaemon); + let _ = std::fs::remove_dir_all(&project); + } + + #[test] + fn a_live_owner_with_an_unreachable_socket_is_unresponsive_and_unsignalled() { + let project = std::env::temp_dir().join(format!( + "cg-control-unresponsive-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&project).unwrap(); + let project = project.canonicalize().unwrap(); + let paths = crate::paths::index_paths(&project).expect("resolve v2 paths"); + std::fs::create_dir_all(paths.current_root()).unwrap(); + // This process is the live "owner": a real live pid whose rendezvous + // socket was never bound. + let info = crate::lock::DaemonLockInfo { + pid: std::process::id(), + version: env!("CARGO_PKG_VERSION").to_string(), + socket_path: paths.daemon_socket(), + started_at: 1, + }; + std::fs::write( + paths.daemon_pid(), + crate::lock::encode_lock_info(&info).unwrap(), + ) + .unwrap(); + + let outcome = + request_daemon_shutdown(&project, IDENTITY).expect("an unreachable socket is data"); + match outcome { + ShutdownOutcome::Unresponsive { pid, .. } => assert_eq!(pid, std::process::id()), + other => panic!("expected an unresponsive outcome, got {other:?}"), + } + assert!( + crate::process::is_process_alive(std::process::id()), + "the recorded owner is never signalled" + ); + let _ = std::fs::remove_dir_all(&project); + } +} diff --git a/crates/codegraph-daemon/src/lib.rs b/crates/codegraph-daemon/src/lib.rs index a53ca5a..3deac02 100644 --- a/crates/codegraph-daemon/src/lib.rs +++ b/crates/codegraph-daemon/src/lib.rs @@ -5,6 +5,7 @@ //! watchdogs, graceful shutdown, and stale-lock recovery. It deliberately does //! not implement task-25 file watching. +pub mod control; pub mod http_registry; mod lock; mod paths; @@ -22,6 +23,10 @@ use std::thread::{self, JoinHandle}; use std::time::Duration; use anyhow::{Context, Result, bail}; +pub use control::{ + CONTROL_PROTOCOL, ControlAck, ControlFrame, ShutdownOutcome, parse_control_frame, + request_daemon_shutdown, +}; pub use lock::{ AcquireResult, DaemonLockInfo, clear_stale_daemon_lock, clear_stale_daemon_socket, decode_lock_info, encode_lock_info, recorded_socket_path, try_acquire_daemon_lock, @@ -37,14 +42,20 @@ pub use session::{SessionRegistry, read_daemon_hello, run_session_recv}; pub use spawn::{CODEGRAPH_HTTP_DETACH_INTERNAL, spawn_detached_daemon, spawn_detached_http}; use tracing::{debug, info, warn}; -use crate::lock::{cleanup_owned_lock, rewrite_lock_socket_path}; -use crate::paths::codegraph_dir; -use crate::session::serve_session_async; +use crate::lock::{cleanup_owned_rendezvous, rewrite_lock_socket_path}; +use crate::session::{ControlHandle, ShutdownRequest, serve_session_async}; use crate::transport::{AsyncListener, Rendezvous, bind_tokio, connect}; const DEFAULT_WATCHDOG_INTERVAL: Duration = Duration::from_millis(500); const ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(250); +/// Bounded budget for the post-control-frame drain: cancelled watcher/catch-up +/// lease loops must exit and active sessions must close before the daemon removes +/// its own rendezvous and ACKs. Exceeding it is reported and the daemon still +/// tears its own rendezvous down, so the caller never has to signal a pid. +const DRAIN_TIMEOUT: Duration = Duration::from_secs(10); +const DRAIN_POLL_INTERVAL: Duration = Duration::from_millis(10); + /// Env var name: when set to `"1"`, the process re-invoked by the launcher IS /// the detached daemon and must listen+serve, never re-spawn. pub const CODEGRAPH_DAEMON_INTERNAL: &str = "CODEGRAPH_DAEMON_INTERNAL"; @@ -166,11 +177,12 @@ pub struct DaemonOptions { /// project (N client inotify sets collapse to 1). Honors /// `watch_disabled_reason` (e.g. `CODEGRAPH_NO_WATCH=1`). pub watch: bool, - /// `config.toml` `include`/`exclude` path patterns (#1063), passed through - /// to the shared `ProjectWatcher` so its scope matches the scan's. The CLI - /// (which owns the config singleton) populates these; empty = pre-#1063. - pub include: Vec, - pub exclude: Vec, + /// Bounded budget for the post-shutdown drain: how long the daemon waits for + /// its cancelled watcher/catch-up lease loops and its actively-closed data + /// sessions to finish before reporting an INCOMPLETE drain. Exceeding it never + /// acknowledges success, so the caller fails closed. Defaults to + /// [`DRAIN_TIMEOUT`]. + pub drain_budget: Duration, } impl Default for DaemonOptions { @@ -181,12 +193,26 @@ impl Default for DaemonOptions { watchdog_interval: DEFAULT_WATCHDOG_INTERVAL, run_mcp: true, watch: true, - include: Vec::new(), - exclude: Vec::new(), + drain_budget: DRAIN_TIMEOUT, } } } +/// The capability a startup gate returns and the daemon RETAINS across +/// pid/socket publication (frozen plan lines 590-592: startup takes a shared +/// index lease across state/owner/tombstone validation AND publication, and +/// releases it only afterwards). +/// +/// It is opaque here on purpose: the gate lives in the crate that owns the index +/// state protocol, so this crate cannot reclassify a namespace itself or reach a +/// store API behind the gate's back. +pub type StartupAuthorization = Box; + +/// A startup gate: validate `project_root`'s namespace and return the capability +/// to retain until the rendezvous is published. `Err` refuses the start, and the +/// daemon then publishes NOTHING. +pub type StartupGate<'a> = &'a (dyn Fn(&Path) -> Result + Sync); + #[derive(Debug)] pub enum StartOrAttach { Started(DaemonHandle), @@ -252,14 +278,41 @@ impl Drop for DaemonHandle { pub fn start_or_attach( project_root: impl AsRef, options: DaemonOptions, +) -> Result { + start_or_attach_gated(project_root, options, None) +} + +/// [`start_or_attach`] with the caller-supplied startup gate. +/// +/// The gate runs BEFORE the pid record is claimed and its returned capability is +/// held until the socket is bound and the chosen rendezvous persisted, so a +/// concurrent `uninit --force` can never interleave its own exclusive-lease work +/// between validation and publication. A refused gate publishes nothing at all. +pub fn start_or_attach_gated( + project_root: impl AsRef, + options: DaemonOptions, + gate: Option>, ) -> Result { let project_root = project_root.as_ref().to_path_buf(); + let authorization = match gate { + Some(gate) => Some(gate(&project_root)?), + None => None, + }; match try_acquire_daemon_lock(&project_root)? { AcquireResult::Acquired { pid_path, info } => { - let handle = start_with_lock(project_root, pid_path, info.socket_path, options)?; + let handle = start_with_lock( + project_root, + pid_path, + info.socket_path, + options, + authorization, + )?; Ok(StartOrAttach::Started(handle)) } AcquireResult::Taken { existing, pid_path } => { + // Nothing will be published on this path, so release the startup + // capability before any recursion re-acquires it. + drop(authorization); if let Some(info) = existing { if let Ok(client) = attach_to_daemon(&info.socket_path) { return Ok(StartOrAttach::Attached(client)); @@ -267,7 +320,7 @@ pub fn start_or_attach( if !clear_stale_daemon_lock(&pid_path, Some(info.pid)) { bail!("daemon already running for this project (pid {})", info.pid); } - return start_or_attach(project_root, options); + return start_or_attach_gated(project_root, options, gate); } if !clear_stale_daemon_lock(&pid_path, None) { bail!( @@ -275,13 +328,22 @@ pub fn start_or_attach( pid_path.display() ); } - start_or_attach(project_root, options) + start_or_attach_gated(project_root, options, gate) } } } pub fn run_foreground(project_root: impl AsRef, options: DaemonOptions) -> Result<()> { - match start_or_attach(project_root, options)? { + run_foreground_gated(project_root, options, None) +} + +/// [`run_foreground`] under a startup gate; see [`start_or_attach_gated`]. +pub fn run_foreground_gated( + project_root: impl AsRef, + options: DaemonOptions, + gate: Option>, +) -> Result<()> { + match start_or_attach_gated(project_root, options, gate)? { StartOrAttach::Started(handle) => handle.wait(), StartOrAttach::Attached(client) => { bail!("daemon already running at {}", client.socket_path.display()) @@ -305,9 +367,14 @@ fn start_with_lock( pid_path: PathBuf, socket_path: PathBuf, options: DaemonOptions, + authorization: Option, ) -> Result { - fs::create_dir_all(codegraph_dir(&project_root)) - .with_context(|| format!("creating {}", codegraph_dir(&project_root).display()))?; + let rendezvous_dir = paths::rendezvous_dir(&project_root)?; + fs::create_dir_all(&rendezvous_dir) + .with_context(|| format!("creating {}", rendezvous_dir.display()))?; + let project_identity = paths::index_paths(&project_root)? + .project_identity() + .to_string(); // The async interprocess Listener must be created inside a tokio runtime // context, so build the runtime here and bind within it. The runtime is @@ -324,6 +391,9 @@ fn start_with_lock( if let Err(err) = rewrite_lock_socket_path(&pid_path, &socket_path) { debug!(error = %err, "could not persist chosen daemon socket into lock"); } + // The rendezvous is published; the startup capability is released here, and + // every later data request takes its own shared lease. + drop(authorization); let registry = SessionRegistry::default(); let shutdown = Arc::new(AtomicBool::new(false)); let thread_registry = registry.clone(); @@ -336,6 +406,7 @@ fn start_with_lock( runtime.block_on(run_accept_loop_async( listener, thread_project, + project_identity, thread_socket, thread_pid_path, thread_registry, @@ -356,19 +427,19 @@ fn start_with_lock( /// remaining unix fallback candidates (`f83a1ec`). On non-unix there is a single /// namespaced pipe name, so the chain is just `[preferred]`. #[cfg(unix)] -fn socket_candidate_chain(project_root: &Path, preferred: PathBuf) -> Vec { +fn socket_candidate_chain(project_root: &Path, preferred: PathBuf) -> Result> { let mut candidates = vec![preferred]; - for candidate in paths::daemon_socket_candidates(project_root) { + for candidate in paths::daemon_socket_candidates(project_root)? { if !candidates.contains(&candidate) { candidates.push(candidate); } } - candidates + Ok(candidates) } #[cfg(not(unix))] -fn socket_candidate_chain(_project_root: &Path, preferred: PathBuf) -> Vec { - vec![preferred] +fn socket_candidate_chain(_project_root: &Path, preferred: PathBuf) -> Result> { + Ok(vec![preferred]) } /// Bind the daemon listener, falling through the deterministic socket-candidate @@ -378,7 +449,7 @@ fn socket_candidate_chain(_project_root: &Path, preferred: PathBuf) -> Vec Result<(AsyncListener, PathBuf)> { - let candidates = socket_candidate_chain(project_root, preferred); + let candidates = socket_candidate_chain(project_root, preferred)?; let mut last_err = None; for socket_path in candidates { @@ -413,6 +484,7 @@ fn bind_with_fallback(project_root: &Path, preferred: PathBuf) -> Result<(AsyncL async fn run_accept_loop_async( listener: AsyncListener, project_root: PathBuf, + project_identity: String, socket_path: PathBuf, pid_path: PathBuf, registry: SessionRegistry, @@ -437,12 +509,24 @@ async fn run_accept_loop_async( let mut first_connection_seen = false; info!(project = %project_root.display(), socket = %socket_path.display(), "daemon started"); + // ONE cooperative cancellation shared by the watcher's lease loops and the + // startup catch-up, so a shutdown control frame can refuse queued loops and + // interrupt a running one instead of waiting out its lease budget. + let lease_loops = codegraph_watch::SyncCancellation::new(); + // ONE shared watcher per daemon process. Bound to a local so // its `Drop` stops the watch thread on shutdown. NEVER move this into // the session task: per-connection would spawn N watchers. - let _watcher = start_project_watcher(&project_root, &options); + let watcher = start_project_watcher(&project_root, &options, &lease_loops); + + let _catch_up_done = spawn_catch_up(&project_root, &lease_loops); - let _catch_up_done = spawn_catch_up(&project_root); + // The control channel is separate from the request path by construction: a + // session that reads a control frame never builds an engine and never takes a + // data-request shared lease. + let (control_tx, mut control_rx) = tokio::sync::mpsc::unbounded_channel::(); + let control = ControlHandle::new(&project_identity, control_tx); + let mut pending_shutdown: Option = None; // Tick cadence for lifecycle checks: the client-sweep interval clamped to a // responsive ceiling so idle-exit / supervision loss are observed promptly @@ -464,6 +548,7 @@ async fn run_accept_loop_async( let session_socket = socket_display.clone(); let session_registry = registry.clone(); let run_mcp = options.run_mcp; + let session_control = control.clone(); tokio::spawn(async move { if let Err(err) = serve_session_async( stream, @@ -471,6 +556,7 @@ async fn run_accept_loop_async( session_socket, session_registry, run_mcp, + Some(session_control), ) .await { @@ -482,6 +568,11 @@ async fn run_accept_loop_async( break Some(anyhow::Error::new(err).context("accepting daemon connection")); } }, + Some(request) = control_rx.recv() => { + info!(project = %project_root.display(), "daemon shutting down on control frame"); + pending_shutdown = Some(request); + break None; + } _ = ticker.tick() => { let state = SupervisionState { original_ppid, @@ -523,10 +614,46 @@ async fn run_accept_loop_async( } }; - cleanup_owned_lock(&pid_path, std::process::id()); - #[cfg(unix)] - if let Some(stale) = Rendezvous::from_socket_path(&socket_path).cleanup_path() { - let _ = fs::remove_file(stale); + // Accepting has stopped (the loop exited and `listener` is dropped below with + // this scope). Drain in the plan's order: signal the watcher and cancel every + // queued/running lease loop, actively close every data session, wait for both + // to finish within the bounded budget, remove the rendezvous this process owns, + // and only then answer with the ACTUAL drain result. + // + // `begin_shutdown` deliberately does NOT join: a running sync can hold the + // event-loop thread for a whole extraction pass, so joining here would block + // past the budget and make the "incomplete drain" answer unreachable. + lease_loops.cancel(); + if let Some(watcher) = &watcher { + watcher.begin_shutdown(); + } + let watcher_done = { + let watcher = watcher.as_ref(); + move || watcher.is_none_or(codegraph_watch::ProjectWatcher::is_finished) + }; + let drained = + close_sessions_and_drain(watcher_done, &lease_loops, ®istry, options.drain_budget).await; + // Past the deadline the watcher thread is already cancelled and exits on its + // own; detach it so no destructor re-introduces an unbounded join AFTER the + // incomplete result was computed. + match watcher { + Some(watcher) if drained => watcher.stop(), + Some(watcher) => watcher.detach(), + None => {} + } + cleanup_owned_rendezvous(&pid_path, &socket_path, std::process::id()); + if let Some(request) = pending_shutdown { + if !drained { + warn!( + "daemon drain exceeded its bounded budget; reporting an INCOMPLETE drain so the \ + caller fails closed" + ); + } + // The reply is written by the session task, which still owns its socket; + // wait for it so this runtime is not torn down mid-write. + if request.ack.send(drained).is_ok() { + let _ = tokio::time::timeout(DRAIN_TIMEOUT, request.done).await; + } } info!(project = %project_root.display(), "daemon stopped"); match stop_reason { @@ -535,6 +662,63 @@ async fn run_accept_loop_async( } } +/// Sample what is outstanding, signal every data session to close, then drain +/// within `budget`. +/// +/// SAMPLING BEFORE THE SIGNAL IS LOAD-BEARING. The signalled sessions retire on +/// other tokio worker threads, so the first drain poll can already observe an +/// EMPTIED registry — and then report a COMPLETED drain for a budget that +/// permitted no waiting at all. A daemon must never report a drain it did not +/// perform, so the zero-budget answer is decided by the state AT THE SHUTDOWN +/// INSTANT, before any session can react, instead of by whichever thread the +/// scheduler happened to run next. +/// +/// A nonzero budget keeps its previous semantics exactly: sessions are given the +/// whole budget to retire and the poll loop decides. +/// This is deliberately NOT an `async fn`: the body of one does not run until the +/// future is first polled, which would put the sample back on the scheduler's +/// timeline. Sampling here, then returning the drain future, makes the sample +/// happen at the CALL. +fn close_sessions_and_drain<'a>( + watcher_done: impl Fn() -> bool + 'a, + lease_loops: &'a codegraph_watch::SyncCancellation, + registry: &'a SessionRegistry, + budget: Duration, +) -> impl std::future::Future + 'a { + let idle_at_shutdown = + watcher_done() && lease_loops.active_syncs() == 0 && registry.active_count() == 0; + registry.close_all_sessions(); + async move { + if budget.is_zero() { + return idle_at_shutdown; + } + drain_watcher_loops_and_sessions(watcher_done, lease_loops, registry, budget).await + } +} + +/// Wait for the watcher's event loop to finish, every cancellation-aware lease +/// loop to exit, and every active session to close — all bounded by `budget` and +/// observed by POLLING, never by a blocking join. Returns whether all three +/// reached completion; `false` is reported to the caller as an INCOMPLETE drain +/// and never acknowledged as success. +async fn drain_watcher_loops_and_sessions( + watcher_done: impl Fn() -> bool, + lease_loops: &codegraph_watch::SyncCancellation, + registry: &SessionRegistry, + budget: Duration, +) -> bool { + let deadline = tokio::time::Instant::now() + budget; + loop { + if watcher_done() && lease_loops.active_syncs() == 0 && registry.active_count() == 0 { + return true; + } + if tokio::time::Instant::now() >= deadline { + return false; + } + tokio::time::sleep(DRAIN_POLL_INTERVAL).await; + } +} + fn sweep_dead_clients(registry: &SessionRegistry) { for id in registry.dead_session_ids(is_process_alive) { debug!(session = id, "sweeping session whose host pid is dead"); @@ -558,16 +742,32 @@ fn backstop_should_exit(registry: &SessionRegistry, is_alive: impl Fn(u32) -> bo fn start_project_watcher( project_root: &Path, options: &DaemonOptions, + lease_loops: &codegraph_watch::SyncCancellation, ) -> Option { let counter = Arc::new(AtomicUsize::new(0)); - let mut watch_options = codegraph_watch::WatchOptions::default(); - watch_options.no_watch = !options.watch; - watch_options.include = options.include.clone(); - watch_options.exclude = options.exclude.clone(); + // The watcher's scope comes from THIS project's own config (include/exclude, + // debounce, enable flag, extension overrides), loaded from its resolved index + // root — never from a process-global value or from whichever project the + // launcher happened to start in. A config that cannot be read is reported and + // the daemon keeps serving without a watcher rather than watching the wrong + // scope. + let mut watch_options = match codegraph_watch::watch_options_for_project(project_root) { + Ok(watch_options) => watch_options, + Err(err) => { + warn!(error = %err, "daemon could not load project watch config"); + return None; + } + }; + // An explicit `--no-watch` (or `CODEGRAPH_NO_WATCH`) still wins over config. + watch_options.no_watch = watch_options.no_watch || !options.watch; + watch_options = watch_options.with_cancellation(lease_loops.clone()); watch_options.on_sync_complete = Some(Arc::new(move |outcome: codegraph_watch::SyncOutcome| { let n = counter.fetch_add(1, Ordering::SeqCst) + 1; - let tail = changed_paths_tail(&outcome.changed_paths); + // Report the watcher event batch even when startup catch-up won the + // writer lease and already indexed it. `changed_paths` deliberately + // remains the set this particular sync actually mutated. + let tail = changed_paths_tail(&outcome.trigger_paths); // The subscriber prepends the RFC3339 timestamp; this daemon's stderr // is redirected to `.codegraph/daemon.log`, so events land there timed. info!( @@ -617,12 +817,16 @@ fn changed_paths_tail(paths: &[String]) -> String { /// daemon was down (#905). Returns an `Arc` flipped `true` on /// completion. Runs on a detached `std::thread`; the accept loop is never /// blocked on it, so the first client's first tool call does not wait. -fn spawn_catch_up(project_root: &Path) -> Arc { +fn spawn_catch_up( + project_root: &Path, + lease_loops: &codegraph_watch::SyncCancellation, +) -> Arc { let done = Arc::new(AtomicBool::new(false)); let thread_done = Arc::clone(&done); let root = project_root.to_path_buf(); + let cancel = lease_loops.clone(); thread::spawn(move || { - match codegraph_watch::sync_project_once(&root) { + match codegraph_watch::sync_project_once_cancellable(&root, &cancel) { Ok(outcome) => { let changed = outcome.files_reindexed + outcome.files_removed; if changed > 0 { @@ -710,11 +914,95 @@ mod tests { let options = DaemonOptions::default(); assert!(options.run_mcp); assert!(options.watch); + assert_eq!(options.drain_budget, DRAIN_TIMEOUT); assert_eq!(options.parent_pid, None); assert_eq!(options.host_pid, None); assert_eq!(options.watchdog_interval, DEFAULT_WATCHDOG_INTERVAL); } + #[tokio::test] + async fn drain_reports_completion_only_when_loops_and_sessions_reach_zero() { + let lease_loops = codegraph_watch::SyncCancellation::new(); + let registry = SessionRegistry::default(); + // Nothing outstanding: the drain completes. + assert!( + drain_watcher_loops_and_sessions( + || true, + &lease_loops, + ®istry, + Duration::from_millis(50) + ) + .await + ); + + // A still-open session keeps the drain INCOMPLETE, which is what must + // travel to the caller as a refusal instead of a success acknowledgement. + let session = registry.start_session(); + assert!( + !drain_watcher_loops_and_sessions( + || true, + &lease_loops, + ®istry, + Duration::from_millis(50) + ) + .await, + "an outstanding session must report an incomplete drain" + ); + drop(session); + assert!( + drain_watcher_loops_and_sessions( + || true, + &lease_loops, + ®istry, + Duration::from_millis(50) + ) + .await + ); + + // An unfinished watcher ALONE also keeps the drain incomplete, and the wait + // returns at its deadline instead of joining the event-loop thread. + assert!( + !drain_watcher_loops_and_sessions( + || false, + &lease_loops, + ®istry, + Duration::from_millis(50) + ) + .await, + "an unfinished watcher event loop must report an incomplete drain" + ); + } + + #[tokio::test] + async fn a_zero_budget_answers_from_the_state_at_the_shutdown_instant() { + let lease_loops = codegraph_watch::SyncCancellation::new(); + + let idle = SessionRegistry::default(); + assert!( + close_sessions_and_drain(|| true, &lease_loops, &idle, Duration::ZERO).await, + "a zero budget with nothing outstanding completes" + ); + + // Retiring the session BEFORE the drain can poll is the exact interleaving + // that used to answer `true`, so the guarantee must hold on that schedule. + let racing = SessionRegistry::default(); + let leaving = racing.start_session(); + let answer = close_sessions_and_drain(|| true, &lease_loops, &racing, Duration::ZERO); + drop(leaving); + assert_eq!(racing.active_count(), 0, "the session retired first"); + assert!( + !answer.await, + "a session outstanding AT the shutdown instant must report an incomplete drain even \ + when it retires before the drain is polled" + ); + + let empty = SessionRegistry::default(); + assert!( + !close_sessions_and_drain(|| false, &lease_loops, &empty, Duration::ZERO).await, + "an unfinished watcher event loop at a zero budget is equally incomplete" + ); + } + #[test] fn backstop_exits_only_when_no_client_is_provably_alive() { let registry = SessionRegistry::default(); @@ -775,14 +1063,15 @@ mod tests { #[cfg(unix)] #[test] fn socket_candidate_chain_puts_preferred_first_and_dedups() { - let root = Path::new("/tmp/cg-candidate-chain"); - let preferred = daemon_socket_path(root); - let chain = socket_candidate_chain(root, preferred.clone()); + let root = temp_root("candidate-chain"); + let preferred = socket_path_of(&root); + let chain = socket_candidate_chain(&root, preferred.clone()).expect("resolve candidates"); assert_eq!(chain[0], preferred); let mut deduped = chain.clone(); deduped.sort(); deduped.dedup(); assert_eq!(deduped.len(), chain.len()); + let _ = fs::remove_dir_all(&root); } fn temp_root(label: &str) -> PathBuf { @@ -795,7 +1084,19 @@ mod tests { .as_nanos() )); fs::create_dir_all(&root).unwrap(); - root + root.canonicalize().unwrap() + } + + fn pid_path_of(root: &Path) -> PathBuf { + daemon_pid_path(root).expect("resolve the v2 rendezvous pid path") + } + + fn socket_path_of(root: &Path) -> PathBuf { + daemon_socket_path(root).expect("resolve the v2 rendezvous socket identity") + } + + fn create_rendezvous_dir(root: &Path) { + fs::create_dir_all(paths::rendezvous_dir(root).expect("resolve rendezvous dir")).unwrap(); } fn quiet_options() -> DaemonOptions { @@ -888,16 +1189,16 @@ mod tests { #[test] fn start_or_attach_clears_a_stale_lock_with_a_dead_pid_and_restarts() { let root = temp_root("stale-dead"); - fs::create_dir_all(paths::codegraph_dir(&root)).unwrap(); + create_rendezvous_dir(&root); let mut dead_pid = 999_999u32; while is_process_alive(dead_pid) { dead_pid -= 1; } - let pid_path = daemon_pid_path(&root); + let pid_path = pid_path_of(&root); let stale = DaemonLockInfo { pid: dead_pid, version: env!("CARGO_PKG_VERSION").to_string(), - socket_path: daemon_socket_path(&root), + socket_path: socket_path_of(&root), started_at: 1, }; fs::write(&pid_path, encode_lock_info(&stale).unwrap()).unwrap(); @@ -915,8 +1216,8 @@ mod tests { #[test] fn start_or_attach_clears_a_garbage_lock_without_info_and_restarts() { let root = temp_root("stale-garbage"); - fs::create_dir_all(paths::codegraph_dir(&root)).unwrap(); - let pid_path = daemon_pid_path(&root); + create_rendezvous_dir(&root); + let pid_path = pid_path_of(&root); fs::write(&pid_path, b"not-json-at-all").unwrap(); let started = start_or_attach(&root, quiet_options()) @@ -931,8 +1232,8 @@ mod tests { #[tokio::test] async fn bind_with_fallback_binds_the_preferred_socket_first() { let root = temp_root("bind-fallback"); - fs::create_dir_all(paths::codegraph_dir(&root)).unwrap(); - let preferred = daemon_socket_path(&root); + create_rendezvous_dir(&root); + let preferred = socket_path_of(&root); let (_listener, bound) = bind_with_fallback(&root, preferred.clone()).expect("preferred socket must bind"); assert_eq!(bound, preferred, "the preferred candidate binds first"); @@ -946,7 +1247,7 @@ mod tests { #[test] fn attach_to_a_dead_socket_path_errors() { let root = temp_root("attach-dead"); - let socket = daemon_socket_path(&root); + let socket = socket_path_of(&root); let err = attach_to_daemon(&socket).expect_err("attaching to an unbound socket must fail"); assert!( err.to_string().contains("connecting to daemon socket"), diff --git a/crates/codegraph-daemon/src/lock.rs b/crates/codegraph-daemon/src/lock.rs index ec0de1c..f974028 100644 --- a/crates/codegraph-daemon/src/lock.rs +++ b/crates/codegraph-daemon/src/lock.rs @@ -7,8 +7,9 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; +use tracing::debug; -use crate::paths::{codegraph_dir, daemon_pid_path, daemon_socket_path}; +use crate::paths::{daemon_pid_path, daemon_socket_path, rendezvous_dir}; use crate::process::is_process_alive; const EMPTY_RETRY_DELAY: Duration = Duration::from_millis(20); @@ -59,14 +60,14 @@ pub fn decode_lock_info(raw: &str) -> Option { } pub fn try_acquire_daemon_lock(project_root: &Path) -> Result { - let pid_path = daemon_pid_path(project_root); - fs::create_dir_all(codegraph_dir(project_root)) - .with_context(|| format!("creating {}", codegraph_dir(project_root).display()))?; + let pid_path = daemon_pid_path(project_root)?; + let dir = rendezvous_dir(project_root)?; + fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?; let info = DaemonLockInfo { pid: process::id(), version: env!("CARGO_PKG_VERSION").to_string(), - socket_path: daemon_socket_path(project_root), + socket_path: daemon_socket_path(project_root)?, started_at: now_millis(), }; @@ -144,9 +145,28 @@ pub fn clear_stale_daemon_lock(pid_path: &Path, expected_dead_pid: Option) fs::remove_file(pid_path).is_ok() } +/// Clear a stale daemon lock for `project_root`. An unresolvable index root is +/// reported as "not cleared" rather than reconstructing a rendezvous path. pub fn unlock_project(project_root: &Path) -> bool { - let pid_path = daemon_pid_path(project_root); - clear_stale_daemon_lock(&pid_path, None) + daemon_pid_path(project_root) + .map(|pid_path| clear_stale_daemon_lock(&pid_path, None)) + .unwrap_or(false) +} + +/// The mutation boundaries of [`clear_stale_daemon_socket`], exposed only so a +/// test can drive a competing daemon start at an exact point with no timing +/// assumption. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum StaleHealCheckpoint { + /// The stale (dead/absent-owner) pid record is gone and NOTHING is claimed: + /// the namespace is momentarily unowned and open to any starter. The socket + /// has not been touched. + StaleRecordCleared, + /// This cleaner WON the heal claim — its own pid record is published, so + /// every starter now reports `Taken`. The socket has not been touched. + HealClaimHeld, + /// The stale socket is gone; the heal claim is STILL published and still ours. + SocketRemoved, } /// Self-heal a project's stale daemon artifacts after a failed attach (Fix A): @@ -156,24 +176,108 @@ pub fn unlock_project(project_root: &Path) -> bool { /// /// Gated on liveness: returns `false` and touches nothing when the lock is held /// by a LIVE pid (`clear_stale_daemon_lock` refuses to remove a live lock). -/// Returns `true` once the stale lock is cleared; socket removal is best-effort -/// (a missing socket is already the desired end state). +/// +/// OWNERSHIP IS LOAD-BEARING — this cleaner unlinks a socket it did not bind, so +/// it must first BECOME the namespace's owner. The published pid record IS the +/// single-instance exclusion token ([`try_acquire_daemon_lock`] claims it with +/// `create_new`, and a daemon only ever binds after that claim succeeds). An +/// earlier revision cleared the stale record and THEN unlinked, holding no token +/// in between: a replacement daemon could legitimately claim the freed record and +/// bind the same path, and this unlink then destroyed the LIVE replacement's +/// socket — leaving a record naming a live daemon with no socket, which the +/// liveness gate makes unhealable. Merely swapping the two deletions does not fix +/// that, because an unlink performed while owning nothing is unsafe in either +/// order. +/// +/// So the sequence is claim-then-clean, and every step after the liveness gate is +/// conditional on WINNING the claim: +/// +/// 1. read the RECORDED socket (the record is the only place a fallback path is +/// written down), then remove the stale record — liveness-gated, so a live +/// owner's namespace is never entered; +/// 2. re-claim the pid record via [`try_acquire_daemon_lock`], i.e. with the very +/// same `create_new` token a starter uses. LOSING this claim means a +/// replacement (or a competing cleaner) now owns the namespace, so this cleaner +/// returns `false` and touches NOTHING — that is what closes the window rather +/// than narrowing it; +/// 3. unlink the stale socket while the claim excludes every starter, so no +/// process can have bound that path in the meantime; +/// 4. release the claim last, re-corroborated against our own pid +/// ([`cleanup_owned_lock`]). +/// +/// An ABSENT pid record is treated as an unowned namespace: `clear_stale_daemon_lock` +/// reports it cleared, and step 2 then claims it, so the socket is still only ever +/// removed under a held claim. A crash mid-heal leaves a record naming this +/// now-dead process and no socket — exactly the self-healing residue the next +/// heal clears. +/// +/// On Windows the recorded "socket path" is a bare namespaced pipe name rather +/// than a filesystem entry, so step 3's `remove_file` simply fails and is ignored +/// (a pipe disappears with its owning process); steps 1, 2 and 4 operate on the +/// real pid file and are what carry the exclusion there. pub fn clear_stale_daemon_socket(project_root: &Path) -> bool { - let pid_path = daemon_pid_path(project_root); - let socket_path = recorded_socket_path(project_root); + clear_stale_daemon_socket_with(project_root, |_| {}) +} + +fn clear_stale_daemon_socket_with( + project_root: &Path, + mut checkpoint: impl FnMut(StaleHealCheckpoint), +) -> bool { + let Ok(pid_path) = daemon_pid_path(project_root) else { + return false; + }; + // Read the recorded socket BEFORE the stale record goes away: the record is + // the only place a bind-fallback path is written down. + let Ok(socket_path) = recorded_socket_path(project_root) else { + return false; + }; // Liveness gate: only proceed once the owning pid is proven dead/absent. if !clear_stale_daemon_lock(&pid_path, None) { return false; } + checkpoint(StaleHealCheckpoint::StaleRecordCleared); + + // Become the owner before deleting anything in the namespace. A lost claim + // means somebody else owns it now; their socket is not ours to unlink. + let claim_pid = match try_acquire_daemon_lock(project_root) { + Ok(AcquireResult::Acquired { info, .. }) => info.pid, + Ok(AcquireResult::Taken { .. }) => { + debug!( + pid_path = %pid_path.display(), + "daemon rendezvous was re-claimed while healing; leaving its record and socket intact" + ); + return false; + } + Err(err) => { + debug!( + pid_path = %pid_path.display(), error = %err, + "could not claim the daemon rendezvous for healing; leaving it untouched" + ); + return false; + } + }; + checkpoint(StaleHealCheckpoint::HealClaimHeld); + + // Under the claim no starter can have bound this path, so the socket we see + // is provably the stale one. let _ = fs::remove_file(&socket_path); - true + checkpoint(StaleHealCheckpoint::SocketRemoved); + + // Release the claim last, re-corroborated against our pid. + cleanup_owned_lock(&pid_path, claim_pid) +} + +/// Whether the published pid record currently names `pid`. +fn record_names(pid_path: &Path, pid: u32) -> bool { + read_lock_info_tolerant(pid_path).is_some_and(|info| info.pid == pid) } -pub(crate) fn cleanup_owned_lock(pid_path: &Path, pid: u32) { - let owned = read_lock_info_tolerant(pid_path).is_some_and(|info| info.pid == pid); +pub(crate) fn cleanup_owned_lock(pid_path: &Path, pid: u32) -> bool { + let owned = record_names(pid_path, pid); if owned { let _ = fs::remove_file(pid_path); } + owned } enum ReadOutcome { @@ -218,12 +322,83 @@ fn read_lock_info_tolerant(pid_path: &Path) -> Option { /// recorded socket (a legacy plain-pid lock). Reading the recorded path — not /// recomputing — is what lets a client attach to a daemon that bound a fallback /// candidate (e.g. the tmpdir socket on an ExFAT project dir). -pub fn recorded_socket_path(project_root: &Path) -> PathBuf { - let pid_path = daemon_pid_path(project_root); - read_lock_info_tolerant(&pid_path) +pub fn recorded_socket_path(project_root: &Path) -> Result { + let pid_path = daemon_pid_path(project_root)?; + match read_lock_info_tolerant(&pid_path) .map(|info| info.socket_path) .filter(|socket| !socket.as_os_str().is_empty()) - .unwrap_or_else(|| daemon_socket_path(project_root)) + { + Some(recorded) => Ok(recorded), + None => daemon_socket_path(project_root), + } +} + +/// The two mutation boundaries of [`cleanup_owned_rendezvous`], exposed only so a +/// test can drive a competing daemon start at an exact point with no timing +/// assumption. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RendezvousCleanupCheckpoint { + /// The pid record was just corroborated as ours and is STILL PUBLISHED. The + /// socket has not been touched. + OwnershipCorroborated, + /// Our socket is gone; the pid record is still published and still ours. + SocketRemoved, +} + +/// Remove the rendezvous artifacts this daemon process itself published: on Unix +/// its bound socket file, then its owner-bound pid record. +/// +/// ORDER IS LOAD-BEARING. The published pid record IS the single-instance +/// exclusion: `try_acquire_daemon_lock` claims it with `create_new`, so while our +/// record exists a competing start reports `Taken` and never binds this socket +/// path. Removing the record FIRST (as an earlier revision did) opens a window in +/// which a replacement daemon legitimately claims the record and binds the same +/// path — and the departing process's later unlink then destroys the LIVE +/// daemon's socket while its record still advertises it. So the record is held as +/// exclusion until our socket is unlinked, and only then is the record removed, +/// re-corroborated against our pid. +/// +/// Crash behavior between the two operations is fail-closed and self-healing: the +/// namespace is left with a pid record naming a now-dead pid and no socket. A +/// client's attach to the recorded socket fails fast (bounded, never hangs), and +/// `clear_stale_daemon_lock` / `clear_stale_daemon_socket` clear that record on the +/// next start because the recorded pid is provably not alive. The inverse order +/// could instead leave a LIVE daemon with no socket, which nothing can heal. +/// +/// An already-replaced record (an owner mismatch observed on entry) preserves BOTH +/// the replacement record and its socket and reports `false`. The permanent index +/// lock is never named here. +pub(crate) fn cleanup_owned_rendezvous(pid_path: &Path, socket_path: &Path, pid: u32) -> bool { + cleanup_owned_rendezvous_with(pid_path, socket_path, pid, |_| {}) +} + +fn cleanup_owned_rendezvous_with( + pid_path: &Path, + socket_path: &Path, + pid: u32, + mut checkpoint: impl FnMut(RendezvousCleanupCheckpoint), +) -> bool { + if !record_names(pid_path, pid) { + debug!( + pid_path = %pid_path.display(), + "daemon rendezvous is owned by another process; leaving its record and socket intact" + ); + return false; + } + checkpoint(RendezvousCleanupCheckpoint::OwnershipCorroborated); + + #[cfg(unix)] + if let Some(stale) = crate::transport::Rendezvous::from_socket_path(socket_path).cleanup_path() + { + let _ = fs::remove_file(stale); + } + #[cfg(not(unix))] + let _ = socket_path; + checkpoint(RendezvousCleanupCheckpoint::SocketRemoved); + + // Re-corroborate at the second mutation boundary: only a record that STILL + // names this process may be removed. + cleanup_owned_lock(pid_path, pid) } fn now_millis() -> u128 { @@ -248,11 +423,7 @@ mod tests { fn rewrite_socket_path_updates_recorded_socket_and_keeps_pid() { // Given an acquired lock recording the default socket, rewriting the // recorded socket to a fallback path is what the client later reads. - let base = std::env::temp_dir().join(format!( - "cg-lock-rewrite-{}-{}", - process::id(), - now_millis() - )); + let base = temp_base("rewrite"); let AcquireResult::Acquired { pid_path, info } = try_acquire_daemon_lock(&base).expect("acquire lock") else { @@ -271,12 +442,60 @@ mod tests { let _ = fs::remove_dir_all(&base); } + /// A real, existing project directory. `IndexPaths` derives the physical + /// project identity from the filesystem object, so the project must exist + /// before any rendezvous path resolves. fn temp_base(label: &str) -> PathBuf { - std::env::temp_dir().join(format!( + let base = std::env::temp_dir().join(format!( "cg-lock-{label}-{}-{}", process::id(), now_millis() - )) + )); + fs::create_dir_all(&base).unwrap(); + base.canonicalize().unwrap() + } + + fn pid_path_of(base: &Path) -> PathBuf { + daemon_pid_path(base).expect("resolve the v2 rendezvous pid path") + } + + fn socket_path_of(base: &Path) -> PathBuf { + daemon_socket_path(base).expect("resolve the v2 rendezvous socket identity") + } + + fn create_rendezvous_dir(base: &Path) { + let dir = rendezvous_dir(base).expect("resolve the v2 rendezvous dir"); + fs::create_dir_all(dir).unwrap(); + } + + /// A bound socket is a FILESYSTEM artifact only on unix. On windows + /// [`daemon_socket_path`] returns a bare namespaced pipe NAME + /// (`codegraph-v2-`), a kernel object reclaimed when its last handle + /// closes — which is why [`cleanup_owned_rendezvous`] unlinks nothing there, + /// correctly. Writing that name as a file and then asserting it was removed + /// tests a premise windows does not have, so these helpers confine the + /// file-level claims to unix. The ORDERING guarantees stay asserted on EVERY + /// platform: the pid record as exclusion, a competing start refused at the + /// midpoint, and the crash residue the stale heal clears. + fn publish_socket_file(socket: &Path, contents: &[u8]) { + #[cfg(unix)] + fs::write(socket, contents).unwrap(); + #[cfg(not(unix))] + let _ = (socket, contents); + } + + fn assert_socket_file_present(socket: &Path, message: &str) { + #[cfg(unix)] + assert!(socket.exists(), "{message}"); + #[cfg(not(unix))] + let _ = (socket, message); + } + + fn assert_socket_file_absent(socket: &Path, message: &str) { + #[cfg(unix)] + assert!(!socket.exists(), "{message}"); + #[cfg(not(unix))] + let _ = (socket, message); } #[test] @@ -303,7 +522,7 @@ mod tests { #[test] fn clear_stale_lock_returns_true_when_missing() { let base = temp_base("clear-missing"); - let pid_path = daemon_pid_path(&base); + let pid_path = pid_path_of(&base); assert!(clear_stale_daemon_lock(&pid_path, None)); } @@ -326,8 +545,8 @@ mod tests { #[test] fn clear_stale_lock_removes_a_dead_pid_lock() { let base = temp_base("clear-dead"); - fs::create_dir_all(codegraph_dir(&base)).unwrap(); - let pid_path = daemon_pid_path(&base); + create_rendezvous_dir(&base); + let pid_path = pid_path_of(&base); let dead = DaemonLockInfo { pid: 4_000_000_000, version: "1.0.0".to_string(), @@ -343,8 +562,8 @@ mod tests { #[test] fn clear_stale_lock_refuses_when_expected_pid_mismatches() { let base = temp_base("clear-mismatch"); - fs::create_dir_all(codegraph_dir(&base)).unwrap(); - let pid_path = daemon_pid_path(&base); + create_rendezvous_dir(&base); + let pid_path = pid_path_of(&base); let dead = DaemonLockInfo { pid: 4_000_000_000, version: "1.0.0".to_string(), @@ -362,8 +581,8 @@ mod tests { #[test] fn unlock_project_clears_a_dead_lock() { let base = temp_base("unlock"); - fs::create_dir_all(codegraph_dir(&base)).unwrap(); - let pid_path = daemon_pid_path(&base); + create_rendezvous_dir(&base); + let pid_path = pid_path_of(&base); let dead = DaemonLockInfo { pid: 4_000_000_000, version: "1.0.0".to_string(), @@ -378,7 +597,11 @@ mod tests { #[test] fn recorded_socket_path_falls_back_to_default_when_lock_absent() { let base = temp_base("recorded-absent"); - assert_eq!(recorded_socket_path(&base), daemon_socket_path(&base)); + assert_eq!( + recorded_socket_path(&base).expect("resolve recorded socket"), + socket_path_of(&base) + ); + let _ = fs::remove_dir_all(&base); } #[test] @@ -391,7 +614,10 @@ mod tests { }; let recorded = std::env::temp_dir().join("cg-recorded.sock"); rewrite_lock_socket_path(&pid_path, &recorded).unwrap(); - assert_eq!(recorded_socket_path(&base), recorded); + assert_eq!( + recorded_socket_path(&base).expect("resolve recorded socket"), + recorded + ); let _ = fs::remove_dir_all(&base); } @@ -412,12 +638,369 @@ mod tests { let _ = fs::remove_dir_all(&base); } + #[test] + fn cleanup_owned_rendezvous_preserves_a_replacement_owners_record_and_socket() { + let base = temp_base("cleanup-replaced"); + create_rendezvous_dir(&base); + let pid_path = pid_path_of(&base); + let socket = socket_path_of(&base); + // A NEW owner already replaced the record and rebound the same socket. + let replacement = DaemonLockInfo { + pid: process::id(), + version: env!("CARGO_PKG_VERSION").to_string(), + socket_path: socket.clone(), + started_at: 2, + }; + fs::write(&pid_path, encode_lock_info(&replacement).unwrap()).unwrap(); + fs::write(&socket, b"").unwrap(); + + // The DEPARTING daemon (a different pid) must strip neither artifact. + let mut departing = 4_000_000_000u32; + while departing == process::id() { + departing -= 1; + } + assert!( + !cleanup_owned_rendezvous(&pid_path, &socket, departing), + "an owner mismatch must report that nothing was cleaned" + ); + assert!( + pid_path.exists(), + "the replacement owner's record must survive" + ); + assert!( + socket.exists(), + "the replacement owner's socket must survive" + ); + + // The true owner cleans both. + assert!(cleanup_owned_rendezvous(&pid_path, &socket, process::id())); + assert!(!pid_path.exists()); + #[cfg(unix)] + assert!(!socket.exists()); + let _ = fs::remove_dir_all(&base); + } + + /// The former vulnerable midpoint. A competing start is driven at the EXACT + /// boundary where the old revision had already removed the pid record, and it + /// is allowed to claim the record and rebind the same socket path. The + /// departing owner must not destroy that replacement. + /// + /// Deterministic by construction: the replacement runs inside the checkpoint + /// callback, so it is ordered by the call itself, not by any sleep. + /// + /// Under the OLD pid-record-first order this test fails — `try_acquire` would + /// succeed (the record was gone), and the subsequent unlink would delete the + /// replacement's socket. + #[test] + fn a_replacement_start_at_the_cleanup_midpoint_keeps_its_own_rendezvous() { + let base = temp_base("cleanup-midpoint"); + create_rendezvous_dir(&base); + let pid_path = pid_path_of(&base); + let socket = socket_path_of(&base); + + // The DEPARTING owner: a distinct, provably dead pid, with its record + // published and its socket bound. + let mut departing = 4_000_000_000u32; + while departing == process::id() || is_process_alive(departing) { + departing -= 1; + } + let old = DaemonLockInfo { + pid: departing, + version: env!("CARGO_PKG_VERSION").to_string(), + socket_path: socket.clone(), + started_at: 1, + }; + fs::write(&pid_path, encode_lock_info(&old).unwrap()).unwrap(); + publish_socket_file(&socket, b"old"); + + let mut replacement_claim = None; + let cleaned = cleanup_owned_rendezvous_with(&pid_path, &socket, departing, |checkpoint| { + if checkpoint != RendezvousCleanupCheckpoint::OwnershipCorroborated { + return; + } + // A competing start races here. While OUR record is still published it + // must be refused, which is exactly what keeps the replacement from + // binding a socket the departing owner is about to unlink. + replacement_claim = Some(try_acquire_daemon_lock(&base).expect("competing start")); + }); + + match replacement_claim.expect("the competing start ran at the checkpoint") { + AcquireResult::Taken { existing, .. } => { + let existing = existing.expect("the departing record is readable"); + assert_eq!( + existing.pid, departing, + "the published record must still be the departing owner's, so the \ + replacement is refused instead of binding a doomed socket" + ); + } + AcquireResult::Acquired { .. } => panic!( + "a competing start must NOT be able to claim the record before the departing \ + owner has finished unlinking its socket" + ), + } + + assert!(cleaned, "the departing owner cleans its own rendezvous"); + assert!(!pid_path.exists(), "its record is removed last"); + assert_socket_file_absent(&socket, "its socket was removed first"); + + // The replacement can now claim a clean namespace and bind its own socket, + // and nothing left over can delete it. + let AcquireResult::Acquired { .. } = + try_acquire_daemon_lock(&base).expect("post-cleanup acquire") + else { + panic!("a cleaned namespace must be claimable"); + }; + publish_socket_file(&socket, b"new"); + assert!( + !cleanup_owned_rendezvous(&pid_path, &socket, departing), + "a second cleanup pass by the departed owner must be refused" + ); + assert!(pid_path.exists()); + assert_socket_file_present(&socket, "the replacement's socket survives"); + let _ = fs::remove_dir_all(&base); + } + + /// The socket is unlinked BEFORE the record, so a crash between the two + /// boundaries leaves a dead-pid record with no socket — the self-healing + /// residue `clear_stale_daemon_socket` is built to clear. The inverse order + /// would leave a live daemon with no socket, which nothing can heal. + #[test] + fn a_crash_between_cleanup_boundaries_leaves_only_self_healing_residue() { + let base = temp_base("cleanup-crash"); + create_rendezvous_dir(&base); + let pid_path = pid_path_of(&base); + let socket = socket_path_of(&base); + let mut departing = 4_000_000_000u32; + while departing == process::id() || is_process_alive(departing) { + departing -= 1; + } + let old = DaemonLockInfo { + pid: departing, + version: env!("CARGO_PKG_VERSION").to_string(), + socket_path: socket.clone(), + started_at: 1, + }; + fs::write(&pid_path, encode_lock_info(&old).unwrap()).unwrap(); + publish_socket_file(&socket, b"old"); + + // Simulate the crash by panicking-free early return: run cleanup only up to + // the socket-removed boundary and drop the process there. + let mut reached_socket_removed = false; + let _ = cleanup_owned_rendezvous_with(&pid_path, &socket, departing, |checkpoint| { + if checkpoint == RendezvousCleanupCheckpoint::SocketRemoved { + reached_socket_removed = true; + assert_socket_file_absent(&socket, "the socket is gone at this boundary"); + assert!( + pid_path.exists(), + "the record is STILL published as exclusion at this boundary" + ); + } + }); + assert!(reached_socket_removed); + + // The residue is exactly what the stale-socket self-heal clears. + assert!(clear_stale_daemon_socket(&base)); + assert!(!pid_path.exists()); + let _ = fs::remove_dir_all(&base); + } + + /// A dead pid whose record and socket are the stale residue to be healed. + fn dead_pid() -> u32 { + let mut candidate = 4_000_000_000u32; + while candidate == process::id() || is_process_alive(candidate) { + candidate -= 1; + } + candidate + } + + fn publish_record(pid_path: &Path, pid: u32, socket: &Path) { + let info = DaemonLockInfo { + pid, + version: env!("CARGO_PKG_VERSION").to_string(), + socket_path: socket.to_path_buf(), + started_at: 1, + }; + fs::write(pid_path, encode_lock_info(&info).unwrap()).unwrap(); + } + + /// The rendezvous-ownership race. A replacement daemon legitimately claims the + /// namespace at the EXACT point where the stale record is gone and nothing is + /// owned, and binds the same socket path. The cleaner must lose its claim and + /// destroy nothing. + /// + /// Deterministic by construction: the replacement runs inside the checkpoint + /// callback, so it is ordered by the call itself, not by any sleep. + /// + /// Under the OLD clear-record-then-unlink order this test fails — the unlink + /// was unconditional, so it deleted the LIVE replacement's socket. + #[test] + fn a_replacement_start_during_the_stale_heal_keeps_its_own_socket() { + let base = temp_base("heal-replaced"); + create_rendezvous_dir(&base); + let pid_path = pid_path_of(&base); + let socket = socket_path_of(&base); + publish_record(&pid_path, dead_pid(), &socket); + fs::write(&socket, b"stale").unwrap(); + + let mut replacement_ran = false; + let healed = clear_stale_daemon_socket_with(&base, |checkpoint| { + if checkpoint != StaleHealCheckpoint::StaleRecordCleared { + return; + } + // A replacement start wins the freed namespace here: it claims the + // record with its own LIVE pid and binds the same socket path. + replacement_ran = true; + publish_record(&pid_path, process::id(), &socket); + fs::write(&socket, b"live-replacement").unwrap(); + }); + + assert!(replacement_ran, "the replacement ran at the checkpoint"); + assert!( + socket.exists(), + "the LIVE replacement's socket must survive the stale heal" + ); + assert_eq!( + fs::read(&socket).unwrap(), + b"live-replacement", + "the surviving socket must be the replacement's, not the stale one" + ); + assert!( + !healed, + "a cleaner that lost the namespace must report that it healed nothing" + ); + let surviving = + read_lock_info_tolerant(&pid_path).expect("the replacement record survives"); + assert_eq!( + surviving.pid, + process::id(), + "the replacement's record must be left exactly as published" + ); + let _ = fs::remove_dir_all(&base); + } + + /// The claim is what makes the unlink safe, so it must be PUBLISHED across the + /// unlink and released only afterwards. While it is held every starter is + /// excluded, which is why no process can have bound the path being unlinked. + #[test] + fn the_stale_heal_holds_its_claim_across_the_unlink_and_releases_it_last() { + let base = temp_base("heal-claim"); + create_rendezvous_dir(&base); + let pid_path = pid_path_of(&base); + let socket = socket_path_of(&base); + publish_record(&pid_path, dead_pid(), &socket); + fs::write(&socket, b"stale").unwrap(); + + let mut competing_start = None; + let mut saw_socket_removed = false; + let healed = clear_stale_daemon_socket_with(&base, |checkpoint| match checkpoint { + StaleHealCheckpoint::HealClaimHeld => { + let claim = + read_lock_info_tolerant(&pid_path).expect("the heal claim is published"); + assert_eq!( + claim.pid, + process::id(), + "the heal claim must name this cleaner" + ); + assert!(socket.exists(), "the socket is untouched under the claim"); + competing_start = + Some(try_acquire_daemon_lock(&base).expect("competing start under the claim")); + } + StaleHealCheckpoint::SocketRemoved => { + saw_socket_removed = true; + assert!( + !socket.exists(), + "the stale socket is gone at this boundary" + ); + assert!( + pid_path.exists(), + "the heal claim must STILL be published while the socket is unlinked" + ); + } + StaleHealCheckpoint::StaleRecordCleared => {} + }); + + match competing_start.expect("a competing start ran under the claim") { + AcquireResult::Taken { existing, .. } => { + let existing = existing.expect("the heal claim is readable"); + assert_eq!( + existing.pid, + process::id(), + "a starter racing the heal must be refused BY the heal claim, so it \ + cannot bind the socket the cleaner is about to unlink" + ); + } + AcquireResult::Acquired { .. } => panic!( + "a starter must NOT be able to claim the namespace while the cleaner holds \ + its heal claim" + ), + } + assert!(saw_socket_removed); + assert!(healed, "a genuinely stale namespace heals"); + assert!(!pid_path.exists(), "the heal claim is released last"); + let _ = fs::remove_dir_all(&base); + } + + /// Stale recovery still works: a dead owner's record AND socket are both gone + /// after the heal, so the next start spawns fresh. + #[test] + fn a_genuinely_stale_namespace_still_self_heals_both_artifacts() { + let base = temp_base("heal-stale"); + create_rendezvous_dir(&base); + let pid_path = pid_path_of(&base); + let socket = socket_path_of(&base); + publish_record(&pid_path, dead_pid(), &socket); + fs::write(&socket, b"stale").unwrap(); + + assert!(clear_stale_daemon_socket(&base), "the stale residue heals"); + assert!(!pid_path.exists(), "the stale record is gone"); + assert!(!socket.exists(), "the stale socket is gone"); + let AcquireResult::Acquired { .. } = + try_acquire_daemon_lock(&base).expect("post-heal acquire") + else { + panic!("a healed namespace must be claimable by a fresh start"); + }; + let _ = fs::remove_dir_all(&base); + } + + /// An ABSENT record means nobody owns the namespace, so a leftover socket may + /// be removed — but only after the cleaner has CLAIMED that namespace, exactly + /// as it does for a dead-owner record. Without the claim, an absent record + /// would be the widest interleaving of all: it names no pid to prove dead, so + /// an unlink under it could hit a daemon that bound between the two syscalls. + #[test] + fn an_absent_record_is_claimed_before_its_leftover_socket_is_removed() { + let base = temp_base("heal-absent"); + create_rendezvous_dir(&base); + let pid_path = pid_path_of(&base); + let socket = socket_path_of(&base); + fs::write(&socket, b"orphan").unwrap(); + assert!(!pid_path.exists(), "precondition: no record at all"); + + let mut claim_held_with_socket_present = false; + let healed = clear_stale_daemon_socket_with(&base, |checkpoint| { + if checkpoint == StaleHealCheckpoint::HealClaimHeld { + claim_held_with_socket_present = + record_names(&pid_path, process::id()) && socket.exists(); + } + }); + + assert!( + claim_held_with_socket_present, + "the leftover socket must still be present at the moment the claim is won, \ + proving the removal happens UNDER the claim and not before it" + ); + assert!(healed, "an unowned namespace with a leftover socket heals"); + assert!(!socket.exists(), "the orphaned socket is removed"); + assert!(!pid_path.exists(), "the heal claim is released"); + let _ = fs::remove_dir_all(&base); + } + #[test] fn clear_stale_daemon_socket_removes_lock_and_socket_when_dead() { let base = temp_base("socket-dead"); - fs::create_dir_all(codegraph_dir(&base)).unwrap(); - let pid_path = daemon_pid_path(&base); - let socket = daemon_socket_path(&base); + create_rendezvous_dir(&base); + let pid_path = pid_path_of(&base); + let socket = socket_path_of(&base); let dead = DaemonLockInfo { pid: 4_000_000_000, version: "1.0.0".to_string(), diff --git a/crates/codegraph-daemon/src/paths.rs b/crates/codegraph-daemon/src/paths.rs index 143ce64..0696f6f 100644 --- a/crates/codegraph-daemon/src/paths.rs +++ b/crates/codegraph-daemon/src/paths.rs @@ -1,124 +1,235 @@ -#[cfg(unix)] -use std::env; +//! Daemon rendezvous paths, derived from the ONE `IndexPaths` authority. +//! +//! Frozen plan lines 590-592: current daemon paths use the centralized current +//! root and the v2 socket identity. Nothing here reconstructs a `.codegraph*` +//! path: every value comes from [`IndexPaths::resolve`], which fails closed on an +//! unsafe/aliased/overlapping configured root, so each accessor is fallible. + use std::path::{Path, PathBuf}; +use anyhow::Result; +use codegraph_core::IndexPaths; use sha2::{Digest, Sha256}; #[cfg(unix)] const POSIX_SOCKET_PATH_LIMIT: usize = 100; -pub(crate) fn codegraph_dir(project_root: &Path) -> PathBuf { - project_root.join(".codegraph") +/// The v2 rendezvous discriminator carried by the out-of-root socket identities +/// (the POSIX tmpdir fallback and the Windows namespaced pipe), so a current +/// daemon can never collide with a legacy `v0.40.4` rendezvous name. +const V2_RENDEZVOUS_PREFIX: &str = "codegraph-v2-"; + +/// Resolve the project's index paths, honoring `CODEGRAPH_DIR`. +pub(crate) fn index_paths(project_root: &Path) -> Result { + Ok(IndexPaths::resolve( + project_root, + std::env::var("CODEGRAPH_DIR").ok().as_deref(), + )?) } -pub fn daemon_pid_path(project_root: &Path) -> PathBuf { - codegraph_dir(project_root).join("daemon.pid") +/// The rendezvous directory: the resolved current index root. +pub(crate) fn rendezvous_dir(project_root: &Path) -> Result { + Ok(index_paths(project_root)?.current_root().to_path_buf()) +} + +pub fn daemon_pid_path(project_root: &Path) -> Result { + Ok(index_paths(project_root)?.daemon_pid()) } /// Path of the appended log file the detached daemon's stdout+stderr are -/// redirected to (`.codegraph/daemon.log`). -pub fn daemon_log_path(project_root: &Path) -> PathBuf { - codegraph_dir(project_root).join("daemon.log") +/// redirected to. +pub fn daemon_log_path(project_root: &Path) -> Result { + Ok(index_paths(project_root)?.daemon_log()) } #[cfg(unix)] -pub fn daemon_socket_path(project_root: &Path) -> PathBuf { - let in_project = codegraph_dir(project_root).join("daemon.sock"); - if in_project.as_os_str().len() <= POSIX_SOCKET_PATH_LIMIT { - return in_project; +pub fn daemon_socket_path(project_root: &Path) -> Result { + Ok(socket_identity(&index_paths(project_root)?)) +} + +#[cfg(unix)] +fn socket_identity(paths: &IndexPaths) -> PathBuf { + let in_root = paths.daemon_socket(); + if in_root.as_os_str().len() <= POSIX_SOCKET_PATH_LIMIT { + return in_root; } - env::temp_dir().join(format!("codegraph-{}.sock", project_hash(project_root))) + tmp_socket(paths) +} + +#[cfg(unix)] +fn tmp_socket(paths: &IndexPaths) -> PathBuf { + std::env::temp_dir().join(format!( + "{V2_RENDEZVOUS_PREFIX}{}.sock", + rendezvous_name(paths) + )) } /// Ordered, deterministic socket-bind candidates for `project_root` -/// (`f83a1ec`). Candidate #1 is the project-dir socket (when its path fits the -/// POSIX limit); candidate #2 is the hashed-tmpdir socket. On filesystems that -/// reject `bind()` for an AF_UNIX socket (ExFAT/FAT, some network mounts, WSL -/// DrvFs), the daemon falls through to the next candidate. The list is -/// deduplicated and never empty: when the project path is too long for #1, the -/// tmpdir socket IS candidate #1 (matching `daemon_socket_path`). +/// (`f83a1ec`). Candidate #1 is the in-root socket (when its path fits the POSIX +/// limit); candidate #2 is the hashed-tmpdir socket. On filesystems that reject +/// `bind()` for an AF_UNIX socket (ExFAT/FAT, some network mounts, WSL DrvFs), +/// the daemon falls through to the next candidate. The list is deduplicated and +/// never empty: when the in-root path is too long for #1, the tmpdir socket IS +/// candidate #1 (matching [`daemon_socket_path`]). #[cfg(unix)] -pub fn daemon_socket_candidates(project_root: &Path) -> Vec { - let in_project = codegraph_dir(project_root).join("daemon.sock"); - let tmp = env::temp_dir().join(format!("codegraph-{}.sock", project_hash(project_root))); - if in_project.as_os_str().len() <= POSIX_SOCKET_PATH_LIMIT && in_project != tmp { - vec![in_project, tmp] - } else { - vec![tmp] - } +pub fn daemon_socket_candidates(project_root: &Path) -> Result> { + let paths = index_paths(project_root)?; + let in_root = paths.daemon_socket(); + let tmp = tmp_socket(&paths); + Ok( + if in_root.as_os_str().len() <= POSIX_SOCKET_PATH_LIMIT && in_root != tmp { + vec![in_root, tmp] + } else { + vec![tmp] + }, + ) } // Windows has no filesystem socket: the rendezvous is a BARE namespaced name. // interprocess `GenericNamespaced` prepends `\\.\pipe\` itself, so storing the // prefix here would double it (Locked decision #8/#9). #[cfg(windows)] -pub fn daemon_socket_path(project_root: &Path) -> PathBuf { - PathBuf::from(format!("codegraph-{}", project_hash(project_root))) +pub fn daemon_socket_path(project_root: &Path) -> Result { + let paths = index_paths(project_root)?; + Ok(PathBuf::from(format!( + "{V2_RENDEZVOUS_PREFIX}{}", + rendezvous_name(&paths) + ))) } -fn project_hash(project_root: &Path) -> String { - let resolved = project_root - .canonicalize() - .unwrap_or_else(|_| project_root.to_path_buf()); +/// The 16-hex-character discriminated name used by the OUT-OF-ROOT rendezvous +/// identities (the POSIX tmpdir fallback and the Windows namespaced pipe). +/// +/// It is `sha256(V2_RENDEZVOUS_PREFIX || projectIdentity)` truncated, NOT a +/// prefix of the identity itself: the legacy `v0.40.4` name is +/// `sha256()[..16]`, and mixing in this version's own domain +/// separator makes the two provably distinct names for the same project even if +/// a future identity scheme ever coincided with a path hash. The authoritative +/// owner binding always carries the FULL identity; this value only has to be a +/// stable, collision-resistant NAME. +fn rendezvous_name(paths: &IndexPaths) -> String { let mut hasher = Sha256::new(); - hasher.update(resolved.to_string_lossy().as_bytes()); - let hex = format!("{:x}", hasher.finalize()); - hex[..16].to_string() + hasher.update(V2_RENDEZVOUS_PREFIX.as_bytes()); + hasher.update(paths.project_identity().as_bytes()); + let digest = hasher.finalize(); + let mut out = String::with_capacity(16); + for byte in digest.iter().take(8) { + out.push_str(&format!("{byte:02x}")); + } + out } #[cfg(test)] mod tests { use super::*; - #[cfg(unix)] + fn temp_project(label: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "cg-daemon-paths-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&path).unwrap(); + path.canonicalize().unwrap() + } + #[test] - fn socket_path_uses_project_dir_for_short_paths() { - let root = PathBuf::from("/tmp/cg-short"); - assert_eq!( - daemon_socket_path(&root), - root.join(".codegraph/daemon.sock") + fn rendezvous_paths_come_from_the_resolved_current_root() { + let project = temp_project("current-root"); + let paths = index_paths(&project).unwrap(); + assert_eq!(daemon_pid_path(&project).unwrap(), paths.daemon_pid()); + assert_eq!(daemon_log_path(&project).unwrap(), paths.daemon_log()); + assert_eq!(rendezvous_dir(&project).unwrap(), paths.current_root()); + assert!( + paths + .current_root() + .starts_with(project.join(".codegraph-v2")), + "the default rendezvous dir is the v2 current root: {}", + paths.current_root().display() ); + let _ = std::fs::remove_dir_all(&project); + } + + #[test] + fn rendezvous_paths_fail_closed_for_an_unresolvable_project() { + // No `CODEGRAPH_DIR` mutation: that variable is process-global and this + // crate's unit tests share one binary, so an env-mutating test would race + // every other rendezvous resolution. An absent project is an equally + // authoritative fail-closed input, because `IndexPaths` derives the + // PHYSICAL project identity and cannot invent one for a path that does + // not exist. + let missing = std::env::temp_dir().join(format!( + "cg-daemon-paths-absent-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + assert!(daemon_pid_path(&missing).is_err()); + assert!(daemon_log_path(&missing).is_err()); + assert!(daemon_socket_path(&missing).is_err()); + assert!(rendezvous_dir(&missing).is_err()); + #[cfg(unix)] + assert!(daemon_socket_candidates(&missing).is_err()); + } + + #[cfg(unix)] + #[test] + fn socket_path_uses_the_current_root_for_short_paths() { + let project = temp_project("short"); + let paths = index_paths(&project).unwrap(); + assert_eq!(daemon_socket_path(&project).unwrap(), paths.daemon_socket()); + let _ = std::fs::remove_dir_all(&project); } #[cfg(unix)] #[test] - fn candidate_chain_starts_with_project_socket_then_tmpdir() { - // Given a short project path, candidate #1 is the project-dir socket and - // candidate #2 is the hashed-tmpdir socket (the bind-fallback target). - let root = PathBuf::from("/tmp/cg-short"); - let candidates = daemon_socket_candidates(&root); + fn candidate_chain_starts_with_the_in_root_socket_then_tmpdir() { + let project = temp_project("chain"); + let paths = index_paths(&project).unwrap(); + let candidates = daemon_socket_candidates(&project).unwrap(); assert_eq!(candidates.len(), 2); - assert_eq!(candidates[0], root.join(".codegraph/daemon.sock")); - assert!(candidates[1].starts_with(env::temp_dir())); + assert_eq!(candidates[0], paths.daemon_socket()); + assert!(candidates[1].starts_with(std::env::temp_dir())); assert!( candidates[1] .file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n.starts_with("codegraph-") && n.ends_with(".sock")) + .and_then(|name| name.to_str()) + .is_some_and( + |name| name.starts_with(V2_RENDEZVOUS_PREFIX) && name.ends_with(".sock") + ), + "the tmpdir fallback carries the v2 discriminator: {:?}", + candidates[1] ); - // The default socket path equals candidate #1. - assert_eq!(daemon_socket_path(&root), candidates[0]); + assert_eq!(daemon_socket_path(&project).unwrap(), candidates[0]); + let _ = std::fs::remove_dir_all(&project); } #[cfg(unix)] #[test] fn candidate_chain_collapses_to_tmpdir_for_long_paths() { - // Given a project path too long for an AF_UNIX socket, the only candidate - // is the hashed-tmpdir socket (so the chain is never empty). - let long = PathBuf::from(format!("/tmp/{}", "x".repeat(120))); - let candidates = daemon_socket_candidates(&long); + let base = temp_project("long"); + let project = base.join("x".repeat(120)); + std::fs::create_dir_all(&project).unwrap(); + let candidates = daemon_socket_candidates(&project).unwrap(); assert_eq!(candidates.len(), 1); - assert!(candidates[0].starts_with(env::temp_dir())); - assert_eq!(daemon_socket_path(&long), candidates[0]); + assert!(candidates[0].starts_with(std::env::temp_dir())); + assert_eq!(daemon_socket_path(&project).unwrap(), candidates[0]); + let _ = std::fs::remove_dir_all(&base); } #[cfg(windows)] #[test] - fn socket_path_is_a_bare_namespaced_name() { - let root = PathBuf::from(r"C:\tmp\cg-short"); - let name = daemon_socket_path(&root); + fn socket_path_is_a_bare_namespaced_v2_name() { + let project = temp_project("windows-name"); + let name = daemon_socket_path(&project).unwrap(); let name = name.to_string_lossy(); - assert!(name.starts_with("codegraph-")); + assert!(name.starts_with(V2_RENDEZVOUS_PREFIX)); assert!(!name.contains(r"\\.\pipe\")); + let _ = std::fs::remove_dir_all(&project); } } diff --git a/crates/codegraph-daemon/src/proxy.rs b/crates/codegraph-daemon/src/proxy.rs index 1e3fb90..ee99a3a 100644 --- a/crates/codegraph-daemon/src/proxy.rs +++ b/crates/codegraph-daemon/src/proxy.rs @@ -43,6 +43,16 @@ const EXPECTED_PROTOCOL: u64 = 1; /// Poll cadence for the PPID watchdog (mirrors colby `DEFAULT_PPID_POLL_MS`). const PPID_POLL_INTERVAL: Duration = Duration::from_millis(500); +/// Last-resort bound on the post-host-EOF wait for the replies the host is still +/// owed (see [`ReplyLedger`]). Never reached in normal operation — the ledger +/// settles the instant the daemon answers the last forwarded request, or the +/// instant the daemon stream ends. It exists only so a daemon that accepted the +/// connection and then stalled forever cannot wedge the proxy's teardown; it is +/// deliberately LONGER than any caller's own deadline, so a genuinely stalled +/// daemon still surfaces as that caller's failure rather than being absorbed +/// here. +const REPLY_DRAIN_BUDGET: Duration = Duration::from_secs(20); + /// Outcome of a proxy attempt. #[derive(Debug, PartialEq, Eq)] pub enum ProxyOutcome { @@ -100,15 +110,16 @@ pub fn run_proxy( return Ok(mismatch); } - // Split into independent recv/send halves. interprocess's sync UDS split - // hands BOTH halves an `Arc` over the SAME fd, so merely DROPPING the send - // half does not signal EOF to the daemon — the fd stays open via the recv - // half. We therefore capture the WRITE-side fd before moving `send` into the - // up pump and, once the host side is done, explicitly half-close it - // (shutdown(SHUT_WR)); that is what makes the daemon's session reader hit - // EOF, flush its last reply, and close — which in turn EOFs our recv pump so - // `down.join()` never hangs. The fd stays valid through teardown because the - // recv half keeps the shared socket open. + // Split into independent recv/send halves. interprocess's split hands BOTH + // halves a refcount over the SAME kernel object (an `Arc` on + // windows, an `Arc` over one fd on unix), so merely DROPPING the send half + // does not signal EOF to the daemon — the object stays open via the recv + // half. On unix we therefore capture the WRITE-side fd before moving `send` + // into the up pump and, once the host side is done, explicitly half-close it + // (shutdown(SHUT_WR)), which makes the daemon's session reader hit EOF, + // flush its last reply, and close. Windows named pipes have NO half-close, + // so teardown there cannot depend on the daemon closing first; the reply + // ledger below is what bounds it on every platform. let (recv, mut send) = stream.split(); let write_fd = write_raw_fd(&send); @@ -125,6 +136,10 @@ pub fn run_proxy( // Shared shutdown flag flipped by the watchdog on host death and polled // per-line by the up pump. Its byte-for-byte pump semantics are unchanged. let shutdown = Arc::new(AtomicBool::new(false)); + // What the host is still OWED: every forwarded request id, retired only once + // its reply has been written to the host. This is the platform-independent + // teardown condition — see `ReplyLedger`. + let ledger = Arc::new(ReplyLedger::default()); // Event channel the watchdog parks on: lets teardown wake it the instant // shutdown is decided instead of after the remainder of a poll interval. let watchdog_wake = Arc::new(Shutdown::new()); @@ -144,29 +159,144 @@ pub fn run_proxy( let socket_reader = BufReader::new(recv); let down_suppressed = Arc::clone(&suppressed_id); let down_out = Arc::clone(&host_out); - let down = - thread::spawn(move || pump_daemon_to_host(socket_reader, &down_out, &down_suppressed)); + let down_ledger = Arc::clone(&ledger); + let down = thread::spawn(move || { + let result = pump_daemon_to_host(socket_reader, &down_out, &down_suppressed, &down_ledger); + // The daemon stream ended: nothing further can ever arrive, so whatever is + // still outstanding will never be answered. Settle the ledger so a + // teardown parked on it wakes instead of waiting out the budget. + down_ledger.abandon(); + result + }); // host -> daemon pump (this thread): answer initialize/tools-list locally, // forward the rest. Runs to completion on host_in EOF. - let up_result = pump_host_to_daemon(host_in, send, &host_out, &shutdown, &suppressed_id); - - // Host side is done. Half-close the write direction so the daemon reader - // EOFs (it flushes its final reply first); the down pump then drains those - // replies and exits on its own EOF. Do NOT flip `shutdown` before the join - // or it would race the drain and drop the last reply. + let up_result = + pump_host_to_daemon(host_in, send, &host_out, &shutdown, &suppressed_id, &ledger); + + // Host side is done, but the daemon may still owe replies to requests we + // forwarded. Wait for the LEDGER to settle — every owed reply written, or the + // daemon stream gone — NOT for the down pump to exit. + // + // On unix we first half-close the write direction: the daemon reader EOFs, + // flushes its final reply, and closes, so the down pump reaches EOF on its + // own and `down.join()` returns. Windows named pipes have NO half-close + // (`half_close_write` is a documented no-op there), so the daemon's session + // reader NEVER EOFs while our recv half keeps the pipe open — its rmcp serve + // loop parks on the next frame, the pipe is never closed, and the down pump + // therefore never returns. Joining it unconditionally is an UNBOUNDED wait on + // windows; it is what made this function never return there. half_close_write(write_fd); - let _ = down.join(); + let settled = ledger.wait_until_settled(REPLY_DRAIN_BUDGET); shutdown.store(true, Ordering::SeqCst); // Wake the watchdog at once so its join (in drop) returns promptly instead // of waiting out the remainder of a poll interval. watchdog_wake.signal(); drop(watchdog); + // Join only when the down pump can actually be known to have finished: it + // exits on daemon EOF, which only the half-closing platform guarantees. + // Elsewhere it is detached — its thread ends when the daemon closes the pipe + // (idle-exit, sweep, or shutdown), and it holds only clones. + if HALF_CLOSE_EOFS_DAEMON { + let _ = down.join(); + } up_result?; + anyhow::ensure!( + settled, + "daemon did not answer every forwarded request within {REPLY_DRAIN_BUDGET:?}" + ); Ok(ProxyOutcome::Proxied) } +/// Whether [`half_close_write`] actually makes the daemon's session reader see +/// EOF. True on unix (`shutdown(SHUT_WR)` on the shared socket fd); FALSE on +/// windows, where named pipes have no half-close, so the daemon keeps its +/// session open and the daemon->host pump never reaches EOF on its own. +#[cfg(unix)] +const HALF_CLOSE_EOFS_DAEMON: bool = true; +#[cfg(not(unix))] +const HALF_CLOSE_EOFS_DAEMON: bool = false; + +/// The proxy's platform-independent teardown condition: the set of forwarded +/// request ids the host is still OWED a reply for. +/// +/// The proxy cannot tear down the instant the host's stdin closes — the daemon +/// may still be computing the answer to a `tools/call` already in flight, and +/// dropping it would lose the host's result. The ORIGINAL teardown waited for +/// the daemon->host pump to hit EOF, which is only reachable when the proxy can +/// half-close its write direction and make the daemon close first. That is a +/// UNIX property: windows named pipes have no half-close, so on windows nothing +/// ever ends that pump and the wait was unbounded. +/// +/// A ledger replaces "wait for the peer to close" with "wait until nothing is +/// owed", which holds on every platform: an id is recorded when its request is +/// forwarded and retired when its reply is written to the host, so +/// [`wait_until_settled`](Self::wait_until_settled) returns exactly when the last +/// answer has been delivered. [`abandon`](Self::abandon) settles it when the +/// daemon stream ends (nothing more can arrive), and notifications — which have +/// no id and are never answered — are never recorded. +#[derive(Default)] +struct ReplyLedger { + state: Mutex, + settled: Condvar, +} + +#[derive(Default)] +struct LedgerState { + outstanding: Vec, + abandoned: bool, +} + +impl ReplyLedger { + fn record(&self, id: Value) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.outstanding.push(id); + } + + fn retire(&self, id: &Value) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(at) = state.outstanding.iter().position(|owed| owed == id) { + state.outstanding.remove(at); + } + if state.outstanding.is_empty() { + self.settled.notify_all(); + } + } + + fn abandon(&self) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.abandoned = true; + self.settled.notify_all(); + } + + /// Park until nothing is owed (or the daemon stream ended), bounded by + /// `budget`. `true` means settled; `false` means the budget ran out with + /// replies still owed. + fn wait_until_settled(&self, budget: Duration) -> bool { + let state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (state, _) = self + .settled + .wait_timeout_while(state, budget, |state| { + !state.abandoned && !state.outstanding.is_empty() + }) + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.abandoned || state.outstanding.is_empty() + } +} + /// The write-side fd handle carried between `write_raw_fd` and /// `half_close_write`. On unix it is the real socket `RawFd`; on non-unix there /// is no half-close and the value is always `None`, so a unit placeholder keeps @@ -227,6 +357,7 @@ fn pump_host_to_daemon( host_out: &Arc>, shutdown: &Arc, suppressed_id: &Arc>>, + ledger: &Arc, ) -> Result<()> where R: BufRead, @@ -285,7 +416,12 @@ where } _ => { // Everything else (tools/call, ping, notifications, ...) is - // forwarded verbatim to the daemon. + // forwarded verbatim to the daemon. Only a REQUEST (one carrying + // an `id`) is ever answered, so only that owes the host a reply; + // a notification has no id and is deliberately not recorded. + if let Some(id) = id { + ledger.record(id); + } forward_to_daemon(&mut daemon_send, &line)?; } } @@ -294,13 +430,16 @@ where } /// daemon -> host: forward each daemon line to the host, dropping the response -/// to the suppressed-initialize id. Drains to socket EOF (NOT a `shutdown` -/// flag): the daemon closes the socket only after flushing its last reply, so -/// exiting on EOF alone guarantees the final `tools/call` answer is delivered. +/// to the suppressed-initialize id and retiring each delivered reply from +/// `ledger` — that retirement, not this pump's own EOF, is what releases the +/// teardown, so the final `tools/call` answer is delivered on every platform +/// including ones with no socket half-close. Still drains to EOF (NOT a +/// `shutdown` flag) so a daemon that keeps talking is never cut off mid-reply. fn pump_daemon_to_host( daemon_recv: S, host_out: &Arc>, suppressed_id: &Arc>>, + ledger: &Arc, ) -> Result<()> where S: BufRead, @@ -315,7 +454,9 @@ where continue; } - // Suppress the daemon's reply to the forwarded initialize id. + // Suppress the daemon's reply to the forwarded initialize id. That id is + // answered locally and never recorded as owed, so nothing is retired here. + let mut delivered_id = None; if let Ok(resp) = serde_json::from_str::(&line) { let is_reply = resp.get("result").is_some() || resp.get("error").is_some(); if is_reply { @@ -328,10 +469,16 @@ where { continue; } + delivered_id = resp_id.cloned(); } } write_host_line(host_out, &line)?; + // Retire AFTER the host write: the teardown may proceed the instant the + // ledger settles, so the reply must already be in the host's hands. + if let Some(id) = delivered_id { + ledger.retire(&id); + } } Ok(()) } @@ -610,9 +757,17 @@ mod tests { let host_out = Arc::new(Mutex::new(Vec::::new())); let shutdown = Arc::new(AtomicBool::new(false)); let suppressed: Arc>> = Arc::new(Mutex::new(None)); - - pump_host_to_daemon(host_in, &mut daemon_sink, &host_out, &shutdown, &suppressed) - .expect("pump runs to host_in EOF"); + let ledger = Arc::new(ReplyLedger::default()); + + pump_host_to_daemon( + host_in, + &mut daemon_sink, + &host_out, + &shutdown, + &suppressed, + &ledger, + ) + .expect("pump runs to host_in EOF"); let to_host = String::from_utf8(host_out.lock().unwrap().clone()).unwrap(); assert!(to_host.contains("\"id\":1"), "initialize answered locally"); @@ -634,6 +789,20 @@ mod tests { Some(json!(1)), "the forwarded initialize id is recorded for reply suppression" ); + + // Only the FORWARDED request whose reply must come from the daemon (id 3) + // is owed. The locally answered initialize (id 1, suppressed) and + // tools/list (id 2, not forwarded) are already delivered, so recording + // them would leave the ledger permanently unsettled. + assert!( + !ledger.wait_until_settled(Duration::from_millis(0)), + "the forwarded tools/call still owes the host a reply" + ); + ledger.retire(&json!(3)); + assert!( + ledger.wait_until_settled(Duration::from_millis(0)), + "retiring the only owed id settles the ledger" + ); } #[test] @@ -647,9 +816,17 @@ mod tests { let host_out = Arc::new(Mutex::new(Vec::::new())); let shutdown = Arc::new(AtomicBool::new(true)); let suppressed: Arc>> = Arc::new(Mutex::new(None)); - - pump_host_to_daemon(host_in, &mut daemon_sink, &host_out, &shutdown, &suppressed) - .expect("pump exits promptly on a pre-set shutdown"); + let ledger = Arc::new(ReplyLedger::default()); + + pump_host_to_daemon( + host_in, + &mut daemon_sink, + &host_out, + &shutdown, + &suppressed, + &ledger, + ) + .expect("pump exits promptly on a pre-set shutdown"); assert!( daemon_sink.is_empty(), "no line forwarded once shutdown is set" @@ -667,12 +844,92 @@ mod tests { ); let host_out = Arc::new(Mutex::new(Vec::::new())); let suppressed: Arc>> = Arc::new(Mutex::new(Some(json!(1)))); + let ledger = Arc::new(ReplyLedger::default()); + ledger.record(json!(3)); - pump_daemon_to_host(daemon_recv, &host_out, &suppressed).expect("drains to EOF"); + pump_daemon_to_host(daemon_recv, &host_out, &suppressed, &ledger).expect("drains to EOF"); let to_host = String::from_utf8(host_out.lock().unwrap().clone()).unwrap(); assert!(!to_host.contains("suppressed"), "id 1 reply is dropped"); assert!(to_host.contains("forwarded"), "id 3 reply is delivered"); + assert!( + ledger.wait_until_settled(Duration::from_millis(0)), + "delivering the owed reply retires it from the ledger" + ); + } + + /// The teardown wait must be released by the LEDGER, not by the daemon + /// closing its end: a windows named pipe has no half-close, so the daemon + /// never EOFs while the proxy's recv half holds the pipe open. Waiting on the + /// ledger settles on the last reply DELIVERED, which happens on every + /// platform. + #[test] + fn reply_ledger_settles_on_the_last_delivered_reply_not_on_peer_close() { + let ledger = Arc::new(ReplyLedger::default()); + ledger.record(json!(1)); + ledger.record(json!("two")); + assert!( + !ledger.wait_until_settled(Duration::from_millis(0)), + "two owed replies keep the ledger unsettled" + ); + + let settler = Arc::clone(&ledger); + let parked = thread::spawn(move || { + let start = Instant::now(); + let settled = settler.wait_until_settled(Duration::from_secs(10)); + (settled, start.elapsed()) + }); + + thread::sleep(Duration::from_millis(20)); + ledger.retire(&json!(1)); + ledger.retire(&json!("two")); + + let (settled, elapsed) = parked.join().expect("waiter thread panicked"); + assert!(settled, "the ledger settles once nothing is owed"); + assert!( + elapsed < Duration::from_secs(5), + "settling must wake the waiter at once, not run out the budget; \ + woke after {elapsed:?}" + ); + } + + /// A daemon stream that ends with replies still owed can never answer them, + /// so `abandon` settles the wait immediately instead of burning the budget. + #[test] + fn reply_ledger_abandon_settles_an_unanswerable_wait() { + let ledger = Arc::new(ReplyLedger::default()); + ledger.record(json!(4)); + ledger.abandon(); + let start = Instant::now(); + assert!(ledger.wait_until_settled(Duration::from_secs(10))); + assert!(start.elapsed() < Duration::from_millis(500)); + } + + /// Retiring an id that was never owed (a daemon reply to something the proxy + /// did not forward, e.g. the primed `initialize`) must not underflow the + /// ledger or falsely settle a wait that still owes another reply. + #[test] + fn reply_ledger_ignores_an_unrecorded_retirement() { + let ledger = ReplyLedger::default(); + ledger.record(json!(1)); + ledger.retire(&json!(99)); + assert!( + !ledger.wait_until_settled(Duration::from_millis(0)), + "an unrelated retirement must not settle a ledger that still owes id 1" + ); + ledger.retire(&json!(1)); + assert!(ledger.wait_until_settled(Duration::from_millis(0))); + } + + /// An unsettled ledger must still give up at its budget so teardown is + /// bounded even against a daemon that accepted the connection and stalled. + #[test] + fn reply_ledger_reports_an_unsettled_budget_expiry() { + let ledger = ReplyLedger::default(); + ledger.record(json!(5)); + let start = Instant::now(); + assert!(!ledger.wait_until_settled(Duration::from_millis(40))); + assert!(start.elapsed() >= Duration::from_millis(40)); } #[test] diff --git a/crates/codegraph-daemon/src/session.rs b/crates/codegraph-daemon/src/session.rs index b36b4b0..f4707a5 100644 --- a/crates/codegraph-daemon/src/session.rs +++ b/crates/codegraph-daemon/src/session.rs @@ -11,7 +11,9 @@ use interprocess::local_socket::traits::Stream as _; use interprocess::local_socket::traits::tokio::Stream as _; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tracing::debug; +use crate::control::{ControlAck, ControlFrame, encode_control_line, parse_control_frame}; use crate::transport::{AsyncStream, Stream}; /// Max milliseconds the PROXY waits for the daemon's one-line versioned hello @@ -73,6 +75,11 @@ pub struct SessionRegistry { sessions: Arc>>, next_id: Arc, last_active: Arc>, + /// Cross-platform "stop serving" signal for EVERY data session. A `watch` + /// channel (not a raw-fd shutdown, which exists only on Unix) is what lets the + /// control-frame drain actively close long-lived rmcp sessions on Windows too; + /// its retained value also refuses a session that connects after the signal. + closing: Arc>, } impl std::fmt::Debug for SessionRegistry { @@ -89,6 +96,7 @@ impl Default for SessionRegistry { sessions: Arc::new(Mutex::new(HashMap::new())), next_id: Arc::new(AtomicU64::new(1)), last_active: Arc::new(Mutex::new(Instant::now())), + closing: Arc::new(tokio::sync::watch::channel(false).0), } } } @@ -183,6 +191,41 @@ impl SessionRegistry { .any(|entry| matches!(entry.pid, Some(pid) if is_alive(pid))) } + /// Signal every data session — current and future — to stop serving. Each + /// session races its serve loop against this signal, so its transport is + /// dropped and its socket closed on every platform. Idempotent. + pub(crate) fn close_all_sessions(&self) { + // `send_replace`, not `send`: `send` FAILS (and stores nothing) when no + // receiver exists yet, which would let a session accepted after the signal + // start serving. `send_replace` always updates the retained value. + self.closing.send_replace(true); + #[cfg(unix)] + { + // Additionally force EOF on the recorded recv fds: a session blocked in + // a read that has not yet reached an await point observes the close + // immediately rather than at its next poll. + let ids = self + .sessions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .keys() + .copied() + .collect::>(); + for id in ids { + self.shutdown_session(id); + } + } + } + + /// Whether [`close_all_sessions`](Self::close_all_sessions) has fired. + pub(crate) fn is_closing(&self) -> bool { + *self.closing.borrow() + } + + fn closing_signal(&self) -> tokio::sync::watch::Receiver { + self.closing.subscribe() + } + /// Force a dead session's reader to EOF by half-closing the read direction /// of its socket. The session thread, blocked in the rmcp serve loop, then sees /// EOF, returns, and drops its [`SessionGuard`] (which removes the entry + @@ -249,6 +292,44 @@ impl Drop for SessionGuard { } } +/// One accepted control-frame shutdown, handed from a session task to the accept +/// loop. `ack` is signalled by the accept loop AFTER it has stopped accepting, +/// cancelled its watcher lease loops, drained, and removed its own rendezvous; +/// the session then writes the wire ACK and reports completion through `done` so +/// the loop never tears its runtime down mid-write. +pub(crate) struct ShutdownRequest { + /// Carries the DRAIN RESULT, not merely a wake: `false` means the bounded + /// drain did not complete, and the session must then answer with an UNDRAINED + /// ack so `uninit --force` fails closed instead of deleting children behind a + /// daemon that still holds work. + pub(crate) ack: tokio::sync::oneshot::Sender, + pub(crate) done: tokio::sync::oneshot::Receiver<()>, +} + +/// The session-side capability for the control channel: the FULL project identity +/// a frame must name, plus the sender the accept loop listens on. +#[derive(Clone)] +pub(crate) struct ControlHandle { + identity: Arc, + tx: tokio::sync::mpsc::UnboundedSender, +} + +impl ControlHandle { + pub(crate) fn new( + identity: &str, + tx: tokio::sync::mpsc::UnboundedSender, + ) -> Self { + Self { + identity: Arc::from(identity), + tx, + } + } + + fn authorizes(&self, frame: &ControlFrame) -> bool { + frame.authorizes_shutdown_of(&self.identity) + } +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct DaemonHello<'a> { @@ -276,6 +357,7 @@ pub(crate) async fn serve_session_async( socket_path: String, registry: SessionRegistry, run_mcp: bool, + control: Option, ) -> Result<()> { let guard = registry.start_session(); let session_id = guard.session_id(); @@ -302,12 +384,39 @@ pub(crate) async fn serve_session_async( send.write_all(hello_line.as_bytes()).await?; send.flush().await?; - if !run_mcp { + if !run_mcp && control.is_none() { return Ok(()); } - let (first, recv) = read_first_line_bounded_async(recv, MAX_HELLO_LINE_BYTES + 1).await; + // The first-line read parks until the peer speaks, so it must observe the + // registry's closing signal too — otherwise a connected-but-silent client would + // keep the session count nonzero for the whole drain budget on platforms with + // no raw-fd half-close. + if registry.is_closing() { + return Ok(()); + } + let mut closing = registry.closing_signal(); + // Mark the current value seen so `changed()` awaits the NEXT edge. + let _ = closing.borrow_and_update(); + let (first, recv) = tokio::select! { + read = read_first_line_bounded_async(recv, MAX_HELLO_LINE_BYTES + 1) => read, + _ = closing.changed() => { + debug!("daemon session closed by shutdown drain before its first frame"); + return Ok(()); + } + }; let line = String::from_utf8_lossy(&first); + + // A CONTROL frame never reaches the request executor, so it never takes a + // data-request shared index lease — that is what lets it be answered while + // `uninit --force` holds the namespace's exclusive lease. + if let Some(frame) = parse_control_frame(&line) { + drop(guard); + return serve_control_frame(send, &frame, control.as_ref()).await; + } + if !run_mcp { + return Ok(()); + } let pid = parse_client_hello_line(&line); if let Some(pid) = pid { registry.set_pid(session_id, pid); @@ -319,10 +428,78 @@ pub(crate) async fn serve_session_async( let put_back: Vec = if pid.is_some() { Vec::new() } else { first }; let chained = AsyncReadExt::chain(Cursor::new(put_back), recv); let transport = tokio::io::join(chained, send); - codegraph_mcp::rmcp_session::serve_session_rmcp_async(transport, project_root).await?; + // Race the session against the registry's closing signal so a shutdown + // control frame ACTIVELY ends this session (dropping the transport closes the + // socket) instead of waiting for the client to disconnect. + tokio::select! { + served = codegraph_mcp::rmcp_session::serve_session_rmcp_async(transport, project_root) => { + served?; + } + _ = closing.changed() => { + debug!("daemon session closed by shutdown drain"); + } + } Ok(()) } +/// Answer one control frame on its own connection. +/// +/// An unauthorized frame (foreign protocol version, unknown action, or another +/// project's identity) is refused with an undrained ACK and changes nothing. An +/// authorized frame hands the request to the accept loop, waits for the loop's +/// post-drain signal, writes the ACK, and reports completion. +async fn serve_control_frame( + mut send: crate::transport::AsyncSendHalf, + frame: &ControlFrame, + control: Option<&ControlHandle>, +) -> Result<()> { + let Some(control) = control.filter(|control| control.authorizes(frame)) else { + send.write_all(refusal_line(frame).as_bytes()).await?; + send.flush().await?; + return Ok(()); + }; + + let (ack_tx, ack_rx) = tokio::sync::oneshot::channel(); + let (done_tx, done_rx) = tokio::sync::oneshot::channel(); + if control + .tx + .send(ShutdownRequest { + ack: ack_tx, + done: done_rx, + }) + .is_err() + { + anyhow::bail!("daemon accept loop is gone; shutdown control frame cannot be served"); + } + let drained = ack_rx + .await + .map_err(|_| anyhow::anyhow!("daemon drain ended without acknowledging shutdown"))?; + // An incomplete drain answers with `drained: false`, which the caller treats as + // unresponsive and therefore fail-closed. A success ack is emitted ONLY when + // every session and lease loop actually finished. + send.write_all(shutdown_reply_line(drained).as_bytes()) + .await?; + send.flush().await?; + let _ = done_tx.send(()); + Ok(()) +} + +/// The wire reply for a completed (`true`) or INCOMPLETE (`false`) drain. +fn shutdown_reply_line(drained: bool) -> String { + encode_control_line(&ControlAck::for_drain(drained)) +} + +/// The wire reply for an UNAUTHORIZED control frame: never drained, and the +/// frame's own action is echoed so the caller can see what was refused. Nothing +/// is mutated and no shutdown is scheduled. +fn refusal_line(frame: &ControlFrame) -> String { + encode_control_line(&ControlAck { + codegraph_control: crate::control::CONTROL_PROTOCOL, + action: frame.action.clone(), + drained: false, + }) +} + /// Async analog of [`read_first_line_bounded`]: read from `recv` up to and /// including the first `\n`, or `max` bytes, whichever comes first. Returns the /// raw bytes read plus the (unconsumed) recv half so the caller can chain the @@ -563,6 +740,48 @@ mod tests { drop((no_pid, dead, alive)); } + #[test] + fn shutdown_reply_reports_the_drain_result_and_a_refusal_is_never_drained() { + let completed: ControlAck = + serde_json::from_str(shutdown_reply_line(true).trim()).expect("ack json"); + assert!( + completed.accepted(), + "a completed drain acknowledges success" + ); + + let incomplete: ControlAck = + serde_json::from_str(shutdown_reply_line(false).trim()).expect("ack json"); + assert!( + !incomplete.accepted(), + "an incomplete drain must NOT be acknowledged as success" + ); + + let foreign = ControlFrame::shutdown("another-project"); + let refusal: ControlAck = + serde_json::from_str(refusal_line(&foreign).trim()).expect("refusal json"); + assert!(!refusal.accepted()); + assert_eq!(refusal.action, foreign.action); + } + + #[test] + fn close_all_sessions_is_observable_and_idempotent() { + let registry = SessionRegistry::default(); + assert!(!registry.is_closing()); + let guard = registry.start_session(); + registry.close_all_sessions(); + assert!( + registry.is_closing(), + "the closing signal must be observable by every session" + ); + // Idempotent: a second call is a no-op and a session that connects AFTER + // the signal still observes it. + registry.close_all_sessions(); + assert!(registry.is_closing()); + let late = registry.start_session(); + assert!(registry.is_closing()); + drop((guard, late)); + } + #[test] fn session_registry_millis_since_active_advances_over_time() { let registry = SessionRegistry::default(); diff --git a/crates/codegraph-daemon/src/spawn.rs b/crates/codegraph-daemon/src/spawn.rs index cc13add..ba6c5ff 100644 --- a/crates/codegraph-daemon/src/spawn.rs +++ b/crates/codegraph-daemon/src/spawn.rs @@ -35,6 +35,9 @@ const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; /// file watcher, exactly as if the flag had been inherited — but without any /// global-env mutation in the parent. pub fn spawn_detached_daemon(exe: &Path, root: &Path, no_watch: bool) -> Result<()> { + // Resolved once, fail-closed: the log target must be the project's own v2 + // rendezvous log, never a reconstructed path. + let log_path = daemon_log_path(root)?; let mut command = Command::new(exe); command .arg("serve") @@ -43,8 +46,8 @@ pub fn spawn_detached_daemon(exe: &Path, root: &Path, no_watch: bool) -> Result< .arg(root) .env(CODEGRAPH_DAEMON_INTERNAL, "1") .stdin(Stdio::null()) - .stdout(log_target(root)) - .stderr(log_target(root)); + .stdout(log_target(&log_path)) + .stderr(log_target(&log_path)); if no_watch { command.env(CODEGRAPH_NO_WATCH, "1"); } @@ -60,11 +63,18 @@ pub fn spawn_detached_daemon(exe: &Path, root: &Path, no_watch: bool) -> Result< Ok(()) } -fn log_target(root: &Path) -> Stdio { +/// The rendezvous dir is created by the daemon's lock layer in the CHILD, but +/// this log redirect is set up in the PARENT before spawn, so the parent creates +/// the log's parent directory here — otherwise the redirect silently falls back +/// to a null sink and the child's stderr is lost. +fn log_target(path: &Path) -> Stdio { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } OpenOptions::new() .create(true) .append(true) - .open(daemon_log_path(root)) + .open(path) .map_or_else(|_| Stdio::null(), Stdio::from) } diff --git a/crates/codegraph-daemon/src/transport.rs b/crates/codegraph-daemon/src/transport.rs index 8fef060..e46dd2d 100644 --- a/crates/codegraph-daemon/src/transport.rs +++ b/crates/codegraph-daemon/src/transport.rs @@ -29,7 +29,8 @@ pub use interprocess::local_socket::{SendHalf, Stream}; /// yields `AsyncStream`s whose split halves are `AsyncRead`/`AsyncWrite`, and on /// unix expose `AsFd` for the force-close reap handle. pub use interprocess::local_socket::tokio::{ - Listener as AsyncListener, RecvHalf as AsyncRecvHalf, Stream as AsyncStream, + Listener as AsyncListener, RecvHalf as AsyncRecvHalf, SendHalf as AsyncSendHalf, + Stream as AsyncStream, }; /// Resolved rendezvous address for a project daemon. On unix `socket_path` is diff --git a/crates/codegraph-daemon/tests/async_model.rs b/crates/codegraph-daemon/tests/async_model.rs index a3844a8..30e9d95 100644 --- a/crates/codegraph-daemon/tests/async_model.rs +++ b/crates/codegraph-daemon/tests/async_model.rs @@ -97,7 +97,8 @@ fn handshake(stream: &mut Stream, id: i64) { } fn start_daemon(project: &Path) -> DaemonHandle { - let _ = codegraph_core::config::init_config(None, project); + // No process-global config to initialize: every project-scoped operation + // loads the addressed project's own immutable config on demand. let options = DaemonOptions { host_pid: None, watchdog_interval: Duration::from_millis(10), @@ -360,8 +361,10 @@ fn temp_indexed_project(name: &str) -> PathBuf { "codegraph-daemon-{name}-{}-{nanos}-{seq}", std::process::id() )); - let cg = path.join(".codegraph"); - fs::create_dir_all(&cg).expect("create project .codegraph"); + // Batch M: the served index DB moved to the isolated v2 namespace + // (`.codegraph-v2`); the daemon rendezvous still lives under `.codegraph`. + let cg = path.join(".codegraph-v2"); + fs::create_dir_all(&cg).expect("create project .codegraph-v2"); let root = workspace_root(); fs::copy( root.join("reference/golden/mini/colby.db"), diff --git a/crates/codegraph-daemon/tests/control_shutdown.rs b/crates/codegraph-daemon/tests/control_shutdown.rs new file mode 100644 index 0000000..4b40ecb --- /dev/null +++ b/crates/codegraph-daemon/tests/control_shutdown.rs @@ -0,0 +1,308 @@ +//! Batch M item 20 — the daemon side of the `uninit --force` shutdown control +//! channel. +//! +//! The CLI acceptance tests in `codegraph-rs` drive whole processes. These tests +//! pin the daemon-local guarantees that acceptance cannot observe from outside: +//! +//! 1. an authorized control frame ACTIVELY closes a long-lived data session (the +//! client's socket reaches EOF) and only then acknowledges the drain; +//! 2. an UNAUTHORIZED frame (foreign project identity) is answered +//! `drained: false`, mutates nothing, and leaves the daemon serving; +//! 3. an outstanding session at an exhausted drain budget is answered +//! `drained: false` on the wire — the daemon never reports success it did not +//! achieve; +//! 4. the caller maps an incomplete-drain reply to +//! [`ShutdownOutcome::Unresponsive`] and never signals the recorded pid. +//! +//! (3) is deterministic, not timing-dependent: with a zero drain budget the +//! accept loop checks the outstanding counts in the same poll in which it signals +//! the sessions to close, before any session task can be polled again, so the +//! session is provably still counted. (4) is driven against a stub listener that +//! replies `drained: false`. +//! +//! Unix-gated: the stub rendezvous is bound through `GenericFilePath`. The +//! production code paths under test are platform-independent (the session close +//! signal is a `tokio::sync::watch`, not a raw fd), but this host cannot execute +//! the Windows named-pipe arm, so no Windows runtime claim is made here. + +#![cfg(unix)] + +use std::io::{BufRead, BufReader, Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use codegraph_core::IndexPaths; +use codegraph_daemon::{ + ControlAck, ControlFrame, DaemonHandle, DaemonLockInfo, DaemonOptions, ShutdownOutcome, + StartOrAttach, encode_lock_info, is_process_alive, request_daemon_shutdown, start_or_attach, +}; +use interprocess::local_socket::traits::{ListenerExt as _, Stream as _}; +use interprocess::local_socket::{GenericFilePath, ListenerOptions, Stream, ToFsName}; + +const WAIT: Duration = Duration::from_secs(10); + +fn temp_project(label: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "cg-daemon-control-{label}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock after epoch") + .as_nanos() + )); + std::fs::create_dir_all(&path).expect("create project"); + path.canonicalize().expect("canonicalize project") +} + +fn paths_of(project: &Path) -> IndexPaths { + IndexPaths::resolve(project, None).expect("resolve v2 index paths") +} + +fn options(drain_budget: Duration) -> DaemonOptions { + DaemonOptions { + run_mcp: true, + watch: false, + watchdog_interval: Duration::from_millis(10), + drain_budget, + ..DaemonOptions::default() + } +} + +fn start(project: &Path, drain_budget: Duration) -> DaemonHandle { + match start_or_attach(project, options(drain_budget)).expect("daemon starts") { + StartOrAttach::Started(handle) => handle, + StartOrAttach::Attached(_) => panic!("a fresh project must not attach"), + } +} + +fn connect(socket_path: &Path) -> Stream { + let name = socket_path + .as_os_str() + .to_fs_name::() + .expect("fs name"); + Stream::connect(name).expect("connect to the daemon rendezvous") +} + +/// Connect a data client, consume the daemon hello, and leave the connection +/// open and SILENT — the shape that would keep the session count nonzero forever +/// if shutdown merely waited instead of actively closing sessions. +fn connect_silent_data_client(socket_path: &Path) -> Stream { + let stream = connect(socket_path); + stream + .set_recv_timeout(Some(WAIT)) + .expect("bound the client read"); + let mut reader = BufReader::new(&stream); + let mut hello = String::new(); + reader.read_line(&mut hello).expect("read the daemon hello"); + assert!( + hello.contains("\"protocol\":1"), + "the daemon hello must arrive first: {hello}" + ); + stream +} + +fn send_control_frame(socket_path: &Path, frame: &ControlFrame) -> ControlAck { + let stream = connect(socket_path); + stream + .set_recv_timeout(Some(WAIT)) + .expect("bound the control read"); + stream + .set_send_timeout(Some(WAIT)) + .expect("bound the control write"); + let mut reader = BufReader::new(&stream); + let mut hello = String::new(); + reader.read_line(&mut hello).expect("read the daemon hello"); + (&stream) + .write_all(format!("{}\n", serde_json::to_string(frame).expect("frame json")).as_bytes()) + .expect("send the control frame"); + (&stream).flush().expect("flush the control frame"); + let mut ack = String::new(); + reader.read_line(&mut ack).expect("read the control reply"); + serde_json::from_str(ack.trim()).expect("decode the control reply") +} + +fn wait_finished(handle: &DaemonHandle, label: &str) { + let deadline = Instant::now() + WAIT; + while Instant::now() < deadline { + if handle.is_finished() { + return; + } + std::thread::sleep(Duration::from_millis(10)); + } + panic!("{label} did not finish within {WAIT:?}"); +} + +#[test] +fn authorized_shutdown_closes_a_long_lived_session_before_acknowledging() { + let project = temp_project("closes-session"); + let paths = paths_of(&project); + let handle = start(&project, Duration::from_secs(10)); + let socket = handle.socket_path().to_path_buf(); + + let mut silent = connect_silent_data_client(&socket); + let ack = send_control_frame(&socket, &ControlFrame::shutdown(paths.project_identity())); + assert!( + ack.accepted(), + "an authorized frame must be acknowledged after a completed drain: {ack:?}" + ); + + // The long-lived session was closed by the daemon, not by the client: its + // socket reads EOF. Reverting the active close makes this read time out. + let mut rest = Vec::new(); + silent + .read_to_end(&mut rest) + .expect("the closed session must EOF, not time out"); + assert!( + rest.is_empty(), + "no further bytes are served after shutdown: {rest:?}" + ); + + // The ACK follows rendezvous removal, so both are already gone. + assert!(!paths.daemon_pid().exists(), "the owner record is removed"); + assert!(!socket.exists(), "the bound socket is removed"); + wait_finished(&handle, "the drained daemon"); + handle.wait().expect("the accept loop joins cleanly"); + let _ = std::fs::remove_dir_all(&project); +} + +#[test] +fn an_unauthorized_frame_is_refused_without_shutting_the_daemon_down() { + let project = temp_project("refuses-foreign"); + let paths = paths_of(&project); + let handle = start(&project, Duration::from_secs(10)); + let socket = handle.socket_path().to_path_buf(); + + for frame in [ + ControlFrame::shutdown("0000000000000000000000000000000000000000000000000000000000000000"), + ControlFrame { + codegraph_control: codegraph_daemon::CONTROL_PROTOCOL + 1, + action: "shutdown".to_string(), + project_identity: paths.project_identity().to_string(), + }, + ControlFrame { + codegraph_control: codegraph_daemon::CONTROL_PROTOCOL, + action: "reindex".to_string(), + project_identity: paths.project_identity().to_string(), + }, + ] { + let ack = send_control_frame(&socket, &frame); + assert!( + !ack.accepted(), + "an unauthorized frame must never be acknowledged: {frame:?} -> {ack:?}" + ); + assert!( + paths.daemon_pid().exists() && socket.exists(), + "a refused frame mutates no rendezvous artifact" + ); + assert!(!handle.is_finished(), "a refused frame never stops serving"); + } + + // Still serving: an authorized frame afterwards is what actually drains it. + let ack = send_control_frame(&socket, &ControlFrame::shutdown(paths.project_identity())); + assert!(ack.accepted()); + wait_finished(&handle, "the drained daemon"); + handle.wait().expect("the accept loop joins cleanly"); + let _ = std::fs::remove_dir_all(&project); +} + +#[test] +fn an_exhausted_drain_budget_replies_incomplete_instead_of_success() { + let project = temp_project("budget-exhausted"); + let paths = paths_of(&project); + // A zero budget cannot be met while any session is still outstanding. + let handle = start(&project, Duration::ZERO); + let socket = handle.socket_path().to_path_buf(); + + let _silent = connect_silent_data_client(&socket); + let ack = send_control_frame(&socket, &ControlFrame::shutdown(paths.project_identity())); + assert!( + !ack.drained, + "an unmet drain budget must reply with an INCOMPLETE drain: {ack:?}" + ); + assert!( + !ack.accepted(), + "an incomplete drain must never be accepted as success" + ); + + // The daemon still tears down only what it owns, so a repeated `uninit --force` + // can continue; nothing else was touched. + wait_finished(&handle, "the daemon after an incomplete drain"); + handle.wait().expect("the accept loop joins cleanly"); + assert!(!paths.daemon_pid().exists()); + assert!( + paths.current_root().is_dir(), + "the namespace itself survives" + ); + let _ = std::fs::remove_dir_all(&project); +} + +#[test] +fn an_incomplete_drain_reply_is_unresponsive_and_never_signals_the_owner() { + let project = temp_project("incomplete-drain"); + let paths = paths_of(&project); + std::fs::create_dir_all(paths.current_root()).expect("create the v2 rendezvous dir"); + let socket = paths.current_root().join("daemon.sock"); + + // A stub rendezvous that answers every control frame with an INCOMPLETE drain, + // recorded as owned by THIS live process. + let listener = ListenerOptions::new() + .name( + socket + .as_os_str() + .to_fs_name::() + .expect("fs name"), + ) + .create_sync() + .expect("bind the stub rendezvous"); + let stub = std::thread::spawn(move || { + let Some(Ok(stream)) = listener.incoming().next() else { + return; + }; + let _ = stream.set_send_timeout(Some(WAIT)); + let _ = stream.set_recv_timeout(Some(WAIT)); + let mut reader = BufReader::new(&stream); + let _ = (&stream).write_all(b"{\"codegraph\":\"stub\",\"protocol\":1}\n"); + let _ = (&stream).flush(); + let mut frame = String::new(); + let _ = reader.read_line(&mut frame); + let reply = serde_json::to_string(&ControlAck::for_drain(false)).expect("reply json"); + let _ = (&stream).write_all(format!("{reply}\n").as_bytes()); + let _ = (&stream).flush(); + }); + + let owner = DaemonLockInfo { + pid: std::process::id(), + version: env!("CARGO_PKG_VERSION").to_string(), + socket_path: socket.clone(), + started_at: 1, + }; + std::fs::write( + paths.daemon_pid(), + encode_lock_info(&owner).expect("encode the owner record"), + ) + .expect("write the owner record"); + + let outcome = request_daemon_shutdown(&project, paths.project_identity()) + .expect("an incomplete drain is data, not an error"); + match outcome { + ShutdownOutcome::Unresponsive { pid, detail } => { + assert_eq!(pid, std::process::id()); + assert!( + detail.contains("incomplete drain"), + "the refusal must name the incomplete drain: {detail}" + ); + } + other => panic!("an incomplete drain must be unresponsive, got {other:?}"), + } + assert!( + is_process_alive(std::process::id()), + "the recorded owner is never signalled" + ); + assert!( + paths.daemon_pid().exists(), + "a fail-closed exchange removes no rendezvous artifact" + ); + + let _ = stub.join(); + let _ = std::fs::remove_dir_all(&project); +} diff --git a/crates/codegraph-daemon/tests/lifecycle.rs b/crates/codegraph-daemon/tests/lifecycle.rs index 4b98569..9a4e6ca 100644 --- a/crates/codegraph-daemon/tests/lifecycle.rs +++ b/crates/codegraph-daemon/tests/lifecycle.rs @@ -9,6 +9,10 @@ use codegraph_daemon::{ start_or_attach, unlock_project, }; +fn pid_path_of(project: &Path) -> PathBuf { + daemon_pid_path(project).expect("resolve the v2 rendezvous pid path") +} + #[test] fn second_daemon_attaches_to_existing_project_daemon() { let project = temp_project("single-instance"); @@ -29,7 +33,7 @@ fn second_daemon_attaches_to_existing_project_daemon() { } first.stop().expect("daemon stops"); - assert!(!daemon_pid_path(&project).exists()); + assert!(!pid_path_of(&project).exists()); let _ = fs::remove_dir_all(project); } @@ -68,20 +72,21 @@ fn host_pid_watchdog_stops_daemon_after_parent_agent_exits() { "daemon did not stop after watched pid exited" ); handle.stop().expect("finished daemon joins"); - assert!(!daemon_pid_path(&project).exists()); + assert!(!pid_path_of(&project).exists()); let _ = fs::remove_dir_all(project); } #[test] fn unlock_project_removes_stale_daemon_lock() { let project = temp_project("unlock-stale"); - let codegraph_dir = project.join(".codegraph"); - fs::create_dir_all(&codegraph_dir).expect("create .codegraph"); - let pid_path = daemon_pid_path(&project); + let pid_path = pid_path_of(&project); + fs::create_dir_all(pid_path.parent().expect("rendezvous dir")) + .expect("create the v2 rendezvous dir"); let info = DaemonLockInfo { pid: 999_999_999, version: "test".to_string(), - socket_path: codegraph_dir.join("daemon.sock"), + socket_path: codegraph_daemon::daemon_socket_path(&project) + .expect("resolve the v2 rendezvous socket identity"), started_at: 1, }; fs::write(&pid_path, encode_lock_info(&info).expect("serialize lock")).expect("write lock"); @@ -110,9 +115,12 @@ fn temp_project(name: &str) -> PathBuf { std::process::id() )); create_project(&path); - path + path.canonicalize().expect("canonicalize project") } +/// A real project directory: `IndexPaths` derives the physical project identity +/// from the filesystem object, so the project must exist before any rendezvous +/// path resolves. fn create_project(path: &Path) { - fs::create_dir_all(path.join(".codegraph")).expect("create project"); + fs::create_dir_all(path).expect("create project"); } diff --git a/crates/codegraph-daemon/tests/regressions.rs b/crates/codegraph-daemon/tests/regressions.rs index 6b3f217..3d73f2e 100644 --- a/crates/codegraph-daemon/tests/regressions.rs +++ b/crates/codegraph-daemon/tests/regressions.rs @@ -24,6 +24,10 @@ use codegraph_daemon::{ try_acquire_daemon_lock, }; +fn pid_path_of(project: &Path) -> PathBuf { + daemon_pid_path(project).expect("resolve the v2 rendezvous pid path") +} + // A pid that is not a live process on any sane Unix host; used to forge a // "stale" lock whose owner is provably dead. const DEAD_PID: u32 = 999_999_999; @@ -91,7 +95,7 @@ fn concurrent_acquire_has_exactly_one_process_winner() { // The published pidfile is a full, valid lock record -- never the empty // create_new placeholder a torn write-then-publish would leave behind. - let pid_path = daemon_pid_path(&project); + let pid_path = pid_path_of(&project); let raw = fs::read_to_string(&pid_path).expect("winner published a pidfile"); assert!( !raw.trim().is_empty(), @@ -125,7 +129,7 @@ fn stop_flag_terminates_accept_loop_and_clears_pidfile() { StartOrAttach::Attached(_) => panic!("first start unexpectedly attached"), }; assert!( - daemon_pid_path(&project).exists(), + pid_path_of(&project).exists(), "a started daemon must publish its pidfile" ); assert!( @@ -142,7 +146,7 @@ fn stop_flag_terminates_accept_loop_and_clears_pidfile() { "accept loop must stop within {SHUTDOWN_CEILING:?}, took {elapsed:?}" ); assert!( - !daemon_pid_path(&project).exists(), + !pid_path_of(&project).exists(), "stopping the daemon must clean up its pidfile" ); let _ = fs::remove_dir_all(project); @@ -153,7 +157,7 @@ fn stop_flag_terminates_accept_loop_and_clears_pidfile() { #[test] fn dead_pid_lock_is_recovered_by_start_or_attach() { let project = temp_project("stale-dead-pid"); - let pid_path = daemon_pid_path(&project); + let pid_path = pid_path_of(&project); write_lock(&pid_path, DEAD_PID); let handle = match start_or_attach(&project, test_options()).expect("recovers stale lock") { @@ -177,7 +181,7 @@ fn dead_pid_lock_is_recovered_by_start_or_attach() { #[test] fn clear_stale_daemon_lock_removes_dead_pid_lock() { let project = temp_project("clear-dead-pid"); - let pid_path = daemon_pid_path(&project); + let pid_path = pid_path_of(&project); write_lock(&pid_path, DEAD_PID); assert!( @@ -195,7 +199,7 @@ fn clear_stale_daemon_lock_removes_dead_pid_lock() { #[test] fn empty_pidfile_is_not_deleted_by_reader() { let project = temp_project("empty-pidfile"); - let pid_path = daemon_pid_path(&project); + let pid_path = pid_path_of(&project); // Durable 0-byte placeholder: `fs::write` can return before the directory // entry is flushed, so under load the guard's 20ms-later re-read @@ -320,6 +324,11 @@ fn temp_project(name: &str) -> PathBuf { "codegraph-daemon-reg-{name}-{}-{nanos}-{seq}", std::process::id() )); - fs::create_dir_all(path.join(".codegraph")).expect("create project"); + fs::create_dir_all(&path).expect("create project"); + let path = path.canonicalize().expect("canonicalize project"); + // Tests here write the pid record directly, so materialize the v2 rendezvous + // dir the daemon's lock layer would otherwise create. + fs::create_dir_all(pid_path_of(&path).parent().expect("rendezvous dir")) + .expect("create the v2 rendezvous dir"); path } diff --git a/crates/codegraph-daemon/tests/rmcp_reap.rs b/crates/codegraph-daemon/tests/rmcp_reap.rs index 11c4c65..4d25d0d 100644 --- a/crates/codegraph-daemon/tests/rmcp_reap.rs +++ b/crates/codegraph-daemon/tests/rmcp_reap.rs @@ -148,6 +148,6 @@ fn temp_project(name: &str) -> PathBuf { "codegraph-daemon-{name}-{}-{nanos}-{seq}", std::process::id() )); - fs::create_dir_all(path.join(".codegraph")).expect("create project"); - path + fs::create_dir_all(&path).expect("create project"); + path.canonicalize().expect("canonicalize project") } diff --git a/crates/codegraph-daemon/tests/rmcp_tools_call.rs b/crates/codegraph-daemon/tests/rmcp_tools_call.rs index 460b8cc..4675eae 100644 --- a/crates/codegraph-daemon/tests/rmcp_tools_call.rs +++ b/crates/codegraph-daemon/tests/rmcp_tools_call.rs @@ -91,10 +91,8 @@ fn real_client_tools_call_through_daemon_session_returns_result() { // golden mini db into the temp project's `.codegraph/`. let project = temp_indexed_project("rmcp-toolscall"); - // In-process daemon must replicate the binary's `init_config` startup - // (main.rs) or tool execution panics on the uninitialized global config. - let _ = codegraph_core::config::init_config(None, &project); - + // No process-global config to initialize: tool execution loads the addressed + // project's own config (or its documented defaults) per request. let options = DaemonOptions { host_pid: None, watchdog_interval: Duration::from_millis(10), @@ -174,8 +172,11 @@ fn temp_indexed_project(name: &str) -> PathBuf { "codegraph-daemon-{name}-{}-{nanos}-{seq}", std::process::id() )); - let cg = path.join(".codegraph"); - fs::create_dir_all(&cg).expect("create project .codegraph"); + // Batch M: the served index DB moved to the isolated v2 namespace + // (`.codegraph-v2`), which is what `CodeGraphEngine::open` now reads; the + // daemon rendezvous (pid/socket/log) still lives under `.codegraph`. + let cg = path.join(".codegraph-v2"); + fs::create_dir_all(&cg).expect("create project .codegraph-v2"); let root = workspace_root(); fs::copy( root.join("reference/golden/mini/colby.db"), diff --git a/crates/codegraph-daemon/tests/stale_socket.rs b/crates/codegraph-daemon/tests/stale_socket.rs index 3f64551..4086c65 100644 --- a/crates/codegraph-daemon/tests/stale_socket.rs +++ b/crates/codegraph-daemon/tests/stale_socket.rs @@ -13,6 +13,8 @@ //! 2. The stale-socket self-heal helper clears the leftover `daemon.sock` + //! `daemon.pid` when the recorded pid is dead, so the NEXT startup spawns a //! fresh daemon cleanly. +//! +//! Both operate on the v2 rendezvous identities resolved through `IndexPaths`. #![cfg(unix)] @@ -22,8 +24,8 @@ use std::process; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use codegraph_daemon::{ - DaemonLockInfo, attach_to_daemon, clear_stale_daemon_socket, daemon_pid_path, encode_lock_info, - recorded_socket_path, + DaemonLockInfo, attach_to_daemon, clear_stale_daemon_socket, daemon_pid_path, + daemon_socket_path, encode_lock_info, recorded_socket_path, }; // A pid that is not a live process on any sane Unix host. @@ -39,7 +41,7 @@ const ATTACH_CEILING: Duration = Duration::from_secs(4); #[test] fn attach_to_orphaned_socket_returns_err_within_bound() { let project = temp_project("orphaned-sock"); - let socket_path = project.join(".codegraph/daemon.sock"); + let socket_path = socket_path_of(&project); // Bind a listener to create the .sock file, then DROP it: the filesystem // entry survives but nothing is accepting. A connect either refuses or @@ -72,11 +74,11 @@ fn attach_to_orphaned_socket_returns_err_within_bound() { } /// 1b. A plain FILE at the `.sock` path (not even a socket) must also fail fast -/// — this is the `touch .codegraph/daemon.sock` shape of the leftover. +/// — this is the `touch /daemon.sock` shape of the leftover. #[test] fn attach_to_plain_file_socket_returns_err_within_bound() { let project = temp_project("plain-file-sock"); - let socket_path = project.join(".codegraph/daemon.sock"); + let socket_path = socket_path_of(&project); fs::write(&socket_path, b"not a socket").expect("write plain file at sock path"); let started = Instant::now(); @@ -101,8 +103,8 @@ fn attach_to_plain_file_socket_returns_err_within_bound() { #[test] fn clear_stale_daemon_socket_removes_dead_pid_sock_and_lock() { let project = temp_project("self-heal-dead"); - let pid_path = daemon_pid_path(&project); - let socket_path = project.join(".codegraph/daemon.sock"); + let pid_path = pid_path_of(&project); + let socket_path = socket_path_of(&project); write_lock(&pid_path, DEAD_PID, &socket_path); // Leave an orphaned socket file behind (the crashed daemon's residue). @@ -141,8 +143,8 @@ fn clear_stale_daemon_socket_removes_dead_pid_sock_and_lock() { #[test] fn clear_stale_daemon_socket_preserves_live_pid_lock() { let project = temp_project("self-heal-live"); - let pid_path = daemon_pid_path(&project); - let socket_path = project.join(".codegraph/daemon.sock"); + let pid_path = pid_path_of(&project); + let socket_path = socket_path_of(&project); // Our own pid is alive — a live lock must never be cleared. write_lock(&pid_path, process::id(), &socket_path); @@ -169,9 +171,18 @@ fn clear_stale_daemon_socket_preserves_live_pid_lock() { #[test] fn clear_stale_daemon_socket_removes_recorded_fallback_socket() { let project = temp_project("self-heal-recorded"); - let pid_path = daemon_pid_path(&project); - // Record a DIFFERENT socket path than the default (a fallback-tmpdir shape). - let recorded = project.join(".codegraph/daemon-fallback.sock"); + let pid_path = pid_path_of(&project); + // Record a DIFFERENT socket than the default: the tmpdir shape production + // falls back to when the in-root path exceeds the AF_UNIX limit. Keeping it + // in the tmpdir root is also what keeps THIS path bindable. + let recorded = std::env::temp_dir().join(format!( + "cg-v2-fallback-{}-{}.sock", + process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time") + .as_nanos() + )); write_lock(&pid_path, DEAD_PID, &recorded); { let listener = @@ -179,7 +190,7 @@ fn clear_stale_daemon_socket_removes_recorded_fallback_socket() { drop(listener); } assert_eq!( - recorded_socket_path(&project), + recorded_socket_path(&project).expect("resolve recorded socket"), recorded, "precondition: lock records the fallback socket" ); @@ -189,6 +200,7 @@ fn clear_stale_daemon_socket_removes_recorded_fallback_socket() { assert!(healed, "stale recorded socket + dead pid must heal"); assert!(!recorded.exists(), "the RECORDED socket must be removed"); assert!(!pid_path.exists(), "the stale pid must be removed"); + let _ = fs::remove_file(&recorded); let _ = fs::remove_dir_all(project); } @@ -200,10 +212,13 @@ fn write_lock(pid_path: &Path, pid: u32, socket_path: &Path) { socket_path: socket_path.to_path_buf(), started_at: 1, }; - fs::create_dir_all(pid_path.parent().expect("pid parent")).expect("create .codegraph"); + fs::create_dir_all(pid_path.parent().expect("pid parent")).expect("create the rendezvous dir"); fs::write(pid_path, encode_lock_info(&info).expect("serialize lock")).expect("write lock"); } +/// A real project directory whose v2 rendezvous dir exists. `IndexPaths` derives +/// the physical project identity from the filesystem object, so the project must +/// exist before any rendezvous path resolves. fn temp_project(name: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -213,6 +228,18 @@ fn temp_project(name: &str) -> PathBuf { "codegraph-daemon-stale-{name}-{}-{nanos}", process::id() )); - fs::create_dir_all(path.join(".codegraph")).expect("create project"); + fs::create_dir_all(&path).expect("create project"); + let path = path.canonicalize().expect("canonicalize project"); + let pid_path = pid_path_of(&path); + fs::create_dir_all(pid_path.parent().expect("rendezvous dir")) + .expect("create the v2 rendezvous dir"); path } + +fn pid_path_of(project: &Path) -> PathBuf { + daemon_pid_path(project).expect("resolve the v2 rendezvous pid path") +} + +fn socket_path_of(project: &Path) -> PathBuf { + daemon_socket_path(project).expect("resolve the v2 rendezvous socket identity") +} diff --git a/crates/codegraph-extract/src/embedded/dfm.rs b/crates/codegraph-extract/src/embedded/dfm.rs index 03065cd..1fd2152 100644 --- a/crates/codegraph-extract/src/embedded/dfm.rs +++ b/crates/codegraph-extract/src/embedded/dfm.rs @@ -15,6 +15,17 @@ pub struct DfmExtractor<'a> { source: &'a str, } +/// One entry of the `object … end` stack. +/// +/// `id` is the node id used as the parent of the next `contains` edge. For the +/// synthetic file root `node_index` is `None`; for a real `object`/`inherited`/ +/// `inline` block it is that node's index in `ExtractionResult::nodes`, so the +/// matching `end` line can close the block's span (upstream issue #1350). +struct OpenBlock { + id: String, + node_index: Option, +} + impl<'a> DfmExtractor<'a> { pub fn new(file_path: &'a str, source: &'a str) -> Self { Self { file_path, source } @@ -63,7 +74,10 @@ impl<'a> DfmExtractor<'a> { let multiline_start_re = Regex::new(r"=\s*\(\s*$").unwrap(); let multiline_item_start_re = Regex::new(r"=\s*<\s*$").unwrap(); - let mut stack = vec![file_id.to_string()]; + let mut stack = vec![OpenBlock { + id: file_id.to_string(), + node_index: None, + }]; let mut in_multiline = false; let mut multiline_end = ')'; @@ -103,18 +117,22 @@ impl<'a> DfmExtractor<'a> { ); node.signature = Some(type_name); let node_id = node.id.clone(); + let node_index = result.nodes.len(); result.nodes.push(node); result .edges - .push(contains_edge(stack.last().unwrap(), &node_id)); - stack.push(node_id); + .push(contains_edge(&stack.last().unwrap().id, &node_id)); + stack.push(OpenBlock { + id: node_id, + node_index: Some(node_index), + }); continue; } if let Some(captures) = event_re.captures(line) { let method_name = captures.get(2).unwrap().as_str().to_string(); result.unresolved_references.push(unresolved_ref( - stack.last().unwrap(), + &stack.last().unwrap().id, method_name, EdgeKind::References, line_num, @@ -126,7 +144,10 @@ impl<'a> DfmExtractor<'a> { } if end_re.is_match(line) && stack.len() > 1 { - stack.pop(); + let closed = stack.pop().unwrap(); + if let Some(index) = closed.node_index { + result.nodes[index].end_line = line_num; + } } } } diff --git a/crates/codegraph-extract/src/embedded/mybatis.rs b/crates/codegraph-extract/src/embedded/mybatis.rs index a5fe067..8911759 100644 --- a/crates/codegraph-extract/src/embedded/mybatis.rs +++ b/crates/codegraph-extract/src/embedded/mybatis.rs @@ -35,19 +35,28 @@ impl<'a> MyBatisExtractor<'a> { result } + /// Locates the mapper root element and its namespace. + /// + /// Both dialect forms are accepted: the MyBatis `` + /// root and the older iBatis 2 `` root (upstream + /// #1182). `\b` keeps `` — the iBatis *config* file, which + /// holds no statements — from being mistaken for a statement map. The + /// closing tag is derived from whichever root matched, so the body window + /// ends at the real root close instead of a hard-coded ``. fn find_mapper_root(&self) -> Option<(String, usize, usize)> { - let open_re = Regex::new(r#"]*)>"#).unwrap(); + let open_re = Regex::new(r#"<(mapper|sqlMap)\b([^>]*)>"#).unwrap(); let ns_re = Regex::new(r#"\bnamespace\s*=\s*"([^"]+)""#).unwrap(); - let open = open_re.find(self.source)?; - let attrs = open_re - .captures(open.as_str()) - .and_then(|cap| cap.get(1).map(|m| m.as_str().to_string()))?; + let open = open_re.captures(self.source)?; + let matched = open.get(0)?; + let root_tag = open.get(1)?.as_str(); + let attrs = open.get(2).map_or("", |m| m.as_str()); let namespace = ns_re - .captures(&attrs) + .captures(attrs) .and_then(|cap| cap.get(1).map(|m| m.as_str().to_string()))?; - let body_start = open.end(); + let body_start = matched.end(); + let close_tag = format!(""); let body_end = self.source[body_start..] - .find("") + .find(&close_tag) .map_or(self.source.len(), |idx| body_start + idx); Some((namespace, body_start, body_end)) } @@ -132,11 +141,7 @@ impl<'a> MyBatisExtractor<'a> { for cap in include_re.captures_iter(elem_body) { let full = cap.get(0).unwrap(); let refid = cap.get(1).unwrap().as_str(); - let ref_qualified = if refid.contains('.') { - refid.replace('.', "::") - } else { - format!("{namespace}::{refid}") - }; + let ref_qualified = qualify_refid(namespace, refid); let line = line_number_for_offset(&self.line_starts, elem_body_abs + full.start()); result.unresolved_references.push(unresolved_ref( node_id, @@ -151,6 +156,25 @@ impl<'a> MyBatisExtractor<'a> { } } +/// Builds the `{namespace}::{id}` reference name a `` fragment node +/// carries, from an `` value (upstream #1209). +/// +/// A bare `refid` is local to the enclosing mapper. A QUALIFIED `refid` names +/// the owning namespace, and only its LAST dotted segment is the fragment id — +/// the rest is the namespace, whose own dots are preserved, because that is +/// exactly the `qualified_name` the fragment node stores +/// (`com.example.UserMapper` + `::` + `baseColumns`). Rewriting every dot to +/// `::` would produce a name no node carries, and dropping the namespace would +/// let a same-`id` fragment in another namespace answer the include. +fn qualify_refid(namespace: &str, refid: &str) -> String { + match refid.rsplit_once('.') { + Some((owner, fragment)) if !owner.is_empty() && !fragment.is_empty() => { + format!("{owner}::{fragment}") + } + _ => format!("{namespace}::{refid}"), + } +} + fn build_signature(elem_type: &str, attrs: &str) -> String { if elem_type == "sql" { return "".to_string(); diff --git a/crates/codegraph-extract/src/engine.rs b/crates/codegraph-extract/src/engine.rs index 3a8729b..b0aba8d 100644 --- a/crates/codegraph-extract/src/engine.rs +++ b/crates/codegraph-extract/src/engine.rs @@ -5,7 +5,7 @@ //! `tree-sitter.ts:4350-4425` maps to source dispatch. use anyhow::{Context, Result}; -use codegraph_core::config::IndexingConfig; +use codegraph_core::config::{Config, IndexingConfig}; use codegraph_core::node_id::hash_content; use codegraph_core::types::{ExtractionResult, Language}; use rayon::prelude::*; @@ -13,10 +13,11 @@ use regex::Regex; use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; -use std::sync::OnceLock; +use std::sync::{Arc, OnceLock}; use std::time::Instant; use tree_sitter::Parser; +use crate::ext_config::ExtensionOverrides; use crate::lang::spec_for_language; use crate::walker::TreeSitterWalker; @@ -28,6 +29,12 @@ pub struct ExtractOptions { pub exclude: Vec, pub include: Vec, pub parallel: bool, + /// The addressed project's custom extension→language overrides, loaded from + /// ITS current-root `codegraph.json` ([`ExtensionOverrides::load_for_paths`]). + /// Empty by default, so a project that declares none behaves exactly as + /// before. Passed explicitly instead of discovered, so two projects handled + /// by one process can never see each other's overrides. + pub extensions: Arc, } impl Default for ExtractOptions { @@ -40,6 +47,25 @@ impl Default for ExtractOptions { exclude: indexing.exclude, include: indexing.include, parallel: true, + extensions: ExtensionOverrides::empty(), + } + } +} + +impl ExtractOptions { + /// Build the scan/extract options for ONE project from its own immutable + /// [`Config`] and extension overrides. This is the only shape production + /// callers use; nothing here consults a process-global value. + #[must_use] + pub fn for_project(config: &Config, extensions: Arc) -> Self { + Self { + max_file_size: config.indexing.max_file_size, + ignore_dirs: config.indexing.ignore_dirs.clone(), + ignore_paths: config.indexing.ignore_paths.clone(), + exclude: config.indexing.exclude.clone(), + include: config.indexing.include.clone(), + parallel: true, + extensions, } } } @@ -96,7 +122,25 @@ pub fn builtin_language_for_ext(ext: &str) -> Option { Some(language) } +/// Built-in language detection with NO project extension overrides. +/// +/// Use this only where no project is addressed (a bare path classification). +/// Every pipeline path that indexes or syncs a project calls +/// [`detect_language_with`] with that project's own overrides. pub fn detect_language(file_path: impl AsRef) -> Language { + detect_language_with(file_path, &ExtensionOverrides::default()) +} + +/// Detect `file_path`'s language, consulting `overrides` for extensions the +/// built-in table and the embedded pre-pass leave unclaimed. +/// +/// Golden safety: the override is consulted ONLY for extensions unclaimed by +/// both the built-in match and the embedded pre-pass (both checked first), so +/// empty overrides are byte-identical to the pre-override behavior. +pub fn detect_language_with( + file_path: impl AsRef, + overrides: &ExtensionOverrides, +) -> Language { let path = file_path.as_ref(); let normalized = normalize_path(path); if let Some(language) = crate::embedded::detect_embedded_language(&normalized) { @@ -114,10 +158,7 @@ pub fn detect_language(file_path: impl AsRef) -> Language { if let Some(language) = builtin_language_for_ext(&ext) { return language; } - // Golden safety: the override is consulted ONLY for extensions unclaimed by - // both the built-in match and the embedded pre-pass (both already checked - // above). Absent codegraph.json => no override => exact current behavior. - if let Some(language) = crate::ext_config::override_language_for(path, &ext) { + if let Some(language) = overrides.language_for(&ext) { return language; } Language::Unknown @@ -156,13 +197,27 @@ fn prefix_8k(source: &str) -> &str { } } +/// Extract `source` with NO project extension overrides. Equivalent to +/// [`extract_source_with`] with empty overrides; used where `language` is already +/// known (embedded delegation) or no project is addressed. pub fn extract_source( file_path: &str, source: &str, language: Option, +) -> ExtractionResult { + extract_source_with(file_path, source, language, &ExtensionOverrides::default()) +} + +/// Extract `source`, resolving an absent `language` through the addressed +/// project's own extension `overrides`. +pub fn extract_source_with( + file_path: &str, + source: &str, + language: Option, + overrides: &ExtensionOverrides, ) -> ExtractionResult { let start = Instant::now(); - let mut language = language.unwrap_or_else(|| detect_language(file_path)); + let mut language = language.unwrap_or_else(|| detect_language_with(file_path, overrides)); if language == Language::C && Path::new(file_path) .extension() @@ -235,16 +290,30 @@ pub fn extract_source( .extract(start.elapsed().as_millis() as i64) } +/// Extract one file under the DEFAULT options (no project config). Kept for +/// callers that address no project; project pipelines use +/// [`extract_file_with_options`] so the addressed project's `max_file_size` and +/// extension overrides apply. pub fn extract_file( root: impl AsRef, relative_path: impl AsRef, +) -> Result { + extract_file_with_options(root, relative_path, &ExtractOptions::default()) +} + +/// Extract one file under ONE project's own `options` (size limit + extension +/// overrides), so a per-project `max_file_size` and custom extension map apply +/// to the incremental path exactly as they do to a full scan. +pub fn extract_file_with_options( + root: impl AsRef, + relative_path: impl AsRef, + options: &ExtractOptions, ) -> Result { let root = root.as_ref(); let relative_path = normalize_path(relative_path.as_ref()); let full_path = root.join(&relative_path); let metadata = fs::metadata(&full_path) .with_context(|| format!("stat source file {}", full_path.display()))?; - let options = ExtractOptions::default(); if metadata.len() > options.max_file_size { return Ok(size_skip_result( &relative_path, @@ -255,7 +324,12 @@ pub fn extract_file( let source = fs::read_to_string(&full_path) .with_context(|| format!("read source file {}", full_path.display()))?; let _content_hash = hash_content(&source); - Ok(extract_source(&relative_path, &source, None)) + Ok(extract_source_with( + &relative_path, + &source, + None, + &options.extensions, + )) } pub fn extract_project( @@ -278,7 +352,12 @@ pub fn extract_project( let source = fs::read_to_string(&full) .with_context(|| format!("read source file {}", full.display()))?; let _content_hash = hash_content(&source); - Ok(extract_source(relative, &source, None)) + Ok(extract_source_with( + relative, + &source, + None, + &options.extensions, + )) }; let mut results = if options.parallel { @@ -301,24 +380,39 @@ pub fn scan_project(root: &Path, options: &ExtractOptions) -> Result // later `!pattern` negation re-includes a path an earlier set excluded. let pattern_sets: Vec<&[String]> = vec![&options.ignore_paths, &options.exclude, &gitignore]; let include = IncludeSet::new(&options.include, &options.exclude); + // The EXACT resolved reserved index-root PATHS for THIS project (fixed + // legacy `.codegraph`, the configured legacy root, and the resolved current + // root — including nested configured roots at their true depth), resolved + // once. scan_dir prunes a directory iff its FULL path equals one of these, + // never by basename, so a user `.codegraph-sources/` stays scannable and an + // unrelated same-basename directory is never mis-excluded. + let reserved_roots = codegraph_core::IndexPaths::reserved_index_roots( + root, + std::env::var("CODEGRAPH_DIR").ok().as_deref(), + ); scan_dir( root, root, &ignored_dirs, + &reserved_roots, &pattern_sets, &include, + &options.extensions, &mut files, )?; files.sort(); Ok(files) } +#[allow(clippy::too_many_arguments)] fn scan_dir( root: &Path, dir: &Path, ignored_dirs: &HashSet<&str>, + reserved_roots: &std::collections::BTreeSet, pattern_sets: &[&[String]], include: &IncludeSet, + overrides: &ExtensionOverrides, files: &mut Vec, ) -> Result<()> { let entries = fs::read_dir(dir).with_context(|| format!("read dir {}", dir.display()))?; @@ -327,7 +421,13 @@ fn scan_dir( let path = entry.path(); let name = entry.file_name(); let name = name.to_string_lossy(); - if name == ".git" || name == ".codegraph" || ignored_dirs.contains(name.as_ref()) { + // Skip `.git` (a direct child of the scan root) and any directory whose + // FULL path is a resolved reserved index root — matched at any depth, so + // a nested configured root like `/cache/index` is pruned exactly, + // while a same-basename user directory elsewhere is not. + let is_reserved_root_here = + (dir == root && name == ".git") || reserved_roots.contains(&path); + if is_reserved_root_here || ignored_dirs.contains(name.as_ref()) { continue; } let relative = normalize_path(path.strip_prefix(root).unwrap_or(&path)); @@ -341,8 +441,17 @@ fn scan_dir( if ignored && !include.wants_descend(&relative) { continue; } - scan_dir(root, &path, ignored_dirs, pattern_sets, include, files)?; - } else if file_type.is_file() && is_extractable_source_path(&relative) { + scan_dir( + root, + &path, + ignored_dirs, + reserved_roots, + pattern_sets, + include, + overrides, + files, + )?; + } else if file_type.is_file() && is_extractable_source_path(&relative, overrides) { // Post-model include decision: a model-ignored file is force-included // iff it matches `include` and is NOT overridden by an explicit // `exclude` (checked inside `IncludeSet::forces`). Built-in dir skips @@ -540,8 +649,8 @@ fn include_file_matches(relative: &str, pattern: &str) -> bool { pattern_matches(relative, pattern) } -fn is_extractable_source_path(relative: &str) -> bool { - let language = detect_language(relative); +fn is_extractable_source_path(relative: &str, overrides: &ExtensionOverrides) -> bool { + let language = detect_language_with(relative, overrides); language != Language::Unknown && (crate::lang::spec_for_language(language).is_some() || crate::embedded::is_embedded_source_path(relative) @@ -593,6 +702,76 @@ mod tests { fs::write(&path, contents).expect("write file"); } + /// The scanner excludes ONLY the exact reserved index roots (the fixed + /// legacy `.codegraph` and the resolved current `.codegraph-v2`), never an + /// arbitrary `.codegraph-*`-prefixed user directory. A first-party source + /// dir named `.codegraph-sources` must still be indexed. + #[test] + fn scan_keeps_user_codegraph_prefixed_dir_but_excludes_reserved_roots() { + let project = unique_project("reserved_roots"); + touch( + &project, + ".codegraph-sources/kept.ts", + "export const a = 1;", + ); + touch(&project, ".codegraph/codegraph.db", "reserved"); + touch(&project, ".codegraph-v2/codegraph.db", "reserved"); + touch(&project, "src/app.ts", "export const b = 2;"); + + let files = scan_project(&project, &ExtractOptions::default()).expect("scan project"); + + assert!( + files.contains(&".codegraph-sources/kept.ts".to_string()), + "a user `.codegraph-sources` dir must stay scannable: {files:?}" + ); + assert!( + files.contains(&"src/app.ts".to_string()), + "ordinary source must be indexed: {files:?}" + ); + assert!( + !files.iter().any(|f| f.starts_with(".codegraph/")), + "the fixed legacy `.codegraph` root must be excluded: {files:?}" + ); + assert!( + !files.iter().any(|f| f.starts_with(".codegraph-v2/")), + "the resolved current `.codegraph-v2` root must be excluded: {files:?}" + ); + + fs::remove_dir_all(&project).ok(); + } + + /// Exclusion is by EXACT resolved root PATH, not basename: a directory that + /// merely SHARES the reserved basename but lives at a different path (nested + /// under a source subtree) is NOT a resolved root and stays scannable. + #[test] + fn scan_excludes_only_top_level_root_paths_not_same_named_nested_dirs() { + let project = unique_project("reserved_nested"); + // A `.ts` under the real top-level reserved current root is excluded… + touch(&project, ".codegraph-v2/inner.ts", "export const r = 0;"); + // …but a `.codegraph-v2`-named directory NESTED under `src/` is a user + // path (not the resolved current root) and must be indexed. + touch( + &project, + "src/.codegraph-v2/nested.ts", + "export const a = 1;", + ); + touch(&project, "src/app.ts", "export const b = 2;"); + + let files = scan_project(&project, &ExtractOptions::default()).expect("scan project"); + + assert!( + files.contains(&"src/.codegraph-v2/nested.ts".to_string()), + "a same-basename dir nested off the root is NOT the reserved root: {files:?}" + ); + assert!(files.contains(&"src/app.ts".to_string()), "{files:?}"); + assert!( + !files.iter().any(|f| f.starts_with(".codegraph-v2/")), + "the top-level `.codegraph-v2` root is still excluded: {files:?}" + ); + + fs::remove_dir_all(&project).ok(); + } + /// A real directory scan of a Godot project skips the regenerated `.godot/` /// engine cache and the vendored `addons/` plugin tree while still finding /// first-party `.gd` business code. @@ -884,7 +1063,10 @@ mod tests { Language::Unknown ); assert!( - !is_extractable_source_path("Scripts/effect_manager.gd.uid"), + !is_extractable_source_path( + "Scripts/effect_manager.gd.uid", + &ExtensionOverrides::default() + ), ".gd.uid sidecar must never be scanned into a file record" ); } diff --git a/crates/codegraph-extract/src/ext_config.rs b/crates/codegraph-extract/src/ext_config.rs index 6d13e2e..707ab25 100644 --- a/crates/codegraph-extract/src/ext_config.rs +++ b/crates/codegraph-extract/src/ext_config.rs @@ -1,134 +1,118 @@ -//! Reader for `.codegraph/codegraph.json` custom extension overrides: +//! Reader for the PROJECT-SCOPED `codegraph.json` extension overrides: //! `{"extensions": {".x": "lua"}}`. Keys normalized (dot stripped, lowercased); -//! languages parse via `Language` serde names (unknown skipped). The parsed map -//! is mtime-cached per config-file path; "absent" is cached too, so a project -//! with no codegraph.json pays no repeated I/O. +//! languages parse via `Language` serde names (unknown skipped). +//! +//! The file read is ALWAYS the caller-supplied current-root +//! [`IndexPaths::extension_config`](codegraph_core::IndexPaths::extension_config) +//! path: nothing here walks the directory tree, consults the process working +//! directory, or reads a legacy `.codegraph/codegraph.json`. The parsed result is +//! an immutable [`ExtensionOverrides`] value that the caller threads through +//! [`crate::ExtractOptions`], so two projects served by ONE process cannot see +//! each other's overrides and no cache can go stale. +//! +//! Absence and malformed content stay tolerant (empty overrides, logged), which +//! is the documented opt-in contract: a project with no `codegraph.json` behaves +//! byte-identically to a build without the feature. +use codegraph_core::IndexPaths; use codegraph_core::types::Language; use serde::Deserialize; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex, OnceLock}; -use std::time::SystemTime; +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::Arc; #[derive(Debug, Deserialize)] struct CodegraphJson { #[serde(default)] - extensions: HashMap, + extensions: BTreeMap, } -type ExtMap = HashMap; - -#[derive(Clone)] -enum CacheEntry { - Absent, - Present { mtime: SystemTime, map: Arc }, -} - -fn cache() -> &'static Mutex> { - static CACHE: OnceLock>> = OnceLock::new(); - CACHE.get_or_init(|| Mutex::new(HashMap::new())) -} - -/// Override language for `ext` (lowercased, no dot), resolving the nearest -/// `.codegraph/codegraph.json` walking up from `path`. `None` when no config is -/// reachable, parse failed, or the extension is unmapped. -pub fn override_language_for(path: &Path, ext: &str) -> Option { - let config_path = find_config_path(path)?; - let map = load_cached(&config_path)?; - map.get(ext).copied() +/// One project's parsed extension overrides: lowercased dot-free extension → +/// language. Empty when the project declares none. +/// +/// Deterministic: the backing map is ordered, and the value is immutable once +/// built, so every consumer of the same `Arc` observes identical bytes. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct ExtensionOverrides { + map: BTreeMap, } -fn find_config_path(file_path: &Path) -> Option { - let start = if file_path.is_absolute() { - file_path.parent().map(Path::to_path_buf) - } else { - std::env::current_dir() - .ok() - .map(|cwd| cwd.join(file_path)) - .and_then(|abs| abs.parent().map(Path::to_path_buf)) - }?; - let mut dir: Option<&Path> = Some(start.as_path()); - while let Some(current) = dir { - let candidate = current.join(".codegraph").join("codegraph.json"); - if candidate.is_file() { - return Some(candidate); - } - dir = current.parent(); +impl ExtensionOverrides { + /// No overrides — the zero-config default. + #[must_use] + pub fn empty() -> Arc { + Arc::new(Self::default()) } - None -} - -fn load_cached(config_path: &Path) -> Option> { - let current_mtime = std::fs::metadata(config_path) - .and_then(|m| m.modified()) - .ok(); - let mut guard = cache().lock().unwrap_or_else(|p| p.into_inner()); - if let Some(entry) = guard.get(config_path) { - match (entry, current_mtime) { - (CacheEntry::Present { mtime, map }, Some(now)) if *mtime == now => { - return Some(Arc::clone(map)); - } - (CacheEntry::Absent, None) => return None, - _ => {} - } + /// Load the overrides declared by ONE project's current index root. + /// + /// Reads exactly `paths.extension_config()`. A missing file yields empty + /// overrides; an unreadable or malformed file is tolerated as empty and + /// logged, matching the opt-in contract. + #[must_use] + pub fn load_for_paths(paths: &IndexPaths) -> Arc { + Self::load_from_file(&paths.extension_config()) } - let Some(mtime) = current_mtime else { - guard.insert(config_path.to_path_buf(), CacheEntry::Absent); - return None; - }; - - let map = Arc::new(parse_config(config_path)); - guard.insert( - config_path.to_path_buf(), - CacheEntry::Present { - mtime, - map: Arc::clone(&map), - }, - ); - Some(map) -} - -fn parse_config(config_path: &Path) -> ExtMap { - let Ok(contents) = std::fs::read_to_string(config_path) else { - return ExtMap::new(); - }; - let parsed: CodegraphJson = match serde_json::from_str(&contents) { - Ok(parsed) => parsed, - Err(error) => { - tracing::warn!( - target: "codegraph_extract::ext_config", - path = %config_path.display(), - %error, - "ignoring malformed .codegraph/codegraph.json" - ); - return ExtMap::new(); - } - }; + /// Load overrides from an explicit `codegraph.json` path. Exposed so a + /// caller that already resolved the file (or a test fixture) can supply it + /// directly; the tolerance rules are identical to [`Self::load_for_paths`]. + #[must_use] + pub fn load_from_file(config_path: &Path) -> Arc { + let Ok(contents) = std::fs::read_to_string(config_path) else { + return Self::empty(); + }; + Arc::new(Self::parse(&contents, config_path)) + } - let mut map = ExtMap::new(); - for (raw_ext, raw_lang) in parsed.extensions { - let ext = normalize_ext(&raw_ext); - if ext.is_empty() { - continue; - } - match parse_language(&raw_lang) { - Some(language) => { - map.insert(ext, language); - } - None => { + fn parse(contents: &str, config_path: &Path) -> Self { + let parsed: CodegraphJson = match serde_json::from_str(contents) { + Ok(parsed) => parsed, + Err(error) => { tracing::warn!( target: "codegraph_extract::ext_config", - extension = %raw_ext, - language = %raw_lang, - "ignoring unknown language in codegraph.json extensions" + path = %config_path.display(), + %error, + "ignoring malformed codegraph.json" ); + return Self::default(); + } + }; + + let mut map = BTreeMap::new(); + for (raw_ext, raw_lang) in parsed.extensions { + let ext = normalize_ext(&raw_ext); + if ext.is_empty() { + continue; + } + match parse_language(&raw_lang) { + Some(language) => { + map.insert(ext, language); + } + None => { + tracing::warn!( + target: "codegraph_extract::ext_config", + extension = %raw_ext, + language = %raw_lang, + "ignoring unknown language in codegraph.json extensions" + ); + } } } + Self { map } + } + + /// `true` when the project declared no usable override. + #[must_use] + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } + + /// The override language for `ext` (lowercased, no leading dot), if any. + #[must_use] + pub fn language_for(&self, ext: &str) -> Option { + self.map.get(ext).copied() } - map } fn normalize_ext(raw: &str) -> String { @@ -143,3 +127,73 @@ fn parse_language(raw: &str) -> Option { } Some(language) } + +#[cfg(test)] +mod tests { + use super::*; + + fn overrides(json: &str) -> ExtensionOverrides { + ExtensionOverrides::parse(json, Path::new("codegraph.json")) + } + + #[test] + fn parses_normalized_keys_and_known_languages() { + let parsed = overrides(r#"{"extensions":{".MyExt":"lua","X":"go"}}"#); + assert_eq!(parsed.language_for("myext"), Some(Language::Lua)); + assert_eq!(parsed.language_for("x"), Some(Language::Go)); + assert!(!parsed.is_empty()); + } + + #[test] + fn skips_unknown_languages_and_empty_keys() { + let parsed = overrides(r#"{"extensions":{".foo":"klingon",".":"lua"," ":"go"}}"#); + assert_eq!(parsed.language_for("foo"), None); + assert!(parsed.is_empty()); + } + + #[test] + fn malformed_json_yields_empty_overrides() { + assert!(overrides("{ not json ").is_empty()); + } + + #[test] + fn missing_file_yields_empty_overrides() { + let missing = std::env::temp_dir().join(format!( + "codegraph-ext-missing-{}-{}/codegraph.json", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + assert!(ExtensionOverrides::load_from_file(&missing).is_empty()); + } + + /// The reader consults the caller's current-root path only: a legacy + /// `.codegraph/codegraph.json` next to it is never adopted. + #[test] + fn load_for_paths_reads_only_the_current_root_config() { + let project = std::env::temp_dir().join(format!( + "codegraph-ext-scoped-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(project.join(".codegraph")).unwrap(); + std::fs::write( + project.join(".codegraph/codegraph.json"), + r#"{"extensions":{".zz":"go"}}"#, + ) + .unwrap(); + let paths = IndexPaths::resolve(&project, None).expect("resolve paths"); + std::fs::create_dir_all(paths.current_root()).unwrap(); + std::fs::write(paths.extension_config(), r#"{"extensions":{".zz":"lua"}}"#).unwrap(); + + let loaded = ExtensionOverrides::load_for_paths(&paths); + assert_eq!(loaded.language_for("zz"), Some(Language::Lua)); + + let _ = std::fs::remove_dir_all(&project); + } +} diff --git a/crates/codegraph-extract/src/lang/c.rs b/crates/codegraph-extract/src/lang/c.rs index e4e16d1..fe40c52 100644 --- a/crates/codegraph-extract/src/lang/c.rs +++ b/crates/codegraph-extract/src/lang/c.rs @@ -1,6 +1,10 @@ //! C `LanguageSpec`, ported from `upstream extraction/languages/c-cpp.ts:98-142`. +use std::collections::BTreeSet; +use std::sync::OnceLock; + use codegraph_core::types::{Language, NodeKind}; +use regex::Regex; use tree_sitter::{Language as TsLanguage, Node}; use crate::spec::{ImportInfo, LanguageSpec}; @@ -80,12 +84,152 @@ impl LanguageSpec for CSpec { include_import(node, source) } fn pre_parse(&self, source: &str, _file_path: &str) -> String { - if crate::lang::cpp::looks_like_cuda_source(source) { - crate::lang::cpp::blank_cuda_constructs_str(source) + let blanked = blank_c_leading_attr_macros(source); + if crate::lang::cpp::looks_like_cuda_source(&blanked) { + crate::lang::cpp::blank_cuda_constructs_str(&blanked) } else { - source.to_string() + blanked + } + } +} + +/// Blank an attribute macro sitting in front of a C function definition's return +/// type: `SEC_ATTR UINT32 LostName(VOID) { … }` where `SEC_ATTR` is a macro +/// wrapping `__attribute__((…))` (common in embedded/kernel/firmware C). +/// tree-sitter's C grammar reads the macro as the declaration's type and the real +/// return type as the declarator, so the function indexes under the RETURN TYPE's +/// name (`UINT32`) or, in other spacings, under its parameter list — the real name +/// is lost and the symbol is unfindable (#1311). +/// +/// # Why the structural heuristic alone is WRONG +/// +/// The obvious rule — "line-leading ALL-CAPS token, then two identifiers, then +/// `(`" — is purely lexical, and the shape `MACRO Ret name(` is INDISTINGUISHABLE +/// from `Ret CALLCONV name(` and from `KEYWORD_ALIAS Ret name(`. Measured on real +/// firmware-flavoured C, the permissive form DAMAGED correct extraction: +/// +/// | source | correct | permissive rule | +/// | -------------------------------------- | ------------ | --------------- | +/// | `EFI_STATUS EFIAPI DriverEntry (VOID)` | `EFI_STATUS` | `EFIAPI` ✗ | +/// | `STATIC void helper (void)` | `STATIC` | dropped ✗ | +/// | `CONST CHAR8 *GetName (VOID)` | `CONST` | `CHAR8` | +/// +/// `EFI_STATUS` / `UINT32` / `CHAR8` are typedef'd RETURN TYPES and `STATIC` / +/// `EXTERN` / `INLINE` / `CONST` are macro aliases for keywords; none is an +/// attribute macro, yet all are all-caps identifiers of ≥3 chars in declaration +/// position. No all-caps-plus-length heuristic can separate them, so the token's +/// spelling is not admissible evidence. +/// +/// # The rule actually applied +/// +/// Blank ONLY when the SAME translation unit proves the token is attribute-like: +/// it has an object-like `#define TOKEN …` in this file whose replacement text is +/// either EMPTY or contains an attribute construct (`__attribute__`, `__attribute`, +/// `__declspec`, `__asm`, `__pragma`, `_Pragma`). Every other leading +/// token — typedef'd return type, keyword alias, calling convention, or a macro +/// defined in a header this pass cannot see — is left untouched, so the parse is +/// byte-identical to not having this pass at all. +/// +/// Function-like `#define TOKEN(x) …` is deliberately NOT accepted: those are used +/// as `TOKEN(arg) …`, not as a bare leading token, and the `(`-suffixed name would +/// be a different lexeme anyway. +/// +/// # Documented limitation +/// +/// `pre_parse` sees one file, so a macro `#define SEC_ATTR __attribute__((…))` +/// living in a HEADER and used in a `.c` yields NO evidence and the source is left +/// untouched — #1311's symptom persists for that (very common) layout. That is +/// deliberate: a cross-file macro table would be non-deterministic with respect to +/// include order, and guessing from spelling is what produced the regression +/// above. Under-fixing is recoverable; corrupting ordinary C is not. +/// +/// Blanking replaces the macro with equal-length ASCII spaces, so every byte +/// offset — and therefore every line/column and every node ID — is preserved, +/// exactly like the C++ blanks in `lang/cpp.rs`. +fn blank_c_leading_attr_macros(source: &str) -> String { + let attr_macros = attribute_like_defines(source); + if attr_macros.is_empty() { + return source.to_string(); + } + static RE: OnceLock = OnceLock::new(); + let re = RE.get_or_init(|| { + Regex::new(r"(?m)^[ \t]*([A-Za-z_]\w*)\s+[A-Za-z_]\w*[\s*]+[A-Za-z_]\w*\s*\(") + .expect("c-leading-attr-macro regex") + }); + let spans: Vec<(usize, usize)> = re + .captures_iter(source) + .filter_map(|caps| caps.get(1)) + .filter(|m| attr_macros.contains(m.as_str())) + .map(|m| (m.start(), m.end())) + .collect(); + if spans.is_empty() { + return source.to_string(); + } + let mut bytes = source.as_bytes().to_vec(); + for (start, end) in spans { + for b in bytes.iter_mut().take(end).skip(start) { + *b = b' '; } } + String::from_utf8(bytes).unwrap_or_else(|_| source.to_string()) +} + +/// Attribute constructs whose presence in a `#define`'s replacement text proves +/// the macro is an attribute wrapper rather than a type or keyword alias. +const C_ATTRIBUTE_CONSTRUCTS: [&str; 6] = [ + "__attribute__", + "__attribute", + "__declspec", + "__asm", + "__pragma", + "_Pragma", +]; + +/// Collect the object-like `#define`s in THIS file whose replacement text is empty +/// or contains an attribute construct — i.e. the tokens that are provably safe to +/// blank in declaration position. A `BTreeSet` keeps membership order-independent +/// so extraction stays deterministic. +fn attribute_like_defines(source: &str) -> BTreeSet<&str> { + let mut names = BTreeSet::new(); + if !source.contains("#define") { + return names; + } + for line in source.lines() { + let rest = match line.trim_start().strip_prefix('#') { + Some(rest) => rest.trim_start(), + None => continue, + }; + let rest = match rest.strip_prefix("define") { + Some(rest) => rest, + None => continue, + }; + // `#defineFOO` is not a define; a separator is required. + if !rest.starts_with([' ', '\t']) { + continue; + } + let rest = rest.trim_start(); + let name_end = rest + .find(|c: char| !(c == '_' || c.is_ascii_alphanumeric())) + .unwrap_or(rest.len()); + let (name, replacement) = rest.split_at(name_end); + if name.is_empty() || name.starts_with(|c: char| c.is_ascii_digit()) { + continue; + } + // Function-like macro (`#define F(x) …`) — never used as a bare leading + // token, so it is not a candidate. + if replacement.starts_with('(') { + continue; + } + let replacement = replacement.trim(); + if replacement.is_empty() + || C_ATTRIBUTE_CONSTRUCTS + .iter() + .any(|marker| replacement.contains(marker)) + { + names.insert(name); + } + } + names } pub(crate) fn include_import(node: Node<'_>, source: &str) -> Option { @@ -201,4 +345,107 @@ mod tests { let src = "int add(int a, int b) { return a + b; }"; assert_eq!(CSpec.pre_parse(src, "m.c"), src); } + + const ATTR_DEFINE: &str = "#define SEC_ATTR __attribute__((section(\".init\")))\n"; + + #[test] + fn blank_c_leading_attr_macro_blanks_a_same_file_proved_macro() { + let src = format!("{ATTR_DEFINE}SEC_ATTR UINT32 f(void) {{}}"); + let out = blank_c_leading_attr_macros(&src); + assert_eq!(out, format!("{ATTR_DEFINE} UINT32 f(void) {{}}")); + assert_eq!(out.len(), src.len()); + } + + #[test] + fn blank_c_leading_attr_macro_accepts_an_empty_define() { + let src = "#define SEC_ATTR\nSEC_ATTR UINT32 f(void) {}"; + assert_eq!( + blank_c_leading_attr_macros(src), + "#define SEC_ATTR\n UINT32 f(void) {}" + ); + } + + #[test] + fn blank_c_leading_attr_macro_accepts_declspec_and_asm_defines() { + for define in ["#define DLL_A __declspec(dllexport)", "#define NAKED __asm"] { + let name = define.split_whitespace().nth(1).expect("macro name"); + let src = format!("{define}\n{name} UINT32 f(void) {{}}"); + let out = blank_c_leading_attr_macros(&src); + assert!( + out.lines().nth(1).expect("body line").starts_with(" "), + "{define} should have proved `{name}` blankable, got: {out}" + ); + assert_eq!(out.len(), src.len()); + } + } + + #[test] + fn blank_c_leading_attr_macro_is_offset_preserving() { + let src = + format!("{ATTR_DEFINE}SEC_ATTR\nUINT32 f(void) {{}}\nSEC_ATTR VOID g(VOID) {{}}\n"); + let out = blank_c_leading_attr_macros(&src); + assert_eq!(out.len(), src.len()); + assert_eq!( + out.bytes().filter(|&b| b == b'\n').count(), + src.bytes().filter(|&b| b == b'\n').count() + ); + } + + #[test] + fn blank_c_leading_attr_macro_handles_pointer_returns() { + let src = format!("{ATTR_DEFINE}SEC_ATTR UINT32 *f(void) {{}}"); + assert_eq!( + blank_c_leading_attr_macros(&src), + format!("{ATTR_DEFINE} UINT32 *f(void) {{}}") + ); + } + + #[test] + fn blank_c_leading_attr_macro_leaves_unproved_leading_tokens_untouched() { + for src in [ + "SEC_ATTR UINT32 f(void) {}", + "UINT32 helper(void) {}", + "MY_ASSERT(x);", + ATTR_DEFINE, + "SEC_ATTR unsigned int f(void) {}", + "x = SEC_ATTR UINT32 y(z);", + ] { + assert_eq!(blank_c_leading_attr_macros(src), src, "changed: {src}"); + } + } + + // The rows that the earlier ALL-CAPS-only rule DAMAGED: a typedef'd return type + // in front of a calling-convention macro, and keyword-alias macros. None has an + // attribute-like `#define`, so none may be touched even though `SEC_ATTR`'s + // define makes the pass active in the same file. + #[test] + fn blank_c_leading_attr_macro_leaves_return_types_and_keyword_aliases_untouched() { + for line in [ + "EFI_STATUS EFIAPI DriverEntry (VOID) {}", + "CONST CHAR8 *GetName (VOID) {}", + "STATIC void helper (void) {}", + "UINT32 Untouched (void) {}", + ] { + let src = format!("{ATTR_DEFINE}{line}\n"); + assert_eq!( + blank_c_leading_attr_macros(&src), + src, + "must stay untouched: {line}" + ); + } + } + + #[test] + fn attribute_like_defines_rejects_types_keywords_and_function_like_macros() { + let src = concat!( + "#define VOID void\n", + "#define STATIC static\n", + "#define CONST const\n", + "#define EFIAPI\n", + "#define CHECK(x) __attribute__((unused)) x\n", + "#define SEC_ATTR __attribute__((section(\".init\")))\n" + ); + let names: Vec<&str> = attribute_like_defines(src).into_iter().collect(); + assert_eq!(names, ["EFIAPI", "SEC_ATTR"]); + } } diff --git a/crates/codegraph-extract/src/lang/cpp.rs b/crates/codegraph-extract/src/lang/cpp.rs index 2f9f95a..8c8162d 100644 --- a/crates/codegraph-extract/src/lang/cpp.rs +++ b/crates/codegraph-extract/src/lang/cpp.rs @@ -8,7 +8,7 @@ use tree_sitter::{Language as TsLanguage, Node}; use crate::lang::c::{include_import, normalize_c_return_type}; use crate::spec::{ImportInfo, LanguageSpec}; -use crate::walker::{child_by_field, node_text}; +use crate::walker::{child_by_field, node_text, strip_cpp_template_args}; pub struct CppSpec; @@ -84,7 +84,18 @@ impl LanguageSpec for CppSpec { .filter(|part| !part.is_empty()) .map(str::to_string) .collect::>(); - (parts.len() > 1).then(|| parts[..parts.len() - 1].join("::")) + if parts.len() <= 1 { + return None; + } + // An out-of-line template method definition spells the class's template + // parameter list in the qualifier (`template T Box::get()`) + // while the class node is indexed as bare `Box` — strip `<…>` so the + // receiver matches it, the same normalization #1043 applies to + // base-class refs. A multi-line parameter list otherwise leaks whole + // `<…>` blocks (newlines included) into `qualified_name`, which can + // exceed NAME_MAX (#1309). + let receiver = strip_cpp_template_args(&parts[..parts.len() - 1].join("::")); + (!receiver.is_empty()).then_some(receiver) } fn get_return_type(&self, node: Node<'_>, source: &str) -> Option { recover_return_type(node, source) @@ -479,6 +490,91 @@ fn recover_cpp_macro_defined_name(node: Node<'_>, source: &str) -> Option`). + Callee(String), + /// A receiver that cannot aid type inference (`w.obj()->operator+`): emit + /// NOTHING. A bare operator name would fall through to exact-name matching, + /// which guesses among unrelated same-named operators — a silent miss is + /// preferable to a wrong edge. + Drop, +} + +/// Recover the callee of a C++ explicit operator call (`a.operator+(b)`, +/// `p->operator+(b)`, `a.operator[](3)`) — upstream #1268. +/// +/// tree-sitter-cpp cannot parse an `operator_name` in field position, so the +/// `call_expression` carries `function: ` plus an ERROR child wrapping +/// the `operator_name` instead of a `field_expression` callee. Reading the +/// `function` field alone yields just the receiver (`a`), an unresolvable ref. +/// +/// `None` means "not this shape" — the caller continues down the normal call +/// path (which still owns `V::operator+(a, b)` and the `a.operator bool()` word +/// form, both of which parse without a stranded `operator_name`). +pub(crate) fn recover_explicit_operator_call( + node: Node<'_>, + source: &str, +) -> Option { + let operator_name = node + .named_children(&mut node.walk()) + .filter(|child| child.kind() == "ERROR") + .find_map(|error| { + error + .named_children(&mut error.walk()) + .find(|c| c.kind() == "operator_name") + }) + .map(|op| compact_operator_name(&node_text(op, source)))?; + let func = child_by_field(node, "function").or_else(|| node.named_child(0))?; + let receiver: String = node_text(func, source) + .replace("->", ".") + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); + if receiver == "this" { + return Some(ExplicitOperatorCall::Callee(operator_name)); + } + if !is_simple_receiver_chain(&receiver) { + return Some(ExplicitOperatorCall::Drop); + } + Some(ExplicitOperatorCall::Callee(format!( + "{receiver}.{operator_name}" + ))) +} + +/// Call sites may space a symbolic operator name (`it.operator * ()`, +/// `other.operator < (*this)` in nlohmann/json) while the definition indexes +/// compact (`operator*`). Squeeze the whitespace out of the SYMBOLIC forms only +/// — the word forms (`operator new`, `operator bool`) need their space. +fn compact_operator_name(raw: &str) -> String { + let trimmed = raw.trim(); + let Some(symbol) = trimmed.strip_prefix("operator") else { + return trimmed.to_string(); + }; + let symbol = symbol.trim(); + let symbolic = symbol + .chars() + .next() + .is_some_and(|c| !c.is_alphanumeric() && c != '_'); + if !symbolic { + return trimmed.to_string(); + } + let squeezed: String = symbol.chars().filter(|c| !c.is_whitespace()).collect(); + format!("operator{squeezed}") +} + +/// `^[A-Za-z_][\w.]*$` — an identifier or plain member chain, the only receiver +/// shapes downstream receiver-type inference can work with. +fn is_simple_receiver_chain(receiver: &str) -> bool { + let mut chars = receiver.chars(); + chars + .next() + .is_some_and(|c| c == '_' || c.is_ascii_alphabetic()) + && chars.all(|c| c == '_' || c == '.' || c.is_ascii_alphanumeric()) +} + fn declarator_qualified_id<'tree>(declarator: Node<'tree>) -> Option> { let mut queue = vec![declarator]; while let Some(current) = queue.pop() { diff --git a/crates/codegraph-extract/src/lang/mod.rs b/crates/codegraph-extract/src/lang/mod.rs index ade65d1..d162557 100644 --- a/crates/codegraph-extract/src/lang/mod.rs +++ b/crates/codegraph-extract/src/lang/mod.rs @@ -44,7 +44,10 @@ pub(crate) use cfml::{ cfml_component_name_from_path, cfml_string_attr_value, cfml_tag_attr, is_bare_script_cfml, }; pub use cpp::CPP_SPEC; -pub(crate) use cpp::{ExportMacroClass, detect_export_macro_class}; +pub(crate) use cpp::{ + ExplicitOperatorCall, ExportMacroClass, detect_export_macro_class, + recover_explicit_operator_call, +}; pub use csharp::CSHARP_SPEC; pub use dart::DART_SPEC; pub use erlang::ERLANG_SPEC; diff --git a/crates/codegraph-extract/src/lib.rs b/crates/codegraph-extract/src/lib.rs index 5fb9982..143f5ea 100644 --- a/crates/codegraph-extract/src/lib.rs +++ b/crates/codegraph-extract/src/lib.rs @@ -17,6 +17,7 @@ pub mod spec; pub mod walker; pub use engine::{ - ExtractOptions, detect_language, extract_file, extract_project, extract_source, - include_exclude_pattern_matches, + ExtractOptions, detect_language, detect_language_with, extract_file, extract_file_with_options, + extract_project, extract_source, extract_source_with, include_exclude_pattern_matches, }; +pub use ext_config::ExtensionOverrides; diff --git a/crates/codegraph-extract/src/walker.rs b/crates/codegraph-extract/src/walker.rs index 2ea0795..321e9db 100644 --- a/crates/codegraph-extract/src/walker.rs +++ b/crates/codegraph-extract/src/walker.rs @@ -29,6 +29,71 @@ pub fn child_by_field<'tree>(node: SyntaxNode<'tree>, field: &str) -> Option) -> bool { + LITERAL_RECEIVER_KINDS.contains(&node.kind()) +} + pub struct TreeSitterWalker<'a, 'tree> { file_path: &'a str, source: &'a str, @@ -2104,7 +2169,8 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { is_async: self.spec.is_async(node), is_static: self.spec.is_static(node, self.source), return_type: self.spec.get_return_type(node, self.source), - qualified_name: receiver_type.map(|receiver| format!("{receiver}::{name}")), + qualified_name: receiver_type + .map(|receiver| self.compose_receiver_qualified_name(&receiver, &name)), ..NodeExtra::default() }, ); @@ -2893,6 +2959,16 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } } } + if self.spec.language() == Language::Cpp { + match crate::lang::recover_explicit_operator_call(node, self.source) { + Some(crate::lang::ExplicitOperatorCall::Callee(callee)) => { + self.push_ref(&caller_id, &callee, EdgeKind::Calls, node); + return; + } + Some(crate::lang::ExplicitOperatorCall::Drop) => return, + None => {} + } + } let mut callee_name = String::new(); let func = child_by_field(node, "function").or_else(|| node.named_child(0)); if let Some(func) = func { @@ -2928,6 +3004,9 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { .or_else(|| child_by_field(func, "argument")) .or_else(|| func.named_child(0)); if let Some(receiver) = receiver { + if is_literal_receiver(receiver) { + return; + } if matches!( receiver.kind(), "identifier" | "simple_identifier" | "field_identifier" @@ -3027,6 +3106,9 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { if method_name.is_empty() { return; } + if is_literal_receiver(object_field) { + return; + } // PHP `Cls::for($x)->method()`: receiver is a static call, re-encode as // `().` (#608). @@ -3549,6 +3631,37 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { .or_else(|| child_by_field(node, self.spec.body_field())) } + /// Qualified name for a method defined out-of-line via a receiver qualifier + /// (`Type::method() {}`). The declarator spells the receiver RELATIVE to the + /// enclosing namespace, so the active C++ namespace prefix must compose in — + /// `namespace sim { Output ManifestStartup::Apply() {} }` otherwise indexes as + /// `ManifestStartup::Apply` while its own class node carries + /// `sim::ManifestStartup`, and qualified call sites never resolve (#1310). + /// + /// The source may legally re-spell part or all of the namespace path + /// (`namespace sim { void sim::M::f() {} }`), so the receiver is anchored at + /// the first prefix segment it names: everything before that anchor comes + /// from the prefix, the receiver supplies the rest. `namespace_prefix` is only + /// ever non-empty for C++, so every other receiver language (Go, Rust, + /// Kotlin, Lua) passes through unchanged. + fn compose_receiver_qualified_name(&self, receiver: &str, name: &str) -> String { + let base = format!("{receiver}::{name}"); + if self.namespace_prefix.is_empty() { + return base; + } + let receiver_head = receiver.split("::").next().unwrap_or(receiver); + let anchor = self + .namespace_prefix + .iter() + .position(|segment| segment == receiver_head); + let prefix = &self.namespace_prefix[..anchor.unwrap_or(self.namespace_prefix.len())]; + if prefix.is_empty() { + base + } else { + format!("{}::{base}", prefix.join("::")) + } + } + fn build_qualified_name(&self, name: &str) -> String { let mut parts: Vec<&str> = self.namespace_prefix.iter().map(String::as_str).collect(); for node_id in &self.node_stack { @@ -4113,7 +4226,7 @@ fn is_builtin_type(name: &str) -> bool { ) } -fn strip_cpp_template_args(name: &str) -> String { +pub(crate) fn strip_cpp_template_args(name: &str) -> String { let mut depth: i32 = 0; let mut out = String::with_capacity(name.len()); for ch in name.chars() { @@ -5550,6 +5663,160 @@ struct Bar { int x; }; assert!(has_node(&nodes, NodeKind::Struct, "Bar")); } + // ---- Batch B4: C leading attribute macros (upstream b6a05d1) ---- + + const C_ATTR_DEFINE: &str = "#define SEC_ATTR __attribute__((section(\".init\")))\n"; + + fn c_names(src: &str) -> Vec { + let (nodes, _) = run("attrs.c", src, Language::C); + nodes + .iter() + .filter(|n| n.kind == NodeKind::Function) + .map(|n| n.name.clone()) + .collect() + } + + fn c_function_return_types(src: &str) -> Vec<(String, i64, String)> { + let (nodes, _) = run("attrs.c", src, Language::C); + let mut rows: Vec<(String, i64, String)> = nodes + .iter() + .filter(|n| n.kind == NodeKind::Function) + .map(|n| { + ( + n.name.clone(), + n.start_line, + n.return_type + .clone() + .unwrap_or_else(|| "".to_string()), + ) + }) + .collect(); + rows.sort_by_key(|(_, line, _)| *line); + rows + } + + #[test] + fn c_leading_attr_macro_function_indexes_under_its_real_name() { + let names = c_names(&format!( + "{C_ATTR_DEFINE}SEC_ATTR UINT32 Foo (VOID) {{ return 0; }}\n" + )); + assert_eq!( + names, + vec!["Foo".to_string()], + "a function behind a same-file-proved attribute macro must index as `Foo`" + ); + } + + #[test] + fn c_leading_attr_macro_with_macro_return_type_indexes_under_its_real_name() { + let names = c_names(&format!("{C_ATTR_DEFINE}SEC_ATTR VOID Foo (VOID) {{ }}\n")); + assert_eq!( + names, + vec!["Foo".to_string()], + "a macro return type behind a leading attribute macro must not become the name" + ); + } + + // NEGATIVE CONTROL for the tightened rule (#1311 follow-up). Firmware-flavoured + // C where the leading ALL-CAPS token is a typedef'd RETURN TYPE or a + // keyword-alias macro, NOT an attribute macro. The earlier purely structural + // rule blanked the return type here (`EFI_STATUS`→`EFIAPI`, `STATIC`→dropped); + // the return types below are the ones extraction produced BEFORE any blanking + // existed, so this test fails under the permissive rule and passes under the + // `#define`-proved one. `SEC_ATTR`'s define is present so the pass is ACTIVE. + #[test] + fn c_leading_typedef_return_and_keyword_alias_macros_keep_their_return_types() { + let src = format!( + "{C_ATTR_DEFINE}{}", + concat!( + "EFI_STATUS EFIAPI DriverEntry (VOID) {\n", + " return 0;\n", + "}\n", + "CONST CHAR8 *GetName (VOID) {\n", + " return 0;\n", + "}\n", + "STATIC void helper (void) {\n", + "}\n", + "UINT32 Untouched (void) {\n", + " return 0;\n", + "}\n" + ) + ); + assert_eq!( + c_function_return_types(&src), + vec![ + ("DriverEntry".to_string(), 2, "EFI_STATUS".to_string()), + ("GetName".to_string(), 5, "CONST".to_string()), + ("helper".to_string(), 8, "STATIC".to_string()), + ("Untouched".to_string(), 10, "UINT32".to_string()), + ] + ); + } + + #[test] + fn c_leading_attr_macro_without_a_visible_define_is_left_untouched() { + assert_eq!( + c_function_return_types("SEC_ATTR UINT32 Foo (VOID) { return 0; }\n"), + vec![("UINT32".to_string(), 1, "SEC_ATTR".to_string())], + "with no same-file `#define` the source must parse exactly as it did \ + before this pass existed — #1311 stays unfixed for header-defined macros" + ); + } + + #[test] + fn c_isolation_table_functions_all_index_under_real_names() { + let src = concat!( + "#define SEC_ATTR __attribute__((section(\".init\")))\n", + "typedef unsigned int UINT32;\n", + "#define VOID void\n", + "\n", + "SEC_ATTR VOID GoodName (VOID) { }\n", + "SEC_ATTR UINT32 LostName (VOID) { return 0; }\n", + "UINT32 NoAttr (void) { return 0; }\n", + "SEC_ATTR int BuiltinRet (void) { return 0; }\n", + "__attribute__((section(\".init\"))) UINT32 RawAttr (void) { return 0; }\n", + "SEC_ATTR UINT32 OneNamedArg (UINT32 x) { return x; }\n", + "SEC_ATTR UINT32 *PtrRet (VOID) { return 0; }\n" + ); + let names = c_names(src); + for want in [ + "GoodName", + "LostName", + "NoAttr", + "BuiltinRet", + "RawAttr", + "OneNamedArg", + "PtrRet", + ] { + assert!( + names.iter().any(|n| n == want), + "missing `{want}` among C function names: {names:?}" + ); + } + assert!( + !names.iter().any(|n| n.contains('(')), + "no parameter list may be stored as a function name: {names:?}" + ); + } + + #[test] + fn c_plain_typedef_return_and_macro_call_shapes_unaffected() { + assert_eq!(c_names("UINT32 helper (void) { return 0; }\n"), ["helper"]); + assert_eq!( + c_names(&format!( + "{C_ATTR_DEFINE}SEC_ATTR unsigned int f(void) {{ return 0; }}\n" + )), + ["f"] + ); + let (nodes, _) = run("call.c", "void g(void) { MY_ASSERT(x); }\n", Language::C); + let names: Vec<&str> = nodes + .iter() + .filter(|n| n.kind == NodeKind::Function) + .map(|n| n.name.as_str()) + .collect(); + assert_eq!(names, ["g"]); + } + // #1093 NEGATIVE — a bodiless class in a language where that is a COMPLETE // definition (Kotlin `class Empty`) must still be indexed. #[test] @@ -5892,6 +6159,181 @@ WINAPI HRESULT DoThing(int x) { return x; } assert_eq!(f.qualified_name, "f"); } + // ---- Batch B3: namespaced out-of-line methods (upstream e437918) ---- + + #[test] + fn cpp_out_of_line_method_in_namespace_carries_namespace_prefix() { + let src = concat!( + "namespace simulator {\n", + "class ManifestStartup {\n", + "public:\n", + " struct Input { int x; };\n", + " struct Output { int y; };\n", + " static Output Apply(const Input& input);\n", + "};\n", + "ManifestStartup::Output ManifestStartup::Apply(const Input& input) { return {}; }\n", + "}\n" + ); + let (nodes, _) = run("manifest_startup.cpp", src, Language::Cpp); + let class = node(&nodes, NodeKind::Class, "ManifestStartup"); + assert_eq!(class.qualified_name, "simulator::ManifestStartup"); + let apply_qns: Vec<&str> = nodes + .iter() + .filter(|n| n.name == "Apply") + .map(|n| n.qualified_name.as_str()) + .collect(); + assert!( + apply_qns.contains(&"simulator::ManifestStartup::Apply"), + "the out-of-line definition must carry the namespace prefix, got: {apply_qns:?}" + ); + } + + #[test] + fn cpp_receiver_that_respells_the_namespace_is_not_double_prefixed() { + let src = concat!( + "namespace sim {\n", + "class M { public: static void f(); static void g(); };\n", + "void sim::M::f() {}\n", + "void M::g() {}\n", + "}\n", + "void sim::M::f2() {}\n" + ); + let (nodes, _) = run("m.cpp", src, Language::Cpp); + let qns: Vec<&str> = nodes + .iter() + .filter(|n| n.kind == NodeKind::Method) + .map(|n| n.qualified_name.as_str()) + .collect(); + for want in ["sim::M::f", "sim::M::g", "sim::M::f2"] { + assert!( + qns.contains(&want), + "missing `{want}` among method qualified names: {qns:?}" + ); + } + assert!( + !qns.iter().any(|q| q.contains("sim::sim")), + "a re-spelled namespace must not be double-prefixed, got: {qns:?}" + ); + } + + #[test] + fn go_receiver_method_qualified_name_unaffected_by_namespace_composition() { + let src = concat!( + "package main\n", + "\n", + "type Server struct{}\n", + "\n", + "func (s *Server) Start() {}\n" + ); + let (nodes, _) = run("srv.go", src, Language::Go); + let start = nodes + .iter() + .find(|n| n.kind == NodeKind::Method && n.name == "Start") + .expect("Start method"); + assert_eq!(start.qualified_name, "Server::Start"); + } + + // ---- Batch B2: out-of-line template method receivers (upstream 4dd29ea) ---- + + #[test] + fn cpp_out_of_line_template_method_receiver_strips_template_args() { + let src = concat!( + "template \n", + "class Box {\n", + "public:\n", + " T get() const;\n", + " void set(T v);\n", + "};\n", + "\n", + "template T Box::get() const { return T(); }\n", + "template void Box::set(T v) {}\n" + ); + let (nodes, _) = run("box.cpp", src, Language::Cpp); + let qns: Vec<&str> = nodes + .iter() + .filter(|n| n.kind == NodeKind::Method) + .map(|n| n.qualified_name.as_str()) + .collect(); + assert!( + qns.contains(&"Box::get"), + "out-of-line `Box::get` should qualify as `Box::get`, got: {qns:?}" + ); + assert!( + qns.contains(&"Box::set"), + "out-of-line `Box::set` should qualify as `Box::set`, got: {qns:?}" + ); + assert!( + !qns.iter().any(|q| q.contains('<')), + "no method qualified name may keep template args, got: {qns:?}" + ); + } + + #[test] + fn cpp_multiline_template_parameter_list_cannot_leak_into_qualified_name() { + let src = concat!( + "template \n", + "class ApiHelper {\n", + "public:\n", + " CPPType* validate();\n", + "};\n", + "\n", + "template \n", + "CPPType* ApiHelper::validate() {\n", + " return nullptr;\n", + "}\n" + ); + let (nodes, _) = run("capi_helper.cpp", src, Language::Cpp); + let validate = nodes + .iter() + .filter(|n| n.kind == NodeKind::Method && n.name == "validate") + .find(|n| n.qualified_name.contains("::")) + .expect("out-of-line validate method"); + assert_eq!( + validate.qualified_name, "ApiHelper::validate", + "multi-line template parameter list must not leak into the qualified name" + ); + assert!( + !validate.qualified_name.contains(['<', '>', '\n']), + "qualified name must carry no template/newline bytes: {:?}", + validate.qualified_name + ); + assert!( + validate.qualified_name.len() < 255, + "qualified name must stay under NAME_MAX, got {} bytes", + validate.qualified_name.len() + ); + } + + #[test] + fn cpp_out_of_line_template_method_links_to_its_class() { + let src = concat!( + "template \n", + "class Box {\n", + "public:\n", + " T get() const;\n", + "};\n", + "\n", + "template T Box::get() const { return T(); }\n" + ); + let (nodes, _) = run("box_link.cpp", src, Language::Cpp); + let class = node(&nodes, NodeKind::Class, "Box"); + let method = nodes + .iter() + .find(|n| n.kind == NodeKind::Method && n.name == "get") + .expect("get method"); + assert_eq!( + method.qualified_name, + format!("{}::get", class.qualified_name), + "the out-of-line definition must share the class node's qualifier" + ); + } + #[test] fn cpp_template_arg_call_strips_to_base() { let src = r#" @@ -5921,6 +6363,123 @@ void caller() { operator<<(a, b); } ); } + // ---- Batch B1: C++ explicit operator calls (upstream 6103f5e / #1268) ---- + + const CPP_OP_HEADER: &str = concat!( + "struct V {\n", + " V operator+(const V& o) const;\n", + " V operator[](int i) const;\n", + " V operator()(int i) const;\n", + " bool operator==(const V& o) const;\n", + " int get() const;\n", + "};\n" + ); + + fn cpp_call_refs(body: &str) -> Vec { + let src = format!("{CPP_OP_HEADER}{body}"); + let (_, refs) = run("op.cpp", &src, Language::Cpp); + refs.iter() + .filter(|r| r.reference_kind == EdgeKind::Calls) + .map(|r| r.reference_name.clone()) + .collect() + } + + #[test] + fn cpp_explicit_operator_call_recovers_receiver_operator() { + let refs = cpp_call_refs("V f(const V& a, const V& b) { return a.operator+(b); }\n"); + assert!( + refs.iter().any(|r| r == "a.operator+"), + "explicit `a.operator+(b)` should emit a `a.operator+` Calls ref, got: {refs:?}" + ); + } + + #[test] + fn cpp_explicit_operator_call_normalizes_arrow_receiver() { + let refs = cpp_call_refs("V f(const V* p, const V& b) { return p->operator+(b); }\n"); + assert!( + refs.iter().any(|r| r == "p.operator+"), + "`p->operator+(b)` should normalize to `p.operator+`, got: {refs:?}" + ); + } + + #[test] + fn cpp_explicit_operator_call_covers_symbolic_forms() { + let refs = cpp_call_refs(concat!( + "V f1(const V& a) { return a.operator[](3); }\n", + "V f2(V& a) { return a.operator()(1); }\n", + "bool f3(const V& a, const V& b) { return a.operator==(b); }\n" + )); + for want in ["a.operator[]", "a.operator()", "a.operator=="] { + assert!( + refs.iter().any(|r| r == want), + "missing `{want}` among explicit operator refs: {refs:?}" + ); + } + } + + #[test] + fn cpp_explicit_operator_call_compacts_spaced_names() { + let refs = cpp_call_refs(concat!( + "bool f(const V& a, const V& b) { return a.operator == (b); }\n", + "V g(const V& a) { return a.operator [] (3); }\n" + )); + for want in ["a.operator==", "a.operator[]"] { + assert!( + refs.iter().any(|r| r == want), + "spaced call-site operator should compact to `{want}`, got: {refs:?}" + ); + } + } + + #[test] + fn cpp_explicit_operator_call_drops_complex_receiver() { + let refs = cpp_call_refs(concat!( + "struct W { V* obj(); };\n", + "V f(W& w, const V& b) { return w.obj()->operator+(b); }\n" + )); + assert!( + !refs.iter().any(|r| r.contains("operator+")), + "a call-result receiver must not emit a guessable operator ref, got: {refs:?}" + ); + assert!( + refs.iter().any(|r| r == "w.obj"), + "the inner `w.obj()` call must still ref normally, got: {refs:?}" + ); + } + + #[test] + fn cpp_explicit_operator_call_this_receiver_is_bare() { + let src = concat!( + "struct V {\n", + " V operator+(const V& o) const;\n", + " V twice() const { return this->operator+(*this); }\n", + "};\n" + ); + let (_, refs) = run("op.cpp", src, Language::Cpp); + let calls: Vec<&str> = refs + .iter() + .filter(|r| r.reference_kind == EdgeKind::Calls) + .map(|r| r.reference_name.as_str()) + .collect(); + assert!( + calls.contains(&"operator+"), + "`this->operator+(...)` should emit the bare `operator+`, got: {calls:?}" + ); + assert!( + !calls.iter().any(|r| r.contains("this")), + "no `this` receiver should leak into the ref name, got: {calls:?}" + ); + } + + #[test] + fn cpp_plain_member_call_unaffected_by_operator_recovery() { + let refs = cpp_call_refs("int f(const V& a) { return a.get(); }\n"); + assert!( + refs.iter().any(|r| r == "a.get"), + "plain member call must stay `a.get`, got: {refs:?}" + ); + } + #[test] fn cpp_ue_reflected_class_recovered() { let src = r#" @@ -6666,4 +7225,123 @@ g() ->\n\ || has_ref(&refs, EdgeKind::Imports, "sub.cfm") ); } + + // ---- Literal-receiver builtin calls (#1230 / upstream c472cfb) ---------- + + fn call_refs(file: &str, source: &str, lang: Language) -> Vec { + let (_, refs) = run(file, source, lang); + refs.iter() + .filter(|r| r.reference_kind == EdgeKind::Calls) + .map(|r| r.reference_name.clone()) + .collect() + } + + #[test] + fn python_literal_receiver_calls_emit_no_ref() { + let calls = call_refs( + "src/repro.py", + "def report_missing(unresolved):\n return \", \".join(sorted(unresolved))\n", + Language::Python, + ); + assert!( + !calls.iter().any(|c| c == "join"), + "`\", \".join(...)` is a str builtin and must emit NO call ref, got: {calls:?}" + ); + assert!( + calls.iter().any(|c| c == "sorted"), + "the nested `sorted(...)` call must still be extracted, got: {calls:?}" + ); + } + + #[test] + fn python_collection_and_number_literal_receivers_emit_no_ref() { + let calls = call_refs( + "src/lits.py", + concat!( + "def f(x):\n", + " [1, 2].append(3)\n", + " {'k': 1}.keys()\n", + " {1, 2}.union(x)\n", + " (1, 2).count(1)\n", + " 1.5.hex()\n", + " None.__str__()\n", + " True.__str__()\n", + ), + Language::Python, + ); + assert!( + calls.is_empty(), + "every literal-receiver builtin must emit NO call ref, got: {calls:?}" + ); + } + + #[test] + fn typescript_literal_receiver_calls_emit_no_ref() { + let calls = call_refs( + "src/lits.ts", + concat!( + "export function f(): void {\n", + " \"x\".toUpperCase();\n", + " [1, 2].map((n) => n);\n", + " `t`.trim();\n", + " /re/.test(\"s\");\n", + " 0xff.toString();\n", + " true.toString();\n", + "}\n", + ), + Language::TypeScript, + ); + assert!( + calls.is_empty(), + "every literal-receiver builtin must emit NO call ref, got: {calls:?}" + ); + } + + #[test] + fn java_string_literal_receiver_call_emits_no_ref() { + let calls = call_refs( + "src/C.java", + "class C { void f() { \"x\".trim(); } }\n", + Language::Java, + ); + assert!( + calls.is_empty(), + "`\"x\".trim()` is a String builtin and must emit NO call ref, got: {calls:?}" + ); + } + + #[test] + fn php_string_literal_receiver_call_emits_no_ref() { + let calls = call_refs( + "src/a.php", + "foo(); }\n", + Language::Php, + ); + assert!( + calls.is_empty(), + "a literal PHP receiver must emit NO call ref, got: {calls:?}" + ); + } + + #[test] + fn identifier_receiver_calls_are_unaffected_by_the_literal_guard() { + let py = call_refs( + "src/ok.py", + "def f(sep, x):\n return sep.join(sorted(x))\n", + Language::Python, + ); + assert!( + py.iter().any(|c| c == "sep.join"), + "an identifier receiver must still emit `sep.join`, got: {py:?}" + ); + let java = call_refs( + "src/D.java", + "class D { void f(String s) { s.trim(); } }\n", + Language::Java, + ); + assert!( + java.iter().any(|c| c == "s.trim"), + "an identifier receiver must still emit `s.trim`, got: {java:?}" + ); + } } diff --git a/crates/codegraph-extract/tests/coverage_ext_config.rs b/crates/codegraph-extract/tests/coverage_ext_config.rs index e01a9bf..d344233 100644 --- a/crates/codegraph-extract/tests/coverage_ext_config.rs +++ b/crates/codegraph-extract/tests/coverage_ext_config.rs @@ -1,10 +1,12 @@ -//! Coverage for the `.codegraph/codegraph.json` custom extension-override -//! reader (`ext_config.rs`) driven through the public `detect_language` API. +//! Coverage for the PROJECT-SCOPED `codegraph.json` extension-override reader +//! (`ext_config.rs`) driven through its public API plus `detect_language_with`. //! Exercises: successful override, unknown-language skip, malformed-JSON -//! tolerance, and the absent-config fast path. TEST-ONLY: no production change. +//! tolerance, the absent-config fast path, and the built-in skip-list. +//! TEST-ONLY: no production change. +use codegraph_core::IndexPaths; use codegraph_core::types::Language; -use codegraph_extract::detect_language; +use codegraph_extract::{ExtensionOverrides, detect_language, detect_language_with}; use std::fs; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU32, Ordering}; @@ -22,26 +24,30 @@ fn unique_project(tag: &str) -> PathBuf { dir } -fn write_config(root: &Path, contents: &str) { - let dir = root.join(".codegraph"); - fs::create_dir_all(&dir).expect("create .codegraph"); - fs::write(dir.join("codegraph.json"), contents).expect("write codegraph.json"); +/// Write the project's CURRENT-ROOT `codegraph.json` and load it the way the +/// pipeline does — explicitly, from the resolved index paths. +fn load_overrides(project: &Path, contents: &str) -> std::sync::Arc { + let paths = IndexPaths::resolve(project, None).expect("resolve index paths"); + fs::create_dir_all(paths.current_root()).expect("create current root"); + fs::write(paths.extension_config(), contents).expect("write codegraph.json"); + ExtensionOverrides::load_for_paths(&paths) } #[test] fn override_maps_custom_extension_to_a_known_language() { let project = unique_project("known"); - write_config( + let overrides = load_overrides( &project, r#"{ "extensions": { ".blade": "php", "X": "lua" } }"#, ); // A custom extension unmapped by the built-in table resolves via the config. - let blade = project.join("views/home.blade"); - assert_eq!(detect_language(&blade), Language::Php); + assert_eq!( + detect_language_with("views/home.blade", &overrides), + Language::Php + ); // Keys are dot-stripped and lowercased before matching. - let x = project.join("script.x"); - assert_eq!(detect_language(&x), Language::Lua); + assert_eq!(detect_language_with("script.x", &overrides), Language::Lua); fs::remove_dir_all(&project).ok(); } @@ -49,10 +55,12 @@ fn override_maps_custom_extension_to_a_known_language() { #[test] fn override_ignores_unknown_language_names() { let project = unique_project("unknown_lang"); - write_config(&project, r#"{ "extensions": { ".foo": "klingon" } }"#); + let overrides = load_overrides(&project, r#"{ "extensions": { ".foo": "klingon" } }"#); - let foo = project.join("thing.foo"); - assert_eq!(detect_language(&foo), Language::Unknown); + assert_eq!( + detect_language_with("thing.foo", &overrides), + Language::Unknown + ); fs::remove_dir_all(&project).ok(); } @@ -60,10 +68,13 @@ fn override_ignores_unknown_language_names() { #[test] fn malformed_config_is_tolerated_and_yields_no_override() { let project = unique_project("malformed"); - write_config(&project, "{ this is not valid json "); + let overrides = load_overrides(&project, "{ this is not valid json "); - let bar = project.join("thing.bar"); - assert_eq!(detect_language(&bar), Language::Unknown); + assert!(overrides.is_empty()); + assert_eq!( + detect_language_with("thing.bar", &overrides), + Language::Unknown + ); fs::remove_dir_all(&project).ok(); } @@ -73,10 +84,12 @@ fn builtin_extension_never_consults_the_override() { let project = unique_project("builtin_wins"); // Even if the config tries to remap `.rs`, the built-in table wins because // the override is consulted only for extensions the built-ins do not claim. - write_config(&project, r#"{ "extensions": { ".rs": "python" } }"#); + let overrides = load_overrides(&project, r#"{ "extensions": { ".rs": "python" } }"#); - let rs = project.join("src/lib.rs"); - assert_eq!(detect_language(&rs), Language::Rust); + assert_eq!( + detect_language_with("src/lib.rs", &overrides), + Language::Rust + ); fs::remove_dir_all(&project).ok(); } @@ -84,8 +97,39 @@ fn builtin_extension_never_consults_the_override() { #[test] fn absent_config_leaves_custom_extension_unknown() { let project = unique_project("absent"); - let baz = project.join("thing.baz"); - assert_eq!(detect_language(&baz), Language::Unknown); + let paths = IndexPaths::resolve(&project, None).expect("resolve index paths"); + let overrides = ExtensionOverrides::load_for_paths(&paths); + + assert!(overrides.is_empty()); + assert_eq!( + detect_language_with("thing.baz", &overrides), + Language::Unknown + ); + // The override-free entry point agrees. + assert_eq!(detect_language("thing.baz"), Language::Unknown); + + fs::remove_dir_all(&project).ok(); +} + +/// A LEGACY `.codegraph/codegraph.json` is never consulted: only the project's +/// current-root config can supply an override. +#[test] +fn legacy_codegraph_json_is_never_read() { + let project = unique_project("legacy"); + fs::create_dir_all(project.join(".codegraph")).unwrap(); + fs::write( + project.join(".codegraph/codegraph.json"), + r#"{ "extensions": { ".legacyext": "lua" } }"#, + ) + .unwrap(); + let paths = IndexPaths::resolve(&project, None).expect("resolve index paths"); + + let overrides = ExtensionOverrides::load_for_paths(&paths); + assert!(overrides.is_empty(), "a legacy config must not be adopted"); + assert_eq!( + detect_language_with("plugin.legacyext", &overrides), + Language::Unknown + ); fs::remove_dir_all(&project).ok(); } diff --git a/crates/codegraph-extract/tests/custom_ext.rs b/crates/codegraph-extract/tests/custom_ext.rs index 595a01b..9ca9c87 100644 --- a/crates/codegraph-extract/tests/custom_ext.rs +++ b/crates/codegraph-extract/tests/custom_ext.rs @@ -1,16 +1,23 @@ -//! T11: custom file-extension -> language via `.codegraph/codegraph.json`. +//! T11: custom file-extension -> language via the project's CURRENT-ROOT +//! `codegraph.json` (`IndexPaths::extension_config`). //! //! Golden-safety contract: the override may ONLY add a language for an //! extension that has NO built-in match arm AND NO embedded mapping. A //! hostile attempt to re-map a real golden extension (`.ts`, `.py`) MUST be //! ignored, so the byte-stable golden oracle is never perturbed. +//! +//! The overrides are loaded EXPLICITLY from the project's resolved index paths +//! and passed into `detect_language_with`, exactly as the pipeline threads them +//! through `ExtractOptions` — no directory walk, no process-CWD lookup, no cache. +use codegraph_core::IndexPaths; use codegraph_core::types::Language; -use codegraph_extract::detect_language; +use codegraph_extract::{ExtensionOverrides, detect_language, detect_language_with}; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::sync::atomic::{AtomicU32, Ordering}; -use std::time::{Duration, SystemTime}; +use std::time::SystemTime; /// A unique temp project directory per call (no external `tempfile` dep). fn unique_project(tag: &str) -> PathBuf { @@ -28,10 +35,17 @@ fn unique_project(tag: &str) -> PathBuf { dir } +/// Write the project's current-root `codegraph.json` (the only config consulted). fn write_codegraph_json(project: &Path, contents: &str) { - let cg = project.join(".codegraph"); - fs::create_dir_all(&cg).expect("create .codegraph"); - fs::write(cg.join("codegraph.json"), contents).expect("write codegraph.json"); + let paths = IndexPaths::resolve(project, None).expect("resolve index paths"); + fs::create_dir_all(paths.current_root()).expect("create current root"); + fs::write(paths.extension_config(), contents).expect("write codegraph.json"); +} + +/// Load the project's overrides the way the pipeline does. +fn overrides_for(project: &Path) -> Arc { + let paths = IndexPaths::resolve(project, None).expect("resolve index paths"); + ExtensionOverrides::load_for_paths(&paths) } /// (a) A custom extension with NO built-in/embedded mapping maps to the @@ -43,7 +57,10 @@ fn custom_ext_maps_unknown_extension() { let file = project.join("foo.myext"); fs::write(&file, "-- lua").unwrap(); - assert_eq!(detect_language(&file), Language::Lua); + assert_eq!( + detect_language_with(&file, &overrides_for(&project)), + Language::Lua + ); fs::remove_dir_all(&project).ok(); } @@ -53,10 +70,14 @@ fn custom_ext_maps_unknown_extension() { #[test] fn custom_ext_absent_config_is_unknown() { let project = unique_project("b"); - // No .codegraph/codegraph.json at all. + // No current-root codegraph.json at all. let file = project.join("foo.myext"); fs::write(&file, "whatever").unwrap(); + assert_eq!( + detect_language_with(&file, &overrides_for(&project)), + Language::Unknown + ); assert_eq!(detect_language(&file), Language::Unknown); fs::remove_dir_all(&project).ok(); @@ -71,6 +92,7 @@ fn custom_ext_hostile_remap_is_ignored_for_builtins() { &project, r#"{"extensions": {".ts": "lua", ".py": "go", ".rs": "python", ".go": "ruby"}}"#, ); + let overrides = overrides_for(&project); let ts = project.join("x.ts"); let py = project.join("a.py"); let rs = project.join("lib.rs"); @@ -81,17 +103,25 @@ fn custom_ext_hostile_remap_is_ignored_for_builtins() { fs::write(&go, "package main").unwrap(); assert_eq!( - detect_language(&ts), + detect_language_with(&ts, &overrides), Language::TypeScript, ".ts must stay TypeScript" ); assert_eq!( - detect_language(&py), + detect_language_with(&py, &overrides), Language::Python, ".py must stay Python" ); - assert_eq!(detect_language(&rs), Language::Rust, ".rs must stay Rust"); - assert_eq!(detect_language(&go), Language::Go, ".go must stay Go"); + assert_eq!( + detect_language_with(&rs, &overrides), + Language::Rust, + ".rs must stay Rust" + ); + assert_eq!( + detect_language_with(&go, &overrides), + Language::Go, + ".go must stay Go" + ); fs::remove_dir_all(&project).ok(); } @@ -102,18 +132,19 @@ fn custom_ext_hostile_remap_is_ignored_for_builtins() { fn custom_ext_hostile_remap_is_ignored_for_embedded() { let project = unique_project("emb"); write_codegraph_json(&project, r#"{"extensions": {".vue": "lua", ".xml": "go"}}"#); + let overrides = overrides_for(&project); let vue = project.join("widget.vue"); let xml = project.join("mapper.xml"); fs::write(&vue, "").unwrap(); fs::write(&xml, "").unwrap(); assert_eq!( - detect_language(&vue), + detect_language_with(&vue, &overrides), Language::Vue, ".vue stays Vue (embedded)" ); assert_eq!( - detect_language(&xml), + detect_language_with(&xml, &overrides), Language::Xml, ".xml stays Xml (embedded)" ); @@ -121,25 +152,28 @@ fn custom_ext_hostile_remap_is_ignored_for_embedded() { fs::remove_dir_all(&project).ok(); } -/// (d) ADVERSARIAL — mtime recache: changing codegraph.json (new mtime) is -/// picked up on the next detect_language call. +/// (d) ADVERSARIAL — no cache to go stale: rewriting `codegraph.json` and +/// reloading picks up the change immediately, with no mtime dependence (so a +/// same-second edit is honored too). #[test] -fn custom_ext_mtime_recache_picks_up_changes() { +fn custom_ext_reload_picks_up_changes_immediately() { let project = unique_project("d"); write_codegraph_json(&project, r#"{"extensions": {".zz": "lua"}}"#); let file = project.join("foo.zz"); fs::write(&file, "x").unwrap(); - assert_eq!(detect_language(&file), Language::Lua); + assert_eq!( + detect_language_with(&file, &overrides_for(&project)), + Language::Lua + ); - // Ensure the mtime advances enough to be observable across filesystems. - std::thread::sleep(Duration::from_millis(1100)); + // No sleep: the reader never caches, so the very next load sees the change. write_codegraph_json(&project, r#"{"extensions": {".zz": "go"}}"#); assert_eq!( - detect_language(&file), + detect_language_with(&file, &overrides_for(&project)), Language::Go, - "cache must re-read after mtime change" + "an explicit reload must observe the new config" ); fs::remove_dir_all(&project).ok(); @@ -156,7 +190,10 @@ fn custom_ext_malformed_config_is_ignored() { fs::write(&file, "x").unwrap(); // No panic; unmapped extension -> Unknown. - assert_eq!(detect_language(&file), Language::Unknown); + assert_eq!( + detect_language_with(&file, &overrides_for(&project)), + Language::Unknown + ); // Unknown language string is skipped (warning), other valid entries still apply. let project2 = unique_project("e2"); @@ -164,17 +201,18 @@ fn custom_ext_malformed_config_is_ignored() { &project2, r#"{"extensions": {".aa": "not_a_language", ".bb": "lua"}}"#, ); + let overrides2 = overrides_for(&project2); let aa = project2.join("f.aa"); let bb = project2.join("f.bb"); fs::write(&aa, "x").unwrap(); fs::write(&bb, "x").unwrap(); assert_eq!( - detect_language(&aa), + detect_language_with(&aa, &overrides2), Language::Unknown, "unknown lang skipped" ); assert_eq!( - detect_language(&bb), + detect_language_with(&bb, &overrides2), Language::Lua, "valid entry still applies" ); @@ -182,3 +220,34 @@ fn custom_ext_malformed_config_is_ignored() { fs::remove_dir_all(&project).ok(); fs::remove_dir_all(&project2).ok(); } + +/// A LEGACY `.codegraph/codegraph.json` never supplies an override, and neither +/// does another project's config. +#[test] +fn custom_ext_ignores_legacy_and_other_projects() { + let project = unique_project("legacy"); + let other = unique_project("other"); + fs::create_dir_all(project.join(".codegraph")).unwrap(); + fs::write( + project.join(".codegraph/codegraph.json"), + r#"{"extensions": {".myext": "lua"}}"#, + ) + .unwrap(); + write_codegraph_json(&other, r#"{"extensions": {".myext": "go"}}"#); + let file = project.join("foo.myext"); + fs::write(&file, "x").unwrap(); + + assert_eq!( + detect_language_with(&file, &overrides_for(&project)), + Language::Unknown, + "neither the legacy config nor another project's may apply" + ); + assert_eq!( + detect_language_with(&file, &overrides_for(&other)), + Language::Go, + "the other project's own config applies to its own operations" + ); + + fs::remove_dir_all(&project).ok(); + fs::remove_dir_all(&other).ok(); +} diff --git a/crates/codegraph-extract/tests/embedded_languages.rs b/crates/codegraph-extract/tests/embedded_languages.rs index b2326fa..df5d336 100644 --- a/crates/codegraph-extract/tests/embedded_languages.rs +++ b/crates/codegraph-extract/tests/embedded_languages.rs @@ -118,6 +118,105 @@ fn mybatis_extracts_mapper_methods_and_include_refs_on_original_lines() { println!("mybatis original-line assertions passed"); } +#[test] +fn mybatis_accepts_the_ibatis_sqlmap_root_form() { + // Upstream #1182: the iBatis 2 `` root carries the same + // statement elements as MyBatis ``; before this port the root regex + // only matched `` is the iBatis *config* file: it only points at the real + // statement maps, so the `\b` in the root regex must keep the `sqlMap` prefix + // of its name from being read as a statement-map root. The stray `SELECT 1\n", + "\n", + ); + let result = extract_source("sqlMapConfig.xml", source, Some(Language::Xml)); + assert_no_errors(&result); + assert_eq!( + result.nodes.len(), + 1, + "sqlMapConfig keeps only the file node; nodes={:#?}", + result.nodes + ); + assert_eq!(result.nodes[0].kind, NodeKind::File); +} + +#[test] +fn mybatis_qualified_refid_keeps_its_namespace_and_only_splits_the_fragment_id() { + // Upstream #1209: a namespace-qualified `refid` must become the exact + // `{namespace}::{id}` the `` node carries — every dot BEFORE the last one + // belongs to the namespace. Rewriting them all to `::` produced + // `com::example::UserMapper::baseColumns`, a name no node has, so the include + // stayed unresolved. + let result = extract_fixture("qualified_mapper.xml", Some(Language::Xml)); + assert_no_errors(&result); + + assert_node(&result, NodeKind::Method, "baseColumns", 4); + assert_ref( + &result, + EdgeKind::References, + "com.example.UserMapper::baseColumns", + 8, + ); + assert_ref( + &result, + EdgeKind::References, + "com.example.OrderMapper::orderColumns", + 14, + ); + + let bare = result + .unresolved_references + .iter() + .find(|reference| reference.line == 8) + .expect("bare refid on line 8"); + let qualified = result + .unresolved_references + .iter() + .find(|reference| reference.line == 11) + .expect("qualified refid on line 11"); + assert_eq!( + bare.reference_name, qualified.reference_name, + "a bare refid and the same fragment written out in full must name one node" + ); + assert!( + !result + .unresolved_references + .iter() + .any(|reference| reference.reference_name.starts_with("com::example")), + "namespace dots must survive; refs={:#?}", + result.unresolved_references + ); +} + #[test] fn astro_extracts_frontmatter_scripts_and_template_refs_on_original_lines() { // Mirrors upstream extraction/astro-extractor.ts:48-69,123-235. diff --git a/crates/codegraph-extract/tests/fixtures/legacy_sqlmap.xml b/crates/codegraph-extract/tests/fixtures/legacy_sqlmap.xml new file mode 100644 index 0000000..c099b94 --- /dev/null +++ b/crates/codegraph-extract/tests/fixtures/legacy_sqlmap.xml @@ -0,0 +1,13 @@ + + + + + account_id, balance + + + + UPDATE accounts SET balance = #{balance} WHERE account_id = #{id} + + diff --git a/crates/codegraph-extract/tests/fixtures/markup_risk/BrokenForm.dfm b/crates/codegraph-extract/tests/fixtures/markup_risk/BrokenForm.dfm new file mode 100644 index 0000000..f0a9cfe --- /dev/null +++ b/crates/codegraph-extract/tests/fixtures/markup_risk/BrokenForm.dfm @@ -0,0 +1,7 @@ +object BrokenForm: TBrokenForm + Caption = 'Broken' + object OrphanPanel: TPanel + Caption = 'Orphan' + object LostButton: TButton + Caption = 'Lost' + OnClick = LostButtonClick diff --git a/crates/codegraph-extract/tests/fixtures/markup_risk/DeepForm.dfm b/crates/codegraph-extract/tests/fixtures/markup_risk/DeepForm.dfm new file mode 100644 index 0000000..b799af6 --- /dev/null +++ b/crates/codegraph-extract/tests/fixtures/markup_risk/DeepForm.dfm @@ -0,0 +1,33 @@ +object DeepForm: TDeepForm + Caption = 'Deep' + OnCreate = DeepFormCreate + object TopPanel: TPanel + Caption = 'Top' + object InnerPanel: TPanel + Caption = 'Inner' + object DeepButton: TButton + Caption = 'Deep' + OnClick = DeepButtonClick + end + object DeepLabel: TLabel + Caption = 'Label' + end + end + object SiblingButton: TButton + Caption = 'Sibling' + end + end + object BottomPanel: TPanel + Caption = 'Bottom' + object Items: TListView + Columns = < + item + Caption = 'Name' + end + item + Caption = 'Size' + end> + OnDblClick = ItemsDblClick + end + end +end diff --git a/crates/codegraph-extract/tests/fixtures/qualified_mapper.xml b/crates/codegraph-extract/tests/fixtures/qualified_mapper.xml new file mode 100644 index 0000000..55d63ff --- /dev/null +++ b/crates/codegraph-extract/tests/fixtures/qualified_mapper.xml @@ -0,0 +1,16 @@ + + + + + id, name + + + + + diff --git a/crates/codegraph-extract/tests/markup_risk_languages.rs b/crates/codegraph-extract/tests/markup_risk_languages.rs index a749b6d..ca3627e 100644 --- a/crates/codegraph-extract/tests/markup_risk_languages.rs +++ b/crates/codegraph-extract/tests/markup_risk_languages.rs @@ -69,6 +69,115 @@ fn lang_markup_risk_dfm_uses_custom_component_extractor() { ); } +#[test] +fn lang_markup_risk_dfm_spans_close_at_their_own_matching_end() { + // Upstream issue #1350: every DFM object must report the line of ITS OWN + // matching `end`, so a container's span covers its children and a sibling + // never inherits the neighbour's terminator. DeepForm.dfm nests four deep + // (DeepForm > TopPanel > InnerPanel > DeepButton) and puts sibling pairs at + // two different depths. + let result = extract_fixture("DeepForm.dfm", None); + assert!(result.errors.is_empty(), "{:?}", result.errors); + + let mut spans: Vec<(&str, i64, i64)> = result + .nodes + .iter() + .filter(|node| node.kind == NodeKind::Component) + .map(|node| (node.name.as_str(), node.start_line, node.end_line)) + .collect(); + spans.sort(); + assert_eq!( + spans, + vec![ + ("BottomPanel", 20, 32), + ("DeepButton", 8, 11), + ("DeepForm", 1, 33), + ("DeepLabel", 12, 14), + ("InnerPanel", 6, 15), + ("Items", 22, 31), + ("SiblingButton", 16, 18), + ("TopPanel", 4, 19), + ], + "each object must span from its own header line to its own matching end" + ); + + // Depth >= 2 exact spans, stated separately so a regression that only + // breaks deep nesting cannot hide behind the aggregate above. + let inner = assert_node(&result, NodeKind::Component, "InnerPanel"); + assert_eq!((inner.start_line, inner.end_line), (6, 15)); + let deep_button = assert_node(&result, NodeKind::Component, "DeepButton"); + assert_eq!((deep_button.start_line, deep_button.end_line), (8, 11)); + + // Sibling pair at the same depth: an off-by-one that hands DeepButton's + // `end` (11) to DeepLabel, or DeepLabel's (14) to DeepButton, reddens here. + let deep_label = assert_node(&result, NodeKind::Component, "DeepLabel"); + assert_eq!((deep_label.start_line, deep_label.end_line), (12, 14)); + assert!( + deep_button.end_line < deep_label.start_line, + "siblings must not overlap: {:?} vs {:?}", + (deep_button.start_line, deep_button.end_line), + (deep_label.start_line, deep_label.end_line) + ); + + // The multiline `Columns = <...>` block ends with `end>` and `end` lines + // that must NOT close the enclosing objects (dfm-extractor.ts:95-109). + let items = assert_node(&result, NodeKind::Component, "Items"); + assert_eq!((items.start_line, items.end_line), (22, 31)); + + // Nesting is unchanged by the span fix: parents still contain their children. + let file = assert_node(&result, NodeKind::File, "DeepForm.dfm"); + let form = assert_node(&result, NodeKind::Component, "DeepForm"); + let top = assert_node(&result, NodeKind::Component, "TopPanel"); + let sibling = assert_node(&result, NodeKind::Component, "SiblingButton"); + let bottom = assert_node(&result, NodeKind::Component, "BottomPanel"); + assert_contains(&result, &file.id, &form.id); + assert_contains(&result, &form.id, &top.id); + assert_contains(&result, &top.id, &inner.id); + assert_contains(&result, &inner.id, &deep_button.id); + assert_contains(&result, &inner.id, &deep_label.id); + assert_contains(&result, &top.id, &sibling.id); + assert_contains(&result, &form.id, &bottom.id); + assert_contains(&result, &bottom.id, &items.id); + assert_ref_at(&result, EdgeKind::References, "DeepButtonClick", 10); + assert_ref_at(&result, EdgeKind::References, "ItemsDblClick", 30); +} + +#[test] +fn lang_markup_risk_dfm_unterminated_object_keeps_its_header_span() { + // Upstream issue #1350, malformed half: a truncated DFM leaves objects with + // no matching `end`. The extractor must neither panic nor invent a span it + // did not see, so an unclosed object keeps its header line as end_line + // rather than borrowing the file end (7) or a sibling's terminator. + let result = extract_fixture("BrokenForm.dfm", None); + assert!(result.errors.is_empty(), "{:?}", result.errors); + + let mut spans: Vec<(&str, i64, i64)> = result + .nodes + .iter() + .filter(|node| node.kind == NodeKind::Component) + .map(|node| (node.name.as_str(), node.start_line, node.end_line)) + .collect(); + spans.sort(); + assert_eq!( + spans, + vec![ + ("BrokenForm", 1, 1), + ("LostButton", 5, 5), + ("OrphanPanel", 3, 3), + ], + "an unterminated object must not fabricate a span" + ); + + let file = assert_node(&result, NodeKind::File, "BrokenForm.dfm"); + let form = assert_node(&result, NodeKind::Component, "BrokenForm"); + let panel = assert_node(&result, NodeKind::Component, "OrphanPanel"); + let button = assert_node(&result, NodeKind::Component, "LostButton"); + assert_contains(&result, &file.id, &form.id); + assert_contains(&result, &form.id, &panel.id); + assert_contains(&result, &panel.id, &button.id); + assert_ref_at(&result, EdgeKind::References, "LostButtonClick", 7); +} + #[test] fn lang_markup_risk_kotlin_extracts_upstream_symbol_set() { // Golden run: upstream extractFromSource on this exact fixture (kotlin.ts:71-308). diff --git a/crates/codegraph-graph/src/graph/mod.rs b/crates/codegraph-graph/src/graph/mod.rs index 5a1d0ec..b49cb0c 100644 --- a/crates/codegraph-graph/src/graph/mod.rs +++ b/crates/codegraph-graph/src/graph/mod.rs @@ -100,6 +100,18 @@ pub struct PathStep { pub edge: Option, } +/// Queue accounting for one [`GraphTraverser::find_path_instrumented`] BFS +/// (#1359). `enqueued` counts pushes, so a work item enqueued more than once is +/// directly observable from a test; `duplicate_dequeues` counts items that were +/// already `visited` when popped, i.e. wasted pushes that survived to the front +/// of the queue. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +pub struct PathSearchStats { + pub enqueued: usize, + pub dequeued: usize, + pub duplicate_dequeues: usize, +} + /// Sentinel-prefix that L3's `godot_script` resolver stamps onto the /// `reference_name` of a computed (statically-unconfirmable) Godot call-site. /// Mirrors `codegraph_resolve::frameworks::godot_script::DYNAMIC_PREFIX`; @@ -775,14 +787,37 @@ impl<'store> GraphTraverser<'store> { to_id: &str, edge_kinds: &[EdgeKind], ) -> rusqlite::Result>> { + Ok(self.find_path_instrumented(from_id, to_id, edge_kinds)?.0) + } + + /// [`GraphTraverser::find_path`] plus the [`PathSearchStats`] its BFS + /// accumulated (#1359). + /// + /// Enqueue counts are not observable from the returned path, so the + /// enqueue-once guard cannot be regression-tested through `find_path` + /// alone; this is the seam that makes it assertable instead of a log line + /// someone has to read. + pub fn find_path_instrumented( + &self, + from_id: &str, + to_id: &str, + edge_kinds: &[EdgeKind], + ) -> rusqlite::Result<(Option>, PathSearchStats)> { + let mut stats = PathSearchStats::default(); let Some(from_node) = self.store.node_by_id(from_id)? else { - return Ok(None); + return Ok((None, stats)); }; if self.store.node_by_id(to_id)?.is_none() { - return Ok(None); + return Ok((None, stats)); } let mut visited = HashSet::new(); + // #1359: `visited` is set at DEQUEUE, so it cannot stop a fan-in layer + // from pushing the same target once per predecessor — up to k² entries + // (each carrying a cloned path) for a k-wide layer into k shared + // targets. `enqueued` is the separate enqueue-once guard, mirroring + // `traverse_bfs`'s #1090 fix, and is seeded with the start node. + let mut enqueued: HashSet = HashSet::from([from_id.to_string()]); let mut queue: VecDeque<(String, Vec)> = VecDeque::new(); queue.push_back(( from_id.to_string(), @@ -791,12 +826,15 @@ impl<'store> GraphTraverser<'store> { edge: None, }], )); + stats.enqueued += 1; while let Some((node_id, path)) = queue.pop_front() { + stats.dequeued += 1; if node_id == to_id { - return Ok(Some(path)); + return Ok((Some(path), stats)); } if visited.contains(&node_id) { + stats.duplicate_dequeues += 1; continue; } visited.insert(node_id.clone()); @@ -808,12 +846,13 @@ impl<'store> GraphTraverser<'store> { let want_ids: Vec = outgoing .iter() .map(|e| e.target.clone()) - .filter(|id| !visited.contains(id)) + .filter(|id| !visited.contains(id) && !enqueued.contains(id)) .collect(); let next_nodes = self.store.nodes_by_ids(&want_ids)?; for edge in outgoing { if !visited.contains(&edge.target) + && !enqueued.contains(&edge.target) && let Some(next_node) = next_nodes.get(&edge.target) { let mut next_path = path.clone(); @@ -821,12 +860,14 @@ impl<'store> GraphTraverser<'store> { node: next_node.clone(), edge: Some(edge.clone()), }); + enqueued.insert(edge.target.clone()); queue.push_back((edge.target.clone(), next_path)); + stats.enqueued += 1; } } } - Ok(None) + Ok((None, stats)) } /// Ports `getAncestors` from `upstream graph/traversal.ts:615-645`. diff --git a/crates/codegraph-graph/src/query/mod.rs b/crates/codegraph-graph/src/query/mod.rs index e18859f..7d678f7 100644 --- a/crates/codegraph-graph/src/query/mod.rs +++ b/crates/codegraph-graph/src/query/mod.rs @@ -9,6 +9,17 @@ use codegraph_store::queries::SearchResult; pub use parser::{ParsedQuery, bounded_edit_distance, parse_query}; +/// #1319 infix-seeding bounds: how many callable candidates the store returns +/// per token, and how many survive the segment-boundary filter into the result +/// set. Both are hard caps so a hot substring can never flood the ranking. +const INFIX_CANDIDATE_CAP: i64 = 50; +const INFIX_SEED_CAP: usize = 3; + +/// Base score for an infix-seeded definer. Deliberately small: the definer earns +/// its rank from the `kind_bonus` + `name_match_bonus` pass that follows, so it +/// can never outrank a genuine exact-name hit. +const INFIX_SEED_SCORE: f64 = 1.0; + #[derive(Debug, Clone, Default)] pub struct SearchOptions { pub kinds: Vec, @@ -92,6 +103,8 @@ pub fn search_nodes( } } + seed_multi_segment_definers(store, query, &kinds, &mut results)?; + if !results.is_empty() && (!text.is_empty() || !query.is_empty()) { let scoring_query = if !text.is_empty() { &text } else { query }; for result in &mut results { @@ -135,6 +148,66 @@ fn sort_by_score_desc(results: &mut [SearchResult]) { }); } +/// Multi-hump field-name seeding (upstream `1de7e8f` #1319). +/// +/// A query token that names a multi-SEGMENT field (`userProfileId`, +/// `user_profile_id`, `ProfileInfo`) but matches no symbol EXACTLY is seeded +/// with the callables that DEFINE it — the ones whose own segments contain the +/// query's segment run contiguously. Scoped to multi-segment tokens so the +/// natural-language guard on bare words is untouched, and applied only when the +/// token is not already an exact symbol name so exact matches always win. +/// +/// A candidate whose name merely CONTAINS the lowercase run without a segment +/// boundary (`xxprofileinfoxx`) is rejected: a silent miss beats a wrong answer. +fn seed_multi_segment_definers( + store: &Store, + query: &str, + kinds: &[NodeKind], + results: &mut Vec, +) -> rusqlite::Result<()> { + let mut existing: HashSet = results.iter().map(|r| r.node.id.clone()).collect(); + for token in query.split_whitespace() { + if !scoring::is_multi_segment_identifier(token) { + continue; + } + if results.iter().any(|r| r.node.name == token) { + continue; + } + if !store.nodes_by_lower_name(&token.to_lowercase())?.is_empty() { + continue; + } + let needle: String = token + .chars() + .filter(|c| c.is_alphanumeric()) + .collect::() + .to_lowercase(); + if needle.is_empty() { + continue; + } + let mut seeded = 0usize; + for node in store.callable_nodes_by_name_infix(&needle, INFIX_CANDIDATE_CAP)? { + if seeded >= INFIX_SEED_CAP { + break; + } + if !kinds.is_empty() && !kinds.contains(&node.kind) { + continue; + } + if !scoring::name_segments_contain_run(&node.name, token) { + continue; + } + if !existing.insert(node.id.clone()) { + continue; + } + results.push(SearchResult { + node, + score: INFIX_SEED_SCORE, + }); + seeded += 1; + } + } + Ok(()) +} + fn search_nodes_fuzzy( store: &Store, text: &str, diff --git a/crates/codegraph-graph/src/query/scoring.rs b/crates/codegraph-graph/src/query/scoring.rs index 571d0cc..ca1cab1 100644 --- a/crates/codegraph-graph/src/query/scoring.rs +++ b/crates/codegraph-graph/src/query/scoring.rs @@ -5,10 +5,25 @@ use codegraph_core::types::NodeKind; pub fn normalize_name_token(raw: &str) -> String { raw.to_lowercase() .chars() - .filter(|c| c.is_ascii_alphanumeric()) + .filter(|c| c.is_alphanumeric()) .collect() } +/// Minimum length for a scoring token (#1372). +/// +/// An ASCII word shorter than three characters carries no retrieval signal +/// ("of", "is", "at"), which is why the floor exists. An unsegmented script +/// packs a whole word into two characters — `模块` IS "module" — so a token +/// holding any non-ASCII character is admitted at two. ASCII tokens keep the +/// original three-character floor byte-for-byte. +fn meets_min_token_len(token: &str) -> bool { + let len = token.chars().count(); + if len >= 3 { + return true; + } + len >= 2 && !token.is_ascii() +} + pub const STOP_WORDS: &[&str] = &[ "the", "a", @@ -336,12 +351,12 @@ pub fn extract_search_terms(query: &str, include_stems: bool) -> Vec { .map(|c| if c == '_' || c == '.' { ' ' } else { c }) .collect(); - for word in normalised.split(|c: char| !c.is_ascii_alphanumeric()) { + for word in normalised.split(|c: char| !c.is_alphanumeric()) { if word.is_empty() { continue; } let lower = word.to_lowercase(); - if lower.chars().count() < 3 { + if !meets_min_token_len(&lower) { continue; } if is_stop_word(&lower) { @@ -645,6 +660,62 @@ pub fn kind_bonus(kind: NodeKind) -> f64 { } } +/// Split an identifier into its lowercase word segments (#1319). +/// +/// Non-alphanumerics separate runs (`user_profile_id`, `order-state`, +/// `file.name`); inside a run, a lowercase/digit→uppercase transition is a +/// camelCase hump (`orderState` → order|state) and an uppercase followed by +/// uppercase+lowercase closes an acronym (`HTMLParser` → html|parser). Digits +/// stay glued to their word (`getInfoV2` → get|info|v2). +pub fn identifier_segments(raw: &str) -> Vec { + let mut out: Vec = Vec::new(); + for run in raw.split(|c: char| !c.is_alphanumeric()) { + if run.is_empty() { + continue; + } + let chars: Vec = run.chars().collect(); + let mut current = String::new(); + for i in 0..chars.len() { + if i > 0 { + let prev = chars[i - 1]; + let cur = chars[i]; + let hump = (prev.is_lowercase() || prev.is_numeric()) && cur.is_uppercase(); + let acronym = prev.is_uppercase() + && cur.is_uppercase() + && chars.get(i + 1).is_some_and(|n| n.is_lowercase()); + if (hump || acronym) && !current.is_empty() { + out.push(std::mem::take(&mut current).to_lowercase()); + } + } + current.push(chars[i]); + } + if !current.is_empty() { + out.push(current.to_lowercase()); + } + } + out +} + +/// Whether a query token names a MULTI-SEGMENT field (#1319): `userProfileId`, +/// `user_profile_id`, `ProfileInfo` — but not a bare word like `profile`, which +/// must keep its natural-language stop-word guard. +pub fn is_multi_segment_identifier(token: &str) -> bool { + identifier_segments(token).len() >= 2 +} + +/// Whether `name`'s segments CONTAIN the query's segment run contiguously +/// (#1319) — the hump-boundary filter that separates a real definer +/// (`getProfileInfoV2` for `profileInfo`) from an incidental namesake +/// (`xxprofileinfoxx`, one unsegmented word). +pub fn name_segments_contain_run(name: &str, query: &str) -> bool { + let want = identifier_segments(query); + if want.is_empty() { + return false; + } + let have = identifier_segments(name); + have.windows(want.len()).any(|w| w == want.as_slice()) +} + pub fn is_distinctive_identifier(token: &str) -> bool { if token.is_empty() { return false; @@ -877,6 +948,61 @@ mod tests { assert_eq!(kind_bonus(NodeKind::Interface), 9.0); } + #[test] + fn identifier_segments_splits_humps_snake_and_acronyms() { + assert_eq!( + identifier_segments("userProfileId"), + ["user", "profile", "id"] + ); + assert_eq!( + identifier_segments("user_profile_id"), + ["user", "profile", "id"] + ); + assert_eq!(identifier_segments("order-state"), ["order", "state"]); + assert_eq!(identifier_segments("HTMLParser"), ["html", "parser"]); + assert_eq!(identifier_segments("getInfoV2"), ["get", "info", "v2"]); + assert_eq!(identifier_segments("plain"), ["plain"]); + assert!(identifier_segments("").is_empty()); + assert!(identifier_segments("__").is_empty()); + } + + #[test] + fn is_multi_segment_identifier_only_for_multi_segment_tokens() { + assert!(is_multi_segment_identifier("userProfileId")); + assert!(is_multi_segment_identifier("user_profile_id")); + assert!(is_multi_segment_identifier("ProfileInfo")); + assert!(!is_multi_segment_identifier("profile")); + assert!(!is_multi_segment_identifier("PROFILE")); + assert!(!is_multi_segment_identifier("")); + } + + #[test] + fn name_segments_contain_run_requires_a_segment_boundary() { + // A real definer: the query's segment run appears contiguously. + assert!(name_segments_contain_run("getProfileInfoV2", "profileInfo")); + assert!(name_segments_contain_run( + "getProfileInfoV2", + "profile_info" + )); + assert!(name_segments_contain_run( + "updateUserProfileIdMapping", + "userProfileId" + )); + assert!(name_segments_contain_run("load_order_state", "orderState")); + // An incidental namesake: the lowercase run is there, the boundary is not. + assert!(!name_segments_contain_run("xxprofileinfoxx", "profileInfo")); + assert!(!name_segments_contain_run( + "xxuserprofileidxx", + "userProfileId" + )); + // Non-contiguous segments are not a match either. + assert!(!name_segments_contain_run( + "profileOtherInfo", + "profileInfo" + )); + assert!(!name_segments_contain_run("anything", "")); + } + #[test] fn is_distinctive_identifier_rules() { assert!(!is_distinctive_identifier("")); diff --git a/crates/codegraph-graph/tests/enqueue_once.rs b/crates/codegraph-graph/tests/enqueue_once.rs new file mode 100644 index 0000000..fdb18bb --- /dev/null +++ b/crates/codegraph-graph/tests/enqueue_once.rs @@ -0,0 +1,237 @@ +//! Upstream #1359 — `find_path`'s BFS must enqueue each work item EXACTLY once. +//! +//! `traverse_bfs` got a separate `enqueued` guard in #1090; `find_path` did not. +//! Its `visited` set is populated at DEQUEUE, so on a fan-in layer (k nodes all +//! pointing into the same k targets — a shared-helper hub, common in real call +//! graphs) the same target is pushed once per predecessor, up to k² pushes for +//! that transition, each carrying a cloned path array. +//! +//! The shortest path itself stays correct, so the defect is invisible from +//! `find_path`'s return value. These tests assert on +//! `find_path_instrumented`'s queue accounting instead, which is why the +//! instrumentation is part of the fix rather than a log line. + +use codegraph_core::types::{Edge, EdgeKind, Language, Node, NodeKind}; +use codegraph_graph::graph::GraphTraverser; +use codegraph_store::Store; + +fn temp_db_path(label: &str) -> std::path::PathBuf { + let mut path = std::env::temp_dir(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + path.push(format!( + "codegraph-enqueue-once-{label}-{}-{nanos}.db", + std::process::id() + )); + path +} + +fn node(id: &str, name: &str) -> Node { + Node { + id: id.to_string(), + kind: NodeKind::Function, + name: name.to_string(), + qualified_name: name.to_string(), + file_path: "src/hub.ts".to_string(), + language: Language::TypeScript, + start_line: 1, + end_line: 2, + start_column: 0, + end_column: 0, + docstring: None, + signature: None, + visibility: None, + is_exported: false, + is_async: false, + is_static: false, + is_abstract: false, + decorators: Vec::new(), + type_parameters: Vec::new(), + return_type: None, + updated_at: 1, + } +} + +fn edge(source: &str, target: &str) -> Edge { + Edge { + id: None, + source: source.to_string(), + target: target.to_string(), + kind: EdgeKind::Calls, + metadata: None, + line: Some(3), + col: Some(0), + provenance: None, + } +} + +/// A fan-in graph: `root` → `mid0..mid{k-1}`, and EVERY mid → EVERY +/// `sink0..sink{k-1}`. `target` hangs off `sink0` so the search must traverse +/// the whole fan-in layer before finding it. +fn fan_in_store(label: &str, k: usize) -> (Store, usize) { + let mut store = Store::open(&temp_db_path(label)).expect("open store"); + let mut nodes = vec![ + node("function:root", "root"), + node("function:target", "target"), + ]; + for i in 0..k { + nodes.push(node(&format!("function:mid{i}"), &format!("mid{i}"))); + nodes.push(node(&format!("function:sink{i}"), &format!("sink{i}"))); + } + let reachable = nodes.len(); + store.upsert_nodes(&nodes).expect("insert nodes"); + + let mut edges = Vec::new(); + for i in 0..k { + edges.push(edge("function:root", &format!("function:mid{i}"))); + for j in 0..k { + edges.push(edge( + &format!("function:mid{i}"), + &format!("function:sink{j}"), + )); + } + } + edges.push(edge("function:sink0", "function:target")); + store.insert_edges(&edges).expect("insert edges"); + (store, reachable) +} + +/// The enqueue-once contract: over a fan-in hub, the number of pushes must be +/// bounded by the number of distinct nodes reached — never by the edge count. +#[test] +fn find_path_enqueues_each_work_item_exactly_once_over_a_fan_in_hub() { + let k = 8; + let (store, reachable) = fan_in_store("fanin", k); + let traverser = GraphTraverser::new(&store); + + let (path, stats) = traverser + .find_path_instrumented("function:root", "function:target", &[EdgeKind::Calls]) + .expect("find_path_instrumented"); + + assert!(path.is_some(), "the fan-in graph does contain a path"); + assert!( + stats.enqueued <= reachable, + "each work item must be enqueued at most once: enqueued {} for {reachable} reachable nodes (fan-in edges: {}) — stats {stats:?}", + stats.enqueued, + k * k + k + 1 + ); + assert_eq!( + stats.duplicate_dequeues, 0, + "a correctly guarded queue never dequeues an already-visited item: {stats:?}" + ); +} + +/// The same contract on a diamond — the smallest shape that double-enqueues. +#[test] +fn find_path_does_not_re_enqueue_a_shared_successor_in_a_diamond() { + let mut store = Store::open(&temp_db_path("diamond")).expect("open store"); + store + .upsert_nodes(&[ + node("function:a", "a"), + node("function:b", "b"), + node("function:c", "c"), + node("function:d", "d"), + node("function:e", "e"), + ]) + .expect("insert nodes"); + store + .insert_edges(&[ + edge("function:a", "function:b"), + edge("function:a", "function:c"), + edge("function:b", "function:d"), + edge("function:c", "function:d"), + edge("function:d", "function:e"), + ]) + .expect("insert edges"); + + let traverser = GraphTraverser::new(&store); + let (path, stats) = traverser + .find_path_instrumented("function:a", "function:e", &[EdgeKind::Calls]) + .expect("find_path_instrumented"); + + assert!(path.is_some(), "a→b→d→e exists"); + assert_eq!( + stats.enqueued, 5, + "a, b, c, d, e — `d` is reachable from both b and c but must be enqueued ONCE: {stats:?}" + ); + assert_eq!(stats.duplicate_dequeues, 0, "{stats:?}"); +} + +/// The guard must not change the ANSWER: the shortest path is still the shortest +/// path, and an unreachable target is still unreachable. +#[test] +fn enqueue_once_guard_preserves_the_shortest_path_and_the_no_path_answer() { + let mut store = Store::open(&temp_db_path("shortest")).expect("open store"); + store + .upsert_nodes(&[ + node("function:a", "a"), + node("function:short", "short"), + node("function:long1", "long1"), + node("function:long2", "long2"), + node("function:z", "z"), + node("function:island", "island"), + ]) + .expect("insert nodes"); + store + .insert_edges(&[ + edge("function:a", "function:long1"), + edge("function:long1", "function:long2"), + edge("function:long2", "function:z"), + edge("function:a", "function:short"), + edge("function:short", "function:z"), + ]) + .expect("insert edges"); + + let traverser = GraphTraverser::new(&store); + let path = traverser + .find_path("function:a", "function:z", &[EdgeKind::Calls]) + .expect("find_path") + .expect("a path exists"); + let names: Vec<&str> = path.iter().map(|s| s.node.name.as_str()).collect(); + assert_eq!( + names, + vec!["a", "short", "z"], + "the shortest of two routes must still win" + ); + + assert!( + traverser + .find_path("function:a", "function:island", &[EdgeKind::Calls]) + .expect("find_path") + .is_none(), + "an unreachable target must still report no path" + ); +} + +/// A cycle must still terminate with the guard in place. +#[test] +fn enqueue_once_guard_still_terminates_on_a_cycle() { + let mut store = Store::open(&temp_db_path("cycle")).expect("open store"); + store + .upsert_nodes(&[ + node("function:a", "a"), + node("function:b", "b"), + node("function:c", "c"), + node("function:unreached", "unreached"), + ]) + .expect("insert nodes"); + store + .insert_edges(&[ + edge("function:a", "function:b"), + edge("function:b", "function:c"), + edge("function:c", "function:a"), + ]) + .expect("insert edges"); + + let traverser = GraphTraverser::new(&store); + let (path, stats) = traverser + .find_path_instrumented("function:a", "function:unreached", &[EdgeKind::Calls]) + .expect("find_path_instrumented"); + assert!(path.is_none(), "`unreached` has no incoming edge"); + assert_eq!( + stats.enqueued, 3, + "a, b, c each enqueued once despite the cycle: {stats:?}" + ); +} diff --git a/crates/codegraph-mcp/Cargo.toml b/crates/codegraph-mcp/Cargo.toml index 26feebb..060c05b 100644 --- a/crates/codegraph-mcp/Cargo.toml +++ b/crates/codegraph-mcp/Cargo.toml @@ -52,7 +52,7 @@ tokio-util = { version = "0.7" } # dependency of an integration test binary, so a Cargo feature (auto-enabled for # this crate's own dev/test builds via the dev-dependency self-reference below) # is the only reliable gate. NEVER enabled in the shipped binary. -test-hooks = [] +test-hooks = ["codegraph-store/test-hooks"] [dev-dependencies] # Self-reference with `test-hooks` so the seams above are compiled into the lib diff --git a/crates/codegraph-mcp/src/engine.rs b/crates/codegraph-mcp/src/engine.rs index 1e93d79..c3382b0 100644 --- a/crates/codegraph-mcp/src/engine.rs +++ b/crates/codegraph-mcp/src/engine.rs @@ -9,7 +9,10 @@ use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use codegraph_core::config::Config; use codegraph_core::types::{EdgeKind, FileRecord, Node, NodeKind}; use codegraph_graph::graph::{GodotReach, GraphTraverser, NodeEdge}; use codegraph_graph::query::{SearchOptions, search_nodes}; @@ -59,26 +62,62 @@ const TYPE_KINDS: [NodeKind; 7] = [ /// for forward-compatibility, but extraction-widening is DEFERRED per the plan). const SIG_EDGE_KINDS: [EdgeKind; 3] = [EdgeKind::References, EdgeKind::TypeOf, EdgeKind::Returns]; -/// Holds an opened project store. One engine per project path; the server keeps -/// a cache keyed by resolved project path (mirrors `ToolHandler.projectCache`, -/// `tools.ts:591`). +/// Holds one request-scoped project store. Its retained shared lease remains +/// live until the request has produced its final result. pub struct CodeGraphEngine { store: Store, project_root: PathBuf, + /// The ADDRESSED project's own immutable config, loaded per request from its + /// resolved current index root. A server that answers for many projects (a + /// global HTTP process) therefore honors each project's own settings, and + /// nothing here reads a process-global value or a legacy `.codegraph` config. + config: Arc, } impl CodeGraphEngine { - /// Open the store at `/.codegraph/codegraph.db` - /// (`upstream directory.ts`; learnings Task 4). + const READ_LEASE_TIMEOUT: Duration = Duration::from_secs(30); + + /// Open the store at the project's current (v2) index DB, resolved + /// fail-closed through the single `codegraph-core::IndexPaths` authority + /// (`.codegraph-v2/codegraph.db` by default; a `-v2-` + /// sibling for a configured `CODEGRAPH_DIR`). An unsafe/aliased/overlapping + /// configured root errors here rather than opening a reconstructed path. pub fn open(project_root: &Path) -> anyhow::Result { - let db_path = project_root.join(".codegraph").join("codegraph.db"); - let store = Store::open(&db_path)?; + let paths = codegraph_core::IndexPaths::resolve( + project_root, + std::env::var("CODEGRAPH_DIR").ok().as_deref(), + )?; + let config = Config::load_for_paths(None, &paths)?; + let store = + Store::open_for_read(&paths, Instant::now() + Self::READ_LEASE_TIMEOUT, || false)?; Ok(Self { store, project_root: project_root.to_path_buf(), + config, }) } + /// This project's own immutable configuration for the current request. + pub fn config(&self) -> &Config { + &self.config + } + + /// Read an indexed file's on-disk source, refusing anything larger than THIS + /// project's `indexing.max_file_size`. + /// + /// Extraction skips a file over that limit, so its symbols are not in the + /// graph; serving its full text through a graph tool would contradict the + /// project's own size policy. `None` therefore means "unreadable or beyond + /// this project's limit", and every rendering path treats it exactly as it + /// already treats an unreadable file. + fn read_project_source(&self, abs: &Path) -> Option { + let metadata = fs::metadata(abs).ok()?; + if metadata.len() > self.config.indexing.max_file_size { + return None; + } + fs::read_to_string(abs).ok() + } + fn project_name_tokens(&self) -> HashSet { HashSet::new() } @@ -358,6 +397,29 @@ impl CodeGraphEngine { "Symbol \"{symbol}\" not found in the codebase" ))); } + // `symbol` + `file` (#1314): the schema promises `file` PINS an + // overloaded name to the definition in that file. A pin that matches + // nothing reports not-found rather than falling back to an arbitrary + // overload — a silent miss beats a wrong answer. + if let Some(file_hint) = file_hint { + let pinned: Vec = matches + .iter() + .filter(|n| file_path_matches_hint(&n.file_path, file_hint)) + .cloned() + .collect(); + if pinned.is_empty() { + return Ok(ToolResult::not_found_text(format!( + "Symbol \"{symbol}\" not found in \"{file_hint}\"" + ))); + } + if pinned.len() == 1 { + let rendered = self.render_node_section(&pinned[0], include_code)?; + return Ok(ToolResult::text(truncate_output(&rendered))); + } + return Ok(ToolResult::text(truncate_output( + &self.render_ambiguous_node(symbol, &pinned, include_code)?, + ))); + } if matches.len() == 1 { let rendered = self.render_node_section(&matches[0], include_code)?; return Ok(ToolResult::text(truncate_output(&rendered))); @@ -566,9 +628,9 @@ impl CodeGraphEngine { } let abs = self.project_root.join(&file_path); - let content = match fs::read_to_string(&abs) { - Ok(c) => c, - Err(_) => { + let content = match self.read_project_source(&abs) { + Some(c) => c, + None => { let mut out = vec![ format!( "**{file_path}** — could not read from disk (it may have moved since indexing). {dep_summary}" @@ -731,9 +793,9 @@ impl CodeGraphEngine { continue; } let abs = self.project_root.join(file_path); - let content = match fs::read_to_string(&abs) { - Ok(c) => c, - Err(_) => continue, + let content = match self.read_project_source(&abs) { + Some(c) => c, + None => continue, }; let file_lines: Vec<&str> = content.split('\n').collect(); let lang = subgraph.file_language(file_path); @@ -1525,9 +1587,9 @@ impl CodeGraphEngine { } seen_node.insert(node.id.as_str()); let abs = self.project_root.join(&node.file_path); - let content = match fs::read_to_string(&abs) { - Ok(c) => c, - Err(_) => continue, + let content = match self.read_project_source(&abs) { + Some(c) => c, + None => continue, }; let file_lines: Vec<&str> = content.split('\n').collect(); let start_idx = ((node.start_line - 1).max(0) as usize).min(file_lines.len()); @@ -1739,9 +1801,9 @@ impl CodeGraphEngine { /// out of the on-disk file (1-based inclusive). fn get_code(&self, node: &Node) -> anyhow::Result> { let abs = self.project_root.join(&node.file_path); - let content = match fs::read_to_string(&abs) { - Ok(c) => c, - Err(_) => return Ok(None), + let content = match self.read_project_source(&abs) { + Some(c) => c, + None => return Ok(None), }; let lines: Vec<&str> = content.split('\n').collect(); let start_idx = (node.start_line - 1).max(0) as usize; @@ -2329,6 +2391,28 @@ fn basename(path: &str) -> &str { path.rsplit('/').next().unwrap_or(path) } +/// Whether an indexed `file_path` is the file a `codegraph_node` `file` hint +/// names (#1314). Accepts the exact repo-relative path, a path suffix on a +/// segment boundary (`auth/session.ts` for `src/auth/session.ts`), or a bare +/// basename (`session.ts`). Separators are normalized so a Windows-style hint +/// pins the same node as its POSIX form. +fn file_path_matches_hint(file_path: &str, hint: &str) -> bool { + let normalize = |s: &str| s.replace('\\', "/").trim_start_matches("./").to_string(); + let path = normalize(file_path); + let hint = normalize(hint); + if hint.is_empty() { + return false; + } + if path == hint { + return true; + } + if !hint.contains('/') { + return path.rsplit('/').next() == Some(hint.as_str()); + } + path.strip_suffix(hint.as_str()) + .is_some_and(|prefix| prefix.ends_with('/')) +} + /// `matchesSymbol` (`tools.ts:3175-3210`). fn matches_symbol(node: &Node, symbol: &str) -> bool { if node.name == symbol { @@ -2664,6 +2748,7 @@ mod tests { CodeGraphEngine { store, project_root: base, + config: Arc::new(Config::default()), } } @@ -3105,6 +3190,90 @@ mod tests { assert_eq!(tr.is_error, Some(true)); } + #[test] + fn file_path_matches_hint_accepts_exact_suffix_and_basename_only() { + assert!(file_path_matches_hint( + "src/auth/session.ts", + "src/auth/session.ts" + )); + assert!(file_path_matches_hint( + "src/auth/session.ts", + "auth/session.ts" + )); + assert!(file_path_matches_hint("src/auth/session.ts", "session.ts")); + assert!(file_path_matches_hint( + "src/auth/session.ts", + "./src/auth/session.ts" + )); + // Windows-style hint pins the same node as its POSIX form. + assert!(file_path_matches_hint( + "src/auth/session.ts", + "auth\\session.ts" + )); + // A suffix that does not land on a segment boundary is NOT a match. + assert!(!file_path_matches_hint( + "src/myauth/session.ts", + "auth/session.ts" + )); + // A basename hint must match the basename, not a substring of it. + assert!(!file_path_matches_hint( + "src/auth/mysession.ts", + "session.ts" + )); + assert!(!file_path_matches_hint("src/auth/session.ts", "other.ts")); + assert!(!file_path_matches_hint("src/auth/session.ts", "")); + } + + #[test] + fn ext_node_symbol_plus_file_pins_to_that_definition_and_reports_a_bad_pin() { + let mut engine = test_engine(); + let alpha = node_lang( + "setState", + "setState", + "src/alpha.ts", + 1, + 4, + NodeKind::Function, + Language::TypeScript, + ); + let beta = node_lang( + "setState", + "setState", + "src/beta.ts", + 1, + 4, + NodeKind::Function, + Language::TypeScript, + ); + put_nodes(&mut engine, &[alpha, beta]); + + let pinned = engine.execute( + "codegraph_node", + &serde_json::json!({"symbol": "setState", "file": "src/beta.ts", "includeCode": true}), + ); + let txt = text_of(&pinned); + assert!(txt.contains("src/beta.ts"), "got: {txt}"); + assert!( + !txt.contains("src/alpha.ts"), + "the pin must exclude the other overload: {txt}" + ); + assert!( + !txt.contains("definitions named"), + "a resolved pin renders one definition, not the ambiguity list: {txt}" + ); + + let bad = engine.execute( + "codegraph_node", + &serde_json::json!({"symbol": "setState", "file": "src/nowhere.ts"}), + ); + assert_eq!(bad.not_found, Some(true)); + assert!( + text_of(&bad).contains("not found in \"src/nowhere.ts\""), + "got: {}", + text_of(&bad) + ); + } + #[test] fn ext_node_missing_symbol_errors() { let engine = test_engine(); @@ -3865,11 +4034,14 @@ mod tests { std::process::id(), TEMP_SEQ.fetch_add(1, Ordering::Relaxed) )); - std::fs::create_dir_all(base.join(".codegraph")).unwrap(); + // Batch M: `CodeGraphEngine::open` reads the isolated v2 namespace. + std::fs::create_dir_all(base.join(".codegraph-v2")).unwrap(); { - let db = base.join(".codegraph").join("codegraph.db"); + let db = base.join(".codegraph-v2").join("codegraph.db"); Store::open(&db).unwrap(); } + let paths = codegraph_core::IndexPaths::resolve(&base, None).unwrap(); + codegraph_store::test_support::finalize_current_test_fixture(&paths).unwrap(); let engine = CodeGraphEngine::open(&base).unwrap(); let tr = engine.execute("codegraph_status", &serde_json::json!({})); assert!(text_of(&tr).contains("## CodeGraph Status")); diff --git a/crates/codegraph-mcp/src/rmcp_handler.rs b/crates/codegraph-mcp/src/rmcp_handler.rs index 851fe34..fba383c 100644 --- a/crates/codegraph-mcp/src/rmcp_handler.rs +++ b/crates/codegraph-mcp/src/rmcp_handler.rs @@ -7,14 +7,11 @@ //! //! ## Sync engine bridge //! -//! [`CodeGraphEngine`] wraps a `rusqlite::Connection` — `Send + !Sync`. rmcp -//! handler futures are `Send + 'static`, so a `&CodeGraphEngine` borrowed -//! through the cache mutex may NOT cross an `.await`, and a `spawn_blocking` -//! closure (`'static + Send`) cannot borrow `&self`. `call_tool` therefore does -//! the WHOLE "open-or-get-cached engine + execute + render to an OWNED -//! [`ToolResult`]" inside ONE `spawn_blocking` closure: it moves in an `Arc` -//! clone of the cache + owned project path + owned args and returns an owned -//! result. No engine borrow crosses the closure boundary. +//! [`CodeGraphEngine`] wraps a `rusqlite::Connection` — `Send + !Sync` — and is +//! deliberately request-scoped so its retained shared lease spans the complete +//! query but never survives into the next request. `call_tool` therefore does +//! the WHOLE "open + execute + render to an OWNED [`ToolResult`]" inside ONE +//! `spawn_blocking` closure. No engine borrow crosses the closure boundary. //! //! ## Panic isolation //! @@ -23,7 +20,6 @@ //! to an `isError` [`CallToolResult`] — a tool bug returns an error and the //! process/runtime stays alive (parity with the sync stdio server). -use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -40,7 +36,7 @@ use serde_json::{Value, json}; use crate::engine::CodeGraphEngine; use crate::instructions::SERVER_INSTRUCTIONS; use crate::protocol::ToolResult; -use crate::roots::{WorkspaceRoots, db_path_for}; +use crate::roots::{WorkspaceRoots, db_exists_for, db_path_for}; use crate::schemas; const SERVER_NAME: &str = "codegraph"; @@ -101,20 +97,17 @@ fn tool_timeout() -> Option { parse_tool_timeout(std::env::var(TOOL_TIMEOUT_ENV).ok().as_deref()) } -type EngineCache = Arc>>; - /// The default project may be DISPLACED at runtime by roots adoption /// (`on_initialized`, non-pinned mode), and adoption runs through a `&self` /// handler, so it lives behind a `Mutex` for interior mutability. In pinned / /// `no_roots` mode it never changes after construction. type DefaultProject = Arc>>; -/// rmcp handler state: the shared engine cache plus the default project / -/// cwd used to resolve a per-call `projectPath`. `no_roots` mirrors the +/// rmcp handler state: the default project / cwd used to resolve a per-call +/// `projectPath`. `no_roots` mirrors the /// [`crate::McpServer::http`] pin — when set, roots adoption is OFF (HTTP / /// pinned mode); when clear, `on_initialized` may adopt an indexed client root. pub struct CodeGraphHandler { - engines: EngineCache, default_project: DefaultProject, cwd: Option, no_roots: bool, @@ -123,7 +116,6 @@ pub struct CodeGraphHandler { impl CodeGraphHandler { pub fn new(default_project: Option) -> Self { Self { - engines: Arc::new(Mutex::new(HashMap::new())), default_project: Arc::new(Mutex::new(default_project)), cwd: std::env::current_dir().ok(), no_roots: true, @@ -146,7 +138,6 @@ impl CodeGraphHandler { /// cwd-derived, possibly unindexed dir. pub fn serve_with_roots(default_project: Option, cwd: Option) -> Self { Self { - engines: Arc::new(Mutex::new(HashMap::new())), default_project: Arc::new(Mutex::new(default_project)), cwd, no_roots: false, @@ -159,7 +150,6 @@ impl CodeGraphHandler { #[doc(hidden)] pub fn new_with_cwd(default_project: Option, cwd: Option) -> Self { Self { - engines: Arc::new(Mutex::new(HashMap::new())), default_project: Arc::new(Mutex::new(default_project)), cwd, no_roots: true, @@ -178,36 +168,20 @@ impl CodeGraphHandler { fn has_default_codegraph(&self) -> bool { self.default_project_snapshot() .as_ref() - .is_some_and(|p| db_path_for(p).is_file()) + .is_some_and(|p| db_exists_for(p)) } - /// Resolve a caller's `projectPath` to an INDEXED project dir, byte-for-byte - /// the same candidate order as [`crate::McpServer`]'s `resolve_project_arg` - /// (server.rs:568): absolute raw → cwd-join → bare raw → default-by-basename; - /// `None` raw → the indexed default. Returns `None` when nothing resolves. - fn resolve_project_arg(&self, raw: Option<&str>) -> Option { - let default_project = self.default_project_snapshot(); - let Some(raw) = raw else { - return default_project.filter(|p| db_path_for(p).is_file()); - }; - let raw_path = PathBuf::from(raw); - let mut candidates: Vec = Vec::new(); - if raw_path.is_absolute() { - candidates.push(raw_path.clone()); - } else { - if let Some(cwd) = &self.cwd { - candidates.push(cwd.join(&raw_path)); - } - candidates.push(raw_path.clone()); - } - if let Some(default) = &default_project - && raw_path.file_name() == default.file_name() - { - candidates.push(default.clone()); - } - candidates - .into_iter() - .find(|candidate| db_path_for(candidate).is_file()) + /// Resolve a caller's `projectPath` through the shared + /// [`crate::roots::resolve_project_arg`], so both front-ends agree on + /// candidate order AND on the invalid-config vs absent-index distinction: an + /// unsafe configured root fails closed with the real diagnostic instead of a + /// generic miss (the Failure-B fix). + fn resolve_project_arg(&self, raw: Option<&str>) -> crate::roots::ProjectArg { + crate::roots::resolve_project_arg( + raw, + self.cwd.as_deref(), + self.default_project_snapshot().as_deref(), + ) } /// Adopt an indexed client workspace root when the current default is @@ -290,16 +264,10 @@ fn tool_result_to_call_result(result: &ToolResult) -> CallToolResult { } } -/// Open-or-get-cached engine + execute + render — the ENTIRE Decision-10 unit, -/// run inside a `spawn_blocking` closure. Takes owned inputs (an `Arc` cache -/// clone, owned project path, tool name, args) and returns an owned -/// [`ToolResult`]; no `&self` / engine borrow crosses the closure boundary. -fn execute_owned( - engines: &EngineCache, - project_path: &Path, - tool_name: &str, - args: &Value, -) -> ToolResult { +/// Open a request-scoped engine + execute + render inside a `spawn_blocking` +/// closure. The engine (and therefore SQLite handle + shared lease) drops only +/// after the owned result has been fully produced. +fn execute_owned(project_path: &Path, tool_name: &str, args: &Value) -> ToolResult { #[cfg(feature = "test-hooks")] if tool_name == PANIC_TOOL { panic!("simulated tool handler panic (Q5-unwind test)"); @@ -312,23 +280,15 @@ fn execute_owned( return ToolResult::text(format!("slept {secs}s")); } - let mut guard = engines - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if !guard.contains_key(project_path) { - match CodeGraphEngine::open(project_path) { - Ok(engine) => { - guard.insert(project_path.to_path_buf(), engine); - } - Err(e) => { - return ToolResult::error(format!( - "Failed to open project at {}: {e}", - project_path.display() - )); - } + let engine = match CodeGraphEngine::open(project_path) { + Ok(engine) => engine, + Err(e) => { + return ToolResult::error(format!( + "Failed to open project at {}: {e}", + project_path.display() + )); } - } - let engine = guard.get(project_path).expect("engine present after open"); + }; engine.execute(tool_name, args) } @@ -420,8 +380,13 @@ impl ServerHandler for CodeGraphHandler { let raw_project = args.get("projectPath").and_then(Value::as_str); let project_path = match self.resolve_project_arg(raw_project) { - Some(p) => p, - None => { + crate::roots::ProjectArg::Resolved(p) => p, + crate::roots::ProjectArg::InvalidConfig(detail) => { + return Ok(tool_result_to_call_result(&ToolResult::error( + crate::roots::invalid_config_message(&detail), + ))); + } + crate::roots::ProjectArg::NotIndexed => { let message = match raw_project { Some(raw) => format!( "No indexed project found for projectPath {raw:?}. Pass an absolute path to an indexed project, or run `codegraph init` there." @@ -459,11 +424,9 @@ impl ServerHandler for CodeGraphHandler { // work; interrupting sync SQLite mid-read is unsafe. The client is // unblocked fast with an isError result; the orphaned thread drains // on its own. `tool_timeout() == None` (env `0`) opts out entirely. - let engines = Arc::clone(&self.engines); let outcome_tool = tool_name.clone(); - let join_future = tokio::task::spawn_blocking(move || { - execute_owned(&engines, &project_path, &tool_name, &args) - }); + let join_future = + tokio::task::spawn_blocking(move || execute_owned(&project_path, &tool_name, &args)); let timed_out; let join = match tool_timeout() { @@ -510,7 +473,6 @@ impl ServerHandler for CodeGraphHandler { } } -/// Serve `CodeGraphHandler` over stdio via rmcp, building a multi-thread tokio /// Serve `CodeGraphHandler` over stdio via rmcp, building a multi-thread tokio /// runtime (the sync engine work runs on `spawn_blocking` pool threads). Blocks /// until the client disconnects (EOF). This is the CLI `serve --mcp` direct @@ -629,11 +591,15 @@ pub fn serve_http( match &default_project { Some(project) => { let db = db_path_for(project); + let db_display = db + .as_ref() + .map(|d| d.display().to_string()) + .unwrap_or_else(|| "(invalid CODEGRAPH_DIR)".to_string()); tracing::info!( %addr, project = %project.display(), - db = %db.display(), - db_exists = db.is_file(), + db = %db_display, + db_exists = db.as_ref().is_some_and(|d| d.is_file()), "streamable-HTTP serving on http://{addr}/mcp" ); } @@ -855,11 +821,11 @@ mod handler_tests { TempDir { path } } - /// Write a placeholder (non-SQLite) db file so `db_path_for(p).is_file()` is - /// true — resolution treats the dir as indexed, but a real engine open fails. + /// Write a placeholder (non-SQLite) db file so `db_exists_for(p)` is true — + /// resolution treats the dir as indexed, but a real engine open fails. fn placeholder_indexed(tag: &str) -> TempDir { let dir = unique_dir(tag); - let db = db_path_for(&dir.path); + let db = db_path_for(&dir.path).expect("default project resolves"); std::fs::create_dir_all(db.parent().unwrap()).unwrap(); std::fs::write(&db, b"not a real sqlite db").unwrap(); dir @@ -1009,8 +975,7 @@ mod handler_tests { #[test] fn execute_owned_open_failure_returns_error_result() { let project = placeholder_indexed("exec-open-fail"); - let engines: EngineCache = Arc::new(Mutex::new(HashMap::new())); - let result = execute_owned(&engines, &project.path, "codegraph_search", &json!({})); + let result = execute_owned(&project.path, "codegraph_search", &json!({})); assert_eq!(result.is_error, Some(true)); let text: String = result.content.iter().map(|c| c.text.clone()).collect(); assert!( @@ -1062,7 +1027,7 @@ mod handler_tests { let project = placeholder_indexed("resolve-none"); let handler = CodeGraphHandler::new_with_cwd(Some(project.path.clone()), None); assert_eq!( - handler.resolve_project_arg(None).as_deref(), + handler.resolve_project_arg(None).resolved(), Some(project.path.as_path()) ); } @@ -1071,7 +1036,7 @@ mod handler_tests { fn resolve_project_arg_none_unindexed_default_is_none() { let dir = unique_dir("resolve-none-unidx"); let handler = CodeGraphHandler::new_with_cwd(Some(dir.path.clone()), None); - assert_eq!(handler.resolve_project_arg(None), None); + assert_eq!(handler.resolve_project_arg(None).resolved(), None); } #[test] @@ -1080,7 +1045,7 @@ mod handler_tests { let raw = project.path.display().to_string(); let handler = CodeGraphHandler::new_with_cwd(None, None); assert_eq!( - handler.resolve_project_arg(Some(&raw)).as_deref(), + handler.resolve_project_arg(Some(&raw)).resolved(), Some(project.path.as_path()) ); } @@ -1092,7 +1057,7 @@ mod handler_tests { let name = project.path.file_name().and_then(|s| s.to_str()).unwrap(); let handler = CodeGraphHandler::new_with_cwd(None, Some(parent)); assert_eq!( - handler.resolve_project_arg(Some(name)).as_deref(), + handler.resolve_project_arg(Some(name)).resolved(), Some(project.path.as_path()) ); } @@ -1109,7 +1074,7 @@ mod handler_tests { let cwd = std::env::temp_dir().join("cg-mcp-h-basename-elsewhere"); let handler = CodeGraphHandler::new_with_cwd(Some(project.path.clone()), Some(cwd)); assert_eq!( - handler.resolve_project_arg(Some(&name)).as_deref(), + handler.resolve_project_arg(Some(&name)).resolved(), Some(project.path.as_path()) ); } @@ -1119,7 +1084,9 @@ mod handler_tests { let project = placeholder_indexed("resolve-bogus"); let handler = CodeGraphHandler::new_with_cwd(Some(project.path.clone()), None); assert_eq!( - handler.resolve_project_arg(Some("no-such-project-xyz")), + handler + .resolve_project_arg(Some("no-such-project-xyz")) + .resolved(), None ); } @@ -1158,11 +1125,10 @@ mod handler_tests { } /// Materialize a real indexed project (golden mini db + fixture sources) so - /// `CodeGraphEngine::open` succeeds — the only way to reach the cache-insert - /// + `engine.execute` success arm of `execute_owned`. + /// `CodeGraphEngine::open` succeeds and `execute_owned` reaches tool dispatch. fn real_indexed_project(tag: &str) -> TempDir { let dir = unique_dir(tag); - let db = db_path_for(&dir.path); + let db = db_path_for(&dir.path).expect("default project resolves"); std::fs::create_dir_all(db.parent().unwrap()).unwrap(); let root = workspace_root(); std::fs::copy(root.join("reference/golden/mini/colby.db"), &db).unwrap(); @@ -1172,34 +1138,30 @@ mod handler_tests { std::fs::create_dir_all(dst.parent().unwrap()).unwrap(); std::fs::copy(fixtures.join(rel), &dst).unwrap(); } + let paths = codegraph_core::IndexPaths::resolve(&dir.path, None).unwrap(); + codegraph_store::test_support::finalize_current_test_fixture(&paths).unwrap(); dir } #[test] - fn execute_owned_success_caches_engine_and_runs_tool() { + fn execute_owned_success_opens_request_scoped_engine_and_runs_tool() { let project = real_indexed_project("exec-ok"); - let engines: EngineCache = Arc::new(Mutex::new(HashMap::new())); let first = execute_owned( - &engines, &project.path, "codegraph_search", &json!({ "query": "add" }), ); assert_ne!(first.is_error, Some(true), "search on indexed project"); - assert!( - engines - .lock() - .unwrap_or_else(|p| p.into_inner()) - .contains_key(&project.path), - "engine cached after first open" - ); let second = execute_owned( - &engines, &project.path, "codegraph_search", &json!({ "query": "add" }), ); - assert_ne!(second.is_error, Some(true), "cached engine reused"); + assert_ne!( + second.is_error, + Some(true), + "second request reopens cleanly" + ); } fn rt() -> tokio::runtime::Runtime { diff --git a/crates/codegraph-mcp/src/roots.rs b/crates/codegraph-mcp/src/roots.rs index 73ca8ea..8c60637 100644 --- a/crates/codegraph-mcp/src/roots.rs +++ b/crates/codegraph-mcp/src/roots.rs @@ -16,14 +16,14 @@ pub fn format_tool_debug_line( default_project: Option<&Path>, ) -> String { let raw = raw_project.unwrap_or("(none)"); - let (resolved_str, db_str, db_exists) = match resolved { - Some(p) => { - let db = db_path_for(p); - let exists = db.is_file(); - (p.display().to_string(), db.display().to_string(), exists) - } - None => ("(unresolved)".to_string(), "(none)".to_string(), false), - }; + let (resolved_str, db_str, db_exists) = + match resolved.and_then(|p| db_path_for(p).map(|db| (p, db))) { + Some((p, db)) => { + let exists = db.is_file(); + (p.display().to_string(), db.display().to_string(), exists) + } + None => ("(unresolved)".to_string(), "(none)".to_string(), false), + }; let cwd_str = cwd.map_or_else(|| "(none)".to_string(), |p| p.display().to_string()); let default_str = default_project.map_or_else(|| "(none)".to_string(), |p| p.display().to_string()); @@ -32,11 +32,193 @@ pub fn format_tool_debug_line( ) } -/// The relative `.codegraph/codegraph.db` path under a project root, honoring -/// the `CODEGRAPH_DIR` override. -pub fn db_path_for(project_path: &Path) -> PathBuf { - let dir = std::env::var("CODEGRAPH_DIR").unwrap_or_else(|_| ".codegraph".to_string()); - project_path.join(dir).join("codegraph.db") +/// The current (v2) index DB path under a project root, resolved fail-closed +/// through the single `codegraph-core::IndexPaths` authority so it agrees with +/// [`crate::CodeGraphEngine::open`]. Returns `None` when the configured root is +/// unsafe/aliased/overlapping (a `resolve` failure): callers treat that as "not +/// an indexed project", which is DISTINCT from a reconstructed default path that +/// could shadow another project. This helper is for path DISPLAY only and +/// deliberately drops the reason; the authoritative fail-closed diagnostic comes +/// from the typed [`probe_root`] / [`resolve_project_arg`] states, which reject +/// an invalid configured root BEFORE any engine is opened. +pub fn db_path_for(project_path: &Path) -> Option { + codegraph_core::IndexPaths::resolve( + project_path, + std::env::var("CODEGRAPH_DIR").ok().as_deref(), + ) + .ok() + .map(|paths| paths.current_db()) +} + +/// Whether `project_path` resolves to an existing current-namespace index DB. +/// `false` for both an unresolvable configured root and a resolvable-but-absent +/// index — the shared adoption / `tools/list`-schema predicate. Adoption and the +/// schema selector only ask "is there a usable index here?", so collapsing the +/// invalid-config and absent cases to `false` is correct FOR THEM. A tool CALL +/// needs the finer [`probe_root`] to tell an unsafe configured root apart from an +/// absent index and surface the actionable diagnostic instead of a generic miss. +pub fn db_exists_for(project_path: &Path) -> bool { + matches!(probe_root(project_path), RootStatus::Indexed) +} + +/// Fail-closed classification of one project-root candidate under the current +/// `CODEGRAPH_DIR`. Distinguishes an UNSAFE configured root (an +/// [`codegraph_core::IndexPaths::resolve`] failure — the actionable diagnostic +/// is carried verbatim, NEVER re-parsed from a rendered string) from a valid +/// root whose DB is merely ABSENT, so a tool call can fail closed with the real +/// configuration error rather than a generic "not indexed" miss that would let +/// an invalid root masquerade as an un-init'd one. +#[derive(Debug)] +pub enum RootStatus { + /// Valid configured root AND its current-namespace DB exists on disk. + Indexed, + /// Valid configured root, but its current-namespace DB does not exist yet. + Absent, + /// The configured `CODEGRAPH_DIR` is unsafe/aliased/overlapping; the string + /// is the stable `IndexPaths` diagnostic (its `Display`). + Invalid(String), +} + +/// Classify `project_path` via the single `IndexPaths` authority. NEVER +/// reconstructs a default path on a resolve failure — an invalid configured root +/// yields [`RootStatus::Invalid`], not a fabricated `.codegraph-v2` fallback that +/// could open an unrelated project's database. +pub fn probe_root(project_path: &Path) -> RootStatus { + classify_resolve(codegraph_core::IndexPaths::resolve( + project_path, + std::env::var("CODEGRAPH_DIR").ok().as_deref(), + )) +} + +/// Pure classifier for an [`codegraph_core::IndexPaths::resolve`] outcome, split +/// out so the invalid/absent decision is unit-tested WITHOUT mutating the +/// process-global `CODEGRAPH_DIR`. +fn classify_resolve( + resolved: Result, +) -> RootStatus { + use codegraph_core::IndexPathsError; + match resolved { + Ok(paths) => { + if paths.current_db().is_file() { + RootStatus::Indexed + } else { + RootStatus::Absent + } + } + // A project directory that cannot be canonicalized is MISSING, not a bad + // configuration — a bogus `projectPath` must stay a generic "not indexed" + // miss (the `codegraph init` case), never an "invalid CODEGRAPH_DIR" + // error. Every OTHER variant is a genuinely unsafe/aliased/overlapping + // configured root and fails closed with its stable diagnostic. + Err(IndexPathsError::ProjectInaccessible { .. }) => RootStatus::Absent, + Err(err) => RootStatus::Invalid(err.to_string()), + } +} + +/// The outcome of resolving a tool call's `projectPath` argument to a project +/// directory, preserving the invalid-config vs absent-index distinction the +/// caller needs to emit an actionable error rather than a generic miss. +#[derive(Debug)] +pub enum ProjectArg { + /// Resolved to an indexed project directory. + Resolved(PathBuf), + /// A candidate's configured `CODEGRAPH_DIR` is unsafe/aliased/overlapping; + /// carries the stable `IndexPaths` diagnostic. Fail closed with this rather + /// than masking it as a generic miss. + InvalidConfig(String), + /// Nothing resolved to an existing index (valid roots with absent DBs, or no + /// candidate matched) — the genuine "run `codegraph init`" case. + NotIndexed, +} + +impl ProjectArg { + /// The resolved indexed path, if any — for the debug trace and the existing + /// resolution unit tests (which only assert the resolved/none outcome). + pub fn resolved(&self) -> Option<&Path> { + match self { + ProjectArg::Resolved(p) => Some(p.as_path()), + _ => None, + } + } +} + +/// Resolve a caller's `projectPath` to an INDEXED project dir, in the SAME +/// candidate order both server front-ends use: absolute raw → cwd-join → bare +/// raw → default-by-basename; `None` raw → the default project. An INDEXED +/// candidate wins immediately (a valid configured root is honored even if an +/// earlier candidate carried a bad config); otherwise the FIRST invalid-config +/// diagnostic is surfaced (fail closed); otherwise [`ProjectArg::NotIndexed`]. +pub fn resolve_project_arg( + raw: Option<&str>, + cwd: Option<&Path>, + default_project: Option<&Path>, +) -> ProjectArg { + resolve_project_arg_with(raw, cwd, default_project, &probe_root) +} + +/// [`resolve_project_arg`] with the per-candidate classifier injected, so the +/// candidate ORDER and the indexed/absent/invalid decision are unit-tested +/// without mutating the process-global `CODEGRAPH_DIR`. Production callers use +/// [`resolve_project_arg`], which injects [`probe_root`]. +fn resolve_project_arg_with( + raw: Option<&str>, + cwd: Option<&Path>, + default_project: Option<&Path>, + probe: &dyn Fn(&Path) -> RootStatus, +) -> ProjectArg { + let Some(raw) = raw else { + return match default_project { + Some(p) => match probe(p) { + RootStatus::Indexed => ProjectArg::Resolved(p.to_path_buf()), + RootStatus::Invalid(detail) => ProjectArg::InvalidConfig(detail), + RootStatus::Absent => ProjectArg::NotIndexed, + }, + None => ProjectArg::NotIndexed, + }; + }; + + let raw_path = PathBuf::from(raw); + let mut candidates: Vec = Vec::new(); + if raw_path.is_absolute() { + candidates.push(raw_path.clone()); + } else { + if let Some(cwd) = cwd { + candidates.push(cwd.join(&raw_path)); + } + candidates.push(raw_path.clone()); + } + if let Some(default) = default_project + && raw_path.file_name() == default.file_name() + { + candidates.push(default.to_path_buf()); + } + + let mut first_invalid: Option = None; + for candidate in &candidates { + match probe(candidate) { + RootStatus::Indexed => return ProjectArg::Resolved(candidate.clone()), + RootStatus::Invalid(detail) if first_invalid.is_none() => { + first_invalid = Some(detail); + } + _ => {} + } + } + match first_invalid { + Some(detail) => ProjectArg::InvalidConfig(detail), + None => ProjectArg::NotIndexed, + } +} + +/// The actionable tool-error text for an unsafe/aliased configured root, shared +/// by both front-ends so the wording (and the "unsafe configured root" naming +/// the regressions assert) stays identical. +pub fn invalid_config_message(detail: &str) -> String { + format!( + "Invalid CODEGRAPH_DIR configuration: {detail}. The configured index root is \ + unsafe — fix or unset CODEGRAPH_DIR (it must not alias the project root, an \ + ancestor, a symlink/reparse component, or overlap the legacy `.codegraph` \ + root), then re-run `codegraph init`." + ) } pub struct WorkspaceRoots { @@ -145,7 +327,7 @@ fn adopt_path( if !default_is_adoptable(default_project.as_ref(), cwd) { return None; } - if db_path_for(&path).is_file() { + if db_exists_for(&path) { let adopted = path.clone(); *default_project = Some(path); return Some(adopted); @@ -172,7 +354,7 @@ fn default_is_adoptable(default_project: Option<&PathBuf>, cwd: Option<&Path>) - let (Some(current), Some(cwd)) = (default_project, cwd) else { return false; }; - !db_path_for(current).is_file() && canonicalize_lenient(current) == canonicalize_lenient(cwd) + !db_exists_for(current) && canonicalize_lenient(current) == canonicalize_lenient(cwd) } fn file_uri_to_path(uri: &str) -> Option { @@ -248,7 +430,10 @@ mod tests { let seq = SEQ.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!("cg-mcp-roots-{tag}-{}-{seq}", std::process::id())); - let db = db_path_for(&path); + // The project dir must exist before `db_path_for` (which resolves the + // physical identity) can succeed. + std::fs::create_dir_all(&path).unwrap(); + let db = db_path_for(&path).expect("default project resolves"); std::fs::create_dir_all(db.parent().unwrap()).unwrap(); std::fs::write(&db, b"placeholder").unwrap(); TempProject { path } @@ -441,7 +626,7 @@ mod tests { let expected = format!( "[codegraph debug] tool=codegraph_search projectPath_raw=codegraph-rust resolved={} db={} db_exists=true cwd=/tmp/cwd default_project=/tmp/default", project.path().display(), - db_path_for(project.path()).display(), + db_path_for(project.path()).unwrap().display(), ); assert_eq!(line, expected); } @@ -610,12 +795,215 @@ mod tests { #[test] fn db_path_for_honors_codegraph_dir_default() { - let p = Path::new("/proj"); - let db = db_path_for(p); + // A real dir so `resolve` (which canonicalizes) succeeds; the default + // current DB is `/.codegraph-v2/codegraph.db`. + let dir = std::env::temp_dir().join(format!( + "cg-roots-dbpath-{}-{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).unwrap(); + let db = db_path_for(&dir).expect("resolve default"); + assert!( + db.ends_with(".codegraph-v2/codegraph.db"), + "default db is under .codegraph-v2: {}", + db.display() + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A `resolve` failure (here a nonexistent project) must yield `None`, not a + /// reconstructed `.codegraph-v2` default that could shadow another project. + /// Race-free: it never mutates the process-global `CODEGRAPH_DIR`, unlike an + /// env-set test. The invalid-`CODEGRAPH_DIR` path is covered end-to-end by + /// the real CLI/MCP black-box regressions. + #[test] + fn db_path_for_returns_none_on_resolve_failure() { + let missing = std::env::temp_dir().join(format!( + "cg-roots-dbpath-missing-{}-{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )); + assert!(!missing.exists(), "sanity: the probe path must not exist"); + assert!( + db_path_for(&missing).is_none(), + "a resolve failure must not resolve to a reconstructed default path" + ); + assert!( + !db_exists_for(&missing), + "db_exists_for must be false when resolution fails" + ); + } + + /// `classify_resolve` on a VALID configured root whose DB is present → + /// [`RootStatus::Indexed`]. Race-free: `IndexPaths::resolve` is called with an + /// explicit `codegraph_dir` argument, never the process-global env. + #[test] + fn classify_resolve_valid_present_is_indexed() { + let dir = std::env::temp_dir().join(format!( + "cg-roots-classify-present-{}-{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).unwrap(); + let paths = codegraph_core::IndexPaths::resolve(&dir, None).expect("resolve default"); + let db = paths.current_db(); + std::fs::create_dir_all(db.parent().unwrap()).unwrap(); + std::fs::write(&db, b"placeholder").unwrap(); + assert!(matches!( + classify_resolve(codegraph_core::IndexPaths::resolve(&dir, None)), + RootStatus::Indexed + )); + let _ = std::fs::remove_dir_all(&dir); + } + + /// `classify_resolve` on a VALID configured root whose DB is ABSENT → + /// [`RootStatus::Absent`] (the genuine un-init'd case, not an error). + #[test] + fn classify_resolve_valid_absent_is_absent() { + let dir = std::env::temp_dir().join(format!( + "cg-roots-classify-absent-{}-{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).unwrap(); + assert!(matches!( + classify_resolve(codegraph_core::IndexPaths::resolve(&dir, None)), + RootStatus::Absent + )); + let _ = std::fs::remove_dir_all(&dir); + } + + /// `classify_resolve` on an UNSAFE configured root (`.` aliases the project + /// root) → [`RootStatus::Invalid`] carrying the stable diagnostic. Race-free: + /// the bad value is passed as an argument, not via the global env. + #[test] + fn classify_resolve_invalid_config_is_invalid_with_diagnostic() { + let dir = std::env::temp_dir().join(format!( + "cg-roots-classify-invalid-{}-{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).unwrap(); + match classify_resolve(codegraph_core::IndexPaths::resolve(&dir, Some("."))) { + RootStatus::Invalid(detail) => assert!( + detail.contains("project root"), + "invalid diagnostic must name the aliasing reason: {detail}" + ), + other => panic!("`.` alias must classify Invalid, got {other:?}"), + } + let _ = std::fs::remove_dir_all(&dir); + } + + /// A MISSING project directory is not a bad configuration: it classifies + /// [`RootStatus::Absent`] (generic "not indexed"), never `Invalid`. + #[test] + fn classify_resolve_missing_project_is_absent_not_invalid() { + let missing = std::env::temp_dir().join(format!( + "cg-roots-classify-missing-{}-{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )); + assert!(!missing.exists(), "sanity: probe path must not exist"); + assert!(matches!( + classify_resolve(codegraph_core::IndexPaths::resolve(&missing, None)), + RootStatus::Absent + )); + } + + /// A stub classifier keyed by path, so candidate ORDER and the three typed + /// states are asserted without touching the filesystem or the env. + fn stub_probe(map: Vec<(PathBuf, RootStatus)>) -> impl Fn(&Path) -> RootStatus { + move |candidate: &Path| { + for (path, status) in &map { + if path == candidate { + return match status { + RootStatus::Indexed => RootStatus::Indexed, + RootStatus::Absent => RootStatus::Absent, + RootStatus::Invalid(d) => RootStatus::Invalid(d.clone()), + }; + } + } + RootStatus::Absent + } + } + + /// `raw = None` + an INVALID default root ⇒ [`ProjectArg::InvalidConfig`] + /// carrying the diagnostic — NOT `NotIndexed`. This is the exact Failure-B + /// masking: a bad `CODEGRAPH_DIR` used to collapse into "no indexed project". + #[test] + fn resolve_project_arg_none_invalid_default_is_invalid_config() { + let default = PathBuf::from("/tmp/cg-stub-default"); + let probe = stub_probe(vec![( + default.clone(), + RootStatus::Invalid("unsafe root: project root itself".to_string()), + )]); + match resolve_project_arg_with(None, None, Some(&default), &probe) { + ProjectArg::InvalidConfig(detail) => { + assert!(detail.contains("project root itself"), "{detail}") + } + other => panic!("invalid default must fail closed, got {other:?}"), + } + } + + /// An INDEXED candidate wins over an EARLIER invalid one: a valid configured + /// root must still resolve, so the fail-closed behavior cannot regress into + /// refusing every call whenever any candidate happens to be misconfigured. + #[test] + fn resolve_project_arg_indexed_candidate_wins_over_earlier_invalid() { + let cwd = PathBuf::from("/tmp/cg-stub-cwd"); + let bare = PathBuf::from("proj"); + let joined = cwd.join(&bare); + let probe = stub_probe(vec![ + (joined, RootStatus::Invalid("bad".to_string())), + (bare.clone(), RootStatus::Indexed), + ]); + assert_eq!( + resolve_project_arg_with(Some("proj"), Some(&cwd), None, &probe).resolved(), + Some(bare.as_path()), + "an indexed later candidate must win over an earlier invalid one" + ); + } + + /// With NO indexed candidate, the FIRST invalid diagnostic is surfaced (in + /// candidate order: cwd-join before bare raw), so the reported reason belongs + /// to the highest-priority misconfigured candidate. + #[test] + fn resolve_project_arg_reports_first_invalid_in_candidate_order() { + let cwd = PathBuf::from("/tmp/cg-stub-cwd2"); + let bare = PathBuf::from("proj"); + let joined = cwd.join(&bare); + let probe = stub_probe(vec![ + (joined, RootStatus::Invalid("first-invalid".to_string())), + (bare, RootStatus::Invalid("second-invalid".to_string())), + ]); + match resolve_project_arg_with(Some("proj"), Some(&cwd), None, &probe) { + ProjectArg::InvalidConfig(detail) => assert_eq!(detail, "first-invalid"), + other => panic!("expected InvalidConfig, got {other:?}"), + } + } + + /// All-absent candidates stay the genuine [`ProjectArg::NotIndexed`] "run + /// `codegraph init`" case — an absent index is not a bad configuration. + #[test] + fn resolve_project_arg_all_absent_is_not_indexed() { + let probe = stub_probe(vec![]); + assert!(matches!( + resolve_project_arg_with(Some("/tmp/cg-stub-absolute"), None, None, &probe), + ProjectArg::NotIndexed + )); + } + + /// The shared invalid-config message embeds the verbatim diagnostic AND the + /// actionable remedy both front-ends surface. + #[test] + fn invalid_config_message_carries_detail_and_remedy() { + let msg = invalid_config_message("refusing an unsafe index root /p: reason"); assert!( - db.ends_with("codegraph.db"), - "db path ends with codegraph.db" + msg.contains("refusing an unsafe index root /p: reason"), + "{msg}" ); - assert!(db.starts_with("/proj"), "under the project root"); + assert!(msg.contains("CODEGRAPH_DIR"), "{msg}"); + assert!(msg.contains("unsafe"), "{msg}"); } } diff --git a/crates/codegraph-mcp/src/server.rs b/crates/codegraph-mcp/src/server.rs index 7bbd3e4..db79290 100644 --- a/crates/codegraph-mcp/src/server.rs +++ b/crates/codegraph-mcp/src/server.rs @@ -19,7 +19,8 @@ use crate::engine::CodeGraphEngine; use crate::instructions::SERVER_INSTRUCTIONS; use crate::protocol::{JsonRpcRequest, JsonRpcResponse, ToolResult, error_codes}; use crate::roots::{ - ROOTS_LIST_REQUEST_ID, WorkspaceRoots, db_path_for, format_tool_debug_line, roots_list_request, + ROOTS_LIST_REQUEST_ID, WorkspaceRoots, db_exists_for, db_path_for, format_tool_debug_line, + roots_list_request, }; use crate::schemas; @@ -68,7 +69,7 @@ struct DbIdentity { /// /// Deliberately EXCLUDED: /// - bytes `[24..28]` — file change counter: increments on EVERY transaction -/// including a plain WAL write, which would false-fire a reopen. +/// including a plain WAL write, which would false-report a replacement. /// - bytes `[92..100]` — version-valid-for / SQLite version: mutate on writes. /// /// No timestamp (mtime) is involved, so a normal WAL write that @@ -122,10 +123,9 @@ fn header_sig(db_path: &std::path::Path) -> u64 { } impl DbIdentity { - /// Identity of the db file, or `None` when it is missing — which the caller - /// treats as "must reopen". Honors "never miss a replace": a metadata error - /// yields `None` (reopen); a header read error degrades `header_sig` to `0` - /// (the slices simply do not contribute), never a panic. + /// Identity of the db file, or `None` when it cannot be observed. A header + /// read error degrades `header_sig` to `0` (the slices simply do not + /// contribute), never a panic. fn read(db_path: &std::path::Path) -> Option { let meta = std::fs::metadata(db_path).ok()?; #[cfg(unix)] @@ -160,28 +160,26 @@ impl DbIdentity { } } -/// A cached engine plus the db-file identity recorded when it was opened. -/// -/// `engine` is `Option` so [`McpServer::close_cached_handles`] can drop the live -/// DB connection (releasing the OS file handle) while keeping the recorded -/// `identity`. [`McpServer::engine_for`] treats a `None` engine as stale, so it -/// reopens and counts the reopen exactly as a replaced-on-disk db would. Normal -/// serve flow always holds `Some`. -struct CachedEngine { - engine: Option, - identity: DbIdentity, -} +/// Last successfully observed file identity for one project. SQLite connections +/// and leases remain request-scoped and are never retained in this map. +struct ObservedDbIdentity(DbIdentity); -/// Process-global count of engine reopens (drop the cached engine + open a -/// fresh one because the db file went missing or was replaced). The first open -/// of a never-cached path is not a reopen. `tests/reopen.rs` reads it via -/// [`reopen_count`] to prove a same-inode project triggers no needless reopen. -static REOPEN_COUNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +/// Process-global count of DB identity changes observed between successful +/// requests. Request-scoped engines open on every call, so this metric does not +/// count engine or connection opens. The first observation is not a change. +static DB_IDENTITY_CHANGE_COUNT: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + +/// Number of DB identity changes observed between successful requests since +/// process start. +pub fn db_identity_change_count() -> u64 { + DB_IDENTITY_CHANGE_COUNT.load(std::sync::atomic::Ordering::Relaxed) +} -/// Number of engine reopens since process start. Test-observability hook for -/// the #925 replacement rule; cheap enough to keep unconditionally. +/// Compatibility alias for the former cached-engine diagnostic name. +#[doc(hidden)] pub fn reopen_count() -> u64 { - REOPEN_COUNT.load(std::sync::atomic::Ordering::Relaxed) + db_identity_change_count() } pub enum RunUntilAdoption { @@ -189,14 +187,12 @@ pub enum RunUntilAdoption { Adopted { project_root: PathBuf, reader: R }, } -/// Holds the default project path and a per-path engine cache (mirrors -/// `ToolHandler.projectCache`, `tools.ts:591`). Each cached engine carries the -/// db-file identity it was opened against, so [`McpServer::engine_for`] can -/// reopen when the database is REPLACED on disk (#925). +/// Holds the default project path and per-path DB identities used to observe +/// replacements (#925). Every request opens and drops its own engine. pub struct McpServer { default_project: Option, cwd: Option, - engines: HashMap, + observed_identities: HashMap, workspace_roots: WorkspaceRoots, no_roots: bool, } @@ -206,7 +202,7 @@ impl McpServer { Self { default_project, cwd: std::env::current_dir().ok(), - engines: HashMap::new(), + observed_identities: HashMap::new(), workspace_roots: WorkspaceRoots::new(), no_roots: false, } @@ -221,7 +217,7 @@ impl McpServer { let Some(project) = &self.default_project else { return false; }; - db_path_for(project).is_file() + db_exists_for(project) } /// Run the stdio loop until the broad-root daemon handoff adopts a project @@ -444,14 +440,22 @@ impl McpServer { format_tool_debug_line( &tool_name, raw_project, - resolved.as_deref(), + resolved.resolved(), self.cwd.as_deref(), self.default_project.as_deref(), ) ); let project_path = match resolved { - Some(p) => p, - None => { + crate::roots::ProjectArg::Resolved(p) => p, + crate::roots::ProjectArg::InvalidConfig(detail) => { + return Dispatch::Reply( + serde_json::to_value(ToolResult::error(crate::roots::invalid_config_message( + &detail, + ))) + .expect("ToolResult serializes"), + ); + } + crate::roots::ProjectArg::NotIndexed => { let message = match raw_project { Some(raw) => format!( "No indexed project found for projectPath {raw:?}. Pass an absolute path to an indexed project, or run `codegraph init` there." @@ -497,88 +501,53 @@ impl McpServer { /// - `raw = None`: the `default_project` (existing behavior). /// /// Honesty rule: when `raw` is given but resolves to nothing AND its basename - /// does not match `default_project`, this returns `None` rather than silently - /// falling back to `default_project` — a genuinely wrong path must surface an - /// error, not mask itself behind the default. - fn resolve_project_arg(&self, raw: Option<&str>) -> Option { - let Some(raw) = raw else { - return self - .default_project - .clone() - .filter(|p| db_path_for(p).is_file()); - }; - - let raw_path = PathBuf::from(raw); - let mut candidates: Vec = Vec::new(); - if raw_path.is_absolute() { - candidates.push(raw_path.clone()); - } else { - if let Some(cwd) = &self.cwd { - candidates.push(cwd.join(&raw_path)); - } - candidates.push(raw_path.clone()); - } - if let Some(default) = &self.default_project - && raw_path.file_name() == default.file_name() - { - candidates.push(default.clone()); - } - - candidates - .into_iter() - .find(|candidate| db_path_for(candidate).is_file()) + /// does not match `default_project`, this yields `NotIndexed` rather than + /// silently falling back to `default_project` — a genuinely wrong path must + /// surface an error, not mask itself behind the default. An unsafe configured + /// root yields `InvalidConfig` (the Failure-B fix), not a generic miss. + /// Delegates to the shared [`crate::roots::resolve_project_arg`] so this + /// front-end and the rmcp handler stay byte-identical. + fn resolve_project_arg(&self, raw: Option<&str>) -> crate::roots::ProjectArg { + crate::roots::resolve_project_arg(raw, self.cwd.as_deref(), self.default_project.as_deref()) } - /// Open-on-demand + cache the engine for a project path - /// (`ToolHandler.getCodeGraph`, `tools.ts`), reopening when the db file was - /// REPLACED on disk (#925). Before returning a cached engine, re-stat the db - /// path: reopen iff it is MISSING or its identity differs from the recorded - /// one (inode/file-index changed). An in-place write keeps the same identity, - /// so the common path returns the cached engine without reopening. - fn engine_for(&mut self, project_path: &PathBuf) -> anyhow::Result<&CodeGraphEngine> { - let db_path = db_path_for(project_path); - let current = DbIdentity::read(&db_path); - - let stale = match self.engines.get(project_path) { - None => true, - Some(cached) => cached.engine.is_none() || current != Some(cached.identity), - }; - - if stale { - if self.engines.remove(project_path).is_some() { - REOPEN_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - } - let engine = CodeGraphEngine::open(project_path)?; - let identity = DbIdentity::read(&db_path).ok_or_else(|| { - anyhow::anyhow!("database vanished after open at {}", db_path.display()) - })?; - self.engines.insert( - project_path.clone(), - CachedEngine { - engine: Some(engine), - identity, - }, - ); - } - - Ok(self - .engines + /// Open one request-scoped engine and update the #925 identity-change + /// observation. No SQLite handle or lease is cached. + fn engine_for(&mut self, project_path: &PathBuf) -> anyhow::Result { + // A resolvable configured root yields its DB path. An invalid one is + // already rejected upstream by `roots::resolve_project_arg`, which carries + // the stable `IndexPaths` diagnostic to the caller before a tool call ever + // reaches here; this arm is the defensive backstop for an unresolved DB + // path (e.g. a non-tool-call caller), so it fails closed rather than + // reconstructing a default namespace. + let db_path = db_path_for(project_path).ok_or_else(|| { + anyhow::anyhow!( + "invalid CODEGRAPH_DIR configuration for project {}", + project_path.display() + ) + })?; + let previous = self + .observed_identities .get(project_path) - .and_then(|c| c.engine.as_ref()) - .expect("engine present after open")) + .map(|observed| observed.0); + let engine = CodeGraphEngine::open(project_path)?; + let identity = DbIdentity::read(&db_path).ok_or_else(|| { + anyhow::anyhow!("database vanished after open at {}", db_path.display()) + })?; + if previous.is_some_and(|recorded| recorded != identity) { + DB_IDENTITY_CHANGE_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + self.observed_identities + .insert(project_path.clone(), ObservedDbIdentity(identity)); + Ok(engine) } - /// Test/diagnostic only: drop every cached engine's live DB connection while - /// keeping its recorded identity, so the underlying db files can be replaced - /// on platforms (windows) where an open handle blocks delete/overwrite. The - /// next [`McpServer::engine_for`] reopens the still-recorded path and counts - /// it as a reopen, identical to a replaced-on-disk db. Normal serve flow - /// never calls this; the cache reopens on demand. + /// Backward-compatible diagnostic seam. There are no cached handles to close; + /// this forgets DB identity observations so the next request establishes a + /// new baseline without reporting a synthetic identity change. #[doc(hidden)] pub fn close_cached_handles(&mut self) { - for cached in self.engines.values_mut() { - cached.engine = None; - } + self.observed_identities.clear(); } /// Test/diagnostic only: the currently adopted default project path. @@ -595,7 +564,7 @@ impl McpServer { Self { default_project, cwd, - engines: HashMap::new(), + observed_identities: HashMap::new(), workspace_roots: WorkspaceRoots::new(), no_roots: false, } @@ -677,7 +646,10 @@ mod tests { let seq = SEQ.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!("cg-mcp-init-{tag}-{}-{seq}", std::process::id())); - let db = db_path_for(&path); + // The project dir must exist before `db_path_for` (which resolves the + // physical identity) can succeed. + std::fs::create_dir_all(&path).unwrap(); + let db = db_path_for(&path).expect("default project resolves"); std::fs::create_dir_all(db.parent().unwrap()).unwrap(); std::fs::write(&db, b"placeholder").unwrap(); TempProject { path } @@ -831,7 +803,7 @@ mod tests { let cwd = std::env::temp_dir().join("cg-mcp-basename-cwd-elsewhere"); let server = McpServer::new_with_cwd(Some(project.path().to_path_buf()), Some(cwd)); assert_eq!( - server.resolve_project_arg(Some(&name)).as_deref(), + server.resolve_project_arg(Some(&name)).resolved(), Some(project.path()), ); } @@ -847,7 +819,7 @@ mod tests { // default_project unset so ONLY the cwd-join candidate can resolve. let server = McpServer::new_with_cwd(None, Some(parent)); assert_eq!( - server.resolve_project_arg(Some(name)).as_deref(), + server.resolve_project_arg(Some(name)).resolved(), Some(project.path()), ); } @@ -859,7 +831,7 @@ mod tests { let raw = project.path().display().to_string(); let server = McpServer::new_with_cwd(None, None); assert_eq!( - server.resolve_project_arg(Some(&raw)).as_deref(), + server.resolve_project_arg(Some(&raw)).resolved(), Some(project.path()), ); } @@ -873,7 +845,9 @@ mod tests { let cwd = std::env::temp_dir().join("cg-mcp-bogus-cwd-elsewhere"); let server = McpServer::new_with_cwd(Some(project.path().to_path_buf()), Some(cwd)); assert_eq!( - server.resolve_project_arg(Some("definitely-not-a-real-project-xyz")), + server + .resolve_project_arg(Some("definitely-not-a-real-project-xyz")) + .resolved(), None, ); } @@ -885,7 +859,7 @@ mod tests { let project = indexed_project("none-default"); let server = McpServer::new_with_cwd(Some(project.path().to_path_buf()), None); assert_eq!( - server.resolve_project_arg(None).as_deref(), + server.resolve_project_arg(None).resolved(), Some(project.path()), ); } @@ -901,7 +875,7 @@ mod tests { )); std::fs::create_dir_all(&dir).unwrap(); let server = McpServer::new_with_cwd(Some(dir.clone()), None); - assert_eq!(server.resolve_project_arg(None), None); + assert_eq!(server.resolve_project_arg(None).resolved(), None); let _ = std::fs::remove_dir_all(&dir); } @@ -1242,11 +1216,15 @@ mod tests { } #[test] - fn close_cached_handles_forces_reopen_counter() { - let before = reopen_count(); - let mut server = McpServer::new(None); + fn close_cached_handles_clears_only_identity_observations() { + let project = indexed_project("close-identity-observations"); + let mut server = McpServer::new(Some(project.path().to_path_buf())); + server.observed_identities.insert( + project.path().to_path_buf(), + ObservedDbIdentity(DbIdentity::read(&db_path_for(project.path()).unwrap()).unwrap()), + ); server.close_cached_handles(); - assert!(reopen_count() >= before); + assert!(server.observed_identities.is_empty()); } #[test] diff --git a/crates/codegraph-mcp/tests/godot_honesty_mcp.rs b/crates/codegraph-mcp/tests/godot_honesty_mcp.rs index a958961..e8520ad 100644 --- a/crates/codegraph-mcp/tests/godot_honesty_mcp.rs +++ b/crates/codegraph-mcp/tests/godot_honesty_mcp.rs @@ -57,7 +57,8 @@ fn pipeline_project(files: &[(&str, &str)]) -> TestProject { fs::write(&dst, src).unwrap(); } - let mut store = Store::open(&base.join(".codegraph").join("codegraph.db")).unwrap(); + // Batch M: the MCP engine reads the isolated v2 namespace (`.codegraph-v2`). + let mut store = Store::open(&base.join(".codegraph-v2").join("codegraph.db")).unwrap(); for (rel, src) in files { let result = extract_file(&base, rel).unwrap(); store @@ -92,6 +93,8 @@ fn pipeline_project(files: &[(&str, &str)]) -> TestProject { resolver.resolve_and_persist(&mut store).unwrap(); resolver.run_post_extract(&mut store).unwrap(); drop(store); + let paths = codegraph_core::IndexPaths::resolve(&base, None).unwrap(); + codegraph_store::test_support::finalize_current_test_fixture(&paths).unwrap(); TestProject { path: base } } diff --git a/crates/codegraph-mcp/tests/golden_mcp.rs b/crates/codegraph-mcp/tests/golden_mcp.rs index fafbdc9..a7f906f 100644 --- a/crates/codegraph-mcp/tests/golden_mcp.rs +++ b/crates/codegraph-mcp/tests/golden_mcp.rs @@ -87,11 +87,15 @@ fn setup_mini_project() -> TestProject { let seq = TEMP_SEQ.fetch_add(1, Ordering::Relaxed); let base = std::env::temp_dir().join(format!("cg-mcp-test-{}-{nanos}-{seq}", std::process::id())); - fs::create_dir_all(base.join(".codegraph")).unwrap(); + // Batch M: the MCP engine now opens the isolated v2 namespace + // (`.codegraph-v2`), so stage the golden DB there. + fs::create_dir_all(base.join(".codegraph-v2")).unwrap(); copy_with_retry( &root.join("reference/golden/mini/colby.db"), - &base.join(".codegraph").join("codegraph.db"), + &base.join(".codegraph-v2").join("codegraph.db"), ); + let paths = codegraph_core::IndexPaths::resolve(&base, None).unwrap(); + codegraph_store::test_support::finalize_current_test_fixture(&paths).unwrap(); let fixtures = root.join("crates/codegraph-bench/fixtures/mini"); for rel in ["src/app.ts", "src/math.ts", "tools/greeter.py"] { @@ -748,7 +752,8 @@ fn index_fixture(files: &[(&str, &str)]) -> TestProject { fs::create_dir_all(dst.parent().unwrap()).unwrap(); fs::write(&dst, src).unwrap(); } - let mut store = Store::open(&base.join(".codegraph").join("codegraph.db")).unwrap(); + // Batch M: the MCP engine reads the isolated v2 namespace (`.codegraph-v2`). + let mut store = Store::open(&base.join(".codegraph-v2").join("codegraph.db")).unwrap(); let mut all_edges = Vec::new(); for (rel, src) in files { let result = extract_file(&base, rel).unwrap(); @@ -769,6 +774,8 @@ fn index_fixture(files: &[(&str, &str)]) -> TestProject { } store.insert_edges(&all_edges).unwrap(); drop(store); + let paths = codegraph_core::IndexPaths::resolve(&base, None).unwrap(); + codegraph_store::test_support::finalize_current_test_fixture(&paths).unwrap(); TestProject { path: base } } diff --git a/crates/codegraph-mcp/tests/mcp_configured_root.rs b/crates/codegraph-mcp/tests/mcp_configured_root.rs new file mode 100644 index 0000000..85d5433 --- /dev/null +++ b/crates/codegraph-mcp/tests/mcp_configured_root.rs @@ -0,0 +1,525 @@ +//! Real MCP public-surface regressions for a configured `CODEGRAPH_DIR`. +//! +//! Isolated in its OWN test binary (a separate process) because these tests set +//! the process-global `CODEGRAPH_DIR`; keeping them out of `golden_mcp.rs` avoids +//! racing that binary's env-sensitive default-surface readers. The tests drive a +//! REAL server front-end — most over an in-memory stdio pipe against `McpServer`, +//! and one against the SHIPPED rmcp handler over a duplex transport — and prove +//! the MCP request path opens the SAME identity-suffixed DB the CLI `init` +//! writes, and fails closed identically on an invalid configured root. + +use std::fs; +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use codegraph_mcp::McpServer; +use serde_json::{Value, json}; + +static SEQ: AtomicU64 = AtomicU64::new(0); +// Every test here mutates the PROCESS-GLOBAL `CODEGRAPH_DIR`; cargo runs a +// binary's tests multi-threaded in ONE process, so they must serialize the +// set→read→restore window against each other. +static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// RAII guard for the process-global `CODEGRAPH_DIR`: sets it on construction and +/// restores the PREVIOUS value (or removes it) in `Drop`. Manual restore lines +/// are skipped when an assertion panics mid-test, leaking a bad `CODEGRAPH_DIR` +/// into every later test in this binary; `Drop` runs on the unwind path too, so +/// the restoration is panic-safe. Holds the [`ENV_LOCK`] for its whole lifetime. +struct EnvGuard { + prev: Option, + _lock: std::sync::MutexGuard<'static, ()>, +} + +impl EnvGuard { + fn set(value: &str) -> Self { + let lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let prev = std::env::var_os("CODEGRAPH_DIR"); + // SAFETY: the ENV_LOCK held for this guard's lifetime serializes every + // `CODEGRAPH_DIR` mutation in this test binary, and `Drop` restores the + // prior value on both the normal and the unwind path. + unsafe { std::env::set_var("CODEGRAPH_DIR", value) }; + Self { prev, _lock: lock } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + // SAFETY: still holding the ENV_LOCK, so no other test thread is reading + // or writing `CODEGRAPH_DIR` while it is restored. + match self.prev.take() { + Some(v) => unsafe { std::env::set_var("CODEGRAPH_DIR", v) }, + None => unsafe { std::env::remove_var("CODEGRAPH_DIR") }, + } + } +} + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize() + .expect("workspace root") +} + +struct TempDir { + path: PathBuf, +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +fn unique_dir(tag: &str) -> TempDir { + let path = std::env::temp_dir().join(format!( + "cg-mcp-cfgroot-{tag}-{}-{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&path).unwrap(); + TempDir { path } +} + +fn stage_mini_db_at(paths: &codegraph_core::IndexPaths, project: &Path) { + let root = workspace_root(); + let db = paths.current_db(); + fs::create_dir_all(db.parent().unwrap()).unwrap(); + fs::copy(root.join("reference/golden/mini/colby.db"), &db).unwrap(); + let fixtures = root.join("crates/codegraph-bench/fixtures/mini"); + for rel in ["src/app.ts", "src/math.ts"] { + let dst = project.join(rel); + fs::create_dir_all(dst.parent().unwrap()).unwrap(); + fs::copy(fixtures.join(rel), &dst).unwrap(); + } + codegraph_store::test_support::finalize_current_test_fixture(paths).unwrap(); +} + +fn tool_call_search(project: &Path, project_path_arg: &str) -> Value { + let request = json!({ + "jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": { + "name": "codegraph_search", + "arguments": { "query": "add", "projectPath": project_path_arg } + } + }); + let frame = format!("{}\n", serde_json::to_string(&request).unwrap()); + let mut output = Vec::new(); + let mut server = McpServer::new(Some(project.to_path_buf())); + server + .run_until_adoption(Cursor::new(frame.into_bytes()), &mut output) + .expect("server run"); + let text = String::from_utf8(output).expect("utf8"); + let line = text.lines().next().expect("one response line"); + serde_json::from_str(line).expect("response json") +} + +/// A relative `CODEGRAPH_DIR` is honored through `resolve`'s identity-suffixed +/// sibling: staging the golden DB at `/-v2-/codegraph.db` +/// (NOT the simple-join `/`) makes a real `tools/call` resolve and +/// return results — proving the MCP path opens the DB the CLI init would write. +#[test] +fn configured_relative_root_mcp_opens_identity_sibling_db() { + let dir = unique_dir("rel"); + let base = &dir.path; + + let _env = EnvGuard::set("cache"); + + let paths = codegraph_core::IndexPaths::resolve(base, Some("cache")).expect("resolve"); + stage_mini_db_at(&paths, base); + assert!( + !base.join("cache").join("codegraph.db").is_file(), + "configured root must resolve to the identity sibling, not the simple-join" + ); + + let resp = tool_call_search(base, base.to_str().unwrap()); + + let text = resp["result"]["content"][0]["text"] + .as_str() + .expect("tool content text"); + assert_ne!( + resp["result"]["isError"], + json!(true), + "configured-root tool call must not error: {text}" + ); + assert!( + text.contains("add"), + "search against the identity-sibling DB must return results: {text}" + ); +} + +/// Two DISTINCT projects sharing ONE absolute `CODEGRAPH_DIR` cannot cross-read: +/// each resolves to its OWN identity-suffixed sibling, so staging only project +/// A's DB leaves project B unindexed — B can never open A's graph through the +/// shared absolute root. +#[test] +fn two_projects_sharing_absolute_root_cannot_cross_read_via_mcp() { + let dir = unique_dir("abs"); + let holder = &dir.path; + let project_a = holder.join("a"); + let project_b = holder.join("b"); + let shared = holder.join("shared").join("cg"); + fs::create_dir_all(&project_a).unwrap(); + fs::create_dir_all(&project_b).unwrap(); + fs::create_dir_all(holder.join("shared")).unwrap(); + + let shared_str = shared.to_string_lossy().into_owned(); + let _env = EnvGuard::set(&shared_str); + + let paths_a = codegraph_core::IndexPaths::resolve(&project_a, Some(&shared_str)).expect("a"); + stage_mini_db_at(&paths_a, &project_a); + + let paths_b = codegraph_core::IndexPaths::resolve(&project_b, Some(&shared_str)).expect("b"); + assert_ne!( + paths_a.current_root(), + paths_b.current_root(), + "two projects sharing an absolute root must get distinct identity siblings" + ); + assert!( + !paths_b.current_db().is_file(), + "project B must NOT resolve to project A's staged DB" + ); + + let resp_a = tool_call_search(&project_a, project_a.to_str().unwrap()); + let resp_b = tool_call_search(&project_b, project_b.to_str().unwrap()); + + let text_a = resp_a["result"]["content"][0]["text"] + .as_str() + .unwrap_or(""); + assert!( + resp_a["result"]["isError"] != json!(true) && text_a.contains("add"), + "project A must read its own graph: {text_a}" + ); + let text_b = serde_json::to_string(&resp_b).unwrap(); + assert!( + !text_b.contains("Greeter") && !text_b.contains("Counter"), + "project B must NOT surface project A's indexed symbols: {text_b}" + ); +} + +/// The exact payload of ONE filesystem entry in the nonmutation oracle: a +/// directory's mere presence (so an EMPTY directory's creation/removal is +/// detectable), a regular file's COMPLETE bytes (never its length — an +/// equal-length in-place write keeps the size identical), or a symlink's TARGET +/// (the link is never followed). +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum EntryKind { + Directory, + RegularFile(Vec), + Symlink(PathBuf), +} + +/// One snapshot entry keyed by its OS-native relative path, so the equality key +/// is never a lossy `to_string_lossy` rendering. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct TreeEntry { + rel: PathBuf, + kind: EntryKind, +} + +fn kind_label(kind: &EntryKind) -> String { + match kind { + EntryKind::Directory => "directory".to_string(), + EntryKind::RegularFile(bytes) => format!("file[{} bytes]", bytes.len()), + EntryKind::Symlink(target) => format!("symlink -> {}", target.display()), + } +} + +/// Snapshot EVERY filesystem entry under `root` — directories, regular files +/// (complete bytes) and symlinks (their targets) — sorted; the nonmutation +/// oracle, identical in semantics to the CLI-side one. +/// +/// FAIL-CLOSED: every I/O step panics on error instead of skipping or +/// defaulting. A swallowed `read_dir`/entry error would drop a whole subtree +/// from BOTH snapshots and `fs::read(..).unwrap_or_default()` would record an +/// unreadable file as empty on both sides — each turns a real mutation into a +/// false "unchanged". An entry kind with no deterministic exact representation +/// panics rather than being silently omitted. +fn tree_snapshot(root: &Path) -> Vec { + fn walk(dir: &Path, base: &Path, out: &mut Vec) { + let entries = fs::read_dir(dir).unwrap_or_else(|e| { + panic!( + "nonmutation oracle: read_dir({}) failed: {e} — the oracle must fail \ + loudly, never silently skip a subtree", + dir.display() + ) + }); + for entry in entries { + let entry = entry.unwrap_or_else(|e| { + panic!( + "nonmutation oracle: a directory entry of {} could not be read: {e}", + dir.display() + ) + }); + let path = entry.path(); + let rel = path + .strip_prefix(base) + .unwrap_or_else(|_| { + panic!( + "nonmutation oracle: {} is not under the snapshot base {}", + path.display(), + base.display() + ) + }) + .to_path_buf(); + // `symlink_metadata` describes the LINK itself, so a symlink is never + // resolved to its destination here. + let meta = fs::symlink_metadata(&path).unwrap_or_else(|e| { + panic!( + "nonmutation oracle: symlink_metadata({}) failed: {e}", + path.display() + ) + }); + let file_type = meta.file_type(); + if file_type.is_symlink() { + let target = fs::read_link(&path).unwrap_or_else(|e| { + panic!( + "nonmutation oracle: read_link({}) failed: {e}", + path.display() + ) + }); + out.push(TreeEntry { + rel, + kind: EntryKind::Symlink(target), + }); + } else if file_type.is_dir() { + out.push(TreeEntry { + rel, + kind: EntryKind::Directory, + }); + walk(&path, base, out); + } else if file_type.is_file() { + let bytes = fs::read(&path).unwrap_or_else(|e| { + panic!( + "nonmutation oracle: read({}) failed: {e} — an unreadable file \ + must not be recorded as empty bytes", + path.display() + ) + }); + out.push(TreeEntry { + rel, + kind: EntryKind::RegularFile(bytes), + }); + } else { + panic!( + "nonmutation oracle: unsupported entry kind at {} ({file_type:?}); no \ + deterministic exact representation exists, so the oracle refuses to \ + omit it", + path.display() + ); + } + } + } + let mut out = Vec::new(); + walk(root, root, &mut out); + out.sort(); + out +} + +/// Assert two [`tree_snapshot`]s are EXACTLY equal, naming only the changed +/// entries: a bare `assert_eq!` would dump every file's contents (the golden DB +/// included) into the failure message. +fn assert_tree_unchanged(before: &[TreeEntry], after: &[TreeEntry], context: &str) { + let mut diffs: Vec = Vec::new(); + let before_map: std::collections::BTreeMap<&Path, &EntryKind> = + before.iter().map(|e| (e.rel.as_path(), &e.kind)).collect(); + let after_map: std::collections::BTreeMap<&Path, &EntryKind> = + after.iter().map(|e| (e.rel.as_path(), &e.kind)).collect(); + for (path, kind) in &before_map { + match after_map.get(path) { + None => diffs.push(format!( + "removed: {} ({})", + path.display(), + kind_label(kind) + )), + Some(other) if other != kind => diffs.push(format!( + "changed: {} ({} -> {})", + path.display(), + kind_label(kind), + kind_label(other) + )), + Some(_) => {} + } + } + for (path, kind) in &after_map { + if !before_map.contains_key(path) { + diffs.push(format!( + "created: {} ({})", + path.display(), + kind_label(kind) + )); + } + } + assert!( + diffs.is_empty(), + "{context}: the project tree must be EXACTLY unchanged (complete file bytes, \ + directory presence, and symlink targets compared — never sizes), but \ + changed: {diffs:?}" + ); +} + +/// An INVALID `CODEGRAPH_DIR` must fail closed on the REAL MCP public surface — +/// it must not silently fall back to the default `.codegraph-v2` namespace. +/// +/// A TRAP database is staged at the default location `/.codegraph-v2/ +/// codegraph.db` (the very path a `.`-alias fallback would open) and +/// `CODEGRAPH_DIR=.` is set, which `IndexPaths::resolve` refuses because it +/// aliases the project root. A real `tools/call codegraph_search` must then: +/// surface the actionable invalid-configuration diagnostic, NOT the generic "No +/// indexed project" miss; serve NONE of the trap DB's symbols; and mutate zero +/// bytes of the project tree (trap DB included). +#[test] +fn invalid_configured_root_mcp_fails_closed_and_never_serves_trap_default_db() { + let dir = unique_dir("trap"); + let project = dir.path.join("mini"); + fs::create_dir_all(&project).unwrap(); + + let trap_paths = codegraph_core::IndexPaths::resolve(&project, None).unwrap(); + let trap_db = trap_paths.current_db(); + stage_mini_db_at(&trap_paths, &project); + assert!( + trap_db.is_file(), + "sanity: the trap DB must exist at the default namespace a fallback would open" + ); + + let _env = EnvGuard::set("."); + assert!( + codegraph_core::IndexPaths::resolve(&project, Some(".")).is_err(), + "sanity: `CODEGRAPH_DIR=.` must be refused by the path authority" + ); + + let before = tree_snapshot(&project); + let resp = tool_call_search(&project, project.to_str().unwrap()); + let after = tree_snapshot(&project); + + let text = resp["result"]["content"][0]["text"] + .as_str() + .unwrap_or_default() + .to_string(); + let whole = serde_json::to_string(&resp).unwrap(); + + assert_eq!( + resp["result"]["isError"], + json!(true), + "an invalid configured root must make the tool call fail closed: {whole}" + ); + assert!( + text.contains("CODEGRAPH_DIR"), + "the error must name the offending configuration: {text}" + ); + assert!( + text.contains("project root itself"), + "the error must carry the STABLE `IndexPaths` reason (the `.` alias \ + resolves to the project root itself), not a generic message: {text}" + ); + assert!( + !text.contains("No indexed project"), + "an invalid configuration must NOT masquerade as an un-init'd project: {text}" + ); + assert!( + !whole.contains("Counter") && !whole.contains("increment") && !whole.contains("math.ts"), + "the trap DB at the default namespace must NEVER be served: {whole}" + ); + + assert_tree_unchanged( + &before, + &after, + "a fail-closed tool call must leave the project tree (trap DB included) unchanged", + ); +} + +/// The rmcp front-end (the SHIPPED transport) must fail closed identically: the +/// same trap DB at the default namespace, the same refused `CODEGRAPH_DIR=.`, a +/// real rmcp `tools/call` over a duplex transport. Proves the invalid-config +/// state survives BOTH tool-call paths, not just the hand-rolled one. +#[test] +fn invalid_configured_root_rmcp_fails_closed_and_never_serves_trap_default_db() { + let dir = unique_dir("trap-rmcp"); + let project = dir.path.join("mini"); + fs::create_dir_all(&project).unwrap(); + let trap_paths = codegraph_core::IndexPaths::resolve(&project, None).unwrap(); + stage_mini_db_at(&trap_paths, &project); + + let _env = EnvGuard::set("."); + + let before = tree_snapshot(&project); + let resp = rmcp_tool_call_search(&project); + let after = tree_snapshot(&project); + + let whole = serde_json::to_string(&resp).unwrap(); + assert_eq!( + resp["isError"], + json!(true), + "rmcp must fail closed on an invalid configured root: {whole}" + ); + let text = resp["content"][0]["text"].as_str().unwrap_or_default(); + assert!( + text.contains("CODEGRAPH_DIR") && text.contains("project root itself"), + "rmcp must surface the actionable IndexPaths diagnostic: {text}" + ); + assert!( + !text.contains("No indexed project"), + "rmcp must not mask an invalid configuration as an un-init'd project: {text}" + ); + assert!( + !whole.contains("Counter") && !whole.contains("increment") && !whole.contains("math.ts"), + "rmcp must NEVER serve the trap DB at the default namespace: {whole}" + ); + assert_tree_unchanged( + &before, + &after, + "a fail-closed rmcp tool call must mutate zero project bytes", + ); +} + +/// Drive ONE real rmcp `tools/call codegraph_search` against `project` over a +/// `tokio::io::duplex` transport, returning the serialized `CallToolResult`. +fn rmcp_tool_call_search(project: &Path) -> Value { + use rmcp::ServiceExt; + use rmcp::model::{ + CallToolRequestParams, ClientCapabilities, ClientInfo, Implementation, ProtocolVersion, + }; + + let project = project.to_path_buf(); + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("tokio runtime"); + rt.block_on(async move { + let (client_io, server_io) = tokio::io::duplex(1024 * 1024); + let handler = + codegraph_mcp::rmcp_handler::CodeGraphHandler::new(Some(project.to_path_buf())); + let server = tokio::spawn(async move { + if let Ok(running) = handler.serve(server_io).await { + let _ = running.waiting().await; + } + }); + let client = ClientInfo::new( + ClientCapabilities::default(), + Implementation::new("cfgroot", "0"), + ) + .with_protocol_version(ProtocolVersion::V_2024_11_05) + .serve(client_io) + .await + .expect("rmcp handshake"); + + let mut args = serde_json::Map::new(); + args.insert("query".to_string(), json!("add")); + args.insert( + "projectPath".to_string(), + json!(project.to_str().expect("utf8 project path")), + ); + let result = client + .call_tool( + CallToolRequestParams::new("codegraph_search".to_string()).with_arguments(args), + ) + .await + .expect("rmcp call_tool"); + let value = serde_json::to_value(&result).expect("serialize CallToolResult"); + let _ = client.cancel().await; + let _ = server.await; + value + }) +} diff --git a/crates/codegraph-mcp/tests/reopen.rs b/crates/codegraph-mcp/tests/reopen.rs index 4895276..bc74c01 100644 --- a/crates/codegraph-mcp/tests/reopen.rs +++ b/crates/codegraph-mcp/tests/reopen.rs @@ -1,10 +1,10 @@ -//! T6 (#925): `McpServer` reopens its cached `CodeGraphEngine` when the -//! project's `.codegraph/codegraph.db` is REPLACED on disk, so a long-lived -//! `serve` never keeps serving a stale database. +//! T6 (#925): a long-lived `McpServer` serves replacement database contents +//! without retaining a `CodeGraphEngine`, SQLite connection, or read lease +//! between requests. //! //! The decision is keyed on the db file IDENTITY (unix inode / a content-based //! signature on non-unix), NOT on modified-time: an in-place WAL write bumps -//! mtime but does not replace the file, so it must NOT trigger a reopen. On +//! mtime but does not replace the file, so it must NOT record a change. On //! non-unix the signature is a hash of the WAL-stable SQLite header slices //! (page count + schema cookie + structural header), which is deterministic and //! mtime-independent — it changes only when the db is rebuilt. @@ -13,17 +13,25 @@ use std::fs; use std::io::Cursor; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, MutexGuard, OnceLock}; use codegraph_core::types::FileRecord; use codegraph_extract::engine::{detect_language, extract_file}; use codegraph_mcp::McpServer; -use codegraph_mcp::server::reopen_count; +use codegraph_mcp::server::db_identity_change_count; use codegraph_store::Store; use serde_json::{Value, json}; static TEMP_SEQ: AtomicU64 = AtomicU64::new(0); +fn counter_test_guard() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + /// Owns a temp project dir and removes it on drop. struct TestProject { path: PathBuf, @@ -48,7 +56,7 @@ fn unique_base(tag: &str) -> PathBuf { .as_nanos(); let seq = TEMP_SEQ.fetch_add(1, Ordering::Relaxed); std::env::temp_dir().join(format!( - "cg-mcp-reopen-{tag}-{}-{nanos}-{seq}", + "cg-mcp-identity-{tag}-{}-{nanos}-{seq}", std::process::id() )) } @@ -63,7 +71,8 @@ fn index_into(base: &Path, files: &[(&str, &str)]) { fs::create_dir_all(dst.parent().unwrap()).unwrap(); fs::write(&dst, src).unwrap(); } - let mut store = Store::open(&base.join(".codegraph").join("codegraph.db")).unwrap(); + // Batch M: the MCP engine reads the isolated v2 namespace (`.codegraph-v2`). + let mut store = Store::open(&base.join(".codegraph-v2").join("codegraph.db")).unwrap(); let mut all_edges = Vec::new(); for (rel, src) in files { let result = extract_file(base, rel).unwrap(); @@ -84,11 +93,13 @@ fn index_into(base: &Path, files: &[(&str, &str)]) { } store.insert_edges(&all_edges).unwrap(); drop(store); + let paths = codegraph_core::IndexPaths::resolve(base, None).unwrap(); + codegraph_store::test_support::finalize_current_test_fixture(&paths).unwrap(); } /// Drive one `codegraph_search` `tools/call` against `server`, returning the -/// rendered text body (NOT a fresh server — reuse the SAME server so its engine -/// cache persists across calls, which is the whole point of the test). +/// rendered text body. Reusing the SAME server proves request-scoped engines do +/// not leave stale graph state attached to the long-lived protocol session. fn search(server: &mut McpServer, project: &Path, query: &str) -> String { let req = json!({ "jsonrpc": "2.0", @@ -114,7 +125,8 @@ fn search(server: &mut McpServer, project: &Path, query: &str) -> String { } #[test] -fn reopens_cached_engine_when_db_replaced_with_new_inode() { +fn same_server_reads_replacement_graph_without_close_helper() { + let _counter = counter_test_guard(); // Given: an indexed project whose db (inode A) contains `alphaSymbol`. let project = TestProject { path: unique_base("replace"), @@ -126,66 +138,60 @@ fn reopens_cached_engine_when_db_replaced_with_new_inode() { let mut server = McpServer::new(Some(project.path().to_path_buf())); - // Populate the engine cache against inode A. let first = search(&mut server, project.path(), "alphaSymbol"); assert!( first.contains("alphaSymbol"), "first call should find the original symbol; got:\n{first}" ); - let reopens_before = reopen_count(); + let changes_before = db_identity_change_count(); + let identity_before = db_identity(project.path()); // When: the db is REPLACED on disk with a fresh index whose content differs - // (a new symbol `betaSymbol`). The cached engine still holds an open WAL - // connection, which on windows blocks deleting the db dir (OS error 32). - // Drop the live connections first — `close_cached_handles` releases the file - // handle while keeping the cache RECORD, so the next call reopens exactly - // once and re-reads the freshly built db from disk. Removing the dir first - // guarantees a freshly built db. - // - // We deliberately do NOT assert the raw `DbIdentity` changed here. On unix - // the identity is the inode, and the OS is free to RECYCLE the just-deleted - // db's inode for the rebuilt file (observed under `cargo llvm-cov`: both = - // 1343671), so an `assert_ne!(id_before, id_after)` gambles on an OS - // implementation detail that delete+rebuild does not guarantee. That inode - // assertion was the sole flake source and is redundant with #925's real - // contract, which is the BEHAVIORAL outcome asserted below and does not - // depend on inode luck: the SAME cached server reopens its engine and serves - // the NEW on-disk content. Serving `betaSymbol` (not `alphaSymbol`, not an - // open error) is itself the proof the engine reopened against the fresh db. - server.close_cached_handles(); - fs::remove_dir_all(project.path().join(".codegraph")).unwrap(); + // (a new symbol `betaSymbol`). No compatibility helper participates: every + // request must already have dropped its engine, SQLite handles, and lease. + fs::remove_dir_all(project.path().join(".codegraph-v2")).unwrap(); index_into( project.path(), &[("src/b.ts", "export function betaSymbol() {}\n")], ); + let identity_after = db_identity(project.path()); - // Then: the SAME server's SAME tool call must REOPEN the engine — without the - // #925 fix the cached engine keeps serving the old db and `reopen_count` - // never advances (this delta is the deterministic RED probe). + // Then: the SAME server's next request serves only the replacement graph. let after = search(&mut server, project.path(), "betaSymbol"); - let reopens_after = reopen_count(); + let changes_after = db_identity_change_count(); + #[cfg(unix)] assert_eq!( - reopens_after - reopens_before, - 1, - "a db replacement (new inode) must trigger exactly one reopen \ - (before={reopens_before}, after={reopens_after})" + changes_after - changes_before, + u64::from(identity_before != identity_after), + "the observation counter must match the inode actually allocated \ + (before={changes_before}, after={changes_after})" + ); + #[cfg(not(unix))] + let _ = ( + changes_before, + changes_after, + identity_before, + identity_after, ); - // And: it returns FRESH data from inode B, not an open error. assert!( after.contains("betaSymbol"), - "after replacement the engine must reopen and serve the new index; got:\n{after}" + "after replacement the next request must serve the new index; got:\n{after}" + ); + assert!( + !after.contains("alphaSymbol"), + "after replacement the old graph must not leak into the response; got:\n{after}" ); assert!( !after.to_lowercase().contains("failed to open"), - "reopen must not surface an open error; got:\n{after}" + "the replacement request must not surface an open error; got:\n{after}" ); } #[test] -fn does_not_reopen_when_inode_is_unchanged() { - // Given: an indexed project; one tool call populates the cache (one open, - // which is NOT counted as a reopen). +fn unchanged_identity_does_not_increment_observation_count() { + let _counter = counter_test_guard(); + // Given: one request establishes the server's identity observation baseline. let project = TestProject { path: unique_base("stable"), }; @@ -197,13 +203,13 @@ fn does_not_reopen_when_inode_is_unchanged() { let mut server = McpServer::new(Some(project.path().to_path_buf())); let _ = search(&mut server, project.path(), "gammaSymbol"); - let after_first = reopen_count(); + let after_first = db_identity_change_count(); // When: more calls run WITHOUT replacing the db (same file), even after an // in-place mtime bump (a normal WAL write) — that must NOT be treated as a // replace. First prove the identity itself is content-neutral under a pure // mtime touch (the header bytes are unchanged, so the signature holds). - let db = project.path().join(".codegraph").join("codegraph.db"); + let db = project.path().join(".codegraph-v2").join("codegraph.db"); let id_before_touch = db_identity(project.path()); filetouch(&db); assert_eq!( @@ -214,25 +220,50 @@ fn does_not_reopen_when_inode_is_unchanged() { for _ in 0..5 { let _ = search(&mut server, project.path(), "gammaSymbol"); } - let after_many = reopen_count(); + let after_many = db_identity_change_count(); - // Then: the engine identity is stable — no reopen fired after the initial - // open, despite the changed mtime. + // Then: the stable identity produced no replacement observation. assert_eq!( after_first, after_many, - "a same-file (in-place) project must NOT trigger any reopen \ + "a same-file (in-place) project must NOT record an identity change \ (after_first_call={after_first}, after_many={after_many})" ); } +#[test] +fn close_cached_handles_resets_diagnostics_without_forcing_replacement() { + let _counter = counter_test_guard(); + let project = TestProject { + path: unique_base("close-diagnostic"), + }; + index_into( + project.path(), + &[("src/a.ts", "export function deltaSymbol() {}\n")], + ); + let mut server = McpServer::new(Some(project.path().to_path_buf())); + assert!(search(&mut server, project.path(), "deltaSymbol").contains("deltaSymbol")); + let before = db_identity_change_count(); + + server.close_cached_handles(); + let after = search(&mut server, project.path(), "deltaSymbol"); + + assert!(after.contains("deltaSymbol")); + assert_eq!( + db_identity_change_count(), + before, + "clearing diagnostic identity state must not synthesize a replacement" + ); +} + /// Mirror of the production `DbIdentity` for the project's db file, folded into -/// a single `u128` so the replace test can assert the identity actually changed. +/// a single `u128` so the replacement test can condition its metric assertion +/// on the identity the filesystem actually allocated. /// Unix uses the inode; non-unix mirrors the production `(len, creation_time, /// header_sig)` fold where `header_sig` hashes the SAME WAL-stable SQLite header /// slices (`[16..24]`, `[28..32]`, `[40..44]`) — NO mtime, so a pure mtime touch /// leaves it unchanged while a rebuild changes it. fn db_identity(project: &Path) -> u128 { - let db = project.join(".codegraph").join("codegraph.db"); + let db = project.join(".codegraph-v2").join("codegraph.db"); let meta = fs::metadata(&db).expect("db metadata"); #[cfg(unix)] { diff --git a/crates/codegraph-mcp/tests/support/parity.rs b/crates/codegraph-mcp/tests/support/parity.rs index af4800c..039d12f 100644 --- a/crates/codegraph-mcp/tests/support/parity.rs +++ b/crates/codegraph-mcp/tests/support/parity.rs @@ -83,11 +83,14 @@ pub fn setup_mini_project() -> TestProject { "cg-mcp-parity-{}-{nanos}-{seq}", std::process::id() )); - fs::create_dir_all(base.join(".codegraph")).unwrap(); + // Batch M: the MCP engine now opens the isolated v2 namespace. + fs::create_dir_all(base.join(".codegraph-v2")).unwrap(); copy_with_retry( &root.join("reference/golden/mini/colby.db"), - &base.join(".codegraph").join("codegraph.db"), + &base.join(".codegraph-v2").join("codegraph.db"), ); + let paths = codegraph_core::IndexPaths::resolve(&base, None).unwrap(); + codegraph_store::test_support::finalize_current_test_fixture(&paths).unwrap(); let fixtures = root.join("crates/codegraph-bench/fixtures/mini"); for rel in ["src/app.ts", "src/math.ts", "tools/greeter.py"] { diff --git a/crates/codegraph-resolve/src/framework.rs b/crates/codegraph-resolve/src/framework.rs index 9d4c59f..e850874 100644 --- a/crates/codegraph-resolve/src/framework.rs +++ b/crates/codegraph-resolve/src/framework.rs @@ -23,8 +23,57 @@ //! On a project where none are detected the orchestrator holds an empty list, so //! behavior is identical to the upstream with zero `FrameworkResolver`s detected. +use crate::frameworks::godot_dsl_config::GodotDslConfig; use crate::types::{FrameworkResolverExtractionResult, RefView, ResolutionContext, ResolvedRef}; use codegraph_core::types::Language; +use std::sync::Arc; + +/// The per-project inputs a [`FrameworkResolver::extract`] pass may need beyond +/// the file itself: the absolute project root, and the project's explicitly +/// loaded framework configuration. +/// +/// Project configuration is PASSED IN, never discovered: the pipeline loads it +/// once from the addressed project's resolved current index root +/// (`/codegraph.json`) and hands the same immutable value to every +/// file's extraction. No resolver walks the directory tree, reads a legacy +/// `.codegraph/codegraph.json`, or caches config across projects, so one process +/// serving several projects can never mix their configuration. +#[derive(Debug, Clone)] +pub struct FrameworkExtractionContext { + project_root: String, + godot_dsl: Arc, +} + +impl FrameworkExtractionContext { + /// Build a context for `project_root` with the project's Godot DSL config. + #[must_use] + pub fn new(project_root: impl Into, godot_dsl: Arc) -> Self { + Self { + project_root: project_root.into(), + godot_dsl, + } + } + + /// Build a context for `project_root` with NO project configuration — the + /// off-by-default case (and what a caller that addresses no project uses). + #[must_use] + pub fn without_config(project_root: impl Into) -> Self { + Self::new(project_root, GodotDslConfig::empty()) + } + + /// The absolute project root being indexed (may be empty for a caller that + /// supplies absolute file paths). + #[must_use] + pub fn project_root(&self) -> &str { + &self.project_root + } + + /// The project's opt-in Godot DSL configuration (empty when it declares none). + #[must_use] + pub fn godot_dsl(&self) -> &GodotDslConfig { + &self.godot_dsl + } +} /// A framework-specific resolution strategy (EXTENSION POINT). /// @@ -64,18 +113,16 @@ pub trait FrameworkResolver: Sync { /// per-file extraction. /// /// `file_path` is the repo-RELATIVE path that MUST be used for all node / - /// reference attribution (preserving golden byte-stability). `project_root` - /// is the absolute project root the pipeline is indexing; resolvers that - /// read a per-project `.codegraph/codegraph.json` (e.g. Godot's opt-in DSL - /// config) MUST resolve that config against `project_root.join(file_path)` - /// rather than the process CWD — otherwise the config is only found when the - /// CLI happens to run with its CWD == the project root. Resolvers that need - /// no project config simply ignore `project_root`. + /// reference attribution (preserving golden byte-stability). `context` + /// carries the absolute project root plus the project's EXPLICITLY loaded + /// configuration (see [`FrameworkExtractionContext`]); a resolver that needs + /// per-project config reads it from there instead of discovering a file, + /// and one that needs none simply ignores it. fn extract( &self, _file_path: &str, _content: &str, - _project_root: &str, + _context: &FrameworkExtractionContext, ) -> Option { None } @@ -160,6 +207,7 @@ mod tests { fn a_ref() -> RefView { RefView { + row_id: None, from_node_id: "from".to_string(), reference_name: "X".to_string(), reference_kind: EdgeKind::Calls, @@ -196,7 +244,15 @@ mod tests { #[test] fn extract_default_is_none() { // The default `extract` returns None (no per-file extraction). - assert!(BareResolver.extract("a.ts", "content", "/root").is_none()); + assert!( + BareResolver + .extract( + "a.ts", + "content", + &FrameworkExtractionContext::without_config("/root") + ) + .is_none() + ); } #[test] diff --git a/crates/codegraph-resolve/src/frameworks/godot.rs b/crates/codegraph-resolve/src/frameworks/godot.rs index b2782f7..0831584 100644 --- a/crates/codegraph-resolve/src/frameworks/godot.rs +++ b/crates/codegraph-resolve/src/frameworks/godot.rs @@ -237,8 +237,9 @@ impl FrameworkResolver for GodotResolver { &self, file_path: &str, content: &str, - project_root: &str, + context: &crate::framework::FrameworkExtractionContext, ) -> Option { + let project_root = context.project_root(); if godot_project::is_project_godot(file_path) { return Some(godot_project::parse_project_godot( file_path, @@ -250,7 +251,11 @@ impl FrameworkResolver for GodotResolver { return Some(godot_scene::parse_tscn(file_path, content)); } if godot_resource::is_tres(file_path) { - return Some(godot_resource::parse_tres(file_path, content, project_root)); + return Some(godot_resource::parse_tres( + file_path, + content, + context.godot_dsl(), + )); } if godot_script::is_gdscript(file_path) { let mut result = godot_script::parse_gdscript_dynamics(file_path, content); @@ -579,6 +584,7 @@ mod tests { fn gd_ref(name: &str, kind: EdgeKind) -> RefView { RefView { + row_id: None, from_node_id: "from:caller.gd".to_string(), reference_name: name.to_string(), reference_kind: kind, diff --git a/crates/codegraph-resolve/src/frameworks/godot_dsl_config.rs b/crates/codegraph-resolve/src/frameworks/godot_dsl_config.rs index ceff110..993b6e6 100644 --- a/crates/codegraph-resolve/src/frameworks/godot_dsl_config.rs +++ b/crates/codegraph-resolve/src/frameworks/godot_dsl_config.rs @@ -1,6 +1,7 @@ -//! OPT-IN DSL config reader for Godot `.tres` resource fields (L5 / T9). +//! OPT-IN DSL config for Godot `.tres` resource fields (L5 / T9). //! -//! Reads an OPTIONAL, OFF-by-default block from `.codegraph/codegraph.json`: +//! Reads an OPTIONAL, OFF-by-default block from ONE project's current-root +//! `codegraph.json` ([`IndexPaths::extension_config`]): //! //! ```jsonc //! { "godot": { "dsl": { "resourceFields": ["skill_effect", "effect_on"] } } } @@ -25,30 +26,27 @@ //! } } } } //! ``` //! -//! Same off-by-default + tolerant-parse + mtime-cache discipline as -//! `resourceFields`. All field names / kinds / separators / segment indices are -//! project-supplied; nothing is hardcoded. See [`dsl_id_fields`] and -//! [`super::godot_resource`]'s `dsl_id_targets`. +//! # Explicit, project-scoped, cache-free //! -//! # Mirrors `codegraph_extract::ext_config` +//! The config is loaded ONCE per operation from the addressed project's resolved +//! current root and threaded into extraction as an immutable +//! [`GodotDslConfig`]. Nothing here walks up the directory tree, consults the +//! process working directory, reads a legacy `.codegraph/codegraph.json`, or +//! caches across calls — so two projects handled by one process can never see +//! each other's DSL fields and no mtime cache can go stale. //! -//! The reading strategy is intentionally identical to the existing -//! `.codegraph/codegraph.json` extension-override reader: walk up the directory -//! tree from the file being parsed to find the nearest config, parse it with -//! `serde` using `#[serde(default)]` at every level (so any missing key yields -//! the empty list), tolerate a malformed file (logged + ignored, never panics), -//! and mtime-cache the parsed result per config-file path — caching "absent" -//! too, so a project with no `godot.dsl` block pays no repeated I/O. +//! Parsing stays tolerant (`#[serde(default)]` at every level, a malformed file +//! yielding the empty config), preserving the documented opt-in contract. +use codegraph_core::IndexPaths; use serde::Deserialize; -use std::collections::{BTreeMap, HashMap}; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex, OnceLock}; -use std::time::SystemTime; +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::Arc; -/// Top-level `.codegraph/codegraph.json` shape — only the `godot` key matters -/// here; other keys (e.g. `extensions`) are ignored. `#[serde(default)]` makes a -/// file with no `godot` key parse to an empty config. +/// Top-level `codegraph.json` shape — only the `godot` key matters here; other +/// keys (e.g. `extensions`) are ignored. `#[serde(default)]` makes a file with no +/// `godot` key parse to an empty config. #[derive(Debug, Default, Deserialize)] struct CodegraphJson { #[serde(default)] @@ -59,13 +57,12 @@ struct CodegraphJson { #[derive(Debug, Default, Deserialize)] struct GodotConfig { #[serde(default)] - dsl: GodotDslConfig, + dsl: GodotDslConfigFile, } -/// The `godot.dsl` block. `resourceFields` lists the `.tres` `[resource]` -/// property names that should emit a reference edge from their value. +/// The raw `godot.dsl` block as it appears on disk. #[derive(Debug, Default, Deserialize)] -struct GodotDslConfig { +struct GodotDslConfigFile { #[serde(default, rename = "resourceFields")] resource_fields: Vec, #[serde(default, rename = "idFields")] @@ -77,7 +74,7 @@ struct GodotDslConfig { /// /// `separator` + `id_segments` together select compound parts; with neither, the /// whole quote-stripped value is the single ID. All fields are project-supplied. -#[derive(Debug, Default, Clone, Deserialize)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)] pub struct IdFieldSpec { #[serde(default)] pub kind: String, @@ -87,211 +84,167 @@ pub struct IdFieldSpec { pub id_segments: Option>, } -/// The cached, parsed DSL field list for one config-file path. -type DslFields = Vec; - -#[derive(Clone)] -enum CacheEntry { - Absent, - Present { - mtime: SystemTime, - fields: Arc, - }, -} - -fn cache() -> &'static Mutex> { - static CACHE: OnceLock>> = OnceLock::new(); - CACHE.get_or_init(|| Mutex::new(HashMap::new())) +/// One project's parsed, immutable Godot DSL configuration: the `resourceFields` +/// list and the `idFields` spec map. Both empty for a project that declares none +/// (the off-by-default case). +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct GodotDslConfig { + resource_fields: Vec, + id_fields: BTreeMap, } -/// The configured DSL resource-field names for the `.tres` at `path`, resolving -/// the nearest `.codegraph/codegraph.json` walking up from `path`. Returns an -/// EMPTY list when no config is reachable, the file is malformed, or no -/// `godot.dsl.resourceFields` block is present — i.e. the off-by-default case -/// yields no DSL behavior. -pub(crate) fn dsl_resource_fields(path: &Path) -> Arc { - let Some(config_path) = find_config_path(path) else { - return Arc::new(Vec::new()); - }; - load_cached(&config_path) -} +impl GodotDslConfig { + /// The empty config — no DSL resource fields, no ID fields. + #[must_use] + pub fn empty() -> Arc { + Arc::new(Self::default()) + } -/// Walk up from `path` to the nearest `.codegraph/codegraph.json`. The starting -/// directory is the file's own parent. `path` is expected to be ABSOLUTE — the -/// pipeline resolves the `.tres` against the project root before calling -/// (`godot_resource::config_lookup_path`), and the unit tests pass absolute -/// paths. A relative `path` is still tolerated (joined onto the CWD, matching -/// `ext_config::find_config_path`), but the pipeline never relies on that CWD -/// join: doing so silently mislocated the config whenever the CLI ran with its -/// CWD != the project root, which is the bug this resolution was hardened -/// against. -fn find_config_path(file_path: &Path) -> Option { - let start = if file_path.is_absolute() { - file_path.parent().map(Path::to_path_buf) - } else { - std::env::current_dir() - .ok() - .map(|cwd| cwd.join(file_path)) - .and_then(|abs| abs.parent().map(Path::to_path_buf)) - }?; - let mut dir: Option<&Path> = Some(start.as_path()); - while let Some(current) = dir { - let candidate = current.join(".codegraph").join("codegraph.json"); - if candidate.is_file() { - return Some(candidate); - } - dir = current.parent(); + /// Load the DSL config declared by ONE project's current index root + /// (`/codegraph.json`). A missing, unreadable, or malformed + /// file yields the empty config. + #[must_use] + pub fn load_for_paths(paths: &IndexPaths) -> Arc { + Self::load_from_file(&paths.extension_config()) } - None -} -/// Return the cached DSL field list for `config_path`, re-parsing when the -/// file's mtime changed. Mirrors `ext_config::load_cached`; "absent" (unreadable -/// file) is cached as an empty list so it is not re-read. -fn load_cached(config_path: &Path) -> Arc { - let current_mtime = std::fs::metadata(config_path) - .and_then(|m| m.modified()) - .ok(); - let mut guard = cache().lock().unwrap_or_else(|p| p.into_inner()); + /// Load from an explicit `codegraph.json` path, with the same tolerance as + /// [`Self::load_for_paths`]. + #[must_use] + pub fn load_from_file(config_path: &Path) -> Arc { + let Ok(contents) = std::fs::read_to_string(config_path) else { + return Self::empty(); + }; + Arc::new(Self::parse(&contents)) + } - if let Some(entry) = guard.get(config_path) { - match (entry, current_mtime) { - (CacheEntry::Present { mtime, fields }, Some(now)) if *mtime == now => { - return Arc::clone(fields); - } - (CacheEntry::Absent, None) => return Arc::new(Vec::new()), - _ => {} + /// Parse the `godot.dsl` block out of `contents`, tolerating any parse + /// failure as the empty config. Field names are trimmed; empty names are + /// dropped, and an `idFields` entry needs a non-empty key AND kind. + #[must_use] + pub fn parse(contents: &str) -> Self { + // A malformed config is swallowed silently (not logged): + // `codegraph-resolve` has no logging dependency, and the no-new-dep + // posture forbids adding one. + let Ok(parsed) = serde_json::from_str::(contents) else { + return Self::default(); + }; + let resource_fields = parsed + .godot + .dsl + .resource_fields + .into_iter() + .map(|field| field.trim().to_string()) + .filter(|field| !field.is_empty()) + .collect(); + let id_fields = parsed + .godot + .dsl + .id_fields + .into_iter() + .map(|(key, spec)| (key.trim().to_string(), spec)) + .filter(|(key, spec)| !key.is_empty() && !spec.kind.trim().is_empty()) + .collect(); + Self { + resource_fields, + id_fields, } } - let Some(mtime) = current_mtime else { - guard.insert(config_path.to_path_buf(), CacheEntry::Absent); - return Arc::new(Vec::new()); - }; - - let fields = Arc::new(parse_config(config_path)); - guard.insert( - config_path.to_path_buf(), - CacheEntry::Present { - mtime, - fields: Arc::clone(&fields), - }, - ); - fields -} - -/// Parse `godot.dsl.resourceFields` out of the config file, tolerating any -/// read/parse failure as an empty list (logged). Field names are trimmed; empty -/// names are dropped. Mirrors `ext_config::parse_config`'s tolerance. -fn parse_config(config_path: &Path) -> DslFields { - let Ok(contents) = std::fs::read_to_string(config_path) else { - return Vec::new(); - }; - // Malformed config is swallowed silently (not logged): `codegraph-resolve` - // has no logging dependency, and the no-new-dep posture forbids adding one. - let Ok(parsed) = serde_json::from_str::(&contents) else { - return Vec::new(); - }; - - parsed - .godot - .dsl - .resource_fields - .into_iter() - .map(|f| f.trim().to_string()) - .filter(|f| !f.is_empty()) - .collect() -} - -// --------------------------------------------------------------------------- -// `idFields` reader — a second, independent opt-in block (PR2). Same shape as -// the `resourceFields` reader above: tree-walk find_config_path + mtime cache + -// tolerant parse + "absent" caching, kept separate so `resourceFields` behavior -// is provably untouched. -// --------------------------------------------------------------------------- - -/// The cached, parsed `idFields` spec map for one config-file path. -type IdFields = BTreeMap; - -#[derive(Clone)] -enum IdCacheEntry { - Absent, - Present { - mtime: SystemTime, - fields: Arc, - }, -} + /// `true` when the project declared neither `resourceFields` nor `idFields`. + #[must_use] + pub fn is_empty(&self) -> bool { + self.resource_fields.is_empty() && self.id_fields.is_empty() + } -fn id_cache() -> &'static Mutex> { - static CACHE: OnceLock>> = OnceLock::new(); - CACHE.get_or_init(|| Mutex::new(HashMap::new())) -} + /// The configured `.tres` `[resource]` property names whose value becomes a + /// reference target, in declaration order. + #[must_use] + pub fn resource_fields(&self) -> &[String] { + &self.resource_fields + } -/// The configured `idFields` spec map for the `.tres` at `path`, resolving the -/// nearest `.codegraph/codegraph.json` walking up from `path`. Returns an EMPTY -/// map when no config is reachable, the file is malformed, or no -/// `godot.dsl.idFields` block is present — the off-by-default case yields no ID -/// behavior. -pub(crate) fn dsl_id_fields(path: &Path) -> Arc { - let Some(config_path) = find_config_path(path) else { - return Arc::new(BTreeMap::new()); - }; - load_cached_id(&config_path) + /// The configured `idFields` spec map, keyed by property name. + #[must_use] + pub fn id_fields(&self) -> &BTreeMap { + &self.id_fields + } } -/// Return the cached `idFields` map for `config_path`, re-parsing when the -/// file's mtime changed. Mirrors [`load_cached`]; "absent" is cached as an empty -/// map so it is not re-read. -fn load_cached_id(config_path: &Path) -> Arc { - let current_mtime = std::fs::metadata(config_path) - .and_then(|m| m.modified()) - .ok(); - let mut guard = id_cache().lock().unwrap_or_else(|p| p.into_inner()); - - if let Some(entry) = guard.get(config_path) { - match (entry, current_mtime) { - (IdCacheEntry::Present { mtime, fields }, Some(now)) if *mtime == now => { - return Arc::clone(fields); - } - (IdCacheEntry::Absent, None) => return Arc::new(BTreeMap::new()), - _ => {} - } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_resource_and_id_fields() { + let config = GodotDslConfig::parse( + r#"{"godot":{"dsl":{"resourceFields":[" skill_effect ",""], + "idFields":{"buff_id":{"kind":"buff"}, + " skill ":{"kind":"skill","separator":":","idSegments":[0,2]}, + "bad":{"kind":" "}}}}}"#, + ); + assert_eq!(config.resource_fields(), ["skill_effect"]); + let ids = config.id_fields(); + assert_eq!(ids.len(), 2, "empty-kind entries are dropped: {ids:?}"); + assert_eq!(ids["buff_id"].kind, "buff"); + assert_eq!(ids["skill"].separator.as_deref(), Some(":")); + assert_eq!(ids["skill"].id_segments.as_deref(), Some([0, 2].as_slice())); } - let Some(mtime) = current_mtime else { - guard.insert(config_path.to_path_buf(), IdCacheEntry::Absent); - return Arc::new(BTreeMap::new()); - }; - - let fields = Arc::new(parse_id_config(config_path)); - guard.insert( - config_path.to_path_buf(), - IdCacheEntry::Present { - mtime, - fields: Arc::clone(&fields), - }, - ); - fields -} + #[test] + fn absent_block_and_malformed_json_are_empty() { + assert!(GodotDslConfig::parse(r#"{"extensions":{".zz":"lua"}}"#).is_empty()); + assert!(GodotDslConfig::parse("{ not json ").is_empty()); + assert!(GodotDslConfig::default().is_empty()); + } -/// Parse `godot.dsl.idFields` out of the config file, tolerating any read/parse -/// failure as an empty map. Entry keys are trimmed; entries with an empty key or -/// empty `kind` are dropped (a sentinel needs a non-empty key + kind). Mirrors -/// [`parse_config`]'s tolerance. -fn parse_id_config(config_path: &Path) -> IdFields { - let Ok(contents) = std::fs::read_to_string(config_path) else { - return BTreeMap::new(); - }; - let Ok(parsed) = serde_json::from_str::(&contents) else { - return BTreeMap::new(); - }; + /// The reader consults ONLY the current-root config path; a legacy + /// `.codegraph/codegraph.json` beside it is never adopted. + #[test] + fn load_for_paths_reads_only_the_current_root_config() { + let project = std::env::temp_dir().join(format!( + "codegraph-godot-dsl-scoped-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(project.join(".codegraph")).unwrap(); + std::fs::write( + project.join(".codegraph/codegraph.json"), + r#"{"godot":{"dsl":{"resourceFields":["legacy_field"]}}}"#, + ) + .unwrap(); + let paths = IndexPaths::resolve(&project, None).expect("resolve paths"); + std::fs::create_dir_all(paths.current_root()).unwrap(); + std::fs::write( + paths.extension_config(), + r#"{"godot":{"dsl":{"resourceFields":["current_field"]}}}"#, + ) + .unwrap(); + + let config = GodotDslConfig::load_for_paths(&paths); + assert_eq!(config.resource_fields(), ["current_field"]); + + let _ = std::fs::remove_dir_all(&project); + } - parsed - .godot - .dsl - .id_fields - .into_iter() - .map(|(k, spec)| (k.trim().to_string(), spec)) - .filter(|(k, spec)| !k.is_empty() && !spec.kind.trim().is_empty()) - .collect() + #[test] + fn missing_current_root_config_is_empty() { + let project = std::env::temp_dir().join(format!( + "codegraph-godot-dsl-absent-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&project).unwrap(); + let paths = IndexPaths::resolve(&project, None).expect("resolve paths"); + + assert!(GodotDslConfig::load_for_paths(&paths).is_empty()); + + let _ = std::fs::remove_dir_all(&project); + } } diff --git a/crates/codegraph-resolve/src/frameworks/godot_project.rs b/crates/codegraph-resolve/src/frameworks/godot_project.rs index b0bb837..9a20a82 100644 --- a/crates/codegraph-resolve/src/frameworks/godot_project.rs +++ b/crates/codegraph-resolve/src/frameworks/godot_project.rs @@ -425,6 +425,7 @@ fn constant_node(file_path: &str, line_no: i64, name: &str) -> Node { /// Build a `References` edge from a synthesized node to a repo-relative path. fn reference(from_node_id: String, target: String, line_no: i64, file_path: &str) -> RefView { RefView { + row_id: None, from_node_id, reference_name: target, reference_kind: EdgeKind::References, diff --git a/crates/codegraph-resolve/src/frameworks/godot_resource.rs b/crates/codegraph-resolve/src/frameworks/godot_resource.rs index b604b1e..870e960 100644 --- a/crates/codegraph-resolve/src/frameworks/godot_resource.rs +++ b/crates/codegraph-resolve/src/frameworks/godot_resource.rs @@ -63,14 +63,13 @@ //! (only the text `.tres`). use std::collections::{BTreeMap, HashMap}; -use std::path::{Path, PathBuf}; use codegraph_core::node_id::generate_node_id; use codegraph_core::types::{EdgeKind, Language, Node, NodeKind, ReferenceSubkind}; use super::framework_node; use super::godot_common::{map_res_path, quoted_strings, strip_quotes}; -use super::godot_dsl_config::{IdFieldSpec, dsl_id_fields, dsl_resource_fields}; +use super::godot_dsl_config::{GodotDslConfig, IdFieldSpec}; use crate::types::{FrameworkResolverExtractionResult, RefView}; /// The marker-node name used when a `.tres` has no `[gd_resource type="..."]`. @@ -86,21 +85,6 @@ pub(crate) fn is_tres(file_path: &str) -> bool { .is_some_and(|base| base.ends_with(".tres")) } -/// The path the DSL config reader walks up from. An absolute `file_path` is used -/// as-is (test callers pass absolute paths; `find_config_path` walks from its -/// parent). A relative `file_path` (the pipeline's repo-relative path) is joined -/// onto `project_root` so the walk starts at the file's real on-disk location — -/// independent of the process CWD. An empty `project_root` falls back to the -/// `file_path` verbatim (the pre-fix behavior, for callers that supply none). -fn config_lookup_path(file_path: &str, project_root: &str) -> PathBuf { - let p = Path::new(file_path); - if p.is_absolute() || project_root.is_empty() { - p.to_path_buf() - } else { - Path::new(project_root).join(file_path) - } -} - /// Parse a `.tres` resource into a resource marker node + its script/resource /// references. /// @@ -111,31 +95,25 @@ fn config_lookup_path(file_path: &str, project_root: &str) -> PathBuf { pub(crate) fn parse_tres( file_path: &str, content: &str, - project_root: &str, + dsl: &GodotDslConfig, ) -> FrameworkResolverExtractionResult { let mut nodes: Vec = Vec::new(); let mut references: Vec = Vec::new(); - // The DSL config is resolved against the project root, NOT `file_path`'s - // CWD-relative join: the pipeline passes a repo-RELATIVE `file_path` (used - // verbatim for golden-stable node/ref attribution below), so config lookup - // walks up from the file's ABSOLUTE location (`project_root` + `file_path`). - // A `file_path` that is already absolute is used as-is. This keeps - // attribution relative while letting `find_config_path` find the project's - // `.codegraph/codegraph.json` regardless of the process CWD. - let config_lookup_path = config_lookup_path(file_path, project_root); - // OPT-IN DSL fields (T9): the `godot.dsl.resourceFields` list from the - // nearest `.codegraph/codegraph.json` (empty when absent — the off-by-default - // case, so zero DSL behavior). A `key = value` line whose key is in this list - // emits a reference to its value (string literal → the literal text). - let dsl_fields = dsl_resource_fields(&config_lookup_path); - - // OPT-IN ID fields (PR2): the `godot.dsl.idFields` spec map from the same - // nearest config (empty when absent — off-by-default). A `key = value` line + // addressed project's EXPLICITLY loaded current-root `codegraph.json` (empty + // when it declares none — the off-by-default case, so zero DSL behavior). A + // `key = value` line whose key is in this list emits a reference to its value + // (string literal → the literal text). The config arrives as a parameter, so + // `file_path` stays purely an attribution path and no directory walk or + // process-CWD lookup can mislocate it. + let dsl_fields = dsl.resource_fields(); + + // OPT-IN ID fields (PR2): the `godot.dsl.idFields` spec map from the SAME + // project config (empty when absent — off-by-default). A `key = value` line // whose key is a spec key emits one `godot:id::` sentinel per // selected segment. Independent of `dsl_fields`: a line may match both. - let id_fields = dsl_id_fields(&config_lookup_path); + let id_fields = dsl.id_fields(); // In-file ext_resource lookup: id → repo-relative path. Built top-down as // the file is scanned (Godot declares ext_resources before `[resource]`), @@ -189,7 +167,7 @@ pub(crate) fn parse_tres( // values fall through to the opt-in DSL check. let target = match resolve_ext_resource(value, &ext_resources) { Some(resolved) => Some(resolved), - None => dsl_literal_target(key, value, &dsl_fields), + None => dsl_literal_target(key, value, dsl_fields), }; if let Some(target) = target { let from = marker_id(&mut marker, &mut nodes, file_path, line_no, &resource_type); @@ -198,7 +176,7 @@ pub(crate) fn parse_tres( // The opt-in PR2 ID edges: one `godot:id::` sentinel per // configured segment, emitted AFTER the standard edge so a line that // matches both keeps source-line order. Empty when `id_fields` is empty. - for sentinel in dsl_id_targets(key, value, &id_fields) { + for sentinel in dsl_id_targets(key, value, id_fields) { let from = marker_id(&mut marker, &mut nodes, file_path, line_no, &resource_type); references.push(reference(from, sentinel, line_no, file_path)); } @@ -353,6 +331,7 @@ fn constant_node(file_path: &str, line_no: i64, name: &str) -> Node { /// repo-relative path. fn reference(from_node_id: String, target: String, line_no: i64, file_path: &str) -> RefView { RefView { + row_id: None, from_node_id, reference_name: target, reference_kind: EdgeKind::References, diff --git a/crates/codegraph-resolve/src/frameworks/godot_scene.rs b/crates/codegraph-resolve/src/frameworks/godot_scene.rs index 699407d..d0cd6bc 100644 --- a/crates/codegraph-resolve/src/frameworks/godot_scene.rs +++ b/crates/codegraph-resolve/src/frameworks/godot_scene.rs @@ -537,6 +537,7 @@ fn reference( subkind: ReferenceSubkind, ) -> RefView { RefView { + row_id: None, from_node_id, reference_name: target, reference_kind: kind, diff --git a/crates/codegraph-resolve/src/frameworks/godot_script.rs b/crates/codegraph-resolve/src/frameworks/godot_script.rs index ce0a3fa..fefd556 100644 --- a/crates/codegraph-resolve/src/frameworks/godot_script.rs +++ b/crates/codegraph-resolve/src/frameworks/godot_script.rs @@ -947,6 +947,7 @@ fn reference( file_path: &str, ) -> RefView { RefView { + row_id: None, from_node_id: from_node_id.to_string(), reference_name: name.to_string(), reference_kind: kind, diff --git a/crates/codegraph-resolve/src/frameworks/mod.rs b/crates/codegraph-resolve/src/frameworks/mod.rs index 8108367..4e71be0 100644 --- a/crates/codegraph-resolve/src/frameworks/mod.rs +++ b/crates/codegraph-resolve/src/frameworks/mod.rs @@ -12,7 +12,7 @@ pub mod godot; pub(crate) mod godot_common; -pub(crate) mod godot_dsl_config; +pub mod godot_dsl_config; pub(crate) mod godot_project; pub(crate) mod godot_resource; pub(crate) mod godot_scene; diff --git a/crates/codegraph-resolve/src/frameworks/nestjs.rs b/crates/codegraph-resolve/src/frameworks/nestjs.rs index c61dd4e..ce9220a 100644 --- a/crates/codegraph-resolve/src/frameworks/nestjs.rs +++ b/crates/codegraph-resolve/src/frameworks/nestjs.rs @@ -103,7 +103,7 @@ impl FrameworkResolver for NestjsResolver { &self, file_path: &str, content: &str, - _project_root: &str, + _context: &crate::framework::FrameworkExtractionContext, ) -> Option { if !nestjs_file_ext_regex().is_match(file_path) { return Some(FrameworkResolverExtractionResult::default()); @@ -378,6 +378,7 @@ fn add_route( nodes.push(node); if let Some(handler) = handler { references.push(RefView { + row_id: None, from_node_id: node_id, reference_name: handler, reference_kind: EdgeKind::References, @@ -1046,6 +1047,11 @@ fn module_file_regex() -> &'static Regex { #[cfg(test)] mod tests { + /// Extraction context for a resolver that needs no project config. + fn test_extraction_context() -> crate::framework::FrameworkExtractionContext { + crate::framework::FrameworkExtractionContext::without_config("") + } + use super::*; use crate::types::ImportMapping; use std::collections::HashMap; @@ -1153,6 +1159,7 @@ mod tests { fn a_ref(name: &str, file: &str) -> RefView { RefView { + row_id: None, from_node_id: format!("from:{file}"), reference_name: name.to_string(), reference_kind: EdgeKind::References, @@ -1357,7 +1364,7 @@ mod tests { #[test] fn extract_non_nest_extension_returns_default() { let result = NestjsResolver - .extract("src/styles.css", "body{}", "") + .extract("src/styles.css", "body{}", &test_extraction_context()) .expect("extract"); assert!(result.nodes.is_empty() && result.references.is_empty()); } @@ -1366,7 +1373,7 @@ mod tests { fn extract_graphql_query_inside_resolver() { let content = "@Resolver()\nclass UsersResolver {\n @Query()\n users() {}\n}"; let result = NestjsResolver - .extract("src/users.resolver.ts", content, "") + .extract("src/users.resolver.ts", content, &test_extraction_context()) .expect("extract"); let route = result .nodes @@ -1381,7 +1388,7 @@ mod tests { // @Query not inside an @Resolver class -> ignored. let content = "class Plain {\n @Query()\n q() {}\n}"; let result = NestjsResolver - .extract("src/x.resolver.ts", content, "") + .extract("src/x.resolver.ts", content, &test_extraction_context()) .expect("extract"); assert!(!result.nodes.iter().any(|n| n.kind == NodeKind::Route)); } @@ -1390,7 +1397,7 @@ mod tests { fn extract_microservice_message_and_event_patterns() { let content = "class H {\n @MessagePattern('cmd')\n handle() {}\n @EventPattern('evt')\n onEvt() {}\n}"; let result = NestjsResolver - .extract("src/h.controller.ts", content, "") + .extract("src/h.controller.ts", content, &test_extraction_context()) .expect("extract"); let names: Vec<&str> = result .nodes @@ -1413,7 +1420,7 @@ mod tests { // No string arg -> the handler name is used as the path. let content = "class H {\n @MessagePattern()\n doThing() {}\n}"; let result = NestjsResolver - .extract("src/h.controller.ts", content, "") + .extract("src/h.controller.ts", content, &test_extraction_context()) .expect("extract"); let route = result .nodes @@ -1427,7 +1434,7 @@ mod tests { fn extract_websocket_handler_with_gateway_namespace() { let content = "@WebSocketGateway({ namespace: 'chat' })\nclass ChatGw {\n @SubscribeMessage('msg')\n onMsg() {}\n}"; let result = NestjsResolver - .extract("src/chat.gateway.ts", content, "") + .extract("src/chat.gateway.ts", content, &test_extraction_context()) .expect("extract"); let route = result .nodes @@ -1447,7 +1454,7 @@ mod tests { fn extract_websocket_without_namespace_uses_event() { let content = "class Gw {\n @SubscribeMessage('ping')\n onPing() {}\n}"; let result = NestjsResolver - .extract("src/x.gateway.ts", content, "") + .extract("src/x.gateway.ts", content, &test_extraction_context()) .expect("extract"); let route = result .nodes @@ -1462,7 +1469,7 @@ mod tests { // @Get outside any controller class -> prefix is empty. let content = "@Get('bare')\nfunction f() {}"; let result = NestjsResolver - .extract("src/x.controller.ts", content, "") + .extract("src/x.controller.ts", content, &test_extraction_context()) .expect("extract"); let route = result .nodes @@ -1638,7 +1645,11 @@ mod tests { // A `.controller.js` routes through the JavaScript comment-strip branch. let content = "@Controller('items')\nclass ItemsController {\n @Get()\n list() {}\n}"; let result = NestjsResolver - .extract("src/items.controller.js", content, "") + .extract( + "src/items.controller.js", + content, + &test_extraction_context(), + ) .expect("extract"); let route = result .nodes @@ -1654,7 +1665,11 @@ mod tests { // `@Controller({ path: 'admin' })` prefix joins onto the method path. let content = "@Controller({ path: 'admin' })\nclass AdminController {\n @Get('users')\n list() {}\n}"; let result = NestjsResolver - .extract("src/admin.controller.ts", content, "") + .extract( + "src/admin.controller.ts", + content, + &test_extraction_context(), + ) .expect("extract"); let route = result .nodes @@ -1670,7 +1685,7 @@ mod tests { // the explicit `name` field, not the handler. let content = "@Resolver()\nclass R {\n @Query({ name: 'allUsers' })\n fetch() {}\n}"; let result = NestjsResolver - .extract("src/r.resolver.ts", content, "") + .extract("src/r.resolver.ts", content, &test_extraction_context()) .expect("extract"); let route = result .nodes @@ -1685,7 +1700,7 @@ mod tests { // `@Mutation('doThing')` inside a resolver names by the leading string. let content = "@Resolver()\nclass R {\n @Mutation('doThing')\n run() {}\n}"; let result = NestjsResolver - .extract("src/r.resolver.ts", content, "") + .extract("src/r.resolver.ts", content, &test_extraction_context()) .expect("extract"); let route = result .nodes @@ -1700,7 +1715,7 @@ mod tests { // `@EventPattern()` with no string arg → path is the handler name. let content = "class H {\n @EventPattern()\n onEvt() {}\n}"; let result = NestjsResolver - .extract("src/h.controller.ts", content, "") + .extract("src/h.controller.ts", content, &test_extraction_context()) .expect("extract"); let route = result .nodes @@ -1716,7 +1731,7 @@ mod tests { // name → empty event, empty namespace → "WS ". let content = "class Gw {\n @SubscribeMessage()\n}"; let result = NestjsResolver - .extract("src/x.gateway.ts", content, "") + .extract("src/x.gateway.ts", content, &test_extraction_context()) .expect("extract"); let route = result .nodes diff --git a/crates/codegraph-resolve/src/frameworks/react.rs b/crates/codegraph-resolve/src/frameworks/react.rs index 7131a3e..cfef9dc 100644 --- a/crates/codegraph-resolve/src/frameworks/react.rs +++ b/crates/codegraph-resolve/src/frameworks/react.rs @@ -78,7 +78,7 @@ impl FrameworkResolver for ReactResolver { &self, file_path: &str, content: &str, - _project_root: &str, + _context: &crate::framework::FrameworkExtractionContext, ) -> Option { let mut nodes = Vec::new(); let mut references = Vec::new(); @@ -151,7 +151,7 @@ impl FrameworkResolver for ReactResolver { // React Router (v5/v6) (react.ts:158-198). for tag in route_tag_regex().find_iter(content) { - let window = byte_window(content, tag.start(), 400); + let window = route_opening_tag_window(content, tag.start()); let Some(path_match) = route_path_attr().captures(window) else { continue; }; @@ -185,10 +185,10 @@ impl FrameworkResolver for ReactResolver { if data_router_regex().is_match(content) { for m in obj_path_regex().captures_iter(content) { let whole = m.get(0).expect("group 0"); - let win = byte_window(content, whole.start(), 300); + let win = route_object_own_properties(content, whole.start()); let comp = obj_element_attr() - .captures(win) - .or_else(|| obj_component_attr().captures(win)) + .captures(&win) + .or_else(|| obj_component_attr().captures(&win)) .map(|c| c.get(1).expect("comp group").as_str().to_string()); let Some(comp) = comp else { continue }; let route_path = { @@ -274,6 +274,7 @@ fn route_reference( language: Language, ) -> RefView { RefView { + row_id: None, from_node_id, reference_name: name, reference_kind: EdgeKind::References, @@ -519,6 +520,183 @@ fn byte_window(content: &str, start: usize, max_bytes: usize) -> &str { &content[start..end] } +/// Hard byte bound on how far the `` opening-tag scan may look forward +/// from the `<` (upstream issue #1348). +/// +/// The scan normally stops at the tag's own `>` (see [`opening_tag_end`]), but a +/// malformed or unterminated tag has no `>` to find, so the search itself must be +/// capped: without a cap one stray `` opening tag's own text, starting at `start` (its `<`). +/// +/// Replaces the upstream fixed 400-byte window (`react.ts:114-123`), which never +/// cut at the tag's end and therefore let a parent or pathless route match a +/// *sibling's* `path`/`element` (upstream issue #1348). The window is bounded +/// twice over: by the tag terminator when the tag is well formed, and by +/// [`ROUTE_OPENING_TAG_SCAN_LIMIT`] always — so an unterminated tag can never +/// scan unboundedly. A malformed tag additionally stops at the next ` &str { + let bounded = byte_window(content, start, ROUTE_OPENING_TAG_SCAN_LIMIT); + if let Some(end) = opening_tag_end(bounded) { + return &bounded[..end]; + } + match bounded.get(1..).and_then(|rest| rest.find(" &bounded[..idx + 1], + None => bounded, + } +} + +/// Hard byte bound on how far the data-router route-object scan may walk forward +/// from a `path:` key (upstream issue #1348, data-router half). +/// +/// The walk normally stops at the `}` closing that route object (see +/// [`route_object_own_properties`]), but a malformed or unterminated object literal +/// has no matching `}` to find, so the walk itself must be capped: without a cap one +/// stray `path:` would drag it to end-of-file, and a file carrying many such keys +/// would turn this pass quadratic. This is the object-form twin of +/// [`ROUTE_OPENING_TAG_SCAN_LIMIT`], and both invariants are the same: terminate at +/// the construct's own end, and cap the search regardless. +/// +/// 4096 bytes is the cap, sized off route objects actually measured rather than a +/// guess. Measured reference points, each the raw walk distance from the object's +/// `path:` key to its own closing `}` (nested children included, because the walk +/// traverses and elides them rather than stopping at them): a minimal inline +/// `{ path, element }` is 38 bytes; a typical v6.4 route with `loader` and +/// `errorElement` is 142; the same plus `action`, `handle={…}` and +/// `shouldRevalidate`, with `element` last, is 407; a wide enterprise route adding +/// `hydrateFallbackElement` and a nested `handle` object is 611; a parent route +/// whose `children` array holds eight loader-bearing child routes, with the parent's +/// own `element` declared last — the worst realistic walk, since every child byte +/// must be traversed before the parent's own `element` is reached — is 1503. 4096 is +/// ~2.7x that widest measured object, so a legitimate object is never truncated, +/// while the work per `path:` key stays constant. It is also strictly wider than the +/// fixed 300-byte window it replaces (`react.ts:206`), so no property the old window +/// could reach is lost. +const ROUTE_OBJECT_SCAN_LIMIT: usize = 4096; + +/// The route object's OWN properties, as text, starting at the `path:` key that +/// begins at `path_key_start`. +/// +/// Replaces the upstream fixed 300-byte window (`react.ts:206`), which scanned +/// blindly forward and took the FIRST `element`/`Component` it found — so a parent +/// route with no `element` of its own silently borrowed a CHILD route's component, +/// the same borrowing defect [`route_opening_tag_window`] fixes for the `` +/// JSX form (upstream issue #1348). +/// +/// Nested `{…}` / `[…]` groups are ELIDED, not merely stopped at: a `children: […]` +/// array cannot leak its own `element` into the parent's text, and neither can a +/// `handle: {…}` object. Quoted values are copied verbatim, so a `}` or `>` inside +/// `path: 'a>b'` neither ends the object nor unbalances the depth. The walk is +/// bounded twice over, exactly as the JSX form is: by the object's own closing `}` +/// when the literal is well formed, and by [`ROUTE_OBJECT_SCAN_LIMIT`] always. A +/// malformed object additionally stops at the next `path:` key, so even then it +/// cannot borrow a following sibling's properties. +fn route_object_own_properties(content: &str, path_key_start: usize) -> String { + let bounded = byte_window(content, path_key_start, ROUTE_OBJECT_SCAN_LIMIT); + let bytes = bounded.as_bytes(); + let mut own = String::with_capacity(bounded.len()); + // Start of the run of depth-0 bytes not yet copied into `own`. Every byte the + // walk inspects structurally is ASCII, so every boundary is a char boundary. + let mut segment_start = 0usize; + let mut depth = 0usize; + let mut quote: Option = None; + + for (index, byte) in bytes.iter().enumerate() { + match quote { + Some(open) => { + if *byte == open { + quote = None; + } + } + None => match byte { + b'"' | b'\'' | b'`' => quote = Some(*byte), + b'{' | b'[' => { + if depth == 0 { + // Elide the nested group; a space keeps neighbouring tokens + // from fusing across the hole. + own.push_str(&bounded[segment_start..index]); + own.push(' '); + } + depth += 1; + } + b'}' | b']' => { + if depth == 0 { + // This object's own end: nothing beyond it belongs to it. + own.push_str(&bounded[segment_start..index]); + return own; + } + depth -= 1; + if depth == 0 { + segment_start = index + 1; + } + } + b'p' if depth == 0 && index > 0 && is_path_key_at(bounded, index) => { + // Unterminated object: a second depth-0 `path:` can only be a + // following sibling's, so stop rather than borrow from it. + own.push_str(&bounded[segment_start..index]); + return own; + } + _ => {} + }, + } + } + + if depth == 0 { + own.push_str(&bounded[segment_start..]); + } + own +} + +/// Whether a `path` object key (`path` then optional whitespace then `:`) starts at +/// `index` in `text`. +fn is_path_key_at(text: &str, index: usize) -> bool { + let Some(rest) = text.get(index..).and_then(|r| r.strip_prefix("path")) else { + return false; + }; + rest.trim_start().starts_with(':') +} + +/// Byte index just past the `>` closing the opening tag that starts at byte 0 of +/// `text`, or `None` when the tag does not terminate inside `text`. +/// +/// `{…}` expression containers and quoted attribute values are skipped, so the +/// `>` inside `element={}` or `path="a>b"` does not end the tag early. +fn opening_tag_end(text: &str) -> Option { + let mut brace_depth = 0usize; + let mut quote: Option = None; + for (index, byte) in text.as_bytes().iter().enumerate() { + match quote { + Some(open) => { + if *byte == open { + quote = None; + } + } + None => match byte { + b'"' | b'\'' | b'`' => quote = Some(*byte), + b'{' => brace_depth += 1, + b'}' => brace_depth = brace_depth.saturating_sub(1), + b'>' if brace_depth == 0 => return Some(index + 1), + _ => {} + }, + } + } + None +} + fn component_patterns() -> &'static [Regex] { static RE: OnceLock> = OnceLock::new(); RE.get_or_init(|| { @@ -651,6 +829,11 @@ fn page_ext_strip_regex() -> &'static Regex { #[cfg(test)] mod tests { + /// Extraction context for a resolver that needs no project config. + fn test_extraction_context() -> crate::framework::FrameworkExtractionContext { + crate::framework::FrameworkExtractionContext::without_config("") + } + use super::*; use crate::types::ImportMapping; use codegraph_core::types::Node; @@ -757,6 +940,7 @@ mod tests { fn a_ref(name: &str, kind: EdgeKind, file: &str, lang: Language) -> RefView { RefView { + row_id: None, from_node_id: format!("from:{file}"), reference_name: name.to_string(), reference_kind: kind, @@ -1030,7 +1214,7 @@ mod tests { fn extract_arrow_component_with_jsx() { let content = "export const Card = () => { return
; };"; let result = ReactResolver - .extract("src/Card.jsx", content, "") + .extract("src/Card.jsx", content, &test_extraction_context()) .expect("extract"); assert!( result @@ -1048,7 +1232,7 @@ mod tests { content.push('é'); let result = ReactResolver - .extract("src/Card.tsx", &content, "") + .extract("src/Card.tsx", &content, &test_extraction_context()) .expect("extract"); assert!( @@ -1064,7 +1248,7 @@ mod tests { // A PascalCase function that returns no JSX must NOT be a component node. let content = "export function Helper() { return 42; }"; let result = ReactResolver - .extract("src/Helper.tsx", content, "") + .extract("src/Helper.tsx", content, &test_extraction_context()) .expect("extract"); assert!(!result.nodes.iter().any(|n| n.kind == NodeKind::Component)); } @@ -1074,7 +1258,7 @@ mod tests { let content = "const Fancy = React.forwardRef(() =>
);\nconst Wrapped = memo(() => );"; let result = ReactResolver - .extract("src/W.tsx", content, "") + .extract("src/W.tsx", content, &test_extraction_context()) .expect("extract"); let names: Vec<&str> = result .nodes @@ -1090,7 +1274,7 @@ mod tests { fn extract_custom_hook_node() { let content = "export function useCounter() { return 0; }"; let result = ReactResolver - .extract("src/useCounter.ts", content, "") + .extract("src/useCounter.ts", content, &test_extraction_context()) .expect("extract"); let hook = result .nodes @@ -1106,7 +1290,7 @@ mod tests { fn extract_hook_js_language_for_plain_js() { let content = "const useX = () => 1;"; let result = ReactResolver - .extract("src/useX.js", content, "") + .extract("src/useX.js", content, &test_extraction_context()) .expect("extract"); let hook = result .nodes @@ -1121,7 +1305,7 @@ mod tests { // }/> uses the element attr branch. let content = "}/>"; let result = ReactResolver - .extract("src/App.tsx", content, "") + .extract("src/App.tsx", content, &test_extraction_context()) .expect("extract"); let route = result .nodes @@ -1139,7 +1323,7 @@ mod tests { content.push('é'); let result = ReactResolver - .extract("src/App.tsx", &content, "") + .extract("src/App.tsx", &content, &test_extraction_context()) .expect("extract"); let route = result .nodes @@ -1155,7 +1339,7 @@ mod tests { fn extract_route_without_path_is_skipped() { let content = "}/>"; let result = ReactResolver - .extract("src/App.tsx", content, "") + .extract("src/App.tsx", content, &test_extraction_context()) .expect("extract"); assert!(!result.nodes.iter().any(|n| n.kind == NodeKind::Route)); } @@ -1165,7 +1349,7 @@ mod tests { let content = "const r = createBrowserRouter([\n { path: '/dash', element: },\n]);"; let result = ReactResolver - .extract("src/routes.tsx", content, "") + .extract("src/routes.tsx", content, &test_extraction_context()) .expect("extract"); let route = result .nodes @@ -1191,7 +1375,7 @@ mod tests { content.push('é'); let result = ReactResolver - .extract("src/routes.tsx", &content, "") + .extract("src/routes.tsx", &content, &test_extraction_context()) .expect("extract"); let route = result .nodes @@ -1207,7 +1391,7 @@ mod tests { fn extract_data_router_empty_path_becomes_root() { let content = "createBrowserRouter([{ path: '', Component: Index }]);"; let result = ReactResolver - .extract("src/routes.tsx", content, "") + .extract("src/routes.tsx", content, &test_extraction_context()) .expect("extract"); let route = result .nodes @@ -1221,7 +1405,7 @@ mod tests { fn extract_data_router_path_without_component_skipped() { let content = "createBrowserRouter([{ path: '/only' }]);"; let result = ReactResolver - .extract("src/routes.tsx", content, "") + .extract("src/routes.tsx", content, &test_extraction_context()) .expect("extract"); assert!(!result.nodes.iter().any(|n| n.kind == NodeKind::Route)); } @@ -1230,7 +1414,7 @@ mod tests { fn extract_nextjs_app_page_route() { let content = "export default function Page() { return
; }"; let result = ReactResolver - .extract("app/blog/page.tsx", content, "") + .extract("app/blog/page.tsx", content, &test_extraction_context()) .expect("extract"); let route = result .nodes @@ -1244,7 +1428,7 @@ mod tests { fn extract_nextjs_without_export_default_no_route() { let content = "export function NotDefault() { return
; }"; let result = ReactResolver - .extract("pages/x.tsx", content, "") + .extract("pages/x.tsx", content, &test_extraction_context()) .expect("extract"); assert!(!result.nodes.iter().any(|n| n.kind == NodeKind::Route)); } diff --git a/crates/codegraph-resolve/src/frameworks/vue.rs b/crates/codegraph-resolve/src/frameworks/vue.rs index 5fa1599..1c3417a 100644 --- a/crates/codegraph-resolve/src/frameworks/vue.rs +++ b/crates/codegraph-resolve/src/frameworks/vue.rs @@ -134,7 +134,7 @@ impl FrameworkResolver for VueResolver { &self, file_path: &str, _content: &str, - _project_root: &str, + _context: &crate::framework::FrameworkExtractionContext, ) -> Option { let mut nodes = Vec::new(); let normalized = file_path.replace('\\', "/"); @@ -382,6 +382,11 @@ fn param_regex() -> &'static Regex { #[cfg(test)] mod tests { + /// Extraction context for a resolver that needs no project config. + fn test_extraction_context() -> crate::framework::FrameworkExtractionContext { + crate::framework::FrameworkExtractionContext::without_config("") + } + use super::*; use crate::types::ImportMapping; use codegraph_core::types::Node; @@ -488,6 +493,7 @@ mod tests { fn a_ref(name: &str, kind: EdgeKind, file: &str) -> RefView { RefView { + row_id: None, from_node_id: format!("from:{file}"), reference_name: name.to_string(), reference_kind: kind, @@ -675,7 +681,7 @@ mod tests { #[test] fn extract_nuxt_catch_all_route() { let result = VueResolver - .extract("app/pages/[...slug].vue", "", "") + .extract("app/pages/[...slug].vue", "", &test_extraction_context()) .expect("extract"); let route = result .nodes @@ -688,7 +694,7 @@ mod tests { #[test] fn extract_nuxt_optional_param_route() { let result = VueResolver - .extract("app/pages/[[maybe]].vue", "", "") + .extract("app/pages/[[maybe]].vue", "", &test_extraction_context()) .expect("extract"); let route = result .nodes @@ -702,7 +708,7 @@ mod tests { fn extract_nuxt_nested_index_route_strips_index() { // `pages/users/index.vue` -> after_pages "users/index" -> strip /index -> "/users". let result = VueResolver - .extract("app/pages/users/index.vue", "", "") + .extract("app/pages/users/index.vue", "", &test_extraction_context()) .expect("extract"); let route = result .nodes @@ -717,7 +723,11 @@ mod tests { // The matcher keys on a leading-slash "/server/api/", so the file needs a // parent segment (app/server/api/...). let result = VueResolver - .extract("app/server/api/users/index.ts", "", "") + .extract( + "app/server/api/users/index.ts", + "", + &test_extraction_context(), + ) .expect("extract"); let route = result .nodes @@ -732,7 +742,7 @@ mod tests { fn extract_middleware_node() { // Leading-slash "/middleware/" match requires a parent segment. let result = VueResolver - .extract("app/middleware/auth.ts", "", "") + .extract("app/middleware/auth.ts", "", &test_extraction_context()) .expect("extract"); let mw = result .nodes @@ -746,7 +756,7 @@ mod tests { #[test] fn extract_non_route_file_yields_no_nodes() { let result = VueResolver - .extract("src/plain.ts", "", "") + .extract("src/plain.ts", "", &test_extraction_context()) .expect("extract"); assert!(result.nodes.is_empty()); } @@ -755,7 +765,7 @@ mod tests { fn extract_backslash_paths_normalized() { // Windows-style separators normalize so /pages/ still matches. let result = VueResolver - .extract("app\\pages\\about.vue", "", "") + .extract("app\\pages\\about.vue", "", &test_extraction_context()) .expect("extract"); let route = result .nodes @@ -847,7 +857,7 @@ mod tests { // A `.vue` file under server/api and under middleware exercises the // Language::Vue arm of both extract branches. let api = VueResolver - .extract("app/server/api/ping.vue", "", "") + .extract("app/server/api/ping.vue", "", &test_extraction_context()) .expect("extract"); let api_route = api .nodes @@ -858,7 +868,7 @@ mod tests { assert_eq!(api_route.language, Language::Vue); let mw = VueResolver - .extract("app/middleware/guard.vue", "", "") + .extract("app/middleware/guard.vue", "", &test_extraction_context()) .expect("extract"); let mw_node = mw .nodes @@ -874,7 +884,7 @@ mod tests { // `pages/index.vue` → after_pages "index"; strip_suffix("/index") does // not match (no leading segment), so the route is "/index". let result = VueResolver - .extract("app/pages/index.vue", "", "") + .extract("app/pages/index.vue", "", &test_extraction_context()) .expect("extract"); let route = result .nodes diff --git a/crates/codegraph-resolve/src/import_resolver.rs b/crates/codegraph-resolve/src/import_resolver.rs index d7eb33f..10ac25c 100644 --- a/crates/codegraph-resolve/src/import_resolver.rs +++ b/crates/codegraph-resolve/src/import_resolver.rs @@ -6,6 +6,10 @@ //! mappings + re-exports, and turns import-shaped references into edges. Every //! ported branch cites its upstream source range. +use crate::name_matcher::{ + local_receiver_type_patterns, normalize_inferred_type_name, regex_escape, + resolve_method_on_type, +}; use crate::path_aliases::apply_aliases; use crate::pathutil; use crate::types::{ImportMapping, ReExport, RefView, ResolutionContext, ResolvedBy, ResolvedRef}; @@ -1246,6 +1250,22 @@ pub fn resolve_via_import( // `calls`/etc. to `method:bar` rather than mislinking to the class // (which `create_edges` would then mis-promote to `instantiates`). // Ports import-resolver.ts:1298-1316 (upstream f7441f21 / #825). + if !imp.is_namespace { + // An imported VALUE called through a member — + // `reproStore.notifyJoinGuildStatus()` after + // `import { reproStore } from './store'`. Linking the + // CALL to the constant hides the real callee, so + // `callers ` misses every cross-file use + // (#1292 / upstream 2ec877b). + if let Some(instance_member) = resolve_imported_instance_member( + &target_node, + reference, + &imp.local_name, + context, + ) { + return Some(instance_member); + } + } let resolved_target = if !imp.is_namespace { resolve_static_member(&target_node, reference, &imp.local_name, context) .unwrap_or(target_node) @@ -1535,6 +1555,71 @@ fn find_exported_symbol( None } +/// Resolve a CALL through an imported VALUE to the method on the value's own +/// type: `reproStore.notifyJoinGuildStatus()` where the exporting file declares +/// `export const reproStore = new ReproStore()` (#1292, upstream `2ec877b`). +/// +/// The same-file form of this call already resolves through local-variable +/// receiver inference (#1108); this is the cross-file/import half. The type is +/// recovered ONLY from the value's OWN declaration lines in the exporting file +/// (the shared #1108 pattern table), so a same-named local elsewhere in that +/// file cannot donate a type. The member is then resolved AND VALIDATED on that +/// type by [`resolve_method_on_type`], so a failed inference or a type that does +/// not declare the member returns `None` and the caller keeps its existing +/// constant edge — never a fabricated one. Calls only: a plain member READ still +/// references the value. +fn resolve_imported_instance_member( + value: &Node, + reference: &RefView, + local_name: &str, + context: &dyn ResolutionContext, +) -> Option { + if reference.reference_kind != codegraph_core::types::EdgeKind::Calls { + return None; + } + if !matches!(value.kind, NodeKind::Constant | NodeKind::Variable) { + return None; + } + let prefix = format!("{local_name}."); + let remainder = reference.reference_name.strip_prefix(&prefix)?; + let member = remainder.split('.').next().filter(|s| !s.is_empty())?; + + let source = context.read_file(&value.file_path)?; + let lines: Vec<&str> = source + .split('\n') + .map(|l| l.trim_end_matches('\r')) + .collect(); + let start = (value.start_line.max(1) - 1) as usize; + let end = (value.end_line.max(value.start_line) as usize).min(lines.len()); + if start >= end { + return None; + } + let decl_slice = lines[start..end].join("\n"); + + let escaped = regex_escape(&value.name); + for pattern in local_receiver_type_patterns(value.language, &escaped) { + let Some(captured) = pattern.captures(&decl_slice).and_then(|c| c.get(1)) else { + continue; + }; + let Some(type_name) = normalize_inferred_type_name(captured.as_str()) else { + continue; + }; + if let Some(resolved) = resolve_method_on_type( + &type_name, + member, + reference, + context, + 0.85, + ResolvedBy::InstanceMethod, + None, + 0, + ) { + return Some(resolved); + } + } + None +} + fn is_static_member_container(kind: NodeKind) -> bool { matches!( kind, @@ -2125,6 +2210,7 @@ mod tests { fn reference(name: &str, kind: EdgeKind, file_path: &str, language: Language) -> RefView { RefView { + row_id: None, from_node_id: "function:caller".to_string(), reference_name: name.to_string(), reference_kind: kind, @@ -3697,4 +3783,158 @@ export { } from './empty'; ); assert!(resolve_rust_module_file(&[], "src/app.rs", &ctx).is_none()); } + + // ---- Imported-singleton instance-method calls (#1292 / upstream 2ec877b) -- + + /// The exporting file's source. `reproStore` is the singleton constant on + /// line 7; `ReproStore::notifyJoinGuildStatus` is its method. + const SINGLETON_STORE_SRC: &str = "export class ReproStore {\n notifyJoinGuildStatus(): void {\n console.log('notified');\n }\n}\n\nexport const reproStore = new ReproStore();\n"; + + /// Graph + context for `reproStore.` called from `src/caller.ts` + /// through `import { reproStore } from './store'`. `decl_line` places the + /// constant's declaration span so the inferrer reads only those lines. + fn singleton_ctx(decl_line: i64, source: &str) -> TestContext { + let mut existing = BTreeSet::new(); + existing.insert("src/store.ts".to_string()); + let mut file_contents = BTreeMap::new(); + file_contents.insert("src/store.ts".to_string(), source.to_string()); + let mut import_mappings = BTreeMap::new(); + import_mappings.insert( + "src/caller.ts".to_string(), + vec![mapping("reproStore", "reproStore", "./store")], + ); + + let class_node = { + let mut n = exported(node( + "class:ReproStore", + "ReproStore", + NodeKind::Class, + "src/store.ts", + Language::TypeScript, + )); + n.qualified_name = "ReproStore".to_string(); + n + }; + let method = { + let mut n = node( + "method:notifyJoinGuildStatus", + "notifyJoinGuildStatus", + NodeKind::Method, + "src/store.ts", + Language::TypeScript, + ); + n.qualified_name = "ReproStore::notifyJoinGuildStatus".to_string(); + n.start_line = 2; + n.end_line = 4; + n + }; + let singleton = { + let mut n = exported(node( + "constant:reproStore", + "reproStore", + NodeKind::Constant, + "src/store.ts", + Language::TypeScript, + )); + n.start_line = decl_line; + n.end_line = decl_line; + n + }; + + TestContext { + project_root: "/proj".to_string(), + existing_files: existing, + file_contents, + import_mappings, + nodes: vec![class_node, method, singleton], + ..Default::default() + } + } + + #[test] + fn imported_singleton_call_resolves_to_the_class_method() { + let ctx = singleton_ctx(7, SINGLETON_STORE_SRC); + let r = reference( + "reproStore.notifyJoinGuildStatus", + EdgeKind::Calls, + "src/caller.ts", + Language::TypeScript, + ); + let resolved = resolve_via_import(&r, &ctx).expect("resolves"); + assert_eq!( + resolved.target_node_id, "method:notifyJoinGuildStatus", + "a call through an imported singleton must reach the METHOD, not the constant" + ); + assert_eq!(resolved.resolved_by, ResolvedBy::InstanceMethod); + assert_eq!(resolved.confidence, 0.85); + } + + #[test] + fn imported_singleton_member_read_still_references_the_value() { + let ctx = singleton_ctx(7, SINGLETON_STORE_SRC); + let r = reference( + "reproStore.notifyJoinGuildStatus", + EdgeKind::References, + "src/caller.ts", + Language::TypeScript, + ); + let resolved = resolve_via_import(&r, &ctx).expect("resolves"); + assert_eq!( + resolved.target_node_id, "constant:reproStore", + "a plain member READ still points at the imported value" + ); + assert_eq!(resolved.resolved_by, ResolvedBy::Import); + } + + #[test] + fn imported_singleton_call_keeps_constant_when_type_is_uninferable() { + // `= makeStore()` matches no declaration pattern, so no type is + // recovered and the existing constant edge stands — never a guess. + let src = "export class ReproStore {\n notifyJoinGuildStatus(): void {}\n}\n\nexport const reproStore = makeStore();\n"; + let ctx = singleton_ctx(5, src); + let r = reference( + "reproStore.notifyJoinGuildStatus", + EdgeKind::Calls, + "src/caller.ts", + Language::TypeScript, + ); + let resolved = resolve_via_import(&r, &ctx).expect("resolves"); + assert_eq!(resolved.target_node_id, "constant:reproStore"); + assert_eq!(resolved.resolved_by, ResolvedBy::Import); + } + + #[test] + fn imported_singleton_call_keeps_constant_when_method_is_not_on_the_type() { + // The type infers to `ReproStore`, but `ReproStore` declares no + // `somethingElse`, so resolve_method_on_type validation fails. + let ctx = singleton_ctx(7, SINGLETON_STORE_SRC); + let r = reference( + "reproStore.somethingElse", + EdgeKind::Calls, + "src/caller.ts", + Language::TypeScript, + ); + let resolved = resolve_via_import(&r, &ctx).expect("resolves"); + assert_eq!(resolved.target_node_id, "constant:reproStore"); + assert_eq!(resolved.resolved_by, ResolvedBy::Import); + } + + #[test] + fn imported_singleton_type_is_read_only_from_its_own_declaration_lines() { + // A same-named local in ANOTHER function must not donate a type: the + // constant's own declaration span is line 9 only. + let src = "export class ReproStore {\n notifyJoinGuildStatus(): void {}\n}\n\nexport function unrelated(): void {\n const reproStore = new ReproStore();\n}\n\nexport const reproStore = pickStore();\n"; + let ctx = singleton_ctx(9, src); + let r = reference( + "reproStore.notifyJoinGuildStatus", + EdgeKind::Calls, + "src/caller.ts", + Language::TypeScript, + ); + let resolved = resolve_via_import(&r, &ctx).expect("resolves"); + assert_eq!( + resolved.target_node_id, "constant:reproStore", + "the line-6 local must not type the line-9 constant" + ); + } } diff --git a/crates/codegraph-resolve/src/name_matcher.rs b/crates/codegraph-resolve/src/name_matcher.rs index 5d348ad..9e797a1 100644 --- a/crates/codegraph-resolve/src/name_matcher.rs +++ b/crates/codegraph-resolve/src/name_matcher.rs @@ -203,16 +203,66 @@ fn apply_language_gate(candidates: Vec, reference: &RefView) -> Vec } } +/// A function nested inside another FUNCTION is only callable from within its +/// container — Python, JS/TS and every closure language scope it lexically. +/// Resolving a bare name from elsewhere to a nested local fabricates an edge +/// scope already rules out: `join(...)` in one function must never bind to a +/// `join` defined inside a DIFFERENT function (`isLexicallyReachable`, #1230 / +/// upstream `c472cfb`). +/// +/// A candidate whose `qualified_name` parent resolves to a same-file +/// function/method ENCLOSING it is kept only when the ref originates inside that +/// parent's line range. Class members are unaffected (their parent resolves to a +/// class-like node), as are top-level symbols and C++ namespace-prefixed names +/// (the prefix has no node). +fn is_lexically_reachable( + candidate: &Node, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + if candidate.kind != NodeKind::Function { + return true; + } + let Some(sep) = candidate.qualified_name.rfind("::") else { + return true; + }; + let parent_qualified = &candidate.qualified_name[..sep]; + if parent_qualified.is_empty() { + return true; + } + let containers: Vec = context + .get_nodes_by_qualified_name(parent_qualified) + .into_iter() + .filter(|p| { + p.file_path == candidate.file_path + && matches!(p.kind, NodeKind::Function | NodeKind::Method) + && p.start_line <= candidate.start_line + && p.end_line >= candidate.end_line + }) + .collect(); + if containers.is_empty() { + return true; + } + reference.file_path == candidate.file_path + && containers + .iter() + .any(|p| reference.line >= p.start_line && reference.line <= p.end_line) +} + /// Try to resolve a reference by exact name match (`matchByExactName`, /// `name-matcher.ts:173-209`). pub fn match_by_exact_name( reference: &RefView, context: &dyn ResolutionContext, ) -> Option { - let candidates = apply_language_gate( + let candidates: Vec = apply_language_gate( context.get_nodes_by_name(&reference.reference_name), reference, - ); + ) + .into_iter() + // Nested locals are only reachable from inside their container (#1230). + .filter(|n| is_lexically_reachable(n, reference, context)) + .collect(); if candidates.is_empty() { return None; @@ -338,7 +388,7 @@ fn prefer_call_site_file(nodes: Vec, call_site_file: &str) -> Vec { /// Resolve a method on a type, walking supertypes (`resolveMethodOnType`, /// `name-matcher.ts:254-331`). #[allow(clippy::too_many_arguments)] -fn resolve_method_on_type( +pub(crate) fn resolve_method_on_type( type_name: &str, method_name: &str, reference: &RefView, @@ -435,7 +485,7 @@ fn cpp_class_exists(name: &str, reference: &RefView, context: &dyn ResolutionCon /// Escape a receiver for embedding in a `Regex` (the JS /// `/[.*+?^${}()|[\]\\]/g` set, `name-matcher.ts:523`). -fn regex_escape(s: &str) -> String { +pub(crate) fn regex_escape(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { if matches!( @@ -750,7 +800,7 @@ fn is_non_type_receiver_token(seg: &str) -> bool { /// Normalize a captured type expression to a simple type name: drop generic /// args and pointer/ref markers, take the last `.`/`::`-qualified segment, and /// reject obvious non-types (`normalizeInferredTypeName`, name-matcher.ts:#1108). -fn normalize_inferred_type_name(raw: &str) -> Option { +pub(crate) fn normalize_inferred_type_name(raw: &str) -> Option { static GENERICS: OnceLock = OnceLock::new(); let generics = GENERICS.get_or_init(|| Regex::new(r"<[^>]*>").expect("inferred generics re")); let cleaned = generics.replace_all(raw, ""); @@ -768,7 +818,7 @@ fn normalize_inferred_type_name(raw: &str) -> Option { /// most-specific first. The type-annotation / typed-parameter forms (#1125) are /// baked into the second pattern per language. Languages without a pattern set /// (the C++ path uses its own dedicated inferrer) return an empty vector. -fn local_receiver_type_patterns(language: Language, r: &str) -> Vec { +pub(crate) fn local_receiver_type_patterns(language: Language, r: &str) -> Vec { local_receiver_type_patterns_tagged(language, r) .into_iter() .map(|(re, _)| re) @@ -1342,6 +1392,11 @@ fn parse_method_call(name: &str, language: Language) -> Option<(String, String)> if let Some(captures) = match_dot_call(name) { return Some(captures); } + if matches!(language, Language::C | Language::Cpp) { + if let Some(captures) = match_cpp_operator_dot_call(name) { + return Some(captures); + } + } if let Some(captures) = match_colon_call(name) { return Some(captures); } @@ -1366,6 +1421,11 @@ fn is_inferable_receiver_call(name: &str, language: Language) -> bool { if match_dot_call(name).is_some() { return true; } + if matches!(language, Language::C | Language::Cpp) + && match_cpp_operator_dot_call(name).is_some() + { + return true; + } if matches!(language, Language::Lua | Language::Luau) && match_lua_colon_call(name).is_some() { return true; } @@ -1442,6 +1502,37 @@ fn match_dot_call(name: &str) -> Option<(String, String)> { Some((receiver.to_string(), method.to_string())) } +/// Matches `/^([\w.]+)\.(operator[^\w\s.]+)$/` — a C++ explicit operator call +/// (`a.operator+`, `it.operator[]`) reaching the resolver from the extraction +/// recovery (#1268). The operator's symbol chars fail `match_dot_call`'s +/// selector-like method part, so they are admitted here instead. At least one +/// non-word char after `operator` is required, so `a.operatorTable` stays on the +/// plain dotted pattern (tried first); every downstream strategy compares the +/// method part by exact string equality, so a stray match cannot invent an edge. +fn match_cpp_operator_dot_call(name: &str) -> Option<(String, String)> { + let idx = name.rfind(".operator")?; + if idx == 0 { + return None; + } + let receiver = &name[..idx]; + let method = &name[idx + 1..]; + if !receiver + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '.') + { + return None; + } + let symbol = method.strip_prefix("operator")?; + if symbol.is_empty() + || !symbol + .chars() + .all(|c| !c.is_alphanumeric() && c != '_' && c != '.' && !c.is_whitespace()) + { + return None; + } + Some((receiver.to_string(), method.to_string())) +} + /// Matches `/^(\w+)::(\w+)$/` (name-matcher.ts:771). fn match_colon_call(name: &str) -> Option<(String, String)> { let idx = name.find("::")?; @@ -2162,6 +2253,7 @@ mod ceiling_tests { fn reference() -> RefView { RefView { + row_id: None, from_node_id: "function:caller".to_string(), reference_name: "doThing".to_string(), reference_kind: EdgeKind::Calls, @@ -2354,6 +2446,7 @@ mod tests { fn refv(name: &str, kind: EdgeKind, path: &str, lang: Language, line: i64) -> RefView { RefView { + row_id: None, from_node_id: "function:caller".to_string(), reference_name: name.to_string(), reference_kind: kind, @@ -3950,6 +4043,80 @@ mod tests { assert_eq!(res.resolved_by, ResolvedBy::InstanceMethod); } + #[test] + fn cpp_explicit_operator_call_resolves_via_receiver_type() { + // `const V& a; a.operator+(b)` reaches the resolver as `a.operator+` + // (#1268). `Aaa` declares the SAME operator and sorts first, so only + // receiver-type inference can pick `V`. + let v_op = mk( + "method:v_plus", + NodeKind::Method, + "operator+", + "V::operator+", + "src/optest.cpp", + Language::Cpp, + ); + let decoy = mk( + "method:aaa_plus", + NodeKind::Method, + "operator+", + "Aaa::operator+", + "src/optest.cpp", + Language::Cpp, + ); + let ctx = Ctx::default() + .file( + "src/optest.cpp", + "struct Aaa { Aaa operator+(const Aaa& o) const; };\nstruct V { V operator+(const V& o) const; };\nV add(const V& a, const V& b) { return a.operator+(b); }\n", + ) + .name("operator+", vec![decoy, v_op]); + let r = refv( + "a.operator+", + EdgeKind::Calls, + "src/optest.cpp", + Language::Cpp, + 3, + ); + let res = match_method_call(&r, &ctx).expect("explicit operator call resolves"); + assert_eq!( + res.target_node_id, "method:v_plus", + "receiver type V must win over the same-named Aaa::operator+" + ); + assert_eq!(res.resolved_by, ResolvedBy::InstanceMethod); + } + + #[test] + fn cpp_operator_dot_shape_is_a_method_call_shape() { + assert_eq!( + parse_method_call("a.operator+", Language::Cpp), + Some(("a".to_string(), "operator+".to_string())) + ); + assert_eq!( + parse_method_call("it.operator[]", Language::Cpp), + Some(("it".to_string(), "operator[]".to_string())) + ); + // Word-shaped names keep the plain dotted pattern. + assert_eq!( + parse_method_call("a.operatorTable", Language::Cpp), + Some(("a".to_string(), "operatorTable".to_string())) + ); + // The symbolic form is C/C++-only. + assert_eq!(parse_method_call("a.operator+", Language::TypeScript), None); + // `operator` with no trailing symbol is NOT the operator shape; it stays + // an ordinary dotted call (handled by the plain pattern, tried first). + assert_eq!(match_cpp_operator_dot_call("a.operator"), None); + assert_eq!( + parse_method_call("a.operator", Language::Cpp), + Some(("a".to_string(), "operator".to_string())) + ); + // A dotted receiver chain is accepted; a bare `.operator+` is not. + assert_eq!( + match_cpp_operator_dot_call("w.inner.operator=="), + Some(("w.inner".to_string(), "operator==".to_string())) + ); + assert_eq!(match_cpp_operator_dot_call(".operator+"), None); + } + #[test] fn cpp_receiver_auto_new_initializer() { // `auto f = new Foo();\n f.v()` — infer type from the `new Foo` initializer. @@ -6038,4 +6205,170 @@ mod tests { // R separator not inferable outside R. assert!(!is_inferable_receiver_call("lg$log", Language::TypeScript)); } + + // ---- Nested-local lexical reachability (#1230 / upstream c472cfb) ------- + + /// `report_missing` (lines 5-8) calls `join`, but the ONLY project `join` + /// is nested inside `format_fields` (lines 1-3) — lexically unreachable. + fn nested_local_ctx() -> Ctx { + let outer = { + let mut n = mk( + "function:format_fields", + NodeKind::Function, + "format_fields", + "format_fields", + "src/repro.py", + Language::Python, + ); + n.start_line = 1; + n.end_line = 3; + n + }; + let nested = { + let mut n = mk( + "function:join", + NodeKind::Function, + "join", + "format_fields::join", + "src/repro.py", + Language::Python, + ); + n.start_line = 2; + n.end_line = 3; + n + }; + let caller = { + let mut n = mk( + "function:report_missing", + NodeKind::Function, + "report_missing", + "report_missing", + "src/repro.py", + Language::Python, + ); + n.start_line = 5; + n.end_line = 8; + n + }; + Ctx::default() + .name("join", vec![nested.clone()]) + .qualified("format_fields", vec![outer.clone()]) + .nodes_in_file("src/repro.py", vec![outer, nested, caller]) + } + + #[test] + fn nested_local_is_unreachable_from_another_function() { + let ctx = nested_local_ctx(); + let reference = refv("join", EdgeKind::Calls, "src/repro.py", Language::Python, 6); + assert_eq!( + match_by_exact_name(&reference, &ctx), + None, + "a `join` nested in format_fields must NOT resolve from report_missing" + ); + } + + #[test] + fn nested_local_resolves_from_inside_its_container() { + let ctx = nested_local_ctx(); + let reference = refv("join", EdgeKind::Calls, "src/repro.py", Language::Python, 3); + let resolved = match_by_exact_name(&reference, &ctx).expect("reachable from inside"); + assert_eq!(resolved.target_node_id, "function:join"); + } + + #[test] + fn nested_local_is_unreachable_from_another_file() { + let ctx = nested_local_ctx(); + let reference = refv("join", EdgeKind::Calls, "src/other.py", Language::Python, 2); + assert_eq!( + match_by_exact_name(&reference, &ctx), + None, + "a nested local is never reachable from a different file" + ); + } + + #[test] + fn class_member_candidate_is_not_scope_filtered() { + let cls = { + let mut n = mk( + "class:Fmt", + NodeKind::Class, + "Fmt", + "Fmt", + "src/fmt.py", + Language::Python, + ); + n.start_line = 1; + n.end_line = 6; + n + }; + let member = { + let mut n = mk( + "function:join", + NodeKind::Function, + "join", + "Fmt::join", + "src/fmt.py", + Language::Python, + ); + n.start_line = 2; + n.end_line = 3; + n + }; + let ctx = Ctx::default() + .name("join", vec![member.clone()]) + .qualified("Fmt", vec![cls.clone()]) + .nodes_in_file("src/fmt.py", vec![cls, member]); + let reference = refv("join", EdgeKind::Calls, "src/other.py", Language::Python, 9); + let resolved = match_by_exact_name(&reference, &ctx).expect("class members stay reachable"); + assert_eq!(resolved.target_node_id, "function:join"); + } + + #[test] + fn top_level_and_unparented_candidates_are_not_scope_filtered() { + let top = mk( + "function:helper", + NodeKind::Function, + "helper", + "helper", + "src/top.py", + Language::Python, + ); + let ctx = Ctx::default() + .name("helper", vec![top.clone()]) + .nodes_in_file("src/top.py", vec![top]); + let reference = refv( + "helper", + EdgeKind::Calls, + "src/other.py", + Language::Python, + 3, + ); + assert_eq!( + match_by_exact_name(&reference, &ctx) + .expect("top-level symbol stays reachable") + .target_node_id, + "function:helper" + ); + + // A C++ namespace prefix has no node, so the qualified name still holds + // `::` but no container resolves — the candidate must survive. + let ns_fn = mk( + "function:emit", + NodeKind::Function, + "emit", + "sim::emit", + "src/sim.cpp", + Language::Cpp, + ); + let ns_ctx = Ctx::default() + .name("emit", vec![ns_fn.clone()]) + .nodes_in_file("src/sim.cpp", vec![ns_fn]); + let ns_ref = refv("emit", EdgeKind::Calls, "src/use.cpp", Language::Cpp, 4); + assert_eq!( + match_by_exact_name(&ns_ref, &ns_ctx) + .expect("namespace-prefixed symbol stays reachable") + .target_node_id, + "function:emit" + ); + } } diff --git a/crates/codegraph-resolve/src/resolver.rs b/crates/codegraph-resolve/src/resolver.rs index 0fb1392..969a96f 100644 --- a/crates/codegraph-resolve/src/resolver.rs +++ b/crates/codegraph-resolve/src/resolver.rs @@ -799,6 +799,26 @@ impl ReferenceResolver { &self, store: &mut Store, relative_files: &[String], + ) -> anyhow::Result<()> { + self.extract_and_persist_frameworks_with( + store, + relative_files, + &crate::framework::FrameworkExtractionContext::without_config(&self.project_root), + &codegraph_extract::ext_config::ExtensionOverrides::default(), + ) + } + + /// Like [`Self::extract_and_persist_frameworks`] but driven by the addressed + /// project's EXPLICITLY loaded configuration: `context` carries its framework + /// config (Godot DSL fields) and `extensions` its custom extension→language + /// overrides, so language gating and framework extraction both reflect that + /// one project and never another's. + pub fn extract_and_persist_frameworks_with( + &self, + store: &mut Store, + relative_files: &[String], + context: &crate::framework::FrameworkExtractionContext, + extensions: &codegraph_extract::ext_config::ExtensionOverrides, ) -> anyhow::Result<()> { if self.framework_resolver_extensions.is_empty() { return Ok(()); @@ -806,7 +826,7 @@ impl ReferenceResolver { let mut nodes: Vec = Vec::new(); let mut refs: Vec = Vec::new(); for relative in relative_files { - let language = codegraph_extract::detect_language(relative); + let language = codegraph_extract::detect_language_with(relative, extensions); let Some(content) = std::fs::read_to_string(std::path::Path::new(&self.project_root).join(relative)) .ok() @@ -817,7 +837,7 @@ impl ReferenceResolver { if !applies_to_language(resolver.as_ref(), language) { continue; } - if let Some(result) = resolver.extract(relative, &content, &self.project_root) { + if let Some(result) = resolver.extract(relative, &content, context) { nodes.extend(result.nodes); for reference in result.references { refs.push(ref_view_to_unresolved(&reference)); @@ -1304,11 +1324,12 @@ impl ReferenceResolver { /// instead of materializing the whole graph. Byte-equivalence rests on four /// invariants: `warm_caches` runs ONCE over the full node set; the cursor reads /// refs in ascending rowid order so edges insert in the same global order with - /// identical autoinc ids; resolved-row deletion is DEFERRED to after the loop so - /// the tuple-keyed delete never removes a not-yet-read duplicate row from a later - /// batch (which would lose its edge — `unresolved_refs` rows have no UNIQUE - /// constraint, so the same `(from,name,kind)` tuple recurs across batches); and - /// only resolved rows are deleted, leaving the same final `unresolved_refs` table. + /// identical autoinc ids; resolved-row deletion is keyed on `unresolved_refs.id`, + /// so a batch deletes exactly the rows it resolved and can reach neither a + /// not-yet-read row from a later batch nor a sibling call site sharing its + /// `(from,name,kind)` tuple (`unresolved_refs` has no UNIQUE constraint, so that + /// tuple repeats both within and across batches); and only resolved rows are + /// deleted, leaving the same final `unresolved_refs` table. /// /// Each chunk's refs are resolved IN PARALLEL via an order-preserving /// `par_iter().map().collect()` over a `Sync` [`SnapshotResolutionContext`], @@ -1482,24 +1503,17 @@ impl ReferenceResolver { } } - // Delete THIS batch's resolved rows immediately, bounded by the - // batch's max id, instead of accumulating every resolved key for a - // single end-of-loop delete (peak-memory bound on large graphs). The - // `id <= cursor` guard preserves any duplicate tuple in a later batch - // (ascending-id read order ⇒ its id > cursor) — same final table. - let batch_keys: Vec<(String, String, EdgeKind)> = result + // Per-batch deletion (peak-memory bound on large graphs) is precise + // because the key is `row_id`: a batch can only delete rows it read, so + // neither a later batch's row nor a sibling call site sharing this + // row's (from,name,kind) tuple is reachable from here. + let batch_row_ids: Vec = result .resolved .iter() - .map(|reference| { - ( - reference.original.from_node_id.clone(), - reference.original.reference_name.clone(), - reference.original.reference_kind, - ) - }) + .filter_map(|reference| reference.original.row_id) .collect(); - if !batch_keys.is_empty() { - store.delete_resolved_unresolved_refs_up_to(&batch_keys, cursor)?; + if !batch_row_ids.is_empty() { + store.delete_resolved_unresolved_refs(&batch_row_ids)?; } // WAL valve in the resolution write loop (#1212 port / resolution-loop @@ -1893,6 +1907,7 @@ impl ReferenceResolver { /// Denormalize a stored [`UnresolvedRef`] into a [`RefView`] (`index.ts:522-531`). fn to_ref_view(reference: &UnresolvedRef) -> RefView { RefView { + row_id: reference.id, from_node_id: reference.from_node_id.clone(), reference_name: reference.reference_name.clone(), reference_kind: reference.reference_kind, @@ -1909,7 +1924,7 @@ fn to_ref_view(reference: &UnresolvedRef) -> RefView { /// for persistence (inverse of [`to_ref_view`]). fn ref_view_to_unresolved(reference: &RefView) -> UnresolvedRef { UnresolvedRef { - id: None, + id: reference.row_id, from_node_id: reference.from_node_id.clone(), reference_name: reference.reference_name.clone(), reference_kind: reference.reference_kind, @@ -2054,17 +2069,8 @@ fn highest_confidence(best: ResolvedRef, curr: ResolvedRef) -> ResolvedRef { /// Delete resolved rows from `unresolved_refs` /// (`deleteSpecificResolvedReferences`, `index.ts:811-817`). fn delete_resolved_rows(store: &mut Store, resolved: &[ResolvedRef]) -> anyhow::Result<()> { - let keys: Vec<(String, String, EdgeKind)> = resolved - .iter() - .map(|r| { - ( - r.original.from_node_id.clone(), - r.original.reference_name.clone(), - r.original.reference_kind, - ) - }) - .collect(); - store.delete_resolved_unresolved_refs(&keys)?; + let row_ids: Vec = resolved.iter().filter_map(|r| r.original.row_id).collect(); + store.delete_resolved_unresolved_refs(&row_ids)?; Ok(()) } @@ -2193,11 +2199,29 @@ mod tests { assert_eq!(view.line, 3); assert_eq!(view.column, 4); assert!(view.is_function_ref); + assert_eq!(view.row_id, Some(7)); let back = ref_view_to_unresolved(&view); assert_eq!(back.reference_name, "X"); assert_eq!(back.col, 4); assert!(back.is_function_ref); - assert_eq!(back.id, None); + assert_eq!(back.id, Some(7)); + } + + #[test] + fn ref_view_to_unresolved_keeps_framework_ref_row_id_absent() { + let synthesized = RefView { + row_id: None, + from_node_id: "from".to_string(), + reference_name: "Autoload.ping".to_string(), + reference_kind: EdgeKind::Calls, + line: 1, + column: 0, + file_path: "a.gd".to_string(), + language: Language::Gdscript, + is_function_ref: false, + reference_subkind: None, + }; + assert_eq!(ref_view_to_unresolved(&synthesized).id, None); } #[test] @@ -2370,6 +2394,7 @@ mod tests { let extends = ResolvedRef { original: RefView { + row_id: None, from_node_id: cls.id.clone(), reference_name: "I".to_string(), reference_kind: EdgeKind::Extends, @@ -2386,6 +2411,7 @@ mod tests { }; let calls = ResolvedRef { original: RefView { + row_id: None, from_node_id: caller.id.clone(), reference_name: "D".to_string(), reference_kind: EdgeKind::Calls, @@ -2953,6 +2979,7 @@ mod tests { )); let ctx = crate::context::StoreResolutionContext::new(&store, "/root"); let reference = RefView { + row_id: None, from_node_id: caller.id.clone(), reference_name: "this.helper".to_string(), reference_kind: EdgeKind::References, @@ -2996,6 +3023,7 @@ mod tests { { let ctx = crate::context::StoreResolutionContext::new(&store, "/root"); let reference = RefView { + row_id: None, from_node_id: caller.id.clone(), reference_name: "this.absent".to_string(), reference_kind: EdgeKind::References, @@ -3044,6 +3072,7 @@ mod tests { )); let ctx = crate::context::StoreResolutionContext::new(&store, "/root"); let reference = RefView { + row_id: None, from_node_id: caller.id.clone(), reference_name: "onBlur".to_string(), reference_kind: EdgeKind::References, @@ -3226,6 +3255,7 @@ mod tests { "Sub", "greet", &RefView { + row_id: None, from_node_id: run.id.clone(), reference_name: "this.greet".to_string(), reference_kind: EdgeKind::References, @@ -3259,6 +3289,7 @@ mod tests { "C", "gone", &RefView { + row_id: None, from_node_id: "x".to_string(), reference_name: "this.gone".to_string(), reference_kind: EdgeKind::References, @@ -3348,6 +3379,7 @@ mod tests { fn mk_ref(name: &str, kind: EdgeKind, lang: Language) -> RefView { RefView { + row_id: None, from_node_id: "from".to_string(), reference_name: name.to_string(), reference_kind: kind, @@ -3411,4 +3443,191 @@ mod tests { errors: Vec::new(), } } + + // ---------------------------------------------------------------------- + // #1230 PORT: literal receivers + nested locals stop fabricating edges + // ---------------------------------------------------------------------- + + /// Upstream's exact repro, end-to-end through `resolve_and_persist`: + /// `format_fields` (lines 1-5) holds a NESTED `join`; `report_missing` + /// (lines 8-10) calls `", ".join(...)`. The nested `join` is the decoy — a + /// bare-name fallback binds the string builtin straight to it. + fn write_literal_and_nested_fixture(dir: &std::path::Path) -> &'static str { + std::fs::write( + dir.join("src/repro.py"), + concat!( + "def format_fields(values):\n", + " def join(vals):\n", + " return \"-\".join(sorted(vals))\n", + "\n", + " return join(values)\n", + "\n", + "\n", + "def report_missing(unresolved):\n", + " missing_list = \", \".join(sorted(unresolved))\n", + " return missing_list\n", + ), + ) + .expect("write repro.py"); + "src/repro.py" + } + + fn index_python_fixture( + slug: &str, + root: &std::path::Path, + relative: &str, + ) -> (Store, std::path::PathBuf) { + let db = temp_db(slug); + let mut store = Store::open(&db).expect("open store"); + let result = codegraph_extract::extract_file(root, relative).expect("extract"); + store + .upsert_file(&FileRecord { + path: relative.to_string(), + content_hash: "fixture".to_string(), + language: Language::Python, + size: 0, + modified_at: 0, + indexed_at: 0, + node_count: result.nodes.len() as i64, + errors: Vec::new(), + }) + .expect("upsert file"); + store.upsert_nodes(&result.nodes).expect("upsert nodes"); + store.insert_edges(&result.edges).expect("insert edges"); + store + .insert_unresolved_refs(&result.unresolved_references) + .expect("insert refs"); + (store, db) + } + + #[test] + fn literal_receiver_and_nested_local_produce_no_fabricated_call_edge() { + let dir = fresh_fixture_dir("lit-nested"); + let relative = write_literal_and_nested_fixture(&dir); + let root = dir.to_string_lossy().to_string(); + let (mut store, db) = index_python_fixture("lit-nested", &dir, relative); + + let mut resolver = ReferenceResolver::new(root); + resolver + .resolve_and_persist(&mut store) + .expect("resolve_and_persist"); + + let nodes = store.all_nodes().expect("nodes"); + let by_name = |name: &str| -> String { + nodes + .iter() + .find(|n| n.kind == NodeKind::Function && n.name == name) + .map(|n| n.id.clone()) + .unwrap_or_else(|| panic!("missing function {name}")) + }; + let join_id = by_name("join"); + let format_fields_id = by_name("format_fields"); + let report_missing_id = by_name("report_missing"); + + let edges = store.all_edges().expect("edges"); + let callers_of_join: Vec<&str> = edges + .iter() + .filter(|e| e.kind == EdgeKind::Calls && e.target == join_id) + .map(|e| e.source.as_str()) + .collect(); + assert_eq!( + callers_of_join, + vec![format_fields_id.as_str()], + "the nested `join` must have exactly ONE caller (its own container)" + ); + assert!( + !edges + .iter() + .any(|e| e.kind == EdgeKind::Calls && e.source == report_missing_id), + "`report_missing` calls only string/`sorted` builtins, so it must have NO project callee" + ); + + let _ = std::fs::remove_file(&db); + let _ = std::fs::remove_dir_all(&dir); + } + + // ---------------------------------------------------------------------- + // #1269/#1270 PORT: cleanup by row id keeps sibling call sites + // ---------------------------------------------------------------------- + + /// `run` calls `thing.render()` TWICE (lines 12 and 14) — two + /// `unresolved_refs` rows agreeing on `from_node_id`, `reference_name` and + /// `reference_kind`, differing only in `line`. Only the line-14 call resolves + /// (`thing = Widget()` precedes it), so cleanup must remove exactly that row + /// and leave line 12's for the next resolve pass. + fn write_sibling_call_sites_fixture(dir: &std::path::Path) -> &'static str { + std::fs::write( + dir.join("src/siblings.py"), + concat!( + "class Widget:\n", + " def render(self):\n", + " return 1\n", + "\n", + "\n", + "class Gadget:\n", + " def render(self):\n", + " return 2\n", + "\n", + "\n", + "def run(thing):\n", + " thing.render()\n", + " thing = Widget()\n", + " thing.render()\n", + ), + ) + .expect("write siblings.py"); + "src/siblings.py" + } + + #[test] + fn resolving_one_call_site_keeps_its_tuple_identical_sibling_row() { + let dir = fresh_fixture_dir("sibling-rows"); + let relative = write_sibling_call_sites_fixture(&dir); + let root = dir.to_string_lossy().to_string(); + let (mut store, db) = index_python_fixture("sibling-rows", &dir, relative); + + let seeded = store.all_unresolved_refs().expect("seeded refs"); + let seeded_siblings: Vec<&UnresolvedRef> = seeded + .iter() + .filter(|r| r.reference_name == "thing.render") + .collect(); + assert_eq!( + seeded_siblings.len(), + 2, + "fixture must seed TWO tuple-identical rows, got {seeded:?}" + ); + + let mut resolver = ReferenceResolver::new(root); + let result = resolver + .resolve_and_persist(&mut store) + .expect("resolve_and_persist"); + + let resolved_siblings: Vec<&ResolvedRef> = result + .resolved + .iter() + .filter(|r| r.original.reference_name == "thing.render") + .collect(); + assert_eq!( + resolved_siblings.len(), + 1, + "exactly ONE of the two call sites resolves" + ); + let resolved_line = resolved_siblings[0].original.line; + + let survivors: Vec = store + .all_unresolved_refs() + .expect("remaining refs") + .into_iter() + .filter(|r| r.reference_name == "thing.render") + .collect(); + assert_eq!( + survivors.len(), + 1, + "the UNRESOLVED sibling must survive cleanup of its resolved twin" + ); + assert_ne!(survivors[0].line, resolved_line); + + let _ = std::fs::remove_file(&db); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/codegraph-resolve/src/types.rs b/crates/codegraph-resolve/src/types.rs index 59347f0..87b9ce4 100644 --- a/crates/codegraph-resolve/src/types.rs +++ b/crates/codegraph-resolve/src/types.rs @@ -16,6 +16,16 @@ use codegraph_core::types::{EdgeKind, Language, Node, ReferenceSubkind}; /// node when the row omitted them — `index.ts:522-531`). #[derive(Debug, Clone, PartialEq, Eq)] pub struct RefView { + /// `unresolved_refs.id` of the PERSISTED row this view was read from + /// (`INTEGER PRIMARY KEY AUTOINCREMENT`, so unique per row), threaded through + /// resolution so cleanup can delete EXACTLY the row that resolved (#1269/#1270, + /// upstream `e871c49`). + /// + /// `None` for a view that was never persisted — a `FrameworkResolver` + /// synthesizes `RefView`s in memory, and a test may build one by hand. Such a + /// view identifies no row, so it must never delete anything; the cleanup path + /// skips it. + pub row_id: Option, /// ID of the source node containing the reference (`fromNodeId`). pub from_node_id: String, /// The name being referenced (`referenceName`). @@ -289,6 +299,7 @@ mod tests { fn sample_ref() -> RefView { RefView { + row_id: None, from_node_id: "fn:1".to_string(), reference_name: "foo".to_string(), reference_kind: EdgeKind::Calls, diff --git a/crates/codegraph-resolve/tests/frameworks.rs b/crates/codegraph-resolve/tests/frameworks.rs index d332abe..f963690 100644 --- a/crates/codegraph-resolve/tests/frameworks.rs +++ b/crates/codegraph-resolve/tests/frameworks.rs @@ -11,6 +11,11 @@ use codegraph_resolve::framework::FrameworkResolver; use codegraph_resolve::frameworks::{detect_frameworks, godot, nestjs, react, vue}; use codegraph_resolve::types::{ImportMapping, RefView, ResolutionContext, ResolvedBy}; +/// Extraction context for a resolver call that needs no project configuration. +fn no_project_config() -> codegraph_resolve::framework::FrameworkExtractionContext { + codegraph_resolve::framework::FrameworkExtractionContext::without_config("") +} + /// A self-contained, in-memory [`ResolutionContext`] for resolver tests. #[derive(Default)] struct MockContext { @@ -124,6 +129,7 @@ fn node(id: &str, kind: NodeKind, name: &str, file_path: &str, lang: Language) - fn ref_view(name: &str, kind: EdgeKind, file_path: &str, lang: Language) -> RefView { RefView { + row_id: None, from_node_id: format!("from:{file_path}"), reference_name: name.to_string(), reference_kind: kind, @@ -254,7 +260,7 @@ fn react_extract_emits_nextjs_page_route() { .extract( "pages/about.tsx", "export default function About() { return
; }", - "", + &no_project_config(), ) .expect("extract result"); let route = result @@ -270,7 +276,7 @@ fn react_extract_component_and_route_reference() { let content = "export function Home() { return ; }\n"; let result = react::ReactResolver - .extract("src/Home.tsx", content, "") + .extract("src/Home.tsx", content, &no_project_config()) .expect("extract result"); assert!( result @@ -292,6 +298,397 @@ fn react_extract_component_and_route_reference() { ); } +/// Nested v6 shape from upstream #1348: the parent route has a `path` but renders +/// ``, and the pathless `index` child must stay skipped. The unbounded +/// 400-byte window let the parent borrow the child's `element` and let the `index` +/// route borrow its sibling's `path`, producing `/dashboard -> DashboardHome`, +/// a duplicate `settings` node, and `settings -> DashboardHome`. +#[test] +fn react_route_window_stops_at_tag_end_not_at_sibling_routes() { + let content = concat!( + "function DashboardHome() { return null; }\n", + "function Settings() { return null; }\n", + "export function App() {\n", + " return (\n", + " \n", + " \n", + " } />\n", + " } />\n", + " \n", + " \n", + " );\n", + "}\n", + ); + let result = react::ReactResolver + .extract("src/App.tsx", content, &no_project_config()) + .expect("extract result"); + + let routes: Vec<(String, i64)> = result + .nodes + .iter() + .filter(|n| n.kind == NodeKind::Route) + .map(|n| (n.name.clone(), n.start_line)) + .collect(); + assert_eq!( + routes, + vec![("/dashboard".to_string(), 6), ("settings".to_string(), 8)], + "the pathless index route must not borrow its sibling's path" + ); + + let refs: Vec<(String, i64)> = result + .references + .iter() + .map(|r| (r.reference_name.clone(), r.line)) + .collect(); + assert_eq!( + refs, + vec![("Settings".to_string(), 8)], + "only the settings route owns an element; the parent renders " + ); +} + +/// An unterminated `} />\n"); + + let result = react::ReactResolver + .extract("src/Patho.tsx", &content, &no_project_config()) + .expect("extract result"); + + let routes: Vec = result + .nodes + .iter() + .filter(|n| n.kind == NodeKind::Route) + .map(|n| n.name.clone()) + .collect(); + assert_eq!(routes, vec!["/a".to_string(), "/b".to_string()]); + + let refs: Vec<(String, i64)> = result + .references + .iter() + .map(|r| (r.reference_name.clone(), r.line)) + .collect(); + assert_eq!( + refs, + vec![("Comp".to_string(), 4)], + "the unterminated /a tag must not reach the /b element 200KB later" + ); +} + +/// Pins `ROUTE_OPENING_TAG_SCAN_LIMIT` from the outside: a 700-byte prettier-wrapped +/// opening tag — wider than both the old 400-byte window and any tag measured while +/// sizing the bound — still yields its `path` and its `element`, so the bound cannot +/// be tightened to a value that truncates legitimate input. +#[test] +fn react_route_window_keeps_long_multiline_opening_tag_intact() { + let filler: String = (0..12) + .map(|i| format!(" data-attribute-number-{i:02}=\"filler-value-{i:02}\"\n")) + .collect(); + let content = format!( + concat!( + "function DeepSettingsPage() {{ return null; }}\n", + "export function Router() {{\n", + " return (\n", + " \n", + " }}\n", + " />\n", + " \n", + " );\n", + "}}\n", + ), + filler = filler + ); + let tag_start = content.find("").expect("tag end") + 2; + assert!( + tag_len > 400, + "fixture must exceed the old 400-byte window, got {tag_len}" + ); + + let result = react::ReactResolver + .extract("src/Router.tsx", &content, &no_project_config()) + .expect("extract result"); + + let route = result + .nodes + .iter() + .find(|n| n.kind == NodeKind::Route) + .expect("route node"); + assert_eq!(route.name, "/dashboard/settings"); + assert!( + result + .references + .iter() + .any(|r| r.reference_name == "DeepSettingsPage"), + "the element of a {tag_len}-byte opening tag must still be seen" + ); +} + +/// The data-router half of upstream #1348: a PARENT route object carries a `path` +/// but no `element` of its own, and the first `element` the fixed 300-byte window +/// met belonged to a CHILD inside `children: [...]`. The parent must produce NO +/// borrowed edge, and each child exactly its own. +#[test] +fn react_data_router_parent_without_element_does_not_borrow_from_children() { + let content = concat!( + "const router = createBrowserRouter([\n", + " {\n", + " path: '/dashboard',\n", + " children: [\n", + " { index: true, element: },\n", + " { path: 'settings', element: },\n", + " ],\n", + " },\n", + "]);\n", + ); + let result = react::ReactResolver + .extract("src/routes.tsx", content, &no_project_config()) + .expect("extract result"); + + let routes: Vec<(String, i64)> = result + .nodes + .iter() + .filter(|n| n.kind == NodeKind::Route) + .map(|n| (n.name.clone(), n.start_line)) + .collect(); + assert_eq!( + routes, + vec![("settings".to_string(), 6)], + "a parent route with no element of its own must emit no route->component pair" + ); + + let refs: Vec<(String, i64)> = result + .references + .iter() + .map(|r| (r.reference_name.clone(), r.line)) + .collect(); + assert_eq!( + refs, + vec![("Settings".to_string(), 6)], + "/dashboard must not borrow DashboardHome from its pathless index child" + ); +} + +/// The mirror of the parent case, and the shape that makes the object's own closing +/// `}` load-bearing: a pathless-of-its-own CHILD sits inside `children: [...]` while +/// the PARENT declares its `element` AFTER the array. Walking past the child's `}` +/// would hand the parent's component to the child. +#[test] +fn react_data_router_child_without_element_does_not_borrow_from_parent() { + let content = concat!( + "const router = createBrowserRouter([\n", + " {\n", + " path: '/dashboard',\n", + " children: [\n", + " { path: 'settings' },\n", + " ],\n", + " element: ,\n", + " },\n", + "]);\n", + ); + let result = react::ReactResolver + .extract("src/routes.tsx", content, &no_project_config()) + .expect("extract result"); + + let routes: Vec<(String, i64)> = result + .nodes + .iter() + .filter(|n| n.kind == NodeKind::Route) + .map(|n| (n.name.clone(), n.start_line)) + .collect(); + assert_eq!( + routes, + vec![("/dashboard".to_string(), 3)], + "the element-less child must emit nothing; only the parent owns an element" + ); + + let refs: Vec<(String, i64)> = result + .references + .iter() + .map(|r| (r.reference_name.clone(), r.line)) + .collect(); + assert_eq!( + refs, + vec![("DashboardLayout".to_string(), 3)], + "the child must not escape its own closing brace to borrow the parent's element" + ); +} + +/// A malformed object carrying two depth-0 `path:` keys — no `{` and no `}` separates +/// them, so only the next-`path:` stop can keep the first from borrowing the second's +/// element. +#[test] +fn react_data_router_object_stops_at_next_path_key_when_unbraced() { + let content = "createBrowserRouter([{ path: '/a', path: '/b', element: }]);\n"; + let result = react::ReactResolver + .extract("src/routes.tsx", content, &no_project_config()) + .expect("extract result"); + + let routes: Vec = result + .nodes + .iter() + .filter(|n| n.kind == NodeKind::Route) + .map(|n| n.name.clone()) + .collect(); + assert_eq!( + routes, + vec!["/b".to_string()], + "the first path key has no element between it and the second key" + ); + + let refs: Vec = result + .references + .iter() + .map(|r| r.reference_name.clone()) + .collect(); + assert_eq!(refs, vec!["B".to_string()]); +} + +/// A route object whose `element` is a nested JSX expression containing `>` inside +/// braces, and whose `path` string itself contains `>`. Neither `>` may end the +/// object, and the following SIBLING object's element must not leak backwards. +#[test] +fn react_data_router_object_survives_angle_brackets_and_stops_at_sibling() { + let content = concat!( + "const router = createBrowserRouter([\n", + " { path: '/a>b', element: 1 ? 8 : 4}/>}/> },\n", + " { path: '/c>d' },\n", + " { path: '/plain', element: },\n", + "]);\n", + ); + let result = react::ReactResolver + .extract("src/routes.tsx", content, &no_project_config()) + .expect("extract result"); + + let routes: Vec<(String, i64)> = result + .nodes + .iter() + .filter(|n| n.kind == NodeKind::Route) + .map(|n| (n.name.clone(), n.start_line)) + .collect(); + assert_eq!( + routes, + vec![("/a>b".to_string(), 2), ("/plain".to_string(), 4)], + "a `>` inside a quoted path or a braced expression must not end the object, \ + and an element-less object must not borrow from its next sibling" + ); + + let refs: Vec<(String, i64)> = result + .references + .iter() + .map(|r| (r.reference_name.clone(), r.line)) + .collect(); + assert_eq!( + refs, + vec![("Guard".to_string(), 2), ("Plain".to_string(), 4)], + "each object owns exactly its own element; no sibling leakage either way" + ); +} + +/// A route object that legitimately runs well past the old 300-byte window — `loader`, +/// `action`, `errorElement`, `hydrateFallbackElement`, a nested `handle={{…}}` and a +/// `shouldRevalidate`, with its own `element` declared LAST — must still be seen whole. +/// This pins the new bound from the outside: tightening it below the object length +/// reddens this test. +#[test] +fn react_data_router_keeps_long_route_object_intact() { + let content = concat!( + "const router = createBrowserRouter([\n", + " {\n", + " path: '/organisations/:organisationId/settings/billing',\n", + " loader: billingSettingsLoader,\n", + " action: billingSettingsAction,\n", + " errorElement: ,\n", + " hydrateFallbackElement: ,\n", + " handle: {\n", + " crumb: () => 'Billing settings',\n", + " analytics: { screen: 'billing-settings', category: 'billing' },\n", + " permissions: ['billing:read', 'billing:write'],\n", + " },\n", + " shouldRevalidate: ({ currentUrl, nextUrl }) => currentUrl.pathname !== nextUrl.pathname,\n", + " element: ,\n", + " },\n", + "]);\n", + ); + let path_key = content.find("path:").expect("path key"); + let element_key = content + .find("element: 300, + "fixture must place its own element past the old 300-byte window, got {}", + element_key - path_key + ); + + let result = react::ReactResolver + .extract("src/routes.tsx", content, &no_project_config()) + .expect("extract result"); + + let route = result + .nodes + .iter() + .find(|n| n.kind == NodeKind::Route) + .expect("route node"); + assert_eq!( + route.name, + "/organisations/:organisationId/settings/billing" + ); + assert_eq!( + result + .references + .iter() + .map(|r| r.reference_name.clone()) + .collect::>(), + vec!["BillingSettingsPage".to_string()], + "the own element of a {}-byte route object must still be seen, and the \ + errorElement/hydrateFallbackElement must not be mistaken for it", + element_key - path_key + ); +} + +/// An unterminated route object followed by a well-formed one 200KB later: the walk +/// must neither run to end-of-file nor reach that far-away element. +#[test] +fn react_data_router_object_walk_is_bounded_for_unterminated_literal() { + let mut content = String::from("createBrowserRouter([\n { path: '/a',\n"); + content.push_str(&"filler,\n".repeat(25_000)); + content.push_str(" { path: '/b', element: },\n]);\n"); + + let result = react::ReactResolver + .extract("src/routes.tsx", &content, &no_project_config()) + .expect("extract result"); + + let routes: Vec = result + .nodes + .iter() + .filter(|n| n.kind == NodeKind::Route) + .map(|n| n.name.clone()) + .collect(); + assert_eq!( + routes, + vec!["/b".to_string()], + "the unterminated /a object has no element of its own and must emit nothing" + ); + + let refs: Vec = result + .references + .iter() + .map(|r| r.reference_name.clone()) + .collect(); + assert_eq!( + refs, + vec!["Comp".to_string()], + "the unterminated /a object must not reach the /b element 200KB later" + ); +} + // --------------------------------------------------------------------------- // Vue // --------------------------------------------------------------------------- @@ -354,7 +751,7 @@ fn vue_extract_emits_nuxt_page_route() { // the upstream extract keys on `/pages/` (with leading slash), so the route file // must sit under a parent dir (vue.ts:198). let result = vue::VueResolver - .extract("app/pages/users/[id].vue", "", "") + .extract("app/pages/users/[id].vue", "", &no_project_config()) .expect("extract result"); let route = result .nodes @@ -411,7 +808,11 @@ fn nestjs_resolves_service_provider_preferring_convention() { fn nestjs_extract_http_route_joins_controller_prefix() { let content = "@Controller('users')\nclass UsersController {\n @Get(':id')\n findOne() {}\n}"; let result = nestjs::NestjsResolver - .extract("src/users/users.controller.ts", content, "") + .extract( + "src/users/users.controller.ts", + content, + &no_project_config(), + ) .expect("extract result"); let route = result .nodes diff --git a/crates/codegraph-resolve/tests/godot_dsl.rs b/crates/codegraph-resolve/tests/godot_dsl.rs index 5adb4f4..11050b0 100644 --- a/crates/codegraph-resolve/tests/godot_dsl.rs +++ b/crates/codegraph-resolve/tests/godot_dsl.rs @@ -1,23 +1,27 @@ //! L5 Godot static-analysis tests: OPTIONAL, config-gated DSL resource-field //! reference edges (T9 of godot-static-analysis). //! -//! The DSL hook is OFF by default. It fires ONLY when a `.codegraph/codegraph.json` -//! up the directory tree from the `.tres` declares +//! The DSL hook is OFF by default. It fires ONLY when the addressed project's own +//! current-root `codegraph.json` (`IndexPaths::extension_config`) declares //! `godot.dsl.resourceFields = [...]`. Each listed `[resource]` property name `F` //! then makes its `F = ` line emit a [`EdgeKind::References`] edge from the //! resource marker to the value (a string literal → the literal text; an //! `ExtResource("id")` handle → the resolved repo-relative path, via the same //! id-table T5 uses for `script`/property bindings). //! -//! These tests build a temp project dir (config + `.tres`) and drive the public -//! [`FrameworkResolver::extract`] entry point with the ABSOLUTE `.tres` path, so -//! the config reader can walk up to the config exactly as the pipeline does. +//! These tests write the config into the project's resolved v2 root, load it +//! EXPLICITLY through [`GodotDslConfig::load_for_paths`], and drive the public +//! [`FrameworkResolver::extract`] entry point with that config in the extraction +//! context — exactly as the pipeline does. No legacy `.codegraph/codegraph.json` +//! is written, because it is no longer consulted. use std::path::{Path, PathBuf}; +use codegraph_core::IndexPaths; use codegraph_core::types::EdgeKind; -use codegraph_resolve::framework::FrameworkResolver; +use codegraph_resolve::framework::{FrameworkExtractionContext, FrameworkResolver}; use codegraph_resolve::frameworks::godot::GodotResolver; +use codegraph_resolve::frameworks::godot_dsl_config::GodotDslConfig; use codegraph_resolve::types::FrameworkResolverExtractionResult; /// A fresh, uniquely-named temp project directory. @@ -34,15 +38,24 @@ fn unique_dir(slug: &str) -> PathBuf { dir } -/// Write `.codegraph/codegraph.json` with the given JSON contents under `root`. +/// Write the project's CURRENT-ROOT `codegraph.json` — the only config the DSL +/// reader consults. fn write_config(root: &Path, json: &str) { - let cfg_dir = root.join(".codegraph"); - std::fs::create_dir_all(&cfg_dir).expect("mkdir .codegraph"); - std::fs::write(cfg_dir.join("codegraph.json"), json).expect("write codegraph.json"); + let paths = IndexPaths::resolve(root, None).expect("resolve index paths"); + std::fs::create_dir_all(paths.current_root()).expect("mkdir current root"); + std::fs::write(paths.extension_config(), json).expect("write codegraph.json"); } -/// Write `rel` under `root` and return its ABSOLUTE path as a `/`-joined string -/// (the config reader walks up from this path). +/// The extraction context for `root`, carrying its EXPLICITLY loaded DSL config. +fn project_config(root: &Path) -> FrameworkExtractionContext { + let paths = IndexPaths::resolve(root, None).expect("resolve index paths"); + FrameworkExtractionContext::new( + root.to_string_lossy().into_owned(), + GodotDslConfig::load_for_paths(&paths), + ) +} + +/// Write `rel` under `root` and return its ABSOLUTE path as a `/`-joined string. fn write_tres(root: &Path, rel: &str, content: &str) -> String { let abs = root.join(rel); if let Some(parent) = abs.parent() { @@ -52,10 +65,11 @@ fn write_tres(root: &Path, rel: &str, content: &str) -> String { abs.to_string_lossy().into_owned() } -/// Extract a `.tres` at `abs_path` (panics if the resolver returned `None`). -fn extract(abs_path: &str, content: &str) -> FrameworkResolverExtractionResult { +/// Extract a `.tres` under `root`'s own loaded DSL config (panics if the resolver +/// returned `None`). +fn extract_in(root: &Path, abs_path: &str, content: &str) -> FrameworkResolverExtractionResult { GodotResolver - .extract(abs_path, content, "") + .extract(abs_path, content, &project_config(root)) .expect(".tres must produce Some(result)") } @@ -79,7 +93,7 @@ fn with_dsl_config_string_field_emits_reference_to_literal_value() { let tres = write_tres(&root, "data/strength.tres", SKILL_TRES); // When extracting the `.tres` that has `skill_effect = "Fireball"`, - let result = extract(&tres, SKILL_TRES); + let result = extract_in(&root, &tres, SKILL_TRES); // Then a reference edge to the literal value `Fireball` is emitted. let dsl_ref = result @@ -114,7 +128,7 @@ fn without_dsl_config_same_tres_emits_no_dsl_edge() { let tres = write_tres(&root, "data/strength.tres", SKILL_TRES); // When extracting the SAME `.tres`, - let result = extract(&tres, SKILL_TRES); + let result = extract_in(&root, &tres, SKILL_TRES); // Then ZERO DSL edges are emitted — `skill_effect = "Fireball"` produces no // reference, because the config is the only trigger. @@ -147,7 +161,7 @@ fn dsl_config_present_but_other_fields_listed_emits_no_edge() { let tres = write_tres(&root, "data/strength.tres", SKILL_TRES); // When extracting a `.tres` whose only DSL-shaped line is `skill_effect`, - let result = extract(&tres, SKILL_TRES); + let result = extract_in(&root, &tres, SKILL_TRES); // Then no DSL edge fires — the field list is honored exactly. assert!( @@ -179,7 +193,7 @@ skill_effect = ExtResource(\"1\") let tres = write_tres(&root, "data/mage.tres", content); // When extracting, - let result = extract(&tres, content); + let result = extract_in(&root, &tres, content); // Then the DSL edge resolves the ExtResource id to the repo-relative path // (res:// stripped), exactly like T5's script/property bindings. @@ -201,7 +215,7 @@ fn malformed_config_is_ignored_no_panic_no_dsl_edge() { let tres = write_tres(&root, "data/strength.tres", SKILL_TRES); // When extracting (must not panic), - let result = extract(&tres, SKILL_TRES); + let result = extract_in(&root, &tres, SKILL_TRES); // Then the malformed config is ignored — no DSL edge, as if absent. assert!( @@ -231,8 +245,8 @@ effect_on = \"Enemy\" let tres = write_tres(&root, "data/spell.tres", content); // When extracting twice, - let a = extract(&tres, content); - let b = extract(&tres, content); + let a = extract_in(&root, &tres, content); + let b = extract_in(&root, &tres, content); // Then the parser-controlled fields (ids/names/kinds/targets/order) match. let nodes_a: Vec<(&str, &str)> = a @@ -315,7 +329,7 @@ buff_id = 7005 let tres = write_tres(&root, "data/strength.tres", content); // When extracting a `.tres` with a bare integer `buff_id = 7005`, - let result = extract(&tres, content); + let result = extract_in(&root, &tres, content); // Then exactly one `godot:id:buff:7005` sentinel is emitted, anchored on the // resource marker with EdgeKind::References. @@ -349,7 +363,7 @@ skill_effect = \"a:b:9015:c:7005:1000\" let tres = write_tres(&root, "data/mage.tres", content); // When extracting the compound value, - let result = extract(&tres, content); + let result = extract_in(&root, &tres, content); // Then EXACTLY the 0-based segments 2 and 4 (9015, 7005) become sentinels — // not the whole string, not segment 5 (1000). @@ -376,7 +390,7 @@ skill_effect = \"a:b:9015:c:7005:1000\" let tres = write_tres(&root, "data/strength.tres", content); // When extracting a `.tres` full of ID-shaped lines, - let result = extract(&tres, content); + let result = extract_in(&root, &tres, content); // Then ZERO id sentinels are emitted — the config is the only trigger, so a // non-configured project behaves byte-identically to pre-PR2. @@ -412,7 +426,7 @@ duration = 5.0 let tres = write_tres(&root, "data/strength.tres", content); // When extracting a `.tres` with no matching key, - let result = extract(&tres, content); + let result = extract_in(&root, &tres, content); // Then no sentinel fires — the spec map is matched by exact key. assert!( @@ -441,7 +455,7 @@ pair = \"100:200\" let tres = write_tres(&root, "data/oob.tres", content); // When extracting a value with only two segments, - let result = extract(&tres, content); + let result = extract_in(&root, &tres, content); // Then the in-range segment yields a sentinel and the out-of-range index is // silently skipped (no panic, no error, no empty sentinel). @@ -472,8 +486,8 @@ skill_effect = \"9015:c:7005\" let tres = write_tres(&root, "data/spell.tres", content); // When extracting twice, - let a = extract(&tres, content); - let b = extract(&tres, content); + let a = extract_in(&root, &tres, content); + let b = extract_in(&root, &tres, content); // Then the sentinel set, order, and edge sources are byte-stable, and follow // SOURCE-LINE order (buff_id line before skill_effect line), not config order. diff --git a/crates/codegraph-resolve/tests/godot_project.rs b/crates/codegraph-resolve/tests/godot_project.rs index ec46634..371d851 100644 --- a/crates/codegraph-resolve/tests/godot_project.rs +++ b/crates/codegraph-resolve/tests/godot_project.rs @@ -10,11 +10,16 @@ use codegraph_resolve::framework::FrameworkResolver; use codegraph_resolve::frameworks::godot::GodotResolver; use codegraph_resolve::types::FrameworkResolverExtractionResult; +/// Extraction context for a resolver call that needs no project configuration. +fn no_project_config() -> codegraph_resolve::framework::FrameworkExtractionContext { + codegraph_resolve::framework::FrameworkExtractionContext::without_config("") +} + /// Run `extract()` and unwrap the result (panics if the resolver returned /// `None`, which for `project.godot` is itself a failure). fn extract(path: &str, content: &str) -> FrameworkResolverExtractionResult { GodotResolver - .extract(path, content, "") + .extract(path, content, &no_project_config()) .expect("project.godot must produce Some(result)") } @@ -349,15 +354,19 @@ fn extract_returns_none_for_non_project_godot_file() { // project parser. assert!( GodotResolver - .extract("foo.gd", "extends Node\n", "") + .extract("foo.gd", "extends Node\n", &no_project_config()) .is_some() ); // A genuinely unclaimed file → None. - assert!(GodotResolver.extract("README.md", "# hi\n", "").is_none()); + assert!( + GodotResolver + .extract("README.md", "# hi\n", &no_project_config()) + .is_none() + ); // A .tres routes to T5's resource parser (Some, not this project parser). assert!( GodotResolver - .extract("data/item.tres", "[gd_resource]\n", "") + .extract("data/item.tres", "[gd_resource]\n", &no_project_config()) .is_some() ); // A nested path whose basename IS project.godot still dispatches. @@ -366,7 +375,7 @@ fn extract_returns_none_for_non_project_godot_file() { .extract( "sub/dir/project.godot", "[autoload]\nX=\"res://x.gd\"\n", - "" + &no_project_config() ) .is_some() ); diff --git a/crates/codegraph-resolve/tests/godot_resource.rs b/crates/codegraph-resolve/tests/godot_resource.rs index 9f747d6..bd3a366 100644 --- a/crates/codegraph-resolve/tests/godot_resource.rs +++ b/crates/codegraph-resolve/tests/godot_resource.rs @@ -12,11 +12,16 @@ use codegraph_resolve::framework::FrameworkResolver; use codegraph_resolve::frameworks::godot::GodotResolver; use codegraph_resolve::types::FrameworkResolverExtractionResult; +/// Extraction context for a resolver call that needs no project configuration. +fn no_project_config() -> codegraph_resolve::framework::FrameworkExtractionContext { + codegraph_resolve::framework::FrameworkExtractionContext::without_config("") +} + /// Run `extract()` and unwrap the result (panics if the resolver returned /// `None`, which for a `.tres` is itself a failure). fn extract(path: &str, content: &str) -> FrameworkResolverExtractionResult { GodotResolver - .extract(path, content, "") + .extract(path, content, &no_project_config()) .expect(".tres must produce Some(result)") } @@ -236,32 +241,48 @@ fn extract_routes_tres_to_t5_and_others_correctly() { // A .tres dispatches to T5 (Some). assert!( GodotResolver - .extract("data/item.tres", "[gd_resource format=3]\n", "") + .extract( + "data/item.tres", + "[gd_resource format=3]\n", + &no_project_config() + ) .is_some() ); // A nested path whose extension is .tres still dispatches. assert!( GodotResolver - .extract("a/b/c/Deep.tres", "[gd_resource format=3]\n", "") + .extract( + "a/b/c/Deep.tres", + "[gd_resource format=3]\n", + &no_project_config() + ) .is_some() ); // A .gd file now routes to T6's GDScript dynamic parser (Some). assert!( GodotResolver - .extract("player.gd", "extends Node\n", "") + .extract("player.gd", "extends Node\n", &no_project_config()) .is_some() ); // A .tscn routes to T4 (Some, via the scene parser, not this one). assert!( GodotResolver - .extract("scenes/Main.tscn", "[gd_scene format=3]\n", "") + .extract( + "scenes/Main.tscn", + "[gd_scene format=3]\n", + &no_project_config() + ) .is_some() ); // project.godot routes to T3 (Some, via the project parser). assert!( GodotResolver - .extract("project.godot", "[autoload]\nX=\"res://x.gd\"\n", "") + .extract( + "project.godot", + "[autoload]\nX=\"res://x.gd\"\n", + &no_project_config() + ) .is_some() ); } diff --git a/crates/codegraph-resolve/tests/godot_scene.rs b/crates/codegraph-resolve/tests/godot_scene.rs index d747473..dbc0b9e 100644 --- a/crates/codegraph-resolve/tests/godot_scene.rs +++ b/crates/codegraph-resolve/tests/godot_scene.rs @@ -11,11 +11,16 @@ use codegraph_resolve::framework::FrameworkResolver; use codegraph_resolve::frameworks::godot::GodotResolver; use codegraph_resolve::types::FrameworkResolverExtractionResult; +/// Extraction context for a resolver call that needs no project configuration. +fn no_project_config() -> codegraph_resolve::framework::FrameworkExtractionContext { + codegraph_resolve::framework::FrameworkExtractionContext::without_config("") +} + /// Run `extract()` and unwrap the result (panics if the resolver returned /// `None`, which for a `.tscn` is itself a failure). fn extract(path: &str, content: &str) -> FrameworkResolverExtractionResult { GodotResolver - .extract(path, content, "") + .extract(path, content, &no_project_config()) .expect(".tscn must produce Some(result)") } @@ -296,33 +301,49 @@ fn extract_routes_only_tscn_not_gd_or_tres() { // A .tscn dispatches to T4. assert!( GodotResolver - .extract("scenes/Main.tscn", "[gd_scene format=3]\n", "") + .extract( + "scenes/Main.tscn", + "[gd_scene format=3]\n", + &no_project_config() + ) .is_some() ); // A nested path whose extension is .tscn still dispatches. assert!( GodotResolver - .extract("a/b/c/Deep.tscn", "[gd_scene format=3]\n", "") + .extract( + "a/b/c/Deep.tscn", + "[gd_scene format=3]\n", + &no_project_config() + ) .is_some() ); // A .gd file now routes to T6's GDScript dynamic parser (Some). assert!( GodotResolver - .extract("player.gd", "extends Node\n", "") + .extract("player.gd", "extends Node\n", &no_project_config()) .is_some() ); // A .tres routes to T5's resource parser (Some, via that parser, not this). assert!( GodotResolver - .extract("data/item.tres", "[gd_resource format=3]\n", "") + .extract( + "data/item.tres", + "[gd_resource format=3]\n", + &no_project_config() + ) .is_some() ); // project.godot still routes to T3 (not this parser) — it returns Some, but // via the project parser, so it is NOT None. assert!( GodotResolver - .extract("project.godot", "[autoload]\nX=\"res://x.gd\"\n", "") + .extract( + "project.godot", + "[autoload]\nX=\"res://x.gd\"\n", + &no_project_config() + ) .is_some() ); } diff --git a/crates/codegraph-resolve/tests/godot_script.rs b/crates/codegraph-resolve/tests/godot_script.rs index 7db58bb..b3b8e45 100644 --- a/crates/codegraph-resolve/tests/godot_script.rs +++ b/crates/codegraph-resolve/tests/godot_script.rs @@ -15,11 +15,16 @@ use codegraph_resolve::frameworks::godot::GodotResolver; use codegraph_resolve::frameworks::godot_script::{AUTOLOAD_CALL_PREFIX, DYNAMIC_PREFIX}; use codegraph_resolve::types::{FrameworkResolverExtractionResult, RefView}; +/// Extraction context for a resolver call that needs no project configuration. +fn no_project_config() -> codegraph_resolve::framework::FrameworkExtractionContext { + codegraph_resolve::framework::FrameworkExtractionContext::without_config("") +} + /// Run `extract()` and unwrap the result (panics if the resolver returned /// `None`, which for a `.gd` is itself a failure). fn extract(path: &str, content: &str) -> FrameworkResolverExtractionResult { GodotResolver - .extract(path, content, "") + .extract(path, content, &no_project_config()) .expect(".gd must produce Some(result)") } @@ -425,36 +430,52 @@ fn extract_routes_gd_to_t6_and_others_correctly() { // A .gd dispatches to T6 (Some). assert!( GodotResolver - .extract("player.gd", "extends Node\n", "") + .extract("player.gd", "extends Node\n", &no_project_config()) .is_some() ); // A nested path whose extension is .gd still dispatches. assert!( GodotResolver - .extract("a/b/c/Deep.gd", "extends Node\n", "") + .extract("a/b/c/Deep.gd", "extends Node\n", &no_project_config()) .is_some() ); // A .tscn routes to T4 (Some, via the scene parser). assert!( GodotResolver - .extract("scenes/Main.tscn", "[gd_scene format=3]\n", "") + .extract( + "scenes/Main.tscn", + "[gd_scene format=3]\n", + &no_project_config() + ) .is_some() ); // A .tres routes to T5 (Some, via the resource parser). assert!( GodotResolver - .extract("data/item.tres", "[gd_resource format=3]\n", "") + .extract( + "data/item.tres", + "[gd_resource format=3]\n", + &no_project_config() + ) .is_some() ); // project.godot routes to T3 (Some, via the project parser). assert!( GodotResolver - .extract("project.godot", "[autoload]\nX=\"res://x.gd\"\n", "") + .extract( + "project.godot", + "[autoload]\nX=\"res://x.gd\"\n", + &no_project_config() + ) .is_some() ); // A non-Godot file the resolver doesn't claim → None. - assert!(GodotResolver.extract("README.md", "# hi\n", "").is_none()); + assert!( + GodotResolver + .extract("README.md", "# hi\n", &no_project_config()) + .is_none() + ); } #[test] diff --git a/crates/codegraph-resolve/tests/golden_resolution.rs b/crates/codegraph-resolve/tests/golden_resolution.rs index f987e75..59be0e6 100644 --- a/crates/codegraph-resolve/tests/golden_resolution.rs +++ b/crates/codegraph-resolve/tests/golden_resolution.rs @@ -124,6 +124,7 @@ fn resolve_fixture(test_name: &str, root: &Path, relative_files: &[&str]) -> Vec Some("java") => Language::Java, Some("cpp") => Language::Cpp, Some("php") => Language::Php, + Some("xml") => Language::Xml, other => panic!("unexpected fixture extension {other:?}"), }; store @@ -667,3 +668,104 @@ fn php_this_prop_interface_typed_resolves_via_conformance() { "expected run→Handler::handle conformance edge, got: {resolved:#?}" ); } + +#[test] +fn mybatis_qualified_refid_resolves_across_namespaces_and_rejects_the_decoy() { + // #1209 end-to-end: an `` + // must reach the `` fragment in THAT namespace. The decoy is a second + // `baseColumns` fragment in OrderMapper carrying the same `id` as UserMapper's: + // a match on the bare id alone can pick either one, so the include that names + // OrderMapper explicitly must land on OrderMapper's node and the bare one must + // stay inside its own mapper. + let dir = fresh_fixture_dir("mybatis-qualified-refid"); + std::fs::write( + dir.join("src/UserMapper.xml"), + concat!( + "\n", + " id, name\n", + " \n", + " \n", + " \n", + " \n", + "\n", + ), + ) + .expect("write UserMapper.xml"); + std::fs::write( + dir.join("src/OrderMapper.xml"), + concat!( + "\n", + " order_id, total\n", + " order_id\n", + "\n", + ), + ) + .expect("write OrderMapper.xml"); + + let user = codegraph_extract::extract_file(&dir, "src/UserMapper.xml").expect("extract user"); + let order = + codegraph_extract::extract_file(&dir, "src/OrderMapper.xml").expect("extract order"); + let resolved = resolve_fixture( + "mybatis-qualified-refid", + &dir, + &["src/OrderMapper.xml", "src/UserMapper.xml"], + ); + let _ = std::fs::remove_dir_all(&dir); + + let node_id = |result: &codegraph_core::types::ExtractionResult, qualified: &str| -> String { + result + .nodes + .iter() + .find(|n| n.qualified_name == qualified) + .unwrap_or_else(|| panic!("missing node {qualified}; nodes={:#?}", result.nodes)) + .id + .clone() + }; + let user_base = node_id(&user, "com.example.UserMapper::baseColumns"); + let order_base = node_id(&order, "com.example.OrderMapper::baseColumns"); + let order_columns = node_id(&order, "com.example.OrderMapper::orderColumns"); + let find_local = node_id(&user, "com.example.UserMapper::findLocal"); + let find_foreign = node_id(&user, "com.example.UserMapper::findForeign"); + let find_decoy = node_id(&user, "com.example.UserMapper::findDecoy"); + let find_missing = node_id(&user, "com.example.UserMapper::findMissing"); + assert_ne!( + user_base, order_base, + "the decoy must be a genuinely different node than UserMapper's fragment" + ); + + let target_of = |source: &str| -> Option { + resolved + .iter() + .find(|e| e.kind == codegraph_core::types::EdgeKind::References && e.source == source) + .map(|e| e.target.clone()) + }; + + assert_eq!( + target_of(&find_local).as_deref(), + Some(user_base.as_str()), + "a bare refid stays inside its own mapper, not the same-id decoy; edges={resolved:#?}" + ); + assert_eq!( + target_of(&find_foreign).as_deref(), + Some(order_columns.as_str()), + "a qualified refid must cross into the named namespace; edges={resolved:#?}" + ); + assert_eq!( + target_of(&find_decoy).as_deref(), + Some(order_base.as_str()), + "the qualified refid names OrderMapper, so the OrderMapper fragment wins over the same-id UserMapper one; edges={resolved:#?}" + ); + assert_eq!( + target_of(&find_missing), + None, + "a refid naming a namespace that does not exist must emit NO edge rather than binding an arbitrary same-named fragment; edges={resolved:#?}" + ); +} diff --git a/crates/codegraph-resolve/tests/snapshot_equivalence.rs b/crates/codegraph-resolve/tests/snapshot_equivalence.rs index dc41a51..42c962b 100644 --- a/crates/codegraph-resolve/tests/snapshot_equivalence.rs +++ b/crates/codegraph-resolve/tests/snapshot_equivalence.rs @@ -117,6 +117,7 @@ fn assert_contexts_equivalent(test_name: &str, root: &Path, relative_files: &[&s let mut mismatches: Vec = Vec::new(); for unresolved in &refs { let view = codegraph_resolve::RefView { + row_id: unresolved.id, from_node_id: unresolved.from_node_id.clone(), reference_name: unresolved.reference_name.clone(), reference_kind: unresolved.reference_kind, diff --git a/crates/codegraph-store/Cargo.toml b/crates/codegraph-store/Cargo.toml index b1c8d00..94d2256 100644 --- a/crates/codegraph-store/Cargo.toml +++ b/crates/codegraph-store/Cargo.toml @@ -12,6 +12,12 @@ rust-version.workspace = true description = "SQLite + FTS5 persistence layer for CodeGraph-rs (fixed, byte-stable schema)." readme = "../../README.md" +[features] +# Enables the deterministic lease-acquisition barrier used by cross-process +# integration tests. Normal/release builds never enable this feature, so the +# environment-controlled hook is absent from shipped binaries. +test-hooks = [] + [dependencies] codegraph-core = { path = "../codegraph-core" } serde = { workspace = true } diff --git a/crates/codegraph-store/src/connection.rs b/crates/codegraph-store/src/connection.rs index 764281f..1f5f6c9 100644 --- a/crates/codegraph-store/src/connection.rs +++ b/crates/codegraph-store/src/connection.rs @@ -1,8 +1,160 @@ use std::path::{Path, PathBuf}; +use std::time::Instant; -use rusqlite::Connection; +use codegraph_core::IndexPaths; +use rusqlite::{Connection, OpenFlags, OptionalExtension}; use crate::migrations; +use crate::{ + CURRENT_EXTRACTION_VERSION, EXTRACTION_VERSION_KEY, ExtractionStatus, IndexLease, + IndexLeaseError, IndexLeaseValidationError, classify, +}; + +/// Why a `Current` state slot could not be corroborated against the SQLite +/// extraction stamp. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExtractionStampIssue { + /// The store-owned metadata key is absent. + Missing, + /// The stored value is not a canonical decimal `u64`. + Malformed { found: String }, + /// The stored decimal version differs from this binary's version. + Mismatch { expected: u64, found: u64 }, +} + +impl std::fmt::Display for ExtractionStampIssue { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Missing => write!( + formatter, + "metadata key {EXTRACTION_VERSION_KEY:?} is missing" + ), + Self::Malformed { found } => write!( + formatter, + "metadata key {EXTRACTION_VERSION_KEY:?} is not a decimal extraction version: {found:?}" + ), + Self::Mismatch { expected, found } => write!( + formatter, + "metadata key {EXTRACTION_VERSION_KEY:?} records extraction version {found}, expected {expected}" + ), + } + } +} + +/// Explicit authorization requested from [`Store::open_for_write`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StoreWritePurpose { + /// Mutate an already-current, corroborated database. + CurrentMutation, + /// Retain exclusive authority for a later destructive full rebuild or + /// explicit init recovery. This slice does not perform that rebuild. + FullRebuild, + /// One incremental sync that must CLASSIFY BEFORE MUTATING A ROW. A + /// corroborated `Current` namespace is opened for ordinary incremental + /// mutation; `Missing`, `Outdated`, and a recoverable `Building` instead + /// return the lease-retaining full-rebuild authorization so the caller + /// escalates to a forced migration under the SAME exclusive lease. An + /// interrupted-`uninit` namespace stays reserved for an explicit `init`. + IncrementalSync, + /// Retain exclusive authority for a new uninit or an interrupted-uninit + /// continuation. Only `Current`, recoverable `Building`, or + /// `Uninitialized` may issue this capability. + UninitContinuation, + /// Fold a PREVIOUS owner's un-checkpointed write-ahead log back into the + /// main database file of an otherwise fully corroborated `Current` + /// namespace, so the sidecar-free artifact shape the read gate requires is + /// restored instead of the namespace staying permanently unreadable. Only + /// [`Store::recover_stale_current_sidecars`] issues this purpose, and only + /// `Current` may authorize it. + StaleSidecarRecovery, +} + +impl std::fmt::Display for StoreWritePurpose { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::CurrentMutation => "current mutation", + Self::FullRebuild => "full rebuild", + Self::IncrementalSync => "incremental sync", + Self::UninitContinuation => "uninit continuation", + Self::StaleSidecarRecovery => "stale sidecar recovery", + }) + } +} + +/// Opaque exclusive capability returned when state requires a later lifecycle +/// operation rather than an immediate SQLite open. +#[derive(Debug)] +pub struct StoreWriteAuthorization { + pub(crate) status: ExtractionStatus, + pub(crate) purpose: StoreWritePurpose, + pub(crate) lease: IndexLease, +} + +impl StoreWriteAuthorization { + /// The under-lease state that authorized this lifecycle operation. + #[must_use] + pub fn status(&self) -> &ExtractionStatus { + &self.status + } + + /// The narrow lifecycle purpose this capability authorizes. + #[must_use] + pub fn purpose(&self) -> StoreWritePurpose { + self.purpose + } + + /// Whether the opaque authorization still owns an exclusive lease. + #[must_use] + pub fn retains_exclusive_lease(&self) -> bool { + self.lease.is_exclusive() + } + + /// Clone the retained capability for a lifecycle operation this + /// authorization already authorizes. A clone NEVER unlocks (only the final + /// owner does), so handing one to the rebuild layer keeps the ONE outer + /// exclusive lease alive instead of releasing and reacquiring it. + pub(crate) fn clone_lease(&self) -> IndexLease { + self.lease.clone() + } +} + +/// Result of a state-gated writer open. +#[derive(Debug)] +pub enum StoreWriteOpen { + /// A corroborated Current database is open for ordinary mutation. + Current(Box), + /// SQLite was not opened; a later full-rebuild API must consume this opaque + /// authorization while its exclusive lease remains alive. + FullRebuildRequired(StoreWriteAuthorization), + /// SQLite was not opened; a later uninit-continuation API must consume this + /// opaque authorization while its exclusive lease remains alive. + UninitContinuation(StoreWriteAuthorization), +} + +/// Typed status probe returned by [`Store::open_for_status`]. +#[derive(Debug)] +pub struct StoreStatusOpen { + /// Stable state observed under a shared lease. `None` means an exclusive + /// holder prevented a stable observation before the bounded deadline. + pub status: Option, + /// `true` only when shared acquisition timed out behind a writer. + pub rebuilding: bool, + store: Option, +} + +impl StoreStatusOpen { + /// The retained read-only Store used to corroborate a Current state. + #[must_use] + pub fn store(&self) -> Option<&Store> { + self.store.as_ref() + } + + /// Consume this probe and return its retained read-only Store, if Current. + #[must_use] + pub fn into_store(self) -> Option { + self.store + } +} #[derive(Debug, thiserror::Error)] pub enum StoreError { @@ -30,13 +182,105 @@ pub enum StoreError { #[source] source: rusqlite::Error, }, + #[error(transparent)] + Lease(#[from] IndexLeaseError), + #[error(transparent)] + LeaseValidation(#[from] IndexLeaseValidationError), + #[error("state-gated Store open rejected index state {status}")] + StateRejected { status: ExtractionStatus }, + #[error("{purpose} is not authorized for index state {status}")] + WritePurposeRejected { + purpose: StoreWritePurpose, + status: ExtractionStatus, + }, + #[error("state is missing but a database artifact already exists at {path}")] + MissingStateWithDatabase { path: PathBuf }, + #[error("index state {status} exists without its permanent lock at {path}")] + StateWithoutPermanentLock { + status: ExtractionStatus, + path: PathBuf, + }, + #[error("Current index state is blocked by the uninitialized tombstone at {path}")] + CurrentTombstoned { path: PathBuf }, + #[error("Current index state has no SQLite database at {path}")] + CurrentDatabaseMissing { path: PathBuf }, + #[error("Current index state has an unexpected SQLite sidecar at {path}")] + CurrentWithDatabaseSidecar { path: PathBuf }, + #[error("failed to inspect database artifact {path}: {source}")] + InspectArtifact { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to read extraction stamp from SQLite database {path}: {source}")] + ReadExtractionStamp { + path: PathBuf, + #[source] + source: rusqlite::Error, + }, + #[error("failed to read SQLite database bytes from {path}: {source}")] + ReadDatabase { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("SQLite database {path} has an invalid extraction stamp: {issue}")] + InvalidExtractionStamp { + path: PathBuf, + issue: ExtractionStampIssue, + }, + #[error("only a state-gated write Store may stamp the extraction version")] + StampNotAuthorized, + #[error("only a state-gated write Store may be closed as a rebuild finalizer")] + CloseNotAuthorized, + #[error("failed to close the final SQLite connection for {path}: {source}")] + Close { + path: PathBuf, + #[source] + source: rusqlite::Error, + }, + #[error("failed to checkpoint the SQLite write-ahead log for {path}: {source}")] + CheckpointWal { + path: PathBuf, + #[source] + source: rusqlite::Error, + }, + #[error("write-ahead log {path} still holds {remaining} bytes after a truncating checkpoint")] + WalNotFolded { path: PathBuf, remaining: u64 }, + #[error("failed to remove the checkpointed SQLite sidecar {path}: {source}")] + RemoveSidecar { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to stamp extraction version in SQLite database {path}: {source}")] + StampExtractionVersion { + path: PathBuf, + #[source] + source: rusqlite::Error, + }, } pub type Result = std::result::Result; +#[derive(Debug)] pub struct Store { + // Field order is protocol behavior: Rust drops fields in declaration order, + // so both SQLite connections close before the final retained lease owner + // can unlock. pub(crate) conn: Connection, + _source_conn: Option, path: PathBuf, + access: StoreAccess, + guarded_paths: Option, + lease: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StoreAccess { + Legacy, + StateRead, + StateWrite, } impl Store { @@ -74,10 +318,484 @@ impl Store { Ok(Self { conn, + _source_conn: None, path: db_path.to_path_buf(), + access: StoreAccess::Legacy, + guarded_paths: None, + lease: None, + }) + } + + /// Classify only the two fixed state slots. This never opens SQLite and + /// never creates or mutates a filesystem entry. + #[must_use] + pub fn extraction_status(paths: &IndexPaths) -> ExtractionStatus { + classify(paths).status().clone() + } + + /// Acquire and retain a bounded shared lease, require Current state, and + /// corroborate the existing database through a read-only/no-create SQLite + /// handle and the exact current extraction stamp. + pub fn open_for_read( + paths: &IndexPaths, + deadline: Instant, + cancelled: impl FnMut() -> bool, + ) -> Result { + let lease = match IndexLease::acquire_shared_existing(paths, deadline, cancelled) { + Ok(lease) => lease, + Err(IndexLeaseError::LockNotFound { .. }) => { + return Err(lockless_state_error(paths)?); + } + Err(error) => return Err(StoreError::Lease(error)), + }; + let status = Self::extraction_status(paths); + reject_missing_database_artifacts(paths, &status)?; + if status != ExtractionStatus::Current { + return Err(StoreError::StateRejected { status }); + } + open_current_read_only(paths, lease) + } + + /// Probe status under a short shared lease. Contention is status data rather + /// than an error and never opens SQLite. Non-Current states are reported + /// without SQLite; Current is corroborated through [`Self::open_for_read`]'s + /// exact read-only path while retaining the same acquired lease. + pub fn open_for_status( + paths: &IndexPaths, + deadline: Instant, + cancelled: impl FnMut() -> bool, + ) -> Result { + let lease = match IndexLease::acquire_shared_existing(paths, deadline, cancelled) { + Ok(lease) => lease, + Err(IndexLeaseError::TimedOut { .. }) => { + return Ok(StoreStatusOpen { + status: None, + rebuilding: true, + store: None, + }); + } + Err(IndexLeaseError::LockNotFound { .. }) => { + let status = Self::extraction_status(paths); + if status == ExtractionStatus::Missing { + reject_missing_database_artifacts(paths, &status)?; + return Ok(StoreStatusOpen { + status: Some(status), + rebuilding: false, + store: None, + }); + } + if matches!( + status, + ExtractionStatus::Future { .. } | ExtractionStatus::Corrupt { .. } + ) { + return Err(StoreError::StateRejected { status }); + } + return Err(StoreError::StateWithoutPermanentLock { + status, + path: paths.permanent_lock(), + }); + } + Err(error) => return Err(StoreError::Lease(error)), + }; + + let status = Self::extraction_status(paths); + reject_missing_database_artifacts(paths, &status)?; + let store = if status == ExtractionStatus::Current { + Some(open_current_read_only(paths, lease)?) + } else { + drop(lease); + None + }; + Ok(StoreStatusOpen { + status: Some(status), + rebuilding: false, + store, + }) + } + + /// Validate one already-held exclusive lease immediately, classify under + /// it, and either open a corroborated Current DB or return an opaque, + /// lease-retaining authorization for a later lifecycle operation. + pub fn open_for_write( + paths: &IndexPaths, + lease: IndexLease, + purpose: StoreWritePurpose, + ) -> Result { + Self::open_for_write_with(paths, lease, purpose, || {}) + } + + /// Authorize the destructive rebuild used only by an explicit `init` retry. + /// Unlike an ordinary reindex, this narrow recovery surface may accept a + /// fully corroborated `Current` database whose tombstone remains after the + /// prior finalizer published Current but failed to remove that tombstone. + /// The namespace is still not readable until the explicit retry completes. + pub(crate) fn open_for_explicit_init_rebuild( + paths: &IndexPaths, + lease: IndexLease, + ) -> Result { + Self::open_for_write_with_options(paths, lease, StoreWritePurpose::FullRebuild, true, || {}) + } + + fn open_for_write_with( + paths: &IndexPaths, + lease: IndexLease, + purpose: StoreWritePurpose, + before_write_open: impl FnOnce(), + ) -> Result { + Self::open_for_write_with_options(paths, lease, purpose, false, before_write_open) + } + + fn open_for_write_with_options( + paths: &IndexPaths, + lease: IndexLease, + purpose: StoreWritePurpose, + allow_current_tombstone: bool, + before_write_open: impl FnOnce(), + ) -> Result { + lease.validate_exclusive(paths)?; + let status = Self::extraction_status(paths); + + if matches!( + status, + ExtractionStatus::Future { .. } | ExtractionStatus::Corrupt { .. } + ) { + return Err(StoreError::StateRejected { status }); + } + if status == ExtractionStatus::Missing + && let Some(path) = first_existing_database_artifact(paths)? + { + return Err(StoreError::MissingStateWithDatabase { path }); + } + + match purpose { + StoreWritePurpose::CurrentMutation + | StoreWritePurpose::IncrementalSync + | StoreWritePurpose::StaleSidecarRecovery + if status == ExtractionStatus::Current => + { + // Corroborate through a separate read-only/no-create handle before + // any write-capable setup can alter pragmas or sidecars. An + // incremental sync tolerates a live reader's sidecars (see + // `corroborate_current_database_options`); the stamp still comes + // from the checkpointed main-file bytes. Stale-sidecar recovery + // exists precisely BECAUSE sidecars are present, so it tolerates + // them for the same reason — and folds them away afterwards. + let (corroboration, source_conn) = corroborate_current_database_options( + paths, + false, + matches!( + purpose, + StoreWritePurpose::IncrementalSync + | StoreWritePurpose::StaleSidecarRecovery + ), + )?; + drop(corroboration); + drop(source_conn); + before_write_open(); + // The fixed lock path may be replaced after classification and + // read-only corroboration. Revalidate the exact held handle at + // the latest checkpoint, immediately before the first + // write-capable SQLite open. + lease.validate_exclusive(paths)?; + let db_path = paths.current_db(); + let conn = Connection::open_with_flags(&db_path, OpenFlags::SQLITE_OPEN_READ_WRITE) + .map_err(|source| StoreError::Open { + path: db_path.clone(), + source, + })?; + configure_connection(&conn).map_err(|source| StoreError::Configure { + path: db_path.clone(), + source, + })?; + Ok(StoreWriteOpen::Current(Box::new(Self { + conn, + _source_conn: None, + path: db_path, + access: StoreAccess::StateWrite, + guarded_paths: Some(paths.clone()), + lease: Some(lease), + }))) + } + // Frozen plan lines 557-565: an incremental sync classifies BEFORE + // row mutation. A namespace that is not a corroborated Current + // cannot be updated file-by-file, so Missing / Outdated / a + // recoverable Building escalate to a forced full migration through + // the SAME retained exclusive lease. Future/Corrupt were already + // refused above, and `Uninitialized` falls through to the catch-all + // rejection because only an explicit `init` may rebuild it. + StoreWritePurpose::IncrementalSync + if matches!( + status, + ExtractionStatus::Missing + | ExtractionStatus::Outdated { .. } + | ExtractionStatus::Building { .. } + ) => + { + Ok(StoreWriteOpen::FullRebuildRequired( + StoreWriteAuthorization { + status, + purpose, + lease, + }, + )) + } + StoreWritePurpose::FullRebuild => { + if status == ExtractionStatus::Current { + let (corroboration, source_conn) = + corroborate_current_database_with(paths, allow_current_tombstone)?; + drop(corroboration); + drop(source_conn); + } + Ok(StoreWriteOpen::FullRebuildRequired( + StoreWriteAuthorization { + status, + purpose, + lease, + }, + )) + } + StoreWritePurpose::UninitContinuation + if matches!( + status, + ExtractionStatus::Current + | ExtractionStatus::Building { .. } + | ExtractionStatus::Uninitialized + ) => + { + if status == ExtractionStatus::Current { + // A live reader may legitimately have recreated WAL/SHM on a + // still-Current namespace. Uninit owns the exclusive lease, + // corroborates the checkpointed main-file extraction stamp, + // then publishes Uninitialized before deleting those + // sidecars. Rejecting them here would make that required + // cleanup path unreachable. + let (corroboration, source_conn) = + corroborate_current_database_options(paths, false, true)?; + drop(corroboration); + drop(source_conn); + } + Ok(StoreWriteOpen::UninitContinuation( + StoreWriteAuthorization { + status, + purpose, + lease, + }, + )) + } + _ => Err(StoreError::WritePurposeRejected { purpose, status }), + } + } + + /// Open the fresh, write-capable target of an in-progress destructive + /// rebuild under a clone of the caller's already-validated exclusive lease. + /// + /// The caller (`crate::rebuild`) has already published `phase=building` and + /// removed the previous database files, so this deliberately CREATES the + /// database and runs schema setup. It is not a state-classifying entry point: + /// classification happened once, before publication, in + /// [`Self::open_for_write`]. + pub(crate) fn open_rebuild_target_with( + paths: &IndexPaths, + lease: &IndexLease, + before_open: impl FnOnce(), + ) -> Result { + let db_path = paths.current_db(); + // `IndexLease::create_exclusive` created the root for an initial + // namespace, while existing acquisition proved it already existed. Do + // not insert filesystem work between this immediate capability check and + // SQLite's write-capable open. + before_open(); + lease.validate_exclusive(paths)?; + let mut conn = Connection::open(&db_path).map_err(|source| StoreError::Open { + path: db_path.clone(), + source, + })?; + lease.validate_exclusive(paths)?; + migrations::configure_auto_vacuum_for_fresh_db(&conn).map_err(|source| { + StoreError::Configure { + path: db_path.clone(), + source, + } + })?; + configure_connection_for_rebuild(&conn, paths, lease, &db_path)?; + lease.validate_exclusive(paths)?; + migrations::ensure_schema_and_migrations(&mut conn).map_err(|source| { + StoreError::Migrate { + path: db_path.clone(), + source, + } + })?; + Ok(Self { + conn, + _source_conn: None, + path: db_path, + access: StoreAccess::StateWrite, + guarded_paths: Some(paths.clone()), + lease: Some(lease.clone()), }) } + /// Fold a DEAD previous owner's leftover write-ahead log back into the main + /// database file of an otherwise fully corroborated `Current` namespace and + /// remove the checkpointed `-wal`/`-shm` sidecars, so the sidecar-free + /// artifact shape [`Self::open_for_read`] requires is restored. + /// + /// A daemon SIGKILLed with an open SQLite connection leaves un-checkpointed + /// `-wal`/`-shm` behind. The read gate then refuses the namespace forever, + /// which is correct as a READ contract but leaves recovery impossible + /// without `codegraph init`. This is that recovery, and it never relaxes the + /// read gate: the fold happens under the ONE outer exclusive lease, the + /// `Current` contract (tombstone absence, database presence, exact + /// main-file extraction stamp) is corroborated first, and the caller then + /// re-enters the UNCHANGED strict read path. + /// + /// Reports `Ok(false)` — touching nothing — when there is nothing to + /// recover (no sidecar), when the namespace is not a tombstone-free + /// `Current`, or when the bounded exclusive acquisition loses to a LIVE + /// holder (a running daemon or a long-lived MCP reader legitimately owns + /// those sidecars). Callers must additionally prove the previous RENDEZVOUS + /// owner is not alive before calling: the lease excludes concurrent + /// cooperating writers, not a live daemon that is merely idle. + /// + /// A leftover rollback `-journal` is deliberately NOT unlinked here: the + /// write-capable open below is what lets SQLite itself replay and retire a + /// hot journal, and unlinking one by hand would discard committed pages. + /// `-shm` carries no durable content (it is a derived shared-memory index), + /// so it is removed once the log is proven folded. + pub fn recover_stale_current_sidecars( + paths: &IndexPaths, + deadline: Instant, + cancelled: impl FnMut() -> bool, + ) -> Result { + if Self::extraction_status(paths) != ExtractionStatus::Current { + return Ok(false); + } + if artifact_exists(&paths.tombstone())? { + return Ok(false); + } + if first_existing_database_sidecar(paths)?.is_none() { + return Ok(false); + } + let lease = match IndexLease::acquire_exclusive_existing(paths, deadline, cancelled) { + Ok(lease) => lease, + Err(IndexLeaseError::TimedOut { .. } | IndexLeaseError::Cancelled { .. }) => { + return Ok(false); + } + Err(error) => return Err(StoreError::Lease(error)), + }; + let store = match Self::open_for_write( + paths, + lease.clone(), + StoreWritePurpose::StaleSidecarRecovery, + )? { + StoreWriteOpen::Current(store) => store, + other => { + unreachable!("stale sidecar recovery returned unexpected Store open: {other:?}") + } + }; + store.finish_current_mutation()?; + remove_checkpointed_sidecars(paths, &lease)?; + drop(lease); + Ok(true) + } + + /// Explicit fallible completion of ONE state-gated incremental mutation of a + /// `Current` namespace: fold the write-ahead log back into the main database + /// file, then close the final SQLite connection (which releases this Store's + /// lease clone last). The namespace stays `Current` throughout — an + /// incremental sync republishes no state — so this attempts to restore the + /// checkpointed, sidecar-free artifact shape a new reader corroborates. + /// + /// The checkpoint is best-effort by SQLite's own contract: a concurrent + /// reader can leave the log un-truncated, which is reported as busy rather + /// than as an error. Any genuine failure propagates. + pub fn finish_current_mutation(self) -> Result<()> { + self.validate_state_write_authority()?; + self.checkpoint_wal_truncate()?; + self.close() + } + + /// Fold the write-ahead log back into the main database file and truncate the + /// `-wal` sidecar. Used by the rebuild finalizer to make the extraction stamp + /// durable in the main file that `Current` corroboration deserializes. + pub(crate) fn checkpoint_wal_truncate(&self) -> Result<()> { + self.validate_state_write_authority()?; + self.conn + .pragma_update(None, "wal_checkpoint", "TRUNCATE") + .map_err(|source| StoreError::CheckpointWal { + path: self.path.clone(), + source, + }) + } + + /// Explicitly close the final SQLite connection of a state-gated writer, + /// propagating any close failure instead of discarding it in `Drop`. The + /// retained lease clone is released only after the connection is closed. + /// + /// Lease validation and SQLite close are necessarily separate API calls: + /// neither portable `std` nor SQLite exposes an atomic check-and-close + /// primitive. "Immediately before" therefore means that this method inserts + /// no other operation between the last capability validation and + /// [`Connection::close`]; it does not claim portable atomicity. + pub(crate) fn close(self) -> Result<()> { + if self.access != StoreAccess::StateWrite { + return Err(StoreError::CloseNotAuthorized); + } + // Closing a WAL connection may checkpoint or remove sidecars. Treat it + // as a finalization mutation boundary, not as an authority-free drop. + self.validate_state_write_authority()?; + let Self { + conn, + _source_conn, + path, + access: _, + guarded_paths: _, + lease, + } = self; + drop(_source_conn); + conn.close().map_err(|(_conn, source)| StoreError::Close { + path: path.clone(), + source, + })?; + // Explicit ordering: the lease clone is dropped only once no SQLite + // handle for this namespace remains open in this process. + drop(lease); + Ok(()) + } + + /// Write the store-owned extraction metadata key and exact current decimal + /// value. Read/status/legacy Stores are rejected before SQL execution. + pub fn stamp_extraction_version(&self) -> Result { + self.validate_state_write_authority() + .map_err(|error| match error { + StoreError::CloseNotAuthorized => StoreError::StampNotAuthorized, + other => other, + })?; + self.set_project_metadata( + EXTRACTION_VERSION_KEY, + &CURRENT_EXTRACTION_VERSION.to_string(), + ) + .map_err(|source| StoreError::StampExtractionVersion { + path: self.path.clone(), + source, + }) + } + + pub(crate) fn validate_state_write_authority(&self) -> Result<()> { + if self.access != StoreAccess::StateWrite || self.lease.is_none() { + return Err(StoreError::CloseNotAuthorized); + } + let paths = self + .guarded_paths + .as_ref() + .expect("state-gated write Store always retains its IndexPaths"); + self.lease + .as_ref() + .expect("state-gated write Store always retains its lease") + .validate_exclusive(paths)?; + Ok(()) + } + pub fn connection(&self) -> &Connection { &self.conn } @@ -91,6 +809,326 @@ impl Store { } } +fn open_current_read_only(paths: &IndexPaths, lease: IndexLease) -> Result { + let (conn, source_conn) = corroborate_current_database(paths)?; + Ok(Store { + conn, + _source_conn: Some(source_conn), + path: paths.current_db(), + access: StoreAccess::StateRead, + guarded_paths: Some(paths.clone()), + lease: Some(lease), + }) +} + +fn corroborate_current_database(paths: &IndexPaths) -> Result<(Connection, Connection)> { + corroborate_current_database_with(paths, false) +} + +fn corroborate_current_database_with( + paths: &IndexPaths, + allow_tombstone: bool, +) -> Result<(Connection, Connection)> { + corroborate_current_database_options(paths, allow_tombstone, false) +} + +/// Corroborate a `Current` namespace from its main-database bytes. +/// +/// `allow_live_sidecar` relaxes ONLY the sidecar check, and only for a +/// write-capable open. The sidecar-free rule is an artifact contract for the +/// PUBLICATION and READ paths: the rebuild finalizer checkpoints the stamp into +/// the main file and closes its connection before publishing `Current`, and a +/// reader refuses a namespace whose sidecars reappeared without a state change. +/// A live in-process reader (the MCP engine holds one for the server's whole +/// life) legitimately recreates `-wal`/`-shm` on an untouched Current namespace, +/// so demanding sidecar-freedom from an incremental WRITER would make every +/// watcher sync inside `serve` fail. The stamp is still read from the +/// already-checkpointed main-file bytes, so the version gate is unchanged. +fn corroborate_current_database_options( + paths: &IndexPaths, + allow_tombstone: bool, + allow_live_sidecar: bool, +) -> Result<(Connection, Connection)> { + let db_path = paths.current_db(); + if !allow_tombstone && artifact_exists(&paths.tombstone())? { + return Err(StoreError::CurrentTombstoned { + path: paths.tombstone(), + }); + } + if !artifact_exists(&db_path)? { + return Err(StoreError::CurrentDatabaseMissing { path: db_path }); + } + if !allow_live_sidecar && let Some(path) = first_existing_database_sidecar(paths)? { + return Err(StoreError::CurrentWithDatabaseSidecar { path }); + } + let (conn, source_conn) = open_read_only_without_sidecars(&db_path)?; + let stamp = conn + .query_row( + "SELECT value FROM project_metadata WHERE key = ?1", + [EXTRACTION_VERSION_KEY], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|source| StoreError::ReadExtractionStamp { + path: db_path.clone(), + source, + })?; + validate_extraction_stamp(&db_path, stamp)?; + Ok((conn, source_conn)) +} + +fn open_read_only_without_sidecars(db_path: &Path) -> Result<(Connection, Connection)> { + // Opening an existing WAL-mode DB read-only is not enough to prevent SQLite + // from creating `-wal`/`-shm` on the first query. Open the real path with + // exactly READ_ONLY/no-create flags, then deserialize the already-checkpointed + // main-DB bytes into a separate in-memory SQLite handle before executing SQL. + // SQLite therefore performs the stamp query over a READONLY deserialized + // image and never enters the file pager path that authors sidecars. The outer + // shared IndexLease keeps the authoritative DB stable for the Store's whole + // life, and the READ_ONLY source connection remains retained beside it. + // Open the existing path with the required READ_ONLY/no-create flags before + // constructing the sidecar-free query handle. This validates SQLite's own + // file-open contract without executing a pager query that could create WAL + // sidecars; that source connection is retained unchanged for the Store's + // lifetime. + let source_conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY) + .map_err(|source| StoreError::Open { + path: db_path.to_path_buf(), + source, + })?; + let bytes = std::fs::read(db_path).map_err(|source| StoreError::ReadDatabase { + path: db_path.to_path_buf(), + source, + })?; + let mut conn = Connection::open_in_memory().map_err(|source| StoreError::Open { + path: db_path.to_path_buf(), + source, + })?; + deserialize_read_only(&mut conn, &bytes).map_err(|source| StoreError::Open { + path: db_path.to_path_buf(), + source, + })?; + Ok((conn, source_conn)) +} + +fn deserialize_read_only(conn: &mut Connection, bytes: &[u8]) -> rusqlite::Result<()> { + use std::ptr::NonNull; + + let (size, deserialize_size) = deserialize_sizes(bytes.len())?; + // Keep every fallible size conversion above the ownership-bearing + // allocation. After sqlite3_malloc64 succeeds, no Rust `?` may return before + // sqlite3_deserialize assumes FREEONCLOSE ownership. + // SAFETY: sqlite3_malloc64 returns an allocation suitable for + // SQLITE_DESERIALIZE_FREEONCLOSE. The byte copy uses the exact allocation + // length. Once sqlite3_deserialize is called, SQLite owns and frees the + // allocation both on success and on failure. + let allocation = unsafe { rusqlite::ffi::sqlite3_malloc64(size) }; + let allocation = NonNull::new(allocation.cast::()).ok_or_else(|| { + rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_NOMEM), None) + })?; + // SAFETY: allocation is valid for `bytes.len()` bytes and non-overlapping + // with the borrowed source slice. + unsafe { + std::ptr::copy_nonoverlapping(bytes.as_ptr(), allocation.as_ptr(), bytes.len()); + } + if bytes.len() >= 20 && bytes.starts_with(b"SQLite format 3\0") { + // A clean checkpointed WAL database still records WAL read/write format + // bytes (2) in its main-file header. The deserialized image has no WAL + // sidecar by design, so normalize only this private in-memory copy to + // rollback format (1) before SQLite reads it. Disk bytes remain exact. + // SAFETY: the allocation has `bytes.len() >= 20` writable bytes here. + unsafe { + allocation.as_ptr().add(18).write(1); + allocation.as_ptr().add(19).write(1); + } + } + let schema = c"main"; + // SAFETY: `conn.handle()` is live for this call; allocation and lengths meet + // sqlite3_deserialize's contract. READONLY forbids image mutation and + // FREEONCLOSE transfers allocation ownership to SQLite on success. + let result = unsafe { + rusqlite::ffi::sqlite3_deserialize( + conn.handle(), + schema.as_ptr(), + allocation.as_ptr(), + deserialize_size, + deserialize_size, + rusqlite::ffi::SQLITE_DESERIALIZE_FREEONCLOSE + | rusqlite::ffi::SQLITE_DESERIALIZE_READONLY, + ) + }; + if result != rusqlite::ffi::SQLITE_OK { + // FREEONCLOSE also requires SQLite to free the buffer before returning + // from a failed sqlite3_deserialize call; do not free it a second time. + return Err(rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error::new(result), + None, + )); + } + Ok(()) +} + +fn deserialize_sizes(len: usize) -> rusqlite::Result<(u64, i64)> { + let allocation_size = u64::try_from(len).map_err(|_| rusqlite::Error::InvalidQuery)?; + let image_size = i64::try_from(len).map_err(|_| rusqlite::Error::InvalidQuery)?; + Ok((allocation_size, image_size)) +} + +fn validate_extraction_stamp(db_path: &Path, stamp: Option) -> Result<()> { + let Some(stamp) = stamp else { + return Err(StoreError::InvalidExtractionStamp { + path: db_path.to_path_buf(), + issue: ExtractionStampIssue::Missing, + }); + }; + let built = stamp + .parse::() + .map_err(|_| StoreError::InvalidExtractionStamp { + path: db_path.to_path_buf(), + issue: ExtractionStampIssue::Malformed { + found: stamp.clone(), + }, + })?; + if built.to_string() != stamp { + return Err(StoreError::InvalidExtractionStamp { + path: db_path.to_path_buf(), + issue: ExtractionStampIssue::Malformed { found: stamp }, + }); + } + if built != CURRENT_EXTRACTION_VERSION { + return Err(StoreError::InvalidExtractionStamp { + path: db_path.to_path_buf(), + issue: ExtractionStampIssue::Mismatch { + expected: CURRENT_EXTRACTION_VERSION, + found: built, + }, + }); + } + Ok(()) +} + +fn first_existing_database_artifact(paths: &IndexPaths) -> Result> { + let db = paths.current_db(); + let mut artifacts = vec![db.clone()]; + artifacts.push(database_sidecar_path(&db, "-wal")); + artifacts.push(database_sidecar_path(&db, "-shm")); + for path in artifacts { + match std::fs::symlink_metadata(&path) { + Ok(_) => return Ok(Some(path)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(source) => return Err(StoreError::InspectArtifact { path, source }), + } + } + Ok(None) +} + +/// Remove the `-wal`/`-shm` pair AFTER the log is proven folded into the main +/// file. A closing SQLite connection normally unlinks both itself, so this is +/// usually a no-op; it exists so the post-recovery artifact shape is guaranteed +/// rather than dependent on SQLite's cleanup succeeding. +/// +/// A `-wal` that still carries bytes is refused instead of deleted: deleting an +/// un-checkpointed log discards committed transactions. +fn remove_checkpointed_sidecars(paths: &IndexPaths, lease: &IndexLease) -> Result<()> { + let db = paths.current_db(); + let wal = database_sidecar_path(&db, "-wal"); + if let Some(remaining) = artifact_len(&wal)? + && remaining > 0 + { + return Err(StoreError::WalNotFolded { + path: wal, + remaining, + }); + } + for path in [wal, database_sidecar_path(&db, "-shm")] { + lease.validate_exclusive(paths)?; + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(source) => return Err(StoreError::RemoveSidecar { path, source }), + } + } + Ok(()) +} + +/// Append SQLite's sidecar suffix to the native database pathname without a +/// Unicode rendering round-trip, so both Unix byte paths and Windows wide paths +/// stay lossless. +/// +/// Every sidecar path in this module MUST come from here. `Path::display()` +/// replaces each byte that is not valid UTF-8 with U+FFFD, so a sidecar name +/// built from that rendering points at a DIFFERENT file: the detection gates +/// would see a sidecar-free namespace while a committed `-wal` sat undetected +/// beside the database, and the strict `Current` read would then serve an index +/// missing every row that lives only in that log. +fn database_sidecar_path(db: &Path, suffix: &str) -> PathBuf { + let mut native = db.as_os_str().to_os_string(); + native.push(suffix); + PathBuf::from(native) +} + +fn artifact_len(path: &Path) -> Result> { + match std::fs::symlink_metadata(path) { + Ok(metadata) => Ok(Some(metadata.len())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(source) => Err(StoreError::InspectArtifact { + path: path.to_path_buf(), + source, + }), + } +} + +fn first_existing_database_sidecar(paths: &IndexPaths) -> Result> { + let db = paths.current_db(); + for path in [ + database_sidecar_path(&db, "-wal"), + database_sidecar_path(&db, "-shm"), + ] { + if artifact_exists(&path)? { + return Ok(Some(path)); + } + } + Ok(None) +} + +fn reject_missing_database_artifacts(paths: &IndexPaths, status: &ExtractionStatus) -> Result<()> { + if status == &ExtractionStatus::Missing + && let Some(path) = first_existing_database_artifact(paths)? + { + return Err(StoreError::MissingStateWithDatabase { path }); + } + Ok(()) +} + +fn lockless_state_error(paths: &IndexPaths) -> Result { + let status = Store::extraction_status(paths); + if status == ExtractionStatus::Missing { + reject_missing_database_artifacts(paths, &status)?; + return Ok(StoreError::StateRejected { status }); + } + if matches!( + status, + ExtractionStatus::Future { .. } | ExtractionStatus::Corrupt { .. } + ) { + return Ok(StoreError::StateRejected { status }); + } + Ok(StoreError::StateWithoutPermanentLock { + status, + path: paths.permanent_lock(), + }) +} + +fn artifact_exists(path: &Path) -> Result { + match std::fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(source) => Err(StoreError::InspectArtifact { + path: path.to_path_buf(), + source, + }), + } +} + fn configure_connection(conn: &Connection) -> rusqlite::Result<()> { // Order mirrors the upstream configureConnection exactly. busy_timeout must be // first so later file-touching pragmas wait instead of immediately failing. @@ -104,10 +1142,155 @@ fn configure_connection(conn: &Connection) -> rusqlite::Result<()> { Ok(()) } +fn configure_connection_for_rebuild( + conn: &Connection, + paths: &IndexPaths, + lease: &IndexLease, + db_path: &Path, +) -> Result<()> { + let configure = |result: rusqlite::Result<()>| { + result.map_err(|source| StoreError::Configure { + path: db_path.to_path_buf(), + source, + }) + }; + + lease.validate_exclusive(paths)?; + configure(conn.busy_timeout(std::time::Duration::from_millis(5_000)))?; + lease.validate_exclusive(paths)?; + configure(conn.pragma_update(None, "foreign_keys", "ON"))?; + lease.validate_exclusive(paths)?; + configure(conn.pragma_update(None, "journal_mode", "WAL"))?; + lease.validate_exclusive(paths)?; + configure(conn.pragma_update(None, "synchronous", "NORMAL"))?; + lease.validate_exclusive(paths)?; + configure(conn.pragma_update(None, "cache_size", -64_000))?; + lease.validate_exclusive(paths)?; + configure(conn.pragma_update(None, "temp_store", "MEMORY"))?; + lease.validate_exclusive(paths)?; + configure(conn.pragma_update(None, "mmap_size", 268_435_456_i64))?; + Ok(()) +} + #[cfg(test)] mod tests { + use std::sync::atomic::{AtomicU64, Ordering}; + use super::*; + /// Monotonic per-process serial that keeps every temp name in this module unique. + /// + /// A wall-clock timestamp alone is NOT sufficient. `SystemTime::now()` has + /// nanosecond resolution on Linux but is only updated at the system timer tick + /// on Windows (~15.6 ms), so two tests running concurrently in threads of ONE + /// process, hence one pid, can observe the SAME `as_nanos()` value. Every + /// builder here already carries a per-callsite label EXCEPT the late-lock base + /// below, which the serial makes unique by construction. + static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + + #[derive(Debug, PartialEq, Eq)] + enum SnapshotKind { + Directory, + File(Vec), + Symlink(PathBuf), + } + + /// Windows `ERROR_LOCK_VIOLATION`. + /// + /// A byte-range lock taken through `File::try_lock` is ADVISORY on Unix, so + /// an unrelated `read` of the locked file always succeeds there. On Windows + /// the same lock is MANDATORY: any read overlapping the locked range is + /// refused outright with this code. Renaming a locked file does not release + /// the lock — the lock follows the handle — so a snapshot walking a + /// namespace can meet a still-locked file under its new name. + const ERROR_LOCK_VIOLATION: i32 = 33; + + /// Read one regular file's bytes for a snapshot, tolerating exactly one + /// otherwise-fatal case: a mandatory Windows byte-range lock refusing a read + /// of an EMPTY file. + /// + /// `len` is the length from the `symlink_metadata` already taken for this + /// entry. Metadata queries are not byte-range-locked, so the length stays + /// observable while the lock is held, and a zero-length file has exactly one + /// possible content. Recording it as empty is therefore byte-exact AND + /// independent of whether the lock happened to be held at snapshot time — + /// which is what the equality assertions need, because a rejected + /// write-capable open DROPS the exclusive lease it consumed, so two + /// snapshots taken across that rejection observe the same file first locked + /// and then unlocked. Creation, removal and kind changes are still detected + /// because the entry is recorded exactly as the regular file it is. + /// + /// Every other read error still panics: a genuinely unreadable file is a + /// real fault, and a non-empty locked file has content that cannot be + /// observed at all, so silently substituting anything would be a lie. + fn snapshot_file_bytes(path: &Path, len: u64) -> Vec { + match std::fs::read(path) { + Ok(bytes) => bytes, + Err(error) + if cfg!(windows) + && len == 0 + && error.raw_os_error() == Some(ERROR_LOCK_VIOLATION) => + { + Vec::new() + } + Err(error) => panic!("snapshot read {}: {error}", path.display()), + } + } + + fn snapshot_tree(root: &Path) -> std::collections::BTreeMap { + fn walk( + root: &Path, + directory: &Path, + out: &mut std::collections::BTreeMap, + ) { + let mut paths = std::fs::read_dir(directory) + .unwrap_or_else(|error| { + panic!("snapshot read_dir {}: {error}", directory.display()) + }) + .map(|entry| { + entry + .unwrap_or_else(|error| { + panic!("snapshot entry in {}: {error}", directory.display()) + }) + .path() + }) + .collect::>(); + paths.sort(); + for path in paths { + let relative = path + .strip_prefix(root) + .unwrap_or_else(|error| panic!("snapshot strip {}: {error}", path.display())) + .to_path_buf(); + let metadata = std::fs::symlink_metadata(&path).unwrap_or_else(|error| { + panic!("snapshot metadata {}: {error}", path.display()) + }); + let file_type = metadata.file_type(); + let kind = if file_type.is_dir() { + SnapshotKind::Directory + } else if file_type.is_file() { + SnapshotKind::File(snapshot_file_bytes(&path, metadata.len())) + } else if file_type.is_symlink() { + SnapshotKind::Symlink(std::fs::read_link(&path).unwrap_or_else(|error| { + panic!("snapshot read_link {}: {error}", path.display()) + })) + } else { + panic!("snapshot unsupported entry kind: {}", path.display()); + }; + assert!( + out.insert(relative, kind).is_none(), + "duplicate snapshot path" + ); + if file_type.is_dir() { + walk(root, &path, out); + } + } + } + + let mut out = std::collections::BTreeMap::new(); + walk(root, root, &mut out); + out + } + fn temp_db_path(label: &str) -> PathBuf { std::env::temp_dir().join(format!( "codegraph-conn-{label}-{}-{}.db", @@ -146,6 +1329,15 @@ mod tests { ); } + #[test] + #[cfg(target_pointer_width = "64")] + fn impossible_deserialize_size_is_rejected_before_ownership_bearing_allocation() { + assert!(matches!( + deserialize_sizes(usize::MAX), + Err(rusqlite::Error::InvalidQuery) + )); + } + #[test] fn open_creates_parent_dir_migrates_and_exposes_accessors() { let base = std::env::temp_dir().join(format!( @@ -187,7 +1379,7 @@ mod tests { assert_eq!(v1, crate::migrations::CURRENT_SCHEMA_VERSION); for ext in ["", "-wal", "-shm"] { - let _ = std::fs::remove_file(format!("{}{ext}", db_path.display())); + let _ = std::fs::remove_file(database_sidecar_path(&db_path, ext)); } } @@ -313,4 +1505,74 @@ mod tests { }; assert!(migrate.to_string().contains("/tmp/x.db")); } + + #[test] + fn current_writer_revalidates_lock_at_the_last_pre_open_checkpoint() { + fn deadline() -> Instant { + Instant::now() + std::time::Duration::from_secs(5) + } + + let base = std::env::temp_dir().join(format!( + "codegraph-conn-late-lock-replacement-{}-{}-{}", + std::process::id(), + NEXT_TEMP.fetch_add(1, Ordering::Relaxed), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&base).unwrap(); + let project = base.canonicalize().unwrap(); + let paths = IndexPaths::resolve(&project, None).unwrap(); + let initial = IndexLease::create_exclusive(&paths, deadline(), || false).unwrap(); + let fixture = Store::open(&paths.current_db()).unwrap(); + fixture + .set_project_metadata( + EXTRACTION_VERSION_KEY, + &CURRENT_EXTRACTION_VERSION.to_string(), + ) + .unwrap(); + fixture.restore_default_pragmas().unwrap(); + drop(fixture); + let state = serde_json::json!({ + "sequence": 1, + "storageProtocol": crate::CURRENT_STORAGE_PROTOCOL, + "extractionVersion": CURRENT_EXTRACTION_VERSION, + "phase": "current", + "projectIdentity": paths.project_identity(), + "checksum": crate::checksum_hex( + 1, + crate::CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION, + "current", + paths.project_identity(), + ), + }); + std::fs::write(&paths.state_slots()[0], serde_json::to_vec(&state).unwrap()).unwrap(); + drop(initial); + let lease = IndexLease::acquire_exclusive_existing(&paths, deadline(), || false).unwrap(); + let displaced = paths.current_root().join("displaced-at-write-open.lock"); + let expected_after_replacement = std::cell::RefCell::new(None); + + let error = + Store::open_for_write_with(&paths, lease, StoreWritePurpose::CurrentMutation, || { + std::fs::rename(paths.permanent_lock(), &displaced).unwrap(); + std::fs::write(paths.permanent_lock(), b"late replacement").unwrap(); + expected_after_replacement.replace(Some(snapshot_tree(&project))); + }) + .expect_err("late lock replacement must prevent write-capable SQLite open"); + assert!(matches!( + error, + StoreError::LeaseValidation(IndexLeaseValidationError::PermanentLockChanged { .. }) + )); + assert_eq!( + snapshot_tree(&project), + expected_after_replacement + .into_inner() + .expect("checkpoint captured post-replacement tree"), + "rejection must not mutate any byte after the deterministic replacement checkpoint" + ); + + let _ = std::fs::remove_dir_all(base); + } } diff --git a/crates/codegraph-store/src/file_identity.rs b/crates/codegraph-store/src/file_identity.rs new file mode 100644 index 0000000..23274e0 --- /dev/null +++ b/crates/codegraph-store/src/file_identity.rs @@ -0,0 +1,201 @@ +//! Private cross-platform filesystem-object identity helpers. + +#[cfg(windows)] +use std::fs::OpenOptions; +use std::fs::{File, Metadata}; +use std::io; +use std::path::Path; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FileIdentity { + #[cfg(unix)] + Unix { device: u64, inode: u64 }, + #[cfg(windows)] + Windows { + volume_serial_number: u64, + file_id: [u8; 16], + }, + #[cfg(not(any(unix, windows)))] + Portable { + len: u64, + modified: Option, + created: Option, + }, +} + +pub(crate) fn is_alias(metadata: &Metadata) -> bool { + if metadata.file_type().is_symlink() { + return true; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt as _; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + } + #[cfg(not(windows))] + false +} + +pub(crate) fn is_regular(metadata: &Metadata) -> bool { + metadata.file_type().is_file() && !is_alias(metadata) +} + +pub(crate) fn metadata_observation_matches(left: &Metadata, right: &Metadata) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + left.dev() == right.dev() && left.ino() == right.ino() + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt as _; + left.file_attributes() == right.file_attributes() + && left.creation_time() == right.creation_time() + && left.last_write_time() == right.last_write_time() + && left.file_size() == right.file_size() + } + #[cfg(not(any(unix, windows)))] + { + left.len() == right.len() + && left.modified().ok() == right.modified().ok() + && left.created().ok() == right.created().ok() + } +} + +pub(crate) fn identity_for_file(file: &File) -> io::Result { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + let metadata = file.metadata()?; + Ok(FileIdentity::Unix { + device: metadata.dev(), + inode: metadata.ino(), + }) + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle as _; + + const FILE_ID_INFO_CLASS: i32 = 18; + #[repr(C)] + struct FileIdInfo { + volume_serial_number: u64, + file_id: [u8; 16], + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetFileInformationByHandleEx( + h_file: isize, + file_information_class: i32, + lp_file_information: *mut core::ffi::c_void, + dw_buffer_size: u32, + ) -> i32; + } + + let mut info = FileIdInfo { + volume_serial_number: 0, + file_id: [0; 16], + }; + // SAFETY: `file` owns a live Windows handle and `info` is a correctly + // sized writable FILE_ID_INFO buffer for FileIdInfo. + let ok = unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle() as isize, + FILE_ID_INFO_CLASS, + (&mut info as *mut FileIdInfo).cast(), + core::mem::size_of::() as u32, + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + Ok(FileIdentity::Windows { + volume_serial_number: info.volume_serial_number, + file_id: info.file_id, + }) + } + #[cfg(not(any(unix, windows)))] + { + let metadata = file.metadata()?; + Ok(FileIdentity::Portable { + len: metadata.len(), + modified: metadata.modified().ok(), + created: metadata.created().ok(), + }) + } +} + +pub(crate) fn identity_for_validated_path( + path: &Path, + metadata: &Metadata, +) -> io::Result { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + let _ = path; + Ok(FileIdentity::Unix { + device: metadata.dev(), + inode: metadata.ino(), + }) + } + #[cfg(windows)] + { + let file = open_no_follow(path)?; + let opened = file.metadata()?; + if !is_regular(&opened) || !metadata_observation_matches(metadata, &opened) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "path changed while capturing exact Windows file identity", + )); + } + identity_for_file(&file) + } + #[cfg(not(any(unix, windows)))] + { + let _ = path; + Ok(FileIdentity::Portable { + len: metadata.len(), + modified: metadata.modified().ok(), + created: metadata.created().ok(), + }) + } +} + +pub(crate) fn path_still_names_file(path: &Path, file: &File) -> io::Result { + path_still_names_identity(path, identity_for_file(file)?) +} + +fn path_still_names_identity(path: &Path, expected: FileIdentity) -> io::Result { + let metadata = std::fs::symlink_metadata(path)?; + if !is_regular(&metadata) { + return Ok(false); + } + #[cfg(unix)] + { + Ok(identity_for_validated_path(path, &metadata)? == expected) + } + #[cfg(windows)] + { + let file = open_no_follow(path)?; + let opened = file.metadata()?; + if !is_regular(&opened) || !metadata_observation_matches(&metadata, &opened) { + return Ok(false); + } + Ok(identity_for_file(&file)? == expected) + } + #[cfg(not(any(unix, windows)))] + { + Ok(identity_for_validated_path(path, &metadata)? == expected) + } +} + +#[cfg(windows)] +fn open_no_follow(path: &Path) -> io::Result { + use std::os::windows::fs::OpenOptionsExt as _; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + OpenOptions::new() + .read(true) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) +} diff --git a/crates/codegraph-store/src/index_lease.rs b/crates/codegraph-store/src/index_lease.rs new file mode 100644 index 0000000..804a4ac --- /dev/null +++ b/crates/codegraph-store/src/index_lease.rs @@ -0,0 +1,782 @@ +//! Cooperative kernel-lock capability for one resolved v2 index namespace. +//! +//! A lease owns one locked file description behind an [`Arc`]. Cloning a lease +//! clones only that `Arc`; the file is unlocked and closed when the final owner +//! drops. Acquisition always uses the nonblocking standard-library `try_lock*` +//! calls in a bounded loop with a monotonic deadline and cancellation checks. + +use std::fs::{File, OpenOptions}; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +#[cfg(feature = "test-hooks")] +use std::io::{Read, Write}; + +use codegraph_core::IndexPaths; +use thiserror::Error; + +use crate::file_identity::{ + FileIdentity, identity_for_file, identity_for_validated_path, is_alias, is_regular, + metadata_observation_matches, path_still_names_file, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LeaseMode { + Shared, + Exclusive, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AcquireCheckpoint { + RootCreated, + InitialMetadataValidated, + HandleOpened, + KernelLockAcquired, + FinalPathCorroborated, +} + +#[derive(Debug)] +struct LeaseInner { + file: File, + mode: LeaseMode, + db_parent: PathBuf, + lock_path: PathBuf, +} + +struct PendingAcquisition { + lock_path: PathBuf, + mode: LeaseMode, + db_parent: PathBuf, + deadline: Instant, + opened_identity: FileIdentity, +} + +impl Drop for LeaseInner { + fn drop(&mut self) { + // This Drop runs exactly once, when the final `Arc` owner is + // gone. Unlock before File's own Drop closes the one locked description. + // There is no recovery action available from Drop; close also releases + // the kernel lock if this best-effort explicit unlock reports an error. + let _ = self.file.unlock(); + } +} + +/// A cloneable capability tied to one resolved v2 database parent. +#[derive(Debug, Clone)] +pub struct IndexLease { + inner: Arc, +} + +/// Typed failures while opening or acquiring a permanent index lock. +#[derive(Debug, Error)] +pub enum IndexLeaseError { + /// An existing namespace has no permanent lock. + #[error("permanent index lock does not exist: {path}")] + LockNotFound { path: PathBuf }, + /// The fixed permanent-lock path is a symlink or Windows reparse point. + #[error("permanent index lock is an alias and cannot be lock authority: {path}")] + AliasedLock { path: PathBuf }, + /// The fixed permanent-lock path exists but is not a regular file. + #[error("permanent index lock is a {kind}, not a regular file: {path}")] + NonRegularLock { path: PathBuf, kind: &'static str }, + /// The fixed path stopped naming the opened lock during acquisition. + #[error("permanent index lock changed during acquisition: {path}")] + LockChangedDuringAcquisition { path: PathBuf }, + /// Another entry won creation of the permanent lock in a newly created root. + #[error("permanent index lock creation lost a race: {path}")] + LockCreationConflict { path: PathBuf }, + /// Explicit namespace creation was requested for an existing root. + #[error("cannot create an initial index lease because the namespace already exists: {path}")] + NamespaceAlreadyExists { path: PathBuf }, + /// The current root could not be created or inspected. + #[error("cannot create or inspect index root {path}: {source}")] + CreateRoot { + path: PathBuf, + source: std::io::Error, + }, + /// The permanent lock file could not be opened. + #[error("cannot open permanent index lock {path}: {source}")] + OpenLock { + path: PathBuf, + source: std::io::Error, + }, + /// The bounded acquisition deadline elapsed while another process held an + /// incompatible lock. + #[error("timed out acquiring permanent index lock {path}")] + TimedOut { path: PathBuf }, + /// The caller cancelled bounded acquisition. + #[error("cancelled while acquiring permanent index lock {path}")] + Cancelled { path: PathBuf }, + /// The operating system rejected a lock operation for another reason. + #[error("cannot acquire permanent index lock {path}: {source}")] + Lock { + path: PathBuf, + source: std::io::Error, + }, +} + +/// Typed failures when a later writer validates an [`IndexLease`] capability. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum IndexLeaseValidationError { + /// A shared reader lease cannot authorize a writer. + #[error("a shared index lease cannot authorize a writer")] + SharedLease, + /// The capability belongs to another resolved v2 database parent. + #[error("index lease belongs to a different v2 database parent")] + WrongDbParent, + /// The permanent fixed path no longer names the exact locked handle. + #[error("permanent index lock changed, disappeared, or became an alias: {path}")] + PermanentLockChanged { + /// Fixed permanent-lock path expected to name the held handle. + path: PathBuf, + }, +} + +impl IndexLease { + /// Open and acquire the permanent lock of an existing namespace for shared + /// reading. This API never creates a directory or file. + pub fn acquire_shared_existing( + paths: &IndexPaths, + deadline: Instant, + cancelled: impl FnMut() -> bool, + ) -> Result { + Self::acquire_existing(paths, LeaseMode::Shared, deadline, cancelled) + } + + /// Open and acquire the permanent lock of an existing namespace for + /// exclusive writing. This API never creates a directory or file. + pub fn acquire_exclusive_existing( + paths: &IndexPaths, + deadline: Instant, + cancelled: impl FnMut() -> bool, + ) -> Result { + Self::acquire_existing(paths, LeaseMode::Exclusive, deadline, cancelled) + } + + /// Acquire the ONE outer exclusive capability of a namespace that may or may + /// not exist yet: an existing current root must already carry its permanent + /// lock (never repaired), while a genuinely absent root is created together + /// with its lock. This is the single entry point every writer that owns a + /// whole lifecycle operation uses, so no caller re-implements the + /// existing-vs-initial decision or acquires a second, nested lease. + pub fn acquire_or_create_exclusive( + paths: &IndexPaths, + deadline: Instant, + cancelled: impl FnMut() -> bool, + ) -> Result { + match std::fs::symlink_metadata(paths.current_root()) { + Ok(_) => Self::acquire_exclusive_existing(paths, deadline, cancelled), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + Self::create_exclusive(paths, deadline, cancelled) + } + Err(source) => Err(IndexLeaseError::CreateRoot { + path: paths.current_root().to_path_buf(), + source, + }), + } + } + + /// Explicitly create a genuinely absent current root and its permanent lock, + /// then acquire the initial exclusive capability. + pub fn create_exclusive( + paths: &IndexPaths, + deadline: Instant, + cancelled: impl FnMut() -> bool, + ) -> Result { + Self::create_exclusive_with(paths, deadline, cancelled, |_| {}) + } + + fn create_exclusive_with( + paths: &IndexPaths, + deadline: Instant, + mut cancelled: impl FnMut() -> bool, + mut checkpoint: impl FnMut(AcquireCheckpoint), + ) -> Result { + let root = paths.current_root(); + let lock_path = paths.permanent_lock(); + if cancelled() { + return Err(IndexLeaseError::Cancelled { path: lock_path }); + } + if Instant::now() >= deadline { + return Err(IndexLeaseError::TimedOut { path: lock_path }); + } + match std::fs::symlink_metadata(root) { + Ok(_) => { + return Err(IndexLeaseError::NamespaceAlreadyExists { + path: root.to_path_buf(), + }); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(source) => { + return Err(IndexLeaseError::CreateRoot { + path: root.to_path_buf(), + source, + }); + } + } + let parent = root + .parent() + .expect("IndexPaths current root always has a parent"); + std::fs::create_dir_all(parent).map_err(|source| IndexLeaseError::CreateRoot { + path: parent.to_path_buf(), + source, + })?; + std::fs::create_dir(root).map_err(|source| { + if source.kind() == io::ErrorKind::AlreadyExists { + IndexLeaseError::NamespaceAlreadyExists { + path: root.to_path_buf(), + } + } else { + IndexLeaseError::CreateRoot { + path: root.to_path_buf(), + source, + } + } + })?; + checkpoint(AcquireCheckpoint::RootCreated); + let file = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .truncate(false) + .open(&lock_path) + .map_err(|source| classify_create_error(&lock_path, source))?; + let opened_identity = opened_identity(&file, &lock_path, None)?; + checkpoint(AcquireCheckpoint::HandleOpened); + Self::acquire_file( + file, + PendingAcquisition { + lock_path, + mode: LeaseMode::Exclusive, + db_parent: db_parent(paths), + deadline, + opened_identity, + }, + cancelled, + checkpoint, + ) + } + + /// Whether this capability represents a shared reader lock. + #[must_use] + pub fn is_shared(&self) -> bool { + self.inner.mode == LeaseMode::Shared + } + + /// Whether this capability represents an exclusive writer lock. + #[must_use] + pub fn is_exclusive(&self) -> bool { + self.inner.mode == LeaseMode::Exclusive + } + + /// Whether this capability belongs to the normalized v2 DB parent in + /// `paths`. The identity itself remains private. + #[must_use] + pub fn matches_db_parent(&self, paths: &IndexPaths) -> bool { + self.inner.db_parent == db_parent(paths) + } + + /// Validate this capability for a future write-capable store open. + pub fn validate_exclusive(&self, paths: &IndexPaths) -> Result<(), IndexLeaseValidationError> { + if self.inner.mode != LeaseMode::Exclusive { + return Err(IndexLeaseValidationError::SharedLease); + } + if !self.matches_db_parent(paths) { + return Err(IndexLeaseValidationError::WrongDbParent); + } + let expected_path = paths.permanent_lock(); + if self.inner.lock_path != expected_path + || !path_still_names_file(&self.inner.lock_path, &self.inner.file).unwrap_or(false) + { + return Err(IndexLeaseValidationError::PermanentLockChanged { + path: expected_path, + }); + } + Ok(()) + } + + fn acquire_existing( + paths: &IndexPaths, + mode: LeaseMode, + deadline: Instant, + cancelled: impl FnMut() -> bool, + ) -> Result { + Self::acquire_existing_with(paths, mode, deadline, cancelled, |_| {}) + } + + fn acquire_existing_with( + paths: &IndexPaths, + mode: LeaseMode, + deadline: Instant, + mut cancelled: impl FnMut() -> bool, + mut checkpoint: impl FnMut(AcquireCheckpoint), + ) -> Result { + let lock_path = paths.permanent_lock(); + if cancelled() { + return Err(IndexLeaseError::Cancelled { path: lock_path }); + } + if Instant::now() >= deadline { + return Err(IndexLeaseError::TimedOut { path: lock_path }); + } + let initial = validated_path_metadata(&lock_path)?; + let initial_identity = + identity_for_validated_path(&lock_path, &initial).map_err(|_| changed(&lock_path))?; + checkpoint(AcquireCheckpoint::InitialMetadataValidated); + let file = OpenOptions::new() + .read(true) + .write(true) + .open(&lock_path) + .map_err(|source| { + if source.kind() == std::io::ErrorKind::NotFound { + changed(&lock_path) + } else { + IndexLeaseError::OpenLock { + path: lock_path.clone(), + source, + } + } + })?; + let opened_identity = opened_identity(&file, &lock_path, Some(&initial))?; + if initial_identity != opened_identity { + return Err(changed(&lock_path)); + } + checkpoint(AcquireCheckpoint::HandleOpened); + Self::acquire_file( + file, + PendingAcquisition { + lock_path, + mode, + db_parent: db_parent(paths), + deadline, + opened_identity, + }, + cancelled, + checkpoint, + ) + } + + fn acquire_file( + file: File, + pending: PendingAcquisition, + mut cancelled: impl FnMut() -> bool, + mut checkpoint: impl FnMut(AcquireCheckpoint), + ) -> Result { + let PendingAcquisition { + lock_path, + mode, + db_parent, + deadline, + opened_identity, + } = pending; + loop { + if cancelled() { + return Err(IndexLeaseError::Cancelled { path: lock_path }); + } + if Instant::now() >= deadline { + return Err(IndexLeaseError::TimedOut { path: lock_path }); + } + + let attempt = match mode { + LeaseMode::Shared => file.try_lock_shared(), + LeaseMode::Exclusive => file.try_lock(), + }; + match attempt { + Ok(()) => { + checkpoint(AcquireCheckpoint::KernelLockAcquired); + if !final_path_matches(&lock_path, &file, opened_identity) { + // The locked handle is dropped here instead of becoming + // a capability, releasing the kernel lock exactly once. + return Err(changed(&lock_path)); + } + checkpoint(AcquireCheckpoint::FinalPathCorroborated); + let lease = Self { + inner: Arc::new(LeaseInner { + file, + mode, + db_parent, + lock_path, + }), + }; + #[cfg(feature = "test-hooks")] + lease_test_barrier(mode); + return Ok(lease); + } + Err(std::fs::TryLockError::WouldBlock) => { + // Check cancellation after every observed contention, then + // bound the next retry by the monotonic deadline. The short + // park avoids a hot spin but never substitutes for try_lock + // as the lock authority. + if cancelled() { + return Err(IndexLeaseError::Cancelled { path: lock_path }); + } + let now = Instant::now(); + if now >= deadline { + return Err(IndexLeaseError::TimedOut { path: lock_path }); + } + let remaining = deadline.saturating_duration_since(now); + std::thread::park_timeout(remaining.min(Duration::from_millis(5))); + } + Err(std::fs::TryLockError::Error(source)) => { + return Err(IndexLeaseError::Lock { + path: lock_path, + source, + }); + } + } + } + } +} + +/// Deterministic cross-process checkpoint used only by integration-test builds. +/// +/// A test child opts in by supplying a loopback listener address and the lease +/// mode it wants to stop. The hook writes one acknowledged mode byte only after +/// the kernel lock and final path corroboration both succeeded, then waits for a +/// release byte with bounded socket timeouts. No corresponding strings or I/O +/// path are compiled into normal/release builds. +#[cfg(feature = "test-hooks")] +fn lease_test_barrier(mode: LeaseMode) { + const ADDR_ENV: &str = "CODEGRAPH_TEST_LEASE_BARRIER_ADDR"; + const MODE_ENV: &str = "CODEGRAPH_TEST_LEASE_BARRIER_MODE"; + const WAIT: Duration = Duration::from_secs(10); + + let expected = match std::env::var(MODE_ENV) { + Ok(value) => value, + Err(_) => return, + }; + let (mode_name, marker) = match mode { + LeaseMode::Shared => ("shared", b'S'), + LeaseMode::Exclusive => ("exclusive", b'X'), + }; + if expected != mode_name { + return; + } + let address = std::env::var(ADDR_ENV) + .unwrap_or_else(|_| panic!("{MODE_ENV} requires {ADDR_ENV}")) + .parse() + .unwrap_or_else(|error| panic!("invalid {ADDR_ENV}: {error}")); + let mut stream = std::net::TcpStream::connect_timeout(&address, WAIT) + .unwrap_or_else(|error| panic!("connect lease test barrier {address}: {error}")); + stream + .set_read_timeout(Some(WAIT)) + .expect("set lease test barrier read timeout"); + stream + .set_write_timeout(Some(WAIT)) + .expect("set lease test barrier write timeout"); + stream + .write_all(&[marker]) + .expect("acknowledge lease test barrier arrival"); + let mut release = [0_u8; 1]; + stream + .read_exact(&mut release) + .expect("receive lease test barrier release"); + assert_eq!(release, [b'R'], "invalid lease test barrier release byte"); +} + +fn validated_path_metadata(lock_path: &Path) -> Result { + let metadata = std::fs::symlink_metadata(lock_path).map_err(|source| { + if source.kind() == io::ErrorKind::NotFound { + IndexLeaseError::LockNotFound { + path: lock_path.to_path_buf(), + } + } else { + IndexLeaseError::OpenLock { + path: lock_path.to_path_buf(), + source, + } + } + })?; + if is_alias(&metadata) { + return Err(IndexLeaseError::AliasedLock { + path: lock_path.to_path_buf(), + }); + } + if !is_regular(&metadata) { + let kind = if metadata.file_type().is_dir() { + "directory" + } else { + "non-regular filesystem entry" + }; + return Err(IndexLeaseError::NonRegularLock { + path: lock_path.to_path_buf(), + kind, + }); + } + Ok(metadata) +} + +fn opened_identity( + file: &File, + lock_path: &Path, + initial: Option<&std::fs::Metadata>, +) -> Result { + let opened = file + .metadata() + .map_err(|source| IndexLeaseError::OpenLock { + path: lock_path.to_path_buf(), + source, + })?; + if !is_regular(&opened) + || initial.is_some_and(|initial| !metadata_observation_matches(initial, &opened)) + { + return Err(changed(lock_path)); + } + identity_for_file(file).map_err(|_| changed(lock_path)) +} + +fn final_path_matches(lock_path: &Path, file: &File, opened: FileIdentity) -> bool { + let Ok(metadata) = std::fs::symlink_metadata(lock_path) else { + return false; + }; + if !is_regular(&metadata) { + return false; + } + let Ok(path_identity) = identity_for_validated_path(lock_path, &metadata) else { + return false; + }; + path_identity == opened && path_still_names_file(lock_path, file).unwrap_or(false) +} + +fn changed(lock_path: &Path) -> IndexLeaseError { + IndexLeaseError::LockChangedDuringAcquisition { + path: lock_path.to_path_buf(), + } +} + +fn classify_create_error(lock_path: &Path, source: io::Error) -> IndexLeaseError { + if source.kind() == io::ErrorKind::AlreadyExists || std::fs::symlink_metadata(lock_path).is_ok() + { + IndexLeaseError::LockCreationConflict { + path: lock_path.to_path_buf(), + } + } else { + IndexLeaseError::OpenLock { + path: lock_path.to_path_buf(), + source, + } + } +} + +fn db_parent(paths: &IndexPaths) -> PathBuf { + let db = paths.current_db(); + let parent = db + .parent() + .expect("IndexPaths current DB always has its resolved current root as parent"); + debug_assert_eq!(parent, paths.current_root()); + parent.to_path_buf() +} + +#[cfg(all(test, unix))] +mod tests { + use std::os::unix::fs::symlink; + use std::sync::atomic::{AtomicU64, Ordering}; + + use super::*; + + static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + + struct TempProject(PathBuf); + + impl TempProject { + fn new(label: &str) -> Self { + let serial = NEXT_TEMP.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "codegraph-index-lease-unit-{label}-{}-{serial}", + std::process::id() + )); + std::fs::create_dir(&path).expect("create lease unit-test project"); + Self( + path.canonicalize() + .expect("canonical lease unit-test project"), + ) + } + + fn paths(&self) -> IndexPaths { + IndexPaths::resolve(&self.0, None).expect("resolve lease unit-test paths") + } + } + + impl Drop for TempProject { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn deadline() -> Instant { + Instant::now() + .checked_add(Duration::from_secs(5)) + .expect("test deadline") + } + + #[test] + fn replacement_after_kernel_lock_is_rejected_and_releases_the_opened_file() { + let project = TempProject::new("replacement-after-lock"); + let paths = project.paths(); + std::fs::create_dir(paths.current_root()).expect("create current root"); + let lock_path = paths.permanent_lock(); + let displaced = paths.current_root().join("displaced.lock"); + let replacement = paths.current_root().join("replacement.lock"); + std::fs::write(&lock_path, b"original").expect("write original lock"); + std::fs::write(&replacement, b"replacement").expect("write replacement lock"); + + let mut replaced = false; + let error = IndexLease::acquire_existing_with( + &paths, + LeaseMode::Exclusive, + deadline(), + || false, + |point| { + if point == AcquireCheckpoint::KernelLockAcquired && !replaced { + std::fs::rename(&lock_path, &displaced).expect("displace opened lock"); + std::fs::rename(&replacement, &lock_path).expect("install replacement lock"); + replaced = true; + } + }, + ) + .expect_err("replacement must invalidate the acquired handle"); + assert!(matches!( + error, + IndexLeaseError::LockChangedDuringAcquisition { path } if path == lock_path + )); + + let displaced_handle = OpenOptions::new() + .read(true) + .write(true) + .open(&displaced) + .expect("open displaced original"); + displaced_handle + .try_lock() + .expect("rejected authority must release its kernel lock"); + displaced_handle + .unlock() + .expect("unlock displaced original"); + + let final_lease = IndexLease::acquire_exclusive_existing(&paths, deadline(), || false) + .expect("fresh contender acquires the final fixed lock"); + drop(final_lease); + } + + #[test] + fn replacement_after_initial_validation_is_rejected_before_authority_returns() { + let project = TempProject::new("replacement-before-open"); + let paths = project.paths(); + std::fs::create_dir(paths.current_root()).expect("create current root"); + let lock_path = paths.permanent_lock(); + let displaced = paths.current_root().join("validated.lock"); + let replacement = paths.current_root().join("replacement.lock"); + std::fs::write(&lock_path, b"validated").expect("write validated lock"); + std::fs::write(&replacement, b"replacement").expect("write replacement lock"); + + let mut replaced = false; + let error = IndexLease::acquire_existing_with( + &paths, + LeaseMode::Exclusive, + deadline(), + || false, + |point| { + if point == AcquireCheckpoint::InitialMetadataValidated && !replaced { + std::fs::rename(&lock_path, &displaced).expect("displace validated lock"); + std::fs::rename(&replacement, &lock_path).expect("install replacement lock"); + replaced = true; + } + }, + ) + .expect_err("opened file must match the initially validated object"); + assert!(matches!( + error, + IndexLeaseError::LockChangedDuringAcquisition { path } if path == lock_path + )); + + for path in [&displaced, &lock_path] { + let handle = OpenOptions::new() + .read(true) + .write(true) + .open(path) + .expect("open race participant"); + handle + .try_lock() + .expect("rejected acquisition returns no locked authority"); + handle.unlock().expect("unlock race participant"); + } + } + + #[test] + fn initial_creation_rejects_an_alias_that_wins_after_root_creation() { + let project = TempProject::new("creation-alias-race"); + let paths = project.paths(); + let external = project.0.join("external.lock"); + let external_bytes = b"external-lock-must-stay-unchanged"; + std::fs::write(&external, external_bytes).expect("write external target"); + + let mut installed = false; + let error = IndexLease::create_exclusive_with( + &paths, + deadline(), + || false, + |point| { + if point == AcquireCheckpoint::RootCreated && !installed { + symlink(&external, paths.permanent_lock()).expect("install competing alias"); + installed = true; + } + }, + ) + .expect_err("create-new must reject a competing alias"); + assert!(matches!( + error, + IndexLeaseError::LockCreationConflict { path } if path == paths.permanent_lock() + )); + assert_eq!(std::fs::read(&external).unwrap(), external_bytes); + + let external_handle = OpenOptions::new() + .read(true) + .write(true) + .open(&external) + .expect("open external target"); + external_handle + .try_lock() + .expect("failed creation never locks the alias target"); + external_handle.unlock().expect("unlock external target"); + } + + #[test] + fn initial_creation_rejects_a_regular_entry_that_wins_after_root_creation() { + let project = TempProject::new("creation-regular-race"); + let paths = project.paths(); + let competing_bytes = b"competing-regular-lock"; + + let mut installed = false; + let error = IndexLease::create_exclusive_with( + &paths, + deadline(), + || false, + |point| { + if point == AcquireCheckpoint::RootCreated && !installed { + std::fs::write(paths.permanent_lock(), competing_bytes) + .expect("install competing regular lock"); + installed = true; + } + }, + ) + .expect_err("create-new must reject a competing regular entry"); + assert!(matches!( + error, + IndexLeaseError::LockCreationConflict { path } if path == paths.permanent_lock() + )); + assert_eq!( + std::fs::read(paths.permanent_lock()).unwrap(), + competing_bytes + ); + + let competing_handle = OpenOptions::new() + .read(true) + .write(true) + .open(paths.permanent_lock()) + .expect("open competing regular lock"); + competing_handle + .try_lock() + .expect("failed creation never locks the competing entry"); + competing_handle.unlock().expect("unlock competing entry"); + } +} diff --git a/crates/codegraph-store/src/index_state.rs b/crates/codegraph-store/src/index_state.rs new file mode 100644 index 0000000..cf35083 --- /dev/null +++ b/crates/codegraph-store/src/index_state.rs @@ -0,0 +1,1052 @@ +//! Read-only index-state authority: the dual fixed state slots and the typed +//! classification derived from them. +//! +//! Frozen plan `upstream-v1.5-portable-fixes.md`, Batch M ("Store ownership, +//! version gate, and final publication", plan lines 452-522). This module owns +//! the SINGLE SOURCE OF TRUTH for the storage protocol / extraction version and +//! the persisted state-slot contract, plus the classifier that turns the two +//! fixed slots into an [`ExtractionStatus`]. +//! +//! # Scope of the classifier: READ-ONLY +//! +//! The classifier in this module is deliberately NONMUTATING and self-contained: +//! +//! - it never creates, truncates, renames, or deletes any file or directory +//! (not the current root, the permanent lock, a state slot, a temp file, the +//! database, a sidecar, or the tombstone); +//! - it never opens SQLite, so it cannot run schema setup, migrations, WAL +//! changes, or `ANALYZE`; +//! - publication lives in the separate `index_state_publisher` module and +//! consumes this classifier without weakening its aggregate ordering. This +//! classifier itself remains strictly read-only; +//! - `Store::open_for_read` / `open_for_status` / `open_for_write` and +//! `Store::stamp_extraction_version` consume this classifier without changing +//! it. Migration mode, uninit lifecycle, and the daemon/watcher lifecycle land +//! in later Batch M slices. +//! +//! This classifier boundary is narrower than the commit series containing it: +//! existing CLI indexing still writes `project_metadata`, but now takes that +//! metadata key and extraction version (`2`) from this store-owned module. That +//! pre-existing DB stamping is not state-slot publication and is not performed by +//! this classifier. +//! +//! [`checksum_hex`] and [`canonical_checksum_payload`] are pure functions (no +//! I/O); they define the byte representation the publisher reproduces, and the +//! tests use them to author protocol fixtures. +//! +//! # Persisted slot contract +//! +//! The two fixed files [`IndexPaths::state_slots`] returns — +//! `/index-state.0.json` and `/index-state.1.json` — +//! each hold ONE JSON object with these canonical fields: +//! +//! ```json +//! { +//! "sequence": 7, +//! "storageProtocol": 2, +//! "extractionVersion": 2, +//! "phase": "current", +//! "projectIdentity": "<64 lowercase hex>", +//! "checksum": "<64 lowercase hex>" +//! } +//! ``` +//! +//! - `sequence`, `storageProtocol`, `extractionVersion` are JSON unsigned +//! integers (`u64`); `phase`, `projectIdentity`, `checksum` are JSON strings. +//! These six names and their JSON types are PROTOCOL-STABLE: a future storage +//! protocol may add fields and may add `phase` vocabulary, but must keep these +//! six readable so an older binary can still recognize a future namespace. +//! - Unknown fields are IGNORED (forward compatibility). Missing or wrong-typed +//! required fields are `Corrupt`. +//! - `phase` is exactly one of `building`, `current`, `uninitialized` for +//! storage protocol [`CURRENT_STORAGE_PROTOCOL`]; any other value is `Corrupt`. +//! - `checksum` is the lowercase 64-hex SHA-256 of [`canonical_checksum_payload`], +//! which is a fixed ASCII, LF-terminated, field-ordered byte string. It does +//! NOT depend on JSON key order, whitespace, pretty-printing, host endianness, +//! locale, or line-ending conventions. +//! +//! # Classification order (deterministic, typed, never string-matched) +//! +//! 1. **Structural defects dominate.** A present slot that cannot be stat'd or +//! read, is not a regular file (a directory or a statically observed symlink at +//! a fixed slot path is an INVALID slot), changes identity while being read, or +//! whose bytes are not a JSON object with +//! the six canonical fields at their canonical types ⇒ [`ExtractionStatus::Corrupt`]. +//! A present invalid slot is NEVER ignored because the other slot is valid: +//! a future writer may have authored it, so it is security-relevant. +//! 2. **Stable fields are validated before protocol trust.** Every parsed slot, +//! including a future-protocol slot, must carry lowercase 64-hex owner and +//! checksum strings, a checksum matching the canonical payload (which hashes +//! the RAW phase text), and the expected owner. A lower/zero storage protocol +//! is unsupported. The first defect, scanning slot 0 then slot 1, is `Corrupt`. +//! Current protocol accepts exactly the three known phases; future protocol +//! may use an unknown phase only after those stable checks pass. +//! 3. No validated record at all ⇒ [`ExtractionStatus::Missing`]. A missing +//! INACTIVE slot is allowed. +//! 4. Two validated records with the SAME `sequence` ⇒ `Corrupt`, whether they +//! are current/current, future/future, or mixed, and regardless of raw JSON +//! formatting. +//! 5. A validated future-protocol record dominates a current-protocol companion +//! regardless of sequence (the highest future sequence wins). Otherwise the +//! highest current-protocol record is AUTHORITATIVE. An +//! authoritative sequence of [`u64::MAX`] is sequence exhaustion ⇒ `Corrupt` +//! (and nonmutating, like every other outcome). +//! 6. Future storage protocol ⇒ [`ExtractionStatus::Future`]. Otherwise, +//! `extractionVersion` above [`CURRENT_EXTRACTION_VERSION`] ⇒ +//! [`ExtractionStatus::Future`] for EVERY phase, `uninitialized` included. +//! Below ⇒ [`ExtractionStatus::Outdated`] preserving the built version. Equal +//! ⇒ [`ExtractionStatus::Current`], [`ExtractionStatus::Building`], or +//! [`ExtractionStatus::Uninitialized`] by phase. +//! +//! Callers must branch on the typed variants and on [`CorruptReason`]; the +//! `Display` renderings exist for operator messages only and are never parsed. + +use std::fmt; +use std::fs::{File, Metadata}; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; + +use codegraph_core::IndexPaths; +use serde::Deserialize; + +/// The on-disk storage-protocol version this binary defines. A slot above this +/// is [`ExtractionStatus::Future`]; below (or zero) is `Corrupt`. +pub const CURRENT_STORAGE_PROTOCOL: u64 = 2; + +/// The extraction-pipeline version this binary produces. This is the single +/// source of truth: no other crate may define its own copy. +pub const CURRENT_EXTRACTION_VERSION: u64 = 2; + +/// The `project_metadata` key under which a built index records the extraction +/// version it was produced with. Single source of truth for the key spelling. +pub const EXTRACTION_VERSION_KEY: &str = "indexed_with_extraction_version"; + +/// Domain-separation prefix of the canonical checksum payload. Bumping it would +/// invalidate every existing checksum, so it is versioned independently of the +/// storage protocol. +const CHECKSUM_DOMAIN: &str = "codegraph-index-state-v1"; + +/// The lifecycle phase a state slot records. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum StatePhase { + /// A destructive (re)build is in progress; the DB may be absent or partial. + Building, + /// The namespace holds a completely published index. + Current, + /// A `uninit --force` was in progress when the namespace last advanced. + Uninitialized, +} + +impl StatePhase { + /// The exact wire spelling stored in `phase` and hashed into the checksum. + #[must_use] + pub const fn as_wire(self) -> &'static str { + match self { + Self::Building => "building", + Self::Current => "current", + Self::Uninitialized => "uninitialized", + } + } + + /// Parse a wire `phase` value. `None` for any value outside the exactly + /// three supported phases (the caller turns that into `Corrupt`). + #[must_use] + pub fn from_wire(value: &str) -> Option { + match value { + "building" => Some(Self::Building), + "current" => Some(Self::Current), + "uninitialized" => Some(Self::Uninitialized), + _ => None, + } + } +} + +impl fmt::Display for StatePhase { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_wire()) + } +} + +/// The canonical byte representation the [`checksum_hex`] digest covers. +/// +/// It is a pure ASCII, LF-separated, LF-terminated, fixed-field-order string: +/// +/// ```text +/// codegraph-index-state-v1\n +/// sequence=\n +/// storageProtocol=\n +/// extractionVersion=\n +/// phase=\n +/// projectIdentity=<64 lowercase hex>\n +/// ``` +/// +/// The labels, separators, decimal integers, and line endings are exactly the +/// ASCII bytes shown above. `phase` and `projectIdentity` are inserted verbatim +/// as UTF-8 (current phases and valid identities are ASCII); no escaping or +/// normalization is performed. The final byte is one LF. Integer rendering is +/// locale-independent, so the payload is unaffected by JSON key order, +/// whitespace, pretty-printing, endianness, or host line-ending conventions. +#[must_use] +pub fn canonical_checksum_payload( + sequence: u64, + storage_protocol: u64, + extraction_version: u64, + phase: &str, + project_identity: &str, +) -> String { + format!( + "{CHECKSUM_DOMAIN}\nsequence={sequence}\nstorageProtocol={storage_protocol}\n\ + extractionVersion={extraction_version}\nphase={phase}\nprojectIdentity={project_identity}\n" + ) +} + +/// The lowercase 64-hex SHA-256 of [`canonical_checksum_payload`]. +/// +/// The digest comes from `codegraph_core::node_id::hash_content`, the workspace's +/// existing public exact-SHA-256-hex utility. Reusing it keeps this crate free of +/// a direct `sha2` dependency (and therefore leaves `Cargo.lock` byte-identical) +/// without weakening the hash: `hash_content` is a plain SHA-256 over the UTF-8 +/// bytes of its argument, and the canonical payload is pure ASCII. +#[must_use] +pub fn checksum_hex( + sequence: u64, + storage_protocol: u64, + extraction_version: u64, + phase: &str, + project_identity: &str, +) -> String { + codegraph_core::node_id::hash_content(&canonical_checksum_payload( + sequence, + storage_protocol, + extraction_version, + phase, + project_identity, + )) +} + +fn is_lowercase_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +/// The canonical fields of one parsed slot. +/// +/// `phase` is `None` only for a future-storage-protocol slot, whose phase +/// vocabulary this binary does not define; for a current-protocol slot it is +/// always `Some` (an unknown phase is `Corrupt`, never a valid record). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StateSlotRecord { + /// Monotonic publication sequence; the highest valid one is authoritative. + pub sequence: u64, + /// On-disk storage protocol of this slot. + pub storage_protocol: u64, + /// Extraction-pipeline version the namespace was built with. + pub extraction_version: u64, + /// Parsed phase, or `None` for a future-protocol slot. + pub phase: Option, + /// The verbatim `phase` string as stored. + pub phase_raw: String, + /// Owning project identity (`IndexPaths::project_identity`). + pub project_identity: String, + /// The verbatim `checksum` string as stored. + pub checksum: String, +} + +/// Why a namespace is [`ExtractionStatus::Corrupt`]. Typed so callers branch on +/// the variant; the `Display` rendering is for operator messages only. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CorruptReason { + /// A present slot could not be stat'd or read. + UnreadableSlot { + /// Slot index (0 or 1). + slot: u8, + /// Slot path. + path: PathBuf, + /// Stable I/O detail. + detail: String, + }, + /// The fixed slot path exists but is not a regular file (for example a + /// directory or a symlink, which is recorded and refused, never followed). + NotARegularFile { + /// Slot index (0 or 1). + slot: u8, + /// Slot path. + path: PathBuf, + /// The observed entry kind. + kind: &'static str, + }, + /// The fixed slot path stopped naming the same regular filesystem entry + /// between the initial no-follow metadata check, opening the read handle, + /// and the post-read no-follow corroboration. + SlotChangedDuringRead { + /// Slot index (0 or 1). + slot: u8, + /// Slot path. + path: PathBuf, + /// Stable diagnostic without platform-specific identity values. + detail: String, + }, + /// The bytes are not a JSON object carrying the six canonical fields at + /// their canonical JSON types. + MalformedJson { + /// Slot index (0 or 1). + slot: u8, + /// Slot path. + path: PathBuf, + /// Stable parser detail. + detail: String, + }, + /// `projectIdentity` is not exactly 64 lowercase hexadecimal characters. + InvalidOwnerEncoding { + /// Slot index (0 or 1). + slot: u8, + /// Slot path. + path: PathBuf, + /// The rejected identity. + found: String, + }, + /// `checksum` is not exactly 64 lowercase hexadecimal characters. + InvalidChecksumEncoding { + /// Slot index (0 or 1). + slot: u8, + /// Slot path. + path: PathBuf, + /// The rejected checksum. + found: String, + }, + /// `phase` is outside the three supported values. + UnknownPhase { + /// Slot index (0 or 1). + slot: u8, + /// Slot path. + path: PathBuf, + /// The verbatim rejected `phase` value. + phase: String, + }, + /// The stored `checksum` does not equal the recomputed one. + ChecksumMismatch { + /// Slot index (0 or 1). + slot: u8, + /// Slot path. + path: PathBuf, + /// Recomputed checksum. + expected: String, + /// Checksum found in the file. + found: String, + }, + /// `storageProtocol` is below [`CURRENT_STORAGE_PROTOCOL`] (zero included); + /// such a slot is unsupported, not merely old. + UnsupportedStorageProtocol { + /// Slot index (0 or 1). + slot: u8, + /// Slot path. + path: PathBuf, + /// The rejected protocol value. + found: u64, + /// The protocol this binary defines. + supported: u64, + }, + /// `projectIdentity` does not match the owner identity of the namespace. + OwnerMismatch { + /// Slot index (0 or 1). + slot: u8, + /// Slot path. + path: PathBuf, + /// The owner identity this binary computed. + expected: String, + /// The identity stored in the slot. + found: String, + }, + /// Both slots are valid and carry the same `sequence`. This is `Corrupt` + /// whether the payload bytes are identical or different: the publication + /// protocol never produces two slots at one sequence, so the namespace's + /// history is unreconstructible either way. + EqualSequence { + /// The duplicated sequence. + sequence: u64, + /// Whether the two slots' bytes are identical. + identical_payload: bool, + }, + /// The authoritative sequence is [`u64::MAX`], so no successor can be + /// published. Reported without mutating anything. + SequenceExhausted { + /// The exhausted sequence. + sequence: u64, + }, +} + +impl fmt::Display for CorruptReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnreadableSlot { slot, path, detail } => write!( + f, + "index state slot {slot} ({}) could not be read: {detail}", + path.display() + ), + Self::NotARegularFile { slot, path, kind } => write!( + f, + "index state slot {slot} ({}) is a {kind}, not a regular file", + path.display() + ), + Self::SlotChangedDuringRead { slot, path, detail } => write!( + f, + "index state slot {slot} ({}) changed while being read: {detail}", + path.display() + ), + Self::MalformedJson { slot, path, detail } => write!( + f, + "index state slot {slot} ({}) is malformed: {detail}", + path.display() + ), + Self::InvalidOwnerEncoding { slot, path, found } => write!( + f, + "index state slot {slot} ({}) has invalid project identity encoding {found:?}", + path.display() + ), + Self::InvalidChecksumEncoding { slot, path, found } => write!( + f, + "index state slot {slot} ({}) has invalid checksum encoding {found:?}", + path.display() + ), + Self::UnknownPhase { slot, path, phase } => write!( + f, + "index state slot {slot} ({}) has unknown phase {phase:?}", + path.display() + ), + Self::ChecksumMismatch { + slot, + path, + expected, + found, + } => write!( + f, + "index state slot {slot} ({}) checksum mismatch: expected {expected}, found {found}", + path.display() + ), + Self::UnsupportedStorageProtocol { + slot, + path, + found, + supported, + } => write!( + f, + "index state slot {slot} ({}) has unsupported storage protocol {found} \ + (this binary supports {supported})", + path.display() + ), + Self::OwnerMismatch { + slot, + path, + expected, + found, + } => write!( + f, + "index state slot {slot} ({}) belongs to project identity {found}, not {expected}", + path.display() + ), + Self::EqualSequence { + sequence, + identical_payload, + } => { + let payload = if *identical_payload { + "identical" + } else { + "differing" + }; + write!( + f, + "both index state slots are valid at sequence {sequence} with {payload} payloads" + ) + } + Self::SequenceExhausted { sequence } => write!( + f, + "index state sequence is exhausted at {sequence}; no successor can be published" + ), + } + } +} + +/// The typed classification of a v2 namespace, derived from the two fixed slots +/// alone. Equivalent to the frozen plan's `ExtractionStatus`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExtractionStatus { + /// A complete index built by this extraction version. + Current, + /// A destructive build was in progress at this extraction version. + Building { + /// The extraction version the interrupted build was producing. + built: u64, + }, + /// A `uninit --force` was in progress when the namespace last advanced. + Uninitialized, + /// No state slot exists at all. + Missing, + /// Built by an older extraction version; a rebuild is required. + Outdated { + /// The older extraction version the namespace was built with. + built: u64, + }, + /// Written by a newer extraction version or storage protocol. This binary + /// must not read, migrate, or delete the namespace. + Future { + /// The newer extraction version recorded on disk. + built: u64, + }, + /// The namespace cannot be interpreted; manual recovery is required. + Corrupt { + /// The typed reason. + reason: CorruptReason, + }, +} + +impl fmt::Display for ExtractionStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Current => f.write_str("current"), + Self::Building { built } => write!(f, "building (extraction version {built})"), + Self::Uninitialized => f.write_str("uninitialized (interrupted uninit)"), + Self::Missing => f.write_str("missing"), + Self::Outdated { built } => { + write!(f, "outdated (built with extraction version {built})") + } + Self::Future { built } => write!(f, "future (built with extraction version {built})"), + Self::Corrupt { reason } => write!(f, "corrupt: {reason}"), + } + } +} + +/// The slot the classification treated as authoritative, with enough metadata +/// for the later lease / `Store::open_for_*` slices to re-verify it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthoritativeSlot { + /// Slot index (0 or 1). + pub index: u8, + /// Absolute slot path. + pub path: PathBuf, + /// The parsed record. + pub record: StateSlotRecord, +} + +/// The per-slot outcome, kept for inspection independently of the aggregate +/// [`ExtractionStatus`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SlotOutcome { + /// The slot file does not exist (allowed for the inactive slot). + Absent, + /// The slot parsed and passed every current-protocol check. + Valid(StateSlotRecord), + /// The slot parsed but declares a storage protocol above this binary's. + FutureProtocol(StateSlotRecord), + /// The slot is present and invalid. + Invalid(CorruptReason), +} + +/// The complete result of one read-only classification. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IndexStateClassification { + status: ExtractionStatus, + authoritative: Option, + slots: [SlotOutcome; 2], +} + +impl IndexStateClassification { + /// The aggregate status. + #[must_use] + pub fn status(&self) -> &ExtractionStatus { + &self.status + } + + /// The authoritative slot, when one was selected. `None` for + /// [`ExtractionStatus::Missing`] and for a `Corrupt` outcome caused by a + /// defective or duplicated slot. + #[must_use] + pub fn authoritative(&self) -> Option<&AuthoritativeSlot> { + self.authoritative.as_ref() + } + + /// The per-slot outcome for slot `index` (0 or 1). + /// + /// # Panics + /// + /// Panics if `index` is not 0 or 1; the slot layout is a fixed pair. + #[must_use] + pub fn slot(&self, index: usize) -> &SlotOutcome { + assert!(index < 2, "index state has exactly two fixed slots"); + &self.slots[index] + } +} + +/// Classify the namespace owned by `paths`, reading ONLY the two fixed slots. +/// +/// Consumes [`IndexPaths::state_slots`] and [`IndexPaths::project_identity`] +/// rather than reconstructing paths or owner identity. Nonmutating: see the +/// module docs. +#[must_use] +pub fn classify(paths: &IndexPaths) -> IndexStateClassification { + classify_slots(&paths.state_slots(), paths.project_identity()) +} + +/// Classify from exact slot paths and an exact owner identity. +/// +/// The unit-test seam for [`classify`], and the form later slices use when they +/// already hold the resolved slot pair. Nonmutating: see the module docs. +#[must_use] +pub fn classify_slots(slots: &[PathBuf; 2], owner_identity: &str) -> IndexStateClassification { + let raw = [read_slot(0, &slots[0]), read_slot(1, &slots[1])]; + classify_raw(&raw, slots, owner_identity) +} + +/// One slot as observed on disk, before semantic validation. +#[derive(Debug, Clone)] +enum RawSlot { + Absent, + Invalid(CorruptReason), + Parsed { wire: WireSlot, bytes: Vec }, +} + +/// The wire form. Unknown fields are ignored; every canonical field is required +/// at its canonical JSON type. +#[derive(Debug, Clone, Deserialize)] +struct WireSlot { + sequence: u64, + #[serde(rename = "storageProtocol")] + storage_protocol: u64, + #[serde(rename = "extractionVersion")] + extraction_version: u64, + phase: String, + #[serde(rename = "projectIdentity")] + project_identity: String, + checksum: String, +} + +fn read_slot(index: u8, path: &Path) -> RawSlot { + read_slot_with(index, path, |_| {}) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReadCheckpoint { + InitialMetadataValidated, + HandleOpened, + BytesRead, +} + +/// Read one fixed slot from one opened handle and corroborate that the path +/// still names the same entry. The callback is private and exists solely so the +/// unit test can replace a slot at an exact checkpoint without a timing race. +fn read_slot_with(index: u8, path: &Path, mut checkpoint: impl FnMut(ReadCheckpoint)) -> RawSlot { + let initial = match std::fs::symlink_metadata(path) { + Ok(meta) => meta, + Err(err) if err.kind() == io::ErrorKind::NotFound => return RawSlot::Absent, + Err(err) => { + return RawSlot::Invalid(CorruptReason::UnreadableSlot { + slot: index, + path: path.to_path_buf(), + detail: err.to_string(), + }); + } + }; + let file_type = initial.file_type(); + if !file_type.is_file() { + let kind = if file_type.is_dir() { + "directory" + } else if file_type.is_symlink() { + "symlink" + } else { + "non-regular filesystem entry" + }; + return RawSlot::Invalid(CorruptReason::NotARegularFile { + slot: index, + path: path.to_path_buf(), + kind, + }); + } + checkpoint(ReadCheckpoint::InitialMetadataValidated); + + let mut file = match File::open(path) { + Ok(file) => file, + Err(err) => { + return RawSlot::Invalid(CorruptReason::UnreadableSlot { + slot: index, + path: path.to_path_buf(), + detail: err.to_string(), + }); + } + }; + let opened = match file.metadata() { + Ok(meta) => meta, + Err(err) => { + return RawSlot::Invalid(CorruptReason::UnreadableSlot { + slot: index, + path: path.to_path_buf(), + detail: format!("opened-handle metadata failed: {err}"), + }); + } + }; + if !opened.is_file() { + return slot_changed(index, path, "opened handle is not a regular file"); + } + if !same_file_identity(&initial, &opened) { + return slot_changed(index, path, "entry identity changed before open"); + } + checkpoint(ReadCheckpoint::HandleOpened); + + let mut bytes = Vec::new(); + if let Err(err) = file.read_to_end(&mut bytes) { + return RawSlot::Invalid(CorruptReason::UnreadableSlot { + slot: index, + path: path.to_path_buf(), + detail: err.to_string(), + }); + } + checkpoint(ReadCheckpoint::BytesRead); + + let final_meta = match std::fs::symlink_metadata(path) { + Ok(meta) => meta, + Err(err) => { + return slot_changed( + index, + path, + &format!("path disappeared or became unreadable after read: {err}"), + ); + } + }; + if !final_meta.file_type().is_file() { + return slot_changed(index, path, "path no longer names a regular file"); + } + if !same_file_identity(&opened, &final_meta) { + return slot_changed(index, path, "entry identity changed during read"); + } + + #[cfg(windows)] + match path_still_names_opened_file(&file, path) { + Ok(true) => {} + Ok(false) => { + return slot_changed( + index, + path, + "entry identity changed during Windows handle corroboration", + ); + } + Err(err) => { + return slot_changed( + index, + path, + &format!("Windows handle corroboration failed: {err}"), + ); + } + } + + match serde_json::from_slice::(&bytes) { + Ok(wire) => RawSlot::Parsed { wire, bytes }, + Err(err) => RawSlot::Invalid(CorruptReason::MalformedJson { + slot: index, + path: path.to_path_buf(), + detail: err.to_string(), + }), + } +} + +fn slot_changed(index: u8, path: &Path, detail: &str) -> RawSlot { + RawSlot::Invalid(CorruptReason::SlotChangedDuringRead { + slot: index, + path: path.to_path_buf(), + detail: detail.to_string(), + }) +} + +fn same_file_identity(left: &Metadata, right: &Metadata) -> bool { + crate::file_identity::metadata_observation_matches(left, right) +} + +#[cfg(windows)] +fn path_still_names_opened_file(opened: &File, path: &Path) -> io::Result { + crate::file_identity::path_still_names_file(path, opened) +} + +/// Semantically validate ONE parsed slot into a [`SlotOutcome`]. +/// +/// Order is fixed and typed: owner encoding → checksum encoding → checksum over +/// the raw phase text → owner equality → supported protocol → current-protocol +/// phase vocabulary. A future protocol is trusted only after every stable field +/// check; its phase vocabulary may be unknown. +fn validate_parsed(index: u8, path: &Path, wire: &WireSlot, owner_identity: &str) -> SlotOutcome { + if !is_lowercase_sha256(&wire.project_identity) { + return SlotOutcome::Invalid(CorruptReason::InvalidOwnerEncoding { + slot: index, + path: path.to_path_buf(), + found: wire.project_identity.clone(), + }); + } + if !is_lowercase_sha256(&wire.checksum) { + return SlotOutcome::Invalid(CorruptReason::InvalidChecksumEncoding { + slot: index, + path: path.to_path_buf(), + found: wire.checksum.clone(), + }); + } + + let expected = checksum_hex( + wire.sequence, + wire.storage_protocol, + wire.extraction_version, + &wire.phase, + &wire.project_identity, + ); + if expected != wire.checksum { + return SlotOutcome::Invalid(CorruptReason::ChecksumMismatch { + slot: index, + path: path.to_path_buf(), + expected, + found: wire.checksum.clone(), + }); + } + + if wire.project_identity != owner_identity { + return SlotOutcome::Invalid(CorruptReason::OwnerMismatch { + slot: index, + path: path.to_path_buf(), + expected: owner_identity.to_string(), + found: wire.project_identity.clone(), + }); + } + + if wire.storage_protocol < CURRENT_STORAGE_PROTOCOL { + return SlotOutcome::Invalid(CorruptReason::UnsupportedStorageProtocol { + slot: index, + path: path.to_path_buf(), + found: wire.storage_protocol, + supported: CURRENT_STORAGE_PROTOCOL, + }); + } + + let phase = StatePhase::from_wire(&wire.phase); + let record = StateSlotRecord { + sequence: wire.sequence, + storage_protocol: wire.storage_protocol, + extraction_version: wire.extraction_version, + phase, + phase_raw: wire.phase.clone(), + project_identity: wire.project_identity.clone(), + checksum: wire.checksum.clone(), + }; + + if wire.storage_protocol > CURRENT_STORAGE_PROTOCOL { + return SlotOutcome::FutureProtocol(record); + } + + if phase.is_none() { + return SlotOutcome::Invalid(CorruptReason::UnknownPhase { + slot: index, + path: path.to_path_buf(), + phase: wire.phase.clone(), + }); + } + + SlotOutcome::Valid(record) +} + +/// Aggregate two observed slots into one classification. +/// +/// Dominance order, applied before any authority selection: +/// +/// 1. A present INVALID slot (unreadable, non-regular, malformed, unknown phase, +/// checksum mismatch, unsupported lower protocol, owner mismatch) is +/// `Corrupt`. It dominates even a future-protocol companion: a defective +/// present fixed slot always requires manual recovery, and both outcomes are +/// equally nonmutating, so the stricter one is reported. Slot 0 is inspected +/// before slot 1 so the reason is deterministic. +/// 2. Equal sequence across ANY two validated records is `Corrupt`, before +/// future dominance and without relying on raw JSON byte equality. +/// 3. A future-STORAGE-PROTOCOL slot dominates a valid current-protocol +/// companion regardless of sequence; otherwise current records decide. +/// 4. The selected authority is rejected at `u64::MAX` before Future/status +/// mapping; then protocol/extraction version/phase maps the status. +fn classify_raw( + raw: &[RawSlot; 2], + slots: &[PathBuf; 2], + owner_identity: &str, +) -> IndexStateClassification { + let outcomes: [SlotOutcome; 2] = [ + observe(0, &slots[0], &raw[0], owner_identity), + observe(1, &slots[1], &raw[1], owner_identity), + ]; + + for outcome in &outcomes { + if let SlotOutcome::Invalid(reason) = outcome { + return IndexStateClassification { + status: ExtractionStatus::Corrupt { + reason: reason.clone(), + }, + authoritative: None, + slots: outcomes, + }; + } + } + + let validated: Vec<(u8, &StateSlotRecord)> = outcomes + .iter() + .enumerate() + .filter_map(|(index, outcome)| match outcome { + SlotOutcome::Valid(record) | SlotOutcome::FutureProtocol(record) => { + Some((u8::try_from(index).unwrap_or(u8::MAX), record)) + } + _ => None, + }) + .collect(); + + if validated.is_empty() { + return IndexStateClassification { + status: ExtractionStatus::Missing, + authoritative: None, + slots: outcomes, + }; + } + + if let [(_, first), (_, second)] = validated.as_slice() + && first.sequence == second.sequence + { + let identical_payload = match (&raw[0], &raw[1]) { + (RawSlot::Parsed { bytes: left, .. }, RawSlot::Parsed { bytes: right, .. }) => { + left == right + } + _ => false, + }; + return IndexStateClassification { + status: ExtractionStatus::Corrupt { + reason: CorruptReason::EqualSequence { + sequence: first.sequence, + identical_payload, + }, + }, + authoritative: None, + slots: outcomes, + }; + } + + let future = pick_highest(&outcomes, slots, |outcome| match outcome { + SlotOutcome::FutureProtocol(record) => Some(record), + _ => None, + }); + let authoritative = future.unwrap_or_else(|| { + pick_highest(&outcomes, slots, |outcome| match outcome { + SlotOutcome::Valid(record) => Some(record), + _ => None, + }) + .expect("at least one validated slot exists here") + }); + + if authoritative.record.sequence == u64::MAX { + return IndexStateClassification { + status: ExtractionStatus::Corrupt { + reason: CorruptReason::SequenceExhausted { + sequence: authoritative.record.sequence, + }, + }, + authoritative: Some(authoritative), + slots: outcomes, + }; + } + + let built = authoritative.record.extraction_version; + let status = if authoritative.record.storage_protocol > CURRENT_STORAGE_PROTOCOL + || built > CURRENT_EXTRACTION_VERSION + { + // Future dominance applies to EVERY phase, `uninitialized` included: a + // future-extraction `uninitialized` slot is never treated as a + // recoverable interrupted uninit. + ExtractionStatus::Future { built } + } else if built < CURRENT_EXTRACTION_VERSION { + ExtractionStatus::Outdated { built } + } else { + match authoritative.record.phase { + Some(StatePhase::Current) => ExtractionStatus::Current, + Some(StatePhase::Building) => ExtractionStatus::Building { built }, + Some(StatePhase::Uninitialized) => ExtractionStatus::Uninitialized, + // Unreachable: a current-protocol valid slot always carries a phase. + None => ExtractionStatus::Corrupt { + reason: CorruptReason::UnknownPhase { + slot: authoritative.index, + path: authoritative.path.clone(), + phase: authoritative.record.phase_raw.clone(), + }, + }, + } + }; + + IndexStateClassification { + status, + authoritative: Some(authoritative), + slots: outcomes, + } +} + +fn observe(index: u8, path: &Path, raw: &RawSlot, owner_identity: &str) -> SlotOutcome { + match raw { + RawSlot::Absent => SlotOutcome::Absent, + RawSlot::Invalid(reason) => SlotOutcome::Invalid(reason.clone()), + RawSlot::Parsed { wire, .. } => validate_parsed(index, path, wire, owner_identity), + } +} + +/// Select the highest-sequence slot among the outcomes `select` accepts; ties +/// cannot occur here because equal sequences are rejected earlier, so slot 0 +/// wins a hypothetical tie deterministically. +fn pick_highest<'a>( + outcomes: &'a [SlotOutcome; 2], + slots: &[PathBuf; 2], + select: impl Fn(&'a SlotOutcome) -> Option<&'a StateSlotRecord>, +) -> Option { + let mut best: Option = None; + for (index, outcome) in outcomes.iter().enumerate() { + let Some(record) = select(outcome) else { + continue; + }; + let replace = best + .as_ref() + .is_none_or(|current| record.sequence > current.record.sequence); + if replace { + best = Some(AuthoritativeSlot { + index: u8::try_from(index).unwrap_or(u8::MAX), + path: slots[index].clone(), + record: record.clone(), + }); + } + } + best +} + +#[cfg(test)] +mod read_tests { + use super::*; + + #[test] + fn regular_slot_replaced_after_validation_is_typed_corruption() { + let root = std::env::temp_dir().join(format!( + "codegraph-slot-replacement-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + std::fs::create_dir(&root).expect("create replacement test root"); + let slot = root.join("index-state.0.json"); + let replacement = root.join("replacement.json"); + std::fs::write(&slot, b"original").expect("write original slot"); + std::fs::write(&replacement, b"replacement").expect("write replacement slot"); + + let mut replaced = false; + let observed = read_slot_with(0, &slot, |point| { + if point == ReadCheckpoint::InitialMetadataValidated && !replaced { + std::fs::remove_file(&slot).expect("remove validated slot"); + std::fs::rename(&replacement, &slot).expect("install replacement slot"); + replaced = true; + } + }); + + assert!(matches!( + observed, + RawSlot::Invalid(CorruptReason::SlotChangedDuringRead { slot: 0, .. }) + )); + std::fs::remove_dir_all(&root).expect("remove replacement test root"); + } +} diff --git a/crates/codegraph-store/src/index_state_publisher.rs b/crates/codegraph-store/src/index_state_publisher.rs new file mode 100644 index 0000000..9680386 --- /dev/null +++ b/crates/codegraph-store/src/index_state_publisher.rs @@ -0,0 +1,908 @@ +//! Lease-gated, crash-safe publication of the dual fixed index-state slots. +//! +//! Publication always validates an exact exclusive [`IndexLease`], consumes the +//! accepted read-only classifier under that lease, and preserves the selected +//! authoritative slot. The successor is written to a same-directory +//! create-new temporary file, flushed, synchronized, and renamed over only the +//! older or missing inactive slot. Temporary files are outside the fixed-slot +//! scanner by construction. + +use std::fmt; +use std::fs::{File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use codegraph_core::IndexPaths; +use serde::Serialize; +use thiserror::Error; + +use crate::file_identity::is_regular; +use crate::{ + CURRENT_EXTRACTION_VERSION, CURRENT_STORAGE_PROTOCOL, ExtractionStatus, IndexLease, + IndexLeaseValidationError, SlotOutcome, StatePhase, StateSlotRecord, checksum_hex, classify, +}; + +const TEMP_PREFIX: &str = ".codegraph-index-state-publisher-v2-"; +const TEMP_SUFFIX: &str = ".tmp"; +const TEMP_CREATE_ATTEMPTS: u8 = 64; +static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + +/// Whether the publication's parent-directory durability barrier was available. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ParentSyncStatus { + /// The parent directory was synchronized after the final rename. + Synced, + /// The platform/filesystem explicitly reported that directory sync is unsupported. + Unsupported, +} + +/// The fully validated state made visible by one publication. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PublishedState { + /// Fixed slot index that received the new record. + pub slot: u8, + /// Canonical record written to that slot. + pub record: StateSlotRecord, + /// Result of the attempted parent-directory durability barrier. + pub parent_sync: ParentSyncStatus, +} + +/// The filesystem operation that failed during publication. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StatePublishOperation { + /// Creating a fresh same-directory temporary file. + CreateTemp, + /// Writing all canonical JSON bytes to the temporary file. + WriteTemp, + /// Flushing userspace buffers for the temporary file. + FlushTemp, + /// Synchronizing the temporary file's data and metadata. + SyncTemp, + /// Removing the older inactive fixed slot before replacement. + PrepareInactiveSlot, + /// Renaming the valid temporary file into the inactive fixed slot. + RenameTemp, + /// Synchronizing the parent directory after the rename. + SyncParent, +} + +impl fmt::Display for StatePublishOperation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::CreateTemp => "create publisher temporary file", + Self::WriteTemp => "write publisher temporary file", + Self::FlushTemp => "flush publisher temporary file", + Self::SyncTemp => "synchronize publisher temporary file", + Self::PrepareInactiveSlot => "prepare inactive state slot", + Self::RenameTemp => "rename publisher temporary file", + Self::SyncParent => "synchronize state-slot parent directory", + }) + } +} + +/// Typed failures from state publication. +#[derive(Debug, Error)] +pub enum StatePublishError { + /// The supplied capability is shared or belongs to another namespace. + #[error(transparent)] + Lease(#[from] IndexLeaseValidationError), + /// The under-lease classifier refused mutation of this namespace. + #[error("index state publication refused for {status}")] + Refused { + /// Exact accepted-classifier result that caused the refusal. + status: ExtractionStatus, + }, + /// The requested phase is not a valid lifecycle successor of the accepted + /// under-lease classification. + #[error("cannot publish phase {requested} from index state {current}")] + InvalidTransition { + /// Exact accepted-classifier status before publication. + current: ExtractionStatus, + /// Requested successor phase. + requested: StatePhase, + }, + /// The accepted classifier returned an internally inconsistent authority. + #[error("index state classifier invariant failed: {detail}")] + ClassifierInvariant { + /// Stable invariant description. + detail: &'static str, + }, + /// Canonical typed JSON serialization failed. + #[error("cannot serialize canonical index state: {source}")] + Serialize { + /// Serialization failure. + source: serde_json::Error, + }, + /// A bounded create-new retry could not find an unused publisher temp name. + #[error( + "cannot allocate a create-new index state temp under {parent} after {attempts} attempts" + )] + TempNameExhausted { + /// State-slot parent directory. + parent: PathBuf, + /// Number of bounded attempts. + attempts: u8, + }, + /// A publication I/O operation failed. + #[error("cannot {operation} at {path}: {source}")] + Io { + /// Operation that failed. + operation: StatePublishOperation, + /// Exact affected path. + path: PathBuf, + /// Operating-system error. + source: io::Error, + }, +} + +/// Publish the current storage/extraction protocol at `phase`. +/// +/// The only accepted initial transition is `Missing -> Building`, which +/// deterministically publishes sequence `0` to fixed slot `0`. Every allowed +/// successor uses `checked_add(1)` and the opposite fixed slot. Invalid lifecycle +/// transitions, `Future`, `Corrupt` (including invalid fixed slots, owner +/// mismatch, equal sequence, and exhaustion), and invalid leases fail before any +/// mutation. +pub fn publish_index_state( + paths: &IndexPaths, + lease: &IndexLease, + phase: StatePhase, +) -> Result { + publish_index_state_with(paths, lease, phase, &mut NoFault) +} + +#[derive(Serialize)] +struct WireState<'a> { + sequence: u64, + #[serde(rename = "storageProtocol")] + storage_protocol: u64, + #[serde(rename = "extractionVersion")] + extraction_version: u64, + phase: &'static str, + #[serde(rename = "projectIdentity")] + project_identity: &'a str, + checksum: &'a str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PublishCheckpoint { + TempCreated, + TempWritten, + TempFlushed, + TempSynced, + InactiveSlotPrepared, + TempRenamed, + ParentSyncAttempted, +} + +trait FaultInjector { + fn after(&mut self, checkpoint: PublishCheckpoint) -> io::Result<()>; +} + +struct NoFault; + +impl FaultInjector for NoFault { + fn after(&mut self, _checkpoint: PublishCheckpoint) -> io::Result<()> { + Ok(()) + } +} + +fn publish_index_state_with( + paths: &IndexPaths, + lease: &IndexLease, + phase: StatePhase, + fault: &mut impl FaultInjector, +) -> Result { + // Capability validation and accepted classification must both happen before + // temp creation or any other mutation. + lease.validate_exclusive(paths)?; + let classification = classify(paths); + if matches!( + classification.status(), + ExtractionStatus::Future { .. } | ExtractionStatus::Corrupt { .. } + ) { + return Err(StatePublishError::Refused { + status: classification.status().clone(), + }); + } + validate_transition(classification.status(), phase)?; + + let (sequence, target_index) = match classification.status() { + ExtractionStatus::Missing => { + if classification.authoritative().is_some() { + return Err(StatePublishError::ClassifierInvariant { + detail: "Missing classification selected an authoritative slot", + }); + } + (0, 0_usize) + } + ExtractionStatus::Current + | ExtractionStatus::Building { .. } + | ExtractionStatus::Uninitialized + | ExtractionStatus::Outdated { .. } => { + let authority = + classification + .authoritative() + .ok_or(StatePublishError::ClassifierInvariant { + detail: "non-Missing publishable classification has no authority", + })?; + let sequence = authority.record.sequence.checked_add(1).ok_or({ + StatePublishError::Refused { + status: ExtractionStatus::Corrupt { + reason: crate::CorruptReason::SequenceExhausted { + sequence: authority.record.sequence, + }, + }, + } + })?; + let authority_index = usize::from(authority.index); + if authority_index >= 2 { + return Err(StatePublishError::ClassifierInvariant { + detail: "authoritative fixed-slot index is outside the slot pair", + }); + } + (sequence, 1 - authority_index) + } + ExtractionStatus::Future { .. } | ExtractionStatus::Corrupt { .. } => unreachable!(), + }; + + match classification.slot(target_index) { + SlotOutcome::Absent => {} + SlotOutcome::Valid(record) => { + if record.sequence >= sequence { + return Err(StatePublishError::ClassifierInvariant { + detail: "inactive slot is not older than the successor sequence", + }); + } + } + SlotOutcome::FutureProtocol(_) | SlotOutcome::Invalid(_) => { + return Err(StatePublishError::ClassifierInvariant { + detail: "publishable classification contains a future or invalid inactive slot", + }); + } + } + + let owner = paths.project_identity(); + let phase_wire = phase.as_wire(); + let checksum = checksum_hex( + sequence, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION, + phase_wire, + owner, + ); + let bytes = serde_json::to_vec(&WireState { + sequence, + storage_protocol: CURRENT_STORAGE_PROTOCOL, + extraction_version: CURRENT_EXTRACTION_VERSION, + phase: phase_wire, + project_identity: owner, + checksum: &checksum, + }) + .map_err(|source| StatePublishError::Serialize { source })?; + + let slots = paths.state_slots(); + let target = &slots[target_index]; + let parent = target + .parent() + .ok_or(StatePublishError::ClassifierInvariant { + detail: "fixed state slot has no parent directory", + })?; + if parent != paths.current_root() || slots[1 - target_index].parent() != Some(parent) { + return Err(StatePublishError::ClassifierInvariant { + detail: "fixed state slots do not share IndexPaths::current_root", + }); + } + + let (temp_path, mut temp) = create_temp(parent)?; + checkpoint( + fault, + PublishCheckpoint::TempCreated, + StatePublishOperation::CreateTemp, + &temp_path, + )?; + + temp.write_all(&bytes) + .map_err(|source| io_error(StatePublishOperation::WriteTemp, &temp_path, source))?; + checkpoint( + fault, + PublishCheckpoint::TempWritten, + StatePublishOperation::WriteTemp, + &temp_path, + )?; + temp.flush() + .map_err(|source| io_error(StatePublishOperation::FlushTemp, &temp_path, source))?; + checkpoint( + fault, + PublishCheckpoint::TempFlushed, + StatePublishOperation::FlushTemp, + &temp_path, + )?; + temp.sync_all() + .map_err(|source| io_error(StatePublishOperation::SyncTemp, &temp_path, source))?; + checkpoint( + fault, + PublishCheckpoint::TempSynced, + StatePublishOperation::SyncTemp, + &temp_path, + )?; + drop(temp); + + match std::fs::symlink_metadata(target) { + Ok(metadata) => { + if !is_regular(&metadata) { + return Err(StatePublishError::ClassifierInvariant { + detail: "inactive fixed slot changed after under-lease classification", + }); + } + std::fs::remove_file(target).map_err(|source| { + io_error(StatePublishOperation::PrepareInactiveSlot, target, source) + })?; + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(source) => { + return Err(io_error( + StatePublishOperation::PrepareInactiveSlot, + target, + source, + )); + } + } + checkpoint( + fault, + PublishCheckpoint::InactiveSlotPrepared, + StatePublishOperation::PrepareInactiveSlot, + target, + )?; + + std::fs::rename(&temp_path, target) + .map_err(|source| io_error(StatePublishOperation::RenameTemp, target, source))?; + checkpoint( + fault, + PublishCheckpoint::TempRenamed, + StatePublishOperation::RenameTemp, + target, + )?; + + let parent_sync = sync_parent(parent)?; + checkpoint( + fault, + PublishCheckpoint::ParentSyncAttempted, + StatePublishOperation::SyncParent, + parent, + )?; + + let record = StateSlotRecord { + sequence, + storage_protocol: CURRENT_STORAGE_PROTOCOL, + extraction_version: CURRENT_EXTRACTION_VERSION, + phase: Some(phase), + phase_raw: phase_wire.to_string(), + project_identity: owner.to_string(), + checksum, + }; + Ok(PublishedState { + slot: u8::try_from(target_index).expect("fixed slot index fits u8"), + record, + parent_sync, + }) +} + +fn checkpoint( + fault: &mut impl FaultInjector, + checkpoint: PublishCheckpoint, + operation: StatePublishOperation, + path: &Path, +) -> Result<(), StatePublishError> { + fault + .after(checkpoint) + .map_err(|source| io_error(operation, path, source)) +} + +fn io_error(operation: StatePublishOperation, path: &Path, source: io::Error) -> StatePublishError { + StatePublishError::Io { + operation, + path: path.to_path_buf(), + source, + } +} + +fn create_temp(parent: &Path) -> Result<(PathBuf, File), StatePublishError> { + for _ in 0..TEMP_CREATE_ATTEMPTS { + let serial = NEXT_TEMP.fetch_add(1, Ordering::Relaxed); + let name = format!( + "{TEMP_PREFIX}{}-{serial:016x}{TEMP_SUFFIX}", + std::process::id() + ); + let path = parent.join(name); + match OpenOptions::new() + .write(true) + .create_new(true) + .truncate(false) + .open(&path) + { + Ok(file) => return Ok((path, file)), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(source) => { + return Err(io_error(StatePublishOperation::CreateTemp, &path, source)); + } + } + } + Err(StatePublishError::TempNameExhausted { + parent: parent.to_path_buf(), + attempts: TEMP_CREATE_ATTEMPTS, + }) +} + +fn validate_transition( + current: &ExtractionStatus, + requested: StatePhase, +) -> Result<(), StatePublishError> { + let allowed = matches!( + (current, requested), + (ExtractionStatus::Missing, StatePhase::Building) + | (ExtractionStatus::Outdated { .. }, StatePhase::Building) + | ( + ExtractionStatus::Uninitialized, + StatePhase::Building | StatePhase::Uninitialized + ) + | ( + ExtractionStatus::Current, + StatePhase::Building | StatePhase::Uninitialized + ) + | ( + ExtractionStatus::Building { .. }, + StatePhase::Building | StatePhase::Current | StatePhase::Uninitialized + ) + ); + if allowed { + Ok(()) + } else { + Err(StatePublishError::InvalidTransition { + current: current.clone(), + requested, + }) + } +} + +#[cfg(unix)] +fn sync_parent(parent: &Path) -> Result { + let directory = File::open(parent) + .map_err(|source| io_error(StatePublishOperation::SyncParent, parent, source))?; + match directory.sync_all() { + Ok(()) => Ok(ParentSyncStatus::Synced), + Err(error) if directory_sync_unsupported(&error) => Ok(ParentSyncStatus::Unsupported), + Err(source) => Err(io_error(StatePublishOperation::SyncParent, parent, source)), + } +} + +#[cfg(windows)] +fn sync_parent(parent: &Path) -> Result { + use std::os::windows::fs::OpenOptionsExt as _; + + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + let directory = OpenOptions::new() + .read(true) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) + .open(parent) + .map_err(|source| io_error(StatePublishOperation::SyncParent, parent, source))?; + match directory.sync_all() { + Ok(()) => Ok(ParentSyncStatus::Synced), + Err(error) if directory_sync_unsupported(&error) => Ok(ParentSyncStatus::Unsupported), + Err(source) => Err(io_error(StatePublishOperation::SyncParent, parent, source)), + } +} + +#[cfg(not(any(unix, windows)))] +fn sync_parent(_parent: &Path) -> Result { + Ok(ParentSyncStatus::Unsupported) +} + +/// EINVAL and EOPNOTSUPP/ENOTSUP are the conventional reports for a filesystem +/// that cannot synchronize directory entries. +#[cfg(any(unix, test))] +const UNIX_DIRECTORY_SYNC_UNSUPPORTED: &[i32] = &[22, 95]; + +/// Win32 statuses that mean this handle or filesystem exposes no directory +/// flush primitive. +/// +/// ERROR_INVALID_FUNCTION, ERROR_INVALID_HANDLE, ERROR_NOT_SUPPORTED, and +/// ERROR_INVALID_PARAMETER are the explicit "no such primitive" reports. +/// ERROR_ACCESS_DENIED belongs to the same class here and NOT to the class of +/// genuine permission faults: `FlushFileBuffers` requires a handle carrying +/// write access, an unprivileged process cannot obtain write access to a +/// DIRECTORY handle, and this code path only ever reaches the flush after the +/// directory open already succeeded. The denial is therefore a property of the +/// platform's directory API, identical on every run, and never a signal that +/// this specific namespace is unwritable — the writes that need the barrier are +/// the file operations that already completed. Access errors from OPENING the +/// directory stay hard failures; only the flush is downgraded. +#[cfg(any(windows, test))] +const WINDOWS_DIRECTORY_SYNC_UNSUPPORTED: &[i32] = &[1, 5, 6, 50, 87]; + +#[cfg(any(unix, windows))] +fn directory_sync_unsupported(error: &io::Error) -> bool { + #[cfg(unix)] + let unsupported = UNIX_DIRECTORY_SYNC_UNSUPPORTED; + #[cfg(windows)] + let unsupported = WINDOWS_DIRECTORY_SYNC_UNSUPPORTED; + directory_sync_unsupported_in(error, unsupported) +} + +/// Platform-independent classifier: an explicit [`io::ErrorKind::Unsupported`], +/// or a raw OS status in this platform's "no directory flush primitive" set. +#[cfg(any(unix, windows))] +fn directory_sync_unsupported_in(error: &io::Error, unsupported: &[i32]) -> bool { + error.kind() == io::ErrorKind::Unsupported + || error + .raw_os_error() + .is_some_and(|code| unsupported.contains(&code)) +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use super::*; + + struct TempProject(PathBuf); + + impl TempProject { + fn new(label: &str) -> Self { + let serial = NEXT_TEMP.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "codegraph-state-publisher-unit-{label}-{}-{serial}", + std::process::id() + )); + std::fs::create_dir(&path).expect("create publisher unit project"); + Self( + path.canonicalize() + .expect("canonical publisher unit project"), + ) + } + + fn paths(&self) -> IndexPaths { + IndexPaths::resolve(&self.0, None).expect("resolve publisher unit paths") + } + } + + impl Drop for TempProject { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).expect("remove publisher unit project"); + } + } + + fn deadline() -> Instant { + Instant::now() + .checked_add(Duration::from_secs(5)) + .expect("publisher unit deadline") + } + + struct FailAfter(PublishCheckpoint); + + impl FaultInjector for FailAfter { + fn after(&mut self, checkpoint: PublishCheckpoint) -> io::Result<()> { + if checkpoint == self.0 { + Err(io::Error::other(format!( + "injected crash after {checkpoint:?}" + ))) + } else { + Ok(()) + } + } + } + + #[derive(Debug, Clone, Copy)] + enum PriorStatus { + Outdated, + Building, + Current, + Uninitialized, + } + + fn stage_prior(paths: &IndexPaths, lease: &IndexLease, prior: PriorStatus) -> Vec { + match prior { + PriorStatus::Outdated => { + let sequence = 1; + let extraction_version = CURRENT_EXTRACTION_VERSION - 1; + let phase = StatePhase::Current; + let owner = paths.project_identity(); + let checksum = checksum_hex( + sequence, + CURRENT_STORAGE_PROTOCOL, + extraction_version, + phase.as_wire(), + owner, + ); + let bytes = serde_json::to_vec(&WireState { + sequence, + storage_protocol: CURRENT_STORAGE_PROTOCOL, + extraction_version, + phase: phase.as_wire(), + project_identity: owner, + checksum: &checksum, + }) + .expect("serialize outdated prior state"); + std::fs::write(&paths.state_slots()[1], &bytes) + .expect("write outdated prior state"); + } + PriorStatus::Building => { + publish_index_state(paths, lease, StatePhase::Building) + .expect("publish first building fixture state"); + publish_index_state(paths, lease, StatePhase::Building) + .expect("publish authoritative building fixture state"); + } + PriorStatus::Current => { + publish_index_state(paths, lease, StatePhase::Building) + .expect("publish building fixture state"); + publish_index_state(paths, lease, StatePhase::Current) + .expect("publish current fixture state"); + } + PriorStatus::Uninitialized => { + publish_index_state(paths, lease, StatePhase::Building) + .expect("publish building fixture state"); + publish_index_state(paths, lease, StatePhase::Uninitialized) + .expect("publish uninitialized fixture state"); + } + } + std::fs::read(&paths.state_slots()[1]).expect("read prior authoritative bytes") + } + + fn expected_phase_status(phase: StatePhase) -> ExtractionStatus { + match phase { + StatePhase::Building => ExtractionStatus::Building { + built: CURRENT_EXTRACTION_VERSION, + }, + StatePhase::Current => ExtractionStatus::Current, + StatePhase::Uninitialized => ExtractionStatus::Uninitialized, + } + } + + fn expected_prior_status(prior: PriorStatus) -> ExtractionStatus { + match prior { + PriorStatus::Outdated => ExtractionStatus::Outdated { + built: CURRENT_EXTRACTION_VERSION - 1, + }, + PriorStatus::Building => expected_phase_status(StatePhase::Building), + PriorStatus::Current => expected_phase_status(StatePhase::Current), + PriorStatus::Uninitialized => expected_phase_status(StatePhase::Uninitialized), + } + } + + #[test] + fn transition_validator_covers_every_status_and_requested_phase_pair() { + let statuses = [ + ExtractionStatus::Missing, + ExtractionStatus::Outdated { built: 1 }, + ExtractionStatus::Uninitialized, + ExtractionStatus::Current, + ExtractionStatus::Building { + built: CURRENT_EXTRACTION_VERSION, + }, + ExtractionStatus::Future { built: 99 }, + ExtractionStatus::Corrupt { + reason: crate::CorruptReason::SequenceExhausted { sequence: u64::MAX }, + }, + ]; + let phases = [ + StatePhase::Building, + StatePhase::Current, + StatePhase::Uninitialized, + ]; + + for current in statuses { + for requested in phases { + let expected = matches!( + (¤t, requested), + (ExtractionStatus::Missing, StatePhase::Building) + | (ExtractionStatus::Outdated { .. }, StatePhase::Building) + | ( + ExtractionStatus::Uninitialized, + StatePhase::Building | StatePhase::Uninitialized + ) + | ( + ExtractionStatus::Current, + StatePhase::Building | StatePhase::Uninitialized + ) + | (ExtractionStatus::Building { .. }, _) + ); + let result = validate_transition(¤t, requested); + assert_eq!( + result.is_ok(), + expected, + "current={current:?}, requested={requested:?}, result={result:?}" + ); + if !expected { + assert!(matches!( + result, + Err(StatePublishError::InvalidTransition { + current: actual_current, + requested: actual_requested, + }) if actual_current == current && actual_requested == requested + )); + } + } + } + } + + #[test] + fn every_publication_checkpoint_preserves_old_authority_or_exposes_valid_successor() { + let allowed = [ + (PriorStatus::Outdated, StatePhase::Building), + (PriorStatus::Uninitialized, StatePhase::Building), + (PriorStatus::Uninitialized, StatePhase::Uninitialized), + (PriorStatus::Current, StatePhase::Building), + (PriorStatus::Current, StatePhase::Uninitialized), + (PriorStatus::Building, StatePhase::Building), + (PriorStatus::Building, StatePhase::Current), + (PriorStatus::Building, StatePhase::Uninitialized), + ]; + let checkpoints = [ + PublishCheckpoint::TempCreated, + PublishCheckpoint::TempWritten, + PublishCheckpoint::TempFlushed, + PublishCheckpoint::TempSynced, + PublishCheckpoint::InactiveSlotPrepared, + PublishCheckpoint::TempRenamed, + PublishCheckpoint::ParentSyncAttempted, + ]; + + for (prior, next_phase) in allowed { + for checkpoint in checkpoints { + let project = TempProject::new("fault-matrix"); + let paths = project.paths(); + let lease = IndexLease::create_exclusive(&paths, deadline(), || false) + .expect("create fault-matrix lease"); + let authority_bytes = stage_prior(&paths, &lease, prior); + + let error = publish_index_state_with( + &paths, + &lease, + next_phase, + &mut FailAfter(checkpoint), + ) + .expect_err("fault checkpoint must interrupt publication"); + assert!(matches!(error, StatePublishError::Io { .. })); + assert_eq!( + std::fs::read(&paths.state_slots()[1]).expect("old authority survives"), + authority_bytes, + "prior={prior:?}, next={next_phase:?}, fault={checkpoint:?}" + ); + + let observed = classify(&paths); + let old = expected_prior_status(prior); + let new = expected_phase_status(next_phase); + assert!( + observed.status() == &old || observed.status() == &new, + "prior={prior:?}, next={next_phase:?}, fault={checkpoint:?}, observed={observed:?}" + ); + if matches!( + checkpoint, + PublishCheckpoint::TempRenamed | PublishCheckpoint::ParentSyncAttempted + ) { + assert_eq!(observed.status(), &new); + } else { + assert_eq!(observed.status(), &old); + } + } + } + } + + #[test] + fn windows_directory_flush_denial_is_classified_unsupported_not_a_hard_failure() { + // ERROR_ACCESS_DENIED is the structural report for FlushFileBuffers on a + // directory handle, so it must not abort publication. + assert!(directory_sync_unsupported_in( + &io::Error::from_raw_os_error(5), + WINDOWS_DIRECTORY_SYNC_UNSUPPORTED + )); + for code in WINDOWS_DIRECTORY_SYNC_UNSUPPORTED { + assert!( + directory_sync_unsupported_in( + &io::Error::from_raw_os_error(*code), + WINDOWS_DIRECTORY_SYNC_UNSUPPORTED + ), + "win32 {code} must be classified unsupported" + ); + } + for code in UNIX_DIRECTORY_SYNC_UNSUPPORTED { + assert!( + directory_sync_unsupported_in( + &io::Error::from_raw_os_error(*code), + UNIX_DIRECTORY_SYNC_UNSUPPORTED + ), + "errno {code} must be classified unsupported" + ); + } + assert!(directory_sync_unsupported_in( + &io::Error::from(io::ErrorKind::Unsupported), + &[] + )); + } + + #[test] + fn directory_flush_codes_outside_the_platform_set_stay_hard_failures() { + // ERROR_DISK_FULL and ERROR_FILE_NOT_FOUND are genuine faults, and the + // per-platform sets must not leak into each other. + for code in [2, 112] { + assert!(!directory_sync_unsupported_in( + &io::Error::from_raw_os_error(code), + WINDOWS_DIRECTORY_SYNC_UNSUPPORTED + )); + } + assert!(!directory_sync_unsupported_in( + &io::Error::from_raw_os_error(5), + UNIX_DIRECTORY_SYNC_UNSUPPORTED + )); + assert!(!directory_sync_unsupported_in( + &io::Error::from_raw_os_error(1), + UNIX_DIRECTORY_SYNC_UNSUPPORTED + )); + } + + #[test] + fn publication_reports_a_parent_sync_status_instead_of_failing() { + let project = TempProject::new("parent-sync-status"); + let paths = project.paths(); + let lease = IndexLease::create_exclusive(&paths, deadline(), || false) + .expect("create parent-sync lease"); + + let published = publish_index_state(&paths, &lease, StatePhase::Building) + .expect("publication must not fail on the parent-directory barrier"); + + assert!(matches!( + published.parent_sync, + ParentSyncStatus::Synced | ParentSyncStatus::Unsupported + )); + } + + #[test] + fn every_initial_publication_checkpoint_leaves_missing_or_exposes_a_valid_first_slot() { + let checkpoints = [ + PublishCheckpoint::TempCreated, + PublishCheckpoint::TempWritten, + PublishCheckpoint::TempFlushed, + PublishCheckpoint::TempSynced, + PublishCheckpoint::InactiveSlotPrepared, + PublishCheckpoint::TempRenamed, + PublishCheckpoint::ParentSyncAttempted, + ]; + + for checkpoint in checkpoints { + let project = TempProject::new("initial-fault-matrix"); + let paths = project.paths(); + let lease = IndexLease::create_exclusive(&paths, deadline(), || false) + .expect("create initial-fault lease"); + + let error = publish_index_state_with( + &paths, + &lease, + StatePhase::Building, + &mut FailAfter(checkpoint), + ) + .expect_err("initial fault checkpoint must interrupt publication"); + assert!(matches!(error, StatePublishError::Io { .. })); + let observed = classify(&paths); + if matches!( + checkpoint, + PublishCheckpoint::TempRenamed | PublishCheckpoint::ParentSyncAttempted + ) { + assert_eq!( + observed.status(), + &expected_phase_status(StatePhase::Building) + ); + assert_eq!( + observed + .authoritative() + .expect("valid initial authority") + .record + .sequence, + 0 + ); + } else { + assert_eq!(observed.status(), &ExtractionStatus::Missing); + } + } + } +} diff --git a/crates/codegraph-store/src/lib.rs b/crates/codegraph-store/src/lib.rs index a667970..ccc34a6 100644 --- a/crates/codegraph-store/src/lib.rs +++ b/crates/codegraph-store/src/lib.rs @@ -1,9 +1,34 @@ pub mod connection; +mod file_identity; +pub mod index_lease; +pub mod index_state; +pub mod index_state_publisher; pub mod migrations; pub mod queries; +pub mod rebuild; pub mod schema; +#[cfg(feature = "test-hooks")] +pub mod test_support; +pub mod uninit; -pub use connection::Store; +pub use connection::{ + ExtractionStampIssue, Store, StoreError, StoreStatusOpen, StoreWriteAuthorization, + StoreWriteOpen, StoreWritePurpose, +}; +pub use index_lease::{IndexLease, IndexLeaseError, IndexLeaseValidationError}; +pub use index_state::{ + AuthoritativeSlot, CURRENT_EXTRACTION_VERSION, CURRENT_STORAGE_PROTOCOL, CorruptReason, + EXTRACTION_VERSION_KEY, ExtractionStatus, IndexStateClassification, SlotOutcome, StatePhase, + StateSlotRecord, canonical_checksum_payload, checksum_hex, classify, classify_slots, +}; +pub use index_state_publisher::{ + ParentSyncStatus, PublishedState, StatePublishError, publish_index_state, +}; pub use queries::{ CODEGRAPH_NO_WAL_DEFER, CODEGRAPH_WAL_VALVE_MB, DEFAULT_WAL_VALVE_MB, wal_valve_threshold_bytes, }; +pub use rebuild::{ + ActiveFullRebuild, FullRebuild, RebuildError, RebuildKind, begin_full_rebuild, + resume_full_rebuild, +}; +pub use uninit::{UninitError, UninitOutcome, uninit_index, uninit_index_with_drain}; diff --git a/crates/codegraph-store/src/queries.rs b/crates/codegraph-store/src/queries.rs index d6377b3..341bf26 100644 --- a/crates/codegraph-store/src/queries.rs +++ b/crates/codegraph-store/src/queries.rs @@ -450,6 +450,41 @@ impl Store { rows.collect() } + /// Callable definitions whose name CONTAINS `needle` as a substring, fetched + /// as a SEPARATE batch from the FTS/LIKE ladder (upstream `1de7e8f` #1319 + /// "kind whitelist" — hot single-word terms must not crowd definers out of + /// the length-ordered batch). + /// + /// `needle` is matched lowercase against both `lower(name)` and its + /// separator-stripped form, so a `user_profile_id` query reaches a + /// `userProfileId`-humped name and a `userProfileId` query reaches a + /// `user_profile_id`-separated one. Substring containment is only the + /// CANDIDATE gate; the caller still applies the segment-boundary filter, so + /// an incidental namesake never becomes a match. `ORDER BY` is fully + /// specified — shortest name first, then name/path/line — so the candidate + /// order never depends on SQLite's incidental row order. + pub fn callable_nodes_by_name_infix( + &self, + needle: &str, + limit: i64, + ) -> rusqlite::Result> { + let pattern = format!("%{}%", needle.to_lowercase()); + let mut stmt = self.conn.prepare( + r#" + SELECT * FROM nodes + WHERE kind IN ('function', 'method', 'component') + AND ( + lower(name) LIKE ? + OR replace(replace(replace(lower(name), '_', ''), '-', ''), '.', '') LIKE ? + ) + ORDER BY length(name) ASC, name ASC, file_path ASC, start_line ASC + LIMIT ? + "#, + )?; + let rows = stmt.query_map(params![pattern, pattern, limit], row_to_node)?; + rows.collect() + } + /// Ports `getAllNodeNames` from `upstream db/queries.ts:1655-1661`. /// `SELECT DISTINCT name FROM nodes` — the candidate name set for fuzzy fallback. pub fn all_node_names(&self) -> rusqlite::Result> { @@ -739,58 +774,29 @@ impl Store { .query_row("SELECT count(*) FROM unresolved_refs", [], |row| row.get(0)) } - /// Ports `deleteSpecificResolvedReferences` from `upstream db/queries.ts:1716-1727`. - /// Deletes one row per `(from_node_id, reference_name, reference_kind)` tuple in a single - /// transaction, matching the upstream precise per-tuple delete so only actually-resolved refs go. - pub fn delete_resolved_unresolved_refs( - &mut self, - keys: &[(String, String, EdgeKind)], - ) -> rusqlite::Result<()> { - if keys.is_empty() { - return Ok(()); - } - let tx = self.conn.transaction()?; - { - let mut stmt = tx.prepare_cached( - "DELETE FROM unresolved_refs WHERE from_node_id = ? AND reference_name = ? AND reference_kind = ?", - )?; - for (from_node_id, reference_name, reference_kind) in keys { - stmt.execute(params![ - from_node_id, - reference_name, - reference_kind.as_str() - ])?; - } - } - tx.commit() - } - - /// Per-batch variant of [`Self::delete_resolved_unresolved_refs`] bounded by - /// `max_id`: deletes only rows whose `id <= max_id`. The batched resolver - /// reads refs in ascending-`id` order, so a duplicate `(from,name,kind)` - /// tuple in a LATER batch has `id > max_id` and is preserved — letting the - /// caller drop each batch's keys immediately instead of accumulating every - /// resolved key for one final delete (bounds peak memory on large graphs). - pub fn delete_resolved_unresolved_refs_up_to( - &mut self, - keys: &[(String, String, EdgeKind)], - max_id: i64, - ) -> rusqlite::Result<()> { - if keys.is_empty() { + /// Ports `deleteSpecificResolvedReferences` from `upstream db/queries.ts:1716-1727` + /// (row-id form, #1269/#1270 / upstream `e871c49`). + /// + /// Deletes EXACTLY the rows named by `row_ids` in a single transaction. `id` is + /// the table's `INTEGER PRIMARY KEY AUTOINCREMENT`, so each id addresses one + /// row and nothing else. + /// + /// The previous key — `(from_node_id, reference_name, reference_kind)` — is NOT + /// unique: two calls to the same name from the same enclosing node differ only + /// in `line`/`col`/`id`, and the tuple-keyed `DELETE` carried no `LIMIT`, so + /// resolving ONE of them deleted BOTH and the sibling's edge was silently lost. + /// Keying on the row id removes that whole class of loss, and with it the need + /// for any `id <= max_id` batch guard: an id from batch N cannot name a row in + /// batch N+1, so a per-batch caller is already precise. + pub fn delete_resolved_unresolved_refs(&mut self, row_ids: &[i64]) -> rusqlite::Result<()> { + if row_ids.is_empty() { return Ok(()); } let tx = self.conn.transaction()?; { - let mut stmt = tx.prepare_cached( - "DELETE FROM unresolved_refs WHERE from_node_id = ? AND reference_name = ? AND reference_kind = ? AND id <= ?", - )?; - for (from_node_id, reference_name, reference_kind) in keys { - stmt.execute(params![ - from_node_id, - reference_name, - reference_kind.as_str(), - max_id - ])?; + let mut stmt = tx.prepare_cached("DELETE FROM unresolved_refs WHERE id = ?")?; + for row_id in row_ids { + stmt.execute(params![row_id])?; } } tx.commit() @@ -2153,6 +2159,54 @@ mod tests { assert_eq!(names, vec!["shared".to_string(), "unique".to_string()]); } + #[test] + fn callable_infix_returns_callables_only_shortest_first_and_folds_separators() { + let mut store = store("callable-infix"); + let mut cls = node("class:holder", "UserProfileIdHolder", "src/holder.rs"); + cls.kind = NodeKind::Class; + let mut var = node("variable:v", "userProfileId", "src/var.rs"); + var.kind = NodeKind::Variable; + store + .upsert_nodes(&[ + node("function:long", "updateUserProfileIdMapping", "src/b.rs"), + node("function:short", "userProfileId2", "src/a.rs"), + node("function:snake", "read_user_profile_id", "src/c.rs"), + node("function:other", "unrelated", "src/d.rs"), + cls, + var, + ]) + .unwrap(); + + let hits = store + .callable_nodes_by_name_infix("userprofileid", 50) + .unwrap(); + let names: Vec<&str> = hits.iter().map(|n| n.name.as_str()).collect(); + assert_eq!( + names, + vec![ + "userProfileId2", + "read_user_profile_id", + "updateUserProfileIdMapping" + ], + "callables only, shortest name first; the separator-stripped form matches snake_case" + ); + + assert!( + store + .callable_nodes_by_name_infix("userprofileid", 1) + .unwrap() + .len() + == 1, + "the limit is honoured" + ); + assert!( + store + .callable_nodes_by_name_infix("nosuchneedle", 50) + .unwrap() + .is_empty() + ); + } + #[test] fn distinct_non_file_node_names_omits_file_and_import() { let mut store = store("distinct-non-file"); @@ -2487,18 +2541,22 @@ mod tests { assert_eq!(store.unresolved_refs_count().unwrap(), 0); } + /// Two sibling call sites — SAME `from_node_id`, SAME `reference_name`, SAME + /// `reference_kind`, differing only in `line` — must be independently + /// deletable. The old `(from,name,kind)` key could not tell them apart and, + /// carrying no `LIMIT`, removed both when one resolved (#1269/#1270). #[test] - fn delete_resolved_refs_precise_and_bounded() { - let mut store = store("delete-resolved"); + fn delete_resolved_refs_by_row_id_removes_only_the_named_row() { + let mut store = store("delete-resolved-siblings"); store .upsert_nodes(&[node("function:src", "src", "a.rs")]) .unwrap(); - let mk = |name: &str| UnresolvedRef { + let mk = |name: &str, line: i64| UnresolvedRef { id: None, from_node_id: "function:src".to_string(), reference_name: name.to_string(), reference_kind: EdgeKind::Calls, - line: 1, + line, col: 0, candidates: None, file_path: "a.rs".to_string(), @@ -2507,38 +2565,40 @@ mod tests { reference_subkind: None, }; store - .insert_unresolved_refs(&[mk("Alpha"), mk("Beta"), mk("Gamma")]) + .insert_unresolved_refs(&[mk("helper", 2), mk("helper", 3), mk("Other", 4)]) .unwrap(); assert_eq!(store.unresolved_refs_count().unwrap(), 3); store.delete_resolved_unresolved_refs(&[]).unwrap(); - store - .delete_resolved_unresolved_refs_up_to(&[], 100) - .unwrap(); assert_eq!(store.unresolved_refs_count().unwrap(), 3); - store - .delete_resolved_unresolved_refs(&[( - "function:src".to_string(), - "Alpha".to_string(), - EdgeKind::Calls, - )]) - .unwrap(); - assert_eq!(store.unresolved_refs_count().unwrap(), 2); + let siblings: Vec = store + .all_unresolved_refs() + .unwrap() + .into_iter() + .filter(|r| r.reference_name == "helper") + .collect(); + assert_eq!(siblings.len(), 2); + let first = siblings[0].id.unwrap(); + let second = siblings[1].id.unwrap(); + assert_ne!(first, second); + + store.delete_resolved_unresolved_refs(&[first]).unwrap(); + + let remaining = store.all_unresolved_refs().unwrap(); + assert_eq!(remaining.len(), 2); + let survivor = remaining + .iter() + .find(|r| r.reference_name == "helper") + .expect("the sibling `helper` row must survive its twin's deletion"); + assert_eq!(survivor.id, Some(second)); + assert_eq!(survivor.line, siblings[1].line); - let rows = store.all_unresolved_refs().unwrap(); - let max_id = rows.iter().map(|r| r.id.unwrap()).min().unwrap(); - store - .delete_resolved_unresolved_refs_up_to( - &[( - "function:src".to_string(), - "Beta".to_string(), - EdgeKind::Calls, - )], - max_id, - ) - .unwrap(); - assert!(store.unresolved_refs_count().unwrap() <= 2); + store.delete_resolved_unresolved_refs(&[second]).unwrap(); + assert_eq!(store.unresolved_refs_count().unwrap(), 1); + + store.delete_resolved_unresolved_refs(&[second]).unwrap(); + assert_eq!(store.unresolved_refs_count().unwrap(), 1); } #[test] diff --git a/crates/codegraph-store/src/rebuild.rs b/crates/codegraph-store/src/rebuild.rs new file mode 100644 index 0000000..7fff8a4 --- /dev/null +++ b/crates/codegraph-store/src/rebuild.rs @@ -0,0 +1,1739 @@ +//! Explicit, fallible finalization of a destructive v2 full rebuild. +//! +//! Frozen plan `upstream-v1.5-portable-fixes.md` lines 548-556: under ONE +//! retained exclusive [`IndexLease`], a destructive rebuild +//! +//! 1. classifies the namespace and takes its write authorization, +//! 2. publishes `phase=building` BEFORE deleting or mutating the database, +//! 3. removes only the v2 `codegraph.db`, `-wal`, and `-shm`, +//! 4. rebuilds into a fresh state-gated writer database, and +//! 5. finalizes: restore default pragmas -> final checkpoint + compaction -> +//! stamp extraction version -> checkpoint that stamp into the main database -> +//! close the final SQLite connection. +//! +//! Only after every one of those steps succeeded may the rebuild atomically +//! publish `phase=current`; it then removes a tombstone only for an explicit, +//! successful `init`. Any earlier fault leaves the namespace `Building` (or +//! `Missing`, before the first publication), fail-closed and unreadable. The +//! handle's `Drop` is emergency best-effort only: it can never publish `Current`, +//! because no publication call exists on that path. + +use std::io::{self, Write}; +use std::path::PathBuf; +use std::time::Instant; + +use codegraph_core::IndexPaths; +use thiserror::Error; + +use crate::connection::{Store, StoreError, StoreWriteOpen, StoreWritePurpose}; +use crate::{ + IndexLease, IndexLeaseError, IndexLeaseValidationError, StatePhase, StatePublishError, + publish_index_state, +}; + +/// Which lifecycle command is performing the destructive rebuild. +/// +/// The distinction is narrow and load-bearing: only an explicit, fully +/// successful `init` may remove an existing tombstone. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RebuildKind { + /// `codegraph init` — an explicit initialization/recovery rebuild. + ExplicitInit, + /// `codegraph index` / forced migration — an ordinary destructive rebuild. + Reindex, +} + +/// Deterministic finalization boundaries. Private: production always uses the +/// no-op injector, while unit tests inject a fault after (or immediately before) +/// each named boundary without any sleep or polling. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RebuildCheckpoint { + BeforeWriteAuthorization, + BuildingPublished, + BeforeDatabaseRemoval, + DatabaseRemoved, + BeforePragmasRestored, + PragmasRestored, + BeforeCompaction, + Compacted, + BeforeVersionStamp, + VersionStamped, + BeforeStampCheckpoint, + StampCheckpointed, + BeforeConnectionClose, + ConnectionClosed, + BeforeCurrentPublication, + CurrentPublished, + BeforeTombstoneRemoval, + AfterTombstoneRemoval, +} + +impl RebuildCheckpoint { + fn label(self) -> &'static str { + match self { + Self::BeforeWriteAuthorization => "classify for full-rebuild authorization", + Self::BuildingPublished => "publish phase=building", + Self::BeforeDatabaseRemoval => "prepare v2 database artifact removal", + Self::DatabaseRemoved => "remove v2 database files", + Self::BeforePragmasRestored => "prepare default pragma restoration", + Self::PragmasRestored => "restore default pragmas", + Self::BeforeCompaction => "prepare database checkpoint and compaction", + Self::Compacted => "checkpoint and compact the database", + Self::BeforeVersionStamp => "prepare extraction-version stamp", + Self::VersionStamped => "stamp the extraction version", + Self::BeforeStampCheckpoint => "prepare extraction-stamp checkpoint", + Self::StampCheckpointed => "checkpoint the extraction stamp into the main database", + Self::BeforeConnectionClose => "prepare final SQLite connection close", + Self::ConnectionClosed => "close the final SQLite connection", + Self::BeforeCurrentPublication => "prepare phase=current publication", + Self::CurrentPublished => "publish phase=current", + Self::BeforeTombstoneRemoval => "prepare uninitialized tombstone removal", + Self::AfterTombstoneRemoval => "after removing the uninitialized tombstone", + } + } +} + +trait RebuildFault { + fn at(&mut self, checkpoint: RebuildCheckpoint) -> io::Result<()>; + + fn remove_tombstone(&mut self, path: &std::path::Path) -> io::Result<()> { + std::fs::remove_file(path) + } +} + +struct NoFault; + +impl RebuildFault for NoFault { + fn at(&mut self, _checkpoint: RebuildCheckpoint) -> io::Result<()> { + Ok(()) + } +} + +/// Typed failures of a destructive rebuild's lease, state, or finalization work. +#[derive(Debug, Error)] +pub enum RebuildError { + /// The single outer exclusive lease could not be acquired or created. + #[error(transparent)] + Lease(#[from] IndexLeaseError), + /// The retained capability stopped authorizing this namespace. + #[error(transparent)] + LeaseValidation(#[from] IndexLeaseValidationError), + /// The state-gated store layer refused the rebuild. + #[error(transparent)] + Store(#[from] StoreError), + /// A state-slot publication failed. + #[error(transparent)] + Publish(#[from] StatePublishError), + /// The current root could not be inspected before lease selection. + #[error("cannot inspect index root {path}: {source}")] + InspectRoot { + /// Resolved current root. + path: PathBuf, + /// Operating-system error. + source: io::Error, + }, + /// A v2 database artifact could not be removed under the lease. + #[error("cannot remove v2 database artifact {path}: {source}")] + RemoveDatabase { + /// Exact artifact path. + path: PathBuf, + /// Operating-system error. + source: io::Error, + }, + /// An interrupted-uninit namespace was reached by an ordinary reindex. + #[error( + "index namespace {path} was left uninitialized; only an explicit `init` may rebuild it" + )] + UninitializedRequiresExplicitInit { + /// Resolved current root. + path: PathBuf, + }, + /// The tombstone could not be removed after a successful explicit init. + #[error("cannot remove the uninitialized tombstone {path}: {source}")] + RemoveTombstone { + /// Tombstone path. + path: PathBuf, + /// Operating-system error. + source: io::Error, + }, + /// A SQLite finalization statement failed. + #[error("cannot {step} for {path}: {source}")] + Finalize { + /// Stable finalization-step description. + step: &'static str, + /// Database path. + path: PathBuf, + /// SQLite error. + source: rusqlite::Error, + }, + /// A deterministic test fault interrupted the rebuild at a named boundary. + #[error("interrupted rebuild at {step}: {source}")] + Interrupted { + /// Stable boundary description. + step: &'static str, + /// Injected error. + source: io::Error, + }, +} + +/// One destructive rebuild in progress: `phase=building` is already published and +/// the previous database is already removed. The exclusive lease acquired before +/// classification is retained here for the whole rebuild, including +/// finalization, connection close, and the final `phase=current` publication. +/// +/// Dropping this unopened handle deliberately performs NO publication: the +/// namespace stays `phase=building`, unreadable and fail-closed, and a rerun +/// rebuilds it from scratch. Once [`FullRebuild::open_store`] consumes it, the +/// resulting [`ActiveFullRebuild`] owns the unique writer and its emergency +/// best-effort, publication-free cleanup. +#[derive(Debug)] +pub struct FullRebuild { + paths: IndexPaths, + lease: IndexLease, + kind: RebuildKind, + // Retains the state-gated write authorization (and its own lease clone) for + // the rebuild's whole lifetime so the capability cannot be re-derived. + _authorization: crate::connection::StoreWriteAuthorization, +} + +/// The unique final SQLite writer opened by one [`FullRebuild`]. The writer is +/// owned by this typestate and cannot be supplied by a caller to [`Self::finish`], +/// so finalization is necessarily tied to the exact path, lease, authorization, +/// and connection opened by the rebuild capability. +#[derive(Debug)] +pub struct ActiveFullRebuild { + paths: IndexPaths, + lease: IndexLease, + kind: RebuildKind, + _authorization: crate::connection::StoreWriteAuthorization, + store: Option, +} + +/// Acquire the single outer exclusive lease, classify under it, publish +/// `phase=building`, and remove the previous v2 database files. +pub fn begin_full_rebuild( + paths: &IndexPaths, + kind: RebuildKind, + deadline: Instant, + cancelled: impl FnMut() -> bool, +) -> Result { + begin_full_rebuild_with(paths, kind, deadline, cancelled, &mut NoFault) +} + +fn begin_full_rebuild_with( + paths: &IndexPaths, + kind: RebuildKind, + deadline: Instant, + cancelled: impl FnMut() -> bool, + fault: &mut impl RebuildFault, +) -> Result { + let lease = acquire_outer_exclusive(paths, deadline, cancelled)?; + checkpoint(fault, RebuildCheckpoint::BeforeWriteAuthorization)?; + // Classification happens through the state gate under the already-held + // exclusive capability; a Future/Corrupt namespace is refused before any + // byte changes. + let authorized = if kind == RebuildKind::ExplicitInit { + Store::open_for_explicit_init_rebuild(paths, lease.clone())? + } else { + Store::open_for_write(paths, lease.clone(), StoreWritePurpose::FullRebuild)? + }; + let authorization = match authorized { + StoreWriteOpen::FullRebuildRequired(authorization) => authorization, + other => { + unreachable!("FullRebuild purpose always yields FullRebuildRequired, got {other:?}") + } + }; + // Authorization's status is the accepted classification performed while the + // exclusive lease was held. Never consult a precheck and then trust a second + // classification: that would create a classification/authorization TOCTOU. + if authorization.status() == &crate::ExtractionStatus::Uninitialized + && kind != RebuildKind::ExplicitInit + { + return Err(RebuildError::UninitializedRequiresExplicitInit { + path: paths.current_root().to_path_buf(), + }); + } + + begin_from_authorization(paths, lease, kind, authorization, fault) +} + +/// Escalate an ALREADY-AUTHORIZED namespace to a destructive full rebuild using +/// the exclusive lease the authorization still retains. +/// +/// This is the migration entry point for an incremental sync that classified +/// `Missing`, `Outdated`, or a recoverable `Building` under its one outer +/// exclusive lease (frozen plan lines 557-565). No lease is acquired here: doing +/// so would be a nested acquisition of a lock this process already holds. The +/// retained authorization's status is the accepted classification, so no +/// reclassification-after-precheck TOCTOU is possible. +pub fn resume_full_rebuild( + paths: &IndexPaths, + authorization: crate::connection::StoreWriteAuthorization, +) -> Result { + resume_full_rebuild_with(paths, authorization, &mut NoFault) +} + +fn resume_full_rebuild_with( + paths: &IndexPaths, + authorization: crate::connection::StoreWriteAuthorization, + fault: &mut impl RebuildFault, +) -> Result { + if authorization.purpose() != StoreWritePurpose::IncrementalSync { + return Err(RebuildError::Store(StoreError::WritePurposeRejected { + purpose: authorization.purpose(), + status: authorization.status().clone(), + })); + } + // Only the states the sync gate escalates may migrate. `Uninitialized` is + // never escalated: it is reserved for an explicit `init`. + match authorization.status() { + crate::ExtractionStatus::Missing + | crate::ExtractionStatus::Outdated { .. } + | crate::ExtractionStatus::Building { .. } => {} + other => { + return Err(RebuildError::Store(StoreError::WritePurposeRejected { + purpose: authorization.purpose(), + status: other.clone(), + })); + } + } + let lease = authorization.clone_lease(); + lease.validate_exclusive(paths)?; + begin_from_authorization(paths, lease, RebuildKind::Reindex, authorization, fault) +} + +/// Shared destructive prologue: publish `phase=building` BEFORE deleting any +/// database byte, then remove only the v2 database files — all under the one +/// already-held exclusive lease carried by `authorization`. +fn begin_from_authorization( + paths: &IndexPaths, + lease: IndexLease, + kind: RebuildKind, + authorization: crate::connection::StoreWriteAuthorization, + fault: &mut impl RebuildFault, +) -> Result { + // `phase=building` is durable BEFORE any destructive database work, so an + // interruption leaves an explicit, owner-bound marker instead of a bare DB. + publish_index_state(paths, &lease, StatePhase::Building)?; + checkpoint(fault, RebuildCheckpoint::BuildingPublished)?; + checkpoint(fault, RebuildCheckpoint::BeforeDatabaseRemoval)?; + remove_database_files(paths, &lease)?; + checkpoint(fault, RebuildCheckpoint::DatabaseRemoved)?; + + Ok(FullRebuild { + paths: paths.clone(), + lease, + kind, + _authorization: authorization, + }) +} + +impl FullRebuild { + /// Which lifecycle command owns this rebuild. + #[must_use] + pub fn kind(&self) -> RebuildKind { + self.kind + } + + /// The resolved paths this rebuild is bound to. + #[must_use] + pub fn paths(&self) -> &IndexPaths { + &self.paths + } + + /// Consume the unopened capability and open its one final SQLite writer. + /// Consuming `self` makes a second live writer structurally impossible. + pub fn open_store(self) -> Result { + self.open_store_with(|| {}) + } + + fn open_store_with( + self, + before_open: impl FnOnce(), + ) -> Result { + let store = Store::open_rebuild_target_with(&self.paths, &self.lease, before_open)?; + Ok(ActiveFullRebuild { + paths: self.paths, + lease: self.lease, + kind: self.kind, + _authorization: self._authorization, + store: Some(store), + }) + } +} + +impl ActiveFullRebuild { + /// The resolved paths this active rebuild is bound to. + #[must_use] + pub fn paths(&self) -> &IndexPaths { + &self.paths + } + + /// Access the exact writer owned by this rebuild. + #[must_use] + pub fn store(&self) -> &Store { + self.store + .as_ref() + .expect("an active rebuild owns its writer until finish") + } + + /// Mutably access the exact writer owned by this rebuild. + #[must_use] + pub fn store_mut(&mut self) -> &mut Store { + self.store + .as_mut() + .expect("an active rebuild owns its writer until finish") + } + + /// Apply the full-index pragma profile only after revalidating the retained + /// fixed-lock capability at this pragma mutation boundary. + pub fn set_bulk_index_pragmas(&self) -> Result<(), RebuildError> { + let store = self + .store + .as_ref() + .expect("an active rebuild owns its writer until finish"); + let path = store.path().to_path_buf(); + let pragma = |result: rusqlite::Result<()>| { + result.map_err(|source| RebuildError::Finalize { + step: "set bulk-index pragmas", + path: path.clone(), + source, + }) + }; + store.validate_state_write_authority()?; + pragma(store.connection().pragma_update(None, "synchronous", "OFF"))?; + store.validate_state_write_authority()?; + pragma( + store + .connection() + .pragma_update(None, "cache_size", -262_144), + )?; + store.validate_state_write_authority()?; + pragma( + store + .connection() + .pragma_update(None, "mmap_size", 1_073_741_824_i64), + )?; + if !matches!(std::env::var(crate::CODEGRAPH_NO_WAL_DEFER), Ok(value) if value == "1") { + store.validate_state_write_authority()?; + pragma( + store + .connection() + .pragma_update(None, "wal_autocheckpoint", 0), + )?; + } + Ok(()) + } + + /// Apply the bulk-index WAL valve only after revalidating the retained + /// capability at the checkpoint mutation boundary. + pub fn checkpoint_wal_if_over(&self, threshold_bytes: u64) -> Result { + let store = self + .store + .as_ref() + .expect("an active rebuild owns its writer until finish"); + if store.wal_size_bytes() <= threshold_bytes { + return Ok(false); + } + store.validate_state_write_authority()?; + store + .connection() + .pragma_update(None, "wal_checkpoint", "TRUNCATE") + .map_err(|source| RebuildError::Finalize { + step: "checkpoint bulk-index WAL valve", + path: store.path().to_path_buf(), + source, + })?; + Ok(true) + } + + /// Explicit fallible completion. Every step runs under the same retained + /// exclusive lease; any failure propagates and leaves the namespace + /// `Building` and unreadable. + pub fn finish(self) -> Result<(), RebuildError> { + self.finish_with(&mut NoFault) + } + + fn finish_with(mut self, fault: &mut impl RebuildFault) -> Result<(), RebuildError> { + // `finish` consumes the handle, so a caller cannot finalize twice and an + // abandoned rebuild can only reach the publication-free Drop path. + let store = self + .store + .as_ref() + .expect("an active rebuild owns its final writer until finish"); + let db_path = store.path().to_path_buf(); + + checkpoint(fault, RebuildCheckpoint::BeforePragmasRestored)?; + restore_default_pragmas(store, &db_path)?; + checkpoint(fault, RebuildCheckpoint::PragmasRestored)?; + + checkpoint(fault, RebuildCheckpoint::BeforeCompaction)?; + compact(store, &db_path)?; + checkpoint(fault, RebuildCheckpoint::Compacted)?; + + checkpoint(fault, RebuildCheckpoint::BeforeVersionStamp)?; + store.stamp_extraction_version()?; + checkpoint(fault, RebuildCheckpoint::VersionStamped)?; + + // The stamp must live in the main database file, not only in the WAL: + // a Current namespace is corroborated from main-file bytes. + checkpoint(fault, RebuildCheckpoint::BeforeStampCheckpoint)?; + store.checkpoint_wal_truncate()?; + checkpoint(fault, RebuildCheckpoint::StampCheckpointed)?; + + // Close the final SQLite connection (and drop its lease clone) BEFORE + // publishing Current, so no writer handle survives the transition. The + // outer lease below is still held by `self`. + checkpoint(fault, RebuildCheckpoint::BeforeConnectionClose)?; + let store = self + .store + .take() + .expect("the validated final writer is still owned before close"); + store.close()?; + checkpoint(fault, RebuildCheckpoint::ConnectionClosed)?; + + checkpoint(fault, RebuildCheckpoint::BeforeCurrentPublication)?; + publish_index_state(&self.paths, &self.lease, StatePhase::Current)?; + checkpoint(fault, RebuildCheckpoint::CurrentPublished)?; + + if self.kind == RebuildKind::ExplicitInit { + // Current+tombstone is deliberately fail-closed. Revalidate the same + // retained fixed-lock handle immediately before the removal + // operation, then inject any deterministic failure AT that operation. + checkpoint(fault, RebuildCheckpoint::BeforeTombstoneRemoval)?; + self.lease.validate_exclusive(&self.paths)?; + remove_tombstone(&self.paths, fault)?; + } + checkpoint(fault, RebuildCheckpoint::AfterTombstoneRemoval)?; + Ok(()) + } + + /// Run the publication-free emergency cleanup used by [`Drop`]. The callback + /// is a private deterministic test seam at the last pre-close boundary; + /// production passes a no-op. Returning the close result lets the regression + /// prove that this path uses [`Store::close`]'s state gate rather than a raw + /// field drop. Validation and SQLite close remain separate API calls, so this + /// is an explicit best-effort boundary, not a claim of portable atomicity. + fn emergency_cleanup_with( + &mut self, + before_close: impl FnOnce(), + ) -> Option> { + let store = self.store.take()?; + let path = store.path().to_path_buf(); + if let Err(error) = restore_default_pragmas(&store, &path) { + log_emergency_cleanup_error("restore default pragmas", &path, &error); + } + if let Err(error) = compact(&store, &path) { + log_emergency_cleanup_error("compact the database", &path, &error); + } + before_close(); + Some(store.close()) + } +} + +impl Drop for ActiveFullRebuild { + fn drop(&mut self) { + // Emergency cleanup is deliberately limited to operations safe while + // `phase=building`: best-effort pragma restoration/checkpoint and compact, + // each guarded by a fresh validation of the retained capability, followed + // by the explicit state-gated final connection close. There is no + // state-publication or tombstone-removal call in this path, so Drop + // structurally cannot publish Current. + if let Some(Err(error)) = self.emergency_cleanup_with(|| {}) { + let path = self.paths.current_db(); + log_emergency_cleanup_error("close the final SQLite connection", &path, &error); + } + } +} + +fn log_emergency_cleanup_error(step: &str, path: &std::path::Path, error: &dyn std::fmt::Display) { + // This crate intentionally has no logger dependency. STDERR is protocol-safe + // for CLI and stdio-MCP callers, unlike stdout. Ignore the write result so an + // emergency cleanup diagnostic can never turn Drop into a panic. + let _ = writeln!( + io::stderr().lock(), + "codegraph-store: emergency rebuild cleanup could not {step} for {}: {error}", + path.display() + ); +} + +impl std::ops::Deref for ActiveFullRebuild { + type Target = Store; + + fn deref(&self) -> &Self::Target { + self.store() + } +} + +impl std::ops::DerefMut for ActiveFullRebuild { + fn deref_mut(&mut self) -> &mut Self::Target { + self.store_mut() + } +} + +fn restore_default_pragmas(store: &Store, path: &std::path::Path) -> Result<(), RebuildError> { + store.validate_state_write_authority()?; + store + .connection() + .pragma_update(None, "wal_checkpoint", "TRUNCATE") + .map_err(|source| RebuildError::Finalize { + step: RebuildCheckpoint::PragmasRestored.label(), + path: path.to_path_buf(), + source, + })?; + store.validate_state_write_authority()?; + store + .connection() + .pragma_update(None, "synchronous", "NORMAL") + .map_err(|source| RebuildError::Finalize { + step: RebuildCheckpoint::PragmasRestored.label(), + path: path.to_path_buf(), + source, + })?; + Ok(()) +} + +fn compact(store: &Store, path: &std::path::Path) -> Result<(), RebuildError> { + store.validate_state_write_authority()?; + store + .connection() + .pragma_update(None, "wal_checkpoint", "TRUNCATE") + .map_err(|source| RebuildError::Finalize { + step: RebuildCheckpoint::Compacted.label(), + path: path.to_path_buf(), + source, + })?; + store.validate_state_write_authority()?; + let mut statement = store + .connection() + .prepare("PRAGMA incremental_vacuum") + .map_err(|source| RebuildError::Finalize { + step: RebuildCheckpoint::Compacted.label(), + path: path.to_path_buf(), + source, + })?; + let mut rows = statement + .query([]) + .map_err(|source| RebuildError::Finalize { + step: RebuildCheckpoint::Compacted.label(), + path: path.to_path_buf(), + source, + })?; + while rows + .next() + .map_err(|source| RebuildError::Finalize { + step: RebuildCheckpoint::Compacted.label(), + path: path.to_path_buf(), + source, + })? + .is_some() + {} + Ok(()) +} + +fn checkpoint( + fault: &mut impl RebuildFault, + checkpoint: RebuildCheckpoint, +) -> Result<(), RebuildError> { + fault + .at(checkpoint) + .map_err(|source| RebuildError::Interrupted { + step: checkpoint.label(), + source, + }) +} + +fn acquire_outer_exclusive( + paths: &IndexPaths, + deadline: Instant, + cancelled: impl FnMut() -> bool, +) -> Result { + // An existing namespace is never repaired: its permanent lock must already + // exist, otherwise acquisition fails closed. The existing-vs-initial + // decision itself lives in `IndexLease` so every lifecycle owner (rebuild, + // forced sync migration) takes the SAME one outer capability. + Ok(IndexLease::acquire_or_create_exclusive( + paths, deadline, cancelled, + )?) +} + +/// Remove only the v2 `codegraph.db`, `-wal`, and `-shm`. The legacy sibling is +/// never a target, and no other namespace child is touched. +fn remove_database_files(paths: &IndexPaths, lease: &IndexLease) -> Result<(), RebuildError> { + let db = paths.current_db(); + for suffix in ["", "-wal", "-shm"] { + let mut native = db.as_os_str().to_os_string(); + native.push(suffix); + let path = PathBuf::from(native); + lease.validate_exclusive(paths)?; + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(source) => return Err(RebuildError::RemoveDatabase { path, source }), + } + } + Ok(()) +} + +fn remove_tombstone(paths: &IndexPaths, fault: &mut impl RebuildFault) -> Result<(), RebuildError> { + let path = paths.tombstone(); + match fault.remove_tombstone(&path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(source) => Err(RebuildError::RemoveTombstone { path, source }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{CURRENT_EXTRACTION_VERSION, EXTRACTION_VERSION_KEY, ExtractionStatus}; + use std::time::Duration; + + struct TempProject(PathBuf); + + impl TempProject { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-rebuild-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&path).expect("create rebuild unit project"); + Self(path.canonicalize().expect("canonicalize rebuild project")) + } + + fn paths(&self) -> IndexPaths { + IndexPaths::resolve(&self.0, None).expect("resolve rebuild unit paths") + } + } + + impl Drop for TempProject { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn deadline() -> Instant { + Instant::now() + .checked_add(Duration::from_secs(10)) + .expect("rebuild unit deadline") + } + + /// Bounded deadline for refusal probes. It is NOT timing evidence: every + /// assertion using it also pins the classified state, and a lease-contention + /// refusal is rejected outright in [`assert_unreadable`]. + fn short_deadline() -> Instant { + Instant::now() + .checked_add(Duration::from_millis(50)) + .expect("rebuild unit short deadline") + } + + struct FailAt(RebuildCheckpoint); + + impl RebuildFault for FailAt { + fn at(&mut self, checkpoint: RebuildCheckpoint) -> io::Result<()> { + if checkpoint == self.0 { + Err(io::Error::other(format!( + "injected rebuild fault at {checkpoint:?}" + ))) + } else { + Ok(()) + } + } + } + + struct FailTombstoneRemoval; + + impl RebuildFault for FailTombstoneRemoval { + fn at(&mut self, _checkpoint: RebuildCheckpoint) -> io::Result<()> { + Ok(()) + } + + fn remove_tombstone(&mut self, _path: &std::path::Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "injected failure at tombstone removal operation", + )) + } + } + + struct PublishUninitializedBeforeAuthorization { + paths: IndexPaths, + } + + impl RebuildFault for PublishUninitializedBeforeAuthorization { + fn at(&mut self, checkpoint: RebuildCheckpoint) -> io::Result<()> { + if checkpoint != RebuildCheckpoint::BeforeWriteAuthorization { + return Ok(()); + } + let record = serde_json::json!({ + "sequence": 0, + "storageProtocol": crate::CURRENT_STORAGE_PROTOCOL, + "extractionVersion": CURRENT_EXTRACTION_VERSION, + "phase": "uninitialized", + "projectIdentity": self.paths.project_identity(), + "checksum": crate::checksum_hex( + 0, + crate::CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION, + "uninitialized", + self.paths.project_identity(), + ), + }); + std::fs::write( + &self.paths.state_slots()[0], + serde_json::to_vec(&record).expect("serialize test state"), + ) + } + } + + /// Replaces the fixed permanent lock immediately before the `phase=current` + /// publication, so the publication itself fails AFTER the database was fully + /// finalized and closed. + struct ReplaceLockBeforeCurrent { + lock: PathBuf, + displaced: PathBuf, + } + + struct ReplaceLockAt { + checkpoint: RebuildCheckpoint, + lock: PathBuf, + displaced: PathBuf, + } + + impl RebuildFault for ReplaceLockAt { + fn at(&mut self, checkpoint: RebuildCheckpoint) -> io::Result<()> { + if checkpoint == self.checkpoint { + std::fs::rename(&self.lock, &self.displaced)?; + std::fs::write(&self.lock, b"replacement at mutation boundary")?; + } + Ok(()) + } + } + + impl RebuildFault for ReplaceLockBeforeCurrent { + fn at(&mut self, checkpoint: RebuildCheckpoint) -> io::Result<()> { + if checkpoint == RebuildCheckpoint::BeforeCurrentPublication { + std::fs::rename(&self.lock, &self.displaced)?; + std::fs::write(&self.lock, b"late replacement")?; + } + Ok(()) + } + } + + fn build_graph(store: &Store) { + store + .set_project_metadata("indexed_with_version", "test") + .expect("write a metadata row into the rebuild target"); + } + + fn sidecars(paths: &IndexPaths) -> Vec { + let db = paths.current_db(); + ["-wal", "-shm"] + .into_iter() + .map(|suffix| { + let mut native = db.as_os_str().to_os_string(); + native.push(suffix); + PathBuf::from(native) + }) + .filter(|path| path.exists()) + .collect() + } + + /// Fail-closed with NO writer holding the lease: the read gate must refuse on + /// state/artifact grounds, not merely because acquisition timed out. + fn assert_unreadable(paths: &IndexPaths, context: &str) { + let error = Store::open_for_read(paths, short_deadline(), || false) + .err() + .unwrap_or_else(|| panic!("{context}: an unfinished rebuild must not be readable")); + assert!( + !matches!(error, StoreError::Lease(_)), + "{context}: refusal must be a state/artifact refusal, not lease contention: {error}" + ); + assert_ne!( + Store::extraction_status(paths), + ExtractionStatus::Current, + "{context}: an unfinished rebuild must never classify Current" + ); + } + + /// Fail-closed while the rebuild still holds its exclusive lease: any refusal + /// (state or bounded shared-acquisition timeout) is acceptable, but the state + /// must not be Current. + fn assert_unreadable_under_writer(paths: &IndexPaths, context: &str) { + assert!( + Store::open_for_read(paths, short_deadline(), || false).is_err(), + "{context}: a building namespace must never be readable" + ); + assert_ne!( + Store::extraction_status(paths), + ExtractionStatus::Current, + "{context}: a building namespace must never classify Current" + ); + } + + /// Publish the authoritative `uninitialized` slot and then the tombstone, in + /// the exact order `uninit --force` uses, so the staged residue is a genuine + /// interrupted-uninit namespace rather than a contradictory Current+tombstone. + fn stage_interrupted_uninit(paths: &IndexPaths) { + let lease = IndexLease::acquire_exclusive_existing(paths, deadline(), || false) + .expect("acquire exclusive to stage an interrupted uninit"); + publish_index_state(paths, &lease, StatePhase::Uninitialized) + .expect("publish the uninitialized slot"); + std::fs::write(paths.tombstone(), b"uninitialized").expect("publish the tombstone"); + drop(lease); + assert_eq!( + Store::extraction_status(paths), + ExtractionStatus::Uninitialized + ); + } + + fn run_successful_rebuild(paths: &IndexPaths, kind: RebuildKind) { + let rebuild = begin_full_rebuild(paths, kind, deadline(), || false) + .expect("begin a destructive rebuild"); + let rebuild = rebuild.open_store().expect("open the rebuild target"); + build_graph(&rebuild); + rebuild.finish().expect("finish the rebuild"); + } + + #[test] + fn successful_rebuild_publishes_readable_current_after_every_finalization_step() { + let project = TempProject::new("happy"); + let paths = project.paths(); + + let rebuild = begin_full_rebuild(&paths, RebuildKind::ExplicitInit, deadline(), || false) + .expect("begin the initial rebuild"); + // `phase=building` is durable before the graph exists, and the namespace + // is not readable while it builds. + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Building { + built: CURRENT_EXTRACTION_VERSION + } + ); + assert_unreadable_under_writer(&paths, "mid-rebuild"); + + let rebuild = rebuild.open_store().expect("open the rebuild target"); + build_graph(&rebuild); + rebuild.finish().expect("finish the rebuild"); + + assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Current); + assert!( + sidecars(&paths).is_empty(), + "the final checkpoint/compaction/close pipeline must satisfy the sidecar-free Current contract: {:?}", + sidecars(&paths) + ); + let store = Store::open_for_read(&paths, deadline(), || false) + .expect("a finalized namespace must be readable through the state gate"); + assert_eq!( + store + .get_project_metadata(EXTRACTION_VERSION_KEY) + .expect("read the extraction stamp"), + Some(CURRENT_EXTRACTION_VERSION.to_string()), + "the stamp must be checkpointed into the main database file" + ); + } + + #[test] + fn every_rebuild_fault_leaves_building_or_missing_and_unreadable() { + let pre_current = [ + RebuildCheckpoint::BeforeWriteAuthorization, + RebuildCheckpoint::BuildingPublished, + RebuildCheckpoint::BeforeDatabaseRemoval, + RebuildCheckpoint::DatabaseRemoved, + RebuildCheckpoint::BeforePragmasRestored, + RebuildCheckpoint::PragmasRestored, + RebuildCheckpoint::BeforeCompaction, + RebuildCheckpoint::Compacted, + RebuildCheckpoint::BeforeVersionStamp, + RebuildCheckpoint::VersionStamped, + RebuildCheckpoint::BeforeStampCheckpoint, + RebuildCheckpoint::StampCheckpointed, + RebuildCheckpoint::BeforeConnectionClose, + RebuildCheckpoint::ConnectionClosed, + RebuildCheckpoint::BeforeCurrentPublication, + ]; + + for checkpoint in pre_current { + let project = TempProject::new("fault"); + let paths = project.paths(); + let mut fault = FailAt(checkpoint); + + let begun = begin_full_rebuild_with( + &paths, + RebuildKind::ExplicitInit, + deadline(), + || false, + &mut fault, + ); + match begun { + Err(error) => { + assert!( + matches!(error, RebuildError::Interrupted { .. }), + "fault={checkpoint:?} must interrupt begin: {error}" + ); + } + Ok(rebuild) => { + let rebuild = rebuild.open_store().expect("open the rebuild target"); + build_graph(&rebuild); + let error = rebuild + .finish_with(&mut fault) + .expect_err("the injected fault must interrupt finalization"); + assert!( + matches!(error, RebuildError::Interrupted { .. }), + "fault={checkpoint:?} must interrupt finish: {error}" + ); + } + } + + let status = Store::extraction_status(&paths); + assert!( + matches!( + status, + ExtractionStatus::Building { .. } | ExtractionStatus::Missing + ), + "fault={checkpoint:?} must leave Building or Missing, got {status:?}" + ); + assert_unreadable(&paths, &format!("fault={checkpoint:?}")); + } + } + + #[test] + fn faults_at_or_after_current_publication_have_already_published_current() { + for checkpoint in [ + RebuildCheckpoint::CurrentPublished, + RebuildCheckpoint::AfterTombstoneRemoval, + ] { + let project = TempProject::new("post-current"); + let paths = project.paths(); + let mut fault = FailAt(checkpoint); + + let rebuild = begin_full_rebuild_with( + &paths, + RebuildKind::ExplicitInit, + deadline(), + || false, + &mut fault, + ) + .expect("begin the rebuild"); + let rebuild = rebuild.open_store().expect("open the rebuild target"); + build_graph(&rebuild); + let error = rebuild + .finish_with(&mut fault) + .expect_err("the injected fault must be reported"); + assert!(matches!(error, RebuildError::Interrupted { .. }), "{error}"); + + // Current is published LAST, so a fault at/after it observes Current: + // the ordering guarantee is that nothing before it can be readable. + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Current, + "fault={checkpoint:?}" + ); + } + } + + #[test] + fn explicit_init_recovery_publication_faults_leave_only_protocol_valid_states() { + for (checkpoint, expected_step, expected_status) in [ + ( + RebuildCheckpoint::BuildingPublished, + "publish phase=building", + ExtractionStatus::Building { + built: CURRENT_EXTRACTION_VERSION, + }, + ), + ( + RebuildCheckpoint::CurrentPublished, + "publish phase=current", + ExtractionStatus::Current, + ), + ] { + let project = TempProject::new("explicit-init-recovery-fault"); + let paths = project.paths(); + run_successful_rebuild(&paths, RebuildKind::ExplicitInit); + stage_interrupted_uninit(&paths); + let mut fault = FailAt(checkpoint); + + let begun = begin_full_rebuild_with( + &paths, + RebuildKind::ExplicitInit, + deadline(), + || false, + &mut fault, + ); + let error = match begun { + Err(error) => error, + Ok(rebuild) => { + let rebuild = rebuild + .open_store() + .expect("open explicit-init recovery writer"); + build_graph(&rebuild); + rebuild + .finish_with(&mut fault) + .expect_err("publication checkpoint must interrupt recovery") + } + }; + match error { + RebuildError::Interrupted { step, source } => { + assert_eq!(step, expected_step, "checkpoint={checkpoint:?}"); + assert_eq!(source.kind(), io::ErrorKind::Other); + } + other => panic!("checkpoint={checkpoint:?}: unexpected error {other:?}"), + } + + assert_eq!( + Store::extraction_status(&paths), + expected_status, + "checkpoint={checkpoint:?} must publish a complete protocol state" + ); + assert!( + paths.tombstone().is_file(), + "checkpoint={checkpoint:?}: recovery interruption retains tombstone" + ); + assert!( + paths.state_slots().iter().all(|slot| slot.is_file()), + "checkpoint={checkpoint:?}: both crash-safe slots remain present" + ); + + run_successful_rebuild(&paths, RebuildKind::ExplicitInit); + assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Current); + assert!( + !paths.tombstone().exists(), + "a later successful explicit init removes the retained tombstone" + ); + } + } + + #[test] + fn failed_current_publication_after_finalization_never_becomes_readable_current() { + let project = TempProject::new("publish-fails"); + let paths = project.paths(); + let displaced = paths.current_root().join("displaced-index.lock"); + let mut fault = ReplaceLockBeforeCurrent { + lock: paths.permanent_lock(), + displaced, + }; + + let rebuild = begin_full_rebuild_with( + &paths, + RebuildKind::ExplicitInit, + deadline(), + || false, + &mut fault, + ) + .expect("begin the rebuild"); + let rebuild = rebuild.open_store().expect("open the rebuild target"); + build_graph(&rebuild); + let error = rebuild + .finish_with(&mut fault) + .expect_err("a replaced permanent lock must fail the Current publication"); + assert!( + matches!( + error, + RebuildError::Publish(StatePublishError::Lease( + IndexLeaseValidationError::PermanentLockChanged { .. } + )) + ), + "unexpected error: {error}" + ); + + // The database WAS finalized and closed before the publication attempt, + // yet the namespace must not be reported as a readable Current. + assert!( + sidecars(&paths).is_empty(), + "the final connection must already be closed: {:?}", + sidecars(&paths) + ); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Building { + built: CURRENT_EXTRACTION_VERSION + }, + "a failed Current publication must leave phase=building" + ); + assert_unreadable(&paths, "failed Current publication"); + } + + #[test] + fn stale_lease_is_rejected_at_every_destructive_and_finalization_mutation_boundary() { + for checkpoint in [ + RebuildCheckpoint::BeforeDatabaseRemoval, + RebuildCheckpoint::BeforePragmasRestored, + RebuildCheckpoint::BeforeCompaction, + RebuildCheckpoint::BeforeVersionStamp, + RebuildCheckpoint::BeforeStampCheckpoint, + RebuildCheckpoint::BeforeConnectionClose, + RebuildCheckpoint::BeforeCurrentPublication, + RebuildCheckpoint::BeforeTombstoneRemoval, + ] { + let project = TempProject::new("stale-boundary"); + let paths = project.paths(); + let displaced = paths.current_root().join(format!( + "displaced-{}.lock", + checkpoint.label().replace(' ', "-") + )); + let mut fault = ReplaceLockAt { + checkpoint, + lock: paths.permanent_lock(), + displaced, + }; + + let begun = begin_full_rebuild_with( + &paths, + RebuildKind::ExplicitInit, + deadline(), + || false, + &mut fault, + ); + let error = match begun { + Err(error) => error, + Ok(rebuild) => { + let rebuild = rebuild.open_store().expect("open the final writer"); + build_graph(&rebuild); + if checkpoint == RebuildCheckpoint::BeforeTombstoneRemoval { + std::fs::write(paths.tombstone(), b"retained") + .expect("stage tombstone for the removal boundary"); + } + rebuild + .finish_with(&mut fault) + .expect_err("a replaced lock must reject the next mutation") + } + }; + assert!( + matches!( + error, + RebuildError::LeaseValidation( + IndexLeaseValidationError::PermanentLockChanged { .. } + ) | RebuildError::Store(StoreError::LeaseValidation( + IndexLeaseValidationError::PermanentLockChanged { .. } + )) | RebuildError::Publish(StatePublishError::Lease( + IndexLeaseValidationError::PermanentLockChanged { .. } + )) + ), + "checkpoint={checkpoint:?}: unexpected stale-lease result: {error}" + ); + if checkpoint == RebuildCheckpoint::BeforeTombstoneRemoval { + assert!( + paths.tombstone().exists(), + "stale authority must be rejected before tombstone removal" + ); + } else { + assert_ne!( + Store::extraction_status(&paths), + ExtractionStatus::Current, + "checkpoint={checkpoint:?}: stale authority must not publish Current" + ); + } + } + } + + #[test] + fn stale_lease_is_rejected_immediately_before_write_capable_sqlite_open() { + let project = TempProject::new("stale-write-open"); + let paths = project.paths(); + let rebuild = begin_full_rebuild(&paths, RebuildKind::ExplicitInit, deadline(), || false) + .expect("begin rebuild before stale-open injection"); + let displaced = paths.current_root().join("displaced-before-open.lock"); + let error = rebuild + .open_store_with(|| { + std::fs::rename(paths.permanent_lock(), &displaced) + .expect("displace fixed lock before SQLite open"); + std::fs::write(paths.permanent_lock(), b"replacement before SQLite open") + .expect("install replacement lock"); + }) + .expect_err("stale capability must reject the write-capable open"); + assert!( + matches!( + error, + RebuildError::Store(StoreError::LeaseValidation( + IndexLeaseValidationError::PermanentLockChanged { .. } + )) + ), + "unexpected stale-open error: {error}" + ); + assert!( + !paths.current_db().exists(), + "revalidation must happen immediately before SQLite creates the DB" + ); + } + + #[test] + fn dropping_an_unfinished_rebuild_never_publishes_current() { + let project = TempProject::new("drop"); + let paths = project.paths(); + + { + let rebuild = + begin_full_rebuild(&paths, RebuildKind::ExplicitInit, deadline(), || false) + .expect("begin the rebuild"); + let rebuild = rebuild.open_store().expect("open the rebuild target"); + rebuild + .set_bulk_index_pragmas() + .expect("enable bulk pragmas before emergency cleanup"); + build_graph(&rebuild); + drop(rebuild); + } + + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Building { + built: CURRENT_EXTRACTION_VERSION + }, + "Drop is emergency cleanup only and must never publish Current" + ); + assert!( + sidecars(&paths).is_empty(), + "Drop must best-effort checkpoint and close its writer: {:?}", + sidecars(&paths) + ); + assert_unreadable(&paths, "dropped rebuild"); + } + + #[test] + fn emergency_cleanup_close_is_explicitly_state_gated() { + let project = TempProject::new("drop-close-gate"); + let paths = project.paths(); + let mut rebuild = + begin_full_rebuild(&paths, RebuildKind::ExplicitInit, deadline(), || false) + .expect("begin the rebuild") + .open_store() + .expect("open the rebuild target"); + rebuild + .set_bulk_index_pragmas() + .expect("enable bulk pragmas before emergency cleanup"); + build_graph(&rebuild); + + let displaced = paths + .current_root() + .join("displaced-before-drop-close.lock"); + let result = rebuild + .emergency_cleanup_with(|| { + // Deterministically replace the fixed lock AFTER emergency pragma + // restoration/compaction and immediately BEFORE the shared + // Store::close path validates. No sleep or race inference is used. + std::fs::rename(paths.permanent_lock(), &displaced) + .expect("displace fixed lock before emergency close"); + std::fs::write( + paths.permanent_lock(), + b"replacement before emergency close", + ) + .expect("install replacement lock before emergency close"); + }) + .expect("the active rebuild owns one writer to close"); + let error = result.expect_err("the explicit close gate must reject stale authority"); + assert!( + matches!( + error, + StoreError::LeaseValidation(IndexLeaseValidationError::PermanentLockChanged { .. }) + ), + "unexpected emergency-close result: {error}" + ); + // The consumed Store necessarily falls back to Rust Drop after this + // validation error. This assertion proves the explicit close boundary + // was capability-gated; it does NOT claim validation+close atomicity or + // that resource release can be suppressed after ownership is consumed. + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Building { + built: CURRENT_EXTRACTION_VERSION, + }, + "emergency close rejection must not publish Current" + ); + assert!( + !paths.tombstone().exists(), + "emergency cleanup must have no tombstone-removal or creation path" + ); + } + + #[test] + fn reindex_refusal_uses_the_status_accepted_by_under_lease_authorization() { + let project = TempProject::new("authorization-status"); + let paths = project.paths(); + let lease = IndexLease::create_exclusive(&paths, deadline(), || false) + .expect("stage a valid empty namespace"); + drop(lease); + let mut fault = PublishUninitializedBeforeAuthorization { + paths: paths.clone(), + }; + + let error = begin_full_rebuild_with( + &paths, + RebuildKind::Reindex, + deadline(), + || false, + &mut fault, + ) + .expect_err("Reindex must refuse the status accepted by authorization"); + assert!( + matches!( + error, + RebuildError::UninitializedRequiresExplicitInit { .. } + ), + "unexpected error: {error}" + ); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Uninitialized + ); + assert!( + !paths.current_db().exists(), + "refusal from accepted authorization must precede DB mutation" + ); + } + + #[test] + fn only_successful_explicit_init_removes_the_tombstone() { + let project = TempProject::new("tombstone-reindex"); + let paths = project.paths(); + run_successful_rebuild(&paths, RebuildKind::ExplicitInit); + stage_interrupted_uninit(&paths); + + // An ordinary reindex is not authorized for an interrupted-uninit + // namespace at all, so it can never reach tombstone removal. + let error = begin_full_rebuild(&paths, RebuildKind::Reindex, deadline(), || false) + .expect_err("a reindex must refuse an interrupted-uninit namespace"); + assert!( + matches!( + error, + RebuildError::UninitializedRequiresExplicitInit { .. } + ), + "unexpected error: {error}" + ); + assert!( + paths.tombstone().exists(), + "a refused reindex must never remove the tombstone" + ); + + // Explicit init: the tombstone is removed, but only after full success. + run_successful_rebuild(&paths, RebuildKind::ExplicitInit); + assert!( + !paths.tombstone().exists(), + "a successful explicit init must remove the tombstone" + ); + assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Current); + } + + #[test] + fn a_successful_reindex_preserves_an_unrelated_tombstone_free_namespace() { + // Complementary to the interrupted-uninit case: an ordinary reindex over a + // clean Current namespace never touches the tombstone path either. + let project = TempProject::new("tombstone-clean"); + let paths = project.paths(); + run_successful_rebuild(&paths, RebuildKind::ExplicitInit); + run_successful_rebuild(&paths, RebuildKind::Reindex); + assert!( + !paths.tombstone().exists(), + "a reindex must not create a tombstone" + ); + assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Current); + } + + #[test] + fn an_earlier_fault_never_removes_the_tombstone() { + let project = TempProject::new("tombstone-fault"); + let paths = project.paths(); + run_successful_rebuild(&paths, RebuildKind::ExplicitInit); + stage_interrupted_uninit(&paths); + + let mut fault = FailAt(RebuildCheckpoint::ConnectionClosed); + let rebuild = begin_full_rebuild_with( + &paths, + RebuildKind::ExplicitInit, + deadline(), + || false, + &mut fault, + ) + .expect("begin the rebuild"); + let rebuild = rebuild.open_store().expect("open the rebuild target"); + build_graph(&rebuild); + rebuild + .finish_with(&mut fault) + .expect_err("the injected fault must interrupt finalization"); + + assert!( + paths.tombstone().exists(), + "a failed rebuild must never remove the tombstone" + ); + } + + #[test] + fn tombstone_removal_failure_is_failed_closed_and_explicit_init_retry_recovers() { + let project = TempProject::new("tombstone-remove-failure"); + let paths = project.paths(); + run_successful_rebuild(&paths, RebuildKind::ExplicitInit); + stage_interrupted_uninit(&paths); + + let rebuild = begin_full_rebuild(&paths, RebuildKind::ExplicitInit, deadline(), || false) + .expect("begin explicit recovery"); + let rebuild = rebuild.open_store().expect("open recovery writer"); + build_graph(&rebuild); + let error = rebuild + .finish_with(&mut FailTombstoneRemoval) + .expect_err("the removal operation itself must fail"); + assert!( + matches!( + error, + RebuildError::RemoveTombstone { ref source, .. } + if source.kind() == io::ErrorKind::PermissionDenied + ), + "unexpected error: {error}" + ); + assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Current); + assert!(paths.tombstone().exists()); + assert!( + matches!( + Store::open_for_read(&paths, deadline(), || false), + Err(StoreError::CurrentTombstoned { .. }) + ), + "Current+tombstone must stay failed closed, never silently healthy" + ); + + run_successful_rebuild(&paths, RebuildKind::ExplicitInit); + assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Current); + assert!(!paths.tombstone().exists()); + Store::open_for_read(&paths, deadline(), || false) + .expect("an explicit-init retry must recover Current+tombstone residue"); + } + + #[test] + fn rebuild_retains_one_exclusive_lease_and_never_reacquires() { + let project = TempProject::new("lease"); + let paths = project.paths(); + let rebuild = begin_full_rebuild(&paths, RebuildKind::ExplicitInit, deadline(), || false) + .expect("begin the rebuild"); + assert!(rebuild.lease.is_exclusive()); + // A competing writer cannot acquire while the rebuild holds the lease, + // proving the capability is retained rather than released between steps. + let competing = IndexLease::acquire_exclusive_existing( + &paths, + Instant::now() + Duration::from_millis(50), + || false, + ); + assert!( + matches!(competing, Err(IndexLeaseError::TimedOut { .. })), + "a retained exclusive lease must block a competing writer: {competing:?}" + ); + + let rebuild = rebuild.open_store().expect("open the rebuild target"); + build_graph(&rebuild); + rebuild.finish().expect("finish the rebuild"); + + // Only after the rebuild handle is gone may another writer acquire. + IndexLease::acquire_exclusive_existing(&paths, deadline(), || false) + .expect("the lease is released once the finished rebuild is dropped"); + } + + #[test] + fn existing_root_without_a_permanent_lock_fails_closed() { + let project = TempProject::new("lockless"); + let paths = project.paths(); + std::fs::create_dir_all(paths.current_root()).expect("stage a lockless root"); + + let error = begin_full_rebuild(&paths, RebuildKind::ExplicitInit, deadline(), || false) + .expect_err("a lockless existing namespace must never be repaired"); + assert!( + matches!( + error, + RebuildError::Lease(IndexLeaseError::LockNotFound { .. }) + ), + "unexpected error: {error}" + ); + assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Missing); + assert!( + !paths.current_db().exists(), + "a refused rebuild must not create a database" + ); + } + + /// Take the incremental-sync authorization the way a real sync does: ONE + /// outer exclusive lease, one classification under it. + fn incremental_sync_authorization( + paths: &IndexPaths, + ) -> Result { + let lease = IndexLease::acquire_or_create_exclusive(paths, deadline(), || false) + .expect("acquire the one outer exclusive lease"); + match Store::open_for_write(paths, lease, StoreWritePurpose::IncrementalSync)? { + StoreWriteOpen::FullRebuildRequired(authorization) => Ok(authorization), + other => panic!("expected an escalation authorization, got {other:?}"), + } + } + + /// A sync that classified `Outdated` escalates through the SAME retained + /// lease: `resume_full_rebuild` acquires nothing, publishes `phase=building` + /// before touching the database, and finalizes to a readable `Current`. + #[test] + fn resume_full_rebuild_migrates_outdated_under_the_retained_lease() { + let project = TempProject::new("resume-outdated"); + let paths = project.paths(); + run_successful_rebuild(&paths, RebuildKind::ExplicitInit); + + // Stage an OLDER built version through the accepted publisher path, then + // rewrite only the version field of the authoritative slot. + let outdated = CURRENT_EXTRACTION_VERSION - 1; + let record = serde_json::json!({ + "sequence": 99, + "storageProtocol": crate::CURRENT_STORAGE_PROTOCOL, + "extractionVersion": outdated, + "phase": "current", + "projectIdentity": paths.project_identity(), + "checksum": crate::checksum_hex( + 99, + crate::CURRENT_STORAGE_PROTOCOL, + outdated, + "current", + paths.project_identity(), + ), + }); + std::fs::write( + &paths.state_slots()[0], + serde_json::to_vec(&record).expect("serialize the outdated slot"), + ) + .expect("stage the outdated slot"); + let _ = std::fs::remove_file(&paths.state_slots()[1]); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Outdated { built: outdated } + ); + + let authorization = + incremental_sync_authorization(&paths).expect("Outdated must authorize escalation"); + assert!( + authorization.retains_exclusive_lease(), + "the escalation authorization must still own the one outer exclusive lease" + ); + let rebuild = resume_full_rebuild(&paths, authorization).expect("resume the full rebuild"); + // Publication precedes destruction, so an interruption here is Building. + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Building { + built: CURRENT_EXTRACTION_VERSION, + } + ); + assert!( + !paths.current_db().exists(), + "the outdated database must be removed after phase=building is durable" + ); + let rebuild = rebuild.open_store().expect("open the migration writer"); + build_graph(&rebuild); + rebuild.finish().expect("finalize the migration"); + + assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Current); + Store::open_for_read(&paths, deadline(), || false) + .expect("a finalized migration must be readable"); + } + + /// The incremental-sync gate is state-directed: only Missing/Outdated/ + /// Building escalate. A corroborated Current opens the incremental writer, + /// and an interrupted-uninit namespace is refused outright. + #[test] + fn incremental_sync_gate_escalates_only_the_migratable_states() { + let missing = TempProject::new("gate-missing"); + let missing_paths = missing.paths(); + incremental_sync_authorization(&missing_paths) + .expect("Missing must authorize escalation, not a row-level update"); + + let current = TempProject::new("gate-current"); + let current_paths = current.paths(); + run_successful_rebuild(¤t_paths, RebuildKind::ExplicitInit); + let lease = IndexLease::acquire_or_create_exclusive(¤t_paths, deadline(), || false) + .expect("acquire exclusive over the Current namespace"); + match Store::open_for_write(¤t_paths, lease, StoreWritePurpose::IncrementalSync) + .expect("Current must authorize an incremental writer") + { + StoreWriteOpen::Current(_) => {} + other => panic!("Current must stay incremental, got {other:?}"), + } + + let building = TempProject::new("gate-building"); + let building_paths = building.paths(); + let begun = begin_full_rebuild(&building_paths, RebuildKind::Reindex, deadline(), || false) + .expect("stage an interrupted Building namespace"); + drop(begun); + incremental_sync_authorization(&building_paths) + .expect("a recoverable Building must authorize escalation"); + + let uninit = TempProject::new("gate-uninit"); + let uninit_paths = uninit.paths(); + run_successful_rebuild(&uninit_paths, RebuildKind::ExplicitInit); + stage_interrupted_uninit(&uninit_paths); + let error = incremental_sync_authorization(&uninit_paths) + .expect_err("an uninitialized namespace is reserved for an explicit init"); + assert!( + matches!( + error, + StoreError::WritePurposeRejected { + purpose: StoreWritePurpose::IncrementalSync, + status: ExtractionStatus::Uninitialized, + } + ), + "unexpected refusal: {error}" + ); + } + + /// `resume_full_rebuild` refuses an authorization whose retained status it + /// did not accept for escalation, so no caller can smuggle a Current or + /// Uninitialized namespace into a destructive migration. + #[test] + fn resume_full_rebuild_refuses_a_non_migratable_authorization() { + let project = TempProject::new("resume-refuses"); + let paths = project.paths(); + run_successful_rebuild(&paths, RebuildKind::ExplicitInit); + + // A FullRebuild-purpose authorization over a Current namespace is valid + // for `begin_full_rebuild` but is NOT a sync escalation. + let lease = IndexLease::acquire_or_create_exclusive(&paths, deadline(), || false) + .expect("acquire exclusive"); + let authorization = + match Store::open_for_write(&paths, lease, StoreWritePurpose::FullRebuild) + .expect("Current authorizes a full rebuild") + { + StoreWriteOpen::FullRebuildRequired(authorization) => authorization, + other => panic!("unexpected open result {other:?}"), + }; + let before = std::fs::read(paths.current_db()).expect("read the Current database bytes"); + let error = resume_full_rebuild(&paths, authorization) + .expect_err("a Current authorization must not resume a migration"); + assert!( + matches!( + error, + RebuildError::Store(StoreError::WritePurposeRejected { + status: ExtractionStatus::Current, + .. + }) + ), + "unexpected refusal: {error}" + ); + assert_eq!( + std::fs::read(paths.current_db()).expect("re-read the database bytes"), + before, + "a refused resume must not change one database byte" + ); + assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Current); + } + + #[test] + fn opening_the_final_writer_consumes_the_unopened_capability() { + let project = TempProject::new("single-writer"); + let paths = project.paths(); + let rebuild = begin_full_rebuild(&paths, RebuildKind::ExplicitInit, deadline(), || false) + .expect("begin the rebuild"); + let rebuild = rebuild.open_store().expect("open the one final writer"); + build_graph(&rebuild); + // `FullRebuild::open_store(self)` consumes the unopened capability. This + // is a compile-time ownership guarantee: there is no remaining value on + // which a second open can be invoked. + drop(rebuild); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Building { + built: CURRENT_EXTRACTION_VERSION, + }, + "refusing a second writer must never publish Current" + ); + } +} diff --git a/crates/codegraph-store/src/test_support.rs b/crates/codegraph-store/src/test_support.rs new file mode 100644 index 0000000..b1852c6 --- /dev/null +++ b/crates/codegraph-store/src/test_support.rs @@ -0,0 +1,67 @@ +//! Test-only helpers for materializing state-gated index fixtures. + +use std::fs::OpenOptions; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, ensure}; +use codegraph_core::IndexPaths; + +use crate::{ + CURRENT_EXTRACTION_VERSION, EXTRACTION_VERSION_KEY, IndexLease, StatePhase, Store, + publish_index_state, +}; + +/// Complete a freshly created or copied SQLite fixture as a readable `Current` +/// namespace. +/// +/// Production code must never repair a database artifact that lacks its state +/// protocol. Tests, however, frequently construct the SQLite bytes directly. +/// This feature-gated helper gives those fixtures the same permanent lock, +/// extraction stamp, and monotonic `Building -> Current` state publication that +/// a successful rebuild would leave behind, without weakening any production +/// read gate. +pub fn finalize_current_test_fixture(paths: &IndexPaths) -> Result<()> { + let db = paths.current_db(); + ensure!( + db.is_file(), + "test fixture database does not exist at {}", + db.display() + ); + + let lock_path = paths.permanent_lock(); + if !lock_path.exists() { + let lock = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(&lock_path) + .with_context(|| format!("create test fixture lock {}", lock_path.display()))?; + lock.sync_all() + .with_context(|| format!("sync test fixture lock {}", lock_path.display()))?; + } + + let lease = IndexLease::acquire_exclusive_existing( + paths, + Instant::now() + Duration::from_secs(5), + || false, + ) + .context("acquire test fixture lease")?; + + let store = Store::open(&db).context("open test fixture database")?; + store + .set_project_metadata( + EXTRACTION_VERSION_KEY, + &CURRENT_EXTRACTION_VERSION.to_string(), + ) + .context("stamp test fixture extraction version")?; + store + .restore_default_pragmas() + .context("checkpoint test fixture database")?; + drop(store); + + publish_index_state(paths, &lease, StatePhase::Building) + .context("publish test fixture Building state")?; + publish_index_state(paths, &lease, StatePhase::Current) + .context("publish test fixture Current state")?; + Ok(()) +} diff --git a/crates/codegraph-store/src/uninit.rs b/crates/codegraph-store/src/uninit.rs new file mode 100644 index 0000000..53a0057 --- /dev/null +++ b/crates/codegraph-store/src/uninit.rs @@ -0,0 +1,852 @@ +//! Crash-recoverable `uninit --force` lifecycle. +//! +//! The state slot authenticates interrupted-uninit residue. Publish it under one +//! retained exclusive lease before creating the tombstone or deleting v2 bytes. + +use std::fs::OpenOptions; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use codegraph_core::IndexPaths; +use thiserror::Error; + +use crate::{ + ExtractionStatus, IndexLease, IndexLeaseError, IndexLeaseValidationError, StatePhase, + StatePublishError, Store, StoreError, StoreWriteOpen, StoreWritePurpose, publish_index_state, +}; + +const TOMBSTONE_BYTES: &[u8] = b"uninitialized\n"; + +/// Result of one complete new-uninit or cleanup-continuation pass. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UninitOutcome { + /// Whether any configured/fixed legacy namespace remains untouched. + pub legacy_index_present: bool, +} + +/// Typed failures from the destructive uninit lifecycle. +#[derive(Debug, Error)] +pub enum UninitError { + #[error("uninit is not authorized for index state {status}")] + StateRejected { status: ExtractionStatus }, + #[error(transparent)] + Lease(#[from] IndexLeaseError), + #[error(transparent)] + LeaseValidation(#[from] IndexLeaseValidationError), + #[error(transparent)] + Store(#[from] StoreError), + #[error(transparent)] + Publish(#[from] StatePublishError), + #[error("failed to publish uninitialized tombstone {path}: {source}")] + Tombstone { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("refusing to remove non-file v2 lifecycle child {path}")] + UnsupportedChild { path: PathBuf }, + #[error("failed to remove v2 lifecycle child {path}: {source}")] + RemoveChild { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("uninit interrupted after {step}: {source}")] + Interrupted { + step: &'static str, + #[source] + source: io::Error, + }, + /// A live daemon never acknowledged the owner-bound shutdown control frame. + /// Both durable markers already published, so the namespace stays recoverable + /// `Uninitialized` and no runtime child was removed. No pid is ever signalled. + #[error( + "uninit is incomplete: {detail}. The index is now uninitialized and its \ + tombstone is published; rerun `codegraph uninit --force` once the daemon \ + has exited, or run `codegraph init` to rebuild" + )] + DaemonNotDrained { detail: String }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UninitCheckpoint { + BeforeAuthorization, + StatePublished, + TombstoneEnsured, + DaemonDrained, + DatabaseRemoved, + WalRemoved, + ShmRemoved, + ConfigRemoved, + ExtensionConfigRemoved, + DaemonPidRemoved, + DaemonLogRemoved, + DaemonSocketRemoved, +} + +impl UninitCheckpoint { + fn label(self) -> &'static str { + match self { + Self::BeforeAuthorization => "write authorization", + Self::StatePublished => "uninitialized state publication", + Self::TombstoneEnsured => "tombstone creation", + Self::DaemonDrained => "daemon drain", + Self::DatabaseRemoved => "database removal", + Self::WalRemoved => "WAL removal", + Self::ShmRemoved => "SHM removal", + Self::ConfigRemoved => "config removal", + Self::ExtensionConfigRemoved => "extension config removal", + Self::DaemonPidRemoved => "daemon pid removal", + Self::DaemonLogRemoved => "daemon log removal", + Self::DaemonSocketRemoved => "daemon socket removal", + } + } +} + +trait UninitFault { + fn after(&mut self, checkpoint: UninitCheckpoint) -> io::Result<()>; +} + +struct NoFault; + +impl UninitFault for NoFault { + fn after(&mut self, _checkpoint: UninitCheckpoint) -> io::Result<()> { + Ok(()) + } +} + +/// Start or continue `uninit --force` under one existing exclusive lease. +/// +/// This never creates or repairs a namespace lock. The pre-probe is nonmutating; +/// Store reclassifies under the acquired lease before the first mutation. +pub fn uninit_index( + paths: &IndexPaths, + deadline: Instant, + cancelled: impl FnMut() -> bool, +) -> Result { + uninit_index_with_drain(paths, deadline, cancelled, || Ok(())) +} + +/// [`uninit_index`] with the owner-bound daemon-drain step wired in. +/// +/// `drain` runs INSIDE the retained exclusive lease, AFTER both durable markers +/// (the authoritative `uninitialized` state slot, then the tombstone) have +/// published and BEFORE any runtime child is removed — the exact order the frozen +/// plan requires. It must send the versioned, project-identity-bound shutdown +/// control frame and return only once the daemon has drained and removed its own +/// rendezvous; an `Err(detail)` is fail-closed +/// ([`UninitError::DaemonNotDrained`]), leaving the recoverable `Uninitialized` +/// namespace and every runtime child in place. The store layer never learns how +/// to reach a daemon, so it can never fall back to signalling a pid. +pub fn uninit_index_with_drain( + paths: &IndexPaths, + deadline: Instant, + cancelled: impl FnMut() -> bool, + drain: impl FnOnce() -> Result<(), String>, +) -> Result { + uninit_index_with(paths, deadline, cancelled, drain, &mut NoFault) +} + +fn uninit_index_with( + paths: &IndexPaths, + deadline: Instant, + cancelled: impl FnMut() -> bool, + drain: impl FnOnce() -> Result<(), String>, + fault: &mut impl UninitFault, +) -> Result { + let visible = Store::extraction_status(paths); + if !matches!( + visible, + ExtractionStatus::Current + | ExtractionStatus::Building { .. } + | ExtractionStatus::Uninitialized + ) { + return Err(UninitError::StateRejected { status: visible }); + } + + let lease = IndexLease::acquire_exclusive_existing(paths, deadline, cancelled)?; + checkpoint(fault, UninitCheckpoint::BeforeAuthorization)?; + let authorization = + match Store::open_for_write(paths, lease.clone(), StoreWritePurpose::UninitContinuation)? { + StoreWriteOpen::UninitContinuation(authorization) => authorization, + other => unreachable!("uninit purpose returned unexpected Store open: {other:?}"), + }; + if authorization.purpose() != StoreWritePurpose::UninitContinuation + || !matches!( + authorization.status(), + ExtractionStatus::Current + | ExtractionStatus::Building { .. } + | ExtractionStatus::Uninitialized + ) + { + return Err(UninitError::StateRejected { + status: authorization.status().clone(), + }); + } + + publish_index_state(paths, &lease, StatePhase::Uninitialized)?; + checkpoint(fault, UninitCheckpoint::StatePublished)?; + ensure_tombstone(paths, &lease)?; + checkpoint(fault, UninitCheckpoint::TombstoneEnsured)?; + + // Both durable markers are published, so a failure from here on classifies as + // recoverable `Uninitialized` and is continuable by a repeated + // `uninit --force`. + drain().map_err(|detail| UninitError::DaemonNotDrained { detail })?; + checkpoint(fault, UninitCheckpoint::DaemonDrained)?; + + let db = paths.current_db(); + remove_child(paths, &lease, &db)?; + checkpoint(fault, UninitCheckpoint::DatabaseRemoved)?; + let wal = sqlite_sidecar_path(&db, "-wal"); + remove_child(paths, &lease, &wal)?; + checkpoint(fault, UninitCheckpoint::WalRemoved)?; + let shm = sqlite_sidecar_path(&db, "-shm"); + remove_child(paths, &lease, &shm)?; + checkpoint(fault, UninitCheckpoint::ShmRemoved)?; + + for (path, boundary) in [ + (paths.config_toml(), UninitCheckpoint::ConfigRemoved), + ( + paths.extension_config(), + UninitCheckpoint::ExtensionConfigRemoved, + ), + (paths.daemon_pid(), UninitCheckpoint::DaemonPidRemoved), + (paths.daemon_log(), UninitCheckpoint::DaemonLogRemoved), + (paths.daemon_socket(), UninitCheckpoint::DaemonSocketRemoved), + ] { + remove_child(paths, &lease, &path)?; + checkpoint(fault, boundary)?; + } + + drop(authorization); + drop(lease); + Ok(UninitOutcome { + legacy_index_present: paths.legacy_roots().iter().any(|path| path.exists()), + }) +} + +/// Append SQLite's sidecar suffix to the native database pathname without a +/// Unicode rendering round-trip. Both Unix byte paths and Windows wide paths +/// remain lossless. +fn sqlite_sidecar_path(db: &Path, suffix: &str) -> PathBuf { + let mut native = db.as_os_str().to_os_string(); + native.push(suffix); + PathBuf::from(native) +} + +fn checkpoint( + fault: &mut impl UninitFault, + checkpoint: UninitCheckpoint, +) -> Result<(), UninitError> { + fault + .after(checkpoint) + .map_err(|source| UninitError::Interrupted { + step: checkpoint.label(), + source, + }) +} + +fn ensure_tombstone(paths: &IndexPaths, lease: &IndexLease) -> Result<(), UninitError> { + lease.validate_exclusive(paths)?; + let path = paths.tombstone(); + match std::fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_file() => return Ok(()), + Ok(_) => return Err(UninitError::UnsupportedChild { path }), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(source) => return Err(UninitError::Tombstone { path, source }), + } + + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .map_err(|source| UninitError::Tombstone { + path: path.clone(), + source, + })?; + file.write_all(TOMBSTONE_BYTES) + .and_then(|()| file.flush()) + .and_then(|()| file.sync_all()) + .map_err(|source| UninitError::Tombstone { path, source }) +} + +fn remove_child(paths: &IndexPaths, lease: &IndexLease, path: &Path) -> Result<(), UninitError> { + lease.validate_exclusive(paths)?; + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_dir() => Err(UninitError::UnsupportedChild { + path: path.to_path_buf(), + }), + Ok(_) => std::fs::remove_file(path).map_err(|source| UninitError::RemoveChild { + path: path.to_path_buf(), + source, + }), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(source) => Err(UninitError::RemoveChild { + path: path.to_path_buf(), + source, + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::{BTreeMap, BTreeSet}; + use std::time::Duration; + + use crate::{CURRENT_EXTRACTION_VERSION, CURRENT_STORAGE_PROTOCOL, checksum_hex, classify}; + + struct TempProject(PathBuf); + + impl TempProject { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-uninit-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock after epoch") + .as_nanos() + )); + std::fs::create_dir(&path).expect("create uninit test project"); + Self(path.canonicalize().expect("canonicalize test project")) + } + + fn paths(&self) -> IndexPaths { + IndexPaths::resolve(&self.0, None).expect("resolve uninit paths") + } + } + + impl Drop for TempProject { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn deadline() -> Instant { + Instant::now() + Duration::from_secs(10) + } + + fn db_artifact(paths: &IndexPaths, suffix: &str) -> PathBuf { + sqlite_sidecar_path(&paths.current_db(), suffix) + } + + fn stage_building_with_all_residue(paths: &IndexPaths) { + let lease = IndexLease::create_exclusive(paths, deadline(), || false) + .expect("create Building namespace"); + publish_index_state(paths, &lease, StatePhase::Building).expect("publish Building fixture"); + for (path, bytes) in [ + (paths.current_db(), b"db residue".as_slice()), + (db_artifact(paths, "-wal"), b"wal residue".as_slice()), + (db_artifact(paths, "-shm"), b"shm residue".as_slice()), + (paths.config_toml(), b"config residue".as_slice()), + (paths.extension_config(), b"extension residue".as_slice()), + (paths.daemon_pid(), b"pid residue".as_slice()), + (paths.daemon_log(), b"log residue".as_slice()), + (paths.daemon_socket(), b"socket residue".as_slice()), + ] { + std::fs::write(path, bytes).expect("write uninit residue fixture"); + } + drop(lease); + } + + #[derive(Debug, Clone, PartialEq, Eq)] + enum SnapshotEntry { + Directory, + File(Vec), + Symlink(PathBuf), + } + + fn snapshot(root: &Path) -> BTreeMap { + fn walk(root: &Path, dir: &Path, out: &mut BTreeMap) { + let mut children = std::fs::read_dir(dir) + .unwrap_or_else(|error| panic!("snapshot read_dir {}: {error}", dir.display())) + .map(|entry| { + entry + .unwrap_or_else(|error| { + panic!("snapshot entry in {}: {error}", dir.display()) + }) + .path() + }) + .collect::>(); + children.sort(); + for path in children { + let relative = path + .strip_prefix(root) + .unwrap_or_else(|error| panic!("snapshot strip {}: {error}", path.display())) + .to_path_buf(); + let metadata = std::fs::symlink_metadata(&path).unwrap_or_else(|error| { + panic!("snapshot metadata {}: {error}", path.display()) + }); + let ty = metadata.file_type(); + let entry = if ty.is_dir() { + SnapshotEntry::Directory + } else if ty.is_file() { + SnapshotEntry::File(std::fs::read(&path).unwrap_or_else(|error| { + panic!("snapshot read {}: {error}", path.display()) + })) + } else if ty.is_symlink() { + SnapshotEntry::Symlink(std::fs::read_link(&path).unwrap_or_else(|error| { + panic!("snapshot read_link {}: {error}", path.display()) + })) + } else { + panic!("snapshot unsupported entry kind: {}", path.display()); + }; + assert!(out.insert(relative, entry).is_none()); + if ty.is_dir() { + walk(root, &path, out); + } + } + } + + let mut out = BTreeMap::new(); + walk(root, root, &mut out); + out + } + + fn assert_unchanged( + before: &BTreeMap, + after: &BTreeMap, + ) { + let changed = before + .keys() + .chain(after.keys()) + .filter(|path| before.get(*path) != after.get(*path)) + .cloned() + .collect::>(); + assert!(changed.is_empty(), "filesystem changed at {changed:?}"); + } + + struct FailAfter(UninitCheckpoint); + + impl UninitFault for FailAfter { + fn after(&mut self, checkpoint: UninitCheckpoint) -> io::Result<()> { + if checkpoint == self.0 { + Err(io::Error::other("injected uninit interruption")) + } else { + Ok(()) + } + } + } + + #[test] + fn interrupted_uninit_state_slot_is_recoverable_not_corrupt_fault_matrix() { + for (boundary, expected_step, tombstone_present, removed_count) in [ + ( + UninitCheckpoint::StatePublished, + "uninitialized state publication", + false, + 0, + ), + ( + UninitCheckpoint::TombstoneEnsured, + "tombstone creation", + true, + 0, + ), + ( + UninitCheckpoint::DatabaseRemoved, + "database removal", + true, + 1, + ), + (UninitCheckpoint::WalRemoved, "WAL removal", true, 2), + (UninitCheckpoint::ShmRemoved, "SHM removal", true, 3), + (UninitCheckpoint::ConfigRemoved, "config removal", true, 4), + ( + UninitCheckpoint::ExtensionConfigRemoved, + "extension config removal", + true, + 5, + ), + ( + UninitCheckpoint::DaemonPidRemoved, + "daemon pid removal", + true, + 6, + ), + ( + UninitCheckpoint::DaemonLogRemoved, + "daemon log removal", + true, + 7, + ), + ( + UninitCheckpoint::DaemonSocketRemoved, + "daemon socket removal", + true, + 8, + ), + ] { + let project = TempProject::new("fault-matrix"); + let paths = project.paths(); + stage_building_with_all_residue(&paths); + let legacy = project.0.join(".codegraph"); + std::fs::create_dir(&legacy).expect("create legacy namespace"); + std::fs::write(legacy.join("legacy.bin"), b"legacy bytes") + .expect("write legacy proof bytes"); + let legacy_before = snapshot(&legacy); + + let error = uninit_index_with( + &paths, + deadline(), + || false, + || Ok(()), + &mut FailAfter(boundary), + ) + .expect_err("fault checkpoint must interrupt uninit"); + match error { + UninitError::Interrupted { step, source } => { + assert_eq!(step, expected_step, "boundary={boundary:?}"); + assert_eq!(source.kind(), io::ErrorKind::Other); + } + other => panic!("unexpected fault result at {boundary:?}: {other:?}"), + } + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Uninitialized, + "boundary={boundary:?} must leave authenticated interrupted-uninit state" + ); + assert!(paths.current_root().is_dir(), "boundary={boundary:?}"); + assert!(paths.permanent_lock().is_file()); + assert!(paths.state_slots().iter().all(|slot| slot.is_file())); + assert_eq!( + paths.tombstone().is_file(), + tombstone_present, + "unexpected tombstone frontier at {boundary:?}" + ); + let artifacts = [ + paths.current_db(), + db_artifact(&paths, "-wal"), + db_artifact(&paths, "-shm"), + paths.config_toml(), + paths.extension_config(), + paths.daemon_pid(), + paths.daemon_log(), + paths.daemon_socket(), + ]; + for (index, artifact) in artifacts.iter().enumerate() { + assert_eq!( + artifact.exists(), + index >= removed_count, + "unexpected deletion frontier at {boundary:?}: artifact #{index} {}", + artifact.display() + ); + } + assert_unchanged(&legacy_before, &snapshot(&legacy)); + } + } + + #[cfg(unix)] + #[test] + fn non_utf8_database_sidecars_are_removed_without_touching_lossy_lookalikes() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let outer = TempProject::new("non-utf8-sidecar"); + let project_path = outer.0.join(OsString::from_vec(b"project-\xff".to_vec())); + std::fs::create_dir(&project_path).expect("create non-UTF-8 project"); + let project = TempProject(project_path.canonicalize().expect("canonicalize project")); + let paths = project.paths(); + stage_building_with_all_residue(&paths); + + let native_wal = db_artifact(&paths, "-wal"); + let native_shm = db_artifact(&paths, "-shm"); + let lossy_wal = PathBuf::from(format!("{}-wal", paths.current_db().display())); + assert_ne!(native_wal, lossy_wal, "fixture must expose lossy rendering"); + std::fs::create_dir_all(lossy_wal.parent().expect("lossy WAL parent")) + .expect("create lossy-lookalike parent"); + std::fs::write(&lossy_wal, b"must survive").expect("write lossy-lookalike WAL"); + + uninit_index(&paths, deadline(), || false).expect("uninit non-UTF-8 namespace"); + + assert!(!native_wal.exists(), "true native WAL must be removed"); + assert!(!native_shm.exists(), "true native SHM must be removed"); + assert_eq!( + std::fs::read(&lossy_wal).expect("read preserved lossy lookalike"), + b"must survive" + ); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Uninitialized + ); + } + + #[test] + fn every_db_sidecar_tombstone_residue_combination_is_uninitialized() { + for bits in 0_u8..16 { + let project = TempProject::new("residue-matrix"); + let paths = project.paths(); + let lease = IndexLease::create_exclusive(&paths, deadline(), || false) + .expect("create residue namespace"); + publish_index_state(&paths, &lease, StatePhase::Building).expect("publish Building"); + publish_index_state(&paths, &lease, StatePhase::Uninitialized) + .expect("publish Uninitialized"); + drop(lease); + + for (bit, path) in [ + (0, paths.current_db()), + (1, db_artifact(&paths, "-wal")), + (2, db_artifact(&paths, "-shm")), + (3, paths.tombstone()), + ] { + if bits & (1 << bit) != 0 { + std::fs::write(path, format!("residue bit {bit}")) + .expect("write residue combination"); + } + } + + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Uninitialized, + "bits={bits:04b}" + ); + let status = Store::open_for_status(&paths, deadline(), || false) + .expect("Uninitialized residue is typed status data"); + assert_eq!(status.status, Some(ExtractionStatus::Uninitialized)); + let lease = IndexLease::acquire_exclusive_existing(&paths, deadline(), || false) + .expect("acquire continuation lease"); + assert!(matches!( + Store::open_for_write(&paths, lease, StoreWritePurpose::UninitContinuation,), + Ok(StoreWriteOpen::UninitContinuation(_)) + )); + } + } + + #[test] + fn repeated_uninit_uses_monotonic_publication_and_preserves_both_slots() { + let project = TempProject::new("continuation"); + let paths = project.paths(); + stage_building_with_all_residue(&paths); + + uninit_index(&paths, deadline(), || false).expect("first uninit pass"); + let first = classify(&paths); + let first_authority = first + .authoritative() + .expect("first uninit has authority") + .clone(); + let first_bytes = + std::fs::read(&first_authority.path).expect("read first authoritative slot"); + std::fs::write(paths.current_db(), b"continued residue") + .expect("stage continuation residue"); + + uninit_index(&paths, deadline(), || false).expect("continued uninit pass"); + let second = classify(&paths); + let second_authority = second.authoritative().expect("second uninit has authority"); + assert_eq!(second.status(), &ExtractionStatus::Uninitialized); + assert_eq!( + second_authority.record.sequence, + first_authority.record.sequence + 1 + ); + assert_eq!( + std::fs::read(&first_authority.path).expect("old authority remains readable"), + first_bytes + ); + assert!(paths.state_slots().iter().all(|slot| slot.is_file())); + assert!(!paths.current_db().exists()); + } + + fn write_future_uninitialized(paths: &IndexPaths) { + let sequence = 0; + let protocol = CURRENT_STORAGE_PROTOCOL + 1; + let extraction = CURRENT_EXTRACTION_VERSION + 1; + let phase = "uninitialized"; + let checksum = checksum_hex( + sequence, + protocol, + extraction, + phase, + paths.project_identity(), + ); + std::fs::write( + &paths.state_slots()[0], + serde_json::to_vec(&serde_json::json!({ + "sequence": sequence, + "storageProtocol": protocol, + "extractionVersion": extraction, + "phase": phase, + "projectIdentity": paths.project_identity(), + "checksum": checksum, + })) + .expect("serialize Future fixture"), + ) + .expect("write Future fixture"); + } + + #[test] + fn future_corrupt_owner_mismatch_and_missing_lock_are_byte_nonmutating() { + for fixture in ["future", "corrupt", "owner-mismatch"] { + let project = TempProject::new("refused"); + let paths = project.paths(); + let lease = IndexLease::create_exclusive(&paths, deadline(), || false) + .expect("create refusal namespace"); + match fixture { + "future" => write_future_uninitialized(&paths), + "corrupt" => std::fs::write(&paths.state_slots()[0], b"not-json") + .expect("write Corrupt fixture"), + "owner-mismatch" => { + let sequence = 0; + let owner = "0".repeat(64); + let checksum = checksum_hex( + sequence, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION, + "uninitialized", + &owner, + ); + std::fs::write( + &paths.state_slots()[0], + serde_json::to_vec(&serde_json::json!({ + "sequence": sequence, + "storageProtocol": CURRENT_STORAGE_PROTOCOL, + "extractionVersion": CURRENT_EXTRACTION_VERSION, + "phase": "uninitialized", + "projectIdentity": owner, + "checksum": checksum, + })) + .expect("serialize owner-mismatch fixture"), + ) + .expect("write owner-mismatch fixture"); + } + _ => unreachable!(), + } + drop(lease); + let before = snapshot(&project.0); + assert!(matches!( + uninit_index(&paths, deadline(), || false), + Err(UninitError::StateRejected { .. }) + )); + assert_unchanged(&before, &snapshot(&project.0)); + } + + let project = TempProject::new("missing-lock"); + let paths = project.paths(); + let lease = IndexLease::create_exclusive(&paths, deadline(), || false) + .expect("create missing-lock namespace"); + publish_index_state(&paths, &lease, StatePhase::Building).expect("publish Building"); + publish_index_state(&paths, &lease, StatePhase::Uninitialized) + .expect("publish Uninitialized"); + drop(lease); + std::fs::remove_file(paths.permanent_lock()).expect("remove permanent lock fixture"); + let before = snapshot(&project.0); + assert!(matches!( + uninit_index(&paths, deadline(), || false), + Err(UninitError::Lease(IndexLeaseError::LockNotFound { .. })) + )); + assert_unchanged(&before, &snapshot(&project.0)); + } + + #[test] + fn an_undrained_daemon_leaves_recoverable_uninitialized_residue() { + let project = TempProject::new("drain-refused"); + let paths = project.paths(); + stage_building_with_all_residue(&paths); + + let error = uninit_index_with_drain( + &paths, + deadline(), + || false, + || Err("daemon 4242 never acknowledged the shutdown control frame".to_string()), + ) + .expect_err("an undrained daemon must fail closed"); + match &error { + UninitError::DaemonNotDrained { detail } => { + assert!(detail.contains("4242"), "detail names the owner: {detail}"); + } + other => panic!("unexpected uninit result: {other:?}"), + } + let rendered = error.to_string(); + assert!( + rendered.contains("uninit is incomplete") && rendered.contains("uninit --force"), + "the error must state the incomplete uninit and its continuation: {rendered}" + ); + + // Both durable markers published BEFORE the drain, so the namespace is + // recoverable — never Corrupt — and no runtime child was removed. + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Uninitialized + ); + assert_eq!(classify(&paths).status(), &ExtractionStatus::Uninitialized); + assert!(paths.tombstone().is_file()); + assert!(paths.permanent_lock().is_file()); + assert!(paths.state_slots().iter().all(|slot| slot.is_file())); + for artifact in [ + paths.current_db(), + db_artifact(&paths, "-wal"), + db_artifact(&paths, "-shm"), + paths.config_toml(), + paths.extension_config(), + paths.daemon_pid(), + paths.daemon_log(), + paths.daemon_socket(), + ] { + assert!( + artifact.exists(), + "a fail-closed uninit removes no runtime child: {}", + artifact.display() + ); + } + + // The continuation resumes idempotently once the daemon is gone. + uninit_index(&paths, deadline(), || false).expect("uninit continuation"); + assert!(!paths.current_db().exists()); + assert!(!paths.daemon_pid().exists()); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Uninitialized + ); + } + + #[test] + fn the_drain_runs_after_both_markers_and_before_any_child_removal() { + let project = TempProject::new("drain-order"); + let paths = project.paths(); + stage_building_with_all_residue(&paths); + + let mut observed = None; + uninit_index_with_drain( + &paths, + deadline(), + || false, + || { + observed = Some(( + Store::extraction_status(&paths), + paths.tombstone().is_file(), + paths.current_db().exists(), + paths.daemon_pid().exists(), + )); + Ok(()) + }, + ) + .expect("uninit with a draining daemon"); + + let (status, tombstone, db, pid) = observed.expect("the drain step ran exactly once"); + assert_eq!( + status, + ExtractionStatus::Uninitialized, + "the authoritative slot publishes before the drain" + ); + assert!(tombstone, "the tombstone publishes before the drain"); + assert!(db, "no database is removed before the drain"); + assert!(pid, "no runtime child is removed before the drain"); + assert!(!paths.current_db().exists(), "cleanup follows the drain"); + assert!(!paths.daemon_pid().exists()); + } + + #[test] + fn nonmutation_snapshot_detects_equal_length_replacement() { + let project = TempProject::new("snapshot-self-test"); + let path = project.0.join("same-length.bin"); + std::fs::write(&path, b"AAAA").expect("write snapshot fixture"); + let before = snapshot(&project.0); + std::fs::write(&path, b"BBBB").expect("replace fixture at equal length"); + let after = snapshot(&project.0); + assert!( + std::panic::catch_unwind(|| assert_unchanged(&before, &after)).is_err(), + "snapshot oracle must reject equal-length byte replacement" + ); + } +} diff --git a/crates/codegraph-store/tests/bulk_index_pragmas.rs b/crates/codegraph-store/tests/bulk_index_pragmas.rs index 5a564d2..f9d4d92 100644 --- a/crates/codegraph-store/tests/bulk_index_pragmas.rs +++ b/crates/codegraph-store/tests/bulk_index_pragmas.rs @@ -1,7 +1,18 @@ use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use codegraph_store::Store; +/// Monotonic per-process serial that makes every [`TestDir`] name unique. +/// +/// A wall-clock timestamp alone is NOT sufficient. `SystemTime::now()` has +/// nanosecond resolution on Linux but is only updated at the system timer tick on +/// Windows (~15.6 ms), so two of this target's tests — run concurrently in +/// threads of ONE process, hence one pid — can observe the SAME `as_nanos()` +/// value and derive the same directory name, making the second `create_dir` fail +/// with `ERROR_ALREADY_EXISTS`. The serial cannot collide by construction. +static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + #[test] fn set_bulk_index_pragmas_drops_synchronous_to_off() { let dir = TestDir::new(); @@ -174,8 +185,9 @@ struct TestDir { impl TestDir { fn new() -> Self { let name = format!( - "codegraph-store-bulk-pragmas-{}-{}", + "codegraph-store-bulk-pragmas-{}-{}-{}", std::process::id(), + NEXT_TEMP.fetch_add(1, Ordering::Relaxed), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() diff --git a/crates/codegraph-store/tests/index_lease.rs b/crates/codegraph-store/tests/index_lease.rs new file mode 100644 index 0000000..f414207 --- /dev/null +++ b/crates/codegraph-store/tests/index_lease.rs @@ -0,0 +1,721 @@ +//! Public behavioral contract for the reusable v2 index lease capability. + +use std::cell::Cell; +use std::fs::OpenOptions; +use std::io::{BufRead, BufReader, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::{self, Receiver}; +use std::time::{Duration, Instant}; + +use codegraph_core::IndexPaths; +use codegraph_store::{ + IndexLease, IndexLeaseError, IndexLeaseValidationError, RebuildKind, Store, begin_full_rebuild, +}; + +const CHILD_ACTION: &str = "CODEGRAPH_INDEX_LEASE_CHILD_ACTION"; +const CHILD_MODE: &str = "CODEGRAPH_INDEX_LEASE_CHILD_MODE"; +const CHILD_PROJECT: &str = "CODEGRAPH_INDEX_LEASE_CHILD_PROJECT"; +const LOCK_BYTES: &[u8] = b"permanent-lock-sentinel\nnot-a-pid\n"; +const CHILD_WAIT: Duration = Duration::from_secs(5); +const SHORT_DEADLINE: Duration = Duration::from_millis(80); +static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + +struct TempProject(PathBuf); + +impl TempProject { + fn new(label: &str) -> Self { + let serial = NEXT_TEMP.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "codegraph-index-lease-{label}-{}-{serial}", + std::process::id() + )); + std::fs::create_dir(&path) + .unwrap_or_else(|err| panic!("create temp project {}: {err}", path.display())); + Self(path.canonicalize().expect("canonical temp project")) + } + + fn path(&self) -> &Path { + &self.0 + } + + fn paths(&self) -> IndexPaths { + IndexPaths::resolve(&self.0, None).expect("resolve test IndexPaths") + } +} + +impl Drop for TempProject { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0) + .unwrap_or_else(|err| panic!("remove temp project {}: {err}", self.0.display())); + } +} + +fn deadline_after(duration: Duration) -> Instant { + Instant::now() + .checked_add(duration) + .expect("test deadline is representable") +} + +fn stage_existing_lock(paths: &IndexPaths, bytes: &[u8]) { + std::fs::create_dir_all(paths.current_root()).expect("create current root fixture"); + let mut lock = OpenOptions::new() + .write(true) + .create_new(true) + .open(paths.permanent_lock()) + .expect("create permanent lock fixture"); + lock.write_all(bytes).expect("write permanent lock fixture"); + lock.sync_all().expect("sync permanent lock fixture"); +} + +fn lock_bytes(paths: &IndexPaths) -> Vec { + std::fs::read(paths.permanent_lock()).expect("read permanent lock bytes") +} + +/// Windows `ERROR_LOCK_VIOLATION`. +/// +/// A byte-range lock taken through `File::try_lock` is ADVISORY on Unix, so an +/// unrelated read of the locked file always succeeds there. On Windows the same +/// lock is MANDATORY: a read overlapping an EXCLUSIVELY locked range is refused +/// with this code, while a SHARED lock still permits reads and only denies +/// writes. +const ERROR_LOCK_VIOLATION: i32 = 33; + +/// Assert the permanent lock still carries `expected` while a lease may be live +/// over it. +/// +/// When the content is readable this is an exact byte comparison. When Windows +/// refuses the read because the range is exclusively locked, the content cannot +/// be observed at all, so this asserts the strongest thing that remains +/// observable: the LENGTH, which `metadata` reports without taking the byte +/// range. That is precisely the observation that refutes the failure mode these +/// fixtures guard — an acquisition path truncating or rewriting the lock, which +/// cannot preserve `expected.len()`. Each caller additionally re-asserts exact +/// bytes through [`lock_bytes`] once the holder is released and no lock remains, +/// so byte-exactness is still proven on every platform. +fn assert_lock_bytes_preserved(paths: &IndexPaths, expected: &[u8]) { + let path = paths.permanent_lock(); + match std::fs::read(&path) { + Ok(bytes) => assert_eq!(bytes, expected, "permanent lock bytes changed"), + Err(error) if cfg!(windows) && error.raw_os_error() == Some(ERROR_LOCK_VIOLATION) => { + let observed = std::fs::metadata(&path) + .unwrap_or_else(|error| { + panic!("inspect exclusively locked permanent lock: {error}") + }) + .len(); + let expected_len = u64::try_from(expected.len()).expect("lock length fits u64"); + assert_eq!( + observed, expected_len, + "permanent lock length changed while exclusively locked" + ); + } + Err(error) => panic!("read permanent lock bytes: {error}"), + } +} + +#[cfg(unix)] +#[test] +fn static_symlink_lock_is_rejected_without_using_the_external_target_as_authority() { + use std::os::unix::fs::symlink; + + let project = TempProject::new("static-symlink"); + let paths = project.paths(); + std::fs::create_dir_all(paths.current_root()).expect("create current root fixture"); + let external = project.path().join("external.lock"); + std::fs::write(&external, LOCK_BYTES).expect("write external lock target"); + symlink(&external, paths.permanent_lock()).expect("stage malicious lock symlink"); + + let result = + IndexLease::acquire_exclusive_existing(&paths, deadline_after(CHILD_WAIT), || false); + match result { + Err(IndexLeaseError::AliasedLock { path }) => { + assert_eq!(path, paths.permanent_lock()); + let external_handle = OpenOptions::new() + .read(true) + .write(true) + .open(&external) + .expect("open external target independently"); + external_handle + .try_lock() + .expect("rejected alias never became this namespace's lock authority"); + external_handle.unlock().expect("unlock external target"); + } + Ok(lease) => { + let external_handle = OpenOptions::new() + .read(true) + .write(true) + .open(&external) + .expect("open external target independently"); + assert!( + matches!( + external_handle.try_lock(), + Err(std::fs::TryLockError::WouldBlock) + ), + "the defective lease must demonstrably hold the external target" + ); + drop(lease); + panic!("static symlink was accepted as permanent lock authority"); + } + Err(other) => panic!("wrong typed error for lock alias: {other:?}"), + } + + assert_eq!(std::fs::read(&external).unwrap(), LOCK_BYTES); + assert!( + std::fs::symlink_metadata(paths.permanent_lock()) + .unwrap() + .file_type() + .is_symlink(), + "existing-only rejection must not mutate the alias entry" + ); +} + +#[cfg(unix)] +#[test] +fn socket_lock_entry_is_rejected_as_non_regular() { + use std::os::unix::net::UnixListener; + + let socket_project = TempProject::new("lock-socket"); + let socket_paths = socket_project.paths(); + std::fs::create_dir_all(socket_paths.current_root()).expect("create socket fixture root"); + let _listener = UnixListener::bind(socket_paths.permanent_lock()).expect("stage lock socket"); + let error = + IndexLease::acquire_shared_existing(&socket_paths, deadline_after(CHILD_WAIT), || false) + .expect_err("a socket cannot be permanent lock authority"); + assert!(matches!( + error, + IndexLeaseError::NonRegularLock { + path, + kind: "non-regular filesystem entry" + } if path == socket_paths.permanent_lock() + )); +} + +#[test] +fn existing_open_missing_is_nonmutating_and_never_creates() { + let missing_root = TempProject::new("missing-root"); + let paths = missing_root.paths(); + assert!(!paths.current_root().exists()); + + let err = IndexLease::acquire_shared_existing(&paths, deadline_after(SHORT_DEADLINE), || false) + .expect_err("ordinary existing open must reject a missing lock"); + assert!(matches!(err, IndexLeaseError::LockNotFound { .. })); + assert!( + !paths.current_root().exists(), + "ordinary probe must not create the current root" + ); + + let missing_lock = TempProject::new("missing-lock"); + let paths = missing_lock.paths(); + std::fs::create_dir(paths.current_root()).expect("stage empty existing root"); + std::fs::write(paths.current_root().join("keep.bin"), b"keep").expect("stage sibling bytes"); + let before = std::fs::read(paths.current_root().join("keep.bin")).unwrap(); + + let err = + IndexLease::acquire_exclusive_existing(&paths, deadline_after(SHORT_DEADLINE), || false) + .expect_err("ordinary existing open must reject a missing lock"); + assert!(matches!(err, IndexLeaseError::LockNotFound { .. })); + assert!(!paths.permanent_lock().exists()); + assert_eq!( + std::fs::read(paths.current_root().join("keep.bin")).unwrap(), + before + ); +} + +#[test] +fn existing_open_rejects_a_directory_lock_without_mutation() { + let project = TempProject::new("directory-lock"); + let paths = project.paths(); + std::fs::create_dir_all(paths.permanent_lock()).expect("stage directory at lock path"); + let marker = paths.permanent_lock().join("keep.bin"); + std::fs::write(&marker, LOCK_BYTES).expect("stage directory marker"); + + let err = IndexLease::acquire_exclusive_existing(&paths, deadline_after(CHILD_WAIT), || false) + .expect_err("directory cannot be permanent lock authority"); + assert!(matches!( + err, + IndexLeaseError::NonRegularLock { + path, + kind: "directory" + } if path == paths.permanent_lock() + )); + assert!(paths.permanent_lock().is_dir()); + assert_eq!(std::fs::read(marker).unwrap(), LOCK_BYTES); +} + +#[test] +fn explicit_initial_creation_is_separate_and_never_truncates_an_existing_lock() { + let project = TempProject::new("initial-create"); + let paths = project.paths(); + + let lease = IndexLease::create_exclusive(&paths, deadline_after(CHILD_WAIT), || false) + .expect("explicit initial creation"); + assert!(lease.is_exclusive()); + lease + .validate_exclusive(&paths) + .expect("new lease authorizes its exact namespace"); + drop(lease); + assert!(paths.current_root().is_dir()); + assert!(paths.permanent_lock().is_file()); + + std::fs::write(paths.permanent_lock(), LOCK_BYTES).expect("stage permanent bytes"); + let err = IndexLease::create_exclusive(&paths, deadline_after(CHILD_WAIT), || false) + .expect_err("initial creation must not repair an existing namespace"); + assert!(matches!( + err, + IndexLeaseError::NamespaceAlreadyExists { .. } + )); + assert_eq!(lock_bytes(&paths), LOCK_BYTES); + + let shared = IndexLease::acquire_shared_existing(&paths, deadline_after(CHILD_WAIT), || false) + .expect("open existing lock without truncation"); + drop(shared); + let exclusive = + IndexLease::acquire_exclusive_existing(&paths, deadline_after(CHILD_WAIT), || false) + .expect("open existing lock without truncation"); + drop(exclusive); + assert_eq!(lock_bytes(&paths), LOCK_BYTES); +} + +#[test] +fn shared_processes_coexist_but_shared_blocks_exclusive() { + let project = TempProject::new("shared-matrix"); + let paths = project.paths(); + stage_existing_lock(&paths, LOCK_BYTES); + + let holder = Holder::spawn(project.path(), "shared"); + assert_eq!(run_probe(project.path(), "shared", CHILD_WAIT), "ACQUIRED"); + assert_eq!( + run_probe(project.path(), "exclusive", SHORT_DEADLINE), + "TIMED_OUT" + ); + assert_lock_bytes_preserved(&paths, LOCK_BYTES); + holder.release(); + assert_eq!(lock_bytes(&paths), LOCK_BYTES); +} + +#[test] +fn exclusive_process_blocks_shared_and_exclusive() { + let project = TempProject::new("exclusive-matrix"); + let paths = project.paths(); + stage_existing_lock(&paths, LOCK_BYTES); + + let holder = Holder::spawn(project.path(), "exclusive"); + assert_eq!( + run_probe(project.path(), "shared", SHORT_DEADLINE), + "TIMED_OUT" + ); + assert_eq!( + run_probe(project.path(), "exclusive", SHORT_DEADLINE), + "TIMED_OUT" + ); + assert_lock_bytes_preserved(&paths, LOCK_BYTES); + holder.release(); + assert_eq!(lock_bytes(&paths), LOCK_BYTES); + + assert_eq!(run_probe(project.path(), "shared", CHILD_WAIT), "ACQUIRED"); + assert_eq!( + run_probe(project.path(), "exclusive", CHILD_WAIT), + "ACQUIRED" + ); + assert_eq!(lock_bytes(&paths), LOCK_BYTES); +} + +#[test] +fn timeout_and_post_contention_cancellation_preserve_lock_bytes() { + let project = TempProject::new("bounded"); + let paths = project.paths(); + stage_existing_lock(&paths, LOCK_BYTES); + let holder = Holder::spawn(project.path(), "exclusive"); + + let timeout = + IndexLease::acquire_shared_existing(&paths, deadline_after(SHORT_DEADLINE), || false) + .expect_err("exclusive holder must force bounded timeout"); + assert!(matches!(timeout, IndexLeaseError::TimedOut { .. })); + assert_lock_bytes_preserved(&paths, LOCK_BYTES); + + let checks = Cell::new(0_u8); + let cancelled = IndexLease::acquire_shared_existing(&paths, deadline_after(CHILD_WAIT), || { + let next = checks.get().saturating_add(1); + checks.set(next); + next >= 3 + }) + .expect_err("cancellation must be checked again after observing contention"); + assert!(matches!(cancelled, IndexLeaseError::Cancelled { .. })); + assert!( + checks.get() >= 3, + "the cancellation check must run after a busy try_lock" + ); + assert_lock_bytes_preserved(&paths, LOCK_BYTES); + holder.release(); + assert_eq!(lock_bytes(&paths), LOCK_BYTES); +} + +#[test] +fn an_already_expired_deadline_is_nonmutating_even_when_the_lock_is_free() { + let project = TempProject::new("expired"); + let paths = project.paths(); + stage_existing_lock(&paths, LOCK_BYTES); + + let expired = Instant::now() + .checked_sub(Duration::from_millis(1)) + .expect("expired deadline is representable"); + let err = IndexLease::acquire_exclusive_existing(&paths, expired, || false) + .expect_err("an expired deadline cannot acquire a free lock"); + assert!(matches!(err, IndexLeaseError::TimedOut { .. })); + assert_eq!(lock_bytes(&paths), LOCK_BYTES); +} + +#[test] +fn a_clone_keeps_the_single_lock_alive_until_the_final_drop() { + let project = TempProject::new("clone"); + let paths = project.paths(); + stage_existing_lock(&paths, LOCK_BYTES); + + let lease = + IndexLease::acquire_exclusive_existing(&paths, deadline_after(CHILD_WAIT), || false) + .expect("acquire exclusive lease"); + let clone = lease.clone(); + drop(lease); + + assert_eq!( + run_probe(project.path(), "shared", SHORT_DEADLINE), + "TIMED_OUT", + "dropping a non-final clone must not unlock" + ); + drop(clone); + assert_eq!( + run_probe(project.path(), "shared", CHILD_WAIT), + "ACQUIRED", + "the final owner must release the kernel lock" + ); +} + +#[test] +fn lease_mode_parent_and_clone_drop_order_are_enforced() { + // Use two clones so both the original parent and a clone are observed as + // non-final owners. Every contender is a separate process synchronized by + // its result sentinel; no same-process lock semantics or sleeps are evidence. + let shared_project = TempProject::new("shared-parent-clones"); + let shared_paths = shared_project.paths(); + stage_existing_lock(&shared_paths, LOCK_BYTES); + let shared_parent = + IndexLease::acquire_shared_existing(&shared_paths, deadline_after(CHILD_WAIT), || false) + .expect("acquire shared parent lease"); + let shared_clone_a = shared_parent.clone(); + let shared_clone_b = shared_parent.clone(); + + drop(shared_parent); + assert_eq!( + run_probe(shared_project.path(), "exclusive", SHORT_DEADLINE), + "TIMED_OUT", + "dropping a non-final shared parent must keep exclusives blocked" + ); + drop(shared_clone_a); + assert_eq!( + run_probe(shared_project.path(), "exclusive", SHORT_DEADLINE), + "TIMED_OUT", + "dropping a non-final shared clone must keep exclusives blocked" + ); + drop(shared_clone_b); + assert_eq!( + run_probe(shared_project.path(), "exclusive", SHORT_DEADLINE), + "ACQUIRED", + "the final shared owner must release immediately" + ); + + let exclusive_project = TempProject::new("exclusive-parent-clones"); + let exclusive_paths = exclusive_project.paths(); + stage_existing_lock(&exclusive_paths, LOCK_BYTES); + let exclusive_parent = IndexLease::acquire_exclusive_existing( + &exclusive_paths, + deadline_after(CHILD_WAIT), + || false, + ) + .expect("acquire exclusive parent lease"); + let exclusive_clone_a = exclusive_parent.clone(); + let exclusive_clone_b = exclusive_parent.clone(); + + drop(exclusive_parent); + for mode in ["shared", "exclusive"] { + assert_eq!( + run_probe(exclusive_project.path(), mode, SHORT_DEADLINE), + "TIMED_OUT", + "dropping a non-final exclusive parent must keep {mode} contenders blocked" + ); + } + drop(exclusive_clone_a); + for mode in ["shared", "exclusive"] { + assert_eq!( + run_probe(exclusive_project.path(), mode, SHORT_DEADLINE), + "TIMED_OUT", + "dropping a non-final exclusive clone must keep {mode} contenders blocked" + ); + } + drop(exclusive_clone_b); + assert_eq!( + run_probe(exclusive_project.path(), "shared", SHORT_DEADLINE), + "ACQUIRED", + "the final exclusive owner must release immediately" + ); + + // Build a real Current namespace exclusively through public production APIs, + // then let Store own the final shared lease capability and both SQLite + // handles. Store's declared field order must close those handles before its + // retained lease drops and admits the next exclusive contender. + let store_project = TempProject::new("current-store-final-owner"); + let store_paths = store_project.paths(); + let rebuild = begin_full_rebuild( + &store_paths, + RebuildKind::ExplicitInit, + deadline_after(CHILD_WAIT), + || false, + ) + .expect("begin Current Store fixture rebuild"); + rebuild + .open_store() + .expect("open Current Store fixture writer") + .finish() + .expect("finish Current Store fixture"); + + #[cfg(windows)] + let replacement = { + let replacement = store_paths.current_root().join("replacement.db"); + std::fs::copy(store_paths.current_db(), &replacement) + .expect("stage Windows database replacement"); + replacement + }; + + let store = Store::open_for_read(&store_paths, deadline_after(CHILD_WAIT), || false) + .expect("open Current Store with retained shared lease"); + assert_eq!( + run_probe(store_project.path(), "exclusive", SHORT_DEADLINE), + "TIMED_OUT", + "a live Current Store must retain its lease through its SQLite handles" + ); + drop(store); + + #[cfg(windows)] + { + // Windows refuses renaming an open SQLite database. Performing the + // replacement synchronously after Store::drop therefore proves its two + // connections closed before the final retained lease capability dropped. + let retired = store_paths.current_root().join("retired.db"); + std::fs::rename(store_paths.current_db(), &retired) + .expect("final Current Store drop closes Windows database handles"); + std::fs::rename(replacement, store_paths.current_db()) + .expect("install Windows database replacement immediately after Store drop"); + } + + assert_eq!( + run_probe(store_project.path(), "exclusive", SHORT_DEADLINE), + "ACQUIRED", + "dropping the final Store owner must admit a fresh contender immediately" + ); +} + +#[test] +fn writer_validation_rejects_shared_and_wrong_parent_capabilities() { + let project_a = TempProject::new("capability-a"); + let project_b = TempProject::new("capability-b"); + let paths_a = project_a.paths(); + let paths_b = project_b.paths(); + stage_existing_lock(&paths_a, LOCK_BYTES); + + let shared = + IndexLease::acquire_shared_existing(&paths_a, deadline_after(CHILD_WAIT), || false) + .expect("shared lease"); + assert!(shared.is_shared()); + assert!(!shared.is_exclusive()); + assert!(shared.matches_db_parent(&paths_a)); + assert_eq!( + shared.validate_exclusive(&paths_a), + Err(IndexLeaseValidationError::SharedLease) + ); + drop(shared); + + let exclusive = + IndexLease::acquire_exclusive_existing(&paths_a, deadline_after(CHILD_WAIT), || false) + .expect("exclusive lease"); + assert!(exclusive.is_exclusive()); + assert!(exclusive.validate_exclusive(&paths_a).is_ok()); + assert!(!exclusive.matches_db_parent(&paths_b)); + assert_eq!( + exclusive.validate_exclusive(&paths_b), + Err(IndexLeaseValidationError::WrongDbParent) + ); +} + +/// Child-process entry point. Parent tests coordinate holder readiness through a +/// pipe before launching a contender, so lock ordering never depends on sleeps. +#[test] +fn lease_child_process() { + let Ok(action) = std::env::var(CHILD_ACTION) else { + return; + }; + let project = PathBuf::from(std::env::var_os(CHILD_PROJECT).expect("child project env")); + let mode = std::env::var(CHILD_MODE).expect("child mode env"); + let paths = IndexPaths::resolve(&project, None).expect("child resolve IndexPaths"); + + let acquire = || match mode.as_str() { + "shared" => { + IndexLease::acquire_shared_existing(&paths, deadline_after(SHORT_DEADLINE), || false) + } + "exclusive" => { + IndexLease::acquire_exclusive_existing(&paths, deadline_after(SHORT_DEADLINE), || false) + } + other => panic!("unknown child mode {other}"), + }; + + match action.as_str() { + "hold" => { + let lease = acquire().expect("holder acquires lock"); + println!("READY"); + std::io::stdout().flush().expect("flush READY"); + let mut release = [0_u8; 1]; + std::io::stdin() + .read_exact(&mut release) + .expect("read release byte"); + drop(lease); + println!("RELEASED"); + std::io::stdout().flush().expect("flush RELEASED"); + } + "probe" => match acquire() { + Ok(lease) => { + println!("ACQUIRED"); + drop(lease); + } + Err(IndexLeaseError::TimedOut { .. }) => println!("TIMED_OUT"), + Err(err) => panic!("unexpected probe error: {err}"), + }, + other => panic!("unknown child action {other}"), + } +} + +struct Holder { + child: Option, + stdin: Option, + tail: Receiver, +} + +impl Holder { + fn spawn(project: &Path, mode: &str) -> Self { + let mut child = child_command(project, mode, "hold") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("spawn lock holder"); + let stdin = child.stdin.take().expect("holder stdin"); + let stdout = child.stdout.take().expect("holder stdout"); + let (ready_tx, ready_rx) = mpsc::channel(); + let (tail_tx, tail_rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let mut line = String::new(); + let read = reader.read_line(&mut line).expect("read holder output"); + assert_ne!(read, 0, "holder exited before READY"); + if line.trim() == "READY" { + ready_tx.send(line).expect("send holder READY"); + break; + } + } + let mut tail = String::new(); + reader.read_to_string(&mut tail).expect("read holder tail"); + tail_tx.send(tail).expect("send holder tail"); + }); + let ready = ready_rx + .recv_timeout(CHILD_WAIT) + .expect("holder READY before finite deadline"); + assert_eq!(ready.trim(), "READY"); + Self { + child: Some(child), + stdin: Some(stdin), + tail: tail_rx, + } + } + + fn release(mut self) { + let mut stdin = self.stdin.take().expect("holder release stdin"); + stdin.write_all(b"x").expect("signal holder release"); + drop(stdin); + let child = self.child.as_mut().expect("holder child"); + let status = wait_bounded(child, CHILD_WAIT); + assert!(status.success(), "holder child failed: {status}"); + let tail = self + .tail + .recv_timeout(CHILD_WAIT) + .expect("holder tail before finite deadline"); + assert!( + tail.lines().any(|line| line == "RELEASED"), + "holder emitted no RELEASED sentinel: {tail:?}" + ); + self.child.take(); + } +} + +impl Drop for Holder { + fn drop(&mut self) { + if let Some(child) = self.child.as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +fn child_command(project: &Path, mode: &str, action: &str) -> Command { + let mut command = Command::new(std::env::current_exe().expect("current test executable")); + command + .arg("--exact") + .arg("lease_child_process") + .arg("--nocapture") + .env(CHILD_ACTION, action) + .env(CHILD_MODE, mode) + .env(CHILD_PROJECT, project); + command +} + +fn run_probe(project: &Path, mode: &str, acquisition_bound: Duration) -> String { + let mut child = child_command(project, mode, "probe") + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("spawn lock probe"); + let mut stdout = child.stdout.take().expect("probe stdout"); + let (output_tx, output_rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut output = String::new(); + stdout + .read_to_string(&mut output) + .expect("read probe stdout"); + output_tx.send(output).expect("send probe output"); + }); + let process_bound = acquisition_bound + .checked_add(CHILD_WAIT) + .expect("probe process bound"); + let status = wait_bounded(&mut child, process_bound); + assert!(status.success(), "probe child failed: {status}"); + output_rx + .recv_timeout(CHILD_WAIT) + .expect("probe output before finite deadline") + .lines() + .find(|line| matches!(*line, "ACQUIRED" | "TIMED_OUT")) + .unwrap_or_else(|| panic!("probe emitted no result sentinel")) + .to_string() +} + +fn wait_bounded(child: &mut Child, timeout: Duration) -> std::process::ExitStatus { + let deadline = deadline_after(timeout); + loop { + if let Some(status) = child.try_wait().expect("poll child status") { + return status; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("child process exceeded finite {timeout:?} bound"); + } + std::thread::park_timeout(Duration::from_millis(5)); + } +} diff --git a/crates/codegraph-store/tests/index_state.rs b/crates/codegraph-store/tests/index_state.rs new file mode 100644 index 0000000..909502e --- /dev/null +++ b/crates/codegraph-store/tests/index_state.rs @@ -0,0 +1,685 @@ +//! Public-surface contract tests for the read-only dual-slot classifier. + +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use codegraph_core::IndexPaths; +use codegraph_store::{ + CURRENT_EXTRACTION_VERSION, CURRENT_STORAGE_PROTOCOL, CorruptReason, EXTRACTION_VERSION_KEY, + ExtractionStatus, SlotOutcome, StatePhase, canonical_checksum_payload, checksum_hex, classify, + classify_slots, +}; +use serde_json::{Value, json}; + +const OWNER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const OTHER_OWNER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + +struct TempTree(PathBuf); + +impl TempTree { + fn new(label: &str) -> Self { + let serial = NEXT_TEMP.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "codegraph-index-state-{label}-{}-{serial}", + std::process::id() + )); + std::fs::create_dir(&path) + .unwrap_or_else(|err| panic!("create temp tree {}: {err}", path.display())); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + + fn slots(&self) -> [PathBuf; 2] { + [ + self.0.join("index-state.0.json"), + self.0.join("index-state.1.json"), + ] + } +} + +impl Drop for TempTree { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0) + .unwrap_or_else(|err| panic!("remove temp tree {}: {err}", self.0.display())); + } +} + +fn wire_value( + sequence: u64, + storage_protocol: u64, + extraction_version: u64, + phase: &str, + owner: &str, +) -> Value { + json!({ + "sequence": sequence, + "storageProtocol": storage_protocol, + "extractionVersion": extraction_version, + "phase": phase, + "projectIdentity": owner, + "checksum": checksum_hex(sequence, storage_protocol, extraction_version, phase, owner), + }) +} + +fn wire_bytes( + sequence: u64, + storage_protocol: u64, + extraction_version: u64, + phase: &str, + owner: &str, +) -> Vec { + serde_json::to_vec(&wire_value( + sequence, + storage_protocol, + extraction_version, + phase, + owner, + )) + .expect("serialize state fixture") +} + +fn write_bytes(path: &Path, bytes: &[u8]) { + std::fs::write(path, bytes) + .unwrap_or_else(|err| panic!("write state fixture {}: {err}", path.display())); +} + +fn write_wire( + path: &Path, + sequence: u64, + storage_protocol: u64, + extraction_version: u64, + phase: &str, + owner: &str, +) { + write_bytes( + path, + &wire_bytes(sequence, storage_protocol, extraction_version, phase, owner), + ); +} + +fn assert_corrupt( + classification: &codegraph_store::IndexStateClassification, + expected: impl FnOnce(&CorruptReason) -> bool, +) { + match classification.status() { + ExtractionStatus::Corrupt { reason } => { + assert!(expected(reason), "unexpected corruption reason: {reason:?}"); + } + status => panic!("expected Corrupt, got {status:?}"), + } +} + +#[test] +fn index_state_constants_and_canonical_payload_are_exact() { + assert_eq!(CURRENT_STORAGE_PROTOCOL, 2); + assert_eq!(CURRENT_EXTRACTION_VERSION, 2); + assert_eq!(EXTRACTION_VERSION_KEY, "indexed_with_extraction_version"); + assert_eq!( + canonical_checksum_payload(7, 2, 2, "future-phase", OWNER), + format!( + "codegraph-index-state-v1\nsequence=7\nstorageProtocol=2\n\ + extractionVersion=2\nphase=future-phase\nprojectIdentity={OWNER}\n" + ) + ); + let digest = checksum_hex(7, 2, 2, "future-phase", OWNER); + assert_eq!(digest.len(), 64); + assert!( + digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + ); +} + +#[test] +fn index_state_both_missing_is_missing() { + let tree = TempTree::new("missing"); + let result = classify_slots(&tree.slots(), OWNER); + assert_eq!(result.status(), &ExtractionStatus::Missing); + assert_eq!(result.authoritative(), None); + assert!(matches!(result.slot(0), SlotOutcome::Absent)); + assert!(matches!(result.slot(1), SlotOutcome::Absent)); +} + +#[test] +fn index_state_current_protocol_maps_each_phase() { + for (phase, expected) in [ + ("current", ExtractionStatus::Current), + ( + "building", + ExtractionStatus::Building { + built: CURRENT_EXTRACTION_VERSION, + }, + ), + ("uninitialized", ExtractionStatus::Uninitialized), + ] { + let tree = TempTree::new(phase); + write_wire( + &tree.slots()[0], + 1, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION, + phase, + OWNER, + ); + assert_eq!( + classify_unchanged(tree.path(), || classify_slots(&tree.slots(), OWNER)).status(), + &expected + ); + } +} + +#[test] +fn index_state_extraction_version_maps_outdated_and_future_for_every_phase() { + for phase in ["building", "current", "uninitialized"] { + let old = TempTree::new("outdated"); + write_wire( + &old.slots()[0], + 1, + CURRENT_STORAGE_PROTOCOL, + 1, + phase, + OWNER, + ); + assert_eq!( + classify_slots(&old.slots(), OWNER).status(), + &ExtractionStatus::Outdated { built: 1 } + ); + + let future = TempTree::new("future-extraction"); + write_wire( + &future.slots()[0], + 1, + CURRENT_STORAGE_PROTOCOL, + 3, + phase, + OWNER, + ); + assert_eq!( + classify_slots(&future.slots(), OWNER).status(), + &ExtractionStatus::Future { built: 3 }, + "future extraction must dominate phase {phase}" + ); + } +} + +#[test] +fn index_state_unknown_fields_key_order_and_whitespace_are_ignored() { + let tree = TempTree::new("formatting"); + let checksum = checksum_hex(9, 2, 2, "current", OWNER); + let formatted = format!( + "{{\n \"unknownFutureField\": {{\"nested\": true}},\n \ + \"checksum\": \"{checksum}\", \"projectIdentity\": \"{OWNER}\",\n \ + \"phase\": \"current\", \"extractionVersion\": 2,\n \ + \"storageProtocol\": 2, \"sequence\": 9\n}}\n" + ); + write_bytes(&tree.slots()[1], formatted.as_bytes()); + let result = classify_slots(&tree.slots(), OWNER); + assert_eq!(result.status(), &ExtractionStatus::Current); + assert_eq!(result.authoritative().expect("authority").index, 1); +} + +#[test] +fn index_state_missing_and_wrong_typed_required_fields_are_malformed() { + for value in [ + json!({ + "storageProtocol": 2, + "extractionVersion": 2, + "phase": "current", + "projectIdentity": OWNER, + "checksum": "0".repeat(64), + }), + json!({ + "sequence": "1", + "storageProtocol": 2, + "extractionVersion": 2, + "phase": "current", + "projectIdentity": OWNER, + "checksum": "0".repeat(64), + }), + json!([]), + ] { + let tree = TempTree::new("malformed-shape"); + write_bytes(&tree.slots()[0], &serde_json::to_vec(&value).unwrap()); + let result = classify_unchanged(tree.path(), || classify_slots(&tree.slots(), OWNER)); + assert_corrupt(&result, |reason| { + matches!(reason, CorruptReason::MalformedJson { .. }) + }); + } +} + +#[test] +fn index_state_bad_owner_and_checksum_shapes_are_typed_corruption() { + let bad_owners = [ + "abc", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ]; + for owner in bad_owners { + let tree = TempTree::new("bad-owner-shape"); + write_wire(&tree.slots()[0], 1, 3, 2, "future-phase", owner); + let result = classify_unchanged(tree.path(), || classify_slots(&tree.slots(), OWNER)); + assert_corrupt(&result, |reason| { + matches!(reason, CorruptReason::InvalidOwnerEncoding { .. }) + }); + } + + for checksum in ["abc".to_string(), "A".repeat(64), "g".repeat(64)] { + let tree = TempTree::new("bad-checksum-shape"); + let mut value = wire_value(1, 3, 2, "future-phase", OWNER); + value["checksum"] = Value::String(checksum); + write_bytes(&tree.slots()[0], &serde_json::to_vec(&value).unwrap()); + let result = classify_slots(&tree.slots(), OWNER); + assert_corrupt(&result, |reason| { + matches!(reason, CorruptReason::InvalidChecksumEncoding { .. }) + }); + } +} + +#[test] +fn index_state_checksum_and_owner_mismatch_are_corrupt_even_for_future_protocol() { + let checksum_tree = TempTree::new("checksum-mismatch"); + let mut value = wire_value(1, 3, 2, "future-phase", OWNER); + value["checksum"] = Value::String("0".repeat(64)); + write_bytes( + &checksum_tree.slots()[0], + &serde_json::to_vec(&value).unwrap(), + ); + let result = classify_unchanged(checksum_tree.path(), || { + classify_slots(&checksum_tree.slots(), OWNER) + }); + assert_corrupt(&result, |reason| { + matches!(reason, CorruptReason::ChecksumMismatch { .. }) + }); + + let owner_tree = TempTree::new("owner-mismatch"); + write_wire(&owner_tree.slots()[0], 1, 3, 2, "future-phase", OTHER_OWNER); + let result = classify_unchanged(owner_tree.path(), || { + classify_slots(&owner_tree.slots(), OWNER) + }); + assert_corrupt(&result, |reason| { + matches!(reason, CorruptReason::OwnerMismatch { .. }) + }); +} + +#[test] +fn index_state_current_unknown_phase_is_corrupt_but_future_unknown_phase_is_valid() { + let current = TempTree::new("unknown-current-phase"); + write_wire(¤t.slots()[0], 1, 2, 2, "future-phase", OWNER); + let result = classify_slots(¤t.slots(), OWNER); + assert_corrupt( + &result, + |reason| matches!(reason, CorruptReason::UnknownPhase { phase, .. } if phase == "future-phase"), + ); + + let future = TempTree::new("unknown-future-phase"); + write_wire(&future.slots()[0], 1, 3, 44, "future-phase", OWNER); + let result = classify_slots(&future.slots(), OWNER); + assert_eq!(result.status(), &ExtractionStatus::Future { built: 44 }); + let authority = result.authoritative().expect("future authority"); + assert_eq!(authority.record.phase, None); + assert_eq!(authority.record.phase_raw, "future-phase"); + assert!(matches!(result.slot(0), SlotOutcome::FutureProtocol(_))); +} + +#[test] +fn index_state_lower_and_zero_storage_protocol_are_corrupt() { + for protocol in [0, 1] { + let tree = TempTree::new("lower-protocol"); + write_wire(&tree.slots()[0], 1, protocol, 2, "current", OWNER); + let result = classify_slots(&tree.slots(), OWNER); + assert_corrupt( + &result, + |reason| matches!(reason, CorruptReason::UnsupportedStorageProtocol { found, .. } if *found == protocol), + ); + } +} + +#[test] +fn index_state_any_malformed_present_slot_dominates_a_valid_companion() { + let tree = TempTree::new("invalid-dominance"); + write_bytes(&tree.slots()[0], b"not json"); + write_wire(&tree.slots()[1], 99, 2, 2, "current", OWNER); + let result = classify_unchanged(tree.path(), || classify_slots(&tree.slots(), OWNER)); + assert_corrupt(&result, |reason| { + matches!(reason, CorruptReason::MalformedJson { slot: 0, .. }) + }); + assert_eq!(result.authoritative(), None); +} + +#[test] +fn index_state_non_regular_and_unreadable_slots_are_corrupt() { + let directory = TempTree::new("slot-directory"); + std::fs::create_dir(&directory.slots()[0]).unwrap(); + let result = classify_unchanged(directory.path(), || { + classify_slots(&directory.slots(), OWNER) + }); + assert_corrupt(&result, |reason| { + matches!( + reason, + CorruptReason::NotARegularFile { + kind: "directory", + .. + } + ) + }); + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + let link = TempTree::new("slot-symlink"); + let target = link.path().join("target.json"); + write_wire(&target, 1, 2, 2, "current", OWNER); + symlink(&target, &link.slots()[0]).unwrap(); + let result = classify_unchanged(link.path(), || classify_slots(&link.slots(), OWNER)); + assert_corrupt(&result, |reason| { + matches!( + reason, + CorruptReason::NotARegularFile { + kind: "symlink", + .. + } + ) + }); + } + + let unreadable = TempTree::new("slot-unreadable"); + let too_long = unreadable.path().join(OsString::from("x".repeat(4096))); + let result = classify_slots(&[too_long, unreadable.slots()[1].clone()], OWNER); + assert_corrupt(&result, |reason| { + matches!(reason, CorruptReason::UnreadableSlot { slot: 0, .. }) + }); +} + +#[test] +fn index_state_future_protocol_dominates_current_in_both_sequence_directions() { + for (future_sequence, current_sequence) in [(1, 100), (100, 1)] { + let tree = TempTree::new("future-dominance"); + write_wire(&tree.slots()[0], current_sequence, 2, 2, "current", OWNER); + write_wire( + &tree.slots()[1], + future_sequence, + 3, + 77, + "future-phase", + OWNER, + ); + let result = classify_unchanged(tree.path(), || classify_slots(&tree.slots(), OWNER)); + assert_eq!(result.status(), &ExtractionStatus::Future { built: 77 }); + assert_eq!(result.authoritative().expect("future authority").index, 1); + } +} + +#[test] +fn index_state_equal_current_sequences_are_corrupt_for_identical_and_reformatted_json() { + let identical = TempTree::new("equal-identical"); + let bytes = wire_bytes(7, 2, 2, "current", OWNER); + write_bytes(&identical.slots()[0], &bytes); + write_bytes(&identical.slots()[1], &bytes); + let result = classify_unchanged(identical.path(), || { + classify_slots(&identical.slots(), OWNER) + }); + assert_corrupt(&result, |reason| { + matches!( + reason, + CorruptReason::EqualSequence { + sequence: 7, + identical_payload: true + } + ) + }); + + let reformatted = TempTree::new("equal-reformatted"); + write_bytes(&reformatted.slots()[0], &bytes); + let checksum = checksum_hex(7, 2, 2, "current", OWNER); + let other = format!( + "{{ \"checksum\":\"{checksum}\", \"phase\":\"current\", \ + \"projectIdentity\":\"{OWNER}\", \"extractionVersion\":2, \ + \"storageProtocol\":2, \"sequence\":7 }}" + ); + write_bytes(&reformatted.slots()[1], other.as_bytes()); + let result = classify_unchanged(reformatted.path(), || { + classify_slots(&reformatted.slots(), OWNER) + }); + assert_corrupt(&result, |reason| { + matches!( + reason, + CorruptReason::EqualSequence { + sequence: 7, + identical_payload: false + } + ) + }); +} + +#[test] +fn index_state_equal_future_and_mixed_sequences_are_corrupt_before_future_dominance() { + let future = TempTree::new("equal-future"); + write_wire(&future.slots()[0], 8, 3, 9, "alpha", OWNER); + write_wire(&future.slots()[1], 8, 4, 10, "beta", OWNER); + let result = classify_unchanged(future.path(), || classify_slots(&future.slots(), OWNER)); + assert_corrupt(&result, |reason| { + matches!(reason, CorruptReason::EqualSequence { sequence: 8, .. }) + }); + + let mixed = TempTree::new("equal-mixed"); + write_wire(&mixed.slots()[0], 8, 2, 2, "current", OWNER); + write_wire(&mixed.slots()[1], 8, 3, 10, "future-phase", OWNER); + let result = classify_unchanged(mixed.path(), || classify_slots(&mixed.slots(), OWNER)); + assert_corrupt(&result, |reason| { + matches!(reason, CorruptReason::EqualSequence { sequence: 8, .. }) + }); +} + +#[test] +fn index_state_selected_max_sequence_is_corrupt_before_status_mapping() { + for (protocol, extraction, phase) in [(2, 2, "current"), (3, 99, "future-phase")] { + let tree = TempTree::new("max-sequence"); + write_wire( + &tree.slots()[0], + u64::MAX, + protocol, + extraction, + phase, + OWNER, + ); + let result = classify_unchanged(tree.path(), || classify_slots(&tree.slots(), OWNER)); + assert_corrupt( + &result, + |reason| matches!(reason, CorruptReason::SequenceExhausted { sequence } if *sequence == u64::MAX), + ); + assert_eq!( + result + .authoritative() + .expect("selected authority") + .record + .sequence, + u64::MAX + ); + } +} + +#[test] +fn index_state_highest_ordinary_current_slot_is_authoritative_with_full_metadata() { + let tree = TempTree::new("authority"); + write_wire(&tree.slots()[0], 4, 2, 1, "building", OWNER); + write_wire(&tree.slots()[1], 5, 2, 2, "current", OWNER); + let result = classify_unchanged(tree.path(), || classify_slots(&tree.slots(), OWNER)); + assert_eq!(result.status(), &ExtractionStatus::Current); + let authority = result.authoritative().expect("authority"); + assert_eq!(authority.index, 1); + assert_eq!(authority.path, tree.slots()[1]); + assert_eq!(authority.record.sequence, 5); + assert_eq!(authority.record.storage_protocol, 2); + assert_eq!(authority.record.extraction_version, 2); + assert_eq!(authority.record.phase, Some(StatePhase::Current)); + assert_eq!(authority.record.phase_raw, "current"); + assert_eq!(authority.record.project_identity, OWNER); + assert_eq!( + authority.record.checksum, + checksum_hex(5, 2, 2, "current", OWNER) + ); +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum SnapshotKind { + Directory, + File(Vec), + Symlink(PathBuf), +} + +fn snapshot_tree(root: &Path) -> BTreeMap { + fn walk(root: &Path, directory: &Path, out: &mut BTreeMap) { + let entries = std::fs::read_dir(directory) + .unwrap_or_else(|err| panic!("snapshot read_dir {}: {err}", directory.display())); + let mut paths = entries + .map(|entry| { + entry + .unwrap_or_else(|err| { + panic!("snapshot entry in {}: {err}", directory.display()) + }) + .path() + }) + .collect::>(); + paths.sort(); + for path in paths { + let relative = path + .strip_prefix(root) + .unwrap_or_else(|err| panic!("snapshot strip {}: {err}", path.display())) + .to_path_buf(); + let metadata = std::fs::symlink_metadata(&path) + .unwrap_or_else(|err| panic!("snapshot metadata {}: {err}", path.display())); + let file_type = metadata.file_type(); + let kind = if file_type.is_dir() { + SnapshotKind::Directory + } else if file_type.is_file() { + SnapshotKind::File( + std::fs::read(&path) + .unwrap_or_else(|err| panic!("snapshot read {}: {err}", path.display())), + ) + } else if file_type.is_symlink() { + SnapshotKind::Symlink( + std::fs::read_link(&path).unwrap_or_else(|err| { + panic!("snapshot read_link {}: {err}", path.display()) + }), + ) + } else { + panic!("snapshot unsupported entry kind: {}", path.display()); + }; + assert!( + out.insert(relative, kind).is_none(), + "duplicate snapshot path" + ); + if file_type.is_dir() { + walk(root, &path, out); + } + } + } + + let mut out = BTreeMap::new(); + walk(root, root, &mut out); + out +} + +fn assert_snapshot_unchanged( + before: &BTreeMap, + after: &BTreeMap, +) { + let changed = before + .keys() + .chain(after.keys()) + .filter(|path| before.get(*path) != after.get(*path)) + .cloned() + .collect::>(); + let total = changed.len(); + let bounded = changed.iter().take(16).cloned().collect::>(); + assert!( + changed.is_empty(), + "filesystem snapshot changed at {bounded:?}{}", + if total > bounded.len() { + format!(" (and {} more paths)", total - bounded.len()) + } else { + String::new() + } + ); +} + +fn classify_unchanged( + root: &Path, + classify_once: impl FnOnce() -> codegraph_store::IndexStateClassification, +) -> codegraph_store::IndexStateClassification { + let before = snapshot_tree(root); + let classification = classify_once(); + let after = snapshot_tree(root); + assert_snapshot_unchanged(&before, &after); + classification +} + +#[test] +fn index_state_classifier_is_exactly_byte_nonmutating_and_never_opens_sqlite() { + let project = TempTree::new("nonmutation"); + let paths = IndexPaths::resolve(project.path(), None).expect("resolve paths"); + std::fs::create_dir_all(paths.current_root().join("empty-directory")).unwrap(); + write_bytes(&paths.current_db(), b"not sqlite; immutable trap bytes"); + write_bytes(&paths.permanent_lock(), b"lock trap"); + write_wire( + &paths.state_slots()[0], + 1, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION, + "current", + paths.project_identity(), + ); + #[cfg(unix)] + std::os::unix::fs::symlink("codegraph.db", paths.current_root().join("db-link")).unwrap(); + + let result = classify_unchanged(project.path(), || classify(&paths)); + assert_eq!(result.status(), &ExtractionStatus::Current); +} + +#[test] +fn index_state_snapshot_detects_equal_length_byte_mutation() { + let tree = TempTree::new("snapshot-bytes"); + let file = tree.path().join("same-length.bin"); + write_bytes(&file, b"AAAA"); + let before = snapshot_tree(tree.path()); + write_bytes(&file, b"BBBB"); + let after = snapshot_tree(tree.path()); + let caught = std::panic::catch_unwind(|| assert_snapshot_unchanged(&before, &after)); + assert!( + caught.is_err(), + "equal-length byte mutation must be detected" + ); +} + +#[cfg(unix)] +#[test] +fn index_state_snapshot_records_symlink_target_without_following_it() { + use std::os::unix::fs::symlink; + + let tree = TempTree::new("snapshot-symlink"); + let outside = TempTree::new("snapshot-outside"); + let target = outside.path().join("target.bin"); + write_bytes(&target, b"AAAA"); + symlink(&target, tree.path().join("link")).unwrap(); + let before = snapshot_tree(tree.path()); + assert!( + matches!(before.get(Path::new("link")), Some(SnapshotKind::Symlink(found)) if found == &target) + ); + + write_bytes(&target, b"BBBB"); + let after_target_write = snapshot_tree(tree.path()); + assert_snapshot_unchanged(&before, &after_target_write); + + std::fs::remove_file(tree.path().join("link")).unwrap(); + let second_target = outside.path().join("second.bin"); + write_bytes(&second_target, b"BBBB"); + symlink(&second_target, tree.path().join("link")).unwrap(); + let after_retarget = snapshot_tree(tree.path()); + let caught = std::panic::catch_unwind(|| assert_snapshot_unchanged(&before, &after_retarget)); + assert!(caught.is_err(), "symlink retarget must be detected"); +} diff --git a/crates/codegraph-store/tests/index_state_publisher.rs b/crates/codegraph-store/tests/index_state_publisher.rs new file mode 100644 index 0000000..721a6c5 --- /dev/null +++ b/crates/codegraph-store/tests/index_state_publisher.rs @@ -0,0 +1,778 @@ +//! Public behavioral contract for lease-gated dual-slot state publication. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use codegraph_core::IndexPaths; +use codegraph_store::{ + CURRENT_EXTRACTION_VERSION, CURRENT_STORAGE_PROTOCOL, CorruptReason, ExtractionStatus, + IndexLease, IndexLeaseValidationError, ParentSyncStatus, SlotOutcome, StatePhase, + StatePublishError, checksum_hex, classify, publish_index_state, +}; +use serde_json::json; + +static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + +struct TempProject(PathBuf); + +impl TempProject { + fn new(label: &str) -> Self { + let serial = NEXT_TEMP.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "codegraph-index-state-publisher-{label}-{}-{serial}", + std::process::id() + )); + std::fs::create_dir(&path) + .unwrap_or_else(|err| panic!("create temp project {}: {err}", path.display())); + Self(path.canonicalize().expect("canonical temp project")) + } + + fn path(&self) -> &Path { + &self.0 + } + + fn paths(&self) -> IndexPaths { + IndexPaths::resolve(self.path(), None).expect("resolve publisher test paths") + } +} + +impl Drop for TempProject { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0) + .unwrap_or_else(|err| panic!("remove temp project {}: {err}", self.0.display())); + } +} + +fn deadline() -> Instant { + Instant::now() + .checked_add(Duration::from_secs(5)) + .expect("publisher test deadline") +} + +fn wire_bytes( + sequence: u64, + storage_protocol: u64, + extraction_version: u64, + phase: &str, + owner: &str, +) -> Vec { + serde_json::to_vec(&json!({ + "sequence": sequence, + "storageProtocol": storage_protocol, + "extractionVersion": extraction_version, + "phase": phase, + "projectIdentity": owner, + "checksum": checksum_hex( + sequence, + storage_protocol, + extraction_version, + phase, + owner, + ), + })) + .expect("serialize publisher state fixture") +} + +fn canonical_published_bytes(sequence: u64, phase: &str, owner: &str) -> Vec { + let checksum = checksum_hex( + sequence, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION, + phase, + owner, + ); + format!( + "{{\"sequence\":{sequence},\"storageProtocol\":{CURRENT_STORAGE_PROTOCOL},\"extractionVersion\":{CURRENT_EXTRACTION_VERSION},\"phase\":\"{phase}\",\"projectIdentity\":\"{owner}\",\"checksum\":\"{checksum}\"}}" + ) + .into_bytes() +} + +fn write_wire( + path: &Path, + sequence: u64, + storage_protocol: u64, + extraction_version: u64, + phase: &str, + owner: &str, +) { + std::fs::write( + path, + wire_bytes(sequence, storage_protocol, extraction_version, phase, owner), + ) + .unwrap_or_else(|err| panic!("write publisher fixture {}: {err}", path.display())); +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum SnapshotKind { + Directory, + File(Vec), + Symlink(PathBuf), +} + +/// Windows `ERROR_LOCK_VIOLATION`. +/// +/// A byte-range lock taken through `File::try_lock` is ADVISORY on Unix, so an +/// unrelated `read` of the locked file always succeeds there. On Windows the +/// same lock is MANDATORY and any overlapping read is refused with this code. +/// The publisher fixtures below hold a live EXCLUSIVE lease over the zero-length +/// `index.lock` — including after renaming it aside, since the lock follows the +/// handle — while they snapshot. +const ERROR_LOCK_VIOLATION: i32 = 33; + +/// Read one regular file's bytes for a snapshot, tolerating exactly one +/// otherwise-fatal case: a mandatory Windows byte-range lock refusing a read of +/// an EMPTY file. +/// +/// `len` is the length from the `symlink_metadata` already taken for this entry. +/// Metadata queries are not byte-range-locked, so the length stays observable +/// while the lock is held, and a zero-length file has exactly one possible +/// content. Recording it as empty is therefore byte-exact AND independent of +/// whether the lock happened to be held at snapshot time, so a `before`/`after` +/// pair still compares equal across a lock release. Creation, removal and kind +/// changes stay detectable because the entry is still recorded as the regular +/// file it is. Every other read error panics: an unreadable file is a real fault, +/// and a non-empty locked file has content that cannot be observed at all. +fn snapshot_file_bytes(path: &Path, len: u64) -> Vec { + match std::fs::read(path) { + Ok(bytes) => bytes, + Err(error) + if cfg!(windows) && len == 0 && error.raw_os_error() == Some(ERROR_LOCK_VIOLATION) => + { + Vec::new() + } + Err(error) => panic!("snapshot read {}: {error}", path.display()), + } +} + +fn snapshot_tree(root: &Path) -> BTreeMap { + fn walk(root: &Path, directory: &Path, out: &mut BTreeMap) { + let entries = std::fs::read_dir(directory) + .unwrap_or_else(|err| panic!("snapshot read_dir {}: {err}", directory.display())); + let mut paths = entries + .map(|entry| { + entry + .unwrap_or_else(|err| { + panic!("snapshot entry in {}: {err}", directory.display()) + }) + .path() + }) + .collect::>(); + paths.sort(); + for path in paths { + let relative = path + .strip_prefix(root) + .unwrap_or_else(|err| panic!("snapshot strip {}: {err}", path.display())) + .to_path_buf(); + let metadata = std::fs::symlink_metadata(&path) + .unwrap_or_else(|err| panic!("snapshot metadata {}: {err}", path.display())); + let file_type = metadata.file_type(); + let kind = + if file_type.is_dir() { + SnapshotKind::Directory + } else if file_type.is_file() { + SnapshotKind::File(snapshot_file_bytes(&path, metadata.len())) + } else if file_type.is_symlink() { + SnapshotKind::Symlink(std::fs::read_link(&path).unwrap_or_else(|err| { + panic!("snapshot read_link {}: {err}", path.display()) + })) + } else { + panic!("snapshot unsupported entry kind: {}", path.display()); + }; + assert!( + out.insert(relative, kind).is_none(), + "duplicate snapshot path" + ); + if file_type.is_dir() { + walk(root, &path, out); + } + } + } + + let mut out = BTreeMap::new(); + walk(root, root, &mut out); + out +} + +fn assert_snapshot_unchanged( + before: &BTreeMap, + after: &BTreeMap, +) { + let changed = before + .keys() + .chain(after.keys()) + .filter(|path| before.get(*path) != after.get(*path)) + .cloned() + .collect::>(); + assert!(changed.is_empty(), "filesystem changed at {changed:?}"); +} + +fn assert_rejected_without_mutation( + project: &TempProject, + invoke: impl FnOnce() -> Result, + expected: impl FnOnce(&StatePublishError) -> bool, +) { + let before = snapshot_tree(project.path()); + let error = invoke().expect_err("publication must be rejected"); + assert!(expected(&error), "unexpected publisher error: {error:?}"); + let after = snapshot_tree(project.path()); + assert_snapshot_unchanged(&before, &after); +} + +#[test] +fn first_publication_from_absent_state_writes_canonical_slot_zero_sequence_zero() { + let project = TempProject::new("initial-red"); + let paths = project.paths(); + let lease = + IndexLease::create_exclusive(&paths, deadline(), || false).expect("create namespace lease"); + assert_eq!(classify(&paths).status(), &ExtractionStatus::Missing); + + let published = publish_index_state(&paths, &lease, StatePhase::Building) + .expect("initial state publication must succeed"); + + assert_eq!(published.slot, 0); + assert_eq!(published.record.sequence, 0); + assert_eq!(published.record.storage_protocol, CURRENT_STORAGE_PROTOCOL); + assert_eq!( + published.record.extraction_version, + CURRENT_EXTRACTION_VERSION + ); + assert_eq!(published.record.phase, Some(StatePhase::Building)); + assert_eq!(published.record.project_identity, paths.project_identity()); + assert!(matches!( + published.parent_sync, + ParentSyncStatus::Synced | ParentSyncStatus::Unsupported + )); + assert!(paths.state_slots()[0].is_file()); + assert!(!paths.state_slots()[1].exists()); + assert_eq!( + std::fs::read(&paths.state_slots()[0]).expect("read canonical first slot"), + canonical_published_bytes(0, "building", paths.project_identity()) + ); + assert_eq!( + classify(&paths).status(), + &ExtractionStatus::Building { + built: CURRENT_EXTRACTION_VERSION + } + ); +} + +#[test] +fn all_current_protocol_phases_publish_monotonically_and_replace_only_the_older_slot() { + let project = TempProject::new("phase-cycle"); + let paths = project.paths(); + let lease = + IndexLease::create_exclusive(&paths, deadline(), || false).expect("create namespace lease"); + + let building = publish_index_state(&paths, &lease, StatePhase::Building).unwrap(); + let building_bytes = std::fs::read(&paths.state_slots()[0]).unwrap(); + let current = publish_index_state(&paths, &lease, StatePhase::Current).unwrap(); + let current_bytes = std::fs::read(&paths.state_slots()[1]).unwrap(); + assert_eq!( + std::fs::read(&paths.state_slots()[0]).unwrap(), + building_bytes + ); + + let uninitialized = publish_index_state(&paths, &lease, StatePhase::Uninitialized).unwrap(); + assert_eq!(building.slot, 0); + assert_eq!(current.slot, 1); + assert_eq!(uninitialized.slot, 0); + assert_eq!( + [ + building.record.sequence, + current.record.sequence, + uninitialized.record.sequence, + ], + [0, 1, 2] + ); + assert_eq!( + std::fs::read(&paths.state_slots()[1]).unwrap(), + current_bytes, + "third publication must preserve the current authoritative slot" + ); + assert_eq!(classify(&paths).status(), &ExtractionStatus::Uninitialized); +} + +#[derive(Debug, Clone, Copy)] +enum PriorStatus { + Missing, + Outdated, + Uninitialized, + Current, +} + +fn stage_prior_status(paths: &IndexPaths, lease: &IndexLease, prior: PriorStatus) { + match prior { + PriorStatus::Missing => {} + PriorStatus::Outdated => write_wire( + &paths.state_slots()[0], + 4, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + paths.project_identity(), + ), + PriorStatus::Uninitialized => { + publish_index_state(paths, lease, StatePhase::Building).unwrap(); + publish_index_state(paths, lease, StatePhase::Uninitialized).unwrap(); + } + PriorStatus::Current => { + publish_index_state(paths, lease, StatePhase::Building).unwrap(); + publish_index_state(paths, lease, StatePhase::Current).unwrap(); + } + } +} + +fn expected_prior_status(prior: PriorStatus) -> ExtractionStatus { + match prior { + PriorStatus::Missing => ExtractionStatus::Missing, + PriorStatus::Outdated => ExtractionStatus::Outdated { + built: CURRENT_EXTRACTION_VERSION - 1, + }, + PriorStatus::Uninitialized => ExtractionStatus::Uninitialized, + PriorStatus::Current => ExtractionStatus::Current, + } +} + +#[test] +fn invalid_lifecycle_transitions_are_byte_nonmutating() { + let cases = [ + (PriorStatus::Missing, StatePhase::Current), + (PriorStatus::Missing, StatePhase::Uninitialized), + (PriorStatus::Outdated, StatePhase::Current), + (PriorStatus::Outdated, StatePhase::Uninitialized), + (PriorStatus::Uninitialized, StatePhase::Current), + (PriorStatus::Current, StatePhase::Current), + ]; + + for (prior, requested) in cases { + let project = TempProject::new("invalid-transition"); + let paths = project.paths(); + let lease = IndexLease::create_exclusive(&paths, deadline(), || false).unwrap(); + stage_prior_status(&paths, &lease, prior); + assert_rejected_without_mutation( + &project, + || publish_index_state(&paths, &lease, requested), + |error| { + matches!( + error, + StatePublishError::InvalidTransition { + current, + requested: actual, + } if current == &expected_prior_status(prior) && *actual == requested + ) + }, + ); + } +} + +#[test] +fn shared_and_wrong_parent_capabilities_are_typed_and_byte_nonmutating() { + let shared_project = TempProject::new("shared-capability"); + let shared_paths = shared_project.paths(); + let initial = IndexLease::create_exclusive(&shared_paths, deadline(), || false).unwrap(); + publish_index_state(&shared_paths, &initial, StatePhase::Building).unwrap(); + drop(initial); + let shared = IndexLease::acquire_shared_existing(&shared_paths, deadline(), || false).unwrap(); + assert_rejected_without_mutation( + &shared_project, + || publish_index_state(&shared_paths, &shared, StatePhase::Building), + |error| { + matches!( + error, + StatePublishError::Lease(IndexLeaseValidationError::SharedLease) + ) + }, + ); + drop(shared); + + let other_project = TempProject::new("wrong-parent-capability"); + let other_paths = other_project.paths(); + let other_lease = IndexLease::create_exclusive(&other_paths, deadline(), || false).unwrap(); + publish_index_state(&other_paths, &other_lease, StatePhase::Building).unwrap(); + let wrong_lease = + IndexLease::acquire_exclusive_existing(&shared_paths, deadline(), || false).unwrap(); + assert_rejected_without_mutation( + &other_project, + || publish_index_state(&other_paths, &wrong_lease, StatePhase::Building), + |error| { + matches!( + error, + StatePublishError::Lease(IndexLeaseValidationError::WrongDbParent) + ) + }, + ); +} + +#[test] +fn replaced_permanent_lock_invalidates_publisher_capability_before_state_mutation() { + let project = TempProject::new("replaced-lock"); + let paths = project.paths(); + let stale = IndexLease::create_exclusive(&paths, deadline(), || false).unwrap(); + publish_index_state(&paths, &stale, StatePhase::Building).unwrap(); + + let displaced = paths.current_root().join("displaced-index.lock"); + std::fs::rename(paths.permanent_lock(), &displaced).expect("displace locked handle"); + std::fs::write(paths.permanent_lock(), b"replacement lock").expect("install replacement lock"); + + assert_rejected_without_mutation( + &project, + || publish_index_state(&paths, &stale, StatePhase::Current), + |error| { + matches!( + error, + StatePublishError::Lease( + IndexLeaseValidationError::PermanentLockChanged { path } + ) if path == &paths.permanent_lock() + ) + }, + ); + + let fresh = IndexLease::acquire_exclusive_existing(&paths, deadline(), || false) + .expect("replacement lock is independent of the stale capability"); + drop(fresh); + drop(stale); +} + +#[test] +fn missing_permanent_lock_invalidates_publisher_capability_before_state_mutation() { + let project = TempProject::new("missing-lock-after-acquire"); + let paths = project.paths(); + let stale = IndexLease::create_exclusive(&paths, deadline(), || false).unwrap(); + publish_index_state(&paths, &stale, StatePhase::Building).unwrap(); + + let displaced = paths.current_root().join("displaced-index.lock"); + std::fs::rename(paths.permanent_lock(), &displaced).expect("displace locked handle"); + + assert_rejected_without_mutation( + &project, + || publish_index_state(&paths, &stale, StatePhase::Current), + |error| { + matches!( + error, + StatePublishError::Lease( + IndexLeaseValidationError::PermanentLockChanged { path } + ) if path == &paths.permanent_lock() + ) + }, + ); +} + +#[test] +fn non_regular_permanent_lock_invalidates_publisher_capability_before_state_mutation() { + let project = TempProject::new("non-regular-lock-after-acquire"); + let paths = project.paths(); + let stale = IndexLease::create_exclusive(&paths, deadline(), || false).unwrap(); + publish_index_state(&paths, &stale, StatePhase::Building).unwrap(); + + let displaced = paths.current_root().join("displaced-index.lock"); + std::fs::rename(paths.permanent_lock(), &displaced).expect("displace locked handle"); + std::fs::create_dir(paths.permanent_lock()).expect("install directory at fixed lock path"); + + assert_rejected_without_mutation( + &project, + || publish_index_state(&paths, &stale, StatePhase::Current), + |error| { + matches!( + error, + StatePublishError::Lease( + IndexLeaseValidationError::PermanentLockChanged { path } + ) if path == &paths.permanent_lock() + ) + }, + ); +} + +#[cfg(unix)] +#[test] +fn aliased_permanent_lock_invalidates_publisher_capability_before_state_mutation() { + use std::os::unix::fs::symlink; + + let project = TempProject::new("aliased-lock-after-acquire"); + let paths = project.paths(); + let stale = IndexLease::create_exclusive(&paths, deadline(), || false).unwrap(); + publish_index_state(&paths, &stale, StatePhase::Building).unwrap(); + + let displaced = paths.current_root().join("displaced-index.lock"); + let external = project.path().join("external.lock"); + std::fs::rename(paths.permanent_lock(), &displaced).expect("displace locked handle"); + std::fs::write(&external, b"external lock target").expect("write external lock target"); + symlink(&external, paths.permanent_lock()).expect("install alias at fixed lock path"); + + assert_rejected_without_mutation( + &project, + || publish_index_state(&paths, &stale, StatePhase::Current), + |error| { + matches!( + error, + StatePublishError::Lease( + IndexLeaseValidationError::PermanentLockChanged { path } + ) if path == &paths.permanent_lock() + ) + }, + ); + assert_eq!(std::fs::read(&external).unwrap(), b"external lock target"); +} + +#[test] +fn invalid_fixed_slot_equal_sequence_and_exhaustion_are_refused_byte_nonmutating() { + for case in ["invalid", "equal", "exhausted"] { + let project = TempProject::new(case); + let paths = project.paths(); + let lease = IndexLease::create_exclusive(&paths, deadline(), || false).unwrap(); + match case { + "invalid" => { + write_wire( + &paths.state_slots()[0], + 3, + 2, + 2, + "current", + paths.project_identity(), + ); + std::fs::write(&paths.state_slots()[1], b"not json").unwrap(); + } + "equal" => { + write_wire( + &paths.state_slots()[0], + 3, + 2, + 2, + "building", + paths.project_identity(), + ); + write_wire( + &paths.state_slots()[1], + 3, + 2, + 2, + "current", + paths.project_identity(), + ); + } + "exhausted" => write_wire( + &paths.state_slots()[0], + u64::MAX, + 2, + 2, + "current", + paths.project_identity(), + ), + _ => unreachable!(), + } + + assert_rejected_without_mutation( + &project, + || publish_index_state(&paths, &lease, StatePhase::Uninitialized), + |error| { + matches!( + (case, error), + ( + "invalid", + StatePublishError::Refused { + status: ExtractionStatus::Corrupt { + reason: CorruptReason::MalformedJson { slot: 1, .. }, + }, + }, + ) | ( + "equal", + StatePublishError::Refused { + status: ExtractionStatus::Corrupt { + reason: CorruptReason::EqualSequence { sequence: 3, .. }, + }, + }, + ) | ( + "exhausted", + StatePublishError::Refused { + status: ExtractionStatus::Corrupt { + reason: CorruptReason::SequenceExhausted { sequence: u64::MAX }, + }, + }, + ) + ) + }, + ); + } +} + +#[test] +fn owner_mismatch_is_typed_and_byte_nonmutating() { + let project = TempProject::new("owner-mismatch"); + let paths = project.paths(); + let lease = IndexLease::create_exclusive(&paths, deadline(), || false).unwrap(); + let other_owner = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + write_wire( + &paths.state_slots()[0], + 1, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION, + "current", + other_owner, + ); + + assert_rejected_without_mutation( + &project, + || publish_index_state(&paths, &lease, StatePhase::Building), + |error| { + matches!( + error, + StatePublishError::Refused { + status: ExtractionStatus::Corrupt { + reason: CorruptReason::OwnerMismatch { slot: 0, .. } + } + } + ) + }, + ); +} + +#[test] +fn future_v3_slot_dominates_current_companion_and_is_never_replaced() { + let project = TempProject::new("future-v3"); + let paths = project.paths(); + let lease = IndexLease::create_exclusive(&paths, deadline(), || false).unwrap(); + write_wire( + &paths.state_slots()[0], + 90, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION, + "current", + paths.project_identity(), + ); + write_wire( + &paths.state_slots()[1], + 91, + 3, + 99, + "v3-replacement", + paths.project_identity(), + ); + assert_eq!( + classify(&paths).status(), + &ExtractionStatus::Future { built: 99 } + ); + + assert_rejected_without_mutation( + &project, + || publish_index_state(&paths, &lease, StatePhase::Uninitialized), + |error| { + matches!( + error, + StatePublishError::Refused { + status: ExtractionStatus::Future { built: 99 } + } + ) + }, + ); +} + +#[test] +fn missing_inactive_slot_is_recreated_while_all_preexisting_temps_remain_untouched() { + let project = TempProject::new("missing-inactive"); + let paths = project.paths(); + let lease = IndexLease::create_exclusive(&paths, deadline(), || false).unwrap(); + publish_index_state(&paths, &lease, StatePhase::Building).unwrap(); + publish_index_state(&paths, &lease, StatePhase::Current).unwrap(); + std::fs::remove_file(&paths.state_slots()[0]).expect("remove inactive fixture slot"); + assert!(matches!(classify(&paths).slot(0), SlotOutcome::Absent)); + let orphan = paths + .current_root() + .join(".codegraph-index-state-publisher-v2-999-0000000000000001.tmp"); + std::fs::write(&orphan, b"orphan").unwrap(); + let orphan_directory = paths + .current_root() + .join(".codegraph-index-state-publisher-v2-998-0000000000000002.tmp"); + std::fs::create_dir(&orphan_directory).unwrap(); + let directory_marker = orphan_directory.join("keep.bin"); + std::fs::write(&directory_marker, b"directory marker").unwrap(); + let unrelated = paths + .current_root() + .join(".codegraph-index-state-publisher-v2-not-owned.tmp"); + std::fs::write(&unrelated, b"unrelated").unwrap(); + assert_eq!(classify(&paths).status(), &ExtractionStatus::Current); + + let published = publish_index_state(&paths, &lease, StatePhase::Building).unwrap(); + assert_eq!(published.slot, 0); + assert_eq!(std::fs::read(&orphan).unwrap(), b"orphan"); + assert_eq!( + std::fs::read(&directory_marker).unwrap(), + b"directory marker" + ); + assert_eq!(std::fs::read(&unrelated).unwrap(), b"unrelated"); + assert_eq!( + classify(&paths).status(), + &ExtractionStatus::Building { + built: CURRENT_EXTRACTION_VERSION + } + ); +} + +#[test] +fn future_or_corrupt_classification_preserves_preexisting_temp_like_entries() { + for case in ["future", "corrupt"] { + let project = TempProject::new(case); + let paths = project.paths(); + let lease = IndexLease::create_exclusive(&paths, deadline(), || false).unwrap(); + if case == "future" { + write_wire( + &paths.state_slots()[0], + 1, + 3, + 3, + "future", + paths.project_identity(), + ); + } else { + std::fs::write(&paths.state_slots()[0], b"invalid").unwrap(); + } + let preexisting = paths + .current_root() + .join(".codegraph-index-state-publisher-v2-999-0000000000000001.tmp"); + std::fs::write(&preexisting, b"preexisting").unwrap(); + + for requested in [ + StatePhase::Building, + StatePhase::Current, + StatePhase::Uninitialized, + ] { + assert_rejected_without_mutation( + &project, + || publish_index_state(&paths, &lease, requested), + |error| matches!(error, StatePublishError::Refused { .. }), + ); + } + } +} + +#[cfg(unix)] +#[test] +fn temp_symlinks_and_unrelated_names_survive_successful_publication() { + use std::os::unix::fs::symlink; + + let project = TempProject::new("temp-alias"); + let outside = TempProject::new("temp-alias-outside"); + let paths = project.paths(); + let lease = IndexLease::create_exclusive(&paths, deadline(), || false).unwrap(); + publish_index_state(&paths, &lease, StatePhase::Building).unwrap(); + let target = outside.path().join("target.bin"); + std::fs::write(&target, b"external bytes").unwrap(); + let alias = paths + .current_root() + .join(".codegraph-index-state-publisher-v2-999-0000000000000001.tmp"); + symlink(&target, &alias).unwrap(); + let unrelated = paths + .current_root() + .join(".codegraph-index-state-publisher-v2-not-owned.tmp"); + std::fs::write(&unrelated, b"unrelated").unwrap(); + + publish_index_state(&paths, &lease, StatePhase::Building).unwrap(); + assert!( + std::fs::symlink_metadata(&alias) + .unwrap() + .file_type() + .is_symlink() + ); + assert_eq!(std::fs::read(&target).unwrap(), b"external bytes"); + assert_eq!(std::fs::read(&unrelated).unwrap(), b"unrelated"); +} diff --git a/crates/codegraph-store/tests/schema_parity.rs b/crates/codegraph-store/tests/schema_parity.rs index b38ac4a..def17d0 100644 --- a/crates/codegraph-store/tests/schema_parity.rs +++ b/crates/codegraph-store/tests/schema_parity.rs @@ -1,9 +1,20 @@ use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use codegraph_store::Store; const GOLDEN_SCHEMA: &str = include_str!("../../../reference/golden/colby.schema.sql"); +/// Monotonic per-process serial that makes every [`TestDir`] name unique. +/// +/// A wall-clock timestamp alone is NOT sufficient. `SystemTime::now()` has +/// nanosecond resolution on Linux but is only updated at the system timer tick on +/// Windows (~15.6 ms), so two of this target's tests — run concurrently in +/// threads of ONE process, hence one pid — can observe the SAME `as_nanos()` +/// value and derive the same directory name, making the second `create_dir` fail +/// with `ERROR_ALREADY_EXISTS`. The serial cannot collide by construction. +static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + #[test] fn fresh_database_schema_matches_upstream_golden() { let tempdir = TestDir::new(); @@ -181,8 +192,9 @@ struct TestDir { impl TestDir { fn new() -> Self { let name = format!( - "codegraph-store-schema-parity-{}-{}", + "codegraph-store-schema-parity-{}-{}-{}", std::process::id(), + NEXT_TEMP.fetch_add(1, Ordering::Relaxed), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() diff --git a/crates/codegraph-store/tests/store_state_gates.rs b/crates/codegraph-store/tests/store_state_gates.rs new file mode 100644 index 0000000..b16af68 --- /dev/null +++ b/crates/codegraph-store/tests/store_state_gates.rs @@ -0,0 +1,1798 @@ +//! Public contract for lease-retaining, state-gated Store opens. + +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::{self, Receiver}; +use std::time::{Duration, Instant}; + +use codegraph_core::IndexPaths; +use codegraph_store::{ + CURRENT_EXTRACTION_VERSION, CURRENT_STORAGE_PROTOCOL, EXTRACTION_VERSION_KEY, + ExtractionStampIssue, ExtractionStatus, IndexLease, IndexLeaseValidationError, Store, + StoreError, StoreWriteOpen, StoreWritePurpose, checksum_hex, +}; +use rusqlite::{Connection, OpenFlags, OptionalExtension}; +use serde_json::json; + +const CHILD_ACTION: &str = "CODEGRAPH_STORE_GATE_CHILD_ACTION"; +const CHILD_PROJECT: &str = "CODEGRAPH_STORE_GATE_CHILD_PROJECT"; +const CHILD_WAIT: Duration = Duration::from_secs(5); +const SHORT_DEADLINE: Duration = Duration::from_millis(80); +static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + +struct TempProject(PathBuf); + +impl TempProject { + fn new(label: &str) -> Self { + let serial = NEXT_TEMP.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "codegraph-store-state-gates-{label}-{}-{serial}", + std::process::id() + )); + std::fs::create_dir(&path) + .unwrap_or_else(|error| panic!("create temp project {}: {error}", path.display())); + Self(path.canonicalize().expect("canonical temp project")) + } + + fn path(&self) -> &Path { + &self.0 + } + + fn paths(&self) -> IndexPaths { + IndexPaths::resolve(self.path(), None).expect("resolve Store test paths") + } +} + +impl Drop for TempProject { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0) + .unwrap_or_else(|error| panic!("remove temp project {}: {error}", self.0.display())); + } +} + +fn deadline_after(duration: Duration) -> Instant { + Instant::now() + .checked_add(duration) + .expect("Store test deadline") +} + +fn deadline() -> Instant { + deadline_after(CHILD_WAIT) +} + +fn create_namespace(paths: &IndexPaths) -> IndexLease { + IndexLease::create_exclusive(paths, deadline(), || false) + .expect("create permanent Store test namespace") +} + +fn wire_bytes( + sequence: u64, + storage_protocol: u64, + extraction_version: u64, + phase: &str, + owner: &str, +) -> Vec { + serde_json::to_vec(&json!({ + "sequence": sequence, + "storageProtocol": storage_protocol, + "extractionVersion": extraction_version, + "phase": phase, + "projectIdentity": owner, + "checksum": checksum_hex( + sequence, + storage_protocol, + extraction_version, + phase, + owner, + ), + })) + .expect("serialize Store state fixture") +} + +fn write_state(paths: &IndexPaths, storage_protocol: u64, extraction_version: u64, phase: &str) { + std::fs::write( + &paths.state_slots()[0], + wire_bytes( + 1, + storage_protocol, + extraction_version, + phase, + paths.project_identity(), + ), + ) + .expect("write Store state fixture"); +} + +fn create_database(paths: &IndexPaths, stamp: Option<&str>) { + let store = Store::open(&paths.current_db()).expect("create Store SQLite fixture"); + if let Some(stamp) = stamp { + store + .set_project_metadata(EXTRACTION_VERSION_KEY, stamp) + .expect("write extraction stamp fixture"); + } + store + .restore_default_pragmas() + .expect("checkpoint Store SQLite fixture"); + drop(store); + remove_sidecars(paths); +} + +fn remove_sidecars(paths: &IndexPaths) { + let db = paths.current_db(); + for path in [sqlite_sidecar(&db, "-wal"), sqlite_sidecar(&db, "-shm")] { + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => panic!("remove fixture sidecar {}: {error}", path.display()), + } + } +} + +fn sqlite_sidecar(db: &Path, suffix: &str) -> PathBuf { + let mut path = db.as_os_str().to_os_string(); + path.push(suffix); + PathBuf::from(path) +} + +fn stage_current(project: &TempProject, stamp: Option<&str>) -> IndexPaths { + let paths = project.paths(); + let lease = create_namespace(&paths); + create_database(&paths, stamp); + write_state( + &paths, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION, + "current", + ); + drop(lease); + paths +} + +fn stage_state_without_lock(project: &TempProject, phase: &str) -> IndexPaths { + let paths = project.paths(); + std::fs::create_dir_all(paths.current_root()).expect("create lockless state namespace"); + write_state( + &paths, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION, + phase, + ); + paths +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FixtureStatus { + Missing, + Building, + Uninitialized, + Outdated, + Future, + Corrupt, +} + +fn stage_non_current(paths: &IndexPaths, status: FixtureStatus, trap_db: bool) { + let lease = create_namespace(paths); + match status { + FixtureStatus::Missing => {} + FixtureStatus::Building => write_state( + paths, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION, + "building", + ), + FixtureStatus::Uninitialized => write_state( + paths, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION, + "uninitialized", + ), + FixtureStatus::Outdated => write_state( + paths, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ), + FixtureStatus::Future => write_state( + paths, + CURRENT_STORAGE_PROTOCOL + 1, + CURRENT_EXTRACTION_VERSION + 1, + "future-phase", + ), + FixtureStatus::Corrupt => { + std::fs::write(&paths.state_slots()[0], b"not-json").expect("write corrupt state") + } + } + if trap_db { + std::fs::write( + paths.current_db(), + b"not SQLite; opening this trap is a bug", + ) + .expect("write non-SQLite trap DB"); + } + drop(lease); +} + +fn expected_status(status: FixtureStatus) -> ExtractionStatus { + match status { + FixtureStatus::Missing => ExtractionStatus::Missing, + FixtureStatus::Building => ExtractionStatus::Building { + built: CURRENT_EXTRACTION_VERSION, + }, + FixtureStatus::Uninitialized => ExtractionStatus::Uninitialized, + FixtureStatus::Outdated => ExtractionStatus::Outdated { + built: CURRENT_EXTRACTION_VERSION - 1, + }, + FixtureStatus::Future => ExtractionStatus::Future { + built: CURRENT_EXTRACTION_VERSION + 1, + }, + FixtureStatus::Corrupt => { + panic!("Corrupt fixtures carry a typed reason and compare by variant") + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum SnapshotKind { + Directory, + File(Vec), + Symlink(PathBuf), +} + +/// Windows `ERROR_LOCK_VIOLATION`. +/// +/// A byte-range lock taken through `File::try_lock` is ADVISORY on Unix, so an +/// unrelated `read` of the locked file always succeeds there. On Windows the +/// same lock is MANDATORY and any overlapping read is refused with this code. +/// Every write-gate fixture below holds a live EXCLUSIVE lease over the +/// zero-length `index.lock` while it snapshots, so the snapshot walker must +/// survive that refusal. +const ERROR_LOCK_VIOLATION: i32 = 33; + +/// Read one regular file's bytes for a snapshot, tolerating exactly one +/// otherwise-fatal case: a mandatory Windows byte-range lock refusing a read of +/// an EMPTY file. +/// +/// `len` is the length from the `symlink_metadata` already taken for this entry. +/// Metadata queries are not byte-range-locked, so the length stays observable +/// while the lock is held, and a zero-length file has exactly one possible +/// content. Recording it as empty is therefore byte-exact AND independent of +/// whether the lock happened to be held at snapshot time — which the equality +/// assertions need, because a rejected `open_for_write` DROPS the exclusive +/// lease it consumed, so the `before` and `after` snapshots straddle a lock +/// release. Creation, removal and kind changes stay detectable because the entry +/// is still recorded as the regular file it is. Every other read error panics: +/// an unreadable file is a real fault, and a non-empty locked file has content +/// that cannot be observed at all. +fn snapshot_file_bytes(path: &Path, len: u64) -> Vec { + match std::fs::read(path) { + Ok(bytes) => bytes, + Err(error) + if cfg!(windows) && len == 0 && error.raw_os_error() == Some(ERROR_LOCK_VIOLATION) => + { + Vec::new() + } + Err(error) => panic!("snapshot read {}: {error}", path.display()), + } +} + +fn snapshot_tree(root: &Path) -> BTreeMap { + fn walk(root: &Path, directory: &Path, out: &mut BTreeMap) { + let mut paths = std::fs::read_dir(directory) + .unwrap_or_else(|error| panic!("snapshot read_dir {}: {error}", directory.display())) + .map(|entry| { + entry + .unwrap_or_else(|error| { + panic!("snapshot entry in {}: {error}", directory.display()) + }) + .path() + }) + .collect::>(); + paths.sort(); + for path in paths { + let relative = path + .strip_prefix(root) + .unwrap_or_else(|error| panic!("snapshot strip {}: {error}", path.display())) + .to_path_buf(); + let metadata = std::fs::symlink_metadata(&path) + .unwrap_or_else(|error| panic!("snapshot metadata {}: {error}", path.display())); + let file_type = metadata.file_type(); + let kind = if file_type.is_dir() { + SnapshotKind::Directory + } else if file_type.is_file() { + SnapshotKind::File(snapshot_file_bytes(&path, metadata.len())) + } else if file_type.is_symlink() { + SnapshotKind::Symlink(std::fs::read_link(&path).unwrap_or_else(|error| { + panic!("snapshot read_link {}: {error}", path.display()) + })) + } else { + panic!("snapshot unsupported entry kind: {}", path.display()); + }; + assert!( + out.insert(relative, kind).is_none(), + "duplicate snapshot path" + ); + if file_type.is_dir() { + walk(root, &path, out); + } + } + } + + let mut out = BTreeMap::new(); + walk(root, root, &mut out); + out +} + +fn assert_snapshot_unchanged( + before: &BTreeMap, + after: &BTreeMap, +) { + let changed = before + .keys() + .chain(after.keys()) + .filter(|path| before.get(*path) != after.get(*path)) + .cloned() + .collect::>(); + assert!(changed.is_empty(), "filesystem changed at {changed:?}"); +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DatabaseObservation { + schema: Vec<(String, String, String, Option)>, + schema_versions: Vec<(i64, i64, String)>, + project_metadata: Vec<(String, String, i64)>, +} + +fn observe_database_without_touching_namespace(paths: &IndexPaths) -> DatabaseObservation { + let proof = TempProject::new("database-observation"); + let copy = proof.path().join("observation.db"); + let mut bytes = std::fs::read(paths.current_db()).unwrap_or_else(|error| { + panic!( + "read database observation source {}: {error}", + paths.current_db().display() + ) + }); + assert!( + bytes.len() >= 20 && bytes.starts_with(b"SQLite format 3\0"), + "database observation source is not a SQLite main file" + ); + // A checkpointed WAL database keeps WAL format bytes in its main header. + // Normalize only this private copy so observation never asks SQLite's pager + // to inspect or author sidecars beside the authoritative database. + bytes[18] = 1; + bytes[19] = 1; + std::fs::write(©, bytes) + .unwrap_or_else(|error| panic!("write private database observation: {error}")); + let conn = Connection::open_with_flags(©, OpenFlags::SQLITE_OPEN_READ_ONLY) + .expect("open private database observation read-only"); + + let schema = conn + .prepare( + "SELECT type, name, tbl_name, sql FROM sqlite_schema \ + ORDER BY type, name, tbl_name, sql", + ) + .expect("prepare schema observation") + .query_map([], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + }) + .expect("query schema observation") + .collect::>>() + .expect("collect schema observation"); + let schema_versions = conn + .prepare( + "SELECT version, applied_at, description FROM schema_versions \ + ORDER BY version, applied_at, description", + ) + .expect("prepare schema-version observation") + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) + .expect("query schema-version observation") + .collect::>>() + .expect("collect schema-version observation"); + let project_metadata = conn + .prepare( + "SELECT key, value, updated_at FROM project_metadata \ + ORDER BY key, value, updated_at", + ) + .expect("prepare project-metadata observation") + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) + .expect("query project-metadata observation") + .collect::>>() + .expect("collect project-metadata observation"); + + DatabaseObservation { + schema, + schema_versions, + project_metadata, + } +} + +fn assert_read_open_did_not_mutate( + project: &TempProject, + paths: &IndexPaths, + before_tree: &BTreeMap, + before_database: &DatabaseObservation, +) { + for (label, path) in [ + ("WAL", sqlite_sidecar(&paths.current_db(), "-wal")), + ("SHM", sqlite_sidecar(&paths.current_db(), "-shm")), + ] { + match std::fs::symlink_metadata(&path) { + Ok(_) => panic!( + "read/status open must not create a {label} sidecar at {}", + path.display() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => panic!("inspect {label} sidecar {}: {error}", path.display()), + } + } + assert_snapshot_unchanged(before_tree, &snapshot_tree(project.path())); + assert_eq!( + &observe_database_without_touching_namespace(paths), + before_database, + "read/status open changed SQLite schema or metadata rows" + ); +} + +#[test] +fn read_and_status_open_are_sqlite_read_only_and_never_migrate() { + let project = TempProject::new("read-status-never-migrate"); + let paths = stage_current(&project, Some(&CURRENT_EXTRACTION_VERSION.to_string())); + std::fs::write( + &paths.state_slots()[1], + wire_bytes( + 0, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION, + "current", + paths.project_identity(), + ), + ) + .expect("stage older valid Current state slot"); + + // Keep the physical schema at the latest shape but lower its recorded + // version. Any accidental call to the legacy migration path will insert a + // version-7 row and change both the logical and byte snapshots. + let conn = Connection::open_with_flags(paths.current_db(), OpenFlags::SQLITE_OPEN_READ_WRITE) + .expect("open migration-canary fixture"); + conn.execute( + "UPDATE schema_versions \ + SET version = ?1, applied_at = ?2, description = ?3 \ + WHERE version = ?4", + rusqlite::params![ + codegraph_store::migrations::CURRENT_SCHEMA_VERSION - 1, + -7_i64, + "acceptance migration canary", + codegraph_store::migrations::CURRENT_SCHEMA_VERSION, + ], + ) + .expect("stage migration-version canary"); + conn.execute( + "INSERT INTO project_metadata (key, value, updated_at) VALUES (?1, ?2, ?3)", + rusqlite::params!["acceptance_canary", "must-remain-byte-exact", -11_i64], + ) + .expect("stage metadata-repair canary"); + conn.pragma_update(None, "wal_checkpoint", "TRUNCATE") + .expect("checkpoint migration-canary fixture"); + drop(conn); + remove_sidecars(&paths); + + let before_tree = snapshot_tree(project.path()); + let before_database = observe_database_without_touching_namespace(&paths); + assert_eq!( + before_database.schema_versions.last(), + Some(&( + codegraph_store::migrations::CURRENT_SCHEMA_VERSION - 1, + -7, + "acceptance migration canary".to_string(), + )), + "fixture must be migration-eligible if a read path invokes maintenance" + ); + + let store = Store::open_for_read(&paths, deadline(), || false) + .expect("Current read accepts an intentionally migration-eligible schema"); + assert_eq!( + store.schema_version().expect("observe canary version"), + codegraph_store::migrations::CURRENT_SCHEMA_VERSION - 1 + ); + assert_read_open_did_not_mutate(&project, &paths, &before_tree, &before_database); + drop(store); + assert_read_open_did_not_mutate(&project, &paths, &before_tree, &before_database); + + let opened = Store::open_for_status(&paths, deadline(), || false) + .expect("Current status accepts an intentionally migration-eligible schema"); + assert_eq!(opened.status, Some(ExtractionStatus::Current)); + assert!(opened.store().is_some()); + assert_read_open_did_not_mutate(&project, &paths, &before_tree, &before_database); + drop(opened); + assert_read_open_did_not_mutate(&project, &paths, &before_tree, &before_database); + + for open in ["read", "status"] { + let mismatch = TempProject::new("read-status-stamp-mismatch"); + let mismatch_paths = stage_current(&mismatch, Some("1")); + let before_tree = snapshot_tree(mismatch.path()); + let before_database = observe_database_without_touching_namespace(&mismatch_paths); + let error = match open { + "read" => Store::open_for_read(&mismatch_paths, deadline(), || false) + .expect_err("Current/DB stamp mismatch rejects read"), + "status" => Store::open_for_status(&mismatch_paths, deadline(), || false) + .expect_err("Current/DB stamp mismatch rejects status"), + other => panic!("unknown acceptance open {other}"), + }; + assert!(matches!( + error, + StoreError::InvalidExtractionStamp { + path, + issue: ExtractionStampIssue::Mismatch { + expected: CURRENT_EXTRACTION_VERSION, + found: 1, + }, + } if path == mismatch_paths.current_db() + )); + assert_read_open_did_not_mutate(&mismatch, &mismatch_paths, &before_tree, &before_database); + } +} + +#[test] +fn extraction_status_never_opens_sqlite_or_mutates_bytes() { + let project = TempProject::new("classifier-boundary"); + let paths = project.paths(); + stage_non_current(&paths, FixtureStatus::Building, true); + let before = snapshot_tree(project.path()); + + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Building { + built: CURRENT_EXTRACTION_VERSION + } + ); + + assert_snapshot_unchanged(&before, &snapshot_tree(project.path())); +} + +#[test] +fn read_open_rejects_every_non_current_state_without_opening_trap_sqlite() { + for status in [ + FixtureStatus::Missing, + FixtureStatus::Building, + FixtureStatus::Uninitialized, + FixtureStatus::Outdated, + FixtureStatus::Future, + FixtureStatus::Corrupt, + ] { + let project = TempProject::new("read-rejected"); + let paths = project.paths(); + stage_non_current(&paths, status, true); + let before = snapshot_tree(project.path()); + + let error = Store::open_for_read(&paths, deadline(), || false) + .expect_err("every non-Current state must reject read open"); + match (status, error) { + (FixtureStatus::Missing, StoreError::MissingStateWithDatabase { path }) => { + assert_eq!(path, paths.current_db()); + } + (FixtureStatus::Corrupt, StoreError::StateRejected { status }) => { + assert!(matches!(status, ExtractionStatus::Corrupt { .. })); + } + (fixture, StoreError::StateRejected { status }) => { + assert_eq!(status, expected_status(fixture)); + } + (_, other) => panic!("unexpected read rejection: {other:?}"), + } + assert_snapshot_unchanged(&before, &snapshot_tree(project.path())); + } +} + +#[test] +fn current_read_is_read_only_retains_shared_lease_and_preserves_db_observation() { + let project = TempProject::new("current-read"); + let paths = stage_current(&project, Some(&CURRENT_EXTRACTION_VERSION.to_string())); + let before_tree = snapshot_tree(project.path()); + + let store = Store::open_for_read(&paths, deadline(), || false).expect("open Current read"); + assert_eq!( + store.schema_version().expect("read schema version"), + codegraph_store::migrations::CURRENT_SCHEMA_VERSION + ); + assert_eq!( + store + .get_project_metadata(EXTRACTION_VERSION_KEY) + .expect("read exact metadata through retained read Store"), + Some(CURRENT_EXTRACTION_VERSION.to_string()) + ); + assert_eq!( + run_exclusive_probe(project.path(), SHORT_DEADLINE), + "TIMED_OUT", + "Store must retain its shared lease for the whole SQLite lifetime" + ); + assert!( + store + .stamp_extraction_version() + .is_err_and(|error| matches!(error, StoreError::StampNotAuthorized)) + ); + assert_snapshot_unchanged(&before_tree, &snapshot_tree(project.path())); + drop(store); + + assert_eq!( + run_exclusive_probe(project.path(), CHILD_WAIT), + "ACQUIRED", + "dropping Store must close SQLite and release its final lease owner" + ); + assert_snapshot_unchanged(&before_tree, &snapshot_tree(project.path())); +} + +#[test] +fn current_read_and_status_reject_tombstone_without_mutating_any_artifact() { + let project = TempProject::new("current-tombstoned"); + let paths = stage_current(&project, Some(&CURRENT_EXTRACTION_VERSION.to_string())); + std::fs::write(paths.tombstone(), b"interrupted-uninit marker") + .expect("stage Current+tombstone contradiction"); + let before = snapshot_tree(project.path()); + + for error in [ + Store::open_for_read(&paths, deadline(), || false) + .expect_err("Current+tombstone must reject read"), + Store::open_for_status(&paths, deadline(), || false) + .expect_err("Current+tombstone must reject status corroboration"), + ] { + assert!(matches!( + error, + StoreError::CurrentTombstoned { path } if path == paths.tombstone() + )); + assert_snapshot_unchanged(&before, &snapshot_tree(project.path())); + } +} + +#[test] +fn current_read_stamp_failures_are_typed_and_byte_nonmutating() { + for (stamp, expected) in [ + (None, "missing"), + (Some("02"), "malformed"), + (Some("1"), "mismatch"), + ] { + let project = TempProject::new("bad-stamp"); + let paths = stage_current(&project, stamp); + let before_tree = snapshot_tree(project.path()); + + let error = Store::open_for_read(&paths, deadline(), || false) + .expect_err("bad extraction stamp must fail closed"); + assert!(matches!( + (expected, error), + ( + "missing", + StoreError::InvalidExtractionStamp { + issue: ExtractionStampIssue::Missing, + .. + } + ) | ( + "malformed", + StoreError::InvalidExtractionStamp { + issue: ExtractionStampIssue::Malformed { .. }, + .. + } + ) | ( + "mismatch", + StoreError::InvalidExtractionStamp { + issue: ExtractionStampIssue::Mismatch { + expected: CURRENT_EXTRACTION_VERSION, + found: 1 + }, + .. + } + ) + )); + assert_snapshot_unchanged(&before_tree, &snapshot_tree(project.path())); + } +} + +#[test] +fn status_reports_non_current_states_without_opening_sqlite() { + for status in [ + FixtureStatus::Missing, + FixtureStatus::Building, + FixtureStatus::Uninitialized, + FixtureStatus::Outdated, + FixtureStatus::Future, + FixtureStatus::Corrupt, + ] { + let project = TempProject::new("status-non-current"); + let paths = project.paths(); + stage_non_current(&paths, status, true); + let before = snapshot_tree(project.path()); + + let result = Store::open_for_status(&paths, deadline(), || false); + if status == FixtureStatus::Missing { + assert!(matches!( + result, + Err(StoreError::MissingStateWithDatabase { path }) if path == paths.current_db() + )); + assert_snapshot_unchanged(&before, &snapshot_tree(project.path())); + continue; + } + let opened = result.expect("non-Current state with authenticated residue is typed data"); + assert!(!opened.rebuilding); + assert!(opened.store().is_none()); + if status == FixtureStatus::Corrupt { + assert!(matches!( + opened.status, + Some(ExtractionStatus::Corrupt { .. }) + )); + } else { + assert_eq!(opened.status, Some(expected_status(status))); + } + assert_snapshot_unchanged(&before, &snapshot_tree(project.path())); + } +} + +#[test] +fn status_missing_without_a_namespace_is_data_and_creates_nothing() { + let project = TempProject::new("status-absent"); + let paths = project.paths(); + let before = snapshot_tree(project.path()); + + let status = Store::open_for_status(&paths, deadline(), || false) + .expect("fully absent namespace is Missing status data"); + + assert_eq!(status.status, Some(ExtractionStatus::Missing)); + assert!(!status.rebuilding); + assert!(status.store().is_none()); + assert_snapshot_unchanged(&before, &snapshot_tree(project.path())); +} + +#[test] +fn status_current_uses_the_same_read_only_stamp_corroboration() { + let project = TempProject::new("status-current"); + let paths = stage_current(&project, Some(&CURRENT_EXTRACTION_VERSION.to_string())); + let before = snapshot_tree(project.path()); + let opened = Store::open_for_status(&paths, deadline(), || false).expect("Current status"); + assert_eq!(opened.status, Some(ExtractionStatus::Current)); + assert!(!opened.rebuilding); + assert!(opened.store().is_some()); + drop(opened); + assert_snapshot_unchanged(&before, &snapshot_tree(project.path())); + + for (stamp, expected) in [ + (None, "missing"), + (Some("02"), "malformed"), + (Some("1"), "mismatch"), + ] { + let project = TempProject::new("status-current-bad-stamp"); + let paths = stage_current(&project, stamp); + let before = snapshot_tree(project.path()); + let error = Store::open_for_status(&paths, deadline(), || false) + .expect_err("Current status must corroborate its DB stamp"); + assert!(matches!( + (expected, error), + ( + "missing", + StoreError::InvalidExtractionStamp { + issue: ExtractionStampIssue::Missing, + .. + } + ) | ( + "malformed", + StoreError::InvalidExtractionStamp { + issue: ExtractionStampIssue::Malformed { .. }, + .. + } + ) | ( + "mismatch", + StoreError::InvalidExtractionStamp { + issue: ExtractionStampIssue::Mismatch { + expected: CURRENT_EXTRACTION_VERSION, + found: 1 + }, + .. + } + ) + )); + assert_snapshot_unchanged(&before, &snapshot_tree(project.path())); + } +} + +#[test] +fn busy_status_returns_rebuilding_without_opening_sqlite() { + let project = TempProject::new("status-busy"); + let paths = stage_current(&project, Some(&CURRENT_EXTRACTION_VERSION.to_string())); + let holder = Holder::spawn(project.path()); + let before = snapshot_tree(project.path()); + + let status = Store::open_for_status(&paths, deadline_after(SHORT_DEADLINE), || false) + .expect("busy writer is typed status data"); + + assert_eq!(status.status, None); + assert!(status.rebuilding); + assert!(status.store().is_none()); + assert_snapshot_unchanged(&before, &snapshot_tree(project.path())); + holder.release(); +} + +#[test] +fn write_purpose_matrix_is_typed_and_rejected_pairs_are_nonmutating() { + let cases = [ + ( + FixtureStatus::Missing, + StoreWritePurpose::CurrentMutation, + "reject", + ), + ( + FixtureStatus::Missing, + StoreWritePurpose::FullRebuild, + "rebuild", + ), + ( + FixtureStatus::Missing, + StoreWritePurpose::UninitContinuation, + "reject", + ), + ( + FixtureStatus::Building, + StoreWritePurpose::CurrentMutation, + "reject", + ), + ( + FixtureStatus::Building, + StoreWritePurpose::FullRebuild, + "rebuild", + ), + ( + FixtureStatus::Building, + StoreWritePurpose::UninitContinuation, + "uninit", + ), + ( + FixtureStatus::Outdated, + StoreWritePurpose::CurrentMutation, + "reject", + ), + ( + FixtureStatus::Outdated, + StoreWritePurpose::FullRebuild, + "rebuild", + ), + ( + FixtureStatus::Outdated, + StoreWritePurpose::UninitContinuation, + "reject", + ), + ( + FixtureStatus::Uninitialized, + StoreWritePurpose::CurrentMutation, + "reject", + ), + ( + FixtureStatus::Uninitialized, + StoreWritePurpose::FullRebuild, + "rebuild", + ), + ( + FixtureStatus::Uninitialized, + StoreWritePurpose::UninitContinuation, + "uninit", + ), + ]; + + for (status, purpose, expected) in cases { + let project = TempProject::new("write-purpose"); + let paths = project.paths(); + stage_non_current(&paths, status, false); + let lease = IndexLease::acquire_exclusive_existing(&paths, deadline(), || false) + .expect("acquire write matrix lease"); + let before = snapshot_tree(project.path()); + let result = Store::open_for_write(&paths, lease, purpose); + match (expected, result) { + ( + "reject", + Err(StoreError::WritePurposeRejected { + purpose: got, + status: got_status, + }), + ) => { + assert_eq!(got, purpose); + assert_eq!(got_status, expected_status(status)); + } + ("rebuild", Ok(StoreWriteOpen::FullRebuildRequired(authorization))) => { + assert_eq!(authorization.purpose(), purpose); + assert_eq!(authorization.status(), &expected_status(status)); + assert!(authorization.retains_exclusive_lease()); + assert_eq!( + run_exclusive_probe(project.path(), SHORT_DEADLINE), + "TIMED_OUT" + ); + drop(authorization); + } + ("uninit", Ok(StoreWriteOpen::UninitContinuation(authorization))) => { + assert_eq!(authorization.purpose(), purpose); + assert_eq!(authorization.status(), &expected_status(status)); + assert!(authorization.retains_exclusive_lease()); + assert_eq!( + run_exclusive_probe(project.path(), SHORT_DEADLINE), + "TIMED_OUT" + ); + drop(authorization); + } + (_, other) => panic!("unexpected write matrix outcome: {other:?}"), + } + assert_snapshot_unchanged(&before, &snapshot_tree(project.path())); + } +} + +#[test] +fn current_state_accepts_only_current_mutation_or_explicit_full_rebuild() { + let project = TempProject::new("current-purpose-matrix"); + let paths = stage_current(&project, Some(&CURRENT_EXTRACTION_VERSION.to_string())); + + let rebuild_lease = IndexLease::acquire_exclusive_existing(&paths, deadline(), || false) + .expect("acquire Current full-rebuild lease"); + let before = snapshot_tree(project.path()); + let StoreWriteOpen::FullRebuildRequired(authorization) = + Store::open_for_write(&paths, rebuild_lease, StoreWritePurpose::FullRebuild) + .expect("Current state allows an explicitly requested full rebuild") + else { + panic!("Current full rebuild must return opaque authorization"); + }; + assert_eq!(authorization.status(), &ExtractionStatus::Current); + assert_eq!(authorization.purpose(), StoreWritePurpose::FullRebuild); + assert_snapshot_unchanged(&before, &snapshot_tree(project.path())); + drop(authorization); + + let uninit_lease = IndexLease::acquire_exclusive_existing(&paths, deadline(), || false) + .expect("acquire Current continuation rejection lease"); + let before = snapshot_tree(project.path()); + let StoreWriteOpen::UninitContinuation(authorization) = + Store::open_for_write(&paths, uninit_lease, StoreWritePurpose::UninitContinuation) + .expect("Current state authorizes a new uninit under the retained lease") + else { + panic!("Current uninit must return opaque authorization"); + }; + assert_eq!(authorization.status(), &ExtractionStatus::Current); + assert_eq!( + authorization.purpose(), + StoreWritePurpose::UninitContinuation + ); + drop(authorization); + assert_snapshot_unchanged(&before, &snapshot_tree(project.path())); +} + +#[test] +fn current_full_rebuild_requires_the_same_database_corroboration_as_a_read() { + for (fixture, stamp, expected) in [ + ("missing-db", None, "missing-db"), + ("tombstone", Some("2"), "tombstone"), + ("missing-stamp", None, "missing-stamp"), + ("malformed-stamp", Some("02"), "malformed-stamp"), + ("mismatched-stamp", Some("1"), "mismatched-stamp"), + ] { + let project = TempProject::new(fixture); + let paths = if fixture == "missing-db" { + let paths = project.paths(); + let initial = create_namespace(&paths); + write_state( + &paths, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION, + "current", + ); + drop(initial); + paths + } else { + let paths = stage_current(&project, stamp); + if fixture == "tombstone" { + std::fs::write(paths.tombstone(), b"interrupted uninit") + .expect("stage Current tombstone contradiction"); + } + paths + }; + let lease = IndexLease::acquire_exclusive_existing(&paths, deadline(), || false) + .expect("acquire inconsistent Current rebuild lease"); + let before = snapshot_tree(project.path()); + + let error = Store::open_for_write(&paths, lease, StoreWritePurpose::FullRebuild) + .expect_err("inconsistent Current artifacts must not authorize a full rebuild"); + let matched = match expected { + "missing-db" => { + matches!( + &error, + StoreError::CurrentDatabaseMissing { path } if path == &paths.current_db() + ) + } + "tombstone" => matches!( + &error, + StoreError::CurrentTombstoned { path } if path == &paths.tombstone() + ), + "missing-stamp" => matches!( + &error, + StoreError::InvalidExtractionStamp { + issue: ExtractionStampIssue::Missing, + .. + } + ), + "malformed-stamp" => matches!( + &error, + StoreError::InvalidExtractionStamp { + issue: ExtractionStampIssue::Malformed { .. }, + .. + } + ), + "mismatched-stamp" => matches!( + &error, + StoreError::InvalidExtractionStamp { + issue: ExtractionStampIssue::Mismatch { + expected: CURRENT_EXTRACTION_VERSION, + found: 1 + }, + .. + } + ), + other => panic!("unknown Current contradiction fixture {other}"), + }; + assert!(matched, "unexpected {expected} rejection: {error:?}"); + assert_snapshot_unchanged(&before, &snapshot_tree(project.path())); + } +} + +#[test] +fn current_writer_is_state_gated_retains_lease_and_stamps_the_exact_owned_value() { + let project = TempProject::new("current-writer"); + let paths = stage_current(&project, Some(&CURRENT_EXTRACTION_VERSION.to_string())); + let lease = IndexLease::acquire_exclusive_existing(&paths, deadline(), || false) + .expect("acquire Current writer lease"); + let opened = Store::open_for_write(&paths, lease, StoreWritePurpose::CurrentMutation) + .expect("open Current writer"); + let StoreWriteOpen::Current(store) = opened else { + panic!("Current mutation must return an open Store"); + }; + assert_eq!( + run_exclusive_probe(project.path(), SHORT_DEADLINE), + "TIMED_OUT" + ); + store + .set_project_metadata(EXTRACTION_VERSION_KEY, "1") + .expect("stage stale stamp under writer lease"); + store + .stamp_extraction_version() + .expect("authorized writer stamps exact current version"); + assert_eq!( + store + .get_project_metadata(EXTRACTION_VERSION_KEY) + .expect("read stamped value"), + Some(CURRENT_EXTRACTION_VERSION.to_string()) + ); + drop(store); + assert_eq!(run_exclusive_probe(project.path(), CHILD_WAIT), "ACQUIRED"); + + let legacy = Store::open(&project.path().join("legacy.db")).expect("legacy Store fixture"); + assert!(matches!( + legacy.stamp_extraction_version(), + Err(StoreError::StampNotAuthorized) + )); +} + +#[test] +fn writer_stamp_revalidates_the_retained_fixed_lock_before_metadata_mutation() { + let project = TempProject::new("stamp-replaced-lock"); + let paths = stage_current(&project, Some(&CURRENT_EXTRACTION_VERSION.to_string())); + let lease = IndexLease::acquire_exclusive_existing(&paths, deadline(), || false) + .expect("acquire stamp fixture lease"); + let StoreWriteOpen::Current(store) = + Store::open_for_write(&paths, lease, StoreWritePurpose::CurrentMutation) + .expect("open stamp fixture writer") + else { + panic!("Current mutation must return an open Store"); + }; + let displaced = paths.current_root().join("displaced-before-stamp.lock"); + std::fs::rename(paths.permanent_lock(), &displaced).expect("displace retained lock handle"); + std::fs::write(paths.permanent_lock(), b"replacement before stamp") + .expect("install replacement before stamp"); + let before = snapshot_tree(project.path()); + + let error = store + .stamp_extraction_version() + .expect_err("stale retained lease must not authorize metadata stamping"); + assert!(matches!( + error, + StoreError::LeaseValidation(IndexLeaseValidationError::PermanentLockChanged { .. }) + )); + assert_snapshot_unchanged(&before, &snapshot_tree(project.path())); + drop(store); +} + +#[test] +fn write_open_validates_shared_wrong_parent_and_replaced_lock_before_mutation() { + let shared_project = TempProject::new("write-shared"); + let shared_paths = stage_current( + &shared_project, + Some(&CURRENT_EXTRACTION_VERSION.to_string()), + ); + let shared = IndexLease::acquire_shared_existing(&shared_paths, deadline(), || false) + .expect("acquire shared fixture lease"); + let before = snapshot_tree(shared_project.path()); + assert!(matches!( + Store::open_for_write(&shared_paths, shared, StoreWritePurpose::CurrentMutation), + Err(StoreError::LeaseValidation( + IndexLeaseValidationError::SharedLease + )) + )); + assert_snapshot_unchanged(&before, &snapshot_tree(shared_project.path())); + + let other_project = TempProject::new("write-wrong-parent"); + let other_paths = stage_current( + &other_project, + Some(&CURRENT_EXTRACTION_VERSION.to_string()), + ); + let wrong = IndexLease::acquire_exclusive_existing(&shared_paths, deadline(), || false) + .expect("acquire wrong-parent fixture lease"); + let before = snapshot_tree(other_project.path()); + assert!(matches!( + Store::open_for_write(&other_paths, wrong, StoreWritePurpose::CurrentMutation), + Err(StoreError::LeaseValidation( + IndexLeaseValidationError::WrongDbParent + )) + )); + assert_snapshot_unchanged(&before, &snapshot_tree(other_project.path())); + + let replaced_project = TempProject::new("write-replaced-lock"); + let replaced_paths = stage_current( + &replaced_project, + Some(&CURRENT_EXTRACTION_VERSION.to_string()), + ); + let stale = IndexLease::acquire_exclusive_existing(&replaced_paths, deadline(), || false) + .expect("acquire soon-stale lease"); + let displaced = replaced_paths.current_root().join("displaced-index.lock"); + std::fs::rename(replaced_paths.permanent_lock(), &displaced).expect("displace held lock"); + std::fs::write(replaced_paths.permanent_lock(), b"replacement lock") + .expect("install replacement lock"); + let before = snapshot_tree(replaced_project.path()); + assert!(matches!( + Store::open_for_write(&replaced_paths, stale, StoreWritePurpose::CurrentMutation), + Err(StoreError::LeaseValidation( + IndexLeaseValidationError::PermanentLockChanged { .. } + )) + )); + assert_snapshot_unchanged(&before, &snapshot_tree(replaced_project.path())); + let fresh = IndexLease::acquire_exclusive_existing(&replaced_paths, deadline(), || false) + .expect("replacement fixed lock is independently acquirable"); + drop(fresh); +} + +#[test] +fn future_and_corrupt_writer_opens_are_typed_and_byte_nonmutating_for_every_purpose() { + for status in [FixtureStatus::Future, FixtureStatus::Corrupt] { + let project = TempProject::new("write-refused-state"); + let paths = project.paths(); + stage_non_current(&paths, status, true); + for purpose in [ + StoreWritePurpose::CurrentMutation, + StoreWritePurpose::FullRebuild, + StoreWritePurpose::UninitContinuation, + ] { + let lease = IndexLease::acquire_exclusive_existing(&paths, deadline(), || false) + .expect("acquire refusal fixture lease"); + let before = snapshot_tree(project.path()); + let error = Store::open_for_write(&paths, lease, purpose) + .expect_err("Future/Corrupt writer open must fail closed"); + match (status, error) { + (FixtureStatus::Future, StoreError::StateRejected { status }) => { + assert_eq!(status, expected_status(FixtureStatus::Future)); + } + (FixtureStatus::Corrupt, StoreError::StateRejected { status }) => { + assert!(matches!(status, ExtractionStatus::Corrupt { .. })); + } + (_, other) => panic!("unexpected refused writer error: {other:?}"), + } + assert_snapshot_unchanged(&before, &snapshot_tree(project.path())); + } + } +} + +#[test] +fn missing_state_with_any_existing_database_artifact_fails_closed() { + for suffix in ["", "-wal", "-shm"] { + let project = TempProject::new("missing-with-artifact"); + let paths = project.paths(); + let initial = create_namespace(&paths); + drop(initial); + let db = paths.current_db(); + let artifact = PathBuf::from(format!("{}{suffix}", db.display())); + std::fs::write(&artifact, b"reserved unknown database artifact") + .expect("write reserved DB artifact"); + let lease = IndexLease::acquire_exclusive_existing(&paths, deadline(), || false) + .expect("acquire Missing fixture lease"); + let before = snapshot_tree(project.path()); + + let error = Store::open_for_write(&paths, lease, StoreWritePurpose::FullRebuild) + .expect_err("Missing plus DB artifact is not a fresh writable namespace"); + assert!(matches!( + error, + StoreError::MissingStateWithDatabase { path } if path == artifact + )); + assert_snapshot_unchanged(&before, &snapshot_tree(project.path())); + } +} + +#[test] +fn missing_state_with_database_artifact_rejects_read_and_status_with_or_without_lock() { + for with_lock in [false, true] { + for suffix in ["", "-wal", "-shm"] { + let project = TempProject::new("missing-artifact-read-status"); + let paths = project.paths(); + if with_lock { + drop(create_namespace(&paths)); + } else { + std::fs::create_dir_all(paths.current_root()) + .expect("create lockless artifact namespace"); + } + let artifact = PathBuf::from(format!("{}{suffix}", paths.current_db().display())); + std::fs::write(&artifact, b"reserved unknown database artifact") + .expect("stage unknown database artifact"); + let before = snapshot_tree(project.path()); + + for error in [ + Store::open_for_read(&paths, deadline(), || false) + .expect_err("Missing artifact must reject read"), + Store::open_for_status(&paths, deadline(), || false) + .expect_err("Missing artifact must reject status"), + ] { + assert!(matches!( + error, + StoreError::MissingStateWithDatabase { path } if path == artifact + )); + assert_snapshot_unchanged(&before, &snapshot_tree(project.path())); + } + } + } +} + +#[test] +fn persisted_state_without_permanent_lock_is_typed_and_byte_nonmutating() { + for (phase, expected) in [ + ("current", ExtractionStatus::Current), + ( + "building", + ExtractionStatus::Building { + built: CURRENT_EXTRACTION_VERSION, + }, + ), + ("uninitialized", ExtractionStatus::Uninitialized), + ] { + let project = TempProject::new("state-without-lock"); + let paths = stage_state_without_lock(&project, phase); + let before = snapshot_tree(project.path()); + + for error in [ + Store::open_for_read(&paths, deadline(), || false) + .expect_err("persisted lockless state must reject read"), + Store::open_for_status(&paths, deadline(), || false) + .expect_err("persisted lockless state must reject status"), + ] { + assert!(matches!( + error, + StoreError::StateWithoutPermanentLock { status, path } + if status == expected && path == paths.permanent_lock() + )); + assert_snapshot_unchanged(&before, &snapshot_tree(project.path())); + } + } +} + +#[test] +fn current_with_committed_wal_fails_closed_without_ignoring_or_mutating_sidecars() { + let project = TempProject::new("current-committed-wal"); + let paths = stage_current(&project, Some(&CURRENT_EXTRACTION_VERSION.to_string())); + run_crashed_current_writer(project.path()); + let wal_path = PathBuf::from(format!("{}-wal", paths.current_db().display())); + assert!( + std::fs::metadata(&wal_path) + .expect("committed WAL exists") + .len() + > 0, + "fixture must carry committed WAL frames" + ); + let live = Connection::open_with_flags(paths.current_db(), OpenFlags::SQLITE_OPEN_READ_ONLY) + .expect("open live WAL-aware proof connection"); + assert_eq!( + live.query_row( + "SELECT value FROM project_metadata WHERE key = 'wal_only_probe'", + [], + |row| row.get::<_, String>(0), + ) + .optional() + .expect("live SQLite observes committed WAL data"), + Some("committed".to_string()) + ); + drop(live); + + let proof = TempProject::new("main-image-proof"); + let main_only = proof.path().join("main-only.db"); + let mut bytes = std::fs::read(paths.current_db()).expect("read checkpointed main DB image"); + assert!(bytes.len() >= 20 && bytes.starts_with(b"SQLite format 3\0")); + bytes[18] = 1; + bytes[19] = 1; + std::fs::write(&main_only, bytes).expect("write private main-only proof image"); + let main_conn = Connection::open_with_flags(&main_only, OpenFlags::SQLITE_OPEN_READ_ONLY) + .expect("open main-only proof image"); + let main_value = main_conn + .query_row( + "SELECT value FROM project_metadata WHERE key = 'wal_only_probe'", + [], + |row| row.get::<_, String>(0), + ) + .optional() + .expect("query main-only proof image"); + assert_eq!( + main_value, None, + "deserializing only main-file bytes would silently miss committed WAL data" + ); + + let before = snapshot_tree(project.path()); + for error in [ + Store::open_for_read(&paths, deadline(), || false) + .expect_err("Current with committed WAL must fail closed"), + Store::open_for_status(&paths, deadline(), || false) + .expect_err("Current status with committed WAL must fail closed"), + ] { + assert!(matches!( + error, + StoreError::CurrentWithDatabaseSidecar { path } if path == wal_path + )); + assert_snapshot_unchanged(&before, &snapshot_tree(project.path())); + } +} + +/// Build a project whose DIRECTORY NAME carries bytes that are not valid UTF-8. +/// +/// A Unix path is an arbitrary byte string, so `Path::display()` renders such a +/// name LOSSILY (each invalid byte becomes U+FFFD) and every sidecar path built +/// from that rendering names a file that does not exist. Windows is excluded +/// from these fixtures because its paths are UTF-16 and the byte-oriented +/// `OsString` constructors used here (`std::os::unix::ffi`) do not exist there. +#[cfg(unix)] +fn non_utf8_project(outer: &TempProject, label: &str) -> TempProject { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let mut name = OsString::from_vec(format!("{label}-").into_bytes()); + name.push(OsString::from_vec(vec![0x80, 0xff])); + let path = outer.0.join(name); + std::fs::create_dir(&path) + .unwrap_or_else(|error| panic!("create non-UTF-8 project {}: {error}", path.display())); + TempProject( + path.canonicalize() + .unwrap_or_else(|error| panic!("canonicalize non-UTF-8 project: {error}")), + ) +} + +/// The path a LOSSY `Path::display()` round-trip would name. On a non-UTF-8 +/// project this is a different file from [`sqlite_sidecar`]'s native path, and +/// nothing in production may read, write, or delete it. +#[cfg(unix)] +fn lossy_sidecar(db: &Path, suffix: &str) -> PathBuf { + PathBuf::from(format!("{}{suffix}", db.display())) +} + +#[cfg(unix)] +fn probe_row_is_folded_into_main_file(paths: &IndexPaths) -> bool { + observe_database_without_touching_namespace(paths) + .project_metadata + .iter() + .any(|(key, _, _)| key == "wal_only_probe") +} + +/// The strict `Current` read gate must refuse a namespace whose committed +/// write-ahead log sits beside a database under a NON-UTF-8 project path. +/// +/// Detecting that sidecar through `Path::display()` looks at a path that does +/// not exist, so the gate would conclude "sidecar-free", admit the namespace, +/// and serve an index missing every row that lives only in the log. +#[cfg(unix)] +#[test] +fn non_utf8_current_with_committed_wal_fails_closed() { + let outer = TempProject::new("non-utf8-wal-gate"); + let project = non_utf8_project(&outer, "proj"); + let paths = stage_current(&project, Some(&CURRENT_EXTRACTION_VERSION.to_string())); + run_crashed_current_writer(project.path()); + + let wal_path = sqlite_sidecar(&paths.current_db(), "-wal"); + assert_ne!( + wal_path, + lossy_sidecar(&paths.current_db(), "-wal"), + "fixture must expose a lossy rendering distinct from the native path" + ); + assert!( + std::fs::metadata(&wal_path) + .expect("committed WAL exists beside the non-UTF-8 database") + .len() + > 0, + "fixture must carry committed WAL frames" + ); + assert!( + !probe_row_is_folded_into_main_file(&paths), + "the probe row must live ONLY in the write-ahead log" + ); + + let before = snapshot_tree(project.path()); + for error in [ + Store::open_for_read(&paths, deadline(), || false) + .expect_err("non-UTF-8 Current with committed WAL must fail closed"), + Store::open_for_status(&paths, deadline(), || false) + .expect_err("non-UTF-8 Current status with committed WAL must fail closed"), + ] { + assert!( + matches!( + &error, + StoreError::CurrentWithDatabaseSidecar { path } if path == &wal_path + ), + "unexpected non-UTF-8 sidecar refusal: {error:?}" + ); + assert_snapshot_unchanged(&before, &snapshot_tree(project.path())); + } +} + +/// The dead-owner recovery path must detect and FOLD a committed write-ahead log +/// on a non-UTF-8 project path, so the row that existed only in that log becomes +/// readable instead of being silently skipped by a stale main-file-only read. +#[cfg(unix)] +#[test] +fn non_utf8_dead_owner_wal_is_folded_and_then_readable() { + let outer = TempProject::new("non-utf8-wal-recovery"); + let project = non_utf8_project(&outer, "proj"); + let paths = stage_current(&project, Some(&CURRENT_EXTRACTION_VERSION.to_string())); + run_crashed_current_writer(project.path()); + + let wal = sqlite_sidecar(&paths.current_db(), "-wal"); + let shm = sqlite_sidecar(&paths.current_db(), "-shm"); + assert!( + std::fs::metadata(&wal).expect("planted WAL exists").len() > 0, + "a zero-byte sidecar would prove nothing" + ); + assert!( + !probe_row_is_folded_into_main_file(&paths), + "the probe row must live ONLY in the write-ahead log before recovery" + ); + + assert!( + Store::recover_stale_current_sidecars(&paths, deadline(), || false) + .expect("recovery over a dead owner's residue must not error"), + "a dead owner's committed WAL must be DETECTED on a non-UTF-8 path" + ); + assert!(!wal.exists(), "checkpointed WAL must be removed"); + assert!(!shm.exists(), "derived SHM must be removed"); + assert!( + probe_row_is_folded_into_main_file(&paths), + "recovery must fold the log into the main database file, not discard it" + ); + + let store = Store::open_for_read(&paths, deadline(), || false) + .expect("strict gate admits the recovered non-UTF-8 namespace"); + assert_eq!( + store + .get_project_metadata("wal_only_probe") + .expect("read the WAL-only probe row"), + Some("committed".to_string()), + "the WAL-only row must be visible through the strict read path" + ); + drop(store); + + assert!( + paths.permanent_lock().is_file(), + "the permanent index lock must survive recovery" + ); + assert!( + paths.state_slots().iter().any(|slot| slot.is_file()), + "the published state must survive recovery" + ); + assert!( + !paths.tombstone().exists(), + "recovery must not author a tombstone" + ); + assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Current); +} + +/// Recovery must operate on the NATIVE sidecar only. A file whose name matches +/// the LOSSY `display()` rendering is a different, unrelated file and must be +/// neither checkpointed nor deleted. +#[cfg(unix)] +#[test] +fn non_utf8_recovery_never_touches_the_lossy_lookalike_sidecars() { + let outer = TempProject::new("non-utf8-lookalike"); + let project = non_utf8_project(&outer, "proj"); + let paths = stage_current(&project, Some(&CURRENT_EXTRACTION_VERSION.to_string())); + run_crashed_current_writer(project.path()); + + let native_wal = sqlite_sidecar(&paths.current_db(), "-wal"); + let lossy_wal = lossy_sidecar(&paths.current_db(), "-wal"); + let lossy_shm = lossy_sidecar(&paths.current_db(), "-shm"); + assert_ne!( + native_wal, lossy_wal, + "fixture must expose a lossy rendering distinct from the native path" + ); + std::fs::create_dir_all(lossy_wal.parent().expect("lossy sidecar parent")) + .expect("create lossy-lookalike parent directory"); + std::fs::write(&lossy_wal, b"lookalike WAL must survive").expect("write lookalike WAL"); + std::fs::write(&lossy_shm, b"lookalike SHM must survive").expect("write lookalike SHM"); + + assert!( + Store::recover_stale_current_sidecars(&paths, deadline(), || false) + .expect("recovery over a dead owner's residue must not error"), + "a dead owner's committed WAL must be DETECTED on a non-UTF-8 path" + ); + + assert!(!native_wal.exists(), "native WAL must be checkpointed away"); + assert_eq!( + std::fs::read(&lossy_wal).expect("read preserved lookalike WAL"), + b"lookalike WAL must survive", + "the lossy lookalike WAL must be untouched" + ); + assert_eq!( + std::fs::read(&lossy_shm).expect("read preserved lookalike SHM"), + b"lookalike SHM must survive", + "the lossy lookalike SHM must be untouched" + ); + Store::open_for_read(&paths, deadline(), || false) + .expect("strict gate admits the recovered non-UTF-8 namespace") + .get_project_metadata("wal_only_probe") + .expect("read the WAL-only probe row") + .expect("the folded WAL-only row must be readable"); +} + +/// The `Missing`-state artifact guard reads the same sidecar names, so it must +/// also see them losslessly: an unexplained `-wal`/`-shm` beside a stateless +/// namespace on a non-UTF-8 path is not a fresh writable namespace. +#[cfg(unix)] +#[test] +fn missing_state_with_non_utf8_database_sidecar_fails_closed() { + for suffix in ["-wal", "-shm"] { + let outer = TempProject::new("non-utf8-missing-artifact"); + let project = non_utf8_project(&outer, "proj"); + let paths = project.paths(); + drop(create_namespace(&paths)); + let artifact = sqlite_sidecar(&paths.current_db(), suffix); + assert_ne!( + artifact, + lossy_sidecar(&paths.current_db(), suffix), + "fixture must expose a lossy rendering distinct from the native path" + ); + std::fs::write(&artifact, b"reserved unknown database artifact") + .expect("stage unknown database artifact"); + let before = snapshot_tree(project.path()); + + let lease = IndexLease::acquire_exclusive_existing(&paths, deadline(), || false) + .expect("acquire Missing fixture lease"); + let write_error = Store::open_for_write(&paths, lease, StoreWritePurpose::FullRebuild) + .expect_err("Missing plus a DB sidecar is not a fresh writable namespace"); + for error in [ + write_error, + Store::open_for_read(&paths, deadline(), || false) + .expect_err("Missing artifact must reject read"), + Store::open_for_status(&paths, deadline(), || false) + .expect_err("Missing artifact must reject status"), + ] { + assert!( + matches!( + &error, + StoreError::MissingStateWithDatabase { path } if path == &artifact + ), + "unexpected non-UTF-8 Missing-artifact refusal: {error:?}" + ); + assert_snapshot_unchanged(&before, &snapshot_tree(project.path())); + } + } +} + +/// Child-process entry point used by deterministic lock contention tests. +#[test] +fn store_gate_child_process() { + let Ok(action) = std::env::var(CHILD_ACTION) else { + return; + }; + let project = PathBuf::from(std::env::var_os(CHILD_PROJECT).expect("child project env")); + let paths = IndexPaths::resolve(&project, None).expect("child resolve IndexPaths"); + match action.as_str() { + "hold-exclusive" => { + let lease = IndexLease::acquire_exclusive_existing(&paths, deadline(), || false) + .expect("holder acquires exclusive lease"); + println!("READY"); + std::io::stdout().flush().expect("flush READY"); + let mut release = [0_u8; 1]; + std::io::stdin() + .read_exact(&mut release) + .expect("read release byte"); + drop(lease); + println!("RELEASED"); + std::io::stdout().flush().expect("flush RELEASED"); + } + "probe-exclusive" => match IndexLease::acquire_exclusive_existing( + &paths, + deadline_after(SHORT_DEADLINE), + || false, + ) { + Ok(lease) => { + println!("ACQUIRED"); + drop(lease); + } + Err(codegraph_store::IndexLeaseError::TimedOut { .. }) => println!("TIMED_OUT"), + Err(error) => panic!("unexpected child probe error: {error}"), + }, + "write-current-wal-and-exit" => { + let lease = IndexLease::acquire_exclusive_existing(&paths, deadline(), || false) + .expect("crash fixture acquires exclusive lease"); + let StoreWriteOpen::Current(store) = + Store::open_for_write(&paths, lease, StoreWritePurpose::CurrentMutation) + .expect("crash fixture opens state-gated Current writer") + else { + panic!("Current mutation must return an open Store"); + }; + store + .connection() + .pragma_update(None, "wal_autocheckpoint", 0) + .expect("disable WAL autocheckpoint in crash fixture"); + store + .set_project_metadata("wal_only_probe", "committed") + .expect("commit WAL-only probe through state-gated writer"); + println!("WAL_COMMITTED"); + std::io::stdout().flush().expect("flush WAL sentinel"); + std::process::exit(91); + } + other => panic!("unknown Store gate child action {other}"), + } +} + +struct Holder { + child: Option, + stdin: Option, + tail: Receiver, +} + +impl Holder { + fn spawn(project: &Path) -> Self { + let mut child = child_command(project, "hold-exclusive") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("spawn Store lock holder"); + let stdin = child.stdin.take().expect("holder stdin"); + let stdout = child.stdout.take().expect("holder stdout"); + let (ready_tx, ready_rx) = mpsc::channel(); + let (tail_tx, tail_rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let mut line = String::new(); + let read = reader.read_line(&mut line).expect("read holder output"); + assert_ne!(read, 0, "holder exited before READY"); + if line.trim() == "READY" { + ready_tx.send(line).expect("send holder READY"); + break; + } + } + let mut tail = String::new(); + reader.read_to_string(&mut tail).expect("read holder tail"); + tail_tx.send(tail).expect("send holder tail"); + }); + let ready = ready_rx + .recv_timeout(CHILD_WAIT) + .expect("holder READY before finite deadline"); + assert_eq!(ready.trim(), "READY"); + Self { + child: Some(child), + stdin: Some(stdin), + tail: tail_rx, + } + } + + fn release(mut self) { + let mut stdin = self.stdin.take().expect("holder release stdin"); + stdin.write_all(b"x").expect("signal holder release"); + drop(stdin); + let child = self.child.as_mut().expect("holder child"); + let status = wait_bounded(child, CHILD_WAIT); + assert!(status.success(), "holder child failed: {status}"); + let tail = self + .tail + .recv_timeout(CHILD_WAIT) + .expect("holder tail before finite deadline"); + assert!( + tail.lines().any(|line| line == "RELEASED"), + "holder emitted no RELEASED sentinel: {tail:?}" + ); + self.child.take(); + } +} + +impl Drop for Holder { + fn drop(&mut self) { + if let Some(child) = self.child.as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +fn child_command(project: &Path, action: &str) -> Command { + let mut command = Command::new(std::env::current_exe().expect("current test executable")); + command + .arg("--exact") + .arg("store_gate_child_process") + .arg("--nocapture") + .env(CHILD_ACTION, action) + .env(CHILD_PROJECT, project); + command +} + +fn run_exclusive_probe(project: &Path, process_bound: Duration) -> String { + let mut child = child_command(project, "probe-exclusive") + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("spawn Store lock probe"); + let mut stdout = child.stdout.take().expect("probe stdout"); + let (output_tx, output_rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut output = String::new(); + stdout + .read_to_string(&mut output) + .expect("read probe stdout"); + output_tx.send(output).expect("send probe output"); + }); + let status = wait_bounded( + &mut child, + process_bound + .checked_add(CHILD_WAIT) + .expect("probe process bound"), + ); + assert!(status.success(), "probe child failed: {status}"); + output_rx + .recv_timeout(CHILD_WAIT) + .expect("probe output before finite deadline") + .lines() + .find(|line| matches!(*line, "ACQUIRED" | "TIMED_OUT")) + .unwrap_or_else(|| panic!("probe emitted no result sentinel")) + .to_string() +} + +fn run_crashed_current_writer(project: &Path) { + let mut child = child_command(project, "write-current-wal-and-exit") + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("spawn state-gated WAL crash fixture"); + let mut stdout = child.stdout.take().expect("WAL fixture stdout"); + let (output_tx, output_rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut output = String::new(); + stdout + .read_to_string(&mut output) + .expect("read WAL fixture stdout"); + output_tx.send(output).expect("send WAL fixture output"); + }); + let status = wait_bounded(&mut child, CHILD_WAIT); + assert_eq!( + status.code(), + Some(91), + "WAL fixture must terminate without running Rust/SQLite destructors" + ); + let output = output_rx + .recv_timeout(CHILD_WAIT) + .expect("WAL fixture output before finite deadline"); + assert!( + output.lines().any(|line| line == "WAL_COMMITTED"), + "WAL fixture emitted no commit sentinel: {output:?}" + ); +} + +fn wait_bounded(child: &mut Child, timeout: Duration) -> std::process::ExitStatus { + let deadline = deadline_after(timeout); + loop { + if let Some(status) = child.try_wait().expect("poll child status") { + return status; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("child process exceeded finite {timeout:?} bound"); + } + std::thread::park_timeout(Duration::from_millis(5)); + } +} diff --git a/crates/codegraph-store/tests/writer_process_lifecycle.rs b/crates/codegraph-store/tests/writer_process_lifecycle.rs new file mode 100644 index 0000000..f7358f7 --- /dev/null +++ b/crates/codegraph-store/tests/writer_process_lifecycle.rs @@ -0,0 +1,889 @@ +//! Process-level acceptance for the production v2 full-writer lifecycle. + +#![cfg(unix)] + +use std::collections::BTreeMap; +use std::fs::{self, File, Metadata}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::os::unix::fs::MetadataExt as _; +use std::os::unix::process::ExitStatusExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use codegraph_core::IndexPaths; +use codegraph_store::{ + ExtractionStatus, IndexLeaseError, RebuildError, RebuildKind, Store, begin_full_rebuild, +}; + +const CHILD_ACTION: &str = "CODEGRAPH_WRITER_LIFECYCLE_CHILD_ACTION"; +const CHILD_PROJECT: &str = "CODEGRAPH_WRITER_LIFECYCLE_CHILD_PROJECT"; +const CHILD_WAIT: Duration = Duration::from_secs(10); +const LOSER_DEADLINE: Duration = Duration::from_millis(80); +static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone, PartialEq, Eq)] +enum NamespaceEntry { + Directory, + RegularFile(Vec), + Symlink(PathBuf), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SnapshotCheckpoint { + DirectoryPathValidated, + DirectoryHandleAndPathCorroborated, + DirectoryEnumerationCorroborated, + RegularPathValidated, + RegularHandleCorroborated, +} + +#[derive(Debug, PartialEq, Eq)] +enum NamespaceSnapshotError { + PathChangedDuringRead(PathBuf), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct FileIdentity { + device: u64, + inode: u64, +} + +impl FileIdentity { + fn from_metadata(metadata: &Metadata) -> Self { + Self { + device: metadata.dev(), + inode: metadata.ino(), + } + } +} + +struct TempProject(PathBuf); + +impl TempProject { + fn new(label: &str) -> Self { + let serial = NEXT_TEMP.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "codegraph-writer-lifecycle-{label}-{}-{serial}", + std::process::id() + )); + fs::create_dir(&path) + .unwrap_or_else(|error| panic!("create temp project {}: {error}", path.display())); + Self(path.canonicalize().expect("canonical temp project")) + } + + fn path(&self) -> &Path { + &self.0 + } + + fn paths(&self) -> IndexPaths { + IndexPaths::resolve(&self.0, None).expect("resolve test IndexPaths") + } +} + +impl Drop for TempProject { + fn drop(&mut self) { + fs::remove_dir_all(&self.0) + .unwrap_or_else(|error| panic!("remove temp project {}: {error}", self.0.display())); + } +} + +fn deadline_after(duration: Duration) -> Instant { + Instant::now() + .checked_add(duration) + .expect("test deadline is representable") +} + +fn snapshot_namespace(root: &Path) -> BTreeMap { + snapshot_namespace_with_checkpoint(root, |_, _| {}) + .expect("snapshot namespace without path replacement") +} + +/// Windows `ERROR_LOCK_VIOLATION`. +/// +/// A byte-range lock taken through `File::try_lock` is ADVISORY on Unix, so an +/// unrelated read of the locked file always succeeds there. On Windows the same +/// lock is MANDATORY and any overlapping read is refused with this code. The +/// contention fixtures below snapshot a namespace whose zero-length `index.lock` +/// is exclusively held by a live HOLDER CHILD PROCESS. +const ERROR_LOCK_VIOLATION: i32 = 33; + +/// Read one already-corroborated namespace file's bytes, tolerating exactly one +/// otherwise-fatal case: a mandatory Windows byte-range lock refusing a read of +/// an EMPTY file. +/// +/// `len` is the length of the very handle being read, taken from the metadata +/// this walker already corroborated against the path. Metadata queries are not +/// byte-range-locked, so the length stays observable while the lock is held, and +/// a zero-length file has exactly one possible content. Recording it as empty is +/// therefore byte-exact AND independent of whether the lock happened to be held +/// at snapshot time, so a `before`/`after` pair still compares equal across a +/// lock release. Creation, removal and kind changes stay detectable because the +/// entry is still recorded as the regular file it is. Every other read error +/// panics: an unreadable file is a real fault, and a non-empty locked file has +/// content that cannot be observed at all. +fn read_namespace_file_bytes(file: &mut File, path: &Path, len: u64) -> Vec { + let mut bytes = Vec::new(); + match file.read_to_end(&mut bytes) { + Ok(_) => bytes, + Err(error) + if cfg!(windows) && len == 0 && error.raw_os_error() == Some(ERROR_LOCK_VIOLATION) => + { + Vec::new() + } + Err(error) => panic!("read opened namespace file {}: {error}", path.display()), + } +} + +fn snapshot_namespace_with_checkpoint( + root: &Path, + mut checkpoint: F, +) -> Result, NamespaceSnapshotError> +where + F: FnMut(&Path, SnapshotCheckpoint), +{ + fn corroborate_directory_path( + directory: &Path, + initial_identity: FileIdentity, + stage: &str, + ) -> Result<(), NamespaceSnapshotError> { + let metadata = match fs::symlink_metadata(directory) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(NamespaceSnapshotError::PathChangedDuringRead( + directory.to_path_buf(), + )); + } + Err(error) => panic!( + "reinspect namespace directory {stage} {}: {error}", + directory.display() + ), + }; + if !metadata.file_type().is_dir() + || FileIdentity::from_metadata(&metadata) != initial_identity + { + return Err(NamespaceSnapshotError::PathChangedDuringRead( + directory.to_path_buf(), + )); + } + Ok(()) + } + + fn walk( + root: &Path, + directory: &Path, + initial_identity: FileIdentity, + snapshot: &mut BTreeMap, + checkpoint: &mut F, + ) -> Result<(), NamespaceSnapshotError> + where + F: FnMut(&Path, SnapshotCheckpoint), + { + let opened_directory = match File::open(directory) { + Ok(directory) => directory, + Err(error) => { + corroborate_directory_path(directory, initial_identity, "after failed open")?; + panic!("open namespace directory {}: {error}", directory.display()); + } + }; + let opened_metadata = opened_directory.metadata().unwrap_or_else(|error| { + panic!( + "inspect opened namespace directory {}: {error}", + directory.display() + ) + }); + if !opened_metadata.file_type().is_dir() + || FileIdentity::from_metadata(&opened_metadata) != initial_identity + { + return Err(NamespaceSnapshotError::PathChangedDuringRead( + directory.to_path_buf(), + )); + } + + corroborate_directory_path(directory, initial_identity, "before enumeration")?; + checkpoint( + directory, + SnapshotCheckpoint::DirectoryHandleAndPathCorroborated, + ); + let entries = match fs::read_dir(directory) { + Ok(entries) => entries, + Err(error) => { + corroborate_directory_path( + directory, + initial_identity, + "after failed enumeration", + )?; + panic!("read namespace directory {}: {error}", directory.display()); + } + }; + let mut paths = Vec::new(); + for entry in entries { + match entry { + Ok(entry) => paths.push(entry.path()), + Err(error) => { + corroborate_directory_path( + directory, + initial_identity, + "after failed entry enumeration", + )?; + panic!( + "read namespace entry under {}: {error}", + directory.display() + ); + } + } + } + corroborate_directory_path(directory, initial_identity, "after enumeration")?; + checkpoint( + directory, + SnapshotCheckpoint::DirectoryEnumerationCorroborated, + ); + paths.sort(); + + for path in paths { + let relative = path + .strip_prefix(root) + .unwrap_or_else(|error| { + panic!( + "namespace entry {} escaped root {}: {error}", + path.display(), + root.display() + ) + }) + .to_path_buf(); + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(NamespaceSnapshotError::PathChangedDuringRead(path)); + } + Err(error) => panic!("inspect namespace entry {}: {error}", path.display()), + }; + let file_type = metadata.file_type(); + let entry = if file_type.is_dir() { + NamespaceEntry::Directory + } else if file_type.is_file() { + let initial_identity = FileIdentity::from_metadata(&metadata); + checkpoint(&path, SnapshotCheckpoint::RegularPathValidated); + let mut file = match File::open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(NamespaceSnapshotError::PathChangedDuringRead(path)); + } + Err(error) => panic!("open namespace file {}: {error}", path.display()), + }; + let opened = file.metadata().unwrap_or_else(|error| { + panic!("inspect opened namespace file {}: {error}", path.display()) + }); + if !opened.file_type().is_file() + || FileIdentity::from_metadata(&opened) != initial_identity + { + return Err(NamespaceSnapshotError::PathChangedDuringRead(path)); + } + let before_read = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(NamespaceSnapshotError::PathChangedDuringRead(path)); + } + Err(error) => { + panic!( + "reinspect namespace file before read {}: {error}", + path.display() + ) + } + }; + if !before_read.file_type().is_file() + || FileIdentity::from_metadata(&before_read) != initial_identity + { + return Err(NamespaceSnapshotError::PathChangedDuringRead(path)); + } + + checkpoint(&path, SnapshotCheckpoint::RegularHandleCorroborated); + let bytes = read_namespace_file_bytes(&mut file, &path, before_read.len()); + let after_read = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(NamespaceSnapshotError::PathChangedDuringRead(path)); + } + Err(error) => { + panic!( + "reinspect namespace file after read {}: {error}", + path.display() + ) + } + }; + if !after_read.file_type().is_file() + || FileIdentity::from_metadata(&after_read) != initial_identity + { + return Err(NamespaceSnapshotError::PathChangedDuringRead(path)); + } + NamespaceEntry::RegularFile(bytes) + } else if file_type.is_symlink() { + NamespaceEntry::Symlink(fs::read_link(&path).unwrap_or_else(|error| { + panic!("read namespace symlink {}: {error}", path.display()) + })) + } else { + panic!("unsupported namespace entry kind at {}", path.display()); + }; + assert!( + snapshot.insert(relative, entry).is_none(), + "duplicate native namespace path while snapshotting {}", + path.display() + ); + if file_type.is_dir() { + let identity = FileIdentity::from_metadata(&metadata); + checkpoint(&path, SnapshotCheckpoint::DirectoryPathValidated); + walk(root, &path, identity, snapshot, checkpoint)?; + } + } + corroborate_directory_path(directory, initial_identity, "after processing entries")?; + Ok(()) + } + + let metadata = match fs::symlink_metadata(root) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(BTreeMap::new()); + } + Err(error) => panic!("inspect namespace root {}: {error}", root.display()), + }; + assert!( + metadata.file_type().is_dir(), + "namespace root is not a directory: {}", + root.display() + ); + let identity = FileIdentity::from_metadata(&metadata); + checkpoint(root, SnapshotCheckpoint::DirectoryPathValidated); + let mut snapshot = BTreeMap::new(); + snapshot.insert(PathBuf::new(), NamespaceEntry::Directory); + walk(root, root, identity, &mut snapshot, &mut checkpoint)?; + Ok(snapshot) +} + +fn entry_label(entry: Option<&NamespaceEntry>) -> String { + match entry { + None => "missing".to_string(), + Some(NamespaceEntry::Directory) => "directory".to_string(), + Some(NamespaceEntry::RegularFile(bytes)) => format!("file[{} bytes]", bytes.len()), + Some(NamespaceEntry::Symlink(target)) => format!("symlink -> {}", target.display()), + } +} + +fn assert_namespace_unchanged( + before: &BTreeMap, + after: &BTreeMap, + label: &str, +) { + let changed = before + .keys() + .chain(after.keys()) + .collect::>() + .into_iter() + .filter(|path| before.get(*path) != after.get(*path)) + .map(|path| { + format!( + "{}: {} -> {}", + path.display(), + entry_label(before.get(path)), + entry_label(after.get(path)) + ) + }) + .collect::>(); + assert!( + changed.is_empty(), + "{label} changed: {}", + changed.join(", ") + ); +} + +fn stage_legacy_namespace(project: &Path) -> PathBuf { + let legacy = project.join(".codegraph"); + fs::create_dir(&legacy).expect("create independent legacy namespace"); + fs::write(legacy.join("codegraph.db"), b"legacy-db-sentinel\0\xff") + .expect("write legacy DB sentinel"); + fs::create_dir(legacy.join("empty-directory")).expect("create legacy empty directory"); + legacy +} + +fn finish_rebuild(project: &Path) { + let paths = IndexPaths::resolve(project, None).expect("resolve recovery IndexPaths"); + let rebuild = begin_full_rebuild( + &paths, + RebuildKind::ExplicitInit, + deadline_after(CHILD_WAIT), + || false, + ) + .expect("fresh writer acquires released kernel lease"); + rebuild + .open_store() + .expect("open recovery writer") + .finish() + .expect("complete recovery rebuild"); +} + +/// Child entry point. READY is emitted only after `begin_full_rebuild` has +/// acquired the production outer exclusive lease and completed its destructive +/// prologue. The parent then controls release or deliberately kills this process. +#[test] +fn writer_lifecycle_child_process() { + let Ok(action) = std::env::var(CHILD_ACTION) else { + return; + }; + let project = PathBuf::from(std::env::var_os(CHILD_PROJECT).expect("child project env")); + let paths = IndexPaths::resolve(&project, None).expect("child resolve IndexPaths"); + + match action.as_str() { + "hold" => { + let rebuild = begin_full_rebuild( + &paths, + RebuildKind::ExplicitInit, + deadline_after(CHILD_WAIT), + || false, + ) + .expect("holder enters production full-writer lifecycle"); + println!("READY"); + std::io::stdout().flush().expect("flush READY"); + let mut release = [0_u8; 1]; + std::io::stdin() + .read_exact(&mut release) + .expect("read release byte"); + drop(rebuild); + println!("RELEASED"); + std::io::stdout().flush().expect("flush RELEASED"); + } + "lose" => match begin_full_rebuild( + &paths, + RebuildKind::ExplicitInit, + deadline_after(LOSER_DEADLINE), + || false, + ) { + Err(RebuildError::Lease(IndexLeaseError::TimedOut { .. })) => { + println!("LEASE_TIMED_OUT") + } + Err(error) => panic!("losing writer failed for unrelated reason: {error:?}"), + Ok(rebuild) => { + drop(rebuild); + panic!("losing writer unexpectedly acquired the production writer lease"); + } + }, + "recover" => { + finish_rebuild(&project); + println!("RECOVERED_CURRENT"); + } + other => panic!("unknown writer lifecycle child action {other}"), + } +} + +struct Holder { + child: Option, + stdin: Option, + reader: Option>>, +} + +impl Holder { + fn spawn(project: &Path) -> Self { + let mut child = child_command(project, "hold") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("spawn production writer holder"); + let stdin = child.stdin.take().expect("holder stdin"); + let stdout = child.stdout.take().expect("holder stdout"); + let (ready_tx, ready_rx) = mpsc::channel(); + let reader = std::thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let mut line = String::new(); + let read = reader + .read_line(&mut line) + .map_err(|error| format!("read holder output: {error}"))?; + if read == 0 { + return Err("holder exited before READY".to_string()); + } + if line.trim() == "READY" { + ready_tx + .send(line) + .map_err(|_| "holder READY receiver was dropped".to_string())?; + break; + } + } + let mut tail = String::new(); + reader + .read_to_string(&mut tail) + .map_err(|error| format!("read holder tail: {error}"))?; + Ok(tail) + }); + let ready = match ready_rx.recv_timeout(CHILD_WAIT) { + Ok(ready) => ready, + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + let reader_result = reader.join(); + panic!("holder READY before finite deadline: {error}; reader={reader_result:?}"); + } + }; + assert_eq!(ready.trim(), "READY"); + Self { + child: Some(child), + stdin: Some(stdin), + reader: Some(reader), + } + } + + fn join_reader(&mut self) -> String { + self.reader + .take() + .expect("holder reader") + .join() + .expect("join holder reader") + .expect("holder reader completed") + } + + fn release(mut self) { + let mut stdin = self.stdin.take().expect("holder release stdin"); + stdin.write_all(b"x").expect("signal holder release"); + drop(stdin); + let status = wait_bounded(self.child.as_mut().expect("holder child"), CHILD_WAIT); + assert!(status.success(), "holder child failed: {status}"); + let tail = self.join_reader(); + assert!( + tail.lines().any(|line| line == "RELEASED"), + "holder emitted no RELEASED sentinel: {tail:?}" + ); + self.child.take(); + } + + fn crash(mut self) -> ExitStatus { + let child = self.child.as_mut().expect("holder child"); + child.kill().expect("send OS kill to holder"); + let status = wait_bounded(child, CHILD_WAIT); + self.join_reader(); + self.child.take(); + self.stdin.take(); + status + } +} + +impl Drop for Holder { + fn drop(&mut self) { + if let Some(child) = self.child.as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + self.stdin.take(); + if let Some(reader) = self.reader.take() { + let _ = reader.join(); + } + } +} + +fn child_command(project: &Path, action: &str) -> Command { + let mut command = Command::new(std::env::current_exe().expect("current test executable")); + command + .arg("--exact") + .arg("writer_lifecycle_child_process") + .arg("--nocapture") + .env(CHILD_ACTION, action) + .env(CHILD_PROJECT, project); + command +} + +fn run_child(project: &Path, action: &str) -> (ExitStatus, String, String) { + let mut child = child_command(project, action) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap_or_else(|error| panic!("spawn writer child {action}: {error}")); + let stdout = child.stdout.take().expect("child stdout"); + let stderr = child.stderr.take().expect("child stderr"); + let (output_tx, output_rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut stdout_reader = BufReader::new(stdout); + let mut stderr_reader = BufReader::new(stderr); + let mut stdout_text = String::new(); + let mut stderr_text = String::new(); + stdout_reader + .read_to_string(&mut stdout_text) + .expect("read child stdout"); + stderr_reader + .read_to_string(&mut stderr_text) + .expect("read child stderr"); + output_tx + .send((stdout_text, stderr_text)) + .expect("send child output"); + }); + let status = wait_bounded(&mut child, CHILD_WAIT); + let (stdout, stderr) = output_rx + .recv_timeout(CHILD_WAIT) + .expect("child output before finite deadline"); + (status, stdout, stderr) +} + +fn wait_bounded(child: &mut Child, timeout: Duration) -> ExitStatus { + let deadline = deadline_after(timeout); + loop { + if let Some(status) = child.try_wait().expect("poll child status") { + return status; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("child process exceeded finite {timeout:?} bound"); + } + std::thread::park_timeout(Duration::from_millis(5)); + } +} + +#[test] +fn namespace_oracle_detects_equal_length_content_mutation() { + let project = TempProject::new("oracle-equal-length"); + let root = project.path().join("namespace"); + fs::create_dir(&root).expect("create oracle namespace"); + let file = root.join("payload.bin"); + fs::write(&file, b"AAAA").expect("write first equal-length payload"); + let before = snapshot_namespace(&root); + fs::write(&file, b"BBBB").expect("write second equal-length payload"); + let after = snapshot_namespace(&root); + let detection = std::panic::catch_unwind(|| { + assert_namespace_unchanged(&before, &after, "oracle self-test") + }); + assert!( + detection.is_err(), + "namespace oracle missed an equal-length byte mutation" + ); +} + +#[test] +fn namespace_oracle_accepts_only_typed_root_absence_and_rejects_aliases() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt as _; + + let project = TempProject::new("oracle-root-validation"); + let missing = project.path().join("missing"); + assert!(snapshot_namespace(&missing).is_empty()); + + let external = project.path().join("external"); + fs::create_dir(&external).expect("create external root target"); + fs::write(external.join("must-not-be-traversed"), b"outside") + .expect("write external root target payload"); + let alias = project.path().join("alias"); + std::os::unix::fs::symlink(&external, &alias).expect("create root symlink"); + assert!( + std::panic::catch_unwind(|| snapshot_namespace(&alias)).is_err(), + "a root symlink must be rejected before traversal" + ); + + let regular = project.path().join("regular"); + fs::write(®ular, b"not-a-directory").expect("write regular root path"); + assert!( + std::panic::catch_unwind(|| snapshot_namespace(®ular)).is_err(), + "a regular root path must be rejected before traversal" + ); + + let invalid = project + .path() + .join(OsString::from_vec(b"invalid\0root".to_vec())); + assert!( + std::panic::catch_unwind(|| snapshot_namespace(&invalid)).is_err(), + "a non-NotFound root metadata error must fail closed" + ); +} + +#[test] +fn namespace_oracle_detects_regular_path_replaced_by_symlink_before_read() { + let project = TempProject::new("oracle-symlink-replacement"); + let root = project.path().join("namespace"); + fs::create_dir(&root).expect("create replacement-test namespace"); + let file = root.join("payload.bin"); + fs::write(&file, b"original-namespace-bytes").expect("write original namespace file"); + let external = project.path().join("external.bin"); + fs::write(&external, b"external-target-must-not-be-authoritative") + .expect("write external symlink target"); + + let mut read_boundary_reached = false; + let result = snapshot_namespace_with_checkpoint(&root, |path, checkpoint| { + if path == file && checkpoint == SnapshotCheckpoint::RegularPathValidated { + fs::remove_file(path).expect("remove validated regular path"); + std::os::unix::fs::symlink(&external, path) + .expect("replace regular path with external symlink"); + } else if path == file && checkpoint == SnapshotCheckpoint::RegularHandleCorroborated { + read_boundary_reached = true; + } + }); + + assert_eq!( + result, + Err(NamespaceSnapshotError::PathChangedDuringRead(file)), + "a replacement symlink must be detected explicitly rather than accepting its external target bytes" + ); + assert!( + !read_boundary_reached, + "the external symlink target must be rejected before any authoritative byte read" + ); +} + +fn assert_directory_replacement_rejected( + result: Result, NamespaceSnapshotError>, + changed_path: &Path, + external_sentinel: &Path, +) { + match result { + Err(error) => assert_eq!( + error, + NamespaceSnapshotError::PathChangedDuringRead(changed_path.to_path_buf()) + ), + Ok(snapshot) => { + assert!( + !snapshot.contains_key(external_sentinel), + "external-tree entry escaped into a successful authoritative snapshot: {}", + external_sentinel.display() + ); + panic!( + "directory replacement unexpectedly produced a successful authoritative snapshot: {}", + changed_path.display() + ); + } + } +} + +#[test] +fn namespace_oracle_detects_root_directory_replaced_by_external_symlink() { + let project = TempProject::new("oracle-root-directory-replacement"); + let root = project.path().join("namespace"); + fs::create_dir(&root).expect("create root replacement-test namespace"); + let external = project.path().join("external-root-directory"); + fs::create_dir(&external).expect("create external root directory"); + let sentinel_name = PathBuf::from("external-root-sentinel-must-never-be-authoritative"); + fs::write(external.join(&sentinel_name), b"outside-root-tree") + .expect("write external root sentinel"); + + let mut replaced = false; + let result = snapshot_namespace_with_checkpoint(&root, |path, checkpoint| { + if path == root && checkpoint == SnapshotCheckpoint::DirectoryPathValidated { + fs::remove_dir(path).expect("remove validated root directory"); + std::os::unix::fs::symlink(&external, path) + .expect("replace root directory with external symlink"); + replaced = true; + } + }); + + assert!(replaced, "root replacement checkpoint was not reached"); + assert_directory_replacement_rejected(result, &root, &sentinel_name); +} + +#[test] +fn namespace_oracle_detects_nested_directory_replaced_by_external_symlink() { + let project = TempProject::new("oracle-nested-directory-replacement"); + let root = project.path().join("namespace"); + let nested = root.join("nested"); + fs::create_dir_all(&nested).expect("create nested replacement-test namespace"); + let external = project.path().join("external-nested-directory"); + fs::create_dir(&external).expect("create external nested directory"); + let sentinel_name = PathBuf::from("external-nested-sentinel-must-never-be-authoritative"); + fs::write(external.join(&sentinel_name), b"outside-nested-tree") + .expect("write external nested sentinel"); + let sentinel_relative = Path::new("nested").join(&sentinel_name); + + let mut replaced = false; + let result = snapshot_namespace_with_checkpoint(&root, |path, checkpoint| { + if path == nested && checkpoint == SnapshotCheckpoint::DirectoryHandleAndPathCorroborated { + fs::remove_dir(path).expect("remove validated nested directory"); + std::os::unix::fs::symlink(&external, path) + .expect("replace nested directory with external symlink"); + replaced = true; + } + }); + + assert!(replaced, "nested replacement checkpoint was not reached"); + assert_directory_replacement_rejected(result, &nested, &sentinel_relative); +} + +#[test] +fn concurrent_v2_writers_serialize_and_loser_is_nonmutating() { + let project = TempProject::new("concurrent-writers"); + let paths = project.paths(); + let legacy = stage_legacy_namespace(project.path()); + let legacy_before = snapshot_namespace(&legacy); + + let holder = Holder::spawn(project.path()); + assert!(matches!( + Store::extraction_status(&paths), + ExtractionStatus::Building { .. } + )); + let current_before_loser = snapshot_namespace(paths.current_root()); + + let (status, stdout, stderr) = run_child(project.path(), "lose"); + assert!( + status.success(), + "losing writer child did not report its typed contention result: status={status}, stdout={stdout:?}, stderr={stderr:?}" + ); + assert_eq!( + stdout.lines().find(|line| *line == "LEASE_TIMED_OUT"), + Some("LEASE_TIMED_OUT"), + "losing writer must fail for exact bounded lease contention: stdout={stdout:?}, stderr={stderr:?}" + ); + + assert_namespace_unchanged( + ¤t_before_loser, + &snapshot_namespace(paths.current_root()), + "v2 namespace while winner retains the production lease", + ); + assert_namespace_unchanged( + &legacy_before, + &snapshot_namespace(&legacy), + "legacy namespace during writer contention", + ); + holder.release(); + assert_namespace_unchanged( + &legacy_before, + &snapshot_namespace(&legacy), + "legacy namespace after winner release", + ); +} + +#[test] +fn crashed_writer_releases_permanent_kernel_lease() { + let project = TempProject::new("crashed-writer"); + let paths = project.paths(); + let legacy = stage_legacy_namespace(project.path()); + let legacy_before = snapshot_namespace(&legacy); + + let holder = Holder::spawn(project.path()); + assert!(matches!( + Store::extraction_status(&paths), + ExtractionStatus::Building { .. } + )); + let crash_status = holder.crash(); + assert_eq!( + crash_status.signal(), + Some(9), + "holder must terminate abnormally by SIGKILL, bypassing Rust cleanup: {crash_status:?}" + ); + assert_namespace_unchanged( + &legacy_before, + &snapshot_namespace(&legacy), + "legacy namespace immediately after holder crash", + ); + + let (status, stdout, stderr) = run_child(project.path(), "recover"); + assert!( + status.success(), + "fresh writer failed to recover after holder crash: status={status}, stdout={stdout:?}, stderr={stderr:?}" + ); + assert!( + stdout.lines().any(|line| line == "RECOVERED_CURRENT"), + "fresh writer emitted no recovery sentinel: stdout={stdout:?}, stderr={stderr:?}" + ); + assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Current); + let readable = Store::open_for_read(&paths, deadline_after(CHILD_WAIT), || false) + .expect("public read gate corroborates recovered Current"); + drop(readable); + assert_namespace_unchanged( + &legacy_before, + &snapshot_namespace(&legacy), + "legacy namespace across crash recovery", + ); +} diff --git a/crates/codegraph-watch/src/lib.rs b/crates/codegraph-watch/src/lib.rs index 791da05..3e5bbf5 100644 --- a/crates/codegraph-watch/src/lib.rs +++ b/crates/codegraph-watch/src/lib.rs @@ -1,4 +1,5 @@ mod git; +mod migrate; mod policy; mod sync; mod watcher; @@ -12,10 +13,108 @@ pub use policy::{ CODEGRAPH_NO_WATCH, TooBroadRoot, WatchPolicy, too_broad_root_reason, watch_disabled_reason, }; pub use sync::{ - SyncOutcome, sync_changed_paths, sync_project_once, sync_project_once_with_progress, + SyncCancellation, SyncOutcome, sync_changed_paths, sync_project_once, + sync_project_once_cancellable, sync_project_once_with_progress, +}; +pub use watcher::{ + PendingFile, ProjectWatcher, WatchOptions, start_serve_watcher, watch_options_for_project, }; -pub use watcher::{PendingFile, ProjectWatcher, WatchOptions, start_serve_watcher}; pub use worktree::{ WorktreeIndexMismatch, detect_worktree_index_mismatch, git_worktree_root, worktree_mismatch_notice, worktree_mismatch_warning, }; + +/// Test-only: the ONE process-wide environment lock for this crate. +/// +/// `HOME`, `CODEGRAPH_NO_WATCH`, and `CODEGRAPH_FORCE_WATCH` are process-global, +/// and `cargo test` runs this crate's unit tests as threads of ONE process. The +/// `policy` tests mutate all three; the `watcher` tests call +/// `ProjectWatcher::start`, which READS them through `watch_disabled_reason` and +/// returns `Ok(None)` when they say "don't watch". A watcher test that does not +/// hold this lock can therefore observe another test's `CODEGRAPH_NO_WATCH=1` +/// and get `None` back. Both sides must go through [`test_env::env_guard`]. +#[cfg(test)] +pub(crate) mod test_env { + use std::ffi::{OsStr, OsString}; + use std::sync::{Mutex, MutexGuard}; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + pub(crate) struct EnvGuard { + _lock: MutexGuard<'static, ()>, + saved: Vec<(String, Option)>, + expected: Vec<(String, Option)>, + } + + pub(crate) fn env_guard() -> EnvGuard { + EnvGuard { + _lock: ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()), + saved: Vec::new(), + expected: Vec::new(), + } + } + + impl EnvGuard { + fn remember(&mut self, key: &str) { + if !self.saved.iter().any(|(k, _)| k == key) { + self.saved.push((key.to_string(), std::env::var_os(key))); + } + } + + fn expect(&mut self, key: &str, value: Option) { + match self.expected.iter_mut().find(|(k, _)| k == key) { + Some(slot) => slot.1 = value, + None => self.expected.push((key.to_string(), value)), + } + } + + pub(crate) fn set(&mut self, key: &str, value: impl AsRef) -> &mut Self { + let value = value.as_ref().to_os_string(); + self.remember(key); + // SAFETY: ENV_LOCK is held for this guard's whole lifetime, so no + // other test thread of this crate reads or writes env concurrently. + unsafe { std::env::set_var(key, &value) }; + self.expect(key, Some(value)); + self.assert_intact(); + self + } + + pub(crate) fn remove(&mut self, key: &str) -> &mut Self { + self.remember(key); + // SAFETY: as in `set` — serialized by ENV_LOCK. + unsafe { std::env::remove_var(key) }; + self.expect(key, None); + self.assert_intact(); + self + } + + /// Panic if a variable this guard wrote changed underneath it — the + /// escape detector for an unlocked env mutation elsewhere. + pub(crate) fn assert_intact(&self) { + for (key, want) in &self.expected { + assert_eq!( + &std::env::var_os(key), + want, + "env var {key} changed underneath this guard: another test \ + mutated process-global env without holding the shared lock" + ); + } + } + + pub(crate) fn home_key() -> &'static str { + if cfg!(windows) { "USERPROFILE" } else { "HOME" } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + for (key, value) in self.saved.drain(..).rev() { + match value { + // SAFETY: still holding ENV_LOCK; single-threaded here. + Some(v) => unsafe { std::env::set_var(&key, v) }, + None => unsafe { std::env::remove_var(&key) }, + } + } + } + } +} diff --git a/crates/codegraph-watch/src/migrate.rs b/crates/codegraph-watch/src/migrate.rs new file mode 100644 index 0000000..e45efc8 --- /dev/null +++ b/crates/codegraph-watch/src/migrate.rs @@ -0,0 +1,202 @@ +//! Forced full migration of a v2 namespace that cannot be updated incrementally. +//! +//! Frozen plan `upstream-v1.5-portable-fixes.md` lines 557-565: an incremental +//! sync classifies BEFORE mutating a row. `Missing`, `Outdated`, and a +//! recoverable `Building` are not states a file-by-file update can repair, so the +//! sync escalates HERE, reusing the SAME retained exclusive lease its +//! [`StoreWriteAuthorization`] carries — no lease is released and reacquired, and +//! no nested lock is taken. +//! +//! Migration is a from-source rebuild, so it structurally +//! +//! - bypasses every mtime and content-hash skip (it reports zero unchanged +//! skips even when every source byte matches the outdated database), +//! - processes every current candidate in sorted `scan_project` order, +//! - drops tracked files that no longer exist on disk (they are simply not +//! candidates, so they never enter the fresh database), +//! - reruns framework extraction, resolution, and maintenance, and +//! - publishes `phase=current` LAST through the rebuild finalizer. +//! +//! Its five canonical surfaces therefore equal a fresh v2 `index --force`: the +//! persist order below reproduces the CLI full-index pipeline exactly — file row +//! then nodes per file in sorted order, ALL nodes before ANY edge, then edges, +//! then unresolved refs, then framework extraction, batched resolution, and +//! cross-file finalization. +//! +//! Unlike the CLI's streaming full index, which spills edges/refs to a temporary +//! file, this path buffers them in memory for the duration of one migration. +//! That keeps the crate dependency-free while preserving the exact insert order; +//! the memory profile matches the pre-existing `extract_project` API and applies +//! only to the rare extraction-version migration, not to routine indexing. + +use std::path::Path; +use std::time::Instant; + +use anyhow::{Context, Result}; +use codegraph_core::IndexPaths; +use codegraph_core::node_id::hash_content; +use codegraph_core::types::{Edge, FileRecord, Node, UnresolvedRef}; +use codegraph_extract::{detect_language_with, extract_source_with}; +use codegraph_resolve::ReferenceResolver; +use codegraph_store::StoreWriteAuthorization; + +use crate::sync::{ProjectScope, SyncOutcome, modified_millis, now_millis}; + +/// Batch sizes and the resolver batch, matching the CLI full-index constants so a +/// migrated index is byte-equal to `index --force`. +const NODE_FLUSH_ROWS: usize = 10_000; +const EDGE_FLUSH_ROWS: usize = 20_000; +const REF_FLUSH_ROWS: usize = 20_000; +const RESOLVE_BATCH_ROWS: usize = 5_000; + +/// The version key an index records for `codegraph status`. Same key and same +/// workspace version the CLI full index writes. +const INDEXED_WITH_VERSION_KEY: &str = "indexed_with_version"; + +/// Run one forced full migration under the exclusive lease `authorization` +/// already holds. +/// +/// `authorization` must be the `FullRebuildRequired` capability the incremental +/// sync's state gate returned; the rebuild layer revalidates it and refuses any +/// state it did not accept for escalation. +pub(crate) fn migrate_project( + project_root: &Path, + paths: &IndexPaths, + authorization: StoreWriteAuthorization, + scope: &ProjectScope, + started: Instant, + mut on_progress: impl FnMut(usize, usize), +) -> Result { + // The addressed project's own scan/extract options and framework config; the + // migration never consults a process-global value. + let options = &scope.options; + // `scan_project` returns a SORTED list, and every downstream pass keeps that + // order, so no HashSet iteration order can reach the database or the outcome. + let candidates = codegraph_extract::engine::scan_project(project_root, options)?; + let total = candidates.len(); + + // Publishes `phase=building` BEFORE deleting a database byte, removes only + // the v2 database files, and opens the single fresh writer — all under the + // lease the authorization retains. + let rebuild = codegraph_store::resume_full_rebuild(paths, authorization)?; + let mut rebuild = rebuild.open_store()?; + rebuild.set_bulk_index_pragmas()?; + + let mut pending_nodes: Vec = Vec::with_capacity(NODE_FLUSH_ROWS); + let mut edges: Vec = Vec::new(); + let mut refs: Vec = Vec::new(); + let mut outcome = SyncOutcome { + files_checked: total, + ..SyncOutcome::default() + }; + + for (done, relative) in candidates.iter().enumerate() { + let full = project_root.join(relative); + // One metadata + one source read per file, mirroring the CLI producer so + // the oversized-file skip message is byte-identical. + let metadata = std::fs::metadata(&full) + .with_context(|| format!("reading metadata for {}", full.display()))?; + let source = std::fs::read_to_string(&full) + .with_context(|| format!("reading source file {}", full.display()))?; + let mut result = if metadata.len() > options.max_file_size { + codegraph_core::types::ExtractionResult { + nodes: Vec::new(), + edges: Vec::new(), + unresolved_references: Vec::new(), + errors: vec![format!( + "File exceeds max size ({} > {}): {relative}", + metadata.len(), + options.max_file_size + )], + duration_ms: 0, + } + } else { + extract_source_with(relative, &source, None, &options.extensions) + }; + let file = FileRecord { + path: relative.clone(), + content_hash: hash_content(&source), + language: detect_language_with(relative, &options.extensions), + size: metadata.len() as i64, + modified_at: modified_millis(&metadata), + indexed_at: now_millis(), + node_count: result + .nodes + .iter() + .filter(|node| node.file_path == *relative) + .count() as i64, + errors: result.errors.clone(), + }; + + rebuild.store().upsert_file(&file)?; + pending_nodes.append(&mut result.nodes); + if pending_nodes.len() >= NODE_FLUSH_ROWS { + rebuild.store_mut().upsert_nodes(&pending_nodes)?; + pending_nodes.clear(); + } + edges.append(&mut result.edges); + refs.append(&mut result.unresolved_references); + + // Every candidate counts as reindexed: migration has no skip gate at all. + outcome.files_reindexed += 1; + on_progress(done + 1, total); + } + + if !pending_nodes.is_empty() { + rebuild.store_mut().upsert_nodes(&pending_nodes)?; + } + drop(pending_nodes); + + // ALL nodes are persisted before ANY edge, because `insert_edges` drops edges + // whose endpoints are absent. The WAL valve folds the log back during the + // replay passes exactly as the CLI full index does. + let wal_valve_bytes = codegraph_store::wal_valve_threshold_bytes(); + for batch in edges.chunks(EDGE_FLUSH_ROWS) { + rebuild.store_mut().insert_edges(batch)?; + rebuild.checkpoint_wal_if_over(wal_valve_bytes)?; + } + drop(edges); + for batch in refs.chunks(REF_FLUSH_ROWS) { + rebuild.store_mut().insert_unresolved_refs(batch)?; + rebuild.checkpoint_wal_if_over(wal_valve_bytes)?; + } + drop(refs); + + let mut resolver = ReferenceResolver::new(project_root.to_string_lossy()); + { + let context = codegraph_resolve::StoreResolutionContext::new( + rebuild.store(), + project_root.to_string_lossy(), + ); + resolver.initialize(&context); + } + if resolver.has_framework_resolvers() { + let relative_files = rebuild + .store() + .all_files()? + .into_iter() + .map(|file| file.path) + .collect::>(); + resolver.extract_and_persist_frameworks_with( + rebuild.store_mut(), + &relative_files, + &scope.framework, + &options.extensions, + )?; + } + resolver.resolve_and_persist_batched(rebuild.store_mut(), RESOLVE_BATCH_ROWS)?; + resolver.run_post_extract(rebuild.store_mut())?; + rebuild + .store() + .set_project_metadata(INDEXED_WITH_VERSION_KEY, env!("CARGO_PKG_VERSION"))?; + + // Explicit fallible finalization: pragma restore, checkpoint + compaction, + // extraction stamp, stamp checkpoint, connection close, and only then the + // atomic `phase=current` publication. + rebuild.finish()?; + + outcome.duration_ms = started.elapsed().as_millis(); + // Deterministic, sorted, and free of any HashSet iteration order. + outcome.changed_paths = candidates; + Ok(outcome) +} diff --git a/crates/codegraph-watch/src/policy.rs b/crates/codegraph-watch/src/policy.rs index 03f7211..ef20b2f 100644 --- a/crates/codegraph-watch/src/policy.rs +++ b/crates/codegraph-watch/src/policy.rs @@ -1,7 +1,8 @@ use std::fs; use std::path::{Path, PathBuf}; +use std::sync::Arc; -use codegraph_extract::detect_language; +use codegraph_extract::{ExtensionOverrides, detect_language_with}; pub const CODEGRAPH_NO_WATCH: &str = "CODEGRAPH_NO_WATCH"; @@ -85,6 +86,10 @@ pub struct WatchPolicy { builtin_rule_count: usize, include: Vec, exclude: Vec, + /// The addressed project's custom extension→language overrides, so a file the + /// project declared as source is HANDLED by the watcher exactly as the scan + /// indexes it. Empty by default (built-in detection only). + extensions: Arc, } impl WatchPolicy { @@ -136,9 +141,20 @@ impl WatchPolicy { builtin_rule_count, include: include.to_vec(), exclude: exclude.to_vec(), + extensions: ExtensionOverrides::empty(), } } + /// Adopt the addressed project's extension overrides, so + /// [`should_handle_file`](Self::should_handle_file) treats a project-declared + /// custom extension as source. Without this the policy uses built-in + /// detection only, which is the zero-config behavior. + #[must_use] + pub fn with_extension_overrides(mut self, extensions: Arc) -> Self { + self.extensions = extensions; + self + } + pub fn normalize_relative(&self, path: impl AsRef) -> Option { let path = path.as_ref(); let relative = if path.is_absolute() { @@ -156,7 +172,8 @@ impl WatchPolicy { pub fn should_handle_file(&self, relative: &str) -> bool { !self.is_always_ignored(relative) && !self.is_ignored(relative, false) - && detect_language(relative) != codegraph_core::types::Language::Unknown + && detect_language_with(relative, &self.extensions) + != codegraph_core::types::Language::Unknown } pub fn allows_file_path(&self, relative: &str) -> bool { @@ -468,51 +485,15 @@ fn is_windows_drive_mount(path: &Path) -> bool { #[cfg(test)] mod tests { use super::*; - use std::sync::Mutex; - - static ENV_LOCK: Mutex<()> = Mutex::new(()); - - struct EnvGuard { - home: Option, - force: Option, - no_watch: Option, - } - - impl EnvGuard { - fn capture() -> Self { - Self { - home: std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }), - force: std::env::var_os("CODEGRAPH_FORCE_WATCH"), - no_watch: std::env::var_os(CODEGRAPH_NO_WATCH), - } - } - } - - impl Drop for EnvGuard { - fn drop(&mut self) { - let home_key = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; - restore(home_key, self.home.take()); - restore("CODEGRAPH_FORCE_WATCH", self.force.take()); - restore(CODEGRAPH_NO_WATCH, self.no_watch.take()); - } - } - - fn restore(key: &str, value: Option) { - match value { - Some(v) => unsafe { std::env::set_var(key, v) }, - None => unsafe { std::env::remove_var(key) }, - } - } + use crate::test_env::{EnvGuard, env_guard}; #[test] fn watch_disabled_when_root_is_home() { - let _lock = ENV_LOCK.lock().unwrap(); - let _guard = EnvGuard::capture(); + let mut env = env_guard(); let home = crate::sync::tests::TestDir::new("watch-policy-home"); - let home_key = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; - unsafe { std::env::set_var(home_key, home.path()) }; - unsafe { std::env::remove_var("CODEGRAPH_FORCE_WATCH") }; - unsafe { std::env::remove_var(CODEGRAPH_NO_WATCH) }; + env.set(EnvGuard::home_key(), home.path()); + env.remove("CODEGRAPH_FORCE_WATCH"); + env.remove(CODEGRAPH_NO_WATCH); let reason = watch_disabled_reason(home.path(), false); assert!(reason.is_some(), "watching HOME must be disabled"); @@ -521,13 +502,11 @@ mod tests { #[test] fn watch_disabled_for_home_even_with_force_watch() { - let _lock = ENV_LOCK.lock().unwrap(); - let _guard = EnvGuard::capture(); + let mut env = env_guard(); let home = crate::sync::tests::TestDir::new("watch-policy-home-force"); - let home_key = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; - unsafe { std::env::set_var(home_key, home.path()) }; - unsafe { std::env::set_var("CODEGRAPH_FORCE_WATCH", "1") }; - unsafe { std::env::remove_var(CODEGRAPH_NO_WATCH) }; + env.set(EnvGuard::home_key(), home.path()); + env.set("CODEGRAPH_FORCE_WATCH", "1"); + env.remove(CODEGRAPH_NO_WATCH); assert!( watch_disabled_reason(home.path(), false).is_some(), @@ -537,10 +516,9 @@ mod tests { #[test] fn watch_disabled_when_root_is_filesystem_root() { - let _lock = ENV_LOCK.lock().unwrap(); - let _guard = EnvGuard::capture(); - unsafe { std::env::remove_var("CODEGRAPH_FORCE_WATCH") }; - unsafe { std::env::remove_var(CODEGRAPH_NO_WATCH) }; + let mut env = env_guard(); + env.remove("CODEGRAPH_FORCE_WATCH"); + env.remove(CODEGRAPH_NO_WATCH); let reason = watch_disabled_reason(Path::new("/"), false); assert!(reason.is_some(), "watching `/` must be disabled"); @@ -549,13 +527,11 @@ mod tests { #[test] fn watch_allowed_for_normal_project_subdir() { - let _lock = ENV_LOCK.lock().unwrap(); - let _guard = EnvGuard::capture(); + let mut env = env_guard(); let home = crate::sync::tests::TestDir::new("watch-policy-subdir-home"); - let home_key = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; - unsafe { std::env::set_var(home_key, home.path()) }; - unsafe { std::env::remove_var("CODEGRAPH_FORCE_WATCH") }; - unsafe { std::env::remove_var(CODEGRAPH_NO_WATCH) }; + env.set(EnvGuard::home_key(), home.path()); + env.remove("CODEGRAPH_FORCE_WATCH"); + env.remove(CODEGRAPH_NO_WATCH); let project = home.path().join("workspace/proj"); fs::create_dir_all(&project).unwrap(); @@ -608,11 +584,9 @@ mod tests { #[test] fn too_broad_reason_flags_home_and_filesystem_root_but_not_nested_project() { - let _lock = ENV_LOCK.lock().unwrap(); - let _guard = EnvGuard::capture(); + let mut env = env_guard(); let home = crate::sync::tests::TestDir::new("too-broad-home"); - let home_key = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; - unsafe { std::env::set_var(home_key, home.path()) }; + env.set(EnvGuard::home_key(), home.path()); assert!( too_broad_root_reason(home.path()).is_some(), @@ -633,11 +607,9 @@ mod tests { #[test] fn too_broad_reason_normalizes_trailing_dot_to_home() { - let _lock = ENV_LOCK.lock().unwrap(); - let _guard = EnvGuard::capture(); + let mut env = env_guard(); let home = crate::sync::tests::TestDir::new("too-broad-home-dot"); - let home_key = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; - unsafe { std::env::set_var(home_key, home.path()) }; + env.set(EnvGuard::home_key(), home.path()); let with_dot = home.path().join("."); assert!( @@ -648,13 +620,11 @@ mod tests { #[test] fn watch_disabled_when_root_is_home_with_trailing_dot() { - let _lock = ENV_LOCK.lock().unwrap(); - let _guard = EnvGuard::capture(); + let mut env = env_guard(); let home = crate::sync::tests::TestDir::new("watch-policy-home-dot"); - let home_key = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; - unsafe { std::env::set_var(home_key, home.path()) }; - unsafe { std::env::remove_var("CODEGRAPH_FORCE_WATCH") }; - unsafe { std::env::remove_var(CODEGRAPH_NO_WATCH) }; + env.set(EnvGuard::home_key(), home.path()); + env.remove("CODEGRAPH_FORCE_WATCH"); + env.remove(CODEGRAPH_NO_WATCH); let with_dot = home.path().join("."); assert!( @@ -665,10 +635,9 @@ mod tests { #[test] fn watch_disabled_when_no_watch_flag_is_set() { - let _lock = ENV_LOCK.lock().unwrap(); - let _guard = EnvGuard::capture(); + let mut env = env_guard(); let project = crate::sync::tests::TestDir::new("watch-policy-flag"); - unsafe { std::env::remove_var(CODEGRAPH_NO_WATCH) }; + env.remove(CODEGRAPH_NO_WATCH); // The explicit `no_watch` parameter wins even for a normal project dir. let reason = watch_disabled_reason(project.path(), true); @@ -677,10 +646,9 @@ mod tests { #[test] fn watch_disabled_when_no_watch_env_is_set() { - let _lock = ENV_LOCK.lock().unwrap(); - let _guard = EnvGuard::capture(); + let mut env = env_guard(); let project = crate::sync::tests::TestDir::new("watch-policy-env"); - unsafe { std::env::set_var(CODEGRAPH_NO_WATCH, "1") }; + env.set(CODEGRAPH_NO_WATCH, "1"); let reason = watch_disabled_reason(project.path(), false); assert_eq!(reason.as_deref(), Some("CODEGRAPH_NO_WATCH=1 is set")); @@ -826,13 +794,11 @@ mod tests { #[test] fn force_watch_re_enables_a_wsl_drive_mount_path() { - let _lock = ENV_LOCK.lock().unwrap(); - let _guard = EnvGuard::capture(); + let mut env = env_guard(); let project = crate::sync::tests::TestDir::new("watch-policy-force"); - let home_key = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; - unsafe { std::env::set_var(home_key, project.path()) }; - unsafe { std::env::remove_var(CODEGRAPH_NO_WATCH) }; - unsafe { std::env::set_var("CODEGRAPH_FORCE_WATCH", "1") }; + env.set(EnvGuard::home_key(), project.path()); + env.remove(CODEGRAPH_NO_WATCH); + env.set("CODEGRAPH_FORCE_WATCH", "1"); // A normal (non-home, non-root) project with FORCE_WATCH set returns // None — the force escape short-circuits the WSL/mount check below it. diff --git a/crates/codegraph-watch/src/sync.rs b/crates/codegraph-watch/src/sync.rs index 87f19fe..3784684 100644 --- a/crates/codegraph-watch/src/sync.rs +++ b/crates/codegraph-watch/src/sync.rs @@ -1,17 +1,86 @@ use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; +use codegraph_core::IndexPaths; +use codegraph_core::config::Config; use codegraph_core::node_id::hash_content; use codegraph_core::types::FileRecord; -use codegraph_extract::{ExtractOptions, detect_language, extract_file}; +use codegraph_extract::{ + ExtensionOverrides, ExtractOptions, detect_language_with, extract_file_with_options, +}; use codegraph_resolve::ReferenceResolver; -use codegraph_store::Store; +use codegraph_resolve::framework::FrameworkExtractionContext; +use codegraph_resolve::frameworks::godot_dsl_config::GodotDslConfig; +use codegraph_store::{IndexLease, Store, StoreWriteOpen, StoreWritePurpose}; use crate::policy::WatchPolicy; +/// Bounded wall-clock budget for acquiring the ONE outer exclusive lease a sync +/// owns. Never a blocking wait: `IndexLease` polls `try_lock` against this +/// monotonic deadline, so a sync behind another writer fails with a typed +/// timeout instead of hanging. +const SYNC_LEASE_TIMEOUT: Duration = Duration::from_secs(30); + +/// Cooperative cancellation for a sync's bounded lease loop. +/// +/// Frozen plan lines 598-601: `uninit --force` signals cancellation to queued and +/// running watcher lock loops and drains only after every cancellation-aware +/// lease loop has exited. Without this, a watcher sync queued behind uninit's +/// exclusive lease would spin its full [`SYNC_LEASE_TIMEOUT`] before the daemon +/// could finish draining. Cancellation is observed by `IndexLease`'s bounded +/// acquisition loop, so a cancelled sync returns a typed error and mutates +/// nothing. +#[derive(Clone, Debug, Default)] +pub struct SyncCancellation { + cancelled: Arc, + running: Arc, +} + +impl SyncCancellation { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Refuse every queued lease loop and interrupt every running one. + pub fn cancel(&self) { + self.cancelled + .store(true, std::sync::atomic::Ordering::SeqCst); + } + + #[must_use] + pub fn is_cancelled(&self) -> bool { + self.cancelled.load(std::sync::atomic::Ordering::SeqCst) + } + + /// How many cancellation-aware lease loops are currently inside a sync. + /// A drain waits for this to reach zero after [`cancel`](Self::cancel). + #[must_use] + pub fn active_syncs(&self) -> usize { + self.running.load(std::sync::atomic::Ordering::SeqCst) + } + + fn enter(&self) -> SyncCancellationGuard<'_> { + self.running + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + SyncCancellationGuard(self) + } +} + +struct SyncCancellationGuard<'a>(&'a SyncCancellation); + +impl Drop for SyncCancellationGuard<'_> { + fn drop(&mut self) { + self.0 + .running + .fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + } +} + #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct SyncOutcome { pub files_checked: usize, @@ -23,12 +92,52 @@ pub struct SyncOutcome { /// Reindexed or removed paths this sync, root-relative and SORTED — the /// source set is a `HashSet`, so sorting is required for a stable log line. pub changed_paths: Vec, + /// Root-relative paths that triggered a watcher sync, sorted and deduplicated + /// by the watcher. Empty for direct/full sync callers. Unlike `changed_paths`, + /// this remains populated when another writer (for example startup catch-up) + /// indexed the same event first and this sync consequently skips it unchanged. + pub trigger_paths: Vec, } pub fn sync_project_once(project_root: impl AsRef) -> Result { sync_project_once_with_progress(project_root, |_, _| {}) } +/// [`sync_project_once`] under a caller-owned [`SyncCancellation`], so a daemon +/// shutdown can refuse this sync's queued lease loop and interrupt a running one +/// instead of waiting out the full lease budget. +pub fn sync_project_once_cancellable( + project_root: impl AsRef, + cancel: &SyncCancellation, +) -> Result { + let project_root = project_root.as_ref(); + let paths = index_paths(project_root)?; + let scope = ProjectScope::load(project_root, &paths)?; + sync_project_once_with_scope(project_root, &paths, &scope, Some(cancel), |_, _| {}) +} + +/// Whole-project sync using watcher-owned path patterns while preserving every +/// other scan option from the normal sync path. +/// +/// The watcher owns `include`/`exclude` because it derived them from the SAME +/// project config when it started; every other option (size limit, ignore sets, +/// extension overrides, framework config) is loaded here from the addressed +/// project, so a watcher-triggered full sync scans exactly what a direct sync of +/// that project scans. +pub(crate) fn sync_project_once_with_patterns( + project_root: impl AsRef, + include: &[String], + exclude: &[String], + cancel: Option<&SyncCancellation>, +) -> Result { + let project_root = project_root.as_ref(); + let paths = index_paths(project_root)?; + let mut scope = ProjectScope::load(project_root, &paths)?; + scope.options.include = include.to_vec(); + scope.options.exclude = exclude.to_vec(); + sync_project_once_with_scope(project_root, &paths, &scope, cancel, |_, _| {}) +} + /// Like [`sync_project_once`] but invokes `on_progress(done, total)` after each /// candidate file is processed, letting a caller drive a progress bar. The /// callback is a pure side effect: it never gates or reorders work, so the @@ -38,41 +147,147 @@ pub fn sync_project_once_with_progress( on_progress: impl FnMut(usize, usize), ) -> Result { let project_root = project_root.as_ref(); - let config = codegraph_core::config::get_config(); - let options = ExtractOptions { - max_file_size: config.indexing.max_file_size, - ignore_dirs: config.indexing.ignore_dirs.clone(), - ignore_paths: config.indexing.ignore_paths.clone(), - exclude: config.indexing.exclude.clone(), - include: config.indexing.include.clone(), - parallel: true, - }; - let started = std::time::Instant::now(); - let mut candidates = codegraph_extract::engine::scan_project(project_root, &options)?; - - let db_path = default_db_path(project_root); - let mut store = Store::open(&db_path).with_context(|| format!("open {}", db_path.display()))?; - - // Cold CLI sync has no watcher event list, so deletions are found by diffing - // tracked files against scan_project's on-disk set; absent paths flow through - // sync_one's delete branch (upstream removal pass, index.ts:1436-1441). The - // `exists()` guard keeps a still-present file that merely became ignored. - let on_disk = candidates.iter().cloned().collect::>(); - for tracked in store.all_files()? { - if !on_disk.contains(&tracked.path) && !project_root.join(&tracked.path).exists() { - candidates.push(tracked.path); + let paths = index_paths(project_root)?; + let scope = ProjectScope::load(project_root, &paths)?; + sync_project_once_with_scope(project_root, &paths, &scope, None, on_progress) +} + +fn sync_project_once_with_scope( + project_root: &Path, + paths: &IndexPaths, + scope: &ProjectScope, + cancel: Option<&SyncCancellation>, + mut on_progress: impl FnMut(usize, usize), +) -> Result { + let options = &scope.options; + let include = options.include.clone(); + let exclude = options.exclude.clone(); + let started = Instant::now(); + + // CLASSIFY BEFORE MUTATING A ROW (frozen plan lines 557-565). The gate takes + // the ONE outer exclusive lease, classifies under it, and either hands back + // the incremental writer for a corroborated Current namespace or the + // lease-retaining authorization that escalates to a forced full migration. + // Future/Corrupt/unauthorized-Uninitialized fail here, before any byte moves. + let _active = cancel.map(SyncCancellation::enter); + match open_sync_writer(paths, cancel)? { + SyncWriter::Incremental(mut store) => { + let mut candidates = codegraph_extract::engine::scan_project(project_root, options)?; + // Cold CLI sync has no watcher event list, so deletions are found by + // diffing tracked files against scan_project's on-disk set; absent + // paths flow through sync_one's delete branch (upstream removal pass, + // index.ts:1436-1441). The `exists()` guard keeps a still-present file + // that merely became ignored. + let on_disk = candidates.iter().cloned().collect::>(); + let mut absent = Vec::new(); + for tracked in store.all_files()? { + if !on_disk.contains(&tracked.path) && !project_root.join(&tracked.path).exists() { + absent.push(tracked.path); + } + } + // `all_files` is ordered by path, but sort explicitly so the appended + // tail is deterministic regardless of the query's ordering. + absent.sort(); + candidates.extend(absent); + + let outcome = sync_paths_with_store( + &mut store, + project_root, + candidates, + scope, + &include, + &exclude, + started, + on_progress, + )?; + store.finish_current_mutation()?; + Ok(outcome) } + SyncWriter::MigrationRequired(authorization) => crate::migrate::migrate_project( + project_root, + paths, + authorization, + scope, + started, + &mut on_progress, + ), } +} + +/// One project's immutable configuration for ONE sync operation. +/// +/// Loaded from the addressed project's resolved [`IndexPaths`] — its current-root +/// `config.toml` (scan/extract options) and current-root `codegraph.json` +/// (extension overrides + Godot DSL fields). Nothing here reads a process-global +/// value, a legacy `.codegraph` root, or the process working directory, so a +/// watcher, a direct sync, and a startup catch-up on different projects inside one +/// process each operate under their own settings. +pub(crate) struct ProjectScope { + pub(crate) options: ExtractOptions, + pub(crate) framework: FrameworkExtractionContext, +} - sync_paths_with_store( - &mut store, +impl ProjectScope { + pub(crate) fn load(project_root: &Path, paths: &IndexPaths) -> Result { + let config = Config::load_for_paths(None, paths)?; + let extensions = ExtensionOverrides::load_for_paths(paths); + let framework = FrameworkExtractionContext::new( + project_root.to_string_lossy().into_owned(), + GodotDslConfig::load_for_paths(paths), + ); + Ok(Self { + options: ExtractOptions::for_project(&config, extensions), + framework, + }) + } +} + +/// What the state gate authorized for ONE sync. +enum SyncWriter { + /// A corroborated `Current` namespace, open for incremental mutation under + /// the retained exclusive lease. Boxed because the state gate already yields + /// a boxed `Store`, so this variant stays the same size as the authorization. + Incremental(Box), + /// A namespace that cannot be updated file-by-file. The opaque authorization + /// still owns the same exclusive lease, which the migration reuses. + MigrationRequired(codegraph_store::StoreWriteAuthorization), +} + +pub(crate) fn index_paths(project_root: &Path) -> Result { + Ok(IndexPaths::resolve( project_root, - candidates, - &config.indexing.include, - &config.indexing.exclude, - started, - on_progress, - ) + std::env::var("CODEGRAPH_DIR").ok().as_deref(), + )?) +} + +/// Acquire the ONE outer exclusive lease and classify under it. The retained +/// status decides policy; no precheck-then-reclassify happens anywhere. +fn open_sync_writer(paths: &IndexPaths, cancel: Option<&SyncCancellation>) -> Result { + if let Some(cancel) = cancel + && cancel.is_cancelled() + { + bail!( + "sync of {} was cancelled before acquiring the index lease", + paths.current_root().display() + ); + } + let deadline = Instant::now() + SYNC_LEASE_TIMEOUT; + let cancelled = || cancel.is_some_and(SyncCancellation::is_cancelled); + let lease = IndexLease::acquire_or_create_exclusive(paths, deadline, cancelled)?; + match Store::open_for_write(paths, lease, StoreWritePurpose::IncrementalSync)? { + StoreWriteOpen::Current(store) => Ok(SyncWriter::Incremental(store)), + StoreWriteOpen::FullRebuildRequired(authorization) => { + Ok(SyncWriter::MigrationRequired(authorization)) + } + StoreWriteOpen::UninitContinuation(_) => { + // The gate never returns this for an incremental sync; keep it a + // typed refusal rather than an unreachable panic. + bail!( + "index namespace {} awaits an explicit `codegraph init`", + paths.current_root().display() + ) + } + } } pub fn sync_changed_paths( @@ -80,7 +295,7 @@ pub fn sync_changed_paths( db_path: impl AsRef, paths: impl IntoIterator>, ) -> Result { - sync_changed_paths_with_patterns(project_root, db_path, paths, &[], &[]) + sync_changed_paths_with_patterns(project_root, db_path, paths, &[], &[], None) } /// Like [`sync_changed_paths`] but threads the `codegraph.json`/`config.toml` @@ -90,23 +305,62 @@ pub fn sync_changed_paths( pub fn sync_changed_paths_with_patterns( project_root: impl AsRef, db_path: impl AsRef, - paths: impl IntoIterator>, + changed: impl IntoIterator>, include: &[String], exclude: &[String], + cancel: Option<&SyncCancellation>, ) -> Result { - let started = std::time::Instant::now(); + let started = Instant::now(); let project_root = project_root.as_ref(); let db_path = db_path.as_ref(); - let mut store = Store::open(db_path).with_context(|| format!("open {}", db_path.display()))?; - sync_paths_with_store( - &mut store, - project_root, - paths, - include, - exclude, - started, - |_, _| {}, - ) + let paths = index_paths(project_root)?; + // The caller-supplied database must BE this project's resolved v2 database. + // Accepting any other path would let a watcher mutate a namespace the state + // gate never classified, so a mismatch fails closed. + if db_path != paths.current_db() { + bail!( + "incremental sync target {} is not the resolved v2 database {}", + db_path.display(), + paths.current_db().display() + ); + } + + // The addressed project's own immutable config drives extraction and any + // migration escalation; the caller-supplied `include`/`exclude` (the watcher's + // own patterns, derived from this same project config) override those two + // fields so watcher scope and scan scope stay identical. + let mut scope = ProjectScope::load(project_root, &paths)?; + scope.options.include = include.to_vec(); + scope.options.exclude = exclude.to_vec(); + + // Same classify-before-mutate gate as the cold full sync: a Current namespace + // stays incremental, while Missing/Outdated/recoverable Building escalate to a + // forced migration under the SAME retained exclusive lease. + let _active = cancel.map(SyncCancellation::enter); + match open_sync_writer(&paths, cancel)? { + SyncWriter::Incremental(mut store) => { + let outcome = sync_paths_with_store( + &mut store, + project_root, + changed, + &scope, + include, + exclude, + started, + |_, _| {}, + )?; + store.finish_current_mutation()?; + Ok(outcome) + } + SyncWriter::MigrationRequired(authorization) => crate::migrate::migrate_project( + project_root, + &paths, + authorization, + &scope, + started, + |_, _| {}, + ), + } } #[allow(clippy::too_many_arguments)] @@ -114,12 +368,14 @@ fn sync_paths_with_store( store: &mut Store, project_root: &Path, paths: impl IntoIterator>, + scope: &ProjectScope, include: &[String], exclude: &[String], started: std::time::Instant, mut on_progress: impl FnMut(usize, usize), ) -> Result { - let policy = WatchPolicy::with_config(project_root, include, exclude); + let policy = WatchPolicy::with_config(project_root, include, exclude) + .with_extension_overrides(Arc::clone(&scope.options.extensions)); let mut outcome = SyncOutcome::default(); let mut changed = false; let mut seen = HashSet::new(); @@ -150,6 +406,7 @@ fn sync_paths_with_store( project_root, store, &relative, + scope, &mut outcome, &mut dependents, &mut changed_names, @@ -170,6 +427,7 @@ fn sync_paths_with_store( refresh_dependent_refs( project_root, store, + scope, &dependents, &reindexed, &mut changed_names, @@ -192,7 +450,12 @@ fn sync_paths_with_store( // nodes/refs were dropped on re-extraction (upstream tree-sitter.ts:4796-4819). if resolver.has_framework_resolvers() { let reindexed_files: Vec = reindexed.iter().cloned().collect(); - resolver.extract_and_persist_frameworks(store, &reindexed_files)?; + resolver.extract_and_persist_frameworks_with( + store, + &reindexed_files, + &scope.framework, + &scope.options.extensions, + )?; } resolver.resolve_incremental_and_persist(store, &scope_files, &changed_names)?; // Cross-file framework finalization on every sync (upstream index.ts:464). @@ -237,6 +500,7 @@ fn sync_paths_with_store( fn refresh_dependent_refs( project_root: &Path, store: &mut Store, + scope: &ProjectScope, dependents: &HashSet, already_reindexed: &HashSet, changed_names: &mut HashSet, @@ -248,7 +512,7 @@ fn refresh_dependent_refs( if !project_root.join(relative).exists() { continue; } - let result = extract_file(project_root, relative)?; + let result = extract_file_with_options(project_root, relative, &scope.options)?; let node_ids = result .nodes .iter() @@ -297,14 +561,20 @@ fn sweep_orphaned_refs(project_root: &Path, store: &mut Store) -> Result<()> { /// (`RESOLVE_BATCH_ROWS`) so a healed index is byte-equal to `index --force`. const ORPHAN_SWEEP_BATCH_ROWS: usize = 5_000; -pub(crate) fn default_db_path(project_root: &Path) -> PathBuf { - project_root.join(".codegraph").join("codegraph.db") +pub(crate) fn default_db_path(project_root: &Path) -> Result { + Ok(codegraph_core::IndexPaths::resolve( + project_root, + std::env::var("CODEGRAPH_DIR").ok().as_deref(), + )? + .current_db()) } +#[allow(clippy::too_many_arguments)] fn sync_one( project_root: &Path, store: &mut Store, relative: &str, + scope: &ProjectScope, outcome: &mut SyncOutcome, dependents: &mut HashSet, changed_names: &mut HashSet, @@ -356,7 +626,7 @@ fn sync_one( for dependent in store.dependent_file_paths(relative)? { dependents.insert(dependent); } - reextract_into_store(project_root, store, relative, changed_names)?; + reextract_into_store(project_root, store, relative, scope, changed_names)?; outcome.files_reindexed += 1; Ok(true) } @@ -365,12 +635,13 @@ fn reextract_into_store( project_root: &Path, store: &mut Store, relative: &str, + scope: &ProjectScope, changed_names: &mut HashSet, ) -> Result<()> { let full = project_root.join(relative); let source = fs::read_to_string(&full).with_context(|| format!("read {}", full.display()))?; let metadata = fs::metadata(&full).with_context(|| format!("stat {}", full.display()))?; - let result = extract_file(project_root, relative)?; + let result = extract_file_with_options(project_root, relative, &scope.options)?; let node_ids = result .nodes .iter() @@ -384,7 +655,7 @@ fn reextract_into_store( let file = FileRecord { path: relative.to_string(), content_hash: hash_content(&source), - language: detect_language(relative), + language: detect_language_with(relative, &scope.options.extensions), size: metadata.len() as i64, modified_at: modified_millis(&metadata), indexed_at: now_millis(), @@ -440,7 +711,7 @@ fn delete_unresolved_refs_by_file(store: &Store, relative: &str) -> rusqlite::Re ) } -fn modified_millis(metadata: &fs::Metadata) -> i64 { +pub(crate) fn modified_millis(metadata: &fs::Metadata) -> i64 { metadata .modified() .ok() @@ -449,7 +720,7 @@ fn modified_millis(metadata: &fs::Metadata) -> i64 { .unwrap_or_else(now_millis) } -fn now_millis() -> i64 { +pub(crate) fn now_millis() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_millis() as i64) @@ -477,6 +748,25 @@ pub(crate) mod tests { std::env::temp_dir().join(format!("codegraph-{name}-{}-{id}", std::process::id())); let _ = fs::remove_dir_all(&path); fs::create_dir_all(&path).unwrap(); + // State-gated fixture setup: a sync now CLASSIFIES before mutating a + // row, and a `Missing` namespace legitimately escalates to a forced + // full migration (frozen plan lines 557-565). Every incremental + // expectation below therefore starts from an EMPTY, published + // `Current` namespace, built through the shipped rebuild finalizer + // rather than by conjuring a raw database file. + let paths = index_paths(&path).expect("resolve the fixture v2 namespace"); + let deadline = Instant::now() + Duration::from_secs(30); + codegraph_store::begin_full_rebuild( + &paths, + codegraph_store::RebuildKind::Reindex, + deadline, + || false, + ) + .expect("begin the fixture namespace rebuild") + .open_store() + .expect("open the fixture namespace writer") + .finish() + .expect("publish the empty fixture namespace as Current"); Self { path } } @@ -500,7 +790,7 @@ pub(crate) mod tests { "export function answer() { return 42; }\n", ) .unwrap(); - let db = default_db_path(dir.path()); + let db = default_db_path(dir.path()).unwrap(); let first = sync_changed_paths(dir.path(), &db, ["src/app.ts"]).unwrap(); assert_eq!(first.files_reindexed, 1); @@ -529,7 +819,7 @@ pub(crate) mod tests { "export function alpha() { return 2; }\n", ) .unwrap(); - let db = default_db_path(dir.path()); + let db = default_db_path(dir.path()).unwrap(); // When: a sync processes them in reverse-sorted input order. let first = sync_changed_paths(dir.path(), &db, ["src/zeta.ts", "src/alpha.ts"]).unwrap(); @@ -566,7 +856,7 @@ pub(crate) mod tests { "export function answer() { return 42; }\n", ) .unwrap(); - let db = default_db_path(dir.path()); + let db = default_db_path(dir.path()).unwrap(); let _ = sync_changed_paths(dir.path(), &db, ["src/app.ts"]).unwrap(); // When: a sync re-checks the same unchanged file. @@ -594,7 +884,7 @@ pub(crate) mod tests { "export const x = 1;\n", ) .unwrap(); - let db = default_db_path(dir.path()); + let db = default_db_path(dir.path()).unwrap(); // When: the input mixes an escaping path (ignored via normalize), // a node_modules path (ignored via policy), a non-source file @@ -631,7 +921,7 @@ pub(crate) mod tests { fs::create_dir_all(dir.path().join("src")).unwrap(); let file = dir.path().join("src/app.ts"); fs::write(&file, "export function answer() { return 42; }\n").unwrap(); - let db = default_db_path(dir.path()); + let db = default_db_path(dir.path()).unwrap(); let first = sync_changed_paths(dir.path(), &db, ["src/app.ts"]).unwrap(); assert_eq!(first.files_reindexed, 1); @@ -665,7 +955,7 @@ pub(crate) mod tests { "import { help } from './helper';\nexport function use() { return help(); }\n", ) .unwrap(); - let db = default_db_path(dir.path()); + let db = default_db_path(dir.path()).unwrap(); sync_changed_paths(dir.path(), &db, ["src/helper.ts", "src/consumer.ts"]).unwrap(); // When: only the helper changes (adds a symbol) and is re-synced alone, @@ -683,19 +973,12 @@ pub(crate) mod tests { assert_eq!(outcome.changed_paths, vec!["src/helper.ts".to_string()]); } - /// `sync_project_once` reads the global config; initialize it once (the - /// global `OnceLock` tolerates a repeat set as an ignorable error). - fn ensure_config(project_root: &Path) { - let _ = codegraph_core::config::init_config(None, project_root); - } - #[test] fn framework_sweep_does_not_grow_unresolved_refs_across_heal_cycles() { // Given: an indexed Godot project (autoload + signal-handler refs produce // framework `unresolved_refs`), with the #1187 marker set so every sync // triggers the orphan sweep. let dir = TestDir::new("watch-godot-sweep"); - ensure_config(dir.path()); fs::create_dir_all(dir.path().join(".codegraph")).unwrap(); fs::write( dir.path().join("project.godot"), @@ -712,7 +995,7 @@ pub(crate) mod tests { "extends Node\n\nfunc _ready() -> void:\n\tvar button := Button.new()\n\tbutton.pressed.connect(_on_pressed.bind(button))\n\nfunc _goto_map() -> void:\n\tGameFlow.return_to_map()\n\nfunc _on_pressed(source) -> void:\n\tpass\n", ) .unwrap(); - let db = default_db_path(dir.path()); + let db = default_db_path(dir.path()).unwrap(); sync_project_once(dir.path()).unwrap(); let baseline = { @@ -743,7 +1026,6 @@ pub(crate) mod tests { fn sync_project_once_scans_and_removes_absent_tracked_files() { // Given: a project with two source files indexed via a full scan. let dir = TestDir::new("watch-once"); - ensure_config(dir.path()); fs::create_dir_all(dir.path().join("src")).unwrap(); fs::create_dir_all(dir.path().join(".codegraph")).unwrap(); fs::write( @@ -777,7 +1059,6 @@ pub(crate) mod tests { fn sync_project_once_with_progress_reports_monotonic_progress() { // Given: a project with a couple of source files. let dir = TestDir::new("watch-progress"); - ensure_config(dir.path()); fs::create_dir_all(dir.path().join("src")).unwrap(); fs::create_dir_all(dir.path().join(".codegraph")).unwrap(); fs::write( @@ -817,7 +1098,7 @@ pub(crate) mod tests { "export function answer() { return 42; }\n", ) .unwrap(); - let db = default_db_path(dir.path()); + let db = default_db_path(dir.path()).unwrap(); sync_changed_paths(dir.path(), &db, ["src/app.ts"]).unwrap(); // When: the stored node names for that file are queried. @@ -850,7 +1131,7 @@ pub(crate) mod tests { "import { add } from './math';\nexport function run(): number { return add(1, 2); }\n", ) .unwrap(); - let db = default_db_path(dir.path()); + let db = default_db_path(dir.path()).unwrap(); // Index both files so nodes exist, then simulate the interruption by // deleting the resolved Calls edge, re-parking its unresolved ref, and @@ -917,7 +1198,7 @@ pub(crate) mod tests { "export function run(): void { externalMissing(); }\n", ) .unwrap(); - let db = default_db_path(dir.path()); + let db = default_db_path(dir.path()).unwrap(); sync_changed_paths(dir.path(), &db, ["src/app.ts"]).unwrap(); // When: a bare no-change sync runs. @@ -943,19 +1224,27 @@ pub(crate) mod tests { ); } + /// Write this project's CURRENT-ROOT `config.toml` — the only config a sync + /// consults. + fn write_project_config(project_root: &Path, contents: &str) { + let paths = index_paths(project_root).expect("resolve project paths"); + fs::create_dir_all(paths.current_root()).unwrap(); + fs::write(paths.config_toml(), contents).unwrap(); + } + #[test] fn sync_project_once_indexes_gitignored_dir_named_in_include() { // #1063: a gitignored first-party dir named in `[indexing] include` is - // indexed on a full `codegraph sync` (sync_project_once reads the config - // and threads include into both the scan and the WatchPolicy gate). + // indexed on a full `codegraph sync`. The config is THIS project's own + // current-root `config.toml`, loaded per operation, so the assertion is + // unconditional — no process-global singleton can hold another project's + // settings. let dir = TestDir::new("watch-once-include"); - fs::create_dir_all(dir.path().join(".codegraph")).unwrap(); fs::write(dir.path().join(".gitignore"), "Tools/\n").unwrap(); - fs::write( - dir.path().join(".codegraph/config.toml"), + write_project_config( + dir.path(), "[app]\nname = \"p\"\n\n[indexing]\ninclude = [\"Tools/\"]\n", - ) - .unwrap(); + ); fs::create_dir_all(dir.path().join("src")).unwrap(); fs::create_dir_all(dir.path().join("Tools")).unwrap(); fs::write( @@ -968,26 +1257,117 @@ pub(crate) mod tests { "export function help() { return 2; }\n", ) .unwrap(); - // The config OnceLock is process-global; point it at THIS project (a - // repeat set is an ignorable error, but then it may hold another test's - // path). Guard: only assert when our config actually took effect. - let cfg = codegraph_core::config::init_config(None, dir.path()); - if cfg - .map(|c| c.indexing.include.iter().any(|p| p == "Tools/")) - .unwrap_or(false) - { - let outcome = sync_project_once(dir.path()).unwrap(); - assert!( - outcome - .changed_paths - .contains(&"Tools/helper.ts".to_string()) - ); - let store = Store::open(&default_db_path(dir.path())).unwrap(); - assert!( - store.file_by_path("Tools/helper.ts").unwrap().is_some(), - "gitignored Tools/ named in include must be indexed on sync" - ); + + let outcome = sync_project_once(dir.path()).unwrap(); + assert!( + outcome + .changed_paths + .contains(&"Tools/helper.ts".to_string()) + ); + let store = Store::open(&default_db_path(dir.path()).unwrap()).unwrap(); + assert!( + store.file_by_path("Tools/helper.ts").unwrap().is_some(), + "gitignored Tools/ named in include must be indexed on sync" + ); + } + + /// A LEGACY `.codegraph/config.toml` must never influence a sync: with the + /// same `include` written only there, the gitignored dir stays unindexed. + #[test] + fn sync_project_once_ignores_a_legacy_project_config() { + let dir = TestDir::new("watch-once-legacy-config"); + fs::write(dir.path().join(".gitignore"), "Tools/\n").unwrap(); + fs::create_dir_all(dir.path().join(".codegraph")).unwrap(); + fs::write( + dir.path().join(".codegraph/config.toml"), + "[app]\nname = \"legacy\"\n\n[indexing]\ninclude = [\"Tools/\"]\n", + ) + .unwrap(); + fs::create_dir_all(dir.path().join("Tools")).unwrap(); + fs::write( + dir.path().join("Tools/helper.ts"), + "export function help() { return 2; }\n", + ) + .unwrap(); + + let outcome = sync_project_once(dir.path()).unwrap(); + assert!( + !outcome + .changed_paths + .contains(&"Tools/helper.ts".to_string()), + "a legacy .codegraph/config.toml must not re-include a gitignored dir: {outcome:?}" + ); + } + + /// Two projects synced by ONE process use their OWN current-root configs: + /// only the project whose config names `Tools/` indexes its gitignored dir. + #[test] + fn two_projects_in_one_process_use_their_own_configs() { + let alpha = TestDir::new("watch-scope-alpha"); + let beta = TestDir::new("watch-scope-beta"); + for project in [alpha.path(), beta.path()] { + fs::write(project.join(".gitignore"), "Tools/\n").unwrap(); + fs::create_dir_all(project.join("Tools")).unwrap(); + fs::write( + project.join("Tools/helper.ts"), + "export function help() { return 2; }\n", + ) + .unwrap(); } + write_project_config( + alpha.path(), + "[app]\nname = \"alpha\"\n\n[indexing]\ninclude = [\"Tools/\"]\n", + ); + write_project_config(beta.path(), "[app]\nname = \"beta\"\n"); + + let alpha_outcome = sync_project_once(alpha.path()).unwrap(); + let beta_outcome = sync_project_once(beta.path()).unwrap(); + assert!( + alpha_outcome + .changed_paths + .contains(&"Tools/helper.ts".to_string()), + "alpha's own include must apply: {alpha_outcome:?}" + ); + assert!( + !beta_outcome + .changed_paths + .contains(&"Tools/helper.ts".to_string()), + "beta must not inherit alpha's include: {beta_outcome:?}" + ); + } + + /// A project-declared custom extension is indexed by a sync AND handled by + /// the incremental path, proving the current-root `codegraph.json` overrides + /// reach both scan and watcher gating. + #[test] + fn project_extension_overrides_reach_scan_and_incremental_sync() { + let dir = TestDir::new("watch-ext-override"); + let paths = index_paths(dir.path()).expect("resolve project paths"); + fs::create_dir_all(paths.current_root()).unwrap(); + fs::write( + paths.extension_config(), + "{\"extensions\":{\".myext\":\"lua\"}}", + ) + .unwrap(); + fs::write(dir.path().join("plugin.myext"), "local x = 1\n").unwrap(); + + let full = sync_project_once(dir.path()).unwrap(); + assert!( + full.changed_paths.contains(&"plugin.myext".to_string()), + "a project-declared extension must be indexed: {full:?}" + ); + + fs::write(dir.path().join("plugin.myext"), "local x = 2\n").unwrap(); + let incremental = sync_changed_paths( + dir.path(), + default_db_path(dir.path()).unwrap(), + ["plugin.myext"], + ) + .unwrap(); + assert_eq!( + incremental.files_reindexed, 1, + "the incremental path must handle it too: {incremental:?}" + ); } #[test] @@ -1003,7 +1383,7 @@ pub(crate) mod tests { "export function help() { return 1; }\n", ) .unwrap(); - let db = default_db_path(dir.path()); + let db = default_db_path(dir.path()).unwrap(); // Without include: the gitignored file is ignored by the policy gate. let plain = sync_changed_paths(dir.path(), &db, ["Tools/helper.ts"]).unwrap(); @@ -1017,6 +1397,7 @@ pub(crate) mod tests { ["Tools/helper.ts"], &["Tools/".to_string()], &[], + None, ) .unwrap(); assert_eq!(included.files_reindexed, 1); @@ -1027,6 +1408,139 @@ pub(crate) mod tests { ); } + /// Rewrite the authoritative state slot's built version so the namespace + /// classifies `Outdated` while its database stays byte-identical. + fn stage_outdated(dir: &TestDir) -> u64 { + let paths = index_paths(dir.path()).unwrap(); + let built = codegraph_store::CURRENT_EXTRACTION_VERSION - 1; + let identity = paths.project_identity(); + let checksum = codegraph_store::checksum_hex( + 77, + codegraph_store::CURRENT_STORAGE_PROTOCOL, + built, + "current", + identity, + ); + let body = format!( + "{{\"sequence\":77,\"storageProtocol\":{},\"extractionVersion\":{built},\ + \"phase\":\"current\",\"projectIdentity\":\"{identity}\",\"checksum\":\"{checksum}\"}}\n", + codegraph_store::CURRENT_STORAGE_PROTOCOL, + ); + let [slot0, slot1] = paths.state_slots(); + fs::write(&slot0, body).unwrap(); + let _ = fs::remove_file(&slot1); + assert_eq!( + Store::extraction_status(&paths), + codegraph_store::ExtractionStatus::Outdated { built } + ); + built + } + + #[test] + fn watcher_sync_on_outdated_namespace_migrates_every_file_without_skips() { + // Given: an indexed project whose namespace is then marked as built by an + // OLDER extraction version, with every source byte unchanged. + let dir = TestDir::new("watch-outdated-migrate"); + fs::create_dir_all(dir.path().join("src")).unwrap(); + fs::write( + dir.path().join("src/alpha.ts"), + "export function alpha() { return 1; }\n", + ) + .unwrap(); + fs::write( + dir.path().join("src/beta.ts"), + "export function beta() { return 2; }\n", + ) + .unwrap(); + let db = default_db_path(dir.path()).unwrap(); + let first = sync_changed_paths(dir.path(), &db, ["src/alpha.ts", "src/beta.ts"]).unwrap(); + assert_eq!(first.files_reindexed, 2); + stage_outdated(&dir); + + // When: the watcher's incremental entry point runs for ONE changed path. + let outcome = sync_changed_paths(dir.path(), &db, ["src/alpha.ts"]).unwrap(); + + // Then: it escalated to a full migration — every candidate was processed, + // nothing was skipped as unchanged, changed_paths is the sorted full set, + // and the namespace is a readable Current again. + assert_eq!(outcome.files_skipped_unchanged, 0); + assert_eq!(outcome.files_reindexed, 2); + assert_eq!( + outcome.changed_paths, + vec!["src/alpha.ts".to_string(), "src/beta.ts".to_string()] + ); + let paths = index_paths(dir.path()).unwrap(); + assert_eq!( + Store::extraction_status(&paths), + codegraph_store::ExtractionStatus::Current + ); + let store = + Store::open_for_read(&paths, Instant::now() + Duration::from_secs(10), || false) + .unwrap(); + assert!(store.file_by_path("src/beta.ts").unwrap().is_some()); + } + + #[test] + fn full_sync_on_outdated_namespace_migrates_and_drops_absent_files() { + // Given: two indexed files, one of which is then deleted, over a + // namespace marked Outdated. + let dir = TestDir::new("watch-outdated-full"); + fs::create_dir_all(dir.path().join("src")).unwrap(); + fs::write( + dir.path().join("src/alpha.ts"), + "export function alpha() { return 1; }\n", + ) + .unwrap(); + fs::write( + dir.path().join("src/beta.ts"), + "export function beta() { return 2; }\n", + ) + .unwrap(); + sync_project_once(dir.path()).unwrap(); + fs::remove_file(dir.path().join("src/beta.ts")).unwrap(); + stage_outdated(&dir); + + // When: the cold full sync runs. + let outcome = sync_project_once(dir.path()).unwrap(); + + // Then: migration re-extracted the surviving file with no skips and the + // absent file is gone from the fresh database. + assert_eq!(outcome.files_skipped_unchanged, 0); + assert_eq!(outcome.changed_paths, vec!["src/alpha.ts".to_string()]); + let paths = index_paths(dir.path()).unwrap(); + let store = + Store::open_for_read(&paths, Instant::now() + Duration::from_secs(10), || false) + .unwrap(); + assert!(store.file_by_path("src/beta.ts").unwrap().is_none()); + } + + #[test] + fn incremental_sync_rejects_a_database_outside_the_resolved_namespace() { + // Given: an initialized project and a database path that is NOT its + // resolved v2 database. + let dir = TestDir::new("watch-foreign-db"); + fs::create_dir_all(dir.path().join("src")).unwrap(); + fs::write( + dir.path().join("src/app.ts"), + "export function app() { return 1; }\n", + ) + .unwrap(); + let foreign = dir.path().join("elsewhere.db"); + + // When/Then: the sync refuses rather than mutating an unclassified file. + let error = sync_changed_paths(dir.path(), &foreign, ["src/app.ts"]).unwrap_err(); + assert!( + error + .to_string() + .contains("is not the resolved v2 database"), + "unexpected error: {error}" + ); + assert!( + !foreign.exists(), + "a refused sync must not create the foreign database" + ); + } + #[test] fn deleting_an_imported_module_refreshes_its_dependents() { // Given: a helper module and a consumer that resolves an import to it, @@ -1043,7 +1557,7 @@ pub(crate) mod tests { "import { help } from './helper';\nexport function use() { return help(); }\n", ) .unwrap(); - let db = default_db_path(dir.path()); + let db = default_db_path(dir.path()).unwrap(); sync_changed_paths(dir.path(), &db, ["src/helper.ts", "src/consumer.ts"]).unwrap(); // When: the helper is deleted and re-synced, driving the delete branch diff --git a/crates/codegraph-watch/src/watcher.rs b/crates/codegraph-watch/src/watcher.rs index 70d4a99..c7a08c3 100644 --- a/crates/codegraph-watch/src/watcher.rs +++ b/crates/codegraph-watch/src/watcher.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::io; use std::path::{Path, PathBuf}; @@ -9,13 +9,24 @@ use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use anyhow::Result; +use notify::event::{EventKind, RemoveKind}; use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher}; +use codegraph_core::IndexPaths; +use codegraph_core::config::Config; +use codegraph_extract::ExtensionOverrides; + use crate::policy::{WatchPolicy, watch_disabled_reason}; -use crate::sync::{SyncOutcome, default_db_path, sync_changed_paths_with_patterns}; +use crate::sync::{ + SyncCancellation, SyncOutcome, default_db_path, sync_changed_paths_with_patterns, + sync_project_once_with_patterns, +}; type SyncCallback = Arc; type SyncFn = Arc) -> Result + Send + Sync>; +/// Whole-project sync, used when an event cannot be expressed as a path list — +/// currently only a removed DIRECTORY (see [`RemovalHint`]). +type FullSyncFn = Arc Result + Send + Sync>; type NoticeCallback = Arc; /// The OS watcher, shared between [`ProjectWatcher`] (which owns its lifetime) @@ -182,6 +193,13 @@ fn collect_watch_dirs(root: &Path, policy: &WatchPolicy) -> Vec { dirs } +fn known_directory_paths(root: &Path, policy: &WatchPolicy) -> BTreeSet { + collect_watch_dirs(root, policy) + .into_iter() + .filter_map(|dir| policy.normalize_relative(dir)) + .collect() +} + fn initial_watch_targets( backend: WatchBackend, project_root: &Path, @@ -283,12 +301,23 @@ pub struct WatchOptions { /// Called for a non-degrading watch/sync error (e.g. inotify watch-count /// exhaustion). May fire more than once; the watcher keeps running. pub on_sync_error: Option, - /// `codegraph.json`/`config.toml` `include`/`exclude` path patterns (#1063). - /// Threaded into the [`WatchPolicy`] and the default incremental-sync fn so - /// the watcher's scope matches the scan's. Empty = pre-#1063 behavior. + /// The addressed project's `config.toml` `include`/`exclude` path patterns + /// (#1063). Threaded into the [`WatchPolicy`] and the default incremental-sync + /// fn so the watcher's scope matches the scan's. Empty = pre-#1063 behavior. pub include: Vec, pub exclude: Vec, + /// The addressed project's custom extension→language overrides (its + /// current-root `codegraph.json`), so the watcher HANDLES a file the project + /// declared as source. Empty = built-in detection only. + pub extensions: Arc, sync_fn: Option, + /// Override for the whole-project sync a removed directory escalates to. + /// Defaults to [`crate::sync::sync_project_once`]; tests inject a counter. + full_sync_fn: Option, + /// Cooperative cancellation shared with the default sync closures, so a + /// shutdown can refuse queued lease loops and interrupt a running one + /// (frozen plan lines 598-601). + cancel: SyncCancellation, } impl Default for WatchOptions { @@ -305,7 +334,40 @@ impl Default for WatchOptions { on_sync_error: None, include: Vec::new(), exclude: Vec::new(), + extensions: ExtensionOverrides::empty(), sync_fn: None, + full_sync_fn: None, + cancel: SyncCancellation::new(), + } + } +} + +impl WatchOptions { + /// Build watch options from ONE project's immutable [`Config`] and its own + /// extension overrides. + /// + /// `include`/`exclude` and the debounce window come from that project, and + /// `watch.enabled = false` disables watching for it — so two projects served + /// by one process can watch different scopes (or one not at all). An explicit + /// `CODEGRAPH_WATCH_DEBOUNCE_MS` still wins, keeping the documented env escape + /// hatch authoritative over config. + /// Share `cancel` with this watcher's default sync closures so a shutdown + /// can cancel queued and running lease loops. + #[must_use] + pub fn with_cancellation(mut self, cancel: SyncCancellation) -> Self { + self.cancel = cancel; + self + } + + #[must_use] + pub fn for_project(config: &Config, extensions: Arc) -> Self { + Self { + debounce: debounce_from_env_or(config.watch.debounce_ms), + no_watch: !config.watch.enabled, + include: config.indexing.include.clone(), + exclude: config.indexing.exclude.clone(), + extensions, + ..Self::default() } } } @@ -322,6 +384,11 @@ pub struct ProjectWatcher { thread: Option>, watcher: SharedWatcher, degraded: Arc, + cancel: SyncCancellation, + /// Set by the event-loop thread as its LAST action. Lets a caller observe + /// completion without joining, so a bounded shutdown never blocks on a + /// still-running sync (see [`Self::begin_shutdown`]). + finished: Arc, } pub fn start_serve_watcher( @@ -331,27 +398,71 @@ pub fn start_serve_watcher( ProjectWatcher::start(project_root, options) } +/// Build [`WatchOptions`] from the ADDRESSED project's own immutable config. +/// +/// Loads `/config.toml` and `/codegraph.json` through +/// the resolved [`IndexPaths`], so a caller that serves several projects in one +/// process (a shared daemon, a global HTTP server) gives each watcher that +/// project's include/exclude, debounce, enable flag, and extension overrides — +/// never another project's and never a process-global value. +pub fn watch_options_for_project(project_root: impl AsRef) -> Result { + let paths = IndexPaths::resolve( + project_root.as_ref(), + std::env::var("CODEGRAPH_DIR").ok().as_deref(), + )?; + let config = Config::load_for_paths(None, &paths)?; + let extensions = ExtensionOverrides::load_for_paths(&paths); + Ok(WatchOptions::for_project(&config, extensions)) +} + impl ProjectWatcher { pub fn start(project_root: impl AsRef, options: WatchOptions) -> Result> { let project_root = project_root.as_ref().to_path_buf(); if watch_disabled_reason(&project_root, options.no_watch).is_some() { return Ok(None); } - let policy = WatchPolicy::with_config(&project_root, &options.include, &options.exclude); - let db_path = options - .db_path - .clone() - .unwrap_or_else(|| default_db_path(&project_root)); + let policy = WatchPolicy::with_config(&project_root, &options.include, &options.exclude) + .with_extension_overrides(Arc::clone(&options.extensions)); + let db_path = match options.db_path.clone() { + Some(db) => db, + None => default_db_path(&project_root)?, + }; + let cancel = options.cancel.clone(); let sync_fn = options.sync_fn.clone().unwrap_or_else(|| { let project_root = project_root.clone(); let include = options.include.clone(); let exclude = options.exclude.clone(); + let cancel = cancel.clone(); Arc::new(move |paths| { - sync_changed_paths_with_patterns(&project_root, &db_path, paths, &include, &exclude) + sync_changed_paths_with_patterns( + &project_root, + &db_path, + paths, + &include, + &exclude, + Some(&cancel), + ) + }) + }); + // A removed directory cannot be expressed as a path list (its tracked + // descendants are only discoverable by diffing the whole index against + // disk), so it escalates to the SAME full sync `codegraph sync` runs. + let full_sync_fn = options.full_sync_fn.clone().unwrap_or_else(|| { + let project_root = project_root.clone(); + let include = options.include.clone(); + let exclude = options.exclude.clone(); + let cancel = cancel.clone(); + Arc::new(move || { + sync_project_once_with_patterns(&project_root, &include, &exclude, Some(&cancel)) }) }); let (tx, rx) = mpsc::channel(); let degraded = Arc::new(DegradedState::default()); + // Capture directory identity before registering the backend. Windows emits + // `RemoveKind::Any` after deletion, so this pre-event snapshot is the only + // deterministic way to distinguish a known directory from an extensionless + // file without inspecting a path that no longer exists. + let known_dirs = known_directory_paths(&project_root, &policy); // Build the OS watcher and register the pruned watch set BEFORE spawning // the loop, so its create-event handler can share the same watcher to add @@ -363,7 +474,10 @@ impl ProjectWatcher { let mut watcher = notify::recommended_watcher(move |event: notify::Result| match event { Ok(event) => { - let _ = callback_tx.send(LoopMessage::Event(event.paths)); + let _ = callback_tx.send(LoopMessage::Event(WatchEventBatch::from_event( + &event.kind, + event.paths, + ))); } Err(err) => { let _ = callback_tx.send(LoopMessage::WatchError(err)); @@ -426,18 +540,23 @@ impl ProjectWatcher { let debounce = options.debounce; let loop_degraded = Arc::clone(°raded); let loop_watcher = Arc::clone(&watcher); + let finished = Arc::new(AtomicBool::new(false)); + let loop_finished = Arc::clone(&finished); let thread = thread::spawn(move || { event_loop(EventLoopCtx { rx, policy: loop_policy, debounce, sync_fn, + full_sync_fn, on_sync_complete, on_degraded, on_sync_error, degraded: loop_degraded, watcher: loop_watcher, + known_dirs, }); + loop_finished.store(true, Ordering::SeqCst); }); Ok(Some(Self { @@ -445,9 +564,55 @@ impl ProjectWatcher { thread: Some(thread), watcher, degraded, + cancel, + finished, })) } + /// Begin shutdown WITHOUT joining: stop delivering new OS events, refuse + /// queued lease loops, interrupt a running one, and ask the event loop to + /// exit. Idempotent and never blocks. + /// + /// The join is deliberately separated from this signal. A running sync can + /// hold the event-loop thread for the whole extraction pass, so joining here + /// (as `stop`/`Drop` do) would block past any caller-side deadline and make a + /// bounded drain unbounded. Callers poll [`Self::is_finished`] against their + /// own budget and then either [`Self::stop`] (an instant join) or + /// [`Self::detach`]. + pub fn begin_shutdown(&self) { + self.cancel.cancel(); + if let Ok(mut guard) = self.watcher.lock() { + let _ = guard.take(); + } + let _ = self.tx.send(LoopMessage::Stop); + } + + /// Whether the event-loop thread has run to completion. + #[must_use] + pub fn is_finished(&self) -> bool { + self.finished.load(Ordering::SeqCst) + } + + /// Give up ownership of the event-loop thread without joining it. + /// + /// Used only after a bounded shutdown deadline elapsed: the loop is already + /// cancelled and will exit on its own, and the caller has already reported an + /// INCOMPLETE drain, so nothing destructive proceeds behind it. Dropping the + /// `JoinHandle` detaches the thread, and the subsequent `Drop` finds no handle + /// to join. + pub fn detach(mut self) { + let _ = self.thread.take(); + } + + /// The watcher's cooperative cancellation handle. A shutdown cancels it so + /// queued lease loops refuse immediately and a running one returns a typed + /// error, then waits for [`SyncCancellation::active_syncs`] to reach zero + /// before considering the watcher drained. + #[must_use] + pub fn cancellation(&self) -> SyncCancellation { + self.cancel.clone() + } + pub fn is_degraded(&self) -> bool { self.degraded.is_degraded() } @@ -457,7 +622,35 @@ impl ProjectWatcher { } pub fn ingest_event_for_tests(&self, relative: impl Into) { - let _ = self.tx.send(LoopMessage::Event(vec![relative.into()])); + let _ = self.tx.send(LoopMessage::Event(WatchEventBatch::paths(vec![ + relative.into(), + ]))); + } + + /// Feed a REMOVED-DIRECTORY event, the notify `Remove(RemoveKind::Folder)` + /// shape, without needing a real OS watcher. + pub fn ingest_removed_dir_for_tests(&self, relative: impl Into) { + let _ = self.tx.send(LoopMessage::Event(WatchEventBatch::from_event( + &EventKind::Remove(RemoveKind::Folder), + vec![relative.into()], + ))); + } + + /// Feed the ambiguous removal shape emitted by Windows + /// `ReadDirectoryChangesW`, without requiring a native Windows runner. + pub fn ingest_ambiguous_remove_for_tests(&self, relative: impl Into) { + let _ = self.tx.send(LoopMessage::Event(WatchEventBatch::from_event( + &EventKind::Remove(RemoveKind::Any), + vec![relative.into()], + ))); + } + + #[cfg(test)] + fn flush_for_tests(&self) { + let (tx, rx) = mpsc::channel(); + let _ = self.tx.send(LoopMessage::Flush(tx)); + rx.recv_timeout(Duration::from_secs(1)) + .expect("event loop accepted deterministic flush"); } pub fn pending_files(&self) -> Vec { @@ -471,10 +664,9 @@ impl ProjectWatcher { } fn stop_inner(&mut self) { - if let Ok(mut guard) = self.watcher.lock() { - let _ = guard.take(); - } - let _ = self.tx.send(LoopMessage::Stop); + // Signal first (never join without cancelling: the event-loop thread may be + // inside a bounded lease acquisition), then join. + self.begin_shutdown(); if let Some(thread) = self.thread.take() { let _ = thread.join(); } @@ -488,12 +680,71 @@ impl Drop for ProjectWatcher { } enum LoopMessage { - Event(Vec), + Event(WatchEventBatch), WatchError(notify::Error), Snapshot(Sender>), + #[cfg(test)] + Flush(Sender<()>), Stop, } +/// One notify event, reduced to exactly what the debounce loop needs: the paths, +/// plus the backend's removal classification. +/// +/// The distinction has to travel with the event because it cannot be recovered +/// later: a removed directory is already gone from disk, so `path.is_dir()` is +/// false and its extensionless name is indistinguishable from a deleted +/// extensionless file. Carrying the notify `EventKind` forward keeps the +/// escalation deterministic instead of guessing from a missing path. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct WatchEventBatch { + paths: Vec, + removal: RemovalHint, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum RemovalHint { + #[default] + None, + Directory, + Ambiguous, +} + +impl WatchEventBatch { + fn paths(paths: Vec) -> Self { + Self { + paths, + removal: RemovalHint::None, + } + } + + fn from_event(kind: &EventKind, paths: Vec) -> Self { + let removal = match kind { + EventKind::Remove(RemoveKind::Folder) => RemovalHint::Directory, + EventKind::Remove(RemoveKind::Any) => RemovalHint::Ambiguous, + _ => RemovalHint::None, + }; + Self { paths, removal } + } +} + +fn classify_removed_directory( + hint: RemovalHint, + relative: &str, + known_dirs: &mut BTreeSet, +) -> bool { + let removed_dir = match hint { + RemovalHint::Directory => true, + RemovalHint::Ambiguous => known_dirs.contains(relative), + RemovalHint::None => false, + }; + if removed_dir { + let descendant_prefix = format!("{relative}/"); + known_dirs.retain(|known| known != relative && !known.starts_with(&descendant_prefix)); + } + removed_dir +} + #[derive(Debug, Clone)] struct PendingInfo { first_seen_ms: u128, @@ -505,11 +756,13 @@ struct EventLoopCtx { policy: WatchPolicy, debounce: Duration, sync_fn: SyncFn, + full_sync_fn: FullSyncFn, on_sync_complete: Option, on_degraded: Option, on_sync_error: Option, degraded: Arc, watcher: SharedWatcher, + known_dirs: BTreeSet, } fn event_loop(ctx: EventLoopCtx) { @@ -518,15 +771,21 @@ fn event_loop(ctx: EventLoopCtx) { policy, debounce, sync_fn, + full_sync_fn, on_sync_complete, on_degraded, on_sync_error, degraded, watcher, + mut known_dirs, } = ctx; let mut pending = BTreeMap::::new(); let mut deadline = None::; let mut consecutive_sync_errors = 0u32; + // Set by a removed-directory event in the current burst. It DOMINATES the + // per-path list (one full sync instead of N incremental ones) yet still + // flushes exactly once, on the same debounce deadline. + let mut full_sync_pending = false; loop { let message = match deadline { Some(when) => match rx.recv_timeout(when.saturating_duration_since(Instant::now())) { @@ -541,9 +800,30 @@ fn event_loop(ctx: EventLoopCtx) { }; match message { - Some(LoopMessage::Event(paths)) => { + Some(LoopMessage::Event(batch)) => { + let WatchEventBatch { paths, removal } = batch; for path in paths { if let Some(relative) = policy.normalize_relative(&path) { + // A removed DIRECTORY bypasses extension filtering: it has + // no source extension, so the file gate below would drop it + // and every tracked descendant would linger in the index + // forever. The watch policy still applies (an ignored dir is + // still ignored), and the removal escalates the burst to one + // full sync — the only pass that can find those descendants. + if classify_removed_directory(removal, &relative, &mut known_dirs) { + if policy.should_watch_dir(&relative) { + full_sync_pending = true; + let now = epoch_millis(); + pending + .entry(relative) + .and_modify(|info| info.last_seen_ms = now) + .or_insert(PendingInfo { + first_seen_ms: now, + last_seen_ms: now, + }); + } + continue; + } // A brand-new non-ignored directory holds no inotify watch // yet (Linux watches are per-dir NonRecursive — see // `collect_watch_dirs`). Register it (and any non-ignored @@ -551,6 +831,7 @@ fn event_loop(ctx: EventLoopCtx) { // edits inside it are seen without a server restart. if path.is_dir() && policy.should_watch_dir(&relative) { register_new_dirs(&watcher, &policy, &path); + known_dirs.extend(known_directory_paths(&path, &policy)); } if policy.should_handle_file(&relative) || (policy.allows_file_path(&relative) @@ -582,15 +863,32 @@ fn event_loop(ctx: EventLoopCtx) { Some(LoopMessage::Snapshot(reply)) => { let _ = reply.send(snapshot(&pending)); } + #[cfg(test)] + Some(LoopMessage::Flush(reply)) => { + if !pending.is_empty() { + deadline = Some(Instant::now()); + } + let _ = reply.send(()); + } Some(LoopMessage::Stop) => break, None => { let paths = pending.keys().cloned().collect::>(); pending.clear(); deadline = None; - let attempt = run_sync_with_backoff(&sync_fn, paths); + let full_sync = std::mem::take(&mut full_sync_pending); + let attempt = if full_sync { + run_full_sync_with_backoff(&full_sync_fn) + } else { + run_sync_with_backoff(&sync_fn, paths.clone()) + }; let decision = classify_persistent_failure(&attempt, &mut consecutive_sync_errors); match attempt { SyncAttempt::Done(outcome) => { + // Preserve the event batch independently of actual DB + // mutations. Startup catch-up can win the writer lease and + // index the same file first; the watcher still handled this + // event and its callback must be able to report that fact. + let outcome = with_trigger_paths(outcome, paths); if let Some(callback) = &on_sync_complete { callback(outcome); } @@ -618,6 +916,11 @@ fn event_loop(ctx: EventLoopCtx) { } } +fn with_trigger_paths(mut outcome: SyncOutcome, trigger_paths: Vec) -> SyncOutcome { + outcome.trigger_paths = trigger_paths; + outcome +} + /// Apply the EMFILE/ENFILE → degrade-once, ENOSPC → warn classification to a /// backend watch error. Returns the class so the event loop can stop the watch /// on `Degrade`. `on_degraded` fires at most once across the watcher's life. @@ -702,18 +1005,35 @@ fn run_sync_with_backoff(sync_fn: &SyncFn, paths: Vec) -> SyncAttempt { run_sync_with_backoff_inner(sync_fn, paths, MAX_BACKOFF, thread::sleep) } +/// Same bounded-backoff contract as [`run_sync_with_backoff`], for the +/// whole-project sync a removed directory escalates to. +fn run_full_sync_with_backoff(full_sync_fn: &FullSyncFn) -> SyncAttempt { + run_with_backoff(MAX_BACKOFF, thread::sleep, || full_sync_fn()) +} + /// Inner retry loop with an injectable budget and sleeper so the cap can be /// unit-tested without sleeping a real 30 seconds. fn run_sync_with_backoff_inner( sync_fn: &SyncFn, paths: Vec, + budget: Duration, + sleeper: impl FnMut(Duration), +) -> SyncAttempt { + run_with_backoff(budget, sleeper, || sync_fn(paths.clone())) +} + +/// The shared retry policy: retry on write-lock contention with bounded +/// exponential backoff capped at [`MAX_BACKOFF`], degrade once the cumulative +/// sleep budget is spent, and surface any non-contention error immediately. +fn run_with_backoff( budget: Duration, mut sleeper: impl FnMut(Duration), + mut run: impl FnMut() -> Result, ) -> SyncAttempt { let mut backoff = Duration::ZERO; let mut slept = Duration::ZERO; loop { - match sync_fn(paths.clone()) { + match run() { Ok(outcome) => return SyncAttempt::Done(outcome), Err(err) => { if !is_lock_contention(&err) { @@ -758,10 +1078,17 @@ fn maybe_deleted_source(relative: &str) -> bool { } fn debounce_from_env() -> Duration { + debounce_from_env_or(2_000) +} + +/// The debounce window: `CODEGRAPH_WATCH_DEBOUNCE_MS` when set (the documented +/// env escape hatch), else `fallback_ms` (a project's `watch.debounce_ms`, or the +/// upstream 2000ms default). Always clamped to [100ms, 60s]. +fn debounce_from_env_or(fallback_ms: u64) -> Duration { let millis = std::env::var("CODEGRAPH_WATCH_DEBOUNCE_MS") .ok() .and_then(|raw| raw.parse::().ok()) - .unwrap_or(2_000) + .unwrap_or(fallback_ms) .clamp(100, 60_000); Duration::from_millis(millis) } @@ -779,11 +1106,27 @@ mod tests { use std::fs; use std::sync::{Arc, Mutex}; + #[test] + fn noop_watcher_completion_preserves_trigger_paths() { + let outcome = with_trigger_paths( + SyncOutcome::default(), + vec!["src/brand_new_symbol.ts".to_string()], + ); + + assert_eq!(outcome.files_reindexed, 0); + assert!(outcome.changed_paths.is_empty()); + assert_eq!( + outcome.trigger_paths, + vec!["src/brand_new_symbol.ts".to_string()] + ); + } + #[test] fn rapid_save_burst_triggers_exactly_one_reindex() { + let _env = crate::test_env::env_guard(); let dir = crate::sync::tests::TestDir::new("watch-debounce"); fs::create_dir_all(dir.path().join("src")).unwrap(); - let db = crate::sync::default_db_path(dir.path()); + let db = crate::sync::default_db_path(dir.path()).unwrap(); let outcomes = Arc::new(Mutex::new(Vec::new())); let seen = Arc::clone(&outcomes); let watcher = ProjectWatcher::start( @@ -828,8 +1171,347 @@ mod tests { assert_eq!(outcomes[0].files_checked, 1); } + #[test] + fn deleted_directory_event_schedules_one_full_sync() { + let _env = crate::test_env::env_guard(); + // A removed DIRECTORY has no source extension, so extension-based event + // filtering drops it and every tracked descendant lingers in the index + // forever. The watcher must escalate the removal to exactly ONE + // full-project sync — the only pass that discovers every absent tracked + // descendant — instead of an incremental path-scoped sync. + let dir = crate::sync::tests::TestDir::new("watch-deleted-dir"); + fs::write(dir.path().join(".gitignore"), "Tools/\n").unwrap(); + fs::create_dir_all(dir.path().join("Tools/feature")).unwrap(); + fs::create_dir_all(dir.path().join("src")).unwrap(); + fs::write( + dir.path().join("Tools/feature/alpha.ts"), + "export function alpha() { return 1; }\n", + ) + .unwrap(); + fs::write( + dir.path().join("Tools/feature/beta.ts"), + "export function beta() { return 2; }\n", + ) + .unwrap(); + fs::write( + dir.path().join("Tools/keep.ts"), + "export function includedBefore() { return 3; }\n", + ) + .unwrap(); + fs::write( + dir.path().join("src/excluded.ts"), + "export function excludedBefore() { return 4; }\n", + ) + .unwrap(); + let db = crate::sync::default_db_path(dir.path()).unwrap(); + let indexed = crate::sync::sync_changed_paths_with_patterns( + dir.path(), + &db, + [ + "Tools/feature/alpha.ts", + "Tools/feature/beta.ts", + "Tools/keep.ts", + "src/excluded.ts", + ], + &["Tools/".to_string()], + &[], + None, + ) + .unwrap(); + assert_eq!( + indexed.files_reindexed, 4, + "all fixture files indexed first" + ); + + // The watcher-local patterns deliberately oppose the process-global/default + // full-sync scope: include the gitignored Tools tree, but exclude one normal + // source file. Both surviving files change before the directory removal so + // the resulting stored symbols prove which patterns the full sync used. + fs::write( + dir.path().join("Tools/keep.ts"), + "export function includedAfter() { return 30; }\n", + ) + .unwrap(); + fs::write( + dir.path().join("src/excluded.ts"), + "export function excludedAfter() { return 40; }\n", + ) + .unwrap(); + + let (outcome_tx, outcome_rx) = mpsc::channel(); + // An injected INCREMENTAL sync_fn that must never run: a directory + // removal is not a per-path event. + let incremental_calls = Arc::new(AtomicUsize::new(0)); + let incremental = Arc::clone(&incremental_calls); + let sync_fn: SyncFn = Arc::new(move |_paths| { + incremental.fetch_add(1, AtomicOrdering::SeqCst); + Ok(SyncOutcome::default()) + }); + let watcher = ProjectWatcher::start( + dir.path(), + WatchOptions { + debounce: Duration::from_secs(30), + inert_for_tests: true, + db_path: Some(db.clone()), + sync_fn: Some(sync_fn), + include: vec!["Tools/".to_string()], + exclude: vec!["src/excluded.ts".to_string()], + on_sync_complete: Some(Arc::new(move |outcome| { + outcome_tx.send(outcome).unwrap(); + })), + ..WatchOptions::default() + }, + ) + .unwrap() + .unwrap(); + + // When: the directory disappears and the backend reports the ambiguous + // Windows removal shape. The watcher must recover directory identity from + // its startup registration state rather than inspecting the missing path. + fs::remove_dir_all(dir.path().join("Tools/feature")).unwrap(); + watcher.ingest_ambiguous_remove_for_tests("Tools/feature"); + watcher.flush_for_tests(); + let outcome = outcome_rx + .recv_timeout(Duration::from_secs(2)) + .expect("removed-directory sync completion"); + watcher.stop(); + + // Then: exactly one sync fired, it was the FULL sync (not the injected + // incremental one), it removed both tracked descendants, and the removed + // directory is reported as the trigger path. + assert!(outcome_rx.try_recv().is_err(), "only one sync may complete"); + assert_eq!( + incremental_calls.load(AtomicOrdering::SeqCst), + 0, + "a directory removal must NOT take the incremental per-path sync" + ); + assert_eq!( + outcome.files_removed, 2, + "the full sync must drop every tracked descendant of the missing dir" + ); + assert_eq!(outcome.trigger_paths, vec!["Tools/feature".to_string()]); + let store = codegraph_store::Store::open(&db).unwrap(); + for gone in ["Tools/feature/alpha.ts", "Tools/feature/beta.ts"] { + assert!( + store.file_by_path(gone).unwrap().is_none(), + "{gone} must be gone from the store after the directory removal" + ); + } + let included_names = store + .nodes_by_file_path("Tools/keep.ts") + .unwrap() + .into_iter() + .map(|node| node.name) + .collect::>(); + assert!( + included_names.iter().any(|name| name == "includedAfter"), + "watcher-local include must let full sync refresh Tools/keep.ts: {included_names:?}" + ); + let excluded_names = store + .nodes_by_file_path("src/excluded.ts") + .unwrap() + .into_iter() + .map(|node| node.name) + .collect::>(); + assert!( + excluded_names.iter().any(|name| name == "excludedBefore") + && !excluded_names.iter().any(|name| name == "excludedAfter"), + "watcher-local exclude must keep src/excluded.ts untouched: {excluded_names:?}" + ); + } + + #[test] + fn ambiguous_remove_of_known_directory_keeps_directory_semantics() { + // Windows ReadDirectoryChangesW reports directory deletion as Any. The + // current dirty implementation loses that identity unconditionally; + // the corrected classifier must corroborate it from the watcher's known + // directory state rather than from extension or the now-missing path. + let dir = crate::sync::tests::TestDir::new("watch-ambiguous-remove"); + fs::create_dir_all(dir.path().join("src/feature")).unwrap(); + fs::write(dir.path().join("src/README"), "extensionless file\n").unwrap(); + let policy = WatchPolicy::new(dir.path()); + let mut known_dirs = known_directory_paths(dir.path(), &policy); + fs::remove_dir_all(dir.path().join("src/feature")).unwrap(); + fs::remove_file(dir.path().join("src/README")).unwrap(); + + let directory_batch = WatchEventBatch::from_event( + &EventKind::Remove(RemoveKind::Any), + vec![PathBuf::from("src/feature")], + ); + assert!( + classify_removed_directory(directory_batch.removal, "src/feature", &mut known_dirs,), + "an ambiguous removal of a watcher-known directory must remain a directory removal" + ); + + let file_batch = WatchEventBatch::from_event( + &EventKind::Remove(RemoveKind::Any), + vec![PathBuf::from("src/README")], + ); + assert!( + !classify_removed_directory(file_batch.removal, "src/README", &mut known_dirs), + "an extensionless deleted file must not be guessed to be a directory" + ); + } + + #[test] + fn real_watcher_classifies_a_removed_directory_as_a_full_sync() { + let _env = crate::test_env::env_guard(); + // End-to-end with a REAL notify watcher: proves the OS actually delivers + // either an explicit Folder removal or Windows' ambiguous Any removal, + // and that the watcher escalates the known directory to a full sync. + let dir = crate::sync::tests::TestDir::new("watch-real-deleted-dir"); + fs::create_dir_all(dir.path().join("src/feature")).unwrap(); + fs::write( + dir.path().join("src/feature/mod.ts"), + "export const x = 1;\n", + ) + .unwrap(); + + let full_calls = Arc::new(AtomicUsize::new(0)); + let full_counter = Arc::clone(&full_calls); + let full_sync_fn: FullSyncFn = Arc::new(move || { + full_counter.fetch_add(1, AtomicOrdering::SeqCst); + Ok(SyncOutcome::default()) + }); + let sync_fn: SyncFn = Arc::new(|_paths| Ok(SyncOutcome::default())); + let watcher = ProjectWatcher::start( + dir.path(), + WatchOptions { + debounce: Duration::from_millis(50), + sync_fn: Some(sync_fn), + full_sync_fn: Some(full_sync_fn), + ..WatchOptions::default() + }, + ) + .unwrap() + .unwrap(); + + std::thread::sleep(Duration::from_millis(150)); + fs::remove_dir_all(dir.path().join("src/feature")).unwrap(); + + let mut saw_full = false; + for _ in 0..40 { + std::thread::sleep(Duration::from_millis(50)); + if full_calls.load(AtomicOrdering::SeqCst) > 0 { + saw_full = true; + break; + } + } + watcher.stop(); + assert!( + saw_full, + "removing a watched directory must escalate to the full sync" + ); + } + + #[test] + fn directory_removal_burst_deduplicates_to_one_full_sync() { + let _env = crate::test_env::env_guard(); + // A `rm -rf` burst surfaces the folder removal AND each child removal. + // The full-sync escalation must dominate the per-file paths yet still + // collapse to ONE sync, with every event path retained as a trigger. + let dir = crate::sync::tests::TestDir::new("watch-deleted-dir-burst"); + let full_calls = Arc::new(AtomicUsize::new(0)); + let full_counter = Arc::clone(&full_calls); + let full_sync_fn: FullSyncFn = Arc::new(move || { + full_counter.fetch_add(1, AtomicOrdering::SeqCst); + Ok(SyncOutcome { + files_removed: 2, + ..Default::default() + }) + }); + let incremental_calls = Arc::new(AtomicUsize::new(0)); + let incremental = Arc::clone(&incremental_calls); + let sync_fn: SyncFn = Arc::new(move |_paths| { + incremental.fetch_add(1, AtomicOrdering::SeqCst); + Ok(SyncOutcome::default()) + }); + let (outcome_tx, outcome_rx) = mpsc::channel(); + let watcher = ProjectWatcher::start( + dir.path(), + WatchOptions { + debounce: Duration::from_secs(30), + inert_for_tests: true, + sync_fn: Some(sync_fn), + full_sync_fn: Some(full_sync_fn), + on_sync_complete: Some(Arc::new(move |outcome| { + outcome_tx.send(outcome).unwrap(); + })), + ..WatchOptions::default() + }, + ) + .unwrap() + .unwrap(); + + watcher.ingest_event_for_tests("src/feature/beta.ts"); + watcher.ingest_removed_dir_for_tests("src/feature"); + watcher.ingest_event_for_tests("src/feature/alpha.ts"); + watcher.ingest_event_for_tests("src/feature/beta.ts"); + watcher.flush_for_tests(); + let outcome = outcome_rx + .recv_timeout(Duration::from_secs(2)) + .expect("burst sync completion"); + assert!(watcher.pending_files().is_empty()); + watcher.stop(); + + assert!( + outcome_rx.try_recv().is_err(), + "the burst must collapse to one sync" + ); + assert_eq!( + full_calls.load(AtomicOrdering::SeqCst), + 1, + "the full sync must run exactly once for the burst" + ); + assert_eq!( + incremental_calls.load(AtomicOrdering::SeqCst), + 0, + "a full-sync burst must not also run the incremental sync" + ); + assert_eq!( + outcome.trigger_paths, + vec![ + "src/feature".to_string(), + "src/feature/alpha.ts".to_string(), + "src/feature/beta.ts".to_string(), + ], + "every event path in the burst stays a sorted, deduped trigger path" + ); + } + + #[test] + fn removed_ignored_directory_does_not_schedule_a_full_sync() { + let _env = crate::test_env::env_guard(); + // The escalation honors the watch policy: an ignored directory removal + // is still dropped, so no sync is scheduled at all. + let dir = crate::sync::tests::TestDir::new("watch-deleted-dir-ignored"); + let full_calls = Arc::new(AtomicUsize::new(0)); + let full_counter = Arc::clone(&full_calls); + let full_sync_fn: FullSyncFn = Arc::new(move || { + full_counter.fetch_add(1, AtomicOrdering::SeqCst); + Ok(SyncOutcome::default()) + }); + let watcher = ProjectWatcher::start( + dir.path(), + WatchOptions { + debounce: Duration::from_secs(30), + inert_for_tests: true, + full_sync_fn: Some(full_sync_fn), + ..WatchOptions::default() + }, + ) + .unwrap() + .unwrap(); + + watcher.ingest_removed_dir_for_tests("node_modules/pkg"); + watcher.flush_for_tests(); + watcher.stop(); + assert_eq!(full_calls.load(AtomicOrdering::SeqCst), 0); + } + #[test] fn ignored_directory_event_does_not_schedule_reindex() { + let _env = crate::test_env::env_guard(); let dir = crate::sync::tests::TestDir::new("watch-ignore"); fs::create_dir_all(dir.path().join("node_modules/pkg")).unwrap(); fs::write( @@ -903,6 +1585,7 @@ mod tests { #[test] fn start_returns_none_when_watch_disabled_by_flag() { + let _env = crate::test_env::env_guard(); let dir = crate::sync::tests::TestDir::new("watch-nowatch-flag"); let watcher = ProjectWatcher::start( dir.path(), @@ -1098,6 +1781,7 @@ mod tests { #[test] fn fresh_watcher_is_not_degraded_and_has_no_reason() { + let _env = crate::test_env::env_guard(); // Given: an inert watcher that never hits a backend error. let dir = crate::sync::tests::TestDir::new("watch-not-degraded"); let watcher = ProjectWatcher::start( @@ -1118,6 +1802,7 @@ mod tests { #[test] fn start_serve_watcher_returns_none_when_watching_is_disabled() { + let _env = crate::test_env::env_guard(); // Given: a normal project but the no_watch flag forced on. let dir = crate::sync::tests::TestDir::new("watch-serve-disabled"); // Then: the public wrapper returns Ok(None) (no watcher started). @@ -1138,6 +1823,7 @@ mod tests { #[test] fn start_serve_watcher_starts_an_inert_watcher_for_a_normal_project() { + let _env = crate::test_env::env_guard(); // Given: a normal project directory. let dir = crate::sync::tests::TestDir::new("watch-serve-start"); // Then: the public wrapper starts and returns an inert watcher. @@ -1156,10 +1842,11 @@ mod tests { #[test] fn pending_files_snapshot_reflects_ingested_events_before_debounce() { + let _env = crate::test_env::env_guard(); // Given: an inert watcher with a long debounce so events stay pending. let dir = crate::sync::tests::TestDir::new("watch-pending"); fs::create_dir_all(dir.path().join("src")).unwrap(); - let db = crate::sync::default_db_path(dir.path()); + let db = crate::sync::default_db_path(dir.path()).unwrap(); let watcher = ProjectWatcher::start( dir.path(), WatchOptions { @@ -1198,8 +1885,100 @@ mod tests { watcher.stop(); } + /// A watcher whose event loop is INSIDE a sync must not be forced to a join: + /// `begin_shutdown` returns immediately, `is_finished` truthfully stays false + /// while the sync runs, and `detach` releases the handle without blocking. This + /// is what lets the daemon's bounded drain report an INCOMPLETE result instead + /// of blocking past its budget inside `Drop`. The sync blocks on a barrier, so + /// "still running" is deterministic rather than timed. + #[test] + fn begin_shutdown_and_detach_never_block_on_a_running_sync() { + let _env = crate::test_env::env_guard(); + let dir = crate::sync::tests::TestDir::new("watch-running-sync"); + let release = Arc::new(std::sync::Barrier::new(2)); + let entered = Arc::new(AtomicBool::new(false)); + let sync_release = Arc::clone(&release); + let sync_entered = Arc::clone(&entered); + let watcher = ProjectWatcher::start( + dir.path(), + WatchOptions { + debounce: Duration::from_millis(1), + inert_for_tests: true, + sync_fn: Some(Arc::new(move |_paths| { + sync_entered.store(true, AtomicOrdering::SeqCst); + sync_release.wait(); + Ok(SyncOutcome::default()) + })), + ..WatchOptions::default() + }, + ) + .unwrap() + .unwrap(); + + watcher.ingest_event_for_tests("src/app.ts"); + while !entered.load(AtomicOrdering::SeqCst) { + std::thread::sleep(Duration::from_millis(5)); + } + + let started = Instant::now(); + watcher.begin_shutdown(); + assert!( + started.elapsed() < Duration::from_secs(2), + "begin_shutdown must not join a running sync" + ); + assert!( + !watcher.is_finished(), + "a watcher inside a sync must report itself unfinished" + ); + + let started = Instant::now(); + watcher.detach(); + assert!( + started.elapsed() < Duration::from_secs(2), + "detach must not join a running sync" + ); + release.wait(); + } + + #[test] + fn begin_shutdown_is_nonblocking_and_detach_skips_the_join() { + let _env = crate::test_env::env_guard(); + // Given: an inert watcher whose event loop is idle. + let dir = crate::sync::tests::TestDir::new("watch-begin-shutdown"); + let watcher = ProjectWatcher::start( + dir.path(), + WatchOptions { + inert_for_tests: true, + sync_fn: Some(Arc::new(|_paths| Ok(SyncOutcome::default()))), + ..WatchOptions::default() + }, + ) + .unwrap() + .unwrap(); + + // When: shutdown begins. + let cancel = watcher.cancellation(); + watcher.begin_shutdown(); + + // Then: cancellation is observable immediately and the call did not join. + assert!(cancel.is_cancelled()); + watcher.begin_shutdown(); + for _ in 0..200 { + if watcher.is_finished() { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + assert!( + watcher.is_finished(), + "the event loop must exit after begin_shutdown" + ); + watcher.detach(); + } + #[test] fn event_loop_reports_non_degrading_sync_error_through_callback() { + let _env = crate::test_env::env_guard(); // Given: an inert watcher whose injected sync_fn always fails with a // non-contention error, plus recorders for both notice callbacks. let dir = crate::sync::tests::TestDir::new("watch-loop-error"); @@ -1252,6 +2031,7 @@ mod tests { #[test] fn event_loop_disables_auto_sync_after_persistent_errors() { + let _env = crate::test_env::env_guard(); // #1127: an injected sync_fn that always fails with the SAME // non-contention error must, after MAX_CONSECUTIVE_SYNC_ERRORS flushes, // degrade the watcher with an actionable message and stop retrying. @@ -1536,6 +2316,7 @@ mod tests { #[test] fn editing_a_real_source_file_still_triggers_sync() { + let _env = crate::test_env::env_guard(); // End-to-end with a REAL notify watcher (inert_for_tests = false), proving // the per-dir NonRecursive registration still delivers source edits to the // sync pipeline. `sync_fn` is injected so no real index is needed. @@ -1600,6 +2381,7 @@ mod tests { #[test] fn newly_created_source_dir_is_watched_after_start() { + let _env = crate::test_env::env_guard(); // A directory created AFTER start must be picked up (Linux NonRecursive: // the event loop registers a watch on the create event) so edits inside // it sync without a server restart. diff --git a/docs/architecture.md b/docs/architecture.md index 37e8f63..2f68099 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,7 +86,7 @@ graph LR RS -->|已解析边| ST ST --> GR[graph
遍历 + FTS 打分] GR --> MCP[mcp 8 工具] - GR --> CLI[cli 13 子命令] + GR --> CLI[cli 26 子命令] ST --> MCP ST --> CLI DA[daemon] --> MCP @@ -221,19 +221,22 @@ FTS5 候选(`search_nodes_fts_filtered`)→(空且文本≥2)LIKE 阶梯 ## 7. CLI 表面 / CLI Surface(`codegraph-cli`) -`codegraph` 单一二进制,clap 13 个子命令(见 -[README §CLI 子命令](../README.md#cli-子命令--cli-subcommands共-13-个))。CLI 负责 -rust-base-convention bootstrap:解析命令取项目根 → `init_config` fail-fast(人类可读 -错误)→ `init_logger`(在 `main` 中持有 guard;CLI 进程关闭 stdout/file 输出, -保证 JSON 输出干净)。 +`codegraph` 单一二进制,clap 26 个子命令(见 +[README §CLI 子命令](../README.md#cli-subcommands))。CLI bootstrap: +`Config::load_env_or_default` fail-fast(人类可读错误,只配置 logger,不代表任何项目) +→ `init_logger`(在 `main` 中持有 guard;日志只走 stderr,`serve --mcp` 独占 stdout +以免污染 JSON-RPC 流)。每个针对项目的操作再各自从该项目解析出的索引根加载其 +不可变配置。 - 索引编排只用各 crate 公共 API:`extract::engine::{scan_project, extract_file}` → `store::Store` upsert/insert → `resolve::ReferenceResolver::resolve_and_persist`。 - 全量索引写 `.codegraph/codegraph.db`、文件记录、结构边、未解析引用、已解析边及 + 全量索引写 `.codegraph-v2/codegraph.db`、文件记录、结构边、未解析引用、已解析边及 索引元数据。 - `serve --mcp` 调用 `codegraph_mcp::McpServer` 公共入口(stdin/stdout)。 -- **`sync` 当前复用安全的全量索引路径**(`main.rs` 注释标明),即“增量”实为全量 - 重建——这是 daemon/watch 接线前的有意取舍,已在基准报告中如实记录。 +- **`sync` 为真正的单文件增量路径**:`codegraph_watch::sync_project_once_with_progress` + 自行扫描候选文件,按内容哈希跳过未变更文件,对变更文件逐个删除并重新插入, + 删除已消失的文件,最后整体重新解析,因此结果等价于 `index --force`。 + 输出报告 reindexed / skipped / removed 三个计数。 子命令的输出/JSON 形状遵循固定的输出/JSON 契约(源码注释标注行号)。 @@ -275,8 +278,9 @@ serverInfo:{name:"codegraph",version}, instructions}`;`tools/list` 返回 8 所有客户端断开且空闲超时后自动退出(`CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS`, 默认 5 分钟)。另有 `run_foreground`/`attach_to_daemon`/`unlock_project`/ `daemon_pid_path`/`daemon_socket_path`。 -- 会合文件位于 `/.codegraph/`:`daemon.pid` 与 `daemon.sock`(socket 路径 - 过长时回落到哈希后的 tmpdir socket)。 +- 会合文件位于当前索引根 `/.codegraph-v2/`:`daemon.pid`、`daemon.sock` + 与 `daemon.log`,全部经 `IndexPaths` 派生(socket 路径过长时回落到哈希后的 + tmpdir socket,其名称带 v2 判别前缀)。 - 加锁逻辑(`daemon.ts:393-412`):完整 JSON 锁信息先写私有临时文件,再原子 hard-link 就位,避免空/半写 pidfile 竞争。 - 陈旧解锁镜像 `daemon.ts:453-481`:删除前重读、已知 pid 时比对、**绝不**清除存活 diff --git a/docs/cli.md b/docs/cli.md index f5cfe1e..7de4bdb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -23,10 +23,10 @@ | `skill update` | Refresh the installed skill when unchanged by the user | `-t/--target`, `--global`, `--local`, `--force` | | `skill uninstall` | Remove the skill from agent skill directories | `-t/--target`, `--global`, `--local`, `-y/--yes` | | `skill status` | Report install state per agent (up to date / locally modified / outdated / not installed) | `-t/--target`, `--global`, `--local` | -| `init` | Initialize `.codegraph/` and run the first full index | `[path]`, `-t/--target` (also write project-level MCP config; default `none`) | -| `uninit` | Delete the project's `.codegraph/` index | `[path]`, `-f/--force` | +| `init` | Initialize `.codegraph-v2/` and run the first full index | `[path]`, `-t/--target` (also write project-level MCP config; default `none`) | +| `uninit` | Delete the project's `.codegraph-v2/` index | `[path]`, `-f/--force` | | `index` | (Re-)index in full | `[path]`, `-f/--force`, `-q/--quiet`, `-v/--verbose` | -| `sync` | Sync changes (currently reuses the safe full-index path) | `[path]`, `-q/--quiet` | +| `sync` | Incremental sync: re-index only changed files, drop deleted ones, re-resolve | `[path]`, `-q/--quiet` | | `status` | Print index stats (files/nodes/edges/DB size/journal) | `[path]`, `-j/--json` | | `query` | FTS5 + multi-signal scored search | ``, `-p`, `-l/--limit`, `-k/--kind`, `-j/--json` | | `files` | List indexed files (tree/flat/grouped) | `-p`, `--filter `, `--language `, `--pattern`, `--format`, `--max-depth`, `-j` | @@ -149,7 +149,7 @@ codegraph init /path/to/proj -t auto # index that project + wire detected edito directory. The skill teaches the agent to use CodeGraph for code research and project onboarding: reach for `codegraph_explore` before grep/read, use `codegraph_node` instead of a plain file read on indexed source, and run -`codegraph init` when no `.codegraph/` index is present. +`codegraph init` when no `.codegraph-v2/` index is present. Four actions: @@ -547,7 +547,7 @@ codegraph completions elvish > ~/.config/codegraph/completion.elv The launcher selects a mode in this exact order: 1. `CODEGRAPH_NO_DAEMON=1` is set → **Direct** (foreground, no daemon ever spawned) -2. No `.codegraph/` directory in the project → **Direct** (nothing to share yet) +2. No `.codegraph-v2/` directory in the project → **Direct** (nothing to share yet) 3. Otherwise → **SpawnOrProxy**: spawn a new shared detached daemon, or proxy to one already running > `CODEGRAPH_DAEMON_INTERNAL=1` is **internal-only** — it is set automatically on @@ -557,15 +557,15 @@ The launcher selects a mode in this exact order: When the daemon starts, it detaches from the parent process group (Unix: `process_group(0)`; Windows: `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP`). -Its stdout and stderr are appended to `.codegraph/daemon.log`. The Unix socket is -at `.codegraph/daemon.sock`; the pid/lock file lives alongside it. +Its stdout and stderr are appended to `.codegraph-v2/daemon.log`. The Unix socket +is at `.codegraph-v2/daemon.sock`; the pid/lock file lives alongside it. On filesystems that reject binding an `AF_UNIX` socket inside the project directory (ExFAT/FAT, some network mounts, WSL DrvFs), the daemon falls back through a deterministic candidate chain — first the project-dir -`.codegraph/daemon.sock`, then a hashed socket under the system temp dir — and +`.codegraph-v2/daemon.sock`, then a hashed socket under the system temp dir — and records the socket it actually bound in the lock file. The pid/lock file always -stays at `.codegraph/daemon.pid`, and clients read the recorded socket from the +stays at `.codegraph-v2/daemon.pid`, and clients read the recorded socket from the lock, so they attach to whichever candidate the daemon chose. If the daemon crashes and leaves a stale lock: @@ -589,8 +589,8 @@ those paths; the reason is surfaced in the log. The watcher registers per-directory watches only on non-ignored directories, pruning `node_modules`, `.venv`, `__pycache__`, `target`, `dist`, `.godot`, -`.cache`, `.git`, `.codegraph`, and everything else in the default ignore set, -plus any paths matched by the root `.gitignore`. This pruning applies at any +`.cache`, `.git`, `.codegraph`, `.codegraph-v2`, and everything else in the +default ignore set, plus any paths matched by the root `.gitignore`. This pruning applies at any nesting depth, so an `node_modules` buried several levels deep is never walked. This keeps the total watch count well inside the OS inotify limit on large trees and makes daemon startup fast. A newly-created non-ignored directory is picked up @@ -632,9 +632,9 @@ Three escape hatches: Values outside the clamp range are silently clamped to the nearest bound. -### Custom extension mapping (`.codegraph/codegraph.json`) +### Custom extension mapping (`.codegraph-v2/codegraph.json`) -Place a `codegraph.json` inside the `.codegraph/` directory of any project to +Place a `codegraph.json` inside the `.codegraph-v2/` directory of any project to teach CodeGraph how to treat files with non-standard extensions: ```jsonc @@ -652,9 +652,10 @@ Rules: is lowercased (so `.X` and `.x` are the same key). - Language names must match the internal `Language` enum (serde names). Unknown language names are **silently skipped**. -- Config resolution walks up the directory tree from each source file; the nearest - `.codegraph/codegraph.json` wins. Results are mtime-cached — absent files are - cached too, so no repeated I/O on every lookup. +- Exactly one file is read: the resolved project's own + `.codegraph-v2/codegraph.json`. There is no directory-tree walk and no + cross-project inheritance, so one project can never adopt another's overrides. + A legacy `.codegraph/codegraph.json` is ignored. - A malformed JSON file is ignored and the error is logged; it does not abort indexing. diff --git a/docs/data-model.md b/docs/data-model.md index d035cd5..ecba4d5 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -13,14 +13,14 @@ Golden artifacts captured from that run, committed under `reference/golden/`: - Schema dump: `reference/golden/colby.schema.sql` - Raw database: `reference/golden/mini/colby.db` - Nodes table JSON: `reference/golden/mini/colby.nodes.json` -- Database location: `/tmp/cg-fixture-mini/.codegraph/codegraph.db` +- Database location: `/tmp/cg-fixture-mini/.codegraph-v2/codegraph.db` ## Real `.schema` Dump Contract `reference/golden/colby.schema.sql` was generated with: ```bash -sqlite3 /tmp/cg-fixture-mini/.codegraph/codegraph.db .schema > reference/golden/colby.schema.sql +sqlite3 /tmp/cg-fixture-mini/.codegraph-v2/codegraph.db .schema > reference/golden/colby.schema.sql ``` The dump is post-initialization/post-migration truth. It includes the six application tables (`schema_versions`, `nodes`, `edges`, `files`, `unresolved_refs`, `project_metadata`), `nodes_fts` plus its FTS5 shadow tables, the three FTS triggers, the post-migration indexes, `sqlite_sequence`, and `sqlite_stat1` created by the maintenance/ANALYZE path. diff --git a/docs/equivalence.md b/docs/equivalence.md index b929fd9..316e030 100644 --- a/docs/equivalence.md +++ b/docs/equivalence.md @@ -170,7 +170,7 @@ The minimal source corpus lives at `crates/codegraph-bench/fixtures/godot/` Regenerate the committed database + canonical JSON reproducibly from the corpus: ```bash -# 1. Copy the corpus to a clean directory (keeps the workspace .codegraph/ out of it). +# 1. Copy the corpus to a clean directory (keeps the workspace index out of it). rm -rf /tmp/cg-fixture-godot cp -r crates/codegraph-bench/fixtures/godot /tmp/cg-fixture-godot @@ -180,7 +180,7 @@ CODEGRAPH_NO_DAEMON=1 CODEGRAPH_NO_WATCH=1 \ ./target/release/codegraph init /tmp/cg-fixture-godot # 3. Commit the produced database as the fixture's colby.db. -cp /tmp/cg-fixture-godot/.codegraph/codegraph.db reference/golden/godot/colby.db +cp /tmp/cg-fixture-godot/.codegraph-v2/codegraph.db reference/golden/godot/colby.db # 4. Dump the canonical golden JSON + schema from that database. cargo run -p codegraph-bench --bin bench -- \ @@ -193,6 +193,20 @@ index or the dump reproduces identical `nodes.json`/`edges.json`/`refs.json`/ and `upstream_db_is_self_equivalent_to_godot_golden` tests in `crates/codegraph-bench/tests/equivalence.rs` enforce this. +Two properties this recipe does NOT claim, for every fixture below as well: + +- **`colby.db` is not byte-reproducible.** SQLite's header carries a change + counter, so a freshly indexed database differs from the committed one in the + first page even when every row matches. Only the `--gen-golden` artifacts are + compared byte-for-byte; the `.db` is an input to that dump, not a golden. +- **`schema.sql` records `.schema` statement ORDER, which can shift.** The order + reflects how the current binary creates its objects. Regenerating a fixture + whose committed `schema.sql` was produced by an earlier binary can therefore + reorder statements (e.g. `idx_edges_identity`) with no schema change. Always + regenerate `schema.sql` from the database you are committing — steps 3 and 4 + do exactly that, which keeps the pair self-consistent — and review an + order-only diff as expected rather than as drift. + The schema normalization helper is replicated inside `codegraph-bench` rather than extracted into `codegraph-store` to avoid changing store source during the parallel CRUD work. It preserves `.schema` statement order, strips optional @@ -222,7 +236,7 @@ The minimal source corpus lives at `crates/codegraph-bench/fixtures/ruby/` Regenerate the committed database + canonical JSON reproducibly from the corpus: ```bash -# 1. Copy the corpus to a clean directory (keeps the workspace .codegraph/ out of it). +# 1. Copy the corpus to a clean directory (keeps the workspace index out of it). rm -rf /tmp/cg-fixture-ruby cp -r crates/codegraph-bench/fixtures/ruby /tmp/cg-fixture-ruby @@ -232,7 +246,7 @@ CODEGRAPH_NO_DAEMON=1 CODEGRAPH_NO_WATCH=1 \ ./target/release/codegraph init /tmp/cg-fixture-ruby # 3. Commit the produced database as the fixture's colby.db. -cp /tmp/cg-fixture-ruby/.codegraph/codegraph.db reference/golden/ruby/colby.db +cp /tmp/cg-fixture-ruby/.codegraph-v2/codegraph.db reference/golden/ruby/colby.db # 4. Dump the canonical golden JSON + schema from that database. cargo run -p codegraph-bench --bin bench -- \ @@ -281,16 +295,65 @@ UObject` plus line-leading `GENERATED_BODY()`/`UPROPERTY(...)`/`UFUNCTION()`, reclassifies the `.h` to C++, and the offset-preserving pre-parse blanking recovers the `UFoo` class + its `Extends UObject` clause (both dropped before). +Five further files exercise the Batch B C/C++ gains: + +- **C leading attribute macros** (upstream #1311) — `attr_macro.c` is the one `.c` + file in this corpus (extension-mapped to `Language::C`, so it guards the C + walker, not the C++ one). It `#define`s an attribute macro + a `VOID` macro, + `typedef`s `UINT32`, and declares four functions: two behind the macro + (`GoodName` with a macro return type, `LostName` with a typedef'd one), one + without it (`NoAttr`, the control), and one pointer-returning (`PtrRet`). + tree-sitter's C grammar reads the macro as the type and the real return type as + the declarator, so before the fix these indexed under the RETURN TYPE's name + (`VOID` / `UINT32`); the golden now pins all four under their real names with + their real return types. The blank fires ONLY because `#define SEC_ATTR +__attribute__((section(".init")))` is visible IN THIS FILE — the pass demands + same-file `#define` proof that a leading token is attribute-like, so this fixture + also pins that evidence requirement, not just the macro's name. + +- **namespaced out-of-line method + fully-qualified call** (upstream #1310) — + `namespaced_member.hpp` declares `namespace simulator { class ManifestStartup }` + with a static `Apply`, and `namespaced_member.cpp` defines it OUT OF LINE inside + the same namespace block, then calls it through the fully-qualified path + (`simulator::ManifestStartup::Apply(1)`) from a function OUTSIDE the namespace. + The receiver qualifier is spelled relative to the namespace, so the golden pins + the method at `simulator::ManifestStartup::Apply` (matching the class node's + `simulator::ManifestStartup`) plus the `Calls` edge + `run_manifest → simulator::ManifestStartup::Apply` resolved by + `qualified-name` — the edge that a namespace-less qualifier loses. + +- **out-of-line template method receivers** (upstream #1309) — + `template_method.cpp` declares `template class Box` with `get` / + `set` and defines both OUT OF LINE (`template T Box::get()`). + The receiver qualifier carries ``, which the class node never spells, so the + golden pins both methods at `Box::get` / `Box::set` (template args stripped) + plus the `contains` edges from `class Box` to each — the link that a `Box::` + qualifier breaks. + +A further file exercises the Batch B explicit-operator gain (upstream #1268): + +- **explicit operator calls** — `operators.cpp` defines `struct Vec2` with + `operator+` / `operator[]` / a plain `get`, then calls each through the + EXPLICIT syntax (`a.operator+(b)`, `a.operator[](3)`, `p->operator+(b)`) plus + one plain `a.get()` control. tree-sitter-cpp strands the `operator_name` in an + ERROR child, so before the fix the extractor emitted the bare receiver (`a`) + and no edge existed; the golden now carries four `Calls` edges resolved by + `instance-method` at confidence 0.9 (`Vec2::operator+` twice, + `Vec2::operator[]`, `Vec2::get`). + The minimal source corpus lives at `crates/codegraph-bench/fixtures/cpp/` -(`base.hpp`, `derived.cpp`, `namespaced.cpp`, `templated_call.cpp`, -`ue_actor.h`). The inheritance base classes live in a `.hpp` file (not `.h`, +(`attr_macro.c`, `base.hpp`, `derived.cpp`, `namespaced.cpp`, +`namespaced_member.cpp`, `namespaced_member.hpp`, `operators.cpp`, +`template_method.cpp`, `templated_call.cpp`, `ue_actor.h`). The inheritance base +classes live in a `.hpp` file (not `.h`, which maps to `Language::C` by extension); `ue_actor.h` deliberately uses `.h` to -guard the content-based C++ reclassification. +guard the content-based C++ reclassification, and `attr_macro.c` uses `.c` so the +C walker (not the C++ one) is the thing under test. Regenerate the committed database + canonical JSON reproducibly from the corpus: ```bash -# 1. Copy the corpus to a clean directory (keeps the workspace .codegraph/ out of it). +# 1. Copy the corpus to a clean directory (keeps the workspace index out of it). rm -rf /tmp/cg-fixture-cpp cp -r crates/codegraph-bench/fixtures/cpp /tmp/cg-fixture-cpp @@ -300,7 +363,7 @@ CODEGRAPH_NO_DAEMON=1 CODEGRAPH_NO_WATCH=1 \ ./target/release/codegraph init /tmp/cg-fixture-cpp # 3. Commit the produced database as the fixture's colby.db. -cp /tmp/cg-fixture-cpp/.codegraph/codegraph.db reference/golden/cpp/colby.db +cp /tmp/cg-fixture-cpp/.codegraph-v2/codegraph.db reference/golden/cpp/colby.db # 4. Dump the canonical golden JSON + schema from that database. cargo run -p codegraph-bench --bin bench -- \ @@ -365,7 +428,7 @@ substituting `metal`/`cuda`): rm -rf /tmp/cg-fixture-metal && cp -r crates/codegraph-bench/fixtures/metal /tmp/cg-fixture-metal cargo build --release -p codegraph-rs CODEGRAPH_NO_DAEMON=1 CODEGRAPH_NO_WATCH=1 ./target/release/codegraph init /tmp/cg-fixture-metal -cp /tmp/cg-fixture-metal/.codegraph/codegraph.db reference/golden/metal/colby.db +cp /tmp/cg-fixture-metal/.codegraph-v2/codegraph.db reference/golden/metal/colby.db cargo run -p codegraph-bench --bin bench -- --gen-golden reference/golden/metal/colby.db reference/golden/metal # …and the same for cuda. ``` @@ -406,7 +469,7 @@ Regenerate reproducibly (identical recipe to the C++ fixture, substituting rm -rf /tmp/cg-fixture-arkts && cp -r crates/codegraph-bench/fixtures/arkts /tmp/cg-fixture-arkts cargo build --release -p codegraph-rs CODEGRAPH_NO_DAEMON=1 CODEGRAPH_NO_WATCH=1 ./target/release/codegraph init /tmp/cg-fixture-arkts -cp /tmp/cg-fixture-arkts/.codegraph/codegraph.db reference/golden/arkts/colby.db +cp /tmp/cg-fixture-arkts/.codegraph-v2/codegraph.db reference/golden/arkts/colby.db cargo run -p codegraph-bench --bin bench -- --gen-golden reference/golden/arkts/colby.db reference/golden/arkts ``` @@ -456,7 +519,7 @@ Regenerate reproducibly (identical recipe to the ArkTS fixture, substituting rm -rf /tmp/cg-fixture-solidity && cp -r crates/codegraph-bench/fixtures/solidity /tmp/cg-fixture-solidity cargo build --release -p codegraph-rs CODEGRAPH_NO_DAEMON=1 CODEGRAPH_NO_WATCH=1 ./target/release/codegraph init /tmp/cg-fixture-solidity -cp /tmp/cg-fixture-solidity/.codegraph/codegraph.db reference/golden/solidity/colby.db +cp /tmp/cg-fixture-solidity/.codegraph-v2/codegraph.db reference/golden/solidity/colby.db cargo run -p codegraph-bench --bin bench -- --gen-golden reference/golden/solidity/colby.db reference/golden/solidity ``` @@ -504,7 +567,7 @@ Regenerate reproducibly (identical recipe to the Solidity fixture, substituting rm -rf /tmp/cg-fixture-nix && cp -r crates/codegraph-bench/fixtures/nix /tmp/cg-fixture-nix cargo build --release -p codegraph-rs CODEGRAPH_NO_DAEMON=1 CODEGRAPH_NO_WATCH=1 ./target/release/codegraph init /tmp/cg-fixture-nix -cp /tmp/cg-fixture-nix/.codegraph/codegraph.db reference/golden/nix/colby.db +cp /tmp/cg-fixture-nix/.codegraph-v2/codegraph.db reference/golden/nix/colby.db cargo run -p codegraph-bench --bin bench -- --gen-golden reference/golden/nix/colby.db reference/golden/nix ``` @@ -557,7 +620,7 @@ Regenerate reproducibly (identical recipe to the Nix fixture, substituting rm -rf /tmp/cg-fixture-terraform && cp -r crates/codegraph-bench/fixtures/terraform /tmp/cg-fixture-terraform cargo build --release -p codegraph-rs CODEGRAPH_NO_DAEMON=1 CODEGRAPH_NO_WATCH=1 ./target/release/codegraph init /tmp/cg-fixture-terraform -cp /tmp/cg-fixture-terraform/.codegraph/codegraph.db reference/golden/terraform/colby.db +cp /tmp/cg-fixture-terraform/.codegraph-v2/codegraph.db reference/golden/terraform/colby.db cargo run -p codegraph-bench --bin bench -- --gen-golden reference/golden/terraform/colby.db reference/golden/terraform ``` @@ -616,7 +679,7 @@ Regenerate reproducibly (identical recipe to the Terraform fixture, substituting rm -rf /tmp/cg-fixture-erlang && cp -r crates/codegraph-bench/fixtures/erlang /tmp/cg-fixture-erlang cargo build --release -p codegraph-rs CODEGRAPH_NO_DAEMON=1 CODEGRAPH_NO_WATCH=1 ./target/release/codegraph init /tmp/cg-fixture-erlang -cp /tmp/cg-fixture-erlang/.codegraph/codegraph.db reference/golden/erlang/colby.db +cp /tmp/cg-fixture-erlang/.codegraph-v2/codegraph.db reference/golden/erlang/colby.db cargo run -p codegraph-bench --bin bench -- --gen-golden reference/golden/erlang/colby.db reference/golden/erlang ``` @@ -663,7 +726,7 @@ Regenerate reproducibly (identical recipe, substituting `cfml`): rm -rf /tmp/cg-fixture-cfml && cp -r crates/codegraph-bench/fixtures/cfml /tmp/cg-fixture-cfml cargo build --release -p codegraph-rs CODEGRAPH_NO_DAEMON=1 CODEGRAPH_NO_WATCH=1 ./target/release/codegraph init /tmp/cg-fixture-cfml -cp /tmp/cg-fixture-cfml/.codegraph/codegraph.db reference/golden/cfml/colby.db +cp /tmp/cg-fixture-cfml/.codegraph-v2/codegraph.db reference/golden/cfml/colby.db cargo run -p codegraph-bench --bin bench -- --gen-golden reference/golden/cfml/colby.db reference/golden/cfml ``` @@ -673,12 +736,27 @@ The `generated_golden_matches_committed_cfml_fixture` and ### KNOWN_DIFFS.md format -Tier-3 differences are allowlisted by grep-able lines in repo-root -`KNOWN_DIFFS.md`: +Tier-3 differences are allowlisted by grep-able lines in +`docs/upstream-sync/KNOWN_DIFFS.md` — the single path +`KnownDiffs::repo_doc_path` hardcodes +(`crates/codegraph-bench/src/oracle/diff.rs`): ```text RULE tier=3 surface= key= justification= ``` Only Tier-3 entries can be allowed. Tier-1 byte mismatches and Tier-2 multiset -mismatches always fail; the differ never weakens those tiers to pass. +mismatches always fail; the differ never weakens those tiers to pass — +`KnownDiffs::allows` returns `false` for anything that is not `Tier::Tier3`, and +`parse_rule` rejects `tier=1` / `tier=2` before that, so a Tier-1/Tier-2 rule +cannot even be written down. + +The parser is fail-closed: an unparsable document fails every equivalence +assertion instead of being ignored. A `RULE` line is rejected when a token is +not `key=value`, a key or value is empty, a field name is outside +`tier`/`surface`/`key`/`justification`, a field is repeated, `tier` is anything +other than Tier-3, `surface` is outside the five surfaces the differ reports +(`nodes`, `files`, `schema`, `edges`, `unresolved_refs`), or any of the four +fields is missing. Lines inside a fenced code block are documentation, not +rules — including the template above — and an unterminated fence is an error, +because every `RULE` after it would otherwise be skipped silently. diff --git a/docs/godot-gdext-decision.md b/docs/godot-gdext-decision.md new file mode 100644 index 0000000..fd07325 --- /dev/null +++ b/docs/godot-gdext-decision.md @@ -0,0 +1,165 @@ +# Decision: gdext is not used for Godot analysis + +## The question + +A user asked whether CodeGraph could or should use +[`gdext`](https://github.com/godot-rust/gdext) — the godot-rust GDExtension +bindings — to improve Godot/GDScript analysis. + +## Verdict + +**Rejected, and unnecessary.** `gdext` is a binding for Rust code running inside +a live Godot engine. It exposes no offline API for reading `.gd`, `.tscn`, +`.tres` or `project.godot`, so it cannot do the job CodeGraph does, and it +solves none of the gaps recorded in +[`docs/godot.md`](godot.md#limitations) — those are either genuinely runtime-only +or solvable with better static parsing. + +## What gdext actually is + +`gdext` compiles Rust into a GDExtension shared library that Godot loads at +startup. Its type surface (`Node`, `Callable`, `NodePath`, …) is generated from +Godot's `extension_api.json`, and those types are only meaningful once the engine +has initialized — a `Gd` is a handle into a running engine's object +database, not a description of a file on disk. It is not a GDScript parser, not +a `.tscn`/`.tres` reader, and offers no offline API for inspecting project files. +Anything CodeGraph wanted from it would require launching Godot. + +To be precise about the build step, because an earlier draft of this document got +it wrong: a plain `gdext` dependency does **not** need a Godot binary present at +build time. Per the crate docs +([docs.rs/godot 0.5.4, "Cargo features"](https://docs.rs/godot/latest/godot/#cargo-features)), +the `api-*` features "Set the **API level** to the specified Godot version, or a +custom-built local binary… If absent, the current Godot minor version is used, +with patch level 0" — i.e. the bindings are generated from an API description +bundled with the crate and pinned by its version. Only the two opt-in features +need an engine or a hand-supplied API: "`api-custom` feature requires specifying +`GDRUST_GODOT_BIN` environment variable with a path to your Godot4 binary. The +`api-custom-json` feature requires specifying `GDRUST_GODOT_API_JSON` environment +variable with a path to your custom-defined `extension_api.json`." The engine +requirement is at **run** time — Godot loads the produced library — and that is +the part that disqualifies `gdext` here. + +An earlier draft also argued that `gdext` would break the golden `.schema` +byte-stability invariant because its bindings vary with the installed engine. +That argument does not survive the correction: with the default prebuilt API the +generated surface is fixed by the crate version, so it is as reproducible as any +other pinned dependency, and it would only become runner-dependent under +`api-custom` (engine binary) or `api-custom-json` (hand-supplied JSON) — features +nobody is proposing. The determinism objection is therefore dropped rather than +kept for symmetry. The real determinism risk was never the bindings; it is that +any output derived from a live engine's state is not a pure function of the +corpus, which is the runtime problem stated above. + +## Why it disqualifies itself + +**1. It requires the engine at runtime; CodeGraph deliberately requires none.** +`docs/godot.md` opens by stating that CodeGraph parses Godot project files +"— no engine required, no compilation, no runtime". Releases are standalone +binaries, and README.md notes that "Linux builds are statically linked (musl) — +no glibc or SQLite system dependency". Users indexing a Godot repo on CI, on a +server, or on a machine without the editor installed are a supported case. +`gdext` code only executes as a GDExtension loaded by a Godot process, so +reaching its types at all would mean shipping or locating an engine and starting +it. `codegraph index` would stop being a self-contained command. + +**2. It solves none of the recorded gaps.** +The limitations in [`docs/godot.md`](godot.md#limitations) split cleanly, and +neither half needs an engine binding: + +- Runtime-only by nature — `get_node(var)` / `call(method_var)` computed + targets, `NodePath`s built by string concatenation, and "No runtime + verification". An in-engine binding does not make a static analyser able to + know a value that only exists while the game runs; it would only let CodeGraph + _run_ the project, which is a different tool (`docs/godot.md` already assigns + runtime load-verification to Godot MCP Pro). +- Statically solvable — scene-backed autoloads not binding the attached script's + methods, `.tscn` `[connection]` handlers matched by name only, binary `.res` + files unparsed. Each is a parsing/resolution improvement in existing crates. + +## Alternatives worth pursuing instead + +Ranked by value against risk. None adds a dependency on the engine. + +**1. Bind scene-backed autoload methods (highest value, lowest risk).** +`docs/godot.md` records: "**Scene-backed autoloads are registration-only.** A +`uid://…` autoload that resolves to a `.tscn` (scene autoload) emits the +`project.godot → .tscn` registration reference but no F1 `Autoload.method()` +binding to the scene's attached script." The missing hop is one the resolver +already knows how to make: the `.tscn` extraction already emits `script = +ExtResource(…)` scene-node → `.gd` references, and F1 already binds +`Autoload.method()` to a uniquely-named `func` in a bound script. Following the +scene's root-node script and reusing F1's existing determinism rule (edge only +when exactly one matching `func` exists) closes a concrete, user-visible gap +inside one resolver, with a new golden fixture to pin it. Purely additive. + +**2. Move the `.gd` dynamic scanner onto tree-sitter queries (high value, +moderate risk).** `crates/codegraph-resolve/src/frameworks/godot_script.rs` is +today a defensive line/string/paren scanner — `scan_line`, `scan_connect`, +`scan_dollar_node`, `first_arg`, `top_level_comma`, `ident_before` and friends +walk raw bytes per line. It is deliberately tolerant (malformed input is +skipped, never panics), but hand-rolled text windows are exactly the bug class +commit `b7ff1f8b3a0ee5f99912e8a426d6af810eec3b9c` ("fix(resolve): bound the +React Route opening-tag scan to the tag itself") fixed on this branch: a fixed +byte window that never cut at the element's own `>` let a parent route borrow a +sibling's `path`/`element`, producing 2 wrong edges out of 3. The Godot scanner +carries the same shape of risk — multi-line call arguments, comments and strings +containing `connect(`, and nested parens are all handled by ad-hoc byte logic. +Running tree-sitter queries over the parse tree the GDScript grammar already +produces removes that class of error at the root. Moderate risk because the +resolver's output is golden-protected via `reference/golden/godot/`, so the port +must be output-preserving: every existing sentinel and edge has to come out +byte-identical, and the change is only worth making with the current unit tests +plus the golden fixture as the gate. + +**3. Upgrade the GDScript grammar (conditional value, low risk).** +The pinned version is `tree-sitter-gdscript = "6.1.0"` +(`crates/codegraph-extract/Cargo.toml`, checksum +`b7a37fe8c0a10c0c39ecd5b2f7db53933f691488f5572409a4d3c0dfeb3f6108` in +`Cargo.lock`, also recorded in [`docs/grammar-manifest.md`](grammar-manifest.md)). +Whether a newer release exists is **unverified** — nothing in this repository +records the upstream crate's latest version, and no network lookup was performed +for this decision. What would settle it: `cargo search tree-sitter-gdscript` or +the crate's registry page, plus the grammar's changelog. Ranked below (2) because +its value is unknown until that check happens: a grammar bump only helps if it +fixes parse gaps CodeGraph actually hits, and any bump is a golden-affecting +change that must be re-verified against `reference/golden/godot/`. + +**4. Parse binary `.res` (lowest value).** Recorded as a limitation, but it +means implementing Godot's binary resource format against no specification in +this repository, for files that are usually a compiled form of a `.tres` the +project also has in text form. Not worth the surface area. + +Not worth pursuing at all: computed `get_node`/`call` targets and +runtime-constructed `NodePath`s. `docs/godot.md` states the position — +"**Computed targets are unresolved, not fabricated.** … They appear as +`godot:dynamic:` sentinels, never as concrete edges." The sentinel _is_ +the correct answer; replacing it with a guess would be a regression in honesty. + +## What remains uncertain + +- Whether a newer `tree-sitter-gdscript` release exists, and whether it fixes + anything CodeGraph hits. Unverified, as above. +- Whether the tree-sitter-query port of `godot_script.rs` can be made exactly + output-preserving. Unknown until attempted; the golden fixture decides it, and + a diff there is a stop signal, not something to regenerate around. +- No throughput, latency, or memory benchmark exists for the GDScript path, so + no performance claim is made here in either direction. If a future argument + for a different parsing strategy rests on speed, it needs a measurement first. + +Evidence that would reopen the gdext question: an offline, engine-free, +version-stable API for reading Godot project files. `gdext` is not that, and is +not trying to be. + +`scripts/guardrail.sh` is **not** extended to ban Godot-engine crates as part of +this decision — its forbidden list targets AI/vector/LLM crates, and widening a +CI gate is a separate call. If a hard block is wanted, adding `gdext`/`godot` to +that list is the mechanism; it is recommended here, not done here. + +## See also + +- [`docs/godot.md`](godot.md) — what CodeGraph extracts from Godot projects, the + static-vs-runtime boundary, and the limitations argued against above. +- [`AGENTS.md`](../AGENTS.md) — the hard invariants quoted here. +- [`docs/grammar-manifest.md`](grammar-manifest.md) — the pinned GDScript + grammar and its ABI status. diff --git a/docs/godot.md b/docs/godot.md index 8a74d36..f5d5aed 100644 --- a/docs/godot.md +++ b/docs/godot.md @@ -24,7 +24,7 @@ vendored third-party editor plugins that would otherwise crowd out first-party Both exclusions are opt-out. To re-include a directory (for example, a team that keeps first-party code under `addons/`), set a custom `indexing.ignore_dirs` list -in `.codegraph/config.toml`. That list replaces the default set entirely, so +in `.codegraph-v2/config.toml`. That list replaces the default set entirely, so re-list any other directories you still want ignored — e.g. keep `.godot` while dropping `addons`: @@ -272,7 +272,7 @@ flag reference. For projects that define custom `.tres` resource types with domain-specific fields that carry semantic edges (e.g. a `skill_effect` field that references another resource), CodeGraph supports an opt-in DSL mapping via -`.codegraph/codegraph.json`. List the `[resource]` property names that should +`.codegraph-v2/codegraph.json`. List the `[resource]` property names that should emit a reference edge from their value under `godot.dsl.resourceFields`: ```jsonc @@ -385,13 +385,16 @@ sync` that touches only a `.gd.uid`, a `.tscn` uid header, or a file rename does - **`.godot/` and `addons/` are skipped by default.** Both directories are excluded from indexing so engine cache and vendored plugins don't bury first-party code in search results. Re-include a directory via a custom - `indexing.ignore_dirs` list in `.codegraph/config.toml`. See + `indexing.ignore_dirs` list in `.codegraph-v2/config.toml`. See [Indexing scope](#indexing-scope-ignored-directories). --- ## See also +- [`docs/godot-gdext-decision.md`](godot-gdext-decision.md) — why CodeGraph does + NOT use `gdext` (the godot-rust GDExtension bindings), and the engine-free + alternatives for the limitations above. - [`docs/languages.md`](languages.md) — GDScript (Tier 1), GodotScene, GodotResource, GodotProject file types. - [`docs/mcp.md`](mcp.md) — `codegraph_callers` and `codegraph_impact` tools diff --git a/docs/languages.md b/docs/languages.md index 61b17cb..f582dd5 100644 --- a/docs/languages.md +++ b/docs/languages.md @@ -95,7 +95,7 @@ impact analysis at the file level. ## Adding custom extension mappings -Non-standard extensions can be mapped to any supported language via `.codegraph/codegraph.json`: +Non-standard extensions can be mapped to any supported language via `.codegraph-v2/codegraph.json`: ```jsonc { @@ -107,7 +107,9 @@ Non-standard extensions can be mapped to any supported language via `.codegraph/ ``` Keys are dot-stripped and lowercased before matching. Unknown language names are silently -skipped. The nearest config up the directory tree wins. +skipped. Exactly one file is read — the resolved project's own +`.codegraph-v2/codegraph.json`; there is no directory-tree walk, and a legacy +`.codegraph/codegraph.json` is ignored. --- diff --git a/docs/mcp.md b/docs/mcp.md index cdab91c..97f2d8e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -94,13 +94,13 @@ Run `codegraph install --target=zed` to write the bare global entry, or ### Zed over SSH (remote development) **Symptom.** You open a remote SSH project in Zed (Zed UI on your local machine, -code and `.codegraph/` index on a remote Linux host). All codegraph MCP tools +code and `.codegraph-v2/` index on a remote Linux host). All codegraph MCP tools return empty — "No relevant code found" — even after the index built successfully. **Root cause.** Zed currently executes `context_servers` entries on the **local machine**, not on the remote host. This is true even when a remote SSH project is open: the `command: "codegraph"` runs locally, cannot reach the remote -`.codegraph/` index, and returns nothing. Native remote MCP execution — running +`.codegraph-v2/` index, and returns nothing. Native remote MCP execution — running the server on the remote host — is not yet implemented in Zed (tracked in Zed's GitHub issues; status as of mid-2026). @@ -226,7 +226,7 @@ Two distinct error channels: ## Daemon & live watch -When the project is indexed (`.codegraph/` exists), `serve --mcp` does not run +When the project is indexed (`.codegraph-v2/` exists), `serve --mcp` does not run inline. Instead it spawns — or proxies to — a single shared detached daemon process per project. Multiple agent clients (e.g. Claude Code + Cursor open simultaneously) all attach to the same daemon, so the index is loaded and @@ -275,7 +275,7 @@ The server resolves a default project by walking three sources in order: 1. **`--path` flag** — explicit pin; always wins. 2. **find-up from cwd** — ascends from the working directory to the nearest - `.codegraph/` directory. A cwd at or inside an indexed project resolves it + `.codegraph-v2/` index root. A cwd at or inside an indexed project resolves it here, and `projectPath` is optional. 3. **MCP `initialize` handshake** — if find-up yields nothing, the server reads the `initialize` message sent by the client and adopts the workspace it @@ -298,7 +298,7 @@ them by passing an explicit `projectPath`; for single-project setups, pinning > resolved via find-up and gets the full daemon and watcher. The daemon exits automatically after all clients disconnect and an idle timeout -elapses. Logs are appended to `.codegraph/daemon.log`. A stale lock (e.g. after +elapses. Logs are appended to `.codegraph-v2/daemon.log`. A stale lock (e.g. after a crash) can be cleared with `codegraph unlock`. On Unix, the detached daemon calls `setsid` to become a session leader, so when diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 99ee7c2..142cda7 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -18,6 +18,7 @@ SQLite 数据库(含 FTS5 检索索引),并通过 CLI 与 MCP stdio 服务 ## 目录 - [快速上手](#快速上手) +- [从 v0.40.4 升级](#从-v0404-升级) - [安装](#安装) - [MCP 快速注册](#mcp-快速注册) - [安装 Agent 技能](#安装-agent-技能codegraph-skill) @@ -51,13 +52,34 @@ irm https://raw.githubusercontent.com/sunerpy/codegraph-rust/main/scripts/instal **索引项目并查询:** ```bash -codegraph init /path/to/project # 创建 .codegraph/ 并执行首次索引 +codegraph init /path/to/project # 创建 .codegraph-v2/ 并执行首次索引 codegraph query "" -p /path/to/project # 全文检索 codegraph serve --mcp --path /path/to/project # 为 AI 代理启动 MCP 服务器(--path 可选,默认 cwd) ``` --- +## 从 v0.40.4 升级 + +按项目的索引目录已从 `/.codegraph/` 迁至隔离的 `/.codegraph-v2/`。 +`v0.40.4` 留下的旧索引**既不迁移也不读取**:本版本二进制永不打开、迁移或写入旧根目录, +已存在的 `.codegraph/` 会被直接忽略。由此有两点影响: + +- **每个项目需重新执行 `codegraph init`。** 在此之前该项目视为未索引: + `serve --mcp` 回退到直连模式且无索引可服务,查询类命令报 + `CodeGraph not initialized`。 +- **旧配置不会自动继承。** `.codegraph/config.toml` 与 `.codegraph/codegraph.json` + 均被忽略;若仍需沿用,请手动复制为 `.codegraph-v2/config.toml` 与 + `.codegraph-v2/codegraph.json`。 + +daemon 会合文件(`daemon.pid`、`daemon.sock`、`daemon.log`)随索引一同迁移, +现在也位于 `.codegraph-v2/` 之下。 + +`codegraph status` 会同时报告两个根目录:`indexPath` 为当前索引根, +`legacyIndexPaths` 列出磁盘上仍存在的旧根。旧目录不会被改动,确认不再需要后可手动删除。 + +--- + ## 安装 CLI 包名为 **`codegraph-rs`**,安装后的二进制命令名为 **`codegraph`**。SQLite @@ -84,7 +106,7 @@ irm https://raw.githubusercontent.com/sunerpy/codegraph-rust/main/scripts/instal # 回退方案 —— 从源码构建(仅当你已有 Rust 工具链时) cargo install --git https://github.com/sunerpy/codegraph-rust codegraph-rs # 二进制:`codegraph` -codegraph init /path/to/project # 建立索引库(.codegraph/) +codegraph init /path/to/project # 建立索引库(.codegraph-v2/) codegraph index /path/to/project # 解析 + 构建图 ``` @@ -200,7 +222,7 @@ codegraph install --target=auto --local # 项目级配置 除了写入 MCP 服务器配置,CodeGraph 还可以把一个 `SKILL.md` 直接安装到每个代理的 技能目录中。该技能教会代理如何将 CodeGraph 用于代码研究与项目初始化:在 grep/read 之前优先调用 `codegraph_explore`,对已索引的源码用 `codegraph_node` 代替直接读文件, -以及在尚未建立 `.codegraph/` 索引时自动运行 `codegraph init`。 +以及在尚未建立 `.codegraph-v2/` 索引时自动运行 `codegraph init`。 ```bash codegraph skill install --yes # 安装到所有已检测到的代理(全局) @@ -356,7 +378,7 @@ python examples/llm_orchestration.py --repo . --query "how does indexing work" 在已索引的项目中运行 `codegraph serve --mcp` 时,CodeGraph 会自动在后台启动一个 **共享的、脱离终端的守护进程**。同一项目的多个 MCP 客户端通过 Unix socket -(`.codegraph/daemon.sock`)共用该 daemon,所有客户端断开且空闲超时后自动退出。 +(`.codegraph-v2/daemon.sock`)共用该 daemon,所有客户端断开且空闲超时后自动退出。 常用操作: @@ -381,7 +403,7 @@ codegraph serve --http --detach # 后台运行 codegraph http list / stop # 管理运行中的 HTTP 服务 ``` -自定义扩展名映射写在 `.codegraph/codegraph.json`;可选的 Claude prompt hook +自定义扩展名映射写在 `.codegraph-v2/codegraph.json`;可选的 Claude prompt hook 通过 `codegraph install --prompt-hook` 启用。 完整参考——守护进程生命周期、所有环境变量、HTTP 注册表、扩展名映射、Claude hook: diff --git a/docs/upstream-sync/KNOWN_DIFFS.md b/docs/upstream-sync/KNOWN_DIFFS.md new file mode 100644 index 0000000..411cd7b --- /dev/null +++ b/docs/upstream-sync/KNOWN_DIFFS.md @@ -0,0 +1,184 @@ +# Known CodeGraph Equivalence Differences + +This file is parsed by `codegraph-bench::oracle::diff::KnownDiffs` for Tier-3 +differences only. Tier-1 and Tier-2 differences are never allowlisted by this +file. + +## Rule format + +One grep-able rule per line: + +```text +RULE tier=3 surface= key= justification= +``` + +- `tier`: currently only `3` can allow a diff. +- `surface`: presentation or behavioral surface name reported by the differ. +- `key`: substring matched against the diff key, or `*` for the whole surface. +- `justification`: short no-spaces reason; expand with prose nearby when needed. + +## Current rules + +No Tier-3 rules are active yet. + +## Canonicalized by design, not allowlisted + +Timestamps are stripped before comparison and are therefore not represented as +Tier-3 rules: + +- `nodes.updated_at` +- `files.modified_at` +- `files.indexed_at` + +## Deferred colby resolution behavior (Task 20, codegraph-resolve) + +The v1 Rust port of `reference/colby/src/resolution/` ships the two CORE +deterministic strategies — import resolution (`import-resolver.ts`) and name +matching (`name-matcher.ts`) — orchestrated by `ReferenceResolver` +(`index.ts`), plus THREE concrete `FrameworkResolver` implementations behind the +`FrameworkResolver` trait EXTENSION POINT (`crates/codegraph-resolve/src/framework.rs`): +React/Next.js, Vue/Nuxt, and NestJS (`crates/codegraph-resolve/src/frameworks/`), +detected per-project by `detect_frameworks`. The remaining ~19 colby resolvers +stay deferred. The following colby behaviors are intentionally NOT ported in v1: + +- **The other framework-specific resolvers** — every concrete `FrameworkResolver` + in `reference/colby/src/resolution/frameworks/**` EXCEPT react/vue/nestjs: + Spring/Java, Drupal `routing.yml`, Express, Svelte, React Native / Expo / Fabric + bridges, Swift↔ObjC bridging, the Go-module / Python / Ruby / C# / Play route + extractors, and the Astro resolver + (`frameworks/astro.ts`: `astro:*` module imports + the `Astro` global resolved + as framework-provided, and `src/pages` file-based route-node mapping). These + are an additive heuristic layer; on a project where none of react/vue/nestjs is + detected the orchestrator holds an empty resolver list, so behavior matches + colby with zero frameworks detected. The Astro + EXTRACTION half (frontmatter + `