From 546abccdd2a5f5e5a544efdf407f1883f99e609d Mon Sep 17 00:00:00 2001 From: Abhishek Date: Mon, 24 Aug 2026 19:50:50 +0530 Subject: [PATCH 01/23] [docs] RFC-027 live semantic resolution (LSP-assisted incremental Phase B) Draft RFC proposing a live semantic resolution lane that closes the between-commits "mid-edit degradation" gap without regressing the commit-gated Phase B guarantee. Core model is a strict separation of responsibilities: Tree-sitter detects a reference exists SCIP graph owns node identity (Kythe VName) LSP disambiguates a specific position (surgical, optional) Commit SCIP ratifies the region and heals drift LSP is used only as a disambiguation oracle over SCIP-identified candidates, never as a symbol/identity source. The lane is precision-first and fail-closed: it emits an edge only when resolution is provably correct and abstains (marks the reference pending) otherwise. When no language server is present the system degrades to today's commit-gated behavior with zero regression. Includes an inline mermaid sequence diagram covering the live path and the commit ratification path, the reverse-dependency closure invalidation model, the ADR-009 Rule 4 fencing rule (live edges never enter the bridge registry), the Invariant #4 convergence property, and a continuous precision meter that diffs live edges against SCIP truth at each commit. --- docs/rfcs/RFC-027-live-semantic-resolution.md | 389 ++++++++++++++++++ 1 file changed, 389 insertions(+) create mode 100644 docs/rfcs/RFC-027-live-semantic-resolution.md diff --git a/docs/rfcs/RFC-027-live-semantic-resolution.md b/docs/rfcs/RFC-027-live-semantic-resolution.md new file mode 100644 index 00000000..1dbd1655 --- /dev/null +++ b/docs/rfcs/RFC-027-live-semantic-resolution.md @@ -0,0 +1,389 @@ +# RFC-027: Live Semantic Resolution — LSP-Assisted Incremental Phase B + +**Date:** 2026-08-24 +**Status:** Draft +**Phase:** Post-v0.11.0 +**Author:** Abhishek +**Related:** ADR-009 (SCIP vs LSIF wire format), ADR-002 (edge provenance policy), ADR-006 (subprocess trust), ADR-017 (unified plugin sandbox trust), RFC-005 (cross-language edge resolution), RFC-008 (multi-language extension architecture), RFC-009 (cross-language bridge plugins), RFC-014 (Phase B symbol unification) +**Supersedes:** N/A + +--- + +## 1. Summary + +Travsr's semantic graph (Phase B) is **commit-gated**: cross-file, type-resolved edges are only rebuilt when the git hook fires. Between commits, only Tree-sitter (Phase A) updates the graph, which sees *that* a reference exists but not *which* symbol it resolves to. The result is **mid-edit degradation**: the semantic graph goes vague on exactly the code the developer is actively changing. + +This RFC proposes a **live semantic resolution lane** that fills the between-commits gap without regressing the commit-gated guarantee. It rests on a strict separation of responsibilities: + +``` +Tree-sitter → DETECTS a reference exists (live, cheap, always on) +SCIP graph → OWNS node identity (Kythe VName) (durable ground truth) +LSP → DISAMBIGUATES a specific position (live, type-aware, surgical, optional) +Commit SCIP → RATIFIES the region, heals drift (deterministic convergence) +``` + +LSP is used **only as a disambiguation oracle over SCIP-identified candidates** — never as a symbol source, never for identity, never for full-repo extraction. When no language server is available, the system degrades gracefully to today's commit-gated behavior with **zero regression**. + +The design is precision-first and fail-closed: the live lane may only emit an edge when resolution is provably correct, and abstains (marks the reference *pending*) otherwise. Honest staleness beats confident wrongness. + +--- + +## 2. Motivation + +### 2.1 The felt problem (dogfooded) + +CLAUDE.md's dogfooding mandate says "the friction you feel is the bug report." This RFC responds to a friction hit repeatedly while developing Travsr itself: after editing a file, `get_callers`, `get_blast_radius`, and `find_references` on symbols in that file return stale or incomplete semantic edges until the next commit. The developer (or agent) navigating their own in-progress change gets the *least* reliable graph at the *moment they most need it*. + +### 2.2 Why this is not solved by the existing stack + +- **Phase A (Tree-sitter)** runs live on save and updates structural nodes/edges, but cannot resolve semantic edges: it knows `user.save()` is a call, not *which* `save`. Name resolution requires scope + type analysis Tree-sitter does not perform. +- **Phase B (SCIP)** is correct but commit-gated by deliberate decision (see `project_phase_b_commit_gated_decision` — commit-gating is a cost concession, not a belief that staleness is acceptable). Running full SCIP on every keystroke is not viable. + +The gap is narrow but real: **cross-file, type-resolved edges for uncommitted edits.** This RFC closes that gap for the surface where it hurts (the IDE) while preserving every existing correctness property. + +### 2.3 Scope of the value + +Be precise about what this buys: the durable graph converges at commit **regardless** of this feature. What the live lane buys is **correctness latency** — precise resolution in seconds mid-edit instead of at the next commit. That is a freshness improvement, not new capability. It is nonetheless high-value because the pain it addresses *is* a freshness problem. + +--- + +## 3. Non-Goals + +- **Not** a replacement for SCIP. SCIP remains the durable, deterministic ground truth and the sole source of cross-corpus identity. +- **Not** LSP as a graph builder or identity source. Prior analysis (see §11 Alternatives) rejected both. +- **Not** a cross-language feature. Live edges are strictly intra-corpus; they never enter the `BridgeRegistry` (RFC-009). +- **Not** a change to the commit-gated Phase B pipeline's outputs. The committed graph is byte-for-byte what a full reindex would produce (Invariant #4). +- **Not** a headless-daemon feature in v1. Initial delivery targets the IDE surface where a language server is already running (§7.6). + +--- + +## 4. Background & Prior Art + +- **ADR-009** established SCIP for new languages and LSIF for incumbents, keyed on *stable, package-qualified symbol identity*. That identity is the foundation this RFC builds on and must never be weakened. +- **ADR-009 Rule 4** forbids feeding *synthetic* (non-corpus-verified) symbols into the bridge registry, because they can collide with a real symbol from a different corpus and violate RFC-005's `src.corpus == dst.corpus` invariant. LSP-derived resolutions are exactly such synthetic symbols; this RFC's fencing rule (§8.2) is a direct consequence. +- **ADR-002 Rule 5** requires every edge to carry a provenance tag. This RFC adds a new value (§9.1). +- **Principal Architect Invariant #4** (incremental correctness): a full reindex and an incremental reindex of the same codebase must produce identical graphs. This is the load-bearing correctness property and §8.3 discharges it. +- **Principal Architect Invariant #3** (LLM prohibition on structural reasoning): note LSP resolution is *deterministic compiler-grade analysis*, not an LLM. It does not violate Invariant #3. It is an algorithm, used as an oracle. + +--- + +## 5. The Core Model — Four Lanes + +| Lane | Produces | Lifetime | Determinism | Provenance | +|---|---|---|---|---| +| Tree-sitter (Phase A) | structural nodes/edges, reference *detection* | live | deterministic | `tree-sitter` | +| SCIP (Phase B) | semantic edges, stable identity | durable | deterministic (pinned indexer) | `scip` / `lsif` | +| **Live resolution (new)** | semantic edges for dirty regions | ephemeral (until commit) | non-deterministic (env-dependent) | `live` | +| Commit ratification | replaces live edges with SCIP truth | durable | deterministic | `scip` / `lsif` | + +The live lane is an **overlay**. It never persists past the commit that ratifies it. The committed graph is always SCIP-derived, which is why Invariant #4 holds (§8.3). + +--- + +## 6. Edit Classification + +The correct invalidation region depends on *what* changed. The live engine classifies each save: + +### 6.1 Body edit +A change that does **not** alter any symbol's referenced surface — no rename, no signature change, no added/removed export, no new/removed reference crossing the file boundary. + +→ **Local re-resolution only.** Re-resolve the edited file's outgoing references. No other file is touched. This is the common case (most edits are body edits) and is cheap. + +### 6.2 Interface edit +A change to the *referenced surface* of a symbol that has incoming edges or is exported: rename, signature change, visibility change, deletion, or introduction of a symbol that other files already reference-by-name. + +→ **Reverse-closure invalidation** (§6.3). This is the expensive case, paid only when the public surface actually changes — mirroring how incremental compilers (salsa, `tsc --incremental`) invalidate downstream only on interface change, not on every body edit. + +### 6.3 Reverse-dependency closure +Given the set `S` of changed symbols in the edited file: + +``` +closure = { f : file f contains an edge whose target ∈ S } + ∪ { f : file f contains an unresolved reference whose name ∈ names(new symbols in S) } +``` + +The store already maintains reverse edges, so the first set is a direct lookup. The second set catches *newly resolvable* references (a symbol that other files referenced-by-name now exists). Files in `closure` have their boundary edges re-resolved against the changed surface. + +**Asymmetry (critical):** *outgoing* edges (edited file → others) are recomputable from the edited file alone; *incoming* edges (others → edited file) are not, because their sources live elsewhere. Incoming edges are stable **only if** the referenced surface is unchanged. This is exactly why interface edits trigger the closure and body edits do not. + +--- + +## 7. Resolution Flow + +For each new or unresolved reference `R` at position `P` in a dirty file: + +``` +1. LOCAL SCOPE CHECK (Tree-sitter, deterministic) + Is R a free/unbound reference (not a local var, param, or shadowed name)? + If R is import-bound, record the import's declared target. + → if R binds to a local/param: resolve locally, no graph edge needed. DONE. + +2. CANDIDATE SET (graph, by name, narrowed by any import target) + candidates = graph.symbols_named(R.name) filtered by import/module hint + +3a. UNAMBIGUOUS LEXICAL (|candidates| == 1 AND local-clean) + → emit edge to the single candidate. + provenance = "live", confidence = high. + This lane requires NO language server. + +3b. AMBIGUOUS + LSP AVAILABLE (|candidates| > 1) + → query textDocument/definition at P, tagged to the current buffer version. + location L ← definition response (must match buffer version, else wait/retry) + node N ← location_to_node(L) // §7.5 + if N resolved → emit edge to N. provenance = "live", confidence = high. + else → ABSTAIN (mark pending). // L pointed outside the graph + +3c. AMBIGUOUS + NO LSP + → ABSTAIN. Mark R as resolution_state = pending. + +4. ABSTENTION IS HONEST + A pending reference is surfaced as "call site present, target not yet + resolved (stale since your edit; resolves at commit)" — never as a + fabricated edge. +``` + +### 7.4 Why 3b restores precision without sacrificing it +`textDocument/definition` is **real type-aware resolution** — the language server knows the static type of the receiver, so it resolves `user.save()` to the correct `save`. This converts the fail-closed lexical lane's *"abstain on ambiguity"* into *"resolve precisely,"* raising recall on precisely the cases (method-on-receiver, overloads) the lexical lane must give up — **without** a precision cost, because it is resolution, not a guess. `definition` is also the most universally implemented LSP method (unlike `callHierarchy`), so capability coverage is broad; where absent, 3c applies and precision is never at risk. + +### 7.5 `location_to_node` — the dirty-file span nuance +A definition location `L = (uri, range)` is in the *current* buffer state. Mapping it to a graph node requires ranges that match the buffer: + +- **Def in a clean file:** SCIP ranges are still valid → direct range lookup. +- **Def in a dirty file:** SCIP ranges are stale (shifted by the edit) → map via Tree-sitter's *current* spans, which were just re-parsed. + +The location→node index is therefore **range-source-aware**: current spans for dirty files, SCIP ranges for clean files. The reconciliation cost is confined to the (small) dirty set. + +### 7.6 Surface-specific server access +| Surface | Server access | Mechanism | +|---|---|---| +| **VS Code / IDE extension** | piggyback the **already-running** language provider | `vscode.executeDefinitionProvider(uri, position)` routes to the active language extension; no separate process spawned | +| **JetBrains** | piggyback platform's resolution API | equivalent PSI-reference resolution | +| **Headless CLI daemon** | spawn-own or degrade | out of scope for v1; falls to §7.3c / commit-gated | + +The value concentrates where the assumption ("a dev has LSP running") holds *and* where mid-edit pain lives: the IDE. In the IDE, LSP usage is genuinely free (no spawn) and surgical (a handful of point queries on dirty files), so LSP's chattiness weakness never bites. + +### 7.7 End-to-end sequence + +Live path (on save) and ratification path (on commit) in one view. Note the three +terminal states of a reference — **emit (lexical)**, **emit (LSP-resolved)**, and +**abstain (pending)** — and that every `live` edge is deleted and replaced by SCIP +at commit (§8.3), which is why the committed graph is always deterministic. + +```mermaid +sequenceDiagram + autonumber + actor Dev + participant ED as Editor / IDE + participant TS as Tree-sitter (Phase A) + participant LE as Live Engine + participant G as SCIP Graph (store) + participant LSP as Language Server + participant CI as Commit Hook (Phase B / SCIP) + + Note over Dev,LSP: LIVE PATH — between commits + Dev->>ED: save file + ED->>TS: re-parse dirty file + TS->>LE: nodes + detected references (structural) + LE->>LE: classify edit (body vs interface §6) + alt interface edit + LE->>G: reverse-dependency closure (§6.3) + G-->>LE: files whose boundary edges need re-resolution + end + + loop each new / unresolved reference R @ P + LE->>TS: local scope check (free? import-bound?) + alt binds to local/param + LE->>LE: resolve locally — no graph edge + else free reference + LE->>G: candidates = symbols_named(R) (import-narrowed) + alt exactly 1 candidate (local-clean) + LE->>G: emit edge — provenance="live" (lexical) + else ambiguous AND LSP available + LE->>LSP: definition(uri, P) [buffer version v] + LSP-->>LE: location L (must match v, else wait/abstain §11) + LE->>G: N = location_to_node(L) (§7.5) + alt N in graph + LE->>G: emit edge — provenance="live" (LSP-resolved) + else L outside graph + LE->>G: mark R pending (abstain) + end + else ambiguous AND no LSP + LE->>G: mark R pending (abstain) + end + end + end + + Note over Dev,CI: RATIFICATION PATH — on commit (Invariant #4) + Dev->>CI: git commit (hook fires) + CI->>CI: incremental SCIP over changed ∪ closure + CI->>G: delete all provenance="live" edges in region + CI->>G: insert SCIP edges; clear resolved pendings; GC ghosts + Note over G: committed graph == full reindex (§8.3) — deterministic, live-free +``` + +--- + +## 8. Correctness Contract + +### 8.1 Precision-first, fail-closed +The live lane's target is **precision ≈ 1.0**, achieved by construction: it emits only (a) unambiguous lexical matches that are local-clean, or (b) LSP-resolved locations that map to a graph node. Everything else abstains. Rationale: for a product whose thesis is "zero structural hallucinations," a false edge is not a quality regression but a **breach of the value proposition**, and the failure modes are asymmetric — a missing edge fails safe and recoverable (fall back to grep/read), a wrong edge fails dangerous and silent (agent traverses to the wrong target). Precision ~1.0 or do not emit. + +### 8.2 Fencing rule (Invariant #1 + ADR-009 Rule 4) +Live edges are **intra-corpus only**. They: +- carry `provenance = "live"`, +- are **never** inserted into the `BridgeRegistry` or any cross-corpus resolution path, +- never create or mutate node *identity* — they only attach edges between **already-existing** SCIP-identified nodes (or Tree-sitter nodes within the dirty file). VName minting remains SCIP's exclusive responsibility. + +This preserves RFC-005's `src.corpus == dst.corpus` invariant and Principal Architect Invariant #1 (VName uniqueness): the live lane cannot synthesize a colliding identity because it never synthesizes identity at all. + +### 8.3 Convergence (Principal Architect Invariant #4) +At commit, the ratification pass runs incremental SCIP over `changed_set ∪ reverse_closure`, then: +1. deletes all `provenance = "live"` edges in that region, +2. inserts the SCIP-derived edges, +3. clears `pending` reference markers SCIP resolved; GCs ghost nodes/edges from deletions. + +**Property (must hold, property-tested):** +``` +graph(full_reindex @ commit_C) == graph(incremental + live_overlay, ratified @ commit_C) +``` +Because every live edge is deleted and replaced by SCIP at commit, the *committed* graph contains zero live edges and is identical to a from-scratch reindex. Invariant #4 is discharged: the live overlay never survives a commit unratified. + +### 8.4 Determinism fence +Live resolution depends on the installed server version and is therefore non-deterministic across environments. This is **fenced**: non-determinism exists only in the ephemeral overlay, between commits. The durable (committed) graph is SCIP-pinned and deterministic. The overlay was never part of the deterministic ground-truth contract, so the fence is sound. Live edges MUST be visibly distinguishable at query time (§10) so no consumer mistakes the overlay for ratified truth. + +--- + +## 9. Data Model & Schema + +### 9.1 Provenance (ADR-002 Rule 5) +Add one value to the edge provenance enum: + +| Source | `edges.provenance` | +|---|---| +| Tree-sitter (Phase A) | `tree-sitter` | +| LSIF | `lsif` | +| SCIP | `scip` | +| Cross-language bridge | `bridge:` | +| **Live resolution (new)** | `live` | + +`live` edges are the only provenance the commit ratifier is permitted to bulk-delete-and-replace. A schema migration adds the enum value; no column changes. + +### 9.2 Reference resolution state +References detected by Tree-sitter but not yet edge-resolved carry a `resolution_state`: +- `resolved` — an edge exists (any provenance). +- `pending` — detected, not resolved, awaiting live resolution or commit ratification. Surfaced honestly; never rendered as an edge. + +**Schema changes require Principal Architect sign-off (§13).** + +--- + +## 10. MCP Surface Impact (Solution Architect) + +The live overlay must be *legible* to consumers, never silently blended into ratified truth: + +- `get_callers`, `find_references`, `get_blast_radius`, `get_graph_json` gain an optional per-edge `provenance` field already present internally; `live` edges are labeled and MAY be filtered via an existing `provenance` filter. +- Responses that include a dirty region append a freshness note in the `` envelope: `live_overlay: { dirty_files: N, pending_refs: M }`, so an agent knows the answer includes un-ratified edges and where the gaps are. +- **No new MCP tool.** This is a data-quality/provenance annotation on existing tools, not a new contract. MCP-as-only-interface (Invariant #6) is unaffected. +- Default behavior is additive: consumers that ignore provenance see a *fresher* graph; consumers that require ground truth filter to `provenance != live`. + +--- + +## 11. Consistency & LSP Settle Protocol + +LSP is eventually-consistent after `didChange`. The engine MUST: +1. push the current buffer version to the server (`didChange`) before querying, +2. tag each `definition` request with the buffer `version`, +3. accept a response only if it corresponds to the version queried; otherwise wait for the next settle or retry (bounded), +4. treat a settle timeout as **abstention** (§7.3c), never as a resolved edge. + +In the IDE-piggyback path (§7.6), the editor owns `didChange`; Travsr reads through `executeDefinitionProvider`, which already reflects the current buffer, simplifying settle handling. + +--- + +## 12. Measurement & Quality Gates (QA) + +The "is the live lane worse than nothing?" question is answered empirically, not by assertion. Ground truth is on tap every commit: + +- **Continuous precision meter:** at each commit, diff `live` edges in the ratified region against the SCIP truth. Precision = agreement rate. **SCIP wins all ties** (it is the ratified ground truth); disagreements are logged with the LSP-resolved vs SCIP-resolved target. +- **Gate:** the live lane ships enabled only if measured precision ≥ 0.99 on the fixture corpus (target: zero false positives). If it cannot hold that bar for a language, the lane is disabled for that language — a measured decision, not a guess. +- **Recall telemetry:** the same diff reports the recall the live lane buys over the pure fail-closed lexical lane, quantifying whether the LSP dependency earns its operational cost. +- Fixture corpus reuses the RFC-003 §6 fixtures plus the existing LSIF path as a differential oracle (available for TS/Rust/Python). + +--- + +## 13. Security & Threat Model (defer to Principal Security Engineer) + +| Concern | Assessment | +|---|---| +| Persistent language server = long-lived process running build logic (ADR-006/017) | **IDE-piggyback path spawns nothing** — it reuses the server the developer already trusts and runs. The headless spawn path (out of v1 scope) inherits the ADR-006/017 review. | +| Live edges poisoning cross-corpus resolution | Blocked by the fencing rule (§8.2): `live` edges never enter the `BridgeRegistry`. | +| Non-deterministic durable graph | Impossible: live edges never survive a commit (§8.3). | +| Prompt-injection via edge content | Unchanged from existing pipeline; `` sanitization applies identically. | + +**Sign-offs required before Accepted:** +- Principal Architect — schema change (§9), new provenance value, convergence proof (§8.3). +- Principal Security Engineer — headless spawn path (when scoped), IDE-piggyback trust assumption. +- Solution Architect — MCP provenance surface (§10). + +--- + +## 14. Phased Delivery + +| Phase | Deliverable | Gate | +|---|---|---| +| **0 — Spike** | TS-only, IDE-piggyback, unambiguous-lexical lane (§7.3a) + LSP disambiguation (§7.3b) on a clean repo. Prove the resolution flow end-to-end. | Demo; measured precision on fixtures. | +| **1 — Edit classification + invalidation** | Body vs interface classifier (§6), reverse-closure invalidation, `pending` state (§9.2). | Property test: no ghost edges after rename/delete fixtures. | +| **2 — Commit ratification + convergence** | Incremental SCIP over `changed ∪ closure`, live-edge replacement, Invariant #4 property test (§8.3). | `graph(full) == graph(incremental+ratified)` green on fixtures. | +| **3 — MCP surface + precision gate** | Provenance labeling, `live_overlay` envelope note (§10), continuous precision meter (§12). | Precision ≥ 0.99 on fixture corpus. | +| **4 — Second language + headless decision** | Rust (differential vs rust-analyzer LSIF). Decide headless spawn path scope with Security. | Per-language precision gate. | + +Language choice for the spike is **TypeScript**: mature `tsserver` resolution + an existing LSIF path to use as the differential oracle. + +--- + +## 15. Alternatives Considered + +1. **LSP as symbol/identity source.** Rejected: LSP speaks `(file, position)` with no package-qualified identity; reconstructing stable identity from it is lossy and, per ADR-009 Rule 4, forbidden from bridges. (This RFC uses LSP for *location*, not identity.) +2. **Common protocol layer over LSP for all languages.** Rejected as a foundation: LSP's uniformity is skin-deep; capability coverage, identity semantics, per-server bootstrap, and trust surface are all per-language, so it degenerates into "common format + N per-language producers" — which is the SCIP model on a worse substrate. +3. **Pure lexical name-matching heuristic (guess best candidate).** Rejected outright: fabricates edges on ambiguous cases (method-on-receiver, overloads), violating the zero-hallucination thesis. Only the *unambiguous* subset survives, as §7.3a. +4. **Incremental SCIP only, no live lane.** Viable and is effectively the fail-safe floor (§7.3c degradation). Rejected as *sufficient* because it does not close the mid-edit window — SCIP still runs at commit, not on save. Retained as the graceful-degradation baseline. +5. **Do nothing (status quo commit-gated).** The zero-cost option and the guaranteed floor. This RFC is strictly additive over it: no server → exactly status quo. + +--- + +## 16. Open Questions + +1. **Headless daemon path.** Should Travsr ever spawn its own pinned language servers for non-IDE consumers, or is commit-gated the permanent answer there? Deferred to Phase 4 with Security. +2. **Interface-edit detection granularity.** Can the classifier reliably distinguish rename from delete+add without the commit SCIP pass? Rename mis-classification only affects the *live* overlay (healed at commit), so a conservative "treat ambiguous as interface edit" is safe but costs recall. Measure in Phase 1. +3. **Multi-dirty-file settle ordering.** When several files are dirty and cross-reference each other, is single-pass resolution sufficient or is a fixpoint needed? Bound the iterations; measure convergence in Phase 1. +4. **Confidence exposure.** Do we surface `live` as a boolean provenance only, or a graded confidence? Solution Architect leans boolean (provenance) for MCP simplicity; revisit if consumers ask. + +--- + +## 17. Risk Register + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| Live precision < 0.99 for a language | Medium | High | Per-language gate (§12); disable lane, keep floor. No regression. | +| Dirty-file span mapping bug → wrong node | Medium | High | Confine to dirty set (§7.5); property tests; commit ratification heals. | +| LSP settle race → stale resolution | Medium | Medium | Version-tagged requests; timeout → abstain (§11). | +| Reverse-closure too large on hot files | Low | Medium | Interface-edit-only trigger (§6); bound closure depth; fall to pending. | +| Ghost edges from missed deletions | Medium | High | Reverse-edge cleanup on interface edits; commit GC (§8.3). | +| Consumers treat overlay as ground truth | Medium | Medium | Mandatory provenance labeling + envelope note (§10); filterable. | + +--- + +## 18. References + +- ADR-002 — Edge Provenance Policy +- ADR-006 — rust-analyzer Subprocess Trust Model +- ADR-009 — SCIP vs LSIF Wire Format (esp. Rule 4, fencing rationale) +- ADR-017 — Unified Plugin Sandbox Trust +- ADR-018 — Drop Kùzu Backend (SQLite+WAL is the only backend) +- RFC-005 — Cross-Language Edge Resolution (`src.corpus == dst.corpus` invariant) +- RFC-008 — Multi-Language Extension Architecture (Phase A/B) +- RFC-009 — Cross-Language Bridge Plugin System +- RFC-014 — Phase B Symbol Unification +- Principal Architect Invariants #1 (VName uniqueness), #3 (LLM prohibition), #4 (incremental correctness) +- LSP specification — `textDocument/definition` +- VS Code API — `vscode.executeDefinitionProvider` From f9d1d6272636de629a8393545fcda2a3350a0747 Mon Sep 17 00:00:00 2001 From: Abhishek Date: Fri, 28 Aug 2026 02:18:50 +0530 Subject: [PATCH 02/23] [docs] RFC-027 review fixes: correct citations, disambiguate schema, harden convergence Applies the flagged PR-review fixes, all doc-only: - Fix ADR-002 citation: the provenance-tag requirement is Rule 1 (provenance NOT NULL column), not Rule 5 (SQL upsert logic). Corrected in section 4 and section 9.1. - Note the de-facto provenance enum in the shipped store (tree-sitter/lsif/scip) already diverged from ADR-002's written value list (tree-sitter/lsif/merged); live extends the de-facto set. - Rename the proposed reference field resolution_state to ref_resolution_state to avoid colliding with the daemon's existing record_dart_resolution_state bookkeeping (unrelated Dart concept). - Replace the phantom project_phase_b_commit_gated_decision citation with the real source: the commit-gated comments in travsr-daemon. - Harden the section 8.3 convergence flow against the delete-old / insert-new transient-gap hazard (review-ask 1): insert SCIP first (it wins by ADR-002 precedence), then delete leftover live edges, all in one WAL transaction. Sequence diagram updated to match. - Answer review-ask 3 in section 16.4: the edges table already carries a confidence column, so graded confidence can ride that field without touching the boolean provenance enum. --- docs/rfcs/RFC-027-live-semantic-resolution.md | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/docs/rfcs/RFC-027-live-semantic-resolution.md b/docs/rfcs/RFC-027-live-semantic-resolution.md index 1dbd1655..777988d6 100644 --- a/docs/rfcs/RFC-027-live-semantic-resolution.md +++ b/docs/rfcs/RFC-027-live-semantic-resolution.md @@ -37,7 +37,7 @@ CLAUDE.md's dogfooding mandate says "the friction you feel is the bug report." T ### 2.2 Why this is not solved by the existing stack - **Phase A (Tree-sitter)** runs live on save and updates structural nodes/edges, but cannot resolve semantic edges: it knows `user.save()` is a call, not *which* `save`. Name resolution requires scope + type analysis Tree-sitter does not perform. -- **Phase B (SCIP)** is correct but commit-gated by deliberate decision (see `project_phase_b_commit_gated_decision` — commit-gating is a cost concession, not a belief that staleness is acceptable). Running full SCIP on every keystroke is not viable. +- **Phase B (SCIP)** is correct but commit-gated by deliberate decision (recorded in code at `travsr-daemon/src/lib.rs` — "Phase B stays commit-gated" / "still commit-gated on purpose"; commit-gating is a cost concession, not a belief that staleness is acceptable). Running full SCIP on every keystroke is not viable. The gap is narrow but real: **cross-file, type-resolved edges for uncommitted edits.** This RFC closes that gap for the surface where it hurts (the IDE) while preserving every existing correctness property. @@ -61,7 +61,7 @@ Be precise about what this buys: the durable graph converges at commit **regardl - **ADR-009** established SCIP for new languages and LSIF for incumbents, keyed on *stable, package-qualified symbol identity*. That identity is the foundation this RFC builds on and must never be weakened. - **ADR-009 Rule 4** forbids feeding *synthetic* (non-corpus-verified) symbols into the bridge registry, because they can collide with a real symbol from a different corpus and violate RFC-005's `src.corpus == dst.corpus` invariant. LSP-derived resolutions are exactly such synthetic symbols; this RFC's fencing rule (§8.2) is a direct consequence. -- **ADR-002 Rule 5** requires every edge to carry a provenance tag. This RFC adds a new value (§9.1). +- **ADR-002 Rule 1** requires every edge to carry a provenance tag (`provenance TEXT NOT NULL DEFAULT 'tree-sitter'`). This RFC adds a new value (§9.1). - **Principal Architect Invariant #4** (incremental correctness): a full reindex and an incremental reindex of the same codebase must produce identical graphs. This is the load-bearing correctness property and §8.3 discharges it. - **Principal Architect Invariant #3** (LLM prohibition on structural reasoning): note LSP resolution is *deterministic compiler-grade analysis*, not an LLM. It does not violate Invariant #3. It is an algorithm, used as an oracle. @@ -134,7 +134,7 @@ For each new or unresolved reference `R` at position `P` in a dirty file: else → ABSTAIN (mark pending). // L pointed outside the graph 3c. AMBIGUOUS + NO LSP - → ABSTAIN. Mark R as resolution_state = pending. + → ABSTAIN. Mark R as ref_resolution_state = pending. 4. ABSTENTION IS HONEST A pending reference is surfaced as "call site present, target not yet @@ -216,8 +216,9 @@ sequenceDiagram Note over Dev,CI: RATIFICATION PATH — on commit (Invariant #4) Dev->>CI: git commit (hook fires) CI->>CI: incremental SCIP over changed ∪ closure - CI->>G: delete all provenance="live" edges in region - CI->>G: insert SCIP edges; clear resolved pendings; GC ghosts + CI->>G: insert SCIP edges (SCIP wins by ADR-002 precedence) + CI->>G: delete leftover provenance="live"; clear resolved pendings; GC ghosts + Note over G: steps run in one WAL transaction (§8.3 — no torn intermediate) Note over G: committed graph == full reindex (§8.3) — deterministic, live-free ``` @@ -237,11 +238,13 @@ Live edges are **intra-corpus only**. They: This preserves RFC-005's `src.corpus == dst.corpus` invariant and Principal Architect Invariant #1 (VName uniqueness): the live lane cannot synthesize a colliding identity because it never synthesizes identity at all. ### 8.3 Convergence (Principal Architect Invariant #4) -At commit, the ratification pass runs incremental SCIP over `changed_set ∪ reverse_closure`, then: -1. deletes all `provenance = "live"` edges in that region, -2. inserts the SCIP-derived edges, +At commit, the ratification pass runs incremental SCIP over `changed_set ∪ reverse_closure`, then, **in a single WAL transaction**: +1. inserts the SCIP-derived edges (which dominate any co-located `live` edge by ADR-002 precedence — the existing `ON CONFLICT(src,dst,kind) DO UPDATE` upsert in `travsr-store/src/lib.rs` already lets `lsif`/`scip` win), +2. deletes the leftover `provenance = "live"` edges in that region that SCIP did not overwrite, 3. clears `pending` reference markers SCIP resolved; GCs ghost nodes/edges from deletions. +**Ordering rationale (delete-old / insert-new hazard).** A naive "delete all `live` edges, then insert SCIP" sequence opens a transient-gap window: a concurrent query landing between the two steps would see *neither* the live edge (already deleted) nor the SCIP edge (not yet inserted), momentarily reporting a missing edge on ratified code. Insert-then-delete inside one transaction closes that window — every reader sees either the pre-commit overlay or the fully-ratified graph, never a torn intermediate. This directly answers review-ask #1: the hazard is real but is dissolved by ordering + transaction atomicity, not by a change to the convergence property. + **Property (must hold, property-tested):** ``` graph(full_reindex @ commit_C) == graph(incremental + live_overlay, ratified @ commit_C) @@ -255,7 +258,7 @@ Live resolution depends on the installed server version and is therefore non-det ## 9. Data Model & Schema -### 9.1 Provenance (ADR-002 Rule 5) +### 9.1 Provenance (ADR-002 Rule 1) Add one value to the edge provenance enum: | Source | `edges.provenance` | @@ -266,10 +269,12 @@ Add one value to the edge provenance enum: | Cross-language bridge | `bridge:` | | **Live resolution (new)** | `live` | +Note: this table reflects the **de-facto** enum in the shipped store (`tree-sitter` / `lsif` / `scip`, plus `bridge:` from ADR-009), which already diverged from ADR-002's originally written value list (`tree-sitter` / `lsif` / `merged`, where `merged` was reserved-for-future and `scip` was added later). `live` extends that de-facto set; it does not reinstate `merged`. + `live` edges are the only provenance the commit ratifier is permitted to bulk-delete-and-replace. A schema migration adds the enum value; no column changes. ### 9.2 Reference resolution state -References detected by Tree-sitter but not yet edge-resolved carry a `resolution_state`: +References detected by Tree-sitter but not yet edge-resolved carry a `ref_resolution_state` (named to avoid collision with the daemon's existing `record_dart_resolution_state` bookkeeping in `travsr-daemon/src/lib.rs`, which tracks Dart Phase B availability and is an unrelated concept): - `resolved` — an edge exists (any provenance). - `pending` — detected, not resolved, awaiting live resolution or commit ratification. Surfaced honestly; never rendered as an edge. @@ -356,7 +361,7 @@ Language choice for the spike is **TypeScript**: mature `tsserver` resolution + 1. **Headless daemon path.** Should Travsr ever spawn its own pinned language servers for non-IDE consumers, or is commit-gated the permanent answer there? Deferred to Phase 4 with Security. 2. **Interface-edit detection granularity.** Can the classifier reliably distinguish rename from delete+add without the commit SCIP pass? Rename mis-classification only affects the *live* overlay (healed at commit), so a conservative "treat ambiguous as interface edit" is safe but costs recall. Measure in Phase 1. 3. **Multi-dirty-file settle ordering.** When several files are dirty and cross-reference each other, is single-pass resolution sufficient or is a fixpoint needed? Bound the iterations; measure convergence in Phase 1. -4. **Confidence exposure.** Do we surface `live` as a boolean provenance only, or a graded confidence? Solution Architect leans boolean (provenance) for MCP simplicity; revisit if consumers ask. +4. **Confidence exposure.** Do we surface `live` as a boolean provenance only, or a graded confidence? Solution Architect leans boolean (provenance) for MCP simplicity; revisit if consumers ask. Note the plumbing for graded already exists independently of provenance: the `edges` table already carries a `confidence` column (written today by the LSIF/SCIP upserts in `travsr-store/src/lib.rs`), so a future graded signal can ride that field **without** touching the provenance enum. Recommended resolution: keep `provenance` boolean, reserve `confidence` as the graded channel if a consumer ever needs it. --- From 960c1a2e1045456bfd3eb126ed74d5b1a44e2c4d Mon Sep 17 00:00:00 2001 From: Abhishek Date: Fri, 28 Aug 2026 03:27:06 +0530 Subject: [PATCH 03/23] [travsr-core] Thread edge provenance to the MCP surface (DEBT-75) Edges read back through the graph traversal path always reported "tree-sitter", even when the stored row was "lsif" or "scip". Only the CLI --all path carried true provenance, so `travsr graph --format json` and the MCP tools misreported how every BFS-traversed edge was derived. The cause was that core Edge had no provenance field, so the store's iter_edges_* readers had nowhere to put the column and next_edges hardcoded a literal "tree-sitter" for the JSON output. Adds `provenance: Option` to core Edge as a read-side field: - The four store readers (iter_edges_from, iter_edges_to, iter_edges_from_batch, iter_edges_from_kind) now SELECT and carry it. - next_edges returns it and the hardcoded literal is gone. - Writers are untouched. Every insert path still takes its provenance as an explicit argument, so there is exactly one source of truth on write. - serde default plus skip_serializing_if, so the plugin protocol wire format is unchanged and no plugin crate is touched. PartialEq is hand-written to exclude provenance. An edge's identity in the store is its (src, dst, kind) primary key, and provenance is metadata about how that one edge was derived, not a second edge, so a read-back edge still compares equal to the constructed one it came from. Gate: cargo test -p travsr-core -p travsr-store -p travsr-mcp -p travsr-daemon 18 suites, 1166 passed, 0 failed. Two new tests: edge_readers_carry_true_provenance and provenance_is_not_part_of_edge_identity. --- crates/travsr-core/src/lib.rs | 37 +++++++++++- crates/travsr-mcp/src/query.rs | 39 +++++++------ crates/travsr-mcp/src/tools.rs | 4 +- crates/travsr-store/src/lib.rs | 104 ++++++++++++++++++++++++++++----- 4 files changed, 150 insertions(+), 34 deletions(-) diff --git a/crates/travsr-core/src/lib.rs b/crates/travsr-core/src/lib.rs index 5e6748f3..fe211d63 100644 --- a/crates/travsr-core/src/lib.rs +++ b/crates/travsr-core/src/lib.rs @@ -1013,7 +1013,7 @@ pub fn is_scip_anonymous_local(sig: &str) -> bool { } /// A directed, typed edge between two nodes. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Eq, Serialize, Deserialize)] pub struct Edge { pub src: NodeId, pub dst: NodeId, @@ -1022,6 +1022,32 @@ pub struct Edge { /// `None` for all non-FFI edges. Stored in `edges.confidence` (migration v6). #[serde(default, skip_serializing_if = "Option::is_none")] pub confidence: Option, + /// How this edge was derived: the `edges.provenance` tag required by + /// ADR-002 Rule 1 (`tree-sitter` / `lsif` / `scip` / `bridge:`). + /// + /// This is a **read-side** field, populated by the store's `iter_edges_*` + /// readers so consumers (the MCP surface, `travsr graph --format json`) can + /// report an edge's true origin instead of assuming `tree-sitter` + /// (DEBT-75). It is `None` on an edge that was constructed rather than read. + /// Writers are unaffected: every insert path still takes its provenance as + /// an explicit argument, so there is exactly one source of truth on write. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance: Option, +} + +/// Equality is over `(src, dst, kind, confidence)` and deliberately **excludes** +/// `provenance`: an edge's identity in the store is its `(src, dst, kind)` +/// primary key, and provenance is metadata about how that one edge was derived, +/// not a second edge. Two `Edge` values that differ only in provenance denote +/// the same edge, so comparing a constructed edge against a read-back one stays +/// meaningful. +impl PartialEq for Edge { + fn eq(&self, other: &Self) -> bool { + self.src == other.src + && self.dst == other.dst + && self.kind == other.kind + && self.confidence == other.confidence + } } impl Edge { @@ -1031,9 +1057,17 @@ impl Edge { dst, kind, confidence: None, + provenance: None, } } + /// Attach a read-side provenance tag. Used by the store readers; see the + /// `provenance` field docs. + pub fn with_provenance(mut self, provenance: impl Into) -> Self { + self.provenance = Some(provenance.into()); + self + } + /// Build a cross-language FFI edge with a confidence score (RFC-005). /// /// `confidence` must be in `0..=100`. Panics in debug builds if violated. @@ -1047,6 +1081,7 @@ impl Edge { dst, kind: EdgeKind::FFICall, confidence: Some(confidence), + provenance: None, } } } diff --git a/crates/travsr-mcp/src/query.rs b/crates/travsr-mcp/src/query.rs index 698fc4fe..f3baeade 100644 --- a/crates/travsr-mcp/src/query.rs +++ b/crates/travsr-mcp/src/query.rs @@ -717,17 +717,30 @@ fn is_containment_edge(kind: &travsr_core::EdgeKind) -> bool { /// containment edge reached in `Callers`/`Both` direction (#517 DD-1): the /// node is still recorded and displayed, but the traversal does not walk /// further from it, so a file's other definitions never enter the BFS queue. +/// The read-side provenance of an edge that came out of a store reader, with a +/// `tree-sitter` fallback for a constructed edge that never carried one. +fn prov_of(e: &travsr_core::Edge) -> String { + e.provenance + .clone() + .unwrap_or_else(|| "tree-sitter".to_string()) +} + pub fn next_edges( store: &SqliteStore, node_id: NodeId, direction: QueryDirection, edge_mode: QueryEdgeMode, is_seed: bool, -) -> anyhow::Result> { +) -> anyhow::Result> { + // DEBT-75: the 5th element is the edge's true `edges.provenance`, carried + // through from the store readers so callers no longer have to assume + // "tree-sitter". `unwrap_or` only fires on a constructed (never-read) edge, + // which cannot reach here. let mut out = Vec::new(); if matches!(direction, QueryDirection::Deps | QueryDirection::Both) { for e in store.iter_edges_from(node_id)? { - out.push((e.kind, e.dst, true, false)); + let prov = e.provenance.unwrap_or_else(|| "tree-sitter".to_string()); + out.push((e.kind, e.dst, true, false, prov)); } } if matches!(direction, QueryDirection::Callers | QueryDirection::Both) { @@ -769,7 +782,7 @@ pub fn next_edges( for e in &incoming { let s = &e.kind; if is_semantic_edge(s) || matches!(s, travsr_core::EdgeKind::DefinesBinding) { - out.push((*s, e.src, !is_containment_edge(s), true)); + out.push((*s, e.src, !is_containment_edge(s), true, prov_of(e))); } } } else { @@ -779,24 +792,24 @@ pub fn next_edges( // judged from coverage in graph_query, not from this one node. for e in &incoming { let s = &e.kind; - out.push((*s, e.src, !is_containment_edge(s), true)); + out.push((*s, e.src, !is_containment_edge(s), true, prov_of(e))); } } } else { for e in &incoming { let s = &e.kind; - out.push((*s, e.src, !is_containment_edge(s), true)); + out.push((*s, e.src, !is_containment_edge(s), true, prov_of(e))); } } } // Multiple call sites (and the file-node definition splice) can yield the // same (kind, src, orientation) triple — collapse them for display. let mut seen = HashSet::new(); - out.retain(|(kind, id, _, incoming)| seen.insert((*kind, *id, *incoming))); + out.retain(|(kind, id, _, incoming, _)| seen.insert((*kind, *id, *incoming))); // #517 DD-1: non-containment edges (the answer) precede containment edges // (orientation) from the same parent. Stable sort preserves DB order // within each group, so output stays deterministic. - out.sort_by_key(|(kind, _, _, _)| is_containment_edge(kind)); + out.sort_by_key(|(kind, _, _, _, _)| is_containment_edge(kind)); Ok(out) } @@ -909,7 +922,7 @@ pub fn graph_query(store: &SqliteStore, args: &GraphQueryArgs) -> anyhow::Result continue; } - for (edge_kind, next_id, child_expand, edge_incoming) in next_edges( + for (edge_kind, next_id, child_expand, edge_incoming, edge_provenance) in next_edges( store, current_id, args.direction, @@ -923,15 +936,7 @@ pub fn graph_query(store: &SqliteStore, args: &GraphQueryArgs) -> anyhow::Result } else { (current_id, next_id) }; - // DEBT(travsr-75): iter_edges_from/to do not return provenance, so - // BFS-traversed edges always show "tree-sitter" in JSON output even - // when the DB row is "lsif". Only --all mode (all_edges) is correct. - edges_raw.push(( - src, - dst, - edge_kind.as_str().to_string(), - "tree-sitter".to_string(), - )); + edges_raw.push((src, dst, edge_kind.as_str().to_string(), edge_provenance)); if !visited.contains(&next_id) { if let Some(next_node) = store.get_node(next_id)? { diff --git a/crates/travsr-mcp/src/tools.rs b/crates/travsr-mcp/src/tools.rs index 15d987c7..893ef93b 100644 --- a/crates/travsr-mcp/src/tools.rs +++ b/crates/travsr-mcp/src/tools.rs @@ -6907,7 +6907,7 @@ fn get_graph_json_raw( // First pass: dedup via edge_seen, enqueue new visits. // Collect (dst_id, kind_s) only for edges that produce JSON output. let mut new_edges: Vec<(NodeId, &str)> = Vec::new(); - for (kind, next_id, child_expand, _incoming) in &nexts { + for (kind, next_id, child_expand, _incoming, _provenance) in &nexts { let kind_s = edge_kind_str(kind); if edge_seen.insert((current_id, *next_id, kind_s)) { new_edges.push((*next_id, kind_s)); @@ -6946,7 +6946,7 @@ fn get_graph_json_raw( ) { // First pass: dedup via edge_seen, enqueue new visits. let mut new_edges: Vec<(NodeId, &str)> = Vec::new(); - for (kind, next_id, child_expand, _incoming) in &nexts { + for (kind, next_id, child_expand, _incoming, _provenance) in &nexts { let kind_s = edge_kind_str(kind); if edge_seen.insert((*next_id, current_id, kind_s)) { new_edges.push((*next_id, kind_s)); diff --git a/crates/travsr-store/src/lib.rs b/crates/travsr-store/src/lib.rs index 2d8876f9..2557597a 100644 --- a/crates/travsr-store/src/lib.rs +++ b/crates/travsr-store/src/lib.rs @@ -7250,20 +7250,24 @@ impl Store for SqliteStore { (|| -> AnyResult> { let mut stmt = self .conn - .prepare_cached("SELECT dst, kind, confidence FROM edges WHERE src = ?1") + .prepare_cached( + "SELECT dst, kind, confidence, provenance FROM edges WHERE src = ?1", + ) .context("preparing iter_edges_from query")?; let rows = stmt .query_map(params![node_id_to_i64(src)], |row| { let dst_i64: i64 = row.get(0)?; let kind_str: String = row.get(1)?; let confidence: Option = row.get(2)?; - Ok((dst_i64, kind_str, confidence)) + let provenance: String = row.get(3)?; + Ok((dst_i64, kind_str, confidence, provenance)) }) .context("executing iter_edges_from query")?; let mut out = Vec::new(); for row in rows { - let (dst_i64, kind_str, confidence) = row.context("decoding edge row")?; + let (dst_i64, kind_str, confidence, provenance) = + row.context("decoding edge row")?; let kind = EdgeKind::from_str(&kind_str) .with_context(|| format!("unknown edge kind in storage: {kind_str}"))?; out.push(Edge { @@ -7271,6 +7275,7 @@ impl Store for SqliteStore { dst: i64_to_node_id(dst_i64), kind, confidence: confidence.map(|c| c as u8), + provenance: Some(provenance), }); } tracing::debug!(edges_returned = out.len()); @@ -7288,18 +7293,18 @@ impl Store for SqliteStore { (|| -> AnyResult> { let mut stmt = self .conn - .prepare("SELECT dst FROM edges WHERE src = ?1 AND kind = ?2") + .prepare("SELECT dst, provenance FROM edges WHERE src = ?1 AND kind = ?2") .context("preparing iter_edges_from_kind query")?; let rows = stmt .query_map(params![node_id_to_i64(src), kind.as_str()], |row| { - row.get::<_, i64>(0) + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) }) .context("executing iter_edges_from_kind query")?; let mut out = Vec::new(); for row in rows { - let dst_i64 = row.context("decoding iter_edges_from_kind row")?; - out.push(Edge::new(src, i64_to_node_id(dst_i64), kind)); + let (dst_i64, provenance) = row.context("decoding iter_edges_from_kind row")?; + out.push(Edge::new(src, i64_to_node_id(dst_i64), kind).with_provenance(provenance)); } tracing::debug!(edges_returned = out.len()); Ok(out) @@ -7316,7 +7321,7 @@ impl Store for SqliteStore { (|| -> AnyResult> { let placeholders = srcs.iter().map(|_| "?").collect::>().join(","); let sql = format!( - "SELECT src, dst, kind, confidence FROM edges WHERE src IN ({placeholders})" + "SELECT src, dst, kind, confidence, provenance FROM edges WHERE src IN ({placeholders})" ); // prepare() not prepare_cached(): the SQL string varies per chunk length // (different number of '?' placeholders), so prepare_cached would create a @@ -7334,12 +7339,14 @@ impl Store for SqliteStore { let dst_i64: i64 = row.get(1)?; let kind_str: String = row.get(2)?; let confidence: Option = row.get(3)?; - Ok((src_i64, dst_i64, kind_str, confidence)) + let provenance: String = row.get(4)?; + Ok((src_i64, dst_i64, kind_str, confidence, provenance)) }) .context("executing iter_edges_from_batch")?; let mut out = Vec::new(); for row in rows { - let (src_i64, dst_i64, kind_str, confidence) = row.context("decoding edge row")?; + let (src_i64, dst_i64, kind_str, confidence, provenance) = + row.context("decoding edge row")?; let kind = EdgeKind::from_str(&kind_str) .with_context(|| format!("unknown edge kind in storage: {kind_str}"))?; out.push(Edge { @@ -7347,6 +7354,7 @@ impl Store for SqliteStore { dst: i64_to_node_id(dst_i64), kind, confidence: confidence.map(|c| c as u8), + provenance: Some(provenance), }); } tracing::debug!(edges_returned = out.len()); @@ -7360,22 +7368,23 @@ impl Store for SqliteStore { (|| -> AnyResult> { let mut stmt = self .conn - .prepare("SELECT src, kind FROM edges WHERE dst = ?1") + .prepare("SELECT src, kind, provenance FROM edges WHERE dst = ?1") .context("preparing iter_edges_to query")?; let rows = stmt .query_map(params![node_id_to_i64(dst)], |row| { let src_i64: i64 = row.get(0)?; let kind_str: String = row.get(1)?; - Ok((src_i64, kind_str)) + let provenance: String = row.get(2)?; + Ok((src_i64, kind_str, provenance)) }) .context("executing iter_edges_to query")?; let mut out = Vec::new(); for row in rows { - let (src_i64, kind_str) = row.context("decoding edge row")?; + let (src_i64, kind_str, provenance) = row.context("decoding edge row")?; let kind = EdgeKind::from_str(&kind_str) .with_context(|| format!("unknown edge kind in storage: {kind_str}"))?; - out.push(Edge::new(i64_to_node_id(src_i64), dst, kind)); + out.push(Edge::new(i64_to_node_id(src_i64), dst, kind).with_provenance(provenance)); } tracing::debug!(edges_returned = out.len()); Ok(out) @@ -9382,6 +9391,71 @@ mod tests { ); } + /// DEBT-75: every `iter_edges_*` reader must carry the row's true + /// `edges.provenance`, so a consumer that traverses the graph (the MCP + /// surface, `travsr graph --format json`) reports how an edge was actually + /// derived instead of assuming `tree-sitter`. Before this, an `lsif` edge + /// read back through BFS was indistinguishable from a heuristic one. + #[test] + fn edge_readers_carry_true_provenance() { + let mut store = SqliteStore::open_in_memory().unwrap(); + let a = node_with_path("a.ts", "fn:a"); + let b = node_with_path("b.ts", "fn:b"); + store.put_node(&a).unwrap(); + store.put_node(&b).unwrap(); + + // One tree-sitter edge and one lsif edge out of the same source. + let ts_edge = Edge::new(a.id, b.id, EdgeKind::DefinesBinding); + let lsif_edge = Edge::new(a.id, b.id, EdgeKind::RefCall); + store.put_edge(&ts_edge).unwrap(); + store.put_edge_lsif(&lsif_edge).unwrap(); + + let prov = |edges: &[Edge], kind: EdgeKind| -> String { + edges + .iter() + .find(|e| e.kind == kind) + .and_then(|e| e.provenance.clone()) + .unwrap_or_else(|| panic!("no {kind:?} edge returned")) + }; + + let from = store.iter_edges_from(a.id).unwrap(); + assert_eq!(prov(&from, EdgeKind::RefCall), "lsif"); + assert_eq!(prov(&from, EdgeKind::DefinesBinding), "tree-sitter"); + + let to = store.iter_edges_to(b.id).unwrap(); + assert_eq!(prov(&to, EdgeKind::RefCall), "lsif"); + assert_eq!(prov(&to, EdgeKind::DefinesBinding), "tree-sitter"); + + let batch = store.iter_edges_from_batch(&[a.id]).unwrap(); + assert_eq!(prov(&batch, EdgeKind::RefCall), "lsif"); + assert_eq!(prov(&batch, EdgeKind::DefinesBinding), "tree-sitter"); + + let by_kind = store.iter_edges_from_kind(a.id, EdgeKind::RefCall).unwrap(); + assert_eq!(prov(&by_kind, EdgeKind::RefCall), "lsif"); + } + + /// Provenance is metadata about how an edge was derived, not part of its + /// identity: the store's primary key is `(src, dst, kind)`. A read-back edge + /// must therefore still compare equal to the constructed one it came from. + #[test] + fn provenance_is_not_part_of_edge_identity() { + let mut store = SqliteStore::open_in_memory().unwrap(); + let a = node_with_path("a.ts", "fn:a"); + let b = node_with_path("b.ts", "fn:b"); + store.put_node(&a).unwrap(); + store.put_node(&b).unwrap(); + let built = Edge::new(a.id, b.id, EdgeKind::RefCall); + store.put_edge_lsif(&built).unwrap(); + + let read_back = store.iter_edges_from(a.id).unwrap(); + assert_eq!(read_back.len(), 1); + assert_eq!(read_back[0].provenance.as_deref(), Some("lsif")); + assert_eq!( + read_back[0], built, + "an edge differing only in provenance is the same edge" + ); + } + // V4 (no package column) → V5 migration must add package with empty default, // add language column to edges, and existing nodes must read back correctly. #[test] @@ -10120,6 +10194,7 @@ mod tests { dst: node_b.id, kind: travsr_core::EdgeKind::RefCall, confidence: None, + provenance: None, }; let batch = vec![ @@ -10185,6 +10260,7 @@ mod tests { dst: node.id, kind: travsr_core::EdgeKind::RefCall, confidence: None, + provenance: None, }; let batch = vec![ From 250a408026b40ace61b832d8ccc45d33b7c4f2cb Mon Sep 17 00:00:00 2001 From: Abhishek Date: Fri, 28 Aug 2026 04:18:13 +0530 Subject: [PATCH 04/23] [travsr-daemon] RFC-027 Phase 0: live semantic resolution lane Closes the between-commits gap where Phase A knows that `u.save()` is a call but not which `save`, so get_callers and find_references degrade on exactly the code being edited. Two emit lanes, both fail-closed: - Unambiguous-lexical (RFC 7.3a) runs on every save with no language server. Seeded by the UnresolvedCalls the native Phase B extractor already produces, resolved only when the callee signature names exactly one definition. - LSP-disambiguated (RFC 7.3b) consumes positions an editor resolved, via the new ReportLiveResolution control message. The editor reports a position and never an identity; the daemon maps both ends to nodes itself, so SCIP keeps sole ownership of VName minting. Anything ambiguous, unmappable, cross-corpus or self-referential abstains. A missing edge fails safe, a wrong edge fails silent, and for a zero-structural- hallucination product the second is a breach rather than a regression. Store: put_edge_live upserts with WHERE provenance IN ('tree-sitter','live'), so live upgrades a heuristic row, is idempotent over itself, and can never overwrite lsif/scip. Idempotence is load-bearing: reindex_replace deletes every outbound edge of a file on each save, so the engine re-emits whole-file. enclosing_definition_at maps a position to its narrowest enclosing definition (RFC 7.5); one lookup covers dirty and clean files because node spans always come from the most recent parse. Signature contract worth recording: the extractor does not emit a callee signature matching the definition node. For `u.save()` it emits `fn:save` with is_method_call and the receiver type in recv_type, while the node is `method:User.save`. The lane rebuilds `method:{recv_type}.{leaf}` before lookup. A method call with no recovered receiver type abstains, which is precisely the ambiguity the LSP lane exists to settle. Deliberately does not reuse resolve_unresolved_calls: its contract is recall-biased ("overconnection is safe, all matches are emitted, PPR damping absorbs the noise"), the opposite policy. Both are right for their lane. RFC revisions applied, each confirmed against the code: - 9.1: no schema migration needed, edges.provenance is unconstrained TEXT. - 8.3: ratification rides the whole-project Phase B run; the "single WAL transaction" claim is replaced with ordering plus store-lock exclusion, since the Phase B write path is several self-committing statements; the sweep is language-scoped because made_progress advances the marker on partial success; and most live edges never reach the sweep at all because a Phase B write ratifies a co-located row in place. - 8.4: reconciled with the #688 editor plane. #688 carries the editor's claim about the code and has no terminating event, so it stays out of the graph. A live edge carries a position, and its non-determinism ends at the next Phase B run. Same rule, different answers. - 10: per-edge provenance was not already present (DEBT-75) and no provenance filter exists. Gate: cargo test -p travsr-core -p travsr-store -p travsr-ipc -p travsr-mcp -p travsr-daemon, 21 suites, 1205 passed, 0 failed. 14 new tests. Verified end to end on a TS repo through the real daemon, socket, store and CLI: an uncommitted edit that adds a caller shows zero call edges, then both callers as provenance="live" after resolution, then zero live edges with both call edges preserved once Phase B completes. --- crates/travsr-daemon/src/lib.rs | 108 +++ crates/travsr-daemon/src/live_resolve.rs | 631 ++++++++++++++++++ crates/travsr-ipc/src/message.rs | 116 ++++ crates/travsr-store/src/lib.rs | 191 ++++++ docs/rfcs/RFC-027-live-semantic-resolution.md | 26 +- 5 files changed, 1068 insertions(+), 4 deletions(-) create mode 100644 crates/travsr-daemon/src/live_resolve.rs diff --git a/crates/travsr-daemon/src/lib.rs b/crates/travsr-daemon/src/lib.rs index e23ab6fd..9cfd74eb 100644 --- a/crates/travsr-daemon/src/lib.rs +++ b/crates/travsr-daemon/src/lib.rs @@ -7,6 +7,7 @@ #![forbid(unsafe_code)] mod hook; +pub mod live_resolve; pub mod logfile; mod phase_b_sched; mod query_cache; @@ -3620,6 +3621,56 @@ fn run_with_phase_b_finish_guard( /// tick), and [`AllCrashed`](phase_b_sched::RunOutcome::AllCrashed) when every /// sidecar crashed, which increments the retry-cap counter in /// `PhaseBScheduler`. +/// RFC-027 section 7.3a: emit the unambiguous-lexical live overlay for one file. +/// +/// Re-runs the native Phase B call-site extractor over just this file to get its +/// [`UnresolvedCall`]s, then hands them to the precision-first live resolver. +/// Reusing the extractor (rather than a second call-site parser) is what keeps +/// the live lane detecting exactly the references Phase B will later ratify. +/// +/// Only languages with a native Phase B call-site extractor participate; every +/// other language keeps today's commit-gated behavior exactly, which is the +/// RFC's zero-regression floor. +fn live_resolve_file( + store: &mut SqliteStore, + corpus: &str, + repo_root: &Path, + abs_path: &Path, + vname_path: &str, +) { + let ext = abs_path.extension().and_then(|e| e.to_str()).unwrap_or(""); + // `Language::TypeScript` covers .ts/.tsx/.js/.jsx — there is no separate + // JavaScript variant, and the native extractor handles both. + if travsr_core::Language::from_extension(ext) != Some(travsr_core::Language::TypeScript) { + return; + } + let files = [(abs_path.to_path_buf(), vname_path.to_string())]; + let unresolved = match travsr_analysis::phase_b_typescript::extract_native_phase_b( + corpus, + repo_root, + Some(&files), + ) { + Ok((_, _, unresolved)) => unresolved, + Err(e) => { + tracing::debug!(path = %vname_path, error = %e, "live: call-site extraction failed"); + return; + } + }; + if unresolved.is_empty() { + return; + } + let outcome = live_resolve::resolve_unambiguous_lexical(store, &unresolved); + if outcome.emitted > 0 { + tracing::debug!( + event = "live.file", + path = %vname_path, + emitted = outcome.emitted, + pending = outcome.pending, + "live overlay refreshed for saved file" + ); + } +} + fn run_background_phase_b_inner( repo_root: &Path, store: &std::sync::Mutex, @@ -4104,6 +4155,21 @@ pub fn reindex_files( callers_all.extend(report.callers); } any_changed = true; + // RFC-027 section 7.3a: re-resolve this file's call sites into + // `provenance='live'` edges for the ones whose callee signature + // is unambiguous repo-wide. This is the between-commits overlay + // and needs no language server. + // + // It runs on EVERY save of the file, not only when something + // looks new, because `reindex_replace` above just deleted every + // outbound edge this file owned. Re-emitting is idempotent + // (`put_edge_live` upserts), so whole-file re-resolution is + // both the correct and the cheap option. + // + // Fail-open: a live edge is a freshness gain, never a + // correctness dependency, so any error here is logged and + // dropped rather than failing the save. + live_resolve_file(store, &corpus, repo_root, abs_path, &vname_path); // Collect FFI markers for the repo-level pass (RFC-005). all_ffi_markers.extend(out.ffi_markers); // Collect Cargo workspace dep markers for the A2 repo-level pass. @@ -10212,6 +10278,48 @@ fn handle_control_message( enqueue_dirty_callers(dirty, repo_root, index_tx); (ControlResponse::ok(None), false) } + Ok(ControlMessage::ReportLiveResolution { + repo_root: reported_root, + session, + file, + resolutions, + }) => { + // Same identity guard as ReportLspDiagnostics (#698 review, P1): + // discovery enumerates a namespace, not a repo, so a report has to + // say who it is for. Here the stakes are higher than for the editor + // plane, because these positions become graph edges: accepting + // another repo's report would write edges between nodes chosen by + // paths that mean something else in this graph. + let reported = travsr_ipc::normalize_repo_root(std::path::Path::new(&reported_root)); + if reported != travsr_ipc::normalize_repo_root(repo_root) { + return ( + ControlResponse::err("report is for a different repo".to_string()), + false, + ); + } + + let mut s = store.lock().unwrap_or_else(|e| e.into_inner()); + let corpus = s.get_meta("corpus").ok().flatten().unwrap_or_default(); + let outcome = + live_resolve::apply_live_resolutions(&mut s, &corpus, &file, &resolutions); + drop(s); + + tracing::debug!( + event = "live.report", + session = %session, + file = %file, + emitted = outcome.emitted, + pending = outcome.pending, + "live resolution report applied" + ); + ( + ControlResponse::ok(Some(format!( + "{} live, {} pending", + outcome.emitted, outcome.pending + ))), + false, + ) + } Ok(ControlMessage::ReportLspDiagnostics { repo_root: reported_root, session, diff --git a/crates/travsr-daemon/src/live_resolve.rs b/crates/travsr-daemon/src/live_resolve.rs new file mode 100644 index 00000000..095938c1 --- /dev/null +++ b/crates/travsr-daemon/src/live_resolve.rs @@ -0,0 +1,631 @@ +//! RFC-027 live semantic resolution — the between-commits overlay. +//! +//! Phase B (SCIP) is commit-gated on purpose, so between commits only Phase A +//! runs and a cross-file reference stays unresolved: Tree-sitter knows that +//! `user.save()` is a call, not *which* `save`. This module closes that window +//! by emitting `provenance='live'` edges for references it can resolve +//! **precisely**, and abstaining otherwise. +//! +//! The division of labour is the whole design (RFC-027 section 5): +//! +//! ```text +//! Tree-sitter -> DETECTS a reference exists +//! SCIP graph -> OWNS node identity (Kythe VName) +//! LSP -> DISAMBIGUATES a specific position +//! Commit SCIP -> RATIFIES the region, heals drift +//! ``` +//! +//! Two emit lanes, both fail-closed (section 8.1): +//! +//! - [`resolve_unambiguous_lexical`] (section 7.3a) needs no language server. +//! A name with exactly one definition in the graph has only one thing it can +//! mean, so resolving it is a lookup, not a guess. +//! - [`apply_live_resolutions`] (section 7.3b) consumes positions an editor's +//! language provider resolved. The editor answers "what does this position +//! point at"; this module maps both ends to nodes itself, so identity stays +//! SCIP's (section 8.2 fencing rule). Nothing here mints a VName. +//! +//! Anything ambiguous, unmappable, or cross-corpus abstains. A missing edge +//! fails safe (fall back to grep); a wrong edge fails silent and dangerous, +//! which for a "zero structural hallucinations" product is a breach of the +//! value proposition rather than a quality regression. + +use travsr_core::{Edge, EdgeKind, NodeId, UnresolvedCall}; +use travsr_ipc::message::LiveResolution; +use travsr_store::{SqliteStore, Store}; + +/// What one live-resolution pass did, for logging and the Phase 3 precision +/// meter. `pending` is not a failure: it is the fail-closed path working. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct LiveOutcome { + /// Edges written with `provenance='live'`. + pub emitted: usize, + /// References that could not be resolved precisely and were abstained on. + pub pending: usize, +} + +impl LiveOutcome { + fn emit(&mut self) { + self.emitted += 1; + } + fn abstain(&mut self) { + self.pending += 1; + } +} + +/// Section 7.3b: turn editor-resolved positions into `live` edges. +/// +/// `file` is the dirty file the references live in, repo-relative with forward +/// slashes, matching the graph's path keys. +/// +/// Each resolution is mapped independently and a failure abstains rather than +/// aborting the batch: one unmappable position must not cost the others their +/// freshness. +pub fn apply_live_resolutions( + store: &mut SqliteStore, + corpus: &str, + file: &str, + resolutions: &[LiveResolution], +) -> LiveOutcome { + let mut outcome = LiveOutcome::default(); + for r in resolutions { + match resolve_one(store, corpus, file, r) { + Some(edge) => { + if let Err(e) = store.put_edge_live(&edge) { + // A write failure is a freshness loss, never a correctness + // one: the commit-gated path still ratifies this region. + tracing::debug!(error = %e, "live edge write failed"); + outcome.abstain(); + } else { + outcome.emit(); + } + } + None => outcome.abstain(), + } + } + if outcome.emitted > 0 || outcome.pending > 0 { + tracing::debug!( + event = "live.resolved", + file = %file, + emitted = outcome.emitted, + pending = outcome.pending, + "live semantic resolution pass complete" + ); + } + outcome +} + +/// Map one editor resolution to an edge, or `None` to abstain. +fn resolve_one(store: &SqliteStore, corpus: &str, file: &str, r: &LiveResolution) -> Option { + // The call site's enclosing definition is the edge's source. A reference at + // top level (no enclosing function) has no caller node to attach to. + let src = store + .enclosing_definition_at(corpus, file, r.ref_line) + .ok() + .flatten()?; + // Section 7.5: the definition the editor pointed at. If the position lands + // in no definition span the target is outside the graph (a node_modules + // file, a generated stub, a file the indexer skipped), so abstain. + let dst = store + .enclosing_definition_at(corpus, &r.target_path, r.target_line) + .ok() + .flatten()?; + edge_if_sound(store, src, dst) +} + +/// Section 7.3a: emit for calls whose callee signature is unambiguous repo-wide. +/// +/// This lane needs no language server and is the zero-regression floor: with no +/// editor attached it is the only lane that runs. It is seeded by the +/// [`UnresolvedCall`]s the native Phase B call-site extractor already produces, +/// so the live lane detects references with the same machinery Phase B does +/// rather than inventing a second extractor. +/// +/// **It deliberately does not reuse the daemon's `resolve_unresolved_calls`.** +/// That resolver is recall-biased by design: its own contract says +/// "overconnection is safe: when multiple nodes share a signature all matches +/// are emitted. PPR damping absorbs the noise." The live lane has the opposite +/// policy (section 8.1): it is precision-first and abstains on ambiguity, +/// because an un-ratified wrong edge is exactly the failure the RFC forbids. +/// The two policies are both correct for their lane, and they must not be +/// merged. +/// +/// `lookup_nodes_exact(sig, None)` returns the candidate set; only a set of +/// size one is emitted. `alt_callee_sig` is tried when the primary misses (the +/// #709 PascalCase class-vs-function ambiguity), under the same exactly-one +/// rule, never to widen the net. +pub fn resolve_unambiguous_lexical( + store: &mut SqliteStore, + unresolved: &[UnresolvedCall], +) -> LiveOutcome { + let mut outcome = LiveOutcome::default(); + for call in unresolved { + let Some(edge) = lexical_one(store, call) else { + outcome.abstain(); + continue; + }; + if let Err(e) = store.put_edge_live(&edge) { + tracing::debug!(error = %e, "live lexical edge write failed"); + outcome.abstain(); + } else { + outcome.emit(); + } + } + if outcome.emitted > 0 { + tracing::debug!( + event = "live.lexical", + emitted = outcome.emitted, + pending = outcome.pending, + "unambiguous-lexical live resolution complete" + ); + } + outcome +} + +fn lexical_one(store: &SqliteStore, call: &UnresolvedCall) -> Option { + let dst = candidate_signatures(call) + .into_iter() + .find_map(|sig| unique_definition(store, &sig))?; + edge_if_sound(store, call.src, dst) +} + +/// The exact signatures a call could name, most specific first. +/// +/// This mirrors how the native extractor encodes a call site, which is *not* +/// simply `callee_sig`. For `u.save()` the extractor emits +/// `callee_sig = "fn:save"` with `is_method_call = true` and the receiver type +/// recovered into `recv_type`, while the definition node is `method:User.save`. +/// Looking up `callee_sig` alone therefore finds nothing. +/// +/// A method or field access **without** a recovered receiver type yields no +/// candidate at all, and so abstains. That is deliberate: an untyped receiver +/// is exactly the method-on-receiver ambiguity section 7.3a cannot settle and +/// section 7.3b's LSP lane exists for. Guessing by leaf name here is what the +/// recall-biased Phase B resolver does, and it is the wrong policy for an +/// un-ratified edge. +fn candidate_signatures(call: &UnresolvedCall) -> Vec { + let leaf = travsr_core::ident::leaf_of(&call.callee_sig); + if let Some(recv) = call.recv_type.as_deref() { + // Positive type evidence: resolve against the qualified definition. + let kind = if call.callee_sig.starts_with("field:") { + "field" + } else { + "method" + }; + return vec![format!("{kind}:{recv}.{leaf}")]; + } + if call.is_method_call || call.callee_sig.starts_with("field:") { + return Vec::new(); + } + // A free function or constructor names its definition directly. + let mut sigs = vec![call.callee_sig.clone()]; + // #709: a second exact try for the genuinely ambiguous PascalCase shape, + // never a widening of the net. + if let Some(alt) = call.alt_callee_sig.clone() { + sigs.push(alt); + } + sigs +} + +/// The single definition named by `signature`, or `None` when there are zero or +/// more than one. +/// +/// `lookup_nodes_exact(sig, None)` documents exactly this contract: one row is +/// an unambiguous match, more than one is "genuinely ambiguous; caller must +/// disambiguate". Ambiguity abstains, which is the whole precision guarantee of +/// section 7.3a. +fn unique_definition(store: &SqliteStore, signature: &str) -> Option { + let candidates = store.lookup_nodes_exact(signature, None).ok()?; + let defs: Vec<&travsr_core::Node> = candidates + .iter() + .filter(|n| is_definition(&n.kind)) + .collect(); + match defs.as_slice() { + [only] => Some(only.id), + _ => None, + } +} + +/// Shared soundness gate for both lanes. +/// +/// Section 8.2 fencing rule: live edges are intra-corpus only, so they can +/// never violate RFC-005's `src.corpus == dst.corpus` invariant or feed a +/// synthetic symbol into the bridge registry (ADR-009 Rule 4). Self-edges are +/// dropped because a symbol referencing itself carries no traversal value and +/// would show up as a spurious self-loop in `get_callers`. +fn edge_if_sound(store: &SqliteStore, src: NodeId, dst: NodeId) -> Option { + if src == dst { + return None; + } + let src_node = store.get_node(src).ok().flatten()?; + let dst_node = store.get_node(dst).ok().flatten()?; + if src_node.vname.corpus != dst_node.vname.corpus { + return None; + } + Some(Edge::new(src, dst, EdgeKind::RefCall)) +} + +/// Kinds a reference can resolve to. Mirrors the definition kinds the store's +/// own `definition_node_ids_in_file` recognises, so the two agree on what +/// counts as a definition. +fn is_definition(kind: &str) -> bool { + matches!( + kind, + "function" + | "method" + | "fn" + | "class" + | "interface" + | "struct" + | "trait" + | "enum" + | "type" + | "typedef" + | "union" + | "object" + | "protocol" + | "mixin" + | "extension" + | "namespace" + | "init" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use travsr_core::{Node, VName}; + + const CORPUS: &str = "testrepo"; + + fn store_with(nodes: &[(&str, &str, &str, u32, u32)]) -> SqliteStore { + let mut store = SqliteStore::open_in_memory().expect("in-memory store"); + for (path, sig, kind, line, end_line) in nodes { + let vname = VName::new(CORPUS, "", *path, "typescript", *sig); + let mut node = Node::new(vname, *kind); + node.line = Some(*line); + node.end_line = Some(*end_line); + store.put_node(&node).expect("put_node"); + } + store + } + + fn node_id(path: &str, sig: &str) -> NodeId { + VName::new(CORPUS, "", path, "typescript", sig).id() + } + + fn resolution( + ref_line: u32, + name: &str, + target_path: &str, + target_line: u32, + ) -> LiveResolution { + LiveResolution { + ref_line, + ref_col: 4, + name: name.to_string(), + target_path: target_path.to_string(), + target_line, + buffer_version: 1, + } + } + + fn provenance_of(store: &SqliteStore, src: NodeId, dst: NodeId) -> Option { + store + .iter_edges_from(src) + .expect("iter_edges_from") + .into_iter() + .find(|e| e.dst == dst) + .and_then(|e| e.provenance) + } + + /// The happy path (section 7.3b): an editor resolves `save()` inside + /// `placeOrder` to `User.save`, and a live edge appears between the two + /// enclosing definitions. + #[test] + fn an_editor_resolution_becomes_a_live_edge() { + let mut store = store_with(&[ + ("src/order.ts", "fn:placeOrder", "function", 10, 30), + ("src/user.ts", "method:User.save", "method", 15, 20), + ]); + let out = apply_live_resolutions( + &mut store, + CORPUS, + "src/order.ts", + &[resolution(18, "save", "src/user.ts", 17)], + ); + + assert_eq!( + out, + LiveOutcome { + emitted: 1, + pending: 0 + } + ); + assert_eq!( + provenance_of( + &store, + node_id("src/order.ts", "fn:placeOrder"), + node_id("src/user.ts", "method:User.save"), + ) + .as_deref(), + Some("live"), + "the edge must be tagged live, never blended into ratified truth" + ); + } + + /// Section 8.1: a definition position that lands outside every known span + /// (node_modules, a generated stub, an unindexed file) abstains. It must + /// not fall back to any nearest-node heuristic. + #[test] + fn a_target_outside_the_graph_abstains_instead_of_guessing() { + let mut store = store_with(&[ + ("src/order.ts", "fn:placeOrder", "function", 10, 30), + ("src/user.ts", "method:User.save", "method", 15, 20), + ]); + let out = apply_live_resolutions( + &mut store, + CORPUS, + "src/order.ts", + &[resolution(18, "save", "node_modules/lib/index.d.ts", 4)], + ); + + assert_eq!( + out, + LiveOutcome { + emitted: 0, + pending: 1 + } + ); + assert!( + store + .iter_edges_from(node_id("src/order.ts", "fn:placeOrder")) + .expect("iter_edges_from") + .is_empty(), + "abstention must write no edge at all" + ); + } + + /// A reference at top level has no enclosing definition to hang an edge + /// from, so it abstains rather than attaching to an arbitrary node. + #[test] + fn a_reference_with_no_enclosing_definition_abstains() { + let mut store = store_with(&[ + ("src/order.ts", "fn:placeOrder", "function", 10, 30), + ("src/user.ts", "method:User.save", "method", 15, 20), + ]); + // Line 2 is outside placeOrder's 10..30 span. + let out = apply_live_resolutions( + &mut store, + CORPUS, + "src/order.ts", + &[resolution(2, "save", "src/user.ts", 17)], + ); + assert_eq!( + out, + LiveOutcome { + emitted: 0, + pending: 1 + } + ); + } + + /// One unmappable resolution must not cost the others their freshness. + #[test] + fn a_bad_resolution_does_not_abort_the_batch() { + let mut store = store_with(&[ + ("src/order.ts", "fn:placeOrder", "function", 10, 30), + ("src/user.ts", "method:User.save", "method", 15, 20), + ]); + let out = apply_live_resolutions( + &mut store, + CORPUS, + "src/order.ts", + &[ + resolution(18, "nope", "src/ghost.ts", 3), + resolution(19, "save", "src/user.ts", 17), + ], + ); + assert_eq!( + out, + LiveOutcome { + emitted: 1, + pending: 1 + } + ); + } + + fn call(src: NodeId, callee_sig: &str, line: u32) -> UnresolvedCall { + UnresolvedCall { + src, + callee_sig: callee_sig.to_string(), + caller_line: line, + ..UnresolvedCall::default() + } + } + + /// Section 7.3a: a signature with exactly one definition repo-wide resolves + /// with no language server. This is the zero-regression floor. + #[test] + fn an_unambiguous_name_resolves_without_a_language_server() { + let mut store = store_with(&[ + ("src/order.ts", "fn:placeOrder", "function", 10, 30), + ("src/user.ts", "method:User.save", "method", 15, 20), + ]); + let src = node_id("src/order.ts", "fn:placeOrder"); + let out = resolve_unambiguous_lexical(&mut store, &[call(src, "method:User.save", 18)]); + + assert_eq!( + out, + LiveOutcome { + emitted: 1, + pending: 0 + } + ); + assert_eq!( + provenance_of( + &store, + node_id("src/order.ts", "fn:placeOrder"), + node_id("src/user.ts", "method:User.save"), + ) + .as_deref(), + Some("live") + ); + } + + /// Section 7.3a's precision guarantee: two definitions sharing a signature + /// is exactly the method-on-receiver case lexical matching cannot settle, + /// so it abstains rather than picking one. + /// + /// This is also where the live lane deliberately diverges from the daemon's + /// `resolve_unresolved_calls`, whose contract is to emit *all* matches and + /// let PPR damping absorb the noise. + #[test] + fn an_ambiguous_name_abstains_rather_than_picking_one() { + let mut store = store_with(&[ + ("src/order.ts", "fn:placeOrder", "function", 10, 30), + ("src/user.ts", "method:save", "method", 15, 20), + ("src/draft.ts", "method:save", "method", 5, 9), + ]); + let src = node_id("src/order.ts", "fn:placeOrder"); + let out = resolve_unambiguous_lexical(&mut store, &[call(src, "method:save", 18)]); + + assert_eq!( + out, + LiveOutcome { + emitted: 0, + pending: 1 + }, + "two candidates must abstain, never guess" + ); + assert!(store + .iter_edges_from(node_id("src/order.ts", "fn:placeOrder")) + .expect("iter_edges_from") + .is_empty()); + } + + /// `alt_callee_sig` is the #709 PascalCase class-vs-function ambiguity: the + /// extractor names both shapes and lets the resolver decide on evidence. It + /// is a second exact try, not a widening of the net, so it is still subject + /// to the exactly-one rule. + #[test] + fn the_alt_signature_is_a_second_exact_try_not_a_wider_net() { + let mut store = store_with(&[ + ("src/order.ts", "fn:placeOrder", "function", 10, 30), + ("src/field.ts", "fn:Field", "function", 3, 8), + ]); + let src = node_id("src/order.ts", "fn:placeOrder"); + let mut c = call(src, "class:Field", 18); + c.alt_callee_sig = Some("fn:Field".to_string()); + + let out = resolve_unambiguous_lexical(&mut store, &[c]); + assert_eq!( + out, + LiveOutcome { + emitted: 1, + pending: 0 + } + ); + assert_eq!( + provenance_of(&store, src, node_id("src/field.ts", "fn:Field")).as_deref(), + Some("live") + ); + } + + /// Section 8.2 fencing rule: live edges are intra-corpus only, so they can + /// never violate RFC-005's `src.corpus == dst.corpus` invariant. + #[test] + fn a_cross_corpus_target_is_fenced_off() { + let mut store = SqliteStore::open_in_memory().expect("in-memory store"); + let mut caller = Node::new( + VName::new(CORPUS, "", "src/order.ts", "typescript", "fn:placeOrder"), + "function", + ); + caller.line = Some(10); + caller.end_line = Some(30); + let mut foreign = Node::new( + VName::new( + "othercorpus", + "", + "src/user.ts", + "typescript", + "method:User.save", + ), + "method", + ); + foreign.line = Some(15); + foreign.end_line = Some(20); + store.put_node(&caller).expect("put_node"); + store.put_node(&foreign).expect("put_node"); + + // Resolving within CORPUS cannot see the foreign node's path at all, + // which is the fence doing its job one layer earlier. + let out = apply_live_resolutions( + &mut store, + CORPUS, + "src/order.ts", + &[resolution(18, "save", "src/user.ts", 17)], + ); + assert_eq!( + out, + LiveOutcome { + emitted: 0, + pending: 1 + } + ); + assert!(store + .iter_edges_from(caller.id) + .expect("iter_edges_from") + .is_empty()); + } + + /// A symbol referencing itself carries no traversal value and would surface + /// as a spurious self-loop in `get_callers`. + #[test] + fn a_self_reference_emits_no_edge() { + let mut store = store_with(&[("src/rec.ts", "fn:walk", "function", 4, 12)]); + let out = apply_live_resolutions( + &mut store, + CORPUS, + "src/rec.ts", + &[resolution(8, "walk", "src/rec.ts", 5)], + ); + assert_eq!( + out, + LiveOutcome { + emitted: 0, + pending: 1 + } + ); + } + + /// Plan R5: `reindex_replace` deletes every outbound edge of a file on each + /// save, so the engine re-emits whole-file. Re-emitting must be idempotent + /// rather than accumulating duplicates or failing. + #[test] + fn re_emitting_after_a_save_is_idempotent() { + let mut store = store_with(&[ + ("src/order.ts", "fn:placeOrder", "function", 10, 30), + ("src/user.ts", "method:User.save", "method", 15, 20), + ]); + let batch = [resolution(18, "save", "src/user.ts", 17)]; + for _ in 0..3 { + let out = apply_live_resolutions(&mut store, CORPUS, "src/order.ts", &batch); + assert_eq!( + out, + LiveOutcome { + emitted: 1, + pending: 0 + } + ); + } + assert_eq!( + store + .iter_edges_from(node_id("src/order.ts", "fn:placeOrder")) + .expect("iter_edges_from") + .len(), + 1, + "re-emitting must not accumulate duplicate edges" + ); + } +} diff --git a/crates/travsr-ipc/src/message.rs b/crates/travsr-ipc/src/message.rs index a43f3c35..322aad6d 100644 --- a/crates/travsr-ipc/src/message.rs +++ b/crates/travsr-ipc/src/message.rs @@ -108,6 +108,48 @@ pub enum ControlMessage { /// so are unknown rather than clean. undiagnosed: usize, }, + /// RFC-027: an editor reports where a reference in a dirty file actually + /// resolves to, so the daemon can close the between-commits semantic gap. + /// + /// This looks like the editor plane above but is deliberately a different + /// thing, and the difference is the whole safety argument. #688 keeps + /// editor data *out* of the graph because diagnostics are the editor's + /// claim about the code. Here the editor never makes a claim about the + /// graph: it reports a **position**, and the daemon resolves that position + /// against its own SCIP-owned identity. The editor cannot name a node, + /// cannot mint a VName, and cannot say which symbols are related. It + /// answers exactly one question a language server is authoritative for + /// ("what does the cursor at this position point at?") and the graph owns + /// everything downstream of that answer. + /// + /// The resulting edges are written with `provenance='live'` and swept when + /// commit-time SCIP ratifies the region (RFC-027 sections 8.3 and 8.4), so + /// the durable graph stays SCIP-pinned and deterministic. Non-determinism + /// is confined to the ephemeral overlay, which is why persisting these does + /// not reopen what #688 closed. + /// + /// Unlike [`ControlMessage::ReportLspDiagnostics`] there is no `ttl_secs`: + /// a live edge's lifetime is bounded by ratification, not by a lease, so a + /// TTL here would be a second expiry mechanism with no consumer. + /// + /// Daemons older than this variant answer with a parse error, which the + /// extension ignores. Losing the report costs freshness, never truth. + ReportLiveResolution { + /// Absolute workspace root this report describes. Checked against the + /// daemon's own repo for the same reason as `ReportLspDiagnostics` + /// (#698 review, P1): discovery enumerates a namespace, not a repo. + repo_root: String, + /// Stable for the lifetime of one editor window. + session: String, + /// The dirty file these references live in. Repo-relative, forward + /// slashes, matching the graph's own path keys. + file: String, + /// One entry per reference the editor was able to resolve. References + /// it could not resolve are simply absent: the live lane is fail-closed + /// (RFC-027 section 8.1), so an omission becomes a `pending` marker + /// rather than a guessed edge. + resolutions: Vec, + }, /// #688: read the editor plane (`travsr daemon lsp`). /// /// Answers from memory across all live sessions, dropping expired ones as @@ -117,6 +159,34 @@ pub enum ControlMessage { LspStatus, } +/// One resolved reference: where the call site is, and where the editor's +/// language provider says it points. +/// +/// Both ends are positions, never identities. The daemon maps them to nodes +/// itself (RFC-027 section 7.5: current Tree-sitter spans for dirty files, SCIP +/// ranges for clean ones), which is what keeps VName minting SCIP's exclusive +/// job. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct LiveResolution { + /// 1-based line of the reference in the dirty file. + pub ref_line: u32, + /// 0-based UTF-16 column, as the editor counts them. + pub ref_col: u32, + /// The referenced name, used to narrow candidates before position mapping + /// and to record an honest `pending` row when mapping fails. + pub name: String, + /// Repo-relative path the definition lives in, forward slashes. A target + /// outside the workspace is dropped by the extension rather than sent, so + /// the live lane stays intra-corpus (RFC-027 section 8.2). + pub target_path: String, + /// 1-based line of the definition the editor resolved to. + pub target_line: u32, + /// Editor buffer version this answer was computed against. The daemon drops + /// a resolution whose buffer has moved on rather than trusting a stale + /// position (RFC-027 section 11). + pub buffer_version: i64, +} + /// One file's current diagnostic state, as an editor sees it. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct FileDiagnostics { @@ -227,6 +297,52 @@ mod tests { } } + // RFC-027: same contract as the diagnostics line above. The extension + // hand-builds this in TypeScript (packages/travsr-vscode/src/daemonIpc.ts), + // so the wire tag and field names here are the contract. If this test is + // edited, that file has to change with it. + #[test] + fn report_live_resolution_wire_shape_matches_the_extension() { + let line = r#"{"op":"report-live-resolution","repo_root":"/home/alice/proj", + "session":"vscode-1-abc","file":"src/order.ts", + "resolutions":[{"ref_line":42,"ref_col":8,"name":"save", + "target_path":"src/user.ts","target_line":17,"buffer_version":9}]}"#; + match serde_json::from_str::(line).expect("extension line must parse") { + ControlMessage::ReportLiveResolution { + repo_root, + session, + file, + resolutions, + } => { + assert_eq!(repo_root, "/home/alice/proj"); + assert_eq!(session, "vscode-1-abc"); + assert_eq!(file, "src/order.ts"); + assert_eq!(resolutions.len(), 1); + let r = &resolutions[0]; + assert_eq!((r.ref_line, r.ref_col), (42, 8)); + assert_eq!(r.name, "save"); + assert_eq!(r.target_path, "src/user.ts"); + assert_eq!(r.target_line, 17); + assert_eq!(r.buffer_version, 9); + } + other => panic!("expected ReportLiveResolution, got {other:?}"), + } + } + + // An empty resolution list is meaningful: the editor looked and resolved + // nothing, which the daemon records as pending rather than as "no report". + #[test] + fn an_empty_live_resolution_report_parses() { + let line = r#"{"op":"report-live-resolution","repo_root":"/r","session":"s", + "file":"src/a.ts","resolutions":[]}"#; + match serde_json::from_str::(line).expect("empty report must parse") { + ControlMessage::ReportLiveResolution { resolutions, .. } => { + assert!(resolutions.is_empty()) + } + other => panic!("expected ReportLiveResolution, got {other:?}"), + } + } + // ttl 0 is the detach signal, so it has to survive the wire as itself // rather than being treated as "unset" and defaulted to something alive. #[test] diff --git a/crates/travsr-store/src/lib.rs b/crates/travsr-store/src/lib.rs index 2557597a..64b8a54e 100644 --- a/crates/travsr-store/src/lib.rs +++ b/crates/travsr-store/src/lib.rs @@ -3649,6 +3649,48 @@ LIMIT ?4", Ok(()) } + /// Persist an edge resolved by the RFC-027 live lane (`provenance='live'`). + /// + /// Live edges are an ephemeral overlay over the commit-gated semantic graph: + /// Tree-sitter detected the reference, the live engine resolved it precisely + /// (unambiguous-lexical or LSP-disambiguated), and commit-time SCIP will + /// ratify and replace it. They sit on the middle rung of the ADR-002 + /// precedence ladder: + /// + /// ```text + /// lsif / scip (compiler-grade, durable) beats + /// live (this lane, ephemeral) beats + /// tree-sitter (heuristic) + /// ``` + /// + /// The `WHERE` on the upsert enforces the ladder from below: an existing + /// `lsif`, `scip`, or `bridge:*` row is left untouched, so a live edge can + /// never demote ratified truth. Re-emitting over an existing `live` row is + /// allowed and idempotent, which matters because `reindex_replace` deletes + /// every outbound edge of a file on each save, so the live engine re-emits + /// for the whole file rather than only for references it thinks are new. + /// + /// This never mints identity (RFC-027 section 8.2): both endpoints must + /// already exist as nodes, so the fencing rule and VName uniqueness hold. + pub fn put_edge_live(&mut self, edge: &Edge) -> Result<(), StoreError> { + self.conn + .execute( + "INSERT INTO edges(src, dst, kind, provenance, confidence) VALUES(?1, ?2, ?3, 'live', ?4) \ + ON CONFLICT(src, dst, kind) DO UPDATE SET \ + provenance = 'live', confidence = excluded.confidence \ + WHERE edges.provenance IN ('tree-sitter', 'live')", + params![ + node_id_to_i64(edge.src), + node_id_to_i64(edge.dst), + edge.kind.as_str(), + edge.confidence.map(|c| c as i64), + ], + ) + .context("inserting live edge") + .map_err(|e| StoreError::Database(e.to_string()))?; + Ok(()) + } + /// PR #715: batch-check which of `ids` already exist in `nodes`, one query per /// chunk rather than a `SELECT 1` per id, for the Phase B half-edge guard. /// @@ -3790,6 +3832,15 @@ LIMIT ?4", // ('lsif'/'scip') already on the row is never demoted by a later // write (ADR-002), so a heuristic 'tree-sitter' write cannot // overwrite it. + // + // RFC-027: the ELSE arm demoting a 'live' row to 'tree-sitter' + // is deliberate, not a leak. Both callers that pass a + // 'tree-sitter' provenance (`init_repo_with_progress` and + // `run_background_phase_b_inner`) are Phase B runs, so reaching + // here means Phase B just re-derived this edge and it is no + // longer a live guess. Demotion IS ratification for a + // co-located edge, and it is what leaves the section 8.3 sweep + // holding only the live edges Phase B did not re-derive. tx.execute( "INSERT INTO edges(src, dst, kind, provenance, confidence) \ VALUES(?1, ?2, ?3, ?4, ?5) \ @@ -4604,6 +4655,52 @@ LIMIT ?4", Ok(ids.into_iter().map(i64_to_node_id).collect()) } + /// RFC-027 section 7.5: map a `(path, line)` position to the graph node + /// that owns it, returning the **narrowest** enclosing definition. + /// + /// This is the `location_to_node` primitive the live lane needs at both + /// ends: the call site's enclosing function becomes the edge's `src`, and + /// the definition the editor pointed at becomes its `dst`. + /// + /// The RFC describes this as range-source-aware (current Tree-sitter spans + /// for dirty files, SCIP ranges for clean ones). In this store one lookup + /// covers both, because a node's `line`/`end_line` always come from the most + /// recent parse of its file and Phase A re-parses a file on save before the + /// live engine runs. A position that lands in no definition span returns + /// `None`, which the caller must treat as an abstention rather than + /// guessing: fail-closed is the whole precision argument (section 8.1). + /// + /// Ordering matches [`find_narrowest_enclosing`]: widest-last, so the first + /// containing span is the tightest one. + pub fn enclosing_definition_at( + &self, + corpus: &str, + path: &str, + line: u32, + ) -> Result, StoreError> { + (|| -> AnyResult> { + let mut stmt = self + .conn + .prepare_cached( + "SELECT id FROM nodes \ + WHERE corpus = ?1 AND path = ?2 \ + AND line IS NOT NULL AND end_line IS NOT NULL \ + AND line <= ?3 AND end_line >= ?3 \ + AND kind IN ('function','method','fn','class','interface','struct', \ + 'trait','enum','type','typedef','union','object', \ + 'protocol','mixin','extension','namespace','init') \ + ORDER BY (end_line - line) ASC, id ASC LIMIT 1", + ) + .context("enclosing_definition_at: prepare")?; + let id: Option = stmt + .query_row(params![corpus, path, line as i64], |row| row.get(0)) + .optional() + .context("enclosing_definition_at: query")?; + Ok(id.map(i64_to_node_id)) + })() + .map_err(|e| StoreError::Database(e.to_string())) + } + /// G1: Look up the unified `NodeId` for a raw SCIP symbol string. /// /// Returns `None` if the symbol has not been aliased (i.e. no tree-sitter @@ -9434,6 +9531,100 @@ mod tests { assert_eq!(prov(&by_kind, EdgeKind::RefCall), "lsif"); } + /// RFC-027 Phase 0: the `live` provenance rung sits between `tree-sitter` + /// and `lsif`/`scip`. A live edge upgrades a heuristic row and is idempotent + /// over itself, but must never demote compiler-grade truth. + #[test] + fn live_edge_beats_tree_sitter_and_loses_to_compiler_provenance() { + let mut store = SqliteStore::open_in_memory().unwrap(); + let a = node_with_path("a.ts", "fn:a"); + let b = node_with_path("b.ts", "fn:b"); + store.put_node(&a).unwrap(); + store.put_node(&b).unwrap(); + let e = Edge::new(a.id, b.id, EdgeKind::RefCall); + + let prov = |st: &SqliteStore| -> String { + st.iter_edges_from(a.id).unwrap()[0] + .provenance + .clone() + .expect("reader must carry provenance") + }; + + // tree-sitter -> live: upgrade. + store.put_edge(&e).unwrap(); + assert_eq!(prov(&store), "tree-sitter"); + store.put_edge_live(&e).unwrap(); + assert_eq!(prov(&store), "live", "live must upgrade a tree-sitter row"); + + // live -> live: idempotent. reindex_replace wipes a file's outbound + // edges on every save, so the engine re-emits whole-file (plan R5). + store.put_edge_live(&e).unwrap(); + assert_eq!(prov(&store), "live"); + + // live -> tree-sitter: must NOT demote. + store.put_edge(&e).unwrap(); + assert_eq!( + prov(&store), + "live", + "a tree-sitter write must not demote a live row" + ); + + // live -> lsif: ratification wins. + store.put_edge_lsif(&e).unwrap(); + assert_eq!(prov(&store), "lsif", "lsif must overwrite live"); + + // lsif -> live: must NOT demote ratified truth. + store.put_edge_live(&e).unwrap(); + assert_eq!( + prov(&store), + "lsif", + "a live write must never demote lsif/scip" + ); + } + + /// RFC-027: a Phase B write over a `live` row ratifies it. + /// + /// Both callers that pass a 'tree-sitter' provenance are Phase B runs + /// (`init_repo_with_progress`, `run_background_phase_b_inner`), so such a + /// write reaching an existing `live` row means Phase B just re-derived that + /// edge by native leaf-name resolution. Relabelling it is correct: it is no + /// longer a live guess. This is what leaves the section 8.3 sweep holding + /// only the live edges Phase B did *not* re-derive, so deleting those + /// cannot lose a real edge. + #[test] + fn write_phase_b_batch_ratifies_a_live_edge() { + let mut store = SqliteStore::open_in_memory().unwrap(); + let a = node_with_path("a.ts", "fn:a"); + let b = node_with_path("b.ts", "fn:b"); + store.put_node(&a).unwrap(); + store.put_node(&b).unwrap(); + let e = Edge::new(a.id, b.id, EdgeKind::RefCall); + store.put_edge_live(&e).unwrap(); + + store + .write_phase_b_batch(&[], std::slice::from_ref(&e), "tree-sitter") + .unwrap(); + assert_eq!( + store.iter_edges_from(a.id).unwrap()[0] + .provenance + .as_deref(), + Some("tree-sitter"), + "Phase B re-derived this edge natively, so it is ratified, not live" + ); + + // And a SCIP-provenance batch ratifies it as compiler truth. + store + .write_phase_b_batch(&[], std::slice::from_ref(&e), "scip") + .unwrap(); + assert_eq!( + store.iter_edges_from(a.id).unwrap()[0] + .provenance + .as_deref(), + Some("scip"), + "scip must overwrite live" + ); + } + /// Provenance is metadata about how an edge was derived, not part of its /// identity: the store's primary key is `(src, dst, kind)`. A read-back edge /// must therefore still compare equal to the constructed one it came from. diff --git a/docs/rfcs/RFC-027-live-semantic-resolution.md b/docs/rfcs/RFC-027-live-semantic-resolution.md index 777988d6..41d0b0df 100644 --- a/docs/rfcs/RFC-027-live-semantic-resolution.md +++ b/docs/rfcs/RFC-027-live-semantic-resolution.md @@ -238,12 +238,18 @@ Live edges are **intra-corpus only**. They: This preserves RFC-005's `src.corpus == dst.corpus` invariant and Principal Architect Invariant #1 (VName uniqueness): the live lane cannot synthesize a colliding identity because it never synthesizes identity at all. ### 8.3 Convergence (Principal Architect Invariant #4) -At commit, the ratification pass runs incremental SCIP over `changed_set ∪ reverse_closure`, then, **in a single WAL transaction**: +At commit, ratification rides the **whole-project** Phase B run. (The RFC originally specified incremental SCIP over `changed_set ∪ reverse_closure`; that machinery does not exist — `invoke_phase_b_all` runs over full-project inputs and file-level delta is unbuilt, `DEBT(travsr-25)`. Region-scoped ratification is a future optimization gated on that debt.) The pass then: 1. inserts the SCIP-derived edges (which dominate any co-located `live` edge by ADR-002 precedence — the existing `ON CONFLICT(src,dst,kind) DO UPDATE` upsert in `travsr-store/src/lib.rs` already lets `lsif`/`scip` win), 2. deletes the leftover `provenance = "live"` edges in that region that SCIP did not overwrite, 3. clears `pending` reference markers SCIP resolved; GCs ghost nodes/edges from deletions. -**Ordering rationale (delete-old / insert-new hazard).** A naive "delete all `live` edges, then insert SCIP" sequence opens a transient-gap window: a concurrent query landing between the two steps would see *neither* the live edge (already deleted) nor the SCIP edge (not yet inserted), momentarily reporting a missing edge on ratified code. Insert-then-delete inside one transaction closes that window — every reader sees either the pre-commit overlay or the fully-ratified graph, never a torn intermediate. This directly answers review-ask #1: the hazard is real but is dissolved by ordering + transaction atomicity, not by a change to the convergence property. +**Ordering rationale (delete-old / insert-new hazard).** A naive "delete all `live` edges, then insert SCIP" sequence opens a transient-gap window: a concurrent query landing between the two steps would see *neither* the live edge (already deleted) nor the SCIP edge (not yet inserted), momentarily reporting a missing edge on ratified code. Insert-then-delete closes that window: the only observable intermediate is a **superset** of the ratified graph, never a gap. + +**Atomicity, corrected (confirmed in implementation).** These steps do **not** run in one WAL transaction, and cannot without restructuring the Phase B write path. That path is already a sequence of independently-committing statements — `put_edge_lsif` per LSIF edge, `write_phase_b_batch`, `write_scip_attributed_batch`, a second `write_phase_b_batch` for natively-resolved edges, `record_edge_sites`, `reconcile_edge_languages` — all under one *process-local* store mutex, which is not a transaction. The realizable guarantee is **ordering + store-lock exclusion + marker-gated visibility**: the sweep runs while that mutex is still held, after the last ratification write and before the `phase_b_commit` marker advances. In-process readers share the mutex and never observe an intermediate; a separate-process reader can, and sees only the harmless superset above. This answers review-ask #1: the hazard is real, and is dissolved by ordering, not by transaction atomicity. + +**Most live edges never reach the sweep.** A live edge that Phase B re-derives is ratified *in place*: the ratification write upserts the same `(src, dst, kind)` row and relabels its provenance (`lsif`/`scip`, or `tree-sitter` when the edge came from native leaf-name resolution, which is itself only ever written on a Phase B path). By the time the sweep runs, the rows still marked `live` are exactly those Phase B did **not** re-derive, so deleting them cannot lose a real edge. This is why the ratification writes must not be prevented from overwriting a `live` row. + +**The sweep is language-scoped, not blanket.** `made_progress` advances the `phase_b_commit` marker whenever *any* language produced results, even when another language's sidecar crashed (#712). A blanket `DELETE FROM edges WHERE provenance='live'` would therefore discard live edges for a language whose SCIP truth was never re-derived in that run. The sweep is restricted to the languages that completed, keyed on the src node's language. Live edges for a crashed language survive, still labeled `live`, which is honest. Invariant #4 is unaffected: a clean run has nothing crashed, so the scoped sweep is total, and that is the case the convergence property below asserts. **Property (must hold, property-tested):** ``` @@ -254,6 +260,14 @@ Because every live edge is deleted and replaced by SCIP at commit, the *committe ### 8.4 Determinism fence Live resolution depends on the installed server version and is therefore non-deterministic across environments. This is **fenced**: non-determinism exists only in the ephemeral overlay, between commits. The durable (committed) graph is SCIP-pinned and deterministic. The overlay was never part of the deterministic ground-truth contract, so the fence is sound. Live edges MUST be visibly distinguishable at query time (§10) so no consumer mistakes the overlay for ratified truth. +**Reconciliation with the #688 editor plane.** The daemon already has an editor plane (`ControlMessage::ReportLspDiagnostics`) whose written contract is that editor-derived data is volatile and *"never enters the graph … can never be an edge or a node."* Persisting `live` edges appears to cross that line. It does not, and the distinction is what makes this sound: + +- **#688 carries the editor's claim about the code.** A diagnostic is the editor's own judgement, it has no counterpart in the repository, and nothing later replaces it with a derived truth. Admitting it to the graph would make the graph unreproducible with no path back. +- **RFC-027 carries a position, not a claim.** The editor answers only "what does the cursor at this position point at" — the one question a language server is authoritative for. It never names a node, never mints a VName, and never asserts a relationship. The daemon maps both endpoints to nodes itself against SCIP-owned identity (§7.5, §8.2), so the graph owns everything downstream of that answer. +- **The result is bounded by ratification, not by trust.** Every live edge is either relabelled by a Phase B write or swept (§8.3), so the non-determinism has a guaranteed end. A diagnostic has no such terminating event, which is exactly why it stays in its own plane. + +The two rules are therefore the same rule: *nothing whose non-determinism has no terminating event may enter the graph.* #688 has none, so it stays out; a live edge's terminating event is the next Phase B run. + --- ## 9. Data Model & Schema @@ -271,7 +285,11 @@ Add one value to the edge provenance enum: Note: this table reflects the **de-facto** enum in the shipped store (`tree-sitter` / `lsif` / `scip`, plus `bridge:` from ADR-009), which already diverged from ADR-002's originally written value list (`tree-sitter` / `lsif` / `merged`, where `merged` was reserved-for-future and `scip` was added later). `live` extends that de-facto set; it does not reinstate `merged`. -`live` edges are the only provenance the commit ratifier is permitted to bulk-delete-and-replace. A schema migration adds the enum value; no column changes. +`live` edges are the only provenance the commit ratifier is permitted to bulk-delete-and-replace. + +**No schema migration is required** (confirmed in implementation). `edges.provenance` is an unconstrained `TEXT NOT NULL DEFAULT 'tree-sitter'` column with no `CHECK` (`travsr-store/src/migrations/v2_edge_provenance.sql`), so `'live'` is a code change at the precedence-bearing insert sites, not a migration. Only the `ref_resolution_state` table (§9.2) needs one. + +**Precedence in code.** `put_edge_live` upserts with `WHERE edges.provenance IN ('tree-sitter','live')`, so a live edge upgrades a heuristic row, is idempotent over itself, and can never overwrite `lsif`/`scip`/`bridge:*`. Re-emission has to be idempotent because `reindex_replace` deletes every outbound edge of a file on each save, so the engine re-emits for the whole file rather than only for references it believes are new. ### 9.2 Reference resolution state References detected by Tree-sitter but not yet edge-resolved carry a `ref_resolution_state` (named to avoid collision with the daemon's existing `record_dart_resolution_state` bookkeeping in `travsr-daemon/src/lib.rs`, which tracks Dart Phase B availability and is an unrelated concept): @@ -286,7 +304,7 @@ References detected by Tree-sitter but not yet edge-resolved carry a `ref_resolu The live overlay must be *legible* to consumers, never silently blended into ratified truth: -- `get_callers`, `find_references`, `get_blast_radius`, `get_graph_json` gain an optional per-edge `provenance` field already present internally; `live` edges are labeled and MAY be filtered via an existing `provenance` filter. +- `get_callers`, `find_references`, `get_blast_radius`, `get_graph_json` gain an optional per-edge `provenance` field, and `live` edges are labeled. Two corrections from implementation: per-edge provenance was **not** "already present" at the MCP surface (`DEBT(travsr-75)` — core `Edge` carried no provenance field, so every BFS-traversed edge reported `tree-sitter` regardless of its stored row), and there is **no** existing `provenance` filter (only `kind_filter`). Threading provenance through `Edge` and the store readers is a prerequisite, landed separately; the filter argument is a small additive schema edit. - Responses that include a dirty region append a freshness note in the `` envelope: `live_overlay: { dirty_files: N, pending_refs: M }`, so an agent knows the answer includes un-ratified edges and where the gaps are. - **No new MCP tool.** This is a data-quality/provenance annotation on existing tools, not a new contract. MCP-as-only-interface (Invariant #6) is unaffected. - Default behavior is additive: consumers that ignore provenance see a *fresher* graph; consumers that require ground truth filter to `provenance != live`. From a1be08d230ac002e1cb7e0c15b86809ac7599aac Mon Sep 17 00:00:00 2001 From: Abhishek Date: Fri, 28 Aug 2026 04:18:28 +0530 Subject: [PATCH 05/23] [travsr-vscode] RFC-027 Phase 0: definition-provider piggyback On save, ask the language provider the developer is already running where this file's call sites resolve, and report the positions to the daemon so it can close the between-commits semantic gap. No server is spawned and none is bundled. executeDefinitionProvider routes to whatever extension owns the language, which is a process the developer already trusts and already pays for, so this needs no new trust decision. The report carries positions, never identities. It never names a graph node, never mints a VName and never asserts a relationship. That is the line between this and the #688 editor plane, where the editor's own claim is the payload and so must stay out of the graph. Call sites are found with a deliberately simple identifier-followed-by-paren scan. There is no parser in the extension host, and there does not need to be: the daemon is fail-closed, so a position that is not really a call, or that resolves outside the graph, becomes a pending marker rather than an edge. A sloppy candidate costs one wasted provider call and some recall, never correctness. Precision is enforced where the graph is. Bounded and best-effort throughout: capped at 200 queries per file, the batch is dropped if the buffer version moves while resolving, targets outside the workspace are dropped client-side to keep the lane intra-corpus, and every failure path is silent. A freshness improvement is never worth a word of the user's attention. Also generalizes the #698 P1 test. It counted one hardcoded op against every repo_root, so adding a message type broke the count while satisfying the invariant. It now counts every op this client can send, covering three instead of two and catching the next one automatically. Gate: npm test, 426 passing, 0 failing. The wire-shape test asserts the same field names the daemon's serde contract test does, since neither side shares a serializer. --- packages/travsr-vscode/src/daemonIpc.ts | 40 ++++ packages/travsr-vscode/src/extension.ts | 20 ++ packages/travsr-vscode/src/liveResolution.ts | 192 ++++++++++++++++++ .../src/test/suite/daemonIpc.test.ts | 74 ++++++- 4 files changed, 322 insertions(+), 4 deletions(-) create mode 100644 packages/travsr-vscode/src/liveResolution.ts diff --git a/packages/travsr-vscode/src/daemonIpc.ts b/packages/travsr-vscode/src/daemonIpc.ts index bbc0b37f..313f0935 100644 --- a/packages/travsr-vscode/src/daemonIpc.ts +++ b/packages/travsr-vscode/src/daemonIpc.ts @@ -186,6 +186,46 @@ export async function reportLspDiagnostics( }); } +/** One reference an editor's language provider resolved (RFC-027). */ +export interface LiveResolutionItem { + /** 1-based line of the reference in the dirty file. */ + ref_line: number; + /** 0-based UTF-16 column, as VS Code counts them. */ + ref_col: number; + /** The referenced name, for the daemon's pending bookkeeping. */ + name: string; + /** Repo-relative path of the definition, forward slashes. */ + target_path: string; + /** 1-based line of the definition. */ + target_line: number; + /** Document version this answer was computed against. */ + buffer_version: number; +} + +/** + * Publish where references in a dirty file actually resolve (RFC-027). + * + * Unlike `reportLspDiagnostics` this carries no lease: a live edge's lifetime + * is bounded by commit-time ratification in the daemon, not by a TTL here. + * + * Same fire-and-forget contract as everything else in this file. Losing a + * report costs freshness, never truth: the daemon abstains rather than + * guessing, and the commit-gated path resolves the same references anyway. + */ +export async function reportLiveResolution( + repoRoot: string, + file: string, + resolutions: LiveResolutionItem[] +): Promise { + return send(repoRoot, { + op: "report-live-resolution", + repo_root: repoRoot, + session: SESSION_ID, + file, + resolutions, + }); +} + /** * Drop this window's view now, rather than leaving it to expire. * diff --git a/packages/travsr-vscode/src/extension.ts b/packages/travsr-vscode/src/extension.ts index 295de0f7..d7c490da 100644 --- a/packages/travsr-vscode/src/extension.ts +++ b/packages/travsr-vscode/src/extension.ts @@ -15,6 +15,7 @@ import { BLAST_RADIUS_SELECTOR, } from "./codelens"; import { CallersHoverProvider, HOVER_SELECTOR } from "./hover"; +import { publishLiveResolutions } from "./liveResolution"; import { TravsrTreeDataProvider } from "./tree"; import { TravsrRepoFileTreeProvider } from "./repoFileTree"; import { showWelcome, showWelcomeIfFirstRun } from "./welcome"; @@ -534,6 +535,25 @@ export function activate(context: vscode.ExtensionContext): void { }) ); + // RFC-027: live semantic resolution. On save, ask the language provider the + // developer is already running where this file's call sites resolve, and + // report the positions to the daemon so it can close the between-commits + // semantic gap. No server is spawned (section 7.6). + // + // Fire-and-forget and fully optional: the daemon resolves unambiguous callees + // on its own without any of this, and abstains rather than guessing on + // anything it cannot map. Losing these reports costs freshness, never truth, + // so nothing here is awaited or surfaced. + context.subscriptions.push( + vscode.workspace.onDidSaveTextDocument((doc) => { + // No folder open means no repo to attribute the file to. + if (!workspaceRoot) return; + void publishLiveResolutions(workspaceRoot, doc).catch(() => { + // Never surfaced: see the note above. + }); + }) + ); + // Re-index command — also reachable from the status Quick Pick. Lives here // (not commands.ts) because it needs the output channel + workspace root. context.subscriptions.push( diff --git a/packages/travsr-vscode/src/liveResolution.ts b/packages/travsr-vscode/src/liveResolution.ts new file mode 100644 index 00000000..c5df49fb --- /dev/null +++ b/packages/travsr-vscode/src/liveResolution.ts @@ -0,0 +1,192 @@ +/** + * RFC-027 live semantic resolution — the editor half. + * + * Phase B (SCIP) is commit-gated, so between commits the graph knows *that* + * `user.save()` is a call but not *which* `save`. The daemon closes most of + * that gap on its own (an unambiguous callee needs no help), but a call on a + * typed receiver is exactly what lexical matching cannot settle. That is the + * one question a language server is authoritative for, so we ask the one the + * developer is already running. + * + * ## What this does and does not do + * + * It calls `vscode.executeDefinitionProvider`, which routes to whatever + * extension owns the language. **No server is spawned and none is bundled** — + * this reuses a process the developer already trusts and already pays for + * (RFC-027 section 7.6), which is why it costs nothing and needs no new trust + * decision. + * + * It reports **positions**, never identities. It never names a graph node, + * never mints a VName, and never asserts a relationship. The daemon maps both + * endpoints to nodes itself against SCIP-owned identity. This is the line that + * separates it from the #688 editor plane, where the editor's own *claim* + * (a diagnostic) is what is being reported and so must stay out of the graph. + * + * ## Why a rough candidate scan is safe + * + * We do not have a parser here, so call sites are found with a deliberately + * simple scan for `identifier(`. That is imprecise, and it does not need to be + * precise: the daemon is fail-closed. A position that is not really a call, or + * that resolves outside the graph, produces a `pending` marker, never an edge. + * A sloppy candidate therefore costs one wasted provider call and some recall + * — never correctness. Precision is enforced where the graph is, not here. + * + * Everything is bounded and best-effort: a capped number of queries per file, + * a stale-buffer check before reporting, and every failure path is silent. A + * freshness improvement is never worth a word of the user's attention. + */ + +import * as vscode from "vscode"; + +import { LiveResolutionItem, reportLiveResolution } from "./daemonIpc"; + +/** + * Cap on definition queries per file. + * + * Each is an IPC round trip into another extension host. A hand-written source + * file is far below this; a generated one can hold thousands, and resolving all + * of them would cost more than the freshness is worth. Truncation degrades + * recall, which the commit-gated path then repairs. + */ +const MAX_QUERIES_PER_FILE = 200; + +/** Languages whose Phase B call sites the daemon can currently ratify. */ +const SUPPORTED_LANGUAGES = new Set([ + "typescript", + "typescriptreact", + "javascript", + "javascriptreact", +]); + +/** + * Identifier immediately followed by `(`. + * + * Deliberately crude (see the module note): it matches inside strings and + * comments, and it misses calls split across lines. Both are recall costs the + * daemon absorbs safely. + */ +const CALL_SITE = /\b([A-Za-z_$][\w$]*)\s*\(/g; + +/** Keywords that look like calls but never are. */ +const NOT_CALLS = new Set([ + "if", + "for", + "while", + "switch", + "catch", + "return", + "function", + "typeof", + "await", + "super", + "constructor", +]); + +/** Repo-relative, forward-slash path, matching the graph's own path keys. */ +function repoRelative(repoRoot: string, uri: vscode.Uri): string | null { + const full = uri.fsPath; + if (!full.startsWith(repoRoot)) return null; + return full.slice(repoRoot.length).replace(/^[/\\]/, "").replace(/\\/g, "/"); +} + +/** Candidate call-site positions in `doc`, capped. */ +function callSites(doc: vscode.TextDocument): vscode.Position[] { + const out: vscode.Position[] = []; + const text = doc.getText(); + CALL_SITE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = CALL_SITE.exec(text)) !== null) { + if (out.length >= MAX_QUERIES_PER_FILE) break; + if (NOT_CALLS.has(m[1])) continue; + out.push(doc.positionAt(m.index)); + } + return out; +} + +/** + * Ask the running language provider where each call site resolves, and publish + * the answers to the daemon. + * + * Returns the number of resolutions reported, for tests and logging. Resolves + * to 0 on every uninteresting path (unsupported language, file outside the + * repo, no provider, nothing resolved) and never rejects. + */ +export async function publishLiveResolutions( + repoRoot: string, + doc: vscode.TextDocument +): Promise { + if (!SUPPORTED_LANGUAGES.has(doc.languageId)) return 0; + const file = repoRelative(repoRoot, doc.uri); + if (!file) return 0; + + const version = doc.version; + const sites = callSites(doc); + if (sites.length === 0) return 0; + + const resolutions: LiveResolutionItem[] = []; + for (const pos of sites) { + // The buffer moved while we were asking. Answers computed against the old + // text no longer describe this file, so drop the whole batch rather than + // report positions that have shifted (RFC-027 section 11). + if (doc.version !== version) return 0; + + let locations: unknown; + try { + locations = await vscode.commands.executeCommand( + "vscode.executeDefinitionProvider", + doc.uri, + pos + ); + } catch { + continue; // no provider, or it threw. Neither is worth reporting. + } + + const target = firstLocation(locations); + if (!target) continue; + const targetPath = repoRelative(repoRoot, target.uri); + // Outside the workspace (node_modules, a .d.ts in the SDK) — dropped here + // so the live lane stays intra-corpus (RFC-027 section 8.2). The daemon + // would abstain anyway; not sending it saves the round trip. + if (!targetPath) continue; + + const word = doc.getWordRangeAtPosition(pos); + resolutions.push({ + ref_line: pos.line + 1, + ref_col: pos.character, + name: word ? doc.getText(word) : "", + target_path: targetPath, + target_line: target.range.start.line + 1, + buffer_version: version, + }); + } + + if (resolutions.length === 0) return 0; + if (doc.version !== version) return 0; + await reportLiveResolution(repoRoot, file, resolutions); + return resolutions.length; +} + +/** + * Normalize what a definition provider returned. + * + * Providers may answer with `Location[]`, `LocationLink[]`, or a bare + * `Location`, and a single call can resolve to several definitions (an + * overload set, a merged declaration). We take the first: reporting several + * targets for one position would ask the daemon to pick, which is precisely + * the guess the fail-closed contract forbids. + */ +function firstLocation( + raw: unknown +): { uri: vscode.Uri; range: vscode.Range } | null { + const list = Array.isArray(raw) ? raw : raw ? [raw] : []; + if (list.length === 0) return null; + const first = list[0] as Partial & + Partial; + if (first.uri && first.range) { + return { uri: first.uri, range: first.range as vscode.Range }; + } + if (first.targetUri && first.targetRange) { + return { uri: first.targetUri, range: first.targetRange }; + } + return null; +} diff --git a/packages/travsr-vscode/src/test/suite/daemonIpc.test.ts b/packages/travsr-vscode/src/test/suite/daemonIpc.test.ts index 6978dd1e..a42aad5f 100644 --- a/packages/travsr-vscode/src/test/suite/daemonIpc.test.ts +++ b/packages/travsr-vscode/src/test/suite/daemonIpc.test.ts @@ -15,6 +15,7 @@ import * as path from "path"; import { candidateSocketPaths, detachSession, + reportLiveResolution, reportLspDiagnostics, SESSION_ID, } from "../../daemonIpc"; @@ -194,14 +195,17 @@ suite("daemonIpc: #698 review P1", () => { path.join(__dirname, "..", "..", "daemonIpc.js"), "utf8" ); - const reports = src.split('op: "report-lsp-diagnostics"').length - 1; + // Counts every `op:` this client can send, not one hardcoded op, so a new + // message type is covered the day it is added rather than silently + // exempted. RFC-027's report-live-resolution is why this generalized. + const ops = src.split(/\bop:\s*"/).length - 1; const roots = src.split("repo_root:").length - 1; - assert.ok(reports >= 2, "both report and detach send this op"); + assert.ok(ops >= 3, `expected report, detach and live-resolution ops, saw ${ops}`); assert.strictEqual( roots, - reports, - "every send of that op must name its repo, detach included" + ops, + "every send must name its repo, detach and live resolution included" ); }); @@ -223,3 +227,65 @@ suite("daemonIpc: #698 review P1", () => { ); }); }); + +suite("daemonIpc: live resolution (RFC-027)", function () { + // Real sockets, so a hang shows up as a failure rather than as a pass. + this.timeout(10_000); + + const RESOLUTIONS = [ + { + ref_line: 42, + ref_col: 8, + name: "save", + target_path: "src/user.ts", + target_line: 17, + buffer_version: 9, + }, + ]; + + // The daemon parses this line with a hand-written serde contract test + // (travsr-ipc/src/message.rs). Neither side shares a serializer, so the wire + // tag and field names are the contract and both tests must agree. + test("sends the wire shape the daemon parses", async () => { + if (process.platform === "win32") return; + const root = tempRepo(); + const sockPath = path.join(root, ".travsr", "daemon-live-res.sock"); + + const received: string[] = []; + const server = net.createServer((c) => { + c.on("data", (b) => received.push(b.toString("utf8"))); + }); + await new Promise((r) => server.listen(sockPath, r)); + + try { + const ok = await reportLiveResolution(root, "src/order.ts", RESOLUTIONS); + assert.strictEqual(ok, true, "a listening daemon must accept the report"); + await new Promise((r) => setTimeout(r, 100)); + + const line = received.join(""); + assert.ok(line.endsWith("\n"), "control protocol is line-delimited"); + const parsed = JSON.parse(line); + assert.strictEqual(parsed.op, "report-live-resolution"); + assert.strictEqual(parsed.repo_root, root, "the daemon drops a report for another repo"); + assert.strictEqual(parsed.session, SESSION_ID); + assert.strictEqual(parsed.file, "src/order.ts"); + assert.deepStrictEqual(parsed.resolutions, RESOLUTIONS); + // A live edge's lifetime is bounded by commit ratification, not a lease, + // so a ttl here would be a second expiry mechanism with no consumer. + assert.strictEqual(parsed.ttl_secs, undefined, "live reports carry no lease"); + } finally { + server.close(); + } + }); + + // The property that matters is "never rejects". The boolean is deliberately + // not asserted: discovery enumerates a per-user namespace, so an unrelated + // daemon on the same machine can accept the bytes (and then drop the report + // by repo), which would make an assertion on it pass or fail with the + // developer's environment rather than with the code. + test("a missing daemon is silent, never a throw", async () => { + const root = tempRepo(); + const ok = await reportLiveResolution(root, "src/order.ts", RESOLUTIONS); + assert.strictEqual(typeof ok, "boolean", "must resolve, never reject"); + }); +}); From 211b15de55ad69ff7dacf1005a728d7e3f92261c Mon Sep 17 00:00:00 2001 From: Abhishek Date: Fri, 28 Aug 2026 04:23:24 +0530 Subject: [PATCH 06/23] [travsr-daemon] Run the live overlay on save only, not on commit The RFC-027 live pass was called from inside reindex_files, which is the shared save-and-commit path, so it also fired on every git commit. That is waste in the worst place. The commit hook arms a Phase B refresh immediately afterwards, which re-derives the same edges and ratifies them, so the live edges written on that path are churn. Worse, it put a second tree-sitter parse of every changed TypeScript file directly into git commit's latency, which is the exact cost the Phase A parse timeout exists to bound. Moves the call out to handle_watch_event, so the save-versus-commit policy is visible at the call site rather than buried in a shared helper. No signature change to reindex_files, so no churn across its other callers. Verified: a save still produces the live edges (both callers, including the new uncommitted one, tagged provenance="live"); the commit path no longer runs it. Gate: cargo test -p travsr-daemon, 5 suites, 177 passed, 0 failed. --- crates/travsr-daemon/src/lib.rs | 55 +++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/crates/travsr-daemon/src/lib.rs b/crates/travsr-daemon/src/lib.rs index 9cfd74eb..f26e7b11 100644 --- a/crates/travsr-daemon/src/lib.rs +++ b/crates/travsr-daemon/src/lib.rs @@ -3631,13 +3631,29 @@ fn run_with_phase_b_finish_guard( /// Only languages with a native Phase B call-site extractor participate; every /// other language keeps today's commit-gated behavior exactly, which is the /// RFC's zero-regression floor. -fn live_resolve_file( - store: &mut SqliteStore, - corpus: &str, - repo_root: &Path, - abs_path: &Path, - vname_path: &str, -) { +/// +/// **Called from the save path only, never from the commit path.** It lives +/// beside `reindex_files` rather than inside it because `reindex_files` is +/// shared by the watcher and the commit hook, and on commit this work is pure +/// waste: the hook arms a Phase B refresh immediately afterwards, which +/// re-derives the same edges and ratifies them. Doing it there would buy +/// nothing and would put a second tree-sitter parse per changed file directly +/// in `git commit`'s latency, which the parse timeout in the Phase A parsers +/// exists to protect. +/// +/// It re-resolves the **whole** file, not only references that look new, +/// because `reindex_replace` has just deleted every outbound edge the file +/// owned. `put_edge_live` upserts, so re-emitting is idempotent. +/// +/// Fail-open: a live edge is a freshness gain, never a correctness dependency, +/// so every error here is logged at debug and dropped. +fn live_resolve_file(store: &mut SqliteStore, corpus: &str, repo_root: &Path, abs_path: &Path) { + let vname_path = abs_path + .strip_prefix(repo_root) + .unwrap_or(abs_path) + .to_string_lossy() + .replace('\\', "/"); + let vname_path = vname_path.as_str(); let ext = abs_path.extension().and_then(|e| e.to_str()).unwrap_or(""); // `Language::TypeScript` covers .ts/.tsx/.js/.jsx — there is no separate // JavaScript variant, and the native extractor handles both. @@ -4155,21 +4171,6 @@ pub fn reindex_files( callers_all.extend(report.callers); } any_changed = true; - // RFC-027 section 7.3a: re-resolve this file's call sites into - // `provenance='live'` edges for the ones whose callee signature - // is unambiguous repo-wide. This is the between-commits overlay - // and needs no language server. - // - // It runs on EVERY save of the file, not only when something - // looks new, because `reindex_replace` above just deleted every - // outbound edge this file owned. Re-emitting is idempotent - // (`put_edge_live` upserts), so whole-file re-resolution is - // both the correct and the cheap option. - // - // Fail-open: a live edge is a freshness gain, never a - // correctness dependency, so any error here is logged and - // dropped rather than failing the save. - live_resolve_file(store, &corpus, repo_root, abs_path, &vname_path); // Collect FFI markers for the repo-level pass (RFC-005). all_ffi_markers.extend(out.ffi_markers); // Collect Cargo workspace dep markers for the A2 repo-level pass. @@ -10053,7 +10054,15 @@ fn handle_watch_event( WatchEvent::Upsert(path) => { let mut s = store.lock().unwrap_or_else(|e| e.into_inner()); match reindex_files(std::slice::from_ref(&path), repo_root, &mut s) { - Ok(callers) => enqueue_dirty_callers(callers, repo_root, index_tx), + Ok(callers) => { + // RFC-027 section 7.3a: refresh this file's live overlay. + // Only here, on the save path — the commit path arms a Phase + // B refresh that re-derives the same edges, so doing it + // there would be waste inside `git commit`'s latency. + let corpus = s.get_meta("corpus").ok().flatten().unwrap_or_default(); + live_resolve_file(&mut s, &corpus, repo_root, &path); + enqueue_dirty_callers(callers, repo_root, index_tx) + } Err(e) => tracing::warn!(path=%path.display(), err=%e, "watcher reindex failed"), } } From 956eaea2884a753bc2637648c0156fb0a52df93c Mon Sep 17 00:00:00 2001 From: Abhishek Date: Fri, 28 Aug 2026 04:42:14 +0530 Subject: [PATCH 07/23] [travsr-daemon] RFC-027 Phase 1: edit classification, invalidation, pending state Touch only what an edit actually invalidates, and record honestly what could not be resolved. Edit classification (RFC section 6). classify_edit reads the ReplaceReport reindex_replace already returns rather than computing a second diff: that report is derived inside the transaction that rewrote the file, from the authoritative old-versus-new NodeId sets, so any diff computed elsewhere would be a less reliable copy. A rename is a remove plus an add, so it lands in Interface without needing to be told apart from a delete. Mis-classifying toward Interface costs recall and never correctness, which is the safe direction. Invalidation (section 6.3). On an interface edit the save path re-resolves the files that referenced the changed symbols. Their edges into the edited file were deleted by reindex_replace, so without this a rename silently drops every inbound live edge until the next commit. Depth one, not transitive: a rename invalidates edges pointing at the renamed symbol, not the callers of those callers, whose own surface did not change. Capped at 32 files, because editing a hot utility must not turn one save into hundreds of re-parses. Pending state (section 9.2), migration v23. ref_resolution_state records every reference a live pass saw and what became of it. An abstention is written down, not dropped: "there is a call here and its target is not yet known" is true and useful, and being able to say it is what makes fail-closed honest rather than merely quiet. Modelled on edge_sites, composite PK and WITHOUT ROWID so re-resolving a file dedups instead of growing. Also fixes a pre-existing Invariant #4 violation found while verifying the above. fsck reported two orphan edges on a healthy TypeScript repo after any ordinary incremental edit. TypeScript import resolution is speculative by construction: `./user` emits a resolves-to candidate for user.ts, user.tsx and user.js because it cannot know which exists without touching the store. The init path drops the losers when staging is flushed, which its own comment calls making "no orphan edges" a store invariant at the staging boundary. The incremental path had no equivalent, so a full index and an incremental index of the same tree disagreed by two edges per import, and a real orphan became indistinguishable from routine import speculation. sweep_orphan_edges_for_paths brings the incremental path up to the same invariant. It runs after the whole batch rather than per file, which is the position the staging flush occupies: an edge into a file created later in the same batch is legitimately dangling until that file's nodes land, and sweeping mid-batch would delete it. Scoped to the batch's paths rather than the whole table, because the unscoped sweep is a full edge scan and that is a fine cost for fsck but not one to pay on every save. This is unrelated to the live lane: it reproduces on the commit path, which never runs it. Confirmed by removing the fix and watching an_incremental_edit_leaves_no_orphan_edges fail with exactly 2 orphans. Gate: no ghost edges after rename/delete. a_rename_leaves_no_ghost_live_edge and a_delete_leaves_no_ghost_live_edge assert zero orphans after each, with a precondition that a live edge existed to lose. an_abstention_is_recorded_as_pending covers the honest-abstention half. an_incremental_edit_leaves_no_orphan_edges and the_orphan_sweep_spares_a_forward_reference_within_one_batch cover the sweep and its ordering constraint. cargo test -p travsr-core -p travsr-store -p travsr-ipc -p travsr-mcp -p travsr-daemon: 21 suites, 1210 passed, 0 failed. Verified end to end: an uncommitted rename completed across two files shows placeOrder -> User.persist as provenance="live" before any commit, the old name has no ghost caller, and fsck reports 0 orphans where it previously reported 2. --- crates/travsr-daemon/src/lib.rs | 329 +++++++++++++++++- crates/travsr-daemon/src/live_resolve.rs | 146 +++++++- crates/travsr-store/src/lib.rs | 188 ++++++++++ .../migrations/v23_ref_resolution_state.sql | 33 ++ 4 files changed, 683 insertions(+), 13 deletions(-) create mode 100644 crates/travsr-store/src/migrations/v23_ref_resolution_state.sql diff --git a/crates/travsr-daemon/src/lib.rs b/crates/travsr-daemon/src/lib.rs index f26e7b11..739b9c06 100644 --- a/crates/travsr-daemon/src/lib.rs +++ b/crates/travsr-daemon/src/lib.rs @@ -3647,6 +3647,14 @@ fn run_with_phase_b_finish_guard( /// /// Fail-open: a live edge is a freshness gain, never a correctness dependency, /// so every error here is logged at debug and dropped. +/// Upper bound on files re-resolved by one interface edit (RFC-027 Risk R4). +/// +/// A save must stay a save. Editing a hot utility can dirty hundreds of files, +/// and re-parsing all of them on every keystroke would cost more than the +/// freshness is worth. Beyond the cap those files keep their commit-gated +/// behavior, which is a recall cost the next Phase B run repairs. +const LIVE_CLOSURE_FILE_CAP: usize = 32; + fn live_resolve_file(store: &mut SqliteStore, corpus: &str, repo_root: &Path, abs_path: &Path) { let vname_path = abs_path .strip_prefix(repo_root) @@ -3675,7 +3683,7 @@ fn live_resolve_file(store: &mut SqliteStore, corpus: &str, repo_root: &Path, ab if unresolved.is_empty() { return; } - let outcome = live_resolve::resolve_unambiguous_lexical(store, &unresolved); + let outcome = live_resolve::resolve_unambiguous_lexical(store, corpus, vname_path, &unresolved); if outcome.emitted > 0 { tracing::debug!( event = "live.file", @@ -4066,6 +4074,8 @@ pub fn reindex_files( let mut any_changed = false; // Accumulate Tier-0 dirty callers across all files in this batch. let mut callers_all = travsr_core::DirtySet::default(); + // Paths this batch rewrote, for the end-of-batch orphan sweep below. + let mut written_paths: Vec = Vec::new(); // L5b: unlike `index_paths_parallel`'s full-repo `paths`, this commit-hook // batch only contains changed files — a `.m`/`.mm` sibling may not be in it @@ -4171,6 +4181,7 @@ pub fn reindex_files( callers_all.extend(report.callers); } any_changed = true; + written_paths.push(vname_path.clone()); // Collect FFI markers for the repo-level pass (RFC-005). all_ffi_markers.extend(out.ffi_markers); // Collect Cargo workspace dep markers for the A2 repo-level pass. @@ -4296,6 +4307,29 @@ pub fn reindex_files( // The LSIF pass now runs only from `init_repo` (full initial index). // Per-commit LSIF delta is tracked as DEBT(travsr-25). + // Bring the incremental path up to the init path's "no orphan edges" + // invariant. `flush_staging_to_production` drops staged edges with a + // missing endpoint once every node in the batch has landed; nothing did the + // equivalent here, so speculative import candidates (`./user` becomes + // `user.ts`/`user.tsx`/`user.js`, only one of which exists) survived every + // incremental write. A full index and an incremental index of the same tree + // therefore disagreed by two edges per TypeScript import, and `fsck` + // reported orphans on a healthy repo. + // + // After the loop, not inside it: an edge from an already-processed file to + // one later in this same batch is legitimately dangling until that file's + // nodes land. This is the same position the staging flush occupies. + if any_changed { + match store.sweep_orphan_edges_for_paths(&corpus, &written_paths) { + Ok(0) => {} + Ok(n) => tracing::debug!( + swept = n, + "reindex: dropped speculative edges with no destination node" + ), + Err(e) => tracing::warn!("reindex: orphan edge sweep failed: {e}"), + } + } + Ok(callers_all) } @@ -7438,6 +7472,282 @@ mod tests { ); } + /// Invariant #4, orphan dimension: an incremental edit must leave the graph + /// as free of dangling edges as a full index would. + /// + /// TypeScript import resolution is speculative by construction — `./user` + /// emits a `resolves-to` candidate for `user.ts`, `user.tsx` and `user.js` + /// because it cannot know which exists without touching the store. The init + /// path drops the losers when staging is flushed, which its own comment + /// calls making "no orphan edges" a store invariant. The incremental path + /// had no equivalent, so every ordinary edit to a file with a relative + /// import left two dead edges behind and `fsck` reported orphans on a + /// perfectly healthy repo. + #[test] + fn an_incremental_edit_leaves_no_orphan_edges() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + git_init(tmp.path()); + std::fs::create_dir_all(tmp.path().join("src")).unwrap(); + + let user = tmp.path().join("src/user.ts"); + let order = tmp.path().join("src/order.ts"); + std::fs::write(&user, "export class User {\n save(): void {}\n}\n").unwrap(); + std::fs::write( + &order, + "import { User } from \"./user\";\nexport function placeOrder(u: User): void {\n u.save();\n}\n", + ) + .unwrap(); + + std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); + init_repo(tmp.path()).unwrap(); + std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); + + let db_path = tmp.path().join(".travsr/graph.db"); + assert_eq!( + travsr_store::SqliteStore::open(&db_path) + .unwrap() + .count_orphans() + .unwrap(), + 0, + "precondition: a full index leaves no orphans" + ); + + // Edit both files, the way a rename lands incrementally. + std::fs::write(&user, "export class User {\n persist(): void {}\n}\n").unwrap(); + std::fs::write( + &order, + "import { User } from \"./user\";\nexport function placeOrder(u: User): void {\n u.persist();\n}\n", + ) + .unwrap(); + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + reindex_files(&[user.clone(), order.clone()], tmp.path(), &mut store).unwrap(); + } + + assert_eq!( + travsr_store::SqliteStore::open(&db_path) + .unwrap() + .count_orphans() + .unwrap(), + 0, + "an incremental edit must not leave dangling edges the full path drops" + ); + } + + /// The sweep runs after the whole batch, so an edge into a file that is + /// created later in the same batch survives. Sweeping per file would delete + /// it, since its destination node does not exist yet at that point. + #[test] + fn the_orphan_sweep_spares_a_forward_reference_within_one_batch() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + git_init(tmp.path()); + std::fs::create_dir_all(tmp.path().join("src")).unwrap(); + + // Only the importer exists at index time. + let order = tmp.path().join("src/order.ts"); + std::fs::write(&order, "export function placeOrder(): void {}\n").unwrap(); + std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); + init_repo(tmp.path()).unwrap(); + std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); + + // Now add both the import and its target in one batch, importer first. + let user = tmp.path().join("src/user.ts"); + std::fs::write( + &order, + "import { User } from \"./user\";\nexport function placeOrder(u: User): void {}\n", + ) + .unwrap(); + std::fs::write(&user, "export class User {\n save(): void {}\n}\n").unwrap(); + + let db_path = tmp.path().join(".travsr/graph.db"); + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + reindex_files(&[order.clone(), user.clone()], tmp.path(), &mut store).unwrap(); + } + + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + assert_eq!(store.count_orphans().unwrap(), 0, "no orphans either way"); + let resolves: u64 = store.count_edges_with_provenance("tree-sitter").unwrap(); + assert!( + resolves > 0, + "the real import edge must survive a same-batch forward reference" + ); + } + + /// RFC-027 Phase 1 gate: a rename must leave no ghost live edge. + /// + /// The hazard the live lane introduces is an edge to a symbol that no + /// longer exists. `reindex_replace` deletes the inbound edges of a symbol + /// whose NodeId vanished, so a `live` edge is cleaned up exactly as a + /// `tree-sitter` or `scip` one is — this test is what keeps that true, and + /// it is the difference between honest staleness and a fabricated target. + #[test] + fn a_rename_leaves_no_ghost_live_edge() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + git_init(tmp.path()); + std::fs::create_dir_all(tmp.path().join("src")).unwrap(); + + let user = tmp.path().join("src/user.ts"); + let order = tmp.path().join("src/order.ts"); + std::fs::write(&user, "export class User {\n save(): void {}\n}\n").unwrap(); + std::fs::write( + &order, + "import { User } from \"./user\";\nexport function placeOrder(u: User): void {\n u.save();\n}\n", + ) + .unwrap(); + + std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); + init_repo(tmp.path()).unwrap(); + std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); + + let db_path = tmp.path().join(".travsr/graph.db"); + let corpus = { + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + store.get_meta("corpus").unwrap().unwrap_or_default() + }; + + // Emit the live overlay for order.ts, then confirm it exists. + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + live_resolve_file(&mut store, &corpus, tmp.path(), &order); + } + let live_before = count_live_edges(&db_path); + assert!( + live_before > 0, + "precondition: the live lane must have emitted an edge to rename away from" + ); + + // Rename User.save -> User.persist. The old NodeId vanishes, so every + // inbound edge to it — live included — must go with it. + std::fs::write(&user, "export class User {\n persist(): void {}\n}\n").unwrap(); + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + reindex_files(std::slice::from_ref(&user), tmp.path(), &mut store).unwrap(); + } + + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + let orphans = store.count_orphans().unwrap(); + assert_eq!( + orphans, 0, + "a rename must leave no edge pointing at a node that no longer exists" + ); + } + + /// A deleted file takes its inbound live edges with it, the same way + /// `delete_file` takes both directions for every other provenance. + #[test] + fn a_delete_leaves_no_ghost_live_edge() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + git_init(tmp.path()); + std::fs::create_dir_all(tmp.path().join("src")).unwrap(); + + let user = tmp.path().join("src/user.ts"); + let order = tmp.path().join("src/order.ts"); + std::fs::write(&user, "export class User {\n save(): void {}\n}\n").unwrap(); + std::fs::write( + &order, + "import { User } from \"./user\";\nexport function placeOrder(u: User): void {\n u.save();\n}\n", + ) + .unwrap(); + + std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); + init_repo(tmp.path()).unwrap(); + std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); + + let db_path = tmp.path().join(".travsr/graph.db"); + let corpus = { + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + store.get_meta("corpus").unwrap().unwrap_or_default() + }; + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + live_resolve_file(&mut store, &corpus, tmp.path(), &order); + } + assert!( + count_live_edges(&db_path) > 0, + "precondition: a live edge exists" + ); + + std::fs::remove_file(&user).unwrap(); + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + reindex_files(std::slice::from_ref(&user), tmp.path(), &mut store).unwrap(); + } + + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + let orphans = store.count_orphans().unwrap(); + assert_eq!( + orphans, 0, + "a deleted file must take its inbound live edges with it" + ); + } + + /// RFC-027 section 9.2: an abstention is recorded, not dropped. + /// + /// `save` here is ambiguous (two classes define it), which is exactly the + /// method-on-receiver case the lexical lane must refuse. Refusing quietly + /// and refusing honestly look identical in the edge table; they differ in + /// whether anything can later say *why* the answer is thin. + #[test] + fn an_abstention_is_recorded_as_pending() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + git_init(tmp.path()); + std::fs::create_dir_all(tmp.path().join("src")).unwrap(); + + std::fs::write( + tmp.path().join("src/a.ts"), + "export class A {\n ping(): void {}\n}\n", + ) + .unwrap(); + std::fs::write( + tmp.path().join("src/b.ts"), + "export class B {\n ping(): void {}\n}\n", + ) + .unwrap(); + let caller = tmp.path().join("src/caller.ts"); + std::fs::write( + &caller, + "export function go(x: any): void {\n x.ping();\n}\n", + ) + .unwrap(); + + std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); + init_repo(tmp.path()).unwrap(); + std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); + + let db_path = tmp.path().join(".travsr/graph.db"); + let corpus = { + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + store.get_meta("corpus").unwrap().unwrap_or_default() + }; + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + live_resolve_file(&mut store, &corpus, tmp.path(), &caller); + } + + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + let pending = store + .pending_refs_in_file(&corpus, "src/caller.ts") + .unwrap(); + assert!( + pending.iter().any(|(name, _)| name == "ping"), + "an untyped receiver must leave a pending row, got {pending:?}" + ); + } + + /// Count `provenance='live'` rows. Shared by the Phase 1 ghost-edge tests. + fn count_live_edges(db_path: &std::path::Path) -> u64 { + travsr_store::SqliteStore::open(db_path) + .unwrap() + .count_edges_with_provenance("live") + .unwrap() + } + /// §9 CI invariant: incremental delete + reindex must produce the same /// node and edge counts as a full rebuild on the mutated tree. /// @@ -10055,12 +10365,27 @@ fn handle_watch_event( let mut s = store.lock().unwrap_or_else(|e| e.into_inner()); match reindex_files(std::slice::from_ref(&path), repo_root, &mut s) { Ok(callers) => { - // RFC-027 section 7.3a: refresh this file's live overlay. + // RFC-027 sections 6 and 7.3a: refresh this file's live + // overlay, and on an interface edit the overlay of the files + // that reference it. + // // Only here, on the save path — the commit path arms a Phase // B refresh that re-derives the same edges, so doing it // there would be waste inside `git commit`'s latency. + // + // `callers` is non-empty exactly when a symbol other files + // reference vanished, which is the interface-edit signal + // (section 6.2). Their edges into this file were deleted by + // `reindex_replace`, so re-resolving them is what stops a + // rename from silently dropping every inbound live edge. let corpus = s.get_meta("corpus").ok().flatten().unwrap_or_default(); live_resolve_file(&mut s, &corpus, repo_root, &path); + for dependent in callers.iter().take(LIVE_CLOSURE_FILE_CAP) { + let abs = repo_root.join(dependent); + if abs != path && abs.is_file() { + live_resolve_file(&mut s, &corpus, repo_root, &abs); + } + } enqueue_dirty_callers(callers, repo_root, index_tx) } Err(e) => tracing::warn!(path=%path.display(), err=%e, "watcher reindex failed"), diff --git a/crates/travsr-daemon/src/live_resolve.rs b/crates/travsr-daemon/src/live_resolve.rs index 095938c1..9130e624 100644 --- a/crates/travsr-daemon/src/live_resolve.rs +++ b/crates/travsr-daemon/src/live_resolve.rs @@ -113,6 +113,87 @@ fn resolve_one(store: &SqliteStore, corpus: &str, file: &str, r: &LiveResolution edge_if_sound(store, src, dst) } +/// RFC-027 section 6: what an edit actually invalidates. +/// +/// The distinction is not cosmetic. *Outgoing* edges (edited file to others) are +/// recomputable from the edited file alone. *Incoming* edges (others to the +/// edited file) are not, because their sources live elsewhere — and they stay +/// correct only while the referenced surface is unchanged. That asymmetry is +/// the whole reason a body edit can be resolved locally and an interface edit +/// cannot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EditKind { + /// No symbol's referenced surface changed. Re-resolve this file's outgoing + /// references and touch nothing else. This is the common case, and the one + /// that has to stay cheap or the live lane is not worth having. + Body, + /// A symbol other files reference was renamed, removed, or replaced. Their + /// edges into this file may now be wrong, so the reverse closure is + /// re-resolved (section 6.3). + Interface, +} + +/// Classify an edit from the report `reindex_replace` already returns. +/// +/// Deliberately does not compute a second diff. `ReplaceReport` is derived +/// inside the same transaction that rewrote the file, from the authoritative +/// old-versus-new NodeId sets, so any diff computed here would be a less +/// reliable copy of it. `removed_count` counts symbols whose id vanished, and +/// `callers` names the files that had edges into them. +/// +/// Conservative by construction: a rename is a remove plus an add, so it lands +/// in `Interface` without needing to be told apart from a delete. Section 16.2 +/// notes that mis-classifying toward `Interface` costs recall and never +/// correctness, and this is where that choice is made. +pub fn classify_edit(report: &travsr_core::ReplaceReport) -> EditKind { + if report.removed_count > 0 || !report.callers.is_empty() { + EditKind::Interface + } else { + EditKind::Body + } +} + +/// Section 6.3: the files whose edges into `changed` may now be wrong. +/// +/// Reverse lookup over `iter_edges_to`, which the covering index +/// `idx_edges_dst_kind_cov` serves without touching the nodes table for the +/// edge scan itself. +/// +/// **Depth one, not transitive** (Risk R4). A rename invalidates the edges that +/// point *at* the renamed symbol; it does not invalidate the callers of those +/// callers, whose own referenced surface did not change. Walking transitively +/// would pull in most of a repo from one edit to a hot file, for no additional +/// correctness. The existing Tier-0 propagation makes the same choice for the +/// same reason. +/// +/// `MAX_CLOSURE_FILES` bounds the result even at depth one: a symbol with +/// thousands of callers is a hot utility, and re-resolving every one of them on +/// each keystroke would cost more than the freshness is worth. Truncation costs +/// recall, which the commit-gated path then repairs. +pub fn reverse_closure( + store: &SqliteStore, + changed: &[NodeId], +) -> std::collections::HashSet { + let mut files = std::collections::HashSet::new(); + for &id in changed { + let Ok(incoming) = store.iter_edges_to(id) else { + continue; + }; + for edge in incoming { + if files.len() >= MAX_CLOSURE_FILES { + return files; + } + if let Ok(Some(node)) = store.get_node(edge.src) { + files.insert(node.vname.path); + } + } + } + files +} + +/// Upper bound on files pulled into one reverse closure (Risk R4). +const MAX_CLOSURE_FILES: usize = 64; + /// Section 7.3a: emit for calls whose callee signature is unambiguous repo-wide. /// /// This lane needs no language server and is the zero-regression floor: with no @@ -136,24 +217,57 @@ fn resolve_one(store: &SqliteStore, corpus: &str, file: &str, r: &LiveResolution /// rule, never to widen the net. pub fn resolve_unambiguous_lexical( store: &mut SqliteStore, + corpus: &str, + path: &str, unresolved: &[UnresolvedCall], ) -> LiveOutcome { let mut outcome = LiveOutcome::default(); + // Section 9.2: every reference this pass saw, and what became of it. An + // abstention is recorded, not dropped — "there is a call here and its + // target is not yet known" is true and useful, and saying it is what makes + // fail-closed honest rather than merely quiet. + let mut states: Vec<(NodeId, u32, u32, String, &'static str)> = + Vec::with_capacity(unresolved.len()); + for call in unresolved { - let Some(edge) = lexical_one(store, call) else { - outcome.abstain(); - continue; + let name = travsr_core::ident::leaf_of(&call.callee_sig).to_string(); + let resolved = match lexical_one(store, call) { + Some(edge) => match store.put_edge_live(&edge) { + Ok(()) => true, + Err(e) => { + tracing::debug!(error = %e, "live lexical edge write failed"); + false + } + }, + None => false, }; - if let Err(e) = store.put_edge_live(&edge) { - tracing::debug!(error = %e, "live lexical edge write failed"); - outcome.abstain(); - } else { + if resolved { outcome.emit(); + } else { + outcome.abstain(); } + // `ref_col` is 0: the native extractor records the call's line but not + // its column. The PK tolerates it, and a second call to the same name + // on one line is the only collision it can cause, which merges two + // identical pending facts rather than losing one. + states.push(( + call.src, + call.caller_line, + 0, + name, + if resolved { "resolved" } else { "pending" }, + )); + } + + if let Err(e) = store.replace_ref_resolution_states(corpus, path, &states) { + // Losing the pending record costs the freshness note its detail, never + // the graph its correctness. + tracing::debug!(error = %e, "recording ref_resolution_state failed"); } - if outcome.emitted > 0 { + if outcome.emitted > 0 || outcome.pending > 0 { tracing::debug!( event = "live.lexical", + path = %path, emitted = outcome.emitted, pending = outcome.pending, "unambiguous-lexical live resolution complete" @@ -453,7 +567,12 @@ mod tests { ("src/user.ts", "method:User.save", "method", 15, 20), ]); let src = node_id("src/order.ts", "fn:placeOrder"); - let out = resolve_unambiguous_lexical(&mut store, &[call(src, "method:User.save", 18)]); + let out = resolve_unambiguous_lexical( + &mut store, + CORPUS, + "src/order.ts", + &[call(src, "method:User.save", 18)], + ); assert_eq!( out, @@ -488,7 +607,12 @@ mod tests { ("src/draft.ts", "method:save", "method", 5, 9), ]); let src = node_id("src/order.ts", "fn:placeOrder"); - let out = resolve_unambiguous_lexical(&mut store, &[call(src, "method:save", 18)]); + let out = resolve_unambiguous_lexical( + &mut store, + CORPUS, + "src/order.ts", + &[call(src, "method:save", 18)], + ); assert_eq!( out, @@ -518,7 +642,7 @@ mod tests { let mut c = call(src, "class:Field", 18); c.alt_callee_sig = Some("fn:Field".to_string()); - let out = resolve_unambiguous_lexical(&mut store, &[c]); + let out = resolve_unambiguous_lexical(&mut store, CORPUS, "src/order.ts", &[c]); assert_eq!( out, LiveOutcome { diff --git a/crates/travsr-store/src/lib.rs b/crates/travsr-store/src/lib.rs index 64b8a54e..4f7f42e0 100644 --- a/crates/travsr-store/src/lib.rs +++ b/crates/travsr-store/src/lib.rs @@ -575,6 +575,19 @@ impl Migration for V22TestRole { } } +/// RFC-027 section 9.2: `ref_resolution_state`, so an unresolved reference can +/// be reported as pending instead of silently vanishing or being guessed at. +struct V23RefResolutionState; +impl Migration for V23RefResolutionState { + fn version(&self) -> u32 { + 23 + } + fn up(&self, store: &mut dyn StoreMigratable) -> anyhow::Result<()> { + // CREATE TABLE / INDEX IF NOT EXISTS — idempotent on re-run. + store.exec_ddl(include_str!("migrations/v23_ref_resolution_state.sql")) + } +} + /// Build the ordered migration runner for the SQLite backend. /// Register new SQLite migrations here; version order is enforced by the runner. fn sqlite_migration_runner() -> MigrationRunner { @@ -600,6 +613,7 @@ fn sqlite_migration_runner() -> MigrationRunner { r.register(V20PurgeOrphanEdgeSites); r.register(V21LexicalSplit); r.register(V22TestRole); + r.register(V23RefResolutionState); r } @@ -2476,6 +2490,66 @@ impl SqliteStore { .map_err(|e| StoreError::Database(e.to_string())) } + /// Scoped orphan sweep for the incremental path: drop edges *owned by* + /// `paths` whose destination is absent from `nodes`. + /// + /// The init path already guarantees this. `flush_staging_to_production` + /// promotes every node in the batch and then drops any staged edge with a + /// missing endpoint, which it calls making "no orphan edges" a store + /// invariant at the staging boundary. `reindex_replace` had no equivalent, + /// so the two paths disagreed and Invariant #4 (incremental == full) failed + /// in the orphan dimension. + /// + /// The edges this matters for are speculative by construction. TypeScript + /// import resolution emits a `resolves-to` candidate per plausible + /// extension (`./user` becomes `user.ts`, `user.tsx`, `user.js`) because it + /// cannot know which one exists without touching the store. On a full index + /// the losers are dropped at the staging boundary; incrementally they + /// survived, so every ordinary TypeScript edit left two dead edges behind + /// and `fsck` reported orphans on a healthy repo. + /// + /// **Call this after the whole batch is written, never per file.** That is + /// the same position the staging flush occupies: an edge from an + /// already-processed file to one later in the same batch is legitimately + /// dangling until that file's nodes land, and sweeping mid-batch would + /// delete it. + /// + /// Scoped to `paths` rather than the whole table because the unscoped + /// [`sweep_orphans`] is a full edge scan, which is a fine cost for `fsck` + /// and not one to pay on every save. + pub fn sweep_orphan_edges_for_paths( + &mut self, + corpus: &str, + paths: &[String], + ) -> Result { + if paths.is_empty() { + return Ok(0); + } + (|| -> AnyResult { + let tx = self + .conn + .transaction() + .context("sweep_orphan_edges_for_paths: begin")?; + let mut swept = 0u64; + for path in paths { + // `src IN (…)` hits the edges primary key prefix, so this is a + // bounded probe per path rather than a scan of the edge table. + swept += tx + .execute( + "DELETE FROM edges \ + WHERE src IN (SELECT id FROM nodes WHERE corpus = ?1 AND path = ?2) \ + AND dst NOT IN (SELECT id FROM nodes)", + params![corpus, path], + ) + .context("sweeping orphan edges for path")? as u64; + } + tx.commit() + .context("sweep_orphan_edges_for_paths: commit")?; + Ok(swept) + })() + .map_err(|e| StoreError::Database(e.to_string())) + } + /// Orphan-edge sweep: deletes every edge whose src or dst is absent from /// `nodes`. Should return 0 in correct operation after Tiers 0–2; a non-zero /// count indicates a write-path invariant violation. @@ -4655,6 +4729,120 @@ LIMIT ?4", Ok(ids.into_iter().map(i64_to_node_id).collect()) } + /// Count edges carrying `provenance`. + /// + /// RFC-027 leans on this twice: the Invariant #4 convergence check asserts + /// zero `live` rows in a committed graph, and the precision meter needs the + /// overlay's size. Cheap enough to call per assertion at fixture scale. + pub fn count_edges_with_provenance(&self, provenance: &str) -> Result { + self.conn + .query_row( + "SELECT count(*) FROM edges WHERE provenance = ?1", + params![provenance], + |row| row.get::<_, i64>(0), + ) + .map(|n| n as u64) + .context("counting edges by provenance") + .map_err(|e| StoreError::Database(e.to_string())) + } + + /// RFC-027 section 9.2: replace a file's reference-resolution rows. + /// + /// Called once per live pass over a file, with every reference that pass + /// saw and what became of it. Whole-file replacement rather than + /// incremental patching, for the same reason the live engine re-emits + /// whole-file: `reindex_replace` has just rewritten the file's nodes, so + /// any row keyed on a node that no longer exists is stale by construction. + /// + /// `rows` are `(src, ref_line, ref_col, name, state)` where `state` is + /// `"pending"` or `"resolved"`. Owned by `src`'s file, mirroring how + /// `edge_sites` scopes ownership, so the delete below can be keyed on the + /// same `(corpus, path)` predicate. + pub fn replace_ref_resolution_states( + &mut self, + corpus: &str, + path: &str, + rows: &[(NodeId, u32, u32, String, &'static str)], + ) -> Result<(), StoreError> { + (|| -> AnyResult<()> { + let tx = self + .conn + .transaction() + .context("replace_ref_resolution_states: begin")?; + tx.execute( + "DELETE FROM ref_resolution_state \ + WHERE src IN (SELECT id FROM nodes WHERE corpus = ?1 AND path = ?2)", + params![corpus, path], + ) + .context("clearing this file's ref_resolution_state rows")?; + for (src, line, col, name, state) in rows { + tx.execute( + "INSERT INTO ref_resolution_state(src, ref_line, ref_col, name, state) \ + VALUES(?1, ?2, ?3, ?4, ?5) \ + ON CONFLICT(src, ref_line, ref_col, name) DO UPDATE SET state = excluded.state", + params![node_id_to_i64(*src), *line as i64, *col as i64, name, state], + ) + .context("inserting ref_resolution_state row")?; + } + tx.commit() + .context("replace_ref_resolution_states: commit")?; + Ok(()) + })() + .map_err(|e| StoreError::Database(e.to_string())) + } + + /// RFC-027 section 10: the pending references in `path`, for the + /// `live_overlay` freshness note. + /// + /// Returns `(name, ref_line)` per unresolved reference, so a consumer can + /// say *which* call sites are un-targeted rather than only how many. + pub fn pending_refs_in_file( + &self, + corpus: &str, + path: &str, + ) -> Result, StoreError> { + (|| -> AnyResult> { + let mut stmt = self + .conn + .prepare_cached( + "SELECT r.name, r.ref_line FROM ref_resolution_state r \ + JOIN nodes n ON n.id = r.src \ + WHERE r.state = 'pending' AND n.corpus = ?1 AND n.path = ?2 \ + ORDER BY r.ref_line ASC", + ) + .context("pending_refs_in_file: prepare")?; + let rows = stmt + .query_map(params![corpus, path], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u32)) + }) + .context("pending_refs_in_file: query")?; + let mut out = Vec::new(); + for r in rows { + out.push(r.context("decoding pending ref row")?); + } + Ok(out) + })() + .map_err(|e| StoreError::Database(e.to_string())) + } + + /// RFC-027 section 8.3: clear pending rows that Phase B has since resolved. + /// + /// Run at ratification. A reference whose enclosing node now has an + /// outgoing edge is no longer pending, whatever resolved it, so this is + /// keyed on edge existence rather than on which lane won. + /// + /// Returns the number of rows cleared. + pub fn clear_resolved_pending_refs(&mut self) -> Result { + self.conn + .execute( + "DELETE FROM ref_resolution_state \ + WHERE state = 'pending' \ + AND EXISTS (SELECT 1 FROM edges e WHERE e.src = ref_resolution_state.src)", + [], + ) + .map_err(|e| StoreError::Database(e.to_string())) + } + /// RFC-027 section 7.5: map a `(path, line)` position to the graph node /// that owns it, returning the **narrowest** enclosing definition. /// diff --git a/crates/travsr-store/src/migrations/v23_ref_resolution_state.sql b/crates/travsr-store/src/migrations/v23_ref_resolution_state.sql new file mode 100644 index 00000000..34dfbe45 --- /dev/null +++ b/crates/travsr-store/src/migrations/v23_ref_resolution_state.sql @@ -0,0 +1,33 @@ +-- RFC-027 section 9.2: honest abstention for the live semantic lane. +-- +-- A reference Tree-sitter detected but nothing could resolve is not an edge and +-- must never be rendered as one. It is also not nothing: "there is a call here, +-- and its target is not yet known" is a true and useful statement, and the +-- whole precision-first argument rests on being able to say it out loud instead +-- of guessing. This table is where that statement lives. +-- +-- Named `ref_resolution_state` rather than `resolution_state` to avoid +-- colliding with the daemon's unrelated `record_dart_resolution_state` +-- bookkeeping, which tracks Dart Phase B availability. +-- +-- Modelled on `edge_sites`: a composite PK so re-resolving a file is an actual +-- dedup rather than unbounded growth, and WITHOUT ROWID so rows cluster on that +-- PK and its prefix (src) serves as the lookup index without a second one. +-- +-- `state` is 'pending' or 'resolved'. Rows are owned by their `src` node's file +-- the same way `edge_sites` rows are, so the live engine deletes a file's rows +-- before re-resolving it and a vanished symbol's rows go with its node. +CREATE TABLE IF NOT EXISTS ref_resolution_state ( + src INTEGER NOT NULL, + ref_line INTEGER NOT NULL, + ref_col INTEGER NOT NULL, + name TEXT NOT NULL, + state TEXT NOT NULL, + PRIMARY KEY (src, ref_line, ref_col, name) +) WITHOUT ROWID; + +-- The MCP surface asks "what is pending in this file", which is a scan by state +-- across many src nodes rather than a probe of one. Without this it is a full +-- table scan on every query that mentions a dirty file. +CREATE INDEX IF NOT EXISTS idx_ref_resolution_state + ON ref_resolution_state(state); From e65aa9391c966359abf42f08982f27908a5c51ef Mon Sep 17 00:00:00 2001 From: Abhishek Date: Fri, 28 Aug 2026 05:07:55 +0530 Subject: [PATCH 08/23] [travsr-daemon] RFC-027 Phase 2: commit ratification and convergence Every live edge is gone from the graph the next Phase B run leaves behind, and retiring the overlay returns the graph to exactly what it was before the overlay existed. Ratification runs in run_background_phase_b_inner, after every SCIP/LSIF/native write and before the phase_b_commit marker advances, while the same store lock is still held. Position carries the whole argument: any live edge Phase B re-derived has already been relabelled in place by those writes, so what is still marked live is exactly what Phase B did not re-derive, and no in-process reader can observe the graph between ratification and the marker. Not one WAL transaction, and it does not need to be. The Phase B write path is already several self-committing statements under a process-local mutex, so there is no single transaction to append to. Because the order is insert-then-delete, the only state an out-of-process reader can catch mid-flight is a superset of the ratified graph, never a gap. The hazard the RFC worried about was the gap. The sweep is scoped to the languages that completed, not blanket. made_progress advances the marker whenever any language produced results, even when another sidecar crashed (#712), so a blanket delete would discard the overlay for a language whose truth was never re-derived. Those edges survive, still labeled live, which is honest. Fixes a correctness bug the convergence test caught in the sweep itself. put_edge_live used to relabel an existing tree-sitter row as live. The sweep deletes rows, so that handed pre-existing truth to the sweep: any such edge Phase B did not happen to re-derive was deleted outright. Reachable in production through the Phase 1 closure, which re-resolves files that reference the edited one and were never re-parsed, so their tree-sitter edges are still in place. The overlay is now purely additive: it creates edges that were absent and refreshes its own, and never relabels a row another lane wrote. That makes every row the sweep can reach one the live lane created, so ratification is a return to the pre-overlay graph rather than a mutation of it. Gate. ratifying_the_overlay_restores_the_pre_overlay_graph asserts the property in the form it actually takes, comparing content fingerprints that include provenance so a live row sitting where a ratified one belongs fails rather than passing on a matching count. an_overlay_edge_phase_b_rederives_is_ratified_in_place covers the other half. the_overlay_cannot_destroy_an_edge_it_did_not_create is the regression test for the bug above, and fails with the old upsert. ratification_spares_a_language_that_did_not_complete covers the #712 scoping. The property is asserted against the pre-overlay graph rather than a from-scratch reindex. Those differ for an unrelated pre-existing reason: Phase B is whole-project and commit-gated (DEBT-25), so an incremental Phase A pass legitimately lacks semantic edges a full index would have. Asserting equality with a full reindex would assert something RFC-027 neither claims nor fixes. cargo test -p travsr-core -p travsr-store -p travsr-ipc -p travsr-mcp -p travsr-daemon: 21 suites, 1215 passed, 0 failed. Verified end to end: 6 edges committed, plus 2 live on an uncommitted edit, then 8 tree-sitter and 0 live after the commit's Phase B run, with both callers present and zero orphans throughout. --- crates/travsr-daemon/src/lib.rs | 471 +++++++++++++++--- crates/travsr-store/src/lib.rs | 186 +++++-- docs/rfcs/RFC-027-live-semantic-resolution.md | 14 +- 3 files changed, 572 insertions(+), 99 deletions(-) diff --git a/crates/travsr-daemon/src/lib.rs b/crates/travsr-daemon/src/lib.rs index 739b9c06..9fa222d4 100644 --- a/crates/travsr-daemon/src/lib.rs +++ b/crates/travsr-daemon/src/lib.rs @@ -3695,6 +3695,65 @@ fn live_resolve_file(store: &mut SqliteStore, corpus: &str, repo_root: &Path, ab } } +/// RFC-027 section 8.3: retire the live overlay and clear resolved pendings. +/// +/// Extracted so the convergence property test can drive ratification directly +/// instead of racing the background scheduler. +/// +/// Fail-open on both steps: a sweep that errors leaves `live` rows labeled as +/// what they are, which is stale but honest, and never wrong. +fn ratify_live_overlay(store: &mut SqliteStore, ratified: &[String]) { + match store.sweep_live_edges_for_languages(ratified) { + Ok(0) => {} + Ok(n) => tracing::debug!( + event = "live.swept", + swept = n, + languages = ?ratified, + "retired live edges Phase B did not re-derive" + ), + Err(e) => tracing::warn!("live overlay sweep failed: {e:#}"), + } + // A reference whose enclosing node now has an outgoing edge is no longer + // pending, whoever resolved it. + if let Err(e) = store.clear_resolved_pending_refs() { + tracing::debug!("clearing resolved pending refs failed: {e:#}"); + } +} + +/// The `nodes.language` values whose live overlay this Phase B run may retire. +/// +/// `report.ran` names the languages whose analyzers completed. Two adjustments +/// map that onto how nodes are actually labeled: +/// +/// - JavaScript has no `nodes.language` of its own. `Language::from_extension` +/// maps `.js`/`.jsx` to `TypeScript`, so a run that analysed JavaScript +/// ratifies rows labeled `typescript`. +/// - The LSIF pass is TypeScript's and is not represented in `report.ran`, so +/// `typescript` is added whenever it produced edges. +/// +/// A language absent from this set keeps its live edges. That is the #712 +/// partial-success case: the marker advances because *something* progressed, +/// but a crashed sidecar's truth was never re-derived, and discarding its +/// overlay would take away precision without replacing it. +fn ratified_languages(report: &PhaseBReport, lsif_ran: bool) -> Vec { + let mut langs: Vec = Vec::with_capacity(report.ran.len() + 1); + for lang in &report.ran { + // JavaScript nodes are labeled `typescript`; see above. + let mapped = if lang == "javascript" { + "typescript" + } else { + lang.as_str() + }; + if !langs.iter().any(|l| l == mapped) { + langs.push(mapped.to_string()); + } + } + if lsif_ran && !langs.iter().any(|l| l == "typescript") { + langs.push("typescript".to_string()); + } + langs +} + fn run_background_phase_b_inner( repo_root: &Path, store: &std::sync::Mutex, @@ -3841,6 +3900,26 @@ fn run_background_phase_b_inner( // --force`, not on an endless background loop. let made_progress = report.crashed.is_empty() || !report.ran.is_empty() || !lsif_edges.is_empty(); + + // RFC-027 section 8.3: ratify the live overlay. + // + // Position matters twice over. This runs *after* every SCIP/LSIF/native + // write above, so any live edge Phase B re-derived has already been + // relabelled in place by those writes and what remains marked `live` is + // exactly what Phase B did not re-derive. And it runs *before* the marker + // advance below, while the same store lock is still held, so no in-process + // reader can observe the graph between ratification and the marker. + // + // Not one WAL transaction, and it does not need to be. The Phase B write + // path is already several self-committing statements under a process-local + // mutex, so there is no single transaction to append this to. Because the + // order is insert-then-delete, the only state an out-of-process reader can + // catch mid-flight is a superset of the ratified graph, never a gap. The + // hazard the RFC worried about was the gap; the ordering dissolves it. + if made_progress { + ratify_live_overlay(&mut s, &ratified_languages(&report, !lsif_edges.is_empty())); + } + if made_progress { let _ = s.set_meta("phase_b_commit", &target_sha); // #583: the semantic layer now matches the working tree again. @@ -7472,6 +7551,315 @@ mod tests { ); } + /// RFC-027 Phase 2 gate, Invariant #4: ratifying the live overlay returns + /// the graph to exactly what it was before the overlay existed. + /// + /// ```text + /// graph(G) --overlay--> G' --ratify--> G + /// ``` + /// + /// This is the load-bearing half of the convergence argument. The overlay + /// is allowed to be non-deterministic — it depends on which language server + /// a developer happens to be running — and this is the fence that makes + /// that safe: whatever it contained, retiring it cannot leave a trace. + /// + /// It holds because the overlay is purely **additive**. `put_edge_live` + /// creates edges that were absent and refreshes its own, but never relabels + /// a row another lane wrote, so every row the sweep can delete is one the + /// live lane created. An earlier version relabelled `tree-sitter` rows, + /// and this test is what caught it: the sweep then deleted pre-existing + /// truth instead of returning the graph to it. + /// + /// Fingerprints compare provenance as well as endpoints, so a `live` row + /// left sitting where a ratified one belongs fails here rather than + /// passing on a matching count. + #[test] + fn ratifying_the_overlay_restores_the_pre_overlay_graph() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (tmp, db_path, corpus) = repo_with_a_call(); + let order = tmp.path().join("src/order.ts"); + + // Phase A only, exactly what a save does: content changes, the file's + // outgoing edges are rebuilt from the parse, and the new call site has + // no semantic edge. This is the mid-edit degradation RFC-027 covers. + { + let mut text = std::fs::read_to_string(&order).unwrap(); + text.push_str("\nexport function expressOrder(u: User): void {\n u.save();\n}\n"); + std::fs::write(&order, text).unwrap(); + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + reindex_files(std::slice::from_ref(&order), tmp.path(), &mut store).unwrap(); + } + let degraded = graph_fingerprint(&db_path); + + // Overlay. + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + live_resolve_file(&mut store, &corpus, tmp.path(), &order); + } + let live_rows = travsr_store::SqliteStore::open(&db_path) + .unwrap() + .count_edges_with_provenance("live") + .unwrap(); + assert!( + live_rows > 0, + "precondition: the overlay must be non-empty or this asserts nothing" + ); + assert_ne!( + graph_fingerprint(&db_path), + degraded, + "precondition: the overlay must actually have changed the graph" + ); + + // Ratify with nothing re-derived: the strictest case for the sweep. + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + ratify_live_overlay(&mut store, &["typescript".to_string()]); + } + + assert_eq!( + travsr_store::SqliteStore::open(&db_path) + .unwrap() + .count_edges_with_provenance("live") + .unwrap(), + 0, + "no live edge may survive the run that ratifies it" + ); + assert_eq!( + graph_fingerprint(&db_path), + degraded, + "retiring the overlay must restore the graph exactly, not merely its size" + ); + } + + /// The destructive case the additive rule exists to prevent. + /// + /// An interface edit re-resolves the files that *reference* the edited one + /// (section 6.3). Those files were never re-parsed, so their existing + /// `tree-sitter` edges are still in place. If the overlay relabelled them + /// `live`, the ratification sweep would delete pre-existing truth rather + /// than returning the graph to it, and a caller edge would simply vanish. + /// + /// So: run the overlay over a file that was *not* re-indexed, then ratify, + /// and require the graph to be unchanged throughout. + #[test] + fn the_overlay_cannot_destroy_an_edge_it_did_not_create() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (tmp, db_path, corpus) = repo_with_a_call(); + let order = tmp.path().join("src/order.ts"); + + // order.ts keeps every edge the full index gave it. This is a closure + // re-resolution, not a save. + let before = graph_fingerprint(&db_path); + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + live_resolve_file(&mut store, &corpus, tmp.path(), &order); + } + assert_eq!( + graph_fingerprint(&db_path), + before, + "resolving a file whose edges already exist must change nothing" + ); + + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + ratify_live_overlay(&mut store, &["typescript".to_string()]); + } + assert_eq!( + graph_fingerprint(&db_path), + before, + "ratification must not delete an edge the overlay did not create" + ); + } + + /// The other half: an overlay edge Phase B *does* re-derive is ratified in + /// place and survives the sweep, now carrying the ratified provenance. + /// + /// This is why the sweep can be a blunt delete. By the time it runs, every + /// edge Phase B re-derived has already been relabelled by the ratification + /// write, so what is still marked `live` is exactly what Phase B did not + /// re-derive. + #[test] + fn an_overlay_edge_phase_b_rederives_is_ratified_in_place() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (tmp, db_path, corpus) = repo_with_a_call(); + edit_and_reindex_order(&tmp, &db_path, &corpus); + let overlay: Vec = { + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + live_edges(&store) + }; + assert!(!overlay.is_empty(), "precondition: an overlay edge exists"); + + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + // Stand in for Phase B re-deriving these edges natively. This is the + // same call the daemon makes after a Phase B run. + store + .write_phase_b_batch(&[], &overlay, "tree-sitter") + .unwrap(); + ratify_live_overlay(&mut store, &["typescript".to_string()]); + } + + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + assert_eq!( + store.count_edges_with_provenance("live").unwrap(), + 0, + "the row is no longer live once Phase B has re-derived it" + ); + for e in &overlay { + let survived = store + .iter_edges_from(e.src) + .unwrap() + .into_iter() + .any(|x| x.dst == e.dst && x.kind == e.kind); + assert!( + survived, + "an edge Phase B re-derived must survive ratification, not be swept" + ); + } + } + + /// The sweep is scoped to languages that completed, so a crashed sidecar's + /// overlay survives rather than being discarded with nothing to replace it + /// (#712 partial success advances the marker regardless). + #[test] + fn ratification_spares_a_language_that_did_not_complete() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (tmp, db_path, corpus) = repo_with_a_call(); + edit_and_reindex_order(&tmp, &db_path, &corpus); + let before = travsr_store::SqliteStore::open(&db_path) + .unwrap() + .count_edges_with_provenance("live") + .unwrap(); + assert!(before > 0, "precondition: an overlay exists"); + + // Rust "completed"; TypeScript did not. The TypeScript overlay stays. + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + ratify_live_overlay(&mut store, &["rust".to_string()]); + } + assert_eq!( + travsr_store::SqliteStore::open(&db_path) + .unwrap() + .count_edges_with_provenance("live") + .unwrap(), + before, + "a language whose truth was never re-derived must keep its overlay" + ); + } + + /// `ratified_languages` maps analyzer names onto how nodes are labeled. + #[test] + fn ratified_languages_maps_javascript_and_the_lsif_pass_onto_typescript() { + let js_only = PhaseBReport { + ran: vec!["javascript".to_string()], + ..PhaseBReport::default() + }; + assert_eq!( + ratified_languages(&js_only, false), + vec!["typescript".to_string()], + "JavaScript nodes are labeled typescript, so that is what ratifies" + ); + + // The LSIF pass is TypeScript's and never appears in `ran`. + let nothing_ran = PhaseBReport::default(); + assert_eq!( + ratified_languages(¬hing_ran, true), + vec!["typescript".to_string()] + ); + + // Nothing ran and no LSIF edges: sweep nothing at all. + assert!(ratified_languages(¬hing_ran, false).is_empty()); + + // No duplicate when both signals point at typescript. + let both = PhaseBReport { + ran: vec!["typescript".to_string(), "javascript".to_string()], + ..PhaseBReport::default() + }; + assert_eq!( + ratified_languages(&both, true), + vec!["typescript".to_string()] + ); + } + + /// Append a second caller to `order.ts` and re-index it, the way a save + /// lands: content changes, so `reindex_files` does not skip it on the hash, + /// Phase A rewrites the file's outgoing edges, and the new call site has no + /// semantic edge until something resolves it. + /// + /// Returns the overlay outcome so a caller can assert on it. + fn edit_and_reindex_order( + tmp: &tempfile::TempDir, + db_path: &std::path::Path, + corpus: &str, + ) -> std::path::PathBuf { + let order = tmp.path().join("src/order.ts"); + let mut text = std::fs::read_to_string(&order).unwrap(); + text.push_str("\nexport function expressOrder(u: User): void {\n u.save();\n}\n"); + std::fs::write(&order, text).unwrap(); + let mut store = travsr_store::SqliteStore::open(db_path).unwrap(); + reindex_files(std::slice::from_ref(&order), tmp.path(), &mut store).unwrap(); + live_resolve_file(&mut store, corpus, tmp.path(), &order); + order + } + + /// A two-file TypeScript repo where `order.ts` calls into `user.ts`, + /// indexed. Shared by the RFC-027 Phase 1 and Phase 2 tests. + fn repo_with_a_call() -> (tempfile::TempDir, std::path::PathBuf, String) { + let tmp = tempfile::tempdir().unwrap(); + git_init(tmp.path()); + std::fs::create_dir_all(tmp.path().join("src")).unwrap(); + std::fs::write( + tmp.path().join("src/user.ts"), + "export class User {\n save(): void {}\n}\n", + ) + .unwrap(); + std::fs::write( + tmp.path().join("src/order.ts"), + "import { User } from \"./user\";\nexport function placeOrder(u: User): void {\n u.save();\n}\n", + ) + .unwrap(); + + std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); + init_repo(tmp.path()).unwrap(); + std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); + + let db_path = tmp.path().join(".travsr/graph.db"); + let corpus = travsr_store::SqliteStore::open(&db_path) + .unwrap() + .get_meta("corpus") + .unwrap() + .unwrap_or_default(); + (tmp, db_path, corpus) + } + + /// Every `provenance='live'` edge currently in the store. + /// + /// `all_edges` returns `(src, dst, kind, provenance)` tuples, so rebuild the + /// core `Edge` from them for the ratification write. + fn live_edges(store: &travsr_store::SqliteStore) -> Vec { + store + .all_edges() + .unwrap() + .into_iter() + .filter(|(_, _, _, prov)| prov == "live") + .filter_map(|(src, dst, kind, _)| { + travsr_core::EdgeKind::from_str(&kind).map(|k| travsr_core::Edge::new(src, dst, k)) + }) + .collect() + } + + /// A content fingerprint of the whole graph: every node, and every edge with + /// its provenance, in a stable order. Comparing counts alone would miss a + /// live edge sitting where a ratified one belongs, which is the exact + /// failure the convergence property exists to rule out. + fn graph_fingerprint(db_path: &std::path::Path) -> (Vec, Vec) { + let store = travsr_store::SqliteStore::open(db_path).unwrap(); + ( + store.node_fingerprint().unwrap(), + store.edge_fingerprint().unwrap(), + ) + } + /// Invariant #4, orphan dimension: an incremental edit must leave the graph /// as free of dangling edges as a full index would. /// @@ -7581,57 +7969,35 @@ mod tests { /// The hazard the live lane introduces is an edge to a symbol that no /// longer exists. `reindex_replace` deletes the inbound edges of a symbol /// whose NodeId vanished, so a `live` edge is cleaned up exactly as a - /// `tree-sitter` or `scip` one is — this test is what keeps that true, and + /// `tree-sitter` or `scip` one is. This test is what keeps that true, and /// it is the difference between honest staleness and a fabricated target. #[test] fn a_rename_leaves_no_ghost_live_edge() { let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let tmp = tempfile::tempdir().unwrap(); - git_init(tmp.path()); - std::fs::create_dir_all(tmp.path().join("src")).unwrap(); - + let (tmp, db_path, corpus) = repo_with_a_call(); let user = tmp.path().join("src/user.ts"); - let order = tmp.path().join("src/order.ts"); - std::fs::write(&user, "export class User {\n save(): void {}\n}\n").unwrap(); - std::fs::write( - &order, - "import { User } from \"./user\";\nexport function placeOrder(u: User): void {\n u.save();\n}\n", - ) - .unwrap(); - - std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); - init_repo(tmp.path()).unwrap(); - std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); - - let db_path = tmp.path().join(".travsr/graph.db"); - let corpus = { - let store = travsr_store::SqliteStore::open(&db_path).unwrap(); - store.get_meta("corpus").unwrap().unwrap_or_default() - }; - - // Emit the live overlay for order.ts, then confirm it exists. - { - let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); - live_resolve_file(&mut store, &corpus, tmp.path(), &order); - } - let live_before = count_live_edges(&db_path); + // A real save adds a call site the overlay then resolves, which is the + // only way a live edge exists to be renamed away from. + edit_and_reindex_order(&tmp, &db_path, &corpus); assert!( - live_before > 0, - "precondition: the live lane must have emitted an edge to rename away from" + count_live_edges(&db_path) > 0, + "precondition: a live edge must exist to rename away from" ); // Rename User.save -> User.persist. The old NodeId vanishes, so every - // inbound edge to it — live included — must go with it. + // inbound edge to it, live included, must go with it. std::fs::write(&user, "export class User {\n persist(): void {}\n}\n").unwrap(); { let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); reindex_files(std::slice::from_ref(&user), tmp.path(), &mut store).unwrap(); } - let store = travsr_store::SqliteStore::open(&db_path).unwrap(); - let orphans = store.count_orphans().unwrap(); assert_eq!( - orphans, 0, + travsr_store::SqliteStore::open(&db_path) + .unwrap() + .count_orphans() + .unwrap(), + 0, "a rename must leave no edge pointing at a node that no longer exists" ); } @@ -7641,32 +8007,9 @@ mod tests { #[test] fn a_delete_leaves_no_ghost_live_edge() { let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let tmp = tempfile::tempdir().unwrap(); - git_init(tmp.path()); - std::fs::create_dir_all(tmp.path().join("src")).unwrap(); - + let (tmp, db_path, corpus) = repo_with_a_call(); let user = tmp.path().join("src/user.ts"); - let order = tmp.path().join("src/order.ts"); - std::fs::write(&user, "export class User {\n save(): void {}\n}\n").unwrap(); - std::fs::write( - &order, - "import { User } from \"./user\";\nexport function placeOrder(u: User): void {\n u.save();\n}\n", - ) - .unwrap(); - - std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); - init_repo(tmp.path()).unwrap(); - std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); - - let db_path = tmp.path().join(".travsr/graph.db"); - let corpus = { - let store = travsr_store::SqliteStore::open(&db_path).unwrap(); - store.get_meta("corpus").unwrap().unwrap_or_default() - }; - { - let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); - live_resolve_file(&mut store, &corpus, tmp.path(), &order); - } + edit_and_reindex_order(&tmp, &db_path, &corpus); assert!( count_live_edges(&db_path) > 0, "precondition: a live edge exists" @@ -7678,10 +8021,12 @@ mod tests { reindex_files(std::slice::from_ref(&user), tmp.path(), &mut store).unwrap(); } - let store = travsr_store::SqliteStore::open(&db_path).unwrap(); - let orphans = store.count_orphans().unwrap(); assert_eq!( - orphans, 0, + travsr_store::SqliteStore::open(&db_path) + .unwrap() + .count_orphans() + .unwrap(), + 0, "a deleted file must take its inbound live edges with it" ); } diff --git a/crates/travsr-store/src/lib.rs b/crates/travsr-store/src/lib.rs index 4f7f42e0..2ca190a0 100644 --- a/crates/travsr-store/src/lib.rs +++ b/crates/travsr-store/src/lib.rs @@ -2490,6 +2490,105 @@ impl SqliteStore { .map_err(|e| StoreError::Database(e.to_string())) } + /// Stable content fingerprint of every node, for equality assertions. + /// + /// Ordered by id so two graphs built by different routes compare directly. + pub fn node_fingerprint(&self) -> Result, StoreError> { + (|| -> AnyResult> { + let mut stmt = self + .conn + .prepare( + "SELECT corpus || '|' || path || '|' || signature || '|' || kind \ + FROM nodes ORDER BY id", + ) + .context("node_fingerprint: prepare")?; + let rows = stmt + .query_map([], |r| r.get::<_, String>(0)) + .context("node_fingerprint: query")?; + let mut out = Vec::new(); + for r in rows { + out.push(r.context("node_fingerprint: decode")?); + } + Ok(out) + })() + .map_err(|e| StoreError::Database(e.to_string())) + } + + /// Stable content fingerprint of every edge **including its provenance**. + /// + /// Provenance is in the fingerprint on purpose. The RFC-027 convergence + /// property is not "the same number of edges" but "the same graph", and an + /// un-ratified `live` row sitting where a `scip` row belongs has the same + /// count and the wrong meaning. Counting alone would let exactly the + /// failure this property exists to rule out slip through. + pub fn edge_fingerprint(&self) -> Result, StoreError> { + (|| -> AnyResult> { + let mut stmt = self + .conn + .prepare( + "SELECT src || '|' || dst || '|' || kind || '|' || provenance \ + FROM edges ORDER BY src, dst, kind", + ) + .context("edge_fingerprint: prepare")?; + let rows = stmt + .query_map([], |r| r.get::<_, String>(0)) + .context("edge_fingerprint: query")?; + let mut out = Vec::new(); + for r in rows { + out.push(r.context("edge_fingerprint: decode")?); + } + Ok(out) + })() + .map_err(|e| StoreError::Database(e.to_string())) + } + + /// RFC-027 section 8.3: retire the live overlay for the languages that just + /// completed a Phase B run. + /// + /// Most live edges never reach this. When Phase B re-derives an edge the + /// live lane had already found, the ratification write upserts the same + /// `(src, dst, kind)` row and relabels its provenance, so the edge is + /// ratified *in place*. What is still marked `live` afterwards is exactly + /// the set Phase B did **not** re-derive, which is why deleting it cannot + /// lose a real edge. That is also why the ratification writes must run + /// first, and must not be prevented from overwriting a `live` row. + /// + /// **Scoped, not blanket.** The Phase B completion marker advances whenever + /// *any* language produced results, even when another language's sidecar + /// crashed (#712). A blanket delete would therefore discard the overlay for + /// a language whose truth was never re-derived, taking away precision the + /// developer had a moment earlier and not giving it back until that sidecar + /// is fixed and a later commit runs. Live edges for a crashed language + /// survive, still labeled `live`, which is honest. + /// + /// Keyed on the **src node's** language rather than `edges.language`, which + /// is a derived label reconciled after the fact and defaulted at insert + /// time, so it cannot be trusted as the scoping key. + /// + /// Invariant #4 is unaffected: a clean run has nothing crashed, so every + /// language that has nodes is in `languages` and the sweep is total. + pub fn sweep_live_edges_for_languages( + &mut self, + languages: &[String], + ) -> Result { + if languages.is_empty() { + return Ok(0); + } + let placeholders = std::iter::repeat("?") + .take(languages.len()) + .collect::>() + .join(","); + let sql = format!( + "DELETE FROM edges WHERE provenance = 'live' \ + AND src IN (SELECT id FROM nodes WHERE language IN ({placeholders}))" + ); + self.conn + .execute(&sql, params_from_iter(languages.iter())) + .map(|n| n as u64) + .context("sweeping live edges for ratified languages") + .map_err(|e| StoreError::Database(e.to_string())) + } + /// Scoped orphan sweep for the incremental path: drop edges *owned by* /// `paths` whose destination is absent from `nodes`. /// @@ -3727,22 +3826,28 @@ LIMIT ?4", /// /// Live edges are an ephemeral overlay over the commit-gated semantic graph: /// Tree-sitter detected the reference, the live engine resolved it precisely - /// (unambiguous-lexical or LSP-disambiguated), and commit-time SCIP will - /// ratify and replace it. They sit on the middle rung of the ADR-002 - /// precedence ladder: - /// - /// ```text - /// lsif / scip (compiler-grade, durable) beats - /// live (this lane, ephemeral) beats - /// tree-sitter (heuristic) - /// ``` - /// - /// The `WHERE` on the upsert enforces the ladder from below: an existing - /// `lsif`, `scip`, or `bridge:*` row is left untouched, so a live edge can - /// never demote ratified truth. Re-emitting over an existing `live` row is - /// allowed and idempotent, which matters because `reindex_replace` deletes - /// every outbound edge of a file on each save, so the live engine re-emits - /// for the whole file rather than only for references it thinks are new. + /// (unambiguous-lexical or LSP-disambiguated), and the next Phase B run + /// ratifies it. + /// + /// **The overlay is purely additive: this never relabels an edge that + /// already exists.** That is what makes retiring it safe. The sweep in + /// [`sweep_live_edges_for_languages`] deletes rows, so anything it can + /// reach must be something the live lane itself created — otherwise + /// retiring the overlay would destroy pre-existing truth rather than + /// returning the graph to what it was. + /// + /// The hazard is concrete, not theoretical. An interface edit re-resolves + /// the files that reference the edited one (section 6.3), and those files + /// were *not* re-parsed, so their `tree-sitter` edges are still in place. An + /// upsert that relabelled them `live` would hand them to the sweep, and any + /// one Phase B did not happen to re-derive would be deleted outright. The + /// convergence property test is what surfaced this. + /// + /// So the `ON CONFLICT` only refreshes a row that is *already* `live`, which + /// keeps re-emission idempotent — `reindex_replace` deletes every outbound + /// edge of a file on each save, so the engine re-emits whole-file rather + /// than only for references it believes are new. Every other provenance is + /// left exactly as it was. /// /// This never mints identity (RFC-027 section 8.2): both endpoints must /// already exist as nodes, so the fencing rule and VName uniqueness hold. @@ -3751,8 +3856,8 @@ LIMIT ?4", .execute( "INSERT INTO edges(src, dst, kind, provenance, confidence) VALUES(?1, ?2, ?3, 'live', ?4) \ ON CONFLICT(src, dst, kind) DO UPDATE SET \ - provenance = 'live', confidence = excluded.confidence \ - WHERE edges.provenance IN ('tree-sitter', 'live')", + confidence = excluded.confidence \ + WHERE edges.provenance = 'live'", params![ node_id_to_i64(edge.src), node_id_to_i64(edge.dst), @@ -9719,11 +9824,15 @@ mod tests { assert_eq!(prov(&by_kind, EdgeKind::RefCall), "lsif"); } - /// RFC-027 Phase 0: the `live` provenance rung sits between `tree-sitter` - /// and `lsif`/`scip`. A live edge upgrades a heuristic row and is idempotent - /// over itself, but must never demote compiler-grade truth. + /// RFC-027: the live overlay is purely additive. + /// + /// It creates edges that were absent and refreshes its own, but never + /// relabels a row another lane wrote. That is the property that makes the + /// ratification sweep safe: everything the sweep can delete is something + /// the live lane created, so retiring the overlay returns the graph to what + /// it was instead of destroying pre-existing truth. #[test] - fn live_edge_beats_tree_sitter_and_loses_to_compiler_provenance() { + fn the_live_overlay_is_additive_and_never_relabels_another_lane() { let mut store = SqliteStore::open_in_memory().unwrap(); let a = node_with_path("a.ts", "fn:a"); let b = node_with_path("b.ts", "fn:b"); @@ -9738,25 +9847,15 @@ mod tests { .expect("reader must carry provenance") }; - // tree-sitter -> live: upgrade. - store.put_edge(&e).unwrap(); - assert_eq!(prov(&store), "tree-sitter"); + // Absent -> live: the overlay's actual job. store.put_edge_live(&e).unwrap(); - assert_eq!(prov(&store), "live", "live must upgrade a tree-sitter row"); + assert_eq!(prov(&store), "live", "a missing edge is created as live"); // live -> live: idempotent. reindex_replace wipes a file's outbound // edges on every save, so the engine re-emits whole-file (plan R5). store.put_edge_live(&e).unwrap(); assert_eq!(prov(&store), "live"); - // live -> tree-sitter: must NOT demote. - store.put_edge(&e).unwrap(); - assert_eq!( - prov(&store), - "live", - "a tree-sitter write must not demote a live row" - ); - // live -> lsif: ratification wins. store.put_edge_lsif(&e).unwrap(); assert_eq!(prov(&store), "lsif", "lsif must overwrite live"); @@ -9768,6 +9867,25 @@ mod tests { "lsif", "a live write must never demote lsif/scip" ); + + // tree-sitter -> live: must NOT relabel. This is the one the sweep + // depends on. An interface edit re-resolves files that were never + // re-parsed and still hold their tree-sitter edges; relabelling those + // would hand pre-existing truth to the sweep to delete. + let e2 = Edge::new(a.id, b.id, EdgeKind::DefinesBinding); + store.put_edge(&e2).unwrap(); + store.put_edge_live(&e2).unwrap(); + let ts = store + .iter_edges_from(a.id) + .unwrap() + .into_iter() + .find(|x| x.kind == EdgeKind::DefinesBinding) + .and_then(|x| x.provenance) + .unwrap(); + assert_eq!( + ts, "tree-sitter", + "a live write must leave an existing tree-sitter row untouched" + ); } /// RFC-027: a Phase B write over a `live` row ratifies it. diff --git a/docs/rfcs/RFC-027-live-semantic-resolution.md b/docs/rfcs/RFC-027-live-semantic-resolution.md index 41d0b0df..d3387f18 100644 --- a/docs/rfcs/RFC-027-live-semantic-resolution.md +++ b/docs/rfcs/RFC-027-live-semantic-resolution.md @@ -249,13 +249,23 @@ At commit, ratification rides the **whole-project** Phase B run. (The RFC origin **Most live edges never reach the sweep.** A live edge that Phase B re-derives is ratified *in place*: the ratification write upserts the same `(src, dst, kind)` row and relabels its provenance (`lsif`/`scip`, or `tree-sitter` when the edge came from native leaf-name resolution, which is itself only ever written on a Phase B path). By the time the sweep runs, the rows still marked `live` are exactly those Phase B did **not** re-derive, so deleting them cannot lose a real edge. This is why the ratification writes must not be prevented from overwriting a `live` row. +**The overlay must be purely additive** (confirmed in implementation; this is what makes the sweep safe). Emitting a live edge creates a row that was absent, or refreshes one the live lane already owns. It must **never** relabel a row another lane wrote. Otherwise the sweep — which deletes rows — could reach an edge the overlay did not create, and retiring the overlay would destroy pre-existing truth instead of returning the graph to it. + +The hazard is concrete rather than theoretical: an interface edit re-resolves the files that *reference* the edited one (§6.3), and those files were not re-parsed, so their `tree-sitter` edges are still in place. An upsert that relabelled them would hand them to the sweep, and any one Phase B did not happen to re-derive would vanish. The convergence property below is what surfaced this. + +Stated as an invariant: **every row the sweep can delete is a row the live lane created**, so ratification is a return to the pre-overlay graph rather than a mutation of it. + **The sweep is language-scoped, not blanket.** `made_progress` advances the `phase_b_commit` marker whenever *any* language produced results, even when another language's sidecar crashed (#712). A blanket `DELETE FROM edges WHERE provenance='live'` would therefore discard live edges for a language whose SCIP truth was never re-derived in that run. The sweep is restricted to the languages that completed, keyed on the src node's language. Live edges for a crashed language survive, still labeled `live`, which is honest. Invariant #4 is unaffected: a clean run has nothing crashed, so the scoped sweep is total, and that is the case the convergence property below asserts. **Property (must hold, property-tested):** ``` -graph(full_reindex @ commit_C) == graph(incremental + live_overlay, ratified @ commit_C) +graph(G) --overlay--> G' --ratify--> graph(G) ``` -Because every live edge is deleted and replaced by SCIP at commit, the *committed* graph contains zero live edges and is identical to a from-scratch reindex. Invariant #4 is discharged: the live overlay never survives a commit unratified. +Ratifying the overlay returns the graph to exactly what it was before the overlay existed, provenance included. This is the form the property actually takes in code, and it is stronger than a count comparison: a `live` row sitting where a ratified one belongs has the same count and the wrong meaning. + +It composes with in-place ratification to give the original statement. An overlay edge Phase B re-derives is relabelled and survives; one it does not re-derive is swept; either way the committed graph carries zero live edges and is what the deterministic pipeline produces. Invariant #4 is discharged: the live overlay never survives the run that ratifies it. + +Note the property is asserted against the *pre-overlay* graph rather than against a from-scratch reindex. Those differ for an unrelated, pre-existing reason — Phase B is whole-project and commit-gated (`DEBT(travsr-25)`), so an incremental Phase A pass legitimately lacks semantic edges a full index would have. Asserting equality with a full reindex would be asserting something RFC-027 does not claim and does not fix. ### 8.4 Determinism fence Live resolution depends on the installed server version and is therefore non-deterministic across environments. This is **fenced**: non-determinism exists only in the ephemeral overlay, between commits. The durable (committed) graph is SCIP-pinned and deterministic. The overlay was never part of the deterministic ground-truth contract, so the fence is sound. Live edges MUST be visibly distinguishable at query time (§10) so no consumer mistakes the overlay for ratified truth. From 03abb53a66b42f7a3280c99e51731b8e11549981 Mon Sep 17 00:00:00 2001 From: Abhishek Date: Fri, 28 Aug 2026 05:26:38 +0530 Subject: [PATCH 09/23] [travsr-mcp] RFC-027 Phase 3: make the live overlay legible at the MCP surface The overlay is only safe to ship if a consumer can tell it apart from ratified truth. Three additive changes, none of which alter what an existing caller sees except by adding information. Per-edge marking. get_callers appends a short marker to each un-ratified row, and get_graph_json emits a provenance field on every edge. Only live is called out: every other provenance is ratified, and tagging all of them would be noise on the common case. The live_overlay note. Attached through two seams, because a prose note appended to a JSON body would break JSON.parse: prose tools get it via append_read_notes, JSON tools as a signals array item via read_note_signals. A test asserts the two agree, since it would otherwise be easy for one surface to gain a note the other never learns about. The note reports both halves of the overlay, and that is deliberate. Live edges are references the lane resolved ahead of the commit, pending references are ones it refused to guess at. Reporting only the first would read as pure upside and hide the abstentions that are the other half of a fail-closed lane. It stays silent when both are zero, so it never fires on a clean tree. The provenance filter. RFC section 10 called this an "existing provenance filter"; it did not exist, only kind_filter did. Added to both get_graph_json schemas. Empty is the default and returns everything, so a caller that ignores it keeps seeing the fresher graph. "ratified" excludes the overlay for a consumer that needs only what the commit-gated pipeline has confirmed, and an exact provenance name selects one lane. A word rather than a != expression: the meaningful question a consumer has is "confirmed, or everything", and a filter grammar would invite queries the store cannot answer cheaply. An unknown value matches nothing rather than everything, so a typo returns an obviously empty graph instead of silently dropping the constraint a consumer added precisely because it needed ground truth. Gate: cargo test -p travsr-core -p travsr-store -p travsr-ipc -p travsr-mcp -p travsr-daemon, 21 suites, 1219 passed, 0 failed. Five new tests. Verified over a real MCP stdio session against a repo with an uncommitted edit: get_callers marks both un-ratified callers inline and appends the note; get_graph_json returns provenance=live on both call edges; provenance=ratified returns none of them; provenance=live returns exactly those two. Not included: the section 12 precision meter, which is Phase 3's remaining item. Until it exists the >= 0.99 per-language gate is unenforced, and Phase 0's "measured precision on fixtures" is likewise still outstanding. --- crates/travsr-mcp/src/server.rs | 10 + crates/travsr-mcp/src/tools.rs | 322 +++++++++++++++++- crates/travsr-store/src/lib.rs | 16 + docs/rfcs/RFC-027-live-semantic-resolution.md | 6 +- 4 files changed, 334 insertions(+), 20 deletions(-) diff --git a/crates/travsr-mcp/src/server.rs b/crates/travsr-mcp/src/server.rs index dc2518df..17a075b4 100644 --- a/crates/travsr-mcp/src/server.rs +++ b/crates/travsr-mcp/src/server.rs @@ -253,6 +253,9 @@ fn handle_tool_call( // #319 P3: LOD repo-map overview mode + package drill path_prefix. let mode = args["mode"].as_str().unwrap_or(""); let path_prefix = args["path_prefix"].as_str().unwrap_or(""); + // RFC-027 section 10: optional, and absent means "everything", so an + // existing caller keeps seeing the fresher graph unchanged. + let provenance = args["provenance"].as_str().unwrap_or(""); tools::get_graph_json( store, &tools::GraphJsonParams { @@ -263,6 +266,7 @@ fn handle_tool_call( token_budget, mode, path_prefix, + provenance, }, ) } @@ -497,6 +501,7 @@ pub fn tools_list() -> serde_json::Value { "direction": { "type": "string", "enum": ["deps", "callers", "both"], "description": "Edge direction. Default: both" }, "depth": { "type": "integer", "minimum": 1, "maximum": 4, "description": "BFS depth. Default: 2" }, "kind_filter": { "type": "string", "enum": ["file", ""], "description": "Restrict nodes to a specific kind. 'file' returns only file nodes and imports edges (project module map). Default: empty (all kinds)." }, + "provenance": { "type": "string", "enum": ["", "ratified", "tree-sitter", "lsif", "scip", "live"], "description": "Restrict edges by how they were derived. Default empty returns everything, including 'live' edges resolved from uncommitted edits and not yet ratified. Use 'ratified' to exclude those and see only what the commit-gated pipeline has confirmed. Every edge in the response carries its own 'provenance' field." }, "token_budget": { "type": "integer", "description": "Cap the payload to roughly this many tokens (0 or omitted = unlimited). Truncation is reported via truncated_by_budget." }, "mode": { "type": "string", "enum": ["", "overview"], "description": "'overview' returns directory-level component tiles (each with file_count and dependents) plus cross-component dependency edges from the resolved graph, ranked by how depended-upon each component is. Combine with path_prefix to drill into a component." }, "path_prefix": { "type": "string", "description": "When mode='overview', scope to files under this path prefix (e.g. 'src/components/'). Returns file nodes inside the prefix plus external package nodes for cross-boundary dependencies." } @@ -886,6 +891,9 @@ fn handle_tool_call_global( let kind_filter = args["kind_filter"].as_str().unwrap_or(""); let mode = args["mode"].as_str().unwrap_or(""); let path_prefix = args["path_prefix"].as_str().unwrap_or(""); + // RFC-027 section 10: optional, and absent means "everything", so an + // existing caller keeps seeing the fresher graph unchanged. + let provenance = args["provenance"].as_str().unwrap_or(""); tools::get_graph_json_global( repos, repo_arg, @@ -897,6 +905,7 @@ fn handle_tool_call_global( token_budget: 0, mode, path_prefix, + provenance, }, ) } @@ -1142,6 +1151,7 @@ pub fn tools_list_global() -> serde_json::Value { "direction": { "type": "string", "enum": ["deps", "callers", "both"], "description": "Edge direction. Default: both" }, "depth": { "type": "integer", "minimum": 1, "maximum": 4, "description": "BFS depth. Default: 2" }, "kind_filter": { "type": "string", "enum": ["file", ""], "description": "Restrict nodes to a specific kind. 'file' returns only file nodes and imports edges (project module map). Default: empty (all kinds)." }, + "provenance": { "type": "string", "enum": ["", "ratified", "tree-sitter", "lsif", "scip", "live"], "description": "Restrict edges by how they were derived. Default empty returns everything, including 'live' edges resolved from uncommitted edits and not yet ratified. Use 'ratified' to exclude those and see only what the commit-gated pipeline has confirmed. Every edge in the response carries its own 'provenance' field." }, "repo": { "type": "string", "description": "Repo name (run repos_list to discover). Always supply to avoid cross-repo noise; omit only when explicitly querying across all repos." }, "mode": { "type": "string", "enum": ["", "overview"], "description": "'overview' returns directory-level component tiles (each with file_count and dependents) plus cross-component dependency edges from the resolved graph." }, "path_prefix": { "type": "string", "description": "When mode='overview', scope to files under this path prefix. Returns file nodes inside the prefix plus external package nodes for cross-boundary dependencies." } diff --git a/crates/travsr-mcp/src/tools.rs b/crates/travsr-mcp/src/tools.rs index 893ef93b..e77d8fd1 100644 --- a/crates/travsr-mcp/src/tools.rs +++ b/crates/travsr-mcp/src/tools.rs @@ -249,6 +249,44 @@ pub fn phase_b_degraded_note(store: &SqliteStore) -> Option { } } +/// RFC-027 section 10: tell a reader that this answer includes un-ratified +/// edges, and where the remaining gaps are. +/// +/// Two numbers, because they mean opposite things and an agent needs both: +/// `live` edges are references the overlay *did* resolve ahead of the commit +/// (extra freshness, not yet confirmed), while `pending` references are ones +/// nothing could resolve (a known gap, deliberately not guessed at). Reporting +/// only a count of live edges would read as pure upside and hide the abstentions +/// that are the other half of a fail-closed lane. +/// +/// `None` when the overlay is empty and nothing is pending, which is the state +/// of any repo with no uncommitted edits — so the note never fires on a clean +/// tree and costs a reader nothing. +fn live_overlay_note(store: &SqliteStore) -> Option { + let live = store.count_edges_with_provenance("live").ok().unwrap_or(0); + let pending = store.pending_ref_count().ok().unwrap_or(0); + if live == 0 && pending == 0 { + return None; + } + let mut parts = Vec::new(); + if live > 0 { + parts.push(format!( + "{live} edge{} resolved from uncommitted edits and not yet ratified", + if live == 1 { "" } else { "s" } + )); + } + if pending > 0 { + parts.push(format!( + "{pending} reference{} detected but not resolved", + if pending == 1 { "" } else { "s" } + )); + } + Some(format!( + "[note: live overlay active \u{2014} {}. These resolve deterministically at the next commit; filter to provenance != live for ratified truth only.]", + parts.join("; ") + )) +} + /// Languages whose Phase B never produced call/ref edges on the last run, per /// the `phase_b_warnings` meta (#755). /// @@ -421,6 +459,7 @@ fn append_read_notes(store: &SqliteStore, body: String, head: Option<&str>) -> S let mut out = body; for note in [ phase_b_degraded_note(store), + live_overlay_note(store), head.and_then(|h| head_index_mismatch_note(h, &stored)), ] .into_iter() @@ -488,6 +527,10 @@ fn read_note_signals(store: &SqliteStore, head: Option<&str>) -> Vec String { let Some(src_node) = node_map.get(&edge.src) else { continue; }; + // RFC-027 section 10: mark an un-ratified edge so a reader never takes + // the live overlay for committed truth. Only `live` is called out — + // every other provenance is ratified, and tagging all of them would be + // noise on the common case. + let live = live_marker(edge); // True call edge: try to expand into exact call-site lines. if tag == "[call]" { if let Some(root) = &repo_root { @@ -630,7 +678,7 @@ fn get_callers_raw(store: &SqliteStore, symbol: &str) -> String { if !sites.is_empty() { for line in sites { lines.push(format!( - "{tag} {} ({}) \u{2014} {}:{}", + "{tag} {} ({}) \u{2014} {}:{}{live}", display_label(src_node), src_node.kind, src_node.vname.path, @@ -645,7 +693,7 @@ fn get_callers_raw(store: &SqliteStore, symbol: &str) -> String { // report the node's definition line as before. let loc = src_node.line.map(|l| format!(":{l}")).unwrap_or_default(); lines.push(format!( - "{tag} {} ({}) \u{2014} {}{}", + "{tag} {} ({}) \u{2014} {}{}{live}", display_label(src_node), src_node.kind, src_node.vname.path, @@ -661,6 +709,22 @@ fn get_callers_raw(store: &SqliteStore, symbol: &str) -> String { lines.join("\n") } +/// RFC-027 section 10: the suffix marking an edge as part of the live overlay. +/// +/// Empty for every ratified provenance, so the common case reads exactly as it +/// did before. A `live` edge is resolved but not yet ratified — precise enough +/// to act on, and honest that the commit-gated pipeline has not confirmed it +/// yet. Consumers that need ground truth filter it out; consumers that ignore +/// provenance simply see a fresher graph, which is the additive default +/// section 10 asks for. +fn live_marker(edge: &travsr_core::Edge) -> &'static str { + if edge.provenance.as_deref() == Some("live") { + " [live: resolved from your uncommitted edit, not yet ratified]" + } else { + "" + } +} + /// Extract the bare symbol name from a stored signature for textual call-site /// matching: strips the `kind:` prefix and any scope qualifier. Examples: /// `fn:SyncPod` → `SyncPod`, `method:Foo#charge` → `charge`, @@ -6246,6 +6310,46 @@ pub struct GraphJsonParams<'a> { pub token_budget: usize, pub mode: &'a str, pub path_prefix: &'a str, + /// RFC-027 section 10: restrict edges by how they were derived. + /// + /// `""` (the default) returns everything, so an existing caller sees the + /// fresher graph and nothing changes for it. `"ratified"` excludes the live + /// overlay, for a consumer that needs only what the deterministic pipeline + /// has confirmed. Any other value names a single provenance exactly + /// (`tree-sitter`, `lsif`, `scip`, `live`). + /// + /// A word rather than a `!=` expression: the meaningful question a consumer + /// has is "confirmed, or everything", and spelling that as a filter grammar + /// would invite queries the store cannot answer cheaply. + pub provenance: &'a str, +} + +impl Default for GraphJsonParams<'_> { + fn default() -> Self { + Self { + query: "", + direction: "both", + depth: 2, + kind_filter: "", + token_budget: 0, + mode: "", + path_prefix: "", + provenance: "", + } + } +} + +/// Whether an edge with `provenance` passes the caller's filter. +/// +/// Unknown filter values match nothing rather than everything: a typo should +/// return an obviously empty graph, not silently ignore the constraint a +/// consumer added precisely because it needed ground truth. +fn provenance_allowed(filter: &str, provenance: &str) -> bool { + match filter { + "" => true, + "ratified" => provenance != "live", + exact => provenance == exact, + } } /// BFS from seed node(s) matching `query`, respecting `direction` and `depth`. @@ -6261,6 +6365,7 @@ pub fn get_graph_json(store: &SqliteStore, params: &GraphJsonParams<'_>) -> Stri token_budget, mode, path_prefix, + provenance, } = params; if !matches!(*mode, "" | "overview") { tracing::warn!("get_graph_json rejected unknown mode: {mode}"); @@ -6298,6 +6403,7 @@ pub fn get_graph_json(store: &SqliteStore, params: &GraphJsonParams<'_>) -> Stri depth, kind_filter, *token_budget, + provenance, head.as_deref(), ) } @@ -6626,6 +6732,7 @@ fn strip_native_kind_prefix(label: &str) -> &str { .unwrap_or(label) } +#[allow(clippy::too_many_arguments)] fn get_graph_json_raw( store: &SqliteStore, query: &str, @@ -6633,6 +6740,8 @@ fn get_graph_json_raw( depth: u8, kind_filter: &str, token_budget: usize, + // RFC-027 section 10: edge provenance filter. `""` returns everything. + provenance: &str, // #645/#661: the caller's live short HEAD for the index/HEAD drift signal. // Injected (not read from LAUNCH_CWD here) so the global aggregator can pass // *each* repo's own HEAD rather than the one workspace's — see @@ -6906,30 +7015,36 @@ fn get_graph_json_raw( ) { // First pass: dedup via edge_seen, enqueue new visits. // Collect (dst_id, kind_s) only for edges that produce JSON output. - let mut new_edges: Vec<(NodeId, &str)> = Vec::new(); - for (kind, next_id, child_expand, _incoming, _provenance) in &nexts { + let mut new_edges: Vec<(NodeId, &str, &str)> = Vec::new(); + for (kind, next_id, child_expand, _incoming, edge_prov) in &nexts { let kind_s = edge_kind_str(kind); + if !provenance_allowed(provenance, edge_prov) { + continue; + } if edge_seen.insert((current_id, *next_id, kind_s)) { - new_edges.push((*next_id, kind_s)); + new_edges.push((*next_id, kind_s, edge_prov.as_str())); } if visited.insert(*next_id) { queue.push_back((*next_id, hop + 1, *child_expand)); } } // Batch-fetch dst nodes, then emit JSON edges in original order. - let dst_ids: Vec = new_edges.iter().map(|(id, _)| *id).collect(); + let dst_ids: Vec = new_edges.iter().map(|(id, _, _)| *id).collect(); let node_map: HashMap = store .get_nodes(&dst_ids) .unwrap_or_default() .into_iter() .map(|n| (n.id, n)) .collect(); - for (dst_id, kind_s) in &new_edges { + for (dst_id, kind_s, provenance) in &new_edges { if let Some(dst) = node_map.get(dst_id) { + // RFC-027 section 10: renderers and agents need to tell + // the un-ratified overlay from committed truth. edges_out.push(serde_json::json!({ - "source": node_json_id(&node), - "target": node_json_id(dst), - "kind": kind_s, + "source": node_json_id(&node), + "target": node_json_id(dst), + "kind": kind_s, + "provenance": provenance, })); } } @@ -6945,30 +7060,34 @@ fn get_graph_json_raw( hop == 0, ) { // First pass: dedup via edge_seen, enqueue new visits. - let mut new_edges: Vec<(NodeId, &str)> = Vec::new(); - for (kind, next_id, child_expand, _incoming, _provenance) in &nexts { + let mut new_edges: Vec<(NodeId, &str, &str)> = Vec::new(); + for (kind, next_id, child_expand, _incoming, edge_prov) in &nexts { let kind_s = edge_kind_str(kind); + if !provenance_allowed(provenance, edge_prov) { + continue; + } if edge_seen.insert((*next_id, current_id, kind_s)) { - new_edges.push((*next_id, kind_s)); + new_edges.push((*next_id, kind_s, edge_prov.as_str())); } if visited.insert(*next_id) { queue.push_back((*next_id, hop + 1, *child_expand)); } } // Batch-fetch src nodes, then emit JSON edges in original order. - let src_ids: Vec = new_edges.iter().map(|(id, _)| *id).collect(); + let src_ids: Vec = new_edges.iter().map(|(id, _, _)| *id).collect(); let node_map: HashMap = store .get_nodes(&src_ids) .unwrap_or_default() .into_iter() .map(|n| (n.id, n)) .collect(); - for (src_id, kind_s) in &new_edges { + for (src_id, kind_s, provenance) in &new_edges { if let Some(src) = node_map.get(src_id) { edges_out.push(serde_json::json!({ - "source": node_json_id(src), - "target": node_json_id(&node), - "kind": kind_s, + "source": node_json_id(src), + "target": node_json_id(&node), + "kind": kind_s, + "provenance": provenance, })); } } @@ -7057,6 +7176,7 @@ pub fn get_graph_json_global( token_budget: _, mode, path_prefix, + provenance, } = params; if *mode == "overview" { if !path_prefix.is_empty() { @@ -7118,6 +7238,7 @@ pub fn get_graph_json_global( depth, kind_filter, 0, + provenance, head.as_deref(), ); let parsed: serde_json::Value = match serde_json::from_str(&raw) { @@ -8900,6 +9021,7 @@ mod tests { direction: "both", depth: 1, kind_filter: "", + provenance: "", token_budget: 0, mode: "", path_prefix: "", @@ -8922,6 +9044,7 @@ mod tests { direction: "both", depth: 1, kind_filter: "file", + provenance: "", token_budget: 0, mode: "", path_prefix: "", @@ -8968,6 +9091,7 @@ mod tests { direction: "deps", depth: 2, kind_filter: "", + provenance: "", token_budget: 0, mode: "", path_prefix: "", @@ -8982,6 +9106,7 @@ mod tests { direction: "deps", depth: 2, kind_filter: "", + provenance: "", token_budget: 30, mode: "", path_prefix: "", @@ -9046,6 +9171,7 @@ mod tests { direction: "both", depth: 2, kind_filter: "", + provenance: "", token_budget: 0, mode: "overview", path_prefix: "", @@ -9106,6 +9232,7 @@ mod tests { direction: "both", depth: 2, kind_filter: "", + provenance: "", token_budget: 0, mode: "overview", path_prefix: "", @@ -9149,6 +9276,7 @@ mod tests { direction: "both", depth: 2, kind_filter: "", + provenance: "", token_budget: 0, mode: "overview", path_prefix: "pkg/a/", @@ -9220,6 +9348,7 @@ mod tests { direction: "both", depth: 2, kind_filter: "", + provenance: "", token_budget: 0, mode: "overview", path_prefix: "pkg/a/", @@ -9246,6 +9375,7 @@ mod tests { direction: "both", depth: 2, kind_filter: "", + provenance: "", token_budget: 0, mode: "badmode", path_prefix: "", @@ -9264,6 +9394,7 @@ mod tests { direction: "both", depth: 2, kind_filter: "", + provenance: "", token_budget: 0, mode: "overview", path_prefix: "../etc/passwd", @@ -9521,6 +9652,7 @@ mod tests { direction: "callers", depth: 2, kind_filter: "", + provenance: "", token_budget: 0, mode: "", path_prefix: "", @@ -9577,6 +9709,7 @@ mod tests { direction: "deps", depth: 2, kind_filter: "", + provenance: "", token_budget: 0, mode: "", path_prefix: "", @@ -11742,6 +11875,155 @@ mod snippet_tests { assert!(out.contains("chk1111"), "head note: {out}"); } + // ── RFC-027 section 10: the live overlay is legible, never silent ──────── + + /// The note never fires on a clean tree, so it costs an ordinary reader + /// nothing, and fires as soon as either half of the overlay is non-empty. + #[test] + fn the_live_overlay_note_fires_only_when_there_is_an_overlay() { + let mut store = travsr_store::SqliteStore::open_in_memory().unwrap(); + store.set_meta("last_commit", "idx0000").unwrap(); + store.set_meta("phase_b_commit", "idx0000").unwrap(); + assert!( + live_overlay_note(&store).is_none(), + "a repo with no uncommitted edits must get no note" + ); + + let a = node_with("fn:a", "function", "a.ts"); + let b = node_with("fn:b", "function", "b.ts"); + store.put_node(&a).unwrap(); + store.put_node(&b).unwrap(); + store + .put_edge_live(&travsr_core::Edge::new( + a.id, + b.id, + travsr_core::EdgeKind::RefCall, + )) + .unwrap(); + + let note = live_overlay_note(&store).expect("an overlay must be announced"); + assert!(note.contains("1 edge resolved"), "singular form: {note}"); + assert!( + note.contains("provenance != live"), + "a reader must be told how to get ratified-only truth: {note}" + ); + } + + /// Both halves are reported, because they mean opposite things: live edges + /// are extra freshness, pending references are known gaps. Reporting only + /// the first would read as pure upside and hide the abstentions. + #[test] + fn the_live_overlay_note_reports_abstentions_as_well_as_resolutions() { + let mut store = travsr_store::SqliteStore::open_in_memory().unwrap(); + let a = node_with("fn:a", "function", "a.ts"); + store.put_node(&a).unwrap(); + store + .replace_ref_resolution_states( + &a.vname.corpus, + "a.ts", + &[(a.id, 3, 0, "save".to_string(), "pending")], + ) + .unwrap(); + + let note = live_overlay_note(&store).expect("a pending reference must be announced"); + assert!( + note.contains("1 reference detected but not resolved"), + "the abstention must be visible: {note}" + ); + } + + /// The overlay marker is attached per edge, and only to un-ratified ones. + #[test] + fn only_a_live_edge_is_marked_in_caller_output() { + let ratified = travsr_core::Edge::new( + travsr_core::NodeId(1), + travsr_core::NodeId(2), + travsr_core::EdgeKind::RefCall, + ); + assert_eq!( + live_marker(&ratified), + "", + "an unlabelled edge is not marked" + ); + + let mut ts = ratified.clone(); + ts.provenance = Some("tree-sitter".to_string()); + assert_eq!(live_marker(&ts), "", "ratified provenance is not marked"); + + let mut live = ratified.clone(); + live.provenance = Some("live".to_string()); + assert!( + live_marker(&live).contains("not yet ratified"), + "a live edge must say so" + ); + } + + /// The filter defaults to returning everything, so an existing caller keeps + /// the fresher graph; `ratified` is the opt-in for ground truth only. + #[test] + fn the_provenance_filter_defaults_to_everything() { + assert!(provenance_allowed("", "live")); + assert!(provenance_allowed("", "scip")); + + assert!(!provenance_allowed("ratified", "live")); + assert!(provenance_allowed("ratified", "tree-sitter")); + assert!(provenance_allowed("ratified", "scip")); + + assert!(provenance_allowed("live", "live")); + assert!(!provenance_allowed("live", "scip")); + + // An unknown filter matches nothing rather than everything: a typo must + // produce an obviously empty graph, not silently drop the constraint a + // consumer added because it needed ground truth. + assert!(!provenance_allowed("ratifed", "scip")); + } + + /// The prose and JSON surfaces must announce the overlay identically. They + /// are separate seams (`append_read_notes` vs `read_note_signals`) because a + /// prose note appended to a JSON body would break `JSON.parse`, and it would + /// be easy for one to gain a note the other never learns about. + #[test] + fn both_surfaces_announce_the_live_overlay() { + let mut store = travsr_store::SqliteStore::open_in_memory().unwrap(); + store.set_meta("last_commit", "idx0000").unwrap(); + store.set_meta("phase_b_commit", "idx0000").unwrap(); + let a = node_with("fn:a", "function", "a.ts"); + let b = node_with("fn:b", "function", "b.ts"); + store.put_node(&a).unwrap(); + store.put_node(&b).unwrap(); + store + .put_edge_live(&travsr_core::Edge::new( + a.id, + b.id, + travsr_core::EdgeKind::RefCall, + )) + .unwrap(); + + let prose = append_read_notes(&store, "body".to_string(), None); + assert!(prose.contains("live overlay active"), "prose: {prose}"); + + let signals = read_note_signals(&store, None); + assert!( + signals + .iter() + .any(|s| s.as_str().unwrap_or("").contains("live overlay active")), + "json signals: {signals:?}" + ); + + // And the JSON body still parses with the signal embedded. + let out = serde_json::json!({ "nodes": [], "edges": [], "signals": signals }); + let text = serde_json::to_string(&out).unwrap(); + serde_json::from_str::(&text).expect("body must stay valid JSON"); + } + + /// Build a node for the overlay tests. + fn node_with(sig: &str, kind: &str, path: &str) -> travsr_core::Node { + travsr_core::Node::new( + travsr_core::VName::new("test", "", path, "typescript", sig), + kind, + ) + } + // ── #661 WS-D: head-only note on the deterministic path:line tools ──────── #[test] @@ -11889,6 +12171,7 @@ mod snippet_tests { direction: "both", depth: 2, kind_filter: "", + provenance: "", token_budget: 0, mode: "", path_prefix: "", @@ -11917,6 +12200,7 @@ mod snippet_tests { direction: "both", depth: 2, kind_filter: "", + provenance: "", token_budget: 0, mode: "", path_prefix: "", @@ -11937,6 +12221,7 @@ mod snippet_tests { direction: "both", depth: 1, kind_filter: "", + provenance: "", token_budget: 0, mode: "", path_prefix: "", @@ -12152,6 +12437,7 @@ mod snippet_tests { direction: "both", depth: 1, kind_filter: "file", + provenance: "", token_budget: 0, mode: "", path_prefix: "", diff --git a/crates/travsr-store/src/lib.rs b/crates/travsr-store/src/lib.rs index 2ca190a0..4c5770ec 100644 --- a/crates/travsr-store/src/lib.rs +++ b/crates/travsr-store/src/lib.rs @@ -4930,6 +4930,22 @@ LIMIT ?4", .map_err(|e| StoreError::Database(e.to_string())) } + /// RFC-027 section 10: how many references are currently unresolved. + /// + /// The count half of [`pending_refs_in_file`], for the envelope note where + /// only the magnitude is wanted. + pub fn pending_ref_count(&self) -> Result { + self.conn + .query_row( + "SELECT count(*) FROM ref_resolution_state WHERE state = 'pending'", + [], + |row| row.get::<_, i64>(0), + ) + .map(|n| n as u64) + .context("counting pending references") + .map_err(|e| StoreError::Database(e.to_string())) + } + /// RFC-027 section 8.3: clear pending rows that Phase B has since resolved. /// /// Run at ratification. A reference whose enclosing node now has an diff --git a/docs/rfcs/RFC-027-live-semantic-resolution.md b/docs/rfcs/RFC-027-live-semantic-resolution.md index d3387f18..707a9f15 100644 --- a/docs/rfcs/RFC-027-live-semantic-resolution.md +++ b/docs/rfcs/RFC-027-live-semantic-resolution.md @@ -315,9 +315,11 @@ References detected by Tree-sitter but not yet edge-resolved carry a `ref_resolu The live overlay must be *legible* to consumers, never silently blended into ratified truth: - `get_callers`, `find_references`, `get_blast_radius`, `get_graph_json` gain an optional per-edge `provenance` field, and `live` edges are labeled. Two corrections from implementation: per-edge provenance was **not** "already present" at the MCP surface (`DEBT(travsr-75)` — core `Edge` carried no provenance field, so every BFS-traversed edge reported `tree-sitter` regardless of its stored row), and there is **no** existing `provenance` filter (only `kind_filter`). Threading provenance through `Edge` and the store readers is a prerequisite, landed separately; the filter argument is a small additive schema edit. -- Responses that include a dirty region append a freshness note in the `` envelope: `live_overlay: { dirty_files: N, pending_refs: M }`, so an agent knows the answer includes un-ratified edges and where the gaps are. +- Responses that include a live overlay carry a freshness note, so an agent knows the answer includes un-ratified edges *and* where the gaps are. Both halves are reported, because they mean opposite things: resolved-but-un-ratified edges are extra freshness, while `pending` references are known gaps the lane deliberately refused to guess at. A count of live edges alone would read as pure upside and hide the abstentions that are the other half of a fail-closed lane. + + Delivered as two seams rather than one (confirmed in implementation): prose tools append the note to the body, and JSON tools carry it as a `signals` array item, because appending a `[note: …]` string to a JSON body would break `JSON.parse`. Both are asserted to agree by test. Per-edge, `get_callers` marks each un-ratified row inline and `get_graph_json` emits a `provenance` field on every edge. - **No new MCP tool.** This is a data-quality/provenance annotation on existing tools, not a new contract. MCP-as-only-interface (Invariant #6) is unaffected. -- Default behavior is additive: consumers that ignore provenance see a *fresher* graph; consumers that require ground truth filter to `provenance != live`. +- Default behavior is additive: consumers that ignore provenance see a *fresher* graph; consumers that require ground truth pass `provenance: "ratified"`. The filter is a word rather than a `!=` expression — the meaningful question a consumer has is "confirmed, or everything", and a filter grammar would invite queries the store cannot answer cheaply. An unknown value matches nothing rather than everything, so a typo returns an obviously empty graph instead of silently dropping the constraint a consumer added precisely because it needed ground truth. --- From 7d7e66ce715a3c71128e3983df45df756739a80b Mon Sep 17 00:00:00 2001 From: Abhishek Date: Fri, 28 Aug 2026 05:50:42 +0530 Subject: [PATCH 10/23] [travsr-daemon] RFC-027 section 12: continuous precision meter "Is the live lane worse than nothing?" is now answered empirically. Ground truth is on tap every commit, so each Phase B run scores what the lane claimed against what Phase B derived, and records the result where the per-language shipping gate can read it. Precision, not agreement. The two are not the same here because the lanes have deliberately opposite policies: the live lane is precision-first and abstains on ambiguity, while resolve_unresolved_calls is explicitly recall-biased ("overconnection is safe, all matches are emitted, PPR damping absorbs the noise"). The live lane therefore emits a legitimate subset, and scoring agreement would penalise it for exactly the abstentions section 8.1 requires. Verification is at call-site line granularity, joining each claim to the edge_sites row Phase B wrote for the same (src, line). Anything coarser is not safe to gate on: a function that calls several things would let a mis-targeted claim match some other call's correct answer and score as agreement. An optimistic meter is worse than none, because it clears a bar the lane has not met. Three buckets, not two. A claim Phase B left no evidence for is unverifiable, not wrong: Phase B has recall gaps of its own, and code can change between the edit and the commit. Precision is reported over the verified subset with coverage beside it, because precision alone would let "1.0 over two of four hundred claims" read as a passing grade. A sample with nothing verifiable reports no precision rather than a perfect one, so an empty measurement cannot clear the gate. Migration v24 adds ref_resolution_state.resolved_dst. The edges table cannot answer what the lane claimed: by ratification a re-derived live edge has been relabelled and an unratified one is about to be swept, so the claim has to be kept independently of the edge's fate. The meter runs at ratification, after the Phase B writes and before the sweep. Not stylistic: reindex_replace deletes the edited file's edge_sites on save, so between the save and the next Phase B run there is no call-site evidence at all. Measuring earlier yields no number rather than a pessimistic one, which is how the first version of the fixture gate came out vacuous at coverage 0. travsr status surfaces the cumulative reading, since a gate needs a value a human can read rather than a log line that scrolled away. Disagreements are also logged individually at warn: one wrong edge is the failure this design exists to avoid, and it should be visible when it happens rather than averaged into a ratio. Gate: measured_live_precision_clears_the_shipping_gate drives a fixture through save, overlay, commit and a real Phase B run, then asserts coverage > 0 (so the gate cannot pass vacuously), zero disagreements, and precision >= 0.99. Measured 1.0000 at coverage 1.0. Verified it catches a bad lane by injecting a mis-resolution: the gate failed with agree 0, disagree 2. cargo test -p travsr-core -p travsr-store -p travsr-ipc -p travsr-mcp -p travsr-daemon -p travsr-cli: 32 suites, 1534 passed, 0 failed. Verified end to end through the daemon: 2 live claims mid-edit with no reading yet, then after commit and Phase B, 0 live edges and `travsr status` reporting "live lane: precision 1.0000 over 2/2 verifiable claims (0 disagreed with semantic analysis)". --- crates/travsr-cli/src/status.rs | 97 +++++++ crates/travsr-daemon/src/lib.rs | 268 ++++++++++++++++++ crates/travsr-daemon/src/live_resolve.rs | 36 ++- crates/travsr-mcp/src/tools.rs | 9 +- crates/travsr-store/src/lib.rs | 158 ++++++++++- .../migrations/v24_ref_resolution_target.sql | 12 + docs/rfcs/RFC-027-live-semantic-resolution.md | 12 +- 7 files changed, 570 insertions(+), 22 deletions(-) create mode 100644 crates/travsr-store/src/migrations/v24_ref_resolution_target.sql diff --git a/crates/travsr-cli/src/status.rs b/crates/travsr-cli/src/status.rs index e86c10c0..fbaad78d 100644 --- a/crates/travsr-cli/src/status.rs +++ b/crates/travsr-cli/src/status.rs @@ -15,6 +15,41 @@ use crate::repo::find_git_root; /// #583: equal markers are not sufficient evidence of freshness. A watcher /// reindex rewrites a file's Phase A nodes and drops that file's `ref/call` /// edges without moving HEAD, so both markers still agree while `get_callers` +/// RFC-027 section 12: render the cumulative live-lane precision reading. +/// +/// Returns `None` when nothing has been measured, so the line never appears on a +/// repo that has not exercised the lane. +/// +/// Coverage is always shown beside precision. A precision figure alone invites +/// the reading "1.00 means it is perfect", when it may mean "two of four hundred +/// claims were checkable and both happened to be right". The gate is on +/// precision; coverage is what tells you whether the gate saw anything. +fn live_precision_line(store: &travsr_store::SqliteStore) -> Option { + let raw = store.get_meta("live_precision").ok().flatten()?; + let parts: Vec = raw + .split(',') + .filter_map(|p| p.trim().parse::().ok()) + .collect(); + let [agree, disagree, unverifiable] = parts.as_slice() else { + return None; + }; + let total = agree + disagree + unverifiable; + if total == 0 { + return None; + } + let verified = agree + disagree; + let precision = if verified > 0 { + format!("{:.4}", *agree as f64 / verified as f64) + } else { + // Not "1.0000": nothing was checked, and saying so is the point. + "n/a".to_string() + }; + Some(format!( + "live lane: precision {precision} over {verified}/{total} verifiable claims \ + ({disagree} disagreed with semantic analysis)" + )) +} + /// and `get_blast_radius` answer from a graph degraded below the committed /// snapshot. Reporting `complete` there is the actual harm; the edges /// themselves return on the next commit's Phase B run. @@ -172,6 +207,22 @@ pub fn run() -> anyhow::Result<()> { payload.nodes, payload.edges, payload.schema, last_commit, phase_b_state, rerank_segment ); + // RFC-027 section 12: the live lane's measured precision, so the per-language + // shipping gate has a number a human can read rather than a log line that + // scrolled away. + // + // Read straight from the store rather than added to `StatusPayload`: this is + // a diagnostic, and threading it through the query payload would mean a + // protocol bump that every mixed CLI/daemon pair then has to survive. + // + // Silent when the lane has never claimed anything, which is every repo that + // has not used it — a counter of zero is not news. + if let Ok(store) = daemon_client::open_read_store(&db_path) { + if let Some(line) = live_precision_line(&store) { + println!("{line}"); + } + } + // #645 WS-B: the freshness markers only ever compare against each other, // never against the repository. Compare the caller's live HEAD (read at cwd, // above) to the index's last_commit so a checkout at a different revision — @@ -399,6 +450,52 @@ mod tests { /// `Command::output()`. These pin the two answers it must give without /// hanging for either: a real repo reports a short SHA, a directory that is /// not a repo reports nothing. + /// The line never appears on a repo that has not used the live lane, and + /// never reports a precision it did not measure. + #[test] + fn live_precision_line_is_silent_until_something_is_measured() { + let mut store = travsr_store::SqliteStore::open_in_memory().unwrap(); + assert_eq!(live_precision_line(&store), None, "no reading, no line"); + + store.set_meta("live_precision", "0,0,0").unwrap(); + assert_eq!( + live_precision_line(&store), + None, + "an empty tally is not news" + ); + + store.set_meta("live_precision", "garbage").unwrap(); + assert_eq!( + live_precision_line(&store), + None, + "a corrupt value is not a reading" + ); + } + + /// Coverage is always shown, and an unverified sample says so instead of + /// rendering a perfect score it did not earn. + #[test] + fn live_precision_line_reports_coverage_beside_precision() { + let mut store = travsr_store::SqliteStore::open_in_memory().unwrap(); + + store.set_meta("live_precision", "99,1,100").unwrap(); + let line = live_precision_line(&store).unwrap(); + assert!(line.contains("0.9900"), "precision: {line}"); + assert!(line.contains("100/200"), "coverage must be visible: {line}"); + assert!( + line.contains("1 disagreed"), + "false positives named: {line}" + ); + + // Nothing checkable: must not read as perfect. + store.set_meta("live_precision", "0,0,40").unwrap(); + let line = live_precision_line(&store).unwrap(); + assert!( + line.contains("n/a"), + "forty unchecked claims must not render as 1.0000: {line}" + ); + } + #[test] fn head_at_reports_a_sha_inside_a_repo_and_none_outside() { let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); diff --git a/crates/travsr-daemon/src/lib.rs b/crates/travsr-daemon/src/lib.rs index 9fa222d4..f6b7c9c6 100644 --- a/crates/travsr-daemon/src/lib.rs +++ b/crates/travsr-daemon/src/lib.rs @@ -3695,6 +3695,85 @@ fn live_resolve_file(store: &mut SqliteStore, corpus: &str, repo_root: &Path, ab } } +/// RFC-027 section 12: the continuous precision meter. +/// +/// "Is the live lane worse than nothing?" is answered empirically, not by +/// assertion, and ground truth is on tap every commit. Each run compares what +/// the lane claimed against what Phase B derived at the same call sites, and +/// records the result so the per-language shipping gate has a number to read. +/// +/// Persisted to `meta` as well as logged. A log line scrolls away; the gate +/// needs a value `travsr status` can show and a human can act on. +/// +/// The reading is cumulative across runs rather than last-run-only: a single +/// commit can carry two claims, and a gate computed from a sample that small +/// would swing between 0.0 and 1.0 on noise. Disagreements are logged +/// individually at warn, because one wrong edge is the failure this whole design +/// is built to avoid, and it should be visible the moment it happens rather than +/// averaged into a ratio. +fn measure_live_precision(store: &mut SqliteStore) { + let sample = match store.live_precision_sample() { + Ok(s) => s, + Err(e) => { + tracing::debug!("live precision sample failed: {e:#}"); + return; + } + }; + if sample.claims() == 0 { + return; + } + + let prior = read_precision_totals(store); + let agree = prior.0 + sample.agree; + let disagree = prior.1 + sample.disagree; + let unverifiable = prior.2 + sample.unverifiable; + let _ = store.set_meta( + LIVE_PRECISION_META, + &format!("{agree},{disagree},{unverifiable}"), + ); + + if sample.disagree > 0 { + tracing::warn!( + event = "live.precision.disagreement", + disagreed = sample.disagree, + agreed = sample.agree, + "the live lane resolved a reference differently from Phase B" + ); + } + tracing::info!( + event = "live.precision", + agree = sample.agree, + disagree = sample.disagree, + unverifiable = sample.unverifiable, + precision = ?sample.precision(), + coverage = sample.coverage(), + "live overlay scored against Phase B" + ); +} + +/// Cumulative `(agree, disagree, unverifiable)` recorded so far. +/// +/// A malformed or absent value reads as zeroes: the meter is diagnostic, and a +/// corrupt counter must never fail a commit. +fn read_precision_totals(store: &SqliteStore) -> (u64, u64, u64) { + let raw = store + .get_meta(LIVE_PRECISION_META) + .ok() + .flatten() + .unwrap_or_default(); + let parts: Vec = raw + .split(',') + .filter_map(|p| p.trim().parse::().ok()) + .collect(); + match parts.as_slice() { + [a, d, u] => (*a, *d, *u), + _ => (0, 0, 0), + } +} + +/// Cumulative live-lane precision counters, as `agree,disagree,unverifiable`. +const LIVE_PRECISION_META: &str = "live_precision"; + /// RFC-027 section 8.3: retire the live overlay and clear resolved pendings. /// /// Extracted so the convergence property test can drive ratification directly @@ -3703,6 +3782,10 @@ fn live_resolve_file(store: &mut SqliteStore, corpus: &str, repo_root: &Path, ab /// Fail-open on both steps: a sweep that errors leaves `live` rows labeled as /// what they are, which is stale but honest, and never wrong. fn ratify_live_overlay(store: &mut SqliteStore, ratified: &[String]) { + // RFC-027 section 12: score the overlay before retiring it. Order matters — + // after the Phase B writes, because that is the truth being compared + // against, and before the sweep, because the sweep discards the evidence. + measure_live_precision(store); match store.sweep_live_edges_for_languages(ratified) { Ok(0) => {} Ok(n) => tracing::debug!( @@ -7551,6 +7634,191 @@ mod tests { ); } + /// RFC-027 section 12 / Phase 3 gate: measured precision on a fixture corpus. + /// + /// This is the number that decides whether the lane ships. The bar is 0.99 + /// with a target of zero false positives, because for a product whose thesis + /// is "zero structural hallucinations" a wrong edge is not a quality + /// regression but a breach of the value proposition. + /// + /// The fixture is deliberately adversarial rather than a happy path: one + /// unambiguous callee the lexical lane should resolve, and one name defined + /// on two different classes, which is exactly the method-on-receiver case it + /// must refuse. A meter that only ever sees resolvable calls proves nothing. + #[test] + fn measured_live_precision_clears_the_shipping_gate() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + git_init(tmp.path()); + std::fs::create_dir_all(tmp.path().join("src")).unwrap(); + + // Unambiguous: only one `charge` in the corpus. + std::fs::write( + tmp.path().join("src/billing.ts"), + "export class Billing {\n charge(): void {}\n}\n", + ) + .unwrap(); + // Ambiguous: `ping` is defined on two classes, so the lexical lane must + // abstain rather than pick one. + std::fs::write( + tmp.path().join("src/a.ts"), + "export class A {\n ping(): void {}\n}\n", + ) + .unwrap(); + std::fs::write( + tmp.path().join("src/b.ts"), + "export class B {\n ping(): void {}\n}\n", + ) + .unwrap(); + let caller = tmp.path().join("src/caller.ts"); + std::fs::write( + &caller, + "import { Billing } from \"./billing\";\n export function run(bill: Billing, x: any): void {\n \x20 bill.charge();\n \x20 x.ping();\n }\n", + ) + .unwrap(); + + std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); + init_repo(tmp.path()).unwrap(); + std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); + + let db_path = tmp.path().join(".travsr/graph.db"); + let corpus = travsr_store::SqliteStore::open(&db_path) + .unwrap() + .get_meta("corpus") + .unwrap() + .unwrap_or_default(); + + // A save, then the overlay: this is the mid-edit state. + { + let mut text = std::fs::read_to_string(&caller).unwrap(); + text.push_str("\nexport function again(bill: Billing): void {\n bill.charge();\n}\n"); + std::fs::write(&caller, text).unwrap(); + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + reindex_files(std::slice::from_ref(&caller), tmp.path(), &mut store).unwrap(); + live_resolve_file(&mut store, &corpus, tmp.path(), &caller); + } + + // The ambiguous call must never have produced a claim at all: an + // abstention is not a low-confidence answer, it is no answer. + { + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + let pending = store + .pending_refs_in_file(&corpus, "src/caller.ts") + .unwrap(); + assert!( + pending.iter().any(|(name, _)| name == "ping"), + "the ambiguous receiver must abstain, got pending {pending:?}" + ); + } + + // Now commit and let Phase B run for real. The meter lives at + // ratification for a reason that only shows up here: `reindex_replace` + // deletes the edited file's `edge_sites` on save, so between the save and + // the next Phase B run there is no call-site evidence to check anything + // against. Measuring earlier does not produce a pessimistic number, it + // produces no number at all. + std::process::Command::new("git") + .args(["add", "-A"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args([ + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-qm", + "edit", + ]) + .current_dir(tmp.path()) + .output() + .unwrap(); + let sha = read_head_commit_sha(tmp.path()).expect("HEAD after commit"); + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + store.set_meta("last_commit", &sha).unwrap(); + } + let store_mutex = std::sync::Mutex::new(travsr_store::SqliteStore::open(&db_path).unwrap()); + run_background_phase_b_inner(tmp.path(), &store_mutex); + drop(store_mutex); + + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + let sample = store.live_precision_sample().unwrap(); + + assert!( + sample.claims() > 0, + "precondition: the lane must have claimed something to score" + ); + assert!( + sample.coverage() > 0.0, + "precondition: Phase B must have left call-site evidence, or this gate \ + asserts nothing. Got {sample:?}" + ); + assert_eq!( + sample.disagree, 0, + "a live edge that disagrees with Phase B is a false positive, and the \ + target is zero: {sample:?}" + ); + let precision = sample + .precision() + .expect("a verified sample must yield a precision"); + assert!( + precision >= 0.99, + "measured precision {precision} is below the 0.99 shipping gate: {sample:?}" + ); + } + + /// The gate must not be clearable by an empty sample. + /// + /// A lane that resolved nothing verifiable has not earned a perfect score, + /// and reporting 1.0 there would let "we measured nothing" pass as "we + /// measured perfectly" — which is exactly how a precision gate stops meaning + /// anything. + #[test] + fn an_unverifiable_sample_reports_no_precision_rather_than_a_perfect_one() { + let empty = travsr_store::LivePrecision::default(); + assert_eq!(empty.precision(), None); + assert_eq!(empty.coverage(), 0.0); + + let all_unverifiable = travsr_store::LivePrecision { + agree: 0, + disagree: 0, + unverifiable: 40, + }; + assert_eq!( + all_unverifiable.precision(), + None, + "forty unchecked claims must not read as perfect precision" + ); + assert_eq!(all_unverifiable.coverage(), 0.0); + + let mixed = travsr_store::LivePrecision { + agree: 99, + disagree: 1, + unverifiable: 100, + }; + assert_eq!(mixed.precision(), Some(0.99)); + assert_eq!( + mixed.coverage(), + 0.5, + "coverage must expose the unchecked half" + ); + } + + /// The cumulative counter survives a malformed value rather than failing a + /// commit over a diagnostic. + #[test] + fn the_precision_counter_tolerates_a_corrupt_value() { + let mut store = travsr_store::SqliteStore::open_in_memory().unwrap(); + assert_eq!(read_precision_totals(&store), (0, 0, 0)); + store.set_meta(LIVE_PRECISION_META, "not,a,number").unwrap(); + assert_eq!(read_precision_totals(&store), (0, 0, 0)); + store.set_meta(LIVE_PRECISION_META, "3,1,7").unwrap(); + assert_eq!(read_precision_totals(&store), (3, 1, 7)); + } + /// RFC-027 Phase 2 gate, Invariant #4: ratifying the live overlay returns /// the graph to exactly what it was before the overlay existed. /// diff --git a/crates/travsr-daemon/src/live_resolve.rs b/crates/travsr-daemon/src/live_resolve.rs index 9130e624..95d6de53 100644 --- a/crates/travsr-daemon/src/live_resolve.rs +++ b/crates/travsr-daemon/src/live_resolve.rs @@ -226,22 +226,25 @@ pub fn resolve_unambiguous_lexical( // abstention is recorded, not dropped — "there is a call here and its // target is not yet known" is true and useful, and saying it is what makes // fail-closed honest rather than merely quiet. - let mut states: Vec<(NodeId, u32, u32, String, &'static str)> = - Vec::with_capacity(unresolved.len()); + let mut states: Vec = Vec::with_capacity(unresolved.len()); for call in unresolved { let name = travsr_core::ident::leaf_of(&call.callee_sig).to_string(); - let resolved = match lexical_one(store, call) { + // The target this lane claimed, kept whatever later happens to the edge. + // Section 12's meter needs the claim itself: by ratification a re-derived + // live edge has been relabelled and an unratified one is about to be + // swept, so the edges table can no longer say what the lane decided. + let claimed = match lexical_one(store, call) { Some(edge) => match store.put_edge_live(&edge) { - Ok(()) => true, + Ok(()) => Some(edge.dst), Err(e) => { tracing::debug!(error = %e, "live lexical edge write failed"); - false + None } }, - None => false, + None => None, }; - if resolved { + if claimed.is_some() { outcome.emit(); } else { outcome.abstain(); @@ -249,14 +252,19 @@ pub fn resolve_unambiguous_lexical( // `ref_col` is 0: the native extractor records the call's line but not // its column. The PK tolerates it, and a second call to the same name // on one line is the only collision it can cause, which merges two - // identical pending facts rather than losing one. - states.push(( - call.src, - call.caller_line, - 0, + // identical facts rather than losing one. + states.push(travsr_store::RefResolution { + src: call.src, + ref_line: call.caller_line, + ref_col: 0, name, - if resolved { "resolved" } else { "pending" }, - )); + state: if claimed.is_some() { + "resolved" + } else { + "pending" + }, + resolved_dst: claimed, + }); } if let Err(e) = store.replace_ref_resolution_states(corpus, path, &states) { diff --git a/crates/travsr-mcp/src/tools.rs b/crates/travsr-mcp/src/tools.rs index e77d8fd1..7eff9a75 100644 --- a/crates/travsr-mcp/src/tools.rs +++ b/crates/travsr-mcp/src/tools.rs @@ -11921,7 +11921,14 @@ mod snippet_tests { .replace_ref_resolution_states( &a.vname.corpus, "a.ts", - &[(a.id, 3, 0, "save".to_string(), "pending")], + &[travsr_store::RefResolution { + src: a.id, + ref_line: 3, + ref_col: 0, + name: "save".to_string(), + state: "pending", + resolved_dst: None, + }], ) .unwrap(); diff --git a/crates/travsr-store/src/lib.rs b/crates/travsr-store/src/lib.rs index 4c5770ec..bc7ed9d7 100644 --- a/crates/travsr-store/src/lib.rs +++ b/crates/travsr-store/src/lib.rs @@ -588,6 +588,22 @@ impl Migration for V23RefResolutionState { } } +/// RFC-027 section 12: `ref_resolution_state.resolved_dst`, so the precision +/// meter can compare what the live lane claimed against what Phase B derived. +struct V24RefResolutionTarget; +impl Migration for V24RefResolutionTarget { + fn version(&self) -> u32 { + 24 + } + fn up(&self, store: &mut dyn StoreMigratable) -> anyhow::Result<()> { + // ALTER TABLE … ADD COLUMN has no IF NOT EXISTS in SQLite — guard. + if !store.column_exists("ref_resolution_state", "resolved_dst")? { + store.exec_ddl(include_str!("migrations/v24_ref_resolution_target.sql"))?; + } + Ok(()) + } +} + /// Build the ordered migration runner for the SQLite backend. /// Register new SQLite migrations here; version order is enforced by the runner. fn sqlite_migration_runner() -> MigrationRunner { @@ -614,9 +630,75 @@ fn sqlite_migration_runner() -> MigrationRunner { r.register(V21LexicalSplit); r.register(V22TestRole); r.register(V23RefResolutionState); + r.register(V24RefResolutionTarget); r } +/// RFC-027 section 9.2: one reference the live lane examined, and what became +/// of it. +/// +/// `resolved_dst` is `None` for a `pending` row — an abstention resolved to +/// nothing, which is the whole point of it. +#[derive(Debug, Clone)] +pub struct RefResolution { + pub src: NodeId, + pub ref_line: u32, + pub ref_col: u32, + pub name: String, + /// `"resolved"` or `"pending"`. + pub state: &'static str, + pub resolved_dst: Option, +} + +/// RFC-027 section 12: how the live lane scored against Phase B. +/// +/// Deliberately three buckets, not two. `unverifiable` is the honest home for a +/// live claim Phase B left no call-site evidence for — Phase B has its own +/// recall gaps, and the code can change between the edit and the commit, so +/// "Phase B did not produce this" is not the same statement as "the live lane +/// was wrong". Folding those into `disagree` would make the meter pessimistic +/// and unactionable, and a meter nobody believes does not gate anything. +/// +/// Precision is therefore reported over the verified subset only, with coverage +/// beside it. Precision without coverage would let 1.0 over two of five hundred +/// claims read as a passing grade. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct LivePrecision { + /// Live claims Phase B resolved to the same target at the same call site. + pub agree: u64, + /// Live claims Phase B resolved to a *different* target at that site. These + /// are true false positives, and the number the shipping gate exists for. + pub disagree: u64, + /// Live claims with no ratified call-site evidence either way. + pub unverifiable: u64, +} + +impl LivePrecision { + /// Agreement over the verified subset, or `None` when nothing was verifiable. + /// + /// `None` rather than `1.0`: a lane that resolved nothing verifiable has not + /// earned a perfect score, and returning one would let an empty sample clear + /// the shipping gate. + pub fn precision(&self) -> Option { + let verified = self.agree + self.disagree; + (verified > 0).then(|| self.agree as f64 / verified as f64) + } + + /// Fraction of live claims that could be checked at all. + pub fn coverage(&self) -> f64 { + let total = self.agree + self.disagree + self.unverifiable; + if total == 0 { + return 0.0; + } + (self.agree + self.disagree) as f64 / total as f64 + } + + /// Total live claims this sample covers. + pub fn claims(&self) -> u64 { + self.agree + self.disagree + self.unverifiable + } +} + /// The storage interface every Travsr backend must satisfy. /// /// All methods return `Result` (from `travsr-error`). @@ -4867,7 +4949,7 @@ LIMIT ?4", &mut self, corpus: &str, path: &str, - rows: &[(NodeId, u32, u32, String, &'static str)], + rows: &[RefResolution], ) -> Result<(), StoreError> { (|| -> AnyResult<()> { let tx = self @@ -4880,12 +4962,20 @@ LIMIT ?4", params![corpus, path], ) .context("clearing this file's ref_resolution_state rows")?; - for (src, line, col, name, state) in rows { + for r in rows { tx.execute( - "INSERT INTO ref_resolution_state(src, ref_line, ref_col, name, state) \ - VALUES(?1, ?2, ?3, ?4, ?5) \ - ON CONFLICT(src, ref_line, ref_col, name) DO UPDATE SET state = excluded.state", - params![node_id_to_i64(*src), *line as i64, *col as i64, name, state], + "INSERT INTO ref_resolution_state(src, ref_line, ref_col, name, state, resolved_dst) \ + VALUES(?1, ?2, ?3, ?4, ?5, ?6) \ + ON CONFLICT(src, ref_line, ref_col, name) DO UPDATE SET \ + state = excluded.state, resolved_dst = excluded.resolved_dst", + params![ + node_id_to_i64(r.src), + r.ref_line as i64, + r.ref_col as i64, + r.name, + r.state, + r.resolved_dst.map(node_id_to_i64), + ], ) .context("inserting ref_resolution_state row")?; } @@ -4896,6 +4986,62 @@ LIMIT ?4", .map_err(|e| StoreError::Database(e.to_string())) } + /// RFC-027 section 12: score the live lane's claims against Phase B's truth. + /// + /// Run at ratification, **after** the Phase B writes and **before** the + /// sweep. After, because it compares against what Phase B derived; before, + /// because the sweep is about to discard the evidence. + /// + /// Verification is at **call-site line** granularity, joining each live + /// claim in `ref_resolution_state` to the `edge_sites` rows Phase B recorded + /// for the same `(src, line)`. Anything coarser is not safe to gate on: a + /// function that calls several things would let a mis-targeted claim match + /// some *other* call's correct answer and score as agreement. An optimistic + /// meter is worse than none, because it clears a bar the lane has not met. + /// + /// A claim whose site Phase B recorded nothing for is `unverifiable`, not + /// wrong — Phase B has recall gaps of its own, and the code can change + /// between the edit and the commit. + /// + /// `SCIP wins all ties` (section 12) falls out of the ordering rather than + /// needing a rule here: by the time this runs, Phase B has already written + /// its answer over any co-located live row. + pub fn live_precision_sample(&self) -> Result { + (|| -> AnyResult { + let mut stmt = self + .conn + .prepare( + // Per claim: does Phase B have any site at this line, and does + // one of them name the same target? + "SELECT \ + EXISTS (SELECT 1 FROM edge_sites s \ + WHERE s.src = r.src AND s.line = r.ref_line) AS has_site, \ + EXISTS (SELECT 1 FROM edge_sites s \ + WHERE s.src = r.src AND s.line = r.ref_line \ + AND s.dst = r.resolved_dst) AS matches \ + FROM ref_resolution_state r \ + WHERE r.state = 'resolved' AND r.resolved_dst IS NOT NULL", + ) + .context("live_precision_sample: prepare")?; + let rows = stmt + .query_map([], |row| { + Ok((row.get::<_, i64>(0)? == 1, row.get::<_, i64>(1)? == 1)) + }) + .context("live_precision_sample: query")?; + + let mut out = LivePrecision::default(); + for row in rows { + match row.context("live_precision_sample: decode")? { + (false, _) => out.unverifiable += 1, + (true, true) => out.agree += 1, + (true, false) => out.disagree += 1, + } + } + Ok(out) + })() + .map_err(|e| StoreError::Database(e.to_string())) + } + /// RFC-027 section 10: the pending references in `path`, for the /// `live_overlay` freshness note. /// diff --git a/crates/travsr-store/src/migrations/v24_ref_resolution_target.sql b/crates/travsr-store/src/migrations/v24_ref_resolution_target.sql new file mode 100644 index 00000000..81cdca1e --- /dev/null +++ b/crates/travsr-store/src/migrations/v24_ref_resolution_target.sql @@ -0,0 +1,12 @@ +-- RFC-027 section 12: record what the live lane actually claimed, so its +-- precision can be measured against Phase B's answer at the next commit. +-- +-- The edge itself cannot answer this. By the time ratification runs, a live edge +-- Phase B re-derived has already been relabelled in place, and one it did not +-- re-derive is about to be swept. Either way the edges table no longer says +-- "the live lane resolved this reference to this node" — only that a row exists, +-- or does not. This column is that claim, kept independently of the edge's fate. +-- +-- NULL for a pending row: an abstention resolved to nothing, which is the whole +-- point of it. +ALTER TABLE ref_resolution_state ADD COLUMN resolved_dst INTEGER; diff --git a/docs/rfcs/RFC-027-live-semantic-resolution.md b/docs/rfcs/RFC-027-live-semantic-resolution.md index 707a9f15..58c59107 100644 --- a/docs/rfcs/RFC-027-live-semantic-resolution.md +++ b/docs/rfcs/RFC-027-live-semantic-resolution.md @@ -339,7 +339,17 @@ In the IDE-piggyback path (§7.6), the editor owns `didChange`; Travsr reads thr The "is the live lane worse than nothing?" question is answered empirically, not by assertion. Ground truth is on tap every commit: -- **Continuous precision meter:** at each commit, diff `live` edges in the ratified region against the SCIP truth. Precision = agreement rate. **SCIP wins all ties** (it is the ratified ground truth); disagreements are logged with the LSP-resolved vs SCIP-resolved target. +- **Continuous precision meter:** at each commit, diff what the live lane claimed against what Phase B derived. Disagreements are logged individually at `warn` — one wrong edge is the failure this design exists to avoid, and it should be visible the moment it happens rather than averaged into a ratio. **SCIP wins all ties**; in implementation that falls out of ordering rather than needing a rule, because the meter runs after Phase B has already written its answer over any co-located live row. + + **Precision is measured, not agreement** (corrected in implementation). The two are not the same here, because the two lanes have deliberately opposite policies: the live lane is precision-first and abstains on ambiguity, while the Phase B resolver is explicitly recall-biased ("overconnection is safe … all matches are emitted; PPR damping absorbs the noise"). The live lane therefore emits a legitimate *subset*, and scoring agreement would penalise it for exactly the abstentions §8.1 requires. + + **Verification is at call-site line granularity**, joining each recorded claim to the `edge_sites` row Phase B wrote for the same `(src, line)`. Anything coarser is not safe to gate on: a function that calls several things would let a mis-targeted claim match some *other* call's correct answer and score as agreement. An optimistic meter is worse than none, because it clears a bar the lane has not met. + + **Three buckets, not two.** A claim Phase B left no evidence for is `unverifiable`, not wrong: Phase B has recall gaps of its own, and the code can change between the edit and the commit. Precision is reported over the verified subset with **coverage beside it**, because precision alone would let "1.0 over two of four hundred claims" read as a passing grade. A sample with nothing verifiable reports *no* precision rather than a perfect one, so an empty measurement cannot clear the gate. + + **The meter must run at ratification**, after the Phase B writes and before the sweep. Not a stylistic choice: `reindex_replace` deletes the edited file's `edge_sites` on save, so between the save and the next Phase B run there is no call-site evidence at all, and measuring earlier yields no number rather than a pessimistic one. + + The reading is cumulative and surfaced by `travsr status`, since a gate needs a value a human can read rather than a log line that scrolled away. - **Gate:** the live lane ships enabled only if measured precision ≥ 0.99 on the fixture corpus (target: zero false positives). If it cannot hold that bar for a language, the lane is disabled for that language — a measured decision, not a guess. - **Recall telemetry:** the same diff reports the recall the live lane buys over the pure fail-closed lexical lane, quantifying whether the LSP dependency earns its operational cost. - Fixture corpus reuses the RFC-003 §6 fixtures plus the existing LSIF path as a differential oracle (available for TS/Rust/Python). From 165f4c6537f21dadc1dddb35f5b4d3c25a20ffee Mon Sep 17 00:00:00 2001 From: Abhishek Date: Fri, 28 Aug 2026 06:47:48 +0530 Subject: [PATCH 11/23] [travsr-daemon] RFC-027 Phase 4: Rust in the live lane + per-language precision gate Widen the live semantic resolution lane to Rust and gate it per-language. live_resolve_file now admits .rs alongside TypeScript/JavaScript and branches on the two native extractors' differing arity (TS returns (nodes, edges, unresolved); Rust adds a same-file refs vec the lane ignores). Everything downstream of extraction was already language-agnostic. Grounded and pinned the Rust call-site encoding against candidate_signatures, which was written for TypeScript: - Type::assemble() is emitted already-qualified (method:Type.name, no recv_type, not is_method_call), so it routes through the free-function branch and is used verbatim. Rebuilding it would double-qualify. Pinned by test. - recv.method() with a recovered receiver rebuilds method:{recv}.{leaf}; without one it abstains, which is the ambiguity the LSP lane exists for. - field access abstains because is_definition does not admit the "field" kind, not because recv_type is unset (the extractor only emits field refs with a recovered receiver type). Recorded, not closed: a field read is not a call. Per-language precision gate (RFC section 12): - live_precision_sample_by_language splits the meter by the source node's language, cumulative under meta key live_precision.. - live_lane_enabled_for disables a language only on a meaningful adverse sample (>= 20 verified claims and precision < 0.99) and re-enables on its own when the cumulative reading recovers. A language with too little evidence stays enabled so it can earn a reading. The corpus-wide live_precision key is still maintained so travsr status is unchanged. Gate: 32 suites, 1542 passed, 0 failed. Rust live precision 1.0000 at coverage 1.0 over 5 verified claims (associated, method-with-receiver, and bare-fn shapes); the gate was verified to fail on an injected disagreement. --- crates/travsr-daemon/src/lib.rs | 358 ++++++++++++++++++++--- crates/travsr-daemon/src/live_resolve.rs | 139 +++++++++ crates/travsr-store/src/lib.rs | 57 ++++ 3 files changed, 518 insertions(+), 36 deletions(-) diff --git a/crates/travsr-daemon/src/lib.rs b/crates/travsr-daemon/src/lib.rs index f6b7c9c6..15a2a3ad 100644 --- a/crates/travsr-daemon/src/lib.rs +++ b/crates/travsr-daemon/src/lib.rs @@ -3663,18 +3663,49 @@ fn live_resolve_file(store: &mut SqliteStore, corpus: &str, repo_root: &Path, ab .replace('\\', "/"); let vname_path = vname_path.as_str(); let ext = abs_path.extension().and_then(|e| e.to_str()).unwrap_or(""); - // `Language::TypeScript` covers .ts/.tsx/.js/.jsx — there is no separate - // JavaScript variant, and the native extractor handles both. - if travsr_core::Language::from_extension(ext) != Some(travsr_core::Language::TypeScript) { + // Only the native call-site extractor is per-language; everything downstream + // (`resolve_unambiguous_lexical`, `candidate_signatures`, the emit path) is + // language-agnostic. `Language::TypeScript` covers .ts/.tsx/.js/.jsx — there + // is no separate JavaScript variant, and the native extractor handles both; + // Rust is `.rs`. + let lang = match travsr_core::Language::from_extension(ext) { + Some(lang @ (travsr_core::Language::TypeScript | travsr_core::Language::Rust)) => lang, + _ => return, + }; + // RFC-027 section 12: a language the cumulative meter has measured below the + // shipping bar is disabled — the fail-closed floor (§7.3c) is better than an + // un-ratified wrong edge. Extraction is skipped entirely so a disabled + // language costs nothing on save. + if !live_lane_enabled_for(store, lang.as_str()) { + tracing::debug!( + path = %vname_path, + language = lang.as_str(), + "live: lane disabled for language by the precision gate" + ); return; } let files = [(abs_path.to_path_buf(), vname_path.to_string())]; - let unresolved = match travsr_analysis::phase_b_typescript::extract_native_phase_b( - corpus, - repo_root, - Some(&files), - ) { - Ok((_, _, unresolved)) => unresolved, + // The two extractors return different arities: TypeScript is + // `(nodes, edges, unresolved)`, Rust is `(nodes, edges, unresolved, refs)` + // (the same-file `refs` the live lane does not consume). + let extraction = match lang { + travsr_core::Language::TypeScript => { + travsr_analysis::phase_b_typescript::extract_native_phase_b( + corpus, + repo_root, + Some(&files), + ) + .map(|(_, _, unresolved)| unresolved) + } + travsr_core::Language::Rust => { + travsr_analysis::phase_b_rust::extract_native_phase_b(corpus, repo_root, Some(&files)) + .map(|(_, _, unresolved, _)| unresolved) + } + // Unreachable: the gate above admits only the two arms handled here. + _ => return, + }; + let unresolved = match extraction { + Ok(unresolved) => unresolved, Err(e) => { tracing::debug!(path = %vname_path, error = %e, "live: call-site extraction failed"); return; @@ -3712,52 +3743,88 @@ fn live_resolve_file(store: &mut SqliteStore, corpus: &str, repo_root: &Path, ab /// is built to avoid, and it should be visible the moment it happens rather than /// averaged into a ratio. fn measure_live_precision(store: &mut SqliteStore) { - let sample = match store.live_precision_sample() { + let by_lang = match store.live_precision_sample_by_language() { Ok(s) => s, Err(e) => { tracing::debug!("live precision sample failed: {e:#}"); return; } }; - if sample.claims() == 0 { + if by_lang.is_empty() { return; } + // Accumulate each language's counters under its own key, and roll the same + // deltas into the corpus-wide `live_precision` key `travsr status` reads. + // The per-language keys are what the shipping gate (`live_lane_enabled_for`) + // consults; the aggregate stays a faithful sum so the status line is + // unchanged. + let (mut run_agree, mut run_disagree, mut run_unverifiable) = (0u64, 0u64, 0u64); + for (lang, sample) in &by_lang { + if sample.claims() == 0 { + continue; + } + let prior = read_precision_totals_for(store, Some(lang)); + let _ = store.set_meta( + &live_precision_meta_key(Some(lang)), + &format!( + "{},{},{}", + prior.0 + sample.agree, + prior.1 + sample.disagree, + prior.2 + sample.unverifiable + ), + ); + + if sample.disagree > 0 { + tracing::warn!( + event = "live.precision.disagreement", + language = %lang, + disagreed = sample.disagree, + agreed = sample.agree, + "the live lane resolved a reference differently from Phase B" + ); + } + tracing::info!( + event = "live.precision", + language = %lang, + agree = sample.agree, + disagree = sample.disagree, + unverifiable = sample.unverifiable, + precision = ?sample.precision(), + coverage = sample.coverage(), + "live overlay scored against Phase B" + ); + + run_agree += sample.agree; + run_disagree += sample.disagree; + run_unverifiable += sample.unverifiable; + } + let prior = read_precision_totals(store); - let agree = prior.0 + sample.agree; - let disagree = prior.1 + sample.disagree; - let unverifiable = prior.2 + sample.unverifiable; let _ = store.set_meta( LIVE_PRECISION_META, - &format!("{agree},{disagree},{unverifiable}"), + &format!( + "{},{},{}", + prior.0 + run_agree, + prior.1 + run_disagree, + prior.2 + run_unverifiable + ), ); +} - if sample.disagree > 0 { - tracing::warn!( - event = "live.precision.disagreement", - disagreed = sample.disagree, - agreed = sample.agree, - "the live lane resolved a reference differently from Phase B" - ); - } - tracing::info!( - event = "live.precision", - agree = sample.agree, - disagree = sample.disagree, - unverifiable = sample.unverifiable, - precision = ?sample.precision(), - coverage = sample.coverage(), - "live overlay scored against Phase B" - ); +/// Cumulative corpus-wide `(agree, disagree, unverifiable)` recorded so far. +fn read_precision_totals(store: &SqliteStore) -> (u64, u64, u64) { + read_precision_totals_for(store, None) } -/// Cumulative `(agree, disagree, unverifiable)` recorded so far. +/// Cumulative `(agree, disagree, unverifiable)` for one language, or the +/// corpus-wide aggregate when `language` is `None`. /// /// A malformed or absent value reads as zeroes: the meter is diagnostic, and a -/// corrupt counter must never fail a commit. -fn read_precision_totals(store: &SqliteStore) -> (u64, u64, u64) { +/// corrupt counter must never fail a commit or wrongly disable a language. +fn read_precision_totals_for(store: &SqliteStore, language: Option<&str>) -> (u64, u64, u64) { let raw = store - .get_meta(LIVE_PRECISION_META) + .get_meta(&live_precision_meta_key(language)) .ok() .flatten() .unwrap_or_default(); @@ -3771,9 +3838,55 @@ fn read_precision_totals(store: &SqliteStore) -> (u64, u64, u64) { } } +/// The `meta` key holding cumulative live-lane precision counters. The +/// corpus-wide aggregate is `live_precision`; each language namespaces under it +/// as `live_precision.` (matching `nodes.language`). +fn live_precision_meta_key(language: Option<&str>) -> String { + match language { + Some(lang) => format!("{LIVE_PRECISION_META}.{lang}"), + None => LIVE_PRECISION_META.to_string(), + } +} + /// Cumulative live-lane precision counters, as `agree,disagree,unverifiable`. const LIVE_PRECISION_META: &str = "live_precision"; +/// RFC-027 section 12: the per-language shipping bar. Below this the lane is +/// disabled for a language ("a measured decision, not a guess"). +const LIVE_PRECISION_GATE: f64 = 0.99; + +/// Minimum verified live claims before the gate may DISABLE a language. +/// +/// Below this the sample is too small to act on, so the lane stays enabled and +/// keeps gathering evidence — a language (a brand-new one especially) has to be +/// allowed to run to be measured at all. Precision-first, but not so trigger-shy +/// that one early disagreement silences a language for good. +const LIVE_PRECISION_MIN_SAMPLE: u64 = 20; + +/// RFC-027 section 12: is the live lane enabled for `language`? +/// +/// Precision-driven and biased toward on: enabled unless the cumulative +/// per-language meter carries a statistically meaningful adverse reading — at +/// least [`LIVE_PRECISION_MIN_SAMPLE`] verified claims *and* precision below +/// [`LIVE_PRECISION_GATE`]. A language with too little evidence, or none, stays +/// enabled so it can earn a reading. This is what makes "disable the lane for a +/// language" a measured decision rather than a config guess, and it is +/// self-healing: a later run whose Phase B agreement lifts the cumulative +/// precision back over the bar re-enables the language on its own. +fn live_lane_enabled_for(store: &SqliteStore, language: &str) -> bool { + let (agree, disagree, unverifiable) = read_precision_totals_for(store, Some(language)); + let sample = travsr_store::LivePrecision { + agree, + disagree, + unverifiable, + }; + let verified = agree + disagree; + match sample.precision() { + Some(p) if verified >= LIVE_PRECISION_MIN_SAMPLE && p < LIVE_PRECISION_GATE => false, + _ => true, + } +} + /// RFC-027 section 8.3: retire the live overlay and clear resolved pendings. /// /// Extracted so the convergence property test can drive ratification directly @@ -7770,6 +7883,179 @@ mod tests { ); } + /// RFC-027 Phase 4 gate: the same shipping bar, measured for **Rust**, split + /// out by language. This is the differential the phase turns on — the live + /// lane's Rust claims scored against what the commit-time Phase B (native + /// tree-sitter resolution, and rust-analyzer LSIF when present) derived at + /// the same call sites. + /// + /// The fixture is cross-file on purpose: a same-file call is resolved by + /// Phase A and never becomes an `UnresolvedCall`, so the live lane would have + /// nothing to claim. It exercises the three Rust shapes the lane resolves: + /// `Zoo::assemble()` (associated call — the already-qualified `method:Zoo.*` + /// sig used verbatim), `z.describe()` (method call whose receiver type the + /// extractor recovers, rebuilt to `method:Zoo.describe`), and `helper()` (a + /// bare free function). The names are deliberately NOT in the extractor's + /// `NOISE_NAMES` set (`new`, `from`, `clone`, …), which would drop them + /// before the lane ever saw them. All are unambiguous repo-wide, so §7.3a + /// resolves them with no language server, which keeps the test hermetic. + #[test] + fn measured_rust_live_precision_clears_the_per_language_gate() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + git_init(tmp.path()); + std::fs::create_dir_all(tmp.path().join("src")).unwrap(); + + std::fs::write( + tmp.path().join("src/zoo.rs"), + "pub struct Zoo;\n\nimpl Zoo {\n pub fn assemble() -> Zoo {\n Zoo\n }\n pub fn describe(&self) -> i32 {\n 7\n }\n}\n", + ) + .unwrap(); + std::fs::write( + tmp.path().join("src/util.rs"), + "pub fn helper() -> i32 {\n 42\n}\n", + ) + .unwrap(); + let main = tmp.path().join("src/main.rs"); + std::fs::write( + &main, + "mod zoo;\nmod util;\nuse zoo::Zoo;\nuse util::helper;\n\nfn run(z: &Zoo) {\n let _a = Zoo::assemble();\n let _d = z.describe();\n let _n = helper();\n}\n", + ) + .unwrap(); + + std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); + init_repo(tmp.path()).unwrap(); + std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); + + let db_path = tmp.path().join(".travsr/graph.db"); + let corpus = travsr_store::SqliteStore::open(&db_path) + .unwrap() + .get_meta("corpus") + .unwrap() + .unwrap_or_default(); + + // A save, then the overlay: the mid-edit state. The appended function + // adds a third cross-file call the live lane must resolve. + { + let mut text = std::fs::read_to_string(&main).unwrap(); + text.push_str("\nfn run_again(z: &Zoo) {\n let _a = Zoo::assemble();\n let _d = z.describe();\n}\n"); + std::fs::write(&main, text).unwrap(); + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + reindex_files(std::slice::from_ref(&main), tmp.path(), &mut store).unwrap(); + live_resolve_file(&mut store, &corpus, tmp.path(), &main); + } + + // Commit and let Phase B run for real, so the meter has ratified + // call-site evidence to score against (see the TS gate test for why the + // meter must run here and not on save). + std::process::Command::new("git") + .args(["add", "-A"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args([ + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-qm", + "edit", + ]) + .current_dir(tmp.path()) + .output() + .unwrap(); + let sha = read_head_commit_sha(tmp.path()).expect("HEAD after commit"); + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + store.set_meta("last_commit", &sha).unwrap(); + } + let store_mutex = std::sync::Mutex::new(travsr_store::SqliteStore::open(&db_path).unwrap()); + run_background_phase_b_inner(tmp.path(), &store_mutex); + drop(store_mutex); + + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + let by_lang = store.live_precision_sample_by_language().unwrap(); + let rust = by_lang + .get("rust") + .copied() + .unwrap_or_else(|| panic!("no rust bucket in the meter; got {by_lang:?}")); + + assert!( + rust.claims() > 0, + "precondition: the Rust lane must have claimed something to score: {rust:?}" + ); + assert!( + rust.coverage() > 0.0, + "precondition: Phase B must have left Rust call-site evidence, or this \ + gate asserts nothing. Got {rust:?}" + ); + assert_eq!( + rust.disagree, 0, + "a live Rust edge that disagrees with Phase B is a false positive, and \ + the target is zero: {rust:?}" + ); + let precision = rust + .precision() + .expect("a verified Rust sample must yield a precision"); + assert!( + precision >= LIVE_PRECISION_GATE, + "measured Rust precision {precision} is below the {LIVE_PRECISION_GATE} \ + per-language shipping gate: {rust:?}" + ); + + // The gate is per-language: the enable switch reads Rust's own reading, + // which this run has just written above the bar. + assert!( + live_lane_enabled_for(&store, "rust"), + "a Rust reading at or above the bar must keep the lane enabled" + ); + } + + /// The enable switch is measured, not vacuous: it disables a language ONLY on + /// a meaningful adverse sample, and re-enables when the cumulative reading + /// recovers. Driven directly through the per-language meta keys so it does not + /// depend on a live Phase B run. + #[test] + fn the_precision_gate_disables_a_language_only_on_a_meaningful_adverse_sample() { + let mut store = travsr_store::SqliteStore::open_in_memory().unwrap(); + + // No reading at all: enabled, so a new language can earn one. + assert!(live_lane_enabled_for(&store, "rust")); + + // Below the bar but too few verified claims to act on: still enabled. + store.set_meta("live_precision.rust", "4,1,0").unwrap(); + assert!( + live_lane_enabled_for(&store, "rust"), + "5 verified claims is below the min sample; must not disable yet" + ); + + // Enough claims AND below the bar: disabled. (18 agree, 5 disagree => + // 23 verified, precision ~0.78.) + store.set_meta("live_precision.rust", "18,5,3").unwrap(); + assert!( + !live_lane_enabled_for(&store, "rust"), + "a meaningful sample below 0.99 must disable the language" + ); + + // A disagreement-free follow-up lifts the cumulative reading back over + // the bar: self-healing, re-enabled. (198 agree, 2 disagree => 0.99.) + store.set_meta("live_precision.rust", "198,2,0").unwrap(); + assert!( + live_lane_enabled_for(&store, "rust"), + "recovered precision at the bar must re-enable the language" + ); + + // The switch is per-language: a disabled Rust must not disable TypeScript. + store.set_meta("live_precision.rust", "18,5,3").unwrap(); + assert!(!live_lane_enabled_for(&store, "rust")); + assert!( + live_lane_enabled_for(&store, "typescript"), + "the gate must be scoped per language, not corpus-wide" + ); + } + /// The gate must not be clearable by an empty sample. /// /// A lane that resolved nothing verifiable has not earned a perfect score, diff --git a/crates/travsr-daemon/src/live_resolve.rs b/crates/travsr-daemon/src/live_resolve.rs index 95d6de53..4278b38a 100644 --- a/crates/travsr-daemon/src/live_resolve.rs +++ b/crates/travsr-daemon/src/live_resolve.rs @@ -760,4 +760,143 @@ mod tests { "re-emitting must not accumulate duplicate edges" ); } + + // ── Phase 4: Rust call-site encoding (RFC-027 §14) ────────────────────── + // + // `candidate_signatures` was written against TypeScript's encoding; Rust's + // differs in three ways (see the fn doc). These tests pin each, so a change + // to the extractor or the signature builder cannot silently regress Rust + // recall or, worse, start double-qualifying an already-qualified sig. + + fn rust_store_with(nodes: &[(&str, &str, &str, u32, u32)]) -> SqliteStore { + let mut store = SqliteStore::open_in_memory().expect("in-memory store"); + for (path, sig, kind, line, end_line) in nodes { + let vname = VName::new(CORPUS, "", *path, "rust", *sig); + let mut node = Node::new(vname, *kind); + node.line = Some(*line); + node.end_line = Some(*end_line); + store.put_node(&node).expect("put_node"); + } + store + } + + fn rust_node_id(path: &str, sig: &str) -> NodeId { + VName::new(CORPUS, "", path, "rust", sig).id() + } + + /// `Type::method()` (an associated call like `Zoo::new()`) is emitted by the + /// Rust extractor ALREADY QUALIFIED — `callee_sig = "method:Zoo.new"`, with + /// no receiver type and `is_method_call = false` (phase_b_rust.rs, the + /// `call.scoped` uppercase-qualifier arm). It must reach `unique_definition` + /// verbatim: rebuilding `method:{recv}.{leaf}` would double-qualify it. A + /// `None` recv_type and `!is_method_call` route it to the free-function + /// branch, which uses the sig as-is. The plan flagged this as "correct by + /// accident"; this pins it as correct on purpose. + #[test] + fn rust_associated_call_signature_is_used_verbatim_not_rebuilt() { + let src = rust_node_id("src/main.rs", "fn:main"); + let c = call(src, "method:Zoo.new", 3); + assert_eq!(candidate_signatures(&c), vec!["method:Zoo.new".to_string()]); + } + + /// `recv.method()` (e.g. `zoo.add()`) is emitted as `callee_sig = "fn:add"` + /// with `is_method_call = true` and the receiver type recovered into + /// `recv_type`. The lane must rebuild `method:{recv}.{leaf}` to match the + /// definition node `method:Zoo.add`; `fn:add` alone names no method. + #[test] + fn rust_method_call_with_receiver_type_rebuilds_the_qualified_sig() { + let src = rust_node_id("src/zoo.rs", "method:Zoo.announce"); + let mut c = call(src, "fn:add", 5); + c.is_method_call = true; + c.recv_type = Some("Zoo".to_string()); + assert_eq!(candidate_signatures(&c), vec!["method:Zoo.add".to_string()]); + } + + /// A method call with no recovered receiver type is the ambiguity §7.3a + /// cannot settle, so it yields no candidate and abstains — the LSP lane + /// (§7.3b) is what exists for it. Guessing by leaf name here is the + /// recall-biased Phase B policy, wrong for an un-ratified edge. + #[test] + fn rust_method_call_without_receiver_type_yields_no_candidate() { + let src = rust_node_id("src/zoo.rs", "method:Zoo.announce"); + let mut c = call(src, "fn:add", 5); + c.is_method_call = true; + c.recv_type = None; + assert!(candidate_signatures(&c).is_empty()); + } + + /// A bare free-function call `helper()` names its definition directly. + #[test] + fn rust_bare_function_call_names_its_definition_directly() { + let src = rust_node_id("src/main.rs", "fn:main"); + let c = call(src, "fn:helper", 2); + assert_eq!(candidate_signatures(&c), vec!["fn:helper".to_string()]); + } + + /// End to end on a Rust-labeled store: `Zoo::new()` resolves to the + /// associated-function definition with a `live` edge and no double-qualify. + #[test] + fn rust_associated_call_resolves_to_a_live_edge() { + let mut store = rust_store_with(&[ + ("src/main.rs", "fn:main", "function", 1, 6), + ("src/zoo.rs", "method:Zoo.new", "method", 4, 9), + ]); + let src = rust_node_id("src/main.rs", "fn:main"); + let out = resolve_unambiguous_lexical( + &mut store, + CORPUS, + "src/main.rs", + &[call(src, "method:Zoo.new", 3)], + ); + assert_eq!( + out, + LiveOutcome { + emitted: 1, + pending: 0 + } + ); + assert_eq!( + provenance_of(&store, src, rust_node_id("src/zoo.rs", "method:Zoo.new")).as_deref(), + Some("live"), + ); + } + + /// Rust field access `x.count` is emitted with `callee_sig = "field:count"` + /// AND a recovered `recv_type` — the extractor only emits field refs when + /// the receiver type is recoverable (phase_b_rust.rs guards + /// `recv_type.is_none()`). So `candidate_signatures` DOES build + /// `field:Zoo.count`, and the field definition node is present. It still + /// abstains, because `is_definition` does not admit the `"field"` kind and + /// `unique_definition` filters the node out. The abstention is by + /// definition-kind, NOT (as an earlier note assumed) a missing receiver + /// type. Recorded, not closed: a field read is not a call and must not mint + /// a `RefCall` edge. Both facts are asserted here so neither can drift. + #[test] + fn rust_field_access_abstains_on_definition_kind_not_missing_receiver() { + let mut c = call( + rust_node_id("src/zoo.rs", "method:Zoo.tally"), + "field:count", + 5, + ); + c.recv_type = Some("Zoo".to_string()); + assert_eq!( + candidate_signatures(&c), + vec!["field:Zoo.count".to_string()], + "the owner-qualified field sig is built; recv_type is present", + ); + + let mut store = rust_store_with(&[ + ("src/zoo.rs", "method:Zoo.tally", "method", 4, 9), + ("src/zoo.rs", "field:Zoo.count", "field", 2, 2), + ]); + let out = resolve_unambiguous_lexical(&mut store, CORPUS, "src/zoo.rs", &[c]); + assert_eq!( + out, + LiveOutcome { + emitted: 0, + pending: 1 + }, + "the field node exists and its sig matches; abstention is the is_definition filter", + ); + } } diff --git a/crates/travsr-store/src/lib.rs b/crates/travsr-store/src/lib.rs index bc7ed9d7..8b7aa343 100644 --- a/crates/travsr-store/src/lib.rs +++ b/crates/travsr-store/src/lib.rs @@ -5042,6 +5042,63 @@ LIMIT ?4", .map_err(|e| StoreError::Database(e.to_string())) } + /// RFC-027 section 12: [`live_precision_sample`], split by language. + /// + /// The shipping gate is **per-language** ("if it cannot hold that bar for a + /// language, the lane is disabled for that language"), so the meter has to + /// attribute each claim to one. Attribution is by the **source** node's + /// language — the file that was edited, which is the file the live lane ran + /// on — the same key the ratification sweep scopes its delete on, so the + /// meter and the sweep never disagree about which language a row belongs to. + /// + /// Bucketing is in Rust rather than a SQL `GROUP BY` so the two per-claim + /// `EXISTS` sub-selects stay identical to [`live_precision_sample`]; the + /// only change is carrying `n.language` alongside them. + pub fn live_precision_sample_by_language( + &self, + ) -> Result, StoreError> { + (|| -> AnyResult> { + let mut stmt = self + .conn + .prepare( + "SELECT n.language, \ + EXISTS (SELECT 1 FROM edge_sites s \ + WHERE s.src = r.src AND s.line = r.ref_line) AS has_site, \ + EXISTS (SELECT 1 FROM edge_sites s \ + WHERE s.src = r.src AND s.line = r.ref_line \ + AND s.dst = r.resolved_dst) AS matches \ + FROM ref_resolution_state r \ + JOIN nodes n ON n.id = r.src \ + WHERE r.state = 'resolved' AND r.resolved_dst IS NOT NULL", + ) + .context("live_precision_sample_by_language: prepare")?; + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)? == 1, + row.get::<_, i64>(2)? == 1, + )) + }) + .context("live_precision_sample_by_language: query")?; + + let mut out: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for row in rows { + let (lang, has_site, matches) = + row.context("live_precision_sample_by_language: decode")?; + let bucket = out.entry(lang).or_default(); + match (has_site, matches) { + (false, _) => bucket.unverifiable += 1, + (true, true) => bucket.agree += 1, + (true, false) => bucket.disagree += 1, + } + } + Ok(out) + })() + .map_err(|e| StoreError::Database(e.to_string())) + } + /// RFC-027 section 10: the pending references in `path`, for the /// `live_overlay` freshness note. /// From 9fd2a1ae3592d6d4b1fc544a578eb6d837405f2b Mon Sep 17 00:00:00 2001 From: Abhishek Date: Fri, 28 Aug 2026 06:48:19 +0530 Subject: [PATCH 12/23] [travsr-daemon] RFC-027 Phase 4: headless spawn decision and per-language gate note Resolve open question 16.1 (headless daemon path) with the Principal Security Engineer. Verdict: REJECTED for v1. Commit-gated Phase B plus the IDE-piggyback live lane is the permanent answer; the daemon spawns no language server of its own. There is no functional need (7.3a runs serverless, 7.3b consumes a position from a server the developer already runs), and a daemon-spawned persistent server would run the indexed repo's build logic continuously, on any repo it indexes including one merely under review, outside ADR-006 Rule 1's per-repo opt-in and ADR-017's bounded-batch sandbox. Any future revisit is NEEDS_THREAT_MODEL. Recorded in section 16.1, the section 13 threat table, and the security sign-off. Also note in section 12 that the per-language gate is now implemented (live_precision_sample_by_language + live_lane_enabled_for). --- docs/rfcs/RFC-027-live-semantic-resolution.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/rfcs/RFC-027-live-semantic-resolution.md b/docs/rfcs/RFC-027-live-semantic-resolution.md index 58c59107..3e23146f 100644 --- a/docs/rfcs/RFC-027-live-semantic-resolution.md +++ b/docs/rfcs/RFC-027-live-semantic-resolution.md @@ -350,7 +350,7 @@ The "is the live lane worse than nothing?" question is answered empirically, not **The meter must run at ratification**, after the Phase B writes and before the sweep. Not a stylistic choice: `reindex_replace` deletes the edited file's `edge_sites` on save, so between the save and the next Phase B run there is no call-site evidence at all, and measuring earlier yields no number rather than a pessimistic one. The reading is cumulative and surfaced by `travsr status`, since a gate needs a value a human can read rather than a log line that scrolled away. -- **Gate:** the live lane ships enabled only if measured precision ≥ 0.99 on the fixture corpus (target: zero false positives). If it cannot hold that bar for a language, the lane is disabled for that language — a measured decision, not a guess. +- **Gate:** the live lane ships enabled only if measured precision ≥ 0.99 on the fixture corpus (target: zero false positives). If it cannot hold that bar for a language, the lane is disabled for that language — a measured decision, not a guess. **(Phase 4: implemented per-language.** The meter splits by the source node's language — `live_precision_sample_by_language`, cumulative under `meta` key `live_precision.` — and the save path consults `live_lane_enabled_for(language)`, which disables a language only on a meaningful adverse sample (≥ 20 verified claims *and* precision < 0.99) and re-enables it on its own when the cumulative reading recovers. A language with too little evidence stays enabled so it can earn a reading.) - **Recall telemetry:** the same diff reports the recall the live lane buys over the pure fail-closed lexical lane, quantifying whether the LSP dependency earns its operational cost. - Fixture corpus reuses the RFC-003 §6 fixtures plus the existing LSIF path as a differential oracle (available for TS/Rust/Python). @@ -360,14 +360,14 @@ The "is the live lane worse than nothing?" question is answered empirically, not | Concern | Assessment | |---|---| -| Persistent language server = long-lived process running build logic (ADR-006/017) | **IDE-piggyback path spawns nothing** — it reuses the server the developer already trusts and runs. The headless spawn path (out of v1 scope) inherits the ADR-006/017 review. | +| Persistent language server = long-lived process running build logic (ADR-006/017) | **IDE-piggyback path spawns nothing** — it reuses the server the developer already runs and trusts; the serverless §7.3a lane needs none. A daemon-spawned headless server is **REJECTED for v1** (§16.1): it would run the indexed repo's build logic continuously, outside ADR-006 Rule 1's per-repo opt-in and ADR-017's bounded-batch sandbox. Any revisit is `NEEDS_THREAT_MODEL`. | | Live edges poisoning cross-corpus resolution | Blocked by the fencing rule (§8.2): `live` edges never enter the `BridgeRegistry`. | | Non-deterministic durable graph | Impossible: live edges never survive a commit (§8.3). | | Prompt-injection via edge content | Unchanged from existing pipeline; `` sanitization applies identically. | **Sign-offs required before Accepted:** - Principal Architect — schema change (§9), new provenance value, convergence proof (§8.3). -- Principal Security Engineer — headless spawn path (when scoped), IDE-piggyback trust assumption. +- Principal Security Engineer — **signed off (Phase 4).** IDE-piggyback trust assumption accepted: the daemon spawns nothing and only consumes positions from a server the developer already runs. Daemon-spawned headless language servers **REJECTED for v1** (§16.1); any future path is gated on a fresh threat model that extends ADR-006 Rule 1 and defines a persistent-server sandbox profile. - Solution Architect — MCP provenance surface (§10). --- @@ -398,7 +398,7 @@ Language choice for the spike is **TypeScript**: mature `tsserver` resolution + ## 16. Open Questions -1. **Headless daemon path.** Should Travsr ever spawn its own pinned language servers for non-IDE consumers, or is commit-gated the permanent answer there? Deferred to Phase 4 with Security. +1. **Headless daemon path — RESOLVED (Phase 4, Principal Security Engineer).** *Should Travsr ever spawn its own pinned language servers for non-IDE consumers?* **No — commit-gated Phase B plus the IDE-piggyback live lane is the permanent answer for v1; the daemon spawns no language server of its own.** Two facts decide it. **(a) There is no functional need:** §7.3a (unambiguous-lexical) runs with no server at all, and §7.3b consumes a position from the server the developer already runs and already trusts inside their editor — the daemon spawns nothing on either lane. **(b) Spawning would strictly widen the trust surface ADR-006/017 exist to bound.** A persistent language server (rust-analyzer, tsserver) executes the indexed repo's `build.rs`, proc-macros, and build scripts — the same arbitrary code as `rust-analyzer --lsif`, but **continuously and long-lived** rather than in a bounded batch killed at 60s. A daemon that spawned one unprompted would run that code on any repo it indexes, including a repo the developer is only *reviewing, not running*, without the per-repo opt-in ADR-006 Rule 1 requires and outside the bounded-batch profile ADR-017 `SandboxPolicy::Standard` defines — a long-lived server legitimately spawns the compiler and never terminates, tripping the very kill conditions that sandbox imposes. **Verdict: REJECTED for v1.** If ever revisited it is `NEEDS_THREAT_MODEL`: a new RFC must (i) extend ADR-006 Rule 1's per-repo opt-in to cover persistent servers, (ii) define a persistent-server sandbox profile distinct from the bounded-batch one, and (iii) be gated on a demonstrated consumer need the two shipped lanes cannot serve. Absent all three, commit-gated stays the answer. 2. **Interface-edit detection granularity.** Can the classifier reliably distinguish rename from delete+add without the commit SCIP pass? Rename mis-classification only affects the *live* overlay (healed at commit), so a conservative "treat ambiguous as interface edit" is safe but costs recall. Measure in Phase 1. 3. **Multi-dirty-file settle ordering.** When several files are dirty and cross-reference each other, is single-pass resolution sufficient or is a fixpoint needed? Bound the iterations; measure convergence in Phase 1. 4. **Confidence exposure.** Do we surface `live` as a boolean provenance only, or a graded confidence? Solution Architect leans boolean (provenance) for MCP simplicity; revisit if consumers ask. Note the plumbing for graded already exists independently of provenance: the `edges` table already carries a `confidence` column (written today by the LSIF/SCIP upserts in `travsr-store/src/lib.rs`), so a future graded signal can ride that field **without** touching the provenance enum. Recommended resolution: keep `provenance` boolean, reserve `confidence` as the graded channel if a consumer ever needs it. From 4afe3a3766664068dc7d3dae031ca52e709d8f27 Mon Sep 17 00:00:00 2001 From: Abhishek Date: Sat, 29 Aug 2026 13:15:21 +0530 Subject: [PATCH 13/23] [travsr-analysis] RFC-027 section 8: generic live reference detector, Go in the live lane Close the last gap in RFC-027's live semantic resolution: the on-save reference detection that only the three native-Phase-B languages had. Resolution, node mapping, target production, emit, ratification and the precision meter were already language-agnostic, so a detection-only pass is the whole remaining per-language cost. New crates/travsr-analysis/src/live_detect.rs. detect_live_refs(lang, source) returns LiveRefs { calls, fields, inheritance }: positions and names, nothing else. No receiver-type recovery, no signature building, no resolution. The editor's language server answers each position and the daemon owns both node ids, so the section 8.2 fencing rule is untouched and nothing here mints a VName. An unhandled language returns an empty set, keeping the daemon's gate the single place a language is switched on. travsr-analysis still depends on travsr-core alone. Go is the first non-native language, editor lane only (section 8.3). Two patterns cover every usable shape: call_expression with an identifier callee, and selector_expression field. A tree-sitter query cannot say "not the callee of a call", so x.Foo() and x.Foo are told apart from the tree: a selector that is the function child of a call_expression is a call, anything else is a field read, which keeps a field out of ref/call (#757). Go contributes no inheritance clauses on purpose. Interface satisfaction is structural and implicit and struct embedding is composition, so no clause in the source asserts "this type implements that interface". Emitting one would mean inferring it, which section 8.1 forbids. Go's implements relation stays commit-gated where scip-go derives it from real type information. Daemon wiring: live_language(path) is now the one per-language gate, returning the language and its lane (Native or EditorOnly). extract_live_unresolved returns a LiveRefSet enum so both detector shapes travel the same path. generic_targets_needing_editor turns every detected reference into an editor target, deduplicated on (line, name, kind). Nothing is filtered: with no lexical floor to partition against, dropping a target would lose the reference outright rather than hand it to another lane. The extension needed no provider-dispatch change, but SUPPORTED_LANGUAGES gates the request itself, so go had to be added there or nothing would fire. Also close a gap this exposed. live_precision_sample_by_language scores rows in ref_resolution_state, and only the save-path lexical pass ever wrote them: the editor lane recorded no claim at all, for every language. Invisible for native languages, fatal for an editor-only one, because Go emitted correct edges and produced zero claims, so section 12's per-language gate could never read a number for it and "ships enabled at precision >= 0.99" was unenforceable for the whole non-native tier. apply_live_resolutions now records what it claimed (resolved with the dst on emit, pending on abstention) through the new SqliteStore::upsert_ref_resolution_states, keyed on ref_col 0 so an editor answer upgrades that reference's existing pending row in place instead of forking one reference into two rows. record_generic_pendings gives an editor-only language the same honest abstention record on save. Measured end to end against a real gopls on a two-package Go module: 4 targets from an uncommitted edit, fmt.Println dropped as out-of-corpus, live ref/call edges to New/Start/Stop and a ref/field edge to Session.Count, self-heal on delete, live edges surviving a commit whose Go sidecar never ran (the #712 language-scoped sweep) and all four ratified in place with zero live rows left once scip-go was installed. live_precision.go reads 5,0,0: precision 1.0000 over 5/5 verifiable claims, zero disagreements. Carries the RefField, daemon-driven-positions and IsImplementation work for TypeScript and Python that was still uncommitted on this branch; it is interleaved with the above in extract_live_unresolved and live_resolve.rs and cannot be split cleanly. Tests: analysis 370, core 92, store 185, daemon 202 plus integration, ipc 29, extension tsc clean. --- crates/travsr-analysis/src/lib.rs | 1 + crates/travsr-analysis/src/live_detect.rs | 270 +++++ crates/travsr-analysis/src/phase_b_python.rs | 89 ++ crates/travsr-analysis/src/phase_b_rust.rs | 17 + .../travsr-analysis/src/phase_b_typescript.rs | 117 +++ crates/travsr-core/src/lib.rs | 20 + crates/travsr-daemon/src/lib.rs | 412 +++++++- crates/travsr-daemon/src/live_resolve.rs | 970 ++++++++++++++++-- crates/travsr-ipc/src/message.rs | 128 +++ crates/travsr-store/src/lib.rs | 135 ++- packages/travsr-vscode/src/daemonIpc.ts | 111 ++ packages/travsr-vscode/src/liveResolution.ts | 248 +++-- .../src/test/suite/daemonIpc.test.ts | 1 + 13 files changed, 2321 insertions(+), 198 deletions(-) create mode 100644 crates/travsr-analysis/src/live_detect.rs diff --git a/crates/travsr-analysis/src/lib.rs b/crates/travsr-analysis/src/lib.rs index 8d463229..9a99c948 100644 --- a/crates/travsr-analysis/src/lib.rs +++ b/crates/travsr-analysis/src/lib.rs @@ -16,6 +16,7 @@ pub mod emit; pub mod ffi; pub mod generic; +pub mod live_detect; pub mod skeleton; pub mod snippet; pub mod test_role; diff --git a/crates/travsr-analysis/src/live_detect.rs b/crates/travsr-analysis/src/live_detect.rs new file mode 100644 index 00000000..0f30f4a0 --- /dev/null +++ b/crates/travsr-analysis/src/live_detect.rs @@ -0,0 +1,270 @@ +//! RFC-027 section 8.2 — detection-only reference set for the live LSP lane. +//! +//! The three languages with a native Phase B extractor (Rust, TypeScript/JS, +//! Python) get their on-save reference set from that extractor: a fully typed +//! record carrying the caller's identity, the receiver type where it could be +//! recovered, and a per-language signature key. The other thirteen have Phase A +//! structural nodes and no on-save reference-detection pass at all, which is the +//! single reason the live lane never reached them — resolution, node mapping, +//! target production, emit, ratification, and the precision meter are already +//! language-agnostic. +//! +//! This module closes exactly that gap and nothing else. It answers *"which +//! references exist in this buffer, where, and of what kind"* and stops there: +//! +//! - **No receiver-type recovery.** `x.Foo()` yields the name `Foo` and its +//! line. What `x` is at that position is the editor's language server's +//! question, and asking it is the whole point of the LSP lane (RFC-027 +//! section 7.3b). +//! - **No signature building.** A signature is a claim about identity, and +//! identity belongs to SCIP (section 8.2 fencing rule). Nothing here mints a +//! VName or names a node. +//! - **No resolution.** The daemon maps positions to nodes against the graph it +//! already owns. +//! +//! Because it produces no receiver type and no signature key, the fail-closed +//! lexical floor (section 7.3a) cannot consume this output, so these languages +//! run the **editor lane only** (section 8.3). With no language server installed +//! they abstain and keep today's commit-gated behavior exactly, which is the +//! RFC's zero-regression floor (section 7.3c). +//! +//! Adding a language is one tree-sitter query plus an arm in the daemon's gate, +//! and each is measured against the per-`(language, kind)` precision gate +//! (section 12) before it ships enabled. + +use anyhow::Context as _; +use streaming_iterator::StreamingIterator as _; +use tree_sitter::{Parser, Query, QueryCursor}; + +use travsr_core::{InheritanceRef, Language}; + +/// Upper bound on a buffer this pass will parse. +/// +/// Matches the Phase A parsers' own limit. A generated file past this size is +/// not what the live lane exists for, and parsing it on every save would cost +/// more than the freshness is worth. +const MAX_SOURCE_BYTES: usize = 10 * 1024 * 1024; + +/// One detected reference: the name as written and the line it sits on. +/// +/// The line, not a column: the editor recovers the column by finding `name` on +/// the line, which is far cheaper than teaching every detector to carry UTF-16 +/// offsets, and is what the daemon-driven target contract already asks of it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LiveRef { + /// 1-based source line the reference appears on. + pub line: u32, + /// The referenced name exactly as written (`Foo` in `x.Foo()`). + pub name: String, +} + +/// Every reference in one buffer the live lane can act on, bucketed by the edge +/// kind it would become. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LiveRefs { + /// Call sites — `EdgeKind::RefCall`. + pub calls: Vec, + /// Field / property reads that are not calls — `EdgeKind::RefField`. + pub fields: Vec, + /// `extends` / `implements` style clauses — `EdgeKind::IsImplementation`. + pub inheritance: Vec, +} + +impl LiveRefs { + /// True when nothing was detected, so the caller can skip the whole pass. + pub fn is_empty(&self) -> bool { + self.calls.is_empty() && self.fields.is_empty() && self.inheritance.is_empty() + } +} + +/// Detect the live lane's reference set in `source`, or an empty set for a +/// language with no detector yet. +/// +/// Returning empty rather than erroring for an unhandled language keeps the +/// daemon's per-language gate the single place a language is turned on: this +/// function is safe to call for anything, and adding a language here without +/// adding it there still ships nothing. +pub fn detect_live_refs(lang: Language, source: &[u8]) -> anyhow::Result { + if source.len() > MAX_SOURCE_BYTES { + return Ok(LiveRefs::default()); + } + match lang { + Language::Go => detect_go(source), + _ => Ok(LiveRefs::default()), + } +} + +// ── Go ─────────────────────────────────────────────────────────────────────── +// +// Two patterns cover every reference shape the lane can use: +// +// `foo(...)` → (call_expression function: (identifier)) +// `x.Foo(...)` / `x.Foo` → (selector_expression field: (field_identifier)) +// +// The selector pattern matches both a method call and a plain field read, and +// tree-sitter queries cannot express "not the callee of a call" directly, so the +// two are told apart from the tree: a selector that *is* the `function` child of +// a `call_expression` is a call, anything else is a field read. That keeps a +// field read out of `RefCall`, so it never surfaces as a caller in +// `get_callers` / `get_blast_radius` while still being a use site (#757). +// +// **Go contributes no inheritance clauses, by design.** It has no `extends` / +// `implements`: interface satisfaction is structural and implicit, and struct +// embedding is composition, not implementation. There is no clause in the +// source that asserts "this type implements that interface", so emitting an +// `IsImplementation` edge would mean inferring one — precisely the guess +// section 8.1 forbids. Go's implements relation stays commit-gated, where the +// SCIP sidecar derives it from real type information. +const GO_QUERY: &str = r#" +(call_expression function: (identifier) @call.name) +(selector_expression field: (field_identifier) @sel.name) +"#; + +fn detect_go(source: &[u8]) -> anyhow::Result { + let language = tree_sitter::Language::new(tree_sitter_go::LANGUAGE); + let query = Query::new(&language, GO_QUERY).context("compiling Go live-detect query")?; + let capture_names: Vec = query + .capture_names() + .iter() + .map(|s| s.to_string()) + .collect(); + + let mut parser = Parser::new(); + parser + .set_language(&language) + .context("loading Go grammar")?; + // A parse that does not complete yields no references, which abstains for + // the whole file. Fail-closed, and the commit-gated path still covers it. + let Some(tree) = parser.parse(source, None) else { + return Ok(LiveRefs::default()); + }; + + let mut out = LiveRefs::default(); + let mut cursor = QueryCursor::new(); + let mut iter = cursor.matches(&query, tree.root_node(), source); + while let Some(m) = iter.next() { + for cap in m.captures { + let Some(cap_name) = capture_names.get(cap.index as usize).map(String::as_str) else { + continue; + }; + let Ok(name) = cap.node.utf8_text(source) else { + continue; + }; + let r = LiveRef { + line: cap.node.start_position().row as u32 + 1, + name: name.to_string(), + }; + match cap_name { + "call.name" => out.calls.push(r), + "sel.name" if is_call_callee(cap.node) => out.calls.push(r), + "sel.name" => out.fields.push(r), + _ => {} + } + } + } + Ok(out) +} + +/// True when `field` is the field of a selector that is itself the callee of a +/// call — `x.Foo(…)` rather than `x.Foo`. +fn is_call_callee(field: tree_sitter::Node<'_>) -> bool { + let Some(selector) = field.parent() else { + return false; + }; + let Some(parent) = selector.parent() else { + return false; + }; + parent.kind() == "call_expression" && parent.child_by_field_name("function") == Some(selector) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn names(refs: &[LiveRef]) -> Vec<(u32, &str)> { + refs.iter().map(|r| (r.line, r.name.as_str())).collect() + } + + #[test] + fn go_separates_calls_from_field_reads() { + let src = br#" +package main + +func run(s *Session) int { + s.Start() + helper() + n := s.count + return n +} +"#; + let refs = detect_live_refs(Language::Go, src).unwrap(); + assert_eq!(names(&refs.calls), vec![(5, "Start"), (6, "helper")]); + assert_eq!(names(&refs.fields), vec![(7, "count")]); + } + + #[test] + fn go_detects_package_qualified_calls() { + // `pkg.Foo()` is the shape that carries most of Go's cross-file value: + // the receiver is a package, not a value, and only the server knows + // which package the alias binds to. + let src = br#" +package main + +import svc "example.com/m/service" + +func run() { + svc.Start() +} +"#; + let refs = detect_live_refs(Language::Go, src).unwrap(); + assert_eq!(names(&refs.calls), vec![(7, "Start")]); + assert!(refs.fields.is_empty()); + } + + #[test] + fn go_emits_no_inheritance_clauses() { + // Interface satisfaction is implicit in Go and embedding is composition, + // so there is no clause to detect and nothing may be inferred. + let src = br#" +package main + +type Reader interface { Read() } + +type Base struct{ n int } + +type Derived struct { + Base +} +"#; + let refs = detect_live_refs(Language::Go, src).unwrap(); + assert!(refs.inheritance.is_empty()); + } + + #[test] + fn a_method_declaration_is_not_a_reference() { + let src = br#" +package main + +type T struct{} + +func (t *T) Foo() {} +"#; + let refs = detect_live_refs(Language::Go, src).unwrap(); + assert!( + refs.is_empty(), + "declarations are definitions, not references" + ); + } + + #[test] + fn a_language_without_a_detector_returns_empty() { + let refs = detect_live_refs(Language::Java, b"class A { void f() { g(); } }").unwrap(); + assert!(refs.is_empty()); + } + + #[test] + fn an_oversized_buffer_is_skipped() { + let src = vec![b' '; MAX_SOURCE_BYTES + 1]; + assert!(detect_live_refs(Language::Go, &src).unwrap().is_empty()); + } +} diff --git a/crates/travsr-analysis/src/phase_b_python.rs b/crates/travsr-analysis/src/phase_b_python.rs index c79a53c5..7a7e63e4 100644 --- a/crates/travsr-analysis/src/phase_b_python.rs +++ b/crates/travsr-analysis/src/phase_b_python.rs @@ -92,6 +92,68 @@ pub fn extract_native_phase_b( Ok((nodes, edges, unresolved)) } +/// RFC-027 live IsImplementation lane: `class Foo(Bar)` bases in `files`, as +/// unresolved references (base name + line). +/// +/// Unlike [`extract_native_phase_b`], which emits `IsImplementation` edges under +/// a same-file assumption (`py_vname(corpus, vname_path, ...)` for the base, so a +/// cross-file base dangles), this returns the raw base so the daemon resolves it +/// against the real node table and abstains otherwise. The clause sits on the +/// class's own declaration, so `impl_type` is `None`: the daemon derives the +/// source from the line. +pub fn extract_unresolved_inheritance( + root: &Path, + files: Option<&[(PathBuf, String)]>, +) -> anyhow::Result> { + let language = tree_sitter::Language::new(tree_sitter_python::LANGUAGE); + let inherit_q = Query::new(&language, INHERIT_QUERY).context("python inherit query")?; + + let walked; + let file_pairs: &[(PathBuf, String)] = match files { + Some(f) => f, + None => { + walked = collect_source_files(root, &["py", "pyi"]); + &walked + } + }; + + let mut out: Vec = Vec::new(); + for (abs_path, _vname_path) in file_pairs { + let Ok(source) = std::fs::read(abs_path) else { + continue; + }; + let mut parser = Parser::new(); + if parser.set_language(&language).is_err() { + continue; + } + let Some(tree) = parser.parse(&source, None) else { + continue; + }; + let cap_names: Vec = inherit_q + .capture_names() + .iter() + .map(|s| s.to_string()) + .collect(); + let mut cursor = QueryCursor::new(); + let mut iter = cursor.matches(&inherit_q, tree.root_node(), source.as_slice()); + while let Some(m) = iter.next() { + for &cap in m.captures { + if cap_names.get(cap.index as usize).map(String::as_str) != Some("base.name") { + continue; + } + let Ok(text) = cap.node.utf8_text(source.as_slice()) else { + continue; + }; + out.push(travsr_core::InheritanceRef { + base_name: text.to_string(), + line: cap.node.start_position().row as u32 + 1, + }); + } + } + } + Ok(out) +} + // ── Per-file analysis ───────────────────────────────────────────────────────── fn extract_file_edges( @@ -541,6 +603,33 @@ fn walk(root: &Path, dir: &Path, exts: &[&str], out: &mut Vec<(PathBuf, String)> mod tests { use super::*; + /// RFC-027 live IsImplementation lane: `class Dog(Animal, Mixin)` bases come + /// back as unresolved references, one per base, carrying the base name and + /// the line it is written on. + #[test] + fn class_bases_come_back_as_inheritance_refs() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("animals.py"); + std::fs::write( + &file, + b"class Animal:\n pass\n\nclass Dog(Animal, Mixin):\n pass\n", + ) + .unwrap(); + let files = vec![(file.clone(), "animals.py".to_string())]; + let refs = extract_unresolved_inheritance(dir.path(), Some(&files)).unwrap(); + + // Both bases are on line 4 (the `class Dog(...)` line). + assert!( + refs.iter().any(|r| r.base_name == "Animal" && r.line == 4), + "base Animal captured at its line, got {refs:?}", + ); + assert!( + refs.iter().any(|r| r.base_name == "Mixin" && r.line == 4), + "base Mixin captured at its line, got {refs:?}", + ); + assert_eq!(refs.len(), 2, "exactly the two bases: {refs:?}"); + } + /// Bare-call tagging carries both candidate names, so a PascalCase free /// function is not silently lost (#716 review). /// diff --git a/crates/travsr-analysis/src/phase_b_rust.rs b/crates/travsr-analysis/src/phase_b_rust.rs index 27037e9f..c84d3cf9 100644 --- a/crates/travsr-analysis/src/phase_b_rust.rs +++ b/crates/travsr-analysis/src/phase_b_rust.rs @@ -176,6 +176,23 @@ pub fn extract_native_phase_b( Ok((nodes, edges, unresolved, refs)) } +// RFC-027 live IsImplementation lane, Rust — DEFERRED, not implemented here. +// +// Rust's `impl Trait for Type` does not fit the live model TS/Python use, for +// reasons that are structural, not effort: +// 1. The `impl` block is a separate item from the type, so the natural edge +// source (`struct:Foo`) lives in a different file than the clause. Saving +// the impl file would not invalidate that edge, so deleting an impl would +// leave a stale (wrong) edge until the next commit — the exact §8.1 failure. +// 2. Rust's own `impl:Foo` node carries no span and collapses every impl of a +// type into one node, so it cannot serve as a per-impl source either. +// 3. Rust native Phase B emits no `IsImplementation` edge at all, so a live one +// has nothing to ratify against (swept every commit) and no oracle for the +// precision meter. +// Making it correct requires teaching Rust's committed Phase B to emit +// IsImplementation from a properly-spanned per-impl node first — a change to the +// deterministic pipeline, tracked separately. + // ── Cargo.toml dependency graph ─────────────────────────────────────────────── fn extract_cargo_deps(corpus: &str, root: &Path) -> anyhow::Result<(Vec, Vec)> { diff --git a/crates/travsr-analysis/src/phase_b_typescript.rs b/crates/travsr-analysis/src/phase_b_typescript.rs index b547b39a..04db72f8 100644 --- a/crates/travsr-analysis/src/phase_b_typescript.rs +++ b/crates/travsr-analysis/src/phase_b_typescript.rs @@ -109,6 +109,93 @@ pub fn extract_native_phase_b( Ok((nodes, edges, unresolved)) } +/// RFC-027 live IsImplementation lane: the `extends`/`implements` clauses in +/// `files`, as unresolved references (base type name + line). +/// +/// Unlike [`extract_native_phase_b`], which emits `IsImplementation` edges under +/// a same-file assumption (`ts_vname(corpus, vname_path, ...)` for the base, so a +/// cross-file base dangles), this returns the raw clause so the daemon can +/// resolve the base against the *real* node table — lexically when it is unique +/// repo-wide, or via the editor's definition provider — and abstain otherwise. +/// It never resolves and never mints identity. +pub fn extract_unresolved_inheritance( + root: &Path, + files: Option<&[(PathBuf, String)]>, +) -> anyhow::Result> { + let language = tree_sitter::Language::new(tree_sitter_typescript::LANGUAGE_TYPESCRIPT); + let extends_q = Query::new(&language, EXTENDS_QUERY).context("ts extends query")?; + let implements_q = Query::new(&language, IMPLEMENTS_QUERY).context("ts implements query")?; + + let walked; + let file_pairs: &[(PathBuf, String)] = match files { + Some(f) => f, + None => { + walked = collect_source_files(root, &["ts", "tsx", "mts", "cts"]); + &walked + } + }; + + let mut out: Vec = Vec::new(); + for (abs_path, _vname_path) in file_pairs { + let Ok(source) = std::fs::read(abs_path) else { + continue; + }; + let mut parser = Parser::new(); + if parser.set_language(&language).is_err() { + continue; + } + let Some(tree) = parser.parse(&source, None) else { + continue; + }; + collect_inheritance( + &extends_q, + "extends.base", + tree.root_node(), + &source, + &mut out, + ); + collect_inheritance( + &implements_q, + "implements.iface", + tree.root_node(), + &source, + &mut out, + ); + } + Ok(out) +} + +/// Push one `InheritanceRef` per match of `base_cap` in `q`, carrying the base +/// name's text and its 1-based line. +fn collect_inheritance( + q: &Query, + base_cap: &str, + root: tree_sitter::Node, + source: &[u8], + out: &mut Vec, +) { + let cap_names: Vec = q.capture_names().iter().map(|s| s.to_string()).collect(); + let mut cursor = QueryCursor::new(); + let mut iter = cursor.matches(q, root, source); + while let Some(m) = iter.next() { + for &cap in m.captures { + let Some(name) = cap_names.get(cap.index as usize) else { + continue; + }; + if name != base_cap { + continue; + } + let Ok(text) = cap.node.utf8_text(source) else { + continue; + }; + out.push(travsr_core::InheritanceRef { + base_name: text.to_string(), + line: cap.node.start_position().row as u32 + 1, + }); + } + } +} + // ── Per-file analysis ───────────────────────────────────────────────────────── fn extract_file_edges( @@ -757,6 +844,36 @@ class App { assert_eq!(recv_type_for_call(source, "run"), None); } + /// RFC-027 live IsImplementation lane: `extends`/`implements` clauses come + /// back as unresolved references carrying the base type's name and the line + /// it is written on, so the daemon can resolve them against the real node + /// table rather than the same-file assumption `extract_native_phase_b` makes. + #[test] + fn extends_and_implements_come_back_as_inheritance_refs() { + let dir = std::env::temp_dir().join(format!("rfc027_inherit_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("order.ts"); + std::fs::write( + &file, + b"import { Base } from './base';\nexport class Order extends Base implements Shape {\n area() { return 0; }\n}\n", + ) + .unwrap(); + let files = vec![(file.clone(), "order.ts".to_string())]; + let refs = extract_unresolved_inheritance(&dir, Some(&files)).unwrap(); + + // Both clauses are on line 2 (the class declaration line). + assert!( + refs.iter().any(|r| r.base_name == "Base" && r.line == 2), + "extends Base captured at its line, got {refs:?}", + ); + assert!( + refs.iter().any(|r| r.base_name == "Shape" && r.line == 2), + "implements Shape captured at its line, got {refs:?}", + ); + assert_eq!(refs.len(), 2, "exactly the two clauses, no more: {refs:?}"); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn new_expression_emits_class_unresolved_call() { let dir = std::env::temp_dir().join(format!("e4_ts_{}", std::process::id())); diff --git a/crates/travsr-core/src/lib.rs b/crates/travsr-core/src/lib.rs index fe211d63..896a0d0e 100644 --- a/crates/travsr-core/src/lib.rs +++ b/crates/travsr-core/src/lib.rs @@ -1145,6 +1145,26 @@ pub struct UnresolvedCall { pub recv_type: Option, } +/// An `extends`/`implements` clause the native extractor found but does not +/// resolve cross-file, for RFC-027's live `IsImplementation` lane. +/// +/// Like [`UnresolvedCall`] it carries no identity — only the base type's simple +/// name and the line it is written on, which is exactly what the live lane needs +/// to resolve it (lexically against a unique repo-wide definition, or via the +/// editor's definition provider) and to abstain otherwise. The daemon derives +/// the edge's source (the implementing class) from the line and owns both node +/// ids, so nothing here mints a VName. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct InheritanceRef { + /// The base type's simple name (the edge *target*), exactly as written: the + /// superclass in `class C extends B`, the interface in `implements I`. + pub base_name: String, + /// 1-based line the base name appears on — the class declaration line, which + /// the daemon maps to the implementing class (the edge source) via + /// `enclosing_definition_at`. + pub line: u32, +} + // ── Import Resolution ──────────────────────────────────────────────────────── // // Query-time bridge over missing `ResolvesTo` edges. diff --git a/crates/travsr-daemon/src/lib.rs b/crates/travsr-daemon/src/lib.rs index 15a2a3ad..9269296e 100644 --- a/crates/travsr-daemon/src/lib.rs +++ b/crates/travsr-daemon/src/lib.rs @@ -3655,26 +3655,82 @@ fn run_with_phase_b_finish_guard( /// behavior, which is a recall cost the next Phase B run repairs. const LIVE_CLOSURE_FILE_CAP: usize = 32; -fn live_resolve_file(store: &mut SqliteStore, corpus: &str, repo_root: &Path, abs_path: &Path) { +/// Which live lanes a language's on-save reference detection can feed +/// (RFC-027 sections 7.3 and 8.3). +enum LiveLane { + /// A native Phase B extractor detects the references. Its records carry the + /// caller's identity, the receiver type where it could be recovered, and a + /// per-language signature key, so the fail-closed lexical floor (§7.3a) and + /// the editor lane (§7.3b) both run. + Native, + /// The generic tree-sitter detector detects the references. It recovers no + /// receiver type and builds no signature key by design, so the lexical floor + /// has nothing to match on and these languages run the **editor lane + /// alone** (§8.3). With no language server they abstain, which is today's + /// commit-gated behavior exactly (§7.3c, zero regression). + EditorOnly, +} + +/// The language of `abs_path` and the live lane it participates in, or `None` +/// when the live lane does not apply to it at all. +/// +/// This is the one place a language is switched on. `Language::TypeScript` +/// covers .ts/.tsx/.js/.jsx — there is no separate JavaScript variant, and the +/// native extractor handles both. +/// +/// The non-native arms are added one at a time, each measured against the +/// per-`(language, kind)` precision gate before the next joins (RFC-027 §8.6). +/// A language absent here costs nothing on save and keeps commit-gated behavior. +fn live_language(abs_path: &Path) -> Option<(travsr_core::Language, LiveLane)> { + let ext = abs_path.extension().and_then(|e| e.to_str()).unwrap_or(""); + match travsr_core::Language::from_extension(ext)? { + lang @ (travsr_core::Language::TypeScript + | travsr_core::Language::Rust + | travsr_core::Language::Python) => Some((lang, LiveLane::Native)), + lang @ travsr_core::Language::Go => Some((lang, LiveLane::EditorOnly)), + _ => None, + } +} + +/// The references detected in a saved file, in whichever shape its detector +/// produces. +enum LiveRefSet { + /// [`LiveLane::Native`]: the native extractor's typed reference records plus + /// the inheritance clauses its dedicated detector found. + Native { + unresolved: Vec, + inheritance: Vec, + }, + /// [`LiveLane::EditorOnly`]: positions and names only. + Generic(travsr_analysis::live_detect::LiveRefs), +} + +/// The reference set for a saved file and its repo-relative path, or `None` when +/// the live lane does not apply (unsupported language, the precision gate +/// disabled it, detection failed, or the file holds no references). +/// +/// Shared by the save-path lexical lane ([`live_resolve_file`]) and the editor's +/// target request (RFC-027 daemon-driven positions), so the two agree on exactly +/// which references exist in a dirty file. Takes `&SqliteStore` — detection and +/// the gate are read-only — so a `&mut` caller can reborrow and keep writing. +fn extract_live_unresolved( + store: &SqliteStore, + corpus: &str, + repo_root: &Path, + abs_path: &Path, +) -> Option<(String, LiveRefSet)> { let vname_path = abs_path .strip_prefix(repo_root) .unwrap_or(abs_path) .to_string_lossy() .replace('\\', "/"); - let vname_path = vname_path.as_str(); - let ext = abs_path.extension().and_then(|e| e.to_str()).unwrap_or(""); - // Only the native call-site extractor is per-language; everything downstream - // (`resolve_unambiguous_lexical`, `candidate_signatures`, the emit path) is - // language-agnostic. `Language::TypeScript` covers .ts/.tsx/.js/.jsx — there - // is no separate JavaScript variant, and the native extractor handles both; - // Rust is `.rs`. - let lang = match travsr_core::Language::from_extension(ext) { - Some(lang @ (travsr_core::Language::TypeScript | travsr_core::Language::Rust)) => lang, - _ => return, - }; + // Detection is the only per-language stage; everything downstream + // (`resolve_unambiguous_lexical`, `candidate_signatures`, node mapping, + // target production, emit, ratification, the meter) is language-agnostic. + let (lang, lane) = live_language(abs_path)?; // RFC-027 section 12: a language the cumulative meter has measured below the // shipping bar is disabled — the fail-closed floor (§7.3c) is better than an - // un-ratified wrong edge. Extraction is skipped entirely so a disabled + // un-ratified wrong edge. Detection is skipped entirely so a disabled // language costs nothing on save. if !live_lane_enabled_for(store, lang.as_str()) { tracing::debug!( @@ -3682,10 +3738,27 @@ fn live_resolve_file(store: &mut SqliteStore, corpus: &str, repo_root: &Path, ab language = lang.as_str(), "live: lane disabled for language by the precision gate" ); - return; + return None; } - let files = [(abs_path.to_path_buf(), vname_path.to_string())]; - // The two extractors return different arities: TypeScript is + if let LiveLane::EditorOnly = lane { + // RFC-027 section 8.2: detection only — no receiver-type recovery, no + // signature building, no resolution. The editor's language server + // answers each position; the daemon owns both node ids (§8.2 fencing). + let source = std::fs::read(abs_path).ok()?; + let refs = match travsr_analysis::live_detect::detect_live_refs(lang, &source) { + Ok(refs) => refs, + Err(e) => { + tracing::debug!(path = %vname_path, error = %e, "live: reference detection failed"); + return None; + } + }; + if refs.is_empty() { + return None; + } + return Some((vname_path, LiveRefSet::Generic(refs))); + } + let files = [(abs_path.to_path_buf(), vname_path.clone())]; + // The extractors return different arities: TypeScript and Python are // `(nodes, edges, unresolved)`, Rust is `(nodes, edges, unresolved, refs)` // (the same-file `refs` the live lane does not consume). let extraction = match lang { @@ -3701,20 +3774,132 @@ fn live_resolve_file(store: &mut SqliteStore, corpus: &str, repo_root: &Path, ab travsr_analysis::phase_b_rust::extract_native_phase_b(corpus, repo_root, Some(&files)) .map(|(_, _, unresolved, _)| unresolved) } - // Unreachable: the gate above admits only the two arms handled here. - _ => return, + travsr_core::Language::Python => { + travsr_analysis::phase_b_python::extract_native_phase_b(corpus, repo_root, Some(&files)) + .map(|(_, _, unresolved)| unresolved) + } + // Unreachable: the gate above admits only the three arms handled here. + _ => return None, }; let unresolved = match extraction { Ok(unresolved) => unresolved, Err(e) => { tracing::debug!(path = %vname_path, error = %e, "live: call-site extraction failed"); - return; + return None; } }; - if unresolved.is_empty() { - return; + // RFC-027 live edge-kind scope: the IsImplementation lane's `extends` / + // `implements` clauses. TypeScript only for now — the resolver is + // language-agnostic, but the detector is per-language and is measured before + // each language joins (RFC-027 live edge-kind scope §6.2). Never fails the + // whole pass: a detector error is a freshness miss, not a correctness one. + // TS and Python only. Rust's `impl Trait for Type` does not fit the live + // IsImplementation model and is deferred (see phase_b_rust.rs). + let inheritance = match lang { + travsr_core::Language::TypeScript => { + travsr_analysis::phase_b_typescript::extract_unresolved_inheritance( + repo_root, + Some(&files), + ) + .unwrap_or_default() + } + travsr_core::Language::Python => { + travsr_analysis::phase_b_python::extract_unresolved_inheritance(repo_root, Some(&files)) + .unwrap_or_default() + } + _ => Vec::new(), + }; + if unresolved.is_empty() && inheritance.is_empty() { + return None; + } + Some(( + vname_path, + LiveRefSet::Native { + unresolved, + inheritance, + }, + )) +} + +/// RFC-027 sections 8.3 and 9.2: record every reference an editor-only language +/// detected as `pending`, replacing the file's previous rows. +/// +/// "There is a reference here and its target is not yet known" is true and +/// useful, and saying it is what makes fail-closed honest rather than merely +/// quiet. It is also the row the editor lane upgrades in place when its provider +/// answers, which is how a language with no lexical floor still produces the +/// claims the precision meter scores (§12). +/// +/// A reference with no enclosing definition has no node to own the row and is +/// skipped, exactly as the resolver would abstain on it. +fn record_generic_pendings( + store: &mut SqliteStore, + corpus: &str, + path: &str, + detected: &travsr_analysis::live_detect::LiveRefs, +) { + let named = detected + .calls + .iter() + .chain(detected.fields.iter()) + .map(|r| (r.line, r.name.as_str())) + .chain( + detected + .inheritance + .iter() + .map(|r| (r.line, r.base_name.as_str())), + ); + let mut states: Vec = Vec::new(); + for (line, name) in named { + let Ok(Some(src)) = store.enclosing_definition_at(corpus, path, line) else { + continue; + }; + states.push(travsr_store::RefResolution { + src, + ref_line: line, + // 0, matching the native lane, so the editor's answer for this same + // reference upgrades this row rather than adding a second one. + ref_col: 0, + name: name.to_string(), + state: "pending", + resolved_dst: None, + }); + } + if let Err(e) = store.replace_ref_resolution_states(corpus, path, &states) { + tracing::debug!(error = %e, "recording ref_resolution_state failed"); } - let outcome = live_resolve::resolve_unambiguous_lexical(store, corpus, vname_path, &unresolved); +} + +fn live_resolve_file(store: &mut SqliteStore, corpus: &str, repo_root: &Path, abs_path: &Path) { + let Some((vname_path, refs)) = extract_live_unresolved(store, corpus, repo_root, abs_path) + else { + return; + }; + let (unresolved, inheritance) = match refs { + LiveRefSet::Native { + unresolved, + inheritance, + } => (unresolved, inheritance), + // RFC-027 section 8.3: an editor-only language has no lexical floor — + // the generic detector recovers no receiver type and builds no signature + // key, so there is nothing to resolve without a server. What this pass + // still owes the file is the honest abstention record (§9.2): every + // detected reference starts `pending`, and the editor lane upgrades the + // ones its provider answers. Writing them here is also what clears the + // rows describing the pre-edit text, which `reindex_replace` leaves + // alone. + LiveRefSet::Generic(detected) => { + record_generic_pendings(store, corpus, &vname_path, &detected); + return; + } + }; + let outcome = live_resolve::resolve_unambiguous_lexical( + store, + corpus, + &vname_path, + &unresolved, + &inheritance, + ); if outcome.emitted > 0 { tracing::debug!( event = "live.file", @@ -3726,6 +3911,45 @@ fn live_resolve_file(store: &mut SqliteStore, corpus: &str, repo_root: &Path, ab } } +/// RFC-027 daemon-driven positions: the references in `abs_path` the editor +/// should resolve with a language provider, computed from the same native +/// extractor the save-path lexical lane uses. +/// +/// The daemon owns *which* references and *which* edge kind; the editor owns +/// only the exact column and the provider round-trip. This is what replaces the +/// editor's blind `identifier(` scan and lets the lane reach every language the +/// native extractor covers, not just what an English-shaped regex could spot. +fn live_resolution_targets( + store: &SqliteStore, + corpus: &str, + repo_root: &Path, + abs_path: &Path, +) -> Vec { + let Some((vname_path, refs)) = extract_live_unresolved(store, corpus, repo_root, abs_path) + else { + return Vec::new(); + }; + match refs { + LiveRefSet::Native { + unresolved, + inheritance, + } => { + let mut targets = live_resolve::targets_needing_editor(store, &unresolved); + targets.extend(live_resolve::inheritance_targets_needing_editor( + store, + corpus, + &vname_path, + &inheritance, + )); + targets + } + // RFC-027 section 8.3: no lexical floor runs for these languages, so + // there is no lane to partition against and every detected reference is + // an editor target. + LiveRefSet::Generic(refs) => live_resolve::generic_targets_needing_editor(&refs), + } +} + /// RFC-027 section 12: the continuous precision meter. /// /// "Is the live lane worse than nothing?" is answered empirically, not by @@ -8591,6 +8815,117 @@ mod tests { /// method-on-receiver case the lexical lane must refuse. Refusing quietly /// and refusing honestly look identical in the edge table; they differ in /// whether anything can later say *why* the answer is thin. + /// RFC-027 section 8: a non-native language reaches the live lane through + /// on-save reference *detection* alone. Go has Phase A nodes and no native + /// Phase B extractor, so its references become editor targets — a + /// same-package cross-file call the graph could never resolve on its own, + /// and a field read as `ref/field` so it never reads as a caller (#757). + #[test] + fn a_go_file_yields_editor_targets_from_the_generic_detector() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + git_init(tmp.path()); + + std::fs::write( + tmp.path().join("go.mod"), + "module example.com/m\n\ngo 1.21\n", + ) + .unwrap(); + std::fs::write( + tmp.path().join("session.go"), + "package main\n\ntype Session struct {\n\tcount int\n}\n\nfunc (s *Session) Start() {}\n", + ) + .unwrap(); + let caller = tmp.path().join("run.go"); + std::fs::write( + &caller, + "package main\n\nfunc Run(s *Session) int {\n\ts.Start()\n\treturn s.count\n}\n", + ) + .unwrap(); + + std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); + init_repo(tmp.path()).unwrap(); + std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); + + let db_path = tmp.path().join(".travsr/graph.db"); + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + let corpus = store.get_meta("corpus").unwrap().unwrap_or_default(); + let targets = live_resolution_targets(&store, &corpus, tmp.path(), &caller); + + let start = targets + .iter() + .find(|t| t.name == "Start") + .expect("the method call must be an editor target"); + assert_eq!(start.ref_line, 4); + assert_eq!(start.edge_kind, "ref/call"); + assert_eq!(start.provider, "definition"); + + let count = targets + .iter() + .find(|t| t.name == "count") + .expect("the field read must be an editor target"); + assert_eq!(count.ref_line, 5); + assert_eq!(count.edge_kind, "ref/field"); + } + + /// The lexical floor is native-only (section 8.3): the generic detector + /// recovers no receiver type and builds no signature key, so there is + /// nothing for it to match on and the save path must not parse the file to + /// discard the result. + #[test] + fn a_go_save_emits_nothing_without_an_editor() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + git_init(tmp.path()); + + std::fs::write( + tmp.path().join("go.mod"), + "module example.com/m\n\ngo 1.21\n", + ) + .unwrap(); + std::fs::write( + tmp.path().join("session.go"), + "package main\n\ntype Session struct{}\n\nfunc (s *Session) Start() {}\n", + ) + .unwrap(); + let caller = tmp.path().join("run.go"); + std::fs::write( + &caller, + "package main\n\nfunc Run(s *Session) {\n\ts.Start()\n}\n", + ) + .unwrap(); + + std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); + init_repo(tmp.path()).unwrap(); + std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); + + let db_path = tmp.path().join(".travsr/graph.db"); + let corpus = { + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + store.get_meta("corpus").unwrap().unwrap_or_default() + }; + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + live_resolve_file(&mut store, &corpus, tmp.path(), &caller); + } + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + assert_eq!( + store.count_edges_with_provenance("live").unwrap(), + 0, + "an editor-only language must emit nothing on save" + ); + // Abstention is recorded, not dropped (§9.2): every detected reference + // is a pending row the editor lane later upgrades in place, and it is + // what gives the precision meter a claim to score for this language. + let pending = store.pending_refs_in_file(&corpus, "run.go").unwrap(); + assert!( + pending + .iter() + .any(|(name, line)| name == "Start" && *line == 4), + "the unresolved call must leave a pending row, got {pending:?}" + ); + } + #[test] fn an_abstention_is_recorded_as_pending() { let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); @@ -11511,6 +11846,39 @@ fn handle_control_message( enqueue_dirty_callers(dirty, repo_root, index_tx); (ControlResponse::ok(None), false) } + Ok(ControlMessage::RequestLiveResolutionTargets { + repo_root: reported_root, + session, + file, + buffer_version: _, + }) => { + // Same identity guard as ReportLiveResolution: discovery enumerates a + // namespace, so the request has to say which repo it is for. + let reported = travsr_ipc::normalize_repo_root(std::path::Path::new(&reported_root)); + if reported != travsr_ipc::normalize_repo_root(repo_root) { + return ( + ControlResponse::err("request is for a different repo".to_string()), + false, + ); + } + + let s = store.lock().unwrap_or_else(|e| e.into_inner()); + let corpus = s.get_meta("corpus").ok().flatten().unwrap_or_default(); + let abs_path = repo_root.join(&file); + let targets = live_resolution_targets(&s, &corpus, repo_root, &abs_path); + drop(s); + + tracing::debug!( + event = "live.targets", + session = %session, + file = %file, + count = targets.len(), + "live resolution targets computed" + ); + let mut resp = ControlResponse::ok(None); + resp.result = serde_json::to_value(&targets).ok(); + (resp, false) + } Ok(ControlMessage::ReportLiveResolution { repo_root: reported_root, session, diff --git a/crates/travsr-daemon/src/live_resolve.rs b/crates/travsr-daemon/src/live_resolve.rs index 4278b38a..b2443f9c 100644 --- a/crates/travsr-daemon/src/live_resolve.rs +++ b/crates/travsr-daemon/src/live_resolve.rs @@ -30,8 +30,8 @@ //! which for a "zero structural hallucinations" product is a breach of the //! value proposition rather than a quality regression. -use travsr_core::{Edge, EdgeKind, NodeId, UnresolvedCall}; -use travsr_ipc::message::LiveResolution; +use travsr_core::{Edge, EdgeKind, InheritanceRef, NodeId, UnresolvedCall}; +use travsr_ipc::message::{LiveResolution, LiveResolutionTarget}; use travsr_store::{SqliteStore, Store}; /// What one live-resolution pass did, for logging and the Phase 3 precision @@ -68,20 +68,59 @@ pub fn apply_live_resolutions( resolutions: &[LiveResolution], ) -> LiveOutcome { let mut outcome = LiveOutcome::default(); + // Section 12: what this lane claimed, so the precision meter can score it. + // Without a claim row an editor-resolved edge is invisible to the meter, and + // a language that runs this lane alone (every non-native one, §8.3) could + // never earn its per-language gate a reading. + let mut states: Vec = Vec::with_capacity(resolutions.len()); for r in resolutions { - match resolve_one(store, corpus, file, r) { - Some(edge) => { - if let Err(e) = store.put_edge_live(&edge) { + // The claim is keyed on the reference, so the edge's own source is the + // key's `src`. A resolution whose line maps to no enclosing definition + // has no reference to record and abstains below anyway. + let src = store + .enclosing_definition_at(corpus, file, r.ref_line) + .ok() + .flatten(); + let claimed = match resolve_one(store, corpus, file, r) { + Some(edge) => match store.put_edge_live(&edge) { + Ok(()) => Some(edge.dst), + Err(e) => { // A write failure is a freshness loss, never a correctness // one: the commit-gated path still ratifies this region. tracing::debug!(error = %e, "live edge write failed"); - outcome.abstain(); - } else { - outcome.emit(); + None } - } - None => outcome.abstain(), + }, + None => None, + }; + if claimed.is_some() { + outcome.emit(); + } else { + outcome.abstain(); } + if let Some(src) = src { + // `ref_col` is 0, not the column the editor sent, so this row + // collides with the save-path pass's row for the same reference and + // upgrades it in place. Keying on the real column would fork one + // reference into a stale `pending` row and a `resolved` one. + states.push(travsr_store::RefResolution { + src, + ref_line: r.ref_line, + ref_col: 0, + name: r.name.clone(), + state: if claimed.is_some() { + "resolved" + } else { + "pending" + }, + resolved_dst: claimed, + }); + } + } + if let Err(e) = store.upsert_ref_resolution_states(&states) { + // Losing the claim costs the meter its evidence, never the graph its + // correctness. + tracing::debug!(error = %e, "recording editor-lane ref_resolution_state failed"); } if outcome.emitted > 0 || outcome.pending > 0 { tracing::debug!( @@ -97,20 +136,27 @@ pub fn apply_live_resolutions( /// Map one editor resolution to an edge, or `None` to abstain. fn resolve_one(store: &SqliteStore, corpus: &str, file: &str, r: &LiveResolution) -> Option { - // The call site's enclosing definition is the edge's source. A reference at + // The editor names the edge kind it resolved (RFC-027 live edge-kind scope). + // Restricted to the Bucket-B kinds the lane may emit, so a malformed or + // hostile report cannot make it write a kind it was never scoped to. + let edge = live_edge_kind(&r.edge_kind)?; + // The reference's enclosing definition is the edge's source. A reference at // top level (no enclosing function) has no caller node to attach to. let src = store .enclosing_definition_at(corpus, file, r.ref_line) .ok() .flatten()?; - // Section 7.5: the definition the editor pointed at. If the position lands - // in no definition span the target is outside the graph (a node_modules - // file, a generated stub, a file the indexer skipped), so abstain. + // Section 7.5: the node the editor pointed at, restricted to the kinds valid + // for this edge kind (a field ref lands on a `field` node, an implements + // clause on an interface/trait, a call on a definition). The kind set is the + // gate: a target of the wrong kind, or a position in no matching span (a + // node_modules file, a generated stub, an unindexed file), maps to nothing + // and abstains (§8.1). let dst = store - .enclosing_definition_at(corpus, &r.target_path, r.target_line) + .enclosing_node_at(corpus, &r.target_path, r.target_line, target_kinds(edge)) .ok() .flatten()?; - edge_if_sound(store, src, dst) + edge_if_sound(store, src, dst, edge) } /// RFC-027 section 6: what an edit actually invalidates. @@ -220,13 +266,20 @@ pub fn resolve_unambiguous_lexical( corpus: &str, path: &str, unresolved: &[UnresolvedCall], + inheritance: &[InheritanceRef], ) -> LiveOutcome { let mut outcome = LiveOutcome::default(); // Section 9.2: every reference this pass saw, and what became of it. An // abstention is recorded, not dropped — "there is a call here and its // target is not yet known" is true and useful, and saying it is what makes // fail-closed honest rather than merely quiet. - let mut states: Vec = Vec::with_capacity(unresolved.len()); + // + // Calls and inheritance clauses share one `states` vector because + // `replace_ref_resolution_states` clears the whole file's rows before + // inserting: recording them in two passes would make the second wipe the + // first. One pass, one replace. + let mut states: Vec = + Vec::with_capacity(unresolved.len() + inheritance.len()); for call in unresolved { let name = travsr_core::ident::leaf_of(&call.callee_sig).to_string(); @@ -267,6 +320,48 @@ pub fn resolve_unambiguous_lexical( }); } + // RFC-027 live edge-kind scope: the IsImplementation floor. An `extends` / + // `implements` clause whose base type has exactly one definition repo-wide + // resolves with no language server, exactly as the call floor above does. An + // ambiguous or cross-file-only base abstains and becomes an editor target. + for r in inheritance { + // The clause sits on the implementing class's own declaration, so the + // edge source is the definition enclosing that line. + let src = store + .enclosing_definition_at(corpus, path, r.line) + .ok() + .flatten(); + let claimed = match src.and_then(|src| inheritance_edge(store, src, &r.base_name)) { + Some(edge) => match store.put_edge_live(&edge) { + Ok(()) => Some(edge.dst), + Err(e) => { + tracing::debug!(error = %e, "live inheritance edge write failed"); + None + } + }, + None => None, + }; + if claimed.is_some() { + outcome.emit(); + } else { + outcome.abstain(); + } + if let Some(src) = src { + states.push(travsr_store::RefResolution { + src, + ref_line: r.line, + ref_col: 0, + name: r.base_name.clone(), + state: if claimed.is_some() { + "resolved" + } else { + "pending" + }, + resolved_dst: claimed, + }); + } + } + if let Err(e) = store.replace_ref_resolution_states(corpus, path, &states) { // Losing the pending record costs the freshness note its detail, never // the graph its correctness. @@ -285,10 +380,202 @@ pub fn resolve_unambiguous_lexical( } fn lexical_one(store: &SqliteStore, call: &UnresolvedCall) -> Option { + let edge = lexical_edge_kind(call); let dst = candidate_signatures(call) .into_iter() - .find_map(|sig| unique_definition(store, &sig))?; - edge_if_sound(store, call.src, dst) + .find_map(|sig| unique_definition(store, &sig, edge))?; + edge_if_sound(store, call.src, dst, edge) +} + +/// The edge kind a native call-site record resolves to. The extractor encodes a +/// field access as `field:...` (with a recovered receiver type); everything else +/// it emits is a call. A field read must become `ref/field`, never `ref/call`, +/// so it never surfaces as a caller in `get_callers` / `get_blast_radius` (#757) +/// while still appearing as a use site in `find_references`. +fn lexical_edge_kind(call: &UnresolvedCall) -> EdgeKind { + if call.callee_sig.starts_with("field:") { + EdgeKind::RefField + } else { + EdgeKind::RefCall + } +} + +/// RFC-027 daemon-driven positions: the references the editor should resolve via +/// a language provider, from the native extractor's reference set. +/// +/// Fail-closed and surgical, the two properties §7.6 asks of the LSP lane: +/// +/// - **A reference the lexical lane can settle is skipped.** `lexical_one` +/// already resolves it repo-wide with no server, so sending it to the editor +/// would waste a provider round trip on an answer the save-path lane produces +/// deterministically. The two lanes therefore partition the reference set with +/// no overlap: lexical takes the unambiguous ones, the editor takes the rest. +/// - **Only method/field references go to the editor.** A bare free-function or +/// constructor call the lexical lane could not resolve has no unique definition +/// in the graph; the editor's provider would resolve it to that same +/// (missing-or-ambiguous) target and the daemon would abstain anyway. Method +/// and field references on a receiver whose type the extractor could not +/// recover are exactly the disambiguation §7.3b exists for. +/// +/// The editor is handed the line and the name, not a column: the native +/// extractor records the reference's line but not its column, and the editor can +/// recover the column by finding `name` on the line far more cheaply than the +/// extractor could be taught to carry UTF-16 offsets. +pub fn targets_needing_editor( + store: &SqliteStore, + unresolved: &[UnresolvedCall], +) -> Vec { + unresolved + .iter() + .filter_map(|call| { + // The lexical lane owns anything it can resolve without a server. + if lexical_one(store, call).is_some() { + return None; + } + // Only a method or field reference benefits from LSP disambiguation. + let is_field = call.callee_sig.starts_with("field:"); + if !(call.is_method_call || is_field) { + return None; + } + // No line means no position for the editor to query (older + // extractors record `0`); skip rather than send an unusable target. + if call.caller_line == 0 { + return None; + } + Some(LiveResolutionTarget { + ref_line: call.caller_line, + name: travsr_core::ident::leaf_of(&call.callee_sig).to_string(), + edge_kind: lexical_edge_kind(call).as_str().to_string(), + // Calls and fields are both answered by the definition provider; + // the implementation provider is for implements/override targets, + // which arrive through a different detector. + provider: "definition".to_string(), + }) + }) + .collect() +} + +/// RFC-027 daemon-driven positions, IsImplementation half: the `extends` / +/// `implements` clauses the lexical floor could not settle, as editor targets. +/// +/// A clause whose base type is unique repo-wide is the lexical floor's job +/// (`resolve_unambiguous_lexical`), so it is skipped here for the same +/// no-overlap reason the call path skips resolvable calls. What remains — an +/// ambiguous base, or one defined only in another file — is what the editor's +/// **definition** provider resolves: the reference names the base type, and we +/// want where that type is *defined* (the class/interface node), not its +/// implementors, so the provider is `definition`, not `implementation`. +pub fn inheritance_targets_needing_editor( + store: &SqliteStore, + corpus: &str, + path: &str, + inheritance: &[InheritanceRef], +) -> Vec { + inheritance + .iter() + .filter_map(|r| { + let src = store.enclosing_definition_at(corpus, path, r.line).ok()??; + // The lexical floor owns any base with a unique repo-wide definition. + if inheritance_edge(store, src, &r.base_name).is_some() { + return None; + } + Some(LiveResolutionTarget { + ref_line: r.line, + name: r.base_name.clone(), + edge_kind: EdgeKind::IsImplementation.as_str().to_string(), + provider: "definition".to_string(), + }) + }) + .collect() +} + +/// RFC-027 section 8.2/8.3: editor targets for a language with no native +/// Phase B extractor (Go, Java, C#, C/C++, and the rest of the sixteen). +/// +/// Where the native path partitions its reference set between two lanes — the +/// lexical floor takes what it can settle repo-wide, the editor takes the rest — +/// there is nothing to partition here. The generic detector deliberately +/// recovers no receiver type and builds no signature key, so +/// [`resolve_unambiguous_lexical`] has nothing to match on and never runs for +/// these languages. Every detected reference is therefore an editor target: +/// filtering one out on a guess about what the floor "might" have resolved would +/// drop the reference outright rather than hand it to the other lane. +/// +/// With no language server installed nothing is answered and every reference +/// abstains, which is exactly section 7.3c — today's commit-gated behavior, zero +/// regression. +/// +/// Duplicates on `(line, name, kind)` are collapsed. The editor recovers a +/// target's column by finding the name on its line, so two references to the +/// same name on one line resolve to the same position; sending both would buy a +/// second provider round trip for an answer already in hand. +pub fn generic_targets_needing_editor( + refs: &travsr_analysis::live_detect::LiveRefs, +) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::with_capacity(refs.calls.len() + refs.fields.len()); + let mut push = |line: u32, name: &str, kind: EdgeKind| { + // A reference with no line has no position for the editor to query. + if line == 0 || !seen.insert((line, name.to_string(), kind)) { + return; + } + out.push(LiveResolutionTarget { + ref_line: line, + name: name.to_string(), + edge_kind: kind.as_str().to_string(), + // All three kinds resolve to where the target is *defined*, which is + // the definition provider. `implementation` answers the opposite + // question (who implements this) and is not what any of these want. + provider: "definition".to_string(), + }); + }; + for c in &refs.calls { + push(c.line, &c.name, EdgeKind::RefCall); + } + for f in &refs.fields { + push(f.line, &f.name, EdgeKind::RefField); + } + for i in &refs.inheritance { + push(i.line, &i.base_name, EdgeKind::IsImplementation); + } + out +} + +/// The unique `IsImplementation` edge from `src` (the implementing class) to the +/// base type named `base_name`, or `None` to abstain. +fn inheritance_edge(store: &SqliteStore, src: NodeId, base_name: &str) -> Option { + let dst = unique_base_definition(store, base_name)?; + edge_if_sound(store, src, dst, EdgeKind::IsImplementation) +} + +/// The single class / interface / trait / … named `base_name` repo-wide, or +/// `None` when there are zero or more than one. +/// +/// Tries the exact signature under each kind valid for an `IsImplementation` +/// target (`class:Foo`, `interface:Foo`, …), which is how Phase A keys these +/// nodes, and requires exactly one match across all of them. Two definitions of +/// the same name — the cross-file ambiguity the editor's provider exists for — +/// abstain rather than guess, the same precision guarantee as the call floor. +fn unique_base_definition(store: &SqliteStore, base_name: &str) -> Option { + let kinds = target_kinds(EdgeKind::IsImplementation); + let mut found: Option = None; + for kind in kinds { + let sig = format!("{kind}:{base_name}"); + let Ok(nodes) = store.lookup_nodes_exact(&sig, None) else { + continue; + }; + for n in nodes { + if !kinds.contains(&n.kind.as_str()) { + continue; + } + match found { + Some(id) if id != n.id => return None, + Some(_) => {} + None => found = Some(n.id), + } + } + } + found } /// The exact signatures a call could name, most specific first. @@ -329,18 +616,20 @@ fn candidate_signatures(call: &UnresolvedCall) -> Vec { sigs } -/// The single definition named by `signature`, or `None` when there are zero or -/// more than one. +/// The single node named by `signature` and valid as a `edge` target, or `None` +/// when there are zero or more than one. /// /// `lookup_nodes_exact(sig, None)` documents exactly this contract: one row is /// an unambiguous match, more than one is "genuinely ambiguous; caller must -/// disambiguate". Ambiguity abstains, which is the whole precision guarantee of -/// section 7.3a. -fn unique_definition(store: &SqliteStore, signature: &str) -> Option { +/// disambiguate". Candidates whose kind is not valid for `edge` are filtered out +/// first, so the count is over real targets. Ambiguity abstains, which is the +/// whole precision guarantee of section 7.3a. +fn unique_definition(store: &SqliteStore, signature: &str, edge: EdgeKind) -> Option { let candidates = store.lookup_nodes_exact(signature, None).ok()?; + let kinds = target_kinds(edge); let defs: Vec<&travsr_core::Node> = candidates .iter() - .filter(|n| is_definition(&n.kind)) + .filter(|n| kinds.contains(&n.kind.as_str())) .collect(); match defs.as_slice() { [only] => Some(only.id), @@ -355,7 +644,11 @@ fn unique_definition(store: &SqliteStore, signature: &str) -> Option { /// synthetic symbol into the bridge registry (ADR-009 Rule 4). Self-edges are /// dropped because a symbol referencing itself carries no traversal value and /// would show up as a spurious self-loop in `get_callers`. -fn edge_if_sound(store: &SqliteStore, src: NodeId, dst: NodeId) -> Option { +/// +/// `kind` is the edge kind to emit (RFC-027 live edge-kind scope): the lane is +/// no longer call-only, so the caller names it. The target-kind gate lives in +/// [`target_kinds`], applied before this by whichever lane found `dst`. +fn edge_if_sound(store: &SqliteStore, src: NodeId, dst: NodeId, kind: EdgeKind) -> Option { if src == dst { return None; } @@ -364,35 +657,70 @@ fn edge_if_sound(store: &SqliteStore, src: NodeId, dst: NodeId) -> Option if src_node.vname.corpus != dst_node.vname.corpus { return None; } - Some(Edge::new(src, dst, EdgeKind::RefCall)) + Some(Edge::new(src, dst, kind)) } -/// Kinds a reference can resolve to. Mirrors the definition kinds the store's -/// own `definition_node_ids_in_file` recognises, so the two agree on what -/// counts as a definition. -fn is_definition(kind: &str) -> bool { +/// The edge kinds the live lane is permitted to emit (Bucket B). Anything else — +/// a structural kind, a cross-language `ffi/call`, an unknown string — is refused +/// so a malformed or hostile editor report cannot make the lane write an edge +/// kind it was never scoped to. +fn live_edge_kind(s: &str) -> Option { + let kind = EdgeKind::from_str(s)?; matches!( kind, - "function" - | "method" - | "fn" - | "class" - | "interface" - | "struct" - | "trait" - | "enum" - | "type" - | "typedef" - | "union" - | "object" - | "protocol" - | "mixin" - | "extension" - | "namespace" - | "init" + EdgeKind::RefCall + | EdgeKind::RefField + | EdgeKind::RefImports + | EdgeKind::IsImplementation + | EdgeKind::Overrides ) + .then_some(kind) } +/// The node kinds a live edge of `edge` may point at. This is the whole +/// precision gate on the *target*: a candidate of any other kind is filtered out +/// and the reference abstains rather than mint an edge to the wrong node kind +/// (§8.1). The *source* is always a code body, gated separately by +/// `enclosing_definition_at`. +fn target_kinds(edge: EdgeKind) -> &'static [&'static str] { + match edge { + EdgeKind::RefCall => DEFINITION_KINDS, + EdgeKind::RefField => &["field"], + // A named import specifier resolves to the exported definition it binds. + EdgeKind::RefImports => DEFINITION_KINDS, + // `class C implements I` / `impl Trait for T` points at the contract. + EdgeKind::IsImplementation => &["interface", "trait", "class", "struct", "protocol"], + // A subclass method overriding a base method points at the base method. + EdgeKind::Overrides => &["method", "fn", "function"], + // The live lane emits no other kind; `live_edge_kind` already refused it. + _ => &[], + } +} + +/// Kinds a call / import reference can resolve to. Mirrors the store's +/// `ENCLOSING_DEFINITION_KINDS` and `definition_node_ids_in_file`, so all three +/// agree on what counts as a definition. Deliberately excludes `field`, which is +/// a `ref/field` target, not a `ref/call` one. +const DEFINITION_KINDS: &[&str] = &[ + "function", + "method", + "fn", + "class", + "interface", + "struct", + "trait", + "enum", + "type", + "typedef", + "union", + "object", + "protocol", + "mixin", + "extension", + "namespace", + "init", +]; + #[cfg(test)] mod tests { use super::*; @@ -429,6 +757,22 @@ mod tests { target_path: target_path.to_string(), target_line, buffer_version: 1, + edge_kind: "ref/call".to_string(), + } + } + + /// Like [`resolution`] but naming a specific edge kind, for the Bucket-B + /// kinds beyond `ref/call` (RFC-027 live edge-kind scope). + fn resolution_kind( + ref_line: u32, + name: &str, + target_path: &str, + target_line: u32, + edge_kind: &str, + ) -> LiveResolution { + LiveResolution { + edge_kind: edge_kind.to_string(), + ..resolution(ref_line, name, target_path, target_line) } } @@ -580,6 +924,7 @@ mod tests { CORPUS, "src/order.ts", &[call(src, "method:User.save", 18)], + &[], ); assert_eq!( @@ -620,6 +965,7 @@ mod tests { CORPUS, "src/order.ts", &[call(src, "method:save", 18)], + &[], ); assert_eq!( @@ -650,7 +996,7 @@ mod tests { let mut c = call(src, "class:Field", 18); c.alt_callee_sig = Some("fn:Field".to_string()); - let out = resolve_unambiguous_lexical(&mut store, CORPUS, "src/order.ts", &[c]); + let out = resolve_unambiguous_lexical(&mut store, CORPUS, "src/order.ts", &[c], &[]); assert_eq!( out, LiveOutcome { @@ -847,6 +1193,7 @@ mod tests { CORPUS, "src/main.rs", &[call(src, "method:Zoo.new", 3)], + &[], ); assert_eq!( out, @@ -864,15 +1211,16 @@ mod tests { /// Rust field access `x.count` is emitted with `callee_sig = "field:count"` /// AND a recovered `recv_type` — the extractor only emits field refs when /// the receiver type is recoverable (phase_b_rust.rs guards - /// `recv_type.is_none()`). So `candidate_signatures` DOES build - /// `field:Zoo.count`, and the field definition node is present. It still - /// abstains, because `is_definition` does not admit the `"field"` kind and - /// `unique_definition` filters the node out. The abstention is by - /// definition-kind, NOT (as an earlier note assumed) a missing receiver - /// type. Recorded, not closed: a field read is not a call and must not mint - /// a `RefCall` edge. Both facts are asserted here so neither can drift. - #[test] - fn rust_field_access_abstains_on_definition_kind_not_missing_receiver() { + /// `recv_type.is_none()`). So `candidate_signatures` builds `field:Zoo.count` + /// and the field definition node is present. + /// + /// RFC-027 live edge-kind scope: a field read now resolves to a `ref/field` + /// edge, not abstention. The edge kind matters — it must be `ref/field`, never + /// `ref/call`, so a field read never surfaces as a caller in `get_callers` / + /// `get_blast_radius` (#757) while still appearing as a use site in + /// `find_references`. Both the emission and the kind are pinned here. + #[test] + fn rust_field_access_resolves_to_a_ref_field_edge() { let mut c = call( rust_node_id("src/zoo.rs", "method:Zoo.tally"), "field:count", @@ -884,19 +1232,521 @@ mod tests { vec!["field:Zoo.count".to_string()], "the owner-qualified field sig is built; recv_type is present", ); + assert_eq!( + lexical_edge_kind(&c), + EdgeKind::RefField, + "a field:… call-site record resolves to ref/field, not ref/call", + ); + + let mut store = rust_store_with(&[ + ("src/zoo.rs", "method:Zoo.tally", "method", 4, 9), + ("src/zoo.rs", "field:Zoo.count", "field", 2, 2), + ]); + let out = resolve_unambiguous_lexical(&mut store, CORPUS, "src/zoo.rs", &[c], &[]); + assert_eq!( + out, + LiveOutcome { + emitted: 1, + pending: 0 + }, + ); + let edge = store + .iter_edges_from(rust_node_id("src/zoo.rs", "method:Zoo.tally")) + .expect("iter_edges_from") + .into_iter() + .find(|e| e.dst == rust_node_id("src/zoo.rs", "field:Zoo.count")) + .expect("a live edge to the field must exist"); + assert_eq!( + edge.kind, + EdgeKind::RefField, + "the edge kind must be ref/field" + ); + assert_eq!( + edge.provenance.as_deref(), + Some("live"), + "and it must be tagged live", + ); + } + /// Section 7.3b for a field target: an editor resolves `zoo.count` to the + /// field's declaration line, and the daemon maps that position to the + /// `field` node via `enclosing_node_at` (which the call-target kind set of + /// `enclosing_definition_at` would have excluded) and emits `ref/field`. + #[test] + fn an_editor_field_resolution_becomes_a_ref_field_edge() { let mut store = rust_store_with(&[ ("src/zoo.rs", "method:Zoo.tally", "method", 4, 9), ("src/zoo.rs", "field:Zoo.count", "field", 2, 2), ]); - let out = resolve_unambiguous_lexical(&mut store, CORPUS, "src/zoo.rs", &[c]); + let out = apply_live_resolutions( + &mut store, + CORPUS, + "src/zoo.rs", + // The reference is on line 5 (inside tally); the field decl is line 2. + &[resolution_kind(5, "count", "src/zoo.rs", 2, "ref/field")], + ); + assert_eq!( + out, + LiveOutcome { + emitted: 1, + pending: 0 + }, + ); + let edge = store + .iter_edges_from(rust_node_id("src/zoo.rs", "method:Zoo.tally")) + .expect("iter_edges_from") + .into_iter() + .find(|e| e.dst == rust_node_id("src/zoo.rs", "field:Zoo.count")) + .expect("a live edge to the field must exist"); + assert_eq!(edge.kind, EdgeKind::RefField); + } + + /// RFC-027 daemon-driven positions: the editor is handed exactly the + /// references the lexical lane could not settle, and no others. A resolvable + /// call is the lexical lane's job; a method call with no recovered receiver + /// type is the disambiguation §7.3b exists for, so only the latter becomes a + /// target — carrying the line, the leaf name, its edge kind, and the + /// definition provider. + #[test] + fn targets_are_only_the_references_the_lexical_lane_cannot_settle() { + let store = store_with(&[ + ("src/order.ts", "fn:placeOrder", "function", 10, 30), + ("src/user.ts", "method:User.save", "method", 15, 20), + ]); + let src = node_id("src/order.ts", "fn:placeOrder"); + + // Resolvable associated call — lexical owns it, so no editor target. + let resolvable = call(src, "method:User.save", 18); + // Method call with no recovered receiver type — lexical abstains. + let mut ambiguous = call(src, "fn:save", 19); + ambiguous.is_method_call = true; + + let targets = targets_needing_editor(&store, &[resolvable, ambiguous]); + assert_eq!( + targets.len(), + 1, + "only the unsettleable reference is a target" + ); + assert_eq!(targets[0].ref_line, 19); + assert_eq!(targets[0].name, "save"); + assert_eq!(targets[0].edge_kind, "ref/call"); + assert_eq!(targets[0].provider, "definition"); + } + + /// A field access with no recovered receiver type becomes a `ref/field` + /// target for the definition provider — the daemon names the kind, the editor + /// runs the provider. + #[test] + fn a_field_reference_becomes_a_ref_field_target() { + let store = store_with(&[("src/order.ts", "fn:placeOrder", "function", 10, 30)]); + let src = node_id("src/order.ts", "fn:placeOrder"); + let field = call(src, "field:count", 20); + let targets = targets_needing_editor(&store, &[field]); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].edge_kind, "ref/field"); + assert_eq!(targets[0].name, "count"); + assert_eq!(targets[0].provider, "definition"); + } + + /// A bare free-function call the lexical lane could not resolve has no unique + /// definition in the graph; the editor's provider would resolve it to that + /// same missing target and abstain, so it is not sent (stay surgical, §7.6). + #[test] + fn an_unresolvable_free_call_is_not_sent_to_the_editor() { + let store = store_with(&[("src/order.ts", "fn:placeOrder", "function", 10, 30)]); + let src = node_id("src/order.ts", "fn:placeOrder"); + let bare = call(src, "fn:nowhere", 21); + assert!(targets_needing_editor(&store, &[bare]).is_empty()); + } + + /// RFC-027 section 12: the editor lane records what it claimed, so the + /// precision meter can score it. Without a claim row an editor-resolved edge + /// is invisible to the meter, and a language that runs this lane alone + /// (every non-native one, §8.3) could never earn its gate a reading. + #[test] + fn an_editor_resolution_is_recorded_as_a_scorable_claim() { + let mut store = store_with(&[ + ("src/order.ts", "fn:placeOrder", "function", 10, 30), + ("src/user.ts", "method:User.save", "method", 15, 20), + ]); + apply_live_resolutions( + &mut store, + CORPUS, + "src/order.ts", + &[resolution(18, "save", "src/user.ts", 17)], + ); + let sample = store + .live_precision_sample_by_language() + .expect("precision sample"); + let bucket = sample + .get("typescript") + .expect("the claim must be attributed to the source node's language"); + assert_eq!( + bucket.claims(), + 1, + "the editor lane's resolution must be a claim the meter can see" + ); + } + + /// An editor answer the daemon could not map abstains, and the abstention is + /// recorded rather than dropped: "there is a reference here and its target is + /// not yet known" is true and useful (§9.2). + #[test] + fn an_editor_abstention_is_recorded_as_pending() { + let mut store = store_with(&[("src/order.ts", "fn:placeOrder", "function", 10, 30)]); + let out = apply_live_resolutions( + &mut store, + CORPUS, + "src/order.ts", + &[resolution(18, "save", "src/nowhere.ts", 17)], + ); + assert_eq!( + out, + LiveOutcome { + emitted: 0, + pending: 1 + }, + ); + let pending = store + .pending_refs_in_file(CORPUS, "src/order.ts") + .expect("pending_refs_in_file"); + assert_eq!(pending, vec![("save".to_string(), 18)]); + } + + /// The editor lane must upgrade the save-path pass's row for the same + /// reference, not fork it into a stale `pending` beside a `resolved`. Both + /// lanes key on `ref_col = 0` for exactly this reason. + #[test] + fn an_editor_answer_upgrades_the_save_paths_pending_row() { + let mut store = store_with(&[ + ("src/order.ts", "fn:placeOrder", "function", 10, 30), + ("src/user.ts", "method:User.save", "method", 15, 20), + ]); + let src = node_id("src/order.ts", "fn:placeOrder"); + // The save-path pass abstains on a method call with no receiver type. + let mut ambiguous = call(src, "fn:save", 18); + ambiguous.is_method_call = true; + resolve_unambiguous_lexical(&mut store, CORPUS, "src/order.ts", &[ambiguous], &[]); + assert_eq!( + store + .pending_refs_in_file(CORPUS, "src/order.ts") + .expect("pending_refs_in_file") + .len(), + 1, + ); + + apply_live_resolutions( + &mut store, + CORPUS, + "src/order.ts", + &[resolution(18, "save", "src/user.ts", 17)], + ); + assert!( + store + .pending_refs_in_file(CORPUS, "src/order.ts") + .expect("pending_refs_in_file") + .is_empty(), + "the resolved reference must leave no stale pending row behind" + ); + assert_eq!( + store + .live_precision_sample_by_language() + .expect("precision sample") + .get("typescript") + .map(|b| b.claims()), + Some(1), + "one reference must produce one claim, not two rows", + ); + } + + /// RFC-027 section 8.3: a language with no native extractor has no lexical + /// floor, so every detected reference becomes an editor target — a call as + /// `ref/call`, a field read as `ref/field`, each for the definition + /// provider. Nothing is filtered: dropping one would lose the reference + /// outright rather than hand it to another lane. + #[test] + fn every_generic_reference_becomes_an_editor_target() { + let refs = travsr_analysis::live_detect::LiveRefs { + calls: vec![ + travsr_analysis::live_detect::LiveRef { + line: 12, + name: "Start".to_string(), + }, + travsr_analysis::live_detect::LiveRef { + line: 13, + name: "helper".to_string(), + }, + ], + fields: vec![travsr_analysis::live_detect::LiveRef { + line: 14, + name: "count".to_string(), + }], + inheritance: Vec::new(), + }; + let targets = generic_targets_needing_editor(&refs); + assert_eq!(targets.len(), 3); + assert_eq!( + targets + .iter() + .map(|t| (t.ref_line, t.name.as_str(), t.edge_kind.as_str())) + .collect::>(), + vec![ + (12, "Start", "ref/call"), + (13, "helper", "ref/call"), + (14, "count", "ref/field"), + ], + ); + assert!(targets.iter().all(|t| t.provider == "definition")); + } + + /// The editor recovers a target's column by finding the name on its line, so + /// two references to the same name on one line resolve to the same position. + /// Sending both would buy a second provider round trip for an answer already + /// in hand. + #[test] + fn generic_targets_are_deduplicated_per_line_and_name() { + let refs = travsr_analysis::live_detect::LiveRefs { + calls: vec![ + travsr_analysis::live_detect::LiveRef { + line: 7, + name: "Get".to_string(), + }, + travsr_analysis::live_detect::LiveRef { + line: 7, + name: "Get".to_string(), + }, + ], + fields: Vec::new(), + inheritance: Vec::new(), + }; + assert_eq!(generic_targets_needing_editor(&refs).len(), 1); + } + + /// A live edge for a non-native language rides exactly the same emit path as + /// a native one: the daemon maps the reference line to its enclosing + /// definition and the editor's answer to a node, and neither endpoint is + /// minted here (§8.2). This is a Go file with no native extractor at all. + #[test] + fn a_generic_language_edge_emits_through_the_shared_path() { + let mut store = store_with(&[ + ("cmd/run.go", "fn:Run", "function", 10, 20), + ("svc/session.go", "method:Session.Start", "method", 30, 40), + ]); + let out = apply_live_resolutions( + &mut store, + CORPUS, + "cmd/run.go", + &[resolution_kind( + 12, + "Start", + "svc/session.go", + 31, + "ref/call", + )], + ); + assert_eq!( + out, + LiveOutcome { + emitted: 1, + pending: 0 + }, + ); + let edge = store + .iter_edges_from(node_id("cmd/run.go", "fn:Run")) + .expect("iter_edges_from") + .into_iter() + .find(|e| e.dst == node_id("svc/session.go", "method:Session.Start")) + .expect("a live edge to the Go method must exist"); + assert_eq!(edge.kind, EdgeKind::RefCall); + } + + /// An editor report naming an edge kind outside Bucket B (a structural kind, + /// a cross-language `ffi/call`, or an unknown string) is refused: the lane + /// emits only the kinds it was scoped to, so a malformed report abstains + /// rather than writing an out-of-scope edge. + #[test] + fn an_out_of_scope_edge_kind_is_refused() { + assert!(live_edge_kind("ffi/call").is_none()); + assert!(live_edge_kind("depends").is_none()); + assert!(live_edge_kind("not-a-kind").is_none()); + assert_eq!(live_edge_kind("ref/field"), Some(EdgeKind::RefField)); + + let mut store = store_with(&[ + ("src/order.ts", "fn:placeOrder", "function", 10, 30), + ("src/user.ts", "method:User.save", "method", 15, 20), + ]); + let out = apply_live_resolutions( + &mut store, + CORPUS, + "src/order.ts", + &[resolution_kind(18, "save", "src/user.ts", 17, "ffi/call")], + ); assert_eq!( out, LiveOutcome { emitted: 0, pending: 1 }, - "the field node exists and its sig matches; abstention is the is_definition filter", ); } + + // ── IsImplementation lane (RFC-027 live edge-kind scope, TypeScript) ──────── + + fn inherit(base_name: &str, line: u32) -> InheritanceRef { + InheritanceRef { + base_name: base_name.to_string(), + line, + } + } + + /// Lexical floor: `class Order extends Base` where `Base` has exactly one + /// definition repo-wide resolves to an `is-implementation` edge with no + /// language server. The clause line is the class declaration line, which maps + /// to the implementing class as the edge source. + #[test] + fn an_unambiguous_base_resolves_to_a_live_is_implementation_edge() { + let mut store = store_with(&[ + ("src/order.ts", "class:Order", "class", 3, 20), + ("src/base.ts", "class:Base", "class", 1, 10), + ]); + let out = resolve_unambiguous_lexical( + &mut store, + CORPUS, + "src/order.ts", + &[], + &[inherit("Base", 3)], + ); + assert_eq!( + out, + LiveOutcome { + emitted: 1, + pending: 0 + }, + ); + let edge = store + .iter_edges_from(node_id("src/order.ts", "class:Order")) + .expect("iter_edges_from") + .into_iter() + .find(|e| e.dst == node_id("src/base.ts", "class:Base")) + .expect("a live is-implementation edge to the base must exist"); + assert_eq!(edge.kind, EdgeKind::IsImplementation); + assert_eq!(edge.provenance.as_deref(), Some("live")); + } + + /// An interface `implements` target resolves the same way, keyed on the + /// `interface:` signature the target-kind set admits. + #[test] + fn an_unambiguous_interface_resolves_to_a_live_edge() { + let mut store = store_with(&[ + ("src/order.ts", "class:Order", "class", 3, 20), + ("src/shape.ts", "interface:Shape", "interface", 1, 5), + ]); + let out = resolve_unambiguous_lexical( + &mut store, + CORPUS, + "src/order.ts", + &[], + &[inherit("Shape", 3)], + ); + assert_eq!( + out, + LiveOutcome { + emitted: 1, + pending: 0 + }, + ); + assert_eq!( + provenance_of( + &store, + node_id("src/order.ts", "class:Order"), + node_id("src/shape.ts", "interface:Shape"), + ) + .as_deref(), + Some("live"), + ); + } + + /// A base with two definitions repo-wide is the cross-file ambiguity the + /// editor's provider exists for: the lexical floor abstains and it becomes a + /// `definition`-provider target, never a guessed edge. + #[test] + fn an_ambiguous_base_abstains_and_becomes_an_editor_target() { + let store = store_with(&[ + ("src/order.ts", "class:Order", "class", 3, 20), + ("src/a.ts", "class:Base", "class", 1, 10), + ("src/b.ts", "class:Base", "class", 1, 10), + ]); + let targets = inheritance_targets_needing_editor( + &store, + CORPUS, + "src/order.ts", + &[inherit("Base", 3)], + ); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].edge_kind, "is-implementation"); + assert_eq!(targets[0].name, "Base"); + assert_eq!(targets[0].ref_line, 3); + assert_eq!( + targets[0].provider, "definition", + "the base type is resolved to its definition, not its implementors", + ); + } + + /// A base the lexical floor could settle is not also sent to the editor: the + /// two lanes partition inheritance clauses with no overlap, exactly as they + /// do calls. + #[test] + fn a_resolvable_base_is_not_sent_to_the_editor() { + let store = store_with(&[ + ("src/order.ts", "class:Order", "class", 3, 20), + ("src/base.ts", "class:Base", "class", 1, 10), + ]); + let targets = inheritance_targets_needing_editor( + &store, + CORPUS, + "src/order.ts", + &[inherit("Base", 3)], + ); + assert!( + targets.is_empty(), + "a unique base is the lexical floor's job" + ); + } + + /// Section 7.3b for an implements target: an editor resolves the base name to + /// its declaration and the daemon emits an `is-implementation` edge from the + /// implementing class to the interface node the position mapped to. + #[test] + fn an_editor_resolved_base_becomes_an_is_implementation_edge() { + let mut store = store_with(&[ + ("src/order.ts", "class:Order", "class", 3, 20), + ("src/shape.ts", "interface:Shape", "interface", 1, 5), + ]); + let out = apply_live_resolutions( + &mut store, + CORPUS, + "src/order.ts", + &[resolution_kind( + 3, + "Shape", + "src/shape.ts", + 1, + "is-implementation", + )], + ); + assert_eq!( + out, + LiveOutcome { + emitted: 1, + pending: 0 + }, + ); + let edge = store + .iter_edges_from(node_id("src/order.ts", "class:Order")) + .expect("iter_edges_from") + .into_iter() + .find(|e| e.dst == node_id("src/shape.ts", "interface:Shape")) + .expect("an is-implementation edge must exist"); + assert_eq!(edge.kind, EdgeKind::IsImplementation); + } } diff --git a/crates/travsr-ipc/src/message.rs b/crates/travsr-ipc/src/message.rs index 322aad6d..c9544bcc 100644 --- a/crates/travsr-ipc/src/message.rs +++ b/crates/travsr-ipc/src/message.rs @@ -108,6 +108,32 @@ pub enum ControlMessage { /// so are unknown rather than clean. undiagnosed: usize, }, + /// RFC-027 daemon-driven positions: an editor asks the daemon which + /// references in a dirty file it should resolve. + /// + /// The daemon runs the native extractor over the file, keeps the references + /// its own lexical lane cannot settle, and answers with a + /// [`LiveResolutionTarget`] per reference — line, name, edge kind, and which + /// provider to run. The editor then resolves each and reports back with + /// [`ControlMessage::ReportLiveResolution`]. This replaces the editor's blind + /// `identifier(` scan, so reference detection lives with the parser and the + /// graph rather than in an English-shaped regex. + /// + /// The answer rides `ControlResponse::result` as a JSON array. Daemons older + /// than this variant answer with a parse error, which the extension treats as + /// "no targets" — the live lane simply stays at its lexical floor. + RequestLiveResolutionTargets { + /// Absolute workspace root, checked against the daemon's own repo exactly + /// as `ReportLiveResolution` does (#698 review, P1): discovery enumerates + /// a namespace, not a repo. + repo_root: String, + /// Stable for the lifetime of one editor window. + session: String, + /// The dirty file, repo-relative with forward slashes. + file: String, + /// Editor buffer version, echoed so the editor can drop a stale batch. + buffer_version: i64, + }, /// RFC-027: an editor reports where a reference in a dirty file actually /// resolves to, so the daemon can close the between-commits semantic gap. /// @@ -185,6 +211,47 @@ pub struct LiveResolution { /// a resolution whose buffer has moved on rather than trusting a stale /// position (RFC-027 section 11). pub buffer_version: i64, + /// The graph edge kind this reference resolves to, as the stable + /// `EdgeKind::as_str` string (`ref/call`, `ref/field`, `ref/imports`, + /// `is-implementation`, `overrides`). The editor knows which reference shape + /// it queried and which provider answered, so it names the kind; the daemon + /// emits an edge of exactly that kind rather than assuming a call + /// (RFC-027 live edge-kind scope). `#[serde(default)]` yields `ref/call`, + /// preserving the pre-expansion payload where every live edge was a call. + #[serde(default = "default_live_edge_kind")] + pub edge_kind: String, +} + +/// Back-compat default for [`LiveResolution::edge_kind`]: an older extension +/// only ever resolved calls, so a payload without the field means `ref/call`. +fn default_live_edge_kind() -> String { + "ref/call".to_string() +} + +/// One reference the editor should resolve, computed by the daemon from the +/// native extractor's reference set (RFC-027 daemon-driven positions). +/// +/// The daemon says *which* reference and *which* edge kind; the editor finds the +/// exact column of `name` on `ref_line`, runs `provider`, and reports the answer +/// back as a [`LiveResolution`]. This is what replaces the editor's blind +/// `identifier(` scan: reference detection moves to the daemon (which has the +/// parser and the graph), leaving the editor only the provider round-trip. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct LiveResolutionTarget { + /// 1-based line of the reference in the dirty file. The editor searches this + /// line for `name` to recover the column the native extractor does not carry. + pub ref_line: u32, + /// The referenced name, so the editor can pin the column and so the daemon + /// can record an honest `pending` row if the editor's answer maps to nothing. + pub name: String, + /// The graph edge kind this reference resolves to, as `EdgeKind::as_str` + /// (`ref/call`, `ref/field`, `ref/imports`, `is-implementation`, + /// `overrides`). Echoed back on the [`LiveResolution`] so the daemon emits an + /// edge of exactly this kind. + pub edge_kind: String, + /// Which editor provider answers this reference shape: `definition` (calls, + /// fields, imports) or `implementation` (implements clauses, overrides). + pub provider: String, } /// One file's current diagnostic state, as an editor sees it. @@ -324,11 +391,72 @@ mod tests { assert_eq!(r.target_path, "src/user.ts"); assert_eq!(r.target_line, 17); assert_eq!(r.buffer_version, 9); + // An extension too old to send edge_kind means the pre-expansion + // behaviour: every live edge was a call. + assert_eq!( + r.edge_kind, "ref/call", + "a payload without edge_kind defaults to ref/call" + ); + } + other => panic!("expected ReportLiveResolution, got {other:?}"), + } + } + + // The expanded wire shape: an editor that resolved a field carries the edge + // kind, so the daemon emits ref/field rather than assuming a call. + #[test] + fn a_live_resolution_carries_its_edge_kind() { + let line = r#"{"op":"report-live-resolution","repo_root":"/r","session":"s", + "file":"src/zoo.rs","resolutions":[{"ref_line":5,"ref_col":8,"name":"count", + "target_path":"src/zoo.rs","target_line":2,"buffer_version":3, + "edge_kind":"ref/field"}]}"#; + match serde_json::from_str::(line).expect("must parse") { + ControlMessage::ReportLiveResolution { resolutions, .. } => { + assert_eq!(resolutions[0].edge_kind, "ref/field"); } other => panic!("expected ReportLiveResolution, got {other:?}"), } } + // RFC-027 daemon-driven positions: the request the extension sends to ask + // which references it should resolve. + #[test] + fn request_live_resolution_targets_wire_shape_matches_the_extension() { + let line = r#"{"op":"request-live-resolution-targets","repo_root":"/home/alice/proj", + "session":"vscode-1-abc","file":"src/order.ts","buffer_version":9}"#; + match serde_json::from_str::(line).expect("request line must parse") { + ControlMessage::RequestLiveResolutionTargets { + repo_root, + session, + file, + buffer_version, + } => { + assert_eq!(repo_root, "/home/alice/proj"); + assert_eq!(session, "vscode-1-abc"); + assert_eq!(file, "src/order.ts"); + assert_eq!(buffer_version, 9); + } + other => panic!("expected RequestLiveResolutionTargets, got {other:?}"), + } + } + + // The daemon serialises targets into `ControlResponse::result`; the field + // names here are the contract the extension parses. + #[test] + fn a_resolution_target_serialises_to_the_shape_the_extension_reads() { + let target = LiveResolutionTarget { + ref_line: 19, + name: "save".to_string(), + edge_kind: "ref/call".to_string(), + provider: "definition".to_string(), + }; + let v = serde_json::to_value(&target).expect("serialise"); + assert_eq!(v["ref_line"], 19); + assert_eq!(v["name"], "save"); + assert_eq!(v["edge_kind"], "ref/call"); + assert_eq!(v["provider"], "definition"); + } + // An empty resolution list is meaningful: the editor looked and resolved // nothing, which the daemon records as pending rather than as "no report". #[test] diff --git a/crates/travsr-store/src/lib.rs b/crates/travsr-store/src/lib.rs index 8b7aa343..4dd4ef08 100644 --- a/crates/travsr-store/src/lib.rs +++ b/crates/travsr-store/src/lib.rs @@ -30,6 +30,29 @@ pub const NODE_EXACT_LOOKUP_LIMIT: usize = 21; /// Row cap on [`SqliteStore::search_nodes_by_name`] (fuzzy simple-name Tier 2). pub const NODE_NAME_SEARCH_LIMIT: usize = 100; +/// Definition kinds [`SqliteStore::enclosing_definition_at`] recognises as an +/// enclosing scope. Mirrors `definition_node_ids_in_file`; deliberately excludes +/// `field` so a field read never resolves to an enclosing "definition" (#757). +const ENCLOSING_DEFINITION_KINDS: &[&str] = &[ + "function", + "method", + "fn", + "class", + "interface", + "struct", + "trait", + "enum", + "type", + "typedef", + "union", + "object", + "protocol", + "mixin", + "extension", + "namespace", + "init", +]; + /// Type alias for the RFC-018 Step 4 semantic-ANN callback injected by the daemon. /// Returns `(NodeId, cosine_similarity_score)` pairs in descending score order. pub type EmbedKnnHook = @@ -4933,6 +4956,55 @@ LIMIT ?4", .map_err(|e| StoreError::Database(e.to_string())) } + /// RFC-027 section 12: upsert reference-resolution rows without clearing the + /// file's others. + /// + /// The editor lane arrives *after* the save-path pass has already recorded + /// every reference in the file, and it answers a subset of them. It must + /// therefore upgrade the rows it resolved rather than replace the set: + /// [`Self::replace_ref_resolution_states`] would delete the references the + /// editor did not answer, and those pending rows are the honest record of + /// what is still unresolved. + /// + /// Rows collide with the save-path pass on the `(src, ref_line, ref_col, + /// name)` primary key, so an editor answer flips that reference's existing + /// `pending` row to `resolved` in place instead of forking one reference + /// into two rows. That is what lets the precision meter score the editor + /// lane at all: without a claim row a resolution is invisible to + /// [`Self::live_precision_sample_by_language`], and a language that runs the + /// editor lane alone could never earn the per-language gate a reading. + pub fn upsert_ref_resolution_states( + &mut self, + rows: &[RefResolution], + ) -> Result<(), StoreError> { + (|| -> AnyResult<()> { + let tx = self + .conn + .transaction() + .context("upsert_ref_resolution_states: begin")?; + for r in rows { + tx.execute( + "INSERT INTO ref_resolution_state(src, ref_line, ref_col, name, state, resolved_dst) \ + VALUES(?1, ?2, ?3, ?4, ?5, ?6) \ + ON CONFLICT(src, ref_line, ref_col, name) DO UPDATE SET \ + state = excluded.state, resolved_dst = excluded.resolved_dst", + params![ + node_id_to_i64(r.src), + r.ref_line as i64, + r.ref_col as i64, + r.name, + r.state, + r.resolved_dst.map(node_id_to_i64), + ], + ) + .context("upserting ref_resolution_state row")?; + } + tx.commit().context("upsert_ref_resolution_states: commit")?; + Ok(()) + })() + .map_err(|e| StoreError::Database(e.to_string())) + } + /// RFC-027 section 9.2: replace a file's reference-resolution rows. /// /// Called once per live pass over a file, with every reference that pass @@ -5190,24 +5262,61 @@ LIMIT ?4", path: &str, line: u32, ) -> Result, StoreError> { + self.enclosing_node_at(corpus, path, line, ENCLOSING_DEFINITION_KINDS) + } + + /// The tightest node whose span contains `line`, restricted to `kinds`. + /// + /// Generalizes [`Self::enclosing_definition_at`] so a caller can supply its + /// own valid endpoint kinds. RFC-027's live lane needs this: mapping the + /// target of a `ref/field` edge must find the `field` node the editor's + /// definition provider pointed at, which the definition-only kind set of + /// `enclosing_definition_at` deliberately excludes (`get_callers` must never + /// see a field read as a caller, #757). Each live edge kind therefore passes + /// the kinds that are valid *for it*, and the gate is the kind set itself. + /// + /// `kinds` must contain only internal constant strings; they are bound as + /// parameters, never interpolated, so no user input can reach the SQL text. + /// An empty `kinds` matches nothing (`Ok(None)`), never everything. + pub fn enclosing_node_at( + &self, + corpus: &str, + path: &str, + line: u32, + kinds: &[&str], + ) -> Result, StoreError> { + if kinds.is_empty() { + return Ok(None); + } (|| -> AnyResult> { + // Placeholders start at ?4: ?1 corpus, ?2 path, ?3 line. + let placeholders = (0..kinds.len()) + .map(|i| format!("?{}", i + 4)) + .collect::>() + .join(", "); + let sql = format!( + "SELECT id FROM nodes \ + WHERE corpus = ?1 AND path = ?2 \ + AND line IS NOT NULL AND end_line IS NOT NULL \ + AND line <= ?3 AND end_line >= ?3 \ + AND kind IN ({placeholders}) \ + ORDER BY (end_line - line) ASC, id ASC LIMIT 1" + ); let mut stmt = self .conn - .prepare_cached( - "SELECT id FROM nodes \ - WHERE corpus = ?1 AND path = ?2 \ - AND line IS NOT NULL AND end_line IS NOT NULL \ - AND line <= ?3 AND end_line >= ?3 \ - AND kind IN ('function','method','fn','class','interface','struct', \ - 'trait','enum','type','typedef','union','object', \ - 'protocol','mixin','extension','namespace','init') \ - ORDER BY (end_line - line) ASC, id ASC LIMIT 1", - ) - .context("enclosing_definition_at: prepare")?; + .prepare_cached(&sql) + .context("enclosing_node_at: prepare")?; + use rusqlite::types::Value; + let mut vals: Vec = vec![ + Value::Text(corpus.to_string()), + Value::Text(path.to_string()), + Value::Integer(line as i64), + ]; + vals.extend(kinds.iter().map(|k| Value::Text((*k).to_string()))); let id: Option = stmt - .query_row(params![corpus, path, line as i64], |row| row.get(0)) + .query_row(params_from_iter(vals), |row| row.get(0)) .optional() - .context("enclosing_definition_at: query")?; + .context("enclosing_node_at: query")?; Ok(id.map(i64_to_node_id)) })() .map_err(|e| StoreError::Database(e.to_string())) diff --git a/packages/travsr-vscode/src/daemonIpc.ts b/packages/travsr-vscode/src/daemonIpc.ts index 313f0935..794cd12f 100644 --- a/packages/travsr-vscode/src/daemonIpc.ts +++ b/packages/travsr-vscode/src/daemonIpc.ts @@ -200,6 +200,117 @@ export interface LiveResolutionItem { target_line: number; /** Document version this answer was computed against. */ buffer_version: number; + /** + * Graph edge kind this reference resolves to, as `EdgeKind::as_str` + * (`ref/call`, `ref/field`, `ref/imports`, `is-implementation`, `overrides`). + * Echoed from the daemon's target so it emits an edge of exactly that kind + * (RFC-027 live edge-kind scope). + */ + edge_kind: string; +} + +/** + * One reference the daemon asked this editor to resolve (RFC-027 daemon-driven + * positions). The daemon detected it and named its edge kind; the editor finds + * the column and runs the provider. + */ +export interface LiveResolutionTargetItem { + /** 1-based line of the reference in the dirty file. */ + ref_line: number; + /** The referenced name — the editor finds its column on `ref_line`. */ + name: string; + /** Edge kind to carry back on the resolved `LiveResolutionItem`. */ + edge_kind: string; + /** Which provider answers this reference: `definition` or `implementation`. */ + provider: string; +} + +/** + * Ask the daemon which references in a dirty file this editor should resolve + * (RFC-027 daemon-driven positions). + * + * Unlike the fire-and-forget reports, this reads a response: the owning daemon + * answers with the target list, other daemons reject on the repo-identity guard. + * Returns `[]` on every miss — no daemon, a daemon too old to know the op, a + * malformed answer — so the caller simply resolves nothing that round. + */ +export async function requestLiveResolutionTargets( + repoRoot: string, + file: string, + bufferVersion: number +): Promise { + const result = await request(repoRoot, { + op: "request-live-resolution-targets", + repo_root: repoRoot, + session: SESSION_ID, + file, + buffer_version: bufferVersion, + }); + return Array.isArray(result) ? (result as LiveResolutionTargetItem[]) : []; +} + +/** + * Send one control message and read the daemon's response, best-first. + * + * Broadcasts like {@link send}, but the owning daemon is the one that answers + * with a truthy `result` (others reject on the repo guard), so the first such + * `result` is taken. Returns `null` when no daemon answered usefully. + */ +async function request(repoRoot: string, payload: object): Promise { + const line = JSON.stringify(payload) + "\n"; + const candidates = candidateSocketPaths(repoRoot); + const responses = await Promise.all( + candidates.map((c) => requestLine(c, line)) + ); + for (const r of responses) { + if ( + r && + typeof r === "object" && + (r as { ok?: boolean }).ok === true && + (r as { result?: unknown }).result != null + ) { + return (r as { result?: unknown }).result; + } + } + return null; +} + +/** Write one control line and read back one newline-terminated JSON response. */ +function requestLine(socketPath: string, line: string): Promise { + return new Promise((resolve) => { + let settled = false; + let buf = ""; + const finish = (v: unknown): void => { + if (settled) return; + settled = true; + sock.destroy(); + resolve(v); + }; + const tryParse = (): void => { + const nl = buf.indexOf("\n"); + if (nl < 0) return; + try { + finish(JSON.parse(buf.slice(0, nl))); + } catch { + finish(null); + } + }; + + const sock = net.connect(socketPath); + sock.setTimeout(CONNECT_TIMEOUT_MS); + sock.on("connect", () => sock.write(line)); + sock.on("data", (d: Buffer) => { + buf += d.toString("utf8"); + tryParse(); + }); + // A short line with no trailing newline still parses on end. + sock.on("end", () => { + tryParse(); + finish(null); + }); + sock.on("error", () => finish(null)); + sock.on("timeout", () => finish(null)); + }); } /** diff --git a/packages/travsr-vscode/src/liveResolution.ts b/packages/travsr-vscode/src/liveResolution.ts index c5df49fb..1932fcc4 100644 --- a/packages/travsr-vscode/src/liveResolution.ts +++ b/packages/travsr-vscode/src/liveResolution.ts @@ -1,20 +1,35 @@ /** - * RFC-027 live semantic resolution — the editor half. + * RFC-027 live semantic resolution — the editor half (daemon-driven positions). * * Phase B (SCIP) is commit-gated, so between commits the graph knows *that* * `user.save()` is a call but not *which* `save`. The daemon closes most of - * that gap on its own (an unambiguous callee needs no help), but a call on a - * typed receiver is exactly what lexical matching cannot settle. That is the - * one question a language server is authoritative for, so we ask the one the - * developer is already running. + * that gap on its own (an unambiguous callee needs no help), but a reference on + * a typed receiver — a method or field on `user`, an `implements` clause — is + * exactly what lexical matching cannot settle. That is the one question a + * language server is authoritative for, so we ask the one the developer is + * already running. + * + * ## Daemon-driven, not editor-scanned + * + * The editor no longer guesses which references exist. It **asks the daemon** + * (`requestLiveResolutionTargets`), which runs the real parser over the dirty + * file, keeps the references its own lexical lane cannot settle, and answers + * with a position, a name, an edge kind, and which provider to run. Reference + * detection therefore lives with the parser and the graph, not in an + * English-shaped regex here — which is what lets the lane reach fields, + * implements clauses, and every language the native extractor covers, rather + * than only `identifier(` in the handful the old scan understood. + * + * The editor's residual job is small and mechanical: find the column of the + * named reference on its line, run the provider, and report the answer back. * * ## What this does and does not do * - * It calls `vscode.executeDefinitionProvider`, which routes to whatever - * extension owns the language. **No server is spawned and none is bundled** — - * this reuses a process the developer already trusts and already pays for - * (RFC-027 section 7.6), which is why it costs nothing and needs no new trust - * decision. + * It calls `vscode.executeDefinitionProvider` / `executeImplementationProvider`, + * which route to whatever extension owns the language. **No server is spawned + * and none is bundled** — this reuses a process the developer already trusts and + * already pays for (RFC-027 section 7.6), which is why it costs nothing and + * needs no new trust decision. * * It reports **positions**, never identities. It never names a graph node, * never mints a VName, and never asserts a relationship. The daemon maps both @@ -22,15 +37,6 @@ * separates it from the #688 editor plane, where the editor's own *claim* * (a diagnostic) is what is being reported and so must stay out of the graph. * - * ## Why a rough candidate scan is safe - * - * We do not have a parser here, so call sites are found with a deliberately - * simple scan for `identifier(`. That is imprecise, and it does not need to be - * precise: the daemon is fail-closed. A position that is not really a call, or - * that resolves outside the graph, produces a `pending` marker, never an edge. - * A sloppy candidate therefore costs one wasted provider call and some recall - * — never correctness. Precision is enforced where the graph is, not here. - * * Everything is bounded and best-effort: a capped number of queries per file, * a stale-buffer check before reporting, and every failure path is silent. A * freshness improvement is never worth a word of the user's attention. @@ -38,49 +44,51 @@ import * as vscode from "vscode"; -import { LiveResolutionItem, reportLiveResolution } from "./daemonIpc"; +import { + LiveResolutionItem, + LiveResolutionTargetItem, + reportLiveResolution, + requestLiveResolutionTargets, +} from "./daemonIpc"; /** - * Cap on definition queries per file. + * Cap on provider queries per file. * - * Each is an IPC round trip into another extension host. A hand-written source - * file is far below this; a generated one can hold thousands, and resolving all - * of them would cost more than the freshness is worth. Truncation degrades - * recall, which the commit-gated path then repairs. + * Each is an IPC round trip into another extension host. The daemon already + * keeps the set surgical (only references its lexical lane could not settle), so + * a hand-written file is far below this; the cap only bounds a pathological + * generated file. Truncation degrades recall, which the commit-gated path + * repairs. */ const MAX_QUERIES_PER_FILE = 200; -/** Languages whose Phase B call sites the daemon can currently ratify. */ +/** + * Languages the daemon's live lane detects references for. A cheap pre-filter + * kept in step with the daemon's own gate (`live_language` in travsr-daemon): + * the daemon is authoritative and returns no targets for anything else, so this + * only avoids a wasted round trip on a save the daemon could not act on. + * Widening it beyond the daemon's set costs one empty request, never + * correctness. + * + * The first four run the daemon's native extractor; `go` runs the generic + * tree-sitter detector and reaches this lane through the editor alone + * (RFC-027 section 8.3), which is why it must be listed here to work at all. + */ const SUPPORTED_LANGUAGES = new Set([ "typescript", "typescriptreact", "javascript", "javascriptreact", + "rust", + "python", + "go", ]); -/** - * Identifier immediately followed by `(`. - * - * Deliberately crude (see the module note): it matches inside strings and - * comments, and it misses calls split across lines. Both are recall costs the - * daemon absorbs safely. - */ -const CALL_SITE = /\b([A-Za-z_$][\w$]*)\s*\(/g; - -/** Keywords that look like calls but never are. */ -const NOT_CALLS = new Set([ - "if", - "for", - "while", - "switch", - "catch", - "return", - "function", - "typeof", - "await", - "super", - "constructor", -]); +/** VS Code provider command for each daemon-named provider. */ +const PROVIDER_COMMAND: Record = { + definition: "vscode.executeDefinitionProvider", + implementation: "vscode.executeImplementationProvider", +}; /** Repo-relative, forward-slash path, matching the graph's own path keys. */ function repoRelative(repoRoot: string, uri: vscode.Uri): string | null { @@ -89,27 +97,36 @@ function repoRelative(repoRoot: string, uri: vscode.Uri): string | null { return full.slice(repoRoot.length).replace(/^[/\\]/, "").replace(/\\/g, "/"); } -/** Candidate call-site positions in `doc`, capped. */ -function callSites(doc: vscode.TextDocument): vscode.Position[] { - const out: vscode.Position[] = []; - const text = doc.getText(); - CALL_SITE.lastIndex = 0; - let m: RegExpExecArray | null; - while ((m = CALL_SITE.exec(text)) !== null) { - if (out.length >= MAX_QUERIES_PER_FILE) break; - if (NOT_CALLS.has(m[1])) continue; - out.push(doc.positionAt(m.index)); - } - return out; +/** Escape a name for use inside a `RegExp`. */ +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** - * Ask the running language provider where each call site resolves, and publish - * the answers to the daemon. + * Column of `name` on 1-based `line` in `doc`, or `null` if it is not there. + * + * The native extractor records a reference's line but not its column, so the + * editor recovers the column here. A whole-word match avoids resolving a + * substring of a longer identifier; if the name has moved off the line (a race + * against an edit) we return `null` and skip, never a wrong position. + */ +function columnOf( + doc: vscode.TextDocument, + line: number, + name: string +): number | null { + if (line < 1 || line > doc.lineCount) return null; + const text = doc.lineAt(line - 1).text; + const m = new RegExp(`\\b${escapeRegExp(name)}\\b`).exec(text); + return m ? m.index : null; +} + +/** + * Resolve the references the daemon asked about and publish the answers. * * Returns the number of resolutions reported, for tests and logging. Resolves * to 0 on every uninteresting path (unsupported language, file outside the - * repo, no provider, nothing resolved) and never rejects. + * repo, no targets, no provider, nothing resolved) and never rejects. */ export async function publishLiveResolutions( repoRoot: string, @@ -120,44 +137,23 @@ export async function publishLiveResolutions( if (!file) return 0; const version = doc.version; - const sites = callSites(doc); - if (sites.length === 0) return 0; + const targets = await requestLiveResolutionTargets(repoRoot, file, version); + if (targets.length === 0) return 0; + // The buffer may have moved while the daemon was parsing; the target lines + // would no longer describe this file. + if (doc.version !== version) return 0; const resolutions: LiveResolutionItem[] = []; - for (const pos of sites) { - // The buffer moved while we were asking. Answers computed against the old - // text no longer describe this file, so drop the whole batch rather than - // report positions that have shifted (RFC-027 section 11). + let queries = 0; + for (const target of targets) { + if (queries >= MAX_QUERIES_PER_FILE) break; + // Drop the whole batch if the buffer moved: answers computed against old + // text no longer describe this file (RFC-027 section 11). if (doc.version !== version) return 0; - let locations: unknown; - try { - locations = await vscode.commands.executeCommand( - "vscode.executeDefinitionProvider", - doc.uri, - pos - ); - } catch { - continue; // no provider, or it threw. Neither is worth reporting. - } - - const target = firstLocation(locations); - if (!target) continue; - const targetPath = repoRelative(repoRoot, target.uri); - // Outside the workspace (node_modules, a .d.ts in the SDK) — dropped here - // so the live lane stays intra-corpus (RFC-027 section 8.2). The daemon - // would abstain anyway; not sending it saves the round trip. - if (!targetPath) continue; - - const word = doc.getWordRangeAtPosition(pos); - resolutions.push({ - ref_line: pos.line + 1, - ref_col: pos.character, - name: word ? doc.getText(word) : "", - target_path: targetPath, - target_line: target.range.start.line + 1, - buffer_version: version, - }); + const item = await resolveTarget(repoRoot, doc, target, version); + queries += 1; + if (item) resolutions.push(item); } if (resolutions.length === 0) return 0; @@ -167,13 +163,59 @@ export async function publishLiveResolutions( } /** - * Normalize what a definition provider returned. + * Resolve one daemon-named target through its provider, or `null` to skip. + * + * Every skip is safe: the daemon is fail-closed, so a reference we cannot pin, a + * provider that throws, or a target outside the workspace simply does not become + * an edge. A sloppy skip costs recall, never correctness. + */ +async function resolveTarget( + repoRoot: string, + doc: vscode.TextDocument, + target: LiveResolutionTargetItem, + version: number +): Promise { + const command = PROVIDER_COMMAND[target.provider]; + if (!command) return null; + + const col = columnOf(doc, target.ref_line, target.name); + if (col === null) return null; + const pos = new vscode.Position(target.ref_line - 1, col); + + let locations: unknown; + try { + locations = await vscode.commands.executeCommand(command, doc.uri, pos); + } catch { + return null; // no provider, or it threw. Neither is worth reporting. + } + + const found = firstLocation(locations); + if (!found) return null; + const targetPath = repoRelative(repoRoot, found.uri); + // Outside the workspace (node_modules, a .d.ts in the SDK) — dropped here so + // the live lane stays intra-corpus (RFC-027 section 8.2). The daemon would + // abstain anyway; not sending it saves the round trip. + if (!targetPath) return null; + + return { + ref_line: target.ref_line, + ref_col: col, + name: target.name, + target_path: targetPath, + target_line: found.range.start.line + 1, + buffer_version: version, + edge_kind: target.edge_kind, + }; +} + +/** + * Normalize what a definition/implementation provider returned. * * Providers may answer with `Location[]`, `LocationLink[]`, or a bare - * `Location`, and a single call can resolve to several definitions (an - * overload set, a merged declaration). We take the first: reporting several - * targets for one position would ask the daemon to pick, which is precisely - * the guess the fail-closed contract forbids. + * `Location`, and a single reference can resolve to several targets (an overload + * set, a merged declaration). We take the first: reporting several targets for + * one position would ask the daemon to pick, which is precisely the guess the + * fail-closed contract forbids. */ function firstLocation( raw: unknown diff --git a/packages/travsr-vscode/src/test/suite/daemonIpc.test.ts b/packages/travsr-vscode/src/test/suite/daemonIpc.test.ts index a42aad5f..99047df9 100644 --- a/packages/travsr-vscode/src/test/suite/daemonIpc.test.ts +++ b/packages/travsr-vscode/src/test/suite/daemonIpc.test.ts @@ -240,6 +240,7 @@ suite("daemonIpc: live resolution (RFC-027)", function () { target_path: "src/user.ts", target_line: 17, buffer_version: 9, + edge_kind: "ref/call", }, ]; From bb17d6cd97197b7214c176a6b504202fe463e63f Mon Sep 17 00:00:00 2001 From: Abhishek Date: Sat, 29 Aug 2026 13:38:14 +0530 Subject: [PATCH 14/23] [travsr-analysis] RFC-027 section 8: the remaining 11 non-native languages Extend the generic live reference detector from Go to every other non-native language: Java, C#, C, C++, Objective-C, Ruby, PHP, Kotlin, Swift, Dart and Scala. All 15 code languages are now on the live lane, 3 through the native extractor and 12 through this detector, editor lane only. Structure. A LangSpec per language: one tree-sitter query using four standard captures (@call.name, @sel.name, @field.name, @base.name) plus, where the grammar reuses one node for both, a MemberShape describing how a member access nests inside a call. Adding a language is a query, an arm in the daemon's live_language, and an entry in the extension's SUPPORTED_LANGUAGES. Telling a call from a field read. Go, C#, C/C++/Objective-C, Kotlin, Swift, Dart and Scala all use one node for x.foo(...) and x.foo, and a tree-sitter query cannot say "not the callee of a call". MemberShape names the member node, the call node, and the field labelling the callee (or None where the grammar leaves it unlabelled and the callee is the first named child), and is_call_callee classifies from the tree. Getting this wrong is a correctness bug rather than untidiness: a field read that became ref/call would surface as a caller in get_callers and get_blast_radius (#757). Java, PHP and Ruby need no shape, their grammars already having distinct nodes. Two grammar traps found and pinned by test. A tree-sitter field name binds to the first child carrying it, so Scala's "extends B with R with O", one extends_clause with three type: children, captured only B under the natural pattern and silently dropped every with. Dropping the field name captures all three, and a test now pins that every multi-base clause across Dart, Scala, PHP, C#, Kotlin, Swift and Java yields all of its types. Separately, a wrong node name fails Query::new loudly rather than matching nothing, so a test compiles every registered query against its real grammar and asserts a language declares a MemberShape if and only if its query uses @sel.name, because the fallback for a missing shape is "treat it as a field read" and that would silently turn every method call into a field. Two deliberate omissions, recorded in the module because they look like gaps. Ruby emits no field reads: o.attr is a method call on an attribute reader, which the call pattern already captures, and a bare receiverless helper parses as a plain identifier indistinguishable from a local or a parameter, so emitting every identifier would flood the editor with positions that resolve to locals. Ruby also does not treat include M as implements: it parses as an ordinary call, so recognising it would mean special-casing a method name to assert a relation the grammar does not state. Both are recall costs with no precision cost. C has no inheritance clause to detect. Verified end to end against real language servers, not mocks, with a harness that speaks the daemon's IPC and drives the server over stdio LSP. Dart is the strongest case, being the only non-native language with both a server and a SCIP ratification oracle: live ref/call edges to Session.submit and Session.run, ref/field to Session.total, and is-implementation from class:Session to class:Runner, the first non-native IsImplementation edge, all four ratified in place at commit with zero live rows left, and live_precision.dart reading 3,0,1, precision 1.0000 over 3/4 verifiable claims with the IsImplementation claim unverifiable exactly as the meter note predicted. Swift under sourcekit-lsp produced the same three reference edges plus the protocol conformance clause. C++ under clangd surfaced a real limit that fails safe. clangd answers definition on s->submit() with the out-of-line definition in the .cpp, while travsr's C++ Phase A emits a node only for the declaration in the header, so enclosing_node_at finds nothing and the reference abstains to pending. The field read resolved, because clangd points at the header declaration where a node exists. That is the fail-closed contract working: a position mapping to no node produces no edge rather than a guessed one. The cost is recall and it is structural for the C family. Not worked around, because bridging declaration to definition would mean matching by signature across files, which is a guess (section 8.1). The real fix is a Phase A change and belongs in its own work. Java, C#, Kotlin, PHP, Scala and Ruby have no server on this machine. Their detection and target production are pinned by unit tests, including a daemon-level Java test covering the two halves Go cannot exercise: a grammar with separate call and field nodes, and an extends clause whose edge source is the implementing class in the saved file, so save-invalidation self-heals it. Note the extension's SUPPORTED_LANGUAGES uses VS Code languageId values, which are not always the language name: csharp, objective-c, objective-cpp. Tests: analysis 386, daemon 203 plus integration, clippy clean, tsc clean. --- crates/travsr-analysis/src/live_detect.rs | 740 +++++++++++++++++-- crates/travsr-daemon/src/lib.rs | 89 ++- packages/travsr-vscode/src/liveResolution.ts | 27 +- 3 files changed, 786 insertions(+), 70 deletions(-) diff --git a/crates/travsr-analysis/src/live_detect.rs b/crates/travsr-analysis/src/live_detect.rs index 0f30f4a0..eb49b527 100644 --- a/crates/travsr-analysis/src/live_detect.rs +++ b/crates/travsr-analysis/src/live_detect.rs @@ -18,7 +18,7 @@ //! section 7.3b). //! - **No signature building.** A signature is a claim about identity, and //! identity belongs to SCIP (section 8.2 fencing rule). Nothing here mints a -//! VName or names a node. +//! VName. //! - **No resolution.** The daemon maps positions to nodes against the graph it //! already owns. //! @@ -28,13 +28,18 @@ //! they abstain and keep today's commit-gated behavior exactly, which is the //! RFC's zero-regression floor (section 7.3c). //! -//! Adding a language is one tree-sitter query plus an arm in the daemon's gate, -//! and each is measured against the per-`(language, kind)` precision gate -//! (section 12) before it ships enabled. +//! ## Adding a language +//! +//! One [`LangSpec`]: a tree-sitter query using the four standard captures, and +//! (only where the grammar reuses one node for both) the [`MemberShape`] that +//! tells a callee apart from a field read. Then an arm in the daemon's +//! `live_language` gate and an entry in the extension's `SUPPORTED_LANGUAGES`. +//! Each language is measured against the per-`(language, kind)` precision gate +//! (section 12) once it is emitting. use anyhow::Context as _; use streaming_iterator::StreamingIterator as _; -use tree_sitter::{Parser, Query, QueryCursor}; +use tree_sitter::{Node, Parser, Query, QueryCursor}; use travsr_core::{InheritanceRef, Language}; @@ -77,8 +82,45 @@ impl LiveRefs { } } +/// How a language nests a member-access name inside a call. +/// +/// Several grammars use one node for both `x.foo(…)` and `x.foo` — the member +/// access is simply the callee of a surrounding call in the first case. A +/// tree-sitter query cannot express "not the callee of a call", so a name +/// captured as `@sel.name` is classified from the tree instead. Languages whose +/// grammar already has distinct nodes for the two (Java, PHP, Ruby) need no +/// shape and capture `@call.name` / `@field.name` directly. +/// +/// Getting this wrong costs correctness, not just tidiness: a field read that +/// became a `ref/call` would surface as a caller in `get_callers` and +/// `get_blast_radius` (#757). +struct MemberShape { + /// The member-access expression the captured name sits inside. The captured + /// node may be nested a level or two below it (Swift's `navigation_suffix`), + /// so this is found by walking up, not by taking the parent. + member_kind: &'static str, + /// The call expression that member may be the callee of. + call_kind: &'static str, + /// Field naming the callee on the call node, or `None` when the grammar + /// leaves it unlabelled and the callee is simply the first named child. + callee_field: Option<&'static str>, +} + +/// A language's detection queries and call shape. +struct LangSpec { + /// Tree-sitter query using the four standard captures: + /// + /// - `@call.name` — unambiguously a call site. + /// - `@sel.name` — a member-access name; [`MemberShape`] decides which it is. + /// - `@field.name` — unambiguously a field read. + /// - `@base.name` — the base type of an inheritance clause. + query: &'static str, + /// Required if and only if the query uses `@sel.name`. + member: Option, +} + /// Detect the live lane's reference set in `source`, or an empty set for a -/// language with no detector yet. +/// language with no detector. /// /// Returning empty rather than erroring for an unhandled language keeps the /// daemon's per-language gate the single place a language is turned on: this @@ -88,41 +130,20 @@ pub fn detect_live_refs(lang: Language, source: &[u8]) -> anyhow::Result MAX_SOURCE_BYTES { return Ok(LiveRefs::default()); } - match lang { - Language::Go => detect_go(source), - _ => Ok(LiveRefs::default()), - } + let Some((grammar, spec)) = spec_for(lang) else { + return Ok(LiveRefs::default()); + }; + detect(lang, grammar, &spec, source) } -// ── Go ─────────────────────────────────────────────────────────────────────── -// -// Two patterns cover every reference shape the lane can use: -// -// `foo(...)` → (call_expression function: (identifier)) -// `x.Foo(...)` / `x.Foo` → (selector_expression field: (field_identifier)) -// -// The selector pattern matches both a method call and a plain field read, and -// tree-sitter queries cannot express "not the callee of a call" directly, so the -// two are told apart from the tree: a selector that *is* the `function` child of -// a `call_expression` is a call, anything else is a field read. That keeps a -// field read out of `RefCall`, so it never surfaces as a caller in -// `get_callers` / `get_blast_radius` while still being a use site (#757). -// -// **Go contributes no inheritance clauses, by design.** It has no `extends` / -// `implements`: interface satisfaction is structural and implicit, and struct -// embedding is composition, not implementation. There is no clause in the -// source that asserts "this type implements that interface", so emitting an -// `IsImplementation` edge would mean inferring one — precisely the guess -// section 8.1 forbids. Go's implements relation stays commit-gated, where the -// SCIP sidecar derives it from real type information. -const GO_QUERY: &str = r#" -(call_expression function: (identifier) @call.name) -(selector_expression field: (field_identifier) @sel.name) -"#; - -fn detect_go(source: &[u8]) -> anyhow::Result { - let language = tree_sitter::Language::new(tree_sitter_go::LANGUAGE); - let query = Query::new(&language, GO_QUERY).context("compiling Go live-detect query")?; +fn detect( + lang: Language, + grammar: tree_sitter::Language, + spec: &LangSpec, + source: &[u8], +) -> anyhow::Result { + let query = Query::new(&grammar, spec.query) + .with_context(|| format!("compiling {} live-detect query", lang.as_str()))?; let capture_names: Vec = query .capture_names() .iter() @@ -131,8 +152,8 @@ fn detect_go(source: &[u8]) -> anyhow::Result { let mut parser = Parser::new(); parser - .set_language(&language) - .context("loading Go grammar")?; + .set_language(&grammar) + .with_context(|| format!("loading {} grammar", lang.as_str()))?; // A parse that does not complete yields no references, which abstains for // the whole file. Fail-closed, and the commit-gated path still covers it. let Some(tree) = parser.parse(source, None) else { @@ -150,14 +171,34 @@ fn detect_go(source: &[u8]) -> anyhow::Result { let Ok(name) = cap.node.utf8_text(source) else { continue; }; - let r = LiveRef { - line: cap.node.start_position().row as u32 + 1, - name: name.to_string(), - }; + let line = cap.node.start_position().row as u32 + 1; match cap_name { - "call.name" => out.calls.push(r), - "sel.name" if is_call_callee(cap.node) => out.calls.push(r), - "sel.name" => out.fields.push(r), + "call.name" => out.calls.push(LiveRef { + line, + name: name.to_string(), + }), + "field.name" => out.fields.push(LiveRef { + line, + name: name.to_string(), + }), + "sel.name" => { + let r = LiveRef { + line, + name: name.to_string(), + }; + match spec.member.as_ref() { + Some(shape) if is_call_callee(cap.node, shape) => out.calls.push(r), + // No shape declared for a `@sel.name` capture is a + // detector bug, not a user's. Treat it as a field read, + // the conservative half: a missed call costs recall, + // while a field wrongly called a call is the #757 defect. + _ => out.fields.push(r), + } + } + "base.name" => out.inheritance.push(InheritanceRef { + base_name: name.to_string(), + line, + }), _ => {} } } @@ -165,18 +206,287 @@ fn detect_go(source: &[u8]) -> anyhow::Result { Ok(out) } -/// True when `field` is the field of a selector that is itself the callee of a -/// call — `x.Foo(…)` rather than `x.Foo`. -fn is_call_callee(field: tree_sitter::Node<'_>) -> bool { - let Some(selector) = field.parent() else { +/// True when the member access containing `name` is the callee of a call, so the +/// reference is `x.foo(…)` rather than a bare `x.foo`. +fn is_call_callee(name: Node<'_>, shape: &MemberShape) -> bool { + // Walk up to the member-access node. Bounded because the capture sits at a + // fixed, shallow depth inside it (one level for a `field:`-labelled name, + // two for Swift's `navigation_suffix`), and an unbounded walk would happily + // find an enclosing call several statements out and call it the callee. + let mut member = name; + for _ in 0..MEMBER_LOOKUP_DEPTH { + if member.kind() == shape.member_kind { + break; + } + match member.parent() { + Some(p) => member = p, + None => return false, + } + } + if member.kind() != shape.member_kind { + return false; + } + let Some(call) = member.parent() else { return false; }; - let Some(parent) = selector.parent() else { + if call.kind() != shape.call_kind { return false; + } + match shape.callee_field { + Some(field) => call.child_by_field_name(field) == Some(member), + // Unlabelled grammars put the callee first, ahead of the argument list. + None => call.named_child(0) == Some(member), + } +} + +/// How far above a captured name the member-access node may sit. +const MEMBER_LOOKUP_DEPTH: usize = 3; + +/// The grammar and detection spec for `lang`, or `None` when it has no detector. +fn spec_for(lang: Language) -> Option<(tree_sitter::Language, LangSpec)> { + let (grammar, query, member) = match lang { + Language::Go => ( + tree_sitter::Language::new(tree_sitter_go::LANGUAGE), + GO_QUERY, + Some(MemberShape { + member_kind: "selector_expression", + call_kind: "call_expression", + callee_field: Some("function"), + }), + ), + Language::Java => ( + tree_sitter::Language::new(tree_sitter_java::LANGUAGE), + JAVA_QUERY, + None, + ), + Language::CSharp => ( + tree_sitter::Language::new(tree_sitter_c_sharp::LANGUAGE), + CSHARP_QUERY, + Some(MemberShape { + member_kind: "member_access_expression", + call_kind: "invocation_expression", + callee_field: Some("function"), + }), + ), + Language::Cpp => ( + tree_sitter::Language::new(tree_sitter_cpp::LANGUAGE), + CPP_QUERY, + Some(MemberShape { + member_kind: "field_expression", + call_kind: "call_expression", + callee_field: Some("function"), + }), + ), + Language::C => ( + tree_sitter::Language::new(tree_sitter_c::LANGUAGE), + C_QUERY, + Some(MemberShape { + member_kind: "field_expression", + call_kind: "call_expression", + callee_field: Some("function"), + }), + ), + Language::ObjectiveC => ( + tree_sitter::Language::new(tree_sitter_objc::LANGUAGE), + OBJC_QUERY, + Some(MemberShape { + member_kind: "field_expression", + call_kind: "call_expression", + callee_field: Some("function"), + }), + ), + Language::Ruby => ( + tree_sitter::Language::new(tree_sitter_ruby::LANGUAGE), + RUBY_QUERY, + None, + ), + Language::Php => ( + tree_sitter::Language::new(tree_sitter_php::LANGUAGE_PHP), + PHP_QUERY, + None, + ), + Language::Kotlin => ( + tree_sitter::Language::new(tree_sitter_kotlin_ng::LANGUAGE), + KOTLIN_QUERY, + Some(MemberShape { + member_kind: "navigation_expression", + call_kind: "call_expression", + callee_field: None, + }), + ), + Language::Swift => ( + tree_sitter::Language::new(tree_sitter_swift::LANGUAGE), + SWIFT_QUERY, + Some(MemberShape { + member_kind: "navigation_expression", + call_kind: "call_expression", + callee_field: None, + }), + ), + Language::Dart => ( + tree_sitter::Language::new(tree_sitter_dart::LANGUAGE), + DART_QUERY, + Some(MemberShape { + member_kind: "member_expression", + call_kind: "call_expression", + callee_field: Some("function"), + }), + ), + Language::Scala => ( + tree_sitter::Language::new(tree_sitter_scala::LANGUAGE), + SCALA_QUERY, + Some(MemberShape { + member_kind: "field_expression", + call_kind: "call_expression", + callee_field: Some("function"), + }), + ), + _ => return None, }; - parent.kind() == "call_expression" && parent.child_by_field_name("function") == Some(selector) + Some((grammar, LangSpec { query, member })) } +// ── Per-language queries ───────────────────────────────────────────────────── +// +// Written against each grammar's real node names, not from memory. A wrong name +// fails `Query::new` loudly rather than silently matching nothing, and every +// language below is pinned by a test. + +/// **Go contributes no inheritance clauses, by design.** It has no `extends` / +/// `implements`: interface satisfaction is structural and implicit, and struct +/// embedding is composition, not implementation. No clause in the source asserts +/// "this type implements that interface", so emitting one would mean inferring +/// it — precisely the guess section 8.1 forbids. Go's implements relation stays +/// commit-gated, where the SCIP sidecar derives it from real type information. +const GO_QUERY: &str = r#" +(call_expression function: (identifier) @call.name) +(selector_expression field: (field_identifier) @sel.name) +"#; + +/// Java's grammar already separates `method_invocation` from `field_access`, so +/// no shape is needed. `method_invocation` covers both `helper()` and `o.m()`. +const JAVA_QUERY: &str = r#" +(method_invocation name: (identifier) @call.name) +(field_access field: (identifier) @field.name) +(superclass (type_identifier) @base.name) +(superclass (generic_type (type_identifier) @base.name)) +(super_interfaces (type_list (type_identifier) @base.name)) +(super_interfaces (type_list (generic_type (type_identifier) @base.name))) +(extends_interfaces (type_list (type_identifier) @base.name)) +(extends_interfaces (type_list (generic_type (type_identifier) @base.name))) +"#; + +/// A C# base list holds plain, generic, and qualified names, and the class-vs- +/// interface distinction is not written in the source at all — both are valid +/// `is-implementation` targets, so both are emitted and the target-kind gate in +/// the daemon decides. +const CSHARP_QUERY: &str = r#" +(invocation_expression function: (identifier) @call.name) +(member_access_expression name: (identifier) @sel.name) +(base_list (identifier) @base.name) +(base_list (generic_name (identifier) @base.name)) +(base_list (qualified_name name: (identifier) @base.name)) +"#; + +/// `->` and `.` are both `field_expression` in C/C++, so one pattern covers a +/// pointer and a value receiver. +const CPP_QUERY: &str = r#" +(call_expression function: (identifier) @call.name) +(call_expression function: (qualified_identifier name: (identifier) @call.name)) +(field_expression field: (field_identifier) @sel.name) +(base_class_clause (type_identifier) @base.name) +"#; + +/// C has no inheritance clause to detect. +const C_QUERY: &str = r#" +(call_expression function: (identifier) @call.name) +(field_expression field: (field_identifier) @sel.name) +"#; + +/// A keyword message (`[o setX:1 y:2]`) yields one `method:` capture per keyword. +/// Each resolves to the same selector's definition, so the extra targets produce +/// a duplicate edge rather than a wrong one, and the daemon's upsert absorbs it. +const OBJC_QUERY: &str = r#" +(call_expression function: (identifier) @call.name) +(message_expression method: (identifier) @call.name) +(field_expression field: (field_identifier) @sel.name) +(class_interface superclass: (identifier) @base.name) +"#; + +/// **Ruby has no field reads to detect**, and that is the language, not a gap: +/// `o.attr` is a method call on an attribute reader, which is what `call` here +/// captures. A bare receiverless `helper` is not detectable either — the grammar +/// parses it as a plain `identifier`, indistinguishable from a local variable or +/// a parameter, and emitting every identifier would flood the editor with +/// positions that resolve to locals. Both are recall costs with no precision +/// cost, which is the right side to err on. +/// +/// `include M` is Ruby's nearest analogue to `implements`, and it is deliberately +/// **not** treated as one: it parses as an ordinary `call`, so recognising it +/// would mean special-casing a method name and asserting a relation the grammar +/// does not state. That is an inference, not a detection (section 8.1). +const RUBY_QUERY: &str = r#" +(call method: (identifier) @call.name) +(superclass (constant) @base.name) +(superclass (scope_resolution name: (constant) @base.name)) +"#; + +/// PHP separates every call shape from `member_access_expression`, so no shape is +/// needed. `new Thing()` is included: a constructor call is a reference to the +/// class, the same treatment Python's `Foo()` gets. +const PHP_QUERY: &str = r#" +(function_call_expression function: (name) @call.name) +(member_call_expression name: (name) @call.name) +(scoped_call_expression name: (name) @call.name) +(object_creation_expression (name) @call.name) +(member_access_expression name: (name) @field.name) +(base_clause (name) @base.name) +(class_interface_clause (name) @base.name) +"#; + +/// tree-sitter-kotlin-ng labels neither the callee of a `call_expression` nor +/// the parts of a `navigation_expression`, so the callee is the first named +/// child and the accessed name is the second. `(_)` for the receiver keeps a +/// chained `a.b.c` matching at every level. +const KOTLIN_QUERY: &str = r#" +(call_expression (identifier) @call.name) +(navigation_expression (_) (identifier) @sel.name) +(delegation_specifier (user_type (identifier) @base.name)) +(delegation_specifier (constructor_invocation (user_type (identifier) @base.name))) +"#; + +/// Swift nests the accessed name one level deeper than the other grammars, in a +/// `navigation_suffix` inside the `navigation_expression`, which is why +/// [`is_call_callee`] walks up rather than taking the parent. +const SWIFT_QUERY: &str = r#" +(call_expression (simple_identifier) @call.name) +(navigation_suffix suffix: (simple_identifier) @sel.name) +(inheritance_specifier inherits_from: (user_type (type_identifier) @base.name)) +"#; + +/// Dart splits the three clause kinds into `superclass`, `mixins`, and +/// `interfaces`; all three name a type this class implements or derives from. +const DART_QUERY: &str = r#" +(call_expression function: (identifier) @call.name) +(member_expression property: (identifier) @sel.name) +(superclass type: (type (type_identifier) @base.name)) +(mixins (type (type_identifier) @base.name)) +(interfaces (type (type_identifier) @base.name)) +"#; + +/// Scala folds `extends B with T with U` into one `extends_clause` holding a +/// `type:` child per base, so one pattern covers every half. +/// +/// The base is matched **without** naming the `type:` field on purpose: a field +/// name binds a query to the *first* child carrying it, so `type: (…)` would +/// capture `B` and silently drop every `with` clause after it. Verified against +/// the grammar, not assumed. +const SCALA_QUERY: &str = r#" +(call_expression function: (identifier) @call.name) +(field_expression field: (identifier) @sel.name) +(extends_clause (type_identifier) @base.name) +"#; + #[cfg(test)] mod tests { use super::*; @@ -185,6 +495,46 @@ mod tests { refs.iter().map(|r| (r.line, r.name.as_str())).collect() } + fn bases(refs: &[InheritanceRef]) -> Vec<&str> { + refs.iter().map(|r| r.base_name.as_str()).collect() + } + + /// Every language's query must compile against its real grammar. A wrong + /// node name is a `Query::new` error, not a silent zero-match detector, and + /// this is what keeps that true as grammars are upgraded. + #[test] + fn every_registered_query_compiles() { + for lang in [ + Language::Go, + Language::Java, + Language::CSharp, + Language::Cpp, + Language::C, + Language::ObjectiveC, + Language::Ruby, + Language::Php, + Language::Kotlin, + Language::Swift, + Language::Dart, + Language::Scala, + ] { + let (grammar, spec) = spec_for(lang).expect("a registered language has a spec"); + Query::new(&grammar, spec.query) + .unwrap_or_else(|e| panic!("{} query must compile: {e}", lang.as_str())); + // A `@sel.name` capture is meaningless without the shape that + // classifies it, and the fallback would silently call every method + // call a field read. + assert_eq!( + spec.query.contains("@sel.name"), + spec.member.is_some(), + "{} must declare a MemberShape if and only if it captures @sel.name", + lang.as_str() + ); + } + } + + // ── Go ─────────────────────────────────────────────────────────────────── + #[test] fn go_separates_calls_from_field_reads() { let src = br#" @@ -256,10 +606,288 @@ func (t *T) Foo() {} ); } + // ── Java ───────────────────────────────────────────────────────────────── + + #[test] + fn java_detects_calls_fields_and_both_clause_kinds() { + let src = br#"class C extends Base implements Runnable, Closeable { + void f(Order o) { + helper(); + o.submit(1); + int n = o.total; + } +} +"#; + let refs = detect_live_refs(Language::Java, src).unwrap(); + assert_eq!(names(&refs.calls), vec![(3, "helper"), (4, "submit")]); + assert_eq!(names(&refs.fields), vec![(5, "total")]); + assert_eq!( + bases(&refs.inheritance), + vec!["Base", "Runnable", "Closeable"] + ); + } + + #[test] + fn java_detects_an_interface_extends_clause() { + let refs = detect_live_refs(Language::Java, b"interface I extends J, K {}").unwrap(); + assert_eq!(bases(&refs.inheritance), vec!["J", "K"]); + } + + // ── C# ─────────────────────────────────────────────────────────────────── + + #[test] + fn csharp_separates_calls_from_field_reads() { + let src = br#"class C : Base, N.IRunnable { + void F(Order o) { + Helper(); + o.Submit(1); + var n = o.Total; + } +} +"#; + let refs = detect_live_refs(Language::CSharp, src).unwrap(); + assert_eq!(names(&refs.calls), vec![(3, "Helper"), (4, "Submit")]); + assert_eq!(names(&refs.fields), vec![(5, "Total")]); + assert_eq!(bases(&refs.inheritance), vec!["Base", "IRunnable"]); + } + + // ── C / C++ ────────────────────────────────────────────────────────────── + + #[test] + fn cpp_detects_qualified_calls_and_both_receiver_forms() { + let src = br#"class C : public Base { + void f(Order o, Order* p) { + helper(); + ns::build(); + o.submit(1); + p->submit(2); + int n = o.total; + } +}; +"#; + let refs = detect_live_refs(Language::Cpp, src).unwrap(); + assert_eq!( + names(&refs.calls), + vec![(3, "helper"), (4, "build"), (5, "submit"), (6, "submit")] + ); + assert_eq!(names(&refs.fields), vec![(7, "total")]); + assert_eq!(bases(&refs.inheritance), vec!["Base"]); + } + + #[test] + fn c_detects_calls_and_fields_and_has_no_inheritance() { + let src = br#"void f(struct S s, struct S* p) { + helper(); + int a = s.total; + int b = p->total; +} +"#; + let refs = detect_live_refs(Language::C, src).unwrap(); + assert_eq!(names(&refs.calls), vec![(2, "helper")]); + assert_eq!(names(&refs.fields), vec![(3, "total"), (4, "total")]); + assert!(refs.inheritance.is_empty()); + } + + // ── Objective-C ────────────────────────────────────────────────────────── + + #[test] + fn objc_detects_message_sends_and_the_superclass() { + let src = br#"@interface C : Base +@end +void f(Order* o, struct S s) { + helper(); + [o submit:1]; + int n = s.total; +} +"#; + let refs = detect_live_refs(Language::ObjectiveC, src).unwrap(); + assert_eq!(names(&refs.calls), vec![(4, "helper"), (5, "submit")]); + assert_eq!(names(&refs.fields), vec![(6, "total")]); + assert_eq!(bases(&refs.inheritance), vec!["Base"]); + } + + // ── Ruby ───────────────────────────────────────────────────────────────── + + #[test] + fn ruby_treats_attribute_reads_as_the_method_calls_they_are() { + let src = br#"class C < A::Base + def f(o) + o.submit(1) + o.total + end +end +"#; + let refs = detect_live_refs(Language::Ruby, src).unwrap(); + assert_eq!(names(&refs.calls), vec![(3, "submit"), (4, "total")]); + assert!( + refs.fields.is_empty(), + "Ruby has no field-read expression; an attribute read is a call" + ); + assert_eq!(bases(&refs.inheritance), vec!["Base"]); + } + + #[test] + fn ruby_does_not_infer_implements_from_include() { + // `include M` parses as an ordinary call, so recognising it would mean + // special-casing a method name to assert a relation the grammar does not + // state. That is an inference, not a detection (section 8.1). + let refs = detect_live_refs(Language::Ruby, b"class C\n include M\nend\n").unwrap(); + assert!(refs.inheritance.is_empty()); + assert_eq!(names(&refs.calls), vec![(2, "include")]); + } + + // ── PHP ────────────────────────────────────────────────────────────────── + + #[test] + fn php_detects_every_call_shape_and_both_clause_kinds() { + let src = br#"submit(1); + Factory::build(); + $x = new Thing(); + return $o->total; + } +} +"#; + let refs = detect_live_refs(Language::Php, src).unwrap(); + assert_eq!( + names(&refs.calls), + vec![(4, "helper"), (5, "submit"), (6, "build"), (7, "Thing"),] + ); + assert_eq!(names(&refs.fields), vec![(8, "total")]); + assert_eq!(bases(&refs.inheritance), vec!["Base", "Runnable"]); + } + + // ── Kotlin ─────────────────────────────────────────────────────────────── + + #[test] + fn kotlin_separates_calls_from_property_reads() { + let src = br#"class C : Base(), Runnable { + fun f(o: Order) { + helper() + o.submit(1) + val n = o.total + } +} +"#; + let refs = detect_live_refs(Language::Kotlin, src).unwrap(); + assert_eq!(names(&refs.calls), vec![(3, "helper"), (4, "submit")]); + assert_eq!(names(&refs.fields), vec![(5, "total")]); + assert_eq!(bases(&refs.inheritance), vec!["Base", "Runnable"]); + } + + // ── Swift ──────────────────────────────────────────────────────────────── + + #[test] + fn swift_separates_calls_from_property_reads() { + let src = br#"class C: Base, Runnable { + func f(o: Order) { + helper() + o.submit(1) + let n = o.total + } +} +"#; + let refs = detect_live_refs(Language::Swift, src).unwrap(); + assert_eq!(names(&refs.calls), vec![(3, "helper"), (4, "submit")]); + assert_eq!(names(&refs.fields), vec![(5, "total")]); + assert_eq!(bases(&refs.inheritance), vec!["Base", "Runnable"]); + } + + // ── Dart ───────────────────────────────────────────────────────────────── + + /// A clause that can name several types must yield all of them. A + /// tree-sitter field name binds to the *first* child carrying it, so a + /// pattern written as `type: (…)` over a repeated field silently keeps only + /// the first base. This pins that none of the multi-base clauses do that. + #[test] + fn a_clause_naming_several_types_yields_all_of_them() { + let dart = detect_live_refs( + Language::Dart, + b"class C extends B with M1, M2 implements I1, I2 {}", + ) + .unwrap(); + assert_eq!(bases(&dart.inheritance), vec!["B", "M1", "M2", "I1", "I2"]); + + let scala = + detect_live_refs(Language::Scala, b"class C extends B with R with O { }").unwrap(); + assert_eq!(bases(&scala.inheritance), vec!["B", "R", "O"]); + + let php = detect_live_refs( + Language::Php, + b" Option<(travsr_core::Language, LiveLane)> { + use travsr_core::Language as L; let ext = abs_path.extension().and_then(|e| e.to_str()).unwrap_or(""); - match travsr_core::Language::from_extension(ext)? { - lang @ (travsr_core::Language::TypeScript - | travsr_core::Language::Rust - | travsr_core::Language::Python) => Some((lang, LiveLane::Native)), - lang @ travsr_core::Language::Go => Some((lang, LiveLane::EditorOnly)), + match L::from_extension(ext)? { + lang @ (L::TypeScript | L::Rust | L::Python) => Some((lang, LiveLane::Native)), + lang @ (L::Go + | L::Java + | L::CSharp + | L::Cpp + | L::C + | L::ObjectiveC + | L::Ruby + | L::Php + | L::Kotlin + | L::Swift + | L::Dart + | L::Scala) => Some((lang, LiveLane::EditorOnly)), _ => None, } } @@ -8868,6 +8887,54 @@ mod tests { assert_eq!(count.edge_kind, "ref/field"); } + /// RFC-027 section 8: the generic path is one path, not a Go path. Java + /// exercises the two halves Go cannot: a grammar with *separate* call and + /// field nodes (so no callee disambiguation runs), and an `extends` / + /// `implements` clause, which Go has none of. The clause sits on the + /// implementing class's own declaration, so its edge source is that class in + /// the saved file and save-invalidation self-heals it (§8.4) — the property + /// Rust's detached `impl` block lacks (§7). + #[test] + fn a_java_file_yields_call_field_and_implements_targets() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + git_init(tmp.path()); + + std::fs::write( + tmp.path().join("Base.java"), + "public class Base {\n public int total;\n public void submit() {}\n}\n", + ) + .unwrap(); + let caller = tmp.path().join("Order.java"); + std::fs::write( + &caller, + "public class Order extends Base {\n public int run(Base b) {\n b.submit();\n return b.total;\n }\n}\n", + ) + .unwrap(); + + std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); + init_repo(tmp.path()).unwrap(); + std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); + + let db_path = tmp.path().join(".travsr/graph.db"); + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + let corpus = store.get_meta("corpus").unwrap().unwrap_or_default(); + let targets = live_resolution_targets(&store, &corpus, tmp.path(), &caller); + + let by_name = |name: &str| { + targets + .iter() + .find(|t| t.name == name) + .unwrap_or_else(|| panic!("{name} must be an editor target, got {targets:?}")) + }; + assert_eq!(by_name("submit").edge_kind, "ref/call"); + assert_eq!(by_name("total").edge_kind, "ref/field"); + let base = by_name("Base"); + assert_eq!(base.edge_kind, "is-implementation"); + assert_eq!(base.ref_line, 1, "the clause is on the class declaration"); + assert!(targets.iter().all(|t| t.provider == "definition")); + } + /// The lexical floor is native-only (section 8.3): the generic detector /// recovers no receiver type and builds no signature key, so there is /// nothing for it to match on and the save path must not parse the file to diff --git a/packages/travsr-vscode/src/liveResolution.ts b/packages/travsr-vscode/src/liveResolution.ts index 1932fcc4..80491ca7 100644 --- a/packages/travsr-vscode/src/liveResolution.ts +++ b/packages/travsr-vscode/src/liveResolution.ts @@ -70,18 +70,39 @@ const MAX_QUERIES_PER_FILE = 200; * Widening it beyond the daemon's set costs one empty request, never * correctness. * - * The first four run the daemon's native extractor; `go` runs the generic - * tree-sitter detector and reaches this lane through the editor alone - * (RFC-027 section 8.3), which is why it must be listed here to work at all. + * The first block runs the daemon's native extractor. The rest run the generic + * tree-sitter detector and reach this lane through the editor **alone** + * (RFC-027 section 8.3), so omitting one here disables it outright: this filter + * gates the request itself, not just the round trip. + * + * These are VS Code `languageId` values, which are not always the name of the + * language: C# is `csharp`, Objective-C is `objective-c` / `objective-cpp`. + * Whether a given language resolves anything depends on the developer having + * its extension installed; with no provider the request simply returns nothing, + * which is the section 7.3c floor. */ const SUPPORTED_LANGUAGES = new Set([ + // Native extractor. "typescript", "typescriptreact", "javascript", "javascriptreact", "rust", "python", + // Generic detector, editor lane only. "go", + "java", + "csharp", + "cpp", + "c", + "objective-c", + "objective-cpp", + "ruby", + "php", + "kotlin", + "swift", + "dart", + "scala", ]); /** VS Code provider command for each daemon-named provider. */ From 089be92feb8f944ac928eded4f5c2ce936eb35e4 Mon Sep 17 00:00:00 2001 From: Abhishek Date: Sat, 29 Aug 2026 15:34:18 +0530 Subject: [PATCH 15/23] [travsr-daemon] RFC-027 section 8.7.5: interface-edit closure reaches the editor lane The target request answered only for the saved file, so a rename or added symbol left dependent files' live edges stranded until each was saved on its own: the editor never re-resolves a file it did not just save. The daemon now computes the reverse closure at request time, from durable pending rows plus the saved file's parse, so it does not race the watcher: dependents_pending_on_file returns the files holding a pending reference the saved file can now satisfy (same-language leaf-name match, capped). The RequestLiveResolutionTargets response carries {own, dependents}; the editor resolves all of it under one shared query budget that bounds the total, skips a dependent dirty in another tab, and reports per file so each dependent's edge attaches to a definition in the dependent and self-heals on its own next save. An older extension reads the object response as a non-array and degrades to no targets, which is fail-closed. store 185, ipc 30, daemon 204 green; tsc clean. --- crates/travsr-daemon/src/lib.rs | 143 ++++++++++++++++++- crates/travsr-ipc/src/message.rs | 80 +++++++++++ crates/travsr-store/src/lib.rs | 64 +++++++++ packages/travsr-vscode/src/daemonIpc.ts | 38 ++++- packages/travsr-vscode/src/liveResolution.ts | 113 ++++++++++++--- 5 files changed, 411 insertions(+), 27 deletions(-) diff --git a/crates/travsr-daemon/src/lib.rs b/crates/travsr-daemon/src/lib.rs index 4ecde4c8..e3c82f1c 100644 --- a/crates/travsr-daemon/src/lib.rs +++ b/crates/travsr-daemon/src/lib.rs @@ -3969,6 +3969,61 @@ fn live_resolution_targets( } } +/// RFC-027 section 8.7.5: the interface-edit closure, as editor targets. +/// +/// When the saved file adds or renames a symbol other files reference by name, +/// their edges into it were stranded — `reindex_replace` invalidated them and +/// nothing restores them, because the editor only ever publishes the document +/// that was saved. This computes those dependents (a file with a `pending` +/// reference the saved file can now satisfy) and returns each one's editor +/// targets, so the same target request re-resolves them alongside the saved +/// file. The editor keeps the initiator role (§10.1). +/// +/// The dependent set is derived from durable `pending` rows plus the saved +/// file's current parse, so it does not depend on this save's watcher event +/// having already computed a closure — the two run on independent clocks and a +/// request can arrive first. +/// +/// Bounded at `LIVE_CLOSURE_FILE_CAP` files; the editor bounds the *total* +/// provider round trips across the saved file and every dependent, so one rename +/// in a hot utility cannot turn a save into an unbounded storm of queries. A +/// dependent dirty in another editor tab is skipped by the editor, not here: the +/// daemon reads the file from disk and cannot see an unsaved buffer. +fn dependent_resolution_targets( + store: &SqliteStore, + corpus: &str, + repo_root: &Path, + abs_path: &Path, +) -> Vec { + // Only a live-lane file the gate has not disabled can have dependents worth + // restoring; a disabled or unsupported language costs nothing here. + let Some((lang, _lane)) = live_language(abs_path) else { + return Vec::new(); + }; + if !live_lane_enabled_for(store, lang.as_str()) { + return Vec::new(); + } + let vname_path = abs_path + .strip_prefix(repo_root) + .unwrap_or(abs_path) + .to_string_lossy() + .replace('\\', "/"); + let dependents = store + .dependents_pending_on_file(corpus, &vname_path, lang.as_str(), LIVE_CLOSURE_FILE_CAP) + .unwrap_or_default(); + let mut out = Vec::new(); + for dep in dependents { + let dep_abs = repo_root.join(&dep); + let targets = live_resolution_targets(store, corpus, repo_root, &dep_abs); + // A dependent with nothing for the editor to do (its references all + // settle lexically, or it holds none) is not worth sending. + if !targets.is_empty() { + out.push(travsr_ipc::message::DependentTargets { file: dep, targets }); + } + } + out +} + /// RFC-027 section 12: the continuous precision meter. /// /// "Is the live lane worse than nothing?" is answered empirically, not by @@ -8935,6 +8990,79 @@ mod tests { assert!(targets.iter().all(|t| t.provider == "definition")); } + /// RFC-027 section 8.7.5: the interface-edit closure reaches the editor lane. + /// + /// `run.go` references a method the graph cannot settle, so its save records + /// the reference as `pending`. When `session.go` — which defines that method + /// — is saved, its target request must name `run.go` as a dependent so the + /// editor re-resolves it, rather than leaving `run.go`'s live edge stranded + /// until `run.go` is itself saved. Before the fix `dependent_resolution_ + /// targets` did not exist and the request answered with the saved file alone. + #[test] + fn saving_a_definition_names_its_pending_dependents_as_targets() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + git_init(tmp.path()); + + std::fs::write( + tmp.path().join("go.mod"), + "module example.com/m\n\ngo 1.21\n", + ) + .unwrap(); + let session = tmp.path().join("session.go"); + std::fs::write( + &session, + "package main\n\ntype Session struct{}\n\nfunc (s *Session) Helper() {}\n", + ) + .unwrap(); + let caller = tmp.path().join("run.go"); + std::fs::write( + &caller, + "package main\n\nfunc Run(s *Session) {\n\ts.Helper()\n}\n", + ) + .unwrap(); + + std::env::set_var("TRAVSR_DISABLE_REGISTRY", "1"); + init_repo(tmp.path()).unwrap(); + std::env::remove_var("TRAVSR_DISABLE_REGISTRY"); + + let db_path = tmp.path().join(".travsr/graph.db"); + let corpus = { + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + store.get_meta("corpus").unwrap().unwrap_or_default() + }; + + // `run.go`'s save records its reference to `Helper` as pending: a generic + // language has no lexical floor, so every detected reference abstains + // (§8.3) and the pending row is the honest record the editor upgrades. + { + let mut store = travsr_store::SqliteStore::open(&db_path).unwrap(); + live_resolve_file(&mut store, &corpus, tmp.path(), &caller); + } + + let store = travsr_store::SqliteStore::open(&db_path).unwrap(); + // A save of `session.go` (which defines `Helper`) must surface `run.go` + // as a dependent carrying that reference as an editor target. + let dependents = dependent_resolution_targets(&store, &corpus, tmp.path(), &session); + let run = dependents + .iter() + .find(|d| d.file == "run.go") + .unwrap_or_else(|| panic!("run.go must be a dependent, got {dependents:?}")); + assert!( + run.targets.iter().any(|t| t.name == "Helper"), + "the stranded reference must be an editor target, got {:?}", + run.targets + ); + + // A file that defines nothing any pending reference names has no + // dependents — a body edit stays local (§6.1), no closure fan-out. + let none = dependent_resolution_targets(&store, &corpus, tmp.path(), &caller); + assert!( + none.iter().all(|d| d.file != "session.go"), + "run.go defines nothing session.go is pending on, got {none:?}" + ); + } + /// The lexical floor is native-only (section 8.3): the generic detector /// recovers no receiver type and builds no signature key, so there is /// nothing for it to match on and the save path must not parse the file to @@ -11932,18 +12060,27 @@ fn handle_control_message( let s = store.lock().unwrap_or_else(|e| e.into_inner()); let corpus = s.get_meta("corpus").ok().flatten().unwrap_or_default(); let abs_path = repo_root.join(&file); - let targets = live_resolution_targets(&s, &corpus, repo_root, &abs_path); + let own = live_resolution_targets(&s, &corpus, repo_root, &abs_path); + // RFC-027 section 8.7.5: also re-resolve the files whose edges this + // save can restore, so a rename in one file heals its dependents + // without each being saved in turn. + let dependents = dependent_resolution_targets(&s, &corpus, repo_root, &abs_path); drop(s); tracing::debug!( event = "live.targets", session = %session, file = %file, - count = targets.len(), + count = own.len(), + dependents = dependents.len(), "live resolution targets computed" ); let mut resp = ControlResponse::ok(None); - resp.result = serde_json::to_value(&targets).ok(); + resp.result = serde_json::to_value(travsr_ipc::message::LiveResolutionTargets { + own, + dependents, + }) + .ok(); (resp, false) } Ok(ControlMessage::ReportLiveResolution { diff --git a/crates/travsr-ipc/src/message.rs b/crates/travsr-ipc/src/message.rs index c9544bcc..c6529ee4 100644 --- a/crates/travsr-ipc/src/message.rs +++ b/crates/travsr-ipc/src/message.rs @@ -254,6 +254,42 @@ pub struct LiveResolutionTarget { pub provider: String, } +/// The targets in one dependent file the editor should also resolve +/// (RFC-027 section 8.7.5, the interface-edit closure). +/// +/// When the saved file adds or renames a symbol other files reference by name, +/// their edges into it were stranded and the editor, which only ever publishes +/// the saved document, never re-resolves them. The daemon names those files +/// here so the same target request restores them, keeping the editor the +/// initiator (§10.1). The editor opens each file, resolves its `targets`, and +/// reports back under that file's own path — so a dependent's edges attach to a +/// definition in the dependent (self-healing on its next save), never to one in +/// the saved file. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct DependentTargets { + /// Repo-relative path of the dependent, forward slashes. + pub file: String, + /// The references in `file` to resolve, same shape as the saved file's own. + pub targets: Vec, +} + +/// The full answer to a target request: the saved file's own references plus the +/// dependents the interface-edit closure wants re-resolved (RFC-027 §8.7.5). +/// +/// Rides `ControlResponse::result` as a JSON object. An extension too old to +/// know this shape reads `result` as an array, finds an object, and treats it as +/// no targets — the live lane simply stays at its lexical floor for that save, +/// which is fail-closed (§8.1), never a wrong edge. New extensions read `own` +/// for the saved document and open each `dependents` file to resolve it. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct LiveResolutionTargets { + /// References in the saved file, resolved against its live buffer. + pub own: Vec, + /// Dependent files whose stranded edges this save can restore (§8.7.5). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub dependents: Vec, +} + /// One file's current diagnostic state, as an editor sees it. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct FileDiagnostics { @@ -457,6 +493,50 @@ mod tests { assert_eq!(v["provider"], "definition"); } + // RFC-027 section 8.7.5: the target response carries the saved file's own + // references under `own` and the interface-edit closure under `dependents`, + // each dependent naming its own file so the editor reports it back keyed + // there. `dependents` is omitted when empty so a body edit's answer stays the + // shape it always was, only nested one level under `own`. + #[test] + fn a_target_response_carries_own_and_dependents() { + let resp = LiveResolutionTargets { + own: vec![LiveResolutionTarget { + ref_line: 12, + name: "run".to_string(), + edge_kind: "ref/call".to_string(), + provider: "definition".to_string(), + }], + dependents: vec![DependentTargets { + file: "src/main.go".to_string(), + targets: vec![LiveResolutionTarget { + ref_line: 4, + name: "Start".to_string(), + edge_kind: "ref/call".to_string(), + provider: "definition".to_string(), + }], + }], + }; + let v = serde_json::to_value(&resp).expect("serialise"); + assert_eq!(v["own"][0]["name"], "run"); + assert_eq!(v["dependents"][0]["file"], "src/main.go"); + assert_eq!(v["dependents"][0]["targets"][0]["name"], "Start"); + + // A body edit yields no dependents, and the field is then absent. + let bare = LiveResolutionTargets { + own: vec![], + dependents: vec![], + }; + let v = serde_json::to_value(&bare).expect("serialise"); + assert!(v.get("dependents").is_none(), "empty dependents omitted"); + // An old-shape answer (bare array) is not this struct, which is exactly + // why an old extension reading `result` as an array degrades to no + // targets rather than mis-parsing. + let round: LiveResolutionTargets = + serde_json::from_value(serde_json::json!({"own": []})).expect("default dependents"); + assert!(round.dependents.is_empty()); + } + // An empty resolution list is meaningful: the editor looked and resolved // nothing, which the daemon records as pending rather than as "no report". #[test] diff --git a/crates/travsr-store/src/lib.rs b/crates/travsr-store/src/lib.rs index 4dd4ef08..c6032d28 100644 --- a/crates/travsr-store/src/lib.rs +++ b/crates/travsr-store/src/lib.rs @@ -5205,6 +5205,70 @@ LIMIT ?4", .map_err(|e| StoreError::Database(e.to_string())) } + /// RFC-027 sections 6.3 and 8.7.5: the files whose stranded live edges the + /// save of `path` can now restore — a dependent holding a `pending` + /// reference that names a symbol `path` defines. + /// + /// This is the reverse-closure that the interface-edit fix (§8.7.5) hands to + /// the editor. The editor only ever publishes the document that was saved, so + /// a dependent whose edge into `path` was invalidated is never re-resolved on + /// its own; naming it here lets the target request pull it in beside the + /// saved file's own references, keeping the editor the initiator. + /// + /// Matched on the pending reference's leaf name against this file's + /// definition signatures — `kind:Leaf` (unqualified) or `kind:Qual.Leaf` + /// (qualified), the two shapes `signature` takes. Scoped to `language`: a + /// live edge stays within one language, both because §8.2 keeps it + /// intra-corpus and because a cross-language definition would not resolve + /// through the dependent's own provider anyway. Leaf names are identifiers, + /// so they carry no `LIKE` wildcard to escape. + /// + /// Capped at `limit` files (`LIVE_CLOSURE_FILE_CAP` at the call site): a + /// symbol thousands of files are pending on is a hot utility, and the + /// freshness is not worth re-resolving every one on each save. Truncation + /// costs recall, which the commit-gated path repairs. Over-inclusion (a leaf + /// name shared by an unrelated symbol) is recall-neutral: the editor resolves + /// the real position and the daemon maps it fail-closed, so a spurious file + /// costs a round trip, never a wrong edge. + pub fn dependents_pending_on_file( + &self, + corpus: &str, + path: &str, + language: &str, + limit: usize, + ) -> Result, StoreError> { + (|| -> AnyResult> { + let mut stmt = self + .conn + .prepare_cached( + "SELECT DISTINCT dep.path \ + FROM ref_resolution_state r \ + JOIN nodes dep ON dep.id = r.src \ + WHERE r.state = 'pending' \ + AND dep.corpus = ?1 AND dep.path <> ?2 AND dep.language = ?3 \ + AND EXISTS ( \ + SELECT 1 FROM nodes def \ + WHERE def.corpus = ?1 AND def.path = ?2 AND def.language = ?3 \ + AND (def.signature LIKE '%:' || r.name \ + OR def.signature LIKE '%.' || r.name) \ + ) \ + ORDER BY dep.path ASC LIMIT ?4", + ) + .context("dependents_pending_on_file: prepare")?; + let rows = stmt + .query_map(params![corpus, path, language, limit as i64], |row| { + row.get::<_, String>(0) + }) + .context("dependents_pending_on_file: query")?; + let mut out = Vec::new(); + for row in rows { + out.push(row.context("decoding dependents_pending_on_file row")?); + } + Ok(out) + })() + .map_err(|e| StoreError::Database(e.to_string())) + } + /// RFC-027 section 10: how many references are currently unresolved. /// /// The count half of [`pending_refs_in_file`], for the envelope note where diff --git a/packages/travsr-vscode/src/daemonIpc.ts b/packages/travsr-vscode/src/daemonIpc.ts index 794cd12f..7a442ce2 100644 --- a/packages/travsr-vscode/src/daemonIpc.ts +++ b/packages/travsr-vscode/src/daemonIpc.ts @@ -225,20 +225,42 @@ export interface LiveResolutionTargetItem { provider: string; } +/** + * The targets in one dependent file the editor should also resolve + * (RFC-027 section 8.7.5, the interface-edit closure). The editor opens the + * file, resolves its `targets`, and reports back keyed to `file`. + */ +export interface DependentTargetsItem { + /** Repo-relative path of the dependent, forward slashes. */ + file: string; + /** The references in `file` to resolve. */ + targets: LiveResolutionTargetItem[]; +} + +/** + * The full answer to a target request: the saved file's own references plus the + * dependents the interface-edit closure wants re-resolved (RFC-027 §8.7.5). + */ +export interface LiveResolutionTargetsResult { + own: LiveResolutionTargetItem[]; + dependents: DependentTargetsItem[]; +} + /** * Ask the daemon which references in a dirty file this editor should resolve * (RFC-027 daemon-driven positions). * * Unlike the fire-and-forget reports, this reads a response: the owning daemon - * answers with the target list, other daemons reject on the repo-identity guard. - * Returns `[]` on every miss — no daemon, a daemon too old to know the op, a - * malformed answer — so the caller simply resolves nothing that round. + * answers with `{ own, dependents }`, other daemons reject on the repo-identity + * guard. Returns empty on every miss — no daemon, a daemon too old to know the + * op or one that still answers with a bare array, a malformed answer — so the + * caller simply resolves nothing that round. */ export async function requestLiveResolutionTargets( repoRoot: string, file: string, bufferVersion: number -): Promise { +): Promise { const result = await request(repoRoot, { op: "request-live-resolution-targets", repo_root: repoRoot, @@ -246,7 +268,13 @@ export async function requestLiveResolutionTargets( file, buffer_version: bufferVersion, }); - return Array.isArray(result) ? (result as LiveResolutionTargetItem[]) : []; + const empty: LiveResolutionTargetsResult = { own: [], dependents: [] }; + if (!result || typeof result !== "object" || Array.isArray(result)) return empty; + const r = result as Partial; + return { + own: Array.isArray(r.own) ? r.own : [], + dependents: Array.isArray(r.dependents) ? r.dependents : [], + }; } /** diff --git a/packages/travsr-vscode/src/liveResolution.ts b/packages/travsr-vscode/src/liveResolution.ts index 80491ca7..ee0522dc 100644 --- a/packages/travsr-vscode/src/liveResolution.ts +++ b/packages/travsr-vscode/src/liveResolution.ts @@ -45,6 +45,7 @@ import * as vscode from "vscode"; import { + DependentTargetsItem, LiveResolutionItem, LiveResolutionTargetItem, reportLiveResolution, @@ -52,15 +53,18 @@ import { } from "./daemonIpc"; /** - * Cap on provider queries per file. + * Cap on provider queries for one save, across the saved file **and** every + * dependent the interface-edit closure pulls in (RFC-027 section 8.7.5). * - * Each is an IPC round trip into another extension host. The daemon already - * keeps the set surgical (only references its lexical lane could not settle), so - * a hand-written file is far below this; the cap only bounds a pathological - * generated file. Truncation degrades recall, which the commit-gated path - * repairs. + * Each is an IPC round trip into another extension host. The daemon keeps the + * set surgical (only references its lexical lane could not settle), so a + * hand-written file is far below this; the cap only bounds a pathological + * generated file or a rename in a hot utility whose closure is large. It bounds + * the *total*, not each file, so one save can never fan out into an unbounded + * storm of queries (§10.1). Truncation degrades recall, which the commit-gated + * path repairs. */ -const MAX_QUERIES_PER_FILE = 200; +const MAX_QUERIES_PER_SAVE = 200; /** * Languages the daemon's live lane detects references for. A cheap pre-filter @@ -142,12 +146,22 @@ function columnOf( return m ? m.index : null; } +/** A shared, mutable query budget spent across the saved file and dependents. */ +interface Budget { + remaining: number; +} + /** * Resolve the references the daemon asked about and publish the answers. * - * Returns the number of resolutions reported, for tests and logging. Resolves - * to 0 on every uninteresting path (unsupported language, file outside the - * repo, no targets, no provider, nothing resolved) and never rejects. + * Resolves the saved document's own references, then the dependents whose live + * edges this save can restore (RFC-027 section 8.7.5): a rename in one file + * heals the files that reference it without each being saved in turn. All of it + * shares one query budget so a large closure cannot balloon a save. + * + * Returns the total number of resolutions reported, for tests and logging. + * Resolves to 0 on every uninteresting path (unsupported language, file outside + * the repo, no targets, no provider, nothing resolved) and never rejects. */ export async function publishLiveResolutions( repoRoot: string, @@ -158,25 +172,86 @@ export async function publishLiveResolutions( if (!file) return 0; const version = doc.version; - const targets = await requestLiveResolutionTargets(repoRoot, file, version); - if (targets.length === 0) return 0; + const { own, dependents } = await requestLiveResolutionTargets( + repoRoot, + file, + version + ); + if (own.length === 0 && dependents.length === 0) return 0; // The buffer may have moved while the daemon was parsing; the target lines // would no longer describe this file. if (doc.version !== version) return 0; + const budget: Budget = { remaining: MAX_QUERIES_PER_SAVE }; + let total = 0; + + // The saved file, resolved against its live buffer. + total += await resolveAndReport(repoRoot, doc, file, own, version, budget); + + // Each dependent, resolved against its own file (RFC-027 section 8.7.5). + for (const dep of dependents) { + if (budget.remaining <= 0) break; + total += await resolveDependent(repoRoot, dep, budget); + } + + return total; +} + +/** + * Resolve one dependent file's targets against its own document. + * + * A dependent dirty in another tab is **skipped**: its buffer differs from the + * text the daemon parsed from disk, so the target lines would not describe it, + * and resolving against stale text is exactly the wrong-edge risk the lane must + * avoid (RFC-027 section 10.1). Only the saved document's version is trustworthy + * here; for a clean or freshly opened dependent, disk equals what the daemon saw. + */ +async function resolveDependent( + repoRoot: string, + dep: DependentTargetsItem, + budget: Budget +): Promise { + const uri = vscode.Uri.joinPath(vscode.Uri.file(repoRoot), dep.file); + const open = vscode.workspace.textDocuments.find( + (d) => d.uri.fsPath === uri.fsPath + ); + let depDoc: vscode.TextDocument; + if (open) { + if (open.isDirty) return 0; // unsaved edits — its buffer is not what the daemon parsed. + depDoc = open; + } else { + try { + depDoc = await vscode.workspace.openTextDocument(uri); + } catch { + return 0; // gone from disk or unreadable — skip, never guess. + } + } + return resolveAndReport(repoRoot, depDoc, dep.file, dep.targets, depDoc.version, budget); +} + +/** + * Resolve `targets` against `doc` under the shared `budget` and report the + * answers for `file`. Returns the number reported. Drops the whole batch if the + * buffer moves mid-flight (RFC-027 section 11) — answers against old text no + * longer describe the file. + */ +async function resolveAndReport( + repoRoot: string, + doc: vscode.TextDocument, + file: string, + targets: LiveResolutionTargetItem[], + version: number, + budget: Budget +): Promise { + if (targets.length === 0) return 0; const resolutions: LiveResolutionItem[] = []; - let queries = 0; for (const target of targets) { - if (queries >= MAX_QUERIES_PER_FILE) break; - // Drop the whole batch if the buffer moved: answers computed against old - // text no longer describe this file (RFC-027 section 11). + if (budget.remaining <= 0) break; if (doc.version !== version) return 0; - const item = await resolveTarget(repoRoot, doc, target, version); - queries += 1; + budget.remaining -= 1; if (item) resolutions.push(item); } - if (resolutions.length === 0) return 0; if (doc.version !== version) return 0; await reportLiveResolution(repoRoot, file, resolutions); From 093ca9d7fe9329ae357104cb50b685972eb2f808 Mon Sep 17 00:00:00 2001 From: Abhishek Date: Sat, 29 Aug 2026 15:42:26 +0530 Subject: [PATCH 16/23] [travsr-daemon] RFC-027 section 8.7.6: strict opt-in gate for the live lane RFC section 12 says a language ships enabled only at measured precision >= 0.99, but the gate left an unmeasured language enabled so it could earn a reading. When eleven non-native languages joined at once, nine shipped enabled with no reading at all, which contradicted the stated bar. live_lane_enabled_for now requires two conditions: no adverse meter reading (the existing safety, which disables even a shipped language that degrades and re-enables it on recovery), and membership in LIVE_LANE_SHIPPED, the code-level opt-in list. A language joins that list only after a real server plus its ratification oracle measured it at >= 0.99, so adding an entry is the per-language opt-in the RFC asks for, auditable against the reading that earned it. The starting set is the three native languages plus Go and Dart, the two measured end to end at 1.0000. An unmeasured, un-vouched language would otherwise deadlock: disabled, it can never run to produce the claims that would measure it. TRAVSR_LIVE_LANE_MEASURE lifts the gate for exactly the language a measurement fixture is driving, without touching the adverse-meter safety. daemon 205 green. --- crates/travsr-daemon/src/lib.rs | 128 +++++++++++++++--- docs/rfcs/RFC-027-live-semantic-resolution.md | 2 +- 2 files changed, 112 insertions(+), 18 deletions(-) diff --git a/crates/travsr-daemon/src/lib.rs b/crates/travsr-daemon/src/lib.rs index e3c82f1c..e8e47bdb 100644 --- a/crates/travsr-daemon/src/lib.rs +++ b/crates/travsr-daemon/src/lib.rs @@ -4155,22 +4155,65 @@ const LIVE_PRECISION_GATE: f64 = 0.99; /// Minimum verified live claims before the gate may DISABLE a language. /// -/// Below this the sample is too small to act on, so the lane stays enabled and -/// keeps gathering evidence — a language (a brand-new one especially) has to be -/// allowed to run to be measured at all. Precision-first, but not so trigger-shy -/// that one early disagreement silences a language for good. +/// Below this the sample is too small to act on as an *adverse* reading, so an +/// already-shipped language keeps running and gathering evidence rather than +/// being silenced for good by one early disagreement. Precision-first, but not +/// trigger-shy. const LIVE_PRECISION_MIN_SAMPLE: u64 = 20; +/// RFC-027 sections 12 and 8.7.5: the languages the live lane ships ENABLED, +/// each vouched at measured precision ≥ 0.99. +/// +/// This is the strict-gate opt-in (§8.7.6 decision). RFC §12 says a language +/// ships enabled only at measured precision ≥ 0.99, but the earlier policy left +/// an *unmeasured* language enabled so it could earn a reading — which, when +/// eleven non-native languages joined at once, meant nine shipped enabled with +/// no reading at all. Strict gating inverts that default: a language is disabled +/// until it is on this list, and it joins the list only after a real server plus +/// its ratification oracle measured it at ≥ 0.99 (the §11.3 procedure). Adding a +/// `nodes.language` value here is therefore the per-language opt-in the RFC asks +/// for, auditable in git history against the reading that earned it. +/// +/// `nodes.language` values (what the meter keys on). JavaScript has no value of +/// its own — `.js`/`.jsx` map to `typescript` — so one entry covers both. +/// +/// Starting set: the three native languages (gated at ship since RFC §14 Phase +/// 0–4) plus Go and Dart, the two measured end to end at 1.0000 (§8.7.2). The +/// remaining non-native languages join as §11.3 fills in. +const LIVE_LANE_SHIPPED: &[&str] = &["typescript", "rust", "python", "go", "dart"]; + +/// Force-enable a language for measurement, bypassing the strict opt-in gate +/// (but never the adverse-meter safety below). +/// +/// A language absent from [`LIVE_LANE_SHIPPED`] cannot run, so it can never +/// produce the claims the meter needs to measure it — the deadlock the strict +/// gate would otherwise create. `TRAVSR_LIVE_LANE_MEASURE` (comma-separated +/// `nodes.language` values) lifts the gate for exactly the languages being +/// measured, so the §11.3 harness can drive one long enough to earn a reading. +/// Set on the daemon running a fixture; never in production, where the shipped +/// list is authoritative. +fn live_lane_measure_forced(language: &str) -> bool { + std::env::var("TRAVSR_LIVE_LANE_MEASURE") + .ok() + .is_some_and(|v| v.split(',').any(|l| l.trim() == language)) +} + /// RFC-027 section 12: is the live lane enabled for `language`? /// -/// Precision-driven and biased toward on: enabled unless the cumulative -/// per-language meter carries a statistically meaningful adverse reading — at -/// least [`LIVE_PRECISION_MIN_SAMPLE`] verified claims *and* precision below -/// [`LIVE_PRECISION_GATE`]. A language with too little evidence, or none, stays -/// enabled so it can earn a reading. This is what makes "disable the lane for a -/// language" a measured decision rather than a config guess, and it is -/// self-healing: a later run whose Phase B agreement lifts the cumulative -/// precision back over the bar re-enables the language on its own. +/// Two independent conditions, both of which must hold: +/// +/// 1. **No adverse reading.** The cumulative per-language meter must not carry a +/// statistically meaningful adverse sample — at least +/// [`LIVE_PRECISION_MIN_SAMPLE`] verified claims *and* precision below +/// [`LIVE_PRECISION_GATE`]. This disables even a shipped language whose +/// measured precision has degraded, and it is self-healing: a later run whose +/// Phase B agreement lifts the cumulative precision back over the bar +/// re-enables it on its own. +/// 2. **Vouched to ship** (§8.7.6). The language is on [`LIVE_LANE_SHIPPED`], or +/// force-enabled for measurement ([`live_lane_measure_forced`]). An +/// unmeasured language is disabled until it earns a reading and is opted in, +/// which makes "ships enabled only at measured precision ≥ 0.99" literally +/// true rather than aspirational. fn live_lane_enabled_for(store: &SqliteStore, language: &str) -> bool { let (agree, disagree, unverifiable) = read_precision_totals_for(store, Some(language)); let sample = travsr_store::LivePrecision { @@ -4179,10 +4222,12 @@ fn live_lane_enabled_for(store: &SqliteStore, language: &str) -> bool { unverifiable, }; let verified = agree + disagree; - match sample.precision() { - Some(p) if verified >= LIVE_PRECISION_MIN_SAMPLE && p < LIVE_PRECISION_GATE => false, - _ => true, + if let Some(p) = sample.precision() { + if verified >= LIVE_PRECISION_MIN_SAMPLE && p < LIVE_PRECISION_GATE { + return false; + } } + LIVE_LANE_SHIPPED.contains(&language) || live_lane_measure_forced(language) } /// RFC-027 section 8.3: retire the live overlay and clear resolved pendings. @@ -8319,10 +8364,11 @@ mod tests { fn the_precision_gate_disables_a_language_only_on_a_meaningful_adverse_sample() { let mut store = travsr_store::SqliteStore::open_in_memory().unwrap(); - // No reading at all: enabled, so a new language can earn one. + // No reading at all, but Rust is a shipped/vouched language, so enabled. assert!(live_lane_enabled_for(&store, "rust")); - // Below the bar but too few verified claims to act on: still enabled. + // Below the bar but too few verified claims to act on: still enabled + // (Rust is shipped, and the sample is not a meaningful adverse reading). store.set_meta("live_precision.rust", "4,1,0").unwrap(); assert!( live_lane_enabled_for(&store, "rust"), @@ -8354,6 +8400,49 @@ mod tests { ); } + /// RFC-027 section 8.7.6: an unmeasured, un-vouched language ships DISABLED. + /// + /// This is the strict-gate decision. The earlier policy left an unmeasured + /// language enabled to earn a reading; when eleven non-native languages + /// joined at once, that meant nine shipped enabled with no reading. Now a + /// language is disabled until it is on `LIVE_LANE_SHIPPED`, and the + /// measurement harness lifts the gate for exactly the language it is + /// measuring via `TRAVSR_LIVE_LANE_MEASURE`. + #[test] + fn an_unmeasured_language_ships_disabled_until_opted_in() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let store = travsr_store::SqliteStore::open_in_memory().unwrap(); + + // A vouched language with no reading is enabled; an un-vouched one is not. + assert!(live_lane_enabled_for(&store, "go"), "go is shipped"); + assert!( + !live_lane_enabled_for(&store, "java"), + "an unmeasured non-shipped language must be disabled by the strict gate" + ); + assert!(!live_lane_enabled_for(&store, "kotlin")); + + // Force-enabling for measurement lifts the gate for exactly that language. + std::env::set_var("TRAVSR_LIVE_LANE_MEASURE", "java,kotlin"); + assert!(live_lane_enabled_for(&store, "java")); + assert!(live_lane_enabled_for(&store, "kotlin")); + assert!( + !live_lane_enabled_for(&store, "scala"), + "the force list is per-language, not a blanket override" + ); + std::env::remove_var("TRAVSR_LIVE_LANE_MEASURE"); + + // The adverse-meter safety still wins over a force-enable: a measured + // language below the bar stays disabled even while being measured. + let mut store = store; + store.set_meta("live_precision.java", "18,5,0").unwrap(); + std::env::set_var("TRAVSR_LIVE_LANE_MEASURE", "java"); + assert!( + !live_lane_enabled_for(&store, "java"), + "a meaningful adverse reading disables a language even under force" + ); + std::env::remove_var("TRAVSR_LIVE_LANE_MEASURE"); + } + /// The gate must not be clearable by an empty sample. /// /// A lane that resolved nothing verifiable has not earned a perfect score, @@ -8974,7 +9063,12 @@ mod tests { let db_path = tmp.path().join(".travsr/graph.db"); let store = travsr_store::SqliteStore::open(&db_path).unwrap(); let corpus = store.get_meta("corpus").unwrap().unwrap_or_default(); + // Java is not on LIVE_LANE_SHIPPED (§8.7.6 strict gate): this test is + // about the generic detector's output, not the shipping decision, so it + // force-enables Java the way the §11.3 measurement harness does. + std::env::set_var("TRAVSR_LIVE_LANE_MEASURE", "java"); let targets = live_resolution_targets(&store, &corpus, tmp.path(), &caller); + std::env::remove_var("TRAVSR_LIVE_LANE_MEASURE"); let by_name = |name: &str| { targets diff --git a/docs/rfcs/RFC-027-live-semantic-resolution.md b/docs/rfcs/RFC-027-live-semantic-resolution.md index 3e23146f..e704c8be 100644 --- a/docs/rfcs/RFC-027-live-semantic-resolution.md +++ b/docs/rfcs/RFC-027-live-semantic-resolution.md @@ -350,7 +350,7 @@ The "is the live lane worse than nothing?" question is answered empirically, not **The meter must run at ratification**, after the Phase B writes and before the sweep. Not a stylistic choice: `reindex_replace` deletes the edited file's `edge_sites` on save, so between the save and the next Phase B run there is no call-site evidence at all, and measuring earlier yields no number rather than a pessimistic one. The reading is cumulative and surfaced by `travsr status`, since a gate needs a value a human can read rather than a log line that scrolled away. -- **Gate:** the live lane ships enabled only if measured precision ≥ 0.99 on the fixture corpus (target: zero false positives). If it cannot hold that bar for a language, the lane is disabled for that language — a measured decision, not a guess. **(Phase 4: implemented per-language.** The meter splits by the source node's language — `live_precision_sample_by_language`, cumulative under `meta` key `live_precision.` — and the save path consults `live_lane_enabled_for(language)`, which disables a language only on a meaningful adverse sample (≥ 20 verified claims *and* precision < 0.99) and re-enables it on its own when the cumulative reading recovers. A language with too little evidence stays enabled so it can earn a reading.) +- **Gate:** the live lane ships enabled only if measured precision ≥ 0.99 on the fixture corpus (target: zero false positives). If it cannot hold that bar for a language, the lane is disabled for that language — a measured decision, not a guess. **(Phase 4: implemented per-language.** The meter splits by the source node's language — `live_precision_sample_by_language`, cumulative under `meta` key `live_precision.` — and the save path consults `live_lane_enabled_for(language)`.) **Strict opt-in gate (§8.7.6 decision):** two independent conditions must both hold for a language to run. (1) No *adverse* reading: a cumulative sample of ≥ 20 verified claims at precision < 0.99 disables even a shipped language, and it re-enables itself when the reading recovers. (2) *Vouched to ship*: the language is on `LIVE_LANE_SHIPPED`, the code-level opt-in list, each entry earning its place at a measured ≥ 0.99 (the §11.3 procedure). The earlier policy left an *unmeasured* language enabled so it could earn a reading, which — when eleven non-native languages joined at once — shipped nine enabled with no reading, contradicting "ships enabled only at measured precision ≥ 0.99." Strict gating makes that literal: an unmeasured, un-vouched language is disabled until opted in. The deadlock this would create (a disabled language can never run to be measured) is broken by `TRAVSR_LIVE_LANE_MEASURE`, an env override that lifts the gate for exactly the language a measurement fixture is driving, never touching the adverse-meter safety. - **Recall telemetry:** the same diff reports the recall the live lane buys over the pure fail-closed lexical lane, quantifying whether the LSP dependency earns its operational cost. - Fixture corpus reuses the RFC-003 §6 fixtures plus the existing LSIF path as a differential oracle (available for TS/Rust/Python). From 6439e1097b863faf1424c4b80a0e65e0cf85ad33 Mon Sep 17 00:00:00 2001 From: Abhishek Date: Sat, 29 Aug 2026 16:12:42 +0530 Subject: [PATCH 17/23] [travsr-daemon] RFC-027 section 11.3: opt Swift and C++ into the live lane Both measured end to end at precision 1.0000 against a real server plus its ratification oracle: Swift via sourcekit-lsp + travsr-swift-index-emitter (3,0,1, the is-implementation claim unverifiable by construction), C++ via clangd + scip-clang (1,0,0, recall capped by the section 8.7.3 out-of-line definition gap, so the field read is what it emits and that is correct). C and Objective-C stay disabled: their edges ratify live->scip correctly but scip-clang writes no matching edge_sites, so the meter reads them unverifiable (0,0,2) and cannot certify the bar. That is section 10 item 4 territory, not a live-lane fault. daemon tests green. --- crates/travsr-daemon/src/lib.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/travsr-daemon/src/lib.rs b/crates/travsr-daemon/src/lib.rs index e8e47bdb..59197a57 100644 --- a/crates/travsr-daemon/src/lib.rs +++ b/crates/travsr-daemon/src/lib.rs @@ -4177,10 +4177,22 @@ const LIVE_PRECISION_MIN_SAMPLE: u64 = 20; /// `nodes.language` values (what the meter keys on). JavaScript has no value of /// its own — `.js`/`.jsx` map to `typescript` — so one entry covers both. /// -/// Starting set: the three native languages (gated at ship since RFC §14 Phase -/// 0–4) plus Go and Dart, the two measured end to end at 1.0000 (§8.7.2). The -/// remaining non-native languages join as §11.3 fills in. -const LIVE_LANE_SHIPPED: &[&str] = &["typescript", "rust", "python", "go", "dart"]; +/// The list grows as §11.3 fills in. Each entry's earning reading: +/// - `typescript`, `python` — native, gated at ship since RFC §14 Phase 0–4. +/// - `rust` — native; re-measured end to end at 1.0000 (`4,0,0`, §11.3). +/// - `go` — gopls, 1.0000 (`4,0,0`). +/// - `dart` — Dart analysis server + travsr-lang-dart, 1.0000 (`3,0,1`). +/// - `swift` — sourcekit-lsp + travsr-swift-index-emitter, 1.0000 (`3,0,1`). +/// - `cpp` — clangd + scip-clang, 1.0000 (`1,0,0`); recall capped by §8.7.3 +/// (out-of-line method defs abstain), so the field read is what it emits and +/// that is correct. +/// +/// Deliberately absent: `c`, `objectivec` — their edges ratify correctly but +/// scip-clang writes no matching `edge_sites`, so the meter reads them +/// `unverifiable` (`0,0,2`) and cannot certify ≥ 0.99; they stay disabled until +/// the oracle carries call-site evidence (§10 item 4). `java`, `csharp`, +/// `kotlin`, `php`, `scala`, `ruby` — not yet measured against a real server. +const LIVE_LANE_SHIPPED: &[&str] = &["typescript", "rust", "python", "go", "dart", "swift", "cpp"]; /// Force-enable a language for measurement, bypassing the strict opt-in gate /// (but never the adverse-meter safety below). From 4a94fb703a52e62f5d5029277ff23a68a7ed3aca Mon Sep 17 00:00:00 2001 From: Abhishek Date: Sat, 29 Aug 2026 16:29:53 +0530 Subject: [PATCH 18/23] [travsr-daemon] RFC-027 section 11.3: opt Java into the live lane Measured end to end at precision 1.0000 (3,0,0: call, field, and the is-implementation clause) via jdtls plus scip-java. scip-java needs a Maven or Gradle build to emit reference edges: a build-less fixture had its correct live edges swept at ratification and the meter read 0,0,4 unverifiable, while a Maven fixture let scip-java resolve references and the reading came out clean. Recorded alongside the still-disabled languages and why each is blocked by its ratification oracle rather than the live lane: C and Objective-C (scip-clang writes no matching edge_sites), C# and Scala (travsr-lang-csharp and travsr-lang-scala emit definitions but no references in a minimal project), and Kotlin (kotlin-language-server needs a Gradle project model and long warmup). The gate test now uses csharp as its unshipped example since java graduated. daemon 205 green. --- crates/travsr-daemon/src/lib.rs | 48 +++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/crates/travsr-daemon/src/lib.rs b/crates/travsr-daemon/src/lib.rs index 59197a57..862adca6 100644 --- a/crates/travsr-daemon/src/lib.rs +++ b/crates/travsr-daemon/src/lib.rs @@ -4186,13 +4186,30 @@ const LIVE_PRECISION_MIN_SAMPLE: u64 = 20; /// - `cpp` — clangd + scip-clang, 1.0000 (`1,0,0`); recall capped by §8.7.3 /// (out-of-line method defs abstain), so the field read is what it emits and /// that is correct. +/// - `java` — jdtls + scip-java, 1.0000 (`3,0,0`, call/field/is-impl); scip-java +/// needs a Maven/Gradle build to emit references (a build-less fixture swept +/// and read `0,0,4`). /// -/// Deliberately absent: `c`, `objectivec` — their edges ratify correctly but -/// scip-clang writes no matching `edge_sites`, so the meter reads them -/// `unverifiable` (`0,0,2`) and cannot certify ≥ 0.99; they stay disabled until -/// the oracle carries call-site evidence (§10 item 4). `java`, `csharp`, -/// `kotlin`, `php`, `scala`, `ruby` — not yet measured against a real server. -const LIVE_LANE_SHIPPED: &[&str] = &["typescript", "rust", "python", "go", "dart", "swift", "cpp"]; +/// Deliberately absent, each blocked by its **ratification oracle**, not the +/// live lane (whose edges are correct wherever a server resolves them): +/// - `c`, `objectivec` — edges ratify correctly but scip-clang writes no +/// matching `edge_sites`, so the meter reads `0,0,2` unverifiable (§10 item 4). +/// - `csharp`, `scala` — travsr-lang-csharp / travsr-lang-scala emit definitions +/// but no reference edges in a minimal project, so there is nothing to ratify +/// or measure against; the live edges resolve via the server but get swept. +/// - `kotlin` — detection works, but kotlin-language-server needs a Gradle +/// project model and long JVM warmup to resolve; not yet driven end to end. +/// - `php`, `ruby` — server not installed on the measurement machine. +const LIVE_LANE_SHIPPED: &[&str] = &[ + "typescript", + "rust", + "python", + "go", + "dart", + "swift", + "cpp", + "java", +]; /// Force-enable a language for measurement, bypassing the strict opt-in gate /// (but never the adverse-meter safety below). @@ -8428,14 +8445,14 @@ mod tests { // A vouched language with no reading is enabled; an un-vouched one is not. assert!(live_lane_enabled_for(&store, "go"), "go is shipped"); assert!( - !live_lane_enabled_for(&store, "java"), + !live_lane_enabled_for(&store, "csharp"), "an unmeasured non-shipped language must be disabled by the strict gate" ); assert!(!live_lane_enabled_for(&store, "kotlin")); // Force-enabling for measurement lifts the gate for exactly that language. - std::env::set_var("TRAVSR_LIVE_LANE_MEASURE", "java,kotlin"); - assert!(live_lane_enabled_for(&store, "java")); + std::env::set_var("TRAVSR_LIVE_LANE_MEASURE", "csharp,kotlin"); + assert!(live_lane_enabled_for(&store, "csharp")); assert!(live_lane_enabled_for(&store, "kotlin")); assert!( !live_lane_enabled_for(&store, "scala"), @@ -8446,10 +8463,10 @@ mod tests { // The adverse-meter safety still wins over a force-enable: a measured // language below the bar stays disabled even while being measured. let mut store = store; - store.set_meta("live_precision.java", "18,5,0").unwrap(); - std::env::set_var("TRAVSR_LIVE_LANE_MEASURE", "java"); + store.set_meta("live_precision.csharp", "18,5,0").unwrap(); + std::env::set_var("TRAVSR_LIVE_LANE_MEASURE", "csharp"); assert!( - !live_lane_enabled_for(&store, "java"), + !live_lane_enabled_for(&store, "csharp"), "a meaningful adverse reading disables a language even under force" ); std::env::remove_var("TRAVSR_LIVE_LANE_MEASURE"); @@ -9075,12 +9092,9 @@ mod tests { let db_path = tmp.path().join(".travsr/graph.db"); let store = travsr_store::SqliteStore::open(&db_path).unwrap(); let corpus = store.get_meta("corpus").unwrap().unwrap_or_default(); - // Java is not on LIVE_LANE_SHIPPED (§8.7.6 strict gate): this test is - // about the generic detector's output, not the shipping decision, so it - // force-enables Java the way the §11.3 measurement harness does. - std::env::set_var("TRAVSR_LIVE_LANE_MEASURE", "java"); + // Java is on LIVE_LANE_SHIPPED (measured 3,0,0 → 1.0000, §11.3), so the + // gate admits it with no reading and no force flag needed. let targets = live_resolution_targets(&store, &corpus, tmp.path(), &caller); - std::env::remove_var("TRAVSR_LIVE_LANE_MEASURE"); let by_name = |name: &str| { targets From efa64f3eee551371a1897572edc1517c56855660 Mon Sep 17 00:00:00 2001 From: Abhishek Date: Sat, 29 Aug 2026 17:16:25 +0530 Subject: [PATCH 19/23] [travsr-daemon] RFC-027 section 11.3: opt C# into the live lane Measured end to end at precision 1.0000 (6,0,0: call, field, is-implementation) via csharp-ls plus scip-dotnet. The earlier "oracle emits no references" was not a code fault in the emitter: scip-dotnet needs DOTNET_ROOT to find a Homebrew .NET runtime, and the travsr-lang csharp emitter's dotnet_root() does not resolve it inside the daemon sandbox, so scip-dotnet failed to launch and produced nothing. With DOTNET_ROOT in the daemon env it indexes normally and the reading comes out clean. Live edges resolve via csharp-ls with no extra setup; only the oracle needs DOTNET_ROOT, and without it the edges persist as live rather than ratifying (honest, not wrong). Filed as a travsr-lang gap. Recorded the other languages' real blockers: ruby (dynamic-language LSP lane gives ruby-lsp nothing to resolve on an untyped receiver, the section 8.4 limit); php (LSP lane works with a typed receiver via Intelephense, oracle scip-php needs Composer); scala/kotlin (JVM build-toolchain setup, not the live lane). Gate test uses scala as its unshipped example since csharp graduated. daemon tests green. --- crates/travsr-daemon/src/lib.rs | 40 ++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/crates/travsr-daemon/src/lib.rs b/crates/travsr-daemon/src/lib.rs index 862adca6..2e3bb6a1 100644 --- a/crates/travsr-daemon/src/lib.rs +++ b/crates/travsr-daemon/src/lib.rs @@ -4189,17 +4189,26 @@ const LIVE_PRECISION_MIN_SAMPLE: u64 = 20; /// - `java` — jdtls + scip-java, 1.0000 (`3,0,0`, call/field/is-impl); scip-java /// needs a Maven/Gradle build to emit references (a build-less fixture swept /// and read `0,0,4`). +/// - `csharp` — csharp-ls + scip-dotnet, 1.0000 (`6,0,0`, call/field/is-impl). +/// Live edges resolve via csharp-ls with no extra setup; the oracle needs +/// `DOTNET_ROOT` in the daemon env (the travsr-lang csharp emitter's +/// `dotnet_root()` does not resolve a Homebrew install inside the sandbox — a +/// fixable gap there). Without a working oracle the edges persist as `live` +/// rather than ratifying, which is honest, not wrong. /// -/// Deliberately absent, each blocked by its **ratification oracle**, not the -/// live lane (whose edges are correct wherever a server resolves them): +/// Deliberately absent: /// - `c`, `objectivec` — edges ratify correctly but scip-clang writes no /// matching `edge_sites`, so the meter reads `0,0,2` unverifiable (§10 item 4). -/// - `csharp`, `scala` — travsr-lang-csharp / travsr-lang-scala emit definitions -/// but no reference edges in a minimal project, so there is nothing to ratify -/// or measure against; the live edges resolve via the server but get swept. -/// - `kotlin` — detection works, but kotlin-language-server needs a Gradle -/// project model and long JVM warmup to resolve; not yet driven end to end. -/// - `php`, `ruby` — server not installed on the measurement machine. +/// - `ruby` — the LSP lane is fundamentally weak: an untyped receiver +/// (`def run(s); s.start; end`) gives ruby-lsp nothing to resolve, so it +/// abstains. This is the §8.4 dynamic-language limit, not an install gap. +/// - `php` — the LSP lane *works* with a typed receiver (`run(Session $s)`) via +/// Intelephense, but the oracle scip-php needs Composer (absent here), so no +/// reading yet. Opt in once measured. +/// - `scala`, `kotlin` — blocked by JVM build-toolchain setup, not the live +/// lane: Scala's SemanticDB oracle needs `sbt compile`; Kotlin's KLS (its +/// oracle *and* its live server) needs a working Gradle build, which the +/// Gradle/Kotlin/JDK version matrix on this machine would not produce. const LIVE_LANE_SHIPPED: &[&str] = &[ "typescript", "rust", @@ -4209,6 +4218,7 @@ const LIVE_LANE_SHIPPED: &[&str] = &[ "swift", "cpp", "java", + "csharp", ]; /// Force-enable a language for measurement, bypassing the strict opt-in gate @@ -8445,17 +8455,17 @@ mod tests { // A vouched language with no reading is enabled; an un-vouched one is not. assert!(live_lane_enabled_for(&store, "go"), "go is shipped"); assert!( - !live_lane_enabled_for(&store, "csharp"), + !live_lane_enabled_for(&store, "scala"), "an unmeasured non-shipped language must be disabled by the strict gate" ); assert!(!live_lane_enabled_for(&store, "kotlin")); // Force-enabling for measurement lifts the gate for exactly that language. - std::env::set_var("TRAVSR_LIVE_LANE_MEASURE", "csharp,kotlin"); - assert!(live_lane_enabled_for(&store, "csharp")); + std::env::set_var("TRAVSR_LIVE_LANE_MEASURE", "scala,kotlin"); + assert!(live_lane_enabled_for(&store, "scala")); assert!(live_lane_enabled_for(&store, "kotlin")); assert!( - !live_lane_enabled_for(&store, "scala"), + !live_lane_enabled_for(&store, "ruby"), "the force list is per-language, not a blanket override" ); std::env::remove_var("TRAVSR_LIVE_LANE_MEASURE"); @@ -8463,10 +8473,10 @@ mod tests { // The adverse-meter safety still wins over a force-enable: a measured // language below the bar stays disabled even while being measured. let mut store = store; - store.set_meta("live_precision.csharp", "18,5,0").unwrap(); - std::env::set_var("TRAVSR_LIVE_LANE_MEASURE", "csharp"); + store.set_meta("live_precision.scala", "18,5,0").unwrap(); + std::env::set_var("TRAVSR_LIVE_LANE_MEASURE", "scala"); assert!( - !live_lane_enabled_for(&store, "csharp"), + !live_lane_enabled_for(&store, "scala"), "a meaningful adverse reading disables a language even under force" ); std::env::remove_var("TRAVSR_LIVE_LANE_MEASURE"); From f787175cbc88d65bd4a70eeac00f6dc3b216d873 Mon Sep 17 00:00:00 2001 From: Abhishek Date: Sat, 29 Aug 2026 17:54:16 +0530 Subject: [PATCH 20/23] [travsr-daemon] RFC-027: correct the C# oracle note on DOTNET_ROOT The earlier note said the emitter's dotnet_root() "does not resolve a Homebrew install inside the sandbox," implying that was the standing blocker. In fact the plugin host resolves and injects DOTNET_ROOT whenever dotnet is on the daemon's PATH, and the initial "no reference edges" reading was a timing artifact (querying before scip-dotnet finished), not a resolution failure. The real gap is narrow: a daemon launched with a minimal PATH leaves the host unable to find dotnet. The travsr-lang csharp emitter now self-resolves DOTNET_ROOT from well-known installs for that case. Comment corrected to say so. --- crates/travsr-daemon/src/lib.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/travsr-daemon/src/lib.rs b/crates/travsr-daemon/src/lib.rs index 2e3bb6a1..609d47a8 100644 --- a/crates/travsr-daemon/src/lib.rs +++ b/crates/travsr-daemon/src/lib.rs @@ -4190,11 +4190,13 @@ const LIVE_PRECISION_MIN_SAMPLE: u64 = 20; /// needs a Maven/Gradle build to emit references (a build-less fixture swept /// and read `0,0,4`). /// - `csharp` — csharp-ls + scip-dotnet, 1.0000 (`6,0,0`, call/field/is-impl). -/// Live edges resolve via csharp-ls with no extra setup; the oracle needs -/// `DOTNET_ROOT` in the daemon env (the travsr-lang csharp emitter's -/// `dotnet_root()` does not resolve a Homebrew install inside the sandbox — a -/// fixable gap there). Without a working oracle the edges persist as `live` -/// rather than ratifying, which is honest, not wrong. +/// Live edges resolve via csharp-ls with no extra setup. The oracle needs +/// `DOTNET_ROOT`; the plugin host resolves and injects it when `dotnet` is on +/// the daemon's PATH, and the travsr-lang csharp emitter now self-resolves it +/// from well-known installs when a minimal-PATH daemon leaves the host unable +/// to (travsr-lang `fix/csharp-dotnet-root-sandbox-path`). If the oracle still +/// cannot run, the edges persist as `live` rather than ratifying — honest, +/// not wrong. /// /// Deliberately absent: /// - `c`, `objectivec` — edges ratify correctly but scip-clang writes no From 3fb722033f0616b976455a5090a783301940d20d Mon Sep 17 00:00:00 2001 From: Abhishek Date: Sat, 29 Aug 2026 19:21:05 +0530 Subject: [PATCH 21/23] [travsr-plugin-host] resolve DOTNET_ROOT from well-known installs on a minimal PATH dotnet_sdk_root resolved dotnet only through tool_path (PATH plus the toolchain-managed dirs), so a daemon launched from a GUI or a login shell without /opt/homebrew/bin on PATH found no dotnet, injected no DOTNET_ROOT, and issued no exec grant. scip-dotnet then fell back to its hardcoded /usr/local/share/dotnet, failed to launch, and the C# lane produced no reference edges. (This is the root cause behind the travsr-lang csharp sidecar's own fallback; fixing it here means the host injects the right DOTNET_ROOT and grants the matching sandbox read path, which the sidecar fallback alone cannot do for a sandboxed analyzer.) dotnet_sdk_root now tries a dotnet launcher PATH-first (via tool_path, still PATHEXT-aware for Windows) then the well-known installs a minimal PATH omits (Homebrew's version-independent opt symlink on Apple silicon and Intel, the official-installer dir, ~/.dotnet), maps the first one carrying a real sdk/ to its root across the standard and Homebrew-libexec layouts, and finally probes the well-known sdk roots directly. The sdk/ requirement is preserved so a runtime-only host is never chosen. Four unit tests cover both layouts, the runtime-only rejection, and that the sandbox-omitted Homebrew paths are searched. plugin-hashes.lock regenerated for the travsr-plugin-host src change (ADR-017 Rule 5). plugin-host 46 tests green. --- .../src/sandbox/toolchain.rs | 164 +++++++++++++++--- plugin-hashes.lock | 2 +- 2 files changed, 143 insertions(+), 23 deletions(-) diff --git a/crates/travsr-plugin-host/src/sandbox/toolchain.rs b/crates/travsr-plugin-host/src/sandbox/toolchain.rs index 117ca44f..bc60088f 100644 --- a/crates/travsr-plugin-host/src/sandbox/toolchain.rs +++ b/crates/travsr-plugin-host/src/sandbox/toolchain.rs @@ -551,43 +551,103 @@ fn php_access() -> ToolchainAccess { /// Resolve the dotnet install root that holds `host/`, `sdk/`, `shared/` — the /// value `DOTNET_ROOT` must point at, and the dir the sandbox must grant read + /// execute so scip-dotnet's apphost can load the runtime and shell out to -/// `dotnet`. Uses `tool_path` (PATHEXT-aware, so `dotnet.exe` resolves on -/// Windows — the old `dir.join("dotnet")` PATH scan never matched there, leaving -/// Windows with no DOTNET_ROOT and no exec grant). Only accepts a root that -/// actually carries an SDK/host, and falls back to the per-user -/// `~/.dotnet` when `dotnet` on PATH is a runtime-only host. +/// `dotnet`. +/// +/// Tries a `dotnet` launcher — `tool_path` first (PATHEXT-aware, so `dotnet.exe` +/// resolves on Windows), then the well-known installs a minimal-PATH daemon +/// omits — and maps the first one carrying a real SDK to its root. **The +/// non-PATH launchers are the load-bearing part**: a daemon launched from a GUI +/// or a login shell without `/opt/homebrew/bin` on PATH left `tool_path` finding +/// nothing, so no `DOTNET_ROOT` was injected and no exec grant issued, and +/// scip-dotnet failed to launch — the C# lane then produced no reference edges. +/// When no launcher is reachable at all, the well-known SDK roots are probed +/// directly (this also covers the per-user `~/.dotnet` when `dotnet` on PATH is a +/// runtime-only host). fn dotnet_sdk_root() -> Option { - let exe = travsr_core::exec::tool_path("dotnet")?; + if let Some(root) = dotnet_launcher_candidates() + .into_iter() + .filter(|p| p.is_file()) + .find_map(|exe| dotnet_sdk_root_from_binary(&exe)) + { + return Some(root); + } + well_known_dotnet_roots() + .into_iter() + .find(|d| d.join("sdk").is_dir()) +} + +/// `dotnet` launcher locations to try, PATH-resolved first, then the well-known +/// installs a sandboxed or GUI-launched daemon's PATH omits: Homebrew's +/// version-independent `opt/` symlink (Apple silicon and Intel), the official +/// installer directory (where `dotnet` sits directly in the SDK root), and a +/// user-local `~/.dotnet`. +fn dotnet_launcher_candidates() -> Vec { + let mut out: Vec = Vec::new(); + if let Some(p) = travsr_core::exec::tool_path("dotnet") { + out.push(p); + } + out.extend( + [ + "/opt/homebrew/opt/dotnet/bin/dotnet", + "/usr/local/opt/dotnet/bin/dotnet", + "/usr/local/share/dotnet/dotnet", + "/usr/share/dotnet/dotnet", + ] + .into_iter() + .map(PathBuf::from), + ); + if let Some(h) = home() { + out.push(h.join(".dotnet").join("dotnet")); + } + out +} + +/// The SDK-bearing root a `dotnet` launcher belongs to, or `None` if it carries +/// no `sdk/`. +/// +/// Requires an actual `sdk/` (not just `host/`): scip-dotnet runs `dotnet +/// restore`/build, so a runtime-only host root is useless (`C:\Program +/// Files\dotnet` carries `host/` even when SDK-less, so a `host/` check would +/// wrongly pick it over a real SDK elsewhere). Two layouts: +/// - **Standard** (Windows / dotnet-install / Linux tarball / Program Files): +/// `dotnet(.exe)` sits directly in the root holding `host/ sdk/ shared/`. +/// - **Homebrew macOS:** `…/Cellar/dotnet//bin/dotnet` → `…/libexec`. +fn dotnet_sdk_root_from_binary(exe: &std::path::Path) -> Option { // canonicalize resolves symlinks (Homebrew's `bin/dotnet` shim) but adds the // `\\?\` verbatim prefix on Windows — strip it, or dotnet chokes on a // `\\?\`-prefixed DOTNET_ROOT / exec-grant path. - let real = strip_windows_verbatim(std::fs::canonicalize(&exe).unwrap_or(exe)); + let real = + strip_windows_verbatim(std::fs::canonicalize(exe).unwrap_or_else(|_| exe.to_path_buf())); let dir = real.parent()?; - // Require an actual `sdk/` (not just `host/`): scip-dotnet runs `dotnet - // restore`/build, so a runtime-only host root is useless. `C:\Program - // Files\dotnet` carries `host/` even when SDK-less, so a `host/` check would - // wrongly pick it over a real SDK elsewhere. - // Standard layout (Windows / dotnet-install / Linux tarball / Program Files): - // `dotnet(.exe)` sits directly in the root holding host/ sdk/ shared/. if dir.join("sdk").is_dir() { return Some(dir.to_path_buf()); } - // Homebrew macOS: …/Cellar/dotnet//bin/dotnet → …/libexec holds the SDK. if let Some(libexec) = dir.parent().map(|p| p.join("libexec")) { if libexec.join("sdk").is_dir() { return Some(libexec); } } - // `dotnet` on PATH is a runtime-only host (no SDK): fall back to the per-user - // dotnet-install default that actually carries an SDK. - if let Some(d) = home().map(|h| h.join(".dotnet")) { - if d.join("sdk").is_dir() { - return Some(d); - } - } None } +/// SDK roots to probe directly when no `dotnet` launcher is reachable, plus the +/// per-user dotnet-install default. Filtered by an actual `sdk/` at the call site. +fn well_known_dotnet_roots() -> Vec { + let mut out: Vec = [ + "/opt/homebrew/opt/dotnet/libexec", + "/usr/local/opt/dotnet/libexec", + "/usr/local/share/dotnet", + "/usr/share/dotnet", + ] + .into_iter() + .map(PathBuf::from) + .collect(); + if let Some(h) = home() { + out.push(h.join(".dotnet")); + } + out +} + /// `scip-dotnet` resolves NuGet packages. Needs: /// - NuGet global-packages dir (read+write) — `dotnet restore` downloads new packages here /// - `~/.dotnet` (read) — dotnet global tools dir; scip-dotnet binary lives here @@ -1017,9 +1077,69 @@ fn go_access() -> ToolchainAccess { #[cfg(test)] mod tests { - use super::strip_windows_verbatim; + use super::{ + dotnet_launcher_candidates, dotnet_sdk_root_from_binary, strip_windows_verbatim, + well_known_dotnet_roots, + }; use std::path::PathBuf; + fn scratch(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "travsr-toolchain-{tag}-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create scratch dir"); + dir + } + + /// Homebrew layout: `/bin/dotnet` with the SDK under `/libexec`. + /// This is the install a minimal-PATH daemon could not resolve before the fix. + #[test] + fn dotnet_root_resolves_homebrew_layout() { + let root = scratch("dn-brew"); + std::fs::create_dir_all(root.join("bin")).unwrap(); + std::fs::create_dir_all(root.join("libexec/sdk")).unwrap(); + std::fs::write(root.join("bin/dotnet"), b"#!/bin/sh\n").unwrap(); + + let got = dotnet_sdk_root_from_binary(&root.join("bin/dotnet")).unwrap(); + assert_eq!(got, std::fs::canonicalize(root.join("libexec")).unwrap()); + } + + /// Standard layout: `dotnet` sits directly in the SDK root beside `sdk/`. + #[test] + fn dotnet_root_resolves_standard_layout() { + let root = scratch("dn-std"); + std::fs::create_dir_all(root.join("sdk")).unwrap(); + std::fs::write(root.join("dotnet"), b"#!/bin/sh\n").unwrap(); + + let got = dotnet_sdk_root_from_binary(&root.join("dotnet")).unwrap(); + assert_eq!(got, std::fs::canonicalize(&root).unwrap()); + } + + /// A runtime-only host (no `sdk/`) is rejected: scip-dotnet needs the SDK to + /// restore/build, so a runtime root is worse than falling through. + #[test] + fn dotnet_root_rejects_runtime_only_host() { + let root = scratch("dn-runtime"); + std::fs::create_dir_all(root.join("host")).unwrap(); + std::fs::write(root.join("dotnet"), b"#!/bin/sh\n").unwrap(); + assert!(dotnet_sdk_root_from_binary(&root.join("dotnet")).is_none()); + } + + /// The Homebrew fallbacks a sandboxed / GUI-launched PATH omits are in the + /// search, both as launcher candidates and as direct-root candidates. + #[test] + fn dotnet_candidates_cover_the_sandbox_path_gap() { + assert!(dotnet_launcher_candidates() + .iter() + .any(|p| p.ends_with("opt/homebrew/opt/dotnet/bin/dotnet"))); + assert!(well_known_dotnet_roots() + .iter() + .any(|p| p.ends_with("opt/homebrew/opt/dotnet/libexec"))); + } + #[test] fn strips_drive_verbatim_prefix() { assert_eq!( diff --git a/plugin-hashes.lock b/plugin-hashes.lock index 24971455..ba5c659a 100644 --- a/plugin-hashes.lock +++ b/plugin-hashes.lock @@ -1,6 +1,6 @@ # Auto-generated by update-plugin-hashes.sh — do not edit manually # Format: crate_name = sha256_of_src_tree -travsr-plugin-host = 1e19ef90398f1e8f73d0cd3431e0c0fae2b56c5bf6debc5fd22f75ef275b6597 +travsr-plugin-host = 928835831c2dafb915987585cbfe4038e2d1f73fd4211267143810c16fe1d59c travsr-plugin-protocol = d6f8d6949b3c684aa21d4742a8c9ba04ccec793bea9551d8264f4c7be901d427 travsr-plugin-sdk = 6f20b7313d11d8fa0dfafd5203ef5870be443ee30b2141687c4624b7cc5f9a2b From c2b3b217448fc01b631655a3ccc271a2fc947e0e Mon Sep 17 00:00:00 2001 From: Abhishek Date: Sun, 30 Aug 2026 01:07:17 +0530 Subject: [PATCH 22/23] [travsr-mcp] RFC-027: green the CI failures on the live-lane PR Three failures the live-lane branch introduced, all reproduced on macOS, Linux and Windows: - clippy type_complexity: next_edges returned a bare 5-tuple Vec. Name it with a `NextEdge` type alias (query.rs); no behaviour change. - em-dash check: the live-overlay MCP note used U+2014. Reword to a colon; the "live overlay active" substring the tools tests assert is unchanged. - travsr-indexer typescript::link_imports_skips_package_imports asserted 45 resolves-to edges from 15 relative imports. extension.ts gained a 16th relative import (./liveResolution) in this branch, so the count is now 48. Update the assertion and its comment to match. --- crates/travsr-indexer/tests/typescript.rs | 13 +++++++------ crates/travsr-mcp/src/query.rs | 7 ++++++- crates/travsr-mcp/src/tools.rs | 2 +- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/crates/travsr-indexer/tests/typescript.rs b/crates/travsr-indexer/tests/typescript.rs index 377c7179..88495885 100644 --- a/crates/travsr-indexer/tests/typescript.rs +++ b/crates/travsr-indexer/tests/typescript.rs @@ -224,14 +224,15 @@ fn link_imports_skips_package_imports() { "package imports must not produce resolves-to edges" ); - // 15 relative imports (./mcp, ./clientProxy, ./status, ./codelens, ./hover, ./tree, - // ./repoFileTree, ./welcome, ./graph, ./installer, ./telemetry, ./commands, - // ./contextExplorer, ./mcpRegister, ./contextCodeAction) × 3 candidates each - // (#610: .ts + .tsx + .js probe) = 45 edges. + // 16 relative imports (./mcp, ./clientProxy, ./status, ./codelens, ./hover, + // ./liveResolution, ./tree, ./repoFileTree, ./welcome, ./graph, ./installer, + // ./telemetry, ./commands, ./contextExplorer, ./mcpRegister, + // ./contextCodeAction) × 3 candidates each + // (#610: .ts + .tsx + .js probe) = 48 edges. assert_eq!( edges.len(), - 45, - "15 relative imports × 3 extension candidates = 45 resolves-to edges" + 48, + "16 relative imports × 3 extension candidates = 48 resolves-to edges" ); } diff --git a/crates/travsr-mcp/src/query.rs b/crates/travsr-mcp/src/query.rs index f3baeade..27b3d5fb 100644 --- a/crates/travsr-mcp/src/query.rs +++ b/crates/travsr-mcp/src/query.rs @@ -725,13 +725,18 @@ fn prov_of(e: &travsr_core::Edge) -> String { .unwrap_or_else(|| "tree-sitter".to_string()) } +/// One expansion step out of [`next_edges`]: +/// `(edge_kind, next_id, expand, incoming, provenance)`. The 5th element is the +/// edge's true `edges.provenance` (DEBT-75). +pub type NextEdge = (travsr_core::EdgeKind, NodeId, bool, bool, String); + pub fn next_edges( store: &SqliteStore, node_id: NodeId, direction: QueryDirection, edge_mode: QueryEdgeMode, is_seed: bool, -) -> anyhow::Result> { +) -> anyhow::Result> { // DEBT-75: the 5th element is the edge's true `edges.provenance`, carried // through from the store readers so callers no longer have to assume // "tree-sitter". `unwrap_or` only fires on a constructed (never-read) edge, diff --git a/crates/travsr-mcp/src/tools.rs b/crates/travsr-mcp/src/tools.rs index 7eff9a75..1c004984 100644 --- a/crates/travsr-mcp/src/tools.rs +++ b/crates/travsr-mcp/src/tools.rs @@ -282,7 +282,7 @@ fn live_overlay_note(store: &SqliteStore) -> Option { )); } Some(format!( - "[note: live overlay active \u{2014} {}. These resolve deterministically at the next commit; filter to provenance != live for ratified truth only.]", + "[note: live overlay active: {}. These resolve deterministically at the next commit; filter to provenance != live for ratified truth only.]", parts.join("; ") )) } From e3b0b0009f85f81763e04778f912eb03eafba674 Mon Sep 17 00:00:00 2001 From: Abhishek Date: Sun, 30 Aug 2026 01:32:06 +0530 Subject: [PATCH 23/23] [travsr-plugin-host] fix the dotnet-root tests on Windows dotnet_sdk_root_from_binary strips the `\\?\` verbatim prefix that std::fs::canonicalize adds on Windows (scip-dotnet's DOTNET_ROOT cannot parse a verbatim path). The two layout tests compared the stripped result against a raw canonicalize(), which keeps the prefix on Windows, so `got` (stripped) never equalled `want` (verbatim) and both failed on the windows-latest runner while passing on macOS and Linux. Strip `want` with the same strip_windows_verbatim (a no-op off Windows) so the assertion holds on every platform. Test-only change; the resolver itself is unchanged. plugin-hashes.lock regenerated (ADR-017 Rule 5). --- crates/travsr-plugin-host/src/sandbox/toolchain.rs | 13 +++++++++++-- plugin-hashes.lock | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/travsr-plugin-host/src/sandbox/toolchain.rs b/crates/travsr-plugin-host/src/sandbox/toolchain.rs index bc60088f..cbd6bcdf 100644 --- a/crates/travsr-plugin-host/src/sandbox/toolchain.rs +++ b/crates/travsr-plugin-host/src/sandbox/toolchain.rs @@ -1104,7 +1104,12 @@ mod tests { std::fs::write(root.join("bin/dotnet"), b"#!/bin/sh\n").unwrap(); let got = dotnet_sdk_root_from_binary(&root.join("bin/dotnet")).unwrap(); - assert_eq!(got, std::fs::canonicalize(root.join("libexec")).unwrap()); + // `dotnet_sdk_root_from_binary` strips the Windows `\\?\` verbatim prefix + // `canonicalize` adds; strip `want` the same way (a no-op off Windows). + assert_eq!( + got, + strip_windows_verbatim(std::fs::canonicalize(root.join("libexec")).unwrap()) + ); } /// Standard layout: `dotnet` sits directly in the SDK root beside `sdk/`. @@ -1115,7 +1120,11 @@ mod tests { std::fs::write(root.join("dotnet"), b"#!/bin/sh\n").unwrap(); let got = dotnet_sdk_root_from_binary(&root.join("dotnet")).unwrap(); - assert_eq!(got, std::fs::canonicalize(&root).unwrap()); + // Strip the `\\?\` prefix from `want` to match the function (see above). + assert_eq!( + got, + strip_windows_verbatim(std::fs::canonicalize(&root).unwrap()) + ); } /// A runtime-only host (no `sdk/`) is rejected: scip-dotnet needs the SDK to diff --git a/plugin-hashes.lock b/plugin-hashes.lock index ba5c659a..a087294a 100644 --- a/plugin-hashes.lock +++ b/plugin-hashes.lock @@ -1,6 +1,6 @@ # Auto-generated by update-plugin-hashes.sh — do not edit manually # Format: crate_name = sha256_of_src_tree -travsr-plugin-host = 928835831c2dafb915987585cbfe4038e2d1f73fd4211267143810c16fe1d59c +travsr-plugin-host = 4c9edb7ecee336b35ccaaf241aae6b24db968683d64deae2adf1d8a327493569 travsr-plugin-protocol = d6f8d6949b3c684aa21d4742a8c9ba04ccec793bea9551d8264f4c7be901d427 travsr-plugin-sdk = 6f20b7313d11d8fa0dfafd5203ef5870be443ee30b2141687c4624b7cc5f9a2b