Skip to main content

agentstatedeveloper_core/
search_fts.rs

1//! FTS5-backed search index for ASD symbols.
2//!
3//! Opens a direct `rusqlite` connection to the same `.asd-state.db` used by
4//! the stategraph backend and maintains a `asd_search_fts` virtual table.
5//!
6//! ## Why a separate connection?
7//! Stategraph abstracts over SQLite and doesn't expose raw SQL. Opening a
8//! second connection to the same WAL-mode database is safe and avoids coupling
9//! the two storage layers.
10//!
11//! ## Rebuild vs. incremental
12//! `rebuild()` is the canonical path: the indexer already holds the complete
13//! "current world" snapshot after `asd index`, so a full replace avoids any
14//! sync bugs with the git blob store (e.g. deleted files leaving stale FTS rows).
15//!
16//! ## Tokenizer choice
17//! `unicode61 remove_diacritics 1` — word-based, unicode-aware. CamelCase and
18//! snake_case identifiers are pre-expanded at insert time so "refreshDriftPlayhead"
19//! becomes "refreshDriftPlayhead refresh drift playhead", making word-level
20//! queries work without prefix tricks.
21//!
22//! ## Column weights for BM25
23//! qname=10, signature=5, doc=5, file=2  (unindexed metadata columns excluded)
24//!
25//! ## Test symbol handling
26//! `is_test` is set at insert time based on file-path heuristics. Tests are
27//! excluded from results by default; pass `FtsFilters { include_tests: true }`
28//! to include them.
29
30use std::collections::HashMap;
31use std::path::Path;
32
33use chrono::{DateTime, Utc};
34use rusqlite::{Connection, params};
35
36use crate::schema::{EffectDecl, FeedbackEntry, FeedbackVerdict, LedgerEntry, Symbol};
37
38// ---------------------------------------------------------------------------
39// Stopwords
40// ---------------------------------------------------------------------------
41
42/// English function words with no discriminative value for code search.
43///
44/// These are prepositions and conjunctions that appear naturally in
45/// natural-language queries ("playhead **over** clips", "scroll **with**
46/// velocity") but never appear as meaningful code identifiers. Filtering them
47/// prevents FTS MATCH from finding false positives like Swift named parameters
48/// (`punchIn(over existingClip:)`) or generic variable names.
49///
50/// Intentionally omits: "do", "in", "go" (language keywords), "no" (boolean
51/// shorthand), "up", "down" (directional — common in audio/UI APIs).
52pub const STOPWORDS: &[&str] = &[
53    "a", "an", "and", "as", "at", "be", "but", "by", "for", "from", "if", "into", "is", "it",
54    "nor", "not", "of", "on", "or", "so", "the", "to", "via", "vs", "yet", "with", "over", "about",
55    "between", "than", "that", "this", "are", "was", "were", "has", "have", "had", "its", "our",
56];
57
58/// Returns true if the token is a stopword.
59#[inline]
60pub fn is_stopword(token: &str) -> bool {
61    STOPWORDS.contains(&token)
62}
63
64// ---------------------------------------------------------------------------
65// Public types
66// ---------------------------------------------------------------------------
67
68/// Symbol tier — controls default inclusion and scoring penalty.
69///
70/// - `0` Production: app/core/library source; ranked highest, included by default.
71/// - `1` Utility: Preview, Sample, Editor extension, Generated, Mock, Stub, Fixture,
72///        Demo — included by default but penalised in hybrid_boost.
73/// - `2` Test: files in test/spec directories or with test naming conventions —
74///        excluded by default; shown with `--include-tests`.
75pub type SymbolTier = u8;
76
77/// A single ranked result from FTS search.
78#[derive(Debug, Clone)]
79pub struct FtsHit {
80    /// BM25 score (negated from SQLite's negative convention; higher = more relevant).
81    pub bm25_score: f64,
82    pub symbol_id: String,
83    /// Original qname (not expanded).
84    pub qname: String,
85    pub kind: String,
86    pub language: String,
87    /// Original file path (not expanded).
88    pub file: String,
89    pub line: u32,
90    /// Original signature (not expanded).
91    pub signature: Option<String>,
92    pub doc: Option<String>,
93    /// Symbol tier: 0=production, 1=utility/preview/sample, 2=test.
94    pub tier: SymbolTier,
95    /// Concatenated ledger entry summaries (all kinds) in lowercase.
96    /// Empty when the symbol has no ledger entries.
97    /// Populated at `asd index` time and stored in the FTS table.
98    pub ledger_text: String,
99    /// Comma-separated ledger kinds present for this symbol, e.g. "ownership,invariant".
100    /// Used for fast has_ownership() / has_invariant() checks without reading git objects.
101    pub ledger_flags: String,
102}
103
104impl FtsHit {
105    /// True if this symbol has at least one Ownership ledger entry.
106    #[inline]
107    pub fn has_ownership(&self) -> bool {
108        self.ledger_flags.contains("ownership")
109    }
110    /// True if this symbol has at least one Invariant ledger entry.
111    #[inline]
112    pub fn has_invariant(&self) -> bool {
113        self.ledger_flags.contains("invariant")
114    }
115    /// True if this symbol has at least one Hazard ledger entry.
116    #[inline]
117    pub fn has_hazard(&self) -> bool {
118        self.ledger_flags.contains("hazard")
119    }
120    /// True if this symbol has any ledger entries.
121    #[inline]
122    pub fn has_ledger(&self) -> bool {
123        !self.ledger_text.is_empty()
124    }
125}
126
127/// Lightweight symbol metadata stored in the `asd_symbols_meta` SQLite table.
128///
129/// Populated at `asd index` time alongside the FTS rebuild. Allows feedback
130/// adjustment functions to resolve qname → (symbol_id, file, kind) via a
131/// simple SQL lookup instead of traversing the git object store.
132#[derive(Debug, Clone)]
133pub struct SymbolMeta {
134    pub symbol_id: String,
135    pub file: String,
136    pub kind: String,
137}
138
139/// Full symbol resolution result from the FTS table.
140///
141/// Returned by [`SearchFtsDb::resolve_qnames_bulk`] and
142/// [`SearchFtsDb::resolve_symbol_ids_bulk`]. Carries every field needed by
143/// feedback adjustment and filter functions so they can operate without any
144/// git object-store reads.
145#[derive(Debug, Clone, Default)]
146pub struct ResolvedSymbol {
147    pub symbol_id: String,
148    /// Original (unexpanded) qname — needed for name-token extraction in feedback functions.
149    pub qname: String,
150    pub file: String,
151    pub kind: String,
152    pub doc: Option<String>,
153    pub signature: Option<String>,
154}
155
156/// Filters applied before BM25 ranking.
157#[derive(Debug, Default, Clone)]
158pub struct FtsFilters {
159    pub kind: Option<String>,
160    pub language: Option<String>,
161    /// Include test symbols (files under test/tests/spec directories, etc.).
162    /// Default: false — tests are excluded so production entry points rank first.
163    /// Ignored when `tests_only` is set.
164    pub include_tests: bool,
165    /// Restrict to test symbols only. Overrides `include_tests` when true.
166    /// Use this when classifying test coverage, finding fixture usage, or
167    /// auditing test layout (Plan A, t-006).
168    pub tests_only: bool,
169    /// Lowercase substring terms to exclude. Any candidate whose qname, file,
170    /// doc, or signature contains one of these strings is dropped.
171    pub exclude_terms: Vec<String>,
172    /// Glob patterns to restrict results to (e.g. "App/**/DriftPad*").
173    /// When non-empty, only symbols whose file matches at least one pattern
174    /// are kept. Patterns use `*` (within a segment) and `**` (any segments).
175    pub paths_filter: Vec<String>,
176    /// Plan J t-011: glob patterns to DROP. Inverse of `paths_filter` —
177    /// any candidate whose file matches one of these patterns is removed.
178    /// Stacks on top of `exclude_terms`: terms are substring matches over
179    /// qname/file/doc/sig; these are glob matches over file paths only.
180    pub exclude_paths: Vec<String>,
181    /// Plan J t-011: language tags to drop (e.g. "swift", "python").
182    /// Compared case-insensitively to the resolved symbol's `language`.
183    /// Useful in polyglot monorepos for queries like "auth flow" where
184    /// the same concept lives in both the iOS and backend codebases and
185    /// only one is in scope.
186    pub exclude_languages: Vec<String>,
187}
188
189/// Helper: build the SQL clause that handles the tri-state test filter.
190/// - `tests_only=true`  → `AND tier = '2'`
191/// - `include_tests=true` (and not tests_only) → no clause
192/// - default → `AND tier != '2'`
193fn tests_clause(include_tests: bool, tests_only: bool) -> &'static str {
194    if tests_only {
195        "AND tier = '2'"
196    } else if include_tests {
197        ""
198    } else {
199        "AND tier != '2'"
200    }
201}
202
203// ---------------------------------------------------------------------------
204// SearchFtsDb
205// ---------------------------------------------------------------------------
206
207pub struct SearchFtsDb {
208    conn: Connection,
209}
210
211impl SearchFtsDb {
212    /// Open (or create) the FTS index in `db_path`.
213    ///
214    /// Pragmas are tuned adaptively based on DB file size.  Users can override
215    /// the defaults by adding a `[performance]` section to `.asd/config.toml`
216    /// in the project root (the directory that contains `.asd-state.db`):
217    ///
218    /// ```toml
219    /// [performance]
220    /// cache_size_kb = 32768   # override adaptive cache (default: ~80% of DB, 8–64 MB)
221    /// mmap_size_mb  = 512     # override mmap window (default: 256 MB)
222    /// ```
223    pub fn open(db_path: &Path) -> rusqlite::Result<Self> {
224        let conn = Connection::open(db_path)?;
225        // Base pragmas: WAL for concurrent readers, NORMAL sync for durability/speed
226        // balance, MEMORY temp store avoids disk spills for sort/group operations,
227        // and mmap lets the OS page cache do the heavy lifting.
228        conn.execute_batch(
229            "PRAGMA journal_mode=WAL;
230             PRAGMA synchronous=NORMAL;
231             PRAGMA temp_store=MEMORY;
232             -- Wait out short-lived writers (e.g. asd-serve holding a sync
233             -- transaction) instead of failing instantly with SQLITE_BUSY.
234             -- A failed cache sync used to leave the DB cold forever; the
235             -- timeout plus the open-time self-heal makes that unreachable.
236             PRAGMA busy_timeout=5000;",
237        )?;
238
239        // Adaptive cache: scale to ~80 % of the current DB file size, clamped
240        // 8 MB … 64 MB.  A negative value tells SQLite the number is in KiB.
241        let db_bytes = std::fs::metadata(db_path)
242            .map(|m| m.len() as usize)
243            .unwrap_or(0);
244        let mut cache_kb: usize = ((db_bytes * 8 / 10) / 1024).clamp(8_192, 65_536);
245        let mut mmap_bytes: u64 = 268_435_456; // 256 MB default
246
247        // P1: Check for user overrides in .asd/config.toml (derived from db_path).
248        // Silently use adaptive defaults on any read/parse failure.
249        if let Some(project_dir) = db_path.parent() {
250            let cfg_path = project_dir.join(".asd").join("config.toml");
251            if let Ok(raw) = std::fs::read_to_string(&cfg_path) {
252                if let Ok(table) = raw.parse::<toml::Table>() {
253                    if let Some(perf) = table.get("performance").and_then(|v| v.as_table()) {
254                        if let Some(v) = perf.get("cache_size_kb").and_then(|v| v.as_integer()) {
255                            cache_kb = (v as usize).clamp(1_024, 131_072);
256                        }
257                        if let Some(v) = perf.get("mmap_size_mb").and_then(|v| v.as_integer()) {
258                            mmap_bytes = (v as u64).clamp(64, 4096) * 1024 * 1024;
259                        }
260                    }
261                }
262            }
263        }
264
265        conn.execute_batch(&format!(
266            "PRAGMA mmap_size={mmap_bytes}; PRAGMA cache_size=-{cache_kb};"
267        ))?;
268        let db = Self { conn };
269        db.ensure_schema()?;
270        Ok(db)
271    }
272
273    fn ensure_schema(&self) -> rusqlite::Result<()> {
274        // Version 5: adds ledger_text UNINDEXED + ledger_flags UNINDEXED columns to FTS
275        //            and asd_symbols_meta table (qname → symbol_id, file, kind).
276        //            Populated at `asd index` time — eliminates git reads from scoring hot path.
277        // Any version mismatch drops and recreates — data is reproduced by next `asd index`.
278        const SCHEMA_VER: i64 = 5;
279
280        let current: i64 = self
281            .conn
282            .query_row("SELECT version FROM asd_fts_meta LIMIT 1", [], |r| r.get(0))
283            .unwrap_or(0);
284
285        if current != SCHEMA_VER {
286            self.conn.execute_batch(
287                "DROP TABLE IF EXISTS asd_search_fts;
288                 DROP TABLE IF EXISTS asd_search_meta;
289                 DROP TABLE IF EXISTS asd_fts_meta;
290                 DROP TABLE IF EXISTS asd_symbols_meta;",
291            )?;
292        }
293
294        // asd_index_meta is a simple key-value store for index metadata.
295        // It is NOT dropped on FTS schema version changes — it persists
296        // across rebuilds so indexed_at survives schema upgrades.
297        self.conn.execute_batch(
298            "CREATE TABLE IF NOT EXISTS asd_index_meta (
299                key   TEXT PRIMARY KEY,
300                value TEXT NOT NULL
301            );",
302        )?;
303
304        // asd_ledger_cache: write-through cache of LedgerEntry records.
305        // NOT version-gated — survives FTS schema bumps like asd_index_meta.
306        // Full entry stored as a JSON blob (body) so the round-trip is lossless
307        // and new LedgerEntry fields don't require a schema migration here.
308        // The secondary index on (symbol_id, ref_name) makes list_entries_for
309        // a single indexed scan with no full-table reads.
310        self.conn.execute_batch(
311            "CREATE TABLE IF NOT EXISTS asd_ledger_cache (
312                entry_id   TEXT NOT NULL,
313                symbol_id  TEXT NOT NULL,
314                ref_name   TEXT NOT NULL,
315                body       TEXT NOT NULL,
316                PRIMARY KEY (entry_id, ref_name)
317            );
318            CREATE INDEX IF NOT EXISTS idx_asd_lc_sym
319                ON asd_ledger_cache(symbol_id, ref_name);",
320        )?;
321
322        // asd_effects_cache: one row per (symbol_id, ref_name) — stores the
323        // full EffectDecl JSON blob for lossless round-trips.
324        self.conn.execute_batch(
325            "CREATE TABLE IF NOT EXISTS asd_effects_cache (
326                symbol_id TEXT NOT NULL,
327                ref_name  TEXT NOT NULL,
328                body      TEXT NOT NULL,
329                PRIMARY KEY (symbol_id, ref_name)
330            );",
331        )?;
332
333        // asd_feedback is a write-through cache of git-backed FeedbackEntry
334        // records.  It is NOT version-gated — like asd_index_meta it survives
335        // FTS schema bumps.  The git object store remains authoritative; this
336        // table is the fast read path.  `asd index` / `asd reindex` reconciles
337        // any drift via sync_feedback_entries().
338        self.conn.execute_batch(
339            "CREATE TABLE IF NOT EXISTS asd_feedback (
340                entry_id     TEXT PRIMARY KEY,
341                symbol_id    TEXT NOT NULL,
342                symbol_qname TEXT NOT NULL,
343                query        TEXT NOT NULL,
344                verdict      TEXT NOT NULL,
345                author       TEXT NOT NULL,
346                created_at   TEXT NOT NULL,
347                note         TEXT,
348                file_scope   TEXT,
349                expires_at   TEXT
350            );
351            CREATE INDEX IF NOT EXISTS idx_asd_fb_symbol  ON asd_feedback(symbol_id);
352            CREATE INDEX IF NOT EXISTS idx_asd_fb_qname   ON asd_feedback(symbol_qname);
353            CREATE INDEX IF NOT EXISTS idx_asd_fb_verdict ON asd_feedback(verdict);",
354        )?;
355        // Plan J t-014: add expires_at to pre-existing tables (DBs
356        // created before 1.0.48). ALTER...ADD COLUMN errors if the
357        // column already exists; we swallow that case and let any
358        // other error surface.
359        let _ = self
360            .conn
361            .execute("ALTER TABLE asd_feedback ADD COLUMN expires_at TEXT", []);
362        // feedback-lifecycle t-003: withdrawal columns, same migration
363        // pattern — ALTER errors if the column exists, which we swallow.
364        for col in ["withdrawn_at", "withdrawn_by", "withdrawn_reason"] {
365            let _ = self.conn.execute(
366                &format!("ALTER TABLE asd_feedback ADD COLUMN {col} TEXT"),
367                [],
368            );
369        }
370
371        // asd_symbols_cache: full Symbol JSON for every indexed symbol.
372        // Populated at asd index time; eliminates the by-qname git tree walk
373        // in build_id_map (was ~2s on repos with hundreds of symbols).
374        self.conn.execute_batch(
375            "CREATE TABLE IF NOT EXISTS asd_symbols_cache (
376                symbol_id   TEXT NOT NULL,
377                ref_name    TEXT NOT NULL,
378                qname       TEXT NOT NULL,
379                file        TEXT NOT NULL,
380                kind        TEXT NOT NULL DEFAULT '',
381                symbol_json TEXT NOT NULL,
382                PRIMARY KEY (symbol_id, ref_name)
383            );
384            CREATE INDEX IF NOT EXISTS idx_asd_sc_qname
385                ON asd_symbols_cache(qname, ref_name);
386            -- asd_call_edges: directed caller/callee edges.
387            -- direction is 'caller' (this symbol is called by neighbor)
388            --            or 'callee' (this symbol calls neighbor).
389            CREATE TABLE IF NOT EXISTS asd_call_edges (
390                symbol_id   TEXT NOT NULL,
391                neighbor_id TEXT NOT NULL,
392                direction   TEXT NOT NULL,
393                ref_name    TEXT NOT NULL,
394                PRIMARY KEY (symbol_id, neighbor_id, direction, ref_name)
395            );
396            CREATE INDEX IF NOT EXISTS idx_asd_ce_lookup
397                ON asd_call_edges(symbol_id, direction, ref_name);",
398        )?;
399
400        self.conn.execute_batch(&format!(
401            "CREATE VIRTUAL TABLE IF NOT EXISTS asd_search_fts USING fts5(
402                symbol_id    UNINDEXED,
403                qname,
404                signature,
405                doc,
406                file,
407                language     UNINDEXED,
408                kind         UNINDEXED,
409                line         UNINDEXED,
410                qname_orig   UNINDEXED,
411                sig_orig     UNINDEXED,
412                file_orig    UNINDEXED,
413                tier         UNINDEXED,
414                ledger_text  UNINDEXED,
415                ledger_flags UNINDEXED,
416                tokenize     = 'unicode61 remove_diacritics 1'
417            );
418            CREATE TABLE IF NOT EXISTS asd_search_meta (
419                file       TEXT PRIMARY KEY,
420                indexed_at INTEGER NOT NULL
421            );
422            -- Lightweight qname→(symbol_id, file, kind) lookup table.
423            -- Populated alongside FTS rebuild; allows feedback functions to resolve
424            -- qname without traversing the git object store.
425            CREATE TABLE IF NOT EXISTS asd_symbols_meta (
426                qname     TEXT PRIMARY KEY,
427                symbol_id TEXT NOT NULL,
428                file      TEXT NOT NULL,
429                kind      TEXT NOT NULL
430            );
431            CREATE TABLE IF NOT EXISTS asd_fts_meta (version INTEGER PRIMARY KEY);
432            INSERT OR IGNORE INTO asd_fts_meta VALUES ({SCHEMA_VER});"
433        ))
434    }
435
436    /// Atomically replace the entire FTS table from `symbols`.
437    ///
438    /// This is the canonical indexing path. Because `asd index` already has the
439    /// complete current-world snapshot, a full rebuild is cheaper than tracking
440    /// which files changed and avoids stale rows from deleted files.
441    ///
442    /// `ledger_data`: `symbol_id → (ledger_text, ledger_flags)` map built at
443    /// index time from the ledger tree. Pass `&HashMap::new()` when no ledger
444    /// data is available (e.g. test helpers that call rebuild directly).
445    pub fn rebuild(&self, symbols: &[Symbol]) -> rusqlite::Result<()> {
446        self.rebuild_refs(&symbols.iter().collect::<Vec<_>>(), &HashMap::new())
447    }
448
449    /// Like [`rebuild`] but accepts a slice of references — avoids a copy when
450    /// the caller already has a deduplicated `Vec<&Symbol>`.
451    ///
452    /// `ledger_data`: `symbol_id → (ledger_text, ledger_flags)`. Pass
453    /// `&HashMap::new()` when ledger data is unavailable.
454    pub fn rebuild_refs(
455        &self,
456        symbols: &[&Symbol],
457        ledger_data: &HashMap<String, (String, String)>,
458    ) -> rusqlite::Result<()> {
459        self.conn.execute_batch(
460            "DELETE FROM asd_search_fts; DELETE FROM asd_search_meta; DELETE FROM asd_symbols_meta;",
461        )?;
462        for sym in symbols {
463            let (lt, lf) = ledger_data
464                .get(&sym.symbol_id)
465                .map(|(t, f)| (t.as_str(), f.as_str()))
466                .unwrap_or(("", ""));
467            self.insert_symbol(sym, lt, lf)?;
468        }
469
470        // Populate asd_symbols_meta: lightweight qname→(symbol_id, file, kind) table.
471        // This is the single source used by feedback functions to resolve qnames
472        // without touching the git object store.
473        for sym in symbols {
474            let kind = format!("{:?}", sym.kind).to_lowercase();
475            self.conn.execute(
476                "INSERT OR REPLACE INTO asd_symbols_meta(qname, symbol_id, file, kind)
477                 VALUES(?1, ?2, ?3, ?4)",
478                params![sym.qname, sym.symbol_id, sym.file, kind],
479            )?;
480        }
481
482        // Stamp the rebuild time so staleness checks can compare against it.
483        let now = std::time::SystemTime::now()
484            .duration_since(std::time::UNIX_EPOCH)
485            .map(|d| d.as_secs() as i64)
486            .unwrap_or(0);
487        self.conn.execute(
488            "INSERT OR REPLACE INTO asd_index_meta (key, value) VALUES ('indexed_at', ?1)",
489            params![now.to_string()],
490        )?;
491        Ok(())
492    }
493
494    /// Unix timestamp (seconds) of the last `rebuild()` call, or `None` if
495    /// the index has never been built.
496    pub fn last_indexed_at(&self) -> Option<i64> {
497        self.conn
498            .query_row(
499                "SELECT value FROM asd_index_meta WHERE key = 'indexed_at' LIMIT 1",
500                [],
501                |r| r.get::<_, String>(0),
502            )
503            .ok()
504            .and_then(|s| s.parse().ok())
505    }
506
507    /// Whether the FTS rebuild succeeded during the last `asd index` run.
508    /// Returns `None` if no `mark_symbols_indexed` call has been recorded yet
509    /// (pre-0.9.70 index runs or DBs that have never been indexed with this version).
510    pub fn fts_last_rebuild_ok(&self) -> Option<bool> {
511        self.conn
512            .query_row(
513                "SELECT value FROM asd_index_meta WHERE key = 'fts_last_rebuild_ok' LIMIT 1",
514                [],
515                |r| r.get::<_, String>(0),
516            )
517            .ok()
518            .map(|s| s == "1")
519    }
520
521    /// Record the outcome of a symbol-indexing run. Call this from
522    /// `index_pipeline` after the FTS rebuild attempt (success *or* failure).
523    /// Writes `symbols_indexed_at` and `fts_last_rebuild_ok` into
524    /// `asd_index_meta`. This is the authoritative signal used by
525    /// `stale_warning()` to detect the "symbols fresh / FTS stale" state.
526    pub fn mark_symbols_indexed(&self, fts_succeeded: bool) -> rusqlite::Result<()> {
527        let now = std::time::SystemTime::now()
528            .duration_since(std::time::UNIX_EPOCH)
529            .map(|d| d.as_secs() as i64)
530            .unwrap_or(0);
531        self.conn.execute(
532            "INSERT OR REPLACE INTO asd_index_meta (key, value) VALUES ('symbols_indexed_at', ?1)",
533            params![now.to_string()],
534        )?;
535        self.conn.execute(
536            "INSERT OR REPLACE INTO asd_index_meta (key, value) \
537             VALUES ('fts_last_rebuild_ok', ?1)",
538            params![if fts_succeeded { "1" } else { "0" }],
539        )?;
540        Ok(())
541    }
542
543    // -----------------------------------------------------------------------
544    // Symbol + edge cache — O(1) reads vs. full git tree walks
545    // -----------------------------------------------------------------------
546
547    /// Number of rows in the `asd_search_fts` symbol search table. Used by
548    /// `Engine::warm_caches` to decide whether a full-text rebuild is needed:
549    /// a populated FTS table is never clobbered (it carries index-time
550    /// `ledger_text`/`ledger_flags` denormalizations), only an empty one is
551    /// rebuilt.
552    pub fn fts_symbol_row_count(&self) -> i64 {
553        self.conn
554            .query_row("SELECT COUNT(*) FROM asd_search_fts", [], |r| r.get(0))
555            .unwrap_or(0)
556    }
557
558    /// Returns `true` when `asd_symbols_cache` has at least one row for
559    /// `ref_name`. Used as the primary guard before attempting a cached read —
560    /// if the cache is empty the caller falls back to the git path.
561    pub fn symbols_cached_for(&self, ref_name: &str) -> bool {
562        self.conn
563            .query_row(
564                "SELECT COUNT(*) FROM asd_symbols_cache WHERE ref_name = ?1 LIMIT 1",
565                params![ref_name],
566                |r| r.get::<_, i64>(0),
567            )
568            .map(|n| n > 0)
569            .unwrap_or(false)
570    }
571
572    /// Full replace of `asd_symbols_cache` for `ref_name`. Called after every
573    /// `asd index` run so the cache always reflects the current snapshot.
574    pub fn sync_symbols(
575        &self,
576        symbols: &[&crate::schema::Symbol],
577        ref_name: &str,
578    ) -> rusqlite::Result<()> {
579        let tx = self.conn.unchecked_transaction()?;
580        tx.execute(
581            "DELETE FROM asd_symbols_cache WHERE ref_name = ?1",
582            params![ref_name],
583        )?;
584        {
585            let mut stmt = tx.prepare(
586                "INSERT OR REPLACE INTO asd_symbols_cache
587                 (symbol_id, ref_name, qname, file, kind, symbol_json)
588                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
589            )?;
590            for sym in symbols {
591                let json = serde_json::to_string(sym).unwrap_or_default();
592                let kind = format!("{:?}", sym.kind).to_lowercase();
593                stmt.execute(params![
594                    sym.symbol_id,
595                    ref_name,
596                    sym.qname,
597                    sym.file,
598                    kind,
599                    json
600                ])?;
601            }
602        }
603        tx.commit()
604    }
605
606    /// Full replace of `asd_call_edges` for `ref_name`. `callees_of` maps
607    /// caller_id → [callee_id, …]; `callers_of` maps callee_id → [caller_id, …].
608    pub fn sync_call_edges(
609        &self,
610        callees_of: &HashMap<String, Vec<String>>,
611        callers_of: &HashMap<String, Vec<String>>,
612        ref_name: &str,
613    ) -> rusqlite::Result<()> {
614        let tx = self.conn.unchecked_transaction()?;
615        tx.execute(
616            "DELETE FROM asd_call_edges WHERE ref_name = ?1",
617            params![ref_name],
618        )?;
619        {
620            let mut stmt = tx.prepare(
621                "INSERT OR REPLACE INTO asd_call_edges
622                 (symbol_id, neighbor_id, direction, ref_name)
623                 VALUES (?1, ?2, ?3, ?4)",
624            )?;
625            for (caller_id, callee_ids) in callees_of {
626                for callee_id in callee_ids {
627                    stmt.execute(params![caller_id, callee_id, "callee", ref_name])?;
628                }
629            }
630            for (callee_id, caller_ids) in callers_of {
631                for caller_id in caller_ids {
632                    stmt.execute(params![callee_id, caller_id, "caller", ref_name])?;
633                }
634            }
635        }
636        tx.commit()
637    }
638
639    /// Build the full `symbol_id → Symbol` map from `asd_symbols_cache`.
640    /// Returns an empty map on any error (caller falls back to git).
641    pub fn build_id_map_cached(&self, ref_name: &str) -> HashMap<String, crate::schema::Symbol> {
642        let mut stmt = match self
643            .conn
644            .prepare("SELECT symbol_id, symbol_json FROM asd_symbols_cache WHERE ref_name = ?1")
645        {
646            Ok(s) => s,
647            Err(_) => return HashMap::new(),
648        };
649        let rows = match stmt.query_map(params![ref_name], |row| {
650            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
651        }) {
652            Ok(r) => r,
653            Err(_) => return HashMap::new(),
654        };
655        let mut map = HashMap::new();
656        for row in rows.flatten() {
657            let (id, json) = row;
658            if let Ok(sym) = serde_json::from_str::<crate::schema::Symbol>(&json) {
659                map.insert(id, sym);
660            }
661        }
662        map
663    }
664
665    /// Plan E t-005: bulk qname → file lookup. Returns a HashMap with one
666    /// entry per qname found in the symbol cache (qnames not in the cache
667    /// are simply absent from the result). Callers use this to avoid
668    /// per-candidate SQL roundtrips when they only need the file path,
669    /// not the full Symbol JSON.
670    ///
671    /// Note: asd_symbols_meta is single-ref by design (rebuilt fresh per
672    /// `asd index .`), so the `ref_name` parameter is currently unused
673    /// but reserved for the future multi-ref world.
674    pub fn files_for_qnames(
675        &self,
676        qnames: &[&str],
677        _ref_name: &str,
678    ) -> std::collections::HashMap<String, String> {
679        let mut out = std::collections::HashMap::with_capacity(qnames.len());
680        if qnames.is_empty() {
681            return out;
682        }
683        let placeholders: Vec<&str> = (0..qnames.len()).map(|_| "?").collect();
684        let sql = format!(
685            "SELECT qname, file FROM asd_symbols_meta WHERE qname IN ({})",
686            placeholders.join(", ")
687        );
688        let mut stmt = match self.conn.prepare(&sql) {
689            Ok(s) => s,
690            Err(_) => return out,
691        };
692        let rows = stmt.query_map(
693            rusqlite::params_from_iter(qnames.iter().map(|q| q as &dyn rusqlite::ToSql)),
694            |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)),
695        );
696        if let Ok(rows) = rows {
697            for r in rows.flatten() {
698                out.insert(r.0, r.1);
699            }
700        }
701        out
702    }
703
704    /// Look up a single Symbol by qname from the cache.
705    pub fn get_symbol_by_qname_cached(
706        &self,
707        qname: &str,
708        ref_name: &str,
709    ) -> Option<crate::schema::Symbol> {
710        self.conn
711            .query_row(
712                "SELECT symbol_json FROM asd_symbols_cache
713                 WHERE qname = ?1 AND ref_name = ?2 LIMIT 1",
714                params![qname, ref_name],
715                |r| r.get::<_, String>(0),
716            )
717            .ok()
718            .and_then(|json| serde_json::from_str(&json).ok())
719    }
720
721    /// Return the neighbor IDs for `symbol_id` in the given `direction`
722    /// (`"caller"` or `"callee"`). Empty Vec if none or on error.
723    pub fn get_neighbors_cached(
724        &self,
725        symbol_id: &str,
726        direction: &str,
727        ref_name: &str,
728    ) -> Vec<String> {
729        let mut stmt = match self.conn.prepare(
730            "SELECT neighbor_id FROM asd_call_edges
731             WHERE symbol_id = ?1 AND direction = ?2 AND ref_name = ?3",
732        ) {
733            Ok(s) => s,
734            Err(_) => return Vec::new(),
735        };
736        stmt.query_map(params![symbol_id, direction, ref_name], |r| {
737            r.get::<_, String>(0)
738        })
739        .map(|rows| rows.flatten().collect())
740        .unwrap_or_default()
741    }
742
743    /// Build both full edge maps from `asd_call_edges` in one query:
744    /// `(callers_of, callees_of)`, each `symbol_id → [neighbor_id, …]`.
745    /// The bulk analog of [`Self::get_neighbors_cached`] — callers that walk
746    /// many nodes (graph BFS) use this instead of one query per node.
747    /// Returns empty maps on any error (caller falls back to git).
748    pub fn build_edge_maps_cached(
749        &self,
750        ref_name: &str,
751    ) -> (HashMap<String, Vec<String>>, HashMap<String, Vec<String>>) {
752        let mut callers_of: HashMap<String, Vec<String>> = HashMap::new();
753        let mut callees_of: HashMap<String, Vec<String>> = HashMap::new();
754        let mut stmt = match self.conn.prepare(
755            "SELECT symbol_id, neighbor_id, direction FROM asd_call_edges
756             WHERE ref_name = ?1",
757        ) {
758            Ok(s) => s,
759            Err(_) => return (callers_of, callees_of),
760        };
761        let rows = match stmt.query_map(params![ref_name], |r| {
762            Ok((
763                r.get::<_, String>(0)?,
764                r.get::<_, String>(1)?,
765                r.get::<_, String>(2)?,
766            ))
767        }) {
768            Ok(r) => r,
769            Err(_) => return (callers_of, callees_of),
770        };
771        for (symbol_id, neighbor_id, direction) in rows.flatten() {
772            match direction.as_str() {
773                "caller" => callers_of.entry(symbol_id).or_default().push(neighbor_id),
774                "callee" => callees_of.entry(symbol_id).or_default().push(neighbor_id),
775                _ => {}
776            }
777        }
778        (callers_of, callees_of)
779    }
780
781    /// Number of rows in the FTS table (total indexed symbols).
782    pub fn symbol_count(&self) -> usize {
783        self.conn
784            .query_row("SELECT COUNT(*) FROM asd_search_fts", [], |r| {
785                r.get::<_, i64>(0)
786            })
787            .map(|n| n as usize)
788            .unwrap_or(0)
789    }
790
791    /// Count symbols that have any ledger entries.
792    ///
793    /// Field-test refinement (AcmeFlow feedback, 2026-06-04): this used
794    /// to read `SELECT COUNT(*) FROM asd_search_fts WHERE ledger_text != ''`,
795    /// but `ledger_text` is only populated at full `asd index` time. Any
796    /// entries written via `asd think`, `asd ledger append`, or
797    /// `asd annotate-commit` go to `asd_ledger_cache` via `upsert_ledger_entry`
798    /// — they're invisible to the FTS table until the next reindex. The
799    /// result: trust scoring kept reporting `unannotated` and projects
800    /// stuck in a confidence-erasing loop ("write entries → ASD says
801    /// nothing's annotated → reindex → maybe ASD sees them"). Reading
802    /// from the live cache instead means writes flip the count instantly.
803    ///
804    /// `ref_name` is required for accurate counting — `asd_ledger_cache`
805    /// stores entries per-ref. The DISTINCT collapses multiple entries
806    /// on the same symbol so we count *annotated symbols*, not entries.
807    pub fn annotated_symbol_count(&self, ref_name: &str) -> usize {
808        self.conn
809            .query_row(
810                "SELECT COUNT(DISTINCT symbol_id) FROM asd_ledger_cache WHERE ref_name = ?1",
811                params![ref_name],
812                |r| r.get::<_, i64>(0),
813            )
814            .map(|n| n as usize)
815            .unwrap_or(0)
816    }
817
818    fn insert_symbol(
819        &self,
820        sym: &Symbol,
821        ledger_text: &str,
822        ledger_flags: &str,
823    ) -> rusqlite::Result<()> {
824        let qname_exp = expand_identifier(&sym.qname);
825        let sig_orig = sym.signature.as_deref().unwrap_or("");
826        let sig_exp = if sig_orig.is_empty() {
827            String::new()
828        } else {
829            expand_text(sig_orig)
830        };
831        let doc = sym.doc.as_deref().unwrap_or("");
832        let file_exp = expand_text(&sym.file);
833        let kind = format!("{:?}", sym.kind).to_lowercase();
834        let tier = symbol_tier(&sym.file).to_string();
835
836        self.conn.execute(
837            "INSERT INTO asd_search_fts(
838                 symbol_id, qname, signature, doc, file, language, kind, line,
839                 qname_orig, sig_orig, file_orig, tier, ledger_text, ledger_flags)
840             VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)",
841            params![
842                sym.symbol_id,
843                qname_exp,
844                sig_exp,
845                doc,
846                file_exp,
847                sym.language,
848                kind,
849                sym.start.line,
850                sym.qname,
851                sym.signature.as_deref().unwrap_or(""),
852                sym.file,
853                tier,
854                ledger_text,
855                ledger_flags,
856            ],
857        )?;
858        Ok(())
859    }
860
861    /// Ranked FTS search. Returns hits ordered by relevance (highest first).
862    ///
863    /// BM25 column weights: qname=10, signature=5, doc=5, file=2.
864    /// Tests excluded unless `filters.include_tests` is true.
865    pub fn search(
866        &self,
867        query: &str,
868        filters: &FtsFilters,
869        limit: usize,
870    ) -> rusqlite::Result<Vec<FtsHit>> {
871        if query.trim().is_empty() {
872            return Ok(vec![]);
873        }
874
875        let tokens: Vec<String> = query
876            .split(|c: char| c.is_whitespace() || c == '_' || c == '-' || c == '.')
877            .map(|t| t.to_lowercase())
878            .filter(|t| t.len() >= 2 && !is_stopword(t))
879            .collect();
880
881        if tokens.is_empty() {
882            return Ok(vec![]);
883        }
884
885        // FTS5 MATCH: "token1" OR "token2" ... — each token quoted against special chars.
886        let match_expr = tokens
887            .iter()
888            .map(|t| format!("\"{}\"", t.replace('"', "")))
889            .collect::<Vec<_>>()
890            .join(" OR ");
891
892        // UNINDEXED columns can be used in regular WHERE clauses (not MATCH).
893        let kind_clause = filters
894            .kind
895            .as_deref()
896            .map(|k| format!("AND kind = '{}'", k.to_lowercase().replace('\'', "")))
897            .unwrap_or_default();
898        let lang_clause = filters
899            .language
900            .as_deref()
901            .map(|l| format!("AND language = '{}'", l.to_lowercase().replace('\'', "")))
902            .unwrap_or_default();
903        // Exclude tier=2 (tests) by default. Tier=1 (utility) is included but penalised
904        // in hybrid_boost so production symbols rank above them.
905        let test_clause = tests_clause(filters.include_tests, filters.tests_only);
906
907        // Fetch extra for hybrid ledger reranking.
908        let fetch = (limit * 4).max(80);
909
910        // Columns: 0=symbol_id,1=language,2=kind,3=line,4=doc,
911        //          5=qname_orig,6=sig_orig,7=file_orig,8=tier,
912        //          9=ledger_text,10=ledger_flags,11=score
913        let sql = format!(
914            "SELECT symbol_id, language, kind, line, doc,
915                    qname_orig, sig_orig, file_orig, tier,
916                    ledger_text, ledger_flags,
917                    bm25(asd_search_fts, 10.0, 5.0, 5.0, 2.0) AS score
918             FROM asd_search_fts
919             WHERE asd_search_fts MATCH ?1
920             {test_clause}
921             {kind_clause}
922             {lang_clause}
923             ORDER BY score
924             LIMIT {fetch}"
925        );
926
927        let mut stmt = self.conn.prepare(&sql)?;
928        let hits = stmt
929            .query_map(params![match_expr], |row| {
930                let bm25_raw: f64 = row.get(11)?;
931                let sig_orig: Option<String> = row.get(6)?;
932                let tier_str: String = row.get(8).unwrap_or_default();
933                let tier: SymbolTier = tier_str.parse().unwrap_or(0);
934                Ok(FtsHit {
935                    bm25_score: -bm25_raw,
936                    symbol_id: row.get(0)?,
937                    language: row.get(1)?,
938                    kind: row.get(2)?,
939                    line: row.get::<_, u32>(3).unwrap_or(0),
940                    doc: row.get(4)?,
941                    qname: row.get(5)?,
942                    signature: sig_orig.filter(|s| !s.is_empty()),
943                    file: row.get(7)?,
944                    tier,
945                    ledger_text: row.get::<_, String>(9).unwrap_or_default(),
946                    ledger_flags: row.get::<_, String>(10).unwrap_or_default(),
947                })
948            })?
949            .filter_map(|r| r.ok())
950            .collect();
951
952        Ok(hits)
953    }
954
955    /// For each token in `tokens`, return (token, distinct_file_count) using a single
956    /// SQL UNION ALL query instead of N separate searches.  Used by
957    /// `detect_ambiguous_tokens` to batch all token checks into one round-trip.
958    pub fn count_distinct_files_per_token(
959        &self,
960        tokens: &[&str],
961        include_tests: bool,
962    ) -> rusqlite::Result<Vec<(String, usize)>> {
963        if tokens.is_empty() {
964            return Ok(vec![]);
965        }
966        let test_clause = if include_tests { "" } else { "AND tier != '2'" };
967
968        // Build: SELECT 'tok', COUNT(DISTINCT file_orig) FROM asd_search_fts
969        //        WHERE asd_search_fts MATCH '"tok"' AND tier != '2'
970        // UNION ALL ...
971        // Each sub-select is a separate FTS5 scan but they share one SQL round-trip,
972        // removing N-1 statement-prepare + result-iteration cycles vs the old path.
973        let mut parts = Vec::with_capacity(tokens.len());
974        let mut params_vec: Vec<String> = Vec::with_capacity(tokens.len());
975        for (i, tok) in tokens.iter().enumerate() {
976            let tok_clean = tok.replace('"', "");
977            parts.push(format!(
978                "SELECT ?{} AS tok, COUNT(DISTINCT file_orig) AS cnt \
979                 FROM asd_search_fts \
980                 WHERE asd_search_fts MATCH '\"{}\"' {test_clause}",
981                i + 1,
982                tok_clean
983            ));
984            params_vec.push(tok_clean);
985        }
986        let sql = parts.join(" UNION ALL ");
987
988        let mut stmt = self.conn.prepare(&sql)?;
989        // rusqlite requires params as a slice of &dyn ToSql; build it dynamically.
990        let params_refs: Vec<&dyn rusqlite::types::ToSql> = params_vec
991            .iter()
992            .map(|s| s as &dyn rusqlite::types::ToSql)
993            .collect();
994        let results = stmt
995            .query_map(params_refs.as_slice(), |row| {
996                let tok: String = row.get(0)?;
997                let cnt: i64 = row.get(1)?;
998                Ok((tok, cnt as usize))
999            })?
1000            .filter_map(|r| r.ok())
1001            .collect();
1002        Ok(results)
1003    }
1004
1005    /// Returns true if `name` exactly matches any symbol's short name or qname.
1006    ///
1007    /// Matches:
1008    /// - `qname_orig` ends with `.{name}` or equals `{name}` (case-insensitive)
1009    ///
1010    /// Used by the uncertainty override guard to distinguish "exact symbol lookup"
1011    /// from generic broad queries, preventing false-high uncertainty on direct
1012    /// symbol names like `AcmeFlowViewModel`.
1013    pub fn has_exact_symbol_name(&self, name: &str) -> bool {
1014        if name.len() < 3 {
1015            return false;
1016        }
1017        let name_lc = name.to_lowercase();
1018        // Match qname_orig case-insensitively: either it IS the name or ends with .name
1019        let sql = "SELECT 1 FROM asd_search_fts \
1020                   WHERE lower(qname_orig) = ?1 \
1021                      OR lower(qname_orig) LIKE ?2 \
1022                   LIMIT 1";
1023        let suffix_pattern = format!("%.{}", name_lc);
1024        self.conn
1025            .query_row(sql, rusqlite::params![name_lc, suffix_pattern], |_| {
1026                Ok(true)
1027            })
1028            .unwrap_or(false)
1029    }
1030
1031    /// True if the FTS table has at least one row.
1032    pub fn has_data(&self) -> bool {
1033        self.conn
1034            .query_row("SELECT COUNT(*) FROM asd_search_fts LIMIT 1", [], |r| {
1035                r.get::<_, i64>(0)
1036            })
1037            .map(|n| n > 0)
1038            .unwrap_or(false)
1039    }
1040
1041    /// Bulk-resolve qnames to [`SymbolMeta`] from the `asd_symbols_meta` table.
1042    ///
1043    /// Returns a map of `qname → SymbolMeta` for all qnames that have a row.
1044    /// Missing entries are silently omitted; callers should fall back to a git
1045    /// object read when the result map does not contain a needed qname.
1046    ///
1047    /// Cost: one SQL query regardless of how many qnames are requested.
1048    pub fn get_symbols_meta_bulk(&self, qnames: &[&str]) -> HashMap<String, SymbolMeta> {
1049        if qnames.is_empty() {
1050            return HashMap::new();
1051        }
1052        // Build: SELECT qname, symbol_id, file, kind FROM asd_symbols_meta
1053        //        WHERE qname IN (?1, ?2, ...)
1054        let placeholders: Vec<String> = (1..=qnames.len()).map(|i| format!("?{i}")).collect();
1055        let sql = format!(
1056            "SELECT qname, symbol_id, file, kind FROM asd_symbols_meta WHERE qname IN ({})",
1057            placeholders.join(", ")
1058        );
1059        let mut stmt = match self.conn.prepare(&sql) {
1060            Ok(s) => s,
1061            Err(_) => return HashMap::new(),
1062        };
1063        let params_refs: Vec<&dyn rusqlite::types::ToSql> = qnames
1064            .iter()
1065            .map(|q| q as &dyn rusqlite::types::ToSql)
1066            .collect();
1067        stmt.query_map(params_refs.as_slice(), |row| {
1068            Ok((
1069                row.get::<_, String>(0)?,
1070                SymbolMeta {
1071                    symbol_id: row.get(1)?,
1072                    file: row.get(2)?,
1073                    kind: row.get(3)?,
1074                },
1075            ))
1076        })
1077        .map(|rows| rows.filter_map(|r| r.ok()).collect())
1078        .unwrap_or_default()
1079    }
1080
1081    /// Anchor-candidate lookup: return `(qname, symbol_id)` pairs for symbols
1082    /// that have Invariant or Hazard ledger entries whose text matches at least
1083    /// one of `tokens`, and are not already in `existing_qnames`.
1084    ///
1085    /// Replaces the two `get_tree` calls in `ledger_anchor_pass`:
1086    ///   1. `get_tree("/asd/v1/ledger")` — full ledger tree walk
1087    ///   2. `get_tree("/asd/v1/index/by-qname")` — reverse id→qname map
1088    ///
1089    /// Both replaced by a single SQLite scan against the UNINDEXED
1090    /// `ledger_text` / `ledger_flags` columns populated at `asd index` time.
1091    ///
1092    /// Returns at most `limit` results (matches `MAX_ANCHORS` in candidates.rs).
1093    pub fn anchor_candidates(
1094        &self,
1095        tokens: &[String],
1096        existing_qnames: &std::collections::HashSet<String>,
1097        limit: usize,
1098    ) -> Vec<(String, String)> {
1099        if tokens.is_empty() {
1100            return vec![];
1101        }
1102
1103        // Build WHERE clause: ledger_flags must contain invariant or hazard,
1104        // AND ledger_text must contain at least one query token.
1105        // Both are UNINDEXED columns — regular LIKE is fine (not FTS MATCH).
1106        let flag_clause = "(ledger_flags LIKE '%invariant%' OR ledger_flags LIKE '%hazard%')";
1107
1108        // One OR clause per token: ledger_text LIKE '%token%'
1109        let text_clauses: Vec<String> = tokens
1110            .iter()
1111            .map(|t| format!("ledger_text LIKE '%{}%'", t.replace('\'', "''")))
1112            .collect();
1113        let text_clause = format!("({})", text_clauses.join(" OR "));
1114
1115        let sql = format!(
1116            "SELECT qname_orig, symbol_id
1117             FROM asd_search_fts
1118             WHERE ledger_text != ''
1119               AND {flag_clause}
1120               AND {text_clause}
1121               AND tier != '2'
1122             LIMIT {limit}"
1123        );
1124
1125        let mut stmt = match self.conn.prepare(&sql) {
1126            Ok(s) => s,
1127            Err(_) => return vec![],
1128        };
1129
1130        stmt.query_map([], |row| {
1131            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1132        })
1133        .map(|rows| {
1134            rows.filter_map(|r| r.ok())
1135                .filter(|(qname, _)| !existing_qnames.contains(qname))
1136                .collect()
1137        })
1138        .unwrap_or_default()
1139    }
1140
1141    /// Bulk-resolve qnames to [`ResolvedSymbol`] from the FTS table.
1142    ///
1143    /// Returns `HashMap<qname, ResolvedSymbol>` for all qnames that have a row.
1144    /// Missing entries are silently omitted (unknown qname or stale index).
1145    ///
1146    /// Replaces per-symbol `get_symbol_by_qname` git reads in:
1147    /// - `apply_feedback_adjustments`
1148    /// - `explain_feedback_impacts`
1149    /// - `apply_file_scope_feedback`
1150    /// - `apply_paths_filter`
1151    /// - `apply_exclusions`
1152    ///
1153    /// Cost: one SQL query per call regardless of batch size.
1154    pub fn resolve_qnames_bulk(&self, qnames: &[&str]) -> HashMap<String, ResolvedSymbol> {
1155        if qnames.is_empty() {
1156            return HashMap::new();
1157        }
1158        // One row per qname_orig (guaranteed unique by rebuild_refs dedup).
1159        // doc and sig_orig may be NULL — map to Option.
1160        let placeholders: Vec<String> = (1..=qnames.len()).map(|i| format!("?{i}")).collect();
1161        let sql = format!(
1162            "SELECT qname_orig, symbol_id, file_orig, kind, doc, sig_orig
1163             FROM asd_search_fts
1164             WHERE qname_orig IN ({})",
1165            placeholders.join(", ")
1166        );
1167        let mut stmt = match self.conn.prepare(&sql) {
1168            Ok(s) => s,
1169            Err(_) => return HashMap::new(),
1170        };
1171        let params_refs: Vec<&dyn rusqlite::types::ToSql> = qnames
1172            .iter()
1173            .map(|q| q as &dyn rusqlite::types::ToSql)
1174            .collect();
1175        // Columns: 0=qname_orig (key), 1=symbol_id, 2=file_orig, 3=kind, 4=doc, 5=sig_orig
1176        stmt.query_map(params_refs.as_slice(), |row| {
1177            let qname: String = row.get(0)?;
1178            let doc_raw: Option<String> = row.get(4)?;
1179            let sig_raw: Option<String> = row.get(5)?;
1180            Ok((
1181                qname.clone(),
1182                ResolvedSymbol {
1183                    symbol_id: row.get(1)?,
1184                    qname,
1185                    file: row.get(2)?,
1186                    kind: row.get(3)?,
1187                    doc: doc_raw.filter(|s| !s.is_empty()),
1188                    signature: sig_raw.filter(|s| !s.is_empty()),
1189                },
1190            ))
1191        })
1192        .map(|rows| rows.filter_map(|r| r.ok()).collect())
1193        .unwrap_or_default()
1194    }
1195
1196    /// Also resolve by symbol_id batch — used by feedback functions that have
1197    /// symbol_ids (not qnames) and need file/qname/kind for sibling suppression.
1198    ///
1199    /// Cost: one SQL query, returns `HashMap<symbol_id, ResolvedSymbol>`.
1200    pub fn resolve_symbol_ids_bulk(&self, symbol_ids: &[&str]) -> HashMap<String, ResolvedSymbol> {
1201        if symbol_ids.is_empty() {
1202            return HashMap::new();
1203        }
1204        let placeholders: Vec<String> = (1..=symbol_ids.len()).map(|i| format!("?{i}")).collect();
1205        // Columns: 0=symbol_id (key), 1=qname_orig, 2=file_orig, 3=kind, 4=doc, 5=sig_orig
1206        let sql = format!(
1207            "SELECT symbol_id, qname_orig, file_orig, kind, doc, sig_orig
1208             FROM asd_search_fts
1209             WHERE symbol_id IN ({})",
1210            placeholders.join(", ")
1211        );
1212        let mut stmt = match self.conn.prepare(&sql) {
1213            Ok(s) => s,
1214            Err(_) => return HashMap::new(),
1215        };
1216        let params_refs: Vec<&dyn rusqlite::types::ToSql> = symbol_ids
1217            .iter()
1218            .map(|s| s as &dyn rusqlite::types::ToSql)
1219            .collect();
1220        stmt.query_map(params_refs.as_slice(), |row| {
1221            let sym_id: String = row.get(0)?;
1222            let doc_raw: Option<String> = row.get(4)?;
1223            let sig_raw: Option<String> = row.get(5)?;
1224            Ok((
1225                sym_id.clone(),
1226                ResolvedSymbol {
1227                    symbol_id: sym_id,
1228                    qname: row.get(1)?,
1229                    file: row.get(2)?,
1230                    kind: row.get(3)?,
1231                    doc: doc_raw.filter(|s| !s.is_empty()),
1232                    signature: sig_raw.filter(|s| !s.is_empty()),
1233                },
1234            ))
1235        })
1236        .map(|rows| rows.filter_map(|r| r.ok()).collect())
1237        .unwrap_or_default()
1238    }
1239
1240    // -----------------------------------------------------------------------
1241    // Ledger write-through cache helpers
1242    // -----------------------------------------------------------------------
1243
1244    /// Plan E t-004: one-shot SQL scan that returns every `symbol_id`
1245    /// whose ledger contains a Constraint or Decision entry carrying a
1246    /// penalty role (`stale-api` / `audit-pending`). Used by
1247    /// `apply_constraint_penalties` to avoid an N-walk over the ledger
1248    /// per query.
1249    ///
1250    /// Returns an empty vec when the cache is empty (caller falls back
1251    /// to the per-candidate ledger walk).
1252    pub fn symbols_with_constraint_penalties(
1253        &self,
1254        ref_name: &str,
1255    ) -> rusqlite::Result<Vec<String>> {
1256        let pairs = self.symbols_with_constraint_penalties_scoped(ref_name)?;
1257        Ok(pairs.into_iter().map(|(sid, _)| sid).collect())
1258    }
1259
1260    /// Plan E t-008: like `symbols_with_constraint_penalties` but also
1261    /// returns the optional scope-glob list parsed from each entry's
1262    /// `body` JSON. Schema: `body` MAY be a JSON object containing
1263    /// `{"scope": ["glob1", "glob2", ...]}`. When present, the penalty
1264    /// applies only to symbols whose file matches at least one glob;
1265    /// when absent or unparseable, the penalty applies globally
1266    /// (preserves the Plan E t-004 contract).
1267    ///
1268    /// The same symbol_id may appear multiple times in the result when
1269    /// it has multiple penalty entries (e.g. one global + one scoped).
1270    /// The caller suppresses on ANY match (most permissive scope wins).
1271    pub fn symbols_with_constraint_penalties_scoped(
1272        &self,
1273        ref_name: &str,
1274    ) -> rusqlite::Result<Vec<(String, Option<Vec<String>>)>> {
1275        let mut stmt = self.conn.prepare(
1276            "SELECT symbol_id, body FROM asd_ledger_cache
1277             WHERE ref_name = ?1
1278               AND json_extract(body, '$.kind') IN ('decision', 'constraint')
1279               AND json_extract(body, '$.role') IN ('stale-api', 'audit-pending')",
1280        )?;
1281        let rows = stmt
1282            .query_map(params![ref_name], |r| {
1283                Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
1284            })?
1285            .filter_map(|r| r.ok())
1286            .map(|(sid, body)| {
1287                let scope = serde_json::from_str::<serde_json::Value>(&body)
1288                    .ok()
1289                    .and_then(|v| {
1290                        v.get("body").and_then(|b| {
1291                            // body field itself may be a JSON string or null
1292                            b.as_str()
1293                                .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
1294                                .or_else(|| Some(b.clone()))
1295                        })
1296                    })
1297                    .and_then(|inner| inner.get("scope").and_then(|s| s.as_array().cloned()))
1298                    .map(|arr| {
1299                        arr.into_iter()
1300                            .filter_map(|g| g.as_str().map(String::from))
1301                            .filter(|g| !g.is_empty())
1302                            .collect::<Vec<_>>()
1303                    })
1304                    .filter(|v| !v.is_empty());
1305                (sid, scope)
1306            })
1307            .collect::<Vec<_>>();
1308        Ok(rows)
1309    }
1310
1311    /// Number of ledger rows cached for this (symbol_id, ref_name) pair.
1312    /// Returns 0 when the symbol hasn't been cached yet — triggers git fallback.
1313    pub fn ledger_entry_count_for(&self, symbol_id: &str, ref_name: &str) -> usize {
1314        self.conn
1315            .query_row(
1316                "SELECT COUNT(*) FROM asd_ledger_cache WHERE symbol_id = ?1 AND ref_name = ?2",
1317                params![symbol_id, ref_name],
1318                |r| r.get::<_, i64>(0),
1319            )
1320            .unwrap_or(0) as usize
1321    }
1322
1323    /// Insert or replace a single ledger entry for `ref_name`.
1324    ///
1325    /// Field-test refinement (AcmeFlow, 2026-06-04): also backfills
1326    /// the symbol's row in `asd_search_fts` so the ledger_text /
1327    /// ledger_flags columns stay warm between reindexes. Previously these
1328    /// columns were ONLY populated at full `asd index` time, which meant
1329    /// any entries written via `asd think`/`asd ledger append` were
1330    /// invisible to search-time ledger ranking until the next reindex.
1331    /// The backfill is best-effort: if the symbol row doesn't exist in
1332    /// FTS yet (rare — symbol writes happen before any ledger writes),
1333    /// the UPDATE silently no-ops and the next reindex catches up.
1334    pub fn upsert_ledger_entry(&self, entry: &LedgerEntry, ref_name: &str) -> rusqlite::Result<()> {
1335        let body = serde_json::to_string(entry).unwrap_or_else(|_| "{}".to_string());
1336        self.conn.execute(
1337            "INSERT OR REPLACE INTO asd_ledger_cache (entry_id, symbol_id, ref_name, body)
1338             VALUES (?1, ?2, ?3, ?4)",
1339            params![entry.entry_id, entry.symbol_id, ref_name, body],
1340        )?;
1341
1342        // Refresh the denormalized FTS columns from the now-current cache
1343        // for this symbol. Concatenate all summaries for ledger_text,
1344        // dedupe kinds for ledger_flags.
1345        let summary_lower = entry.summary.to_lowercase();
1346        let kind_str = entry.kind.as_str();
1347        // Use COALESCE so the first entry on a symbol initializes from
1348        // empty; subsequent entries append a space + new summary.
1349        let _ = self.conn.execute(
1350            "UPDATE asd_search_fts
1351             SET ledger_text = CASE
1352                   WHEN ledger_text IS NULL OR ledger_text = '' THEN ?1
1353                   ELSE ledger_text || ' ' || ?1
1354                 END,
1355                 ledger_flags = CASE
1356                   WHEN ledger_flags IS NULL OR ledger_flags = '' THEN ?2
1357                   WHEN INSTR(',' || ledger_flags || ',', ',' || ?2 || ',') > 0 THEN ledger_flags
1358                   ELSE ledger_flags || ',' || ?2
1359                 END
1360             WHERE symbol_id = ?3",
1361            params![summary_lower, kind_str, entry.symbol_id],
1362        );
1363        Ok(())
1364    }
1365
1366    /// Return all ledger entries for a symbol (including superseded), newest first.
1367    /// The caller's `list_entries` default method handles supersede filtering.
1368    pub fn list_ledger_entries_for(
1369        &self,
1370        symbol_id: &str,
1371        ref_name: &str,
1372    ) -> rusqlite::Result<Vec<LedgerEntry>> {
1373        let mut stmt = self.conn.prepare(
1374            "SELECT body FROM asd_ledger_cache
1375             WHERE symbol_id = ?1 AND ref_name = ?2
1376             ORDER BY rowid DESC",
1377        )?;
1378        let entries = stmt
1379            .query_map(params![symbol_id, ref_name], |row| {
1380                let body: String = row.get(0)?;
1381                Ok(body)
1382            })?
1383            .filter_map(|r| r.ok())
1384            .filter_map(|body| serde_json::from_str::<LedgerEntry>(&body).ok())
1385            .collect();
1386        Ok(entries)
1387    }
1388
1389    /// Every cached ledger entry id for `ref_name`, without parsing bodies —
1390    /// the cheap side of the ledger integrity count.
1391    pub fn ledger_cache_entry_ids(&self, ref_name: &str) -> rusqlite::Result<Vec<String>> {
1392        let mut stmt = self
1393            .conn
1394            .prepare("SELECT entry_id FROM asd_ledger_cache WHERE ref_name = ?1")?;
1395        let ids = stmt
1396            .query_map(params![ref_name], |row| row.get::<_, String>(0))?
1397            .collect::<rusqlite::Result<Vec<_>>>()?;
1398        Ok(ids)
1399    }
1400
1401    /// Every cached ledger entry for `ref_name`. Rows whose body no longer
1402    /// parses come back as ids in the second vec rather than being skipped,
1403    /// so an integrity count cannot under-report what the cache holds.
1404    pub fn all_ledger_entries(
1405        &self,
1406        ref_name: &str,
1407    ) -> rusqlite::Result<(Vec<LedgerEntry>, Vec<String>)> {
1408        let mut stmt = self
1409            .conn
1410            .prepare("SELECT entry_id, body FROM asd_ledger_cache WHERE ref_name = ?1")?;
1411        let rows = stmt.query_map(params![ref_name], |row| {
1412            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1413        })?;
1414        let mut entries = Vec::new();
1415        let mut unparseable = Vec::new();
1416        for row in rows {
1417            let (entry_id, body) = row?;
1418            match serde_json::from_str::<LedgerEntry>(&body) {
1419                Ok(e) => entries.push(e),
1420                Err(_) => unparseable.push(entry_id),
1421            }
1422        }
1423        Ok((entries, unparseable))
1424    }
1425
1426    /// Bulk-insert ledger entries in a single transaction — used by `asd index`
1427    /// to reconcile the SQLite cache from the authoritative git store.
1428    /// `entries`: slice of `(symbol_id, LedgerEntry)` pairs.
1429    pub fn sync_ledger_entries(
1430        &self,
1431        entries: &[(String, LedgerEntry)],
1432        ref_name: &str,
1433    ) -> rusqlite::Result<()> {
1434        self.conn.execute_batch("BEGIN;")?;
1435        for (symbol_id, entry) in entries {
1436            let body = serde_json::to_string(entry).unwrap_or_else(|_| "{}".to_string());
1437            if let Err(err) = self.conn.execute(
1438                "INSERT OR REPLACE INTO asd_ledger_cache (entry_id, symbol_id, ref_name, body)
1439                 VALUES (?1, ?2, ?3, ?4)",
1440                params![entry.entry_id, symbol_id, ref_name, body],
1441            ) {
1442                eprintln!("asd: ledger cache sync warning — {err}");
1443            }
1444        }
1445        self.conn.execute_batch("COMMIT;")?;
1446        Ok(())
1447    }
1448
1449    // -----------------------------------------------------------------------
1450    // Effects write-through cache helpers
1451    // -----------------------------------------------------------------------
1452
1453    /// True when an `EffectDecl` for this (symbol_id, ref_name) is cached.
1454    pub fn effects_cached_for(&self, symbol_id: &str, ref_name: &str) -> bool {
1455        self.conn
1456            .query_row(
1457                "SELECT 1 FROM asd_effects_cache WHERE symbol_id = ?1 AND ref_name = ?2 LIMIT 1",
1458                params![symbol_id, ref_name],
1459                |_| Ok(()),
1460            )
1461            .is_ok()
1462    }
1463
1464    /// Insert or replace the `EffectDecl` for a symbol.
1465    pub fn upsert_effects(
1466        &self,
1467        symbol_id: &str,
1468        ref_name: &str,
1469        decl: &EffectDecl,
1470    ) -> rusqlite::Result<()> {
1471        let body = serde_json::to_string(decl).unwrap_or_else(|_| "{}".to_string());
1472        self.conn.execute(
1473            "INSERT OR REPLACE INTO asd_effects_cache (symbol_id, ref_name, body)
1474             VALUES (?1, ?2, ?3)",
1475            params![symbol_id, ref_name, body],
1476        )?;
1477        Ok(())
1478    }
1479
1480    /// Drop the cached `EffectDecl`s of symbols that left the index.
1481    pub fn delete_effects_for(
1482        &self,
1483        symbol_ids: &[String],
1484        ref_name: &str,
1485    ) -> rusqlite::Result<()> {
1486        if symbol_ids.is_empty() {
1487            return Ok(());
1488        }
1489        self.conn.execute_batch("BEGIN;")?;
1490        for symbol_id in symbol_ids {
1491            if let Err(e) = self.conn.execute(
1492                "DELETE FROM asd_effects_cache WHERE symbol_id = ?1 AND ref_name = ?2",
1493                params![symbol_id, ref_name],
1494            ) {
1495                let _ = self.conn.execute_batch("ROLLBACK;");
1496                return Err(e);
1497            }
1498        }
1499        self.conn.execute_batch("COMMIT;")
1500    }
1501
1502    /// Return the cached `EffectDecl` for a symbol, or `None` if not cached.
1503    pub fn get_effects_for(
1504        &self,
1505        symbol_id: &str,
1506        ref_name: &str,
1507    ) -> rusqlite::Result<Option<EffectDecl>> {
1508        match self.conn.query_row(
1509            "SELECT body FROM asd_effects_cache WHERE symbol_id = ?1 AND ref_name = ?2 LIMIT 1",
1510            params![symbol_id, ref_name],
1511            |row| row.get::<_, String>(0),
1512        ) {
1513            Ok(body) => Ok(serde_json::from_str::<EffectDecl>(&body).ok()),
1514            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1515            Err(e) => Err(e),
1516        }
1517    }
1518
1519    /// Bulk-insert effects in a single transaction.
1520    /// `entries`: slice of `(symbol_id, EffectDecl)` pairs.
1521    pub fn sync_effects(
1522        &self,
1523        entries: &[(String, EffectDecl)],
1524        ref_name: &str,
1525    ) -> rusqlite::Result<()> {
1526        self.conn.execute_batch("BEGIN;")?;
1527        for (symbol_id, decl) in entries {
1528            let body = serde_json::to_string(decl).unwrap_or_else(|_| "{}".to_string());
1529            if let Err(err) = self.conn.execute(
1530                "INSERT OR REPLACE INTO asd_effects_cache (symbol_id, ref_name, body)
1531                 VALUES (?1, ?2, ?3)",
1532                params![symbol_id, ref_name, body],
1533            ) {
1534                eprintln!("asd: effects cache sync warning — {err}");
1535            }
1536        }
1537        self.conn.execute_batch("COMMIT;")?;
1538        Ok(())
1539    }
1540
1541    // -----------------------------------------------------------------------
1542    // Feedback write-through cache helpers
1543    // -----------------------------------------------------------------------
1544
1545    /// Number of feedback rows currently cached in SQLite.
1546    /// Used as a guard: if 0, the caller should fall back to git.
1547    pub fn feedback_count(&self) -> usize {
1548        self.conn
1549            .query_row("SELECT COUNT(*) FROM asd_feedback", [], |r| {
1550                r.get::<_, i64>(0)
1551            })
1552            .unwrap_or(0) as usize
1553    }
1554
1555    /// Insert or replace a single feedback entry.
1556    ///
1557    /// `created_at` is stored as an RFC 3339 string so it sorts correctly
1558    /// in text order and round-trips through `list_all_feedback` accurately.
1559    pub fn upsert_feedback(&self, e: &FeedbackEntry) -> rusqlite::Result<()> {
1560        self.conn.execute(
1561            "INSERT OR REPLACE INTO asd_feedback
1562             (entry_id, symbol_id, symbol_qname, query, verdict, author, created_at, note, file_scope, expires_at,
1563              withdrawn_at, withdrawn_by, withdrawn_reason)
1564             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
1565            rusqlite::params![
1566                e.entry_id,
1567                e.symbol_id,
1568                e.symbol_qname,
1569                e.query,
1570                e.verdict.as_str(),
1571                e.author,
1572                e.created_at.to_rfc3339(),
1573                e.note,
1574                e.file_scope,
1575                // Plan J t-014: persist expires_at so the FTS cache
1576                // round-trips it (was always None pre-1.0.48).
1577                e.expires_at.map(|t| t.to_rfc3339()),
1578                // feedback-lifecycle t-003: withdrawal must round-trip too.
1579                // If it lived only in the git tree, `list_all`'s SQLite fast
1580                // path would keep serving the verdict as live — the exact
1581                // tree/cache divergence this plan exists to prevent.
1582                e.withdrawn_at.map(|t| t.to_rfc3339()),
1583                e.withdrawn_by,
1584                e.withdrawn_reason,
1585            ],
1586        )?;
1587        Ok(())
1588    }
1589
1590    /// Return all feedback entries from the SQLite cache, newest first.
1591    /// Hard-delete one feedback row from the cache. Returns rows removed, so
1592    /// a caller can tell "gone" from "was never here" — a purge that reports
1593    /// success without touching anything is how the two stores drift apart.
1594    ///
1595    /// Only for `asd feedback purge`. Retiring a verdict normally goes through
1596    /// `upsert_feedback` via `record`, which keeps the row and marks it.
1597    pub fn delete_feedback(&self, entry_id: &str) -> rusqlite::Result<usize> {
1598        self.conn.execute(
1599            "DELETE FROM asd_feedback WHERE entry_id = ?1",
1600            rusqlite::params![entry_id],
1601        )
1602    }
1603
1604    pub fn list_all_feedback(&self) -> rusqlite::Result<Vec<FeedbackEntry>> {
1605        let mut stmt = self.conn.prepare(
1606            "SELECT entry_id, symbol_id, symbol_qname, query, verdict, author, created_at, note, file_scope, expires_at,
1607                    withdrawn_at, withdrawn_by, withdrawn_reason
1608             FROM asd_feedback
1609             ORDER BY created_at DESC",
1610        )?;
1611        let entries = stmt
1612            .query_map([], |row| {
1613                let verdict_str: String = row.get(4)?;
1614                let verdict =
1615                    FeedbackVerdict::from_str(&verdict_str).unwrap_or(FeedbackVerdict::Useful);
1616                let ts_str: String = row.get(6)?;
1617                let created_at: DateTime<Utc> = DateTime::parse_from_rfc3339(&ts_str)
1618                    .map(|d| d.with_timezone(&Utc))
1619                    .unwrap_or_else(|_| Utc::now());
1620                // Plan J t-014: round-trip expires_at through the
1621                // cache. Older DBs that pre-date the column have it
1622                // backfilled as NULL by the ALTER migration above.
1623                let expires_at: Option<DateTime<Utc>> = row
1624                    .get::<_, Option<String>>(9)?
1625                    .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
1626                    .map(|d| d.with_timezone(&Utc));
1627                let withdrawn_at: Option<DateTime<Utc>> = row
1628                    .get::<_, Option<String>>(10)?
1629                    .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
1630                    .map(|d| d.with_timezone(&Utc));
1631                Ok(FeedbackEntry {
1632                    entry_id: row.get(0)?,
1633                    symbol_id: row.get(1)?,
1634                    symbol_qname: row.get(2)?,
1635                    query: row.get(3)?,
1636                    verdict,
1637                    author: row.get(5)?,
1638                    created_at,
1639                    note: row.get(7)?,
1640                    file_scope: row.get(8)?,
1641                    expires_at,
1642                    withdrawn_at,
1643                    withdrawn_by: row.get(11)?,
1644                    withdrawn_reason: row.get(12)?,
1645                })
1646            })?
1647            .filter_map(|r| r.ok())
1648            .collect();
1649        Ok(entries)
1650    }
1651
1652    /// Bulk-insert feedback entries from an authoritative slice (e.g. from the
1653    /// git store after `asd index`).  Uses INSERT OR REPLACE so existing rows
1654    /// are overwritten.  Wraps all inserts in a single transaction for speed.
1655    pub fn sync_feedback_entries(&self, entries: &[FeedbackEntry]) -> rusqlite::Result<()> {
1656        self.conn.execute_batch("BEGIN;")?;
1657        for e in entries {
1658            if let Err(err) = self.upsert_feedback(e) {
1659                // Best-effort: log and continue rather than aborting the whole sync.
1660                eprintln!("asd: feedback sync warning — {err}");
1661            }
1662        }
1663        self.conn.execute_batch("COMMIT;")?;
1664        Ok(())
1665    }
1666
1667    /// Secondary file-stem scan: return one representative symbol per file
1668    /// whose stored path contains `token` (case-insensitive substring match).
1669    ///
1670    /// Used to inject view/render files that BM25 misses when the query term
1671    /// appears only in the file name and not in any indexed symbol text.
1672    /// Returns at most `limit` (symbol_id, qname, file) triples.
1673    pub fn file_stem_candidates(
1674        &self,
1675        token: &str,
1676        filters: &FtsFilters,
1677        limit: usize,
1678    ) -> rusqlite::Result<Vec<FtsHit>> {
1679        if token.len() < 2 {
1680            return Ok(vec![]);
1681        }
1682        let test_clause = tests_clause(filters.include_tests, filters.tests_only);
1683        let kind_clause = filters
1684            .kind
1685            .as_deref()
1686            .map(|k| format!("AND kind = '{}'", k.to_lowercase().replace('\'', "")))
1687            .unwrap_or_default();
1688        // One row per distinct file_orig; pick the lowest line number (class/module
1689        // declaration) as the representative symbol for that file.
1690        // Use MIN(line) in SELECT — SQLite picks the other non-aggregated columns
1691        // from the same row that has the minimum, so no HAVING clause is needed.
1692        let sql = format!(
1693            "SELECT symbol_id, language, kind, MIN(line) as line, doc,
1694                    qname_orig, sig_orig, file_orig, tier,
1695                    ledger_text, ledger_flags
1696             FROM asd_search_fts
1697             WHERE lower(file_orig) LIKE '%' || lower(?1) || '%'
1698             {test_clause}
1699             {kind_clause}
1700             GROUP BY file_orig
1701             LIMIT {limit}"
1702        );
1703        let token_owned = token.to_lowercase();
1704        let mut stmt = self.conn.prepare(&sql)?;
1705        let hits = stmt
1706            .query_map(params![token_owned], |row| {
1707                let sig_orig: Option<String> = row.get(6)?;
1708                let tier_str: String = row.get(8).unwrap_or_default();
1709                let tier: SymbolTier = tier_str.parse().unwrap_or(0);
1710                Ok(FtsHit {
1711                    bm25_score: 0.0,
1712                    symbol_id: row.get(0)?,
1713                    language: row.get(1)?,
1714                    kind: row.get(2)?,
1715                    line: row.get::<_, u32>(3).unwrap_or(0),
1716                    doc: row.get(4)?,
1717                    qname: row.get(5)?,
1718                    signature: sig_orig.filter(|s| !s.is_empty()),
1719                    file: row.get(7)?,
1720                    tier,
1721                    ledger_text: row.get::<_, String>(9).unwrap_or_default(),
1722                    ledger_flags: row.get::<_, String>(10).unwrap_or_default(),
1723                })
1724            })?
1725            .filter_map(|r| r.ok())
1726            .collect();
1727        Ok(hits)
1728    }
1729
1730    /// Batch version of [`file_stem_candidates`]: query all `tokens` in a
1731    /// single SQL UNION ALL instead of one round-trip per token.
1732    ///
1733    /// Returns one representative symbol per distinct file (lowest line number),
1734    /// deduplicated across token arms.  Results are capped at `limit`.
1735    ///
1736    /// Falls back to an empty vec if `tokens` is empty or all tokens are
1737    /// shorter than 2 characters.
1738    pub fn file_stem_candidates_batch(
1739        &self,
1740        tokens: &[String],
1741        filters: &FtsFilters,
1742        limit: usize,
1743    ) -> rusqlite::Result<Vec<FtsHit>> {
1744        // Keep only tokens long enough to be useful stem matches.
1745        let valid: Vec<String> = tokens
1746            .iter()
1747            .filter(|t| t.len() >= 2)
1748            .map(|t| t.to_lowercase())
1749            .collect();
1750        if valid.is_empty() {
1751            return Ok(vec![]);
1752        }
1753
1754        let test_clause = tests_clause(filters.include_tests, filters.tests_only);
1755        let kind_clause = filters
1756            .kind
1757            .as_deref()
1758            .map(|k| format!("AND kind = '{}'", k.to_lowercase().replace('\'', "")))
1759            .unwrap_or_default();
1760
1761        // Build one SELECT arm per token; each arm uses ?N positional binding.
1762        let arm_sql = |n: usize| -> String {
1763            format!(
1764                "SELECT symbol_id, language, kind, MIN(line) AS line, doc,
1765                        qname_orig, sig_orig, file_orig, tier,
1766                        ledger_text, ledger_flags
1767                 FROM asd_search_fts
1768                 WHERE lower(file_orig) LIKE '%' || ?{n} || '%'
1769                 {test_clause}
1770                 {kind_clause}
1771                 GROUP BY file_orig"
1772            )
1773        };
1774
1775        let sql = valid
1776            .iter()
1777            .enumerate()
1778            .map(|(i, _)| arm_sql(i + 1))
1779            .collect::<Vec<_>>()
1780            .join("\nUNION ALL\n");
1781
1782        let mut stmt = self.conn.prepare(&sql)?;
1783        let mut seen_files: std::collections::HashSet<String> = std::collections::HashSet::new();
1784        let hits: Vec<FtsHit> = stmt
1785            .query_map(rusqlite::params_from_iter(valid.iter()), |row| {
1786                let sig_orig: Option<String> = row.get(6)?;
1787                let tier_str: String = row.get(8).unwrap_or_default();
1788                let tier: SymbolTier = tier_str.parse().unwrap_or(0);
1789                Ok(FtsHit {
1790                    bm25_score: 0.0,
1791                    symbol_id: row.get(0)?,
1792                    language: row.get(1)?,
1793                    kind: row.get(2)?,
1794                    line: row.get::<_, u32>(3).unwrap_or(0),
1795                    doc: row.get(4)?,
1796                    qname: row.get(5)?,
1797                    signature: sig_orig.filter(|s| !s.is_empty()),
1798                    file: row.get(7)?,
1799                    tier,
1800                    ledger_text: row.get::<_, String>(9).unwrap_or_default(),
1801                    ledger_flags: row.get::<_, String>(10).unwrap_or_default(),
1802                })
1803            })?
1804            .filter_map(|r| r.ok())
1805            .filter(|h| seen_files.insert(h.file.clone()))
1806            .take(limit)
1807            .collect();
1808        Ok(hits)
1809    }
1810}
1811
1812// ---------------------------------------------------------------------------
1813// Hybrid reranking boost
1814// ---------------------------------------------------------------------------
1815
1816/// Rust-side score boost applied on top of BM25 after FTS returns hits.
1817///
1818/// Two components, both measured against `tokens` (lowercased query words):
1819///
1820/// **Path word boost (+1.5 / token)** — camelCase-expands each file path
1821/// segment and checks for exact word matches. "drift" matches the segment
1822/// "AcmeFlow" (expanded → ["session","drift"]) but NOT "overlap" for
1823/// the token "over". Rewards symbols that live in a subsystem named after
1824/// a query concept without rewarding false substring matches.
1825///
1826/// **Name segment boost (+2.0 / token)** — camelCase-expands only the last
1827/// dotted segment of `qname` (the function/method name, stripped of
1828/// namespace). "drift playhead" matches both words in `refreshDriftPlayhead`
1829/// (+4.0) but only "playhead" in `isInPlayheadHandle` (+2.0), correcting
1830/// the case where a single-token exact match outranks a dual-token match.
1831pub fn hybrid_boost(hit: &FtsHit, tokens: &[String]) -> f64 {
1832    if tokens.is_empty() {
1833        return 0.0;
1834    }
1835
1836    // Expand file path into whole camelCase words.
1837    let path_words: Vec<String> = hit
1838        .file
1839        .split(|c: char| c == '/' || c == '\\' || c == '.')
1840        .filter(|s| !s.is_empty())
1841        .flat_map(|seg| {
1842            let mut words: Vec<String> = vec![seg.to_lowercase()];
1843            words.extend(split_camel(seg).iter().map(|w| w.to_lowercase()));
1844            words
1845        })
1846        .filter(|w| w.len() >= 2)
1847        .collect();
1848
1849    let path_boost = tokens
1850        .iter()
1851        .filter(|t| path_words.iter().any(|w| w == t.as_str()))
1852        .count() as f64
1853        * 2.5;
1854
1855    // Expand last qname segment (function name) into camelCase words.
1856    let last_seg = hit.qname.rsplit('.').next().unwrap_or(&hit.qname);
1857    let name_words: Vec<String> = {
1858        let mut words: Vec<String> = vec![last_seg.to_lowercase()];
1859        words.extend(split_camel(last_seg).iter().map(|w| w.to_lowercase()));
1860        words
1861    };
1862
1863    let name_boost = tokens
1864        .iter()
1865        .filter(|t| name_words.iter().any(|w| w == t.as_str()))
1866        .count() as f64
1867        * 2.0;
1868
1869    // Phrase match bonus (t-005): consecutive query tokens that appear as an
1870    // adjacent subsequence in the name word list represent a domain concept
1871    // ("drift playhead", "refresh lane", etc.). Reward each adjacent pair
1872    // with +3.0 so compound-concept queries rank the most specific symbol first.
1873    let phrase_bonus = if tokens.len() >= 2 {
1874        let mut bonus = 0.0f64;
1875        let lower_tokens: Vec<&str> = tokens.iter().map(|t| t.as_str()).collect();
1876        for window_start in 0..name_words.len().saturating_sub(1) {
1877            for pair_len in 2..=(lower_tokens.len().min(name_words.len() - window_start)) {
1878                let name_slice = &name_words[window_start..window_start + pair_len];
1879                for tok_start in 0..=lower_tokens.len().saturating_sub(pair_len) {
1880                    let tok_slice = &lower_tokens[tok_start..tok_start + pair_len];
1881                    if name_slice
1882                        .iter()
1883                        .map(|s| s.as_str())
1884                        .eq(tok_slice.iter().copied())
1885                    {
1886                        bonus = bonus.max(pair_len as f64 * 3.0);
1887                    }
1888                }
1889            }
1890        }
1891        bonus
1892    } else {
1893        0.0
1894    };
1895
1896    // Utility penalty: Preview/Sample/Editor/Generated symbols ranked below production.
1897    let tier_penalty = if hit.tier == 1 { -2.0 } else { 0.0 };
1898
1899    // t-001: Penalize results where ALL matched tokens are generic unless a
1900    // domain-specific co-occurring token also matched in the name or path.
1901    // Generic tokens produce noise when used alone (e.g. "state", "update").
1902    // Generic + project-ambiguous tokens: terms that match many symbols without
1903    // anchoring a specific domain concept. "playhead" is ambiguous in this project
1904    // (many UI and scheduler files contain it); require a co-occurring anchor.
1905    const GENERIC_TOKENS: &[&str] = &[
1906        "state",
1907        "update",
1908        "local",
1909        "position",
1910        "value",
1911        "data",
1912        "item",
1913        "list",
1914        "info",
1915        "event",
1916        "action",
1917        "type",
1918        "mode",
1919        "flag",
1920        "current",
1921        "node",
1922        "result",
1923        "status",
1924        "record",
1925        "entry",
1926        "object",
1927        "element",
1928        "get",
1929        "set",
1930        "add",
1931        "remove",
1932        "reset",
1933        "apply",
1934        "build",
1935        "make",
1936        "playhead",
1937        "cursor",
1938        "progress",
1939        "indicator",
1940        "tick",
1941    ];
1942    let matched_tokens: Vec<&str> = tokens
1943        .iter()
1944        .filter(|t| {
1945            name_words.iter().any(|w| w == t.as_str()) || path_words.iter().any(|w| w == t.as_str())
1946        })
1947        .map(|t| t.as_str())
1948        .collect();
1949    let generic_penalty = if !matched_tokens.is_empty()
1950        && matched_tokens.iter().all(|t| GENERIC_TOKENS.contains(t))
1951    {
1952        -2.5 // increased from -1.5 to push generic-only matches below domain-anchored ones
1953    } else {
1954        0.0
1955    };
1956
1957    path_boost + name_boost + phrase_bonus + tier_penalty + generic_penalty
1958}
1959
1960// ---------------------------------------------------------------------------
1961// Staleness helpers
1962// ---------------------------------------------------------------------------
1963
1964/// Format a unix timestamp age as a human-readable string ("3h ago", "just now").
1965pub fn format_age(indexed_at: i64) -> String {
1966    let now = std::time::SystemTime::now()
1967        .duration_since(std::time::UNIX_EPOCH)
1968        .map(|d| d.as_secs() as i64)
1969        .unwrap_or(0);
1970    let secs = (now - indexed_at).max(0);
1971    if secs < 60 {
1972        "just now".to_string()
1973    } else if secs < 3600 {
1974        format!("{}m ago", secs / 60)
1975    } else if secs < 86400 {
1976        format!("{}h ago", secs / 3600)
1977    } else {
1978        format!("{}d ago", secs / 86400)
1979    }
1980}
1981
1982/// Return a stale-index warning string if the index is older than
1983/// `threshold_secs` (default: 3600 = 1 hour), or `None` if fresh.
1984///
1985/// Distinguishes three degraded states:
1986/// - **Empty** — FTS table has never been populated.
1987/// - **Symbols fresh / FTS stale** — the last `asd index` run recorded an FTS
1988///   rebuild failure (e.g. "database is locked"). Symbol data in git is current
1989///   but search ranking reflects an older snapshot.
1990/// - **Stale** — the last successful FTS rebuild is older than `threshold_secs`.
1991pub fn stale_warning(db_path: &std::path::Path, threshold_secs: u64) -> Option<String> {
1992    let fts = SearchFtsDb::open(db_path).ok()?;
1993    if !fts.has_data() {
1994        return Some("asd: index is empty — run 'asd index <dir>' to build it.".to_string());
1995    }
1996
1997    // If the last index run recorded an FTS failure, surface it immediately
1998    // regardless of age — the FTS may be out of sync with the symbol data.
1999    if fts.fts_last_rebuild_ok() == Some(false) {
2000        return Some(
2001            "asd: symbols are indexed but the FTS search index failed to rebuild \
2002             during the last 'asd index' run (database may have been locked). \
2003             Search ranking may be stale — re-run 'asd index <dir>' to repair."
2004                .to_string(),
2005        );
2006    }
2007
2008    let indexed_at = fts.last_indexed_at()?;
2009    let now = std::time::SystemTime::now()
2010        .duration_since(std::time::UNIX_EPOCH)
2011        .map(|d| d.as_secs() as i64)
2012        .unwrap_or(0);
2013    let age_secs = (now - indexed_at).max(0) as u64;
2014    if age_secs > threshold_secs {
2015        Some(format!(
2016            "asd: index may be stale — last indexed {}. Run 'asd index <dir>' to update.",
2017            format_age(indexed_at)
2018        ))
2019    } else {
2020        None
2021    }
2022}
2023
2024/// AcmeFlow refinement (1.0.77): same as `stale_warning` but
2025/// returns a struct so callers can distinguish a soft "index is old
2026/// but results came back fine" hint from a hard "FTS is broken /
2027/// empty" alert. The MCP response includes `stale_severity` as a
2028/// machine-readable field so downstream UIs can render appropriately
2029/// (Craig decision Q7: yes, future-proof rendering).
2030#[derive(Debug, Clone, serde::Serialize)]
2031pub struct StaleWarning {
2032    pub message: String,
2033    pub severity: StaleSeverity,
2034    pub age_secs: u64,
2035    pub indexed_at: Option<i64>,
2036}
2037
2038#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
2039#[serde(rename_all = "snake_case")]
2040pub enum StaleSeverity {
2041    /// FTS empty or last rebuild failed — search ranking IS broken.
2042    /// Loud surfacing is correct everywhere; never demote.
2043    Critical,
2044    /// Index is past the age threshold but otherwise healthy. Soft
2045    /// callers (prepare-change, context_for) can demote this to a
2046    /// hints array when the queried symbols resolved fine and their
2047    /// ledger entries are newer than the index.
2048    Soft,
2049}
2050
2051pub fn stale_warning_classified(
2052    db_path: &std::path::Path,
2053    threshold_secs: u64,
2054) -> Option<StaleWarning> {
2055    let fts = SearchFtsDb::open(db_path).ok()?;
2056    if !fts.has_data() {
2057        return Some(StaleWarning {
2058            message: "asd: index is empty — run 'asd index <dir>' to build it.".to_string(),
2059            severity: StaleSeverity::Critical,
2060            age_secs: 0,
2061            indexed_at: None,
2062        });
2063    }
2064    if fts.fts_last_rebuild_ok() == Some(false) {
2065        return Some(StaleWarning {
2066            message: "asd: symbols are indexed but the FTS search index failed to rebuild \
2067                      during the last 'asd index' run (database may have been locked). \
2068                      Search ranking may be stale — re-run 'asd index <dir>' to repair."
2069                .to_string(),
2070            severity: StaleSeverity::Critical,
2071            age_secs: 0,
2072            indexed_at: None,
2073        });
2074    }
2075    let indexed_at = fts.last_indexed_at()?;
2076    let now = std::time::SystemTime::now()
2077        .duration_since(std::time::UNIX_EPOCH)
2078        .map(|d| d.as_secs() as i64)
2079        .unwrap_or(0);
2080    let age_secs = (now - indexed_at).max(0) as u64;
2081    if age_secs > threshold_secs {
2082        Some(StaleWarning {
2083            message: format!(
2084                "asd: index may be stale — last indexed {}. Run 'asd index <dir>' to update.",
2085                format_age(indexed_at)
2086            ),
2087            severity: StaleSeverity::Soft,
2088            age_secs,
2089            indexed_at: Some(indexed_at),
2090        })
2091    } else {
2092        None
2093    }
2094}
2095
2096/// AcmeFlow refinement: 24h soft-threshold for handlers that
2097/// have a "did the query resolve" signal (prepare-change,
2098/// context_for). Matches a typical dev day — index built this
2099/// morning is fine all afternoon. Other commands (status, search,
2100/// investigate) keep the 1h legacy threshold via `stale_warning`
2101/// since they ARE the index-health signal.
2102pub const SOFT_STALE_THRESHOLD_SECS: u64 = 86_400;
2103
2104/// Plan J t-005: reconcile the two divergent symbol counts that
2105/// `asd status` and `asd health` were each reporting in isolation.
2106///
2107/// - **`asg_symbols`** — `len(/asd/v1/index/by-qname)`, the canonical
2108///   ASG view. This is what `health` already returned as the bare
2109///   `symbol_count`. Authoritative for "what does the indexed graph
2110///   actually contain right now."
2111/// - **`fts_symbols`** — `SearchFtsDb::symbol_count()`, the count
2112///   inside the search cache. This is what `status` already returned
2113///   as `symbols`. Authoritative for "what will ranked queries see."
2114///
2115/// They diverge when:
2116/// - An `asd index` run wrote symbols to the ASG tree but the FTS
2117///   rebuild failed mid-pass (e.g. `database is locked`).
2118/// - A `hydrate` repopulated the ASG from a sidecar without
2119///   re-running FTS (rare, but the call surfaces have changed across
2120///   M22–M24 and the wiring isn't guaranteed).
2121/// - Symbol-level migrations (`asd ledger-rebind`, `asd
2122///   ledger-supersede`) touch the qname tree but skip FTS.
2123///
2124/// Returns `null`-shaped guidance fields when the two agree, so the
2125/// hot path in `status` / `health` adds zero ceremony on a healthy
2126/// repo. When they diverge, includes an `advice` string the agent
2127/// can act on directly.
2128pub fn compute_index_consistency(asg_symbols: usize, fts_symbols: usize) -> serde_json::Value {
2129    let delta = asg_symbols as i64 - fts_symbols as i64;
2130    if delta == 0 {
2131        // Token economy (1.0.79): return Null when consistent.
2132        // The agent infers "indexes agree" from absence; emitting
2133        // a 5-field block to say "everything is fine" is pure
2134        // bloat. Callers (status, health) can pair with
2135        // `drop_empty_top_level` to omit the field from output.
2136        return serde_json::Value::Null;
2137    }
2138    let advice = if delta > 0 {
2139        format!(
2140            "ASG has {delta} symbol{p} not in the FTS search cache — run 'asd index' to rebuild the search index.",
2141            p = if delta == 1 { "" } else { "s" }
2142        )
2143    } else {
2144        // FTS > ASG. Rare — usually means FTS holds symbols whose
2145        // ASG entries were deleted (e.g. `asd ledger-withdraw`
2146        // followed by no reindex). Same fix: rebuild FTS.
2147        let extra = -delta;
2148        format!(
2149            "FTS holds {extra} stale symbol{p} no longer in the ASG — run 'asd index' to rebuild.",
2150            p = if extra == 1 { "" } else { "s" }
2151        )
2152    };
2153    serde_json::json!({
2154        "asg_symbols": asg_symbols,
2155        "fts_symbols": fts_symbols,
2156        "delta": delta,
2157        "consistent": false,
2158        "advice": advice,
2159    })
2160}
2161
2162// ---------------------------------------------------------------------------
2163// Agent output trimming
2164// ---------------------------------------------------------------------------
2165
2166/// Default token budget for `--agent` mode (8 000 tokens ≈ 32 000 chars).
2167pub const AGENT_DEFAULT_BUDGET: usize = 8_000;
2168
2169/// Trim a JSON value for LLM consumption.
2170///
2171/// 1. Recursively removes bulk fields: `body`, `doc`, `tokens`.
2172/// 2. Collapses low-signal arrays (`callers`, `callees`, `notes`,
2173///    `decisions_and_notes`, `proofs`, `ownership`, `commits`)
2174///    to at most `max_list` items.
2175/// 3. In `callers` / `callees` arrays, keeps only `qname` + `file`.
2176/// 4. Limits `recently_touched` to 3 files × `max_list` commits.
2177///
2178/// Returns the trimmed value. Does **not** mutate the input.
2179pub fn trim_for_agent(v: &serde_json::Value, max_list: usize) -> serde_json::Value {
2180    use serde_json::Value;
2181
2182    // Fields to drop entirely.
2183    const DROP_FIELDS: &[&str] = &["body", "doc", "tokens"];
2184    // Arrays to truncate to max_list.
2185    const TRUNCATE_ARRAYS: &[&str] = &[
2186        "callers",
2187        "callees",
2188        "notes",
2189        "decisions_and_notes",
2190        "proofs",
2191        "ownership",
2192        "other_ledger",
2193    ];
2194    // Arrays where we simplify each item to {qname, file} only.
2195    const SIMPLIFY_REFS: &[&str] = &["callers", "callees"];
2196
2197    match v {
2198        Value::Object(map) => {
2199            let mut out = serde_json::Map::new();
2200            for (k, val) in map {
2201                if DROP_FIELDS.contains(&k.as_str()) {
2202                    continue;
2203                }
2204                let trimmed = trim_for_agent(val, max_list);
2205                let final_val = if TRUNCATE_ARRAYS.contains(&k.as_str()) {
2206                    if let Value::Array(arr) = &trimmed {
2207                        let slice: Vec<Value> = arr
2208                            .iter()
2209                            .take(max_list)
2210                            .map(|item| {
2211                                if SIMPLIFY_REFS.contains(&k.as_str()) {
2212                                    // Keep only qname + file for call graph refs.
2213                                    if let Value::Object(obj) = item {
2214                                        let mut mini = serde_json::Map::new();
2215                                        if let Some(q) = obj.get("qname") {
2216                                            mini.insert("qname".into(), q.clone());
2217                                        }
2218                                        if let Some(f) = obj.get("file") {
2219                                            mini.insert("file".into(), f.clone());
2220                                        }
2221                                        Value::Object(mini)
2222                                    } else {
2223                                        item.clone()
2224                                    }
2225                                } else {
2226                                    item.clone()
2227                                }
2228                            })
2229                            .collect();
2230                        let truncated = arr.len() > max_list;
2231                        if truncated {
2232                            Value::Array({
2233                                let mut s = slice;
2234                                s.push(serde_json::json!(format!(
2235                                    "... {} more",
2236                                    arr.len() - max_list
2237                                )));
2238                                s
2239                            })
2240                        } else {
2241                            Value::Array(slice)
2242                        }
2243                    } else {
2244                        trimmed
2245                    }
2246                } else if k == "recently_touched" {
2247                    // Cap to 3 files, each with max_list commits.
2248                    if let Value::Array(files) = &trimmed {
2249                        let capped: Vec<Value> = files
2250                            .iter()
2251                            .take(3)
2252                            .map(|file_entry| {
2253                                if let Value::Object(obj) = file_entry {
2254                                    let mut m = obj.clone();
2255                                    if let Some(Value::Array(commits)) = m.get_mut("commits") {
2256                                        commits.truncate(max_list);
2257                                    }
2258                                    Value::Object(m)
2259                                } else {
2260                                    file_entry.clone()
2261                                }
2262                            })
2263                            .collect();
2264                        Value::Array(capped)
2265                    } else {
2266                        trimmed
2267                    }
2268                } else {
2269                    trimmed
2270                };
2271                out.insert(k.clone(), final_val);
2272            }
2273            Value::Object(out)
2274        }
2275        Value::Array(arr) => {
2276            Value::Array(arr.iter().map(|i| trim_for_agent(i, max_list)).collect())
2277        }
2278        other => other.clone(),
2279    }
2280}
2281
2282/// Estimate token count from a JSON string (rough: 1 token ≈ 4 chars).
2283pub fn estimate_tokens(json: &str) -> usize {
2284    json.len().saturating_add(3) / 4
2285}
2286
2287// ---------------------------------------------------------------------------
2288// Intent mode
2289// ---------------------------------------------------------------------------
2290
2291/// Parse and validate an intent string. Returns a static str or `None`
2292/// if the value is unrecognised.
2293///
2294/// Valid values: `bugfix`, `feature`, `refactor`, `test`, `architecture`, `ui`.
2295pub fn parse_intent(s: &str) -> Option<&'static str> {
2296    match s.to_lowercase().as_str() {
2297        "bugfix" => Some("bugfix"),
2298        "feature" => Some("feature"),
2299        "refactor" => Some("refactor"),
2300        "test" => Some("test"),
2301        "architecture" => Some("architecture"),
2302        "ui" => Some("ui"),
2303        _ => None,
2304    }
2305}
2306
2307/// One-line agent guidance for each intent.
2308pub fn intent_focus(intent: &str) -> &'static str {
2309    match intent {
2310        "bugfix" => {
2311            "Focus: callers that may be broken, effects, invariants to preserve, affected tests."
2312        }
2313        "feature" => "Focus: callees to extend, ownership boundaries, empty extension points.",
2314        "refactor" => {
2315            "Focus: callers (blast radius), invariants that must hold, ownership constraints."
2316        }
2317        "test" => {
2318            "Focus: affected test symbols, effects under test, existing proof ledger entries."
2319        }
2320        "architecture" => {
2321            "Focus: invariants, ownership boundaries, layer grouping, cross-layer effects."
2322        }
2323        "ui" => "Focus: UI/ViewModel layers, effects that touch display state, scheduler coupling.",
2324        _ => "",
2325    }
2326}
2327
2328/// Return the preferred layer display order for an intent.
2329/// The standard order is used for unlisted layers.
2330pub fn intent_layer_order(intent: &str) -> &'static [&'static str] {
2331    match intent {
2332        "ui" => &[
2333            "ui",
2334            "viewmodel",
2335            "scheduler",
2336            "core_model",
2337            "persistence",
2338            "utility",
2339            "tests",
2340            "other",
2341        ],
2342        "architecture" => &[
2343            "core_model",
2344            "persistence",
2345            "scheduler",
2346            "viewmodel",
2347            "ui",
2348            "utility",
2349            "tests",
2350            "other",
2351        ],
2352        "bugfix" => &[
2353            "core_model",
2354            "scheduler",
2355            "persistence",
2356            "viewmodel",
2357            "ui",
2358            "tests",
2359            "utility",
2360            "other",
2361        ],
2362        "test" => &[
2363            "tests",
2364            "core_model",
2365            "scheduler",
2366            "persistence",
2367            "viewmodel",
2368            "ui",
2369            "utility",
2370            "other",
2371        ],
2372        _ => &[
2373            "ui",
2374            "viewmodel",
2375            "scheduler",
2376            "core_model",
2377            "persistence",
2378            "utility",
2379            "tests",
2380            "other",
2381        ],
2382    }
2383}
2384
2385// ---------------------------------------------------------------------------
2386// Recency helpers
2387// ---------------------------------------------------------------------------
2388
2389/// Per-file recency metadata derived from a single `git log` call.
2390#[derive(Debug, Clone)]
2391pub struct FileRecency {
2392    /// Days since last commit on this file (fractional). `None` if git
2393    /// is unavailable or the file has no git history.
2394    pub last_touched_days: Option<f64>,
2395    /// True when the file was last touched within `hot_days` days.
2396    pub hot: bool,
2397}
2398
2399/// Return source file paths that are modified (dirty) since the last commit.
2400///
2401/// Uses `git status --short --untracked-files=no`. Returns an empty set when
2402/// git is unavailable or the working tree is clean.
2403pub fn git_dirty_files() -> std::collections::HashSet<String> {
2404    use std::process::Command as Proc;
2405    let out = Proc::new("git")
2406        .args(["status", "--short", "--untracked-files=no"])
2407        .output();
2408    let Ok(o) = out else {
2409        return std::collections::HashSet::new();
2410    };
2411    if !o.status.success() {
2412        return std::collections::HashSet::new();
2413    }
2414    const SRC_EXTS: &[&str] = &[
2415        ".swift", ".py", ".ts", ".tsx", ".js", ".rs", ".go", ".kt", ".java", ".rb", ".cs", ".m",
2416        ".mm", ".cpp", ".c",
2417    ];
2418    String::from_utf8_lossy(&o.stdout)
2419        .lines()
2420        // git status --short lines: "XY path" — path starts at index 3
2421        .filter(|l| l.len() > 3 && SRC_EXTS.iter().any(|ext| l.ends_with(ext)))
2422        .map(|l| l[3..].trim().to_string())
2423        .collect()
2424}
2425
2426/// Heuristically propose a parallel test file path for a given source file.
2427///
2428/// Replaces `Sources/` → `Tests/` (or `src/` → `tests/`) and appends `Tests`
2429/// to the filename stem. Falls back to `Tests/<Stem>Tests.<ext>` when no
2430/// recognisable source directory is found.
2431/// Derive behavioural test hints from a symbol's own metadata (qname, signature,
2432/// doc) when no invariants or effects have been recorded yet (cold-start).
2433///
2434/// Returns up to 3 hints suitable for inclusion in `suggested_test_coverage`.
2435/// Callers should append these after invariant/effect hints, not replace them.
2436pub fn derive_cold_hints(qname: &str, signature: Option<&str>, doc: Option<&str>) -> Vec<String> {
2437    let mut hints: Vec<String> = Vec::new();
2438
2439    // 1. Doc first sentence → "verify: <sentence>"
2440    if let Some(d) = doc {
2441        let first = d.lines().next().unwrap_or("").trim();
2442        let first = first.split('.').next().unwrap_or("").trim();
2443        // Only use if it reads like a description (starts with verb-ish word).
2444        if first.len() > 12 {
2445            hints.push(format!("verify: {}", first.to_lowercase()));
2446        }
2447    }
2448
2449    // 2. Function/method name words → "verify <words> behavior"
2450    let leaf = qname
2451        .split(|c: char| c == '.' || c == ':' || c == '/')
2452        .last()
2453        .unwrap_or(qname);
2454    let words = split_identifier_words(leaf);
2455    if words.len() >= 2 {
2456        hints.push(format!(
2457            "verify {} behavior",
2458            words.join(" ").to_lowercase()
2459        ));
2460    } else if words.len() == 1 && hints.is_empty() {
2461        hints.push(format!("verify {} is correct", words[0].to_lowercase()));
2462    }
2463
2464    // 3. Parameter names from signature → "verify effect of <params>"
2465    if let Some(sig) = signature {
2466        let params = extract_sig_param_names(sig);
2467        if !params.is_empty() && params.len() <= 4 {
2468            hints.push(format!("verify effect of {} on output", params.join(", ")));
2469        }
2470    }
2471
2472    hints.truncate(3);
2473    hints
2474}
2475
2476/// Split a camelCase or snake_case identifier into lowercase words.
2477fn split_identifier_words(s: &str) -> Vec<String> {
2478    let mut words: Vec<String> = Vec::new();
2479    let mut cur = String::new();
2480    for ch in s.chars() {
2481        if ch == '_' || ch == '-' {
2482            if !cur.is_empty() {
2483                words.push(cur.clone());
2484                cur.clear();
2485            }
2486        } else if ch.is_uppercase() && !cur.is_empty() {
2487            words.push(cur.clone());
2488            cur.clear();
2489            cur.push(ch);
2490        } else {
2491            cur.push(ch);
2492        }
2493    }
2494    if !cur.is_empty() {
2495        words.push(cur);
2496    }
2497    words.into_iter().filter(|w| w.len() > 1).collect()
2498}
2499
2500/// Extract meaningful parameter names from a function signature string.
2501/// Handles Swift, Rust, TypeScript, Python styles heuristically.
2502fn extract_sig_param_names(sig: &str) -> Vec<String> {
2503    // Grab what's between the outermost parens.
2504    let inner = match (sig.find('('), sig.rfind(')')) {
2505        (Some(a), Some(b)) if b > a + 1 => &sig[a + 1..b],
2506        _ => return vec![],
2507    };
2508    let mut names: Vec<String> = Vec::new();
2509    for part in inner.split(',') {
2510        let part = part.trim();
2511        if part.is_empty() {
2512            continue;
2513        }
2514        // Take first token, strip leading `_` (Swift external labels), `&`, `mut`.
2515        let token = part
2516            .split(|c: char| c.is_whitespace() || c == ':')
2517            .find(|t| !t.is_empty())
2518            .unwrap_or("")
2519            .trim_start_matches(['_', '&'])
2520            .trim_start_matches("mut ")
2521            .to_string();
2522        if token.len() > 1 && token.chars().all(|c| c.is_alphanumeric() || c == '_') {
2523            names.push(token.to_lowercase());
2524        }
2525    }
2526    names
2527}
2528
2529/// Look up real indexed test files that correspond to `source_file`.
2530///
2531/// Scans the FTS index for files whose path contains a test/spec indicator
2532/// AND whose stem shares the source file's stem (e.g. `Foo.swift` → `FooTests.swift`).
2533/// Returns found paths. Falls back to an empty vec if the index is unavailable.
2534pub fn find_indexed_test_files(db_path: &std::path::Path, source_file: &str) -> Vec<String> {
2535    let stem = std::path::Path::new(source_file)
2536        .file_stem()
2537        .and_then(|s| s.to_str())
2538        .unwrap_or("")
2539        .to_lowercase();
2540    if stem.is_empty() {
2541        return vec![];
2542    }
2543    let Ok(db) = SearchFtsDb::open(db_path) else {
2544        return vec![];
2545    };
2546    // Query unique file paths from the index that look like test files.
2547    let Ok(mut stmt) = db
2548        .conn
2549        .prepare("SELECT DISTINCT file_orig FROM asd_search_fts WHERE tier = 2")
2550    else {
2551        return vec![];
2552    };
2553    let rows: Vec<String> = stmt
2554        .query_map([], |r| r.get::<_, String>(0))
2555        .map(|iter| iter.filter_map(|r| r.ok()).collect())
2556        .unwrap_or_default();
2557    // Keep only files whose stem contains (or is contained by) the source stem.
2558    rows.into_iter()
2559        .filter(|f| {
2560            let f_stem = std::path::Path::new(f)
2561                .file_stem()
2562                .and_then(|s| s.to_str())
2563                .unwrap_or("")
2564                .to_lowercase();
2565            f_stem.contains(&stem)
2566                || stem.contains(
2567                    f_stem
2568                        .trim_end_matches("tests")
2569                        .trim_end_matches("test")
2570                        .trim_end_matches("spec"),
2571                )
2572        })
2573        .collect()
2574}
2575
2576/// Pre-fetch all test-tier file paths in a single DB open.
2577/// Pass the result to [`test_files_for_source`] for per-file stem matching,
2578/// avoiding repeated DB opens when processing many source files in a loop.
2579pub fn fetch_all_test_file_paths(db_path: &std::path::Path) -> Vec<String> {
2580    let Ok(db) = SearchFtsDb::open(db_path) else {
2581        return vec![];
2582    };
2583    let Ok(mut stmt) = db
2584        .conn
2585        .prepare("SELECT DISTINCT file_orig FROM asd_search_fts WHERE tier = 2")
2586    else {
2587        return vec![];
2588    };
2589    stmt.query_map([], |r| r.get::<_, String>(0))
2590        .map(|iter| iter.filter_map(|r| r.ok()).collect())
2591        .unwrap_or_default()
2592}
2593
2594/// Filter `all_test_files` (from [`fetch_all_test_file_paths`]) for those
2595/// whose stem matches `source_file`'s stem. Same logic as [`find_indexed_test_files`]
2596/// but avoids the per-call DB open.
2597pub fn test_files_for_source(all_test_files: &[String], source_file: &str) -> Vec<String> {
2598    let stem = std::path::Path::new(source_file)
2599        .file_stem()
2600        .and_then(|s| s.to_str())
2601        .unwrap_or("")
2602        .to_lowercase();
2603    if stem.is_empty() {
2604        return vec![];
2605    }
2606    all_test_files
2607        .iter()
2608        .filter(|f| {
2609            let f_stem = std::path::Path::new(f.as_str())
2610                .file_stem()
2611                .and_then(|s| s.to_str())
2612                .unwrap_or("")
2613                .to_lowercase();
2614            f_stem.contains(&stem)
2615                || stem.contains(
2616                    f_stem
2617                        .trim_end_matches("tests")
2618                        .trim_end_matches("test")
2619                        .trim_end_matches("spec"),
2620                )
2621        })
2622        .cloned()
2623        .collect()
2624}
2625
2626pub fn propose_test_path(source_file: &str) -> String {
2627    let path = std::path::Path::new(source_file);
2628    let stem = path
2629        .file_stem()
2630        .and_then(|s| s.to_str())
2631        .unwrap_or("Unknown");
2632    let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("swift");
2633    // Try parallel test path by substituting common source dirs.
2634    let candidate = source_file
2635        .replace("/Sources/", "/Tests/")
2636        .replace("/Source/", "/Tests/")
2637        .replace("/src/", "/tests/");
2638    let parent = if candidate != source_file {
2639        std::path::Path::new(&candidate)
2640            .parent()
2641            .and_then(|p| p.to_str())
2642            .unwrap_or("Tests")
2643            .to_string()
2644    } else {
2645        "Tests".to_string()
2646    };
2647    format!("{parent}/{stem}Tests.{ext}")
2648}
2649
2650/// Plan J t-007: language-aware test stub body for an agent that
2651/// needs to write a new test against `source_file` exercising
2652/// `symbol_name`. Returns the recommended skeleton — function
2653/// declaration, arrange/act/assert comments, and a
2654/// `NotImplementedError`-equivalent marker so the test FAILS until
2655/// the agent fills it in (failing-first matches the missing-test
2656/// recipe item which says "fails before the edit, passes after").
2657///
2658/// Language detected from the source file extension. Falls back to
2659/// a generic comment-only stub for unknown extensions.
2660pub fn propose_test_stub(source_file: &str, symbol_name: &str) -> String {
2661    let ext = std::path::Path::new(source_file)
2662        .extension()
2663        .and_then(|s| s.to_str())
2664        .unwrap_or("")
2665        .to_ascii_lowercase();
2666    let snake = to_snake_case(symbol_name);
2667    let pascal = to_pascal_case(symbol_name);
2668    match ext.as_str() {
2669        "py" => format!(
2670            "def test_{snake}():\n    # arrange\n    # act\n    # assert\n    raise NotImplementedError(\"test_{snake}: fill in\")\n"
2671        ),
2672        "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" => format!(
2673            "test('{snake}', () => {{\n  // arrange\n  // act\n  // assert\n  throw new Error('test {snake}: fill in');\n}});\n"
2674        ),
2675        "rs" => format!(
2676            "#[test]\nfn {snake}() {{\n    // arrange\n    // act\n    // assert\n    todo!(\"{snake}: fill in\");\n}}\n"
2677        ),
2678        "go" => format!(
2679            "func Test{pascal}(t *testing.T) {{\n    // arrange\n    // act\n    // assert\n    t.Fatal(\"Test{pascal}: fill in\")\n}}\n"
2680        ),
2681        "java" => format!(
2682            "@Test\npublic void test{pascal}() {{\n    // arrange\n    // act\n    // assert\n    fail(\"test{pascal}: fill in\");\n}}\n"
2683        ),
2684        "cs" => format!(
2685            "[Test]\npublic void {pascal}_Should() {{\n    // arrange\n    // act\n    // assert\n    Assert.Fail(\"{pascal}_Should: fill in\");\n}}\n"
2686        ),
2687        "rb" => format!(
2688            "def test_{snake}\n  # arrange\n  # act\n  # assert\n  flunk(\"test_{snake}: fill in\")\nend\n"
2689        ),
2690        "kt" | "kts" => format!(
2691            "@Test\nfun `{snake}`() {{\n    // arrange\n    // act\n    // assert\n    fail(\"{snake}: fill in\")\n}}\n"
2692        ),
2693        "swift" => format!(
2694            "func test{pascal}() throws {{\n    // arrange\n    // act\n    // assert\n    XCTFail(\"test{pascal}: fill in\")\n}}\n"
2695        ),
2696        _ => {
2697            format!("// New test exercising {symbol_name} — fill in the arrange / act / assert.\n")
2698        }
2699    }
2700}
2701
2702/// `payment.charge` / `PaymentCharge` / `payment_charge` → `payment_charge`.
2703fn to_snake_case(name: &str) -> String {
2704    // Drop module prefix if qname-shaped.
2705    let leaf = name.rsplit('.').next().unwrap_or(name);
2706    let mut out = String::new();
2707    let mut prev_lower = false;
2708    for ch in leaf.chars() {
2709        if ch == '_' || ch == '-' {
2710            if !out.ends_with('_') {
2711                out.push('_');
2712            }
2713            prev_lower = false;
2714        } else if ch.is_ascii_uppercase() {
2715            if prev_lower && !out.ends_with('_') {
2716                out.push('_');
2717            }
2718            out.push(ch.to_ascii_lowercase());
2719            prev_lower = false;
2720        } else {
2721            out.push(ch);
2722            prev_lower = ch.is_ascii_lowercase() || ch.is_ascii_digit();
2723        }
2724    }
2725    out.trim_matches('_').to_string()
2726}
2727
2728/// `payment_charge` / `payment.charge` → `PaymentCharge`.
2729fn to_pascal_case(name: &str) -> String {
2730    let leaf = name.rsplit('.').next().unwrap_or(name);
2731    let mut out = String::new();
2732    let mut capitalize_next = true;
2733    for ch in leaf.chars() {
2734        if ch == '_' || ch == '-' {
2735            capitalize_next = true;
2736        } else if capitalize_next {
2737            out.push(ch.to_ascii_uppercase());
2738            capitalize_next = false;
2739        } else {
2740            out.push(ch);
2741        }
2742    }
2743    out
2744}
2745
2746/// Run one `git log` pass covering up to `scan_commits` commits and return
2747/// a map of relative file path → `FileRecency`.
2748///
2749/// Uses `--name-only --pretty=format:%ct` so each commit block looks like:
2750/// ```text
2751/// <unix_timestamp>
2752///
2753/// path/to/file.swift
2754/// another/file.swift
2755/// ```
2756///
2757/// The first commit that mentions a file is its "last touched" commit.
2758/// `hot_days` controls the `hot` flag (files modified within that window).
2759pub fn gather_recency(
2760    scan_commits: usize,
2761    hot_days: f64,
2762) -> std::collections::HashMap<String, FileRecency> {
2763    use std::collections::HashMap;
2764    use std::process::Command;
2765
2766    let output = Command::new("git")
2767        .args([
2768            "log",
2769            &format!("-n{}", scan_commits),
2770            "--pretty=format:%ct",
2771            "--name-only",
2772        ])
2773        .output();
2774
2775    let Ok(out) = output else {
2776        return HashMap::new();
2777    };
2778    if !out.status.success() {
2779        return HashMap::new();
2780    }
2781
2782    let now_secs = std::time::SystemTime::now()
2783        .duration_since(std::time::UNIX_EPOCH)
2784        .map(|d| d.as_secs() as f64)
2785        .unwrap_or(0.0);
2786    let hot_secs = hot_days * 86400.0;
2787
2788    let text = String::from_utf8_lossy(&out.stdout);
2789    let mut current_ts: Option<f64> = None;
2790    let mut map: HashMap<String, FileRecency> = HashMap::new();
2791
2792    for line in text.lines() {
2793        let trimmed = line.trim();
2794        if trimmed.is_empty() {
2795            continue;
2796        }
2797        if let Ok(ts) = trimmed.parse::<f64>() {
2798            current_ts = Some(ts);
2799        } else if let Some(ts) = current_ts {
2800            // Only record the first (most recent) commit that mentions each file.
2801            map.entry(trimmed.to_string()).or_insert_with(|| {
2802                let days = (now_secs - ts) / 86400.0;
2803                FileRecency {
2804                    last_touched_days: Some(days.max(0.0)),
2805                    hot: (now_secs - ts) <= hot_secs,
2806                }
2807            });
2808        }
2809    }
2810    map
2811}
2812
2813// ---------------------------------------------------------------------------
2814// Symbol summary extraction
2815// ---------------------------------------------------------------------------
2816
2817/// The evidence source that identified an owner.
2818#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2819#[serde(rename_all = "snake_case")]
2820pub enum OwnerSignalSource {
2821    /// Determined by frequency in `git blame` for the symbol's line range.
2822    GitBlame,
2823    /// Extracted from `@owner` / `Owner:` annotation in the doc comment.
2824    DocComment,
2825    /// Listed among recent unique committers from `git log`.
2826    GitLog,
2827    /// Recorded in an `Ownership` ledger entry for this symbol.
2828    LedgerTruth,
2829}
2830
2831/// An owner with the evidence source that identified them.
2832#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2833pub struct AnnotatedOwner {
2834    pub name: String,
2835    pub source: OwnerSignalSource,
2836}
2837
2838/// Ownership signal returned by [`discover_symbol_ownership`].
2839#[derive(Debug, Clone)]
2840pub struct OwnershipSignal {
2841    /// Name of the most frequent author in the symbol's git blame range.
2842    pub primary_author: Option<String>,
2843    /// Author extracted from a `@owner` / `Owner:` annotation in the doc comment.
2844    pub doc_owner: Option<String>,
2845    /// The `N` most recent unique committers to the symbol's file.
2846    pub recent_committers: Vec<String>,
2847    /// All owner signals with source confidence annotations.
2848    pub annotated: Vec<AnnotatedOwner>,
2849}
2850
2851/// Discover the likely owner of a symbol from git blame + doc-comment annotations.
2852///
2853/// Returns an `OwnershipSignal` with signals from multiple sources so callers
2854/// can choose how to display or prioritise them.  All signals are best-effort;
2855/// errors (no git, no file) return empty/`None` gracefully.
2856pub fn discover_symbol_ownership(
2857    file: &str,
2858    start_line: u32,
2859    end_line: u32,
2860    doc: Option<&str>,
2861) -> OwnershipSignal {
2862    use std::collections::HashMap;
2863    use std::process::Command;
2864
2865    // 1. Extract `@owner` / `Owner:` from doc comment.
2866    let doc_owner = doc.and_then(|d| {
2867        for line in d.lines() {
2868            let l = line.trim().to_lowercase();
2869            for prefix in &["@owner ", "owner: ", "owned by "] {
2870                if let Some(rest) = l.strip_prefix(prefix) {
2871                    let owner = rest.split_whitespace().next().unwrap_or("").to_string();
2872                    if !owner.is_empty() {
2873                        return Some(owner);
2874                    }
2875                }
2876            }
2877        }
2878        None
2879    });
2880
2881    // 2. Git blame for the symbol's line range → most frequent author.
2882    let blame_out = Command::new("git")
2883        .args([
2884            "blame",
2885            "--porcelain",
2886            &format!("-L{},{}", start_line.max(1), end_line.max(start_line)),
2887            "--",
2888            file,
2889        ])
2890        .output();
2891    let mut blame_authors: HashMap<String, usize> = HashMap::new();
2892    if let Ok(out) = blame_out {
2893        if out.status.success() {
2894            for line in String::from_utf8_lossy(&out.stdout).lines() {
2895                if let Some(author) = line.strip_prefix("author ") {
2896                    *blame_authors.entry(author.trim().to_string()).or_insert(0) += 1;
2897                }
2898            }
2899        }
2900    }
2901    let primary_author = blame_authors
2902        .into_iter()
2903        .max_by_key(|(_, count)| *count)
2904        .map(|(a, _)| a)
2905        .filter(|a| !a.is_empty() && a != "Not Committed Yet");
2906
2907    // 3. Recent unique committers to the file (up to 5).
2908    let log_out = Command::new("git")
2909        .args(["log", "-n20", "--pretty=format:%an", "--", file])
2910        .output();
2911    let mut seen = std::collections::HashSet::new();
2912    let mut recent_committers: Vec<String> = Vec::new();
2913    if let Ok(out) = log_out {
2914        if out.status.success() {
2915            for author in String::from_utf8_lossy(&out.stdout).lines() {
2916                let a = author.trim().to_string();
2917                if !a.is_empty() && seen.insert(a.clone()) {
2918                    recent_committers.push(a);
2919                    if recent_committers.len() >= 5 {
2920                        break;
2921                    }
2922                }
2923            }
2924        }
2925    }
2926
2927    // Build annotated list with source confidence labels.
2928    let mut annotated: Vec<AnnotatedOwner> = Vec::new();
2929    // Doc owner is the strongest signal — explicit annotation by the author.
2930    if let Some(ref owner) = doc_owner {
2931        annotated.push(AnnotatedOwner {
2932            name: owner.clone(),
2933            source: OwnerSignalSource::DocComment,
2934        });
2935    }
2936    // Primary blame author is high-confidence structural ownership.
2937    if let Some(ref author) = primary_author {
2938        if !annotated.iter().any(|a| &a.name == author) {
2939            annotated.push(AnnotatedOwner {
2940                name: author.clone(),
2941                source: OwnerSignalSource::GitBlame,
2942            });
2943        }
2944    }
2945    // Recent committers complete the picture with lower-confidence recency signal.
2946    for committer in &recent_committers {
2947        if !annotated.iter().any(|a| &a.name == committer) {
2948            annotated.push(AnnotatedOwner {
2949                name: committer.clone(),
2950                source: OwnerSignalSource::GitLog,
2951            });
2952        }
2953    }
2954
2955    OwnershipSignal {
2956        primary_author,
2957        doc_owner,
2958        recent_committers,
2959        annotated,
2960    }
2961}
2962
2963/// A test symbol that likely covers a given impl symbol.
2964#[derive(Debug, Clone)]
2965pub struct CoveringTest {
2966    pub qname: String,
2967    pub file: String,
2968    pub line: i64,
2969    /// Exact command to run this test (e.g. `swift test --filter testRefreshPlayhead`).
2970    pub run_command: String,
2971}
2972
2973/// Find test symbols in the FTS index that are likely to cover `impl_qname`.
2974///
2975/// Matching strategy (in order of confidence):
2976/// 1. Test qname contains the impl symbol's leaf name (e.g. `testRefreshPlayhead`
2977///    matches `refreshPlayhead`).
2978/// 2. Test doc comment mentions the impl qname.
2979///
2980/// Returns a list of `CoveringTest` with file path and exact run command.
2981pub fn find_covering_tests(fts: Option<&SearchFtsDb>, impl_qname: &str) -> Vec<CoveringTest> {
2982    let leaf = impl_qname
2983        .split(|c: char| c == '.' || c == ':' || c == '/')
2984        .last()
2985        .unwrap_or(impl_qname)
2986        .to_lowercase();
2987    if leaf.is_empty() || leaf.len() < 3 {
2988        return vec![];
2989    }
2990    let Some(db) = fts else {
2991        return vec![];
2992    };
2993    // Query test-tier symbols whose qname or doc mentions the leaf name.
2994    let Ok(mut stmt) = db
2995        .conn
2996        .prepare("SELECT qname_orig, file_orig, start_line FROM asd_search_fts WHERE tier = 2")
2997    else {
2998        return vec![];
2999    };
3000    let rows: Vec<(String, String, i64)> = stmt
3001        .query_map([], |r| {
3002            Ok((
3003                r.get::<_, String>(0)?,
3004                r.get::<_, String>(1)?,
3005                r.get::<_, i64>(2)?,
3006            ))
3007        })
3008        .map(|iter| iter.filter_map(|r| r.ok()).collect())
3009        .unwrap_or_default();
3010
3011    rows.into_iter()
3012        .filter(|(qname, _, _)| {
3013            let q = qname.to_lowercase();
3014            let stripped = q
3015                .trim_start_matches("test")
3016                .trim_start_matches('_')
3017                .trim_start_matches("spec")
3018                .trim_start_matches('_');
3019            stripped.contains(leaf.as_str()) || q.contains(leaf.as_str())
3020        })
3021        .map(|(qname, file, line)| {
3022            let run_command = derive_test_run_command(&file, &qname);
3023            CoveringTest {
3024                qname,
3025                file,
3026                line,
3027                run_command,
3028            }
3029        })
3030        .collect()
3031}
3032
3033/// Derive an exact test run command from a test file path and test qname.
3034///
3035/// Walks up from the file to find the project manifest, then emits the
3036/// appropriate test runner invocation.
3037fn derive_test_run_command(file: &str, test_qname: &str) -> String {
3038    use std::path::Path;
3039
3040    let file_path = Path::new(file);
3041    let leaf_name = test_qname
3042        .split(|c: char| c == '.' || c == ':')
3043        .last()
3044        .unwrap_or(test_qname);
3045
3046    // Walk up to find a project manifest.
3047    let mut dir = file_path.parent();
3048    while let Some(d) = dir {
3049        if d.join("Package.swift").exists() {
3050            return format!("swift test --filter {}", leaf_name);
3051        }
3052        if d.join("Cargo.toml").exists() {
3053            return format!("cargo test {}", leaf_name);
3054        }
3055        if d.join("package.json").exists() {
3056            // Prefer jest/vitest based on file extension.
3057            if file.ends_with(".test.ts")
3058                || file.ends_with(".spec.ts")
3059                || file.ends_with(".test.js")
3060                || file.ends_with(".spec.js")
3061            {
3062                return format!("npx jest --testNamePattern=\"{}\"", leaf_name);
3063            }
3064            return format!("npm test -- --grep \"{}\"", leaf_name);
3065        }
3066        if d.join("pyproject.toml").exists() || d.join("setup.py").exists() {
3067            return format!("pytest -k \"{}\"", leaf_name);
3068        }
3069        if d.join("go.mod").exists() {
3070            // Derive package from file path relative to go.mod.
3071            let rel = file_path
3072                .strip_prefix(d)
3073                .ok()
3074                .and_then(|p| p.parent())
3075                .and_then(|p| p.to_str())
3076                .map(|s| format!("./{}", s))
3077                .unwrap_or_else(|| "./...".to_string());
3078            return format!("go test {} -run {}", rel, leaf_name);
3079        }
3080        if d.join("Gemfile").exists() {
3081            return format!("bundle exec rspec --example \"{}\"", leaf_name);
3082        }
3083        if d.join("build.gradle").exists() || d.join("build.gradle.kts").exists() {
3084            return format!("./gradlew test --tests \"*{}*\"", leaf_name);
3085        }
3086        if d.join("pom.xml").exists() {
3087            return format!("mvn -Dtest=*{}* test", leaf_name);
3088        }
3089        dir = d.parent();
3090    }
3091
3092    // Fallback: language-based inference from file extension.
3093    if file.ends_with(".swift") {
3094        format!("swift test --filter {}", leaf_name)
3095    } else if file.ends_with(".rs") {
3096        format!("cargo test {}", leaf_name)
3097    } else if file.ends_with(".py") {
3098        format!("pytest -k \"{}\"", leaf_name)
3099    } else if file.ends_with(".go") {
3100        format!("go test ./... -run {}", leaf_name)
3101    } else {
3102        format!("# run: {}", leaf_name)
3103    }
3104}
3105
3106/// Extract a one-line human-readable summary for a symbol.
3107///
3108/// Priority:
3109/// 1. First sentence of the doc comment (up to `.` / `!` / `?` / newline,
3110///    capped at 120 chars). Trailing punctuation stripped.
3111/// 2. Condensed signature (first 100 chars, trimmed).
3112/// 3. Empty string.
3113/// Strip language-specific doc comment markers from a single line.
3114/// Handles Rust `///`/`//!`, C/Java/Swift `/**`/`*`/`*/`, Python `#`, Haskell/SQL `--`.
3115fn strip_doc_prefix(line: &str) -> &str {
3116    let s = line.trim();
3117    // Multi-char prefixes first.
3118    for prefix in &["///", "//!", "/**", "*/", "//"] {
3119        if let Some(rest) = s.strip_prefix(prefix) {
3120            return rest
3121                .trim_start_matches([' ', '\t'])
3122                .trim_end_matches([' ', '\t']);
3123        }
3124    }
3125    // Single-char: leading `*` (continuation in /** ... */), `#`, `-`
3126    if s.starts_with("* ") || s == "*" {
3127        return s[1..].trim_start_matches([' ', '\t']);
3128    }
3129    for prefix in &["#", "--"] {
3130        if let Some(rest) = s.strip_prefix(prefix) {
3131            return rest.trim_start_matches([' ', '\t']);
3132        }
3133    }
3134    s
3135}
3136
3137pub fn extract_summary(doc: Option<&str>, signature: Option<&str>) -> String {
3138    if let Some(d) = doc {
3139        // Strip per-line doc prefixes, join, then take first sentence.
3140        let cleaned: String = d
3141            .lines()
3142            .map(strip_doc_prefix)
3143            .collect::<Vec<_>>()
3144            .join(" ")
3145            .trim()
3146            .to_string();
3147        if !cleaned.is_empty() {
3148            let end = cleaned
3149                .char_indices()
3150                .find_map(|(i, c)| {
3151                    if matches!(c, '.' | '!' | '?') {
3152                        Some(i + c.len_utf8())
3153                    } else {
3154                        None
3155                    }
3156                })
3157                .unwrap_or(cleaned.len().min(120));
3158            let sentence = cleaned[..end.min(cleaned.len())]
3159                .trim()
3160                .trim_end_matches(['.', '!', '?']);
3161            if !sentence.is_empty() {
3162                return sentence.to_string();
3163            }
3164        }
3165    }
3166    if let Some(sig) = signature {
3167        let trimmed = sig.trim();
3168        if !trimmed.is_empty() {
3169            return trimmed.chars().take(100).collect();
3170        }
3171    }
3172    String::new()
3173}
3174
3175// ---------------------------------------------------------------------------
3176// Tier classification
3177// ---------------------------------------------------------------------------
3178
3179/// Classify a source file into a symbol tier.
3180///
3181/// - `2` Test: excluded from results by default (`--include-tests` to include).
3182/// - `1` Utility: Preview / Sample / Editor / Generated / Mock — included by
3183///        default but penalised in `hybrid_boost` so production ranks first.
3184/// - `0` Production: all other source files.
3185pub fn symbol_tier(file: &str) -> SymbolTier {
3186    if is_test_file(file) {
3187        2
3188    } else if is_utility_file(file) {
3189        1
3190    } else {
3191        0
3192    }
3193}
3194
3195/// Load user-defined layer overrides from `.asd/layers.toml` next to `db_path`.
3196///
3197/// Returns a vec of `(path_substring, layer_name)` pairs in declaration order.
3198/// Unknown layer names are silently skipped. Returns an empty vec if the file
3199/// does not exist or cannot be parsed.
3200///
3201/// Config format:
3202/// ```toml
3203/// # .asd/layers.toml
3204/// [patterns]
3205/// "Sources/Networking" = "persistence"
3206/// "Sources/Features"   = "core_model"
3207/// "Sources/UI"         = "ui"
3208/// ```
3209pub fn load_layer_overrides(db_path: &std::path::Path) -> Vec<(String, String)> {
3210    let candidates = [
3211        db_path.parent().map(|p| p.join(".asd").join("layers.toml")),
3212        db_path.parent().map(|p| p.join("layers.toml")),
3213    ];
3214
3215    for maybe_path in candidates.iter().flatten() {
3216        let Ok(text) = std::fs::read_to_string(maybe_path) else {
3217            continue;
3218        };
3219        #[derive(serde::Deserialize)]
3220        struct LayersFile {
3221            patterns: Option<toml::Table>,
3222        }
3223        let Ok(parsed) = toml::from_str::<LayersFile>(&text) else {
3224            continue;
3225        };
3226        let Some(patterns) = parsed.patterns else {
3227            continue;
3228        };
3229        let pairs: Vec<(String, String)> = patterns
3230            .into_iter()
3231            .filter_map(|(k, v)| {
3232                let layer = v.as_str()?;
3233                validate_layer_name(layer).map(|l| (k.to_lowercase(), l.to_string()))
3234            })
3235            .collect();
3236        if !pairs.is_empty() {
3237            return pairs;
3238        }
3239    }
3240    vec![]
3241}
3242
3243fn validate_layer_name(name: &str) -> Option<&'static str> {
3244    match name {
3245        "ui" => Some("ui"),
3246        "viewmodel" => Some("viewmodel"),
3247        "scheduler" => Some("scheduler"),
3248        "core_model" => Some("core_model"),
3249        "persistence" => Some("persistence"),
3250        "utility" => Some("utility"),
3251        "tests" => Some("tests"),
3252        "other" => Some("other"),
3253        _ => None,
3254    }
3255}
3256
3257/// Classify a source file into a workflow layer for `asd investigate` grouping.
3258///
3259/// Returns one of: `"ui"`, `"viewmodel"`, `"core_model"`, `"scheduler"`,
3260/// `"persistence"`, `"utility"`, `"tests"`, or `"other"`.
3261///
3262/// `overrides` is a slice of `(path_substring, layer_name)` pairs loaded from
3263/// `.asd/layers.toml`. User entries are checked first in order; the first
3264/// substring match wins. Pass `&[]` to use only built-in patterns.
3265///
3266/// Classification is purely file-path based — directory names and filename
3267/// suffixes are matched against common patterns for each layer. The `tier`
3268/// argument is used so that tier-2 (test) files always land in `"tests"` and
3269/// tier-1 (utility) files always land in `"utility"` regardless of path.
3270pub fn classify_layer(
3271    file: &str,
3272    tier: SymbolTier,
3273    overrides: &[(String, String)],
3274) -> &'static str {
3275    if tier == 2 {
3276        return "tests";
3277    }
3278    if tier == 1 {
3279        return "utility";
3280    }
3281
3282    // User-defined overrides take priority over built-in patterns.
3283    let lower = file.to_lowercase();
3284    for (pattern, layer) in overrides {
3285        if lower.contains(pattern.as_str()) {
3286            if let Some(l) = validate_layer_name(layer) {
3287                return l;
3288            }
3289        }
3290    }
3291
3292    let segments: Vec<&str> = lower.split(|c| c == '/' || c == '\\').collect();
3293    let filename = segments.last().copied().unwrap_or("");
3294    // Strip extension to check suffix patterns.
3295    let stem = filename.rsplit('.').nth(1).unwrap_or(filename);
3296
3297    // Check directory components (all except filename) for layer keywords.
3298    let dirs: Vec<&str> = segments.iter().copied().rev().skip(1).collect();
3299
3300    // --- Scheduler / Engine ---
3301    const SCHED_DIRS: &[&str] = &[
3302        "scheduler",
3303        "schedulers",
3304        "engine",
3305        "engines",
3306        "audio",
3307        "audioengine",
3308        "transport",
3309        "render",
3310        "renderer",
3311        "pipeline",
3312        "worker",
3313        "workers",
3314        "processing",
3315        "realtime",
3316        "dsp",
3317        "clock",
3318    ];
3319    const SCHED_SUFFIXES: &[&str] = &[
3320        "scheduler",
3321        "engine",
3322        "transport",
3323        "renderer",
3324        "pipeline",
3325        "worker",
3326        "processor",
3327        "synthesizer",
3328        "synth",
3329        "clock",
3330        "timer",
3331        "compiler",
3332        "loop",
3333    ];
3334    if dirs.iter().any(|d| SCHED_DIRS.contains(d))
3335        || SCHED_SUFFIXES.iter().any(|s| stem.ends_with(s))
3336    {
3337        return "scheduler";
3338    }
3339
3340    // --- Persistence ---
3341    const PERSIST_DIRS: &[&str] = &[
3342        "storage",
3343        "database",
3344        "db",
3345        "repository",
3346        "repositories",
3347        "cache",
3348        "datastore",
3349        "persistence",
3350        "migration",
3351        "migrations",
3352        "store",
3353        "dao",
3354    ];
3355    const PERSIST_SUFFIXES: &[&str] = &[
3356        "repository",
3357        "store",
3358        "database",
3359        "cache",
3360        "storage",
3361        "dao",
3362        "datasource",
3363        "migration",
3364    ];
3365    if dirs.iter().any(|d| PERSIST_DIRS.contains(d))
3366        || PERSIST_SUFFIXES.iter().any(|s| stem.ends_with(s))
3367    {
3368        return "persistence";
3369    }
3370
3371    // --- ViewModel / Presenter / Controller ---
3372    const VM_DIRS: &[&str] = &[
3373        "viewmodels",
3374        "viewmodel",
3375        "presenters",
3376        "presenter",
3377        "coordinators",
3378        "coordinator",
3379        "interactors",
3380        "interactor",
3381        "controllers",
3382        "controller",
3383        "states",
3384        "state",
3385        "routers",
3386        "router",
3387    ];
3388    const VM_SUFFIXES: &[&str] = &[
3389        "viewmodel",
3390        "viewstate",
3391        "presenter",
3392        "coordinator",
3393        "interactor",
3394        "statemanager",
3395        "controller",
3396        "observable",
3397        "environment",
3398        "router",
3399    ];
3400    if dirs.iter().any(|d| VM_DIRS.contains(d)) || VM_SUFFIXES.iter().any(|s| stem.ends_with(s)) {
3401        return "viewmodel";
3402    }
3403
3404    // --- UI ---
3405    const UI_DIRS: &[&str] = &[
3406        "views",
3407        "view",
3408        "screens",
3409        "screen",
3410        "pages",
3411        "page",
3412        "components",
3413        "component",
3414        "widgets",
3415        "widget",
3416        "cells",
3417        "viewcontrollers",
3418        "ui",
3419        "fragments",
3420    ];
3421    const UI_SUFFIXES: &[&str] = &[
3422        "view",
3423        "screen",
3424        "page",
3425        "component",
3426        "widget",
3427        "cell",
3428        "viewcontroller",
3429        "fragment",
3430        "layout",
3431        "button",
3432        "label",
3433        "panel",
3434        "sheet",
3435        "modal",
3436        "overlay",
3437        "header",
3438        "footer",
3439    ];
3440    if dirs.iter().any(|d| UI_DIRS.contains(d)) || UI_SUFFIXES.iter().any(|s| stem.ends_with(s)) {
3441        return "ui";
3442    }
3443
3444    // --- Core Model / Domain ---
3445    const MODEL_DIRS: &[&str] = &[
3446        "models", "model", "domain", "core", "entities", "entity", "services", "service",
3447        "usecases", "usecase", "business", "logic", "features", "feature",
3448    ];
3449    const MODEL_SUFFIXES: &[&str] = &[
3450        "model",
3451        "entity",
3452        "service",
3453        "usecase",
3454        "manager",
3455        "handler",
3456        "factory",
3457        "builder",
3458        "validator",
3459    ];
3460    if dirs.iter().any(|d| MODEL_DIRS.contains(d))
3461        || MODEL_SUFFIXES.iter().any(|s| stem.ends_with(s))
3462    {
3463        return "core_model";
3464    }
3465
3466    "other"
3467}
3468
3469/// Plan J t-003: classify a file by its functional ROLE in the
3470/// project — `view` / `viewmodel` / `test` / `example` / `fixture` /
3471/// `script` / `generated` / `reference` / `impl`. Distinct from
3472/// `classify_layer`, which buckets by architectural tier
3473/// (presentation / domain / persistence / etc.).
3474///
3475/// Order of precedence matters — tests/specs short-circuit before
3476/// other patterns so a `ViewTests.swift` is `test`, not `view`.
3477/// `view` and `viewmodel` are detected by filename suffix
3478/// (`*ViewModel.*`, `*View.*`) AND `/views/` / `/viewmodels/` /
3479/// `*.vue` / `*.svelte` path/extension patterns. Conservative on
3480/// `.tsx`/`.jsx` — those frequently are UI but not always.
3481///
3482/// Was previously inline-duplicated in CLI prepare_change.rs and
3483/// MCP mcp_server.rs (with diverged pattern sets); Plan J t-003
3484/// lifts to one canonical impl. The MCP variant was missing
3485/// `fixture` / `script` / `generated` / `view` / `viewmodel`
3486/// entirely — the unified classifier picks them up everywhere.
3487pub fn classify_file_role(file: &str) -> &'static str {
3488    // Prefix with `/` so root-level dirs (`scripts/foo.sh`) match
3489    // the same `/scripts` predicate as nested ones (`pkg/scripts/foo.sh`).
3490    // Without this, paths without a leading slash fell through to
3491    // `impl` silently — a pre-existing bug the old inline classifier
3492    // also had.
3493    let fl = format!("/{}", file.to_lowercase());
3494    // Tests first — `ViewTests.swift` must classify as `test`, not `view`.
3495    if fl.contains("/test")
3496        || fl.contains("/tests/")
3497        || fl.contains("/spec")
3498        || fl.contains("_test.")
3499        || fl.contains("spec.")
3500        || fl.ends_with("tests.swift")
3501    {
3502        return "test";
3503    }
3504    if fl.contains("/example")
3505        || fl.contains("/examples")
3506        || fl.contains("/sample")
3507        || fl.contains("/samples")
3508        || fl.contains("/demo")
3509        || fl.contains("/demos")
3510    {
3511        return "example";
3512    }
3513    if fl.contains("/fixture")
3514        || fl.contains("/fixtures")
3515        || fl.contains("/seed")
3516        || fl.contains("/seeds")
3517    {
3518        return "fixture";
3519    }
3520    if fl.contains("/script")
3521        || fl.contains("/scripts")
3522        || fl.contains("/tool/")
3523        || fl.contains("/tools/")
3524        || fl.contains("/bin/")
3525        || fl.contains("/hack/")
3526    {
3527        return "script";
3528    }
3529    if fl.contains("/generated")
3530        || fl.contains("/gen/")
3531        || fl.contains(".generated.")
3532        || fl.contains(".pb.")
3533        || fl.contains("_generated")
3534    {
3535        return "generated";
3536    }
3537    if fl.contains("/doc")
3538        || fl.contains("/docs")
3539        || fl.contains("/reference")
3540        || fl.contains("readme")
3541        || fl.ends_with(".md")
3542        || fl.ends_with(".rst")
3543        || fl.ends_with(".adoc")
3544    {
3545        return "reference";
3546    }
3547    // Plan J t-003: viewmodel + view patterns. Order matters —
3548    // ViewModel.swift must short-circuit before the bare "view"
3549    // suffix check below catches it.
3550    if fl.contains("/viewmodels/") || fl.contains("/viewmodel/") || stem_ends_with(&fl, "viewmodel")
3551    {
3552        return "viewmodel";
3553    }
3554    if fl.contains("/views/")
3555        || fl.contains("/view/")
3556        || stem_ends_with(&fl, "view")
3557        || fl.ends_with(".vue")
3558        || fl.ends_with(".svelte")
3559    {
3560        return "view";
3561    }
3562    "impl"
3563}
3564
3565/// Lower-cased helper: does the file STEM (without the extension)
3566/// end with `suffix`? Avoids matching `viewmodel` in a path
3567/// segment like `previewmodel/foo.rs`. Plan J t-003.
3568fn stem_ends_with(path_lower: &str, suffix: &str) -> bool {
3569    let stem = std::path::Path::new(path_lower)
3570        .file_stem()
3571        .and_then(|s| s.to_str())
3572        .unwrap_or("");
3573    stem.ends_with(suffix)
3574}
3575
3576/// Like [`classify_layer`] but uses the symbol's qualified name as a secondary
3577/// hint when the file path alone yields `"other"`. Catches common cases where a
3578/// class named `FooViewModel` lives in a file named after the app rather than
3579/// the ViewModel.
3580pub fn classify_layer_sym(
3581    file: &str,
3582    qname: &str,
3583    tier: SymbolTier,
3584    overrides: &[(String, String)],
3585) -> &'static str {
3586    let layer = classify_layer(file, tier, overrides);
3587    if layer != "other" {
3588        return layer;
3589    }
3590    // Secondary: walk all qname components (split on `.`, `::`, `/`).
3591    // For method qnames like `AcmeFlowViewModel.refreshDriftPlayhead` the
3592    // method leaf won't match, but the class component will — so we check every
3593    // component and return the *highest-priority* layer found across any of them.
3594    let components: Vec<&str> = qname
3595        .split(|c| c == '.' || c == ':' || c == '/')
3596        .filter(|s| !s.is_empty())
3597        .collect();
3598
3599    let mut found_viewmodel = false;
3600    let mut found_ui = false;
3601    let mut found_scheduler = false;
3602    let mut found_persistence = false;
3603    let mut found_core_model = false;
3604
3605    for component in &components {
3606        let n = component.to_lowercase();
3607        if n.ends_with("viewmodel")
3608            || n.ends_with("controller")
3609            || n.ends_with("presenter")
3610            || n.ends_with("coordinator")
3611            || n.ends_with("interactor")
3612            || n.ends_with("viewstate")
3613            || n.ends_with("statemanager")
3614            || n.ends_with("router")
3615        {
3616            found_viewmodel = true;
3617        } else if n.ends_with("view")
3618            || n.ends_with("screen")
3619            || n.ends_with("page")
3620            || n.ends_with("cell")
3621            || n.ends_with("widget")
3622            || n.ends_with("button")
3623            || n.ends_with("label")
3624            || n.ends_with("panel")
3625            || n.ends_with("viewcontroller")
3626            || n.ends_with("sheet")
3627            || n.ends_with("overlay")
3628            || n.ends_with("header")
3629        {
3630            found_ui = true;
3631        } else if n.ends_with("scheduler")
3632            || n.ends_with("engine")
3633            || n.ends_with("compiler")
3634            || n.ends_with("processor")
3635            || n.ends_with("renderer")
3636            || n.ends_with("clock")
3637            || n.ends_with("timer")
3638            || n.ends_with("pipeline")
3639            || n.ends_with("worker")
3640            || n.ends_with("synthesizer")
3641            || n.ends_with("transport")
3642        {
3643            found_scheduler = true;
3644        } else if n.ends_with("repository")
3645            || n.ends_with("store")
3646            || n.ends_with("cache")
3647            || n.ends_with("dao")
3648            || n.ends_with("database")
3649            || n.ends_with("datasource")
3650        {
3651            found_persistence = true;
3652        } else if n.ends_with("model")
3653            || n.ends_with("entity")
3654            || n.ends_with("service")
3655            || n.ends_with("manager")
3656            || n.ends_with("handler")
3657            || n.ends_with("factory")
3658            || n.ends_with("validator")
3659            || n.ends_with("usecase")
3660            || n.ends_with("builder")
3661        {
3662            found_core_model = true;
3663        }
3664    }
3665
3666    // Return in priority order: most specific wins.
3667    if found_viewmodel {
3668        return "viewmodel";
3669    }
3670    if found_ui {
3671        return "ui";
3672    }
3673    if found_scheduler {
3674        return "scheduler";
3675    }
3676    if found_persistence {
3677        return "persistence";
3678    }
3679    if found_core_model {
3680        return "core_model";
3681    }
3682    "other"
3683}
3684
3685/// Heuristically determine whether a symbol's source file is a utility file
3686/// (Preview, Sample, Editor extension, Generated, Mock, Stub, Fixture, Demo).
3687/// These are compiled into the app but are not production logic.
3688fn is_utility_file(file: &str) -> bool {
3689    let lower = file.to_lowercase();
3690    let segments: Vec<&str> = lower.split(|c| c == '/' || c == '\\').collect();
3691
3692    // Directory names that indicate non-production utility trees.
3693    const UTILITY_DIRS: &[&str] = &[
3694        "previews",
3695        "preview",
3696        "samples",
3697        "sample",
3698        "sampledata",
3699        "examples",
3700        "example",
3701        "mocks",
3702        "mock",
3703        "stubs",
3704        "stub",
3705        "fixtures",
3706        "fixture",
3707        "demo",
3708        "demos",
3709        "editor",
3710        "editors",
3711        "generated",
3712        "gen",
3713        "sandbox",
3714        "playground",
3715        "playgrounds",
3716    ];
3717    // Also match compound directory names that start with a utility prefix.
3718    const UTILITY_PREFIXES: &[&str] = &["mock", "stub", "fake", "sample", "preview", "generated"];
3719    if segments
3720        .iter()
3721        .rev()
3722        .skip(1)
3723        .any(|s| UTILITY_DIRS.contains(s) || UTILITY_PREFIXES.iter().any(|p| s.starts_with(p)))
3724    {
3725        return true;
3726    }
3727
3728    // Filename patterns.
3729    if let Some(filename) = segments.last() {
3730        // Swift Previews: FooView_Previews.swift, FooPreview.swift
3731        if filename.contains("preview") || filename.contains("_previews") {
3732            return true;
3733        }
3734        // SwiftUI canvas / sample data
3735        if filename.contains("sampledata") || filename.contains("sample_data") {
3736            return true;
3737        }
3738        // Generated files: Foo.generated.swift, Foo.g.swift, FooGenerated.swift
3739        if filename.contains(".generated.")
3740            || filename.contains(".g.")
3741            || filename.ends_with("generated.swift")
3742        {
3743            return true;
3744        }
3745        // Mock/Stub/Fake: MockFoo.swift, FooMock.kt, FakeBar.ts
3746        if filename.starts_with("mock")
3747            || filename.contains("_mock.")
3748            || filename.starts_with("stub")
3749            || filename.contains("_stub.")
3750            || filename.starts_with("fake")
3751            || filename.contains("_fake.")
3752        {
3753            return true;
3754        }
3755        // Xcode Playground files
3756        if filename.ends_with(".playground") {
3757            return true;
3758        }
3759    }
3760
3761    false
3762}
3763
3764/// Heuristically determine whether a symbol's source file is a test file.
3765///
3766/// Checks directory components and filename patterns that are idiomatic across
3767/// all supported languages. Does NOT check symbol name — only the file path.
3768fn is_test_file(file: &str) -> bool {
3769    let lower = file.to_lowercase();
3770    let segments: Vec<&str> = lower.split(|c| c == '/' || c == '\\').collect();
3771
3772    // Directory components that indicate a test tree.
3773    const TEST_DIRS: &[&str] = &[
3774        "tests",
3775        "test",
3776        "specs",
3777        "spec",
3778        "__tests__",
3779        "__mocks__",
3780        "testing",
3781        "testcases",
3782    ];
3783    if segments.iter().rev().skip(1).any(|s| TEST_DIRS.contains(s)) {
3784        return true;
3785    }
3786
3787    // Filename patterns (language-specific conventions).
3788    if let Some(filename) = segments.last() {
3789        // Python: test_foo.py, foo_test.py
3790        if filename.starts_with("test_") || filename.ends_with("_test.py") {
3791            return true;
3792        }
3793        // Go: foo_test.go
3794        if filename.ends_with("_test.go") {
3795            return true;
3796        }
3797        // Rust: foo_test.rs
3798        if filename.ends_with("_test.rs") {
3799            return true;
3800        }
3801        // Swift: FooTests.swift, FooSpec.swift
3802        if filename.ends_with("tests.swift") || filename.ends_with("spec.swift") {
3803            return true;
3804        }
3805        // JS/TS: foo.test.ts, foo.spec.ts, foo.test.tsx, foo.spec.tsx, foo.test.js
3806        if filename.contains(".test.") || filename.contains(".spec.") {
3807            return true;
3808        }
3809        // Ruby: foo_spec.rb
3810        if filename.ends_with("_spec.rb") {
3811            return true;
3812        }
3813        // Java/Kotlin: FooTest.java, FooTests.kt
3814        if filename.ends_with("test.java")
3815            || filename.ends_with("tests.java")
3816            || filename.ends_with("test.kt")
3817            || filename.ends_with("tests.kt")
3818        {
3819            return true;
3820        }
3821        // C#: FooTests.cs
3822        if filename.ends_with("tests.cs") || filename.ends_with("test.cs") {
3823            return true;
3824        }
3825    }
3826
3827    false
3828}
3829
3830// ---------------------------------------------------------------------------
3831// Text expansion helpers
3832// ---------------------------------------------------------------------------
3833
3834/// Expand a dotted qname like `App.MyModule.refreshDriftPlayhead` into a
3835/// string that contains both the original and all word fragments.
3836fn expand_identifier(qname: &str) -> String {
3837    let mut parts: Vec<String> = vec![qname.to_string()];
3838
3839    for segment in qname.split('.') {
3840        parts.push(segment.to_lowercase());
3841        for word in split_camel(segment) {
3842            let w = word.to_lowercase();
3843            if w.len() >= 2 {
3844                parts.push(w);
3845            }
3846        }
3847    }
3848
3849    parts.dedup();
3850    parts.join(" ")
3851}
3852
3853/// Expand arbitrary text (signature, file path) by splitting on non-alphanumeric
3854/// chars and camelCase boundaries, appending lowercased words.
3855fn expand_text(text: &str) -> String {
3856    let mut words: Vec<String> = vec![text.to_string()];
3857
3858    let tokens: Vec<&str> = text
3859        .split(|c: char| !c.is_alphanumeric())
3860        .filter(|s| s.len() >= 2)
3861        .collect();
3862
3863    for tok in tokens {
3864        words.push(tok.to_lowercase());
3865        for word in split_camel(tok) {
3866            let w = word.to_lowercase();
3867            if w.len() >= 2 {
3868                words.push(w);
3869            }
3870        }
3871    }
3872
3873    words.dedup();
3874    words.join(" ")
3875}
3876
3877/// Split a camelCase or PascalCase token into constituent words.
3878fn split_camel(s: &str) -> Vec<&str> {
3879    let bytes = s.as_bytes();
3880    let mut starts = vec![0usize];
3881
3882    for i in 1..bytes.len() {
3883        let prev = bytes[i - 1];
3884        let curr = bytes[i];
3885        // lower→upper ("dD") or digit→upper ("1D")
3886        if curr.is_ascii_uppercase() && (prev.is_ascii_lowercase() || prev.is_ascii_digit()) {
3887            starts.push(i);
3888        }
3889        // consecutive uppercase followed by lower ("XMLParser" → "XML","Parser")
3890        if i + 1 < bytes.len()
3891            && prev.is_ascii_uppercase()
3892            && curr.is_ascii_uppercase()
3893            && bytes[i + 1].is_ascii_lowercase()
3894        {
3895            starts.push(i);
3896        }
3897    }
3898
3899    starts.push(bytes.len());
3900    starts
3901        .windows(2)
3902        .map(|w| &s[w[0]..w[1]])
3903        .filter(|seg| !seg.is_empty())
3904        .collect()
3905}
3906
3907// ---------------------------------------------------------------------------
3908// Tests
3909// ---------------------------------------------------------------------------
3910
3911// ---------------------------------------------------------------------------
3912// Document search — broad corpus beyond semantic symbols
3913// ---------------------------------------------------------------------------
3914
3915/// A searchable chunk from a non-code file (markdown, config, manifest, etc.).
3916#[derive(Debug, Clone)]
3917pub struct SearchDoc {
3918    /// Stable content-addressable id: sha256 hex of "{path}:{span_start}".
3919    pub doc_id: String,
3920    /// Broad kind category.
3921    pub kind: DocKind,
3922    /// Relative file path from project root.
3923    pub path: String,
3924    /// Optional start line (1-based). None = whole-file chunk.
3925    pub span_start: Option<u32>,
3926    /// Human-readable title (heading text, target name, key path, etc.).
3927    pub title: String,
3928    /// Searchable body text (stripped of formatting).
3929    pub body_text: String,
3930    /// qname of the nearest semantic symbol that "owns" this doc, if known.
3931    pub owner_symbol_id: Option<String>,
3932}
3933
3934impl SearchDoc {
3935    pub fn new(
3936        kind: DocKind,
3937        path: impl Into<String>,
3938        span_start: Option<u32>,
3939        title: impl Into<String>,
3940        body_text: impl Into<String>,
3941    ) -> Self {
3942        let path = path.into();
3943        let span_str = span_start.map(|l| l.to_string()).unwrap_or_default();
3944        let raw = format!("{}:{}", path, span_str);
3945        // Simple djb2-style deterministic id — no external dep needed.
3946        let mut hash: u64 = 5381;
3947        for b in raw.bytes() {
3948            hash = hash.wrapping_mul(33).wrapping_add(b as u64);
3949        }
3950        let doc_id = format!("doc_{:016x}", hash);
3951        Self {
3952            doc_id,
3953            kind,
3954            path,
3955            span_start,
3956            title: title.into(),
3957            body_text: body_text.into(),
3958            owner_symbol_id: None,
3959        }
3960    }
3961}
3962
3963/// Broad category for a document chunk.
3964#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3965pub enum DocKind {
3966    Markdown,
3967    Config, // JSON, TOML, YAML, plist
3968    Html,
3969    Css,
3970    Manifest,    // Package.swift, Cargo.toml, pubspec.yaml, package.json (as manifests)
3971    BuildScript, // Makefile, Fastfile, Podfile, Gemfile
3972    Other,
3973}
3974
3975impl DocKind {
3976    pub fn as_str(self) -> &'static str {
3977        match self {
3978            DocKind::Markdown => "markdown",
3979            DocKind::Config => "config",
3980            DocKind::Html => "html",
3981            DocKind::Css => "css",
3982            DocKind::Manifest => "manifest",
3983            DocKind::BuildScript => "build_script",
3984            DocKind::Other => "other",
3985        }
3986    }
3987}
3988
3989/// A ranked document search hit.
3990#[derive(Debug, Clone)]
3991pub struct DocHit {
3992    pub bm25_score: f64,
3993    pub doc_id: String,
3994    pub kind: String,
3995    pub path: String,
3996    pub span_start: Option<u32>,
3997    pub title: String,
3998    /// First 200 chars of body_text for preview.
3999    pub preview: String,
4000    pub owner_symbol_id: Option<String>,
4001}
4002
4003/// FTS index for document/resource chunks (separate from symbol FTS).
4004pub struct SearchDocsDb {
4005    conn: Connection,
4006}
4007
4008impl SearchDocsDb {
4009    const SCHEMA_VER: i64 = 1;
4010
4011    pub fn open(db_path: &Path) -> rusqlite::Result<Self> {
4012        let conn = Connection::open(db_path)?;
4013        conn.execute_batch(
4014            "PRAGMA journal_mode=WAL;
4015             PRAGMA synchronous=NORMAL;
4016             PRAGMA temp_store=MEMORY;
4017             -- Wait out short-lived writers (e.g. asd-serve holding a sync
4018             -- transaction) instead of failing instantly with SQLITE_BUSY.
4019             -- A failed cache sync used to leave the DB cold forever; the
4020             -- timeout plus the open-time self-heal makes that unreachable.
4021             PRAGMA busy_timeout=5000;",
4022        )?;
4023        let db_bytes = std::fs::metadata(db_path)
4024            .map(|m| m.len() as usize)
4025            .unwrap_or(0);
4026        let mut cache_kb: usize = ((db_bytes * 8 / 10) / 1024).clamp(8_192, 65_536);
4027        let mut mmap_bytes: u64 = 268_435_456;
4028        if let Some(project_dir) = db_path.parent() {
4029            let cfg_path = project_dir.join(".asd").join("config.toml");
4030            if let Ok(raw) = std::fs::read_to_string(&cfg_path) {
4031                if let Ok(table) = raw.parse::<toml::Table>() {
4032                    if let Some(perf) = table.get("performance").and_then(|v| v.as_table()) {
4033                        if let Some(v) = perf.get("cache_size_kb").and_then(|v| v.as_integer()) {
4034                            cache_kb = (v as usize).clamp(1_024, 131_072);
4035                        }
4036                        if let Some(v) = perf.get("mmap_size_mb").and_then(|v| v.as_integer()) {
4037                            mmap_bytes = (v as u64).clamp(64, 4096) * 1024 * 1024;
4038                        }
4039                    }
4040                }
4041            }
4042        }
4043        conn.execute_batch(&format!(
4044            "PRAGMA mmap_size={mmap_bytes}; PRAGMA cache_size=-{cache_kb};"
4045        ))?;
4046        let db = Self { conn };
4047        db.ensure_schema()?;
4048        Ok(db)
4049    }
4050
4051    fn ensure_schema(&self) -> rusqlite::Result<()> {
4052        let current: i64 = self
4053            .conn
4054            .query_row("SELECT version FROM asd_docs_meta LIMIT 1", [], |r| {
4055                r.get(0)
4056            })
4057            .unwrap_or(0);
4058
4059        if current != Self::SCHEMA_VER {
4060            self.conn.execute_batch(
4061                "DROP TABLE IF EXISTS asd_search_docs;
4062                 DROP TABLE IF EXISTS asd_docs_meta;",
4063            )?;
4064        }
4065
4066        self.conn.execute_batch(&format!(
4067            "CREATE VIRTUAL TABLE IF NOT EXISTS asd_search_docs USING fts5(
4068                doc_id          UNINDEXED,
4069                kind            UNINDEXED,
4070                path            UNINDEXED,
4071                span_start      UNINDEXED,
4072                owner_symbol_id UNINDEXED,
4073                title,
4074                body_text,
4075                tokenize = 'unicode61 remove_diacritics 1'
4076            );
4077            CREATE TABLE IF NOT EXISTS asd_docs_meta (version INTEGER PRIMARY KEY);
4078            INSERT OR IGNORE INTO asd_docs_meta VALUES ({});",
4079            Self::SCHEMA_VER
4080        ))
4081    }
4082
4083    /// Atomically replace all document chunks.
4084    pub fn rebuild(&self, docs: &[SearchDoc]) -> rusqlite::Result<()> {
4085        self.conn.execute_batch("DELETE FROM asd_search_docs;")?;
4086        for doc in docs {
4087            self.conn.execute(
4088                "INSERT INTO asd_search_docs(doc_id, kind, path, span_start, owner_symbol_id, title, body_text)
4089                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
4090                params![
4091                    doc.doc_id,
4092                    doc.kind.as_str(),
4093                    doc.path,
4094                    doc.span_start,
4095                    doc.owner_symbol_id,
4096                    doc.title,
4097                    doc.body_text,
4098                ],
4099            )?;
4100        }
4101        Ok(())
4102    }
4103
4104    pub fn count(&self) -> rusqlite::Result<i64> {
4105        self.conn
4106            .query_row("SELECT COUNT(*) FROM asd_search_docs", [], |r| r.get(0))
4107    }
4108
4109    /// Full-text search over document chunks. Returns up to `limit` hits ranked by BM25.
4110    pub fn search(
4111        &self,
4112        tokens: &[String],
4113        limit: usize,
4114        kinds: Option<&[&str]>,
4115    ) -> rusqlite::Result<Vec<DocHit>> {
4116        let filtered: Vec<&String> = tokens.iter().filter(|t| !is_stopword(t)).collect();
4117        if filtered.is_empty() {
4118            return Ok(vec![]);
4119        }
4120        let match_expr = filtered
4121            .iter()
4122            .map(|t| format!("\"{}\"", t.replace('"', "")))
4123            .collect::<Vec<_>>()
4124            .join(" OR ");
4125
4126        let kind_filter = kinds
4127            .map(|ks| {
4128                let list = ks
4129                    .iter()
4130                    .map(|k| format!("'{}'", k))
4131                    .collect::<Vec<_>>()
4132                    .join(",");
4133                format!(" AND kind IN ({})", list)
4134            })
4135            .unwrap_or_default();
4136
4137        let sql = format!(
4138            "SELECT doc_id, kind, path, span_start, owner_symbol_id, title, body_text,
4139                    -bm25(asd_search_docs, 5.0, 3.0) AS score
4140             FROM asd_search_docs
4141             WHERE asd_search_docs MATCH ?1{kind_filter}
4142             ORDER BY score DESC
4143             LIMIT {limit}"
4144        );
4145
4146        let mut stmt = self.conn.prepare(&sql)?;
4147        let hits = stmt
4148            .query_map(params![match_expr], |row| {
4149                let body: String = row.get(6)?;
4150                let preview = body.chars().take(200).collect::<String>();
4151                Ok(DocHit {
4152                    doc_id: row.get(0)?,
4153                    kind: row.get(1)?,
4154                    path: row.get(2)?,
4155                    span_start: row.get(3)?,
4156                    owner_symbol_id: row.get(4)?,
4157                    title: row.get(5)?,
4158                    preview,
4159                    bm25_score: row.get(7)?,
4160                })
4161            })?
4162            .filter_map(|r| r.ok())
4163            .collect();
4164        Ok(hits)
4165    }
4166
4167    pub fn is_empty(&self) -> bool {
4168        self.count().unwrap_or(0) == 0
4169    }
4170}
4171
4172// ---------------------------------------------------------------------------
4173// Effect detail reason — one-line human + agent explanation
4174// ---------------------------------------------------------------------------
4175
4176/// Return a one-line explanation of an effect declaration's verification state.
4177///
4178/// Examples:
4179///   "ok — verified by test_observed at 2024-01-15"
4180///   "effects declared but not verified — run 'asd verify-effects'"
4181///   "mismatch — unexpected for StateIO (expected: declared)"
4182///   "no effects declared"
4183pub fn effect_detail_reason(decl: Option<&crate::schema::EffectDecl>) -> String {
4184    let Some(decl) = decl else {
4185        return "no effects declared".to_string();
4186    };
4187    if decl.declared.is_empty() {
4188        return "no effects declared".to_string();
4189    }
4190    // Runtime-trace evidence is the strongest signal (real execution observed
4191    // the effects), so it takes precedence in the badge when present. The
4192    // derived confidence already folds the static prior + accumulated counts.
4193    if let Some(rt) = &decl.runtime {
4194        let total = rt.confirmations + rt.contradictions;
4195        if total > 0 {
4196            let verdict = if rt.contradictions == 0 {
4197                "runtime-verified"
4198            } else {
4199                "runtime-contested"
4200            };
4201            return format!(
4202                "{} — confidence {:.2} ({}✓/{}✗ over {} trace{})",
4203                verdict,
4204                rt.confidence(),
4205                rt.confirmations,
4206                rt.contradictions,
4207                total,
4208                if total == 1 { "" } else { "s" },
4209            );
4210        }
4211    }
4212    let Some(ref v) = decl.verification else {
4213        return format!(
4214            "effects declared ({} effect{}) but not verified — run 'asd verify-effects'",
4215            decl.declared.len(),
4216            if decl.declared.len() == 1 { "" } else { "s" }
4217        );
4218    };
4219    match v.status {
4220        crate::schema::VerificationStatus::Ok => {
4221            let source = format!("{:?}", v.by).to_lowercase().replace("_", "-");
4222            let date = v.at.format("%Y-%m-%d").to_string();
4223            format!("ok — verified by {} at {}", source, date)
4224        }
4225        crate::schema::VerificationStatus::Unverified => {
4226            "unverified — verification run did not complete".to_string()
4227        }
4228        crate::schema::VerificationStatus::Mismatch => {
4229            if v.mismatches.is_empty() {
4230                "mismatch — declared effects not observed at runtime".to_string()
4231            } else {
4232                let mm = &v.mismatches[0];
4233                format!("mismatch — {} for {:?}", mm.kind, mm.effect)
4234            }
4235        }
4236    }
4237}
4238
4239#[cfg(test)]
4240mod tests {
4241    use super::*;
4242
4243    // --- effect_detail_reason: runtime-trace badge (t-001 slice 3) -------
4244
4245    fn decl_with_runtime(conf: u64, contra: u64, prior: f64) -> crate::schema::EffectDecl {
4246        use crate::schema::*;
4247        EffectDecl {
4248            symbol_id: "s".into(),
4249            declared: vec![Effect::new(EffectCategory::IoNetOut)],
4250            transitive: vec![],
4251            // Runtime ingest also sets a RuntimeTracer verification; the badge
4252            // must prefer the richer runtime evidence over this.
4253            verification: Some(Verification {
4254                by: VerificationSource::RuntimeTracer,
4255                at: chrono::Utc::now(),
4256                status: if contra == 0 {
4257                    VerificationStatus::Ok
4258                } else {
4259                    VerificationStatus::Mismatch
4260                },
4261                mismatches: vec![],
4262            }),
4263            confidence: Some(RuntimeEvidence::derive_confidence(prior, conf, contra)),
4264            runtime: Some(RuntimeEvidence {
4265                confirmations: conf,
4266                contradictions: contra,
4267                prior,
4268                last_trace_id: Some("trc".into()),
4269                last_observed_at: chrono::Utc::now(),
4270            }),
4271            matched_policy: None,
4272        }
4273    }
4274
4275    #[test]
4276    fn effect_detail_runtime_verified_badge() {
4277        let label = effect_detail_reason(Some(&decl_with_runtime(5, 0, 0.5)));
4278        assert!(label.starts_with("runtime-verified"), "got: {label}");
4279        assert!(label.contains("5✓/0✗"), "got: {label}");
4280        assert!(label.contains("over 5 traces"), "got: {label}");
4281        assert!(label.contains("confidence 0."), "got: {label}");
4282    }
4283
4284    #[test]
4285    fn effect_detail_runtime_contested_when_contradictions() {
4286        let label = effect_detail_reason(Some(&decl_with_runtime(2, 3, 0.5)));
4287        assert!(label.starts_with("runtime-contested"), "got: {label}");
4288        assert!(label.contains("2✓/3✗"), "got: {label}");
4289    }
4290
4291    #[test]
4292    fn effect_detail_single_trace_is_singular() {
4293        let label = effect_detail_reason(Some(&decl_with_runtime(1, 0, 0.5)));
4294        assert!(
4295            label.contains("over 1 trace)"),
4296            "expected singular, got: {label}"
4297        );
4298    }
4299
4300    #[test]
4301    fn effect_detail_runtime_takes_precedence_over_static_label() {
4302        // Static verification is Ok, but the runtime badge (with confidence)
4303        // must win over the plain "ok — verified by ..." label.
4304        let label = effect_detail_reason(Some(&decl_with_runtime(3, 0, 0.6)));
4305        assert!(
4306            !label.starts_with("ok — verified"),
4307            "static label leaked: {label}"
4308        );
4309    }
4310
4311    #[test]
4312    fn effect_detail_falls_back_to_static_without_runtime() {
4313        use crate::schema::*;
4314        let decl = EffectDecl {
4315            symbol_id: "s".into(),
4316            declared: vec![Effect::new(EffectCategory::IoNetOut)],
4317            transitive: vec![],
4318            verification: Some(Verification {
4319                by: VerificationSource::StaticChecker,
4320                at: chrono::Utc::now(),
4321                status: VerificationStatus::Ok,
4322                mismatches: vec![],
4323            }),
4324            confidence: None,
4325            runtime: None,
4326            matched_policy: None,
4327        };
4328        let label = effect_detail_reason(Some(&decl));
4329        // Existing renderer lowercases the Debug name ("StaticChecker" → "staticchecker").
4330        assert!(
4331            label.starts_with("ok — verified by staticchecker"),
4332            "got: {label}"
4333        );
4334    }
4335
4336    #[test]
4337    fn effect_detail_none_and_empty() {
4338        assert_eq!(effect_detail_reason(None), "no effects declared");
4339    }
4340
4341    // Plan J t-005 / 1.0.79 token economy: consistent → returns Null.
4342    // Callers omit the field entirely; agent infers "indexes agree"
4343    // from absence.
4344    #[test]
4345    fn index_consistency_consistent_returns_null() {
4346        let v = compute_index_consistency(100, 100);
4347        assert!(v.is_null(), "consistent → Null; got: {v:#?}");
4348    }
4349
4350    #[test]
4351    fn index_consistency_asg_ahead_advises_reindex() {
4352        // ASG has 412, FTS only 408 — the classic "FTS rebuild
4353        // failed mid-pass" pattern. Advice tells the agent what
4354        // to run.
4355        let v = compute_index_consistency(412, 408);
4356        assert_eq!(v["delta"], 4);
4357        assert_eq!(v["consistent"], false);
4358        let advice = v["advice"].as_str().expect("advice when divergent");
4359        assert!(advice.contains("4 symbols"), "got: {advice}");
4360        assert!(advice.contains("'asd index'"), "got: {advice}");
4361    }
4362
4363    #[test]
4364    fn index_consistency_singular_grammar() {
4365        // Off-by-one: "1 symbol" not "1 symbols".
4366        let v = compute_index_consistency(101, 100);
4367        let advice = v["advice"].as_str().unwrap();
4368        assert!(advice.contains("1 symbol "), "got: {advice}");
4369        assert!(!advice.contains("1 symbols"), "got: {advice}");
4370    }
4371
4372    #[test]
4373    fn index_consistency_fts_ahead_advises_reindex_with_stale_wording() {
4374        // Rare inverse: FTS holds entries no longer in the ASG.
4375        // Advice should say "stale" rather than "not in cache" so
4376        // the agent reads the direction of the drift correctly.
4377        let v = compute_index_consistency(100, 103);
4378        assert_eq!(v["delta"], -3);
4379        assert_eq!(v["consistent"], false);
4380        let advice = v["advice"].as_str().unwrap();
4381        assert!(advice.contains("3 stale symbols"), "got: {advice}");
4382        assert!(advice.contains("'asd index'"), "got: {advice}");
4383    }
4384
4385    #[test]
4386    fn index_consistency_handles_empty_repo() {
4387        // 0/0 is consistent (just empty) → Null (1.0.79).
4388        let v = compute_index_consistency(0, 0);
4389        assert!(v.is_null());
4390    }
4391
4392    // AcmeFlow refinement (1.0.77): stale_warning_classified
4393    // distinguishes critical (empty/broken FTS) from soft (just-past-
4394    // age-threshold). Critical fires regardless of age; soft is
4395    // demotable when the consuming handler had a successful query.
4396    #[test]
4397    fn stale_classified_empty_index_is_critical() {
4398        let dir = tempfile::tempdir().unwrap();
4399        let db_path = dir.path().join("empty.db");
4400        // Open and close to create the table but populate nothing.
4401        let _ = SearchFtsDb::open(&db_path).unwrap();
4402        let w = stale_warning_classified(&db_path, 3600).expect("empty index must warn");
4403        assert_eq!(w.severity, StaleSeverity::Critical);
4404        assert!(w.message.contains("empty"), "got: {}", w.message);
4405    }
4406
4407    #[test]
4408    fn stale_classified_returns_none_when_no_fts_file() {
4409        // Nonexistent path → fts open fails → None (no warning at all).
4410        let dir = tempfile::tempdir().unwrap();
4411        let db_path = dir.path().join("does_not_exist_yet.db");
4412        // Don't even create the file — SearchFtsDb::open creates one
4413        // and the empty branch fires, so we'd get Critical above. To
4414        // hit the "no fts at all" return we'd need a path that fails
4415        // to open, but tempdir paths always open. Skip the negative
4416        // case here; the empty-index test covers the more common path.
4417        // Just verify a successful open + populate.
4418        use crate::schema::{Position, Symbol, SymbolKind};
4419        let fts = SearchFtsDb::open(&db_path).unwrap();
4420        let sym = Symbol {
4421            symbol_id: "s1".into(),
4422            symbol_fp: "fp".into(),
4423            qname: "p.f".into(),
4424            language: "python".into(),
4425            kind: SymbolKind::Function,
4426            file: "p.py".into(),
4427            start: Position { line: 1, col: 0 },
4428            end: Position { line: 2, col: 0 },
4429            signature: None,
4430            doc: None,
4431        };
4432        fts.rebuild(&[sym]).unwrap();
4433        // Mark fresh — last_indexed_at returns now.
4434        let now = std::time::SystemTime::now()
4435            .duration_since(std::time::UNIX_EPOCH)
4436            .unwrap()
4437            .as_secs() as i64;
4438        fts.conn
4439            .execute(
4440                "INSERT OR REPLACE INTO asd_index_meta(key, value) VALUES('indexed_at', ?1)",
4441                rusqlite::params![now.to_string()],
4442            )
4443            .unwrap();
4444        // Fresh → soft threshold not crossed → no warning.
4445        assert!(stale_warning_classified(&db_path, SOFT_STALE_THRESHOLD_SECS).is_none());
4446    }
4447
4448    #[test]
4449    fn stale_classified_soft_when_past_threshold_but_fts_healthy() {
4450        use crate::schema::{Position, Symbol, SymbolKind};
4451        let dir = tempfile::tempdir().unwrap();
4452        let db_path = dir.path().join("aged.db");
4453        let fts = SearchFtsDb::open(&db_path).unwrap();
4454        let sym = Symbol {
4455            symbol_id: "s1".into(),
4456            symbol_fp: "fp".into(),
4457            qname: "p.f".into(),
4458            language: "python".into(),
4459            kind: SymbolKind::Function,
4460            file: "p.py".into(),
4461            start: Position { line: 1, col: 0 },
4462            end: Position { line: 2, col: 0 },
4463            signature: None,
4464            doc: None,
4465        };
4466        fts.rebuild(&[sym]).unwrap();
4467        // Stamp last_indexed_at to 48 hours ago.
4468        let now = std::time::SystemTime::now()
4469            .duration_since(std::time::UNIX_EPOCH)
4470            .unwrap()
4471            .as_secs() as i64;
4472        let two_days_ago = now - 48 * 3600;
4473        fts.conn
4474            .execute(
4475                "INSERT OR REPLACE INTO asd_index_meta(key, value) VALUES('indexed_at', ?1)",
4476                rusqlite::params![two_days_ago.to_string()],
4477            )
4478            .unwrap();
4479        let w = stale_warning_classified(&db_path, SOFT_STALE_THRESHOLD_SECS)
4480            .expect("48h-old at 24h threshold must warn");
4481        assert_eq!(w.severity, StaleSeverity::Soft);
4482        assert!(w.age_secs >= 48 * 3600);
4483        assert_eq!(w.indexed_at, Some(two_days_ago));
4484    }
4485
4486    #[test]
4487    fn stale_classified_severity_serializes_as_snake_case() {
4488        // Locked because the MCP response shape contracts on this.
4489        let critical = serde_json::to_value(StaleSeverity::Critical).unwrap();
4490        assert_eq!(critical, serde_json::Value::String("critical".to_string()));
4491        let soft = serde_json::to_value(StaleSeverity::Soft).unwrap();
4492        assert_eq!(soft, serde_json::Value::String("soft".to_string()));
4493    }
4494
4495    #[test]
4496    fn stopword_filtering() {
4497        // Pure stopword queries produce no tokens → empty results, not a panic.
4498        let dir = tempfile::tempdir().unwrap();
4499        let db_path = dir.path().join("test.db");
4500        let fts = SearchFtsDb::open(&db_path).unwrap();
4501        use crate::schema::{Position, Symbol, SymbolKind};
4502        let sym = Symbol {
4503            symbol_id: "s1".into(),
4504            symbol_fp: "fp".into(),
4505            qname: "App.Foo.punchIn".into(),
4506            language: "swift".into(),
4507            kind: SymbolKind::Method,
4508            file: "App/Foo.swift".into(),
4509            start: Position { line: 1, col: 0 },
4510            end: Position { line: 5, col: 0 },
4511            signature: Some("func punchIn(over existingClip: Clip)".into()),
4512            doc: None,
4513        };
4514        fts.rebuild(std::slice::from_ref(&sym)).unwrap();
4515
4516        // "over" alone is a stopword — filtered → empty tokens → no results.
4517        let hits = fts.search("over", &FtsFilters::default(), 10).unwrap();
4518        assert!(
4519            hits.is_empty(),
4520            "'over' is a stopword, should return no hits"
4521        );
4522
4523        // "playhead over clips" — only "playhead" and "clips" survive filtering.
4524        // "punchIn(over existingClip)" has no "playhead" or "clips" → no match.
4525        let hits2 = fts
4526            .search("playhead over clips", &FtsFilters::default(), 10)
4527            .unwrap();
4528        assert!(
4529            hits2.is_empty(),
4530            "stopword-only overlap should not match punchIn"
4531        );
4532    }
4533
4534    #[test]
4535    fn split_camel_basic() {
4536        assert_eq!(
4537            split_camel("refreshDriftPlayhead"),
4538            vec!["refresh", "Drift", "Playhead"]
4539        );
4540        assert_eq!(
4541            split_camel("DriftSynthPool"),
4542            vec!["Drift", "Synth", "Pool"]
4543        );
4544        assert_eq!(split_camel("XMLParser"), vec!["XML", "Parser"]);
4545        assert_eq!(split_camel("simple"), vec!["simple"]);
4546    }
4547
4548    #[test]
4549    fn expand_identifier_roundtrip() {
4550        let exp = expand_identifier("App.AcmeFlow.refreshDriftPlayhead");
4551        assert!(exp.contains("refreshDriftPlayhead"), "original preserved");
4552        assert!(exp.contains("refresh"), "camel split");
4553        assert!(exp.contains("playhead"), "camel split tail");
4554        assert!(exp.contains("acmeflow"), "segment lowercased");
4555    }
4556
4557    #[test]
4558    fn is_test_file_detection() {
4559        assert!(is_test_file(
4560            "Packages/AudioEngine/Tests/KarplusStrongTests.swift"
4561        ));
4562        assert!(is_test_file("tests/test_charge_card.py"));
4563        assert!(is_test_file("src/__tests__/auth.test.ts"));
4564        assert!(is_test_file("payments/test_stripe.py"));
4565        assert!(is_test_file("pkg/payments/charge_test.go"));
4566        assert!(is_test_file("src/auth/auth_spec.rb"));
4567        assert!(!is_test_file("App/AcmeFlow/AcmeFlowApp.swift"));
4568        assert!(!is_test_file("src/payments/charge.py"));
4569        assert!(!is_test_file(
4570            "Packages/AudioEngine/Sources/KarplusStrong.swift"
4571        ));
4572    }
4573
4574    #[test]
4575    fn tier_classification() {
4576        // Production — tier 0
4577        assert_eq!(symbol_tier("App/ViewModel/DriftPlayheadViewModel.swift"), 0);
4578        assert_eq!(symbol_tier("src/payments/charge.py"), 0);
4579        assert_eq!(
4580            symbol_tier("Packages/AudioEngine/Sources/KarplusStrong.swift"),
4581            0
4582        );
4583
4584        // Utility — tier 1
4585        assert_eq!(symbol_tier("App/Previews/DriftView_Previews.swift"), 1);
4586        assert_eq!(
4587            symbol_tier("App/ViewModel/DriftView_Previews.swift"),
4588            1,
4589            "preview filename"
4590        );
4591        assert_eq!(symbol_tier("Mocks/MockAudioEngine.swift"), 1);
4592        assert_eq!(symbol_tier("App/SampleData/TimelineFixture.swift"), 1);
4593        assert_eq!(symbol_tier("App/Generated/Schema.generated.swift"), 1);
4594
4595        // Test — tier 2
4596        assert_eq!(symbol_tier("Tests/DriftTests/PlayheadTests.swift"), 2);
4597        assert_eq!(symbol_tier("tests/test_charge.py"), 2);
4598        assert_eq!(symbol_tier("pkg/payments/charge_test.go"), 2);
4599    }
4600
4601    #[test]
4602    fn utility_symbols_rank_below_production() {
4603        let dir = tempfile::tempdir().unwrap();
4604        let db_path = dir.path().join("test.db");
4605        let fts = SearchFtsDb::open(&db_path).unwrap();
4606
4607        use crate::schema::{Position, Symbol, SymbolKind};
4608        let make = |id: &str, qname: &str, file: &str| Symbol {
4609            symbol_id: id.into(),
4610            symbol_fp: format!("fp_{id}"),
4611            qname: qname.into(),
4612            language: "swift".into(),
4613            kind: SymbolKind::Method,
4614            file: file.into(),
4615            start: Position { line: 1, col: 0 },
4616            end: Position { line: 5, col: 0 },
4617            signature: None,
4618            doc: None,
4619        };
4620
4621        let prod = make("prod", "App.VM.refreshDriftPlayhead", "App/ViewModel.swift");
4622        let preview = make(
4623            "preview",
4624            "App.Previews.refreshDriftPlayhead",
4625            "App/Previews/DriftView_Previews.swift",
4626        );
4627
4628        fts.rebuild(&[prod, preview]).unwrap();
4629
4630        let hits = fts
4631            .search("refresh drift playhead", &FtsFilters::default(), 10)
4632            .unwrap();
4633        assert_eq!(hits.len(), 2, "both prod and utility included by default");
4634        // Production should rank first due to tier penalty on utility.
4635        assert_eq!(hits[0].symbol_id, "prod", "production ranks before preview");
4636        assert_eq!(hits[0].tier, 0);
4637        assert_eq!(hits[1].tier, 1);
4638    }
4639
4640    #[test]
4641    fn fts_excludes_tests_by_default() {
4642        let dir = tempfile::tempdir().unwrap();
4643        let db_path = dir.path().join("test.db");
4644        let fts = SearchFtsDb::open(&db_path).unwrap();
4645
4646        use crate::schema::{Position, Symbol, SymbolKind};
4647        let make_sym = |id: &str, qname: &str, file: &str| Symbol {
4648            symbol_id: id.to_string(),
4649            symbol_fp: format!("fp_{id}"),
4650            qname: qname.to_string(),
4651            language: "swift".to_string(),
4652            kind: SymbolKind::Method,
4653            file: file.to_string(),
4654            start: Position { line: 1, col: 0 },
4655            end: Position { line: 10, col: 0 },
4656            signature: None,
4657            doc: None,
4658        };
4659
4660        let prod = make_sym(
4661            "sym_prod",
4662            "App.ViewModel.refreshDriftPlayhead",
4663            "App/ViewModel.swift",
4664        );
4665        let test = make_sym(
4666            "sym_test",
4667            "Tests.DriftTests.testRefreshPlayhead",
4668            "Tests/DriftTests.swift",
4669        );
4670
4671        fts.rebuild(&[prod, test]).unwrap();
4672        assert!(fts.has_data());
4673
4674        // Default: tests excluded.
4675        let hits = fts.search("playhead", &FtsFilters::default(), 10).unwrap();
4676        assert_eq!(hits.len(), 1, "only prod symbol by default");
4677        assert_eq!(hits[0].symbol_id, "sym_prod");
4678
4679        // With include_tests: both returned.
4680        let hits_all = fts
4681            .search(
4682                "playhead",
4683                &FtsFilters {
4684                    include_tests: true,
4685                    ..Default::default()
4686                },
4687                10,
4688            )
4689            .unwrap();
4690        assert_eq!(hits_all.len(), 2, "both when include_tests");
4691
4692        // Plan A t-006: with tests_only, just the test symbol.
4693        let hits_test_only = fts
4694            .search(
4695                "playhead",
4696                &FtsFilters {
4697                    tests_only: true,
4698                    ..Default::default()
4699                },
4700                10,
4701            )
4702            .unwrap();
4703        assert_eq!(hits_test_only.len(), 1, "only test symbol when tests_only");
4704        assert_eq!(hits_test_only[0].symbol_id, "sym_test");
4705
4706        // tests_only takes precedence over include_tests=false.
4707        let hits_precedence = fts
4708            .search(
4709                "playhead",
4710                &FtsFilters {
4711                    tests_only: true,
4712                    include_tests: false,
4713                    ..Default::default()
4714                },
4715                10,
4716            )
4717            .unwrap();
4718        assert_eq!(
4719            hits_precedence.len(),
4720            1,
4721            "tests_only overrides include_tests=false"
4722        );
4723        assert_eq!(hits_precedence[0].symbol_id, "sym_test");
4724    }
4725
4726    #[test]
4727    fn fts_round_trip() {
4728        let dir = tempfile::tempdir().unwrap();
4729        let db_path = dir.path().join("test.db");
4730        let fts = SearchFtsDb::open(&db_path).unwrap();
4731
4732        use crate::schema::{Position, Symbol, SymbolKind};
4733        let sym = Symbol {
4734            symbol_id: "sym_abc".into(),
4735            symbol_fp: "fp_abc".into(),
4736            qname: "App.ViewModel.refreshDriftPlayhead".into(),
4737            language: "swift".into(),
4738            kind: SymbolKind::Method,
4739            file: "App/ViewModel.swift".into(),
4740            start: Position { line: 10, col: 0 },
4741            end: Position { line: 20, col: 0 },
4742            signature: Some("private func refreshDriftPlayhead()".into()),
4743            doc: Some("Refreshes the drift playhead position".into()),
4744        };
4745
4746        fts.rebuild(std::slice::from_ref(&sym)).unwrap();
4747        assert!(fts.has_data());
4748
4749        let hits = fts.search("playhead", &FtsFilters::default(), 10).unwrap();
4750        assert!(!hits.is_empty(), "should find by qname fragment");
4751        assert_eq!(hits[0].symbol_id, "sym_abc");
4752        assert_eq!(
4753            hits[0].qname, "App.ViewModel.refreshDriftPlayhead",
4754            "orig qname preserved"
4755        );
4756        assert_eq!(hits[0].tier, 0, "production file should be tier 0");
4757
4758        let hits2 = fts
4759            .search("refresh drift", &FtsFilters::default(), 10)
4760            .unwrap();
4761        assert!(!hits2.is_empty(), "should find multi-token");
4762
4763        let hits3 = fts
4764            .search(
4765                "playhead",
4766                &FtsFilters {
4767                    language: Some("python".into()),
4768                    ..Default::default()
4769                },
4770                10,
4771            )
4772            .unwrap();
4773        assert!(hits3.is_empty(), "language filter should exclude swift");
4774    }
4775
4776    #[test]
4777    fn summary_extraction() {
4778        // First sentence from doc.
4779        assert_eq!(
4780            extract_summary(
4781                Some(
4782                    "Updates drift pad visual playhead from transport state. Called on every render tick."
4783                ),
4784                None
4785            ),
4786            "Updates drift pad visual playhead from transport state"
4787        );
4788        // Doc with newline — joined, first sentence extracted.
4789        assert_eq!(
4790            extract_summary(Some("Short summary.\nMore detail here."), None),
4791            "Short summary"
4792        );
4793        // Falls back to signature when doc absent.
4794        assert_eq!(
4795            extract_summary(None, Some("func refreshDriftPlayhead() -> Void")),
4796            "func refreshDriftPlayhead() -> Void"
4797        );
4798        // Returns empty when neither present.
4799        assert_eq!(extract_summary(None, None), "");
4800
4801        // Doc prefix stripping — Rust triple-slash.
4802        assert_eq!(
4803            extract_summary(
4804                Some("/// Updates the drift playhead. Called each frame."),
4805                None
4806            ),
4807            "Updates the drift playhead"
4808        );
4809        // Multi-line Rust doc block.
4810        assert_eq!(
4811            extract_summary(
4812                Some("/// Refreshes visual state.\n/// Must be called on main thread."),
4813                None
4814            ),
4815            "Refreshes visual state"
4816        );
4817        // JavaDoc / Swift style — trailing dot stripped.
4818        assert_eq!(
4819            extract_summary(Some("/** Computes clip boundaries. */"), None),
4820            "Computes clip boundaries"
4821        );
4822        // Python # prefix.
4823        assert_eq!(
4824            extract_summary(Some("# Process the audio buffer."), None),
4825            "Process the audio buffer"
4826        );
4827    }
4828
4829    #[test]
4830    fn layer_classification() {
4831        assert_eq!(
4832            classify_layer("App/Views/DriftPlayheadView.swift", 0, &[]),
4833            "ui"
4834        );
4835        assert_eq!(
4836            classify_layer("App/ViewModel/DriftPlayheadViewModel.swift", 0, &[]),
4837            "viewmodel"
4838        );
4839        assert_eq!(
4840            classify_layer("App/Scheduler/DriftScheduler.swift", 0, &[]),
4841            "scheduler"
4842        );
4843        assert_eq!(
4844            classify_layer("App/Engine/AudioEngine.swift", 0, &[]),
4845            "scheduler"
4846        );
4847        assert_eq!(
4848            classify_layer("App/Storage/ClipRepository.swift", 0, &[]),
4849            "persistence"
4850        );
4851        assert_eq!(
4852            classify_layer("App/Domain/Clip.swift", 0, &[]),
4853            "core_model"
4854        );
4855        assert_eq!(
4856            classify_layer("App/Previews/DriftView_Previews.swift", 1, &[]),
4857            "utility"
4858        );
4859        assert_eq!(classify_layer("Tests/DriftTests.swift", 2, &[]), "tests");
4860        assert_eq!(classify_layer("App/AppDelegate.swift", 0, &[]), "other");
4861    }
4862
4863    #[test]
4864    fn layer_classification_qname_fallback() {
4865        // File named after app, but qname carries the ViewModel suffix.
4866        assert_eq!(
4867            classify_layer_sym("App/AcmeFlow.swift", "AcmeFlowViewModel", 0, &[]),
4868            "viewmodel"
4869        );
4870        assert_eq!(
4871            classify_layer_sym("App/AcmeFlow.swift", "AcmeFlowController", 0, &[]),
4872            "viewmodel"
4873        );
4874        assert_eq!(
4875            classify_layer_sym("App/AcmeFlow.swift", "DriftCompiler", 0, &[]),
4876            "scheduler"
4877        );
4878        assert_eq!(
4879            classify_layer_sym("App/AcmeFlow.swift", "ClipStore", 0, &[]),
4880            "persistence"
4881        );
4882        assert_eq!(
4883            classify_layer_sym("App/AcmeFlow.swift", "DriftEngine", 0, &[]),
4884            "scheduler"
4885        );
4886        // File-based classification still wins when it fires.
4887        assert_eq!(
4888            classify_layer_sym("App/Views/DriftView.swift", "DriftViewModel", 0, &[]),
4889            "ui"
4890        );
4891        // Truly unclassifiable stays other.
4892        assert_eq!(
4893            classify_layer_sym("App/AppDelegate.swift", "AppDelegate", 0, &[]),
4894            "other"
4895        );
4896        // Method qnames: class component must propagate even when the method leaf doesn't match.
4897        assert_eq!(
4898            classify_layer_sym(
4899                "App/AcmeFlow.swift",
4900                "AcmeFlowViewModel.refreshDriftPlayhead",
4901                0,
4902                &[]
4903            ),
4904            "viewmodel"
4905        );
4906        assert_eq!(
4907            classify_layer_sym("App/AcmeFlow.swift", "DriftCompiler.compile", 0, &[]),
4908            "scheduler"
4909        );
4910        assert_eq!(
4911            classify_layer_sym("App/AcmeFlow.swift", "ClipStore.save", 0, &[]),
4912            "persistence"
4913        );
4914        // Rust-style :: separators.
4915        assert_eq!(
4916            classify_layer_sym("src/drift.rs", "AcmeFlowViewModel::refresh", 0, &[]),
4917            "viewmodel"
4918        );
4919    }
4920
4921    #[test]
4922    fn layer_overrides_respected() {
4923        // Keys in overrides must be lowercase (as produced by load_layer_overrides).
4924        let overrides = vec![
4925            ("custominfra".to_string(), "persistence".to_string()),
4926            ("bizlogic".to_string(), "core_model".to_string()),
4927        ];
4928        assert_eq!(
4929            classify_layer("App/CustomInfra/Repo.swift", 0, &overrides),
4930            "persistence"
4931        );
4932        assert_eq!(
4933            classify_layer("App/BizLogic/Workflow.swift", 0, &overrides),
4934            "core_model"
4935        );
4936        // Non-matching path falls through to built-in rules.
4937        assert_eq!(classify_layer("App/Views/Foo.swift", 0, &overrides), "ui");
4938    }
4939
4940    #[test]
4941    fn load_layer_overrides_missing_file() {
4942        let tmp = tempfile::tempdir().unwrap();
4943        let overrides = load_layer_overrides(&tmp.path().join("asd.db"));
4944        assert!(overrides.is_empty());
4945    }
4946
4947    #[test]
4948    fn load_layer_overrides_parses_toml() {
4949        let tmp = tempfile::tempdir().unwrap();
4950        let asd_dir = tmp.path().join(".asd");
4951        std::fs::create_dir_all(&asd_dir).unwrap();
4952        std::fs::write(
4953            asd_dir.join("layers.toml"),
4954            "[patterns]\nInfra = \"persistence\"\nBusiness = \"core_model\"\n",
4955        )
4956        .unwrap();
4957        // load_layer_overrides takes the db_path (not the project root) and uses its parent.
4958        let overrides = load_layer_overrides(&tmp.path().join("asd.db"));
4959        assert_eq!(overrides.len(), 2);
4960        assert!(overrides.contains(&("infra".to_string(), "persistence".to_string())));
4961        assert!(overrides.contains(&("business".to_string(), "core_model".to_string())));
4962    }
4963
4964    #[test]
4965    fn intent_parsing() {
4966        assert_eq!(parse_intent("bugfix"), Some("bugfix"));
4967        assert_eq!(parse_intent("ARCHITECTURE"), Some("architecture"));
4968        assert_eq!(parse_intent("ui"), Some("ui"));
4969        assert_eq!(parse_intent("typo"), None);
4970        // Each valid intent has non-empty focus guidance.
4971        for i in &[
4972            "bugfix",
4973            "feature",
4974            "refactor",
4975            "test",
4976            "architecture",
4977            "ui",
4978        ] {
4979            assert!(!intent_focus(i).is_empty(), "no focus for {i}");
4980        }
4981        // Layer order has 8 entries for every intent.
4982        for i in &[
4983            "bugfix",
4984            "feature",
4985            "refactor",
4986            "test",
4987            "architecture",
4988            "ui",
4989            "",
4990        ] {
4991            assert_eq!(intent_layer_order(i).len(), 8);
4992        }
4993    }
4994
4995    #[test]
4996    fn gather_recency_no_crash_outside_repo() {
4997        // gather_recency must not panic even if git is unavailable or returns non-zero.
4998        // In a git repo this should return a non-empty map; outside one it returns empty.
4999        // Either way it must not panic.
5000        let result = gather_recency(10, 14.0);
5001        // No assertion on content — just verify it didn't panic and types are correct.
5002        for (file, rec) in &result {
5003            assert!(!file.is_empty());
5004            if let Some(days) = rec.last_touched_days {
5005                assert!(days >= 0.0);
5006            }
5007        }
5008    }
5009
5010    // -- Plan E t-004: bulk-fetch fast path for constraint penalties -------
5011
5012    #[test]
5013    fn symbols_with_constraint_penalties_via_sql_returns_matching_ids() {
5014        use crate::schema::{Author, AuthorKind, LedgerEntry, LedgerKind};
5015        let dir = tempfile::tempdir().unwrap();
5016        let db_path = dir.path().join("test.db");
5017        let fts = SearchFtsDb::open(&db_path).unwrap();
5018
5019        // Populate the ledger cache with three entries:
5020        //   sym_a: Constraint with role=stale-api      → SHOULD match
5021        //   sym_b: Decision with role=audit-pending    → SHOULD match
5022        //   sym_c: Hazard with role=stale-api          → must NOT match (wrong kind)
5023        //   sym_d: Constraint with role=fast-test      → must NOT match (non-penalty role)
5024        //   sym_e: Constraint with no role             → must NOT match (no role)
5025        let make = |sym_id: &str, kind: LedgerKind, role: Option<&str>| {
5026            let mut e = LedgerEntry::new(
5027                sym_id,
5028                kind,
5029                "test entry",
5030                Author {
5031                    kind: AuthorKind::Agent,
5032                    id: "t".into(),
5033                },
5034            );
5035            e.role = role.map(str::to_string);
5036            e
5037        };
5038        for (sym_id, kind, role) in [
5039            ("sym_a", LedgerKind::Constraint, Some("stale-api")),
5040            ("sym_b", LedgerKind::Decision, Some("audit-pending")),
5041            ("sym_c", LedgerKind::Hazard, Some("stale-api")),
5042            ("sym_d", LedgerKind::Constraint, Some("fast-test")),
5043            ("sym_e", LedgerKind::Constraint, None),
5044        ] {
5045            fts.upsert_ledger_entry(&make(sym_id, kind, role), "main")
5046                .unwrap();
5047        }
5048
5049        let mut ids = fts.symbols_with_constraint_penalties("main").unwrap();
5050        ids.sort();
5051        assert_eq!(ids, vec!["sym_a".to_string(), "sym_b".to_string()]);
5052    }
5053
5054    #[test]
5055    fn symbols_with_constraint_penalties_returns_empty_when_cache_empty() {
5056        let dir = tempfile::tempdir().unwrap();
5057        let db_path = dir.path().join("test.db");
5058        let fts = SearchFtsDb::open(&db_path).unwrap();
5059        let ids = fts.symbols_with_constraint_penalties("main").unwrap();
5060        assert!(ids.is_empty());
5061    }
5062}
5063
5064#[cfg(test)]
5065mod plan_j_t007_test_stub_tests {
5066    //! Plan J t-007: `propose_test_stub` returns the language-aware
5067    //! skeleton an agent should fill in. Covers all 10 indexed
5068    //! adapter languages + the unknown-extension fallback.
5069
5070    use super::propose_test_stub;
5071
5072    fn assert_contains_all(actual: &str, needles: &[&str]) {
5073        for n in needles {
5074            assert!(actual.contains(n), "stub missing `{n}` — got:\n{actual}");
5075        }
5076    }
5077
5078    #[test]
5079    fn python_stub_uses_snake_case_def_and_raises_until_filled() {
5080        let s = propose_test_stub("src/billing/calc.py", "discount");
5081        assert_contains_all(
5082            &s,
5083            &[
5084                "def test_discount",
5085                "arrange",
5086                "act",
5087                "assert",
5088                "NotImplementedError",
5089            ],
5090        );
5091    }
5092
5093    #[test]
5094    fn typescript_stub_uses_test_function_and_throws() {
5095        let s = propose_test_stub("src/billing/calc.ts", "discount");
5096        assert_contains_all(&s, &["test('discount'", "throw new Error"]);
5097    }
5098
5099    #[test]
5100    fn rust_stub_uses_test_attribute_and_todo_macro() {
5101        let s = propose_test_stub("src/billing/calc.rs", "discount");
5102        assert_contains_all(&s, &["#[test]", "fn discount", "todo!"]);
5103    }
5104
5105    #[test]
5106    fn go_stub_uses_pascal_case_and_t_fatal() {
5107        let s = propose_test_stub("billing/calc.go", "discount");
5108        assert_contains_all(&s, &["func TestDiscount", "*testing.T", "t.Fatal"]);
5109    }
5110
5111    #[test]
5112    fn java_stub_uses_test_annotation_and_fail() {
5113        let s = propose_test_stub("Billing.java", "discount");
5114        assert_contains_all(&s, &["@Test", "testDiscount", "fail("]);
5115    }
5116
5117    #[test]
5118    fn csharp_stub_uses_pascal_case_and_assert_fail() {
5119        let s = propose_test_stub("Billing.cs", "discount");
5120        assert_contains_all(&s, &["[Test]", "Discount_Should", "Assert.Fail"]);
5121    }
5122
5123    #[test]
5124    fn ruby_stub_uses_def_test_and_flunk() {
5125        let s = propose_test_stub("billing.rb", "discount");
5126        assert_contains_all(&s, &["def test_discount", "flunk"]);
5127    }
5128
5129    #[test]
5130    fn kotlin_stub_uses_test_annotation_and_backticks() {
5131        let s = propose_test_stub("Billing.kt", "discount");
5132        assert_contains_all(&s, &["@Test", "`discount`", "fail("]);
5133    }
5134
5135    #[test]
5136    fn swift_stub_uses_pascal_case_and_xctfail() {
5137        let s = propose_test_stub("Billing.swift", "discount");
5138        assert_contains_all(&s, &["func testDiscount", "XCTFail"]);
5139    }
5140
5141    #[test]
5142    fn unknown_extension_falls_back_to_generic_comment() {
5143        let s = propose_test_stub("data.bin", "discount");
5144        assert!(s.contains("discount"));
5145        assert!(s.starts_with("// "));
5146    }
5147
5148    #[test]
5149    fn qname_prefix_is_stripped_from_test_name() {
5150        // billing.payment.charge → just `charge` for the test body.
5151        let s = propose_test_stub("src/calc.py", "billing.payment.charge");
5152        assert!(s.contains("def test_charge"), "got:\n{s}");
5153        assert!(
5154            !s.contains("billing_payment"),
5155            "qname module path must not leak into test name; got:\n{s}"
5156        );
5157    }
5158
5159    #[test]
5160    fn camel_case_input_becomes_snake_for_python() {
5161        let s = propose_test_stub("src/calc.py", "applyDiscount");
5162        assert!(s.contains("def test_apply_discount"), "got:\n{s}");
5163    }
5164}
5165
5166#[cfg(test)]
5167mod plan_j_t003_classify_file_role_tests {
5168    //! Plan J t-003: locks the unified file-role classifier and
5169    //! exercises the new `view` / `viewmodel` patterns. Previously
5170    //! a file like `AcmeFlowViewModel.swift` fell through to
5171    //! `impl`; M21 field-eval flagged it as mis-bucketed "other".
5172
5173    use super::classify_file_role;
5174
5175    #[test]
5176    fn impl_is_default_when_nothing_matches() {
5177        assert_eq!(classify_file_role("src/lib.rs"), "impl");
5178        assert_eq!(classify_file_role("crates/foo/src/main.py"), "impl");
5179    }
5180
5181    #[test]
5182    fn tests_short_circuit_before_view_check() {
5183        // `ViewTests.swift` must be `test`, not `view` — the
5184        // /test predicate runs first.
5185        assert_eq!(
5186            classify_file_role("App/Tests/AcmeFlowViewTests.swift"),
5187            "test"
5188        );
5189        assert_eq!(classify_file_role("src/lib_test.py"), "test");
5190        assert_eq!(classify_file_role("crates/x/tests/it.rs"), "test");
5191    }
5192
5193    #[test]
5194    fn viewmodel_by_filename_suffix() {
5195        // The key M21 reproducer.
5196        assert_eq!(
5197            classify_file_role("App/Sources/AcmeFlowViewModel.swift"),
5198            "viewmodel"
5199        );
5200        assert_eq!(
5201            classify_file_role("web/src/PlaybackViewModel.ts"),
5202            "viewmodel"
5203        );
5204    }
5205
5206    #[test]
5207    fn viewmodel_by_path_pattern() {
5208        assert_eq!(
5209            classify_file_role("App/Sources/viewmodels/AcmeFlow.swift"),
5210            "viewmodel"
5211        );
5212        assert_eq!(classify_file_role("web/viewmodel/foo.ts"), "viewmodel");
5213    }
5214
5215    #[test]
5216    fn view_by_filename_suffix() {
5217        assert_eq!(classify_file_role("App/Sources/AcmeFlowView.swift"), "view");
5218        assert_eq!(classify_file_role("web/src/TrackView.tsx"), "view");
5219    }
5220
5221    #[test]
5222    fn view_by_path_pattern() {
5223        assert_eq!(
5224            classify_file_role("App/Sources/views/AcmeFlow.swift"),
5225            "view"
5226        );
5227        assert_eq!(classify_file_role("web/view/index.ts"), "view");
5228    }
5229
5230    #[test]
5231    fn view_by_extension() {
5232        assert_eq!(classify_file_role("web/src/App.vue"), "view");
5233        assert_eq!(classify_file_role("web/src/App.svelte"), "view");
5234    }
5235
5236    #[test]
5237    fn viewmodel_short_circuits_before_view() {
5238        // ViewModel-suffixed files must NOT be reported as `view`.
5239        // The viewmodel branch runs before the view branch in the
5240        // classifier; without that ordering, `*ViewModel.*` would
5241        // hit `stem_ends_with(view)` first and mis-classify.
5242        let r = classify_file_role("App/Sources/AcmeFlowViewModel.swift");
5243        assert_eq!(r, "viewmodel", "ViewModel must NOT fall to `view`; got {r}");
5244    }
5245
5246    #[test]
5247    fn preview_does_not_match_view_in_middle_of_segment() {
5248        // `previewmodel/foo.rs` would naïvely match `viewmodel`
5249        // in a substring scan — stem_ends_with guards against
5250        // that for the suffix patterns. The path-segment match
5251        // for `/viewmodels/` is explicit. Verify we don't
5252        // misclassify a file whose stem coincidentally contains
5253        // `view` mid-word but doesn't end in it.
5254        assert_eq!(
5255            classify_file_role("src/PreviewService.swift"),
5256            "impl",
5257            "PreviewService.swift must NOT be a view"
5258        );
5259    }
5260
5261    #[test]
5262    fn existing_roles_still_classify_correctly() {
5263        // Regression guard against the CLI-side helper that this
5264        // function lifted from.
5265        assert_eq!(
5266            classify_file_role("examples/sample-py-repo/foo.py"),
5267            "example"
5268        );
5269        assert_eq!(classify_file_role("fixtures/seed.json"), "fixture");
5270        assert_eq!(classify_file_role("scripts/release.sh"), "script");
5271        assert_eq!(classify_file_role("generated/proto.pb.go"), "generated");
5272        assert_eq!(classify_file_role("docs/architecture.md"), "reference");
5273    }
5274}