1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
//! LSP-resolution helper queries (unresolved edges, marker updates).
//!
//! Part of the [`Database`](super::Database) impl, split out of `lib.rs` for navigability.
use super::*;
impl Database {
// ── LSP Resolution Helpers ──
//
// These helpers (`unresolved_edges`, `find_symbol_at_location`,
// `update_edge_target`, `mark_edge_unresolvable`, `mark_edge_external`,
// `edge_resolution_state`) are called from `cartog-lsp::lsp_resolve_edges`,
// which itself runs inside `index_directory`'s outer indexing transaction
// (see [`Self::begin_indexing_tx`]). They MUST remain transaction-free —
// any future addition of `unchecked_transaction()` here would re-introduce
// the Phase 3 atomicity bug at runtime ("cannot start a transaction
// within a transaction").
/// Return edges still waiting for resolution (`resolution_state = 0`).
///
/// Edges marked `state = 2` (unresolvable), `state = 3` (external), or
/// `state = 4` (heuristic-exhausted) are excluded so a dirty reindex
/// doesn't re-query the language server for edges it already classified.
/// All three are sticky and re-enter the unresolved set only via
/// [`Self::reset_unresolvable_for_names`] when a matching symbol is added,
/// [`Self::reset_all_unresolvable`] on `--force`, or (state 4 only)
/// [`Self::reopen_heuristic_exhausted`] before an LSP-enabled reindex.
/// ([`Self::invalidate_edges_targeting`] only touches state=1 rows because
/// it filters on `target_id IS NOT NULL`, and state {2, 3, 4} rows always
/// have `target_id NULL`.)
///
/// tx-safe: read-only single statement — see note above the section header.
pub fn unresolved_edges(&self) -> Result<Vec<UnresolvedEdge>> {
let mut stmt = self.conn.prepare(
"SELECT e.id, e.target_name, e.file_path, e.line
FROM edges e
WHERE e.resolution_state = 0",
)?;
let rows = stmt.query_map([], |row| {
Ok(UnresolvedEdge {
edge_id: row.get(0)?,
target_name: row.get(1)?,
file_path: row.get(2)?,
line: row.get(3)?,
})
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
/// Find the tightest-enclosing symbol at a given file + line.
///
/// tx-safe: read-only single statement — see the LSP-section header note.
pub fn find_symbol_at_location(&self, file_path: &str, line: u32) -> Result<Option<String>> {
let id: Option<String> = self
.conn
.query_row(
"SELECT id FROM symbols
WHERE file_path = ?1 AND start_line <= ?2 AND end_line >= ?2
ORDER BY (end_line - start_line) ASC
LIMIT 1",
params![file_path, line],
|row| row.get(0),
)
.optional()?;
Ok(id)
}
/// Update a single edge's target_id and flip it to `resolution_state = 1`.
///
/// tx-safe: single statement — see the LSP-section header note. If you
/// ever batch this internally with `unchecked_transaction()`, also update
/// `index_directory` so it does not call `lsp_resolve_edges` inside its
/// outer transaction.
pub fn update_edge_target(&self, edge_id: i64, target_id: &str) -> Result<()> {
// Overwrites any heuristic provenance: an LSP definition is more precise.
self.conn.execute(
"UPDATE edges SET target_id = ?1, resolution_state = 1, resolution_source = ?2
WHERE id = ?3",
params![target_id, EdgeProvenance::Lsp.as_str(), edge_id],
)?;
Ok(())
}
/// Test-only inspector: returns true when the edge is at `resolution_state = 2`.
///
/// Exposed because the column is otherwise crate-private, and downstream
/// integration tests (cartog-indexer) need a read-only way to assert the
/// marker state without snapshotting raw SQL.
pub fn is_edge_unresolvable(&self, edge_id: i64) -> Result<bool> {
Ok(self.edge_resolution_state(edge_id)? == 2)
}
/// Test-only inspector: returns the raw `resolution_state` value for an edge.
///
/// 0=unresolved, 1=resolved, 2=unresolvable, 3=external, 4=heuristic-exhausted.
pub fn edge_resolution_state(&self, edge_id: i64) -> Result<i64> {
let state: i64 = self.conn.query_row(
"SELECT resolution_state FROM edges WHERE id = ?1",
params![edge_id],
|row| row.get(0),
)?;
Ok(state)
}
/// Whether any edge sits at `resolution_state = 4` (heuristic-exhausted).
/// Gates the MCP warm-LSP catch-up on no-op reindexes.
///
/// tx-safe: read-only single statement — see the LSP-section header note.
pub fn has_heuristic_exhausted(&self) -> Result<bool> {
let exists: bool = self.conn.query_row(
"SELECT EXISTS(SELECT 1 FROM edges WHERE resolution_state = 4)",
[],
|row| row.get(0),
)?;
Ok(exists)
}
/// Test-only inspector: count edges at a given raw `resolution_state`
/// (see [`Self::edge_resolution_state`] for the state legend).
///
/// tx-safe: read-only single statement — see the LSP-section header note.
pub fn count_edges_in_state(&self, state: i64) -> Result<u32> {
let n: u32 = self.conn.query_row(
"SELECT COUNT(*) FROM edges WHERE resolution_state = ?1",
params![state],
|row| row.get(0),
)?;
Ok(n)
}
/// Reset every edge at `resolution_state IN (2, 3, 4)` back to `0`. Used by
/// `cartog index --force` to honor the "retry everything" contract:
/// without this, the heuristic + LSP would still skip permanently-marked
/// edges (unresolvable, external, or heuristic-exhausted) even on a forced
/// re-index.
///
/// tx-safe: single statement — see the LSP-section header note.
pub fn reset_all_unresolvable(&self) -> Result<u32> {
let n = self.conn.execute(
"UPDATE edges SET resolution_state = 0, resolution_source = NULL
WHERE resolution_state IN (2, 3, 4)",
[],
)?;
Ok(n as u32)
}
/// Reset `resolution_state` from `4` (heuristic-exhausted) → `0` so a
/// later LSP-enabled reindex retries them.
///
/// State 4 is written by [`Self::mark_heuristic_exhausted_in_tx`] only in
/// LSP-disabled runs (`--no-lsp`, `watch`). When a subsequent `cartog
/// index` does run LSP, those edges have never seen the language server, so
/// the indexer reopens them here before the LSP pass. Distinct from
/// [`Self::reset_all_unresolvable`]: this leaves the genuine LSP verdicts
/// (state {2, 3}) sealed.
///
/// tx-safe: single statement — see the LSP-section header note.
pub fn reopen_heuristic_exhausted(&self) -> Result<u32> {
let n = self.conn.execute(
"UPDATE edges SET resolution_state = 0 WHERE resolution_state = 4",
[],
)?;
Ok(n as u32)
}
/// Mark a single edge as `resolution_state = 2` (LSP definitively gave up).
///
/// Callers MUST only invoke this after a definitive negative answer from
/// the language server. Never call from a transient-error branch (server
/// crash, didOpen failure, half-loaded warmup) — the marker is sticky
/// across runs until [`Self::reset_unresolvable_for_names`] reopens it
/// (on a matching new symbol) or [`Self::reset_all_unresolvable`] runs
/// (`--force`).
///
/// The `WHERE resolution_state = 0` guard preserves the invariant that
/// state {2, 3} rows have `target_id IS NULL` — without it an accidental
/// call on a state=1 (resolved) edge would silently flip the state while
/// keeping the stale target, hiding a corrupted edge from
/// [`Self::unresolved_edges`].
///
/// tx-safe: single statement — see the LSP-section header note.
pub fn mark_edge_unresolvable(&self, edge_id: i64) -> Result<()> {
self.conn.execute(
"UPDATE edges SET resolution_state = 2, resolution_source = ?2
WHERE id = ?1 AND resolution_state = 0",
params![edge_id, EdgeProvenance::LspUnresolvable.as_str()],
)?;
Ok(())
}
/// Mark a single edge as `resolution_state = 3` (LSP located the target
/// outside the indexed root — stdlib, third-party deps, node_modules).
///
/// Same stickiness contract as [`Self::mark_edge_unresolvable`]: only call
/// after a definitive positive answer naming an out-of-root URI;
/// reopened by the same name-keyed and force-reset paths. The
/// `WHERE resolution_state = 0` guard preserves the `target_id IS NULL`
/// invariant for state=3 rows.
///
/// tx-safe: single statement — see the LSP-section header note.
pub fn mark_edge_external(&self, edge_id: i64) -> Result<()> {
self.conn.execute(
"UPDATE edges SET resolution_state = 3, resolution_source = ?2
WHERE id = ?1 AND resolution_state = 0",
params![edge_id, EdgeProvenance::LspExternal.as_str()],
)?;
Ok(())
}
/// Reset `resolution_state` from {2, 3, 4} → 0 for edges whose target_name is in `names`.
///
/// Called from the indexer when new symbols are added: an edge previously
/// "unresolvable" (no symbol with this name), "external" (target outside
/// the index, now vendored in-tree), or "heuristic-exhausted" may now
/// resolve against the freshly-added target. Returns edges reopened; no-op
/// when `names` is empty.
///
/// tx-safe: single statement. Names are batched to honor SQLite's default
/// 999-parameter limit; only rows at state {2, 3, 4} are touched so the
/// write set stays tiny even on a large rename.
pub fn reset_unresolvable_for_names(&self, names: &[String]) -> Result<u32> {
if names.is_empty() {
return Ok(0);
}
const CHUNK: usize = 500;
let mut total: u32 = 0;
for chunk in names.chunks(CHUNK) {
let placeholders = vec!["?"; chunk.len()].join(",");
let sql = format!(
"UPDATE edges
SET resolution_state = 0, resolution_source = NULL
WHERE resolution_state IN (2, 3, 4)
AND target_name IN ({placeholders})"
);
let params: Vec<&dyn rusqlite::ToSql> =
chunk.iter().map(|n| n as &dyn rusqlite::ToSql).collect();
let n = self.conn.execute(&sql, params.as_slice())?;
total += n as u32;
}
Ok(total)
}
}